thincoder 0.12.50 → 0.12.52

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 (49) hide show
  1. package/CHANGELOG.md +64 -3
  2. package/README.md +2 -2
  3. package/package.json +4 -3
  4. package/src/acp/bridge.mjs +5 -0
  5. package/src/agent/dispatch.mjs +19 -7
  6. package/src/agent/helpers.mjs +13 -1
  7. package/src/agent/record-results.mjs +130 -0
  8. package/src/agent/setup.mjs +4 -7
  9. package/src/agent/spawn-child.mjs +159 -0
  10. package/src/agent-tools/consult.mjs +94 -73
  11. package/src/agent-tools/escalate.mjs +53 -62
  12. package/src/agent-tools/skill.mjs +1 -1
  13. package/src/agent-tools/subagent.mjs +39 -38
  14. package/src/agent-tools/task.mjs +0 -2
  15. package/src/agent-tools/verify.mjs +0 -1
  16. package/src/agent.mjs +27 -112
  17. package/src/config.mjs +8 -103
  18. package/src/generate-title.mjs +30 -1
  19. package/src/model-specs.mjs +108 -0
  20. package/src/prompts/advisor-round1.md +5 -6
  21. package/src/prompts/advisor-round2.md +3 -4
  22. package/src/prompts/advisor-round3.md +3 -4
  23. package/src/prompts/eng-coder.md +9 -0
  24. package/src/prompts/engineering.md +61 -9
  25. package/src/prompts/system.md +2 -2
  26. package/src/provider/core.mjs +5 -71
  27. package/src/provider/normalize.mjs +81 -0
  28. package/src/session.mjs +40 -1
  29. package/src/tools/git.mjs +3 -3
  30. package/src/tools/shared.mjs +1 -0
  31. package/src/tools/system.mjs +3 -1
  32. package/src/tui/agent-turn.mjs +37 -364
  33. package/src/tui/clipboard.mjs +3 -1
  34. package/src/tui/dims.mjs +47 -0
  35. package/src/tui/fold-block.mjs +208 -0
  36. package/src/tui/index.mjs +33 -17
  37. package/src/tui/key-handler-search.mjs +1 -1
  38. package/src/tui/key-handler.mjs +10 -6
  39. package/src/tui/layout.mjs +21 -20
  40. package/src/tui/mouse.mjs +9 -6
  41. package/src/tui/pickers.mjs +1 -1
  42. package/src/tui/render-conversation.mjs +367 -113
  43. package/src/tui/render-frame.mjs +16 -90
  44. package/src/tui/render-loop.mjs +12 -8
  45. package/src/tui/render.mjs +16 -0
  46. package/src/tui/startup.mjs +66 -13
  47. package/src/tui/subagent-blocks.mjs +327 -0
  48. package/src/tui/tool-args.mjs +67 -0
  49. package/src/tui/tool-events.mjs +459 -0
@@ -8,13 +8,18 @@
8
8
  */
9
9
  import { ansi, C, ESC } from "./ansi.mjs"
10
10
  import { convCacheKey, renderConversation, countConvLines } from "./render-conversation.mjs"
11
- import { sliceByWidth, stringWidth, sanitizeDisplay } from "./render.mjs"
11
+ import { sliceByWidth, stringWidth } 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
+ import { readFileSync } from "node:fs"
15
16
 
16
17
  export { convCacheKey, renderConversation, countConvLines } from "./render-conversation.mjs"
17
18
 
19
+ // Module-load read, once per process (same pattern as cmd-upgrade.mjs): the
20
+ // header shows the installed version next to the logo (user request 2026-08-31).
21
+ const THINCODER_VERSION = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8")).version
22
+
18
23
  // ---------- status bar slash-command hints ----------
19
24
  const SLASH_HINTS = {
20
25
  "/config": "open config menu",
@@ -42,96 +47,19 @@ export function renderHeader(agent, cols) {
42
47
  const thinkBadge = t?.type === "disabled" ? "│ think: off"
43
48
  : effort ? `│ think: ${effort}`
44
49
  : t?.type === thinkOnValue ? "│ think: on" : ""
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}`
50
+ 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}`
46
51
  }
47
52
 
