Skip to content

Commit 3693eed

Browse files
committed
feat(tools): add direct Bun script runner
1 parent 432111f commit 3693eed

File tree

4 files changed

+443
-0
lines changed

4 files changed

+443
-0
lines changed
Lines changed: 127 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,127 @@
1+
/**
2+
* bun-runner – registers a Bun script execution tool.
3+
*
4+
* Runs workspace Bun scripts directly (no shell), with optional argv and cwd.
5+
* Stdout is discarded; stderr is captured and returned.
6+
*/
7+
8+
import { Type, type Static } from "@sinclair/typebox";
9+
import type { AgentToolResult, ExtensionAPI, ExtensionFactory } from "@mariozechner/pi-coding-agent";
10+
11+
import { runBunScript } from "../tools/bun-runner.js";
12+
13+
const BunRunSchema = Type.Object({
14+
script: Type.String({ description: "Workspace-relative script file to execute with Bun (for example `runtime/scripts/foo.ts`)." }),
15+
args: Type.Optional(Type.Array(Type.String(), { description: "Arguments passed to the script. No shell parsing is performed." })),
16+
cwd: Type.Optional(Type.String({ description: "Working directory relative to the workspace (defaults to the workspace root)." })),
17+
timeout_sec: Type.Optional(Type.Integer({ description: "Timeout in seconds.", minimum: 1, maximum: 3600 })),
18+
});
19+
20+
type BunRunParams = Static<typeof BunRunSchema>;
21+
22+
const HINT = [
23+
"## Direct Bun scripts",
24+
"Use bun_run to execute a workspace Bun script directly without a shell.",
25+
"Pass script arguments as an array; do not rely on shell features like pipes or redirects.",
26+
"Scripts should write structured output to files themselves; bun_run only captures stderr.",
27+
].join("\n");
28+
29+
function formatArgs(args: string[]): string {
30+
if (args.length === 0) return "(none)";
31+
return args.map((arg) => JSON.stringify(arg)).join(" ");
32+
}
33+
34+
function buildResultText(result: {
35+
scriptDisplayPath: string;
36+
cwdDisplayPath: string;
37+
args: string[];
38+
exitCode: number | null;
39+
stderr: string;
40+
stderrTruncated: boolean;
41+
}): string {
42+
const status = result.exitCode === 0
43+
? `bun_run completed successfully for ${result.scriptDisplayPath}.`
44+
: `bun_run finished with exit code ${result.exitCode ?? "unknown"} for ${result.scriptDisplayPath}.`;
45+
const lines = [
46+
status,
47+
`cwd: ${result.cwdDisplayPath}`,
48+
`args: ${formatArgs(result.args)}`,
49+
"stdout: discarded",
50+
];
51+
52+
if (result.stderr) {
53+
lines.push("stderr:");
54+
lines.push(result.stderrTruncated ? `${result.stderr}\n[stderr truncated]` : result.stderr);
55+
} else {
56+
lines.push("stderr: (empty)");
57+
}
58+
59+
return lines.join("\n");
60+
}
61+
62+
export const bunRunner: ExtensionFactory = (pi: ExtensionAPI) => {
63+
pi.on("before_agent_start", async (event) => ({
64+
systemPrompt: `${event.systemPrompt}\n\n${HINT}`,
65+
}));
66+
67+
pi.registerTool({
68+
name: "bun_run",
69+
label: "bun_run",
70+
description: "Run a workspace Bun script directly with optional arguments and cwd. No shell parsing, piping, or redirects; stdout is discarded and only stderr is captured.",
71+
promptSnippet: "bun_run: execute a workspace Bun script directly with optional arguments and cwd, capturing stderr only.",
72+
parameters: BunRunSchema,
73+
async execute(
74+
_toolCallId: string,
75+
params: BunRunParams,
76+
signal?: AbortSignal,
77+
): Promise<AgentToolResult<Record<string, unknown>>> {
78+
try {
79+
const result = await runBunScript({
80+
script: params.script,
81+
args: params.args,
82+
cwd: params.cwd,
83+
timeoutSec: params.timeout_sec,
84+
}, signal);
85+
86+
return {
87+
content: [{ type: "text", text: buildResultText(result) }],
88+
details: {
89+
ok: result.exitCode === 0,
90+
script: result.scriptDisplayPath,
91+
cwd: result.cwdDisplayPath,
92+
args: result.args,
93+
bun_path: result.bunPath,
94+
exit_code: result.exitCode,
95+
stderr: result.stderr,
96+
stderr_bytes: result.stderrBytes,
97+
stderr_truncated: result.stderrTruncated,
98+
},
99+
};
100+
} catch (error) {
101+
const message = error instanceof Error ? error.message : String(error);
102+
const timedOut = message.startsWith("timeout:");
103+
const aborted = message === "aborted";
104+
return {
105+
content: [{
106+
type: "text",
107+
text: timedOut
108+
? `bun_run timed out after ${params.timeout_sec ?? 120}s while running ${params.script}.`
109+
: aborted
110+
? `bun_run was aborted while running ${params.script}.`
111+
: `bun_run failed: ${message}`,
112+
}],
113+
details: {
114+
ok: false,
115+
script: params.script,
116+
cwd: params.cwd || ".",
117+
args: Array.isArray(params.args) ? params.args : [],
118+
timeout_sec: params.timeout_sec ?? 120,
119+
timed_out: timedOut,
120+
aborted,
121+
error: message,
122+
},
123+
};
124+
}
125+
},
126+
});
127+
};

