thincoder 0.12.59 → 0.12.60

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (127) hide show
  1. package/CHANGELOG.md +38 -3
  2. package/README.md +2 -2
  3. package/bin/thincoder.mjs +80 -19
  4. package/package.json +4 -3
  5. package/src/acp/bridge.mjs +7 -4
  6. package/src/advisor/messages.mjs +24 -4
  7. package/src/advisor/run.mjs +35 -33
  8. package/src/advisor.mjs +25 -6
  9. package/src/agent/completion.mjs +17 -11
  10. package/src/agent/dispatch.mjs +102 -19
  11. package/src/agent/helpers.mjs +36 -0
  12. package/src/agent/record-results.mjs +46 -10
  13. package/src/agent/run-stages.mjs +227 -0
  14. package/src/agent/setup-reminders.mjs +62 -0
  15. package/src/agent/setup.mjs +18 -2
  16. package/src/agent/spawn-child.mjs +29 -4
  17. package/src/agent-tools/advisor-async.mjs +456 -0
  18. package/src/agent-tools/advisor.mjs +110 -108
  19. package/src/agent-tools/async-settle.mjs +191 -0
  20. package/src/agent-tools/consult.mjs +121 -102
  21. package/src/agent-tools/design-token.mjs +104 -0
  22. package/src/agent-tools/eng.mjs +24 -29
  23. package/src/agent-tools/escalate-async.mjs +286 -0
  24. package/src/agent-tools/read-history.mjs +155 -31
  25. package/src/agent-tools/recent-changes.mjs +2 -1
  26. package/src/agent-tools/settings.mjs +7 -17
  27. package/src/agent-tools/subagent-actions.mjs +168 -130
  28. package/src/agent-tools/subagent-async.mjs +129 -174
  29. package/src/agent-tools/subagent-panel.mjs +153 -0
  30. package/src/agent-tools/subagent-run.mjs +202 -0
  31. package/src/agent-tools/subagent-scheduler.mjs +45 -21
  32. package/src/agent-tools/subagent-spawn.mjs +406 -0
  33. package/src/agent-tools/subagent.mjs +107 -555
  34. package/src/agent-tools/verify.mjs +118 -270
  35. package/src/agent.mjs +57 -190
  36. package/src/cli/distill-command.mjs +10 -4
  37. package/src/cli/make-agent.mjs +3 -1
  38. package/src/cli/memory-command.mjs +2 -1
  39. package/src/cli/permission.mjs +2 -2
  40. package/src/cli/setup-wizard.mjs +17 -12
  41. package/src/config.mjs +56 -8
  42. package/src/context.mjs +5 -147
  43. package/src/crash-reports.mjs +123 -0
  44. package/src/distill.mjs +11 -11
  45. package/src/explore-distill.mjs +155 -0
  46. package/src/memory/code-sync.mjs +2 -1
  47. package/src/memory/core.mjs +6 -193
  48. package/src/memory/delete.mjs +234 -0
  49. package/src/memory/docs.mjs +58 -48
  50. package/src/memory.mjs +3 -1
  51. package/src/peer-domains.mjs +265 -0
  52. package/src/peer-instances.mjs +231 -0
  53. package/src/prompt-overlays.mjs +25 -0
  54. package/src/prompts/advisor-design.md +9 -76
  55. package/src/prompts/advisor-round1.md +9 -68
  56. package/src/prompts/advisor-round2.md +7 -54
  57. package/src/prompts/advisor-round3.md +7 -54
  58. package/src/prompts/coder.md +7 -50
  59. package/src/prompts/consult-base.md +4 -24
  60. package/src/prompts/discipline.md +26 -44
  61. package/src/prompts/eng-coder.md +7 -32
  62. package/src/prompts/engineering-sub.md +3 -23
  63. package/src/prompts/engineering.md +53 -306
  64. package/src/prompts/explore.md +3 -12
  65. package/src/prompts/main.md +10 -32
  66. package/src/prompts/methodology-template.md +28 -48
  67. package/src/prompts/plan.md +2 -9
  68. package/src/prompts/system.md +16 -35
  69. package/src/provider/core.mjs +6 -67
  70. package/src/provider/errors.mjs +76 -0
  71. package/src/provider/retry.mjs +8 -45
  72. package/src/session-gc.mjs +214 -0
  73. package/src/session-guard.mjs +47 -0
  74. package/src/session-rename.mjs +38 -0
  75. package/src/session-slots.mjs +181 -58
  76. package/src/session.mjs +48 -89
  77. package/src/token-ttl.mjs +273 -0
  78. package/src/tools/checklist-sync.mjs +181 -0
  79. package/src/tools/checklist.mjs +52 -39
  80. package/src/tools/edit-batch.mjs +109 -10
  81. package/src/tools/edit-diff.mjs +110 -27
  82. package/src/tools/edit.md +17 -12
  83. package/src/tools/execute.mjs +31 -4
  84. package/src/tools/file.mjs +11 -6
  85. package/src/tools/git.mjs +14 -6
  86. package/src/tools/glob-dialect.mjs +130 -0
  87. package/src/tools/glob.md +3 -3
  88. package/src/tools/grep.md +1 -1
  89. package/src/tools/index.mjs +5 -6
  90. package/src/tools/ops.mjs +175 -3
  91. package/src/tools/patch.mjs +3 -3
  92. package/src/tools/question.md +3 -0
  93. package/src/tools/read.md +0 -1
  94. package/src/tools/shared.mjs +14 -13
  95. package/src/tools/system.mjs +44 -9
  96. package/src/tools/wait_for.md +22 -0
  97. package/src/tui/agent-turn.mjs +17 -228
  98. package/src/tui/cmd-config.mjs +48 -7
  99. package/src/tui/cmd-eng.mjs +20 -16
  100. package/src/tui/cmd-mcp.mjs +8 -2
  101. package/src/tui/cmd-new.mjs +3 -2
  102. package/src/tui/cmd-session.mjs +19 -4
  103. package/src/tui/cmd-think.mjs +10 -10
  104. package/src/tui/cmd-upgrade.mjs +19 -4
  105. package/src/tui/config-helpers.mjs +28 -16
  106. package/src/tui/distill-cmd.mjs +1 -1
  107. package/src/tui/index.mjs +3 -2
  108. package/src/tui/interaction.mjs +3 -3
  109. package/src/tui/mouse.mjs +7 -1
  110. package/src/tui/pickers.mjs +40 -22
  111. package/src/tui/render-segments.mjs +27 -10
  112. package/src/tui/startup.mjs +4 -0
  113. package/src/tui/subagent-blocks.mjs +95 -263
  114. package/src/tui/subagent-children.mjs +176 -0
  115. package/src/tui/subagent-freeze.mjs +172 -0
  116. package/src/tui/subagent-panel.mjs +61 -23
  117. package/src/tui/suspension-drive.mjs +351 -0
  118. package/src/tui/tool-args.mjs +3 -3
  119. package/src/tui/tool-display.mjs +142 -0
  120. package/src/tui/tool-events.mjs +37 -173
  121. package/src/tui/tui-lifecycle.mjs +29 -0
  122. package/src/tui/update-notice.mjs +4 -0
  123. package/src/tui/wizard.mjs +12 -6
  124. package/src/tools/pdf-parse-text.mjs +0 -497
  125. package/src/tools/pdf-parse-xref.mjs +0 -499
  126. package/src/tools/pdf.mjs +0 -155
  127. package/src/tools/read_pdf.md +0 -21
