- 1. 使用 readline 搭建交互式行输入
- 2. 接入 DeepSeek:用 OpenAI SDK 完成终端对话
- 3. 受控工作区与 readFile / writeFile 工具骨架
- 4. 工具调用循环:把 readFile / writeFile 接到 API
- 5. 扩展工具:editFile 就地替换与 listDir 目录浏览
- 6. 在工作区内执行 Shell 命令(runCommand)
- 7. 长驻命令后台运行:spawn 与启动段输出采集
- 8. 后台任务登记与 task_list 管理
- 9. task_logs / task_stop
- 10. 含 runCommand 时同轮工具顺序执行
- 11. runCommand 执行前用户审批
- 12. 接入 MCP:通过 stdio 子进程扩展模型工具
- 13. Agent Skills:斜杠命令与 readSkill 按需加载
- 14.修改编码
1. 使用 readline 搭建交互式行输入 #
程序在做什么: 启动后会在控制台显示提示符 ›,等待你输入一行文字;回车后会把内容原样回显为「你说:…」。空行会被忽略;输入 q 并回车则结束程序。
核心 API:
readline.createInterface({ input, output, terminal }):把可读流(一般是process.stdin)和可写流(一般是process.stdout)绑在一起,形成「一问一答」的交互环境。terminal: true表示按终端语义处理(光标、行编辑等),适合在真正的控制台里运行。rl.question(prompt, callback):在prompt后等待用户输入一整行,以回车结束;callback收到去掉末尾换行的字符串。示例里用new Promise把它包成await askLine(),便于在async function main里用顺序写法组织逻辑。rl.close():用完接口后应关闭,避免句柄与监听器一直占用。
chcp 650011.1 claude.js #
// 引入 Node.js 的 readline 模块,用于读取终端输入
const readline = require("readline");
// 创建一个 readline 接口,指定输入输出为标准输入输出
const rl = readline.createInterface({
input: process.stdin,// 标准输入流
output: process.stdout,// 标准输出流
terminal: true,// 是否启用终端模式
});
// 监听 SIGINT(通常是 Ctrl+C)事件,只监听一次
rl.once("SIGINT", () => {
// 打印中断提示信息,换两行
console.log("\n\n⌁ 已中断,下次见。");
// 关闭 readline 接口
rl.close();
// 退出当前进程,状态码为 0(正常退出)
process.exit(0);
});
// 定义一个 ask 函数,返回一个 Promise,用于异步获取用户输入
const askLine = () =>
new Promise((resolve) => {
// 向控制台输出普通的提示符,并等待用户输入
rl.question("› ", resolve);
});
// 定义异步主函数 main
async function main() {
// 无限循环,持续读取用户输入
for (;;) {
// 等待用户输入一行内容
const line = await askLine();
// 如果输入内容为空(只包含空白),则跳过本次循环
if (!line.trim()) continue;
// 如果输入的内容为 "q",则跳出循环,结束程序
if (line.trim() === "q") break;
// 输出用户输入的内容,前面加上提示文字
console.log("你说:", line);
}
// 关闭 readline 接口,释放资源
rl.close();
}
// 调用主函数 main,启动程序
main();1.2 时序图 #
2. 接入 DeepSeek:用 OpenAI SDK 完成终端对话 #
在第 1 节「读一行、回显一行」的基础上,本节为 claude.js 增加大模型回复:仍用 readline 在终端读入,但不再原样打印,而是把用户输入发给 DeepSeek 的 Chat Completions API,并把助手回复打印到控制台。
程序在做什么: 启动时从 .env 读取 DEEPSEEK_API_KEY;每轮在 › 后读入一行。空行忽略;输入 q 退出。其它内容会带上固定的 system 角色说明,以 user 消息发给模型,模型返回后以 Assistant 标题打印回复。
require("dotenv").config({ override: true }):加载项目根目录.env中的环境变量;override: true表示用文件里的值覆盖进程里已有的同名变量,便于本地开发统一配置密钥。OpenAI客户端 +baseURL: "https://api.deepseek.com":DeepSeek 提供与 OpenAI 兼容的 HTTP API,因此可直接使用官方openainpm 包,只需改baseURL和apiKey,无需换 SDK。AGENT_SYSTEM_INSTRUCTION:写入messages首条的role: "system",约束助手身份与行为。openaiClient.chat.completions.create({ model, messages }):异步请求聊天补全;model使用deepseek-v4-pro;从completion.choices[0].message取出助手文本并打印。
关于对话上下文: 当前实现每轮循环都会新建 messages = [system],因此每一轮独立,不会把上一轮的 user / assistant 带入下一轮 API 请求;循环末尾 messages.push(assistant) 主要为单轮结构完整,若要做多轮记忆,应把 messages 提到 for 循环外并在多轮间复用。
运行前准备: 在项目根目录配置 .env(例如 DEEPSEEK_API_KEY=你的密钥),安装依赖后执行 node claude.js。需能访问 https://api.deepseek.com;密钥勿提交到版本库。
2.1 安装依赖 #
npm install openai dotenv --save2.2 claude.js #
// 引入 Node.js 的 readline 模块,用于读取终端输入
const readline = require("readline");
+// 加载 dotenv 配置文件,并允许覆盖已存在的环境变量
+require("dotenv").config({ override: true });
+// 从 openai 包中引入 OpenAI 类
+const { OpenAI } = require("openai");
+// 创建 OpenAI 客户端实例,使用 DeepSeek 的 API 密钥和自定义的 baseURL
+const openaiClient = new OpenAI({
+ apiKey: process.env.DEEPSEEK_API_KEY,
+ baseURL: "https://api.deepseek.com",
+});
+// 定义代理的系统指令
+const AGENT_SYSTEM_INSTRUCTION = `你是「Claude Code」—— 在受控 workspace 内协助用户读改代码、跑命令的助手。`;
// 创建一个 readline 接口,指定输入输出为标准输入输出
const rl = readline.createInterface({
input: process.stdin,// 标准输入流
output: process.stdout,// 标准输出流
terminal: true,// 是否启用终端模式
});
// 定义一个 ask 函数,返回一个 Promise,用于异步获取用户输入
const askLine = () =>
new Promise((resolve) => {
// 向控制台输出普通的提示符,并等待用户输入
rl.question("› ", resolve);
});
// 定义异步主函数 main
async function main() {
// 无限循环,持续读取用户输入
+ for (; ;) {
+ // 创建一个消息列表,包含系统指令
+ const messages = [{ role: "system", content: AGENT_SYSTEM_INSTRUCTION }];
// 等待用户输入一行内容
const line = await askLine();
// 如果输入内容为空(只包含空白),则跳过本次循环
if (!line.trim()) continue;
// 如果输入的内容为 "q",则跳出循环,结束程序
if (line.trim() === "q") break;
+ // 将用户输入加入对话消息列表
+ messages.push({ role: "user", content: line });
+ // 调用 OpenAI API,发送所有消息,生成聊天回复
+ const completion = await openaiClient.chat.completions.create({
+ model: "deepseek-v4-pro",
+ messages: messages,
+ });
+ // 获取助手回复内容
+ const reply = completion.choices[0].message;
+ // 打印助手的回复到控制台
+ console.log("\nAssistant\n", reply.content, "\n");
+ // 将助手回复也加入消息列表,以便对话上下文保持同步
+ messages.push({ role: "assistant", content: reply.content });
}
// 关闭 readline 接口,释放资源
rl.close();
}
// 调用主函数 main,启动程序
main();2.3 .env #
DEEPSEEK_API_KEY=sk-fcfe6462dcd24095ab2f7e238e74143f2.4 completion #
2.4.1 示例代码 #
// 加载 dotenv 配置文件,并允许覆盖已存在的环境变量
require("dotenv").config({ override: true });
// 从 openai 包中引入 OpenAI 类
const { OpenAI } = require("openai");
// 创建 OpenAI 客户端实例,使用 DeepSeek 的 API 密钥和自定义的 baseURL
const openaiClient = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: "https://api.deepseek.com",
});
async function main() {
const completion = await openaiClient.chat.completions.create({
model: "deepseek-v4-pro",
messages: [
{ role: "user", content: "1+1=?" },
]
});
console.log(JSON.stringify(completion, null, 2));
// 获取模型的文本回答
const assistantMessage = completion.choices[0].message.content;
console.log(assistantMessage);
// 获取本次请求的Token消耗
const tokensUsed = completion.usage.total_tokens;
console.log(`本次请求共消耗 ${tokensUsed} 个Token`);
}
main();2.4.2 输出信息 #
{
"id": "5e3d5c3d-5d36-493b-9eb8-3066ef854d58", // 本次会话的唯一标识
"object": "chat.completion", // 返回对象类型,固定为 chat.completion
"created": 1778910161, // 响应生成的UNIX时间戳
"model": "deepseek-v4-pro", // 所使用的模型名称
"choices": [ // 多个候选答案的列表,通常只有一个
{
"index": 0, // 当前choice在choices数组中的索引
"message": { // assistant生成的消息对象
"role": "assistant", // 消息角色,这里是assistant(助手)
"content": "1 + 1 = 2", // assistant的回复内容
"reasoning_content": "We are asked: \"1+1=?\" This is a very simple arithmetic question. The answer is 2. But let's consider if there is any trick. The user might be testing for a basic response. I'll just answer directly." // assistant推理与分析过程
},
"logprobs": null, // 置信概率信息,通常为null
"finish_reason": "stop" // 回答完成的原因(如stop表示正常结束)
}
],
"usage": { // token消耗统计
"prompt_tokens": 8, // 用户输入的token数
"completion_tokens": 58, // assistant回复的token数
"total_tokens": 66, // 总token数(prompt_tokens + completion_tokens)
"prompt_tokens_details": { // 提示词token细节
"cached_tokens": 0 // 缓存命中的token数
},
"completion_tokens_details": { // 回复token细节
"reasoning_tokens": 50 // assistant推理内容的token数
},
"prompt_cache_hit_tokens": 0, // prompt部分缓存命中token数
"prompt_cache_miss_tokens": 8 // prompt部分没有命中的token数
},
"system_fingerprint": "fp_9954b31ca7_prod0820_fp8_kvcache_20260402" // 系统指纹标识
}2.4.3 数据结构 #
completion 对象遵循 OpenAI 的 API 标准格式。对于你的 create 调用(非流式),它的结构是一个顶层对象,主要包含 id, object, created, model, choices, 和 usage 等字段。
下面是其核心数据结构分解:
2.4.3.1 ChatCompletion对象 #
这个顶层对象是响应的主体,为你提供本次对话生成的全局信息。
| 字段 | 类型 | 描述 | 示例 |
|---|---|---|---|
id |
string |
本次对话补全的唯一标识符。 | "chatcmpl-abc123" |
object |
string |
对象类型,对于非流式响应,其值固定为 chat.completion。 |
"chat.completion" |
created |
integer |
对话补全创建时的 Unix 时间戳(秒)。 | 1677652288 |
model |
string |
实际生成补全所使用的模型名称。 | "deepseek-v4-pro" |
choices |
array |
一个或多个生成的对话补全选择。这是你获取模型回答的主要入口。 | 见下文详细说明 |
usage |
object |
本次请求的 Token 用量统计,用于成本计算。 | 见下文详细说明 |
2.4.3.2 choices 数组 #
choices 数组包含了模型实际生成的回答。如果你在请求中设置了参数 n(例如 n: 2),这个数组里会包含多个选项,每个选项是一个 Choice 对象,并通过 index 字段来标识序号。
一个 Choice 对象的结构如下:
| 字段 | 类型 | 描述 | 示例 |
|---|---|---|---|
index |
integer |
该选项在 choices 数组中的索引。 |
0 |
message |
object |
模型生成的完整回答消息。这是你需要关注的核心数据。 | 见下文详细说明 |
finish_reason |
string |
模型停止生成的原因。可能是 "stop" (自然结束)、"length" (达到最大token限制)、"content_filter" (内容被过滤) 等。 |
"stop" |
2.4.3.3 message 对象 #
这是 choices[*].message 字段的内容,代表了模型生成的一条完整消息。
| 字段 | 类型 | 描述 | 示例 | |
|---|---|---|---|---|
role |
string |
此消息的作者角色,通常是 "assistant"。 |
"assistant" |
|
content |
string \ |
null |
模型生成的文本回复内容。 | "Python是一种高级编程语言..." |
2.4.3.4 usage 对象 #
此对象用于统计本次 API 调用所消耗的 Token 数量,方便你进行成本控制和额度管理。
| 字段 | 类型 | 描述 | 示例 |
|---|---|---|---|
prompt_tokens |
integer |
用户输入的 messages 参数中所有内容的 Token 数量。 |
32 |
completion_tokens |
integer |
模型生成的 content 所包含的 Token 数量。 |
86 |
total_tokens |
integer |
prompt_tokens 与 completion_tokens 的总和。 |
118 |
2.4.3.5 补充说明 #
- 兼容性:这个数据结构是通用的,当你使用
openai库调用其他兼容 OpenAI 格式的 API 时(包括deepseek-v4-pro),返回的结构通常是相同的。 - 流式响应:如果你在请求时设置了
stream: true,API 会返回一个数据流,其中每个数据块是一个ChatCompletionChunk对象,结构与上述的完整ChatCompletion对象不同。你通常需要逐步处理这些delta内容。
2.5 时序图 #
3. 受控工作区与 readFile / writeFile 工具骨架 #
在第 2 节「终端对话 + 大模型回复」之上,本节为助手增加可读写文件的沙箱能力:所有文件操作限定在项目下的 workspace/ 目录内,并通过 JSON Schema 向模型声明 readFile、writeFile 两个函数工具。
程序在做什么: 启动时把 WORKSPACE_ROOT 固定为 process.cwd()/workspace;模型(或本地测试)若调用 readFile / writeFile,会先经 resolvePathInsideWorkspace 把相对路径解析为绝对路径并校验不越界,再由 ReadText / WriteText 用 UTF-8 读写字面量文本。写入前会对父目录 mkdir(..., { recursive: true }),不存在则自动创建。
核心设计:
isDescendantOrSameDirectory+resolvePathInsideWorkspace:用path.resolve规范化路径,再用path.relative判断目标是否落在WORKSPACE_ROOT之下(拒绝以..逃逸、拒绝跨盘符等「相对结果为绝对路径」的情况),避免模型通过../../../读写工作区外文件。ReadText/WriteText:各实现一个async run(args),与 OpenAI「function calling」里工具 handler 的形态一致;错误以字符串返回(如ENOENT→「找不到文件」),便于后续把结果塞回role: "tool"消息。MODEL_TOOL_DEFINITIONS:符合 Chat Completionstools参数的 schema,描述工具名、说明与parameters(readFile仅需path,writeFile需path+content)。toolHandlerByName:工具名到实例的映射,便于在收到tool_calls时按function.name分发执行。
3.1 claude.js #
// 引入 Node.js 的 readline 模块,用于读取终端输入
const readline = require("readline");
// 加载 dotenv 配置文件,并允许覆盖已存在的环境变量
require("dotenv").config({ override: true });
+// 引入 Node.js 的 path 模块,用于处理和转换文件路径
+const path = require("path");
+// 引入 Node.js 的 fs/promises 模块,提供基于 promise 的文件系统操作方法
+const fsp = require("fs/promises");
// 从 openai 包中引入 OpenAI 类
const { OpenAI } = require("openai");
// 创建 OpenAI 客户端实例,使用 DeepSeek 的 API 密钥和自定义的 baseURL
const openaiClient = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: "https://api.deepseek.com",
});
// 定义代理的系统指令
const AGENT_SYSTEM_INSTRUCTION = `你是「Claude Code」—— 在受控 workspace 内协助用户读改代码、跑命令的助手。`;
// 创建一个 readline 接口,指定输入输出为标准输入输出
const rl = readline.createInterface({
input: process.stdin,// 标准输入流
output: process.stdout,// 标准输出流
terminal: true,// 是否启用终端模式
});
+// 定义工作区根目录,位于当前进程的 workspace 文件夹内
+const WORKSPACE_ROOT = path.resolve(process.cwd(), "workspace");
+// 判断目标路径是否是根目录的子目录或根目录本身
+function isDescendantOrSameDirectory(candidatePath) {
+ // 解析 candidatePath 为绝对路径
+ const targetAbs = path.resolve(candidatePath);
+ // 如果目标路径和工作区根目录完全相同,则返回 true
+ if (WORKSPACE_ROOT === targetAbs) return true;
+ // 计算目标路径相对于工作区根目录的相对路径
+ const relative = path.relative(WORKSPACE_ROOT, targetAbs);
+ // 如果不是空字符串,不以 .. 开头,也不是绝对路径,则说明在工作区内,返回 true
+ return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
+}
+// 在工作区内解析路径,并校验是否越界
+function resolvePathInsideWorkspace(relativePath) {
+ // 将相对路径或带 ~ 路径解析为工作区下的绝对路径
+ const candidatePath = path.resolve(WORKSPACE_ROOT, relativePath);
+ // 如果解析后的路径不在工作区内,则抛出异常
+ if (!isDescendantOrSameDirectory(candidatePath))
+ throw new Error(`路径越界:${candidatePath}`);
+ // 返回校验通过的绝对路径
+ return candidatePath;
+}
+// 文本读取工具实现
+class ReadText {
+ // 执行读取操作,参数为 { path }
+ async run({ path: relativePath }) {
+ try {
+ // 调用文件系统 API 读取文件内容
+ return await fsp.readFile(resolvePathInsideWorkspace(relativePath), "utf8");
+ } catch (err) {
+ // 如果找不到文件,返回自定义提示;否则返回异常原因
+ return err.code === "ENOENT" ? `找不到文件:${relativePath}` : `读取异常:${err.message}`;
+ }
+ }
+}
+// 文本写入工具实现
+class WriteText {
+ // 执行写入操作,参数为 { path, content }
+ async run({ path: relativePath, content }) {
+ try {
+ // 解析目标文件的绝对路径
+ const absolute = resolvePathInsideWorkspace(relativePath);
+ // 如果父级目录不存在则递归创建
+ await fsp.mkdir(path.dirname(absolute), { recursive: true });
+ // 写入内容到文件,使用 UTF-8 编码
+ await fsp.writeFile(absolute, content, "utf8");
+ // 返回写入结果以及写入字节数
+ return `已落盘 ${Buffer.byteLength(content, "utf8")} 字节 → ${relativePath}`;
+ } catch (err) {
+ // 返回写入过程中的异常信息
+ return `写入异常:${err.message}`;
+ }
+ }
+}
+// 工具的 Schema 定义,描述每个工具的用途和参数结构
+const MODEL_TOOL_DEFINITIONS = [
+ {
+ // 声明工具类型为函数
+ type: "function",
+ function: {
+ // 工具名称为 readFile
+ name: "readFile",
+ // 工具功能描述
+ description: "以 UTF-8 读取工作区内文本文件。",
+ // 工具参数定义
+ parameters: {
+ // 参数类型为对象
+ type: "object",
+ // 参数属性为 path,字符串类型
+ properties: { path: { type: "string" } },
+ // path 字段为必需参数
+ required: ["path"],
+ },
+ },
+ },
+ {
+ // 声明写入工具类型为函数
+ type: "function",
+ function: {
+ // 工具名称为 writeFile
+ name: "writeFile",
+ // 工具描述:新建或覆盖写入工作区内文件
+ description: "在工作区内新建或覆盖写入文件。",
+ // 工具参数定义
+ parameters: {
+ type: "object",
+ properties: {
+ // path 为文件路径,字符串类型
+ path: { type: "string" },
+ // content 为要写入的文本内容,字符串类型
+ content: { type: "string" },
+ },
+ // path 和 content 都必需
+ required: ["path", "content"],
+ },
+ },
+ },
+];
+// 工具名称到对应实例的映射表
+const toolHandlerByName = {
+ // 读取文件工具
+ readFile: new ReadText(),
+ // 写入文件工具
+ writeFile: new WriteText(),
+};
// 定义一个 ask 函数,返回一个 Promise,用于异步获取用户输入
const askLine = () =>
new Promise((resolve) => {
// 向控制台输出普通的提示符,并等待用户输入
rl.question("› ", resolve);
});
// 定义异步主函数 main
async function main() {
// 无限循环,持续读取用户输入
for (; ;) {
// 创建一个消息列表,包含系统指令
const messages = [{ role: "system", content: AGENT_SYSTEM_INSTRUCTION }];
// 等待用户输入一行内容
const line = await askLine();
// 如果输入内容为空(只包含空白),则跳过本次循环
if (!line.trim()) continue;
// 如果输入的内容为 "q",则跳出循环,结束程序
if (line.trim() === "q") break;
// 将用户输入加入对话消息列表
messages.push({ role: "user", content: line });
// 调用 OpenAI API,发送所有消息,生成聊天回复
const completion = await openaiClient.chat.completions.create({
model: "deepseek-v4-pro",
messages: messages,
});
// 获取助手回复内容
const reply = completion.choices[0].message;
// 打印助手的回复到控制台
console.log("\nAssistant\n", reply.content, "\n");
// 将助手回复也加入消息列表,以便对话上下文保持同步
messages.push({ role: "assistant", content: reply.content });
}
// 关闭 readline 接口,释放资源
rl.close();
}
// 调用主函数 main,启动程序
main();4. 工具调用循环:把 readFile / writeFile 接到 API #
在第 3 节已具备「工作区沙箱 + 工具 Schema + handler」的基础上,本节把 Function Calling 真正接到对话主循环:模型可自动发起 readFile / writeFile,本地执行后把结果以 role: "tool" 回传,直到模型给出最终文字回复或达到步数上限。
程序在做什么: 用户每输入一行(非空且非 q),main 构造 messages 后调用 runAgentUntilReplyOrMaxSteps;该函数在 agentMaxSteps(默认 100)内反复请求 API(带 tools + tool_choice: "auto")。若助手消息含 tool_calls,则并发执行 executeSingleToolCall,把工具结果追加进 messages 并再次请求;若无 tool_calls,返回该条 assistant 消息,由 main 打印 reply.content。启动时还会 mkdir(WORKSPACE_ROOT),并监听 SIGINT 优雅退出。
新增点:
executeSingleToolCall:解析function.arguments(JSON 字符串 → 对象)、按名查toolHandlerByName、执行handler.run,构造{ role: "tool", tool_call_id, name, content }供下一轮 API 使用。runAgentUntilReplyOrMaxSteps:实现「模型 → 工具 → 模型」的 ReAct 式多步循环;同一轮用户输入内,messages会累积assistant(含tool_calls)与多条tool,直到模型不再调工具。CONFIG:agentMaxSteps防止工具调用死循环;
与第 2 节类似的限制: 外层 for 每轮仍新建 messages = [system],跨用户输入的多轮记忆仍未保留;单轮用户输入内部则可通过工具循环保留完整上下文。
4.1 claude.js #
// 引入 Node.js 的 readline 模块,用于读取终端输入
const readline = require("readline");
// 加载 dotenv 配置文件,并允许覆盖已存在的环境变量
require("dotenv").config({ override: true });
// 引入 Node.js 的 path 模块,用于处理和转换文件路径
const path = require("path");
// 引入 Node.js 的 fs/promises 模块,提供基于 promise 的文件系统操作方法
const fsp = require("fs/promises");
// 从 openai 包中引入 OpenAI 类
const { OpenAI } = require("openai");
// 创建 OpenAI 客户端实例,使用 DeepSeek 的 API 密钥和自定义的 baseURL
const openaiClient = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: "https://api.deepseek.com",
});
// 定义代理的系统指令
const AGENT_SYSTEM_INSTRUCTION = `你是「Claude Code」—— 在受控 workspace 内协助用户读改代码、跑命令的助手。`;
// 创建一个 readline 接口,指定输入输出为标准输入输出
const rl = readline.createInterface({
input: process.stdin,// 标准输入流
output: process.stdout,// 标准输出流
terminal: true,// 是否启用终端模式
});
// 监听 SIGINT(通常是 Ctrl+C)事件,只监听一次
rl.once("SIGINT", () => {
// 打印中断提示信息,换两行
console.log("\n\n⌁ 已中断,下次见。");
// 关闭 readline 接口
rl.close();
// 退出当前进程,状态码为 0(正常退出)
process.exit(0);
});
// 定义工作区根目录,位于当前进程的 workspace 文件夹内
const WORKSPACE_ROOT = path.resolve(process.cwd(), "workspace");
// 判断目标路径是否是根目录的子目录或根目录本身
function isDescendantOrSameDirectory(candidatePath) {
// 解析 candidatePath 为绝对路径
const targetAbs = path.resolve(candidatePath);
// 如果目标路径和工作区根目录完全相同,则返回 true
if (WORKSPACE_ROOT === targetAbs) return true;
// 计算目标路径相对于工作区根目录的相对路径
const relative = path.relative(WORKSPACE_ROOT, targetAbs);
// 如果不是空字符串,不以 .. 开头,也不是绝对路径,则说明在工作区内,返回 true
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
}
// 在工作区内解析路径,并校验是否越界
function resolvePathInsideWorkspace(relativePath) {
// 将相对路径或带 ~ 路径解析为工作区下的绝对路径
const candidatePath = path.resolve(WORKSPACE_ROOT, relativePath);
// 如果解析后的路径不在工作区内,则抛出异常
if (!isDescendantOrSameDirectory(candidatePath))
throw new Error(`路径越界:${candidatePath}`);
// 返回校验通过的绝对路径
return candidatePath;
}
// 文本读取工具实现
class ReadText {
// 执行读取操作,参数为 { path }
async run({ path: relativePath }) {
try {
// 调用文件系统 API 读取文件内容
return await fsp.readFile(resolvePathInsideWorkspace(relativePath), "utf8");
} catch (err) {
// 如果找不到文件,返回自定义提示;否则返回异常原因
return err.code === "ENOENT" ? `找不到文件:${relativePath}` : `读取异常:${err.message}`;
}
}
}
// 文本写入工具实现
class WriteText {
// 执行写入操作,参数为 { path, content }
async run({ path: relativePath, content }) {
try {
// 解析目标文件的绝对路径
const absolute = resolvePathInsideWorkspace(relativePath);
// 如果父级目录不存在则递归创建
await fsp.mkdir(path.dirname(absolute), { recursive: true });
// 写入内容到文件,使用 UTF-8 编码
await fsp.writeFile(absolute, content, "utf8");
// 返回写入结果以及写入字节数
return `已落盘 ${Buffer.byteLength(content, "utf8")} 字节 → ${relativePath}`;
} catch (err) {
// 返回写入过程中的异常信息
return `写入异常:${err.message}`;
}
}
}
// 工具的 Schema 定义,描述每个工具的用途和参数结构
const MODEL_TOOL_DEFINITIONS = [
{
// 声明工具类型为函数
type: "function",
function: {
// 工具名称为 readFile
name: "readFile",
// 工具功能描述
description: "以 UTF-8 读取工作区内文本文件。",
// 工具参数定义
parameters: {
// 参数类型为对象
type: "object",
// 参数属性为 path,字符串类型
properties: { path: { type: "string" } },
// path 字段为必需参数
required: ["path"],
},
},
},
{
// 声明写入工具类型为函数
type: "function",
function: {
// 工具名称为 writeFile
name: "writeFile",
// 工具描述:新建或覆盖写入工作区内文件
description: "在工作区内新建或覆盖写入文件。",
// 工具参数定义
parameters: {
type: "object",
properties: {
// path 为文件路径,字符串类型
path: { type: "string" },
// content 为要写入的文本内容,字符串类型
content: { type: "string" },
},
// path 和 content 都必需
required: ["path", "content"],
},
},
},
];
// 工具名称到对应实例的映射表
const toolHandlerByName = {
// 读取文件工具
readFile: new ReadText(),
// 写入文件工具
writeFile: new WriteText(),
};
// 定义一个 ask 函数,返回一个 Promise,用于异步获取用户输入
const askLine = () =>
new Promise((resolve) => {
// 向控制台输出普通的提示符,并等待用户输入
rl.question("› ", resolve);
});
+// 定义常量配置对象
+const CONFIG = {
+ // 智能体允许的最大步数
+ agentMaxSteps: 100,
+};
+// 定义异步函数,用于执行一次工具调用
+async function executeSingleToolCall(toolCallPayload) {
+ // 获取本次工具调用的名字
+ const name = toolCallPayload.function.name;
+ // 声明解析参数变量
+ let parsedArgs;
+ try {
+ // 尝试将工具调用的参数字符串解析为对象
+ parsedArgs = JSON.parse(toolCallPayload.function.arguments || "{}");
+ } catch {
+ // 如果解析失败,则设为空对象
+ parsedArgs = {};
+ }
+ // 控制台输出工具调用信息
+ console.log(`\n工具 ${name} 被调用`);
+ // 控制台输出工具的入参
+ console.log(`入参:${JSON.stringify(parsedArgs, null, 2)}`);
+ // 根据工具名获取对应处理器
+ const handler = toolHandlerByName[name];
+ // 判断是否有对应处理器,如有则执行,否则返回未实现提示
+ const textResult = handler
+ ? await handler.run(parsedArgs)
+ : `✖ 未实现的工具:${name}`;
+ // 控制台输出工具调用的结果
+ console.log(`返回:${textResult}`);
+ // 构造并返回本次工具调用的完整回复对象
+ return {
+ // 设置角色为 tool
+ role: "tool",
+ // 工具调用对应的 id
+ tool_call_id: toolCallPayload.id,
+ // 工具名称
+ name,
+ // 工具返回的内容
+ content: textResult,
+ };
+}
+// 定义异步函数,驱动智能体连续推理直到文字回复或超步
+async function runAgentUntilReplyOrMaxSteps(messages) {
+ // 初始化步数计数器
+ let step = 0;
+ // 循环,直到步数超过最大限制
+ while (step < CONFIG.agentMaxSteps) {
+ // 步数递增
+ step++;
+ // 控制台显示正在请求模型
+ console.log("\n请求模型中...");
+ // 向模型 API 发送消息请求回复
+ const completion = await openaiClient.chat.completions.create({
+ // 模型名称
+ model: "deepseek-v4-pro",
+ // 消息参数
+ messages: messages,
+ // 工具函数定义
+ tools: MODEL_TOOL_DEFINITIONS,
+ // 工具选择方式
+ tool_choice: "auto",
+ });
+ // 获取模型助手返回的消息对象
+ const assistantMessage = completion.choices[0].message;
+ // 控制台输出助手消息
+ console.log(JSON.stringify(assistantMessage, null, 2));
+ // 将助手消息加入对话消息历史
+ messages.push(assistantMessage);
+ // 获取本轮助手消息产生的工具调用
+ const calls = assistantMessage.tool_calls;
+ // 判断是否有工具调用,无则直接返回助手回复
+ if (!calls || calls.length === 0) {
+ // 返回助手文字回复
+ return assistantMessage;
+ }
+ // 并发执行每个工具调用,等待所有结果
+ const toolResponses = await Promise.all(calls.map(executeSingleToolCall));
+ // 将所有工具回复添加到消息历史
+ for (const row of toolResponses)
+ messages.push(row);
+ }
+ // 若达到最大步数仍未获得直接回复,则返回警告信息
+ return {
+ role: "assistant",
+ content: "⚠ 对话步数已达上限。",
+ };
+}
// 定义异步主函数 main
async function main() {
+ //创建工作区目录
+ await fsp.mkdir(WORKSPACE_ROOT, { recursive: true });
// 无限循环,持续读取用户输入
for (; ;) {
// 创建一个消息列表,包含系统指令
const messages = [{ role: "system", content: AGENT_SYSTEM_INSTRUCTION }];
// 等待用户输入一行内容
const line = await askLine();
// 如果输入内容为空(只包含空白),则跳过本次循环
if (!line.trim()) continue;
// 如果输入的内容为 "q",则跳出循环,结束程序
if (line.trim() === "q") break;
// 将用户输入加入对话消息列表
messages.push({ role: "user", content: line });
// 调用 OpenAI API,发送所有消息,生成聊天回复
+ const reply = await runAgentUntilReplyOrMaxSteps(messages);
// 打印助手的回复到控制台
+ if (reply) console.log(`\nAssistant\n${reply.content}\n`);
}
// 关闭 readline 接口,释放资源
rl.close();
}
// 调用主函数 main,启动程序
main();4.2 时序图 #
下图描述单次用户输入内,runAgentUntilReplyOrMaxSteps 与工具执行的交互(若模型连续多轮调工具,则 loop 单轮 agent 步 会重复)。
4.3 测试数据 #
› 把I love you写入msg.txt
请求模型中...
{
"role": "assistant",
"content": "",
"reasoning_content": "The user wants to write \"I love you\" to a file named msg.txt.",
"tool_calls": [
{
"index": 0,
"id": "call_00_72JFPzLd2yo2WbQSCtIJ7049",
"type": "function",
"function": {
"name": "writeFile",
"arguments": "{\"path\": \"msg.txt\", \"content\": \"I love you\"}"
}
}
]
}
工具 writeFile 被调用
入参:{
"path": "msg.txt",
"content": "I love you"
}
返回:已落盘 10 字节 → msg.txt
请求模型中...
{
"role": "assistant",
"content": "已写入 `msg.txt`。",
"reasoning_content": "The file has been written successfully."
}
Assistant
已写入 `msg.txt`。
› 读取msg.txt文件的内容
请求模型中...
{
"role": "assistant",
"content": "",
"reasoning_content": "用户想要读取msg.txt文件的内容。",
"tool_calls": [
{
"index": 0,
"id": "call_00_gZSYt35Zv7poCriwwDTC3801",
"type": "function",
"function": {
"name": "readFile",
"arguments": "{\"path\": \"msg.txt\"}"
}
}
]
}
工具 readFile 被调用
入参:{
"path": "msg.txt"
}
返回:I love you
请求模型中...
{
"role": "assistant",
"content": "文件 `msg.txt` 的内容是:\n\n```\nI love you\n```\n\n这是一个简短的消息。需要我帮你做其他操作吗?",
"reasoning_content": "文件内容已读取,是\"I love you\"。"
}
Assistant
文件 `msg.txt` 的内容是:
I love you5. 扩展工具:editFile 就地替换与 listDir 目录浏览 #
在第 4 节「工具调用循环」已能驱动 readFile / writeFile 的基础上,本节为工作区再增加两类常用能力:局部改文件与看目录结构
程序在做什么: 工具集从 2 个扩为 4 个。模型可先 listDir 摸清 workspace 布局,用 readFile 读内容,用 writeFile 整文件覆盖,或用 editFile 在已有文本上做一次精确子串替换(oldText 必须与文件内容完全一致,且只替换第一次匹配)。用户说「把某文件里的 A 换成 B」时,典型路径是 readFile → editFile → 最终文字回复(见 5.4 测试)。
相对第 4 节的新增与调整:
EditFile:readFile读入 →includes(oldText)校验 →before.replace(oldText, newText)只改第一处 → 写回。若oldText未出现或出现多次但模型只给了片段,可能替换失败或只改一处;与「整文件重写」相比更适合小改动。ListDir:readdir+withFileTypes,用▣(目录)/▢(文件)格式化列表,按名称排序,便于模型探索 workspace。MODEL_TOOL_DEFINITIONS/toolHandlerByName:注册editFile、listDir的 schema 与 handler。
5.1 claude.js #
// 引入 Node.js 的 readline 模块,用于读取终端输入
const readline = require("readline");
// 加载 dotenv 配置文件,并允许覆盖已存在的环境变量
require("dotenv").config({ override: true });
// 引入 Node.js 的 path 模块,用于处理和转换文件路径
const path = require("path");
// 引入 Node.js 的 fs/promises 模块,提供基于 promise 的文件系统操作方法
const fsp = require("fs/promises");
// 从 openai 包中引入 OpenAI 类
const { OpenAI } = require("openai");
// 创建 OpenAI 客户端实例,使用 DeepSeek 的 API 密钥和自定义的 baseURL
const openaiClient = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: "https://api.deepseek.com",
});
// 定义代理的系统指令
const AGENT_SYSTEM_INSTRUCTION = `你是「Claude Code」—— 在受控 workspace 内协助用户读改代码、跑命令的助手。`;
// 创建一个 readline 接口,指定输入输出为标准输入输出
const rl = readline.createInterface({
input: process.stdin,// 标准输入流
output: process.stdout,// 标准输出流
terminal: true,// 是否启用终端模式
});
// 监听 SIGINT(通常是 Ctrl+C)事件,只监听一次
rl.once("SIGINT", () => {
// 打印中断提示信息,换两行
console.log("\n\n⌁ 已中断,下次见。");
// 关闭 readline 接口
rl.close();
// 退出当前进程,状态码为 0(正常退出)
process.exit(0);
});
// 定义工作区根目录,位于当前进程的 workspace 文件夹内
const WORKSPACE_ROOT = path.resolve(process.cwd(), "workspace");
// 判断目标路径是否是根目录的子目录或根目录本身
function isDescendantOrSameDirectory(candidatePath) {
// 解析 candidatePath 为绝对路径
const targetAbs = path.resolve(candidatePath);
// 如果目标路径和工作区根目录完全相同,则返回 true
if (WORKSPACE_ROOT === targetAbs) return true;
// 计算目标路径相对于工作区根目录的相对路径
const relative = path.relative(WORKSPACE_ROOT, targetAbs);
// 如果不是空字符串,不以 .. 开头,也不是绝对路径,则说明在工作区内,返回 true
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
}
// 在工作区内解析路径,并校验是否越界
function resolvePathInsideWorkspace(relativePath) {
// 将相对路径或带 ~ 路径解析为工作区下的绝对路径
const candidatePath = path.resolve(WORKSPACE_ROOT, relativePath);
// 如果解析后的路径不在工作区内,则抛出异常
if (!isDescendantOrSameDirectory(candidatePath))
throw new Error(`路径越界:${candidatePath}`);
// 返回校验通过的绝对路径
return candidatePath;
}
// 文本读取工具实现
class ReadText {
// 执行读取操作,参数为 { path }
async run({ path: relativePath }) {
try {
// 调用文件系统 API 读取文件内容
return await fsp.readFile(resolvePathInsideWorkspace(relativePath), "utf8");
} catch (err) {
// 如果找不到文件,返回自定义提示;否则返回异常原因
return err.code === "ENOENT" ? `找不到文件:${relativePath}` : `读取异常:${err.message}`;
}
}
}
// 文本写入工具实现
class WriteText {
// 执行写入操作,参数为 { path, content }
async run({ path: relativePath, content }) {
try {
// 解析目标文件的绝对路径
const absolute = resolvePathInsideWorkspace(relativePath);
// 如果父级目录不存在则递归创建
await fsp.mkdir(path.dirname(absolute), { recursive: true });
// 写入内容到文件,使用 UTF-8 编码
await fsp.writeFile(absolute, content, "utf8");
// 返回写入结果以及写入字节数
return `已落盘 ${Buffer.byteLength(content, "utf8")} 字节 → ${relativePath}`;
} catch (err) {
// 返回写入过程中的异常信息
return `写入异常:${err.message}`;
}
}
}
+// 定义 EditFile 类,用于精确修改文件内容
+class EditFile {
+ // 定义异步运行方法,接受文件路径、要替换的旧文本、以及新文本
+ async run({ path: relativePath, oldText, newText }) {
+ try {
+ // 解析出目标文件的绝对路径,确保在工作区内
+ const absolute = resolvePathInsideWorkspace(relativePath);
+ // 以 UTF-8 编码读取文件内容
+ const before = await fsp.readFile(absolute, "utf8");
+ // 检查文件内容中是否包含需要被替换的片段,如果没有则返回提示
+ if (!before.includes(oldText)) return "✖ 文件中未出现与 oldText 完全一致的片段";
+ // 将第一次出现的 oldText 替换为 newText 并写回原文件
+ await fsp.writeFile(absolute, before.replace(oldText, newText), "utf8");
+ // 返回替换成功的提示
+ return `✔ 已就地替换一处:${relativePath}`;
+ } catch (err) {
+ // 异常处理:如果找不到文件则返回对应提示,否则返回错误信息
+ return err.code === "ENOENT" ? `✖ 找不到文件:${relativePath}` : `✖ 编辑异常:${err.message}`;
+ }
+ }
+}
+// 定义 ListDir 类,用于列出目录内容
+class ListDir {
+ // 定义异步运行方法,参数为目录的相对路径
+ async run({ path: relativePath }) {
+ try {
+ // 解析出目标目录的绝对路径,确保在工作区内
+ const absolute = resolvePathInsideWorkspace(relativePath);
+ // 获取目标路径的文件状态信息
+ const stat = await fsp.stat(absolute);
+ // 判断目标路径是否为目录,如果不是则返回提示
+ if (!stat.isDirectory()) return `✖ 目标不是目录:${relativePath}`;
+ // 读取目录下的所有条目,获取类型信息
+ const entries = await fsp.readdir(absolute, { withFileTypes: true });
+ // 根据名称进行字典序排序
+ entries.sort((a, b) => a.name.localeCompare(b.name));
+ // 格式化每条目录项,区分文件与子目录
+ const rows = entries.map((ent) => `${ent.isDirectory() ? "目录:" : "文件:"} ${ent.name}`);
+ // 如果有内容则按行拼接返回,否则返回空目录提示
+ return rows.length ? rows.join("\n") : "(空目录)";
+ } catch (err) {
+ // 异常处理:目录不存在单独提示,否则返回错误信息
+ return err.code === "ENOENT" ? `✖ 目录不存在:${relativePath}` : `✖ 枚举失败:${err.message}`;
+ }
+ }
+}
// 工具的 Schema 定义,描述每个工具的用途和参数结构
const MODEL_TOOL_DEFINITIONS = [
{
// 声明工具类型为函数
type: "function",
function: {
// 工具名称为 readFile
name: "readFile",
// 工具功能描述
description: "以 UTF-8 读取工作区内文本文件。",
// 工具参数定义
parameters: {
// 参数类型为对象
type: "object",
// 参数属性为 path,字符串类型
properties: { path: { type: "string" } },
// path 字段为必需参数
required: ["path"],
},
},
},
{
// 声明写入工具类型为函数
type: "function",
function: {
// 工具名称为 writeFile
name: "writeFile",
// 工具描述:新建或覆盖写入工作区内文件
description: "在工作区内新建或覆盖写入文件。",
// 工具参数定义
parameters: {
type: "object",
properties: {
// path 为文件路径,字符串类型
path: { type: "string" },
// content 为要写入的文本内容,字符串类型
content: { type: "string" },
},
// path 和 content 都必需
required: ["path", "content"],
},
},
},
+ // 定义一个函数工具,用于精确子串替换现有文件内容
+ {
+ // 工具类型为 function
+ type: "function",
+ // function 字段,定义具体工具内容
+ function: {
+ // 工具名称为 editFile
+ name: "editFile",
+ // 工具描述,说明功能为仅替换一次完全匹配的原文
+ description: "对已有文本做一次精确子串替换;oldText 必须与文件内容完全一致。",
+ // parameters 字段,定义参数信息
+ parameters: {
+ // 参数类型为对象
+ type: "object",
+ // properties 字段,定义具体参数
+ properties: {
+ // path:待修改的文件路径,类型为字符串,描述为待修改文件路径
+ path: { type: "string", description: "待修改文件路径" },
+ // oldText:要被替换的原文,只有唯一一次匹配,类型为字符串
+ oldText: {
+ type: "string",
+ description: "要被替换掉的原文(唯一一次匹配)",
+ },
+ // newText:用于替换的新文本,类型为字符串,描述为替换后的新文本
+ newText: { type: "string", description: "替换后的新文本" },
+ },
+ // 这三个参数都是必需的
+ required: ["path", "oldText", "newText"],
+ },
+ },
+ },
+ // 定义一个函数工具,用于罗列某个目录下的条目(文件和子文件夹)
+ {
+ // 工具类型为 function
+ type: "function",
+ // function 字段,定义具体工具内容
+ function: {
+ // 工具名称为 listDir
+ name: "listDir",
+ // 工具描述,说明功能为罗列目录结构,可区分文件夹
+ description: "罗列目录下的条目(含子文件夹标记),用于快速摸清目录结构。",
+ // parameters 字段,定义参数信息
+ parameters: {
+ // 参数类型为对象
+ type: "object",
+ // properties 字段,定义具体参数
+ properties: {
+ // path:要罗列的目录路径,类型为字符串,路径相对于 workspace,描述为目录路径(相对 workspace)
+ path: { type: "string", description: "目录路径(相对 workspace)" },
+ },
+ // path 是必需参数
+ required: ["path"],
+ },
+ },
+ },
];
// 工具名称到对应实例的映射表
const toolHandlerByName = {
// 读取文件工具
readFile: new ReadText(),
// 写入文件工具
writeFile: new WriteText(),
+ // 替换文件工具
+ editFile: new EditFile(),
+ // 枚举目录工具
+ listDir: new ListDir(),
};
// 定义一个 ask 函数,返回一个 Promise,用于异步获取用户输入
const askLine = () =>
new Promise((resolve) => {
// 向控制台输出普通的提示符,并等待用户输入
rl.question("› ", resolve);
});
// 定义常量配置对象
const CONFIG = {
// 智能体允许的最大步数
agentMaxSteps: 100,
};
// 定义异步函数,用于执行一次工具调用
async function executeSingleToolCall(toolCallPayload) {
// 获取本次工具调用的名字
const name = toolCallPayload.function.name;
// 声明解析参数变量
let parsedArgs;
try {
// 尝试将工具调用的参数字符串解析为对象
parsedArgs = JSON.parse(toolCallPayload.function.arguments || "{}");
} catch {
// 如果解析失败,则设为空对象
parsedArgs = {};
}
// 控制台输出工具调用信息
console.log(`\n工具 ${name} 被调用`);
// 控制台输出工具的入参
console.log(`入参:${JSON.stringify(parsedArgs, null, 2)}`);
// 根据工具名获取对应处理器
const handler = toolHandlerByName[name];
// 判断是否有对应处理器,如有则执行,否则返回未实现提示
const textResult = handler
? await handler.run(parsedArgs)
: `✖ 未实现的工具:${name}`;
// 控制台输出工具调用的结果
console.log(`返回:${textResult}`);
// 构造并返回本次工具调用的完整回复对象
return {
// 设置角色为 tool
role: "tool",
// 工具调用对应的 id
tool_call_id: toolCallPayload.id,
// 工具名称
name,
// 工具返回的内容
content: textResult,
};
}
// 定义异步函数,驱动智能体连续推理直到文字回复或超步
async function runAgentUntilReplyOrMaxSteps(messages) {
// 初始化步数计数器
let step = 0;
// 循环,直到步数超过最大限制
while (step < CONFIG.agentMaxSteps) {
// 步数递增
step++;
// 控制台显示正在请求模型
console.log("\n请求模型中...");
// 向模型 API 发送消息请求回复
const completion = await openaiClient.chat.completions.create({
// 模型名称
model: "deepseek-v4-pro",
// 消息参数
messages: messages,
// 工具函数定义
tools: MODEL_TOOL_DEFINITIONS,
// 工具选择方式
tool_choice: "auto",
});
// 获取模型助手返回的消息对象
const assistantMessage = completion.choices[0].message;
// 控制台输出助手消息
console.log(JSON.stringify(assistantMessage, null, 2));
// 将助手消息加入对话消息历史
messages.push(assistantMessage);
// 获取本轮助手消息产生的工具调用
const calls = assistantMessage.tool_calls;
// 判断是否有工具调用,无则直接返回助手回复
if (!calls || calls.length === 0) {
// 返回助手文字回复
return assistantMessage;
}
// 并发执行每个工具调用,等待所有结果
const toolResponses = await Promise.all(calls.map(executeSingleToolCall));
// 将所有工具回复添加到消息历史
for (const row of toolResponses)
messages.push(row);
}
// 若达到最大步数仍未获得直接回复,则返回警告信息
return {
role: "assistant",
content: "⚠ 对话步数已达上限。",
};
}
// 定义异步主函数 main
async function main() {
//创建工作区目录
await fsp.mkdir(WORKSPACE_ROOT, { recursive: true });
// 无限循环,持续读取用户输入
for (; ;) {
// 创建一个消息列表,包含系统指令
const messages = [{ role: "system", content: AGENT_SYSTEM_INSTRUCTION }];
// 等待用户输入一行内容
const line = await askLine();
// 如果输入内容为空(只包含空白),则跳过本次循环
if (!line.trim()) continue;
// 如果输入的内容为 "q",则跳出循环,结束程序
if (line.trim() === "q") break;
// 将用户输入加入对话消息列表
messages.push({ role: "user", content: line });
// 调用 OpenAI API,发送所有消息,生成聊天回复
const reply = await runAgentUntilReplyOrMaxSteps(messages);
// 打印助手的回复到控制台
if (reply) console.log(`\nAssistant\n${reply.content}\n`);
}
// 关闭 readline 接口,释放资源
rl.close();
}
// 调用主函数 main,启动程序
main();5.2 时序图 #
下图以 5.4 测试(先读后改 msg.txt)为例,展示单次用户输入内多步工具链;若模型改用 listDir 或 writeFile,Handler 分支会换成对应工具,整体仍走第 4 节的 agent 循环。
5.3 msg.txt #
workspace/msg.txt
I love her5.4 测试 #
› 把msg.txt文件里的you替换为her
请求模型中...
{
"role": "assistant",
"content": "",
"reasoning_content": "用户想把 msg.txt 文件中的 \"you\" 替换为 \"her\"。让我先读取这个文件。",
"tool_calls": [
{
"index": 0,
"id": "call_00_9UylBi8MJAXgwH2lFcS33443",
"type": "function",
"function": {
"name": "readFile",
"arguments": "{\"path\": \"msg.txt\"}"
}
}
]
}
工具 readFile 被调用
入参:{
"path": "msg.txt"
}
返回:I love you
请求模型中...
{
"role": "assistant",
"content": "",
"reasoning_content": "The file contains \"I love you\". I need to replace \"you\" with \"her\". That would make it \"I love her\".",
"tool_calls": [
{
"index": 0,
"id": "call_00_rl1M5O569v8hkU4kqSeD9719",
"type": "function",
"function": {
"name": "editFile",
"arguments": "{\"path\": \"msg.txt\", \"oldText\": \"I love you\", \"newText\": \"I love her\"}"
}
}
]
}
工具 editFile 被调用
入参:{
"path": "msg.txt",
"oldText": "I love you",
"newText": "I love her"
}
返回:✔ 已就地替换一处:msg.txt
请求模型中...
{
"role": "assistant",
"content": "已完成替换。`msg.txt` 的内容从 **\"I love you\"** 变成了 **\"I love her\"**。",
"reasoning_content": "Done. The file now reads \"I love her\"."
}
Assistant
已完成替换。`msg.txt` 的内容从 **"I love you"** 变成了 **"I love her"**。6. 在工作区内执行 Shell 命令(runCommand) #
在第 5 节四类文件工具之上,本节增加 runCommand:模型可在 workspace/ 目录下通过系统 shell 同步执行一条命令行(如 Windows 的 dir、npm test),并把 stdout/stderr 汇总成字符串回传给对话,再由模型组织自然语言答复。
程序在做什么: 用户输入「执行 dir 命令」等意图后,模型发起 runCommand({ commandLine: "dir" });RunCommandLine 用 spawnSync 在 cwd: WORKSPACE_ROOT 下、shell: true 阻塞执行,将输出 Buffer 经 decodeResultBuffer 转成字符串(Windows 用 cp936,其它平台用 utf8),拼上退出码后作为 role: "tool" 的 content 返回。
相对第 5 节的新增点:
child_process.spawnSync:encoding: "buffer"保留原始字节,便于按系统代码页解码;maxBuffer: 10 * 1024 * 1024限制单次输出体积。decodeResultBuffer+iconv-lite:解决 Windows 中文控制台 GBK 输出被误当 UTF-8 解码产生乱码的问题。RunCommandLine:当前仅实现同步阻塞执行(run→runBlockingCommandLine);diff 中引入了spawn但尚未用于长驻/异步进程。MODEL_TOOL_DEFINITIONS/toolHandlerByName:注册runCommand及参数commandLine。ListDir展示文案:条目前缀由▣/▢改为「目录:」「文件:」(与第 5 节 diff 中的次要调整一致)。
运行前准备: 需安装依赖 npm install iconv-lite --save(若尚未安装)。命令在 workspace 为当前工作目录 下执行
6.1 安装依赖 #
npm install iconv-lite --save6.2 claude.js #
// 引入 Node.js 的 readline 模块,用于读取终端输入
const readline = require("readline");
// 加载 dotenv 配置文件,并允许覆盖已存在的环境变量
require("dotenv").config({ override: true });
// 引入 Node.js 的 path 模块,用于处理和转换文件路径
const path = require("path");
// 引入 Node.js 的 fs/promises 模块,提供基于 promise 的文件系统操作方法
const fsp = require("fs/promises");
// 从 openai 包中引入 OpenAI 类
const { OpenAI } = require("openai");
+// 引入 Node.js 的 child_process 模块,用于执行 shell 命令
+const { spawn, spawnSync } = require("child_process");
+// Windows 控制台常见为 GBK(cp936);用 UTF-8 解码会得到乱码
+const iconv = require("iconv-lite");
// 创建 OpenAI 客户端实例,使用 DeepSeek 的 API 密钥和自定义的 baseURL
const openaiClient = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: "https://api.deepseek.com",
});
// 定义代理的系统指令
const AGENT_SYSTEM_INSTRUCTION = `你是「Claude Code」—— 在受控 workspace 内协助用户读改代码、跑命令的助手。`;
// 创建一个 readline 接口,指定输入输出为标准输入输出
const rl = readline.createInterface({
input: process.stdin,// 标准输入流
output: process.stdout,// 标准输出流
terminal: true,// 是否启用终端模式
});
// 监听 SIGINT(通常是 Ctrl+C)事件,只监听一次
rl.once("SIGINT", () => {
// 打印中断提示信息,换两行
console.log("\n\n⌁ 已中断,下次见。");
// 关闭 readline 接口
rl.close();
// 退出当前进程,状态码为 0(正常退出)
process.exit(0);
});
// 定义工作区根目录,位于当前进程的 workspace 文件夹内
const WORKSPACE_ROOT = path.resolve(process.cwd(), "workspace");
// 判断目标路径是否是根目录的子目录或根目录本身
function isDescendantOrSameDirectory(candidatePath) {
// 解析 candidatePath 为绝对路径
const targetAbs = path.resolve(candidatePath);
// 如果目标路径和工作区根目录完全相同,则返回 true
if (WORKSPACE_ROOT === targetAbs) return true;
// 计算目标路径相对于工作区根目录的相对路径
const relative = path.relative(WORKSPACE_ROOT, targetAbs);
// 如果不是空字符串,不以 .. 开头,也不是绝对路径,则说明在工作区内,返回 true
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
}
// 在工作区内解析路径,并校验是否越界
function resolvePathInsideWorkspace(relativePath) {
// 将相对路径或带 ~ 路径解析为工作区下的绝对路径
const candidatePath = path.resolve(WORKSPACE_ROOT, relativePath);
// 如果解析后的路径不在工作区内,则抛出异常
if (!isDescendantOrSameDirectory(candidatePath))
throw new Error(`路径越界:${candidatePath}`);
// 返回校验通过的绝对路径
return candidatePath;
}
// 文本读取工具实现
class ReadText {
// 执行读取操作,参数为 { path }
async run({ path: relativePath }) {
try {
// 调用文件系统 API 读取文件内容
return await fsp.readFile(resolvePathInsideWorkspace(relativePath), "utf8");
} catch (err) {
// 如果找不到文件,返回自定义提示;否则返回异常原因
return err.code === "ENOENT" ? `找不到文件:${relativePath}` : `读取异常:${err.message}`;
}
}
}
// 文本写入工具实现
class WriteText {
// 执行写入操作,参数为 { path, content }
async run({ path: relativePath, content }) {
try {
// 解析目标文件的绝对路径
const absolute = resolvePathInsideWorkspace(relativePath);
// 如果父级目录不存在则递归创建
await fsp.mkdir(path.dirname(absolute), { recursive: true });
// 写入内容到文件,使用 UTF-8 编码
await fsp.writeFile(absolute, content, "utf8");
// 返回写入结果以及写入字节数
return `已落盘 ${Buffer.byteLength(content, "utf8")} 字节 → ${relativePath}`;
} catch (err) {
// 返回写入过程中的异常信息
return `写入异常:${err.message}`;
}
}
}
// 定义 EditFile 类,用于精确修改文件内容
class EditFile {
// 定义异步运行方法,接受文件路径、要替换的旧文本、以及新文本
async run({ path: relativePath, oldText, newText }) {
try {
// 解析出目标文件的绝对路径,确保在工作区内
const absolute = resolvePathInsideWorkspace(relativePath);
// 以 UTF-8 编码读取文件内容
const before = await fsp.readFile(absolute, "utf8");
// 检查文件内容中是否包含需要被替换的片段,如果没有则返回提示
if (!before.includes(oldText)) return "✖ 文件中未出现与 oldText 完全一致的片段";
// 将第一次出现的 oldText 替换为 newText 并写回原文件
await fsp.writeFile(absolute, before.replace(oldText, newText), "utf8");
// 返回替换成功的提示
return `✔ 已就地替换一处:${relativePath}`;
} catch (err) {
// 异常处理:如果找不到文件则返回对应提示,否则返回错误信息
return err.code === "ENOENT" ? `✖ 找不到文件:${relativePath}` : `✖ 编辑异常:${err.message}`;
}
}
}
// 定义 ListDir 类,用于列出目录内容
class ListDir {
// 定义异步运行方法,参数为目录的相对路径
async run({ path: relativePath }) {
try {
// 解析出目标目录的绝对路径,确保在工作区内
const absolute = resolvePathInsideWorkspace(relativePath);
// 获取目标路径的文件状态信息
const stat = await fsp.stat(absolute);
// 判断目标路径是否为目录,如果不是则返回提示
if (!stat.isDirectory()) return `✖ 目标不是目录:${relativePath}`;
// 读取目录下的所有条目,获取类型信息
const entries = await fsp.readdir(absolute, { withFileTypes: true });
// 根据名称进行字典序排序
entries.sort((a, b) => a.name.localeCompare(b.name));
// 格式化每条目录项,区分文件与子目录
const rows = entries.map((ent) => `${ent.isDirectory() ? "目录:" : "文件:"} ${ent.name}`);
// 如果有内容则按行拼接返回,否则返回空目录提示
return rows.length ? rows.join("\n") : "(空目录)";
} catch (err) {
// 异常处理:目录不存在单独提示,否则返回错误信息
return err.code === "ENOENT" ? `✖ 目录不存在:${relativePath}` : `✖ 枚举失败:${err.message}`;
}
}
}
// 工具的 Schema 定义,描述每个工具的用途和参数结构
const MODEL_TOOL_DEFINITIONS = [
{
// 声明工具类型为函数
type: "function",
function: {
// 工具名称为 readFile
name: "readFile",
// 工具功能描述
description: "以 UTF-8 读取工作区内文本文件。",
// 工具参数定义
parameters: {
// 参数类型为对象
type: "object",
// 参数属性为 path,字符串类型
properties: { path: { type: "string" } },
// path 字段为必需参数
required: ["path"],
},
},
},
{
// 声明写入工具类型为函数
type: "function",
function: {
// 工具名称为 writeFile
name: "writeFile",
// 工具描述:新建或覆盖写入工作区内文件
description: "在工作区内新建或覆盖写入文件。",
// 工具参数定义
parameters: {
type: "object",
properties: {
// path 为文件路径,字符串类型
path: { type: "string" },
// content 为要写入的文本内容,字符串类型
content: { type: "string" },
},
// path 和 content 都必需
required: ["path", "content"],
},
},
},
// 定义一个函数工具,用于精确子串替换现有文件内容
{
// 工具类型为 function
type: "function",
// function 字段,定义具体工具内容
function: {
// 工具名称为 editFile
name: "editFile",
// 工具描述,说明功能为仅替换一次完全匹配的原文
description: "对已有文本做一次精确子串替换;oldText 必须与文件内容完全一致。",
// parameters 字段,定义参数信息
parameters: {
// 参数类型为对象
type: "object",
// properties 字段,定义具体参数
properties: {
// path:待修改的文件路径,类型为字符串,描述为待修改文件路径
path: { type: "string", description: "待修改文件路径" },
// oldText:要被替换的原文,只有唯一一次匹配,类型为字符串
oldText: {
type: "string",
description: "要被替换掉的原文(唯一一次匹配)",
},
// newText:用于替换的新文本,类型为字符串,描述为替换后的新文本
newText: { type: "string", description: "替换后的新文本" },
},
// 这三个参数都是必需的
required: ["path", "oldText", "newText"],
},
},
},
// 定义一个函数工具,用于罗列某个目录下的条目(文件和子文件夹)
{
// 工具类型为 function
type: "function",
// function 字段,定义具体工具内容
function: {
// 工具名称为 listDir
name: "listDir",
// 工具描述,说明功能为罗列目录结构,可区分文件夹
description: "罗列目录下的条目(含子文件夹标记),用于快速摸清目录结构。",
// parameters 字段,定义参数信息
parameters: {
// 参数类型为对象
type: "object",
// properties 字段,定义具体参数
properties: {
// path:要罗列的目录路径,类型为字符串,路径相对于 workspace,描述为目录路径(相对 workspace)
path: { type: "string", description: "目录路径(相对 workspace)" },
},
// path 是必需参数
required: ["path"],
},
},
},
+ // 定义工具对象,表示类型为 function
+ {
+ // 设置 type 字段为 "function",表示此为函数型工具
+ type: "function",
+ // function 字段,定义具体的函数内容
+ function: {
+ // name 字段,工具名称为 runCommand
+ name: "runCommand",
+ // description 字段,对工具功能做简要描述
+ description:
+ // 描述字符串:在 shell 中执行命令;短命令阻塞至结束...
+ "在 shell 中执行命令;短命令阻塞至结束\n" +
+ "重要:命令会在当前工作目录执行,不要使用 cd 切换目录\n"+
+ "常规:install / build / test 等同步执行,stdout/stderr 汇总返回。\n",
+ // parameters 字段,定义参数类型和要求
+ parameters: {
+ // type 指定参数为对象形式
+ type: "object",
+ // properties 字段,详细定义各参数
+ properties: {
+ // commandLine 参数,定义为字符串类型
+ commandLine: {
+ // type 指明参数为字符串
+ type: "string",
+ // description 说明 commandLine 的用途
+ description: "Shell 命令行(例如:npm install)",
+ }
+ },
+ // required 字段,列出必传参数
+ required: ["commandLine"],
+ },
+ },
+ },
];
+// 将子进程的 stdout/stderr Buffer 解码为可读字符串(Windows 中文环境多为 cp936 编码)
+function decodeResultBuffer(buf) {
+ // 若 Buffer 为空或长度为0,直接返回空字符串
+ if (!buf || buf.length === 0) return "";
+ // 若当前系统是 Windows,则以 cp936 编码解码
+ if (process.platform === "win32") return iconv.decode(buf, "cp936");
+ // 其他系统则以 utf8 解码
+ return buf.toString("utf8");
+}
+// 工具类:用于在工作空间目录中执行 commandLine 命令
+class RunCommandLine {
+ // 异步运行 commandLine 命令,接收包含 commandLine 字段的对象参数
+ async run({ commandLine }) {
+ // 调用同步(阻塞)方式运行 commandLine 命令的方法
+ return this.runBlockingCommandLine(commandLine)
+ }
+ // 同步运行 commandLine 命令,并返回命令执行的文本结果
+ runBlockingCommandLine(commandLine) {
+ // 使用 spawnSync 同步执行命令行
+ const result = spawnSync(commandLine, {
+ // 指定使用 shell 运行 commandLine
+ shell: true,
+ // 指定工作目录为工作空间根目录
+ cwd: WORKSPACE_ROOT,
+ // 输出按二进制 Buffer 格式返回
+ encoding: "buffer",
+ // 最大缓冲区 10MB
+ maxBuffer: 10 * 1024 * 1024,
+ });
+ // 命令执行超时,则返回超时提示
+ if (result.error?.code === "ETIMEDOUT") return `✖ 同步命令超时:${commandLine}`;
+ // 若 stdout 有内容则解码,否则返回“stdout 为空”
+ let merged = result.stdout?.length ? decodeResultBuffer(result.stdout) : "(stdout 为空)";
+ // 若 stderr 有内容,则拼接 stderr 的解码结果
+ if (result.stderr?.length)
+ merged += `\n【stderr】\n${decodeResultBuffer(result.stderr)}`;
+ // 如果命令退出码非0,显示错误提示和全部输出,否则显示成功提示和输出
+ return result.status !== 0
+ ? `退出码 ${result.status}\n${merged}`
+ : `命令完成\n${merged}`;
+ }
+}
// 工具名称到对应实例的映射表
const toolHandlerByName = {
// 读取文件工具
readFile: new ReadText(),
// 写入文件工具
writeFile: new WriteText(),
// 替换文件工具
editFile: new EditFile(),
// 枚举目录工具
listDir: new ListDir(),
+ // 执行命令工具
+ runCommand: new RunCommandLine(),
};
// 定义一个 ask 函数,返回一个 Promise,用于异步获取用户输入
const askLine = () =>
new Promise((resolve) => {
// 向控制台输出普通的提示符,并等待用户输入
rl.question("› ", resolve);
});
// 定义常量配置对象
const CONFIG = {
// 智能体允许的最大步数
agentMaxSteps: 100,
};
// 定义异步函数,用于执行一次工具调用
async function executeSingleToolCall(toolCallPayload) {
// 获取本次工具调用的名字
const name = toolCallPayload.function.name;
// 声明解析参数变量
let parsedArgs;
try {
// 尝试将工具调用的参数字符串解析为对象
parsedArgs = JSON.parse(toolCallPayload.function.arguments || "{}");
} catch {
// 如果解析失败,则设为空对象
parsedArgs = {};
}
// 控制台输出工具调用信息
console.log(`\n工具 ${name} 被调用`);
// 控制台输出工具的入参
console.log(`入参:${JSON.stringify(parsedArgs, null, 2)}`);
// 根据工具名获取对应处理器
const handler = toolHandlerByName[name];
// 判断是否有对应处理器,如有则执行,否则返回未实现提示
const textResult = handler
? await handler.run(parsedArgs)
: `✖ 未实现的工具:${name}`;
// 控制台输出工具调用的结果
console.log(`返回:${textResult}`);
// 构造并返回本次工具调用的完整回复对象
return {
// 设置角色为 tool
role: "tool",
// 工具调用对应的 id
tool_call_id: toolCallPayload.id,
// 工具名称
name,
// 工具返回的内容
content: textResult,
};
}
// 定义异步函数,驱动智能体连续推理直到文字回复或超步
async function runAgentUntilReplyOrMaxSteps(messages) {
// 初始化步数计数器
let step = 0;
// 循环,直到步数超过最大限制
while (step < CONFIG.agentMaxSteps) {
// 步数递增
step++;
// 控制台显示正在请求模型
console.log("\n请求模型中...");
// 向模型 API 发送消息请求回复
const completion = await openaiClient.chat.completions.create({
// 模型名称
model: "deepseek-v4-pro",
// 消息参数
messages: messages,
// 工具函数定义
tools: MODEL_TOOL_DEFINITIONS,
// 工具选择方式
tool_choice: "auto",
});
// 获取模型助手返回的消息对象
const assistantMessage = completion.choices[0].message;
// 控制台输出助手消息
console.log(JSON.stringify(assistantMessage, null, 2));
// 将助手消息加入对话消息历史
messages.push(assistantMessage);
// 获取本轮助手消息产生的工具调用
const calls = assistantMessage.tool_calls;
// 判断是否有工具调用,无则直接返回助手回复
if (!calls || calls.length === 0) {
// 返回助手文字回复
return assistantMessage;
}
// 并发执行每个工具调用,等待所有结果
const toolResponses = await Promise.all(calls.map(executeSingleToolCall));
// 将所有工具回复添加到消息历史
for (const row of toolResponses)
messages.push(row);
}
// 若达到最大步数仍未获得直接回复,则返回警告信息
return {
role: "assistant",
content: "⚠ 对话步数已达上限。",
};
}
// 定义异步主函数 main
async function main() {
//创建工作区目录
await fsp.mkdir(WORKSPACE_ROOT, { recursive: true });
// 无限循环,持续读取用户输入
for (; ;) {
// 创建一个消息列表,包含系统指令
const messages = [{ role: "system", content: AGENT_SYSTEM_INSTRUCTION }];
// 等待用户输入一行内容
const line = await askLine();
// 如果输入内容为空(只包含空白),则跳过本次循环
if (!line.trim()) continue;
// 如果输入的内容为 "q",则跳出循环,结束程序
if (line.trim() === "q") break;
// 将用户输入加入对话消息列表
messages.push({ role: "user", content: line });
// 调用 OpenAI API,发送所有消息,生成聊天回复
const reply = await runAgentUntilReplyOrMaxSteps(messages);
// 打印助手的回复到控制台
if (reply) console.log(`\nAssistant\n${reply.content}\n`);
}
// 关闭 readline 接口,释放资源
rl.close();
}
// 调用主函数 main,启动程序
main();6.3 时序图 #
下图以 6.4 测试(执行 dir)为例:单次用户输入内,模型一次 runCommand 后给出文字总结。
6.4 测试 #
› 执行dir命令
请求模型中...
{
"role": "assistant",
"content": "",
"reasoning_content": "用户想让我执行 `dir` 命令(Windows 中列出目录内容的命令)。让我执行它。",
"tool_calls": [
{
"index": 0,
"id": "call_00_bu3oknLgzw5ySoKJc3eh4502",
"type": "function",
"function": {
"name": "runCommand",
"arguments": "{\"commandLine\": \"dir\"}"
}
}
]
}
工具 runCommand 被调用
入参:{
"commandLine": "dir"
}
返回:✔ 命令完成
驱动器 D 中的卷是 软件
卷的序列号是 8001-E599
D:\aprepare\testcode\workspace 的目录
2026-05-16 14:28 <DIR> .
2026-05-16 14:28 <DIR> ..
2026-05-16 15:34 10 msg.txt
1 个文件 10 字节
2 个目录 98,774,122,496 可用字节
请求模型中...
{
"role": "assistant",
"content": "`dir` 命令已执行。当前工作区 `D:\\aprepare\\testcode\\workspace` 下只有:\n\n- **文件**:`msg.txt`(10 字 节)\n- **可用磁盘空间**:约 98.8 GB\n\n除此之外没有其他文件或子目录。需要我进一步查看 `msg.txt` 的内容吗?",
"reasoning_content": "`dir` 命令已执行。工作区目录 `D:\\aprepare\\testcode\\workspace` 下目前只有一个文件 `msg.txt`(10 字节),以及 `.` 和 `..` 两个目录项。可用空间约 98.8 GB。"
}
Assistant
`dir` 命令已执行。当前工作区 `D:\aprepare\testcode\workspace` 下只有:
- **文件**:`msg.txt`(10 字节)
- **可用磁盘空间**:约 98.8 GB7. 长驻命令后台运行:spawn 与启动段输出采集 #
第 6 节的 runCommand 一律用 spawnSync 阻塞到命令结束,适合 dir、npm test 等短命令;若执行 node server.js 这类不会自行退出的 HTTP 服务,同步调用会一直卡住。本节在 workspace/ 中放入两个最小 HTTP 示例(server1.js / server2.js),并改造 RunCommandLine:按命令行启发式判断是否走后台分支。
程序在做什么: looksLikeLongRunningCommand 检查命令是否包含 server、dev、start、watch 等关键字(见 LONG_RUNNING_HINTS)。命中则 spawn 子进程(stdio 管道、cwd: workspace),用 bindStreamToBuffers 收集启动阶段的 stdout/stderr,等待 backgroundWarmupMs(默认 8 秒) 后立刻向模型返回 PID 与「启动段输出」,主进程不等待服务退出。未命中仍走第 6 节的 spawnSync 同步路径。
配套示例文件:
server1.js:监听1000,响应正文"1000"。server2.js:监听2000,响应正文"2000"。
相对第 6 节的新增点:
looksLikeLongRunningCommand+LONG_RUNNING_HINTS:用子串匹配区分「可能常驻」与「一次性」命令(启发式,有误判可能,例如普通命令里碰巧含start)。runBackgroundCommandLine:spawn非阻塞拉起;delay(CONFIG.backgroundWarmupMs)给服务留出监听端口、打印日志的时间;返回字符串含 PID,便于用户后续自行结束进程。bindStreamToBuffers:流式追加 chunk,end时压入null标记(当前汇总用Buffer.concat(buffers).toString("utf8")
7.1 server1.js #
workspace/server1.js
// 引入 Node.js 内置的 http 模块
const http = require('http');
// 定义服务器监听的端口号为 1000
const PORT = 1000;
// 创建一个 HTTP 服务器实例
const server = http.createServer((req, res) => {
// 设置响应状态码为 200(成功)
res.statusCode = 200;
// 设置响应头,指定内容类型为纯文本
res.setHeader('Content-Type', 'text/plain');
// 结束响应并返回消息内容
res.end('1000');
});
// 启动服务器,监听指定端口
server.listen(PORT, () => {
// 服务器启动后在控制台输出提示信息
console.log(`Server running at http://localhost:${PORT}/`);
});7.2 server2.js #
workspace/server2.js
// 引入 Node.js 内置的 http 模块
const http = require('http');
// 定义服务器监听的端口号为 2000
const PORT = 2000;
// 创建一个 HTTP 服务器实例
const server = http.createServer((req, res) => {
// 设置响应状态码为 200(成功)
res.statusCode = 200;
// 设置响应头,指定内容类型为纯文本
res.setHeader('Content-Type', 'text/plain');
// 结束响应并返回消息内容
res.end('2000');
});
// 启动服务器,监听指定端口
server.listen(PORT, () => {
// 服务器启动后在控制台输出提示信息
console.log(`Server running at http://localhost:${PORT}/`);
});7.3 claude.js #
// 引入 Node.js 的 readline 模块,用于读取终端输入
const readline = require("readline");
// 加载 dotenv 配置文件,并允许覆盖已存在的环境变量
require("dotenv").config({ override: true });
// 引入 Node.js 的 path 模块,用于处理和转换文件路径
const path = require("path");
// 引入 Node.js 的 fs/promises 模块,提供基于 promise 的文件系统操作方法
const fsp = require("fs/promises");
// 从 openai 包中引入 OpenAI 类
const { OpenAI } = require("openai");
// 引入 Node.js 的 child_process 模块,用于执行 shell 命令
const { spawn, spawnSync } = require("child_process");
// Windows 控制台常见为 GBK(cp936);用 UTF-8 解码会得到乱码
const iconv = require("iconv-lite");
// 创建 OpenAI 客户端实例,使用 DeepSeek 的 API 密钥和自定义的 baseURL
const openaiClient = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: "https://api.deepseek.com",
});
// 定义代理的系统指令
const AGENT_SYSTEM_INSTRUCTION = `你是「Claude Code」—— 在受控 workspace 内协助用户读改代码、跑命令的助手。`;
// 创建一个 readline 接口,指定输入输出为标准输入输出
const rl = readline.createInterface({
input: process.stdin,// 标准输入流
output: process.stdout,// 标准输出流
terminal: true,// 是否启用终端模式
});
// 监听 SIGINT(通常是 Ctrl+C)事件,只监听一次
rl.once("SIGINT", () => {
// 打印中断提示信息,换两行
console.log("\n\n⌁ 已中断,下次见。");
// 关闭 readline 接口
rl.close();
// 退出当前进程,状态码为 0(正常退出)
process.exit(0);
});
// 定义工作区根目录,位于当前进程的 workspace 文件夹内
const WORKSPACE_ROOT = path.resolve(process.cwd(), "workspace");
// 判断目标路径是否是根目录的子目录或根目录本身
function isDescendantOrSameDirectory(candidatePath) {
// 解析 candidatePath 为绝对路径
const targetAbs = path.resolve(candidatePath);
// 如果目标路径和工作区根目录完全相同,则返回 true
if (WORKSPACE_ROOT === targetAbs) return true;
// 计算目标路径相对于工作区根目录的相对路径
const relative = path.relative(WORKSPACE_ROOT, targetAbs);
// 如果不是空字符串,不以 .. 开头,也不是绝对路径,则说明在工作区内,返回 true
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
}
// 在工作区内解析路径,并校验是否越界
function resolvePathInsideWorkspace(relativePath) {
// 将相对路径或带 ~ 路径解析为工作区下的绝对路径
const candidatePath = path.resolve(WORKSPACE_ROOT, relativePath);
// 如果解析后的路径不在工作区内,则抛出异常
if (!isDescendantOrSameDirectory(candidatePath))
throw new Error(`路径越界:${candidatePath}`);
// 返回校验通过的绝对路径
return candidatePath;
}
// 文本读取工具实现
class ReadText {
// 执行读取操作,参数为 { path }
async run({ path: relativePath }) {
try {
// 调用文件系统 API 读取文件内容
return await fsp.readFile(resolvePathInsideWorkspace(relativePath), "utf8");
} catch (err) {
// 如果找不到文件,返回自定义提示;否则返回异常原因
return err.code === "ENOENT" ? `找不到文件:${relativePath}` : `读取异常:${err.message}`;
}
}
}
// 文本写入工具实现
class WriteText {
// 执行写入操作,参数为 { path, content }
async run({ path: relativePath, content }) {
try {
// 解析目标文件的绝对路径
const absolute = resolvePathInsideWorkspace(relativePath);
// 如果父级目录不存在则递归创建
await fsp.mkdir(path.dirname(absolute), { recursive: true });
// 写入内容到文件,使用 UTF-8 编码
await fsp.writeFile(absolute, content, "utf8");
// 返回写入结果以及写入字节数
return `已落盘 ${Buffer.byteLength(content, "utf8")} 字节 → ${relativePath}`;
} catch (err) {
// 返回写入过程中的异常信息
return `写入异常:${err.message}`;
}
}
}
// 定义 EditFile 类,用于精确修改文件内容
class EditFile {
// 定义异步运行方法,接受文件路径、要替换的旧文本、以及新文本
async run({ path: relativePath, oldText, newText }) {
try {
// 解析出目标文件的绝对路径,确保在工作区内
const absolute = resolvePathInsideWorkspace(relativePath);
// 以 UTF-8 编码读取文件内容
const before = await fsp.readFile(absolute, "utf8");
// 检查文件内容中是否包含需要被替换的片段,如果没有则返回提示
if (!before.includes(oldText)) return "✖ 文件中未出现与 oldText 完全一致的片段";
// 将第一次出现的 oldText 替换为 newText 并写回原文件
await fsp.writeFile(absolute, before.replace(oldText, newText), "utf8");
// 返回替换成功的提示
return `✔ 已就地替换一处:${relativePath}`;
} catch (err) {
// 异常处理:如果找不到文件则返回对应提示,否则返回错误信息
return err.code === "ENOENT" ? `✖ 找不到文件:${relativePath}` : `✖ 编辑异常:${err.message}`;
}
}
}
// 定义 ListDir 类,用于列出目录内容
class ListDir {
// 定义异步运行方法,参数为目录的相对路径
async run({ path: relativePath }) {
try {
// 解析出目标目录的绝对路径,确保在工作区内
const absolute = resolvePathInsideWorkspace(relativePath);
// 获取目标路径的文件状态信息
const stat = await fsp.stat(absolute);
// 判断目标路径是否为目录,如果不是则返回提示
if (!stat.isDirectory()) return `✖ 目标不是目录:${relativePath}`;
// 读取目录下的所有条目,获取类型信息
const entries = await fsp.readdir(absolute, { withFileTypes: true });
// 根据名称进行字典序排序
entries.sort((a, b) => a.name.localeCompare(b.name));
// 格式化每条目录项,区分文件与子目录
const rows = entries.map((ent) => `${ent.isDirectory() ? "目录:" : "文件:"} ${ent.name}`);
// 如果有内容则按行拼接返回,否则返回空目录提示
return rows.length ? rows.join("\n") : "(空目录)";
} catch (err) {
// 异常处理:目录不存在单独提示,否则返回错误信息
return err.code === "ENOENT" ? `✖ 目录不存在:${relativePath}` : `✖ 枚举失败:${err.message}`;
}
}
}
// 工具的 Schema 定义,描述每个工具的用途和参数结构
const MODEL_TOOL_DEFINITIONS = [
{
// 声明工具类型为函数
type: "function",
function: {
// 工具名称为 readFile
name: "readFile",
// 工具功能描述
description: "以 UTF-8 读取工作区内文本文件。",
// 工具参数定义
parameters: {
// 参数类型为对象
type: "object",
// 参数属性为 path,字符串类型
properties: { path: { type: "string" } },
// path 字段为必需参数
required: ["path"],
},
},
},
{
// 声明写入工具类型为函数
type: "function",
function: {
// 工具名称为 writeFile
name: "writeFile",
// 工具描述:新建或覆盖写入工作区内文件
description: "在工作区内新建或覆盖写入文件。",
// 工具参数定义
parameters: {
type: "object",
properties: {
// path 为文件路径,字符串类型
path: { type: "string" },
// content 为要写入的文本内容,字符串类型
content: { type: "string" },
},
// path 和 content 都必需
required: ["path", "content"],
},
},
},
// 定义一个函数工具,用于精确子串替换现有文件内容
{
// 工具类型为 function
type: "function",
// function 字段,定义具体工具内容
function: {
// 工具名称为 editFile
name: "editFile",
// 工具描述,说明功能为仅替换一次完全匹配的原文
description: "对已有文本做一次精确子串替换;oldText 必须与文件内容完全一致。",
// parameters 字段,定义参数信息
parameters: {
// 参数类型为对象
type: "object",
// properties 字段,定义具体参数
properties: {
// path:待修改的文件路径,类型为字符串,描述为待修改文件路径
path: { type: "string", description: "待修改文件路径" },
// oldText:要被替换的原文,只有唯一一次匹配,类型为字符串
oldText: {
type: "string",
description: "要被替换掉的原文(唯一一次匹配)",
},
// newText:用于替换的新文本,类型为字符串,描述为替换后的新文本
newText: { type: "string", description: "替换后的新文本" },
},
// 这三个参数都是必需的
required: ["path", "oldText", "newText"],
},
},
},
// 定义一个函数工具,用于罗列某个目录下的条目(文件和子文件夹)
{
// 工具类型为 function
type: "function",
// function 字段,定义具体工具内容
function: {
// 工具名称为 listDir
name: "listDir",
// 工具描述,说明功能为罗列目录结构,可区分文件夹
description: "罗列目录下的条目(含子文件夹标记),用于快速摸清目录结构。",
// parameters 字段,定义参数信息
parameters: {
// 参数类型为对象
type: "object",
// properties 字段,定义具体参数
properties: {
// path:要罗列的目录路径,类型为字符串,路径相对于 workspace,描述为目录路径(相对 workspace)
path: { type: "string", description: "目录路径(相对 workspace)" },
},
// path 是必需参数
required: ["path"],
},
},
},
// 定义工具对象,表示类型为 function
{
// 设置 type 字段为 "function",表示此为函数型工具
type: "function",
// function 字段,定义具体的函数内容
function: {
// name 字段,工具名称为 runCommand
name: "runCommand",
// description 字段,对工具功能做简要描述
description:
// 描述字符串:在 shell 中执行命令;短命令阻塞至结束...
"在 shell 中执行命令;短命令阻塞至结束\n" +
"重要:命令会在当前工作目录执行,不要使用 cd 切换目录\n"+
"常规:install / build / test 等同步执行,stdout/stderr 汇总返回。\n",
// parameters 字段,定义参数类型和要求
parameters: {
// type 指定参数为对象形式
type: "object",
// properties 字段,详细定义各参数
properties: {
// commandLine 参数,定义为字符串类型
commandLine: {
// type 指明参数为字符串
type: "string",
// description 说明 commandLine 的用途
description: "Shell 命令行(例如:npm install)",
}
},
// required 字段,列出必传参数
required: ["commandLine"],
},
},
},
];
// 将子进程的 stdout/stderr Buffer 解码为可读字符串(Windows 中文环境多为 cp936 编码)
function decodeResultBuffer(buf) {
// 若 Buffer 为空或长度为0,直接返回空字符串
if (!buf || buf.length === 0) return "";
// 若当前系统是 Windows,则以 cp936 编码解码
if (process.platform === "win32") return iconv.decode(buf, "cp936");
// 其他系统则以 utf8 解码
return buf.toString("utf8");
}
+// 定义常量配置对象
+const CONFIG = {
+ // 智能体允许的最大步数
+ agentMaxSteps: 100,
+ // 后台进程预热时间 8秒
+ backgroundWarmupMs: 8000,
+};
+// 定义 delay 函数,实现异步延迟
+function delay(ms) {
+ // 返回一个 Promise,在指定毫秒数(ms)后调用 resolve,达到延迟效果
+ return new Promise((resolve) => setTimeout(resolve, ms));
+// 定义一个异步延迟函数,参数为毫秒数
+}
+// 包含了会让开发环境长时间运行的命令字符串
+const LONG_RUNNING_HINTS = [
+ // "dev" 表示开发模式
+ "dev",
+ // "start" 表示启动服务
+ "start",
+ // "serve" 表示启动服务器
+ "serve",
+ // "server" 表示启动开发服务器
+ "server",
+ // "watch" 表示监听文件变更
+ "watch",
+ // "run server" 表示运行服务器
+ "run server",
+ // "runserver" 另一种写法,运行服务器
+ "runserver",
+ // "preview" 表示预览模式
+ "preview",
+ // "nodemon" 表示自动重启开发服务器
+ "nodemon",
+ // "uvicorn" 表示 Python asgi 服务器
+ "uvicorn",
+ // "gunicorn" 表示 Python wsgi 服务器
+ "gunicorn",
+ // "flask run" 表示 Flask 的运行命令
+ "flask run",
+ // "vite" 表示前端构建工具
+ "vite",
+ // "webpack" 表示前端构建工具
+ "webpack",
+ // "--watch" 表示监听参数
+ "--watch",
+ // "--hot" 表示热更新参数
+ "--hot"
+];
+// 定义函数,用于判断给定命令行是否为疑似长时间运行的命令
+function looksLikeLongRunningCommand(commandLine) {
+ // 去除命令行字符串首尾空白,并转为小写,标准化处理
+ const normalized = commandLine.trim().toLowerCase();
+ // 在长时间运行命令关键字集合 LONG_RUNNING_HINTS 中查找,判断 normalized 是否包含任一关键字
+ return LONG_RUNNING_HINTS.some((hint) => normalized.includes(hint));
+}
+// 定义 bindStreamToBuffers 函数,将可读流绑定到缓冲区
+function bindStreamToBuffers(readableStream, buffers) {
+ // 监听 "data" 事件,每收到一段数据时被调用
+ readableStream.on("data", (chunk) => {
+ // 将收到的数据块 chunk 压入缓冲区 buffers
+ buffers.push(chunk);
+ });
+ // 监听 "end" 事件,当数据流结束时被调用
+ readableStream.on("end", () => {
+ // 将 null 压入缓冲区 buffers,表示流已结束
+ buffers.push(null);
+ });
+}
// 工具类:用于在工作空间目录中执行 commandLine 命令
class RunCommandLine {
// 异步运行 commandLine 命令,接收包含 commandLine 字段的对象参数
async run({ commandLine }) {
+ // 判断命令行是否看起来像是长时间运行的命令
+ if (looksLikeLongRunningCommand(commandLine)) {
+ // 如果是,则调用异步方法在后台运行命令行
+ return this.runBackgroundCommandLine(commandLine);
+ }
+ // 如果不是长时间运行的命令,则调用同步(阻塞)方法运行命令行
return this.runBlockingCommandLine(commandLine)
}
// 同步运行 commandLine 命令,并返回命令执行的文本结果
runBlockingCommandLine(commandLine) {
// 使用 spawnSync 同步执行命令行
const result = spawnSync(commandLine, {
// 指定使用 shell 运行 commandLine
shell: true,
// 指定工作目录为工作空间根目录
cwd: WORKSPACE_ROOT,
// 输出按二进制 Buffer 格式返回
encoding: "buffer",
// 最大缓冲区 10MB
maxBuffer: 10 * 1024 * 1024,
});
// 命令执行超时,则返回超时提示
if (result.error?.code === "ETIMEDOUT") return `✖ 同步命令超时:${commandLine}`;
// 若 stdout 有内容则解码,否则返回“stdout 为空”
let merged = result.stdout?.length ? decodeResultBuffer(result.stdout) : "(stdout 为空)";
// 若 stderr 有内容,则拼接 stderr 的解码结果
if (result.stderr?.length)
merged += `\n【stderr】\n${decodeResultBuffer(result.stderr)}`;
// 如果命令退出码非0,显示错误提示和全部输出,否则显示成功提示和输出
return result.status !== 0
? `退出码 ${result.status}\n${merged}`
: `命令完成\n${merged}`;
}
+ // 定义一个异步方法,用于在后台启动 commandLine 命令
+ async runBackgroundCommandLine(commandLine) {
+ // 创建一个用于存储子进程输出的缓冲区数组
+ const buffers = [];
+ // 使用 spawn 方法启动一个新的子进程,传入命令行参数和配置对象
+ const child = spawn(commandLine, {
+ // 指定使用 shell 运行 commandLine 命令
+ shell: true,
+ // 指定子进程的工作目录为工作空间根目录
+ cwd: WORKSPACE_ROOT,
+ // 配置子进程的标准输入被忽略,标准输出和标准错误重定向到管道
+ stdio: ["ignore", "pipe", "pipe"],
+ });
+ // 绑定子进程的标准输出流到缓冲区数组
+ bindStreamToBuffers(child.stdout, buffers);
+ // 绑定子进程的标准错误流到缓冲区数组
+ bindStreamToBuffers(child.stderr, buffers);
+ // 获取子进程的进程ID(PID)
+ const pid = child.pid;
+ // 等待后台进程初始化,延迟指定的时间(用于预热/启动)
+ await delay(CONFIG.backgroundWarmupMs);
+ // 返回启动成功的信息,包含进程ID、命令和启动阶段的输出信息
+ return `已在后台拉起子进程\n PID:${pid}\n 命令:${commandLine}\n 启动段输出:\n${Buffer.concat(buffers).toString("utf8") || "(尚无输出)"}`;
+ }
}
// 工具名称到对应实例的映射表
const toolHandlerByName = {
// 读取文件工具
readFile: new ReadText(),
// 写入文件工具
writeFile: new WriteText(),
// 替换文件工具
editFile: new EditFile(),
// 枚举目录工具
listDir: new ListDir(),
// 执行命令工具
runCommand: new RunCommandLine(),
};
// 定义一个 ask 函数,返回一个 Promise,用于异步获取用户输入
const askLine = () =>
new Promise((resolve) => {
// 向控制台输出普通的提示符,并等待用户输入
rl.question("› ", resolve);
});
// 定义异步函数,用于执行一次工具调用
async function executeSingleToolCall(toolCallPayload) {
// 获取本次工具调用的名字
const name = toolCallPayload.function.name;
// 声明解析参数变量
let parsedArgs;
try {
// 尝试将工具调用的参数字符串解析为对象
parsedArgs = JSON.parse(toolCallPayload.function.arguments || "{}");
} catch {
// 如果解析失败,则设为空对象
parsedArgs = {};
}
// 控制台输出工具调用信息
console.log(`\n工具 ${name} 被调用`);
// 控制台输出工具的入参
console.log(`入参:${JSON.stringify(parsedArgs, null, 2)}`);
// 根据工具名获取对应处理器
const handler = toolHandlerByName[name];
// 判断是否有对应处理器,如有则执行,否则返回未实现提示
const textResult = handler
? await handler.run(parsedArgs)
: `✖ 未实现的工具:${name}`;
// 控制台输出工具调用的结果
console.log(`返回:${textResult}`);
// 构造并返回本次工具调用的完整回复对象
return {
// 设置角色为 tool
role: "tool",
// 工具调用对应的 id
tool_call_id: toolCallPayload.id,
// 工具名称
name,
// 工具返回的内容
content: textResult,
};
}
// 定义异步函数,驱动智能体连续推理直到文字回复或超步
async function runAgentUntilReplyOrMaxSteps(messages) {
// 初始化步数计数器
let step = 0;
// 循环,直到步数超过最大限制
while (step < CONFIG.agentMaxSteps) {
// 步数递增
step++;
// 控制台显示正在请求模型
console.log("\n请求模型中...");
// 向模型 API 发送消息请求回复
const completion = await openaiClient.chat.completions.create({
// 模型名称
model: "deepseek-v4-pro",
// 消息参数
messages: messages,
// 工具函数定义
tools: MODEL_TOOL_DEFINITIONS,
// 工具选择方式
tool_choice: "auto",
});
// 获取模型助手返回的消息对象
const assistantMessage = completion.choices[0].message;
// 控制台输出助手消息
console.log(JSON.stringify(assistantMessage, null, 2));
// 将助手消息加入对话消息历史
messages.push(assistantMessage);
// 获取本轮助手消息产生的工具调用
const calls = assistantMessage.tool_calls;
// 判断是否有工具调用,无则直接返回助手回复
if (!calls || calls.length === 0) {
// 返回助手文字回复
return assistantMessage;
}
// 并发执行每个工具调用,等待所有结果
const toolResponses = await Promise.all(calls.map(executeSingleToolCall));
// 将所有工具回复添加到消息历史
for (const row of toolResponses)
messages.push(row);
}
// 若达到最大步数仍未获得直接回复,则返回警告信息
return {
role: "assistant",
content: "⚠ 对话步数已达上限。",
};
}
// 定义异步主函数 main
async function main() {
//创建工作区目录
await fsp.mkdir(WORKSPACE_ROOT, { recursive: true });
// 无限循环,持续读取用户输入
for (; ;) {
// 创建一个消息列表,包含系统指令
const messages = [{ role: "system", content: AGENT_SYSTEM_INSTRUCTION }];
// 等待用户输入一行内容
const line = await askLine();
// 如果输入内容为空(只包含空白),则跳过本次循环
if (!line.trim()) continue;
// 如果输入的内容为 "q",则跳出循环,结束程序
if (line.trim() === "q") break;
// 将用户输入加入对话消息列表
messages.push({ role: "user", content: line });
// 调用 OpenAI API,发送所有消息,生成聊天回复
const reply = await runAgentUntilReplyOrMaxSteps(messages);
// 打印助手的回复到控制台
if (reply) console.log(`\nAssistant\n${reply.content}\n`);
}
// 关闭 readline 接口,释放资源
rl.close();
}
// 调用主函数 main,启动程序
main();7.4 时序图 #
下图以 7.5 测试 中 node server1.js 为例:命令因含 server 走后台分支;dir 等短命令仍走第 6 节同步路径(图中未画出)。
7.5 测试 #
› 执行命令 node server1.js
请求模型中...
{
"role": "assistant",
"content": "",
"reasoning_content": "The user wants me to run `node server1.js`. Let me execute that command.",
"tool_calls": [
{
"index": 0,
"id": "call_00_eBwOMAUuh91Z6YYmsbnR0848",
"type": "function",
"function": {
"name": "runCommand",
"arguments": "{\"commandLine\": \"node server1.js\"}"
}
}
]
}
工具 runCommand 被调用
入参:{
"commandLine": "node server1.js"
}
返回:已在后台拉起子进程
PID:22976
命令:node server1.js
启动段输出:
Server running at http://localhost:1000/
请求模型中...
{
"role": "assistant",
"content": "服务已成功启动,运行在 `http://localhost:1000/`,PID 为 22976。",
"reasoning_content": "The server has started successfully. It's running at http://localhost:1000/. This is a background process with PID 22976."
}
Assistant
服务已成功启动,运行在 `http://localhost:1000/`,PID 为 22976。
› 执行命令 node server2.js
请求模型中...
{
"role": "assistant",
"content": "",
"reasoning_content": "用户想要执行 `node server2.js` 命令。让我在受控 workspace 内执行这个命令。",
"tool_calls": [
{
"index": 0,
"id": "call_00_c45sYEanJGXJZZuEMBHb9630",
"type": "function",
"function": {
"name": "runCommand",
"arguments": "{\"commandLine\": \"node server2.js\"}"
}
}
]
}
工具 runCommand 被调用
入参:{
"commandLine": "node server2.js"
}
返回:已在后台拉起子进程
PID:14512
命令:node server2.js
启动段输出:
Server running at http://localhost:2000/
请求模型中...
{
"role": "assistant",
"content": "命令 `node server2.js` 已在后台成功启动:\n\n- **PID**:14512\n- **地址**:http://localhost:2000/\n\n服务 器正在监听 2000 端口,可以正常访问了。",
"reasoning_content": "命令已成功在后台启动。服务器正在 http://localhost:2000/ 运行。"
}
Assistant
命令 `node server2.js` 已在后台成功启动:
- **PID**:14512
- **地址**:http://localhost:2000/
服务器正在监听 2000 端口,可以正常访问了。8. 后台任务登记与 task_list 管理 #
第 7 节用 spawn 拉起长驻进程后,子进程 PID 只出现在当次工具返回里,进程表未集中保存,用户难以追问「现在还有哪些服务在跑」。本节引入全局 backgroundProcesses(Map<pid, meta>),在后台拉起时登记 commandLine、child、buffers,进程 exit 时自动移除;并通过 runCommand 的伪子命令 task_list 列出当前任务。
程序在做什么: runBackgroundCommandLine 在 spawn 成功后执行 backgroundProcesses.set(pid, { commandLine, child, buffers }),并 child.once("exit", () => delete)。RunCommandLine.run 入口处先调用 handleBackgroundTaskControlCommand:若 commandLine 匹配 task_list(正则,不区分大小写),则不启动 shell,直接遍历 Map 返回「活动中 / 已结束」与对应命令。工具描述中已写明 task_logs、task_stop(第 9 节才实现);本节 diff 仅实现 task_list。见 8.3 测试:先后台启动 server1 / server2,再 task_list 看到两个 PID。
相对第 7 节的新增点:
backgroundProcesses:进程级「任务台账」,供控制类伪命令查询;buffers为后续task_logs预留。handleBackgroundTaskControlCommand:在looksLikeLongRunningCommand之前拦截,避免把task_list当成普通 shell 命令或误判为长驻命令。- 工具 Schema 文案更新:向模型说明同步命令、后台常驻命令与
task_*子命令的用法,减少误用。
8.1 claude.js #
// 引入 Node.js 的 readline 模块,用于读取终端输入
const readline = require("readline");
// 加载 dotenv 配置文件,并允许覆盖已存在的环境变量
require("dotenv").config({ override: true });
// 引入 Node.js 的 path 模块,用于处理和转换文件路径
const path = require("path");
// 引入 Node.js 的 fs/promises 模块,提供基于 promise 的文件系统操作方法
const fsp = require("fs/promises");
// 从 openai 包中引入 OpenAI 类
const { OpenAI } = require("openai");
// 引入 Node.js 的 child_process 模块,用于执行 shell 命令
const { spawn, spawnSync } = require("child_process");
// Windows 控制台常见为 GBK(cp936);用 UTF-8 解码会得到乱码
const iconv = require("iconv-lite");
// 创建 OpenAI 客户端实例,使用 DeepSeek 的 API 密钥和自定义的 baseURL
const openaiClient = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: "https://api.deepseek.com",
});
// 定义代理的系统指令
const AGENT_SYSTEM_INSTRUCTION = `你是「Claude Code」—— 在受控 workspace 内协助用户读改代码、跑命令的助手。`;
+// 存储所有后台拉起的子进程信息
+const backgroundProcesses = new Map();
// 创建一个 readline 接口,指定输入输出为标准输入输出
const rl = readline.createInterface({
input: process.stdin,// 标准输入流
output: process.stdout,// 标准输出流
terminal: true,// 是否启用终端模式
});
// 监听 SIGINT(通常是 Ctrl+C)事件,只监听一次
rl.once("SIGINT", () => {
// 打印中断提示信息,换两行
console.log("\n\n⌁ 已中断,下次见。");
// 关闭 readline 接口
rl.close();
// 退出当前进程,状态码为 0(正常退出)
process.exit(0);
});
// 定义工作区根目录,位于当前进程的 workspace 文件夹内
const WORKSPACE_ROOT = path.resolve(process.cwd(), "workspace");
// 判断目标路径是否是根目录的子目录或根目录本身
function isDescendantOrSameDirectory(candidatePath) {
// 解析 candidatePath 为绝对路径
const targetAbs = path.resolve(candidatePath);
// 如果目标路径和工作区根目录完全相同,则返回 true
if (WORKSPACE_ROOT === targetAbs) return true;
// 计算目标路径相对于工作区根目录的相对路径
const relative = path.relative(WORKSPACE_ROOT, targetAbs);
// 如果不是空字符串,不以 .. 开头,也不是绝对路径,则说明在工作区内,返回 true
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
}
// 在工作区内解析路径,并校验是否越界
function resolvePathInsideWorkspace(relativePath) {
// 将相对路径或带 ~ 路径解析为工作区下的绝对路径
const candidatePath = path.resolve(WORKSPACE_ROOT, relativePath);
// 如果解析后的路径不在工作区内,则抛出异常
if (!isDescendantOrSameDirectory(candidatePath))
throw new Error(`路径越界:${candidatePath}`);
// 返回校验通过的绝对路径
return candidatePath;
}
// 文本读取工具实现
class ReadText {
// 执行读取操作,参数为 { path }
async run({ path: relativePath }) {
try {
// 调用文件系统 API 读取文件内容
return await fsp.readFile(resolvePathInsideWorkspace(relativePath), "utf8");
} catch (err) {
// 如果找不到文件,返回自定义提示;否则返回异常原因
return err.code === "ENOENT" ? `找不到文件:${relativePath}` : `读取异常:${err.message}`;
}
}
}
// 文本写入工具实现
class WriteText {
// 执行写入操作,参数为 { path, content }
async run({ path: relativePath, content }) {
try {
// 解析目标文件的绝对路径
const absolute = resolvePathInsideWorkspace(relativePath);
// 如果父级目录不存在则递归创建
await fsp.mkdir(path.dirname(absolute), { recursive: true });
// 写入内容到文件,使用 UTF-8 编码
await fsp.writeFile(absolute, content, "utf8");
// 返回写入结果以及写入字节数
return `已落盘 ${Buffer.byteLength(content, "utf8")} 字节 → ${relativePath}`;
} catch (err) {
// 返回写入过程中的异常信息
return `写入异常:${err.message}`;
}
}
}
// 定义 EditFile 类,用于精确修改文件内容
class EditFile {
// 定义异步运行方法,接受文件路径、要替换的旧文本、以及新文本
async run({ path: relativePath, oldText, newText }) {
try {
// 解析出目标文件的绝对路径,确保在工作区内
const absolute = resolvePathInsideWorkspace(relativePath);
// 以 UTF-8 编码读取文件内容
const before = await fsp.readFile(absolute, "utf8");
// 检查文件内容中是否包含需要被替换的片段,如果没有则返回提示
if (!before.includes(oldText)) return "✖ 文件中未出现与 oldText 完全一致的片段";
// 将第一次出现的 oldText 替换为 newText 并写回原文件
await fsp.writeFile(absolute, before.replace(oldText, newText), "utf8");
// 返回替换成功的提示
return `✔ 已就地替换一处:${relativePath}`;
} catch (err) {
// 异常处理:如果找不到文件则返回对应提示,否则返回错误信息
return err.code === "ENOENT" ? `✖ 找不到文件:${relativePath}` : `✖ 编辑异常:${err.message}`;
}
}
}
// 定义 ListDir 类,用于列出目录内容
class ListDir {
// 定义异步运行方法,参数为目录的相对路径
async run({ path: relativePath }) {
try {
// 解析出目标目录的绝对路径,确保在工作区内
const absolute = resolvePathInsideWorkspace(relativePath);
// 获取目标路径的文件状态信息
const stat = await fsp.stat(absolute);
// 判断目标路径是否为目录,如果不是则返回提示
if (!stat.isDirectory()) return `✖ 目标不是目录:${relativePath}`;
// 读取目录下的所有条目,获取类型信息
const entries = await fsp.readdir(absolute, { withFileTypes: true });
// 根据名称进行字典序排序
entries.sort((a, b) => a.name.localeCompare(b.name));
// 格式化每条目录项,区分文件与子目录
const rows = entries.map((ent) => `${ent.isDirectory() ? "目录:" : "文件:"} ${ent.name}`);
// 如果有内容则按行拼接返回,否则返回空目录提示
return rows.length ? rows.join("\n") : "(空目录)";
} catch (err) {
// 异常处理:目录不存在单独提示,否则返回错误信息
return err.code === "ENOENT" ? `✖ 目录不存在:${relativePath}` : `✖ 枚举失败:${err.message}`;
}
}
}
// 工具的 Schema 定义,描述每个工具的用途和参数结构
const MODEL_TOOL_DEFINITIONS = [
{
// 声明工具类型为函数
type: "function",
function: {
// 工具名称为 readFile
name: "readFile",
// 工具功能描述
description: "以 UTF-8 读取工作区内文本文件。",
// 工具参数定义
parameters: {
// 参数类型为对象
type: "object",
// 参数属性为 path,字符串类型
properties: { path: { type: "string" } },
// path 字段为必需参数
required: ["path"],
},
},
},
{
// 声明写入工具类型为函数
type: "function",
function: {
// 工具名称为 writeFile
name: "writeFile",
// 工具描述:新建或覆盖写入工作区内文件
description: "在工作区内新建或覆盖写入文件。",
// 工具参数定义
parameters: {
type: "object",
properties: {
// path 为文件路径,字符串类型
path: { type: "string" },
// content 为要写入的文本内容,字符串类型
content: { type: "string" },
},
// path 和 content 都必需
required: ["path", "content"],
},
},
},
// 定义一个函数工具,用于精确子串替换现有文件内容
{
// 工具类型为 function
type: "function",
// function 字段,定义具体工具内容
function: {
// 工具名称为 editFile
name: "editFile",
// 工具描述,说明功能为仅替换一次完全匹配的原文
description: "对已有文本做一次精确子串替换;oldText 必须与文件内容完全一致。",
// parameters 字段,定义参数信息
parameters: {
// 参数类型为对象
type: "object",
// properties 字段,定义具体参数
properties: {
// path:待修改的文件路径,类型为字符串,描述为待修改文件路径
path: { type: "string", description: "待修改文件路径" },
// oldText:要被替换的原文,只有唯一一次匹配,类型为字符串
oldText: {
type: "string",
description: "要被替换掉的原文(唯一一次匹配)",
},
// newText:用于替换的新文本,类型为字符串,描述为替换后的新文本
newText: { type: "string", description: "替换后的新文本" },
},
// 这三个参数都是必需的
required: ["path", "oldText", "newText"],
},
},
},
// 定义一个函数工具,用于罗列某个目录下的条目(文件和子文件夹)
{
// 工具类型为 function
type: "function",
// function 字段,定义具体工具内容
function: {
// 工具名称为 listDir
name: "listDir",
// 工具描述,说明功能为罗列目录结构,可区分文件夹
description: "罗列目录下的条目(含子文件夹标记),用于快速摸清目录结构。",
// parameters 字段,定义参数信息
parameters: {
// 参数类型为对象
type: "object",
// properties 字段,定义具体参数
properties: {
// path:要罗列的目录路径,类型为字符串,路径相对于 workspace,描述为目录路径(相对 workspace)
path: { type: "string", description: "目录路径(相对 workspace)" },
},
// path 是必需参数
required: ["path"],
},
},
},
// 定义工具对象,表示类型为 function
{
// 设置 type 字段为 "function",表示此为函数型工具
type: "function",
// function 字段,定义具体的函数内容
function: {
// name 字段,工具名称为 runCommand
name: "runCommand",
// description 字段,对工具功能做简要描述
description:
// 描述字符串:在 shell 中执行命令;短命令阻塞至结束...
+ "在 shell 中执行命令;短命令阻塞至结束,疑似开发服务器的命令会后台拉起。\n" +
+ "重要:命令会在当前工作目录执行,不要使用 cd 切换目录\n"+
+ "常规:install / build / test 等同步执行,stdout/stderr 汇总返回。\n" +
+ "常驻:pnpm dev、npm start、uvicorn、flask run 等——后台运行,约 8 秒后回传 PID 与启动阶段日志。\n" +
+ "后台子命令:\n" +
+ " task_list 列出已登记的后台任务\n" +
+ " task_logs <pid> 拉取该 PID 最近若干行合并输出\n" +
+ " task_stop <pid> 结束该后台任务",
// parameters 字段,定义参数类型和要求
parameters: {
// type 指定参数为对象形式
type: "object",
// properties 字段,详细定义各参数
properties: {
// commandLine 参数,定义为字符串类型
commandLine: {
// type 指明参数为字符串
type: "string",
// description 说明 commandLine 的用途
description: "Shell 命令行(例如:npm install)",
}
},
// required 字段,列出必传参数
required: ["commandLine"],
},
},
},
];
// 将子进程的 stdout/stderr Buffer 解码为可读字符串(Windows 中文环境多为 cp936 编码)
function decodeResultBuffer(buf) {
// 若 Buffer 为空或长度为0,直接返回空字符串
if (!buf || buf.length === 0) return "";
// 若当前系统是 Windows,则以 cp936 编码解码
if (process.platform === "win32") return iconv.decode(buf, "cp936");
// 其他系统则以 utf8 解码
return buf.toString("utf8");
}
// 定义常量配置对象
const CONFIG = {
// 智能体允许的最大步数
agentMaxSteps: 100,
// 后台进程预热时间 8秒
backgroundWarmupMs: 8000,
};
// 定义 delay 函数,实现异步延迟
function delay(ms) {
// 返回一个 Promise,在指定毫秒数(ms)后调用 resolve,达到延迟效果
return new Promise((resolve) => setTimeout(resolve, ms));
+ // 定义一个异步延迟函数,参数为毫秒数
}
// 包含了会让开发环境长时间运行的命令字符串
const LONG_RUNNING_HINTS = [
// "dev" 表示开发模式
+ "dev",
// "start" 表示启动服务
+ "start",
// "serve" 表示启动服务器
+ "serve",
// "server" 表示启动开发服务器
+ "server",
// "watch" 表示监听文件变更
+ "watch",
// "run server" 表示运行服务器
+ "run server",
// "runserver" 另一种写法,运行服务器
+ "runserver",
// "preview" 表示预览模式
+ "preview",
// "nodemon" 表示自动重启开发服务器
+ "nodemon",
// "uvicorn" 表示 Python asgi 服务器
+ "uvicorn",
// "gunicorn" 表示 Python wsgi 服务器
+ "gunicorn",
// "flask run" 表示 Flask 的运行命令
+ "flask run",
// "vite" 表示前端构建工具
+ "vite",
// "webpack" 表示前端构建工具
+ "webpack",
// "--watch" 表示监听参数
+ "--watch",
// "--hot" 表示热更新参数
+ "--hot"
];
// 定义函数,用于判断给定命令行是否为疑似长时间运行的命令
function looksLikeLongRunningCommand(commandLine) {
// 去除命令行字符串首尾空白,并转为小写,标准化处理
const normalized = commandLine.trim().toLowerCase();
// 在长时间运行命令关键字集合 LONG_RUNNING_HINTS 中查找,判断 normalized 是否包含任一关键字
return LONG_RUNNING_HINTS.some((hint) => normalized.includes(hint));
}
// 定义 bindStreamToBuffers 函数,将可读流绑定到缓冲区
function bindStreamToBuffers(readableStream, buffers) {
// 监听 "data" 事件,每收到一段数据时被调用
readableStream.on("data", (chunk) => {
// 将收到的数据块 chunk 压入缓冲区 buffers
buffers.push(chunk);
});
// 监听 "end" 事件,当数据流结束时被调用
readableStream.on("end", () => {
// 将 null 压入缓冲区 buffers,表示流已结束
buffers.push(null);
});
}
// 工具类:用于在工作空间目录中执行 commandLine 命令
class RunCommandLine {
+ // 检查并处理后台任务控制命令,比如 task_list
+ handleBackgroundTaskControlCommand(commandLine) {
+ // 判断是否为 task_list 指令(不区分大小写、后可带空白或行尾)
+ if (/^task_list(\s|$)/i.test(commandLine)) {
+ if (backgroundProcesses.size === 0) return "(当前没有已登记的后台任务)";
+ return [
+ "【后台任务一览】",
+ ...[...backgroundProcesses].map(([pid, meta]) =>
+ ` · ${pid} ${meta.child.exitCode === null ? "活动中" : "已结束"} | ${meta.commandLine}`
+ )
+ ].join("\n");
+ }
+ return null;
+ }
// 异步运行 commandLine 命令,接收包含 commandLine 字段的对象参数
async run({ commandLine }) {
+ // 尝试解析是否为后台任务控制指令(如 task_list),如果是则优先返回对应回复
+ const backgroundTaskControlReply = this.handleBackgroundTaskControlCommand(commandLine);
+ // 如果解析出控制回复,不再继续后续命令执行,直接返回
+ if (backgroundTaskControlReply !== null) return backgroundTaskControlReply;
// 判断命令行是否看起来像是长时间运行的命令
if (looksLikeLongRunningCommand(commandLine)) {
// 如果是,则调用异步方法在后台运行命令行
return this.runBackgroundCommandLine(commandLine);
}
// 如果不是长时间运行的命令,则调用同步(阻塞)方法运行命令行
return this.runBlockingCommandLine(commandLine)
}
// 同步运行 commandLine 命令,并返回命令执行的文本结果
runBlockingCommandLine(commandLine) {
// 使用 spawnSync 同步执行命令行
const result = spawnSync(commandLine, {
// 指定使用 shell 运行 commandLine
shell: true,
// 指定工作目录为工作空间根目录
cwd: WORKSPACE_ROOT,
// 输出按二进制 Buffer 格式返回
encoding: "buffer",
// 最大缓冲区 10MB
maxBuffer: 10 * 1024 * 1024,
});
// 命令执行超时,则返回超时提示
if (result.error?.code === "ETIMEDOUT") return `✖ 同步命令超时:${commandLine}`;
// 若 stdout 有内容则解码,否则返回“stdout 为空”
let merged = result.stdout?.length ? decodeResultBuffer(result.stdout) : "(stdout 为空)";
// 若 stderr 有内容,则拼接 stderr 的解码结果
if (result.stderr?.length)
merged += `\n【stderr】\n${decodeResultBuffer(result.stderr)}`;
// 如果命令退出码非0,显示错误提示和全部输出,否则显示成功提示和输出
return result.status !== 0
? `退出码 ${result.status}\n${merged}`
: `命令完成\n${merged}`;
}
// 定义一个异步方法,用于在后台启动 commandLine 命令
async runBackgroundCommandLine(commandLine) {
// 创建一个用于存储子进程输出的缓冲区数组
const buffers = [];
// 使用 spawn 方法启动一个新的子进程,传入命令行参数和配置对象
const child = spawn(commandLine, {
// 指定使用 shell 运行 commandLine 命令
shell: true,
// 指定子进程的工作目录为工作空间根目录
cwd: WORKSPACE_ROOT,
// 配置子进程的标准输入被忽略,标准输出和标准错误重定向到管道
stdio: ["ignore", "pipe", "pipe"],
});
// 绑定子进程的标准输出流到缓冲区数组
bindStreamToBuffers(child.stdout, buffers);
// 绑定子进程的标准错误流到缓冲区数组
bindStreamToBuffers(child.stderr, buffers);
// 获取子进程的进程ID(PID)
const pid = child.pid;
+ // 将当前子进程的信息存储到 backgroundProcesses 映射中
+ backgroundProcesses.set(pid, {
+ // 记录启动该子进程使用的命令行字符串
+ commandLine,
+ // 记录子进程对象本身
+ child,
+ // 记录用于收集子进程输出的缓冲区数组
+ buffers
+ });
+ // 监听子进程的 "exit" 事件,在其退出时将其从 backgroundProcesses 移除
+ child.once("exit", () => backgroundProcesses.delete(pid));
// 等待后台进程初始化,延迟指定的时间(用于预热/启动)
await delay(CONFIG.backgroundWarmupMs);
// 返回启动成功的信息,包含进程ID、命令和启动阶段的输出信息
return `已在后台拉起子进程\n PID:${pid}\n 命令:${commandLine}\n 启动段输出:\n${Buffer.concat(buffers).toString("utf8") || "(尚无输出)"}`;
}
}
// 工具名称到对应实例的映射表
const toolHandlerByName = {
// 读取文件工具
readFile: new ReadText(),
// 写入文件工具
writeFile: new WriteText(),
// 替换文件工具
editFile: new EditFile(),
// 枚举目录工具
listDir: new ListDir(),
// 执行命令工具
runCommand: new RunCommandLine(),
};
// 定义一个 ask 函数,返回一个 Promise,用于异步获取用户输入
const askLine = () =>
new Promise((resolve) => {
// 向控制台输出普通的提示符,并等待用户输入
rl.question("› ", resolve);
});
// 定义异步函数,用于执行一次工具调用
async function executeSingleToolCall(toolCallPayload) {
// 获取本次工具调用的名字
const name = toolCallPayload.function.name;
// 声明解析参数变量
let parsedArgs;
try {
// 尝试将工具调用的参数字符串解析为对象
parsedArgs = JSON.parse(toolCallPayload.function.arguments || "{}");
} catch {
// 如果解析失败,则设为空对象
parsedArgs = {};
}
// 控制台输出工具调用信息
console.log(`\n工具 ${name} 被调用`);
// 控制台输出工具的入参
console.log(`入参:${JSON.stringify(parsedArgs, null, 2)}`);
// 根据工具名获取对应处理器
const handler = toolHandlerByName[name];
// 判断是否有对应处理器,如有则执行,否则返回未实现提示
const textResult = handler
? await handler.run(parsedArgs)
: `✖ 未实现的工具:${name}`;
// 控制台输出工具调用的结果
console.log(`返回:${textResult}`);
// 构造并返回本次工具调用的完整回复对象
return {
// 设置角色为 tool
role: "tool",
// 工具调用对应的 id
tool_call_id: toolCallPayload.id,
// 工具名称
name,
// 工具返回的内容
content: textResult,
};
}
// 定义异步函数,驱动智能体连续推理直到文字回复或超步
async function runAgentUntilReplyOrMaxSteps(messages) {
// 初始化步数计数器
let step = 0;
// 循环,直到步数超过最大限制
while (step < CONFIG.agentMaxSteps) {
// 步数递增
step++;
// 控制台显示正在请求模型
console.log("\n请求模型中...");
// 向模型 API 发送消息请求回复
const completion = await openaiClient.chat.completions.create({
// 模型名称
model: "deepseek-v4-pro",
// 消息参数
messages: messages,
// 工具函数定义
tools: MODEL_TOOL_DEFINITIONS,
// 工具选择方式
tool_choice: "auto",
});
// 获取模型助手返回的消息对象
const assistantMessage = completion.choices[0].message;
// 控制台输出助手消息
console.log(JSON.stringify(assistantMessage, null, 2));
// 将助手消息加入对话消息历史
messages.push(assistantMessage);
// 获取本轮助手消息产生的工具调用
const calls = assistantMessage.tool_calls;
// 判断是否有工具调用,无则直接返回助手回复
if (!calls || calls.length === 0) {
// 返回助手文字回复
return assistantMessage;
}
// 并发执行每个工具调用,等待所有结果
const toolResponses = await Promise.all(calls.map(executeSingleToolCall));
// 将所有工具回复添加到消息历史
for (const row of toolResponses)
messages.push(row);
}
// 若达到最大步数仍未获得直接回复,则返回警告信息
return {
role: "assistant",
content: "⚠ 对话步数已达上限。",
};
}
// 定义异步主函数 main
async function main() {
//创建工作区目录
await fsp.mkdir(WORKSPACE_ROOT, { recursive: true });
// 无限循环,持续读取用户输入
for (; ;) {
// 创建一个消息列表,包含系统指令
const messages = [{ role: "system", content: AGENT_SYSTEM_INSTRUCTION }];
// 等待用户输入一行内容
const line = await askLine();
// 如果输入内容为空(只包含空白),则跳过本次循环
if (!line.trim()) continue;
// 如果输入的内容为 "q",则跳出循环,结束程序
if (line.trim() === "q") break;
// 将用户输入加入对话消息列表
messages.push({ role: "user", content: line });
// 调用 OpenAI API,发送所有消息,生成聊天回复
const reply = await runAgentUntilReplyOrMaxSteps(messages);
// 打印助手的回复到控制台
if (reply) console.log(`\nAssistant\n${reply.content}\n`);
}
// 关闭 readline 接口,释放资源
rl.close();
}
// 调用主函数 main,启动程序
main();8.2 时序图 #
下图综合 8.3 测试:先登记两个后台服务,再通过 task_list 查询(task_list 不经过 shell,也不触发 spawn)。
8.3 测试 #
› 执行命令 node server1.js
请求模型中...
{
"role": "assistant",
"content": "",
"reasoning_content": "用户想要我执行 `node server1.js`。让我先看看这个文件是否存在以及它的内容。",
"tool_calls": [
{
"index": 0,
"id": "call_00_b3BGRYqx3NBXLSseK2Gf3780",
"type": "function",
"function": {
"name": "readFile",
"arguments": "{\"path\": \"server1.js\"}"
}
}
]
}
工具 readFile 被调用
入参:{
"path": "server1.js"
}
返回:// 引入 Node.js 内置的 http 模块
const http = require('http');
// 定义服务器监听的端口号为 1000
const PORT = 1000;
// 创建一个 HTTP 服务器实例
const server = http.createServer((req, res) => {
// 设置响应状态码为 200(成功)
res.statusCode = 200;
// 设置响应头,指定内容类型为纯文本
res.setHeader('Content-Type', 'text/plain');
// 结束响应并返回消息内容
res.end('1000');
});
// 启动服务器,监听指定端口
server.listen(PORT, () => {
// 服务器启动后在控制台输出提示信息
console.log(`Server running at http://localhost:${PORT}/`);
});
请求模型中...
{
"role": "assistant",
"content": "这是一个 HTTP 服务器,会持续监听端口 1000。我将以后台模式启动它:",
"reasoning_content": "这是一个简单的 HTTP 服务器,监听端口 1000,返回 \"1000\"。由于这是一个常驻服务器,应该使用后台模式运行。",
"tool_calls": [
{
"index": 0,
"id": "call_00_Za1nqrrzNMz2eZGPe5dD7278",
"type": "function",
"function": {
"name": "runCommand",
"arguments": "{\"commandLine\": \"node server1.js\"}"
}
}
]
}
工具 runCommand 被调用
入参:{
"commandLine": "node server1.js"
}
返回:已在后台拉起子进程
PID:21292
命令:node server1.js
启动段输出:
Server running at http://localhost:1000/
请求模型中...
{
"role": "assistant",
"content": "服务器已在后台成功启动!\n\n- **PID**:21292\n- **地址**:`http://localhost:1000/`\n- **行为**:访问时返回纯文本 `1000`\n\n如有需要,你可以使用以下命令管理该后台任务:\n- 查看日志:`task_logs 21292`\n- 停止服务:`task_stop 21292`",
"reasoning_content": "服务器已成功在后台启动。"
}
Assistant
服务器已在后台成功启动!
- **PID**:21292
- **地址**:`http://localhost:1000/`
- **行为**:访问时返回纯文本 `1000`
如有需要,你可以使用以下命令管理该后台任务:
- 查看日志:`task_logs 21292`
- 停止服务:`task_stop 21292`
› 执行命令 node server2.js
请求模型中...
{
"role": "assistant",
"content": "",
"reasoning_content": "The user wants me to execute `node server2.js`. Let me first check what this file contains and understand the project structure.",
"tool_calls": [
{
"index": 0,
"id": "call_00_ybhTRwYdz2YtU0L5js3D9714",
"type": "function",
"function": {
"name": "runCommand",
"arguments": "{\"commandLine\": \"node server2.js\"}"
}
}
]
}
工具 runCommand 被调用
入参:{
"commandLine": "node server2.js"
}
返回:已在后台拉起子进程
PID:26924
命令:node server2.js
启动段输出:
Server running at http://localhost:2000/
请求模型中...
{
"role": "assistant",
"content": "`server2.js` 已在后台启动,PID 为 **26924**,监听地址:**http://localhost:2000/**。\n\n如需查看日志、停止 服务或进行其他操作,随时告诉我。",
"reasoning_content": "The server has been started in the background. It's running on http://localhost:2000/."
}
Assistant
`server2.js` 已在后台启动,PID 为 **26924**,监听地址:**http://localhost:2000/**。
如需查看日志、停止服务或进行其他操作,随时告诉我。
› task_list
请求模型中...
{
"role": "assistant",
"content": "",
"reasoning_content": "The user is asking me to list the background tasks. Let me run the task_list command.",
"tool_calls": [
{
"index": 0,
"id": "call_00_pal5Dw8UTBnkzpa3WKxs3789",
"type": "function",
"function": {
"name": "runCommand",
"arguments": "{\"commandLine\": \"task_list\"}"
}
}
]
}
工具 runCommand 被调用
入参:{
"commandLine": "task_list"
}
返回:【后台任务一览】
· 21292 活动中 | node server1.js
· 26924 活动中 | node server2.js
请求模型中...
{
"role": "assistant",
"content": "当前有 2 个活动的后台任务:\n\n| PID | 状态 | 命令 |\n|-----|------|------|\n| 21292 | 活动中 | `node server1.js` |\n| 26924 | 活动中 | `node server2.js` |\n\n如果你想查看某个任务的日志或停止它,告诉我就行。",
"reasoning_content": "The user can see two active background tasks:\n\n1. PID 21292 - running `node server1.js`\n2. PID 26924 - running `node server2.js`\n\nI should present this clearly."
}
Assistant
当前有 2 个活动的后台任务:
| PID | 状态 | 命令 |
|-----|------|------|
| 21292 | 活动中 | `node server1.js` |
| 26924 | 活动中 | `node server2.js` |
如果你想查看某个任务的日志或停止它,告诉我就行。9. task_logs / task_stop #
第 8 节只实现了 task_list,工具描述里承诺的 task_logs、task_stop 在本节落地,并统一后台输出的解码方式,使「启动段输出」与「事后查日志」行为一致。
程序在做什么: 在 handleBackgroundTaskControlCommand 中新增两条伪命令:task_logs <pid> 从 backgroundProcesses 取出登记时的 buffers,经 buffersToText(合并 Buffer + decodeResultBuffer,Windows 用 cp936)转成文本,再用 tailTextLines 截取末尾 CONFIG.backgroundLogPreviewLines(默认 50)行返回;task_stop <pid> 在 Windows 上 taskkill /PID … /T /F,其它平台尝试 process.kill(-pid, "SIGTERM"),失败则 child.kill,并从 Map 删除。runBackgroundCommandLine 的启动段输出也改为 buffersToText(buffers),不再硬编码 utf8。完整流程见 9.3 测试数据。
相对第 8 节的新增点:
buffersToText/tailTextLines:把bindStreamToBuffers收集的 stdout/stderr 块转为可读字符串并做行级截断。task_logs:只读、不启 shell;PID 未登记时返回✖ 未登记的后台 PID。task_stop:主动结束子进程并移出登记表;与child.once("exit")的自动清理互补。backgroundLogPreviewLines:控制task_logs返回体积,避免把过长日志塞进模型上下文。
三个命令对比:
| 命令 | 作用 | 是否 spawn |
|---|---|---|
task_list |
列出 PID、状态、原命令 | 否 |
task_logs <pid> |
最近 N 行合并输出 | 否 |
task_stop <pid> |
终止并注销 | 否(仅 kill) |
9.1 claude.js #
// 引入 Node.js 的 readline 模块,用于读取终端输入
const readline = require("readline");
// 加载 dotenv 配置文件,并允许覆盖已存在的环境变量
require("dotenv").config({ override: true });
// 引入 Node.js 的 path 模块,用于处理和转换文件路径
const path = require("path");
// 引入 Node.js 的 fs/promises 模块,提供基于 promise 的文件系统操作方法
const fsp = require("fs/promises");
// 从 openai 包中引入 OpenAI 类
const { OpenAI } = require("openai");
// 引入 Node.js 的 child_process 模块,用于执行 shell 命令
const { spawn, spawnSync } = require("child_process");
// Windows 控制台常见为 GBK(cp936);用 UTF-8 解码会得到乱码
const iconv = require("iconv-lite");
// 创建 OpenAI 客户端实例,使用 DeepSeek 的 API 密钥和自定义的 baseURL
const openaiClient = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: "https://api.deepseek.com",
});
// 定义代理的系统指令
const AGENT_SYSTEM_INSTRUCTION = `你是「Claude Code」—— 在受控 workspace 内协助用户读改代码、跑命令的助手。`;
// 存储所有后台拉起的子进程信息
const backgroundProcesses = new Map();
// 创建一个 readline 接口,指定输入输出为标准输入输出
const rl = readline.createInterface({
input: process.stdin,// 标准输入流
output: process.stdout,// 标准输出流
terminal: true,// 是否启用终端模式
});
// 监听 SIGINT(通常是 Ctrl+C)事件,只监听一次
rl.once("SIGINT", () => {
// 打印中断提示信息,换两行
console.log("\n\n⌁ 已中断,下次见。");
// 关闭 readline 接口
rl.close();
// 退出当前进程,状态码为 0(正常退出)
process.exit(0);
});
// 定义工作区根目录,位于当前进程的 workspace 文件夹内
const WORKSPACE_ROOT = path.resolve(process.cwd(), "workspace");
// 判断目标路径是否是根目录的子目录或根目录本身
function isDescendantOrSameDirectory(candidatePath) {
// 解析 candidatePath 为绝对路径
const targetAbs = path.resolve(candidatePath);
// 如果目标路径和工作区根目录完全相同,则返回 true
if (WORKSPACE_ROOT === targetAbs) return true;
// 计算目标路径相对于工作区根目录的相对路径
const relative = path.relative(WORKSPACE_ROOT, targetAbs);
// 如果不是空字符串,不以 .. 开头,也不是绝对路径,则说明在工作区内,返回 true
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
}
// 在工作区内解析路径,并校验是否越界
function resolvePathInsideWorkspace(relativePath) {
// 将相对路径或带 ~ 路径解析为工作区下的绝对路径
const candidatePath = path.resolve(WORKSPACE_ROOT, relativePath);
// 如果解析后的路径不在工作区内,则抛出异常
if (!isDescendantOrSameDirectory(candidatePath))
throw new Error(`路径越界:${candidatePath}`);
// 返回校验通过的绝对路径
return candidatePath;
}
// 文本读取工具实现
class ReadText {
// 执行读取操作,参数为 { path }
async run({ path: relativePath }) {
try {
// 调用文件系统 API 读取文件内容
return await fsp.readFile(resolvePathInsideWorkspace(relativePath), "utf8");
} catch (err) {
// 如果找不到文件,返回自定义提示;否则返回异常原因
return err.code === "ENOENT" ? `找不到文件:${relativePath}` : `读取异常:${err.message}`;
}
}
}
// 文本写入工具实现
class WriteText {
// 执行写入操作,参数为 { path, content }
async run({ path: relativePath, content }) {
try {
// 解析目标文件的绝对路径
const absolute = resolvePathInsideWorkspace(relativePath);
// 如果父级目录不存在则递归创建
await fsp.mkdir(path.dirname(absolute), { recursive: true });
// 写入内容到文件,使用 UTF-8 编码
await fsp.writeFile(absolute, content, "utf8");
// 返回写入结果以及写入字节数
return `已落盘 ${Buffer.byteLength(content, "utf8")} 字节 → ${relativePath}`;
} catch (err) {
// 返回写入过程中的异常信息
return `写入异常:${err.message}`;
}
}
}
// 定义 EditFile 类,用于精确修改文件内容
class EditFile {
// 定义异步运行方法,接受文件路径、要替换的旧文本、以及新文本
async run({ path: relativePath, oldText, newText }) {
try {
// 解析出目标文件的绝对路径,确保在工作区内
const absolute = resolvePathInsideWorkspace(relativePath);
// 以 UTF-8 编码读取文件内容
const before = await fsp.readFile(absolute, "utf8");
// 检查文件内容中是否包含需要被替换的片段,如果没有则返回提示
if (!before.includes(oldText)) return "✖ 文件中未出现与 oldText 完全一致的片段";
// 将第一次出现的 oldText 替换为 newText 并写回原文件
await fsp.writeFile(absolute, before.replace(oldText, newText), "utf8");
// 返回替换成功的提示
return `✔ 已就地替换一处:${relativePath}`;
} catch (err) {
// 异常处理:如果找不到文件则返回对应提示,否则返回错误信息
return err.code === "ENOENT" ? `✖ 找不到文件:${relativePath}` : `✖ 编辑异常:${err.message}`;
}
}
}
// 定义 ListDir 类,用于列出目录内容
class ListDir {
// 定义异步运行方法,参数为目录的相对路径
async run({ path: relativePath }) {
try {
// 解析出目标目录的绝对路径,确保在工作区内
const absolute = resolvePathInsideWorkspace(relativePath);
// 获取目标路径的文件状态信息
const stat = await fsp.stat(absolute);
// 判断目标路径是否为目录,如果不是则返回提示
if (!stat.isDirectory()) return `✖ 目标不是目录:${relativePath}`;
// 读取目录下的所有条目,获取类型信息
const entries = await fsp.readdir(absolute, { withFileTypes: true });
// 根据名称进行字典序排序
entries.sort((a, b) => a.name.localeCompare(b.name));
// 格式化每条目录项,区分文件与子目录
const rows = entries.map((ent) => `${ent.isDirectory() ? "目录:" : "文件:"} ${ent.name}`);
// 如果有内容则按行拼接返回,否则返回空目录提示
return rows.length ? rows.join("\n") : "(空目录)";
} catch (err) {
// 异常处理:目录不存在单独提示,否则返回错误信息
return err.code === "ENOENT" ? `✖ 目录不存在:${relativePath}` : `✖ 枚举失败:${err.message}`;
}
}
}
// 工具的 Schema 定义,描述每个工具的用途和参数结构
const MODEL_TOOL_DEFINITIONS = [
{
// 声明工具类型为函数
type: "function",
function: {
// 工具名称为 readFile
name: "readFile",
// 工具功能描述
description: "以 UTF-8 读取工作区内文本文件。",
// 工具参数定义
parameters: {
// 参数类型为对象
type: "object",
// 参数属性为 path,字符串类型
properties: { path: { type: "string" } },
// path 字段为必需参数
required: ["path"],
},
},
},
{
// 声明写入工具类型为函数
type: "function",
function: {
// 工具名称为 writeFile
name: "writeFile",
// 工具描述:新建或覆盖写入工作区内文件
description: "在工作区内新建或覆盖写入文件。",
// 工具参数定义
parameters: {
type: "object",
properties: {
// path 为文件路径,字符串类型
path: { type: "string" },
// content 为要写入的文本内容,字符串类型
content: { type: "string" },
},
// path 和 content 都必需
required: ["path", "content"],
},
},
},
// 定义一个函数工具,用于精确子串替换现有文件内容
{
// 工具类型为 function
type: "function",
// function 字段,定义具体工具内容
function: {
// 工具名称为 editFile
name: "editFile",
// 工具描述,说明功能为仅替换一次完全匹配的原文
description: "对已有文本做一次精确子串替换;oldText 必须与文件内容完全一致。",
// parameters 字段,定义参数信息
parameters: {
// 参数类型为对象
type: "object",
// properties 字段,定义具体参数
properties: {
// path:待修改的文件路径,类型为字符串,描述为待修改文件路径
path: { type: "string", description: "待修改文件路径" },
// oldText:要被替换的原文,只有唯一一次匹配,类型为字符串
oldText: {
type: "string",
description: "要被替换掉的原文(唯一一次匹配)",
},
// newText:用于替换的新文本,类型为字符串,描述为替换后的新文本
newText: { type: "string", description: "替换后的新文本" },
},
// 这三个参数都是必需的
required: ["path", "oldText", "newText"],
},
},
},
// 定义一个函数工具,用于罗列某个目录下的条目(文件和子文件夹)
{
// 工具类型为 function
type: "function",
// function 字段,定义具体工具内容
function: {
// 工具名称为 listDir
name: "listDir",
// 工具描述,说明功能为罗列目录结构,可区分文件夹
description: "罗列目录下的条目(含子文件夹标记),用于快速摸清目录结构。",
// parameters 字段,定义参数信息
parameters: {
// 参数类型为对象
type: "object",
// properties 字段,定义具体参数
properties: {
// path:要罗列的目录路径,类型为字符串,路径相对于 workspace,描述为目录路径(相对 workspace)
path: { type: "string", description: "目录路径(相对 workspace)" },
},
// path 是必需参数
required: ["path"],
},
},
},
// 定义工具对象,表示类型为 function
{
// 设置 type 字段为 "function",表示此为函数型工具
type: "function",
// function 字段,定义具体的函数内容
function: {
// name 字段,工具名称为 runCommand
name: "runCommand",
// description 字段,对工具功能做简要描述
description:
// 描述字符串:在 shell 中执行命令;短命令阻塞至结束...
"在 shell 中执行命令;短命令阻塞至结束,疑似开发服务器的命令会后台拉起。\n" +
"重要:命令会在当前工作目录执行,不要使用 cd 切换目录\n"+
"常规:install / build / test 等同步执行,stdout/stderr 汇总返回。\n" +
"常驻:pnpm dev、npm start、uvicorn、flask run 等——后台运行,约 8 秒后回传 PID 与启动阶段日志。\n" +
"后台子命令:\n" +
" task_list 列出已登记的后台任务\n" +
" task_logs <pid> 拉取该 PID 最近若干行合并输出\n" +
" task_stop <pid> 结束该后台任务",
// parameters 字段,定义参数类型和要求
parameters: {
// type 指定参数为对象形式
type: "object",
// properties 字段,详细定义各参数
properties: {
// commandLine 参数,定义为字符串类型
commandLine: {
// type 指明参数为字符串
type: "string",
// description 说明 commandLine 的用途
description: "Shell 命令行(例如:npm install)",
}
},
// required 字段,列出必传参数
required: ["commandLine"],
},
},
},
];
// 将子进程的 stdout/stderr Buffer 解码为可读字符串(Windows 中文环境多为 cp936 编码)
function decodeResultBuffer(buf) {
// 若 Buffer 为空或长度为0,直接返回空字符串
if (!buf || buf.length === 0) return "";
// 若当前系统是 Windows,则以 cp936 编码解码
if (process.platform === "win32") return iconv.decode(buf, "cp936");
// 其他系统则以 utf8 解码
return buf.toString("utf8");
}
// 定义常量配置对象
const CONFIG = {
// 智能体允许的最大步数
agentMaxSteps: 100,
// 后台进程预热时间 8秒
backgroundWarmupMs: 8000,
+ // task_logs 返回的最大行数
+ backgroundLogPreviewLines: 50,
};
// 定义 delay 函数,实现异步延迟
function delay(ms) {
// 返回一个 Promise,在指定毫秒数(ms)后调用 resolve,达到延迟效果
return new Promise((resolve) => setTimeout(resolve, ms));
// 定义一个异步延迟函数,参数为毫秒数
}
// 包含了会让开发环境长时间运行的命令字符串
const LONG_RUNNING_HINTS = [
// "dev" 表示开发模式
"dev",
// "start" 表示启动服务
"start",
// "serve" 表示启动服务器
"serve",
// "server" 表示启动开发服务器
"server",
// "watch" 表示监听文件变更
"watch",
// "run server" 表示运行服务器
"run server",
// "runserver" 另一种写法,运行服务器
"runserver",
// "preview" 表示预览模式
"preview",
// "nodemon" 表示自动重启开发服务器
"nodemon",
// "uvicorn" 表示 Python asgi 服务器
"uvicorn",
// "gunicorn" 表示 Python wsgi 服务器
"gunicorn",
// "flask run" 表示 Flask 的运行命令
"flask run",
// "vite" 表示前端构建工具
"vite",
// "webpack" 表示前端构建工具
"webpack",
// "--watch" 表示监听参数
"--watch",
// "--hot" 表示热更新参数
"--hot"
];
// 定义函数,用于判断给定命令行是否为疑似长时间运行的命令
function looksLikeLongRunningCommand(commandLine) {
// 去除命令行字符串首尾空白,并转为小写,标准化处理
const normalized = commandLine.trim().toLowerCase();
// 在长时间运行命令关键字集合 LONG_RUNNING_HINTS 中查找,判断 normalized 是否包含任一关键字
return LONG_RUNNING_HINTS.some((hint) => normalized.includes(hint));
}
+// 定义函数:将 bindStreamToBuffers 收集的 Buffer 块解码为文本
+function buffersToText(buffers) {
+ // 过滤掉为 null 的 chunk,得到有效的 Buffer 块
+ const chunks = buffers.filter((chunk) => chunk !== null);
+ // 如果 chunks 为空数组,说明没有内容,返回空字符串
+ if (!chunks.length) return "";
+ // 将所有 Buffer 块合并后,调用 decodeResultBuffer 进行解码为字符串
+ return decodeResultBuffer(Buffer.concat(chunks));
+}
+// 定义函数:取文本末尾指定行数,用于 task_logs 日志预览
+function tailTextLines(text, maxLines) {
+ // 如果输入文本为空,直接返回空字符串
+ if (!text) return "";
+ // 按换行符分割文本成数组,取最后的 maxLines 行,再用换行符拼接成字符串返回
+ return text.split(/\r?\n/).slice(-maxLines).join("\n");
+}
// 定义 bindStreamToBuffers 函数,将可读流绑定到缓冲区
function bindStreamToBuffers(readableStream, buffers) {
// 监听 "data" 事件,每收到一段数据时被调用
readableStream.on("data", (chunk) => {
// 将收到的数据块 chunk 压入缓冲区 buffers
buffers.push(chunk);
});
// 监听 "end" 事件,当数据流结束时被调用
readableStream.on("end", () => {
// 将 null 压入缓冲区 buffers,表示流已结束
buffers.push(null);
});
}
// 工具类:用于在工作空间目录中执行 commandLine 命令
class RunCommandLine {
// 检查并处理后台任务控制命令,比如 task_list
handleBackgroundTaskControlCommand(commandLine) {
// 判断是否为 task_list 指令(不区分大小写、后可带空白或行尾)
if (/^task_list(\s|$)/i.test(commandLine)) {
if (backgroundProcesses.size === 0) return "(当前没有已登记的后台任务)";
return [
"【后台任务一览】",
...[...backgroundProcesses].map(([pid, meta]) =>
` · ${pid} ${meta.child.exitCode === null ? "活动中" : "已结束"} | ${meta.commandLine}`
)
].join("\n");
}
+ // 匹配 task_logs <pid> 指令,获取日志查看请求
+ const logsMatch = /^task_logs\s+(\d+)\s*$/i.exec(commandLine.trim());
+ // 如果匹配成功
+ if (logsMatch) {
+ // 从匹配结果中获取 pid
+ const pid = Number.parseInt(logsMatch[1], 10);
+ // 检查后台进程集中是否有该 pid,没有则返回错误提示
+ if (!backgroundProcesses.has(pid)) return `✖ 未登记的后台 PID:${pid}`;
+ // 获取该 pid 对应的缓冲区 buffers
+ const { buffers } = backgroundProcesses.get(pid);
+ // 将缓冲区内容转换为文本,并截取末尾若干行
+ const text = tailTextLines(
+ buffersToText(buffers),
+ CONFIG.backgroundLogPreviewLines
+ );
+ // 返回格式化的输出内容
+ return `【PID ${pid} 最近输出】\n${text || "(尚无输出)"}`;
+ }
+ // 匹配 task_stop <pid> 指令,获取终止进程请求
+ const stopMatch = /^task_stop\s+(\d+)\s*$/i.exec(commandLine.trim());
+ // 如果匹配成功
+ if (stopMatch) {
+ // 从匹配结果中获取 pid
+ const pid = Number.parseInt(stopMatch[1], 10);
+ // 检查后台进程集中是否有该 pid,没有则返回错误提示
+ if (!backgroundProcesses.has(pid)) return `✖ 未登记的后台 PID:${pid}`;
+ // 获取该 pid 对应的子进程 child
+ const { child } = backgroundProcesses.get(pid);
+ try {
+ // 如果是 Windows 系统,则使用 taskkill 命令终止进程
+ if (process.platform === "win32") {
+ spawnSync(`taskkill /PID ${pid} /T /F`, { shell: true, stdio: "ignore" });
+ } else {
+ // 非 Windows 则通过向进程组发送 SIGTERM 信号终止进程
+ process.kill(-pid, "SIGTERM");
+ }
+ } catch {
+ // 如果上面 kill 失败,则直接对子进程对象调用 kill 方法
+ child.kill("SIGTERM");
+ }
+ // 从后台进程集删除该 pid
+ backgroundProcesses.delete(pid);
+ // 返回终止请求已发送的提示
+ return `已请求终止后台 PID=${pid}`;
+ }
return null;
}
// 异步运行 commandLine 命令,接收包含 commandLine 字段的对象参数
async run({ commandLine }) {
// 尝试解析是否为后台任务控制指令(如 task_list),如果是则优先返回对应回复
const backgroundTaskControlReply = this.handleBackgroundTaskControlCommand(commandLine);
// 如果解析出控制回复,不再继续后续命令执行,直接返回
if (backgroundTaskControlReply !== null) return backgroundTaskControlReply;
// 判断命令行是否看起来像是长时间运行的命令
if (looksLikeLongRunningCommand(commandLine)) {
// 如果是,则调用异步方法在后台运行命令行
return this.runBackgroundCommandLine(commandLine);
}
// 如果不是长时间运行的命令,则调用同步(阻塞)方法运行命令行
return this.runBlockingCommandLine(commandLine)
}
// 同步运行 commandLine 命令,并返回命令执行的文本结果
runBlockingCommandLine(commandLine) {
// 使用 spawnSync 同步执行命令行
const result = spawnSync(commandLine, {
// 指定使用 shell 运行 commandLine
shell: true,
// 指定工作目录为工作空间根目录
cwd: WORKSPACE_ROOT,
// 输出按二进制 Buffer 格式返回
encoding: "buffer",
// 最大缓冲区 10MB
maxBuffer: 10 * 1024 * 1024,
});
// 命令执行超时,则返回超时提示
if (result.error?.code === "ETIMEDOUT") return `✖ 同步命令超时:${commandLine}`;
// 若 stdout 有内容则解码,否则返回“stdout 为空”
let merged = result.stdout?.length ? decodeResultBuffer(result.stdout) : "(stdout 为空)";
// 若 stderr 有内容,则拼接 stderr 的解码结果
if (result.stderr?.length)
merged += `\n【stderr】\n${decodeResultBuffer(result.stderr)}`;
// 如果命令退出码非0,显示错误提示和全部输出,否则显示成功提示和输出
return result.status !== 0
? `退出码 ${result.status}\n${merged}`
: `命令完成\n${merged}`;
}
// 定义一个异步方法,用于在后台启动 commandLine 命令
async runBackgroundCommandLine(commandLine) {
// 创建一个用于存储子进程输出的缓冲区数组
const buffers = [];
// 使用 spawn 方法启动一个新的子进程,传入命令行参数和配置对象
const child = spawn(commandLine, {
// 指定使用 shell 运行 commandLine 命令
shell: true,
// 指定子进程的工作目录为工作空间根目录
cwd: WORKSPACE_ROOT,
// 配置子进程的标准输入被忽略,标准输出和标准错误重定向到管道
stdio: ["ignore", "pipe", "pipe"],
});
// 绑定子进程的标准输出流到缓冲区数组
bindStreamToBuffers(child.stdout, buffers);
// 绑定子进程的标准错误流到缓冲区数组
bindStreamToBuffers(child.stderr, buffers);
// 获取子进程的进程ID(PID)
const pid = child.pid;
// 将当前子进程的信息存储到 backgroundProcesses 映射中
backgroundProcesses.set(pid, {
// 记录启动该子进程使用的命令行字符串
commandLine,
// 记录子进程对象本身
child,
// 记录用于收集子进程输出的缓冲区数组
buffers
});
// 监听子进程的 "exit" 事件,在其退出时将其从 backgroundProcesses 移除
child.once("exit", () => backgroundProcesses.delete(pid));
// 等待后台进程初始化,延迟指定的时间(用于预热/启动)
await delay(CONFIG.backgroundWarmupMs);
// 返回启动成功的信息,包含进程ID、命令和启动阶段的输出信息
+ return `已在后台拉起子进程\n PID:${pid}\n 命令:${commandLine}\n 启动段输出:\n${buffersToText(buffers) || "(尚无输出)"}`;
}
}
// 工具名称到对应实例的映射表
const toolHandlerByName = {
// 读取文件工具
readFile: new ReadText(),
// 写入文件工具
writeFile: new WriteText(),
// 替换文件工具
editFile: new EditFile(),
// 枚举目录工具
listDir: new ListDir(),
// 执行命令工具
runCommand: new RunCommandLine(),
};
// 定义一个 ask 函数,返回一个 Promise,用于异步获取用户输入
const askLine = () =>
new Promise((resolve) => {
// 向控制台输出普通的提示符,并等待用户输入
rl.question("› ", resolve);
});
// 定义异步函数,用于执行一次工具调用
async function executeSingleToolCall(toolCallPayload) {
// 获取本次工具调用的名字
const name = toolCallPayload.function.name;
// 声明解析参数变量
let parsedArgs;
try {
// 尝试将工具调用的参数字符串解析为对象
parsedArgs = JSON.parse(toolCallPayload.function.arguments || "{}");
} catch {
// 如果解析失败,则设为空对象
parsedArgs = {};
}
// 控制台输出工具调用信息
console.log(`\n工具 ${name} 被调用`);
// 控制台输出工具的入参
console.log(`入参:${JSON.stringify(parsedArgs, null, 2)}`);
// 根据工具名获取对应处理器
const handler = toolHandlerByName[name];
// 判断是否有对应处理器,如有则执行,否则返回未实现提示
const textResult = handler
? await handler.run(parsedArgs)
: `✖ 未实现的工具:${name}`;
// 控制台输出工具调用的结果
console.log(`返回:${textResult}`);
// 构造并返回本次工具调用的完整回复对象
return {
// 设置角色为 tool
role: "tool",
// 工具调用对应的 id
tool_call_id: toolCallPayload.id,
// 工具名称
name,
// 工具返回的内容
content: textResult,
};
}
// 定义异步函数,驱动智能体连续推理直到文字回复或超步
async function runAgentUntilReplyOrMaxSteps(messages) {
// 初始化步数计数器
let step = 0;
// 循环,直到步数超过最大限制
while (step < CONFIG.agentMaxSteps) {
// 步数递增
step++;
// 控制台显示正在请求模型
console.log("\n请求模型中...");
// 向模型 API 发送消息请求回复
const completion = await openaiClient.chat.completions.create({
// 模型名称
model: "deepseek-v4-pro",
// 消息参数
messages: messages,
// 工具函数定义
tools: MODEL_TOOL_DEFINITIONS,
// 工具选择方式
tool_choice: "auto",
});
// 获取模型助手返回的消息对象
const assistantMessage = completion.choices[0].message;
// 控制台输出助手消息
console.log(JSON.stringify(assistantMessage, null, 2));
// 将助手消息加入对话消息历史
messages.push(assistantMessage);
// 获取本轮助手消息产生的工具调用
const calls = assistantMessage.tool_calls;
// 判断是否有工具调用,无则直接返回助手回复
if (!calls || calls.length === 0) {
// 返回助手文字回复
return assistantMessage;
}
// 并发执行每个工具调用,等待所有结果
const toolResponses = await Promise.all(calls.map(executeSingleToolCall));
// 将所有工具回复添加到消息历史
for (const row of toolResponses)
messages.push(row);
}
// 若达到最大步数仍未获得直接回复,则返回警告信息
return {
role: "assistant",
content: "⚠ 对话步数已达上限。",
};
}
// 定义异步主函数 main
async function main() {
//创建工作区目录
await fsp.mkdir(WORKSPACE_ROOT, { recursive: true });
// 无限循环,持续读取用户输入
for (; ;) {
// 创建一个消息列表,包含系统指令
const messages = [{ role: "system", content: AGENT_SYSTEM_INSTRUCTION }];
// 等待用户输入一行内容
const line = await askLine();
// 如果输入内容为空(只包含空白),则跳过本次循环
if (!line.trim()) continue;
// 如果输入的内容为 "q",则跳出循环,结束程序
if (line.trim() === "q") break;
// 将用户输入加入对话消息列表
messages.push({ role: "user", content: line });
// 调用 OpenAI API,发送所有消息,生成聊天回复
const reply = await runAgentUntilReplyOrMaxSteps(messages);
// 打印助手的回复到控制台
if (reply) console.log(`\nAssistant\n${reply.content}\n`);
}
// 关闭 readline 接口,释放资源
rl.close();
}
// 调用主函数 main,启动程序
main();9.2 时序图 #
下图对应 9.3 测试数据 的完整后台任务生命周期(task_list → task_logs → task_stop → 再 task_list)。
9.3 测试数据 #
› 执行命令 node server1.js
请求模型中...
{
"role": "assistant",
"content": "",
"reasoning_content": "The user wants me to execute the command `node server1.js`. Let me first check if this file exists and look at the directory structure.",
"tool_calls": [
{
"index": 0,
"id": "call_00_X9mPpE9DNORK0jTo653O0865",
"type": "function",
"function": {
"name": "runCommand",
"arguments": "{\"commandLine\": \"node server1.js\"}"
}
}
]
}
工具 runCommand 被调用
入参:{
"commandLine": "node server1.js"
}
返回:已在后台拉起子进程
PID:36580
命令:node server1.js
启动段输出:
Server running at http://localhost:1000/
请求模型中...
{
"role": "assistant",
"content": "服务已后台启动,进程 PID 为 **36580**。\n\n- 访问地址: http://localhost:1000/\n\n如需查看后续日志,请告诉 我;如需停止该服务,我也可以随时终止。",
"reasoning_content": "The server has been started in the background. It's running on http://localhost:1000/."
}
Assistant
服务已后台启动,进程 PID 为 **36580**。
- 访问地址: http://localhost:1000/
如需查看后续日志,请告诉我;如需停止该服务,我也可以随时终止。
› task_list
请求模型中...
{
"role": "assistant",
"content": "",
"reasoning_content": "The user wants me to list the background tasks. Let me use the runCommand with the task_list subcommand.",
"tool_calls": [
{
"index": 0,
"id": "call_00_JLb2UFYG8kNRBeQfDuB15978",
"type": "function",
"function": {
"name": "runCommand",
"arguments": "{\"commandLine\": \"task_list\"}"
}
}
]
}
工具 runCommand 被调用
入参:{
"commandLine": "task_list"
}
返回:【后台任务一览】
· 36580 活动中 | node server1.js
请求模型中...
task logs {
"role": "assistant",
"content": "当前共有 **1 个后台任务**在运行:\n\n| PID | 状态 | 命令 |\n|--------|--------|-------------------|\n| 36580 | 活动中 | `node server1.js` |\n\n需要我查看日志或停止该任务吗?",
"reasoning_content": "There's one background task running:\n\n- PID 36580: `node server1.js` (active)\n\nThe user just asked to list the tasks, so I'll present this clearly."
}
Assistant
当前共有 **1 个后台任务**在运行:
| PID | 状态 | 命令 |
|--------|--------|-------------------|
| 36580 | 活动中 | `node server1.js` |
需要我查看日志或停止该任务吗?
› task logs 36580
请求模型中...
{
"role": "assistant",
"content": "",
"reasoning_content": "The user wants to see the logs for a background task with PID 36580.",
"tool_calls": [
{
"index": 0,
"id": "call_00_ffRdY17mAzRvhth8Ee6G8287",
"type": "function",
"function": {
"name": "runCommand",
"arguments": "{\"commandLine\": \"task_logs 36580\"}"
}
}
]
}
工具 runCommand 被调用
入参:{
"commandLine": "task_logs 36580"
}
返回:【PID 36580 最近输出】
Server running at http://localhost:1000/
请求模型中...
{
"role": "assistant",
"content": "PID 36580 是一个正在运行的开发服务器,监听在 **http://localhost:1000/**,状态正常,暂无错误输出。",
"reasoning_content": "The task with PID 36580 is a server running at http://localhost:1000/. It appears to be running fine."
}
Assistant
PID 36580 是一个正在运行的开发服务器,监听在 **http://localhost:1000/**,状态正常,暂无错误输出。
› task stop 36580
请求模型中...
{
"role": "assistant",
"content": "",
"reasoning_content": "The user wants to stop a background task with PID 36580.",
"tool_calls": [
{
"index": 0,
"id": "call_00_7O7lcPnmGxczCYdD0qEg4493",
"type": "function",
"function": {
"name": "runCommand",
"arguments": "{\"commandLine\": \"task_stop 36580\"}"
}
}
]
}
工具 runCommand 被调用
入参:{
"commandLine": "task_stop 36580"
}
返回:已请求终止后台 PID=36580
请求模型中...
{
"role": "assistant",
"content": "已成功终止后台任务 PID=36580。",
"reasoning_content": "The task has been successfully stopped."
}
Assistant
已成功终止后台任务 PID=36580。
› task_list
请求模型中...
{
"role": "assistant",
"content": "",
"reasoning_content": "The user wants to see the list of background tasks.",
"tool_calls": [
{
"index": 0,
"id": "call_00_O4ICJeTbEZtVkzr56hhf7169",
"type": "function",
"function": {
"name": "runCommand",
"arguments": "{\"commandLine\": \"task_list\"}"
}
}
]
}
工具 runCommand 被调用
入参:{
"commandLine": "task_list"
}
返回:(当前没有已登记的后台任务)
请求模型中...
{
"role": "assistant",
"content": "当前没有已注册的后台任务。如果你需要启动某个开发服务器或其他常驻进程,告诉我命令即可。",
"reasoning_content": "The user asked for task_list and the result shows no background tasks are currently registered."
}
Assistant
当前没有已注册的后台任务。如果你需要启动某个开发服务器或其他常驻进程,告诉我命令即可。10. 含 runCommand 时同轮工具顺序执行 #
第 9 节在同一轮 API 回复里若模型返回多个 tool_calls,一律用 Promise.all 并发执行。对 runCommand 而言,并发可能导致「先 task_stop 再 task_list」等顺序敏感操作乱序。本节在 runAgentUntilReplyOrMaxSteps 中增加分支:只要本轮 tool_calls 里出现 runCommand,则整批工具按数组顺序依次 await 执行;否则仍并发(适合多个独立的 readFile / listDir)。
程序在做什么: 模型返回 tool_calls 后,先 calls.some((c) => c.function.name === "runCommand");为真则 for … await executeSingleToolCall(call),为假则 Promise.all。配套文件 workspace/a.txt(见 10.1)记录期望结果 helloworld;10.4 测试 演示模型多轮调用 writeFile / readFile 完成「先写 hello、再拼成 helloworld」——该用例每轮通常只有一个工具,重点展示多步 agent 循环;顺序执行主要惠及同一轮内多个 runCommand 或其它工具与命令混排的场景。
相对第 9 节的新增点:
- 顺序执行触发条件:本轮任意一个工具名为
runCommand。 - 整批顺序:一旦触发,同轮中的
readFile、writeFile也会排在runCommand前后按序执行,避免与命令副作用交错时的竞态。 - 未改工具实现:
ReadText/RunCommandLine等逻辑不变,仅调度策略变化。
10.1 a.txt #
workspace/a.txt
helloworld10.2 claude.js #
// 引入 Node.js 的 readline 模块,用于读取终端输入
const readline = require("readline");
// 加载 dotenv 配置文件,并允许覆盖已存在的环境变量
require("dotenv").config({ override: true });
// 引入 Node.js 的 path 模块,用于处理和转换文件路径
const path = require("path");
// 引入 Node.js 的 fs/promises 模块,提供基于 promise 的文件系统操作方法
const fsp = require("fs/promises");
// 从 openai 包中引入 OpenAI 类
const { OpenAI } = require("openai");
// 引入 Node.js 的 child_process 模块,用于执行 shell 命令
const { spawn, spawnSync } = require("child_process");
// Windows 控制台常见为 GBK(cp936);用 UTF-8 解码会得到乱码
const iconv = require("iconv-lite");
// 创建 OpenAI 客户端实例,使用 DeepSeek 的 API 密钥和自定义的 baseURL
const openaiClient = new OpenAI({
apiKey: process.env.DEEPSEEK_API_KEY,
baseURL: "https://api.deepseek.com",
});
// 定义代理的系统指令
const AGENT_SYSTEM_INSTRUCTION = `你是「Claude Code」—— 在受控 workspace 内协助用户读改代码、跑命令的助手。`;
// 存储所有后台拉起的子进程信息
const backgroundProcesses = new Map();
// 创建一个 readline 接口,指定输入输出为标准输入输出
const rl = readline.createInterface({
input: process.stdin,// 标准输入流
output: process.stdout,// 标准输出流
terminal: true,// 是否启用终端模式
});
// 监听 SIGINT(通常是 Ctrl+C)事件,只监听一次
rl.once("SIGINT", () => {
// 打印中断提示信息,换两行
console.log("\n\n⌁ 已中断,下次见。");
// 关闭 readline 接口
rl.close();
// 退出当前进程,状态码为 0(正常退出)
process.exit(0);
});
// 定义工作区根目录,位于当前进程的 workspace 文件夹内
const WORKSPACE_ROOT = path.resolve(process.cwd(), "workspace");
// 判断目标路径是否是根目录的子目录或根目录本身
function isDescendantOrSameDirectory(candidatePath) {
// 解析 candidatePath 为绝对路径
const targetAbs = path.resolve(candidatePath);
// 如果目标路径和工作区根目录完全相同,则返回 true
if (WORKSPACE_ROOT === targetAbs) return true;
// 计算目标路径相对于工作区根目录的相对路径
const relative = path.relative(WORKSPACE_ROOT, targetAbs);
// 如果不是空字符串,不以 .. 开头,也不是绝对路径,则说明在工作区内,返回 true
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
}
// 在工作区内解析路径,并校验是否越界
function resolvePathInsideWorkspace(relativePath) {
// 将相对路径或带 ~ 路径解析为工作区下的绝对路径
const candidatePath = path.resolve(WORKSPACE_ROOT, relativePath);
// 如果解析后的路径不在工作区内,则抛出异常
if (!isDescendantOrSameDirectory(candidatePath))
throw new Error(`路径越界:${candidatePath}`);
// 返回校验通过的绝对路径
return candidatePath;
}
// 文本读取工具实现
class ReadText {
// 执行读取操作,参数为 { path }
async run({ path: relativePath }) {
try {
// 调用文件系统 API 读取文件内容
return await fsp.readFile(resolvePathInsideWorkspace(relativePath), "utf8");
} catch (err) {
// 如果找不到文件,返回自定义提示;否则返回异常原因
return err.code === "ENOENT" ? `找不到文件:${relativePath}` : `读取异常:${err.message}`;
}
}
}
// 文本写入工具实现
class WriteText {
// 执行写入操作,参数为 { path, content }
async run({ path: relativePath, content }) {
try {
// 解析目标文件的绝对路径
const absolute = resolvePathInsideWorkspace(relativePath);
// 如果父级目录不存在则递归创建
await fsp.mkdir(path.dirname(absolute), { recursive: true });
// 写入内容到文件,使用 UTF-8 编码
await fsp.writeFile(absolute, content, "utf8");
// 返回写入结果以及写入字节数
return `已落盘 ${Buffer.byteLength(content, "utf8")} 字节 → ${relativePath}`;
} catch (err) {
// 返回写入过程中的异常信息
return `写入异常:${err.message}`;
}
}
}
// 定义 EditFile 类,用于精确修改文件内容
class EditFile {
// 定义异步运行方法,接受文件路径、要替换的旧文本、以及新文本
async run({ path: relativePath, oldText, newText }) {
try {
// 解析出目标文件的绝对路径,确保在工作区内
const absolute = resolvePathInsideWorkspace(relativePath);
// 以 UTF-8 编码读取文件内容
const before = await fsp.readFile(absolute, "utf8");
// 检查文件内容中是否包含需要被替换的片段,如果没有则返回提示
if (!before.includes(oldText)) return "✖ 文件中未出现与 oldText 完全一致的片段";
// 将第一次出现的 oldText 替换为 newText 并写回原文件
await fsp.writeFile(absolute, before.replace(oldText, newText), "utf8");
// 返回替换成功的提示
return `✔ 已就地替换一处:${relativePath}`;
} catch (err) {
// 异常处理:如果找不到文件则返回对应提示,否则返回错误信息
return err.code === "ENOENT" ? `✖ 找不到文件:${relativePath}` : `✖ 编辑异常:${err.message}`;
}
}
}
// 定义 ListDir 类,用于列出目录内容
class ListDir {
// 定义异步运行方法,参数为目录的相对路径
async run({ path: relativePath }) {
try {
// 解析出目标目录的绝对路径,确保在工作区内
const absolute = resolvePathInsideWorkspace(relativePath);
// 获取目标路径的文件状态信息
const stat = await fsp.stat(absolute);
// 判断目标路径是否为目录,如果不是则返回提示
if (!stat.isDirectory()) return `✖ 目标不是目录:${relativePath}`;
// 读取目录下的所有条目,获取类型信息
const entries = await fsp.readdir(absolute, { withFileTypes: true });
// 根据名称进行字典序排序
entries.sort((a, b) => a.name.localeCompare(b.name));
// 格式化每条目录项,区分文件与子目录
const rows = entries.map((ent) => `${ent.isDirectory() ? "目录:" : "文件:"} ${ent.name}`);
// 如果有内容则按行拼接返回,否则返回空目录提示
return rows.length ? rows.join("\n") : "(空目录)";
} catch (err) {
// 异常处理:目录不存在单独提示,否则返回错误信息
return err.code === "ENOENT" ? `✖ 目录不存在:${relativePath}` : `✖ 枚举失败:${err.message}`;
}
}
}
// 工具的 Schema 定义,描述每个工具的用途和参数结构
const MODEL_TOOL_DEFINITIONS = [
{
// 声明工具类型为函数
type: "function",
function: {
// 工具名称为 readFile
name: "readFile",
// 工具功能描述
description: "以 UTF-8 读取工作区内文本文件。",
// 工具参数定义
parameters: {
// 参数类型为对象
type: "object",
// 参数属性为 path,字符串类型
properties: { path: { type: "string" } },
// path 字段为必需参数
required: ["path"],
},
},
},
{
// 声明写入工具类型为函数
type: "function",
function: {
// 工具名称为 writeFile
name: "writeFile",
// 工具描述:新建或覆盖写入工作区内文件
description: "在工作区内新建或覆盖写入文件。",
// 工具参数定义
parameters: {
type: "object",
properties: {
// path 为文件路径,字符串类型
path: { type: "string" },
// content 为要写入的文本内容,字符串类型
content: { type: "string" },
},
// path 和 content 都必需
required: ["path", "content"],
},
},
},
// 定义一个函数工具,用于精确子串替换现有文件内容
{
// 工具类型为 function
type: "function",
// function 字段,定义具体工具内容
function: {
// 工具名称为 editFile
name: "editFile",
// 工具描述,说明功能为仅替换一次完全匹配的原文
description: "对已有文本做一次精确子串替换;oldText 必须与文件内容完全一致。",
// parameters 字段,定义参数信息
parameters: {
// 参数类型为对象
type: "object",
// properties 字段,定义具体参数
properties: {
// path:待修改的文件路径,类型为字符串,描述为待修改文件路径
path: { type: "string", description: "待修改文件路径" },
// oldText:要被替换的原文,只有唯一一次匹配,类型为字符串
oldText: {
type: "string",
description: "要被替换掉的原文(唯一一次匹配)",
},
// newText:用于替换的新文本,类型为字符串,描述为替换后的新文本
newText: { type: "string", description: "替换后的新文本" },
},
// 这三个参数都是必需的
required: ["path", "oldText", "newText"],
},
},
},
// 定义一个函数工具,用于罗列某个目录下的条目(文件和子文件夹)
{
// 工具类型为 function
type: "function",
// function 字段,定义具体工具内容
function: {
// 工具名称为 listDir
name: "listDir",
// 工具描述,说明功能为罗列目录结构,可区分文件夹
description: "罗列目录下的条目(含子文件夹标记),用于快速摸清目录结构。",
// parameters 字段,定义参数信息
parameters: {
// 参数类型为对象
type: "object",
// properties 字段,定义具体参数
properties: {
// path:要罗列的目录路径,类型为字符串,路径相对于 workspace,描述为目录路径(相对 workspace)
path: { type: "string", description: "目录路径(相对 workspace)" },
},
// path 是必需参数
required: ["path"],
},
},
},
// 定义工具对象,表示类型为 function
{
// 设置 type 字段为 "function",表示此为函数型工具
type: "function",
// function 字段,定义具体的函数内容
function: {
// name 字段,工具名称为 runCommand
name: "runCommand",
// description 字段,对工具功能做简要描述
description:
// 描述字符串:在 shell 中执行命令;短命令阻塞至结束...
"在 shell 中执行命令;短命令阻塞至结束,疑似开发服务器的命令会后台拉起。\n" +
"重要:命令会在当前工作目录执行,不要使用 cd 切换目录\n"+
"常规:install / build / test 等同步执行,stdout/stderr 汇总返回。\n" +
"常驻:pnpm dev、npm start、uvicorn、flask run 等——后台运行,约 8 秒后回传 PID 与启动阶段日志。\n" +
"后台子命令:\n" +
" task_list 列出已登记的后台任务\n" +
" task_logs <pid> 拉取该 PID 最近若干行合并输出\n" +
" task_stop <pid> 结束该后台任务",
// parameters 字段,定义参数类型和要求
parameters: {
// type 指定参数为对象形式
type: "object",
// properties 字段,详细定义各参数
properties: {
// commandLine 参数,定义为字符串类型
commandLine: {
// type 指明参数为字符串
type: "string",
// description 说明 commandLine 的用途
description: "Shell 命令行(例如:npm install)",
}
},
// required 字段,列出必传参数
required: ["commandLine"],
},
},
},
];
// 将子进程的 stdout/stderr Buffer 解码为可读字符串(Windows 中文环境多为 cp936 编码)
function decodeResultBuffer(buf) {
// 若 Buffer 为空或长度为0,直接返回空字符串
if (!buf || buf.length === 0) return "";
// 若当前系统是 Windows,则以 cp936 编码解码
if (process.platform === "win32") return iconv.decode(buf, "cp936");
// 其他系统则以 utf8 解码
return buf.toString("utf8");
}
// 定义常量配置对象
const CONFIG = {
// 智能体允许的最大步数
agentMaxSteps: 100,
// 后台进程预热时间 8秒
backgroundWarmupMs: 8000,
// task_logs 返回的最大行数
backgroundLogPreviewLines: 50,
};
// 定义 delay 函数,实现异步延迟
function delay(ms) {
// 返回一个 Promise,在指定毫秒数(ms)后调用 resolve,达到延迟效果
return new Promise((resolve) => setTimeout(resolve, ms));
// 定义一个异步延迟函数,参数为毫秒数
}
// 包含了会让开发环境长时间运行的命令字符串
const LONG_RUNNING_HINTS = [
// "dev" 表示开发模式
"dev",
// "start" 表示启动服务
"start",
// "serve" 表示启动服务器
"serve",
// "server" 表示启动开发服务器
"server",
// "watch" 表示监听文件变更
"watch",
// "run server" 表示运行服务器
"run server",
// "runserver" 另一种写法,运行服务器
"runserver",
// "preview" 表示预览模式
"preview",
// "nodemon" 表示自动重启开发服务器
"nodemon",
// "uvicorn" 表示 Python asgi 服务器
"uvicorn",
// "gunicorn" 表示 Python wsgi 服务器
"gunicorn",
// "flask run" 表示 Flask 的运行命令
"flask run",
// "vite" 表示前端构建工具
"vite",
// "webpack" 表示前端构建工具
"webpack",
// "--watch" 表示监听参数
"--watch",
// "--hot" 表示热更新参数
"--hot"
];
// 定义函数,用于判断给定命令行是否为疑似长时间运行的命令
function looksLikeLongRunningCommand(commandLine) {
// 去除命令行字符串首尾空白,并转为小写,标准化处理
const normalized = commandLine.trim().toLowerCase();
// 在长时间运行命令关键字集合 LONG_RUNNING_HINTS 中查找,判断 normalized 是否包含任一关键字
return LONG_RUNNING_HINTS.some((hint) => normalized.includes(hint));
}
// 定义函数:将 bindStreamToBuffers 收集的 Buffer 块解码为文本
function buffersToText(buffers) {
// 过滤掉为 null 的 chunk,得到有效的 Buffer 块
const chunks = buffers.filter((chunk) => chunk !== null);
// 如果 chunks 为空数组,说明没有内容,返回空字符串
if (!chunks.length) return "";
// 将所有 Buffer 块合并后,调用 decodeResultBuffer 进行解码为字符串
return decodeResultBuffer(Buffer.concat(chunks));
}
// 定义函数:取文本末尾指定行数,用于 task_logs 日志预览
function tailTextLines(text, maxLines) {
// 如果输入文本为空,直接返回空字符串
if (!text) return "";
// 按换行符分割文本成数组,取最后的 maxLines 行,再用换行符拼接成字符串返回
return text.split(/\r?\n/).slice(-maxLines).join("\n");
}
// 定义 bindStreamToBuffers 函数,将可读流绑定到缓冲区
function bindStreamToBuffers(readableStream, buffers) {
// 监听 "data" 事件,每收到一段数据时被调用
readableStream.on("data", (chunk) => {
// 将收到的数据块 chunk 压入缓冲区 buffers
buffers.push(chunk);
});
// 监听 "end" 事件,当数据流结束时被调用
readableStream.on("end", () => {
// 将 null 压入缓冲区 buffers,表示流已结束
buffers.push(null);
});
}
// 工具类:用于在工作空间目录中执行 commandLine 命令
class RunCommandLine {
// 检查并处理后台任务控制命令,比如 task_list
handleBackgroundTaskControlCommand(commandLine) {
// 判断是否为 task_list 指令(不区分大小写、后可带空白或行尾)
if (/^task_list(\s|$)/i.test(commandLine)) {
if (backgroundProcesses.size === 0) return "(当前没有已登记的后台任务)";
return [
"【后台任务一览】",
...[...backgroundProcesses].map(([pid, meta]) =>
` · ${pid} ${meta.child.exitCode === null ? "活动中" : "已结束"} | ${meta.commandLine}`
)
].join("\n");
}
// 匹配 task_logs <pid> 指令,获取日志查看请求
const logsMatch = /^task_logs\s+(\d+)\s*$/i.exec(commandLine.trim());
// 如果匹配成功
if (logsMatch) {
// 从匹配结果中获取 pid
const pid = Number.parseInt(logsMatch[1], 10);
// 检查后台进程集中是否有该 pid,没有则返回错误提示
if (!backgroundProcesses.has(pid)) return `✖ 未登记的后台 PID:${pid}`;
// 获取该 pid 对应的缓冲区 buffers
const { buffers } = backgroundProcesses.get(pid);
// 将缓冲区内容转换为文本,并截取末尾若干行
const text = tailTextLines(
buffersToText(buffers),
CONFIG.backgroundLogPreviewLines
);
// 返回格式化的输出内容
return `【PID ${pid} 最近输出】\n${text || "(尚无输出)"}`;
}
// 匹配 task_stop <pid> 指令,获取终止进程请求
const stopMatch = /^task_stop\s+(\d+)\s*$/i.exec(commandLine.trim());
// 如果匹配成功
if (stopMatch) {
// 从匹配结果中获取 pid
const pid = Number.parseInt(stopMatch[1], 10);
// 检查后台进程集中是否有该 pid,没有则返回错误提示
if (!backgroundProcesses.has(pid)) return `✖ 未登记的后台 PID:${pid}`;
// 获取该 pid 对应的子进程 child
const { child } = backgroundProcesses.get(pid);
try {
// 如果是 Windows 系统,则使用 taskkill 命令终止进程
if (process.platform === "win32") {
spawnSync(`taskkill /PID ${pid} /T /F`, { shell: true, stdio: "ignore" });
} else {
// 非 Windows 则通过向进程组发送 SIGTERM 信号终止进程
process.kill(-pid, "SIGTERM");
}
} catch {
// 如果上面 kill 失败,则直接对子进程对象调用 kill 方法
child.kill("SIGTERM");
}
// 从后台进程集删除该 pid
backgroundProcesses.delete(pid);
// 返回终止请求已发送的提示
return `已请求终止后台 PID=${pid}`;
}
return null;
}
// 异步运行 commandLine 命令,接收包含 commandLine 字段的对象参数
async run({ commandLine }) {
// 尝试解析是否为后台任务控制指令(如 task_list),如果是则优先返回对应回复
const backgroundTaskControlReply = this.handleBackgroundTaskControlCommand(commandLine);
// 如果解析出控制回复,不再继续后续命令执行,直接返回
if (backgroundTaskControlReply !== null) return backgroundTaskControlReply;
// 判断命令行是否看起来像是长时间运行的命令
if (looksLikeLongRunningCommand(commandLine)) {
// 如果是,则调用异步方法在后台运行命令行
return this.runBackgroundCommandLine(commandLine);
}
// 如果不是长时间运行的命令,则调用同步(阻塞)方法运行命令行
return this.runBlockingCommandLine(commandLine)
}
// 同步运行 commandLine 命令,并返回命令执行的文本结果
runBlockingCommandLine(commandLine) {
// 使用 spawnSync 同步执行命令行
const result = spawnSync(commandLine, {
// 指定使用 shell 运行 commandLine
shell: true,
// 指定工作目录为工作空间根目录
cwd: WORKSPACE_ROOT,
// 输出按二进制 Buffer 格式返回
encoding: "buffer",
// 最大缓冲区 10MB
maxBuffer: 10 * 1024 * 1024,
});
// 命令执行超时,则返回超时提示
if (result.error?.code === "ETIMEDOUT") return `✖ 同步命令超时:${commandLine}`;
// 若 stdout 有内容则解码,否则返回“stdout 为空”
let merged = result.stdout?.length ? decodeResultBuffer(result.stdout) : "(stdout 为空)";
// 若 stderr 有内容,则拼接 stderr 的解码结果
if (result.stderr?.length)
merged += `\n【stderr】\n${decodeResultBuffer(result.stderr)}`;
// 如果命令退出码非0,显示错误提示和全部输出,否则显示成功提示和输出
return result.status !== 0
? `退出码 ${result.status}\n${merged}`
: `命令完成\n${merged}`;
}
// 定义一个异步方法,用于在后台启动 commandLine 命令
async runBackgroundCommandLine(commandLine) {
// 创建一个用于存储子进程输出的缓冲区数组
const buffers = [];
// 使用 spawn 方法启动一个新的子进程,传入命令行参数和配置对象
const child = spawn(commandLine, {
// 指定使用 shell 运行 commandLine 命令
shell: true,
// 指定子进程的工作目录为工作空间根目录
cwd: WORKSPACE_ROOT,
// 配置子进程的标准输入被忽略,标准输出和标准错误重定向到管道
stdio: ["ignore", "pipe", "pipe"],
});
// 绑定子进程的标准输出流到缓冲区数组
bindStreamToBuffers(child.stdout, buffers);
// 绑定子进程的标准错误流到缓冲区数组
bindStreamToBuffers(child.stderr, buffers);
// 获取子进程的进程ID(PID)
const pid = child.pid;
// 将当前子进程的信息存储到 backgroundProcesses 映射中
backgroundProcesses.set(pid, {
// 记录启动该子进程使用的命令行字符串
commandLine,
// 记录子进程对象本身
child,
// 记录用于收集子进程输出的缓冲区数组
buffers
});
// 监听子进程的 "exit" 事件,在其退出时将其从 backgroundProcesses 移除
child.once("exit", () => backgroundProcesses.delete(pid));
// 等待后台进程初始化,延迟指定的时间(用于预热/启动)
await delay(CONFIG.backgroundWarmupMs);
// 返回启动成功的信息,包含进程ID、命令和启动阶段的输出信息
return `已在后台拉起子进程\n PID:${pid}\n 命令:${commandLine}\n 启动段输出:\n${buffersToText(buffers) || "(尚无输出)"}`;
}
}
// 工具名称到对应实例的映射表
const toolHandlerByName = {
// 读取文件工具
readFile: new ReadText(),
// 写入文件工具
writeFile: new WriteText(),
// 替换文件工具
editFile: new EditFile(),
// 枚举目录工具
listDir: new ListDir(),
// 执行命令工具
runCommand: new RunCommandLine(),
};
// 定义一个 ask 函数,返回一个 Promise,用于异步获取用户输入
const askLine = () =>
new Promise((resolve) => {
// 向控制台输出普通的提示符,并等待用户输入
rl.question("› ", resolve);
});
// 定义异步函数,用于执行一次工具调用
async function executeSingleToolCall(toolCallPayload) {
// 获取本次工具调用的名字
const name = toolCallPayload.function.name;
// 声明解析参数变量
let parsedArgs;
try {
// 尝试将工具调用的参数字符串解析为对象
parsedArgs = JSON.parse(toolCallPayload.function.arguments || "{}");
} catch {
// 如果解析失败,则设为空对象
parsedArgs = {};
}
// 控制台输出工具调用信息
console.log(`\n工具 ${name} 被调用`);
// 控制台输出工具的入参
console.log(`入参:${JSON.stringify(parsedArgs, null, 2)}`);
// 根据工具名获取对应处理器
const handler = toolHandlerByName[name];
// 判断是否有对应处理器,如有则执行,否则返回未实现提示
const textResult = handler
? await handler.run(parsedArgs)
: `✖ 未实现的工具:${name}`;
// 控制台输出工具调用的结果
console.log(`返回:${textResult}`);
// 构造并返回本次工具调用的完整回复对象
return {
// 设置角色为 tool
role: "tool",
// 工具调用对应的 id
tool_call_id: toolCallPayload.id,
// 工具名称
name,
// 工具返回的内容
content: textResult,
};
}
// 定义异步函数,驱动智能体连续推理直到文字回复或超步
async function runAgentUntilReplyOrMaxSteps(messages) {
// 初始化步数计数器
let step = 0;
// 循环,直到步数超过最大限制
while (step < CONFIG.agentMaxSteps) {
// 步数递增
step++;
// 控制台显示正在请求模型
console.log("\n请求模型中...");
// 向模型 API 发送消息请求回复
const completion = await openaiClient.chat.completions.create({
// 模型名称
model: "deepseek-v4-pro",
// 消息参数
messages: messages,
// 工具函数定义
tools: MODEL_TOOL_DEFINITIONS,
// 工具选择方式
tool_choice: "auto",
});
// 获取模型助手返回的消息对象
const assistantMessage = completion.choices[0].message;
// 控制台输出助手消息
console.log(JSON.stringify(assistantMessage, null, 2));
// 将助手消息加入对话消息历史
messages.push(assistantMessage);
// 获取本轮助手消息产生的工具调用
const calls = assistantMessage.tool_calls;
// 判断是否有工具调用,无则直接返回助手回复
if (!calls || calls.length === 0) {
// 返回助手文字回复
return assistantMessage;
}
+ // 判断是否存在名为 "exec" 的工具调用,决定是否顺序执行工具
+ const sequential = calls.some((call) => call.function.name === "runCommand");
+ // 声明保存工具响应的变量
+ let toolResponses;
+ if (sequential) {
+ // 如果需要顺序执行(如包含 runCommand),初始化响应数组
+ toolResponses = [];
+ // 依次执行每个工具调用,并将结果推入响应数组
+ for (const call of calls) toolResponses.push(await executeSingleToolCall(call));
+ } else {
+ // 否则并发执行所有工具调用,并收集所有结果
+ toolResponses = await Promise.all(calls.map(executeSingleToolCall));
+ }
// 将所有工具回复添加到消息历史
for (const row of toolResponses)
messages.push(row);
}
// 若达到最大步数仍未获得直接回复,则返回警告信息
return {
role: "assistant",
content: "⚠ 对话步数已达上限。",
};
}
// 定义异步主函数 main
async function main() {
//创建工作区目录
await fsp.mkdir(WORKSPACE_ROOT, { recursive: true });
// 无限循环,持续读取用户输入
for (; ;) {
// 创建一个消息列表,包含系统指令
const messages = [{ role: "system", content: AGENT_SYSTEM_INSTRUCTION }];
// 等待用户输入一行内容
const line = await askLine();
// 如果输入内容为空(只包含空白),则跳过本次循环
if (!line.trim()) continue;
// 如果输入的内容为 "q",则跳出循环,结束程序
if (line.trim() === "q") break;
// 将用户输入加入对话消息列表
messages.push({ role: "user", content: line });
// 调用 OpenAI API,发送所有消息,生成聊天回复
const reply = await runAgentUntilReplyOrMaxSteps(messages);
// 打印助手的回复到控制台
if (reply) console.log(`\nAssistant\n${reply.content}\n`);
}
// 关闭 readline 接口,释放资源
rl.close();
}
// 调用主函数 main,启动程序
main();10.3 时序图 #
左: 单轮内是否顺序执行的判定;右: 10.4 测试的多轮 agent 步进(每轮常仅一个工具)。
10.4 测试 #
› 写入一个a.txt的文件,再添加hello进去,然后再添加world进去
请求模型中...
{
"role": "assistant",
"content": "好的,我来分步骤完成:先创建 `a.txt` 写入 `hello`,再追加 `world`。",
"reasoning_content": "用户想要:\n1. 创建一个 a.txt 文件\n2. 先写入 \"hello\"\n3. 再添加 \"world\"\n\n我理解这是要分步骤操作:先创建文件并写入 \"hello\",然后追加 \"world\"(最终内容应该是 \"helloworld\" 或 \"hello world\")。我就按 \"helloworld\" 来理解(中间没有空格),分两次写入。\n\n让我先创建文件写入 \"hello\",然后再追加 \"world\"。",
"tool_calls": [
{
"index": 0,
"id": "call_00_1VF7ntXSPaiuhtFLKSpw6369",
"type": "function",
"function": {
"name": "writeFile",
"arguments": "{\"path\": \"a.txt\", \"content\": \"hello\"}"
}
}
]
}
工具 writeFile 被调用
入参:{
"path": "a.txt",
"content": "hello"
}
返回:已落盘 5 字节 → a.txt
请求模型中...
{
"role": "assistant",
"content": "已写入 `hello`。现在追加 `world`:",
"reasoning_content": "文件已创建,内容是 \"hello\"。现在需要追加 \"world\"。我得读取当前内容,然后追加 \"world\" 再写 回去。",
"tool_calls": [
{
"index": 0,
"id": "call_00_9q475F2aBxvjwxVdF8hw6687",
"type": "function",
"function": {
"name": "readFile",
"arguments": "{\"path\": \"a.txt\"}"
}
}
]
}
工具 readFile 被调用
入参:{
"path": "a.txt"
}
返回:hello
请求模型中...
{
"role": "assistant",
"content": "当前内容是 `hello`,现在在后面追加 `world`:",
"reasoning_content": "当前内容是 \"hello\",需要追加 \"world\",变成 \"helloworld\"。",
"tool_calls": [
{
"index": 0,
"id": "call_00_TLnyDGj6N2sdvTRDFqQn5306",
"type": "function",
"function": {
"name": "writeFile",
"arguments": "{\"content\": \"helloworld\", \"path\": \"a.txt\"}"
}
}
]
}
工具 writeFile 被调用
入参:{
"content": "helloworld",
"path": "a.txt"
}
返回:已落盘 10 字节 → a.txt
请求模型中...
{
"role": "assistant",
"content": "搞定了!来验证一下最终内容:",
"reasoning_content": "完成。让我确认一下最终内容。",
"tool_calls": [
{
"index": 0,
"id": "call_00_wyCafnJ8nh4Yw8kcVPgp6166",
"type": "function",
"function": {
"name": "readFile",
"arguments": "{\"path\": \"a.txt\"}"
}
}
]
}
工具 readFile 被调用
入参:{
"path": "a.txt"
}
返回:helloworld
请求模型中...
{
"role": "assistant",
"content": "全部完成!`a.txt` 的演变过程:\n\n1. 创建文件 → `hello`\n2. 追加 world → `helloworld`\n\n文件当前内容就 是 **helloworld**。",
"reasoning_content": "Done. The file a.txt now contains \"helloworld\"."
}
Assistant
全部完成!`a.txt` 的演变过程:
1. 创建文件 → `hello`
2. 追加 world → `helloworld`
文件当前内容就是 **helloworld**。
11. runCommand 执行前用户审批 #
第 10 节解决了「同轮多工具」的调度顺序,但 runCommand 仍会在用户未确认时直接 spawn / spawnSync,存在误删、误装依赖等风险。本节在 RunCommandLine.run 真正起子进程之前 插入人工审批:终端展示即将执行的 commandLine,用户可选 本次同意、以后全部同意 或 拒绝;拒绝时不启动子进程,并把 用户已拒绝执行命令: … 作为 tool 消息回给模型,由助手向用户说明。
程序在做什么:
alwaysApproveCommands(默认false):进程内标志;用户选2后置为true,同一会话内后续 shell 命令跳过询问。askCommandApproval(commandLine):用与主循环相同的rl.question阻塞等待1/2/3;非法输入则递归prompt()重问。- 挂接点:在
handleBackgroundTaskControlCommand之后、looksLikeLongRunningCommand分支之前;task_list/task_logs/task_stop为只读或管理类操作,不经过审批,避免打断查看后台任务。 - 拒绝路径:
decision.approved === false时直接return拒绝文案,不调用runBlockingCommandLine/runBackgroundCommandLine。 - 未改 agent 调度:
executeSingleToolCall→runCommand的调用链与第 10 节一致;审批只封装在RunCommandLine内部。
相对第 10 节的新增点:
| 选项 | 行为 |
|---|---|
1 本次同意 |
仅执行当前命令;下一条 runCommand 仍会弹出 [命令审批] |
2 以后全部同意 |
执行当前命令,并设 alwaysApproveCommands = true |
3 拒绝 |
不执行;工具返回拒绝文案,模型据此回复用户 |
说明: 「以后全部同意」仅保存在当前 Node 进程内存中,退出
node claude.js后恢复为逐条询问;未写入.env或磁盘。
11.1. claude.js #
claude.js
//用于读取终端输入
const readline = require('readline');
//加载.env配置文件,并允许覆盖已经存在的环境变量
require("dotenv").config({ override: true })
//从openai包引入OpenAI与大模型交互的客户端类
const { OpenAI } = require("openai")
//引入Node.js中的path模块,用于处理和转换文件的路径
const path = require("path");
//引入子进程模块,用于开启子进程执行同步或异步命令
const { spawn, spawnSync, exec } = require("child_process")
//WINDOWS控制台编码为GBK(cp936),如果直接用UTF8解码的话会乱码
const iconv = require("iconv-lite");
//引入fs/promises模块,用于读写文件,提供基于promise的文件系统操作方法
const fsp = require("fs/promises");
const { buffer } = require('stream/consumers');
//创建OpenAI的客户端实例,使用DeepSeek的APIKEY和baseURL
const openaiClient = new OpenAI({
baseURL: "https://api.deepseek.com",
apiKey: process.env.DEEPSEEK_API_KEY
})
//定义代理的系统提示词
const AGENT_SYSTEM_INSTRUCTION = `你是[Claude Code],在受控的workspace内协助用户读改代码,跑命令的智能助手`
//存储所有的后台拉起的子进程的注册表
const backgroundProcesses = new Map()
//创建一个readline接口,指定输入输出为标准输入输出
const rl = readline.createInterface({
input: process.stdin,//标准输入流
output: process.stdout,//标准输出流
terminal: true//是否启用终端模式
})
//监听SIGINT(通常指的是Ctrl+C)事件,只监听一次
rl.once('SIGINT', () => {
//打印中断提示信息,换两行
console.log("\n已中断,下次再见")
//关闭readline接口
rl.close()
//退出当前的进程,状态码设置为0(正常退出)
process.exit(0)
})
//定义工作区根目录,位于当前目录下面的workspace文件夹中
const WORKSPACE_ROOT = path.resolve(process.cwd(), "workspace")
function isDescendantOrSameDirectory(candidatePath) {
const targetAbs = path.resolve(candidatePath)
if (WORKSPACE_ROOT == targetAbs) return true
//计算目标路径相对于工作区根目录的相对路径
const relative = path.relative(WORKSPACE_ROOT, targetAbs)
//如果不是空字符串,不以..点,也不是绝对路径,则说明在工作区内,可以返回true
return relative != "" && !relative.startsWith("..") && !path.isAbsolute(relative)
}
//在工作区内解析路径,并校验是否越界
function resolvePathInsideWorkspace(relativePath) {
//将相对路径解析为工作区下的绝对路径
const candidatePath = path.resolve(WORKSPACE_ROOT, relativePath)
//如果说解析后的路径不在工作区内,就意味着路径越界了,抛出异常
if (!isDescendantOrSameDirectory(candidatePath)) {
throw new Error(`路径越界:${candidatePath}`)
}
//返回校验通过的绝对路径
return candidatePath
}
//文本读取工具的实现
class ReadText {
//执行读取操作 参数为{path}
async run({ path: relativePath }) {
try {
return await fsp.readFile(resolvePathInsideWorkspace(relativePath), 'utf-8')
} catch (err) {
//如果找不到文件,则返回自定义的提示,否则返回异常的原因
return err.code === 'ENOENT' ? `找不到文件:${relativePath}` : `读取异常:${err.message}`
}
}
}
//文本写入工具的实现
class WriteText {
//执行写入操作 参数为{path,content}
async run({ path: relativePath, content }) {
try {
//解析目标文件的绝对路径
const absolute = resolvePathInsideWorkspace(relativePath)
//如果父级目录不存在则需要递归创建
await fsp.mkdir(path.dirname(absolute), { recursive: true })
//写入内容到文件中,使用UTF8编码
await fsp.writeFile(absolute, content, "utf-8")
return `已落盘${Buffer.byteLength(content, 'utf-8')}字节 -> ${relativePath}`
} catch (err) {
return `写入异常:${err.message}`
}
}
}
//用于精确修改文件的内容
class EditFile {
//接收文件路径,要替换的旧文本,以及新文本
async run({ path: relativePath, oldText, newText }) {
try {
//解析目标文件的绝对路径
const absolute = resolvePathInsideWorkspace(relativePath)
//以UTF8编码读取文件的内容
const before = await fsp.readFile(absolute, 'utf-8')
//检查原来的文件内容是是否包含被替换的文件,如果没有则给出提示
if (!before.includes(oldText)) return `文件中未出现与oldText完全一致的片段`
//将第一次出现的oldText替换为newText,并写回原文件
await fsp.writeFile(absolute, before.replace(oldText, newText), 'utf-8')
return `已成功替换了文件内容:${relativePath}`
} catch (err) {
//如果找不到文件,则返回自定义的提示,否则返回异常的原因
return err.code === 'ENOENT' ? `找不到文件:${relativePath}` : `读取异常:${err.message}`
}
}
}
//列出目录里的内容
class ListDir {
//执行写入操作 参数为{path,content}
async run({ path: relativePath }) {
try {
//解析目标文件的绝对路径
const absolute = resolvePathInsideWorkspace(relativePath)
//获取目标路径的文件状态信息
const stat = await fsp.stat(absolute)
//判断目标路径是否为目录,如果不是目录则返回报错信息
if (!stat.isDirectory()) {
return `目标路径不是一个目录:{relativePath}`
}
//读取目录下面的所有的条目,并且获取类型信息
const entries = await fsp.readdir(absolute, { withFileTypes: true })
//根据名称进行字典排序
entries.sort((a, b) => a.name.localeCompare(b.name))
//格式化每个条目,区分目录还是文件
const rows = entries.map((entry) => `${entry.isDirectory() ? "目录:" : "文件:"} ${entry.name}`)
//如果有内容则按行拼接返回,否则 返回空目录提示
return rows.length > 0 ? rows.join("\n") : "(空目录)"
} catch (err) {
//如果找不到文件,则返回自定义的提示,否则返回异常的原因
return err.code === 'ENOENT' ? `找不到文件:${relativePath}` : `读取异常:${err.message}`
}
}
}
//工具的Schema定义,描述每个工具的用途和参数结构
const MODEL_TOOL_DEFINATIONS = [
{
"type": "function",//声明工具的类型为函数
"function": {
name: "readFile",//工具的名称为readFile
description: "以UTF-8编码读取工作区内的文本文件",//工具功能描述
parameters: {//工具参数定义
type: "object",//参数类型为对象
properties: { path: { type: "string" } },//参数属性为path,字符串类型
required: ["path"]//要想调用这个工具,必须传递path参数,path参数是必需的参数
}
}
},
{
"type": "function",//声明工具的类型为函数
"function": {
name: "writeFile",//工具的名称为writeFile
description: "在工作区内新建或覆盖写入文件",//工具功能描述
parameters: {//工具参数定义
type: "object",//参数类型为对象
properties: {
path: { type: "string", "description": "文件路径" },//参数属性为path,字符串类型
content: { type: "string" }//conent为要写入的文件内容,字符串类型
},
required: ["path", "content"]//要想调用这个工具,必须传递path和content参数,path参数和content参数都必需的参数
}
}
},
{
"type": "function",//声明工具的类型为函数
"function": {
name: "editFile",//工具的名称为editFile
description: "对已有文本做一次精确的子串替换,oldText必须与文件内容完全一致",//工具功能描述
parameters: {//工具参数定义
type: "object",//参数类型为对象
properties: {
path: { type: "string", "description": "待修改的文件路径" },//参数属性为path,字符串类型
oldText: { type: "string", "description": "要被替换的原文(唯一一次匹配)" },
newText: { type: "string", "description": "替换后的新文本" },
},
required: ["path", "oldText", "newText"]//要想调用这个工具,必须传递path和content参数,path参数和content参数都必需的参数
}
}
},
{
"type": "function",//声明工具的类型为函数
"function": {
name: "listDir",//工具的名称为listDir
description: "罗列目录下的条目(包含子文件夹标识),用于快速摸清目录结构",//工具功能描述
parameters: {//工具参数定义
type: "object",//参数类型为对象
properties: {
path: { type: "string", "description": "文件路径" },//参数属性为path,字符串类型
},
required: ["path", "content"]//要想调用这个工具,必须传递path参数
}
}
},
{
"type": "function",//声明工具的类型为函数
"function": {
name: "runCommand",//工具的名称为runCommand
description:
"在shell中执行命令,短命令阻塞直至结束,疑似开发服务器的命令会在后台拉起\n" +
"重要:命令会在当前的目录中执行,不要轻易使用cd命令切换目录\n" +
"常规: install / build / test 等同步执行,stdout和stderr汇总返回\n"+
"常驻: pnpm dev 、npm start、uvicorn 、flask run 等后台运行,约8秒后回传PID与启动阶段日志。\n"+
"后台子命令:\n"+
" task_list 列出已登记的后台任务列表\n"+
" task_logs <pid> 拉取该PID最近若干行合并输出\n"+
" task_stop <pid> 结束该PID对应的后台任务\n"
,//工具功能描述
parameters: {//工具参数定义
type: "object",//参数类型为对象
properties: {
commandLine: { type: "string", "description": "shell命令行(例如 npm install)" },//参数属性为commandLine,字符串类型
},
required: ["commandLine"]//要想调用这个工具,必须传递path参数
}
}
}
]
//将子进程stdout/stderr的Buffer输出解码为可读的字符串(windows中文环境一般为cp936)
function decodeResultBuffer(buffer) {
if (!buffer || buffer.length == 0) return ""
//如果是windows,则以cp936
if (process.platform === 'win32') return iconv.decode(buffer, 'cp936')
//其它 系统都用 UTF8
return buffer.toString('utf8')
}
// 包含了会让开发环境长时间运行的命令字符串
const LONG_RUNNING_HINTS = [
// "dev" 表示开发模式
"dev",
// "start" 表示启动服务
"start",
// "serve" 表示启动服务器
"serve",
// "server" 表示启动开发服务器
"server",
// "watch" 表示监听文件变更
"watch",
// "run server" 表示运行服务器
"run server",
// "runserver" 另一种写法,运行服务器
"runserver",
// "preview" 表示预览模式
"preview",
// "nodemon" 表示自动重启开发服务器
"nodemon",
// "uvicorn" 表示 Python asgi 服务器
"uvicorn",
// "gunicorn" 表示 Python wsgi 服务器
"gunicorn",
// "flask run" 表示 Flask 的运行命令
"flask run",
// "vite" 表示前端构建工具
"vite",
// "webpack" 表示前端构建工具
"webpack",
// "--watch" 表示监听参数
"--watch",
// "--hot" 表示热更新参数
"--hot"
];
function looksLikeLongRunningCommand(commandLine) {
//去除字符串收尾空格并且转小写格式化
const normalized = commandLine.trim().toLowerCase()
//在长时间运行命令关键字集合中查找,判断normalized是否任意一个关键字
return LONG_RUNNING_HINTS.some(hint=>normalized.includes(hint))
}
//将标准输出和错误输出二个可读流绑定到缓冲区数组中
function bindStreamToBuffers(readableStream,buffers){
//监听可读流的数据,当可读流有数据发射的时候,会把这个数据放在buffers里,就是说你在子进程中 console.log('world') process.stdout.write('hello')
readableStream.on('data',(chunk)=>buffers.push(chunk))
readableStream.on('end',(chunk)=>buffers.push(null))
}
//将bindStreamToBuffers收集到的buffers数组转成文本字符串
function buffersToText(buffers){
const chunks = buffers.filter(chunk=>chunk!=null)
if (!chunks.length)return ""
return decodeResultBuffer(Buffer.concat(chunks))
}
//取文本末尾的指定行数,用于task_logs日志预览
function tailTextLines(text,maxLines){
if (!text)return ""
return text.split(/\r?\n/).slice(-maxLines).join('\n')
}
+// 标记是否全部同意,默认 false,用户选择 2 后会变为 true
+let alwaysApproveCommands = false
+// 定义 askCommandApproval 函数,用于在执行 shell 命令前请求用户审批(task_* 指令不经过此流程)
+function askCommandApproval(commandLine) {
+ // 返回一个 Promise 异步执行审批流程
+ return new Promise((resolve) => {
+ // 定义嵌套的 prompt 提示函数,用于在用户输入无效时递归调用
+ const prompt = () => {
+ // 控制台输出命令审批提示
+ console.log("\n[命令审批] 即将执行:")
+ // 控制台输出即将执行的命令内容
+ console.log(` ${commandLine}`)
+ // 提示选项 1)本次同意
+ console.log(" 1) 本次同意")
+ // 提示选项 2)以后全部同意
+ console.log(" 2) 以后全部同意")
+ // 提示选项 3)拒绝
+ console.log(" 3) 拒绝")
+ // 询问用户输入审批编号
+ rl.question("请选择 [1/2/3]: ", (answer) => {
+ // 去除输入前后空格,获取用户选择
+ const choice = answer.trim()
+ // 如果选择 1,本次同意,resolve 返回 approved: true
+ if (choice === "1") {
+ resolve({ approved: true })
+ // 如果选择 2,后续全部同意,设置标志位并提示
+ } else if (choice === "2") {
+ alwaysApproveCommands = true
+ // 控制台输出记住状态的说明
+ console.log("已记住:后续命令将自动执行,不再询问。")
+ // 仍然本次同意,resolve 返回 approved: true
+ resolve({ approved: true })
+ // 如果选择 3,拒绝执行该命令
+ } else if (choice === "3") {
+ resolve({ approved: false })
+ // 其他无效输入,重新调用 prompt 函数
+ } else {
+ // 控制台输出无效输入提示
+ console.log("无效输入,请输入 1、2 或 3。")
+ prompt()
+ }
+ })
+ }
+ // 初始调用 prompt 函数
+ prompt()
+ })
+}
class RunCommandLine {
//检查并处理后台任务控制指令 比如task_list task_logs task_stop
handleBackgroundTaskControlCommand(commandLine){
//判断是否为task_list指令 不区分大小写,后面可带空格或行尾
if (/^task_list(\s|$)/i.test(commandLine)){
if (backgroundProcesses.size==0) return `当前没有已登记的后台任务`
return [
"[后台任务一览]",
...[...backgroundProcesses].map(([pid,meta])=>`- ${pid} ${meta.child.exitCode==null?"活动中":"已结束"} ${meta.commandLine}`)
].join('\n')
}
//匹配task_logs pid 指令 ,用于获取指定的后台进程的日志 task_logs 1000
const logsMatch = /^task_logs\s+(\d+)\s*$/i.exec(commandLine.trim())
if(logsMatch){
//从匹配的结果中获取PID进程ID这个数字
const pid = Number.parseInt(logsMatch[1],10)
if(!backgroundProcesses.has(pid)) return `未登记的后台PID:${pid}`
//获取该PID对应的缓冲区buffers
const {buffers}=backgroundProcesses.get(pid)
//将缓冲区列表中的文件转为字符串文本,并截取末尾的若干行
const text = tailTextLines(buffersToText(buffers),CONFIG.backgroundLogPreviewLines)
return `[PID ${pid}最近的输出]\n${text||(尚无输出)}`
}
//匹配task_stop pid 指令 ,用于终止指定进程ID的子进程
const stopMatch = /^task_stop\s+(\d+)\s*$/i.exec(commandLine.trim())
if(stopMatch){
//从匹配的结果中获取PID进程ID这个数字
const pid = Number.parseInt(stopMatch[1],10)
if(!backgroundProcesses.has(pid)) return `未登记的后台PID:${pid}`
//获取该PID对应的子进程
const {child}= backgroundProcesses.get(pid)
try{
//如果是windows系统的话,使用taskkill命令终止进程
if (process.platform=='win32'){
spawnSync(`taskkill /PID ${pid} \T \F`,{shell:true,stdio:"ignore"})
}else{
//非windows则通过向进程组发送SIGTERM信号终止进程
process.kill(-pid,"SIGTERM")
}
}catch{
//如果上面的kill都失败了,则直接向子进程对象调用kill方法
child.kill("SIGTERM")
}
//从后台进程注册表中删除该pid
backgroundProcesses.delete(pid)
//返回中止进程的提示
return `已请求终止后台进程 PID={pid}`
}
return null
}
//异步的方式运行commandLine命令,接收包含commandLine字段的对象参数
async run({ commandLine }) {
//尝试解析是否为后台任务控制指令(比如说task_list),如果是的话优先返回对应的回复
const backgroundTaskControlReply = this.handleBackgroundTaskControlCommand(commandLine)
//如果解析出是控制后台任务的命令,则不再继续向下执行,直接返回结果
if (backgroundTaskControlReply!=null){
return backgroundTaskControlReply
}
+ // 如果不是总是自动批准命令
+ if (!alwaysApproveCommands) {
+ // 异步请求用户对命令行命令进行批准
+ const decision = await askCommandApproval(commandLine)
+ // 如果用户没有批准这个命令
+ if (!decision.approved) {
+ // 返回用户拒绝执行命令的提示信息
+ return `用户已拒绝执行命令: ${commandLine}`
+ }
+ }
//判断命令行是否看起来像是一个长时间运行的命令
if (looksLikeLongRunningCommand(commandLine)) {
//调用异步非阻塞的方式运行commandLine这个shell命令
return this.runBackgroundCommandLine(commandLine)
} else {
//调用同步阻塞的方式运行commandLine这个shell命令
return this.runBlockingCommandLine(commandLine)
}
}
async runBackgroundCommandLine(commandLine){
//创建一个存储子进程输出的缓冲区数组
const buffers = []
//使用spawn启动一个新的子进程,传入命令行和配置对象
const child = spawn(commandLine,{
shell:true,
cwd:WORKSPACE_ROOT,//指定子进程的工作目录为
stdio:["ignore","pipe",'pipe']//配置子进程的标准输入为忽略,标准输出和错误输出重定向到管道中
})
//绑定子进程的标准输出流到buffers缓存区数组中
bindStreamToBuffers(child.stdout,buffers)
//绑定子进程的错误输出流到buffers缓存区数组中
bindStreamToBuffers(child.stderr,buffers)
//获取子进程的进程 ID
const pid = child.pid
//当启动后台任务的子进程的时候,把当前的子进程信息存储到backgroundProcesses映射中
backgroundProcesses.set(
pid,{
commandLine,//记录启动该子进程使用的命令行字符串
child,//记录子进程本身
buffers//记录用于收集子进程输出的缓冲区buffer数组
}
)
//监听了进程的退出事件,当子进程退出的时候将其从backgroundProcesses移除
child.once('exit',()=>backgroundProcesses.delete(pid))
//等待后台进行中初始化,延迟指定的时间(用于后台命令的启动和预热)
await delay(CONFIG.backgroundWarmupMs)
return `已经在后台成功启动了子进程\n PID:${pid}\n命令:${commandLine}\n 启动阶段输出:\n${Buffer.concat(buffers).toString('utf-8')||("尚未输出")}`
}
//同步运行commandLine命令,并返回命令执行结果
runBlockingCommandLine(commandLine) {
//使用spawnSync同步执行commandLine命令
const result = spawnSync(commandLine, {
//指定使用shell运行commandLine
shell: true,
//指定工作目录为工作空间根目录
cwd: WORKSPACE_ROOT,
//输出二进制Buffer格式
encoding: 'buffer',
//最大缓冲区设置为10M
maxBuffer: 10 * 1024 * 1024
})
// 如果命令执行超时,则返回超时提示
if (result.error?.code === "ETIMEOUT") return "同步命令超时:{commandLine}"
//如果有标准输出,则解码否则 返回空
let merged = result.stdout?.length ? decodeResultBuffer(result.stdout) : "(stdout为空)"
//如果有错误输出,则拼接错误输出到merged
if (result.stderr?.length) {
merged += `\n[stderr] \n ${decodeResultBuffer(result.stderr)}`
}
return result.status != 0 ? ` 退出码:${result.status}\n${merged}` : `命令正常完成\n${merged}`
}
}
//工具名称到对应的实例映射表
const toolHandlerByName = {
//读取文件工具
readFile: new ReadText(),
//写入文件的工具
writeFile: new WriteText(),
//替换文件工具
editFile: new EditFile(),
//枚举目录工具
listDir: new ListDir(),
//枚举目录工具
runCommand: new RunCommandLine()
}
//定义一个askLine函数,返回一个Promise,用于异步获取用户输入
const askLine = () => new Promise((resolve) => {
//向控制台输出普通的提示符,并等待用户的输入
rl.question("> ", resolve)
})
//定义常量配置对象
const CONFIG = {
//智能体允许的最大步数
agentMaxSteps: 100,
//后台进程预热时间 8s
backgroundWarmupMs: 8000,
//task_logs返回的最大行数
backgroundLogPreviewLines:50
}
//实现一个异步延迟的delay函数
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
//定义异步函数,用于执行一次工具调用
async function executeSingleToolCall(toolCallPayload) {
//获取本次工具调用的名称 writeFile
const name = toolCallPayload.function.name
let parsedArgs = {}
try {
parsedArgs = JSON.parse(toolCallPayload.function.arguments)
} catch {
parsedArgs = {}
}
console.log(`\n工具${name}被调用`)
console.log(`工具入参:${JSON.stringify(parsedArgs, null, 2)}`)
//根据工具名称获取对应的处理器
const handler = toolHandlerByName[name]
//判断是否有对应的处理器,如果 有则执行处理器的run方法,如果没有未返回未实现这个工具
const textResult = handler ? await handler.run(parsedArgs) : `未实现的工具:${name}`
console.log(`返回:${textResult}`)
//构建并返回本次工具调用的完整回复消息对象
return {
role: 'tool',//设置消息的角色为tool
tool_call_id: toolCallPayload.id,//工具调用ID
name,//工具名称
content: textResult//工具返回的内容
}
}
//定义一个异步函数,驱动智能体连续推理直到获得文件回复或超过最长步数为止
async function runAgentUntilReplyOrMaxSteps(messages) {
//初始化步数计数器
let step = 0
//执行循环,直到步数超过最大限制
while (step < CONFIG.agentMaxSteps) {
//步数递增
step++
console.log(`\n 请求模型中...`)
//向大模型的API发送消息请求回复
const completion = await openaiClient.chat.completions.create({
model: "deepseek-v4-pro",//模型名称
"messages": messages,//消息列表
tools: MODEL_TOOL_DEFINATIONS,//传递给大模型的工具函数定义说明列表
tool_choice: "auto"//工具的选择方法
})
//获取 模型返回的消息
const assistantMessage = completion.choices[0].message
//打印返回的消息
console.log(JSON.stringify(assistantMessage, null, 2))
//把助手消息添加到对话历史中
messages.push(assistantMessage)
// 获取本轮助手消息产生的工具调用
const tool_calls = assistantMessage.tool_calls
//判断是否有工具调用,如果无工具调用则直接返回助手回复
if (!tool_calls || tool_calls.length == 0) {
return assistantMessage
}
//判断是否存在命令执行,决定是否是顺序调用工具还是并行调用工具
const sequential = tool_calls.some(tool_call=>tool_call.function.name === 'runCommand')
let toolResponses=[];
//如果需要串行顺序执行
if(sequential){
//依次按顺序执行工具调用,并将结果推入响应数组中
for(const tool_call of tool_calls){
const toolResponse = await executeSingleToolCall(tool_call)
toolResponses.push(toolResponse)
}
}else{
//否则并发执行所有的工具调用,并收集所有的结果
toolResponses=await Promise.all(tool_calls.map(executeSingleToolCall))
}
//将所有的工具返回的消息添加到消息列表中
for (const toolResponse of toolResponses) {
messages.push(toolResponse)
}
}
return {
role: 'assistant',
content: "对话步数已达上限"
}
}
//定义异步主函数
async function main() {
//创建工作区目录
await fsp.mkdir(WORKSPACE_ROOT, { recursive: true })
//创建一个消息列表,包含系统指令
const messages = [{ "role": "system", "content": AGENT_SYSTEM_INSTRUCTION }]
//无限循环,持续读取用户的输入
for (; ;) {
//等待用户输入完成一行的内容
const line = await askLine()
//如果用户输入的内容为空,也就是说只包含空白的话,则跳过本次循环
if (!line.trim()) continue;
//如果用户输入内容为q或者quit ,则跳出循环,结束程序
if (line.trim() == "q") break;
///btw的消息不往上下文窗口也不是messages列表里放
//将用户输入内容加入到对话消息列表中
messages.push({ "role": "user", "content": line })
//调用OPENAI的API,发送消息获取回复
const reply = await runAgentUntilReplyOrMaxSteps(messages)
if (reply) {
console.log(`\nAssistant:\n ${reply.content}`)
}
}
rl.close()
}
main()11.2 时序图 #
左: 普通 shell 命令经审批后同步/后台执行;右: 用户拒绝时子进程不创建,结果仍进入 agent 上下文。
11.3 测试 #
> 执行命令 npm install
请求模型中...
{
"role": "assistant",
"content": "",
"reasoning_content": "用户想要执行 npm install 命令。让我在workspace中运行这个命令。",
"tool_calls": [
{
"index": 0,
"id": "call_00_Gb3bi31yNsMbHqIXKeZx5345",
"type": "function",
"function": {
"name": "runCommand",
"arguments": "{\"commandLine\": \"npm install\"}"
}
}
]
}
工具runCommand被调用
工具入参:{
"commandLine": "npm install"
}
[命令审批] 即将执行:
npm install
1) 本次同意
2) 以后全部同意
3) 拒绝
请选择 [1/2/3]: 1
返回:命令正常完成
up to date, audited 5 packages in 3s
2 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
[stderr]
npm warn Unknown user config "home". This will stop working in the next major version of npm.
请求模型中...
{
"role": "assistant",
"content": "`npm install` 已成功完成,所有 5 个依赖包均是最新版本,没有发现漏洞。\n\n⚠️ 有一个警告:`npm warn Unknown user config \"home\"`,提示 `home` 这个用户配置项在 npm 的下一个大版本中将不再生效。如果这是你全局 `.npmrc` 中的自定义配置,可以考虑移除。",
"reasoning_content": "npm install 已成功完成。所有 5 个包均已是最新版本,且没有发现漏洞。有一个关于 `home` 用户配置的 警告,但这不影响使用。"
}
Assistant:
`npm install` 已成功完成,所有 5 个依赖包均是最新版本,没有发现漏洞。
⚠️ 有一个警告:`npm warn Unknown user config "home"`,提示 `home` 这个用户配置项在 npm 的下一个大版本中将不再生效。如 果这是你全局 `.npmrc` 中的自定义配置,可以考虑移除。
> 执行命令 npm install
请求模型中...
{
"role": "assistant",
"content": "",
"reasoning_content": "The user wants to run npm install again. Let me just execute it.",
"tool_calls": [
{
"index": 0,
"id": "call_00_STXnEcuVNefPriDT1pk51091",
"type": "function",
"function": {
"name": "runCommand",
"arguments": "{\"commandLine\": \"npm install\"}"
}
}
]
}
工具runCommand被调用
工具入参:{
"commandLine": "npm install"
}
[命令审批] 即将执行:
npm install
1) 本次同意
2) 以后全部同意
3) 拒绝
请选择 [1/2/3]: 3
返回:用户已拒绝执行命令: npm install
请求模型中...
{
"role": "assistant",
"content": "命令已被取消。如果还需要执行其他操作,请告诉我。",
"reasoning_content": "用户拒绝了执行命令。看起来可能是误操作或者不想再执行了。我直接告知即可。"
}
Assistant:
命令已被取消。如果还需要执行其他操作,请告诉我。
> 执行命令 npm install
请求模型中...
{
"role": "assistant",
"content": "",
"reasoning_content": "The user wants to run npm install again.",
"tool_calls": [
{
"index": 0,
"id": "call_00_TX8UPJpluRybey306X9D3787",
"type": "function",
"function": {
"name": "runCommand",
"arguments": "{\"commandLine\": \"npm install\"}"
}
}
]
}
工具runCommand被调用
工具入参:{
"commandLine": "npm install"
}
[命令审批] 即将执行:
npm install
1) 本次同意
2) 以后全部同意
3) 拒绝
请选择 [1/2/3]: 2
已记住:后续命令将自动执行,不再询问。
返回:命令正常完成
up to date, audited 5 packages in 1s
2 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
[stderr]
npm warn Unknown user config "home". This will stop working in the next major version of npm.
请求模型中...
{
"role": "assistant",
"content": "`npm install` 已完成,所有 5 个包都是最新的,没有漏洞。和之前一样,有一个关于 `home` 用户配置的 npm 警告,不影响使用。",
"reasoning_content": "npm install completed successfully again, same result as before."
}
Assistant:
`npm install` 已完成,所有 5 个包都是最新的,没有漏洞。和之前一样,有一个关于 `home` 用户配置的 npm 警告,不影响使用。
> 执行命令 npm install
请求模型中...
{
"role": "assistant",
"content": "",
"reasoning_content": "The user is asking me to run npm install yet again. Let me just do it.",
"tool_calls": [
{
"index": 0,
"id": "call_00_RN8k7PEzTkickq1TToHV3555",
"type": "function",
"function": {
"name": "runCommand",
"arguments": "{\"commandLine\": \"npm install\"}"
}
}
]
}
工具runCommand被调用
工具入参:{
"commandLine": "npm install"
}
返回:命令正常完成
up to date, audited 5 packages in 1s
2 packages are looking for funding
run `npm fund` for details
found 0 vulnerabilities
[stderr]
npm warn Unknown user config "home". This will stop working in the next major version of npm.
请求模型中...
{
"role": "assistant",
"content": "已完成,结果与之前一致——所有包都是最新的,没有漏洞。",
"reasoning_content": "Same result again. Everything is up to date."
}
Assistant:
已完成,结果与之前一致——所有包都是最新的,没有漏洞。
>
12. 接入 MCP:通过 stdio 子进程扩展模型工具 #
第 11 节及之前的工具(readFile、runCommand 等)全部写在 claude.js 进程内。本节引入 Model Context Protocol (MCP):由独立子进程 mcp_server.js 注册工具,mcp_client.js 经 stdio 长连接拉取工具列表并转发调用,claude.js 在启动时合并进 activeModelToolDefinitions,使 DeepSeek 与本地工具、greet 等 MCP 工具共用同一套 tool_calls 流程。
程序在做什么:
mcp_server.js:用@modelcontextprotocol/sdk创建McpServer,经StdioServerTransport监听 stdin/stdout;示例注册greet工具(参数name,返回你好,{name}!)。mcp_client.js:StdioClientTransport以node mcp_server.js拉起服务端 →client.connect→listTools→mcpToolToOpenAI转成 OpenAIfunction格式;callMcpTool调用client.callTool并用mcpResultToText抽取text块。claude.js挂接:main()开头await connectMcpServer()+refreshModelToolDefinitions(),失败则console.warn,仅保留本地工具;runAgentUntilReplyOrMaxSteps的tools改为activeModelToolDefinitions(本地 + MCP);executeSingleToolCall中isMcpTool(name)走callMcpTool,否则仍走toolHandlerByName;- 退出时
closeMcpConnection()关闭子进程连接。
- 与第 11 节关系:
runCommand仍走本地RunCommandLine及命令审批;MCP 工具名不在toolHandlerByName中,由toolNames集合区分。
相对第 11 节的新增点:
| 组件 | 职责 |
|---|---|
LOCAL_MODEL_TOOL_DEFINITIONS |
原内置五工具定义,与 MCP 解耦 |
activeModelToolDefinitions |
发给 API 的合并列表 |
refreshModelToolDefinitions() |
连接成功后把 getMcpOpenAiTools() 拼到本地定义后 |
| stdio 子进程 | MCP 服务与主进程隔离,可独立增删工具而不改 agent 核心 |
依赖: 需先执行
npm install @modelcontextprotocol/sdk(服务端示例还用zod)。
12.1. mcp_client.js #
npm install @modelcontextprotocol/sdk zodmcp_client.js
/**
* 通过 stdio 长连接 mcp-server.js,供 claude.js 调用 greet 等工具
*/
// 导入 path 模块,用于处理文件路径
const path = require("path");
// 从 MCP SDK 导入 Client 构造器
const { Client } = require("@modelcontextprotocol/sdk/client/index.js");
// 从 MCP SDK 导入 StdioClientTransport 构造器
const { StdioClientTransport } = require("@modelcontextprotocol/sdk/client/stdio.js");
// 获取 MCP 服务端脚本的路径
const SERVER_SCRIPT = path.join(__dirname, "mcp_server.js");
// 定义全局 client 实例,初始为 null
let client = null;
// 保存可用工具名字的集合
const toolNames = new Set();
// 保存适用于 OpenAI 的工具定义列表
let openAiToolDefinitions = [];
// MCP 工具定义转为 OpenAI 工具定义
function mcpToolToOpenAI(tool) {
// 获取输入参数的 schema,默认为空对象
const schema = tool.inputSchema || { type: "object", properties: {} };
// 返回符合 OpenAI 的工具定义对象
return {
// 类型为 function
type: "function",
// function 字段,描述工具
function: {
// 工具名称
name: tool.name,
// 工具描述,不存在则用默认值
description: tool.description || `MCP 工具:${tool.name}`,
// 工具参数
parameters: schema,
},
};
}
// MCP 执行结果转为纯文本
function mcpResultToText(result) {
// 没有内容时返回默认提示
if (!result?.content?.length) return "(MCP 无返回内容)";
// 返回拼接后的字符串,仅包含所有 text 类型内容
return result.content
// 筛选类型为 text 的内容块
.filter((block) => block.type === "text")
// 取出文本
.map((block) => block.text)
// 用换行连接
.join("\n");
}
// 连接 MCP 服务端(通过 stdio)
async function connectMcpServer() {
// 如果已经连接,则直接返回相关参数
if (client) return { client, toolNames, openAiToolDefinitions };
// 创建 stdio 传输用于连接 MCP 服务端
const transport = new StdioClientTransport({
// 指定 Node.js 可执行路径
command: process.execPath,
// 启动 MCP 服务端脚本
args: [SERVER_SCRIPT],
// 指定当前工作目录
cwd: path.dirname(SERVER_SCRIPT),
// 标准错误输出使用管道
stderr: "pipe",
});
// 初始化客户端对象,设置名称和版本
client = new Client({ name: "claude-code-mcp-client", version: "1.0.0" });
// 建立连接
await client.connect(transport);
// 获取全部工具列表
const { tools } = await client.listTools();
// 工具定义转换为 OpenAI 格式
openAiToolDefinitions = tools.map(mcpToolToOpenAI);
// 清空工具名集合
toolNames.clear();
// 添加每个工具名称到集合
for (const t of tools) toolNames.add(t.name);
// 控制台输出连接成功信息和可用工具名
console.log(
`已连接 MCP(stdio → ${SERVER_SCRIPT}),工具:${[...toolNames].join(", ") || "无"}`
);
// 返回相关对象
return { client, toolNames, openAiToolDefinitions };
}
// 调用 MCP 工具
async function callMcpTool(name, args) {
// 未连接 MCP 时抛出异常
if (!client) throw new Error("MCP 未连接");
// 调用指定工具,传递参数
const result = await client.callTool({ name, arguments: args || {} });
// 返回文本化结果
return mcpResultToText(result);
}
// 判断给定名称是否是 MCP 工具
function isMcpTool(name) {
// 查询工具名集合
return toolNames.has(name);
}
// 获取所有 OpenAI 格式的 MCP 工具定义
function getMcpOpenAiTools() {
// 返回 OpenAI 工具定义列表
return openAiToolDefinitions;
}
// 关闭 MCP 连接与清理资源
async function closeMcpConnection() {
// 如果已连接
if (client) {
// 关闭连接
await client.close();
// 置空 client
client = null;
// 清空工具名集合
toolNames.clear();
// 清空工具定义数组
openAiToolDefinitions = [];
}
}
// 导出所有接口方法
module.exports = {
// 连接 MCP 服务端
connectMcpServer,
// 调用 MCP 工具
callMcpTool,
// 判断给定名称是否是 MCP 工具
isMcpTool,
// 获取所有 OpenAI 格式的 MCP 工具定义
getMcpOpenAiTools,
// 关闭 MCP 连接与清理资源
closeMcpConnection,
};
12.2. mcp_server.js #
mcp_server.js
// 导入McpServer类
const { McpServer } = require("@modelcontextprotocol/sdk/server/mcp.js");
// 导入StdioServerTransport类
const { StdioServerTransport } = require("@modelcontextprotocol/sdk/server/stdio.js");
// 导入zod库用于数据验证
const { z } = require("zod");
// 创建一个名为"greeting-server",版本为"1.0.0"的McpServer实例
const server = new McpServer({ name: "greeting-server", version: "1.0.0" });
// 注册一个名为"greet"的工具到server上
server.registerTool(
// 工具名称为"greet"
"greet",
{
// 工具描述
description: "通过名字向某人问好",
// 工具输入参数的Schema定义
inputSchema: {
// name参数是字符串类型,并带有描述
name: z.string().describe("要问候的人的名字")
}
},
// 实现工具功能的异步回调函数
async ({ name }) => ({
// 返回的内容,类型为"text",文本内容为“你好,xxx!”
content: [{ type: "text", text: `你好,${name}!` }],
})
);
// 定义主函数
async function main() {
// 创建标准输入输出传输层实例
const transport = new StdioServerTransport();
// 让server通过transport连接
await server.connect(transport);
}
// 调用主函数,如果出错则捕获错误并退出进程
main().catch((err) => {
// 打印错误信息
console.error(err);
// 用非正常状态码退出进程
process.exit(1);
});
12.3. claude.js #
claude.js
//用于读取终端输入
const readline = require('readline');
//加载.env配置文件,并允许覆盖已经存在的环境变量
require("dotenv").config({ override: true })
//从openai包引入OpenAI与大模型交互的客户端类
const { OpenAI } = require("openai")
//引入Node.js中的path模块,用于处理和转换文件的路径
const path = require("path");
//引入子进程模块,用于开启子进程执行同步或异步命令
const { spawn, spawnSync, exec } = require("child_process")
//WINDOWS控制台编码为GBK(cp936),如果直接用UTF8解码的话会乱码
const iconv = require("iconv-lite");
//引入fs/promises模块,用于读写文件,提供基于promise的文件系统操作方法
const fsp = require("fs/promises");
const { buffer } = require('stream/consumers');
//创建OpenAI的客户端实例,使用DeepSeek的APIKEY和baseURL
const openaiClient = new OpenAI({
baseURL: "https://api.deepseek.com",
apiKey: process.env.DEEPSEEK_API_KEY
})
+// 引入 MCP 客户端模块
+const {
+ connectMcpServer, //连接 MCP 服务端
+ callMcpTool, //调用 MCP 工具
+ isMcpTool, //判断给定名称是否是 MCP 工具
+ getMcpOpenAiTools, //获取所有 OpenAI 格式的 MCP 工具定义
+ closeMcpConnection, //关闭 MCP 连接与清理资源
+} = require("./mcp_client");
//定义代理的系统提示词
+const AGENT_SYSTEM_INSTRUCTION = `你是「Claude Code」—— 在受控 workspace 内协助用户读改代码、跑命令的助手。当用户要求执行某些操作时,可调用 MCP 工具。`
//存储所有的后台拉起的子进程的注册表
const backgroundProcesses = new Map()
//创建一个readline接口,指定输入输出为标准输入输出
const rl = readline.createInterface({
input: process.stdin,//标准输入流
output: process.stdout,//标准输出流
terminal: true//是否启用终端模式
})
//监听SIGINT(通常指的是Ctrl+C)事件,只监听一次
rl.once('SIGINT', () => {
//打印中断提示信息,换两行
console.log("\n已中断,下次再见")
//关闭readline接口
rl.close()
//退出当前的进程,状态码设置为0(正常退出)
process.exit(0)
})
//定义工作区根目录,位于当前目录下面的workspace文件夹中
const WORKSPACE_ROOT = path.resolve(process.cwd(), "workspace")
function isDescendantOrSameDirectory(candidatePath) {
const targetAbs = path.resolve(candidatePath)
if (WORKSPACE_ROOT == targetAbs) return true
//计算目标路径相对于工作区根目录的相对路径
const relative = path.relative(WORKSPACE_ROOT, targetAbs)
//如果不是空字符串,不以..点,也不是绝对路径,则说明在工作区内,可以返回true
return relative != "" && !relative.startsWith("..") && !path.isAbsolute(relative)
}
//在工作区内解析路径,并校验是否越界
function resolvePathInsideWorkspace(relativePath) {
//将相对路径解析为工作区下的绝对路径
const candidatePath = path.resolve(WORKSPACE_ROOT, relativePath)
//如果说解析后的路径不在工作区内,就意味着路径越界了,抛出异常
if (!isDescendantOrSameDirectory(candidatePath)) {
throw new Error(`路径越界:${candidatePath}`)
}
//返回校验通过的绝对路径
return candidatePath
}
//文本读取工具的实现
class ReadText {
//执行读取操作 参数为{path}
async run({ path: relativePath }) {
try {
return await fsp.readFile(resolvePathInsideWorkspace(relativePath), 'utf-8')
} catch (err) {
//如果找不到文件,则返回自定义的提示,否则返回异常的原因
return err.code === 'ENOENT' ? `找不到文件:${relativePath}` : `读取异常:${err.message}`
}
}
}
//文本写入工具的实现
class WriteText {
//执行写入操作 参数为{path,content}
async run({ path: relativePath, content }) {
try {
//解析目标文件的绝对路径
const absolute = resolvePathInsideWorkspace(relativePath)
//如果父级目录不存在则需要递归创建
await fsp.mkdir(path.dirname(absolute), { recursive: true })
//写入内容到文件中,使用UTF8编码
await fsp.writeFile(absolute, content, "utf-8")
return `已落盘${Buffer.byteLength(content, 'utf-8')}字节 -> ${relativePath}`
} catch (err) {
return `写入异常:${err.message}`
}
}
}
//用于精确修改文件的内容
class EditFile {
//接收文件路径,要替换的旧文本,以及新文本
async run({ path: relativePath, oldText, newText }) {
try {
//解析目标文件的绝对路径
const absolute = resolvePathInsideWorkspace(relativePath)
//以UTF8编码读取文件的内容
const before = await fsp.readFile(absolute, 'utf-8')
//检查原来的文件内容是是否包含被替换的文件,如果没有则给出提示
if (!before.includes(oldText)) return `文件中未出现与oldText完全一致的片段`
//将第一次出现的oldText替换为newText,并写回原文件
await fsp.writeFile(absolute, before.replace(oldText, newText), 'utf-8')
return `已成功替换了文件内容:${relativePath}`
} catch (err) {
//如果找不到文件,则返回自定义的提示,否则返回异常的原因
return err.code === 'ENOENT' ? `找不到文件:${relativePath}` : `读取异常:${err.message}`
}
}
}
//列出目录里的内容
class ListDir {
//执行写入操作 参数为{path,content}
async run({ path: relativePath }) {
try {
//解析目标文件的绝对路径
const absolute = resolvePathInsideWorkspace(relativePath)
//获取目标路径的文件状态信息
const stat = await fsp.stat(absolute)
//判断目标路径是否为目录,如果不是目录则返回报错信息
if (!stat.isDirectory()) {
return `目标路径不是一个目录:{relativePath}`
}
//读取目录下面的所有的条目,并且获取类型信息
const entries = await fsp.readdir(absolute, { withFileTypes: true })
//根据名称进行字典排序
entries.sort((a, b) => a.name.localeCompare(b.name))
//格式化每个条目,区分目录还是文件
const rows = entries.map((entry) => `${entry.isDirectory() ? "目录:" : "文件:"} ${entry.name}`)
//如果有内容则按行拼接返回,否则 返回空目录提示
return rows.length > 0 ? rows.join("\n") : "(空目录)"
} catch (err) {
//如果找不到文件,则返回自定义的提示,否则返回异常的原因
return err.code === 'ENOENT' ? `找不到文件:${relativePath}` : `读取异常:${err.message}`
}
}
}
+//本地工具的 Schema 定义
+const LOCAL_MODEL_TOOL_DEFINITIONS = [
{
"type": "function",//声明工具的类型为函数
"function": {
name: "readFile",//工具的名称为readFile
description: "以UTF-8编码读取工作区内的文本文件",//工具功能描述
parameters: {//工具参数定义
type: "object",//参数类型为对象
properties: { path: { type: "string" } },//参数属性为path,字符串类型
required: ["path"]//要想调用这个工具,必须传递path参数,path参数是必需的参数
}
}
},
{
"type": "function",//声明工具的类型为函数
"function": {
name: "writeFile",//工具的名称为writeFile
description: "在工作区内新建或覆盖写入文件",//工具功能描述
parameters: {//工具参数定义
type: "object",//参数类型为对象
properties: {
path: { type: "string", "description": "文件路径" },//参数属性为path,字符串类型
content: { type: "string" }//conent为要写入的文件内容,字符串类型
},
required: ["path", "content"]//要想调用这个工具,必须传递path和content参数,path参数和content参数都必需的参数
}
}
},
{
"type": "function",//声明工具的类型为函数
"function": {
name: "editFile",//工具的名称为editFile
description: "对已有文本做一次精确的子串替换,oldText必须与文件内容完全一致",//工具功能描述
parameters: {//工具参数定义
type: "object",//参数类型为对象
properties: {
path: { type: "string", "description": "待修改的文件路径" },//参数属性为path,字符串类型
oldText: { type: "string", "description": "要被替换的原文(唯一一次匹配)" },
newText: { type: "string", "description": "替换后的新文本" },
},
required: ["path", "oldText", "newText"]//要想调用这个工具,必须传递path和content参数,path参数和content参数都必需的参数
}
}
},
{
"type": "function",//声明工具的类型为函数
"function": {
name: "listDir",//工具的名称为listDir
description: "罗列目录下的条目(包含子文件夹标识),用于快速摸清目录结构",//工具功能描述
parameters: {//工具参数定义
type: "object",//参数类型为对象
properties: {
path: { type: "string", "description": "文件路径" },//参数属性为path,字符串类型
},
required: ["path", "content"]//要想调用这个工具,必须传递path参数
}
}
},
{
"type": "function",//声明工具的类型为函数
"function": {
name: "runCommand",//工具的名称为runCommand
description:
"在shell中执行命令,短命令阻塞直至结束,疑似开发服务器的命令会在后台拉起\n" +
"重要:命令会在当前的目录中执行,不要轻易使用cd命令切换目录\n" +
"常规: install / build / test 等同步执行,stdout和stderr汇总返回\n"+
"常驻: pnpm dev 、npm start、uvicorn 、flask run 等后台运行,约8秒后回传PID与启动阶段日志。\n"+
"后台子命令:\n"+
" task_list 列出已登记的后台任务列表\n"+
" task_logs <pid> 拉取该PID最近若干行合并输出\n"+
" task_stop <pid> 结束该PID对应的后台任务\n"
,//工具功能描述
parameters: {//工具参数定义
type: "object",//参数类型为对象
properties: {
commandLine: { type: "string", "description": "shell命令行(例如 npm install)" },//参数属性为commandLine,字符串类型
},
required: ["commandLine"]//要想调用这个工具,必须传递path参数
}
}
}
]
+// 定义一个变量 activeModelToolDefinitions,初始值为 LOCAL_MODEL_TOOL_DEFINITIONS 的浅拷贝
+let activeModelToolDefinitions = [...LOCAL_MODEL_TOOL_DEFINITIONS];
+// 定义刷新模型工具定义的函数
+function refreshModelToolDefinitions() {
+ // 将 activeModelToolDefinitions 更新为 LOCAL_MODEL_TOOL_DEFINITIONS 和 getMcpOpenAiTools() 的合并数组
+ activeModelToolDefinitions = [
+ ...LOCAL_MODEL_TOOL_DEFINITIONS,
+ ...getMcpOpenAiTools(),
+ ];
+}
//将子进程stdout/stderr的Buffer输出解码为可读的字符串(windows中文环境一般为cp936)
function decodeResultBuffer(buffer) {
if (!buffer || buffer.length == 0) return ""
//如果是windows,则以cp936
if (process.platform === 'win32') return iconv.decode(buffer, 'cp936')
//其它 系统都用 UTF8
return buffer.toString('utf8')
}
// 包含了会让开发环境长时间运行的命令字符串
const LONG_RUNNING_HINTS = [
// "dev" 表示开发模式
"dev",
// "start" 表示启动服务
"start",
// "serve" 表示启动服务器
"serve",
// "server" 表示启动开发服务器
"server",
// "watch" 表示监听文件变更
"watch",
// "run server" 表示运行服务器
"run server",
// "runserver" 另一种写法,运行服务器
"runserver",
// "preview" 表示预览模式
"preview",
// "nodemon" 表示自动重启开发服务器
"nodemon",
// "uvicorn" 表示 Python asgi 服务器
"uvicorn",
// "gunicorn" 表示 Python wsgi 服务器
"gunicorn",
// "flask run" 表示 Flask 的运行命令
"flask run",
// "vite" 表示前端构建工具
"vite",
// "webpack" 表示前端构建工具
"webpack",
// "--watch" 表示监听参数
"--watch",
// "--hot" 表示热更新参数
"--hot"
];
function looksLikeLongRunningCommand(commandLine) {
//去除字符串收尾空格并且转小写格式化
const normalized = commandLine.trim().toLowerCase()
//在长时间运行命令关键字集合中查找,判断normalized是否任意一个关键字
return LONG_RUNNING_HINTS.some(hint => normalized.includes(hint))
}
//将标准输出和错误输出二个可读流绑定到缓冲区数组中
function bindStreamToBuffers(readableStream, buffers) {
//监听可读流的数据,当可读流有数据发射的时候,会把这个数据放在buffers里,就是说你在子进程中 console.log('world') process.stdout.write('hello')
readableStream.on('data', (chunk) => buffers.push(chunk))
readableStream.on('end', (chunk) => buffers.push(null))
}
//将bindStreamToBuffers收集到的buffers数组转成文本字符串
function buffersToText(buffers) {
const chunks = buffers.filter(chunk => chunk != null)
if (!chunks.length) return ""
return decodeResultBuffer(Buffer.concat(chunks))
}
//取文本末尾的指定行数,用于task_logs日志预览
function tailTextLines(text, maxLines) {
if (!text) return ""
return text.split(/\r?\n/).slice(-maxLines).join('\n')
}
// 标记是否全部同意,默认 false,用户选择 2 后会变为 true
let alwaysApproveCommands = false
// 定义 askCommandApproval 函数,用于在执行 shell 命令前请求用户审批(task_* 指令不经过此流程)
function askCommandApproval(commandLine) {
// 返回一个 Promise 异步执行审批流程
return new Promise((resolve) => {
// 定义嵌套的 prompt 提示函数,用于在用户输入无效时递归调用
const prompt = () => {
// 控制台输出命令审批提示
console.log("\n[命令审批] 即将执行:")
// 控制台输出即将执行的命令内容
console.log(` ${commandLine}`)
// 提示选项 1)本次同意
console.log(" 1) 本次同意")
// 提示选项 2)以后全部同意
console.log(" 2) 以后全部同意")
// 提示选项 3)拒绝
console.log(" 3) 拒绝")
// 询问用户输入审批编号
rl.question("请选择 [1/2/3]: ", (answer) => {
// 去除输入前后空格,获取用户选择
const choice = answer.trim()
// 如果选择 1,本次同意,resolve 返回 approved: true
if (choice === "1") {
resolve({ approved: true })
// 如果选择 2,后续全部同意,设置标志位并提示
} else if (choice === "2") {
alwaysApproveCommands = true
// 控制台输出记住状态的说明
console.log("已记住:后续命令将自动执行,不再询问。")
// 仍然本次同意,resolve 返回 approved: true
resolve({ approved: true })
// 如果选择 3,拒绝执行该命令
} else if (choice === "3") {
resolve({ approved: false })
// 其他无效输入,重新调用 prompt 函数
} else {
// 控制台输出无效输入提示
console.log("无效输入,请输入 1、2 或 3。")
prompt()
}
})
}
// 初始调用 prompt 函数
prompt()
})
}
class RunCommandLine {
//检查并处理后台任务控制指令 比如task_list task_logs task_stop
handleBackgroundTaskControlCommand(commandLine) {
//判断是否为task_list指令 不区分大小写,后面可带空格或行尾
if (/^task_list(\s|$)/i.test(commandLine)) {
if (backgroundProcesses.size == 0) return `当前没有已登记的后台任务`
return [
"[后台任务一览]",
...[...backgroundProcesses].map(([pid, meta]) => `- ${pid} ${meta.child.exitCode == null ? "活动中" : "已结束"} ${meta.commandLine}`)
].join('\n')
}
//匹配task_logs pid 指令 ,用于获取指定的后台进程的日志 task_logs 1000
const logsMatch = /^task_logs\s+(\d+)\s*$/i.exec(commandLine.trim())
if (logsMatch) {
//从匹配的结果中获取PID进程ID这个数字
const pid = Number.parseInt(logsMatch[1], 10)
if (!backgroundProcesses.has(pid)) return `未登记的后台PID:${pid}`
//获取该PID对应的缓冲区buffers
const { buffers } = backgroundProcesses.get(pid)
//将缓冲区列表中的文件转为字符串文本,并截取末尾的若干行
const text = tailTextLines(buffersToText(buffers), CONFIG.backgroundLogPreviewLines)
return `[PID ${pid}最近的输出]\n${text || (尚无输出)}`
}
//匹配task_stop pid 指令 ,用于终止指定进程ID的子进程
const stopMatch = /^task_stop\s+(\d+)\s*$/i.exec(commandLine.trim())
if (stopMatch) {
//从匹配的结果中获取PID进程ID这个数字
const pid = Number.parseInt(stopMatch[1], 10)
if (!backgroundProcesses.has(pid)) return `未登记的后台PID:${pid}`
//获取该PID对应的子进程
const { child } = backgroundProcesses.get(pid)
try {
//如果是windows系统的话,使用taskkill命令终止进程
if (process.platform == 'win32') {
spawnSync(`taskkill /PID ${pid} \T \F`, { shell: true, stdio: "ignore" })
} else {
//非windows则通过向进程组发送SIGTERM信号终止进程
process.kill(-pid, "SIGTERM")
}
} catch {
//如果上面的kill都失败了,则直接向子进程对象调用kill方法
child.kill("SIGTERM")
}
//从后台进程注册表中删除该pid
backgroundProcesses.delete(pid)
//返回中止进程的提示
return `已请求终止后台进程 PID={pid}`
}
return null
}
//异步的方式运行commandLine命令,接收包含commandLine字段的对象参数
async run({ commandLine }) {
//尝试解析是否为后台任务控制指令(比如说task_list),如果是的话优先返回对应的回复
const backgroundTaskControlReply = this.handleBackgroundTaskControlCommand(commandLine)
//如果解析出是控制后台任务的命令,则不再继续向下执行,直接返回结果
if (backgroundTaskControlReply != null) {
return backgroundTaskControlReply
}
// 如果不是总是自动批准命令
if (!alwaysApproveCommands) {
// 异步请求用户对命令行命令进行批准
const decision = await askCommandApproval(commandLine)
// 如果用户没有批准这个命令
if (!decision.approved) {
// 返回用户拒绝执行命令的提示信息
return `用户已拒绝执行命令: ${commandLine}`
}
}
//判断命令行是否看起来像是一个长时间运行的命令
if (looksLikeLongRunningCommand(commandLine)) {
//调用异步非阻塞的方式运行commandLine这个shell命令
return this.runBackgroundCommandLine(commandLine)
} else {
//调用同步阻塞的方式运行commandLine这个shell命令
return this.runBlockingCommandLine(commandLine)
}
}
async runBackgroundCommandLine(commandLine) {
//创建一个存储子进程输出的缓冲区数组
const buffers = []
//使用spawn启动一个新的子进程,传入命令行和配置对象
const child = spawn(commandLine, {
shell: true,
cwd: WORKSPACE_ROOT,//指定子进程的工作目录为
stdio: ["ignore", "pipe", 'pipe']//配置子进程的标准输入为忽略,标准输出和错误输出重定向到管道中
})
//绑定子进程的标准输出流到buffers缓存区数组中
bindStreamToBuffers(child.stdout, buffers)
//绑定子进程的错误输出流到buffers缓存区数组中
bindStreamToBuffers(child.stderr, buffers)
//获取子进程的进程 ID
const pid = child.pid
//当启动后台任务的子进程的时候,把当前的子进程信息存储到backgroundProcesses映射中
backgroundProcesses.set(
pid, {
commandLine,//记录启动该子进程使用的命令行字符串
child,//记录子进程本身
buffers//记录用于收集子进程输出的缓冲区buffer数组
}
)
//监听了进程的退出事件,当子进程退出的时候将其从backgroundProcesses移除
child.once('exit', () => backgroundProcesses.delete(pid))
//等待后台进行中初始化,延迟指定的时间(用于后台命令的启动和预热)
await delay(CONFIG.backgroundWarmupMs)
return `已经在后台成功启动了子进程\n PID:${pid}\n命令:${commandLine}\n 启动阶段输出:\n${Buffer.concat(buffers).toString('utf-8') || ("尚未输出")}`
}
//同步运行commandLine命令,并返回命令执行结果
runBlockingCommandLine(commandLine) {
//使用spawnSync同步执行commandLine命令
const result = spawnSync(commandLine, {
//指定使用shell运行commandLine
shell: true,
//指定工作目录为工作空间根目录
cwd: WORKSPACE_ROOT,
//输出二进制Buffer格式
encoding: 'buffer',
//最大缓冲区设置为10M
maxBuffer: 10 * 1024 * 1024
})
// 如果命令执行超时,则返回超时提示
if (result.error?.code === "ETIMEOUT") return "同步命令超时:{commandLine}"
//如果有标准输出,则解码否则 返回空
let merged = result.stdout?.length ? decodeResultBuffer(result.stdout) : "(stdout为空)"
//如果有错误输出,则拼接错误输出到merged
if (result.stderr?.length) {
merged += `\n[stderr] \n ${decodeResultBuffer(result.stderr)}`
}
return result.status != 0 ? ` 退出码:${result.status}\n${merged}` : `命令正常完成\n${merged}`
}
}
//工具名称到对应的实例映射表
const toolHandlerByName = {
//读取文件工具
readFile: new ReadText(),
//写入文件的工具
writeFile: new WriteText(),
//替换文件工具
editFile: new EditFile(),
//枚举目录工具
listDir: new ListDir(),
//枚举目录工具
runCommand: new RunCommandLine()
}
//定义一个askLine函数,返回一个Promise,用于异步获取用户输入
const askLine = () => new Promise((resolve) => {
//向控制台输出普通的提示符,并等待用户的输入
rl.question("> ", resolve)
})
//定义常量配置对象
const CONFIG = {
//智能体允许的最大步数
agentMaxSteps: 100,
//后台进程预热时间 8s
backgroundWarmupMs: 8000,
//task_logs返回的最大行数
backgroundLogPreviewLines: 50
}
//实现一个异步延迟的delay函数
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
//定义异步函数,用于执行一次工具调用
async function executeSingleToolCall(toolCallPayload) {
//获取本次工具调用的名称 writeFile
const name = toolCallPayload.function.name
let parsedArgs = {}
try {
parsedArgs = JSON.parse(toolCallPayload.function.arguments)
} catch {
parsedArgs = {}
}
console.log(`\n工具${name}被调用`)
console.log(`工具入参:${JSON.stringify(parsedArgs, null, 2)}`)
+ // 声明用于存放工具调用结果的变量
+ let textResult;
+ // 判断工具名称是否为MCP工具
+ if (isMcpTool(name)) {
+ // 如果是MCP工具,尝试调用MCP专用处理函数
+ try {
+ // 异步调用MCP工具,并获取执行结果
+ textResult = await callMcpTool(name, parsedArgs);
+ } catch (err) {
+ // 如果调用过程中发生错误,记录错误信息作为结果
+ textResult = `✖ MCP 调用失败:${err.message}`;
+ }
+ } else {
+ // 如果不是MCP工具,从工具处理器映射表中获取对应的实例
+ const handler = toolHandlerByName[name];
+ // 如果存在对应的处理器,则调用其run方法获取结果,否则返回“未实现”提示
+ textResult = handler
+ ? await handler.run(parsedArgs)
+ : `✖ 未实现的工具:${name}`;
+ }
console.log(`返回:${textResult}`)
//构建并返回本次工具调用的完整回复消息对象
return {
role: 'tool',//设置消息的角色为tool
tool_call_id: toolCallPayload.id,//工具调用ID
name,//工具名称
content: textResult//工具返回的内容
}
}
//定义一个异步函数,驱动智能体连续推理直到获得文件回复或超过最长步数为止
async function runAgentUntilReplyOrMaxSteps(messages) {
//初始化步数计数器
let step = 0
//执行循环,直到步数超过最大限制
while (step < CONFIG.agentMaxSteps) {
//步数递增
step++
console.log(`\n 请求模型中...`)
//向大模型的API发送消息请求回复
const completion = await openaiClient.chat.completions.create({
model: "deepseek-v4-pro",//模型名称
"messages": messages,//消息列表
+ tools: activeModelToolDefinitions,//传递给大模型的工具函数定义说明列表
tool_choice: "auto"//工具的选择方法
})
//获取 模型返回的消息
const assistantMessage = completion.choices[0].message
//打印返回的消息
console.log(JSON.stringify(assistantMessage, null, 2))
//把助手消息添加到对话历史中
messages.push(assistantMessage)
// 获取本轮助手消息产生的工具调用
const tool_calls = assistantMessage.tool_calls
//判断是否有工具调用,如果无工具调用则直接返回助手回复
if (!tool_calls || tool_calls.length == 0) {
return assistantMessage
}
//判断是否存在命令执行,决定是否是顺序调用工具还是并行调用工具
const sequential = tool_calls.some(tool_call => tool_call.function.name === 'runCommand')
let toolResponses = [];
//如果需要串行顺序执行
if (sequential) {
//依次按顺序执行工具调用,并将结果推入响应数组中
for (const tool_call of tool_calls) {
const toolResponse = await executeSingleToolCall(tool_call)
toolResponses.push(toolResponse)
}
} else {
//否则并发执行所有的工具调用,并收集所有的结果
toolResponses = await Promise.all(tool_calls.map(executeSingleToolCall))
}
//将所有的工具返回的消息添加到消息列表中
for (const toolResponse of toolResponses) {
messages.push(toolResponse)
}
}
return {
role: 'assistant',
content: "对话步数已达上限"
}
}
//定义异步主函数
async function main() {
//创建工作区目录
await fsp.mkdir(WORKSPACE_ROOT, { recursive: true })
+ // 尝试执行以下代码块
+ try {
+ // 异步连接 MCP(多通道处理服务器)
+ await connectMcpServer();
+ // 刷新模型工具定义
+ refreshModelToolDefinitions();
+ // 如果上面的代码块出现异常,则捕获错误
+ } catch (err) {
+ // 在控制台输出警告信息,提示 MCP 未连接,并显示具体错误信息
+ console.warn(`未连接 MCP(需 mcp_server.js 可被 stdio 拉起):${err.message}`);
+ }
//创建一个消息列表,包含系统指令
const messages = [{ "role": "system", "content": AGENT_SYSTEM_INSTRUCTION }]
//无限循环,持续读取用户的输入
for (; ;) {
//等待用户输入完成一行的内容
const line = await askLine()
//如果用户输入的内容为空,也就是说只包含空白的话,则跳过本次循环
if (!line.trim()) continue;
//如果用户输入内容为q或者quit ,则跳出循环,结束程序
if (line.trim() == "q") break;
///btw的消息不往上下文窗口也不是messages列表里放
//将用户输入内容加入到对话消息列表中
messages.push({ "role": "user", "content": line })
//调用OPENAI的API,发送消息获取回复
const reply = await runAgentUntilReplyOrMaxSteps(messages)
if (reply) {
console.log(`\nAssistant:\n ${reply.content}`)
}
}
+ // 关闭 readline 接口,停止接受用户输入
+ rl.close();
+ // 异步关闭 MCP 连接,忽略关闭过程中可能出现的错误
+ await closeMcpConnection().catch(() => { });
}
// 调用主函数 main,并捕获异常
+main().catch(async (err) => {
+ // 如果有错误,输出错误信息到控制台
+ console.error(err);
+ // 尝试关闭 MCP 连接(忽略关闭过程中的错误)
+ await closeMcpConnection().catch(() => { });
+ // 以代码 1 退出进程,表示异常退出
+ process.exit(1);
+});12.4 时序图 #
左: 启动阶段 stdio 拉起 MCP 并合并工具定义;右: 用户「给张三打招呼」时模型调用 greet 的端到端路径。
12.5 测试 #
已连接 MCP(stdio → D:\aprepare\rscode\rscode\mcp_server.js),工具:greet
> 给张三打招呼
请求模型中...
{
"role": "assistant",
"content": "",
"reasoning_content": "用户想让我用 greet 工具给张三打招呼。",
"tool_calls": [
{
"index": 0,
"id": "call_00_6DTZfy6z4UJO5I7j0o4Y1269",
"type": "function",
"function": {
"name": "greet",
"arguments": "{\"name\": \"张三\"}"
}
}
]
}
工具greet被调用
工具入参:{
"name": "张三"
}
返回:你好,张三!
请求模型中...
{
"role": "assistant",
"content": "已向张三问好!系统返回:**你好,张三!** 👋",
"reasoning_content": "调用成功,系统返回了问候语。"
}
Assistant:
已向张三问好!系统返回:**你好,张三!** 👋13. Agent Skills:斜杠命令与 readSkill 按需加载 #
第 12 节通过 MCP 把外部进程里的工具接进 agent;本节在本仓库与用户主目录下扫描 .claude/skills/*/SKILL.md,把可复用的操作说明(Skill)注册进系统,无需改 claude.js 即可扩展「写 commit message」「跑特定流程」等能力。每条 Skill 含 YAML frontmatter(name、description)与 Markdown 正文;启动时只把名称 + 描述写入 system,正文通过 /skill-name 直灌或模型调用 readSkill 工具再取。
程序在做什么:
skills.js:gray-matter解析 frontmatter;扫描process.cwd()/.claude/skills与~/.claude/skills(同名后者覆盖前者);enrichSystem(base)在 system 末尾追加Skills(匹配时用 readSkill 加载正文):列表;parseSlash(line)识别/commit-message [补充说明]。- 示例
commit-message:正文要求先git status/git diff,再写 conventional commits 风格中文标题。 ReadSkill工具:getSkill(skillName)返回body,供模型在普通对话中按描述匹配后拉取完整步骤。claude.js挂接:main()开头await loadSkills()→agentSystemInstruction = enrichSystem(AGENT_SYSTEM_INSTRUCTION_BASE);- 每轮循环内新建
messages = [{ role: "system", content: agentSystemInstruction }](每轮独立上下文,不累积历史 user/assistant); - 若
parseSlash命中,将userContent设为「请按 Skill /{name} 执行」+ 全文body+ 可选用户补充; readSkill注册在LOCAL_MODEL_TOOL_DEFINITIONS与toolHandlerByName。
- 与第 11、12 节关系:Skill 流程常触发
runCommand(如 git),仍走命令审批;MCP 工具与本地工具定义不变。
两种触发方式:
| 方式 | 用户输入 | Skill 正文何时进入上下文 |
|---|---|---|
| 斜杠命令 | /commit-message 只写标题 |
本轮 user 消息中直接嵌入 body |
| 自然语言 + 工具 | 「帮我写 commit 说明」 | system 仅有描述;模型 readSkill 后再执行 |
依赖:
npm install gray-matter。Skill 目录不存在时loadSkills静默跳过,agent 仍可运行。
npm install gray-matter 13.1. SKILL.md #
.claude/skills/commit-message/SKILL.md
---
name: commit-message
description: 根据 git 变更生成 commit message。用户要提交说明、commit message 时使用。
---
1. 先在工作区执行 `git status` 与 `git diff` 了解变更。
2. 用中文写一行 conventional commits 风格标题,必要时加简短条目。
3. 若无变更,说明没有可提交内容。
13.2. skills.js #
skills.js
// 引入 path 模块,用于路径处理
const path = require("path");
// 引入 fs/promises 模块,提供异步文件操作
const fsp = require("fs/promises");
// 引入 os 模块,用于获取操作系统信息
const os = require("os");
// 解析 SKILL.md 的 YAML frontmatter
const matter = require("gray-matter");
// 定义技能目录,既有当前目录下的 .claude/skills,也有用户主目录下的 .claude/skills
const SKILL_DIRS = [
path.join(process.cwd(), ".claude", "skills"),
path.join(os.homedir(), ".claude", "skills"),
];
// 创建 Map 用于存储技能
const skills = new Map();
// 定义 parseFrontmatter 函数,接收原始字符串 raw 作为参数
function parseFrontmatter(raw) {
// 使用 gray-matter 解析 raw,提取出 data(YAML 属性)和 content(正文内容)
const { data, content } = matter(raw);
// 返回一个对象,meta 字段为解析出的 metadata,body 字段为去除首尾空白后的正文内容
return { meta: data, body: content.trim() };
}
// 扫描指定目录下的所有文件夹,读取其中的 SKILL.md
async function scanDir(root) {
// 定义 entries 用于存储目录项
let entries;
try {
// 读取目录下所有文件和文件夹(包含类型信息)
entries = await fsp.readdir(root, { withFileTypes: true });
} catch {
// 目录不存在则直接返回
return;
}
// 遍历所有目录项
for (const ent of entries) {
// 只处理目录项
if (!ent.isDirectory()) continue;
try {
// 读取每个子目录下的 SKILL.md 文件内容
const raw = await fsp.readFile(path.join(root, ent.name, "SKILL.md"), "utf8");
// 解析 SKILL.md 文件内容,获得 meta 信息和正文 body
const { meta, body } = parseFrontmatter(raw);
// 技能名优先使用 meta.name,否则用目录名
const name = (meta.name || ent.name).trim();
// 将技能信息存储到 skills Map
skills.set(name, {
name,//技能名
description: meta.description || name,//技能描述
body,//技能正文
});
} catch {
// 如果没有 SKILL.md 文件则跳过
/* 无 SKILL.md 则跳过 */
}
}
}
// 加载所有技能,清空 skills,再遍历每个 SKILL_DIRS 调用 scanDir
async function loadSkills() {
// 清空已有的技能
skills.clear();
// 遍历技能目录列表,逐个扫描
for (const dir of SKILL_DIRS) await scanDir(dir);
// 返回所有技能的数组
return [...skills.values()];
}
// 根据技能名搜索已有技能,找不到返回 null
function getSkill(name) {
// 去除 name 的前导斜杠并裁剪首尾空格,然后在 Map 查找
return skills.get(String(name || "").replace(/^\//, "").trim()) || null;
}
// 构造附加技能描述的系统提示文本
function enrichSystem(base) {
// 如果没有任何技能,则返回原始字符串
if (!skills.size) return base;
// 将所有技能转为“- 技能名: 描述”列表
const lines = [...skills.values()].map((s) => `- ${s.name}: ${s.description}`);
// 返回拼接后的系统字符串,加上技能列表
return base + "\n\nSkills(匹配时用 readSkill 加载正文):\n" + lines.join("\n");
}
// 解析类似 /skill-name [args] 格式的输入,返回{ skill, args },否则 null
/** 输入 /skill-name [args] 时返回 { skill, args },否则 null */
function parseSlash(line) {
// 去除输入行的首尾空白符
const t = line.trim();
// 必须以斜杠开头,否则返回 null
if (!t.startsWith("/")) return null;
// 移除斜杠后的剩余部分
const rest = t.slice(1);
// 查找第一个空格
const sp = rest.indexOf(" ");
// 获取命令名(斜杠后的字符串或第一个空格前的字符串),并裁剪空白符
const cmd = (sp === -1 ? rest : rest.slice(0, sp)).trim();
// 查询技能
const skill = getSkill(cmd);
// 未查到技能返回 null
if (!skill) return null;
// 返回对应的技能和参数(如果存在参数的话)
return { skill, args: sp === -1 ? "" : rest.slice(sp + 1).trim() };
}
// 导出相关函数接口
module.exports = { loadSkills, getSkill, enrichSystem, parseSlash };
13.3. claude.js #
claude.js
//用于读取终端输入
const readline = require('readline');
//加载.env配置文件,并允许覆盖已经存在的环境变量
require("dotenv").config({ override: true })
//从openai包引入OpenAI与大模型交互的客户端类
const { OpenAI } = require("openai")
//引入Node.js中的path模块,用于处理和转换文件的路径
const path = require("path");
//引入子进程模块,用于开启子进程执行同步或异步命令
const { spawn, spawnSync, exec } = require("child_process")
//WINDOWS控制台编码为GBK(cp936),如果直接用UTF8解码的话会乱码
const iconv = require("iconv-lite");
//引入fs/promises模块,用于读写文件,提供基于promise的文件系统操作方法
const fsp = require("fs/promises");
const { buffer } = require('stream/consumers');
//创建OpenAI的客户端实例,使用DeepSeek的APIKEY和baseURL
const openaiClient = new OpenAI({
baseURL: "https://api.deepseek.com",
apiKey: process.env.DEEPSEEK_API_KEY
})
// 引入 MCP 客户端模块
const {
connectMcpServer, //连接 MCP 服务端
callMcpTool, //调用 MCP 工具
isMcpTool, //判断给定名称是否是 MCP 工具
getMcpOpenAiTools, //获取所有 OpenAI 格式的 MCP 工具定义
closeMcpConnection, //关闭 MCP 连接与清理资源
} = require("./mcp_client");
+const {
+ loadSkills, //加载所有技能
+ getSkill, //获取指定技能
+ enrichSystem, //增强系统提示
+ parseSlash, //解析斜杠命令
+} = require("./skills");
//定义代理的系统提示词
+const AGENT_SYSTEM_INSTRUCTION_BASE = `你是「Claude Code」—— 在受控 workspace 内协助用户读改代码、跑命令的助手。当用户要求执行某些操作时,可调用 MCP 工具。`
+// 存储代理的系统指令
+let agentSystemInstruction = AGENT_SYSTEM_INSTRUCTION_BASE;
//存储所有的后台拉起的子进程的注册表
const backgroundProcesses = new Map()
//创建一个readline接口,指定输入输出为标准输入输出
const rl = readline.createInterface({
input: process.stdin,//标准输入流
output: process.stdout,//标准输出流
terminal: true//是否启用终端模式
})
//监听SIGINT(通常指的是Ctrl+C)事件,只监听一次
rl.once('SIGINT', () => {
//打印中断提示信息,换两行
console.log("\n已中断,下次再见")
//关闭readline接口
rl.close()
//退出当前的进程,状态码设置为0(正常退出)
process.exit(0)
})
//定义工作区根目录,位于当前目录下面的workspace文件夹中
const WORKSPACE_ROOT = path.resolve(process.cwd(), "workspace")
function isDescendantOrSameDirectory(candidatePath) {
const targetAbs = path.resolve(candidatePath)
if (WORKSPACE_ROOT == targetAbs) return true
//计算目标路径相对于工作区根目录的相对路径
const relative = path.relative(WORKSPACE_ROOT, targetAbs)
//如果不是空字符串,不以..点,也不是绝对路径,则说明在工作区内,可以返回true
return relative != "" && !relative.startsWith("..") && !path.isAbsolute(relative)
}
//在工作区内解析路径,并校验是否越界
function resolvePathInsideWorkspace(relativePath) {
//将相对路径解析为工作区下的绝对路径
const candidatePath = path.resolve(WORKSPACE_ROOT, relativePath)
//如果说解析后的路径不在工作区内,就意味着路径越界了,抛出异常
if (!isDescendantOrSameDirectory(candidatePath)) {
throw new Error(`路径越界:${candidatePath}`)
}
//返回校验通过的绝对路径
return candidatePath
}
//文本读取工具的实现
class ReadText {
//执行读取操作 参数为{path}
async run({ path: relativePath }) {
try {
return await fsp.readFile(resolvePathInsideWorkspace(relativePath), 'utf-8')
} catch (err) {
//如果找不到文件,则返回自定义的提示,否则返回异常的原因
return err.code === 'ENOENT' ? `找不到文件:${relativePath}` : `读取异常:${err.message}`
}
}
}
//文本写入工具的实现
class WriteText {
//执行写入操作 参数为{path,content}
async run({ path: relativePath, content }) {
try {
//解析目标文件的绝对路径
const absolute = resolvePathInsideWorkspace(relativePath)
//如果父级目录不存在则需要递归创建
await fsp.mkdir(path.dirname(absolute), { recursive: true })
//写入内容到文件中,使用UTF8编码
await fsp.writeFile(absolute, content, "utf-8")
return `已落盘${Buffer.byteLength(content, 'utf-8')}字节 -> ${relativePath}`
} catch (err) {
return `写入异常:${err.message}`
}
}
}
//用于精确修改文件的内容
class EditFile {
//接收文件路径,要替换的旧文本,以及新文本
async run({ path: relativePath, oldText, newText }) {
try {
//解析目标文件的绝对路径
const absolute = resolvePathInsideWorkspace(relativePath)
//以UTF8编码读取文件的内容
const before = await fsp.readFile(absolute, 'utf-8')
//检查原来的文件内容是是否包含被替换的文件,如果没有则给出提示
if (!before.includes(oldText)) return `文件中未出现与oldText完全一致的片段`
//将第一次出现的oldText替换为newText,并写回原文件
await fsp.writeFile(absolute, before.replace(oldText, newText), 'utf-8')
return `已成功替换了文件内容:${relativePath}`
} catch (err) {
//如果找不到文件,则返回自定义的提示,否则返回异常的原因
return err.code === 'ENOENT' ? `找不到文件:${relativePath}` : `读取异常:${err.message}`
}
}
}
//列出目录里的内容
class ListDir {
//执行写入操作 参数为{path,content}
async run({ path: relativePath }) {
try {
//解析目标文件的绝对路径
const absolute = resolvePathInsideWorkspace(relativePath)
//获取目标路径的文件状态信息
const stat = await fsp.stat(absolute)
//判断目标路径是否为目录,如果不是目录则返回报错信息
if (!stat.isDirectory()) {
return `目标路径不是一个目录:{relativePath}`
}
//读取目录下面的所有的条目,并且获取类型信息
const entries = await fsp.readdir(absolute, { withFileTypes: true })
//根据名称进行字典排序
entries.sort((a, b) => a.name.localeCompare(b.name))
//格式化每个条目,区分目录还是文件
const rows = entries.map((entry) => `${entry.isDirectory() ? "目录:" : "文件:"} ${entry.name}`)
//如果有内容则按行拼接返回,否则 返回空目录提示
return rows.length > 0 ? rows.join("\n") : "(空目录)"
} catch (err) {
//如果找不到文件,则返回自定义的提示,否则返回异常的原因
return err.code === 'ENOENT' ? `找不到文件:${relativePath}` : `读取异常:${err.message}`
}
}
}
+// 定义一个名为ReadSkill的类
+class ReadSkill {
+ // 定义一个异步方法run,接收一个包含skill属性的对象作为参数
+ async run({ skill: skillName }) {
+ // 调用getSkill方法获取对应的技能对象
+ const skill = getSkill(skillName);
+ // 如果技能存在,返回其body属性,否则返回未找到技能的提示
+ return skill ? skill.body : `✖ 未找到 Skill:${skillName}`;
+ }
+}
//本地工具的 Schema 定义
const LOCAL_MODEL_TOOL_DEFINITIONS = [
{
"type": "function",//声明工具的类型为函数
"function": {
name: "readFile",//工具的名称为readFile
description: "以UTF-8编码读取工作区内的文本文件",//工具功能描述
parameters: {//工具参数定义
type: "object",//参数类型为对象
properties: { path: { type: "string" } },//参数属性为path,字符串类型
required: ["path"]//要想调用这个工具,必须传递path参数,path参数是必需的参数
}
}
},
{
"type": "function",//声明工具的类型为函数
"function": {
name: "writeFile",//工具的名称为writeFile
description: "在工作区内新建或覆盖写入文件",//工具功能描述
parameters: {//工具参数定义
type: "object",//参数类型为对象
properties: {
path: { type: "string", "description": "文件路径" },//参数属性为path,字符串类型
content: { type: "string" }//conent为要写入的文件内容,字符串类型
},
required: ["path", "content"]//要想调用这个工具,必须传递path和content参数,path参数和content参数都必需的参数
}
}
},
{
"type": "function",//声明工具的类型为函数
"function": {
name: "editFile",//工具的名称为editFile
description: "对已有文本做一次精确的子串替换,oldText必须与文件内容完全一致",//工具功能描述
parameters: {//工具参数定义
type: "object",//参数类型为对象
properties: {
path: { type: "string", "description": "待修改的文件路径" },//参数属性为path,字符串类型
oldText: { type: "string", "description": "要被替换的原文(唯一一次匹配)" },
newText: { type: "string", "description": "替换后的新文本" },
},
required: ["path", "oldText", "newText"]//要想调用这个工具,必须传递path和content参数,path参数和content参数都必需的参数
}
}
},
{
"type": "function",//声明工具的类型为函数
"function": {
name: "listDir",//工具的名称为listDir
description: "罗列目录下的条目(包含子文件夹标识),用于快速摸清目录结构",//工具功能描述
parameters: {//工具参数定义
type: "object",//参数类型为对象
properties: {
path: { type: "string", "description": "文件路径" },//参数属性为path,字符串类型
},
required: ["path", "content"]//要想调用这个工具,必须传递path参数
}
}
},
{
"type": "function",//声明工具的类型为函数
"function": {
name: "runCommand",//工具的名称为runCommand
description:
"在shell中执行命令,短命令阻塞直至结束,疑似开发服务器的命令会在后台拉起\n" +
"重要:命令会在当前的目录中执行,不要轻易使用cd命令切换目录\n" +
"常规: install / build / test 等同步执行,stdout和stderr汇总返回\n" +
"常驻: pnpm dev 、npm start、uvicorn 、flask run 等后台运行,约8秒后回传PID与启动阶段日志。\n" +
"后台子命令:\n" +
" task_list 列出已登记的后台任务列表\n" +
" task_logs <pid> 拉取该PID最近若干行合并输出\n" +
" task_stop <pid> 结束该PID对应的后台任务\n"
,//工具功能描述
parameters: {//工具参数定义
type: "object",//参数类型为对象
properties: {
commandLine: { type: "string", "description": "shell命令行(例如 npm install)" },//参数属性为commandLine,字符串类型
},
required: ["commandLine"]//要想调用这个工具,必须传递path参数
}
}
+ },
+ // 定义一个工具对象
+ {
+ // 设置 type 字段为 "function",表示这是一个函数型工具
+ type: "function",
+ // function 字段,定义具体函数内容
+ function: {
+ // name 字段,工具名称为 readSkill
+ name: "readSkill",
+ // description 字段,对该工具的主要功能做简要描述
+ description: "加载 Skill 完整正文;任务与系统提示中的 Skill 描述匹配时使用。",
+ // parameters 字段,定义参数结构
+ parameters: {
+ // type 表示参数采用对象形式
+ type: "object",
+ // properties 字段,具体定义参数项
+ properties: {
+ // skill 参数,类型为字符串
+ skill: {
+ // type 字段指明参数为字符串类型
+ type: "string",
+ // description 字段解释 skill 参数的用途
+ description: "Skill 名称(目录名或 SKILL.md frontmatter 中的 name)",
+ },
+ },
+ // required 字段列出必需参数
+ required: ["skill"],
+ },
+ },
+ },
]
// 定义一个变量 activeModelToolDefinitions,初始值为 LOCAL_MODEL_TOOL_DEFINITIONS 的浅拷贝
let activeModelToolDefinitions = [...LOCAL_MODEL_TOOL_DEFINITIONS];
// 定义刷新模型工具定义的函数
function refreshModelToolDefinitions() {
// 将 activeModelToolDefinitions 更新为 LOCAL_MODEL_TOOL_DEFINITIONS 和 getMcpOpenAiTools() 的合并数组
activeModelToolDefinitions = [
...LOCAL_MODEL_TOOL_DEFINITIONS,
...getMcpOpenAiTools(),
];
}
//将子进程stdout/stderr的Buffer输出解码为可读的字符串(windows中文环境一般为cp936)
function decodeResultBuffer(buffer) {
if (!buffer || buffer.length == 0) return ""
//如果是windows,则以cp936
if (process.platform === 'win32') return iconv.decode(buffer, 'cp936')
//其它 系统都用 UTF8
return buffer.toString('utf8')
}
// 包含了会让开发环境长时间运行的命令字符串
const LONG_RUNNING_HINTS = [
// "dev" 表示开发模式
"dev",
// "start" 表示启动服务
"start",
// "serve" 表示启动服务器
"serve",
// "server" 表示启动开发服务器
"server",
// "watch" 表示监听文件变更
"watch",
// "run server" 表示运行服务器
"run server",
// "runserver" 另一种写法,运行服务器
"runserver",
// "preview" 表示预览模式
"preview",
// "nodemon" 表示自动重启开发服务器
"nodemon",
// "uvicorn" 表示 Python asgi 服务器
"uvicorn",
// "gunicorn" 表示 Python wsgi 服务器
"gunicorn",
// "flask run" 表示 Flask 的运行命令
"flask run",
// "vite" 表示前端构建工具
"vite",
// "webpack" 表示前端构建工具
"webpack",
// "--watch" 表示监听参数
"--watch",
// "--hot" 表示热更新参数
"--hot"
];
function looksLikeLongRunningCommand(commandLine) {
//去除字符串收尾空格并且转小写格式化
const normalized = commandLine.trim().toLowerCase()
//在长时间运行命令关键字集合中查找,判断normalized是否任意一个关键字
return LONG_RUNNING_HINTS.some(hint => normalized.includes(hint))
}
//将标准输出和错误输出二个可读流绑定到缓冲区数组中
function bindStreamToBuffers(readableStream, buffers) {
//监听可读流的数据,当可读流有数据发射的时候,会把这个数据放在buffers里,就是说你在子进程中 console.log('world') process.stdout.write('hello')
readableStream.on('data', (chunk) => buffers.push(chunk))
readableStream.on('end', (chunk) => buffers.push(null))
}
//将bindStreamToBuffers收集到的buffers数组转成文本字符串
function buffersToText(buffers) {
const chunks = buffers.filter(chunk => chunk != null)
if (!chunks.length) return ""
return decodeResultBuffer(Buffer.concat(chunks))
}
//取文本末尾的指定行数,用于task_logs日志预览
function tailTextLines(text, maxLines) {
if (!text) return ""
return text.split(/\r?\n/).slice(-maxLines).join('\n')
}
// 标记是否全部同意,默认 false,用户选择 2 后会变为 true
let alwaysApproveCommands = false
// 定义 askCommandApproval 函数,用于在执行 shell 命令前请求用户审批(task_* 指令不经过此流程)
function askCommandApproval(commandLine) {
// 返回一个 Promise 异步执行审批流程
return new Promise((resolve) => {
// 定义嵌套的 prompt 提示函数,用于在用户输入无效时递归调用
const prompt = () => {
// 控制台输出命令审批提示
console.log("\n[命令审批] 即将执行:")
// 控制台输出即将执行的命令内容
console.log(` ${commandLine}`)
// 提示选项 1)本次同意
console.log(" 1) 本次同意")
// 提示选项 2)以后全部同意
console.log(" 2) 以后全部同意")
// 提示选项 3)拒绝
console.log(" 3) 拒绝")
// 询问用户输入审批编号
rl.question("请选择 [1/2/3]: ", (answer) => {
// 去除输入前后空格,获取用户选择
const choice = answer.trim()
// 如果选择 1,本次同意,resolve 返回 approved: true
if (choice === "1") {
resolve({ approved: true })
// 如果选择 2,后续全部同意,设置标志位并提示
} else if (choice === "2") {
alwaysApproveCommands = true
// 控制台输出记住状态的说明
console.log("已记住:后续命令将自动执行,不再询问。")
// 仍然本次同意,resolve 返回 approved: true
resolve({ approved: true })
// 如果选择 3,拒绝执行该命令
} else if (choice === "3") {
resolve({ approved: false })
// 其他无效输入,重新调用 prompt 函数
} else {
// 控制台输出无效输入提示
console.log("无效输入,请输入 1、2 或 3。")
prompt()
}
})
}
// 初始调用 prompt 函数
prompt()
})
}
class RunCommandLine {
//检查并处理后台任务控制指令 比如task_list task_logs task_stop
handleBackgroundTaskControlCommand(commandLine) {
//判断是否为task_list指令 不区分大小写,后面可带空格或行尾
if (/^task_list(\s|$)/i.test(commandLine)) {
if (backgroundProcesses.size == 0) return `当前没有已登记的后台任务`
return [
"[后台任务一览]",
...[...backgroundProcesses].map(([pid, meta]) => `- ${pid} ${meta.child.exitCode == null ? "活动中" : "已结束"} ${meta.commandLine}`)
].join('\n')
}
//匹配task_logs pid 指令 ,用于获取指定的后台进程的日志 task_logs 1000
const logsMatch = /^task_logs\s+(\d+)\s*$/i.exec(commandLine.trim())
if (logsMatch) {
//从匹配的结果中获取PID进程ID这个数字
const pid = Number.parseInt(logsMatch[1], 10)
if (!backgroundProcesses.has(pid)) return `未登记的后台PID:${pid}`
//获取该PID对应的缓冲区buffers
const { buffers } = backgroundProcesses.get(pid)
//将缓冲区列表中的文件转为字符串文本,并截取末尾的若干行
const text = tailTextLines(buffersToText(buffers), CONFIG.backgroundLogPreviewLines)
return `[PID ${pid}最近的输出]\n${text || (尚无输出)}`
}
//匹配task_stop pid 指令 ,用于终止指定进程ID的子进程
const stopMatch = /^task_stop\s+(\d+)\s*$/i.exec(commandLine.trim())
if (stopMatch) {
//从匹配的结果中获取PID进程ID这个数字
const pid = Number.parseInt(stopMatch[1], 10)
if (!backgroundProcesses.has(pid)) return `未登记的后台PID:${pid}`
//获取该PID对应的子进程
const { child } = backgroundProcesses.get(pid)
try {
//如果是windows系统的话,使用taskkill命令终止进程
if (process.platform == 'win32') {
spawnSync(`taskkill /PID ${pid} \T \F`, { shell: true, stdio: "ignore" })
} else {
//非windows则通过向进程组发送SIGTERM信号终止进程
process.kill(-pid, "SIGTERM")
}
} catch {
//如果上面的kill都失败了,则直接向子进程对象调用kill方法
child.kill("SIGTERM")
}
//从后台进程注册表中删除该pid
backgroundProcesses.delete(pid)
//返回中止进程的提示
return `已请求终止后台进程 PID={pid}`
}
return null
}
//异步的方式运行commandLine命令,接收包含commandLine字段的对象参数
async run({ commandLine }) {
//尝试解析是否为后台任务控制指令(比如说task_list),如果是的话优先返回对应的回复
const backgroundTaskControlReply = this.handleBackgroundTaskControlCommand(commandLine)
//如果解析出是控制后台任务的命令,则不再继续向下执行,直接返回结果
if (backgroundTaskControlReply != null) {
return backgroundTaskControlReply
}
// 如果不是总是自动批准命令
if (!alwaysApproveCommands) {
// 异步请求用户对命令行命令进行批准
const decision = await askCommandApproval(commandLine)
// 如果用户没有批准这个命令
if (!decision.approved) {
// 返回用户拒绝执行命令的提示信息
return `用户已拒绝执行命令: ${commandLine}`
}
}
//判断命令行是否看起来像是一个长时间运行的命令
if (looksLikeLongRunningCommand(commandLine)) {
//调用异步非阻塞的方式运行commandLine这个shell命令
return this.runBackgroundCommandLine(commandLine)
} else {
//调用同步阻塞的方式运行commandLine这个shell命令
return this.runBlockingCommandLine(commandLine)
}
}
async runBackgroundCommandLine(commandLine) {
//创建一个存储子进程输出的缓冲区数组
const buffers = []
//使用spawn启动一个新的子进程,传入命令行和配置对象
const child = spawn(commandLine, {
shell: true,
cwd: WORKSPACE_ROOT,//指定子进程的工作目录为
stdio: ["ignore", "pipe", 'pipe']//配置子进程的标准输入为忽略,标准输出和错误输出重定向到管道中
})
//绑定子进程的标准输出流到buffers缓存区数组中
bindStreamToBuffers(child.stdout, buffers)
//绑定子进程的错误输出流到buffers缓存区数组中
bindStreamToBuffers(child.stderr, buffers)
//获取子进程的进程 ID
const pid = child.pid
//当启动后台任务的子进程的时候,把当前的子进程信息存储到backgroundProcesses映射中
backgroundProcesses.set(
pid, {
commandLine,//记录启动该子进程使用的命令行字符串
child,//记录子进程本身
buffers//记录用于收集子进程输出的缓冲区buffer数组
}
)
//监听了进程的退出事件,当子进程退出的时候将其从backgroundProcesses移除
child.once('exit', () => backgroundProcesses.delete(pid))
//等待后台进行中初始化,延迟指定的时间(用于后台命令的启动和预热)
await delay(CONFIG.backgroundWarmupMs)
return `已经在后台成功启动了子进程\n PID:${pid}\n命令:${commandLine}\n 启动阶段输出:\n${Buffer.concat(buffers).toString('utf-8') || ("尚未输出")}`
}
//同步运行commandLine命令,并返回命令执行结果
runBlockingCommandLine(commandLine) {
//使用spawnSync同步执行commandLine命令
const result = spawnSync(commandLine, {
//指定使用shell运行commandLine
shell: true,
//指定工作目录为工作空间根目录
cwd: WORKSPACE_ROOT,
//输出二进制Buffer格式
encoding: 'buffer',
//最大缓冲区设置为10M
maxBuffer: 10 * 1024 * 1024
})
// 如果命令执行超时,则返回超时提示
if (result.error?.code === "ETIMEOUT") return "同步命令超时:{commandLine}"
//如果有标准输出,则解码否则 返回空
let merged = result.stdout?.length ? decodeResultBuffer(result.stdout) : "(stdout为空)"
//如果有错误输出,则拼接错误输出到merged
if (result.stderr?.length) {
merged += `\n[stderr] \n ${decodeResultBuffer(result.stderr)}`
}
return result.status != 0 ? ` 退出码:${result.status}\n${merged}` : `命令正常完成\n${merged}`
}
}
//工具名称到对应的实例映射表
const toolHandlerByName = {
//读取文件工具
readFile: new ReadText(),
//写入文件的工具
writeFile: new WriteText(),
//替换文件工具
editFile: new EditFile(),
//枚举目录工具
listDir: new ListDir(),
//枚举目录工具
runCommand: new RunCommandLine(),
+ // 加载 Agent Skill
+ readSkill: new ReadSkill(),
}
//定义一个askLine函数,返回一个Promise,用于异步获取用户输入
const askLine = () => new Promise((resolve) => {
//向控制台输出普通的提示符,并等待用户的输入
rl.question("> ", resolve)
})
//定义常量配置对象
const CONFIG = {
//智能体允许的最大步数
agentMaxSteps: 100,
//后台进程预热时间 8s
backgroundWarmupMs: 8000,
//task_logs返回的最大行数
backgroundLogPreviewLines: 50
}
//实现一个异步延迟的delay函数
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms))
}
//定义异步函数,用于执行一次工具调用
async function executeSingleToolCall(toolCallPayload) {
//获取本次工具调用的名称 writeFile
const name = toolCallPayload.function.name
let parsedArgs = {}
try {
parsedArgs = JSON.parse(toolCallPayload.function.arguments)
} catch {
parsedArgs = {}
}
console.log(`\n工具${name}被调用`)
console.log(`工具入参:${JSON.stringify(parsedArgs, null, 2)}`)
// 声明用于存放工具调用结果的变量
let textResult;
// 判断工具名称是否为MCP工具
if (isMcpTool(name)) {
// 如果是MCP工具,尝试调用MCP专用处理函数
try {
// 异步调用MCP工具,并获取执行结果
textResult = await callMcpTool(name, parsedArgs);
} catch (err) {
// 如果调用过程中发生错误,记录错误信息作为结果
textResult = `✖ MCP 调用失败:${err.message}`;
}
} else {
// 如果不是MCP工具,从工具处理器映射表中获取对应的实例
const handler = toolHandlerByName[name];
// 如果存在对应的处理器,则调用其run方法获取结果,否则返回“未实现”提示
textResult = handler
? await handler.run(parsedArgs)
: `✖ 未实现的工具:${name}`;
}
console.log(`返回:${textResult}`)
//构建并返回本次工具调用的完整回复消息对象
return {
role: 'tool',//设置消息的角色为tool
tool_call_id: toolCallPayload.id,//工具调用ID
name,//工具名称
content: textResult//工具返回的内容
}
}
//定义一个异步函数,驱动智能体连续推理直到获得文件回复或超过最长步数为止
async function runAgentUntilReplyOrMaxSteps(messages) {
//初始化步数计数器
let step = 0
//执行循环,直到步数超过最大限制
while (step < CONFIG.agentMaxSteps) {
//步数递增
step++
console.log(`\n 请求模型中...`)
//向大模型的API发送消息请求回复
const completion = await openaiClient.chat.completions.create({
model: "deepseek-v4-pro",//模型名称
"messages": messages,//消息列表
tools: activeModelToolDefinitions,//传递给大模型的工具函数定义说明列表
tool_choice: "auto"//工具的选择方法
})
//获取 模型返回的消息
const assistantMessage = completion.choices[0].message
//打印返回的消息
console.log(JSON.stringify(assistantMessage, null, 2))
//把助手消息添加到对话历史中
messages.push(assistantMessage)
// 获取本轮助手消息产生的工具调用
const tool_calls = assistantMessage.tool_calls
//判断是否有工具调用,如果无工具调用则直接返回助手回复
if (!tool_calls || tool_calls.length == 0) {
return assistantMessage
}
//判断是否存在命令执行,决定是否是顺序调用工具还是并行调用工具
const sequential = tool_calls.some(tool_call => tool_call.function.name === 'runCommand')
let toolResponses = [];
//如果需要串行顺序执行
if (sequential) {
//依次按顺序执行工具调用,并将结果推入响应数组中
for (const tool_call of tool_calls) {
const toolResponse = await executeSingleToolCall(tool_call)
toolResponses.push(toolResponse)
}
} else {
//否则并发执行所有的工具调用,并收集所有的结果
toolResponses = await Promise.all(tool_calls.map(executeSingleToolCall))
}
//将所有的工具返回的消息添加到消息列表中
for (const toolResponse of toolResponses) {
messages.push(toolResponse)
}
}
return {
role: 'assistant',
content: "对话步数已达上限"
}
}
//定义异步主函数
async function main() {
//创建工作区目录
await fsp.mkdir(WORKSPACE_ROOT, { recursive: true })
+ // 异步加载所有技能
+ await loadSkills();
+ // 根据基础系统提示词,增强系统级别的说明,并赋值给 agentSystemInstruction
+ agentSystemInstruction = enrichSystem(AGENT_SYSTEM_INSTRUCTION_BASE);
// 尝试执行以下代码块
try {
// 异步连接 MCP(多通道处理服务器)
await connectMcpServer();
// 刷新模型工具定义
refreshModelToolDefinitions();
// 如果上面的代码块出现异常,则捕获错误
} catch (err) {
// 在控制台输出警告信息,提示 MCP 未连接,并显示具体错误信息
console.warn(`未连接 MCP(需 mcp_server.js 可被 stdio 拉起):${err.message}`);
}
//无限循环,持续读取用户的输入
for (; ;) {
+ // 初始化消息数组,添加系统指令作为第一条消息
+ const messages = [{ role: "system", content: agentSystemInstruction }];
//等待用户输入完成一行的内容
const line = await askLine()
//如果用户输入的内容为空,也就是说只包含空白的话,则跳过本次循环
if (!line.trim()) continue;
//如果用户输入内容为q或者quit ,则跳出循环,结束程序
if (line.trim() == "q") break;
+ // 尝试解析用户输入的斜杠命令(如存在)
+ const slash = parseSlash(line);
+ // 判断是否为斜杠命令,生成相应的用户内容,否则直接采用原始输入
+ const userContent = slash
+ // 如果为斜杠命令,按固定格式拼接 skill 名称与内容,并添加补充信息(如果有)
+ ? `请按 Skill /${slash.skill.name} 执行:\n\n${slash.skill.body}` +
+ (slash.args ? `\n\n用户补充:${slash.args}` : "")
+ // 否则直接采用原始输入
+ : line;
+ // 将用户消息添加到消息数组中
+ messages.push({ role: "user", content: userContent });
//调用OPENAI的API,发送消息获取回复
const reply = await runAgentUntilReplyOrMaxSteps(messages)
if (reply) {
console.log(`\nAssistant:\n ${reply.content}`)
}
}
// 关闭 readline 接口,停止接受用户输入
rl.close();
// 异步关闭 MCP 连接,忽略关闭过程中可能出现的错误
await closeMcpConnection().catch(() => { });
}
// 调用主函数 main,并捕获异常
main().catch(async (err) => {
// 如果有错误,输出错误信息到控制台
console.error(err);
// 尝试关闭 MCP 连接(忽略关闭过程中的错误)
await closeMcpConnection().catch(() => { });
// 以代码 1 退出进程,表示异常退出
process.exit(1);
});13.4 时序图 #
左: 启动扫描 Skill 并增强 system;右: 斜杠 /commit-message 触发后模型 readSkill + runCommand 的协作(含第 10 节顺序执行)。
13.5 测试 #
已连接 MCP(stdio → D:\aprepare\rscode\rscode\mcp_server.js),工具:greet
> /commit-message 只写标题,用 feat 前缀
请求模型中...
{
"role": "assistant",
"content": "",
"reasoning_content": "用户想要我按照 commit-message skill 来生成 commit message。首先需要加载该 skill,然后执行 git status 和 git diff。",
"tool_calls": [
{
"index": 0,
"id": "call_00_a9EzR5TTAlBJcJG0aLeo0379",
"type": "function",
"function": {
"name": "readSkill",
"arguments": "{\"skill\": \"commit-message\"}"
}
},
{
"index": 1,
"id": "call_01_aqVLPKQpMLuKVMATuj0b4695",
"type": "function",
"function": {
"name": "runCommand",
"arguments": "{\"commandLine\": \"git status\"}"
}
},
{
"index": 2,
"id": "call_02_lzUfuUlppNCvypSMR5r35995",
"type": "function",
"function": {
"name": "runCommand",
"arguments": "{\"commandLine\": \"git diff\"}"
}
}
]
}
工具readSkill被调用
工具入参:{
"skill": "commit-message"
}
返回:1. 先在工作区执行 `git status` 与 `git diff` 了解变更。
2. 用中文写一行 conventional commits 风格标题,必要时加简短条目。
3. 若无变更,说明没有可提交内容。
请求模型中...
>
"role": "assistant",
"content": "变更分析完毕。以下是 commit 标题:\n\n**feat: 新增 Skills 子系统,支持技能加载、斜杠命令解析与系统提示词动态增强**",
"reasoning_content": "Let me analyze the changes:\n\n1. **2.claudecode.md**: Added `## 13.` at the end (looks like a new section header)\n2. **claude.js**: Major changes:\n - Added `require(\"./skills\")` import for `loadSkills`, `getSkill`, `enrichSystem`, `parseSlash`\n - Renamed `AGENT_SYSTEM_INSTRUCTION` to `AGENT_SYSTEM_INSTRUCTION_BASE` and made it non-const\n - Added `agentSystemInstruction` variable that gets enriched by skills\n - Added `ReadSkill` class for handling skill loading\n - Added `readSkill` tool definition to `LOCAL_MODEL_TOOL_DEFINITIONS`\n - Added `readSkill` handler to `toolHandlerByName`\n - Fixed a typo: `toolHandlerByName` → `toolHandlerByName` (bug fix)\n - In `main()`: Added `loadSkills()` call, `enrichSystem()` call, moved `messages` initialization inside the loop, added slash command parsing with `parseSlash()`\n3. **package.json**: Added `gray-matter` dependency\n4. **package-lock.json**: Updated accordingly with gray-matter and its dependencies\n5. **Untracked**: `.claude/` directory and `skills.js` (new files)\n\nThe changes are about:\n- Adding a Skills system to the Claude Code agent\n- Slash command parsing\n- Dynamic system instruction enrichment\n- Adding `gray-matter` for parsing frontmatter in skill files\n- Bug fix: `toolHandlerByName` → `toolHandlerByName`\n\nThis is a feature addition. The user asked for `feat` prefix and just the title.\n\nThe core change is adding Skills (技能) support system. Let me write a good commit message."
}
Assistant:
变更分析完毕。以下是 commit 标题:
**feat: 新增 Skills 子系统,支持技能加载、斜杠命令解析与系统提示词动态增强**14.修改编码 #
在 Windows 的 CMD 中设置 UTF-8 编码,如果你想仅临时生效,在 CMD 窗口中输入 chcp 65001 即可;如果你希望永久生效,可以通过修改注册表或调整系统区域设置来实现。
以下是几种具体操作方法,你可以根据需要选择:
14.1 方案一:临时设置(简单,仅当前窗口生效) #
这种方法最适合单次任务。在当前的 CMD 窗口中直接执行以下命令,窗口关闭后设置即失效。
- 打开命令提示符(CMD)。
- 输入以下命令并回车:
注:执行后,该窗口的代码页会临时切换为 UTF-8。如果你想切回默认的 GBK 编码,执行chcp 65001chcp 936即可。
14.2 方案二:永久设置(修改注册表) #
通过修改注册表,可以让所有新打开的 CMD 窗口默认使用 UTF-8 编码。
- 按
Win + R键,输入regedit并回车,打开注册表编辑器。 - 在注册表顶部的地址栏中,粘贴以下路径并回车:
HKEY_CURRENT_USER\Console\%SystemRoot%_system32_cmd.exe - 在右侧的空白区域,右键 -> 新建 -> DWORD (32位)值。
- 将其命名为
CodePage,然后双击打开。 - 选择“十进制”,在数值数据中输入
65001,点击确定。 - 关闭注册表编辑器,之后打开的新 CMD 窗口就会默认使用 UTF-8 编码了。