thincoder 0.12.58 → 0.12.60

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 (158) hide show
  1. package/CHANGELOG.md +78 -2
  2. package/README.md +3 -3
  3. package/bin/thincoder.mjs +88 -19
  4. package/package.json +4 -3
  5. package/src/acp/bridge.mjs +135 -26
  6. package/src/advisor/messages.mjs +57 -4
  7. package/src/advisor/run.mjs +119 -79
  8. package/src/advisor.mjs +34 -7
  9. package/src/agent/completion.mjs +17 -11
  10. package/src/agent/dispatch.mjs +182 -22
  11. package/src/agent/helpers.mjs +71 -4
  12. package/src/agent/record-results.mjs +46 -10
  13. package/src/agent/run-stages.mjs +227 -0
  14. package/src/agent/setup-reminders.mjs +62 -0
  15. package/src/agent/setup.mjs +107 -20
  16. package/src/agent/spawn-child.mjs +54 -4
  17. package/src/agent-tools/advisor-async.mjs +456 -0
  18. package/src/agent-tools/advisor.mjs +133 -109
  19. package/src/agent-tools/async-settle.mjs +191 -0
  20. package/src/agent-tools/consult.mjs +154 -104
  21. package/src/agent-tools/design-token.mjs +104 -0
  22. package/src/agent-tools/eng.mjs +26 -30
  23. package/src/agent-tools/escalate-async.mjs +286 -0
  24. package/src/agent-tools/goal.mjs +11 -1
  25. package/src/agent-tools/read-history.mjs +284 -0
  26. package/src/agent-tools/recent-changes.mjs +2 -1
  27. package/src/agent-tools/settings.mjs +152 -0
  28. package/src/agent-tools/skill.mjs +2 -1
  29. package/src/agent-tools/subagent-actions.mjs +470 -0
  30. package/src/agent-tools/subagent-async.mjs +382 -0
  31. package/src/agent-tools/subagent-panel.mjs +153 -0
  32. package/src/agent-tools/subagent-run.mjs +202 -0
  33. package/src/agent-tools/subagent-scheduler.mjs +343 -0
  34. package/src/agent-tools/subagent-spawn.mjs +406 -0
  35. package/src/agent-tools/subagent.mjs +203 -377
  36. package/src/agent-tools/task.mjs +4 -3
  37. package/src/agent-tools/timer.mjs +9 -4
  38. package/src/agent-tools/verify.mjs +198 -238
  39. package/src/agent-tools.mjs +1 -0
  40. package/src/agent.mjs +145 -242
  41. package/src/auto-think.mjs +14 -0
  42. package/src/cli/distill-command.mjs +10 -4
  43. package/src/cli/make-agent.mjs +4 -1
  44. package/src/cli/memory-command.mjs +2 -1
  45. package/src/cli/permission.mjs +8 -1
  46. package/src/cli/setup-wizard.mjs +17 -12
  47. package/src/config.mjs +61 -8
  48. package/src/context.mjs +81 -163
  49. package/src/crash-reports.mjs +123 -0
  50. package/src/distill.mjs +30 -12
  51. package/src/escape.mjs +6 -4
  52. package/src/explore-distill.mjs +155 -0
  53. package/src/log.mjs +195 -0
  54. package/src/memory/code-sync.mjs +2 -1
  55. package/src/memory/core.mjs +11 -72
  56. package/src/memory/delete.mjs +234 -0
  57. package/src/memory/docs.mjs +206 -87
  58. package/src/memory.mjs +3 -1
  59. package/src/model-specs.mjs +15 -1
  60. package/src/peer-domains.mjs +265 -0
  61. package/src/peer-instances.mjs +231 -0
  62. package/src/prompt-overlays.mjs +25 -0
  63. package/src/prompts/advisor-design.md +18 -39
  64. package/src/prompts/advisor-round1.md +20 -32
  65. package/src/prompts/advisor-round2.md +16 -16
  66. package/src/prompts/advisor-round3.md +16 -16
  67. package/src/prompts/coder.md +7 -28
  68. package/src/prompts/consult-base.md +4 -11
  69. package/src/prompts/discipline.md +31 -44
  70. package/src/prompts/eng-coder.md +9 -34
  71. package/src/prompts/engineering-sub.md +10 -8
  72. package/src/prompts/engineering.md +61 -264
  73. package/src/prompts/explore.md +4 -14
  74. package/src/prompts/main.md +18 -35
  75. package/src/prompts/methodology-template.md +32 -38
  76. package/src/prompts/plan.md +2 -9
  77. package/src/prompts/system.md +18 -35
  78. package/src/provider/core.mjs +62 -69
  79. package/src/provider/errors.mjs +76 -0
  80. package/src/provider/retry.mjs +8 -45
  81. package/src/session-gc.mjs +214 -0
  82. package/src/session-guard.mjs +47 -0
  83. package/src/session-rename.mjs +38 -0
  84. package/src/session-slots.mjs +181 -58
  85. package/src/session.mjs +48 -89
  86. package/src/token-ttl.mjs +273 -0
  87. package/src/tools/apply_patch.md +3 -1
  88. package/src/tools/bash.md +1 -1
  89. package/src/tools/checklist-sync.mjs +181 -0
  90. package/src/tools/checklist.mjs +52 -39
  91. package/src/tools/delete.md +1 -0
  92. package/src/tools/edit-batch.mjs +131 -44
  93. package/src/tools/edit-diff.mjs +348 -0
  94. package/src/tools/edit.md +20 -13
  95. package/src/tools/execute.md +7 -7
  96. package/src/tools/execute.mjs +55 -24
  97. package/src/tools/file.mjs +25 -70
  98. package/src/tools/file_ops.md +2 -1
  99. package/src/tools/get_current_time.md +3 -1
  100. package/src/tools/git.mjs +14 -6
  101. package/src/tools/glob-dialect.mjs +130 -0
  102. package/src/tools/glob.md +3 -3
  103. package/src/tools/grep.md +1 -1
  104. package/src/tools/hashline_edit.md +2 -0
  105. package/src/tools/index.mjs +3 -3
  106. package/src/tools/insert_after.md +2 -1
  107. package/src/tools/lint.md +2 -0
  108. package/src/tools/lsp.md +4 -1
  109. package/src/tools/ops.mjs +175 -3
  110. package/src/tools/patch.mjs +84 -13
  111. package/src/tools/question.md +5 -1
  112. package/src/tools/repomap.mjs +1 -1
  113. package/src/tools/shared.mjs +18 -25
  114. package/src/tools/system.mjs +50 -30
  115. package/src/tools/tree.md +2 -1
  116. package/src/tools/wait_for.md +22 -0
  117. package/src/tools/web.mjs +5 -3
  118. package/src/tools/websearch.md +2 -1
  119. package/src/tools/write.md +2 -0
  120. package/src/traces/trace-store.mjs +224 -0
  121. package/src/tui/agent-turn.mjs +179 -27
  122. package/src/tui/clipboard.mjs +15 -4
  123. package/src/tui/cmd-config.mjs +77 -16
  124. package/src/tui/cmd-eng.mjs +20 -16
  125. package/src/tui/cmd-extract.mjs +1 -1
  126. package/src/tui/cmd-mcp.mjs +17 -2
  127. package/src/tui/cmd-new.mjs +3 -2
  128. package/src/tui/cmd-session.mjs +19 -4
  129. package/src/tui/cmd-think.mjs +11 -11
  130. package/src/tui/cmd-upgrade.mjs +19 -4
  131. package/src/tui/config-helpers.mjs +28 -16
  132. package/src/tui/distill-cmd.mjs +1 -1
  133. package/src/tui/index.mjs +31 -96
  134. package/src/tui/interaction.mjs +13 -2
  135. package/src/tui/key-handler.mjs +105 -155
  136. package/src/tui/key-modes.mjs +215 -0
  137. package/src/tui/layout.mjs +22 -1
  138. package/src/tui/mouse.mjs +46 -0
  139. package/src/tui/pickers.mjs +51 -25
  140. package/src/tui/render-conversation.mjs +13 -161
  141. package/src/tui/render-frame.mjs +27 -10
  142. package/src/tui/render-loop.mjs +4 -1
  143. package/src/tui/render-segments.mjs +182 -0
  144. package/src/tui/startup.mjs +40 -0
  145. package/src/tui/subagent-blocks.mjs +272 -262
  146. package/src/tui/subagent-children.mjs +176 -0
  147. package/src/tui/subagent-freeze.mjs +172 -0
  148. package/src/tui/subagent-panel.mjs +125 -12
  149. package/src/tui/suspension-drive.mjs +351 -0
  150. package/src/tui/tool-args.mjs +10 -2
  151. package/src/tui/tool-display.mjs +142 -0
  152. package/src/tui/tool-events.mjs +127 -231
  153. package/src/tui/tui-lifecycle.mjs +29 -0
  154. package/src/tui/update-notice.mjs +76 -0
  155. package/src/tui/wizard.mjs +48 -12
  156. package/src/agent-tools/escalate.mjs +0 -179
  157. package/src/agent-tools/subagent-check.mjs +0 -107
  158. package/src/tools/exec-prelude.mjs +0 -84