package/src/agent.mjs CHANGED
@@ -3,10 +3,10 @@
3
3
  * LLM ↔ tool-call loop, until the task is done.
4
4
  */
5
5
  import { chat } from "./provider/index.mjs"
6
- import { compressIfNeeded, compressFallback, COMPRESS_FAILURE_LIMIT, pushReal, summarizeRunExplorations } from "./context.mjs"
6
+ import { pushReal, summarizeRunExplorations } from "./context.mjs"
7
7
  import { specForModel } from "./config.mjs"
8
8
  import { readFileSync } from "node:fs"
9
- import { join, dirname } from "node:path"
9
+ import { join, dirname, resolve } from "node:path"
10
10
  import { fileURLToPath } from "node:url"
11
11
  import { executeToolCalls } from "./agent/dispatch.mjs"
12
12
  import { recordToolResults } from "./agent/record-results.mjs"
@@ -14,32 +14,28 @@ import { FILE_MUTATORS } from "./agent/helpers.mjs"
14
14
  import { prepareRun } from "./agent/setup.mjs"
15
15
  import { injectPostTurn } from "./agent/post-turn.mjs"
16
16
  import { handleCompletion } from "./agent/completion.mjs"
17
- import { cleanupConsultSessions } from "./agent-tools/consult.mjs"
18
- import { logEvent } from "./log.mjs"
17
+ // 主循环阶段函数(压缩检查/注入组/回合收尾)2026-09-05 实践轮迁 agent/run-stages.mjs
18
+ import { runCompactionCheck, injectTurnReminders, finalizeAgentTurn, injectResponseReminders } from "./agent/run-stages.mjs"
19
19
  import {
20
- escapeXml, repairHistory, listWorkDir, ensureAutoReminder,
20
+ escapeXml, repairHistory, listWorkDir,
21
21
  readonlyToolNames, collectGitContext, loadProjectInstructions,
22
22
  ContinueError,
23
23
  DEFAULT_MAX_TURNS, DEFAULT_SUBAGENT_TURNS,
24
24
  MIN_REPORT_CHARS, REPORT_CONTINUATION,
25
+ AUTO_TURN_DIGEST_DOMAIN,
25
26
  } from "./agent/helpers.mjs"
27
+ // ENG 提醒族 + auto-turn domain 2026-09-05 迁 agent/helpers.mjs(agent.mjs 530 > 500 硬限)
28
+ // overlay 载荷(explore/coder/plan/eng-coder/consult)迁 prompt-overlays.mjs——re-export 保面
29
+ export {
30
+ EXPLORE_OVERLAY, CODER_OVERLAY, PLAN_OVERLAY, ENG_CODER_OVERLAY, CONSULT_BASE,
31
+ } from "./prompt-overlays.mjs"
32
+ export { ENG_ON_REMINDER, ENG_OFF_REMINDER } from "./agent/helpers.mjs"
26
33
 
