thincoder 0.12.66 → 0.12.68

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 (69) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +23 -5
  3. package/bin/thincoder.mjs +43 -331
  4. package/package.json +2 -2
  5. package/src/acp/bridge.mjs +13 -12
  6. package/src/acp/handlers-session.mjs +12 -1
  7. package/src/cli/make-agent.mjs +44 -119
  8. package/src/cli/memory-command.mjs +45 -3
  9. package/src/cli/permission.mjs +1 -1
  10. package/src/command-interactive.mjs +186 -0
  11. package/src/command-table.mjs +185 -0
  12. package/src/completions.mjs +36 -12
  13. package/src/crash-reports.mjs +42 -20
  14. package/src/distill.mjs +3 -3
  15. package/src/heap-watch.mjs +5 -14
  16. package/src/prompt-injections.mjs +1 -1
  17. package/src/tui/agent-turn.mjs +114 -69
  18. package/src/tui/clipboard.mjs +74 -30
  19. package/src/tui/cmd-advisor.mjs +54 -13
  20. package/src/tui/cmd-clear.mjs +2 -2
  21. package/src/tui/cmd-config.mjs +26 -2
  22. package/src/tui/cmd-eng.mjs +1 -1
  23. package/src/tui/cmd-mcp.mjs +1 -1
  24. package/src/tui/cmd-new.mjs +2 -2
  25. package/src/tui/cmd-reindex.mjs +1 -1
  26. package/src/tui/cmd-session.mjs +13 -5
  27. package/src/tui/cmd-think.mjs +35 -17
  28. package/src/tui/cmd-timers.mjs +33 -0
  29. package/src/tui/conversation-writer.mjs +2 -2
  30. package/src/tui/display-budget.mjs +9 -9
  31. package/src/tui/fold-block.mjs +1 -1
  32. package/src/tui/index.mjs +23 -5
  33. package/src/tui/interaction.mjs +5 -3
  34. package/src/tui/key-handler-busy.mjs +17 -12
  35. package/src/tui/key-handler-ctrlc.mjs +5 -5
  36. package/src/tui/key-handler-edit.mjs +12 -8
  37. package/src/tui/key-handler-search.mjs +1 -1
  38. package/src/tui/key-handler.mjs +2 -2
  39. package/src/tui/key-modes.mjs +1 -1
  40. package/src/tui/layout.mjs +5 -5
  41. package/src/tui/ledger-surface.mjs +26 -5
  42. package/src/tui/lifecycle-records.mjs +199 -0
  43. package/src/tui/model-picker.mjs +16 -199
  44. package/src/tui/mouse.mjs +5 -5
  45. package/src/tui/pickers.mjs +6 -2
  46. package/src/tui/provider-admin.mjs +213 -0
  47. package/src/tui/queued-merge.mjs +12 -0
  48. package/src/tui/queued-pickup.mjs +34 -0
  49. package/src/tui/render-conversation.mjs +49 -4
  50. package/src/tui/render-frame.mjs +54 -11
  51. package/src/tui/render-loop.mjs +2 -3
  52. package/src/tui/render-segments.mjs +4 -4
  53. package/src/tui/render.mjs +1 -1
  54. package/src/tui/slash-commands.mjs +3 -0
  55. package/src/tui/startup.mjs +28 -3
  56. package/src/tui/subagent-blocks.mjs +37 -29
  57. package/src/tui/subagent-children.mjs +5 -5
  58. package/src/tui/subagent-freeze.mjs +43 -41
  59. package/src/tui/subagent-panel.mjs +11 -11
  60. package/src/tui/suspension-drive.mjs +173 -253
  61. package/src/tui/timer-watch.mjs +64 -0
  62. package/src/tui/tool-args.mjs +10 -71
  63. package/src/tui/tool-display.mjs +7 -7
  64. package/src/tui/tool-events.mjs +40 -35
  65. package/src/tui/tui-lifecycle.mjs +13 -4
  66. package/src/tui/tui-state.mjs +3 -2
  67. package/src/tui/turn-face.mjs +6 -3
  68. package/src/tui/wizard.mjs +7 -2
  69. package/src/tui/wrapped-spawn.mjs +3 -3
@@ -15,7 +15,7 @@ export function handleCtrlCFamily(str, key, ctx) {
15
15
  popPicker(null)
16
16
  return
17
17
  }
