thincoder 0.12.59 → 0.12.61

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 (192) hide show
  1. package/CHANGELOG.md +62 -4
  2. package/README.md +10 -8
  3. package/bin/thincoder.mjs +99 -133
  4. package/package.json +6 -4
  5. package/src/abort-provenance.mjs +116 -0
  6. package/src/acp/bridge.mjs +45 -21
  7. package/src/acp.mjs +6 -1
  8. package/src/advisor/citations.mjs +83 -21
  9. package/src/advisor/compaction.mjs +174 -0
  10. package/src/advisor/loop.mjs +293 -0
  11. package/src/advisor/messages.mjs +59 -137
  12. package/src/advisor/project-context.mjs +194 -0
  13. package/src/advisor/repos.mjs +17 -40
  14. package/src/advisor/run.mjs +156 -359
  15. package/src/advisor/truncate.mjs +57 -0
  16. package/src/advisor.mjs +27 -7
  17. package/src/agent/completion.mjs +17 -11
  18. package/src/agent/dispatch.mjs +145 -27
  19. package/src/agent/helpers.mjs +107 -13
  20. package/src/agent/record-results.mjs +55 -11
  21. package/src/agent/relay-prefix.mjs +39 -0
  22. package/src/agent/run-stages.mjs +242 -0
  23. package/src/agent/setup-reminders.mjs +69 -0
  24. package/src/agent/setup.mjs +107 -127
  25. package/src/agent/spawn-child.mjs +55 -13
  26. package/src/agent-tools/advisor-async.mjs +346 -0
  27. package/src/agent-tools/advisor-settle.mjs +231 -0
  28. package/src/agent-tools/advisor.mjs +167 -116
  29. package/src/agent-tools/async-settle.mjs +191 -0
  30. package/src/agent-tools/batch-segment.mjs +195 -0
  31. package/src/agent-tools/consult.mjs +139 -107
  32. package/src/agent-tools/design-token.mjs +117 -0
  33. package/src/agent-tools/digest-budget.mjs +76 -0
  34. package/src/agent-tools/eng.mjs +24 -29
  35. package/src/agent-tools/escalate-async.mjs +289 -0
  36. package/src/agent-tools/read-history.mjs +166 -32
  37. package/src/agent-tools/recent-changes.mjs +2 -1
  38. package/src/agent-tools/review-streak.mjs +93 -0
  39. package/src/agent-tools/settings.mjs +137 -34
  40. package/src/agent-tools/subagent-actions.mjs +180 -133
  41. package/src/agent-tools/subagent-async.mjs +184 -177
  42. package/src/agent-tools/subagent-panel.mjs +160 -0
  43. package/src/agent-tools/subagent-run.mjs +205 -0
  44. package/src/agent-tools/subagent-scheduler.mjs +100 -27
  45. package/src/agent-tools/subagent-spawn.mjs +453 -0
  46. package/src/agent-tools/subagent.mjs +256 -578
  47. package/src/agent-tools/verify.mjs +119 -292
  48. package/src/agent-tools.mjs +1 -0
  49. package/src/agent.mjs +89 -205
  50. package/src/cli/distill-command.mjs +12 -6
  51. package/src/cli/make-agent.mjs +26 -8
  52. package/src/cli/memory-command.mjs +4 -3
  53. package/src/cli/permission.mjs +2 -2
  54. package/src/cli/setup-wizard.mjs +42 -17
  55. package/src/completions.mjs +114 -0
  56. package/src/config-migrate.mjs +70 -0
  57. package/src/config.mjs +180 -63
  58. package/src/context.mjs +5 -147
  59. package/src/conventions.mjs +223 -0
  60. package/src/crash-reports.mjs +128 -0
  61. package/src/distill.mjs +11 -11
  62. package/src/expand-home.mjs +16 -0
  63. package/src/explore-distill.mjs +155 -0
  64. package/src/generate-title.mjs +1 -1
  65. package/src/hooks.mjs +7 -3
  66. package/src/memory/code-index.mjs +9 -3
  67. package/src/memory/code-sync.mjs +72 -32
  68. package/src/memory/core.mjs +6 -193
  69. package/src/memory/delete.mjs +236 -0
  70. package/src/memory/docs.mjs +68 -54
  71. package/src/memory/file-walk.mjs +109 -0
  72. package/src/memory/schema.mjs +15 -3
  73. package/src/memory.mjs +3 -1
  74. package/src/model-ref.mjs +66 -0
  75. package/src/model-specs.mjs +42 -8
  76. package/src/peer-domains.mjs +265 -0
  77. package/src/peer-instances.mjs +231 -0
  78. package/src/prompt-overlays.mjs +82 -0
  79. package/src/prompts/advisor-design.md +18 -75
  80. package/src/prompts/advisor-round1.md +14 -67
  81. package/src/prompts/advisor-round2.md +15 -51
  82. package/src/prompts/advisor-round3.md +15 -51
  83. package/src/prompts/common.md +115 -0
  84. package/src/prompts/consult-base.md +5 -23
  85. package/src/prompts/discipline-engineering.md +217 -0
  86. package/src/prompts/discipline-normal.md +179 -0
  87. package/src/prompts/persona-coder.md +21 -0
  88. package/src/prompts/persona-eng-coder.md +37 -0
  89. package/src/prompts/persona-eng-designer.md +55 -0
  90. package/src/prompts/persona-engineering.md +54 -0
  91. package/src/prompts/persona-explore.md +15 -0
  92. package/src/prompts/persona-normal.md +27 -0
  93. package/src/prompts/persona-plan.md +26 -0
  94. package/src/provider/anthropic.mjs +4 -4
  95. package/src/provider/core.mjs +18 -98
  96. package/src/provider/errors.mjs +101 -0
  97. package/src/provider/google.mjs +5 -6
  98. package/src/provider/index.mjs +2 -1
  99. package/src/provider/list-models.mjs +93 -0
  100. package/src/provider/rate.mjs +2 -1
  101. package/src/provider/responses.mjs +5 -3
  102. package/src/provider/retry.mjs +8 -45
  103. package/src/provider/sse.mjs +3 -4
  104. package/src/proxy.mjs +9 -14
  105. package/src/session-gc.mjs +214 -0
  106. package/src/session-guard.mjs +47 -0
  107. package/src/session-rename.mjs +38 -0
  108. package/src/session-slots.mjs +188 -60
  109. package/src/session.mjs +104 -124
  110. package/src/token-ttl.mjs +274 -0
  111. package/src/tools/{system.mjs → bash.mjs} +19 -221
  112. package/src/tools/checklist-sync.mjs +181 -0
  113. package/src/tools/checklist.mjs +52 -39
  114. package/src/tools/edit-batch.mjs +109 -10
  115. package/src/tools/edit-diff.mjs +110 -27
  116. package/src/tools/edit.md +17 -12
  117. package/src/tools/execute.mjs +31 -4
  118. package/src/tools/file.mjs +41 -16
  119. package/src/tools/git.md +1 -1
  120. package/src/tools/git.mjs +23 -34
  121. package/src/tools/glob-dialect.mjs +130 -0
  122. package/src/tools/glob.md +3 -3
  123. package/src/tools/grep.md +1 -1
  124. package/src/tools/index.mjs +9 -8
  125. package/src/tools/ops.mjs +188 -3
  126. package/src/tools/patch.mjs +3 -3
  127. package/src/tools/question.md +4 -0
  128. package/src/tools/question.mjs +26 -0
  129. package/src/tools/read.md +1 -2
  130. package/src/tools/read_image.md +1 -1
  131. package/src/tools/search.mjs +236 -0
  132. package/src/tools/shared.mjs +14 -13
  133. package/src/tools/wait_for.md +22 -0
  134. package/src/tui/agent-turn.mjs +36 -228
  135. package/src/tui/ansi.mjs +2 -0
  136. package/src/tui/clipboard.mjs +7 -1
  137. package/src/tui/cmd-advisor.mjs +3 -2
  138. package/src/tui/cmd-config.mjs +142 -30
  139. package/src/tui/cmd-eng.mjs +28 -40
  140. package/src/tui/cmd-exit.mjs +6 -8
  141. package/src/tui/cmd-mcp.mjs +8 -2
  142. package/src/tui/cmd-model.mjs +14 -12
  143. package/src/tui/cmd-new.mjs +3 -2
  144. package/src/tui/cmd-reindex.mjs +7 -0
  145. package/src/tui/cmd-session.mjs +19 -4
  146. package/src/tui/cmd-submodel.mjs +8 -5
  147. package/src/tui/cmd-think.mjs +10 -10
  148. package/src/tui/cmd-undo.mjs +4 -3
  149. package/src/tui/cmd-upgrade.mjs +19 -4
  150. package/src/tui/config-helpers.mjs +28 -16
  151. package/src/tui/distill-cmd.mjs +1 -1
  152. package/src/tui/index.mjs +40 -38
  153. package/src/tui/interaction.mjs +3 -3
  154. package/src/tui/key-handler.mjs +61 -17
  155. package/src/tui/key-modes.mjs +86 -8
  156. package/src/tui/layout.mjs +18 -10
  157. package/src/tui/model-catalog.mjs +89 -0
  158. package/src/tui/model-picker.mjs +498 -0
  159. package/src/tui/mouse.mjs +52 -9
  160. package/src/tui/pickers.mjs +28 -392
  161. package/src/tui/render-frame.mjs +32 -16
  162. package/src/tui/render-loop.mjs +2 -0
  163. package/src/tui/render-segments.mjs +12 -9
  164. package/src/tui/render.mjs +37 -5
  165. package/src/tui/slash-commands.mjs +2 -2
  166. package/src/tui/startup.mjs +4 -0
  167. package/src/tui/subagent-blocks.mjs +106 -295
  168. package/src/tui/subagent-children.mjs +162 -0
  169. package/src/tui/subagent-freeze.mjs +169 -0
  170. package/src/tui/subagent-panel.mjs +24 -31
  171. package/src/tui/suspension-drive.mjs +297 -0
  172. package/src/tui/tool-args.mjs +7 -5
  173. package/src/tui/tool-display.mjs +143 -0
  174. package/src/tui/tool-events.mjs +56 -185
  175. package/src/tui/tui-lifecycle.mjs +46 -4
  176. package/src/tui/update-notice.mjs +4 -0
  177. package/src/tui/wizard.mjs +61 -21
  178. package/src/tui/wrapped-spawn.mjs +38 -0
  179. package/src/prompts/coder.md +0 -56
  180. package/src/prompts/discipline.md +0 -102
  181. package/src/prompts/eng-coder.md +0 -44
  182. package/src/prompts/engineering-sub.md +0 -34
  183. package/src/prompts/engineering.md +0 -340
  184. package/src/prompts/explore.md +0 -21
  185. package/src/prompts/main.md +0 -56
  186. package/src/prompts/methodology-template.md +0 -58
  187. package/src/prompts/plan.md +0 -16
  188. package/src/prompts/system.md +0 -63
  189. package/src/tools/pdf-parse-text.mjs +0 -497
  190. package/src/tools/pdf-parse-xref.mjs +0 -499
  191. package/src/tools/pdf.mjs +0 -155
  192. package/src/tools/read_pdf.md +0 -21
