thincoder 0.12.61 → 0.12.62

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 (57) hide show
  1. package/CHANGELOG.md +25 -1
  2. package/bin/thincoder.mjs +12 -3
  3. package/package.json +1 -1
  4. package/src/acp.mjs +13 -5
  5. package/src/agent/run-stages.mjs +3 -1
  6. package/src/agent/spawn-child.mjs +17 -2
  7. package/src/agent-tools/async-settle.mjs +13 -0
  8. package/src/agent-tools/consult.mjs +5 -0
  9. package/src/agent-tools/escalate-async.mjs +6 -0
  10. package/src/agent-tools/read-history.mjs +18 -3
  11. package/src/agent-tools/subagent-actions.mjs +3 -0
  12. package/src/agent-tools/subagent-run.mjs +1 -1
  13. package/src/agent-tools/subagent-spawn.mjs +6 -0
  14. package/src/agent.mjs +4 -0
  15. package/src/context.mjs +12 -1
  16. package/src/crash-reports.mjs +31 -9
  17. package/src/generate-title.mjs +8 -3
  18. package/src/heap-watch.mjs +88 -0
  19. package/src/ledger.mjs +227 -0
  20. package/src/memory/code-sync.mjs +7 -5
  21. package/src/memory/core.mjs +8 -9
  22. package/src/memory/docs.mjs +7 -5
  23. package/src/memory/scan.mjs +95 -0
  24. package/src/prompts/advisor-design.md +1 -1
  25. package/src/prompts/advisor-round1.md +1 -1
  26. package/src/prompts/advisor-round2.md +1 -1
  27. package/src/prompts/advisor-round3.md +1 -1
  28. package/src/prompts/discipline-engineering.md +51 -10
  29. package/src/prompts/discipline-normal.md +10 -4
  30. package/src/prompts/persona-eng-designer.md +6 -1
  31. package/src/prompts/persona-engineering.md +1 -0
  32. package/src/session-gc.mjs +9 -2
  33. package/src/session-guard.mjs +12 -0
  34. package/src/session-segments.mjs +100 -0
  35. package/src/session-slots.mjs +3 -0
  36. package/src/session-store.mjs +441 -0
  37. package/src/session.mjs +78 -61
  38. package/src/text-budget.mjs +46 -0
  39. package/src/traces/trace-store.mjs +195 -64
  40. package/src/tui/cmd-clear.mjs +2 -0
  41. package/src/tui/cmd-new.mjs +5 -1
  42. package/src/tui/cmd-session.mjs +8 -4
  43. package/src/tui/display-budget.mjs +184 -0
  44. package/src/tui/index.mjs +31 -3
  45. package/src/tui/key-handler-search.mjs +9 -1
  46. package/src/tui/ledger-surface.mjs +69 -0
  47. package/src/tui/render-frame.mjs +7 -1
  48. package/src/tui/startup.mjs +45 -13
  49. package/src/tui/subagent-blocks.mjs +1 -0
  50. package/src/tui/subagent-children.mjs +86 -14
  51. package/src/tui/subagent-freeze.mjs +8 -2
  52. package/src/tui/suspension-drive.mjs +3 -1
  53. package/src/tui/tool-args.mjs +5 -2
  54. package/src/tui/tool-display.mjs +16 -2
  55. package/src/tui/tool-events.mjs +53 -14
  56. package/src/tui/tui-lifecycle.mjs +9 -2
  57. package/src/tui/wrapped-spawn.mjs +21 -5
package/CHANGELOG.md CHANGED
@@ -1,3 +1,27 @@
1
+ ## [0.12.62] — 2026-09-13
2
+
3
+ > 0.12.61 → 0.12.62(§4.3 连续号——发布时定号)
4
+
5
+ ### Added
6
+
7
+ - **文档锚一致性机检 V5(DOC-CODE-RECONCILE 批)**:`scripts/doc-anchors.mjs`(三类锚——用例号 / 本仓符号 / 路径——存在性判定 + 假阳类逐条排除 + 注记闭枚举 + 两态 `V5_GATE` / `--v5-gate` / `--v5-report`)· `scripts/doc-impact.mjs`(改动面反查:`--base` 必给、只读不阻断)· 常驻零值锁(`test:full` 内——再出现即红)· **清账 347 处 → 0**。
8
+ - **台账自持机检**:`check-ledger` L4 本仓可解析闸 + 指针 / D3 组计数 / 六态 / 触发 / 老化报告。
9
+ - **工程模式纪律进提示词**:跨仓派单与写域纪律 · 改动面反查(提示词两副本逐字一致)。
10
+
11
+ ### Changed
12
+
13
+ - **文档体系各仓自持**:需求档 / 设计档 / 批次档 / 台账各仓记各仓(禁跨仓写需求与跨仓指针);跨仓引用统一「名称(仓别)§N」规范形态。
14
+ - **台账归档制**:活文件只留未决四态,已决逐条移入 `TODO-archive.md`。
15
+ - **在途批一并签入(8 批)**:TUI 内存取证与上界(session store / segments / 堆遥测)· 台账可见面 · 测试纪律提示词 · 子代理审批面与活动区对位。
16
+
17
+ ### Removed
18
+
19
+ - **散文锚退役(PROSE-ANCHOR-RETIRE 批)**:退役断言「某句在场 / 缺席」类测试 71 例(保留结构机检面:引用可解析 / 组计数 / 六态取值 / 行宽 / slow 归册)。
20
+
21
+ ### Fixed
22
+
23
+ - 文档↔实装漂移清账(逐档处置留痕,禁「只删不记」)· `check-ledger` 扫描域收窄(只扫本仓活档 + 归档档)· 台账 L4 误报修正 · 批次档 / 设计档宽度与计数纪律收正。
24
+
1
25
  ## [0.12.61] — 2026-09-11
2
26
 
3
27
  > 0.12.60 → 0.12.61(§4.3 连续号——发布时定号)
@@ -291,7 +315,7 @@
291
315
 
292
316
  ### Changed
293
317
 