@@ -3,145 +3,37 @@
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
+ * (§19.8——check 动作已删),subagent 家族全部调用以工具名 "subagent" +
15
+ * action 到达——完成路由按 onToolCall 时记录的 action 分流(spawn 区块 / escalate
16
+ * 区块 / status/observe/send 普通工具块——observe/send 不建子代理区块)。
14
17
  */
15
18
  import { C } from "./ansi.mjs"
16
19
  import { formatToolSummary } from "./tool-summaries.mjs"
17
20
  import { describeToolArgs, toolArgsLines } from "./tool-args.mjs"
18
21
  import { ADVISOR_THINKING_PLACEHOLDER, resolveAdvisorProvider } from "../advisor/run.mjs"
19
22
  import {
20
- SUBAGENT_ROLES, routeSubToken, routeSubReasoning, routeSubToolCall,
21
- routeSubToolOutput, finishSubTask, finishSubTasksByRole, finishSubTaskByModel, freezeDoneSubTasks,
23
+ SUB_PREFIX_RE, SUBAGENT_ROLES, routeSubToken, routeSubReasoning, routeSubToolCall,
24
+ routeSubToolOutput, finishSubTask, finishSubTaskKey, finishSubTasksByRole, freezeDoneSubTasks,
22
25
  ensureCompressPanel, markCompressFailed, markCompressDone, markCompressFallback,
23
26
  } from "./subagent-blocks.mjs"
