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
@@ -0,0 +1,67 @@
1
+ /**
2
+ * tool-args.mjs — 工具调用参数的可读展示(2026-08-30,对齐 vscode 端卡片头的参数可见性)。
3
+ *
4
+ * vscode 端:tool-call 卡片头 = name + args(80 字符截断,悬停看全量)。
5
+ * TUI 端此前:live 标题行 = 原始 JSON 前 80 字符(长路径截半、不可读);
6
+ * 恢复的历史会话 = ` [tool] name` 完全没有参数(display snapshot 废弃后
7
+ * historyToLines 是恢复的唯一路径,参数可见性随之丢失——用户报告)。
8
+ *
9
+ * 单源 describeToolArgs():按工具挑关键参数的可读单行摘要(标题行/状态栏共用);
10
+ * 未知工具回退紧凑 JSON。恢复路径另外输出全量 pretty JSON 作 dim 行
11
+ * (走既有连续 dim 自动折叠——与工具结果的恢复惯例一致;TUI 无悬停,
12
+ * 全量必须落行)。
13
+ */
14
+ import { sliceByWidth } from "./render.mjs"
15
+
16
+ /** 单行参数摘要:按工具挑关键字段(vscode 卡片头对齐),未知工具回退 JSON。 */
17
+ export function describeToolArgs(name, args) {
18
+ if (!args || typeof args !== "object") return ""
19
+ const a = args
20
+ switch (name) {
21
+ case "bash": case "cmd-shell": {
22
+ const cmd = String(a.command ?? "").replace(/\s+/g, " ").trim()
23
+ return cmd ? cmd + (a.workdir ? ` (in ${a.workdir})` : "") : ""
24
+ }
25
+ case "read": case "write": case "edit": case "hashline_edit": {
26
+ const p = a.path ? String(a.path) : ""
27
+ const extras = [
28
+ a.offset ? `offset ${a.offset}` : "",
29
+ a.limit ? `limit ${a.limit}` : "",
30
+ name === "edit" && a.old_string ? `old: ${String(a.old_string).replace(/\s+/g, " ").trim().slice(0, 30)}` : "",
31
+ ].filter(Boolean).join(", ")
32
+ return p ? `"${p}"${extras ? " · " + extras : ""}` : extras
33
+ }
34
+ case "grep": case "glob": case "code_search": case "doc_search": case "search": {
35
+ const pat = a.pattern ?? a.query ?? ""
36
+ const p = a.path ? ` in "${a.path}"` : ""
37
+ return `/${String(pat)}/${p}`
38
+ }
39
+ case "websearch": return String(a.query ?? "")
40
+ case "subagent": case "coder": case "explore": case "plan": case "eng-coder": {
41
+ const task = String(a.task ?? "").replace(/\s+/g, " ").trim()
42
+ return task ? task.slice(0, 60) + (task.length > 60 ? "…" : "") : ""
43
+ }
44
+ case "advisor": return String(a.type ?? "review")
45
+ case "read_image": return String(a.path ?? "")
46
+ case "question": return String(a.question ?? "").replace(/\s+/g, " ").trim().slice(0, 60)
47
+ case "memory_put": return String(a.title ?? "")
48
+ case "memory_search": return String(a.query ?? "")
49
+ case "lsp": return [a.subcommand, a.uri].filter(Boolean).map(String).join(" ")
50
+ case "repo_outline": return a.path ? String(a.path) : ""
51
+ case "checkpoint": return [a.checkpointAction ?? a.action, a.checkpointId, a.path].filter(Boolean).map(String).join(" ")
52
+ case "git": return [a.action, a.ref, a.name, a.message].filter(Boolean).map(String).join(" ").slice(0, 60)
53
+ default: {
54
+ // MCP 工具 / 未知工具:单行紧凑 JSON(vscode 卡片头同样给原始 JSON 截断)。
55
+ // 硬上限 160:超大 arguments(如 execute 的千字符 code)绝不进单行头。
56
+ const s = JSON.stringify(a)
57
+ return s.length > 160 ? sliceByWidth(s, 156) + "…" : s.length > 80 ? sliceByWidth(s, 78) + "…" : s
58
+ }
59
+ }
60
+ }
61
+
62
+ /** 恢复路径用:全量参数 pretty JSON 的 dim 行(非空才输出)。
63
+ * 与工具结果的恢复惯例一致——完整落行,超长由连续 dim 折叠收纳。 */
64
+ export function toolArgsLines(args) {
65
+ if (!args || typeof args !== "object" || Object.keys(args).length === 0) return []
66
+ return JSON.stringify(args, null, 2).split("\n")
67
+ }
@@ -0,0 +1,461 @@
1
+ /**
2
+ * tool-events.mjs — runAgentTurn 的工具事件回调构造(自 agent-turn.mjs 拆出,2026-08-30,
3
+ * 满足 500 行硬限)。只做「事件 → TUI 状态/对话行」的映射:
4
+ *
5
+ * - onToken/onReasoning : 子agent 前缀分流(routeSub*)→ 主流 streaming/reasoning
6
+ * - onToolCall : 状态栏 + `❯ name args` 标题行 + 计时
7
+ * - onToolResult : 子agent 完成冻结(finishSubTask + freezeDoneSubTasks)、
8
+ * _live 行清理、done 行、advisor 评审冻结框
9
+ * - onToolOutput : advisor 有序块缓冲 / `_live` 滚动预览(N 行 + `│ …` 折叠)
10
+ * - 其余 : usage 累计、等待提示、task 面板、回合末增量落盘
11
+ *
12
+ * flushStream 同时返回给调用方(回合循环 / onTurnEnd 共用)。纯回调装配,无终端副作用
13
+ * (除经 deps 注入的 pushLine/render)。
14
+ */
15
+ import { sliceByWidth } from "./render.mjs"
16
+ import { C } from "./ansi.mjs"
17
+ import { formatToolSummary } from "./tool-summaries.mjs"
18
+ import { describeToolArgs, toolArgsLines } from "./tool-args.mjs"
19
+ import { ADVISOR_THINKING_PLACEHOLDER, resolveAdvisorProvider } from "../advisor/run.mjs"
20
+ import {
21
+ SUBAGENT_ROLES, routeSubToken, routeSubReasoning, routeSubToolCall,
22
+ routeSubToolOutput, finishSubTask, finishSubTasksByRole, finishSubTaskByModel, freezeDoneSubTasks,
23
+ } from "./subagent-blocks.mjs"
24
+ import { TURN_CAP_MARK } from "../agent/spawn-child.mjs"
25
+
26
+ /** Tool execution start timestamps (performance.now ms). Keyed by tool_call id
27
+ * when available (parallel same-name tools each get their own tick — the
28
+ * P0-3 fix, 2026-08-30), falling back to a per-name FIFO queue for callers
29
+ * without ids (subagent relay). FIFO shift assumes near-call-order completion;
30
+ * a parallel same-name batch finishing out of order swaps durations between
31
+ * siblings — same magnitude, both keep an elapsed (display-level, acceptable). */
32
+
33
+ // Named caps (consult P2, 2026-08-30): inline 200/8/120/3/5 were magic numbers.
34
+ const TOOL_OUTPUT_LINE_CAP = 200 // per-call streaming output ring buffer
35
+ const SUBAGENT_PREVIEW_LINES = 8 // report preview rows in the conversation
36
+ const PREVIEW_LINE_CHARS = 120 // per-line preview slice
37
+ const REMINDER_CAP = 3 // max pending reminders shown on turn end
38
+ const REMINDER_PERSIST_TURNS = 5 // persist reminders every N turns
39
+
40
+ const _toolTicks = new Map()
41
+
42
+ function tickStart(name, toolId) {
43
+ const key = toolId ?? name
44
+ if (toolId) { _toolTicks.set(key, performance.now()); return }
45
+ const q = _toolTicks.get(key) ?? []
46
+ q.push(performance.now())
47
+ _toolTicks.set(key, q)
48
+ }
49
+
50
+ /** Settle one pending tick: by tool_call id, or per-name FIFO. Returns start or null. */
51
+ function tickTake(name, toolId) {
52
+ const key = toolId ?? name
53
+ const v = _toolTicks.get(key)
54
+ if (v === undefined) return null
55
+ if (toolId) { _toolTicks.delete(key); return v }
56
+ if (v.length === 0) { _toolTicks.delete(key); return null }
57
+ const started = v.shift()
58
+ if (v.length === 0) _toolTicks.delete(key)
59
+ return started
60
+ }
61
+
62
+ /** P0-2 sweep (2026-08-30 consult): an interrupted turn (Ctrl+C / error) leaves
63
+ * running tool-block carriers without an onToolResult — their header would say
64
+ * "running" forever. runAgentTurn's finally calls this: mark them done with an
65
+ * "(interrupted)" status and clear the tick table so no stale start time leaks
66
+ * into the next turn. Mirrors freezeAllSubTasks for the tool-block family. */
67
+ export function sweepToolBlocks(state) {
68
+ for (const l of state.lines ?? []) {
69
+ const b = l._toolBlock
70
+ if (b && !b.done) {
71
+ b.done = true
72
+ b.summary = "(interrupted)"
73
+ b.interrupted = true
74
+ }
75
+ }
76
+ _toolTicks.clear()
77
+ }
78
+
79
+ /** Shared display guard for tool results — LIVE and RESTORE use the same
80
+ * function (P1, 2026-08-30 consult: restore lacked the live path's guards).
81
+ * 1) Multimodal results (read_image) embed FULL base64 images in the JSON —
82
+ * the human needs only the text part (model gets images via the multimodal
83
+ * channel); 2) results beyond maxRows are truncated in the block (full
84
+ * text always lives in history for the model). Returns row array. */
85
+ export function slimToolResultForDisplay(result, maxRows = 400) {
86
+ let displayResult = result
87
+ try {
88
+ const parsed = JSON.parse(result)
89
+ if (parsed?.images?.length) displayResult = parsed.text ?? result
90
+ } catch { /* not JSON — show as-is */ }
91
+ const rows = String(displayResult).split("\n").filter((l) => l.trim())
92
+ return rows.length > maxRows
93
+ ? [...rows.slice(0, maxRows), `… (result truncated at ${maxRows} rows — full text in history)`]
94
+ : rows
95
+ }
96
+ /** Mark the dispatch-level tool carrier done when its result is consumed by a
97
+ * dedicated branch (subagent/escalate/advisor blocks) instead of the carrier
98
+ * body — without this the turn sweep mislabels successful calls as
99
+ * "(interrupted)" (consult P1, 2026-08-30). */
100
+ function settleToolBlock(state, name, toolId, summary) {
101
+ const block = findToolBlock(state, name, toolId)
102
+ if (block) {
103
+ block.done = true
104
+ block.summary = summary
105
+ const started = tickTake(name, toolId)
106
+ block.elapsed = started !== null ? Math.round(performance.now() - started) : null
107
+ }
108
+ }
109
+
110
+
111
+
112
+ /** Find the live tool-block carrier for a tool event: exact id match when the
113
+ * callback carries one (P0-3 — parallel same-name tools route to their own
114
+ * block); falls back to the last unfinished block of that name. */
115
+ function findToolBlock(state, name, toolId) {
116
+ const lines = state.lines ?? []
117
+ if (toolId !== undefined && toolId !== null) {
118
+ for (let i = lines.length - 1; i >= 0; i--) {
119
+ if (lines[i]._toolBlock?.id === toolId) return lines[i]._toolBlock
120
+ }
121
+ return null
122
+ }
123
+ for (let i = lines.length - 1; i >= 0; i--) {
124
+ const b = lines[i]._toolBlock
125
+ if (b && b.name === name && !b.done) return b
126
+ }
127
+ return null
128
+ }
129
+
130
+
131
+ /** Build the agent callbacks + the shared flushStream for one turn.
132
+ * deps: { agent, state, pushLine, render, scheduleRender, ensureAssistantLabel,
133
+ * askPermission, askQuestion, summarize, saveSessionImpl } */
134
+ export function buildToolCallbacks(deps) {
135
+ const { agent, state, pushLine, render, scheduleRender, ensureAssistantLabel, askPermission, askQuestion, summarize, saveSessionImpl } = deps
136
+
137
+ // NOTE: advisor buffers (_advisorThink/advisorStreaming) are cleared here too.
138
+ // Timing safety: onToolResult flushes _advisorThink into history and empties
139
+ // the buffers BEFORE onTurnEnd can call flushStream (tool result is
140
+ // dispatched inside executeToolCalls; onTurnEnd fires after the turn loop
141
+ // resumes). If a future change calls flushStream mid-advisor-execution the
142
+ // in-progress thinking WOULD be lost — keep the ordering, or flush here too.
143
+ const flushStream = () => {
144
+ if (state.reasoning) {
145
+ pushLine(state.reasoning, C.reason, "thinking")
146
+ // Reasoning folds IMMEDIATELY on flush (user ruling 2026-08-30: the old
147
+ // "stay expanded until next turn" auto-expand was a leftover of the
148
+ // rejected pre-fold plan — thinking must be DEFAULT FOLDED in the exact
149
+ // unified form: named header + tail 3, expand ≤60%, click back). Same
150
+ // shape as the restore path — zero exceptions on either path.
151
+ state.reasoning = ""
152
+ }
153
+ if (state.streaming) {
154
+ pushLine(state.streaming, C.text, "text")
155
+ state.streaming = ""
156
+ }
157
+ state._advisorBlocks = []
158
+ }
159
+
160
+ const callbacks = {
161
+ onToken: (t) => {
162
+ // Subagent streaming: prefix format role#id/ → route into the child's activity
163
+ // block (D4). Event tokens (⟦ev⟧…) update ONLY the header state — never the
164
+ // block content, never the main stream (D1). Routing details in subagent-blocks.
165
+ if (routeSubToken(state, t, scheduleRender)) return
166
+ ensureAssistantLabel()
167
+ state.streaming += t
168
+ scheduleRender()
169
+ },
170
+ onReasoning: (t) => {
171
+ // Subagent reasoning tokens also carry role#id/ prefix — appended into the
172
+ // block buffer as kind=think (F2: same treatment as the main reasoning stream;
173
+ // previously the token only created the entry and the content was discarded).
174
+ if (routeSubReasoning(state, t, scheduleRender)) return
175
+ ensureAssistantLabel()
176
+ state.reasoning += t
177
+ scheduleRender()
178
+ },
179
+ onToolCall: (name, args, toolId) => {
180
+ // Subagent tool call: prefix role#id/toolName → open a fresh tool block and
181
+ // set currentTool for the header summary line.
182
+ if (routeSubToolCall(state, name, args, scheduleRender)) return
183
+ // Redundant with flushStream() below (it clears both buffers) — kept as
184
+ // defense-in-depth so a future flushStream change cannot leak advisor
185
+ // buffers into the next tool's view.
186
+ if (name === "advisor") { state._advisorBlocks = [] }
187
+ flushStream()
188
+ ensureAssistantLabel()
189
+ state.currentTool = name
190
+ // Advisor's effective model (resolved once for the status line + inline title below).
191
+ const advModel = name === "advisor" ? (() => { try { return resolveAdvisorProvider(agent).model } catch { return null } })() : null
192
+ // Update status bar with current tool and key arguments for user visibility
193
+ if (name === "bash" && args.command) {
194
+ const cmd = args.command.replace(/\s+/g, " ").trim()
195
+ state.status = `Running: ${cmd.length > 50 ? cmd.slice(0, 50) + "…" : cmd}`
196
+ } else if ((name === "read" || name === "write" || name === "edit" || name === "grep" || name === "glob") && args.path) {
197
+ state.status = `${name}: ${args.path}`
198
+ } else if (name === "grep" && args.pattern) {
199
+ state.status = `grep: ${args.pattern}`
200
+ } else if (name === "glob" && args.pattern) {
201
+ state.status = `glob: ${args.pattern}`
202
+ } else if (name === "websearch" && args.query) {
203
+ state.status = `search: ${args.query.length > 40 ? args.query.slice(0, 40) + "…" : args.query}`
204
+ } else if (name === "advisor") {
205
+ state.status = `advisor review (round ${(agent._advisorRound || 0) + 1}${advModel ? " · " + advModel : ""})`
206
+ } else {
207
+ state.status = `tool: ${name}`
208
+ }
209
+ // Advisor: tag the round in the tool title — the model's own "第N轮" narration
210
+ // is unreliable (it glues onto the previous line), so the round belongs here.
211
+ // Also show the advisor's effective model (it may differ from the main agent's).
212
+ const roundTag = name === "advisor" ? ` (round ${(agent._advisorRound || 0) + 1}${advModel ? " · " + advModel : ""})` : ""
213
+ // Readable key-args summary (vscode card-header parity, 2026-08-30) —
214
+ // replaces the raw JSON.stringify-80 slice: long paths landed mid-string,
215
+ // and the crucial argument was often past the cut. Unknown/MCP tools
216
+ // fall back to compact JSON inside describeToolArgs.
217
+ const argSummary = describeToolArgs(name, args)
218
+ // ONE BLOCK PER TOOL CALL (user ruling 2026-08-30: "为什么不把名称和参数行
219
+ // 直接作为流式输出 block 的 title" — the four-piece ❯ title / _live scroll /
220
+ // done-line arrangement was pre-fold-era residue). The carrier line holds
221
+ // the whole call: header = name+args+live status, body = args JSON +
222
+ // streaming output + result. buildConvLines renders it via the shared
223
+ // fold-block component; restore (historyToLines) emits the SAME carrier.
224
+ state.lines.push({
225
+ text: "", color: C.tool,
226
+ _lineId: (state._lineIdCounter = (state._lineIdCounter ?? 0) + 1),
227
+ _toolBlock: {
228
+ name, roundTag, id: toolId,
229
+ argsSummary: argSummary,
230
+ argsJson: toolArgsLines(args),
231
+ output: [],
232
+ result: null,
233
+ summary: null,
234
+ started: performance.now(),
235
+ done: false,
236
+ },
237
+ })
238
+ tickStart(name, toolId)
239
+ },
240
+ onToolResult: (name, result, toolId) => {
241
+ state.currentTool = null
242
+ // Subagent complete: mark the earliest running child as done — the block
243
+ // persists (✓ frozen elapsed header, expandable) as the ONLY carrier of the
244
+ // child's activity (D4: no 3-second cleanup anymore). The report preview
245
+ // (max 8 lines) still enters the conversation via the existing path below.
246
+ // Block buffers survive the turn (no wipe in runAgentTurn start/finally):
247
+ // child tool calls never enter the parent's history, so the block is the
248
+ // only trace of what the child did — memory bounded by the N2 line cap.
249
+ const isSubagent = name === "subagent"
250
+ if (isSubagent) {
251
+ // The dispatch-level tool-block carrier for this call would otherwise
252
+ // never be marked done (its result lands in the subagent block, not the
253
+ // carrier) and the turn sweep would mislabel it "(interrupted)" — every
254
+ // successful subagent call showed that banner (consult P1, 2026-08-30).
255
+ settleToolBlock(state, name, toolId, "completed")
256
+ finishSubTask(state, SUBAGENT_ROLES, result.includes(TURN_CAP_MARK) ? "turn cap reached — work may be partial" : null)
257
+ // Freeze the finished blocks into the conversation stream (user report
258
+ // 2026-08-30): a pinned tail section left every ✓ block stuck above the
259
+ // input box forever. As lines they scroll away, stay expandable via the
260
+ // dim auto-fold, and subTasks releases the entry.
261
+ freezeDoneSubTasks(state)
262
+ // Subagent report preview (max 8 lines) displayed directly in conversation
263
+ const lines = result.split("\n")
264
+ const preview = lines.slice(0, SUBAGENT_PREVIEW_LINES).map((l) => l.slice(0, PREVIEW_LINE_CHARS)).join("\n")
265
+ if (preview) pushLine(preview, C.dim)
266
+ if (lines.length > SUBAGENT_PREVIEW_LINES) pushLine(` ... (${lines.length - SUBAGENT_PREVIEW_LINES} more lines)`, C.dim)
267
+ } else if (name === "escalate") {
268
+ // 飞刀 post-op report landed → freeze its block into the conversation too.
269
+ settleToolBlock(state, name, toolId, "completed")
270
+ finishSubTask(state, ["escalate"], result.includes(TURN_CAP_MARK) ? "turn cap reached — work may be partial" : null)
271
+ freezeDoneSubTasks(state)
272
+ } else if (name === "consult_check" || name === "consult_stop") {
273
+ // Consult session-level settle (2026-08-30 consult review): a session
274
+ // spawns N parallel children, so completion must settle ALL of them.
275
+ // The single-shot finishSubTask here only froze the EARLIEST running
276
+ // block — the other N-1 stayed "running" pinned above the input box
277
+ // all through the final answer, then got mislabeled "interrupted".
278
+ // - individual reply (done:false): settle precisely by r.model —
279
+ // models settle out of order; the earliest-running heuristic froze
280
+ // the wrong block.
281
+ // - done:true / stopped: settle every remaining consult block.
282
+ try {
283
+ const r = JSON.parse(result)
284
+ if (r?.done || r?.stopped !== undefined) {
285
+ finishSubTasksByRole(state, ["consult"], null)
286
+ freezeDoneSubTasks(state)
287
+ } else if (r?.model) {
288
+ finishSubTaskByModel(state, "consult", r.model)
289
+ freezeDoneSubTasks(state)
290
+ }
291
+ } catch {
292
+ if (name === "consult_stop") {
293
+ finishSubTasksByRole(state, ["consult"], null)
294
+ freezeDoneSubTasks(state)
295
+ }
296
+ } /* non-JSON result — leave blocks as-is */
297
+ }
298
+ if (!isSubagent && name !== "advisor") {
299
+ // Result lands INSIDE the block (restore parity — the restored carrier
300
+ // carries the same fields). The done line is gone: status/elapsed live
301
+ // in the header now.
302
+ const block = findToolBlock(state, name, toolId)
303
+ if (block) {
304
+ // Multimodal tool results (read_image) embed the FULL base64 image in
305
+ // the result JSON — thousands of rows into the block body = a full
306
+ // screen of garbage (user report 2026-08-30). The model gets the image
307
+ // via the multimodal channel (agent.mjs), the human needs only the
308
+ // text part: strip image parts from the displayed result.
309
+ block.result = slimToolResultForDisplay(result)
310
+ block.summary = formatToolSummary(name, result)
311
+ block.done = true
312
+ const started = tickTake(name, toolId)
313
+ block.elapsed = started !== null ? Math.round(performance.now() - started) : null
314
+ }
315
+ }
316
+ if (name === "advisor") {
317
+ // Same carrier settle as subagent/escalate — the advisor result lives in
318
+ // the frozen box, but the dispatch-level tool carrier must still be
319
+ // marked done (consult P1, 2026-08-30: sweep mislabeled it interrupted).
320
+ settleToolBlock(state, name, toolId, "completed")
321
+ // The review's thinking must survive into the conversation history like
322
+ // the main agent's reasoning (flushStream does for state.reasoning) —
323
+ // discarding it left the thought process visible only mid-review, then
324
+ // gone. Flush BEFORE the done line so the block sits above it.
325
+ // 2026-08-30: flushed as a COLLAPSIBLE box (frozen-folded semantics,
326
+ // aligned with subagent blocks) instead of a flat auto-expanded line —
327
+ // the flat form flooded the conversation. Full text still lives in the
328
+ // tool result message; the box is the reviewable record. Live
329
+ // _advisorBlocks keep rendering the running view until cleared in the
330
+ // turn finally.
331
+ const blocks = state._advisorBlocks ?? []
332
+ if (blocks.length > 0) {
333
+ const text = blocks
334
+ .map((b) => b.text.replaceAll(ADVISOR_THINKING_PLACEHOLDER, ""))
335
+ .join("")
336
+ .replace(/\n{3,}/g, "\n\n")
337
+ .trim()
338
+ if (text) {
339
+ state.lines.push({
340
+ text: "advisor review",
341
+ color: C.dim,
342
+ _frozenAdvisor: text,
343
+ })
344
+ }
345
+ }
346
+ }
347
+ if (isSubagent) {
348
+ tickTake(name) // subagent: no per-call block — settle the tick
349
+ }
350
+ },
351
+ onToolOutput: (name, chunk, toolId) => {
352
+ // All tools use inline conversation blocks — panel area is abolished.
353
+ // Stream up to N preview lines (config ?? per-tool ?? 5); the full result
354
+ // is in the tool message.
355
+ const part = typeof chunk === "string"
356
+ ? { kind: "text", text: chunk.trimEnd() }
357
+ : { kind: chunk?.kind ?? "text", text: String(chunk?.text ?? "").trimEnd() }
358
+ if (!part.text) return
359
+ // Subagent tool output (D1: childCallbacks.onToolOutput relays under the
360
+ // prefixed name "role#id/toolName") → append into the CURRENT tool block of
361
+ // that child's activity buffer. Render throttled at 250ms (N1); the data
362
+ // append itself is never delayed.
363
+ if (routeSubToolOutput(state, name, part, scheduleRender)) return
364
+ if (name === "advisor") {
365
+ // Accumulate to buffer — formatTables + wrapText in render-conversation
366
+ // handles markdown formatting, same as main agent response.
367
+ // NOTE: the advisor tool ALWAYS emits {kind, text} objects (run.mjs's
368
+ // emit() wrapper) — a raw string chunk is never think; if that ever
369
+ // changes, plain-string think would land in advisorStreaming.
370
+ // ORDERED block buffer — preserves the interleaved emission order
371
+ // (think → tool → think → … → final). Two separate buffers (_advisorThink
372
+ // vs advisorStreaming) rendered think-block-then-main-block, which
373
+ // regrouped ALL thinking above ALL tool progress — the alternating
374
+ // timeline was destroyed. Consecutive chunks of the same kind merge
375
+ // into one block; kind flips start a new block; render walks the
376
+ // blocks in order with per-kind colors.
377
+ const isString = typeof chunk === "string"
378
+ const raw = isString ? chunk : String(chunk?.text ?? "")
379
+ const kind = isString ? "text" : (chunk?.kind ?? "text")
380
+ const blocks = state._advisorBlocks ??= []
381
+ const last = blocks.at(-1)
382
+ if (last && last.kind === kind) last.text += raw
383
+ else blocks.push({ kind, text: raw })
384
+ scheduleRender()
385
+ return
386
+ }
387
+ // Append into the CURRENT tool block's output buffer (the block is the
388
+ // display; no _live scroll lines anymore). N2-style cap keeps memory
389
+ // bounded: keep the LAST 200 output lines per call.
390
+ const block = findToolBlock(state, name, toolId)
391
+ if (block) {
392
+ for (const line of part.text.split("\n")) {
393
+ const trimmed = line.trimEnd()
394
+ if (trimmed) block.output.push(trimmed)
395
+ }
396
+ if (block.output.length > TOOL_OUTPUT_LINE_CAP) {
397
+ block.output.splice(0, block.output.length - TOOL_OUTPUT_LINE_CAP)
398
+ }
399
+ }
400
+ scheduleRender()
401
+ },
402
+ onPermissionRequest: (name, args) => askPermission(name, args),
403
+ onQuestion: (text, options) => askQuestion(text, options),
404
+ onCompress: () => {
405
+ pushLine(" [context] Context too long, auto-compacted (early conversation summarized by LLM, task state preserved)", C.warn)
406
+ },
407
+ // Async distillation landed (SEND-STALL-DISTILL §2.3): the machine line was replaced by
408
+ // the compressed version — persist it so the session file ends up compressed. Silent:
409
+ // a save failure must never surface after the turn already returned.
410
+ onDistilled: () => {
411
+ try { saveSessionImpl(agent, state.lines) } catch { /* 静默 */ }
412
+ },
413
+ onUsage: (usage) => {
414
+ state.tokens.prompt += usage.prompt_tokens ?? 0
415
+ state.tokens.completion += usage.completion_tokens ?? 0
416
+ state.tokens.cacheHit += usage.prompt_cache_hit_tokens ?? 0
417
+ state.tokens.cacheMiss += usage.prompt_cache_miss_tokens ?? 0
418
+ state.tokens.reasoningTokens += usage.completion_tokens_details?.reasoning_tokens ?? 0
419
+ },
420
+ // Throttle wait (active gate / 429 backoff): show in status bar so user knows it's not frozen
421
+ onWait: ({ phase, seconds }) => {
422
+ if (phase === "gate") state.status = `TPM throttle wait ~${seconds}s`
423
+ else if (phase === "overloaded") state.status = `Server overloaded, retrying in ${seconds}s`
424
+ else state.status = `Rate-limited 429, retry in ${seconds}s`
425
+ render()
426
+ },
427
+ onTaskUpdate: (items) => {
428
+ state.tasks = items
429
+ const done = items.filter((i) => i.status === "done").length
430
+ // Leave trace with current task title: reviewing history shows what was in progress
431
+ const current = items.find((i) => i.status === "in_progress")
432
+ pushLine(` [task] ${done}/${items.length}${current ? ` ▶ ${current.title}` : ""}`, C.dim)
433
+ render()
434
+ },
435
+ // Incremental save: flush to disk every 5 tool turns — mid-crash loss window shrinks from an entire round to a few turns
436
+ onTurnEnd: (() => {
437
+ let n = 0
438
+ return () => {
439
+ // Flush pending reasoning/streaming before the next turn starts.
440
+ // Guard pushbacks (verify/advisor) continue the agent loop without
441
+ // returning to the TUI — without flushing, old thinking bleeds into
442
+ // the next turn and the guard reminder is invisible.
443
+ flushStream()
444
+ // Mirror the last system-reminder from agent.history so guard
445
+ // pushback messages appear in the conversation at the right spot.
446
+ const last = agent.history.at(-1)
447
+ if (last?.role === "user" && typeof last.content === "string" && last.content.startsWith("[System reminder:")) {
448
+ // Reminders can embed long prior tables — show only the first lines
449
+ // (the full text is in agent.history); 3 lines + ellipsis.
450
+ const lines = last.content.split("\n")
451
+ const shown = lines.length > REMINDER_CAP ? lines.slice(0, REMINDER_CAP).join("\n") + "\n…" : last.content
452
+ pushLine(shown, C.warn)
453
+ }
454
+ if (++n % REMINDER_PERSIST_TURNS !== 0) return
455
+ try { saveSessionImpl(agent, state.lines) } catch (e) { console.error(`[session] incremental save failed: ${e.message}`) }
456
+ }
457
+ })(),
458
+ }
459
+
460
+ return { callbacks, flushStream }
461
+ }