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
@@ -0,0 +1,498 @@
1
+ /**
2
+ * model-picker.mjs — /model two-level picker + provider management + session-slot model
3
+ * selection(MODEL-MERGE-SESSION 拆分产物——/model 两级面自 pickers.mjs 迁出)。
4
+ *
5
+ * 语义(2026-09-10 MODEL-SELECTION v2):selectModel 写**会话槽**(agent 内存态 + saveSession)——
6
+ * 不写 config(/model 不再串扰全局默认——根治)。候选 = **运行期拉取**(`GET /models`——M2;
7
+ * 拉到的行直接可选);显式 `provider:model` 一律放行(M4——仅[空值/裸值/未知 provider]无效)。
8
+ * 切换成功回显规格来源(M6);渠道默认模型 = `providers[].model` 单值(M3——显示回退读取)。
9
+ * 配置写入面(加渠道 / 设 key)探一次 `/models`(M9——探不通标「不可用」+ 明示原因,不阻断保存)。
10
+ * 渠道管理流(add/remove/key/context)是 provider 级 config 写——语义不变。F-4 (ISSUE-FIX-BATCH):
11
+ * removeProviderFlow 级联清理悬挂引用(cascadeRemoveProvider——文件尾导出)。
12
+ * ctx: { agent, state, render, ansi, C, pushLine, persistRaw, askQuestion, maskKey, showPicker,
13
+ * closePicker, renderPickerLines }(后三者为 pickers.mjs 通用 picker 绑定——闭包入参,环安全)。
14
+ */
15
+ import { sliceByWidth } from "./render.mjs"
16
+ import { PROVIDER_PRESETS as PRESETS, providerSpec, specMatch } from "../config.mjs"
17
+ import { saveSession } from "../session.mjs"
18
+ import { getProviderModels, probeChannelModels, modelListFailureText, dedupeModels } from "./model-catalog.mjs"
19
+
20
+ /** createModelPicker(ctx) → { openModelPicker, selectModel, setProviderKey, setContextFlow, pickModelForSlot } */
21
+ export function createModelPicker(ctx) {
22
+ const { agent, state, pushLine, persistRaw, askQuestion, maskKey, showPicker, closePicker, renderPickerLines } = ctx
23
+ const { ansi, C } = ctx
24
+
25
+ /** entry 唯一标识:异步更新 entries 后按它恢复选中项 */
26
+ function entryKey(e) {
27
+ if (!e) return null
28
+ return e.action === "switch" ? `switch:${e.provider}:${e.model}` : `action:${e.action}`
29
+ }
30
+
31
+ /** 模型信息显示的 context 窗口(PROVIDER.md §15 D-C5):K 单位形态跟随覆盖值。 */
32
+ function fmtContextK(tokens) {
33
+ if (tokens >= 1_000_000) return `${Math.round(tokens / 1_048_576)}M`
34
+ return `${Math.round(tokens / 1024)}K`
35
+ }
36
+
37
+ /** Get API key for a provider (config.json only — env vars are not a key source) */
38
+ function getApiKey(providerConfig) {
39
+ return providerConfig.apiKey
40
+ }
41
+
42
+ /** 渠道默认模型显示值(M3③:无默认模型显 "(no default model)"——替代旧 "(no candidates)")。 */
43
+ function defaultModelLabel(p) {
44
+ return p?.model ? p.model : "(no default model)"
45
+ }
46
+
47
+ /** 异步更新 entries 后的选中项恢复(selKey 命中则回原位,否则钳位)。 */
48
+ function restoreSelection(entries, selKey) {
49
+ const pk = state.picker
50
+ if (!pk) return
51
+ const itemRows = entries.filter((e) => e.type === "item")
52
+ const restored = selKey ? itemRows.findIndex((e) => entryKey(e) === selKey) : -1
53
+ pk.index = restored >= 0 ? restored : Math.min(pk.index, Math.max(0, itemRows.length - 1))
54
+ }
55
+
56
+ /** Level 1: provider list(/model 入口——session 复合两级面)。 */
57
+ async function openModelPicker() {
58
+ for (;;) {
59
+ const entries = buildProviderEntries()
60
+ const items = entries.filter((e) => e.type === "item")
61
+ const current = items.findIndex(
62
+ (e) => e.action === "open-models" && e.provider === agent.activeProvider)
63
+ const picked = showPicker("Models & Providers", entries, { defaultIndex: Math.max(0, current) })
64
+ const e = await picked
65
+ if (!e) return
66
+ if (e.action === "open-models") {
67
+ const modelSelected = await openModelListForProvider(e.provider)
68
+ if (modelSelected) return // model selected, close picker
69
+ // Esc from model list → return to provider list
70
+ } else if (e.action === "add") {
71
+ await addProviderFlow()
72
+ } else if (e.action === "remove") {
73
+ await removeProviderFlow()
74
+ } else if (e.action === "key") {
75
+ await setKeyFlow()
76
+ } else if (e.action === "context") {
77
+ await setContextFlow()
78
+ }
79
+ }
80
+ }
81
+
82
+ /** Level 2: model list for a specific provider(M2——候选 = 运行期拉取)。返回 true = 已选定模型。 */
83
+ async function openModelListForProvider(providerName) {
84
+ const providerConfig = agent.providers.find((p) => p.name === providerName)
85
+ if (!providerConfig) return false
86
+
87
+ const entries = buildModelEntriesForProvider(providerName, providerConfig)
88
+ const items = entries.filter((e) => e.type === "item")
89
+ const sessionProvider = providerName === agent.activeProvider
90
+ const currentModel = sessionProvider ? agent.activeModel : null
91
+ const current = currentModel ? items.findIndex((e) => e.model === currentModel) : -1
92
+ const picked = showPicker(`${providerName} models`, entries, { defaultIndex: Math.max(0, current) })
93
+
94
+ // 进入 L2 即触发拉取(会话缓存 TTL 60s——失败不缓存;失败态在 header + 提示行展示)
95
+ loadSessionModels(providerName, providerConfig, entries)
96
+ .catch((err) => pushLine(`[model] fetch models failed: ${err.message}`, C.error))
97
+
98
+ const e = await picked
99
+ if (!e) return false // Esc → back to provider list
100
+ if (e.action === "switch") {
101
+ await selectModel(e).catch((err) => pushLine(`[error] ${err.message}`, C.error))
102
+ return true
103
+ }
104
+ if (e.action === "keep") return true // 当前会话模型行——保持(无写)
105
+ return false
106
+ }
107
+
108
+ /** Build entries for Level 1: provider list(L1 = 渠道面——session 复合态随行显示) */
109
+ function buildProviderEntries() {
110
+ const entries = []
111
+ for (const p of agent.providers) {
112
+ const active = p.name === agent.activeProvider
113
+ const shown = active ? (agent.activeModel ?? defaultModelLabel(p)) : defaultModelLabel(p)
114
+ const marker = active ? "●" : ""
115
+ // A1(第 20 批 §12.5):渠道警示((no key) / (不可用))上移 text——与既有状态标
116
+ // ((ctx …) / ← session)同簇(先例 = 本文件 setKeyFlow / wizard 的 (added, no key)):
117
+ // 警示不再位于最先牺牲段。note 收窄为 baseURL(补充信息——超宽随行右截断可接受)。
118
+ const sessionNote = active ? " ← session" : ""
119
+ const keyStatus = p.apiKey ? "" : " (no key)"
120
+ const unavailable = p._unavailable ? " (不可用)" : ""
121
+ const ctxTag = ` (ctx ${fmtContextK(providerSpec(p).context)})`
122
+ entries.push({
123
+ type: "item",
124
+ text: `${p.name.padEnd(12)} ${shown}${ctxTag}${keyStatus}${unavailable}${sessionNote}`,
125
+ action: "open-models",
126
+ provider: p.name,
127
+ marker,
128
+ note: p.baseURL,
129
+ })
130
+ }
131
+ entries.push({ type: "header", text: "Management" })
132
+ entries.push({ type: "item", text: "Add provider…", action: "add" })
133
+ if (agent.providers.length > 1) entries.push({ type: "item", text: "Remove provider…", action: "remove" })
134
+ entries.push({ type: "item", text: "Set / change API key…", action: "key" })
135
+ entries.push({ type: "item", text: "Set context window (K units)…", action: "context" })
136
+ return entries
137
+ }
138
+
139
+ /** Level 2 entries(/model 会话面——候选运行期拉取):当前会话行 + 候选区(拉取填充)。
140
+ * 占位行必须存在:showPicker 对 0 item **立即 resolve(null)**(picker 不打开、entries 不可达)——
141
+ * 非会话渠道/空槽渠道的候选区在拉取落地前不能是空的。 */
142
+ function buildModelEntriesForProvider(providerName, providerConfig) {
143
+ const entries = []
144
+ const sessionProvider = providerName === agent.activeProvider
145
+ const sessionModel = sessionProvider ? agent.activeModel : null
146
+ if (sessionModel) {
147
+ entries.push({ type: "header", text: `Current (session): ${sessionModel}` })
148
+ entries.push({ type: "item", text: `${sessionModel} ← keep`, action: "keep", provider: providerName, model: sessionModel })
149
+ }
150
+ entries.push({ type: "header", text: "Available models (loading…)" })
151
+ entries.push(loadingRow())
152
+ return entries
153
+ }
154
+
155
+ /** 拉取期占位行——选中该行视为返回上一级(action:"none" → 非 switch/keep → `return false`;
156
+ * 拉取后台继续、缓存照写——通用 picker 无禁用项概念);拉取落地后由 loader 移除。 */
157
+ function loadingRow() {
158
+ return { type: "item", text: "(loading…)", action: "none", placeholder: true }
159
+ }
160
+
161
+ /** 清占位行 + 在 `Available models` header 后填行(空结果/失败也给一条不可选行——
162
+ * picker 已打开,0 item 列表不可交互)。 */
163
+ function fillAvailableModels(entries, rows, emptyText) {
164
+ const phIdx = entries.findIndex((e) => e.placeholder)
165
+ if (phIdx >= 0) entries.splice(phIdx, 1)
166
+ const headerIdx = entries.findIndex((e) => e.type === "header" && e.text.startsWith("Available models"))
167
+ if (headerIdx < 0) return
168
+ entries[headerIdx].text = rows.length ? `Available models (${rows.length} — type to filter)` : "Available models (none)"
169
+ if (rows.length) entries.splice(headerIdx + 1, 0, ...rows)
170
+ else entries.splice(headerIdx + 1, 0, { type: "item", text: emptyText, action: "none" })
171
+ }
172
+
173
+ /** 会话面拉取(M2):候选 = 拉取结果直接可选(会话面提升到槽位面语义);归并后并入。
174
+ * 失败:header 标 `(fetch failed: …)` + 一行失败消息本体(M8 长句——该渠道不可选)。 */
175
+ async function loadSessionModels(providerName, providerConfig, entries) {
176
+ let selKey = null
177
+ try {
178
+ const models = await getProviderModels(providerConfig)
179
+ if (state.picker?.entries !== entries) return // picker closed or changed
180
+ const itemRows = entries.filter((e) => e.type === "item" && !e.placeholder)
181
+ selKey = itemRows[state.picker.index] ? entryKey(itemRows[state.picker.index]) : null
182
+ const listed = new Set(itemRows.map((e) => e.model).filter(Boolean))
183
+ const rows = dedupeModels(models).filter((m) => !listed.has(m))
184
+ fillAvailableModels(entries, rows.map((m) => ({
185
+ type: "item", text: m, action: "switch", provider: providerName, model: m,
186
+ })), "(no models)")
187
+ } catch (error) {
188
+ if (state.picker?.entries !== entries) return
189
+ const phIdx = entries.findIndex((e) => e.placeholder)
190
+ if (phIdx >= 0) entries.splice(phIdx, 1)
191
+ const headerIdx = entries.findIndex((e) => e.type === "header" && e.text.startsWith("Available models"))
192
+ if (headerIdx >= 0) entries[headerIdx].text = `Available models (fetch failed: ${sliceByWidth(error.message, 30)})`
193
+ entries.splice(headerIdx + 1, 0, { type: "item", text: "(no models — 该渠道不可用)", action: "none" })
194
+ pushLine(modelListFailureText(error), C.error)
195
+ }
196
+ restoreSelection(entries, selKey)
197
+ renderPickerLines()
198
+ }
199
+
200
+ /** Level 2 entries(槽位面——/submodel + /config consult 池——advisor/subagent 覆盖语义
201
+ * 独立不受清单约束——红线零改):当前行 + 拉取建议可直接选(写 agent.* 自由串)。
202
+ * 占位行同会话面:候选区在拉取落地前不能为空(showPicker 0 item = 不打开)。 */
203
+ function buildSlotEntriesForProvider(providerName, providerConfig) {
204
+ const entries = []
205
+ const sessionProvider = providerName === agent.activeProvider
206
+ const fallback = providerConfig.model ?? ""
207
+ const currentModel = sessionProvider ? (agent.activeModel || fallback) : fallback
208
+ entries.push({ type: "header", text: "Current model" })
209
+ if (currentModel) {
210
+ entries.push({ type: "item", text: currentModel, action: "switch", provider: providerName, model: currentModel })
211
+ }
212
+ entries.push({ type: "header", text: "Available models (loading…)" })
213
+ entries.push(loadingRow())
214
+ return entries
215
+ }
216
+
217
+ /** F-3 /model 纯会话级:写槽(agent 内存态 + saveSession)——绝不写 config。
218
+ * 放行语义(M4):仅未知 provider 拒——显式 p:m 一律放行(候选外/多冒号不再拒)。 */
219
+ async function selectModel(item) {
220
+ closePicker()
221
+ const target = agent.providers.find((pp) => pp.name === item.provider)
222
+ if (!target) {
223
+ throw new Error(`Unknown provider: ${item.provider}`)
224
+ }
225
+ if (!item.model) {
226
+ throw new Error(`Missing model name for provider: ${item.provider}`)
227
+ }
228
+ // 内存态(agent 会话运行时)——不触碰 config
229
+ agent.activeProvider = target.name
230
+ agent.activeModel = item.model
231
+ agent.provider = { ...target }
232
+ agent.provider.model = item.model
233
+ if (agent.config?.agent?.compactThresholdAuto) {
234
+ const { resolveCompactThreshold } = await import("../config.mjs")
235
+ agent.config.agent.compactThreshold = resolveCompactThreshold(null, agent.provider).value
236
+ }
237
+ // 写会话槽(saveSession——槽双字段恒非空)——config 文件零写
238
+ saveSession(agent)
239
+ // M6 切换回显:规格来源一行(DEFAULT 兜底 → 警示色 + /config 提示)
240
+ const { matched } = specMatch(agent.provider.model)
241
+ const spec = providerSpec(agent.provider)
242
+ pushLine(
243
+ matched
244
+ ? `Model: ${item.provider}:${item.model} — spec found (ctx ${fmtContextK(spec.context)} / out ${fmtContextK(spec.maxOutput)})`
245
+ : `Model: ${item.provider}:${item.model} — spec not found in MODEL_SPECS — default 128K ctx / 32K out; set context in /config to override`,
246
+ matched ? C.tool : C.error,
247
+ )
248
+ if (!agent.provider.apiKey) {
249
+ const selKey = await askQuestion(`Enter API key for ${item.provider} (leave empty to skip):`)
250
+ if (selKey) await setProviderKey(item.provider, selKey)
251
+ }
252
+ }
253
+
254
+ /** Slot-bound picker(/submodel + consult 池——红线:subagent/advisor 覆盖语义独立不受清单
255
+ * 约束):两级 provider → model——fetch 建议可直接选(写 agent.* 自由串——provider:model
256
+ * 或自由值——与 subagent 工具 model 参数同语义)。返回 { provider, model } 或 null。 */
257
+ async function pickModelForSlot() {
258
+ for (;;) {
259
+ const providers = agent.providers
260
+ if (!providers.length) return null
261
+ const e = await showPicker("Select provider", providers.map((p) => ({
262
+ type: "item",
263
+ text: `${p.name.padEnd(12)} ${defaultModelLabel(p)}`,
264
+ action: "open-models",
265
+ provider: p.name,
266
+ })))
267
+ if (!e?.provider) return null
268
+ const providerConfig = providers.find((p) => p.name === e.provider)
269
+ if (!providerConfig) return null
270
+ const entries = buildSlotEntriesForProvider(e.provider, providerConfig)
271
+ fetchSlotModels(e.provider, providerConfig, entries).catch((err) => {
272
+ pushLine(`[model] fetch models failed: ${err.message}`, C.error)
273
+ })
274
+ const me = await showPicker(`${e.provider} models`, entries)
275
+ if (!me?.model) continue // Esc from model list → back to provider list
276
+ return { provider: e.provider, model: me.model }
277
+ }
278
+ }
279
+
280
+ /** Slot 面 fetch:模型直接并入可选行(旧 pickModelForSlot 语义——自由面;槽位面零改——
281
+ * 仅随 M1 获得三 format 支持 + 占位行(同会话面:0 item 的 picker 不会打开))。 */
282
+ async function fetchSlotModels(providerName, providerConfig, entries) {
283
+ const { listModels } = await import("../provider/index.mjs")
284
+ let selKey = null
285
+ try {
286
+ const models = await listModels(
287
+ { baseURL: providerConfig.baseURL, apiKey: getApiKey(providerConfig) ?? "", format: providerConfig.format },
288
+ { signal: AbortSignal.timeout(10000) }
289
+ )
290
+ if (state.picker?.entries !== entries) return
291
+ const itemRows = entries.filter((e) => e.type === "item" && !e.placeholder)
292
+ selKey = itemRows[state.picker.index] ? entryKey(itemRows[state.picker.index]) : null
293
+ const deduped = dedupeModels(models).filter((m) => !itemRows.some((en) => en.model === m))
294
+ fillAvailableModels(entries, deduped.map((m) => ({
295
+ type: "item", text: m, action: "switch", provider: providerName, model: m,
296
+ })), "(no models)")
297
+ } catch (error) {
298
+ if (state.picker?.entries !== entries) return
299
+ const phIdx = entries.findIndex((e) => e.placeholder)
300
+ if (phIdx >= 0) entries.splice(phIdx, 1)
301
+ const headerIdx = entries.findIndex((e) => e.type === "header" && e.text.startsWith("Available models"))
302
+ if (headerIdx >= 0) entries[headerIdx].text = `Available models (fetch failed: ${sliceByWidth(error.message, 30)})`
303
+ entries.splice(headerIdx + 1, 0, { type: "item", text: "(no models)", action: "none" })
304
+ }
305
+ restoreSelection(entries, selKey)
306
+ renderPickerLines()
307
+ }
308
+
309
+ // ═══ 渠道管理流(provider 级 config 写——语义不变——与 /model 会话选择分离)═══
310
+
311
+ /** M9 配置阶段准入探:探通 → 清标记;探不通 → 会话内存标「不可用」+ 明示原因(绝不落 config;
312
+ * 不阻断保存——条目已保存,仅标注)。 */
313
+ async function probeChannelFlow(cfg, label) {
314
+ const r = await probeChannelModels(cfg)
315
+ if (r.ok) {
316
+ delete cfg._unavailable
317
+ pushLine(`${label}: /models 可用(${r.list.length} 个模型可候选)`, C.tool)
318
+ } else {
319
+ cfg._unavailable = true
320
+ pushLine(`${label} 不可用 — ${r.message}`, C.error)
321
+ }
322
+ return r
323
+ }
324
+
325
+ async function addProviderFlow() {
326
+ const entries = [
327
+ { type: "header", text: "Select a preset provider" },
328
+ ...Object.entries(PRESETS).filter(([name]) => !agent.providers.some((p) => p.name === name))
329
+ .map(([name, p]) => ({ type: "item", text: `${name.padEnd(10)} ${p.desc ?? ""} (${p.model ?? ""})`, name, kind: "preset" })),
330
+ { type: "header", text: "Other" },
331
+ { type: "item", text: "Custom (manual config)", name: "__custom__", kind: "custom" },
332
+ ]
333
+ const se = await showPicker("Add Provider", entries)
334
+ if (!se) return // Esc → 返回上级(openModelPicker 循环会重开主菜单)
335
+ if (se.kind === "custom") {
336
+ const name = await askQuestion("Enter provider name:")
337
+ if (!name) return
338
+ if (agent.providers.some((p) => p.name === name)) return
339
+ const baseURL = (await askQuestion("Enter baseURL:")).replace(/\/+$/, "")
340
+ if (!baseURL) return
341
+ const model = await askQuestion("Enter model name:")
342
+ if (!model) return
343
+ const format = await showPicker("API format", [
344
+ { type: "item", text: "openai", name: "openai" },
345
+ { type: "item", text: "anthropic", name: "anthropic" },
346
+ { type: "item", text: "google", name: "google" },
347
+ ])
348
+ if (!format) return // Esc/取消 → 中止流程
349
+ const cfg = { name, baseURL, model }
350
+ if (format.name === "anthropic" || format.name === "google") cfg.format = format.name
351
+ else if (format.name !== "openai") return // 防御:未知格式(理论不可达——picker 枚举)
352
+ await persistRaw((raw) => { raw.providers ??= []; raw.providers.push(cfg) }) // D-F5a 先盘后存
353
+ agent.providers.push(cfg)
354
+ const key = await askQuestion(`Enter API key for ${name} (skip if none):`)
355
+ if (key) await setProviderKey(name, key, { probe: false }) // 探统一在流尾(M9——精确一次)
356
+ await probeChannelFlow(cfg, name) // M9 准入探(加渠道配置写入面;保存已落——不阻断)
357
+ return
358
+ }
359
+ const preset = PRESETS[se.name]
360
+ if (!preset || agent.providers.some((p) => p.name === se.name)) return
361
+ const cfg = { name: se.name, baseURL: preset.baseURL, model: preset.model }
362
+ if (preset.thinking) cfg.thinking = preset.thinking
363
+ if (preset.reasoningEffort) cfg.reasoningEffort = preset.reasoningEffort
364
+ if (preset.maxTokens) cfg.maxTokens = preset.maxTokens
365
+ if (preset.chatPath) cfg.chatPath = preset.chatPath
366
+ if (preset.format) cfg.format = preset.format
367
+ await persistRaw((raw) => { raw.providers ??= []; raw.providers.push(cfg) }) // D-F5a 先盘后存
368
+ agent.providers.push(cfg)
369
+ const key = await askQuestion(`Enter API key for ${se.name} (skip if none):`)
370
+ if (key) await setProviderKey(se.name, key, { probe: false }) // 探统一在流尾(M9——精确一次)
371
+ await probeChannelFlow(cfg, se.name) // M9 准入探(加渠道配置写入面;保存已落——不阻断)
372
+ }
373
+
374
+ async function removeProviderFlow() {
375
+ const candidates = agent.providers.filter((p) => p.name !== agent.activeProvider)
376
+ if (!candidates.length) return
377
+ const se = await showPicker("Remove Provider", [
378
+ { type: "header", text: "Select provider to remove" },
379
+ ...candidates.map((p) => ({ type: "item", text: `${p.name} (${defaultModelLabel(p)})`, name: p.name })),
380
+ ])
381
+ if (!se) return
382
+ await persistRaw((raw) => {
383
+ raw.providers ??= []
384
+ const idx = raw.providers.findIndex((p) => p?.name === se.name)
385
+ if (idx !== -1) raw.providers.splice(idx, 1)
386
+ // F-4 级联(IKCDMR——AC-4):同盘清 consultModels/subagentModels/advisor.provider 悬挂引用
387
+ cascadeRemoveProvider(raw, se.name)
388
+ })
389
+ agent.providers.splice(agent.providers.findIndex((p) => p.name === se.name), 1)
390
+ // 会话内存镜像同清(agent.config = loadConfig 产物——consult/escalate 同会话读
391
+ // agent.config.agent.*;merged.advisor 为 agent.advisor 的提升拷贝——双处清理)
392
+ if (agent.config) {
393
+ cascadeRemoveProvider(agent.config, se.name)
394
+ if (agent.config.advisor?.provider === se.name) delete agent.config.advisor.provider
395
+ }
396
+ }
397
+
398
+ async function setKeyFlow() {
399
+ const se = await showPicker("Configure API Key", [
400
+ { type: "header", text: "Select provider" },
401
+ ...agent.providers.map((p) => ({ type: "item", text: `${p.name} ${p.apiKey ? `(has key: ${maskKey(p.apiKey)})` : "(no key)"}`, name: p.name })),
402
+ ])
403
+ if (!se) return
404
+ const key = await askQuestion(`Enter API key for ${se.name}:`)
405
+ if (key) await setProviderKey(se.name, key)
406
+ }
407
+
408
+ /** 设 API key(可以来自渠道管理流 / 加渠道流内的 key 步):写盘 + 内存镜像;
409
+ * M9:设 key 属配置写入面——默认探一次 `/models`(不阻断——key 已保存);
410
+ * 加渠道流内调用传 `{ probe: false }`(探由该流尾部统一执行——精确一次)。 */
411
+ async function setProviderKey(name, key, { probe = true } = {}) {
412
+ const target = agent.providers.find((p) => p.name === name)
413
+ if (!target) return
414
+ await persistRaw((raw) => {
415
+ raw.providers ??= []
416
+ const t = raw.providers.find((p) => p?.name === name)
417
+ if (t) t.apiKey = key
418
+ })
419
+ target.apiKey = key
420
+ if (name === agent.activeProvider) agent.provider.apiKey = key
421
+ if (probe) await probeChannelFlow(target, name)
422
+ }
423
+
424
+ /** /model provider 管理:context 窗口字段(K 单位)——picker 选 provider + 表单输入。 */
425
+ async function setContextFlow() {
426
+ const se = await showPicker("Set Context Window", [
427
+ { type: "header", text: "Select provider" },
428
+ ...agent.providers.map((p) => ({
429
+ type: "item",
430
+ text: `${p.name} (ctx ${fmtContextK(providerSpec(p).context)})`,
431
+ name: p.name,
432
+ })),
433
+ ])
434
+ if (!se?.name) return // Esc
435
+ const target = agent.providers.find((p) => p.name === se.name)
436
+ if (!target) return
437
+ const current = Number.isInteger(target.context) && target.context > 0 ? target.context : null
438
+ const val = (await askQuestion(
439
+ `Context window for ${se.name} in K units (current: ${current ? `${current}K` : "spec default"} — e.g. 128 = 128K; empty to clear):`
440
+ ))?.trim() ?? ""
441
+ let newCtx // undefined = 清空(回 spec 值)
442
+ if (val !== "") {
443
+ const n = Number(val)
444
+ if (!Number.isInteger(n) || n <= 0) {
445
+ pushLine(`Invalid context: "${val}" — must be a positive integer in K units (e.g. 128 = 128K)`, C.error)
446
+ return
447
+ }
448
+ newCtx = n
449
+ }
450
+ await persistRaw((raw) => {
451
+ raw.providers ??= []
452
+ const t = raw.providers.find((p) => p?.name === se.name)
453
+ if (!t) return
454
+ if (newCtx === undefined) delete t.context
455
+ else t.context = newCtx
456
+ })
457
+ if (newCtx === undefined) delete target.context
458
+ else target.context = newCtx
459
+ if (se.name === agent.activeProvider) {
460
+ if (newCtx === undefined) delete agent.provider.context
461
+ else agent.provider.context = newCtx
462
+ if (agent.config?.agent?.compactThresholdAuto) {
463
+ const { resolveCompactThreshold } = await import("../config.mjs")
464
+ agent.config.agent.compactThreshold = resolveCompactThreshold(null, agent.provider).value
465
+ }
466
+ }
467
+ pushLine(target.context !== undefined
468
+ ? `ctx = ${target.context}K (${target.context * 1024} tokens)`
469
+ : `context cleared — using model spec (${fmtContextK(providerSpec(target).context)})`, C.tool)
470
+ }
471
+
472
+ return { openModelPicker, selectModel, setProviderKey, setContextFlow, pickModelForSlot }
473
+ }
474
+
475
+ /** F-4 (IKCDMR) 级联清理(删渠道共享写点):raw/merged config 移除 name 渠道后清悬挂引用——
476
+ * agent.consultModels 条目 / agent.subagentModels 角色值(=== name 或 "name:…" 前缀——
477
+ * 角色值是 "provider:model" | 裸渠道名 | 裸模型名)/ agent.advisor.provider。
478
+ * 纯 mutate(removeProviderFlow persistRaw 的 D-F5 新鲜 raw 上调用;agent.config 内存镜像
479
+ * 子树与 raw.agent 同构可复用)。空数组/空对象键删除(规范形态)。 */
480
+ export function cascadeRemoveProvider(raw, name) {
481
+ const a = raw?.agent
482
+ if (!a || typeof a !== "object" || Array.isArray(a)) return
483
+ if (Array.isArray(a.consultModels)) {
484
+ const keep = a.consultModels.filter((m) => m?.provider !== name)
485
+ if (keep.length) a.consultModels = keep
486
+ else delete a.consultModels
487
+ }
488
+ if (a.subagentModels && typeof a.subagentModels === "object" && !Array.isArray(a.subagentModels)) {
489
+ for (const role of Object.keys(a.subagentModels)) {
490
+ const v = a.subagentModels[role]
491
+ if (typeof v === "string" && (v === name || v.startsWith(`${name}:`))) delete a.subagentModels[role]
492
+ }
493
+ if (Object.keys(a.subagentModels).length === 0) delete a.subagentModels
494
+ }
495
+ if (a.advisor && typeof a.advisor === "object" && !Array.isArray(a.advisor) && a.advisor.provider === name) {
496
+ delete a.advisor.provider
497
+ }
498
+ }
package/src/tui/mouse.mjs CHANGED
@@ -17,7 +17,11 @@
17
17
  import { computeLayout, subagentLineIndex } from "./layout.mjs"
