thincoder 0.12.54 → 0.12.59

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 (129) hide show
  1. package/CHANGELOG.md +98 -0
  2. package/README.md +1 -1
  3. package/bin/thincoder.mjs +25 -3
  4. package/package.json +3 -7
  5. package/src/acp/bridge.mjs +132 -26
  6. package/src/advisor/messages.mjs +38 -3
  7. package/src/advisor/run.mjs +91 -53
  8. package/src/advisor.mjs +15 -7
  9. package/src/agent/dispatch.mjs +156 -39
  10. package/src/agent/helpers.mjs +46 -4
  11. package/src/agent/setup.mjs +102 -19
  12. package/src/agent/spawn-child.mjs +28 -1
  13. package/src/agent-tools/advisor.mjs +43 -11
  14. package/src/agent-tools/consult.mjs +37 -6
  15. package/src/agent-tools/eng.mjs +4 -1
  16. package/src/agent-tools/goal.mjs +11 -1
  17. package/src/agent-tools/read-history.mjs +160 -0
  18. package/src/agent-tools/settings.mjs +162 -0
  19. package/src/agent-tools/skill.mjs +2 -1
  20. package/src/agent-tools/subagent-actions.mjs +432 -0
  21. package/src/agent-tools/subagent-async.mjs +427 -0
  22. package/src/agent-tools/subagent-scheduler.mjs +319 -0
  23. package/src/agent-tools/subagent.mjs +565 -128
  24. package/src/agent-tools/task.mjs +4 -3
  25. package/src/agent-tools/timer.mjs +9 -4
  26. package/src/agent-tools/verify.mjs +161 -49
  27. package/src/agent-tools.mjs +1 -0
  28. package/src/agent.mjs +182 -81
  29. package/src/auto-think.mjs +14 -0
  30. package/src/cli/make-agent.mjs +27 -1
  31. package/src/cli/memory-command.mjs +28 -7
  32. package/src/cli/permission.mjs +8 -1
  33. package/src/config.mjs +125 -8
  34. package/src/context.mjs +115 -34
  35. package/src/distill.mjs +19 -1
  36. package/src/escape.mjs +82 -27
  37. package/src/log.mjs +195 -0
  38. package/src/mcp/transport-http.mjs +13 -1
  39. package/src/mcp.mjs +52 -7
  40. package/src/memory/code-sync.mjs +1 -1
  41. package/src/memory/core.mjs +204 -10
  42. package/src/memory/docs.mjs +197 -62
  43. package/src/memory.mjs +1 -1
  44. package/src/model-specs.mjs +38 -1
  45. package/src/prompts/advisor-design.md +46 -0
  46. package/src/prompts/advisor-round1.md +49 -2
  47. package/src/prompts/advisor-round2.md +47 -0
  48. package/src/prompts/advisor-round3.md +47 -0
  49. package/src/prompts/coder.md +22 -0
  50. package/src/prompts/consult-base.md +13 -0
  51. package/src/prompts/discipline.md +25 -6
  52. package/src/prompts/eng-coder.md +2 -2
  53. package/src/prompts/engineering-sub.md +23 -1
  54. package/src/prompts/engineering.md +157 -50
  55. package/src/prompts/explore.md +1 -2
  56. package/src/prompts/main.md +11 -5
  57. package/src/prompts/methodology-template.md +14 -0
  58. package/src/prompts/system.md +5 -2
  59. package/src/provider/anthropic.mjs +7 -5
  60. package/src/provider/core.mjs +104 -28
  61. package/src/provider/google.mjs +57 -24
  62. package/src/provider/normalize.mjs +1 -1
  63. package/src/provider/rate.mjs +0 -2
  64. package/src/provider/responses.mjs +8 -13
  65. package/src/provider/sse.mjs +20 -0
  66. package/src/session.mjs +15 -0
  67. package/src/tools/apply_patch.md +5 -1
  68. package/src/tools/bash.md +3 -3
  69. package/src/tools/delete.md +1 -0
  70. package/src/tools/edit-batch.mjs +92 -0
  71. package/src/tools/edit-diff.mjs +265 -0
  72. package/src/tools/edit.md +11 -6
  73. package/src/tools/execute.md +8 -8
  74. package/src/tools/execute.mjs +31 -35
  75. package/src/tools/file.mjs +26 -114
  76. package/src/tools/file_ops.md +3 -2
  77. package/src/tools/get_current_time.md +3 -1
  78. package/src/tools/git.md +1 -1
  79. package/src/tools/git.mjs +8 -16
  80. package/src/tools/hashline_edit.md +2 -0
  81. package/src/tools/index.mjs +3 -2
  82. package/src/tools/insert_after.md +2 -1
  83. package/src/tools/lint.md +3 -1
  84. package/src/tools/linter.mjs +9 -37
  85. package/src/tools/lsp.md +4 -1
  86. package/src/tools/patch.mjs +84 -13
  87. package/src/tools/pdf-parse-text.mjs +497 -0
  88. package/src/tools/pdf-parse-xref.mjs +499 -0
  89. package/src/tools/pdf.mjs +155 -0
  90. package/src/tools/question.md +2 -1
  91. package/src/tools/read.md +1 -0
  92. package/src/tools/read_pdf.md +21 -0
  93. package/src/tools/repomap.mjs +1 -1
  94. package/src/tools/shared.mjs +11 -32
  95. package/src/tools/system.mjs +6 -21
  96. package/src/tools/tree.md +2 -1
  97. package/src/tools/web.mjs +5 -3
  98. package/src/tools/websearch.md +2 -1
  99. package/src/tools/write.md +2 -0
  100. package/src/traces/trace-store.mjs +224 -0
  101. package/src/tui/agent-turn.mjs +387 -24
  102. package/src/tui/clipboard.mjs +17 -6
  103. package/src/tui/cmd-config.mjs +29 -9
  104. package/src/tui/cmd-eng.mjs +1 -0
  105. package/src/tui/cmd-extract.mjs +1 -1
  106. package/src/tui/cmd-mcp-form.mjs +197 -0
  107. package/src/tui/cmd-mcp.mjs +264 -114
  108. package/src/tui/cmd-think.mjs +1 -1
  109. package/src/tui/index.mjs +49 -95
  110. package/src/tui/interaction.mjs +41 -3
  111. package/src/tui/key-handler.mjs +105 -143
  112. package/src/tui/key-modes.mjs +215 -0
  113. package/src/tui/layout.mjs +22 -1
  114. package/src/tui/mouse.mjs +41 -1
  115. package/src/tui/pickers.mjs +73 -7
  116. package/src/tui/render-conversation.mjs +13 -161
  117. package/src/tui/render-frame.mjs +45 -20
  118. package/src/tui/render-loop.mjs +4 -1
  119. package/src/tui/render-segments.mjs +165 -0
  120. package/src/tui/render.mjs +4 -4
  121. package/src/tui/startup.mjs +40 -2
  122. package/src/tui/subagent-blocks.mjs +404 -111
  123. package/src/tui/subagent-panel.mjs +88 -13
  124. package/src/tui/tool-args.mjs +10 -2
  125. package/src/tui/tool-events.mjs +172 -95
  126. package/src/tui/update-notice.mjs +72 -0
  127. package/src/tui/wizard.mjs +36 -6
  128. package/src/agent-tools/escalate.mjs +0 -179
  129. package/src/tools/exec-prelude.mjs +0 -84
