thincoder 0.12.58 → 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 (114) hide show
  1. package/CHANGELOG.md +42 -1
  2. package/README.md +1 -1
  3. package/bin/thincoder.mjs +8 -0
  4. package/package.json +1 -1
  5. package/src/acp/bridge.mjs +132 -26
  6. package/src/advisor/messages.mjs +34 -1
  7. package/src/advisor/run.mjs +89 -51
  8. package/src/advisor.mjs +15 -7
  9. package/src/agent/dispatch.mjs +91 -14
  10. package/src/agent/helpers.mjs +35 -4
  11. package/src/agent/setup.mjs +90 -19
  12. package/src/agent/spawn-child.mjs +25 -0
  13. package/src/agent-tools/advisor.mjs +24 -2
  14. package/src/agent-tools/consult.mjs +37 -6
  15. package/src/agent-tools/eng.mjs +2 -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 +467 -193
  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 +161 -125
  29. package/src/auto-think.mjs +14 -0
  30. package/src/cli/make-agent.mjs +2 -1
  31. package/src/cli/permission.mjs +8 -1
  32. package/src/config.mjs +5 -0
  33. package/src/context.mjs +87 -27
  34. package/src/distill.mjs +19 -1
  35. package/src/escape.mjs +6 -4
  36. package/src/log.mjs +195 -0
  37. package/src/memory/code-sync.mjs +1 -1
  38. package/src/memory/core.mjs +126 -0
  39. package/src/memory/docs.mjs +196 -87
  40. package/src/memory.mjs +1 -1
  41. package/src/model-specs.mjs +15 -1
  42. package/src/prompts/advisor-design.md +46 -0
  43. package/src/prompts/advisor-round1.md +49 -2
  44. package/src/prompts/advisor-round2.md +47 -0
  45. package/src/prompts/advisor-round3.md +47 -0
  46. package/src/prompts/coder.md +22 -0
  47. package/src/prompts/consult-base.md +13 -0
  48. package/src/prompts/discipline.md +10 -5
  49. package/src/prompts/eng-coder.md +2 -2
  50. package/src/prompts/engineering-sub.md +23 -1
  51. package/src/prompts/engineering.md +106 -56
  52. package/src/prompts/explore.md +1 -2
  53. package/src/prompts/main.md +11 -6
  54. package/src/prompts/methodology-template.md +14 -0
  55. package/src/prompts/system.md +4 -2
  56. package/src/provider/core.mjs +56 -2
  57. package/src/tools/apply_patch.md +3 -1
  58. package/src/tools/bash.md +1 -1
  59. package/src/tools/delete.md +1 -0
  60. package/src/tools/edit-batch.mjs +31 -43
  61. package/src/tools/edit-diff.mjs +265 -0
  62. package/src/tools/edit.md +10 -8
  63. package/src/tools/execute.md +7 -7
  64. package/src/tools/execute.mjs +24 -20
  65. package/src/tools/file.mjs +18 -68
  66. package/src/tools/file_ops.md +2 -1
  67. package/src/tools/get_current_time.md +3 -1
  68. package/src/tools/hashline_edit.md +2 -0
  69. package/src/tools/index.mjs +3 -2
  70. package/src/tools/insert_after.md +2 -1
  71. package/src/tools/lint.md +2 -0
  72. package/src/tools/lsp.md +4 -1
  73. package/src/tools/patch.mjs +84 -13
  74. package/src/tools/pdf-parse-text.mjs +497 -0
  75. package/src/tools/pdf-parse-xref.mjs +499 -0
  76. package/src/tools/pdf.mjs +155 -0
  77. package/src/tools/question.md +2 -1
  78. package/src/tools/read.md +1 -0
  79. package/src/tools/read_pdf.md +21 -0
  80. package/src/tools/repomap.mjs +1 -1
  81. package/src/tools/shared.mjs +4 -12
  82. package/src/tools/system.mjs +6 -21
  83. package/src/tools/tree.md +2 -1
  84. package/src/tools/web.mjs +5 -3
  85. package/src/tools/websearch.md +2 -1
  86. package/src/tools/write.md +2 -0
  87. package/src/traces/trace-store.mjs +224 -0
  88. package/src/tui/agent-turn.mjs +385 -22
  89. package/src/tui/clipboard.mjs +15 -4
  90. package/src/tui/cmd-config.mjs +29 -9
  91. package/src/tui/cmd-extract.mjs +1 -1
  92. package/src/tui/cmd-mcp.mjs +9 -0
  93. package/src/tui/cmd-think.mjs +1 -1
  94. package/src/tui/index.mjs +29 -95
  95. package/src/tui/interaction.mjs +13 -2
  96. package/src/tui/key-handler.mjs +105 -155
  97. package/src/tui/key-modes.mjs +215 -0
  98. package/src/tui/layout.mjs +22 -1
  99. package/src/tui/mouse.mjs +40 -0
  100. package/src/tui/pickers.mjs +11 -3
  101. package/src/tui/render-conversation.mjs +13 -161
  102. package/src/tui/render-frame.mjs +27 -10
  103. package/src/tui/render-loop.mjs +4 -1
  104. package/src/tui/render-segments.mjs +165 -0
  105. package/src/tui/startup.mjs +36 -0
  106. package/src/tui/subagent-blocks.mjs +322 -144
  107. package/src/tui/subagent-panel.mjs +88 -13
  108. package/src/tui/tool-args.mjs +10 -2
  109. package/src/tui/tool-events.mjs +132 -100
  110. package/src/tui/update-notice.mjs +72 -0
  111. package/src/tui/wizard.mjs +36 -6
  112. package/src/agent-tools/escalate.mjs +0 -179
  113. package/src/agent-tools/subagent-check.mjs +0 -107
  114. package/src/tools/exec-prelude.mjs +0 -84
