导航菜单

  • 1.claudecode
  • 2.claudecode
  • readline
  • iconv-lite
  • mcp
  • skills
  • gray-matter
  • modelcontextprotocol
  • 1. 使用 readline 搭建交互式行输入
    • 1.1 claude.js
    • 1.2 时序图
  • 2. 接入 DeepSeek:用 OpenAI SDK 完成终端对话
    • 2.1 安装依赖
    • 2.2 claude.js
    • 2.3 .env
    • 2.4 completion
      • 2.4.1 示例代码
      • 2.4.2 输出信息
      • 2.4.3 数据结构
        • 2.4.3.1 ChatCompletion对象
        • 2.4.3.2 choices 数组
        • 2.4.3.3 message 对象
        • 2.4.3.4 usage 对象
        • 2.4.3.5 补充说明
    • 2.5 时序图
  • 3. 受控工作区与 readFile / writeFile 工具骨架
    • 3.1 claude.js
  • 4. 工具调用循环:把 readFile / writeFile 接到 API
    • 4.1 claude.js
    • 4.2 时序图
    • 4.3 测试数据
  • 5. 扩展工具:editFile 就地替换与 listDir 目录浏览
    • 5.1 claude.js
    • 5.2 时序图
    • 5.3 msg.txt
    • 5.4 测试
  • 6. 在工作区内执行 Shell 命令(runCommand)
    • 6.1 安装依赖
    • 6.2 claude.js
    • 6.3 时序图
    • 6.4 测试
  • 7. 长驻命令后台运行:spawn 与启动段输出采集
    • 7.1 server1.js
    • 7.2 server2.js
    • 7.3 claude.js
    • 7.4 时序图
    • 7.5 测试
  • 8. 后台任务登记与 task_list 管理
    • 8.1 claude.js
    • 8.2 时序图
    • 8.3 测试
  • 9. task_logs / task_stop
    • 9.1 claude.js
    • 9.2 时序图
    • 9.3 测试数据
  • 10. 含 runCommand 时同轮工具顺序执行
    • 10.1 a.txt
    • 10.2 claude.js
    • 10.3 时序图
    • 10.4 测试
  • 11. runCommand 执行前用户审批
    • 11.1. claude.js
    • 11.2 时序图
    • 11.3 测试
  • 12. 接入 MCP:通过 stdio 子进程扩展模型工具
    • 12.1. mcp_client.js
    • 12.2. mcp_server.js
    • 12.3. claude.js
    • 12.4 时序图
    • 12.5 测试
  • 13. Agent Skills:斜杠命令与 readSkill 按需加载
    • 13.1. SKILL.md
    • 13.2. skills.js
    • 13.3. claude.js
    • 13.4 时序图
    • 13.5 测试
  • 14.修改编码
    • 14.1 方案一:临时设置(简单,仅当前窗口生效)
    • 14.2 方案二:永久设置(修改注册表)

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 65001

1.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 时序图 #

sequenceDiagram autonumber participant Top as 脚本入口 participant Main as main() participant Ask as askLine() participant RL as readline (rl) participant Out as stdout participant User as 用户/终端 Top->>Main: main() loop 直到输入为 "q" Main->>Ask: await askLine() Ask->>Ask: new Promise(resolve => ...) Ask->>RL: question("› ", resolve) RL->>Out: 写入提示符 "› " RL-->>User: 等待一行(stdin,回车结束) User->>RL: 输入一行 + Enter RL->>Ask: resolve(line) Ask-->>Main: Promise 兑现,line alt 仅空白 Note over Main: continue,不打印 else 内容为 q Main->>Main: break 跳出循环 else 其它非空 Main->>Out: console.log("你说:", line) end end Main->>RL: rl.close()

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,因此可直接使用官方 openai npm 包,只需改 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 --save

2.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-fcfe6462dcd24095ab2f7e238e74143f

2.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 时序图 #

