thincoder 0.12.58 → 0.12.59

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 (114) hide show
  1. package/CHANGELOG.md +42 -1
  2. package/README.md +1 -1
  3. package/bin/thincoder.mjs +8 -0
  4. package/package.json +1 -1
  5. package/src/acp/bridge.mjs +132 -26
  6. package/src/advisor/messages.mjs +34 -1
  7. package/src/advisor/run.mjs +89 -51
  8. package/src/advisor.mjs +15 -7
  9. package/src/agent/dispatch.mjs +91 -14
  10. package/src/agent/helpers.mjs +35 -4
  11. package/src/agent/setup.mjs +90 -19
  12. package/src/agent/spawn-child.mjs +25 -0
  13. package/src/agent-tools/advisor.mjs +24 -2
  14. package/src/agent-tools/consult.mjs +37 -6
  15. package/src/agent-tools/eng.mjs +2 -1
  16. package/src/agent-tools/goal.mjs +11 -1
  17. package/src/agent-tools/read-history.mjs +160 -0
  18. package/src/agent-tools/settings.mjs +162 -0
  19. package/src/agent-tools/skill.mjs +2 -1
  20. package/src/agent-tools/subagent-actions.mjs +432 -0
  21. package/src/agent-tools/subagent-async.mjs +427 -0
  22. package/src/agent-tools/subagent-scheduler.mjs +319 -0
  23. package/src/agent-tools/subagent.mjs +467 -193
  24. package/src/agent-tools/task.mjs +4 -3
  25. package/src/agent-tools/timer.mjs +9 -4
  26. package/src/agent-tools/verify.mjs +161 -49
  27. package/src/agent-tools.mjs +1 -0
  28. package/src/agent.mjs +161 -125
  29. package/src/auto-think.mjs +14 -0
  30. package/src/cli/make-agent.mjs +2 -1
  31. package/src/cli/permission.mjs +8 -1
  32. package/src/config.mjs +5 -0
  33. package/src/context.mjs +87 -27
  34. package/src/distill.mjs +19 -1
  35. package/src/escape.mjs +6 -4
  36. package/src/log.mjs +195 -0
  37. package/src/memory/code-sync.mjs +1 -1
  38. package/src/memory/core.mjs +126 -0
  39. package/src/memory/docs.mjs +196 -87
  40. package/src/memory.mjs +1 -1
  41. package/src/model-specs.mjs +15 -1
  42. package/src/prompts/advisor-design.md +46 -0
  43. package/src/prompts/advisor-round1.md +49 -2
  44. package/src/prompts/advisor-round2.md +47 -0
  45. package/src/prompts/advisor-round3.md +47 -0
  46. package/src/prompts/coder.md +22 -0
  47. package/src/prompts/consult-base.md +13 -0
  48. package/src/prompts/discipline.md +10 -5
  49. package/src/prompts/eng-coder.md +2 -2
  50. package/src/prompts/engineering-sub.md +23 -1
  51. package/src/prompts/engineering.md +106 -56
  52. package/src/prompts/explore.md +1 -2
  53. package/src/prompts/main.md +11 -6
  54. package/src/prompts/methodology-template.md +14 -0
  55. package/src/prompts/system.md +4 -2
  56. package/src/provider/core.mjs +56 -2
  57. package/src/tools/apply_patch.md +3 -1
  58. package/src/tools/bash.md +1 -1
  59. package/src/tools/delete.md +1 -0
  60. package/src/tools/edit-batch.mjs +31 -43
  61. package/src/tools/edit-diff.mjs +265 -0
  62. package/src/tools/edit.md +10 -8
  63. package/src/tools/execute.md +7 -7
  64. package/src/tools/execute.mjs +24 -20
  65. package/src/tools/file.mjs +18 -68
  66. package/src/tools/file_ops.md +2 -1
  67. package/src/tools/get_current_time.md +3 -1
  68. package/src/tools/hashline_edit.md +2 -0
  69. package/src/tools/index.mjs +3 -2
  70. package/src/tools/insert_after.md +2 -1
  71. package/src/tools/lint.md +2 -0
  72. package/src/tools/lsp.md +4 -1
  73. package/src/tools/patch.mjs +84 -13
  74. package/src/tools/pdf-parse-text.mjs +497 -0
  75. package/src/tools/pdf-parse-xref.mjs +499 -0
  76. package/src/tools/pdf.mjs +155 -0
  77. package/src/tools/question.md +2 -1
  78. package/src/tools/read.md +1 -0
  79. package/src/tools/read_pdf.md +21 -0
  80. package/src/tools/repomap.mjs +1 -1
  81. package/src/tools/shared.mjs +4 -12
  82. package/src/tools/system.mjs +6 -21
  83. package/src/tools/tree.md +2 -1
  84. package/src/tools/web.mjs +5 -3
  85. package/src/tools/websearch.md +2 -1
  86. package/src/tools/write.md +2 -0
  87. package/src/traces/trace-store.mjs +224 -0
  88. package/src/tui/agent-turn.mjs +385 -22
  89. package/src/tui/clipboard.mjs +15 -4
  90. package/src/tui/cmd-config.mjs +29 -9
  91. package/src/tui/cmd-extract.mjs +1 -1
  92. package/src/tui/cmd-mcp.mjs +9 -0
  93. package/src/tui/cmd-think.mjs +1 -1
  94. package/src/tui/index.mjs +29 -95
  95. package/src/tui/interaction.mjs +13 -2
  96. package/src/tui/key-handler.mjs +105 -155
  97. package/src/tui/key-modes.mjs +215 -0
  98. package/src/tui/layout.mjs +22 -1
  99. package/src/tui/mouse.mjs +40 -0
  100. package/src/tui/pickers.mjs +11 -3
  101. package/src/tui/render-conversation.mjs +13 -161
  102. package/src/tui/render-frame.mjs +27 -10
  103. package/src/tui/render-loop.mjs +4 -1
  104. package/src/tui/render-segments.mjs +165 -0
  105. package/src/tui/startup.mjs +36 -0
  106. package/src/tui/subagent-blocks.mjs +322 -144
  107. package/src/tui/subagent-panel.mjs +88 -13
  108. package/src/tui/tool-args.mjs +10 -2
  109. package/src/tui/tool-events.mjs +132 -100
  110. package/src/tui/update-notice.mjs +72 -0
  111. package/src/tui/wizard.mjs +36 -6
  112. package/src/agent-tools/escalate.mjs +0 -179
  113. package/src/agent-tools/subagent-check.mjs +0 -107
  114. package/src/tools/exec-prelude.mjs +0 -84