48
- /** Todo/task panel. Returns empty array when no tasks visible. */
53
+ /** Todo/task panel. Returns empty array when no tasks visible. The first row is
54
+ * a divider line separating the panel from the conversation above it (user
55
+ * request 2026-08-30). */
49
56
  export function renderTodo(visibleTasks, cols) {
50
- return visibleTasks.map((t) => {
57
+ const divider = `${C.dim}${"─".repeat(Math.max(1, cols - 1))}${ansi.reset}`
58
+ return [divider, ...visibleTasks.map((t) => {
51
59
  const mark = t.status === "done" ? "✓" : t.status === "in_progress" ? "▶" : "○"
52
60
  const color = t.status === "done" ? `${C.dim}${ESC}[9m` : t.status === "in_progress" ? C.tool : C.text
53
61
  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
62
+ })]
135
63
  }
136
64
 
137
65
  /** Permission preview panel. Returns empty when no permission request. */
@@ -293,7 +221,7 @@ export function renderRows(state, agent, opts) {
293
221
  const slashCommands = opts.slashCommands ?? []
294
222
 
295
223
  const layout = computeLayout(state, { cols, rows })
296
- const { W, panels, inputLayout, inputOffset, boxLines, visibleTasks, allSubs, permPreviewLines, overlay } = layout
224
+ const { W, panels, inputLayout, inputOffset, boxLines, visibleTasks, permPreviewLines, overlay } = layout
297
225
 
298
226
  const screen = new Array(rows).fill("")
299
227
  const put = (y, lines) => {
@@ -303,9 +231,7 @@ export function renderRows(state, agent, opts) {
303
231
  }
304
232
 
305
233
  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))
234
+ put(panels.conversation.y, renderConversation(state, cols, panels.conversation.h, state.scroll, rows))
309
235
  if (panels.todo) put(panels.todo.y, renderTodo(visibleTasks, cols))
310
236
  if (panels.picker) put(panels.picker.y, renderPicker(state, cols, panels.picker, overlay))
311
237
  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
  }
@@ -203,15 +203,31 @@ export function layoutInput(chars, cursor, width) {
203
203
  * Display-layer only — raw tool results the model sees are unchanged; dirty displays already in session
204
204
  * are also cleaned during replay.
205
205
  */
206
+ // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
206
207
  const ANSI_SEQUENCE = /\x1b\[[0-9;?]*[a-zA-Z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[()][0-9A-B]|\x1b[=>#][0-9]?/
207
208
  // Global variant for replace()/split(); the non-global one keeps match.index for slicing
208
209
  const ANSI_SEQUENCE_RE = new RegExp(ANSI_SEQUENCE.source, "g")
209
210
  export function sanitizeDisplay(s) {
210
211
  return s
211
212
  .replace(ANSI_SEQUENCE_RE, "")
213
+ // §7.2 D5 fallback: an unparsed ⟦ev⟧ event token must never reach the grid —
214
+ // strip the sentinel + its RS-wrapped payload (⟦ev⟧turn\x1e…\x1e / bare RS/GS chars).
215
+ // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
216
+ .replace(/⟦ev⟧[^\x1e\x1d]*\x1e[^\x1e\x1d]*\x1e[^\x1e\x1d]*\x1e[^\x1e\x1d]*\x1e?/g, "")
217
+ // GitHub-#4-class pitfall (2026-08-31): the residue strip used to be
218
+ // /⟦ev⟧[^\x1e\x1d]*/ — "swallow to end of line/string", which ATE REAL
219
+ // CONTENT when the user-visible text legitimately contains the literal
220
+ // sentinel (e.g. a table describing the ACP bridge's ⟦ev⟧ stripping —
221
+ // everything from the sentinel to the end vanished on screen). Real relay
222
+ // tokens start with a phase word (turn/approval); a bare sentinel with no
223
+ // letters attached is not a live token. Strip only sentinel+letters.
224
+ .replace(/⟦ev⟧[A-Za-z]*/g, "")
225
+ // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
226
+ .replace(/[\x1d\x1e]/g, "")
212
227
  .replace(/\r\n/g, "\n")
213
228
  .replace(/\r/g, "\n")
214
229
  .replace(/\t/g, " ")
230
+ // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
215
231
  .replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, "")
216
232
  .replace(/\n+$/, "")
217
233
  }
@@ -1,5 +1,7 @@
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 { slimToolResultForDisplay } from "./tool-events.mjs"
3
5
 
4
6
  /** Lazy history window (parity with VS Code HISTORY_PAGE_SIZE): first paint loads
5
7
  * the latest INITIAL_HISTORY_MESSAGES, then PgUp-at-top loads HISTORY_PAGE_MESSAGES
@@ -28,9 +30,19 @@ export function historyToLines(history, startIdx, endIdx) {
28
30
  const m = history[i]
29
31
  if (m.role === "user") {
30
32
  if (typeof m.content === "string" && m.content.startsWith("[System reminder:")) continue
33
+ // Label only when there is something to show: multimodal user messages
34
+ // (content = [text, image] array, injected after read_image) render NO
35
+ // text on restore — the image is invisible to the terminal and the user
36
+ // just saw it live. A label with no content under it is noise (user
37
+ // report 2026-08-30: stray "❯ You:" after the read_image block).
38
+ const userText = typeof m.content === "string"
39
+ ? m.content
40
+ : (Array.isArray(m.content) ? m.content.find((p) => p?.type === "text")?.text ?? "" : "")
41
+ const displayable = typeof m.content === "string" ? !!m.content : !!(userText && userText.trim())
42
+ if (!displayable) continue
31
43
  if (lines.length > 0) lines.push({ text: "", color: C.dim })
32
44
  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 })
45
+ if (userText) lines.push({ text: userText, color: C.text, _kind: "text" })
34
46
  inTurn = false
35
47
  } else if (m.role === "assistant") {
36
48
  if (!inTurn) {
@@ -39,22 +51,57 @@ export function historyToLines(history, startIdx, endIdx) {
39
51
  lines.push({ text: "❯ ThinCoder:", color: ansi.bold + C.assistant })
40
52
  }
41
53
  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.
54
+ // Reasoning restored as ONE C.reason line entry the exact shape
55
+ // flushStream produces live (single line, full string, no indent), so
56
+ // buildConvLines treats restored thinking IDENTICALLY: >12 wrapped rows
57
+ // fold under the named "▶ thinking" header, short fragments stay visible
58
+ // — same thresholds, same label, both paths. (History: restored thinking
59
+ // used to be split into dim fragments — the consecutive-dim rule's >8
60
+ // threshold never fired on short agentic thinking bursts, so restored
61
+ // sessions showed every fragment unfolded and mislabeled "tool output";
62
+ // user reported thinking "no longer folds" after a restart, 2026-08-30.)
44
63
  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
- }
64
+ if (typeof reasoning === "string" && reasoning.trim()) lines.push({ text: reasoning, color: C.reason, _kind: "thinking" })
48
65
  if (typeof m.content === "string" && m.content) lines.push({ text: m.content, color: C.text })
49
66
  for (const tc of m.tool_calls ?? []) {
50
67
  const toolResult = history[i + 1]
51
68
  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
- }
69
+ const tcName = tc.function?.name ?? "?"
70
+ // Tool arguments are part of the visible conversation (2026-08-30 user
71
+ // report: restored sessions showed no args at all — the deprecated
72
+ // display snapshot made historyToLines the ONLY restore path, and it
73
+ // never rendered arguments). Header line = readable key-args summary
74
+ // (describeToolArgs, vscode card-header parity); full pretty JSON rides
75
+ // below as dim lines, auto-folded by the consecutive-dim rule exactly
76
+ // like the restored tool result — same convention, same readability.
77
+ let argsSummary = ""
78
+ let argJson = []
79
+ let rawArgs = null
80
+ try {
81
+ const args = JSON.parse(tc.function?.arguments || "{}")
82
+ argsSummary = describeToolArgs(tcName, args)
83
+ argJson = toolArgsLines(args)
84
+ } catch { rawArgs = String(tc.function?.arguments ?? "").slice(0, 120) /* malformed — raw fallback */ }
85
+ // ONE BLOCK PER TOOL CALL — same carrier the live path emits (user
86
+ // ruling 2026-08-30): header=name+args, body=args JSON + result.
87
+ // Same carrier the live path emits — identical fields, so buildConvLines
88
+ // renders both through one code path (line-level parity by construction).
89
+ lines.push({
90
+ text: "", color: C.tool,
91
+ _kind: "tool",
92
+ _toolBlock: {
93
+ name: tcName,
94
+ roundTag: "",
95
+ argsSummary,
96
+ argsJson: rawArgs ? [rawArgs] : argJson,
97
+ output: [],
98
+ result: hasResult ? slimToolResultForDisplay(String(toolResult.content)) : null,
99
+ summary: null,
100
+ started: 0,
101
+ done: true,
102
+ elapsed: null,
103
+ },
104
+ })
58
105
  }
59
106
  }
60
107
  }
