thincoder 0.12.59 → 0.12.61

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 (192) hide show
  1. package/CHANGELOG.md +62 -4
  2. package/README.md +10 -8
  3. package/bin/thincoder.mjs +99 -133
  4. package/package.json +6 -4
  5. package/src/abort-provenance.mjs +116 -0
  6. package/src/acp/bridge.mjs +45 -21
  7. package/src/acp.mjs +6 -1
  8. package/src/advisor/citations.mjs +83 -21
  9. package/src/advisor/compaction.mjs +174 -0
  10. package/src/advisor/loop.mjs +293 -0
  11. package/src/advisor/messages.mjs +59 -137
  12. package/src/advisor/project-context.mjs +194 -0
  13. package/src/advisor/repos.mjs +17 -40
  14. package/src/advisor/run.mjs +156 -359
  15. package/src/advisor/truncate.mjs +57 -0
  16. package/src/advisor.mjs +27 -7
  17. package/src/agent/completion.mjs +17 -11
  18. package/src/agent/dispatch.mjs +145 -27
  19. package/src/agent/helpers.mjs +107 -13
  20. package/src/agent/record-results.mjs +55 -11
  21. package/src/agent/relay-prefix.mjs +39 -0
  22. package/src/agent/run-stages.mjs +242 -0
  23. package/src/agent/setup-reminders.mjs +69 -0
  24. package/src/agent/setup.mjs +107 -127
  25. package/src/agent/spawn-child.mjs +55 -13
  26. package/src/agent-tools/advisor-async.mjs +346 -0
  27. package/src/agent-tools/advisor-settle.mjs +231 -0
  28. package/src/agent-tools/advisor.mjs +167 -116
  29. package/src/agent-tools/async-settle.mjs +191 -0
  30. package/src/agent-tools/batch-segment.mjs +195 -0
  31. package/src/agent-tools/consult.mjs +139 -107
  32. package/src/agent-tools/design-token.mjs +117 -0
  33. package/src/agent-tools/digest-budget.mjs +76 -0
  34. package/src/agent-tools/eng.mjs +24 -29
  35. package/src/agent-tools/escalate-async.mjs +289 -0
  36. package/src/agent-tools/read-history.mjs +166 -32
  37. package/src/agent-tools/recent-changes.mjs +2 -1
  38. package/src/agent-tools/review-streak.mjs +93 -0
  39. package/src/agent-tools/settings.mjs +137 -34
  40. package/src/agent-tools/subagent-actions.mjs +180 -133
  41. package/src/agent-tools/subagent-async.mjs +184 -177
  42. package/src/agent-tools/subagent-panel.mjs +160 -0
  43. package/src/agent-tools/subagent-run.mjs +205 -0
  44. package/src/agent-tools/subagent-scheduler.mjs +100 -27
  45. package/src/agent-tools/subagent-spawn.mjs +453 -0
  46. package/src/agent-tools/subagent.mjs +256 -578
  47. package/src/agent-tools/verify.mjs +119 -292
  48. package/src/agent-tools.mjs +1 -0
  49. package/src/agent.mjs +89 -205
  50. package/src/cli/distill-command.mjs +12 -6
  51. package/src/cli/make-agent.mjs +26 -8
  52. package/src/cli/memory-command.mjs +4 -3
  53. package/src/cli/permission.mjs +2 -2
  54. package/src/cli/setup-wizard.mjs +42 -17
  55. package/src/completions.mjs +114 -0
  56. package/src/config-migrate.mjs +70 -0
  57. package/src/config.mjs +180 -63
  58. package/src/context.mjs +5 -147
  59. package/src/conventions.mjs +223 -0
  60. package/src/crash-reports.mjs +128 -0
  61. package/src/distill.mjs +11 -11
  62. package/src/expand-home.mjs +16 -0
  63. package/src/explore-distill.mjs +155 -0
  64. package/src/generate-title.mjs +1 -1
  65. package/src/hooks.mjs +7 -3
  66. package/src/memory/code-index.mjs +9 -3
  67. package/src/memory/code-sync.mjs +72 -32
  68. package/src/memory/core.mjs +6 -193
  69. package/src/memory/delete.mjs +236 -0
  70. package/src/memory/docs.mjs +68 -54
  71. package/src/memory/file-walk.mjs +109 -0
  72. package/src/memory/schema.mjs +15 -3
  73. package/src/memory.mjs +3 -1
  74. package/src/model-ref.mjs +66 -0
  75. package/src/model-specs.mjs +42 -8
  76. package/src/peer-domains.mjs +265 -0
  77. package/src/peer-instances.mjs +231 -0
  78. package/src/prompt-overlays.mjs +82 -0
  79. package/src/prompts/advisor-design.md +18 -75
  80. package/src/prompts/advisor-round1.md +14 -67
  81. package/src/prompts/advisor-round2.md +15 -51
  82. package/src/prompts/advisor-round3.md +15 -51
  83. package/src/prompts/common.md +115 -0
  84. package/src/prompts/consult-base.md +5 -23
  85. package/src/prompts/discipline-engineering.md +217 -0
  86. package/src/prompts/discipline-normal.md +179 -0
  87. package/src/prompts/persona-coder.md +21 -0
  88. package/src/prompts/persona-eng-coder.md +37 -0
  89. package/src/prompts/persona-eng-designer.md +55 -0
  90. package/src/prompts/persona-engineering.md +54 -0
  91. package/src/prompts/persona-explore.md +15 -0
  92. package/src/prompts/persona-normal.md +27 -0
  93. package/src/prompts/persona-plan.md +26 -0
  94. package/src/provider/anthropic.mjs +4 -4
  95. package/src/provider/core.mjs +18 -98
  96. package/src/provider/errors.mjs +101 -0
  97. package/src/provider/google.mjs +5 -6
  98. package/src/provider/index.mjs +2 -1
  99. package/src/provider/list-models.mjs +93 -0
  100. package/src/provider/rate.mjs +2 -1
  101. package/src/provider/responses.mjs +5 -3
  102. package/src/provider/retry.mjs +8 -45
  103. package/src/provider/sse.mjs +3 -4
  104. package/src/proxy.mjs +9 -14
  105. package/src/session-gc.mjs +214 -0
  106. package/src/session-guard.mjs +47 -0
  107. package/src/session-rename.mjs +38 -0
  108. package/src/session-slots.mjs +188 -60
  109. package/src/session.mjs +104 -124
  110. package/src/token-ttl.mjs +274 -0
  111. package/src/tools/{system.mjs → bash.mjs} +19 -221
  112. package/src/tools/checklist-sync.mjs +181 -0
  113. package/src/tools/checklist.mjs +52 -39
  114. package/src/tools/edit-batch.mjs +109 -10
  115. package/src/tools/edit-diff.mjs +110 -27
  116. package/src/tools/edit.md +17 -12
  117. package/src/tools/execute.mjs +31 -4
  118. package/src/tools/file.mjs +41 -16
  119. package/src/tools/git.md +1 -1
  120. package/src/tools/git.mjs +23 -34
  121. package/src/tools/glob-dialect.mjs +130 -0
  122. package/src/tools/glob.md +3 -3
  123. package/src/tools/grep.md +1 -1
  124. package/src/tools/index.mjs +9 -8
  125. package/src/tools/ops.mjs +188 -3
  126. package/src/tools/patch.mjs +3 -3
  127. package/src/tools/question.md +4 -0
  128. package/src/tools/question.mjs +26 -0
  129. package/src/tools/read.md +1 -2
  130. package/src/tools/read_image.md +1 -1
  131. package/src/tools/search.mjs +236 -0
  132. package/src/tools/shared.mjs +14 -13
  133. package/src/tools/wait_for.md +22 -0
  134. package/src/tui/agent-turn.mjs +36 -228
  135. package/src/tui/ansi.mjs +2 -0
  136. package/src/tui/clipboard.mjs +7 -1
  137. package/src/tui/cmd-advisor.mjs +3 -2
  138. package/src/tui/cmd-config.mjs +142 -30
  139. package/src/tui/cmd-eng.mjs +28 -40
  140. package/src/tui/cmd-exit.mjs +6 -8
  141. package/src/tui/cmd-mcp.mjs +8 -2
  142. package/src/tui/cmd-model.mjs +14 -12
  143. package/src/tui/cmd-new.mjs +3 -2
  144. package/src/tui/cmd-reindex.mjs +7 -0
  145. package/src/tui/cmd-session.mjs +19 -4
  146. package/src/tui/cmd-submodel.mjs +8 -5
  147. package/src/tui/cmd-think.mjs +10 -10
  148. package/src/tui/cmd-undo.mjs +4 -3
  149. package/src/tui/cmd-upgrade.mjs +19 -4
  150. package/src/tui/config-helpers.mjs +28 -16
  151. package/src/tui/distill-cmd.mjs +1 -1
  152. package/src/tui/index.mjs +40 -38
  153. package/src/tui/interaction.mjs +3 -3
  154. package/src/tui/key-handler.mjs +61 -17
  155. package/src/tui/key-modes.mjs +86 -8
  156. package/src/tui/layout.mjs +18 -10
  157. package/src/tui/model-catalog.mjs +89 -0
  158. package/src/tui/model-picker.mjs +498 -0
  159. package/src/tui/mouse.mjs +52 -9
  160. package/src/tui/pickers.mjs +28 -392
  161. package/src/tui/render-frame.mjs +32 -16
  162. package/src/tui/render-loop.mjs +2 -0
  163. package/src/tui/render-segments.mjs +12 -9
  164. package/src/tui/render.mjs +37 -5
  165. package/src/tui/slash-commands.mjs +2 -2
  166. package/src/tui/startup.mjs +4 -0
  167. package/src/tui/subagent-blocks.mjs +106 -295
  168. package/src/tui/subagent-children.mjs +162 -0
  169. package/src/tui/subagent-freeze.mjs +169 -0
  170. package/src/tui/subagent-panel.mjs +24 -31
  171. package/src/tui/suspension-drive.mjs +297 -0
  172. package/src/tui/tool-args.mjs +7 -5
  173. package/src/tui/tool-display.mjs +143 -0
  174. package/src/tui/tool-events.mjs +56 -185
  175. package/src/tui/tui-lifecycle.mjs +46 -4
  176. package/src/tui/update-notice.mjs +4 -0
  177. package/src/tui/wizard.mjs +61 -21
  178. package/src/tui/wrapped-spawn.mjs +38 -0
  179. package/src/prompts/coder.md +0 -56
  180. package/src/prompts/discipline.md +0 -102
  181. package/src/prompts/eng-coder.md +0 -44
  182. package/src/prompts/engineering-sub.md +0 -34
  183. package/src/prompts/engineering.md +0 -340
  184. package/src/prompts/explore.md +0 -21
  185. package/src/prompts/main.md +0 -56
  186. package/src/prompts/methodology-template.md +0 -58
  187. package/src/prompts/plan.md +0 -16
  188. package/src/prompts/system.md +0 -63
  189. package/src/tools/pdf-parse-text.mjs +0 -497
  190. package/src/tools/pdf-parse-xref.mjs +0 -499
  191. package/src/tools/pdf.mjs +0 -155
  192. package/src/tools/read_pdf.md +0 -21