package/src/agent.mjs CHANGED
@@ -15,10 +15,11 @@ import { prepareRun } from "./agent/setup.mjs"
15
15
  import { injectPostTurn } from "./agent/post-turn.mjs"
16
16
  import { handleCompletion } from "./agent/completion.mjs"
17
17
  import { cleanupConsultSessions } from "./agent-tools/consult.mjs"
18
+ import { logEvent } from "./log.mjs"
18
19
  import {
19
20
  escapeXml, repairHistory, listWorkDir, ensureAutoReminder,
20
21
  readonlyToolNames, collectGitContext, loadProjectInstructions,
21
- ContinueError, offloadToolResult,
22
+ ContinueError,
22
23
  DEFAULT_MAX_TURNS, DEFAULT_SUBAGENT_TURNS,
23
24
  MIN_REPORT_CHARS, REPORT_CONTINUATION,
24
25
  } from "./agent/helpers.mjs"
@@ -66,6 +67,12 @@ export const ENG_OFF_REMINDER =
66
67
  "Changes go through the normal workflow: you may edit files directly, advisor/verify " +
67
68
  "guards apply per config.]"
68
69
 
70
+ /** Manual-tier auto-turn digest domain (AGENT-LOOP.md §17 D-S6): organize-only.
71
+ * Injected per manual auto-turn run — writes/execute/spawns/questions are also
72
+ * mechanically denied (no permission handler + spawn gate); this steers first. */
73
+ const AUTO_TURN_DIGEST_DOMAIN =
74
+ "[System reminder: auto-turn — background async subagents finished while there was no user message, and this turn runs automatically to digest their reports (the finished-report reminders above). No one is waiting for this reply, so organize only: 1) summarize each finished report's key points into this conversation for the user to read later; 2) update the task list with the task tool (allowed) to mark finished work done; 3) write decision points with a suggested next step as text — do not execute it. FORBIDDEN this turn (mechanically enforced): modifying files, bash/execute/verify, spawning subagents, asking questions — those need a real user message. End the turn once the summaries are written.]"
75
+
69
76
  /** Engineering-mode status injection — one reminder on EVERY transition (2026-08-25:
70
77
  * OFF is announced too — the model must know the gates lifted; silence after /eng-off
71
78
  * left it guessing. Covers TUI /eng, resume, and any path bypassing the eng tool.) */
