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.
- package/CHANGELOG.md +38 -3
- package/README.md +2 -2
- package/bin/thincoder.mjs +80 -19
- package/package.json +4 -3
- package/src/acp/bridge.mjs +7 -4
- package/src/advisor/messages.mjs +24 -4
- package/src/advisor/run.mjs +35 -33
- package/src/advisor.mjs +25 -6
- package/src/agent/completion.mjs +17 -11
- package/src/agent/dispatch.mjs +102 -19
- package/src/agent/helpers.mjs +36 -0
- package/src/agent/record-results.mjs +46 -10
- package/src/agent/run-stages.mjs +227 -0
- package/src/agent/setup-reminders.mjs +62 -0
- package/src/agent/setup.mjs +18 -2
- package/src/agent/spawn-child.mjs +29 -4
- package/src/agent-tools/advisor-async.mjs +456 -0
- package/src/agent-tools/advisor.mjs +110 -108
- package/src/agent-tools/async-settle.mjs +191 -0
- package/src/agent-tools/consult.mjs +121 -102
- package/src/agent-tools/design-token.mjs +104 -0
- package/src/agent-tools/eng.mjs +24 -29
- package/src/agent-tools/escalate-async.mjs +286 -0
- package/src/agent-tools/read-history.mjs +155 -31
- package/src/agent-tools/recent-changes.mjs +2 -1
- package/src/agent-tools/settings.mjs +7 -17
- package/src/agent-tools/subagent-actions.mjs +168 -130
- package/src/agent-tools/subagent-async.mjs +129 -174
- package/src/agent-tools/subagent-panel.mjs +153 -0
- package/src/agent-tools/subagent-run.mjs +202 -0
- package/src/agent-tools/subagent-scheduler.mjs +45 -21
- package/src/agent-tools/subagent-spawn.mjs +406 -0
- package/src/agent-tools/subagent.mjs +107 -555
- package/src/agent-tools/verify.mjs +118 -270
- package/src/agent.mjs +57 -190
- package/src/cli/distill-command.mjs +10 -4
- package/src/cli/make-agent.mjs +3 -1
- package/src/cli/memory-command.mjs +2 -1
- package/src/cli/permission.mjs +2 -2
- package/src/cli/setup-wizard.mjs +17 -12
- package/src/config.mjs +56 -8
- package/src/context.mjs +5 -147
- package/src/crash-reports.mjs +123 -0
- package/src/distill.mjs +11 -11
- package/src/explore-distill.mjs +155 -0
- package/src/memory/code-sync.mjs +2 -1
- package/src/memory/core.mjs +6 -193
- package/src/memory/delete.mjs +234 -0
- package/src/memory/docs.mjs +58 -48
- package/src/memory.mjs +3 -1
- package/src/peer-domains.mjs +265 -0
- package/src/peer-instances.mjs +231 -0
- package/src/prompt-overlays.mjs +25 -0
- package/src/prompts/advisor-design.md +9 -76
- package/src/prompts/advisor-round1.md +9 -68
- package/src/prompts/advisor-round2.md +7 -54
- package/src/prompts/advisor-round3.md +7 -54
- package/src/prompts/coder.md +7 -50
- package/src/prompts/consult-base.md +4 -24
- package/src/prompts/discipline.md +26 -44
- package/src/prompts/eng-coder.md +7 -32
- package/src/prompts/engineering-sub.md +3 -23
- package/src/prompts/engineering.md +53 -306
- package/src/prompts/explore.md +3 -12
- package/src/prompts/main.md +10 -32
- package/src/prompts/methodology-template.md +28 -48
- package/src/prompts/plan.md +2 -9
- package/src/prompts/system.md +16 -35
- package/src/provider/core.mjs +6 -67
- package/src/provider/errors.mjs +76 -0
- package/src/provider/retry.mjs +8 -45
- package/src/session-gc.mjs +214 -0
- package/src/session-guard.mjs +47 -0
- package/src/session-rename.mjs +38 -0
- package/src/session-slots.mjs +181 -58
- package/src/session.mjs +48 -89
- package/src/token-ttl.mjs +273 -0
- package/src/tools/checklist-sync.mjs +181 -0
- package/src/tools/checklist.mjs +52 -39
- package/src/tools/edit-batch.mjs +109 -10
- package/src/tools/edit-diff.mjs +110 -27
- package/src/tools/edit.md +17 -12
- package/src/tools/execute.mjs +31 -4
- package/src/tools/file.mjs +11 -6
- package/src/tools/git.mjs +14 -6
- package/src/tools/glob-dialect.mjs +130 -0
- package/src/tools/glob.md +3 -3
- package/src/tools/grep.md +1 -1
- package/src/tools/index.mjs +5 -6
- package/src/tools/ops.mjs +175 -3
- package/src/tools/patch.mjs +3 -3
- package/src/tools/question.md +3 -0
- package/src/tools/read.md +0 -1
- package/src/tools/shared.mjs +14 -13
- package/src/tools/system.mjs +44 -9
- package/src/tools/wait_for.md +22 -0
- package/src/tui/agent-turn.mjs +17 -228
- package/src/tui/cmd-config.mjs +48 -7
- package/src/tui/cmd-eng.mjs +20 -16
- package/src/tui/cmd-mcp.mjs +8 -2
- package/src/tui/cmd-new.mjs +3 -2
- package/src/tui/cmd-session.mjs +19 -4
- package/src/tui/cmd-think.mjs +10 -10
- package/src/tui/cmd-upgrade.mjs +19 -4
- package/src/tui/config-helpers.mjs +28 -16
- package/src/tui/distill-cmd.mjs +1 -1
- package/src/tui/index.mjs +3 -2
- package/src/tui/interaction.mjs +3 -3
- package/src/tui/mouse.mjs +7 -1
- package/src/tui/pickers.mjs +40 -22
- package/src/tui/render-segments.mjs +27 -10
- package/src/tui/startup.mjs +4 -0
- package/src/tui/subagent-blocks.mjs +95 -263
- package/src/tui/subagent-children.mjs +176 -0
- package/src/tui/subagent-freeze.mjs +172 -0
- package/src/tui/subagent-panel.mjs +61 -23
- package/src/tui/suspension-drive.mjs +351 -0
- package/src/tui/tool-args.mjs +3 -3
- package/src/tui/tool-display.mjs +142 -0
- package/src/tui/tool-events.mjs +37 -173
- package/src/tui/tui-lifecycle.mjs +29 -0
- package/src/tui/update-notice.mjs +4 -0
- package/src/tui/wizard.mjs +12 -6
- package/src/tools/pdf-parse-text.mjs +0 -497
- package/src/tools/pdf-parse-xref.mjs +0 -499
- package/src/tools/pdf.mjs +0 -155
- 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 {
|
|
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
|
-
|
|
18
|
-
import {
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
171
|
-
//
|
|
172
|
-
|
|
173
|
-
//
|
|
174
|
-
//
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
//
|
|
251
|
-
|
|
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
|
|
331
|
-
//
|
|
332
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
//
|
|
461
|
-
//
|
|
462
|
-
|
|
463
|
-
|
|
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] [--
|
|
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] [--
|
|
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.
|
|
73
|
+
if (flags.layer) c.layer = flags.layer
|
|
68
74
|
console.log(`\n--- candidate ---`)
|
|
69
|
-
console.log(`[${c.type}] ${c.title} (
|
|
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 类知识通常建议手动撰写;确认提取吗?)")
|
package/src/cli/make-agent.mjs
CHANGED
|
@@ -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
|
|
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);
|
package/src/cli/permission.mjs
CHANGED
|
@@ -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}
|
|
27
|
-
if (action === "clear") return `clear
|
|
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)
|
package/src/cli/setup-wizard.mjs
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import { existsSync, readFileSync } from "node:fs"
|
|
2
1
|
import { createInterface } from "node:readline"
|
|
3
|
-
import { configPath,
|
|
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
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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
|
-
*
|
|
372
|
-
*
|
|
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
|
|
375
|
-
|
|
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(
|
|
378
|
-
try { chmodSync(
|
|
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
|
}
|