Skip to main content

locode_exec/
run.rs

1//! Session assembly + drive (plan §3.2): resolve inputs, build host/pack/
2//! provider through the facade, run the engine, emit per `--output-format`.
3
4use std::io::Read;
5use std::process::ExitCode;
6use std::sync::Arc;
7
8use locode_core::{
9    CacheHint, EngineConfig, EventSink, FnSink, Host, HostConfig, InstructionsConfig, PackContext,
10    PathPolicy, ProviderInit, ProviderRegistry, SamplingArgs, Session, SkillsConfig,
11};
12
13use crate::cli::{Cli, OutputFormat};
14use crate::output;
15
16/// A pre-run failure (config/setup — no report exists yet): stderr + exit 1.
17pub struct PreRunError(pub String);
18
19impl<E: std::fmt::Display> From<E> for PreRunError {
20    fn from(e: E) -> Self {
21        PreRunError(e.to_string())
22    }
23}
24
25/// Build and drive one session; returns the process exit code.
26///
27/// Every terminal state of a *started* run yields a report (the engine's
28/// `run()` is infallible) — only pre-run setup can fail here.
29///
30/// # Errors
31/// [`PreRunError`] on config/setup failures before a run exists (bad `--cwd`,
32/// unknown/misconfigured provider, empty prompt): stderr + exit 1, nothing on
33/// stdout.
34pub async fn run(cli: Cli, providers: &ProviderRegistry) -> Result<ExitCode, PreRunError> {
35    // ---- 0. SIGTERM handler (ADR-0018, Task 21): installed before any
36    //         pre-run work so a pre-run SIGTERM exits 1 cleanly; armed with
37    //         the session's cancel handle once one exists. ----
38    #[cfg(unix)]
39    let cancel_slot = crate::signal::install_sigterm();
40
41    // ---- 1. Prompt: positional, or stdin when absent / `-`. ----
42    let prompt = resolve_prompt(cli.prompt.as_deref())?;
43
44    // ---- 2. Workspace root: canonicalize FIRST, then hand the SAME canonical
45    //         path to the host (jail root), the engine (cwd), and the pack
46    //         (prompt context) — they must agree (STATUS concern #7). ----
47    let cwd = match &cli.cwd {
48        Some(dir) => dir.clone(),
49        None => std::env::current_dir()?,
50    };
51    let cwd = std::fs::canonicalize(&cwd)
52        .map_err(|e| PreRunError(format!("--cwd {}: {e}", cwd.display())))?;
53
54    // `--add-dir` roots: canonicalized here so a typo fails at startup with the
55    // path the user typed, before any model call (ADR-0008 amendment 2026-07-25).
56    let add_dirs = canonicalize_add_dirs(&cli.add_dir)?;
57    let mut host_config = host_config_for(&cwd, &add_dirs);
58    // Unrestricted is the default (ADR-0008 amendment 2026-07-24): the jail and the
59    // approval seam both ship, but the permission *rules* behind them do not, so the
60    // restricted path cannot remember an answer. `--restricted` opts back in.
61    if !cli.restricted {
62        host_config.path_policy = PathPolicy::Unrestricted;
63    }
64    let host = Arc::new(Host::new(host_config)?);
65    output::warning_line(if cli.restricted {
66        output::RESTRICTED_MODE_NOTICE
67    } else {
68        output::UNRESTRICTED_MODE_NOTICE
69    });
70
71    // ---- 2b. Settings (ADR-0024): the durable defaults *under* the flags —
72    //          an explicit flag/env always wins. Layer degradations surface as
73    //          stderr warnings, never hard errors. ----
74    let settings_load = load_settings_reporting(&cwd, cli.settings.as_deref());
75    let settings = settings_load.settings;
76    let effort = resolve_effort_reporting(cli.effort, settings.effort.as_deref());
77    // The resolved `extends` dotfolders travel with the settings — instruction and
78    // (later) skill discovery are consumers of this value, which is what keeps the
79    // load order an invariant (ADR-0025 §6.1).
80    let extends_dirs = settings_load.extends_dirs;
81
82    // ---- 2c. Resume target (`-c`/`-r`, ADR-0024 §2.5): recover the transcript
83    //          and the run identity (pack/wire/model/id) from the rollout header;
84    //          an explicit conflicting flag errors rather than silently swapping
85    //          a session's pack or wire mid-transcript. ----
86    let identity = resolve_identity(&cli, &cwd, &settings)?;
87
88    // ---- 3. Provider: registry-resolved (ADR-0015); unknown names and factory
89    //         failures (missing env, …) fail BEFORE driving the loop. Built first
90    //         so the pack env block can name the model (D9). ----
91    let pack = locode_core::resolve(&identity.harness)?;
92    let registry = pack.build_registry(&host);
93    let session_id = identity.session_id.clone();
94    let built = providers
95        .build(
96            &identity.api_schema,
97            &ProviderInit {
98                session_id: session_id.clone(),
99                model: identity.model_override.clone(),
100            },
101        )
102        .map_err(|e| PreRunError(e.to_string()))?;
103    let (provider, model) = (built.provider, built.model);
104
105    // ---- 3b. Wire requirement: a pack whose tools only round-trip on a specific
106    //          wire (codex's freeform `apply_patch` → openai-responses, D5) rejects
107    //          a mismatched `--api-schema` here, before the loop. ----
108    enforce_wire_requirement(pack, provider.api_schema())?;
109
110    // ---- 4. Pack: preamble (system prompt + env + first-turn reminder). ----
111    let pack_ctx = PackContext {
112        cwd: cwd.clone(),
113        os: std::env::consts::OS.to_string(),
114        shell: std::env::var("SHELL").unwrap_or_else(|_| "/bin/sh".to_string()),
115        date: chrono::Local::now().format("%Y-%m-%d").to_string(),
116        headless: true,
117        is_git_repo: detect_git_repo(&cwd),
118        model: Some(model.clone()),
119        os_version: os_version(),
120        timezone: timezone(),
121        strip_identity: cli.strip_identity,
122    };
123    // A resumed session's preamble IS the recovered history (the pack preamble
124    // is already inside it — it was traced); Init then carries the full
125    // transcript, keeping the stream self-sufficient with zero engine changes.
126    let preamble = match &identity.resumed {
127        Some(resumed) => resumed.history.clone(),
128        None => pack.preamble(&pack_ctx),
129    };
130
131    // Pack-specific user-prompt shaping (grok wraps in <user_query>; claude sends
132    // it verbatim). The pack owns the shape — the exec layer stays harness-agnostic.
133    let user_prompt = pack.shape_user_prompt(&prompt);
134
135    // ---- 5. Engine config + event sink per output mode. ----
136    let config = EngineConfig {
137        session_id,
138        harness: pack.name().to_string(),
139        api_schema: provider.api_schema().to_string(),
140        model,
141        cwd: cwd.clone(),
142        workspace_root: cwd,
143        max_turns: cli.max_turns,
144        sampling_args: SamplingArgs {
145            reasoning_effort: effort.map(Into::into),
146            ..SamplingArgs::default()
147        },
148        cache_hint: CacheHint::Standard,
149        // Opt-in headless streaming (`--stream`) — required for unbounded output
150        // (Anthropic rejects non-streaming past ~10 min). Off by default keeps
151        // `-p` byte-for-byte as it was (ADR-0021).
152        streaming: cli.stream,
153        // Project-instruction loading (`AGENTS.md`, ADR-0023) — on by default,
154        // `--no-project-instructions` opts out. `root_stop_pattern` threads
155        // through from settings (ADR-0024 §1.4; matching activates in S2).
156        instructions: InstructionsConfig {
157            enabled: !cli.no_project_instructions,
158            root_stop_pattern: settings.root_stop_pattern.clone(),
159            extends_dirs: extends_dirs.clone(),
160            extra_roots: add_dirs.clone(),
161            ..InstructionsConfig::default()
162        },
163        // Skills (ADR-0025): the same resolved settings feed discovery, which is what
164        // keeps "settings before discovery" an invariant rather than a convention.
165        skills: SkillsConfig {
166            extends_dirs,
167            extra: settings.skills_extra.clone(),
168            extra_roots: add_dirs.clone(),
169            ..SkillsConfig::enabled()
170        },
171        ..EngineConfig::default()
172    };
173    // ---- 5b. Session trace (ADR-0024 §2): every run appends a rollout under
174    //          `<locode home>/sessions/<encoded-cwd>/`. See `build_trace_writer`.
175    let mut trace = build_trace_writer(&cli, &identity, &config.cwd);
176
177    let sink = make_sink(cli.output_format, trace.take());
178
179    // ---- 6. Drive to a terminal state (infallible) and emit the artifact. ----
180    let mut session = Session::new(provider, registry, preamble, config, sink);
181    #[cfg(unix)]
182    crate::signal::arm(&cancel_slot, session.cancel_handle());
183    let report = session.run_text(user_prompt).await;
184
185    match cli.output_format {
186        OutputFormat::Json => output::write_json_line(&report),
187        OutputFormat::Text => output::write_text(report.final_message.as_deref().unwrap_or("")),
188        OutputFormat::StreamJson => {} // the result event already streamed
189    }
190    Ok(output::exit_code(report.status))
191}
192
193/// A recovered session (`-c`/`-r`): its rollout path + replayable history.
194struct ResumedSession {
195    path: std::path::PathBuf,
196    history: Vec<locode_core::Message>,
197}
198
199/// The run identity: pack/wire/model/session-id, from flags, settings, and (for
200/// `-c`/`-r`) the rollout header — which wins over settings but loses to an
201/// explicit flag only by *erroring* (no silent pack/wire swap mid-transcript).
202struct RunIdentity {
203    harness: String,
204    api_schema: String,
205    model_override: Option<String>,
206    session_id: String,
207    resumed: Option<ResumedSession>,
208}
209
210fn resolve_identity(
211    cli: &Cli,
212    cwd: &std::path::Path,
213    settings: &locode_core::Settings,
214) -> Result<RunIdentity, PreRunError> {
215    // Locate + read the rollout when resuming.
216    let recovered = if cli.continue_session || cli.resume.is_some() {
217        let home = locode_core::locode_home().map_err(PreRunError)?;
218        let root = home.join("sessions");
219        let path = if let Some(id) = &cli.resume {
220            locode_core::find_rollout_by_id(&root, cwd, id)
221                .ok_or_else(|| PreRunError(format!("--resume: no session `{id}` found")))?
222        } else {
223            locode_core::find_latest_rollout(&root, cwd).ok_or_else(|| {
224                PreRunError(format!(
225                    "--continue: no session found for {}",
226                    cwd.display()
227                ))
228            })?
229        };
230        let contents = locode_core::read_rollout(&path).map_err(PreRunError)?;
231        Some((path, contents))
232    } else {
233        None
234    };
235
236    if let Some((path, contents)) = recovered {
237        let meta = contents.meta;
238        // Explicit flags may confirm the recorded identity, never change it.
239        if let Some(flag) = cli.harness
240            && flag.as_str() != meta.harness
241        {
242            return Err(PreRunError(format!(
243                "--harness {} conflicts with the resumed session's harness `{}`",
244                flag.as_str(),
245                meta.harness
246            )));
247        }
248        if let Some(flag) = &cli.api_schema
249            && flag != &meta.api_schema
250        {
251            return Err(PreRunError(format!(
252                "--api-schema {flag} conflicts with the resumed session's wire `{}` \
253                 (a session never crosses wires)",
254                meta.api_schema
255            )));
256        }
257        return Ok(RunIdentity {
258            harness: meta.harness.clone(),
259            api_schema: meta.api_schema.clone(),
260            // The model is deliberately NOT recovered from the header (user
261            // decision 2026-07-24): resume resolves it exactly like a fresh
262            // run — flag > settings > the wire's default. Pack/wire stay
263            // header-bound because they affect transcript validity; the model
264            // doesn't, and yesterday's model should not leak into today.
265            model_override: cli.model.clone().or_else(|| settings.model.clone()),
266            session_id: meta.session_id.clone(),
267            resumed: Some(ResumedSession {
268                path,
269                history: contents.history,
270            }),
271        });
272    }
273
274    Ok(RunIdentity {
275        harness: match cli.harness {
276            Some(harness) => harness.as_str().to_string(),
277            None => settings
278                .harness
279                .clone()
280                .unwrap_or_else(|| "claude".to_string()),
281        },
282        api_schema: cli
283            .api_schema
284            .clone()
285            .or_else(|| settings.api_schema.clone())
286            .unwrap_or_else(|| "anthropic".to_string()),
287        model_override: cli.model.clone().or_else(|| settings.model.clone()),
288        session_id: new_session_id(),
289        resumed: None,
290    })
291}
292
293/// The session-trace writer (ADR-0024 §2): decoration over the event sink, so
294/// tracing needs zero engine changes. A resumed run reopens the same rollout for
295/// appending; a fresh run creates a new one. `None` — no rollout written — when
296/// there is no `~/.locode` home, `--no-session-persistence` is set, or a resume
297/// reopen fails (warned, never fatal).
298fn build_trace_writer(
299    cli: &Cli,
300    identity: &RunIdentity,
301    cwd: &std::path::Path,
302) -> Option<locode_core::TraceWriter> {
303    let root = locode_core::locode_home()
304        .ok()
305        .filter(|_| !cli.no_session_persistence)?
306        .join("sessions");
307    match &identity.resumed {
308        // Reopen the same rollout for appending (id and file continue).
309        Some(resumed) => locode_core::TraceWriter::resume(resumed.path.clone(), root)
310            .map_err(|e| output::warning_line(&format!("trace: {e}; tracing disabled")))
311            .ok(),
312        None => Some(locode_core::TraceWriter::new(
313            root,
314            locode_core::TraceExtras {
315                cli_version: env!("CARGO_PKG_VERSION").to_string(),
316                git: git_meta(cwd),
317                ..Default::default()
318            },
319        )),
320    }
321}
322
323/// The event sink for `output_format`, decorated with the session-trace writer
324/// (ADR-0024 §2): every event feeds the trace first; a trace failure warns once
325/// and disables tracing, never the run. `stream-json` additionally writes the
326/// whole-message JSONL to stdout; `json`/`text` emit nothing per-event.
327fn make_sink(
328    output_format: OutputFormat,
329    mut trace: Option<locode_core::TraceWriter>,
330) -> Box<dyn EventSink> {
331    let stream = matches!(output_format, OutputFormat::StreamJson);
332    Box::new(FnSink(move |event| {
333        if let Some(writer) = trace.as_mut() {
334            writer.on_event(&event);
335            if let Some(e) = writer.take_error() {
336                output::warning_line(&format!("trace: {e}; tracing disabled"));
337            }
338        }
339        if stream && in_whole_message_trace(&event) {
340            output::write_json_line(&event);
341        }
342    }))
343}
344
345/// Reject a `--api-schema` a pack's tools can't round-trip on (codex's freeform
346/// `apply_patch` requires the OpenAI Responses wire, D5). `mock` (keyless CI) is
347/// the universal escape hatch and is always allowed, independent of the pack list.
348fn enforce_wire_requirement(pack: &dyn locode_core::Pack, schema: &str) -> Result<(), PreRunError> {
349    if schema != "mock"
350        && let Some(required) = pack.required_api_schemas()
351        && !required.contains(&schema)
352    {
353        return Err(PreRunError(format!(
354            "harness `{}` requires one of these wires: {}; got `--api-schema {}`",
355            pack.name(),
356            required.join(", "),
357            schema,
358        )));
359    }
360    Ok(())
361}
362
363/// Positional prompt, or stdin when absent / `-` (Codex's convention;
364/// positional XOR stdin per the plan addendum). Empty → usage error.
365fn resolve_prompt(arg: Option<&str>) -> Result<String, PreRunError> {
366    let prompt = match arg {
367        Some("-") | None => {
368            let mut buf = String::new();
369            std::io::stdin().read_to_string(&mut buf)?;
370            buf
371        }
372        Some(text) => text.to_string(),
373    };
374    let prompt = prompt.trim().to_string();
375    if prompt.is_empty() {
376        return Err(PreRunError(
377            "no prompt: pass it as the positional argument or on stdin".to_string(),
378        ));
379    }
380    Ok(prompt)
381}
382
383/// Best-effort git provenance for the trace header (ADR-0024 §2.3) — one
384/// `git rev-parse` + one `remote get-url`, all fields optional; `None` when not
385/// in a repo at all.
386fn git_meta(cwd: &std::path::Path) -> Option<locode_core::GitMeta> {
387    if !detect_git_repo(cwd) {
388        return None;
389    }
390    let run = |args: &[&str]| -> Option<String> {
391        let out = std::process::Command::new("git")
392            .arg("-C")
393            .arg(cwd)
394            .args(args)
395            .output()
396            .ok()?;
397        if !out.status.success() {
398            return None;
399        }
400        let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
401        (!s.is_empty()).then_some(s)
402    };
403    Some(locode_core::GitMeta {
404        root: run(&["rev-parse", "--show-toplevel"]).map(std::path::PathBuf::from),
405        branch: run(&["rev-parse", "--abbrev-ref", "HEAD"]),
406        head: run(&["rev-parse", "HEAD"]),
407        remote: run(&["remote", "get-url", "origin"]),
408    })
409}
410
411/// Whether `cwd` is inside a git repository — walk up looking for a `.git` entry
412/// (a cheap probe for the Claude pack's env `Is a git repository:` line, D9; no
413/// host handle needed in `preamble()`).
414fn detect_git_repo(cwd: &std::path::Path) -> bool {
415    cwd.ancestors().any(|dir| dir.join(".git").exists())
416}
417
418/// `uname -s -r` for the Claude pack's env `OS Version:` line; `None` off Unix or
419/// if the probe fails.
420fn os_version() -> Option<String> {
421    #[cfg(unix)]
422    {
423        let out = std::process::Command::new("uname")
424            .args(["-s", "-r"])
425            .output()
426            .ok()?;
427        if !out.status.success() {
428            return None;
429        }
430        let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
431        (!s.is_empty()).then_some(s)
432    }
433    #[cfg(not(unix))]
434    {
435        None
436    }
437}
438
439/// Best-effort IANA timezone name for the codex pack's `<environment_context>`
440/// (`America/Los_Angeles`), dependency-free: `$TZ` if set, else the `/etc/localtime`
441/// symlink target after `zoneinfo/`. `None` when neither resolves (the pack then
442/// omits the `<timezone>` line — codex's field is optional).
443fn timezone() -> Option<String> {
444    if let Ok(tz) = std::env::var("TZ") {
445        let tz = tz.trim();
446        if !tz.is_empty() {
447            return Some(tz.to_string());
448        }
449    }
450    #[cfg(unix)]
451    {
452        let target = std::fs::read_link("/etc/localtime").ok()?;
453        let s = target.to_string_lossy();
454        s.split_once("zoneinfo/")
455            .map(|(_, name)| name.to_string())
456            .filter(|name| !name.is_empty())
457    }
458    #[cfg(not(unix))]
459    {
460        None
461    }
462}
463
464/// A unique-enough session id for a headless run (no uuid dep in v0).
465fn new_session_id() -> String {
466    let now = std::time::SystemTime::now()
467        .duration_since(std::time::UNIX_EPOCH)
468        .map_or(0, |d| d.as_millis());
469    format!("sess-{now}-{}", std::process::id())
470}
471
472/// Whether an event belongs in the whole-message `stream-json` trace (ADR-0021
473/// Q1): everything except live token deltas, which are a TUI-only concern so the
474/// trace stays replayable/whole-message even under `--stream`.
475///
476/// `MessageDeltaReset` goes out with them: it only annuls deltas, so a consumer
477/// that never saw one has nothing to annul. The retry that caused it is still
478/// visible in the trace as the `Error` note the engine emits alongside.
479fn in_whole_message_trace(event: &locode_core::Event) -> bool {
480    !matches!(
481        event,
482        locode_core::Event::MessageDelta { .. } | locode_core::Event::MessageDeltaReset { .. }
483    )
484}
485
486/// Canonicalize `--add-dir` roots, failing with the path the user typed.
487///
488/// Resolving here (rather than deep in the host) means a typo is a startup error
489/// naming the bad directory, not a silently narrower jail or a confusing
490/// mid-run escape. Each root must already exist — `--add-dir` widens access to a
491/// tree, it does not create one.
492///
493/// # Errors
494/// [`PreRunError`] when a directory does not exist or cannot be canonicalized.
495pub fn canonicalize_add_dirs(
496    dirs: &[std::path::PathBuf],
497) -> Result<Vec<std::path::PathBuf>, PreRunError> {
498    dirs.iter()
499        .map(|dir| {
500            std::fs::canonicalize(dir)
501                .map_err(|e| PreRunError(format!("--add-dir {}: {e}", dir.display())))
502        })
503        .collect()
504}
505
506/// [`locode_core::load_settings`] plus its stderr warnings, so `run` stays
507/// under the line cap.
508fn load_settings_reporting(
509    cwd: &std::path::Path,
510    inline: Option<&str>,
511) -> locode_core::SettingsLoad {
512    let load = locode_core::load_settings(cwd, inline);
513    for warning in &load.warnings {
514        output::warning_line(warning);
515    }
516    load
517}
518
519/// The host config for `cwd`, widened by any `--add-dir` roots.
520fn host_config_for(cwd: &std::path::Path, add_dirs: &[std::path::PathBuf]) -> HostConfig {
521    let mut config = HostConfig::new(cwd);
522    config.extra_roots = add_dirs.to_vec();
523    config
524}
525
526/// [`resolve_effort`] plus the stderr report, so `run` stays under the line cap.
527fn resolve_effort_reporting(
528    flag: Option<crate::EffortArg>,
529    setting: Option<&str>,
530) -> Option<locode_core::Effort> {
531    let mut warnings = Vec::new();
532    let effort = resolve_effort(flag, setting, &mut warnings);
533    for warning in &warnings {
534        output::warning_line(warning);
535    }
536    effort
537}
538
539/// Resolve the effort rung: `--effort` wins, then the `effort` setting, then
540/// `None` (send no `output_config` and take the API's own default).
541///
542/// An unparseable settings value warns and falls through rather than failing —
543/// a stale rung in a config file must not stop a run (ADR-0024 §1.5 tolerance).
544#[must_use]
545pub fn resolve_effort(
546    flag: Option<crate::EffortArg>,
547    setting: Option<&str>,
548    warnings: &mut Vec<String>,
549) -> Option<locode_core::Effort> {
550    if let Some(flag) = flag {
551        return Some(flag.into());
552    }
553    let raw = setting?;
554    if let Some(effort) = locode_core::Effort::parse(raw) {
555        return Some(effort);
556    }
557    warnings.push(format!(
558        "settings: unknown effort {raw:?} — using the API default (expected one of {})",
559        locode_core::Effort::ALL
560            .iter()
561            .map(|e| e.as_str())
562            .collect::<Vec<_>>()
563            .join(", ")
564    ));
565    None
566}
567
568#[cfg(test)]
569mod tests {
570    use super::{enforce_wire_requirement, in_whole_message_trace};
571    use locode_core::{Event, Message, Role};
572
573    #[test]
574    fn codex_rejects_a_non_responses_wire() {
575        let codex = locode_core::resolve("codex").unwrap();
576        // A real, mismatched wire is rejected pre-run with an actionable message.
577        let err = enforce_wire_requirement(codex, "anthropic").expect_err("mismatch");
578        assert!(err.0.contains("codex"), "{}", err.0);
579        assert!(err.0.contains("openai-responses"), "{}", err.0);
580        assert!(err.0.contains("anthropic"), "{}", err.0);
581        // The required wire and the keyless-CI escape hatch both pass.
582        assert!(enforce_wire_requirement(codex, "openai-responses").is_ok());
583        assert!(enforce_wire_requirement(codex, "mock").is_ok());
584    }
585
586    #[test]
587    fn wire_agnostic_packs_accept_any_wire() {
588        let grok = locode_core::resolve("grok").unwrap();
589        assert!(enforce_wire_requirement(grok, "anthropic").is_ok());
590        assert!(enforce_wire_requirement(grok, "openai-responses").is_ok());
591    }
592
593    #[test]
594    fn stream_json_trace_drops_message_deltas_keeps_whole_messages() {
595        // Token deltas are dropped from the trace...
596        assert!(!in_whole_message_trace(&Event::MessageDelta {
597            text: "tok".into()
598        }));
599        // ...but whole messages and every other event stay.
600        assert!(in_whole_message_trace(&Event::Message {
601            message: Message {
602                role: Role::Assistant,
603                content: vec![],
604            },
605        }));
606        assert!(in_whole_message_trace(&Event::Error {
607            message: "e".into()
608        }));
609    }
610}