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,1595 @@
1
+ # 第三批:配置 + 工程 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:** 多 provider(破坏式 + 自动迁移)、sampling/budget 配置、日志系统、CI、/plan 计划模式、文档完善。
6
+
7
+ **Architecture:** config.ts 提供迁移与 activeProvider 解析(`migrateLegacyConfig` + `getActiveProvider`),provider.ts 按 active/覆盖名解析模型;agent.ts 的 RunOptions 承载 providerName/planMode,runOnceCore 统一处理采样回退与预算检查;logger.ts 为独立轻量模块(同步追加写)。
8
+
9
+ **Tech Stack:** TypeScript, AI SDK v6, bun:test
10
+
11
+ **Spec:** `docs/superpowers/specs/2026-08-16-batch3-config-engineering-design.md`
12
+
13
+ ---
14
+
15
+ ## 文件结构
16
+
17
+ | 文件 | 职责 |
18
+ |---|---|
19
+ | `src/config.ts`(改) | providers[] + activeProvider、迁移、getActiveProvider、sampling/budget 字段、setup 重构 |
20
+ | `src/provider.ts`(改) | resolveModel 按 active/覆盖名、getProviderNames |
21
+ | `src/logger.ts`(新) | 日期滚动日志 |
22
+ | `tests/logger.test.ts`(新) | 日志测试 |
23
+ | `src/agent.ts`(改) | RunOptions.providerName/planMode、采样回退、预算检查、日志记录点 |
24
+ | `src/cli.ts`(改) | --provider、models、setup 提示、日志初始化 |
25
+ | `src/tui-chat.ts`(改) | /provider、/budget、/plan、onRunFinish 预算提示 |
26
+ | `src/tui/slash-commands.ts`(改) | 菜单新命令 |
27
+ | `src/serve.ts`(改) | getActiveProvider 适配、body.provider、done 事件 budget_exceeded |
28
+ | `src/context-window.ts`(改) | getActiveProvider 适配 |
29
+ | `src/compaction.ts`(改) | getActiveProvider 适配 |
30
+ | `src/output.ts`(改) | getActiveProvider 适配 |
31
+ | `src/tui/ModelPicker.tsx`(改) | getActiveProvider 适配 |
32
+ | `.github/workflows/ci.yml`(新) | CI |
33
+ | `tests/config.test.ts`(改) | 迁移测试 |
34
+ | `tests/provider.test.ts`(改) | resolveModel 测试 |
35
+ | `tests/serve.test.ts`(改) | provider/budget_exceeded 测试 |
36
+ | `docs/API.md`、`README.md`(改) | 文档同步 |
37
+
38
+ ---
39
+
40
+ ## Task 1: config.ts — 多 provider 结构 + 迁移 + getActiveProvider
41
+
42
+ **Files:**
43
+ - Modify: `src/config.ts`
44
+ - Test: `tests/config.test.ts`
45
+
46
+ - [ ] **Step 1: 写失败测试**
47
+
48
+ 在 `tests/config.test.ts` 末尾追加:
49
+
50
+ ```ts
51
+ import { getActiveProvider, migrateLegacyConfig, loadConfig } from "../src/config.js"
52
+
53
+ test("migrateLegacyConfig converts the legacy single provider", () => {
54
+ const legacy = { provider: { baseURL: "https://x", apiKey: "k", defaultModel: "m1" } }
55
+ const { config, migrated } = migrateLegacyConfig(legacy as any)
56
+ expect(migrated).toBe(true)
57
+ expect(config.providers).toEqual([{ name: "default", baseURL: "https://x", apiKey: "k", defaultModel: "m1" }])
58
+ expect(config.activeProvider).toBe("default")
59
+ expect((config as any).provider).toBeUndefined()
60
+ })
61
+
62
+ test("migrateLegacyConfig is a no-op for the new format", () => {
63
+ const fresh = { providers: [{ name: "a", baseURL: "https://x", apiKey: "k" }], activeProvider: "a" }
64
+ const { config, migrated } = migrateLegacyConfig(fresh)
65
+ expect(migrated).toBe(false)
66
+ expect(config).toBe(fresh)
67
+ })
68
+
69
+ test("getActiveProvider resolves the active name", () => {
70
+ const cfg = {
71
+ providers: [
72
+ { name: "a", baseURL: "u1", apiKey: "k1" },
73
+ { name: "b", baseURL: "u2", apiKey: "k2" },
74
+ ],
75
+ activeProvider: "b",
76
+ }
77
+ expect(getActiveProvider(cfg)?.name).toBe("b")
78
+ })
79
+
80
+ test("getActiveProvider falls back to the first provider", () => {
81
+ const cfg = {
82
+ providers: [{ name: "a", baseURL: "u1", apiKey: "k1" }],
83
+ }
84
+ expect(getActiveProvider(cfg)?.name).toBe("a")
85
+ })
86
+
87
+ test("getActiveProvider returns undefined without providers", () => {
88
+ expect(getActiveProvider({})).toBeUndefined()
89
+ })
90
+
91
+ test("loadConfig migrates and persists a legacy config file", () => {
92
+ const base = makeTemp()
93
+ const file = path.join(base, "config.json")
94
+ writeFileSync(file, JSON.stringify({ provider: { baseURL: "https://x", apiKey: "k" } }))
95
+ const oldDir = process.env.MIN_AGENT_CONFIG_DIR
96
+ process.env.MIN_AGENT_CONFIG_DIR = base
97
+ try {
98
+ const config = loadConfig()
99
+ expect(config.providers?.[0]?.name).toBe("default")
100
+ expect(config.activeProvider).toBe("default")
101
+ const onDisk = JSON.parse(readFileSync(file, "utf-8"))
102
+ expect(onDisk.providers[0].name).toBe("default")
103
+ expect(onDisk.provider).toBeUndefined()
104
+ } finally {
105
+ if (oldDir) process.env.MIN_AGENT_CONFIG_DIR = oldDir
106
+ else delete process.env.MIN_AGENT_CONFIG_DIR
107
+ rmSync(base, { recursive: true, force: true })
108
+ }
109
+ })
110
+ ```
111
+
112
+ (`readFileSync` 需补 import;`makeTemp` 已存在)
113
+
114
+ - [ ] **Step 2: 运行测试确认失败**
115
+
116
+ Run: `bun test tests/config.test.ts`
117
+ Expected: 新用例 FAIL(migrateLegacyConfig/getActiveProvider 不存在)
118
+
119
+ - [ ] **Step 3: 修改 config.ts**
120
+
121
+ **a)** `ProviderConfig` 接口加 name、`AppConfig` 重构:
122
+
123
+ ```ts
124
+ export interface ProviderConfig {
125
+ name?: string
126
+ type?: "openai-compatible" | "openai" | "ollama"
127
+ baseURL: string
128
+ apiKey: string
129
+ defaultModel?: string
130
+ /** Context window size in tokens (default: 128000) */
131
+ contextWindow?: number
132
+ }
133
+
134
+ export interface AppConfig {
135
+ providers?: ProviderConfig[]
136
+ activeProvider?: string
137
+ instructions?: string[]
138
+ disabledSkills?: string[]
139
+ /** Permission mode: "allow-all" auto-approves everything, "ask" prompts for dangerous ops (default) */
140
+ permission?: "allow-all" | "ask"
141
+ /** Compaction settings */
142
+ compaction?: {
143
+ /** Model to use for summarization (defaults to main model). Use a cheap fast model like "gemini-2.0-flash" */
144
+ model?: string
145
+ /** Auto-continue after compaction (default: true) */
146
+ autoContinue?: boolean
147
+ }
148
+ /** SearXNG-compatible web search instance (default: public instance) */
149
+ webSearchURL?: string
150
+ /** Firecrawl-compatible web fetch instance (default: public instance) */
151
+ webFetchURL?: string
152
+ /** Per-model price overrides (USD per 1M tokens), used for cost estimation */
153
+ pricing?: Record<string, { inputPerMillion: number; outputPerMillion: number }>
154
+ /** Sampling parameters applied when the request does not override them */
155
+ sampling?: { temperature?: number; maxTokens?: number; topP?: number }
156
+ /** Cost budget in USD; the agent stops suggesting work beyond it */
157
+ budget?: { maxCostUSD?: number }
158
+ }
159
+ ```
160
+
161
+ **b)** 添加迁移与解析函数(`loadConfig` 之后):
162
+
163
+ ```ts
164
+ export function migrateLegacyConfig(config: AppConfig): { config: AppConfig; migrated: boolean } {
165
+ const legacy = (config as any).provider as ProviderConfig | undefined
166
+ if (!legacy) return { config, migrated: false }
167
+ const { provider, ...rest } = config as any
168
+ return {
169
+ config: { ...rest, providers: [{ name: "default", ...provider }], activeProvider: "default" },
170
+ migrated: true,
171
+ }
172
+ }
173
+
174
+ export function getActiveProvider(config: AppConfig): ProviderConfig | undefined {
175
+ if (!config.providers || config.providers.length === 0) return undefined
176
+ if (config.activeProvider) {
177
+ const found = config.providers.find((p) => p.name === config.activeProvider)
178
+ if (found) return found
179
+ }
180
+ return config.providers[0]
181
+ }
182
+ ```
183
+
184
+ **c)** `loadConfig` 集成迁移:
185
+
186
+ ```ts
187
+ export function loadConfig(): AppConfig {
188
+ const raw = loadConfigFile(path.join(getConfigDir(), CONFIG_FILE_NAME))
189
+ const { config, migrated } = migrateLegacyConfig(raw)
190
+ if (migrated) saveConfig(config)
191
+ return config
192
+ }
193
+ ```
194
+
195
+ **d)** `isConfigured` 改:
196
+
197
+ ```ts
198
+ export function isConfigured(): boolean {
199
+ const config = loadConfig()
200
+ const provider = getActiveProvider(config)
201
+ return !!(provider?.baseURL && provider?.apiKey)
202
+ }
203
+ ```
204
+
205
+ **e)** `runSetup` 重构(本任务先做最小可用版本,交互式多 provider 管理在 Task 3):
206
+
207
+ 本任务先把 `runSetup` 的结尾保存改为 providers 结构(Task 3 再重构交互流程):
208
+
209
+ ```ts
210
+ const providers = config.providers ?? []
211
+ const existing = getActiveProvider(config)
212
+ ...
213
+ // 现有流程收集 typeChoice/baseURL/apiKey/defaultModel 后:
214
+ const providerEntry: ProviderConfig = { name: existing?.name ?? "default", type: providerType, baseURL, apiKey, defaultModel }
215
+ if (providers.length === 0) {
216
+ config.providers = [providerEntry]
217
+ config.activeProvider = "default"
218
+ } else {
219
+ const idx = providers.findIndex((p) => p.name === (existing?.name ?? "default"))
220
+ if (idx >= 0) providers[idx] = providerEntry
221
+ else providers.push(providerEntry)
222
+ config.providers = providers
223
+ }
224
+ saveConfig(config)
225
+ ```
226
+
227
+ 注意:本任务中 setup 仍提示 `config.provider = {...}` 处需同步修改(消除编译错误)。Task 3 会完整重构 setup 交互。
228
+
229
+ - [ ] **Step 4: 运行测试确认通过**
230
+
231
+ Run: `bun test tests/config.test.ts`
232
+ Expected: PASS(原有 + 6 个新用例)
233
+
234
+ - [ ] **Step 5: 提交**
235
+
236
+ ```bash
237
+ git add src/config.ts tests/config.test.ts
238
+ git commit -m "feat: 多 provider 配置结构与自动迁移"
239
+ ```
240
+
241
+ ---
242
+
243
+ ## Task 2: 全代码库 provider 引用适配 + provider.ts 改造
244
+
245
+ **Files:**
246
+ - Modify: `src/provider.ts`
247
+ - Modify: `src/context-window.ts`
248
+ - Modify: `src/compaction.ts`
249
+ - Modify: `src/output.ts`
250
+ - Modify: `src/tui/ModelPicker.tsx`
251
+ - Test: `tests/provider.test.ts`
252
+
253
+ - [ ] **Step 1: 写失败测试**
254
+
255
+ 在 `tests/provider.test.ts` 末尾追加:
256
+
257
+ ```ts
258
+ import { getProviderNames, resolveModelForProvider, resolveModel } from "../src/provider.js"
259
+
260
+ test("getProviderNames returns configured names", () => {
261
+ const config = loadConfig()
262
+ const names = getProviderNames(config)
263
+ expect(Array.isArray(names)).toBe(true)
264
+ })
265
+ ```
266
+
267
+ (`loadConfig` 需 import;如 provider.test.ts 已有隔离 setup,跟随现有模式。若无,追加一个 MIN_AGENT_CONFIG_DIR 隔离的测试块,先写 config.json 含两个 provider,断言 getProviderNames 返回两个名字、resolveModel 用 activeProvider 的 baseURL 创建模型、resolveModel("m", "b") 用 provider b)
268
+
269
+ - [ ] **Step 2: 运行测试确认失败**
270
+
271
+ Run: `bun test tests/provider.test.ts`
272
+ Expected: 新用例 FAIL(编译错误或函数不存在)
273
+
274
+ - [ ] **Step 3: 修改 provider.ts**
275
+
276
+ ```ts
277
+ import { createOpenAI } from "@ai-sdk/openai"
278
+ import type { LanguageModel } from "ai"
279
+ import { loadConfig, getActiveProvider, type ProviderConfig } from "./config.js"
280
+
281
+ export function resolveModelForProvider(provider: ProviderConfig, modelId?: string): LanguageModel {
282
+ if (!provider.baseURL || !provider.apiKey) {
283
+ throw new Error("Not configured. Run: min-agent setup")
284
+ }
285
+
286
+ const id = modelId ?? provider.defaultModel
287
+ if (!id) {
288
+ throw new Error("No model specified. Run: min-agent setup")
289
+ }
290
+
291
+ switch (provider.type ?? "openai-compatible") {
292
+ case "openai": {
293
+ const client = createOpenAI({ apiKey: provider.apiKey })
294
+ return client.chat(id) as unknown as LanguageModel
295
+ }
296
+
297
+ case "ollama": {
298
+ const client = createOpenAI({
299
+ baseURL: normalizeOllamaBaseURL(provider.baseURL),
300
+ apiKey: provider.apiKey || "ollama",
301
+ })
302
+ return client.chat(id) as unknown as LanguageModel
303
+ }
304
+
305
+ case "openai-compatible": {
306
+ const client = createOpenAI({
307
+ baseURL: provider.baseURL,
308
+ apiKey: provider.apiKey,
309
+ })
310
+ return client.chat(id) as unknown as LanguageModel
311
+ }
312
+
313
+ default:
314
+ throw new Error(`Unknown provider type: ${provider.type}`)
315
+ }
316
+ }
317
+
318
+ export function resolveModel(modelId?: string, providerName?: string): LanguageModel {
319
+ const config = loadConfig()
320
+ const provider = providerName
321
+ ? config.providers?.find((p) => p.name === providerName)
322
+ : getActiveProvider(config)
323
+ if (!provider) {
324
+ throw new Error(providerName ? `Provider "${providerName}" not found` : "Not configured. Run: min-agent setup")
325
+ }
326
+ return resolveModelForProvider(provider, modelId)
327
+ }
328
+
329
+ export function getProviderNames(config: ReturnType<typeof loadConfig>): string[] {
330
+ return (config.providers ?? []).map((p) => p.name ?? "").filter(Boolean)
331
+ }
332
+ ```
333
+
334
+ (`normalizeOllamaBaseURL` 需 import from config.js:`import { loadConfig, getActiveProvider, normalizeOllamaBaseURL, type ProviderConfig } from "./config.js"`)
335
+
336
+ - [ ] **Step 4: 适配其余引用点**
337
+
338
+ **context-window.ts**(detectContextWindow 与 getContextWindow):
339
+
340
+ ```ts
341
+ import { getConfigDir, loadConfig, getActiveProvider, type AppConfig } from "./config.js"
342
+
343
+ async function detectContextWindow(config: AppConfig, id: string): Promise<number> {
344
+ const provider = getActiveProvider(config)
345
+ const baseURL = provider?.baseURL ?? ""
346
+ const apiKey = provider?.apiKey ?? ""
347
+ ...
348
+ }
349
+
350
+ export async function getContextWindow(modelId?: string): Promise<number> {
351
+ const config = loadConfig()
352
+ const provider = getActiveProvider(config)
353
+ // 1. Explicit user config
354
+ if (provider?.contextWindow) return provider.contextWindow
355
+ const id = modelId ?? provider?.defaultModel
356
+ ...
357
+ }
358
+ ```
359
+
360
+ **compaction.ts**(约 175-177 行):
361
+
362
+ ```ts
363
+ const provider = getActiveProvider(cfg)
364
+ if (provider?.contextWindow) return provider.contextWindow
365
+ return await getContextWindow(provider?.defaultModel)
366
+ ```
367
+
368
+ (compaction.ts import 加 getActiveProvider)
369
+
370
+ **output.ts**(约 18 行):
371
+
372
+ ```ts
373
+ const model = modelId ?? getActiveProvider(loadConfig())?.defaultModel ?? "unknown"
374
+ ```
375
+
376
+ (output.ts import 加 getActiveProvider;确认 output.ts 现有 import)
377
+
378
+ **tui/ModelPicker.tsx**(约 36 行):
379
+
380
+ ```ts
381
+ const provider = getActiveProvider(cfg)
382
+ if (provider?.baseURL && provider?.apiKey) {
383
+ ```
384
+
385
+ (ModelPicker.tsx import 加 getActiveProvider)
386
+
387
+ - [ ] **Step 5: 运行测试确认通过**
388
+
389
+ Run: `bun test` 全量与 `bun run typecheck`(仓库根)
390
+ Expected: 全部通过,无类型错误
391
+
392
+ - [ ] **Step 6: 提交**
393
+
394
+ ```bash
395
+ git add src/provider.ts src/context-window.ts src/compaction.ts src/output.ts src/tui/ModelPicker.tsx tests/provider.test.ts
396
+ git commit -m "feat: 模型解析按 activeProvider 与覆盖名"
397
+ ```
398
+
399
+ ---
400
+
401
+ ## Task 3: cli.ts — --provider、models、setup 交互重构
402
+
403
+ **Files:**
404
+ - Modify: `src/cli.ts`
405
+ - Modify: `src/config.ts`(runSetup)
406
+
407
+ - [ ] **Step 1: parseFlags 加 --provider**
408
+
409
+ ```ts
410
+ interface ParsedFlags {
411
+ model?: string
412
+ provider?: string
413
+ resume?: string
414
+ images: string[]
415
+ port?: number
416
+ host?: string
417
+ positionals: string[]
418
+ }
419
+ ```
420
+
421
+ 解析循环加:
422
+
423
+ ```ts
424
+ } else if (a === "--provider" && argv[i + 1]) {
425
+ provider = take()
426
+ }
427
+ ```
428
+
429
+ 返回对象加 `provider`。`chat` 分支解构加 `provider: providerOverride`,传给 runAgent/runTui。
430
+
431
+ **runAgent**(src/agent.ts)加 providerName 参数:
432
+
433
+ ```ts
434
+ export async function runAgent(message: string, modelId?: string, imagePaths?: string[], providerName?: string) {
435
+ ...
436
+ await runOnce(messages, instructions, modelId, undefined, undefined, tracker, undefined, providerName)
437
+ ```
438
+
439
+ 等等——runOnce 签名已定(Task 7 批 2):`runOnce(messages, instructions, modelId, abortSignal, callbacks, tracker, options)`。providerName 放 options:`options = { providerName }`。runOnceCore 里 `resolveModel(modelId, options?.providerName)`。
440
+
441
+ **修改 runOnceCore**:
442
+
443
+ ```ts
444
+ const model = resolveModel(modelId, options?.providerName)
445
+ ```
446
+
447
+ **修改 runOnce/runOnceWithSystem** 的 RunOptions(agent.ts):
448
+
449
+ ```ts
450
+ export interface RunOptions {
451
+ temperature?: number
452
+ maxTokens?: number
453
+ topP?: number
454
+ providerName?: string
455
+ planMode?: boolean
456
+ }
457
+ ```
458
+
459
+ (planMode 字段本任务先加上,Task 8 使用)
460
+
461
+ **runTui**(src/tui-chat.ts)TuiOptions 加 `providerName?: string`,`runTui` 内 `let currentProvider = opts.providerName`,executeAgent 的 runOnce 调用 options 传 `{ providerName: currentProvider }`(与后续 planMode 合并):
462
+
463
+ ```ts
464
+ const runOptions = { providerName: currentProvider }
465
+ if (mode === "code" && codeSystemPrompt) {
466
+ await runOnceWithSystem(messages, codeSystemPrompt, currentModel, abortController?.signal, callbacks, tracker, runOptions)
467
+ } else {
468
+ await runOnce(messages, instructions, currentModel, abortController?.signal, callbacks, tracker, runOptions)
469
+ }
470
+ ```
471
+
472
+ **cli.ts chat/code 分支**:
473
+
474
+ ```ts
475
+ const { model: modelOverride, provider: providerOverride, resume: resumeId, images, positionals } = parseFlags(args.slice(1))
476
+ ...
477
+ if (!message) {
478
+ const { runTui } = await import("./tui-chat.js")
479
+ await runTui({ modelId: modelOverride, providerName: providerOverride, resumeSessionId: resumeId, mode: "chat", images })
480
+ } else {
481
+ await runAgent(message, modelOverride, images.length > 0 ? images : undefined, providerOverride)
482
+ }
483
+ ```
484
+
485
+ code 分支同样透传 providerName。
486
+
487
+ - [ ] **Step 2: models 命令适配**
488
+
489
+ ```ts
490
+ case "models": {
491
+ const config = loadConfig()
492
+ const provider = getActiveProvider(config)
493
+ if (!provider?.baseURL || !provider?.apiKey) {
494
+ console.error("Not configured. Run: min-agent setup")
495
+ process.exit(1)
496
+ }
497
+ console.log(`Fetching models (${provider.name ?? "default"})...`)
498
+ const models = await fetchModels(provider.baseURL, provider.apiKey)
499
+ if (models.length === 0) {
500
+ console.log("No models found or unable to fetch model list.")
501
+ } else {
502
+ console.log(`\nAvailable models (${models.length}):`)
503
+ for (const m of models) {
504
+ const marker = m === provider.defaultModel ? " ← default" : ""
505
+ console.log(` ${m}${marker}`)
506
+ }
507
+ }
508
+ break
509
+ }
510
+ ```
511
+
512
+ (cli.ts import 加 getActiveProvider)
513
+
514
+ - [ ] **Step 3: runSetup 完整重构(src/config.ts)**
515
+
516
+ 将 `runSetup` 整体替换为:
517
+
518
+ ```ts
519
+ export async function runSetup(): Promise<void> {
520
+ const config = loadConfig()
521
+ const providers = config.providers ?? []
522
+ const current = getActiveProvider(config)
523
+
524
+ const rl = readline.createInterface({ input: process.stdin, output: process.stdout })
525
+
526
+ console.log("\n🔧 min-agent 配置\n")
527
+
528
+ if (providers.length > 0) {
529
+ console.log("已配置的 Provider:")
530
+ providers.forEach((p, i) => {
531
+ const marker = p.name === config.activeProvider ? " ← 当前" : ""
532
+ console.log(` ${i + 1}. ${p.name ?? "(未命名)"} ${p.baseURL} (${p.defaultModel ?? "未设置模型"})${marker}`)
533
+ })
534
+ console.log()
535
+ console.log("选项:")
536
+ console.log(" 1. 添加新 Provider")
537
+ console.log(" 2. 切换当前 Provider")
538
+ console.log(" 3. 修改当前 Provider")
539
+ console.log(" 4. 完成")
540
+ const choice = await ask(rl, "选择 (1/2/3/4)", "4")
541
+
542
+ if (choice === "1") {
543
+ const provider = await collectProvider(rl, undefined)
544
+ provider.name = (await ask(rl, "Provider 名称", `provider-${providers.length + 1}`)).trim() || `provider-${providers.length + 1}`
545
+ providers.push(provider)
546
+ config.activeProvider = provider.name
547
+ config.providers = providers
548
+ saveConfig(config)
549
+ console.log(`\n✓ Provider "${provider.name}" 已添加并设为当前`)
550
+ } else if (choice === "2") {
551
+ const sel = await ask(rl, "输入序号选择当前 Provider")
552
+ const idx = parseInt(sel, 10) - 1
553
+ const target = providers[idx]
554
+ if (!target?.name) {
555
+ console.error("无效序号")
556
+ } else {
557
+ config.activeProvider = target.name
558
+ saveConfig(config)
559
+ console.log(`\n✓ 当前 Provider 已切换为: ${target.name}`)
560
+ }
561
+ } else if (choice === "3") {
562
+ const name = current?.name ?? providers[0]?.name
563
+ if (!name) { console.error("无可用 Provider"); rl.close(); return }
564
+ const idx = providers.findIndex((p) => p.name === name)
565
+ providers[idx] = await collectProvider(rl, providers[idx])
566
+ config.providers = providers
567
+ saveConfig(config)
568
+ console.log(`\n✓ Provider "${name}" 已更新`)
569
+ } else {
570
+ console.log("\n配置未变更")
571
+ }
572
+ rl.close()
573
+ return
574
+ }
575
+
576
+ // 首次配置
577
+ const provider = await collectProvider(rl, undefined)
578
+ provider.name = "default"
579
+ config.providers = [provider]
580
+ config.activeProvider = "default"
581
+ saveConfig(config)
582
+ rl.close()
583
+ console.log(`\n✓ 配置已保存到 ${path.join(getConfigDir(), CONFIG_FILE_NAME)}`)
584
+ }
585
+
586
+ async function collectProvider(rl: readline.Interface, existing?: ProviderConfig): Promise<ProviderConfig> {
587
+ console.log("Provider 类型:")
588
+ console.log(" 1. openai-compatible (默认,兼容 OpenAI API 的任意服务)")
589
+ console.log(" 2. openai (OpenAI 官方)")
590
+ console.log(" 3. ollama (本地 Ollama)")
591
+ console.log()
592
+
593
+ const typeChoice = await ask(rl, "选择 Provider (1/2/3)", existing?.type === "ollama" ? "3" : existing?.type === "openai" ? "2" : "1")
594
+ const providerType = typeChoice === "3" ? "ollama" as const
595
+ : typeChoice === "2" ? "openai" as const
596
+ : "openai-compatible" as const
597
+
598
+ let baseURL: string
599
+ let apiKey: string
600
+
601
+ if (providerType === "ollama") {
602
+ baseURL = await ask(rl, "Ollama API URL", existing?.baseURL || "http://localhost:11434/v1")
603
+ baseURL = normalizeOllamaBaseURL(baseURL)
604
+ apiKey = "ollama"
605
+ } else if (providerType === "openai") {
606
+ baseURL = "https://api.openai.com/v1"
607
+ apiKey = await ask(rl, "OpenAI API Key", existing?.apiKey)
608
+ } else {
609
+ baseURL = await ask(rl, "API Base URL", existing?.baseURL || "https://api.openai.com/v1")
610
+ apiKey = await ask(rl, "API Key", existing?.apiKey)
611
+ }
612
+
613
+ if (!baseURL || (!apiKey && providerType !== "ollama")) {
614
+ console.error("URL 和 Key 不能为空")
615
+ process.exit(1)
616
+ }
617
+
618
+ console.log("\n正在获取模型列表...")
619
+ const models = await fetchModels(baseURL, apiKey)
620
+
621
+ let defaultModel = existing?.defaultModel ?? ""
622
+ if (models.length > 0) {
623
+ console.log(`\n可用模型 (${models.length}):`)
624
+ models.forEach((m, i) => {
625
+ const marker = m === defaultModel ? " ← 当前默认" : ""
626
+ console.log(` ${i + 1}. ${m}${marker}`)
627
+ })
628
+ console.log()
629
+ const choice = await ask(rl, "选择默认模型 (输入序号或模型名)", defaultModel)
630
+ const idx = parseInt(choice, 10) - 1
631
+ if (idx >= 0 && idx < models.length) defaultModel = models[idx]!
632
+ else if (choice) defaultModel = choice
633
+ } else {
634
+ console.log(" ⚠ 无法获取模型列表,请手动输入模型名")
635
+ const hint = providerType === "ollama" ? "llama3" : providerType === "openai" ? "gpt-4o" : ""
636
+ defaultModel = await ask(rl, "默认模型", defaultModel || hint)
637
+ }
638
+
639
+ return { type: providerType, baseURL, apiKey, defaultModel }
640
+ }
641
+ ```
642
+
643
+ - [ ] **Step 4: 验证**
644
+
645
+ Run: `bun run typecheck`(仓库根)和 `bun test`
646
+ Expected: 无错误,全部通过
647
+
648
+ - [ ] **Step 5: 提交**
649
+
650
+ ```bash
651
+ git add src/cli.ts src/config.ts src/agent.ts src/tui-chat.ts
652
+ git commit -m "feat: CLI --provider 与交互式多 provider 配置"
653
+ ```
654
+
655
+ ---
656
+
657
+ ## Task 4: TUI /provider + serve 适配
658
+
659
+ **Files:**
660
+ - Modify: `src/tui-chat.ts`
661
+ - Modify: `src/serve.ts`
662
+ - Test: `tests/serve.test.ts`
663
+
664
+ - [ ] **Step 1: 写失败测试**
665
+
666
+ 在 `tests/serve.test.ts` 末尾追加:
667
+
668
+ ```ts
669
+ test("F9: /v1/models 使用 activeProvider", async () => {
670
+ const res = await fetch(`http://127.0.0.1:${port}/v1/models`)
671
+ expect(res.status).toBe(200)
672
+ const body = await res.json()
673
+ expect(body.default_model).toBe(BAD_MODEL)
674
+ })
675
+
676
+ test("F9: POST /v1/chat 接受 provider 字段不报错", async () => {
677
+ const { status } = await postJson("/v1/chat", {
678
+ message: "hi",
679
+ stream: false,
680
+ provider: "default",
681
+ })
682
+ expect(status).toBe(200)
683
+ }, 90000)
684
+ ```
685
+
686
+ (beforeAll 的 config.json 已是 providers 结构——**需要更新**:Task 1 迁移后 serve 测试的 beforeAll 写的是旧格式,serve 启动时会迁移。为明确,将 beforeAll 的 config.json 改为新格式:
687
+
688
+ ```ts
689
+ writeFileSync(
690
+ path.join(configDir, "config.json"),
691
+ JSON.stringify({
692
+ providers: [{ name: "default", baseURL: "http://127.0.0.1:1/v1", apiKey: "test", defaultModel: BAD_MODEL }],
693
+ activeProvider: "default",
694
+ pricing: { [BAD_MODEL]: { inputPerMillion: 10, outputPerMillion: 20 } },
695
+ }),
696
+ )
697
+ ```
698
+
699
+
700
+
701
+ - [ ] **Step 2: 运行测试确认失败**
702
+
703
+ Run: `bun test tests/serve.test.ts`
704
+ Expected: 新用例 FAIL(models 路由 500 或 provider 字段未识别)
705
+
706
+ - [ ] **Step 3: 修改 tui-chat.ts**
707
+
708
+ **/provider 命令**(在 /model case 之前添加):
709
+
710
+ ```ts
711
+ case "provider": {
712
+ const cfg = loadConfig()
713
+ const providers = cfg.providers ?? []
714
+ if (arg) {
715
+ const target = providers.find((p) => p.name === arg)
716
+ if (!target) {
717
+ sysMsg(tui, `Provider "${arg}" 不存在`)
718
+ break
719
+ }
720
+ cfg.activeProvider = arg
721
+ saveConfig(cfg)
722
+ setModel(target.defaultModel ?? "")
723
+ sysMsg(tui, `✓ 当前 Provider 已切换为: ${arg}`)
724
+ } else {
725
+ if (providers.length === 0) {
726
+ sysMsg(tui, "无已配置 Provider,运行: min-agent setup")
727
+ break
728
+ }
729
+ const lines = providers.map((p) => {
730
+ const cur = p.name === cfg.activeProvider ? " ← 当前" : ""
731
+ return ` ${p.name} ${p.baseURL}${cur}`
732
+ })
733
+ sysMsg(tui, `Providers:\n${lines.join("\n")}\n\n切换: /provider <name>`)
734
+ }
735
+ break
736
+ }
737
+ ```
738
+
739
+ **/models 分支适配**(改用 getActiveProvider):
740
+
741
+ ```ts
742
+ case "models": {
743
+ const { fetchModels } = await import("./config.js")
744
+ const cfg = loadConfig()
745
+ const provider = getActiveProvider(cfg)
746
+ if (!provider?.baseURL || !provider?.apiKey) {
747
+ sysMsg(tui, "未配置 Provider,运行: min-agent setup")
748
+ break
749
+ }
750
+ sysMsg(tui, "正在获取模型列表...")
751
+ const models = await fetchModels(provider.baseURL, provider.apiKey)
752
+ if (models.length === 0) {
753
+ sysMsg(tui, "无法获取模型列表")
754
+ } else {
755
+ const lines = models.map((m) => {
756
+ const marker = m === provider.defaultModel ? " ← 默认" : ""
757
+ return ` ${m}${marker}`
758
+ })
759
+ sysMsg(tui, `可用模型 (${models.length}):\n${lines.join("\n")}`)
760
+ }
761
+ break
762
+ }
763
+ ```
764
+
765
+ **/model 分支适配**(保存到 activeProvider):
766
+
767
+ ```ts
768
+ case "model": {
769
+ if (arg) {
770
+ const config = await import("./config.js")
771
+ const cfg = config.loadConfig()
772
+ const provider = getActiveProvider(cfg)
773
+ if (provider) {
774
+ provider.defaultModel = arg
775
+ config.saveConfig(cfg)
776
+ }
777
+ setModel(arg)
778
+ sysMsg(tui, `✓ 默认模型已设为: ${arg}`)
779
+ } else {
780
+ tui.showModelPicker()
781
+ }
782
+ break
783
+ }
784
+ ```
785
+
786
+ **onModelPick 回调适配**(runTui 内约 131 行):
787
+
788
+ ```ts
789
+ onModelPick: (model: string) => {
790
+ tui.hideModelPicker()
791
+ const cfg = loadConfig()
792
+ const provider = getActiveProvider(cfg)
793
+ if (provider) {
794
+ provider.defaultModel = model
795
+ saveConfig(cfg)
796
+ }
797
+ currentModel = model
798
+ tui.setModel(model)
799
+ sysMsg(tui, `✓ 默认模型已设为: ${model}`)
800
+ },
801
+ ```
802
+
803
+ **runTui 启动处适配**(`config.provider?.defaultModel` 引用,约 50、147 行):
804
+
805
+ ```ts
806
+ const config = loadConfig()
807
+ const activeProvider = getActiveProvider(config)
808
+ ...
809
+ let currentModel = modelId ?? activeProvider?.defaultModel
810
+ ...
811
+ new TuiRenderer(callbacks, modelId ?? activeProvider?.defaultModel)
812
+ ```
813
+
814
+ (tui-chat.ts import 加 getActiveProvider)
815
+
816
+ - [ ] **Step 4: 修改 serve.ts**
817
+
818
+ **a)** import 加 getActiveProvider:
819
+
820
+ ```ts
821
+ import { loadConfig, saveConfig, fetchModels, isConfigured, getActiveProvider } from "./config.js"
822
+ ```
823
+
824
+ **b)** `/v1/models` 路由:
825
+
826
+ ```ts
827
+ if (req.method === "GET" && pathname === "/v1/models") {
828
+ const config = loadConfig()
829
+ const provider = getActiveProvider(config)
830
+ const base = provider?.baseURL
831
+ const key = provider?.apiKey
832
+ if (!base || !key) {
833
+ sendJson(res, 500, { error: "provider_not_configured" })
834
+ return
835
+ }
836
+ const models = await fetchModels(base, key)
837
+ sendJson(res, 200, {
838
+ default_model: provider?.defaultModel ?? null,
839
+ models,
840
+ })
841
+ return
842
+ }
843
+ ```
844
+
845
+ **c)** `/v1/context` 与 `/v1/cost` 路由(provider 引用):
846
+
847
+ ```ts
848
+ if (req.method === "GET" && pathname === "/v1/context") {
849
+ const config = loadConfig()
850
+ const provider = getActiveProvider(config)
851
+ const ctxWindow = await getContextWindow(provider?.defaultModel)
852
+ sendJson(res, 200, {
853
+ context_window: ctxWindow,
854
+ model: provider?.defaultModel ?? null,
855
+ })
856
+ return
857
+ }
858
+
859
+ if (req.method === "GET" && pathname === "/v1/cost") {
860
+ const config = loadConfig()
861
+ const provider = getActiveProvider(config)
862
+ const modelId = url.searchParams.get("model") ?? provider?.defaultModel ?? null
863
+ ...
864
+ }
865
+ ```
866
+
867
+ **d)** ChatBody 加 provider 字段,handleChatRequest 的 runOptions 加 providerName:
868
+
869
+ ```ts
870
+ interface ChatBody {
871
+ ...
872
+ provider?: string
873
+ ...
874
+ }
875
+ ```
876
+
877
+ ```ts
878
+ const runOptions: { temperature?: number; maxTokens?: number; topP?: number; providerName?: string } = {
879
+ temperature: ...,
880
+ maxTokens: ...,
881
+ topP: ...,
882
+ providerName: typeof body.provider === "string" ? body.provider : undefined,
883
+ }
884
+ ```
885
+
886
+ - [ ] **Step 5: 运行测试确认通过**
887
+
888
+ Run: `bun test tests/serve.test.ts`
889
+ Expected: PASS(原有 + 2 个新用例)
890
+
891
+ - [ ] **Step 6: 提交**
892
+
893
+ ```bash
894
+ git add src/tui-chat.ts src/serve.ts tests/serve.test.ts
895
+ git commit -m "feat: TUI /provider 与 serve activeProvider 适配"
896
+ ```
897
+
898
+ ---
899
+
900
+ ## Task 5: sampling 配置回退 + 预算上限
901
+
902
+ **Files:**
903
+ - Modify: `src/agent.ts`
904
+ - Modify: `src/tui-chat.ts`
905
+ - Modify: `src/serve.ts`
906
+ - Test: `tests/serve.test.ts`(budget_exceeded 字段)
907
+
908
+ - [ ] **Step 1: 写失败测试**
909
+
910
+ 在 `tests/serve.test.ts` 末尾追加:
911
+
912
+ ```ts
913
+ test("F9: done 事件包含 budget_exceeded 字段", async () => {
914
+ const res = await fetch(`http://127.0.0.1:${port}/v1/chat`, {
915
+ method: "POST",
916
+ headers: { "Content-Type": "application/json", ...authHeaders() },
917
+ body: JSON.stringify({ message: "hi", stream: true }),
918
+ })
919
+ const sse = parseSse(await res.text())
920
+ const done = sse.find((e) => e.type === "done")
921
+ expect(done).toBeTruthy()
922
+ expect(typeof (done as any).budget_exceeded).toBe("boolean")
923
+ }, 90000)
924
+ ```
925
+
926
+ - [ ] **Step 2: 运行测试确认失败**
927
+
928
+ Run: `bun test tests/serve.test.ts`
929
+ Expected: 新用例 FAIL(budget_exceeded undefined)
930
+
931
+ - [ ] **Step 3: 修改 agent.ts**
932
+
933
+ **a)** `RunOnceCallbacks.onRunFinish` 的 info 加字段:
934
+
935
+ ```ts
936
+ onRunFinish?: (info: {
937
+ stepCount: number
938
+ usage: LanguageModelUsage | undefined
939
+ hasError: boolean
940
+ aborted: boolean
941
+ maxStepsReached: boolean
942
+ budgetExceeded?: boolean
943
+ }) => void
944
+ ```
945
+
946
+ **b)** runOnceCore 采样回退(`const model = resolveModel(...)` 之后):
947
+
948
+ ```ts
949
+ const sampling = loadConfig().sampling ?? {}
950
+ const temperature = options?.temperature ?? sampling.temperature
951
+ const maxTokens = options?.maxTokens ?? sampling.maxTokens
952
+ const topP = options?.topP ?? sampling.topP
953
+ ```
954
+
955
+ streamText 条件展开改用这些变量:
956
+
957
+ ```ts
958
+ ...(temperature != null ? { temperature } : {}),
959
+ ...(maxTokens != null ? { maxTokens } : {}),
960
+ ...(topP != null ? { topP } : {}),
961
+ ```
962
+
963
+ (agent.ts 需 import loadConfig、getActiveProvider)
964
+
965
+ **c)** 预算检查(runOnceCore 内,`maxStepsReached` 逻辑附近):
966
+
967
+ ```ts
968
+ let budgetExceeded = false
969
+ const checkBudget = async () => {
970
+ if (!tracker) return
971
+ const maxCost = loadConfig().budget?.maxCostUSD
972
+ if (maxCost == null || maxCost <= 0) return
973
+ const price = await getModelPrice(modelId ?? getActiveProvider(loadConfig())?.defaultModel ?? "")
974
+ const cost = estimateCost(
975
+ { inputTokens: tracker.totalInputTokens, outputTokens: tracker.totalOutputTokens },
976
+ price,
977
+ )
978
+ if (cost != null && cost > maxCost) budgetExceeded = true
979
+ }
980
+ ```
981
+
982
+ (agent.ts 需 import getModelPrice、estimateCost from "./pricing.js",getActiveProvider from "./config.js")
983
+
984
+ 在成功路径与 catch 路径的 onRunFinish 调用前 `await checkBudget()`,并在 info 中传 `budgetExceeded`:
985
+
986
+ ```ts
987
+ if (cbs.onRunFinish) {
988
+ await checkBudget()
989
+ cbs.onRunFinish({ stepCount, usage, hasError, aborted: false, maxStepsReached, budgetExceeded })
990
+ }
991
+ ```
992
+
993
+ catch 路径两处同样 `await checkBudget()` 并传字段(aborted 路径可不检查——aborted 时已返回,保持简单:aborted 分支传 `budgetExceeded: false`)。
994
+
995
+ - [ ] **Step 4: 修改 tui-chat.ts**
996
+
997
+ **onRunFinish** 完成消息追加预算提示(info 解构后):
998
+
999
+ ```ts
1000
+ onRunFinish: async (info) => {
1001
+ abortController = null
1002
+ tui.setRunning(false)
1003
+ if (info.aborted) return
1004
+ if (info.budgetExceeded) {
1005
+ tui.addMessage({
1006
+ id: `budget-${Date.now()}`,
1007
+ role: "system",
1008
+ content: "⚠ 已达到预算上限,后续回合可能被限制。/budget 查看或调高",
1009
+ timestamp: Date.now(),
1010
+ })
1011
+ }
1012
+ ...
1013
+ ```
1014
+
1015
+ **/budget 命令**(/tokens 附近):
1016
+
1017
+ ```ts
1018
+ case "budget": {
1019
+ const cfg = loadConfig()
1020
+ const maxCost = cfg.budget?.maxCostUSD
1021
+ if (arg) {
1022
+ const n = parseFloat(arg)
1023
+ if (isNaN(n) || n <= 0) {
1024
+ sysMsg(tui, "用法: /budget <USD 金额>")
1025
+ break
1026
+ }
1027
+ cfg.budget = { maxCostUSD: n }
1028
+ saveConfig(cfg)
1029
+ sysMsg(tui, `✓ 预算上限已设为: $${n}`)
1030
+ break
1031
+ }
1032
+ const price = await getModelPrice(currentModel ?? "")
1033
+ const cost = formatCost(
1034
+ estimateCost(
1035
+ { inputTokens: tracker.totalInputTokens, outputTokens: tracker.totalOutputTokens },
1036
+ price,
1037
+ ),
1038
+ )
1039
+ sysMsg(tui, maxCost != null
1040
+ ? `预算: 已用约 ${cost} / 上限 $${maxCost}\n设置: /budget <金额>`
1041
+ : `预算: 已用约 ${cost} / 未设置上限\n设置: /budget <金额>`)
1042
+ break
1043
+ }
1044
+ ```
1045
+
1046
+ (getModelPrice/estimateCost/formatCost 已在第一批 import)
1047
+
1048
+ - [ ] **Step 5: 修改 serve.ts 的 done 事件**
1049
+
1050
+ `createSseCallbacks` 的 onRunFinish(约 190-211 行)done 事件加字段:
1051
+
1052
+ ```ts
1053
+ sseWrite(res, {
1054
+ type: "done",
1055
+ step_count: info.stepCount,
1056
+ usage: info.usage,
1057
+ has_error: info.hasError,
1058
+ aborted: info.aborted,
1059
+ max_steps_reached: info.maxStepsReached,
1060
+ budget_exceeded: info.budgetExceeded ?? false,
1061
+ session_id: saved,
1062
+ messages,
1063
+ ...(opts.codeMode ? { mode: "code", project: opts.project } : {}),
1064
+ })
1065
+ ```
1066
+
1067
+ - [ ] **Step 6: 验证**
1068
+
1069
+ Run: `bun run typecheck`(仓库根)和 `bun test`
1070
+ Expected: 无错误,全部通过
1071
+
1072
+ - [ ] **Step 7: 提交**
1073
+
1074
+ ```bash
1075
+ git add src/agent.ts src/tui-chat.ts src/serve.ts tests/serve.test.ts
1076
+ git commit -m "feat: 采样参数配置回退与预算上限"
1077
+ ```
1078
+
1079
+ ---
1080
+
1081
+ ## Task 6: logger.ts 日志系统
1082
+
1083
+ **Files:**
1084
+ - Create: `src/logger.ts`
1085
+ - Modify: `src/agent.ts`
1086
+ - Modify: `src/cli.ts`
1087
+ - Test: `tests/logger.test.ts`
1088
+
1089
+ - [ ] **Step 1: 写失败测试**
1090
+
1091
+ 创建 `tests/logger.test.ts`:
1092
+
1093
+ ```ts
1094
+ import { expect, test, beforeEach, afterEach } from "bun:test"
1095
+ import { mkdtempSync, rmSync, writeFileSync, readdirSync, readFileSync, realpathSync } from "fs"
1096
+ import os from "os"
1097
+ import path from "path"
1098
+ import { initLogger, log, logToolCall, logToolResult } from "../src/logger.js"
1099
+
1100
+ let configDir = ""
1101
+ let originalConfigDir: string | undefined
1102
+
1103
+ beforeEach(() => {
1104
+ configDir = realpathSync(mkdtempSync(path.join(os.tmpdir(), "min-agent-logger-")))
1105
+ originalConfigDir = process.env.MIN_AGENT_CONFIG_DIR
1106
+ process.env.MIN_AGENT_CONFIG_DIR = configDir
1107
+ })
1108
+
1109
+ afterEach(() => {
1110
+ if (originalConfigDir === undefined) delete process.env.MIN_AGENT_CONFIG_DIR
1111
+ else process.env.MIN_AGENT_CONFIG_DIR = originalConfigDir
1112
+ rmSync(configDir, { recursive: true, force: true })
1113
+ })
1114
+
1115
+ function todayLog(): string {
1116
+ const now = new Date()
1117
+ const m = String(now.getMonth() + 1).padStart(2, "0")
1118
+ const d = String(now.getDate()).padStart(2, "0")
1119
+ return path.join(configDir, "logs", `min-agent.${now.getFullYear()}-${m}-${d}.log`)
1120
+ }
1121
+
1122
+ test("log writes to the daily file with timestamp and level", () => {
1123
+ initLogger()
1124
+ log("info", "hello")
1125
+ const content = readFileSync(todayLog(), "utf-8")
1126
+ expect(content).toContain("[info] hello")
1127
+ expect(content).toMatch(/^\[\d{2}:\d{2}:\d{2}\] \[info\] hello/)
1128
+ })
1129
+
1130
+ test("logToolCall and logToolResult record summarized input", () => {
1131
+ initLogger()
1132
+ logToolCall("bash", { command: "echo hi", timeout: 5 })
1133
+ const content = readFileSync(todayLog(), "utf-8")
1134
+ expect(content).toContain("tool_call bash")
1135
+ expect(content).toContain("echo hi")
1136
+ })
1137
+
1138
+ test("initLogger cleans up logs older than 7 days", () => {
1139
+ const logsDir = path.join(configDir, "logs")
1140
+ writeFileSync(path.join(logsDir, "min-agent.2020-01-01.log"), "old", { flag: "a" })
1141
+ writeFileSync(path.join(logsDir, "min-agent.2099-01-01.log"), "future", { flag: "a" })
1142
+ initLogger()
1143
+ const files = readdirSync(logsDir)
1144
+ expect(files).not.toContain("min-agent.2020-01-01.log")
1145
+ expect(files).toContain("min-agent.2099-01-01.log")
1146
+ })
1147
+ ```
1148
+
1149
+ (注意:initLogger 会创建 logsDir——第三个用例先手动写文件需要目录存在:用 `mkdirSync(logsDir, { recursive: true })` 补 import;或直接依赖 writeFileSync 的父目录——writeFileSync 不创建父目录,需 mkdirSync)
1150
+
1151
+ - [ ] **Step 2: 运行测试确认失败**
1152
+
1153
+ Run: `bun test tests/logger.test.ts`
1154
+ Expected: FAIL — module not found
1155
+
1156
+ - [ ] **Step 3: 实现 logger.ts**
1157
+
1158
+ ```ts
1159
+ import { existsSync, mkdirSync, readdirSync, unlinkSync, writeFileSync } from "fs"
1160
+ import path from "path"
1161
+ import { getConfigDir } from "./config.js"
1162
+
1163
+ const KEEP_DAYS = 7
1164
+
1165
+ function logsDir(): string {
1166
+ return path.join(getConfigDir(), "logs")
1167
+ }
1168
+
1169
+ function logFilePath(date: Date): string {
1170
+ const y = date.getFullYear()
1171
+ const m = String(date.getMonth() + 1).padStart(2, "0")
1172
+ const d = String(date.getDate()).padStart(2, "0")
1173
+ return path.join(logsDir(), `min-agent.${y}-${m}-${d}.log`)
1174
+ }
1175
+
1176
+ export function initLogger(): void {
1177
+ try {
1178
+ mkdirSync(logsDir(), { recursive: true })
1179
+ const cutoff = Date.now() - KEEP_DAYS * 24 * 60 * 60 * 1000
1180
+ for (const f of readdirSync(logsDir())) {
1181
+ const m = f.match(/^min-agent\.(\d{4})-(\d{2})-(\d{2})\.log$/)
1182
+ if (!m) continue
1183
+ const ts = new Date(`${m[1]}-${m[2]}-${m[3]}T00:00:00`).getTime()
1184
+ if (ts < cutoff) {
1185
+ try {
1186
+ unlinkSync(path.join(logsDir(), f))
1187
+ } catch {}
1188
+ }
1189
+ }
1190
+ } catch {}
1191
+ }
1192
+
1193
+ function writeLog(level: string, msg: string): void {
1194
+ try {
1195
+ const now = new Date()
1196
+ const time =
1197
+ `${String(now.getHours()).padStart(2, "0")}:${String(now.getMinutes()).padStart(2, "0")}:${String(now.getSeconds()).padStart(2, "0")}`
1198
+ writeFileSync(logFilePath(now), `[${time}] [${level}] ${msg}\n`, { flag: "a" })
1199
+ } catch {}
1200
+ }
1201
+
1202
+ export function log(level: "info" | "warn" | "error", msg: string): void {
1203
+ writeLog(level, msg)
1204
+ }
1205
+
1206
+ function summarize(v: unknown, max = 200): string {
1207
+ const s = typeof v === "string" ? v : JSON.stringify(v) ?? String(v)
1208
+ return s.length > max ? s.slice(0, max) + "…" : s
1209
+ }
1210
+
1211
+ export function logToolCall(name: string, input: unknown): void {
1212
+ writeLog("info", `tool_call ${name} ${summarize(input)}`)
1213
+ }
1214
+
1215
+ export function logToolResult(name: string, output: unknown): void {
1216
+ writeLog("info", `tool_result ${name} ${summarize(output)}`)
1217
+ }
1218
+ ```
1219
+
1220
+ - [ ] **Step 4: agent.ts 记录点**
1221
+
1222
+ import:
1223
+
1224
+ ```ts
1225
+ import { initLogger, log, logToolCall, logToolResult } from "./logger.js"
1226
+ ```
1227
+
1228
+ - runOnceCore 开头(`const model = resolveModel(...)` 之后):
1229
+
1230
+ ```ts
1231
+ log("info", `run start mode=${mode} messages=${messages.length} model=${model.modelId}`)
1232
+ ```
1233
+
1234
+ - tool-call 事件(case "tool-call" 内):
1235
+
1236
+ ```ts
1237
+ logToolCall(event.toolName, event.input)
1238
+ ```
1239
+
1240
+ - tool-result 事件(case "tool-result"/"tool-error" 内):
1241
+
1242
+ ```ts
1243
+ logToolResult(event.toolName, displayed)
1244
+ ```
1245
+
1246
+ - error 事件(case "error" 内):
1247
+
1248
+ ```ts
1249
+ log("error", `stream error: ${String(event.error)}`)
1250
+ ```
1251
+
1252
+ - catch 路径(`const msg = err instanceof Error ? err.message : String(err)` 附近):
1253
+
1254
+ ```ts
1255
+ log("error", msg)
1256
+ ```
1257
+
1258
+ - 成功结束(`if (cbs.onRunFinish) {` 之前):
1259
+
1260
+ ```ts
1261
+ log("info", `run end steps=${stepCount}`)
1262
+ ```
1263
+
1264
+ (注意放在 pushTurn 之后、usage 更新附近;只加一次)
1265
+
1266
+ - [ ] **Step 5: cli.ts 启动记录**
1267
+
1268
+ `main()` 开头(args 检查之后):
1269
+
1270
+ ```ts
1271
+ initLogger()
1272
+ log("info", `min-agent started: ${command}`)
1273
+ ```
1274
+
1275
+ (import from "./logger.js")
1276
+
1277
+ - [ ] **Step 6: 运行测试确认通过**
1278
+
1279
+ Run: `bun test tests/logger.test.ts` 与 `bun test` 全量、`bun run typecheck`
1280
+ Expected: 全部通过
1281
+
1282
+ - [ ] **Step 7: 提交**
1283
+
1284
+ ```bash
1285
+ git add src/logger.ts src/agent.ts src/cli.ts tests/logger.test.ts
1286
+ git commit -m "feat: 日期滚动运行日志"
1287
+ ```
1288
+
1289
+ ---
1290
+
1291
+ ## Task 7: CI
1292
+
1293
+ **Files:**
1294
+ - Create: `.github/workflows/ci.yml`(仓库根目录)
1295
+
1296
+ - [ ] **Step 1: 创建 CI 文件**
1297
+
1298
+ 在仓库根 `/Users/kevin/Desktop/project/min-agent/.github/workflows/ci.yml` 创建:
1299
+
1300
+ ```yaml
1301
+ name: CI
1302
+
1303
+ on:
1304
+ push:
1305
+ branches: [master]
1306
+ pull_request:
1307
+
1308
+ jobs:
1309
+ test:
1310
+ runs-on: ubuntu-latest
1311
+ steps:
1312
+ - uses: actions/checkout@v4
1313
+ - uses: oven-sh/setup-bun@v2
1314
+ with:
1315
+ bun-version: latest
1316
+ - run: bun install --frozen-lockfile
1317
+ - run: bun run typecheck
1318
+ - run: cd packages/min-agent && bun test
1319
+ ```
1320
+
1321
+ - [ ] **Step 2: 验证 YAML 语法**
1322
+
1323
+ Run: `bun -e "const y = await Bun.file('.github/workflows/ci.yml').text(); console.log('ok', y.length)"`(仓库根)
1324
+ Expected: ok + 长度
1325
+
1326
+ - [ ] **Step 3: 提交**
1327
+
1328
+ ```bash
1329
+ git add .github/workflows/ci.yml
1330
+ git commit -m "ci: GitHub Actions 类型检查与测试"
1331
+ ```
1332
+
1333
+ ---
1334
+
1335
+ ## Task 8: /plan 计划模式
1336
+
1337
+ **Files:**
1338
+ - Modify: `src/agent.ts`(buildTools planMode 过滤)
1339
+ - Modify: `src/tui-chat.ts`(/plan 命令)
1340
+
1341
+ - [ ] **Step 1: agent.ts buildTools 加 planMode**
1342
+
1343
+ ```ts
1344
+ async function buildTools(
1345
+ mode: "chat" | "code",
1346
+ modelId: string | undefined,
1347
+ abortSignal: AbortSignal | undefined,
1348
+ planMode = false,
1349
+ ): Promise<Record<string, Tool>> {
1350
+ ...
1351
+ if (planMode) {
1352
+ for (const name of ["bash", "write", "edit", "apply_patch"]) delete allTools[name]
1353
+ }
1354
+ return allTools
1355
+ }
1356
+ ```
1357
+
1358
+ runOnceCore 调用处:
1359
+
1360
+ ```ts
1361
+ const allTools = await buildTools(mode, modelId, abortSignal, options?.planMode)
1362
+ ```
1363
+
1364
+ - [ ] **Step 2: tui-chat.ts /plan**
1365
+
1366
+ **状态变量**(undoStack 附近):
1367
+
1368
+ ```ts
1369
+ let planMode = false
1370
+ ```
1371
+
1372
+ **executeAgent 的 runOptions**(现有 `const runOptions = { providerName: currentProvider }`)改为:
1373
+
1374
+ ```ts
1375
+ const runOptions: { providerName?: string; planMode?: boolean } = {
1376
+ providerName: currentProvider,
1377
+ ...(planMode ? { planMode: true } : {}),
1378
+ }
1379
+ ```
1380
+
1381
+ **/plan 命令**(/diff 附近):
1382
+
1383
+ ```ts
1384
+ case "plan": {
1385
+ state.togglePlanMode()
1386
+ sysMsg(tui, state.planMode
1387
+ ? "✓ 计划模式已开启:仅只读工具可用,输出计划后再次输入 /plan 确认并执行"
1388
+ : "✓ 已退出计划模式")
1389
+ break
1390
+ }
1391
+ ```
1392
+
1393
+ SlashState 接口加 planMode/togglePlanMode:
1394
+
1395
+ ```ts
1396
+ interface SlashState {
1397
+ ...
1398
+ planMode: boolean
1399
+ togglePlanMode: () => void
1400
+ }
1401
+ ```
1402
+
1403
+ 调用处传:
1404
+
1405
+ ```ts
1406
+ planMode,
1407
+ togglePlanMode: () => { planMode = !planMode },
1408
+ ```
1409
+
1410
+ - [ ] **Step 3: 验证**
1411
+
1412
+ Run: `bun run typecheck`(仓库根)和 `bun test`
1413
+ Expected: 无错误,全部通过
1414
+
1415
+ - [ ] **Step 4: 提交**
1416
+
1417
+ ```bash
1418
+ git add src/agent.ts src/tui-chat.ts
1419
+ git commit -m "feat: /plan 只读计划模式"
1420
+ ```
1421
+
1422
+ ---
1423
+
1424
+ ## Task 9: 文档同步
1425
+
1426
+ **Files:**
1427
+ - Modify: `src/cli.ts`(printUsage)
1428
+ - Modify: `src/tui/slash-commands.ts`
1429
+ - Modify: `src/tui-chat.ts`(/help)
1430
+ - Modify: `README.md`
1431
+ - Modify: `docs/API.md`
1432
+
1433
+ - [ ] **Step 1: printUsage 更新**
1434
+
1435
+ - 命令区加:`min-agent chat/code --provider <name> Use a specific provider`
1436
+ - 选项区加:
1437
+
1438
+ ```
1439
+ --provider <name> Use a specific configured provider
1440
+ ```
1441
+
1442
+ - setup 说明行更新为 `min-agent setup Configure providers (interactive)`
1443
+
1444
+ - [ ] **Step 2: slash-commands.ts 菜单**
1445
+
1446
+ 在 `{ name: "model", ... }` 之后加:
1447
+
1448
+ ```ts
1449
+ { name: "provider", description: "查看/切换 Provider", argHint: "[name]" },
1450
+ ```
1451
+
1452
+ 在 `{ name: "tokens", ... }` 之后加:
1453
+
1454
+ ```ts
1455
+ { name: "budget", description: "查看/设置预算上限", argHint: "[USD]" },
1456
+ { name: "plan", description: "切换只读计划模式" },
1457
+ ```
1458
+
1459
+ - [ ] **Step 3: /help 同步**(tui-chat.ts 的 help 数组)
1460
+
1461
+ ```ts
1462
+ " /model [n] 交互式选择/切换模型",
1463
+ " /provider [n] 查看/切换 Provider",
1464
+ " /budget [n] 查看/设置预算上限",
1465
+ " /plan 切换只读计划模式",
1466
+ ```
1467
+
1468
+ (替换原 `/model [n]` 行并在 `/tokens` 前插入 budget/plan 相关行,保持列表顺序清晰)
1469
+
1470
+ - [ ] **Step 4: README.md**
1471
+
1472
+ **Configuration 区**的 config.json 示例替换为:
1473
+
1474
+ ```json
1475
+ // ~/.min-agent/config.json
1476
+ {
1477
+ "providers": [
1478
+ {
1479
+ "name": "default",
1480
+ "type": "openai-compatible",
1481
+ "baseURL": "https://api.openai.com/v1",
1482
+ "apiKey": "sk-...",
1483
+ "defaultModel": "gpt-5.5",
1484
+ "contextWindow": 128000
1485
+ }
1486
+ ],
1487
+ "activeProvider": "default",
1488
+ "instructions": ["./docs/rules.md"],
1489
+ "disabledSkills": [],
1490
+ "pricing": { "gpt-5.5": { "inputPerMillion": 1.25, "outputPerMillion": 10 } },
1491
+ "sampling": { "temperature": 0.7, "maxTokens": 4096 },
1492
+ "budget": { "maxCostUSD": 5 }
1493
+ }
1494
+ ```
1495
+
1496
+ 说明文字更新:
1497
+
1498
+ ```markdown
1499
+ Multiple providers are supported: add entries to `providers` and set `activeProvider` (or use `min-agent setup`). Old single-provider configs are migrated automatically on first run.
1500
+
1501
+ `sampling` sets defaults for temperature/maxTokens/topP (overridable per request). `budget.maxCostUSD` stops cost escalation beyond the limit (check after each turn).
1502
+ ```
1503
+
1504
+ **Commands 区**加:
1505
+
1506
+ ```markdown
1507
+ min-agent chat --provider <name> "msg" # Use a specific provider
1508
+ ```
1509
+
1510
+ **Interactive Commands 表**加:
1511
+
1512
+ ```markdown
1513
+ | `/provider [name]` | List/switch provider |
1514
+ | `/budget [USD]` | Show/set cost budget |
1515
+ | `/plan` | Toggle read-only plan mode |
1516
+ ```
1517
+
1518
+ **日志说明**(Environment Variables 区之前或 Configuration 区末尾):
1519
+
1520
+ ```markdown
1521
+ Logs: `~/.min-agent/logs/min-agent.YYYY-MM-DD.log` (rotated daily, kept 7 days)
1522
+ ```
1523
+
1524
+ - [ ] **Step 5: docs/API.md**
1525
+
1526
+ **`POST /v1/chat` 字段表**加:
1527
+
1528
+ ```markdown
1529
+ | `provider` | string | 使用指定 Provider(默认 activeProvider) |
1530
+ ```
1531
+
1532
+ **done 事件字段表**的 done 行补:
1533
+
1534
+ ```markdown
1535
+ | `done` | step_count, usage, has_error, aborted, max_steps_reached, budget_exceeded, messages, session_id | 结束 |
1536
+ ```
1537
+
1538
+ - [ ] **Step 6: 提交**
1539
+
1540
+ ```bash
1541
+ git add src/cli.ts src/tui/slash-commands.ts src/tui-chat.ts README.md docs/API.md
1542
+ git commit -m "docs: 第三批配置与命令文档同步"
1543
+ ```
1544
+
1545
+ ---
1546
+
1547
+ ## Task 10: 全量验证 + 冒烟
1548
+
1549
+ - [ ] **Step 1: 类型检查**
1550
+
1551
+ Run: `bun run typecheck`(仓库根)
1552
+ Expected: 无错误
1553
+
1554
+ - [ ] **Step 2: 全量测试**
1555
+
1556
+ Run: `bun test`
1557
+ Expected: 全部通过
1558
+
1559
+ - [ ] **Step 3: 手动冒烟**
1560
+
1561
+ Run(packages/min-agent 下):
1562
+ ```bash
1563
+ bun run src/cli.ts --help
1564
+ bun run src/cli.ts chat --provider no-such "hi" # 应报 Provider not found exit 1
1565
+ bun run src/cli.ts models # 显示 activeProvider 模型
1566
+ bun run src/cli.ts setup # 交互流程(可 Ctrl+C 退出)
1567
+ ```
1568
+
1569
+ TUI 冒烟(`bun run src/cli.ts chat`):
1570
+ 1. `/provider` 列出 providers
1571
+ 2. `/plan` 进入计划模式(提示只读)→ `/plan` 退出
1572
+ 3. `/budget` 显示预算状态 → `/budget 10` 设置
1573
+ 4. `/help` 包含新命令
1574
+
1575
+ - [ ] **Step 4: 提交(如有遗留改动)**
1576
+
1577
+ ```bash
1578
+ git add -A
1579
+ git commit -m "chore: 第三批验证收尾"
1580
+ ```
1581
+
1582
+ ---
1583
+
1584
+ ## 自审记录
1585
+
1586
+ **Spec 覆盖:**
1587
+ - 多 provider(结构/迁移/CLI/TUI/serve)→ Task 1、2、3、4
1588
+ - sampling 配置 → Task 5
1589
+ - 预算上限 → Task 5
1590
+ - 日志系统 → Task 6
1591
+ - CI → Task 7
1592
+ - /plan → Task 8
1593
+ - 文档完善 → Task 9
1594
+
1595
+ **类型一致性:** `ProviderConfig`(含 name)在 Task 1 定义,Task 2/3/4 使用;`RunOptions` 的 providerName/planMode 在 Task 3 定义,Task 5/8 使用;`onRunFinish` info 的 budgetExceeded 在 Task 5 定义并被 tui-chat/serve 消费。