27
34
  // Prompt files (byte-stable, loaded once)
28
35
  const __dirname = dirname(fileURLToPath(import.meta.url))
29
36
  const SYSTEM_PROMPT = readFileSync(join(__dirname, "prompts", "system.md"), "utf8")
30
37
  const DISCIPLINE_RULES = readFileSync(join(__dirname, "prompts", "discipline.md"), "utf8")
31
38
  const MAIN_OVERLAY = readFileSync(join(__dirname, "prompts", "main.md"), "utf8")
32
- let _EXPLORE, _CODER, _PLAN, _ENG_CODER, _CONSULT_BASE
33
- try { _EXPLORE = readFileSync(join(__dirname, "prompts", "explore.md"), "utf8") } catch { _EXPLORE = "" }
34
- try { _CODER = readFileSync(join(__dirname, "prompts", "coder.md"), "utf8") } catch { _CODER = "" }
35
- try { _PLAN = readFileSync(join(__dirname, "prompts", "plan.md"), "utf8") } catch { _PLAN = "" }
36
- try { _ENG_CODER = readFileSync(join(__dirname, "prompts", "eng-coder.md"), "utf8") } catch { _ENG_CODER = "" }
37
- try { _CONSULT_BASE = readFileSync(join(__dirname, "prompts", "consult-base.md"), "utf8") } catch { _CONSULT_BASE = "" }
38
- export const EXPLORE_OVERLAY = _EXPLORE
39
- export const CODER_OVERLAY = _CODER
40
- export const PLAN_OVERLAY = _PLAN
41
- export const ENG_CODER_OVERLAY = _ENG_CODER
42
- export const CONSULT_BASE = _CONSULT_BASE
43
39
 
44
40
  // exported for consumption by agent-tools.mjs
