thincoder 0.12.66 → 0.12.67

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.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,24 @@
1
+ ## [0.12.67] — 2026-09-25
2
+
3
+ > 0.12.66 → 0.12.67(连续号——发布时定号)
4
+
5
+ ### Added
6
+
7
+ - **忙时排队扩面(多槽 + 合并消费 + 可见面)**:Enter 排队自单槽升为**队列**(上限 8 条)——排队期会话流尾显示**待发送块**(逐条原文,每条 ≤3 行、超限尾标);状态栏 `enterHint` **四态分流**(已排队 N 条 / 普通 busy / 挂起内用户回合 / 挂起内系统轮——后两态分别「当前步骤结束后自动发送」/「本轮结束后优先发送」);消费 = **步边界领取为主**(不打断在飞工具;`[sending queued message]` 回执),连续非 `/` 条目**合并为一条消息、一次回合**(每批 ≤8 条 ∧ ≤2000 字符;`/cmd` 逐条保序);回合尾兜底与驱动级取批两条既有通道零改。
8
+ - **台账迁移 / 审计命令**:`thincoder ledger migrate --dry-run | --confirm`(跨端双库键归一合并——备份后导入、源库回收不删)+ `thincoder ledger audit`(库目录只读审计与处置建议)。
9
+
10
+ ### Changed
11
+
12
+ - **核依赖升级**:`@thincoder/core` `^0.9.4` → `^0.9.5`(规格扩面 + 意图认领 + 台账键归一随核生效)。
13
+ - **会诊 effort 菜单**:`none` 双行消除(自有两 `none` 与枚举 `none` 叠加——列表构造单源化)。
14
+ - **advisor effort off 语义归一**:菜单选 off ⇒ 统一 `think_off` 形(不再直赋 `reasoningEffort: "none"`),回执准确。
15
+
16
+ ### Fixed
17
+
18
+ - **edit 工具入参守卫**(ACP 桥同收):非数组 `edits` ⇒ 成形错误,不再裸 `TypeError`。
19
+ - **子代理工具表** `question` 排除(CLI 提示词注入面同步)。
20
+ - 多处 TUI 文句 / 指针收正(挂起面 / 子代理面板 / 工具显示 / 台账面板)。
21
+
1
22
  ## [0.12.66] — 2026-09-22
2
23
 
3
24
  > 0.12.65 → 0.12.66(连续号——发布时定号)
@@ -189,6 +210,10 @@
189
210
  ② **工具作用域限制全部移除**(§10.1):resolveInCwd 去边界断言(信任模型 + 权限门禁为唯一防线,与 bash 一致);git workdir / execute scriptFile / file_ops 目录限制一并移除;工具描述与提示词 "confined to workspace" 措辞清理(两端 byte-identical)
190
211
  ③ **模型上下文长度可配置**(PROVIDER.md §15):`providers[].context`(K 单位,如 128 = 128K)覆盖 MODEL_SPECS——providerSpec 拷贝覆盖不污染共享 spec;压缩阈值(auto ×0.6)/ TOKEN 窗口 / 状态栏显示 / advisor 预算全链路跟随;数字字符串("128")两端统一归一;非法值忽略 + 警告一次;CLI /model 管理流 + VS Code settings.json 配置界面
191
212
 
213
+ ## [0.12.58] — 2026-09-02
214
+
215
+ - 维护性发布(本期无单独变更条目)
216
+
192
217
  ## [0.12.57] — 2026-09-02
193
218
 
194
219
  ### Added
@@ -350,6 +375,10 @@
350
375
 
351
376
  - 新增 `test/session-eng-advisor.test.mjs`:/eng 双写断言(slot+config)、applySession slot 恢复(true/false/显式 false 压过 config true)、旧槽位无字段回退 config 锁定、saveSession 每 turn 往返、/advisor guard 双写(model 等仍 config-scoped)
352
377
 
378
+ ## [0.12.50] — 2026-08-29
379
+
380
+ - 维护性发布(本期无单独变更条目)
381
+
353
382
  ## [0.12.49] — 2026-08-29
354
383
 
355
384
  ### Added
package/README.md CHANGED
@@ -226,6 +226,12 @@ Code conventions: pure `.mjs`, no semicolons, no third-party npm dependencies al
226
226
 
227
227
  ## Changelog
228
228
 
229
+ ### 0.12.67 (2026-09)
230
+ - **Visible busy queue (multi-slot)** — queue up to 8 messages while a turn runs: a "to send" block shows each queued message in the stream, the status bar has four states, and batches are picked up at step boundaries (without interrupting in-flight tools) and merged into one message per turn (`/cmd` entries keep their order). New ledger commands: `thincoder ledger migrate --dry-run|--confirm` and `thincoder ledger audit`.
231
+ - **Effort menus fixed** — the consult effort picker no longer shows a duplicated `none`; advisor effort "off" normalizes to `think_off` with an accurate receipt.
232
+ - **Core `^0.9.5`** — wider model coverage (qwen-flash / qwen-vl-max / claude-fable-5.1 / gemini-3.1-pro / step-3.7-flash / kimi-code rows), the `cacheMode` dead field removed, deepseek preset aligned to 384K, plus peer intent claims and cross-platform ledger key fixes.
233
+ - **Fixes** — the edit tool now returns a formed error for non-array `edits` (ACP bridge too); `question` is excluded from subagent tool tables.
234
+
229
235
  ### 0.12.66 (2026-09)
230
236
  - **MiMo V2.6 support** — the core `^0.9.4` dependency registers MiMo V2.6 (pro / flash / pro-ultraspeed: 1M context, 131K output, vision, auto caching) and repoints the `mimo` / `mimoplan` presets to `mimo-v2.6-pro`.
231
237
 
package/bin/thincoder.mjs CHANGED
@@ -122,6 +122,8 @@ Usage:
122
122
  thincoder session index [--status | --rebuild]
123
123
  Derived session index (disposable, rebuilt on demand): --status reports sessions /
124
124
  messages / tool calls + coverage + size; --rebuild reindexes every session file
125
+ thincoder ledger migrate --dry-run | --confirm Ledger variant-key merge (dry-run report / backup then import; source recycled)
126
+ thincoder ledger audit Read-only ledger dir audit (classify every db + suggestions)
125
127
  thincoder upgrade Update to the latest version from npm
126
128
  thincoder completion <sh> Generate shell completion script (bash / zsh / fish)
127
129
  thincoder -v, --version Print version
