thincoder 0.12.54 → 0.12.59

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (129) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/README.md +1 -1
  3. package/bin/thincoder.mjs +25 -3
  4. package/package.json +3 -7
  5. package/src/acp/bridge.mjs +132 -26
  6. package/src/advisor/messages.mjs +38 -3
  7. package/src/advisor/run.mjs +91 -53
  8. package/src/advisor.mjs +15 -7
  9. package/src/agent/dispatch.mjs +156 -39
  10. package/src/agent/helpers.mjs +46 -4
  11. package/src/agent/setup.mjs +102 -19
  12. package/src/agent/spawn-child.mjs +28 -1
  13. package/src/agent-tools/advisor.mjs +43 -11
  14. package/src/agent-tools/consult.mjs +37 -6
  15. package/src/agent-tools/eng.mjs +4 -1
  16. package/src/agent-tools/goal.mjs +11 -1
  17. package/src/agent-tools/read-history.mjs +160 -0
  18. package/src/agent-tools/settings.mjs +162 -0
  19. package/src/agent-tools/skill.mjs +2 -1
  20. package/src/agent-tools/subagent-actions.mjs +432 -0
  21. package/src/agent-tools/subagent-async.mjs +427 -0
  22. package/src/agent-tools/subagent-scheduler.mjs +319 -0
  23. package/src/agent-tools/subagent.mjs +565 -128
  24. package/src/agent-tools/task.mjs +4 -3
  25. package/src/agent-tools/timer.mjs +9 -4
  26. package/src/agent-tools/verify.mjs +161 -49
  27. package/src/agent-tools.mjs +1 -0
  28. package/src/agent.mjs +182 -81
  29. package/src/auto-think.mjs +14 -0
  30. package/src/cli/make-agent.mjs +27 -1
  31. package/src/cli/memory-command.mjs +28 -7
  32. package/src/cli/permission.mjs +8 -1
  33. package/src/config.mjs +125 -8
  34. package/src/context.mjs +115 -34
  35. package/src/distill.mjs +19 -1
  36. package/src/escape.mjs +82 -27
  37. package/src/log.mjs +195 -0
  38. package/src/mcp/transport-http.mjs +13 -1
  39. package/src/mcp.mjs +52 -7
  40. package/src/memory/code-sync.mjs +1 -1
  41. package/src/memory/core.mjs +204 -10
  42. package/src/memory/docs.mjs +197 -62
  43. package/src/memory.mjs +1 -1
  44. package/src/model-specs.mjs +38 -1
  45. package/src/prompts/advisor-design.md +46 -0
  46. package/src/prompts/advisor-round1.md +49 -2
  47. package/src/prompts/advisor-round2.md +47 -0
  48. package/src/prompts/advisor-round3.md +47 -0
  49. package/src/prompts/coder.md +22 -0
  50. package/src/prompts/consult-base.md +13 -0
  51. package/src/prompts/discipline.md +25 -6
  52. package/src/prompts/eng-coder.md +2 -2
  53. package/src/prompts/engineering-sub.md +23 -1
  54. package/src/prompts/engineering.md +157 -50
  55. package/src/prompts/explore.md +1 -2
  56. package/src/prompts/main.md +11 -5
  57. package/src/prompts/methodology-template.md +14 -0
  58. package/src/prompts/system.md +5 -2
  59. package/src/provider/anthropic.mjs +7 -5
  60. package/src/provider/core.mjs +104 -28
  61. package/src/provider/google.mjs +57 -24
  62. package/src/provider/normalize.mjs +1 -1
  63. package/src/provider/rate.mjs +0 -2
  64. package/src/provider/responses.mjs +8 -13
  65. package/src/provider/sse.mjs +20 -0
  66. package/src/session.mjs +15 -0
  67. package/src/tools/apply_patch.md +5 -1
  68. package/src/tools/bash.md +3 -3
  69. package/src/tools/delete.md +1 -0
  70. package/src/tools/edit-batch.mjs +92 -0
  71. package/src/tools/edit-diff.mjs +265 -0
  72. package/src/tools/edit.md +11 -6
  73. package/src/tools/execute.md +8 -8
  74. package/src/tools/execute.mjs +31 -35
  75. package/src/tools/file.mjs +26 -114
  76. package/src/tools/file_ops.md +3 -2
  77. package/src/tools/get_current_time.md +3 -1
  78. package/src/tools/git.md +1 -1
  79. package/src/tools/git.mjs +8 -16
  80. package/src/tools/hashline_edit.md +2 -0
  81. package/src/tools/index.mjs +3 -2
  82. package/src/tools/insert_after.md +2 -1
  83. package/src/tools/lint.md +3 -1
  84. package/src/tools/linter.mjs +9 -37
  85. package/src/tools/lsp.md +4 -1
  86. package/src/tools/patch.mjs +84 -13
  87. package/src/tools/pdf-parse-text.mjs +497 -0
  88. package/src/tools/pdf-parse-xref.mjs +499 -0
  89. package/src/tools/pdf.mjs +155 -0
  90. package/src/tools/question.md +2 -1
  91. package/src/tools/read.md +1 -0
  92. package/src/tools/read_pdf.md +21 -0
  93. package/src/tools/repomap.mjs +1 -1
  94. package/src/tools/shared.mjs +11 -32
  95. package/src/tools/system.mjs +6 -21
  96. package/src/tools/tree.md +2 -1
  97. package/src/tools/web.mjs +5 -3
  98. package/src/tools/websearch.md +2 -1
  99. package/src/tools/write.md +2 -0
  100. package/src/traces/trace-store.mjs +224 -0
  101. package/src/tui/agent-turn.mjs +387 -24
  102. package/src/tui/clipboard.mjs +17 -6
  103. package/src/tui/cmd-config.mjs +29 -9
  104. package/src/tui/cmd-eng.mjs +1 -0
  105. package/src/tui/cmd-extract.mjs +1 -1
  106. package/src/tui/cmd-mcp-form.mjs +197 -0
  107. package/src/tui/cmd-mcp.mjs +264 -114
  108. package/src/tui/cmd-think.mjs +1 -1
  109. package/src/tui/index.mjs +49 -95
  110. package/src/tui/interaction.mjs +41 -3
  111. package/src/tui/key-handler.mjs +105 -143
  112. package/src/tui/key-modes.mjs +215 -0
  113. package/src/tui/layout.mjs +22 -1
  114. package/src/tui/mouse.mjs +41 -1
  115. package/src/tui/pickers.mjs +73 -7
  116. package/src/tui/render-conversation.mjs +13 -161
  117. package/src/tui/render-frame.mjs +45 -20
  118. package/src/tui/render-loop.mjs +4 -1
  119. package/src/tui/render-segments.mjs +165 -0
  120. package/src/tui/render.mjs +4 -4
  121. package/src/tui/startup.mjs +40 -2
  122. package/src/tui/subagent-blocks.mjs +404 -111
  123. package/src/tui/subagent-panel.mjs +88 -13
  124. package/src/tui/tool-args.mjs +10 -2
  125. package/src/tui/tool-events.mjs +172 -95
  126. package/src/tui/update-notice.mjs +72 -0
  127. package/src/tui/wizard.mjs +36 -6
  128. package/src/agent-tools/escalate.mjs +0 -179
  129. package/src/tools/exec-prelude.mjs +0 -84