package/src/escape.mjs CHANGED
@@ -1,38 +1,91 @@
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
- /** 中和单段文本里的非法字面转义序列。
14
- * Known limitation (documented, accepted, VS Code port parity): an ODD backslash
15
- * run of 3+ (e.g. "\\\x") leaves the trailing "\x" un-doubled — vanishingly rare
16
- * in real conversation text (no such hit in the 2026-08-31 repro session). */
22
+ /** 中和非法字面 hex 转义序列(毒源①)。 */
17
23
  export function escapeLiteralEscapes(text) {
18
24
  text = String(text ?? "")
19
- return text
20
- // (?<!\\) 只有单个反斜杠才处理("\\x" 已经是 double 的,必须原样放过)
21
- // 前瞻:\x 后至少 2 个 hex 视为合法(服务端只展开前两个),只有不足 2 个的才 double
22
- .replace(/(?<!\\)\\(x)(?![0-9a-fA-F]{2})/g, "\\\\$1")
23
- .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
24
72
  }
25
73
 
26
- /** 对单条消息的 content 应用 escapeLiteralEscapes(支持字符串或 OpenAI 多模态 part 数组)。
74
+ /** 发送前文本净化总入口:hex 转义中和 + 孤立代理净化。 */
75
+ export function sanitizeText(text) {
76
+ return sanitizeLoneSurrogates(escapeLiteralEscapes(text))
77
+ }
78
+
79
+ /** 对单条消息的 content 应用 sanitizeText(支持字符串或 OpenAI 多模态 part 数组)。
27
80
  * 2026-08-31 会诊 F5:deepseek-v4-flash 网关对 tool_calls[].function.arguments 与
28
- * reasoning_content 做同样的非标二次转义解析(字面 \x/\u 经工具参数/思考回传 → 400,
81
+ * reasoning_content 做同样的非标二次转义解析(字面 \\x5Cx/\\x5Cu 经工具参数/思考回传 → 400,
29
82
  * 列号确定性复现 = 毒序列在 content 之外)——这两个字符串字段同样需要中和。 */