@@ -107,62 +114,81 @@ export function createAgent({
107
114
  }
108
115
 
109
116
  /** Run the agent loop: LLM ↔ tool-call cycle until task completion or turn limit. Returns final text content. */
110
- export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal, maxTurns: overrideTurns, resume = false } = {}) {
111
- // Previous run's async exploration distillation must settle before this run pushes new
112
- // input (SEND-STALL-DISTILL §2.2, N1): the compressed machine line is this run's starting
113
- // point — await BEFORE prepareRun, or the history replacement would wipe the new input.
117
+ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal, maxTurns: overrideTurns, resume = false, autoTurn = false, suspDriven = false } = {}) {
118
+ // Previous run's async exploration distillation must settle before this run pushes
119
+ // input (SEND-STALL-DISTILL §2.2 N1) await first, or its history replace wipes it.
114
120
  if (agent._pendingDistill) {
115
121
  const p = agent._pendingDistill
116
122
  agent._pendingDistill = null
117
123
  await p
118
124
  }
125
+ // §17 D-S3: suspension-settled async results inject before EVERY run's prepareRun
126
+ // (user + auto-turn); spliced = consumed. collectSettledAsync owns a different
127
+ // container, so no double-inject across the two consumption points.
128
+ const pendingAsync = agent._pendingAsyncResults
129
+ if (pendingAsync?.length) {
130
+ const { injectAsyncResult } = await import("./agent-tools/subagent.mjs")
131
+ for (const e of pendingAsync.splice(0)) await injectAsyncResult(agent, e)
132
+ }
133
+ agent._inAutoTurn = autoTurn // spawn gate for manual-tier digests (§17 D-S6/N3)
119
134
  const { maxTurns, threshold, tools, toolSchemas, toolByName, systemPrompt } = await prepareRun(
120
135
  agent, input, callbacks,
121
- { depth, signal, overrideTurns, resume, systemPrompt: SYSTEM_PROMPT, disciplineRules: DISCIPLINE_RULES, mainOverlay: MAIN_OVERLAY },
136
+ { depth, signal, overrideTurns, resume: resume || autoTurn, systemPrompt: SYSTEM_PROMPT, disciplineRules: DISCIPLINE_RULES, mainOverlay: MAIN_OVERLAY },
122
137
  )
123
138
 
124
- // End-of-run exploration distillation boundary (CONTEXT-COMPACTION §5): prepareRun has already
125
- // pushed the user input + injections, so everything appended from here is "this run's" work.
139
+ // Exploration-distillation boundary (CONTEXT-COMPACTION §5): prepareRun already
140
+ // pushed input + injections appended from here counts as "this run's" work.
126
141
  agent._runStartHistoryLen = agent.history.length
127
142
 
128
- // Per-run bookkeeping reset. On `resume` (ContinueError continuation) these are
129
- // PRESERVED: the resumed run must keep mutation tracking so the advisor/verify
130
- // guards stay active (a guard pushback on the last turn must not silently vanish),
131
- // and the convergence budget must not be resettable by continuing the session.
143
+ // Per-run bookkeeping reset PRESERVED on `resume` (ContinueError continuation):
144
+ // mutation/guard continuity and the convergence budget must survive a continuation.
132
145
  if (!resume) {
133
- agent._mutatedThisRun = false
134
- agent._verifiedThisRun = false
135
- agent._verifyPassed = undefined
136
- agent._calledAdvisorThisRun = false
137
- agent._touchedFiles = []
138
- agent._verifyRetries = 0
139
- agent._advisorRound = 0
140
- agent._advisorSession = null // advisor session is per-run: discard when the task ends, next task starts fresh
141
- agent._emptyRetries = 0 // empty-response retry budget is per-run: a fresh user turn restarts from zero
142
- agent._compressFailures = 0 // compaction summary-failure counter is per-run: a fresh user turn restarts from zero
143
- agent._asyncCheckLastN = 0 // subagent_check read counter is per-run (§15 D-A2): a fresh user turn restarts from 1
146
+ // §17 D-S6: an auto-turn's guard marks are inherited by the next USER run (not
147
+ // reset) so auto-turn changes never escape the guard silently.
148
+ const g = agent._inheritedGuard
149
+ if (g) {
150
+ for (const k of ["_mutatedThisRun", "_verifiedThisRun", "_verifyPassed", "_calledAdvisorThisRun", "_touchedFiles", "_verifyRetries", "_advisorRound"]) agent[k] = g[k]
151
+ agent._inheritedGuard = null
152
+ } else {
153
+ agent._mutatedThisRun = false
154
+ agent._verifiedThisRun = false
155
+ agent._verifyPassed = undefined
156
+ agent._calledAdvisorThisRun = false
157
+ agent._touchedFiles = []
158
+ agent._verifyRetries = 0
159
+ agent._advisorRound = 0
160
+ agent._advisorSession = null // advisor session is per-run: discard when the task ends, next task starts fresh
161
+ agent._emptyRetries = 0 // empty-response retry budget is per-run: a fresh user turn restarts from zero
162
+ agent._compressFailures = 0 // compaction summary-failure counter is per-run: a fresh user turn restarts from zero
163
+ agent._asyncCheckLastN = 0 // check action read counter is per-run (§15 D-A2): a fresh user turn restarts from 1
164
+ }
165
+ }
166
+ // §17 D-S6 manual tier: digest action-domain reminder (system-driven turn — organize only).
167
+ if (autoTurn && !agent.autoApprove) {
168
+ agent.history.push({ role: "user", content: AUTO_TURN_DIGEST_DOMAIN, transient: true })
144
169
  }
145
- // eng-coder authorization is set by subagent.mjs AFTER token validation but BEFORE runAgent —
146
- // only reset for the top-level agent (depth 0); child runs must keep their granted authorization
170
+ // eng-coder authorization is set by subagent.mjs AFTER token validation but BEFORE
171
+ // runAgent — only reset for the top-level agent (depth 0); child runs keep theirs
147
172
  if (depth === 0) agent._engDesignReviewed = false
148
- // _engDesignToken survives across turns within the same agent (design review → user approval → spawn eng-coder).
149
- // Lifecycle: invalidated on a failed re-review (advisor.mjs), issued on a passing review.
173
+ // _engDesignToken survives across turns (design review → approval → eng-coder spawn);
174
+ // lifecycle: invalidated on failed re-review (advisor.mjs), issued on a passing one.
150
175
  let guardPushbacks = 0
151
176
  let advisorPushbacks = 0
152
177
  let honestReminderInjected = false
153
178
  const recentCallSigs = []
154
- // repeat: "once" stream rules fire at most once per runAgent call (user turn):
155
- // this set survives across chat() calls (rule abort-retry, tool loop) within the turn.
179
+ // "once" stream rules fire at most once per runAgent call; the set survives across
180
+ // chat() calls (rule abort-retry, tool loop) within the turn.
156
181
  const streamRuleFired = new Set()
157
182
 
158
183
  // Compaction overhead for the pure-estimation path: system prompt + tools schema are
159
- // part of every request but not in history — without them the first-turn/restored/just-
160
- // compacted estimate under-counts and may never trigger compaction. Measured baseline
161
- // path already includes both (prompt_tokens is the full context), so this only applies
162
- // when _lastPromptTokens is null.
184
+ // in every request but not in history — without them the first-turn/just-compacted
185
+ // estimate under-counts and may never trigger. Measured path already includes both.
163
186
  const compactionOverhead = {
164
187
  systemPrompt,
165
188
  tools: toolSchemas,
189
+ // §18.6 D-TR4:compress 轨迹 depth 元数据(runAgent 的 depth 在此作用域——
190
+ // context.mjs compressIfNeeded 经 extras 透出到 logCtx)
191
+ traceDepth: depth,
166
192
  }
167
193
 
168
194
  let thrownError = null
@@ -171,10 +197,9 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
171
197
  // Update turn counter for status bar display
172
198
  agent._currentTurn = turn + 1
173
199
  agent._maxTurns = maxTurns
174
- // D2 (AGENT-LOOP.md §7.2): depth>0 children emit a ⟦ev⟧turn progress token on every
175
- // turn — a single emit point covering all three spawn tools (natural heartbeat for the
176
- // TUI subagent block header: "turn N/max"). phase=llm (tool/done progress rides the
177
- // existing onToolCall/onToolResult prefix relay — no token for those).
200
+ // D2 (AGENT-LOOP.md §7.2): depth>0 children emit a ⟦ev⟧turn progress token each turn —
201
+ // single emit point covering all three spawn tools; phase=llm (tool/done progress rides
202
+ // the onToolCall/onToolResult relay no token for those).
178
203
  if (depth > 0 && callbacks.onToken) {
179
204
  callbacks.onToken(`⟦ev⟧turn\x1e${turn + 1}\x1e${maxTurns}\x1ellm\x1e`)
180
205
  }
@@ -186,10 +211,8 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
186
211
  agent._compressFailures = 0
187
212
  agent._planReminderAtLen = 0 // After compression history shrinks, reset cadence so reminders resume
188
213
  recentCallSigs.length = 0 // After compression history is rebuilt, reset stall detection counter
189
- // Completion info (CONTEXT-COMPACTION §7 D-C2): { mode: "summary", tokensFreed, elapsedMs }
190
- // from compressIfNeeded, or { mode: "fallback", tailMessages } from compressFallback below
191
- // the TUI panel renders the matching completion state. Existing callers that ignore the
192
- // argument keep the exact previous onCompress semantics.
214
+ // Completion info (CONTEXT-COMPACTION §7 D-C2): { mode, tokensFreed, elapsedMs }
215
+ // the TUI panel renders it; callers that ignore the arg keep prior onCompress semantics.
193
216
  callbacks.onCompress?.(agent._lastCompressInfo ?? {})
194
217
  ensureAutoReminder(agent)
195
218
  }
