thincoder 0.12.66 → 0.12.68

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 (69) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/README.md +23 -5
  3. package/bin/thincoder.mjs +43 -331
  4. package/package.json +2 -2
  5. package/src/acp/bridge.mjs +13 -12
  6. package/src/acp/handlers-session.mjs +12 -1
  7. package/src/cli/make-agent.mjs +44 -119
  8. package/src/cli/memory-command.mjs +45 -3
  9. package/src/cli/permission.mjs +1 -1
  10. package/src/command-interactive.mjs +186 -0
  11. package/src/command-table.mjs +185 -0
  12. package/src/completions.mjs +36 -12
  13. package/src/crash-reports.mjs +42 -20
  14. package/src/distill.mjs +3 -3
  15. package/src/heap-watch.mjs +5 -14
  16. package/src/prompt-injections.mjs +1 -1
  17. package/src/tui/agent-turn.mjs +114 -69
  18. package/src/tui/clipboard.mjs +74 -30
  19. package/src/tui/cmd-advisor.mjs +54 -13
  20. package/src/tui/cmd-clear.mjs +2 -2
  21. package/src/tui/cmd-config.mjs +26 -2
  22. package/src/tui/cmd-eng.mjs +1 -1
  23. package/src/tui/cmd-mcp.mjs +1 -1
  24. package/src/tui/cmd-new.mjs +2 -2
  25. package/src/tui/cmd-reindex.mjs +1 -1
  26. package/src/tui/cmd-session.mjs +13 -5
  27. package/src/tui/cmd-think.mjs +35 -17
  28. package/src/tui/cmd-timers.mjs +33 -0
  29. package/src/tui/conversation-writer.mjs +2 -2
  30. package/src/tui/display-budget.mjs +9 -9
  31. package/src/tui/fold-block.mjs +1 -1
  32. package/src/tui/index.mjs +23 -5
  33. package/src/tui/interaction.mjs +5 -3
  34. package/src/tui/key-handler-busy.mjs +17 -12
  35. package/src/tui/key-handler-ctrlc.mjs +5 -5
  36. package/src/tui/key-handler-edit.mjs +12 -8
  37. package/src/tui/key-handler-search.mjs +1 -1
  38. package/src/tui/key-handler.mjs +2 -2
  39. package/src/tui/key-modes.mjs +1 -1
  40. package/src/tui/layout.mjs +5 -5
  41. package/src/tui/ledger-surface.mjs +26 -5
  42. package/src/tui/lifecycle-records.mjs +199 -0
  43. package/src/tui/model-picker.mjs +16 -199
  44. package/src/tui/mouse.mjs +5 -5
  45. package/src/tui/pickers.mjs +6 -2
  46. package/src/tui/provider-admin.mjs +213 -0
  47. package/src/tui/queued-merge.mjs +12 -0
  48. package/src/tui/queued-pickup.mjs +34 -0
  49. package/src/tui/render-conversation.mjs +49 -4
  50. package/src/tui/render-frame.mjs +54 -11
  51. package/src/tui/render-loop.mjs +2 -3
  52. package/src/tui/render-segments.mjs +4 -4
  53. package/src/tui/render.mjs +1 -1
  54. package/src/tui/slash-commands.mjs +3 -0
  55. package/src/tui/startup.mjs +28 -3
  56. package/src/tui/subagent-blocks.mjs +37 -29
  57. package/src/tui/subagent-children.mjs +5 -5
  58. package/src/tui/subagent-freeze.mjs +43 -41
  59. package/src/tui/subagent-panel.mjs +11 -11
  60. package/src/tui/suspension-drive.mjs +173 -253
  61. package/src/tui/timer-watch.mjs +64 -0
  62. package/src/tui/tool-args.mjs +10 -71
  63. package/src/tui/tool-display.mjs +7 -7
  64. package/src/tui/tool-events.mjs +40 -35
  65. package/src/tui/tui-lifecycle.mjs +13 -4
  66. package/src/tui/tui-state.mjs +3 -2
  67. package/src/tui/turn-face.mjs +6 -3
  68. package/src/tui/wizard.mjs +7 -2
  69. package/src/tui/wrapped-spawn.mjs +3 -3
@@ -8,41 +8,48 @@
8
8
  * (routeSub* / finishSubTask / freeze*SubTasks)在 subagent-blocks.mjs;标题生成
9
9
  * 在 generate-title.mjs ensureSessionTitle。
10
10
  *
