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
package/src/agent.mjs CHANGED
@@ -15,6 +15,7 @@ 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,
@@ -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,72 +114,92 @@ 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
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
+ }
143
165
  }
144
- // eng-coder authorization is set by subagent.mjs AFTER token validation but BEFORE runAgent —
145
- // only reset for the top-level agent (depth 0); child runs must keep their granted authorization
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 })
169
+ }
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
146
172
  if (depth === 0) agent._engDesignReviewed = false
147
- // _engDesignToken survives across turns within the same agent (design review → user approval → spawn eng-coder).
148
- // 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.
149
175
  let guardPushbacks = 0
150
176
  let advisorPushbacks = 0
151
177
  let honestReminderInjected = false
152
178
  const recentCallSigs = []
153
- // repeat: "once" stream rules fire at most once per runAgent call (user turn):
154
- // 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.
155
181
  const streamRuleFired = new Set()
156
182
 
157
183
  // Compaction overhead for the pure-estimation path: system prompt + tools schema are
158
- // part of every request but not in history — without them the first-turn/restored/just-
159
- // compacted estimate under-counts and may never trigger compaction. Measured baseline
160
- // path already includes both (prompt_tokens is the full context), so this only applies
161
- // 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.
162
186
  const compactionOverhead = {
163
187
  systemPrompt,
164
188
  tools: toolSchemas,
189
+ // §18.6 D-TR4:compress 轨迹 depth 元数据(runAgent 的 depth 在此作用域——
190
+ // context.mjs compressIfNeeded 经 extras 透出到 logCtx)
191
+ traceDepth: depth,
165
192
  }
166
193
 
194
+ let thrownError = null
167
195
  try {
168
196
  for (let turn = 0; turn < maxTurns; turn++) {
169
197
  // Update turn counter for status bar display
170
198
  agent._currentTurn = turn + 1
171
199
  agent._maxTurns = maxTurns
172
- // D2 (AGENT-LOOP.md §7.2): depth>0 children emit a ⟦ev⟧turn progress token on every
173
- // turn — a single emit point covering all three spawn tools (natural heartbeat for the
174
- // TUI subagent block header: "turn N/max"). phase=llm (tool/done progress rides the
175
- // 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).
176
203
  if (depth > 0 && callbacks.onToken) {
177
204
  callbacks.onToken(`⟦ev⟧turn\x1e${turn + 1}\x1e${maxTurns}\x1ellm\x1e`)
178
205
  }
@@ -184,23 +211,27 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
184
211
  agent._compressFailures = 0
185
212
  agent._planReminderAtLen = 0 // After compression history shrinks, reset cadence so reminders resume
186
213
  recentCallSigs.length = 0 // After compression history is rebuilt, reset stall detection counter
187
- callbacks.onCompress?.()
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.
216
+ callbacks.onCompress?.(agent._lastCompressInfo ?? {})
188
217
  ensureAutoReminder(agent)
189
218
  }
190
219
  } catch (compressError) {
191
220
  // AbortError must not be swallowed: user cancellation must propagate
192
221
  if (compressError?.name === "AbortError" || signal?.aborted) throw compressError
193
222
  agent._compressFailures = (agent._compressFailures ?? 0) + 1
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.
225
+ callbacks?.onCompressFail?.(compressError)
194
226
  if (agent._compressFailures >= COMPRESS_FAILURE_LIMIT) {
195
227
  agent._compressFailures = 0
196
- if (compressFallback(agent)) callbacks.onCompress?.()
228
+ if (compressFallback(agent)) callbacks.onCompress?.(agent._lastCompressInfo ?? {})
197
229
  }
198
230
  }
199
231
  }
200
232
 
201
233
  // Plan-mode reminder cadence: re-inject constraint reminders while plan mode is active
202
- // (sparse every 2 turns, full every 5 turns or when the user sends a new message),
203
- // 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.
204
235
  if (agent.planMode) {
205
236
  const lastMsg = agent.history.at(-1)
206
237
  const realUserMsg = lastMsg?.role === "user"
@@ -216,9 +247,8 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
216
247
  }
217
248
  }
218
249
 