294
- - **系统提示词加两条约束**:①「先定正确再谈实现」——动手前先回答"正确应该是什么"(每个入口/视图/边界),再谈怎么实现,实现规模是"正确"的结果、不是决策标准;②「确认时暴露方案权衡」——确认理解时列出选的做法、为什么它对(绝不是"改动最小")、考虑过但没选的替代方案。治"最小改动"偷懒病根
318
+ - **系统提示词加两条约束**:①「先定正确再谈实现」——动手前先回答"正确应该是什么"(每个入口/视图/边界),再谈怎么实现,实现规模是"正确"的结果、不是决策标准;②「确认时暴露方案权衡」——确认理解时列出选的做法、为什么它对(判据 = 正确性优先——不是省事程度)、考虑过但没选的替代方案。治"图省事"的偷懒病根
295
319
 
296
320
  ## [0.12.47] — 2026-08-28
297
321
 
package/bin/thincoder.mjs CHANGED
@@ -41,6 +41,13 @@ const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.u
41
41
  // 期窗口)——V8 OOM/原生 fatal 自动写 report.*.json(实现批实测:目录缺失时 Node 静默不写
42
42
  // ——预建为必要动作)。失败不阻断启动(尽力面)。
43
43
  prepareCrashReporting()
44
+ // TUI-OOM-ROOTCAUSE(CRASH-REPORTS.md §8.3 武装点——全命令同源单点):堆遥测/看门狗——
45
+ // 60s 采样 + 双档(70/85%)比例边缘预警(stderr + TUI 行 + 事件日志);定时器 unref
46
+ // (一次性命令自然退出零阻塞);默认开 / THINCODER_HEAP_WATCH 关值集可不启。失败面全吞。
47
+ try {
48
+ const { startHeapWatch } = await import("../src/heap-watch.mjs")
49
+ startHeapWatch()
50
+ } catch { /* 看门狗启动失败不阻断启动(尽力面) */ }
44
51
 
45
52
  // R25(F-R25a)异常钩子升级:原"只 console.error 一行(TUI 全屏下不可见)"→ ① 落盘
46
53
  // crash-{ts}-{pid}.json ② TUI 活动态终端恢复 ③ console.error(恢复后打印才可见)