sequenceDiagram autonumber participant Top as 脚本入口 participant Env as dotenv participant Main as main() participant Ask as askLine() participant RL as readline (rl) participant Out as stdout participant User as 用户/终端 participant Client as openaiClient participant API as DeepSeek API Top->>Env: config({ override: true }) Env-->>Top: 加载 DEEPSEEK_API_KEY 等 Note over Top,Client: 模块加载时创建 OpenAI 客户端(baseURL + apiKey) Top->>Main: main() loop 直到输入为 "q" Main->>Main: messages = [system] Main->>Ask: await askLine() Ask->>RL: question("› ", resolve) RL->>Out: 写入提示符 "› " RL-->>User: 等待一行(stdin) User->>RL: 输入一行 + Enter RL->>Ask: resolve(line) Ask-->>Main: line alt 仅空白 Note over Main: continue else 内容为 q Main->>Main: break else 其它非空 Main->>Main: messages.push(user) Main->>Client: chat.completions.create({ model, messages }) Client->>API: POST /chat/completions API-->>Client: completion (choices[0].message) Client-->>Main: completion Main->>Out: console.log("Assistant", reply.content) Main->>Main: messages.push(assistant) Note over Main: 下轮循环重建 messages,不保留上轮对话 end end Main->>RL: rl.close()

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 Completions tools 参数的 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 步 会重复)。

sequenceDiagram autonumber participant User as 用户/终端 participant Main as main() participant Agent as runAgentUntilReplyOrMaxSteps participant Client as openaiClient participant API as DeepSeek API participant Exec as executeSingleToolCall participant Handler as toolHandlerByName participant FS as workspace 文件系统 Main->>Main: mkdir(WORKSPACE_ROOT) loop 直到用户输入 q Main->>Main: messages = [system] User->>Main: 一行输入 Main->>Main: messages.push(user) Main->>Agent: runAgentUntilReplyOrMaxSteps(messages) loop 单轮 agent 步(step < agentMaxSteps) Agent->>Client: create({ messages, tools, tool_choice: auto }) Client->>API: POST /chat/completions API-->>Client: assistantMessage Client-->>Agent: assistantMessage Agent->>Agent: messages.push(assistantMessage) alt 无 tool_calls Agent-->>Main: assistantMessage(最终文字) else 有 tool_calls par 并发执行各 tool_call Agent->>Exec: executeSingleToolCall(call) Exec->>Exec: JSON.parse(arguments) Exec->>Handler: handler.run(parsedArgs) Handler->>FS: readFile / writeFile(经路径校验) FS-->>Handler: 文本结果 Handler-->>Exec: textResult Exec-->>Agent: { role: tool, tool_call_id, content } end Agent->>Agent: messages.push(各 tool 消息) Note over Agent: 继续 while,再次请求模型 end end alt 超过 agentMaxSteps Agent-->>Main: "⚠ 对话步数已达上限。"(字符串) end Main->>User: console.log(Assistant, reply.content) end

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 you

5. 扩展工具: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 循环。

sequenceDiagram autonumber participant User as 用户/终端 participant Main as main() participant Agent as runAgentUntilReplyOrMaxSteps participant Client as openaiClient participant API as DeepSeek API participant Exec as executeSingleToolCall participant Handler as toolHandlerByName participant FS as workspace 文件系统 User->>Main: 把 msg.txt 里的 you 替换为 her Main->>Main: messages = [system, user] Main->>Agent: runAgentUntilReplyOrMaxSteps(messages) Note over Agent,API: 第 1 步:模型选择先读取 Agent->>Client: create({ tools, tool_choice: auto }) Client->>API: POST /chat/completions API-->>Agent: tool_calls: readFile(msg.txt) Agent->>Agent: messages.push(assistant) Agent->>Exec: executeSingleToolCall(readFile) Exec->>Handler: ReadText.run Handler->>FS: readFile → "I love you" FS-->>Handler: 文件内容 Handler-->>Agent: role: tool, content: I love you Agent->>Agent: messages.push(tool) Note over Agent,API: 第 2 步:模型根据内容调用 editFile Agent->>Client: create(...) API-->>Agent: tool_calls: editFile(oldText, newText) Agent->>Agent: messages.push(assistant) Agent->>Exec: executeSingleToolCall(editFile) Exec->>Handler: EditFile.run Handler->>FS: read → replace 第一处 → write FS-->>Handler: I love her Handler-->>Agent: ✔ 已就地替换一处 Agent->>Agent: messages.push(tool) Note over Agent,API: 第 3 步:无 tool_calls,返回文字 Agent->>Client: create(...) API-->>Agent: content: 已完成替换… Agent-->>Main: assistantMessage Main->>User: Assistant 打印 reply.content alt 达到 agentMaxSteps Agent-->>Main: { role: assistant, content: ⚠ 已达上限 } end