219
- // Engineering-mode status injection: every new user message carries a
220
- // reminder so the model always knows whether it's in design-before-code
221
- // mode or standard discipline mode.
250
+ // Engineering-mode status injection on every new user message (design-before-code
251
+ // vs standard discipline) see injectEngineeringReminder.
222
252
  if (depth === 0) {
223
253
  injectEngineeringReminder(agent)
224
254
  }
@@ -226,8 +256,7 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
226
256
  const messages = [{ role: "system", content: systemPrompt }, ...agent.history]
227
257
  let response
228
258
 
229
- // Auto-think: classify task difficulty and set reasoning effort before the real prompt.
230
- // 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.
231
260
  if (agent.config?.agent?.autoThink && turn === 0) {
232
261
  const { classifyAndApply } = await import("./auto-think.mjs")
233
262
  await classifyAndApply(agent, turn).catch(() => {})
@@ -242,14 +271,29 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
242
271
  signal,
243
272
  streamRules: agent.config.agent?.streamRules ?? [],
244
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
+ },
245
289
  })
246
290
  } catch (e) {
247
- // User interrupt (Ctrl+I): controller.abort({ interrupt: true, message: "…" }).
248
- // 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.
249
293
  if (e.name === "AbortError" && signal?.reason?.interrupt) {
250
294
  const msg = `[User interrupt: ${signal.reason.message}]`
251
- // Dedup: if the interrupt was already handled during tool execution (L302-310),
252
- // 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.
253
297
  if (agent.history.at(-1)?.content !== msg) {
254
298
  agent.history.push({ role: "user", content: msg })
255
299
  }
@@ -257,11 +301,9 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
257
301
  throw e
258
302
  }
259
303
 
260
- // 内置工具(Responses web_search)结果本地化:服务端已执行——入历史为 tool 消息,
261
- // 模型下一轮可见;全量回传时 transport tool_call_id 前缀还原 web_search_call item。
262
- // 注意:服务端 item id msg_xxx web_search_call_ 前缀——必须合成前缀(toItems 识别锚点),
263
- // 原始 id 存入 content(真机冒烟 2026-08-31:直接用 msg_xxx 会被转成 function_call_output
264
- // 与服务端不配对,属蒙对)。
304
+ // 内置工具(Responses web_search)结果本地化:服务端已执行——入历史为 tool 消息;
305
+ // 服务端 item id msg_xxx web_search_call_ 前缀——必须合成前缀(toItems 识别锚点),
306
+ // 原始 id 存入 content(真机冒烟 2026-08-31 验证)。
265
307
  for (const btr of response.builtinToolResults ?? []) {
266
308
  if (!btr?.id) continue
267
309
  pushReal(agent, {
@@ -271,8 +313,8 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
271
313
  })
272
314
  }
273
315
 
274
- // Stream rule triggered mid-generation (action: "abort"): halt current output,
275
- // 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.
276
318
  if (response.ruleTriggered) {
277
319
  if (response.content) {
278
320
  pushReal(agent, { role: "assistant", content: response.content })
@@ -285,9 +327,8 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
285
327
  continue
286
328
  }
287
329
 
288
- // Stream rule warnings (action: "warn"): the stream completed, but one or more
289
- // non-interrupting rules matched. Inject warnings after the turn so the model
290
- // 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.
291
332
  if (response._warnings?.length) {
292
333
  const deDuplicated = [...new Map(response._warnings.map(w => [w.name || w.pattern, w])).values()]
293
334
  agent.history.push({
@@ -296,9 +337,8 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
296
337
  })
297
338
  }
298
339
 
299
- // User interrupted mid-generation (Ctrl+I): the SSE stream was aborted while content
300
- // was partially generated. Commit partial output + inject user message, then signal
301
- // 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.
302
342
  if (response.interrupted) {
303
343
  if (response.content) {
304
344
  pushReal(agent, { role: "assistant", content: response.content })
@@ -318,8 +358,7 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
318
358
  }
319
359
  }
320
360
 
321
- // Warn on abnormal finish reasons — the model stopped for a reason other than
322
- // "stop" or "tool_calls", meaning the response may be incomplete or truncated.
361
+ // Warn on abnormal finish reasons — the response may be incomplete/truncated.
323
362
  if (response.finishReason && response.finishReason !== "stop" && response.finishReason !== "tool_calls") {
324
363
  const reasonMap = {
325
364
  length: "output token limit reached after exhausting continuations",
@@ -340,13 +379,11 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
340
379
  advisorPushbacks = cr.advisorPushbacks
341
380
  if (cr.action === "continue") continue
342
381
  if (depth === 0) {
343
- // End-of-run exploration distillation (CONTEXT-COMPACTION §5): this run's inline
344
- // exploration results become one semantic note before the final return. Async
345
- // (SEND-STALL-DISTILL §2.1): the turn-end signal goes out first the promise hangs
346
- // on agent._pendingDistill and settles at the next runAgent's start or the TUI's
347
- // exit flush. Silent (N3): distillation failure must never block the return or lose
348
- // history.
349
- 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(() => {})
350
387
  agent._pendingDistill = distill
351
388
  }
352
389
  return cr.content
@@ -369,8 +406,8 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
369
406
 
370
407
  const results = await executeToolCalls(agent, toolByName, response.toolCalls, callbacks, depth, signal)
371
408
 
372
- // Ctrl+I interrupt during tool execution: skip committing partial results —
373
- // 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).
374
411
  if (signal?.reason?.interrupt) {
375
412
  // 中断变更记账(2026-08-31 评审 #4):此分支的工具已全部执行完成(磁盘已变,execute 已完成),
376
413
  // 真实结果按语义不进历史(placeholder 替代)——但变更必须记账:否则 guard 看到
@@ -391,11 +428,9 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
391
428
  }
392
429
  } catch { /* 畸形 args 不影响记账(touchedFiles 尽力而为) */ }
393
430
  }
394
- // The assistant tool_calls were already committed above (L347) a strict
395
- // provider 400s on dangling tool_calls, so synthesize placeholder tool
396
- // results BEFORE the interrupt message (tool result must immediately
397
- // follow its assistant tool_calls). The retry turn then sees a clean,
398
- // 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).
399
434
  for (const tc of response.toolCalls) {
400
435
  agent.history.push({ role: "tool", tool_call_id: tc.id, content: "[Tool execution interrupted — results discarded]" })
401
436
  }
@@ -407,22 +442,88 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
407
442
  continue
408
443
  }
409
444
 
410
- // Model is executing tools → doing real work, reset guard pushback counter
445
+ // Model is executing tools → real work: reset guard pushback counters
411
446
  guardPushbacks = 0
412
447
  advisorPushbacks = 0
413
448
 
414
- // Commit tool results (pairing, multimodal deferral, mutation accounting,
415
- // touched files, reindex) — split into record-results.mjs (consult P2,
416
- // 2026-08-30).
449
+ // Commit tool results (pairing, multimodal deferral, mutation accounting, reindex)
417
450
  await recordToolResults(agent, toolByName, results)
418
451
 
419
452
  injectPostTurn(agent, results, recentCallSigs, callbacks, turn)
420
453
  }
