summaryrefslogtreecommitdiffhomepage
path: root/packages/core/src/tools/run-shell.ts
blob: ec2db9c2412383408e95e185f29b82b2c6fdb425 (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
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 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. 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)"),
			background: z
				.boolean()
				.optional()
				.describe(
					"If true, the command starts in the background and a job_id is returned immediately. Use the retrieve tool with the job_id to get the result later.",
				),
		}),
		execute: async (
			args: Record<string, unknown>,
			context?: ToolExecuteContext,
		): Promise<string> => {
			const command = args.command as string;
			const timeout = (args.timeout as number | undefined) ?? DEFAULT_TIMEOUT;
			const background = (args.background as boolean | undefined) ?? false;

			const [shell, shellArgs] = getShell();
			const child = spawn(shell, [...shellArgs, command], {
				cwd: workingDirectory,
				env: process.env,
				timeout,
				stdio: ["ignore", "pipe", "pipe"],
			});

			let stdout = "";
			let stderr = "";

			const completionPromise = new Promise<{
				stdout: string;
				stderr: string;
				exitCode: number;
				error?: string;
			}>((resolve) => {
				child.stdout?.on("data", (data: Buffer) => {
					const chunk = data.toString();
					stdout += chunk;
					context?.onOutput?.(chunk, "stdout");
				});
				child.stderr?.on("data", (data: Buffer) => {
					const chunk = data.toString();
					stderr += chunk;
					context?.onOutput?.(chunk, "stderr");
				});

				child.on("close", (exitCode) => {
					resolve({ stdout, stderr, exitCode: exitCode ?? 1 });
				});

				child.on("error", (err) => {
					resolve({ stdout, stderr, exitCode: 1, error: err.message });
				});
			});

			// If background mode requested, register immediately and return job ID
			if (background && shellStore) {
				const jobId = shellStore.register({
					command,
					stdout,
					stderr,
					completion: completionPromise,
				});
				return [
					`Command started in background.`,
					`job_id: ${jobId}`,
					`command: ${command}`,
					``,
					`Use the retrieve tool with this job_id to get the result when ready.`,
				].join("\n");
			}

			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);
		},
	};
}

function getShell(): [string, string[]] {
	return process.platform === "win32" ? ["powershell", ["-Command"]] : ["bash", ["-c"]];
}