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
@@ -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's message history the full human-readable record
5
- * (agent._fullHistory NEVER compacted, audit-complete). Use to recall what
6
- * was said or done earlier: design decisions, tool-call timing, past rulings.
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's message history (the full record never compacted, audit-complete). " +
87
- "Use when you need to recall what was said or done earlier: design decisions, tool-call timing, past rulings. " +
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
- "For file-level changes this run (not messages), use recent_changes.",
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
- if (!m || typeof m !== "object") return false
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 { mkdirSync, readFileSync, writeFileSync } from "node:fs"
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
- const disk = await readDisk()
155
- setKeyPath(disk, args.key, value)
156
- await writeDisk(disk)
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(运行中已生效)"}`