24
27
  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
- /** Async spawn detection (§15 D-A1): the subagent tool's async:true result is a
111
- * status JSON ({id, role, status: running|queued}), NOT a report — the child
112
- * 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). */
116
- function isAsyncSpawnResult(result) {
117
- try {
118
- const o = JSON.parse(result)
119
- return Boolean(o && typeof o === "object" && typeof o.id !== "undefined" && (o.status === "running" || o.status === "queued"))
120
- } catch {
121
- return false
122
- }
123
- }
124
-
125
-
126
-
127
- /** Find the live tool-block carrier for a tool event: exact id match when the
128
- * callback carries one (P0-3 — parallel same-name tools route to their own
129
- * block); falls back to the last unfinished block of that name. */
130
- function findToolBlock(state, name, toolId) {
131
- const lines = state.lines ?? []
132
- if (toolId !== undefined && toolId !== null) {
133
- for (let i = lines.length - 1; i >= 0; i--) {
134
- if (lines[i]._toolBlock?.id === toolId) return lines[i]._toolBlock
135
- }
136
- return null
137
- }
138
- for (let i = lines.length - 1; i >= 0; i--) {
139
- const b = lines[i]._toolBlock
140
- if (b && b.name === name && !b.done) return b
141
- }
142
- return null
143
- }
144
-
28
+ // 2026-09-05 module-split:ticks/maps/sweep/slim/settle/探测/find 族迁 tool-display.mjs——
29
+ // buildToolCallbacks 内部引用用本地 import;sweepToolBlocks re-export(agent-turn 消费面)
30
+ import {
31
+ TOOL_OUTPUT_LINE_CAP, SUBAGENT_PREVIEW_LINES, PREVIEW_LINE_CHARS, REMINDER_CAP, REMINDER_PERSIST_TURNS,
32
+ _toolTicks, _subActions, _subActionQ,
33
+ tickStart, tickTake, settleToolBlock, isAsyncSpawnResult, isSpawnErrorResult,
34
+ findToolBlock, slimToolResultForDisplay, sweepToolBlocks,
35
+ } from "./tool-display.mjs"
36
+ export { sweepToolBlocks, slimToolResultForDisplay } from "./tool-display.mjs"
145
37
 
