thincoder 0.12.58 → 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 (158) hide show
  1. package/CHANGELOG.md +78 -2
  2. package/README.md +3 -3
  3. package/bin/thincoder.mjs +88 -19
  4. package/package.json +4 -3
  5. package/src/acp/bridge.mjs +135 -26
  6. package/src/advisor/messages.mjs +57 -4
  7. package/src/advisor/run.mjs +119 -79
  8. package/src/advisor.mjs +34 -7
  9. package/src/agent/completion.mjs +17 -11
  10. package/src/agent/dispatch.mjs +182 -22
  11. package/src/agent/helpers.mjs +71 -4
  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 +107 -20
  16. package/src/agent/spawn-child.mjs +54 -4
  17. package/src/agent-tools/advisor-async.mjs +456 -0
  18. package/src/agent-tools/advisor.mjs +133 -109
  19. package/src/agent-tools/async-settle.mjs +191 -0
  20. package/src/agent-tools/consult.mjs +154 -104
  21. package/src/agent-tools/design-token.mjs +104 -0
  22. package/src/agent-tools/eng.mjs +26 -30
  23. package/src/agent-tools/escalate-async.mjs +286 -0
  24. package/src/agent-tools/goal.mjs +11 -1
  25. package/src/agent-tools/read-history.mjs +284 -0
  26. package/src/agent-tools/recent-changes.mjs +2 -1
  27. package/src/agent-tools/settings.mjs +152 -0
  28. package/src/agent-tools/skill.mjs +2 -1
  29. package/src/agent-tools/subagent-actions.mjs +470 -0
  30. package/src/agent-tools/subagent-async.mjs +382 -0
  31. package/src/agent-tools/subagent-panel.mjs +153 -0
  32. package/src/agent-tools/subagent-run.mjs +202 -0
  33. package/src/agent-tools/subagent-scheduler.mjs +343 -0
  34. package/src/agent-tools/subagent-spawn.mjs +406 -0
  35. package/src/agent-tools/subagent.mjs +203 -377
  36. package/src/agent-tools/task.mjs +4 -3
  37. package/src/agent-tools/timer.mjs +9 -4
  38. package/src/agent-tools/verify.mjs +198 -238
  39. package/src/agent-tools.mjs +1 -0
  40. package/src/agent.mjs +145 -242
  41. package/src/auto-think.mjs +14 -0
  42. package/src/cli/distill-command.mjs +10 -4
  43. package/src/cli/make-agent.mjs +4 -1
  44. package/src/cli/memory-command.mjs +2 -1
  45. package/src/cli/permission.mjs +8 -1
  46. package/src/cli/setup-wizard.mjs +17 -12
  47. package/src/config.mjs +61 -8
  48. package/src/context.mjs +81 -163
  49. package/src/crash-reports.mjs +123 -0
  50. package/src/distill.mjs +30 -12
  51. package/src/escape.mjs +6 -4
  52. package/src/explore-distill.mjs +155 -0
  53. package/src/log.mjs +195 -0
  54. package/src/memory/code-sync.mjs +2 -1
  55. package/src/memory/core.mjs +11 -72
  56. package/src/memory/delete.mjs +234 -0
  57. package/src/memory/docs.mjs +206 -87
  58. package/src/memory.mjs +3 -1
  59. package/src/model-specs.mjs +15 -1
  60. package/src/peer-domains.mjs +265 -0
  61. package/src/peer-instances.mjs +231 -0
  62. package/src/prompt-overlays.mjs +25 -0
  63. package/src/prompts/advisor-design.md +18 -39
  64. package/src/prompts/advisor-round1.md +20 -32
  65. package/src/prompts/advisor-round2.md +16 -16
  66. package/src/prompts/advisor-round3.md +16 -16
  67. package/src/prompts/coder.md +7 -28
  68. package/src/prompts/consult-base.md +4 -11
  69. package/src/prompts/discipline.md +31 -44
  70. package/src/prompts/eng-coder.md +9 -34
  71. package/src/prompts/engineering-sub.md +10 -8
  72. package/src/prompts/engineering.md +61 -264
  73. package/src/prompts/explore.md +4 -14
  74. package/src/prompts/main.md +18 -35
  75. package/src/prompts/methodology-template.md +32 -38
  76. package/src/prompts/plan.md +2 -9
  77. package/src/prompts/system.md +18 -35
  78. package/src/provider/core.mjs +62 -69
  79. package/src/provider/errors.mjs +76 -0
  80. package/src/provider/retry.mjs +8 -45
  81. package/src/session-gc.mjs +214 -0
  82. package/src/session-guard.mjs +47 -0
  83. package/src/session-rename.mjs +38 -0
  84. package/src/session-slots.mjs +181 -58
  85. package/src/session.mjs +48 -89
  86. package/src/token-ttl.mjs +273 -0
  87. package/src/tools/apply_patch.md +3 -1
  88. package/src/tools/bash.md +1 -1
  89. package/src/tools/checklist-sync.mjs +181 -0
  90. package/src/tools/checklist.mjs +52 -39
  91. package/src/tools/delete.md +1 -0
  92. package/src/tools/edit-batch.mjs +131 -44
  93. package/src/tools/edit-diff.mjs +348 -0
  94. package/src/tools/edit.md +20 -13
  95. package/src/tools/execute.md +7 -7
  96. package/src/tools/execute.mjs +55 -24
  97. package/src/tools/file.mjs +25 -70
  98. package/src/tools/file_ops.md +2 -1
  99. package/src/tools/get_current_time.md +3 -1
  100. package/src/tools/git.mjs +14 -6
  101. package/src/tools/glob-dialect.mjs +130 -0
  102. package/src/tools/glob.md +3 -3
  103. package/src/tools/grep.md +1 -1
  104. package/src/tools/hashline_edit.md +2 -0
  105. package/src/tools/index.mjs +3 -3
  106. package/src/tools/insert_after.md +2 -1
  107. package/src/tools/lint.md +2 -0
  108. package/src/tools/lsp.md +4 -1
  109. package/src/tools/ops.mjs +175 -3
  110. package/src/tools/patch.mjs +84 -13
  111. package/src/tools/question.md +5 -1
  112. package/src/tools/repomap.mjs +1 -1
  113. package/src/tools/shared.mjs +18 -25
  114. package/src/tools/system.mjs +50 -30
  115. package/src/tools/tree.md +2 -1
  116. package/src/tools/wait_for.md +22 -0
  117. package/src/tools/web.mjs +5 -3
  118. package/src/tools/websearch.md +2 -1
  119. package/src/tools/write.md +2 -0
  120. package/src/traces/trace-store.mjs +224 -0
  121. package/src/tui/agent-turn.mjs +179 -27
  122. package/src/tui/clipboard.mjs +15 -4
  123. package/src/tui/cmd-config.mjs +77 -16
  124. package/src/tui/cmd-eng.mjs +20 -16
  125. package/src/tui/cmd-extract.mjs +1 -1
  126. package/src/tui/cmd-mcp.mjs +17 -2
  127. package/src/tui/cmd-new.mjs +3 -2
  128. package/src/tui/cmd-session.mjs +19 -4
  129. package/src/tui/cmd-think.mjs +11 -11
  130. package/src/tui/cmd-upgrade.mjs +19 -4
  131. package/src/tui/config-helpers.mjs +28 -16
  132. package/src/tui/distill-cmd.mjs +1 -1
  133. package/src/tui/index.mjs +31 -96
  134. package/src/tui/interaction.mjs +13 -2
  135. package/src/tui/key-handler.mjs +105 -155
  136. package/src/tui/key-modes.mjs +215 -0
  137. package/src/tui/layout.mjs +22 -1
  138. package/src/tui/mouse.mjs +46 -0
  139. package/src/tui/pickers.mjs +51 -25
  140. package/src/tui/render-conversation.mjs +13 -161
  141. package/src/tui/render-frame.mjs +27 -10
  142. package/src/tui/render-loop.mjs +4 -1
  143. package/src/tui/render-segments.mjs +182 -0
  144. package/src/tui/startup.mjs +40 -0
  145. package/src/tui/subagent-blocks.mjs +272 -262
  146. package/src/tui/subagent-children.mjs +176 -0
  147. package/src/tui/subagent-freeze.mjs +172 -0
  148. package/src/tui/subagent-panel.mjs +125 -12
  149. package/src/tui/suspension-drive.mjs +351 -0
  150. package/src/tui/tool-args.mjs +10 -2
  151. package/src/tui/tool-display.mjs +142 -0
  152. package/src/tui/tool-events.mjs +127 -231
  153. package/src/tui/tui-lifecycle.mjs +29 -0
  154. package/src/tui/update-notice.mjs +76 -0
  155. package/src/tui/wizard.mjs +48 -12
  156. package/src/agent-tools/escalate.mjs +0 -179
  157. package/src/agent-tools/subagent-check.mjs +0 -107
  158. package/src/tools/exec-prelude.mjs +0 -84