@@ -0,0 +1,195 @@
1
+ /**
2
+ * agent-tools/batch-segment.mjs — 批次档段写入工具(ENGINEERING-MODE.md §2.20 · FR22 F1-F7)。
3
+ *
4
+ * 规则、守卫与 fail-closed 清单的**权威正文在 §2.20.1**(本文件不重述——D2 单一权威源);
5
+ * 实现要点(正文未定死、由代码承载):
6
+ * - 身份判据 = 评审实例绑定(`review` 形态)/ `agent._role`;
7
+ * - append-only:在段尾(下一条 `## §N` 或 EOF)插入,既有行字节不变;
8
+ * - 来源戳 `### 轮次 N(评审子代理)`(仅 §3)——N = §3 内该形态行计数 + 1,**调用方自带**
9
+ * 的同名标题行被丢弃(否则伪造戳会污染 N 计数——§2.20.8 #5);
10
+ * - 凭证剥除:含凭证形态的行剥掉该子串,剥后为空则整行丢弃(「零命中」+「其余逐字保留」);
11
+ * - 路径门禁:`resolveBatchDocPath`(评审侧「若传则须可读」);工具内再查一次可读性。
12
+ *
13
+ * 导出面:`batchSegmentTool` · `resolveBatchDocPath` · `batchDocForReview`(异步评审实例键取绑定)。
14
+ */
15
+ import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs"
16
+ import { resolve } from "node:path"
17
+
18
+ /** text 单次上限(§2.20.1——超出引导分段追加,不承诺"不新盖戳")。 */
19
+ export const MAX_TEXT_CHARS = 20000
20
+ /** 身份 → 可写段号(§2.20.1 段白名单)。 */
21
+ export const SEGMENT_BY_ROLE = { "eng-designer": 2, "eng-coder": 5 }
22
+ /** 工具写入的轮次节标题形态(N 计数口径 = 该形态行;`### 轮次与发现(…)` 骨架行不匹配)。 */
23
+ const ROUND_HEADING_RE = /^### 轮次 \d+(评审子代理)/
24
+ /** 段标题定位(`## §N` 独立标题——`## §20` 不误命中 §2)。 */
25
+ const sectionHeaderRe = (seg) => new RegExp(`^## §${seg}(?=\\s|$)`, "m")
26
+ /** 凭证形态(§2.7 冒号态):`[DESIGN-TOKEN:…]` 与 `designId: …`——本节自有正则。 */
27
+ const CRED_RE = /\[DESIGN-TOKEN:[^\]]*\]|designId\s*:\s*\S+/g
28
+ const CRED_TEST_RE = /\[DESIGN-TOKEN:[^\]]*\]|designId\s*:\s*\S+/
29
+
30
+ /** 可读文件判据(存在且为文件——目录/缺失同判不可读)。 */
31
+ function readableFile(abs) {
32
+ try { return existsSync(abs) && statSync(abs).isFile() } catch { return false }
33
+ }
34
+
35
+ /**
36
+ * 批次档路径门禁(评审侧 §2.20.2 口径 = **「若传则须可读」**):空/非字符串/不可读 → throw。
37
+ * 非空且可读 → 返回绝对路径(`\` 归一——照 `files`/`batchDoc` spawn 门先例)。
38
+ */
39
+ export function resolveBatchDocPath(cwd, given) {
40
+ const raw = typeof given === "string" ? given.trim() : ""
41
+ if (!raw) {
42
+ throw new Error("batchDoc must be a non-empty path to the batch record (ENGINEERING-MODE.md §2.20.2) — pass the batch record currently in flight, or omit the parameter entirely when no batch record is in flight.")
43
+ }
44
+ const abs = resolve(cwd ?? process.cwd(), raw.replace(/\\/g, "/"))
45
+ if (!readableFile(abs)) {
46
+ throw new Error(`batchDoc is not a readable file: ${raw} — pass the path of the batch record currently in flight (a path that resolves to an existing file), or omit the parameter when no batch record is in flight.`)
47
+ }
48
+ return abs
49
+ }
50
+
51
+ /**
52
+ * 设计评审的实例绑定解析(§2.20.2/§2.20.3——batch_segment 的唯一路径来源):
53
+ * - 同步路径:调用方(advisor 工具)把实例绑定显式放进 callbacks(**带 batchDoc 键**,
54
+ * 未绑定即 null)——以此为准,不再回看池条目(防同步/异步混跑时串档);
55
+ * - 异步路径:本评审所在**池条目**的 `run.batchDoc`(文档集 = 实例键,各评审各取各条)。
56
+ */
57
+ export function batchDocForReview(agent, documents, callbacks = null) {
58
+ if (callbacks && "batchDoc" in callbacks) return callbacks.batchDoc ?? null
59
+ const key = JSON.stringify([...(documents ?? [])].sort())
60
+ for (const e of agent?._asyncAdvisors?.values?.() ?? []) {
61
+ if (e.status === "running" && e.reviewType === "design" && JSON.stringify([...(e.documents ?? [])].sort()) === key) {
62
+ return e.run?.batchDoc ?? null
63
+ }
64
+ }
65
+ return null
66
+ }
67
+
68
+ /** 段号解析:`§2` / `2` / `§2 批次任务` 均可;无法解析 → null。 */
69
+ function segmentNumber(raw) {
70
+ const m = /^§?\s*(\d+)/.exec(String(raw ?? "").trim())
71
+ return m ? Number(m[1]) : null
72
+ }
73
+
74
+ /** 身份 → 可写段号;无写权身份 → null。评审实例绑定优先(评审者的身份即"设计评审"。) */
75
+ function allowedSegment(agent, review) {
76
+ if (review) return 3
77
+ return SEGMENT_BY_ROLE[agent?._role] ?? null
78
+ }
79
+
80
+ /**
81
+ * 凭证剥除 + 伪造轮次标题丢弃(§2.20.1 F6/AC30/AC34)。
82
+ * 逐行处理:含凭证形态的行 → 剥除该子串,剥后为空则整行丢弃(其余内容逐字保留)。
83
+ * `dropStamp`(仅 §3 目标段):调用方自带的 `### 轮次 N(评审子代理)` 行被丢弃——
84
+ * 工具生成才是唯一来源戳(否则伪造戳会污染 N 计数)。
85
+ */
86
+ function sanitizeText(text, dropStamp) {
87
+ const kept = []
88
+ for (const line of text.replace(/\r\n?/g, "\n").split("\n")) {
89
+ if (dropStamp && ROUND_HEADING_RE.test(line)) continue
90
+ if (!CRED_TEST_RE.test(line)) { kept.push(line); continue }
91
+ const cleaned = line.replace(CRED_RE, "").replace(/[ \t]+$/, "")
92
+ if (cleaned.trim()) kept.push(cleaned)
93
+ }
94
+ return kept.join("\n")
95
+ }
96
+
97
+ /** §3 内工具写入的轮次行计数(N = 计数 + 1;收窄口径——只数工具戳形态)。 */
98
+ function roundCount(src, headerMatch, endIdx) {
99
+ const body = src.slice(headerMatch.index + headerMatch[0].length, endIdx)
100
+ return body.split("\n").filter((l) => ROUND_HEADING_RE.test(l)).length
101
+ }
102
+
103
+ /** 段尾定位 + 插入(append-only:只在段尾插入,既有字节不变;返回新全文 + 本轮 N)。 */
104
+ function insertIntoSection(src, seg, text) {
105
+ const hdr = sectionHeaderRe(seg).exec(src)
106
+ if (!hdr) {
107
+ throw new Error(`batch_segment: the bound batch record has no "## §${seg}" section header — the six-section skeleton is written by the record's creator before any segment write (§1.12). Fix: ask the parent/creator to add the "## §${seg} …" heading (with the template's sub-headings) first, then call batch_segment again. Nothing was written.`)
108
+ }
109
+ const nextRe = /^## §\d/gm
110
+ nextRe.lastIndex = hdr.index + hdr[0].length
111
+ const next = nextRe.exec(src)
112
+ const endIdx = next ? next.index : src.length
113
+ // 来源戳(仅 §3):工具生成标题 + N——调用方写不进自己的标题(sanitize 已丢弃同名行)。
114
+ const roundN = seg === 3 ? roundCount(src, hdr, endIdx) + 1 : 0
115
+ const payload = (seg === 3 ? `### 轮次 ${roundN}(评审子代理)\n\n` : "") + text
116
+ const before = src.slice(0, endIdx)
117
+ const after = src.slice(endIdx)
118
+ const eol = src.includes("\r\n") ? "\r\n" : "\n"
119
+ const lead = before === "" ? "" : before.endsWith(eol + eol) ? "" : before.endsWith(eol) ? eol : eol + eol
120
+ // 尾随 eol 恒加(块自成行);后面还有段标题时再补一个空行(保持模板的空行分隔)。
121
+ const written = before + lead + payload.replaceAll("\n", eol) + eol + (after === "" ? "" : eol) + after
122
+ return { written, roundN }
123
+ }
124
+
125
+ /**
126
+ * 批次档段写入工具工厂。
127
+ * @param {string|null} batchDoc — 绑定的目标档(spawn:`child._batchDoc`;评审:实例键)
128
+ * @param {{review?: boolean}} [opts] — review=true 表示"设计评审实例"形态(写 §3 + 工具盖戳)
129
+ */
130
+ export function batchSegmentTool(batchDoc = null, { review = false } = {}) {
131
+ const own = review ? "§3" : null
132
+ return {
133
+ name: "batch_segment",
134
+ description:
135
+ "Append your own section of the batch record (ENGINEERING-MODE.md §2.20 — 一段一作者). " +
136
+ "There is NO path parameter: the target record is bound to you (at spawn for eng-designer/eng-coder, per review instance for a design review) and your identity fixes the section you may write " +
137
+ "(eng-designer → §2, design review → §3, eng-coder → §5) — a write outside your own section is refused. " +
138
+ "Append-only: the text lands at the end of your section; existing lines are never rewritten or deleted. " +
139
+ "Credential values are stripped mechanically before writing (never write a token or designId value — §2.7). " +
140
+ "A design review's append is stamped by the tool with a `### 轮次 N(评审子代理)` heading — N is tool-counted; do not write your own heading (it would be dropped). " +
141
+ "Failures are hard and visible (no silent fallback): if the write is refused or fails, say so in your report — “§× 未写入”.",
142
+ parameters: {
143
+ type: "object",
144
+ properties: {
145
+ segment: {
146
+ type: "string",
147
+ description: `The batch-record section you are writing${own ? ` — yours is ${own}` : ""}. Declares the section number only; your identity decides what is actually writable (§2.20.1).`,
148
+ },
149
+ text: {
150
+ type: "string",
151
+ description: "The markdown to append (verbatim — findings table + VERDICT + counts for a design review §3). Limit 20000 characters per call; longer content is refused — split it into multiple calls (each call becomes its own section, N continues).",
152
+ },
153
+ },
154
+ required: ["segment", "text"],
155
+ },
156
+ async execute(args, ctx) {
157
+ const agent = ctx?.agent ?? {}
158
+ const seg = allowedSegment(agent, review)
159
+ if (seg === null) {
160
+ throw new Error("batch_segment: no segment is writable by this caller — the channel exists for eng-designer (§2), eng-coder (§5) and design reviews bound to a batch record (§3); the parent agent writes §1/§4/§6 through ordinary document writes (ENGINEERING-MODE.md §2.20.1/§2.20.3).")
161
+ }
162
+ const n = segmentNumber(args?.segment)
163
+ if (n === null) {
164
+ throw new Error(`batch_segment: unknown segment ${JSON.stringify(args?.segment ?? null)} — pass the section number you write (e.g. "§${seg}").`)
165
+ }
166
+ if (n !== seg) {
167
+ throw new Error(`batch_segment: §${n} is not yours to write — this caller writes §${seg} only (一段一作者: eng-designer → §2, design review → §3, eng-coder → §5; ENGINEERING-MODE.md §2.20.1).`)
168
+ }
169
+ if (!batchDoc) {
170
+ throw new Error("batch_segment: no batch record is bound to this caller — there is no path parameter by design (the target arrives via the spawn binding / the review instance key, ENGINEERING-MODE.md §2.20.2). Report the section as not written.")
171
+ }
172
+ const abs = resolve(agent.cwd ?? process.cwd(), String(batchDoc).replace(/\\/g, "/"))
173
+ if (!readableFile(abs)) {
174
+ throw new Error(`batch_segment: the bound batch record is not a readable file: ${abs} — nothing was written (§2.20.1 fail-closed). Check the record still exists, then report the section as not written.`)
175
+ }
176
+ if (typeof args?.text !== "string") {
177
+ throw new Error("batch_segment: text must be a string (the markdown to append).")
178
+ }
179
+ if (args.text.length > MAX_TEXT_CHARS) {
180
+ throw new Error(`batch_segment: text is ${args.text.length} characters — the limit is ${MAX_TEXT_CHARS} per call. Split it into multiple calls: each call becomes its own section and the round number N continues (分段追加——每次调用各成节、N 顺延; ENGINEERING-MODE.md §2.20.1).`)
181
+ }
182
+ if (/^## §\d/m.test(args.text)) {
183
+ throw new Error("batch_segment: the text contains a section header line matching `^## §N` — that would break section location and the append-only guarantee. Rewrite it (escape the heading, or drop the leading `## §N`), then call again (§2.20.1 骨架保护). Nothing was written.")
184
+ }
185
+ const body = sanitizeText(args.text, seg === 3).replace(/^\n+/, "").replace(/\s+$/, "")
186
+ if (!body.trim()) {
187
+ throw new Error("batch_segment: nothing to append — the text is empty after credential stripping (credential values never reach the record; ENGINEERING-MODE.md §2.7/§2.20.1).")
188
+ }
189
+ const src = readFileSync(abs, "utf8")
190
+ const { written, roundN } = insertIntoSection(src, seg, body)
191
+ writeFileSync(abs, written)
192
+ return `batch_segment: appended ${body.length} characters to §${seg} of the batch record${roundN ? ` (### 轮次 ${roundN}(评审子代理))` : ""}.`
193
+ },
194
+ }
195
+ }
@@ -1,20 +1,37 @@
1
1
  /**
2
2
  * consult.mjs — multi-model consultation ("会诊", docs/design/CONSULTATION.md). CLI port.
3
3
  *
4
- * Three tools: consult_start (non-blocking spawn) / consult_check (read the next
5
- * reply as it arrives) / consult_stop (abort the rest). The mechanism does ZERO
6
- * judging the main agent reads replies and verifies with its own tools.
4
+ * Two tools (AGENT-LOOP.md §25 D-R17a R17, 2026-09-06): consult_start
5
+ * (non-blocking spawn) / consult_stop (cancel a running session). consult_check
6
+ * was RETIRED with the digest auto-injection: the mechanism does ZERO judging
7
+ * when every model of a session settles (pending 0), the session moves to the
8
+ * pending single container (`_pendingAsyncResults` +role "consult"——
9
+ * ASYNC-RESULT-CONTAINER.md D2——升格完整 entry) and the NEXT run start (user turn
10
+ * or digest auto-turn) injects the full verdict text ("[System reminder:
11
+ * consultation #id finished — N replies …]" — per-model status annotations on
12
+ * partial/full failures) for the main agent to judge and act on in the digestion
13
+ * round. A cancelled session (consult_stop) never reaches the stream.
7
14
  *
8
15
  * CLI adaptation (vs the VS Code plugin): the child runner is CLI's runAgent
9
16
  * (runAgent(child, input, callbacks, opts) — an agent object, not provider+cwd);
10
17
  * children are built with createAgent({ role: "consult", readonly tools,
11
18
  * CONSULT_BASE overlay }); activity streams to the parent TUI via the relay
12
- * prefix `consult#<id>/` (same channel subagent uses), not onSubagent/onToolPanel.
19
+ * prefix `consult#<childRelayN>/` (one per consultant child the shared subagent
20
+ * relay channel; the child relay number is NOT the session id — sessions key
21
+ * their own `_consultIdCounter`), not onSubagent/onToolPanel.
22
+ * Each child settles its own TUI block with a ⟦ev⟧done event at settle (R17 —
23
+ * the old in-turn check consumption is gone).
13
24
  */
14
25
  import { createAgent, runAgent, readonlyToolNames } from "../agent.mjs"
15
26
  import { resolveChildProvider } from "./subagent.mjs"
27
+ import { pushReal } from "../context.mjs"
28
+ import { offloadToolResult, escapeXml } from "../agent/helpers.mjs"
16
29
  import { logEvent, errText } from "../log.mjs"
30
+ import { deathLine } from "../abort-provenance.mjs"
17
31
  import { makeRelay, wrapChildCallbacks, runWithContinue, ensureChildApiKey, clampEffort } from "../agent/spawn-child.mjs"
32
+ // ASYNC-RESULT-CONTAINER.md D2/D3/D6:pending 单容器停靠 + settle 公共收尾 + child signal 单点
33
+ import { buildChildSignal, settleAsyncEntry } from "./async-settle.mjs"
34
+ import { digestBudgetOver, persistOverflowReport } from "./digest-budget.mjs" // B5(群 B 批 §22 D-DG2):digest 注入预算单源
18
35
 
19
36
  // Named consult defaults (consult P2, 2026-08-30).
20
37
  const CONSULT_TIMEOUT_MS = 600_000 // default consult lifecycle timeout
@@ -112,13 +129,73 @@ export function makeMainHistoryTool(parentAgent) {
112
129
  }
113
130
  }
