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
@@ -1,4 +1,4 @@
1
- import { listSlots, switchToSlot, applySession, renameSlot, activeSlot, slotOccupancy, readEndMarker } from "../session.mjs"
1
+ import { listSlots, switchToSlot, applySession, renameSlot, activeSlot, slotOccupancy, readEndMarker, sessionDescriptor } from "../session.mjs"
2
2
  import { ansi, C } from "./ansi.mjs"
3
3
  import { restoreLines } from "./startup.mjs"
4
4
  import { stringWidth, sliceByWidth } from "./render.mjs"
@@ -89,14 +89,18 @@ export async function handleSessionCommand(ctx) {
89
89
  if (occ.occupied) {
90
90
  pushLine(`⚠ Slot ${e.slot} is being used by another live process (${occ.owner}) — continuing here will create a new copy on the next save`, C.warn)
91
91
  }
92
- applySession(agent, data)
92
+ applySession(agent, data, occ.occupied ? {} : { slot: e.slot })
93
93
  // Rebuild from history (lazy) — the display snapshot is deprecated.
94
+ // §14.3.6:描述符 { history(尾窗+±1), total, base }——标签口径 = total(非窗口长度);
95
+ // 未绑定(占用分支——模式 F)回退全量数组。
96
+ const desc = sessionDescriptor(agent, data)
94
97
  state.lines = []
95
- restoreLines(state, data.history)
98
+ state._linesChars = 0 // TUI-OOM-ROOTCAUSE(§15.3.2):切槽重建行集 → 字符账归零,restoreLines 重新入账
99
+ restoreLines(state, desc)
96
100
  state.tasks = agent.tasks ?? []
97
101
  if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
98
102
  state.tasks = []
99
103
  }
100
- pushLabel(`── Switched to slot ${e.slot} (${data.history.length} messages) ──`, C.warn)
104
+ pushLabel(`── Switched to slot ${e.slot} (${desc.total} messages) ──`, C.warn)
101
105
  render()
102
106
  }