@@ -3,7 +3,7 @@
3
3
  * Message building lives in advisor.mjs.
4
4
  */
5
5
  import { chat } from "../provider/core.mjs"
6
- import { findProvider, specForModel } from "../config.mjs"
6
+ import { findProvider, providerSpec } from "../config.mjs"
7
7
  import { toOpenAISchema } from "../tools/index.mjs"
8
8
  import { prepareAdvisorMessages } from "../advisor.mjs"
9
9
  import { appendCitationReport } from "./citations.mjs"
@@ -120,6 +120,9 @@ function renderTimeline(timeline, tail = "") {
120
120
  }
121
121
  // Test seam (mirrors _advisorToolsFor).
122
122
  export { renderTimeline as _renderTimeline }
123
+ // Test seam (T-TS8/9): the tool loop itself — toolsOverride injects a mock tool
124
+ // set with controllable timing/errors (the real set comes from advisorToolsFor).
125
+ export { runAdvisorToolLoop as _runAdvisorToolLoop }
123
126
 
124
127
  /**
125
128
  * Run the advisor's tool loop: chat → execute tools → repeat.
@@ -129,7 +132,7 @@ export { renderTimeline as _renderTimeline }
129
132
  * the panel keeps moving while the advisor explores — otherwise the panel sits
130
133
  * frozen through every tool-call phase and the review appears to have stalled.
131
134
  */
132
- async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, cwd) {
135
+ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, cwd, toolsOverride = null) {
133
136
  // Kind-tagged wrappers: the TUI panel colors reasoning / answer / tool progress differently.
134
137
  // Every chunk is ALSO recorded into an ordered timeline — the persisted record
135
138
  // must show the review process (thinking ↔ tool progress ↔ final text) at its
@@ -145,7 +148,9 @@ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, c
145
148
  const onThink = emit("think")
146
149
  const onText = emit("text")
147
150
  const onTool = emit("tool")
148
- const { schemas: toolSchemas, byName: toolByName } = advisorToolsFor(agent)
151
+ // toolsOverride = test seam (T-TS8/9): the real advisor tool set, or a mock
152
+ // set with controllable timing/errors.
153
+ const { schemas: toolSchemas, byName: toolByName } = toolsOverride ?? advisorToolsFor(agent)
149
154
  let turns = 0
150
155
  const startTime = Date.now()
151
156
 
@@ -198,6 +203,18 @@ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, c
198
203
  signal: signal ?? null,
199
204
  onToken: onText,
200
205
  onReasoning: onThink,
206
+ // LOGGING(vscode advisor/run.mjs parity——按 stage 可 grep)
207
+ // §18.6 D-TR4:轨迹元数据增补——kind=advisor(评审独立于子代理——T-TR2);role
208
+ // 透出调用方角色(eng-coder 内嵌评审时为 "eng-coder");session/cwd 供轨迹对回;
209
+ // traces 开关沿 agent.config(D-TR6)。
210
+ logCtx: {
211
+ stage: "advisor",
212
+ role: agent?._role ?? null,
213
+ kind: "advisor",
214
+ session: agent?._sessionStart ?? null,
215
+ cwd,
216
+ traces: agent?.config?.traces?.enabled !== false,
217
+ },
201
218
  })
202
219
 
203
220
  // No tool calls — this is the final review text. The final answer was
@@ -221,13 +238,24 @@ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, c
221
238
  id: tc.id, type: "function",
222
239
  function: { name: tc.name, arguments: tc.arguments },
223
240
  })),
224
- ...(response.reasoning && specForModel(provider.model).reasoningEcho === "required"
241
+ ...(response.reasoning && providerSpec(provider).reasoningEcho === "required"
225
242
  ? { reasoning_content: response.reasoning }
226
243
  : {}),
227
244
  })
228
245
 
229
- // Execute each tool call
230
- for (const tc of response.toolCalls) {
246
+ // B1 (AGENT-LOOP.md §18.7 D-TS7): the SAME LLM reply's multiple read-only
247
+ // tool calls run in PARALLEL (Promise.all) — results are backfilled in
248
+ // toolCalls order (Promise.all preserves the input order → tool_call_id
249
+ // never mismatches); each tool's timeout/error is captured independently
250
+ // (the existing TOOL_TIMEOUT stays — one failing tool does not block the
251
+ // others); progress lines are emitted in toolCalls order. The read-only
252
+ // tool set has no side effects — no sequencing/serialization needed.
253
+ // Scope note (round1 review #10): B1 is ONLY in-loop tool parallelism — it
254
+ // does NOT solve the TODO "platform execution: advisor parallel calls are
255
+ // actually serial" mystery (docs/TODO.md — LOGGING evidence item), which
256
+ // concerns multiple advisor CALLS observed as serial, not one reply's
257
+ // tool calls.
258
+ const parsed = response.toolCalls.map((tc) => {
231
259
  const tool = toolByName.get(tc.name)
232
260
  let args = {}
233
261
  let parseError = null
@@ -236,63 +264,69 @@ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, c
236
264
  } catch (e) {
237
265
  parseError = `Error: invalid JSON in tool arguments: ${e.message}\nRaw arguments: ${(tc.arguments || "").slice(0, 200)}`
238
266
  }
239
-
240
- // If parse failed, return error to model immediately
241
- if (parseError) {
242
- messages.push({ role: "tool", tool_call_id: tc.id, content: parseError })
243
- continue
244
- }
245
-
246
- const argsLine = describeToolArgs(tc.name, args)
247
- onTool(`\n→ ${tc.name}${argsLine ? " " + argsLine : ""}\n`)
248
- let result
249
- if (!tool) {
250
- result = `Error: unknown tool "${tc.name}". Available: ${[...toolByName.keys()].join(", ")}`
251
- } else {
252
- // Execute with timeout (clear the timer when the tool wins the race —
253
- // otherwise up to MAX_ADVISOR_TURNS dangling timers accumulate)
267
+ return { tc, tool, args, parseError }
268
+ })
269
+ // Progress lines first, in toolCalls order (emitted before the parallel
270
+ // run display order is independent of completion order).
271
+ for (const p of parsed) {
272
+ if (p.parseError) continue // parse-error tools get no progress line (legacy behavior)
273
+ const argsLine = describeToolArgs(p.tc.name, p.args)
274
+ onTool(`\n→ ${p.tc.name}${argsLine ? " " + argsLine : ""}\n`)
275
+ }
276
+ // Every tool runs CONCURRENTLY; each result/error lands in its own slot —
277
+ // Promise.all preserves input order, so index i always matches parsed[i].
278
+ const executed = await Promise.all(parsed.map(async (p) => {
279
+ // Parse failure → error to model immediately (no execution)
280
+ if (p.parseError) return p.parseError
281
+ if (!p.tool) return `Error: unknown tool "${p.tc.name}". Available: ${[...toolByName.keys()].join(", ")}`
282
+ // Execute with timeout (clear the timer when the tool wins the race —
283
+ // otherwise up to MAX_ADVISOR_TURNS dangling timers accumulate)
284
+ try {
285
+ let timeoutId
286
+ const timeoutPromise = new Promise((_, reject) => {
287
+ timeoutId = setTimeout(() => reject(new Error(`tool timeout after ${TOOL_TIMEOUT_MS}ms`)), TOOL_TIMEOUT_MS)
288
+ })
289
+ let toolPromise
254
290
  try {
255
- let timeoutId
256
- const timeoutPromise = new Promise((_, reject) => {
257
- timeoutId = setTimeout(() => reject(new Error(`tool timeout after ${TOOL_TIMEOUT_MS}ms`)), TOOL_TIMEOUT_MS)
258
- })
259
- let toolPromise
260
- try {
261
- toolPromise = tool.execute(args, { cwd, agent, onOutput, signal })
262
- result = await Promise.race([toolPromise, timeoutPromise])
263
- } finally {
264
- clearTimeout(timeoutId)
265
- // Timeout won → toolPromise is still pending; a later rejection
266
- // would surface as an unhandled rejection. The race already
267
- // consumed the result/error in the normal path, so this no-op
268
- // catch only fires for the abandoned-tool case.
269
- toolPromise?.catch(() => {})
270
- }
271
- } catch (e) {
272
- const errorType = e.message.includes("timeout") ? "timeout"
273
- : e.message.includes("ENOENT") ? "file_not_found"
274
- : e.message.includes("permission") ? "permission_denied"
275
- : "execution_error"
276
- result = `Error (${errorType}): ${e.message}`
291
+ toolPromise = p.tool.execute(p.args, { cwd, agent, onOutput, signal })
292
+ return await Promise.race([toolPromise, timeoutPromise])
293
+ } finally {
294
+ clearTimeout(timeoutId)
295
+ // Timeout won → toolPromise is still pending; a later rejection
296
+ // would surface as an unhandled rejection. The race already
297
+ // consumed the result/error in the normal path, so this no-op
298
+ // catch only fires for the abandoned-tool case.
299
+ toolPromise?.catch(() => {})
277
300
  }
301
+ } catch (e) {
302
+ const errorType = e.message.includes("timeout") ? "timeout"
303
+ : e.message.includes("ENOENT") ? "file_not_found"
304
+ : e.message.includes("permission") ? "permission_denied"
305
+ : "execution_error"
306
+ return `Error (${errorType}): ${e.message}`
278
307
  }
308
+ }))
309
+
310
+ // Backfill in toolCalls order (executed[i] ↔ parsed[i]); per-result
311
+ // non-string serialization + line-aware truncation stay per-tool.
312
+ for (let i = 0; i < parsed.length; i++) {
313
+ let result = executed[i]
279
314
  if (typeof result !== "string") result = JSON.stringify(result)
280
-
281
- // Line-aware truncation: preserve line integrity
315
+
282
316
  if (result.length > MAX_RESULT_CHARS) {
283
317
  const lines = result.split("\n")
284
318
  let truncated = ""
285
319
  let charCount = 0
286
320
  let keptLines = 0
287
-
288
- for (let i = 0; i < lines.length; i++) {
289
- const line = lines[i]
321
+
322
+ for (let j = 0; j < lines.length; j++) {
323
+ const line = lines[j]
290
324
  if (charCount + line.length + 1 > MAX_RESULT_CHARS) break
291
325
  truncated += line + "\n"
292
326
  charCount += line.length + 1
293
327
  keptLines++
294
328
  }
295
-
329
+
296
330
  const remainingLines = lines.length - keptLines
297
331
  result = (
298
332
  truncated +
@@ -300,8 +334,8 @@ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, c
300
334
  `To see more content, use: read(path, offset=${keptLines + 1}, limit=200)`
301
335
  )
302
336
  }
303
-
304
- messages.push({ role: "tool", tool_call_id: tc.id, content: result })
337
+
338
+ messages.push({ role: "tool", tool_call_id: parsed[i].tc.id, content: result })
305
339
  }
306
340
  }
307
341
  }
