thincoder 0.12.51 → 0.12.53

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 (58) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/README.md +2 -2
  3. package/package.json +2 -2
  4. package/src/acp/bridge.mjs +1 -0
  5. package/src/advisor/run.mjs +9 -11
  6. package/src/agent/dispatch.mjs +38 -13
  7. package/src/agent/helpers.mjs +1 -1
  8. package/src/agent/setup.mjs +2 -2
  9. package/src/agent-tools/consult.mjs +0 -1
  10. package/src/agent-tools/skill.mjs +1 -1
  11. package/src/agent-tools/task.mjs +0 -2
  12. package/src/agent-tools/verify.mjs +0 -1
  13. package/src/agent.mjs +36 -3
  14. package/src/cli/make-agent.mjs +11 -5
  15. package/src/config.mjs +8 -103
  16. package/src/mcp/helpers.mjs +14 -5
  17. package/src/mcp/transport-http.mjs +79 -27
  18. package/src/mcp/transport-stdio.mjs +57 -3
  19. package/src/mcp/transport-ws.mjs +46 -12
  20. package/src/mcp.mjs +197 -58
  21. package/src/model-specs.mjs +108 -0
  22. package/src/prompts/discipline.md +43 -0
  23. package/src/prompts/system.md +1 -1
  24. package/src/provider/anthropic.mjs +51 -18
  25. package/src/provider/core.mjs +121 -102
  26. package/src/provider/google.mjs +41 -15
  27. package/src/provider/normalize.mjs +81 -0
  28. package/src/provider/rate.mjs +5 -0
  29. package/src/provider/responses.mjs +498 -0
  30. package/src/provider/retry.mjs +125 -0
  31. package/src/provider/sse.mjs +58 -24
  32. package/src/proxy.mjs +36 -6
  33. package/src/tools/bash.md +2 -2
  34. package/src/tools/execute.md +1 -1
  35. package/src/tools/execute.mjs +3 -3
  36. package/src/tools/fetch.md +1 -0
  37. package/src/tools/file.mjs +136 -11
  38. package/src/tools/git.md +4 -2
  39. package/src/tools/git.mjs +38 -11
  40. package/src/tools/shared.mjs +6 -3
  41. package/src/tools/system.mjs +19 -1
  42. package/src/tools/web.mjs +44 -14
  43. package/src/tools/websearch.md +3 -1
  44. package/src/tui/agent-turn.mjs +2 -10
  45. package/src/tui/clipboard.mjs +3 -1
  46. package/src/tui/dims.mjs +20 -47
  47. package/src/tui/fold-block.mjs +59 -11
  48. package/src/tui/index.mjs +65 -75
  49. package/src/tui/key-handler.mjs +4 -1
  50. package/src/tui/mouse.mjs +47 -7
  51. package/src/tui/render-conversation.mjs +226 -124
  52. package/src/tui/render-frame.mjs +7 -2
  53. package/src/tui/render-loop.mjs +10 -0
  54. package/src/tui/render.mjs +12 -1
  55. package/src/tui/startup.mjs +1 -2
  56. package/src/tui/subagent-blocks.mjs +6 -1
  57. package/src/tui/tool-args.mjs +4 -0
  58. package/src/tui/tool-events.mjs +2 -4
