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
@@ -1,5 +1,6 @@
1
1
  /**
2
- * agent-turn.mjs — runAgentTurn:一个用户回合的驱动器(submit / 队列递归入口)。
2
+ * agent-turn.mjs — runAgentTurn:一个用户回合的驱动器(submit / 队列递归入口)
3
+ * + §17 挂起会话(suspensionSession:async 子代理后台运行期间主会话可继续对话)。
3
4
  *
4
5
  * 2026-08-30 拆分(回回 500 行硬限):回合生命周期(状态复位 → runAgent 循环 →
5
6
  * 错误/Continue/中断处理 → finally 收尾 → 队列)留在这里;工具事件 → TUI 状态的
@@ -7,35 +8,90 @@
7
8
  * + flushStream)在 tool-events.mjs buildToolCallbacks;子agent 区块缓冲与完成冻结
8
9
  * (routeSub* / finishSubTask / freeze*SubTasks)在 subagent-blocks.mjs;标题生成
9
10
  * 在 generate-title.mjs ensureSessionTitle。
11
+ *
12
+ * §17(2026-09-02,AGENT-LOOP.md §17 D-S1..S9):回合尾后台池非空 → 不阻塞等待,
13
+ * 进入挂起态——输入放开(Enter = 新回合 / digest 中 Enter 排队 pendingInput)、
14
+ * settle 事件驱动 auto-turn 消化(手动档 organize-only / AUTO 档全语义)、池空 + 无
15
+ * 待处理输入 → 补发 done 冻结自然退出。状态机行表见 AGENT-LOOP.md §17 D-S9。
10
16
  */
11
17
  import { runAgent, ContinueError } from "../agent.mjs"
12
18
  import { saveSession } from "../session.mjs"
13
19
  import { ansi, C } from "./ansi.mjs"
14
20
  import { buildToolCallbacks, sweepToolBlocks } from "./tool-events.mjs"
15
- import { freezeAllSubTasks } from "./subagent-blocks.mjs"
21
+ import { freezeAllSubTasks, freezeReclaimDigestedBlocks } from "./subagent-blocks.mjs"
16
22
  import { ensureSessionTitle } from "../generate-title.mjs"
23
+ import { logEvent, errText } from "../log.mjs"
17
24
 
18
25
  /** Exit-flush bound for the async end-of-run distillation (SEND-STALL-DISTILL §2.5):
19
26
  * wait at most this long for the in-flight distill before the final session save —
20
27
  * never let shutdown hang on the background summary call. */
21
28
  const DISTILL_FLUSH_TIMEOUT_MS = 5000
22
29
 
