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
@@ -15,9 +15,26 @@
15
15
  * trim = 单载体最旧先行(原树级 trim「子块先丢 / done 豁免」随子块内容层退役)。
16
16
  * 省略计数真值(N6——D-ST5):`…(已省略 N 行)` 的 N 只随内容移除增长;标记自身
17
17
  * 不占额度、不被丢弃、不计入 N。
18
+ *
19
+ * TUI-OOM-ROOTCAUSE(TUI.md §15.1 事实 3 / §15.3.3):原环按 `\n` 计数——无换行巨 chunk
20
+ * 净增 0 行 → 环永不触发、文本无界。本模块加**字符维**(第二维——行数维逐字不动,D-TB5):
21
+ * `carrier._charCount` 与 `SUB_BLOCK_CHAR_LIMIT` 双维同时满足(先到先裁);字符维裁剪
22
+ * 按被裁文本行数折算 N(无幽灵计数——N6)。
18
23
  */
24
+ import { SUB_BLOCK_CHAR_LIMIT, MIDDLE_TRUNC_MARKER, capText } from "./display-budget.mjs"
25
+
19
26
  export const SUB_BLOCK_LINE_LIMIT = 500
20
27
 
28
+ /** 单块自身额度(TUI-OOM-ROOTCAUSE §15.1 事实 3:无换行巨 chunk —— 行数维净增 0、
29
+ * 环永不触发;入块文本先过本条(头 3/4 + 尾 1/4 + 中段标记)——单行内容不因字符维
30
+ * 裁剪被整行丢光(环总量裁剪仍按最旧先行)。 */
31
+ const SUB_BLOCK_CAP_OPTS = {
32
+ max: SUB_BLOCK_CHAR_LIMIT,
33
+ keepHead: 90_000,
34
+ keepTail: 30_000,
35
+ marker: MIDDLE_TRUNC_MARKER,
36
+ }
37
+
21
38
  /** 显示行计数(N6 口径):块尾 `\n` 的空元素不计(行结束符非空行)。 */