runtime/src/extensions/index.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
* - workspaceSearch: search_workspace tool for FTS over workspace files.
1717
* - sendAdaptiveCard: send_adaptive_card for agent-owned Adaptive Card posting.
1818
* - sendDashboardWidget: send_dashboard_widget for posting the built-in live dashboard widget.
19+
* - bunRunner: bun_run for direct Bun script execution without a shell.
1920
*
2021
* Consumers:
2122
* - agent-pool/session.ts passes builtinExtensionFactories to the resource loader.
@@ -33,6 +34,7 @@ import { uiThemeExtension } from "./ui-theme.js";
3334
import { smartCompaction } from "./smart-compaction.js";
3435
import { sendAdaptiveCard } from "./send-adaptive-card.js";
3536
import { sendDashboardWidget } from "./send-dashboard-widget.js";
37+
import { bunRunner } from "./bun-runner.js";
3638
import { exitProcess } from "./exit-process.js";
3739
import { autoresearchSupervisor } from "./autoresearch-supervisor.js";
3840

@@ -50,6 +52,7 @@ export const builtinExtensionFactories: ExtensionFactory[] = [
5052
smartCompaction,
5153
sendAdaptiveCard,
5254
sendDashboardWidget,
55+
bunRunner,
5356
exitProcess,
5457
autoresearchSupervisor,
5558
];

runtime/src/tools/bun-runner.ts