@@ -446,6 +448,19 @@ switch (command) {
446
448
  break
447
449
  }
448
450
 
451
+ case "ledger": {
452
+ // LEDGER.md §2.2(2026-09-25 批):台账存量收正面——变体键合并迁移(migrate)/ 残档审计(audit);
453
+ // 核内实现(ledger-migrate.mjs),壳侧只分发(先例 = `session gc` 双档:--dry-run 只读 / --confirm 执行)。
454
+ if (args[0] === "migrate" || args[0] === "audit") {
455
+ const m = await import("@thincoder/core/ledger-migrate.mjs")
456
+ process.exitCode = await (args[0] === "migrate" ? m.runLedgerMigrate(args) : m.runLedgerAudit(args))
457
+ break
458
+ }
459
+ console.error("Usage: thincoder ledger migrate --dry-run | --confirm [--from <key>] | thincoder ledger audit [--root <dir>]")
460
+ exitSoon(1)
461
+ break
462
+ }
463
+
449
464
  case "--help":
450
465
  case "-h": {
451
466
  process.stdout.write(USAGE)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thincoder",
3
- "version": "0.12.66",
3
+ "version": "0.12.67",
4
4
  "description": "Thin coding agent - zero third-party dependencies, no build step, Node.js native. Sharp code, zero bloat.",
5
5
  "keywords": [
6
6
  "ai",
@@ -20,7 +20,7 @@
20
20
  "node": ">=24"
21
21
  },
22
22
  "dependencies": {
23
- "@thincoder/core": "^0.9.4"
23
+ "@thincoder/core": "^0.9.5"
24
24
  },
25
25
  "files": [
26
26
  "bin/",
@@ -20,7 +20,7 @@
20
20
  * `{ stopReason: "end_turn" }` (kimi session.ts parity).
21
21
  */
22
22
  import { detectDanger, normalizeEOL, joinWithEol } from "@thincoder/core/tools/shared.mjs"
23
- import { computeEditEntry, validateEditEntry, assertEditArgsExclusive, hasLineParams } from "@thincoder/core/tools/edit-diff.mjs"
23
+ import { computeEditEntry, validateEditEntry, assertEditArgsExclusive, assertEditsContainer, assertEditEntries, hasLineParams, EDIT_ENTRY_NO_PATH, EDIT_ABORT_PREFIX, editEntryLabel } from "@thincoder/core/tools/edit-diff.mjs"
24
24
  // 第 27 批 §12.3①/③:relay 前缀文法单一权威(模块直连——不自持正则副本)。
25
25
  import { parseRelayPath } from "@thincoder/core/agent/relay-prefix.mjs"
26
26
  // 批 1 CORE-DEFECT-FIXES B3:onWait 相位值域 + 文案单源(本面仅日志——PROVIDER.md §6.20)
@@ -106,7 +106,6 @@ export function buildAcpCallbacks({ sessionId, notify, request, log = () => {},
106
106
  // (edit-diff.mjs——校验→判定序→应用:行级 LCS、零重叠→替换即删、replace_all 字面替换全部)。
107
107
  // 桥只留「读 IDE 缓冲 → computeEditEntry → 写回 IDE 缓冲」——错误文本经抛错原样透传
108
108
  // ——与本地通道逐字一致(NF15.6b / AC15.10:not found / occurrences / 空 old / 空 new)。
109
- const EDIT_ABORT_PREFIX = "edit aborted (atomic — no files written): "
110
109
 
111
110
  // 不变量(§11.7-2):**任何** fs/* 反向 RPC 发出前必过客户端能力位——守卫住在本函数内
112
111
  // (防御面:路由分支先判,守卫兜底,漏判不发静默超时)。
@@ -137,24 +136,23 @@ export function buildAcpCallbacks({ sessionId, notify, request, log = () => {},
137
136
  await writeBuffer(p, joinWithEol(normalizeEOL(out.updated).split("\n"), raw))
138
137
  return `OK: edited ${p} via IDE (${out.occurrences} occurrence(s))${out.note ? ` — ${out.note}` : ""}`
139
138
  }
140
- /** 数组形态(D15.7):条目校验(path——顶层默认自 args.path ?? args.filePath(pathOf)——
141
- * 2026-09-05 用户裁定 CLI parity——/validateEditEntry/互斥(只对顶层 old/new)——同本地 edit-batch 措辞)→
142
- * 读全部涉及文件缓冲(同文件去重——一次读)→ 逐条 computeEditEntry(abortPrefix——批量
139
+ /** 数组形态(D15.7):守卫与文案单源 = 本地 edit-diff(assertEditsContainer / assertEditEntries /
140
+ * validateEditEntry——EDIT.md §8 D-7:核 edit-batch 与桥同调用、桥零字面副本)→
141
+ * 读全部涉及文件缓冲(同文件去重——一次读)→ 逐条 computeEditEntry(EDIT_ABORT_PREFIX——批量
143
142
  * 原子前缀;同文件条目按数组序串行累积——第二条基于第一条结果)→ 全部通过 → 逐文件写回
144
143
  * 一次(判失败 → 零写;写失败 → 同本地 edit-batch 既有原子语义)。 */
145
144
  const editBatch = async (args) => {
146
145
  const edits = args.edits
147
- if (!Array.isArray(edits) || edits.length === 0) {
148
- throw new Error("edits must be a non-empty array of {path, old_string | line/startLine+endLine, new_string}")
149
- }
146
+ assertEditsContainer(edits)
150
147
  assertEditArgsExclusive(args)
148
+ assertEditEntries(edits)
151
149
  const groups = new Map() // path → { path, raw, content, edits }
152
150
  for (const e of edits) {
153
151
  // 2026-09-05 用户裁定(CLI parity——本地 edit-batch 同句):条目自带 path 优先;
154
152
  // 缺省回退顶层 path(pathOf——path/filePath 别名同单形态)
155
153
  const p = e.path ?? pathOf(args)
156
- if (!p) throw new Error("each edit must have a path — give each entry its own path or pass a top-level path")
157
- validateEditEntry(e, { label: `edit for ${p}: `, rich: false })
154
+ if (!p) throw new Error(EDIT_ENTRY_NO_PATH)
155
+ validateEditEntry(e, { label: editEntryLabel(p), rich: false })
158
156
  let g = groups.get(p)
159
157
  if (!g) {
160
158
  g = { path: p, raw: "", content: "", edits: [] }
@@ -1,4 +1,4 @@
1
- /** permission.mjs — CLI 转口档(P2 机制层端差批 §2.20):闸语义 + 每工具请示文案单源 = 核
1
+ /** permission.mjs — CLI 转口档(P2 机制层端差批 docs/batches/2026-09-20-mechanism-parity-batch.md §2.20):闸语义 + 每工具请示文案单源 = 核
2
2
  * `permission.mjs`(#165 落点——本端零第二实现);展示面由核的 `io.ask` 缝承载。
3
3
  * 导出面与核逐字对齐,保既有 import 面(`bin/thincoder.mjs` · `distill-command.mjs`)。 */
4
4
  export { summarize, formatPermission, askPermission } from "@thincoder/core/permission.mjs"
@@ -16,5 +16,5 @@ export const CLI_PROMPT_INJECTIONS = {
16
16
  // bash 宿主终端面——CLI 无 terminal 参数(工具面 review 再统一)
17
17
  "bash-terminal-face": "",
18
18
  // question 工具可用性行(核内正文该行已移出正文、锚原位替换——本端填回 CLI 措辞)
19
- "question-ui-face": "- Availability: this tool needs an interactive UI — in contexts without one (headless runs, subagent children) it returns an error instead of asking; put the question in your reply text instead.",
19
+ "question-ui-face": "- Availability: this tool needs an interactive UI — in contexts without one (headless runs) it returns an error instead of asking; subagent children (depth>0) never get it (excluded from their tool tables); put the question in your reply text instead.",
20
20
  }
@@ -8,7 +8,7 @@
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 ↔
@@ -18,11 +18,13 @@ import { runAgent, ContinueError } from "@thincoder/core/agent.mjs"
18
18
  import { saveSession } from "@thincoder/core/session.mjs"
19
19
  import { ansi, C } from "./ansi.mjs"
20
20
  import { buildToolCallbacks, sweepToolBlocks } from "./tool-events.mjs"
21
- import { freezeAllSubTasks } from "./subagent-blocks.mjs" // freezeReclaimDigestedBlocks 随 §17 段迁 suspension-drive.mjs
21
+ import { freezeAllSubTasks } from "./subagent-blocks.mjs" // freezeReclaimDigestedBlocks 随 AGENT-LOOP-ASYNC-POOL.md §6.8 段迁 suspension-drive.mjs
22
22
  import { ensureSessionTitle } from "@thincoder/core/generate-title.mjs"
23
23
  import { logEvent, errText } from "@thincoder/core/log.mjs"
24
24
  import { t } from "@thincoder/core/i18n.mjs"
25
25
  import { suspensionSession, poolLive } from "./suspension-drive.mjs"
26
+ import { planQueuedInput } from "./queued-merge.mjs"
27
+ import { pickupQueuedAtStepBoundary } from "./queued-pickup.mjs"
26
28
 
27
29
  /** Exit-flush bound for the async end-of-run distillation (SEND-STALL-DISTILL §2.5):
28
30
  * wait at most this long for the in-flight distill before the final session save —
@@ -80,7 +82,10 @@ async function runAgentTurnInner(ctx, text, opts) {
80
82
  // 可注入覆盖(测试用);默认走真实实现
81
83
  const runAgentImpl = ctx.runAgent ?? runAgent
82
84
  const saveSessionImpl = ctx.saveSession ?? saveSession
83
- // autoTurn(消化轮):无用户输入——不画 "❯ You:"(系统驱动回合,§17 D-S6)
85
+ // F16 步边界 pickup(queue-visible 批 fix 轮 2026-09-24——TUI.md §7.5 消费时机①):用户回合在飞 ⇒
86
+ // 核循环头投递回调(不中断——下一步生效,参照系 = 子代理 `send`);系统轮不传(分流——两者轮不参与)
87
+ const consumeQueuedInput = autoTurn ? null : () => pickupQueuedAtStepBoundary(ctx)
88
+ // autoTurn(消化轮):无用户输入——不画 "❯ You:"(系统驱动回合,AGENT-LOOP-ASYNC-POOL.md §6.8 D-S6)
84
89
  if (!autoTurn) {
85
90
  pushLabel(`❯ You:`, ansi.bold + C.user)
86
91
  pushLine(text, C.text)
@@ -89,7 +94,7 @@ async function runAgentTurnInner(ctx, text, opts) {
89
94
  ctx.assistantLabeled = false
90
95
  state.processing = true
91
96
  state.status = "Processing..."
92
- // §17.6(advisor round2 🟡):回合启动即解除空闲退出武装——exitArmed 只属于空闲态
97
+ // AGENT-LOOP-ASYNC-POOL.md §6.8(advisor round2 🟡):回合启动即解除空闲退出武装——exitArmed 只属于空闲态
93
98
  // 双确认,跨回合残留会让"停回合后的 armed 落空穿透 + 陈旧 exitArmed"组合把一次
94
99
  // 意图为全停/退出的按下变成无二次确认的即时退出(key-handler 落空分支落回空闲
95
100
  // 分支时 `!state.exitArmed` 判假即 exit);计时一并清(key-handler 空闲分支按
@@ -109,7 +114,7 @@ async function runAgentTurnInner(ctx, text, opts) {
109
114
  // bounded by the N2 per-child 500-line ring buffer.
110
115
  state.currentTool = null
111
116
  state.processingStarted = Date.now()
112
- // §17 偏差修复 #3(会话 abort 全覆盖):回合链 controller 登记。链头 = 非挂起会话内
117
+ // AGENT-LOOP-ASYNC-POOL.md §6.8 偏差修复 #3(会话 abort 全覆盖):回合链 controller 登记。链头 = 非挂起会话内
113
118
  // 且非释放窗口期(suspended/_suspPending 均 false)开启的回合——登记表清零;队列
114
119
  // 递归回合(_suspPending 置位期)与会话内回合(suspended=true)继续累积。链条内每次
115
120
  // 重建(Ctrl+I 续跑 / ContinueError 续跑 / AUTO 续跑)都登记——挂起会话的 abort 集合
@@ -151,14 +156,14 @@ async function runAgentTurnInner(ctx, text, opts) {
151
156
  try {
152
157
  for (let resume = false; ; resume = true) {
153
158
  try {
154
- await runAgentImpl(agent, text, callbacks, { signal: state.controller.signal, resume, autoTurn, upstreamTurn, suspDriven: true })
159
+ await runAgentImpl(agent, text, callbacks, { signal: state.controller.signal, resume, autoTurn, upstreamTurn, suspDriven: true, consumeQueuedInput })
155
160
  flushStream()
156
161
  break // Normal completion, exit loop
157
162
  } catch (error) {
158
163
  flushStream()
159
164
  if (error.name === "AbortError" || state.controller?.signal.aborted) {
160
165
  const reason = state.controller?.signal?.reason
161
- // §17.6 D-C1(round1 #1 区分机制——2026-09-03):interrupt 两种语义——
166
+ // AGENT-LOOP-ASYNC-POOL.md §6.8 D-C1(round1 #1 区分机制——2026-09-03):interrupt 两种语义——
162
167
  // 有 message(Ctrl+I 注入——key-modes handleInterruptMode)= 重建 controller
163
168
  // 续跑(既有语义——agent loop 已把消息注入 history,中止的 signal 不能重试);
164
169
  // 无 message(Ctrl+C 首按停回合——key-handler abort({ interrupt: true })
@@ -188,7 +193,7 @@ async function runAgentTurnInner(ctx, text, opts) {
188
193
  }
189
194
  if (error instanceof ContinueError) {
190
195
  if (autoTurn) {
191
- // §17 digest turn-cap 规则(D-S9 ContinueError 行):无面板——AUTO 档按
196
+ // AGENT-LOOP-ASYNC-POOL.md §6.8 digest turn-cap 规则(D-S9 ContinueError 行):无面板——AUTO 档按
192
197
  // §2 统一规则自动 resume(无人值守授权);手动档静默拒绝(部分消化留在
193
198
  // 历史,会话回挂起——结果不丢,只是不再烧轮次)。
194
199
  if (agent.autoApprove) {
@@ -257,12 +262,12 @@ async function runAgentTurnInner(ctx, text, opts) {
257
262
  clearInterval(ticker)
258
263
  state.processing = false
259
264
  state._advisorBlocks = []
260
- // §17 D-S1/D-S8:回合正常结束且后台池仍 live → 挂起会话:子agent 区块保持 live
265
+ // AGENT-LOOP-ASYNC-POOL.md §6.8 D-S1/D-S8:回合正常结束且后台池仍 live → 挂起会话:子agent 区块保持 live
261
266
  // (不冻结——各 settle 事件自行处理),本次回合 controller 交会话层作 abort 句柄
262
267
  // (挂起期 Ctrl+C 中止全部后台子代理);池空 / 中断 / 错误 → 现状 freezeAllSubTasks
263
268
  // (中断态块标 interrupted;正常态块已在 settle 时各自冻结)。
264
269
  const willSuspend = poolLive(agent)
265
- // §17.5.5:挂起会话内回合(digest/会话内用户回合——skipSession 且 suspended)的
270
+ // AGENT-LOOP-ASYNC-POOL.md §6.8:挂起会话内回合(digest/会话内用户回合——skipSession 且 suspended)的
266
271
  // 收尾**不冻结驻留块**——已消化(pinned 且条目已注入)块由 suspensionSession 在
267
272
  // run 返回后 freezeReclaimDigestedBlocks 逐条回收(settle 锚点 splice——digest 总览文本
268
273
  // 之前——round1 #1 裁定,不等池空);
@@ -277,7 +282,7 @@ async function runAgentTurnInner(ctx, text, opts) {
277
282
  } else if (!agent._sessionAbort) {
278
283
  agent._sessionAbort = state.controller // 会话 abort 句柄(children 共享此 signal)
279
284
  }
280
- // §17 偏差修复 #1(回合释放窗口守卫):willSuspend 判定后、进入任何 await 之前置位
285
+ // AGENT-LOOP-ASYNC-POOL.md §6.8 偏差修复 #1(回合释放窗口守卫):willSuspend 判定后、进入任何 await 之前置位
281
286
  // 挂起待定标志。suspensionSession 真正启动前还有真实 await(ensureSessionTitle /
282
287
  // distill flush ≤5s / saveSession),其间 processing=false 且 suspended 尚未置位——
283
288
  // 无此标志 key-handler Enter 会走 submit 并发开第二个 runAgentTurn(双驱动器竞态:
@@ -285,7 +290,7 @@ async function runAgentTurnInner(ctx, text, opts) {
285
290
  // 有效区间 = 释放窗口(suspensionSession 启动即清除);会话内回合(skipSession)
286
291
  // 由 state.suspended 覆盖;_suspAborted 时挂起会话不会启动(下方会话入口同条件)。
287
292
  state._suspPending = willSuspend && !skipSession && !state._suspAborted
288
- // §17 偏差修复 #3(会话 abort 全覆盖):abort 集合快照 = 本链条内全部 controller
293
+ // AGENT-LOOP-ASYNC-POOL.md §6.8 偏差修复 #3(会话 abort 全覆盖):abort 集合快照 = 本链条内全部 controller
289
294
  // (含 Ctrl+I/ContinueError 重建的旧 controller——其下 spawn 的 async children 持旧
290
295
  // signal,Ctrl+C 只 abort 最后一个会让它们跑完整个 turn 预算,正常完成后仍
291
296
  // mergeChildMutations 写父 guard 标记,绕过 advisor/verify 门)。会话内回合的 finally
@@ -327,17 +332,19 @@ async function runAgentTurnInner(ctx, text, opts) {
327
332
  render()
328
333
  }
329
334
 
330
- // §17 偏差 #1 兜底(INPUT-LOCK 单槽化——2026-09-09):释放窗口期 Enter 已入
331
- // pendingInput 单槽——链条走到此处若池已空(挂起会话不会启动,下方 while 是最后一个
332
- // 消费点)则转正队列照常续发,消息不滞留不并发。池非空时挂起会话先消费 pendingInput
333
- // (D-S5 输入优先),无需此处处理。单槽语义下残余至多一条。
335
+ // AGENT-LOOP-ASYNC-POOL.md §6.8 偏差 #1 兜底 + queue-visible 批(2026-09-24)收正:末步入队的排队消息(本 run 无后续
336
+ // 步边界 ⇒ 步边界 pickup 触不到)——按合并计划取批转 state.queue,队列 while 续发新回合;
337
+ // 池非空时挂起会话先消费 pendingInput(D-S5 输入优先),无需此处处理。超一批者截批先行
338
+ // (余下留待下批——仍在队列 ⇒ 待发送块可见,零丢失)。
334
339
  if (!skipSession && !state._suspAborted && (state.pendingInput?.length ?? 0) > 0 && !poolLive(agent)) {
335
- state.queue.push({ text: String(state.pendingInput.shift()) })
340
+ const action = planQueuedInput(state.pendingInput)[0]
341
+ state.pendingInput.splice(0, action.count)
342
+ state.queue.push({ text: action.text })
336
343
  }
337
344
 
338
- // 交接消息自动续发(INPUT-LOCK 单消息——2026-09-09):submit 不再排队(busy 提交吞)
339
- // + R15 攒批删——state.queue 只剩残项单消息(释放窗口兜底/挂起中止残余——零丢失
340
- // 承诺)——逐条直发;斜杠命令直接执行(保序);回合后的续发由递归层同循环续取。
345
+ // 交接消息自动续发(queue-visible 批 2026-09-24——R15 攒批恢复):state.queue 条目 = 合并计划
346
+ // 的动作(残项:释放窗口兜底 / 挂起中止残余——零丢失承诺)——逐条直发;斜杠命令直接执行
347
+ // (保序);回合后的续发由递归层同循环续取。
341
348
  while (state.queue.length > 0 && !state.processing) {
342
349
  const head = state.queue.shift()
343
350
  // F16(TUI.md §7.5):消费回执——queued dim 行(消费事实的可见锚;对位 [continuing…])
@@ -352,12 +359,12 @@ async function runAgentTurnInner(ctx, text, opts) {
352
359
  return
353
360
  }
354
361
 
355
- // §17 D-S2: 回合尾后台池非空 → 挂起会话(D-S9 状态机——输入放开、settle 驱动
362
+ // AGENT-LOOP-ASYNC-POOL.md §6.8 D-S2: 回合尾后台池非空 → 挂起会话(D-S9 状态机——输入放开、settle 驱动
356
363
  // auto-turn 消化、池空自然退出回 idle)。skipSession:会话内回合(消化轮/会话内
357
364
  // 用户回合)由 suspensionSession 统一调度,不再递归进入新会话。
358
365
  if (!skipSession && poolLive(agent) && !state._suspAborted) {
359
366
  await suspensionSession(ctx)
360
- // §17 round2 偏差 #1(_suspAborted 粘滞,code review round2 #1):挂起会话退出
367
+ // AGENT-LOOP-ASYNC-POOL.md §6.8 round2 偏差 #1(_suspAborted 粘滞,code review round2 #1):挂起会话退出
361
368
  // (自然耗尽或 Ctrl+C 中止)即复位中止标志——中止 unwind 已完成、池已清空/耗尽,
362
369
  // 复位不会误触发重入。不清则 _suspPending 守卫与下方会话入口被同一标志永久门控:
363
370
  // 中止后用户再 spawn async、回合尾池再 live 时永不再次进入挂起态(suspensionSession
@@ -54,6 +54,15 @@ export async function handleAdvisorCommand(ctx) {
54
54
  return ` ${advisorStatus()}`.replace(/\|/g, ansi.dim + "|" + ansi.reset)
55
55
  }
56
56
 
57
+ /** off 形落盘(`think_off` 与归一后的 `effort_none` 共用——单一口径,防同状态两公式):
58
+ * 自定义开值族(`thinkEnabledValue` ≠ "enabled")取 `null`(NF1 显式 off);其余取 `{type:"disabled"}`。 */
59
+ async function applyThinkOff() {
60
+ const { specForModel } = await import("@thincoder/core/config.mjs")
61
+ const spec = specForModel(getEffectiveModel(agent, cfg))
62
+ const isCustomThink = (spec.thinkEnabledValue ?? "enabled") !== "enabled"
63
+ cfg.thinking = isCustomThink ? null : { type: "disabled" }
64
+ }
65
+
57
66
  // ── Model picker sub-loop ──
58
67
  async function modelPicker() {
59
68
  if (!modelCache) {
@@ -106,10 +115,16 @@ export async function handleAdvisorCommand(ctx) {
106
115
  pushLabel("❯ Advisor", ansi.bold + C.tool)
107
116
  pushLine(`Thinking: ON`, C.tool)
108
117
  } 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" }
118
+ await applyThinkOff()
119
+ await persist()
120
+ pushLabel("❯ Advisor", ansi.bold + C.tool)
121
+ pushLine("Thinking: OFF", C.tool)
122
+ } else if (c.action === "effort_none") {
123
+ // 归一(镜像 `cmd-think.mjs:104` 归一式 / `:127` 删档 / `:82` 回执):档位 `none` 不是
124
+ // 「强度零」而是**关思考**——与菜单 off 项同语义。不归一则残留 `reasoningEffort:"none"`
125
+ // 被载荷层当强度档送(`provider/core.mjs:197-204`)⇒ 与 off 形矛盾同发、服务端仍思考。
126
+ delete cfg.reasoningEffort
127
+ await applyThinkOff()
113
128
  await persist()
114
129
  pushLabel("❯ Advisor", ansi.bold + C.tool)
115
130
  pushLine("Thinking: OFF", C.tool)
@@ -20,6 +20,14 @@ export function poolCur(pl, defaults) {
20
20
  return (k) => (Number.isInteger(pl?.[k]) && pl[k] >= 1 ? pl[k] : defaults.agent.poolLimits[k])
21
21
  }
22
22
 
23
+ /** Effort 菜单档位构造(#16 双 `none` 去重——枚举自带 `none` 的族如 qwen3.8-flash 时原来出两行):
24
+ * `none` 恒为首项且唯一,其余档序保持(`filter` 保序)。语义面零改——`none` = 清档,
25
+ * 消费点 `:205`/`:232` 只认 `=== "none"`;空枚举在 `pickEffort` 处已早退 null、不经本函数。
26
+ * Exported for unit tests (`cmd-config-effort.test.mjs`). */
27
+ export function effortMenuLevels(enumList) {
28
+ return ["none", ...(enumList ?? []).filter((l) => l !== "none")]
29
+ }
30
+
23
31
  /** /config command: view and set agent/embedding/proxy config. */
24
32
  export async function handleConfigCommand(ctx, args = []) {
25
33
  const { agent, pushLine, pushLabel, showPicker, askQuestion, persistRaw, maskKey, pickModelForSlot } = ctx
@@ -177,7 +185,7 @@ export async function handleConfigCommand(ctx, args = []) {
177
185
  // min/low/medium/high/max list made the user pick values that the runtime then
178
186
  // silently dropped as out-of-enum (2026-08-17 audit). Show the model's real enum.
179
187
  if (!enumList || enumList.length === 0) return null // model has no effort — skip
180
- const levels = ["none", ...enumList] // "none" = clear the effort
188
+ const levels = effortMenuLevels(enumList) // "none" = clear the effort(自带去重——#16)
181
189
  const entries = levels.map((l) => ({ type: "item", text: l === current ? `${l} ← current` : l, action: l }))
182
190
  const c = await showPicker("Reasoning effort", entries, { defaultIndex: Math.max(0, levels.indexOf(current ?? "none")) })
183
191
  return c ? c.action : null // Esc → null (keep unchanged)
package/src/tui/index.mjs CHANGED
@@ -208,10 +208,9 @@ export async function startTUI(agent, opts = {}) {
208
208
  showStartup({ agent, state, opts, pushLine, pushLabel, render, startWizard })
209
209
  // LEDGER-SURFACE(§2.30.3.4):台账可见面——首扫(setImmediate)+ 周期;dispose 挂进程退出(:278 cleanup 先例)
210
210
  const ledgerSurface = startLedgerSurface({ state, agent, pushLine, render })
211
- // 前置缺陷最小修(2026-09-22 structure-debt · 父侧授权 · 只修调用点):K-LX3 归核后
212
- // `startLedgerSurface` 返回 **Promise**(resolve 出 `{ dispose }`)——原同步 `.dispose()`
213
- // 令每次退钩抛 TypeError(`node test-startup.mjs` 前置红,HEAD 逐字同款);按 Promise 承接。
214
- process.on("exit", () => { void Promise.resolve(ledgerSurface).then((s) => s?.dispose?.()) })
211
+ // 端壳契约 = **恒同步返回 `{ dispose }`**(K-LX3 归核后与核形一致——核句柄异步就绪后桥接;
212
+ // 载入失败 ⇒ 空句柄)⇒ 退钩同步 `.dispose()`(零 TypeError)。
213
+ process.on("exit", () => ledgerSurface.dispose())
215
214
  backgroundIndex({ agent, state, render })
216
215
 
217
216
  // Check for updates (non-blocking, after startup screen)——实现 update-notice.mjs
@@ -68,7 +68,7 @@ export function createInteraction(ctx) {
68
68
  pushLine(` [auto] ${name}${argSummary ? ` ${argSummary}` : ""}`, C.warn)
69
69
  return Promise.resolve(true)
70
70
  }
71
- // 闸语义 / 请示流程 = 核单源(P2 批 §2.20);本端只供展示面——卡片预览经 `io.ask` 缝
71
+ // 闸语义 / 请示流程 = 核单源(P2 批 docs/batches/2026-09-20-mechanism-parity-batch.md §2.20);本端只供展示面——卡片预览经 `io.ask` 缝
72
72
  // 注入(permissionPreview 渲染在输入框上方,与 "Allow?" 提示同帧)。
73
73
  return coreAskPermission(name, args, {
74
74
  ask: ({ name: n, toolArgs }) => new Promise((resolve) => {
@@ -1,17 +1,19 @@
1
1
  import { C } from "./ansi.mjs"
2
+ import { QUEUED_MAX_ITEMS } from "./queued-merge.mjs"
2
3
 
3
4
  /** busy 门禁(2026-09-22 structure-debt §2.1 分族 4 · #226):processing(含 digest)期
4
- * Enter 单槽受理 + 吞面四(模态 / 斜杠 / 空 / 槽满)+ 槽满提示 + 挂起面唤醒,Tab 恒吞;
5
- * 块体自 key-handler.mjs createKeyHandler 逐字搬移(仅去缩进;函数体自持原块入口判定)。
5
+ * Enter 队列受理(容量 QUEUED_MAX_ITEMS)+ 吞面四(模态 / 斜杠 / 空 / 队满)+ 满队提示 +
6
+ * 挂起面唤醒,Tab 恒吞;块体自 key-handler.mjs createKeyHandler 逐字搬移(仅去缩进;函数体自持原块入口判定)。
6
7
  * 路由 = 分派器 createKeyHandler(守卫 = busy 块内返回条件:tab / 无 meta 的 Enter、回车)。 */
7
8
  export function handleBusyEnter(str, key, state, ctx) {
8
9
  const { pushLine, render } = ctx
9
10
  if (state.processing) {
10
- // busy 门禁(INPUT-LOCK C'——2026-09-09 + F16 busy 单槽注入——2026-09-21 · busy-extend
11
- // 扩面 2026-09-22):processing(含 digest——单一判据)输入不禁——打字照常回显;Enter
12
- // 提交 = 单槽注入(放行判据五条 = TUI-INPUT-BOX.md §4.1:非模态 · 非斜杠 · 非空 · 槽空
13
- // ⇒ 清框入 pendingInput——回合尾 drain / 挂起 driver 送达;挂起两态不再是吞面);
14
- // 吞面四 = 模态 / 斜杠 / 空 / 槽满(吞 + 提示,文本保留;斜杠同禁发——白名单已删);
11
+ // busy 门禁(INPUT-LOCK C'——2026-09-09 + F16 busy 队列受理——2026-09-21 · busy-extend
12
+ // 扩面 2026-09-22 · queue-visible 多槽 + 容量 8——2026-09-24):processing(含 digest——单一
13
+ // 判据)输入不禁——打字照常回显;Enter 提交 = 入队(放行判据五条 = TUI-INPUT-BOX.md §4.1:
14
+ // 非模态 · 非斜杠 · 非空 · 未满队 ⇒ 清框入 pendingInput——步边界 pickup / 回合尾 drain /
15
+ // 挂起 driver 送达;挂起两态不再是吞面);
16
+ // 吞面四 = 模态 / 斜杠 / 空 / 队满(吞 + 提示,文本保留;斜杠同禁发——白名单已删);
15
17
  // 多行换行(meta/enter——编辑)照常放行;Tab 仍吞。**第 31 批**:↑↓ 分流见下方三规则块。
16
18
  if (key.name === "tab") return
17
19
  if ((key.name === "return" && !key.meta) || (str === "\r" && !key.meta)) {
@@ -23,18 +25,21 @@ export function handleBusyEnter(str, key, state, ctx) {
23
25
  pushLine(`[主会话处理中 —— 消息未发送(回合结束后请重按 Enter)]`, C.warn)
24
26
  render(); return
25
27
  }
26
- // 条件 5:单槽满 = 拒绝 + 提示 + 文本保留(二次提交裁定——与挂起态槽满分支逐字同构)
27
- if (state.pendingInput.length > 0) {
28
- pushLine(`[主会话处理中 —— 已有一条消息待发送,请等其处理完成后再发送]`, C.warn)
28
+ // 条件 5:满队(第 9 条)= 拒绝 + 提示 + 文本保留(容量 8——多槽裁定连带;与挂起态满队分支同构)
29
+ if (state.pendingInput.length >= QUEUED_MAX_ITEMS) {
30
+ pushLine(`[主会话处理中 —— 已排队 ${QUEUED_MAX_ITEMS} 条消息,请等其处理完成后再发送]`, C.warn)
29
31
  render(); return
30
32
  }
31
- // 入槽(判据全满足):清框 + history 收录 + 草稿复位 + 单槽填充(§4.1 执行序)
33
+ // 入队(判据全满足):清框 + history 收录 + 草稿复位 + 队列填充(§4.1 执行序)
32
34
  state.input = []; state.cursor = 0
33
35
  state.history.push(text)
34
36
  state.historyIndex = -1
35
37
  state._draft = null
36
38
  state.pendingInput.push(text)
37
- // 挂起面入槽同款唤醒(§4.1)——busy 期该槽恒 null ⇒ 零动作;普通 busy 面零唤醒
39
+ // F4「新提交消息 → 恢复跟随」(§7.5 跟随——排队提交同列;与 turn-face.mjs submit 同款两行)
40
+ state.scroll = 0
41
+ state._followTail = true
42
+ // 挂起面入队同款唤醒(§4.1)——busy 期该槽恒 null ⇒ 零动作;普通 busy 面零唤醒
38
43
  if (state.suspended || state._suspPending) state._suspWake?.()
39
44
  render(); return
40
45
  }
@@ -15,7 +15,7 @@ export function handleCtrlCFamily(str, key, ctx) {
15
15
  popPicker(null)
16
16
  return
17
17
  }
18
- // §17.6(2026-09-03):武装窗口内第二次 Ctrl+C = 显式全停(D-C4 /abort 语义)。
18
+ // AGENT-LOOP-ASYNC-POOL.md §6.8(2026-09-03):武装窗口内第二次 Ctrl+C = 显式全停(D-C4 /abort 语义)。
19
19
  // 在状态路由前检查(picker 取消分支例外——picker 打开时 Ctrl+C 语义 = 取消
20
20
  // picker,武装让位;场景极窄——3s 窗口内需另有 picker 被打开)——两次按下之间
21
21
  // 状态会迁移(首按停回合 → 释放窗口/挂起会话启动),武装必须跨态桥接:只查挂起
@@ -51,10 +51,10 @@ export function handleCtrlCFamily(str, key, ctx) {
51
51
  // 无目标可停 → 落空继续到下方分支(processing 已停 → 空闲态 exitArmed 双确认)
52
52
  }
53
53
  if (state.suspended) {
54
- // §17 D-S9 + round2 偏差 #4(2026-09-02)+ §17.6 修订(2026-09-03):挂起态
54
+ // AGENT-LOOP-ASYNC-POOL.md §6.8 D-S9 + round2 偏差 #4(2026-09-02)+ AGENT-LOOP-ASYNC-POOL.md §6.8 修订(2026-09-03):挂起态
55
55
  // Ctrl+C = 武装窗口两级中止——一次按键直接清池中止全部后台子代理的误触代价高
56
56
  // (digest 刷屏时用户可能只想停住当前回合);仿空闲态退出武装语义(同下方空闲态
57
- // exitArmed 双确认分支)。§17.6 修订 ①:中止当前回合的 abort 带 interrupt
57
+ // exitArmed 双确认分支)。AGENT-LOOP-ASYNC-POOL.md §6.8 修订 ①:中止当前回合的 abort 带 interrupt
58
58
  // (无 message)——此前平 abort 命中 agent.mjs 回合收尾清池分支(aborted &&
59
59
  // !reason.interrupt → 无条件 _asyncSubagents.clear()),一次首按即误杀全部后台
60
60
  // 子代理(2026-09-03 用户两次实测——提示"再按才杀"与实际"一次就全杀"不符);
@@ -80,7 +80,7 @@ export function handleCtrlCFamily(str, key, ctx) {
80
80
  return
81
81
  }
82
82
  if (state.processing && state.controller) {
83
- // §17.6 D-C1(2026-09-03 紧急修复——processing 态曾无武装窗口,第一按直接平
83
+ // AGENT-LOOP-ASYNC-POOL.md §6.8 D-C1(2026-09-03 紧急修复——processing 态曾无武装窗口,第一按直接平
84
84
  // abort() → 命中 agent.mjs 回合收尾清池分支 → 一次 Ctrl+C 误杀全部后台子代理,
85
85
  // 用户两次实测被坑):首按 = interrupt 语义(无 message——停当前回合不续跑——
86
86
  // interrupt 排除 agent.mjs 清池分支——后台池保留——agent-turn 区分不重建续跑)
@@ -1,8 +1,9 @@
1
1
  import { C } from "./ansi.mjs"
2
2
  import { readClipboardText, insertPastedText } from "./clipboard.mjs"
3
+ import { QUEUED_MAX_ITEMS } from "./queued-merge.mjs"
3
4
 
4
5
  /** 编辑族(2026-09-22 structure-debt §2.1 分族 5 · #226):Tab 补全 / Ctrl+U 清框 /
5
- * 退格·删除 / Enter(多行换行 · 挂起单槽 · submit)/ 剪贴板文本与图片粘贴 / 可打印字符;
6
+ * 退格·删除 / Enter(多行换行 · 挂起队列 · submit)/ 剪贴板文本与图片粘贴 / 可打印字符;
6
7
  * 各块自 key-handler.mjs createKeyHandler 逐字搬移(仅去缩进)。
7
8
  * 路由 = 分派器 createKeyHandler 末族(其后无可达分支 —— 块内 return 即终结本次按键)。 */
8
9
  export function handleEditKeys(str, key, ctx) {
@@ -50,15 +51,15 @@ export function handleEditKeys(str, key, ctx) {
50
51
  render()
51
52
  } else {
52
53
  const text = state.input.join("").trim()
53
- // §17 D-S5/F3/F7 + 偏差 #1 + INPUT-LOCK 单槽化(F-6——2026-09-09):挂起态
54
- // (suspended 或释放窗口 _suspPending)Enter = 新回合输入(非打断)——填
55
- // pendingInput 单槽(至多一条待交接)由挂起会话调度;挂起会话内 busy(含 digest)
56
- // 提交经 busy 门禁入同槽(F16 §4.1——两分支同槽、同款清理与唤醒);输入框零干扰(F3)。
57
- // 槽满(竞态防御——不覆盖不丢失)→ 吞 + 提示,文本保留在输入框。
54
+ // AGENT-LOOP-ASYNC-POOL.md §6.8 D-S5/F3/F7 + 偏差 #1 + INPUT-LOCK 队列受理(F-6——2026-09-09 · queue-visible 多槽
55
+ // 容量 8——2026-09-24):挂起态(suspended 或释放窗口 _suspPending)Enter = 新回合输入
56
+ // (非打断)——填 pendingInput 队列(容量 QUEUED_MAX_ITEMS)由挂起会话调度;挂起会话内
57
+ // busy(含 digest)提交经 busy 门禁入同队列(F16 §4.1——两分支同队列、同款清理与唤醒);
58
+ // 输入框零干扰(F3)。满队(第 9 条——竞态防御——不覆盖不丢失)→ 吞 + 提示,文本保留在输入框。
58
59
  if ((state.suspended || state._suspPending) && text && !text.startsWith("/")) {
59
60
  state.pendingInput ??= []
60
- if (state.pendingInput.length > 0) {
61
- pushLine(`[主会话处理中 —— 已有一条消息待发送,请等其处理完成后再发送]`, C.warn)
61
+ if (state.pendingInput.length >= QUEUED_MAX_ITEMS) {
62
+ pushLine(`[主会话处理中 —— 已排队 ${QUEUED_MAX_ITEMS} 条消息,请等其处理完成后再发送]`, C.warn)
62
63
  render()
63
64
  return
64
65
  }
@@ -68,6 +69,9 @@ export function handleEditKeys(str, key, ctx) {
68
69
  state.historyIndex = -1
69
70
  state._draft = null
70
71
  state.pendingInput.push(text)
72
+ // F4「新提交消息 → 恢复跟随」(§7.5 跟随——排队提交同列;与 turn-face.mjs submit 同款两行)
73
+ state.scroll = 0
74
+ state._followTail = true
71
75
  state._suspWake?.()
72
76
  render()
73
77
  return
@@ -110,7 +110,7 @@ export function createKeyHandler(ctx) {
110
110
  if (key.name === "up" || key.name === "down") { handleHistoryKeys(key, ctx); return }
111
111
  if (key.name === "left" || key.name === "right" || key.name === "home" || key.name === "end") { handleCursorKeys(key, ctx); return }
112
112
 
113
- // 序 4:busy 门禁(key-handler-busy.mjs)——Enter 单槽受理 + 吞面四 / Tab 吞
113
+ // 序 4:busy 门禁(key-handler-busy.mjs)——Enter 队列受理(容量 8)+ 吞面四 / Tab 吞
114
114
  // (守卫 = busy 块内返回条件:processing 期仅 tab 与无 meta 的 Enter/回车 被吞,余键落空至编辑族)
115
115
  if (state.processing && (key.name === "tab" || ((key.name === "return" && !key.meta) || (str === "\r" && !key.meta)))) {
116
116
  handleBusyEnter(str, key, state, ctx)
@@ -5,6 +5,11 @@
5
5
  *
6
6
  * 三面语义(启动行 / 变化行 / 状态位)见核档头注;判活展示(F-LX1)亦随归核自动带上——
7
7
  * 本档零实现、零复刻(KD-M2-3)。
8
+ *
9
+ * 两端契约(#233——端壳与核形一致):`startLedgerSurface` **恒同步返回 `{ dispose }`**——
10
+ * 核句柄异步就绪(动态 import)后桥接;载入失败 ⇒ 空句柄(`dispose` 恒可调);`dispose()`
11
+ * 早于核就绪 ⇒ 置位且**跳过启动**(核侧零调用——零周期;N1 降级不崩)。调用点因此恒可按
12
+ * 同步形态挂进程退钩(`process.on("exit", () => handle.dispose())`)。
8
13
  */
9
14
  import { C } from "./ansi.mjs"
10
15
 
@@ -18,15 +23,31 @@ function loadCore() {
18
23
  return _core
19
24
  }
20
25
 
26
+ /** 测试注入缝(`_setLedgerDirForTest` 同款缝族):`fn` ⇒ 载核改走该桩;`null` ⇒ 回退原生
27
+ * `loadCore`(`??` 默认);用例 `afterEach` 复位。 */
28
+ let _loadCoreForTest = null
29
+ export function _setLoadCoreForTest(fn) { _loadCoreForTest = fn }
30
+ const loadCoreNow = () => (_loadCoreForTest ?? loadCore)()
31
+
21
32
  /** 单次扫描(直驱面——直通核实现;TUI 色表 `C` 注入)。 */
22
33
  export async function runLedgerScan(opts = {}) {
23
- const core = await loadCore()
34
+ const core = await loadCoreNow()
24
35
  return core.runLedgerScan({ ...opts, colors: C })
25
36
  }
26
37
 
27
- /** TUI 挂载(首扫 + 周期——直通核实现;载入失败 → 空句柄 = 无周期,N1 降级不崩)。 */
38
+ /** TUI 挂载(首扫 + 周期——直通核实现):**恒同步返回 `{ dispose }`**——核句柄异步就绪后
39
+ * 桥接;载入失败 ⇒ 空句柄(无周期,N1 降级不崩);`dispose()` 早于核就绪 ⇒ 置位且
40
+ * **跳过启动**(核侧零调用——零周期)。 */
28
41
  export function startLedgerSurface(ctx = {}) {
29
- return loadCore()
30
- .then((core) => core.startLedgerSurface({ ...ctx, colors: C }))
31
- .catch(() => ({ dispose() {} }))
42
+ let disposed = false
43
+ let handle = null
44
+ loadCoreNow()
45
+ .then((core) => { if (!disposed) handle = core.startLedgerSurface({ ...ctx, colors: C }) })
46
+ .catch(() => { /* 载入失败 ⇒ 空句柄(N1) */ })
47
+ return {
48
+ dispose() {
49
+ disposed = true
50
+ handle?.dispose?.()
51
+ },
52
+ }
32
53
  }