114
131
 
115
- /** Wake every parked consult_check waiter. */
116
- function wakeWaiters(session) {
117
- const w = session.waiters.splice(0)
118
- for (const resolve of w) { try { resolve(false) } catch { /* noop */ } }
132
+ /**
133
+ * Full-session settle routing (R17 — AGENT-LOOP.md §25 D-R17a): a session whose
134
+ * pending count reached 0 has no more replies coming — the session leaves
135
+ * `_consultSessions` and, unless it was cancelled (consult_stop / turn-end
136
+ * abort), moves into the pending single container (`_pendingAsyncResults` +role
137
+ * "consult"——ASYNC-RESULT-CONTAINER.md D2——升格完整 entry:同 subagent/advisor/
138
+ * escalate 的 `{id, role, report, done, ...}` 形态)whose report carries the full
139
+ * per-model verdict text (composed here — all settle states are known,
140
+ * partial/full failures annotated per model). The entry is injected at the next
141
+ * run start (user turn or digest auto-turn — agent.mjs); the suspension driver
142
+ * is woken (settle-event parity with the async pools) so an idle settle still
143
+ * triggers the digestion round (T-R17j).
144
+ * D3:公共收尾统一走 settleAsyncEntry 共享 helper(四族同机制)——consult 族参数:
145
+ * 无池(会话池无条目——settle 即出池)、无 ctx(无 TUI 冻结事件——子块各自 settle 时
146
+ * 已冻结)、无 onAccounting;helper 按 role "consult" 恒停靠 pending(非挂起期也停靠)。
147
+ * Cancelled sessions produce no digest (T-R17c).
148
+ */
149
+ function sessionSettled(agent, session) {
150
+ agent?._consultSessions?.delete(String(session.id))
151
+ if (session.stopped) return // cancelled — no digest (T-R17c)
152
+ const entry = {
153
+ id: String(session.id),
154
+ role: "consult",
155
+ report: composeConsultDigest(session),
156
+ error: null, done: true, status: "done", cancelled: false,
157
+ relayPrefix: null, startedAt: null, _settle: null, _settleSeq: 0,
158
+ }
159
+ settleAsyncEntry(agent, entry, { pool: null, ctx: null })
160
+ }
161
+
162
+ /** Digest body for a fully-settled session — title + one annotated line per
163
+ * reply (failed replies marked per-model — round2 #7; full text is injected
164
+ * verbatim and may be >64K → offloaded with a preview at injection). */
165
+ export function composeConsultDigest(session) {
166
+ const replies = session.replies ?? []
167
+ const parts = replies.map((r) =>
168
+ r.failed
169
+ ? `- [${r.model}] (failed): ${r.reply}`
170
+ : `- [${r.model}]: ${r.reply}`)
171
+ const counts = `${replies.length} of ${session.total} models replied (${session.failed} failed)`
172
+ return `[System reminder: consultation #${session.id} finished — ${counts}]\n${parts.join("\n")}`
173
+ }
174
+
175
+ /**
176
+ * Inject one settled consult family entry into the parent history as a
177
+ * user-role reminder (run-start injection — agent.mjs; same shape rules as
178
+ * injectAsyncResult: XML-escaped, >64K offloaded with preview + path). Consumed
179
+ * = the caller splices the entry out of the pending single container
180
+ * (_pendingAsyncResults——ASYNC-RESULT-CONTAINER.md D2——role 分发注入)。
181
+ */
182
+ export async function injectConsultResult(agent, entry) {
183
+ const full = String(entry?.report ?? "(no consultation result)")
184
+ // §22 D-DG3(群 B 批 B5):计入预算的 raw = 报告正文(标签行不计)——首行标签拆出;超限
185
+ // 路径保留族标签行(与 VSC 镜像同形);落盘内容 = raw(与其余三族同口径)。
186
+ const at = full.indexOf("\n")
187
+ const label = at === -1 ? "" : full.slice(0, at + 1)
188
+ const raw = at === -1 ? full : full.slice(at + 1)
189
+ const over = digestBudgetOver(agent, raw.length)
190
+ const saved = over ? await persistOverflowReport(raw, { tag: `consult-${entry?.id ?? "session"}` }) : null
191
+ const preview = saved ? `${label}${saved}` : await offloadToolResult(full, `consult-${entry?.id ?? "session"}`)
192
+ pushReal(agent, {
193
+ role: "user",
194
+ content: escapeXml(preview),
195
+ })
119
196
  }
120
197
 
121
- function settleChild(session, id, label, ok, payload) {
198
+ function settleChild(agent, session, id, label, ok, payload, emitDone) {
122
199
  if (ok) {
123
200
  session.received++
124
201
  session.replies.push({ model: label, reply: payload })
@@ -129,7 +206,8 @@ function settleChild(session, id, label, ok, payload) {
129
206
  session.replies.push({ model: label, reply: `(consultation failed: ${payload})`, failed: true })
130
207
  }
131
208
  session.pending--
132
- wakeWaiters(session)
209
+ emitDone?.() // per-child TUI block freeze at settle (R17 — child's activity card is done)
210
+ if (session.pending === 0) sessionSettled(agent, session)
133
211
  }
134
212
 
135
213
  async function runConsultChild(ctx, session, id, m, problem, ctrl) {
@@ -139,7 +217,8 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
139
217
  const armWatchdog = () => {
140
218
  const t = setTimeout(() => {
141
219
  timedOut = true
142
- try { ctrl.abort() } catch { /* already settled */ }
220
+ // §20.3 站点 #12 信号面(第 24 批):watchdog 中止带 timeout reason
221
+ try { ctrl.abort({ abortTrigger: "timeout", abortDetail: "consult-watchdog" }) } catch { /* already settled */ }
143
222
  }, timeoutMs)
144
223
  t.unref?.()
145
224
  return t
@@ -161,6 +240,12 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
161
240
  if (kind === "ok" || kind === "partial") logEvent("child:done", { ...base, kind })
162
241
  else logEvent("child:error", { ...base, err: errText(payload, 200) })
163
242
  }
243
+ // R17: relay prefix assigned before the child runner arms — the per-child TUI
244
+ // block freeze emits only when a block actually exists (relay established).
245
+ let relayPrefix = null
246
+ const settle = (ok, payload) => settleChild(agent, session, id, label, ok, payload, relayPrefix
247
+ ? () => ctx.callbacks?.onToken?.(`${relayPrefix}⟦ev⟧done\x1e0\x1e0\x1edone\x1e`)
248
+ : null)
164
249
  try {
165
250
  // Provider resolution: consultModels entries are { provider, model, effort? } — resolve
166
251
  // via the subagent's provider resolver ("provider:model" handles cross-provider picks).
@@ -199,7 +284,7 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
199
284
  // Activity relay via the unified spawn-child pipeline (§7.2 D3): `consult#<subId>/`
200
285
  // prefix (same channel subagent uses — parallel consultants stay independent) +
201
286
  // onToolOutput passthrough so the consultant's tool output lands in its TUI block.
202
- const relayPrefix = makeRelay(agent, "consult", ctx.callbacks?.onToken, provider.model ?? "")
287
+ relayPrefix = makeRelay(agent, "consult", ctx.callbacks?.onToken, provider.model ?? "")
203
288
  // LOGGING:arm(spawn 事件——relay 建立后;子内事件归属 _logId)
204
289
  childLogId = relayPrefix.slice(0, -1)
205
290
  child._logId = childLogId
@@ -238,7 +323,7 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
238
323
  },
239
324
  onDeclined: (e) => {
240
325
  declined = true
241
- settleChild(session, id, label, false, `turn cap reached (${e.turn} turns) — stopped, diagnosis may be partial`)
326
+ settle(false, `turn cap reached (${e.turn} turns) — stopped, diagnosis may be partial`)
242
327
  logSettle("partial", null)
243
328
  return undefined
244
329
  },
@@ -246,44 +331,43 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
246
331
  )
247
332
  // Review #1 fix: onDeclined already settled this child as a failed reply —
248
333
  // settling again here would push a phantom empty success reply and decrement
249
- // `pending` twice (negative pending consult_check's two exits both
250
- // unreachable → permanent block until user abort).
334
+ // `pending` twice (negative pending would re-enter the settle routing).
251
335
  if (!declined) {
252
- settleChild(session, id, label, true, String(result ?? ""))
336
+ settle(true, String(result ?? ""))
253
337
  logSettle("ok", null)
254
338
  }
255
339
  } catch (e) {
256
340
  // Runner errors (incl. the watchdog's abort) settle as a failed reply — the
257
341
  // continue/declined paths are already handled inside runWithContinue.
258
- const note = timedOut ? `consultation timed out after ${Math.round(timeoutMs / 60000)}min (agent.consultTimeoutMs)` : e?.message ?? String(e)
259
- settleChild(session, id, label, false, note)
342
+ const note = timedOut ? `consultation timed out after ${Math.round(timeoutMs / 60000)}min (agent.consultTimeoutMs)` : deathLine(e, ctrl?.signal)
343
+ settle(false, note)
260
344
  logSettle("error", note)
261
345
  }
262
346
  } catch (e) {
263
347
  // Errors BEFORE the runner (provider resolution, createAgent) or a throwing
264
348
  // continue-prompt settle as failed replies — the runner's own errors are already
265
- // handled inside the loop above.
266
- settleChild(session, id, label, false, e?.message ?? String(e))
267
- logSettle("error", e?.message ?? String(e))
349
+ // handled inside the loop above. relayPrefix is null on these paths — no TUI
350
+ // block was ever opened, so no freeze event is emitted.
351
+ const line = deathLine(e, ctrl?.signal)
352
+ settle(false, line)
353
+ logSettle("error", line)
268
354
  } finally {
269
355
  clearTimeout(watchdog)
270
356
  }
271
357
  }
