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
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* escalate-async.mjs — async 飞刀 runner (AGENT-LOOP.md §25 D-R17b — R17, 2026-09-06).
|
|
3
|
+
*
|
|
4
|
+
* The subagent action:"escalate" is DEFAULT-async at depth 0 (decision ③: the
|
|
5
|
+
* depth-0-only escalate flips to async like every other background family;
|
|
6
|
+
* `async: false` keeps the legacy synchronous flight). An async escalate runs in
|
|
7
|
+
* the SHARED "other" pool domain (decision Q2 🅰 — the entry lives in
|
|
8
|
+
* `_asyncSubagents` with role "escalate"/_pool "other", so it shares the 4-slot
|
|
9
|
+
* other domain with explore/plan/coder spawns — capacity, queueing, refill,
|
|
10
|
+
* status and cancel all come from the generic machinery):
|
|
11
|
+
* - launch → ack {id, role:"escalate", status:"running"|"queued"[, position]};
|
|
12
|
+
* the turn ends naturally, the session suspends (poolLive counts the entry).
|
|
13
|
+
* - settle THREE-WAY classification (review #4): done → merge-all mutations back
|
|
14
|
+
* into the parent's bookkeeping + overlap warning appended to the report
|
|
15
|
+
* (report-level, not a gate — round2 #4); error (child failure / turn cap) →
|
|
16
|
+
* partial mutations merged ONLY when the parent did not touch overlapping
|
|
17
|
+
* files since launch (overlap → no merge + differences listed in the report);
|
|
18
|
+
* cancelled → never reaches the digest stream (D-M6 — no merge, stopped
|
|
19
|
+
* reminder).
|
|
20
|
+
* - the classified report lands in `_pendingAsyncResults`(pending 单容器 +role——
|
|
21
|
+
* ASYNC-RESULT-CONTAINER.md D2——挂起期 settle 移交)when the settle happens in a
|
|
22
|
+
* suspension, or stays pooled for the turn-end collection otherwise;
|
|
23
|
+
* injectAsyncResult's escalate
|
|
24
|
+
* role branch delivers the digest ("报告已 merge——可继续处置" — the action
|
|
25
|
+
* domain still follows the consuming turn's tier — no family exception).
|
|
26
|
+
*/
|
|
27
|
+
import { relative, isAbsolute } from "node:path"
|
|
28
|
+
import { runAgent, createAgent, CODER_OVERLAY, DEFAULT_SUBAGENT_TURNS } from "../agent.mjs"
|
|
29
|
+
import { runWithContinue, TURN_CAP_MARK, wrapChildCallbacks } from "../agent/spawn-child.mjs"
|
|
30
|
+
import { logEvent } from "../log.mjs"
|
|
31
|
+
import {
|
|
32
|
+
mergeChildMutations, runningPoolCount, poolDomainOf, poolLimitsFor, ASYNC_POOL_LIMITS,
|
|
33
|
+
} from "./subagent-async.mjs"
|
|
34
|
+
import { refreshQueuedTokens } from "./subagent-scheduler.mjs"
|
|
35
|
+
import { mutationSeqOf } from "./advisor-async.mjs"
|
|
36
|
+
// ASYNC-RESULT-CONTAINER.md D3/D6:settle 公共收尾单点 + child signal 构建单点
|
|
37
|
+
import { buildChildSignal, settleAsyncEntry } from "./async-settle.mjs"
|
|
38
|
+
|
|
39
|
+
/** Parent-side mutations (absolute paths) committed AFTER the escalate launch —
|
|
40
|
+
* the overlap scan feeds the settle classification (review #4/round2 #4: the
|
|
41
|
+
* parent may have edited files while the flight ran). */
|
|
42
|
+
function parentMutationsSince(parent, launchSeq) {
|
|
43
|
+
const out = []
|
|
44
|
+
for (const m of parent?._mutLog ?? []) {
|
|
45
|
+
if (m.seq <= (launchSeq ?? -1)) continue
|
|
46
|
+
for (const p of m.paths ?? []) if (!out.includes(p)) out.push(p)
|
|
47
|
+
}
|
|
48
|
+
return out
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Files BOTH sides touched since launch (case-insensitive key on win32 —
|
|
52
|
+
* filesOverlap precedent). */
|
|
53
|
+
function overlapPaths(parent, launchSeq, childTouched) {
|
|
54
|
+
const since = parentMutationsSince(parent, launchSeq)
|
|
55
|
+
if (since.length === 0 || !childTouched?.length) return []
|
|
56
|
+
const key = (p) => (process.platform === "win32" ? String(p).toLowerCase() : String(p))
|
|
57
|
+
const sinceKeys = new Set(since.map(key))
|
|
58
|
+
const hits = childTouched.filter((p) => sinceKeys.has(key(p)))
|
|
59
|
+
// relative display form (touchedFilesNote precedent)
|
|
60
|
+
const cwd = parent?.cwd ?? process.cwd()
|
|
61
|
+
return hits.map((p) => {
|
|
62
|
+
const r = relative(cwd, p)
|
|
63
|
+
return r && !r.startsWith("..") && !isAbsolute(r) ? r : p
|
|
64
|
+
})
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Escalate-side touched files, relative display (or a placeholder). */
|
|
68
|
+
function childTouchedDisplay(child, cwd) {
|
|
69
|
+
const touched = child?._touchedFiles ?? []
|
|
70
|
+
if (touched.length === 0) return "(none recorded)"
|
|
71
|
+
const shown = touched.map((f) => {
|
|
72
|
+
const r = relative(cwd ?? process.cwd(), f)
|
|
73
|
+
return r && !r.startsWith("..") && !isAbsolute(r) ? r : f
|
|
74
|
+
})
|
|
75
|
+
return shown.join(", ")
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Relative touched-file list (mirror of the sync path's note). */
|
|
79
|
+
function touchedFilesNote(child, cwd) {
|
|
80
|
+
const touched = child?._touchedFiles ?? []
|
|
81
|
+
if (touched.length === 0) return ""
|
|
82
|
+
const shown = touched.map((f) => {
|
|
83
|
+
const r = relative(cwd ?? process.cwd(), f)
|
|
84
|
+
return r && !r.startsWith("..") && !isAbsolute(r) ? r : f
|
|
85
|
+
})
|
|
86
|
+
return `\nTouched files: ${shown.join(", ")}`
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Async escalate settle — three-way classification (§25 D-R17b review #4):
|
|
91
|
+
* - done: merge ALL mutations (the escalation's changes are the parent's —
|
|
92
|
+
* verify/advisor guards must see them) + overlap warning into the report
|
|
93
|
+
* (report-level hint — not a gate — round2 #4);
|
|
94
|
+
* - error (child failure / turn cap): partial mutations merge ONLY without a
|
|
95
|
+
* parent-side overlap — overlap → NO merge + differences listed (the report
|
|
96
|
+
* carries both sides so the model can decide);
|
|
97
|
+
* - cancelled: nothing merges, never reaches pending (D-M6 — stopped reminder).
|
|
98
|
+
* Runs BEFORE the pending transfer / digest injection. Mutates entry.report /
|
|
99
|
+
* entry.error into the FINAL digest body (the family wording rides
|
|
100
|
+
* injectAsyncResult's escalate branch).
|
|
101
|
+
*/
|
|
102
|
+
export function classifyEscalateSettle(parent, entry) {
|
|
103
|
+
if (entry.cancelled) return { cancelled: true }
|
|
104
|
+
const child = entry.childAgent
|
|
105
|
+
const touched = child?._touchedFiles ?? []
|
|
106
|
+
const overlap = overlapPaths(parent, entry.launchSeq, touched)
|
|
107
|
+
const hasError = entry.error != null
|
|
108
|
+
const raw = hasError ? entry.error : entry.report
|
|
109
|
+
if (hasError) {
|
|
110
|
+
// error branch (child failure / turn cap — review #4): partial mutations
|
|
111
|
+
// merge ONLY when the parent did not touch overlapping files since launch.
|
|
112
|
+
// childAgent may be null when the flight died before child creation (advisor
|
|
113
|
+
// 复评 🟡2——防御:无 child 即无 partial mutations——不 merge 不崩)。
|
|
114
|
+
const merged = child != null && overlap.length === 0 && mergeChildMutations(parent, child)
|
|
115
|
+
let decision = ""
|
|
116
|
+
if (merged) {
|
|
117
|
+
decision = `\nPartial changes merged into the parent's bookkeeping (no parent-side overlap since launch).`
|
|
118
|
+
} else if (overlap.length > 0) {
|
|
119
|
+
decision = `\nPartial changes NOT merged — the parent changed overlapping files while this escalate ran: ${overlap.join(", ")}. Escalate-side changes: ${childTouchedDisplay(child, parent?.cwd)}. Review the conflict and decide what to keep (report-level — not a gate; AGENT-LOOP.md §25 D-R17b).`
|
|
120
|
+
}
|
|
121
|
+
// (nothing to merge + no overlap → the plain error report stands alone)
|
|
122
|
+
entry.error = `${raw}${decision}`
|
|
123
|
+
entry.report = null
|
|
124
|
+
return { cancelled: false, merged, overlap }
|
|
125
|
+
}
|
|
126
|
+
// done — merge-all + overlap warning into the report (report-level — not a gate)
|
|
127
|
+
const merged = mergeChildMutations(parent, child)
|
|
128
|
+
const overlapNote = overlap.length > 0
|
|
129
|
+
? `\n⚠ Overlapping writes: the parent changed ${overlap.join(", ")} while this escalate ran — mutations merged all the same; review those files before building on the report (report-level warning — not a gate; AGENT-LOOP.md §25 D-R17b round2 #4).`
|
|
130
|
+
: ""
|
|
131
|
+
entry.report = `${raw}${overlapNote}`
|
|
132
|
+
return { cancelled: false, merged, overlap }
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Launch the async escalate (preflights already passed in executeEscalateAction):
|
|
137
|
+
* pool admission into the shared OTHER domain → entry in `_asyncSubagents`
|
|
138
|
+
* (generic queue/refill/status/cancel machinery) → ack. The flight starts on
|
|
139
|
+
* entry.start() (immediately, or later via maybeRefillAsync when a slot frees).
|
|
140
|
+
*/
|
|
141
|
+
export function launchEscalateAsync(parent, ctx, launch) {
|
|
142
|
+
const { task, provider, tag, effortNote } = launch
|
|
143
|
+
parent._asyncSubagents ??= new Map()
|
|
144
|
+
parent._asyncQueue ??= []
|
|
145
|
+
// Async id allocation (AGENT-LOOP.md §15 D-A1 precedent): reserve the relay
|
|
146
|
+
// counter at launch — the returned id stays stable while the entry sits queued.
|
|
147
|
+
// The [model] token (TUI block creation) is DEFERRED to actual start so queued
|
|
148
|
+
// flights don't paint an empty panel block (subagent-parity).
|
|
149
|
+
parent._subAgentCounter = (parent._subAgentCounter ?? 0) + 1
|
|
150
|
+
const relayPrefix = `escalate#${parent._subAgentCounter}/`
|
|
151
|
+
const id = parent._subAgentCounter
|
|
152
|
+
const entry = {
|
|
153
|
+
id, role: "escalate", relayPrefix,
|
|
154
|
+
_pool: poolDomainOf("escalate"), // other — shares the domain with explore/plan/coder (§24 D-24a)
|
|
155
|
+
status: "queued",
|
|
156
|
+
position: undefined,
|
|
157
|
+
report: null, error: null, done: false, cancelled: false,
|
|
158
|
+
promise: null, _settle: null, _settleSeq: 0,
|
|
159
|
+
model: provider.model ?? null,
|
|
160
|
+
startedAt: null,
|
|
161
|
+
turn: 0, maxTurns: 0,
|
|
162
|
+
controller: null,
|
|
163
|
+
_files: undefined, _dependsOn: undefined,
|
|
164
|
+
childAgent: null,
|
|
165
|
+
tag, effortNote, launchSeq: mutationSeqOf(parent),
|
|
166
|
+
}
|
|
167
|
+
const limits = poolLimitsFor(parent)
|
|
168
|
+
entry.status = runningPoolCount(parent, entry._pool) >= (limits[entry._pool] ?? ASYNC_POOL_LIMITS[entry._pool])
|
|
169
|
+
? "queued" : "running"
|
|
170
|
+
entry.promise = new Promise((res) => { entry._settle = res })
|
|
171
|
+
const ctrl = new AbortController()
|
|
172
|
+
entry.controller = ctrl
|
|
173
|
+
// D6 buildChildSignal 单点(ASYNC-RESULT-CONTAINER.md——D5 同款:_sessionSignal 兜底)。
|
|
174
|
+
const baseSignal = buildChildSignal(parent, ctx)
|
|
175
|
+
if (baseSignal) {
|
|
176
|
+
if (baseSignal.aborted) ctrl.abort()
|
|
177
|
+
else baseSignal.addEventListener("abort", () => ctrl.abort(), { once: true })
|
|
178
|
+
}
|
|
179
|
+
// Turn mirror (⟦ev⟧turn from the child runAgent → entry.turn/maxTurns — status parity).
|
|
180
|
+
const flight = async () => {
|
|
181
|
+
entry.status = "running"
|
|
182
|
+
entry.position = undefined
|
|
183
|
+
entry.startedAt = Date.now()
|
|
184
|
+
ctx.callbacks?.onToken?.(relayPrefix + "⟦ev⟧async\x1e")
|
|
185
|
+
ctx.callbacks?.onToken?.(relayPrefix + "[model]" + (provider.model ?? ""))
|
|
186
|
+
const child = createAgent({
|
|
187
|
+
provider,
|
|
188
|
+
tools: parent.tools,
|
|
189
|
+
config: parent.config,
|
|
190
|
+
cwd: parent.cwd,
|
|
191
|
+
memory: parent.memory,
|
|
192
|
+
overlay: CODER_OVERLAY,
|
|
193
|
+
role: "coder",
|
|
194
|
+
})
|
|
195
|
+
entry.childAgent = child // settle 分类/status touched 摘要绑定(start 时刻)
|
|
196
|
+
child._logId = relayPrefix.slice(0, -1)
|
|
197
|
+
logEvent("child:spawn", { role: "escalate", id: child._logId, kind: "async", status: "running", ms: 0 })
|
|
198
|
+
const childCallbacks = wrapChildCallbacks(relayPrefix, ctx.callbacks ?? {})
|
|
199
|
+
const relayOnToken = childCallbacks.onToken
|
|
200
|
+
if (relayOnToken) {
|
|
201
|
+
childCallbacks.onToken = (t) => {
|
|
202
|
+
const ev = String(t).match(/^⟦ev⟧turn\x1e(\d+)\x1e(\d+)\x1e/)
|
|
203
|
+
if (ev) {
|
|
204
|
+
entry.turn = Number(ev[1]) || 0
|
|
205
|
+
entry.maxTurns = Number(ev[2]) || 0
|
|
206
|
+
}
|
|
207
|
+
return relayOnToken(t)
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
const runner = ctx.runAgent ?? runAgent
|
|
211
|
+
const runOpts = {
|
|
212
|
+
depth: 1,
|
|
213
|
+
maxTurns: parent.config?.agent?.subagentTurns ?? DEFAULT_SUBAGENT_TURNS,
|
|
214
|
+
signal: entry.controller.signal,
|
|
215
|
+
}
|
|
216
|
+
// 权限按 async 子代理同款装配:AUTO 直放行;手动档经父 _permQueue(并行子代理
|
|
217
|
+
// 审批不叠弹窗)——背景飞行撞门时无 handler → denied 不悬挂(D-S7 同规则)。
|
|
218
|
+
const childPermission = parent.autoApprove
|
|
219
|
+
? async () => true
|
|
220
|
+
: async (name, toolArgs) => {
|
|
221
|
+
if (!ctx.onPermissionRequest) return false
|
|
222
|
+
const ask = () => ctx.onPermissionRequest(`escalate/${name}`, toolArgs)
|
|
223
|
+
parent._permQueue = (parent._permQueue ?? Promise.resolve()).then(ask, ask)
|
|
224
|
+
return parent._permQueue
|
|
225
|
+
}
|
|
226
|
+
const report = await runWithContinue(
|
|
227
|
+
(childAgent, input, cbs, opts) => runner(childAgent, input, cbs, opts),
|
|
228
|
+
child, task,
|
|
229
|
+
{ ...childCallbacks, onPermissionRequest: childPermission },
|
|
230
|
+
runOpts,
|
|
231
|
+
{
|
|
232
|
+
// 后台飞行不弹 continue 面板(D-A3 §15 例外同款):AUTO && engineering 自动
|
|
233
|
+
// resume——escalate 只在 normal 模式可用(engineering 拒)——恒自动拒 → partial。
|
|
234
|
+
askContinue: () => Promise.resolve(Boolean(parent.config?.agent?.engineering && parent.autoApprove)),
|
|
235
|
+
onDeclined: (e, output) => `escalate (${tag})${entry.effortNote} ${TURN_CAP_MARK} (${e.turn} turns) — work may be partial; review recent_changes before deciding next steps.\nPartial output: ${output.slice(0, 2000)}`,
|
|
236
|
+
},
|
|
237
|
+
)
|
|
238
|
+
// done: compose the post-op body (the settle classification appends the
|
|
239
|
+
// overlap warning / merge notes afterwards)
|
|
240
|
+
return `escalate (${tag})${entry.effortNote} post-op report:\n${report || (child._capturedOutput ?? "").slice(0, 4000)}${touchedFilesNote(child, parent.cwd)}`
|
|
241
|
+
}
|
|
242
|
+
entry.start = () => {
|
|
243
|
+
flight()
|
|
244
|
+
.then((report) => {
|
|
245
|
+
// Turn-cap partial (runWithContinue auto-declined) classifies as the ERROR
|
|
246
|
+
// branch (design: 撞 turn cap = error — partial-merge decision applies):
|
|
247
|
+
// move it to entry.error so the classification + error digest wording fire.
|
|
248
|
+
if (String(report).includes(TURN_CAP_MARK)) entry.error = report
|
|
249
|
+
else entry.report = report
|
|
250
|
+
})
|
|
251
|
+
.catch((e) => {
|
|
252
|
+
// 运行失败/中止:错误文本落 entry.error(子代理同款——cancel 分支忽略它;
|
|
253
|
+
// Ctrl+I 中止的残条目由收尾消化带错误文本——不落空 "(no report)" digest)。
|
|
254
|
+
const child = entry.childAgent
|
|
255
|
+
entry.error = `escalate (${tag}) error: ${e?.message ?? String(e)}\nPartial output: ${(child?._capturedOutput ?? "").slice(0, 2000)}`
|
|
256
|
+
})
|
|
257
|
+
.finally(() => {
|
|
258
|
+
// settle 公共收尾单点(ASYNC-RESULT-CONTAINER.md D3——settleAsyncEntry):日志三连
|
|
259
|
+
// /cancelled 分支(出池+墓碑+⟦ev⟧stopped+提醒)/挂起分流(pending 单容器+出池——
|
|
260
|
+
// 统一守卫 !parentAborted——D4)/公共尾部(settleSeq/_settle/唤醒 waiter + 腾槽补位
|
|
261
|
+
// ——helper 尾部恒补)统一走共享 helper。族特有 hook = 三分类 merge 决策
|
|
262
|
+
// (classifyEscalateSettle——done/error 分类;cancelled 不经此——helper cancelled
|
|
263
|
+
// 分支先行)。
|
|
264
|
+
settleAsyncEntry(parent, entry, {
|
|
265
|
+
pool: parent._asyncSubagents,
|
|
266
|
+
ctx,
|
|
267
|
+
onAccounting: () => {
|
|
268
|
+
// Three-way settle classification (done/error/cancelled — review #4):
|
|
269
|
+
// done → merge-all + 重叠警告;error → 无父侧重叠才 partial merge;
|
|
270
|
+
// cancelled 不经此(helper cancelled 分支先行)。
|
|
271
|
+
void classifyEscalateSettle(parent, entry)
|
|
272
|
+
},
|
|
273
|
+
})
|
|
274
|
+
})
|
|
275
|
+
}
|
|
276
|
+
parent._asyncSubagents.set(String(id), entry)
|
|
277
|
+
logEvent("child:spawn", { role: "escalate", id: `escalate#${id}`, kind: "async", status: entry.status, ms: 0 })
|
|
278
|
+
if (entry.status === "queued") {
|
|
279
|
+
parent._asyncQueue.push(entry)
|
|
280
|
+
entry.position = parent._asyncQueue.length
|
|
281
|
+
refreshQueuedTokens(parent, ctx.callbacks?.onToken)
|
|
282
|
+
return JSON.stringify({ id: String(id), role: "escalate", status: "queued", position: entry.position })
|
|
283
|
+
}
|
|
284
|
+
entry.start()
|
|
285
|
+
return JSON.stringify({ id: String(id), role: "escalate", status: "running" })
|
|
286
|
+
}
|
|
@@ -1,9 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* agent-tools/read-history.mjs — read_history tool (SESSION.md §9).
|
|
2
|
+
* agent-tools/read-history.mjs — read_history tool (SESSION.md §9 + §13 R19 cross-session).
|
|
3
3
|
*
|
|
4
|
-
* Query THIS session
|
|
5
|
-
* (
|
|
6
|
-
*
|
|
4
|
+
* Query message history — THIS session by default, any session on disk with `path`
|
|
5
|
+
* (SESSION.md §13 R19): an explicit session file path deep-queries that file's
|
|
6
|
+
* history line; "cwd:<dir>" discovers the sessions stored for that directory.
|
|
7
|
+
*
|
|
8
|
+
* Default (no path) — THIS session's full human-readable record (agent._fullHistory
|
|
9
|
+
* — NEVER compacted, audit-complete). Use to recall what was said or done earlier:
|
|
10
|
+
* design decisions, tool-call timing, past rulings.
|
|
7
11
|
*
|
|
8
12
|
* Filters AND together: role / keyword (message text) / tool (tool messages by
|
|
9
13
|
* name + assistant messages that declared the call) / since-until (epoch ms
|
|
@@ -17,17 +21,38 @@
|
|
|
17
21
|
* session file. assistant tool_calls are summarized to a name list (arguments
|
|
18
22
|
* never expanded).
|
|
19
23
|
*
|
|
24
|
+
* Cross-session (SESSION.md §13 D-R19a): path = a session file path (absolute, or
|
|
25
|
+
* relative to the project cwd) → read that file's history line and apply the SAME
|
|
26
|
+
* filter surface; path = "cwd:<dir>" → list every slot stored for that directory
|
|
27
|
+
* (slot number + full file path + title/message count/updatedAt — no dead-slot
|
|
28
|
+
* filtering, v1 decision). Single-file retrieval is guarded by a line-scan cap
|
|
29
|
+
* (READ_HISTORY_SCAN_MAX) — an oversized file is refused before it is read whole.
|
|
30
|
+
*
|
|
20
31
|
* readonly: true — planMode pass / no permission ask. Registered depth-0 only:
|
|
21
32
|
* subagents get their own throwaway history, so querying "the session" from a
|
|
22
|
-
* child would be semantically confusing (SESSION.md §9.5 refinement 1).
|
|
33
|
+
* child would be semantically confusing (SESSION.md §9.5 refinement 1 + §13 T-R19.4).
|
|
23
34
|
* Mirrored 1:1 in thincoder-vscode/src/agent-tools/read-history.mjs.
|
|
24
35
|
*/
|
|
25
36
|
|
|
37
|
+
import { openSync, readSync, closeSync, readFileSync, existsSync, statSync } from "node:fs"
|
|
38
|
+
import { isAbsolute, resolve } from "node:path"
|
|
39
|
+
import { listSlots, slotPath } from "../session-slots.mjs"
|
|
40
|
+
|
|
26
41
|
const DEFAULT_LIMIT = 50
|
|
27
42
|
const MAX_LIMIT = 200
|
|
28
43
|
const CONTENT_CAP = 500
|
|
29
44
|
const VALID_ROLES = new Set(["user", "assistant", "tool"])
|
|
30
45
|
|
|
46
|
+
/** 单槽检索行扫护栏(SESSION.md §13 D-R19a——评审 #3 定稿:超限不再读全文,返回定稿错误文案)。 */
|
|
47
|
+
export const READ_HISTORY_SCAN_MAX = 200_000
|
|
48
|
+
|
|
49
|
+
/** 超限错误文案(SESSION.md §13——逐字定稿——T-R19.7 断言)。 */
|
|
50
|
+
const TOO_LARGE_ERROR = JSON.stringify({ error: "session too large — refine keyword or since/until" })
|
|
51
|
+
|
|
52
|
+
/** 检索/记忆族消歧总纲(SESSION.md §13 D-R19b——逐字定稿——read_history 描述尾段——T-R19.5 锚)。 */
|
|
53
|
+
const SEARCH_FAMILY_GUIDE =
|
|
54
|
+
"检索/记忆族选哪个:查**本会话**说过/裁定过 → read_history(默认);查**别的会话/项目**旧对话 → read_history 带 path/cwd 参数;查**本 run 改过哪些文件** → recent_changes;查**跨会话已存知识/约定**(memory)→ memory search;查**项目设计文档** → doc_search;查**代码实现** → code_search;查 git 历史快照 → checkpoint cat/versions。read_history 只查会话消息——文件级改动用 recent_changes——知识与约定用 memory——互相不替代。"
|
|
55
|
+
|
|
31
56
|
/** Message text for keyword matching + output: strings pass through; multimodal content arrays → text parts joined (never crashes, empty parts skipped, images ignored). */
|
|
32
57
|
function messageText(m) {
|
|
33
58
|
if (typeof m?.content === "string") return m.content
|
|
@@ -80,17 +105,125 @@ function toEntry(m) {
|
|
|
80
105
|
return entry
|
|
81
106
|
}
|
|
82
107
|
|
|
108
|
+
/** AND-filter one history message (role/keyword/tool/since-until) — shared by the in-memory
|
|
109
|
+
* default and the cross-session file query (SESSION.md §13 D-R19a: 同 filter 面应用). */
|
|
110
|
+
function matches(m, { role, kwRe, tool, since, until }) {
|
|
111
|
+
if (!m || typeof m !== "object") return false
|
|
112
|
+
if (role !== undefined && m.role !== role) return false
|
|
113
|
+
if (kwRe) {
|
|
114
|
+
const text = messageText(m)
|
|
115
|
+
if (!kwRe.test(text)) return false
|
|
116
|
+
}
|
|
117
|
+
if (tool) {
|
|
118
|
+
const byName = m.role === "tool" && m.name === tool
|
|
119
|
+
const byDeclaration = m.role === "assistant" && Array.isArray(m.tool_calls) && m.tool_calls.some((tc) => toolCallName(tc) === tool)
|
|
120
|
+
if (!byName && !byDeclaration) return false
|
|
121
|
+
}
|
|
122
|
+
const ts = m.ts
|
|
123
|
+
if (since !== null || until !== null) {
|
|
124
|
+
if (typeof ts !== "number") return false // no ts → no time-window match
|
|
125
|
+
if (since !== null && ts < since) return false
|
|
126
|
+
if (until !== null && ts > until) return false
|
|
127
|
+
}
|
|
128
|
+
return true
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Direction picks the END of the matched set; output stays chronological either way. */
|
|
132
|
+
function formatMatches(matched, direction, limit) {
|
|
133
|
+
const windowed = direction === "oldest" ? matched.slice(0, limit) : matched.slice(-limit)
|
|
134
|
+
return JSON.stringify(windowed.map(toEntry))
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Line-scan guard: stream-count physical newlines, bailing the moment the cap is crossed —
|
|
138
|
+
* an oversized file is refused BEFORE it is read whole ("不再读全文"——SESSION.md §13 D-R19a). */
|
|
139
|
+
function exceedsScanMax(file) {
|
|
140
|
+
const CHUNK = 64 * 1024
|
|
141
|
+
let fd = null
|
|
142
|
+
try {
|
|
143
|
+
fd = openSync(file, "r")
|
|
144
|
+
const buf = Buffer.alloc(CHUNK)
|
|
145
|
+
let newlines = 0
|
|
146
|
+
for (;;) {
|
|
147
|
+
const n = readSync(fd, buf, 0, CHUNK, null)
|
|
148
|
+
if (n <= 0) break
|
|
149
|
+
for (let i = 0; i < n; i++) {
|
|
150
|
+
if (buf[i] === 0x0a) newlines++
|
|
151
|
+
}
|
|
152
|
+
if (newlines > READ_HISTORY_SCAN_MAX) return true
|
|
153
|
+
}
|
|
154
|
+
return false
|
|
155
|
+
} catch {
|
|
156
|
+
return false // 行扫失败 → 交由后续读取/解析路径给出真实错误
|
|
157
|
+
} finally {
|
|
158
|
+
if (fd !== null) {
|
|
159
|
+
try { closeSync(fd) } catch { /* ignore */ }
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** Cross-session deep query: one session file, same filter surface (§13 D-R19a). */
|
|
165
|
+
function querySessionFile(pathArg, { role, kwRe, tool, since, until, direction, limit }, baseCwd) {
|
|
166
|
+
const file = isAbsolute(pathArg) ? pathArg : resolve(baseCwd ?? process.cwd(), pathArg)
|
|
167
|
+
if (!existsSync(file)) return `Error: session file not found: ${file}`
|
|
168
|
+
if (exceedsScanMax(file)) return TOO_LARGE_ERROR
|
|
169
|
+
let text
|
|
170
|
+
try {
|
|
171
|
+
text = readFileSync(file, "utf8")
|
|
172
|
+
} catch (e) {
|
|
173
|
+
return `Error: failed to read session file ${file}: ${e.message}`
|
|
174
|
+
}
|
|
175
|
+
let data
|
|
176
|
+
try {
|
|
177
|
+
data = JSON.parse(text)
|
|
178
|
+
} catch (e) {
|
|
179
|
+
return `Error: ${file} is not a valid session file (corrupt JSON: ${e.message})`
|
|
180
|
+
}
|
|
181
|
+
if (!data || typeof data !== "object" || !Array.isArray(data.history)) {
|
|
182
|
+
return `Error: ${file} is not a valid session file (no history array)`
|
|
183
|
+
}
|
|
184
|
+
const matched = data.history.filter((m) => matches(m, { role, kwRe, tool, since, until }))
|
|
185
|
+
return formatMatches(matched, direction, limit)
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/** Discovery surface (path = "cwd:<dir>"): list every slot stored for that directory, one line
|
|
189
|
+
* per slot — slot number + FULL session file path + title/message count/updatedAt(§13 D-R19a
|
|
190
|
+
* ——评审 #2:摘要必须含寻址字段——模型第二步深查 = 复制行内文件路径重调 path=)。 */
|
|
191
|
+
function discoverCwd(raw, baseCwd) {
|
|
192
|
+
const dir = resolve(baseCwd ?? process.cwd(), raw)
|
|
193
|
+
let st = null
|
|
194
|
+
try {
|
|
195
|
+
st = statSync(dir)
|
|
196
|
+
} catch { /* fallthrough to the explicit error below */ }
|
|
197
|
+
if (st === null || !st.isDirectory()) {
|
|
198
|
+
return `Error: unknown cwd "${raw}" — no session directory for this cwd (directory not found: ${dir})`
|
|
199
|
+
}
|
|
200
|
+
const slots = listSlots(dir) // 时间序(updatedAt 降序)——含 manifest 记录的全部槽(v1 不做死槽过滤)
|
|
201
|
+
if (slots.length === 0) {
|
|
202
|
+
return `(no session slots found for cwd: ${dir} — no sessions started there yet)`
|
|
203
|
+
}
|
|
204
|
+
const lines = slots.map((s) => {
|
|
205
|
+
const title = s.title ? `"${s.title}"` : "(untitled)"
|
|
206
|
+
return `slot ${s.slot}: ${slotPath(dir, s.slot)} — title: ${title}, messages: ${s.messageCount}, updatedAt: ${s.updatedAt}`
|
|
207
|
+
})
|
|
208
|
+
return `Session slots for cwd: ${dir} (newest first):\n${lines.join("\n")}`
|
|
209
|
+
}
|
|
210
|
+
|
|
83
211
|
export const readHistoryTool = {
|
|
84
212
|
name: "read_history",
|
|
85
213
|
description:
|
|
86
|
-
"Query THIS session
|
|
87
|
-
"
|
|
214
|
+
"Query message history — THIS session by default, any session on disk with `path`. " +
|
|
215
|
+
"Default (no path): THIS session's full record (never compacted, audit-complete) — recall what " +
|
|
216
|
+
"was said or done earlier: design decisions, tool-call timing, past rulings. " +
|
|
88
217
|
"Filters combine with AND: role / keyword (case-insensitive substring of message text) / " +
|
|
89
218
|
"tool (tool result messages by name AND the assistant messages that declared the call — pair with tool_call_id / ts for timing) / " +
|
|
90
219
|
"since-until (epoch ms time window; only messages with ts can match) / limit (default 50, clamped to 200) / direction (which end of the matches to take). " +
|
|
91
220
|
"Returns a JSON array in chronological order: [{ts, role, name?, tool_call_id?, content (≈500 chars, truncated marker), tool_calls (names only)}]. " +
|
|
92
221
|
"Messages without ts return ts:null. Content is truncated — the full text is in the session file. " +
|
|
93
|
-
"
|
|
222
|
+
"Cross-session (path, optional): a session file path deep-queries THAT session's history with the same filters " +
|
|
223
|
+
"(relative paths resolve against the project cwd); \"cwd:<dir>\" lists every session slot stored for that directory — " +
|
|
224
|
+
"one line per slot: slot number + full session file path + title + message count + updatedAt; copy a listed file path into path= to deep-query it. " +
|
|
225
|
+
"A session file over 200,000 lines is refused (\"session too large\") instead of being read whole.\n" +
|
|
226
|
+
SEARCH_FAMILY_GUIDE,
|
|
94
227
|
parameters: {
|
|
95
228
|
type: "object",
|
|
96
229
|
properties: {
|
|
@@ -101,11 +234,15 @@ export const readHistoryTool = {
|
|
|
101
234
|
until: { type: "integer", description: "Latest ts to match, epoch ms, INCLUSIVE. since > until yields an empty result." },
|
|
102
235
|
limit: { type: "integer", description: "Maximum messages to return (default 50; larger values are clamped to 200)." },
|
|
103
236
|
direction: { type: "string", enum: ["oldest", "newest"], description: "Take the limit window from the oldest or newest end of the matched set (default newest)." },
|
|
237
|
+
path: { type: "string", description: "Optional — query another session instead of this one: a session file path (as listed by a \"cwd:<dir>\" call) deep-queries that session; \"cwd:<dir>\" lists that directory's session slots (slot number + full file path + title + message count + updatedAt)." },
|
|
104
238
|
},
|
|
105
239
|
},
|
|
106
240
|
readonly: true,
|
|
107
241
|
execute(args, ctx) {
|
|
108
242
|
const a = args ?? {}
|
|
243
|
+
if (a.path !== undefined && (typeof a.path !== "string" || a.path.trim().length === 0)) {
|
|
244
|
+
return `Error: invalid path "${a.path}" — must be a session file path or "cwd:<dir>"`
|
|
245
|
+
}
|
|
109
246
|
const role = a.role
|
|
110
247
|
if (role !== undefined && (typeof role !== "string" || !VALID_ROLES.has(role))) {
|
|
111
248
|
return `Error: invalid role "${role}" — valid roles: user, assistant, tool`
|
|
@@ -131,30 +268,17 @@ export const readHistoryTool = {
|
|
|
131
268
|
// keyword stays a literal substring).
|
|
132
269
|
const kwRe = keyword ? new RegExp(keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i") : null
|
|
133
270
|
const tool = typeof a.tool === "string" && a.tool.length > 0 ? a.tool : null
|
|
271
|
+
const baseCwd = ctx.agent?.cwd ?? process.cwd()
|
|
272
|
+
|
|
273
|
+
const pathArg = typeof a.path === "string" && a.path.trim().length > 0 ? a.path.trim() : null
|
|
274
|
+
if (pathArg !== null) {
|
|
275
|
+
return pathArg.startsWith("cwd:")
|
|
276
|
+
? discoverCwd(pathArg.slice("cwd:".length), baseCwd)
|
|
277
|
+
: querySessionFile(pathArg, { role, kwRe, tool, since, until, direction, limit }, baseCwd)
|
|
278
|
+
}
|
|
134
279
|
|
|
135
280
|
const history = Array.isArray(ctx.agent?._fullHistory) ? ctx.agent._fullHistory : []
|
|
136
|
-
const matched = history.filter((m) => {
|
|
137
|
-
|
|
138
|
-
if (role !== undefined && m.role !== role) return false
|
|
139
|
-
if (kwRe) {
|
|
140
|
-
const text = messageText(m)
|
|
141
|
-
if (!kwRe.test(text)) return false
|
|
142
|
-
}
|
|
143
|
-
if (tool) {
|
|
144
|
-
const byName = m.role === "tool" && m.name === tool
|
|
145
|
-
const byDeclaration = m.role === "assistant" && Array.isArray(m.tool_calls) && m.tool_calls.some((tc) => toolCallName(tc) === tool)
|
|
146
|
-
if (!byName && !byDeclaration) return false
|
|
147
|
-
}
|
|
148
|
-
const ts = m.ts
|
|
149
|
-
if (since !== null || until !== null) {
|
|
150
|
-
if (typeof ts !== "number") return false // no ts → no time-window match
|
|
151
|
-
if (since !== null && ts < since) return false
|
|
152
|
-
if (until !== null && ts > until) return false
|
|
153
|
-
}
|
|
154
|
-
return true
|
|
155
|
-
})
|
|
156
|
-
// Direction picks the END of the matched set; output stays chronological either way.
|
|
157
|
-
const windowed = direction === "oldest" ? matched.slice(0, limit) : matched.slice(-limit)
|
|
158
|
-
return JSON.stringify(windowed.map(toEntry))
|
|
281
|
+
const matched = history.filter((m) => matches(m, { role, kwRe, tool, since, until }))
|
|
282
|
+
return formatMatches(matched, direction, limit)
|
|
159
283
|
},
|
|
160
284
|
}
|
|
@@ -8,7 +8,8 @@ export const recentChangesTool = {
|
|
|
8
8
|
description:
|
|
9
9
|
"Show files modified in this agent run (write/edit/insert_after/delete). " +
|
|
10
10
|
"Use when you need to remember which files you've already touched — during long multi-file tasks, " +
|
|
11
|
-
"it's easy to lose track. This is scoped to the current run, unlike git status which shows all uncommitted changes."
|
|
11
|
+
"it's easy to lose track. This is scoped to the current run, unlike git status which shows all uncommitted changes. " +
|
|
12
|
+
"For session-level history (what was said in a session), use read_history.",
|
|
12
13
|
parameters: {
|
|
13
14
|
type: "object",
|
|
14
15
|
properties: {},
|
|
@@ -7,9 +7,7 @@
|
|
|
7
7
|
* (••••(masked)——防密钥泄漏进会话历史/trace);已知键类型校验(类型表自动派生自
|
|
8
8
|
* config.mjs DEFAULTS——不手写防漂移);set 侧效走审批门(dispatch 动作级分类)。
|
|
9
9
|
*/
|
|
10
|
-
import {
|
|
11
|
-
import { join } from "node:path"
|
|
12
|
-
import { DEFAULTS, configPath } from "../config.mjs"
|
|
10
|
+
import { DEFAULTS, configPath, writeConfigAtomic } from "../config.mjs"
|
|
13
11
|
|
|
14
12
|
/** 敏感键段判定(完整点分路径的段级匹配——apiKey/api_key/api-key/token/secret/password 形态) */
|
|
15
13
|
const SENSITIVE_SEGMENT = /(^|[._-])(api[_-]?key|key|token|secret|password)($|[._-])/i
|
|
@@ -91,16 +89,6 @@ function parseValue(raw) {
|
|
|
91
89
|
export function settingsTool(opts = {}) {
|
|
92
90
|
const cfgPath = opts.configPath ?? configPath // 测试注入 tmp 文件;默认全局 configPath
|
|
93
91
|
|
|
94
|
-
async function readDisk() {
|
|
95
|
-
let text
|
|
96
|
-
try { text = readFileSync(cfgPath, "utf8") } catch { return {} } // 不存在 → 空对象(写盘最小化——默认不固化)
|
|
97
|
-
try { return JSON.parse(text) } catch { throw new Error(`settings: config file not parseable — refusing to overwrite: ${cfgPath}`) }
|
|
98
|
-
}
|
|
99
|
-
async function writeDisk(disk) {
|
|
100
|
-
mkdirSync(join(cfgPath, ".."), { recursive: true })
|
|
101
|
-
writeFileSync(cfgPath, JSON.stringify(disk, null, 2) + "\n", { encoding: "utf8", mode: 0o600 })
|
|
102
|
-
}
|
|
103
|
-
|
|
104
92
|
return {
|
|
105
93
|
name: "settings",
|
|
106
94
|
description:
|
|
@@ -150,10 +138,12 @@ export function settingsTool(opts = {}) {
|
|
|
150
138
|
throw new Error(`settings set: "${args.key}" expects ${want} — got ${got} (${JSON.stringify(args.value)})`)
|
|
151
139
|
}
|
|
152
140
|
}
|
|
153
|
-
//
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
141
|
+
// 写盘(D-F5b:磁盘真相最小化——writeConfigAtomic 磁盘新鲜读 + 只改被设键 + mtime
|
|
142
|
+
// 门控——默认不固化;冲突/畸形抛错,内存不热应用(零虚假成功))+ 热应用(内存对象)
|
|
143
|
+
const r = await writeConfigAtomic(cfgPath, (disk) => {
|
|
144
|
+
setKeyPath(disk, args.key, value)
|
|
145
|
+
})
|
|
146
|
+
if (!r.ok) throw new Error("config changed on disk concurrently — retry (settings set not applied)")
|
|
157
147
|
setKeyPath(config, args.key, value)
|
|
158
148
|
const shown = isSensitiveKey(String(args.key)) ? MASKED : value
|
|
159
149
|
return `settings set: ${args.key} = ${shown} (${Array.isArray(value) ? "array" : typeof value})${isSensitiveKey(String(args.key)) ? " — stored(值不回显)" : " — persisted + hot-applied(运行中已生效)"}`
|