@@ -0,0 +1,184 @@
1
+ /**
2
+ * display-budget.mjs — TUI 显示层额度(TUI-OOM-ROOTCAUSE 批——TUI.md §15.3)。
3
+ *
4
+ * 职责(§15.3.2):常量单源(D-TB4)+ 行/载体计长(`lineChars`)+ `state.lines` 总量
5
+ * 对账(`syncLineBudget`)。纯函数本体(`capText` / `appendCappedText`)住
6
+ * `src/text-budget.mjs`(零依赖)——与 agent 侧捕获共用(AGENT-LOOP.md §23.3.1,D2 单源);
7
+ * 本模块只承载 TUI 面常量与对账。
8
+ *
9
+ * 依赖方向:本模块只引 `./ansi.mjs` + `../text-budget.mjs`(叶子——不引 subagent 族,
10
+ * 冻结锚点平移由调用方经 `onTrim` 注入,避环)。
11
+ *
12
+ * 计量口径 = UTF-16 码元(`String.length`——TUI.md §15.2 表 1);行数维(5000 行环 /
13
+ * 500 行块环 / 200 条目环)**原样保留**,字符维为第二维(D-TB5——既有 N5/N6 语义不动)。
14
+ */
15
+ import { C } from "./ansi.mjs"
16
+ import { capText, appendCappedText, fillMarker } from "../text-budget.mjs"
17
+
18
+ export { capText }
19
+
20
+ // ─── 常量(§15.3.1——单源;数值改动一处生效)────────────────────────────────
21
+
22
+ /** 单行(pushLine/pushLabel/恢复行/流式 flush 行)字符额度。 */
23
+ export const LINE_MAX_CHARS = 64_000
24
+ /** 工具块 `argsJson` 总量。 */
25
+ export const ARGS_JSON_MAX_CHARS = 24_000
26
+ /** 工具块 `result` 行数组总量(与既有 400 行双维)。 */
27
+ export const TOOL_RESULT_MAX_CHARS = 64_000
28
+ /** 输出环单条目(超出:尾截断 + 标记)。 */
29
+ export const TOOL_OUTPUT_ENTRY_MAX_CHARS = 8_000
30
+ /** 输出环总量(超出:与既有 200 条目环同款丢最旧)。 */
31
+ export const TOOL_OUTPUT_TOTAL_MAX_CHARS = 128_000
32
+ /** 子代理块单环(与 500 显示行双维;裁最旧——省略标记 N6 语义不变)。 */
33
+ export const SUB_BLOCK_CHAR_LIMIT = 128_000
34
+ /** 评审载体(`_advisorBlocks` 累积 + `_frozenAdvisor`):头 32K + 尾 96K + 中段标记。 */
35
+ export const ADVISOR_TEXT_MAX_CHARS = 128_000
36
+ /** 流式缓冲(`state.streaming` / `state.reasoning`)累积额度。 */
37
+ export const STREAM_MAX_CHARS = 256_000
38
+ /** `state.lines` 全部行与载体文本总量(超出:与 5000 行环同款裁头 1000 行 + 收据行)。 */
39
+ export const LINES_CHAR_BUDGET = 2_000_000
40
+ /** 搜索匹配计数上限(超出截断 + 提示行)。 */
41
+ export const SEARCH_MATCH_CAP = 10_000
42
+
43
+ // ─── 标记形态(逐字——§15.3.2 进测试断言)──────────────────────────────────
44
+
45
+ export const LINE_TRUNC_MARKER = "… [line truncated: N chars omitted]"
46
+ export const MIDDLE_TRUNC_MARKER = "… [middle truncated: N chars omitted]"
47
+
48
+ /** 评审/流式额度头尾分配(头 32K + 尾 96K——保裁决尾部;与常量同基数)。 */
49
+ export const ADVISOR_CAP_OPTS = {
50
+ hard: ADVISOR_TEXT_MAX_CHARS,
51
+ head: 32_000,
52
+ tail: 96_000,
53
+ marker: MIDDLE_TRUNC_MARKER,
54
+ }
55
+ /** 流式累积额度(头/尾各半——flush 行再受 LINE_MAX_CHARS)。 */
56
+ export const STREAM_CAP_OPTS = {
57
+ hard: STREAM_MAX_CHARS,
58
+ head: Math.floor(STREAM_MAX_CHARS / 4),
59
+ tail: Math.floor(STREAM_MAX_CHARS / 2),
60
+ marker: MIDDLE_TRUNC_MARKER,
61
+ }
62
+ /** 行截断(单行巨内容场景):保留头部至 `LINE_MAX_CHARS` + 尾标记(行尾截断语义)。 */
63
+ export function capLine(text) {
64
+ return capText(text, {
65
+ max: LINE_MAX_CHARS,
66
+ keepHead: LINE_MAX_CHARS,
67
+ keepTail: 0,
68
+ marker: LINE_TRUNC_MARKER,
69
+ })
70
+ }
71
+
72
+ /** TUI 侧流式累积(alias——本体 text-budget.appendCappedText,D2 单源)。 */
73
+ export function appendCapped(prev, add, opts) {
74
+ return appendCappedText(prev, add, opts)
75
+ }
76
+
77
+ /** 评审文本单次截断(§15.3.1:头 32K + 尾 96K 保裁决尾部 + 中段标记——与累积侧同源)。 */
78
+ export function capAdvisorText(text) {
79
+ return capText(text, { max: ADVISOR_TEXT_MAX_CHARS, keepHead: 32_000, keepTail: 96_000, marker: MIDDLE_TRUNC_MARKER })
80
+ }
81
+
82
+ /**
83
+ * 行数组总量额度(尾截断 + 标记;首行保真——ARGS_JSON/TOOL_RESULT 双维用)。
84
+ * 行放不下时:可放部分 → 本行头截断;无空间 → 直接以标记行收尾。
85
+ * @returns {string[]} 原数组或(截断 + 标记行)新数组
86
+ */
87
+ export function capLines(lines, max, marker = MIDDLE_TRUNC_MARKER) {
88
+ const arr = Array.isArray(lines) ? lines : []
89
+ if (arr.length === 0) return arr
90
+ let total = 0
91
+ for (const s of arr) total += typeof s === "string" ? s.length : 0
92
+ if (total <= max) return arr
93
+ const out = []
94
+ let used = 0
95
+ for (const s of arr) {
96
+ const str = typeof s === "string" ? s : String(s ?? "")
97
+ const remain = max - used
98
+ if (str.length <= remain) { out.push(str); used += str.length; continue }
99
+ if (remain > 0) {
100
+ const kept = str.slice(0, remain)
101
+ out.push(kept)
102
+ used += kept.length
103
+ }
104
+ break
105
+ }
106
+ out.push(fillMarker(marker, total - used))
107
+ return out
108
+ }
109
+
110
+ // ─── 计长与总量对账(§15.3.2 / §15.3.4)───────────────────────────────────
111
+
112
+ const arrChars = (a) => {
113
+ if (!Array.isArray(a)) return 0
114
+ let n = 0
115
+ for (const s of a) n += typeof s === "string" ? s.length : 0
116
+ return n
117
+ }
118
+
119
+ /** 一行 + 其 `_toolBlock`/`_frozenSubTask`/`_frozenAdvisor` 字段计长(单一口径)。 */
120
+ export function lineChars(l) {
121
+ if (!l) return 0
122
+ let n = typeof l.text === "string" ? l.text.length : 0
123
+ const b = l._toolBlock
124
+ if (b) {
125
+ n += typeof b.argsSummary === "string" ? b.argsSummary.length : 0
126
+ n += typeof b.summary === "string" ? b.summary.length : 0
127
+ n += arrChars(b.argsJson) + arrChars(b.output) + arrChars(b.result)
128
+ }
129
+ if (l._frozenSubTask) n += l._frozenSubTask._charCount ?? 0
130
+ if (typeof l._frozenAdvisor === "string") n += l._frozenAdvisor.length
131
+ return n
132
+ }
133
+
134
+ /** 行增量记账(幂等——行对象上存上次计入值 `_budgetChars`;载体原地变更后重调即自愈)。 */
135
+ export function accountLine(state, l) {
136
+ if (!l) return
137
+ const prev = Number.isFinite(l._budgetChars) ? l._budgetChars : 0
138
+ const next = lineChars(l)
139
+ state._linesChars = Math.max(0, (state._linesChars ?? 0) + (next - prev))
140
+ l._budgetChars = next
141
+ }
142
+
143
+ /** 直算对账(双算法对照 / 初始化 / 漂移自愈——测试直算同源)。 */
144
+ export function accountAll(state) {
145
+ let total = 0
146
+ for (const l of state.lines ?? []) {
147
+ const n = lineChars(l)
148
+ l._budgetChars = n
149
+ total += n
150
+ }
151
+ state._linesChars = total
152
+ return total
153
+ }
154
+
155
+ /**
156
+ * `state.lines` 总量对账(§15.3.3 落点表末行):超 `LINES_CHAR_BUDGET` → 与 5000 行环
157
+ * 同款裁头 1000 行 + 收据行;`onTrim(state, removedCount)` 由调用方注入冻结锚点平移
158
+ * (display-budget 不引 subagent 族——避环)。幂等(未超限零动作)。
159
+ * @returns {number} 本轮裁掉的行数
160
+ */
161
+ export function syncLineBudget(state, { pushLineLike = null, onTrim = null } = {}) {
162
+ const lines = state.lines
163
+ if (!Array.isArray(lines)) return 0
164
+ if (!Number.isFinite(state._linesChars)) accountAll(state)
165
+ let removed = 0
166
+ while (state._linesChars > LINES_CHAR_BUDGET && lines.length > 1) {
167
+ const take = Math.min(1000, lines.length - 1)
168
+ const dropped = lines.splice(0, take)
169
+ let freed = 0
170
+ for (const l of dropped) freed += lineChars(l)
171
+ state._linesChars = Math.max(0, state._linesChars - freed)
172
+ removed += take
173
+ const receipt = `... [earlier messages trimmed — ${lines.length} lines remaining]`
174
+ if (typeof pushLineLike === "function") pushLineLike(receipt)
175
+ else {
176
+ const line = { text: receipt, color: C.dim }
177
+ line._budgetChars = receipt.length
178
+ lines.unshift(line)
179
+ state._linesChars += receipt.length
180
+ }
181
+ if (typeof onTrim === "function") onTrim(state, take)
182
+ }
183
+ return removed
184
+ }
package/src/tui/index.mjs CHANGED
@@ -37,8 +37,10 @@ import { runAgentTurn } from "./agent-turn.mjs"
37
37
  import { createKeyHandler, convMaxScroll, clearAttention } from "./key-handler.mjs"