45
41
  export {
@@ -50,40 +46,9 @@ export {
50
46
  }
51
47
 
52
48
 
53
- // Engineering mode reminder — shared with eng.mjs tool
54
- export const ENG_ON_REMINDER =
55
- "[System reminder: engineering mode is ON — design-before-code enforced. " +
56
- "Workflow: Requirements doc → Design doc → advisor(type='design') → " +
57
- "user approval → eng-coder implementation. Code changes go through eng-coder " +
58
- "subagents only. Advisor calls are NOT per-turn-mandatory — call only at " +
59
- "flow nodes or when the user asks.]"
60
-
61
49
  // Re-exported for API compatibility (single source of truth: advisor/repos.mjs)
62
50
  export { hasCodeMutations } from "./advisor/repos.mjs"
63
51
 
64
- /** Engineering mode OFF reminder — shared with the eng tool and the injector. */
65
- export const ENG_OFF_REMINDER =
66
- "[System reminder: engineering mode is now OFF — standard discipline applies. " +
67
- "Changes go through the normal workflow: you may edit files directly, advisor/verify " +
68
- "guards apply per config.]"
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
-
76
- /** Engineering-mode status injection — one reminder on EVERY transition (2026-08-25:
77
- * OFF is announced too — the model must know the gates lifted; silence after /eng-off
78
- * left it guessing. Covers TUI /eng, resume, and any path bypassing the eng tool.) */
79
- function injectEngineeringReminder(agent) {
80
- const eng = agent.config?.agent?.engineering ?? false
81
- if (eng !== agent._lastEngState) {
82
- agent.history.push({ role: "user", content: eng ? ENG_ON_REMINDER : ENG_OFF_REMINDER, transient: true })
83
- }
84
- agent._lastEngState = eng
85
- }
86
-
87
52
  /** Create a new agent state object with all fields initialized to defaults */
88
53
  export function createAgent({
89
54
  provider, tools, config, cwd, memory, overlay, role,
@@ -97,8 +62,12 @@ export function createAgent({
97
62
  planMode, autoApprove, goal,
98
63
  _mutatedThisRun: false, _verifiedThisRun: false, _verifyPassed: undefined, _calledAdvisorThisRun: false,
99
64
  _engDesignReviewed: false, // eng-coder: design review gate passed (hard gate in dispatch.mjs)
100
- _engDesignToken: null, // issued by advisor(type="design"); required to spawn eng-coder
65
+ // DESIGN-TOKEN-SETTLEMENT D3 (2026-09-08): single-value `_engDesignToken` mirror retired
66
+ // (AC3 零写) — no field initializer; the multi-slot Map `_engDesignTokens` is the
67
+ // authoritative ledger (hydrated by restoreEngTokens / written by settle).
101
68
  _touchedFiles: [], _verifyRetries: 0, _advisorRound: 0, _advisorSession: null,
69
+ _advisorRuns: new Map(), // §24 D-24b: per-review convergence instances (rounds/prior/designId)
70
+ _mutationSeq: 0, _mutLog: [], // §24 D-24b: mutation log (in-flight review staleness scan)
102
71
  _lastAdvisorOutput: null, // full review output from the most recent advisor call (convergence rounds inject it verbatim)
103
72
  _lastEngState: false,
104
73
  _pendingReminders: [],
@@ -114,7 +83,7 @@ export function createAgent({
114
83
  }
115
84
 
116
85
  /** Run the agent loop: LLM ↔ tool-call cycle until task completion or turn limit. Returns final text content. */
117
- export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal, maxTurns: overrideTurns, resume = false, autoTurn = false, suspDriven = false } = {}) {
86
+ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal, maxTurns: overrideTurns, resume = false, autoTurn = false, suspDriven = false, consumeInjected = null } = {}) {
118
87
  // Previous run's async exploration distillation must settle before this run pushes
119
88
  // input (SEND-STALL-DISTILL §2.2 N1) — await first, or its history replace wipes it.
120
89
  if (agent._pendingDistill) {
@@ -125,10 +94,18 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
125
94
  // §17 D-S3: suspension-settled async results inject before EVERY run's prepareRun
126
95
  // (user + auto-turn); spliced = consumed. collectSettledAsync owns a different
127
96
  // container, so no double-inject across the two consumption points.
97
+ // ASYNC-RESULT-CONTAINER.md D2 (2026-09-08):pending 单容器 `_pendingAsyncResults`
98
+ // +role——四族(subagent/advisor/escalate/consult)统一停靠;注入器按 role 分发
99
+ // (consult → injectConsultResult;其余 → injectAsyncResult——族分支同 §25 D-R17a/b)
100
+ // ——单容器一处清,不再逐族三段。
128
101
  const pendingAsync = agent._pendingAsyncResults
129
102
  if (pendingAsync?.length) {
130
103
  const { injectAsyncResult } = await import("./agent-tools/subagent.mjs")
131
- for (const e of pendingAsync.splice(0)) await injectAsyncResult(agent, e)
104
+ const { injectConsultResult } = await import("./agent-tools/consult.mjs")
105
+ for (const e of pendingAsync.splice(0)) {
106
+ if (e.role === "consult") await injectConsultResult(agent, e)
107
+ else await injectAsyncResult(agent, e)
108
+ }
132
109
  }
133
110
  agent._inAutoTurn = autoTurn // spawn gate for manual-tier digests (§17 D-S6/N3)
134
111
  const { maxTurns, threshold, tools, toolSchemas, toolByName, systemPrompt } = await prepareRun(
@@ -160,18 +137,19 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
160
137
  agent._advisorSession = null // advisor session is per-run: discard when the task ends, next task starts fresh
161
138
  agent._emptyRetries = 0 // empty-response retry budget is per-run: a fresh user turn restarts from zero
162
139
  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
140
  }
165
141
  }
166
142
  // §17 D-S6 manual tier: digest action-domain reminder (system-driven turn — organize only).
167
143
  if (autoTurn && !agent.autoApprove) {
168
144
  agent.history.push({ role: "user", content: AUTO_TURN_DIGEST_DOMAIN, transient: true })
169
145
  }
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
172
- if (depth === 0) agent._engDesignReviewed = false
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.
146
+ // eng-coder authorization (_engDesignReviewed) is eng-coder-only: set by subagent-spawn.mjs
147
+ // (spawn gate) / design-token.mjs (design review pass) BEFORE the child runAgent the
148
+ // depth-0 parent never reads or writes it (the parent gate reads anyLiveDesignSlot; the
149
+ // depth-0 per-turn reset was removed 2026-09-08, ENG-SESSION-PROVIDER-CLEANUP D1.3).
150
+ // Design slots (_engDesignTokens Map) survive across turns (design review approval
151
+ // eng-coder spawn) — persisted to the session slot at settle time (DESIGN-TOKEN-
152
+ // SETTLEMENT D1); lifecycle: issued on a passing review, consumed by consume-design / TTL.
175
153
  let guardPushbacks = 0
176
154
  let advisorPushbacks = 0
177
155
  let honestReminderInjected = false
@@ -204,54 +182,21 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
204
182
  callbacks.onToken(`⟦ev⟧turn\x1e${turn + 1}\x1e${maxTurns}\x1ellm\x1e`)
205
183
  }
206
184
 
185
+ // SUBAGENT-OBSERVE-SEND D2: 子代理回合边界消费点——每轮开头把父侧经 subagent
186
+ // action:'send' 注入队列(entry._injected)的消息按普通 user 回合推入子历史
187
+ // (pushReal → 下一轮 chat 即含该指令)。由 executeAsyncSpawn 经 childRunOpts 贯通的
188
+ // consumeInjected 回调承载(异步子代理专属——缺省 null:主会话/阻塞子代理零开销)。
189
+ consumeInjected?.(agent)
190
+
207
191
  const lastRole = agent.history.at(-1)?.role
208
192
  if (lastRole === "user" || lastRole === "tool") {
209
- try {
210
- if (await compressIfNeeded(agent, threshold, callbacks, compactionOverhead, signal)) {
211
- agent._compressFailures = 0
212
- agent._planReminderAtLen = 0 // After compression history shrinks, reset cadence so reminders resume
213
- recentCallSigs.length = 0 // After compression history is rebuilt, reset stall detection counter
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 ?? {})
217
- ensureAutoReminder(agent)
218
- }
219
- } catch (compressError) {
220
- // AbortError must not be swallowed: user cancellation must propagate
221
- if (compressError?.name === "AbortError" || signal?.aborted) throw compressError
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)
226
- if (agent._compressFailures >= COMPRESS_FAILURE_LIMIT) {
227
- agent._compressFailures = 0
228
- if (compressFallback(agent)) callbacks.onCompress?.(agent._lastCompressInfo ?? {})
229
- }
230
- }
231
- }
232
-
233
- // Plan-mode reminder cadence: re-inject constraint reminders while plan mode is active
234
- // (sparse every 2 turns, full every 5 / on new user message) so the restriction never fades.
235
- if (agent.planMode) {
236
- const lastMsg = agent.history.at(-1)
237
- const realUserMsg = lastMsg?.role === "user"
238
- && typeof lastMsg.content === "string"
239
- && !lastMsg.content.startsWith("[System reminder:")
240
- && !lastMsg.content.startsWith("[User interrupt:")
241
- const newUserSince = realUserMsg && agent.history.length > (agent._planReminderAtLen ?? 0)
242
- const { planReminderForTurn } = await import("./agent-tools/plan.mjs")
243
- const reminder = planReminderForTurn(agent, newUserSince)
244
- if (reminder) {
245
- agent._planReminderAtLen = agent.history.length + 1
246
- agent.history.push({ role: "user", content: reminder, transient: true })
247
- }
193
+ // 2026-09-05 实践轮:压缩检查/降级计数提为 runCompactionCheck(agent/run-stages.mjs——
194
+ // CLI 对位 VS run-stages)——循环骨架此处只剩检查调用(recentCallSigs 对象引用回流)。
195
+ await runCompactionCheck(agent, { threshold, callbacks, compactionOverhead, signal, recentCallSigs })
248
196
  }
249
197
 
250
- // Engineering-mode status injection on every new user message (design-before-code
251
- // vs standard discipline) — see injectEngineeringReminder.
252
- if (depth === 0) {
253
- injectEngineeringReminder(agent)
254
- }
198
+ // 2026-09-05 实践轮:plan cadence + eng 状态注入提为 injectTurnReminders(run-stages)
199
+ await injectTurnReminders(agent, { depth })
255
200
 
256
201
  const messages = [{ role: "system", content: systemPrompt }, ...agent.history]
257
202
  let response
@@ -327,15 +272,9 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
327
272
  continue
328
273
  }