146
38
  /** Build the agent callbacks + the shared flushStream for one turn.
147
39
  * askPermission, askQuestion, saveSessionImpl } */
@@ -156,11 +48,8 @@ export function buildToolCallbacks(deps) {
156
48
  const flushStream = () => {
157
49
  if (state.reasoning) {
158
50
  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.
51
+ // Reasoning folds IMMEDIATELY on flush (user ruling 2026-08-30): default-folded,
52
+ // named header + tail 3 same shape as the restore path, zero exceptions.
164
53
  state.reasoning = ""
165
54
  }
166
55
  if (state.streaming) {
@@ -181,9 +70,8 @@ export function buildToolCallbacks(deps) {
181
70
  scheduleRender()
182
71
  },
183
72
  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).
73
+ // Subagent reasoning tokens also carry role#id/ prefix — appended into the block
74
+ // buffer as kind=think (F2: same treatment as the main reasoning stream).
187
75
  if (routeSubReasoning(state, t, scheduleRender)) return
188
76
  ensureAssistantLabel()
189
77
  state.reasoning += t
@@ -193,9 +81,14 @@ export function buildToolCallbacks(deps) {
193
81
  // Subagent tool call: prefix role#id/toolName → open a fresh tool block and
194
82
  // set currentTool for the header summary line.
195
83
  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.
84
+ // §19: record the action of a merged subagent-family call (spawn is the
85
+ // default — only non-spawn actions need a record for result-time routing).
86
+ if (name === "subagent" && args?.action && args.action !== "spawn") {
87
+ if (toolId !== undefined && toolId !== null) _subActions.set(toolId, args.action)
88
+ else _subActionQ.push(args.action)
89
+ }
90
+ // Redundant with flushStream() below (it clears both buffers) — defense-in-depth
91
+ // so a future flushStream change cannot leak advisor buffers into the next view.
199
92
  if (name === "advisor") { state._advisorBlocks = [] }
200
93
  flushStream()
201
94
  ensureAssistantLabel()
@@ -223,17 +116,12 @@ export function buildToolCallbacks(deps) {
223
116
  // is unreliable (it glues onto the previous line), so the round belongs here.
224
117
  // Also show the advisor's effective model (it may differ from the main agent's).
225
118
  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.
119
+ // Readable key-args summary (vscode card-header parity, 2026-08-30) — replaces
120
+ // the raw JSON.stringify-80 slice. Unknown/MCP tools fall back to compact JSON.
230
121
  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.
122
+ // ONE BLOCK PER TOOL CALL (user ruling 2026-08-30): header = name+args+live
123
+ // status, body = args JSON + streaming output + result. buildConvLines renders
124
+ // it via the shared fold-block component; restore emits the SAME carrier.
237
125
  state.lines.push({
238
126
  text: "", color: C.tool,
239
127
  _lineId: (state._lineIdCounter = (state._lineIdCounter ?? 0) + 1),
@@ -250,71 +138,87 @@ export function buildToolCallbacks(deps) {
250
138
  })
251
139
  tickStart(name, toolId)
252
140
  },
253
- onToolResult: (name, result, toolId) => {
141
+ // §7.2.3(方案 e):dispatch runOne 把工具 ctx 上的 _subagentKey(sync spawn/
142
+ // escalate 成功路径设置——relayPrefix 去尾)作为第 4 参传来——undefined 兼容既有
143
+ // 签名(普通工具/老回调/错误路径不带 key)。
144
+ onToolResult: (name, result, toolId, subKey) => {
254
145
  state.currentTool = null
146
+ // §19 merged family: route per the action recorded at onToolCall (no record = default spawn).
147
+ let subAction = null
148
+ if (name === "subagent") {
149
+ subAction = (toolId !== undefined && toolId !== null)
150
+ ? _subActions.get(toolId) ?? null
151
+ : _subActionQ.shift() ?? null
152
+ if (toolId !== undefined && toolId !== null) _subActions.delete(toolId)
153
+ }
154
+ const isSubagent = name === "subagent" && subAction !== "status" && subAction !== "escalate" && subAction !== "cancel" && subAction !== "panel" && subAction !== "observe" && subAction !== "send"
155
+ const isEscalate = name === "subagent" && subAction === "escalate"
255
156
  // Subagent complete: mark the earliest running child as done — the block
256
157
  // 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"
158
+ // child's activity; memory bounded by the N2 line cap.
159
+ // §19.5: cancel 动作排除在 isSubagent 外——ack/错误 JSON 走普通工具块;区块冻结由
160
+ // ⟦ev⟧stopped settle 事件承担(此处 finishSubTask 会误冻最早 running 区块)。
263
161
  if (isSubagent) {
264
162
  // The dispatch-level tool-block carrier for this call would otherwise
265
163
  // 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).
164
+ // carrier) and the turn sweep would mislabel it "(interrupted)".
268
165
  settleToolBlock(state, name, toolId, "completed")
269
166
  // 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.
167
+ // child KEEPS running; skip the freeze (it would tombstone a live block and
168
+ // drop its relay stream). The block freezes on the ⟦ev⟧done settle event.
273
169
  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)
170
+ // §7.2.3 sync spawn 完成精确冻结:结果按 key 归属三支——
171
+ // dispatch 同步成功路径带 subKey(ctx._subagentKey = relayPrefix 去尾):
172
+ // finishSubTaskKey key 精确冻——不再落 finishSubTask 的"最早 started"
173
+ // 启发式(async eng-coder 先启动时 explore 完成会误冻其块——7.2.3.1/T-F2);
174
+ // spawn 门拒错误({status:"error"} JSON——auto-turn digest spawn 拒绝):
175
+ // 不冻结任何块(round1 #1——错误路径不冻结 running 块——T-F5);
176
+ // ③ subKey undefined 非错误(老回调/测试直调——成功路径未知工具)→ 启发式
177
+ // 兜底(既有行为不变——面板单块时与精确冻同效——T-F1)。
178
+ const lastError = result.includes(TURN_CAP_MARK) ? "turn cap reached — work may be partial" : null
179
+ const hasSubKey = subKey !== undefined && subKey !== null && subKey !== ""
180
+ if (hasSubKey) {
181
+ finishSubTaskKey(state, String(subKey), lastError)
182
+ freezeDoneSubTasks(state)
183
+ } else if (!isSpawnErrorResult(result)) {
184
+ finishSubTask(state, SUBAGENT_ROLES, lastError)
185
+ freezeDoneSubTasks(state)
186
+ }
280
187
  // Subagent report preview (max 8 lines) displayed directly in conversation
281
188
  const lines = result.split("\n")
282
189
  const preview = lines.slice(0, SUBAGENT_PREVIEW_LINES).map((l) => l.slice(0, PREVIEW_LINE_CHARS)).join("\n")
283
190
  if (preview) pushLine(preview, C.dim)
284
191
  if (lines.length > SUBAGENT_PREVIEW_LINES) pushLine(` ... (${lines.length - SUBAGENT_PREVIEW_LINES} more lines)`, C.dim)
285
192
  }
286
- } else if (name === "escalate") {
287
- // 飞刀 post-op report landed freeze its block into the conversation too.
193
+ } else if (isEscalate) {
194
+ // 飞刀 post-op report landed under the subagent tool name freeze the
195
+ // escalate#N activity block (no preview; legacy surface).
288
196
  settleToolBlock(state, name, toolId, "completed")
289
- finishSubTask(state, ["escalate"], result.includes(TURN_CAP_MARK) ? "turn cap reached — work may be partial" : null)
290
- freezeDoneSubTasks(state)
291
- } 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.
300
- // - done:true / stopped: settle every remaining consult block.
301
- try {
302
- const r = JSON.parse(result)
303
- if (r?.done || r?.stopped !== undefined) {
304
- finishSubTasksByRole(state, ["consult"], null)
305
- freezeDoneSubTasks(state)
306
- } else if (r?.model) {
307
- finishSubTaskByModel(state, "consult", r.model)
308
- freezeDoneSubTasks(state)
309
- }
310
- } catch {
311
- if (name === "consult_stop") {
312
- finishSubTasksByRole(state, ["consult"], null)
313
- freezeDoneSubTasks(state)
197
+ // §25 D-R17b (R17): async escalate ack ({id, role:"escalate", status:
198
+ // running|queued}) — the child KEEPS running — skip the freeze like the
199
+ // async spawn path (the block freezes on the ⟦ev⟧done/stopped settle
200
+ // event at flight end). Sync results (async:false) freeze below.
201
+ if (!isAsyncSpawnResult(result)) {
202
+ // §7.2.3(round1 #2):escalate 成功返回带 subKey(escalate#N)→ 精确冻;
203
+ // 失败/老回调无 subKey escalate 角色启发式兜底(escalate 串行 + 角色限定——
204
+ // 既有行为)。
205
+ const lastError = result.includes(TURN_CAP_MARK) ? "turn cap reached work may be partial" : null
206
+ if (subKey !== undefined && subKey !== null && subKey !== "") {
207
+ finishSubTaskKey(state, String(subKey), lastError)
208
+ } else {
209
+ finishSubTask(state, ["escalate"], lastError)
314
210
  }
315
- } /* non-JSON result — leave blocks as-is */
211
+ freezeDoneSubTasks(state)
212
+ }
213
+ } else if (name === "consult_stop") {
214
+ // R17(§25 D-R17a——check 已删):consult_stop = 取消指定会诊会话。被 abort 的
215
+ // children 各自 settle(settleChild)时发 ⟦ev⟧done 冻结自己的卡——此处**不做按
216
+ // 角色整组清扫**(会诊会话可并发——按角色会误冻其他仍在运行的会话的卡,冻结即
217
+ // 截断其活动流——tool-events advisor 复评 🟡1 修正);取消的即时可见性由 abort
218
+ // settle 事件承担(abort 解绕通常在同回合内完成——卡片在其 child settle 即冻结)。
219
+ // 仅结果本身落本工具调用自己的载体块(下方通用分支)。
316
220
  }
317
- if (!isSubagent && name !== "advisor") {
221
+ if (!isSubagent && !isEscalate && name !== "advisor") {
318
222
  // Result lands INSIDE the block (restore parity — the restored carrier
319
223
  // carries the same fields). The done line is gone: status/elapsed live
320
224
  // in the header now.
@@ -337,16 +241,11 @@ export function buildToolCallbacks(deps) {
337
241
  // the frozen box, but the dispatch-level tool carrier must still be
338
242
  // marked done (consult P1, 2026-08-30: sweep mislabeled it interrupted).
339
243
  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.
244
+ // The review's thinking must survive into the conversation history like the
245
+ // main agent's reasoning flushed as a COLLAPSIBLE box before the done
246
+ // line (frozen-folded semantics, aligned with subagent blocks; the flat
247
+ // form flooded the conversation). Full text lives in the tool result;
248
+ // _advisorBlocks keep rendering the running view until cleared at turn end.
350
249
  const blocks = state._advisorBlocks ?? []
351
250
  if (blocks.length > 0) {
352
251
  const text = blocks
@@ -368,17 +267,17 @@ export function buildToolCallbacks(deps) {
368
267
  }
369
268
  },
370
269
  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() }
270
+ // Subagent relays (name "role#id/tool", D1) route RAW chunks are
271
+ // child-stdout/SSE fragments at arbitrary byte boundaries; trimEnd eats
272
+ // real trailing newlines and routeSubToolOutput's verbatim concat would
273
+ // glue lines (2026-09-03 修复轮; main path keeps the trimmed form below).
274
+ const isSubRelay = SUB_PREFIX_RE.test(name)
275
+ const rawText = typeof chunk === "string" ? chunk : String(chunk?.text ?? "")
276
+ const part = {
277
+ kind: typeof chunk === "string" ? "text" : (chunk?.kind ?? "text"),
278
+ text: isSubRelay ? rawText : rawText.trimEnd(),
279
+ }
377
280
  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
281
  if (routeSubToolOutput(state, name, part, scheduleRender)) return
383
282
  if (name === "advisor") {
384
283
  // Accumulate to buffer — formatTables + wrapText in render-conversation
@@ -386,13 +285,9 @@ export function buildToolCallbacks(deps) {
386
285
  // NOTE: the advisor tool ALWAYS emits {kind, text} objects (run.mjs's
387
286
  // emit() wrapper) — a raw string chunk is never think; if that ever
388
287
  // 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.
288
+ // ORDERED block buffer — preserves the interleaved emission order (think →
289
+ // tool → think → … → final); consecutive chunks of the same kind merge
290
+ // into one block, kind flips start a new block, render walks them in order.
396
291
  const isString = typeof chunk === "string"
397
292
  const raw = isString ? chunk : String(chunk?.text ?? "")
398
293
  const kind = isString ? "text" : (chunk?.kind ?? "text")
@@ -418,19 +313,20 @@ export function buildToolCallbacks(deps) {
418
313
  }
419
314
  scheduleRender()
420
315
  },
421
- onPermissionRequest: (name, args) => askPermission(name, args),
316
+ // Manual-tier auto-turn digests (agent-turn.mjs suspension driver) pass null
317
+ // handlers — permission requests then deny WITHOUT a panel (§17 D-S7: no modal
318
+ // during unattended digestion) and question errors out instead of hanging.
319
+ ...(askPermission ? { onPermissionRequest: (name, args) => askPermission(name, args) } : {}),
422
320
  // Merged batch ask (§16 D-B1): one confirmation for N non-readonly tools in
423
321
  // the same response — "approve all / one by one / deny" (key-handler resolves
424
322
  // 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).
323
+ ...(askBatchPermission ? { onBatchPermissionRequest: (req) => askBatchPermission(req) } : {}),
324
+ ...(askQuestion ? { onQuestion: (text, options) => askQuestion(text, options) } : {}),
325
+ // Compression lifecycle (CONTEXT-COMPACTION.md §7 D-C2): the compression session
326
+ // renders as a subagent-style panel block — start → running panel ("Compressing
327
+ // context…" + "summarizing N messages" + elapsed ticker), fail → error text only,
328
+ // success frozen "Compressed: N tokens freedsummary (Xs)" / fallback →
329
+ // "truncated to N messages". The summary BODY never enters the panel or stream.
434
330
  onCompressStart: (info) => {
435
331
  ensureCompressPanel(state, info)
436
332
  scheduleRender()
@@ -3,6 +3,7 @@
3
3
  * 超 500 行硬限拆分——启动序列、退出清理序列与退出闭包从 index.mjs 移入本模块)。
4
4
  */
5
5
 
6
+ import { appendFileSync } from "node:fs"
6
7
  import { ansi } from "./ansi.mjs"
7
8
 
8
9
  /** TUI 启动序列:alt buffer + 隐藏光标 + 鼠标/粘贴/键盘增强 + 禁环绕。
@@ -19,6 +20,33 @@ export function writeCleanupSequence(write = (s) => process.stdout.write(s)) {
19
20
  write(ansi.clearScreen + ansi.mouseOff + ansi.bracketedPasteOff + ansi.keyboardPop + ansi.modifyOtherKeysOff + ansi.mainBuffer + ansi.showCursor + ansi.reset + ansi.wrapOn)
20
21
  }
21
22
 
23
+ // R25(ARCHITECTURE.md §R25 F-R25a——复审 #1):TUI 活动态标志——index.mjs 在终端接管
24
+ // 处(writeStartupSequence 之后)经 setTuiActive(true) 置位,createExitCleanup 清理后清
25
+ // false;bin/thincoder.mjs 崩溃钩子经 restoreTerminalAfterCrash 读同一标志——chat 模式
26
+ // stdout 管道不得收 ANSI(误判即污染输出)。默认 false = 未启动(生产零变化——测试缝同源:
27
+ // T-R25a.2 注入 mock 走同一 setter)。
28
+ let tuiActive = false
29
+
30
+ /** 置/清 TUI 活动态(TUI 启动接管处与退出清理处调用;测试 env 门注入同走此 setter)。 */
31
+ export function setTuiActive(active) {
32
+ tuiActive = active === true
33
+ }
34
+
35
+ /** R25 崩溃恢复:仅 TUI 活动态执行 writeCleanupSequence(复用本模块清理序列——符号锚)。
36
+ * 测试缝(T-R25a.2——env 门注入):THINCODER_TEST_CLEANUP_OUT 指向文件时序列写入该文件
37
+ * (观察恢复被调 + stdout 管道零 ANSI)——生产不设该 env → 恒 stdout(零变化)。
38
+ * @returns {boolean} 是否执行了恢复(false = TUI 未启动——调用方无需处理) */
39
+ export function restoreTerminalAfterCrash() {
40
+ if (!tuiActive) return false
41
+ const seamOut = process.env.THINCODER_TEST_CLEANUP_OUT
42
+ if (seamOut) {
43
+ writeCleanupSequence((s) => appendFileSync(seamOut, s, "utf8"))
44
+ } else {
45
+ writeCleanupSequence()
46
+ }
47
+ return true
48
+ }
49
+
22
50
  /** 退出清理闭包:保存会话(同步)+ 关闭 MCP 子进程 + 恢复终端。幂等(cleanedUp 守卫)。 */
23
51
  export function createExitCleanup({ agent, saveSession, closeAllMcp }) {
24
52
  let cleanedUp = false
@@ -41,5 +69,6 @@ export function createExitCleanup({ agent, saveSession, closeAllMcp }) {
41
69
  }
42
70
  process.stdin.setRawMode(false)
43
71
  writeCleanupSequence()
72
+ setTuiActive(false) // R25:清理完成即清活动态(崩溃钩子不再收到误判)
44
73
  }
45
74
  }
@@ -0,0 +1,76 @@
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
+ let done = false
41
+ cp.stdout?.on("data", (d) => { stdout += d })
42
+ cp.stderr?.on("data", (d) => { stdout += d })
43
+ cp.on("error", (e) => { if (done) return; done = true; pushLine(`✗ Upgrade failed (${e.message}). Run \`thincoder upgrade\` manually.`, C.error); render() })
44
+ cp.on("close", (code) => {
45
+ if (done) return
46
+ done = true
47
+ if (code === 0) {
48
+ pushLine(`✓ Upgraded to ${result.latest}. Restart to apply.`, C.tool)
49
+ } else {
50
+ pushLine(upgradeFailureText(code, stdout), C.error)
51
+ }
52
+ render()
53
+ })
54
+ }
55
+
56
+ const checkUpdates = async () => {
57
+ try {
58
+ const { readFileSync } = await import("node:fs")
59
+ const pkg = JSON.parse(readFileSync(new URL("../../package.json", import.meta.url), "utf8"))
60
+ const { checkForUpdate } = await import("../upgrade.mjs")
61
+ const result = await checkForUpdate(pkg.version)
62
+ if (result?.newer) {
63
+ // Defer: if wizard is still active, just show a dim line
64
+ if (state.wizard) {
65
+ pushLine(`Tip: thincoder ${result.latest} is available (run /upgrade later or restart)`, C.dim)
66
+ render()
67
+ } else {
68
+ state.pendingNotice = result
69
+ render()
70
+ }
71
+ }
72
+ } catch { /* network error or timeout — silently skip */ }
73
+ }
74
+
75
+ return { showUpdateNotice, checkUpdates }
76
+ }