package/CHANGELOG.md CHANGED
@@ -1,3 +1,52 @@
1
+ ## [0.12.52] — 2026-08-31
2
+ ## [0.12.53] — 2026-08-31
3
+
4
+ ### Added
5
+
6
+ - **TUI 展开块内滚动**(2026-08-31 用户需求):折叠区块 60% 高度封顶保留,正文改为滚动视口——`state._foldScroll` 记每块窗口起点;**滚轮命中块内容行 → 块内 ±3 行**(未命中走会话滚动),▲/▼ 控制行点击翻窗(step=winH 快速跳转);**穿出语义**:块顶滚上/块底滚下 → 交还会话滚动(会话顶懒加载可达);锚定补偿:暂停流式跟随期间内容增长按 convLen 增量补偿 scroll(读的位置不漂移)。**滚动读全文、永不截断**
7
+ - **流式跟随尾部**:`state._followTail` 默认 true——输出活动期间渲染前钉底;用户上滚(PgUp/滚轮)暂停跟随,PgDn/滚回底部/新提交消息恢复
8
+ - **工具顺手度**(2026-08-31):① insert_after 精确判定(本 session 写入记录受影响区,未受影响区直接插入不逼重 read、受影响区拒绝保护栏);② edit 数组形态 `edits: [{path, old_string, new_string}, ...]` 一次多文件原子替换;③ dispatch 拦截工具执行期间 console.log/console.error 回显给模型(异常路径同样回显);④ 写入工具返回带上下文窗口(edit/insert_after/hashline_edit 返回写入点 ±3 行带行号——模型自检行号语义,防"行号漂移死循环")
9
+
10
+ ### Changed
11
+
12
+ - **懒加载滚动到头自动加载**(2026-08-31 用户约定修复):恢复会话向上滚动到会话顶部自动加载更早一页(原只挂 PgUp 键=违约);`HISTORY_PAGE_MESSAGES` 50→20(单页更平顺,vscode parity)
13
+ - **三层渲染缓存**(懒加载卡顿根治):行级 wrapRowsCached + 段级 _lineSegCache(覆盖普通行/工具块/frozenSubTask/frozenAdvisor)——loadOlder 后 rebuild 111ms→5-8ms 平坦(不随已加载历史增长)
14
+ - **折叠 key 身份化**(会诊三家共识):`long-${i}`/`fold-${foldCounter++}`/`advisor-done-${i}` 位置键全部改 `_lineId` 派生(loadOlder unshift 后展开态/块内滚动 offset 不串位)
15
+ - **视口数学单源** `convViewport`(渲染+鼠标命中共用):短会话顶部补 pad 后命中整体偏移的存量 bug 修复(点击折叠头/滚轮落空或错行)
16
+
17
+ ### Fixed
18
+
19
+ - **块内滚动穿出缺陷**:滚到块顶/块尾后滚轮永远命中该块、穿不出 → 会话顶/懒加载不可达("经过展开块滚不到顶")——显式边界判定穿出
20
+ - **懒加载只挂 PgUp 键**(小键盘无 PgUp 用户等于无入口)——滚轮滚到会话顶同样触发
21
+
22
+ ### Fixed
23
+
24
+ - **sanitizeDisplay 吞正文(用户报障)**:正文含字面 `⟦ev⟧` 时(如讨论 ACP 桥剥除语义的结果表),D5 残段剥除 `/⟦ev⟧[^RS-GS]*/` 按字符类语义"吞到行尾"——该格到回复末尾全没了。真 token = 哨兵+字母 phase 词,收窄为 `/⟦ev⟧[A-Za-z]*/`:裸哨兵正文合法保留,live token 照剥(防伪不破,旧语义断言随测试反转)
25
+ - **窗口拖小不生效**:双次相同确认规则(ConPTY 防御)在"拖动只收一个 resize 事件"的场景永不触发,UI 停在旧大宽度溢出;真 resize 事件 400ms settle 后提交(growth 仍立即、untrusted 采样防御不动)
26
+ - **dims 误诊机器拆除**:ConPTY stale 假说整套机构(双确认/settle/看门狗/启动收敛/turn 采样/sawValid)建立在对折叠块 cols=80 的误诊上——全部删除(dims 98→45 行);现行铁律 = 渲染只读 get()、refresh() 仅事件钩子(seed/resize)、任何 sane 采样立即提交
27
+
28
+ ### Changed
29
+
30
+ - **头部显示版本号**:`ThinCoder 0.12.52 │ model │ cwd`(logo 右侧 dim,模块级读 package.json)
31
+ - **提示词**:system prompt "task tracks work for **EVERY tier — even Small**"(注意力层级对齐,修 Small 漏建 task)
32
+
33
+ ### Refactor
34
+
35
+ - **model-specs.mjs**(TODO #1):MODEL_SPECS + specForModel 抽出,config.mjs 358→266;re-export 保证 23 个 importer 零改动
36
+ - **provider/normalize.mjs**(TODO #2):stripImagesForTextModel + normalizeToolPairing(发送前载荷净化)抽出,core.mjs 420→350
37
+ - **config.schema.json 删除**(TODO #8,用户裁定):从未闭环(线上 URL 未部署/代码零消费/25 键缺 20);saveConfig 不再注入 $schema
38
+ - **死代码清理**:15 处死 import + 9 无用转义 + 17 control-regex 注明有意;lint warnings 86→44
39
+ - **test/ 纳入 lint**(TODO #7):62 条存量清零,`eslint src test`
40
+
41
+ ### Perf
42
+
43
+ - 慢测试修复(TODO #10):"原子写不残留 .tmp" 断言曾 readdirSync 扫 `~/.thincoder/sessions/` 全目录(3 万文件时单次 18s)——改 O(1) existsSync 直探本槽位,session 往返用例 16.4s→28ms(仅测试基建,不影响运行时)
44
+
45
+ ### Tests
46
+
47
+ - read_image × glm-5.3-flash 直接用例(PROVIDER.md §11 T2 落自动化);TOOLS.md §6/§7 用例表回补(T-g-1..12 / T-w / T-e,对齐 §8)
48
+ - 812/812 全绿;lint 0 errors(44 warnings 均为 C 类 parity 锚点)
49
+
1
50
  ## [0.12.51] — 2026-08-30
2
51
 
3
52
  ### Added
package/README.md CHANGED
@@ -300,7 +300,7 @@ Code conventions: pure `.mjs`, no semicolons, no npm dependencies allowed (inclu
300
300
  - **CodeMode: sandboxed JS execution** — `execute` tool backed by `vm.Script.runInNewContext`. Compose multiple file operations (read/write/glob/grep/log) into a single script, reducing API round-trips and keeping intermediate results out of context. Sandbox strips all Node APIs, limits output to 50KB, enforces 30s timeout, and blocks private IPs in fetch. Script size capped at 50KB.
301
301
 
302
302
  ### 0.9.0 (2026-07)
303
- - **Config JSON Schema** — `saveConfig` auto-injects `$schema` reference; `docs/schemas/config.schema.json` provides editor autocompletion/validation for all config fields including the new `hooks` section.
303
+ - **Checkpoint v2** — snapshot-before-destructive with full workspace copies, per-file restore (`cat` preview), auto-recover on failed apply, and escape-hatch hints when git checks fail
304
304
  - **Lifecycle Hooks** — `PreToolUse` / `PostToolUse` / `PostToolUseFailure` / `Notification` events. User-defined shell commands in config, with per-tool regex matching, timeout control, and `block`/`allow`/`notify` actions. Implemented in `src/hooks.mjs`, integrated into tool dispatch.
305
305
  - **Built-in Skills (5)** — `pdf-create`, `xlsx-create`, `frontend-design`, `code-review`, `api-design` ship with the installation. Each is a standalone markdown instruction file using zero-dependency approaches (Chrome headless for PDF, PowerShell for Excel, etc.).
306
306
  - **Conversation message folding** — Long tool result blocks (>8 consecutive dim lines) auto-collapse to first 2 lines + "… N more lines — Enter to expand". `/fold on|off` toggles globally.
@@ -354,7 +354,7 @@ Code conventions: pure `.mjs`, no semicolons, no npm dependencies allowed (inclu
354
354
  - **Feat**: auto update check on startup + `/upgrade` command
355
355
 
356
356
  ### 0.8.3 (2026-07)
357
- - **Output panels**: tools with `outputPanel` flag stream to scrolled panel, auto-collapse to summary on completion (bash, long tool results)
357
+ - **Output panels**: tools with `outputPanel` flag stream to scrolled panel, auto-collapse to summary on completion (bash, long tool results)(机制已于 2026-08-30 废除——D6 清理,见 AGENT-LOOP.md)
358
358
  - **Checkpoint enhancements**: `cat` for file preview from snapshots, per-file rewind, auto-recover on apply failure, escape-hatch hints on errors
359
359
  - **Bash safety**: `checkpoint-before-destructive` discipline rule; bash guard guides checkpoint instead of just commit/stash
360
360
  - **Code review fixes**: output friendliness, readability, English-only strings, TUI polish
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thincoder",
3
- "version": "0.12.51",
3
+ "version": "0.12.53",
4
4
  "description": "Thin coding agent - zero dependencies, no build step, Node.js native. Sharp code, zero bloat.",
5
5
  "keywords": [
6
6
  "ai",
@@ -33,7 +33,7 @@
33
33
  "scripts": {
34
34
  "test": "node --test \"test/*.test.mjs\"",
35
35
  "prepublishOnly": "npm run lint && node --test \"test/*.mjs\"",
36
- "lint": "eslint src",
36
+ "lint": "eslint src test",
37
37
  "test:full": "node test/run-full.mjs"
38
38
  },
39
39
  "author": "liwei <liwei@51marine.com> (上海新舶)",
@@ -73,6 +73,7 @@ export function buildAcpCallbacks({ sessionId, notify, request, log = () => {} }
73
73
  // D7 (AGENT-LOOP.md §7.2): strip ⟦ev⟧ event tokens (bare or prefixed variants) —
74
74
  // they carry RS control characters and are a TUI display signal; structured ACP
75
75
  // mapping (tool_call_update) is tracked separately in docs/TODO.md.
76
+ // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
76
77
  if (/^(?:[\w-]+#\d+\/)?⟦ev⟧(?:turn|approval)\x1e/.test(text)) return
77
78
  update("agent_message_chunk", { content: { type: "text", text } })
78
79
  },
@@ -7,6 +7,7 @@ import { findProvider, specForModel } from "../config.mjs"
7
7
  import { toOpenAISchema } from "../tools/index.mjs"
8
8
  import { prepareAdvisorMessages } from "../advisor.mjs"
9
9
  import { appendCitationReport } from "./citations.mjs"
10
+ import { describeToolArgs } from "../tui/tool-args.mjs"
10
11
 
11
12
  const MAX_ADVISOR_TURNS = 100
12
13
  // Mechanical convergence cap: the protocol assumes up to 5 rounds suffice
@@ -97,16 +98,12 @@ function advisorToolsFor(agent) {
97
98
  // Test seam: the tool set is pure (agent.memory → code_search inclusion).
98
99
  export { advisorToolsFor as _advisorToolsFor }
99
100
 
100
- /** Compact one-line summary of tool args for panel progress lines.
101
- * Picks the most identifying field; falls back to truncated JSON. */
102
- function summarizeToolArgs(args) {
103
- // e.g. "read src/x.mjs", "grep foo src/", "ls docs" action first when present
104
- const parts = [args.action, args.path ?? args.pattern ?? args.command].filter((v) => v != null)
105
- let s = parts.length > 0 ? parts.map(String).join(" ") : JSON.stringify(args)
106
- s = s.replace(/\s+/g, " ").trim()
107
- return s.length > 80 ? s.slice(0, 79) + "…" : s
108
- }
109
-
101
+ /**
102
+ * Tool-call progress line summary delegates to the single source describeToolArgs
103
+ * (../tui/tool-args.mjs) — the same function main-agent tool blocks and subagent
104
+ * blocks use. 2026-08-31: replaced the local picker (action/path/pattern/command-only)
105
+ * so advisor progress lines show the quoted-path forms everywhere else.
106
+ */
110
107
  /**
111
108
  * Render the ordered review timeline — thinking / tool progress / final text
112
109
  * interleaved EXACTLY as emitted, so the persisted record shows the review
@@ -246,7 +243,8 @@ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, c
246
243
  continue
247
244
  }
248
245
 
249
- onTool(`\n→ ${tc.name} ${summarizeToolArgs(args)}\n`)
246
+ const argsLine = describeToolArgs(tc.name, args)
247
+ onTool(`\n→ ${tc.name}${argsLine ? " " + argsLine : ""}\n`)
250
248
  let result
251
249
  if (!tool) {
252
250
  result = `Error: unknown tool "${tc.name}". Available: ${[...toolByName.keys()].join(", ")}`
@@ -156,6 +156,13 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
156
156
  : "Error: no permission handler configured — this tool requires user approval but the current context doesn't support interaction (e.g. subagent or non-TUI mode)"
157
157
  return { ...item, result: reason, ok: false }
158
158
  }
159
+ // 2026-08-31 工具顺手度(用户批准"做吧"):dispatch 拦截工具执行期间的
160
+ // console.log/console.error——工具的探查/调试输出(原本只到终端、模型看不到)
161
+ // 收集后附在工具结果后回显给模型。bash 工具的输出走子进程回显(onOutput),
162
+ // 不走 dispatch console——拦截安全。嵌套 dispatch(subagent)各自拦截/恢复,
163
+ // 捕获分离(父恢复原始后子的拦截期间父捕获停止、子恢复后父继续)——正确。
164
+ // 声明在 try 之外:catch 块(异常路径)也要访问(报错前的探查输出回显)。
165
+ const capturedConsole = []
159
166
  try {
160
167
  // Snapshot for undo before side-effect tools (setupOutputPanel already fired in Phase 1)
161
168
  if (!item.tool?.readonly && item.args) {
@@ -170,26 +177,40 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
170
177
  return { ...item, result: routed.result, ok: true }
171
178
  }
172
179
  }
173
- const rawResult = await item.tool.execute(item.args, {
174
- cwd: agent.cwd,
175
- agent,
176
- depth,
177
- signal,
178
- callbacks,
179
- onOutput: (chunk) => callbacks.onToolOutput?.(item.toolCall.name, chunk, item.toolCall.id),
180
- onQuestion: callbacks.onQuestion,
181
- onPermissionRequest: callbacks.onPermissionRequest,
182
- })
180
+ const origConsoleLog = console.log
181
+ const origConsoleErr = console.error
182
+ console.log = (...a) => capturedConsole.push(a.map(String).join(" "))
183
+ console.error = (...a) => capturedConsole.push("[err] " + a.map(String).join(" "))
184
+ let rawResult
185
+ try {
186
+ rawResult = await item.tool.execute(item.args, {
187
+ cwd: agent.cwd,
188
+ agent,
189
+ depth,
190
+ signal,
191
+ callbacks,
192
+ onOutput: (chunk) => callbacks.onToolOutput?.(item.toolCall.name, chunk, item.toolCall.id),
193
+ onQuestion: callbacks.onQuestion,
194
+ onPermissionRequest: callbacks.onPermissionRequest,
195
+ })
196
+ } finally {
197
+ console.log = origConsoleLog
198
+ console.error = origConsoleErr
199
+ }
183
200
  if (rawResult === undefined) throw new Error(`Tool "${item.toolCall.name}" returned undefined — all tools must return a string value`)
184
201
  const raw = String(rawResult)
185
202
  // Multimodal tools keep the raw result (base64 images ride the multimodal
186
203
  // channel); everything else offloads oversized text to disk. Flag-driven, not
187
204
  // name-driven (consult P3, 2026-08-30).
188
205
  const result = item.tool?.multimodal ? raw : await offloadToolResult(raw, item.toolCall.id)
189
- callbacks.onToolResult?.(item.toolCall.name, result, item.toolCall.id)
206
+ // 2026-08-31:工具执行期间捕获的 console 输出附在结果后回显(模型视野)
207
+ const resultWithConsole = capturedConsole.length > 0
208
+ ? `${result}\n[console during ${item.toolCall.name}]\n${capturedConsole.join("\n")}`
209
+ : result
210
+ callbacks.onToolResult?.(item.toolCall.name, resultWithConsole, item.toolCall.id)
190
211
  // PostToolUse hooks: fire-and-forget (result not awaited on hook failure)
191
212
  runHooks("PostToolUse", { agent, toolName: item.toolCall.name, toolArgs: item.args, result: raw }).catch(() => {})
192
- return { ...item, result, ok: true }
213
+ return { ...item, result: resultWithConsole, ok: true }
193
214
  } catch (error) {
194
215
  // Persist to ~/.thincoder/tool-errors/ for post-mortem; only pass message to the model (stack traces confuse LLMs and may leak paths)
195
216
  logToolError(item.toolCall.name, item.args, error)
@@ -205,7 +226,11 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
205
226
  if (item.args.pattern) ctxParts.push(`pattern=${item.args.pattern}`)
206
227
  if (item.args.command) ctxParts.push(`cmd=${item.args.command.slice(0, 80)}`)
207
228
  const ctx = ctxParts.length > 0 ? ` [${ctxParts.join(", ")}]` : ""
208
- return { ...item, result: `Error: ${error.message}${ctx}`, ok: false }
229
+ // 2026-08-31:异常路径同样回显捕获的 console(工具报错前的探查输出最有价值)
230
+ const consolePart = capturedConsole.length > 0
231
+ ? `\n[console during ${item.toolCall.name}]\n${capturedConsole.join("\n")}`
232
+ : ""
233
+ return { ...item, result: `Error: ${error.message}${ctx}${consolePart}`, ok: false }
209
234
  }
210
235
  }
211
236
 
@@ -2,7 +2,7 @@
2
2
  * agent/helpers.mjs — Agent utility functions and constants
3
3
  */
4
4
  import { configDir } from "../config.mjs"
5
- import { readFileSync, readdirSync, existsSync } from "node:fs"
5
+ import { readFileSync, readdirSync } from "node:fs"
6
6
  import { homedir } from "node:os"
7
7
  import { writeFile, mkdir, readdir, stat, unlink } from "node:fs/promises"
8
8
  import { join } from "node:path"
@@ -11,9 +11,9 @@ import { pushReal } from "../context.mjs"
11
11
  import { toOpenAISchema } from "../tools/index.mjs"
12
12
  import { loadSkills, formatSkillListing } from "../skills.mjs"
13
13
  import {
14
- escapeXml, repairHistory, listWorkDir, readonlyToolNames,
14
+ escapeXml, repairHistory, listWorkDir,
15
15
  collectGitContext, loadProjectInstructions, OUTLINE_INJECT_PREFIX,
16
- DEFAULT_MAX_TURNS, DEFAULT_SUBAGENT_TURNS, ensureAutoReminder,
16
+ DEFAULT_MAX_TURNS, ensureAutoReminder,
17
17
  } from "./helpers.mjs"
18
18
  import { readFileSync, existsSync } from "node:fs"
19
19
  import { resolve, dirname } from "node:path"
@@ -38,7 +38,6 @@ function selectConsultModels(pool, selectors) {
38
38
  const seen = new Set()
39
39
  const unknowns = []
40
40
  for (const raw of list) {
41
- // eslint-disable-next-line no-control-regex -- fixed non-control suffix
42
41
  const s = String(raw).replace(/\s+\([^)]*\)\s*$/, "").trim().toLowerCase()
43
42
  const matches = pool.filter((m) =>
44
43
  consultLabel(m).toLowerCase() === s ||
@@ -1,4 +1,4 @@
1
- import { loadSkills, formatSkillListing, readSkill } from "../skills.mjs"
1
+ import { loadSkills, readSkill } from "../skills.mjs"
2
2
  import { escapeXml } from "../agent.mjs"
3
3
 
4
4
  /**
@@ -1,5 +1,3 @@
1
- const VALID_TASK_STATUS = new Set(["pending", "in_progress", "done"])
2
-
3
1
  /** Common synonyms LLMs tend to use — normalize to canonical values */
4
2
  const STATUS_ALIASES = {
5
3
  completed: "done",
@@ -1,4 +1,3 @@
1
- import { repairHistory, listWorkDir } from "../agent.mjs"
2
1
  import { isDocFile } from "../advisor/repos.mjs"
3
2
  import { execSync, spawn, spawnSync } from "node:child_process"
4
3
  import { readFileSync, existsSync } from "node:fs"
package/src/agent.mjs CHANGED
@@ -3,7 +3,6 @@
3
3
  * LLM ↔ tool-call loop, until the task is done.
4
4
  */
5
5
  import { chat } from "./provider/index.mjs"
6
- import { estimateText } from "./provider/rate.mjs"
7
6
  import { compressIfNeeded, compressFallback, COMPRESS_FAILURE_LIMIT, pushReal, summarizeRunExplorations } from "./context.mjs"
8
7
  import { specForModel } from "./config.mjs"
9
8
  import { readFileSync } from "node:fs"
@@ -11,8 +10,9 @@ import { join, dirname } from "node:path"
11
10
  import { fileURLToPath } from "node:url"
12
11
  import { executeToolCalls } from "./agent/dispatch.mjs"
13
12
  import { recordToolResults } from "./agent/record-results.mjs"
13
+ import { FILE_MUTATORS } from "./agent/helpers.mjs"
14
14
  import { prepareRun } from "./agent/setup.mjs"
15
- import { injectPostTurn, STALL_WINDOW_SIZE, STALL_THRESHOLD, GOAL_BUDGET_WARN_RATIO } from "./agent/post-turn.mjs"
15
+ import { injectPostTurn } from "./agent/post-turn.mjs"
16
16
  import { handleCompletion } from "./agent/completion.mjs"
17
17
  import { cleanupConsultSessions } from "./agent-tools/consult.mjs"
18
18
  import {
@@ -43,7 +43,7 @@ export const CONSULT_BASE = _CONSULT_BASE
43
43
  // exported for consumption by agent-tools.mjs
44
44
  export {
45
45
  ContinueError,
46
- repairHistory, listWorkDir, loadProjectInstructions,
46
+ listWorkDir, loadProjectInstructions,
47
47
  readonlyToolNames, collectGitContext, escapeXml,
48
48
  MIN_REPORT_CHARS, REPORT_CONTINUATION, DEFAULT_SUBAGENT_TURNS,
49
49
  }
@@ -257,6 +257,20 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
257
257
  throw e
258
258
  }
259
259
 
260
+ // 内置工具(Responses web_search)结果本地化:服务端已执行——入历史为 tool 消息,
261
+ // 模型下一轮可见;全量回传时 transport 依 tool_call_id 前缀还原 web_search_call item。
262
+ // 注意:服务端 item id 是 msg_xxx 非 web_search_call_ 前缀——必须合成前缀(toItems 识别锚点),
263
+ // 原始 id 存入 content(真机冒烟 2026-08-31:直接用 msg_xxx 会被转成 function_call_output
264
+ // 与服务端不配对,属蒙对)。
265
+ for (const btr of response.builtinToolResults ?? []) {
266
+ if (!btr?.id) continue
267
+ pushReal(agent, {
268
+ role: "tool",
269
+ tool_call_id: `web_search_call_${btr.id}`,
270
+ content: JSON.stringify({ id: btr.id, query: btr.query ?? "", sources: btr.sources ?? [], status: btr.status ?? "completed" }),
271
+ })
272
+ }
273
+
260
274
  // Stream rule triggered mid-generation (action: "abort"): halt current output,
261
275
  // inject rule's message as a reminder, and retry from the same context.
262
276
  if (response.ruleTriggered) {
@@ -358,6 +372,25 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
358
372
  // Ctrl+I interrupt during tool execution: skip committing partial results —
359
373
  // the tool failure messages would mislead the model. Inject the interrupt and retry.
360
374
  if (signal?.reason?.interrupt) {
375
+ // 中断变更记账(2026-08-31 评审 #4):此分支的工具已全部执行完成(磁盘已变,execute 已完成),
376
+ // 真实结果按语义不进历史(placeholder 替代)——但变更必须记账:否则 guard 看到
377
+ // "本轮未改代码" 放行,评审/verify 门禁被绕过(文件改了却没评审)。
378
+ for (const { toolCall, ok } of results) {
379
+ const tool = toolByName.get(toolCall.name)
380
+ if (!ok || !tool || !FILE_MUTATORS.has(toolCall.name)) continue
381
+ agent._mutatedThisRun = true
382
+ agent._calledAdvisorThisRun = false
383
+ agent._verifiedThisRun = false
384
+ agent._verifyPassed = undefined
385
+ try {
386
+ const args = JSON.parse(toolCall.arguments)
387
+ const paths = tool.touchedPaths ? tool.touchedPaths(args) : [args.path]
388
+ for (const p of paths) {
389
+ const abs = join(agent.cwd, p)
390
+ if (!agent._touchedFiles.includes(abs)) agent._touchedFiles.push(abs)
391
+ }
392
+ } catch { /* 畸形 args 不影响记账(touchedFiles 尽力而为) */ }
393
+ }
361
394
  // The assistant tool_calls were already committed above (L347) — a strict
362
395
  // provider 400s on dangling tool_calls, so synthesize placeholder tool
363
396
  // results BEFORE the interrupt message (tool result must immediately
@@ -60,11 +60,17 @@ export async function assembleAgent() {
60
60
  if (existsSync(mcpJsonPath)) {
61
61
  const mcpJson = JSON.parse(readFileSync(mcpJsonPath, "utf8"))
62
62
  if (mcpJson.mcpServers && typeof mcpJson.mcpServers === "object") {
63
- const configNames = new Set(mcpServers.map((s) => s.name))
64
- for (const [name, server] of Object.entries(mcpJson.mcpServers)) {
65
- if (configNames.has(name)) continue // config.json takes priority
66
- if (!server || typeof server !== "object") continue
67
- mcpServers.push({ name, ...server })
63
+ // 2026-08-31 MCP 会诊 #10:数组型 mcpServers 不是规范形态——Object.entries 会产出
64
+ // "0"/"1" 数字名(变成工具前缀 "0_tool"),必须跳过;server 条目嵌套数组同理。
65
+ if (Array.isArray(mcpJson.mcpServers)) {
66
+ console.error("[mcp] .mcp.json: mcpServers must be a plain object, got array — skipped")
67
+ } else {
68
+ const configNames = new Set(mcpServers.map((s) => s.name))
69
+ for (const [name, server] of Object.entries(mcpJson.mcpServers)) {
70
+ if (configNames.has(name)) continue // config.json takes priority
71
+ if (!server || typeof server !== "object" || Array.isArray(server)) continue
72
+ mcpServers.push({ name, ...server })
73
+ }
68
74
  }
69
75
  }
70
76
  }
package/src/config.mjs CHANGED
@@ -75,110 +75,18 @@ export const DEFAULTS = {
75
75
  },
76
76
  }
77
77
 
78
- /**
79
- * Known model capability spec table (prefix match, longer first).
80
- * Used for compaction threshold derivation, continuation protocol selection, and capability-aware optimization.
81
- *
82
- * context: context window (tokens)
83
- * maxOutput: max output tokens (defaults to context)
84
- * thinking: whether thinking/reasoning mode is supported
85
- * partialMode: Kimi/Qwen Partial Mode truncation continuation (assistant message with partial:true)
86
- * prefixMode: DeepSeek Prefix Completion truncation continuation (uses /beta endpoint, with prefix:true)
87
- * multimodal: whether multimodal (image/vision input supported)
88
- * cacheMode: context caching mode: "auto"=automatic / "prompt"=needs explicit / "none"=unsupported
89
- * thinkApi: thinking API type: "type"=thinking.type field / "effort"=reasoning_effort field
90
- * thinkEnabledValue: when thinkApi is "type", the value used to enable thinking (default "enabled"; MiniMax uses "adaptive")
91
- * reasoningEcho: reasoning_content cross-turn echo strategy: "required"=must echo (error if missing) / "optional"=echo optional (default: don't echo)
92
- * reasoningEffortEnum: valid reasoning_effort enum values (if undeclared, no validation — passed through as-is)
93
- * tempRange: valid temperature range [min, max] (if undeclared, no clamping)
94
- */
95
- const MODEL_SPECS = [
96
- // DeepSeek V4 series
97
- ["deepseek-v4-pro", { context: 1_000_000, maxOutput: 384_000, thinking: true, prefixMode: true, cacheMode: "auto", thinkApi: "type", reasoningEcho: "required", reasoningEffortEnum: ["low", "high", "max"], tempRange: [0, 2] }],
98
- ["deepseek-v4-flash", { context: 1_000_000, maxOutput: 384_000, thinking: true, prefixMode: true, cacheMode: "auto", thinkApi: "type", reasoningEcho: "required", reasoningEffortEnum: ["low", "high", "max"], tempRange: [0, 2] }],
99
- // DeepSeek V4 Flash Vision (experimental) — image input on top of the full V4-Flash stack
100
- ["deepseek-v4-flash-vision-exp", { context: 1_000_000, maxOutput: 384_000, thinking: true, prefixMode: true, cacheMode: "auto", thinkApi: "type", reasoningEcho: "required", reasoningEffortEnum: ["low", "high", "max"], tempRange: [0, 2], multimodal: true }],
101
- // Kimi series
102
- ["kimi-k3", { context: 1_000_000, maxOutput: 131_072, thinking: true, partialMode: true, multimodal: true, cacheMode: "auto", thinkApi: "effort", reasoningEcho: "required", reasoningEffortEnum: ["low", "high", "max"] }],
103
- // Qwen router prefixes model IDs with provider namespace: kimi/kimi-k3 → kimi-k3 (IK7K4V)
104
- ["kimi/kimi-k3", { context: 1_000_000, maxOutput: 131_072, thinking: true, partialMode: true, multimodal: true, cacheMode: "auto", thinkApi: "effort", reasoningEcho: "required", reasoningEffortEnum: ["low", "high", "max"] }],
105
- // Kimi For Coding endpoint uses the short model ID "k3" (same specs as kimi-k3) — IK5VGJ
106
- ["k3", { context: 1_000_000, maxOutput: 131_072, thinking: true, partialMode: true, multimodal: true, cacheMode: "auto", thinkApi: "effort", reasoningEcho: "required", reasoningEffortEnum: ["low", "high", "max"] }],
107
- // GLM series
108
- // GLM-5.3: thinking always-on (no "disabled"); effort converges to low/high/max — NOT the
109
- // 7-level glm-5.2 enum (verified vs docs.bigmodel.cn GLM-5.3 page, 2026-08)
110
- ["glm-5.3", { context: 1_000_000, maxOutput: 128_000, thinking: true, cacheMode: "auto", thinkApi: "type", reasoningEcho: "optional", reasoningEffortEnum: ["low", "high", "max"], tempRange: [0, 1], noUsageStream: true }],
111
- ["glm-5.3-flash", { context: 1_000_000, maxOutput: 128_000, thinking: true, multimodal: true, cacheMode: "auto", thinkApi: "type", reasoningEcho: "optional", reasoningEffortEnum: ["low", "high", "max"], tempRange: [0, 1], noUsageStream: true }],
112
- ["glm-5.2", { context: 1_000_000, maxOutput: 128_000, thinking: true, cacheMode: "auto", thinkApi: "type", reasoningEcho: "optional", reasoningEffortEnum: ["max", "xhigh", "high", "medium", "low", "minimal", "none"], tempRange: [0, 1], noUsageStream: true }],
113
- ["glm-5", { context: 1_000_000, maxOutput: 128_000, thinking: true, cacheMode: "auto", thinkApi: "type", reasoningEcho: "optional", reasoningEffortEnum: ["max", "xhigh", "high", "medium", "low", "minimal", "none"], tempRange: [0, 1], noUsageStream: true }],
114
- ["glm-4", { context: 128_000, maxOutput: 32_000, thinking: true, cacheMode: "auto", thinkApi: "type", reasoningEcho: "optional", tempRange: [0, 1], noUsageStream: true }],
115
- // GPT series
116
- ["gpt-5.6-sol", { context: 1_050_000, maxOutput: 128_000, thinking: false, multimodal: true, cacheMode: "prompt" }],
117
- ["gpt-5.6", { context: 1_050_000, maxOutput: 128_000, thinking: false, multimodal: true, cacheMode: "prompt" }],
118
- ["gpt-4.1", { context: 1_000_000, maxOutput: 128_000, thinking: false, cacheMode: "prompt" }],
119
- ["gpt-4o", { context: 128_000, maxOutput: 16_000, thinking: false, multimodal: true, cacheMode: "prompt" }],
120
- // Qwen series
121
- ["qwen3.8-max-preview", { context: 1_000_000, maxOutput: 131_072, thinking: true, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", reasoningEffortEnum: ["xhigh", "medium", "low"], tempRange: [0, 2] }],
122
- // qwen3.7-max rejects image parts outright (DashScope 400 "Unexpected item type in content") — text-only
123
- ["qwen3.7-max", { context: 1_000_000, maxOutput: 131_072, thinking: true, partialMode: true, cacheMode: "none", thinkApi: "effort", reasoningEffortEnum: ["xhigh", "high"], tempRange: [0, 2] }],
124
- ["qwen3.8-max", { context: 1_000_000, maxOutput: 131_072, thinking: true, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", reasoningEffortEnum: ["xhigh", "medium", "low"], tempRange: [0, 2] }],
125
- ["qwen-max", { context: 1_000_000, maxOutput: 131_072, thinking: false, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", tempRange: [0, 2] }],
126
- ["qwen-plus", { context: 1_000_000, maxOutput: 131_072, thinking: false, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", tempRange: [0, 2] }],
127
- ["qwen", { context: 1_000_000, maxOutput: 131_072, thinking: false, partialMode: true, multimodal: true, cacheMode: "none", thinkApi: "effort", tempRange: [0, 2] }],
128
- // MiniMax series
129
- ["MiniMax-M3", { context: 1_000_000, maxOutput: 128_000, thinking: true, multimodal: true, cacheMode: "auto", thinkApi: "type", thinkEnabledValue: "adaptive", tempRange: [0, 2], noUsageStream: true }],
130
- // MiMo series (Xiaomi — OpenAI-compatible https://api.xiaomimimo.com/v1;
131
- // deep thinking via thinking.type, default ON; multi-turn tool calls MUST echo
132
- // reasoning_content back exactly like DeepSeek V4, else 400 on follow-ups)
133
- ["mimo-v2.5-pro", { context: 1_000_000, maxOutput: 128_000, thinking: true, thinkApi: "type", reasoningEcho: "required", tempRange: [0, 1.5] }],
134
- ["mimo-v2.5", { context: 1_000_000, maxOutput: 128_000, thinking: true, multimodal: true, thinkApi: "type", reasoningEcho: "required", tempRange: [0, 1.5] }],
135
- ["minimax-m3", { context: 1_000_000, maxOutput: 128_000, thinking: true, multimodal: true, cacheMode: "auto", thinkApi: "type", thinkEnabledValue: "adaptive", tempRange: [0, 2], noUsageStream: true }],
136
- ["minimax-m1", { context: 256_000, maxOutput: 128_000, thinking: false, cacheMode: "auto", noUsageStream: true }],
137
- // Grok series (xAI — OpenAI-compatible)
138
- // grok-4.x: 500K context per xAI Grok 4.6 spec (corrected 2026-08; earlier entries said 1M)
139
- ["grok-4.6", { context: 500_000, maxOutput: 64_000, thinking: false, multimodal: true, tempRange: [0, 2] }],
140
- ["grok-4.5", { context: 500_000, maxOutput: 64_000, thinking: false, multimodal: true, tempRange: [0, 2] }],
141
- ["grok-4", { context: 500_000, maxOutput: 64_000, thinking: false, multimodal: true, tempRange: [0, 2] }],
142
- ["grok-4-mini", { context: 128_000, maxOutput: 16_000, thinking: false, tempRange: [0, 2] }],
143
- // Mistral series (OpenAI-compatible)
144
- ["mistral-large", { context: 128_000, maxOutput: 32_000, thinking: false, multimodal: true, tempRange: [0, 2] }],
145
- ["codestral", { context: 256_000, maxOutput: 32_000, thinking: false, tempRange: [0, 2] }],
146
- // Claude series (Anthropic)
147
- ["claude-opus-5", { context: 1_000_000, maxOutput: 128_000, thinking: false, multimodal: true, cacheMode: "none", format: "anthropic" }],
148
- ["claude-sonnet-5", { context: 1_000_000, maxOutput: 128_000, thinking: false, multimodal: true, cacheMode: "none", format: "anthropic" }],
149
- ["claude-opus-4", { context: 200_000, maxOutput: 32_000, thinking: false, multimodal: true, cacheMode: "none", format: "anthropic" }],
150
- ["claude-sonnet-4", { context: 200_000, maxOutput: 32_000, thinking: false, multimodal: true, cacheMode: "none", format: "anthropic" }],
151
- ["claude-3.5-haiku", { context: 200_000, maxOutput: 8_192, thinking: false, cacheMode: "none", format: "anthropic" }],
152
- // Gemini series (Google)
153
- ["gemini-3-pro", { context: 1_000_000, maxOutput: 64_000, thinking: false, multimodal: true, cacheMode: "none", format: "google", noUsageStream: true }],
154
- ["gemini-2.5-pro", { context: 2_000_000, maxOutput: 64_000, thinking: false, multimodal: true, cacheMode: "none", format: "google", noUsageStream: true }],
155
- ["gemini-2.5-flash", { context: 1_000_000, maxOutput: 64_000, thinking: false, multimodal: true, cacheMode: "none", format: "google", noUsageStream: true }],
156
- ]
157
- const DEFAULT_SPEC = { context: 128_000, maxOutput: 32_000, cacheMode: "none" }
78
+ // Model capability table + spec lookup live in model-specs.mjs (2026-08-31
79
+ // extract config.mjs had grown past the 300-line advisory). Re-exported here
80
+ // so the 23 existing importers keep their import paths.
81
+ import { specForModel } from "./model-specs.mjs"
82
+ export { specForModel }
83
+
84
+
158
85
  // Window utilization threshold: compacts at 60% context, reserving 40% headroom
159
86
  // for injected context (directory tree, git context, outline, project instructions,
160
87
  // memory/doc search results) which can consume 30-50K tokens each turn.
161
88
  const COMPACT_RATIO = 0.6
162
89
 
163
- /** Look up spec by model name prefix (case-insensitive), conservative default for unknown models */
164
- const warnedModels = new Set() // warn once per model name — specForModel is a hot path (every request)
165
- // Pre-sorted once at module scope — specForModel runs on every request (agent, provider core,
166
- // context, auto-think, TUI rendering); re-sorting per call was wasteful.
167
- const SORTED_SPECS = [...MODEL_SPECS].sort((a, b) => b[0].length - a[0].length)
168
- export function specForModel(model) {
169
- const m = (model ?? "").toLowerCase()
170
- for (const [prefix, spec] of SORTED_SPECS) {
171
- if (m.startsWith(prefix.toLowerCase())) return spec
172
- }
173
- // Unknown model: warn ONCE (not per request) so a typo'd ID or a missing alias surfaces
174
- // instead of silently degrading to the 128K default (IK5VGJ).
175
- if (m && !warnedModels.has(m)) {
176
- warnedModels.add(m)
177
- console.warn(`[config] model "${model}" not found in MODEL_SPECS — using default spec (128K context, 32K output). Check the model ID or add an alias.`)
178
- }
179
- return DEFAULT_SPEC
180
- }
181
-
182
90
  /** Derive compaction threshold; explicit is the value explicitly set in config file (takes priority), otherwise auto-computed from model */
183
91
  export function resolveCompactThreshold(explicit, model) {
184
92
  if (explicit != null) return { value: explicit, auto: false }
@@ -348,10 +256,7 @@ export function loadConfig() {
348
256
  */
349
257
  export function saveConfig(config) {
350
258
  mkdirSync(configDir, { recursive: true })
351
- // Inject $schema for editor autocompletion/validation (strip on load) — write a copy,
352
- // never mutate the caller's object.
353
- const out = { ...config, $schema: "https://thincoder.dev/schemas/config.json" }
354
259
  // 0600: config.json contains API keys, must not be world-readable (POSIX; chmod is best-effort on Windows)
355
- writeFileSync(configPath, JSON.stringify(out, null, 2) + "\n", { encoding: "utf8", mode: 0o600 })
260
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", { encoding: "utf8", mode: 0o600 })
356
261
  try { chmodSync(configPath, 0o600) } catch { /* may fail on Windows, ignore */ }
357
262
  }
@@ -27,13 +27,22 @@ export function quoteArg(s) {
27
27
  return /[\s"]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
28
28
  }
29
29
 
30
- /** Append a Bearer token as a query parameter to a WebSocket URL */
30
+ /** Convert an Authorization Bearer token into a WebSocket subprotocol.
31
+ * 2026-08-31 MCP 会诊 #10:原实现把 token 塞进 URL query——代理/网关日志会泄露凭证,
32
+ * 且无标准依据。Node 内置 WebSocket(undici)无法自定义请求头,MCP 生态的标准替代
33
+ * 通道是 subprotocol(`bearer.<token>`)。用户 URL 自带的 query token 不动(兼容)。
34
+ * @returns {{ url: string, protocols: string[] }} — protocols 为空数组表示无认证。 */
31
35
  export function withAuthToken(wsUrl, authorization) {
32
- if (!authorization) return wsUrl
36
+ if (!authorization) return { url: wsUrl, protocols: [] }
33
37
  const token = authorization.replace(/^Bearer\s+/i, "")
34
- const u = new URL(wsUrl)
35
- u.searchParams.set("token", token)
36
- return u.href
38
+ let u
39
+ try {
40
+ u = new URL(wsUrl)
41
+ } catch {
42
+ throw new Error(`Invalid WebSocket URL: ${String(wsUrl).slice(0, 120)}`)
43
+ }
44
+ // subprotocol 不进入 URL/日志,token 不再注入 query
45
+ return { url: u.href, protocols: [`bearer.${token}`] }
37
46
  }
38
47
 
39
48
  /** Sanitize a tool name: replace non-alphanumeric chars with underscores, cap at 64 chars */