@@ -1,19 +1,35 @@
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"
29
+ import { logEvent, errText } from "../log.mjs"
16
30
  import { makeRelay, wrapChildCallbacks, runWithContinue, ensureChildApiKey, clampEffort } from "../agent/spawn-child.mjs"
31
+ // ASYNC-RESULT-CONTAINER.md D2/D3/D6:pending 单容器停靠 + settle 公共收尾 + child signal 单点
32
+ import { buildChildSignal, settleAsyncEntry } from "./async-settle.mjs"
17
33
 
18
34
  // Named consult defaults (consult P2, 2026-08-30).
19
35
  const CONSULT_TIMEOUT_MS = 600_000 // default consult lifecycle timeout
@@ -27,7 +43,7 @@ function consultLabel(m) {
27
43
  /** Narrow the configured consultModels pool to a requested subset.
28
44
  * Each selector is "provider:model", a bare provider name, or a bare model name
29
45
  * (case-insensitive). A trailing " (effort)" suffix is tolerated (round2 复核
30
- * 对齐 escalate.mjs:withPool 列表会带 " (high)" 后缀,模型照抄应可匹配).
46
+ * 对齐 escalate 动作(subagent action:"escalate"):withPool 列表会带 " (high)" 后缀,模型照抄应可匹配).
31
47
  * Returns { models, error } — error set when a selector matches
32
48
  * nothing (surface the typo rather than silently dropping it). Absent/empty selectors
33
49
  * → the full pool. */
@@ -111,13 +127,66 @@ export function makeMainHistoryTool(parentAgent) {
111
127
  }
112
128
  }
113
129
 
