thincoder 0.12.49 → 0.12.51

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 (41) hide show
  1. package/CHANGELOG.md +45 -2
  2. package/package.json +4 -3
  3. package/src/acp/bridge.mjs +4 -0
  4. package/src/agent/dispatch.mjs +19 -7
  5. package/src/agent/helpers.mjs +12 -0
  6. package/src/agent/record-results.mjs +130 -0
  7. package/src/agent/setup.mjs +5 -8
  8. package/src/agent/spawn-child.mjs +159 -0
  9. package/src/agent-tools/consult.mjs +95 -73
  10. package/src/agent-tools/escalate.mjs +53 -62
  11. package/src/agent-tools/subagent.mjs +39 -38
  12. package/src/agent.mjs +25 -109
  13. package/src/generate-title.mjs +30 -1
  14. package/src/prompts/advisor-round1.md +5 -6
  15. package/src/prompts/advisor-round2.md +3 -4
  16. package/src/prompts/advisor-round3.md +3 -4
  17. package/src/prompts/eng-coder.md +10 -0
  18. package/src/prompts/engineering.md +81 -14
  19. package/src/prompts/methodology-template.md +8 -3
  20. package/src/prompts/system.md +1 -1
  21. package/src/session.mjs +48 -1
  22. package/src/tools/system.mjs +3 -1
  23. package/src/tui/agent-turn.mjs +44 -363
  24. package/src/tui/cmd-advisor.mjs +20 -2
  25. package/src/tui/cmd-eng.mjs +44 -7
  26. package/src/tui/dims.mjs +74 -0
  27. package/src/tui/fold-block.mjs +208 -0
  28. package/src/tui/index.mjs +53 -16
  29. package/src/tui/key-handler-search.mjs +1 -1
  30. package/src/tui/key-handler.mjs +9 -6
  31. package/src/tui/layout.mjs +21 -20
  32. package/src/tui/mouse.mjs +8 -6
  33. package/src/tui/pickers.mjs +1 -1
  34. package/src/tui/render-conversation.mjs +368 -113
  35. package/src/tui/render-frame.mjs +9 -88
  36. package/src/tui/render-loop.mjs +12 -8
  37. package/src/tui/render.mjs +5 -0
  38. package/src/tui/startup.mjs +67 -13
  39. package/src/tui/subagent-blocks.mjs +326 -0
  40. package/src/tui/tool-args.mjs +67 -0
  41. package/src/tui/tool-events.mjs +461 -0
@@ -10,7 +10,7 @@ import { ansi, C, ESC } from "./ansi.mjs"
10
10
  import { convCacheKey, renderConversation, countConvLines } from "./render-conversation.mjs"
11
11
  import { sliceByWidth, stringWidth, sanitizeDisplay } from "./render.mjs"
12
12
  import { specForModel } from "../config.mjs"
13
- import { computeLayout, MAX_SUB_LINES } from "./layout.mjs"
13
+ import { computeLayout } from "./layout.mjs"
14
14
  import { basename } from "node:path"
15
15
 
16
16
  export { convCacheKey, renderConversation, countConvLines } from "./render-conversation.mjs"
@@ -45,93 +45,16 @@ export function renderHeader(agent, cols) {
45
45
  return `${ansi.bold}${C.tool} ThinCoder ${ansi.reset}${ansi.dim}│ ${sliceByWidth(model, 30)}${thinkBadge ? " " + thinkBadge : ""} │ ${sliceByWidth(basename(agent.cwd), Math.max(10, cols - 60))}${ansi.reset}`
46
46
  }
47
47
 
