@earendil-works/pi-coding-agent
Version:
Coding agent CLI with read, bash, edit, write tools and session management
363 lines • 17.6 kB
JavaScript
import { constants } from "node:fs";
import { access as fsAccess } from "node:fs/promises";
import { constants as osConstants } from "node:os";
import { spawn } from "child_process";
import { Type } from "typebox";
import { waitForChildProcess } from "../../utils/child-process.js";
import { getShellConfig, getShellEnv, killProcessTree, trackDetachedChildPid, untrackDetachedChildPid, } from "../../utils/shell.js";
import { OutputAccumulator } from "./output-accumulator.js";
import { BASH_UPDATE_THROTTLE_MS, createShellRenderers } from "./renderers/bash.js";
import { wrapToolDefinition } from "./tool-definition-wrapper.js";
import { DEFAULT_MAX_BYTES, DEFAULT_MAX_LINES, formatSize } from "./truncate.js";
const MAX_TIMEOUT_MS = 2_147_483_647;
/** Output limit of `structuredContent.output`, which programmatic callers such as codemode scripts receive. */
const STRUCTURED_OUTPUT_MAX_BYTES = 1024 * 1024;
const MAX_TIMEOUT_SECONDS = MAX_TIMEOUT_MS / 1000;
function resolveTimeoutMs(timeout) {
if (timeout === undefined)
return undefined;
if (!Number.isFinite(timeout) || timeout <= 0) {
throw new Error("Invalid timeout: must be a finite number of seconds");
}
const timeoutMs = timeout * 1000;
if (timeoutMs > MAX_TIMEOUT_MS) {
throw new Error(`Invalid timeout: maximum is ${MAX_TIMEOUT_SECONDS} seconds`);
}
return timeoutMs;
}
const bashSchema = Type.Object({
command: Type.String({ description: "Shell command to execute" }),
timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
});
export const bashToolSystemPromptContribution = {
snippet: "Execute bash commands (ls, grep, find, etc.)",
guidelines: ["You can inspect PI_* environment variables for current model and session details."],
};
/**
* Result for programmatic callers such as codemode scripts. A non-zero exit code is an error result for the model, but scripts still resolve to this value.
* `output` is not limited like the model-facing output: callers decide how much of it reaches the model.
*/
const bashOutputSchema = Type.Object({
output: Type.String({ description: "Combined stdout and stderr, possibly truncated" }),
truncated: Type.Boolean(),
full_output_path: Type.Optional(Type.String({ description: "Full output, when truncated" })),
exit_code: Type.Number(),
wall_time_seconds: Type.Number(),
});
/**
* Timeout of the local shell backend. Its message keeps the `timeout:<seconds>` form custom operations throw.
* `shellExit` is set when the shell itself exited before the deadline, so the tool was only still waiting
* because background processes kept writing to the inherited stdout/stderr pipes (#11613).
*/
class ShellTimeoutError extends Error {
shellExit;
constructor(timeout, shellExit) {
super(`timeout:${timeout}`);
this.shellExit = shellExit;
}
}
/** Shared process execution used by the built-in shell tools. */
export function createLocalShellOperations(shellName, resolveShellConfig) {
return {
exec: async (command, cwd, { onData, signal, timeout, env }) => {
const timeoutMs = resolveTimeoutMs(timeout);
if (signal?.aborted) {
throw new Error("aborted");
}
const shellConfig = resolveShellConfig();
try {
await fsAccess(cwd, constants.F_OK);
}
catch {
throw new Error(`Working directory does not exist: ${cwd}\nCannot execute ${shellName} commands.`);
}
const commandFromStdin = shellConfig.commandTransport === "stdin";
const child = spawn(shellConfig.shell, commandFromStdin ? shellConfig.args : [...shellConfig.args, command], {
cwd,
detached: process.platform !== "win32",
env: env ?? getShellEnv(),
stdio: [commandFromStdin ? "pipe" : "ignore", "pipe", "pipe"],
windowsHide: true,
});
if (commandFromStdin) {
child.stdin?.on("error", () => { });
child.stdin?.end(command);
}
if (child.pid)
trackDetachedChildPid(child.pid);
const startedAt = performance.now();
let shellExitedAfterMs;
child.once("exit", () => {
shellExitedAfterMs = performance.now() - startedAt;
});
let timedOut = false;
let exitedBeforeDeadlineAfterMs;
let timeoutHandle;
const onAbort = () => {
if (child.pid)
killProcessTree(child.pid);
};
try {
// Set timeout if provided.
if (timeoutMs !== undefined) {
timeoutHandle = setTimeout(() => {
timedOut = true;
exitedBeforeDeadlineAfterMs = shellExitedAfterMs;
// Also when the shell already exited: on Unix the group kill still reaches background
// processes holding the pipes. Windows cannot find them once the shell pid is gone.
if (child.pid)
killProcessTree(child.pid);
}, timeoutMs);
}
// Stream stdout and stderr.
child.stdout?.on("data", onData);
child.stderr?.on("data", onData);
// Handle abort signal by killing the entire process tree.
if (signal) {
if (signal.aborted)
onAbort();
else
signal.addEventListener("abort", onAbort, { once: true });
}
// Handle shell spawn errors and wait for the process to terminate without hanging
// on inherited stdio handles held by detached descendants.
const exitCode = await waitForChildProcess(child);
if (signal?.aborted) {
throw new Error("aborted");
}
// A signal-killed shell has no exit code. Use the standard shell convention so
// callers do not mistake the termination for a successful command.
const signalCode = child.signalCode;
const resolvedExitCode = exitCode ?? (signalCode ? 128 + (osConstants.signals[signalCode] ?? 0) : 1);
if (timedOut) {
throw new ShellTimeoutError(timeout, exitedBeforeDeadlineAfterMs !== undefined
? { exitCode: resolvedExitCode, afterSeconds: Math.round(exitedBeforeDeadlineAfterMs / 100) / 10 }
: undefined);
}
return { exitCode: resolvedExitCode };
}
finally {
if (child.pid)
untrackDetachedChildPid(child.pid);
if (timeoutHandle)
clearTimeout(timeoutHandle);
if (signal)
signal.removeEventListener("abort", onAbort);
}
},
};
}
/**
* Create bash operations using pi's built-in local shell execution backend.
*
* This is useful for extensions that intercept user_bash and still want pi's
* standard local shell behavior while wrapping or rewriting commands.
*/
export function createLocalBashOperations(options) {
return createLocalShellOperations("bash", () => getShellConfig(options?.shellPath));
}
function resolveSpawnContext(command, cwd, spawnHook, exposeSessionEnvironment, ctx) {
const env = { ...getShellEnv() };
delete env.PI_SESSION_ID;
delete env.PI_SESSION_FILE;
delete env.PI_PROVIDER;
delete env.PI_MODEL;
delete env.PI_REASONING_LEVEL;
if (exposeSessionEnvironment && ctx) {
const model = ctx.model;
env.PI_SESSION_ID = ctx.sessionManager.getSessionId();
const sessionFile = ctx.sessionManager.getSessionFile();
if (sessionFile)
env.PI_SESSION_FILE = sessionFile;
if (model) {
env.PI_PROVIDER = model.provider;
env.PI_MODEL = model.id;
}
if (ctx.thinkingLevel)
env.PI_REASONING_LEVEL = ctx.thinkingLevel;
}
const baseContext = { command, cwd, env };
return spawnHook ? spawnHook(baseContext) : baseContext;
}
export function createShellToolDefinition(cwd, config, options) {
const ops = options?.operations ?? createLocalBashOperations({ shellPath: options?.shellPath });
const commandPrefix = options?.commandPrefix;
const exposeSessionEnvironment = options?.exposeSessionEnvironment ?? true;
const spawnHook = options?.spawnHook;
return {
name: config.name,
label: config.label,
description: `Execute a ${config.shellName} command in the current working directory. Returns stdout and stderr. Output is truncated to last ${DEFAULT_MAX_LINES} lines or ${DEFAULT_MAX_BYTES / 1024}KB (whichever is hit first). If truncated, full output is saved to a temp file. Optionally provide a timeout in seconds.`,
promptSnippet: config.promptSnippet,
promptGuidelines: exposeSessionEnvironment && config.promptGuidelines ? [...config.promptGuidelines] : undefined,
parameters: bashSchema,
outputSchema: bashOutputSchema,
constrainedSampling: { type: "json_schema", strict: "prefer" },
async execute(_toolCallId, { command, timeout }, signal, onUpdate, ctx) {
const resolvedCommand = commandPrefix ? `${commandPrefix}\n${command}` : command;
const spawnContext = resolveSpawnContext(resolvedCommand, ctx?.cwd || cwd, spawnHook, exposeSessionEnvironment, ctx);
const output = new OutputAccumulator({ tempFilePrefix: config.tempFilePrefix });
let acceptingOutput = true;
let updateTimer;
let updateDirty = false;
let lastUpdateAt = 0;
const emitOutputUpdate = () => {
if (!onUpdate || !updateDirty)
return;
updateDirty = false;
lastUpdateAt = Date.now();
const snapshot = output.snapshot({ persistIfTruncated: true });
onUpdate({
content: [{ type: "text", text: snapshot.content || "" }],
details: {
truncation: snapshot.truncation.truncated ? snapshot.truncation : undefined,
fullOutputPath: snapshot.fullOutputPath,
},
});
};
const clearUpdateTimer = () => {
if (updateTimer) {
clearTimeout(updateTimer);
updateTimer = undefined;
}
};
const scheduleOutputUpdate = () => {
if (!onUpdate)
return;
updateDirty = true;
const delay = BASH_UPDATE_THROTTLE_MS - (Date.now() - lastUpdateAt);
if (delay <= 0) {
clearUpdateTimer();
emitOutputUpdate();
return;
}
updateTimer ??= setTimeout(() => {
updateTimer = undefined;
emitOutputUpdate();
}, delay);
};
if (onUpdate) {
onUpdate({ content: [], details: undefined });
}
const handleData = (data) => {
if (!acceptingOutput)
return;
output.append(data);
scheduleOutputUpdate();
};
const finishOutput = async () => {
acceptingOutput = false;
output.finish();
clearUpdateTimer();
emitOutputUpdate();
const snapshot = output.snapshot({ persistIfTruncated: true });
await output.closeTempFile();
return snapshot;
};
const formatOutput = (snapshot, emptyText = "(no output)") => {
const truncation = snapshot.truncation;
let text = snapshot.content || emptyText;
let details;
if (truncation.truncated) {
details = { truncation, fullOutputPath: snapshot.fullOutputPath };
const startLine = truncation.totalLines - truncation.outputLines + 1;
const endLine = truncation.totalLines;
if (truncation.lastLinePartial) {
const lastLineSize = formatSize(output.getLastLineBytes());
text += `\n\n[Showing last ${formatSize(truncation.outputBytes)} of line ${endLine} (line is ${lastLineSize}). Full output: ${snapshot.fullOutputPath}]`;
}
else if (truncation.truncatedBy === "lines") {
text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines}. Full output: ${snapshot.fullOutputPath}]`;
}
else {
text += `\n\n[Showing lines ${startLine}-${endLine} of ${truncation.totalLines} (${formatSize(DEFAULT_MAX_BYTES)} limit). Full output: ${snapshot.fullOutputPath}]`;
}
}
return { text, details };
};
const appendStatus = (text, status) => `${text ? `${text}\n\n` : ""}${status}`;
const startedAt = performance.now();
try {
let exitCode;
try {
const result = await ops.exec(spawnContext.command, spawnContext.cwd, {
onData: handleData,
signal,
timeout,
env: spawnContext.env,
});
exitCode = result.exitCode;
}
catch (err) {
const snapshot = await finishOutput();
const { text } = formatOutput(snapshot, "");
if (err instanceof Error && err.message === "aborted") {
throw new Error(appendStatus(text, "Command aborted"));
}
if (err instanceof Error && err.message.startsWith("timeout:")) {
const timeoutSecs = err.message.split(":")[1];
let status = `Command timed out after ${timeoutSecs} seconds`;
if (err instanceof ShellTimeoutError && err.shellExit) {
// Both facts matter: the command's real result, and that its background processes
// were still writing output when the timeout hit (#11613).
status += `. The shell exited with code ${err.shellExit.exitCode} after ${err.shellExit.afterSeconds} seconds, but background processes it started kept writing output until the timeout.`;
}
throw new Error(appendStatus(text, status));
}
throw err;
}
const snapshot = await finishOutput();
const { text: outputText, details } = formatOutput(snapshot);
if (exitCode === null) {
throw new Error(appendStatus(outputText, "Command terminated without an exit code"));
}
const wallTimeSeconds = Math.round((performance.now() - startedAt) / 100) / 10;
const fullOutput = await output.readFullOutput(STRUCTURED_OUTPUT_MAX_BYTES);
const structuredContent = {
output: fullOutput.content,
truncated: fullOutput.truncated,
...(fullOutput.truncated && snapshot.fullOutputPath
? { full_output_path: snapshot.fullOutputPath }
: {}),
exit_code: exitCode,
wall_time_seconds: wallTimeSeconds,
};
if (exitCode !== 0) {
return {
content: [{ type: "text", text: appendStatus(outputText, `Command exited with code ${exitCode}`) }],
details,
structuredContent,
isError: true,
};
}
return { content: [{ type: "text", text: outputText }], details, structuredContent };
}
finally {
clearUpdateTimer();
}
},
...createShellRenderers(config.prompt),
};
}
const bashToolConfig = {
name: "bash",
label: "bash",
shellName: "bash",
prompt: "$",
promptSnippet: bashToolSystemPromptContribution.snippet,
promptGuidelines: bashToolSystemPromptContribution.guidelines,
tempFilePrefix: "pi-bash",
};
export function createBashToolDefinition(cwd, options) {
return createShellToolDefinition(cwd, bashToolConfig, options);
}
export function createBashTool(cwd, options) {
const definition = createBashToolDefinition(cwd, options);
const tool = wrapToolDefinition(definition);
Object.assign(tool, {
promptSnippet: definition.promptSnippet,
promptGuidelines: definition.promptGuidelines,
});
return tool;
}
//# sourceMappingURL=bash.js.map