package/CHANGELOG.md CHANGED
@@ -1,4 +1,45 @@
1
- ## [0.12.58] — 2026-09-02
1
+ ## [0.12.59] — 2026-09-05
2
+
3
+ > 0.12.58 → 0.12.59(§1.5 连续号——发布时定号)
4
+
5
+ ### Added
6
+
7
+ - **settings 工具(SETTINGS-TOOL.md——2026-09-05 用户三项裁定)**:agent 配置调整通道——list/get/set(全量 config.json 任意键点分路径);set = 写盘 + 热应用(运行中即生效);敏感键(apiKey/token/secret/password 段)回显永遮罩;已知键类型校验(DEFAULTS 自动派生);list/get 只读动作(planMode 放行);set 审批门。测试 T-S1.1-11(CLI 11 + VS Code 6)。
8
+ - **subagent-async 模块拆分(§20.9 Module Split Policy——2026-09-05 F-N1.5 两段式首批)**:CLI subagent-async.mjs 1020 → 405 行(subagent-scheduler.mjs 231——§20 调度组 + 文件域组 / subagent-actions.mjs 416——status/panel/escalate 动作执行器组)——纯迁移零行为变化(测试零改动 + 断言计数前后对拍一致)。
9
+ - **P-SL2 停滞机械检测(AGENT-LOOP §21.1 扩展注 P-SL2——2026-09-05)**:混合边环形等待停滞 → check/status 守卫明确报错列阻塞链(cancel 破环引导)——防御性(自然流不可达——人工注入可构造)。测试 T-SL2 ①-⑤(scheduler 22 → 27 用例)。
10
+
11
+ ### Changed
12
+
13
+ - **轨迹存档隐私默认翻转 + 启动清理(AGENT-LOOP.md §18.6 D-TR6/D-TR10——2026-09-05 用户"不希望用户那边也采集")**:`traces.enabled` 默认 **on → off**(发布后新用户零采集——本地分析可显式开);新增 `traces.retentionHours`(默认 24h——保留期可配置);CLI 启动时删除超过保留期的轨迹文件(`cleanupTraces`——D-TR10——fire-and-forget 不阻塞启动——空日期目录一并清除);`/config` 菜单新增 traces.enabled(开关)与 traces.retentionHours(保留期)两项。测试 T-TR15。
14
+ - **edit 空白差异自动落点(TOOLS.md §14.2——P15.11——2026-09-05 用户裁定)**:edit not-found 且文件存在**唯一内容相同仅空白不同**窗口 → 自动落点应用 + 结果附 note(`whitespace-only match`——双端同句);歧义(多窗口)/实质差异仍 not-found(不猜)。测试 P15.11a-d 双端。
15
+ - **编辑纪律三条固化进 discipline.md(AGENT-LOOP.md §21 扩展注 2——2026-09-05 记忆清空实验)**:① 新鲜读来源(never reconstruct from memory);② hash 来源(never invent one);③ 重试上限(never retry the identical input a third time)。测试 T-N1.8 双端。
16
+ - **普通模式两段式(AGENT-LOOP.md §21 F-N1.5——2026-09-05 用户"把 coder 用起来……解决自查问题")**:规模实现批次默认委托 coder 子代理(async——设计书为 task book)——执行/检查心智分离(隔离上下文破自查盲区);小改动/探索留内联;复核走 F-N1.4(偏差退回 coder ≤2 轮)。测试 T-N1.9 双端。
17
+ - **委托操作标准(AGENT-LOOP.md §21 扩展注 4——F-N1.6——2026-09-05 用户"spawn coder 干活现在并没有明确的标准是吗")**:规模判据可操作化(≥2 文件/单文件 >30 行逻辑/模块边界/双端镜像 → 委托;≤30 行/文档同步/探索 → 内联);任务书七字段标准(目标/已知事实/设计+禁止/约束/硬验收/报告格式/调度元数据——缺字段=委托缺陷);委托模式判据(async 默认/sync 仅依赖链/并行仅文件域互斥);通用验收基线。测试 T-N1.10 双端。
18
+ - **/config 交互修复(2026-09-05)**:改配置项保存后回主菜单(不再退到输入框)——每轮刷新 ac/tc 配置引用(reloadConfig 换对象后显示新值);view 浏览态同回菜单。
19
+ - **版本号连续性规则(RELEASE.md §1.5——2026-09-05 用户裁定"不要跳号")**:号在发布时定、开发期不预占(CHANGELOG 挂 [Unreleased]);待发号 = registry 最高 + 1;缺口不补。
20
+
21
+ ### Fixed
22
+
23
+ - **§18.14 域拆分 import 残留两处(2026-09-05 发版 test:full 门禁抓出——slow 门控测试快层永不执行致漏网)**:session.test.mjs 缺 existsSync + advisor-review.test.mjs 缺 prepareAdvisorMessages。
24
+ - **压缩 fixture 阈值重校准 14000 → 15500(T3b——2026-09-05)**:提示词批(F-N1.4/1.5/1.6 main.md 委托句 + discipline 三条纪律 ≈550 token)抬升 systemPrompt 估算越刀锋——沿革惯例 +1500 档。
25
+
26
+ ### Changed (追加——2026-09-04 后半批)
27
+
28
+ - **advisor 角色定位锚(AGENT-LOOP.md §12.1)**:四模板(advisor-design/round1/round2/round3)开头插入"Your role"段(独立评审者/证据纪律/边界/中立——Known behavior 记忆断言禁止)——修复"评审对象模糊/定级拉锯/行为验证捷径/出界犹豫"人格缺口。
29
+ - **子代理人格锚(AGENT-LOOP.md §7.3)**:coder.md(IMPLEMENTER——证据纪律/设计冲突停报/边界)+ consult-base.md(证据纪律/I-don't-know 合法/无权威)——审计发现"职责越重人格越薄"倒挂。
30
+ - **审计范围引导(AGENT-LOOP.md §18.13)**:eng-coder 内部审计 explore 改 quick 档 + 任务书加机械预算句(只读 touched 文件+点名节——10 轮上限——超时报 PROBLEM)——治理"审计 explore 跑非常久"。
31
+ - **工具输出预览保头保尾(TOOL-OUTPUT-LIMITS-TUNING.md §5)**:超 64K 落盘预览 = 头 16K + 中间省略注 + 尾(tail 优先——测试统计/错误在尾不再被截)——与压缩/蒸馏同口径。
32
+ - **bash 重定向护栏删除(TOOLS.md §13)**:`hasFileRedirection` 整条删除(用户裁定"拦截只误伤正常操作"——零文本拦截与 §5 安全剧场决策彻底对齐)——`2>&1` 误报、测试收窄被拦整链消除。
33
+
34
+ ### Added
35
+
36
+ - **完整轨迹存档(AGENT-LOOP.md §18.6)**:`chat()` 出口统一收集——每次模型调用落 JSONL 到 `~/.thincoder/traces/YYYY-MM-DD/`(本地时区分日/seq=当日 max+1/脱敏复用 log.mjs 黑名单+SECRET_FORM/错误路径也落盘/`traces.enabled` 开关默认 on)——覆盖主/子代理/advisor/compress/distill/consult/auto-think 全部调用;续写标记 `isContinuation`。
37
+
38
+ ### Changed
39
+
40
+ - **子代理零 git(§18.5)**:explore/plan spawn 不再注入 `<untrusted_git_context>`(全角色零 git——审计证据=设计文档+磁盘状态+_touchedFiles 并集;审计任务书附零 git 范围权威声明;explore.md 删除 git 注入声明/命令承诺,描述改为“No git context injected”);顶层主 agent git 上下文不变(§3)。
41
+ - **审计任务书零 git 声明**(D-AG3):`_touchedFiles` 为审计范围——工作区未列改动不作超清单依据。
42
+
2
43
 
3
44
  ### Changed
4
45
 
package/README.md CHANGED
@@ -41,7 +41,7 @@ Three layers, all "query if present, skip if absent", unified hybrid retrieval:
41
41
  - **Hybrid retrieval**: FTS5 (BM25, per-character CJK indexing, bigrams matchable) + embedding vectors (brute-force cosine) + RRF(k=60) fusion ranking
42
42
  - **Embeddings**: OpenAI-compatible `/v1/embeddings`, defaults to SiliconFlow `BAAI/bge-m3` (free tier, good CJK support); Ollama works as an offline option. Vectors generated lazily — not computed on write, backfilled and persisted on first search
43
43
  - **Entry format**: Markdown + frontmatter (type/title/tags/author/created), readable and reviewable directly on GitHub; one file per entry, naturally avoiding merge conflicts; real conflicts produce honest errors, never auto-merged
44
- - **Dual-track accumulation**: conventions written manually (`memory_put`), experience extracted from sessions via `/extract` — **the LLM proposes candidates, a human confirms each y/n** before anything is stored; never fully automatic
44
+ - **Dual-track accumulation**: conventions written manually (the `memory` tool, action put), experience extracted from sessions via `/extract` — **the LLM proposes candidates, a human confirms each y/n** before anything is stored; never fully automatic
45
45
  - **Retrieval isolation**: the Project layer is isolated by project path — project A's memories never leak into project B
46
46
 
47
47
  - **Agent Client Protocol** ⭐: `thincoder acp` exposes the agent over [ACP](https://agentclientprotocol.com/) v1 on stdio — one terminal login drives sessions from **Zed**, **JetBrains** AI chat, or **Paseo**:
package/bin/thincoder.mjs CHANGED
@@ -16,6 +16,7 @@ import { readFileSync } from "node:fs"
16
16
  import { join } from "node:path"
17
17
  import { runAgent } from "../src/agent.mjs"
18
18
  import { loadConfig, configPath } from "../src/config.mjs"
19
+ import { cleanupTraces } from "../src/traces/trace-store.mjs"
19
20
  import { createMemory, syncDir } from "../src/memory.mjs"
20
21
  import { assembleAgent, teamConfig, gitAuthor, validateProvider } from "../src/cli/make-agent.mjs"
21
22
  import { memoryCommand } from "../src/cli/memory-command.mjs"
@@ -68,6 +69,13 @@ function exitSoon(code) {
68
69
  setTimeout(() => process.exit(code), 100)
69
70
  }
70
71
 
72
+ // D-TR9(2026-09-05):启动轨迹清理——删除超过 traces.retentionHours(默认 24h)的
73
+ // 轨迹文件(fire-and-forget——不阻塞启动——失败静默——与轨迹写盘同纪律)。
74
+ try {
75
+ const startupCfg = loadConfig()
76
+ cleanupTraces({ retentionHours: startupCfg.traces?.retentionHours ?? 24 }).catch(() => {})
77
+ } catch { /* 配置缺失/损坏 → 跳过清理(零风险) */ }
78
+
71
79
  switch (command) {
72
80
  case "chat": {
73
81
  const auto = args.includes("--auto")
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thincoder",
3
- "version": "0.12.58",
3
+ "version": "0.12.59",
4
4
  "description": "Thin coding agent - zero dependencies, no build step, Node.js native. Sharp code, zero bloat.",
5
5
  "keywords": [
6
6
  "ai",
@@ -19,8 +19,8 @@
19
19
  * End-of-turn is NOT a notification: `session/prompt` resolves with
20
20
  * `{ stopReason: "end_turn" }` (kimi session.ts parity).
21
21
  */
22
- import { join } from "node:path"
23
- import { detectDanger } from "../tools/shared.mjs"
22
+ import { detectDanger, normalizeEOL, joinWithEol } from "../tools/shared.mjs"
23
+ import { computeEditEntry, validateEditEntry, assertEditArgsExclusive } from "../tools/edit-diff.mjs"
24
24
 
25
25
  /** ACP ToolKind inference (schema v1 enum) — best-effort, clients render by kind. */
26
26
  function inferToolKind(name) {
@@ -56,25 +56,128 @@ export function buildAcpCallbacks({ sessionId, notify, request, log = () => {} }
56
56
  const update = (sessionUpdate, extra = {}) =>
57
57
  notify("session/update", { sessionId, update: { sessionUpdate, ...extra } })
58
58
  let toolSeq = 0
59
- const toolIds = new Map() // active tool name current ACP toolCallId (defined before the literal — no expando)
59
+ // D15.8(TOOLS.md §15.1):tool id FIFO 队列——并行同名工具按 call 序配对(dispatch B1
60
+ // 已测:并行结果回调顺序 = call 顺序——T-TS8/T-TS9)。取代旧 Map 按名覆盖(后写覆盖先写
61
+ // → tool_call_update 与 tool_call id 错配)。条目 { name, id, toolId }——toolId = 模型级
62
+ // toolCall.id(dispatch 在 onToolCall/onToolResult 均传第 3 参——同一 item 恒相同)。
63
+ // 拒绝/中断路径:dispatch 在 onToolCall 之前拒绝(被拒工具从未入队——无孤儿可滞);
64
+ // 中断/异常路径(onToolResult 永不回调——dispatch.mjs catch 分支——T-F5 契约)留下的
65
+ // 孤儿靠 onToolCall 的「同名同 toolId 先弹出」隔离(见 onToolCall)——模型级 id 跨轮
66
+ // 可重复(sse.mjs 每轮从 call_0 重置)——弹出保证精确配对恒命中最新条目。
67
+ const toolQueue = [] // FIFO of pending { name, id, toolId }
60
68
 
61
69
  const toolCallId = () => `t${++toolSeq}`
70
+ /** D15.8:peek 同名最早项 id——权限面板展示用——不消费(result 仍要与自己的条目配对)。 */
71
+ const peekToolId = (name) => {
72
+ for (const e of toolQueue) if (e.name === name) return e.id
73
+ return null
74
+ }
75
+ /** D15.8:消费——①模型级 toolId 精确配对(中断孤儿隔离)②无 id/未命中 → 名称 FIFO 回退
76
+ * (B1 保序)③均未命中 → null(调用方回退新 id——防御)。 */
77
+ const takeToolId = (name, toolId) => {
78
+ if (toolId != null) {
79
+ const i = toolQueue.findIndex((e) => e.name === name && e.toolId === toolId)
80
+ if (i >= 0) return toolQueue.splice(i, 1)[0].id
81
+ }
82
+ for (let i = 0; i < toolQueue.length; i++) {
83
+ if (toolQueue[i].name === name) return toolQueue.splice(i, 1)[0].id
84
+ }
85
+ return null
86
+ }
87
+
62
88
  const contentBlock = (text) => ({ type: "content", content: { type: "text", text } })
63
89
  const pathOf = (args) => {
64
90
  const p = args?.path ?? args?.filePath
65
91
  return typeof p === "string" && p ? p : null
66
92
  }
67
93
 
94
+ // §15.1(TOOLS.md)D15.7 委派:edit 判定/应用单一权威 = 本地 computeEditEntry
95
+ // (edit-diff.mjs——校验→判定序→应用:行级 LCS、零重叠→插入、replace_all 字面替换全部)。
96
+ // 桥只留「读 IDE 缓冲 → computeEditEntry → 写回 IDE 缓冲」——错误文本经抛错原样透传
97
+ // ——与本地通道逐字一致(NF15.6b / AC15.10:not found / occurrences / 空 old / 空 new)。
98
+ const EDIT_ABORT_PREFIX = "edit aborted (atomic — no files written): "
99
+
100
+ const readBuffer = async (p) => {
101
+ try {
102
+ const read = await request("fs/read_text_file", { sessionId, path: p }, { timeoutMs: 30000 })
103
+ return read?.text ?? read?.content ?? ""
104
+ } catch (e) {
105
+ throw new Error(`fs/read_text_file failed: ${e.message}`)
106
+ }
107
+ }
108
+ const writeBuffer = async (p, content) => {
109
+ try {
110
+ await request("fs/write_text_file", { sessionId, path: p, content }, { timeoutMs: 30000 })
111
+ } catch (e) {
112
+ throw new Error(`fs/write_text_file failed: ${e.message}`)
113
+ }
114
+ }
115
+ /** 单形态:读 IDE 缓冲 → computeEditEntry(rich——无 abortPrefix——同本地 runSingleEdit)
116
+ * → 写回。EOL 权威(F1):判定/应用在 normalizeEOL 后的 LF 域;写回 joinWithEol 按原文
117
+ * 首换行恢复(LF 域判定——CRLF 域写回——与本地 edit 工具同判同恢复)。 */
118
+ const editSingle = async (p, args) => {
119
+ const raw = await readBuffer(p)
120
+ const content = normalizeEOL(raw)
121
+ const out = computeEditEntry(content, args, { path: p })
122
+ await writeBuffer(p, joinWithEol(normalizeEOL(out.updated).split("\n"), raw))
123
+ return `OK: edited ${p} via IDE (${out.occurrences} occurrence(s))${out.note ? ` — ${out.note}` : ""}`
124
+ }
125
+ /** 数组形态(D15.7):条目校验(path——顶层默认自 args.path ?? args.filePath(pathOf)——
126
+ * 2026-09-05 用户裁定 CLI parity——/validateEditEntry/互斥(只对顶层 old/new)——同本地 edit-batch 措辞)→
127
+ * 读全部涉及文件缓冲(同文件去重——一次读)→ 逐条 computeEditEntry(abortPrefix——批量
128
+ * 原子前缀;同文件条目按数组序串行累积——第二条基于第一条结果)→ 全部通过 → 逐文件写回
129
+ * 一次(判失败 → 零写;写失败 → 同本地 edit-batch 既有原子语义)。 */
130
+ const editBatch = async (args) => {
131
+ const edits = args.edits
132
+ if (!Array.isArray(edits) || edits.length === 0) {
133
+ throw new Error("edits must be a non-empty array of {path, old_string, new_string}")
134
+ }
135
+ assertEditArgsExclusive(args)
136
+ const groups = new Map() // path → { path, raw, content, edits }
137
+ for (const e of edits) {
138
+ // 2026-09-05 用户裁定(CLI parity——本地 edit-batch 同句):条目自带 path 优先;
139
+ // 缺省回退顶层 path(pathOf——path/filePath 别名同单形态)
140
+ const p = e.path ?? pathOf(args)
141
+ if (!p) throw new Error("each edit must have a path — give each entry its own path or pass a top-level path")
142
+ validateEditEntry(e, { label: `edit for ${p}: `, rich: false })
143
+ let g = groups.get(p)
144
+ if (!g) {
145
+ g = { path: p, raw: "", content: "", edits: [] }
146
+ groups.set(p, g)
147
+ }
148
+ g.edits.push(e)
149
+ }
150
+ for (const g of groups.values()) {
151
+ g.raw = await readBuffer(g.path)
152
+ g.content = normalizeEOL(g.raw)
153
+ }
154
+ const outcomes = []
155
+ for (const g of groups.values()) {
156
+ for (const e of g.edits) {
157
+ const out = computeEditEntry(g.content, e, { path: g.path, abortPrefix: EDIT_ABORT_PREFIX })
158
+ outcomes.push({ g, out })
159
+ g.content = out.updated // 同文件串行累积
160
+ }
161
+ }
162
+ for (const g of groups.values()) {
163
+ await writeBuffer(g.path, joinWithEol(normalizeEOL(g.content).split("\n"), g.raw))
164
+ }
165
+ return outcomes.map((o) => `OK: edited ${o.g.path} via IDE (${o.out.occurrences} occurrence(s))${o.out.note ? ` — ${o.out.note}` : ""}`).join("\n")
166
+ }
167
+
68
168
  const callbacks = {
69
169
  onToken: (text) => {
70
170
  // Strip the subagent `[model]` metadata token (role#id/[model]<name>) — it's a
71
171
  // TUI/webview display signal, not conversation content, and must not reach ACP clients.
72
- if (/^[\w-]+#\d+\/\[model\]/.test(text)) return
73
- // D7 (AGENT-LOOP.md §7.2): strip ⟦ev⟧ event tokens (bare or prefixed variants)
74
- // they carry RS control characters and are a TUI display signal; structured ACP
172
+ // §19.5 D-M8 (round2 #6): nested prefixes recurse — eng-coder#2/explore#1/[model]
173
+ if (/^(?:[\w-]+#\d+\/)*\[model\]/.test(text)) return
174
+ // D7 (AGENT-LOOP.md §7.2 + §19.5 round2 #6 + D-M7b): strip ⟦ev⟧ event tokens (bare or
175
+ // any-depth prefixed variants — turn/approval/done/settled/stopped/async — async
176
+ // = §19.5 D-M7b zero-field spawn marker) — they
177
+ // carry RS control characters and are a TUI display signal; structured ACP
75
178
  // mapping (tool_call_update) is tracked separately in docs/TODO.md.
76
- // 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
77
- if (/^(?:[\w-]+#\d+\/)?⟦ev⟧(?:turn|approval)\x1e/.test(text)) return
179
+ // 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
180
+ if (/^(?:[\w-]+#\d+\/)*⟦ev⟧(?:turn|approval|done|settled|stopped|async)\x1e/.test(text)) return
78
181
  update("agent_message_chunk", { content: { type: "text", text } })
79
182
  },
80
183
  onReasoning: (text) => update("agent_thought_chunk", { content: { type: "text", text } }),
@@ -82,9 +185,19 @@ export function buildAcpCallbacks({ sessionId, notify, request, log = () => {} }
82
185
  onWait: ({ phase, seconds }) => log(`[rate-limit] ${phase} waiting ~${seconds}s`),
83
186
  onCompress: () => log("[context] auto-compacted"),
84
187
 
85
- onToolCall: (name, args) => {
188
+ onToolCall: (name, args, toolId) => {
86
189
  const id = toolCallId()
87
- toolIds.set(name, id)
190
+ // D15.8(advisor 🔴#1 修复):模型级 id 每轮重置(sse.mjs finalizeToolCalls 内
191
+ // seq=0——call_0 call_1… 跨轮/跨消息可重复——设计自注「跨 turn 不保证唯一」)。
192
+ // 因此 push 前若队列已有同名同 toolId 条目,它必是结果永不回调的陈旧孤儿
193
+ // (dispatch 失败/中断路径不调 onToolResult——T-F5 契约)——先弹出再入队——
194
+ // 精确配对恒命中最新——"下个同名结果永不配到旧项"(设计目标,无需动 dispatch)。
195
+ if (toolId != null) {
196
+ for (let i = toolQueue.length - 1; i >= 0; i--) {
197
+ if (toolQueue[i].name === name && toolQueue[i].toolId === toolId) toolQueue.splice(i, 1)
198
+ }
199
+ }
200
+ toolQueue.push({ name, id, toolId: toolId ?? null })
88
201
  update("tool_call", {
89
202
  toolCallId: id,
90
203
  title: name,
@@ -95,9 +208,8 @@ export function buildAcpCallbacks({ sessionId, notify, request, log = () => {} }
95
208
  })
96
209
  },
97
210
 
98
- onToolResult: (name, result) => {
99
- const id = toolIds.get(name) ?? toolCallId()
100
- toolIds.delete(name)
211
+ onToolResult: (name, result, toolId) => {
212
+ const id = takeToolId(name, toolId) ?? toolCallId()
101
213
  update("tool_call_update", {
102
214
  toolCallId: id,
103
215
  status: "completed",
@@ -117,7 +229,7 @@ export function buildAcpCallbacks({ sessionId, notify, request, log = () => {} }
117
229
  if (danger) content.push(contentBlock(`⚠️ Dangerous: ${danger}`))
118
230
  content.push(contentBlock(JSON.stringify(args ?? {})))
119
231
  const toolCall = {
120
- toolCallId: toolIds.get(name) ?? toolCallId(),
232
+ toolCallId: peekToolId(name) ?? toolCallId(), // D15.8:peek 不消费——result 仍要与自己的条目配对
121
233
  title: name,
122
234
  content,
123
235
  }
@@ -137,7 +249,8 @@ export function buildAcpCallbacks({ sessionId, notify, request, log = () => {} }
137
249
  /**
138
250
  * fs reverse-RPC router (dispatch.mjs toolRouter, M2):
139
251
  * - write → fs/write_text_file (full content, no read-back)
140
- * - edit → fs/read_text_file → local single-replacement → fs/write_text_file
252
+ * - edit → fs/read_text_file → computeEditEntry(本地权威——单/数组形态)→ fs/write_text_file
253
+ * (§15.1 D15.7 委派——双通道同语义;数组=原子批量——逐条目串行累积)
141
254
  * - apply_patch → local (unified-diff application is not routed in M2)
142
255
  * - delete, reads → local
143
256
  */
@@ -156,19 +269,12 @@ export function buildAcpCallbacks({ sessionId, notify, request, log = () => {} }
156
269
  return { handled: true, result: `Error: fs/write_text_file failed: ${e.message}` }
157
270
  }
158
271
  }
159
- if (base === "edit" && path && typeof args?.old_string === "string" && typeof args?.new_string === "string") {
272
+ if (base === "edit" && (Array.isArray(args?.edits) || (path && typeof args?.old_string === "string" && typeof args?.new_string === "string"))) {
160
273
  try {
161
- const read = await request("fs/read_text_file", { sessionId, path }, { timeoutMs: 30000 })
162
- const current = read?.text ?? read?.content ?? ""
163
- const idx = current.indexOf(args.old_string)
164
- if (idx === -1) {
165
- return { handled: true, result: `Error: old_string not found in ${path} (read via IDE buffer)` }
166
- }
167
- const next = current.slice(0, idx) + args.new_string + current.slice(idx + args.old_string.length)
168
- await request("fs/write_text_file", { sessionId, path, content: next }, { timeoutMs: 30000 })
169
- return { handled: true, result: `OK: edited ${path} via IDE (1 replacement)` }
274
+ const text = Array.isArray(args?.edits) ? await editBatch(args) : await editSingle(path, args)
275
+ return { handled: true, result: text }
170
276
  } catch (e) {
171
- return { handled: true, result: `Error: edit via IDE failed: ${e.message}` }
277
+ return { handled: true, result: `Error: ${e.message}` }
172
278
  }
173
279
  }
174
280
  return { handled: false } // read-only tools, delete, apply_patch stay local
@@ -105,6 +105,30 @@ function injectProjectGuide(agent, parts, scopeFiles = []) {
105
105
  return root // guide injected — requirement-fit criteria apply (truthy root)
106
106
  }
107
107
 
108
+ /**
109
+ * Build the mechanical review-object declaration block (AGENT-LOOP.md §18.8
110
+ * D-OA2 — English anchored form). Injected at the START of the review user
111
+ * message every round: round 1 (design + code), the legacy convergence path,
112
+ * and the round-2+ follow-up (see buildAdvisorFollowUp) — the reviewer must
113
+ * not re-derive "who is being reviewed / why" from the documents (T-OA2:
114
+ * every round stays anchored). Absent object → "" (legacy calls degrade to
115
+ * the current behavior — T-OA3).
116
+ * @param {Object|null} [object] — { type, target, status, reason, exclude }
117
+ * (strings; `exclude` may also be a list — joined with ", ")
118
+ * @returns {string} the declaration block (empty when no object)
119
+ */
120
+ export function buildObjectDeclarationBlock(object = null) {
121
+ if (!object || typeof object !== "object" || Array.isArray(object)) return ""
122
+ const field = (v) => (Array.isArray(v) ? v.join(", ") : v == null ? "" : String(v))
123
+ return [
124
+ "## Review-object declaration (mechanical — do not infer)",
125
+ `Review type: ${field(object.type)} | Target: ${field(object.target)} | Object state: ${field(object.status)} | Trigger: ${field(object.reason)}`,
126
+ `Excluded (not in this review): ${field(object.exclude)}`,
127
+ "Follow this declaration — do not infer the review target from the documents.",
128
+ "",
129
+ ].join("\n")
130
+ }
131
+
108
132
  /**
109
133
  * Build the user message for an advisor review session.
110
134
  * @param {Object} agent — the parent agent
@@ -115,15 +139,24 @@ function injectProjectGuide(agent, parts, scopeFiles = []) {
115
139
  * When set, the review input is built from this list ONLY — no git-diff change-set collection.
116
140
  * When absent, the legacy git-diff-based scope is kept (backward compatible).
117
141
  * @param {string[]|null} [paths] — code review only: explicit list of file/dir paths to review (deduped; shown under Review Scope)
142
+ * @param {Object|null} [object] — review-object declaration (§18.8 D-OA1/D-OA3):
143
+ * { type, target, status, reason, exclude } — mechanically injected at the
144
+ * start of the user message; absent → no injection (legacy calls unchanged).
118
145
  * @returns {string} the user message
119
146
  */
120
- export function buildAdvisorUserMessage(agent, prior, reviewType, designToken = null, documents = null, paths = null) {
147
+ export function buildAdvisorUserMessage(agent, prior, reviewType, designToken = null, documents = null, paths = null, object = null) {
121
148
  // prior = the full prior review output (string) when a convergence round is
122
149
  // being built (decision 2026-08-08 — verbatim injection, model understands it).
123
150
  // Deterministic: only _advisorRound > 0 with stored output counts.
124
151
  const p = prior ?? ((agent._advisorRound || 0) > 0 ? agent._lastAdvisorOutput : null)
125
152
 
126
153
  const parts = []
154
+ // Review-object declaration FIRST — D-OA1: at the start of the user message
155
+ // (after the system prompt, before the review content). Covers round 1
156
+ // design/code and the legacy convergence path; the round-2+ normal path
157
+ // prepends it in buildAdvisorFollowUp (T-OA2 — every round stays anchored).
158
+ const declaration = buildObjectDeclarationBlock(object)
159
+ if (declaration) parts.push(declaration)
127
160
  const docList = Array.isArray(documents) ? documents.filter((d) => typeof d === "string" && d.trim()) : []
128
161
  const pathList = Array.isArray(paths) ? [...new Set(paths.filter((p) => typeof p === "string" && p.trim()))] : []
129
162
 
@@ -120,6 +120,9 @@ function renderTimeline(timeline, tail = "") {
120
120
  }
121
121
  // Test seam (mirrors _advisorToolsFor).
122
122
  export { renderTimeline as _renderTimeline }
123
+ // Test seam (T-TS8/9): the tool loop itself — toolsOverride injects a mock tool
124
+ // set with controllable timing/errors (the real set comes from advisorToolsFor).
125
+ export { runAdvisorToolLoop as _runAdvisorToolLoop }
123
126
 
124
127
  /**
125
128
  * Run the advisor's tool loop: chat → execute tools → repeat.
@@ -129,7 +132,7 @@ export { renderTimeline as _renderTimeline }
129
132
  * the panel keeps moving while the advisor explores — otherwise the panel sits
130
133
  * frozen through every tool-call phase and the review appears to have stalled.
131
134
  */
132
- async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, cwd) {
135
+ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, cwd, toolsOverride = null) {
133
136
  // Kind-tagged wrappers: the TUI panel colors reasoning / answer / tool progress differently.
134
137
  // Every chunk is ALSO recorded into an ordered timeline — the persisted record
135
138
  // must show the review process (thinking ↔ tool progress ↔ final text) at its
@@ -145,7 +148,9 @@ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, c
145
148
  const onThink = emit("think")
146
149
  const onText = emit("text")
147
150
  const onTool = emit("tool")
148
- const { schemas: toolSchemas, byName: toolByName } = advisorToolsFor(agent)
151
+ // toolsOverride = test seam (T-TS8/9): the real advisor tool set, or a mock
152
+ // set with controllable timing/errors.
153
+ const { schemas: toolSchemas, byName: toolByName } = toolsOverride ?? advisorToolsFor(agent)
149
154
  let turns = 0
150
155
  const startTime = Date.now()
151
156
 
@@ -198,6 +203,18 @@ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, c
198
203
  signal: signal ?? null,
199
204
  onToken: onText,
200
205
  onReasoning: onThink,
206
+ // LOGGING(vscode advisor/run.mjs parity——按 stage 可 grep)
207
+ // §18.6 D-TR4:轨迹元数据增补——kind=advisor(评审独立于子代理——T-TR2);role
208
+ // 透出调用方角色(eng-coder 内嵌评审时为 "eng-coder");session/cwd 供轨迹对回;
209
+ // traces 开关沿 agent.config(D-TR6)。
210
+ logCtx: {
211
+ stage: "advisor",
212
+ role: agent?._role ?? null,
213
+ kind: "advisor",
214
+ session: agent?._sessionStart ?? null,
215
+ cwd,
216
+ traces: agent?.config?.traces?.enabled !== false,
217
+ },
201
218
  })
202
219
 
203
220
  // No tool calls — this is the final review text. The final answer was
@@ -226,8 +243,19 @@ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, c
226
243
  : {}),
227
244
  })
228
245
 
229
- // Execute each tool call
230
- for (const tc of response.toolCalls) {
246
+ // B1 (AGENT-LOOP.md §18.7 D-TS7): the SAME LLM reply's multiple read-only
247
+ // tool calls run in PARALLEL (Promise.all) — results are backfilled in
248
+ // toolCalls order (Promise.all preserves the input order → tool_call_id
249
+ // never mismatches); each tool's timeout/error is captured independently
250
+ // (the existing TOOL_TIMEOUT stays — one failing tool does not block the
251
+ // others); progress lines are emitted in toolCalls order. The read-only
252
+ // tool set has no side effects — no sequencing/serialization needed.
253
+ // Scope note (round1 review #10): B1 is ONLY in-loop tool parallelism — it
254
+ // does NOT solve the TODO "platform execution: advisor parallel calls are
255
+ // actually serial" mystery (docs/TODO.md — LOGGING evidence item), which
256
+ // concerns multiple advisor CALLS observed as serial, not one reply's
257
+ // tool calls.
258
+ const parsed = response.toolCalls.map((tc) => {
231
259
  const tool = toolByName.get(tc.name)
232
260
  let args = {}
233
261
  let parseError = null
@@ -236,63 +264,69 @@ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, c
236
264
  } catch (e) {
237
265
  parseError = `Error: invalid JSON in tool arguments: ${e.message}\nRaw arguments: ${(tc.arguments || "").slice(0, 200)}`
238
266
  }
239
-
240
- // If parse failed, return error to model immediately
241
- if (parseError) {
242
- messages.push({ role: "tool", tool_call_id: tc.id, content: parseError })
243
- continue
244
- }
245
-
246
- const argsLine = describeToolArgs(tc.name, args)
247
- onTool(`\n→ ${tc.name}${argsLine ? " " + argsLine : ""}\n`)
248
- let result
249
- if (!tool) {
250
- result = `Error: unknown tool "${tc.name}". Available: ${[...toolByName.keys()].join(", ")}`
251
- } else {
252
- // Execute with timeout (clear the timer when the tool wins the race —
253
- // otherwise up to MAX_ADVISOR_TURNS dangling timers accumulate)
267
+ return { tc, tool, args, parseError }
268
+ })
269
+ // Progress lines first, in toolCalls order (emitted before the parallel
270
+ // run display order is independent of completion order).
271
+ for (const p of parsed) {
272
+ if (p.parseError) continue // parse-error tools get no progress line (legacy behavior)
273
+ const argsLine = describeToolArgs(p.tc.name, p.args)
274
+ onTool(`\n→ ${p.tc.name}${argsLine ? " " + argsLine : ""}\n`)
275
+ }
276
+ // Every tool runs CONCURRENTLY; each result/error lands in its own slot —
277
+ // Promise.all preserves input order, so index i always matches parsed[i].
278
+ const executed = await Promise.all(parsed.map(async (p) => {
279
+ // Parse failure → error to model immediately (no execution)
280
+ if (p.parseError) return p.parseError
281
+ if (!p.tool) return `Error: unknown tool "${p.tc.name}". Available: ${[...toolByName.keys()].join(", ")}`
282
+ // Execute with timeout (clear the timer when the tool wins the race —
283
+ // otherwise up to MAX_ADVISOR_TURNS dangling timers accumulate)
284
+ try {
285
+ let timeoutId
286
+ const timeoutPromise = new Promise((_, reject) => {
287
+ timeoutId = setTimeout(() => reject(new Error(`tool timeout after ${TOOL_TIMEOUT_MS}ms`)), TOOL_TIMEOUT_MS)
288
+ })
289
+ let toolPromise
254
290
  try {
255
- let timeoutId
256
- const timeoutPromise = new Promise((_, reject) => {
257
- timeoutId = setTimeout(() => reject(new Error(`tool timeout after ${TOOL_TIMEOUT_MS}ms`)), TOOL_TIMEOUT_MS)
258
- })
259
- let toolPromise
260
- try {
261
- toolPromise = tool.execute(args, { cwd, agent, onOutput, signal })
262
- result = await Promise.race([toolPromise, timeoutPromise])
263
- } finally {
264
- clearTimeout(timeoutId)
265
- // Timeout won → toolPromise is still pending; a later rejection
266
- // would surface as an unhandled rejection. The race already
267
- // consumed the result/error in the normal path, so this no-op
268
- // catch only fires for the abandoned-tool case.
269
- toolPromise?.catch(() => {})
270
- }
271
- } catch (e) {
272
- const errorType = e.message.includes("timeout") ? "timeout"
273
- : e.message.includes("ENOENT") ? "file_not_found"
274
- : e.message.includes("permission") ? "permission_denied"
275
- : "execution_error"
276
- result = `Error (${errorType}): ${e.message}`
291
+ toolPromise = p.tool.execute(p.args, { cwd, agent, onOutput, signal })
292
+ return await Promise.race([toolPromise, timeoutPromise])
293
+ } finally {
294
+ clearTimeout(timeoutId)
295
+ // Timeout won → toolPromise is still pending; a later rejection
296
+ // would surface as an unhandled rejection. The race already
297
+ // consumed the result/error in the normal path, so this no-op
298
+ // catch only fires for the abandoned-tool case.
299
+ toolPromise?.catch(() => {})
277
300
  }
301
+ } catch (e) {
302
+ const errorType = e.message.includes("timeout") ? "timeout"
303
+ : e.message.includes("ENOENT") ? "file_not_found"
304
+ : e.message.includes("permission") ? "permission_denied"
305
+ : "execution_error"
306
+ return `Error (${errorType}): ${e.message}`
278
307
  }
308
+ }))
309
+
310
+ // Backfill in toolCalls order (executed[i] ↔ parsed[i]); per-result
311
+ // non-string serialization + line-aware truncation stay per-tool.
312
+ for (let i = 0; i < parsed.length; i++) {
313
+ let result = executed[i]
279
314
  if (typeof result !== "string") result = JSON.stringify(result)
280
-
281
- // Line-aware truncation: preserve line integrity
315
+
282
316
  if (result.length > MAX_RESULT_CHARS) {
283
317
  const lines = result.split("\n")
284
318
  let truncated = ""
285
319
  let charCount = 0
286
320
  let keptLines = 0
287
-
288
- for (let i = 0; i < lines.length; i++) {
289
- const line = lines[i]
321
+
322
+ for (let j = 0; j < lines.length; j++) {
323
+ const line = lines[j]
290
324
  if (charCount + line.length + 1 > MAX_RESULT_CHARS) break
291
325
  truncated += line + "\n"
292
326
  charCount += line.length + 1
293
327
  keptLines++
294
328
  }
295
-
329
+
296
330
  const remainingLines = lines.length - keptLines
297
331
  result = (
298
332
  truncated +
@@ -300,8 +334,8 @@ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, c
300
334
  `To see more content, use: read(path, offset=${keptLines + 1}, limit=200)`
301
335
  )
302
336
  }
303
-
304
- messages.push({ role: "tool", tool_call_id: tc.id, content: result })
337
+
338
+ messages.push({ role: "tool", tool_call_id: parsed[i].tc.id, content: result })
305
339
  }
306
340
  }
307
341
  }
@@ -359,8 +393,12 @@ function extractUnfixedIssues(priorText) {
359
393
  * Run an advisor review. reviewType: "code" (default) or "design". Returns review text or null when skipped.
360
394
  * @param {string|null} [designToken] — injected into the design-review prompt; the advisor echoes it only on approval.
361
395
  * @param {string[]|null} [documents] — design review only: explicit list of doc paths to review; passed through to the message builder.
396
+ * @param {string[]|null} [paths] — code review only: explicit list of file/dir paths to review.
397
+ * @param {Object|null} [object] — review-object declaration (§18.8 D-OA1/D-OA3):
398
+ * { type, target, status, reason, exclude }; mechanically injected at the
399
+ * start of every review round's user message. Absent → legacy behavior (no injection).
362
400
  */
363
- export async function runAdvisorReview(agent, reviewType, callbacks, designToken = null, documents = null, paths = null) {
401
+ export async function runAdvisorReview(agent, reviewType, callbacks, designToken = null, documents = null, paths = null, object = null) {
364
402
  const onOutput = callbacks?.onOutput
365
403
  const signal = callbacks?.signal
366
404
  const startTime = Date.now()
@@ -395,7 +433,7 @@ export async function runAdvisorReview(agent, reviewType, callbacks, designToken
395
433
  // Advisor always works in the agent's cwd — scope is defined by paths/documents.
396
434
  const advisorCwd = agent.cwd
397
435
 
398
- const messages = prepareAdvisorMessages(agent, reviewType, designToken, documents, paths)
436
+ const messages = prepareAdvisorMessages(agent, reviewType, designToken, documents, paths, null, object)
399
437
 
400
438
  try {
401
439
  const result = await runAdvisorToolLoop(provider, messages, onOutput, signal, agent, advisorCwd)