48
- /** Todo/task panel. Returns empty array when no tasks visible. */
48
+ /** Todo/task panel. Returns empty array when no tasks visible. The first row is
49
+ * a divider line separating the panel from the conversation above it (user
50
+ * request 2026-08-30). */
49
51
  export function renderTodo(visibleTasks, cols) {
50
- return visibleTasks.map((t) => {
52
+ const divider = `${C.dim}${"─".repeat(Math.max(1, cols - 1))}${ansi.reset}`
53
+ return [divider, ...visibleTasks.map((t) => {
51
54
  const mark = t.status === "done" ? "✓" : t.status === "in_progress" ? "▶" : "○"
52
55
  const color = t.status === "done" ? `${C.dim}${ESC}[9m` : t.status === "in_progress" ? C.tool : C.text
53
56
  return `${color} ${mark} ${sliceByWidth(t.title, cols - 4)}${ansi.reset}`
54
- })
55
- }
56
-
57
- /** Subagent panel. Returns empty when no subagents. */
58
- export function renderSubagent(allSubs, W) {
59
- const subs = allSubs
60
- if (subs.length === 0) return []
61
- const out = []
62
- for (const s of subs.slice(0, MAX_SUB_LINES)) {
63
- const icon = s.done ? "✓" : "…"
64
- const color = s.done ? C.dim : C.tool
65
- const label = `[${s.role}${s.model ? " · " + s.model : ""}]`.padEnd(10)
66
- let content
67
- if (s.done) {
68
- const elapsed = Math.floor((Date.now() - s.started) / 1000)
69
- content = `done ${elapsed}s`
70
- } else if (s.tool) {
71
- content = s.tool
72
- } else if (s.text) {
73
- const textLines = s.text.split("\n").filter((l) => l.trim())
74
- content = textLines.length > 0 ? textLines[textLines.length - 1] : "thinking..."
75
- } else {
76
- content = "thinking..."
77
- }
78
- out.push(`${color} ${icon} ${label} ${sliceByWidth(content, Math.max(10, W - 4 - stringWidth(label)))}${ansi.reset}`)
79
- }
80
- if (subs.length > MAX_SUB_LINES) {
81
- out.push(`${C.dim} ... +${subs.length - MAX_SUB_LINES} more subagents${ansi.reset}`)
82
- }
83
- return out
84
- }
85
-
86
- /** Per-kind panel colors: reasoning faint, tool progress cyan, main output gray. */
87
- const PANEL_KIND_COLORS = { think: C.reason, tool: C.tool, text: C.dim }
88
-
89
- /**
90
- * Flatten panel parts into display lines, tracking each line's kind.
91
- * A part not starting mid-line continues the previous line (kind of the line's first fragment wins).
92
- */
93
- function panelLines(p) {
94
- const lines = []
95
- for (const part of p.parts ?? []) {
96
- part.text.split("\n").forEach((seg, j) => {
97
- if (j === 0 && lines.length > 0) {
98
- const last = lines[lines.length - 1]
99
- // Empty trailing line = previous part ended exactly at a line break — the new
100
- // part owns this line's kind. Otherwise it's a genuine mid-line continuation.
101
- if (last.text === "") last.kind = part.kind
102
- last.text += seg
103
- } else {
104
- lines.push({ kind: part.kind, text: seg })
105
- }
106
- })
107
- }
108
- return lines.filter((l) => l.text.trim())
109
- }
110
-
111
- /** Tool output panels (streaming output like tail -f). Returns empty when no visible output. */
112
- export function renderOutput(state, W, panelH) {
113
- // Same visibility predicate as computeLayout: done panels linger until closeAt
114
- const active = Object.entries(state.outputPanels ?? {}).filter(([_, p]) => !p.done || (p.closeAt ?? 0) > Date.now())
115
- if (active.length === 0) return []
116
- const out = []
117
- const linesPerPanel = Math.max(1, Math.floor(panelH / active.length))
118
- for (const [toolName, p] of active) {
119
- const status = p.done ? `${C.dim}done${ansi.reset}` : `${C.tool}running${ansi.reset}`
120
- out.push(`${C.text}❯ ${sliceByWidth(sanitizeDisplay(toolName), Math.max(10, W - 25))} — ${status}`)
121
- const titleRows = 1
122
- const contentRows = Math.max(0, linesPerPanel - titleRows)
123
- const lines = contentRows > 0 ? panelLines(p).slice(-contentRows) : []
124
- for (const l of lines) {
125
- const color = PANEL_KIND_COLORS[l.kind] ?? C.dim
126
- out.push(`${color} │ ${sliceByWidth(sanitizeDisplay(l.text), W - 5)}${ansi.reset}`)
127
- }
128
- }
129
- // Safety: never exceed allocated height (could happen with many panels + small terminal)
130
- if (out.length > panelH) out.length = panelH
131
- // Fill remaining rows to match panelH exactly. `out.length` is accurate because
132
- // every pushed entry is a non-empty string (title or content line with prefix).
133
- for (let i = out.length; i < panelH; i++) out.push("")
134
- return out
57
+ })]
135
58
  }
136
59
 
137
60
  /** Permission preview panel. Returns empty when no permission request. */
