min-agent 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/README.md +146 -18
  2. package/dist/agent.js +293 -408
  3. package/dist/assistant-stream.js +11 -7
  4. package/dist/cli.js +403 -140
  5. package/dist/clipboard.js +59 -23
  6. package/dist/code-mode.js +3 -3
  7. package/dist/compaction.js +182 -81
  8. package/dist/config.js +186 -35
  9. package/dist/confirm.js +55 -6
  10. package/dist/context-window.js +67 -54
  11. package/dist/doom-loop.js +19 -12
  12. package/dist/http.js +119 -0
  13. package/dist/instructions.js +51 -33
  14. package/dist/logger.js +66 -0
  15. package/dist/markdown.js +3 -44
  16. package/dist/mcp.js +547 -100
  17. package/dist/memory.js +48 -6
  18. package/dist/output.js +36 -27
  19. package/dist/paste-handler.js +3 -3
  20. package/dist/plugins.js +33 -6
  21. package/dist/pricing.js +119 -0
  22. package/dist/provider.js +17 -15
  23. package/dist/serve.js +658 -369
  24. package/dist/sessions.js +151 -13
  25. package/dist/skills.js +466 -76
  26. package/dist/synthetic.js +7 -0
  27. package/dist/title-gen.js +2 -1
  28. package/dist/tool-display.js +173 -0
  29. package/dist/tool-output.js +54 -45
  30. package/dist/tools/apply_patch.js +191 -0
  31. package/dist/tools/backend.js +61 -0
  32. package/dist/tools/bash.js +147 -70
  33. package/dist/tools/code_search.js +6 -5
  34. package/dist/tools/edit.js +23 -7
  35. package/dist/tools/explore.js +80 -12
  36. package/dist/tools/glob.js +3 -3
  37. package/dist/tools/grep.js +146 -14
  38. package/dist/tools/index.js +7 -7
  39. package/dist/tools/question.js +4 -22
  40. package/dist/tools/read.js +71 -11
  41. package/dist/tools/task.js +33 -20
  42. package/dist/tools/todo.js +83 -73
  43. package/dist/tools/web_fetch.js +150 -46
  44. package/dist/tools/web_search.js +706 -28
  45. package/dist/tools/write.js +13 -7
  46. package/dist/tui/App.js +40 -6
  47. package/dist/tui/ConfirmBar.js +24 -3
  48. package/dist/tui/InputBar.js +390 -45
  49. package/dist/tui/MessageList.js +533 -20
  50. package/dist/tui/ModelPicker.js +108 -0
  51. package/dist/tui/QuestionBar.js +104 -0
  52. package/dist/tui/StatusBar.js +19 -11
  53. package/dist/tui/agent-runner.js +103 -0
  54. package/dist/tui/caret-pos.js +134 -0
  55. package/dist/tui/caret.js +69 -0
  56. package/dist/tui/diff-view.js +61 -0
  57. package/dist/tui/drag-state.js +44 -0
  58. package/dist/tui/index.js +153 -24
  59. package/dist/tui/input-history.js +44 -0
  60. package/dist/tui/layout.js +17 -0
  61. package/dist/tui/mouse.js +46 -0
  62. package/dist/tui/selection.js +134 -0
  63. package/dist/tui/slash-commands.js +90 -0
  64. package/dist/tui/slash-handler.js +370 -0
  65. package/dist/tui/text-width.js +91 -0
  66. package/dist/tui/theme.js +12 -0
  67. package/dist/tui/undo-stack.js +14 -0
  68. package/dist/tui/use-sgr-mouse.js +27 -0
  69. package/dist/tui-chat.js +111 -331
  70. package/dist/updater.js +57 -0
  71. package/docs/API.md +160 -14
  72. package/docs/superpowers/plans/2026-08-16-batch1-tui-improvements.md +1510 -0
  73. package/docs/superpowers/plans/2026-08-16-batch2-cli-tools-api.md +2105 -0
  74. package/docs/superpowers/plans/2026-08-16-batch3-config-engineering.md +1595 -0
  75. package/docs/superpowers/plans/2026-08-16-input-caret.md +782 -0
  76. package/docs/superpowers/specs/2026-08-16-batch1-tui-improvements-design.md +183 -0
  77. package/docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md +220 -0
  78. package/docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md +196 -0
  79. package/docs/superpowers/specs/2026-08-16-input-caret-design.md +63 -0
  80. package/docs/superpowers/specs/2026-08-17-mouse-selection-design.md +116 -0
  81. package/package.json +7 -8