5.3 msg.txt #

workspace/msg.txt

I love her

5.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 --save

6.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 后给出文字总结。

sequenceDiagram autonumber participant User as 用户/终端 participant Main as main() participant Agent as runAgentUntilReplyOrMaxSteps participant Client as openaiClient participant API as DeepSeek API participant Exec as executeSingleToolCall participant Run as RunCommandLine participant Shell as shell (spawnSync) participant Decode as decodeResultBuffer User->>Main: 执行 dir 命令 Main->>Main: messages = [system, user] Main->>Agent: runAgentUntilReplyOrMaxSteps(messages) Agent->>Client: create({ tools, tool_choice: auto }) Client->>API: POST /chat/completions API-->>Agent: tool_calls: runCommand(dir) Agent->>Agent: messages.push(assistant) Agent->>Exec: executeSingleToolCall Exec->>Run: run({ commandLine: "dir" }) Run->>Shell: spawnSync(dir, { cwd: WORKSPACE_ROOT, shell: true }) Shell-->>Run: stdout/stderr Buffer, status Run->>Decode: decodeResultBuffer (win32→cp936) Decode-->>Run: 可读文本 Run-->>Exec: 命令完成 + 输出 / 或 退出码 + 输出 Exec-->>Agent: role: tool, content: 汇总结果 Agent->>Agent: messages.push(tool) Agent->>Client: create(...) 再次请求 API-->>Agent: content: 目录说明(无 tool_calls) Agent-->>Main: assistantMessage Main->>User: Assistant 打印 reply.content

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 GB

7. 长驻命令后台运行: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 节同步路径(图中未画出)。

sequenceDiagram autonumber participant User as 用户/终端 participant Main as main() participant Agent as runAgentUntilReplyOrMaxSteps participant API as DeepSeek API participant Exec as executeSingleToolCall participant Run as RunCommandLine participant Hint as looksLikeLongRunningCommand participant Child as 子进程 (spawn) participant S1 as server1.js :1000 User->>Main: 执行 node server1.js Main->>Agent: runAgentUntilReplyOrMaxSteps Agent->>API: create → tool_calls: runCommand Agent->>Exec: executeSingleToolCall Exec->>Run: run({ commandLine }) Run->>Hint: 是否含 server 等关键字 Hint-->>Run: true → 后台分支 Run->>Child: spawn(shell, cwd=WORKSPACE_ROOT) Child->>S1: node server1.js S1-->>Child: stdout: Server running at… Run->>Run: bindStreamToBuffers 收集 chunk Run->>Run: await delay(8000ms) Run-->>Exec: PID + 启动段输出 Exec-->>Agent: role: tool Agent->>API: 再次 create(无 tool_calls) API-->>Agent: 服务已启动 localhost:1000 Agent-->>Main: assistantMessage Main->>User: 打印 Assistant Note over Child,S1: 子进程继续运行,<br/>不随工具返回而结束

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)。

sequenceDiagram autonumber participant User as 用户/终端 participant Agent as runAgentUntilReplyOrMaxSteps participant Run as RunCommandLine participant Map as backgroundProcesses participant Child as 子进程 Note over User,Child: 阶段 A:后台启动(与第 7 节相同,但多登记) User->>Agent: runCommand(node server1.js) Agent->>Run: run → runBackgroundCommandLine Run->>Child: spawn Run->>Map: set(pid, { commandLine, child, buffers }) Child-->>Run: stdout 启动日志 Run-->>Agent: PID + 启动段输出 Note over Map: server2 同理再 set 一条 Note over User,Map: 阶段 B:task_list 查询 User->>Agent: task_list(或 runCommand task_list) Agent->>Run: run({ commandLine: task_list }) Run->>Run: handleBackgroundTaskControlCommand Run->>Map: 遍历 entries Map-->>Run: pid + 状态 + commandLine Run-->>Agent: 【后台任务一览】文本 Note over Run: 不调用 spawn / spawnSync Child-->>Map: exit 时 delete(pid)

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)。