329
274
 
330
- // Stream rule warnings (action: "warn"): stream completed; inject de-duplicated
331
- // warnings so the model sees them before its next response.
332
- if (response._warnings?.length) {
333
- const deDuplicated = [...new Map(response._warnings.map(w => [w.name || w.pattern, w])).values()]
334
- agent.history.push({
335
- role: "user",
336
- content: `[System reminder — stream rule warnings from your last response:\n${deDuplicated.map(w => `- ${w.name || w.pattern}: ${w.message}`).join("\n")}]`,
337
- })
338
- }
275
+ // Stream rule warnings / finish-reason 警告(2026-09-05 实践轮——提为
276
+ // injectResponseReminders,agent/run-stages.mjs——verbatim,语义零变)
277
+ injectResponseReminders(agent, response)
339
278
 
340
279
  // User interrupted mid-generation (Ctrl+I): commit partial output + inject the
341
280
  // message, then signal the outer loop to recreate the controller and resume.
@@ -359,18 +298,7 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
359
298
  }
360
299
 
361
300
  // Warn on abnormal finish reasons — the response may be incomplete/truncated.
362
- if (response.finishReason && response.finishReason !== "stop" && response.finishReason !== "tool_calls") {
363
- const reasonMap = {
364
- length: "output token limit reached after exhausting continuations",
365
- insufficient_system_resource: "provider inference resources exhausted — consider retrying or switching models",
366
- content_filter: "response blocked by provider content filtering",
367
- }
368
- const detail = reasonMap[response.finishReason] || `unknown reason "${response.finishReason}"`
369
- agent.history.push({
370
- role: "user",
371
- content: `[System reminder: the previous turn ended abnormally — ${detail}. The assistant response that follows may be incomplete.]`,
372
- })
373
- }
301
+ // 2026-09-05 实践轮:finish-reason 注入随流规则警告提为 injectResponseReminders。
374
302
 
