thincoder 0.12.60 → 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 (171) hide show
  1. package/CHANGELOG.md +49 -2
  2. package/README.md +8 -6
  3. package/bin/thincoder.mjs +38 -124
  4. package/package.json +3 -2
  5. package/src/abort-provenance.mjs +116 -0
  6. package/src/acp/bridge.mjs +38 -17
  7. package/src/acp.mjs +19 -6
  8. package/src/advisor/citations.mjs +83 -21
  9. package/src/advisor/compaction.mjs +174 -0
  10. package/src/advisor/loop.mjs +293 -0
  11. package/src/advisor/messages.mjs +36 -134
  12. package/src/advisor/project-context.mjs +194 -0
  13. package/src/advisor/repos.mjs +17 -40
  14. package/src/advisor/run.mjs +124 -329
  15. package/src/advisor/truncate.mjs +57 -0
  16. package/src/advisor.mjs +3 -2
  17. package/src/agent/completion.mjs +1 -1
  18. package/src/agent/dispatch.mjs +47 -12
  19. package/src/agent/helpers.mjs +71 -13
  20. package/src/agent/record-results.mjs +13 -5
  21. package/src/agent/relay-prefix.mjs +39 -0
  22. package/src/agent/run-stages.mjs +24 -7
  23. package/src/agent/setup-reminders.mjs +16 -9
  24. package/src/agent/setup.mjs +92 -128
  25. package/src/agent/spawn-child.mjs +43 -11
  26. package/src/agent-tools/advisor-async.mjs +70 -180
  27. package/src/agent-tools/advisor-settle.mjs +231 -0
  28. package/src/agent-tools/advisor.mjs +69 -20
  29. package/src/agent-tools/async-settle.mjs +13 -0
  30. package/src/agent-tools/batch-segment.mjs +195 -0
  31. package/src/agent-tools/consult.mjs +28 -10
  32. package/src/agent-tools/design-token.mjs +14 -1
  33. package/src/agent-tools/digest-budget.mjs +76 -0
  34. package/src/agent-tools/eng.mjs +3 -3
  35. package/src/agent-tools/escalate-async.mjs +22 -13
  36. package/src/agent-tools/read-history.mjs +31 -6
  37. package/src/agent-tools/review-streak.mjs +93 -0
  38. package/src/agent-tools/settings.mjs +130 -17
  39. package/src/agent-tools/subagent-actions.mjs +18 -6
  40. package/src/agent-tools/subagent-async.mjs +66 -14
  41. package/src/agent-tools/subagent-panel.mjs +22 -15
  42. package/src/agent-tools/subagent-run.mjs +10 -7
  43. package/src/agent-tools/subagent-scheduler.mjs +57 -8
  44. package/src/agent-tools/subagent-spawn.mjs +69 -16
  45. package/src/agent-tools/subagent.mjs +175 -49
  46. package/src/agent-tools/verify.mjs +13 -34
  47. package/src/agent-tools.mjs +1 -0
  48. package/src/agent.mjs +42 -21
  49. package/src/cli/distill-command.mjs +2 -2
  50. package/src/cli/make-agent.mjs +23 -7
  51. package/src/cli/memory-command.mjs +2 -2
  52. package/src/cli/setup-wizard.mjs +29 -9
  53. package/src/completions.mjs +114 -0
  54. package/src/config-migrate.mjs +70 -0
  55. package/src/config.mjs +132 -63
  56. package/src/context.mjs +12 -1
  57. package/src/conventions.mjs +223 -0
  58. package/src/crash-reports.mjs +35 -8
  59. package/src/expand-home.mjs +16 -0
  60. package/src/generate-title.mjs +9 -4
  61. package/src/heap-watch.mjs +88 -0
  62. package/src/hooks.mjs +7 -3
  63. package/src/ledger.mjs +227 -0
  64. package/src/memory/code-index.mjs +9 -3
  65. package/src/memory/code-sync.mjs +77 -36
  66. package/src/memory/core.mjs +8 -9
  67. package/src/memory/delete.mjs +2 -0
  68. package/src/memory/docs.mjs +17 -11
  69. package/src/memory/file-walk.mjs +109 -0
  70. package/src/memory/scan.mjs +95 -0
  71. package/src/memory/schema.mjs +15 -3
  72. package/src/model-ref.mjs +66 -0
  73. package/src/model-specs.mjs +42 -8
  74. package/src/prompt-overlays.mjs +73 -16
  75. package/src/prompts/advisor-design.md +19 -9
  76. package/src/prompts/advisor-round1.md +8 -2
  77. package/src/prompts/advisor-round2.md +14 -3
  78. package/src/prompts/advisor-round3.md +14 -3
  79. package/src/prompts/common.md +115 -0
  80. package/src/prompts/consult-base.md +2 -0
  81. package/src/prompts/discipline-engineering.md +258 -0
  82. package/src/prompts/discipline-normal.md +185 -0
  83. package/src/prompts/persona-coder.md +21 -0
  84. package/src/prompts/persona-eng-coder.md +37 -0
  85. package/src/prompts/persona-eng-designer.md +60 -0
  86. package/src/prompts/persona-engineering.md +55 -0
  87. package/src/prompts/persona-explore.md +15 -0
  88. package/src/prompts/persona-normal.md +27 -0
  89. package/src/prompts/persona-plan.md +26 -0
  90. package/src/provider/anthropic.mjs +4 -4
  91. package/src/provider/core.mjs +13 -32
  92. package/src/provider/errors.mjs +26 -1
  93. package/src/provider/google.mjs +5 -6
  94. package/src/provider/index.mjs +2 -1
  95. package/src/provider/list-models.mjs +93 -0
  96. package/src/provider/rate.mjs +2 -1
  97. package/src/provider/responses.mjs +5 -3
  98. package/src/provider/sse.mjs +3 -4
  99. package/src/proxy.mjs +9 -14
  100. package/src/session-gc.mjs +9 -2
  101. package/src/session-guard.mjs +12 -0
  102. package/src/session-segments.mjs +100 -0
  103. package/src/session-slots.mjs +10 -2
  104. package/src/session-store.mjs +441 -0
  105. package/src/session.mjs +137 -99
  106. package/src/text-budget.mjs +46 -0
  107. package/src/token-ttl.mjs +2 -1
  108. package/src/tools/{system.mjs → bash.mjs} +6 -243
  109. package/src/tools/file.mjs +30 -10
  110. package/src/tools/git.md +1 -1
  111. package/src/tools/git.mjs +15 -34
  112. package/src/tools/index.mjs +4 -2
  113. package/src/tools/ops.mjs +20 -7
  114. package/src/tools/question.md +1 -0
  115. package/src/tools/question.mjs +26 -0
  116. package/src/tools/read.md +1 -1
  117. package/src/tools/read_image.md +1 -1
  118. package/src/tools/search.mjs +236 -0
  119. package/src/traces/trace-store.mjs +195 -64
  120. package/src/tui/agent-turn.mjs +32 -13
  121. package/src/tui/ansi.mjs +2 -0
  122. package/src/tui/clipboard.mjs +7 -1
  123. package/src/tui/cmd-advisor.mjs +3 -2
  124. package/src/tui/cmd-clear.mjs +2 -0
  125. package/src/tui/cmd-config.mjs +108 -37
  126. package/src/tui/cmd-eng.mjs +11 -27
  127. package/src/tui/cmd-exit.mjs +6 -8
  128. package/src/tui/cmd-model.mjs +14 -12
  129. package/src/tui/cmd-new.mjs +5 -1
  130. package/src/tui/cmd-reindex.mjs +7 -0
  131. package/src/tui/cmd-session.mjs +8 -4
  132. package/src/tui/cmd-submodel.mjs +8 -5
  133. package/src/tui/cmd-undo.mjs +4 -3
  134. package/src/tui/display-budget.mjs +184 -0
  135. package/src/tui/index.mjs +69 -40
  136. package/src/tui/key-handler-search.mjs +9 -1
  137. package/src/tui/key-handler.mjs +61 -17
  138. package/src/tui/key-modes.mjs +86 -8
  139. package/src/tui/layout.mjs +18 -10
  140. package/src/tui/ledger-surface.mjs +69 -0
  141. package/src/tui/model-catalog.mjs +89 -0
  142. package/src/tui/model-picker.mjs +498 -0
  143. package/src/tui/mouse.mjs +47 -10
  144. package/src/tui/pickers.mjs +28 -410
  145. package/src/tui/render-frame.mjs +38 -16
  146. package/src/tui/render-loop.mjs +2 -0
  147. package/src/tui/render-segments.mjs +5 -19
  148. package/src/tui/render.mjs +37 -5
  149. package/src/tui/slash-commands.mjs +2 -2
  150. package/src/tui/startup.mjs +45 -13
  151. package/src/tui/subagent-blocks.mjs +70 -90
  152. package/src/tui/subagent-children.mjs +125 -67
  153. package/src/tui/subagent-freeze.mjs +48 -45
  154. package/src/tui/subagent-panel.mjs +21 -66
  155. package/src/tui/suspension-drive.mjs +31 -83
  156. package/src/tui/tool-args.mjs +9 -4
  157. package/src/tui/tool-display.mjs +20 -5
  158. package/src/tui/tool-events.mjs +76 -30
  159. package/src/tui/tui-lifecycle.mjs +27 -7
  160. package/src/tui/wizard.mjs +52 -18
  161. package/src/tui/wrapped-spawn.mjs +54 -0
  162. package/src/prompts/coder.md +0 -13
  163. package/src/prompts/discipline.md +0 -84
  164. package/src/prompts/eng-coder.md +0 -19
  165. package/src/prompts/engineering-sub.md +0 -14
  166. package/src/prompts/engineering.md +0 -87
  167. package/src/prompts/explore.md +0 -12
  168. package/src/prompts/main.md +0 -34
  169. package/src/prompts/methodology-template.md +0 -38
  170. package/src/prompts/plan.md +0 -9
  171. package/src/prompts/system.md +0 -44
