/** * Shell Tool - Command execution * * Implements T-207: shell.run * Emits command.started/completed events; streaming stdout/stderr. * * @module packages/runtime/src/tools/shell */ import { spawn } from 'child_process' import type { ToolDefinition, ToolCall, ToolResultEnvelope, ISOTimeString } from '@aircoding/contracts' export const shell_run: ToolDefinition = { name: 'shell.run', category: 'shell', description: 'Run a shell command', version: 1, output_schema: { type: 'object', properties: {}, required: [] }, input_schema: { type: 'object', properties: { command: { type: 'string', description: 'Command to execute' }, workdir: { type: 'string', description: 'Working directory' }, timeout: { type: 'number', default: 300000, description: 'Timeout in milliseconds' }, env: { type: 'object', description: 'Environment variables to add' } }, required: ['command'] }, permissions: { network: true }, streaming: true } export function createShellExecutor(project_root: string) { return { 'shell.run': async function* (call: ToolCall, context: ToolExecutionContext): AsyncGenerator { const { command, workdir, timeout = 300000, env = {} } = call.arguments as { command: string workdir?: string timeout?: number env?: Record } const cwd = workdir || project_root const timestamp = new Date().toISOString() as ISOTimeString yield { status: 'ok', output: { event: 'command.started', command, cwd }, metadata: { timestamp, streaming: true, call_id: call.call_id, tool_name: 'shell.run' } } const proc = spawn(command, [], { cwd, shell: true, env: { ...process.env, ...env } }) let stdout = '' let stderr = '' let timed_out = false const chunks: ToolResultEnvelope[] = [] proc.stdout.on('data', (data) => { const text = data.toString() stdout += text chunks.push({ status: 'ok', output: { event: 'command.stdout', text }, metadata: { timestamp: new Date().toISOString() as ISOTimeString, streaming: true, call_id: call.call_id, tool_name: 'shell.run' } }) }) proc.stderr.on('data', (data) => { const text = data.toString() stderr += text chunks.push({ status: 'ok', output: { event: 'command.stderr', text }, metadata: { timestamp: new Date().toISOString() as ISOTimeString, streaming: true, call_id: call.call_id, tool_name: 'shell.run' } }) }) const timeout_id = setTimeout(() => { timed_out = true proc.kill('SIGKILL') }, timeout) const exit_code = await new Promise((resolve) => { proc.on('exit', (code) => resolve(code ?? 0)) proc.on('error', () => resolve(1)) }) clearTimeout(timeout_id) if (stdout) { yield { status: 'ok', output: { event: 'command.stdout', text: stdout.slice(-50000) }, metadata: { timestamp: new Date().toISOString() as ISOTimeString, streaming: true, call_id: call.call_id, tool_name: 'shell.run' } } } if (stderr) { yield { status: 'ok', output: { event: 'command.stderr', text: stderr.slice(-10000) }, metadata: { timestamp: new Date().toISOString() as ISOTimeString, streaming: true, call_id: call.call_id, tool_name: 'shell.run' } } } while (chunks.length > 0) { yield chunks.shift()! } if (timed_out) { stderr += `\n[Command timed out after ${timeout}ms]` } yield { status: exit_code === 0 ? 'ok' : 'error', output: { event: 'command.completed', exit_code, stdout: stdout.slice(-50000), stderr: stderr.slice(-10000), timed_out }, error: exit_code === 0 ? undefined : { error_id: call.call_id, kind: 'tool_error', severity: 'error', message: timed_out ? `Command timed out after ${timeout}ms` : `Command exited with code ${exit_code}`, retryability: timed_out ? 'retryable' : 'not_retryable', semantic_signature: 'shell.run' }, metadata: { timestamp: new Date().toISOString() as ISOTimeString, streaming: false, is_final: true, call_id: call.call_id, tool_name: 'shell.run' } } } } } interface ToolExecutionContext { session_id: string project_id: string project_root: string agent_id: string agent_type: string }