thincoder 0.12.58 → 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 (114) hide show
  1. package/CHANGELOG.md +42 -1
  2. package/README.md +1 -1
  3. package/bin/thincoder.mjs +8 -0
  4. package/package.json +1 -1
  5. package/src/acp/bridge.mjs +132 -26
  6. package/src/advisor/messages.mjs +34 -1
  7. package/src/advisor/run.mjs +89 -51
  8. package/src/advisor.mjs +15 -7
  9. package/src/agent/dispatch.mjs +91 -14
  10. package/src/agent/helpers.mjs +35 -4
  11. package/src/agent/setup.mjs +90 -19
  12. package/src/agent/spawn-child.mjs +25 -0
  13. package/src/agent-tools/advisor.mjs +24 -2
  14. package/src/agent-tools/consult.mjs +37 -6
  15. package/src/agent-tools/eng.mjs +2 -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 +467 -193
  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 +161 -125
  29. package/src/auto-think.mjs +14 -0
  30. package/src/cli/make-agent.mjs +2 -1
  31. package/src/cli/permission.mjs +8 -1
  32. package/src/config.mjs +5 -0
  33. package/src/context.mjs +87 -27
  34. package/src/distill.mjs +19 -1
  35. package/src/escape.mjs +6 -4
  36. package/src/log.mjs +195 -0
  37. package/src/memory/code-sync.mjs +1 -1
  38. package/src/memory/core.mjs +126 -0
  39. package/src/memory/docs.mjs +196 -87
  40. package/src/memory.mjs +1 -1
  41. package/src/model-specs.mjs +15 -1
  42. package/src/prompts/advisor-design.md +46 -0
  43. package/src/prompts/advisor-round1.md +49 -2
  44. package/src/prompts/advisor-round2.md +47 -0
  45. package/src/prompts/advisor-round3.md +47 -0
  46. package/src/prompts/coder.md +22 -0
  47. package/src/prompts/consult-base.md +13 -0
  48. package/src/prompts/discipline.md +10 -5
  49. package/src/prompts/eng-coder.md +2 -2
  50. package/src/prompts/engineering-sub.md +23 -1
  51. package/src/prompts/engineering.md +106 -56
  52. package/src/prompts/explore.md +1 -2
  53. package/src/prompts/main.md +11 -6
  54. package/src/prompts/methodology-template.md +14 -0
  55. package/src/prompts/system.md +4 -2
  56. package/src/provider/core.mjs +56 -2
  57. package/src/tools/apply_patch.md +3 -1
  58. package/src/tools/bash.md +1 -1
  59. package/src/tools/delete.md +1 -0
  60. package/src/tools/edit-batch.mjs +31 -43
  61. package/src/tools/edit-diff.mjs +265 -0
  62. package/src/tools/edit.md +10 -8
  63. package/src/tools/execute.md +7 -7
  64. package/src/tools/execute.mjs +24 -20
  65. package/src/tools/file.mjs +18 -68
  66. package/src/tools/file_ops.md +2 -1
  67. package/src/tools/get_current_time.md +3 -1
  68. package/src/tools/hashline_edit.md +2 -0
  69. package/src/tools/index.mjs +3 -2
  70. package/src/tools/insert_after.md +2 -1
  71. package/src/tools/lint.md +2 -0
  72. package/src/tools/lsp.md +4 -1
  73. package/src/tools/patch.mjs +84 -13
  74. package/src/tools/pdf-parse-text.mjs +497 -0
  75. package/src/tools/pdf-parse-xref.mjs +499 -0
  76. package/src/tools/pdf.mjs +155 -0
  77. package/src/tools/question.md +2 -1
  78. package/src/tools/read.md +1 -0
  79. package/src/tools/read_pdf.md +21 -0
  80. package/src/tools/repomap.mjs +1 -1
  81. package/src/tools/shared.mjs +4 -12
  82. package/src/tools/system.mjs +6 -21
  83. package/src/tools/tree.md +2 -1
  84. package/src/tools/web.mjs +5 -3
  85. package/src/tools/websearch.md +2 -1
  86. package/src/tools/write.md +2 -0
  87. package/src/traces/trace-store.mjs +224 -0
  88. package/src/tui/agent-turn.mjs +385 -22
  89. package/src/tui/clipboard.mjs +15 -4
  90. package/src/tui/cmd-config.mjs +29 -9
  91. package/src/tui/cmd-extract.mjs +1 -1
  92. package/src/tui/cmd-mcp.mjs +9 -0
  93. package/src/tui/cmd-think.mjs +1 -1
  94. package/src/tui/index.mjs +29 -95
  95. package/src/tui/interaction.mjs +13 -2
  96. package/src/tui/key-handler.mjs +105 -155
  97. package/src/tui/key-modes.mjs +215 -0
  98. package/src/tui/layout.mjs +22 -1
  99. package/src/tui/mouse.mjs +40 -0
  100. package/src/tui/pickers.mjs +11 -3
  101. package/src/tui/render-conversation.mjs +13 -161
  102. package/src/tui/render-frame.mjs +27 -10
  103. package/src/tui/render-loop.mjs +4 -1
  104. package/src/tui/render-segments.mjs +165 -0
  105. package/src/tui/startup.mjs +36 -0
  106. package/src/tui/subagent-blocks.mjs +322 -144
  107. package/src/tui/subagent-panel.mjs +88 -13
  108. package/src/tui/tool-args.mjs +10 -2
  109. package/src/tui/tool-events.mjs +132 -100
  110. package/src/tui/update-notice.mjs +72 -0
  111. package/src/tui/wizard.mjs +36 -6
  112. package/src/agent-tools/escalate.mjs +0 -179
  113. package/src/agent-tools/subagent-check.mjs +0 -107
  114. 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,24 @@
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,
22
24
  ensureCompressPanel, markCompressFailed, markCompressDone, markCompressFallback,