11
- * 2026-09-05 module-split:§17 挂起会话段(suspensionSession/digestTurn/poolLive/
11
+ * 2026-09-05 module-split:AGENT-LOOP-ASYNC-POOL.md §6.8 挂起会话段(suspensionSession/digestTurn/poolLive/
12
12
  * poolCounts 等——agent-turn.mjs 535 > 500 硬限)verbatim 迁至 suspension-drive.mjs——
13
13
  * 本文件回合尾经 suspensionSession 进入驱动器;驱动器内 digest/用户回合经 runAgentTurn
14
14
  * 递归回本文件(函数级静态环——模块求值期无顶层调用,环安全——session-slots ↔
15
15
  * session.mjs 同款先例)。
16
16
  */
17
- import { runAgent, ContinueError } from "@thincoder/core/agent.mjs"
17
+ import { runAgent } from "@thincoder/core/agent.mjs"
18
18
  import { saveSession } from "@thincoder/core/session.mjs"
19
+ import { continueDecision } from "@thincoder/core/agent/continue-decision.mjs" // #677 · I10 续跑判定单源(#127 ④ 双写退役)
19
20
  import { ansi, C } from "./ansi.mjs"
20
21
  import { buildToolCallbacks, sweepToolBlocks } from "./tool-events.mjs"
21
- import { freezeAllSubTasks } from "./subagent-blocks.mjs" // freezeReclaimDigestedBlocks 随 §17 段迁 suspension-drive.mjs
22
+ import { freezeAllSubTasks } from "./subagent-blocks.mjs" // freezeReclaimDigestedBlocks 随 AGENT-LOOP-ASYNC-POOL.md §6.8 段迁 suspension-drive.mjs
22
23
  import { ensureSessionTitle } from "@thincoder/core/generate-title.mjs"
23
24
  import { logEvent, errText } from "@thincoder/core/log.mjs"
24
- import { t } from "@thincoder/core/i18n.mjs"
25
+ import { pushRecord } from "@thincoder/core/context.mjs"
25
26
  import { suspensionSession, poolLive } from "./suspension-drive.mjs"
27
+ // #726 写点②:cap 行 ∥ cap 记录同点双动作(文案/形 = lifecycle-records 单一实现)
28
+ import { digestCapRecord, digestTraceLines, recordCarrier } from "./lifecycle-records.mjs"
29
+ import { timerWakeArmed } from "./timer-watch.mjs" // §6.30.3 门三件③:在途 timer ∧ 开关开 ⇒ 唤醒会武装
30
+ import { planQueuedInput } from "./queued-merge.mjs"
31
+ import { pickupQueuedAtStepBoundary } from "./queued-pickup.mjs"
26
32
 
27
33
  /** Exit-flush bound for the async end-of-run distillation (SEND-STALL-DISTILL §2.5):
28
34
  * wait at most this long for the in-flight distill before the final session save —
29
35
  * never let shutdown hang on the background summary call. */
30
36
  const DISTILL_FLUSH_TIMEOUT_MS = 5000
31
37
 
32
- /** Provider 失败面 URL 脱敏(X8——判据与 VSC `panel-turn-loop.mjs:153` 同式;两端各自持有
33
- * 字面 = 双写登记——单源化须动 VSC/核写域,出本批射程,见批档 §2.3)。 */
38
+ /** Provider 失败面 URL 脱敏(X8——判据与 VSC `panel-turn-loop.mjs` 的 `e.message` 首行脱敏处同式;两端各自持有
39
+ * 字面 = 双写登记——单源化须动 VSC/核写域,出本批射程)。 */
34
40
  const PROVIDER_URL_RE = /https?:\/\/[^\s,)"]+/g
35
41
 
36
42
  /**
37
43
  * 第 33 批(TUI §14.3(d)——纯函数,供测试直驱):回合链尾「需要用户」谓词——agent 已停
38
44
  * 且无自动续跑 ⇒ 置 attention 位。排除项语义(F13):`skipSession`(digest / 会话内回合
39
45
  * ——由外层链尾统一置位)· 挂起两态 / 池 live / 队列非空(自动续跑中——不由用户接手)·
40
- * processing(回合在跑)。
46
+ * processing(回合在跑)· **在途 timer ∧ 唤醒会武装**(`docs/cli/design/TUI.md` §7.1 awaiting
47
+ * 行除外 / §7.2 置位谓词——自动续跑在途 ⇒ 不计 awaiting;机判 = T-TW13;timer-wake 批 2026-09-27)。
41
48
  * @returns {boolean}
42
49
  */
43
50
  export function userNeededAtTurnEnd(state, agent, skipSession) {
44
51
  return !skipSession && !state.suspended && !state._suspPending && !poolLive(agent)
45
- && state.queue.length === 0 && !state.processing
52
+ && state.queue.length === 0 && !state.processing && !timerWakeArmed(agent)
46
53
  }
47
54
 
48
55
  /**
@@ -73,14 +80,40 @@ export async function runAgentTurn(ctx, text, opts = {}) {
73
80
  }
74
81
  }
75
82
 
76
- /** runAgentTurn 本体(LOGGING 包装之外——见上方包装器)。 */
83
+ /** #448② 重武装守卫(KD-6「异常径重武装——会话停 ∕ 显式撤销除外」):**粘滞位 `_timerRearmRevoked`**
84
+ * = 主线(两实落点:Ctrl+C 全停 `{abortTrigger:"stop"}` 中止分支 ∕ 挂起会话中止(`_suspAborted`)复位前捕获;
85
+ * 链头复位 ⇒ 下一链正常重入)。余两腿为**防御面**(仅在正文异常早退、控制器尚未置空时可达):
86
+ * `_suspAborted` 未及复位 ∥ 本链控制器 stop 语义在场 ∥ 会话 abort 句柄已中止。 */
87
+ function timerRearmBlocked(state, agent) {
88
+ return state?._timerRearmRevoked === true
89
+ || state?._suspAborted === true
90
+ || state?.controller?.signal?.reason?.abortTrigger === "stop"
91
+ || agent?._sessionAbort?.signal?.aborted === true
92
+ }
93
+
94
+ /** runAgentTurn 本体(LOGGING 包装之外——见上方包装器):**链尾收口层**——正文(`runAgentTurnBody`)
95
+ * 之后收「链尾闩重同步」(§6.30.5 载体②武装点——履行原正文内同点调用;#448②:入 `finally` ⇒
96
+ * 异常 ∕ 中止逃逸的回合同样重武装;守卫 = `timerRearmBlocked`)。attention 仍在正文链尾原位
97
+ * (谓词读 `_pendingTimers`、不读闩状态 ⇒ 与 sync 无相对序依赖)。 */
77
98
  async function runAgentTurnInner(ctx, text, opts) {
78
- const { autoTurn = false, skipSession = false, upstreamTurn = false } = opts ?? {} // upstreamTurn:上行 ask 唤醒轮旗标(§6.27.12.5 D/I——透传核 runAgent,仅供域文本选择)
99
+ try {
100
+ await runAgentTurnBody(ctx, text, opts)
101
+ } finally {
102
+ if (!timerRearmBlocked(ctx?.state, ctx?.agent)) ctx?.timerWatch?.sync()
103
+ }
104
+ }
105
+
106
+ /** runAgentTurn 正文(原 `runAgentTurnInner` 逐字——唯一改动 = 链尾 `sync()` 上收收口层 `finally`)。 */
107
+ async function runAgentTurnBody(ctx, text, opts) {
108
+ const { autoTurn = false, skipSession = false, upstreamTurn = false, timerTurn = false } = opts ?? {} // upstreamTurn / timerTurn:系统轮旗标(§6.27.12.5 D/I · §6.30.3 D-TW4)——透传核 `runAgent`,仅供域文本选择
79
109
  const { agent, state, pushLine, pushLabel, render, scheduleRender, ensureAssistantLabel, askPermission, askBatchPermission, askQuestion, handleSlash } = ctx
80
110
  // 可注入覆盖(测试用);默认走真实实现
81
111
  const runAgentImpl = ctx.runAgent ?? runAgent
82
112
  const saveSessionImpl = ctx.saveSession ?? saveSession
83
- // autoTurn(消化轮):无用户输入——不画 "❯ You:"(系统驱动回合,§17 D-S6)
113
+ // F16 步边界 pickup(queue-visible 批 fix 轮 2026-09-24——TUI.md §7.5 消费时机①):用户回合在飞 ⇒
114
+ // 核循环头投递回调(不中断——下一步生效,参照系 = 子代理 `send`);系统轮不传(分流——两者轮不参与)
115
+ const consumeQueuedInput = autoTurn ? null : () => pickupQueuedAtStepBoundary(ctx)
116
+ // autoTurn(消化轮):无用户输入——不画 "❯ You:"(系统驱动回合,AGENT-LOOP-ASYNC-POOL.md §6.8 D-S6)
84
117
  if (!autoTurn) {
85
118
  pushLabel(`❯ You:`, ansi.bold + C.user)
86
119
  pushLine(text, C.text)
@@ -89,7 +122,7 @@ async function runAgentTurnInner(ctx, text, opts) {
89
122
  ctx.assistantLabeled = false
90
123
  state.processing = true
91
124
  state.status = "Processing..."
92
- // §17.6(advisor round2 🟡):回合启动即解除空闲退出武装——exitArmed 只属于空闲态
125
+ // AGENT-LOOP-ASYNC-POOL.md §6.8(advisor round2 🟡):回合启动即解除空闲退出武装——exitArmed 只属于空闲态
93
126
  // 双确认,跨回合残留会让"停回合后的 armed 落空穿透 + 陈旧 exitArmed"组合把一次
94
127
  // 意图为全停/退出的按下变成无二次确认的即时退出(key-handler 落空分支落回空闲
95
128
  // 分支时 `!state.exitArmed` 判假即 exit);计时一并清(key-handler 空闲分支按
@@ -102,17 +135,20 @@ async function runAgentTurnInner(ctx, text, opts) {
102
135
  state.streaming = ""
103
136
  state.reasoning = ""
104
137
  state._advisorBlocks = []
105
- // NOTE (§7.2 D4): state.subTasks is intentionally NOT reset here — subagent
138
+ // NOTE (docs/cli/design/TUI.md §6.8 D4): state.subTasks is intentionally NOT reset here — subagent
106
139
  // activity blocks persist across turns (the user can still expand a finished
107
140
  // child's block from a previous turn). Child tool calls never enter the parent
108
141
  // history, so the blocks are the only trace of child activity; memory is
109
142
  // bounded by the N2 per-child 500-line ring buffer.
110
143
  state.currentTool = null
111
144
  state.processingStarted = Date.now()
112
- // §17 偏差修复 #3(会话 abort 全覆盖):回合链 controller 登记。链头 = 非挂起会话内
145
+ // 停滞轻显形(docs/cli/design/TUI.md §7.7 · stall-indicator 批 2026-09-29):静默起算初始锚 =
146
+ // 回合起刻(此后三类可见输出重置——流式 ∕ 工具面 ∕ 子代理面,见 tool-events ∕ subagent-blocks)。
147
+ state.lastOutputAt = Date.now()
148
+ // AGENT-LOOP-ASYNC-POOL.md §6.8 偏差修复 #3(会话 abort 全覆盖):回合链 controller 登记。链头 = 非挂起会话内
113
149
  // 且非释放窗口期(suspended/_suspPending 均 false)开启的回合——登记表清零;队列
114
150
  // 递归回合(_suspPending 置位期)与会话内回合(suspended=true)继续累积。链条内每次
115
- // 重建(Ctrl+I 续跑 / ContinueError 续跑 / AUTO 续跑)都登记——挂起会话的 abort 集合
151
+ // 重建(Ctrl+I 中断续跑 ∕ 撞帽续跑——核 `continueDecision` 两续跑格)都登记——挂起会话的 abort 集合
116
152
  // (_sessionAbortAll)必须在会话建立时覆盖进入会话以来的全部 controller:只 abort
117
153
  // 最后一个会让旧 controller 下 spawn 的 async children 逃逸中止(偏差 #3)。
118
154
  // 链头同时清掉上一链条残留的会话句柄(上一链 pool 先 live 后耗尽、未进入会话即结束
@@ -122,6 +158,9 @@ async function runAgentTurnInner(ctx, text, opts) {
122
158
  state._turnControllers = []
123
159
  agent._sessionAbort = null
124
160
  agent._sessionAbortAll = null
161
+ // 链头复位「重武装撤销」粘滞位(#448② KD-6——上一链的显式停止只约束到下一链头;新链
162
+ // 正常重入 ⇒ 链尾照常重武装)。会话内回合不在此列(suspended)——链条内标志始终有效。
163
+ state._timerRearmRevoked = false
125
164
  }
126
165
  state._turnControllers ??= []
127
166
  const makeController = () => {
@@ -132,7 +171,7 @@ async function runAgentTurnInner(ctx, text, opts) {
132
171
  state.controller = makeController()
133
172
  state.interruptPrompt = null
134
173
  // Refresh status bar every second during processing; also refresh when any
135
- // subagent block is still running so its header elapsed ticks (§7.2 D4 —
174
+ // subagent block is still running so its header elapsed ticks (docs/cli/design/TUI.md §6.8 D4 —
136
175
  // no new timer, the existing ticker carries it). Blocks stay visible after
137
176
  // the turn ends, but frozen headers don't need 1s refreshes.
138
177
  const subRunning = () => Object.values(state.subTasks ?? {}).some((s) => !s.done)
@@ -151,55 +190,53 @@ async function runAgentTurnInner(ctx, text, opts) {
151
190
  try {
152
191
  for (let resume = false; ; resume = true) {
153
192
  try {
154
- await runAgentImpl(agent, text, callbacks, { signal: state.controller.signal, resume, autoTurn, upstreamTurn, suspDriven: true })
193
+ await runAgentImpl(agent, text, callbacks, { signal: state.controller.signal, resume, autoTurn, upstreamTurn, timerTurn, suspDriven: true, consumeQueuedInput })
155
194
  flushStream()
156
195
  break // Normal completion, exit loop
157
196
  } catch (error) {
158
197
  flushStream()
159
- if (error.name === "AbortError" || state.controller?.signal.aborted) {
160
- const reason = state.controller?.signal?.reason
161
- // §17.6 D-C1(round1 #1 区分机制——2026-09-03):interrupt 两种语义——
162
- // 有 message(Ctrl+I 注入——key-modes handleInterruptMode)= 重建 controller
163
- // 续跑(既有语义——agent loop 已把消息注入 history,中止的 signal 不能重试);
164
- // 无 message(Ctrl+C 首按停回合——key-handler abort({ interrupt: true })
165
- // 不带 message——非挂起 processing 态 / 挂起态 digest·会话内回合)
166
- // = 停回合不续跑——池保留由 agent.mjs 回合收尾的 !interrupt 清池条件排除
167
- // 实现(D-C2——agent.mjs 零改动)。
168
- if (reason?.interrupt && reason?.message) {
169
- state.controller = makeController()
170
- resume = true
171
- continue
172
- }
198
+ // 续跑判定 = 核单源(#677 · I10——continueDecision;本端零第二分支——#127 ④ 双写退役)。
199
+ // reason = 回合 controller 的 `signal.reason`(中止面语义原源——D-C1 两义同式)。
200
+ const reason = state.controller?.signal?.reason
201
+ const decision = continueDecision(error, { autoTurn, autoApprove: agent.autoApprove, reason })
202
+ if (decision === "resume") {
203
+ // AGENT-LOOP-ASYNC-POOL.md §6.8 D-C1(round1 #1 区分机制——2026-09-03):interrupt 有 message
204
+ // (Ctrl+I 注入——key-modes handleInterruptMode)⇒ 重建 controller 续跑(agent loop 已把消息注入
205
+ // history,中止的 signal 不能重试)。
206
+ state.controller = makeController()
207
+ resume = true
208
+ continue
209
+ }
210
+ if (decision === "stop") {
173
211
  if (reason?.interrupt) {
174
- // 无 message interrupt 的注入副作用回滚:agent.mjs 中断三段(chat catch /
175
- // response.interrupted / 工具执行中断)无条件把 "[User interrupt: <msg>]"
176
- // 落 history,对 message 存在性无守卫——无 message 时成为 "[User interrupt:
177
- // undefined]" 垃圾上下文(消息注入语义只属于 Ctrl+I 续跑——停回合无注入
178
- // 消息)。D-C2 agent.mjs 零改动约束下在回合层回滚尾部垃圾(确定性:中断
179
- // 注入恒为最后一条——break 前 history 不再追加)。partial 部分输出不回滚
180
- // ——interrupt 家族语义(§2 Ctrl+I 同款"提交部分输出")——advisor round1
181
- // 🟡 裁定:回滚需区分工具/子代理路径的既有完整消息(history 层不可靠)。
212
+ // 无 message interrupt 的注入副作用回滚(Ctrl+C 首按停回合——key-handler abort({ interrupt: true })
213
+ // 不带 message;agent.mjs 中断三段无条件把 "[User interrupt: <msg>]" 落 history,对 message
214
+ // 存在性无守卫 ⇒ 无 message 时成为 "[User interrupt: undefined]" 垃圾上下文)。D-C2 agent.mjs
215
+ // 零改动约束下在回合层回滚尾部垃圾(确定性:中断注入恒为最后一条——break 前 history 不再追加)。
216
+ // partial 部分输出不回滚——interrupt 家族语义(§2 Ctrl+I 同款"提交部分输出");advisor round1 🟡
217
+ // 裁定:回滚需区分工具/子代理路径的既有完整消息(history 层不可靠)。
182
218
  const h = agent.history
183
219
  while (h?.length > 0 && String(h.at(-1)?.content ?? "") === "[User interrupt: undefined]") h.pop()
184
220
  }
185
221
  pushLine("[stopped]", C.warn)
222
+ // #448② KD-6「显式撤销」:Ctrl+C 全停(`{ abortTrigger: "stop" }`——key-handler 二按语义)
223
+ // ⇒ 落粘滞位:链尾闩不重武装(链头复位后新链恢复——见 `timerRearmBlocked`)。
224
+ if (reason?.abortTrigger === "stop") state._timerRearmRevoked = true
186
225
  if (opts?._logOutcome) opts._logOutcome.result = "stopped"
187
226
  break
188
227
  }
189
- if (error instanceof ContinueError) {
190
- if (autoTurn) {
191
- // §17 digest turn-cap 规则(D-S9 ContinueError 行):无面板——AUTO 档按
192
- // §2 统一规则自动 resume(无人值守授权);手动档静默拒绝(部分消化留在
193
- // 历史,会话回挂起——结果不丢,只是不再烧轮次)。
194
- if (agent.autoApprove) {
195
- pushLine(t("digest.capAuto"), C.dim)
196
- state.controller = makeController()
197
- continue
198
- }
199
- pushLine(t("digest.capStop", { turns: error.turn }), C.warn)
200
- if (opts?._logOutcome) opts._logOutcome.result = "stopped"
201
- break
202
- }
228
+ if (decision === "cap-stop") {
229
+ // TURN-CAP-CONTINUE.md §1 #7 / D-TC15(2026-09-26):digest 无人值守档**不再自续**
230
+ // (原 AUTO 自动 resume 支退役——静默续期 = F8 禁止;无人可答 ⇒ 撞帽即收口)。
231
+ // 部分消化留在历史,会话回挂起——结果不丢,只是不再烧轮次。
232
+ // #726 写点2:cap 可见行 ∥ cap 记录同点双动作(文案 = lifecycle-records 单一实现)。
233
+ const capRec = digestCapRecord(error.turn)
234
+ for (const l of digestTraceLines(capRec)) pushLine(l.text, l.color)
235
+ pushRecord(recordCarrier(state, agent), capRec)
236
+ if (opts?._logOutcome) opts._logOutcome.result = "stopped"
237
+ break
238
+ }
239
+ if (decision === "ask") {
203
240
  pushLabel(`❯ Continue`, ansi.bold + C.warn)
204
241
  pushLine(`Ran ${error.turn} turns (limit ${error.turn}). Continue?`, C.warn)
205
242
  // Pause to ask: reuse permission mechanism
@@ -223,6 +260,7 @@ async function runAgentTurnInner(ctx, text, opts) {
223
260
  state.controller = makeController()
224
261
  continue
225
262
  }
263
+ // decision === "error":其余异常 —— provider 失败面(X8 原语义)——
226
264
  // X8(2026-09-20 端差·显示面消差批):provider 失败面 = 友好首行(判据同 VSC
227
265
  // panel-turn-loop.mjs:152-153:原文首行 + URL 脱敏)+ 诊断两行(Provider/Model)+ Retry
228
266
  // 询问(复用 permission 机制——同意 ⇒ 重建 controller + resume 重入,与 ContinueError
@@ -257,12 +295,12 @@ async function runAgentTurnInner(ctx, text, opts) {
257
295
  clearInterval(ticker)
258
296
  state.processing = false
259
297
  state._advisorBlocks = []
260
- // §17 D-S1/D-S8:回合正常结束且后台池仍 live → 挂起会话:子agent 区块保持 live
261
- // (不冻结——各 settle 事件自行处理),本次回合 controller 交会话层作 abort 句柄
298
+ // AGENT-LOOP-ASYNC-POOL.md §6.8 D-S1/D-S8:回合正常结束且后台池仍 live → 挂起会话:子agent 区块保持 live
299
+ // (不冻结——冻结落消费窗:起跑窗 ∥ reclaim ∥ 退出兜底),本次回合 controller 交会话层作 abort 句柄
262
300
  // (挂起期 Ctrl+C 中止全部后台子代理);池空 / 中断 / 错误 → 现状 freezeAllSubTasks
263
- // (中断态块标 interrupted;正常态块已在 settle 时各自冻结)。
301
+ // (中断态块标 interrupted;正常态块已在消费窗各自冻结)。
264
302
  const willSuspend = poolLive(agent)
265
- // §17.5.5:挂起会话内回合(digest/会话内用户回合——skipSession 且 suspended)的
303
+ // AGENT-LOOP-ASYNC-POOL.md §6.8:挂起会话内回合(digest/会话内用户回合——skipSession 且 suspended)的
266
304
  // 收尾**不冻结驻留块**——已消化(pinned 且条目已注入)块由 suspensionSession 在
267
305
  // run 返回后 freezeReclaimDigestedBlocks 逐条回收(settle 锚点 splice——digest 总览文本
268
306
  // 之前——round1 #1 裁定,不等池空);
@@ -277,7 +315,7 @@ async function runAgentTurnInner(ctx, text, opts) {
277
315
  } else if (!agent._sessionAbort) {
278
316
  agent._sessionAbort = state.controller // 会话 abort 句柄(children 共享此 signal)
279
317
  }
280
- // §17 偏差修复 #1(回合释放窗口守卫):willSuspend 判定后、进入任何 await 之前置位
318
+ // AGENT-LOOP-ASYNC-POOL.md §6.8 偏差修复 #1(回合释放窗口守卫):willSuspend 判定后、进入任何 await 之前置位
281
319
  // 挂起待定标志。suspensionSession 真正启动前还有真实 await(ensureSessionTitle /
282
320
  // distill flush ≤5s / saveSession),其间 processing=false 且 suspended 尚未置位——
283
321
  // 无此标志 key-handler Enter 会走 submit 并发开第二个 runAgentTurn(双驱动器竞态:
@@ -285,7 +323,7 @@ async function runAgentTurnInner(ctx, text, opts) {
285
323
  // 有效区间 = 释放窗口(suspensionSession 启动即清除);会话内回合(skipSession)
286
324
  // 由 state.suspended 覆盖;_suspAborted 时挂起会话不会启动(下方会话入口同条件)。
287
325
  state._suspPending = willSuspend && !skipSession && !state._suspAborted
288
- // §17 偏差修复 #3(会话 abort 全覆盖):abort 集合快照 = 本链条内全部 controller
326
+ // AGENT-LOOP-ASYNC-POOL.md §6.8 偏差修复 #3(会话 abort 全覆盖):abort 集合快照 = 本链条内全部 controller
289
327
  // (含 Ctrl+I/ContinueError 重建的旧 controller——其下 spawn 的 async children 持旧
290
328
  // signal,Ctrl+C 只 abort 最后一个会让它们跑完整个 turn 预算,正常完成后仍
291
329
  // mergeChildMutations 写父 guard 标记,绕过 advisor/verify 门)。会话内回合的 finally
@@ -327,17 +365,19 @@ async function runAgentTurnInner(ctx, text, opts) {
327
365
  render()
328
366
  }
329
367
 
330
- // §17 偏差 #1 兜底(INPUT-LOCK 单槽化——2026-09-09):释放窗口期 Enter 已入
331
- // pendingInput 单槽——链条走到此处若池已空(挂起会话不会启动,下方 while 是最后一个
332
- // 消费点)则转正队列照常续发,消息不滞留不并发。池非空时挂起会话先消费 pendingInput
333
- // (D-S5 输入优先),无需此处处理。单槽语义下残余至多一条。
368
+ // AGENT-LOOP-ASYNC-POOL.md §6.8 偏差 #1 兜底 + queue-visible 批(2026-09-24)收正:末步入队的排队消息(本 run 无后续
369
+ // 步边界 ⇒ 步边界 pickup 触不到)——按合并计划取批转 state.queue,队列 while 续发新回合;
370
+ // 池非空时挂起会话先消费 pendingInput(D-S5 输入优先),无需此处处理。超一批者截批先行
371
+ // (余下留待下批——仍在队列 ⇒ 待发送块可见,零丢失)。
334
372
  if (!skipSession && !state._suspAborted && (state.pendingInput?.length ?? 0) > 0 && !poolLive(agent)) {
335
- state.queue.push({ text: String(state.pendingInput.shift()) })
373
+ const action = planQueuedInput(state.pendingInput)[0]
374
+ state.pendingInput.splice(0, action.count)
375
+ state.queue.push({ text: action.text })
336
376
  }
337
377
 
338
- // 交接消息自动续发(INPUT-LOCK 单消息——2026-09-09):submit 不再排队(busy 提交吞)
339
- // + R15 攒批删——state.queue 只剩残项单消息(释放窗口兜底/挂起中止残余——零丢失
340
- // 承诺)——逐条直发;斜杠命令直接执行(保序);回合后的续发由递归层同循环续取。
378
+ // 交接消息自动续发(queue-visible 批 2026-09-24——R15 攒批恢复):state.queue 条目 = 合并计划
379
+ // 的动作(残项:释放窗口兜底 / 挂起中止残余——零丢失承诺)——逐条直发;斜杠命令直接执行
380
+ // (保序);回合后的续发由递归层同循环续取。
341
381
  while (state.queue.length > 0 && !state.processing) {
342
382
  const head = state.queue.shift()
343
383
  // F16(TUI.md §7.5):消费回执——queued dim 行(消费事实的可见锚;对位 [continuing…])
@@ -352,24 +392,29 @@ async function runAgentTurnInner(ctx, text, opts) {
352
392
  return
353
393
  }
354
394
 
355
- // §17 D-S2: 回合尾后台池非空 → 挂起会话(D-S9 状态机——输入放开、settle 驱动
395
+ // AGENT-LOOP-ASYNC-POOL.md §6.8 D-S2: 回合尾后台池非空 → 挂起会话(D-S9 状态机——输入放开、settle 驱动
356
396
  // auto-turn 消化、池空自然退出回 idle)。skipSession:会话内回合(消化轮/会话内
357
397
  // 用户回合)由 suspensionSession 统一调度,不再递归进入新会话。
358
398
  if (!skipSession && poolLive(agent) && !state._suspAborted) {
359
399
  await suspensionSession(ctx)
360
- // §17 round2 偏差 #1(_suspAborted 粘滞,code review round2 #1):挂起会话退出
400
+ // AGENT-LOOP-ASYNC-POOL.md §6.8 round2 偏差 #1(_suspAborted 粘滞,code review round2 #1):挂起会话退出
361
401
  // (自然耗尽或 Ctrl+C 中止)即复位中止标志——中止 unwind 已完成、池已清空/耗尽,
362
402
  // 复位不会误触发重入。不清则 _suspPending 守卫与下方会话入口被同一标志永久门控:
363
403
  // 中止后用户再 spawn async、回合尾池再 live 时永不再次进入挂起态(suspensionSession
364
404
  // 首行复位成死代码)——子代理结果退化到"下个回合尾才注入"、状态行/自动消化/区块
365
405
  // 驻留全消失;释放窗口守卫同步失效(Enter 并发开第二个 runAgentTurn——双驱动器
366
406
  // 竞态复现)。
407
+ if (state._suspAborted) state._timerRearmRevoked = true // #448② KD-6「会话停」:链尾闩不重武装(粘滞位——复位前捕获)
367
408
  state._suspAborted = false
368
409
  }
369
410
 
411
+ // §6.30.5 载体②武装点(回合链尾「无人自动接手」判据**邻位**):已上收收口层 `finally`
412
+ // (#448② 异常径重武装 —— 见 `runAgentTurnInner`;本处零注册 ∕ 零动作)。
413
+
370
414
  // 第 33 批(TUI §14.3(d)):顶层链尾(队列续发循环与挂起会话退出**之后**)——无人自动
371
415
  // 接手 ⇒ 置 attention 位(渲染层实时派生 blocked/awaiting;用户任意输入清位)。D-AT6:
372
416
  // 不放在回合末 finally——那之后还有队列续发 / 挂起会话(digest 自动消费),不是「需要用户」。
417
+ // timer-wake 批:谓词内新增「在途 timer ∧ 唤醒会武装」除外(§7.1 awaiting 行限定)。
373
418
  if (userNeededAtTurnEnd(state, agent, skipSession)) {
374
419
  state.attentionAwaiting = true
375
420
  render()
@@ -1,3 +1,9 @@
1
+ import { mkdirSync, writeFileSync } from "node:fs"
2
+ import { readFile, stat, unlink } from "node:fs/promises"
3
+ import { tmpdir } from "node:os"
4
+ import { join } from "node:path"
5
+ import { IMAGE_MAX_BYTES, savePastedImages } from "@thincoder/core/attachments.mjs"
6
+ import { cleanupOldToolResults } from "@thincoder/core/agent/helpers.mjs"
1
7
  import { C } from "./ansi.mjs"
2
8
 
3
9
  /** Windows clipboard-read command: force UTF-8 console output so Get-Clipboard's bytes
@@ -132,49 +138,87 @@ export function stripKeyboardProtocol(text) {
132
138
  return text.replace(/\x1b\[\d+;\d+u/g, "").replace(/\x1b\[27;\d+;\d+~/g, "")
133
139
  }
134
140
 
135
- /** Ctrl+V / Alt+V: read clipboard image → write temp file in working directory → insert read_image command into input box.
136
- * Extracted from index.mjs.
137
- * ctx: { agent, state, pushLine, render } */
138
- export async function pasteClipboardImage(ctx) {
139
- const { agent, state, pushLine, render } = ctx
141
+ /** 平台剪贴板图像捕获(缺省实现——写 staging ∥ 无图抛):Windows = PowerShell
142
+ * System.Windows.Forms(无图 ⇒ exit 1 ⇒ 抛);macOS = osascript PNGf;其余 = xclip → wl-paste 回退。
143
+ * 截图直接写 `dest`(系统临时区 staging——不入项目树)。 */
144
+ async function captureClipboardImage(dest) {
140
145
  const { execFile } = await import("node:child_process")
141
- const { stat, unlink } = await import("node:fs/promises")
142
- const { join } = await import("node:path")
143
-
144
146
  const run = (cmd, args) => new Promise((resolve, reject) => {
145
147
  execFile(cmd, args, { timeout: 10000 }, (err, stdout) => { if (err) reject(err); else resolve(stdout) })
146
148
  })
147
-
148
- const dest = join(agent.cwd, `.thincoder-paste-${Date.now()}.png`)
149
149
  const isWin = process.platform === "win32"
150
150
  const isMac = process.platform === "darwin"
151
+ if (isWin) {
152
+ const psScript = `Add-Type -AssemblyName System.Windows.Forms; if ([System.Windows.Forms.Clipboard]::ContainsImage()) { [System.Windows.Forms.Clipboard]::GetImage().Save('${dest.replace(/\\/g, "\\\\")}', [System.Drawing.Imaging.ImageFormat]::Png); exit 0 } else { exit 1 }`
153
+ await run("powershell", ["-NoProfile", "-Command", psScript])
154
+ } else if (isMac) {
155
+ const script = `try; set f to (POSIX file "${dest}"); set img to the clipboard as «class PNGf»; set fd to open for access f with write permission; write img to fd; close access fd; end try`
156
+ await run("osascript", ["-e", script])
157
+ } else {
158
+ await run("bash", ["-c", `xclip -selection clipboard -t image/png -o > "${dest}" 2>/dev/null || { which wl-paste >/dev/null 2>&1 && wl-paste -t image/png > "${dest}" 2>/dev/null; } || exit 1`])
159
+ }
160
+ }
151
161
 
162
+ /** 贴图 buffer ⇒ 核正路落盘(**唯一写盘者 = 核 `savePastedImages`**):写时扫除
163
+ * (`cleanupOldToolResults(<cwd>/.thincoder/tmp)`——3 天窗 mtime 回收;扫除失败不影响落盘)⇒
164
+ * 构 dataURL ⇒ 调核 `savePastedImages(dataUrls, cwd, { fs })` ⇒ 路径 ∥ null(核弃项 ∥ 写败)。
165
+ * @returns {Promise<string|null>} `<cwd>/.thincoder/tmp/paste-<id>-0.png` 绝对路径 / null */
166
+ export async function savePastedImageBuffer(buffer, cwd) {
167
+ const tmpDir = join(cwd, ".thincoder", "tmp")
168
+ await cleanupOldToolResults(tmpDir)
169
+ const dataUrl = `data:image/png;base64,${buffer.toString("base64")}`
170
+ // 契约「路径 ∥ null」——核内异常(未接线等)归「写败」径,不越出本函数
152
171
  try {
153
- if (isWin) {
154
- const psScript = `Add-Type -AssemblyName System.Windows.Forms; if ([System.Windows.Forms.Clipboard]::ContainsImage()) { [System.Windows.Forms.Clipboard]::GetImage().Save('${dest.replace(/\\/g, "\\\\")}', [System.Drawing.Imaging.ImageFormat]::Png); exit 0 } else { exit 1 }`
155
- await run("powershell", ["-NoProfile", "-Command", psScript])
156
- } else if (isMac) {
157
- const script = `try; set f to (POSIX file "${dest}"); set img to the clipboard as «class PNGf»; set fd to open for access f with write permission; write img to fd; close access fd; end try`
158
- await run("osascript", ["-e", script])
159
- } else {
160
- await run("bash", ["-c", `xclip -selection clipboard -t image/png -o > "${dest}" 2>/dev/null || { which wl-paste >/dev/null 2>&1 && wl-paste -t image/png > "${dest}" 2>/dev/null; } || exit 1`])
161
- }
172
+ const { paths } = savePastedImages([dataUrl], cwd, { fs: { mkdirSync, writeFileSync } })
173
+ return paths[0] ?? null
162
174
  } catch {
163
- pushLine("Clipboard does not contain an image, or clipboard access failed", C.dim)
164
- try { await unlink(dest) } catch {}
165
- return
175
+ return null
166
176
  }
177
+ }
167
178
 
168
- const st = await stat(dest).catch(() => null)
179
+ /** Ctrl+V / Alt+V:读剪贴板图像 → 系统临时区 staging(不入项目树)→ 读 buffer ⇒ staging 用毕即清
180
+ * (读取后——与失败径同口径)⇒ `savePastedImageBuffer` 落 `<cwd>/.thincoder/tmp/paste-<id>-0.png`
181
+ * → 插入 `read_image <最终绝对路径>`。
182
+ * 失败径(dim 提示行 ×3):无图(捕获抛 ∥ 零字节)· 超阈(`IMAGE_MAX_BYTES` 单源早筛——stat 后
183
+ * 读前,不读巨件)· 写败(核落盘 ≤0 项)——均零插入、返回 null。
184
+ * @param {{agent:{cwd:string}, state, pushLine:Function, render:Function}} ctx
185
+ * @param {((dest:string)=>Promise<void>)|null} [captureImpl] — 捕获面测试缝(写 staging ∥ 无图抛);
186
+ * 缺省 null ⇒ 平台捕获实现(生产调用点零改;参数作用域——零跨调用残留)
187
+ * @returns {Promise<{insert:string, path:string}|null>} 成功 = 插入文本 + 最终绝对路径;失败 = null */
188
+ export async function pasteClipboardImage(ctx, captureImpl = null) {
189
+ const { agent, state, pushLine, render } = ctx
190
+ const staging = join(tmpdir(), `thincoder-paste-${Date.now()}.png`)
191
+ const capture = captureImpl ?? captureClipboardImage
192
+ try {
193
+ await capture(staging)
194
+ } catch {
195
+ pushLine("Clipboard does not contain an image, or clipboard access failed", C.dim)
196
+ try { await unlink(staging) } catch {}
197
+ return null
198
+ }
199
+ const st = await stat(staging).catch(() => null)
169
200
  if (!st || st.size === 0) {
170
201
  pushLine("Clipboard does not contain an image, or clipboard access failed", C.dim)
171
- try { await unlink(dest) } catch {}
172
- return
202
+ try { await unlink(staging) } catch {}
203
+ return null
173
204
  }
174
-
175
- const cmd = `read_image ${dest}`
176
- state.input.splice(state.cursor, 0, ...[...cmd])
177
- state.cursor += cmd.length
178
- pushLine(`[image pasted → ${dest}]`, C.tool)
205
+ if (st.size > IMAGE_MAX_BYTES) {
206
+ pushLine("Clipboard image too large (max 15MB)", C.dim)
207
+ try { await unlink(staging) } catch {}
208
+ return null
209
+ }
210
+ const buffer = await readFile(staging)
211
+ try { await unlink(staging) } catch {}
212
+ const path = await savePastedImageBuffer(buffer, agent.cwd)
213
+ if (!path) {
214
+ pushLine("Clipboard image could not be saved (write failed)", C.dim)
215
+ return null
216
+ }
217
+ const insert = `read_image ${path}`
218
+ const chars = [...insert] // codepoint 数组(同 insertPastedText 口径——cwd 含星平面字符时游标不漂)
219
+ state.input.splice(state.cursor, 0, ...chars)
220
+ state.cursor += chars.length
221
+ pushLine(`[image pasted → ${path}]`, C.tool)
179
222
  render()
223
+ return { insert, path }
180
224
  }
@@ -4,11 +4,16 @@
4
4
  import { readFileSync } from "node:fs"
5
5
  import { ansi, C } from "./ansi.mjs"
6
6
  import { activeSlot, slotPath } from "@thincoder/core/session.mjs"
7
+ // off 形族单源(`docs/core/design/MODEL-SPECS.md` §16.2 / §16.3 / §16.4)——取形与「有效 off 路径」
8
+ // 判据一律自本档取,本档零副本(叶档:零 import 纯函数)。
9
+ import { thinkOffShape, thinkOffPath } from "@thincoder/core/think-off.mjs"
7
10
  import { writeSessionFile } from "./cmd-eng.mjs"
8
11
 
9
12
  export async function handleAdvisorCommand(ctx) {
10
13
  const { agent, showPicker, pushLine, pushLabel } = ctx
11
14
  const cfg = agent.config.advisor ??= {}
15
+ // 入口预载规格面(懒加载面零改:不做顶层静态 import)——同步的状态行(`advisorStatus`)亦须取 spec。
16
+ const { specForModel } = await import("@thincoder/core/config.mjs")
12
17
  // Deprecated field cleanup (2026-08-21): advisor.enabled is no longer read
13
18
  // anywhere; drop it here so persist() never re-writes the stale flag.
14
19
  delete cfg.enabled
@@ -17,13 +22,20 @@ export async function handleAdvisorCommand(ctx) {
17
22
  if (ctx.persistRaw) {
18
23
  await ctx.persistRaw((raw) => {
19
24
  raw.agent ??= {}
20
- raw.agent.advisor = cfg
25
+ // advisor.guard is session-level (#382 同口径收 · slot authority — VSC parity): the guard
26
+ // never rides the config write — only the config-scoped keys (model/thinking/effort/timeout)
27
+ // persist;盘上既有 guard 值原样保留(回退面不动 —— 读链 = 槽 ?? config)。
28
+ const scoped = { ...cfg }
29
+ delete scoped.guard
30
+ const diskAdvisor = typeof raw.agent.advisor === "object" && raw.agent.advisor !== null ? raw.agent.advisor : {}
31
+ raw.agent.advisor = { ...diskAdvisor, ...scoped }
21
32
  })
22
33
  }
23
34
  }
24
35
 
25
- // Guard-only dual write (2026-08-29 — advisor.guard is session-level): the guard goes into
26
- // the CURRENT session slot first (shared with VS Code), the config.json mirror follows.
36
+ // Guard-only slot write (2026-08-29 — advisor.guard is session-level; #382 同口径收): the guard
37
+ // goes into the CURRENT session slot (shared with VS Code) — the slot is the single authority;
38
+ // no config.json mirror write (与 VSC 面板写面同判——guard 不入 config 写面).
27
39
  // Other advisor keys (model/thinking/effort/timeout) stay config-scoped — persist() only.
28
40
  const persistGuard = async () => {
29
41
  try {
@@ -33,19 +45,23 @@ export async function handleAdvisorCommand(ctx) {
33
45
  data.advisor = { ...(typeof data.advisor === "object" && data.advisor !== null ? data.advisor : {}), guard: cfg.guard === true }
34
46
  writeSessionFile(p, data)
35
47
  }
36
- } catch { /* slot missing/unreadable — config mirror still written */ }
37
- await persist()
48
+ } catch { /* slot missing/unreadable — guard stays in-memory for this session (zero config write) */ }
38
49
  }
39
50
 
40
51
  // Lazy model cache — fetched once per /advisor session
41
52
  let modelCache = null
42
53
 
43
54
  // ── State helpers ──
55
+ /** 状态行(§16.4):无有效 off 路径(`thinkOffPath === false`)⇒ **不报 off**——残留 off 标记
56
+ * (`null` / `{type:"disabled"}`)按「未设档」渲染:有档报 `on (<档>)`,无档报 `on`
57
+ * (服务端恒思考 / 形不发字段 ⇒ 「Think: off」会是假断言)。 */
44
58
  function advisorStatus() {
45
59
  const curModel = cfg.model || agent.provider.model
46
- const thinkInfo = cfg.thinking === null ? "off"
47
- : cfg.thinking?.type === "disabled" ? "off"
60
+ const offPath = thinkOffPath(specForModel(getEffectiveModel(agent, cfg)))
61
+ const offMarked = cfg.thinking === null || cfg.thinking?.type === "disabled"
62
+ const thinkInfo = offPath && offMarked ? "off"
48
63
  : cfg.reasoningEffort ? `on (${cfg.reasoningEffort})`
64
+ : offMarked ? "on"
49
65
  : cfg.thinking ? `on (${cfg.thinking.type})` : "(main)"
50
66
  return `Advisor | Model: ${curModel} | Think: ${thinkInfo}`
51
67
  }
@@ -54,6 +70,17 @@ export async function handleAdvisorCommand(ctx) {
54
70
  return ` ${advisorStatus()}`.replace(/\|/g, ansi.dim + "|" + ansi.reset)
55
71
  }
56
72
 
73
+ /** off 形落盘(`think_off` 与归一后的 `effort_none` 共用——单一口径,防同状态两公式):
74
+ * 取形 = 单源 `thinkOffShape`(`@thincoder/core/think-off.mjs`;§15.4-2 族别判据 / §16.2 生产者表)——
75
+ * effort 族(`thinkApi === "effort"`)⇒ `thinking: null`(载荷层 off 门首款要求;`{type:"disabled"}`
76
+ * 不开门 ⇒ 关思考静默失效);其余(type 族默认 / 自定义开值族)⇒ `{type:"disabled"}`(达载荷层)。
77
+ * 三支同清 `reasoningEffort`(§16.6-⑵:off 标记与档位不得同盘——F2 违约族)。 */
78
+ async function applyThinkOff() {
79
+ const spec = specForModel(getEffectiveModel(agent, cfg))
80
+ cfg.thinking = thinkOffShape(spec)
81
+ delete cfg.reasoningEffort
82
+ }
83
+
57
84
  // ── Model picker sub-loop ──
58
85
  async function modelPicker() {
59
86
  if (!modelCache) {
@@ -98,7 +125,6 @@ export async function handleAdvisorCommand(ctx) {
98
125
  pushLabel("❯ Advisor", ansi.bold + C.tool)
99
126
  pushLine("Thinking: using main model settings", C.tool)
100
127
  } else if (c.action === "think_on") {
101
- const { specForModel } = await import("@thincoder/core/config.mjs")
102
128
  const spec = specForModel(getEffectiveModel(agent, cfg))
103
129
  cfg.thinking = { type: spec.thinkEnabledValue ?? "enabled" }
104
130
  if (spec.thinkApi === "effort") delete cfg.thinking
@@ -106,14 +132,24 @@ export async function handleAdvisorCommand(ctx) {
106
132
  pushLabel("❯ Advisor", ansi.bold + C.tool)
107
133
  pushLine(`Thinking: ON`, C.tool)
108
134
  } else if (c.action === "think_off") {
109
- const { specForModel } = await import("@thincoder/core/config.mjs")
110
- const spec = specForModel(getEffectiveModel(agent, cfg))
111
- const isCustomThink = (spec.thinkEnabledValue ?? "enabled") !== "enabled"
112
- cfg.thinking = isCustomThink ? null : { type: "disabled" }
135
+ await applyThinkOff()
136
+ await persist()
137
+ pushLabel("❯ Advisor", ansi.bold + C.tool)
138
+ pushLine("Thinking: OFF", C.tool)
139
+ } else if (c.action === "effort_none") {
140
+ // 归一(镜像 `cmd-think.mjs:104` 归一式 / `:127` 删档 / `:82` 回执):档位 `none` 不是
141
+ // 「强度零」而是**关思考**——与菜单 off 项同语义。不归一则残留 `reasoningEffort:"none"`
142
+ // 被载荷层当强度档送(`provider/core.mjs:197-204`)⇒ 与 off 形矛盾同发、服务端仍思考。
143
+ delete cfg.reasoningEffort
144
+ await applyThinkOff()
113
145
  await persist()
114
146
  pushLabel("❯ Advisor", ansi.bold + C.tool)
115
147
  pushLine("Thinking: OFF", C.tool)
116
148
  } else if (c.action.startsWith("effort_")) {
149
+ // #346-①(§16.4 表末行):选档 = 要思考 ⇒ 先清 `thinking === null` 标记(对齐
150
+ // `cmd-think.mjs:119-120` 先例 / §15.4-2 末句);`{type:"disabled"}` 不动(同先例;
151
+ // 残余登记 = §16.6-⑸——type 族 off ⇒ 选档 后该标记保持)。
152
+ if (cfg.thinking === null) delete cfg.thinking
117
153
  cfg.reasoningEffort = c.action.slice(7)
118
154
  await persist()
119
155
  pushLabel("❯ Advisor", ansi.bold + C.tool)
@@ -232,6 +268,7 @@ async function buildThinkingEntries(agent, cfg) {
232
268
  const thinkOnValue = spec.thinkEnabledValue ?? "enabled"
233
269
  const isCustomThink = thinkOnValue !== "enabled"
234
270
  const isEffortOnly = spec.thinkApi === "effort"
271
+ const offPath = thinkOffPath(spec)
235
272
  const effortLevels = spec.reasoningEffortEnum ?? ["high", "max"]
236
273
 
237
274
  const curEffort = cfg.reasoningEffort ?? providerForDefaults.reasoningEffort
@@ -245,7 +282,11 @@ async function buildThinkingEntries(agent, cfg) {
245
282
  if (!isEffortOnly) {
246
283
  entries.push({ type: "header", text: "Thinking mode" })
247
284
  entries.push({ type: "item", text: `Enabled ${thinkingEnabled ? "← current" : ""}`, action: "think_on" })
248
- entries.push({ type: "item", text: `Disabled ${(curThinking?.type === "disabled" || curThinking === null) ? "← current" : ""}`, action: "think_off" })
285
+ // §16.4:无有效 off 路径(服务端强制族 / 形不发字段)⇒ Disabled 项**不渲染**;
286
+ // Enabled 项保留 = 残留 `{type:"disabled"}` 标记的恢复径(`/think on` 同旨)。
287
+ if (offPath) {
288
+ entries.push({ type: "item", text: `Disabled ${(curThinking?.type === "disabled" || curThinking === null) ? "← current" : ""}`, action: "think_off" })
289
+ }
249
290
  }
250
291
  entries.push({ type: "header", text: "Reasoning effort" })
251
292
  for (const level of effortLevels) {