421
454
 
422
455
  throw new ContinueError(maxTurns)
456
+ } catch (e) {
457
+ thrownError = e
458
+ throw e
423
459
  } finally {
424
460
  // Turn-end cleanup: abort any leftover consultation children (consult_start spawns
425
461
  // fire-and-forget runners; a completed turn must not let them keep burning tokens).
426
462
  cleanupConsultSessions(agent)
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
+ }
477
+ agent._asyncSubagents?.clear()
478
+ agent._asyncQueue = []
479
+ agent._asyncCheckLastN = 0
480
+ } else if (thrownError instanceof ContinueError) {
481
+ // keep _asyncSubagents + the check counter — the resumed run continues them
482
+ } else {
483
+ await collectSettledAsync(agent, { suspDriven })
484
+ agent._asyncCheckLastN = 0
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
+ }
498
+ }
499
+ }
500
+
501
+ /**
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 } = {}) {
519
+ const map = agent._asyncSubagents
520
+ if (!map || map.size === 0) return
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
524
+ for (const e of [...map.values()]) {
525
+ if (!e.done) continue // still running — stays in the pool (D-S1)
526
+ await injectAsyncResult(agent, e)
527
+ map.delete(String(e.id))
427
528
  }
428
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 ?? []
@@ -107,6 +108,31 @@ export async function assembleAgent() {
107
108
  agent.activeProvider = config.activeProvider
108
109
  agent.activeModel = config.activeModel ?? null
109
110
  agent._mcpWarnings = mcpWarnings
111
+ // SESSION.md §8 D-S1:assembleAgent 后唯一校验点(TUI/chat 两路径同源)——不抛错不退出,
112
+ // 标记由调用侧消费(TUI 弹重选 / headless 报错)。空 provider 由 TUI 路径在 startTUI 前清空。
113
+ validateProvider(agent)
114
+ return agent
115
+ }
116
+
117
+ /**
118
+ * SESSION.md §8 D-S1 — provider 有效性校验(assembleAgent 后唯一校验点,TUI/chat 两路径同源)。
119
+ * 判据(评审 #1/#2):仅 model/baseURL 缺失判 invalid——**不得用 MODEL_SPECS 成员资格判无效**
120
+ * (未知模型 = 受支持场景:自定义端点模型不在 spec 表是常态,误判会让自定义模型用户每次恢复都弹重选)。
121
+ * apiKey 缺失不判(既有 wizard /model 流程处理)。幂等:有效时清标记,无效时置标记 + 原因。
122
+ * 不抛错、不退出。返回 agent 便于链式调用。
123
+ */
124
+ export function validateProvider(agent) {
125
+ const ok = Boolean(agent.provider?.name && agent.provider.model && agent.provider.baseURL)
126
+ if (ok) {
127
+ delete agent._providerInvalid
128
+ delete agent._providerInvalidReason
129
+ } else {
130
+ agent._providerInvalid = true
131
+ agent._providerInvalidReason = !agent.provider?.name
132
+ ? "provider 不存在"
133
+ : !agent.provider.model ? "model 缺失"
134
+ : "缺少 baseURL"
135
+ }
110
136
  return agent
111
137
  }
