thincoder 0.12.54 → 0.12.59

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 (129) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/README.md +1 -1
  3. package/bin/thincoder.mjs +25 -3
  4. package/package.json +3 -7
  5. package/src/acp/bridge.mjs +132 -26
  6. package/src/advisor/messages.mjs +38 -3
  7. package/src/advisor/run.mjs +91 -53
  8. package/src/advisor.mjs +15 -7
  9. package/src/agent/dispatch.mjs +156 -39
  10. package/src/agent/helpers.mjs +46 -4
  11. package/src/agent/setup.mjs +102 -19
  12. package/src/agent/spawn-child.mjs +28 -1
  13. package/src/agent-tools/advisor.mjs +43 -11
  14. package/src/agent-tools/consult.mjs +37 -6
  15. package/src/agent-tools/eng.mjs +4 -1
  16. package/src/agent-tools/goal.mjs +11 -1
  17. package/src/agent-tools/read-history.mjs +160 -0
  18. package/src/agent-tools/settings.mjs +162 -0
  19. package/src/agent-tools/skill.mjs +2 -1
  20. package/src/agent-tools/subagent-actions.mjs +432 -0
  21. package/src/agent-tools/subagent-async.mjs +427 -0
  22. package/src/agent-tools/subagent-scheduler.mjs +319 -0
  23. package/src/agent-tools/subagent.mjs +565 -128
  24. package/src/agent-tools/task.mjs +4 -3
  25. package/src/agent-tools/timer.mjs +9 -4
  26. package/src/agent-tools/verify.mjs +161 -49
  27. package/src/agent-tools.mjs +1 -0
  28. package/src/agent.mjs +182 -81
  29. package/src/auto-think.mjs +14 -0
  30. package/src/cli/make-agent.mjs +27 -1
  31. package/src/cli/memory-command.mjs +28 -7
  32. package/src/cli/permission.mjs +8 -1
  33. package/src/config.mjs +125 -8
  34. package/src/context.mjs +115 -34
  35. package/src/distill.mjs +19 -1
  36. package/src/escape.mjs +82 -27
  37. package/src/log.mjs +195 -0
  38. package/src/mcp/transport-http.mjs +13 -1
  39. package/src/mcp.mjs +52 -7
  40. package/src/memory/code-sync.mjs +1 -1
  41. package/src/memory/core.mjs +204 -10
  42. package/src/memory/docs.mjs +197 -62
  43. package/src/memory.mjs +1 -1
  44. package/src/model-specs.mjs +38 -1
  45. package/src/prompts/advisor-design.md +46 -0
  46. package/src/prompts/advisor-round1.md +49 -2
  47. package/src/prompts/advisor-round2.md +47 -0
  48. package/src/prompts/advisor-round3.md +47 -0
  49. package/src/prompts/coder.md +22 -0
  50. package/src/prompts/consult-base.md +13 -0
  51. package/src/prompts/discipline.md +25 -6
  52. package/src/prompts/eng-coder.md +2 -2
  53. package/src/prompts/engineering-sub.md +23 -1
  54. package/src/prompts/engineering.md +157 -50
  55. package/src/prompts/explore.md +1 -2
  56. package/src/prompts/main.md +11 -5
  57. package/src/prompts/methodology-template.md +14 -0
  58. package/src/prompts/system.md +5 -2
  59. package/src/provider/anthropic.mjs +7 -5
  60. package/src/provider/core.mjs +104 -28
  61. package/src/provider/google.mjs +57 -24
  62. package/src/provider/normalize.mjs +1 -1
  63. package/src/provider/rate.mjs +0 -2
  64. package/src/provider/responses.mjs +8 -13
  65. package/src/provider/sse.mjs +20 -0
  66. package/src/session.mjs +15 -0
  67. package/src/tools/apply_patch.md +5 -1
  68. package/src/tools/bash.md +3 -3
  69. package/src/tools/delete.md +1 -0
  70. package/src/tools/edit-batch.mjs +92 -0
  71. package/src/tools/edit-diff.mjs +265 -0
  72. package/src/tools/edit.md +11 -6
  73. package/src/tools/execute.md +8 -8
  74. package/src/tools/execute.mjs +31 -35
  75. package/src/tools/file.mjs +26 -114
  76. package/src/tools/file_ops.md +3 -2
  77. package/src/tools/get_current_time.md +3 -1
  78. package/src/tools/git.md +1 -1
  79. package/src/tools/git.mjs +8 -16
  80. package/src/tools/hashline_edit.md +2 -0
  81. package/src/tools/index.mjs +3 -2
  82. package/src/tools/insert_after.md +2 -1
  83. package/src/tools/lint.md +3 -1
  84. package/src/tools/linter.mjs +9 -37
  85. package/src/tools/lsp.md +4 -1
  86. package/src/tools/patch.mjs +84 -13
  87. package/src/tools/pdf-parse-text.mjs +497 -0
  88. package/src/tools/pdf-parse-xref.mjs +499 -0
  89. package/src/tools/pdf.mjs +155 -0
  90. package/src/tools/question.md +2 -1
  91. package/src/tools/read.md +1 -0
  92. package/src/tools/read_pdf.md +21 -0
  93. package/src/tools/repomap.mjs +1 -1
  94. package/src/tools/shared.mjs +11 -32
  95. package/src/tools/system.mjs +6 -21
  96. package/src/tools/tree.md +2 -1
  97. package/src/tools/web.mjs +5 -3
  98. package/src/tools/websearch.md +2 -1
  99. package/src/tools/write.md +2 -0
  100. package/src/traces/trace-store.mjs +224 -0
  101. package/src/tui/agent-turn.mjs +387 -24
  102. package/src/tui/clipboard.mjs +17 -6
  103. package/src/tui/cmd-config.mjs +29 -9
  104. package/src/tui/cmd-eng.mjs +1 -0
  105. package/src/tui/cmd-extract.mjs +1 -1
  106. package/src/tui/cmd-mcp-form.mjs +197 -0
  107. package/src/tui/cmd-mcp.mjs +264 -114
  108. package/src/tui/cmd-think.mjs +1 -1
  109. package/src/tui/index.mjs +49 -95
  110. package/src/tui/interaction.mjs +41 -3
  111. package/src/tui/key-handler.mjs +105 -143
  112. package/src/tui/key-modes.mjs +215 -0
  113. package/src/tui/layout.mjs +22 -1
  114. package/src/tui/mouse.mjs +41 -1
  115. package/src/tui/pickers.mjs +73 -7
  116. package/src/tui/render-conversation.mjs +13 -161
  117. package/src/tui/render-frame.mjs +45 -20
  118. package/src/tui/render-loop.mjs +4 -1
  119. package/src/tui/render-segments.mjs +165 -0
  120. package/src/tui/render.mjs +4 -4
  121. package/src/tui/startup.mjs +40 -2
  122. package/src/tui/subagent-blocks.mjs +404 -111
  123. package/src/tui/subagent-panel.mjs +88 -13
  124. package/src/tui/tool-args.mjs +10 -2
  125. package/src/tui/tool-events.mjs +172 -95
  126. package/src/tui/update-notice.mjs +72 -0
  127. package/src/tui/wizard.mjs +36 -6
  128. package/src/agent-tools/escalate.mjs +0 -179
  129. package/src/tools/exec-prelude.mjs +0 -84