@@ -197,10 +220,8 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
197
220
  // AbortError must not be swallowed: user cancellation must propagate
198
221
  if (compressError?.name === "AbortError" || signal?.aborted) throw compressError
199
222
  agent._compressFailures = (agent._compressFailures ?? 0) + 1
200
- // Q3 visibility (CONTEXT-COMPACTION §7 D-C1): a failed compression is no longer silent
201
- // the frontend updates the compression panel with the error text (and logs to stderr).
202
- // Failure STRATEGY is unchanged: COMPRESS_FAILURE_LIMIT consecutive failures still degrade
203
- // to compressFallback — this only adds observability.
223
+ // Q3 (CONTEXT-COMPACTION §7 D-C1): a failed compression is surfaced to the panel;
224
+ // COMPRESS_FAILURE_LIMIT consecutive failures still degrade to compressFallback.
204
225
  callbacks?.onCompressFail?.(compressError)
205
226
  if (agent._compressFailures >= COMPRESS_FAILURE_LIMIT) {
206
227
  agent._compressFailures = 0
@@ -210,8 +231,7 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
210
231
  }
211
232
 
212
233
  // Plan-mode reminder cadence: re-inject constraint reminders while plan mode is active
213
- // (sparse every 2 turns, full every 5 turns or when the user sends a new message),
214
- // so the read-only restriction never fades from context.
234
+ // (sparse every 2 turns, full every 5 / on new user message) so the restriction never fades.
215
235
  if (agent.planMode) {
216
236
  const lastMsg = agent.history.at(-1)
217
237
  const realUserMsg = lastMsg?.role === "user"
@@ -227,9 +247,8 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
227
247
  }
228
248
  }
229
249
 
230
- // Engineering-mode status injection: every new user message carries a
231
- // reminder so the model always knows whether it's in design-before-code
232
- // mode or standard discipline mode.
250
+ // Engineering-mode status injection on every new user message (design-before-code
251
+ // vs standard discipline) see injectEngineeringReminder.
233
252
  if (depth === 0) {
234
253
  injectEngineeringReminder(agent)
235
254
  }
@@ -237,8 +256,7 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
237
256
  const messages = [{ role: "system", content: systemPrompt }, ...agent.history]
238
257
  let response
239
258
 
240
- // Auto-think: classify task difficulty and set reasoning effort before the real prompt.
241
- // Runs only on turn 0 of user input; failure is silent — falls back to current setting.
259
+ // Auto-think: classify difficulty and set reasoning effort on turn 0; silent on failure.
242
260
  if (agent.config?.agent?.autoThink && turn === 0) {
243
261
  const { classifyAndApply } = await import("./auto-think.mjs")
244
262
  await classifyAndApply(agent, turn).catch(() => {})
@@ -253,14 +271,29 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
253
271
  signal,
254
272
  streamRules: agent.config.agent?.streamRules ?? [],
255
273
  firedPatterns: streamRuleFired,
274
+ // LOGGING(LOGGING.md):llm:* 事件的语义上下文(stage=turn 主循环回合——含
275
+ // digest 消化轮 auto=true;child=子代理 id(spawn 时 stamp 于 child._logId))
276
+ // §18.6 D-TR4:轨迹元数据增补(role/depth/kind/session/cwd——trace-store 只读
277
+ // logCtx,签名不变);kind:depth>0 = subagent(consult 孩子 = consult)——子代理
278
+ // 对回靠 role+depth+child id(children 无 _sessionStart——不经 depth-0 设置——
279
+ // session 字段对子代理轨迹为 null——见 trace-store/agent.mjs 注释)。
280
+ logCtx: {
281
+ stage: "turn", turn: turn + 1, auto: autoTurn, child: agent._logId,
282
+ role: agent._role ?? null,
283
+ depth,
284
+ kind: depth > 0 ? (agent._role === "consult" ? "consult" : "subagent") : "turn",
285
+ session: agent._sessionStart ?? null,
286
+ cwd: agent.cwd,
287
+ traces: agent.config?.traces?.enabled !== false,
288
+ },
256
289
  })