272
358
 
273
- /** Turn-end cleanup (called from runAgent's finally): abort every leftover
274
- * consultation controller, wake parked waiters, clear the session map. */
359
+ /** Turn-end / session-end abort cleanup (R17 call sites: the Ctrl+C abort
360
+ * branches of finalizeAgentTurn and the suspension driver). Consultation
361
+ * sessions are now cross-turn background work (like async subagents): a NORMAL
362
+ * turn end keeps them alive — this runs only when the user stops everything:
363
+ * every leftover session is marked stopped (its settles never reach the digest
364
+ * stream — T-R17c) and its controllers aborted. */
275
365
  export function cleanupConsultSessions(agent) {
276
366
  for (const s of agent._consultSessions?.values() ?? []) {
277
367
  s.stopped = true
278
- for (const c of s.controllers ?? []) { try { c.abort() } catch { /* already settled */ } }
279
- for (const w of s.waiters?.splice(0) ?? []) { try { w() } catch { /* noop */ } }
368
+ for (const c of s.controllers ?? []) { try { c.abort({ abortTrigger: "stop", abortDetail: "consult-cleanup" }) } catch { /* already settled */ } }
280
369
  }
281
370
  agent._consultSessions?.clear()
282
- // NOTE: deliberately void (consult P3, 2026-08-30). The { stopped: true } marker
283
- // only reaches the TUI via the consult_stop TOOL return (onToolResult freezes
284
- // blocks on tool calls) — cleanup runs from the turn finally, where the block
285
- // freeze is owned by freezeAllSubTasks + sweepToolBlocks, so a return here is
286
- // dead weight. Blocks still get frozen on interrupt via that sweep.
287
371
  }
