1mod 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
48pub 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: ¶ms.cmd,
173 workdir: ¶ms.workdir,
174 login: params.login,
175 shell_path: ¶ms.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 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 async fn detach_command_process(
242 &self,
243 params: &StartCommandParams,
244 context: &lash_core::ToolContext<'_>,
245 ) -> ToolResult {
246 let launch = match self.runtime.spawn_detached(
249 ¶ms.cmd,
250 ¶ms.workdir,
251 params.login,
252 ¶ms.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 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 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 ¶ms.cmd,
377 ¶ms.workdir,
378 params.login,
379 ¶ms.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
540pub 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(¶ms, 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(¶ms, 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#[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");