257
290
  } catch (e) {
258
- // User interrupt (Ctrl+I): controller.abort({ interrupt: true, message: "…" }).
259
- // Inject the message into history and let the outer loop recreate the controller.
291
+ // User interrupt (Ctrl+I): controller.abort({ interrupt: true, message }).
292
+ // Inject into history; the outer loop recreates the controller and resumes.
260
293
  if (e.name === "AbortError" && signal?.reason?.interrupt) {
261
294
  const msg = `[User interrupt: ${signal.reason.message}]`
262
- // Dedup: if the interrupt was already handled during tool execution (L302-310),
263
- // don't push a duplicate — the outer loop will still recreate the controller.
295
+ // Dedup: if already handled during tool execution (interrupt branch below),
296
+ // don't push a duplicate — the outer loop still recreates the controller.
264
297
  if (agent.history.at(-1)?.content !== msg) {
265
298
  agent.history.push({ role: "user", content: msg })
266
299
  }
@@ -268,11 +301,9 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
268
301
  throw e
269
302
  }
270
303
 
271
- // 内置工具(Responses web_search)结果本地化:服务端已执行——入历史为 tool 消息,
272
- // 模型下一轮可见;全量回传时 transport tool_call_id 前缀还原 web_search_call item。
273
- // 注意:服务端 item id msg_xxx web_search_call_ 前缀——必须合成前缀(toItems 识别锚点),
274
- // 原始 id 存入 content(真机冒烟 2026-08-31:直接用 msg_xxx 会被转成 function_call_output
275
- // 与服务端不配对,属蒙对)。
304
+ // 内置工具(Responses web_search)结果本地化:服务端已执行——入历史为 tool 消息;
305
+ // 服务端 item id msg_xxx web_search_call_ 前缀——必须合成前缀(toItems 识别锚点),
306
+ // 原始 id 存入 content(真机冒烟 2026-08-31 验证)。
276
307
  for (const btr of response.builtinToolResults ?? []) {
277
308
  if (!btr?.id) continue
278
309
  pushReal(agent, {
@@ -282,8 +313,8 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
282
313
  })
283
314
  }
284
315
 
285
- // Stream rule triggered mid-generation (action: "abort"): halt current output,
286
- // inject rule's message as a reminder, and retry from the same context.
316
+ // Stream rule triggered mid-generation (action: "abort"): halt, inject the rule's
317
+ // message as a reminder, retry from the same context.
287
318
  if (response.ruleTriggered) {
288
319
  if (response.content) {
289
320
  pushReal(agent, { role: "assistant", content: response.content })
@@ -296,9 +327,8 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
296
327
  continue
297
328
  }
298
329
 
299
- // Stream rule warnings (action: "warn"): the stream completed, but one or more
300
- // non-interrupting rules matched. Inject warnings after the turn so the model
301
- // sees them before its next response — without aborting mid-generation.
330
+ // Stream rule warnings (action: "warn"): stream completed; inject de-duplicated
331
+ // warnings so the model sees them before its next response.
302
332
  if (response._warnings?.length) {
303
333
  const deDuplicated = [...new Map(response._warnings.map(w => [w.name || w.pattern, w])).values()]
304
334
  agent.history.push({
@@ -307,9 +337,8 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
307
337
  })
308
338
  }
309
339
 
310
- // User interrupted mid-generation (Ctrl+I): the SSE stream was aborted while content
311
- // was partially generated. Commit partial output + inject user message, then signal
312
- // the outer loop to recreate the controller and resume.
340
+ // User interrupted mid-generation (Ctrl+I): commit partial output + inject the
341
+ // message, then signal the outer loop to recreate the controller and resume.
313
342
  if (response.interrupted) {
314
343
  if (response.content) {
315
344
  pushReal(agent, { role: "assistant", content: response.content })
@@ -329,8 +358,7 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
329
358
  }
330
359
  }
331
360
 
332
- // Warn on abnormal finish reasons — the model stopped for a reason other than
333
- // "stop" or "tool_calls", meaning the response may be incomplete or truncated.
361
+ // Warn on abnormal finish reasons — the response may be incomplete/truncated.
334
362
  if (response.finishReason && response.finishReason !== "stop" && response.finishReason !== "tool_calls") {
335
363
  const reasonMap = {
336
364
  length: "output token limit reached after exhausting continuations",
@@ -351,13 +379,11 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
351
379
  advisorPushbacks = cr.advisorPushbacks
352
380
  if (cr.action === "continue") continue
353
381
  if (depth === 0) {
354
- // End-of-run exploration distillation (CONTEXT-COMPACTION §5): this run's inline
355
- // exploration results become one semantic note before the final return. Async
356
- // (SEND-STALL-DISTILL §2.1): the turn-end signal goes out first the promise hangs
357
- // on agent._pendingDistill and settles at the next runAgent's start or the TUI's
358
- // exit flush. Silent (N3): distillation failure must never block the return or lose
359
- // history.
360
- const distill = summarizeRunExplorations(agent, callbacks, signal).catch(() => {})
382
+ // End-of-run exploration distillation (CONTEXT-COMPACTION §5 + SEND-STALL-DISTILL
383
+ // §2.1): async the promise hangs on _pendingDistill, settling at the next run's
384
+ // start or the TUI exit flush. Silent (N3): failure never blocks return/history.
385
+ // §18.6 D-TR4:depth 透传(distill 轨迹元数据——与 compress 同通道)
386
+ const distill = summarizeRunExplorations(agent, callbacks, signal, depth).catch(() => {})
361
387
  agent._pendingDistill = distill
362
388
  }
363
389
  return cr.content
@@ -380,8 +406,8 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
380
406
 
381
407
  const results = await executeToolCalls(agent, toolByName, response.toolCalls, callbacks, depth, signal)
382
408
 
383
- // Ctrl+I interrupt during tool execution: skip committing partial results —
384
- // the tool failure messages would mislead the model. Inject the interrupt and retry.
409
+ // Ctrl+I interrupt during tool execution: skip committing partial results — inject
410
+ // the interrupt and retry (placeholder results keep strict providers pairable).
385
411
  if (signal?.reason?.interrupt) {
386
412
  // 中断变更记账(2026-08-31 评审 #4):此分支的工具已全部执行完成(磁盘已变,execute 已完成),
387
413
  // 真实结果按语义不进历史(placeholder 替代)——但变更必须记账:否则 guard 看到
@@ -402,11 +428,9 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
402
428
  }
403
429
  } catch { /* 畸形 args 不影响记账(touchedFiles 尽力而为) */ }
404
430
  }
405
- // The assistant tool_calls were already committed above (L347) a strict
406
- // provider 400s on dangling tool_calls, so synthesize placeholder tool
407
- // results BEFORE the interrupt message (tool result must immediately
408
- // follow its assistant tool_calls). The retry turn then sees a clean,
409
- // pairable history (consult P1, 2026-08-30).
431
+ // The assistant tool_calls were committed above — synthesize placeholder tool
432
+ // results BEFORE the interrupt message (strict providers 400 on dangling
433
+ // tool_calls; consult P1, 2026-08-30).
410
434
  for (const tc of response.toolCalls) {
411
435
  agent.history.push({ role: "tool", tool_call_id: tc.id, content: "[Tool execution interrupted — results discarded]" })
412
436
  }
@@ -418,13 +442,11 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
418
442
  continue
419
443
  }
420
444
 
421
- // Model is executing tools → doing real work, reset guard pushback counter
445
+ // Model is executing tools → real work: reset guard pushback counters
422
446
  guardPushbacks = 0
423
447
  advisorPushbacks = 0
424
448
 
425
- // Commit tool results (pairing, multimodal deferral, mutation accounting,
426
- // touched files, reindex) — split into record-results.mjs (consult P2,
427
- // 2026-08-30).
449
+ // Commit tool results (pairing, multimodal deferral, mutation accounting, reindex)
428
450
  await recordToolResults(agent, toolByName, results)
429
451
 
430
452
  injectPostTurn(agent, results, recentCallSigs, callbacks, turn)
@@ -438,56 +460,70 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
438
460
  // Turn-end cleanup: abort any leftover consultation children (consult_start spawns
439
461
  // fire-and-forget runners; a completed turn must not let them keep burning tokens).
440
462
  cleanupConsultSessions(agent)
441
- // Async subagent turn-end collection (AGENT-LOOP.md §15 D-A3). Lifecycle:
442
- // - Ctrl+C / Ctrl+I (signal aborted): children were aborted with the parent
443
- // signal — clear WITHOUT injecting stale errors (user explicitly stopped).
444
- // - ContinueError (turn cap): no wait, no injection children keep running
445
- // and the RESUME run's turn-end collection takes over.
446
- // - anything else: refill loop → wait for all → inject reports → clear.
447
- if (signal?.aborted) {
463
+ // Async subagent turn-end handling (AGENT-LOOP.md §15 D-A3 + §17 D-S1). Lifecycle:
464
+ // - Ctrl+C (plain abort): children were aborted with the parent signal — clear
465
+ // WITHOUT injecting stale errors (user explicitly stopped). Ctrl+I (interrupt)
466
+ // keeps the pool: the turn resumes with the interrupt message, children stay
467
+ // tracked (in a suspension session children hold agent._sessionSignal and a
468
+ // digest's own Ctrl+I must not orphan them).
469
+ // - ContinueError (turn cap): no wait, no injection — children keep running and
470
+ // the RESUME run's turn-end collection takes over.
471
+ // - anything else: inject the SETTLED entries only; running/queued stay in the
472
+ // pool for the suspension session (D-S1 — no allSettled turn-end wait).
473
+ if (signal?.aborted && !signal?.reason?.interrupt) {
474
+ if (agent._asyncSubagents?.size > 0) {
475
+ logEvent("ev:stopped", { poolN: agent._asyncSubagents?.size ?? 0, where: "turn-end-abort" })
476
+ }
448
477
  agent._asyncSubagents?.clear()
449
478
  agent._asyncQueue = []
450
479
  agent._asyncCheckLastN = 0
451
480
  } else if (thrownError instanceof ContinueError) {
452
481
  // keep _asyncSubagents + the check counter — the resumed run continues them
453
482
  } else {
454
- await collectAsyncSubagents(agent)
483
+ await collectSettledAsync(agent, { suspDriven })
455
484
  agent._asyncCheckLastN = 0
456
485
  }
486
+ agent._inAutoTurn = false
487
+ // §17 D-S6: auto-turn guard marks survive into the next USER run (restored at its
488
+ // !resume reset above). Normal ends only — abort discards; ContinueError lets the
489
+ // auto-resumed run snapshot at its own end.
490
+ if (autoTurn && !(signal?.aborted && !signal?.reason?.interrupt) && !(thrownError instanceof ContinueError)) {
491
+ agent._inheritedGuard = {
492
+ _mutatedThisRun: agent._mutatedThisRun, _verifiedThisRun: agent._verifiedThisRun,
493
+ _verifyPassed: agent._verifyPassed, _calledAdvisorThisRun: agent._calledAdvisorThisRun,
494
+ _touchedFiles: agent._touchedFiles, _verifyRetries: agent._verifyRetries,
495
+ _advisorRound: agent._advisorRound,
496
+ }
497
+ }
457
498
  }
458
499
  }