@@ -72,7 +119,13 @@ export function restoreLines(state, history) {
72
119
  const total = Array.isArray(history) ? history.length : 0
73
120
  if (total === 0) return
74
121
  const start = Math.max(0, total - INITIAL_HISTORY_MESSAGES)
75
- state.lines.push(...historyToLines(history, start, total))
122
+ state._lineIdCounter = state._lineIdCounter ?? 0
123
+ const fresh = historyToLines(history, start, total)
124
+ // Stable per-line ids (P1, 2026-08-30): fold keys for tool blocks derive from
125
+ // _lineId so loadOlder's head-unshift cannot re-bind an expanded block to a
126
+ // different tool (positional tool-{i} keys drift under unshift).
127
+ for (const l of fresh) l._lineId = ++state._lineIdCounter
128
+ state.lines.push(...fresh)
76
129
  state._historyLoaded = total - start
77
130
  state._historyTotal = total
78
131
  state._hasOlder = start > 0
@@ -0,0 +1,327 @@
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
+ // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
20
+ export const SUB_EVENT_RE = /^⟦ev⟧(turn|approval)\x1e([^\x1e]*)\x1e([^\x1e]*)\x1e([^\x1e]*)\x1e?([\s\S]*)$/
21
+ /** N2: per-child display-line ring buffer cap — oldest lines drop with a marker. */
22
+ export const SUB_BLOCK_LINE_LIMIT = 500
23
+ /** N1: render-layer throttle for child tool-output appends (generation relays verbatim). */
24
+ export const SUB_RELAY_THROTTLE_MS = 250
25
+ /** Roles a subagent tool child can take (finishSubTask matches the block's role). */
26
+ export const SUBAGENT_ROLES = ["sub", "explore", "plan", "coder", "eng-coder"]
27
+
28
+ let _subRenderLast = 0
29
+ let _subRenderTimer = null
30
+
31
+ /** N1 throttle: leading-edge render + one coalesced trailing flush (final chunk
32
+ * within a window is not lost). Data appends are NEVER throttled — rendering only. */
33
+ export function throttleSubRender(scheduleRender) {
34
+ const now = performance.now()
35
+ const wait = SUB_RELAY_THROTTLE_MS - (now - _subRenderLast)
36
+ if (wait <= 0) {
37
+ _subRenderLast = now
38
+ scheduleRender()
39
+ return
40
+ }
41
+ if (_subRenderTimer) return
42
+ _subRenderTimer = setTimeout(() => {
43
+ _subRenderTimer = null
44
+ _subRenderLast = performance.now()
45
+ scheduleRender()
46
+ }, wait)
47
+ _subRenderTimer.unref?.()
48
+ }
49
+
50
+ export function ensureSubTask(state, subMatch) {
51
+ const key = `${subMatch[1]}#${subMatch[2]}`
52
+ // Tombstone guard (2026-08-30 consult residual): a child aborted mid-flight
53
+ // can relay tail tokens AFTER its block was frozen — ensureSubTask must not
54
+ // resurrect it (the recreated running block had no one left to freeze it and
55
+ // sat pinned above the input box until the next turn).
56
+ state._frozenSubKeys ??= new Set()
57
+ if (state._frozenSubKeys.has(key)) return null
58
+ state.subTasks ??= {}
59
+ if (!state.subTasks[key]) {
60
+ state.subTasks[key] = {
61
+ key, role: subMatch[1], model: undefined, started: Date.now(), done: false, doneAt: null,
62
+ blocks: [], currentTool: null, toolArgs: null, turn: 0, maxTurns: 0, approval: null,
63
+ lastError: null, dropped: 0,
64
+ }
65
+ }
66
+ return state.subTasks[key]
67
+ }
68
+
69
+ const countBlockLines = (text) => text.split("\n").length
70
+
71
+ /** Append (kind-merging) with the N2 ring-buffer cap. fresh=true starts a new block
72
+ * even when the previous block has the same kind (used per tool call). */
73
+ export function appendSubBlock(sub, kind, text, { fresh = false } = {}) {
74
+ if (!text) return
75
+ const last = sub.blocks.at(-1)
76
+ if (!fresh && last && last.kind === kind) {
77
+ // Merged append: account only the NET new lines. Counting the full split of
78
+ // every single-line chunk (each ends with \n → 2 segments) would inflate the
79
+ // incremental total far above the block's real line count (P1 修, 2026-08-30).
80
+ const before = countBlockLines(last.text)
81
+ last.text += text
82
+ sub._lineCount = (sub._lineCount ?? 0) + countBlockLines(last.text) - before
83
+ } else {
84
+ sub.blocks.push({ kind, text })
85
+ sub._lineCount = (sub._lineCount ?? 0) + countBlockLines(text)
86
+ }
87
+ sub.blockEpoch = (sub.blockEpoch ?? 0) + 1
88
+ trimSubBlocks(sub)
89
+ }
90
+
91
+ /** N2: keep at most SUB_BLOCK_LINE_LIMIT display lines per child; drop oldest
92
+ * lines and leave one cumulative "…(已省略 N 行)" marker block. Done blocks
93
+ * are bounded by the same cap (called from every append). */
94
+ export function trimSubBlocks(sub) {
95
+ // Incremental line accounting (P1, 2026-08-30): appendSubBlock keeps
96
+ // sub._lineCount; the old implementation recomputed the total with a full
97
+ // blocks.reduce on EVERY append — O(n) per token, O(n²) over a stream.
98
+ sub._lineCount = (sub._lineCount ?? 0)
99
+ let over = sub._lineCount - SUB_BLOCK_LINE_LIMIT
100
+ if (over <= 0) return
101
+ let droppedNow = 0
102
+ while (over > 0 && sub.blocks.length > 0) {
103
+ const first = sub.blocks[0]
104
+ const lines = countBlockLines(first.text)
105
+ const take = Math.min(lines, over)
106
+ if (take >= lines) {
107
+ sub.blocks.shift()
108
+ droppedNow += lines
109
+ } else {
110
+ first.text = first.text.split("\n").slice(take).join("\n")
111
+ droppedNow += take
112
+ }
113
+ over -= take
114
+ }
115
+ sub._lineCount -= droppedNow
116
+ sub.dropped += droppedNow
117
+ const marker = `…(已省略 ${sub.dropped} 行)`
118
+ const first = sub.blocks[0]
119
+ if (first && first.kind === "meta") first.text = marker
120
+ else { sub.blocks.unshift({ kind: "meta", text: marker }); sub._lineCount += 1 }
121
+ }
122
+
123
+ /** Mark the earliest running child of the given role(s) as done (✓ header, frozen elapsed). */
124
+ export function finishSubTask(state, roles, lastError = null) {
125
+ state.subTasks ??= {}
126
+ const roleSet = new Set(Array.isArray(roles) ? roles : [roles])
127
+ const running = Object.values(state.subTasks)
128
+ .filter((s) => !s.done && roleSet.has(s.role))
129
+ .sort((a, b) => a.started - b.started)
130
+ if (running.length === 0) return
131
+ const sub = running[0]
132
+ sub.done = true
133
+ sub.doneAt = Date.now()
134
+ sub.currentTool = null
135
+ sub.approval = null
136
+ if (lastError) sub.lastError = lastError
137
+ sub.blockEpoch = (sub.blockEpoch ?? 0) + 1
138
+ }
139
+
140
+ /** Freeze a finished (or interrupted) child's activity block into state.lines
141
+ * (§7.2 D4 — the block is the child activity's only carrier; moved here from
142
+ * agent-turn.mjs 2026-08-30). Rendering it as a pinned conversation-tail section
143
+ * made every ✓ block a permanent "ghost" stuck above the input box (user report
144
+ * 2026-08-30); frozen into the stream it scrolls away with the conversation AND
145
+ * stays an independent collapsible block — the folded form is a single header
146
+ * summary line, the expanded form re-renders the full activity timeline from the
147
+ * SAME block source. The payload travels as a JSON line flagged with
148
+ * _frozenSubTask; render-conversation.mjs recognizes it and renders the ▶/▼
149
+ * interaction keyed by `sub-${key}` (user ruled 2026-08-30: clickable after
150
+ * freezing — full design interaction, not a dim-lines fallback). subTasks loses
151
+ * the entry on release; memory stays bounded by N2 (ring buffer already applied). */
152
+ export function freezeSubTaskLines(state, sub) {
153
+ if (!sub) return
154
+ state._frozenSubKeys ??= new Set()
155
+ state._frozenSubKeys.add(sub.key)
156
+ if (!sub) return
157
+ sub.done = true
158
+ sub.doneAt = sub.doneAt ?? Date.now()
159
+ state.lines.push({ text: `subagent activity: ${sub.key}`, color: C.dim, _frozenSubTask: sub })
160
+ }
161
+
162
+ /** Freeze + release every already-done child block (tool-result sweep:
163
+ * subagent/escalate/consult-check completions each call this after finishSubTask). */
164
+ export function freezeDoneSubTasks(state) {
165
+ for (const key of Object.keys(state.subTasks ?? {})) {
166
+ if (state.subTasks[key].done) {
167
+ freezeSubTaskLines(state, state.subTasks[key])
168
+ delete state.subTasks[key]
169
+ }
170
+ }
171
+ }
172
+
173
+ /** Mark ALL running blocks of the given role(s) done — session-level settle.
174
+ * A consult spawns N parallel children (consult#1..#N); the single-shot
175
+ * finishSubTask only ever settled the earliest one, leaving N-1 running
176
+ * ghosts pinned above the input box until turn end (consult residual,
177
+ * 2026-08-30 consult review — 4/4 models converged on this). */
178
+ export function finishSubTasksByRole(state, roles, lastError = null) {
179
+ state.subTasks ??= {}
180
+ const roleSet = new Set(Array.isArray(roles) ? roles : [roles])
181
+ for (const sub of Object.values(state.subTasks)) {
182
+ if (!sub.done && roleSet.has(sub.role)) {
183
+ sub.done = true
184
+ sub.doneAt = Date.now()
185
+ sub.currentTool = null
186
+ sub.approval = null
187
+ if (lastError) sub.lastError = lastError
188
+ sub.blockEpoch = (sub.blockEpoch ?? 0) + 1
189
+ }
190
+ }
191
+ }
192
+
193
+ /** Precise settle: mark the child of `role` whose [model] token recorded
194
+ * `model` as done. consult_check returns the reply's model — the earliest-
195
+ * running heuristic froze the WRONG block when models settle out of order. */
196
+ export function finishSubTaskByModel(state, role, model, lastError = null) {
197
+ state.subTasks ??= {}
198
+ // Model-string normalization (2026-08-30 follow-up consult): the [model]
199
+ // token carries the BARE model name (resolveChildProvider keeps mname),
200
+ // while consult_check's r.model is consultLabel = "provider:model". Compare
201
+ // tail segments — a full "provider:model" reply matches a bare-name block.
202
+ const want = String(model ?? "").includes(":") ? String(model).split(":").pop() : String(model ?? "")
203
+ for (const sub of Object.values(state.subTasks)) {
204
+ const have = String(sub.model ?? "")
205
+ const haveTail = have.includes(":") ? have.split(":").pop() : have
206
+ if (!sub.done && sub.role === role && haveTail === want) {
207
+ sub.done = true
208
+ sub.doneAt = Date.now()
209
+ sub.currentTool = null
210
+ sub.approval = null
211
+ if (lastError) sub.lastError = lastError
212
+ sub.blockEpoch = (sub.blockEpoch ?? 0) + 1
213
+ return sub
214
+ }
215
+ }
216
+ return null
217
+ }
218
+
219
+ /** Turn-end sweep (runAgentTurn finally): freeze ALL remaining blocks — interrupted
220
+ * runs (Ctrl+C abort / error mid-turn) would otherwise linger as pinned ghosts
221
+ * above the input box. Not-done children get done + lastError="interrupted"
222
+ * (skipped when the status already recovered to "Ready"). */
223
+ export function freezeAllSubTasks(state) {
224
+ for (const key of Object.keys(state.subTasks ?? {})) {
225
+ const sub = state.subTasks[key]
226
+ if (!sub.done) {
227
+ sub.done = true
228
+ sub.doneAt = Date.now()
229
+ if (!sub.lastError && state.status !== "Ready") sub.lastError = "interrupted"
230
+ }
231
+ freezeSubTaskLines(state, sub)
232
+ delete state.subTasks[key]
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Apply one ⟦ev⟧ event token payload to the child's block header (D1/D2):
238
+ * turn/approval update turn n/max + waiting state. Events NEVER enter blocks
239
+ * or the main stream — header only.
240
+ * @returns {boolean} true = payload was a well-formed event token (consumed)
241
+ */
242
+ export function applySubEvent(sub, payload) {
243
+ const ev = payload.match(SUB_EVENT_RE)
244
+ if (!ev) return false
245
+ if (ev[1] === "turn") {
246
+ sub.turn = Number(ev[2]) || sub.turn
247
+ sub.maxTurns = Number(ev[3]) || sub.maxTurns
248
+ sub.approval = null
249
+ } else if (ev[1] === "approval") {
250
+ sub.turn = Number(ev[2]) || sub.turn
251
+ sub.maxTurns = Number(ev[3]) || sub.maxTurns
252
+ sub.approval = ev[5] ? ev[5].slice(0, 40) : (ev[4] === "approval" ? "tool" : ev[4])
253
+ }
254
+ return true
255
+ }
256
+
257
+ // ─── Prefix routing (agent-turn callbacks delegate here) ────────────────────
258
+ // Each router returns true when the name/token carried a role#id/ prefix and was
259
+ // consumed into the child's block buffer; false = not a child item (main path).
260
+
261
+ /** Child LLM text / `[model]` metadata / ⟦ev⟧ event token (onToken branch). */
262
+ export function routeSubToken(state, t, scheduleRender) {
263
+ const subMatch = t.match(SUB_PREFIX_RE)
264
+ if (!subMatch) return false
265
+ const sub = ensureSubTask(state, subMatch)
266
+ if (!sub) return true // frozen tombstone — late token from an aborted child: drop
267
+ const payload = t.slice(subMatch[0].length)
268
+ // ⟦ev⟧ event token: turn/approval progress → header ONLY (never blocks,
269
+ // never the main stream — D1).
270
+ if (payload.startsWith("⟦ev⟧")) {
271
+ applySubEvent(sub, payload)
272
+ scheduleRender()
273
+ return true
274
+ }
275
+ // `[model]<name>` metadata token: record the subagent's model (may differ from the
276
+ // parent's) — shown in the block header, NOT appended to its content stream.
277
+ // Only treat as metadata when the model isn't set yet (it's always the FIRST token);
278
+ // a child content token that happens to start with "[model]" must not be swallowed.
279
+ if (payload.startsWith("[model]") && sub.model === undefined) {
280
+ sub.model = payload.slice(7)
281
+ scheduleRender()
282
+ return true
283
+ }
284
+ // Child LLM text → text block (N2 cap inside appendSubBlock).
285
+ appendSubBlock(sub, "text", payload)
286
+ scheduleRender()
287
+ return true
288
+ }
289
+
290
+ /** Child reasoning token → think block (F2: same treatment as main reasoning). */
291
+ export function routeSubReasoning(state, t, scheduleRender) {
292
+ const subMatch = t.match(SUB_PREFIX_RE)
293
+ if (!subMatch) return false
294
+ const sub = ensureSubTask(state, subMatch)
295
+ if (!sub) return true // frozen tombstone — drop late token
296
+ appendSubBlock(sub, "think", t.slice(subMatch[0].length))
297
+ scheduleRender()
298
+ return true
299
+ }
300
+
301
+ /** Child tool call → fresh tool block + header currentTool. */
302
+ export function routeSubToolCall(state, name, args, scheduleRender) {
303
+ const subMatch = name.match(SUB_PREFIX_RE)
304
+ if (!subMatch) return false
305
+ const sub = ensureSubTask(state, subMatch)
306
+ if (!sub) return true // frozen tombstone — drop late token
307
+ sub.currentTool = name.slice(subMatch[0].length)
308
+ sub.toolArgs = args
309
+ sub.approval = null
310
+ appendSubBlock(sub, "tool", `❯ ${sub.currentTool}${args?.command ? " — " + String(args.command).replace(/\s+/g, " ").trim().slice(0, 80) : ""}\n`, { fresh: true })
311
+ scheduleRender()
312
+ return true
313
+ }
314
+
315
+ /** Child tool output (D1 prefixed-name relay) → append into the CURRENT tool block.
316
+ * Data append immediate; render throttled (N1). */
317
+ export function routeSubToolOutput(state, name, part, scheduleRender) {
318
+ const subMatch = name.match(SUB_PREFIX_RE)
319
+ if (!subMatch) return false
320
+ const sub = ensureSubTask(state, subMatch)
321
+ if (!sub) return true // frozen tombstone — drop late token
322
+ const toolName = name.slice(subMatch[0].length)
323
+ appendSubBlock(sub, "tool", part.text + "\n", { fresh: sub.currentTool !== toolName })
324
+ if (sub.currentTool !== toolName) sub.currentTool = toolName
325
+ throttleSubRender(scheduleRender)
326
+ return true
327
+ }