38
38
  import { showStartup, backgroundIndex, createLoadOlder } from "./startup.mjs"
39
39
  import { shiftFreezeAnchors } from "./subagent-blocks.mjs"
40
+ import { capLine, accountLine, accountAll, syncLineBudget } from "./display-budget.mjs"
40
41
  import { createConfigHelpers } from "./config-helpers.mjs"
41
42
  import { createUpdateNotice, pendingNoticeReady } from "./update-notice.mjs"
43
+ import { startLedgerSurface } from "./ledger-surface.mjs"
42
44
 
43
45
  export { upgradeFailureText, pendingNoticeReady } from "./update-notice.mjs"
44
46
 
@@ -115,6 +117,7 @@ export async function startTUI(agent, opts = {}) {
115
117
  currentTool: null, // currently executing tool name (shown in status bar)
116
118
  processingStarted: 0, // current turn start time (status bar timer)
117
119
  status: "Ready",
120
+ ledger: { marker: null, warn: false, scannedAt: 0 }, // LEDGER-SURFACE(§2.30.3.4):L1 标记位——ledger-surface 写 / render-frame 读
118
121
  queue: [], // 交接残项单容器 [{ text }](INPUT-LOCK:submit 不再排队——仅释放窗口兜底/挂起中止残余——回合尾队列循环续发,至多一条)
119
122
  interruptPrompt: null, // Ctrl+I inject box(第 31 批——TUI-INPUT-BOX.md §8): { chars: string[], cursor: number } or null
120
123
  attentionAwaiting: false, // 第 33 批(TUI §14.3(f)):回合结束等待输入——链尾置位 / 用户输入清位;不落盘、不进会话
@@ -127,6 +130,7 @@ export async function startTUI(agent, opts = {}) {
127
130
  _historyLoaded: 0, // messages loaded from the TAIL of _fullHistory
128
131
  _historyTotal: 0, // total messages in the restored session
129
132
  _hasOlder: false, // more earlier messages remain unloaded
133
+ _linesChars: 0, // TUI-OOM-ROOTCAUSE(§15.3.2):state.lines 全部行与载体文本总量(字符账——唯一新增状态位)
130
134
  _agent: null, // TUI state → agent 回指挂载点:startTUI 装配时置 agent 引用——
131
135
  // ① SYNC-CANCEL ⏹ 门控读 state._agent._syncChildAborts(subagent-panel.mjs,2026-09-09);
132
136
  // ② CLI-ACTIVITY-DEBLOAT F-3(2026-09-10):agent._tuiState = state 反向挂载——
@@ -276,7 +280,11 @@ export async function startTUI(agent, opts = {}) {
276
280
  process.on("exit", cleanup)
277
281
 
278
282
  const pushLine = (text, color, kind) => {
279
- state.lines.push({ text, color, _kind: kind })
283
+ // TUI-OOM-ROOTCAUSE(TUI.md §15.3.1/§15.3.3):行额度单点——行文本过 capLine
284
+ // (LINE_MAX_CHARS——单行巨内容/无换行巨 chunk 的堵口)+ 总量账 + 预算对账。
285
+ const line = { text: capLine(text), color, _kind: kind }
286
+ state.lines.push(line)
287
+ accountLine(state, line)
280
288
  if (state.lines.length > 5000) {
281
289
  state.lines.splice(0, 1000)
282
290
  state.lines.unshift({ text: `... [earlier messages trimmed — ${state.lines.length} lines remaining]`, color: C.dim })
@@ -284,14 +292,24 @@ export async function startTUI(agent, opts = {}) {
284
292
  // 流位置)——不校正则池空补发冻结(freezeSubTaskLines splice)落点漂移;校正量
285
293
  // = 净位移(裁 1000 补 1 标记行 → −999,code review round1 #3)。
286
294
  shiftFreezeAnchors(state, 1000)
295
+ accountAll(state) // 行对象集合已变(splice/unshift 直写)——直算重对账
287
296
  }
297
+ syncLineBudget(state, { onTrim: (st, n) => shiftFreezeAnchors(st, n) })
288
298
  render()
289
299
  }
290
300
 
291
301
  /** Message block label: blank line + label line. Breathing space between user/assistant messages */
292
302
  const pushLabel = (text, color) => {
293
- if (state.lines.length > 0) state.lines.push({ text: "", color: C.dim })
294
- state.lines.push({ text, color })
303
+ // 行额度同 pushLine(§15.3.1——恢复行/标签行同口径)
304
+ if (state.lines.length > 0) {
305
+ const blank = { text: "", color: C.dim }
306
+ state.lines.push(blank)
307
+ accountLine(state, blank)
308
+ }
309
+ const line = { text: capLine(text), color }
310
+ state.lines.push(line)
311
+ accountLine(state, line)
312
+ syncLineBudget(state, { onTrim: (st, n) => shiftFreezeAnchors(st, n) })
295
313
  render()
296
314
  }
297
315
 
@@ -316,6 +334,13 @@ export async function startTUI(agent, opts = {}) {
316
334
  // 声明在此(render 已可用)——data 回调(滚轮分支)与 createKeyHandler ctx(PgUp)共用。
317
335
  const loadOlder = createLoadOlder({ agent, state, render })
318
336
 
337
+ // TUI-OOM-ROOTCAUSE(CRASH-REPORTS.md §8.3 订阅接线):堆预警行入对话流(pushLine 已 render)——
338
+ // 不新增 TUI 定时器(采样定时器住 heap-watch 模块,bin 入口武装);订阅失败不影响启动。
339
+ try {
340
+ const { onHeapWarn } = await import("../heap-watch.mjs")
341
+ onHeapWarn((line) => pushLine(line, C.warn))
342
+ } catch { /* 尽力面 */ }
343
+
319
344
  // Resize events are genuine dimension changes on every terminal (2026-08-31
320
345
  // simplification: the earlier settle-timer/double-confirm machinery was built
321
346
  // on the misdiagnosed ConPTY-stale hypothesis and even stalled drag-shrink).
@@ -438,6 +463,9 @@ export async function startTUI(agent, opts = {}) {
438
463
  await promptProviderIfInvalid(agent, () => openModelPicker(), pushLine)
439
464
 
440
465
  showStartup({ agent, state, opts, pushLine, pushLabel, render, startWizard })
466
+ // LEDGER-SURFACE(§2.30.3.4):台账可见面——首扫(setImmediate)+ 周期;dispose 挂进程退出(:278 cleanup 先例)
467
+ const ledgerSurface = startLedgerSurface({ state, agent, pushLine, render })
468
+ process.on("exit", () => ledgerSurface.dispose())
441
469
  backgroundIndex({ agent, state, render })
442
470
 
443
471
  // Check for updates (non-blocking, after startup screen)——实现 update-notice.mjs
@@ -1,20 +1,28 @@
1
- /** Perform search on state.lines, update state.search.matches and clamp index */
1
+ /** Perform search on state.lines, update state.search.matches and clamp index
2
+ * TUI-OOM-ROOTCAUSE(TUI.md §15.3.1 SEARCH_MATCH_CAP):匹配计数上限——超限截断 +
3
+ * `state.search.capped` 提示标志(渲染面提示行;次要无界面)。
4
+ */
5
+ import { SEARCH_MATCH_CAP } from "./display-budget.mjs"
6
+
2
7
  export function performSearch(state) {
3
8
  if (!state.search) return
4
9
  const query = state.search.query.toLowerCase()
5
10
  state.search.matches = []
11
+ state.search.capped = false
6
12
  // Clear old highlights
7
13
  state.lines.forEach(l => delete l._searchMatches)
8
14
 
9
15
  if (!query) { state.search.index = 0; return }
10
16
 
11
17
  state.lines.forEach((line, lineIndex) => {
18
+ if (state.search.matches.length >= SEARCH_MATCH_CAP) { state.search.capped = true; return }
12
19
  const text = (line.text || "").toLowerCase()
13
20
  let charIndex = text.indexOf(query)
14
21
  if (charIndex !== -1) {
15
22
  line._searchMatches = []
16
23
  }
17
24
  while (charIndex !== -1) {
25
+ if (state.search.matches.length >= SEARCH_MATCH_CAP) { state.search.capped = true; break }
18
26
  state.search.matches.push({ lineIndex, charIndex })
19
27
  line._searchMatches.push(charIndex)
20
28
  charIndex = text.indexOf(query, charIndex + 1)
@@ -0,0 +1,69 @@
1
+ /**
2
+ * ledger-surface.mjs — TUI 台账可见面胶水(LEDGER-SURFACE 批——设计档 §2.30.3.4)。
3
+ *
4
+ * 三面:① 启动行(首扫、**任一项目可动作**才出——明细行集每项目一行)② 变化行(老化首次
5
+ * 越线 / 池达阈值——一次性去重 + **送达门**:未送达不记账)③ 状态位(`state.ledger`——L1
6
+ * 常驻标记,`render-frame.mjs` 消费;**不受启动行门约束**——F2 按标记态照显)。
7
+ * 只读台账;唯一写面 = 去重档(`~/.thincoder/ledger-notify.json`)。
8
+ */
9
+ import { blameAges, detailScans, discoverFamily, formatDetailLine, formatMarker, loadNotifyState, NOTIFY_FILE, notifyKey, planChangeLines, REFRESH_MS, saveNotifyState, summarizeLedger } from "../ledger.mjs"
10
+ import { C } from "./ansi.mjs"
11
+
12
+ /** 单次扫描(直驱面——timer 包装见 `startLedgerSurface`)。
13
+ * `startup=true` = 会话首扫(补启动行;变化行在前、明细行在后)。 */
14
+ export function runLedgerScan({ state, agent = null, anchor = null, notifyFile = NOTIFY_FILE, pushLine = () => {}, render = () => {}, startup = false, ageOf = blameAges } = {}) {
15
+ const base = anchor ?? agent?.cwd ?? process.cwd()
16
+ const family = discoverFamily(base)
17
+ const scans = []
18
+ for (const p of family.projects) {
19
+ try { scans.push(summarizeLedger(p, { ageOf })) } catch { /* 台账不可读 → 该项目跳过(余者照常——N1/T110①) */ }
20
+ }
21
+ const current = family.current ? scans.find((s) => s.root === family.current.root) ?? null : null
22
+ const notify = loadNotifyState(notifyFile)
23
+ const plans = scans.map((s) => ({ s, plan: planChangeLines(notify.ledgers[notifyKey(s.ledger)], s) }))
24
+ const changeLines = plans.flatMap(({ plan }) => plan.lines)
25
+ const showStartup = startup && scans.some((s) => s.actionable)
26
+ const rows = showStartup
27
+ ? [...changeLines, ...detailScans(scans, current).map((s) => ({ text: formatDetailLine(s), warn: s.actionable }))]
28
+ : changeLines
29
+ let delivered = true
30
+ for (const line of rows) {
31
+ try { pushLine(line.text, line.warn ? C.warn : C.dim) } catch { delivered = false; break }
32
+ }
33
+ // 送达门(F5):有事件行 → 仅在送达后记账;无事件行 → 仅状态确有变化时静默同步(跌回 false 即重置)
34
+ const changed = plans.some(({ s, plan }) => {
35
+ const prev = notify.ledgers[notifyKey(s.ledger)]
36
+ return !prev || JSON.stringify(prev.aged ?? []) !== JSON.stringify(plan.next.aged) || Boolean(prev.threshold) !== plan.next.threshold
37
+ })
38
+ if (changeLines.length ? delivered : changed) {
39
+ const ledgers = { ...notify.ledgers }
40
+ for (const { s, plan } of plans) ledgers[notifyKey(s.ledger)] = { ...plan.next, updatedAt: Date.now() }
41
+ saveNotifyState(notifyFile, { version: 1, ledgers })
42
+ }
43
+ state.ledger = { marker: current ? formatMarker(current) : null, warn: Boolean(current && current.aged > 0), scannedAt: Date.now() }
44
+ render()
45
+ }
46
+
47
+ /** TUI 挂载:首扫(`setImmediate`——不抢首帧)+ 周期(`REFRESH_MS`;`state.processing` 期间跳过本轮)。 */
48
+ export function startLedgerSurface(ctx = {}) {
49
+ let disposed = false
50
+ let timer = null
51
+ const tick = (startup) => {
52
+ if (disposed) return
53
+ if (!startup && ctx.state?.processing) return // git 子进程不打断回合帧
54
+ try { runLedgerScan({ ...ctx, startup }) } catch { /* 可见面尽力——不崩 TUI(N1) */ }
55
+ }
56
+ setImmediate(() => {
57
+ if (disposed) return
58
+ tick(true)
59
+ timer = setInterval(() => tick(false), REFRESH_MS)
60
+ timer.unref?.()
61
+ })
62
+ return {
63
+ dispose() {
64
+ disposed = true
65
+ if (timer) clearInterval(timer)
66
+ timer = null
67
+ },
68
+ }
69
+ }
@@ -393,5 +393,11 @@ function buildStatusLine(state, agent, { cols, slashCommands }) {
393
393
  // INPUT-LOCK-ASYNC(C'——F-3):busy(processing 含 digest)状态栏提示——取代旧
394
394
  // 排队提示(F-7 已删)——“主会话处理中——Enter 提交禁用”
395
395
  const enterHint = state.processing ? `主会话处理中 — Enter 提交禁用(字符可输入,回合结束请重按 Enter)` : "Enter: send"
396
- return ` ${statusText}${taskHint}${turnHint}${tokenHint}${ctxHint}${scrollHint} │ ${enterHint} │ /: commands │ wheel/PgUp/PgDn: scroll │ Ctrl+I: inject │ Ctrl+C: exit (×2)`
396
+ // LEDGER-SURFACE(§2.30.3.3/§2.30.3.4):L1 常驻标记——状态段簇尾(scrollHint 后、键位组前);
397
+ // 空标记零注入(半态逐字节等价——同 :233-238 纪律);warn = 当前项目老化 > 0(警示色段包裹)
398
+ const lg = state.ledger
399
+ const ledgerHint = lg?.marker
400
+ ? ` │ ${lg.warn ? `${ansi.reset}${C.warn}${lg.marker}${ansi.reset}${ansi.dim}` : lg.marker}`
401
+ : ""
402
+ return ` ${statusText}${taskHint}${turnHint}${tokenHint}${ctxHint}${scrollHint}${ledgerHint} │ ${enterHint} │ /: commands │ wheel/PgUp/PgDn: scroll │ Ctrl+I: inject │ Ctrl+C: exit (×2)`
397
403
  }
@@ -3,6 +3,9 @@ import { ansi, C } from "./ansi.mjs"
3
3
  import { describeToolArgs, toolArgsLines } from "./tool-args.mjs"
4
4
  import { slimToolResultForDisplay } from "./tool-events.mjs"
5
5
  import { countConvLines } from "./render-conversation.mjs"
6
+ // TUI-OOM-ROOTCAUSE(TUI.md §15.3.3):恢复/翻页行过额度 + state.lines 总量对账。
7
+ import { capLine, accountLine, accountAll, syncLineBudget } from "./display-budget.mjs"
8
+ import { shiftFreezeAnchors } from "./subagent-blocks.mjs"
6
9
 
7
10
  /** Lazy history window (parity with VS Code HISTORY_PAGE_SIZE): first paint loads
8
11
  * the latest INITIAL_HISTORY_MESSAGES, then PgUp-at-top loads HISTORY_PAGE_MESSAGES
@@ -115,24 +118,34 @@ export function historyToLines(history, startIdx, endIdx) {
115
118
  * INITIAL_HISTORY_MESSAGES, set the _history* counters the loadOlder closure
116
119
  * reads, and prepend the "… N more earlier messages" placeholder. Shared by
117
120
  * startup restore and /session switching (the display snapshot is deprecated).
121
+ * TUI-OOM-ROOTCAUSE(SESSION.md §14.3.6):desc = `{ history, total, base? }`——
122
+ * history = 尾窗(≤200,含 ±1 页沿头一条——跨页回标判定用;不渲染)、total = 绝对总条数
123
+ * (「N messages」标签口径)、base = history[0] 的绝对序号(缺省由 total − len 推导)。
118
124
  */
119
- export function restoreLines(state, history) {
120
- const total = Array.isArray(history) ? history.length : 0
121
- if (total === 0) return
122
- const start = Math.max(0, total - INITIAL_HISTORY_MESSAGES)
125
+ export function restoreLines(state, desc) {
126
+ const window = Array.isArray(desc?.history) ? desc.history : (Array.isArray(desc) ? desc : [])
127
+ const total = Number.isFinite(desc?.total) ? desc.total : window.length
128
+ if (window.length === 0 || total === 0) return
129
+ const base = Number.isFinite(desc?.base) ? desc.base : Math.max(0, total - window.length)
130
+ // 已载入量 = 渲染窗口大小(±1 页沿头一条不计数),绝对起点 = total − loaded
131
+ const loaded = Math.min(INITIAL_HISTORY_MESSAGES, total)
132
+ const start = total - loaded
123
133
  state._lineIdCounter = state._lineIdCounter ?? 0
124
- const fresh = historyToLines(history, start, total)
134
+ const fresh = historyToLines(window, Math.max(0, start - base), window.length)
135
+ for (const l of fresh) l.text = capLine(l.text) // 行额度(§15.3.3 恢复行过 capText)
125
136
  // Stable per-line ids (P1, 2026-08-30): fold keys for tool blocks derive from
126
137
  // _lineId so loadOlder's head-unshift cannot re-bind an expanded block to a
127
138
  // different tool (positional tool-{i} keys drift under unshift).
128
139
  for (const l of fresh) l._lineId = ++state._lineIdCounter
129
140
  state.lines.push(...fresh)
130
- state._historyLoaded = total - start
141
+ state._historyLoaded = loaded
131
142
  state._historyTotal = total
132
143
  state._hasOlder = start > 0
133
144
  if (state._hasOlder) {
134
145
  state.lines.unshift({ text: `… ${start} more earlier messages (PgUp at top to load)`, color: C.dim })
135
146
  }
147
+ accountAll(state) // 行集重建 → 总量直算重对账(§15.3.4)
148
+ syncLineBudget(state, { onTrim: (st, n) => shiftFreezeAnchors(st, n) })
136
149
  }
137
150
  /** 懒加载更早历史(2026-08-31 用户约定:"滚动到头自动加载"——滚轮/PgUp 到顶皆触发;
138
151
  * 2026-08-31 前只挂 PgUp 键 = 违约,滚轮到头无反应)。加载后滚动补偿保持锚定。
@@ -142,10 +155,12 @@ export function restoreLines(state, history) {
142
155
  export function createLoadOlder({ agent, state, render }) {
143
156
  return () => {
144
157
  if (!state._hasOlder) return
145
- const full = agent._fullHistory ?? []
158
+ // 翻页锚 = 恢复时点 total(state._historyTotal)——活消息增长不再使页码漂移
159
+ // (§14.1 事实 4 / §14.3.6 顺带修复)。
160
+ const total = state._historyTotal ?? 0
146
161
  const loaded = state._historyLoaded
147
- const start = Math.max(0, full.length - loaded - HISTORY_PAGE_MESSAGES)
148
- const end = full.length - loaded
162
+ const start = Math.max(0, total - loaded - HISTORY_PAGE_MESSAGES)
163
+ const end = total - loaded
149
164
  if (start >= end) return
150
165
 
151
166
  const d = state.dims ? state.dims.get() : {}
@@ -154,14 +169,29 @@ export function createLoadOlder({ agent, state, render }) {
154
169
 
155
170
  if (state.lines[0]?.text?.startsWith("… ")) state.lines.shift()
156
171
  state._lineIdCounter = state._lineIdCounter ?? 0
157
- const older = historyToLines(full, start, end)
172
+ // 页源(§14.3.6):绑定态 → store.page 绝对区间 + ±1 页沿(页前一消息供跨页回合
173
+ // 标签判定、页后一消息供 tool_result 配对);未绑定(模式 F)→ 内存全量数组回退。
174
+ const store = agent._recordStore
175
+ let older
176
+ if (store?.page) {
177
+ const { messages, base } = store.page(start, end, { margin: 1 })
178
+ older = historyToLines(messages, Math.max(0, start - base), Math.min(messages.length, end - base))
179
+ } else {
180
+ older = historyToLines(agent._fullHistory ?? [], start, end)
181
+ }
182
+ for (const l of older) l.text = capLine(l.text) // 行额度(§15.3.3 翻页行过 capText)
158
183
  for (const l of older) l._lineId = ++state._lineIdCounter
159
184
  state.lines.unshift(...older)
185
+ for (const l of older) accountLine(state, l)
160
186
  state._historyLoaded += end - start
161
187
  state._hasOlder = start > 0
162
188
  if (state._hasOlder) {
163
- state.lines.unshift({ text: `… ${start} more earlier messages (scroll to top to load)`, color: C.dim })
189
+ const ph = { text: `… ${start} more earlier messages (scroll to top to load)`, color: C.dim }
190
+ state.lines.unshift(ph)
191
+ accountLine(state, ph)
164
192
  }
193
+ // 页内不裁头(保锚定)——§15.3.3:总量的裁头由 syncLineBudget 兜(此处只对账字符预算)
194
+ syncLineBudget(state, { onTrim: (st, n) => shiftFreezeAnchors(st, n) })
165
195
 
166
196
  const after = countConvLines(state, cols, (state.dims?.get() ?? {}).rows ?? (process.stdout.rows || 24))
167
197
  state.scroll += Math.max(0, after - before)
@@ -194,8 +224,10 @@ export function showStartup(ctx) {
194
224
  // Recover previous session: rebuild from history (lazy — display snapshot is
195
225
  // deprecated; it drifted out of sync with history on VS Code writes).
196
226
  if (opts.restored?.history?.length) {
197
- restoreLines(state, opts.restored.history)
198
- pushLabel(`── Restored previous session (${opts.restored.history.length} messages); /new for a fresh session ──`, C.warn)
227
+ restoreLines(state, opts.restored)
228
+ // 「N messages」标签口径 = total(§14.3.6——非窗口长度)
229
+ const total = Number.isFinite(opts.restored.total) ? opts.restored.total : opts.restored.history.length
230
+ pushLabel(`── Restored previous session (${total} messages); /new for a fresh session ──`, C.warn)
199
231
  }
200
232
 
201
233
  // Hint when multiple sessions exist
@@ -87,6 +87,7 @@ export function ensureSubTaskKey(state, key, role) {
87
87
  key, role, model: undefined, started: Date.now(), done: false, doneAt: null,
88
88
  blocks: [], currentTool: null, toolArgs: null, turn: 0, maxTurns: 0, approval: null,
89
89
  lastError: null, dropped: 0,
90
+ _lineCount: 0, _charCount: 0, // 双维记账(TUI.md §15.3.4——行数维 + 字符维,subagent-children.mjs)
90
91
  stopped: false, // §19.5: ⟦ev⟧stopped 冻结标记(标题 "stopped")
91
92
  children: [], // SUBAGENT-TAIL: 嵌套子代理守护载体(内容并入本块——subagent-children.mjs)
92
93
  }