459
500
 
460
501
  /**
461
- * Turn-end async subagent collection (AGENT-LOOP.md §15 D-A3):
462
- * 1. refill loop start queued heads while slots free (each settle already
463
- * refills via its finally; this drains the tail), keeping the cap ≤4 serial.
464
- * 2. wait for every entry to settle (queued entries start through the refill
465
- * chainthe drain loop converges when nothing is running and the queue is empty).
466
- * 3. inject one user-role reminder per entry: the report/error text XML-escaped
467
- * (child reports may carry content from files/webpages reminder discipline),
468
- * >64K offloaded to disk with a preview + path.
469
- * 4. clear the map. (The ⟦ev⟧done freeze signal is NOT emitted here — D-A3
470
- * 2026-09-02: each entry's settle callback emits it at completion time so
471
- * blocks freeze at their completion position in the stream.)
472
- */
473
- async function collectAsyncSubagents(agent) {
502
+ * Turn-end async subagent collection (AGENT-LOOP.md §17 D-S1 + §17.5 supersede):
503
+ * two modes, selected by the caller's driver context (17.5.2/17.5.4 #2):
504
+ * - suspDriven=false (fallback headless/direct runAgent callers without a
505
+ * suspension driver): inject every entry that SETTLED during this run
506
+ * (XML-escaped report/error child reports may carry file/webpage content;
507
+ * >64K offloaded with preview + path) and remove it from the pool. Running/
508
+ * queued STAY no allSettled wait. Results never lost without a session.
509
+ * - suspDriven=true (the interaction layer runs suspensionSession after this
510
+ * run): NO direct inject settled entries STAY pooled (settled not consumed)
511
+ * for the session's first sweepSettledToPending digest turn (§17.5 done
512
+ * 条目留池等消化轮注入;check/status sweep 前仍从池读——17.5.2 不变面)
513
+ * maybeRefillAsync runs in both modes (starts queued heads whose slot freed).
514
+ * Single ownership: entries settled inside a suspension session were moved to
515
+ * _pendingAsyncResults by the settle callback, so this only sees user-turn
516
+ * settles (no double inject — D-S3 points ①/②). The ⟦ev⟧done freeze is NOT
517
+ * emitted here — each settle callback emits it (§15 D-A3). */
518
+ async function collectSettledAsync(agent, { suspDriven = false } = {}) {
474
519
  const map = agent._asyncSubagents
475
520
  if (!map || map.size === 0) return
476
- const { maybeRefillAsync } = await import("./agent-tools/subagent.mjs")
477
- for (;;) {
478
- maybeRefillAsync(agent)
479
- const running = [...map.values()].filter((e) => e.status === "running")
480
- if (running.length === 0) break
481
- await Promise.allSettled(running.map((e) => e.promise))
482
- }
521
+ const { maybeRefillAsync, injectAsyncResult } = await import("./agent-tools/subagent.mjs")
522
+ maybeRefillAsync(agent) // start queued heads now that slots may have freed — no waiting
523
+ if (suspDriven) return // §17.5: settled stays pooled — the suspension session digests it
483
524
  for (const e of [...map.values()]) {
484
- const body = e.error ?? e.report ?? "(no report)"
485
- const preview = await offloadToolResult(String(body), `async-subagent-${e.id}`)
486
- pushReal(agent, {
487
- role: "user",
488
- content: `[System reminder: async subagent #${e.id} (${e.role}) finished]\n${escapeXml(preview)}`,
489
- })
525
+ if (!e.done) continue // still running — stays in the pool (D-S1)
526
+ await injectAsyncResult(agent, e)
527
+ map.delete(String(e.id))
490
528
  }
491
- map.clear()
492
- agent._asyncQueue = []
493
529
  }
