命令执行:工作目录、超时与退出状态
把命令执行接入智能体并保留真实执行结果,明确工作目录、超时、退出码与输出裁剪的宿主边界。
命令执行:工作目录、超时与退出状态
真实仓库任务躲不开一件事:跑测试、跑构建、跑 git。这类需求只能落到命令执行上。
命令执行是整条链路上副作用等级最高的工具。它能改任意文件、能装包、能连网络、能读环境变量里的凭据。也正因为如此,这一篇的重点不是"怎么把命令跑起来"——spawn 几行就够——而是跑起来之后宿主拿得住什么:工作目录钉在哪、超时怎么算、退出码怎么变成模型能理解的信息、输出怎么裁剪不让它撑爆上下文、取消时进程是不是真的被杀掉了。
读它需要能读 TypeScript,并且知道"子进程"和"Promise"的基本概念。读完你能自己实现一个受控的命令工具,并解释清楚退出码、超时、取消三者在模型眼里分别长什么样。
Pi 迭代很快,接口可能变化。动手前先记录 pi --version,并按你安装的版本复核参数。
内置 bash 已经定义好的契约
先看清内置实现做了什么,因为自定义工具要跟它保持一致,否则模型会在两套语义之间来回猜。
bash 的参数只有两个:
// 内置 bash 的参数只有两个:命令和超时秒数
const bashSchema = Type.Object({
command: Type.String({ description: "Shell command to execute" }),
// timeout 是可选的,而且不传就没有默认超时:宿主必须自己补上上限
timeout: Type.Optional(Type.Number({ description: "Timeout in seconds (optional, no default timeout)" })),
});注意 timeout 的单位是秒,而且不传就没有默认超时。这个"没有默认超时"是宿主必须补上的第一件事——一个可以永远挂着的命令会占住整个会话。
内置实现里有四个值得学的处理:
超时值本身要校验。 resolveTimeoutMs 会拒绝非有限数和负数,报 Invalid timeout: must be a finite number of seconds,并设上限 MAX_TIMEOUT_MS = 2_147_483_647(约 68 年,实际上等于不限)。传给模型的参数不可信,超时值同样不可信。
超时和取消都要杀掉整棵进程树。 源码里子进程用 detached: process.platform !== "win32" 启动,然后 killProcessTree(child.pid) 发信号。Unix 上是 process.kill(-pid, "SIGKILL")——负的 pid 表示进程组,只杀直接子进程的话,sh -c "npm test" 起的孙进程会活下来继续占资源。
退出码非 0 不是异常,是错误结果。 源码里 exitCode !== 0 时返回的是带 isError: true 的正常结果,内容末尾追加 Command exited with code N;只有 exitCode === null(被信号杀掉且没拿到信号码)才抛 Command terminated without an exit code。这个区分很重要:命令跑失败是正常业务结果,不是程序错误。
顺带一个容易忽略的细节:被信号杀掉的进程,本地执行后端会把退出码折算成 128 + 信号号(SIGKILL 是 137),所以这种情况通常表现为非 0 退出码,而不是抛错。
输出裁剪保留尾部并落临时文件。 bash 用的是 truncateTail 而不是 truncateHead——命令的报错通常在最后。裁剪后会把完整输出的临时文件路径告诉模型。
裁剪阈值来自截断工具,是全局常量:
两个上限先到先算。bash 还会额外把完整输出写到临时文件(pi-bash 前缀),并把路径放进 details.fullOutputPath 和 structuredContent.full_output_path。
自己实现一个受控的命令工具
先看内置实现里可以复用的部分。BashOperations 接口是一个干净的注入点:
// 执行后端的抽象:换掉这一层就能加策略,不用重写整个工具
export interface BashOperations {
exec: (
command: string,
// 工作目录由调用方给出,但自定义实现可以忽略它并强制成固定目录
cwd: string,
options: {
// 输出增量回调,内置实现用它做节流上报
onData: (data: Buffer) => void;
// 取消信号:超时和用户取消走同一条杀掉进程树的路径
signal?: AbortSignal;
timeout?: number;
// 环境变量:默认继承全部 process.env 意味着模型能读到凭据
env?: NodeJS.ProcessEnv;
},
// exitCode 为 null 表示进程被信号杀掉,没拿到退出码
) => Promise<{ exitCode: number | null }>;
}createLocalBashOperations() 给出本地实现,createBashTool(cwd, { operations }) 接受自定义实现。如果你只是想要"更严的策略",替换 BashOperations 比重写整个工具划算得多。
但如果要做成"智能体可调用的命令工具",得自己写 execute。下面这份实现把前面五个要点都落到了代码上。
完整实现
import { spawn } from "node:child_process";
import { mkdtemp, writeFile } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { Type } from "@earendil-works/pi-ai";
import { defineTool, truncateTail } from "@earendil-works/pi-coding-agent";
/** 宿主侧的硬性上限:模型给的 timeout 不能超过这个值。 */
const MAX_TIMEOUT_SECONDS = 120;
const DEFAULT_TIMEOUT_SECONDS = 30;
export interface RunCommandOptions {
/** 命令的工作目录。所有相对路径都以此为基准。 */
workspaceRoot: string;
/** 允许执行的可执行文件名白名单。空数组表示不限制。 */
allowedBinaries?: string[];
}
export function createRunCommandTool(options: RunCommandOptions) {
const { workspaceRoot } = options;
const allowed = options.allowedBinaries;
return defineTool({
name: "run_command",
label: "执行命令",
description:
"在工作区内执行一条命令,返回退出码与输出。输出过长时会截断并给出完整输出的临时文件路径。",
parameters: Type.Object({
command: Type.String({ description: "要执行的命令" }),
timeout: Type.Optional(Type.Number({ description: "超时秒数,上限 120" })),
}),
async execute(_toolCallId, params, signal, onUpdate) {
// 1) 超时值先校验,再套上宿主上限
const requested = params.timeout ?? DEFAULT_TIMEOUT_SECONDS;
if (!Number.isFinite(requested) || requested <= 0) {
throw new Error("timeout 必须是大于 0 的有限数值。");
}
const timeoutSeconds = Math.min(requested, MAX_TIMEOUT_SECONDS);
// 2) 命令白名单:只放行可执行文件名,不解析整条命令行
if (allowed && allowed.length > 0) {
const binary = params.command.trim().split(/\s+/)[0] ?? "";
const basenameOnly = binary.split("/").pop() ?? "";
if (!allowed.includes(basenameOnly)) {
throw new Error(
`命令 ${basenameOnly} 不在允许列表内。允许的命令:${allowed.join("、")}。`,
);
}
}
if (signal?.aborted) throw new Error("操作已取消");
// 3) 启动子进程:独立进程组,超时或取消时能整组杀掉
const child = spawn("/bin/sh", ["-c", params.command], {
cwd: workspaceRoot,
detached: process.platform !== "win32",
stdio: ["ignore", "pipe", "pipe"],
env: { PATH: process.env.PATH ?? "", HOME: workspaceRoot },
});
let stdout = "";
let stderr = "";
let timedOut = false;
let aborted = false;
const collect = (chunk: Buffer, into: "out" | "err") => {
const text = chunk.toString("utf-8");
if (into === "out") stdout += text;
else stderr += text;
};
child.stdout?.on("data", (c: Buffer) => collect(c, "out"));
child.stderr?.on("data", (c: Buffer) => collect(c, "err"));
// 4) 超时与取消共用一条杀掉进程树的路径
const killTree = () => {
if (!child.pid) return;
try {
if (process.platform === "win32") {
child.kill();
} else {
process.kill(-child.pid, "SIGKILL");
}
} catch {
child.kill("SIGKILL");
}
};
const timer = setTimeout(() => {
timedOut = true;
killTree();
}, timeoutSeconds * 1000);
const onAbort = () => {
aborted = true;
killTree();
};
signal?.addEventListener("abort", onAbort, { once: true });
// 5) 等进程真正退出,再做清理
const exitCode = await new Promise<number | null>((resolve) => {
child.on("error", () => resolve(null));
child.on("close", (code) => resolve(code));
});
clearTimeout(timer);
signal?.removeEventListener("abort", onAbort);
if (aborted) throw new Error("命令已取消");
if (timedOut) throw new Error(`命令超过 ${timeoutSeconds} 秒被终止`);
// 6) 输出裁剪:保留尾部,完整内容落临时文件
const combined = [stdout.trimEnd(), stderr.trimEnd()].filter(Boolean).join("\n");
const truncation = truncateTail(combined);
let fullOutputPath: string | undefined;
if (truncation.truncated) {
const dir = await mkdtemp(join(tmpdir(), "pi-cmd-"));
fullOutputPath = join(dir, "output.txt");
await writeFile(fullOutputPath, combined, "utf-8");
}
const summary = truncation.truncated
? `${truncation.content}\n\n[输出已截断,显示最后 ${truncation.outputLines} 行。完整输出:${fullOutputPath}]`
: combined || "(无输出)";
// 7) 上报进度,让界面知道命令跑完了
onUpdate?.({
content: [{ type: "text", text: summary }],
details: { exitCode, truncated: truncation.truncated },
});
// 8) 退出码非 0 返回错误结果,不是抛异常
if (exitCode !== 0) {
return {
content: [{ type: "text", text: `${summary}\n\n命令以退出码 ${exitCode} 结束` }],
details: { exitCode, truncated: truncation.truncated, fullOutputPath },
isError: true,
};
}
return {
content: [{ type: "text", text: summary }],
details: { exitCode, truncated: truncation.truncated, fullOutputPath },
};
},
});
}几个设计取舍要讲清楚。
为什么用 sh -c 而不是直接 spawn(command)。 直接 spawn 一个字符串会按空格切分,无法处理管道和重定向。sh -c 把整条命令交给 shell,语义和用户预期一致。代价是命令必须完全可信——这正是白名单存在的原因。
为什么环境变量要收窄。 上面只传 PATH 和 HOME。默认继承整个 process.env 意味着模型可以 env 读到你的 API key、token 和 CI 凭据。这是宿主该做的第一件事,不是模型的自觉。
为什么超时和取消走同一个 killTree。 两条路径的语义一样:进程必须真的死掉。如果只 child.kill(),孙进程会变孤儿继续跑。
为什么裁剪用 truncateTail。 命令的错误信息在末尾。用 truncateHead 会把真正的报错截掉,模型只能看到一堆无害的开头。
为什么 onUpdate 在最后调一次就够了。 内置 bash 是按节流周期上报的,中间输出会频繁触发事件。命令通常在秒级完成,一次性上报能显著降低事件量。如果你需要实时流式输出,就该在 collect 里按时间节流调用 onUpdate。
把它接进会话
import { createAgentSession, SessionManager } from "@earendil-works/pi-coding-agent";
const workspaceRoot = process.cwd();
const { session } = await createAgentSession({
cwd: workspaceRoot,
// 用白名单:只保留只读工具和这一个命令工具
tools: ["read", "grep", "find", "ls", "run_command"],
// 白名单会连自定义工具一起过滤,所以 run_command 必须写进上面那个名单
customTools: [createRunCommandTool({ workspaceRoot, allowedBinaries: ["npm", "node", "git"] })],
sessionManager: SessionManager.inMemory(workspaceRoot),
});
try {
await session.prompt("运行 npm test 并告诉我失败原因");
console.log(session.getLastAssistantText());
} finally {
// 必须释放:否则子进程和监听器会留在进程里
session.dispose();
}注意 run_command 必须写进 tools 白名单——tools 白名单会连自定义工具一起过滤,白名单没写上的名字会被直接剔除。
复用内置 bash:更严策略的注入点
自己重写一套不是唯一选择。内置 bash 的实现把执行后端抽象成了 BashOperations,你可以只替换这一层,保留它已经处理好的裁剪、临时文件和退出码逻辑。
import {
createBashTool,
createLocalBashOperations,
type BashOperations,
} from "@earendil-works/pi-coding-agent";
/**
* 在内置 bash 的基础上加两道限制:
* 1) 工作目录强制为工作区,忽略调用方传入的任何目录
* 2) 超时封顶,模型给再大也有效
*/
function createRestrictedBash(workspaceRoot: string, maxTimeoutSeconds: number): BashOperations {
const base = createLocalBashOperations();
return {
exec: async (command, _cwd, options) => {
const timeout = options.timeout
? Math.min(options.timeout, maxTimeoutSeconds)
: maxTimeoutSeconds;
return base.exec(command, workspaceRoot, {
...options,
timeout,
// 环境变量在这里收窄:默认继承全部 process.env 意味着模型能读到凭据
env: { PATH: process.env.PATH ?? "", HOME: workspaceRoot },
});
},
};
}
// 把它包装成工具,模型看到的参数 schema 仍是内置 bash 的
const restrictedBash = createBashTool(workspaceRoot, {
operations: createRestrictedBash(workspaceRoot, 120),
});这个注入点的好处是不用重新实现难写的部分。内置 bash 已经处理了:输出累积与内存上限、截断后写临时文件、非 0 退出码转成 isError 结果、structuredContent 里带上 exit_code 和 wall_time_seconds。这些逻辑重写一遍很容易漏。
源码里还有两个相关的注入点,按需要选:
spawnHook 的形态是一个纯函数,官方示例 bash-spawn-hook.ts 就是这么用的:
// spawnHook 是纯函数:在真正启动前改写命令、工作目录和环境变量
const bashTool = createBashTool(cwd, {
spawnHook: ({ command, cwd, env }) => ({
command: `source ~/.profile\n${command}`,
cwd,
env: { ...env, PI_SPAWN_HOOK: "1" },
}),
});两条路怎么选: 需要模型自己决定跑什么命令、并且要严格控制超时和输出,就自己写工具;要保留内置 bash 的全部行为、只想加几道限制,就用 operations 注入。自己写工具时,本文上面的八步实现是一个可用的模板。
宿主负责什么
命令执行这条链路上,宿主的责任比工具实现本身更重:
官方安全文档的原话值得引用:Pi 没有内置权限系统,"Enabled tools use the operating-system permissions of the Pi process"。你的白名单是工具层的第一道防线,真正的隔离要靠容器。
同一份文档还列了三种运行方式,越往后隔离越强:直接以宿主用户权限运行 → 整个跑在容器/虚拟机/沙箱里 → 只有内置工具跑在隔离环境内。对不可信或无人值守的任务,第二种是通常最强也最实际的选择。
三种场景的可观察结果
三种场景在事件层的区分方式很朴素:看 isError 和 details.exitCode。exitCode 为 null 说明进程是被信号杀掉的(取消或超时),是一个数字说明命令自己跑完了。
这个区分之所以重要,是因为它决定了模型的下一步动作:
- 退出码非 0 → 命令本身执行了但结果不对,模型应该改代码再跑。
- 被信号杀掉 → 命令没跑完,模型应该缩短命令或减少输出。
如果把两者混成一句"命令失败了",模型会去改代码,而真正的问题是输出太多。
你现在能做什么
- 用
createBashTool(cwd, { operations })写一个自己的BashOperations,把工作目录强制成某个子目录,然后跑一条会打印pwd的命令,验证输出确实是那个目录。 - 跑一条
sleep 30的命令并把timeout设成 2 秒,确认它返回的是失败结果而不是挂住;同时用ps看有没有残留的sleep进程——这条验证的是进程组是否被整组杀掉。 - 把
allowedBinaries设成["npm"],然后让模型尝试执行cat /etc/passwd,确认它拿到的是白名单拒绝的错误文本。 - 写一个断言:命令返回非 0 退出码时,
result.isError === true且details.exitCode等于那个非 0 数字。这条断言能防止你之后不小心把失败当成成功回填给模型。