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
@@ -10,7 +10,7 @@
10
10
  * maxRows flows in from every caller; omitted/0 → uncapped (tests, odd envs).
11
11
  */
12
12
  import { ansi, C } from "./ansi.mjs"
13
- import { formatTables, sanitizeDisplay, sliceByWidth, wrapText } from "./render.mjs"
13
+ import { formatTables, sanitizeDisplay, sliceByWidth, stringWidth, wrapText } from "./render.mjs"
14
14
  import {
15
15
  isExpanded, foldHintLine, renderExpandedBlock, renderBlockTimeline,
16
16
  renderMathAndMarkdown, foldCapRows, renderFoldedHead, foldTailLines,
@@ -23,6 +23,10 @@ import { frozenSubSeg, toolSeg, frozenAdvSeg } from "./render-segments.mjs"
23
23
  import { renderMarkdownPreservingWidth as _rmpw } from "./fold-block.mjs"
24
24
  export { _rmpw as _renderMarkdownPreservingWidth }
25
25
 
26
+ /** 排队块形态上限(`docs/cli/design/TUI.md` §7.5——queue-visible 批 2026-09-24):每条原文行
27
+ * ≤ 该值,超限 ⇒ 该条尾标记行 `… [该条共 N 行——发送后完整显示]`(与 `MAX_INPUT_LINES` 同口径)。 */
28
+ const QUEUED_ITEM_MAX_LINES = 3
29
+
26
30
  let _convCache = { key: "", cols: 0, lines: [] }
27
31
 
28
32
  /** 2026-08-31 懒加载卡顿优化②:段级行体缓存(行对象→conv 行数组)。
@@ -129,7 +133,7 @@ export function convCacheKey(state, maxRows) {
129
133
  // Content prefix in the signature: same kind+length with different content
130
134
  // would otherwise collide (stale render); 8 chars disambiguate in practice.
131
135
  const blocksSig = (state._advisorBlocks ?? []).map((b) => `${b.kind}:${b.text?.length ?? 0}:${String(b.text ?? "").slice(0, 8)}`).join(",")
132
- // NOTE (§7.2.1): running subagent blocks are NOT part of the conversation
136
+ // NOTE (docs/cli/design/TUI.md §6.8): running subagent blocks are NOT part of the conversation
133
137
  // anymore — they render in the fixed bottom panel (subagent-panel.mjs,
134
138
  // uncached per frame: the 1s ticker refreshes the panel's elapsed display).
135
139
  // The old subSig (blockEpoch/turn/elapsed invalidation) is removed: the panel
@@ -164,7 +168,11 @@ export function convCacheKey(state, maxRows) {
164
168
  // this the cache would serve the pre-search rows and highlight would never
165
169
  // appear (P0-1, 2026-08-30 consult). query+index covers match navigation.
166
170
  const searchPart = state.search?.query ? `${state.search.query}:${state.search.index ?? 0}` : ""
167
- return `${state.lines.length}|${lastLine?.text.length ?? 0}|${state.streaming.length}|${state.reasoning.length}|${blocksSig}|${frozenSig}|${toolSig}|${colorSig}|${state.foldEnabled !== false ? "f" : "u"}|${exp}|${capPart}|${searchPart}|${foldScrollSig}`
171
+ // queue-visible 批(TUI.md §7.5):排队签名(条数 + 各条长度 + 首 8 字——同上方 blocksSig 口径)
172
+ // ——待发送块是**派生插槽**(零 state.lines 写入)⇒ 不入签即缓存命中出陈旧帧(块不现 / 不消)。
173
+ const queuedItems = state.pendingInput ?? []
174
+ const queuedPart = `${queuedItems.length}|${queuedItems.map((m) => `${String(m).length}:${String(m).slice(0, 8)}`).join(",")}`
175
+ return `${state.lines.length}|${lastLine?.text.length ?? 0}|${state.streaming.length}|${state.reasoning.length}|${blocksSig}|${frozenSig}|${toolSig}|${colorSig}|${state.foldEnabled !== false ? "f" : "u"}|${exp}|${capPart}|${searchPart}|${foldScrollSig}|${queuedPart}`
168
176
  }
169
177
 
170
178
 
@@ -192,6 +200,15 @@ function highlightSearchMatches(text, query, matchesInLine, globalCurrentIndex,
192
200
  return result
193
201
  }
194
202
 
203
+ /** 编号态首行预算收窄(#677 · TUI.md §7.5 宽度口径):首行折宽 = `width − firstDeduct`(`i. ` 编号占位),余行逐字照 `wrapText`;`0` ⇒ 逐行同形(单条态负向锁)。 */
204
+ function wrapTextHead(text, width, firstDeduct) {
205
+ const nl = text.indexOf("\n"), head = nl < 0 ? text : text.slice(0, nl), rest = nl < 0 ? null : text.slice(nl + 1)
206
+ const budget = Math.max(1, width - firstDeduct)
207
+ if (stringWidth(head) <= budget) return rest === null ? [head] : [head, ...wrapText(rest, width)]
208
+ const cut = sliceByWidth(head, budget), over = head.slice([...cut].length)
209
+ return [cut, ...wrapText(rest === null ? over : `${over}\n${rest}`, width)]
210
+ }
211
+
195
212
  /**
196
213
  * Build the conversation lines for the given state.
197
214
  * maxRows: terminal rows for the 60% expansion cap (undefined → uncapped —
@@ -271,7 +288,7 @@ function buildConvLines(state, cols, maxRows) {
271
288
  blankAfter = false
272
289
  }
273
290
  }
274
- // ── Subagent activity blocks (§7.2.1 D2) — RUNNING blocks MOVED to the fixed
291
+ // ── Subagent activity blocks (docs/cli/design/TUI.md §6.8 D2) — RUNNING blocks MOVED to the fixed
275
292
  // bottom panel (subagent-panel.mjs renderSubagentPanel, layout.mjs precomputes
276
293
  // the panel height; render-frame puts it between conversation and todo).
277
294
  // buildConvLines no longer renders running children: on completion
@@ -348,6 +365,34 @@ function buildConvLines(state, cols, maxRows) {
348
365
  }
349
366
  }
350
367
  }
368
+ // ── 排队期「待发送」块(queue-visible 批 2026-09-24 · TUI.md §7.5)——会话流尾派生插槽:
369
+ // 判据 = `state.pendingInput` 非空(渲染期现算——零 state.lines 写入 / 零生命周期簿记,
370
+ // 消费 / 中止随判据消失;不入搜索面、无 `_lineId` / `_foldToggle`)。渲染序在 state.streaming
371
+ // **之后** ⇒ 流在块上方增长、块恒居会话区底(F1 跟随语义)。体行 `_skipDimFold` = 连 dim
372
+ // 折叠豁免(§7.5 形态锚「永不被折走」:条数 ≥3 时 dim 连跑必过 FOLD_LINES ⇒ 显式豁免)。
373
+ const queuedItems = state.pendingInput ?? []
374
+ if (queuedItems.length > 0) {
375
+ if (convLines.at(-1)?.text !== "") convLines.push({ text: "", color: C.text }) // 块前空行(主输出呼吸行同约)
376
+ const multi = queuedItems.length >= 2
377
+ convLines.push({
378
+ text: multi
379
+ ? `⏳ 待发送 · ${queuedItems.length} 条消息(不打断当前执行,合并发送)`
380
+ : `⏳ 待发送 · 不打断当前执行,自动发送`,
381
+ color: C.warn,
382
+ })
383
+ const indent = multi ? " " : ""
384
+ const wrapCols = cols - 1 // 与正文同口径(`sanitizeDisplay` + `wrapText`——TUI.md §7.5 逐字)
385
+ for (let qi = 0; qi < queuedItems.length; qi++) {
386
+ const head = multi ? `${qi + 1}. ` : ""
387
+ const rows = wrapTextHead(sanitizeDisplay(String(queuedItems[qi])), wrapCols, stringWidth(head))
388
+ for (let ri = 0; ri < Math.min(rows.length, QUEUED_ITEM_MAX_LINES); ri++) {
389
+ convLines.push({ text: `${ri === 0 ? head : indent}${rows[ri]}`, color: C.dim, _skipDimFold: true })
390
+ }
391
+ if (rows.length > QUEUED_ITEM_MAX_LINES) {
392
+ convLines.push({ text: `${indent}… [该条共 ${rows.length} 行——发送后完整显示]`, color: C.dim, _skipDimFold: true })
393
+ }
394
+ }
395
+ }
351
396
  // Fold long blocks (> 8 consecutive dim lines)
352
397
  const FOLD_LINES = 8
353
398
  const folded = []
@@ -10,6 +10,10 @@ import { ansi, C, ESC } from "./ansi.mjs"
10
10
  import { convCacheKey, renderConversation, countConvLines } from "./render-conversation.mjs"
11
11
  import { sliceByWidth, stringWidth } from "./render.mjs"
12
12
  import { providerSpec } from "@thincoder/core/config.mjs"
13
+ import { t } from "@thincoder/core/i18n.mjs" // 停滞轻显形(TUI.md §7.7):句式键 status.quiet——词面单源(CLI 直取)
14
+ // #363②(off 形族单源——MODEL-SPECS.md §16.2 / §16.4):顶栏 think 徽标的 off 判据引核(本档零副本)。
15
+ import { thinkOffShape, thinkOffPath } from "@thincoder/core/think-off.mjs"
16
+ import { isRealUserMsg } from "@thincoder/core/history-window.mjs" // B6 回退链:首条真实 user 消息谓词(纯件单源)
13
17
  import { computeLayout, subagentVisibleLines } from "./layout.mjs"
14
18
  import { basename } from "node:path"
15
19
  import { readFileSync } from "node:fs"
@@ -32,6 +36,11 @@ const SLASH_HINTS = {
32
36
  "/restore": "select checkpoint to restore",
33
37
  }
34
38
 
39
+ // ---------- 停滞轻显形阈值(docs/cli/design/TUI.md §7.7 · stall-indicator 批 2026-09-29)----------
40
+ /** 静默显示阈值(毫秒)——静默 ≥ 本值才注入读数(首显值 = 10s);跳秒 = 既有 1s 拍
41
+ * (agent-turn ticker——零新定时器);三端同值(VSC ∕ 桌面各自持有常量面)。 */
42
+ export const QUIET_MS = 10_000
43
+
35
44
  // ====================================================================
36
45
  // Panel render functions (exported for incremental rendering)
37
46
  // Each returns string[] — one element per screen row, ANSI-colored,
@@ -49,7 +58,11 @@ export function renderHeader(agent, cols) {
49
58
  const thinkOnValue = spec.thinkEnabledValue ?? "enabled"
50
59
  const t = agent.provider?.thinking
51
60
  const effort = agent.provider?.reasoningEffort
52
- const thinkBadge = t?.type === "disabled" ? "│ think: off"
61
+ // off 徽标(#363②·单源引核):off 可宣称(`thinkOffPath`)∧ 现形 = 该族 off 取形
62
+ // (`thinkOffShape`)——thinkAlwaysOn 族 / effort 族携残留标记不再误报 `think: off`
63
+ // (旧形裸判 `type === "disabled"`,与「有效 off 路径」无判据关系——MODEL-SPECS.md §16.4)。
64
+ const offShape = thinkOffPath(spec) ? thinkOffShape(spec) : null
65
+ const thinkBadge = offShape !== null && t?.type === offShape.type ? "│ think: off"
53
66
  : effort ? `│ think: ${effort}`
54
67
  : t?.type === thinkOnValue ? "│ think: on" : ""
55
68
  return `${ansi.bold}${C.tool} ThinCoder ${ansi.reset}${ansi.dim}${THINCODER_VERSION} │ ${sliceByWidth(model, 30)}${thinkBadge ? " " + thinkBadge : ""} │ ${sliceByWidth(basename(agent.cwd), Math.max(10, cols - 60))}${ansi.reset}`
@@ -268,7 +281,7 @@ export function renderRows(state, agent, opts) {
268
281
 
269
282
  put(panels.header.y, [renderHeader(agent, cols)])
270
283
  put(panels.conversation.y, renderConversation(state, cols, panels.conversation.h, state.scroll, rows))
271
- // §7.2.1 D2: running-subagent fixed panel (between conversation and todo) —
284
+ // docs/cli/design/TUI.md §6.8 D2: running-subagent fixed panel (between conversation and todo) —
272
285
  // lines precomputed by layout (subagent-panel.mjs, neutral module), put
273
286
  // directly (no double render); absent when no block is running (F6). Lines
274
287
  // are objects ({text, color, _foldToggle/_foldBlock…} — mouse hit-testing
@@ -341,6 +354,16 @@ function inputBoxStyle(state) {
341
354
  return { borderColor, title }
342
355
  }
343
356
 
357
+ /** 标题回退链(B6 消 · #677——对位 VSC `panel-session.mjs:243-247` 链值 `title → "firstMessage" → "(empty)"`;
358
+ * 截断逐字同该链 `truncate(s, 40)`——生成前 ∕ 失败期两端同值)。首条真实 user 消息 = 活读单源:
359
+ * 绑定态取槽摘要字段(`counters().firstMessage`——与 VSC `listSlots` 同字段);未绑定回退内存人读线。 */
360
+ function sessionTitleFallback(agent) {
361
+ const fm = (agent?._recordStore?.counters?.().firstMessage ?? "")
362
+ || (agent?._fullHistory ?? agent?.history ?? []).find(isRealUserMsg)?.content
363
+ if (typeof fm !== "string" || fm === "") return "(empty)"
364
+ return `"${fm.length <= 40 ? fm : fm.slice(0, 39) + "…"}"`
365
+ }
366
+
344
367
  function buildStatusLine(state, agent, { cols, slashCommands }) {
345
368
  const scrollHint = state.scroll > 0 ? ` │ scrolled ${state.scroll}` : ""
346
369
  const rawInput = state.input.join("")
@@ -387,7 +410,14 @@ function buildStatusLine(state, agent, { cols, slashCommands }) {
387
410
  ? ` │ ↑${fmtK(tk.prompt)} ↓${fmtK(tk.completion)}${tk.reasoningTokens > 0 ? ` ✦${fmtK(tk.reasoningTokens)}` : ""}${cacheTotal > 0 ? ` hit${Math.round((tk.cacheHit / cacheTotal) * 100)}%` : ""}` : ""
388
411
  const elapsed = state.processing ? ` ${Math.floor((Date.now() - state.processingStarted) / 1000)}s` : ""
389
412
  const toolHint = state.currentTool ? ` ${state.currentTool}…` : ""
390
- const statusText = state.processing ? `${state.status}${toolHint}${elapsed}` : state.status
413
+ // 停滞轻显形(docs/cli/design/TUI.md §7.7 · stall-indicator 批 2026-09-29):显示位 = elapsed 之后;
414
+ // 静默 = 距最近一次可见输出(`state.lastOutputAt`——回合起刻为初始锚,重置点三类见 tool-events ∕
415
+ // subagent-blocks);非显示条件(未至阈值 ∕ 非在飞 ∕ 未起算)零注入(负向锁——逐字节等价);纯读数零警示色。
416
+ const lastOut = state.lastOutputAt ?? 0
417
+ const quietHint = state.processing && lastOut > 0 && Date.now() - lastOut >= QUIET_MS
418
+ ? ` ${t("status.quiet", { s: Math.floor((Date.now() - lastOut) / 1000) })}`
419
+ : ""
420
+ const statusText = state.processing ? `${state.status}${toolHint}${elapsed}${quietHint}` : state.status
391
421
  // 2026-09-02 Q1(SESSION.md §6.8):provider 可为 null —— providerSpec(null) 保守 128K 不抛错
392
422
  // 2026-09-02 §15:context 窗口跟随 providers[].context 覆盖(T-C6——百分比基于覆盖后的窗口)
393
423
  const modelContext = providerSpec(agent.provider).context
@@ -396,12 +426,18 @@ function buildStatusLine(state, agent, { cols, slashCommands }) {
396
426
  const ctxHint = ctxPct > 0
397
427
  ? ctxPct >= 80 ? ` │ ${ansi.reset}${C.warn}context ${ctxPct}%${ctxTokensHint}${ansi.reset}${ansi.dim}` : ` │ context ${ctxPct}%${ctxTokensHint}` : ""
398
428
  // INPUT-LOCK-ASYNC(C'——F-3):busy(processing 含 digest)状态栏提示——取代旧排队提示
399
- // (F-7 已删);F16(TUI.md §7.5)三态分流:单槽已填 ⇒ queued 段(dim 段样式 · 零注意力色对
400
- // ——F13 豁免 · 消费即消失);槽空 ⇒ 按 busy 面分排队句(判据同 TUI-INPUT-BOX.md §4.1)。
429
+ // (F-7 已删);F16(TUI.md §7.5)**四态分流**(queue-visible 批 2026-09-24):队列非空 ⇒
430
+ // 条数段(dim 段样式 · 零注意力色对——F13 豁免 · 消费即消失);队空 ⇒ 按 busy 面分排队句
431
+ // (普通 / 挂起内用户回合 = 步边界「当前步骤结束后自动发送」· 挂起内系统轮 = 轮末「本轮结束后
432
+ // 优先发送」)(判据同 TUI-INPUT-BOX.md §4.1;面感知 = `agent._inAutoTurn` 活读)。
433
+ const queuedN = state.pendingInput?.length ?? 0
401
434
  const enterHint = state.processing
402
- ? ((state.pendingInput?.length ?? 0) > 0 ? "已排队 1 条消息"
403
- : state.suspended || state._suspPending ? `会话内回合处理中 — Enter 排队(本轮结束后优先发送)`
404
- : `主会话处理中 — Enter 排队(回合结束后自动发送)`)
435
+ ? (queuedN > 0 ? `已排队 ${queuedN} 条消息`
436
+ : state.suspended || state._suspPending
437
+ ? (agent._inAutoTurn === true
438
+ ? `会话内回合处理中 — Enter 排队(本轮结束后优先发送)`
439
+ : `会话内回合处理中 — Enter 排队(当前步骤结束后自动发送)`)
440
+ : `主会话处理中 — Enter 排队(当前步骤结束后自动发送)`)
405
441
  : "Enter: send"
406
442
  // LEDGER-SURFACE(§2.30.3.3/§2.30.3.4):L1 常驻标记——状态段簇尾(scrollHint 后、键位组前);
407
443
  // 空标记零注入(半态逐字节等价——同 :233-238 纪律);warn = 当前项目老化 > 0(警示色段包裹)
@@ -409,9 +445,16 @@ function buildStatusLine(state, agent, { cols, slashCommands }) {
409
445
  const ledgerHint = lg?.marker
410
446
  ? ` │ ${lg.warn ? `${ansi.reset}${C.warn}${lg.marker}${ansi.reset}${ansi.dim}` : lg.marker}`
411
447
  : ""
448
+ // timer 可见面(`docs/cli/design/TUI.md` §7.6 · timer-wake 批 2026-09-27):状态段簇尾(ledgerHint 后、
449
+ // titleHint 前)——取值 = `agent._pendingTimers` 活读(每帧 recompute / 零缓存副本 / 不显倒计时);
450
+ // 零在途 ⇒ 零注入(负向锁——半态逐字节等价);到期态(任一在途项已过期未送达)⇒ 同段警示色。
451
+ const timers = agent._pendingTimers ?? []
452
+ const timerHint = timers.length === 0 ? "" : ` │ ${timers.some((t) => t.expiresAt <= Date.now()) ? `${ansi.reset}${C.warn}⏰${timers.length}${ansi.reset}${ansi.dim}` : `⏰${timers.length}`}`
412
453
  // D8(2026-09-21 块标题行对齐批——会话标题常显 · `docs/cli/design/TUI.md` §7.4):状态段簇尾
413
- // (ledgerHint 后、键位组前);取值 = `agent.title` 活读(每帧 recompute)· 空值零注入。
454
+ // (ledgerHint 后、键位组前);取值 = `agent.title` 活读(每帧 recompute)· 40 列截断。
455
+ // B6 消(#677):空白 ⇒ 回退链值(对位 VSC 顶栏——生成前 ∕ 失败期两端同值;值逐字,行级超宽走 statusMax)。
414
456
  const titleRaw = typeof agent.title === "string" ? agent.title.trim() : ""
415
- const titleHint = titleRaw ? ` │ ${stringWidth(titleRaw) > 40 ? sliceByWidth(titleRaw, 39) + "…" : titleRaw}` : ""
416
- return ` ${statusText}${taskHint}${turnHint}${tokenHint}${ctxHint}${scrollHint}${ledgerHint}${titleHint} │ ${enterHint} │ /: commands │ wheel/PgUp/PgDn: scroll │ Ctrl+I: inject │ Ctrl+C: exit (×2)`
457
+ const titleShown = titleRaw ? (stringWidth(titleRaw) > 40 ? sliceByWidth(titleRaw, 39) + "…" : titleRaw) : sessionTitleFallback(agent)
458
+ const titleHint = ` │ ${titleShown}`
459
+ return ` ${statusText}${taskHint}${turnHint}${tokenHint}${ctxHint}${scrollHint}${ledgerHint}${timerHint}${titleHint} │ ${enterHint} │ /: commands │ wheel/PgUp/PgDn: scroll │ Ctrl+I: inject │ Ctrl+C: exit (×2)`
417
460
  }
@@ -76,7 +76,7 @@ export function createRenderLoop(state, agent, ctx, pushLine, write = (s) => pro
76
76
  }
77
77
  state._pauseAnchorLen = convLen
78
78
 
79
- // NOTE (§7.2 D6): the old state.outputPanels prune is gone — output panels
79
+ // NOTE (docs/cli/design/TUI.md §6.8 D6): the old state.outputPanels prune is gone — output panels
80
80
  // are abolished; subagent blocks live in the conversation and are never
81
81
  // auto-pruned (bounded by the N2 per-child line cap instead).
82
82
 
@@ -120,9 +120,8 @@ export function createRenderLoop(state, agent, ctx, pushLine, write = (s) => pro
120
120
  const cursorSuffix = hasOverlay ? "" : `\x1b[${cursorRow};${cursorCol}H${ansi.hideCursor}`
121
121
 
122
122
  if (out.length || cursorSuffix) write(ansi.wrapOff + ansi.syncUpdateStart + out.join("") + ansi.syncUpdateEnd + cursorSuffix + ansi.wrapOn)
123
- } catch (e) {
123
+ } catch {
124
124
  // Don't let a render error crash the TUI
125
- if (process.env.THINCODER_DEBUG_RENDER) process.stderr.write(`[render-error] ${e?.stack ?? e}\n`)
126
125
  }
127
126
  }
128
127
 
@@ -67,7 +67,7 @@ export function toolSeg(state, l, i, cols, maxRows) {
67
67
  return rows
68
68
  }
69
69
 
70
- /** 冻结子agent 活动块渲染(§7.2 D4):state.lines 载体 {_frozenSubTask: sub}(subagent-blocks.mjs
70
+ /** 冻结子agent 活动块渲染(docs/cli/design/TUI.md §6.8 D4):state.lines 载体 {_frozenSubTask: sub}(subagent-blocks.mjs
71
71
  * freezeSubTaskLines 推入)。折叠 = 身份头 + tail 3;展开 = 共享组件(60% 屏幕封顶 + 底部可达
72
72
  * 折叠控制)。折叠键 `sub-${key}`——与运行中面板区块同键,折叠态跨冻结边界无缝延续。
73
73
  * SUBAGENT-TAIL:内层内容已并入 sub.blocks(数据层——取代小节;docs/design/TUI.md §6)
@@ -75,11 +75,11 @@ export function toolSeg(state, l, i, cols, maxRows) {
75
75
  function frozenSubTaskLines(state, sub, cols, maxRows) {
76
76
  const foldKey = `sub-${sub.key}`
77
77
  const elapsed = Math.floor(((sub.doneAt ?? Date.now()) - sub.started) / 1000)
78
- // §19.5 D-M7b ②: 冻结头保留 sync/async 标识(done 头含历史语义——与 model 标识
78
+ // AGENT-LOOP-SUBAGENT.md §6.7.2 D-M7b ②: 冻结头保留 sync/async 标识(done 头含历史语义——与 model 标识
79
79
  // 同生命周期);仅真实 subagent 角色(compress 冻结等无语义)。整行 dim——
80
80
  // 无需 ANSI 注入(running 面板头则套 dim + 恢复行色——subagent-panel.mjs)。
81
81
  const isSubRole = SUBAGENT_ROLES.includes(sub.role)
82
- // §20:冻结的 queued 块(中止清场兜底——interrupted)标 waiting——不误标 sync
82
+ // AGENT-LOOP-SUBAGENT.md §6.9:冻结的 queued 块(中止清场兜底——interrupted)标 waiting——不误标 sync
83
83
  const modePart = isSubRole
84
84
  ? ` · ${sub.queued ? "waiting" : (sub.async === true ? "async" : "sync")}`
85
85
  : ""
@@ -89,7 +89,7 @@ function frozenSubTaskLines(state, sub, cols, maxRows) {
89
89
  // 图标三态互斥(2026-09-20 端差·显示面消差批 M5):修前 stopped 冻结头 `✓` 与 verb 相抵
90
90
  // ⇒ 补齐 VSC `webview/activity-view.js:46-49` 已成的 cancelled → `⏹` 形态(VSC 标尺,CLI 缺面)。
91
91
  const icon = sub.approval ? "⏸" : sub.stopped ? "⏹" : "✓"
92
- const verb = sub.stopped ? "stopped" : "done" // §19.5: cancel 冻结标题 "stopped"
92
+ const verb = sub.stopped ? "stopped" : "done" // AGENT-LOOP-SUBAGENT.md §6.7.2: cancel 冻结标题 "stopped"
93
93
  const header = `[${icon} ${sub.key}${modePart}${modelPart} · ${verb} ${elapsed}s${turnPart}${errPart}]`
94
94
  const out = []
95
95
  if (isExpanded(state, foldKey)) {
@@ -242,7 +242,7 @@ const ANSI_SEQUENCE_RE = new RegExp(ANSI_SEQUENCE.source, "g")
242
242
  export function sanitizeDisplay(s) {
243
243
  return s
244
244
  .replace(ANSI_SEQUENCE_RE, "")
245
- // §7.2 D5 fallback: an unparsed ⟦ev⟧ event token must never reach the grid —
245
+ // docs/cli/design/TUI.md §6.8 D5 fallback: an unparsed ⟦ev⟧ event token must never reach the grid —
246
246
  // strip the sentinel + its RS-wrapped payload (⟦ev⟧turn\x1e…\x1e / bare RS/GS chars).
247
247
  // 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
248
248
  .replace(/⟦ev⟧[^\x1e\x1d]*\x1e[^\x1e\x1d]*\x1e[^\x1e\x1d]*\x1e[^\x1e\x1d]*\x1e?/g, "")
@@ -19,6 +19,7 @@ import { handleInitCommand } from "./cmd-init.mjs"
19
19
  import { handleRestoreCommand } from "./cmd-restore.mjs"
20
20
  import { handlePlanCommand } from "./cmd-plan.mjs"
21
21
  import { handleGoalCommand } from "./cmd-goal.mjs"
22
+ import { handleTimersCommand } from "./cmd-timers.mjs" // timer 可见面(TUI.md §7.6):`/timers` 只读列表
22
23
  import { handleSkillsCommand } from "./cmd-skills.mjs"
23
24
  import { handleMcpCommand } from "./cmd-mcp.mjs"
24
25
  import { handleAutoCommand } from "./cmd-auto.mjs"
@@ -45,6 +46,7 @@ export const SLASH_COMMANDS = [
45
46
  { name: "/submodel", group: "Agent", desc: "subagent model per type (explore/plan/coder/eng-coder/eng-designer)" },
46
47
  { name: "/shell", group: "System", desc: "bash tool shell (git-bash/pwsh path; win11 cmd encoding fix)" },
47
48
  { name: "/goal", group: "Agent", desc: "set/view/cancel long-term goal" },
49
+ { name: "/timers", group: "Agent", desc: "list pending timers (read-only)" },
48
50
  { name: "/think", group: "Agent", desc: "thinking mode & reasoning effort" },
49
51
  { name: "/upgrade", group: "System", desc: "check for updates & upgrade" },
50
52
  { name: "/config", group: "System", desc: "agent config (embedding, proxy, turns, threshold, consult pool)" },
@@ -80,6 +82,7 @@ export const HANDLERS = {
80
82
  "/restore": handleRestoreCommand,
81
83
  "/plan": handlePlanCommand,
82
84
  "/goal": handleGoalCommand,
85
+ "/timers": handleTimersCommand,
83
86
  "/skills": handleSkillsCommand,
84
87
  "/mcp": handleMcpCommand,
85
88
  "/auto": handleAutoCommand,
@@ -1,4 +1,5 @@
1
- import { listSlots } from "@thincoder/core/session.mjs"
1
+ import { listSlots, ledgerHealth } from "@thincoder/core/session.mjs"
2
+ import { stripAtRefs } from "@thincoder/core/file-refs.mjs"
2
3
  import { ansi, C } from "./ansi.mjs"
3
4
  import { describeToolArgs, toolArgsLines } from "./tool-args.mjs"
4
5
  import { slimToolResultForDisplay } from "./tool-events.mjs"
@@ -6,6 +7,8 @@ import { countConvLines } from "./render-conversation.mjs"
6
7
  // TUI-OOM-ROOTCAUSE(TUI-SESSION-VIEW.md §5.3/§5.4):恢复/翻页行过额度 + state.lines 总量对账。
7
8
  import { capLine, accountLine, accountAll, releaseLine, syncLineBudget } from "./display-budget.mjs"
8
9
  import { shiftFreezeAnchors } from "./subagent-blocks.mjs"
10
+ // #726 记录分支承载(痕行文案 live∥重建同调 ∥ 跨页回扫 ∥ subagent 合成件)
11
+ import { digestTraceLines, resolveSplitTerminalNs, resolvedStartN, synthSubTask } from "./lifecycle-records.mjs"
9
12
 
10
13
  /** Lazy history window (parity with VS Code HISTORY_PAGE_SIZE): first paint loads
11
14
  * the latest INITIAL_HISTORY_MESSAGES, then PgUp-at-top loads HISTORY_PAGE_MESSAGES
@@ -20,6 +23,8 @@ export const HISTORY_PAGE_MESSAGES = 20
20
23
  */
21
24
  export function historyToLines(history, startIdx, endIdx) {
22
25
  const lines = []
26
+ // 痕行复列 = 全量(本批 2026-10-01 · 台账 #768 自然形跟正):逐记录出(记录序 ≡ 恢复序——零截点;
27
+ // 未结末轮照现——沿现行口径)。
23
28
  // Cross-page turn state: if the message BEFORE this page is a tool/assistant,
24
29
  // the page starts mid-turn and must NOT emit a fresh "❯ ThinCoder:" label
25
30
  // (a turn gets ONE label in the live run; history stores one assistant
@@ -39,9 +44,11 @@ export function historyToLines(history, startIdx, endIdx) {
39
44
  // text on restore — the image is invisible to the terminal and the user
40
45
  // just saw it live. A label with no content under it is noise (user
41
46
  // report 2026-08-30: stray "❯ You:" after the read_image block).
47
+ // B5 消(#677 · 跨线清零批):恢复面剥离——注入展开文 → `@原文`(核单源 `stripAtRefs`,零第二实现;
48
+ // 手打 `[File: x]` 形近文本零误伤 = 核件 fail-closed 判据——与 VSC `panel-session.mjs` 同款逆变换)。
42
49
  const userText = typeof m.content === "string"
43
- ? m.content
44
- : (Array.isArray(m.content) ? m.content.find((p) => p?.type === "text")?.text ?? "" : "")
50
+ ? stripAtRefs(m.content)
51
+ : (Array.isArray(m.content) ? stripAtRefs(m.content.find((p) => p?.type === "text")?.text ?? "") : "")
45
52
  const displayable = typeof m.content === "string" ? !!m.content : !!(userText && userText.trim())
46
53
  if (!displayable) continue
47
54
  if (lines.length > 0) lines.push({ text: "", color: C.dim })
@@ -107,6 +114,17 @@ export function historyToLines(history, startIdx, endIdx) {
107
114
  },
108
115
  })
109
116
  }
117
+ } else if (m.kind === "digest") {
118
+ // #726 记录分支:痕行逐条复列(文案 = lifecycle-records 单一实现——与活流同算式);
119
+ // **本批 2026-10-01(台账 #768 自然形跟正)**:复列 = **全量**(记录序 ≡ 恢复序——零截点;
120
+ // 未结末轮照现——沿现行口径);
121
+ // end 的 `n` 解析序 = 预解析注记(跨页回扫产物)→ 页内(含 ±1 页沿)回扫。
122
+ const startN = m.status === "end" ? resolvedStartN(history, i) : null
123
+ lines.push(...digestTraceLines(m, startN))
124
+ } else if (m.kind === "subagent") {
125
+ // #726 记录分支:归档快照 ⇒ 冻结载体行 + 合成件(渲染端零改消费);#790:行文取值同源自合成件 `key`
126
+ const frozen = synthSubTask(m) // 读面已剥 `sub:` 前缀——显示 = 本地无前缀形(与活流冻结载体同形)
127
+ lines.push({ text: `subagent activity: ${frozen.key}`, color: C.dim, _frozenSubTask: frozen })
110
128
  }
111
129
  }
112
130
  return lines
@@ -131,6 +149,8 @@ export function restoreLines(state, desc) {
131
149
  const loaded = Math.min(INITIAL_HISTORY_MESSAGES, total)
132
150
  const start = total - loaded
133
151
  state._lineIdCounter = state._lineIdCounter ?? 0
152
+ // #726 终态行 `n` 预解析(跨页分裂 ⇒ 存储回扫——调用面持有存储读口;页内直用者零回扫)
153
+ resolveSplitTerminalNs(window, base, state._agent?._recordStore)
134
154
  const fresh = historyToLines(window, Math.max(0, start - base), window.length)
135
155
  for (const l of fresh) l.text = capLine(l.text) // 行额度(§5.3 恢复行过 capText)
136
156
  // Stable per-line ids (P1, 2026-08-30): fold keys for tool blocks derive from
@@ -175,6 +195,8 @@ export function createLoadOlder({ agent, state, render }) {
175
195
  let older
176
196
  if (store?.page) {
177
197
  const { messages, base } = store.page(start, end, { margin: 1 })
198
+ // #726 终态行 `n` 预解析(页内(含 ±1 页沿)缺席本轮 start ⇒ 存储回扫)
199
+ resolveSplitTerminalNs(messages, base, store)
178
200
  older = historyToLines(messages, Math.max(0, start - base), Math.min(messages.length, end - base))
179
201
  } else {
180
202
  older = historyToLines(agent._fullHistory ?? [], start, end)
@@ -235,6 +257,9 @@ export function showStartup(ctx) {
235
257
  if (allSlots.length > 1) {
236
258
  pushLine(`Tip: ${allSlots.length} sessions — /session to view/switch`, C.dim)
237
259
  }
260
+ // F-L4(账本可靠批 · SESSION.md §6.25 判据句 4):账本异常警示行——**不继承** `allSlots.length > 1`(单 / 零会话项目同样在场);正常 ⇒ 零行。
261
+ const lh = ledgerHealth(agent.cwd)
262
+ if (lh.refused > 0 || lh.scene) pushLine(`会话账本异常(${lh.refused > 0 ? lh.lastReason : "scene"})——打开会话即自动补回${lh.scene ? ";损坏现场档保留 30 天" : ""}`, C.warn)
238
263
  render()
239
264
  }
240
265
 
@@ -1,9 +1,9 @@
1
1
  /**
2
- * subagent-blocks.mjs — 子agent 活动区块缓冲(AGENT-LOOP.md §7.2 D4,消费端)。
3
- * state.subTasks[key] = { key, role, model, async(§19.5 D-M7b——⟦ev⟧async 标记——
2
+ * subagent-blocks.mjs — 子agent 活动区块缓冲(docs/cli/design/TUI.md §6.8,消费端)。
3
+ * state.subTasks[key] = { key, role, model, async(AGENT-LOOP-SUBAGENT.md §6.7.2 D-M7b——⟦ev⟧async 标记——
4
4
  * undefined = sync), started, done, doneAt, blocks:
5
5
  * [{kind,text}], currentTool, toolArgs, turn, maxTurns, approval, lastError,
6
- * dropped, blockEpoch, awaitingDigest(§17), _freezeAt(冻结锚点), stopped(§19.5),
6
+ * dropped, blockEpoch, awaitingDigest(AGENT-LOOP-ASYNC-POOL.md §6.8), _freezeAt(冻结锚点), stopped(AGENT-LOOP-SUBAGENT.md §6.7.2),
7
7
  * children: [](SUBAGENT-TAIL:嵌套子代理**守护载体**——内容并入本块 blocks——见
8
8
  * subagent-children.mjs)}。
9
9
  * 职责:前缀路由(parseRelayPath——**SUBAGENT-TAIL(D-ST1):嵌套段内容行并入外层
@@ -15,7 +15,7 @@
15
15
  */
16
16
 
17
17
  import { C } from "./ansi.mjs"
18
- // 第 27 批 §12.3①/②:relay 前缀文法单一权威 = src/agent/relay-prefix.mjs——本文件
18
+ // 第 27 批:relay 前缀文法单一权威 = src/agent/relay-prefix.mjs——本文件
19
19
  // 不再自持前缀正则/解析副本(防第二套平行正则再漂移)。
20
20
  import { parseRelayPath } from "@thincoder/core/agent/relay-prefix.mjs"
21
21
  // zero-block 批(TUI.md §6.8.3.2 P1):墓碑存活闸复活分支留痕——日志面(零新增 UI 形态)。
@@ -26,34 +26,33 @@ import {
26
26
  closeSubChild,
27
27
  } from "./subagent-children.mjs"
28
28
  export { SUB_BLOCK_LINE_LIMIT, appendSubBlock } from "./subagent-children.mjs"
29
- // 2026-09-05 module-split:finish/freeze 族 + §19.6 面板现算迁 subagent-freeze.mjs——
29
+ // 2026-09-05 module-split:finish/freeze 族 + AGENT-LOOP-SUBAGENT.md §6.7.2 面板现算迁 subagent-freeze.mjs——
30
30
  // routeSub*/compression panel 内部用本地 import;re-export 保外部 import 面(index.mjs 等)
31
31
  // CLI-ACTIVITY-DEBLOAT F-3(2026-09-10):面板手工镜像退役——re-export 面换现算导出
32
32
  // computePanelBlocks(读时现算),本文件全部刷镜调用点删除(单账本——变更点不再手动同步)。
33
- import { freezeSubTaskLines, finishSubTaskKey, finishSubTask, freezeDoneSubTasks, finishSubTasksByRole, freezeAllSubTasks, freezeReclaimDigestedBlocks, shiftFreezeAnchors, livePoolHas, removeFrozenSubTaskLine, tombstoneSubKey } from "./subagent-freeze.mjs"
34
- export { computePanelBlocks, finishSubTask, finishSubTaskKey, freezeSubTaskLines, shiftFreezeAnchors, freezeDoneSubTasks, finishSubTasksByRole, freezeAllSubTasks, freezeReclaimDigestedBlocks } from "./subagent-freeze.mjs"
33
+ import { freezeSubTaskLines, finishSubTaskKey, finishSubTask, freezeDoneSubTasks, freezeAllSubTasks, freezeReclaimDigestedBlocks, shiftFreezeAnchors, livePoolHas, removeFrozenSubTaskLine, tombstoneSubKey } from "./subagent-freeze.mjs"
34
+ export { computePanelBlocks, finishSubTask, finishSubTaskKey, freezeSubTaskLines, shiftFreezeAnchors, freezeDoneSubTasks, freezeAllSubTasks, freezeReclaimDigestedBlocks } from "./subagent-freeze.mjs"
35
35
 
36
- // 前缀文法/解析已迁 src/agent/relay-prefix.mjs(第 27 批 §12.3②)——本文件不自持副本。
36
+ // 前缀文法/解析已迁 src/agent/relay-prefix.mjs(第 27 批)——本文件不自持副本。
37
37
  /** ⟦ev⟧ token parser:`⟦ev⟧<name>\x1e<n>\x1e<max>\x1e<phase>\x1e<detail>`。phase done
38
- * = async 完成即冻结(settle 时发);settled = 挂起期完成——冻结延迟至 digest 消化
39
- * 完成(§17.5.5 freezeReclaimDigestedBlocks 逐条回收——不等池空)或池空退出兜底补发;
40
- * stopped(§19.5 D-M6)= cancel 中止——interrupted 语义立即冻结(标题 "stopped");
41
- * queued(§20 D-SD3b)= 排队 spawn 返回即建 waiting 块(round2 #2 事件通道——
38
+ * = 消费面补发(#746:settle 一律发 settled)——收即冻结;settled = 完成(挂起 ∥ 非挂起两态)——
39
+ * 冻结延迟至消费窗(AGENT-LOOP-ASYNC-POOL.md §6.8 freezeReclaimDigestedBlocks 逐条回收——不等池空 ∥ 退出兜底补发);
40
+ * stopped(AGENT-LOOP-SUBAGENT.md §6.7.2 D-M6)= cancel 中止——interrupted 语义立即冻结(标题 "stopped");
41
+ * queued(AGENT-LOOP-SUBAGENT.md §6.9 D-SD3b)= 排队 spawn 返回即建 waiting 块(round2 #2 事件通道——
42
42
  * `⟦ev⟧queued\x1e<kind>\x1e<position>\x1equeued\x1e<detail>`——kind slot/wait/depc)——
43
- * 启动后 ⟦ev⟧async 转 running(同 key 不重建);cancelled(§20)= 出队/取消——**零字段**
43
+ * 启动后 ⟦ev⟧async 转 running(同 key 不重建);cancelled(§6.9)= 出队/取消——**零字段**
44
44
  * 移除块(不冻结——无活动可冻结)——与 async 同型单独解析(不入本正则);
45
- * async(§19.5 D-M7b + 处置 #4)= **零字段**标记(`⟦ev⟧async\x1e`——无 n/max/phase/detail 段)——
45
+ * async(AGENT-LOOP-SUBAGENT.md §6.7.2 D-M7b + 处置 #4)= **零字段**标记(`⟦ev⟧async\x1e`——无 n/max/phase/detail 段)——
46
46
  * routeSubToken 单独解析设 sub.async = true(不入本正则——本正则要求 4 字段);**缺失 key
47
47
  * (块尚未创建)缓冲 `state._pendingAsyncKeys`——ensureSubTaskKey 块创建时应用(兜底)。 */
48
48
  export const SUB_EVENT_RE = /^⟦ev⟧(turn|approval|done|settled|stopped|queued)\x1e([^\x1e]*)\x1e([^\x1e]*)\x1e([^\x1e]*)\x1e?([\s\S]*)$/
49
49
 
50
- // parseRelayPath 自 src/agent/relay-prefix.mjs import(第 27 批 §12.3①——文法单一权威,
50
+ // parseRelayPath 自 src/agent/relay-prefix.mjs import(第 27 批——文法单一权威,
51
51
  // 语义与迁移前逐字一致;“嵌套解析任意深度/无前缀 → null”见该模块)。
52
52
 
53
53
  /** N1: render-layer throttle for child tool-output appends (generation relays verbatim). */
54
54
  export const SUB_RELAY_THROTTLE_MS = 250
55
- /** Roles a subagent tool child can take(⏹ 门控/渲染角色判据;F-2 后角色匹配完成面 =
56
- * finishSubTasksByRole——finishSubTask 已收窄 no-op)。 */
55
+ /** Roles a subagent tool child can take(⏹ 门控/渲染角色判据). */
57
56
  export const SUBAGENT_ROLES = ["sub", "explore", "plan", "coder", "eng-coder", "eng-designer"]
58
57
 
59
58
  let _subRenderLast = 0
@@ -78,7 +77,7 @@ export function throttleSubRender(scheduleRender) {
78
77
  _subRenderTimer.unref?.()
79
78
  }
80
79
 
81
- /** §19.5: key/role 分拆版核心(routeSub* 经 parseRelayPath;兼容 ensureSubTask(match))。 */
80
+ /** AGENT-LOOP-SUBAGENT.md §6.7.2: key/role 分拆版核心(routeSub* 经 parseRelayPath;兼容 ensureSubTask(match))。 */
82
81
  export function ensureSubTaskKey(state, key, role) {
83
82
  // Tombstone guard:abort 子代理冻结后晚到 token 不得复活区块(2026-08-30 残项)
84
83
  state._frozenSubKeys ??= new Set()
@@ -100,15 +99,18 @@ export function ensureSubTaskKey(state, key, role) {
100
99
  key, role, model: undefined, started: Date.now(), done: false, doneAt: null,
101
100
  blocks: [], currentTool: null, toolArgs: null, turn: 0, maxTurns: 0, approval: null,
102
101
  lastError: null, dropped: 0,
103
- _lineCount: 0, _charCount: 0, // 双维记账(TUI.md §15.3.4——行数维 + 字符维,subagent-children.mjs)
104
- stopped: false, // §19.5: ⟦ev⟧stopped 冻结标记(标题 "stopped")
102
+ _lineCount: 0, _charCount: 0, // 双维记账(TUI-SESSION-VIEW.md §5.4——行数维 + 字符维,subagent-children.mjs)
103
+ stopped: false, // AGENT-LOOP-SUBAGENT.md §6.7.2: ⟦ev⟧stopped 冻结标记(标题 "stopped")
105
104
  children: [], // SUBAGENT-TAIL: 嵌套子代理守护载体(内容并入本块——subagent-children.mjs)
106
105
  }
107
- // §19.5 D-M7b ①(处置 #4 兜底——2026-09-03):缺失 key 的 ⟦ev⟧async 事件已缓冲
106
+ // AGENT-LOOP-SUBAGENT.md §6.7.2 D-M7b ①(处置 #4 兜底——2026-09-03):缺失 key 的 ⟦ev⟧async 事件已缓冲
108
107
  // pending 标志(routeSubToken——async 先于块创建到达:排队条目补位启动的时序窗口)
109
108
  // ——块创建(此处)即应用——sub.async 区块创建即知(⏹ 门控/头标判定源保真)。
110
109
  if (state._pendingAsyncKeys?.delete(key)) state.subTasks[key].async = true
111
110
  }
111
+ // 停滞轻显形(TUI.md §7.7 · stall-indicator 批 2026-09-29):③子代理面 = 可见输出事件——
112
+ // 内容回显四路由(text ∕ think ∕ 工具 ∕ 输出)与 ⟦ev⟧ 块状态事件均先经本函数 ⇒ 单点重置静默起算。
113
+ state.lastOutputAt = Date.now()
112
114
  return state.subTasks[key]
113
115
  }
114
116
 
@@ -150,7 +152,7 @@ export function routeSubToken(state, t, scheduleRender) {
150
152
  if (!path) return false
151
153
  const payload = path.rest
152
154
  const nested = path.inner.length > 0
153
- // §19.5 D-M7b ① + 处置 #4(2026-09-03 评审 #4——兜底):零字段 async 标记在
155
+ // AGENT-LOOP-SUBAGENT.md §6.7.2 D-M7b ① + 处置 #4(2026-09-03 评审 #4——兜底):零字段 async 标记在
154
156
  // ensureSubTaskKey **之前**单独消费(async spawn 实际启动即发——sync 不发——精确
155
157
  // 匹配:async 后须 RS 或串尾——正文伪前缀形态不误吞;内容侧伪哨兵已由生成侧
156
158
  // stripEventToken 先行剥除——本分支只见真事件)。live 区块直接置位 sub.async;
@@ -158,7 +160,7 @@ export function routeSubToken(state, t, scheduleRender) {
158
160
  // pending 标志**(state._pendingAsyncKeys——ensureSubTaskKey 块创建时应用——async
159
161
  // 事件先于块存在到达不丢——queued→running ⏹ 可见性保真——⏹ 门控与头标 async 判定源)。
160
162
  // 内层(嵌套 explore#M/)async 剥除不路由(防污染外层块头)。
161
- // §20 D-SD3b:queued 等待块实际启动(补位/释放)→ **转 running——同 key 不重建**
163
+ // §6.9 D-SD3b:queued 等待块实际启动(补位/释放)→ **转 running——同 key 不重建**
162
164
  // ——清 waiting 标注(sub.queued)+ started 归零(elapsed 从实际启动计时——与池
163
165
  // startedAt 语义一致——排队等待不计 elapsed)。
164
166
  if (!nested && /^⟦ev⟧async(\x1e|$)/.test(payload)) {
@@ -169,6 +171,7 @@ export function routeSubToken(state, t, scheduleRender) {
169
171
  delete live.queued
170
172
  live.started = Date.now()
171
173
  }
174
+ state.lastOutputAt = Date.now() // 停滞轻显形(§7.7):③块状态——queued→running 转正(头标 ∕ 走时可感)
172
175
  }
173
176
  else {
174
177
  state._pendingAsyncKeys ??= new Set()
@@ -177,7 +180,7 @@ export function routeSubToken(state, t, scheduleRender) {
177
180
  scheduleRender()
178
181
  return true
179
182
  }
180
- // §20 D-SD3b(round2 #2——cancelled 事件):取消/出队 → 移除等待块(**不冻结**——
183
+ // §6.9 D-SD3b(round2 #2——cancelled 事件):取消/出队 → 移除等待块(**不冻结**——
181
184
  // 从未启动无活动可冻结;running 取消走 ⟦ev⟧stopped 冻结——两者通道分明)。守卫:
182
185
  // 仅 !async 块(waiting 块未启动——async 置位 = running——running 的取消以 stopped
183
186
  // 通道表达,cancelled 永不合法指向 running 块——迟到/失序事件不误删)。缺失块
@@ -190,16 +193,17 @@ export function routeSubToken(state, t, scheduleRender) {
190
193
  // ⇒ 后续 ⟦ev⟧stopped 经 ensureSubTaskKey 直接丢弃(零幻影冻结块——探针实证的
191
194
  // state.lines 0 → 1 路径封死);no-block 面不写(无可防之幻影——语义零扩)。
192
195
  tombstoneSubKey(state, path.head)
196
+ state.lastOutputAt = Date.now() // 停滞轻显形(§7.7):③块状态——出队 ∕ 取消块移除(可见)
193
197
  }
194
198
  scheduleRender()
195
199
  return true
196
200
  }
197
201
  const sub = ensureSubTaskKey(state, path.head, path.head.slice(0, path.head.lastIndexOf("#")))
198
202
  if (!sub) return true // frozen tombstone — late token from an aborted child: drop
199
- // ⟦ev⟧:turn/approval 进度 → 仅头部(D1——不进 blocks/主流);done(§15 D-A3)=
200
- // 完成即冻结(settle 时发);settled(§17 D-S8)= 挂起期完成——驻留面板中间态
201
- // "done · awaiting digestion",池空补发冻结;stopped(§19.5)= cancel——立即冻结;
202
- // queued(§20 D-SD3b)= 排队 spawn 等待块(spawn 返回即建/状态变迁刷新——覆盖式
203
+ // ⟦ev⟧:turn/approval 进度 → 仅头部(D1——不进 blocks/主流);done(AGENT-LOOP-SUBAGENT.md §6.7.3 D-A3)= 消费面补发(#746:
204
+ // settle 一律发 ⟦ev⟧settled)——收即冻结;settled(AGENT-LOOP-ASYNC-POOL.md §6.8 D-S8)= 完成(挂起 ∥ 非挂起两态)——
205
+ // 驻留面板中间态 "done · awaiting digestion",冻结延迟至消费窗(回收 ∥ 退出兜底);stopped(AGENT-LOOP-SUBAGENT.md §6.7.2)= cancel——立即冻结;
206
+ // queued(§6.9 D-SD3b)= 排队 spawn 等待块(spawn 返回即建/状态变迁刷新——覆盖式
203
207
  // 更新 sub.queued——块不可展开(无活动流);启动后 async 事件清标转 running)。
204
208
  if (payload.startsWith("⟦ev⟧")) {
205
209
  // 内层完成信号(沿革:D-M8 子标 → R23 子块段 → SUBAGENT-TAIL 并入——显示契约
@@ -239,7 +243,7 @@ export function routeSubToken(state, t, scheduleRender) {
239
243
  sub.done = true
240
244
  sub.doneAt = Date.now()
241
245
  sub.awaitingDigest = true
242
- // 冻结锚点(2026-09-03 修复轮):settle 时刻流位置——§17.5.5 digest 完成逐条回收
246
+ // 冻结锚点(2026-09-03 修复轮):settle 时刻流位置——AGENT-LOOP-ASYNC-POOL.md §6.8 digest 完成逐条回收
243
247
  // (freezeReclaimDigestedBlocks)与未消化残项的池空退出兜底(freezeAllSubTasks)
244
248
  // 都按它 splice 落位——digest 总览文本之前(round1 #1 裁定——T-S6/T-S14 同口径)。
245
249
  sub._freezeAt = state.lines?.length ?? 0
@@ -409,6 +413,7 @@ export function ensureCompressPanel(state, info = {}) {
409
413
  const messages = Number.isInteger(info.messages) && info.messages >= 0 ? info.messages : "?"
410
414
  appendSubBlock(panel, "status", "Compressing context…\n", { fresh: true })
411
415
  appendSubBlock(panel, "meta", `summarizing ${messages} messages\n`, { fresh: true })
416
+ state.lastOutputAt = Date.now() // 停滞轻显形(§7.7 语义填充——压缩面板起始 ∕ 刷新 = 可见输出)
412
417
  return panel
413
418
  }
414
419
 
@@ -419,6 +424,7 @@ export function markCompressFailed(state, error) {
419
424
  const text = error?.message ? String(error.message) : String(error ?? "unknown error")
420
425
  panel.lastError = text
421
426
  appendSubBlock(panel, "err", `Compression failed: ${text}\n`, { fresh: true })
427
+ state.lastOutputAt = Date.now() // 停滞轻显形(§7.7 语义填充——压缩失败行 = 可见输出)
422
428
  }
423
429
 
424
430
  /** 冻结压缩面板进流(同完成子代理的载体/折叠 key)+ 释放 live 条目。 */
@@ -438,6 +444,7 @@ export function markCompressDone(state, info = {}) {
438
444
  panel.currentTool = null
439
445
  appendSubBlock(panel, "status", `Compressed: ${tokensFreed} tokens freed → summary (${seconds}s)\n`, { fresh: true })
440
446
  freezeCompressPanel(state, panel)
447
+ state.lastOutputAt = Date.now() // 停滞轻显形(§7.7 语义填充——压缩完成冻结块入流 = 可见输出)
441
448
  }
442
449
 
443
450
  /** 降级态(onCompress mode:"fallback")——3 连败后 "truncated to N messages"。 */
@@ -450,4 +457,5 @@ export function markCompressFallback(state, info = {}) {
450
457
  panel.currentTool = null
451
458
  appendSubBlock(panel, "err", `Compression failed — fallback: truncated to ${tailMessages} messages\n`, { fresh: true })
452
459
  freezeCompressPanel(state, panel)
460
+ state.lastOutputAt = Date.now() // 停滞轻显形(§7.7 语义填充——降级冻结块入流 = 可见输出)
453
461
  }