thincoder 0.12.59 → 0.12.60

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 (127) hide show
  1. package/CHANGELOG.md +38 -3
  2. package/README.md +2 -2
  3. package/bin/thincoder.mjs +80 -19
  4. package/package.json +4 -3
  5. package/src/acp/bridge.mjs +7 -4
  6. package/src/advisor/messages.mjs +24 -4
  7. package/src/advisor/run.mjs +35 -33
  8. package/src/advisor.mjs +25 -6
  9. package/src/agent/completion.mjs +17 -11
  10. package/src/agent/dispatch.mjs +102 -19
  11. package/src/agent/helpers.mjs +36 -0
  12. package/src/agent/record-results.mjs +46 -10
  13. package/src/agent/run-stages.mjs +227 -0
  14. package/src/agent/setup-reminders.mjs +62 -0
  15. package/src/agent/setup.mjs +18 -2
  16. package/src/agent/spawn-child.mjs +29 -4
  17. package/src/agent-tools/advisor-async.mjs +456 -0
  18. package/src/agent-tools/advisor.mjs +110 -108
  19. package/src/agent-tools/async-settle.mjs +191 -0
  20. package/src/agent-tools/consult.mjs +121 -102
  21. package/src/agent-tools/design-token.mjs +104 -0
  22. package/src/agent-tools/eng.mjs +24 -29
  23. package/src/agent-tools/escalate-async.mjs +286 -0
  24. package/src/agent-tools/read-history.mjs +155 -31
  25. package/src/agent-tools/recent-changes.mjs +2 -1
  26. package/src/agent-tools/settings.mjs +7 -17
  27. package/src/agent-tools/subagent-actions.mjs +168 -130
  28. package/src/agent-tools/subagent-async.mjs +129 -174
  29. package/src/agent-tools/subagent-panel.mjs +153 -0
  30. package/src/agent-tools/subagent-run.mjs +202 -0
  31. package/src/agent-tools/subagent-scheduler.mjs +45 -21
  32. package/src/agent-tools/subagent-spawn.mjs +406 -0
  33. package/src/agent-tools/subagent.mjs +107 -555
  34. package/src/agent-tools/verify.mjs +118 -270
  35. package/src/agent.mjs +57 -190
  36. package/src/cli/distill-command.mjs +10 -4
  37. package/src/cli/make-agent.mjs +3 -1
  38. package/src/cli/memory-command.mjs +2 -1
  39. package/src/cli/permission.mjs +2 -2
  40. package/src/cli/setup-wizard.mjs +17 -12
  41. package/src/config.mjs +56 -8
  42. package/src/context.mjs +5 -147
  43. package/src/crash-reports.mjs +123 -0
  44. package/src/distill.mjs +11 -11
  45. package/src/explore-distill.mjs +155 -0
  46. package/src/memory/code-sync.mjs +2 -1
  47. package/src/memory/core.mjs +6 -193
  48. package/src/memory/delete.mjs +234 -0
  49. package/src/memory/docs.mjs +58 -48
  50. package/src/memory.mjs +3 -1
  51. package/src/peer-domains.mjs +265 -0
  52. package/src/peer-instances.mjs +231 -0
  53. package/src/prompt-overlays.mjs +25 -0
  54. package/src/prompts/advisor-design.md +9 -76
  55. package/src/prompts/advisor-round1.md +9 -68
  56. package/src/prompts/advisor-round2.md +7 -54
  57. package/src/prompts/advisor-round3.md +7 -54
  58. package/src/prompts/coder.md +7 -50
  59. package/src/prompts/consult-base.md +4 -24
  60. package/src/prompts/discipline.md +26 -44
  61. package/src/prompts/eng-coder.md +7 -32
  62. package/src/prompts/engineering-sub.md +3 -23
  63. package/src/prompts/engineering.md +53 -306
  64. package/src/prompts/explore.md +3 -12
  65. package/src/prompts/main.md +10 -32
  66. package/src/prompts/methodology-template.md +28 -48
  67. package/src/prompts/plan.md +2 -9
  68. package/src/prompts/system.md +16 -35
  69. package/src/provider/core.mjs +6 -67
  70. package/src/provider/errors.mjs +76 -0
  71. package/src/provider/retry.mjs +8 -45
  72. package/src/session-gc.mjs +214 -0
  73. package/src/session-guard.mjs +47 -0
  74. package/src/session-rename.mjs +38 -0
  75. package/src/session-slots.mjs +181 -58
  76. package/src/session.mjs +48 -89
  77. package/src/token-ttl.mjs +273 -0
  78. package/src/tools/checklist-sync.mjs +181 -0
  79. package/src/tools/checklist.mjs +52 -39
  80. package/src/tools/edit-batch.mjs +109 -10
  81. package/src/tools/edit-diff.mjs +110 -27
  82. package/src/tools/edit.md +17 -12
  83. package/src/tools/execute.mjs +31 -4
  84. package/src/tools/file.mjs +11 -6
  85. package/src/tools/git.mjs +14 -6
  86. package/src/tools/glob-dialect.mjs +130 -0
  87. package/src/tools/glob.md +3 -3
  88. package/src/tools/grep.md +1 -1
  89. package/src/tools/index.mjs +5 -6
  90. package/src/tools/ops.mjs +175 -3
  91. package/src/tools/patch.mjs +3 -3
  92. package/src/tools/question.md +3 -0
  93. package/src/tools/read.md +0 -1
  94. package/src/tools/shared.mjs +14 -13
  95. package/src/tools/system.mjs +44 -9
  96. package/src/tools/wait_for.md +22 -0
  97. package/src/tui/agent-turn.mjs +17 -228
  98. package/src/tui/cmd-config.mjs +48 -7
  99. package/src/tui/cmd-eng.mjs +20 -16
  100. package/src/tui/cmd-mcp.mjs +8 -2
  101. package/src/tui/cmd-new.mjs +3 -2
  102. package/src/tui/cmd-session.mjs +19 -4
  103. package/src/tui/cmd-think.mjs +10 -10
  104. package/src/tui/cmd-upgrade.mjs +19 -4
  105. package/src/tui/config-helpers.mjs +28 -16
  106. package/src/tui/distill-cmd.mjs +1 -1
  107. package/src/tui/index.mjs +3 -2
  108. package/src/tui/interaction.mjs +3 -3
  109. package/src/tui/mouse.mjs +7 -1
  110. package/src/tui/pickers.mjs +40 -22
  111. package/src/tui/render-segments.mjs +27 -10
  112. package/src/tui/startup.mjs +4 -0
  113. package/src/tui/subagent-blocks.mjs +95 -263
  114. package/src/tui/subagent-children.mjs +176 -0
  115. package/src/tui/subagent-freeze.mjs +172 -0
  116. package/src/tui/subagent-panel.mjs +61 -23
  117. package/src/tui/suspension-drive.mjs +351 -0
  118. package/src/tui/tool-args.mjs +3 -3
  119. package/src/tui/tool-display.mjs +142 -0
  120. package/src/tui/tool-events.mjs +37 -173
  121. package/src/tui/tui-lifecycle.mjs +29 -0
  122. package/src/tui/update-notice.mjs +4 -0
  123. package/src/tui/wizard.mjs +12 -6
  124. package/src/tools/pdf-parse-text.mjs +0 -497
  125. package/src/tools/pdf-parse-xref.mjs +0 -499
  126. package/src/tools/pdf.mjs +0 -155
  127. package/src/tools/read_pdf.md +0 -21