30
83
  export function escapeMessageContent(message) {
31
84
  const content = message?.content
32
85
  let changed = false
33
86
  let next = message
34
87
  if (typeof content === "string") {
35
- const escaped = escapeLiteralEscapes(content)
88
+ const escaped = sanitizeText(content)
36
89
  if (escaped !== content) {
37
90
  next = { ...next, content: escaped }
38
91
  changed = true
@@ -40,7 +93,7 @@ export function escapeMessageContent(message) {
40
93
  } else if (Array.isArray(content)) {
41
94
  const parts = content.map((p) => {
42
95
  if (p && typeof p === "object" && p.type === "text" && typeof p.text === "string") {
43
- const escaped = escapeLiteralEscapes(p.text)
96
+ const escaped = sanitizeText(p.text)
44
97
  if (escaped !== p.text) {
45
98
  changed = true
46
99
  return { ...p, text: escaped }
@@ -55,7 +108,7 @@ export function escapeMessageContent(message) {
55
108
  const tool_calls = next.tool_calls.map((tc) => {
56
109
  const args = tc?.function?.arguments
57
110
  if (typeof args === "string") {
58
- const escaped = escapeLiteralEscapes(args)
111
+ const escaped = sanitizeText(args)
59
112
  if (escaped !== args) {
60
113
  tcChanged = true
61
114
  return { ...tc, function: { ...tc.function, arguments: escaped } }
@@ -69,7 +122,7 @@ export function escapeMessageContent(message) {
69
122
  }
70
123
  }
71
124
  if (typeof next.reasoning_content === "string") {
72
- const escaped = escapeLiteralEscapes(next.reasoning_content)
125
+ const escaped = sanitizeText(next.reasoning_content)
73
126
  if (escaped !== next.reasoning_content) {
74
127
  next = { ...next, reasoning_content: escaped }
75
128
  changed = true
@@ -78,13 +131,15 @@ export function escapeMessageContent(message) {
78
131
  return changed ? next : message
79
132
  }
80
133
 
81
- /** IKBGX4 (2026-08-28):剥离仅本地使用的整消息标记字段(transient 等)——发送给 provider 前移除。
134
+ /** IKBGX4 + SESSION.md §9 D-S1:剥离仅本地使用的整消息标记字段(transient/ts)——发送给 provider 前移除。
82
135
  * 严格 OpenAI 兼容服务端(opencode/LiteLLM 等)会拒绝消息级未知 key
83
- * ("Extra inputs are not permitted, field: 'messages[i].transient'")。 */
136
+ * ("Extra inputs are not permitted, field: 'messages[i].transient'");ts 同理
137
+ * (消息时间戳是本地取证字段,不进任何 provider 请求——T-S3)。copy-on-write:
138
+ * 历史里的原对象不动(read_history 仍能读到 ts)。 */
84
139
  export function stripLocalMessageFields(messages) {
85
140
  return messages.map((m) => {
86
- if (m && typeof m === "object" && "transient" in m) {
87
- const { transient, ...rest } = m
141
+ if (m && typeof m === "object" && ("transient" in m || "ts" in m)) {
142
+ const { transient, ts, ...rest } = m
88
143
  return rest
89
144
  }
90
145
  return m
@@ -94,4 +149,4 @@ export function stripLocalMessageFields(messages) {
94
149
  /** 对整个 messages 数组逐条应用 escapeMessageContent(先剥离本地字段,再转义)。 */
95
150
  export function escapeMessages(messages) {
96
151
  return stripLocalMessageFields(messages).map(escapeMessageContent)
97
- }
152
+ }
package/src/log.mjs ADDED
@@ -0,0 +1,195 @@
1
+ /**
2
+ * log.mjs — 诊断事件日志(docs/design/LOGGING.md 权威规格)。
3
+ * 双端同构:thincoder/src/log.mjs 与 thincoder-vscode/src/log.mjs 同一实现语义
4
+ * (共享 ~/.thincoder/logs/、同格式、同事件面——F-L6)。
5
+ *
6
+ * 常驻骨架日志:回合/LLM/工具/子代理/挂起的关键事件——每行一个 JSON 事件,按天轮转
7
+ * (agent-YYYY-MM-DD.log),保留 1 天——用户零手动维护(F-L3/NF-L4)。问题发生时
8
+ * 直接 tail/grep 定位断点(根治"临时插桩再删"循环)。
9
+ *
10
+ * 纪律(实现侧逐条落实):
11
+ * - fire-and-forget:logEvent 失败静默降级(NF-L1)——主流程零影响。进程内首次写失败
12
+ * 即置死(_dead),当日不再尝试(磁盘满/权限错不逐事件空转)。
13
+ * - 单事件行 <512 字符(NF-L2):head(LLM ≤300 / 工具 ≤200)、err ≤200 由调用方截断,
14
+ * 本模块对任意字符串字段做上限兜底(head 300 / err 200 / 其余 120)+ 超长时丢可选字段。
15
+ * - 敏感字段零落盘(NF-L3/§2.4,2026-09-03 评审 refinement #7 匹配语义定稿):
16
+ * ① 字段名精确匹配黑名单(apiKey/designToken/password/secret/token,大小写不敏感;
17
+ * 另加防御性名称 authorization/proxy/proxyUri——防凭据型 URL/头字段泄漏——§2.5)
18
+ * → 整个字段丢弃;
19
+ * ② 内容只扫密钥形态(sk-xxx / Bearer xxx / key=… 等)→ 截断到形态之前
20
+ * ——宁可丢信息不漏密钥。
21
+ * 工具事件不记 args;URL 不入事件(llm/tool 事件从不携带 URL;err 文本经形态扫描)。
22
+ * - 摘要截断(B 方案,2026-09-03 用户裁定):截断处带 "…" 标记(截后仍 ≤上限)。
23
+ * - 测试隔离:node --test 进程(NODE_TEST_CONTEXT)默认不写盘——防测试事件污染真实
24
+ * 诊断日志(两端测试套件都会跑真实 agent 管线);显式设置 THINCODER_LOG_DIR 强制
25
+ * 写入该目录(log.test.mjs 用它隔离临时目录——refinement #6)。
26
+ * - 轮转清理(refinement #3——长驻进程覆盖):非仅启动时——每进程每日**首次写事件**
27
+ * 时顺带清理 >1 天的 agent-*.log(extension host 可长驻数月,启动清理覆盖不到)。
28
+ * - seq:每进程单调计数器。双端同写一个文件时 seq 会各自重复——定位同文件时序以
29
+ * ts 为准,seq 仅进程内参照(refinement #8)。
30
+ */
31
+
32
+ import { appendFileSync, mkdirSync, readdirSync, rmSync, existsSync, statSync } from "node:fs"
33
+ import { homedir } from "node:os"
34
+ import { join } from "node:path"
35
+
36
+ /** 单行长度硬上限(NF-L2) */
37
+ export const MAX_LINE = 512
38
+ /** 字段名精确黑名单(NF-L3/§2.4——2026-09-03 评审 refinement #7:与 §2.4 词表一致含 token) */
39
+ const BLACKLIST_FIELDS = new Set(["apikey", "designtoken", "password", "secret", "token", "authorization", "proxyuri", "proxy"])
40
+ /** 内容密钥形态扫描(大小写不敏感)——命中即截断到形态前 */
41
+ const SECRET_FORM = /(\bsk-[A-Za-z0-9_-]{6,}|\bBearer\s+[A-Za-z0-9._~+/=-]{6,}|\b(?:api[_-]?key|key|token|secret|password|pwd|passwd)\s*[=:]\s*['"]?[A-Za-z0-9._~+/=-]{6,})/i
42
+ /** 字符串字段长度兜底:head ≤300 / err ≤200 / 其余 ≤120 */
43
+ const FIELD_CAPS = { head: 300, err: 200 }
44
+
45
+ let _seq = 0
46
+ let _dead = false // 进程内写失败即死(NF-L1 静默降级——不逐事件重复空转)
47
+ let _cleanupDate = null // 本进程已执行过清理的日期(每日首次写时清一次)
48
+
49
+ /** 日志目录:THINCODER_LOG_DIR(测试隔离/override)> ~/.thincoder/logs(与 sessions/ 同域) */
50
+ export function logsDir() {
51
+ return process.env.THINCODER_LOG_DIR ?? join(homedir(), ".thincoder", "logs")
52
+ }
53
+
54
+ /** 今日日志文件路径(agent-YYYY-MM-DD.log) */
55
+ export function todayLogPath(now = new Date()) {
56
+ const ymd = now.toISOString().slice(0, 10)
57
+ return join(logsDir(), `agent-${ymd}.log`)
58
+ }
59
+
60
+ /** 写门(测试隔离):test runner 进程(NODE_TEST_CONTEXT)默认跳过——除显式
61
+ * THINCODER_LOG_DIR override(log.test.mjs 隔离临时目录——refinement #6)。 */
62
+ function writeEnabled() {
63
+ if (_dead) return false
64
+ if (process.env.NODE_TEST_CONTEXT && !process.env.THINCODER_LOG_DIR) return false
65
+ return true
66
+ }
67
+
68
+ /** 清理 >1 天的 agent-*.log(NF-L4——refinement #3:机会式,非仅启动时)。
69
+ * 保留窗口(2026-09-03 code review #1 修正):以文件名日期的**当日结束**为龄基准——
70
+ * 今天+昨天的文件保留(任何事件至少留存 24h、最多 48h),早于昨天的删除。
71
+ * 无法按名解析日期的 agent-*.log 按 mtime 兜底(>1 天未写的删除)。
72
+ * 导出供测试直呼(T-L7/T-L7b)。静默:任何失败都不影响主流程。 */
73
+ export const LOG_RETENTION_MS = 24 * 60 * 60 * 1000
74
+ export function cleanupOldLogs(now = new Date()) {
75
+ const dir = logsDir()
76
+ if (!existsSync(dir)) return
77
+ let names
78
+ try {
79
+ names = readdirSync(dir)
80
+ } catch {
81
+ return
82
+ }
83
+ for (const name of names) {
84
+ if (!name.startsWith("agent-") || !name.endsWith(".log")) continue
85
+ const m = name.match(/^agent-(\d{4}-\d{2}-\d{2})\.log$/)
86
+ const ts = m ? Date.parse(m[1] + "T00:00:00Z") : NaN
87
+ // 文件名日期 → 龄以该日结束(ts+24h)计:事件在删除时至少已留存 24h
88
+ const ageMs = Number.isFinite(ts) ? now - (ts + LOG_RETENTION_MS) : (tryStatAge(dir, name, now) ?? Infinity)
89
+ if (ageMs > LOG_RETENTION_MS) {
90
+ try { rmSync(join(dir, name), { force: true }) } catch { /* 静默 */ }
91
+ }
92
+ }
93
+ }
94
+
95
+ function tryStatAge(dir, name, now) {
96
+ try {
97
+ return now - statSync(join(dir, name)).mtimeMs
98
+ } catch {
99
+ return null
100
+ }
101
+ }
102
+
103
+ /**
104
+ * 写入一条事件(fire-and-forget)。fields 内所有字符串都经黑名单/截断处理。
105
+ * 事件行结构:{"ts":ISO,"ev":kind,"seq":N,...fields}——单行 JSON <512 字符。
106
+ */
107
+ export function logEvent(kind, fields = {}) {
108
+ if (!writeEnabled()) return
109
+ const now = new Date()
110
+ // 机会式轮转清理:每进程每日首次写事件时执行一次(refinement #3——长驻进程覆盖)
111
+ const ymd = now.toISOString().slice(0, 10)
112
+ if (_cleanupDate !== ymd) {
113
+ _cleanupDate = ymd
114
+ cleanupOldLogs(now)
115
+ }
116
+ const entry = { ts: now.toISOString(), ev: kind, seq: ++_seq }
117
+ for (const [k, v] of Object.entries(fields ?? {})) {
118
+ if (v === undefined || v === null) continue
119
+ // ① 字段名精确黑名单 → 丢弃(token 族永不落盘)
120
+ if (BLACKLIST_FIELDS.has(String(k).toLowerCase())) continue
121
+ if (typeof v === "string") entry[k] = sanitizeString(k, v)
122
+ else if (typeof v === "number" || typeof v === "boolean") entry[k] = v
123
+ }
124
+ let line = JSON.stringify(entry)
125
+ // ② 防御性收尾(NF-L2):字段上限后仍超长 → 丢可选字段(保留 ts/ev/seq)直至 <512
126
+ while (line.length > MAX_LINE && Object.keys(entry).length > 3) {
127
+ for (const k of Object.keys(entry)) {
128
+ if (k !== "ts" && k !== "ev" && k !== "seq") { delete entry[k]; break }
129
+ }
130
+ line = JSON.stringify(entry)
131
+ }
132
+ try {
133
+ const dir = logsDir()
134
+ mkdirSync(dir, { recursive: true })
135
+ appendFileSync(join(dir, `agent-${ymd}.log`), line + "\n", "utf8")
136
+ } catch {
137
+ _dead = true // NF-L1:写失败静默降级——主流程零影响
138
+ }
139
+ }
140
+
141
+ /** 共享脱敏(§18.6 D-TR2——trace-store 复用;logEvent 自身保持"丢弃字段"语义,
142
+ * 本函数提供"遮蔽标记"语义——同一字段名黑名单 + 同一 SECRET_FORM 形态扫描,
143
+ * 不发明新遮蔽模式)。字段名命中黑名单 → "[REDACTED](整个字段遮蔽);内容命中
144
+ * 密钥形态 → 截断到形态前 + "[redacted]" 标记(宁可丢信息不漏密钥——§2.4)。 */
145
+ export function redactSecret(fieldKey, value) {
146
+ const s = String(value)
147
+ if (BLACKLIST_FIELDS.has(String(fieldKey).toLowerCase())) return "[REDACTED]"
148
+ const hit = s.match(SECRET_FORM)
149
+ return hit ? s.slice(0, hit.index) + "[redacted]" : s
150
+ }
151
+
152
+ /** 内容级净化:密钥形态截断到形态前(②);超长截断 + "…" 标记。 */
153
+ export function sanitizeString(key, value) {
154
+ let s = String(value)
155
+ // 密钥形态 → 截断到形态前(宁可丢信息不漏密钥——§2.4)
156
+ const hit = s.match(SECRET_FORM)
157
+ if (hit) s = s.slice(0, hit.index)
158
+ const cap = FIELD_CAPS[key] ?? 120
159
+ if (s.length > cap) s = s.slice(0, cap - 1) + "…"
160
+ return s
161
+ }
162
+
163
+ /** 错误文本提取:message + cause 链首条、压成单行、≤max(默认 200——NF-L2/§2.2) */
164
+ export function errText(err, max = 200) {
165
+ let msg = err?.message ?? String(err ?? "")
166
+ if (!msg && err?.cause) msg = String(err.cause)
167
+ if (err?.cause?.message && !/^LLM API error/.test(msg)) msg += ` (${err.cause.message})`
168
+ msg = msg.replace(/\s*\n\s*/g, " ").trim()
169
+ if (msg.length > max) msg = msg.slice(0, max - 1) + "…"
170
+ return msg
171
+ }
172
+
173
+ /** 错误 kind 分类(llm:error 字段):timeout(网关超时/超时信号)/ abort(用户中止)/
174
+ * error(其余——API/网络/未分类)。signal 为请求中止信号(chat 调用点直传)。 */
175
+ export function classifyErr(err, signal) {
176
+ const msg = String(err?.message ?? "").toLowerCase() + " " + String(err?.cause?.message ?? "").toLowerCase()
177
+ if (/timeout|timed ?out|etimedout|time limit exceeded/i.test(msg)) return "timeout"
178
+ if (err?.name === "AbortError" || signal?.aborted) {
179
+ return signal?.reason?.name === "TimeoutError" ? "timeout" : "abort"
180
+ }
181
+ return "error"
182
+ }
183
+
184
+ /** 文本头截取:前 max 字符、单行化(JSON 单行约束)、截断带 "…" 标记。
185
+ * paragraph=true(llm:done 响应头):取首段(首个空行前)——B 方案"首段文本"语义。 */
186
+ export function headText(text, max, { paragraph = false } = {}) {
187
+ let s = String(text ?? "")
188
+ if (paragraph) {
189
+ const p = s.split(/\n\s*\n/, 1)[0]
190
+ if (p.length < s.length) s = p
191
+ }
192
+ s = s.replace(/\s*\n+\s*/g, " ").trim()
193
+ if (s.length > max) s = s.slice(0, max - 1) + "…"
194
+ return s
195
+ }
@@ -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 },
@@ -313,7 +313,7 @@ export function codeSearchTool(memory) {
313
313
  return {
314
314
  name: "code_search",
315
315
  description:
316
- "Search the project's source code for relevant code. Use this to find functions, classes, or code patterns across the codebase. Supports natural language queries and code snippets. Returns matching code chunks with file paths and line numbers.",
316
+ "Search the project's source code for relevant code. Use this to find functions, classes, or code patterns across the codebase. Supports natural language queries and code snippets. Returns matching code chunks with file paths and line numbers. Prefer doc_search for the intended design (design docs, conventions); code_search for the implementation as written.",
317
317
  parameters: {
318
318
  type: "object",
319
319
  properties: {