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
@@ -1,6 +1,6 @@
1
- import { existsSync, readFileSync } from "node:fs"
2
1
  import { createInterface } from "node:readline"
3
- import { configPath, saveConfig, PROVIDER_PRESETS } from "../config.mjs"
2
+ import { configPath, writeConfigAtomic, PROVIDER_PRESETS } from "../config.mjs"
3
+ import { probeChannelModels } from "../tui/model-catalog.mjs"
4
4
 
5
5
  /** First-time setup (TTY chat / distill): ask a few questions to configure a provider, save to disk, return runtime provider. Cancel returns null. */
6
6
  export async function setupWizard() {
@@ -29,7 +29,7 @@ export async function setupWizard() {
29
29
  presets.forEach(([n, p], i) => console.error(` ${i + 1}. ${n.padEnd(10)} ${p.desc}`))
30
30
  console.error(` ${presets.length + 1}. Custom endpoint`)
31
31
  const choice = Number((await ask(`Pick [1-${presets.length + 1}]: `)).trim())
32
- let name, baseURL, model
32
+ let name, baseURL, model, presetFields = null
33
33
  if (choice === presets.length + 1) {
34
34
  name = (await ask("Name (e.g. my-openai): ")).trim()
35
35
  baseURL = (await ask("baseURL (e.g. https://api.openai.com/v1): ")).trim().replace(/\/+$/, "")
@@ -39,9 +39,11 @@ export async function setupWizard() {
39
39
  return null
40
40
  }
41
41
  } else if (choice >= 1 && choice <= presets.length) {
42
+ // 预设全量字段随行(format/thinking/maxTokens…)——探针分派与落盘同源(M9 需要 format)
43
+ presetFields = presets[choice - 1][1]
42
44
  name = presets[choice - 1][0]
43
- baseURL = presets[choice - 1][1].baseURL
44
- model = presets[choice - 1][1].model
45
+ baseURL = presetFields.baseURL
46
+ model = presetFields.model
45
47
  } else {
46
48
  console.error("Invalid choice — cancelled")
47
49
  return null
@@ -52,19 +54,42 @@ export async function setupWizard() {
52
54
  return null
53
55
  }
54
56
  const embedKey = (await ask("Optional: embedding API key (SiliconFlow, for vector search; press Enter to skip): ")).trim()
55
- const raw = existsSync(configPath) ? JSON.parse(readFileSync(configPath, "utf8")) : {}
56
- const providers = raw.providers?.length ? raw.providers : []
57
- const existing = providers.find((p) => p.name === name)
58
- if (existing) Object.assign(existing, { baseURL, model, apiKey })
59
- else providers.push({ name, baseURL, model, apiKey })
60
- raw.providers = providers
61
- raw.activeProvider = name
62
- delete raw.activeModel // reset to default model
63
- if (embedKey) raw.embedding = { ...(raw.embedding ?? {}), apiKey: embedKey }
64
- saveConfig(raw)
65
- console.error(`Configured: ${name} / ${model} (saved to ${configPath})`)
57
+ // M9 配置阶段准入(加渠道 = 配置写入面):探一次 `/models`——探通/探不通都不阻断保存,
58
+ // 仅明示结果(失败文案 = M8 消息本体;无绕过指引)。
59
+ // 探针输入带预设扩展字段(format 决定 M1 分派——claude/gemini 预设必须走自己的拉取分支)。
60
+ const probe = await probeChannelModels(presetFields ? { ...presetFields, name, apiKey } : { name, baseURL, apiKey })
61
+ console.error(probe.ok
62
+ ? `✓ ${name}: /models 可用(${probe.list.length} 个模型可候选)`
63
+ : `⚠ ${probe.message}`)
64
+ // D-F5b:磁盘新鲜读 mutate → mtime 门控写(writeConfigAtomic 收口);冲突 = 放弃
65
+ // + 提示重试(首配场景另有实例同时写盘——极低概率;不自动合并——决策点① A)
66
+ // MODEL-SELECTION v2:渠道默认模型 = 单值 `model` + defaultModel 顶层复合;
67
+ // 预设扩展字段(format/thinking/maxTokens…)随行落盘——与 TUI wizard 近似同构(探针/聊天字段同源),
68
+ // 但 `thinking` 复制语义不同:本文件用 `!== undefined` → kimi/kimi-code 预设的 `thinking: null`
69
+ // **被落盘**;TUI 路径用真值过滤(model-picker.mjs / wizard.mjs)不落该键。
70
+ // `thinking: null`(NF1 = 显式 off)落盘差异已登记待口径统一(父侧)。
71
+ const r = writeConfigAtomic(configPath, (raw) => {
72
+ const providers = raw.providers?.length ? raw.providers : []
73
+ const existing = providers.find((p) => p.name === name)
74
+ const rec = { name, baseURL, model, apiKey }
75
+ for (const k of ["format", "thinking", "reasoningEffort", "maxTokens", "chatPath"]) {
76
+ if (presetFields?.[k] !== undefined) rec[k] = presetFields[k]
77
+ }
78
+ if (existing) Object.assign(existing, rec) // 渠道老字段(models 候选清单)由 config-migrate 在下次 load 统一清理
79
+ else providers.push(rec)
80
+ raw.providers = providers
81
+ raw.defaultModel = `${name}:${model}`
82
+ delete raw.activeProvider
83
+ delete raw.activeModel
84
+ if (embedKey) raw.embedding = { ...(raw.embedding ?? {}), apiKey: embedKey }
85
+ })
86
+ if (!r.ok) {
87
+ console.error("config changed on disk concurrently — retry")
88
+ return null
89
+ }
90
+ console.error(`Configured: ${name} / ${model} (defaultModel = ${name}:${model} — saved to ${configPath})`)
66
91
  console.error(embedKey ? "Vector search enabled\n" : "(No embedding key configured: memory search will use text-only FTS. Add embedding.apiKey to config.json to enable vector search later.)\n")
67
- return { name, baseURL, model, apiKey }
92
+ return { name, baseURL, model, apiKey, defaultModel: `${name}:${model}` }
68
93
  } finally {
69
94
  rl.close()
70
95
  }
@@ -0,0 +1,114 @@
1
+ /**
2
+ * completions.mjs — `thincoder completion <shell>` shell 补全脚本发射(bash/zsh/fish)。
3
+ * bin/thincoder.mjs case "completion" 直写段拆分(2026-09-08——500 行硬限触碰执行:
4
+ * 段逐字迁出,脚本字节与既有输出一致)。
5
+ */
6
+ export function printCompletion(shell) {
7
+ if (shell === "bash") {
8
+ process.stdout.write(`_thincoder() {
9
+ local cur prev words cword
10
+ _init_completion 2>/dev/null || { COMPREPLY=(); return; }
11
+ cur="\${COMP_WORDS[COMP_CWORD]}"
12
+ prev="\${COMP_WORDS[COMP_CWORD-1]}"
13
+ case "\${COMP_WORDS[1]}" in
14
+ chat) COMPREPLY=( \\$(compgen -W "--auto" -- "\\$cur") ) ;;
15
+ memory)
16
+ case "\\$prev" in
17
+ memory) COMPREPLY=( \\$(compgen -W "list search put remove" -- "\\$cur") ) ;;
18
+ list) COMPREPLY=( \\$(compgen -W "--type=rule --type=knowledge --type=decision --type=pattern" -- "\\$cur") ) ;;
19
+ put) COMPREPLY=( \\$(compgen -W "--type= --title= --content= --tags=" -- "\\$cur") ) ;;
20
+ esac ;;
21
+ distill) COMPREPLY=( \\$(compgen -W "--yes --layer=" -- "\\$cur") ) ;;
22
+ completion) COMPREPLY=( \\$(compgen -W "bash zsh fish" -- "\\$cur") ) ;;
23
+ *)
24
+ COMPREPLY=( \\$(compgen -W "chat acp memory sync reindex distill upgrade completion session -v --version -h --help" -- "\\$cur") ) ;;
25
+ esac
26
+ }
27
+ complete -F _thincoder thincoder
28
+ `)
29
+ } else if (shell === "zsh") {
30
+ process.stdout.write(`#compdef thincoder
31
+
32
+ _thincoder() {
33
+ local context state state_descr line
34
+ typeset -A opt_args
35
+ _arguments -C \\
36
+ '1: :->cmd' \\
37
+ '*:: :->args'
38
+
39
+ case "\\$state" in
40
+ cmd)
41
+ _values 'command' \\
42
+ 'chat[One-shot agent run with tools]' \\
43
+ 'acp[Agent Client Protocol server for IDEs]' \\
44
+ 'memory[Manage long-term memory]' \\
45
+ 'sync[Sync team memory repo]' \\
46
+ 'reindex[Rebuild local index from markdown]' \\
47
+ 'distill[Extract knowledge from session transcript]' \\
48
+ 'upgrade[Update to latest version from npm]' \\
49
+ 'completion[Generate shell completion script]' \\
50
+ 'session[Session dir GC: session gc --dry-run|--confirm]'
51
+ ;;
52
+ args)
53
+ case "\\$words[1]" in
54
+ chat) _arguments '--auto[Auto-approve all tool calls]' ;;
55
+ memory)
56
+ case "\\$words[2]" in
57
+ list) _arguments '--type=[Filter by type]' ;;
58
+ put) _arguments '--type=[Entry type]' '--title=[Title]' '--content=[Content]' '--tags=[Space-separated tags]' ;;
59
+ esac ;;
60
+ distill) _arguments '--yes[Skip confirmation]' '--layer=[Layer filter]' ;;
61
+ completion) _values 'shell' 'bash' 'zsh' 'fish' ;;
62
+ esac ;;
63
+ esac
64
+ }
65
+ _thincoder
66
+ `)
67
+ } else if (shell === "fish") {
68
+ process.stdout.write(`# thincoder completions for fish shell
69
+ complete -c thincoder -f
70
+
71
+ # Subcommands
72
+ complete -c thincoder -a chat -d 'One-shot agent run with tools'
73
+ complete -c thincoder -a memory -d 'Manage long-term memory'
74
+ complete -c thincoder -a sync -d 'Sync team memory repo'
75
+ complete -c thincoder -a reindex -d 'Rebuild local index from markdown'
76
+ complete -c thincoder -a distill -d 'Extract knowledge from session'
77
+ complete -c thincoder -a upgrade -d 'Update to latest version'
78
+ complete -c thincoder -a completion -d 'Shell completion'
79
+ complete -c thincoder -a session -d 'Session dir GC (session gc --dry-run|--confirm)'
80
+ complete -c thincoder -a acp -d 'Agent Client Protocol server for IDEs'
81
+
82
+ # Flags
83
+ complete -c thincoder -s v -l version -d 'Print version'
84
+ complete -c thincoder -s h -l help -d 'Print help'
85
+
86
+ # chat flags
87
+ complete -c thincoder -n '__fish_seen_subcommand_from chat' -l auto -d 'Auto-approve tool calls'
88
+
89
+ # memory subcommands
90
+ complete -c thincoder -n '__fish_seen_subcommand_from memory' -a list -d 'List entries'
91
+ complete -c thincoder -n '__fish_seen_subcommand_from memory' -a search -d 'Search memory'
92
+ complete -c thincoder -n '__fish_seen_subcommand_from memory' -a put -d 'Add entry'
93
+ complete -c thincoder -n '__fish_seen_subcommand_from memory' -a remove -d 'Remove entry'
94
+
95
+ # memory list flags
96
+ complete -c thincoder -n '__fish_seen_subcommand_from memory; and __fish_seen_subcommand_from list' -l type -d 'Filter by type' -xa 'rule knowledge decision pattern'
97
+
98
+ # memory put flags
99
+ complete -c thincoder -n '__fish_seen_subcommand_from memory; and __fish_seen_subcommand_from put' -l type -d 'Entry type'
100
+ complete -c thincoder -n '__fish_seen_subcommand_from memory; and __fish_seen_subcommand_from put' -l title -d 'Title'
101
+ complete -c thincoder -n '__fish_seen_subcommand_from memory; and __fish_seen_subcommand_from put' -l content -d 'Content'
102
+ complete -c thincoder -n '__fish_seen_subcommand_from memory; and __fish_seen_subcommand_from put' -l tags -d 'Space-separated tags'
103
+
104
+ # distill flags
105
+ complete -c thincoder -n '__fish_seen_subcommand_from distill' -l yes -d 'Skip confirmation'
106
+ complete -c thincoder -n '__fish_seen_subcommand_from distill' -l layer -d 'Layer filter'
107
+
108
+ # completion shells
109
+ complete -c thincoder -n '__fish_seen_subcommand_from completion' -a bash -d 'Bash completions'
110
+ complete -c thincoder -n '__fish_seen_subcommand_from completion' -a zsh -d 'Zsh completions'
111
+ complete -c thincoder -n '__fish_seen_subcommand_from completion' -a fish -d 'Fish completions'
112
+ `)
113
+ }
114
+ }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * config-migrate.mjs — model-schema legacy-shape migration core (MODEL-SELECTION v2, §16.2 M7).
3
+ * Split out of config.mjs (500-line hard limit). Pure function, zero imports — the
4
+ * write-back orchestration (writeConfigAtomic / persistRaw, failure tolerance) stays in
5
+ * the caller (CLI: config.mjs loadConfig; VSC: config-io.mjs loadRaw — same rule, 双端各自
6
+ * 独立实现——不做同步依赖).
7
+ *
8
+ * 目标形态 C:每渠道恰好一个默认模型(`providers[].model` 单值)+ 顶层 `defaultModel` 复合。
9
+ * 老形态迁移(v2):
10
+ * - A:`providers[].model` + `activeProvider/activeModel` → `p.model` **保留**(不再搬入 models);
11
+ * `activeProvider/activeModel` → `defaultModel` 复合(`activeModel` 优先;AP 不存在/AM 空 → 首渠道回退)
12
+ * - B:`providers[].models[]` → `p.model = defaultModel 属本渠道的模型段 ?? 现有 p.model(非空字符串)
13
+ * ?? models[] 首个非空字符串`,然后 `delete p.models`
14
+ * - 混合/垃圾:非数组 `p.models` / 非字符串(空串)`p.model` → 删除(清理)
15
+ * - 顺序:先构造/读取有效 `defaultModel` 值(含老 active* 转换),再按它给各渠道播种 `p.model`
16
+ * - 幂等:无老字段(无 `models` / 无 active* / 无垃圾值)→ 返回 false 不动;slot 旧字段
17
+ * (会话文件内)不迁移——读侧容忍
18
+ * - 空结果合法:渠道无模型来源 → `p.model` 不设(模型选择经 `/models` 拉取候选——M8/M9)
19
+ */
20
+ export function migrateLegacyModelFields(raw) {
21
+ if (!raw || typeof raw !== "object") return false
22
+ const providers = Array.isArray(raw.providers) ? raw.providers : []
23
+ let changed = false
24
+
25
+ // ── ① 有效 defaultModel 值(含老 active* 转换)──
26
+ if (raw.activeProvider !== undefined || raw.activeModel !== undefined) {
27
+ const rawAP = typeof raw.activeProvider === "string" && raw.activeProvider.trim() ? raw.activeProvider : ""
28
+ const rawAM = typeof raw.activeModel === "string" && raw.activeModel.trim() ? raw.activeModel : ""
29
+ const ap = rawAP ? providers.find((p) => p?.name === rawAP) : null
30
+ if (ap && rawAM) {
31
+ if (raw.defaultModel == null) { raw.defaultModel = `${rawAP}:${rawAM}`; changed = true }
32
+ } else {
33
+ // AP 已不存在 / AM 无法归属 → 首渠道回退(该渠道有模型来源才可用——迁移产物必可用)
34
+ const first = providers.find((p) => seedModel(p) != null)
35
+ if (first && raw.defaultModel == null) { raw.defaultModel = `${first.name}:${seedModel(first)}`; changed = true }
36
+ }
37
+ delete raw.activeProvider
38
+ delete raw.activeModel
39
+ changed = true
40
+ }
41
+
42
+ // ── ② 渠道播种单值 `p.model` + 清 models ──
43
+ const dm = typeof raw.defaultModel === "string" && raw.defaultModel.trim() ? raw.defaultModel : null
44
+ const dmSep = dm ? dm.indexOf(":") : -1
45
+ const dmProvider = dmSep > 0 ? dm.slice(0, dmSep) : null
46
+ const dmModel = dmSep > 0 && dmSep < dm.length - 1 ? dm.slice(dmSep + 1) : null
47
+ for (const p of providers) {
48
+ if (!p || typeof p !== "object") continue
49
+ if (Array.isArray(p.models)) {
50
+ const next = (p.name === dmProvider && dmModel)
51
+ ? dmModel
52
+ : (typeof p.model === "string" && p.model.trim() ? p.model : seedModel(p))
53
+ if (next == null) delete p.model
54
+ else p.model = next
55
+ delete p.models
56
+ changed = true
57
+ continue
58
+ }
59
+ if (p.models !== undefined) { delete p.models; changed = true } // 非数组 models —— 垃圾清理
60
+ if (p.model !== undefined && !(typeof p.model === "string" && p.model.trim())) { delete p.model; changed = true }
61
+ }
62
+ return changed
63
+ }
64
+
65
+ /** 渠道级模型来源读数(迁移用):现有 `p.model`(非空字符串优先)或 `models[]` 首个非空字符串。 */
66
+ function seedModel(p) {
67
+ if (typeof p?.model === "string" && p.model.trim()) return p.model
68
+ const models = Array.isArray(p?.models) ? p.models : []
69
+ return models.find((m) => typeof m === "string" && m.trim()) ?? null
70
+ }
package/src/config.mjs CHANGED
@@ -1,20 +1,40 @@
1
1
  /**
2
2
  * config.mjs — configuration loading and saving
3
- * Multi-provider structure: providers[] + activeProvider
3
+ * Model-merge schema v2 (2026-09-10 MODEL-SELECTION): providers[] carry ONE default model
4
+ * per channel (`providers[].model` — the new-install seed / empty-slot fallback); the
5
+ * candidates list field models[] is gone — the available-model list is fetched from the
6
+ * provider at runtime (`GET /models`, PROVIDER.md §16). config.defaultModel (top level,
7
+ * "provider:model" composite) is the new-session starting point; activeProvider/activeModel
8
+ * are gone from config (session slots keep their own double fields). Legacy fields migrate
9
+ * on load (write-back failure never blocks startup).
4
10
  * Config file: ~/.thincoder/config.json
5
11
  * API key can fall back to environment variables (when not configured in providers).
6
12
  */
7
13
 
8
- import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"
14
+ import { chmodSync, copyFileSync, existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"
9
15
  import { homedir } from "node:os"
10
- import { join } from "node:path"
16
+ import { dirname, join } from "node:path"
17
+ import { parseModelRef, resolveRuntimeProvider, defaultModelReason } from "./model-ref.mjs"
18
+ // 老形态迁移核(M7 v2——纯函数零依赖——本文件超 500 行硬限拆分,VSC 同构文件)
19
+ import { migrateLegacyModelFields } from "./config-migrate.mjs"
20
+ import { expandHome } from "./expand-home.mjs"
21
+
22
+ export { parseModelRef, resolveRuntimeProvider, defaultModelReason, migrateLegacyModelFields }
11
23
 
12
24
  export const configDir = join(homedir(), ".thincoder")
13
25
  export const configPath = join(configDir, "config.json")
14
26
 
15
- /** Built-in provider presets: shared by /provider add <preset> and first-run wizard */
27
+ /** Test seam: override the config file location (mirrors thincoder-vscode config-io.mjs). */
28
+ let _pathOverride = null
29
+ export function _setConfigPathForTest(p) { _pathOverride = p }
30
+ export function _resetConfigPathForTest() { _pathOverride = null }
31
+ function cfgPath() { return _pathOverride ?? configPath }
32
+
33
+ /** Built-in provider presets: shared by /provider add <preset> and first-run wizard.
34
+ * MODEL-SELECTION v2 (2026-09-10): each preset carries exactly ONE default model
35
+ * (`model` — 新装启动种子/槽位空兜底; 候选清单不再预置——运行期从 provider 拉取). */
16
36
  export const PROVIDER_PRESETS = {
17
- deepseek: { baseURL: "https://api.deepseek.com", model: "deepseek-v4-pro", thinking: { type: "enabled" }, reasoningEffort: "max", maxTokens: 393216, desc: "DeepSeek" },
37
+ deepseek: { baseURL: "https://api.deepseek.com", model: "deepseek-flash", thinking: { type: "enabled" }, reasoningEffort: "max", maxTokens: 393216, desc: "DeepSeek" },
18
38
  kimi: { baseURL: "https://api.moonshot.cn/v1", model: "kimi-k3", thinking: null, reasoningEffort: "max", maxTokens: 131072, desc: "Kimi / Moonshot" },
19
39
  "kimi-code": { baseURL: "https://api.kimi.com/coding/v1", model: "k3", thinking: null, reasoningEffort: "max", maxTokens: 131072, desc: "Kimi For Coding (platform.kimi.com — sk-kimi- keys; NOT interchangeable with Moonshot)" },
20
40
  glm: { baseURL: "https://open.bigmodel.cn/api/paas/v4", model: "glm-5.2", thinking: { type: "enabled" }, reasoningEffort: "max", maxTokens: 128000, desc: "Zhipu GLM" },
@@ -37,7 +57,7 @@ export const PROVIDER_PRESETS = {
37
57
  }
38
58
 
39
59
  export const DEFAULTS = {
40
- activeModel: null, // optional: override provider.model (set via /model picker or /model provider:model)
60
+ defaultModel: null, // top-level "provider:model" composite new-session starting point (F-1)
41
61
  agent: {
42
62
  maxTurns: 200,
43
63
  subagentTurns: 100,
@@ -54,7 +74,19 @@ export const DEFAULTS = {
54
74
  streamRules: [], // time-traveling stream rules: [{ pattern: "regex", message: "reminder", action: "abort"|"warn", repeat: "always"|"once" }]
55
75
  advisor: { guard: false }, // code review is always available; guard: true pushes completion back until reviewed (opt-in). Also accepts provider/model/thinking/reasoningEffort/timeoutMs overrides. Deprecated: enabled (2026-08-21)
56
76
  autoThink: false, // auto-classify task difficulty and set reasoning effort per-turn
57
- engineering: false, // strict methodology enforcement — read METHODOLOGY.md, design-before-code
77
+ engineering: false, // strict methodology enforcement — design-before-code (design review + user approval before code)
78
+ // Async pool limits (AGENT-LOOP.md §11.1 D-24a/R14 + §11.2 R13 — POOL-CONFIG-
79
+ // UNIFIED 2026-09-09): { engCoder, other, advisor } — eng-coder pool / other-role
80
+ // pool / advisor-review pool, defaults 4/4/4 (user ruling "eng-coder 四路,其他
81
+ // 4 路" + advisor 评审池并入同一可配体系——三池统一默认 4)。engCoder/other
82
+ // runtime-validated at every subagent pool admission (subagent-async
83
+ // resolvePoolLimits); advisor runtime-validated at every advisor launch
84
+ // (advisor-async advisorPoolLimitFor——F-2——两域读取器独立不共享)——非法/缺省
85
+ // 回退 4——settings tool 与 /config 并发池菜单写此键——变更下个 spawn/launch 生效。
86
+ // ⚠ 与 subagent-async.mjs ASYNC_POOL_LIMITS(两键)/advisor-async.mjs
87
+ // ADVISOR_POOL_LIMIT 逐键同值(运行时回退常量)——耦合锚 T-24a4 断言锁住——
88
+ // 勿单侧改默认。
89
+ poolLimits: { engCoder: 4, other: 4, advisor: 4 },
58
90
  },
59
91
  memory: {
60
92
  dbPath: join(configDir, "memory.db"),
@@ -70,7 +102,7 @@ export const DEFAULTS = {
70
102
  servers: [],
71
103
  },
72
104
  websearch: {
73
- provider: "tavily", // structured search API; empty apiKey → fall back to Bing HTML scraping
105
+ // Structured search via Tavily when a key is set — empty apiKey → Bing RSS/HTML fallback (zero-config).
74
106
  apiKey: "", // Tavily key (tvly-...) — optional
75
107
  },
76
108
  traces: {
@@ -82,8 +114,8 @@ export const DEFAULTS = {
82
114
  // Model capability table + spec lookup live in model-specs.mjs (2026-08-31
83
115
  // extract — config.mjs had grown past the 300-line advisory). Re-exported here
84
116
  // so the 23 existing importers keep their import paths.
85
- import { specForModel, providerSpec } from "./model-specs.mjs"
86
- export { specForModel, providerSpec }
117
+ import { specForModel, providerSpec, specMatch } from "./model-specs.mjs"
118
+ export { specForModel, providerSpec, specMatch }
87
119
 
88
120
 
89
121
  // Window utilization threshold: compacts at 60% context, reserving 40% headroom
@@ -136,6 +168,44 @@ export function resolveEnableThinking(provider, spec) {
136
168
  /** Module-level one-time warn dedupe for invalid providers[].context (PROVIDER.md §15 D-C1). */
137
169
  const warnedContextProviders = new Set()
138
170
 
171
+ /** F-4 (IKCDMR) 软失败清洗(D-S1 范式——loadConfig 是 CLI 启动砖点):consultModels 非法
172
+ * 条目过滤不 throw——非数组 → []、形状非法/未知渠道丢弃、超 5 截断(keep 前 5)。
173
+ * 返回 { keep, dropped }——dropped 供一次性警告;merged.agent.consultModelsFiltered 挂载
174
+ * 供后续首帧引导消费(数据层——本批文件面无消费方——同 providerInvalidReason 载体先例)。 */
175
+ function sanitizeConsultModels(cm, providerNames) {
176
+ if (cm === undefined || cm === null) return { keep: [], dropped: [] }
177
+ if (!Array.isArray(cm)) {
178
+ return { keep: [], dropped: [`agent.consultModels must be an array of { provider, model } entries (got ${typeof cm})`] }
179
+ }
180
+ const names = new Set(providerNames)
181
+ const keep = []
182
+ const dropped = []
183
+ for (const entry of cm) {
184
+ if (keep.length >= 5) { dropped.push(`over the 5-entry cap — dropped ${JSON.stringify(entry)}`); continue }
185
+ if (!entry || typeof entry !== "object" || typeof entry.provider !== "string" || !entry.provider.trim()
186
+ || typeof entry.model !== "string" || !entry.model.trim()) {
187
+ dropped.push(`invalid entry (expected { provider: string, model: string }) — got ${JSON.stringify(entry)}`)
188
+ continue
189
+ }
190
+ if (!names.has(entry.provider)) {
191
+ dropped.push(`entry "${entry.provider}:${entry.model}" references unknown provider "${entry.provider}" (available: ${[...names].join(", ") || "none"})`)
192
+ continue
193
+ }
194
+ keep.push(entry)
195
+ }
196
+ return { keep, dropped }
197
+ }
198
+
199
+ /** F-4:过滤警告——进程级一次性(loadConfig 同进程可多次调用:TUI 启动 + reloadConfig)。 */
200
+ let warnedConsultModels = false
201
+ function warnConsultModelsFiltered(dropped, path) {
202
+ if (warnedConsultModels || dropped.length === 0) return
203
+ warnedConsultModels = true
204
+ console.warn(`[config] agent.consultModels: ${dropped.length} invalid entr${dropped.length === 1 ? "y ignored" : "ies ignored"} (filtered — startup continues; no crash):\n` +
205
+ dropped.map((d) => ` - ${d}`).join("\n") +
206
+ `\n Fix: clean agent.consultModels in ${path} or use /config → consult/escalate pool menu.`)
207
+ }
208
+
139
209
  /**
140
210
  * Find provider by name in providers[].
141
211
  * Throws if name is non-empty but not found — a typo in activeProvider silently falling to the first provider would use the wrong key on the wrong endpoint.
@@ -179,29 +249,58 @@ function sanitizeProviderHeaders(p) {
179
249
  return p
180
250
  }
181
251
 
252
+ /** loadConfig 内联迁移核已迁 config-migrate.mjs(500 行硬限拆分——VSC 同构)——
253
+ * migrateLegacyModelFields 纯函数 + 本文件 import/写回编排(折中 C 见其头注释)。 */
182
254
  export function loadConfig() {
183
255
  let config = {}
184
- if (existsSync(configPath)) {
256
+ const path = cfgPath()
257
+ if (existsSync(path)) {
185
258
  try {
186
- config = JSON.parse(readFileSync(configPath, "utf8"))
259
+ config = JSON.parse(readFileSync(path, "utf8"))
187
260
  } catch (error) {
188
- throw new Error(`Config file is not valid JSON, check or delete it: ${configPath}\n ${error.message}`, { cause: error })
261
+ throw new Error(`Config file is not valid JSON, check or delete it: ${path}\n ${error.message}`, { cause: error })
262
+ }
263
+ }
264
+
265
+ // ── 迁移(M7 v2):检测老字段(models[] / active* / 垃圾值)→ 内存迁移态先行;写回失败绝不阻断(下次 load 重试——幂等)──
266
+ if (migrateLegacyModelFields(config)) {
267
+ try {
268
+ const r = writeConfigAtomic(path, migrateLegacyModelFields) // 磁盘 fresh raw 同变换
269
+ if (r.ok === false) console.warn(`[config] migration write-back skipped (${r.reason}) — memory state continues, retried on next load`)
270
+ } catch (e) {
271
+ console.warn(`[config] migration write-back failed — memory state continues, retried on next load: ${e.message}`)
189
272
  }
190
273
  }
191
274
 
192
275
  const merged = {
193
276
  ...DEFAULTS,
194
277
  ...config,
278
+ defaultModel: typeof config.defaultModel === "string" && config.defaultModel.trim() ? config.defaultModel : null,
195
279
  providers: Array.isArray(config.providers)
196
280
  ? config.providers.map((p) => sanitizeProviderHeaders({ ...p }))
197
281
  : [],
198
- activeProvider: config.activeProvider ?? "",
199
282
  agent: { ...DEFAULTS.agent, ...config.agent },
200
283
  memory: { ...DEFAULTS.memory, ...config.memory },
201
284
  embedding: { ...DEFAULTS.embedding, ...config.embedding },
202
285
  traces: { ...DEFAULTS.traces, ...config.traces },
203
286
  }
204
287
 
288
+ // 家目录展开(第 29 批):config 路径字段单一规范化点——只读归一(磁盘原文保留)
289
+ merged.memory.dbPath = expandHome(merged.memory.dbPath)
290
+ merged.memory.projectDir = expandHome(merged.memory.projectDir)
291
+ const team = merged.memory.team
292
+ if (team && typeof team === "object" && !Array.isArray(team) && team.dir !== undefined) {
293
+ merged.memory.team = { ...team, dir: expandHome(team.dir) } // 无 dir 键不注入(零键面变化)
294
+ }
295
+ merged.shell = expandHome(merged.shell)
296
+
297
+ // providers[].model 内存归一(v2 M3:非空字符串保留;非字符串/空串归一删除)——渠道默认模型
298
+ // 单值;无默认模型合法(模型选择经 /models 拉取候选——准入判据见 M8/M9)。
299
+ for (const p of merged.providers) {
300
+ if (typeof p.model === "string" && p.model.trim()) continue
301
+ if (p.model !== undefined) delete p.model
302
+ }
303
+
205
304
  // providers[].context (K units, PROVIDER.md §15 D-C1): positive integer only — invalid
206
305
  // values (0/negative/non-numeric) are IGNORED (spec value applies) with a ONE-TIME warn
207
306
  // per provider name (module-level dedupe, same precedent as warnedModels in model-specs.mjs).
@@ -215,27 +314,15 @@ export function loadConfig() {
215
314
  delete p.context
216
315
  }
217
316
 
218
- // Consult/escalate pool validation (CLI parity with the plugin): up to 5 candidates.
219
- const cm = merged.agent.consultModels
220
- if (cm !== undefined && !Array.isArray(cm)) {
221
- throw new Error(`agent.consultModels must be an array of { provider, model } entries (got ${typeof cm})`)
222
- }
223
- if (Array.isArray(cm) && cm.length > 5) {
224
- throw new Error(`agent.consultModels supports at most 5 models (got ${cm.length})`)
225
- }
226
- if (Array.isArray(cm)) {
227
- // Fail fast at load: a pool entry whose provider doesn't exist in providers[] fails
228
- // every consult/escalate call at runtime with a quiet error string (eats a turn).
229
- const providerNames = merged.providers.map((p) => p.name)
230
- for (const entry of cm) {
231
- if (!entry || typeof entry !== "object" || typeof entry.provider !== "string" || typeof entry.model !== "string") {
232
- throw new Error(`agent.consultModels entries must be { provider: string, model: string } objects (got ${JSON.stringify(entry)})`)
233
- }
234
- if (!providerNames.includes(entry.provider)) {
235
- throw new Error(`agent.consultModels entry "${entry.provider}:${entry.model}" references unknown provider "${entry.provider}" (available: ${providerNames.join(", ") || "none"})`)
236
- }
237
- }
238
- }
317
+ // F-4 (IKCDMR) consultModels 软失败化——D-S1 范式(defaultModel 无效同族):非法条目
318
+ // 过滤不 throw(去 startup brick——无修复入口的硬崩消);一次性启动警告内嵌修复指引
319
+ // (引导清条目载体——保留 discoverability);过滤记录挂 merged.agent.consultModelsFiltered
320
+ // (数据层——consumer-ready——首帧 UI 消费点同 D-S1 promptProviderIfInvalid 属后续批)。
321
+ // VSC loadAgentSettings 同规则(共享 config——双端锁步)。
322
+ const cmClean = sanitizeConsultModels(merged.agent.consultModels, merged.providers.map((p) => p.name))
323
+ warnConsultModelsFiltered(cmClean.dropped, path)
324
+ merged.agent.consultModels = cmClean.keep
325
+ if (cmClean.dropped.length) merged.agent.consultModelsFiltered = cmClean.dropped
239
326
 
240
327
  // Backward compatibility: promote root-level config fields to agent sub-object
241
328
  if (config.verifyGuard !== undefined) {
@@ -251,35 +338,24 @@ export function loadConfig() {
251
338
  // 保证 agent.config.proxy 永远是规范形态或 undefined
252
339
  merged.proxy = normalizeProxy(merged.proxy)
253
340
 
254
- // Get the currently active provider
255
- // 2026-09-02 Q1SESSION.md §8):activeProvider 指向不存在的 provider 不再抛错——runtimeProvider
256
- // 置空对象,由 make-agent.mjs assembleAgent 后的校验打 `_providerInvalid` 标记 TUI 引导重选 /
257
- // headless 报可读错误(原 findProvider throw 直接击穿 loadConfig → uncaughtException 退出)。
258
- // findProvider throw 契约保留(advisor/run.mjs 等直接调用方仍依赖)。
259
- let active
260
- try {
261
- active = findProvider(merged.providers, merged.activeProvider)
262
- } catch {
263
- active = {}
264
- }
265
-
266
- // Build runtime provider object (for agent.provider usage)
267
- const runtimeProvider = { ...active }
268
-
269
- // activeModel overrides provider's default model (config only)
270
- if (merged.activeModel) runtimeProvider.model = merged.activeModel
271
- merged.activeModel = merged.activeModel || null // normalize for agent.activeModel
341
+ // Runtime provider = config.defaultModel 复合解析(F-2——resolveRuntimeProvider)。
342
+ // 无效/未设 → {} + providerInvalidReasonD-S1 处置不 throw——make-agent _providerInvalid
343
+ // 标记 TUI 首帧弹选择 / headless 报可读错误;同 2026-09-02 Q1 语义——不复用 findProvider
344
+ // throw 契约——findProvider 保留给 advisor/run.mjs 等直接调用方)。
345
+ merged.provider = resolveRuntimeProvider(merged.providers, merged.defaultModel)
346
+ merged.providerInvalidReason = merged.provider.name
347
+ ? null
348
+ : defaultModelReason(merged.providers, merged.defaultModel)
272
349
 
273
350
  // Compaction threshold follows the model (provider-level context override honored — providerSpec)
274
351
  const explicitThreshold = config.agent?.compactThreshold
275
- const { value, auto } = resolveCompactThreshold(explicitThreshold, runtimeProvider)
352
+ const { value, auto } = resolveCompactThreshold(explicitThreshold, merged.provider)
276
353
  merged.agent.compactThreshold = value
277
354
  merged.agent.compactThresholdAuto = auto
278
355
 
279
356
  // Write back to merged for convenient access by upper layers
280
- merged.provider = runtimeProvider
281
357
  // fetch 超时可配置(2026-09-01:agent.fetchTimeoutMs——provider/core.mjs effectiveFetchTimeoutMs 消费)
282
- runtimeProvider.fetchTimeoutMs = Number.isFinite(merged.agent?.fetchTimeoutMs) && merged.agent.fetchTimeoutMs > 0
358
+ merged.provider.fetchTimeoutMs = Number.isFinite(merged.agent?.fetchTimeoutMs) && merged.agent.fetchTimeoutMs > 0
283
359
  ? merged.agent.fetchTimeoutMs : undefined
284
360
  merged.providersList = merged.providers
285
361
  merged.advisor = { ...merged.agent.advisor } // promote for consistent access (decoupled copy)
@@ -336,7 +412,8 @@ function readMcpSection(path = configPath) {
336
412
  if (!existsSync(path)) return { ok: true, servers: [] }
337
413
  const raw = JSON.parse(readFileSync(path, "utf8"))
338
414
  const servers = raw?.mcp?.servers
339
- if (servers !== undefined && !Array.isArray(servers)) return { ok: true, servers: [] }
415
+ // 非数组 = 畸形磁盘配置(2.3 代码正确性批)——ok:false 走调用方既有畸形回退(reloadMcpFromDisk 早退)
416
+ if (servers !== undefined && !Array.isArray(servers)) return { ok: false, error: "mcp.servers must be an array" }
340
417
  return { ok: true, servers: Array.isArray(servers) ? servers : [] }
341
418
  } catch (error) {
342
419
  return { ok: false, error: error?.message ?? String(error) }
@@ -368,12 +445,52 @@ function mcpFingerprint(s) {
368
445
  }
369
446
 
370
447
  /**
371
- * Save configuration. Preserves providers list structure and activeProvider pointer.
372
- * providers[i].apiKey is only written when explicitly passed in (does not overwrite env-var-fallback keys).
448
+ * R10 F5(D-F5b,2026-09-06)——config.json 写前 mtime 门控收口函数(session-rename
449
+ * mtime-conflict 先例同型——MULTI-INSTANCE-COLLAB.md §2a.2)。所有 config.json 写点
450
+ * (config-helpers persistRaw / cmd-config saveProxy / cli setup-wizard / settings
451
+ * writeDisk)都经它落盘。
452
+ *
453
+ * 本函数持有整条「新鲜读 → mutate 单操作 → 写前重 stat → 写」链:
454
+ * - t0 = 写前重 stat 的比对基线,取在**新鲜读之前**(stat→read 序):若对端在本端
455
+ * stat 与 read 之间的微窗口写入,只会造成假冲突(放弃重试),绝不会带着旧内容覆盖
456
+ * 对端新值——read→stat 序存在漏检窗口(stat 已反映对端新 mtime → 门控放行旧内容)。
457
+ * - 写前重 stat ≠ t0 → **放弃**本次写(D-F5a 后各流已是 fresh 单操作语义,磁盘上对端
458
+ * 的新值保持在线不抹);先 copy `.bak-{ts}` 留现场(仅冲突时——config 低频写不膨胀;
459
+ * copy 而非 rename:冲突即放弃、本体不动,"保现场"是额外副本,非轮转腾位)。
460
+ * - 返回 { ok:false, reason:"mtime-conflict" },调用方提示 "config changed on disk
461
+ * concurrently — retry"——不自动合并(config 是用户显式操作——重试比猜测合并安全,
462
+ * 决策点① A)。
463
+ * - 文件缺失(首写)→ t0 = null;对端在本端读后创建 → null ≠ 新 mtime → 冲突放弃。
464
+ * - 畸形文件拒写(throw,绝不静默覆盖);写后 chmod 0600 尽力而为(saveConfig 旧语义)。
465
+ *
466
+ * @param path config.json 路径(生产默认 configPath;测试注入 tmp 路径)
467
+ * @param mutate 在磁盘新鲜 raw 上执行单操作的同步回调(如 push/splice/单字段补丁)
468
+ * @returns { ok: true } | { ok: false, reason: "mtime-conflict" }
373
469
  */
374
- export function saveConfig(config) {
375
- mkdirSync(configDir, { recursive: true })
470
+ export function writeConfigAtomic(path, mutate) {
471
+ const mtimeOf = (p) => {
472
+ try { return statSync(p).mtimeMs } catch { return null } // 缺失 → null(t0 比对基线)
473
+ }
474
+ const t0 = mtimeOf(path) // stat 先于 read(安全方向——见头注释)
475
+ const text = existsSync(path) ? readFileSync(path, "utf8") : null
476
+ let raw = {}
477
+ if (text !== null) {
478
+ try {
479
+ raw = JSON.parse(text)
480
+ } catch (error) {
481
+ throw new Error(`config file not parseable — refusing to overwrite: ${path} — ${error.message}`, { cause: error })
482
+ }
483
+ }
484
+ mutate(raw)
485
+ const t1 = mtimeOf(path)
486
+ if (t0 !== t1) {
487
+ // 对端在我们新鲜读后改过磁盘 → 放弃本次写(对端内容保持在线);.bak 副本留现场
488
+ try { if (existsSync(path)) copyFileSync(path, `${path}.bak-${Date.now()}`) } catch { /* 现场保留失败不阻断冲突报告 */ }
489
+ return { ok: false, reason: "mtime-conflict" }
490
+ }
491
+ mkdirSync(dirname(path), { recursive: true })
376
492
  // 0600: config.json contains API keys, must not be world-readable (POSIX; chmod is best-effort on Windows)
377
- writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n", { encoding: "utf8", mode: 0o600 })
378
- try { chmodSync(configPath, 0o600) } catch { /* may fail on Windows, ignore */ }
493
+ writeFileSync(path, JSON.stringify(raw, null, 2) + "\n", { encoding: "utf8", mode: 0o600 })
494
+ try { chmodSync(path, 0o600) } catch { /* may fail on Windows, ignore */ }
495
+ return { ok: true }
379
496
  }