114
- /** Wake every parked consult_check waiter. */
115
- function wakeWaiters(session) {
116
- const w = session.waiters.splice(0)
117
- for (const resolve of w) { try { resolve(false) } catch { /* noop */ } }
130
+ /**
131
+ * Full-session settle routing (R17 — AGENT-LOOP.md §25 D-R17a): a session whose
132
+ * pending count reached 0 has no more replies coming — the session leaves
133
+ * `_consultSessions` and, unless it was cancelled (consult_stop / turn-end
134
+ * abort), moves into the pending single container (`_pendingAsyncResults` +role
135
+ * "consult"——ASYNC-RESULT-CONTAINER.md D2——升格完整 entry:同 subagent/advisor/
136
+ * escalate 的 `{id, role, report, done, ...}` 形态)whose report carries the full
137
+ * per-model verdict text (composed here — all settle states are known,
138
+ * partial/full failures annotated per model). The entry is injected at the next
139
+ * run start (user turn or digest auto-turn — agent.mjs); the suspension driver
140
+ * is woken (settle-event parity with the async pools) so an idle settle still
141
+ * triggers the digestion round (T-R17j).
142
+ * D3:公共收尾统一走 settleAsyncEntry 共享 helper(四族同机制)——consult 族参数:
143
+ * 无池(会话池无条目——settle 即出池)、无 ctx(无 TUI 冻结事件——子块各自 settle 时
144
+ * 已冻结)、无 onAccounting;helper 按 role "consult" 恒停靠 pending(非挂起期也停靠)。
145
+ * Cancelled sessions produce no digest (T-R17c).
146
+ */
147
+ function sessionSettled(agent, session) {
148
+ agent?._consultSessions?.delete(String(session.id))
149
+ if (session.stopped) return // cancelled — no digest (T-R17c)
150
+ const entry = {
151
+ id: String(session.id),
152
+ role: "consult",
153
+ report: composeConsultDigest(session),
154
+ error: null, done: true, status: "done", cancelled: false,
155
+ relayPrefix: null, startedAt: null, _settle: null, _settleSeq: 0,
156
+ }
157
+ settleAsyncEntry(agent, entry, { pool: null, ctx: null })
158
+ }
159
+
160
+ /** Digest body for a fully-settled session — title + one annotated line per
161
+ * reply (failed replies marked per-model — round2 #7; full text is injected
162
+ * verbatim and may be >64K → offloaded with a preview at injection). */
163
+ export function composeConsultDigest(session) {
164
+ const replies = session.replies ?? []
165
+ const parts = replies.map((r) =>
166
+ r.failed
167
+ ? `- [${r.model}] (failed): ${r.reply}`
168
+ : `- [${r.model}]: ${r.reply}`)
169
+ const counts = `${replies.length} of ${session.total} models replied (${session.failed} failed)`
170
+ return `[System reminder: consultation #${session.id} finished — ${counts}]\n${parts.join("\n")}`
118
171
  }
119
172
 
120
- function settleChild(session, id, label, ok, payload) {
173
+ /**
174
+ * Inject one settled consult family entry into the parent history as a
175
+ * user-role reminder (run-start injection — agent.mjs; same shape rules as
176
+ * injectAsyncResult: XML-escaped, >64K offloaded with preview + path). Consumed
177
+ * = the caller splices the entry out of the pending single container
178
+ * (_pendingAsyncResults——ASYNC-RESULT-CONTAINER.md D2——role 分发注入)。
179
+ */
180
+ export async function injectConsultResult(agent, entry) {
181
+ const body = entry?.report ?? "(no consultation result)"
182
+ const preview = await offloadToolResult(String(body), `consult-${entry.id ?? "session"}`)
183
+ pushReal(agent, {
184
+ role: "user",
185
+ content: escapeXml(preview),
186
+ })
187
+ }
188
+
189
+ function settleChild(agent, session, id, label, ok, payload, emitDone) {
121
190
  if (ok) {
122
191
  session.received++
123
192
  session.replies.push({ model: label, reply: payload })
@@ -128,7 +197,8 @@ function settleChild(session, id, label, ok, payload) {
128
197
  session.replies.push({ model: label, reply: `(consultation failed: ${payload})`, failed: true })
129
198
  }
130
199
  session.pending--
131
- wakeWaiters(session)
200
+ emitDone?.() // per-child TUI block freeze at settle (R17 — child's activity card is done)
201
+ if (session.pending === 0) sessionSettled(agent, session)
132
202
  }
133
203
 
134
204
  async function runConsultChild(ctx, session, id, m, problem, ctrl) {
@@ -145,6 +215,27 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
145
215
  }
146
216
  let watchdog = armWatchdog()
147
217
  const label = consultLabel(m)
218
+ // LOGGING(LOGGING.md):child:*(consult)——logSettle 在函数作用域声明(外层 catch
219
+ // 覆盖 spawn 前失败路径);spawn 事件在 relay 建立后发射(logArmed 翻转——provider/
220
+ // 创建失败 = 从未启动,不落子事件、错误仅经 settleChild 进会话)。
221
+ let childLogId = null
222
+ let logT0 = 0
223
+ let logArmed = false
224
+ let logDone = false
225
+ const logSettle = (kind, payload) => {
226
+ if (!logArmed || logDone || !childLogId) return
227
+ logDone = true
228
+ const ms = Date.now() - logT0
229
+ const base = { role: "consult", id: childLogId, ms }
230
+ if (kind === "ok" || kind === "partial") logEvent("child:done", { ...base, kind })
231
+ else logEvent("child:error", { ...base, err: errText(payload, 200) })
232
+ }
233
+ // R17: relay prefix assigned before the child runner arms — the per-child TUI
234
+ // block freeze emits only when a block actually exists (relay established).
235
+ let relayPrefix = null
236
+ const settle = (ok, payload) => settleChild(agent, session, id, label, ok, payload, relayPrefix
237
+ ? () => ctx.callbacks?.onToken?.(`${relayPrefix}⟦ev⟧done\x1e0\x1e0\x1edone\x1e`)
238
+ : null)
148
239
  try {
149
240
  // Provider resolution: consultModels entries are { provider, model, effort? } — resolve
150
241
  // via the subagent's provider resolver ("provider:model" handles cross-provider picks).
@@ -156,9 +247,10 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
156
247
  }
157
248
  // Clamp the pool's effort to the model's reasoningEffortEnum — an out-of-enum
158
249
  // value makes provider/core throw on EVERY chat call (candidate dies on takeoff).
159
- // Symmetric with escalate.mjs; 2026-08-16 a real consult died on qwen3.8-max
160
- // effort "high" (enum is xhigh/medium/low). Out-of-enum: DROP the effort entirely
161
- // (the provider preset default may ALSO be out-of-enum for this override model).
250
+ // Symmetric with the escalate action (subagent action:"escalate"); 2026-08-16
251
+ // a real consult died on qwen3.8-max effort "high" (enum is xhigh/medium/low).
252
+ // Out-of-enum: DROP the effort entirely (the provider preset default may ALSO be
253
+ // out-of-enum for this override model).
162
254
  clampEffort(provider, m.model, m.effort)
163
255
 
164
256
  // Read-only consultant: filter the parent tool set down to readonly tools + main_history.
@@ -182,7 +274,13 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
182
274
  // Activity relay via the unified spawn-child pipeline (§7.2 D3): `consult#<subId>/`
183
275
  // prefix (same channel subagent uses — parallel consultants stay independent) +
184
276
  // onToolOutput passthrough so the consultant's tool output lands in its TUI block.
185
- const relayPrefix = makeRelay(agent, "consult", ctx.callbacks?.onToken, provider.model ?? "")
277
+ relayPrefix = makeRelay(agent, "consult", ctx.callbacks?.onToken, provider.model ?? "")
278
+ // LOGGING:arm(spawn 事件——relay 建立后;子内事件归属 _logId)
279
+ childLogId = relayPrefix.slice(0, -1)
280
+ child._logId = childLogId
281
+ logT0 = Date.now()
282
+ logArmed = true
283
+ logEvent("child:spawn", { role: "consult", id: childLogId, kind: "consult" })
186
284
  const childCallbacks = wrapChildCallbacks(relayPrefix, ctx.callbacks ?? {})
187
285
  let declined = false // review #1: guard against double-settle when onDeclined fired
188
286
 
@@ -215,46 +313,50 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
215
313
  },
216
314
  onDeclined: (e) => {
217
315
  declined = true
218
- settleChild(session, id, label, false, `turn cap reached (${e.turn} turns) — stopped, diagnosis may be partial`)
316
+ settle(false, `turn cap reached (${e.turn} turns) — stopped, diagnosis may be partial`)
317
+ logSettle("partial", null)
219
318
  return undefined
220
319
  },
221
320
  },
222
321
  )