sequenceDiagram autonumber participant User as 用户/终端 participant Agent as runAgentUntilReplyOrMaxSteps participant Run as RunCommandLine participant Map as backgroundProcesses participant Decode as buffersToText participant Child as 子进程 User->>Agent: node server1.js Agent->>Run: runBackgroundCommandLine Run->>Child: spawn Run->>Map: set(pid, { buffers, child, commandLine }) Child-->>Run: stdout → buffers Run->>Decode: 启动段输出 Run-->>Agent: PID + 启动段 User->>Agent: task_list Agent->>Run: handleBackgroundTaskControlCommand Run->>Map: 遍历 Run-->>Agent: 活动中 · pid · node server1.js User->>Agent: task_logs pid Agent->>Run: task_logs Run->>Map: get(pid).buffers Run->>Decode: tailTextLines(…, 50) Run-->>Agent: 【PID 最近输出】 User->>Agent: task_stop pid Agent->>Run: task_stop Run->>Child: taskkill / kill Run->>Map: delete(pid) Run-->>Agent: 已请求终止 User->>Agent: task_list Run-->>Agent: (当前没有已登记的后台任务)

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

helloworld

10.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 步进(每轮常仅一个工具)。

sequenceDiagram autonumber participant Agent as runAgentUntilReplyOrMaxSteps participant API as DeepSeek API participant Exec as executeSingleToolCall API-->>Agent: assistantMessage.tool_calls alt 不含 runCommand loop 并发 Agent->>Exec: Promise.all(各 call) end else 含 runCommand loop 按 index 顺序 Agent->>Exec: await executeSingleToolCall(call) Exec-->>Agent: tool 消息 end end Agent->>Agent: messages.push(各 tool 响应) Agent->>API: 若无最终文字则继续 while
sequenceDiagram autonumber participant User as 用户 participant Agent as agent 循环 participant W as writeFile participant R as readFile participant FS as workspace/a.txt User->>Agent: 创建 a.txt,先 hello 再 world Agent->>W: content=hello W->>FS: hello Agent->>Agent: 下一轮 Agent->>R: read a.txt R-->>Agent: hello Agent->>Agent: 下一轮 Agent->>W: content=helloworld W->>FS: helloworld Agent->>R: 验证 R-->>User: helloworld

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 上下文。

sequenceDiagram autonumber participant Agent as executeSingleToolCall participant RC as RunCommandLine.run participant Ctrl as handleBackgroundTaskControl participant Ask as askCommandApproval participant User as 用户(终端) participant Shell as spawn / spawnSync Agent->>RC: run({ commandLine }) RC->>Ctrl: 是否为 task_* ? alt task_list / task_logs / task_stop Ctrl-->>RC: 管理类回复 RC-->>Agent: 直接返回(无审批) else 普通 shell 命令 alt alwaysApproveCommands 为 true Note over RC: 跳过审批 else 需要审批 RC->>Ask: await askCommandApproval(commandLine) Ask->>User: 展示命令与 [1/2/3] User->>Ask: 输入选择 alt 选 3 拒绝 Ask-->>RC: approved: false RC-->>Agent: 用户已拒绝执行命令: … else 选 1 或 2 opt 选 2 Ask->>Ask: alwaysApproveCommands = true end Ask-->>RC: approved: true RC->>Shell: runBlocking 或 runBackground Shell-->>RC: stdout/stderr 汇总 RC-->>Agent: 命令结果文本 end end end
sequenceDiagram autonumber participant User as 用户 participant Main as main / agent 循环 participant API as DeepSeek API participant RC as runCommand User->>Main: 执行命令 npm install Main->>API: messages + tools API-->>Main: tool_calls: runCommand Main->>RC: npm install RC->>User: [命令审批] 请选择 1/2/3 User->>RC: 3 RC-->>Main: 用户已拒绝… Main->>API: role=tool, content=拒绝文案 API-->>User: Assistant: 命令已被取消…

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 转成 OpenAI function 格式;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 zod

