Skip to main content

lash_tools/shell/
mod.rs

1//! Built-in shell tool catalog (`shell.exec` / `shell.start` /
2//! `shell.write`).
3//!
4//! This module is the *surface* layer: tool definitions, argument parsing,
5//! the [`StandardShell`] executor, prompt contributions, and the plugin
6//! factory. The process-lifecycle machinery lives in [`runtime`] and the
7//! output-buffer plumbing in [`output`].
8
9mod output;
10mod runtime;
11
12use std::path::PathBuf;
13use std::sync::Arc;
14use std::time::{Duration, Instant};
15
16use serde_json::json;
17use tokio_util::sync::CancellationToken;
18
19use lash_core::plugin::{
20    PluginError, PluginFactory, PluginSessionContext, PluginSpec, PluginSpecFactory, SessionPlugin,
21};
22use lash_core::runtime::ProcessEventSemanticsSpec;
23use lash_core::{
24    PreparedToolCall, ProcessEventType, ProcessHandleDescriptor, ProcessInput, ProcessStartRequest,
25    ProgressSender, PromptContribution, SessionScope, SessionToolAccess, ToolCall, ToolDefinition,
26    ToolProvider, ToolResult, ToolScheduling,
27};
28
29use lash_tool_support::{
30    StaticToolExecute, StaticToolProvider, ToolDefinitionLashlangExt, object_schema,
31    parse_optional_bool, parse_optional_usize_arg, require_str,
32};
33
34use crate::shell::output::{PollOutcome, shell_io_result, timed_out_shell_io_result};
35use crate::shell::runtime::{
36    CommonCommandParams, DEFAULT_EXEC_COMMAND_TIMEOUT_MS, ExecCommandParams,
37    PipeExecProcessRequest, ShellRuntime, StartCommandParams, WaitBehavior,
38};
39use std::time::{SystemTime, UNIX_EPOCH};
40
41const SHELL_STDIN_SIGNAL: &str = "stdin";
42const SHELL_STDIN_SIGNAL_EVENT: &str = "signal.stdin";
43
44pub fn shell_prompt_contributions() -> Vec<PromptContribution> {
45    shell_prompt_contributions_for_access(&SessionToolAccess::default())
46}
47
48/// Returns the shell prompt contributions, gating the `shell.write`
49/// reference on whether that tool is actually callable in the current
50/// session.
51pub fn shell_prompt_contributions_for_access(
52    access: &SessionToolAccess,
53) -> Vec<PromptContribution> {
54    let mut command_execution = String::from(
55        "Use `shell.exec` for one-shot commands; it returns only after the process exits and successful results include `status: \"completed\"`, `done: true`, and `exit_code`. Use `shell.start` only for interactive or intentionally long-lived processes; it returns a process handle that is visible to `processes.list` and cancellable with `processes.cancel`.",
56    );
57    if tool_callable_from_authority(access, "write_stdin") {
58        command_execution.push_str(" Send stdin to running shell processes with `shell.write`.");
59    }
60    command_execution.push_str(
61        " For builds, installs, tests, migrations, service setup, and verification commands, use `shell.exec` and wait for completion before concluding.",
62    );
63    vec![
64        PromptContribution::guidance("Command Execution", command_execution),
65        PromptContribution::guidance(
66            "Git Safety",
67            "Avoid destructive git commands unless explicitly requested.",
68        ),
69    ]
70}
71
72fn tool_callable_from_authority(access: &SessionToolAccess, name: &str) -> bool {
73    if access.hides(name) {
74        return false;
75    }
76    access.tools.is_empty() || access.tools.iter().any(|tool| tool.name() == name)
77}
78
79pub struct StandardShell {
80    runtime: ShellRuntime,
81}
82
83impl StandardShell {
84    pub fn new() -> Self {
85        Self {
86            runtime: ShellRuntime::new(),
87        }
88    }
89
90    pub fn with_cwd(mut self, cwd: impl Into<PathBuf>) -> Self {
91        self.runtime = self.runtime.with_cwd(cwd);
92        self
93    }
94
95    fn parse_common_command_params(
96        &self,
97        args: &serde_json::Value,
98    ) -> Result<CommonCommandParams, ToolResult> {
99        let cmd = require_str(args, "cmd")?.to_string();
100        let workdir = self.runtime.resolve_workdir(
101            args.get("workdir")
102                .and_then(|value| value.as_str())
103                .filter(|value| !value.is_empty()),
104        );
105        let shell_path = args
106            .get("shell")
107            .and_then(|value| value.as_str())
108            .filter(|value| !value.is_empty())
109            .unwrap_or(&self.runtime.shell_path)
110            .to_string();
111        let login = parse_optional_bool(args, "login", false)?;
112        let max_output_tokens = parse_optional_usize_arg(args, "max_output_tokens", None, true, 1)?;
113
114        Ok(CommonCommandParams {
115            cmd,
116            workdir,
117            shell_path,
118            login,
119            max_output_tokens,
120        })
121    }
122
123    fn parse_exec_command_params(
124        &self,
125        args: &serde_json::Value,
126    ) -> Result<ExecCommandParams, ToolResult> {
127        let common = self.parse_common_command_params(args)?;
128        let timeout_ms = parse_optional_usize_arg(args, "timeout_ms", None, false, 1)?
129            .map(|value| value as u64)
130            .unwrap_or(DEFAULT_EXEC_COMMAND_TIMEOUT_MS);
131
132        Ok(ExecCommandParams {
133            cmd: common.cmd,
134            workdir: common.workdir,
135            shell_path: common.shell_path,
136            login: common.login,
137            timeout_ms,
138            max_output_tokens: common.max_output_tokens,
139        })
140    }
141
142    fn parse_start_command_params(
143        &self,
144        args: &serde_json::Value,
145    ) -> Result<StartCommandParams, ToolResult> {
146        let common = self.parse_common_command_params(args)?;
147        let detach = parse_optional_bool(args, "detach", false)?;
148
149        Ok(StartCommandParams {
150            cmd: common.cmd,
151            workdir: common.workdir,
152            shell_path: common.shell_path,
153            login: common.login,
154            max_output_tokens: common.max_output_tokens,
155            detach,
156        })
157    }
158
159    async fn exec_command(
160        &self,
161        params: &ExecCommandParams,
162        progress: Option<&ProgressSender>,
163        cancel: Option<CancellationToken>,
164    ) -> ToolResult {
165        let started = Instant::now();
166        let handle_id = self.runtime.allocate_handle_id();
167
168        match self
169            .runtime
170            .exec_pipe_process(PipeExecProcessRequest {
171                id: &handle_id,
172                command: &params.cmd,
173                workdir: &params.workdir,
174                login: params.login,
175                shell_path: &params.shell_path,
176                timeout: Some(Duration::from_millis(params.timeout_ms)),
177                progress,
178                max_output_tokens: params.max_output_tokens,
179                cancel,
180            })
181            .await
182        {
183            Ok(PollOutcome::Running {
184                output,
185                original_token_count,
186                full_output_path,
187                ..
188            }) => timed_out_shell_io_result(
189                &handle_id,
190                output,
191                original_token_count,
192                full_output_path.as_deref(),
193                started.elapsed().as_secs_f64(),
194                params.timeout_ms,
195            ),
196            Ok(PollOutcome::Exited {
197                output,
198                original_token_count,
199                exit_code,
200                full_output_path,
201            }) => shell_io_result(
202                &handle_id,
203                output,
204                Some(exit_code),
205                original_token_count,
206                full_output_path.as_deref(),
207                started.elapsed().as_secs_f64(),
208            ),
209            Ok(PollOutcome::Cancelled) => ToolResult::cancelled("tool call cancelled"),
210            Err(err) => ToolResult::err(json!(err)),
211        }
212    }
213
214    async fn start_command(
215        &self,
216        params: &StartCommandParams,
217        context: &lash_core::ToolContext<'_>,
218        progress: Option<&ProgressSender>,
219        cancel: Option<CancellationToken>,
220    ) -> ToolResult {
221        if params.detach {
222            // A Detached Command is single-phase: it is launched and recorded as
223            // an immediately-terminal audit fact, so it never enters the worker
224            // run phase below.
225            return self.detach_command_process(params, context).await;
226        }
227        if let Some(process_id) = context.async_process_id() {
228            return self
229                .run_start_command_process(process_id, params, context, progress, cancel)
230                .await;
231        }
232        self.register_start_command_process(params, context).await
233    }
234
235    /// Launch a Detached Command (ADR 0019): double-fork/setsid it out of the
236    /// worker's process group, retain no PTY or process-map entry, register the
237    /// launch as an Externally-Owned row, and complete it IMMEDIATELY with the
238    /// launch identity as a `Success` value. The row is a terminal audit fact
239    /// from birth — never a running claim — and lash will not track, signal, or
240    /// stop the process afterward; the host/OS owns it.
241    async fn detach_command_process(
242        &self,
243        params: &StartCommandParams,
244        context: &lash_core::ToolContext<'_>,
245    ) -> ToolResult {
246        // Launch first: never register an audit row for a process that failed to
247        // start.
248        let launch = match self.runtime.spawn_detached(
249            &params.cmd,
250            &params.workdir,
251            params.login,
252            &params.shell_path,
253        ) {
254            Ok(launch) => launch,
255            Err(err) => return ToolResult::err(json!(err)),
256        };
257        let started_at = SystemTime::now()
258            .duration_since(UNIX_EPOCH)
259            .map(|elapsed| elapsed.as_millis() as u64)
260            .unwrap_or(0);
261        let process_id = context
262            .tool_call_id()
263            .filter(|id| !id.is_empty())
264            .map(str::to_string)
265            .unwrap_or_else(|| format!("shell:{}", self.runtime.allocate_handle_id()));
266        let launch_value = json!({
267            "pid": launch.pid,
268            "pgid": launch.pgid,
269            "command": params.cmd.clone(),
270            "started_at": started_at,
271        });
272        let descriptor = ProcessHandleDescriptor::new(Some("shell"), Some(params.cmd.clone()));
273        let request = ProcessStartRequest::new(
274            process_id.clone(),
275            ProcessInput::External {
276                metadata: launch_value.clone(),
277            },
278            // Detached commands are host/OS property from birth: lash never
279            // executes or recovers the row (ADR 0019).
280            lash_core::RecoveryDisposition::ExternallyOwned,
281            lash_core::ProcessOriginator::host(),
282        )
283        .with_grant(Some(lash_core::ProcessStartGrant {
284            session_scope: SessionScope::new("request-descriptor"),
285            descriptor,
286        }));
287        if let Err(err) = context.processes().start(request).await {
288            return ToolResult::err_fmt(err.to_string());
289        }
290        if let Err(err) = context
291            .processes()
292            .complete_external(
293                &process_id,
294                lash_core::ProcessAwaitOutput::Success {
295                    value: launch_value.clone(),
296                    control: None,
297                },
298            )
299            .await
300        {
301            return ToolResult::err_fmt(err.to_string());
302        }
303        let mut record = launch_value.as_object().cloned().unwrap_or_default();
304        record.insert("__handle__".to_string(), json!("process"));
305        record.insert("id".to_string(), json!(process_id));
306        record.insert("process_id".to_string(), json!(process_id));
307        record.insert("status".to_string(), json!("detached"));
308        record.insert("done".to_string(), json!(true));
309        record.insert("running".to_string(), json!(false));
310        ToolResult::ok(serde_json::Value::Object(record))
311    }
312
313    async fn register_start_command_process(
314        &self,
315        params: &StartCommandParams,
316        context: &lash_core::ToolContext<'_>,
317    ) -> ToolResult {
318        let process_id = context
319            .tool_call_id()
320            .filter(|id| !id.is_empty())
321            .map(str::to_string)
322            .unwrap_or_else(|| format!("shell:{}", self.runtime.allocate_handle_id()));
323        let args = start_command_process_args(params);
324        let call = PreparedToolCall::from_parts(
325            process_id.clone(),
326            "tool:start_command",
327            "start_command",
328            args,
329            None,
330            serde_json::Value::Null,
331        );
332        let descriptor = ProcessHandleDescriptor::new(Some("shell"), Some(params.cmd.clone()));
333        let request = ProcessStartRequest::new(
334            process_id.clone(),
335            ProcessInput::ToolCall { call },
336            // A shell.start row spawns an OS process group and its side effects are
337            // not idempotent: recovery must never re-execute a started command, so
338            // its contract binds at first start (ADR 0019).
339            lash_core::RecoveryDisposition::OwnerBound,
340            lash_core::ProcessOriginator::host(),
341        )
342        .with_grant(Some(lash_core::ProcessStartGrant {
343            session_scope: SessionScope::new("request-descriptor"),
344            descriptor,
345        }))
346        .with_extra_event_types([shell_signal_event_type()]);
347        match context.processes().start(request).await {
348            Ok(summary) => {
349                let mut handle = serde_json::to_value(summary).unwrap_or_else(|_| {
350                    lash_core::RuntimeExecutionContext::process_handle_json(&process_id)
351                });
352                if let Some(object) = handle.as_object_mut() {
353                    object.insert("status".to_string(), json!("running"));
354                    object.insert("done".to_string(), json!(false));
355                    object.insert("running".to_string(), json!(true));
356                }
357                ToolResult::ok(handle)
358            }
359            Err(err) => ToolResult::err_fmt(err.to_string()),
360        }
361    }
362
363    async fn run_start_command_process(
364        &self,
365        process_id: &str,
366        params: &StartCommandParams,
367        context: &lash_core::ToolContext<'_>,
368        progress: Option<&ProgressSender>,
369        cancel: Option<CancellationToken>,
370    ) -> ToolResult {
371        let started = Instant::now();
372        let handle_id = process_id.to_string();
373
374        if let Err(err) = self.runtime.spawn_process(
375            handle_id.clone(),
376            &params.cmd,
377            &params.workdir,
378            params.login,
379            &params.shell_path,
380        ) {
381            return ToolResult::err(json!(err));
382        }
383
384        let signal_done = CancellationToken::new();
385        let signal_forwarder =
386            self.spawn_stdin_signal_forwarder(handle_id.clone(), context, signal_done.clone());
387        match self
388            .runtime
389            .wait_until_exit_or_timeout(
390                &handle_id,
391                None,
392                progress,
393                params.max_output_tokens,
394                WaitBehavior { baseline_len: 0 },
395                cancel,
396            )
397            .await
398        {
399            Ok(PollOutcome::Running { .. }) => {
400                signal_done.cancel();
401                let _ = signal_forwarder.await;
402                self.runtime.remove_process(&handle_id);
403                ToolResult::err_fmt("background shell process returned running without a timeout")
404            }
405            Ok(PollOutcome::Exited {
406                output,
407                original_token_count,
408                exit_code,
409                full_output_path,
410            }) => {
411                signal_done.cancel();
412                let _ = signal_forwarder.await;
413                self.runtime.remove_process(&handle_id);
414                shell_io_result(
415                    &handle_id,
416                    output,
417                    Some(exit_code),
418                    original_token_count,
419                    full_output_path.as_deref(),
420                    started.elapsed().as_secs_f64(),
421                )
422            }
423            Ok(PollOutcome::Cancelled) => {
424                signal_done.cancel();
425                let _ = signal_forwarder.await;
426                self.runtime.remove_process(&handle_id);
427                ToolResult::cancelled("tool call cancelled")
428            }
429            Err(err) => {
430                signal_done.cancel();
431                let _ = signal_forwarder.await;
432                self.runtime.remove_process(&handle_id);
433                ToolResult::err(json!(err))
434            }
435        }
436    }
437
438    fn spawn_stdin_signal_forwarder(
439        &self,
440        process_id: String,
441        context: &lash_core::ToolContext<'_>,
442        done: CancellationToken,
443    ) -> tokio::task::JoinHandle<()> {
444        let runtime = self.runtime.clone();
445        let events = context.process_events();
446        tokio::spawn(async move {
447            let mut after_sequence = 0;
448            loop {
449                let event = tokio::select! {
450                    _ = done.cancelled() => break,
451                    event = events.wait_event_after(SHELL_STDIN_SIGNAL_EVENT, after_sequence) => event,
452                };
453                let Ok(event) = event else {
454                    break;
455                };
456                after_sequence = event.sequence;
457                if let Some(chars) = event.payload.get("chars").and_then(|value| value.as_str()) {
458                    let _ = runtime.write_stdin(&process_id, chars).await;
459                }
460                if event
461                    .payload
462                    .get("close_stdin")
463                    .and_then(|value| value.as_bool())
464                    .unwrap_or(false)
465                {
466                    let _ = runtime.close_stdin(&process_id).await;
467                }
468            }
469        })
470    }
471
472    async fn write_stdin_call(
473        &self,
474        args: &serde_json::Value,
475        context: &lash_core::ToolContext<'_>,
476    ) -> ToolResult {
477        let process_id = match parse_process_id(args) {
478            Ok(value) => value,
479            Err(err) => return err,
480        };
481        let chars = args
482            .get("chars")
483            .and_then(|value| value.as_str())
484            .unwrap_or("");
485        let close_stdin = match parse_optional_bool(args, "close_stdin", false) {
486            Ok(value) => value,
487            Err(err) => return err,
488        };
489        match context
490            .processes()
491            .signal(
492                &process_id,
493                SHELL_STDIN_SIGNAL,
494                json!({
495                    "chars": chars,
496                    "close_stdin": close_stdin,
497                }),
498            )
499            .await
500        {
501            Ok(event) => ToolResult::ok(json!({
502                "process_id": process_id,
503                "status": "signalled",
504                "sequence": event.sequence,
505            })),
506            Err(err) => ToolResult::err_fmt(err.to_string()),
507        }
508    }
509}
510
511fn start_command_process_args(params: &StartCommandParams) -> serde_json::Value {
512    let mut args = serde_json::Map::new();
513    args.insert("cmd".to_string(), json!(params.cmd.clone()));
514    args.insert(
515        "workdir".to_string(),
516        json!(params.workdir.to_string_lossy().to_string()),
517    );
518    args.insert("shell".to_string(), json!(params.shell_path.clone()));
519    args.insert("login".to_string(), json!(params.login));
520    if let Some(max_output_tokens) = params.max_output_tokens {
521        args.insert("max_output_tokens".to_string(), json!(max_output_tokens));
522    }
523    serde_json::Value::Object(args)
524}
525
526fn shell_signal_event_type() -> ProcessEventType {
527    ProcessEventType {
528        name: SHELL_STDIN_SIGNAL_EVENT.to_string(),
529        payload_schema: lash_core::LashSchema::any(),
530        semantics: ProcessEventSemanticsSpec::default(),
531    }
532}
533
534impl Default for StandardShell {
535    fn default() -> Self {
536        Self::new()
537    }
538}
539
540/// Build the cached shell tool provider (`shell.exec` / `shell.start`).
541pub fn shell_provider(shell: StandardShell) -> StaticToolProvider<StandardShell> {
542    let definitions = shell.tool_definitions();
543    StaticToolProvider::new(definitions, shell)
544}
545
546#[async_trait::async_trait]
547impl StaticToolExecute for StandardShell {
548    async fn execute(&self, call: ToolCall<'_>) -> ToolResult {
549        let cancellation_token = call.context.cancellation_token().cloned();
550        self.dispatch(
551            call.name,
552            call.args,
553            call.context,
554            call.progress,
555            cancellation_token,
556        )
557        .await
558    }
559}
560
561impl StandardShell {
562    fn tool_definitions(&self) -> Vec<ToolDefinition> {
563        let exec_command_description = "Run a noninteractive one-shot command with stdin closed and stdout/stderr captured, then wait for it to finish. The command is executed exactly as written by the selected shell; the tool does not add strict-mode prefixes or rewrite pipelines. Completed commands always include `status: \"completed\"`, `done: true`, `running: false`, cleaned `output`, and `exit_code`. Nonzero exit codes are returned as ordinary result data; in Lashlang, `await shell.exec(...)?` does not abort just because the process exited nonzero. Inspect `exit_code` yourself when it matters. Commands time out after 600000 ms by default; set `timeout_ms` to override the hard timeout. Timed-out commands are killed and returned as a tool failure with `status: \"timed_out\"`, `timed_out: true`, and no `exit_code`. Use `shell.start` instead for interactive, TTY-dependent, or intentionally long-lived processes. ANSI/control noise is stripped from returned output. Large or truncated output may also include `full_output_path` pointing at the saved raw stream; prefer that over shell-level `head`/`tail` truncation when you need to inspect more.";
564        let start_command_description = "Start an interactive or intentionally long-lived command in a PTY as a durable background process. The command is executed exactly as written by the selected shell. The result is a process handle with `__handle__: \"process\"`, `id`, `process_id`, `status: \"running\"`, `done: false`, and `running: true`; use `processes.list` to see it and `processes.cancel` to stop it. When the process exits, nonzero exit codes are returned as ordinary result data with `exit_code`; in Lashlang, `?` does not abort just because the process exited nonzero. Inspect `exit_code` yourself. Use `shell.exec` for builds, installs, tests, service setup, verification, and other commands that must complete before the next step. Set `detach: true` to launch a fully detached process that the host/OS owns: it runs in its own session, outlives this session and host, and lash will NOT track, signal, or stop it. A detached launch returns immediately with `status: \"detached\"`, `done: true`, `running: false`, and the launch identity `pid`, `pgid`, `command`, and `started_at`; there is no exit code, output, or `processes.cancel` for it — supervision is entirely your/the host's responsibility.";
565        let command_common = |command_description: &str| {
566            json!({
567                "cmd": {
568                    "type": "string",
569                    "description": command_description
570                },
571                "workdir": {
572                    "type": "string",
573                    "description": "Optional working directory to run the command in; defaults to the turn cwd."
574                },
575                "shell": {
576                    "type": "string",
577                    "description": "Shell binary to launch. Defaults to the user's default shell."
578                },
579                "login": {
580                    "type": "boolean",
581                    "default": false,
582                    "description": "Whether to run the shell with -l semantics. Defaults to false to avoid startup prompts and shell init noise."
583                },
584                "max_output_tokens": {
585                    "type": "integer",
586                    "minimum": 1,
587                    "description": "Maximum number of tokens to return. Excess output will be truncated."
588                }
589            })
590        };
591        vec![
592            ToolDefinition::raw(
593                "tool:exec_command",
594                "exec_command",
595                exec_command_description,
596                {
597                    let mut properties = command_common("Shell command to execute.");
598                    properties["timeout_ms"] = json!({
599                        "type": "integer",
600                        "minimum": 1,
601                        "default": DEFAULT_EXEC_COMMAND_TIMEOUT_MS,
602                        "description": "Hard timeout in milliseconds. If reached before the command exits, the process is killed and returned as a tool failure with `status: \"timed_out\"` and `timed_out: true`. Defaults to 600000 ms."
603                    });
604                    object_schema(properties, &["cmd"])
605                },
606                shell_exec_output_schema(),
607            )
608            .with_examples(vec![
609                r#"await shell.exec({ cmd: "cargo test -p lash-protocol-rlm", timeout_ms: 600000 })?"#.into(),
610                r#"probe = await shell.exec({ cmd: "test -f Cargo.lock" })?
611finish probe.exit_code == 0"#.into(),
612            ])
613            .with_lashlang_binding(lash_tool_support::lashlang_binding(
614                ["shell"],
615                "exec",
616                &["shell", "bash"],
617            ))
618            .with_scheduling(ToolScheduling::Serial),
619            ToolDefinition::raw(
620                "tool:start_command",
621                "start_command",
622                start_command_description,
623                {
624                    let mut properties = command_common("Shell command to start.");
625                    properties["detach"] = json!({
626                        "type": "boolean",
627                        "default": false,
628                        "description": "Launch the command fully detached (its own session via setsid) so it outlives this session and host. lash records only an immediately-terminal audit fact and never tracks, signals, or stops it. Defaults to false (a tracked PTY process)."
629                    });
630                    object_schema(properties, &["cmd"])
631                },
632                shell_start_output_schema(),
633            )
634            .with_examples(vec![
635                r#"await shell.start({ cmd: "python -m http.server 8000" })?"#.into(),
636                r#"await shell.start({ cmd: "nohup ./daemon --serve", detach: true })?"#.into(),
637            ])
638            .with_lashlang_binding(lash_tool_support::lashlang_binding(
639                ["shell"],
640                "start",
641                &["long_running_command", "pty"],
642            ))
643            .with_scheduling(ToolScheduling::Serial),
644            ToolDefinition::raw(
645                "tool:write_stdin",
646                "write_stdin",
647                "Send bytes to stdin for a running shell process started by `shell.start`. Use `close_stdin: true` to send EOF. This only acknowledges delivery of the signal; use process lifecycle tools to inspect or cancel the background process.",
648                object_schema(
649                    json!({
650                        "process_id": {
651                            "type": "string",
652                            "description": "Process id returned by `shell.start`."
653                        },
654                        "chars": {
655                            "type": "string",
656                            "default": "",
657                            "description": "Bytes to write to stdin; may be empty when only closing stdin."
658                        },
659                        "close_stdin": {
660                            "type": "boolean",
661                            "default": false,
662                            "description": "Close stdin after writing to send EOF to the process."
663                        }
664                    }),
665                    &["process_id"],
666                ),
667                shell_write_output_schema(),
668            )
669            .with_examples(vec![
670                r#"await shell.write({ process_id: "call-shell-1", chars: "status\n" })?"#.into(),
671                r#"await shell.write({ process_id: "call-shell-1", chars: "", close_stdin: true })?"#.into(),
672            ])
673            .with_lashlang_binding(lash_tool_support::lashlang_binding(
674                ["shell"],
675                "write",
676                &["send_stdin", "poll_command"],
677            ))
678            .with_scheduling(ToolScheduling::Serial),
679        ]
680    }
681
682    async fn dispatch(
683        &self,
684        name: &str,
685        args: &serde_json::Value,
686        context: &lash_core::ToolContext<'_>,
687        progress: Option<&ProgressSender>,
688        cancel: Option<CancellationToken>,
689    ) -> ToolResult {
690        match name {
691            "exec_command" => {
692                let params = match self.parse_exec_command_params(args) {
693                    Ok(params) => params,
694                    Err(err) => return err,
695                };
696                self.exec_command(&params, progress, cancel).await
697            }
698            "start_command" => {
699                let params = match self.parse_start_command_params(args) {
700                    Ok(params) => params,
701                    Err(err) => return err,
702                };
703                self.start_command(&params, context, progress, cancel).await
704            }
705            "write_stdin" => self.write_stdin_call(args, context).await,
706            _ => ToolResult::err_fmt(format_args!("Unknown tool: {name}")),
707        }
708    }
709}
710
711fn shell_exec_output_schema() -> serde_json::Value {
712    json!({
713        "type": "object",
714        "properties": {
715            "output": { "type": "string" },
716            "status": { "type": "string", "enum": ["completed", "timed_out"] },
717            "done": { "type": "boolean" },
718            "running": { "type": "boolean" },
719            "wall_time_seconds": { "type": "number", "minimum": 0 },
720            "exit_code": { "type": "integer" },
721            "timed_out": { "type": "boolean" },
722            "error": { "type": "string" },
723            "original_token_count": { "type": "integer", "minimum": 0 },
724            "full_output_path": { "type": "string" }
725        },
726        "required": ["output", "status", "done", "running", "wall_time_seconds"],
727        "additionalProperties": false
728    })
729}
730
731fn shell_start_output_schema() -> serde_json::Value {
732    json!({
733        "type": "object",
734        "properties": {
735            "__handle__": { "type": "string", "enum": ["process"] },
736            "id": { "type": "string" },
737            "process_id": { "type": "string" },
738            "status": { "type": "string", "enum": ["running", "detached"] },
739            "done": { "type": "boolean" },
740            "running": { "type": "boolean" },
741            "pid": { "type": "integer", "minimum": 0 },
742            "pgid": { "type": "integer", "minimum": 0 },
743            "command": { "type": "string" },
744            "started_at": { "type": "integer", "minimum": 0 }
745        },
746        "required": ["__handle__", "id", "process_id", "status", "done", "running"],
747        "additionalProperties": false
748    })
749}
750
751fn shell_write_output_schema() -> serde_json::Value {
752    json!({
753        "type": "object",
754        "properties": {
755            "process_id": { "type": "string" },
756            "status": { "type": "string", "enum": ["signalled"] },
757            "sequence": { "type": "integer", "minimum": 0 }
758        },
759        "required": ["process_id", "status", "sequence"],
760        "additionalProperties": false
761    })
762}
763
764fn parse_process_id(args: &serde_json::Value) -> Result<String, ToolResult> {
765    require_str(args, "process_id").map(str::to_string)
766}
767
768/// PluginFactory for the built-in shell tool catalog.
769///
770/// Wires `StandardShell` into the active session with the access-gated
771/// `shell.write` mention in the prompt contribution so the model only
772/// sees that bullet when the tool is actually callable.
773#[derive(Default)]
774pub struct StandardShellPluginFactory;
775
776impl StandardShellPluginFactory {
777    pub fn new() -> Self {
778        Self
779    }
780}
781
782impl PluginFactory for StandardShellPluginFactory {
783    fn id(&self) -> &'static str {
784        "shell"
785    }
786
787    fn build(&self, ctx: &PluginSessionContext) -> Result<Arc<dyn SessionPlugin>, PluginError> {
788        let tool_access = ctx.tool_access.clone();
789        let provider = Arc::new(shell_provider(StandardShell::new())) as Arc<dyn ToolProvider>;
790        PluginSpecFactory::new(
791            "shell",
792            Arc::new(move |_ctx| {
793                let provider = Arc::clone(&provider);
794                let tool_access = tool_access.clone();
795                Ok(PluginSpec::new()
796                    .with_tool_provider(provider)
797                    .with_prompt_contributor(Arc::new(move |_ctx| {
798                        let tool_access = tool_access.clone();
799                        Box::pin(
800                            async move { Ok(shell_prompt_contributions_for_access(&tool_access)) },
801                        )
802                    })))
803            }),
804        )
805        .build(ctx)
806    }
807}
808
809include!("tests.rs");