package/src/tui/index.mjs CHANGED
@@ -25,16 +25,16 @@ import { closeAllMcp } from "../mcp.mjs"
25
25
  import { ansi, C } from "./ansi.mjs"
26
26
  import { createRenderLoop } from "./render-loop.mjs"
27
27
  import { makeDimsState } from "./dims.mjs"
28
- import { SLASH_COMMANDS, SLASH_ALIASES, createSlashCommands } from "./slash-commands.mjs"
28
+ import { SLASH_COMMANDS, createSlashCommands } from "./slash-commands.mjs"
29
29
  import { createWizard } from "./wizard.mjs"
30
- import { writeStartupSequence, createExitCleanup } from "./tui-lifecycle.mjs"
30
+ import { writeStartupSequence, setTuiActive, createExitCleanup } from "./tui-lifecycle.mjs"
31
31
  import { createPickers } from "./pickers.mjs"
32
32
  import { runDistill as runDistillImpl } from "./distill-cmd.mjs"
33
33
  import { createInteraction } from "./interaction.mjs"
34
34
  import { pasteClipboardImage as pasteClipboardImageImpl, insertPastedText, translateShiftEnter, stripKeyboardProtocol } from "./clipboard.mjs"
35
- import { parseMouseClicks, handleWheel, createMouseDispatch } from "./mouse.mjs"
35
+ import { parseMouseClicks, handleWheel, createMouseDispatch, mouseOob } from "./mouse.mjs"
36
36
  import { runAgentTurn } from "./agent-turn.mjs"
37
- import { createKeyHandler, convMaxScroll } from "./key-handler.mjs"
37
+ import { createKeyHandler, convMaxScroll, clearAttention } from "./key-handler.mjs"
38
38
  import { showStartup, backgroundIndex, createLoadOlder } from "./startup.mjs"
39
39
  import { shiftFreezeAnchors } from "./subagent-blocks.mjs"
40
40
  import { createConfigHelpers } from "./config-helpers.mjs"
@@ -53,7 +53,12 @@ export async function promptProviderIfInvalid(agent, openModelPicker, pushLine)
53
53
  if (!(agent._providerInvalid || !agent.provider)) return false
54
54
  await openModelPicker()