@@ -293,11 +300,13 @@ switch (command) {
293
300
  // 恢复上次的会话(同一项目目录);provider 按保存的名字切回(用户上次可能换过模型)
294
301
  // 2026-09-05 §10(R4):恢复决策按本端记录 resumeSlot(D-2 ①②③)——manifest active
295
302
  // 只作"无记录端"的一次性继承源,不再作本端恢复第一依据(D-6)。
296
- const { resumeSlot, applySession } = await import("../src/session.mjs")
303
+ const { resumeSlot, applySession, sessionDescriptor } = await import("../src/session.mjs")
297
304
  const { slot, data } = resumeSlot(process.cwd())
298
305
  if (data) {
299
306
  // applySession 内部已按槽复合重算 compactThreshold(auto 时)——不再需要 switched 分支
300
- applySession(agent, data)
307
+ // TUI-OOM-ROOTCAUSE(SESSION.md §14.3.4):传 slot → 绑定记录存储(身份核验 + 对账)
308
+ // ——人读线 = 尾窗(磁盘为准);未传 = 模式 F(全量数组)
309
+ applySession(agent, data, { slot })
301
310
  // SESSION.md §11.2(2026-09-08——N6 评审 🔴 修复):process restarted 句 = 进程级
302
311
  // 信号——仅本启动 resume 路径设一次(真进程重启恢复盘上会话);/session 切换与 ACP
303
312
  // 加载走同一 applySession 收敛但不设(不误报进程重启)。prepareRun 发句即清——进程
@@ -326,7 +335,7 @@ switch (command) {
326
335
  projectDir: config.memory.projectDir ? (isAbsolute(config.memory.projectDir) ? config.memory.projectDir : join(process.cwd(), config.memory.projectDir)) : null,
327
336
  team: teamConfig(config),
328
337
  author: gitAuthor(),
329
- restored: data,
338
+ restored: data ? sessionDescriptor(agent, data) : data,
330
339
  // R25(F-R25c):TUI 启动显示一行"上次运行异常终止"提示(showStartup 渲染——无匹配不传)
331
340
  crashNotice: recentCrashHint() ?? undefined,
332
341
  })
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thincoder",
3
- "version": "0.12.61",
3
+ "version": "0.12.62",
4
4
  "description": "Thin coding agent - zero dependencies, no build step, Node.js native. Sharp code, zero bloat.",
5
5
  "keywords": [
6
6
  "ai",
package/src/acp.mjs CHANGED
@@ -19,7 +19,7 @@ import { assembleAgent } from "./cli/make-agent.mjs"
19
19
  import { createAcpServer, ACP_ERRORS } from "./acp/transport.mjs"
20
20
  import { createAcpSession } from "./acp/session.mjs"
21
21
  import { replayHistory } from "./acp/bridge.mjs"
22
- import { listSlots, applySession, deleteSlot, normalizeCwd, loadSlotFile, slotOccupancy, loadManifest, saveManifest, getSessionId, newSession } from "./session.mjs"
22
+ import { listSlots, applySession, deleteSlot, normalizeCwd, loadSlotFile, slotOccupancy, loadManifest, saveManifest, getSessionId, newSession, slotPath, bindRecordStore } from "./session.mjs"
23
23
  import { createCheckpoint, listCheckpoints, rewind, isGitRepo } from "./git/checkpoint.mjs"
24
24
  import { createMemory, list as memList, remove as memRemove } from "./memory.mjs"
25
25
 
@@ -157,6 +157,9 @@ export function buildAcpHandlers({
157
157
  // mySessionId 同进程恒真)→ 第二个会话拿到与第一个相同的槽号 → 双写同槽
158
158
  // F2 互旋。getSessionId() 是进程级,_slot 是 agent 级——粒度错配必须在此切断。
159
159
  session.agent._slot = newSession(getCwd())
160
+ // TUI-OOM-ROOTCAUSE(SESSION.md §14.3.4):新建槽绑定记录存储(baseHistory 空——
161
+ // identity 待固化)——与 /new 同语义
162
+ bindRecordStore(session.agent, { slotFile: slotPath(getCwd(), session.agent._slot), identity: session.agent._sessionStart ?? null, baseHistory: [] })
160
163
  sessions.set(id, session)
161
164
  return { id, configOptions: [{ configId: "model" }, { configId: "thinking" }, { configId: "mode" }] }
162
165
  } catch (e) {
@@ -231,7 +234,6 @@ export function buildAcpHandlers({
231
234
  try {
232
235
  const id = String(nextId++)
233
236
  const session = await createSession({ id, notify: notifyRef.current, request: requestRef.current, log })
234
- applySession(session.agent, data)
235
237
  // 2026-08-31 advisor round2 🟡:钉 _slot 前查活主——目标槽被另一活进程(CLI/另一
236
238
  // IDE)占用时不得钉回(双方 sessionStart 一致 → F2 永不轮转 → 同槽 last-write-wins
237
239
  // 静默互覆盖)。空闲则认领后钉回;占用则不钉 → 下次保存经 activeSlot 自然 fork
@@ -241,7 +243,11 @@ export function buildAcpHandlers({
241
243
  // 同进程防护完全由 sameProcessPinned 承担:本进程另一 session 已钉该槽即视为占用
242
244
  // (进程级属主无法区分 agent,双方 sessionStart 相同 → F2 永不触发 → 静默互覆盖)。
243
245
  const sameProcessPinned = [...sessions.values()].some((s) => s.agent?._slot === slot)
244
- if (!occ.occupied && !sameProcessPinned) {
246
+ const pinned = !occ.occupied && !sameProcessPinned
247
+ // TUI-OOM-ROOTCAUSE(SESSION.md §14.3.4):钉槽(非占用)才绑定记录存储——
248
+ // 占用/fork 分支模式 F(首保存 fork 新槽后补绑)
249
+ applySession(session.agent, data, pinned ? { slot } : {})
250
+ if (pinned) {
245
251
  const m = loadManifest(getCwd())
246
252
  m.slotSessions ??= {}
247
253
  m.slotSessions[slot] = getSessionId()
@@ -278,13 +284,15 @@ export function buildAcpHandlers({
278
284
  try {
279
285
  const id = String(nextId++)
280
286
  const session = await createSession({ id, notify: notifyRef.current, request: requestRef.current, log })
281
- applySession(session.agent, data)
282
287
  // 2026-08-31 advisor round2 🟡:同 session/load——活主占用的槽不钉回(防同槽双写,
283
288
  // 下次保存 fork 新槽);空闲则认领后钉回。
284
289
  const occ = slotOccupancy(getCwd(), slot)
285
290
  // 2026-09-01 会诊 kimi/glm 🔴:同 session/load——同进程其他 session 已钉同槽视为占用 → fork
286
291
  const sameProcessPinned = [...sessions.values()].some((s) => s.agent?._slot === slot)
287
- if (!occ.occupied && !sameProcessPinned) {
292
+ const pinned = !occ.occupied && !sameProcessPinned
293
+ // TUI-OOM-ROOTCAUSE(SESSION.md §14.3.4):同 load——钉槽才绑定(占用/fork 模式 F)
294
+ applySession(session.agent, data, pinned ? { slot } : {})
295
+ if (pinned) {
288
296
  const m = loadManifest(getCwd())
289
297
  m.slotSessions ??= {}
290
298
  m.slotSessions[slot] = getSessionId()
@@ -12,7 +12,7 @@ import { ensureAutoReminder, injectEngineeringReminder, ContinueError } from "./
12
12
  import { cleanupConsultSessions } from "../agent-tools/consult.mjs"
13
13
  import { logEvent } from "../log.mjs"
14
14
  // ASYNC-RESULT-CONTAINER.md D1:池 accessor(absorb 双池——advisor 独立池无队列)
15
- import { getAsyncPool } from "../agent-tools/async-settle.mjs"
15
+ import { getAsyncPool, releaseSettledEntry } from "../agent-tools/async-settle.mjs"
16
16
  // R10 L3 (MULTI-INSTANCE-COLLAB §2a.5 D-L3a):回合末域登记 flush(写工具钩子累积 →
17
17
  // 整写一次本实例 peers 文件——无写入跳过;失败容忍不抛)
18
18
  import { flushPeerDomains } from "../peer-domains.mjs"
@@ -235,6 +235,8 @@ async function collectSettledAsync(agent, { suspDriven = false } = {}) {
235
235
  if (!e.done) continue // still running — stays in the pool (D-S1)
236
236
  await injectAsyncResult(agent, e)
237
237
  if (e.role === "advisor") injectedAdvisor = true
238
+ // TUI-OOM-ROOTCAUSE(§23.3.1 消费点①——回合尾收集):注入完成 → 释放条目持有
239
+ releaseSettledEntry(e)
238
240
  map.delete(String(e.id))
239
241
  }
240
242
  }
@@ -17,6 +17,7 @@
17
17
  import { specForModel } from "../config.mjs"
18
18
  import { ContinueError } from "../agent.mjs"
19
19
  import { relayPrefixOf } from "./relay-prefix.mjs"
20
+ import { appendCappedText } from "../text-budget.mjs"
20
21
  // 第 27 批 §12.3①:relay 前缀文法单一权威模块(`src/agent/relay-prefix.mjs`)——
21
22
  // 生成侧枢纽再导出(TUI/ACP 消费方可经此导入;消费方按 §12.3① 直连模块亦可)。
22
23
  export { RELAY_PREFIX_RE, parseRelayPath, relayPrefixOf } from "./relay-prefix.mjs"
@@ -162,6 +163,18 @@ export function emitNestedChildEvent(ctx, relayPrefix, kind) {
162
163
  return true
163
164
  }
164
165
 
166
+ /** 捕获额度(TUI-OOM-ROOTCAUSE——AGENT-LOOP.md §23.3.1 表 1 候选 1:滞后水位截断):
167
+ * `_capturedOutput` 是子代理流式文本的第二份全量拷贝(原无上限——勘察 C2);超 hard 即
168
+ * 裁至头 16K + 标记 + 尾 48K(摊还 O(1))——消费面读时已各自 slice(0, 2000/4000),头尾
169
+ * 保真覆盖;停止报告内联场景出现截断标记(可断言——D-SM1)。纯函数本体住
170
+ * src/text-budget.mjs(与 TUI 面共用——D2 单源)。 */
171
+ export const CAPTURE_CAP_OPTS = {
172
+ hard: 131_072,
173
+ head: 16_384,
174
+ tail: 49_152,
175
+ marker: "… [captured output truncated: N chars omitted] …",
176
+ }
177
+
165
178
  /**
166
179
  * 子 agent provider API key 检查:trim 后非空才保留;缺失返回 null(调用方
167
180
  * 按各自业务语汇报错——subagent 抛出 / escalate·consult 返回 Error 文本)。
@@ -207,9 +220,11 @@ export async function runWithContinue(runner, child, input, callbacks, runOpts,
207
220
  // Review #4 fix: strip sentinel/control chars from the capture — `output` feeds
208
221
  // onDeclined's partial-output return, which lands in the PARENT LLM history where
209
222
  // the display-layer sanitizeDisplay backstop does not apply.
223
+ // TUI-OOM-ROOTCAUSE(§23.3.1):捕获滞后水位截断——超 CAPTURE_CAP_OPTS.hard 裁至
224
+ // 头 + 标记 + 尾(_capturedOutput 有界;续跑同闭包累积,语义一致)。
210
225
  const capture = callbacks?.onToken
211
- ? (t) => { output += stripEventTokensForCapture(String(t)); child._capturedOutput = output; callbacks.onToken(t) }
212
- : (t) => { output += stripEventTokensForCapture(String(t)); child._capturedOutput = output }
226
+ ? (t) => { output = appendCappedText(output, stripEventTokensForCapture(String(t)), CAPTURE_CAP_OPTS); child._capturedOutput = output; callbacks.onToken(t) }
227
+ : (t) => { output = appendCappedText(output, stripEventTokensForCapture(String(t)), CAPTURE_CAP_OPTS); child._capturedOutput = output }
213
228
  // §27 R23 D-R23c1(评审 🔴 修复——2026-09-07):capture 是子代理 runAgent/dispatch ctx
214
229
  // 实际收到的 onToken——嵌套 wrapper 标记(wrapChildCallbacks `_relayPrefix`)必须随
215
230
  // capture 透传,否则 eng-coder 内 explore 同步收尾的 emitNestedChildEvent 判定恒 false
@@ -69,6 +69,19 @@ export function buildChildSignal(parent, ctx) {
69
69
 
70
70
  // ─── D3 settle 共享 helper ───────────────────────────────────────────────────
71
71
 
72
+ /**
73
+ * §23.3.1 消化窗口持有释放(TUI-OOM-ROOTCAUSE——D-SM2 表 2 候选 1):注入完成后置空条目
74
+ * 对子代理对象的引用(`childAgent`/`report`——条目前此从不显式释放,挂起期 = 分钟级驻留)。
75
+ * 幂等(重入不抛);池内/挂起未消化窗口语义零变(settle 时刻不释放——表 2 候选 2 否决:
76
+ * status/observe 在窗口内仍读 child 摘要)。三消费点(回合尾收集 / run 起始 pending 注入 /
77
+ * 挂起残差注入)在注入完成后调本 helper。
78
+ */
79
+ export function releaseSettledEntry(entry) {
80
+ if (!entry || typeof entry !== "object") return
81
+ entry.childAgent = null
82
+ entry.report = null
83
+ }
84
+
72
85
  /**
73
86
  * D3 settle 共享 helper——四族 settle 回调公共收尾单点(ASYNC-RESULT-CONTAINER.md
74
87
  * AC1:settle 记账单点,无逐字重复)。流程:
@@ -29,6 +29,8 @@ import { offloadToolResult, escapeXml } from "../agent/helpers.mjs"
29
29
  import { logEvent, errText } from "../log.mjs"
30
30
  import { deathLine } from "../abort-provenance.mjs"
31
31
  import { makeRelay, wrapChildCallbacks, runWithContinue, ensureChildApiKey, clampEffort } from "../agent/spawn-child.mjs"
32
+ // TUI-OOM-ROOTCAUSE §23.3.1:子代理人读线窗口常量(单源——store 零依赖)。
33
+ import { RECORD_WINDOW_MESSAGES } from "../session-store.mjs"
32
34
  // ASYNC-RESULT-CONTAINER.md D2/D3/D6:pending 单容器停靠 + settle 公共收尾 + child signal 单点
33
35
  import { buildChildSignal, settleAsyncEntry } from "./async-settle.mjs"
34
36
  import { digestBudgetOver, persistOverflowReport } from "./digest-budget.mjs" // B5(群 B 批 §22 D-DG2):digest 注入预算单源
@@ -280,6 +282,9 @@ async function runConsultChild(ctx, session, id, m, problem, ctrl) {
280
282
  // role "consult" (overlay + base would concatenate it twice).
281
283
  role: "consult",
282
284
  })
285
+ // TUI-OOM-ROOTCAUSE §23.3.1:子代理人读线窗口(四处创建点同置)——consult 会话跨回合驻留,
286
+ // 不置窗则 _fullHistory 仍无界(F-O1 覆盖 depth>0 全部创建点)
287
+ child._historyWindow = RECORD_WINDOW_MESSAGES
283
288
 
284
289
  // Activity relay via the unified spawn-child pipeline (§7.2 D3): `consult#<subId>/`
285
290
  // prefix (same channel subagent uses — parallel consultants stay independent) +
@@ -27,6 +27,8 @@
27
27
  import { relative, isAbsolute } from "node:path"
28
28
  import { runAgent, createAgent, DEFAULT_SUBAGENT_TURNS } from "../agent.mjs"
29
29
  import { runWithContinue, TURN_CAP_MARK, wrapChildCallbacks } from "../agent/spawn-child.mjs"
30
+ // TUI-OOM-ROOTCAUSE §23.3.1:子代理人读线窗口常量(单源——store 零依赖)。
31
+ import { RECORD_WINDOW_MESSAGES } from "../session-store.mjs"
30
32
  import { logEvent } from "../log.mjs"
31
33
  import { deathLine } from "../abort-provenance.mjs"
32
34
  import {
@@ -102,6 +104,9 @@ function touchedFilesNote(child, cwd) {
102
104
  */
103
105
  export function classifyEscalateSettle(parent, entry) {
104
106
  if (entry.cancelled) return { cancelled: true }
107
+ // §23.3.1(TUI-OOM-ROOTCAUSE)——childAgent 语义面:settle 分类(touched 摘要/partial
108
+ // merge)与 status/observe 摘要的读取源;**消化注入完成后由 `releaseSettledEntry`
109
+ // (async-settle.mjs)置 null**(池内未消化窗口不释放——表 2 候选 2 否决)。
105
110
  const child = entry.childAgent
106
111
  const touched = child?._touchedFiles ?? []
107
112
  const overlap = overlapPaths(parent, entry.launchSeq, touched)
@@ -196,6 +201,7 @@ export function launchEscalateAsync(parent, ctx, launch) {
196
201
  role: "coder",
197
202
  })
198
203
  entry.childAgent = child // settle 分类/status touched 摘要绑定(start 时刻)
204
+ child._historyWindow = RECORD_WINDOW_MESSAGES // TUI-OOM-ROOTCAUSE §23.3.1:子代理人读线窗口(四处创建点同置)
199
205
  child._logId = relayPrefix.slice(0, -1)
200
206
  logEvent("child:spawn", { role: "escalate", id: child._logId, kind: "async", status: "running", ms: 0 })
201
207
  const childCallbacks = wrapChildCallbacks(relayPrefix, ctx.callbacks ?? {})
@@ -5,8 +5,9 @@
5
5
  * (SESSION.md §13 R19): an explicit session file path deep-queries that file's
6
6
  * history line; "cwd:<dir>" discovers the sessions stored for that directory.
7
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:
8
+ * Default (no path) — THIS session's full human-readable record (record store when bound
9
+ * — disk-backed, SESSION.md §14.3.7; agent._fullHistory memory fallback otherwise:
10
+ * NEVER compacted, audit-complete). Use to recall what was said or done earlier:
10
11
  * design decisions, tool-call timing, past rulings.
11
12
  *
12
13
  * Filters AND together: role / keyword (message text) / tool (tool messages by
@@ -273,7 +274,8 @@ export const readHistoryTool = {
273
274
  }
274
275
  const keyword = typeof a.keyword === "string" && a.keyword.length > 0 ? a.keyword : null
275
276
  // Case-insensitive substring WITHOUT copying the full message text: the human line is
276
- // never compacted — single tool results can be hundreds of KB to MBs. Lowercase the
277
+ // never compacted (绑定态存储行 = slimForDisplay 产物——匹配基准 delta 见 SESSION.md
278
+ // §14.3.7 / T-RS8b) — single tool results can be hundreds of KB to MBs. Lowercase the
277
279
  // needle once and run a regex-i test over the haystack (escaping regex metachars so the
278
280
  // keyword stays a literal substring).
279
281
  const kwRe = keyword ? new RegExp(keyword.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "i") : null
@@ -287,6 +289,19 @@ export const readHistoryTool = {
287
289
  : querySessionFile(pathArg, { role, kwRe, tool, since, until, direction, limit }, baseCwd)
288
290
  }
289
291
 
292
+ // 本会话(无 path):绑定记录存储 → 方向流式迭代(磁盘为准——全量可见、内存窗口外
293
+ // 可命中;§14.3.7);未绑定(测试 / 模式 F)→ 内存 _fullHistory 既有过滤路径(回退保留)。
294
+ // 方向语义不变:newest 自尾向前取满 limit → 反转回时间序(输出恒时间序)。
295
+ const store = ctx.agent?._recordStore
296
+ if (store?.iterate) {
297
+ const taken = []
298
+ for (const m of store.iterate(direction)) {
299
+ if (!matches(m, { role, kwRe, tool, since, until })) continue
300
+ taken.push(m)
301
+ if (taken.length >= limit) break
302
+ }
303
+ return formatMatches(direction === "newest" ? taken.reverse() : taken, "oldest", limit)
304
+ }
290
305
  const history = Array.isArray(ctx.agent?._fullHistory) ? ctx.agent._fullHistory : []
291
306
  const matched = history.filter((m) => matches(m, { role, kwRe, tool, since, until }))
292
307
  return formatMatches(matched, direction, limit)
@@ -21,6 +21,8 @@ import { logEvent, errText } from "../log.mjs"
21
21
  import { describeBlockers, detectStall, STALL_NOTE } from "./subagent-scheduler.mjs"
22
22
  import { resolveChildProvider, mergeChildMutations } from "./subagent-async.mjs"
23
23
  import { launchEscalateAsync } from "./escalate-async.mjs"
24
+ // TUI-OOM-ROOTCAUSE §23.3.1:子代理人读线窗口常量(单源——store 零依赖)。
25
+ import { RECORD_WINDOW_MESSAGES } from "../session-store.mjs"
24
26
  // ASYNC-RESULT-CONTAINER.md D1:池 accessor(absorb 双池——advisor 独立池无队列)
25
27
  import { getAsyncPool } from "./async-settle.mjs"
26
28
 
@@ -406,6 +408,7 @@ export async function executeEscalateAction(args, ctx) {
406
408
  memory: parent.memory,
407
409
  role: "coder",
408
410
  })
411
+ child._historyWindow = RECORD_WINDOW_MESSAGES // TUI-OOM-ROOTCAUSE §23.3.1:子代理人读线窗口(四处创建点同置)
409
412
  child._logId = escId // LOGGING:子内事件归属(escalate#N)
410
413
  escT0 = Date.now()
411
414
  logEvent("child:spawn", { role: "escalate", id: escId, kind: "escalate" })
@@ -104,7 +104,7 @@ export function executeAsyncSpawn(parent, ctx, role, args, child, input, childOp
104
104
  if (baseSignal.aborted) ctrl.abort(baseSignal.reason)
105
105
  else baseSignal.addEventListener("abort", () => ctrl.abort(baseSignal.reason), { once: true })
106
106
  }
107
- // §19.5 D-M5:turn 镜像拦截层(callbacks 包装层——选改动最小方案:在既有
107
+ // §19.5 D-M5:turn 镜像拦截层(callbacks 包装层——在既有
108
108
  // wrapChildCallbacks 之外再包一层,只解析 ⟦ev⟧turn 更新条目,其余原样转发)。
109
109
  const trackOpts = { ...childOpts }
110
110
  const parentOnToken = trackOpts.onToken
@@ -15,6 +15,8 @@ import {
15
15
  readonlyToolNames, escapeXml,
16
16
  } from "../agent.mjs"
17
17
  import { makeRelay, wrapChildCallbacks, relayPrefixOf } from "../agent/spawn-child.mjs"
18
+ // TUI-OOM-ROOTCAUSE(AGENT-LOOP.md §23.3.1):子代理人读线窗口常量单源(store 零依赖)。
19
+ import { RECORD_WINDOW_MESSAGES } from "../session-store.mjs"
18
20
  import { validateDesignToken } from "./advisor.mjs"
19
21
  import { tokenExpired, removeDesignTokenSlot, reconcileEngTokensFromSlot, persistEngTokens } from "../token-ttl.mjs"
20
22
  import { resolveChildProvider, buildChildRunOpts, enqueueAsk } from "./subagent-async.mjs"
@@ -338,6 +340,10 @@ export function buildSpawnChild(parent, ctx, args, role, wantAsync, files, depen
338
340
  overlay,
339
341
  role,
340
342
  })
343
+ // TUI-OOM-ROOTCAUSE(AGENT-LOOP.md §23.3.1):子代理人读线窗口置位(常量单源
344
+ // session-store.mjs)——机制复用主 agent 同路径(context.mjs pushReal 驱逐);
345
+ // 每个 child 原先各自一份永不压缩的 _fullHistory(勘察 C2 乘数面)。
346
+ child._historyWindow = RECORD_WINDOW_MESSAGES
341
347
 
342
348
  // §2.20.2 批次档段写入通道绑定(第 4 批):工程角色(eng-coder/eng-designer)把批次档
343
349
  // 绝对路径记在 child 上——setup 挂载 batch_segment 时读它。无路径参数的工具靠这条
package/src/agent.mjs CHANGED
@@ -106,9 +106,13 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
106
106
  if (pendingAsync?.length) {
107
107
  const { injectAsyncResult } = await import("./agent-tools/subagent.mjs")
108
108
  const { injectConsultResult } = await import("./agent-tools/consult.mjs")
109
+ // TUI-OOM-ROOTCAUSE(AGENT-LOOP.md §23.3.1 消费点②——run 起始 pending 注入):
110
+ // 注入完成后释放条目对子代理对象的持有(childAgent/report 置空——幂等 helper)。
111
+ const { releaseSettledEntry } = await import("./agent-tools/async-settle.mjs")
109
112
  for (const e of pendingAsync.splice(0)) {
110
113
  if (e.role === "consult") await injectConsultResult(agent, e)
111
114
  else await injectAsyncResult(agent, e)
115
+ releaseSettledEntry(e)
112
116
  }
113
117
  }
114
118
  agent._inAutoTurn = autoTurn // spawn gate for manual-tier digests (§17 D-S6/N3)
package/src/context.mjs CHANGED
@@ -163,7 +163,7 @@ function tightenTailByBudget(history, start, floorStart, budgetTokens) {
163
163
  * pushReal — the single entry point for REAL conversation messages.
164
164
  * A real message (user input, assistant reply, tool result, multimodal image) is appended to BOTH:
165
165
  * agent.history — the machine context (compaction shrinks this)
166
- * agent._fullHistory — the NEVER-COMPACTED human-readable record (persistence source)
166
+ * agent._fullHistory — the human-readable record (persistence source)
167
167
  * Machine-only messages ([System reminder:...], compaction notes, task/plan/checkpoint re-injections)
168
168
  * are pushed directly to agent.history WITHOUT going through here, so they never enter _fullHistory.
169
169
  * The two lines are written independently at the source — no after-the-fact delta sync.
@@ -171,11 +171,22 @@ function tightenTailByBudget(history, start, floorStart, budgetTokens) {
171
171
  * point covers every real message. Pre-existing ts (e.g. from another end writing the shared slot)
172
172
  * is preserved; restored old messages keep no ts rather than getting a misleading backdate (D-S3).
173
173
  * ts is a LOCAL-ONLY field — the send layer strips it before any provider request (T-S3).
174
+ *
175
+ * TUI-OOM-ROOTCAUSE 批(SESSION.md §14.3.5)——人读线内存有界 + 磁盘为准:
176
+ * ① `agent._recordStore?.append(msg)`:记录同步追加(磁盘为准——append-only sidecar);
177
+ * ② 窗口驱逐:绑定态(agent._historyWindow = 200)下 _fullHistory 只保最近窗口条——
178
+ * 更早内容仅存磁盘(翻页/检索/保存从盘按需读)。未绑定(模式 F)不驱逐(零回归)。
179
+ * 追加失败不阻断回合(独立 try/catch——尽力面 N-S6;store 内部另置 degraded 并停写)。
174
180
  */
175
181
  export function pushReal(agent, msg) {
176
182
  if (!Array.isArray(agent._fullHistory)) agent._fullHistory = []
177
183
  if (msg && msg.ts === undefined) msg.ts = Date.now()
178
184
  agent._fullHistory.push(msg)
185
+ try { agent._recordStore?.append(msg) } catch { /* 尽力面:落盘失败不阻断回合(N-S6) */ }
186
+ const win = agent._historyWindow
187
+ if (win > 0 && agent._fullHistory.length > win) {
188
+ agent._fullHistory.splice(0, agent._fullHistory.length - win)
189
+ }
179
190
  agent.history.push(msg)
180
191
  }
181
192
 
@@ -1,12 +1,14 @@
1
1
  /**
2
- * crash-reports.mjs — R25(docs/design/ARCHITECTURE.md §R25——F-R25a/b/c)CLI 异常终止
3
- * 捕获与留痕机制。
2
+ * crash-reports.mjs — 崩溃捕获与取证(docs/design/CRASH-REPORTS.md——F1/F2 为 R25 与
3
+ * TUI-STDERR-CAPTURE 存量迁移;F3 近堆上限堆快照 = TUI-OOM-FORENSICS 批新增)。
4
4
  *
5
- * 三类能力(与设计逐项对应):
6
- * - prepareCrashReporting()(F-R25b):入口最前调用——mkdir 预建 ~/.thincoder/crash-reports/
5
+ * 能力(与设计逐项对应):
6
+ * - prepareCrashReporting()(F-R25b + F3①):入口最前调用——mkdir 预建 ~/.thincoder/crash-reports/
7
7
  * + process.report 代码内启用(reportOnFatalError + directory)——V8 OOM/原生 fatal
8
8
  * 自动写 report.*.json。实现批实测(2026-09-07):目录缺失时 Node 对 fatal 静默不写
9
9
  * 报告——预建是必要动作而非"零成本保险"。
10
+ * F3①:同点武装近堆上限堆快照(v8.setHeapSnapshotNearHeapLimit——对象级证据,回答
11
+ * "谁在持内存";全路径单源,默认开、THINCODER_HEAP_SNAPSHOT 可关——F3②)。
10
12
  * - writeCrashRecord()(F-R25a):JS 异常钩子同步落盘 crash-{ts}-{pid}.json(ts = epoch ms
11
13
  * UTC + pid——跨进程同 ms 防覆盖——复审 #3)——权限 0600(config.json 先例)。
12
14
  * - recentCrashHint()(F-R25c):启动扫描 24h 窗内记录(两类文件模式定死——评审 #8:
@@ -23,8 +25,13 @@
23
25
  */
24
26
  import { chmodSync, existsSync, mkdirSync, readdirSync, statSync, unlinkSync, writeFileSync } from "node:fs"
25
27
  import { join } from "node:path"
28
+ // F3① 命名空间 import:API 缺失(旧 Node)降级为调用期异常并被武装 try 吞掉——不做 import 期硬失败
29
+ import * as v8 from "node:v8"
26
30
  import { configDir } from "./config.mjs"
27
31
 
32
+ /** F3② 关值集合(trim + 大小写不敏感);其余取值(未设 / 空串 / 未知串)默认开——fail-open 向取证。 */
33
+ const HEAP_SNAPSHOT_OFF_VALUES = new Set(["0", "false", "off", "no"])
34
+
28
35
  /** crash-reports 运行时目录(~/.thincoder/crash-reports——非仓内——写时自清理)。 */
29
36
  export function crashReportsDir() {
30
37
  return join(configDir, "crash-reports")
@@ -32,13 +39,15 @@ export function crashReportsDir() {
32
39
 
33
40
  /** 记录文件模式(评审 #8 定死):crash-*.json = 自写(F-R25a);report.*.json = Node
34
41
  * fatal(默认命名 report.YYYYMMDD.HHMMSS.<pid>.<seq>.json——2026-09-07 实现批实测)。
35
- * purge 另含 tui-stderr-*.log(TUI-STDERR-CAPTURE F-2——30 天同族淘汰)——但 recentCrashHint
36
- * 不计该类:tui-stderr 每次 TUI 启动都生成(正常退出也留档)——计入即正常会话误报"异常终止"。 */
42
+ * purge 另含 tui-stderr-*.log(TUI-STDERR-CAPTURE F-2——30 天同族淘汰)与
43
+ * Heap.*.heapsnapshot(CRASH-REPORTS F3③——快照 GB 级、TUI 落点须自动兜底)——但
44
+ * recentCrashHint 两类均不计(N3):tui-stderr 每次 TUI 启动都生成(正常退出也留档)、
45
+ * 快照非「异常终止」证据类——计入即正常会话误报。 */
37
46
  function isCrashRecordName(name) {
38
47
  return /^crash-.+\.json$/.test(name) || /^report\..+\.json$/.test(name)
39
48
  }
40
49
  function isPurgeRecordName(name) {
41
- return isCrashRecordName(name) || /^tui-stderr-.+\.log$/.test(name)
50
+ return isCrashRecordName(name) || /^tui-stderr-.+\.log$/.test(name) || /^Heap\..+\.heapsnapshot$/.test(name)
42
51
  }
43
52
 
44
53
  /** >30 天淘汰(评审 #6)——写时自清理;搭车点 = 写 / 入口 mkdir / 启动扫描(复审 #4)。 */
@@ -54,18 +63,31 @@ function purgeOldCrashReports(dir) {
54
63
  }
55
64
  }
56
65
 
66
+ /** F3② 武装判定(单点——包装器不判 env,防两处判定漂移):关值集合命中 → 不武装;其余 → 武装。 */
67
+ function heapSnapshotEnabled(env) {
68
+ return !HEAP_SNAPSHOT_OFF_VALUES.has(String(env?.THINCODER_HEAP_SNAPSHOT ?? "").trim().toLowerCase())
69
+ }
70
+
57
71
  /**
58
72
  * F-R25b:入口最前调用(一切重活前——缩编程期窗口)——预建目录 + process.report 启用。
59
73
  * 任何失败不阻断启动(尽力面——record 路径自带降级)。返回目录路径。
74
+ * F3①:同点武装近堆上限堆快照(默认开——全路径单源;环境开关见 heapSnapshotEnabled)。
75
+ * @param {object} [opts] 注入缝(默认参数保 bin 入口调用点零改)
76
+ * @param {string} [opts.dir] 目录注入(测试用——同时服务 mkdir / report / purge / 返回)
77
+ * @param {object} [opts.env] env 注入(测试用)——仅服务 F3② 判定
78
+ * @param {(n: number) => void} [opts.armHeapSnapshot] 武装实现注入(测试用)——默认真实 node:v8 API
60
79
  */
61
- export function prepareCrashReporting() {
62
- const dir = crashReportsDir()
80
+ export function prepareCrashReporting({ dir = crashReportsDir(), env = process.env, armHeapSnapshot = v8.setHeapSnapshotNearHeapLimit } = {}) {
63
81
  try {
64
82
  mkdirSync(dir, { recursive: true })
65
83
  // 代码内启用:shebang 入口无法携带启动参数(env 单参数限制 + execArgv 仅子进程——评审 #1)
66
84
  process.report.directory = dir
67
85
  process.report.reportOnFatalError = true
68
86
  } catch { /* mkdir/启用失败不阻断启动——尽力面 */ }
87
+ // F3① 武装(独立 try——失败不阻断启动、不影响上方 F1 既有步骤;尽力面静默)
88
+ if (heapSnapshotEnabled(env)) {
89
+ try { armHeapSnapshot(1) } catch { /* 武装失败不阻断——N1 */ }
90
+ }
69
91
  purgeOldCrashReports(dir) // F-R25b 入口 mkdir 搭车清理(复审 #4——纯 fatal 序列也触发)
70
92
  return dir
71
93
  }
@@ -69,9 +69,14 @@ export async function generateTitle(userContent, provider) {
69
69
  export async function ensureSessionTitle(agent) {
70
70
  if (agent.title) return agent.title
71
71
  try {
72
- const firstUser = (agent._fullHistory ?? agent.history).find(
73
- (m) => m.role === "user" && typeof m.content === "string" && !m.content.startsWith("[System reminder:"),
74
- )
72
+ // TUI-OOM-ROOTCAUSE 批(SESSION.md §14.3.3):绑定态首条 user 消息在记录存储(段 1)——
73
+ // 内存窗口可能已滑过它(store.firstUserMessage 首扫一次并缓存);未绑定回退内存查找。
74
+ let firstUser = agent._recordStore?.firstUserMessage?.() ?? null
75
+ if (!firstUser) {
76
+ firstUser = (agent._fullHistory ?? agent.history).find(
77
+ (m) => m.role === "user" && typeof m.content === "string" && !m.content.startsWith("[System reminder:"),
78
+ )
79
+ }
75
80
  if (firstUser) {
76
81
  const title = await generateTitle(firstUser.content, agent.provider)
77
82
  if (title) agent.title = title
@@ -0,0 +1,88 @@
1
+ /**
2
+ * heap-watch.mjs — 堆遥测/看门狗(TUI-OOM-ROOTCAUSE 批——CRASH-REPORTS.md §8)。
3
+ *
4
+ * 事故会话堆爬升 ~19 分钟无任何事前信号(「炸了才知道」);本模块补「事前预警」:
5
+ * 60s 采样 + 堆上限比例双档(70% / 85%)边缘触发 → stderr 行 + TUI 行(订阅制)+
6
+ * 事件日志。**只看不治**(不强制 GC、不阻断、不触发快照——D-HW2)。
7
+ *
8
+ * 关口(§8.3):阈值 = `heapUsed / v8.getHeapStatistics().heap_size_limit`(比例——可移植,
9
+ * 无绝对 MB 常量);双档边缘触发(每档一次/进程——不重复刷屏);零常态开销
10
+ * (60s 一次 memoryUsage(),无输出——D-HW5)。
11
+ *
12
+ * 开关单点:`THINCODER_HEAP_WATCH`——关值集合 {`0`,`false`,`off`,`no`}(trim + 大小写
13
+ * 不敏感)→ 不启动;未设/空串/其他值 → 启动(默认开——与 F3② 同约定)。
14
+ *
15
+ * 注入缝(N5 可测):`sample` / `heapLimit` / `timer`——测试以假实现 + `checkNow()`
16
+ * 直驱(零等待);失败面全吞(采样抛错不阻断——样本失败静默跳过本次)。
17
+ */
18
+ import { getHeapStatistics } from "node:v8"
19
+ import { logEvent } from "./log.mjs"
20
+
21
+ /** 关值集合(单点判定——§3.2 同约定复用)。 */
22
+ export const HEAP_WATCH_OFF = new Set(["0", "false", "off", "no"])
23
+
24
+ /** 开关判定(单点——grep 面:仅本模块读 env.THINCODER_HEAP_WATCH)。 */
25
+ export function heapWatchEnabled(env = process.env) {
26
+ const v = String(env.THINCODER_HEAP_WATCH ?? "").trim().toLowerCase()
27
+ return !HEAP_WATCH_OFF.has(v)
28
+ }
29
+
30
+ // 订阅表(TUI 行接线——§8.3 订阅制;模块级:武装在 bin、订阅在 startTUI)
31
+ const _subscribers = new Set()
32
+
33
+ /** 订阅预警行(返回退订函数)。 */
34
+ export function onHeapWarn(cb) {
35
+ if (typeof cb !== "function") return () => {}
36
+ _subscribers.add(cb)
37
+ return () => _subscribers.delete(cb)
38
+ }
39
+
40
+ /** 预警行格式化(逐字——§8.3;U/L 一位小数、P 取整)。 */
41
+ export function heapWarnLine(used, limit, ratio) {
42
+ const gb = (n) => (n / 1024 / 1024 / 1024).toFixed(1)
43
+ return `[heap] warning: heapUsed ${gb(used)} GB / ${gb(limit)} GB heap limit (${Math.round(ratio * 100)}%) — long session; consider /new to reset context`
44
+ }
45
+
46
+ /**
47
+ * 启动看门狗(返回 `{ stop(), checkNow() }`——checkNow 直驱一次采样并返回本轮新发预警行)。
48
+ * 关值(§8.3)→ 不注册定时器(返回惰性句柄)。
49
+ */
50
+ export function startHeapWatch({
51
+ intervalMs = 60_000,
52
+ ratios = [0.7, 0.85],
53
+ sample = process.memoryUsage,
54
+ heapLimit = () => getHeapStatistics().heap_size_limit,
55
+ env = process.env,
56
+ timer = setInterval,
57
+ } = {}) {
58
+ const warned = new Set()
59
+ const checkNow = () => {
60
+ const out = []
61
+ try {
62
+ const used = sample()?.heapUsed ?? 0
63
+ const limit = heapLimit()
64
+ if (!(limit > 0)) return out
65
+ const ratio = used / limit
66
+ for (const r of ratios) {
67
+ if (ratio >= r && !warned.has(r)) {
68
+ warned.add(r) // 每档一次/进程
69
+ const line = heapWarnLine(used, limit, ratio)
70
+ out.push(line)
71
+ try { console.error(line) } catch { /* 输出失败不阻断 */ } // stderr(headless 主面——§8.3)
72
+ for (const cb of _subscribers) { try { cb(line) } catch { /* 订阅者失败不阻断 */ } }
73
+ try { logEvent("heap-warn", { kind: "heap-warn", used, limit, ratio: Number(ratio.toFixed(3)) }) } catch { /* 日志失败不阻断(D-HW4) */ }
74
+ }
75
+ }
76
+ } catch { /* 失败面全吞(N5——采样/上限抛错不阻断、状态保持) */ }
77
+ return out
78
+ }
79
+ if (!heapWatchEnabled(env)) return { stop() {}, checkNow }
80
+ const handle = timer(checkNow, intervalMs)
81
+ try { handle?.unref?.() } catch { /* unref 失败不阻断(一次性命令自然退出) */ }
82
+ return {
83
+ stop() {
84
+ try { clearInterval(handle) } catch { /* 已停/不可清——静默(尽力面) */ }
85
+ },
86
+ checkNow,
87
+ }
88
+ }