18
18
  import { buildConvLines, convViewport } from "./render-conversation.mjs"
19
19
  import { toggleFoldBlock, scrollFoldBlock, foldScrollOffset } from "./fold-block.mjs"
20
- import { cancelAsyncSubagent } from "../agent-tools/subagent-async.mjs"
20
+ import { cancelAsyncSubagent, cancelSyncChild } from "../agent-tools/subagent-async.mjs"
21
+ import { cancelAsyncAdvisor } from "../agent-tools/advisor-async.mjs"
22
+ import { maybeRefillAsync, refreshQueuedTokens } from "../agent-tools/subagent-scheduler.mjs" // F-2:queued 取消后续(补位/位置刷新)——叶子模块
23
+ import { routeSubToken } from "./subagent-blocks.mjs" // F-2:queued 取消块移除(⟦ev⟧cancelled 就地路由——引擎动作路径同通道)
24
+ import { denyModalForOwner } from "./key-modes.mjs"
21
25
  import { C } from "./ansi.mjs"
22
26
 
23
27
  /** 2026-08-31 滚轮事件分派(用户需求"展开块能滚动阅读全文"):坐标命中展开块内容行 →
@@ -29,6 +33,7 @@ export function handleWheel(ctx, button, col, row) {
29
33
  const r = row - 1
30
34
  if (r < 0) return false
31
35
  const dims = state.dims ? state.dims.get() : { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 }
36
+ if (mouseOob(col, row, dims)) return false // F-3 sane-gate ②:越界滚轮丢弃——不落面板(> 非 >=——末行列合法)
32
37
  const layout = computeLayout(state, dims)
33
38
  const P = layout.panels
34
39
  // §7.2.1 D4: 固定子agent 面板(conversation 与 todo 之间)——面板行默认穿出
@@ -81,6 +86,13 @@ export function parseMouseClicks(text) {
81
86
  return out
82
87
  }
83
88
 
89
+ /** F-3 sane-gate(RESIZE-MOUSE-LEAK-FIX):越界坐标判定——resize 后短窗口越界上报丢弃
90
+ * (col > dims.cols || row > dims.rows——> 非 >=——末行列合法——与 dims.mjs 同哲学)。
91
+ * 落点:① handleMouseClick 入口 ② handleWheel 入口 ③ index.mjs 滚轮 fallback 前(评审 #3 定稿)。 */
92
+ export function mouseOob(col, row, dims) {
93
+ return col > dims.cols || row > dims.rows
94
+ }
95
+
84
96
  /** Map a 0-based screen row to a conversation line index (same math as renderConversation). */