375
303
  if (response.toolCalls.length === 0) {
376
304
  const cr = handleCompletion(agent, response, depth, turn, guardPushbacks, honestReminderInjected, advisorPushbacks, callbacks)
@@ -412,6 +340,9 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
412
340
  // 中断变更记账(2026-08-31 评审 #4):此分支的工具已全部执行完成(磁盘已变,execute 已完成),
413
341
  // 真实结果按语义不进历史(placeholder 替代)——但变更必须记账:否则 guard 看到
414
342
  // "本轮未改代码" 放行,评审/verify 门禁被绕过(文件改了却没评审)。
343
+ // §29 fix A(2026-09-07):mutation-seq 记账已收敛到 dispatch runOne 执行成功即刻
344
+ // (唯一记账点——本分支不再 noteMutations——不双计——中断+同批 launch seq 单计
345
+ // 回归断言见 §29 T-A1i);此处仅剩 guard 标志 + touchedFiles 记账。
415
346
  for (const { toolCall, ok } of results) {
416
347
  const tool = toolByName.get(toolCall.name)
417
348
  if (!ok || !tool || !FILE_MUTATORS.has(toolCall.name)) continue
@@ -423,7 +354,7 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
423
354
  const args = JSON.parse(toolCall.arguments)
424
355
  const paths = tool.touchedPaths ? tool.touchedPaths(args) : [args.path]
425
356
  for (const p of paths) {
426
- const abs = join(agent.cwd, p)
357
+ const abs = resolve(agent.cwd, p)
427
358
  if (!agent._touchedFiles.includes(abs)) agent._touchedFiles.push(abs)
428
359
  }
429
360
  } catch { /* 畸形 args 不影响记账(touchedFiles 尽力而为) */ }
@@ -457,73 +388,9 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
457
388
  thrownError = e
458
389
  throw e
459
390
  } finally {
460
- // Turn-end cleanup: abort any leftover consultation children (consult_start spawns
461
- // fire-and-forget runners; a completed turn must not let them keep burning tokens).
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))
391
+ // 2026-09-05 实践轮:回合收尾(consult 清理/async 池分流/guard 继承——原 425-462
392
+ // + collectSettledAsync 466-494 整体迁 agent/run-stages.mjs finalizeAgentTurn——
393
+ // CLI 对位 VS run-stages——finally 只剩一行调用 + 骨架注释)。
394
+ await finalizeAgentTurn(agent, { signal, autoTurn, suspDriven, thrownError })
528
395
  }
529
396
  }
@@ -10,7 +10,7 @@ function noKeyMessage() {
10
10
  return `还没有配置 API key。运行 thincoder 进入 TUI,用 /provider add 和 /provider key 配置;或直接编辑 ${configPath}`
11
11
  }
12
12
 
13
- /** thincoder distill <transcript-file> [--yes] [--scope=...]
13
+ /** thincoder distill <transcript-file> [--yes] [--layer=...]
14
14
  * Returns exit code: 0=success, 1=error */