Lines changed: 233 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,233 @@
1+
/**
2+
* tools/bun-runner.ts – Run workspace Bun scripts directly without a shell.
3+
*
4+
* Spawns the Bun runtime with a script path + argv array, keeps cwd/script
5+
* resolution inside the workspace, tracks the child process for abort/shutdown,
6+
* discards stdout, and captures stderr only.
7+
*/
8+
9+
import { spawn } from "child_process";
10+
import { existsSync, statSync } from "fs";
11+
import path from "path";
12+
13+
import { WORKSPACE_DIR } from "../core/config.js";
14+
import { killProcessTree, registerProcess, unregisterProcess } from "../utils/process-tracker.js";
15+
16+
const DEFAULT_TIMEOUT_SEC = 120;
17+
const MAX_TIMEOUT_SEC = 3600;
18+
const MAX_CAPTURED_STDERR_BYTES = 64 * 1024;
19+
20+
export interface RunBunScriptParams {
21+
script: string;
22+
args?: string[];
23+
cwd?: string;
24+
timeoutSec?: number;
25+
}
26+
27+
export interface ResolvedBunScriptTarget {
28+
scriptPath: string;
29+
scriptDisplayPath: string;
30+
cwd: string;
31+
cwdDisplayPath: string;
32+
args: string[];
33+
timeoutSec: number;
34+
}
35+
36+
export interface RunBunScriptResult extends ResolvedBunScriptTarget {
37+
bunPath: string;
38+
exitCode: number | null;
39+
stderr: string;
40+
stderrBytes: number;
41+
stderrTruncated: boolean;
42+
}
43+
44+
function resolveWorkspacePath(input: string): string | null {
45+
const raw = String(input || "").trim();
46+
if (!raw) return null;
47+
const resolved = path.resolve(WORKSPACE_DIR, raw);
48+
const rel = path.relative(WORKSPACE_DIR, resolved);
49+
if (rel.startsWith("..") || path.isAbsolute(rel)) return null;
50+
return resolved;
51+
}
52+
53+
function displayWorkspacePath(absPath: string): string {
54+
const rel = path.relative(WORKSPACE_DIR, absPath);
55+
if (!rel || rel === ".") return ".";
56+
return rel.split(path.sep).join("/");
57+
}
58+
59+
function normalizeArgs(input: unknown): string[] {
60+
if (input === undefined || input === null) return [];
61+
if (!Array.isArray(input)) {
62+
throw new Error("args must be an array of strings.");
63+
}
64+
return input.map((value, index) => {
65+
if (typeof value !== "string") {
66+
throw new Error(`args[${index}] must be a string.`);
67+
}
68+
if (value.includes("\0")) {
69+
throw new Error(`args[${index}] contains an invalid null byte.`);
70+
}
71+
return value;
72+
});
73+
}
74+
75+
export function resolveBunScriptTarget(params: RunBunScriptParams): ResolvedBunScriptTarget {
76+
const resolvedScript = resolveWorkspacePath(params.script);
77+
if (!resolvedScript) {
78+
throw new Error("script must resolve to a file inside the workspace.");
79+
}
80+
if (!existsSync(resolvedScript)) {
81+
throw new Error(`Script not found: ${params.script}`);
82+
}
83+
84+
let scriptStats;
85+
try {
86+
scriptStats = statSync(resolvedScript);
87+
} catch {
88+
throw new Error(`Failed to stat script: ${params.script}`);
89+
}
90+
if (!scriptStats.isFile()) {
91+
throw new Error("script must be a file, not a directory.");
92+
}
93+
94+
const resolvedCwd = params.cwd && String(params.cwd).trim()
95+
? resolveWorkspacePath(params.cwd)
96+
: WORKSPACE_DIR;
97+
if (!resolvedCwd) {
98+
throw new Error("cwd must stay within the workspace.");
99+
}
100+
if (!existsSync(resolvedCwd)) {
101+
throw new Error(`cwd does not exist: ${params.cwd}`);
102+
}
103+
104+
let cwdStats;
105+
try {
106+
cwdStats = statSync(resolvedCwd);
107+
} catch {
108+
throw new Error(`Failed to stat cwd: ${params.cwd}`);
109+
}
110+
if (!cwdStats.isDirectory()) {
111+
throw new Error("cwd must be a directory.");
112+
}
113+
114+
const timeoutSec = Number.isFinite(params.timeoutSec)
115+
? Math.min(Math.max(Number(params.timeoutSec), 1), MAX_TIMEOUT_SEC)
116+
: DEFAULT_TIMEOUT_SEC;
117+
118+
return {
119+
scriptPath: resolvedScript,
120+
scriptDisplayPath: displayWorkspacePath(resolvedScript),
121+
cwd: resolvedCwd,
122+
cwdDisplayPath: displayWorkspacePath(resolvedCwd),
123+
args: normalizeArgs(params.args),
124+
timeoutSec,
125+
};
126+
}
127+
128+
export async function runBunScript(
129+
params: RunBunScriptParams,
130+
signal?: AbortSignal,
131+
): Promise<RunBunScriptResult> {
132+
const target = resolveBunScriptTarget(params);
133+
const bunPath = process.execPath || "bun";
134+
135+
return await new Promise<RunBunScriptResult>((resolve, reject) => {
136+
let settled = false;
137+
let child: ReturnType<typeof spawn> | null = null;
138+
let timedOut = false;
139+
let aborted = false;
140+
let stderrBytes = 0;
141+
let stderrTruncated = false;
142+
const stderrChunks: string[] = [];
143+
144+
const cleanup = (timeoutHandle?: NodeJS.Timeout) => {
145+
if (timeoutHandle) clearTimeout(timeoutHandle);
146+
if (signal) signal.removeEventListener("abort", onAbort);
147+
if (child?.pid) unregisterProcess(child.pid);
148+
};
149+
150+
const finish = (result: RunBunScriptResult) => {
151+
if (settled) return;
152+
settled = true;
153+
resolve(result);
154+
};
155+
156+
const fail = (error: Error, timeoutHandle?: NodeJS.Timeout) => {
157+
if (settled) return;
158+
settled = true;
159+
cleanup(timeoutHandle);
160+
reject(error);
161+
};
162+
163+
const onAbort = () => {
164+
aborted = true;
165+
if (child?.pid) killProcessTree(child.pid);
166+
};
167+
168+
const timeoutHandle = setTimeout(() => {
169+
timedOut = true;
170+
if (child?.pid) killProcessTree(child.pid);
171+
}, target.timeoutSec * 1000);
172+
173+
if (signal) {
174+
if (signal.aborted) {
175+
onAbort();
176+
} else {
177+
signal.addEventListener("abort", onAbort, { once: true });
178+
}
179+
}
180+
181+
child = spawn(bunPath, [target.scriptPath, ...target.args], {
182+
cwd: target.cwd,
183+
detached: true,
184+
env: process.env,
185+
stdio: ["ignore", "ignore", "pipe"],
186+
});
187+
188+
if (child.pid) registerProcess(child.pid);
189+
190+
child.stderr?.on("data", (chunk) => {
191+
const text = Buffer.isBuffer(chunk) ? chunk.toString("utf8") : String(chunk);
192+
stderrBytes += Buffer.byteLength(text, "utf8");
193+
const currentBytes = stderrChunks.reduce((sum, entry) => sum + Buffer.byteLength(entry, "utf8"), 0);
194+
const remaining = MAX_CAPTURED_STDERR_BYTES - currentBytes;
195+
if (remaining <= 0) {
196+
stderrTruncated = true;
197+
return;
198+
}
199+
if (Buffer.byteLength(text, "utf8") > remaining) {
200+
stderrTruncated = true;
201+
stderrChunks.push(Buffer.from(text, "utf8").subarray(0, remaining).toString("utf8"));
202+
return;
203+
}
204+
stderrChunks.push(text);
205+
});
206+
207+
child.on("error", (error) => {
208+
fail(error, timeoutHandle);
209+
});
210+
211+
child.on("close", (exitCode) => {
212+
cleanup(timeoutHandle);
213+
214+
if (aborted || signal?.aborted) {
215+
reject(new Error("aborted"));
216+
return;
217+
}
218+
if (timedOut) {
219+
reject(new Error(`timeout:${target.timeoutSec}`));
220+
return;
221+
}
222+
223+
finish({
224+
...target,
225+
bunPath,
226+
exitCode,
227+
stderr: stderrChunks.join(""),
228+
stderrBytes,
229+
stderrTruncated,
230+
});
231+
});
232+
});
233+
}

0 commit comments

Comments
 (0)