288
372
 
289
373
  export const consultStartTool = {
@@ -294,8 +378,11 @@ export const consultStartTool = {
294
378
  "Start a parallel multi-model consultation (会诊) for a hard problem you are stuck on (repeated failures, no headway). " +
295
379
  "Call it directly when the user asks for 会诊 / consult — an explicit user request applies even if you are not 'stuck'. " +
296
380
  "Several configured models (agent.consultModels) analyze the same problem INDEPENDENTLY and in parallel. " +
297
- "Non-blocking: returns immediately with a consult id. Then call consult_check(id) to read each reply as it " +
298
- "arrives, judge/verify it yourself with your own tools, and call consult_stop(id) once a reply is good enough.\n" +
381
+ "Non-blocking: returns immediately with a consult id; the consultants keep running in the background across turns. " +
382
+ "When EVERY model has replied (or failed), the full verdict text is delivered to you automatically as a system " +
383
+ "reminder at the next run start, or digested on its own while the session is idle — judge and adopt each opinion " +
384
+ "yourself with your own tools (opinions are suggestions, not gates). To stop a session early (user changed their " +
385
+ "mind / wants the tokens back), call consult_stop(id) — a stopped session delivers no digest.\n" +
299
386
  "Parameters:\n" +
300
387
  "- problem (required): a brief — the symptom, what you already tried (failure trail), and entry-point files. " +
301
388
  "Do NOT paste raw error logs; consultants pull the main session history themselves via their main_history tool.\n" +
@@ -325,7 +412,7 @@ export const consultStartTool = {
325
412
  agent._consultSessions ??= new Map()
326
413
  const id = String((agent._consultIdCounter = (agent._consultIdCounter ?? 0) + 1))
327
414
  const session = {
328
- id, controllers: [], replies: [], pending: 0, waiters: [],
415
+ id, controllers: [], replies: [], pending: 0,
329
416
  failed: 0, terminated: 0, stopped: false, received: 0, total: run.length,
330
417
  models: run.map(consultLabel),
331
418
  }
@@ -335,102 +422,47 @@ export const consultStartTool = {
335
422
  session.pending++
336
423
  const ctrl = new AbortController()
337
424
  session.controllers.push(ctrl)
338
- if (ctx.signal) {
339
- if (ctx.signal.aborted) ctrl.abort()
340
- else ctx.signal.addEventListener("abort", () => ctrl.abort(), { once: true })
425
+ // D6 buildChildSignal 单点(ASYNC-RESULT-CONTAINER.md D5——consult 补 _sessionSignal
426
+ // 兜底:挂起会话内的 consult children 持会话 signal,digest 自身 Ctrl+C 不误伤)。
427
+ const baseSignal = buildChildSignal(agent, ctx)
428
+ if (baseSignal) {
429
+ // §20.3 站点 #10(第 24 批):hop 逐跳保 reason
430
+ if (baseSignal.aborted) ctrl.abort(baseSignal.reason)
431
+ else baseSignal.addEventListener("abort", () => ctrl.abort(baseSignal.reason), { once: true })
341
432
  }
342
- // Fire and forget — each child settles itself into the session queue.
433
+ // Fire and forget — each child settles itself into the session; the session
434
+ // routes to the pending single container when every child has settled (R17).
343
435
  runConsultChild(ctx, session, id, m, problem, ctrl)
344
436
  }
345
437
  return JSON.stringify({ id, models: session.models })
346
438
  },
347
439
  }
348
440
 
349
- export const consultCheckTool = {
350
- name: "consult_check",
351
- readonly: true,
352
- description:
353
- "Read the NEXT consultation reply (whichever model answered first). Blocks until a reply arrives or all models " +
354
- "have settled. The reply is raw and unjudged — verify/adopt it with your own tools. When done is true, no more " +
355
- "replies are coming.\n" +
356
- "Call it ALONE in a turn — do NOT batch it with calls that depend on its reply (readonly tools run in parallel).\n" +
357
- "Replies arrive in arrival order: call it repeatedly (n = 1, 2, 3, …) until done is true.\n" +
358
- "Returns JSON: {reply, model, failedReply, received, failed, terminated, total, done} for a reply — or {done: true, received, failed, total} when none are left.\n" +
359
- "Parameters:\n" +
360
- "- id (required): the consult id from consult_start\n" +
361
- "- n (required): the 1-based read number for this consult — pass 1 on the first check, 2 on the next, and so on. It exists so consecutive checks are distinct tool calls (loop detectors) and the transcript reads as a sequence.",
362
- parameters: {
363
- type: "object",
364
- properties: {
365
- id: { type: "string", description: "Consult id" },
366
- n: { type: "number", description: "1-based read number: 1 for the first check, incrementing with each subsequent check of the same consult" },
367
- },
368
- required: ["id", "n"],
369
- },
370
- async execute({ id, n: _n }, ctx) {
371
- const s = ctx.agent?._consultSessions?.get(String(id))
372
- if (!s) return JSON.stringify({ error: "unknown consult id" })
373
- const abortAll = () => { for (const c of s.controllers) { try { c.abort() } catch { /* noop */ } } }
374
- if (ctx.signal?.aborted) abortAll()
375
-
376
- for (;;) {
377
- if (s.replies.length > 0) {
378
- const r = s.replies.shift()
379
- return JSON.stringify({
380
- reply: r.reply, model: r.model, failedReply: r.failed === true,
381
- received: s.received,
382
- failed: s.failed,
383
- terminated: s.terminated ?? 0, total: s.total,
384
- done: s.replies.length === 0 && s.pending === 0,
385
- })
386
- }
387
- if (s.pending === 0) {
388
- return JSON.stringify({ done: true, received: s.received, failed: s.failed, total: s.total })
389
- }
390
- const stopped = await new Promise((resolve) => {
391
- function cleanup() {
392
- const i = s.waiters.indexOf(w)
393
- if (i >= 0) s.waiters.splice(i, 1)
394
- ctx.signal?.removeEventListener("abort", onAbort)
395
- }
396
- function w() { cleanup(); resolve(false) }
397
- function onAbort() { cleanup(); abortAll(); resolve(true) }
398
- s.waiters.push(w)
399
- if (ctx.signal) {
400
- if (ctx.signal.aborted) { onAbort(); return }
401
- ctx.signal.addEventListener("abort", onAbort, { once: true })
402
- }
403
- })
404
- if (stopped) return JSON.stringify({ done: true, stopped: true, received: s.received, failed: s.failed, total: s.total })
405
- }
406
- },
407
- }
408
-
409
441
  export const consultStopTool = {
410
442
  name: "consult_stop",
411
443
  readonly: false,
412
444
  sideEffectExempt: true,
413
445
  description:
414
- "Terminate the still-running consultations of a session once a reply is good enough saves tokens and time. " +
415
- "Already-answered replies stay available for consult_check. " +
416
- "Returns JSON {stopped: <n>, abandoned: <pending count>}or {error: \"unknown consult id\"}.\n" +
446
+ "Cancel a still-running consultation session (会诊) the user changed their mind, the problem resolved, or you want the tokens back. " +
447
+ "Aborts every consultant that is still running; a stopped session delivers NO digest (R17 — its already-collected partial replies are dropped). " +
448
+ "Sessions that finished on their own are no longer cancellable their verdict text is delivered automatically.\n" +
449
+ "Returns JSON {abandoned: <pending count>, cancelled: true} — or {error: \"unknown consult id\"} (already finished/cancelled).\n" +
417
450
  "Parameters:\n" +
418
- "- id (required): the consult id from consult_start\n" +
419
- "- n (required): incrementing call number for this consult (next value after the last consult_check/consult_stop) — keeps repeated calls distinct.",
451
+ "- id (required): the consult id from consult_start",
420
452
  parameters: {
421
453
  type: "object",
422
454
  properties: {
423
455
  id: { type: "string", description: "Consult id" },
424
- n: { type: "number", description: "Incrementing call number for this consult (see consult_check)" },
425
456
  },
426
- required: ["id", "n"],
457
+ required: ["id"],
427
458
  },
428
- async execute({ id, n }, ctx) {
459
+ async execute({ id }, ctx) {
429
460
  const s = ctx.agent?._consultSessions?.get(String(id))
430
461
  if (!s) return JSON.stringify({ error: "unknown consult id" })
431
462
  const abandoned = s.pending
432
463
  s.stopped = true
433
- for (const c of s.controllers) { try { c.abort() } catch { /* already settled */ } }
434
- return JSON.stringify({ stopped: n, abandoned })
464
+ // §20.3 站点 #11(第 24 批):会话级停 = stop
465
+ for (const c of s.controllers) { try { c.abort({ abortTrigger: "stop", abortDetail: "consult-stop" }) } catch { /* already settled */ } }
466
+ return JSON.stringify({ abandoned, cancelled: true })
435
467
  },
436
468
  }