diff options
| author | Adam Malczewski <[email protected]> | 2026-05-23 04:18:54 +0900 |
|---|---|---|
| committer | Adam Malczewski <[email protected]> | 2026-05-23 04:18:54 +0900 |
| commit | 80ce5960c479fe35ab72c822e3b67799d7e1491e (patch) | |
| tree | 2b58bb1cfbc458a70ad021adc1ce0ae6ed810d84 /packages/core/src/tools/run-shell.ts | |
| parent | c47346cc6237044ecb60ff22c4011d89744af581 (diff) | |
| download | dispatch-80ce5960c479fe35ab72c822e3b67799d7e1491e.tar.gz dispatch-80ce5960c479fe35ab72c822e3b67799d7e1491e.zip | |
feat: web_search + youtube_transcribe tools, shell interrupt backgrounding, fixes
- Add web_search tool (Firecrawl POST to /v1/search with query, limit, lang, country, scrapeOptions)
- Add youtube_transcribe tool (GET to transcriber service, handles completed/queued/failed statuses)
- Both tools registered for parent agents (always) and child agents (permission-gated)
- Added to summon enum, TOOL_DESCRIPTIONS, and core exports
- Shell interrupt: run_shell now races against user queue interrupt
- When interrupted, command continues in background with run_shell_<uuid> job ID
- BackgroundShellStore holds running processes, auto-cleans 10min after completion
- retrieve tool extended to handle both agent IDs and shell job IDs
- Tool error detection: results starting with 'Error:' now marked isError in UI
- Fix TS error: cast unavailMatch[1] regex capture group to string
- Docker: network_mode host for Tailscale/LAN access to external services
- Bun.serve idleTimeout set to 60s (was default 10s)
- KeyUsage: clearer message when OpenCode usage data unavailable
- Firecrawl: only send scrapeOptions when scrape=true (avoid 400 on instances without scrape support)
Diffstat (limited to 'packages/core/src/tools/run-shell.ts')
| -rw-r--r-- | packages/core/src/tools/run-shell.ts | 96 |
1 files changed, 88 insertions, 8 deletions
diff --git a/packages/core/src/tools/run-shell.ts b/packages/core/src/tools/run-shell.ts index 608c91d..6671c31 100644 --- a/packages/core/src/tools/run-shell.ts +++ b/packages/core/src/tools/run-shell.ts @@ -1,14 +1,56 @@ import { spawn } from "node:child_process"; +import { randomUUID } from "node:crypto"; import { z } from "zod"; import type { ToolDefinition, ToolExecuteContext } from "../types/index.js"; const DEFAULT_TIMEOUT = 2 * 60 * 1000; // 2 minutes -export function createRunShellTool(workingDirectory: string): ToolDefinition { +export interface BackgroundShellJob { + command: string; + stdout: string; + stderr: string; + /** Resolves when the process exits */ + completion: Promise<{ stdout: string; stderr: string; exitCode: number; error?: string }>; +} + +/** Shared store for shell commands that were backgrounded due to user interrupt */ +export class BackgroundShellStore { + private jobs = new Map<string, BackgroundShellJob>(); + + register(job: BackgroundShellJob): string { + const id = `run_shell_${randomUUID()}`; + this.jobs.set(id, job); + // Auto-cleanup after completion + 10 minutes + job.completion.finally(() => { + setTimeout(() => this.jobs.delete(id), 10 * 60 * 1000); + }); + return id; + } + + async getResult( + id: string, + ): Promise<{ status: "done"; result: string } | { status: "error"; error: string }> { + const job = this.jobs.get(id); + if (!job) { + return { status: "error", error: `No background shell job found with id '${id}'` }; + } + const result = await job.completion; + return { status: "done", result: JSON.stringify(result) }; + } + + has(id: string): boolean { + return this.jobs.has(id); + } +} + +export function createRunShellTool( + workingDirectory: string, + shellStore?: BackgroundShellStore, +): ToolDefinition { return { name: "run_shell", description: - "Execute a shell command in the working directory. Returns stdout, stderr, and exit code. Use for running tests, builds, git operations, package management, and other development tasks.", + "Execute a shell command in the working directory. Returns stdout, stderr, and exit code. Use for running tests, builds, git operations, package management, and other development tasks. If the user interrupts while a command is running, the command continues in the background and you receive a job ID. Use the retrieve tool with that ID to get the result later.", parameters: z.object({ command: z.string().describe("The shell command to execute"), timeout: z.number().optional().describe("Timeout in milliseconds (default 2 minutes)"), @@ -21,11 +63,6 @@ export function createRunShellTool(workingDirectory: string): ToolDefinition { const timeout = (args.timeout as number | undefined) ?? DEFAULT_TIMEOUT; const [shell, shellArgs] = getShell(); - // NOTE (MVP limitation): `spawn` timeout sends SIGTERM only to the shell - // process itself, not to any child processes it may have spawned. If the - // command forks sub-processes they will continue running after timeout. - // A full fix would require spawning with `detached: true` and killing the - // entire process group (process.kill(-child.pid, "SIGTERM")). const child = spawn(shell, [...shellArgs, command], { cwd: workingDirectory, env: process.env, @@ -36,7 +73,7 @@ export function createRunShellTool(workingDirectory: string): ToolDefinition { let stdout = ""; let stderr = ""; - const result = await new Promise<{ + const completionPromise = new Promise<{ stdout: string; stderr: string; exitCode: number; @@ -62,6 +99,49 @@ export function createRunShellTool(workingDirectory: string): ToolDefinition { }); }); + const queueCallbacks = context?.queueCallbacks; + + if (queueCallbacks && shellStore) { + const { promise: queuePromise, cancel: cancelQueueWait } = + queueCallbacks.waitForQueuedMessage(); + const queueSignal = queuePromise.then(() => "QUEUE_INTERRUPT" as const); + + const raceResult = await Promise.race([completionPromise, queueSignal]); + + if (raceResult === "QUEUE_INTERRUPT") { + // Background the still-running process + const jobId = shellStore.register({ + command, + stdout, + stderr, + completion: completionPromise, + }); + + const queuedMsgs = queueCallbacks.dequeueMessages(); + const userMessages = queuedMsgs.map((m) => m.message).join("\n---\n"); + + return [ + `Command backgrounded — still running.`, + `job_id: ${jobId}`, + `command: ${command}`, + `stdout so far: ${stdout.slice(-500) || "(none)"}`, + `stderr so far: ${stderr.slice(-500) || "(none)"}`, + ``, + `Use the retrieve tool with this job_id to get the final result when ready.`, + ``, + `[USER INTERRUPT]`, + `The user has sent you message(s) while you were working. You MUST address these before continuing with your current task:`, + ``, + userMessages, + ].join("\n"); + } + + // Command finished before interrupt — clean up queue listener + cancelQueueWait(); + return JSON.stringify(raceResult); + } + + const result = await completionPromise; return JSON.stringify(result); }, }; |