15
15
  export async function distillCommand(args, exitSoon) {
16
16
  const flags = {}
@@ -20,9 +20,15 @@ export async function distillCommand(args, exitSoon) {
20
20
  if (m) flags[m[1]] = m[2] ?? true
21
21
  else positional.push(a)
22
22
  }
23
+ // AC2(MEMORY.md §6.5):显式拦截旧 flag——`--scope=X` 与 `--scope X` 两形态落入 flags.scope,
24
+ // 报错防静默吞参(解析器本不校验未知 flag——旧 --scope=project 静默落 personal 最危险)。
25
+ if (flags.scope !== undefined) {
26
+ console.error("distill: --scope renamed to --layer — update your invocation")
27
+ return 1
28
+ }
23
29
  const file = positional[0]
24
30
  if (!file) {
25
- console.error("Usage: thincoder distill <transcript-file> [--yes] [--scope=personal|project|team]")
31
+ console.error("Usage: thincoder distill <transcript-file> [--yes] [--layer=personal|project|team]")
26
32
  return 1
27
33
  }
28
34
  const { readFile } = await import("node:fs/promises")
@@ -64,9 +70,9 @@ export async function distillCommand(args, exitSoon) {
64
70
  }
65
71
  let saved = 0
66
72
  for (const c of candidates) {
67
- if (flags.scope) c.scope = flags.scope
73
+ if (flags.layer) c.layer = flags.layer
68
74
  console.log(`\n--- candidate ---`)
69
- console.log(`[${c.type}] ${c.title} (scope: ${c.scope})`)
75
+ console.log(`[${c.type}] ${c.title} (layer: ${c.layer})`)
70
76
  console.log(c.content)
71
77
  if (c.type === "rule") {
72
78
  console.log("(rule 类知识通常建议手动撰写;确认提取吗?)")
@@ -7,6 +7,8 @@ import { settingsTool } from "../agent-tools/settings.mjs"
7
7
  import { repoOutlineTool } from "../tools/repomap.mjs"
8
8
  import { builtinTools } from "../tools/index.mjs"
9
9
  import { discoverRules } from "../rules.mjs"
10
+ // R10 L2(MULTI-INSTANCE-COLLAB §2a.4 D-L2b):peer_instances 只读工具——挂感知模块导出
11
+ import { peerInstancesTool } from "../peer-instances.mjs"
10
12
 
11
13
  /** Assemble an agent with memory, MCP tools, and code/doc indices attached (sync all layers, then return) */
12
14
  export async function assembleAgent() {
@@ -49,7 +51,7 @@ export async function assembleAgent() {
49
51
  await ensureClone(team)
50
52
  await syncDir(memory, { layer: "team", dir: team.dir })
51
53
  }
52
- const baseTools = [...builtinTools, ...memoryTools(memory, { cwd, projectDir: config.memory.projectDir, author: gitAuthor(), team }), codeSearchTool(memory), docSearchTool(memory), repoOutlineTool(memory.db, cwd), settingsTool()]
54
+ const baseTools = [...builtinTools, ...memoryTools(memory, { cwd, projectDir: config.memory.projectDir, author: gitAuthor(), team }), codeSearchTool(memory), docSearchTool(memory), repoOutlineTool(memory.db, cwd), settingsTool(), peerInstancesTool]
53
55
 
54
56
  // MCP servers: connect in parallel (a dead server won't block startup), collect failures as warnings (stderr invisible in TUI, passed via agent object)
55
57
  const mcpServers = config.mcp?.servers ?? []
@@ -1,7 +1,8 @@
1
1
  import { join } from "node:path"
2
2
  import { loadConfig } from "../config.mjs"
3
3
  import { teamConfig } from "./make-agent.mjs"
4
- import { put, search, list, deleteByUid } from "../memory/core.mjs"
4
+ import { put, search, list } from "../memory/core.mjs"
5
+ import { deleteByUid } from "../memory/delete.mjs"
5
6
 
6
7
  /** thincoder memory <list|search|put|remove> subcommands.
7
8
  * opts.dirs: { project, team } layer directories for project/team file deletion (tests inject their own);
@@ -23,8 +23,8 @@ export function formatPermission(name, args) {
23
23
  // §6 action-routed preview: put shows content, batch delete/clear show the gate args
24
24
  const action = String(args.action ?? "")
25
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}`
26
+ if (action === "delete") return args.id ? `id=${args.id}${args.layer ? ` layer=${args.layer}` : ""}` : `batch delete layer=${args.layer ?? ""} type=${args.type ?? ""} keyword=${args.keyword ?? ""} confirm=${args.confirm}`
27
+ if (action === "clear") return `clear layer=${args.layer ?? ""} confirm=${args.confirm}`
28
28
  return cap(summarize(args), 300)
29
29
  }
30
30
  return cap(summarize(args), 300)
@@ -1,6 +1,5 @@
1
- import { existsSync, readFileSync } from "node:fs"
2
1
  import { createInterface } from "node:readline"
3
- import { configPath, saveConfig, PROVIDER_PRESETS } from "../config.mjs"
2
+ import { configPath, writeConfigAtomic, PROVIDER_PRESETS } from "../config.mjs"
4
3
 
5
4
  /** First-time setup (TTY chat / distill): ask a few questions to configure a provider, save to disk, return runtime provider. Cancel returns null. */
6
5
  export async function setupWizard() {
@@ -52,16 +51,22 @@ export async function setupWizard() {
52
51
  return null
53
52
  }
54
53
  const embedKey = (await ask("Optional: embedding API key (SiliconFlow, for vector search; press Enter to skip): ")).trim()
55
- const raw = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")) : {}
56
- const providers = raw.providers?.length ? raw.providers : []
57
- const existing = providers.find((p) => p.name === name)
58
- if (existing) Object.assign(existing, { baseURL, model, apiKey })
59
- else providers.push({ name, baseURL, model, apiKey })
60
- raw.providers = providers
61
- raw.activeProvider = name
62
- delete raw.activeModel // reset to default model
63
- if (embedKey) raw.embedding = { ...(raw.embedding ?? {}), apiKey: embedKey }
64
- saveConfig(raw)
54
+ // D-F5b:磁盘新鲜读 mutate mtime 门控写(writeConfigAtomic 收口);冲突 = 放弃
55
+ // + 提示重试(首配场景另有实例同时写盘——极低概率;不自动合并——决策点① A)
56
+ const r = writeConfigAtomic(configPath, (raw) => {
57
+ const providers = raw.providers?.length ? raw.providers : []
58
+ const existing = providers.find((p) => p.name === name)
59
+ if (existing) Object.assign(existing, { baseURL, model, apiKey })
60
+ else providers.push({ name, baseURL, model, apiKey })
61
+ raw.providers = providers
62
+ raw.activeProvider = name
63
+ delete raw.activeModel // reset to default model
64
+ if (embedKey) raw.embedding = { ...(raw.embedding ?? {}), apiKey: embedKey }
65
+ })
66
+ if (!r.ok) {
67
+ console.error("config changed on disk concurrently — retry")
68
+ return null
69
+ }
65
70
  console.error(`Configured: ${name} / ${model} (saved to ${configPath})`)
66
71
  console.error(embedKey ? "Vector search enabled\n" : "(No embedding key configured: memory search will use text-only FTS. Add embedding.apiKey to config.json to enable vector search later.)\n")
67
72
  return { name, baseURL, model, apiKey }
package/src/config.mjs CHANGED
@@ -5,9 +5,9 @@
5
5
  * API key can fall back to environment variables (when not configured in providers).
6
6
  */
7
7
 
8
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
8
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
9
9
  import { homedir } from "node:os"
10
- import { join } from "node:path"
10
+ import { dirname, join } from "node:path"
11
11
 
12
12
  export const configDir = join(homedir(), ".thincoder")
13
13
  export const configPath = join(configDir, "config.json")
@@ -55,6 +55,14 @@ export const DEFAULTS = {
55
55
  advisor: { guard: false }, // code review is always available; guard: true pushes completion back until reviewed (opt-in). Also accepts provider/model/thinking/reasoningEffort/timeoutMs overrides. Deprecated: enabled (2026-08-21)
56
56
  autoThink: false, // auto-classify task difficulty and set reasoning effort per-turn
57
57
  engineering: false, // strict methodology enforcement — read METHODOLOGY.md, design-before-code
58
+ // Async subagent pool limits per role domain (AGENT-LOOP.md §24 D-24a/R14):
59
+ // { engCoder, other } — eng-coder pool / other-role pool, defaults 4/4 (user
60
+ // ruling "eng-coder 四路,其他 4 路"). Runtime-validated at every pool admission
61
+ // (positive integer ≥1, invalid/absent keys fall back to 4 — settings tool and
62
+ // the /config 并发池 menu write this key; change applies to the next spawn).
63
+ // ⚠ 与 subagent-async.mjs ASYNC_POOL_LIMITS 逐键同值(运行时回退常量)——耦合锚
64
+ // T-24a4 断言锁住——勿单侧改默认。
65
+ poolLimits: { engCoder: 4, other: 4 },
58
66
  },
59
67
  memory: {
60
68
  dbPath: join(configDir, "memory.db"),
@@ -368,12 +376,52 @@ function mcpFingerprint(s) {
368
376
  }
369
377
 
370
378
  /**
371
- * Save configuration. Preserves providers list structure and activeProvider pointer.
372
- * providers[i].apiKey is only written when explicitly passed in (does not overwrite env-var-fallback keys).
379
+ * R10 F5(D-F5b,2026-09-06)——config.json 写前 mtime 门控收口函数(session-rename
380
+ * mtime-conflict 先例同型——MULTI-INSTANCE-COLLAB.md §2a.2)。所有 config.json 写点
381
+ * (config-helpers persistRaw / cmd-config saveProxy / cli setup-wizard / settings
382
+ * writeDisk)都经它落盘。
383
+ *
384
+ * 本函数持有整条「新鲜读 → mutate 单操作 → 写前重 stat → 写」链:
385
+ * - t0 = 写前重 stat 的比对基线,取在**新鲜读之前**(stat→read 序):若对端在本端
386
+ * stat 与 read 之间的微窗口写入,只会造成假冲突(放弃重试),绝不会带着旧内容覆盖
387
+ * 对端新值——read→stat 序存在漏检窗口(stat 已反映对端新 mtime → 门控放行旧内容)。
388
+ * - 写前重 stat ≠ t0 → **放弃**本次写(D-F5a 后各流已是 fresh 单操作语义,磁盘上对端
389
+ * 的新值保持在线不抹);先 copy `.bak-{ts}` 留现场(仅冲突时——config 低频写不膨胀;
390
+ * copy 而非 rename:冲突即放弃、本体不动,"保现场"是额外副本,非轮转腾位)。
391
+ * - 返回 { ok:false, reason:"mtime-conflict" },调用方提示 "config changed on disk
392
+ * concurrently — retry"——不自动合并(config 是用户显式操作——重试比猜测合并安全,
393
+ * 决策点① A)。
394
+ * - 文件缺失(首写)→ t0 = null;对端在本端读后创建 → null ≠ 新 mtime → 冲突放弃。
395
+ * - 畸形文件拒写(throw,绝不静默覆盖);写后 chmod 0600 尽力而为(saveConfig 旧语义)。
396
+ *
397
+ * @param path config.json 路径(生产默认 configPath;测试注入 tmp 路径)
398
+ * @param mutate 在磁盘新鲜 raw 上执行单操作的同步回调(如 push/splice/单字段补丁)
399
+ * @returns { ok: true } | { ok: false, reason: "mtime-conflict" }
373
400
  */
374
- export function saveConfig(config) {
375
- mkdirSync(configDir, { recursive: true })
401
+ export function writeConfigAtomic(path, mutate) {
402
+ const mtimeOf = (p) => {
403
+ try { return statSync(p).mtimeMs } catch { return null } // 缺失 → null(t0 比对基线)
404
+ }
405
+ const t0 = mtimeOf(path) // stat 先于 read(安全方向——见头注释)
406
+ const text = existsSync(path) ? readFileSync(path, "utf8") : null
407
+ let raw = {}
408
+ if (text !== null) {
409
+ try {
410
+ raw = JSON.parse(text)
411
+ } catch (error) {
412
+ throw new Error(`config file not parseable — refusing to overwrite: ${path} — ${error.message}`, { cause: error })
413
+ }
414
+ }
415
+ mutate(raw)
416
+ const t1 = mtimeOf(path)
417
+ if (t0 !== t1) {
418
+ // 对端在我们新鲜读后改过磁盘 → 放弃本次写(对端内容保持在线);.bak 副本留现场
419
+ try { if (existsSync(path)) copyFileSync(path, `${path}.bak-${Date.now()}`) } catch { /* 现场保留失败不阻断冲突报告 */ }
420
+ return { ok: false, reason: "mtime-conflict" }
421
+ }
422
+ mkdirSync(dirname(path), { recursive: true })
376
423
  // 0600: config.json contains API keys, must not be world-readable (POSIX; chmod is best-effort on Windows)
377
- writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", { encoding: "utf8", mode: 0o600 })
378
- try { chmodSync(configPath, 0o600) } catch { /* may fail on Windows, ignore */ }
424
+ writeFileSync(path, JSON.stringify(raw, null, 2) + "\n", { encoding: "utf8", mode: 0o600 })
425
+ try { chmodSync(path, 0o600) } catch { /* may fail on Windows, ignore */ }
426
+ return { ok: true }
379
427
  }