223
322
  // Review #1 fix: onDeclined already settled this child as a failed reply —
224
323
  // settling again here would push a phantom empty success reply and decrement
225
- // `pending` twice (negative pending consult_check's two exits both
226
- // unreachable → permanent block until user abort).
227
- if (!declined) settleChild(session, id, label, true, String(result ?? ""))
324
+ // `pending` twice (negative pending would re-enter the settle routing).
325
+ if (!declined) {
326
+ settle(true, String(result ?? ""))
327
+ logSettle("ok", null)
328
+ }
228
329
  } catch (e) {
229
330
  // Runner errors (incl. the watchdog's abort) settle as a failed reply — the
230
331
  // continue/declined paths are already handled inside runWithContinue.
231
332
  const note = timedOut ? `consultation timed out after ${Math.round(timeoutMs / 60000)}min (agent.consultTimeoutMs)` : e?.message ?? String(e)
232
- settleChild(session, id, label, false, note)
333
+ settle(false, note)
334
+ logSettle("error", note)
233
335
  }
234
336
  } catch (e) {
235
337
  // Errors BEFORE the runner (provider resolution, createAgent) or a throwing
236
338
  // continue-prompt settle as failed replies — the runner's own errors are already
237
- // handled inside the loop above.
238
- settleChild(session, id, label, false, e?.message ?? String(e))
339
+ // handled inside the loop above. relayPrefix is null on these paths — no TUI
340
+ // block was ever opened, so no freeze event is emitted.
341
+ settle(false, e?.message ?? String(e))
342
+ logSettle("error", e?.message ?? String(e))
239
343
  } finally {
240
344
  clearTimeout(watchdog)
241
345
  }
242
346
  }
243
347
 
244
- /** Turn-end cleanup (called from runAgent's finally): abort every leftover
245
- * consultation controller, wake parked waiters, clear the session map. */
348
+ /** Turn-end / session-end abort cleanup (R17 call sites: the Ctrl+C abort
349
+ * branches of finalizeAgentTurn and the suspension driver). Consultation
350
+ * sessions are now cross-turn background work (like async subagents): a NORMAL
351
+ * turn end keeps them alive — this runs only when the user stops everything:
352
+ * every leftover session is marked stopped (its settles never reach the digest
353
+ * stream — T-R17c) and its controllers aborted. */
246
354
  export function cleanupConsultSessions(agent) {
247
355
  for (const s of agent._consultSessions?.values() ?? []) {
248
356
  s.stopped = true
249
357
  for (const c of s.controllers ?? []) { try { c.abort() } catch { /* already settled */ } }
250
- for (const w of s.waiters?.splice(0) ?? []) { try { w() } catch { /* noop */ } }
251
358
  }
252
359
  agent._consultSessions?.clear()
253
- // NOTE: deliberately void (consult P3, 2026-08-30). The { stopped: true } marker
254
- // only reaches the TUI via the consult_stop TOOL return (onToolResult freezes
255
- // blocks on tool calls) — cleanup runs from the turn finally, where the block
256
- // freeze is owned by freezeAllSubTasks + sweepToolBlocks, so a return here is
257
- // dead weight. Blocks still get frozen on interrupt via that sweep.
258
360
  }
