summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src/tools/run-shell.ts
diff options
context:
space:
mode:
authorAdam Malczewski <[email protected]>2026-05-23 04:18:54 +0900
committerAdam Malczewski <[email protected]>2026-05-23 04:18:54 +0900
commit80ce5960c479fe35ab72c822e3b67799d7e1491e (patch)
tree2b58bb1cfbc458a70ad021adc1ce0ae6ed810d84 /packages/core/src/tools/run-shell.ts
parentc47346cc6237044ecb60ff22c4011d89744af581 (diff)
downloaddispatch-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.ts96
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);
},
};