@@ -81,6 +81,20 @@ export async function classifyAndApply(agent, turn) {
81
81
  ],
82
82
  tools: [],
83
83
  signal: AbortSignal.timeout(5_000),
84
+ // D-TS12 (AGENT-LOOP.md §18.7): full logCtx field set at the chat call
85
+ // point — traces/session/cwd/role/depth/kind (this call point carried
86
+ // only {stage,turn,child}). The traces field closes the D-TR6 "off = no
87
+ // persist" switch: without it the tracer treated the auto-think call as
88
+ // enabled and persisted even when agent.config.traces.enabled was false.
89
+ logCtx: {
90
+ stage: "autothink", turn, child: agent._logId,
91
+ traces: agent.config?.traces?.enabled !== false,
92
+ session: agent._sessionStart ?? null,
93
+ cwd: agent.cwd,
94
+ role: agent._role ?? null,
95
+ depth: agent._depth ?? 0, // agent state carries no depth stamp (the call site passes none) — 0 for the top-level agent
96
+ kind: "autothink",
97
+ },
84
98
  })
85
99
  const word = (response.content ?? "").trim().toLowerCase()
86
100
  if (word.startsWith("low")) level = "low"
@@ -3,6 +3,7 @@ import { join } from "node:path"
3
3
  import { createAgent } from "../agent.mjs"
