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
@@ -1,20 +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"
16
29
  import { logEvent, errText } from "../log.mjs"
17
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"
18
33
 
19
34
  // Named consult defaults (consult P2, 2026-08-30).
20
35
  const CONSULT_TIMEOUT_MS = 600_000 // default consult lifecycle timeout
@@ -112,13 +127,66 @@ export function makeMainHistoryTool(parentAgent) {
112
127
  }
113
128
  }
114
129
 
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 */ } }
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")}`
171
+ }
172
+
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
+ })
119
187
  }
120
188
 
121
- function settleChild(session, id, label, ok, payload) {
189
+ function settleChild(agent, session, id, label, ok, payload, emitDone) {
122
190
  if (ok) {
123
191
  session.received++
124
192
  session.replies.push({ model: label, reply: payload })
@@ -129,7 +197,8 @@ function settleChild(session, id, label, ok, payload) {
129
197
  session.replies.push({ model: label, reply: `(consultation failed: ${payload})`, failed: true })
130
198
  }
131
199
  session.pending--
132
- 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)
133
202
  }
134
203
 
135
204
  async function runConsultChild(ctx, session, id, m, problem, ctrl) {
@@ -161,6 +230,12 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
161
230
  if (kind === "ok" || kind === "partial") logEvent("child:done", { ...base, kind })
162
231
  else logEvent("child:error", { ...base, err: errText(payload, 200) })
163
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)
164
239
  try {
165
240
  // Provider resolution: consultModels entries are { provider, model, effort? } — resolve
166
241
  // via the subagent's provider resolver ("provider:model" handles cross-provider picks).
@@ -199,7 +274,7 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
199
274
  // Activity relay via the unified spawn-child pipeline (§7.2 D3): `consult#<subId>/`
200
275
  // prefix (same channel subagent uses — parallel consultants stay independent) +
201
276
  // onToolOutput passthrough so the consultant's tool output lands in its TUI block.
202
- const relayPrefix = makeRelay(agent, "consult", ctx.callbacks?.onToken, provider.model ?? "")
277
+ relayPrefix = makeRelay(agent, "consult", ctx.callbacks?.onToken, provider.model ?? "")
203
278
  // LOGGING:arm(spawn 事件——relay 建立后;子内事件归属 _logId)
204
279
  childLogId = relayPrefix.slice(0, -1)
205
280
  child._logId = childLogId
@@ -238,7 +313,7 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
238
313
  },
239
314
  onDeclined: (e) => {
240
315
  declined = true
241
- 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`)
242
317
  logSettle("partial", null)
243
318
  return undefined
244
319
  },
@@ -246,44 +321,42 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
246
321
  )
247
322
  // Review #1 fix: onDeclined already settled this child as a failed reply —
248
323
  // 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).
324
+ // `pending` twice (negative pending would re-enter the settle routing).
251
325
  if (!declined) {
252
- settleChild(session, id, label, true, String(result ?? ""))
326
+ settle(true, String(result ?? ""))
253
327
  logSettle("ok", null)
254
328
  }
255
329
  } catch (e) {
256
330
  // Runner errors (incl. the watchdog's abort) settle as a failed reply — the
257
331
  // continue/declined paths are already handled inside runWithContinue.
258
332
  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)
333
+ settle(false, note)
260
334
  logSettle("error", note)
261
335
  }
262
336
  } catch (e) {
263
337
  // Errors BEFORE the runner (provider resolution, createAgent) or a throwing
264
338
  // 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))
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))
267
342
  logSettle("error", e?.message ?? String(e))
268
343
  } finally {
269
344
  clearTimeout(watchdog)
270
345
  }
271
346
  }
272
347
 
273
- /** Turn-end cleanup (called from runAgent's finally): abort every leftover
274
- * 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. */
275
354
  export function cleanupConsultSessions(agent) {
276
355
  for (const s of agent._consultSessions?.values() ?? []) {
277
356
  s.stopped = true
278
357
  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 */ } }
280
358
  }
281
359
  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
360
  }
288
361
 
289
362
  export const consultStartTool = {
@@ -294,8 +367,11 @@ export const consultStartTool = {
294
367
  "Start a parallel multi-model consultation (会诊) for a hard problem you are stuck on (repeated failures, no headway). " +
295
368
  "Call it directly when the user asks for 会诊 / consult — an explicit user request applies even if you are not 'stuck'. " +
296
369
  "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" +
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" +
299
375
  "Parameters:\n" +
300
376
  "- problem (required): a brief — the symptom, what you already tried (failure trail), and entry-point files. " +
301
377
  "Do NOT paste raw error logs; consultants pull the main session history themselves via their main_history tool.\n" +
@@ -325,7 +401,7 @@ export const consultStartTool = {
325
401
  agent._consultSessions ??= new Map()
326
402
  const id = String((agent._consultIdCounter = (agent._consultIdCounter ?? 0) + 1))
327
403
  const session = {
328
- id, controllers: [], replies: [], pending: 0, waiters: [],
404
+ id, controllers: [], replies: [], pending: 0,
329
405
  failed: 0, terminated: 0, stopped: false, received: 0, total: run.length,
330
406
  models: run.map(consultLabel),
331
407
  }
@@ -335,102 +411,45 @@ export const consultStartTool = {
335
411
  session.pending++
336
412
  const ctrl = new AbortController()
337
413
  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 })
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 })
341
420
  }
342
- // 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).
343
423
  runConsultChild(ctx, session, id, m, problem, ctrl)
344
424
  }
345
425
  return JSON.stringify({ id, models: session.models })
346
426
  },
347
427
  }
348
428
 
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
429
  export const consultStopTool = {
410
430
  name: "consult_stop",
411
431
  readonly: false,
412
432
  sideEffectExempt: true,
413
433
  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" +
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" +
417
438
  "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.",
439
+ "- id (required): the consult id from consult_start",
420
440
  parameters: {
421
441
  type: "object",
422
442
  properties: {
423
443
  id: { type: "string", description: "Consult id" },
424
- n: { type: "number", description: "Incrementing call number for this consult (see consult_check)" },
425
444
  },
426
- required: ["id", "n"],
445
+ required: ["id"],
427
446
  },
428
- async execute({ id, n }, ctx) {
447
+ async execute({ id }, ctx) {
429
448
  const s = ctx.agent?._consultSessions?.get(String(id))
430
449
  if (!s) return JSON.stringify({ error: "unknown consult id" })
431
450
  const abandoned = s.pending
432
451
  s.stopped = true
433
452
  for (const c of s.controllers) { try { c.abort() } catch { /* already settled */ } }
434
- return JSON.stringify({ stopped: n, abandoned })
453
+ return JSON.stringify({ abandoned, cancelled: true })
435
454
  },
436
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,9 +1,18 @@
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",
@@ -22,50 +31,36 @@ export const engTool = {
22
31
  ctx.agent.config.agent ??= {}
23
32
  if (args.action === "exit") {
24
33
  ctx.agent.config.agent.engineering = false
25
- ctx.agent._engDesignToken = null // stale token from prior design review invalidated
26
- ctx.agent._engDesignTokens = new Map() // multi-design slots die with the mode (2026-09-01 fix #2)
27
- ctx.agent._engDesignReviewed = false // reset gate state
34
+ // R16 (F-R16a): OFF 不清 token——有效 token 跨模式存活(设计评审过的产物不因
35
+ // 开关重复烧)。过期清理跑在另外三处(恢复过滤 / 开模式 / spawn 门禁拒)。
28
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)
29
38
  ctx.agent._touchedFiles = [] // clear mutation tracking
30
39
  ctx.agent._lastEngState = false
31
40
  ctx.agent._pendingReminders = ctx.agent._pendingReminders ?? []
32
41
  ctx.agent._pendingReminders.push(ENG_OFF_REMINDER)
33
- // 持久化工程模式状态到会话
34
- if (ctx.persistState) {
35
- await ctx.persistState({
36
- engineering: false,
37
- engDesignToken: null,
38
- engDesignReviewed: false,
39
- advisorRound: 0,
40
- touchedFiles: []
41
- })
42
- }
43
42
  return "Engineering mode exited. Standard discipline now applies. You may edit files directly."
44
43
  }
45
44
  if (args.action === "enter") {
46
- // Idempotent enter (v2 2026-08-25): already in engineering mode → no-op. The old
47
- // unconditional token clear killed standing design tokens on a redundant defensive
48
- // 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).
49
48
  if (ctx.agent.config.agent.engineering) {
50
49
  return "Engineering mode already active. Existing design tokens stay valid."
51
50
  }
52
51
  ctx.agent.config.agent.engineering = true
53
- ctx.agent._engDesignToken = null // off→on transition requires a fresh design review
54
- 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)
55
56
  ctx.agent._lastEngState = true
56
57
  ctx.agent._pendingReminders = ctx.agent._pendingReminders ?? []
57
58
  ctx.agent._pendingReminders.push(ENG_ON_REMINDER)
58
- // 持久化工程模式状态到会话
59
- if (ctx.persistState) {
60
- await ctx.persistState({
61
- engineering: true,
62
- engDesignToken: null,
63
- engDesignReviewed: false,
64
- advisorRound: 0,
65
- touchedFiles: []
66
- })
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 内不重评)。`
67
62
  }
68
- 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
69
64
  }
70
65
  return "Invalid action: expected 'enter' or 'exit'"
71
66
  },