85
97
  export function convGlobalIndex(convLen, convH, scroll) {
86
98
  const { start, pad } = convViewport(convLen, convH, scroll)
@@ -104,6 +116,7 @@ export function handleMouseClick(ctx, col, row) {
104
116
  // Single source (Windows ConPTY instability, 2026-08-30): the cached dims,
105
117
  // never a live read that can flip between stale and fresh values.
106
118
  const dims = state.dims ? state.dims.get() : { cols: process.stdout.columns || 80, rows: process.stdout.rows || 24 }
119
+ if (mouseOob(col, row, dims)) return false // F-3 sane-gate ①:越界点击丢弃——resize 后短窗口越界上报不落应用(> 非 >=——末行列合法)
107
120
  const layout = computeLayout(state, dims)
108
121
  const P = layout.panels
109
122
 
@@ -127,10 +140,11 @@ export function handleMouseClick(ctx, col, row) {
127
140
  // 评审 #4:保底截断后可见行 ≠ 前 h 行——命中映射与 render-frame 同一几何契约
128
141
  // (subagentLineIndex:分隔线 + 末尾区块行优先)。
129
142
  const lineEl = layout.subagentLines[subagentLineIndex(layout.subagentLines, P.subagent.h, r - P.subagent.y)]
130
- // §19.5 D-M7/D-M7b ⏹ 停止标记(round1 #6 + 用户裁定 B 形态):列级命中——⏹
131
- // 点击 = cancel(定向该子代理——ctx.cancelSubagent 直连池 abort 路径,不经模型
132
- // 回合),不触发折叠翻转;**仅 async 区块带 _stopSub 元数据**(sync 无 ⏹——
133
- // 其右缘点击照常走折叠翻转——D-M7b ③);⏹ 区外点击照常走折叠/翻窗。
143
+ // §19.5 D-M7/D-M7b ⏹ 停止标记(round1 #6 + 用户裁定 B 形态)+ SYNC-CANCEL F3
144
+ // (2026-09-09):列级命中——⏹ 列点击 = cancel(定向该子代理——ctx.cancelSubagent
145
+ // 直连池/registry abort 路径,不经模型回合),不触发折叠翻转;**async 区块与 registry
146
+ // live sync 区块带 _stopSub 元数据**(headless 无 _agent → sync 不钉——零回归);
147
+ // ⏹ 区外点击照常走折叠/翻窗。
134
148
  if (lineEl?._stopSub && col >= (lineEl._stopCol ?? Infinity)) {
135
149
  ctx.cancelSubagent?.(lineEl._stopSub)
136
150
  render()
@@ -184,15 +198,44 @@ export function createMouseDispatch({ agent, state, pushLine, render, popPicker
184
198
  const cancelSubagent = (key) => {
185
199
  try {
186
200
  const id = key.slice(key.lastIndexOf("#") + 1)
187
- const r = cancelAsyncSubagent(agent, id)
201
+ const isAdvisorBlock = key.startsWith("advisor#")
202
+ // §11.2 D-24b (②-6b): ⏹ on an advisor block cancels the background review
203
+ // (directed abort → cancelled settle: no pending entry / no token).
204
+ // SYNC-CANCEL F3: async 池/advisor miss 后查 sync registry(⏹ 门控已放开 sync——
205
+ // cancelSyncChild 与 async cancel 同模块同形态——subagent-async.mjs)。
206
+ let r = isAdvisorBlock
207
+ ? cancelAsyncAdvisor(agent, id)
208
+ : cancelAsyncSubagent(agent, id)
209
+ let syncStopped = false
210
+ if (!isAdvisorBlock && r?.status === "error") {
211
+ r = cancelSyncChild(agent, key)
212
+ syncStopped = r?.status === "cancelled"
213
+ }
188
214
  if (r?.status === "error") {
189
- // 池内无此条目但区块仍 live(!done)→ 阻塞型(sync)spawn 中——无池条目可定向
190
- // 中止(§19.5 cancel 只针对 async 后台子代理);给出可操作指引而非神秘 unknown id
215
+ // 拒绝文案改(SYNC-CANCEL):registry/池 miss + live → 定向中止窗口已过
216
+ // (成功/折叠/整回合停后 finally 注销——"finished or stop no longer applies"——
217
+ // 原 "blocking (sync) child mid-call — targeted stop not available" 文案退役——
218
+ // sync 现已可中止);无 live 块(真 miss/未知 key)→ 原错误直显。
191
219
  const liveBlock = state.subTasks?.[key] && !state.subTasks[key].done
192
220
  pushLine(liveBlock
193
- ? `[subagent stop] ${key} is a blocking (sync) child mid-call — targeted stop is not available; use Ctrl+C to interrupt the turn`
221
+ ? `[subagent stop] ${key} has finished or stop no longer applies`
194
222
  : `[subagent stop] ${r.error}`, C.error)
195
223
  } else {
224
+ // SYNC-CANCEL v2 模态 deny(用户裁):**sync ⏹** 顺带 deny 该 child 的 pending
225
+ // ask(权限/continue 模态)——模态立即解除——child 解绕折叠(async cancel 的
226
+ // 同类缺陷现状已知——非本批引入——v2 deny 机制后续可复用到 async——此处仅
227
+ // syncStopped 路径 deny——async 成功取消不 deny 模态——零回归)。
228
+ if (syncStopped) denyModalForOwner(state, key, { pushLine, render })
229
+ // F-2(QUEUED-VISIBILITY——2026-09-09):queued 出队(引擎返回 was:"queued"——
230
+ // 无 settle 事件链)——UI 直连路径无回合 ctx 事件流——补 executeCancelAction
231
+ // 同款 TUI 维护:等待块移除(⟦ev⟧cancelled 就地路由——守卫同 routeSubToken
232
+ // cancelled 分支——不冻结)+ 补位 + 剩余排队头位置/依赖标注刷新(⟦ev⟧queued)。
233
+ if (r?.was === "queued") {
234
+ const emit = (t) => { if (!routeSubToken(state, t, render)) pushLine(t, C.dim) }
235
+ emit(`${key}/⟦ev⟧cancelled\x1e`)
236
+ maybeRefillAsync(agent)
237
+ refreshQueuedTokens(agent, emit)
238
+ }
196
239
  pushLine(`[subagent ${key} stop requested]`, C.warn)
197
240
  }
198
241
  } catch (e) {