@@ -6,7 +6,13 @@
6
6
  * 运行中区块的渲染行数(F2,会话区被挤小);无运行中区块 → 返回 [](F6 空态,
7
7
  * 无悬空分隔线)。子 agent 完成后立即冻结进会话流(subagent-blocks.mjs
8
8
  * freezeSubTaskLines,✓ 头 + 可展开,§7.2 D4 现状不变),面板下一帧自然移除
9
- * 该区块(F5)——本模块只渲染 `!done` 条目。
9
+ * 该区块(F5)——本模块只渲染 `!done` 条目。§17 T-S14 中间态例外:挂起期已结算
10
+ * 区块(sub.done && sub.awaitingDigest)冻结被延迟,驻留面板显示
11
+ * "done · awaiting digestion",池空补发冻结后才移除。
12
+ *
13
+ * §19.5 D-M7 ⏹:运行中(非 done)折叠头右缘停止标记(dim,仅折叠头)——点击 =
14
+ * cancel(mouse.mjs 列级命中 _stopCol——不触发折叠翻转)。
15
+ * §19.5 D-M8:本模块同时承载子标行 dim 样式(styleSubLabelRow——面板与冻结渲染共用)。
10
16
  *
11
17
  * 中立模块(D1 评审 #6):layout.mjs 调 renderSubagentPanel 预计算面板高度
12
18
  * (subagentLines → subagentH),render-frame.mjs 直接 put 预计算行(不重复
@@ -19,9 +25,30 @@
19
25
  * 折叠状态 key = `sub-${key}` 跨 turn 保持(D5,与冻结区块同一 key——冻结边界
20
26
  * 无缝衔接)。
21
27
  */
22
- import { C } from "./ansi.mjs"
28
+ import { ansi, C } from "./ansi.mjs"
23
29
  import { sliceByWidth, stringWidth } from "./render.mjs"
24
30
  import { isExpanded, renderBlockTimeline, renderExpandedBlock, foldTailLines } from "./fold-block.mjs"
31
+ import { SUBAGENT_ROLES } from "./subagent-blocks.mjs"
32
+
33
+ /** 子标行行首标记(`explore#1 · `——sublabelLine 写入的字面形态)。 */
34
+ const SUB_LABEL_RE = /^([\w-]+#\d+(?:\/[\w-]+#\d+)* · )/
35
+
36
+ /** §19.5 D-M8 子标行 dim 样式(最终渲染点注入——行对象单色外 wrap 无法表达行内
37
+ * 双色):行首 `explore#1 · ` 子标转 gray(90m),其后内容恢复该行 kind 色
38
+ * (tool=cyan / text=白 / think=默认 fg——dim 属性本就由 C.reason 施加,不受
39
+ * fg 切换影响)。折叠 tail / dim 行不需调用(整行已 dim)。返回新行对象。 */
40
+ export function styleSubLabelRow(row) {
41
+ const content = String(row.text).replace(/^│ /, "")
42
+ const m = content.match(SUB_LABEL_RE)
43
+ if (!m) return row
44
+ const restore = row.color === C.tool ? ansi.fg(6)
45
+ : row.color === C.text ? ansi.fg(7)
46
+ : ansi.fg(9) // 默认 fg(think 行 C.reason=dim 属性——90m 后切回默认即还原 dim 白)
47
+ return {
48
+ ...row,
49
+ text: `│ ${ansi.gray}${m[1]}${restore}${content.slice(m[1].length)}`,
50
+ }
51
+ }
25
52
 
26
53
  /**
27
54
  * 面板行构建(纯函数):顶部分隔线 `─` + 各运行中区块(折叠头 + tail 3 /
@@ -30,7 +57,7 @@ import { isExpanded, renderBlockTimeline, renderExpandedBlock, foldTailLines } f
30
57
  * @returns {Array<{text: string, color: string, ...}>}
31
58
  */
32
59
  export function renderSubagentPanel(state, cols, maxRows) {
33
- const runningSubs = Object.values(state.subTasks ?? {}).filter((s) => !s.done)
60
+ const runningSubs = Object.values(state.subTasks ?? {}).filter((s) => !s.done || s.awaitingDigest)
34
61
  if (runningSubs.length === 0) return []
35
62
  const out = []
36
63
  // 面板顶部边界线(现状分隔线语义迁移,§7.2.1 D2/NF2)——面板存在即画线,
@@ -41,8 +68,25 @@ export function renderSubagentPanel(state, cols, maxRows) {
41
68
  // 头部摘要:`[▶ coder#1 · glm-5.3 · 45s · turn 12/100] bash — npm test`
42
69
  // ⏸ = 等待审批态(sub.approval 非空,评审 #5 定义);图标在括号内,
43
70
  // 与冻结头 `[✓ …]` 格式统一(任务简报 UI 决策)。
44
- const icon = sub.approval ? "⏸" : "▶"
45
- const elapsed = Math.floor((Date.now() - sub.started) / 1000)
71
+ const icon = sub.approval ? "⏸" : sub.done ? "✓" : "▶"
72
+ const elapsed = Math.floor(((sub.done ? (sub.doneAt ?? Date.now()) : Date.now()) - sub.started) / 1000)
73
+ // §19.5 D-M7b ②: sync/async 显式头标(B 形态——不靠"没标推断")——async 由
74
+ // ⟦ev⟧async 标记置位;sync 区块(无标记)显式标 sync。仅真实 subagent 角色
75
+ // (escalate/consult/compress 等复用面板槽的条目无语义——非 spawn 角色豁免);
76
+ // 冻结后保留(与 model 标识同生命周期——render-conversation frozenSubTaskLines
77
+ // 同款 modePart)。**颜色后置注入**(code review 🔵#4):bracket 宽度预算用纯文
78
+ // 本(dim ANSI 内嵌会被 sliceByWidth 截断在 restore 之前 → 行尾残留 dim)——
79
+ // 截断后对完整存活的 mode word 单独套 dim + 恢复行色(自闭合——截断落在词内
80
+ // 则 replace 不命中 → 无 ANSI 泄漏,词以行色显示)。
81
+ const isSubRole = SUBAGENT_ROLES.includes(sub.role)
82
+ // §20 D-SD3b waiting 块(sub.queued——排队 spawn 返回即建——未启动无 relay 流):
83
+ // 括号状态词 = waiting(依赖/域冲突等位——detail 即原因)或 queued(槽满等位——
84
+ // 状态区显示 position);不显示 sync/async 词(尚未启动——无 async 标记可言——
85
+ // sync 词会误导:async spawn 排队的块不是 sync);⏹ 门控不变(async 启动后才置)。
86
+ const queued = sub.queued ?? null
87
+ const statusWord = queued ? (queued.kind === "slot" ? "queued" : "waiting") : null
88
+ const modeWord = isSubRole && !queued ? (sub.async === true ? "async" : "sync") : null
89
+ const modePart = modeWord ? ` · ${modeWord}` : (statusWord ? ` · ${statusWord}` : "")
46
90
  // 评审 #1 宽度预算:模型名先单独按显示宽度截断([model] token 原样记录可长
47
91
  // 20-30+ 字符,不截断则括号前缀宽度不可预算、状态区被挤出终端右边距);
48
92
  // 再量括号前缀实际显示宽度,状态区按 cols - bracketWidth - 2 截断——整行
@@ -52,23 +96,54 @@ export function renderSubagentPanel(state, cols, maxRows) {
52
96
  ? ` · ${sliceByWidth(sub.model, Math.max(8, Math.floor(cols / 3)))}`
53
97
  : ""
54
98
  const turnPart = sub.maxTurns > 0 ? ` · turn ${sub.turn}/${sub.maxTurns}` : ""
55
- const bracket = sliceByWidth(`[${icon} ${sub.key}${modelPart} · ${elapsed}s${turnPart}]`, Math.max(1, cols - 2))
99
+ const bracketRaw = `[${icon} ${sub.key}${modePart}${modelPart} · ${elapsed}s${turnPart}]`
100
+ let bracket = sliceByWidth(bracketRaw, Math.max(1, cols - 2))
101
+ if (modeWord) {
102
+ const painted = bracket.replace(` · ${modeWord}`, ` · ${ansi.dim}${modeWord}${C.tool}`)
103
+ if (painted !== bracket) bracket = painted // 词被截断则保持纯文本(不泄漏 dim)
104
+ }
105
+ if (statusWord) {
106
+ const painted = bracket.replace(` · ${statusWord}`, ` · ${ansi.dim}${statusWord}${C.tool}`)
107
+ if (painted !== bracket) bracket = painted
108
+ }
56
109
  const bracketWidth = stringWidth(bracket)
57
110
  let statePart
58
- if (sub.approval) statePart = `等待审批: ${sub.approval}`
111
+ if (queued) {
112
+ // waiting 块状态区:detail 即原因文本(waiting for: …/dependency cancelled: …);
113
+ // slot 等位 → queued · position N(槽满)。
114
+ statePart = queued.kind === "slot"
115
+ ? `queued · position ${queued.position ?? "?"}(槽满等位)`
116
+ : (queued.detail || "queued")
117
+ } else if (sub.approval) statePart = `等待审批: ${sub.approval}`
118
+ else if (sub.awaitingDigest) statePart = "done · awaiting digestion"
59
119
  else if (sub.currentTool) statePart = sub.currentTool
60
120
  else statePart = "thinking..."
61
121
  const argSummary = sub.currentTool && sub.toolArgs?.command
62
122
  ? ` — ${String(sub.toolArgs.command).replace(/\s+/g, " ").trim().slice(0, 60)}`
63
123
  : ""
64
- out.push({
65
- text: `${bracket} ${sliceByWidth(statePart + argSummary, Math.max(0, cols - 2 - bracketWidth))}`,
66
- color: C.tool,
67
- _foldToggle: foldKey,
68
- })
124
+ let headText = `${bracket} ${sliceByWidth(statePart + argSummary, Math.max(0, cols - 2 - bracketWidth))}`
125
+ const line = { color: C.tool, _foldToggle: foldKey }
126
+ // §19.5 D-M7 ⏹ + D-M7b ③ 门控:⏹ 只对 async 区块(running && SUBAGENT_ROLES &&
127
+ // sub.async——sync 区块无 ⏹——杜绝"可见但不可中止"误导——用户裁定 B 形态)。
128
+ // done/awaitingDigest/压缩面板(role compress)/consult 无标记(点击只对池内
129
+ // async 子代理有意义):dim 停止标记钉在折叠头右缘**内收一列**(code review
130
+ // 🟡#1——glyph 在 cols−1、最右列留 margin——避免终端最末列点击不可靠/全角字形
131
+ // 顶格被裁;命中区 = col ≥ _stopCol = cols−1——含 glyph 与其右 margin,左邻
132
+ // padding 空格仍走折叠)。整行 ≤ cols:内容先按 cols−3 截断。
133
+ if (!sub.done && sub.async === true && SUBAGENT_ROLES.includes(sub.role)) {
134
+ const cut = sliceByWidth(headText, Math.max(0, cols - 3))
135
+ headText = cut + " ".repeat(Math.max(0, cols - 3 - stringWidth(cut)))
136
+ line.text = `${headText} ${ansi.dim}⏹${ansi.reset} `
137
+ line._stopSub = sub.key
138
+ line._stopCol = Math.max(1, cols - 1)
139
+ } else {
140
+ line.text = headText
141
+ }
142
+ out.push(line)
69
143
  if (isExpanded(state, foldKey)) {
70
144
  // 展开态:全量活动时间线(per-kind 着色,60% 屏封顶 + 块内滚动——公共组件)。
71
- const body = renderBlockTimeline(sub.blocks, cols)
145
+ // §19.5 D-M8:时间线行过 styleSubLabelRow——行首子标 dim、内容恢复 kind 色。
146
+ const body = renderBlockTimeline(sub.blocks, cols).map(styleSubLabelRow)
72
147
  out.push(...renderExpandedBlock({ body, foldKey, state, maxRows, cols, label: "subagent activity" }))
73
148
  } else {
74
149
  // 折叠态:tail 3 非空 block 行(最近活动),dim。
@@ -48,8 +48,16 @@ export function describeToolArgs(name, args) {
48
48
  case "advisor": return String(a.type ?? "review")
49
49
  case "read_image": return String(a.path ?? "")
50
50
  case "question": return String(a.question ?? "").replace(/\s+/g, " ").trim().slice(0, 60)
51
- case "memory_put": return String(a.title ?? "")
52
- case "memory_search": return String(a.query ?? "")
51
+ case "memory": {
52
+ // §6 action-routed summary (D-M5): one readable line per action
53
+ const action = String(a.action ?? "")
54
+ if (action === "put") return String(a.title ?? "")
55
+ if (action === "search") return String(a.query ?? "")
56
+ if (action === "list") return [a.scope && `scope ${a.scope}`, a.type && `type ${a.type}`, a.keyword && `kw ${a.keyword}`].filter(Boolean).join(" ")
57
+ if (action === "delete") return a.id ? `id ${a.id} (${a.scope ?? ""})` : `batch ${a.scope ?? ""} ${a.type ? `type ${a.type} ` : ""}${a.keyword ? `kw ${a.keyword}` : ""}`.trim()
58
+ if (action === "clear") return `clear ${a.scope ?? ""}`
59
+ return action || JSON.stringify(a)
60
+ }
53
61
  case "lsp": return [a.subcommand, a.uri].filter(Boolean).map(String).join(" ")
54
62
  case "repo_outline": return a.path ? String(a.path) : ""
55
63
  case "checkpoint": return [a.checkpointAction ?? a.action, a.checkpointId, a.path].filter(Boolean).map(String).join(" ")
@@ -3,22 +3,25 @@
3
3
  * 满足 500 行硬限)。只做「事件 → TUI 状态/对话行」的映射:
4
4
  *
5
5
  * - onToken/onReasoning : 子agent 前缀分流(routeSub*)→ 主流 streaming/reasoning
6
- * - onToolCall : 状态栏 + `❯ name args` 标题行 + 计时
6
+ * - onToolCall : 状态栏 + `❯ name args` 标题行 + 计时 + §19 action 记录
7
7
  * - onToolResult : 子agent 完成冻结(finishSubTask + freezeDoneSubTasks)、
8
- * _live 行清理、done 行、advisor 评审冻结框
9
- * - onToolOutput : advisor 有序块缓冲 / `_live` 滚动预览(N 行 + `│ …` 折叠)
8
+ * 工具块结果入块、advisor 评审冻结框
9
+ * - onToolOutput : advisor 有序块缓冲 / 工具块输出流
10
10
  * - 其余 : usage 累计、等待提示、task 面板、回合末增量落盘
11
11
  *
12
12
  * flushStream 同时返回给调用方(回合循环 / onTurnEnd 共用)。纯回调装配,无终端副作用
13
- * (除经 deps 注入的 pushLine/render)。
13
+ * (除经 deps 注入的 pushLine/render)。§19: subagent_check/escalate 工具退役后,
14
+ * subagent 家族全部调用以工具名 "subagent" + action 到达——完成路由按 onToolCall 时
15
+ * 记录的 action 分流(spawn 区块 / escalate 区块 / check·status 普通工具块)。
14
16
  */
15
17
  import { C } from "./ansi.mjs"
16
18
  import { formatToolSummary } from "./tool-summaries.mjs"
17
19
  import { describeToolArgs, toolArgsLines } from "./tool-args.mjs"
18
20
  import { ADVISOR_THINKING_PLACEHOLDER, resolveAdvisorProvider } from "../advisor/run.mjs"
19
21
  import {
20
- SUBAGENT_ROLES, routeSubToken, routeSubReasoning, routeSubToolCall,
21
- routeSubToolOutput, finishSubTask, finishSubTasksByRole, finishSubTaskByModel, freezeDoneSubTasks,
22
+ SUB_PREFIX_RE, SUBAGENT_ROLES, routeSubToken, routeSubReasoning, routeSubToolCall,
23
+ routeSubToolOutput, finishSubTask, finishSubTaskKey, finishSubTasksByRole, finishSubTaskByModel, freezeDoneSubTasks,
24
+ ensureCompressPanel, markCompressFailed, markCompressDone, markCompressFallback,
22
25
  } from "./subagent-blocks.mjs"
23
26
  import { TURN_CAP_MARK } from "../agent/spawn-child.mjs"
24
27
 
@@ -37,6 +40,11 @@ const REMINDER_CAP = 3 // max pending reminders shown on turn
37
40
  const REMINDER_PERSIST_TURNS = 5 // persist reminders every N turns
38
41
 
39
42
  const _toolTicks = new Map()
43
+ // §19 action registry: tool_call id → subagent action (non-spawn only — spawn is
44
+ // the default when no record exists). onToolCall sees the args, onToolResult only
45
+ // the name; without the record every subagent result would route as a spawn.
46
+ const _subActions = new Map()
47
+ const _subActionQ = [] // FIFO for subagent calls without a tool id (same fallback as tick queue)
40
48
 
41
49
  function tickStart(name, toolId) {
42
50
  const key = toolId ?? name
@@ -73,6 +81,8 @@ export function sweepToolBlocks(state) {
73
81
  }
74
82
  }
75
83
  _toolTicks.clear()
84
+ _subActions.clear()
85
+ _subActionQ.length = 0
76
86
  }
77
87
 
78
88
  /** Shared display guard for tool results — LIVE and RESTORE use the same
@@ -93,9 +103,8 @@ export function slimToolResultForDisplay(result, maxRows = 400) {
93
103
  : rows
94
104
  }
95
105
  /** Mark the dispatch-level tool carrier done when its result is consumed by a
96
- * dedicated branch (subagent/escalate/advisor blocks) instead of the carrier
97
- * body without this the turn sweep mislabels successful calls as
98
- * "(interrupted)" (consult P1, 2026-08-30). */
106
+ * dedicated branch (subagent/escalate/advisor blocks) without this the turn
107
+ * sweep mislabels successful calls as "(interrupted)" (consult P1, 2026-08-30). */
99
108
  function settleToolBlock(state, name, toolId, summary) {
100
109
  const block = findToolBlock(state, name, toolId)
101
110
  if (block) {
@@ -106,6 +115,32 @@ function settleToolBlock(state, name, toolId, summary) {
106
115
  }
107
116
  }
108
117
 
118
+ /** Async spawn detection (§15 D-A1): the subagent tool's async:true result is a
119
+ * status JSON ({id, role, status: running|queued}), NOT a report — the child
120
+ * keeps running, so its activity block must not be frozen at spawn time (it
121
+ * freezes via the ⟦ev⟧done event at settle — §15 D-A3). */
122
+ function isAsyncSpawnResult(result) {
123
+ try {
124
+ const o = JSON.parse(result)
125
+ return Boolean(o && typeof o === "object" && typeof o.id !== "undefined" && (o.status === "running" || o.status === "queued"))
126
+ } catch {
127
+ return false
128
+ }
129
+ }
130
+
131
+ /** §7.2.3 spawn 门拒错误探测(T-F5):subagent spawn 的机械拒绝以 {status:"error"}
132
+ * JSON 返回(例:manual auto-turn digest spawn 门——digest 语义 organize-only)——
133
+ * 错误路径不得触发完成冻结(round1 #1——错误路径不冻结任何 running 块)。该形态只
134
+ * 出现在无 subKey 的拒绝路径(成功路径恒带 dispatch 传的 ctx._subagentKey)。 */
135
+ function isSpawnErrorResult(result) {
136
+ try {
137
+ const o = JSON.parse(result)
138
+ return Boolean(o && typeof o === "object" && o.status === "error")
139
+ } catch {
140
+ return false
141
+ }
142
+ }
143
+
109
144
 
110
145
 
111
146
  /** Find the live tool-block carrier for a tool event: exact id match when the
@@ -130,8 +165,7 @@ function findToolBlock(state, name, toolId) {
130
165
  /** Build the agent callbacks + the shared flushStream for one turn.
131
166
  * askPermission, askQuestion, saveSessionImpl } */
132
167
  export function buildToolCallbacks(deps) {
133
- const { agent, state, pushLine, render, scheduleRender, ensureAssistantLabel, askPermission, askQuestion, saveSessionImpl } = deps
134
-
168
+ const { agent, state, pushLine, render, scheduleRender, ensureAssistantLabel, askPermission, askBatchPermission, askQuestion, saveSessionImpl } = deps
135
169
  // NOTE: advisor buffers (_advisorThink/advisorStreaming) are cleared here too.
136
170
  // Timing safety: onToolResult flushes _advisorThink into history and empties
137
171
  // the buffers BEFORE onTurnEnd can call flushStream (tool result is
@@ -141,11 +175,8 @@ export function buildToolCallbacks(deps) {
141
175
  const flushStream = () => {
142
176
  if (state.reasoning) {
143
177
  pushLine(state.reasoning, C.reason, "thinking")
144
- // Reasoning folds IMMEDIATELY on flush (user ruling 2026-08-30: the old
145
- // "stay expanded until next turn" auto-expand was a leftover of the
146
- // rejected pre-fold plan — thinking must be DEFAULT FOLDED in the exact
147
- // unified form: named header + tail 3, expand ≤60%, click back). Same
148
- // shape as the restore path — zero exceptions on either path.
178
+ // Reasoning folds IMMEDIATELY on flush (user ruling 2026-08-30): default-folded,
179
+ // named header + tail 3 same shape as the restore path, zero exceptions.
149
180
  state.reasoning = ""
150
181
  }
151
182
  if (state.streaming) {
@@ -166,9 +197,8 @@ export function buildToolCallbacks(deps) {
166
197
  scheduleRender()
167
198
  },
168
199
  onReasoning: (t) => {
169
- // Subagent reasoning tokens also carry role#id/ prefix — appended into the
170
- // block buffer as kind=think (F2: same treatment as the main reasoning stream;
171
- // previously the token only created the entry and the content was discarded).
200
+ // Subagent reasoning tokens also carry role#id/ prefix — appended into the block
201
+ // buffer as kind=think (F2: same treatment as the main reasoning stream).
172
202
  if (routeSubReasoning(state, t, scheduleRender)) return
173
203
  ensureAssistantLabel()
174
204
  state.reasoning += t
@@ -178,9 +208,14 @@ export function buildToolCallbacks(deps) {
178
208
  // Subagent tool call: prefix role#id/toolName → open a fresh tool block and
179
209
  // set currentTool for the header summary line.
180
210
  if (routeSubToolCall(state, name, args, scheduleRender)) return
181
- // Redundant with flushStream() below (it clears both buffers) kept as
182
- // defense-in-depth so a future flushStream change cannot leak advisor
183
- // buffers into the next tool's view.
211
+ // §19: record the action of a merged subagent-family call (spawn is the
212
+ // default — only non-spawn actions need a record for result-time routing).
213
+ if (name === "subagent" && args?.action && args.action !== "spawn") {
214
+ if (toolId !== undefined && toolId !== null) _subActions.set(toolId, args.action)
215
+ else _subActionQ.push(args.action)
216
+ }
217
+ // Redundant with flushStream() below (it clears both buffers) — defense-in-depth
218
+ // so a future flushStream change cannot leak advisor buffers into the next view.
184
219
  if (name === "advisor") { state._advisorBlocks = [] }
185
220
  flushStream()
186
221
  ensureAssistantLabel()
@@ -208,17 +243,12 @@ export function buildToolCallbacks(deps) {
208
243
  // is unreliable (it glues onto the previous line), so the round belongs here.
209
244
  // Also show the advisor's effective model (it may differ from the main agent's).
210
245
  const roundTag = name === "advisor" ? ` (round ${(agent._advisorRound || 0) + 1}${advModel ? " · " + advModel : ""})` : ""
211
- // Readable key-args summary (vscode card-header parity, 2026-08-30) —
212
- // replaces the raw JSON.stringify-80 slice: long paths landed mid-string,
213
- // and the crucial argument was often past the cut. Unknown/MCP tools
214
- // fall back to compact JSON inside describeToolArgs.
246
+ // Readable key-args summary (vscode card-header parity, 2026-08-30) — replaces
247
+ // the raw JSON.stringify-80 slice. Unknown/MCP tools fall back to compact JSON.
215
248
  const argSummary = describeToolArgs(name, args)
216
- // ONE BLOCK PER TOOL CALL (user ruling 2026-08-30: "为什么不把名称和参数行
217
- // 直接作为流式输出 block title" the four-piece title / _live scroll /
218
- // done-line arrangement was pre-fold-era residue). The carrier line holds
219
- // the whole call: header = name+args+live status, body = args JSON +
220
- // streaming output + result. buildConvLines renders it via the shared
221
- // fold-block component; restore (historyToLines) emits the SAME carrier.
249
+ // ONE BLOCK PER TOOL CALL (user ruling 2026-08-30): header = name+args+live
250
+ // status, body = args JSON + streaming output + result. buildConvLines renders
251
+ // it via the shared fold-block component; restore emits the SAME carrier.
222
252
  state.lines.push({
223
253
  text: "", color: C.tool,
224
254
  _lineId: (state._lineIdCounter = (state._lineIdCounter ?? 0) + 1),
@@ -235,47 +265,78 @@ export function buildToolCallbacks(deps) {
235
265
  })
236
266
  tickStart(name, toolId)
237
267
  },
238
- onToolResult: (name, result, toolId) => {
268
+ // §7.2.3(方案 e):dispatch runOne 把工具 ctx 上的 _subagentKey(sync spawn/
269
+ // escalate 成功路径设置——relayPrefix 去尾)作为第 4 参传来——undefined 兼容既有
270
+ // 签名(普通工具/老回调/错误路径不带 key)。
271
+ onToolResult: (name, result, toolId, subKey) => {
239
272
  state.currentTool = null
273
+ // §19 merged family: route per the action recorded at onToolCall (no record = default spawn).
274
+ let subAction = null
275
+ if (name === "subagent") {
276
+ subAction = (toolId !== undefined && toolId !== null)
277
+ ? _subActions.get(toolId) ?? null
278
+ : _subActionQ.shift() ?? null
279
+ if (toolId !== undefined && toolId !== null) _subActions.delete(toolId)
280
+ }
281
+ const isSubagent = name === "subagent" && subAction !== "check" && subAction !== "status" && subAction !== "escalate" && subAction !== "cancel" && subAction !== "panel"
282
+ const isEscalate = name === "subagent" && subAction === "escalate"
240
283
  // Subagent complete: mark the earliest running child as done — the block
241
284
  // persists (✓ frozen elapsed header, expandable) as the ONLY carrier of the
242
- // child's activity (D4: no 3-second cleanup anymore). The report preview
243
- // (max 8 lines) still enters the conversation via the existing path below.
244
- // Block buffers survive the turn (no wipe in runAgentTurn start/finally):
245
- // child tool calls never enter the parent's history, so the block is the
246
- // only trace of what the child did — memory bounded by the N2 line cap.
247
- const isSubagent = name === "subagent"
285
+ // child's activity; memory bounded by the N2 line cap.
286
+ // §19.5: cancel 动作排除在 isSubagent 外——ack/错误 JSON 走普通工具块;区块冻结由
287
+ // ⟦ev⟧stopped settle 事件承担(此处 finishSubTask 会误冻最早 running 区块)。
248
288
  if (isSubagent) {
249
289
  // The dispatch-level tool-block carrier for this call would otherwise
250
290
  // never be marked done (its result lands in the subagent block, not the
251
- // carrier) and the turn sweep would mislabel it "(interrupted)" — every
252
- // successful subagent call showed that banner (consult P1, 2026-08-30).
291
+ // carrier) and the turn sweep would mislabel it "(interrupted)".
253
292
  settleToolBlock(state, name, toolId, "completed")
254
- finishSubTask(state, SUBAGENT_ROLES, result.includes(TURN_CAP_MARK) ? "turn cap reached work may be partial" : null)
255
- // Freeze the finished blocks into the conversation stream (user report
256
- // 2026-08-30): a pinned tail section left every block stuck above the
257
- // input box forever. As lines they scroll away, stay expandable via the
258
- // dim auto-fold, and subTasks releases the entry.
259
- freezeDoneSubTasks(state)
260
- // Subagent report preview (max 8 lines) displayed directly in conversation
261
- const lines = result.split("\n")
262
- const preview = lines.slice(0, SUBAGENT_PREVIEW_LINES).map((l) => l.slice(0, PREVIEW_LINE_CHARS)).join("\n")
263
- if (preview) pushLine(preview, C.dim)
264
- if (lines.length > SUBAGENT_PREVIEW_LINES) pushLine(` ... (${lines.length - SUBAGENT_PREVIEW_LINES} more lines)`, C.dim)
265
- } else if (name === "escalate") {
266
- // 飞刀 post-op report landed freeze its block into the conversation too.
293
+ // Async spawn (§15 D-A1): the result is a status JSON, not a report the
294
+ // child KEEPS running; skip the freeze (it would tombstone a live block and
295
+ // drop its relay stream). The block freezes on the ⟦ev⟧done settle event.
296
+ if (!isAsyncSpawnResult(result)) {
297
+ // §7.2.3 sync spawn 完成精确冻结:结果按 key 归属三支——
298
+ // ① dispatch 同步成功路径带 subKey(ctx._subagentKey = relayPrefix 去尾):
299
+ // finishSubTaskKey key 精确冻——不再落 finishSubTask 的"最早 started"
300
+ // 启发式(async eng-coder 先启动时 explore 完成会误冻其块——7.2.3.1/T-F2);
301
+ // spawn 门拒错误({status:"error"} JSON——auto-turn digest spawn 拒绝):
302
+ // 不冻结任何块(round1 #1——错误路径不冻结 running 块——T-F5);
303
+ // subKey undefined 非错误(老回调/测试直调——成功路径未知工具)→ 启发式
304
+ // 兜底(既有行为不变——面板单块时与精确冻同效——T-F1)。
305
+ const lastError = result.includes(TURN_CAP_MARK) ? "turn cap reached work may be partial" : null
306
+ const hasSubKey = subKey !== undefined && subKey !== null && subKey !== ""
307
+ if (hasSubKey) {
308
+ finishSubTaskKey(state, String(subKey), lastError)
309
+ freezeDoneSubTasks(state)
310
+ } else if (!isSpawnErrorResult(result)) {
311
+ finishSubTask(state, SUBAGENT_ROLES, lastError)
312
+ freezeDoneSubTasks(state)
313
+ }
314
+ // Subagent report preview (max 8 lines) displayed directly in conversation
315
+ const lines = result.split("\n")
316
+ const preview = lines.slice(0, SUBAGENT_PREVIEW_LINES).map((l) => l.slice(0, PREVIEW_LINE_CHARS)).join("\n")
317
+ if (preview) pushLine(preview, C.dim)
318
+ if (lines.length > SUBAGENT_PREVIEW_LINES) pushLine(` ... (${lines.length - SUBAGENT_PREVIEW_LINES} more lines)`, C.dim)
319
+ }
320
+ } else if (isEscalate) {
321
+ // 飞刀 post-op report landed under the subagent tool name — freeze the
322
+ // escalate#N activity block (no preview; legacy surface).
267
323
  settleToolBlock(state, name, toolId, "completed")
268
- finishSubTask(state, ["escalate"], result.includes(TURN_CAP_MARK) ? "turn cap reached — work may be partial" : null)
324
+ // §7.2.3(round1 #2):escalate 成功返回带 subKey(escalate#N)→ 精确冻;
325
+ // 失败/老回调无 subKey → escalate 角色启发式兜底(escalate 串行 + 角色限定——
326
+ // 既有行为)。
327
+ const lastError = result.includes(TURN_CAP_MARK) ? "turn cap reached — work may be partial" : null
328
+ if (subKey !== undefined && subKey !== null && subKey !== "") {
329
+ finishSubTaskKey(state, String(subKey), lastError)
330
+ } else {
331
+ finishSubTask(state, ["escalate"], lastError)
332
+ }
269
333
  freezeDoneSubTasks(state)
270
334
  } else if (name === "consult_check" || name === "consult_stop") {
271
- // Consult session-level settle (2026-08-30 consult review): a session
272
- // spawns N parallel children, so completion must settle ALL of them.
273
- // The single-shot finishSubTask here only froze the EARLIEST running
274
- // block — the other N-1 stayed "running" pinned above the input box
275
- // all through the final answer, then got mislabeled "interrupted".
276
- // - individual reply (done:false): settle precisely by r.model —
277
- // models settle out of order; the earliest-running heuristic froze
278
- // the wrong block.
335
+ // Consult session-level settle (2026-08-30 consult review): a session spawns
336
+ // N parallel children, so completion must settle ALL of them (single-shot
337
+ // finishSubTask froze only the earliest — N-1 stayed "running" then got
338
+ // mislabeled "interrupted").
339
+ // - individual reply (done:false): settle precisely by r.model.
279
340
  // - done:true / stopped: settle every remaining consult block.
280
341
  try {
281
342
  const r = JSON.parse(result)
@@ -293,7 +354,7 @@ export function buildToolCallbacks(deps) {
293
354
  }
294
355
  } /* non-JSON result — leave blocks as-is */
295
356
  }
296
- if (!isSubagent && name !== "advisor") {
357
+ if (!isSubagent && !isEscalate && name !== "advisor") {
297
358
  // Result lands INSIDE the block (restore parity — the restored carrier
298
359
  // carries the same fields). The done line is gone: status/elapsed live
299
360
  // in the header now.
@@ -316,16 +377,11 @@ export function buildToolCallbacks(deps) {
316
377
  // the frozen box, but the dispatch-level tool carrier must still be
317
378
  // marked done (consult P1, 2026-08-30: sweep mislabeled it interrupted).
318
379
  settleToolBlock(state, name, toolId, "completed")
319
- // The review's thinking must survive into the conversation history like
320
- // the main agent's reasoning (flushStream does for state.reasoning)
321
- // discarding it left the thought process visible only mid-review, then
322
- // gone. Flush BEFORE the done line so the block sits above it.
323
- // 2026-08-30: flushed as a COLLAPSIBLE box (frozen-folded semantics,
324
- // aligned with subagent blocks) instead of a flat auto-expanded line —
325
- // the flat form flooded the conversation. Full text still lives in the
326
- // tool result message; the box is the reviewable record. Live
327
- // _advisorBlocks keep rendering the running view until cleared in the
328
- // turn finally.
380
+ // The review's thinking must survive into the conversation history like the
381
+ // main agent's reasoning flushed as a COLLAPSIBLE box before the done
382
+ // line (frozen-folded semantics, aligned with subagent blocks; the flat
383
+ // form flooded the conversation). Full text lives in the tool result;
384
+ // _advisorBlocks keep rendering the running view until cleared at turn end.
329
385
  const blocks = state._advisorBlocks ?? []
330
386
  if (blocks.length > 0) {
331
387
  const text = blocks
@@ -347,17 +403,17 @@ export function buildToolCallbacks(deps) {
347
403
  }
348
404
  },
349
405
  onToolOutput: (name, chunk, toolId) => {
350
- // All tools use inline conversation blockspanel area is abolished.
351
- // Stream up to N preview lines (config ?? per-tool ?? 5); the full result
352
- // is in the tool message.
353
- const part = typeof chunk === "string"
354
- ? { kind: "text", text: chunk.trimEnd() }
355
- : { kind: chunk?.kind ?? "text", text: String(chunk?.text ?? "").trimEnd() }
406
+ // Subagent relays (name "role#id/tool", D1) route RAW chunks are
407
+ // child-stdout/SSE fragments at arbitrary byte boundaries; trimEnd eats
408
+ // real trailing newlines and routeSubToolOutput's verbatim concat would
409
+ // glue lines (2026-09-03 修复轮; main path keeps the trimmed form below).
410
+ const isSubRelay = SUB_PREFIX_RE.test(name)
411
+ const rawText = typeof chunk === "string" ? chunk : String(chunk?.text ?? "")
412
+ const part = {
413
+ kind: typeof chunk === "string" ? "text" : (chunk?.kind ?? "text"),
414
+ text: isSubRelay ? rawText : rawText.trimEnd(),
415
+ }
356
416
  if (!part.text) return
357
- // Subagent tool output (D1: childCallbacks.onToolOutput relays under the
358
- // prefixed name "role#id/toolName") → append into the CURRENT tool block of
359
- // that child's activity buffer. Render throttled at 250ms (N1); the data
360
- // append itself is never delayed.
361
417
  if (routeSubToolOutput(state, name, part, scheduleRender)) return
362
418
  if (name === "advisor") {
363
419
  // Accumulate to buffer — formatTables + wrapText in render-conversation
@@ -365,13 +421,9 @@ export function buildToolCallbacks(deps) {
365
421
  // NOTE: the advisor tool ALWAYS emits {kind, text} objects (run.mjs's
366
422
  // emit() wrapper) — a raw string chunk is never think; if that ever
367
423
  // changes, plain-string think would land in advisorStreaming.
368
- // ORDERED block buffer — preserves the interleaved emission order
369
- // (think → tool → think → … → final). Two separate buffers (_advisorThink
370
- // vs advisorStreaming) rendered think-block-then-main-block, which
371
- // regrouped ALL thinking above ALL tool progress — the alternating
372
- // timeline was destroyed. Consecutive chunks of the same kind merge
373
- // into one block; kind flips start a new block; render walks the
374
- // blocks in order with per-kind colors.
424
+ // ORDERED block buffer — preserves the interleaved emission order (think →
425
+ // tool → think → … → final); consecutive chunks of the same kind merge
426
+ // into one block, kind flips start a new block, render walks them in order.
375
427
  const isString = typeof chunk === "string"
376
428
  const raw = isString ? chunk : String(chunk?.text ?? "")
377
429
  const kind = isString ? "text" : (chunk?.kind ?? "text")
@@ -397,10 +449,35 @@ export function buildToolCallbacks(deps) {
397
449
  }
398
450
  scheduleRender()
399
451
  },
400
- onPermissionRequest: (name, args) => askPermission(name, args),
401
- onQuestion: (text, options) => askQuestion(text, options),
402
- onCompress: () => {
403
- pushLine(" [context] Context too long, auto-compacted (early conversation summarized by LLM, task state preserved)", C.warn)
452
+ // Manual-tier auto-turn digests (agent-turn.mjs suspension driver) pass null
453
+ // handlers permission requests then deny WITHOUT a panel (§17 D-S7: no modal
454
+ // during unattended digestion) and question errors out instead of hanging.
455
+ ...(askPermission ? { onPermissionRequest: (name, args) => askPermission(name, args) } : {}),
456
+ // Merged batch ask (§16 D-B1): one confirmation for N non-readonly tools in
457
+ // the same response — "approve all / one by one / deny" (key-handler resolves
458
+ // the verdict string; approveAll is batch-scope only, never the AUTO flag).
459
+ ...(askBatchPermission ? { onBatchPermissionRequest: (req) => askBatchPermission(req) } : {}),
460
+ ...(askQuestion ? { onQuestion: (text, options) => askQuestion(text, options) } : {}),
461
+ // Compression lifecycle (CONTEXT-COMPACTION.md §7 D-C2): the compression session
462
+ // renders as a subagent-style panel block — start → running panel ("Compressing
463
+ // context…" + "summarizing N messages" + elapsed ticker), fail → error text only,
464
+ // success → frozen "Compressed: N tokens freed → summary (Xs)" / fallback →
465
+ // "truncated to N messages". The summary BODY never enters the panel or stream.
466
+ onCompressStart: (info) => {
467
+ ensureCompressPanel(state, info)
468
+ scheduleRender()
469
+ },
470
+ onCompressFail: (error) => {
471
+ // Q3 (CONTEXT-COMPACTION §7 F3): failure is no longer silent — visible on the panel AND
472
+ // logged to stderr so the error is traceable (400/timeout/network).
473
+ console.error("[context] compression failed:", error?.message ?? error)
474
+ markCompressFailed(state, error)
475
+ scheduleRender()
476
+ },
477
+ onCompress: (info) => {
478
+ if (info?.mode === "fallback") markCompressFallback(state, info)
479
+ else markCompressDone(state, info)
480
+ scheduleRender()
404
481
  },
405
482
  // Async distillation landed (SEND-STALL-DISTILL §2.3): the machine line was replaced by
406
483
  // the compressed version — persist it so the session file ends up compressed. Silent: