thincoder 0.12.53 → 0.12.58

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 (82) hide show
  1. package/CHANGELOG.md +74 -0
  2. package/bin/thincoder.mjs +17 -3
  3. package/package.json +3 -7
  4. package/src/acp/bridge.mjs +1 -1
  5. package/src/acp.mjs +60 -18
  6. package/src/advisor/messages.mjs +4 -2
  7. package/src/advisor/run.mjs +2 -2
  8. package/src/agent/dispatch.mjs +66 -26
  9. package/src/agent/helpers.mjs +13 -2
  10. package/src/agent/setup.mjs +16 -3
  11. package/src/agent/spawn-child.mjs +3 -1
  12. package/src/agent-tools/advisor.mjs +19 -9
  13. package/src/agent-tools/eng.mjs +2 -0
  14. package/src/agent-tools/subagent-check.mjs +107 -0
  15. package/src/agent-tools/subagent.mjs +205 -42
  16. package/src/agent.mjs +68 -3
  17. package/src/cli/make-agent.mjs +25 -0
  18. package/src/cli/memory-command.mjs +28 -7
  19. package/src/config.mjs +120 -8
  20. package/src/context.mjs +28 -7
  21. package/src/escape.mjs +110 -22
  22. package/src/git/checkpoint.mjs +32 -6
  23. package/src/mcp/transport-http.mjs +13 -1
  24. package/src/mcp.mjs +52 -7
  25. package/src/memory/core.mjs +78 -10
  26. package/src/memory/docs.mjs +33 -7
  27. package/src/memory.mjs +1 -1
  28. package/src/model-specs.mjs +23 -0
  29. package/src/prompts/discipline.md +17 -3
  30. package/src/prompts/engineering.md +62 -5
  31. package/src/prompts/main.md +1 -0
  32. package/src/prompts/system.md +2 -1
  33. package/src/provider/anthropic.mjs +7 -5
  34. package/src/provider/core.mjs +90 -26
  35. package/src/provider/google.mjs +57 -24
  36. package/src/provider/normalize.mjs +1 -1
  37. package/src/provider/rate.mjs +0 -2
  38. package/src/provider/responses.mjs +8 -13
  39. package/src/provider/sse.mjs +20 -0
  40. package/src/session-migrate.mjs +6 -0
  41. package/src/session-slots.mjs +361 -0
  42. package/src/session.mjs +282 -306
  43. package/src/tools/apply_patch.md +2 -0
  44. package/src/tools/bash.md +2 -2
  45. package/src/tools/edit-batch.mjs +104 -0
  46. package/src/tools/edit.md +3 -0
  47. package/src/tools/execute.md +4 -4
  48. package/src/tools/execute.mjs +14 -22
  49. package/src/tools/file.mjs +17 -55
  50. package/src/tools/file_ops.md +1 -1
  51. package/src/tools/git-checkpoint.mjs +143 -0
  52. package/src/tools/git-ext.mjs +173 -0
  53. package/src/tools/git.md +21 -8
  54. package/src/tools/git.mjs +55 -177
  55. package/src/tools/lint.md +1 -1
  56. package/src/tools/linter.mjs +9 -37
  57. package/src/tools/patch.mjs +1 -1
  58. package/src/tools/shared.mjs +7 -20
  59. package/src/tui/agent-turn.mjs +3 -3
  60. package/src/tui/ansi.mjs +2 -0
  61. package/src/tui/clipboard.mjs +2 -2
  62. package/src/tui/cmd-eng.mjs +1 -0
  63. package/src/tui/cmd-mcp-form.mjs +197 -0
  64. package/src/tui/cmd-mcp.mjs +255 -114
  65. package/src/tui/cmd-new.mjs +6 -6
  66. package/src/tui/cmd-restore.mjs +27 -6
  67. package/src/tui/cmd-session.mjs +17 -4
  68. package/src/tui/index.mjs +28 -27
  69. package/src/tui/interaction.mjs +28 -1
  70. package/src/tui/key-handler.mjs +14 -2
  71. package/src/tui/layout.mjs +81 -25
  72. package/src/tui/mouse.mjs +41 -2
  73. package/src/tui/pickers.mjs +62 -4
  74. package/src/tui/render-conversation.mjs +36 -93
  75. package/src/tui/render-frame.mjs +40 -16
  76. package/src/tui/render-loop.mjs +1 -1
  77. package/src/tui/render.mjs +4 -4
  78. package/src/tui/startup.mjs +4 -2
  79. package/src/tui/subagent-blocks.mjs +119 -4
  80. package/src/tui/subagent-panel.mjs +81 -0
  81. package/src/tui/tool-events.mjs +61 -16
  82. package/src/tui/tui-lifecycle.mjs +45 -0
