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.
- package/CHANGELOG.md +25 -1
- package/bin/thincoder.mjs +12 -3
- package/package.json +1 -1
- package/src/acp.mjs +13 -5
- package/src/agent/run-stages.mjs +3 -1
- package/src/agent/spawn-child.mjs +17 -2
- package/src/agent-tools/async-settle.mjs +13 -0
- package/src/agent-tools/consult.mjs +5 -0
- package/src/agent-tools/escalate-async.mjs +6 -0
- package/src/agent-tools/read-history.mjs +18 -3
- package/src/agent-tools/subagent-actions.mjs +3 -0
- package/src/agent-tools/subagent-run.mjs +1 -1
- package/src/agent-tools/subagent-spawn.mjs +6 -0
- package/src/agent.mjs +4 -0
- package/src/context.mjs +12 -1
- package/src/crash-reports.mjs +31 -9
- package/src/generate-title.mjs +8 -3
- package/src/heap-watch.mjs +88 -0
- package/src/ledger.mjs +227 -0
- package/src/memory/code-sync.mjs +7 -5
- package/src/memory/core.mjs +8 -9
- package/src/memory/docs.mjs +7 -5
- package/src/memory/scan.mjs +95 -0
- package/src/prompts/advisor-design.md +1 -1
- package/src/prompts/advisor-round1.md +1 -1
- package/src/prompts/advisor-round2.md +1 -1
- package/src/prompts/advisor-round3.md +1 -1
- package/src/prompts/discipline-engineering.md +51 -10
- package/src/prompts/discipline-normal.md +10 -4
- package/src/prompts/persona-eng-designer.md +6 -1
- package/src/prompts/persona-engineering.md +1 -0
- package/src/session-gc.mjs +9 -2
- package/src/session-guard.mjs +12 -0
- package/src/session-segments.mjs +100 -0
- package/src/session-slots.mjs +3 -0
- package/src/session-store.mjs +441 -0
- package/src/session.mjs +78 -61
- package/src/text-budget.mjs +46 -0
- package/src/traces/trace-store.mjs +195 -64
- package/src/tui/cmd-clear.mjs +2 -0
- package/src/tui/cmd-new.mjs +5 -1
- package/src/tui/cmd-session.mjs +8 -4
- package/src/tui/display-budget.mjs +184 -0
- package/src/tui/index.mjs +31 -3
- package/src/tui/key-handler-search.mjs +9 -1
- package/src/tui/ledger-surface.mjs +69 -0
- package/src/tui/render-frame.mjs +7 -1
- package/src/tui/startup.mjs +45 -13
- package/src/tui/subagent-blocks.mjs +1 -0
- package/src/tui/subagent-children.mjs +86 -14
- package/src/tui/subagent-freeze.mjs +8 -2
- package/src/tui/suspension-drive.mjs +3 -1
- package/src/tui/tool-args.mjs +5 -2
- package/src/tui/tool-display.mjs +16 -2
- package/src/tui/tool-events.mjs +53 -14
- package/src/tui/tui-lifecycle.mjs +9 -2
- package/src/tui/wrapped-spawn.mjs +21 -5
package/src/tui/cmd-session.mjs
CHANGED
|
@@ -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
|
-
|
|
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} (${
|
|
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
|
-
|
|
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
|
-
|
|
294
|
-
state.lines.
|
|
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
|
+
}
|
package/src/tui/render-frame.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/tui/startup.mjs
CHANGED
|
@@ -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,
|
|
120
|
-
const
|
|
121
|
-
|
|
122
|
-
|
|
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(
|
|
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 =
|
|
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
|
-
|
|
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,
|
|
148
|
-
const end =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
198
|
-
|
|
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
|
}
|