23
25
  } from "./subagent-blocks.mjs"
24
26
  import { TURN_CAP_MARK } from "../agent/spawn-child.mjs"
@@ -38,6 +40,11 @@ const REMINDER_CAP = 3 // max pending reminders shown on turn
38
40
  const REMINDER_PERSIST_TURNS = 5 // persist reminders every N turns
39
41
 
40
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)
41
48
 
42
49
  function tickStart(name, toolId) {
43
50
  const key = toolId ?? name
@@ -74,6 +81,8 @@ export function sweepToolBlocks(state) {
74
81
  }
75
82
  }
76
83
  _toolTicks.clear()
84
+ _subActions.clear()
85
+ _subActionQ.length = 0
77
86
  }
78
87
 
79
88
  /** Shared display guard for tool results — LIVE and RESTORE use the same
@@ -94,9 +103,8 @@ export function slimToolResultForDisplay(result, maxRows = 400) {
94
103
  : rows
95
104
  }
96
105
  /** 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). */
106
+ * dedicated branch (subagent/escalate/advisor blocks) without this the turn
107
+ * sweep mislabels successful calls as "(interrupted)" (consult P1, 2026-08-30). */
100
108
  function settleToolBlock(state, name, toolId, summary) {
101
109
  const block = findToolBlock(state, name, toolId)
102
110
  if (block) {
@@ -110,9 +118,7 @@ function settleToolBlock(state, name, toolId, summary) {
110
118
  /** Async spawn detection (§15 D-A1): the subagent tool's async:true result is a
111
119
  * status JSON ({id, role, status: running|queued}), NOT a report — the child
112
120
  * keeps running, so its activity block must not be frozen at spawn time (it
113
- * freezes via the ⟦ev⟧done event emitted at settle time — §15 D-A3). A real blocking
114
- * report that happens to parse as this shape is a freak accident; the only
115
- * consequence would be a late block freeze at turn end (cosmetic). */
121
+ * freezes via the ⟦ev⟧done event at settle — §15 D-A3). */
116
122
  function isAsyncSpawnResult(result) {
117
123
  try {
118
124
  const o = JSON.parse(result)
@@ -122,6 +128,19 @@ function isAsyncSpawnResult(result) {
122
128
  }
123
129
  }
124
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
+
125
144
 
126
145
 
127
146
  /** Find the live tool-block carrier for a tool event: exact id match when the
@@ -156,11 +175,8 @@ export function buildToolCallbacks(deps) {
156
175
  const flushStream = () => {
157
176
  if (state.reasoning) {
158
177
  pushLine(state.reasoning, C.reason, "thinking")
159
- // Reasoning folds IMMEDIATELY on flush (user ruling 2026-08-30: the old
160
- // "stay expanded until next turn" auto-expand was a leftover of the
161
- // rejected pre-fold plan — thinking must be DEFAULT FOLDED in the exact
162
- // unified form: named header + tail 3, expand ≤60%, click back). Same
163
- // 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.
164
180
  state.reasoning = ""
165
181
  }
166
182
  if (state.streaming) {
@@ -181,9 +197,8 @@ export function buildToolCallbacks(deps) {
181
197
  scheduleRender()
182
198
  },
183
199
  onReasoning: (t) => {
184
- // Subagent reasoning tokens also carry role#id/ prefix — appended into the
185
- // block buffer as kind=think (F2: same treatment as the main reasoning stream;
186
- // 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).
187
202
  if (routeSubReasoning(state, t, scheduleRender)) return
188
203
  ensureAssistantLabel()
189
204
  state.reasoning += t
@@ -193,9 +208,14 @@ export function buildToolCallbacks(deps) {
193
208
  // Subagent tool call: prefix role#id/toolName → open a fresh tool block and
194
209
  // set currentTool for the header summary line.
195
210
  if (routeSubToolCall(state, name, args, scheduleRender)) return
196
- // Redundant with flushStream() below (it clears both buffers) kept as
197
- // defense-in-depth so a future flushStream change cannot leak advisor
198
- // 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.
199
219
  if (name === "advisor") { state._advisorBlocks = [] }
200
220
  flushStream()
201
221
  ensureAssistantLabel()
@@ -223,17 +243,12 @@ export function buildToolCallbacks(deps) {
223
243
  // is unreliable (it glues onto the previous line), so the round belongs here.
224
244
  // Also show the advisor's effective model (it may differ from the main agent's).
225
245
  const roundTag = name === "advisor" ? ` (round ${(agent._advisorRound || 0) + 1}${advModel ? " · " + advModel : ""})` : ""
226
- // Readable key-args summary (vscode card-header parity, 2026-08-30) —
227
- // replaces the raw JSON.stringify-80 slice: long paths landed mid-string,
228
- // and the crucial argument was often past the cut. Unknown/MCP tools
229
- // 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.
230
248
  const argSummary = describeToolArgs(name, args)
231
- // ONE BLOCK PER TOOL CALL (user ruling 2026-08-30: "为什么不把名称和参数行
232
- // 直接作为流式输出 block title" the four-piece title / _live scroll /
233
- // done-line arrangement was pre-fold-era residue). The carrier line holds
234
- // the whole call: header = name+args+live status, body = args JSON +
235
- // streaming output + result. buildConvLines renders it via the shared
236
- // 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.
237
252
  state.lines.push({
238
253
  text: "", color: C.tool,
239
254
  _lineId: (state._lineIdCounter = (state._lineIdCounter ?? 0) + 1),
@@ -250,53 +265,78 @@ export function buildToolCallbacks(deps) {
250
265
  })
251
266
  tickStart(name, toolId)
252
267
  },
253
- 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) => {
254
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"
255
283
  // Subagent complete: mark the earliest running child as done — the block
256
284
  // persists (✓ frozen elapsed header, expandable) as the ONLY carrier of the
257
- // child's activity (D4: no 3-second cleanup anymore). The report preview
258
- // (max 8 lines) still enters the conversation via the existing path below.
259
- // Block buffers survive the turn (no wipe in runAgentTurn start/finally):
260
- // child tool calls never enter the parent's history, so the block is the
261
- // only trace of what the child did — memory bounded by the N2 line cap.
262
- 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 区块)。
263
288
  if (isSubagent) {
264
289
  // The dispatch-level tool-block carrier for this call would otherwise
265
290
  // never be marked done (its result lands in the subagent block, not the
266
- // carrier) and the turn sweep would mislabel it "(interrupted)" — every
267
- // successful subagent call showed that banner (consult P1, 2026-08-30).
291
+ // carrier) and the turn sweep would mislabel it "(interrupted)".
268
292
  settleToolBlock(state, name, toolId, "completed")
269
293
  // Async spawn (§15 D-A1): the result is a status JSON, not a report — the
270
- // child KEEPS running; skip the freeze (it would tombstone a live block
271
- // and drop its relay stream). The block freezes on the ⟦ev⟧done event
272
- // emitted at turn-end collection.
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.
273
296
  if (!isAsyncSpawnResult(result)) {
274
- finishSubTask(state, SUBAGENT_ROLES, result.includes(TURN_CAP_MARK) ? "turn cap reached — work may be partial" : null)
275
- // Freeze the finished blocks into the conversation stream (user report
276
- // 2026-08-30): a pinned tail section left every ✓ block stuck above the
277
- // input box forever. As lines they scroll away, stay expandable via the
278
- // dim auto-fold, and subTasks releases the entry.
279
- freezeDoneSubTasks(state)
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
+ }
280
314
  // Subagent report preview (max 8 lines) displayed directly in conversation
281
315
  const lines = result.split("\n")
282
316
  const preview = lines.slice(0, SUBAGENT_PREVIEW_LINES).map((l) => l.slice(0, PREVIEW_LINE_CHARS)).join("\n")
283
317
  if (preview) pushLine(preview, C.dim)
284
318
  if (lines.length > SUBAGENT_PREVIEW_LINES) pushLine(` ... (${lines.length - SUBAGENT_PREVIEW_LINES} more lines)`, C.dim)
285
319
  }
286
- } else if (name === "escalate") {
287
- // 飞刀 post-op report landed freeze its block into the conversation too.
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).
288
323
  settleToolBlock(state, name, toolId, "completed")
289
- 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
+ }
290
333
  freezeDoneSubTasks(state)
291
334
  } else if (name === "consult_check" || name === "consult_stop") {
292
- // Consult session-level settle (2026-08-30 consult review): a session
293
- // spawns N parallel children, so completion must settle ALL of them.
294
- // The single-shot finishSubTask here only froze the EARLIEST running
295
- // block — the other N-1 stayed "running" pinned above the input box
296
- // all through the final answer, then got mislabeled "interrupted".
297
- // - individual reply (done:false): settle precisely by r.model —
298
- // models settle out of order; the earliest-running heuristic froze
299
- // 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.
300
340
  // - done:true / stopped: settle every remaining consult block.
301
341
  try {
302
342
  const r = JSON.parse(result)
@@ -314,7 +354,7 @@ export function buildToolCallbacks(deps) {
314
354
  }
315
355
  } /* non-JSON result — leave blocks as-is */
316
356
  }
317
- if (!isSubagent && name !== "advisor") {
357
+ if (!isSubagent && !isEscalate && name !== "advisor") {
318
358
  // Result lands INSIDE the block (restore parity — the restored carrier
319
359
  // carries the same fields). The done line is gone: status/elapsed live
320
360
  // in the header now.
@@ -337,16 +377,11 @@ export function buildToolCallbacks(deps) {
337
377
  // the frozen box, but the dispatch-level tool carrier must still be
338
378
  // marked done (consult P1, 2026-08-30: sweep mislabeled it interrupted).
339
379
  settleToolBlock(state, name, toolId, "completed")
340
- // The review's thinking must survive into the conversation history like
341
- // the main agent's reasoning (flushStream does for state.reasoning)
342
- // discarding it left the thought process visible only mid-review, then
343
- // gone. Flush BEFORE the done line so the block sits above it.
344
- // 2026-08-30: flushed as a COLLAPSIBLE box (frozen-folded semantics,
345
- // aligned with subagent blocks) instead of a flat auto-expanded line —
346
- // the flat form flooded the conversation. Full text still lives in the
347
- // tool result message; the box is the reviewable record. Live
348
- // _advisorBlocks keep rendering the running view until cleared in the
349
- // 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.
350
385
  const blocks = state._advisorBlocks ?? []
351
386
  if (blocks.length > 0) {
352
387
  const text = blocks
@@ -368,17 +403,17 @@ export function buildToolCallbacks(deps) {
368
403
  }
369
404
  },
370
405
  onToolOutput: (name, chunk, toolId) => {
371
- // All tools use inline conversation blockspanel area is abolished.
372
- // Stream up to N preview lines (config ?? per-tool ?? 5); the full result
373
- // is in the tool message.
374
- const part = typeof chunk === "string"
375
- ? { kind: "text", text: chunk.trimEnd() }
376
- : { 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
+ }
377
416
  if (!part.text) return
378
- // Subagent tool output (D1: childCallbacks.onToolOutput relays under the
379
- // prefixed name "role#id/toolName") → append into the CURRENT tool block of
380
- // that child's activity buffer. Render throttled at 250ms (N1); the data
381
- // append itself is never delayed.
382
417
  if (routeSubToolOutput(state, name, part, scheduleRender)) return
383
418
  if (name === "advisor") {
384
419
  // Accumulate to buffer — formatTables + wrapText in render-conversation
@@ -386,13 +421,9 @@ export function buildToolCallbacks(deps) {
386
421
  // NOTE: the advisor tool ALWAYS emits {kind, text} objects (run.mjs's
387
422
  // emit() wrapper) — a raw string chunk is never think; if that ever
388
423
  // changes, plain-string think would land in advisorStreaming.
389
- // ORDERED block buffer — preserves the interleaved emission order
390
- // (think → tool → think → … → final). Two separate buffers (_advisorThink
391
- // vs advisorStreaming) rendered think-block-then-main-block, which
392
- // regrouped ALL thinking above ALL tool progress — the alternating
393
- // timeline was destroyed. Consecutive chunks of the same kind merge
394
- // into one block; kind flips start a new block; render walks the
395
- // 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.
396
427
  const isString = typeof chunk === "string"
397
428
  const raw = isString ? chunk : String(chunk?.text ?? "")
398
429
  const kind = isString ? "text" : (chunk?.kind ?? "text")
@@ -418,19 +449,20 @@ export function buildToolCallbacks(deps) {
418
449
  }
419
450
  scheduleRender()
420
451
  },
421
- onPermissionRequest: (name, args) => askPermission(name, args),
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) } : {}),
422
456
  // Merged batch ask (§16 D-B1): one confirmation for N non-readonly tools in
423
457
  // the same response — "approve all / one by one / deny" (key-handler resolves
424
458
  // the verdict string; approveAll is batch-scope only, never the AUTO flag).
425
- onBatchPermissionRequest: (req) => askBatchPermission(req),
426
- onQuestion: (text, options) => askQuestion(text, options),
427
- // Compression lifecycle (CONTEXT-COMPACTION.md §7 D-C2): the compression session renders
428
- // as a subagent-style panel block — start → running panel ("Compressing context…" +
429
- // "summarizing N messages" + elapsed ticker), fail → error text ONLY (no degradation note —
430
- // that belongs to the 3-consecutive-failures fallback), successfrozen "Compressed: N
431
- // tokens freed → summary (Xs)" / fallback → "truncated to N messages". The summary BODY
432
- // never enters the panel or the stream (the summary call is silent). Replaces the old
433
- // one-line "[context] Context too long..." warn (user ruling: panel, not a status line).
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 freedsummary (Xs)" / fallback →
465
+ // "truncated to N messages". The summary BODY never enters the panel or stream.
434
466
  onCompressStart: (info) => {
435
467
  ensureCompressPanel(state, info)
436
468
  scheduleRender()
@@ -0,0 +1,72 @@
1
+ /**
2
+ * update-notice.mjs — 后台升级提示(2026-09-03 D-S1c 自 index.mjs 拆出):
3
+ * upgradeFailureText / pendingNoticeReady(纯函数,index.mjs re-export——tui.test
4
+ * 动态 import 面零改动)+ createUpdateNotice 装配(升级提示 picker + 启动检查)。
5
+ * index.mjs 只保留装配调用(showPicker 闭包在此经 ctx 注入)。
6
+ */
7
+ import { ansi, C } from "./ansi.mjs"
8
+
9
+ /** 升级失败提示文案:附 npm 输出尾部(最多 3 行),方便定位失败原因。 */
10
+ export function upgradeFailureText(code, output) {
11
+ const tail = (output ?? "").trimEnd().split("\n").slice(-3).join("\n")
12
+ return `✗ Upgrade failed (exit ${code}). Run \`thincoder upgrade\` manually.${tail ? `\n${tail}` : ""}`
13
+ }
14
+
15
+ /** 后台更新提示可弹出的条件:无任何交互弹层(picker/permission/question)激活。 */
16
+ export function pendingNoticeReady(state) {
17
+ return Boolean(state.pendingNotice && !state.picker && !state.permission && !state.question)
18
+ }
19
+
20
+ /**
21
+ * 升级提示 + 启动检查装配。ctx: { state, showPicker, pushLine, pushLabel, render }
22
+ * 返回 { showUpdateNotice, checkUpdates }——checkUpdates 非阻塞(网络错误静默跳过);
23
+ * 有 picker 打开时提示挂到 state.pendingNotice,picker 全部关闭后由 doRender 弹出。
24
+ */
25
+ export function createUpdateNotice(ctx) {
26
+ const { state, showPicker, pushLine, pushLabel, render } = ctx
27
+
28
+ const showUpdateNotice = async (result) => {
29
+ const sel = await showPicker(`Update available: ${result.local} → ${result.latest}`, [
30
+ { type: "header", text: `thincoder ${result.latest} is available (current: ${result.local})` },
31
+ { type: "item", text: "Upgrade now", action: "upgrade" },
32
+ { type: "item", text: "Later", action: "later" },
33
+ ])
34
+ if (sel?.action !== "upgrade") return
35
+ pushLabel(`❯ Upgrade`, ansi.bold + C.tool)
36
+ pushLine(`Upgrading to ${result.latest}...`, C.tool)
37
+ const { exec } = await import("node:child_process")
38
+ const cp = exec("npm install -g thincoder@latest", { windowsHide: true })
39
+ let stdout = ""
40
+ cp.stdout?.on("data", (d) => { stdout += d })
41
+ cp.stderr?.on("data", (d) => { stdout += d })
42
+ cp.on("close", (code) => {
43
+ if (code === 0) {
44
+ pushLine(`✓ Upgraded to ${result.latest}. Restart to apply.`, C.tool)
45
+ } else {
46
+ pushLine(upgradeFailureText(code, stdout), C.error)
47
+ }
48
+ render()
49
+ })
50
+ }
51
+
52
+ const checkUpdates = async () => {
53
+ try {
54
+ const { readFileSync } = await import("node:fs")
55
+ const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"))
56
+ const { checkForUpdate } = await import("../upgrade.mjs")
57
+ const result = await checkForUpdate(pkg.version)
58
+ if (result?.newer) {
59
+ // Defer: if wizard is still active, just show a dim line
60
+ if (state.wizard) {
61
+ pushLine(`Tip: thincoder ${result.latest} is available (run /upgrade later or restart)`, C.dim)
62
+ render()
63
+ } else {
64
+ state.pendingNotice = result
65
+ render()
66
+ }
67
+ }
68
+ } catch { /* network error or timeout — silently skip */ }
69
+ }
70
+
71
+ return { showUpdateNotice, checkUpdates }
72
+ }