22
39
  const countBlockLines = (text) => {
23
40
  const lines = String(text ?? "").split("\n")
@@ -27,11 +44,42 @@ const countBlockLines = (text) => {
27
44
  /** 省略标记块判定(trim 产物——不占额度/不可被丢;N6)。 */
28
45
  const isTrimMarker = (b) => b?.kind === "meta" && b._trimMarker === true
29
46
 
47
+ /** 省略标记幂等更新(不占额度/不计入字符账——N6)。 */
48
+ function updateTrimMarker(carrier) {
49
+ const marker = `…(已省略 ${carrier.dropped} 行)`
50
+ const head = carrier.blocks[0]
51
+ if (head && isTrimMarker(head)) head.text = marker // 既有标记原位更新(不重复 unshift)
52
+ else carrier.blocks.unshift({ kind: "meta", text: marker, _trimMarker: true })
53
+ }
54
+
55
+ /** 单行丢弃(最旧内容块首行——字符维/行维共用):返回释放字符数(行级元数据同步)。 */
56
+ function dropFirstContentLine(carrier) {
57
+ for (;;) {
58
+ const idx = carrier.blocks.findIndex((b) => !isTrimMarker(b))
59
+ if (idx === -1) return 0 // 仅余省略标记(内容耗尽)——不再产生幽灵行
60
+ const first = carrier.blocks[idx]
61
+ const before = countBlockLines(first.text)
62
+ if (before <= 0) { carrier.blocks.splice(idx, 1); continue }
63
+ const parts = String(first.text).split("\n")
64
+ const droppedLine = parts.shift() ?? ""
65
+ const kept = parts.join("\n")
66
+ if (kept === "") carrier.blocks.splice(idx, 1)
67
+ else first.text = kept
68
+ const lineDelta = before - (kept === "" ? 0 : countBlockLines(kept))
69
+ carrier._lineCount = Math.max(0, (carrier._lineCount ?? 0) - lineDelta)
70
+ carrier.dropped = (carrier.dropped ?? 0) + lineDelta
71
+ // 释放字符数 = 本行正文 + 行分隔符(parts.join("\n") 同步移除——代码评审 #6:少记 1
72
+ // 字符会使 _charCount 偏高、触发早裁)
73
+ return droppedLine.length + (kept === "" ? 0 : 1)
74
+ }
75
+ }
76
+
30
77
  /** 载体级丢行(D-ST4 单环最旧先行):从头部内容块 FIFO 丢行;省略标记跳过(不占额度/
31
- * 不被丢弃/不计入 N——N6)。返回实际丢弃行数(= N 增量——无幽灵行)。 */
78
+ * 不被丢弃/不计入 N——N6)。返回实际丢弃行数(= N 增量——无幽灵行)。字符账同步扣减。 */
32
79
  function dropCarrierLines(carrier, want) {
33
80
  if (want <= 0) return 0
34
81
  let dropped = 0
82
+ let freedChars = 0
35
83
  while (want > dropped) {
36
84
  const idx = carrier.blocks.findIndex((b) => !isTrimMarker(b))
37
85
  if (idx === -1) break // 仅余省略标记(内容耗尽)——不再产生幽灵行
@@ -42,39 +90,63 @@ function dropCarrierLines(carrier, want) {
42
90
  if (take >= lines) {
43
91
  carrier.blocks.splice(idx, 1)
44
92
  dropped += lines
93
+ freedChars += String(first.text).length
45
94
  } else {
46
- first.text = first.text.split("\n").slice(take).join("\n")
95
+ const kept = String(first.text).split("\n").slice(take).join("\n")
96
+ freedChars += String(first.text).length - kept.length
97
+ first.text = kept
47
98
  dropped += take
48
99
  }
49
100
  }
50
101
  carrier._lineCount = Math.max(0, (carrier._lineCount ?? 0) - dropped)
51
102
  carrier.dropped = (carrier.dropped ?? 0) + dropped
52
- if (dropped > 0) {
53
- const marker = `…(已省略 ${carrier.dropped} 行)`
54
- const head = carrier.blocks[0]
55
- if (head && isTrimMarker(head)) head.text = marker // 既有标记原位更新(不重复 unshift)
56
- else carrier.blocks.unshift({ kind: "meta", text: marker, _trimMarker: true }) // 标记不占额度(N6)
57
- }
103
+ carrier._charCount = Math.max(0, (carrier._charCount ?? 0) - freedChars)
104
+ if (dropped > 0) updateTrimMarker(carrier)
58
105
  return dropped
59
106
  }
60
107
 
61
- /** 单载体 trim(原树级 trimSubTree 收窄——D-ST4):超 500 显示行 → 最旧先行丢行。 */
108
+ /** 字符维裁剪(§15.3.3——无换行巨 chunk 的堵口):逐行丢最旧直至释放 ≥ want 字符;
109
+ * 行数按实际被丢行折算 N(无幽灵计数——N6);标记不占额度。返回释放字符数。 */
110
+ function dropCarrierChars(carrier, want) {
111
+ if (!(want > 0)) return 0
112
+ let freed = 0
113
+ while (freed < want) {
114
+ const got = dropFirstContentLine(carrier)
115
+ if (got <= 0) break // 内容耗尽(不再产生幽灵行)
116
+ freed += got
117
+ }
118
+ if (freed > 0) {
119
+ carrier._charCount = Math.max(0, (carrier._charCount ?? 0) - freed)
120
+ updateTrimMarker(carrier)
121
+ }
122
+ return freed
123
+ }
124
+
125
+ /** 单载体 trim(原树级 trimSubTree 收窄——D-ST4):超 500 显示行 → 最旧先行丢行;
126
+ * 超 SUB_BLOCK_CHAR_LIMIT 字符 → 同款最旧先行(双维先到先裁——TUI.md §15.3.4)。 */
62
127
  function trimSubCarrier(carrier) {
63
128
  const over = (carrier._lineCount ?? 0) - SUB_BLOCK_LINE_LIMIT
64
129
  if (over > 0) dropCarrierLines(carrier, over)
130
+ const overChars = (carrier._charCount ?? 0) - SUB_BLOCK_CHAR_LIMIT
131
+ if (overChars > 0) dropCarrierChars(carrier, overChars)
65
132
  }
66
133
 
67
- /** kind 合并追加(载体级)——净增行记账(显示行口径;省略标记不参与合并——N6)。 */
134
+ /** kind 合并追加(载体级)——净增行记账(显示行口径;省略标记不参与合并——N6);
135
+ * 字符账同步累加(第二维——§15.3.4)。 */
68
136
  function pushBlock(carrier, kind, text, fresh) {
69
137
  if (!text) return
138
+ const capped = capText(text, SUB_BLOCK_CAP_OPTS) // 单块自身额度(无换行巨 chunk 堵口)
70
139
  const last = carrier.blocks.at(-1)
71
140
  if (!fresh && last && last.kind === kind && !isTrimMarker(last)) {
72
141
  const before = countBlockLines(last.text)
73
- last.text += text
142
+ const beforeChars = last.text.length
143
+ last.text = capText(last.text + capped, SUB_BLOCK_CAP_OPTS)
74
144
  carrier._lineCount = (carrier._lineCount ?? 0) + countBlockLines(last.text) - before
145
+ carrier._charCount = (carrier._charCount ?? 0) + (last.text.length - beforeChars)
75
146
  } else {
76
- carrier.blocks.push({ kind, text })
77
- carrier._lineCount = (carrier._lineCount ?? 0) + countBlockLines(text)
147
+ carrier.blocks.push({ kind, text: capped })
148
+ carrier._lineCount = (carrier._lineCount ?? 0) + countBlockLines(capped)
149
+ carrier._charCount = (carrier._charCount ?? 0) + capped.length
78
150
  }
79
151
  carrier.blockEpoch = (carrier.blockEpoch ?? 0) + 1
80
152
  }
@@ -102,7 +174,7 @@ export function ensureSubChild(carrier, seg) {
102
174
  started: Date.now(), done: false, doneAt: null, stopped: false,
103
175
  blocks: [], currentTool: null, toolArgs: null,
104
176
  approval: null, lastError: null, dropped: 0,
105
- blockEpoch: 0, children: [], _lineCount: 0,
177
+ blockEpoch: 0, children: [], _lineCount: 0, _charCount: 0,
106
178
  }
107
179
  carrier.children.push(c)
108
180
  }
@@ -12,6 +12,8 @@
12
12
 
13
13
  import { C } from "./ansi.mjs"
14
14
  import { closeOpenSubChildren } from "./subagent-children.mjs"
15
+ // TUI-OOM-ROOTCAUSE(TUI.md §15.3.3 落点表末行):state.lines 总量账——splice 插入路径过账。
16
+ import { accountLine } from "./display-budget.mjs"
15
17
 
16
18
  // ─── §19.6 D-P1 面板视图(subagent panel 检查工具)───
17
19
  // CLI-ACTIVITY-DEBLOAT F-3:面板区块列表由消费面**读时现算**(action:"panel" 经
@@ -84,9 +86,13 @@ export function freezeSubTaskLines(state, sub) {
84
86
  sub.done = true
85
87
  sub.doneAt = sub.doneAt ?? Date.now()
86
88
  const anchor = sub._freezeAt ?? state.lines.length
87
- state.lines.splice(Math.min(anchor, state.lines.length), 0, {
89
+ const line = {
88
90
  text: `subagent activity: ${sub.key}`, color: C.dim, _frozenSubTask: sub,
89
- })
91
+ }
92
+ state.lines.splice(Math.min(anchor, state.lines.length), 0, line)
93
+ // TUI-OOM-ROOTCAUSE(TUI.md §15.3.3 落点表末行「冻结子代理 splice 插入——经
94
+ // syncLineBudget 同款对账」):冻结行进 state.lines 总量账(splice 插入路径)。
95
+ accountLine(state, line)
90
96
  }
91
97
 
92
98
  /** 头裁锚点校正(index.mjs pushLine 调用):裁 removedCount 补 1 标记行 = 净位移
@@ -19,7 +19,7 @@ import { sweepToolBlocks } from "./tool-events.mjs"
19
19
  import { logEvent } from "../log.mjs"
20
20
  import { C } from "./ansi.mjs"
21
21
  // ASYNC-RESULT-CONTAINER.md D1/D2:池 accessor(双池 absorb)+ pending 单容器停靠
22
- import { getAsyncPool, parkAsyncPending } from "../agent-tools/async-settle.mjs"
22
+ import { getAsyncPool, parkAsyncPending, releaseSettledEntry } from "../agent-tools/async-settle.mjs"
23
23
 
24
24
  // INPUT-LOCK-ASYNC(C'——2026-09-09,本档 INPUT-LOCK-ASYNC.md):R15 排队
25
25
  // 用户指令合并(§11.3 D-24c——攒批计划/合并文案/上限常量)整批废弃
@@ -285,6 +285,8 @@ export async function suspensionSession(ctx) {
285
285
  for (const e of residual.splice(0)) {
286
286
  if (e.role === "consult") await injectConsultResult(agent, e)
287
287
  else await injectAsyncResult(agent, e)
288
+ // TUI-OOM-ROOTCAUSE(§23.3.1 消费点③——挂起残差注入):注入完成 → 释放条目持有
289
+ releaseSettledEntry(e)
288
290
  }
289
291
  }
290
292
  }
@@ -12,6 +12,7 @@
12
12
  * 全量必须落行)。
13
13
  */
14
14
  import { sliceByWidth } from "./render.mjs"
15
+ import { capLines, ARGS_JSON_MAX_CHARS } from "./display-budget.mjs"
15
16
 
16
17
  /** 单行参数摘要:按工具挑关键字段(vscode 卡片头对齐),未知工具回退 JSON。 */
17
18
  export function describeToolArgs(name, args) {
@@ -74,8 +75,10 @@ export function describeToolArgs(name, args) {
74
75
  }
75
76
 
76
77
  /** 恢复路径用:全量参数 pretty JSON 的 dim 行(非空才输出)。
77
- * 与工具结果的恢复惯例一致——完整落行,超长由连续 dim 折叠收纳。 */
78
+ * 与工具结果的恢复惯例一致——完整落行;**总量额度**(TUI-OOM-ROOTCAUSE §15.3.1
79
+ * ARGS_JSON_MAX_CHARS——`write`/`apply_patch` 整文件内容进显示层的堵口):超出尾截断
80
+ * + 标记,首行保真。 */
78
81
  export function toolArgsLines(args) {
79
82
  if (!args || typeof args !== "object" || Object.keys(args).length === 0) return []
80
- return JSON.stringify(args, null, 2).split("\n")
83
+ return capLines(JSON.stringify(args, null, 2).split("\n"), ARGS_JSON_MAX_CHARS)
81
84
  }
@@ -7,6 +7,9 @@
7
7
  * 不禁内容变更(.set/.delete/.push),调用方行为与同模块时代一致。
8
8
  */
9
9
 
10
+ // TUI-OOM-ROOTCAUSE(TUI.md §15.3.1):显示层额度常量/工具行定位(display-budget 叶子)。
11
+ import { capLines, TOOL_RESULT_MAX_CHARS } from "./display-budget.mjs"
12
+
10
13
  // Tool execution start timestamps (performance.now ms). Keyed by tool_call id
11
14
  // when available (parallel same-name tools each get their own tick — the
12
15
  // P0-3 fix, 2026-08-30), falling back to a per-name FIFO queue for callers
@@ -73,7 +76,9 @@ export function sweepToolBlocks(state) {
73
76
  * 1) Multimodal results (read_image) embed FULL base64 images in the JSON —
74
77
  * the human needs only the text part (model gets images via the multimodal
75
78
  * channel); 2) results beyond maxRows are truncated in the block (full
76
- * text always lives in history for the model). Returns row array. */
79
+ * text always lives in history for the model). Returns row array.
80
+ * TUI-OOM-ROOTCAUSE(§15.3.1 TOOL_RESULT_MAX_CHARS):总量额度与 400 行双维
81
+ * (单行任意大——无 `\n` 巨 chunk/JSON 尾行),尾截断 + 标记。 */
77
82
  export function slimToolResultForDisplay(result, maxRows = 400) {
78
83
  let displayResult = result
79
84
  try {
@@ -81,9 +86,18 @@ export function slimToolResultForDisplay(result, maxRows = 400) {
81
86
  if (parsed?.images?.length) displayResult = parsed.text ?? result
82
87
  } catch { /* not JSON — show as-is */ }
83
88
  const rows = String(displayResult).split("\n").filter((l) => l.trim())
84
- return rows.length > maxRows
89
+ const capped = rows.length > maxRows
85
90
  ? [...rows.slice(0, maxRows), `… (result truncated at ${maxRows} rows — full text in history)`]
86
91
  : rows
92
+ return capLines(capped, TOOL_RESULT_MAX_CHARS)
93
+ }
94
+
95
+ /** 按块定位其载体行(字符账 re-account 用——块对象唯一)。 */
96
+ export function findToolLine(state, name, toolId) {
97
+ const block = findToolBlock(state, name, toolId)
98
+ if (!block) return null
99
+ for (const l of state.lines ?? []) if (l._toolBlock === block) return l
100
+ return null
87
101
  }
88
102
  /** Mark the dispatch-level tool carrier done when its result is consumed by a
89
103
  * dedicated branch (subagent/escalate/advisor blocks) — without this the turn
@@ -24,6 +24,7 @@ import {
24
24
  SUBAGENT_ROLES, routeSubToken, routeSubReasoning, routeSubToolCall,
25
25
  routeSubToolOutput, finishSubTask, finishSubTaskKey, finishSubTasksByRole, freezeDoneSubTasks,
26
26
  ensureCompressPanel, markCompressFailed, markCompressDone, markCompressFallback,
27
+ shiftFreezeAnchors,
27
28
  } from "./subagent-blocks.mjs"
28
29
  // 第 27 批 §12.3②:前缀正则换名 + import 源改文法模块(纯换名——语义零改)。
29
30
  import { RELAY_PREFIX_RE } from "../agent/relay-prefix.mjs"
@@ -34,14 +35,42 @@ import {
34
35
  TOOL_OUTPUT_LINE_CAP, REMINDER_CAP, REMINDER_PERSIST_TURNS,
35
36
  _toolTicks, _subActions, _subActionQ,
36
37
  tickStart, tickTake, settleToolBlock, isAsyncSpawnResult, isSpawnErrorResult,
37
- findToolBlock, slimToolResultForDisplay, sweepToolBlocks,
38
+ findToolBlock, findToolLine, slimToolResultForDisplay, sweepToolBlocks,
38
39
  } from "./tool-display.mjs"
39
40
  export { sweepToolBlocks, slimToolResultForDisplay } from "./tool-display.mjs"
41
+ // TUI-OOM-ROOTCAUSE(TUI.md §15.3.1/§15.3.3):显示层额度(行/载体/输出环/评审/流式)。
42
+ import {
43
+ capText, capLine, capLines, appendCapped, capAdvisorText, accountLine, syncLineBudget,
44
+ LINE_TRUNC_MARKER, MIDDLE_TRUNC_MARKER, ADVISOR_CAP_OPTS, STREAM_CAP_OPTS,
45
+ TOOL_OUTPUT_ENTRY_MAX_CHARS, TOOL_OUTPUT_TOTAL_MAX_CHARS, ADVISOR_TEXT_MAX_CHARS,
46
+ } from "./display-budget.mjs"
47
+
48
+ /** 输出环字符维总量(§15.3.1 TOOL_OUTPUT_TOTAL_MAX_CHARS——与 200 条目环同款丢最旧)。 */
49
+ function trimOutputChars(block) {
50
+ let total = 0
51
+ for (const s of block.output) total += s.length
52
+ while (total > TOOL_OUTPUT_TOTAL_MAX_CHARS && block.output.length > 0) {
53
+ total -= block.output.shift().length
54
+ }
55
+ }
40
56
 
41
57
  /** Build the agent callbacks + the shared flushStream for one turn.
42
58
  * askPermission, askQuestion, saveSessionImpl } */
43
59
  export function buildToolCallbacks(deps) {
44
60
  const { agent, state, pushLine, render, scheduleRender, ensureAssistantLabel, askPermission, askBatchPermission, askQuestion, saveSessionImpl } = deps
61
+ // 行集总量对账便捷式(§15.3.3——冻结锚点平移交 subagent 族函数)
62
+ const budgetSync = () => syncLineBudget(state, { onTrim: (st, n) => shiftFreezeAnchors(st, n) })
63
+ /** Advisor 有序块累积 + 额度(§15.3.1 ADVISOR_TEXT_MAX_CHARS——头 32K/尾 96K/中段标记)。 */
64
+ const pushAdvisorChunk = (raw, kind) => {
65
+ const blocks = state._advisorBlocks ??= []
66
+ const last = blocks.at(-1)
67
+ if (last && last.kind === kind) last.text = appendCapped(last.text, raw, ADVISOR_CAP_OPTS)
68
+ else blocks.push({ kind, text: appendCapped("", raw, ADVISOR_CAP_OPTS) })
69
+ // 多块总量:丢最旧块直至 ≤ 额度(裁决尾部优先)
70
+ let total = 0
71
+ for (const b of blocks) total += b.text.length
72
+ while (total > ADVISOR_TEXT_MAX_CHARS && blocks.length > 1) total -= blocks.shift().text.length
73
+ }
45
74
  // NOTE: advisor buffers (_advisorThink/advisorStreaming) are cleared here too.
46
75
  // Timing safety: onToolResult flushes _advisorThink into history and empties
47
76
  // the buffers BEFORE onTurnEnd can call flushStream (tool result is
@@ -69,7 +98,7 @@ export function buildToolCallbacks(deps) {
69
98
  // block content, never the main stream (D1). Routing details in subagent-blocks.
70
99
  if (routeSubToken(state, t, scheduleRender)) return
71
100
  ensureAssistantLabel()
72
- state.streaming += t
101
+ state.streaming = appendCapped(state.streaming, t, STREAM_CAP_OPTS) // §15.3.1 STREAM_MAX_CHARS
73
102
  scheduleRender()
74
103
  },
75
104
  onReasoning: (t) => {
@@ -77,7 +106,7 @@ export function buildToolCallbacks(deps) {
77
106
  // buffer as kind=think (F2: same treatment as the main reasoning stream).
78
107
  if (routeSubReasoning(state, t, scheduleRender)) return
79
108
  ensureAssistantLabel()
80
- state.reasoning += t
109
+ state.reasoning = appendCapped(state.reasoning, t, STREAM_CAP_OPTS) // §15.3.1 STREAM_MAX_CHARS
81
110
  scheduleRender()
82
111
  },
83
112
  onToolCall: (name, args, toolId) => {
@@ -139,6 +168,8 @@ export function buildToolCallbacks(deps) {
139
168
  done: false,
140
169
  },
141
170
  })
171
+ accountLine(state, state.lines[state.lines.length - 1]) // 载体入总量账(§15.3.4)
172
+ budgetSync()
142
173
  tickStart(name, toolId)
143
174
  },
144
175
  // §7.2.3(方案 e):dispatch runOne 把工具 ctx 上的 _subagentKey(sync spawn/
@@ -236,11 +267,13 @@ export function buildToolCallbacks(deps) {
236
267
  // screen of garbage (user report 2026-08-30). The model gets the image
237
268
  // via the multimodal channel (agent.mjs), the human needs only the
238
269
  // text part: strip image parts from the displayed result.
239
- block.result = slimToolResultForDisplay(result)
270
+ block.result = slimToolResultForDisplay(result) // §15.3.1 RESULT 额度(行 400 + 字符 64K 双维)
240
271
  block.summary = formatToolSummary(name, result)
241
272
  block.done = true
242
273
  const started = tickTake(name, toolId)
243
274
  block.elapsed = started !== null ? Math.round(performance.now() - started) : null
275
+ accountLine(state, findToolLine(state, name, toolId)) // 载体原地变更 → 重新入账
276
+ budgetSync()
244
277
  }
245
278
  }
246
279
  if (name === "advisor") {
@@ -255,17 +288,22 @@ export function buildToolCallbacks(deps) {
255
288
  // _advisorBlocks keep rendering the running view until cleared at turn end.
256
289
  const blocks = state._advisorBlocks ?? []
257
290
  if (blocks.length > 0) {
258
- const text = blocks
291
+ // 冻结文本额度(§15.3.1:头 32K + 尾 96K 保裁决尾部 + 中段标记——非静默截断,
292
+ // 代码评审 #3:capLines(...)[0] 会丢标记且截尾)
293
+ const text = capAdvisorText(blocks
259
294
  .map((b) => b.text.replaceAll(ADVISOR_THINKING_PLACEHOLDER, ""))
260
295
  .join("")
261
296
  .replace(/\n{3,}/g, "\n\n")
262
- .trim()
297
+ .trim())
263
298
  if (text) {
264
- state.lines.push({
299
+ const line = {
265
300
  text: "advisor review",
266
301
  color: C.dim,
267
302
  _frozenAdvisor: text,
268
- })
303
+ }
304
+ state.lines.push(line)
305
+ accountLine(state, line)
306
+ budgetSync()
269
307
  }
270
308
  }
271
309
  }
@@ -298,25 +336,26 @@ export function buildToolCallbacks(deps) {
298
336
  const isString = typeof chunk === "string"
299
337
  const raw = isString ? chunk : String(chunk?.text ?? "")
300
338
  const kind = isString ? "text" : (chunk?.kind ?? "text")
301
- const blocks = state._advisorBlocks ??= []
302
- const last = blocks.at(-1)
303
- if (last && last.kind === kind) last.text += raw
304
- else blocks.push({ kind, text: raw })
339
+ pushAdvisorChunk(raw, kind) // §15.3.1 累积额度(多块丢最旧)
305
340
  scheduleRender()
306
341
  return
307
342
  }
308
343
  // Append into the CURRENT tool block's output buffer (the block is the
309
344
  // display; no _live scroll lines anymore). N2-style cap keeps memory
310
- // bounded: keep the LAST 200 output lines per call.
345
+ // bounded: keep the LAST 200 output lines per call; 字符维双维
346
+ // (§15.3.1:单条目 ≤ TOOL_OUTPUT_ENTRY_MAX_CHARS、总量 ≤ TOOL_OUTPUT_TOTAL_MAX_CHARS 丢最旧)。
311
347
  const block = findToolBlock(state, name, toolId)
312
348
  if (block) {
313
349
  for (const line of part.text.split("\n")) {
314
350
  const trimmed = line.trimEnd()
315
- if (trimmed) block.output.push(trimmed)
351
+ if (trimmed) block.output.push(capText(trimmed, { max: TOOL_OUTPUT_ENTRY_MAX_CHARS, keepHead: 6_000, keepTail: 2_000, marker: MIDDLE_TRUNC_MARKER }))
316
352
  }
317
353
  if (block.output.length > TOOL_OUTPUT_LINE_CAP) {
318
354
  block.output.splice(0, block.output.length - TOOL_OUTPUT_LINE_CAP)
319
355
  }
356
+ trimOutputChars(block)
357
+ accountLine(state, findToolLine(state, name, toolId))
358
+ budgetSync()
320
359
  }
321
360
  scheduleRender()
322
361
  },
@@ -15,9 +15,16 @@ export function writeStartupSequence(write = (s) => process.stdout.write(s)) {
15
15
  write(ansi.altBuffer + ansi.hideCursor + ansi.mouseOn + ansi.bracketedPasteOn + ansi.keyboardPush + ansi.modifyOtherKeysOn + ansi.wrapOff)
16
16
  }
17
17
 
18
- /** TUI 清理序列:清屏 + 关闭鼠标/粘贴/键盘增强 + 退出 alt buffer + 显示光标 + 恢复环绕。 */
18
+ /** TUI 清理序列 = 异常退出恢复序列(单一来源——§9.2 表 2 候选 1):清屏 + 关闭鼠标/
19
+ * 粘贴/键盘增强 + 退出 alt buffer + 显示光标 + 恢复环绕。
20
+ * TUI-OOM-ROOTCAUSE(CRASH-REPORTS.md §9.3):提取为导出常量 `RECOVERY_SEQUENCE`——
21
+ * 包装父在子异常退出(V8 fatal——不走 JS 钩子)时补发同一序列(单一来源,禁第三份
22
+ * 副本);writeCleanupSequence 引用同常量(行为逐字不变——既有字节锁测试保持,AC-RT3)。 */
23
+ export const RECOVERY_SEQUENCE =
24
+ ansi.clearScreen + ansi.mouseOff + ansi.bracketedPasteOff + ansi.keyboardPop + ansi.modifyOtherKeysOff + ansi.mainBuffer + ansi.showCursor + ansi.reset + ansi.wrapOn
25
+
19
26
  export function writeCleanupSequence(write = (s) => process.stdout.write(s)) {
20
- write(ansi.clearScreen + ansi.mouseOff + ansi.bracketedPasteOff + ansi.keyboardPop + ansi.modifyOtherKeysOff + ansi.mainBuffer + ansi.showCursor + ansi.reset + ansi.wrapOn)
27
+ write(RECOVERY_SEQUENCE)
21
28
  }