23
- /** Execute one agent conversation turn (triggered by submit or queue).
24
- * Extracted from index.mjs: agent loop + callback construction + error handling + queue processing.
25
- * ctx: { agent, state, pushLine, pushLabel, render, scheduleRender,
26
- * ensureAssistantLabel, askPermission, askQuestion,
27
- * handleSlash, summarize } */
28
- export async function runAgentTurn(ctx, text) {
29
- const { agent, state, pushLine, pushLabel, render, scheduleRender, ensureAssistantLabel, askPermission, askQuestion, handleSlash } = ctx
30
+ /** 后台池计数(LOGGING susp/digest 事件字段——pendingN/poolN)。
31
+ * poolN = _asyncSubagents map 大小(queued 条目同样在 map 内——2026-09-03 code
32
+ * review #2:不再 +queue.length 双计;与 vscode suspension poolCounts 口径一致)。 */
33
+ function poolCounts(agent) {
34
+ const map = agent?._asyncSubagents
35
+ const running = map ? [...map.values()].filter((e) => e.status === "running").length : 0
36
+ return {
37
+ poolN: map?.size ?? 0,
38
+ pendingN: agent?._pendingAsyncResults?.length ?? 0,
39
+ runningN: running,
40
+ }
41
+ }
42
+
43
+ /**
44
+ * LOGGING(docs/design/LOGGING.md)包装:回合骨架事件(turn:start/turn:end——kind
45
+ * user/auto;result ok/stopped/error)。内层经 _logOutcome 载具回传终止原因(中止/
46
+ * turn-cap 拒绝/错误 vs 正常完成)——嵌套回合(队列递归/挂起会话内 digest 轮)各自
47
+ * 新开载具(每次包装调用独立),互不串扰。err:internal = 逃出内层的未分类异常。
48
+ */
49
+ export async function runAgentTurn(ctx, text, opts = {}) {
50
+ const kind = opts?.autoTurn ? "auto" : "user"
51
+ const runOpts = { ...(opts ?? {}) }
52
+ delete runOpts._logOutcome
53
+ const carrier = {}
54
+ runOpts._logOutcome = carrier
55
+ const t0 = Date.now()
56
+ logEvent("turn:start", { kind })
57
+ try {
58
+ const result = await runAgentTurnInner(ctx, text, runOpts)
59
+ return result
60
+ } catch (e) {
61
+ carrier.result = "error"
62
+ logEvent("err:internal", { msg: errText(e, 200), where: "runAgentTurn" })
63
+ throw e
64
+ } finally {
65
+ logEvent("turn:end", { kind, ms: Date.now() - t0, result: carrier.result ?? "ok" })
66
+ }
67
+ }
68
+
69
+ /** runAgentTurn 本体(LOGGING 包装之外——见上方包装器)。 */
70
+ async function runAgentTurnInner(ctx, text, opts) {
71
+ const { autoTurn = false, skipSession = false } = opts ?? {}
72
+ const { agent, state, pushLine, pushLabel, render, scheduleRender, ensureAssistantLabel, askPermission, askBatchPermission, askQuestion, handleSlash } = ctx
30
73
  // 可注入覆盖(测试用);默认走真实实现
31
74
  const runAgentImpl = ctx.runAgent ?? runAgent
32
75
  const saveSessionImpl = ctx.saveSession ?? saveSession
33
- pushLabel(`❯ You:`, ansi.bold + C.user)
34
- pushLine(text, C.text)
76
+ // autoTurn(消化轮):无用户输入——不画 "❯ You:"(系统驱动回合,§17 D-S6)
77
+ if (!autoTurn) {
78
+ pushLabel(`❯ You:`, ansi.bold + C.user)
79
+ pushLine(text, C.text)
80
+ }
35
81
 
36
82
  ctx.assistantLabeled = false
37
83
  state.processing = true
38
84
  state.status = "Processing..."
85
+ // §17.6(advisor round2 🟡):回合启动即解除空闲退出武装——exitArmed 只属于空闲态
86
+ // 双确认,跨回合残留会让"停回合后的 armed 落空穿透 + 陈旧 exitArmed"组合把一次
87
+ // 意图为全停/退出的按下变成无二次确认的即时退出(key-handler 落空分支落回空闲
88
+ // 分支时 `!state.exitArmed` 判假即 exit);计时一并清(key-handler 空闲分支按
89
+ // ctx.exitArmTimer 有无决定是否重建——陈旧 timer 残留会在回合中途复位新武装)。
90
+ state.exitArmed = false
91
+ if (ctx.exitArmTimer) {
92
+ clearTimeout(ctx.exitArmTimer)
93
+ ctx.exitArmTimer = null
94
+ }
39
95
  state.streaming = ""
40
96
  state.reasoning = ""
41
97
  state._advisorBlocks = []
@@ -46,7 +102,27 @@ export async function runAgentTurn(ctx, text) {
46
102
  // bounded by the N2 per-child 500-line ring buffer.
47
103
  state.currentTool = null
48
104
  state.processingStarted = Date.now()
49
- state.controller = new AbortController()
105
+ // §17 偏差修复 #3(会话 abort 全覆盖):回合链 controller 登记。链头 = 非挂起会话内
106
+ // 且非释放窗口期(suspended/_suspPending 均 false)开启的回合——登记表清零;队列
107
+ // 递归回合(_suspPending 置位期)与会话内回合(suspended=true)继续累积。链条内每次
108
+ // 重建(Ctrl+I 续跑 / ContinueError 续跑 / AUTO 续跑)都登记——挂起会话的 abort 集合
109
+ // (_sessionAbortAll)必须在会话建立时覆盖进入会话以来的全部 controller:只 abort
110
+ // 最后一个会让旧 controller 下 spawn 的 async children 逃逸中止(偏差 #3)。
111
+ // 链头同时清掉上一链条残留的会话句柄(上一链 pool 先 live 后耗尽、未进入会话即结束
112
+ // 时 _sessionAbort 会滞留——不清理则下一链的会话 signal 指向旧 controller,Ctrl+C
113
+ // 中止集合漏掉会话期 spawn 的 children)。
114
+ if (!state.suspended && !state._suspPending) {
115
+ state._turnControllers = []
116
+ agent._sessionAbort = null
117
+ agent._sessionAbortAll = null
118
+ }
119
+ state._turnControllers ??= []
120
+ const makeController = () => {
121
+ const c = new AbortController()
122
+ state._turnControllers.push(c)
123
+ return c
124
+ }
125
+ state.controller = makeController()
50
126
  state.interruptPrompt = null
51
127
  // Refresh status bar every second during processing; also refresh when any
52
128
  // subagent block is still running so its header elapsed ticks (§7.2 D4 —
@@ -59,7 +135,7 @@ export async function runAgentTurn(ctx, text) {
59
135
  render()
60
136
 
61
137
  const { callbacks, flushStream } = buildToolCallbacks({
62
- agent, state, pushLine, render, scheduleRender, ensureAssistantLabel, askPermission, askQuestion, saveSessionImpl,
138
+ agent, state, pushLine, render, scheduleRender, ensureAssistantLabel, askPermission, askBatchPermission, askQuestion, saveSessionImpl,
63
139
  })
64
140
 
65
141
  // try/finally: every exit path — including an unexpected throw inside the catch
@@ -68,24 +144,55 @@ export async function runAgentTurn(ctx, text) {
68
144
  try {
69
145
  for (let resume = false; ; resume = true) {
70
146
  try {
71
- await runAgentImpl(agent, text, callbacks, { signal: state.controller.signal, resume })
147
+ await runAgentImpl(agent, text, callbacks, { signal: state.controller.signal, resume, autoTurn, suspDriven: true })
72
148
  flushStream()
73
149
  break // Normal completion, exit loop
74
150
  } catch (error) {
75
151
  flushStream()
76
152
  if (error.name === "AbortError" || state.controller?.signal.aborted) {
77
- // Ctrl+I inject: the signal was aborted with an interrupt message — the agent loop
78
- // may have already injected it into history, but the aborted signal prevents retry.
79
- // Recreate the controller and resume from the same context.
80
- if (state.controller?.signal?.reason?.interrupt) {
81
- state.controller = new AbortController()
153
+ const reason = state.controller?.signal?.reason
154
+ // §17.6 D-C1(round1 #1 区分机制——2026-09-03):interrupt 两种语义——
155
+ // message(Ctrl+I 注入——key-modes handleInterruptMode)= 重建 controller
156
+ // 续跑(既有语义——agent loop 已把消息注入 history,中止的 signal 不能重试);
157
+ // 无 message(Ctrl+C 首按停回合——key-handler abort({ interrupt: true })
158
+ // 不带 message——非挂起 processing 态 / 挂起态 digest·会话内回合)
159
+ // = 停回合不续跑——池保留由 agent.mjs 回合收尾的 !interrupt 清池条件排除
160
+ // 实现(D-C2——agent.mjs 零改动)。
161
+ if (reason?.interrupt && reason?.message) {
162
+ state.controller = makeController()
82
163
  resume = true
83
164
  continue
84
165
  }
166
+ if (reason?.interrupt) {
167
+ // 无 message interrupt 的注入副作用回滚:agent.mjs 中断三段(chat catch /
168
+ // response.interrupted / 工具执行中断)无条件把 "[User interrupt: <msg>]"
169
+ // 落 history,对 message 存在性无守卫——无 message 时成为 "[User interrupt:
170
+ // undefined]" 垃圾上下文(消息注入语义只属于 Ctrl+I 续跑——停回合无注入
171
+ // 消息)。D-C2 agent.mjs 零改动约束下在回合层回滚尾部垃圾(确定性:中断
172
+ // 注入恒为最后一条——break 前 history 不再追加)。partial 部分输出不回滚
173
+ // ——interrupt 家族语义(§2 Ctrl+I 同款"提交部分输出")——advisor round1
174
+ // 🟡 裁定:回滚需区分工具/子代理路径的既有完整消息(history 层不可靠)。
175
+ const h = agent.history
176
+ while (h?.length > 0 && String(h.at(-1)?.content ?? "") === "[User interrupt: undefined]") h.pop()
177
+ }
85
178
  pushLine("[stopped]", C.warn)
179
+ if (opts?._logOutcome) opts._logOutcome.result = "stopped"
86
180
  break
87
181
  }
88
182
  if (error instanceof ContinueError) {
183
+ if (autoTurn) {
184
+ // §17 digest turn-cap 规则(D-S9 ContinueError 行):无面板——AUTO 档按
185
+ // §2 统一规则自动 resume(无人值守授权);手动档静默拒绝(部分消化留在
186
+ // 历史,会话回挂起——结果不丢,只是不再烧轮次)。
187
+ if (agent.autoApprove) {
188
+ pushLine("[auto-turn: continuing past turn cap…]", C.dim)
189
+ state.controller = makeController()
190
+ continue
191
+ }
192
+ pushLine(`[auto-turn stopped at ${error.turn} turns — partial digest; finished reports stay in history]`, C.warn)
193
+ if (opts?._logOutcome) opts._logOutcome.result = "stopped"
194
+ break
195
+ }
89
196
  pushLabel(`❯ Continue`, ansi.bold + C.warn)
90
197
  pushLine(`Ran ${error.turn} turns (limit ${error.turn}). Continue?`, C.warn)
91
198
  // Pause to ask: reuse permission mechanism
@@ -101,14 +208,16 @@ export async function runAgentTurn(ctx, text) {
101
208
  state.permission = null
102
209
  if (!willContinue) {
103
210
  pushLine("[continue cancelled]", C.warn)
211
+ if (opts?._logOutcome) opts._logOutcome.result = "stopped"
104
212
  break
105
213
  }
106
214
  pushLine("[continuing…]", C.tool)
107
215
  // Recreate AbortController: once aborted, resume immediately fails (defensive; current path unreachable but tightly coupled)
108
- state.controller = new AbortController()
216
+ state.controller = makeController()
109
217
  continue
110
218
  }
111
219
  pushLine(`[error] ${error.message}`, C.error)
220
+ if (opts?._logOutcome) opts._logOutcome.result = "error"
112
221
  break
113
222
  }
114
223
  }
@@ -116,10 +225,40 @@ export async function runAgentTurn(ctx, text) {
116
225
  clearInterval(ticker)
117
226
  state.processing = false
118
227
  state._advisorBlocks = []
119
- // Interrupted runs (Ctrl+C abort / error mid-turn): still-running child blocks
120
- // would linger as pinned ghosts above the input box — freeze them like normal
121
- // completions so the trace scrolls away with the conversation (2026-08-30).
122
- freezeAllSubTasks(state)
228
+ // §17 D-S1/D-S8:回合正常结束且后台池仍 live 挂起会话:子agent 区块保持 live
229
+ // (不冻结——各 settle 事件自行处理),本次回合 controller 交会话层作 abort 句柄
230
+ // (挂起期 Ctrl+C 中止全部后台子代理);池空 / 中断 / 错误 现状 freezeAllSubTasks
231
+ // (中断态块标 interrupted;正常态块已在 settle 时各自冻结)。
232
+ const willSuspend = poolLive(agent)
233
+ // §17.5.5:挂起会话内回合(digest/会话内用户回合——skipSession 且 suspended)的
234
+ // 收尾**不冻结驻留块**——已消化(pinned 且条目已注入)块由 suspensionSession 在
235
+ // run 返回后 freezeReclaimDigestedBlocks 逐条回收(settle 锚点 splice——digest 总览文本
236
+ // 之前——round1 #1 裁定,不等池空);
237
+ // 未消化残项由会话退出 freezeAllSubTasks 兜底。若无此例外,池空的 digest 回合会在
238
+ // finally 抢先按 settle 锚点冻结(旧池空补发语义)——回收时序被抢占(T-S6)。
239
+ const inSessionTurn = skipSession && state.suspended
240
+ if (!willSuspend && !inSessionTurn) {
241
+ // Interrupted runs (Ctrl+C abort / error mid-turn): still-running child blocks
242
+ // would linger as pinned ghosts above the input box — freeze them like normal
243
+ // completions so the trace scrolls away with the conversation (2026-08-30).
244
+ freezeAllSubTasks(state)
245
+ } else if (!agent._sessionAbort) {
246
+ agent._sessionAbort = state.controller // 会话 abort 句柄(children 共享此 signal)
247
+ }
248
+ // §17 偏差修复 #1(回合释放窗口守卫):willSuspend 判定后、进入任何 await 之前置位
249
+ // 挂起待定标志。suspensionSession 真正启动前还有真实 await(ensureSessionTitle /
250
+ // distill flush ≤5s / saveSession),其间 processing=false 且 suspended 尚未置位——
251
+ // 无此标志 key-handler Enter 会走 submit 并发开第二个 runAgentTurn(双驱动器竞态:
252
+ // 两个 runAgent 循环同时 pushReal 同一 agent.history,工具配对/上下文交错)。标志
253
+ // 有效区间 = 释放窗口(suspensionSession 启动即清除);会话内回合(skipSession)
254
+ // 由 state.suspended 覆盖;_suspAborted 时挂起会话不会启动(下方会话入口同条件)。
255
+ state._suspPending = willSuspend && !skipSession && !state._suspAborted
256
+ // §17 偏差修复 #3(会话 abort 全覆盖):abort 集合快照 = 本链条内全部 controller
257
+ // (含 Ctrl+I/ContinueError 重建的旧 controller——其下 spawn 的 async children 持旧
258
+ // signal,Ctrl+C 只 abort 最后一个会让它们跑完整个 turn 预算,正常完成后仍
259
+ // mergeChildMutations 写父 guard 标记,绕过 advisor/verify 门)。会话内回合的 finally
260
+ // 会重复快照(链条累积),幂等。
261
+ if (willSuspend) agent._sessionAbortAll = [...(state._turnControllers ?? [])]
123
262
  // Tool-block carriers get the same sweep (P0-2, 2026-08-30 consult): without
124
263
  // an onToolResult their header would say "running" forever; ticks are cleared
125
264
  // so no stale start time leaks into the next turn.
@@ -156,6 +295,13 @@ export async function runAgentTurn(ctx, text) {
156
295
  render()
157
296
  }
158
297
 
298
+ // §17 偏差 #1 兜底:释放窗口期 Enter 已入 pendingInput——链条走到此处若池已空
299
+ //(挂起会话不会启动,下方 while 是最后一个消费点)则转正队列照常续发,消息不滞留
300
+ // 不并发。池非空时挂起会话先消费 pendingInput(D-S5 输入优先),无需此处处理。
301
+ if (!skipSession && !state._suspAborted && (state.pendingInput?.length ?? 0) > 0 && !poolLive(agent)) {
302
+ state.queue.push(...state.pendingInput.splice(0).map((t) => ({ text: String(t) })))
303
+ }
304
+
159
305
  // Queued messages: auto-process next one
160
306
  while (state.queue.length > 0 && !state.processing) {
161
307
  const next = state.queue.shift()
@@ -169,4 +315,221 @@ export async function runAgentTurn(ctx, text) {
169
315
  await runAgentTurn(ctx, next.text)
170
316
  return
171
317
  }
318
+
319
+ // §17 D-S2: 回合尾后台池非空 → 挂起会话(D-S9 状态机——输入放开、settle 驱动
320
+ // auto-turn 消化、池空自然退出回 idle)。skipSession:会话内回合(消化轮/会话内
321
+ // 用户回合)由 suspensionSession 统一调度,不再递归进入新会话。
322
+ if (!skipSession && poolLive(agent) && !state._suspAborted) {
323
+ await suspensionSession(ctx)
324
+ // §17 round2 偏差 #1(_suspAborted 粘滞,code review round2 #1):挂起会话退出
325
+ // (自然耗尽或 Ctrl+C 中止)即复位中止标志——中止 unwind 已完成、池已清空/耗尽,
326
+ // 复位不会误触发重入。不清则 _suspPending 守卫与下方会话入口被同一标志永久门控:
327
+ // 中止后用户再 spawn async、回合尾池再 live 时永不再次进入挂起态(suspensionSession
328
+ // 首行复位成死代码)——子代理结果退化到"下个回合尾才注入"、状态行/自动消化/区块
329
+ // 驻留全消失;释放窗口守卫同步失效(Enter 并发开第二个 runAgentTurn——双驱动器
330
+ // 竞态复现)。
331
+ state._suspAborted = false
332
+ }
333
+ }
334
+
335
+ // ─── §17 挂起会话(AGENT-LOOP.md §17 D-S2/D-S9 状态机行表)────────────────
336
+
337
+ /** 后台池存活判据(D-S2/F5 口径):running/queued 子代理,或已 settle 未注入结果
338
+ * (_pendingAsyncResults 非空 = D-S3 "未注入")。回合尾与每次轮末都用它评估退出。 */
339
+ function poolLive(agent) {
340
+ const map = agent._asyncSubagents
341
+ return (map && map.size > 0) || (agent._pendingAsyncResults?.length ?? 0) > 0
342
+ }
343
+
344
+ /** D-S3 ③ 记账清扫:回合边界竞态落下的已 settle 项(settle 回调未及移交——发生在
345
+ * 回合刚结束、_suspended 尚未置位的窗口)补入 pending。幂等:回调已移交的条目已
346
+ * 从 map 删除并带 _inPending 标记,不会重复入列。 */
347
+ function sweepSettledToPending(agent) {
348
+ const map = agent._asyncSubagents
349
+ if (!map || map.size === 0) return
350
+ agent._pendingAsyncResults ??= []
351
+ for (const e of [...map.values()]) {
352
+ if (e.done && !e._inPending) {
353
+ e._inPending = true
354
+ agent._pendingAsyncResults.push(e)
355
+ map.delete(String(e.id))
356
+ }
357
+ }
358
+ }
359
+
360
+ /** 等待下一次 settle(running 子代理 promise 完成)或用户唤醒(Enter 入队 / Ctrl+C /
361
+ * 会话 abort)。唤醒器经 state._suspWake 单槽注入;abort 监听兜底。 */
362
+ function waitForSettleOrWake(agent, state) {
363
+ return new Promise((resolve) => {
364
+ let finished = false
365
+ const cleanup = () => {
366
+ state._suspWake = null
367
+ const i = (agent._asyncWaiters ?? []).indexOf(w)
368
+ if (i >= 0) agent._asyncWaiters.splice(i, 1)
369
+ agent._sessionAbort?.signal.removeEventListener("abort", onAbort)
370
+ }
371
+ const finish = (why) => {
372
+ if (finished) return
373
+ finished = true
374
+ cleanup()
375
+ resolve(why)
376
+ }
377
+ const w = () => finish("settle")
378
+ const wake = () => finish("wake")
379
+ const onAbort = () => finish("aborted")
380
+ ;(agent._asyncWaiters ??= []).push(w)
381
+ state._suspWake = wake
382
+ if (agent._sessionAbort?.signal.aborted) { onAbort(); return }
383
+ agent._sessionAbort?.signal.addEventListener("abort", onAbort, { once: true })
384
+ })
385
+ }
386
+
387
+ /** 后台模式状态行文本(D-S8;17.5.4 #6 顺手对齐):"后台 N 子代理运行中 · M 完成待消化"
388
+ * ——"运行中" = running + queued;"完成待消化" = pending 移交项 + §17.5 回合尾留池的
389
+ * settled 未消费项(挂起会话 sweep 前的可见窗口)。 */
390
+ function backgroundStatusText(agent) {
391
+ const map = agent._asyncSubagents
392
+ const running = map ? [...map.values()].filter((e) => e.status === "running").length : 0
393
+ const queued = agent._asyncQueue?.length ?? 0
394
+ const pending = agent._pendingAsyncResults?.length ?? 0
395
+ const doneInPool = map ? [...map.values()].filter((e) => e.done).length : 0 // §17.5 留池未消费
396
+ const awaiting = pending + doneInPool
397
+ const active = running + queued
398
+ return active > 0 || awaiting > 0
399
+ ? `后台 ${active} 子代理运行中${awaiting ? ` · ${awaiting} 完成待消化` : ""}`
400
+ : "后台子代理收尾…"
401
+ }
402
+
403
+ /** 消化轮:系统驱动的 auto-turn(D-S6)。手动档不传权限/问答 handler(D-S7 装配
404
+ * 契约——denied 不弹面板、不悬挂);AUTO 档沿用普通回调(autoApprove 短路自动
405
+ * 执行)。_suspended 保持 true:消化中 settle 延迟冻结 + 移交 pending。 */
406
+ async function digestTurn(ctx) {
407
+ const { agent, pushLine } = ctx
408
+ const manual = !agent.autoApprove
409
+ pushLine(manual
410
+ ? "[auto-turn: digesting finished subagent reports…]"
411
+ : "[auto-turn: continuing background work…]", C.dim)
412
+ const digestCtx = manual
413
+ ? { ...ctx, askPermission: null, askBatchPermission: null, askQuestion: null }
414
+ : ctx
415
+ // LOGGING:digest:* 事件(D-S9 消化轮边界——LOGGING.md F-L4 挂起态覆盖)
416
+ const d0 = Date.now()
417
+ const pend0 = agent?._pendingAsyncResults?.length ?? 0
418
+ logEvent("digest:start", { pendingN: pend0 })
419
+ await runAgentTurn(digestCtx, "", { autoTurn: true, skipSession: true })
420
+ logEvent("digest:end", { pendingN: agent?._pendingAsyncResults?.length ?? 0, ms: Date.now() - d0 })
421
+ }
422
+
423
+ /**
424
+ * §17 挂起会话驱动(D-S9 行表;由 runAgentTurn 回合尾进入,池空自然退出):
425
+ * - suspension:池项 settle → 入 pending → 开 auto-turn(合并消化近邻 settle);
426
+ * 用户 Enter → pendingInput(digest 运行中排队,D-S5)——用户输入优先于 digest;
427
+ * - auto-turn:消化中 settle 不并发开新轮(单 runAgent 循环),轮末按 pending/池态
428
+ * 续开合并消化轮或回挂起;pendingInput 非空 → 以该消息开新回合(不触发新 digest);
429
+ * - §17.5.5:每次消化/会话内用户回合消费 pending 后 → freezeReclaimDigestedBlocks
430
+ * 逐条冻结回收(消化完成块不滞留面板——不等池空;settle 锚点 splice——digest 总览
431
+ * 文本之前——round1 #1 裁定);
432
+ * - 退出:池空 + pending 空 + 无待处理输入 → freezeAllSubTasks 补发冻结(仅兜底
433
+ * 未消化残项——17.5.5 块回收与池空解耦)→ idle。
434
+ * _suspended 翻转:会话期 true(settle 回调据此延迟冻结 + 移交 pending);会话内
435
+ * 用户回合执行期翻 false(普通回合语义:settle 即冻结 + 回合尾直注入 ①)。
436
+ */
437
+ async function suspensionSession(ctx) {
438
+ const { agent, state, render, pushLine } = ctx
439
+ state.pendingInput ??= []
440
+ state._suspAborted = false
441
+ agent._suspended = true
442
+ agent._sessionSignal = agent._sessionAbort.signal // 会话内 spawn 的 children 共享(subagent.mjs)
443
+ state.suspended = true
444
+ state._suspPending = false // 偏差 #1:进入真正挂起态——标志只在释放窗口期有效(此后由 state.suspended 分流)
445
+ const suspTick = setInterval(() => {
446
+ if (state.suspended && !state.processing) {
447
+ state.status = backgroundStatusText(agent)
448
+ render()
449
+ }
450
+ }, 1000)
451
+ state.status = backgroundStatusText(agent)
452
+ render()
453
+ // LOGGING:susp:* 事件(挂起态进入/退出——F-L4;挂起期输入事件 v1 不记——refinement #1)
454
+ const s0 = Date.now()
455
+ logEvent("susp:enter", poolCounts(agent))
456
+ try {
457
+ while (!state._suspAborted && !agent._sessionAbort.signal.aborted) {
458
+ sweepSettledToPending(agent)
459
+ // 1. 用户输入优先(D-S5):pendingInput 队列 + 消化期排队的 slash 命令
460
+ const queuedText = state.pendingInput.length > 0 ? state.pendingInput.shift()
461
+ : state.queue.length > 0 ? state.queue.shift().text : null
462
+ if (queuedText) {
463
+ if (String(queuedText).startsWith("/")) {
464
+ await ctx.handleSlash?.(queuedText)
465
+ render()
466
+ continue
467
+ }
468
+ agent._suspended = false // 用户回合 = 普通回合语义(① 直注入 + settle 即冻结)
469
+ await runAgentTurn(ctx, String(queuedText), { skipSession: true })
470
+ agent._suspended = true
471
+ // §17.5.5:该回合消化完 pending(run 首行注入)→ 逐条冻结回收驻留块
472
+ // (不等池空——settle 锚点 splice——digest 总览文本之前;与 digest 回收同规则)
473
+ freezeReclaimDigestedBlocks(state, agent._pendingAsyncResults ?? [])
474
+ state.status = backgroundStatusText(agent)
475
+ continue
476
+ }
477
+ // 2. pending 非空 → 合并消化轮(注入由 runAgent 首行统一完成——D-S3 单注入点)
478
+ if ((agent._pendingAsyncResults?.length ?? 0) > 0) {
479
+ await digestTurn(ctx)
480
+ // §17.5.5 实测修订(2026-09-03):digest 消化完成(pending 条目已注入)→ 逐条补发
481
+ // done 冻结回收——不等池空——块从面板移除进流(settle 锚点 splice 落位——digest
482
+ // 总览文本之前——round1 #1 裁定);池空 freeze-out 仅兜底未消化残项(挂起会话
483
+ // 结束统一清场)——块回收与池空解耦(T-H7/AC-H5)。
484
+ // 归属不变式:会话内任何 run 开始前 pinned 块(awaitingDigest)的条目必在 pending
485
+ // ——run 消费后不在 pending 的 pinned 块即本 run 消化者(无需快照即精确归属)。
486
+ freezeReclaimDigestedBlocks(state, agent._pendingAsyncResults ?? [])
487
+ state.status = backgroundStatusText(agent)
488
+ continue
489
+ }
490
+ // 3. 池空(无 running/queued/未注入)→ 自然退出回 idle(补发冻结在 finally)
491
+ if (!poolLive(agent)) break
492
+ // 4. 等下一 settle / 用户唤醒(Enter 入队、Ctrl+C)
493
+ await waitForSettleOrWake(agent, state)
494
+ }
495
+ } finally {
496
+ clearInterval(suspTick)
497
+ const aborted = state._suspAborted || agent._sessionAbort.signal.aborted
498
+ if (aborted && (agent._asyncSubagents?.size ?? 0) > 0) logEvent("ev:stopped", { poolN: agent._asyncSubagents?.size ?? 0, where: "suspension-abort" })
499
+ logEvent("susp:exit", { ...poolCounts(agent), ms: Date.now() - s0, reason: aborted ? "aborted" : "idle" })
500
+ agent._suspended = false
501
+ agent._sessionSignal = null
502
+ agent._sessionAbort = null
503
+ agent._sessionAbortAll = null // 偏差 #3:会话期 controller 集合随句柄一并释放
504
+ state.suspended = false
505
+ state._suspWake = null
506
+ state.suspAbortArmed = false // round2 偏差 #4:会话退出即解除挂起中止武装(防跨会话粘滞)
507
+ if (aborted) {
508
+ // §15 abort 语义:清池不注入(用户显式停——不注入陈旧错误)
509
+ agent._asyncSubagents?.clear()
510
+ agent._asyncQueue = []
511
+ agent._pendingAsyncResults = []
512
+ // §17 round2 偏差 #2-CLI(code review round2 #2-CLI):中止时不静默丢弃挂起期
513
+ // 排队的用户消息——Enter 已清空输入框并入 pendingInput(用户视为已发送),
514
+ // 残余转回 state.queue({text} 条目,下个普通回合的队列循环续发——零丢失)
515
+ // + 提示行明示去向(不静默丢)。
516
+ const queuedN = state.pendingInput?.length ?? 0
517
+ if (queuedN > 0) {
518
+ state.queue.push(...state.pendingInput.splice(0).map((t) => ({ text: String(t) })))
519
+ pushLine(`[background work stopped — ${queuedN} queued message${queuedN > 1 ? "s" : ""} will run as a normal turn]`, C.warn)
520
+ }
521
+ } else {
522
+ // D-S3 ③ 兜底:退出前残余(极端竞态)直注入再退——结果零丢失(AC-S2)
523
+ const residual = agent._pendingAsyncResults
524
+ if (residual?.length) {
525
+ const { injectAsyncResult } = await import("../agent-tools/subagent.mjs")
526
+ for (const e of residual.splice(0)) await injectAsyncResult(agent, e)
527
+ }
528
+ }
529
+ // 补发 done 冻结:驻留面板的 awaiting-digest 块随池空冻结进流(T-S14)
530
+ freezeAllSubTasks(state)
531
+ sweepToolBlocks(state)
532
+ state.status = "Ready"
533
+ render()
534
+ }
172
535
  }
@@ -71,17 +71,28 @@ function spawnWait(spawn, cmd, args, stdinText) {
71
71
  }
72
72
 
73
73
  /** Insert pasted text into the active text target.
74
- * Free-text question active → append to its answer (single-line field: newlines stripped).
74
+ * Free-text question active → splice into its answer at cursor (codepoint array; \r\n runs
75
+ * collapse to spaces — single-line field invariant, text preserved).
75
76
  * Options question active → ignore (no text field; must not leak into the input box).
76
77
  * Otherwise → splice into the main input box at cursor (newlines kept, tabs → 2 spaces).
77
78
  * Shared by bracketed paste (stdin data handler) and Ctrl+V clipboard read,
78
- * so pasted content lands in the same place regardless of how the terminal delivered it. */
79
- export function insertPastedText(state, rawText) {
79
+ * so pasted content lands in the same place regardless of how the terminal delivered it.
80
+ * activeQuestion(异步 Ctrl+V 路径传发起时的 q):目标 question 已关闭/被换(Esc 中止后
81
+ * readClipboardText 才 resolve)→ 整段丢弃——不得落主输入框(审计 F1 竞态守卫)。 */
82
+ export function insertPastedText(state, rawText, activeQuestion = null) {
80
83
  if (!rawText) return
84
+ if (activeQuestion !== null && state.question !== activeQuestion) return // stale async paste(Esc/Enter 后到达)
81
85
  const q = state.question
82
86
  if (q) {
83
87
  if (q.options.length > 0) return
84
- q.answer = (q.answer ?? "") + rawText.replace(/[\r\n]+/g, "")
88
+ // 自由文本态(TUI-INPUT-BOX.md §7.2 round2 #1/#2/#9):answer codepoint 数组——
89
+ // 粘贴落 cursor 位置;\r\n/\r 折叠为空格、\t → 2 空格(单行不变式硬守卫:不劈行、
90
+ // 不吞文本——粘贴文本保字)。options 态无文本字段——不落输入框。
91
+ if (!Array.isArray(q.answer)) q.answer = q.answer ? [...q.answer] : []
92
+ const cur = Math.max(0, Math.min(q.cursor ?? q.answer.length, q.answer.length))
93
+ const chars = [...rawText.replace(/\r\n/g, "\n").replace(/\r/g, "\n").replace(/\n+/g, " ").replace(/\t/g, " ")]
94
+ q.answer.splice(cur, 0, ...chars)
95
+ q.cursor = cur + chars.length
85
96
  return
86
97
  }
87
98
  // IKBU3J (2026-08-28):Ctrl+I 注入框激活时,粘贴进注入文本(与按键路径同语义:去换行保单行),
@@ -102,7 +113,7 @@ export function insertPastedText(state, rawText) {
102
113
  * Terminals without enhancement send a bare \r for Shift+Enter — nothing to translate
103
114
  * (degrades to a normal submit; Alt+Enter remains the fallback). */
104
115
  export function translateShiftEnter(text) {
105
- // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
116
+ // 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
106
117
  return text.replace(/\x1b\[13;2u/g, "\x1b\r").replace(/\x1b\[27;2;13~/g, "\x1b\r")
107
118
  }
108
119
 
@@ -111,7 +122,7 @@ export function translateShiftEnter(text) {
111
122
  * modifyOtherKeys: \x1b[27;mod;key~ — function keys
112
123
  * Call AFTER translateShiftEnter (which already handles Shift+Enter). */
113
124
  export function stripKeyboardProtocol(text) {
114
- // eslint-disable-next-line no-control-regex -- 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
125
+ // 有意为之:控制字符协议/转义序列剥离正则(ANSI/⟦ev⟧/SGR/history 双线分隔)
115
126
  return text.replace(/\x1b\[\d+;\d+u/g, "").replace(/\x1b\[27;\d+;\d+~/g, "")
116
127
  }
117
128