@@ -0,0 +1,2105 @@
1
+ # 第二批:CLI + 工具 + HTTP API Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** 补齐 CLI 命令(history 子命令、mcp env、update、export)、工具能力(bash cwd、apply_patch、write append、web_search 参数、chat 模式工具)、HTTP API(8 个新端点/参数)。
6
+
7
+ **Architecture:** 工具增强以纯函数 + 可注入依赖为主(apply_patch 解析器纯函数、updater 版本比较纯函数);API 端点沿用 serve.ts 现有路由模式;agent.ts 的 runOnce 加可选 options 参数透传采样参数。
8
+
9
+ **Tech Stack:** TypeScript, AI SDK v6, bun:test
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-08-16-batch2-cli-tools-api-design.md`
12
+
13
+ ---
14
+
15
+ ## 文件结构
16
+
17
+ | 文件 | 职责 |
18
+ |---|---|
19
+ | `src/tools/apply_patch.ts`(新) | unified diff 解析与应用工具 |
20
+ | `tests/apply-patch.test.ts`(新) | apply_patch 测试 |
21
+ | `src/updater.ts`(新) | 版本检查与自动升级 |
22
+ | `src/tools/bash.ts`(改) | BashInput.cwd + executeBash cwd |
23
+ | `src/tools/explore.ts`(改) | readOnlyBashTool 加 cwd |
24
+ | `tests/bash.test.ts`(改) | cwd 测试 |
25
+ | `src/tools/write.ts`(改) | append 参数 |
26
+ | `tests/file-tools.test.ts`(改) | append 测试 |
27
+ | `src/tools/web_search.ts`(改) | language/time_range |
28
+ | `tests/web-search.test.ts`(改) | 参数透传测试 |
29
+ | `src/tools/index.ts`(改) | chat 模式加 todo + apply_patch 注册 |
30
+ | `src/agent.ts`(改) | buildTools explore 两种模式 + runOnce options |
31
+ | `src/cli.ts`(改) | history 子命令、mcp --env、update、usage |
32
+ | `src/serve.ts`(改) | 新端点 + 采样参数 + paste code |
33
+ | `tests/serve.test.ts`(改) | 新端点测试 |
34
+ | `docs/API.md`(改) | 新端点文档 |
35
+ | `README.md`(改) | CLI 命令同步 |
36
+
37
+ ---
38
+
39
+ ## Task 1: bash 支持 cwd 参数
40
+
41
+ **Files:**
42
+ - Modify: `src/tools/bash.ts`
43
+ - Modify: `src/tools/explore.ts`
44
+ - Test: `tests/bash.test.ts`
45
+
46
+ - [ ] **Step 1: 写失败测试**
47
+
48
+ 在 `tests/bash.test.ts` 末尾追加(并补 import):
49
+
50
+ ```ts
51
+ import { mkdtempSync, rmSync, realpathSync } from "fs"
52
+ import { tmpdir } from "os"
53
+ import path from "path"
54
+
55
+ test("bash runs in the specified cwd", async () => {
56
+ const dir = realpathSync(mkdtempSync(path.join(tmpdir(), "min-agent-bash-")))
57
+ try {
58
+ const out = await executeBash("pwd", undefined, dir)
59
+ expect(out).toBe(dir)
60
+ } finally {
61
+ rmSync(dir, { recursive: true, force: true })
62
+ }
63
+ })
64
+ ```
65
+
66
+ - [ ] **Step 2: 运行测试确认失败**
67
+
68
+ Run: `bun test tests/bash.test.ts`
69
+ Expected: 新用例 FAIL(`pwd` 输出当前工作目录而非 dir)
70
+
71
+ - [ ] **Step 3: 修改 bash.ts**
72
+
73
+ `type BashInput` 加字段,`executeBash` 加参数,spawn 用 cwd。文件顶部补 `import path from "path"`。
74
+
75
+ ```ts
76
+ type BashInput = { command: string; timeout?: number; cwd?: string }
77
+ ```
78
+
79
+ 工具 schema 与描述:
80
+
81
+ ```ts
82
+ description:
83
+ "Run a shell command. Use this for system operations, running builds, tests, git commands, etc. The command runs in the current working directory (or `cwd` if specified). You SHOULD set a timeout based on how long you expect the command to take. If no timeout is set, the command runs until it finishes or the user manually interrupts (Ctrl+C).",
84
+ inputSchema: jsonSchema<BashInput>({
85
+ type: "object",
86
+ properties: {
87
+ command: { type: "string", description: "The shell command to execute" },
88
+ timeout: { type: "number", description: "Timeout in milliseconds. Set based on expected duration (e.g. 5000 for quick commands, 60000 for builds). Omit only for commands with unpredictable duration." },
89
+ cwd: { type: "string", description: "Working directory for the command (relative to the current directory or absolute)" },
90
+ },
91
+ required: ["command"],
92
+ }),
93
+ execute: async ({ command, timeout, cwd }) => {
94
+ if (!isAutoApprove() && isDangerousCommand(command)) {
95
+ const approved = await confirm(`Execute dangerous command: ${command}`)
96
+ if (!approved) return "Command rejected by user."
97
+ }
98
+ return executeBash(command, timeout, cwd)
99
+ },
100
+ ```
101
+
102
+ `executeBash` 签名与 spawn:
103
+
104
+ ```ts
105
+ export async function executeBash(command: string, timeout?: number, cwd?: string): Promise<string> {
106
+ ensureSigintHandler()
107
+ ...
108
+ const proc = spawn(actualCommand, [], {
109
+ shell: true,
110
+ cwd: cwd ? path.resolve(process.cwd(), cwd) : process.cwd(),
111
+ stdio: ["ignore", "pipe", "pipe"],
112
+ detached: process.platform !== "win32",
113
+ env: { ...process.env, ...(isWin ? { PYTHONIOENCODING: "utf-8" } : {}) },
114
+ })
115
+ ```
116
+
117
+ - [ ] **Step 4: 修改 explore.ts 的 readOnlyBashTool**
118
+
119
+ `execute` 内透传 cwd:
120
+
121
+ ```ts
122
+ inputSchema: jsonSchema<{ command: string; timeout?: number; cwd?: string }>({
123
+ type: "object",
124
+ properties: {
125
+ command: { type: "string", description: "The read-only shell command to execute" },
126
+ timeout: { type: "number", description: "Timeout in milliseconds" },
127
+ cwd: { type: "string", description: "Working directory (relative or absolute)" },
128
+ },
129
+ required: ["command"],
130
+ }),
131
+ execute: async ({ command, timeout, cwd }) => {
132
+ if (!isReadOnlyCommand(command)) {
133
+ return `Error: "${command.slice(0, 80)}" is not a read-only command. Allowed: ${[...READ_ONLY_BINS].join(", ")} (read-only git subcommands only).`
134
+ }
135
+ return executeBash(command, timeout, cwd)
136
+ },
137
+ ```
138
+
139
+ - [ ] **Step 5: 运行测试确认通过**
140
+
141
+ Run: `bun test tests/bash.test.ts`
142
+ Expected: PASS(6 个原有用例 + 1 个新用例)
143
+
144
+ - [ ] **Step 6: 提交**
145
+
146
+ ```bash
147
+ git add src/tools/bash.ts src/tools/explore.ts tests/bash.test.ts
148
+ git commit -m "feat: bash 工具支持工作目录参数"
149
+ ```
150
+
151
+ ---
152
+
153
+ ## Task 2: write 支持 append
154
+
155
+ **Files:**
156
+ - Modify: `src/tools/write.ts`
157
+ - Test: `tests/file-tools.test.ts`
158
+
159
+ - [ ] **Step 1: 写失败测试**
160
+
161
+ 在 `tests/file-tools.test.ts` 末尾追加:
162
+
163
+ ```ts
164
+ test("write appends when append is set", async () => {
165
+ const dir = tmpDir()
166
+ try {
167
+ const file = path.join(dir, "a.txt")
168
+ await exec(writeTool)({ filePath: file, content: "one\n" }, {})
169
+ const out = await exec(writeTool)({ filePath: file, content: "two\n", append: true }, {})
170
+ expect(out).toContain("(appended)")
171
+ expect(readFileSync(file, "utf-8")).toBe("one\ntwo\n")
172
+ } finally {
173
+ rmSync(dir, { recursive: true, force: true })
174
+ }
175
+ })
176
+
177
+ test("write append creates the file when it does not exist", async () => {
178
+ const dir = tmpDir()
179
+ try {
180
+ const file = path.join(dir, "new.txt")
181
+ const out = await exec(writeTool)({ filePath: file, content: "hello", append: true }, {})
182
+ expect(out).toContain("(appended)")
183
+ expect(readFileSync(file, "utf-8")).toBe("hello")
184
+ } finally {
185
+ rmSync(dir, { recursive: true, force: true })
186
+ }
187
+ })
188
+ ```
189
+
190
+ (`readFileSync` 已在 import 中)
191
+
192
+ - [ ] **Step 2: 运行测试确认失败**
193
+
194
+ Run: `bun test tests/file-tools.test.ts`
195
+ Expected: 新用例 FAIL(append 未实现,输出不含 "(appended)",且内容被覆盖)
196
+
197
+ - [ ] **Step 3: 修改 write.ts**
198
+
199
+ ```ts
200
+ type WriteInput = { filePath: string; content: string; append?: boolean }
201
+ ```
202
+
203
+ 描述:
204
+
205
+ ```ts
206
+ description:
207
+ "Write content to a file. Creates the file if it doesn't exist, overwrites if it does (unless `append` is true). Creates parent directories as needed.",
208
+ ```
209
+
210
+ schema properties 追加:
211
+
212
+ ```ts
213
+ append: { type: "boolean", description: "If true, append to the file instead of overwriting" },
214
+ ```
215
+
216
+ execute:
217
+
218
+ ```ts
219
+ execute: async ({ filePath, content, append }) => {
220
+ if (Buffer.byteLength(content, "utf-8") > MAX_WRITE_BYTES) {
221
+ return `Error: content exceeds ${MAX_WRITE_BYTES} bytes. Write in smaller chunks or check the content size.`
222
+ }
223
+ const resolved = path.resolve(process.cwd(), filePath)
224
+
225
+ // Confirm overwriting existing files
226
+ if (!append && !isAutoApprove() && existsSync(resolved)) {
227
+ const approved = await confirm(`Overwrite existing file: ${filePath}`)
228
+ if (!approved) return "Write rejected by user."
229
+ }
230
+
231
+ try {
232
+ await mkdir(path.dirname(resolved), { recursive: true })
233
+ await writeFile(resolved, content, append ? { flag: "a", encoding: "utf-8" } : "utf-8")
234
+ return `Written ${Buffer.byteLength(content, "utf-8")} bytes to ${filePath}${append ? " (appended)" : ""}`
235
+ } catch (err: any) {
236
+ return `Error writing file: ${err.message}`
237
+ }
238
+ },
239
+ ```
240
+
241
+ - [ ] **Step 4: 运行测试确认通过**
242
+
243
+ Run: `bun test tests/file-tools.test.ts`
244
+ Expected: PASS(原有用例 + 2 个新用例)
245
+
246
+ - [ ] **Step 5: 提交**
247
+
248
+ ```bash
249
+ git add src/tools/write.ts tests/file-tools.test.ts
250
+ git commit -m "feat: write 工具支持追加模式"
251
+ ```
252
+
253
+ ---
254
+
255
+ ## Task 3: apply_patch 工具(unified diff 应用)
256
+
257
+ **Files:**
258
+ - Create: `src/tools/apply_patch.ts`
259
+ - Modify: `src/tools/index.ts`(注册到 chat 与 code 工具集)
260
+ - Test: `tests/apply-patch.test.ts`
261
+
262
+ - [ ] **Step 1: 写失败测试**
263
+
264
+ 创建 `tests/apply-patch.test.ts`:
265
+
266
+ ```ts
267
+ import { expect, test } from "bun:test"
268
+ import { mkdtempSync, readFileSync, writeFileSync, rmSync, realpathSync, existsSync } from "fs"
269
+ import os from "os"
270
+ import path from "path"
271
+ import { setAutoApprove } from "../src/confirm.js"
272
+ import { parseUnifiedDiff, applyPatch } from "../src/tools/apply_patch.js"
273
+
274
+ setAutoApprove(true)
275
+
276
+ function makeTemp(): string {
277
+ return realpathSync(mkdtempSync(path.join(os.tmpdir(), "min-agent-patch-")))
278
+ }
279
+
280
+ const MODIFY_DIFF = `--- a/foo.txt
281
+ +++ b/foo.txt
282
+ @@ -1,3 +1,3 @@
283
+ line1
284
+ -line2
285
+ +line2-edited
286
+ line3
287
+ `
288
+
289
+ test("parseUnifiedDiff parses headers and hunks", () => {
290
+ const files = parseUnifiedDiff(MODIFY_DIFF)
291
+ expect(files.length).toBe(1)
292
+ expect(files[0]!.path).toBe("foo.txt")
293
+ expect(files[0]!.isNew).toBe(false)
294
+ expect(files[0]!.hunks.length).toBe(1)
295
+ const lines = files[0]!.hunks[0]!.lines
296
+ expect(lines.map((l) => l.type)).toEqual(["context", "delete", "add", "context"])
297
+ })
298
+
299
+ test("applyPatch modifies an existing file", () => {
300
+ const dir = makeTemp()
301
+ try {
302
+ writeFileSync(path.join(dir, "foo.txt"), "line1\nline2\nline3\n")
303
+ const result = applyPatch(parseUnifiedDiff(MODIFY_DIFF), dir)
304
+ expect(result.ok).toBe(true)
305
+ expect(readFileSync(path.join(dir, "foo.txt"), "utf-8")).toBe("line1\nline2-edited\nline3\n")
306
+ } finally {
307
+ rmSync(dir, { recursive: true, force: true })
308
+ }
309
+ })
310
+
311
+ test("applyPatch creates a new file from /dev/null", () => {
312
+ const dir = makeTemp()
313
+ try {
314
+ const diff = `--- /dev/null
315
+ +++ b/new.txt
316
+ @@ -0,0 +1,2 @@
317
+ +hello
318
+ +world
319
+ `
320
+ const result = applyPatch(parseUnifiedDiff(diff), dir)
321
+ expect(result.ok).toBe(true)
322
+ expect(readFileSync(path.join(dir, "new.txt"), "utf-8")).toBe("hello\nworld\n")
323
+ } finally {
324
+ rmSync(dir, { recursive: true, force: true })
325
+ }
326
+ })
327
+
328
+ test("applyPatch deletes lines", () => {
329
+ const dir = makeTemp()
330
+ try {
331
+ writeFileSync(path.join(dir, "foo.txt"), "keep\nremove-me\nkeep2\n")
332
+ const diff = `--- a/foo.txt
333
+ +++ b/foo.txt
334
+ @@ -1,3 +1,2 @@
335
+ keep
336
+ -remove-me
337
+ keep2
338
+ `
339
+ const result = applyPatch(parseUnifiedDiff(diff), dir)
340
+ expect(result.ok).toBe(true)
341
+ expect(readFileSync(path.join(dir, "foo.txt"), "utf-8")).toBe("keep\nkeep2\n")
342
+ } finally {
343
+ rmSync(dir, { recursive: true, force: true })
344
+ }
345
+ })
346
+
347
+ test("applyPatch matches hunks with small offsets", () => {
348
+ const dir = makeTemp()
349
+ try {
350
+ writeFileSync(path.join(dir, "foo.txt"), "a\nb\nc\nline1\nline2\nline3\nd\n")
351
+ const result = applyPatch(parseUnifiedDiff(MODIFY_DIFF), dir)
352
+ expect(result.ok).toBe(true)
353
+ expect(readFileSync(path.join(dir, "foo.txt"), "utf-8")).toBe("a\nb\nc\nline1\nline2-edited\nline3\nd\n")
354
+ } finally {
355
+ rmSync(dir, { recursive: true, force: true })
356
+ }
357
+ })
358
+
359
+ test("applyPatch rejects conflicts without partial writes", () => {
360
+ const dir = makeTemp()
361
+ try {
362
+ writeFileSync(path.join(dir, "foo.txt"), "totally\ndifferent\ncontent\n")
363
+ const result = applyPatch(parseUnifiedDiff(MODIFY_DIFF), dir)
364
+ expect(result.ok).toBe(false)
365
+ expect(readFileSync(path.join(dir, "foo.txt"), "utf-8")).toBe("totally\ndifferent\ncontent\n")
366
+ } finally {
367
+ rmSync(dir, { recursive: true, force: true })
368
+ }
369
+ })
370
+
371
+ test("applyPatch handles multiple files in one diff", () => {
372
+ const dir = makeTemp()
373
+ try {
374
+ writeFileSync(path.join(dir, "a.txt"), "x1\nx2\n")
375
+ writeFileSync(path.join(dir, "b.txt"), "y1\n")
376
+ const diff = `--- a/a.txt
377
+ +++ b/a.txt
378
+ @@ -1,2 +1,2 @@
379
+ x1
380
+ -x2
381
+ +x2b
382
+ --- a/b.txt
383
+ +++ b/b.txt
384
+ @@ -1 +1 @@
385
+ -y1
386
+ +y1b
387
+ `
388
+ const result = applyPatch(parseUnifiedDiff(diff), dir)
389
+ expect(result.ok).toBe(true)
390
+ expect(readFileSync(path.join(dir, "a.txt"), "utf-8")).toBe("x1\nx2b\n")
391
+ expect(readFileSync(path.join(dir, "b.txt"), "utf-8")).toBe("y1b\n")
392
+ } finally {
393
+ rmSync(dir, { recursive: true, force: true })
394
+ }
395
+ })
396
+
397
+ test("applyPatch handles multiple hunks in one file", () => {
398
+ const dir = makeTemp()
399
+ try {
400
+ writeFileSync(path.join(dir, "foo.txt"), "one\ntwo\nthree\nfour\n")
401
+ const diff = `--- a/foo.txt
402
+ +++ b/foo.txt
403
+ @@ -1,2 +1,2 @@
404
+ -one
405
+ +one-edited
406
+ two
407
+ @@ -3,2 +3,2 @@
408
+ three
409
+ -four
410
+ +four-edited
411
+ `
412
+ const result = applyPatch(parseUnifiedDiff(diff), dir)
413
+ expect(result.ok).toBe(true)
414
+ expect(readFileSync(path.join(dir, "foo.txt"), "utf-8")).toBe("one-edited\ntwo\nthree\nfour-edited\n")
415
+ } finally {
416
+ rmSync(dir, { recursive: true, force: true })
417
+ }
418
+ })
419
+
420
+ test("applyPatch fails when the target file does not exist", () => {
421
+ const dir = makeTemp()
422
+ try {
423
+ const result = applyPatch(parseUnifiedDiff(MODIFY_DIFF), dir)
424
+ expect(result.ok).toBe(false)
425
+ if (!result.ok) expect(result.error).toContain("不存在")
426
+ } finally {
427
+ rmSync(dir, { recursive: true, force: true })
428
+ }
429
+ })
430
+ ```
431
+
432
+ - [ ] **Step 2: 运行测试确认失败**
433
+
434
+ Run: `bun test tests/apply-patch.test.ts`
435
+ Expected: FAIL — module not found
436
+
437
+ - [ ] **Step 3: 实现 apply_patch.ts**
438
+
439
+ ```ts
440
+ import { tool, jsonSchema } from "ai"
441
+ import { readFile, writeFile } from "fs/promises"
442
+ import { existsSync } from "fs"
443
+ import path from "path"
444
+ import { confirm, isAutoApprove } from "../confirm.js"
445
+
446
+ export interface HunkLine {
447
+ type: "context" | "delete" | "add"
448
+ text: string
449
+ }
450
+
451
+ export interface Hunk {
452
+ oldStart: number
453
+ oldCount: number
454
+ newStart: number
455
+ newCount: number
456
+ lines: HunkLine[]
457
+ }
458
+
459
+ export interface PatchFile {
460
+ path: string
461
+ isNew: boolean
462
+ hunks: Hunk[]
463
+ }
464
+
465
+ export type PatchResult =
466
+ | { ok: true; files: { path: string; isNew: boolean; del: number; add: number }[] }
467
+ | { ok: false; error: string }
468
+
469
+ const OFFSET_TOLERANCE = 3
470
+
471
+ export function parseUnifiedDiff(diff: string): PatchFile[] {
472
+ const files: PatchFile[] = []
473
+ let current: PatchFile | null = null
474
+ let currentHunk: Hunk | null = null
475
+ let pendingHeader: string | null = null
476
+
477
+ const lines = diff.replace(/\r\n/g, "\n").split("\n")
478
+
479
+ for (const raw of lines) {
480
+ if (raw.startsWith("--- ")) {
481
+ pendingHeader = raw.slice(4)
482
+ continue
483
+ }
484
+ if (pendingHeader !== null && raw.startsWith("+++ ")) {
485
+ const target = raw.slice(4)
486
+ if (current) files.push(current)
487
+ current = {
488
+ path: stripPrefix(target),
489
+ isNew: pendingHeader === "/dev/null",
490
+ hunks: [],
491
+ }
492
+ currentHunk = null
493
+ pendingHeader = null
494
+ continue
495
+ }
496
+ if (raw.startsWith("@@ ")) {
497
+ const m = raw.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/)
498
+ if (m && current) {
499
+ currentHunk = {
500
+ oldStart: parseInt(m[1]!, 10),
501
+ oldCount: m[2] ? parseInt(m[2], 10) : 1,
502
+ newStart: parseInt(m[3]!, 10),
503
+ newCount: m[4] ? parseInt(m[4], 10) : 1,
504
+ lines: [],
505
+ }
506
+ current.hunks.push(currentHunk)
507
+ }
508
+ continue
509
+ }
510
+ if (currentHunk && current) {
511
+ if (raw.startsWith("+") && !raw.startsWith("+++")) {
512
+ currentHunk.lines.push({ type: "add", text: raw.slice(1) })
513
+ } else if (raw.startsWith("-") && !raw.startsWith("---")) {
514
+ currentHunk.lines.push({ type: "delete", text: raw.slice(1) })
515
+ } else if (raw.startsWith(" ")) {
516
+ currentHunk.lines.push({ type: "context", text: raw.slice(1) })
517
+ }
518
+ // "\" 行忽略
519
+ }
520
+ }
521
+ if (current) files.push(current)
522
+ return files
523
+ }
524
+
525
+ function stripPrefix(p: string): string {
526
+ const trimmed = p.trim()
527
+ if (trimmed === "/dev/null") return trimmed
528
+ if (trimmed.startsWith("a/")) return trimmed.slice(2)
529
+ if (trimmed.startsWith("b/")) return trimmed.slice(2)
530
+ return trimmed
531
+ }
532
+
533
+ function findMatch(lines: string[], start: number, block: string[]): number {
534
+ if (block.length === 0) return Math.max(0, Math.min(start, lines.length))
535
+ for (
536
+ let pos = Math.max(0, start - OFFSET_TOLERANCE);
537
+ pos <= Math.min(lines.length - block.length, start + OFFSET_TOLERANCE);
538
+ pos++
539
+ ) {
540
+ let ok = true
541
+ for (let i = 0; i < block.length; i++) {
542
+ if (lines[pos + i] !== block[i]) {
543
+ ok = false
544
+ break
545
+ }
546
+ }
547
+ if (ok) return pos
548
+ }
549
+ return -1
550
+ }
551
+
552
+ function applyHunk(lines: string[], hunk: Hunk): { lines: string[] } | { error: string } {
553
+ const block = hunk.lines.filter((l) => l.type !== "add").map((l) => l.text)
554
+ const pos = findMatch(lines, hunk.oldStart - 1, block)
555
+ if (pos === -1) {
556
+ return { error: `第 ${hunk.oldStart} 行附近的 hunk 未找到匹配(需要匹配 ${block.length} 行)` }
557
+ }
558
+ const result = [...lines]
559
+ result.splice(pos, block.length)
560
+ const adds = hunk.lines.filter((l) => l.type === "add").map((l) => l.text)
561
+ result.splice(pos, 0, ...adds)
562
+ return { lines: result }
563
+ }
564
+
565
+ export function applyPatch(files: PatchFile[], cwd: string = process.cwd()): PatchResult {
566
+ const applied: { file: PatchFile; content: string }[] = []
567
+ for (const file of files) {
568
+ const target = path.resolve(cwd, file.path)
569
+ if (file.isNew && !existsSync(target)) {
570
+ const lines: string[] = []
571
+ for (const hunk of file.hunks) {
572
+ const r = applyHunk(lines, hunk)
573
+ if ("error" in r) return { ok: false, error: `${file.path}: ${r.error}` }
574
+ lines.length = 0
575
+ lines.push(...r.lines)
576
+ }
577
+ applied.push({ file, content: lines.join("\n") })
578
+ continue
579
+ }
580
+ if (!existsSync(target)) {
581
+ return { ok: false, error: `${file.path}: 文件不存在` }
582
+ }
583
+ let content: string
584
+ try {
585
+ content = readFileSync2(target)
586
+ } catch (err: any) {
587
+ return { ok: false, error: `${file.path}: 读取失败 ${err.message}` }
588
+ }
589
+ const lines = content.split("\n")
590
+ for (const hunk of file.hunks) {
591
+ const r = applyHunk(lines, hunk)
592
+ if ("error" in r) return { ok: false, error: `${file.path}: ${r.error}` }
593
+ lines.length = 0
594
+ lines.push(...r.lines)
595
+ }
596
+ applied.push({ file, content: lines.join("\n") })
597
+ }
598
+
599
+ const changed = applied.map((a) => ({
600
+ path: a.file.path,
601
+ isNew: a.file.isNew,
602
+ del: a.file.hunks.reduce((s, h) => s + h.lines.filter((l) => l.type === "delete").length, 0),
603
+ add: a.file.hunks.reduce((s, h) => s + h.lines.filter((l) => l.type === "add").length, 0),
604
+ }))
605
+ return { ok: true, files: changed }
606
+ }
607
+
608
+ function readFileSync2(p: string): string {
609
+ return readFileSyncSync(p)
610
+ }
611
+ ```
612
+
613
+ 注意:上面 `readFileSync2`/`readFileSyncSync` 是为了避免与 fs/promises 冲突的占位——**实际实现**应为:
614
+
615
+ ```ts
616
+ import { readFileSync } from "fs"
617
+
618
+ function readFileSync2(p: string): string {
619
+ return readFileSync(p, "utf-8")
620
+ }
621
+ ```
622
+
623
+ (`applyPatch` 是同步函数,用 fs.readFileSync;async 写入由工具 execute 层处理。**最终实现请保持 applyPatch 纯同步**,读取用 `readFileSync`(fs),写入在工具 execute 里做。为清晰起见,把写盘逻辑也放进 applyPatch:文件全部应用成功后在内存计算 content,然后由 execute 写盘前 confirm。)
624
+
625
+ 最终 `apply_patch.ts` 的完整结构(以此为准,替换上面占位思路):
626
+
627
+ ```ts
628
+ import { tool, jsonSchema } from "ai"
629
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "fs"
630
+ import path from "path"
631
+ import { confirm, isAutoApprove } from "../confirm.js"
632
+
633
+ export interface HunkLine {
634
+ type: "context" | "delete" | "add"
635
+ text: string
636
+ }
637
+
638
+ export interface Hunk {
639
+ oldStart: number
640
+ oldCount: number
641
+ newStart: number
642
+ newCount: number
643
+ lines: HunkLine[]
644
+ }
645
+
646
+ export interface PatchFile {
647
+ path: string
648
+ isNew: boolean
649
+ hunks: Hunk[]
650
+ }
651
+
652
+ export type PatchResult =
653
+ | { ok: true; files: { path: string; isNew: boolean; del: number; add: number }[] }
654
+ | { ok: false; error: string }
655
+
656
+ const OFFSET_TOLERANCE = 3
657
+
658
+ export function parseUnifiedDiff(diff: string): PatchFile[] {
659
+ const files: PatchFile[] = []
660
+ let current: PatchFile | null = null
661
+ let currentHunk: Hunk | null = null
662
+ let pendingHeader: string | null = null
663
+
664
+ const lines = diff.replace(/\r\n/g, "\n").split("\n")
665
+
666
+ for (const raw of lines) {
667
+ if (raw.startsWith("--- ")) {
668
+ pendingHeader = raw.slice(4)
669
+ continue
670
+ }
671
+ if (pendingHeader !== null && raw.startsWith("+++ ")) {
672
+ const target = raw.slice(4)
673
+ if (current) files.push(current)
674
+ current = { path: stripPrefix(target), isNew: pendingHeader === "/dev/null", hunks: [] }
675
+ currentHunk = null
676
+ pendingHeader = null
677
+ continue
678
+ }
679
+ if (raw.startsWith("@@ ")) {
680
+ const m = raw.match(/^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@/)
681
+ if (m && current) {
682
+ currentHunk = {
683
+ oldStart: parseInt(m[1]!, 10),
684
+ oldCount: m[2] ? parseInt(m[2], 10) : 1,
685
+ newStart: parseInt(m[3]!, 10),
686
+ newCount: m[4] ? parseInt(m[4], 10) : 1,
687
+ lines: [],
688
+ }
689
+ current.hunks.push(currentHunk)
690
+ }
691
+ continue
692
+ }
693
+ if (currentHunk && current) {
694
+ if (raw.startsWith("+") && !raw.startsWith("+++")) {
695
+ currentHunk.lines.push({ type: "add", text: raw.slice(1) })
696
+ } else if (raw.startsWith("-") && !raw.startsWith("---")) {
697
+ currentHunk.lines.push({ type: "delete", text: raw.slice(1) })
698
+ } else if (raw.startsWith(" ")) {
699
+ currentHunk.lines.push({ type: "context", text: raw.slice(1) })
700
+ }
701
+ }
702
+ }
703
+ if (current) files.push(current)
704
+ return files
705
+ }
706
+
707
+ function stripPrefix(p: string): string {
708
+ const trimmed = p.trim()
709
+ if (trimmed === "/dev/null") return trimmed
710
+ if (trimmed.startsWith("a/")) return trimmed.slice(2)
711
+ if (trimmed.startsWith("b/")) return trimmed.slice(2)
712
+ return trimmed
713
+ }
714
+
715
+ function findMatch(lines: string[], start: number, block: string[]): number {
716
+ if (block.length === 0) return Math.max(0, Math.min(start, lines.length))
717
+ for (
718
+ let pos = Math.max(0, start - OFFSET_TOLERANCE);
719
+ pos <= Math.min(lines.length - block.length, start + OFFSET_TOLERANCE);
720
+ pos++
721
+ ) {
722
+ let ok = true
723
+ for (let i = 0; i < block.length; i++) {
724
+ if (lines[pos + i] !== block[i]) {
725
+ ok = false
726
+ break
727
+ }
728
+ }
729
+ if (ok) return pos
730
+ }
731
+ return -1
732
+ }
733
+
734
+ function applyHunk(lines: string[], hunk: Hunk): { lines: string[] } | { error: string } {
735
+ const block = hunk.lines.filter((l) => l.type !== "add").map((l) => l.text)
736
+ const pos = findMatch(lines, hunk.oldStart - 1, block)
737
+ if (pos === -1) {
738
+ return { error: `第 ${hunk.oldStart} 行附近的 hunk 未找到匹配(需要匹配 ${block.length} 行)` }
739
+ }
740
+ const result = [...lines]
741
+ result.splice(pos, block.length)
742
+ const adds = hunk.lines.filter((l) => l.type === "add").map((l) => l.text)
743
+ result.splice(pos, 0, ...adds)
744
+ return { lines: result }
745
+ }
746
+
747
+ /** 在内存中应用全部文件;成功后返回各文件新内容。 */
748
+ export function buildPatchedFiles(
749
+ files: PatchFile[],
750
+ cwd: string = process.cwd(),
751
+ ): { ok: true; contents: { file: PatchFile; content: string }[] } | { ok: false; error: string } {
752
+ const contents: { file: PatchFile; content: string }[] = []
753
+ for (const file of files) {
754
+ const target = path.resolve(cwd, file.path)
755
+ const exists = existsSync(target)
756
+ if (file.isNew && !exists) {
757
+ const lines: string[] = []
758
+ for (const hunk of file.hunks) {
759
+ const r = applyHunk(lines, hunk)
760
+ if ("error" in r) return { ok: false, error: `${file.path}: ${r.error}` }
761
+ lines.length = 0
762
+ lines.push(...r.lines)
763
+ }
764
+ contents.push({ file, content: lines.join("\n") })
765
+ continue
766
+ }
767
+ if (!exists) return { ok: false, error: `${file.path}: 文件不存在` }
768
+ let content: string
769
+ try {
770
+ content = readFileSync(target, "utf-8")
771
+ } catch (err: any) {
772
+ return { ok: false, error: `${file.path}: 读取失败 ${err.message}` }
773
+ }
774
+ const lines = content.split("\n")
775
+ for (const hunk of file.hunks) {
776
+ const r = applyHunk(lines, hunk)
777
+ if ("error" in r) return { ok: false, error: `${file.path}: ${r.error}` }
778
+ lines.length = 0
779
+ lines.push(...r.lines)
780
+ }
781
+ contents.push({ file, content: lines.join("\n") })
782
+ }
783
+ return { ok: true, contents }
784
+ }
785
+
786
+ export const applyPatchTool = tool<{ diff: string }, string>({
787
+ description:
788
+ "Apply a unified diff to the working tree. Use this for precise multi-file edits or when you have a generated diff. " +
789
+ "Supports standard unified diff format (---/+++ headers, @@ hunks, context/delete/add lines). " +
790
+ "New files are created when the diff targets /dev/null. Conflicts are reported without partial writes.",
791
+ inputSchema: jsonSchema<{ diff: string }>({
792
+ type: "object",
793
+ properties: {
794
+ diff: { type: "string", description: "The unified diff text to apply" },
795
+ },
796
+ required: ["diff"],
797
+ }),
798
+ execute: async ({ diff }) => {
799
+ const parsed = parseUnifiedDiff(diff)
800
+ if (parsed.length === 0) return "Error: 无法解析 diff(缺少 ---/+++ 文件头)"
801
+ const built = buildPatchedFiles(parsed)
802
+ if (!built.ok) return `Error: ${built.error}`
803
+ const summary = built.contents
804
+ .map((c) => {
805
+ const del = c.file.hunks.reduce((s, h) => s + h.lines.filter((l) => l.type === "delete").length, 0)
806
+ const add = c.file.hunks.reduce((s, h) => s + h.lines.filter((l) => l.type === "add").length, 0)
807
+ return ` ${c.file.path} ${c.file.isNew ? "(new)" : ""} -${del}/+${add}`
808
+ })
809
+ .join("\n")
810
+ if (!isAutoApprove()) {
811
+ const approved = await confirm(`Apply patch to ${built.contents.length} file(s)?\n${summary}`)
812
+ if (!approved) return "Patch rejected by user."
813
+ }
814
+ for (const c of built.contents) {
815
+ const target = path.resolve(process.cwd(), c.file.path)
816
+ mkdirSync(path.dirname(target), { recursive: true })
817
+ writeFileSync(target, c.content, "utf-8")
818
+ }
819
+ return `Applied patch:\n${summary}`
820
+ },
821
+ })
822
+ ```
823
+
824
+ 注意:
825
+ - `buildPatchedFiles` 是纯同步函数(供测试直接调用,不写盘)
826
+ - `applyPatch` 测试 import 的 `parseUnifiedDiff, applyPatch` —— **测试改为 import `parseUnifiedDiff, buildPatchedFiles`**,断言改为检查 `buildPatchedFiles` 返回的 contents(写盘由工具 execute 负责,测试用 `setAutoApprove(true)` 后可走 execute 验证写盘)。测试文件中的 `applyPatch(...)` 调用全部替换为:
827
+
828
+ ```ts
829
+ const built = buildPatchedFiles(parseUnifiedDiff(diff), dir)
830
+ expect(built.ok).toBe(true)
831
+ if (built.ok) expect(built.contents[0]!.content).toBe("line1\nline2-edited\nline3\n")
832
+ ```
833
+
834
+ 冲突/不存在用例断言 `built.ok === false` 与 `built.error`。**请按此调整 Step 1 的测试代码**(Step 1 中 `applyPatch` 调用点统一改 `buildPatchedFiles`,断言相应调整)。
835
+
836
+ - [ ] **Step 4: 注册到 tools/index.ts**
837
+
838
+ `createChatTools` 与 `createCodeTools` 的 tools 对象都加一行:
839
+
840
+ ```ts
841
+ import { applyPatchTool } from "./apply_patch.js"
842
+ ```
843
+
844
+ ```ts
845
+ apply_patch: applyPatchTool,
846
+ ```
847
+
848
+ - [ ] **Step 5: 运行测试确认通过**
849
+
850
+ Run: `bun test tests/apply-patch.test.ts`
851
+ Expected: PASS(9 个用例)
852
+
853
+ - [ ] **Step 6: 提交**
854
+
855
+ ```bash
856
+ git add src/tools/apply_patch.ts src/tools/index.ts tests/apply-patch.test.ts
857
+ git commit -m "feat: apply_patch 工具支持 unified diff 应用"
858
+ ```
859
+
860
+ ---
861
+
862
+ ## Task 4: web_search 参数扩展
863
+
864
+ **Files:**
865
+ - Modify: `src/tools/web_search.ts`
866
+ - Test: `tests/web-search.test.ts`
867
+
868
+ - [ ] **Step 1: 写失败测试**
869
+
870
+ 在 `tests/web-search.test.ts` 末尾追加:
871
+
872
+ ```ts
873
+ test("web_search forwards language and time_range params", async () => {
874
+ let captured = ""
875
+ const originalFetch = globalThis.fetch
876
+ globalThis.fetch = (async (url: any) => {
877
+ captured = String(url)
878
+ return new Response(JSON.stringify({ results: [] }), {
879
+ status: 200,
880
+ headers: { "Content-Type": "application/json" },
881
+ })
882
+ }) as typeof fetch
883
+ try {
884
+ await exec({ query: "q", language: "zh", time_range: "week" }, {})
885
+ expect(captured).toContain("language=zh")
886
+ expect(captured).toContain("time_range=week")
887
+ } finally {
888
+ globalThis.fetch = originalFetch
889
+ }
890
+ })
891
+
892
+ test("web_search ignores an invalid time_range", async () => {
893
+ let captured = ""
894
+ const originalFetch = globalThis.fetch
895
+ globalThis.fetch = (async (url: any) => {
896
+ captured = String(url)
897
+ return new Response(JSON.stringify({ results: [] }), {
898
+ status: 200,
899
+ headers: { "Content-Type": "application/json" },
900
+ })
901
+ }) as typeof fetch
902
+ try {
903
+ await exec({ query: "q", time_range: "decade" }, {})
904
+ expect(captured).not.toContain("time_range")
905
+ } finally {
906
+ globalThis.fetch = originalFetch
907
+ }
908
+ })
909
+ ```
910
+
911
+ - [ ] **Step 2: 运行测试确认失败**
912
+
913
+ Run: `bun test tests/web-search.test.ts`
914
+ Expected: 新用例 FAIL(URL 不含 language/time_range)
915
+
916
+ - [ ] **Step 3: 修改 web_search.ts**
917
+
918
+ ```ts
919
+ type WebSearchInput = { query: string; categories?: string; language?: string; time_range?: string }
920
+
921
+ const VALID_TIME_RANGES = new Set(["day", "week", "month", "year"])
922
+ ```
923
+
924
+ schema properties 追加:
925
+
926
+ ```ts
927
+ language: { type: "string", description: "Search language code (e.g. zh, en). Passed to the backend when valid" },
928
+ time_range: { type: "string", description: "Time range filter: day, week, month, year" },
929
+ ```
930
+
931
+ execute:
932
+
933
+ ```ts
934
+ execute: async ({ query, categories, language, time_range }) => {
935
+ const cat = categories && VALID_CATEGORIES.has(categories) ? categories : "general"
936
+ try {
937
+ const params = new URLSearchParams({
938
+ q: query,
939
+ format: "json",
940
+ categories: cat,
941
+ })
942
+ if (language && language.trim()) params.set("language", language.trim())
943
+ if (time_range && VALID_TIME_RANGES.has(time_range)) params.set("time_range", time_range)
944
+ const response = await fetch(`${searxngBase()}/search?${params}`, {
945
+ ```
946
+
947
+ - [ ] **Step 4: 运行测试确认通过**
948
+
949
+ Run: `bun test tests/web-search.test.ts`
950
+ Expected: PASS(2 个原有用例 + 2 个新用例)
951
+
952
+ - [ ] **Step 5: 提交**
953
+
954
+ ```bash
955
+ git add src/tools/web_search.ts tests/web-search.test.ts
956
+ git commit -m "feat: web_search 支持语言与时间范围参数"
957
+ ```
958
+
959
+ ---
960
+
961
+ ## Task 5: chat 模式工具扩展
962
+
963
+ **Files:**
964
+ - Modify: `src/tools/index.ts`
965
+ - Modify: `src/agent.ts`(buildTools)
966
+
967
+ - [ ] **Step 1: 修改 tools/index.ts**
968
+
969
+ `createChatTools` 加 todo(文件顶部已 import todoTool):
970
+
971
+ ```ts
972
+ export function createChatTools(): Record<string, Tool> {
973
+ const tools: Record<string, Tool> = {
974
+ bash: bashTool,
975
+ read: readTool,
976
+ write: writeTool,
977
+ edit: editTool,
978
+ glob: globTool,
979
+ grep: grepTool,
980
+ web_search: webSearchTool,
981
+ web_fetch: webFetchTool,
982
+ todo: todoTool,
983
+ question: questionTool,
984
+ }
985
+ return tools
986
+ }
987
+ ```
988
+
989
+ - [ ] **Step 2: 修改 agent.ts buildTools**
990
+
991
+ 现有(约 202-223 行):
992
+
993
+ ```ts
994
+ async function buildTools(
995
+ mode: "chat" | "code",
996
+ modelId: string | undefined,
997
+ abortSignal: AbortSignal | undefined,
998
+ ): Promise<Record<string, Tool>> {
999
+ const builtinTools = mode === "code" ? createCodeTools() : createChatTools()
1000
+ if (mode === "code") builtinTools["todo"] = createTodoTool()
1001
+ const mcpTools = getMcpTools()
1002
+ const memoryTools = getMemoryTools()
1003
+ const pluginTools = await loadPluginTools()
1004
+ const skills = getSkills()
1005
+ const allTools: Record<string, Tool> = { ...builtinTools, ...memoryTools, ...pluginTools }
1006
+ for (const [id, t] of Object.entries(mcpTools)) allTools[id] = t
1007
+ if (skills.length > 0) allTools["skill"] = getSkillsTool()
1008
+ if (mode === "code") {
1009
+ const { createTaskTool } = await import("./tools/task.js")
1010
+ const { createExploreTool } = await import("./tools/explore.js")
1011
+ allTools["task"] = createTaskTool(modelId, abortSignal)
1012
+ allTools["explore"] = createExploreTool(modelId, abortSignal)
1013
+ }
1014
+ return allTools
1015
+ }
1016
+ ```
1017
+
1018
+ 改为:
1019
+
1020
+ ```ts
1021
+ async function buildTools(
1022
+ mode: "chat" | "code",
1023
+ modelId: string | undefined,
1024
+ abortSignal: AbortSignal | undefined,
1025
+ ): Promise<Record<string, Tool>> {
1026
+ const builtinTools = mode === "code" ? createCodeTools() : createChatTools()
1027
+ builtinTools["todo"] = createTodoTool()
1028
+ const mcpTools = getMcpTools()
1029
+ const memoryTools = getMemoryTools()
1030
+ const pluginTools = await loadPluginTools()
1031
+ const skills = getSkills()
1032
+ const allTools: Record<string, Tool> = { ...builtinTools, ...memoryTools, ...pluginTools }
1033
+ for (const [id, t] of Object.entries(mcpTools)) allTools[id] = t
1034
+ if (skills.length > 0) allTools["skill"] = getSkillsTool()
1035
+ const { createExploreTool } = await import("./tools/explore.js")
1036
+ allTools["explore"] = createExploreTool(modelId, abortSignal)
1037
+ if (mode === "code") {
1038
+ const { createTaskTool } = await import("./tools/task.js")
1039
+ allTools["task"] = createTaskTool(modelId, abortSignal)
1040
+ }
1041
+ return allTools
1042
+ }
1043
+ ```
1044
+
1045
+ (todo 两种模式都覆盖为 per-run 实例;explore 两种模式都有;task 仅 code)
1046
+
1047
+ - [ ] **Step 3: 验证**
1048
+
1049
+ Run: `bun run typecheck`(仓库根)和 `bun test`
1050
+ Expected: 无错误,全部通过
1051
+
1052
+ - [ ] **Step 4: 提交**
1053
+
1054
+ ```bash
1055
+ git add src/tools/index.ts src/agent.ts
1056
+ git commit -m "feat: chat 模式提供 todo 与 explore 工具"
1057
+ ```
1058
+
1059
+ ---
1060
+
1061
+ ## Task 6: CLI 增强(history 子命令、mcp --env、update)
1062
+
1063
+ **Files:**
1064
+ - Create: `src/updater.ts`
1065
+ - Modify: `src/cli.ts`
1066
+
1067
+ - [ ] **Step 1: 实现 updater.ts**
1068
+
1069
+ ```ts
1070
+ import { readFileSync } from "fs"
1071
+ import path from "path"
1072
+ import { fileURLToPath } from "url"
1073
+ import { execSync } from "child_process"
1074
+
1075
+ export function localVersion(): string {
1076
+ const pkgPath = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../package.json")
1077
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"))
1078
+ return pkg.version ?? "0.0.0"
1079
+ }
1080
+
1081
+ export function compareVersions(a: string, b: string): number {
1082
+ const pa = a.split(".").map(Number)
1083
+ const pb = b.split(".").map(Number)
1084
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
1085
+ const va = pa[i] ?? 0
1086
+ const vb = pb[i] ?? 0
1087
+ if (va !== vb) return va - vb
1088
+ }
1089
+ return 0
1090
+ }
1091
+
1092
+ export async function fetchLatestVersion(): Promise<string | null> {
1093
+ try {
1094
+ const response = await fetch("https://registry.npmjs.org/min-agent/latest", {
1095
+ signal: AbortSignal.timeout(10000),
1096
+ headers: { Accept: "application/json" },
1097
+ })
1098
+ if (!response.ok) return null
1099
+ const data = (await response.json()) as { version?: string }
1100
+ return data.version ?? null
1101
+ } catch {
1102
+ return null
1103
+ }
1104
+ }
1105
+
1106
+ export async function runUpdate(): Promise<void> {
1107
+ const current = localVersion()
1108
+ console.log(`当前版本: ${current}`)
1109
+ const latest = await fetchLatestVersion()
1110
+ if (!latest) {
1111
+ console.error("无法检查新版本(网络不可用)。")
1112
+ return
1113
+ }
1114
+ if (compareVersions(current, latest) >= 0) {
1115
+ console.log(`已是最新版本 (${latest})`)
1116
+ return
1117
+ }
1118
+ console.log(`发现新版本: ${latest}`)
1119
+ console.log("正在升级 min-agent...")
1120
+ try {
1121
+ execSync("npm install -g min-agent@latest", { stdio: "inherit" })
1122
+ console.log(`✓ 已升级到 ${latest},请重启终端后生效`)
1123
+ } catch {
1124
+ console.error("自动升级失败。请手动执行: npm install -g min-agent@latest")
1125
+ }
1126
+ }
1127
+ ```
1128
+
1129
+ - [ ] **Step 2: 修改 cli.ts**
1130
+
1131
+ **a) printUsage 更新**(Commands 区):
1132
+
1133
+ ```
1134
+ min-agent history List saved sessions
1135
+ min-agent history delete <id> Delete a saved session
1136
+ min-agent history rename <id> <t> Rename a saved session
1137
+ min-agent history export <id> [-o <file>] Export a session as JSON
1138
+ min-agent update Check for updates and upgrade
1139
+ ```
1140
+
1141
+ 并把 `min-agent mcp add <name> <cmd>` 行后补充:
1142
+
1143
+ ```
1144
+ (--env KEY=VALUE 可重复)
1145
+ ```
1146
+
1147
+ **b) parseMcpAddArgs 加 --env**:
1148
+
1149
+ ```ts
1150
+ function parseMcpAddArgs(argv: string[]): {
1151
+ skipCheck: boolean
1152
+ url?: string
1153
+ token?: string
1154
+ sse: boolean
1155
+ environment: Record<string, string>
1156
+ cmd: string[]
1157
+ } {
1158
+ let skipCheck = false
1159
+ let url: string | undefined
1160
+ let token: string | undefined
1161
+ let sse = false
1162
+ const environment: Record<string, string> = {}
1163
+ const cmd: string[] = []
1164
+ for (let i = 0; i < argv.length; i++) {
1165
+ const a = argv[i]
1166
+ if (a === "--skip-check") {
1167
+ skipCheck = true
1168
+ continue
1169
+ }
1170
+ if (a === "--sse") {
1171
+ sse = true
1172
+ continue
1173
+ }
1174
+ if (a === "--url" && argv[i + 1]) {
1175
+ url = argv[++i]
1176
+ continue
1177
+ }
1178
+ if (a === "--token" && argv[i + 1]) {
1179
+ token = argv[++i]
1180
+ continue
1181
+ }
1182
+ if (a === "--env" && argv[i + 1]) {
1183
+ const kv = argv[++i]
1184
+ const eq = kv.indexOf("=")
1185
+ if (eq <= 0) {
1186
+ console.error(`Invalid --env format: ${kv} (use KEY=VALUE)`)
1187
+ process.exit(1)
1188
+ }
1189
+ environment[kv.slice(0, eq)] = kv.slice(eq + 1)
1190
+ continue
1191
+ }
1192
+ cmd.push(a)
1193
+ }
1194
+ return { skipCheck, url, token, sse, environment, cmd }
1195
+ }
1196
+ ```
1197
+
1198
+ **c) mcp add case 应用 environment**:
1199
+
1200
+ ```ts
1201
+ const { skipCheck, url, token, sse, environment, cmd } = parseMcpAddArgs(args.slice(3))
1202
+
1203
+ let entry: McpServerConfig
1204
+ if (url?.trim()) {
1205
+ entry = {
1206
+ url: url.trim(),
1207
+ enabled: true,
1208
+ remoteTransport: sse ? "sse" : "auto",
1209
+ }
1210
+ if (token?.trim()) entry.token = token.trim()
1211
+ } else if (cmd.length > 0) {
1212
+ entry = { command: cmd, enabled: true }
1213
+ } else {
1214
+ console.error("Usage: min-agent mcp add <name> [--skip-check] <command...>")
1215
+ console.error(" or: min-agent mcp add <name> --url <https://host/mcp> [--sse] [--token <bearer>] [--skip-check]")
1216
+ process.exit(1)
1217
+ }
1218
+ if (Object.keys(environment).length > 0) entry.environment = environment
1219
+ ```
1220
+
1221
+ **d) history 分支扩展**(在现有 list 逻辑前加子命令分发):
1222
+
1223
+ ```ts
1224
+ case "history": {
1225
+ const subcommand = args[1]
1226
+ if (subcommand === "delete") {
1227
+ const id = args[2]
1228
+ if (!id) { console.error("Usage: min-agent history delete <id>"); process.exit(1) }
1229
+ const { deleteSession } = await import("./sessions.js")
1230
+ if (deleteSession(id)) {
1231
+ console.log(`✓ 会话 ${id} 已删除`)
1232
+ } else {
1233
+ console.error(`会话 ${id} 不存在`)
1234
+ process.exit(1)
1235
+ }
1236
+ break
1237
+ }
1238
+ if (subcommand === "rename") {
1239
+ const id = args[2]
1240
+ const title = args.slice(3).join(" ")
1241
+ if (!id || !title) { console.error("Usage: min-agent history rename <id> <title>"); process.exit(1) }
1242
+ const { renameSession } = await import("./sessions.js")
1243
+ if (renameSession(id, title)) {
1244
+ console.log(`✓ 会话 ${id} 已重命名为: ${title}`)
1245
+ } else {
1246
+ console.error(`会话 ${id} 不存在`)
1247
+ process.exit(1)
1248
+ }
1249
+ break
1250
+ }
1251
+ if (subcommand === "export") {
1252
+ const id = args[2]
1253
+ if (!id) { console.error("Usage: min-agent history export <id> [-o <file>]"); process.exit(1) }
1254
+ const { loadSession } = await import("./sessions.js")
1255
+ const session = loadSession(id)
1256
+ if (!session) {
1257
+ console.error(`会话 ${id} 不存在`)
1258
+ process.exit(1)
1259
+ }
1260
+ const json = JSON.stringify(session, null, 2)
1261
+ const outIdx = args.indexOf("-o")
1262
+ const outFile = outIdx !== -1 ? args[outIdx + 1] : undefined
1263
+ if (outFile) {
1264
+ writeFileSync(outFile, json, "utf-8")
1265
+ console.log(`✓ 会话 ${id} 已导出到 ${outFile}`)
1266
+ } else {
1267
+ console.log(json)
1268
+ }
1269
+ break
1270
+ }
1271
+ const { listSessions } = await import("./sessions.js")
1272
+ const sessions = listSessions()
1273
+ if (sessions.length === 0) {
1274
+ console.log("No saved sessions.")
1275
+ console.log("Sessions are auto-saved when you exit interactive chat.")
1276
+ } else {
1277
+ console.log(`Sessions (${sessions.length}):`)
1278
+ for (const s of sessions.slice(0, 20)) {
1279
+ const date = s.updated.split("T")[0]
1280
+ console.log(` ${s.id} ${date} ${s.title} (${s.messageCount} msgs)`)
1281
+ }
1282
+ console.log("\nResume with: min-agent chat --resume <id>")
1283
+ }
1284
+ break
1285
+ }
1286
+ ```
1287
+
1288
+ (`writeFileSync` 已在 cli.ts import)
1289
+
1290
+ **e) update case**(在 `case "init"` 之前添加):
1291
+
1292
+ ```ts
1293
+ case "update": {
1294
+ const { runUpdate } = await import("./updater.js")
1295
+ await runUpdate()
1296
+ break
1297
+ }
1298
+ ```
1299
+
1300
+ - [ ] **Step 3: 验证**
1301
+
1302
+ Run: `bun run typecheck`(仓库根)和 `bun test`
1303
+ Expected: 无错误,全部通过
1304
+
1305
+ - [ ] **Step 4: 冒烟**
1306
+
1307
+ Run: `bun run src/cli.ts history delete`(应提示 Usage)与 `bun run src/cli.ts --help`
1308
+ Expected: 正常输出
1309
+
1310
+ - [ ] **Step 5: 提交**
1311
+
1312
+ ```bash
1313
+ git add src/updater.ts src/cli.ts
1314
+ git commit -m "feat: CLI history 子命令、mcp --env 与 update 升级"
1315
+ ```
1316
+
1317
+ ---
1318
+
1319
+ ## Task 7: agent.ts 采样参数 options
1320
+
1321
+ **Files:**
1322
+ - Modify: `src/agent.ts`
1323
+
1324
+ - [ ] **Step 1: 实现**
1325
+
1326
+ 在 `RunOnceCallbacks` 接口之后添加:
1327
+
1328
+ ```ts
1329
+ export interface RunOptions {
1330
+ temperature?: number
1331
+ maxTokens?: number
1332
+ topP?: number
1333
+ }
1334
+ ```
1335
+
1336
+ `runOnceCore` 签名加 `options` 参数(mode 之后):
1337
+
1338
+ ```ts
1339
+ async function runOnceCore(
1340
+ messages: ModelMessage[],
1341
+ systemPrompt: string,
1342
+ modelId: string | undefined,
1343
+ abortSignal: AbortSignal | undefined,
1344
+ callbacks: RunOnceCallbacks | undefined,
1345
+ tracker: TokenTracker | undefined,
1346
+ mode: "chat" | "code",
1347
+ options?: RunOptions,
1348
+ ): Promise<void> {
1349
+ ```
1350
+
1351
+ `streamText` 调用加条件展开(`maxRetries` 之后、`abortSignal` 之后):
1352
+
1353
+ ```ts
1354
+ const result = streamText({
1355
+ model,
1356
+ system: systemPrompt,
1357
+ messages,
1358
+ tools: allTools,
1359
+ stopWhen: stepCountIs(MAX_STEPS),
1360
+ maxRetries: 3,
1361
+ abortSignal,
1362
+ ...(options?.temperature != null ? { temperature: options.temperature } : {}),
1363
+ ...(options?.maxTokens != null ? { maxTokens: options.maxTokens } : {}),
1364
+ ...(options?.topP != null ? { topP: options.topP } : {}),
1365
+ onStepFinish({ stepNumber }) {
1366
+ stepCount = stepNumber
1367
+ if (stepNumber >= MAX_STEPS) maxStepsReached = true
1368
+ },
1369
+ onError() {},
1370
+ })
1371
+ ```
1372
+
1373
+ `runOnce` 与 `runOnceWithSystem` 加 options 参数并透传:
1374
+
1375
+ ```ts
1376
+ export async function runOnce(
1377
+ messages: ModelMessage[],
1378
+ instructions: string[],
1379
+ modelId?: string,
1380
+ abortSignal?: AbortSignal,
1381
+ callbacks?: RunOnceCallbacks,
1382
+ tracker?: TokenTracker,
1383
+ options?: RunOptions,
1384
+ ) {
1385
+ await runOnceCore(messages, buildSystemPrompt(instructions), modelId, abortSignal, callbacks, tracker, "chat", options)
1386
+ }
1387
+
1388
+ /** runOnce variant that accepts a pre-built system prompt (for code mode) */
1389
+ export async function runOnceWithSystem(
1390
+ messages: ModelMessage[],
1391
+ systemPrompt: string,
1392
+ modelId?: string,
1393
+ abortSignal?: AbortSignal,
1394
+ callbacks?: RunOnceCallbacks,
1395
+ tracker?: TokenTracker,
1396
+ options?: RunOptions,
1397
+ ) {
1398
+ await runOnceCore(messages, systemPrompt, modelId, abortSignal, callbacks, tracker, "code", options)
1399
+ }
1400
+ ```
1401
+
1402
+ - [ ] **Step 2: 验证**
1403
+
1404
+ Run: `bun run typecheck`(仓库根)和 `bun test`
1405
+ Expected: 无错误,全部通过(现有调用点不传 options,行为不变)
1406
+
1407
+ - [ ] **Step 3: 提交**
1408
+
1409
+ ```bash
1410
+ git add src/agent.ts
1411
+ git commit -m "feat: agent 支持采样参数透传"
1412
+ ```
1413
+
1414
+ ---
1415
+
1416
+ ## Task 8: serve 端点 — sessions/:id、rename、chat/undo
1417
+
1418
+ **Files:**
1419
+ - Modify: `src/serve.ts`
1420
+ - Test: `tests/serve.test.ts`
1421
+
1422
+ - [ ] **Step 1: 写失败测试**
1423
+
1424
+ 在 `tests/serve.test.ts` 追加(import 加 `saveSession`,从 `../src/sessions.js`):
1425
+
1426
+ ```ts
1427
+ import { saveSession } from "../src/sessions.js"
1428
+
1429
+ test("F6: GET /v1/sessions/:id 返回会话详情", async () => {
1430
+ const id = saveSession([{ role: "user", content: "hello" }])
1431
+ const res = await fetch(`http://127.0.0.1:${port}/v1/sessions/${id}`)
1432
+ expect(res.status).toBe(200)
1433
+ const body = await res.json()
1434
+ expect(body.session.meta.id).toBe(id)
1435
+ expect(body.session.messages).toHaveLength(1)
1436
+ })
1437
+
1438
+ test("F6: GET /v1/sessions/:id 不存在的会话返回 404", async () => {
1439
+ const res = await fetch(`http://127.0.0.1:${port}/v1/sessions/no-such-id`)
1440
+ expect(res.status).toBe(404)
1441
+ })
1442
+
1443
+ test("F6: POST /v1/sessions/:id/rename 重命名会话", async () => {
1444
+ const id = saveSession([{ role: "user", content: "hi" }])
1445
+ const { status, text } = await postJson(`/v1/sessions/${id}/rename`, { title: "renamed" })
1446
+ expect(status).toBe(200)
1447
+ expect(JSON.parse(text).title).toBe("renamed")
1448
+ const session = JSON.parse(await (await fetch(`http://127.0.0.1:${port}/v1/sessions/${id}`)).text())
1449
+ expect(session.session.meta.title).toBe("renamed")
1450
+ })
1451
+
1452
+ test("F6: POST /v1/sessions/:id/rename 缺 title 返回 400", async () => {
1453
+ const id = saveSession([{ role: "user", content: "hi" }])
1454
+ const { status } = await postJson(`/v1/sessions/${id}/rename`, {})
1455
+ expect(status).toBe(400)
1456
+ })
1457
+
1458
+ test("F6: POST /v1/chat/undo 截断到最后一条 user 消息", async () => {
1459
+ const id = saveSession([
1460
+ { role: "user", content: "q1" },
1461
+ { role: "assistant", content: "a1" },
1462
+ { role: "user", content: "q2" },
1463
+ { role: "assistant", content: "a2" },
1464
+ ])
1465
+ const { status, text } = await postJson("/v1/chat/undo", { session_id: id })
1466
+ expect(status).toBe(200)
1467
+ const body = JSON.parse(text)
1468
+ expect(body.message_count).toBe(3)
1469
+ expect(body.messages[2]!.role).toBe("user")
1470
+ expect(body.messages[2]!.content).toBe("q2")
1471
+ })
1472
+
1473
+ test("F6: POST /v1/chat/undo 缺 session_id 返回 400", async () => {
1474
+ const { status } = await postJson("/v1/chat/undo", {})
1475
+ expect(status).toBe(400)
1476
+ })
1477
+ ```
1478
+
1479
+ - [ ] **Step 2: 运行测试确认失败**
1480
+
1481
+ Run: `bun test tests/serve.test.ts`
1482
+ Expected: 新用例 FAIL(404 或 400)
1483
+
1484
+ - [ ] **Step 3: 实现 serve.ts**
1485
+
1486
+ **a)** import 加 `renameSession`:
1487
+
1488
+ ```ts
1489
+ import { loadSession, saveSession, listSessions, deleteSession, renameSession } from "./sessions.js"
1490
+ ```
1491
+
1492
+ **b)** 在 `DELETE /v1/sessions/` 路由之后添加:
1493
+
1494
+ ```ts
1495
+ if (req.method === "GET" && pathname.startsWith("/v1/sessions/")) {
1496
+ const id = pathname.slice("/v1/sessions/".length)
1497
+ if (!id) { sendJson(res, 400, { error: "missing_session_id" }); return }
1498
+ const session = loadSession(id)
1499
+ if (!session) { sendJson(res, 404, { error: "session_not_found", session_id: id }); return }
1500
+ sendJson(res, 200, { session })
1501
+ return
1502
+ }
1503
+
1504
+ if (req.method === "POST" && pathname.startsWith("/v1/sessions/") && pathname.endsWith("/rename")) {
1505
+ const id = pathname.slice("/v1/sessions/".length, -"/rename".length)
1506
+ const parsed = await readJsonBody<{ title?: string }>(req)
1507
+ if (!parsed.ok) { sendJsonError(res, parsed); return }
1508
+ const title = parsed.value.title?.trim()
1509
+ if (!title) { sendJson(res, 400, { error: "missing_title" }); return }
1510
+ if (!renameSession(id, title)) { sendJson(res, 404, { error: "session_not_found", session_id: id }); return }
1511
+ sendJson(res, 200, { ok: true, session_id: id, title })
1512
+ return
1513
+ }
1514
+ ```
1515
+
1516
+ **c)** 在 `POST /v1/chat/compact` 路由附近添加 chat/undo:
1517
+
1518
+ ```ts
1519
+ if (req.method === "POST" && pathname === "/v1/chat/undo") {
1520
+ const parsed = await readJsonBody<{ session_id?: string }>(req)
1521
+ if (!parsed.ok) { sendJsonError(res, parsed); return }
1522
+ const body = parsed.value
1523
+ if (!body.session_id) { sendJson(res, 400, { error: "missing_session_id" }); return }
1524
+ const session = loadSession(body.session_id)
1525
+ if (!session) { sendJson(res, 404, { error: "session_not_found" }); return }
1526
+ let userIdx = -1
1527
+ for (let i = session.messages.length - 1; i >= 0; i--) {
1528
+ if (session.messages[i]!.role === "user") {
1529
+ userIdx = i
1530
+ break
1531
+ }
1532
+ }
1533
+ if (userIdx === -1) { sendJson(res, 400, { error: "no_user_message" }); return }
1534
+ session.messages = session.messages.slice(0, userIdx + 1)
1535
+ saveSession(session.messages, body.session_id)
1536
+ sendJson(res, 200, { ok: true, message_count: session.messages.length, messages: session.messages })
1537
+ return
1538
+ }
1539
+ ```
1540
+
1541
+ 注意:chat/undo 路由必须放在 `/v1/chat` 的 POST 精确匹配之前或之后均可(路径不同);放在 `/v1/chat/compact` 之后即可。
1542
+
1543
+ - [ ] **Step 4: 运行测试确认通过**
1544
+
1545
+ Run: `bun test tests/serve.test.ts`
1546
+ Expected: PASS(原有 + 6 个新用例)
1547
+
1548
+ - [ ] **Step 5: 提交**
1549
+
1550
+ ```bash
1551
+ git add src/serve.ts tests/serve.test.ts
1552
+ git commit -m "feat: API sessions 详情/重命名与会话撤销端点"
1553
+ ```
1554
+
1555
+ ---
1556
+
1557
+ ## Task 9: serve 端点 — skills/mcp toggle、paste code
1558
+
1559
+ **Files:**
1560
+ - Modify: `src/serve.ts`
1561
+ - Test: `tests/serve.test.ts`
1562
+
1563
+ - [ ] **Step 1: 写失败测试**
1564
+
1565
+ 在 `tests/serve.test.ts` 末尾追加:
1566
+
1567
+ ```ts
1568
+ test("F7: POST /v1/skills/:name/disable 与 enable", async () => {
1569
+ const { status } = await postJson("/v1/skills/my-skill/disable", {})
1570
+ expect(status).toBe(200)
1571
+ const config = JSON.parse(require("fs").readFileSync(path.join(configDir, "config.json"), "utf-8"))
1572
+ expect(config.disabledSkills).toContain("my-skill")
1573
+ await postJson("/v1/skills/my-skill/enable", {})
1574
+ const config2 = JSON.parse(require("fs").readFileSync(path.join(configDir, "config.json"), "utf-8"))
1575
+ expect(config2.disabledSkills ?? []).not.toContain("my-skill")
1576
+ })
1577
+
1578
+ test("F7: POST /v1/mcp/:name/disable 与 enable", async () => {
1579
+ const { loadMcpConfig, saveMcpConfig } = await import("../src/mcp.js")
1580
+ const cfg = loadMcpConfig()
1581
+ cfg.mcpServers["test-server"] = { command: ["echo", "hi"], enabled: true }
1582
+ saveMcpConfig(cfg)
1583
+ const { status } = await postJson("/v1/mcp/test-server/disable", {})
1584
+ expect(status).toBe(200)
1585
+ expect(loadMcpConfig().mcpServers["test-server"]!.enabled).toBe(false)
1586
+ await postJson("/v1/mcp/test-server/enable", {})
1587
+ expect(loadMcpConfig().mcpServers["test-server"]!.enabled).toBe(true)
1588
+ })
1589
+
1590
+ test("F7: POST /v1/mcp/:name/disable 不存在的服务器返回 404", async () => {
1591
+ const { status } = await postJson("/v1/mcp/no-such-server/disable", {})
1592
+ expect(status).toBe(404)
1593
+ })
1594
+
1595
+ test("F7: POST /v1/paste 支持 code 模式", async () => {
1596
+ const { status, text } = await postJson("/v1/paste", {
1597
+ image_base64: PNG_B64,
1598
+ message: "分析",
1599
+ code: true,
1600
+ stream: false,
1601
+ })
1602
+ expect(status).toBe(200)
1603
+ const body = JSON.parse(text)
1604
+ expect(body.mode).toBe("code")
1605
+ expect(body.project).toBeTruthy()
1606
+ })
1607
+ ```
1608
+
1609
+ 注意:测试中避免 `require`,改用顶部已 import 的 `readFileSync`(`tests/serve.test.ts` 顶部已 import `readFileSync` from "fs")。skills 测试改用:
1610
+
1611
+ ```ts
1612
+ test("F7: POST /v1/skills/:name/disable 与 enable", async () => {
1613
+ const { status } = await postJson("/v1/skills/my-skill/disable", {})
1614
+ expect(status).toBe(200)
1615
+ const config = JSON.parse(readFileSync(path.join(configDir, "config.json"), "utf-8"))
1616
+ expect(config.disabledSkills).toContain("my-skill")
1617
+ await postJson("/v1/skills/my-skill/enable", {})
1618
+ const config2 = JSON.parse(readFileSync(path.join(configDir, "config.json"), "utf-8"))
1619
+ expect(config2.disabledSkills ?? []).not.toContain("my-skill")
1620
+ })
1621
+ ```
1622
+
1623
+ (注意:config.json 的读取与 saveConfig 的缓存——serve 进程的 loadConfig 有 mtime 缓存,测试进程直接读文件断言即可)
1624
+
1625
+ - [ ] **Step 2: 运行测试确认失败**
1626
+
1627
+ Run: `bun test tests/serve.test.ts`
1628
+ Expected: 新用例 FAIL(404)
1629
+
1630
+ - [ ] **Step 3: 实现 serve.ts**
1631
+
1632
+ **a)** 在 skills 路由(`GET /v1/skills`)之后添加:
1633
+
1634
+ ```ts
1635
+ const skillToggle = pathname.match(/^\/v1\/skills\/([^/]+)\/(enable|disable)$/)
1636
+ if (req.method === "POST" && skillToggle) {
1637
+ const name = decodeURIComponent(skillToggle[1]!)
1638
+ const enabled = skillToggle[2] === "enable"
1639
+ const config = loadConfig()
1640
+ const disabled = new Set(config.disabledSkills ?? [])
1641
+ if (enabled) disabled.delete(name)
1642
+ else disabled.add(name)
1643
+ config.disabledSkills = [...disabled]
1644
+ saveConfig(config)
1645
+ sendJson(res, 200, { ok: true, name, enabled })
1646
+ return
1647
+ }
1648
+ ```
1649
+
1650
+ (需要 `saveConfig` import:`import { loadConfig, saveConfig, fetchModels, isConfigured } from "./config.js"`)
1651
+
1652
+ **b)** 在 MCP DELETE 路由之后添加:
1653
+
1654
+ ```ts
1655
+ const mcpToggle = pathname.match(/^\/v1\/mcp\/([^/]+)\/(enable|disable)$/)
1656
+ if (req.method === "POST" && mcpToggle) {
1657
+ const name = decodeURIComponent(mcpToggle[1]!)
1658
+ const enabled = mcpToggle[2] === "enable"
1659
+ const { loadMcpConfig, saveMcpConfig } = await import("./mcp.js")
1660
+ const config = loadMcpConfig()
1661
+ if (!config.mcpServers[name]) { sendJson(res, 404, { error: "mcp_not_found", name }); return }
1662
+ config.mcpServers[name]!.enabled = enabled
1663
+ saveMcpConfig(config)
1664
+ sendJson(res, 200, { ok: true, name, enabled })
1665
+ return
1666
+ }
1667
+ ```
1668
+
1669
+ **c)** /v1/paste 支持 code 模式:
1670
+
1671
+ paste 的 body 类型与提取处(约 575 行)改为:
1672
+
1673
+ ```ts
1674
+ const parsed = await readJsonBody<{ image_base64?: string; mime_type?: string; message?: string; session_id?: string; model?: string; stream?: boolean; code?: boolean; temperature?: number; maxTokens?: number; topP?: number }>(req)
1675
+ ```
1676
+
1677
+ 提取处添加:
1678
+
1679
+ ```ts
1680
+ const codeMode = body.code === true
1681
+ ```
1682
+
1683
+ 流式分支:
1684
+
1685
+ ```ts
1686
+ if (stream) {
1687
+ res.writeHead(200, sseHeaders(c))
1688
+ res.flushHeaders?.()
1689
+ const callbacks = createSseCallbacks(res, messages, { sessionId })
1690
+ try {
1691
+ if (codeMode) {
1692
+ const project = scanProject()
1693
+ const system = buildCodeSystemPrompt(project, instructions)
1694
+ await runOnceWithSystem(messages, system, modelId, abort.signal, callbacks)
1695
+ } else {
1696
+ await runOnce(messages, instructions, modelId, abort.signal, callbacks)
1697
+ }
1698
+ } catch (err: any) {
1699
+ if (!res.writableEnded) {
1700
+ sseWrite(res, { type: "fatal", message: err?.message ?? String(err) })
1701
+ res.end()
1702
+ }
1703
+ }
1704
+ } else {
1705
+ const { callbacks, toolCalls, toolResults, finishInfo } = createJsonCallbacks()
1706
+ if (codeMode) {
1707
+ const project = scanProject()
1708
+ const system = buildCodeSystemPrompt(project, instructions)
1709
+ await runOnceWithSystem(messages, system, modelId, abort.signal, callbacks)
1710
+ sendJson(res, 200, {
1711
+ messages,
1712
+ assistant: [...messages].reverse().find((m) => m.role === "assistant") ?? null,
1713
+ tool_calls: toolCalls,
1714
+ tool_results: toolResults,
1715
+ session_id: sessionId ? saveSessionSafe(messages, sessionId) : undefined,
1716
+ step_count: finishInfo()?.stepCount ?? 0,
1717
+ usage: finishInfo()?.usage ?? null,
1718
+ has_error: finishInfo()?.hasError ?? false,
1719
+ aborted: finishInfo()?.aborted ?? false,
1720
+ max_steps_reached: finishInfo()?.maxStepsReached ?? false,
1721
+ mode: "code",
1722
+ project,
1723
+ })
1724
+ } else {
1725
+ await runOnce(messages, instructions, modelId, abort.signal, callbacks)
1726
+ const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant")
1727
+ let saved: string | undefined
1728
+ if (sessionId) { try { saved = saveSession(messages, sessionId) } catch {} }
1729
+ const fi = finishInfo()
1730
+ sendJson(res, 200, {
1731
+ messages,
1732
+ assistant: lastAssistant ?? null,
1733
+ tool_calls: toolCalls,
1734
+ tool_results: toolResults,
1735
+ session_id: saved,
1736
+ step_count: fi?.stepCount ?? 0,
1737
+ usage: fi?.usage ?? null,
1738
+ has_error: fi?.hasError ?? false,
1739
+ aborted: fi?.aborted ?? false,
1740
+ max_steps_reached: fi?.maxStepsReached ?? false,
1741
+ })
1742
+ }
1743
+ }
1744
+ ```
1745
+
1746
+ 注意:上述代码中 `saveSessionSafe` 是占位——**实际实现**保持与现有一致的保存逻辑(`if (sessionId) { try { saved = saveSession(messages, sessionId) } catch {} }`),并让 `assistant` 变量在分支前提取。为避免分支重复,重写整个 paste 非流式部分为:
1747
+
1748
+ ```ts
1749
+ } else {
1750
+ const { callbacks, toolCalls, toolResults, finishInfo } = createJsonCallbacks()
1751
+ let project: ProjectInfo | undefined
1752
+ if (codeMode) {
1753
+ project = scanProject()
1754
+ const system = buildCodeSystemPrompt(project, instructions)
1755
+ await runOnceWithSystem(messages, system, modelId, abort.signal, callbacks)
1756
+ } else {
1757
+ await runOnce(messages, instructions, modelId, abort.signal, callbacks)
1758
+ }
1759
+ const lastAssistant = [...messages].reverse().find((m) => m.role === "assistant")
1760
+ let saved: string | undefined
1761
+ if (sessionId) { try { saved = saveSession(messages, sessionId) } catch {} }
1762
+ const fi = finishInfo()
1763
+ sendJson(res, 200, {
1764
+ messages,
1765
+ assistant: lastAssistant ?? null,
1766
+ tool_calls: toolCalls,
1767
+ tool_results: toolResults,
1768
+ session_id: saved,
1769
+ step_count: fi?.stepCount ?? 0,
1770
+ usage: fi?.usage ?? null,
1771
+ has_error: fi?.hasError ?? false,
1772
+ aborted: fi?.aborted ?? false,
1773
+ max_steps_reached: fi?.maxStepsReached ?? false,
1774
+ ...(codeMode ? { mode: "code", project } : {}),
1775
+ })
1776
+ }
1777
+ ```
1778
+
1779
+ 流式分支同样用 project 变量(code 时)并保持 createSseCallbacks 的 opts 不带 project(paste 不做 session save 变更)。**以这个最终版本为准**。
1780
+
1781
+ (`ProjectInfo` 类型已在 serve.ts import 中:`import { scanProject, buildCodeSystemPrompt, type ProjectInfo } from "./code-mode.js"`)
1782
+
1783
+ - [ ] **Step 4: 运行测试确认通过**
1784
+
1785
+ Run: `bun test tests/serve.test.ts`
1786
+ Expected: PASS(原有 + 4 个新用例)
1787
+
1788
+ - [ ] **Step 5: 提交**
1789
+
1790
+ ```bash
1791
+ git add src/serve.ts tests/serve.test.ts
1792
+ git commit -m "feat: API skills/mcp 启停端点与 paste code 模式"
1793
+ ```
1794
+
1795
+ ---
1796
+
1797
+ ## Task 10: serve 端点 — GET /v1/cost + 采样参数透传
1798
+
1799
+ **Files:**
1800
+ - Modify: `src/serve.ts`
1801
+ - Modify: `src/agent.ts`(Task 7 已完成,此任务只需 serve 透传)
1802
+ - Test: `tests/serve.test.ts`
1803
+
1804
+ - [ ] **Step 1: 写失败测试**
1805
+
1806
+ 在 `tests/serve.test.ts` 末尾追加:
1807
+
1808
+ ```ts
1809
+ test("F8: GET /v1/cost 返回价格与成本", async () => {
1810
+ const res = await fetch(`http://127.0.0.1:${port}/v1/cost?model=${BAD_MODEL}&input=1000000&output=500000`)
1811
+ expect(res.status).toBe(200)
1812
+ const body = await res.json()
1813
+ expect(body.model).toBe(BAD_MODEL)
1814
+ expect(body.cost_usd).toBe(20)
1815
+ })
1816
+
1817
+ test("F8: /v1/chat 接受采样参数不报错", async () => {
1818
+ const { status } = await postJson("/v1/chat", {
1819
+ message: "say hi",
1820
+ stream: false,
1821
+ temperature: 0.5,
1822
+ maxTokens: 100,
1823
+ topP: 0.9,
1824
+ })
1825
+ expect(status).toBe(200)
1826
+ })
1827
+ ```
1828
+
1829
+ 注意:cost 测试需要 config.json 提供 pricing 覆盖(避免网络)。在 `beforeAll` 的 config.json 写入中加入:
1830
+
1831
+ ```ts
1832
+ writeFileSync(
1833
+ path.join(configDir, "config.json"),
1834
+ JSON.stringify({
1835
+ provider: { baseURL: "http://127.0.0.1:1/v1", apiKey: "test", defaultModel: BAD_MODEL },
1836
+ pricing: { [BAD_MODEL]: { inputPerMillion: 10, outputPerMillion: 20 } },
1837
+ }),
1838
+ )
1839
+ ```
1840
+
1841
+ - [ ] **Step 2: 运行测试确认失败**
1842
+
1843
+ Run: `bun test tests/serve.test.ts`
1844
+ Expected: 新用例 FAIL(404 或 cost_usd 为 null)
1845
+
1846
+ - [ ] **Step 3: 实现 serve.ts**
1847
+
1848
+ **a)** import 加 pricing:
1849
+
1850
+ ```ts
1851
+ import { getModelPrice, estimateCost } from "./pricing.js"
1852
+ ```
1853
+
1854
+ **b)** 在 `/v1/context` 路由之后添加 cost:
1855
+
1856
+ ```ts
1857
+ if (req.method === "GET" && pathname === "/v1/cost") {
1858
+ const config = loadConfig()
1859
+ const modelId = url.searchParams.get("model") ?? config.provider?.defaultModel ?? null
1860
+ const input = parseInt(url.searchParams.get("input") ?? "0", 10)
1861
+ const output = parseInt(url.searchParams.get("output") ?? "0", 10)
1862
+ const price = modelId ? await getModelPrice(modelId) : null
1863
+ const cost = price ? estimateCost({ inputTokens: input, outputTokens: output }, price) : null
1864
+ sendJson(res, 200, { model: modelId, price, cost_usd: cost })
1865
+ return
1866
+ }
1867
+ ```
1868
+
1869
+ **c)** ChatBody 加采样参数(约 233 行):
1870
+
1871
+ ```ts
1872
+ interface ChatBody {
1873
+ message?: string
1874
+ messages?: ModelMessage[]
1875
+ model?: string
1876
+ stream?: boolean
1877
+ session_id?: string
1878
+ images?: string[]
1879
+ temperature?: number
1880
+ maxTokens?: number
1881
+ topP?: number
1882
+ }
1883
+ ```
1884
+
1885
+ **d)** handleChatRequest 中提取 runOptions(在 `abort` 定义之后):
1886
+
1887
+ ```ts
1888
+ const runOptions: { temperature?: number; maxTokens?: number; topP?: number } = {
1889
+ temperature: typeof body.temperature === "number" ? body.temperature : undefined,
1890
+ maxTokens: typeof body.maxTokens === "number" ? body.maxTokens : undefined,
1891
+ topP: typeof body.topP === "number" ? body.topP : undefined,
1892
+ }
1893
+ ```
1894
+
1895
+ `run` 函数改为:
1896
+
1897
+ ```ts
1898
+ const run = (callbacks: RunOnceCallbacks | undefined) =>
1899
+ codeMode
1900
+ ? runOnceWithSystem(messages, codeSystemPrompt!, modelId, abort.signal, callbacks, undefined, runOptions)
1901
+ : runOnce(messages, instructions, modelId, abort.signal, callbacks, undefined, runOptions)
1902
+ ```
1903
+
1904
+ **e)** paste 也透传采样参数:paste 分支的 runOnce 调用加 `undefined, runOptions`(paste 的 body 类型已在 Task 9 扩展)。在 paste 分支提取处添加:
1905
+
1906
+ ```ts
1907
+ const runOptions: { temperature?: number; maxTokens?: number; topP?: number } = {
1908
+ temperature: typeof body.temperature === "number" ? body.temperature : undefined,
1909
+ maxTokens: typeof body.maxTokens === "number" ? body.maxTokens : undefined,
1910
+ topP: typeof body.topP === "number" ? body.topP : undefined,
1911
+ }
1912
+ ```
1913
+
1914
+ 流式与 JSON 分支的 runOnce/runOnceWithSystem 调用加 `undefined, runOptions`。
1915
+
1916
+ - [ ] **Step 4: 运行测试确认通过**
1917
+
1918
+ Run: `bun test tests/serve.test.ts`
1919
+ Expected: PASS(原有 + 2 个新用例)
1920
+
1921
+ - [ ] **Step 5: 提交**
1922
+
1923
+ ```bash
1924
+ git add src/serve.ts tests/serve.test.ts
1925
+ git commit -m "feat: API 成本端点与采样参数透传"
1926
+ ```
1927
+
1928
+ ---
1929
+
1930
+ ## Task 11: 文档同步(API.md、README、usage)
1931
+
1932
+ **Files:**
1933
+ - Modify: `docs/API.md`
1934
+ - Modify: `README.md`
1935
+
1936
+ - [ ] **Step 1: 更新 API.md**
1937
+
1938
+ **a)** 端点一览表追加:
1939
+
1940
+ ```markdown
1941
+ | GET | `/v1/sessions/:id` | 会话详情 |
1942
+ | POST | `/v1/sessions/:id/rename` | 重命名会话 |
1943
+ | POST | `/v1/chat/undo` | 撤销最后一轮 |
1944
+ | POST | `/v1/skills/:name/enable` | 启用技能 |
1945
+ | POST | `/v1/skills/:name/disable` | 禁用技能 |
1946
+ | POST | `/v1/mcp/:name/enable` | 启用 MCP 服务器 |
1947
+ | POST | `/v1/mcp/:name/disable` | 禁用 MCP 服务器 |
1948
+ | GET | `/v1/cost` | 模型价格与成本估算 |
1949
+ ```
1950
+
1951
+ **b)** `POST /v1/chat` 请求字段表追加:
1952
+
1953
+ ```markdown
1954
+ | `temperature` | number | 采样温度(透传模型 API) |
1955
+ | `maxTokens` | number | 最大输出 token 数 |
1956
+ | `topP` | number | 核采样参数 |
1957
+ ```
1958
+
1959
+ **c)** `POST /v1/paste` 请求字段表追加:
1960
+
1961
+ ```markdown
1962
+ | `code` | boolean | 使用 code 模式(项目感知) |
1963
+ | `temperature` | number | 采样温度 |
1964
+ | `maxTokens` | number | 最大输出 token 数 |
1965
+ | `topP` | number | 核采样参数 |
1966
+ ```
1967
+
1968
+ code 模式的响应说明追加:"`code: true` 时响应额外包含 `mode: "code"` 与 `project` 字段。"
1969
+
1970
+ **d)** 新增端点说明章节(sessions/rename/undo/skills toggle/mcp toggle/cost),参考现有章节格式:
1971
+
1972
+ ```markdown
1973
+ ## `GET /v1/sessions/:id`
1974
+
1975
+ ```json
1976
+ { "session": { "meta": { "id": "abc", "title": "...", ... }, "messages": [...] } }
1977
+ ```
1978
+
1979
+ ## `POST /v1/sessions/:id/rename`
1980
+
1981
+ ```json
1982
+ // 请求
1983
+ { "title": "new title" }
1984
+ // 响应
1985
+ { "ok": true, "session_id": "abc", "title": "new title" }
1986
+ ```
1987
+
1988
+ ## `POST /v1/chat/undo`
1989
+
1990
+ ```json
1991
+ // 请求
1992
+ { "session_id": "abc123" }
1993
+ // 响应
1994
+ { "ok": true, "message_count": 3, "messages": [...] }
1995
+ ```
1996
+
1997
+ 截断到最后一条 user 消息(保留该消息,删除其后的 assistant/tool 消息)。
1998
+
1999
+ ## `POST /v1/skills/:name/enable` / `/disable`
2000
+
2001
+ ```json
2002
+ { "ok": true, "name": "my-skill", "enabled": false }
2003
+ ```
2004
+
2005
+ ## `POST /v1/mcp/:name/enable` / `/disable`
2006
+
2007
+ ```json
2008
+ { "ok": true, "name": "fs", "enabled": true }
2009
+ ```
2010
+
2011
+ ## `GET /v1/cost`
2012
+
2013
+ | 参数 | 说明 |
2014
+ |------|------|
2015
+ | `model` | 模型 ID(默认配置默认模型) |
2016
+ | `input` | 输入 token 数 |
2017
+ | `output` | 输出 token 数 |
2018
+
2019
+ ```json
2020
+ { "model": "gpt-4o", "price": { "inputPerMillion": 2.5, "outputPerMillion": 10 }, "cost_usd": 7.5 }
2021
+ ```
2022
+ ```
2023
+
2024
+ - [ ] **Step 2: 更新 README.md**
2025
+
2026
+ Commands 区(约 27-40 行)更新:
2027
+
2028
+ ```markdown
2029
+ min-agent history # List saved sessions
2030
+ min-agent history delete <id> # Delete a session
2031
+ min-agent history rename <id> <t> # Rename a session
2032
+ min-agent history export <id> [-o file] # Export a session as JSON
2033
+ min-agent update # Check for updates and upgrade
2034
+ ```
2035
+
2036
+ MCP 区补充 --env 示例:
2037
+
2038
+ ```markdown
2039
+ min-agent mcp add <name> <cmd...> --env KEY=VALUE # Local stdio with env vars
2040
+ ```
2041
+
2042
+ - [ ] **Step 3: 提交**
2043
+
2044
+ ```bash
2045
+ git add docs/API.md README.md
2046
+ git commit -m "docs: 第二批 API 与 CLI 文档同步"
2047
+ ```
2048
+
2049
+ ---
2050
+
2051
+ ## Task 12: 全量验证 + 冒烟
2052
+
2053
+ - [ ] **Step 1: 类型检查**
2054
+
2055
+ Run: `bun run typecheck`(仓库根)
2056
+ Expected: 无错误
2057
+
2058
+ - [ ] **Step 2: 全量测试**
2059
+
2060
+ Run: `bun test`
2061
+ Expected: 全部通过
2062
+
2063
+ - [ ] **Step 3: 手动冒烟**
2064
+
2065
+ Run:
2066
+ ```bash
2067
+ bun run src/cli.ts --help
2068
+ bun run src/cli.ts history export no-such-id
2069
+ bun run src/cli.ts history delete no-such-id
2070
+ bun run src/cli.ts mcp add test echo hi --env FOO=bar --skip-check
2071
+ bun run src/cli.ts mcp info test
2072
+ bun run src/cli.ts mcp remove test
2073
+ ```
2074
+
2075
+ 预期:help 含新命令;export/delete 报"会话不存在" exit 1;mcp add 显示 `(0 tools detected)` 且 info 显示 Env: FOO;remove 成功。
2076
+
2077
+ - [ ] **Step 4: 提交(如有遗留改动)**
2078
+
2079
+ ```bash
2080
+ git add -A
2081
+ git commit -m "chore: 第二批验证收尾"
2082
+ ```
2083
+
2084
+ ---
2085
+
2086
+ ## 自审记录
2087
+
2088
+ **Spec 覆盖:**
2089
+ - history delete/rename/export → Task 6
2090
+ - mcp add --env → Task 6
2091
+ - update 自动升级 → Task 6(updater.ts)
2092
+ - bash cwd → Task 1
2093
+ - apply_patch → Task 3
2094
+ - write append → Task 2
2095
+ - web_search 参数 → Task 4
2096
+ - chat 模式工具 → Task 5
2097
+ - GET /v1/sessions/:id、rename → Task 8
2098
+ - skills/mcp enable-disable → Task 9
2099
+ - paste code → Task 9
2100
+ - GET /v1/cost → Task 10
2101
+ - POST /v1/chat/undo → Task 8
2102
+ - 采样参数透传 → Task 7(agent)+ Task 10(serve)
2103
+ - 文档同步 → Task 11
2104
+
2105
+ **类型一致性:** `RunOptions` 在 Task 7 定义并被 Task 10 使用;`buildPatchedFiles`/`parseUnifiedDiff` 在 Task 3 定义并被测试使用;`PatchResult` 判别联合类型一致。