@@ -359,8 +393,12 @@ function extractUnfixedIssues(priorText) {
359
393
  * Run an advisor review. reviewType: "code" (default) or "design". Returns review text or null when skipped.
360
394
  * @param {string|null} [designToken] — injected into the design-review prompt; the advisor echoes it only on approval.
361
395
  * @param {string[]|null} [documents] — design review only: explicit list of doc paths to review; passed through to the message builder.
396
+ * @param {string[]|null} [paths] — code review only: explicit list of file/dir paths to review.
397
+ * @param {Object|null} [object] — review-object declaration (§18.8 D-OA1/D-OA3):
398
+ * { type, target, status, reason, exclude }; mechanically injected at the
399
+ * start of every review round's user message. Absent → legacy behavior (no injection).
362
400
  */
363
- export async function runAdvisorReview(agent, reviewType, callbacks, designToken = null, documents = null, paths = null) {
401
+ export async function runAdvisorReview(agent, reviewType, callbacks, designToken = null, documents = null, paths = null, object = null) {
364
402
  const onOutput = callbacks?.onOutput
365
403
  const signal = callbacks?.signal
366
404
  const startTime = Date.now()
@@ -395,7 +433,7 @@ export async function runAdvisorReview(agent, reviewType, callbacks, designToken
395
433
  // Advisor always works in the agent's cwd — scope is defined by paths/documents.
396
434
  const advisorCwd = agent.cwd
397
435
 
398
- const messages = prepareAdvisorMessages(agent, reviewType, designToken, documents, paths)
436
+ const messages = prepareAdvisorMessages(agent, reviewType, designToken, documents, paths, null, object)
399
437
 
400
438
  try {
401
439
  const result = await runAdvisorToolLoop(provider, messages, onOutput, signal, agent, advisorCwd)
package/src/advisor.mjs CHANGED
@@ -45,12 +45,13 @@ import { readFileSync } from "node:fs"
45
45
  import { join, dirname } from "node:path"
46
46
  import { fileURLToPath } from "node:url"
47
47
  import { extractAgentResponseTable } from "./advisor/history.mjs"
48
- import { buildAdvisorUserMessage, resolveScopeFiles } from "./advisor/messages.mjs"
48
+ import { buildAdvisorUserMessage, resolveScopeFiles, buildObjectDeclarationBlock } from "./advisor/messages.mjs"
49
49
  import { buildConvergenceBody } from "./advisor/convergence.mjs"
50
50
  import { escapeLiteralEscapes } from "./escape.mjs"
51
51
  // Re-export for run.mjs and tests (keeps their imports from "../advisor.mjs" stable)
52
52
  export { ADVISOR_MD_PATH, extractAgentResponseTable, extractConversationBackground } from "./advisor/history.mjs"
53
53
  export { buildAdvisorUserMessage } from "./advisor/messages.mjs"
54
+ export { buildObjectDeclarationBlock } from "./advisor/messages.mjs"
54
55
 
55
56
  const __dirname = dirname(fileURLToPath(import.meta.url))
56
57
 
@@ -135,13 +136,15 @@ export function buildAdvisorSystemPrompt(agent, prior, reviewType) {
135
136
  * @param {Object} agent — the parent agent (history used for the response table)
136
137
  * @param {Object|null} prior — prior issue table (extracted from history when null)
137
138
  * @param {string[]|null} [scopeFiles] — review surface for the no-response fallback (cwd-relative)
139
+ * @param {Object|null} [object] — review-object declaration (§18.8): mechanically
140
+ * prepended to the round-2+ follow-up so every round stays anchored (T-OA2).
138
141
  * @returns {string} the follow-up user message — or a plain "System reminder: …"
139
142
  * fresh-review fallback (NO brackets — some OpenAI-compatible servers parse
140
143
  * '['-prefixed content as structured data / expand escapes) when no prior
141
144
  * review exists at all (caller misuse; the response-table extraction would
142
145
  * otherwise scan history from index 0 and could match an unrelated stale table)
143
146
  */
144
- export function buildAdvisorFollowUp(agent, prior, scopeFiles = null) {
147
+ export function buildAdvisorFollowUp(agent, prior, scopeFiles = null, object = null) {
145
148
  // Convergence follow-up REQUIRES a prior review record — the full output of
146
149
  // the last review, injected VERBATIM (decision 2026-08-08: the model
147
150
  // understands the review output; no table/header/phrase parsing). The caller
@@ -164,7 +167,9 @@ export function buildAdvisorFollowUp(agent, prior, scopeFiles = null) {
164
167
  : "(Agent did not provide a response table — perform a fresh full review; the review surface is unknown, ask the user for the file list)"
165
168
  const response = extractAgentResponseTable(agent.history) || noResponseFallback
166
169
  const round = (agent._advisorRound || 0) + 1
167
- return buildConvergenceBody(p, response, round, scopeFiles)
170
+ // Review-object declaration FIRST (T-OA2 round 2+ stays anchored, no re-archaeology).
171
+ const declaration = buildObjectDeclarationBlock(object)
172
+ return (declaration ? declaration + "\n" : "") + buildConvergenceBody(p, response, round, scopeFiles)
168
173
  }
169
174
 
170
175
  /**
@@ -187,8 +192,11 @@ export { escapeLiteralEscapes }
187
192
  * @param {string|null} [designToken] — design-review approval token (design only)
188
193
  * @param {string[]|null} [documents] — design review only: explicit list of doc paths to review (passed through to buildAdvisorUserMessage)
189
194
  * @param {string[]|null} [paths] — code review only: explicit list of file/dir paths to review
195
+ * @param {Object|null} [object] — review-object declaration (§18.8 D-OA1): passed through
196
+ * to the user-message builders; mechanically injected at the start of every review
197
+ * round (round 1 design/code + round 2+ follow-up). Absent → legacy behavior.
190
198
  */
191
- export function prepareAdvisorMessages(agent, reviewType, designToken = null, documents = null, paths = null, priorParam = null) {
199
+ export function prepareAdvisorMessages(agent, reviewType, designToken = null, documents = null, paths = null, priorParam = null, object = null) {
192
200
  // Deterministic convergence state (decision 2026-08-08): round 2+ requires
193
201
  // _advisorRound > 0 AND a stored prior review output. No history parsing.
194
202
  // priorParam (direct callers) wins over the stored output — same derivation
@@ -201,7 +209,7 @@ export function prepareAdvisorMessages(agent, reviewType, designToken = null, do
201
209
  if (reviewType === "design" && (agent._advisorRound || 0) === 0) {
202
210
  return [
203
211
  { role: "system", content: withTime(buildAdvisorSystemPrompt(agent, prior, reviewType)) },
204
- { role: "user", content: escapeLiteralEscapes(buildAdvisorUserMessage(agent, prior, reviewType, designToken, documents, paths)) },
212
+ { role: "user", content: escapeLiteralEscapes(buildAdvisorUserMessage(agent, prior, reviewType, designToken, documents, paths, object)) },
205
213
  ]
206
214
  }
207
215
 
@@ -226,7 +234,7 @@ export function prepareAdvisorMessages(agent, reviewType, designToken = null, do
226
234
  agent._advisorRound = 0
227
235
  }
228
236
  // Mutations exist → KEEP the round (cap keeps advancing through retries).
229
- const user = buildAdvisorUserMessage(agent, prior, reviewType, designToken, documents, paths)
237
+ const user = buildAdvisorUserMessage(agent, prior, reviewType, designToken, documents, paths, object)
230
238
  return [
231
239
  { role: "system", content: withTime(buildAdvisorSystemPrompt(agent, prior, reviewType)) },
232
240
  {
@@ -257,6 +265,6 @@ export function prepareAdvisorMessages(agent, reviewType, designToken = null, do
257
265
  const scopeFiles = resolveScopeFiles(agent, paths)
258
266
  return [
259
267
  { role: "system", content: withTime(buildAdvisorSystemPrompt(agent, prior, reviewType)) },
260
- { role: "user", content: escapeLiteralEscapes(buildAdvisorFollowUp(agent, prior, scopeFiles)) },
268
+ { role: "user", content: escapeLiteralEscapes(buildAdvisorFollowUp(agent, prior, scopeFiles, object)) },
261
269
  ]
262
270
  }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * agent/dispatch.mjs — two-phase tool call execution
3
3
  */
4
+ import { logEvent, errText, headText } from "../log.mjs"
4
5
  import { offloadToolResult, FILE_MUTATORS } from "./helpers.mjs"
5
6
  import { runHooks } from "../hooks.mjs"
6
7
  import { snapshotForUndo } from "../tui/cmd-undo.mjs"
@@ -37,6 +38,55 @@ function logToolError(toolName, args, error) {
37
38
  }
38
39
  }
39
40
 
41
+ /**
42
+ * §19 action-level classification (AGENT-LOOP.md §19 D-M1): the merged subagent
43
+ * tool expresses spawn (side effect) and check/status (read-only queries) through
44
+ * its `action` parameter — the tool-level readonly flag can no longer express both.
45
+ * dispatch Phase-1/Phase-2 classifies per action: check/status behave as readonly
46
+ * (planMode pass / no permission ask / batchable), spawn keeps its non-readonly
47
+ * gates, escalate runs non-readonly AND serially (the retired escalate tool had no
48
+ * parallel flag — zero behavior change under the merged surface).
49
+ * §19.5 cancel (19.5.2b round2 #4): CONTROL-class exemption — cancel only
50
+ * stops, never starts. isSubagentControlAction feeds the SAME two gate sites as
51
+ * readonly (planMode pass / no permission ask — never joins a batch approval
52
+ * group / no handler → not denied — digest 内 cancel 放行).
53
+ * §19.6 panel (round1 #5): view 面归只读类(同 check/status——planMode 放行、免
54
+ * 审批、可批并行);freeze 面归控制类(同 cancel——planMode 放行、免权限审批、
55
+ * 批审批不入组、digest 内放行)。freeze 存在(非空 key)即控制类——否则只读类。
56
+ */
57
+ function isSubagentReadonlyAction(toolName, args) {
58
+ // §6 memory 工具面重构(MEMORY.md §6 D-M5):memory search/list 是只读动作——与
59
+ // subagent check/status 同分类(planMode 放行/免审批——Phase-2 批并行只认工具级
60
+ // readonly/parallel,memory 无 parallel → 按非只读串行,见 MEMORY.md §6.4 实现注)。
61
+ // 动作级判定——不能按工具名(同一 memory 工具的 put/delete/clear 保持侧效门)。
62
+ if (toolName === "memory") {
63
+ const action = args?.action
64
+ return action === "search" || action === "list"
65
+ }
66
+ // SETTINGS-TOOL.md(2026-09-05):settings list/get 是只读动作(memory search/list 同分类——
67
+ // planMode 放行/免审批);set 保持侧效门。
68
+ if (toolName === "settings") {
69
+ const action = args?.action
70
+ return action === "list" || action === "get"
71
+ }
72
+ if (toolName !== "subagent" || !args || typeof args !== "object") return false
73
+ const action = args.action
74
+ if (action === "check" || action === "status") return true
75
+ // §19.6 panel view 面(freeze 缺省/空 = 视图请求——readonly;非空 freeze 归控制类)
76
+ if (action === "panel" && (args.freeze === undefined || args.freeze === null || String(args.freeze) === "")) return true
77
+ return false
78
+ }
79
+ function isSubagentControlAction(toolName, args) {
80
+ if (toolName !== "subagent") return false
81
+ if (args?.action === "cancel") return true
82
+ // §19.6 panel freeze 面(D-P3 门控在 executor——只读/控制分类在此)
83
+ if (args?.action === "panel" && args.freeze !== undefined && args.freeze !== null && String(args.freeze) !== "") return true
84
+ return false
85
+ }
86
+ function isSubagentEscalateAction(toolName, args) {
87
+ return toolName === "subagent" && args?.action === "escalate"
88
+ }
89
+
40
90
  /**
41
91
  * Two-phase execution:
42
92
  * Phase 1 (serial): parse args one by one + planMode check + permission confirmation (side-effecting tools)
@@ -46,7 +96,12 @@ function logToolError(toolName, args, error) {
46
96
  */
47
97
  export async function executeToolCalls(agent, toolByName, toolCalls, callbacks, depth = 0, signal) {
48
98
  // ---- Phase 1: serial preparation ----
99
+ // Pre-gates run per tool (parse/planMode/engineering gates); non-readonly tools
100
+ // that REACH the permission stage are collected into one batch — a single merged
101
+ // ask covers the whole toolCalls array (§16 D-B1, "approve all / one by one /
102
+ // deny"). Tools stopped by a pre-gate never join the batch (review #7).
49
103
  const prepared = []
104
+ const permPending = [] // { toolCall, tool, args } — reached the permission stage
50
105
  for (const toolCall of toolCalls) {
51
106
  const tool = toolByName.get(toolCall.name)
52
107
  let args
@@ -64,7 +119,7 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
64
119
  continue
65
120
  }
66
121
 
67
- if (agent.planMode && !tool.readonly) {
122
+ if (agent.planMode && !tool.readonly && !isSubagentReadonlyAction(toolCall.name, args) && !isSubagentControlAction(toolCall.name, args)) {
68
123
  prepared.push({ toolCall, tool, denied: true, reason: "plan mode" })
69
124
  continue
70
125
  }
@@ -106,39 +161,82 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
106
161
  }
107
162
  }
108
163
 
109
- if (!tool.readonly) {
110
- // autoApprove short-circuit: skip prompt when agent is already marked for auto-approval
111
- const allowed = agent.autoApprove
112
- ? true
113
- : callbacks.onPermissionRequest
114
- ? await (async () => {
115
- // D2 (AGENT-LOOP.md §7.2): announce the wait BEFORE prompting the TUI
116
- // subagent block header flips to "等待审批" so a waiting child is visibly
117
- // different from a stalled one. Depth>0 only (the parent TUI shows its own
118
- // permission panel). turn n/max = the child's live turn counters.
119
- if (depth > 0) {
120
- callbacks.onToken?.(`⟦ev⟧approval\x1e${agent._currentTurn ?? 0}\x1e${agent._maxTurns ?? 0}\x1eapproval\x1e${String(toolCall.name).slice(0, 40)}`)
121
- }
122
- return await callbacks.onPermissionRequest(toolCall.name, args)
123
- })()
124
- : false
125
- if (!allowed) {
126
- prepared.push({ toolCall, tool, denied: true, reason: callbacks.onPermissionRequest ? "denied by user" : "no permission handler" })
164
+ // Readonly tools (and autoApprove — the short-circuit, unchanged for the
165
+ // whole batch too) skip the permission stage entirely.
166
+ // §18 D-E3 task-domain authorization (spawn-time): an eng-coder child's
167
+ // tools skip the permission ASK stage exactly like autoApprove — granted by
168
+ // the parent spawn (approved design + task = authorization; subagent.mjs
169
+ // sets _engTaskAuthorized on the child). Everything EARLIER in Phase 1
170
+ // (JSON parse / unknown tool / planMode / design-token gates) ran unchanged
171
+ // the exemption never widens what reaches this stage (round4 #3, T-E14).
172
+ // PreToolUse hooks still run below. Non-eng-coder children keep the manual
173
+ // parent ask (human in the loop).
174
+ if (tool.readonly || isSubagentReadonlyAction(toolCall.name, args) || isSubagentControlAction(toolCall.name, args) || agent.autoApprove || agent._engTaskAuthorized) {
175
+ if (!(await runHooks("PreToolUse", { agent, toolName: toolCall.name, toolArgs: args }))) {
176
+ prepared.push({ toolCall, tool, denied: true, reason: "blocked by PreToolUse hook" })
127
177
  continue
128
178
  }
129
- }
130
-
131
- // PreToolUse hooks: allow user scripts to gate tool execution
132
- if (!(await runHooks("PreToolUse", { agent, toolName: toolCall.name, toolArgs: args }))) {
133
- prepared.push({ toolCall, tool, denied: true, reason: "blocked by PreToolUse hook" })
179
+ // Panel area abolished — all tools now stream inline via onToolOutput.
180
+ callbacks.onToolCall?.(toolCall.name, args, toolCall.id)
181
+ prepared.push({ toolCall, tool, args })
134
182
  continue
135
183
  }
184
+ permPending.push({ toolCall, tool, args })
185
+ }
136
186
 
137
- // Panel area abolished all tools now stream inline via onToolOutput.
187
+ // ---- Permission stage: one merged ask for the whole batch (§16 D-B1) ----
188
+ // >1 non-readonly tools in the same toolCalls array → a single
189
+ // onBatchPermissionRequest({ tools, count }) ask; verdicts:
190
+ // "approveAll" → batch-scope allowance (autoApprove style, NOT persistent)
191
+ // "deny" → the whole batch is rejected, no second ask
192
+ // "oneByOne" (or anything else / no handler) → the existing per-item
193
+ // onPermissionRequest channel, signature unchanged (NF-B1: ACP bridge /
194
+ // headless / old versions without the new callback are never harmed).
195
+ if (permPending.length > 0) {
196
+ let batchAllowed = null // true = approveAll, false = deny, null = per-item fallback
197
+ if (permPending.length > 1 && callbacks.onBatchPermissionRequest) {
198
+ const verdict = await callbacks.onBatchPermissionRequest({
199
+ tools: permPending.map((p) => ({ name: p.toolCall.name, args: p.args })),
200
+ count: permPending.length,
201
+ })
202
+ if (verdict === "approveAll") batchAllowed = true
203
+ else if (verdict === "deny") batchAllowed = false
204
+ // anything else (oneByOne/unknown) → fall through to the per-item channel
205
+ }
206
+ for (const p of permPending) {
207
+ let allowed
208
+ if (batchAllowed === true) allowed = true
209
+ else if (batchAllowed === false) allowed = false
210
+ else if (callbacks.onPermissionRequest) {
211
+ allowed = await (async () => {
212
+ // D2 (AGENT-LOOP.md §7.2): announce the wait BEFORE prompting — the TUI
213
+ // subagent block header flips to "等待审批" so a waiting child is visibly
214
+ // different from a stalled one. Depth>0 only (the parent TUI shows its own
215
+ // permission panel). turn n/max = the child's live turn counters.
216
+ if (depth > 0) {
217
+ callbacks.onToken?.(`⟦ev⟧approval\x1e${agent._currentTurn ?? 0}\x1e${agent._maxTurns ?? 0}\x1eapproval\x1e${String(p.toolCall.name).slice(0, 40)}`)
218
+ }
219
+ return await callbacks.onPermissionRequest(p.toolCall.name, p.args)
220
+ })()
221
+ } else allowed = false
222
+ if (!allowed) {
223
+ prepared.push({
224
+ toolCall: p.toolCall, tool: p.tool, denied: true,
225
+ reason: (callbacks.onPermissionRequest || batchAllowed === false) ? "denied by user" : "no permission handler",
226
+ })
227
+ continue
228
+ }
138
229
 
139
- callbacks.onToolCall?.(toolCall.name, args, toolCall.id)
230
+ // PreToolUse hooks: allow user scripts to gate tool execution
231
+ if (!(await runHooks("PreToolUse", { agent, toolName: p.toolCall.name, toolArgs: p.args }))) {
232
+ prepared.push({ toolCall: p.toolCall, tool: p.tool, denied: true, reason: "blocked by PreToolUse hook" })
233
+ continue
234
+ }
140
235
 
141
- prepared.push({ toolCall, tool, args })
236
+ // Panel area abolished — all tools now stream inline via onToolOutput.
237
+ callbacks.onToolCall?.(p.toolCall.name, p.args, p.toolCall.id)
238
+ prepared.push({ toolCall: p.toolCall, tool: p.tool, args: p.args })
239
+ }
142
240
  }
143
241
 
144
242
  // ---- Phase 2: order-preserving execution ----
@@ -163,6 +261,11 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
163
261
  // 捕获分离(父恢复原始后子的拦截期间父捕获停止、子恢复后父继续)——正确。
164
262
  // 声明在 try 之外:catch 块(异常路径)也要访问(报错前的探查输出回显)。
165
263
  const capturedConsole = []
264
+ // LOGGING(LOGGING.md):tool:* 事件——仅真实执行(pre-gate 拦截项在下方早退分支不入事件)。
265
+ // 参数值永不落盘(NF-L3——工具事件不记 args);child=子代理 id(agent._logId,spawn 时 stamp)。
266
+ const toolT0 = Date.now()
267
+ const toolName = item.toolCall.name
268
+ logEvent("tool:call", { tool: toolName, child: agent?._logId })
166
269
  try {
167
270
  // Snapshot for undo before side-effect tools (setupOutputPanel already fired in Phase 1)
168
271
  if (!item.tool?.readonly && item.args) {
@@ -174,6 +277,7 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
174
277
  const routed = await callbacks.toolRouter(item.toolCall.name, item.args)
175
278
  if (routed?.handled) {
176
279
  callbacks.onToolResult?.(item.toolCall.name, routed.result, item.toolCall.id)
280
+ logEvent("tool:done", { tool: toolName, ms: Date.now() - toolT0, head: headText(routed.result, 200), child: agent?._logId })
177
281
  return { ...item, result: routed.result, ok: true }
178
282
  }
179
283
  }
@@ -182,17 +286,22 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
182
286
  console.log = (...a) => capturedConsole.push(a.map(String).join(" "))
183
287
  console.error = (...a) => capturedConsole.push("[err] " + a.map(String).join(" "))
184
288
  let rawResult
289
+ // ctx 对象提升为变量(§7.2.3):subagent 阻塞 execute 返回前在 ctx 上留
290
+ // _subagentKey(relayPrefix 去尾)——runOne 在 execute 返回后读它作 onToolResult
291
+ // 第 4 参(普通工具/错误路径无此字段——undefined 兼容既有签名)。每次工具调用
292
+ // 独立 ctx——并行同名工具(批并行 runOne)各自带自己的 key,互不串扰。
293
+ const toolCtx = {
294
+ cwd: agent.cwd,
295
+ agent,
296
+ depth,
297
+ signal,
298
+ callbacks,
299
+ onOutput: (chunk) => callbacks.onToolOutput?.(item.toolCall.name, chunk, item.toolCall.id),
300
+ onQuestion: callbacks.onQuestion,
301
+ onPermissionRequest: callbacks.onPermissionRequest,
302
+ }
185
303
  try {
186
- rawResult = await item.tool.execute(item.args, {
187
- cwd: agent.cwd,
188
- agent,
189
- depth,
190
- signal,
191
- callbacks,
192
- onOutput: (chunk) => callbacks.onToolOutput?.(item.toolCall.name, chunk, item.toolCall.id),
193
- onQuestion: callbacks.onQuestion,
194
- onPermissionRequest: callbacks.onPermissionRequest,
195
- })
304
+ rawResult = await item.tool.execute(item.args, toolCtx)
196
305
  } finally {
197
306
  console.log = origConsoleLog
198
307
  console.error = origConsoleErr
@@ -207,9 +316,10 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
207
316
  const resultWithConsole = capturedConsole.length > 0
208
317
  ? `${result}\n[console during ${item.toolCall.name}]\n${capturedConsole.join("\n")}`
209
318
  : result
210
- callbacks.onToolResult?.(item.toolCall.name, resultWithConsole, item.toolCall.id)
319
+ callbacks.onToolResult?.(item.toolCall.name, resultWithConsole, item.toolCall.id, toolCtx._subagentKey)
211
320
  // PostToolUse hooks: fire-and-forget (result not awaited on hook failure)
212
321
  runHooks("PostToolUse", { agent, toolName: item.toolCall.name, toolArgs: item.args, result: raw }).catch(() => {})
322
+ logEvent("tool:done", { tool: toolName, ms: Date.now() - toolT0, head: headText(resultWithConsole, 200), child: agent?._logId })
213
323
  return { ...item, result: resultWithConsole, ok: true }
214
324
  } catch (error) {
215
325
  // Persist to ~/.thincoder/tool-errors/ for post-mortem; only pass message to the model (stack traces confuse LLMs and may leak paths)
@@ -219,6 +329,9 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
219
329
  // asked to stop — worst case with subagents, where the child runs its
220
330
  // whole turn budget and the interrupt appears to do nothing.
221
331
  if (signal?.aborted) throw error
332
+ // LOGGING(2026-09-03 code review #4):中止先于事件——用户停不落 tool:error
333
+ //(vscode execute-tools parity;阻塞子代理 child:error 同款抑制)
334
+ logEvent("tool:error", { tool: toolName, ms: Date.now() - toolT0, err: errText(error, 200), child: agent?._logId })
222
335
  runHooks("PostToolUseFailure", { agent, toolName: item.toolCall.name, toolArgs: item.args, error }).catch(() => {})
223
336
  // Build contextual error: tool name + key args so the model can reason about what went wrong
224
337
  const ctxParts = []
@@ -242,7 +355,11 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
242
355
  batch = []
243
356
  }
244
357
  for (const item of prepared) {
245
- if (item.tool && !item.tool.readonly && !item.tool.parallel) {
358
+ // escalate action keeps the retired escalate tool's serial placement (no
359
+ // parallel flag): it flushes the batch and runs alone in call order (§19 —
360
+ // spawn stays parallel; check/status classify as readonly and batch freely).
361
+ if (item.tool && !item.tool.readonly
362
+ && (!item.tool.parallel || isSubagentEscalateAction(item.tool.name, item.args))) {
246
363
  await flush()
247
364
  results.push(await runOne(item))
248
365
  } else {