259
361
 
260
362
  export const consultStartTool = {
@@ -265,8 +367,11 @@ export const consultStartTool = {
265
367
  "Start a parallel multi-model consultation (会诊) for a hard problem you are stuck on (repeated failures, no headway). " +
266
368
  "Call it directly when the user asks for 会诊 / consult — an explicit user request applies even if you are not 'stuck'. " +
267
369
  "Several configured models (agent.consultModels) analyze the same problem INDEPENDENTLY and in parallel. " +
268
- "Non-blocking: returns immediately with a consult id. Then call consult_check(id) to read each reply as it " +
269
- "arrives, judge/verify it yourself with your own tools, and call consult_stop(id) once a reply is good enough.\n" +
370
+ "Non-blocking: returns immediately with a consult id; the consultants keep running in the background across turns. " +
371
+ "When EVERY model has replied (or failed), the full verdict text is delivered to you automatically as a system " +
372
+ "reminder at the next run start, or digested on its own while the session is idle — judge and adopt each opinion " +
373
+ "yourself with your own tools (opinions are suggestions, not gates). To stop a session early (user changed their " +
374
+ "mind / wants the tokens back), call consult_stop(id) — a stopped session delivers no digest.\n" +
270
375
  "Parameters:\n" +
271
376
  "- problem (required): a brief — the symptom, what you already tried (failure trail), and entry-point files. " +
272
377
  "Do NOT paste raw error logs; consultants pull the main session history themselves via their main_history tool.\n" +
@@ -296,7 +401,7 @@ export const consultStartTool = {
296
401
  agent._consultSessions ??= new Map()
297
402
  const id = String((agent._consultIdCounter = (agent._consultIdCounter ?? 0) + 1))
298
403
  const session = {
299
- id, controllers: [], replies: [], pending: 0, waiters: [],
404
+ id, controllers: [], replies: [], pending: 0,
300
405
  failed: 0, terminated: 0, stopped: false, received: 0, total: run.length,
301
406
  models: run.map(consultLabel),
302
407
  }
@@ -306,100 +411,45 @@ export const consultStartTool = {
306
411
  session.pending++
307
412
  const ctrl = new AbortController()
308
413
  session.controllers.push(ctrl)
309
- if (ctx.signal) {
310
- if (ctx.signal.aborted) ctrl.abort()
311
- else ctx.signal.addEventListener("abort", () => ctrl.abort(), { once: true })
414
+ // D6 buildChildSignal 单点(ASYNC-RESULT-CONTAINER.md D5——consult 补 _sessionSignal
415
+ // 兜底:挂起会话内的 consult children 持会话 signal,digest 自身 Ctrl+C 不误伤)。
416
+ const baseSignal = buildChildSignal(agent, ctx)
417
+ if (baseSignal) {
418
+ if (baseSignal.aborted) ctrl.abort()
419
+ else baseSignal.addEventListener("abort", () => ctrl.abort(), { once: true })
312
420
  }
313
- // Fire and forget — each child settles itself into the session queue.
421
+ // Fire and forget — each child settles itself into the session; the session
422
+ // routes to the pending single container when every child has settled (R17).
314
423
  runConsultChild(ctx, session, id, m, problem, ctrl)
315
424
  }
316
425
  return JSON.stringify({ id, models: session.models })
317
426
  },
318
427
  }
319
428
 
320
- export const consultCheckTool = {
321
- name: "consult_check",
322
- readonly: true,
323
- description:
324
- "Read the NEXT consultation reply (whichever model answered first). Blocks until a reply arrives or all models " +
325
- "have settled. The reply is raw and unjudged — verify/adopt it with your own tools. When done is true, no more " +
326
- "replies are coming.\n" +
327
- "Call it ALONE in a turn — do NOT batch it with calls that depend on its reply (readonly tools run in parallel).\n" +
328
- "Replies arrive in arrival order: call it repeatedly (n = 1, 2, 3, …) until done is true.\n" +
329
- "Parameters:\n" +
330
- "- id (required): the consult id from consult_start\n" +
331
- "- 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.",
332
- parameters: {
333
- type: "object",
334
- properties: {
335
- id: { type: "string", description: "Consult id" },
336
- n: { type: "number", description: "1-based read number: 1 for the first check, incrementing with each subsequent check of the same consult" },
337
- },
338
- required: ["id", "n"],
339
- },
340
- async execute({ id, n: _n }, ctx) {
341
- const s = ctx.agent?._consultSessions?.get(String(id))
342
- if (!s) return JSON.stringify({ error: "unknown consult id" })
343
- const abortAll = () => { for (const c of s.controllers) { try { c.abort() } catch { /* noop */ } } }
344
- if (ctx.signal?.aborted) abortAll()
345
-
346
- for (;;) {
347
- if (s.replies.length > 0) {
348
- const r = s.replies.shift()
349
- return JSON.stringify({
350
- reply: r.reply, model: r.model, failedReply: r.failed === true,
351
- received: s.received,
352
- failed: s.failed,
353
- terminated: s.terminated ?? 0, total: s.total,
354
- done: s.replies.length === 0 && s.pending === 0,
355
- })
356
- }
357
- if (s.pending === 0) {
358
- return JSON.stringify({ done: true, received: s.received, failed: s.failed, total: s.total })
359
- }
360
- const stopped = await new Promise((resolve) => {
361
- function cleanup() {
362
- const i = s.waiters.indexOf(w)
363
- if (i >= 0) s.waiters.splice(i, 1)
364
- ctx.signal?.removeEventListener("abort", onAbort)
365
- }
366
- function w() { cleanup(); resolve(false) }
367
- function onAbort() { cleanup(); abortAll(); resolve(true) }
368
- s.waiters.push(w)
369
- if (ctx.signal) {
370
- if (ctx.signal.aborted) { onAbort(); return }
371
- ctx.signal.addEventListener("abort", onAbort, { once: true })
372
- }
373
- })
374
- if (stopped) return JSON.stringify({ done: true, stopped: true, received: s.received, failed: s.failed, total: s.total })
375
- }
376
- },
377
- }
378
-
379
429
  export const consultStopTool = {
380
430
  name: "consult_stop",
381
431
  readonly: false,
382
432
  sideEffectExempt: true,
383
433
  description:
384
- "Terminate the still-running consultations of a session once a reply is good enough saves tokens and time. " +
385
- "Already-answered replies stay available for consult_check.\n" +
434
+ "Cancel a still-running consultation session (会诊) the user changed their mind, the problem resolved, or you want the tokens back. " +
435
+ "Aborts every consultant that is still running; a stopped session delivers NO digest (R17 — its already-collected partial replies are dropped). " +
436
+ "Sessions that finished on their own are no longer cancellable — their verdict text is delivered automatically.\n" +
437
+ "Returns JSON {abandoned: <pending count>, cancelled: true} — or {error: \"unknown consult id\"} (already finished/cancelled).\n" +
386
438
  "Parameters:\n" +
387
- "- id (required): the consult id from consult_start\n" +
388
- "- n (required): incrementing call number for this consult (next value after the last consult_check/consult_stop) — keeps repeated calls distinct.",
439
+ "- id (required): the consult id from consult_start",
389
440
  parameters: {
390
441
  type: "object",
391
442
  properties: {
392
443
  id: { type: "string", description: "Consult id" },
393
- n: { type: "number", description: "Incrementing call number for this consult (see consult_check)" },
394
444
  },
395
- required: ["id", "n"],
445
+ required: ["id"],
396
446
  },
397
- async execute({ id, n }, ctx) {
447
+ async execute({ id }, ctx) {
398
448
  const s = ctx.agent?._consultSessions?.get(String(id))
399
449
  if (!s) return JSON.stringify({ error: "unknown consult id" })
400
450
  const abandoned = s.pending
401
451
  s.stopped = true
402
452
  for (const c of s.controllers) { try { c.abort() } catch { /* already settled */ } }
403
- return JSON.stringify({ stopped: n, abandoned })
453
+ return JSON.stringify({ abandoned, cancelled: true })
404
454
  },
405
455
  }
@@ -0,0 +1,104 @@
1
+ /**
2
+ * design-token.mjs — design-token utilities(2026-09-08 自 advisor-async.mjs 提取——
3
+ * advisor-async 随 DESIGN-TOKEN-SETTLEMENT D1 落盘块再越 500 行硬限;本工具组 verbatim
4
+ * 迁入,零语义变)。
5
+ *
6
+ * 沿革:初由 agent-tools/advisor.mjs 迁至 advisor-async.mjs(sync wrapper 与 async
7
+ * settle 共享一套实现——无 wrapper↔runner 环),再由 advisor-async.mjs 迁入本文件。
8
+ * 既有 import 面不变:advisor-async.mjs re-export 全部 7 个导出(advisor.mjs 与测试
9
+ * 的 import 路径原样保留——advisor.mjs 再转发 validateDesignToken 给 spawn 门禁)。
10
+ */
11
+
12
+ import { randomUUID } from "node:crypto"
13
+ import { tokenExpiryMs } from "../token-ttl.mjs"
14
+
15
+ /**
16
+ * F2c/F2e (§29.1 2026-09-07): the engine-generated Approved suffix — ONE builder
17
+ * shared by the sync settle and the async settle (and the prior stores, which
18
+ * strip it with stripApprovedSuffix — exact-suffix truncation, never a regex
19
+ * guess, zero collateral). The slot count is a point-in-time snapshot taken at
20
+ * settle time — the situation at spawn time may differ (F2d backs that up).
21
+ */
22
+ export function buildApprovedSuffix(designToken, designId, slotCount) {
23
+ return `Approved. Pass this exact token to eng-coder (designToken parameter): ${designToken}\ndesignId: ${designId} (pass as the designId parameter when spawning eng-coder — optional while this session holds a single design; ${slotCount} approved design slot(s) held as of this approval, and the count may have changed since — with several designs the spawn gate refuses a missing designId and lists the held ids)`
24
+ }
25
+
26
+ /** F2e (§29.1): strip the engine-generated Approved suffix from a report before
27
+ * it becomes a prior — the suffix is deterministic (buildApprovedSuffix), so the
28
+ * truncation is exact; a text not ending in it passes through untouched. */
29
+ export function stripApprovedSuffix(text, suffix) {
30
+ if (typeof text !== "string" || !suffix) return text
31
+ return text.endsWith(suffix) ? text.slice(0, text.length - suffix.length).trim() : text
32
+ }
33
+
34
+ const TOKEN_TTL_DEFAULT_MS = 7 * 24 * 3600 * 1000 // 7-day ceiling (v2 2026-08-25)
35
+
36
+ /** Effective token TTL: config override with runtime validation (timeoutMs precedent). */
37
+ export function effectiveTokenTtlMs(agent) {
38
+ const cfg = agent?.config?.agent?.engTokenTtlMs
39
+ return (Number.isFinite(cfg) && cfg > 0) ? cfg : TOKEN_TTL_DEFAULT_MS
40
+ }
41
+
42
+ /** Mint an unsigned design token with expiration (2026-09-06: HMAC layer removed —
43
+ * the token is a FLOW credential: uuid:expiresAt, exact slot match + TTL only). */
44
+ export function generateDesignToken(agent) {
45
+ const uuid = randomUUID()
46
+ const expiresAt = Math.floor(Date.now() + effectiveTokenTtlMs(agent))
47
+ return `${uuid}:${expiresAt}`
48
+ }
49
+
50
+ /** Validate a design token: format + expiration — ALL fail-closed (v2 2026-08-25).
51
+ * R16: format/expiry semantics live in token-ttl.mjs (tokenExpiryMs — single source
52
+ * shared with restore filtering / enter cleanup / spawn-gate slot deletion — D-R16b). */
53
+ export function validateDesignToken(token) {
54
+ const expiry = tokenExpiryMs(token)
55
+ return expiry !== null && expiry >= Date.now()
56
+ }
57
+
58
+ /** Build a [DESIGN-TOKEN:...] regex matching the FULL token (uuid:expiresAt). */
59
+ export function makeDesignTokenRegex(token, flags = "") {
60
+ const escaped = token.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")
61
+ return new RegExp(
62
+ `(?:^|\\s|\`|\\*)\\[DESIGN-TOKEN:\\s*${escaped}\\s*\\](?:\\s|$|\`|\\*)`,
63
+ flags + "ms"
64
+ )
65
+ }
66
+
67
+ /**
68
+ * Shared design-review settlement (sync wrapper + async settle): the token echo
69
+ * IS the verdict — the advisor echoes it only on approval. On echo: slot the
70
+ * token under designId (+ eng-coder gate flag; single-value mirror retired per
71
+ * DESIGN-TOKEN-SETTLEMENT D3) and return
72
+ * the clean output with the Approved suffix; the review instance CLOSES (a
73
+ * later review of the same doc-set starts a fresh full review). On non-echo:
74
+ * strip every dead token occurrence and return the findings text — slots stay
75
+ * untouched (方案 ②: a failed re-review revokes nothing).
76
+ * @returns {{passed: boolean, output: string}}
77
+ */
78
+ export function settleDesignReview(agent, run, designToken, rawResult) {
79
+ if (!designToken || typeof rawResult !== "string") {
80
+ return { passed: false, output: rawResult ?? "" }
81
+ }
82
+ const tokenPattern = makeDesignTokenRegex(designToken)
83
+ if (!tokenPattern.test(rawResult)) {
84
+ const stripped = rawResult.replace(makeDesignTokenRegex(designToken, "g"), "").trim()
85
+ return { passed: false, output: stripped || "Advisor: design review did not pass." }
86
+ }
87
+ // Echoed the token → review passed. Issue it to the parent for eng-coder.
88
+ agent._engDesignTokens ??= new Map()
89
+ agent._engDesignTokens.set(run.designId, designToken)
90
+ // DESIGN-TOKEN-SETTLEMENT D3(2026-09-08):单值镜像 `_engDesignToken` 退役——
91
+ // 只写多槽 Map(AC3 零镜像写);settle 落盘由调用方(settleAdvisorRun)当场做。
92
+ // Unlock the dispatch design gate for eng-coder SELF-review (defense-in-depth —
93
+ // see the sync wrapper's note: unreachable today, kept for parity).
94
+ if (agent._role === "eng-coder") agent._engDesignReviewed = true
95
+ run.open = false // approval closes this doc-set instance — next review is fresh
96
+ const clean = rawResult.replace(makeDesignTokenRegex(designToken, "g"), "").trim()
97
+ // F2c (§29.1): id echo + omission guidance + point-in-time slot snapshot — the
98
+ // same suffix the F2e prior stores strip with (stored for the exact truncation).
99
+ run.approvedSuffix = buildApprovedSuffix(designToken, run.designId, agent._engDesignTokens.size)
100
+ return {
101
+ passed: true,
102
+ output: `${clean}\n\n${run.approvedSuffix}`,
103
+ }
104
+ }
@@ -1,14 +1,24 @@
1
1
  /**
2
2
  * eng tool: enter/exit engineering mode.
3
3
  * In engineering mode the agent follows design-before-code methodology.
4
- * Toggled here at session level; persisted by /eng.
4
+ * Toggled here at session level (in-memory flag; the session slot is the sole authority —
5
+ * saveSession round-trips it at turn end; /eng writes the slot directly). The old ctx
6
+ * state-persist hook was removed 2026-09-08 (ENG-SESSION-PROVIDER-CLEANUP D1.2): no
7
+ * provider existed and its legacy payload keys were never read by applySession.
8
+ * R16 (2026-09-06): design tokens are session-level flow credentials — mode toggles
9
+ * do NOT clear them (ON→OFF keeps, OFF→ON does not require a fresh review); only TTL
10
+ * expiry cleans tokens, at three points: restore filtering (session.mjs applySession) /
11
+ * eng enter expired cleanup (below + cmd-eng ON) / spawn-gate expired rejection
12
+ * (subagent-spawn.mjs). See docs/design/ENG-TOKEN-BINDING-TUNING.md §5/§5.1 (F-R16).
5
13
  */
6
14
  import { ENG_ON_REMINDER, ENG_OFF_REMINDER } from "../agent.mjs"
15
+ import { purgeExpiredDesignTokens } from "../token-ttl.mjs"
7
16
 
8
17
  export const engTool = {
9
18
  name: "eng",
10
19
  description:
11
- "Enter or exit engineering mode. In engineering mode, follow design-before-code: write a design document, run advisor design review, get user approval, then implement via eng-coder subagents.",
20
+ "Enter or exit engineering mode. In engineering mode, follow design-before-code: write a design document, run advisor design review, get user approval, then implement via eng-coder subagents. " +
21
+ "Returns the mode state — 'Engineering mode activated/exited' (an already-active state is acknowledged).",
12
22
  parameters: {
13
23
  type: "object",
14
24
  properties: {
@@ -21,50 +31,36 @@ export const engTool = {
21
31
  ctx.agent.config.agent ??= {}
22
32
  if (args.action === "exit") {
23
33
  ctx.agent.config.agent.engineering = false
24
- ctx.agent._engDesignToken = null // stale token from prior design review invalidated
25
- ctx.agent._engDesignTokens = new Map() // multi-design slots die with the mode (2026-09-01 fix #2)
26
- ctx.agent._engDesignReviewed = false // reset gate state
34
+ // R16 (F-R16a): OFF 不清 token——有效 token 跨模式存活(设计评审过的产物不因
35
+ // 开关重复烧)。过期清理跑在另外三处(恢复过滤 / 开模式 / spawn 门禁拒)。
27
36
  ctx.agent._advisorRound = 0 // reset convergence budget
37
+ ctx.agent._advisorRuns = new Map() // §24 D-24b: per-review instances die with the mode (fresh cycles)
28
38
  ctx.agent._touchedFiles = [] // clear mutation tracking
29
39
  ctx.agent._lastEngState = false
30
40
  ctx.agent._pendingReminders = ctx.agent._pendingReminders ?? []
31
41
  ctx.agent._pendingReminders.push(ENG_OFF_REMINDER)
32
- // 持久化工程模式状态到会话
33
- if (ctx.persistState) {
34
- await ctx.persistState({
35
- engineering: false,
36
- engDesignToken: null,
37
- engDesignReviewed: false,
38
- advisorRound: 0,
39
- touchedFiles: []
40
- })
41
- }
42
42
  return "Engineering mode exited. Standard discipline now applies. You may edit files directly."
43
43
  }
44
44
  if (args.action === "enter") {
45
- // Idempotent enter (v2 2026-08-25): already in engineering mode → no-op. The old
46
- // unconditional token clear killed standing design tokens on a redundant defensive
47
- // eng(enter) only a real off→on transition requires a fresh design review.
45
+ // Idempotent enter (v2 2026-08-25): already in engineering mode → pure no-op
46
+ // (cleanup only runs on a real off→on transition T-R16c precondition: first
47
+ // exit/OFF, then enter; an already-on enter must not touch tokens at all).
48
48
  if (ctx.agent.config.agent.engineering) {
49
49
  return "Engineering mode already active. Existing design tokens stay valid."
50
50
  }
51
51
  ctx.agent.config.agent.engineering = true
52
- ctx.agent._engDesignToken = null // off→on transition requires a fresh design review
53
- ctx.agent._engDesignTokens = new Map() // multi-design slots die with the mode (2026-09-01 fix #2)
52
+ // R16 (F-R16b ②): off→on 不重评——只清过期 token(用户裁定"打开工程模式时
53
+ // 应该清理"),有效 token 原样保留——遍历 Map 删过期,返回文案含清理个数。
54
+ const cleared = purgeExpiredDesignTokens(ctx.agent)
55
+ ctx.agent._advisorRuns = new Map() // §24 D-24b: per-review instances die with the mode (fresh cycles)
54
56
  ctx.agent._lastEngState = true
55
57
  ctx.agent._pendingReminders = ctx.agent._pendingReminders ?? []
56
58
  ctx.agent._pendingReminders.push(ENG_ON_REMINDER)
57
- // 持久化工程模式状态到会话
58
- if (ctx.persistState) {
59
- await ctx.persistState({
60
- engineering: true,
61
- engDesignToken: null,
62
- engDesignReviewed: false,
63
- advisorRound: 0,
64
- touchedFiles: []
65
- })
59
+ let msg = "Engineering mode activated. Design-before-code enforced: write a design document in docs/, run advisor with type='design', get user approval, then implement via eng-coder subagents."
60
+ if (cleared > 0) {
61
+ msg += ` Cleared ${cleared} expired design token${cleared === 1 ? "" : "s"} — 清 ${cleared} 个过期 token,有效 token 保留(TTL 内不重评)。`
66
62
  }
67
- return "Engineering mode activated. Design-before-code enforced: write a design document in docs/, run advisor with type='design', get user approval, then implement via eng-coder subagents."
63
+ return msg
68
64
  }
69
65
  return "Invalid action: expected 'enter' or 'exit'"
70
66
  },