mcp_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 的端到端路径。

sequenceDiagram autonumber participant Main as claude.js main participant MC as mcp_client participant Transport as StdioClientTransport participant MS as mcp_server.js participant API as DeepSeek API Main->>MC: connectMcpServer() MC->>Transport: spawn node mcp_server.js Transport->>MS: stdio 握手 MC->>MS: listTools MS-->>MC: tools: [greet, ...] MC->>MC: mcpToolToOpenAI + toolNames MC-->>Main: openAiToolDefinitions Main->>Main: refreshModelToolDefinitions() Note over Main: activeModelToolDefinitions = 本地 + MCP loop agent 每轮 Main->>API: messages + activeModelToolDefinitions API-->>Main: tool_calls (含 greet 或本地工具) end
sequenceDiagram autonumber participant User as 用户 participant Agent as executeSingleToolCall participant MC as mcp_client participant MS as mcp_server greet participant API as DeepSeek API User->>API: 给张三打招呼 API-->>Agent: tool_calls: greet { name: 张三 } Agent->>Agent: isMcpTool("greet") === true Agent->>MC: callMcpTool("greet", { name }) MC->>MS: callTool via stdio MS-->>MC: content: [{ type: text, text: 你好,张三! }] MC->>MC: mcpResultToText MC-->>Agent: 你好,张三! Agent-->>API: role=tool, content=... API-->>User: Assistant: 已向张三问好…

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 节顺序执行)。

sequenceDiagram autonumber participant Main as claude.js main participant SK as skills.js participant FS as .claude/skills participant API as DeepSeek API Main->>SK: loadSkills() loop 每个 SKILL_DIRS SK->>FS: readdir + 读 SKILL.md FS-->>SK: meta + body SK->>SK: skills.set(name, ...) end Main->>SK: enrichSystem(BASE) SK-->>Main: agentSystemInstruction Note over Main: system 含 Skills 名称列表,不含全文 loop 每轮对话 Main->>Main: messages = [system] Main->>API: user + tools end
sequenceDiagram autonumber participant User as 用户 participant Main as main 循环 participant SK as parseSlash participant Agent as runAgent / executeSingleToolCall participant RS as ReadSkill participant RC as runCommand participant API as DeepSeek API User->>Main: /commit-message 只写标题,用 feat 前缀 Main->>SK: parseSlash(line) SK-->>Main: { skill, args } Main->>Main: userContent = 请按 Skill 执行 + body + 用户补充 Main->>API: messages + activeModelToolDefinitions API-->>Agent: tool_calls: readSkill, git status, git diff Note over Agent: 含 runCommand → 同轮顺序执行 Agent->>RS: readSkill(commit-message) RS-->>Agent: SKILL 正文步骤 Agent->>RC: git status / git diff RC-->>Agent: 变更输出 Agent-->>API: 多条 role=tool API-->>User: Assistant: feat: 新增 Skills 子系统…

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 窗口中直接执行以下命令,窗口关闭后设置即失效。

  1. 打开命令提示符(CMD)。
  2. 输入以下命令并回车:
    chcp 65001
    注:执行后,该窗口的代码页会临时切换为 UTF-8。如果你想切回默认的 GBK 编码,执行 chcp 936 即可。

14.2 方案二:永久设置(修改注册表) #

通过修改注册表,可以让所有新打开的 CMD 窗口默认使用 UTF-8 编码。

  1. 按 Win + R 键,输入 regedit 并回车,打开注册表编辑器。
  2. 在注册表顶部的地址栏中,粘贴以下路径并回车:
    HKEY_CURRENT_USER\Console\%SystemRoot%_system32_cmd.exe
  3. 在右侧的空白区域,右键 -> 新建 -> DWORD (32位)值。
  4. 将其命名为 CodePage,然后双击打开。
  5. 选择“十进制”,在数值数据中输入 65001,点击确定。
  6. 关闭注册表编辑器,之后打开的新 CMD 窗口就会默认使用 UTF-8 编码了。
← 上一节 1.claudecode 下一节 gray-matter →

访问验证

请输入访问令牌

Token不正确,请重新输入