@@ -0,0 +1,456 @@
1
+ /**
2
+ * advisor-async.mjs — async advisor reviews (AGENT-LOOP.md §24 D-24b — R13, 2026-09-06).
3
+ *
4
+ * The advisor tool runs reviews in a background pool at depth 0 (default async —
5
+ * ruling ②-3 A): the launch returns an ack, the turn ends naturally, the session
6
+ * suspends, the review settles into _pendingAsyncResults and a digest turn
7
+ * delivers the report (§17 consumption machinery — role-agnostic; entries carry
8
+ * role "advisor" and ride the subagent panel as a pseudo-role, ruling ②-4 A).
9
+ *
10
+ * This module owns:
11
+ * 1. the per-review INSTANCE registry (agent._advisorRuns — ruling ②-5 A:
12
+ * Map<reviewId, {reviewType, round, priorOutput, stale, open, docSetKey,
13
+ * designId}>) — the 5-round convergence cap is PER REVIEW (fix #4), no longer
14
+ * a run-global shared budget (design + code used to share _advisorRound);
15
+ * 2. launch resolution: design reviews continue the instance of the same
16
+ * document set (reviewId = the designId minted at the instance's first
17
+ * launch); code reviews continue the newest OPEN code instance (round 2+ of
18
+ * a fix loop) and CLOSE at normal user-run ends once no review is in flight
19
+ * (a converged/abandoned thread must not burn the cap of later tasks);
20
+ * 3. the async pool (agent._asyncAdvisors — ADVISOR_POOL_LIMIT = 2, no
21
+ * queueing: an over-limit launch returns the refusal text immediately,
22
+ * ruling ②-6a); cancel (ruling ②-6b — directed abort → cancelled settle: no
23
+ * pending entry, no token, a "评审已取消——token 未签发" reminder);
24
+ * 4. SETTLE accounting (fixes #2/#4 — moved from the tool-result commit): a
25
+ * review whose targets mutated after launch settles STALE — it marks no
26
+ * _calledAdvisorThisRun and issues no token (the guard pushes back); a
27
+ * non-stale code review marks _calledAdvisorThisRun; a passing design review
28
+ * issues its token under the designId slot at settle time.
29
+ *
30
+ * The sync path (depth>0 eng-coder self-review / explicit async:false) reuses
31
+ * the same instance resolution (rounds/prior/cap) — its accounting still lands
32
+ * in recordToolResults (agent.mjs run loop), marker-keyed by toolCall id.
33
+ */
34
+ import { randomUUID } from "node:crypto"
35
+ import { join } from "node:path"
36
+ import { persistEngTokens } from "../token-ttl.mjs"
37
+ import { settleDesignReview, makeDesignTokenRegex, stripApprovedSuffix } from "./design-token.mjs"
38
+ // design-token 工具组(2026-09-08 自本文件迁至 design-token.mjs——再越 500 行硬限)——
39
+ // 既有 import 面(advisor.mjs / 测试)不变:原导出全部经此 re-export 保留。
40
+ export {
41
+ buildApprovedSuffix, stripApprovedSuffix, effectiveTokenTtlMs, generateDesignToken,
42
+ validateDesignToken, makeDesignTokenRegex, settleDesignReview,
43
+ } from "./design-token.mjs"
44
+ import { runAdvisorReview, resolveAdvisorProvider, ADVISOR_THINKING_PLACEHOLDER, looksLikeReviewOutput } from "../advisor/run.mjs"
45
+ import { isDocFile, isTempFile } from "../advisor/repos.mjs"
46
+ import { stripEventToken } from "../agent/spawn-child.mjs"
47
+ import { logEvent } from "../log.mjs"
48
+ // ASYNC-RESULT-CONTAINER.md D3/D6:settle 公共收尾单点 + child signal 构建单点
49
+ import { buildChildSignal, settleAsyncEntry } from "./async-settle.mjs"
50
+
51
+ // ─────────────────────────────────────────────────────────────────────────────
52
+ // Review-instance registry (agent._advisorRuns — per-review rounds/prior/cap)
53
+ // ─────────────────────────────────────────────────────────────────────────────
54
+
55
+ export function advisorRuns(agent) {
56
+ if (!(agent._advisorRuns instanceof Map)) agent._advisorRuns = new Map()
57
+ return agent._advisorRuns
58
+ }
59
+
60
+ /** Canonical scope key for design reviews — the document multi-set
61
+ * (order-insensitive, ABS-path normalized — launch 与 continuation 的写法差异
62
+ * ("./docs/x.md" vs "docs/x.md"、反斜杠) 不误建新实例). */
63
+ function docSetKey(documents, cwd) {
64
+ const list = [...new Set((documents ?? [])
65
+ .filter((d) => typeof d === "string" && d.trim())
66
+ .map((d) => normAbs(d, cwd)))]
67
+ return JSON.stringify(list.sort())
68
+ }
69
+
70
+ /** The newest OPEN code instance (the thread a fix-round launch continues), or null. */
71
+ export function openCodeRun(agent) {
72
+ const runs = agent?._advisorRuns
73
+ if (!(runs instanceof Map) || runs.size === 0) return null
74
+ for (const r of [...runs.values()].reverse()) {
75
+ if (r.reviewType === "code" && r.open) return r
76
+ }
77
+ return null
78
+ }
79
+
80
+ /** The newest OPEN design instance for the given document set, or null. */
81
+ function openDesignRun(agent, key) {
82
+ const runs = agent?._advisorRuns
83
+ if (!(runs instanceof Map) || runs.size === 0) return null
84
+ for (const r of [...runs.values()].reverse()) {
85
+ if (r.reviewType === "design" && r.open && r.docSetKey === key) return r
86
+ }
87
+ return null
88
+ }
89
+
90
+ /**
91
+ * Resolve the review instance a launch continues (§24 D-24b ③ — per-review
92
+ * rounds/prior; reviewId = designId for design reviews, a random id for code).
93
+ * - design: continue the OPEN instance of the same document set (fix rounds of
94
+ * a review thread keep its designId and its round/prior); none → new instance.
95
+ * - code: continue the newest OPEN code instance (guard-driven fix loops);
96
+ * none → new instance.
97
+ * The legacy agent._advisorRound/_lastAdvisorOutput fields are SCOPED to the
98
+ * instance before the launch (message building + the run.mjs cap read them) —
99
+ * prior-less continuation degrades to round 1 semantics (fresh full review).
100
+ * @returns {{run: object, isNew: boolean, reviewId: string, designId: string|null}}
101
+ */
102
+ export function resolveAdvisorLaunch(agent, reviewType, { documents = null } = {}) {
103
+ const runs = advisorRuns(agent)
104
+ let run = null
105
+ if (reviewType === "design") {
106
+ const key = docSetKey(documents, agent.cwd)
107
+ run = openDesignRun(agent, key)
108
+ if (!run) {
109
+ const reviewId = randomUUID()
110
+ // F2h (§29.1 2026-09-07): a same-scope re-review reuses the session's
111
+ // designId for this doc-set (any prior instance of the scope — open OR
112
+ // closed — the newest wins). A passed re-review overwrites the slot under
113
+ // the same id (no slot residue — T14: the old token is then rejected at
114
+ // the gate); old slots die at TTL only, kept as the fail-safe fallback.
115
+ const prior = [...runs.values()].reverse().find((r) => r.reviewType === "design" && r.docSetKey === key)
116
+ run = {
117
+ reviewId, reviewType, designId: prior?.designId ?? reviewId,
118
+ round: 0, priorOutput: null, stale: false, open: true, docSetKey: key,
119
+ }
120
+ runs.set(reviewId, run)
121
+ }
122
+ } else {
123
+ run = openCodeRun(agent)
124
+ if (!run) {
125
+ const reviewId = randomUUID()
126
+ run = {
127
+ reviewId, reviewType, designId: null,
128
+ round: 0, priorOutput: null, stale: false, open: true, docSetKey: null,
129
+ }
130
+ runs.set(reviewId, run)
131
+ }
132
+ }
133
+ // Scope the legacy mirror fields (message builder + run.mjs cap + displays).
134
+ agent._advisorRound = run.priorOutput ? run.round : 0
135
+ agent._lastAdvisorOutput = run.priorOutput
136
+ return { run, isNew: run.round === 0 && !run.priorOutput, reviewId: run.reviewId, designId: run.designId }
137
+ }
138
+
139
+ /** Guard round — the OPEN CODE instance only (2026-09-07 §8 F2): the design-written
140
+ * mirror never gates the code guard — no open code instance → 0. */
141
+ export function effectiveAdvisorRound(agent) {
142
+ return openCodeRun(agent)?.round ?? 0
143
+ }
144
+
145
+ // ─────────────────────────────────────────────────────────────────────────────
146
+ // Mutation log (stale-review determination — fix #2: FILE_MUTATORS after the
147
+ // review launch make the settle stale: no called-mark, no token, guard re-pushes)
148
+ // ─────────────────────────────────────────────────────────────────────────────
149
+
150
+ export function mutationSeqOf(agent) {
151
+ return agent._mutationSeq ?? 0
152
+ }
153
+
154
+ /** Record a file-mutation commit — bounded ring feeding the stale scan. paths = ABSOLUTE.
155
+ * 唯一记账点(§29 fix A):dispatch runOne 写执行成功即刻调用(取代批后段 + 中断分支——
156
+ * 不双计);mergeChildMutations(子代理合入)是独立事件面。 */
157
+ export function noteMutations(agent, paths) {
158
+ const list = (paths ?? []).filter((p) => typeof p === "string" && p.length > 0)
159
+ if (list.length === 0) return
160
+ agent._mutationSeq = (agent._mutationSeq ?? 0) + 1
161
+ const log = agent._mutLog ??= []
162
+ log.push({ seq: agent._mutationSeq, paths: [...new Set(list)] })
163
+ if (log.length > 200) log.splice(0, log.length - 200)
164
+ }
165
+
166
+ /** A path counts as a CODE mutation (src/ unconditional; temp/doc excluded outside src/). */
167
+ function isCodePath(p) {
168
+ return /(?:^|[\\/])src[\\/]/.test(p) || (!isTempFile(p) && !isDocFile(p))
169
+ }
170
+
171
+ function normAbs(p, cwd) {
172
+ const s = String(p)
173
+ return /^[a-zA-Z]:[\\/]/.test(s) || s.startsWith("/") || s.startsWith("\\\\") ? s : join(cwd, s)
174
+ }
175
+
176
+ /** Stale = a mutation committed after the launch touched the review's face:
177
+ * code reviews judge the CODE face; design reviews judge their OWN documents. */
178
+ export function reviewIsStale(agent, entry) {
179
+ const log = agent?._mutLog
180
+ if (!Array.isArray(log) || log.length === 0) return false
181
+ const since = log.filter((m) => m.seq > (entry.launchSeq ?? -1))
182
+ if (since.length === 0) return false
183
+ if (entry.reviewType === "design") {
184
+ const scope = (entry.docAbs ?? []).map((p) => normAbs(p, agent?.cwd))
185
+ if (scope.length === 0) return false // no explicit doc scope — nothing to judge stale
186
+ const set = new Set(scope)
187
+ return since.some((m) => (m.paths ?? []).some((p) => set.has(normAbs(p, agent?.cwd))))
188
+ }
189
+ return since.some((m) => (m.paths ?? []).some((p) => isCodePath(normAbs(p, agent?.cwd))))
190
+ }
191
+
192
+ // ─────────────────────────────────────────────────────────────────────────────
193
+ // Async pool (agent._asyncAdvisors — ADVISOR_POOL_LIMIT = 2, launch-and-refuse)
194
+ // ─────────────────────────────────────────────────────────────────────────────
195
+
196
+ export const ADVISOR_POOL_LIMIT = 2
197
+
198
+ export function runningAdvisorCount(agent) {
199
+ return [...(agent?._asyncAdvisors?.values() ?? [])].filter((e) => e.status === "running").length
200
+ }
201
+
202
+ /** Mechanical-failure prefixes (run.mjs resolves with these — never throws): a
203
+ * settle carrying one produced no review verdict and must not satisfy the guard. */
204
+ const ADVISOR_FAILURE_TEXT = /^Advisor: (?:review failed|review timeout|stopped after|interrupted|context window limit|empty response)/
205
+
206
+ /** Any in-flight (non-done) async advisor review? — the completion guard skips
207
+ * its push-back while a review is pending (T-24b4: 未决不算未评审). */
208
+ export function advisorReviewPending(agent) {
209
+ return [...(agent?._asyncAdvisors?.values() ?? [])].some((e) => !e.done)
210
+ }
211
+
212
+ /** Directed cancel (ruling ②-6b — ⏹/cancel): abort the running review's
213
+ * controller (run.mjs signal chain) → the settle callback runs the cancelled
214
+ * branch (no pending entry / no token / "评审已取消——token 未签发" reminder). */
215
+ export function cancelAsyncAdvisor(agent, id) {
216
+ const key = String(id)
217
+ const map = agent?._asyncAdvisors ?? new Map()
218
+ const entry = map.get(key)
219
+ if (!entry) {
220
+ return { id: key, status: "error", error: `unknown async advisor review id: ${key}` }
221
+ }
222
+ if (entry.done) {
223
+ return { id: key, status: "error", error: `async advisor review #${key} has already finished — nothing to cancel` }
224
+ }
225
+ if (entry.cancelled) return { id: key, status: "cancelled" } // abort already in flight — idempotent
226
+ entry.cancelled = true
227
+ entry.controller?.abort?.()
228
+ return { id: key, status: "cancelled" }
229
+ }
230
+
231
+ /**
232
+ * Settle accounting (fix #2/#4 — runs when a non-cancelled review settles,
233
+ * BEFORE the pending transfer / digest injection):
234
+ * 1. round++ (attempts count — parity with the legacy _advisorRound++ budget);
235
+ * 2. stale determination — mutated targets since launch → no called-mark, no token;
236
+ * 3. non-stale: code review → _calledAdvisorThisRun = true; design review →
237
+ * token echo check (settleDesignReview: slot + instance close; single-value
238
+ * mirror retired per DESIGN-TOKEN-SETTLEMENT D3) + D1 settle-time slot persist
239
+ * (persistEngTokens — write failure = settle failure: no registration, no
240
+ * Approved echo, re-review — 评审 #1);
241
+ * 4. §29 fix B — branch-shaped report output (the caller writes it back to
242
+ * entry.report; digest injects the CLEANED form): pass → stripped +
243
+ * Approved/designId suffix (sync 参照形态); persist-failed → stripped +
244
+ * "D1: …token could NOT be durably written…" re-review notice (no Approved
245
+ * suffix — never an unregistered token); stale → echo stripped +
246
+ * "评审目标已变更——token 未签发" prefix — never an unregistered token.
247
+ * Cancelled / parent-aborted reviews consume nothing (the user dropped the
248
+ * attempt — the retry must not lose budget).
249
+ * @returns {{cancelled: boolean, stale: boolean, passed: boolean, report: string|null}}
250
+ */
251
+ export function settleAdvisorRun(agent, entry) {
252
+ if (entry.cancelled) return { cancelled: true, stale: false, passed: false, report: null }
253
+ const run = entry.run
254
+ if (!run) return { cancelled: false, stale: false, passed: false, report: entry.report ?? null }
255
+ const result = entry.report ?? null
256
+ run.round++
257
+ agent._advisorRound = run.round
258
+ const stale = reviewIsStale(agent, entry)
259
+ run.stale = stale
260
+ let report = result
261
+ let passed = false
262
+ if (!stale) {
263
+ if (run.reviewType === "design" && entry.designToken && result) {
264
+ // settle 前 Map 快照——落盘失败时回滚用(settle 失败 = 结算未发生,不留半结算态:
265
+ // 重评覆盖旧槽的边角(F2h 复用 designId)也原样恢复旧 token——内存与盘一致)。
266
+ const preMap = agent._engDesignTokens instanceof Map
267
+ ? new Map(agent._engDesignTokens)
268
+ : null
269
+ const settled = settleDesignReview(agent, run, entry.designToken, result)
270
+ if (settled.passed) {
271
+ // DESIGN-TOKEN-SETTLEMENT D1(2026-09-08):settle 是唯一结算点——settle 当场
272
+ // 同步落盘 token 字段到槽文件(persistEngTokens = engTokenSlotFields 序列化 +
273
+ // session 安全写/轮转——勿裸写文件),不等下个回合尾 saveSession(消除"settle→
274
+ // 下个 saveSession"间的重启丢 token 窗口)。
275
+ // 写失败即 settle 失败(评审 #1):token 不注册(Map 回滚到 settle 前快照)、无
276
+ // Approved 回显、可重评——不静默吞错、不产生"内存有盘上无"态(宁可结算失败
277
+ // 可重评,不留半结算态)。
278
+ let durable = false
279
+ try {
280
+ durable = persistEngTokens(agent)
281
+ } catch (e) {
282
+ logEvent("advisor:error", { id: `advisor#${entry.id}`, err: `engDesignTokens slot persist threw: ${e?.message ?? String(e)}` })
283
+ }
284
+ if (durable) {
285
+ passed = true
286
+ report = settled.output
287
+ } else {
288
+ if (preMap) agent._engDesignTokens = preMap
289
+ else delete agent._engDesignTokens
290
+ run.approvedSuffix = null
291
+ logEvent("advisor:error", { id: `advisor#${entry.id}`, err: "engDesignTokens slot persist failed — settle failed (re-review)" })
292
+ const stripped = String(result)
293
+ .replace(makeDesignTokenRegex(entry.designToken, "g"), "")
294
+ .trim()
295
+ report = `${stripped}\n\nD1: the design review passed but the token could NOT be durably written to the session ledger (slot persist failed) — re-run advisor(type='design') to re-issue; no eng-coder spawn is authorized for this review (评审通过但 token 未能持久化——需重评).`.trim()
296
+ }
297
+ } else {
298
+ report = settled.output
299
+ }
300
+ }
301
+ // A completed review covers the code face ONLY when it actually produced a
302
+ // verdict: a mechanical-failure settle ("Advisor: review failed/timeout/…" —
303
+ // run.mjs resolves with the failure text, it never throws) must not satisfy
304
+ // the guard silently — the digest shows the failure and the guard pushes
305
+ // back for a retry (fix #2 anti-silent-skip intent; attempts still consume
306
+ // the per-review round budget, so repeated failures stay bounded by the
307
+ // cap). An error settle (rejection path — report null with an error) has no
308
+ // verdict either — same exclusion (advisor 复评补边). Design reviews keep
309
+ // the mark on any completed verdict (parity with the sync recordToolResults
310
+ // mark — they have no code face to cover).
311
+ const failureVerdict = run.reviewType !== "design" && (
312
+ ADVISOR_FAILURE_TEXT.test(String(report ?? "")) ||
313
+ (result == null && entry.error != null)
314
+ )
315
+ if (!failureVerdict) {
316
+ agent._calledAdvisorThisRun = true
317
+ }
318
+ } else if (run.reviewType === "design" && entry.designToken && result != null) {
319
+ // §29 fix B(stale 分支):陈旧评审不签发——digest 不得展示未注册 token——先剥
320
+ // 方括号回显 + 前置 "评审目标已变更——token 未签发"(不变式——两分支都清洗)。
321
+ const stripped = String(result)
322
+ .replace(makeDesignTokenRegex(entry.designToken, "g"), "")
323
+ .trim()
324
+ report = `评审目标已变更——token 未签发 (review target changed after launch — this review judged a stale state; no design token was issued — re-run the review on the current state)\n\n${stripped}`.trim()
325
+ }
326
+ // Prior of round 2+ = the last REVIEW-LOOKING output (mirror of run.mjs's guard).
327
+ // F2e (§29.1): strip the engine-approved suffix FIRST — the prior must never
328
+ // carry the raw token / designId (exact truncation — zero collateral).
329
+ if (report && looksLikeReviewOutput(report)) {
330
+ run.priorOutput = stripApprovedSuffix(report, run.approvedSuffix)
331
+ }
332
+ return { cancelled: false, stale, passed, report }
333
+ }
334
+
335
+ /** Relay an advisor onOutput chunk into the subagent block channel (`advisor#N/`
336
+ * prefix — kind-preserving: think via onReasoning, tool via onToolOutput, text
337
+ * via onToken; the wait placeholder is stripped — it is a live indicator, not
338
+ * content, and the sync path strips it at freeze too). */
339
+ function relayAdvisorOutput(callbacks, prefix, chunk) {
340
+ if (!callbacks) return
341
+ const kind = typeof chunk === "string" ? "text" : (chunk?.kind ?? "text")
342
+ const text = typeof chunk === "string" ? chunk : String(chunk?.text ?? "")
343
+ if (!text) return
344
+ if (kind === "think") {
345
+ callbacks.onReasoning?.(prefix + text.replaceAll(ADVISOR_THINKING_PLACEHOLDER, ""))
346
+ } else if (kind === "tool") {
347
+ callbacks.onToolOutput?.(prefix + "tool", { kind: "tool", text })
348
+ } else {
349
+ callbacks.onToken?.(prefix + stripEventToken(text))
350
+ }
351
+ }
352
+
353
+ /**
354
+ * Launch an async advisor review (pool entry + background runner + settle
355
+ * wiring — mirrors subagent-run.mjs's lifecycle, minus queueing/scheduler).
356
+ * The runner wraps runAdvisorReview (promise → report/error) — the subagent
357
+ * pipeline is untouched (ruling ②-2 A). The entry carries role "advisor" so the
358
+ * digest/panel/freeze consumers route it like any other settled block.
359
+ * @returns {{ok: true, id: string}} — ack; or {{error: string}} — pool full
360
+ * (ADVISOR_POOL_LIMIT — "另有一评审在跑——逐个发起", never queued) or invalid ctx.
361
+ */
362
+ export function launchAsyncAdvisor(parent, ctx, launch) {
363
+ const { reviewType, documents, paths, object, designToken, designId, run } = launch
364
+ if (runningAdvisorCount(parent) >= ADVISOR_POOL_LIMIT) {
365
+ return { error: `Advisor: 另有一评审在跑——逐个发起 — another review is already running in the background pool (${ADVISOR_POOL_LIMIT} reviews at most); launch them one at a time (AGENT-LOOP.md §24 D-24b ruling ②-6a).` }
366
+ }
367
+ parent._asyncAdvisors ??= new Map()
368
+ const id = (parent._subAgentCounter = (parent._subAgentCounter ?? 0) + 1)
369
+ const entry = {
370
+ id, role: "advisor", reviewType, run,
371
+ reviewId: run.reviewId, designId, designToken, documents, paths, object,
372
+ launchSeq: mutationSeqOf(parent),
373
+ docAbs: reviewType === "design" && Array.isArray(documents)
374
+ ? documents.filter((d) => typeof d === "string").map((d) => normAbs(d, parent.cwd))
375
+ : [],
376
+ relayPrefix: `advisor#${id}/`,
377
+ status: "running", position: undefined,
378
+ report: null, error: null, done: false, cancelled: false,
379
+ promise: null, _settle: null,
380
+ model: (() => { try { return resolveAdvisorProvider(parent).model ?? null } catch { return null } })(),
381
+ startedAt: Date.now(), turn: 0, maxTurns: 0,
382
+ controller: null,
383
+ }
384
+ // Entry controller chained to the session/run base signal (subagent-run parity):
385
+ // Ctrl+C / session abort propagates into the review's chat; a digest's own
386
+ // Ctrl+I must not orphan it (children hold the session signal while suspended).
387
+ // D6 buildChildSignal 单点(ASYNC-RESULT-CONTAINER.md)。
388
+ const ctrl = new AbortController()
389
+ entry.controller = ctrl
390
+ const baseSignal = buildChildSignal(parent, ctx)
391
+ if (baseSignal) {
392
+ if (baseSignal.aborted) ctrl.abort()
393
+ else baseSignal.addEventListener("abort", () => ctrl.abort(), { once: true })
394
+ }
395
+ entry.promise = new Promise((res) => { entry._settle = res })
396
+ entry.start = () => {
397
+ entry.startedAt = Date.now()
398
+ // Async mark + [model] — the TUI block opens at ACTUAL start (⏹ gating reads it).
399
+ ctx?.callbacks?.onToken?.(entry.relayPrefix + "⟦ev⟧async\x1e")
400
+ ctx?.callbacks?.onToken?.(entry.relayPrefix + "[model]" + (entry.model ?? ""))
401
+ runAdvisorReview(parent, reviewType, {
402
+ onOutput: (chunk) => relayAdvisorOutput(ctx?.callbacks, entry.relayPrefix, chunk),
403
+ signal: entry.controller.signal,
404
+ }, designToken, documents, paths, object, designId)
405
+ .then((report) => { entry.report = report })
406
+ .catch((err) => { entry.error = err?.message ?? String(err) })
407
+ .finally(() => {
408
+ // settle 公共收尾单点(ASYNC-RESULT-CONTAINER.md D3——settleAsyncEntry):日志三连
409
+ // /cancelled 分支(出池+墓碑+⟦ev⟧stopped+"评审已取消——token 未签发"提醒)/挂起分流
410
+ // (pending 单容器+出池——统一守卫 !parentAborted——D4)/公共尾部(settleSeq/_settle/
411
+ // 唤醒 waiter)统一走共享 helper。族特有 hook = settleAdvisorRun 记账(fix #2/#4——
412
+ // 陈旧判定/轮次/token D1 落盘——settle 分支输出写回 entry.report,digest 原样进);
413
+ // cancelled/parent-aborted 不调(不消费预算——settleAdvisorRun 自己的早退语义等价)。
414
+ settleAsyncEntry(parent, entry, {
415
+ pool: parent._asyncAdvisors,
416
+ ctx,
417
+ onAccounting: () => {
418
+ const settled = settleAdvisorRun(parent, entry)
419
+ // §29 fix B:settle 分支输出(清洗/未签发提示)写回 entry.report——digest 原样进。
420
+ if (settled.report != null) entry.report = settled.report
421
+ },
422
+ })
423
+ })
424
+ }
425
+ parent._asyncAdvisors.set(String(id), entry)
426
+ logEvent("child:spawn", { role: "advisor", id: `advisor#${id}`, kind: "async", status: "running", ms: 0 })
427
+ entry.start()
428
+ return { ok: true, id: String(id) }
429
+ }
430
+
431
+ /**
432
+ * Close every OPEN code instance at a normal non-auto run end (finalizeAgentTurn)
433
+ * when no review/child activity remains: a code thread that settled and whose
434
+ * digest was consumed must not burn the 5-round cap of a later task — the next
435
+ * code review starts a fresh full review (round 1). Runs that END with a review
436
+ * or an eng-coder child in flight keep the thread open (fix-round continuation).
437
+ * @returns {boolean} whether any instance was closed
438
+ */
439
+ export function closeOpenCodeAdvisorRuns(agent) {
440
+ const advisors = agent?._asyncAdvisors
441
+ // Any pooled advisor entry — running OR settled-not-consumed — keeps the
442
+ // thread open: an in-run settle whose report has not reached the model (the
443
+ // suspension sweep / digest consumes it next) must still be continuable by
444
+ // the fix round that follows the disposition.
445
+ if ((advisors?.size ?? 0) > 0) return false
446
+ if ((agent?._asyncSubagents?.size ?? 0) > 0) return false
447
+ if ((agent?._pendingAsyncResults ?? []).some((e) => e.role === "advisor")) return false
448
+ let closed = false
449
+ const runs = agent?._advisorRuns
450
+ if (runs instanceof Map) {
451
+ for (const r of runs.values()) {
452
+ if (r.reviewType === "code" && r.open) { r.open = false; closed = true }
453
+ }
454
+ }
455
+ return closed
456
+ }