18
- // §17.6(2026-09-03):武装窗口内第二次 Ctrl+C = 显式全停(D-C4 /abort 语义)。
18
+ // AGENT-LOOP-ASYNC-POOL.md §6.8(2026-09-03):武装窗口内第二次 Ctrl+C = 显式全停(D-C4 /abort 语义)。
19
19
  // 在状态路由前检查(picker 取消分支例外——picker 打开时 Ctrl+C 语义 = 取消
20
20
  // picker,武装让位;场景极窄——3s 窗口内需另有 picker 被打开)——两次按下之间
21
21
  // 状态会迁移(首按停回合 → 释放窗口/挂起会话启动),武装必须跨态桥接:只查挂起
@@ -33,7 +33,7 @@ export function handleCtrlCFamily(str, key, ctx) {
33
33
  state.suspended
34
34
  if (hasStopTarget) {
35
35
  // 当前回合平 abort(无 interrupt——命中 agent.mjs 回合收尾清池分支——全停)
36
- // §20.3 站点 #11(第 24 批):整批 / 会话级停 = stop(reason 载荷)
36
+ // AGENT-LOOP-SUBAGENT.md §6.12 站点 #11(第 24 批):整批 / 会话级停 = stop(reason 载荷)
37
37
  if (state.processing && state.controller) state.controller.abort({ abortTrigger: "stop", abortDetail: "session-stop" })
38
38
  // 会话 abort 集合 = 链条内全部 controller(含 Ctrl+I/ContinueError 重建的旧
39
39
  // controller——children 不逃逸,round1 偏差 #3)。挂起态才标记 _suspAborted +
@@ -51,10 +51,10 @@ export function handleCtrlCFamily(str, key, ctx) {
51
51
  // 无目标可停 → 落空继续到下方分支(processing 已停 → 空闲态 exitArmed 双确认)
52
52
  }
53
53
  if (state.suspended) {
54
- // §17 D-S9 + round2 偏差 #4(2026-09-02)+ §17.6 修订(2026-09-03):挂起态
54
+ // AGENT-LOOP-ASYNC-POOL.md §6.8 D-S9 + round2 偏差 #4(2026-09-02)+ AGENT-LOOP-ASYNC-POOL.md §6.8 修订(2026-09-03):挂起态
55
55
  // Ctrl+C = 武装窗口两级中止——一次按键直接清池中止全部后台子代理的误触代价高
56
56
  // (digest 刷屏时用户可能只想停住当前回合);仿空闲态退出武装语义(同下方空闲态
57
- // exitArmed 双确认分支)。§17.6 修订 ①:中止当前回合的 abort 带 interrupt
57
+ // exitArmed 双确认分支)。AGENT-LOOP-ASYNC-POOL.md §6.8 修订 ①:中止当前回合的 abort 带 interrupt
58
58
  // (无 message)——此前平 abort 命中 agent.mjs 回合收尾清池分支(aborted &&
59
59
  // !reason.interrupt → 无条件 _asyncSubagents.clear()),一次首按即误杀全部后台
60
60
  // 子代理(2026-09-03 用户两次实测——提示"再按才杀"与实际"一次就全杀"不符);
@@ -80,7 +80,7 @@ export function handleCtrlCFamily(str, key, ctx) {
80
80
  return
81
81
  }
82
82
  if (state.processing && state.controller) {
83
- // §17.6 D-C1(2026-09-03 紧急修复——processing 态曾无武装窗口,第一按直接平
83
+ // AGENT-LOOP-ASYNC-POOL.md §6.8 D-C1(2026-09-03 紧急修复——processing 态曾无武装窗口,第一按直接平
84
84
  // abort() → 命中 agent.mjs 回合收尾清池分支 → 一次 Ctrl+C 误杀全部后台子代理,
85
85
  // 用户两次实测被坑):首按 = interrupt 语义(无 message——停当前回合不续跑——
86
86
  // interrupt 排除 agent.mjs 清池分支——后台池保留——agent-turn 区分不重建续跑)
@@ -1,8 +1,9 @@
1
1
  import { C } from "./ansi.mjs"
2
2
  import { readClipboardText, insertPastedText } from "./clipboard.mjs"
3
+ import { QUEUED_MAX_ITEMS } from "./queued-merge.mjs"
3
4
 
4
5
  /** 编辑族(2026-09-22 structure-debt §2.1 分族 5 · #226):Tab 补全 / Ctrl+U 清框 /
5
- * 退格·删除 / Enter(多行换行 · 挂起单槽 · submit)/ 剪贴板文本与图片粘贴 / 可打印字符;
6
+ * 退格·删除 / Enter(多行换行 · 挂起队列 · submit)/ 剪贴板文本与图片粘贴 / 可打印字符;
6
7
  * 各块自 key-handler.mjs createKeyHandler 逐字搬移(仅去缩进)。
7
8
  * 路由 = 分派器 createKeyHandler 末族(其后无可达分支 —— 块内 return 即终结本次按键)。 */
8
9
  export function handleEditKeys(str, key, ctx) {
@@ -50,15 +51,15 @@ export function handleEditKeys(str, key, ctx) {
50
51
  render()
51
52
  } else {
52
53
  const text = state.input.join("").trim()
53
- // §17 D-S5/F3/F7 + 偏差 #1 + INPUT-LOCK 单槽化(F-6——2026-09-09):挂起态
54
- // (suspended 或释放窗口 _suspPending)Enter = 新回合输入(非打断)——填
55
- // pendingInput 单槽(至多一条待交接)由挂起会话调度;挂起会话内 busy(含 digest)
56
- // 提交经 busy 门禁入同槽(F16 §4.1——两分支同槽、同款清理与唤醒);输入框零干扰(F3)。
57
- // 槽满(竞态防御——不覆盖不丢失)→ 吞 + 提示,文本保留在输入框。
54
+ // AGENT-LOOP-ASYNC-POOL.md §6.8 D-S5/F3/F7 + 偏差 #1 + INPUT-LOCK 队列受理(F-6——2026-09-09 · queue-visible 多槽
55
+ // 容量 8——2026-09-24):挂起态(suspended 或释放窗口 _suspPending)Enter = 新回合输入
56
+ // (非打断)——填 pendingInput 队列(容量 QUEUED_MAX_ITEMS)由挂起会话调度;挂起会话内
57
+ // busy(含 digest)提交经 busy 门禁入同队列(F16 §4.1——两分支同队列、同款清理与唤醒);
58
+ // 输入框零干扰(F3)。满队(第 9 条——竞态防御——不覆盖不丢失)→ 吞 + 提示,文本保留在输入框。
58
59
  if ((state.suspended || state._suspPending) && text && !text.startsWith("/")) {
59
60
  state.pendingInput ??= []
60
- if (state.pendingInput.length > 0) {
61
- pushLine(`[主会话处理中 —— 已有一条消息待发送,请等其处理完成后再发送]`, C.warn)
61
+ if (state.pendingInput.length >= QUEUED_MAX_ITEMS) {
62
+ pushLine(`[主会话处理中 —— 已排队 ${QUEUED_MAX_ITEMS} 条消息,请等其处理完成后再发送]`, C.warn)
62
63
  render()
63
64
  return
64
65
  }
@@ -68,6 +69,9 @@ export function handleEditKeys(str, key, ctx) {
68
69
  state.historyIndex = -1
69
70
  state._draft = null
70
71
  state.pendingInput.push(text)
72
+ // F4「新提交消息 → 恢复跟随」(§7.5 跟随——排队提交同列;与 turn-face.mjs submit 同款两行)
73
+ state.scroll = 0
74
+ state._followTail = true
71
75
  state._suspWake?.()
72
76
  render()
73
77
  return
@@ -1,5 +1,5 @@
1
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):匹配计数上限——超限截断 +
2
+ * TUI-OOM-ROOTCAUSE(TUI-SESSION-VIEW.md §5.1 SEARCH_MATCH_CAP):匹配计数上限——超限截断 +
3
3
  * `state.search.capped` 提示标志(渲染面提示行;次要无界面)。
4
4
  */
5
5
  import { SEARCH_MATCH_CAP } from "./display-budget.mjs"
@@ -44,7 +44,7 @@ export function createKeyHandler(ctx) {
44
44
  // 第 33 批(TUI §14.3(e) 键盘点):模态分派**之前**——任意按键 = 用户在场 ⇒ 清 attention
45
45
  // 位(仅复位呈现态字段,按键语义 / 模态判定 / busy 门禁零改)。
46
46
  clearAttention(state, render)
47
- // permission confirm: y/n/a (a = approve + AUTO ON); batch (§16 D-B1): a/o/n (Esc = deny)
47
+ // permission confirm: y/n/a (a = approve + AUTO ON); batch (D-B1): a/o/n (Esc = deny)
48
48
  // ——模态实现 key-modes.mjs handlePermissionMode(2026-09-03 D-S4)
49
49
  if (handlePermissionMode(str, key, { state, agent, pushLine, render })) return
50
50
 
@@ -110,7 +110,7 @@ export function createKeyHandler(ctx) {
110
110
  if (key.name === "up" || key.name === "down") { handleHistoryKeys(key, ctx); return }
111
111
  if (key.name === "left" || key.name === "right" || key.name === "home" || key.name === "end") { handleCursorKeys(key, ctx); return }
112
112
 
113
- // 序 4:busy 门禁(key-handler-busy.mjs)——Enter 单槽受理 + 吞面四 / Tab 吞
113
+ // 序 4:busy 门禁(key-handler-busy.mjs)——Enter 队列受理(容量 8)+ 吞面四 / Tab 吞
114
114
  // (守卫 = busy 块内返回条件:processing 期仅 tab 与无 meta 的 Enter/回车 被吞,余键落空至编辑族)
115
115
  if (state.processing && (key.name === "tab" || ((key.name === "return" && !key.meta) || (str === "\r" && !key.meta)))) {
116
116
  handleBusyEnter(str, key, state, ctx)
@@ -34,7 +34,7 @@ export function denyModalForOwner(state, key, { pushLine, render } = {}) {
34
34
  return true
35
35
  }
36
36
 
37
- /** permission 确认模态:y/n/a(a = approve + AUTO ON);batch(§16 D-B1):a/o/n(Esc = deny)。
37
+ /** permission 确认模态:y/n/a(a = approve + AUTO ON);batch(D-B1):a/o/n(Esc = deny)。
38
38
  * `continue` / `retry` = y/n-only 族(判据单源 `isYesNoModal`——X8 跟进①:`retry` 框不
39
39
  * 广告 `a`,零会话级 AUTO 副作用)。
40
40
  * consume:valid 键或 Esc 走 resolve 分支;其余键静默吞掉(模态独占)。 */
@@ -5,10 +5,10 @@
5
5
  *
6
6
  * header → conversation → subagent 面板 → todo → picker → permission → input → status
7
7
  * Running subagent activity renders in a FIXED bottom panel between the
8
- * conversation and the todo panel (AGENT-LOOP.md §7.2.1) — full adaptive height
8
+ * conversation and the todo panel (docs/cli/design/TUI.md §6.8) — full adaptive height
9
9
  * (the conversation shrinks); compressed away first on small terminals (to 0 =
10
10
  * hidden, data stays in the buffer). Done children are frozen into the
11
- * conversation stream (§7.2 D4, unchanged). Output panels abolished (§7.2 D6).
11
+ * conversation stream (unchanged). Output panels abolished.
12
12
  * Fixed panels deducted first, conditional panels allocated by priority, remaining space to conversation.
13
13
  */
14
14
  import { layoutInput, wrapText } from "./render.mjs"
@@ -109,7 +109,7 @@ export function computeLayout(state, { cols, rows }) {
109
109
  // Squeeze target: the divider line yields first under small terminals (the
110
110
  // task rows themselves never compress away — put() truncates by panel h).
111
111
 
112
- // Subagent panel (§7.2.1 D1): RUNNING blocks only, between conversation and
112
+ // Subagent panel (docs/cli/design/TUI.md §6.8 D1): RUNNING blocks only, between conversation and
113
113
  // todo. Height = the FULL rendered height of every running block (F2 — fully
114
114
  // adaptive, no cap; the conversation shrinks accordingly). Precomputed here
115
115
  // via renderSubagentPanel (neutral module, no layout↔render-frame cycle);
@@ -152,7 +152,7 @@ export function computeLayout(state, { cols, rows }) {
152
152
  let todoFinalH = taskPanelH
153
153
  const overflow = fixedH + convH - rows
154
154
  if (overflow > 0) {
155
- // 压缩链第 1 级(§7.2.1 NF1/评审 #2 措辞统一):subagent 面板最先让位——
155
+ // 压缩链第 1 级(docs/cli/design/TUI.md §6.8 NF1/评审 #2 措辞统一):subagent 面板最先让位——
156
156
  // 可压缩至 0 隐藏(运行中活动仍在缓冲区不丢),输入框/状态栏/会话区不可挤没。
157
157
  let afterSub = fixedH
158
158
  if (subagentH > 0) {
@@ -212,7 +212,7 @@ export function computeLayout(state, { cols, rows }) {
212
212
  }
213
213
 
214
214
  /**
215
- * §7.2.1 评审 #4:面板部分压缩(subagentFinalH < subagentLines.length)时 render-frame
215
+ * docs/cli/design/TUI.md §6.8 评审 #4:面板部分压缩(subagentFinalH < subagentLines.length)时 render-frame
216
216
  * 实际显示的行——**保底截断**:分隔线 + 末尾 (h-1) 行(最新启动区块优先;小终端上仍
217
217
  * 能看到最新子 agent 活动,旧 slice(0,h) 保留顶部会把最新活动裁掉)。分隔线始终保留
218
218
  * (面板边界语义)。h ≥ 全长 → 原样;h = 0 → [](layout 侧 panels.subagent 已为 null,
@@ -5,6 +5,11 @@
5
5
  *
6
6
  * 三面语义(启动行 / 变化行 / 状态位)见核档头注;判活展示(F-LX1)亦随归核自动带上——
7
7
  * 本档零实现、零复刻(KD-M2-3)。
8
+ *
9
+ * 两端契约(#233——端壳与核形一致):`startLedgerSurface` **恒同步返回 `{ dispose }`**——
10
+ * 核句柄异步就绪(动态 import)后桥接;载入失败 ⇒ 空句柄(`dispose` 恒可调);`dispose()`
11
+ * 早于核就绪 ⇒ 置位且**跳过启动**(核侧零调用——零周期;N1 降级不崩)。调用点因此恒可按
12
+ * 同步形态挂进程退钩(`process.on("exit", () => handle.dispose())`)。
8
13
  */
9
14
  import { C } from "./ansi.mjs"
10
15
 
@@ -18,15 +23,31 @@ function loadCore() {
18
23
  return _core
19
24
  }
20
25
 
26
+ /** 测试注入缝(`_setLedgerDirForTest` 同款缝族):`fn` ⇒ 载核改走该桩;`null` ⇒ 回退原生
27
+ * `loadCore`(`??` 默认);用例 `afterEach` 复位。 */
28
+ let _loadCoreForTest = null
29
+ export function _setLoadCoreForTest(fn) { _loadCoreForTest = fn }
30
+ const loadCoreNow = () => (_loadCoreForTest ?? loadCore)()
31
+
21
32
  /** 单次扫描(直驱面——直通核实现;TUI 色表 `C` 注入)。 */
22
33
  export async function runLedgerScan(opts = {}) {
23
- const core = await loadCore()
34
+ const core = await loadCoreNow()
24
35
  return core.runLedgerScan({ ...opts, colors: C })
25
36
  }
26
37
 
27
- /** TUI 挂载(首扫 + 周期——直通核实现;载入失败 → 空句柄 = 无周期,N1 降级不崩)。 */
38
+ /** TUI 挂载(首扫 + 周期——直通核实现):**恒同步返回 `{ dispose }`**——核句柄异步就绪后
39
+ * 桥接;载入失败 ⇒ 空句柄(无周期,N1 降级不崩);`dispose()` 早于核就绪 ⇒ 置位且
40
+ * **跳过启动**(核侧零调用——零周期)。 */
28
41
  export function startLedgerSurface(ctx = {}) {
29
- return loadCore()
30
- .then((core) => core.startLedgerSurface({ ...ctx, colors: C }))
31
- .catch(() => ({ dispose() {} }))
42
+ let disposed = false
43
+ let handle = null
44
+ loadCoreNow()
45
+ .then((core) => { if (!disposed) handle = core.startLedgerSurface({ ...ctx, colors: C }) })
46
+ .catch(() => { /* 载入失败 ⇒ 空句柄(N1) */ })
47
+ return {
48
+ dispose() {
49
+ disposed = true
50
+ handle?.dispose?.()
51
+ },
52
+ }
32
53
  }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * lifecycle-records.mjs — 消化生命周期面记录承载件(#726 跨端消化面恢复批 —— CLI 承接面)。
3
+ *
4
+ * 机制单源 = `docs/core/design/SESSION.md` §6.26(记录形 ∥ 写缝 ∥ 读缝 ∥ 重建义务 ∥ 容差登记);
5
+ * CLI 承接细则 = `docs/cli/design/TUI-SESSION-VIEW.md` §6。本档 = 三写点 + 恢复面的**单一实现**:
6
+ * ① 记录形构建:`digest` 三型(start ∥ cap ∥ end)∥ `subagent` 快照(meta ∥ rows——rows 保尾 ≤500);
7
+ * ② 痕行文本:digest 记录的可见行文案——live(`digestTurn` ∥ cap 分支)与重建(`historyToLines`)同调;
8
+ * ③ 重建:`subagent` 记录 ⇒ `_frozenSubTask` 合成件(渲染端零改消费——折叠头 + tail-3);
9
+ * ④ 终态行回扫:end 记录页内缺席本轮 start ⇒ `store.page` 向更早逐段回读补齐 `n`(跨页零损)。
10
+ * 写点三处 = `suspension-drive.mjs` `digestTurn` ∥ `agent-turn.mjs` cap 分支 ∥ `subagent-freeze.mjs` `freezeSubTaskLines`。
11
+ */
12
+ import { t } from "@thincoder/core/i18n.mjs"
13
+ import { RECORD_SEG_MESSAGES } from "@thincoder/core/session-store.mjs"
14
+ import { C } from "./ansi.mjs"
15
+
16
+ /** rows 保尾上界(显示行——§6.26「有界保尾」:超界弃最旧 ⇒ 前置省略标记行,标记自身不占额度)。 */
17
+ export const RECORD_ROWS_MAX_LINES = 500
18
+
19
+ /** 写缝载体(`state._agent` ∥ `ctx.agent`——端侧钉定二源,生产同对象;双缺 ⇒ null ⇒ 核口零动作)。 */
20
+ export function recordCarrier(state, agent) {
21
+ return state?._agent ?? agent ?? null
22
+ }
23
+
24
+ // ─── ① 记录形构建(写点三处)─────────────────────────────────────────────
25
+
26
+ /** digest 起跑记录(起跑两行同点):`n` = 起跑待消化数(可 0);`tier` 两档;ask 档携 `from`/`msg`。 */
27
+ export function digestStartRecord({ n, upstream, ask }) {
28
+ return { kind: "digest", status: "start", n, tier: upstream ? "ask" : "digest", ...(ask ?? {}) }
29
+ }
30
+
31
+ /** digest 撞帽记录(cap 行同点;CLI 现无 auto 档产者——读取面按契约前向兼容)。 */
32
+ export function digestCapRecord(turns) {
33
+ return { kind: "digest", status: "cap", mode: "stop", turns }
34
+ }
35
+
36
+ /** digest 收尾记录(终态行同点):`ok` ∥ `ms`(行文案 `seconds` 由同一 `ms` 派生——同值单算式)。 */
37
+ export function digestEndRecord({ ok, ms }) {
38
+ return { kind: "digest", status: "end", ok, ms }
39
+ }
40
+
41
+ /** subagent 归档快照记录(冻结载体行插入同点):meta = 冻结时点块头事实(含 `pool`/`queued?`——§6.26)
42
+ * ∥ rows = 块内容行集(保尾 ≤500)。#790:`key` 写面归一 = 规范形 `sub:<role>#<id>`。 */
43
+ export function subagentRecord(sub) {
44
+ const localKey = String(sub.key ?? "")
45
+ const meta = {
46
+ key: localKey.startsWith("sub:") ? localKey : `sub:${localKey}`,
47
+ role: sub.role ?? null,
48
+ model: sub.model ?? null,
49
+ startedAt: sub.started ?? null,
50
+ doneAt: sub.doneAt ?? null,
51
+ turn: sub.turn ?? null,
52
+ maxTurns: sub.maxTurns ?? null,
53
+ status: sub.stopped ? "stopped" : sub.lastError ? "error" : "done",
54
+ }
55
+ if (sub.queued) meta.queued = true // 排队(未启动)⇒ 重建面 waiting 标注(`pool` 不写——未知)
56
+ else meta.pool = sub.async === true // 非排队显式两写(VSC 头段在场性判据 = `pool != null`——缺省会藏段)
57
+ if (sub.lastError) meta.error = sub.lastError // §6.26 meta `error?`(块头事实——重建面还原冻结头 errPart)
58
+ return { kind: "subagent", meta, rows: boundRows(rowsOf(sub)).map(({ kind, text }) => ({ kind, text })) }
59
+ }
60
+
61
+ /** 块内容行集(条目形 `{kind,text}` 同备;trim 标记随行——保尾裁量时不占额度)。 */
62
+ function rowsOf(sub) {
63
+ const out = []
64
+ for (const b of sub.blocks ?? []) {
65
+ if (!b || typeof b.text !== "string") continue
66
+ const row = { kind: typeof b.kind === "string" ? b.kind : "text", text: b.text }
67
+ if (b._trimMarker === true) row._trimMarker = true
68
+ out.push(row)
69
+ }
70
+ return out
71
+ }
72
+
73
+ /** 行显示行数(核 `countBlockLines` 同式:按 `\n` 切分、文末换行不计)。 */
74
+ function rowLines(row) {
75
+ const lines = String(row?.text ?? "").split("\n")
76
+ return lines[lines.length - 1] === "" ? lines.length - 1 : lines.length
77
+ }
78
+
79
+ /** rows 保尾上界(§6.26「有界保尾」):超界弃最旧 ⇒ 前置省略标记行(`N` = 实弃显示行数、
80
+ * 标记自身不占额度);单行超界保末行(行 = 最小单元,不切行中——桌面归档先例同式)。 */
81
+ function boundRows(rows) {
82
+ const list = Array.isArray(rows) ? rows : []
83
+ const isMarker = (r) => r?._trimMarker === true
84
+ const cost = (r) => (isMarker(r) ? 0 : rowLines(r))
85
+ let total = 0
86
+ let start = list.length
87
+ for (let i = list.length - 1; i >= 0; i -= 1) {
88
+ const n = cost(list[i])
89
+ if (total + n > RECORD_ROWS_MAX_LINES) break
90
+ total += n
91
+ start = i
92
+ }
93
+ if (start === list.length && list.length > 0) start = list.length - 1 // 单行超界:保末行(保尾)
94
+ const kept = [...list.slice(0, start).filter(isMarker), ...list.slice(start)]
95
+ if (kept.length === list.length) return list // 未超界:原引用(零写)
96
+ let dropped = 0
97
+ for (let i = 0; i < start; i += 1) dropped += cost(list[i])
98
+ return [{ kind: "meta", text: `… [rows truncated: ${dropped} lines omitted]` }, ...kept]
99
+ }
100
+
101
+ // ─── ② 痕行文本(live ∥ 重建同调——单一实现零副本)─────────────────────────
102
+
103
+ /** digest 记录 ⇒ 痕行(文案与活流同算式)。`startN` = end 行的本轮起跑数(调用面解析——
104
+ * 页内直用 ∥ 存储回扫);缺省 / 非正数 / `ms` 非数 ⇒ 零行(活流同门 `pend0 > 0`)。 */
105
+ export function digestTraceLines(rec, startN = null) {
106
+ if (rec?.kind !== "digest") return []
107
+ if (rec.status === "start") {
108
+ const ask = rec.tier === "ask" && rec.from !== undefined ? { from: rec.from, msg: rec.msg } : null
109
+ const lines = [{ text: ask ? t("digest.turnLabelAsk", ask) : t("digest.turnLabel"), color: C.dim }]
110
+ if (rec.n > 0) lines.push({ text: t("digest.start", { n: rec.n }), color: C.dim })
111
+ return lines
112
+ }
113
+ if (rec.status === "cap") return [{ text: t("digest.capStop", { turns: rec.turns }), color: C.warn }]
114
+ if (rec.status === "end") {
115
+ if (!Number.isFinite(startN) || startN <= 0 || !Number.isFinite(rec.ms)) return []
116
+ const seconds = (rec.ms / 1000).toFixed(1) // 与记录 `ms` 同值单算式
117
+ return [{ text: t(rec.ok === false ? "digest.aborted" : "digest.done", { n: startN, seconds }), color: C.dim }]
118
+ }
119
+ return []
120
+ }
121
+
122
+ // ─── ③ 重建(读面):subagent 记录 ⇒ 合成件 ────────────────────────────────
123
+
124
+ /** subagent 记录 ⇒ `_frozenSubTask` 合成件(重建面;渲染端零改消费——折叠头 + tail-3 原式)。
125
+ * meta 块头事实逐项回填(渲染端读面:started→elapsed ∥ turn/maxTurns ∥ stopped ∥ error→errPart);
126
+ * #790:`pool`/`queued` ⇒ CLI 本地两事实(`async`/`queued`)∥ `key` 剥 `sub:` 前缀(显示 = 本地无前缀形)。 */
127
+ export function synthSubTask(rec) {
128
+ const meta = rec?.meta ?? {}
129
+ const rows = Array.isArray(rec?.rows) ? rec.rows : []
130
+ const blocks = rows.map((r) => ({ kind: typeof r?.kind === "string" ? r.kind : "text", text: String(r?.text ?? "") }))
131
+ const doneAt = Number.isFinite(meta.doneAt) ? meta.doneAt : null
132
+ const started = Number.isFinite(meta.startedAt) ? meta.startedAt : (doneAt ?? 0)
133
+ return {
134
+ key: String(meta.key ?? "").replace(/^sub:/, ""), // #790:读面容旧形——剥离判别(折叠键 ∥ 显示 = 本地无前缀形)
135
+ role: meta.role ?? null,
136
+ model: meta.model ?? null,
137
+ started,
138
+ done: true,
139
+ doneAt: doneAt ?? started,
140
+ turn: meta.turn ?? null,
141
+ maxTurns: meta.maxTurns ?? null,
142
+ async: meta.pool === true, // #790:记录 `pool` ⇒ 折叠头模式词(§6.26 命名映射:记录 pool ⇄ 本地 async)
143
+ queued: meta.queued === true, // #790:排队冻结 ⇒ 折叠头 waiting 标注
144
+ // #795 残项批:停止面词集扩三词(stopped ∥ cancelled ∥ terminated——词面判据单源 = §6.26;VSC/桌面写 cancelled)。
145
+ stopped: meta.status === "stopped" || meta.status === "cancelled"
146
+ || meta.status === "terminated",
147
+ lastError: typeof meta.error === "string" ? meta.error : null,
148
+ blocks,
149
+ _charCount: blocks.reduce((n, b) => n + b.text.length, 0), // 行集字符和(§5.4 账实一致——恢复直算同源)
150
+ }
151
+ }
152
+
153
+ // ─── ④ 终态行回扫(重建面:跨页分裂 ⇒ 存储逐段回读)─────────────────────────
154
+
155
+ /** 页内(含 ±1 页沿)本轮 start 直用:自 `idx` 向前命中最近 digest start ⇒ 其 `n`(无 ⇒ null)。 */
156
+ function pageStartN(records, idx) {
157
+ for (let j = idx - 1; j >= 0; j -= 1) {
158
+ const r = records[j]
159
+ if (r?.kind === "digest" && r.status === "start") return typeof r.n === "number" ? r.n : null
160
+ }
161
+ return null
162
+ }
163
+
164
+ /** end 记录本轮 start 的 `n`(重建面解析序 = 预解析注记 `_startN`(跨页回扫产物)→ 页内回扫)。 */
165
+ export function resolvedStartN(records, idx) {
166
+ const m = records?.[idx]
167
+ if (m?._startN !== undefined) return m._startN
168
+ return pageStartN(records, idx)
169
+ }
170
+
171
+ /** 存储回扫:`store.page` 向更早逐段回读,至命中本轮 start 止(跨页零损——容差①于 CLI 不成立)。 */
172
+ export function scanStoreForStartN(store, endIdx) {
173
+ if (!store?.page || !Number.isFinite(endIdx) || endIdx <= 0) return null
174
+ let hi = Math.floor(endIdx)
175
+ while (hi > 0) {
176
+ const lo = Math.max(0, hi - RECORD_SEG_MESSAGES)
177
+ let messages = []
178
+ try { messages = store.page(lo, hi, { margin: 0 })?.messages ?? [] } catch { return null }
179
+ for (let k = messages.length - 1; k >= 0; k -= 1) {
180
+ const r = messages[k]
181
+ if (r?.kind === "digest" && r.status === "start") return typeof r.n === "number" ? r.n : null
182
+ }
183
+ hi = lo
184
+ }
185
+ return null
186
+ }
187
+
188
+ /** 预解析(调用面 = `restoreLines` ∥ `createLoadOlder`——持有存储读口):页内(含 ±1 页沿)缺席
189
+ * 本轮 start 的 end ⇒ 回扫 `n` 并注记 `_startN`(重建注记——不动存储形)。 */
190
+ export function resolveSplitTerminalNs(records, base, store) {
191
+ if (!Array.isArray(records) || !store?.page || !Number.isFinite(base)) return
192
+ for (let k = 0; k < records.length; k += 1) {
193
+ const m = records[k]
194
+ if (m?.kind !== "digest" || m.status !== "end" || m._startN !== undefined) continue
195
+ if (pageStartN(records, k) !== null) continue // 页内直用——零回扫
196
+ const n = scanStoreForStartN(store, base + k)
197
+ if (n !== null) m._startN = n
198
+ }
199
+ }