@@ -4,10 +4,11 @@
4
4
  * re-import(runAgentTurn ↔ suspensionSession 函数级静态环——模块求值期无顶层调用,
5
5
  * 环安全——session-slots ↔ session.mjs 同款先例)。
6
6
  *
7
- * §17(2026-09-02,AGENT-LOOP.md §17 D-S1..S9):回合尾后台池非空 → 不阻塞等待,
8
- * 进入挂起态——输入放开(Enter = 新回合 / digest 中 Enter 排队 pendingInput)、
9
- * settle 事件驱动 auto-turn 消化(手动档 organize-only / AUTO 档全语义)、池空 + 无
10
- * 待处理输入 → 补发 done 冻结自然退出。状态机行表见 AGENT-LOOP.md §17 D-S9。
7
+ * §17(2026-09-02,AGENT-LOOP.md §9 D-S1..S9):回合尾后台池非空 → 不阻塞等待,
8
+ * 进入挂起态——挂起空闲输入开放(Enter = 新回合填单槽 + 唤醒)、busy(processing 含
9
+ * digest)输入禁用(INPUT-LOCK-ASYNC C'——提交吞——2026-09-09)、settle 事件驱动
10
+ * auto-turn 消化(手动档 organize-only / AUTO 档全语义)、池空 + 无待处理输入 → 补发
11
+ * done 冻结自然退出。状态机行表见 AGENT-LOOP.md §9.2。
11
12
  */
12
13
 
13
14
  // 函数级静态环(2026-09-05):drive 的 digestTurn/用户回合经 runAgentTurn 递归进入
@@ -18,59 +19,15 @@ import { sweepToolBlocks } from "./tool-events.mjs"
18
19
  import { logEvent } from "../log.mjs"
19
20
  import { C } from "./ansi.mjs"
20
21
  // ASYNC-RESULT-CONTAINER.md D1/D2:池 accessor(双池 absorb)+ pending 单容器停靠
21
- import { getAsyncPool, parkAsyncPending } from "../agent-tools/async-settle.mjs"
22
+ import { getAsyncPool, parkAsyncPending, releaseSettledEntry } from "../agent-tools/async-settle.mjs"
22
23
 
23
- // ── §24 D-24c(R15——2026-09-06)排队用户指令合并常量:单批 ≤8 条且合并注入
24
- // ≤2000 字符(双端逐字一致——§22 D-Q3 常量先例);超限截批先行(余下下批——不丢
25
- // 不截断单条);单条 >2000 字符直发不进批;/cmd 不进合并(逐条保序即时)。──
26
- export const MAX_MERGE_ITEMS = 8
27
- export const MAX_MERGE_CHARS = 2000
28
-
29
- /** 合并注入形态(编号逐条——模型一次处理全部——设计措辞锚)。 */
30
- export function formatMergedMessages(items) {
31
- return `你排队了 ${items.length} 条消息:\n${items.map((m, i) => `${i + 1}. ${m}`).join("\n")}\n——一次处理`
32
- }
33
-
34
- /**
35
- * R15 攒批计划(纯函数——消费点共享同一事实源):按队列顺序产出动作序列。
36
- * - { kind:"slash", text, count:1 }——/cmd 逐条即时执行(保序——不进合并缓冲);
37
- * - { kind:"turn", text, count, merged }——文本批次:连续非 / 条目攒批(单批 ≤8 条;
38
- * 合并注入(格式化后)≤2000 字符;count ≥2 → merged 编号注入;count === 1 → 原文
39
- * 直发(含单条 >2000 字符——不进批);超限截批先行——余下条目留待下批(不丢)。
40
- * 消费者每次取动作 [0] 并按 count 从源队列移除——下轮重新计划余项(边界幂等)。
41
- */
42
- export function planQueuedInput(items) {
43
- const actions = []
44
- const n = items.length
45
- let i = 0
46
- while (i < n) {
47
- const cur = String(items[i])
48
- if (cur.startsWith("/")) { actions.push({ kind: "slash", text: cur, count: 1 }); i++; continue }
49
- let j = i
50
- for (;;) {
51
- if (j - i >= MAX_MERGE_ITEMS || j >= n) break
52
- const it = String(items[j])
53
- if (it.startsWith("/")) break
54
- if (it.length > MAX_MERGE_CHARS) break // 单条超长——不进批(留作直发)
55
- const cand = items.slice(i, j + 1)
56
- if (cand.length > 1 && formatMergedMessages(cand).length > MAX_MERGE_CHARS) break // 批总长超限——截批
57
- j++
58
- }
59
- // 超长条目堵头(内环一步未进)→ 该条直发(不进批——也不与前后合并)
60
- if (j === i) { actions.push({ kind: "turn", text: String(items[i]), count: 1, merged: false }); i++; continue }
61
- const taken = items.slice(i, j)
62
- if (taken.length === 1) {
63
- actions.push({ kind: "turn", text: String(taken[0]), count: 1, merged: false })
64
- } else {
65
- actions.push({ kind: "turn", text: formatMergedMessages(taken), count: taken.length, merged: true })
66
- }
67
- i = j
68
- }
69
- return actions
70
- }
24
+ // INPUT-LOCK-ASYNC(C'——2026-09-09,本档 INPUT-LOCK-ASYNC.md):R15 排队
25
+ // 用户指令合并(§11.3 D-24c——攒批计划/合并文案/上限常量)整批废弃
26
+ // ——busy 提交吞 + pendingInput 单槽化(至多一条待交接——单消息逐发不攒批)。
27
+ // 废弃记录见 AGENT-LOOP.md §11.3。
71
28
 
72
29
  /** 后台池计数(LOGGING susp/digest 事件字段——pendingN/poolN)。
73
- * poolN = _asyncSubagents + _asyncAdvisors(§24 D-24b advisor 池同面板计数——queued
30
+ * poolN = _asyncSubagents + _asyncAdvisors(§11.2 D-24b advisor 池同面板计数——queued
74
31
  * 条目同样在 map 内——2026-09-03 code review #2:不再 +queue.length 双计)。
75
32
  * R17(§25 D-R17a/b):pendingN = pending 单容器 `_pendingAsyncResults` 条数(
76
33
  * ASYNC-RESULT-CONTAINER.md D2——四族统一停靠——digest 驱动判据同单容器)。 */
@@ -117,7 +74,7 @@ export function allPendingEntries(agent) {
117
74
  /** 后台池存活判据(D-S2/F5 口径):running/queued 子代理或后台评审,或已 settle 未注入结果
118
75
  * (pending 单容器非空 = D-S3 "未注入"),或 running consult 会话(会诊跨回合——
119
76
  * §25 D-R17a 挂起活度钩子——consult 启动回合尾即入挂起态)。回合尾与每次轮末都用它
120
- * 评估退出。§24 D-24b:_asyncAdvisors(后台评审池)与子代理池同判——评审飞行中挂起
77
+ * 评估退出。§11.2 D-24b:_asyncAdvisors(后台评审池)与子代理池同判——评审飞行中挂起
121
78
  * 会话必须存活。 */
122
79
  export function poolLive(agent) {
123
80
  const map = agent._asyncSubagents
@@ -173,7 +130,7 @@ function waitForSettleOrWake(agent, state) {
173
130
  }
174
131
 
175
132
  /** 后台模式状态行文本(D-S8;17.5.4 #6 顺手对齐):"后台 N 子代理运行中 · M 完成待消化"
176
- * ——"运行中" = running + queued(含后台评审——§24 D-24b 同面板计数)+ running consult
133
+ * ——"运行中" = running + queued(含后台评审——§11.2 D-24b 同面板计数)+ running consult
177
134
  * children(R17——会诊跨回合);"完成待消化" = pending 任一族移交项 + §17.5 回合尾留池
178
135
  * 的 settled 未消费项(挂起会话 sweep 前的可见窗口)。 */
179
136
  function backgroundStatusText(agent) {
@@ -217,7 +174,8 @@ async function digestTurn(ctx) {
217
174
  /**
218
175
  * §17 挂起会话驱动(D-S9 行表;由 runAgentTurn 回合尾进入,池空自然退出):
219
176
  * - suspension:池项 settle → 入 pending → 开 auto-turn(合并消化近邻 settle);
220
- * 用户 Enter → pendingInput(digest 运行中排队,D-S5)——用户输入优先于 digest;
177
+ * 挂起空闲用户 Enter → pendingInput 单槽(busy 含 digest 提交吞——INPUT-LOCK-ASYNC)
178
+ * ——用户输入优先于 digest;
221
179
  * - auto-turn:消化中 settle 不并发开新轮(单 runAgent 循环),轮末按 pending/池态
222
180
  * 续开合并消化轮或回挂起;pendingInput 非空 → 以该消息开新回合(不触发新 digest);
223
181
  * - §17.5.5:每次消化/会话内用户回合消费 pending 后 → freezeReclaimDigestedBlocks
@@ -250,25 +208,14 @@ export async function suspensionSession(ctx) {
250
208
  try {
251
209
  while (!state._suspAborted && !agent._sessionAbort.signal.aborted) {
252
210
  sweepSettledToPending(agent)
253
- // 1. 用户输入优先(D-S5):pendingInput(digest 期排队)→ queue(处理期排队)。
254
- // §24 D-24c(R15):空闲消费攒批合并——同源连续文本合成单条注入(编号逐条
255
- // ——单批 ≤8 条 / ≤2000 字符;单条超长直发不进批;余下留待下批——不丢);
256
- // /cmd 不进合并——逐条保序即时(位置 = 源内队列序)。每次取计划动作 [0]——
257
- // 单回合消费后回环续取(新到消息自然并进下一回合——不设延迟窗口)。
258
- const fromPending = state.pendingInput.length > 0
259
- const srcLen = fromPending ? state.pendingInput.length : state.queue.length
260
- if (srcLen > 0) {
261
- const src = fromPending ? state.pendingInput : state.queue.map((q) => q.text)
262
- const head = planQueuedInput(src)[0]
263
- if (fromPending) state.pendingInput.splice(0, head.count)
264
- else state.queue.splice(0, head.count)
265
- if (head.kind === "slash") {
266
- await ctx.handleSlash?.(head.text)
267
- render()
268
- continue
269
- }
211
+ // 1. 用户输入优先(D-S5):pendingInput 单槽(INPUT-LOCK-ASYNC C'——busy(processing
212
+ // 含 digest)提交吞——Enter 只可能落在挂起空闲/释放窗口——至多一条待交接——driver
213
+ // 消费清槽即开新回合)。R15 攒批/queue 双源已随排队机制废弃收敛(2026-09-09——
214
+ // 单消息交接——无合并无 /cmd 分流——key-handler 只收非斜杠文本——单消息逐发)。
215
+ if ((state.pendingInput?.length ?? 0) > 0) {
216
+ const head = String(state.pendingInput.shift())
270
217
  agent._suspended = false // 用户回合 = 普通回合语义(① 直注入 + settle 即冻结)
271
- await runAgentTurn(ctx, head.text, { skipSession: true })
218
+ await runAgentTurn(ctx, head, { skipSession: true })
272
219
  agent._suspended = true
273
220
  // §17.5.5:该回合消化完 pending(run 首行注入)→ 逐条冻结回收驻留块
274
221
  // (不等池空——settle 锚点 splice——digest 总览文本之前;与 digest 回收同规则)
@@ -319,14 +266,13 @@ export async function suspensionSession(ctx) {
319
266
  agent._pendingAsyncResults = []
320
267
  const { cleanupConsultSessions } = await import("../agent-tools/consult.mjs")
321
268
  cleanupConsultSessions(agent)
322
- // §17 round2 偏差 #2-CLI(code review round2 #2-CLI):中止时不静默丢弃挂起期
323
- // 排队的用户消息——Enter 已清空输入框并入 pendingInput(用户视为已发送),
324
- // 残余转回 state.queue({text} 条目,下个普通回合的队列循环续发——零丢失)
325
- // + 提示行明示去向(不静默丢)。
326
- const queuedN = state.pendingInput?.length ?? 0
327
- if (queuedN > 0) {
328
- state.queue.push(...state.pendingInput.splice(0).map((t) => ({ text: String(t) })))
329
- pushLine(`[background work stopped — ${queuedN} queued message${queuedN > 1 ? "s" : ""} will run as a normal turn]`, C.warn)
269
+ // §17 round2 偏差 #2-CLI(code review round2 #2-CLI)+ INPUT-LOCK 单槽化:中止时
270
+ // 不静默丢弃挂起期输入——Enter 已清空输入框并入 pendingInput(用户视为已发送)——
271
+ // 单槽语义下残余至多一条——转回 state.queue({text} 单条目,下个普通回合的队列
272
+ // 循环续发——零丢失)+ 提示行明示去向(不静默丢)。
273
+ if ((state.pendingInput?.length ?? 0) > 0) {
274
+ state.queue.push({ text: String(state.pendingInput.shift()) })
275
+ pushLine(`[background work stopped — the message you entered will run as a normal turn]`, C.warn)
330
276
  }
331
277
  } else {
332
278
  // D-S3 ③ 兜底:退出前残余(极端竞态)直注入再退——结果零丢失(AC-S2;
@@ -339,6 +285,8 @@ export async function suspensionSession(ctx) {
339
285
  for (const e of residual.splice(0)) {
340
286
  if (e.role === "consult") await injectConsultResult(agent, e)
341
287
  else await injectAsyncResult(agent, e)
288
+ // TUI-OOM-ROOTCAUSE(§23.3.1 消费点③——挂起残差注入):注入完成 → 释放条目持有
289
+ releaseSettledEntry(e)
342
290
  }
343
291
  }
344
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) {
@@ -41,9 +42,11 @@ export function describeToolArgs(name, args) {
41
42
  return a.filter ? `${p} (filter: ${String(a.filter)})` : p
42
43
  }
43
44
  case "websearch": return String(a.query ?? "")
44
- case "subagent": case "coder": case "explore": case "plan": case "eng-coder": {
45
+ case "subagent": case "coder": case "explore": case "plan": case "eng-coder": case "eng-designer": {
45
46
  const task = String(a.task ?? "").replace(/\s+/g, " ").trim()
46
- return task ? task.slice(0, 60) + (task.length > 60 ? "…" : "") : ""
47
+ if (task) return task.slice(0, 60) + (task.length > 60 ? "…" : "")
48
+ // action-only 调用(status/observe/send/cancel/escalate/consume-design…)无 task——action 兜底(2.4 批)
49
+ return a.action ? `(${String(a.action)})` : ""
47
50
  }
48
51
  case "advisor": return String(a.type ?? "review")
49
52
  case "read_image": return String(a.path ?? "")
@@ -72,8 +75,10 @@ export function describeToolArgs(name, args) {
72
75
  }
73
76
 
74
77
  /** 恢复路径用:全量参数 pretty JSON 的 dim 行(非空才输出)。
75
- * 与工具结果的恢复惯例一致——完整落行,超长由连续 dim 折叠收纳。 */
78
+ * 与工具结果的恢复惯例一致——完整落行;**总量额度**(TUI-OOM-ROOTCAUSE §15.3.1
79
+ * ARGS_JSON_MAX_CHARS——`write`/`apply_patch` 整文件内容进显示层的堵口):超出尾截断
80
+ * + 标记,首行保真。 */
76
81
  export function toolArgsLines(args) {
77
82
  if (!args || typeof args !== "object" || Object.keys(args).length === 0) return []
78
- return JSON.stringify(args, null, 2).split("\n")
83
+ return capLines(JSON.stringify(args, null, 2).split("\n"), ARGS_JSON_MAX_CHARS)
79
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
@@ -14,10 +17,11 @@
14
17
  // a parallel same-name batch finishing out of order swaps durations between
15
18
  // siblings — same magnitude, both keep an elapsed (display-level, acceptable).
16
19
 
17
- // Named caps (consult P2, 2026-08-30): inline 200/8/120/3/5 were magic numbers.
20
+ // Named caps (consult P2, 2026-08-30): inline 200/3/5 were magic numbers.
21
+ // CLI-ACTIVITY-DEBLOAT F-1 (2026-09-10): the report preview constants (former named caps)
22
+ // deleted with the conversation-stream preview (tool-events onToolResult) — the
23
+ // frozen block is the ONLY carrier of a child's report.
18
24
  export const TOOL_OUTPUT_LINE_CAP = 200 // per-call streaming output ring buffer
19
- export const SUBAGENT_PREVIEW_LINES = 8 // report preview rows in the conversation
20
- export const PREVIEW_LINE_CHARS = 120 // per-line preview slice
21
25
  export const REMINDER_CAP = 3 // max pending reminders shown on turn end
22
26
  export const REMINDER_PERSIST_TURNS = 5 // persist reminders every N turns
23
27
 
@@ -72,7 +76,9 @@ export function sweepToolBlocks(state) {
72
76
  * 1) Multimodal results (read_image) embed FULL base64 images in the JSON —
73
77
  * the human needs only the text part (model gets images via the multimodal
74
78
  * channel); 2) results beyond maxRows are truncated in the block (full
75
- * 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 尾行),尾截断 + 标记。 */
76
82
  export function slimToolResultForDisplay(result, maxRows = 400) {
77
83
  let displayResult = result
78
84
  try {
@@ -80,9 +86,18 @@ export function slimToolResultForDisplay(result, maxRows = 400) {
80
86
  if (parsed?.images?.length) displayResult = parsed.text ?? result
81
87
  } catch { /* not JSON — show as-is */ }
82
88
  const rows = String(displayResult).split("\n").filter((l) => l.trim())
83
- return rows.length > maxRows
89
+ const capped = rows.length > maxRows
84
90
  ? [...rows.slice(0, maxRows), `… (result truncated at ${maxRows} rows — full text in history)`]
85
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
86
101
  }
87
102
  /** Mark the dispatch-level tool carrier done when its result is consumed by a
88
103
  * dedicated branch (subagent/escalate/advisor blocks) — without this the turn
@@ -4,7 +4,8 @@
4
4
  *
5
5
  * - onToken/onReasoning : 子agent 前缀分流(routeSub*)→ 主流 streaming/reasoning
6
6
  * - onToolCall : 状态栏 + `❯ name args` 标题行 + 计时 + §19 action 记录
7
- * - onToolResult : 子agent 完成冻结(finishSubTask + freezeDoneSubTasks)、
7
+ * - onToolResult : 子agent 完成冻结(finishSubTaskKey 精确冻 + freezeDoneSubTasks——
8
+ * F-2 后 finishSubTask 为 no-op 兼容保留)、
8
9
  * 工具块结果入块、advisor 评审冻结框
9
10
  * - onToolOutput : advisor 有序块缓冲 / 工具块输出流
10
11
  * - 其余 : usage 累计、等待提示、task 面板、回合末增量落盘
@@ -20,25 +21,56 @@ import { formatToolSummary } from "./tool-summaries.mjs"
20
21
  import { describeToolArgs, toolArgsLines } from "./tool-args.mjs"
21
22
  import { ADVISOR_THINKING_PLACEHOLDER, resolveAdvisorProvider } from "../advisor/run.mjs"
22
23
  import {
23
- SUB_PREFIX_RE, SUBAGENT_ROLES, routeSubToken, routeSubReasoning, routeSubToolCall,
24
+ SUBAGENT_ROLES, routeSubToken, routeSubReasoning, routeSubToolCall,
24
25
  routeSubToolOutput, finishSubTask, finishSubTaskKey, finishSubTasksByRole, freezeDoneSubTasks,
25
26
  ensureCompressPanel, markCompressFailed, markCompressDone, markCompressFallback,
27
+ shiftFreezeAnchors,
26
28
  } from "./subagent-blocks.mjs"
27
- import { TURN_CAP_MARK } from "../agent/spawn-child.mjs"
29
+ // 第 27 批 §12.3②:前缀正则换名 + import 源改文法模块(纯换名——语义零改)。
30
+ import { RELAY_PREFIX_RE } from "../agent/relay-prefix.mjs"
31
+ import { TURN_CAP_MARK, STOPPED_MARK } from "../agent/spawn-child.mjs"
28
32
  // 2026-09-05 module-split:ticks/maps/sweep/slim/settle/探测/find 族迁 tool-display.mjs——
29
33
  // buildToolCallbacks 内部引用用本地 import;sweepToolBlocks re-export(agent-turn 消费面)
30
34
  import {
31
- TOOL_OUTPUT_LINE_CAP, SUBAGENT_PREVIEW_LINES, PREVIEW_LINE_CHARS, REMINDER_CAP, REMINDER_PERSIST_TURNS,
35
+ TOOL_OUTPUT_LINE_CAP, REMINDER_CAP, REMINDER_PERSIST_TURNS,
32
36
  _toolTicks, _subActions, _subActionQ,
33
37
  tickStart, tickTake, settleToolBlock, isAsyncSpawnResult, isSpawnErrorResult,
34
- findToolBlock, slimToolResultForDisplay, sweepToolBlocks,
38
+ findToolBlock, findToolLine, slimToolResultForDisplay, sweepToolBlocks,
35
39
  } from "./tool-display.mjs"
36
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
+ }
37
56
 
38
57
  /** Build the agent callbacks + the shared flushStream for one turn.
39
58
  * askPermission, askQuestion, saveSessionImpl } */
40
59
  export function buildToolCallbacks(deps) {
41
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
+ }
42
74
  // NOTE: advisor buffers (_advisorThink/advisorStreaming) are cleared here too.
43
75
  // Timing safety: onToolResult flushes _advisorThink into history and empties
44
76
  // the buffers BEFORE onTurnEnd can call flushStream (tool result is
@@ -66,7 +98,7 @@ export function buildToolCallbacks(deps) {
66
98
  // block content, never the main stream (D1). Routing details in subagent-blocks.
67
99
  if (routeSubToken(state, t, scheduleRender)) return
68
100
  ensureAssistantLabel()
69
- state.streaming += t
101
+ state.streaming = appendCapped(state.streaming, t, STREAM_CAP_OPTS) // §15.3.1 STREAM_MAX_CHARS
70
102
  scheduleRender()
71
103
  },
72
104
  onReasoning: (t) => {
@@ -74,7 +106,7 @@ export function buildToolCallbacks(deps) {
74
106
  // buffer as kind=think (F2: same treatment as the main reasoning stream).
75
107
  if (routeSubReasoning(state, t, scheduleRender)) return
76
108
  ensureAssistantLabel()
77
- state.reasoning += t
109
+ state.reasoning = appendCapped(state.reasoning, t, STREAM_CAP_OPTS) // §15.3.1 STREAM_MAX_CHARS
78
110
  scheduleRender()
79
111
  },
80
112
  onToolCall: (name, args, toolId) => {
@@ -136,6 +168,8 @@ export function buildToolCallbacks(deps) {
136
168
  done: false,
137
169
  },
138
170
  })
171
+ accountLine(state, state.lines[state.lines.length - 1]) // 载体入总量账(§15.3.4)
172
+ budgetSync()
139
173
  tickStart(name, toolId)
140
174
  },
141
175
  // §7.2.3(方案 e):dispatch runOne 把工具 ctx 上的 _subagentKey(sync spawn/
@@ -173,9 +207,12 @@ export function buildToolCallbacks(deps) {
173
207
  // 启发式(async eng-coder 先启动时 explore 完成会误冻其块——7.2.3.1/T-F2);
174
208
  // ② spawn 门拒错误({status:"error"} JSON——auto-turn digest spawn 拒绝):
175
209
  // 不冻结任何块(round1 #1——错误路径不冻结 running 块——T-F5);
176
- // ③ subKey undefined 非错误(老回调/测试直调——成功路径未知工具)→ 启发式
177
- // 兜底(既有行为不变——面板单块时与精确冻同效——T-F1)。
178
- const lastError = result.includes(TURN_CAP_MARK) ? "turn cap reached — work may be partial" : null
210
+ // ③ subKey undefined 非错误(老回调/测试直调——成功路径未知工具)→ 不冻结
211
+ // 任何块(CLI-ACTIVITY-DEBLOAT F-2 收窄:finishSubTask 恒 no-op——无 key
212
+ // 无从精确归属,宁可 no-op 不误冻;块由回合尾 freezeAllSubTasks 兜底清场)。
213
+ // SYNC-CANCEL(R6):⏹ 折叠报告带 STOPPED_MARK——块冻结标 stopped 而非 done
214
+ // (lastError 注记 + 事件定格——兜底竞态窗口的 dispatch 精确冻路径)
215
+ const lastError = result.includes(TURN_CAP_MARK) ? "turn cap reached — work may be partial" : result.includes(STOPPED_MARK) ? "stopped by user — work may be partial" : null
179
216
  const hasSubKey = subKey !== undefined && subKey !== null && subKey !== ""
180
217
  if (hasSubKey) {
181
218
  finishSubTaskKey(state, String(subKey), lastError)
@@ -184,11 +221,10 @@ export function buildToolCallbacks(deps) {
184
221
  finishSubTask(state, SUBAGENT_ROLES, lastError)
185
222
  freezeDoneSubTasks(state)
186
223
  }
187
- // Subagent report preview (max 8 lines) displayed directly in conversation
188
- const lines = result.split("\n")
189
- const preview = lines.slice(0, SUBAGENT_PREVIEW_LINES).map((l) => l.slice(0, PREVIEW_LINE_CHARS)).join("\n")
190
- if (preview) pushLine(preview, C.dim)
191
- if (lines.length > SUBAGENT_PREVIEW_LINES) pushLine(` ... (${lines.length - SUBAGENT_PREVIEW_LINES} more lines)`, C.dim)
224
+ // CLI-ACTIVITY-DEBLOAT F-1 (2026-09-10): the conversation-stream report
225
+ // preview (max 8 dim lines) is deleted — the frozen block is the ONLY
226
+ // carrier of the child's report (full text also lives in history for the
227
+ // model; escalate#N keeps its no-preview surface unchanged).
192
228
  }
193
229
  } else if (isEscalate) {
194
230
  // 飞刀 post-op report landed under the subagent tool name — freeze the
@@ -200,9 +236,11 @@ export function buildToolCallbacks(deps) {
200
236
  // event at flight end). Sync results (async:false) freeze below.
201
237
  if (!isAsyncSpawnResult(result)) {
202
238
  // §7.2.3(round1 #2):escalate 成功返回带 subKey(escalate#N)→ 精确冻;
203
- // 失败/老回调无 subKey → escalate 角色启发式兜底(escalate 串行 + 角色限定——
204
- // 既有行为)。
205
- const lastError = result.includes(TURN_CAP_MARK) ? "turn cap reached — work may be partial" : null
239
+ // 失败/老回调无 subKey → 不冻结任何块(F-2 收窄——finishSubTask 恒 no-op,
240
+ // 块由回合尾 freezeAllSubTasks 兜底清场)。
241
+ // SYNC-CANCEL(R6):同上——escalate 路径同款扩展(sync escalate 无 registry——
242
+ // 恒不折叠——扩展仅口径一致——零行为变化)
243
+ const lastError = result.includes(TURN_CAP_MARK) ? "turn cap reached — work may be partial" : result.includes(STOPPED_MARK) ? "stopped by user — work may be partial" : null
206
244
  if (subKey !== undefined && subKey !== null && subKey !== "") {
207
245
  finishSubTaskKey(state, String(subKey), lastError)
208
246
  } else {
@@ -229,11 +267,13 @@ export function buildToolCallbacks(deps) {
229
267
  // screen of garbage (user report 2026-08-30). The model gets the image
230
268
  // via the multimodal channel (agent.mjs), the human needs only the
231
269
  // text part: strip image parts from the displayed result.
232
- block.result = slimToolResultForDisplay(result)
270
+ block.result = slimToolResultForDisplay(result) // §15.3.1 RESULT 额度(行 400 + 字符 64K 双维)
233
271
  block.summary = formatToolSummary(name, result)
234
272
  block.done = true
235
273
  const started = tickTake(name, toolId)
236
274
  block.elapsed = started !== null ? Math.round(performance.now() - started) : null
275
+ accountLine(state, findToolLine(state, name, toolId)) // 载体原地变更 → 重新入账
276
+ budgetSync()
237
277
  }
238
278
  }
239
279
  if (name === "advisor") {
@@ -248,17 +288,22 @@ export function buildToolCallbacks(deps) {
248
288
  // _advisorBlocks keep rendering the running view until cleared at turn end.
249
289
  const blocks = state._advisorBlocks ?? []
250
290
  if (blocks.length > 0) {
251
- const text = blocks
291
+ // 冻结文本额度(§15.3.1:头 32K + 尾 96K 保裁决尾部 + 中段标记——非静默截断,
292
+ // 代码评审 #3:capLines(...)[0] 会丢标记且截尾)
293
+ const text = capAdvisorText(blocks
252
294
  .map((b) => b.text.replaceAll(ADVISOR_THINKING_PLACEHOLDER, ""))
253
295
  .join("")
254
296
  .replace(/\n{3,}/g, "\n\n")
255
- .trim()
297
+ .trim())
256
298
  if (text) {
257
- state.lines.push({
299
+ const line = {
258
300
  text: "advisor review",
259
301
  color: C.dim,
260
302
  _frozenAdvisor: text,
261
- })
303
+ }
304
+ state.lines.push(line)
305
+ accountLine(state, line)
306
+ budgetSync()
262
307
  }
263
308
  }
264
309
  }
@@ -271,7 +316,7 @@ export function buildToolCallbacks(deps) {
271
316
  // child-stdout/SSE fragments at arbitrary byte boundaries; trimEnd eats
272
317
  // real trailing newlines and routeSubToolOutput's verbatim concat would
273
318
  // glue lines (2026-09-03 修复轮; main path keeps the trimmed form below).
274
- const isSubRelay = SUB_PREFIX_RE.test(name)
319
+ const isSubRelay = RELAY_PREFIX_RE.test(name)
275
320
  const rawText = typeof chunk === "string" ? chunk : String(chunk?.text ?? "")
276
321
  const part = {
277
322
  kind: typeof chunk === "string" ? "text" : (chunk?.kind ?? "text"),
@@ -291,25 +336,26 @@ export function buildToolCallbacks(deps) {
291
336
  const isString = typeof chunk === "string"
292
337
  const raw = isString ? chunk : String(chunk?.text ?? "")
293
338
  const kind = isString ? "text" : (chunk?.kind ?? "text")
294
- const blocks = state._advisorBlocks ??= []
295
- const last = blocks.at(-1)
296
- if (last && last.kind === kind) last.text += raw
297
- else blocks.push({ kind, text: raw })
339
+ pushAdvisorChunk(raw, kind) // §15.3.1 累积额度(多块丢最旧)
298
340
  scheduleRender()
299
341
  return
300
342
  }
301
343
  // Append into the CURRENT tool block's output buffer (the block is the
302
344
  // display; no _live scroll lines anymore). N2-style cap keeps memory
303
- // 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 丢最旧)。
304
347
  const block = findToolBlock(state, name, toolId)
305
348
  if (block) {
306
349
  for (const line of part.text.split("\n")) {
307
350
  const trimmed = line.trimEnd()
308
- 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 }))
309
352
  }
310
353
  if (block.output.length > TOOL_OUTPUT_LINE_CAP) {
311
354
  block.output.splice(0, block.output.length - TOOL_OUTPUT_LINE_CAP)
312
355
  }
356
+ trimOutputChars(block)
357
+ accountLine(state, findToolLine(state, name, toolId))
358
+ budgetSync()
313
359
  }
314
360
  scheduleRender()
315
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 在终端接管
@@ -32,6 +39,11 @@ export function setTuiActive(active) {
32
39
  tuiActive = active === true
33
40
  }
34
41
 
42
+ /** 读 TUI 活动态(F-2 渲染抑制守卫——render-loop doRender 前查——cleanup 清 false 后不再重绘已恢复的主屏)。 */
43
+ export function isTuiActive() {
44
+ return tuiActive
45
+ }
46
+
35
47
  /** R25 崩溃恢复:仅 TUI 活动态执行 writeCleanupSequence(复用本模块清理序列——符号锚)。
36
48
  * 测试缝(T-R25a.2——env 门注入):THINCODER_TEST_CLEANUP_OUT 指向文件时序列写入该文件
37
49
  * (观察恢复被调 + stdout 管道零 ANSI)——生产不设该 env → 恒 stdout(零变化)。
@@ -47,8 +59,10 @@ export function restoreTerminalAfterCrash() {
47
59
  return true
48
60
  }
49
61
 
50
- /** 退出清理闭包:保存会话(同步)+ 关闭 MCP 子进程 + 恢复终端。幂等(cleanedUp 守卫)。 */
51
- export function createExitCleanup({ agent, saveSession, closeAllMcp }) {
62
+ /** 退出清理闭包:保存会话 + 关闭 MCP + 恢复终端。幂等(cleanedUp 守卫)。stdin/write 注入缝(测试锁序——缺省生产零变化)。
63
+ * F-1 唯一权威序(RESIZE-MOUSE-LEAK-FIX):① mouseOff DECRST → ② settle ~20ms(DECRST 往返——raw 仍开回显仍关)→
64
+ * ③ raw off → ④ stdin 排空 → ⑤ 恢复屏幕 → ⑥ 清 TUI 活动态。 */
65
+ export function createExitCleanup({ agent, saveSession, closeAllMcp, stdin = process.stdin, write = (s) => process.stdout.write(s) }) {
52
66
  let cleanedUp = false
53
67
  return () => {
54
68
  if (cleanedUp) return
@@ -67,8 +81,14 @@ export function createExitCleanup({ agent, saveSession, closeAllMcp }) {
67
81
  } catch {
68
82
  // Can't close? fine, process is exiting anyway
69
83
  }
70
- process.stdin.setRawMode(false)
71
- writeCleanupSequence()
72
- setTuiActive(false) // R25:清理完成即清活动态(崩溃钩子不再收到误判)
84
+ // ① mouseOff 单独写(DECRST——不整包 writeCleanupSequence):先于 raw off 停鼠标——消除「回显开而鼠标未停」暴露窗口
85
+ write(ansi.mouseOff)
86
+ // ② settle 20ms:Atomics.wait 同步等(不拆注册点——cleanup 亦挂 process.on("exit") 兜底异常退出——exit 事件仅同步合法)
87
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 20)
88
+ // ③ raw off(DECRST 已被终端处理——无新上报可回显)→ ④ 排空:摘 data 监听 + pause——不依赖限时读——在途已读字节丢弃不归 shell
89
+ stdin.setRawMode(false); stdin.removeAllListeners("data"); stdin.pause()
90
+ // ⑤ writeCleanupSequence 余部(mouseOff 已单写——clearScreen/bracketedPasteOff/…/wrapOn 恢复屏幕)
91
+ write(ansi.clearScreen + ansi.bracketedPasteOff + ansi.keyboardPop + ansi.modifyOtherKeysOff + ansi.mainBuffer + ansi.showCursor + ansi.reset + ansi.wrapOn)
92
+ setTuiActive(false) // ⑥ R25:清理完即清活动态(崩溃钩子不再误判——渲染抑制锚)
73
93
  }
74
94
  }