22
29
 
23
30
  // R25(ARCHITECTURE.md §R25 F-R25a——复审 #1):TUI 活动态标志——index.mjs 在终端接管
@@ -6,12 +6,13 @@ import { appendFileSync, mkdirSync, writeFileSync } from "node:fs"
6
6
  import { spawn } from "node:child_process"
7
7
  import { fileURLToPath } from "node:url"
8
8
  import { crashReportsDir } from "../crash-reports.mjs"
9
+ import { RECOVERY_SEQUENCE } from "./tui-lifecycle.mjs"
9
10
 
10
11
  // F-3 信号语义(raw mode 既有 key-handler 双按语义——Ctrl+C = stdin 字节不产生信号 → 子正常退 → 父收
11
12
  // exit 同码退):父忽略 SIGINT/SIGTERM——tee 不被打断。2026-09-09 实测:Windows process.kill(SIGINT)
12
13
  // = 硬杀 ≠ 控制台 Ctrl+C 事件(handler 不触发)——信号面测试走 mock(真控制台端到端留发布前手动 QA)。
13
14
  export const ignoreSignal = () => {} // no-op 单一引用——父信号 + stderr-error 监听共用——测试可精确复原
14
- export function spawnTuiWrapped({ dir = crashReportsDir(), script = fileURLToPath(new URL("../../bin/thincoder.mjs", import.meta.url)), spawnImpl = spawn, exitImpl = (code) => process.exit(code) } = {}) {
15
+ export function spawnTuiWrapped({ dir = crashReportsDir(), script = fileURLToPath(new URL("../../bin/thincoder.mjs", import.meta.url)), spawnImpl = spawn, exitImpl = (code) => process.exit(code), writeImpl = (s) => process.stdout.write(s) } = {}) {
15
16
  // ① mkdir 前置(评审 #2——prepareCrashReporting 只在子内跑——首启目录缺失会静默不包装)+ 开日志
16
17
  //(文件头元信息行:时间/pid/argv——F-2——0600 append)——失败 → false:不包装直接跑现逻辑(尽力面)
17
18
  let logPath = null
@@ -25,14 +26,29 @@ export function spawnTuiWrapped({ dir = crashReportsDir(), script = fileURLToPat
25
26
  const note = (s) => { try { appendFileSync(logPath, s) } catch { /* 落盘尽力面——不阻断 tee */ } }
26
27
  let exitCode = null, exitSignal = null, settled = false
27
28
  const finish = (code) => { if (!settled) { settled = true; exitImpl(code) } }
28
- const child = spawnImpl(process.execPath, [script, ...process.argv.slice(2)], {
29
+ // TUI-OOM-ROOTCAUSE(CRASH-REPORTS.md §9.3):子异常退出(V8 fatal——不走 JS 钩子)→
30
+ // 包装父补发恢复序列(唯一存活方;序列单一来源 = tui-lifecycle.RECOVERY_SEQUENCE)。
31
+ // 守卫落点 = exit/close 处置内(**不在共享 finish**——spawn error 路径经 error→finish(1),
32
+ // 字面挂 finish 会在子未启动时补发 clearScreen 序列,违 F5③);按 exitCode/exitSignal
33
+ // 判定(code !== 0 || signal != null);异常退出零动作;正常退出(code 0)零干预;
34
+ // 每进程恰一次(recovered 守卫,exit/close 双路 + 30s 兜底路径同守);写失败不阻断收尾。
35
+ let recovered = false
36
+ const recoverTerminal = (code, signal) => {
37
+ if (recovered) return
38
+ if (!(code !== 0 || signal != null)) return
39
+ recovered = true
40
+ try { writeImpl(RECOVERY_SEQUENCE) } catch { /* 尽力面:终端已关/管道已断——不阻断收尾 */ }
41
+ }
42
+ // F3③(CRASH-REPORTS):近堆上限快照落点定向——--diagnostic-dir 为 Node 选项,须在脚本路径前;
43
+ // 无条件注入(gate 单点在 crash-reports.mjs——包装器不判 env);未触发快照时零副作用。
44
+ const child = spawnImpl(process.execPath, [`--diagnostic-dir=${dir}`, script, ...process.argv.slice(2)], {
29
45
  stdio: ["inherit", "inherit", "pipe"], // 子 stderr pipe → tee;stdin/stdout 继承(TTY 原样)
30
46
  env: { ...process.env, THINCODER_TUI_WRAPPED: "1" }, // env 门——子内判定不包装——纯现逻辑(红线)
31
47
  windowsHide: false,
32
48
  })
33
49
  child.stderr.on("data", (chunk) => { try { process.stderr.write(chunk) } catch { /* 终端已关——日志仍落 */ } note(chunk) }) // ② tee 双写:终端实时 + 日志
34
- child.on("exit", (code, signal) => { exitCode = code; exitSignal = signal; setTimeout(() => finish(exitCode ?? (exitSignal ? 1 : 0)), 30_000).unref() }) // ③ exit 记码;兜底 30s 强退
35
- child.on("close", () => finish(exitCode ?? (exitSignal ? 1 : 0))) // ④ close = stderr 尾数据全收(AC-2)→ 同码退
36
- child.on("error", (err) => { note(`# spawn error: ${err.message}\n`); finish(1) }) // ⑤ spawn 失败不发 exit 只发 error
50
+ child.on("exit", (code, signal) => { exitCode = code; exitSignal = signal; recoverTerminal(code, signal); setTimeout(() => { recoverTerminal(exitCode, exitSignal); finish(exitCode ?? (exitSignal ? 1 : 0)) }, 30_000).unref() }) // ③ exit 记码;异常退出→补发恢复;兜底 30s 强退
51
+ child.on("close", () => { recoverTerminal(exitCode, exitSignal); finish(exitCode ?? (exitSignal ? 1 : 0)) }) // ④ close = stderr 尾数据全收(AC-2)→ 同码退
52
+ child.on("error", (err) => { note(`# spawn error: ${err.message}\n`); finish(1) }) // ⑤ spawn 失败不发 exit 只发 error(零序列动作——F5③)
37
53
  return true
38
54
  }