package/src/config.mjs CHANGED
@@ -78,8 +78,8 @@ export const DEFAULTS = {
78
78
  // Model capability table + spec lookup live in model-specs.mjs (2026-08-31
79
79
  // extract — config.mjs had grown past the 300-line advisory). Re-exported here
80
80
  // so the 23 existing importers keep their import paths.
81
- import { specForModel } from "./model-specs.mjs"
82
- export { specForModel }
81
+ import { specForModel, providerSpec } from "./model-specs.mjs"
82
+ export { specForModel, providerSpec }
83
83
 
84
84
 
85
85
  // Window utilization threshold: compacts at 60% context, reserving 40% headroom
@@ -87,10 +87,14 @@ export { specForModel }
87
87
  // memory/doc search results) which can consume 30-50K tokens each turn.
88
88
  const COMPACT_RATIO = 0.6
89
89
 
90
- /** Derive compaction threshold; explicit is the value explicitly set in config file (takes priority), otherwise auto-computed from model */
91
- export function resolveCompactThreshold(explicit, model) {
90
+ /** Derive compaction threshold; explicit is the value explicitly set in config file (takes priority), otherwise auto-computed from model.
91
+ * Second param accepts EITHER a model name string (pure spec lookup — legacy caller:
92
+ * first-run wizard) OR a provider object (providerSpec — the providers[].context
93
+ * override in K units is honored, PROVIDER.md §15 T-C2). */
94
+ export function resolveCompactThreshold(explicit, modelOrProvider) {
92
95
  if (explicit != null) return { value: explicit, auto: false }
93
- const spec = specForModel(model)
96
+ const provider = typeof modelOrProvider === "string" ? { model: modelOrProvider } : (modelOrProvider ?? {})
97
+ const spec = providerSpec(provider)
94
98
  const value = Math.floor(spec.context * COMPACT_RATIO)
95
99
  return { value, auto: true }
96
100
  }
@@ -125,6 +129,9 @@ export function resolveEnableThinking(provider, spec) {
125
129
  return undefined
126
130
  }
127
131
 
132
+ /** Module-level one-time warn dedupe for invalid providers[].context (PROVIDER.md §15 D-C1). */
133
+ const warnedContextProviders = new Set()
134
+
128
135
  /**
129
136
  * Find provider by name in providers[].
130
137
  * 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.
@@ -190,6 +197,19 @@ export function loadConfig() {
190
197
  embedding: { ...DEFAULTS.embedding, ...config.embedding },
191
198
  }
192
199
 
200
+ // providers[].context (K units, PROVIDER.md §15 D-C1): positive integer only — invalid
201
+ // values (0/negative/non-numeric) are IGNORED (spec value applies) with a ONE-TIME warn
202
+ // per provider name (module-level dedupe, same precedent as warnedModels in model-specs.mjs).
203
+ for (const p of merged.providers) {
204
+ if (p.context === undefined) continue
205
+ if (Number.isInteger(Number(p.context)) && Number(p.context) > 0) { p.context = Number(p.context); continue } // 数字字符串("128")归一为数字——两端语义统一(code review #1)
206
+ if (!warnedContextProviders.has(p.name ?? "(unnamed)")) {
207
+ warnedContextProviders.add(p.name ?? "(unnamed)")
208
+ console.warn(`[config] provider "${p.name}" context must be a positive integer in K units (e.g. 128 = 128K) — got ${JSON.stringify(p.context)} — ignored, using the model spec value`)
209
+ }
210
+ delete p.context
211
+ }
212
+
193
213
  // Consult/escalate pool validation (CLI parity with the plugin): up to 5 candidates.
194
214
  const cm = merged.agent.consultModels
195
215
  if (cm !== undefined && !Array.isArray(cm)) {
@@ -227,7 +247,16 @@ export function loadConfig() {
227
247
  merged.proxy = normalizeProxy(merged.proxy)
228
248
 
229
249
  // Get the currently active provider
230
- const active = findProvider(merged.providers, merged.activeProvider)
250
+ // 2026-09-02 Q1(SESSION.md §8):activeProvider 指向不存在的 provider 不再抛错——runtimeProvider
251
+ // 置空对象,由 make-agent.mjs assembleAgent 后的校验打 `_providerInvalid` 标记 → TUI 引导重选 /
252
+ // headless 报可读错误(原 findProvider throw 直接击穿 loadConfig → uncaughtException 退出)。
253
+ // findProvider 的 throw 契约保留(advisor/run.mjs 等直接调用方仍依赖)。
254
+ let active
255
+ try {
256
+ active = findProvider(merged.providers, merged.activeProvider)
257
+ } catch {
258
+ active = {}
259
+ }
231
260
 
232
261
  // Build runtime provider object (for agent.provider usage)
233
262
  const runtimeProvider = { ...active }
@@ -236,20 +265,103 @@ export function loadConfig() {
236
265
  if (merged.activeModel) runtimeProvider.model = merged.activeModel
237
266
  merged.activeModel = merged.activeModel || null // normalize for agent.activeModel
238
267
 
239
- // Compaction threshold follows the model
268
+ // Compaction threshold follows the model (provider-level context override honored — providerSpec)
240
269
  const explicitThreshold = config.agent?.compactThreshold
241
- const { value, auto } = resolveCompactThreshold(explicitThreshold, runtimeProvider.model)
270
+ const { value, auto } = resolveCompactThreshold(explicitThreshold, runtimeProvider)
242
271
  merged.agent.compactThreshold = value
243
272
  merged.agent.compactThresholdAuto = auto
244
273
 
245
274
  // Write back to merged for convenient access by upper layers
246
275
  merged.provider = runtimeProvider
276
+ // fetch 超时可配置(2026-09-01:agent.fetchTimeoutMs——provider/core.mjs effectiveFetchTimeoutMs 消费)
277
+ runtimeProvider.fetchTimeoutMs = Number.isFinite(merged.agent?.fetchTimeoutMs) && merged.agent.fetchTimeoutMs > 0
278
+ ? merged.agent.fetchTimeoutMs : undefined
247
279
  merged.providersList = merged.providers
248
280
  merged.advisor = { ...merged.agent.advisor } // promote for consistent access (decoupled copy)
249
281
 
250
282
  return merged
251
283
  }
252
284
 
285
+ /**
286
+ * MCP.md §5 D-3 (2026-09-01): re-read config.json and replace ONLY the agent's mcp section
287
+ * — the agent 代配 closed loop (agent edits config.json with its edit tool, /mcp picks it
288
+ * up). Never touches other config sections (providers/activeProvider stay as loaded).
289
+ *
290
+ * Malformed disk config → memory state kept, { ok:false, error } returned (the /mcp menu
291
+ * shows "⚠ disk config unreadable"). Never throws.
292
+ *
293
+ * 对账 (reconciliation, MCP.md §5 D-3 / T23): returns which disk servers CHANGED
294
+ * (fingerprint differs) or are DELETED from disk while still connected — fingerprint =
295
+ * endpoint + token + headers/env key order. Existing connections are NOT torn down (an
296
+ * in-use server must not be dropped): a deleted-but-connected server KEEPS its memory
297
+ * entry (appended after the disk list) so the /mcp list can still show the row with the
298
+ * "⚠ disk changed" mark. A server that is merely NEW on disk is not drift. persistRaw
299
+ * write + reload is idempotent (fingerprints equal → no drift mark).
300
+ *
301
+ * @param path optional config path override (tests inject a tmp file; default configPath)
302
+ */
303
+ export function reloadMcpFromDisk(agent, path) {
304
+ const memoryServers = Array.isArray(agent.config?.mcp?.servers) ? agent.config.mcp.servers : []
305
+ const fileExists = existsSync(path ?? configPath)
306
+ const diskMcp = readMcpSection(path)
307
+ if (!diskMcp.ok) return { ok: false, error: diskMcp.error, changedNames: [] }
308
+ // Missing/deleted config file → keep whichever mcp servers the session had (never
309
+ // silently drop user servers because the file vanished — same memory-keeps policy
310
+ // as the malformed-disk fallback).
311
+ let diskServers = diskMcp.servers
312
+ if (diskServers.length === 0 && !fileExists) diskServers = memoryServers
313
+ // Drift vs the RAW disk list: fingerprint-changed or deleted-from-disk (T23 ⚠ 标记依据)
314
+ const diskNames = new Set(diskServers.filter((s) => s?.name).map((s) => s.name))
315
+ const changedNames = diffMcpServers(memoryServers, diskServers)
316
+ // Connected servers deleted from disk stay in the list (memory copy) — T23: the row
317
+ // must remain visible (marked ⚠) and its live connection untouched. They are already
318
+ // in changedNames (absent from disk), and stay flagged on every reload until the user
319
+ // reconnects (re-persists them) or removes them — real drift, honestly reported.
320
+ const connectedNames = new Set((agent.tools ?? []).filter((t) => t?._mcpName).map((t) => t._mcpName))
321
+ const keptConnected = memoryServers.filter((s) => s?.name && connectedNames.has(s.name) && !diskNames.has(s.name))
322
+ const finalServers = [...diskServers, ...keptConnected]
323
+ agent.config ??= {}
324
+ agent.config.mcp = { ...agent.config.mcp, servers: finalServers }
325
+ return { ok: true, servers: finalServers, changedNames }
326
+ }
327
+
328
+ /** Disk read behind reloadMcpFromDisk — bounded, never throws. */
329
+ function readMcpSection(path = configPath) {
330
+ try {
331
+ if (!existsSync(path)) return { ok: true, servers: [] }
332
+ const raw = JSON.parse(readFileSync(path, "utf8"))
333
+ const servers = raw?.mcp?.servers
334
+ if (servers !== undefined && !Array.isArray(servers)) return { ok: true, servers: [] }
335
+ return { ok: true, servers: Array.isArray(servers) ? servers : [] }
336
+ } catch (error) {
337
+ return { ok: false, error: error?.message ?? String(error) }
338
+ }
339
+ }
340
+
341
+ /** Fingerprint = endpoint + token + headers/env entries in key order (JSON.stringify
342
+ * of a normalized subset — key order included, matching connectMcpServer's
343
+ * configFingerprint semantics: any change the connect layer would see counts).
344
+ * Drift = CHANGED (fingerprint differs) or DELETED (missing from disk) — a server
345
+ * that is new on disk is not drift (no live connection to protect). */
346
+ function diffMcpServers(memoryServers, diskServers) {
347
+ const memFp = new Map(memoryServers.filter((s) => s?.name).map((s) => [s.name, mcpFingerprint(s)]))
348
+ const diskFp = new Map(diskServers.filter((s) => s?.name).map((s) => [s.name, mcpFingerprint(s)]))
349
+ const changed = []
350
+ for (const [name, fp] of diskFp) if (memFp.has(name) && memFp.get(name) !== fp) changed.push(name)
351
+ for (const name of memFp.keys()) if (!diskFp.has(name)) changed.push(name)
352
+ return changed
353
+ }
354
+
355
+ function mcpFingerprint(s) {
356
+ return JSON.stringify([
357
+ s.wsUrl ?? s.url ?? s.command ?? null,
358
+ s.args ?? null,
359
+ s.token ?? null,
360
+ s.headers ?? null,
361
+ s.env ?? null,
362
+ ])
363
+ }
364
+
253
365
  /**
254
366
  * Save configuration. Preserves providers list structure and activeProvider pointer.
255
367
  * providers[i].apiKey is only written when explicitly passed in (does not overwrite env-var-fallback keys).
package/src/context.mjs CHANGED
@@ -11,7 +11,7 @@
11
11
 
12
12
  import { chat } from "./provider/index.mjs"
13
13
  import { estimateText } from "./provider/rate.mjs"
14
- import { specForModel } from "./config.mjs"
14
+ import { providerSpec } from "./config.mjs"
15
15
 
16
16
  const IMAGE_TOKEN_ESTIMATE = 2000 // rough estimate for image content tokens (CLI legacy 256 underestimated real image costs, delaying compaction)
17
17
 
@@ -41,10 +41,11 @@ const KEEP_HEAD = 0 // No dedicated head: earliest messages may be a COMPLETED e
41
41
  // capped at 40% of history so small histories don't over-reserve. Window-adaptive
42
42
  // replaces the old fixed 10: on a 1M window, 10 messages is too thin for recent work.
43
43
  function keepTailSize(provider, historyLen) {
44
- // provider is guaranteed at every call site (runAgent always builds one); specForModel
45
- // degrades to DEFAULT_SPEC (128K) only if provider/model is somehow absent — acceptable
46
- // because the 40% history cap still bounds the tail.
47
- const ctxWindow = specForModel(provider?.model ?? "").context
44
+ // provider is guaranteed at every call site (runAgent always builds one); providerSpec
45
+ // degrades to DEFAULT_SPEC (128K) only if provider is somehow absent — acceptable
46
+ // because the 40% history cap still bounds the tail. providers[].context override
47
+ // (K units) is honored here (PROVIDER.md §15 T-C2: tail formula follows the window).
48
+ const ctxWindow = providerSpec(provider).context
48
49
  return Math.min(Math.max(10, Math.floor((ctxWindow / 100_000) * 30)), Math.floor(historyLen * 0.4))
49
50
  }
50
51
 
@@ -195,8 +196,10 @@ function applyCompression(agent, headEnd, tailStart, note) {
195
196
  * Automatically re-injects task list state after compaction.
196
197
  * @param {object} agent
197
198
  * @param {number} threshold - compaction threshold in tokens
198
- * @param {object} callbacks - { onToken, onReasoning, onCompress } — summary generation is SILENT
199
- * (never forwards onToken/onReasoning: the compaction process is an internal mechanism, not a model reply)
199
+ * @param {object} callbacks - { onToken, onReasoning, onCompress, onCompressStart } — summary
200
+ * generation is SILENT (never forwards onToken/onReasoning: the compaction process is an
201
+ * internal mechanism, not a model reply); onCompressStart fires right before the summary call
202
+ * (§7 D-C1, compression lifecycle visibility — panel start state)
200
203
  * @param {object} extras - { systemPrompt?, tools? } — estimated overhead for the pure-estimation
201
204
  * path (no measured baseline); the measured path already includes system+tools in prompt_tokens.
202
205
  */
@@ -239,6 +242,11 @@ export async function compressIfNeeded(agent, threshold, callbacks, extras = {},
239
242
  // The summary is a plain-text task, no reasoning needed — passing thinking to the compaction provider wastes tokens.
240
243
  // Silent by design (D11): no onToken/onReasoning — the compaction process must not stream to the frontend.
241
244
  // signal propagates user cancellation (Ctrl+C) to the in-flight summary call.
245
+ // Compression visibility (CONTEXT-COMPACTION.md §7 D-C1/D-C2): the frontend learns the compression
246
+ // STARTED right before the LLM call ("Compressing context… / summarizing N messages" panel) — only
247
+ // the lifecycle is surfaced, never the summary body. N = the number of history messages being summarized.
248
+ callbacks?.onCompressStart?.({ messages: middle.length })
249
+ const startedAt = performance.now()
242
250
  const summary = await chat({ ...agent.provider, thinking: null, reasoningEffort: null }, {
243
251
  messages: [{ role: "user", content: SUMMARIZE_PROMPT + serialized }],
244
252
  signal,
@@ -246,6 +254,15 @@ export async function compressIfNeeded(agent, threshold, callbacks, extras = {},
246
254
 
247
255
  applyCompression(agent, split.headEnd, split.tailStart, COMPACTION_PREFIX + summary.content)
248
256
 
257
+ // Completion info for the compression panel (D-C2): tokens freed = the pre-compression prompt
258
+ // estimate (`tokens` — the value that tripped the threshold, incl. system/tools overhead on the
259
+ // pure-estimation path) minus the post-compression estimate on the same basis. Elapsed = the
260
+ // summary call + splice duration. agent.mjs forwards this to onCompress unchanged.
261
+ agent._lastCompressInfo = {
262
+ mode: "summary",
263
+ tokensFreed: Math.max(0, Math.round(tokens - (estimateTokens(agent.history) + overhead))),
264
+ elapsedMs: performance.now() - startedAt,
265
+ }
249
266
  return true
250
267
  }
251
268
 
@@ -257,7 +274,11 @@ export function compressFallback(agent) {
257
274
  const keepTail = keepTailSize(agent.provider, agent.history.length)
258
275
  const split = splitHistory(agent.history, keepTail)
259
276
  if (!split) return false
277
+ const tailMessages = agent.history.length - split.tailStart
260
278
  applyCompression(agent, split.headEnd, split.tailStart, FALLBACK_NOTE)
279
+ // Fallback completion info (D-C2): mode marks the deterministic-truncation path — the panel
280
+ // shows the degradation note ("truncated to N messages") ONLY after 3 consecutive failures.
281
+ agent._lastCompressInfo = { mode: "fallback", tailMessages }
261
282
  return true
262
283
  }
263
284
 
package/src/escape.mjs CHANGED
@@ -1,36 +1,99 @@
1
1
  /**
2
- * escape.mjs — 中和 OpenAI 兼容服务端在 message content 内做的非标二次转义解析。
2
+ * escape.mjs — 中和 OpenAI 兼容服务端在 message content 内做的非标二次转义解析 + 孤立代理净化。
3
3
  *
4
- * 某些服务端(Kimi 等)会把 content 里的字面 "\x" / "\u" 当作 hex escape 再解释一遍,
5
- * 遇到 "\x" 后不足 2 个 hex(或 "\u" 后不足 4 个 hex)时服务端报
6
- * "unexpected end of hex escape" → 400(首次观察于 2026-08-06,见 advisor.mjs 历史)。
4
+ * 两个独立毒源(均真机实证):
7
5
  *
8
- * 对策:把这类"一旦被服务端二次展开就会非法"的字面序列提前 double "\\x" / "\\u",
9
- * 服务端二次解析后还原为字面量;合法完整的 "\xNN" / "\uNNNN" 原样放过(它们能展开成
10
- * 一个字节/码点)。JSON.stringify 层面的反斜杠转义由发送方负责,本模块不碰。
6
+ * 字面 hex 转义二次解析(2026-08-06 Kimi 首观察):Kimi/deepseek 等网关会把 content 里的字面
7
+ * "\x5Cx" / "\x5Cu" 当作 hex escape 再解释一遍,不足位时 400("unexpected end of hex escape")。
8
+ * 对策:不足位序列前 double 反斜杠(\\x5CxNN 形态还原为字面量);合法完整序列放行。
9
+ * Known limitation(2026-09-01 v3 修复):反斜杠 run ≥3 时(如 "\\\x5Cu" 三反斜杠+u)v1 的
10
+ * lookbehind 只看前 1 字符会整体放行,但二次解析按配对消费后尾部的 \x5Cu 仍裸露 → 炸。
11
+ * 修复:按 run 奇偶判断——run 为奇数时尾部的 \x5Cu/\x5Cx 裸露(需 double),偶数已配对(放行)。
12
+ *
13
+ * ② 孤立 UTF-16 代理对(2026-09-02 deepseek 真机实锤):content 里的**真实孤立代理字符**
14
+ * (高代理 U+D800-DBFF 无低代理跟随,或低代理 U+DC00-DFFF 无高代理前置)——JSON.stringify
15
+ * 输出 \ud83d(合法 JSON),但 deepseek 解析器严格 UTF-16 解码,孤立代理 → 400
16
+ * ("unexpected end of hex escape" / "lone leading surrogate in hex escape")。
17
+ * 来源实证:doc_search 结果预览 `slice(0, N)` 按 UTF-16 码元截断,emoji 🔴(代理对)恰在
18
+ * 截断边界被切成孤立高代理 → 注入 system reminder → 每轮发送 → deepseek 400。
19
+ * 对策:发送前把孤立代理替换为 U+FFFD(任何来源安全兜底);源头截断点另修 UTF-16 安全切。
11
20
  */
12
21
 
13
- /** 中和单段文本里的非法字面转义序列。 */
22
+ /** 中和非法字面 hex 转义序列(毒源①)。 */
14
23
  export function escapeLiteralEscapes(text) {
15
24
  text = String(text ?? "")
16
- return text
17
- // (?<!\\) 只有单个反斜杠才处理("\\x" 已经是 double 的,必须原样放过)
18
- // 前瞻:\x 后至少 2 个 hex 视为合法(服务端只展开前两个),只有不足 2 个的才 double
19
- .replace(/(?<!\\)\\(x)(?![0-9a-fA-F]{2})/g, "\\\\$1")
20
- .replace(/(?<!\\)\\(u)(?![0-9a-fA-F]{4})/g, "\\\\$1")
25
+ let out = ""
26
+ let i = 0
27
+ const n = text.length
28
+ while (i < n) {
29
+ const ch = text[i]
30
+ if (ch !== "\\") { out += ch; i++; continue }
31
+ // 数反斜杠 run 长度
32
+ let run = 0
33
+ while (i + run < n && text[i + run] === "\\") run++
34
+ const next = text[i + run]
35
+ if ((next === "x" || next === "u") && run % 2 === 1) {
36
+ // run 奇数 → 二次解析配对消费后尾部 \x5Cx/\x5Cu 裸露——hex 不足则网关炸 → 前插反斜杠 double
37
+ const need = next === "u" ? 4 : 2
38
+ const after = text.slice(i + run + 1, i + run + 1 + need)
39
+ if (!new RegExp(`^[0-9a-fA-F]{${need}}$`).test(after)) {
40
+ out += "\\".repeat(run + 1) + next
41
+ i += run + 1
42
+ continue
43
+ }
44
+ // 合法完整:输出全序列并跳过(hex 尾不重新扫描)
45
+ out += text.slice(i, i + run + 1 + need)
46
+ i += run + 1 + need
47
+ continue
48
+ }
49
+ out += "\\".repeat(run)
50
+ i += run
51
+ }
52
+ return out
53
+ }
54
+
55
+ /** 净化孤立 UTF-16 代理对(毒源②):高代理无低代理跟随 / 低代理无高代理前置 → 替换为 。 */
56
+ export function sanitizeLoneSurrogates(text) {
57
+ text = String(text ?? "")
58
+ let out = ""
59
+ let i = 0
60
+ const n = text.length
61
+ while (i < n) {
62
+ const cp = text.charCodeAt(i)
63
+ if (cp >= 0xd800 && cp <= 0xdbff) {
64
+ const next = text.charCodeAt(i + 1)
65
+ if (next >= 0xdc00 && next <= 0xdfff) { out += text[i] + text[i + 1]; i += 2; continue }
66
+ out += ""; i++; continue // 孤立高代理
67
+ }
68
+ if (cp >= 0xdc00 && cp <= 0xdfff) { out += ""; i++; continue } // 孤立低代理
69
+ out += text[i]; i++
70
+ }
71
+ return out
72
+ }
73
+
74
+ /** 发送前文本净化总入口:hex 转义中和 + 孤立代理净化。 */
75
+ export function sanitizeText(text) {
76
+ return sanitizeLoneSurrogates(escapeLiteralEscapes(text))
21
77
  }
22
78
 
23
- /** 对单条消息的 content 应用 escapeLiteralEscapes(支持字符串或 OpenAI 多模态 part 数组)。 */
79
+ /** 对单条消息的 content 应用 sanitizeText(支持字符串或 OpenAI 多模态 part 数组)。
80
+ * 2026-08-31 会诊 F5:deepseek-v4-flash 网关对 tool_calls[].function.arguments 与
81
+ * reasoning_content 做同样的非标二次转义解析(字面 \\x5Cx/\\x5Cu 经工具参数/思考回传 → 400,
82
+ * 列号确定性复现 = 毒序列在 content 之外)——这两个字符串字段同样需要中和。 */
24
83
  export function escapeMessageContent(message) {
25
84
  const content = message?.content
85
+ let changed = false
86
+ let next = message
26
87
  if (typeof content === "string") {
27
- return { ...message, content: escapeLiteralEscapes(content) }
28
- }
29
- if (Array.isArray(content)) {
30
- let changed = false
88
+ const escaped = sanitizeText(content)
89
+ if (escaped !== content) {
90
+ next = { ...next, content: escaped }
91
+ changed = true
92
+ }
93
+ } else if (Array.isArray(content)) {
31
94
  const parts = content.map((p) => {
32
95
  if (p && typeof p === "object" && p.type === "text" && typeof p.text === "string") {
33
- const escaped = escapeLiteralEscapes(p.text)
96
+ const escaped = sanitizeText(p.text)
34
97
  if (escaped !== p.text) {
35
98
  changed = true
36
99
  return { ...p, text: escaped }
@@ -38,9 +101,34 @@ export function escapeMessageContent(message) {
38
101
  }
39
102
  return p
40
103
  })
41
- return changed ? { ...message, content: parts } : message
104
+ if (changed) next = { ...next, content: parts }
105
+ }
106
+ if (Array.isArray(next.tool_calls)) {
107
+ let tcChanged = false
108
+ const tool_calls = next.tool_calls.map((tc) => {
109
+ const args = tc?.function?.arguments
110
+ if (typeof args === "string") {
111
+ const escaped = sanitizeText(args)
112
+ if (escaped !== args) {
113
+ tcChanged = true
114
+ return { ...tc, function: { ...tc.function, arguments: escaped } }
115
+ }
116
+ }
117
+ return tc
118
+ })
119
+ if (tcChanged) {
120
+ next = { ...next, tool_calls }
121
+ changed = true
122
+ }
42
123
  }
43
- return message
124
+ if (typeof next.reasoning_content === "string") {
125
+ const escaped = sanitizeText(next.reasoning_content)
126
+ if (escaped !== next.reasoning_content) {
127
+ next = { ...next, reasoning_content: escaped }
128
+ changed = true
129
+ }
130
+ }
131
+ return changed ? next : message
44
132
  }
45
133
 
46
134
  /** IKBGX4 (2026-08-28):剥离仅本地使用的整消息标记字段(transient 等)——发送给 provider 前移除。
@@ -59,4 +147,4 @@ export function stripLocalMessageFields(messages) {
59
147
  /** 对整个 messages 数组逐条应用 escapeMessageContent(先剥离本地字段,再转义)。 */
60
148
  export function escapeMessages(messages) {
61
149
  return stripLocalMessageFields(messages).map(escapeMessageContent)
62
- }
150
+ }
@@ -16,7 +16,7 @@ import { configDir } from "../config.mjs"
16
16
 
17
17
  const CWD_HASH_LEN = 12
18
18
 
19
- const MAX_CHECKPOINTS = 20
19
+ const MAX_CHECKPOINTS = 100
20
20
 
21
21
  /** Files larger than this are NOT copied (sqlite db, bundles…) — they are recorded as skipped. */
22
22
  const MAX_FILE_BYTES = 5 * 1024 * 1024
@@ -33,8 +33,16 @@ function git(cwd, args, { allowFail = false } = {}) {
33
33
  }
34
34
  }
35
35
 
36
+ /** Normalize cwd for hashing: uppercase the Windows drive letter so the VS Code
37
+ * extension's uri.fsPath (lowercased) produces the SAME cwdHash12 as the CLI's
38
+ * process.cwd() — cross-end snapshot sharing (CHECKPOINT.md F5/T7) depends on this
39
+ * contract. Same normalization as session storage (session-slots.mjs normalizeCwd). */
40
+ function normalizeCwd(cwd) {
41
+ return cwd.replace(/^([a-z]):/, (_, d) => d.toUpperCase() + ":")
42
+ }
43
+
36
44
  function checkpointRoot(cwd) {
37
- const hash = createHash("sha1").update(cwd).digest("hex").slice(0, CWD_HASH_LEN)
45
+ const hash = createHash("sha1").update(normalizeCwd(cwd)).digest("hex").slice(0, CWD_HASH_LEN)
38
46
  return join(configDir, "checkpoints", hash)
39
47
  }
40
48
 
@@ -412,11 +420,29 @@ export async function catFile(cwd, id, filePath) {
412
420
  }
413
421
  }
414
422
 
415
- /** Keep only the most recent MAX_CHECKPOINTS */
416
- async function pruneCheckpoints(cwd) {
423
+ /** F6: delete ALL checkpoints for a cwd — commit = new safety baseline. Best-effort:
424
+ * a missing dir is a no-op; deletion failures propagate so the git tool's commit case
425
+ * can report "(checkpoint cleanup skipped: …)" without blocking the commit result. */
426
+ export async function deleteCheckpointsForCwd(cwd) {
427
+ await rm(checkpointRoot(cwd), { recursive: true, force: true })
428
+ }
429
+
430
+ /** NF6: keep only the most recent `count` snapshots — oldest removed first (id sort =
431
+ * timestamp prefix, so ascending order IS oldest-first). Returns how many were deleted
432
+ * (0 when the cwd has no checkpoint dir). */
433
+ export async function deleteCheckpointsOlderThan(cwd, count) {
417
434
  const root = checkpointRoot(cwd)
418
- const ids = (await readdir(root)).sort()
419
- while (ids.length > MAX_CHECKPOINTS) {
435
+ let ids
436
+ try { ids = (await readdir(root)).sort() } catch { return 0 }
437
+ let removed = 0
438
+ while (ids.length > count) {
420
439
  await rm(join(root, ids.shift()), { recursive: true, force: true })
440
+ removed++
421
441
  }
442
+ return removed
443
+ }
444
+
445
+ /** Keep only the most recent MAX_CHECKPOINTS (NF6 cap — runs at the end of every create) */
446
+ async function pruneCheckpoints(cwd) {
447
+ await deleteCheckpointsOlderThan(cwd, MAX_CHECKPOINTS)
422
448
  }
@@ -9,6 +9,9 @@ export function httpTransport(baseURL, extraHeaders = {}) {
9
9
  let sessionId = null
10
10
  let closed = false
11
11
  let eventSource = null
12
+ // 2026-09-01 MCP.md §4 D-1:GET SSE 不可用(405/不支持)降级后的纯 POST 模式标记。
13
+ // POST-only server(如 glm-websearch)无流可断:isAlive 不得因 eventSource == null 判死。
14
+ let postOnly = false
12
15
  let abortController = null
13
16
  let postUrl = url
14
17
  let legacySSE = false
@@ -232,5 +235,14 @@ export function httpTransport(baseURL, extraHeaders = {}) {
232
235
  pending.clear()
233
236
  }
234
237
 
235
- return { send, notify, close, openSSE, url, headers: extraHeaders, isAlive: () => !closed && eventSource != null, onDead }
238
+ /** D-1:openSSE 降级(GET 405/不支持)后由 connect 链调用——标记纯 Streamable POST 模式 */
239
+ const markPostOnly = () => { postOnly = true }
240
+
241
+ return {
242
+ send, notify, close, openSSE, url, headers: extraHeaders,
243
+ // F1:POST-only 降级(postOnly)与 legacy SSE 流(eventSource)都算活连接——
244
+ // 不得因 eventSource == null 误判死(glm-websearch "reconnect failed" 根因)。
245
+ isAlive: () => !closed && (eventSource != null || postOnly),
246
+ onDead, markPostOnly,
247
+ }
236
248
  }
package/src/mcp.mjs CHANGED
@@ -7,6 +7,12 @@ import { stdioTransport } from "./mcp/transport-stdio.mjs"
7
7
  import { httpTransport } from "./mcp/transport-http.mjs"
8
8
  import { wsTransport } from "./mcp/transport-ws.mjs"
9
9
 
10
+ /** F6/D-5:config.token → `Authorization: Bearer <token>` 合成(仅当 headers 未显式给
11
+ * Authorization——显式优先,向后兼容)。合成发生在传给 transport 前,不写回 config。 */
12
+ export function withBearerToken(config) {
13
+ if (!config?.token || config.headers?.Authorization) return config
14
+ return { ...config, headers: { ...config.headers, Authorization: `Bearer ${config.token}` } }
15
+ }
10
16
 
11
17
  /** Race a pending MCP request against an abort signal — a hung MCP server must not
12
18
  * hold the turn hostage. signal absent → passthrough. */
@@ -31,8 +37,9 @@ async function sendWithSignal(promise, signal) {
31
37
 
32
38
  /** 2026-08-31 MCP 会诊 P5:CLI session 注册表(serverName → session)。
33
39
  * session.state.transport 可变(重连替换);buildTools 的 execute 动态取
34
- * session.state.transport——server 崩溃后无需重建 agent.tools 即自愈。 */
35
- const _sessions = new Map()
40
+ * session.state.transport——server 崩溃后无需重建 agent.tools 即自愈。
41
+ * 2026-09-01 MCP.md §4:导出给 /mcp test 的零副作用断言用(probe 后 _sessions 不增)。 */
42
+ export const _sessions = new Map()
36
43
  /** 退避重连进行中(serverName → promise)——与 vscode 语义对齐;延迟表见 _mcpHooks。 */
37
44
  const _reconnecting = new Map()
38
45
 
@@ -44,7 +51,8 @@ export const _mcpHooks = {
44
51
  }
45
52
 
46
53
  /** 按 config 创建并完成握手的 transport(findTransportConfig 与 vscode 对齐)。 */
47
- async function createConnectedTransport(config, serverName) {
54
+ async function createConnectedTransport(rawConfig, serverName) {
55
+ const config = withBearerToken(rawConfig) // F6/D-5:token 合成(不写回原 config)
48
56
  let transport
49
57
  if (config.wsUrl) {
50
58
  transport = wsTransport(config.wsUrl, config.headers ?? {})
@@ -54,7 +62,10 @@ async function createConnectedTransport(config, serverName) {
54
62
  try {
55
63
  await transport.openSSE()
56
64
  } catch {
57
- // Server doesn't support GET SSE — degrade to pure Streamable HTTP POST mode
65
+ // Server doesn't support GET SSE — degrade to pure Streamable HTTP POST mode.
66
+ // MCP.md §4 D-1:显式标记 postOnly——POST-only server(glm-websearch 类)isAlive
67
+ // 不得因 eventSource == null 误判死(否则 ensureAlive 触发无意义重连循环)。
68
+ transport.markPostOnly()
58
69
  }
59
70
  } else {
60
71
  transport = stdioTransport(config.command, config.args ?? [], config.env)
@@ -63,6 +74,25 @@ async function createConnectedTransport(config, serverName) {
63
74
  return { transport, mcpTools }
64
75
  }
65
76
 
77
+ /** F4/D-2:一次性探活——createConnectedTransport(initialize + tools/list)+ 计时。
78
+ * 零副作用:不进 _sessions、不动 agent.tools、无 onDead 挂钩;finally close
79
+ * (closed=true 使 onDead 重连不会触发)。initialize 与 tools/list 同受
80
+ * INIT_TIMEOUT_MS 约束(见 doInitialize)。不复用 connectMcpServer(避免污染
81
+ * session 幂等表)。 */
82
+ export async function probeMcpServer(config) {
83
+ const start = Date.now()
84
+ let transport
85
+ let mcpTools
86
+ try {
87
+ ;({ transport, mcpTools } = await createConnectedTransport(config, config.name ?? config.command ?? config.url ?? config.wsUrl))
88
+ return { ok: true, toolCount: mcpTools.length, latencyMs: Date.now() - start }
89
+ } catch (error) {
90
+ return { ok: false, error: error?.message ?? String(error) }
91
+ } finally {
92
+ try { transport?.close() } catch { /* ignore */ }
93
+ }
94
+ }
95
+
66
96
  /** onDead → 后台退避重连;成功替换 session.state.transport(tools 闭包动态引用,
67
97
  * agent.tools 无需重建);失败静默(下次 execute 前置检查再试)。 */
68
98
  function scheduleReconnect(name, config) {
@@ -173,10 +203,14 @@ async function doInitialize(transport, _name) {
173
203
 
174
204
  // 2026-08-31 MCP 会诊 P6:tools/list 分页被忽略(nextCursor 多页工具静默丢失)——
175
205
  // 循环跟随 cursor 直到 server 不再返回(上限 20 页防死循环)。
206
+ // MCP.md §4 评审 #8:每页同受 INIT_TIMEOUT_MS 约束(否则 probe 的延迟统计无界)。
176
207
  const tools = []
177
208
  let cursor
178
209
  for (let page = 0; page < 20; page++) {
179
- const toolsResp = await transport.send("tools/list", cursor ? { cursor } : {})
210
+ const toolsResp = await withTimeout(
211
+ transport.send("tools/list", cursor ? { cursor } : {}),
212
+ INIT_TIMEOUT_MS,
213
+ )
180
214
  if (toolsResp.error) throw new Error(`tools/list failed: ${toolsResp.error.message}`)
181
215
  tools.push(...(toolsResp.result?.tools ?? []))
182
216
  cursor = toolsResp.result?.nextCursor
@@ -192,7 +226,9 @@ export async function connectMcpServer(config) {
192
226
  if (!config || (!config.command && !config.url && !config.wsUrl))
193
227
  throw new Error(`MCP server "${config?.name ?? ""}": needs either 'wsUrl' (websocket), 'command' (stdio), or 'url' (http)`)
194
228
  const name = config.name ?? config.command ?? config.url ?? config.wsUrl
195
- const configFingerprint = JSON.stringify([config.command ?? null, config.args ?? null, config.url ?? null, config.wsUrl ?? null, config.env ?? null, config.headers ?? null])
229
+ // MCP.md §4 D-5:fingerprint 计入 token 字段——/mcp edit token 指纹变更
230
+ // 旧连接主动关闭重建(T13)。
231
+ const configFingerprint = JSON.stringify([config.command ?? null, config.args ?? null, config.url ?? null, config.wsUrl ?? null, config.env ?? null, config.headers ?? null, config.token ?? null])
196
232
 
197
233
  const existing = _sessions.get(name)
198
234
  if (existing && !existing.closed) {
@@ -208,7 +244,16 @@ export async function connectMcpServer(config) {
208
244
  }
209
245
  }
210
246
 
211
- const { transport, mcpTools } = await createConnectedTransport(config, name)
247
+ let transport
248
+ let mcpTools
249
+ try {
250
+ ;({ transport, mcpTools } = await createConnectedTransport(config, name))
251
+ } catch (error) {
252
+ // MCP.md §4 评审 #7:握手失败不泄漏 transport(GET SSE 降级成功但 POST initialize
253
+ // 失败时,openSSE 若开了流会留一个悬挂的 reader/请求)。
254
+ try { transport?.close() } catch { /* ignore */ }
255
+ throw error
256
+ }
212
257
  const session = {
213
258
  config, configFingerprint,
214
259
  state: { transport, tools: null },