Skip to main content

yapi_core/tools/
bash.rs

1//! `bash`: run a command, stream its combined output, keep the tail.
2
3use std::path::{Path, PathBuf};
4use std::process::Stdio;
5use std::time::{Duration, Instant};
6
7use futures_util::future::BoxFuture;
8use serde_json::{Value, json};
9use tokio::io::AsyncReadExt;
10use tokio_util::sync::CancellationToken;
11use yapi_agent::{Tool, UpdateSink};
12use yapi_types::event::ToolResult;
13use yapi_types::message::ToolDeclaration;
14use yapi_types::sync::lock;
15
16use super::truncate::{
17    DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, TruncatedBy, Truncation, format_size, tail_bytes,
18    truncate_tail,
19};
20use super::{ToolEnv, declaration, sleep_until_opt, text_result};
21
22const MAX_TIMEOUT_MS: f64 = 2_147_483_647.0;
23const STRUCTURED_OUTPUT_MAX_BYTES: usize = 1024 * 1024;
24const UPDATE_THROTTLE: Duration = Duration::from_millis(100);
25const EXIT_STDIO_GRACE: Duration = Duration::from_millis(100);
26
27/// The `bash` tool.
28pub struct Bash {
29    env: ToolEnv,
30    declaration: ToolDeclaration,
31    output_schema: Value,
32}
33
34impl Bash {
35    /// A `bash` tool for `env`.
36    pub fn new(env: ToolEnv) -> Bash {
37        Bash {
38            env,
39            declaration: declaration(
40                "bash",
41                format!(
42                    "Execute a bash command in the current working directory. Returns stdout and stderr. Output is truncated to last {DEFAULT_MAX_LINES} lines or {}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.",
43                    DEFAULT_MAX_BYTES / 1024
44                ),
45                json!({"type":"object","required":["command"],"properties":{
46                    "command":{"type":"string","description":"Shell command to execute"},
47                    "timeout":{"type":"number","description":"Timeout in seconds (optional, no default timeout)"}}}),
48            ),
49            // pi's `bashOutputSchema`: what scripts receive, also for non-zero exits.
50            output_schema: json!({"type":"object","required":["output","truncated","exit_code","wall_time_seconds"],"properties":{
51                "output":{"type":"string","description":"Combined stdout and stderr, possibly truncated"},
52                "truncated":{"type":"boolean"},
53                "full_output_path":{"type":"string","description":"Full output, when truncated"},
54                "exit_code":{"type":"number"},
55                "wall_time_seconds":{"type":"number"}}}),
56        }
57    }
58}
59
60/// The shell pi uses: `/bin/bash`, else `bash` on `PATH`, else `sh`.
61pub fn shell() -> (String, Vec<String>) {
62    if std::path::Path::new("/bin/bash").exists() {
63        return ("/bin/bash".into(), vec!["-c".into()]);
64    }
65    if let Some(paths) = std::env::var_os("PATH") {
66        for dir in std::env::split_paths(&paths) {
67            let candidate = dir.join("bash");
68            if candidate.is_file() {
69                return (candidate.to_string_lossy().into_owned(), vec!["-c".into()]);
70            }
71        }
72    }
73    ("sh".into(), vec!["-c".into()])
74}
75
76/// The shell for commands: the `shellPath` setting when set and not empty,
77/// with `~` and `file://` expanded as pi's `getShellPath` does, else [`shell`].
78pub fn shell_with(custom: Option<&str>) -> Result<(String, Vec<String>), String> {
79    match custom
80        .filter(|path| !path.is_empty())
81        .map(super::path::expand)
82    {
83        Some(path) if std::path::Path::new(&path).exists() => Ok((path, vec!["-c".into()])),
84        Some(path) => Err(format!("Custom shell path not found: {path}")),
85        None => Ok(shell()),
86    }
87}
88
89/// `command` after the `shellCommandPrefix` setting's line, when it is set
90/// and not empty.
91pub fn with_prefix(prefix: Option<&str>, command: &str) -> String {
92    match prefix {
93        Some(prefix) if !prefix.is_empty() => format!("{prefix}\n{command}"),
94        _ => command.to_owned(),
95    }
96}
97
98/// A shell process for `command` in `cwd`, in its own process group, with
99/// piped output, the agent's `bin_dir` leading `PATH` and pi's session
100/// variables removed.
101pub(crate) fn shell_command(
102    (shell, shell_args): (String, Vec<String>),
103    command: &str,
104    cwd: &std::path::Path,
105    bin_dir: &std::path::Path,
106) -> tokio::process::Command {
107    let mut process = tokio::process::Command::new(shell);
108    process
109        .envs(crate::config::child_env())
110        .args(shell_args)
111        .arg(command)
112        .current_dir(cwd)
113        .stdin(Stdio::null())
114        .stdout(Stdio::piped())
115        .stderr(Stdio::piped())
116        .kill_on_drop(false);
117    #[cfg(unix)]
118    process.process_group(0);
119    // pi's getShellEnv: the agent's bin directory leads PATH.
120    let path = std::env::var_os("PATH").unwrap_or_default();
121    let mut entries: Vec<std::path::PathBuf> = std::env::split_paths(&path).collect();
122    if !entries.iter().any(|entry| entry == bin_dir) {
123        entries.insert(0, bin_dir.to_path_buf());
124        if let Ok(joined) =
125            std::env::join_paths(entries.iter().filter(|p| !p.as_os_str().is_empty()))
126        {
127            process.env("PATH", joined);
128        }
129    }
130    for name in [
131        "PI_SESSION_ID",
132        "PI_SESSION_FILE",
133        "PI_PROVIDER",
134        "PI_MODEL",
135        "PI_REASONING_LEVEL",
136    ] {
137        process.env_remove(name);
138    }
139    process
140}
141
142/// Collects output: a rolling tail in memory and, past the limits, the full
143/// output in a temp file.
144struct Accumulator {
145    raw: Vec<u8>,
146    pending: Vec<u8>,
147    tail: String,
148    tail_at_line_start: bool,
149    total_bytes: usize,
150    completed_lines: usize,
151    open_line: bool,
152    current_line_bytes: usize,
153    file: Option<(PathBuf, std::fs::File)>,
154}
155
156impl Accumulator {
157    fn new() -> Accumulator {
158        Accumulator {
159            raw: Vec::new(),
160            pending: Vec::new(),
161            tail: String::new(),
162            tail_at_line_start: true,
163            total_bytes: 0,
164            completed_lines: 0,
165            open_line: false,
166            current_line_bytes: 0,
167            file: None,
168        }
169    }
170
171    fn total_lines(&self) -> usize {
172        self.completed_lines + usize::from(self.open_line)
173    }
174
175    fn over_limits(&self) -> bool {
176        self.total_bytes > DEFAULT_MAX_BYTES || self.total_lines() > DEFAULT_MAX_LINES
177    }
178
179    fn append(&mut self, data: &[u8]) {
180        use std::io::Write;
181        let decoded = yapi_types::js::decode_utf8_stream(&mut self.pending, data);
182        self.append_text(&decoded);
183        if self.file.is_some() || self.over_limits() {
184            self.spill();
185            if let Some((_, file)) = &mut self.file {
186                let _ = file.write_all(data);
187            }
188        } else {
189            self.raw.extend_from_slice(data);
190        }
191    }
192
193    fn finish(&mut self) {
194        if !self.pending.is_empty() {
195            let rest = String::from_utf8_lossy(&std::mem::take(&mut self.pending)).into_owned();
196            self.append_text(&rest);
197        }
198        if self.over_limits() {
199            self.spill();
200        }
201    }
202
203    fn append_text(&mut self, text: &str) {
204        if text.is_empty() {
205            return;
206        }
207        self.total_bytes += text.len();
208        self.tail.push_str(text);
209        let rolling = DEFAULT_MAX_BYTES * 2;
210        if self.tail.len() > rolling * 2 {
211            let kept = tail_bytes(&self.tail, rolling);
212            let start = self.tail.len() - kept.len();
213            self.tail_at_line_start = start == 0 || self.tail.as_bytes()[start - 1] == b'\n';
214            self.tail = kept.to_owned();
215        }
216        match text.rfind('\n') {
217            None => {
218                self.current_line_bytes += text.len();
219                self.open_line = true;
220            }
221            Some(last) => {
222                self.completed_lines += text.matches('\n').count();
223                self.current_line_bytes = text.len() - last - 1;
224                self.open_line = last + 1 < text.len();
225            }
226        }
227    }
228
229    fn spill(&mut self) {
230        use std::io::Write;
231        if self.file.is_some() {
232            return;
233        }
234        if let Some((path, mut file)) = create_log() {
235            let _ = file.write_all(&std::mem::take(&mut self.raw));
236            self.file = Some((path, file));
237        }
238    }
239
240    fn snapshot(&mut self, persist: bool) -> (Truncation, Option<PathBuf>) {
241        let text = if self.tail_at_line_start {
242            self.tail.as_str()
243        } else {
244            self.tail
245                .find('\n')
246                .map_or(self.tail.as_str(), |index| &self.tail[index + 1..])
247        };
248        let mut truncation = truncate_tail(text, DEFAULT_MAX_LINES, DEFAULT_MAX_BYTES);
249        let truncated = self.over_limits();
250        truncation.truncated = truncated;
251        truncation.truncated_by = if truncated {
252            truncation
253                .truncated_by
254                .or(Some(if self.total_bytes > DEFAULT_MAX_BYTES {
255                    TruncatedBy::Bytes
256                } else {
257                    TruncatedBy::Lines
258                }))
259        } else {
260            None
261        };
262        truncation.total_lines = self.total_lines();
263        truncation.total_bytes = self.total_bytes;
264        if persist && truncated {
265            self.spill();
266        }
267        (truncation, self.file.as_ref().map(|(path, _)| path.clone()))
268    }
269
270    fn full_output(&self) -> (String, bool) {
271        let Some((path, _)) = &self.file else {
272            return (String::from_utf8_lossy(&self.raw).into_owned(), false);
273        };
274        Self::read_log(path).unwrap_or_default()
275    }
276
277    /// The log at `path`, or its first and last bytes when longer than
278    /// [`STRUCTURED_OUTPUT_MAX_BYTES`]. Only those bytes are read.
279    fn read_log(path: &Path) -> std::io::Result<(String, bool)> {
280        use std::io::{Read, Seek, SeekFrom};
281        let mut file = std::fs::File::open(path)?;
282        let len = usize::try_from(file.metadata()?.len()).unwrap_or(usize::MAX);
283        if len <= STRUCTURED_OUTPUT_MAX_BYTES {
284            let mut bytes = Vec::with_capacity(len);
285            file.read_to_end(&mut bytes)?;
286            return Ok((String::from_utf8_lossy(&bytes).into_owned(), false));
287        }
288        let head_len = STRUCTURED_OUTPUT_MAX_BYTES / 2;
289        let tail_len = STRUCTURED_OUTPUT_MAX_BYTES - head_len;
290        // One byte past the head shows whether the head ends inside a character.
291        let mut head = vec![0; head_len + 1];
292        file.read_exact(&mut head)?;
293        let mut tail = vec![0; tail_len];
294        file.seek(SeekFrom::End(-(tail_len as i64)))?;
295        file.read_exact(&mut tail)?;
296        let mut head_end = head_len;
297        while head_end > 0 && (head[head_end] & 0xc0) == 0x80 {
298            head_end -= 1;
299        }
300        let mut tail_start = 0;
301        while tail_start < tail.len() && (tail[tail_start] & 0xc0) == 0x80 {
302            tail_start += 1;
303        }
304        let omitted = len - head_len - tail_len;
305        Ok((
306            format!(
307                "{}\n\n[... {omitted} bytes omitted ...]\n\n{}",
308                String::from_utf8_lossy(&head[..head_end]),
309                String::from_utf8_lossy(&tail[tail_start..])
310            ),
311            true,
312        ))
313    }
314}
315
316fn format_output(
317    truncation: &Truncation,
318    path: &Option<PathBuf>,
319    last_line_bytes: usize,
320    empty: &str,
321) -> (String, Option<Value>) {
322    let mut text = if truncation.content.is_empty() {
323        empty.to_owned()
324    } else {
325        truncation.content.clone()
326    };
327    if !truncation.truncated {
328        return (text, None);
329    }
330    let path_text = path
331        .as_ref()
332        .map(|path| path.display().to_string())
333        .unwrap_or_default();
334    let start = truncation.total_lines - truncation.output_lines + 1;
335    let end = truncation.total_lines;
336    if truncation.last_line_partial {
337        text += &format!(
338            "\n\n[Showing last {} of line {end} (line is {}). Full output: {path_text}]",
339            format_size(truncation.output_bytes),
340            format_size(last_line_bytes)
341        );
342    } else if truncation.truncated_by == Some(TruncatedBy::Lines) {
343        text += &format!(
344            "\n\n[Showing lines {start}-{end} of {}. Full output: {path_text}]",
345            truncation.total_lines
346        );
347    } else {
348        text += &format!(
349            "\n\n[Showing lines {start}-{end} of {} ({} limit). Full output: {path_text}]",
350            truncation.total_lines,
351            format_size(DEFAULT_MAX_BYTES)
352        );
353    }
354    let mut details = json!({ "truncation": truncation });
355    if let Some(path) = path {
356        details["fullOutputPath"] = json!(path.display().to_string());
357    }
358    (text, Some(details))
359}
360
361/// A progress update with the output so far.
362fn progress(truncation: &Truncation, path: Option<&Path>) -> ToolResult {
363    let mut details = json!({});
364    if truncation.truncated {
365        details["truncation"] = json!(truncation);
366    }
367    if let Some(path) = path {
368        details["fullOutputPath"] = json!(path.display().to_string());
369    }
370    text_result(truncation.content.clone(), Some(details))
371}
372
373fn with_status(text: &str, status: &str) -> String {
374    if text.is_empty() {
375        status.to_owned()
376    } else {
377        format!("{text}\n\n{status}")
378    }
379}
380
381/// Process groups of running commands: pi's tracked detached children,
382/// killed when yapi is terminated.
383static TRACKED: std::sync::Mutex<Vec<u32>> = std::sync::Mutex::new(Vec::new());
384
385/// Keeps a command's process group tracked until it is dropped.
386pub(crate) struct TrackedChild(Option<u32>);
387
388impl TrackedChild {
389    /// Tracks the process group led by `pid`.
390    pub(crate) fn new(pid: Option<u32>) -> TrackedChild {
391        if let Some(pid) = pid {
392            lock(&TRACKED).push(pid);
393        }
394        TrackedChild(pid)
395    }
396}
397
398impl Drop for TrackedChild {
399    fn drop(&mut self) {
400        if let Some(pid) = self.0 {
401            lock(&TRACKED).retain(|tracked| *tracked != pid);
402        }
403    }
404}
405
406/// Kills every running command's process tree, as pi's
407/// `killTrackedDetachedChildren` does before exiting on a signal.
408pub fn kill_tracked_children() {
409    let tracked = std::mem::take(&mut *lock(&TRACKED));
410    for pid in tracked {
411        kill_tree(Some(pid));
412    }
413}
414
415/// Sends `signal` to the process group of `pid`, or to `pid` alone.
416#[cfg(unix)]
417pub(crate) fn signal_tree(pid: Option<u32>, signal: rustix::process::Signal) {
418    use rustix::process::{Pid, kill_process, kill_process_group};
419    let Some(pid) = pid.and_then(|pid| Pid::from_raw(pid as i32)) else {
420        return;
421    };
422    if kill_process_group(pid, signal).is_err() {
423        let _ = kill_process(pid, signal);
424    }
425}
426
427#[cfg(unix)]
428pub(crate) fn kill_tree(pid: Option<u32>) {
429    signal_tree(pid, rustix::process::Signal::KILL);
430}
431
432#[cfg(not(unix))]
433pub(crate) fn kill_tree(_pid: Option<u32>) {}
434
435/// A child's stdout and stderr, read until both close.
436pub(crate) struct Pipes {
437    stdout: Option<tokio::process::ChildStdout>,
438    stderr: Option<tokio::process::ChildStderr>,
439    buffers: [Vec<u8>; 2],
440}
441
442impl Pipes {
443    /// Takes `child`'s piped stdout and stderr.
444    pub(crate) fn take(child: &mut tokio::process::Child) -> Pipes {
445        Pipes {
446            stdout: child.stdout.take(),
447            stderr: child.stderr.take(),
448            buffers: [vec![0; 8192], vec![0; 8192]],
449        }
450    }
451
452    /// Whether either pipe is still open.
453    pub(crate) fn is_open(&self) -> bool {
454        self.stdout.is_some() || self.stderr.is_some()
455    }
456
457    /// The next bytes from either pipe, preferring stdout: when both have
458    /// data, stdout's came first more often than not, and a random pick would
459    /// reorder `echo a; echo b >&2`. Empty when a pipe closed or none is open.
460    /// Cancel safe.
461    pub(crate) async fn read(&mut self) -> &[u8] {
462        let [out, err] = &mut self.buffers;
463        let (read, buffer) = tokio::select! {
464            biased;
465            read = async { self.stdout.as_mut().unwrap_or_else(|| unreachable!()).read(out).await }, if self.stdout.is_some() => {
466                if !matches!(read, Ok(1..)) {
467                    self.stdout = None;
468                }
469                (read, &self.buffers[0])
470            }
471            read = async { self.stderr.as_mut().unwrap_or_else(|| unreachable!()).read(err).await }, if self.stderr.is_some() => {
472                if !matches!(read, Ok(1..)) {
473                    self.stderr = None;
474                }
475                (read, &self.buffers[1])
476            }
477            else => return &[],
478        };
479        &buffer[..read.unwrap_or(0)]
480    }
481}
482
483/// What [`run_child`] reports while a command runs.
484pub(crate) enum Event<'a> {
485    /// Bytes from stdout or stderr.
486    Output(&'a [u8]),
487    /// The time the last event asked for has come.
488    Tick,
489}
490
491/// Why a command's process tree was killed.
492pub(crate) enum Stop {
493    Aborted,
494    TimedOut,
495}
496
497/// Runs `child`, tracked, until it exits and its pipes close or go quiet:
498/// descendants may hold them open. Passes each event to `on`, which returns
499/// when it next wants an [`Event::Tick`]. Kills the process tree when
500/// `cancel` fires or `deadline` passes. Output that never pauses holds off
501/// neither, and stays ahead of the exit so none is lost. Returns the shell's
502/// exit status and why its tree was killed.
503pub(crate) async fn run_child(
504    mut child: tokio::process::Child,
505    cancel: &CancellationToken,
506    deadline: Option<Instant>,
507    mut on: impl FnMut(Event<'_>) -> Option<Instant>,
508) -> (Option<std::process::ExitStatus>, Option<Stop>) {
509    let pid = child.id();
510    let _tracked = TrackedChild::new(pid);
511    let mut pipes = Pipes::take(&mut child);
512    let (mut status, mut stop, mut tick) = (None, None, None);
513    let mut exited_at: Option<Instant> = None;
514    loop {
515        if exited_at.is_some() && !pipes.is_open() {
516            break;
517        }
518        let grace = exited_at.map(|at| at + EXIT_STDIO_GRACE);
519        tokio::select! {
520            biased;
521            () = cancel.cancelled(), if stop.is_none() => {
522                stop = Some(Stop::Aborted);
523                kill_tree(pid);
524            }
525            () = sleep_until_opt(deadline), if stop.is_none() => {
526                stop = Some(Stop::TimedOut);
527                kill_tree(pid);
528            }
529            () = sleep_until_opt(tick) => tick = on(Event::Tick),
530            bytes = pipes.read(), if pipes.is_open() => {
531                if !bytes.is_empty() {
532                    tick = on(Event::Output(bytes));
533                    if let Some(at) = &mut exited_at { *at = Instant::now(); }
534                }
535            }
536            result = child.wait(), if exited_at.is_none() => {
537                status = result.ok();
538                exited_at = Some(Instant::now());
539            }
540            () = sleep_until_opt(grace) => break,
541        }
542    }
543    (status, stop)
544}
545
546/// A finished child's exit code, or 128 plus the signal that killed it.
547pub(crate) fn exit_code_of(status: std::process::ExitStatus) -> Option<i32> {
548    #[cfg(unix)]
549    {
550        use std::os::unix::process::ExitStatusExt;
551        status
552            .code()
553            .or_else(|| status.signal().map(|signal| 128 + signal))
554    }
555    #[cfg(not(unix))]
556    {
557        status.code()
558    }
559}
560
561/// A new file for the full output of a command: `yapi-bash-<hex>.log` in the
562/// temporary directory.
563pub(crate) fn create_log() -> Option<(PathBuf, std::fs::File)> {
564    let path = std::env::temp_dir().join(format!("yapi-bash-{}.log", crate::time::random_hex(8)));
565    std::fs::File::create(&path).ok().map(|file| (path, file))
566}
567
568impl Tool for Bash {
569    fn declaration(&self) -> &ToolDeclaration {
570        &self.declaration
571    }
572
573    fn output_schema(&self) -> Option<&Value> {
574        Some(&self.output_schema)
575    }
576
577    fn execute(
578        &self,
579        _call_id: String,
580        args: Value,
581        cancel: CancellationToken,
582        updates: UpdateSink,
583    ) -> BoxFuture<'_, Result<ToolResult, String>> {
584        Box::pin(async move {
585            let command = args["command"].as_str().unwrap_or_default().to_owned();
586            let timeout = match args.get("timeout").and_then(Value::as_f64) {
587                None => None,
588                Some(seconds) if !seconds.is_finite() || seconds <= 0.0 => {
589                    return Err("Invalid timeout: must be a finite number of seconds".into());
590                }
591                Some(seconds) if seconds * 1000.0 > MAX_TIMEOUT_MS => {
592                    return Err(format!(
593                        "Invalid timeout: maximum is {} seconds",
594                        MAX_TIMEOUT_MS / 1000.0
595                    ));
596                }
597                Some(seconds) => Some(seconds),
598            };
599            let cwd = &self.env.cwd;
600            if cancel.is_cancelled() {
601                return Err("Command aborted".into());
602            }
603            let runtime = self.env.runtime();
604            let shell = shell_with(runtime.shell_path.as_deref())?;
605            if !cwd.exists() {
606                return Err(format!(
607                    "Working directory does not exist: {}\nCannot execute bash commands.",
608                    cwd.display()
609                ));
610            }
611
612            let command = with_prefix(runtime.shell_command_prefix.as_deref(), &command);
613            let mut process = shell_command(shell, &command, cwd, &self.env.bin_dir);
614            if let Some(id) = &runtime.session_id {
615                process.env("PI_SESSION_ID", id);
616            }
617            if let Some(file) = &runtime.session_file {
618                process.env("PI_SESSION_FILE", file);
619            }
620            if let Some(model) = &runtime.model {
621                process.env("PI_PROVIDER", &model.provider);
622                process.env("PI_MODEL", &model.id);
623            }
624            if let Some(level) = runtime.thinking_level {
625                process.env("PI_REASONING_LEVEL", level.as_str());
626            }
627            let child = process.spawn().map_err(|err| err.to_string())?;
628
629            updates(ToolResult::default());
630            let started = Instant::now();
631            let mut output = Accumulator::new();
632            let mut last_update = Instant::now() - UPDATE_THROTTLE;
633            let mut dirty = false;
634            let deadline = timeout.map(|seconds| started + Duration::from_secs_f64(seconds));
635            let (status, stop) = run_child(child, &cancel, deadline, |event| match event {
636                Event::Output(bytes) => {
637                    output.append(bytes);
638                    dirty = true;
639                    Some(last_update + UPDATE_THROTTLE)
640                }
641                Event::Tick => {
642                    dirty = false;
643                    last_update = Instant::now();
644                    let (truncation, path) = output.snapshot(true);
645                    updates(progress(&truncation, path.as_deref()));
646                    None
647                }
648            })
649            .await;
650            output.finish();
651            let (truncation, path) = output.snapshot(true);
652            // pi's finishOutput sends output still waiting for the throttle.
653            if dirty {
654                updates(progress(&truncation, path.as_deref()));
655            }
656            match stop {
657                Some(Stop::Aborted) => {
658                    let (text, _) =
659                        format_output(&truncation, &path, output.current_line_bytes, "");
660                    Err(with_status(&text, "Command aborted"))
661                }
662                Some(Stop::TimedOut) => {
663                    let (text, _) =
664                        format_output(&truncation, &path, output.current_line_bytes, "");
665                    let seconds = yapi_types::json::to_string(&timeout.unwrap_or_default())
666                        .unwrap_or_default();
667                    Err(with_status(
668                        &text,
669                        &format!("Command timed out after {seconds} seconds"),
670                    ))
671                }
672                None => {
673                    let (text, details) =
674                        format_output(&truncation, &path, output.current_line_bytes, "(no output)");
675                    let Some(code) = status.and_then(exit_code_of) else {
676                        return Err(with_status(
677                            &text,
678                            "Command terminated without an exit code",
679                        ));
680                    };
681                    let wall = (started.elapsed().as_secs_f64() * 10.0).round() / 10.0;
682                    let (full, full_truncated) = output.full_output();
683                    let mut structured = json!({"output": full, "truncated": full_truncated});
684                    if full_truncated && let Some(path) = &path {
685                        structured["full_output_path"] = json!(path.display().to_string());
686                    }
687                    structured["exit_code"] = json!(code);
688                    structured["wall_time_seconds"] = json!(wall);
689                    let mut result = text_result(
690                        if code == 0 {
691                            text
692                        } else {
693                            with_status(&text, &format!("Command exited with code {code}"))
694                        },
695                        details,
696                    );
697                    result.structured_content = Some(structured);
698                    if code != 0 {
699                        result.is_error = Some(true);
700                    }
701                    Ok(result)
702                }
703            }
704        })
705    }
706}