4
4
  import { loadConfig, configDir } from "../config.mjs"
5
5
  import { createMemory, memoryTools, syncDir, codeSearchTool, docSearchTool } from "../memory.mjs"
6
+ import { settingsTool } from "../agent-tools/settings.mjs"
6
7
  import { repoOutlineTool } from "../tools/repomap.mjs"
7
8
  import { builtinTools } from "../tools/index.mjs"
8
9
  import { discoverRules } from "../rules.mjs"
@@ -48,7 +49,7 @@ export async function assembleAgent() {
48
49
  await ensureClone(team)
49
50
  await syncDir(memory, { layer: "team", dir: team.dir })
50
51
  }
51
- const baseTools = [...builtinTools, ...memoryTools(memory, { cwd, projectDir: config.memory.projectDir, author: gitAuthor(), team }), codeSearchTool(memory), docSearchTool(memory), repoOutlineTool(memory.db, cwd)]
52
+ const baseTools = [...builtinTools, ...memoryTools(memory, { cwd, projectDir: config.memory.projectDir, author: gitAuthor(), team }), codeSearchTool(memory), docSearchTool(memory), repoOutlineTool(memory.db, cwd), settingsTool()]
52
53
 
53
54
  // MCP servers: connect in parallel (a dead server won't block startup), collect failures as warnings (stderr invisible in TUI, passed via agent object)
54
55
  const mcpServers = config.mcp?.servers ?? []
@@ -19,7 +19,14 @@ export function formatPermission(name, args) {
19
19
  }
20
20
  if (base === "delete") return `${args.path}${args.force ? "(force:跟踪文件也删)" : ""}`
21
21
  if (base === "subagent") return cap(args.task ?? "", 500)
22
- if (base === "memory_put") return `[${args.type ?? ""}] ${args.title ?? ""}\n${cap(args.content ?? "", 500)}`
22
+ if (base === "memory") {
23
+ // §6 action-routed preview: put shows content, batch delete/clear show the gate args
24
+ const action = String(args.action ?? "")
25
+ if (action === "put") return `[${args.type ?? ""}] ${args.title ?? ""}\n${cap(args.content ?? "", 500)}`
26
+ if (action === "delete") return args.id ? `id=${args.id} scope=${args.scope}` : `batch delete scope=${args.scope} type=${args.type ?? ""} keyword=${args.keyword ?? ""} confirm=${args.confirm}`
27
+ if (action === "clear") return `clear scope=${args.scope} confirm=${args.confirm}`
28
+ return cap(summarize(args), 300)
29
+ }
23
30
  return cap(summarize(args), 300)
24
31
  }
25
32
 
package/src/config.mjs CHANGED
@@ -73,6 +73,10 @@ export const DEFAULTS = {
73
73
  provider: "tavily", // structured search API; empty apiKey → fall back to Bing HTML scraping
74
74
  apiKey: "", // Tavily key (tvly-...) — optional
75
75
  },
76
+ traces: {
77
+ enabled: false, // §18.6 D-TR6 修订(2026-09-05 用户裁定——发布隐私:"不希望用户那边也采集"):轨迹存档默认 OFF——新用户零采集;本地调试分析可显式开(~/.thincoder/config.json traces.enabled:true)
78
+ retentionHours: 24, // D-TR10:轨迹文件保留小时数——CLI 启动时删除超过该时长的文件(默认 24h)
79
+ },
76
80
  }
77
81
 
78
82
  // Model capability table + spec lookup live in model-specs.mjs (2026-08-31
@@ -195,6 +199,7 @@ export function loadConfig() {
195
199
  agent: { ...DEFAULTS.agent, ...config.agent },
196
200
  memory: { ...DEFAULTS.memory, ...config.memory },
197
201
  embedding: { ...DEFAULTS.embedding, ...config.embedding },
202
+ traces: { ...DEFAULTS.traces, ...config.traces },
198
203
  }
199
204
 
200
205
  // providers[].context (K units, PROVIDER.md §15 D-C1): positive integer only — invalid