55
55
  if (!agent.provider) {
56
- pushLine("未配置有效 provider,可用 /model 选择或 /provider 配置", C.warn)
56
+ // MODEL-MERGE-SESSION 引导 A(F-6):空槽 + defaultModel 未设 → 提示 /config 默认模型入口
57
+ // (index.mjs 本行为 D-S2 取消提示扩展——设计文件清单外——随批上报)
58
+ const hasProviders = (agent.providers?.length ?? 0) > 0
59
+ pushLine(hasProviders && !agent.config?.defaultModel
60
+ ? "尚未设置默认模型(config.defaultModel——新会话起点):/config → 默认模型 设置一次;/model 仅改本会话"
61
+ : "未配置有效 provider,可用 /model 选择或 /provider 配置", C.warn)
57
62
  }
58
63
  return true
59
64
  }
@@ -106,12 +111,13 @@ export async function startTUI(agent, opts = {}) {
106
111
  reasoning: "", // thinking stream buffer (dimmed display)
107
112
  completion: null, // Tab completion state { candidates, index }
108
113
 
109
- subTasks: {}, // sub-agent activity blocks (§7.2 D4): { "coder#1": { key, role, model, started, done, doneAt, blocks: [{kind,text}], currentTool, toolArgs, turn, maxTurns, approval, lastError, dropped, blockEpoch, awaitingDigest(§17 挂起中间态), _freezeAt(冻结锚点) } } — rendered as collapsible in-conversation blocks; persists across turns (blocks are the child activity's ONLY carrier — child tool calls never enter the parent history); bounded by the N2 500-line per-child ring buffer
114
+ subTasks: {}, // sub-agent activity blocks (§7.2 D4): { "coder#1": { key, role, model, started, done, doneAt, blocks: [{kind,text}], currentTool, toolArgs, turn, maxTurns, approval, lastError, dropped, blockEpoch, awaitingDigest(§17 挂起中间态), _freezeAt(冻结锚点), stopped, children: [](SUBAGENT-TAIL:嵌套子代理**守护载体**——内容行并入本块 blocks——subagent-children.mjs) } } — rendered as collapsible in-conversation blocks; persists across turns (blocks are the child activity's ONLY carrier — child tool calls never enter the parent history); bounded by the N2 500-line per-child ring buffer(SUBAGENT-TAIL 单环:内层行同环计数、单载体最旧先行——TUI.md §6)
110
115
  currentTool: null, // currently executing tool name (shown in status bar)
111
116
  processingStarted: 0, // current turn start time (status bar timer)
112
117
  status: "Ready",
113
- queue: [], // queued messages while processing: [{ text }], auto-dequeued when current turn finishes
114
- interruptPrompt: null, // Ctrl+I interrupt message input: { text: "" } or null
118
+ queue: [], // 交接残项单容器 [{ text }](INPUT-LOCK:submit 不再排队——仅释放窗口兜底/挂起中止残余——回合尾队列循环续发,至多一条)
119
+ interruptPrompt: null, // Ctrl+I inject box(第 31 批——TUI-INPUT-BOX.md §8): { chars: string[], cursor: number } or null
120
+ attentionAwaiting: false, // 第 33 批(TUI §14.3(f)):回合结束等待输入——链尾置位 / 用户输入清位;不落盘、不进会话
115
121
  search: null, // Ctrl+F search mode: { query: "", matches: [{lineIndex, charIndex}], index: 0 } or null
116
122
  expandedBlocks: new Set(), // block hashes that are expanded (Enter toggles)
117
123
  foldEnabled: true, // global fold toggle — /fold on|off
@@ -121,13 +127,15 @@ export async function startTUI(agent, opts = {}) {
121
127
  _historyLoaded: 0, // messages loaded from the TAIL of _fullHistory
122
128
  _historyTotal: 0, // total messages in the restored session
123
129
  _hasOlder: false, // more earlier messages remain unloaded
124
- _agent: null, // §19.6 D-P1 面板镜像挂载点:startTUI 装配时置 agent 引用——subagent-blocks
125
- // syncPanelSnapshot 经此写 agent._panelSnapshot(块级状态变更即刷——subagent
126
- // action:"panel" 读——模型可查面板视图 + 门控补发冻结)。headless/VS Code 无此
127
- // 挂载 = 无镜像 → panel 动作恒降级池视图(CLI-only 完整能力——AC-P4)。
130
+ _agent: null, // TUI state → agent 回指挂载点:startTUI 装配时置 agent 引用——
131
+ // SYNC-CANCEL ⏹ 门控读 state._agent._syncChildAborts(subagent-panel.mjs,2026-09-09);
132
+ // CLI-ACTIVITY-DEBLOAT F-3(2026-09-10):agent._tuiState = state 反向挂载——
133
+ // action:"panel" ctx.state(= agent._tuiState)**读时现算**面板块(computePanelBlocks
134
+ // ——subTasks 活值纯推导——单账本)。headless/VS Code 无此装配 = 无面板 → panel 动作
135
+ // 恒降级池视图(CLI-only 完整能力——AC-P4)。
128
136
  }
129
137
  state._agent = agent
130
- agent._panelSnapshot = [] // 镜像初始态 = 空面板(非"无镜像"——[] 与 undefined 区分)
138
+ agent._tuiState = state // F-3:面板视图现算通道(替代退役的手工面板镜像)
131
139
 
132
140
  // On session restore, if all tasks are completed, auto-collapse the todo panel (match runtime behavior)
133
141
  if (state.tasks.length > 0 && state.tasks.every((t) => t.status === "done")) {
@@ -147,6 +155,7 @@ export async function startTUI(agent, opts = {}) {
147
155
  // modifyOtherKeys lvl 2 (\x1b[>4;2m): Shift+Enter → \x1b[27;2;13~ (mintty / Git Bash)
148
156
  // translateShiftEnter (stdin layer) maps both to \x1b\r → meta+return → multiline branch.
149
157
  writeStartupSequence()
158
+ setTuiActive(true) // R25(F-R25a):终端接管完成——置 TUI 活动态(崩溃钩子恢复判定源)
150
159
 
151
160
  const utf8Decoder = new TextDecoder("utf-8", { fatal: false })
152
161
 
@@ -159,6 +168,10 @@ export async function startTUI(agent, opts = {}) {
159
168
  let text = mousePending + utf8Decoder.decode(chunk, { stream: true })
160
169
  mousePending = ""
161
170
 
171
+ // 第 33 批(TUI §14.3(e) 鼠标点):stdin 数据到达即用户在场 ⇒ 清 attention 位
172
+ // (单点覆盖下方滚轮分支与 onMouseClick;键盘入口在 key-handler)。
173
+ clearAttention(state, render)
174
+
162
175
  // Bracketed paste: terminal wraps pasted text in \x1b[200~ ... \x1b[201~
163
176
  // Route pasted content to the active text target (question answer / input box) in one shot,
164
177
  // avoiding slow char-by-char keypress render — see insertPastedText in clipboard.mjs
@@ -208,6 +221,10 @@ export async function startTUI(agent, opts = {}) {
208
221
  for (const m of text.matchAll(/\x1b\[<(\d+);(\d+);(\d+)([Mm])/g)) {
209
222
  const button = Number(m[1])
210
223
  if (button === 64 || button === 65) {
224
+ // F-3 sane-gate ③(RESIZE-MOUSE-LEAK-FIX):越界 wheel 禁止会话滚动——handleWheel
225
+ // 对越界返回未消费会穿到 fallback 滚动(mouse.mjs 内 gate 覆盖不到此路径)
226
+ const dims = state.dims ? state.dims.get() : { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 }
227
+ if (mouseOob(Number(m[2]), Number(m[3]), dims)) continue
211
228
  const consumed = handleWheel(mouseCtx(), button, Number(m[2]), Number(m[3]))
212
229
  if (!consumed) {
213
230
  if (button === 64) {
@@ -313,6 +330,14 @@ export async function startTUI(agent, opts = {}) {
313
330
  async function submit() {
314
331
  const text = state.input.join("").trim()
315
332
  if (!text) return
333
+ // INPUT-LOCK 防御(C'——2026-09-09)+ INPUT-LOCK-BEHAVIOR-REVISED(2026-09-09——白名单
334
+ // 已删——busy 全拒):提交吞的门禁在 key-handler(busy Enter 已吞 + 提示,文本保留)——
335
+ // submit 只在非 busy 期达此;防御直呼/上游改动:busy 期拒绝(不清输入框不吞内容)。
336
+ if (state.processing) {
337
+ pushLine(`[主会话处理中 —— 消息未发送(回合结束后请重按 Enter)]`, C.warn)
338
+ render()
339
+ return
340
+ }
316
341
  state.input = []
317
342
  state.cursor = 0
318
343
  const wasInHistory = state.historyIndex !== -1
@@ -322,37 +347,14 @@ export async function startTUI(agent, opts = {}) {
322
347
  state.scroll = 0
323
348
  state._followTail = true // 2026-08-31 会诊 deepseek:新消息恢复跟随(注释曾承诺、实现缺漏)
324
349
 
325
- // Slash commands: handled locally, don't enter agent loop
350
+ // Slash commands: handled locally, don't enter agent loop. busy 期提交在 key-handler
351
+ // 门禁被吞(白名单已删——斜杠同吞——INPUT-LOCK-BEHAVIOR-REVISED)——submit 仅非 busy 期可达。
326
352
  if (text.startsWith("/")) {
327
- if (state.processing) {
328
- // While processing: allowlisted commands execute directly (they only touch
329
- // local TUI/agent config, never the in-flight turn); the rest are queued
330
- const cmd0 = text.split(/\s+/)[0].toLowerCase()
331
- const resolved0 = SLASH_ALIASES[cmd0] ?? cmd0
332
- const safeDuringProcessing = new Set(["/help", "/exit", "/model", "/submodel", "/shell", "/think", "/config", "/skills", "/mcp", "/goal", "/session"])
333
- if (safeDuringProcessing.has(resolved0)) {
334
- await handleSlash(text)
335
- render()
336
- } else {
337
- state.queue.push({ text })
338
- render()
339
- }
340
- return
341
- }
342
353
  await handleSlash(text)
343
354
  render()
344
355
  return
345
356
  }
346
357
 
347
- // While processing: queue for later, don't execute immediately.
348
- // The queue panel (renderQueue) already shows pending items — don't also
349
- // push to the conversation area, or the text scrolls up with streaming tokens.
350
- if (state.processing) {
351
- state.queue.push({ text })
352
- render()
353
- return
354
- }
355
-
356
358
  await turn(text)
357
359
  }
358
360
 
@@ -47,9 +47,9 @@ export function createInteraction(ctx) {
47
47
  const action = String(args.action ?? "")
48
48
  if (action === "put") return [`[${args.type ?? ""}] ${args.title ?? ""}`, ...cap(args.content ?? "", 500).split("\n")]
49
49
  if (action === "delete") return args.id
50
- ? [`delete single: ${args.id} (scope ${args.scope})`]
51
- : [`batch delete scope=${args.scope} type=${args.type ?? ""} keyword=${args.keyword ?? ""}`, `confirm=${args.confirm}`]
52
- if (action === "clear") return [`clear personal memory`, `scope=${args.scope} confirm=${args.confirm}`]
50
+ ? [`delete single: ${args.id}${args.layer ? ` (layer ${args.layer})` : ""}`]
51
+ : [`batch delete layer=${args.layer ?? ""} type=${args.type ?? ""} keyword=${args.keyword ?? ""}`, `confirm=${args.confirm}`]
52
+ if (action === "clear") return [`clear personal memory`, `layer=${args.layer ?? ""} confirm=${args.confirm}`]
53
53
  return [cap(summarize(args), 300)]
54
54
  }
55
55
  return [cap(summarize(args), 300)]
@@ -1,10 +1,22 @@
1
1
  import { ansi, C } from "./ansi.mjs"
2
2
  import { readClipboardText, insertPastedText } from "./clipboard.mjs"
3
- import { computeLayout } from "./layout.mjs"
3
+ import { computeLayout, inputContentWidth } from "./layout.mjs"
4
+ import { moveCursorVertical } from "./render.mjs"
4
5
  import { handleSearchKey } from "./key-handler-search.mjs"
5
6
  import { countConvLines } from "./render-conversation.mjs"
6
7
  import { handlePermissionMode, handleQuestionMode, handleInterruptMode } from "./key-modes.mjs"
7
8
 
9
+ /** 第 33 批(TUI §14.3(e)——attention 清位):输入即在场——仅当原值为真时置假 + `render()`
10
+ * (已假则零副作用、零重绘)。键盘入口与本文件(`onKeypress`)与鼠标输入路径(index.mjs
11
+ * stdin data 处理器)两点共用;blocked 两态无需清位(实时派生)。
12
+ * @returns {boolean} 本次是否发生清位 */
13
+ export function clearAttention(state, render) {
14
+ if (!state.attentionAwaiting) return false
15
+ state.attentionAwaiting = false
16
+ render()
17
+ return true
18
+ }
19
+
8
20
  /** Current conversation max scroll offset (display lines beyond the visible panel). */
9
21
  export function convMaxScroll(state) {
10
22
  const d = state.dims ? state.dims.get() : {} // Single source (ConPTY instability, 2026-08-30) — cached dims
@@ -24,6 +36,9 @@ export function createKeyHandler(ctx) {
24
36
  const { agent, state, render, popPicker, renderPickerLines, handleSlash, handleTab, submit, pasteClipboardImage, wizardChooseProvider, wizardSubmitText, cancelWizard, wizardProviderItems, renderWizard, pushLine, cleanup, showPicker, loadOlder } = ctx
25
37
 
26
38
  return function onKeypress(str, key = {}) {
39
+ // 第 33 批(TUI §14.3(e) 键盘点):模态分派**之前**——任意按键 = 用户在场 ⇒ 清 attention
40
+ // 位(仅复位呈现态字段,按键语义 / 模态判定 / busy 门禁零改)。
41
+ clearAttention(state, render)
27
42
  // permission confirm: y/n/a (a = approve + AUTO ON); batch (§16 D-B1): a/o/n (Esc = deny)
28
43
  // ——模态实现 key-modes.mjs handlePermissionMode(2026-09-03 D-S4)
29
44
  if (handlePermissionMode(str, key, { state, agent, pushLine, render })) return
@@ -59,12 +74,13 @@ export function createKeyHandler(ctx) {
59
74
  state.suspended
60
75
  if (hasStopTarget) {
61
76
  // 当前回合平 abort(无 interrupt——命中 agent.mjs 回合收尾清池分支——全停)
62
- if (state.processing && state.controller) state.controller.abort()
77
+ // §20.3 站点 #11(第 24 批):整批 / 会话级停 = stop(reason 载荷)
78
+ if (state.processing && state.controller) state.controller.abort({ abortTrigger: "stop", abortDetail: "session-stop" })
63
79
  // 会话 abort 集合 = 链条内全部 controller(含 Ctrl+I/ContinueError 重建的旧
64
80
  // controller——children 不逃逸,round1 偏差 #3)。挂起态才标记 _suspAborted +
65
81
  // 唤醒 driver(driver 收尾清池)——非挂起语境置位会粘滞阻塞未来挂起会话重入
66
82
  // (round2 偏差 #1 语义)。
67
- for (const c of agent._sessionAbortAll ?? (agent._sessionAbort ? [agent._sessionAbort] : [])) c?.abort()
83
+ for (const c of agent._sessionAbortAll ?? (agent._sessionAbort ? [agent._sessionAbort] : [])) c?.abort({ abortTrigger: "stop", abortDetail: "session-stop" })
68
84
  if (state.suspended) {
69
85
  state._suspAborted = true
70
86
  state._suspWake?.()
@@ -159,7 +175,8 @@ export function createKeyHandler(ctx) {
159
175
  // Ctrl+I (or Tab during processing): interrupt and inject a message
160
176
  if ((key.ctrl && !key.alt && key.name === "i") || (key.name === "tab" && state.processing && !state.interruptPrompt)) {
161
177
  if (state.processing && state.controller && !state.interruptPrompt) {
162
- state.interruptPrompt = { text: "" }
178
+ // 注入框状态模型(第 31 批——TUI-INPUT-BOX.md §1 不变量 9):{ chars, cursor },空态 = { chars: [], cursor: 0 }
179
+ state.interruptPrompt = { chars: [], cursor: 0 }
163
180
  render()
164
181
  }
165
182
  return
@@ -263,17 +280,19 @@ export function createKeyHandler(ctx) {
263
280
  }
264
281
 
265
282
  if (state.processing) {
266
- // during processing, allow input (queued), but block arrow-key history and Tab completion
267
- if (key.name === "tab" || key.name === "up" || key.name === "down") return
268
- // Ctrl+D: remove last item from queue
269
- if (key.ctrl && key.name === "d") {
270
- if (state.queue.length > 0) {
271
- state.queue.pop()
272
- render()
273
- }
283
+ // busy 门禁(INPUT-LOCK C'——2026-09-09 + INPUT-LOCK-BEHAVIOR-REVISED——2026-09-09):
284
+ // processing(含 digest——单一判据)输入不禁——吞提交不吞字符(打字照常回显);Enter
285
+ // 提交吞 + busy 提示——斜杠同禁发(白名单已删——/exit 也发不出——退出靠 Ctrl+C 终端
286
+ // 层通道——门禁前不误伤);空 Enter 静默(text 非空才吞);多行换行
287
+ // (meta/enter——编辑)照常放行。**第 31 批**:↑↓ 不再在此全屏蔽——分流下沉到下方
288
+ // 三规则块(竖移放行 / 历史导航禁;TUI-INPUT-BOX.md §3);Tab 仍吞(死条件在案——原样保留)。
289
+ if (key.name === "tab") return
290
+ const isSend = (key.name === "return" && !key.meta) || (str === "\r" && !key.meta)
291
+ if (isSend && state.input.join("").trim()) {
292
+ pushLine(`[主会话处理中 —— 消息未发送(回合结束后请重按 Enter)]`, C.warn)
293
+ render()
274
294
  return
275
295
  }
276
- // remaining printable characters go into input box normally
277
296
  }
278
297
 
279
298
  // Tab: slash-command completion (cycle candidates); other input ignored (\t would blow up input box, never inserted directly)
@@ -291,7 +310,24 @@ export function createKeyHandler(ctx) {
291
310
  return
292
311
  }
293
312
 
294
- // input history
313
+ // ↑/↓ 三规则(TUI-INPUT-BOX.md §3——第 31 批):① 翻历史中恒历史(多行条目不竖移)
314
+ // ② 竖移优先(可视行口径——折行与 \n 同权;显示列保持 + 短行端钳制)③ 边界回落历史(↑)
315
+ // /无动作(↓)。processing 期:竖移放行(纯编辑——与字符/退格/←→ 同权)、历史导航禁
316
+ // (规则 ①/③ 的历史分支吞——不进入、不切换)。
317
+ if (key.name === "up" || key.name === "down") {
318
+ if (state.historyIndex === -1) { // 规则 ① 优先——翻历史中不竖移
319
+ const cols = (state.dims?.get() ?? {}).cols ?? (process.stdout.columns || 80)
320
+ const next = moveCursorVertical(state.input, state.cursor, inputContentWidth(cols), key.name)
321
+ if (next !== null) {
322
+ state.cursor = next // 规则 ②:竖移(草稿/历史指针零涉——D-31.6)
323
+ render()
324
+ return
325
+ }
326
+ }
327
+ if (state.processing) return // 规则 ①/③ 的历史分支吞——processing 期历史导航禁
328
+ }
329
+
330
+ // input history(规则 ① 恒历史 + 规则 ③ 边界回落——历史语义零改)
295
331
  if (key.name === "up") {
296
332
  if (state.history.length) {
297
333
  // Save current input as draft when entering history mode (historyIndex === -1).
@@ -372,15 +408,23 @@ export function createKeyHandler(ctx) {
372
408
  render()
373
409
  } else {
374
410
  const text = state.input.join("").trim()
375
- // §17 D-S5/F3/F7 + 偏差 #1:挂起态(suspended 或释放窗口 _suspPending)Enter =
376
- // 新回合输入(非打断)——入 pendingInput 由挂起会话调度;输入框零干扰(F3)。
411
+ // §17 D-S5/F3/F7 + 偏差 #1 + INPUT-LOCK 单槽化(F-6——2026-09-09):挂起态
412
+ // (suspended 或释放窗口 _suspPending)Enter = 新回合输入(非打断)——填
413
+ // pendingInput 单槽(至多一条待交接——busy 提交吞在 L265 门禁先拦,digest 期
414
+ // 不会到这——仅挂起空闲/释放窗口触达)由挂起会话调度;输入框零干扰(F3)。
415
+ // 槽满(竞态防御——不覆盖不丢失)→ 吞 + 提示,文本保留在输入框。
377
416
  if ((state.suspended || state._suspPending) && text && !text.startsWith("/")) {
417
+ state.pendingInput ??= []
418
+ if (state.pendingInput.length > 0) {
419
+ pushLine(`[主会话处理中 —— 已有一条消息待发送,请等其处理完成后再发送]`, C.warn)
420
+ render()
421
+ return
422
+ }
378
423
  state.input = []
379
424
  state.cursor = 0
380
425
  state.history.push(text)
381
426
  state.historyIndex = -1
382
427
  state._draft = null
383
- state.pendingInput ??= []
384
428
  state.pendingInput.push(text)
385
429
  state._suspWake?.()
386
430
  render()
@@ -8,6 +8,31 @@
8
8
  import { C } from "./ansi.mjs"
9
9
  import { readClipboardText, insertPastedText } from "./clipboard.mjs"
10
10
  import { QUESTION_CUSTOM } from "./interaction.mjs"
11
+ import { inputContentWidth } from "./layout.mjs"
12
+ import { moveCursorVertical } from "./render.mjs"
13
+
14
+ /** SYNC-CANCEL v2 模态 deny 解绕(用户裁——2026-09-09):⏹ 定向中止 sync child 时
15
+ * 顺带 deny 其 pending 权限/continue 模态——ask 不观 signal(R1——targeted abort 后
16
+ * child 停在模态则 runChildPipeline 不返回、父回合阻塞至模态回答)。owner 判定:
17
+ * 工具权限 ask name 前缀 `${key}/`(subagent-spawn.mjs 装配——owner key 标识);continue
18
+ * ask args.agent === key(subagent.mjs askSubagentContinue 既有参数)。副作用镜像
19
+ * handlePermissionMode deny 分支(清除模态 + deny 轨迹 + resolve(false)——子代理随即
20
+ * 在 abort 检出点解绕折叠)。返回 true = 本模态属该 child 且已 deny 解绕。鼠标 ⏹ 路径
21
+ * (mouse.mjs cancelSubagent)调用——key-modes 是权限模态归属语义的驻点。 */
22
+ export function denyModalForOwner(state, key, { pushLine, render } = {}) {
23
+ const p = state?.permission
24
+ if (!p) return false
25
+ const owned = (typeof p.name === "string" && p.name.startsWith(`${key}/`)) || p.args?.agent === key
26
+ if (!owned) return false
27
+ const { resolve, name } = p
28
+ state.permission = null
29
+ state.permissionPreview = []
30
+ state.status = "Processing..."
31
+ pushLine?.(` [denied] ${name}`, C.error)
32
+ resolve(false)
33
+ render?.()
34
+ return true
35
+ }
11
36
 
12
37
  /** permission 确认模态:y/n/a(a = approve + AUTO ON);batch(§16 D-B1):a/o/n(Esc = deny)。
13
38
  * consume:valid 键或 Esc 走 resolve 分支;其余键静默吞掉(模态独占)。 */
@@ -68,10 +93,11 @@ export function handleQuestionMode(str, key, ctx) {
68
93
  state.status = "Processing..."
69
94
  render()
70
95
  } else if (key.name === "up") {
71
- q.selected = Math.max(0, (q.selected ?? 0) - 1)
96
+ // A4(第 20 批 §12.4 契约——同义键同形):↑↓ 环绕(原钳位——对齐 picker/wizard 既有语义)
97
+ q.selected = ((q.selected ?? 0) - 1 + q.options.length) % q.options.length
72
98
  render()
73
99
  } else if (key.name === "down") {
74
- q.selected = Math.min(q.options.length - 1, (q.selected ?? 0) + 1)
100
+ q.selected = ((q.selected ?? 0) + 1) % q.options.length
75
101
  render()
76
102
  } else if (key.name === "return") {
77
103
  const answer = q.options[q.selected ?? 0]
@@ -182,16 +208,21 @@ export function handleQuestionMode(str, key, ctx) {
182
208
  }
183
209
 
184
210
  /** interruptPrompt 模态(Ctrl+I 后输入注入消息):Enter 提交 → controller.abort
185
- * { interrupt: true, message };Esc 取消;字符进 prompt;其余键静默吞(模态独占)。
211
+ * { interrupt: true, message };Esc 取消;编辑键 = §7.2 最小集 + 四方向键(第 31 批:
212
+ * `{ chars, cursor }` codepoint 模型——TUI-INPUT-BOX.md §8.1);其余键静默吞(模态独占)。
186
213
  * 返回 false 当模态未激活(key-handler 先检 state.interruptPrompt 入口再调)。 */
187
214
  export function handleInterruptMode(str, key, ctx) {
188
215
  const { state, pushLine, render } = ctx
189
216
  if (!state.interruptPrompt) return false
217
+ const p = state.interruptPrompt
218
+ if (!Array.isArray(p.chars)) p.chars = p.chars ? [...p.chars] : [] // 形态防御(同 question 自由文本态)
219
+ p.cursor = Math.max(0, Math.min(p.cursor ?? p.chars.length, p.chars.length))
220
+ const len = p.chars.length
190
221
  if (key.name === "escape") {
191
222
  state.interruptPrompt = null
192
223
  render()
193
224
  } else if (key.name === "return") {
194
- const msg = (state.interruptPrompt.text ?? "").trim()
225
+ const msg = p.chars.join("").trim()
195
226
  state.interruptPrompt = null
196
227
  if (msg) {
197
228
  // Guard: if the turn already finished while the user was typing, the controller
@@ -204,12 +235,59 @@ export function handleInterruptMode(str, key, ctx) {
204
235
  }
205
236
  render()
206
237
  }
207
- } else if (key.name === "backspace") {
208
- state.interruptPrompt.text = state.interruptPrompt.text.slice(0, -1)
238
+ } else if (key.name === "enter") {
239
+ // Ctrl+J(\n name "enter")= no-op 吞——单行不变式:不插换行(§8.1
240
+ } else if (key.name === "left") {
241
+ p.cursor = Math.max(0, p.cursor - 1)
242
+ render()
243
+ } else if (key.name === "right") {
244
+ p.cursor = Math.min(len, p.cursor + 1)
245
+ render()
246
+ } else if (key.name === "up" || key.name === "down") {
247
+ // 折行竖移(§8.1):显示列保持 + 短行端钳制;无邻行 = 吞(零副作用——无历史回落)
248
+ const cols = (state.dims?.get() ?? {}).cols ?? (process.stdout.columns || 80)
249
+ const next = moveCursorVertical(p.chars, p.cursor, inputContentWidth(cols), key.name)
250
+ if (next !== null) {
251
+ p.cursor = next
252
+ render()
253
+ }
254
+ } else if (key.name === "home") {
255
+ p.cursor = 0
209
256
  render()
210
- } else if (str && !key.ctrl && !key.meta) {
211
- state.interruptPrompt.text += str.replace(/[\r\n]+/g, "")
257
+ } else if (key.name === "end") {
258
+ p.cursor = len
212
259
  render()
260
+ } else if (key.ctrl && !key.alt && key.name === "u") {
261
+ p.chars = []
262
+ p.cursor = 0
263
+ render()
264
+ } else if (key.name === "backspace") {
265
+ if (p.cursor > 0) {
266
+ p.chars.splice(p.cursor - 1, 1) // codepoint 元素删除——emoji 不劈半
267
+ p.cursor--
268
+ render()
269
+ }
270
+ } else if (key.ctrl && !key.alt && !key.meta && key.name === "v") {
271
+ // Ctrl+V 粘贴:落 cursor 位置(\n/\r 去除、\t → 2 空格——insertPastedText 注入框分支)。
272
+ // 异步读剪贴板期间框可能已 Esc/Enter 关闭 → 回调守卫比对同一引用,防落主输入框
273
+ // (同 question 态 stale-paste 守卫语义;不加守卫会静默落 state.input)。
274
+ const target = p
275
+ readClipboardText().then((text) => {
276
+ if (text && state.interruptPrompt === target) {
277
+ insertPastedText(state, text)
278
+ render()
279
+ }
280
+ }).catch((e) => console.error(`[tui] clipboard paste failed: ${e.message}`))
281
+ } else if (str && !key.ctrl && !key.meta && key.name !== "tab" && key.name !== "enter") {
282
+ // 可打印字符插入光标位置(codepoint 拆字);\r\n 剥离 + \t→2 空格(单行不变式)
283
+ const chars = [...str.replace(/[\r\n]+/g, "").replace(/\t/g, " ")]
284
+ if (chars.length) {
285
+ p.chars.splice(p.cursor, 0, ...chars)
286
+ p.cursor += chars.length
287
+ render()
288
+ }
213
289
  }
290
+ // 未列键(Delete/PgUp/PgDn/F1/…)一律 consume return——无 fall-through(模态独占;§8.1:
291
+ // Delete 不引入——与 §7.2 最小集对齐)
214
292
  return true
215
293
  }
@@ -3,7 +3,7 @@
3
3
  * Computes position and height of each panel from state + terminal dimensions.
4
4
  * Does not modify state — side effects are performed by the caller before rendering.
5
5
  *
6
- * header → conversation → subagent 面板 → todo → picker → permission → queue → input → status
6
+ * header → conversation → subagent 面板 → todo → picker → permission → input → status
7
7
  * Running subagent activity renders in a FIXED bottom panel between the
8
8
  * conversation and the todo panel (AGENT-LOOP.md §7.2.1) — full adaptive height
9
9
  * (the conversation shrinks); compressed away first on small terminals (to 0 =
@@ -26,6 +26,13 @@ const MAX_INPUT_LINES = 5
26
26
  const MAX_TASK_LINES = 5
27
27
  const QWIN = 5
28
28
 
29
+ /** 输入框内容宽度单源(第 31 批——TUI-INPUT-BOX.md §9.3 #3):`computeLayout` 的输入框
30
+ * layoutInput 宽度与键处理侧竖移(`moveCursorVertical`)共用——布局与竖移零漂移。
31
+ * 含 2 列行前缀(`▸ ` / 续行 2 空格);layoutInput 内部再为前缀预留 2 列。 */
32
+ export function inputContentWidth(cols) {
33
+ return Math.max(20, cols - 1) - 4
34
+ }
35
+
29
36
  /** question 自由文本态渲染布局:与主输入框同实现(TUI-INPUT-BOX.md §7.2 round2 #1/#6)——
30
37
  * 折行展开(`▸ ` 首行 + 续行 2 空格缩进)+ 光标行列;answer 单行不变式(无 \n 字符)由
31
38
  * 输入侧硬守卫(Ctrl+J no-op / 粘贴 \n→空格),此处只复用 layoutInput 的折行/光标语义,
@@ -40,11 +47,14 @@ export function layoutAnswer(chars, cursor, width) {
40
47
  */
41
48
  export function computeLayout(state, { cols, rows }) {
42
49
  const W = Math.max(20, cols - 1)
50
+ const inputW = inputContentWidth(cols) // 输入框内容宽(= W - 4)——单源:布局与竖移共用
43
51
 
44
52
  // --- input box ---
45
- const inputBuf = state.search ? [...state.search.query] : (state.interruptPrompt ? [...state.interruptPrompt.text] : state.input)
46
- const inputCursor = state.search ? inputBuf.length : (state.interruptPrompt ? inputBuf.length : state.cursor)
47
- const inputLayout = layoutInput(inputBuf, inputCursor, W - 4)
53
+ // Ctrl+I 注入框(第 31 批):{ chars, cursor } codepoint 数组 + 光标——与主输入框同渲染路径(§8)。
54
+ const ip = state.interruptPrompt
55
+ const inputBuf = state.search ? [...state.search.query] : (ip ? ip.chars : state.input)
56
+ const inputCursor = state.search ? inputBuf.length : (ip ? Math.max(0, Math.min(ip.cursor ?? inputBuf.length, inputBuf.length)) : state.cursor)
57
+ const inputLayout = layoutInput(inputBuf, inputCursor, inputW)
48
58
  let inputOffset = 0
49
59
  if (inputLayout.lines.length > MAX_INPUT_LINES) {
50
60
  inputOffset = Math.min(inputLayout.cursorLine, inputLayout.lines.length - MAX_INPUT_LINES)
@@ -66,7 +76,7 @@ export function computeLayout(state, { cols, rows }) {
66
76
  } else {
67
77
  const answerChars = Array.isArray(q.answer) ? q.answer : q.answer ? [...q.answer] : []
68
78
  const qCursor = Math.max(0, Math.min(q.cursor ?? answerChars.length, answerChars.length))
69
- questionLayout = layoutAnswer(answerChars, qCursor, W - 4)
79
+ questionLayout = layoutAnswer(answerChars, qCursor, inputW)
70
80
  if (questionLayout.lines.length > MAX_INPUT_LINES) {
71
81
  questionOffset = Math.min(questionLayout.cursorLine, questionLayout.lines.length - MAX_INPUT_LINES)
72
82
  }
@@ -127,11 +137,10 @@ export function computeLayout(state, { cols, rows }) {
127
137
  permPreviewH = 1 + permPreviewLines.length // 1 for header line
128
138
  }
129
139
 
130
- // Queue preview (1 line when queue has items and processing)
131
- const queueH = state.queue.length > 0 && state.processing ? 1 : 0
140
+ // Queue 面板已撤(INPUT-LOCK-ASYNC F-7——2026-09-09——busy 提交吞——无排队展示)
132
141
 
133
142
  // --- elastic panel: conversation takes remaining space ---
134
- const fixedH = headerH + inputBoxH + statusH + pickerH + taskPanelH + permPreviewH + queueH + subagentH
143
+ const fixedH = headerH + inputBoxH + statusH + pickerH + taskPanelH + permPreviewH + subagentH
135
144
  let convH = Math.max(1, rows - fixedH)
136
145
 
137
146
  // 小终端高度补偿:subagent 面板最先让位(可至 0 隐藏,活动仍进缓冲区不丢),
@@ -183,13 +192,12 @@ export function computeLayout(state, { cols, rows }) {
183
192
  const todo = todoFinalH > 0 ? { y, h: todoFinalH } : null; y += todoFinalH
184
193
  const picker = pickerFinalH > 0 ? { y, h: pickerFinalH } : null; y += pickerFinalH
185
194
  const permission = permFinalH > 0 ? { y, h: permFinalH } : null; y += permFinalH
186
- const queue = queueH > 0 ? { y, h: queueH } : null; y += queueH
187
195
  const inputBox = { y, h: inputBoxH }; y += inputBoxH
188
196
  const status = { y, h: statusH }
189
197
 
190
198
  return {
191
199
  W, cols, rows,
192
- panels: { header, conversation, subagent, picker, todo, permission, queue, inputBox, status },
200
+ panels: { header, conversation, subagent, picker, todo, permission, inputBox, status },
193
201
  // precomputed content (affects height, reused during render)
194
202
  inputLayout,
195
203
  inputOffset,
@@ -0,0 +1,89 @@
1
+ /**
2
+ * model-catalog.mjs — 会话级模型清单 helper(PROVIDER.md §16.2 M2/M9——2026-09-10 MODEL-SELECTION)。
3
+ *
4
+ * 清单唯一权威 = provider 运行期拉取(`GET /models`——provider/list-models.mjs 按 format 分派)。
5
+ * 本模块提供:
6
+ * - `getProviderModels(providerConfig)`:拉取 + **会话级缓存**(TTL 60s;失败不缓存——下次重试);
7
+ * - `probeChannelModels(providerConfig)`:配置阶段准入探(M9——探通入缓存候选直接可用 / 探不通返回
8
+ * 明示文案;调用方决定标注与「不入可选来源」——绝不阻断保存);
9
+ * - `modelListFailureText(error)`:M8 失败消息本体(逐字长句——界面明示的唯一断言对象);
10
+ * - `dedupeModels` / `modelSeries`:显示归并(自 model-picker.mjs 迁入——会话面与槽位面共用)。
11
+ *
12
+ * 缓存时钟可注入(`_catalogHooks.now`——先例 rate.mjs `_rateHooks`):测试假时钟确定性断言 T6 不依赖壁钟。
13
+ */
14
+ import { listModels } from "../provider/list-models.mjs"
15
+
16
+ /** 会话级缓存 TTL(§16.4 a2:连续操作不重复付网络;60s 新鲜窗口)。 */
17
+ const CACHE_TTL_MS = 60_000
18
+
19
+ /** 测试钩子(先例 `rate.mjs` `_rateHooks`):时钟可注入——T6 假时钟断言。 */
20
+ export const _catalogHooks = { now: () => Date.now() }
21
+
22
+ /** 会话内缓存:key = name + baseURL(同渠道不同端点各自成条);value = { models, at }。 */
23
+ const _cache = new Map()
24
+
25
+ /** 测试缝:清空会话缓存(跨用例隔离——生产无调用点)。 */
26
+ export function _clearModelCatalogCache() { _cache.clear() }
27
+
28
+ function cacheKey(providerConfig) {
29
+ return `${providerConfig?.name ?? ""}\u0000${providerConfig?.baseURL ?? ""}`
30
+ }
31
+
32
+ /**
33
+ * 拉取渠道模型清单(会话缓存 TTL 60s)。命中缓存(TTL 内)直接返回,否则走 M1 拉取;
34
+ * **失败不缓存**(下次调用重试——失败态由调用方降级展示)。
35
+ * `fresh: true` 跳过缓存读(仍写回——M9 准入探必须真发一次请求:换 key 不得被旧 key 的缓存短路)。
36
+ * 抛错契约与 listModels 一致。
37
+ */
38
+ export async function getProviderModels(providerConfig, { signal, fresh = false } = {}) {
39
+ const key = cacheKey(providerConfig)
40
+ if (!fresh) {
41
+ const hit = _cache.get(key)
42
+ if (hit && _catalogHooks.now() - hit.at < CACHE_TTL_MS) return hit.models
43
+ }
44
+ const models = await listModels(providerConfig, { signal })
45
+ _cache.set(key, { models, at: _catalogHooks.now() })
46
+ return models
47
+ }
48
+
49
+ /** M8 失败消息本体(逐字长句——状态 = HTTP 状态码或网络错误摘要;无绕过指引)。
50
+ * 分工:消息本体 = 本长句;状态标签 = `不可用`(列表行内短标)。 */
51
+ export function modelListFailureText(error) {
52
+ const status = /GET \/models failed (\d+)/.exec(error?.message ?? "")?.[1]
53
+ ?? String(error?.message ?? error)
54
+ return `该渠道不提供模型列表(GET /models ${status})——无法选择模型,请改用其他渠道`
55
+ }
56
+
57
+ /**
58
+ * M9 配置阶段准入探:对目标渠道探一次 `GET /models`(复用 M1 + 既有超时;**fresh——真发一次
59
+ * 请求**(换 key 不被旧缓存短路);探通入会话缓存——该流内候选直接可用)。
60
+ * 返回 `{ ok:true, list }` | `{ ok:false, message }`——**不抛错**(探不通不是异常:调用方标注
61
+ * 不可用 + 明示原因,配置流不阻断)。
62
+ */
63
+ export async function probeChannelModels(providerConfig) {
64
+ try {
65
+ return { ok: true, list: await getProviderModels(providerConfig, { fresh: true }) }
66
+ } catch (error) {
67
+ return { ok: false, message: modelListFailureText(error) }
68
+ }
69
+ }
70
+
71
+ /** Strip known version/date suffixes to get the "series" name of a model.
72
+ * e.g. "qwen-max-latest" → "qwen-max", "qwen-max-2024-09-19" → "qwen-max" */
73
+ export function modelSeries(name) {
74
+ return name
75
+ .replace(/-latest$/, "")
76
+ .replace(/-\d{4}-\d{2}-\d{2}$/, "") // date suffix like -2024-09-19
77
+ .replace(/-\d{8}$/, "") // date suffix like -20240919
78
+ }
79
+
80
+ /** Dedupe model list: group by series, keep shortest name per group. */
81
+ export function dedupeModels(models) {
82
+ const groups = new Map()
83
+ for (const m of models) {
84
+ const series = modelSeries(m)
85
+ const existing = groups.get(series)
86
+ if (!existing || m.length < existing.length) groups.set(series, m)
87
+ }
88
+ return [...groups.values()].sort()
89
+ }