112
138
 
@@ -1,7 +1,12 @@
1
- import { put, remove, search, list } from "../memory.mjs"
1
+ import { join } from "node:path"
2
+ import { loadConfig } from "../config.mjs"
3
+ import { teamConfig } from "./make-agent.mjs"
4
+ import { put, search, list, deleteByUid } from "../memory/core.mjs"
2
5
 
3
- /** thincoder memory <list|search|put|remove> subcommands */
4
- export async function memoryCommand(memory, args) {
6
+ /** thincoder memory <list|search|put|remove> subcommands.
7
+ * opts.dirs: { project, team } layer directories for project/team file deletion (tests inject their own);
8
+ * falls back to the same config-derived dirs the agent uses. */
9
+ export async function memoryCommand(memory, args, opts = {}) {
5
10
  const [sub, ...rest] = args
6
11
 
7
12
  const flags = {}
@@ -37,12 +42,18 @@ export async function memoryCommand(memory, args) {
37
42
  break
38
43
  }
39
44
  case "remove": {
40
- const id = Number(positional[0])
41
- if (!id) {
42
- console.error("Usage: thincoder memory remove <id>")
45
+ const uid = positional[0]
46
+ if (!uid) {
47
+ console.error("Usage: thincoder memory remove <uid> (uid: personal:<n> | project:<origin>:<path> | team:<origin>:<path>; bare <n> = personal)")
48
+ return 1
49
+ }
50
+ try {
51
+ const entry = await deleteByUid(memory, uid, { dirs: opts.dirs ?? cliDirs() })
52
+ console.log(`Removed ${entry.id}: ${entry.title}`)
53
+ } catch (e) {
54
+ console.error(e.message)
43
55
  return 1
44
56
  }
45
- console.log((await remove(memory, id)) ? `Removed #${id}` : `No entry #${id}`)
46
57
  break
47
58
  }
48
59
  default:
@@ -51,6 +62,16 @@ export async function memoryCommand(memory, args) {
51
62
  }
52
63
  }
53
64
 
65
+ /** Layer directories for project/team file deletion — derived from the same config the agent uses. */
66
+ function cliDirs() {
67
+ const config = loadConfig()
68
+ const dirs = { project: null, team: null }
69
+ if (config.memory?.projectDir) dirs.project = join(process.cwd(), config.memory.projectDir)
70
+ const team = teamConfig(config)
71
+ if (team) dirs.team = team.dir
72
+ return dirs
73
+ }
74
+
54
75
  function printEntries(entries) {
55
76
  if (entries.length === 0) {
56
77
  console.log("(no entries)")
@@ -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