@@ -293,7 +216,7 @@ export function renderRows(state, agent, opts) {
293
216
  const slashCommands = opts.slashCommands ?? []
294
217
 
295
218
  const layout = computeLayout(state, { cols, rows })
296
- const { W, panels, inputLayout, inputOffset, boxLines, visibleTasks, allSubs, permPreviewLines, overlay } = layout
219
+ const { W, panels, inputLayout, inputOffset, boxLines, visibleTasks, permPreviewLines, overlay } = layout
297
220
 
298
221
  const screen = new Array(rows).fill("")
299
222
  const put = (y, lines) => {
@@ -303,9 +226,7 @@ export function renderRows(state, agent, opts) {
303
226
  }
304
227
 
305
228
  put(panels.header.y, [renderHeader(agent, cols)])
306
- put(panels.conversation.y, renderConversation(state, cols, panels.conversation.h, state.scroll))
307
- if (panels.subagent) put(panels.subagent.y, renderSubagent(allSubs, W))
308
- if (panels.output) put(panels.output.y, renderOutput(state, W, panels.output.h))
229
+ put(panels.conversation.y, renderConversation(state, cols, panels.conversation.h, state.scroll, rows))
309
230
  if (panels.todo) put(panels.todo.y, renderTodo(visibleTasks, cols))
310
231
  if (panels.picker) put(panels.picker.y, renderPicker(state, cols, panels.picker, overlay))
311
232
  if (panels.permission) put(panels.permission.y, renderPermission(permPreviewLines))
@@ -56,19 +56,23 @@ export function createRenderLoop(state, agent, ctx, pushLine, write = (s) => pro
56
56
 
57
57
  function doRender() {
58
58
  try {
59
- const dims = { cols: process.stdout.columns || startupDims.cols, rows: process.stdout.rows || startupDims.rows }
59
+ // Single source (Windows ConPTY instability, 2026-08-30). CACHE ONLY
60
+ // no per-frame refresh: during heavy streaming output ConPTY reports a
61
+ // STALE buffer size (80) and per-frame sampling let that stale value
62
+ // hijack the cache (streaming content crammed into a left-hand sliver,
63
+ // restored to full width only after flush stopped the output). dims are
64
+ // updated by: startup seed + a delayed re-sample + resize events.
65
+ const dims = state.dims ? state.dims.get() : { cols: process.stdout.columns || startupDims.cols, rows: process.stdout.rows || startupDims.rows }
60
66
 
61
- // Expired output panels: prune once their close grace elapsed (done + closeAt in the past)
62
- const now = Date.now()
63
- for (const [name, p] of Object.entries(state.outputPanels ?? {})) {
64
- if (p.done && (p.closeAt ?? 0) <= now) delete state.outputPanels[name]
65
- }
67
+ // NOTE (§7.2 D6): the old state.outputPanels prune is gone output panels
68
+ // are abolished; subagent blocks live in the conversation and are never
69
+ // auto-pruned (bounded by the N2 per-child line cap instead).
66
70
 
67
71
  const { rows, cursorRow, cursorCol, layout } = renderRows(state, agent,
68
72
  { cols: dims.cols, rows: dims.rows, slashCommands: SLASH_COMMANDS })
69
73
 
70
74
  // Clamp scroll (bookkeeping for the status-bar hint; renderConversation clamps internally too)
71
- state.scroll = Math.min(state.scroll, Math.max(0, countConvLines(state, dims.cols) - layout.panels.conversation.h))
75
+ state.scroll = Math.min(state.scroll, Math.max(0, countConvLines(state, dims.cols, dims.rows) - layout.panels.conversation.h))
72
76
 
73
77
  if (state.ctxCache.len !== agent.history.length) {
74
78
  state.ctxCache = { len: agent.history.length, tokens: estimateTokens(agent.history) }
@@ -107,5 +111,5 @@ export function createRenderLoop(state, agent, ctx, pushLine, write = (s) => pro
107
111
  }
108
112
  }
109
113
 
110
- return { render, scheduleRender }
114
+ return { render, scheduleRender, get lastRenderAt() { return lastRenderAt } }
111
115
  }
@@ -209,6 +209,11 @@ const ANSI_SEQUENCE_RE = new RegExp(ANSI_SEQUENCE.source, "g")
209
209
  export function sanitizeDisplay(s) {
210
210
  return s
211
211
  .replace(ANSI_SEQUENCE_RE, "")
212
+ // §7.2 D5 fallback: an unparsed ⟦ev⟧ event token must never reach the grid —
213
+ // strip the sentinel + its RS-wrapped payload (⟦ev⟧turn\x1e…\x1e / bare RS/GS chars).
214
+ .replace(/⟦ev⟧[^\x1e\x1d]*\x1e[^\x1e\x1d]*\x1e[^\x1e\x1d]*\x1e[^\x1e\x1d]*\x1e?/g, "")
215
+ .replace(/⟦ev⟧[^\x1e\x1d]*/g, "")
216
+ .replace(/[\x1d\x1e]/g, "")
212
217
  .replace(/\r\n/g, "\n")
213
218
  .replace(/\r/g, "\n")
214
219
  .replace(/\t/g, " ")
@@ -1,5 +1,8 @@
1
1
  import { listSlots } from "../session.mjs"
2
2
  import { ansi, C } from "./ansi.mjs"
3
+ import { describeToolArgs, toolArgsLines } from "./tool-args.mjs"
4
+ import { sliceByWidth } from "./render.mjs"
5
+ import { slimToolResultForDisplay } from "./tool-events.mjs"
3
6
 
4
7
  /** Lazy history window (parity with VS Code HISTORY_PAGE_SIZE): first paint loads
5
8
  * the latest INITIAL_HISTORY_MESSAGES, then PgUp-at-top loads HISTORY_PAGE_MESSAGES
@@ -28,9 +31,19 @@ export function historyToLines(history, startIdx, endIdx) {
28
31
  const m = history[i]
29
32
  if (m.role === "user") {
30
33
  if (typeof m.content === "string" && m.content.startsWith("[System reminder:")) continue
34
+ // Label only when there is something to show: multimodal user messages
35
+ // (content = [text, image] array, injected after read_image) render NO
36
+ // text on restore — the image is invisible to the terminal and the user
37
+ // just saw it live. A label with no content under it is noise (user
38
+ // report 2026-08-30: stray "❯ You:" after the read_image block).
39
+ const userText = typeof m.content === "string"
40
+ ? m.content
41
+ : (Array.isArray(m.content) ? m.content.find((p) => p?.type === "text")?.text ?? "" : "")
42
+ const displayable = typeof m.content === "string" ? !!m.content : !!(userText && userText.trim())
43
+ if (!displayable) continue
31
44
  if (lines.length > 0) lines.push({ text: "", color: C.dim })
32
45
  lines.push({ text: "❯ You:", color: ansi.bold + C.user })
33
- if (typeof m.content === "string" && m.content) lines.push({ text: m.content, color: C.text })
46
+ if (userText) lines.push({ text: userText, color: C.text, _kind: "text" })
34
47
  inTurn = false
35
48
  } else if (m.role === "assistant") {
36
49
  if (!inTurn) {
@@ -39,22 +52,57 @@ export function historyToLines(history, startIdx, endIdx) {
39
52
  lines.push({ text: "❯ ThinCoder:", color: ansi.bold + C.assistant })
40
53
  }
41
54
  inTurn = true
42
- // Reasoning restored as dim lines (folded by the consecutive-dim rule when
43
- // long) matches the live thinking stream instead of vanishing on restore.
55
+ // Reasoning restored as ONE C.reason line entry the exact shape
56
+ // flushStream produces live (single line, full string, no indent), so
57
+ // buildConvLines treats restored thinking IDENTICALLY: >12 wrapped rows
58
+ // fold under the named "▶ thinking" header, short fragments stay visible
59
+ // — same thresholds, same label, both paths. (History: restored thinking
60
+ // used to be split into dim fragments — the consecutive-dim rule's >8
61
+ // threshold never fired on short agentic thinking bursts, so restored
62
+ // sessions showed every fragment unfolded and mislabeled "tool output";
63
+ // user reported thinking "no longer folds" after a restart, 2026-08-30.)
44
64
  const reasoning = m.reasoning_content ?? m.reasoning
45
- if (typeof reasoning === "string" && reasoning.trim()) {
46
- for (const line of reasoning.split("\n")) lines.push({ text: " " + line, color: C.dim })
47
- }
65
+ if (typeof reasoning === "string" && reasoning.trim()) lines.push({ text: reasoning, color: C.reason, _kind: "thinking" })
48
66
  if (typeof m.content === "string" && m.content) lines.push({ text: m.content, color: C.text })
49
67
  for (const tc of m.tool_calls ?? []) {
50
68
  const toolResult = history[i + 1]
51
69
  const hasResult = toolResult?.role === "tool" && toolResult?.tool_call_id === tc.id
52
- lines.push({ text: ` [tool] ${tc.function?.name ?? "?"}`, color: C.tool })
53
- if (hasResult && String(toolResult.content).trim()) {
54
- // FULL tool result as dim lines (auto-folded when > 8) — the old
55
- // first-line-only summary made restore feel nothing like the live run.
56
- for (const line of String(toolResult.content).split("\n")) lines.push({ text: " " + line, color: C.dim })
57
- }
70
+ const tcName = tc.function?.name ?? "?"
71
+ // Tool arguments are part of the visible conversation (2026-08-30 user
72
+ // report: restored sessions showed no args at all — the deprecated
73
+ // display snapshot made historyToLines the ONLY restore path, and it
74
+ // never rendered arguments). Header line = readable key-args summary
75
+ // (describeToolArgs, vscode card-header parity); full pretty JSON rides
76
+ // below as dim lines, auto-folded by the consecutive-dim rule exactly
77
+ // like the restored tool result — same convention, same readability.
78
+ let argsSummary = ""
79
+ let argJson = []
80
+ let rawArgs = null
81
+ try {
82
+ const args = JSON.parse(tc.function?.arguments || "{}")
83
+ argsSummary = describeToolArgs(tcName, args)
84
+ argJson = toolArgsLines(args)
85
+ } catch { rawArgs = String(tc.function?.arguments ?? "").slice(0, 120) /* malformed — raw fallback */ }
86
+ // ONE BLOCK PER TOOL CALL — same carrier the live path emits (user
87
+ // ruling 2026-08-30): header=name+args, body=args JSON + result.
88
+ // Same carrier the live path emits — identical fields, so buildConvLines
89
+ // renders both through one code path (line-level parity by construction).
90
+ lines.push({
91
+ text: "", color: C.tool,
92
+ _kind: "tool",
93
+ _toolBlock: {
94
+ name: tcName,
95
+ roundTag: "",
96
+ argsSummary,
97
+ argsJson: rawArgs ? [rawArgs] : argJson,
98
+ output: [],
99
+ result: hasResult ? slimToolResultForDisplay(String(toolResult.content)) : null,
100
+ summary: null,
101
+ started: 0,
102
+ done: true,
103
+ elapsed: null,
104
+ },
105
+ })
58
106
  }
59
107
  }
60
108
  }
@@ -72,7 +120,13 @@ export function restoreLines(state, history) {
72
120
  const total = Array.isArray(history) ? history.length : 0
73
121
  if (total === 0) return
74
122
  const start = Math.max(0, total - INITIAL_HISTORY_MESSAGES)
75
- state.lines.push(...historyToLines(history, start, total))
123
+ state._lineIdCounter = state._lineIdCounter ?? 0
124
+ const fresh = historyToLines(history, start, total)
125
+ // Stable per-line ids (P1, 2026-08-30): fold keys for tool blocks derive from
126
+ // _lineId so loadOlder's head-unshift cannot re-bind an expanded block to a
127
+ // different tool (positional tool-{i} keys drift under unshift).
128
+ for (const l of fresh) l._lineId = ++state._lineIdCounter
129
+ state.lines.push(...fresh)
76
130
  state._historyLoaded = total - start
77
131
  state._historyTotal = total
78
132
  state._hasOlder = start > 0
@@ -0,0 +1,326 @@
1
+ /**
2
+ * subagent-blocks.mjs — 子agent 活动区块缓冲(AGENT-LOOP.md §7.2 D4,消费端)。
3
+ *
4
+ * state.subTasks[key] 是完整的活动缓冲:{ key, role, model, started, done, doneAt,
5
+ * blocks: [{kind,text}], currentTool, toolArgs, turn, maxTurns, approval, lastError,
6
+ * dropped, blockEpoch }。区块是子agent 活动的唯一载体(子工具调用不进父历史),
7
+ * 跨 turn 保留、可重新展开;渲染为会话流内可折叠区块(render-conversation.mjs)。
8
+ *
9
+ * 数据层职责:前缀路由、事件 token 解析(D1/D2)、kind 合并追加、N2 环形上限、
10
+ * N1 渲染节流(渲染调度节流,数据追加永不延迟)、完成冻结(freezeSubTaskLines
11
+ * 家族——_frozenSubTask 载体行,渲染端 render-conversation.mjs 识别)。
12
+ */
13
+
14
+ import { C } from "./ansi.mjs"
15
+
16
+ /** `role#id/` prefix router — hyphen included since the eng-coder fix (2026-08-21). */
17
+ export const SUB_PREFIX_RE = /^([\w-]+)#(\d+)\//
18
+ /** ⟦ev⟧ event token parser (D1/D2): `⟦ev⟧<name>\x1e<n>\x1e<max>\x1e<phase>\x1e<detail>`. */
19
+ export const SUB_EVENT_RE = /^⟦ev⟧(turn|approval)\x1e([^\x1e]*)\x1e([^\x1e]*)\x1e([^\x1e]*)\x1e?([\s\S]*)$/
20
+ /** N2: per-child display-line ring buffer cap — oldest lines drop with a marker. */
21
+ export const SUB_BLOCK_LINE_LIMIT = 500
22
+ /** N1: render-layer throttle for child tool-output appends (generation relays verbatim). */
23
+ export const SUB_RELAY_THROTTLE_MS = 250
24
+ /** Roles a subagent tool child can take (finishSubTask matches the block's role). */
25
+ export const SUBAGENT_ROLES = ["sub", "explore", "plan", "coder", "eng-coder"]
26
+
27
+ let _subRenderLast = 0
28
+ let _subRenderTimer = null
29
+
30
+ /** N1 throttle: leading-edge render + one coalesced trailing flush (final chunk
31
+ * within a window is not lost). Data appends are NEVER throttled — rendering only. */
32
+ export function throttleSubRender(scheduleRender) {
33
+ const now = performance.now()
34
+ const wait = SUB_RELAY_THROTTLE_MS - (now - _subRenderLast)
35
+ if (wait <= 0) {
36
+ _subRenderLast = now
37
+ scheduleRender()
38
+ return
39
+ }
40
+ if (_subRenderTimer) return
41
+ _subRenderTimer = setTimeout(() => {
42
+ _subRenderTimer = null
43
+ _subRenderLast = performance.now()
44
+ scheduleRender()
45
+ }, wait)
46
+ _subRenderTimer.unref?.()
47
+ }
48
+
49
+ export function ensureSubTask(state, subMatch) {
50
+ const key = `${subMatch[1]}#${subMatch[2]}`
51
+ // Tombstone guard (2026-08-30 consult residual): a child aborted mid-flight
52
+ // can relay tail tokens AFTER its block was frozen — ensureSubTask must not
53
+ // resurrect it (the recreated running block had no one left to freeze it and
54
+ // sat pinned above the input box until the next turn).
55
+ state._frozenSubKeys ??= new Set()
56
+ if (state._frozenSubKeys.has(key)) return null
57
+ state.subTasks ??= {}
58
+ if (!state.subTasks[key]) {
59
+ state.subTasks[key] = {
60
+ key, role: subMatch[1], model: undefined, started: Date.now(), done: false, doneAt: null,
61
+ blocks: [], currentTool: null, toolArgs: null, turn: 0, maxTurns: 0, approval: null,
62
+ lastError: null, dropped: 0,
63
+ }
64
+ }
65
+ return state.subTasks[key]
66
+ }
67
+
68
+ const countBlockLines = (text) => text.split("\n").length
69
+
70
+ /** Append (kind-merging) with the N2 ring-buffer cap. fresh=true starts a new block
71
+ * even when the previous block has the same kind (used per tool call). */
72
+ export function appendSubBlock(sub, kind, text, { fresh = false } = {}) {
73
+ if (!text) return
74
+ const last = sub.blocks.at(-1)
75
+ if (!fresh && last && last.kind === kind) {
76
+ // Merged append: account only the NET new lines. Counting the full split of
77
+ // every single-line chunk (each ends with \n → 2 segments) would inflate the
78
+ // incremental total far above the block's real line count (P1 修, 2026-08-30).
79
+ const before = countBlockLines(last.text)
80
+ last.text += text
81
+ sub._lineCount = (sub._lineCount ?? 0) + countBlockLines(last.text) - before
82
+ } else {
83
+ sub.blocks.push({ kind, text })
84
+ sub._lineCount = (sub._lineCount ?? 0) + countBlockLines(text)
85
+ }
86
+ sub.blockEpoch = (sub.blockEpoch ?? 0) + 1
87
+ trimSubBlocks(sub)
88
+ }
89
+
90
+ /** N2: keep at most SUB_BLOCK_LINE_LIMIT display lines per child; drop oldest
91
+ * lines and leave one cumulative "…(已省略 N 行)" marker block. Done blocks
92
+ * are bounded by the same cap (called from every append). */
93
+ export function trimSubBlocks(sub) {
94
+ // Incremental line accounting (P1, 2026-08-30): appendSubBlock keeps
95
+ // sub._lineCount; the old implementation recomputed the total with a full
96
+ // blocks.reduce on EVERY append — O(n) per token, O(n²) over a stream.
97
+ sub._lineCount = (sub._lineCount ?? 0)
98
+ let over = sub._lineCount - SUB_BLOCK_LINE_LIMIT
99
+ if (over <= 0) return
100
+ let droppedNow = 0
101
+ while (over > 0 && sub.blocks.length > 0) {
102
+ const first = sub.blocks[0]
103
+ const lines = countBlockLines(first.text)
104
+ const take = Math.min(lines, over)
105
+ if (take >= lines) {
106
+ sub.blocks.shift()
107
+ droppedNow += lines
108
+ } else {
109
+ first.text = first.text.split("\n").slice(take).join("\n")
110
+ droppedNow += take
111
+ }
112
+ over -= take
113
+ }
114
+ sub._lineCount -= droppedNow
115
+ sub.dropped += droppedNow
116
+ const marker = `…(已省略 ${sub.dropped} 行)`
117
+ const first = sub.blocks[0]
118
+ if (first && first.kind === "meta") first.text = marker
119
+ else { sub.blocks.unshift({ kind: "meta", text: marker }); sub._lineCount += 1 }
120
+ }
121
+
122
+ /** Mark the earliest running child of the given role(s) as done (✓ header, frozen elapsed). */
123
+ export function finishSubTask(state, roles, lastError = null) {
124
+ state.subTasks ??= {}
125
+ const roleSet = new Set(Array.isArray(roles) ? roles : [roles])
126
+ const running = Object.values(state.subTasks)
127
+ .filter((s) => !s.done && roleSet.has(s.role))
128
+ .sort((a, b) => a.started - b.started)
129
+ if (running.length === 0) return
130
+ const sub = running[0]
131
+ sub.done = true
132
+ sub.doneAt = Date.now()
133
+ sub.currentTool = null
134
+ sub.approval = null
135
+ if (lastError) sub.lastError = lastError
136
+ sub.blockEpoch = (sub.blockEpoch ?? 0) + 1
137
+ }
138
+
139
+ /** Freeze a finished (or interrupted) child's activity block into state.lines
140
+ * (§7.2 D4 — the block is the child activity's only carrier; moved here from
141
+ * agent-turn.mjs 2026-08-30). Rendering it as a pinned conversation-tail section
142
+ * made every ✓ block a permanent "ghost" stuck above the input box (user report
143
+ * 2026-08-30); frozen into the stream it scrolls away with the conversation AND
144
+ * stays an independent collapsible block — the folded form is a single header
145
+ * summary line, the expanded form re-renders the full activity timeline from the
146
+ * SAME block source. The payload travels as a JSON line flagged with
147
+ * _frozenSubTask; render-conversation.mjs recognizes it and renders the ▶/▼
148
+ * interaction keyed by `sub-${key}` (user ruled 2026-08-30: clickable after
149
+ * freezing — full design interaction, not a dim-lines fallback). subTasks loses
150
+ * the entry on release; memory stays bounded by N2 (ring buffer already applied). */
151
+ export function freezeSubTaskLines(state, sub) {
152
+ if (!sub) return
153
+ state._frozenSubKeys ??= new Set()
154
+ state._frozenSubKeys.add(sub.key)
155
+ if (!sub) return
156
+ sub.done = true
157
+ sub.doneAt = sub.doneAt ?? Date.now()
158
+ state.lines.push({ text: `subagent activity: ${sub.key}`, color: C.dim, _frozenSubTask: sub })
159
+ }
160
+
161
+ /** Freeze + release every already-done child block (tool-result sweep:
162
+ * subagent/escalate/consult-check completions each call this after finishSubTask). */
163
+ export function freezeDoneSubTasks(state) {
164
+ for (const key of Object.keys(state.subTasks ?? {})) {
165
+ if (state.subTasks[key].done) {
166
+ freezeSubTaskLines(state, state.subTasks[key])
167
+ delete state.subTasks[key]
168
+ }
169
+ }
170
+ }
171
+
172
+ /** Mark ALL running blocks of the given role(s) done — session-level settle.
173
+ * A consult spawns N parallel children (consult#1..#N); the single-shot
174
+ * finishSubTask only ever settled the earliest one, leaving N-1 running
175
+ * ghosts pinned above the input box until turn end (consult residual,
176
+ * 2026-08-30 consult review — 4/4 models converged on this). */
177
+ export function finishSubTasksByRole(state, roles, lastError = null) {
178
+ state.subTasks ??= {}
179
+ const roleSet = new Set(Array.isArray(roles) ? roles : [roles])
180
+ for (const sub of Object.values(state.subTasks)) {
181
+ if (!sub.done && roleSet.has(sub.role)) {
182
+ sub.done = true
183
+ sub.doneAt = Date.now()
184
+ sub.currentTool = null
185
+ sub.approval = null
186
+ if (lastError) sub.lastError = lastError
187
+ sub.blockEpoch = (sub.blockEpoch ?? 0) + 1
188
+ }
189
+ }
190
+ }
191
+
192
+ /** Precise settle: mark the child of `role` whose [model] token recorded
193
+ * `model` as done. consult_check returns the reply's model — the earliest-
194
+ * running heuristic froze the WRONG block when models settle out of order. */
195
+ export function finishSubTaskByModel(state, role, model, lastError = null) {
196
+ state.subTasks ??= {}
197
+ // Model-string normalization (2026-08-30 follow-up consult): the [model]
198
+ // token carries the BARE model name (resolveChildProvider keeps mname),
199
+ // while consult_check's r.model is consultLabel = "provider:model". Compare
200
+ // tail segments — a full "provider:model" reply matches a bare-name block.
201
+ const want = String(model ?? "").includes(":") ? String(model).split(":").pop() : String(model ?? "")
202
+ for (const sub of Object.values(state.subTasks)) {
203
+ const have = String(sub.model ?? "")
204
+ const haveTail = have.includes(":") ? have.split(":").pop() : have
205
+ if (!sub.done && sub.role === role && haveTail === want) {
206
+ sub.done = true
207
+ sub.doneAt = Date.now()
208
+ sub.currentTool = null
209
+ sub.approval = null
210
+ if (lastError) sub.lastError = lastError
211
+ sub.blockEpoch = (sub.blockEpoch ?? 0) + 1
212
+ return sub
213
+ }
214
+ }
215
+ return null
216
+ }
217
+
218
+ /** Turn-end sweep (runAgentTurn finally): freeze ALL remaining blocks — interrupted
219
+ * runs (Ctrl+C abort / error mid-turn) would otherwise linger as pinned ghosts
220
+ * above the input box. Not-done children get done + lastError="interrupted"
221
+ * (skipped when the status already recovered to "Ready"). */
222
+ export function freezeAllSubTasks(state) {
223
+ for (const key of Object.keys(state.subTasks ?? {})) {
224
+ const sub = state.subTasks[key]
225
+ if (!sub.done) {
226
+ sub.done = true
227
+ sub.doneAt = Date.now()
228
+ if (!sub.lastError && state.status !== "Ready") sub.lastError = "interrupted"
229
+ }
230
+ freezeSubTaskLines(state, sub)
231
+ delete state.subTasks[key]
232
+ }
233
+ }
234
+
235
+ /**
236
+ * Apply one ⟦ev⟧ event token payload to the child's block header (D1/D2):
237
+ * turn/approval update turn n/max + waiting state. Events NEVER enter blocks
238
+ * or the main stream — header only.
239
+ * @returns {boolean} true = payload was a well-formed event token (consumed)
240
+ */
241
+ export function applySubEvent(sub, payload) {
242
+ const ev = payload.match(SUB_EVENT_RE)
243
+ if (!ev) return false
244
+ if (ev[1] === "turn") {
245
+ sub.turn = Number(ev[2]) || sub.turn
246
+ sub.maxTurns = Number(ev[3]) || sub.maxTurns
247
+ sub.approval = null
248
+ } else if (ev[1] === "approval") {
249
+ sub.turn = Number(ev[2]) || sub.turn
250
+ sub.maxTurns = Number(ev[3]) || sub.maxTurns
251
+ sub.approval = ev[5] ? ev[5].slice(0, 40) : (ev[4] === "approval" ? "tool" : ev[4])
252
+ }
253
+ return true
254
+ }
255
+
256
+ // ─── Prefix routing (agent-turn callbacks delegate here) ────────────────────
257
+ // Each router returns true when the name/token carried a role#id/ prefix and was
258
+ // consumed into the child's block buffer; false = not a child item (main path).
259
+
260
+ /** Child LLM text / `[model]` metadata / ⟦ev⟧ event token (onToken branch). */
261
+ export function routeSubToken(state, t, scheduleRender) {
262
+ const subMatch = t.match(SUB_PREFIX_RE)
263
+ if (!subMatch) return false
264
+ const sub = ensureSubTask(state, subMatch)
265
+ if (!sub) return true // frozen tombstone — late token from an aborted child: drop
266
+ const payload = t.slice(subMatch[0].length)
267
+ // ⟦ev⟧ event token: turn/approval progress → header ONLY (never blocks,
268
+ // never the main stream — D1).
269
+ if (payload.startsWith("⟦ev⟧")) {
270
+ applySubEvent(sub, payload)
271
+ scheduleRender()
272
+ return true
273
+ }
274
+ // `[model]<name>` metadata token: record the subagent's model (may differ from the
275
+ // parent's) — shown in the block header, NOT appended to its content stream.
276
+ // Only treat as metadata when the model isn't set yet (it's always the FIRST token);
277
+ // a child content token that happens to start with "[model]" must not be swallowed.
278
+ if (payload.startsWith("[model]") && sub.model === undefined) {
279
+ sub.model = payload.slice(7)
280
+ scheduleRender()
281
+ return true
282
+ }
283
+ // Child LLM text → text block (N2 cap inside appendSubBlock).
284
+ appendSubBlock(sub, "text", payload)
285
+ scheduleRender()
286
+ return true
287
+ }
288
+
289
+ /** Child reasoning token → think block (F2: same treatment as main reasoning). */
290
+ export function routeSubReasoning(state, t, scheduleRender) {
291
+ const subMatch = t.match(SUB_PREFIX_RE)
292
+ if (!subMatch) return false
293
+ const sub = ensureSubTask(state, subMatch)
294
+ if (!sub) return true // frozen tombstone — drop late token
295
+ appendSubBlock(sub, "think", t.slice(subMatch[0].length))
296
+ scheduleRender()
297
+ return true
298
+ }
299
+
300
+ /** Child tool call → fresh tool block + header currentTool. */
301
+ export function routeSubToolCall(state, name, args, scheduleRender) {
302
+ const subMatch = name.match(SUB_PREFIX_RE)
303
+ if (!subMatch) return false
304
+ const sub = ensureSubTask(state, subMatch)
305
+ if (!sub) return true // frozen tombstone — drop late token
306
+ sub.currentTool = name.slice(subMatch[0].length)
307
+ sub.toolArgs = args
308
+ sub.approval = null
309
+ appendSubBlock(sub, "tool", `❯ ${sub.currentTool}${args?.command ? " — " + String(args.command).replace(/\s+/g, " ").trim().slice(0, 80) : ""}\n`, { fresh: true })
310
+ scheduleRender()
311
+ return true
312
+ }
313
+
314
+ /** Child tool output (D1 prefixed-name relay) → append into the CURRENT tool block.
315
+ * Data append immediate; render throttled (N1). */
316
+ export function routeSubToolOutput(state, name, part, scheduleRender) {
317
+ const subMatch = name.match(SUB_PREFIX_RE)
318
+ if (!subMatch) return false
319
+ const sub = ensureSubTask(state, subMatch)
320
+ if (!sub) return true // frozen tombstone — drop late token
321
+ const toolName = name.slice(subMatch[0].length)
322
+ appendSubBlock(sub, "tool", part.text + "\n", { fresh: sub.currentTool !== toolName })
323
+ if (sub.currentTool !== toolName) sub.currentTool = toolName
324
+ throttleSubRender(scheduleRender)
325
+ return true
326
+ }