thincoder 0.12.52 → 0.12.54

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 (58) hide show
  1. package/CHANGELOG.md +36 -0
  2. package/package.json +1 -1
  3. package/src/acp.mjs +60 -18
  4. package/src/advisor/run.mjs +9 -11
  5. package/src/agent/dispatch.mjs +38 -13
  6. package/src/agent/setup.mjs +2 -1
  7. package/src/agent.mjs +34 -0
  8. package/src/cli/make-agent.mjs +11 -5
  9. package/src/escape.mjs +43 -8
  10. package/src/git/checkpoint.mjs +32 -6
  11. package/src/mcp/helpers.mjs +14 -5
  12. package/src/mcp/transport-http.mjs +79 -27
  13. package/src/mcp/transport-stdio.mjs +57 -3
  14. package/src/mcp/transport-ws.mjs +46 -12
  15. package/src/mcp.mjs +197 -58
  16. package/src/prompts/discipline.md +44 -1
  17. package/src/provider/anthropic.mjs +51 -18
  18. package/src/provider/core.mjs +163 -36
  19. package/src/provider/google.mjs +41 -15
  20. package/src/provider/rate.mjs +5 -0
  21. package/src/provider/responses.mjs +498 -0
  22. package/src/provider/retry.mjs +125 -0
  23. package/src/provider/sse.mjs +58 -24
  24. package/src/proxy.mjs +36 -6
  25. package/src/session-migrate.mjs +6 -0
  26. package/src/session-slots.mjs +361 -0
  27. package/src/session.mjs +267 -306
  28. package/src/tools/bash.md +2 -2
  29. package/src/tools/execute.md +1 -1
  30. package/src/tools/execute.mjs +3 -3
  31. package/src/tools/fetch.md +1 -0
  32. package/src/tools/file.mjs +136 -11
  33. package/src/tools/git-checkpoint.mjs +143 -0
  34. package/src/tools/git-ext.mjs +173 -0
  35. package/src/tools/git.md +21 -6
  36. package/src/tools/git.mjs +68 -155
  37. package/src/tools/shared.mjs +5 -3
  38. package/src/tools/system.mjs +19 -1
  39. package/src/tools/web.mjs +44 -14
  40. package/src/tools/websearch.md +3 -1
  41. package/src/tui/ansi.mjs +2 -0
  42. package/src/tui/cmd-new.mjs +6 -6
  43. package/src/tui/cmd-restore.mjs +27 -6
  44. package/src/tui/cmd-session.mjs +17 -4
  45. package/src/tui/fold-block.mjs +59 -11
  46. package/src/tui/index.mjs +59 -67
  47. package/src/tui/key-handler.mjs +3 -1
  48. package/src/tui/layout.mjs +81 -25
  49. package/src/tui/mouse.mjs +86 -8
  50. package/src/tui/render-conversation.mjs +260 -214
  51. package/src/tui/render-frame.mjs +22 -6
  52. package/src/tui/render-loop.mjs +11 -1
  53. package/src/tui/startup.mjs +1 -1
  54. package/src/tui/subagent-blocks.mjs +5 -1
  55. package/src/tui/subagent-panel.mjs +81 -0
  56. package/src/tui/tool-args.mjs +4 -0
  57. package/src/tui/tool-events.mjs +1 -1
  58. package/src/tui/tui-lifecycle.mjs +45 -0
@@ -12,6 +12,16 @@ export function httpTransport(baseURL, extraHeaders = {}) {
12
12
  let abortController = null
13
13
  let postUrl = url
14
14
  let legacySSE = false
15
+ let deadFired = false
16
+ let deadListeners = new Set()
17
+
18
+ /** 2026-08-31 MCP 会诊 P5:意外死亡通知(SSE 流断/error,非主动 close)。 */
19
+ const fireDead = (msg) => {
20
+ if (deadFired) return
21
+ deadFired = true
22
+ for (const cb of deadListeners) { try { cb(msg) } catch { /* listener error */ } }
23
+ }
24
+ const onDead = (cb) => { deadListeners.add(cb); return () => deadListeners.delete(cb) }
15
25
 
16
26
  const headers = () => {
17
27
  const h = { "Content-Type": "application/json", Accept: "text/event-stream, application/json", ...extraHeaders }
@@ -86,10 +96,14 @@ export function httpTransport(baseURL, extraHeaders = {}) {
86
96
  }
87
97
  } catch { /* not JSON, ignore */ }
88
98
  }
99
+ // 正常走完 = server 关闭了流(网络/对端退出)→ 非主动关闭视为死亡
100
+ if (!closed) fireDead("SSE stream ended")
89
101
  } catch (error) {
90
- if (!closed) {
102
+ const wasClosed = closed
103
+ if (!wasClosed) {
91
104
  for (const [, resolve] of pending) resolve({ id: null, error: { code: -32000, message: `SSE error: ${error.message}` } })
92
105
  pending.clear()
106
+ fireDead(`SSE error: ${error.message}`)
93
107
  }
94
108
  }
95
109
  })()
@@ -106,14 +120,26 @@ export function httpTransport(baseURL, extraHeaders = {}) {
106
120
  const body = JSON.stringify({ jsonrpc: "2.0", id, method, params })
107
121
 
108
122
  if (legacySSE) {
123
+ // 2026-08-31 MCP 会诊 #10:legacy 模式下 POST 若直接带回 JSON-RPC body(违规 server,
124
+ // 规范是响应经 GET SSE 流回)——原实现对 resp.ok 什么都不做 → pending 挂满 120s。
125
+ // 空 body = 规范行为(等待 SSE 流);非空 JSON = 违规 server,直接解析 resolve。
109
126
  return new Promise((resolve) => {
110
127
  pending.set(id, resolve)
111
128
  fetch(postUrl, { method: "POST", headers: headers(), body, signal: AbortSignal.timeout(CALL_TIMEOUT_MS) })
112
- .then((resp) => {
129
+ .then(async (resp) => {
113
130
  if (!resp.ok) {
114
131
  pending.delete(id)
115
132
  resolve({ id, error: { code: -32000, message: `POST failed: HTTP ${resp.status}` } })
133
+ return
134
+ }
135
+ const raw = await resp.text().catch(() => "")
136
+ if (raw.trim()) {
137
+ try {
138
+ const msg = JSON.parse(raw)
139
+ if (msg.id === id) { pending.delete(id); resolve(msg); return }
140
+ } catch { /* ignore — pending 交给 SSE 流 */ }
116
141
  }
142
+ // 空体或非本次 id 的 JSON:保持 pending 等 GET SSE 流回包
117
143
  })
118
144
  .catch((e) => {
119
145
  pending.delete(id)
@@ -122,30 +148,56 @@ export function httpTransport(baseURL, extraHeaders = {}) {
122
148
  }).finally(() => pending.delete(id))
123
149
  }
124
150
 
125
- const resp = await fetch(postUrl, {
126
- method: "POST",
127
- headers: headers(),
128
- body,
129
- signal: AbortSignal.timeout(CALL_TIMEOUT_MS),
130
- })
131
- if (!resp.ok) throw new Error(`HTTP ${resp.status}`)
132
-
133
- const ct = resp.headers.get("content-type") ?? ""
134
- const newSessionId = resp.headers.get("Mcp-Session-Id")
135
- if (newSessionId) sessionId = newSessionId
136
-
137
- if (ct.includes("text/event-stream")) {
138
- const sse = parseSSE(resp)
139
- for await (const { data } of sse) {
140
- try {
141
- const msg = JSON.parse(data)
142
- if (msg.id === id) return msg
143
- } catch { /* skip */ }
144
- }
145
- return { id, error: { code: -32000, message: "No JSON-RPC response in SSE stream" } }
146
- }
147
-
148
- return resp.json()
151
+ // 2026-08-31 MCP 会诊 P4:Streamable HTTP 规范路径(POST→202→GET SSE 回包)。
152
+ // 原实现非 legacy 分支从不注册 pending:202 空 body → resp.json() 抛 SyntaxError,
153
+ // 而 SSE 流里 pending.get(id) 永远 miss——每次调用挂满 120s。
154
+ // 现在两类通道统一:先注册 pending,POST 得到的结果(直接 body / SSE / 202 等待)
155
+ // 都经 pending resolve;SSE 流由 openSSE() 转发。200+JSON 直接 body 解析。
156
+ return new Promise((resolve) => {
157
+ pending.set(id, resolve)
158
+ fetch(postUrl, { method: "POST", headers: headers(), body, signal: AbortSignal.timeout(CALL_TIMEOUT_MS) })
159
+ .then(async (resp) => {
160
+ const ct = resp.headers.get("content-type") ?? ""
161
+ const newSessionId = resp.headers.get("Mcp-Session-Id")
162
+ if (newSessionId) sessionId = newSessionId
163
+ // 202: 已接受,响应将经 GET SSE 流回——pending 保持,由 openSSE 转发 resolve
164
+ if (resp.status === 202) return
165
+ if (!resp.ok) {
166
+ pending.delete(id)
167
+ resolve({ id, error: { code: -32000, message: `HTTP ${resp.status}` } })
168
+ return
169
+ }
170
+ if (ct.includes("text/event-stream")) {
171
+ // 响应体本身就是一条 SSE(一次性流):解析匹配该 id
172
+ try {
173
+ for await (const { data } of parseSSE(resp)) {
174
+ try {
175
+ const msg = JSON.parse(data)
176
+ if (msg.id === id) { pending.delete(id); resolve(msg); return }
177
+ } catch { /* skip */ }
178
+ }
179
+ pending.delete(id)
180
+ resolve({ id, error: { code: -32000, message: "No JSON-RPC response in SSE stream" } })
181
+ } catch (e) {
182
+ pending.delete(id)
183
+ resolve({ id, error: { code: -32000, message: `SSE response failed: ${e.message}` } })
184
+ }
185
+ return
186
+ }
187
+ // 直接 JSON body:拿掉 pending 立即解析
188
+ pending.delete(id)
189
+ try {
190
+ resolve(await resp.json())
191
+ } catch (e) {
192
+ resolve({ id, error: { code: -32000, message: `invalid JSON body: ${e.message}` } })
193
+ }
194
+ })
195
+ .catch((e) => {
196
+ if (pending.delete(id)) {
197
+ resolve({ id, error: { code: -32000, message: `POST failed: ${e.message}` } })
198
+ }
199
+ })
200
+ }).finally(() => pending.delete(id))
149
201
  }
150
202
 
151
203
  const send = async (method, params) => withTimeout(postRequest(method, params), CALL_TIMEOUT_MS)
@@ -180,5 +232,5 @@ export function httpTransport(baseURL, extraHeaders = {}) {
180
232
  pending.clear()
181
233
  }
182
234
 
183
- return { send, notify, close, openSSE, url, headers: extraHeaders }
235
+ return { send, notify, close, openSSE, url, headers: extraHeaders, isAlive: () => !closed && eventSource != null, onDead }
184
236
  }
@@ -4,6 +4,23 @@
4
4
  import { spawn } from "node:child_process"
5
5
  import { rpcId, CALL_TIMEOUT_MS, withTimeout, quoteArg } from "./helpers.mjs"
6
6
 
7
+ /** Kill the child AND its whole process tree.
8
+ * win32: cmd.exe 包装 spawn 的孙进程(npx/node)必须 taskkill /T /F 才能杀净
9
+ * (2026-08-31 MCP 会诊 P2:此前只 child.kill() 杀 cmd.exe 壳,真 server 成僵尸
10
+ * 并在 Windows 团队每人每次断开/重连泄漏一批);
11
+ * POSIX: SIGTERM 后 2s 未退 SIGKILL 兜底。 */
12
+ function killTree(child) {
13
+ if (!child.pid) return
14
+ if (process.platform === "win32") {
15
+ try { spawn("taskkill", ["/pid", String(child.pid), "/T", "/F"], { stdio: "ignore", windowsHide: true }) } catch { /* best effort */ }
16
+ return
17
+ }
18
+ try {
19
+ child.kill("SIGTERM")
20
+ setTimeout(() => { try { child.kill("SIGKILL") } catch { /* already gone */ } }, 2000).unref?.()
21
+ } catch { /* best effort */ }
22
+ }
23
+
7
24
  /** Create an MCP stdio transport over a spawned child process.
8
25
  * @param {Object} [env] — extra environment variables merged on top of process.env */
9
26
  export function stdioTransport(command, args, env) {
@@ -22,6 +39,17 @@ export function stdioTransport(command, args, env) {
22
39
  let stderrTail = ""
23
40
  let spawnError = null
24
41
  let closed = false
42
+ let deadFired = false
43
+ let deadListeners = new Set()
44
+
45
+ /** 2026-08-31 MCP 会诊 P5:意外死亡通知(进程自杀/崩溃,非主动 close)。
46
+ * 主动 close() 先置 closed → 后续 close 事件不触发。 */
47
+ const fireDead = (msg) => {
48
+ if (deadFired) return
49
+ deadFired = true
50
+ for (const cb of deadListeners) { try { cb(msg) } catch { /* listener error */ } }
51
+ }
52
+ const onDead = (cb) => { deadListeners.add(cb); return () => deadListeners.delete(cb) }
25
53
 
26
54
  const failAll = (message) => {
27
55
  for (const [, resolve] of pending) resolve({ id: null, error: { code: -32000, message } })
@@ -56,16 +84,31 @@ export function stdioTransport(command, args, env) {
56
84
  failAll(`spawn failed: ${error.message}`)
57
85
  })
58
86
  child.on("close", () => {
87
+ const wasClosed = closed // 主动 close() 先置 closed → 意外事件不重复
59
88
  closed = true
60
89
  const lastLine = stderrTail.trim().split("\n").pop()
61
90
  failAll(`Connection closed${lastLine ? ` | stderr: ${lastLine}` : ""}`)
91
+ if (!wasClosed) fireDead(`MCP server process exited${lastLine ? ` | stderr: ${lastLine}` : ""}`)
62
92
  })
63
93
 
64
- const send = (method, params) => {
94
+ const send = (method, params, signal) => {
65
95
  if (spawnError) return Promise.resolve({ id: null, error: { code: -32000, message: `spawn failed: ${spawnError.message}` } })
66
96
  if (closed) return Promise.reject(new Error("MCP connection closed"))
67
97
  const id = rpcId()
68
- const promise = new Promise((resolve) => pending.set(id, resolve))
98
+ let resolveFn
99
+ const promise = new Promise((resolve) => { resolveFn = resolve; pending.set(id, resolve) })
100
+ // 2026-08-31 MCP 会诊 P7:上层 signal 中断时即刻作废 pending(原实现等满
101
+ // CALL_TIMEOUT_MS 才清)并向 server 发 cancelled 通知。
102
+ const onAbort = () => {
103
+ pending.delete(id)
104
+ try { notify("notifications/cancelled", { requestId: id }) } catch { /* ignore */ }
105
+ resolveFn({ id, error: { code: -32000, message: "Request cancelled by user" } })
106
+ }
107
+ if (signal) {
108
+ if (signal.aborted) return Promise.reject((signal.reason instanceof Error) ? signal.reason : new DOMException("Aborted", "AbortError"))
109
+ signal.addEventListener("abort", onAbort, { once: true })
110
+ promise.finally(() => signal.removeEventListener("abort", onAbort)).catch?.(() => {})
111
+ }
69
112
  try {
70
113
  child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n")
71
114
  } catch (error) {
@@ -82,5 +125,16 @@ export function stdioTransport(command, args, env) {
82
125
  } catch { /* ignore */ }
83
126
  }
84
127
 
85
- return { send, notify, close: () => { if (!closed) child.kill() } }
128
+ return {
129
+ send, notify,
130
+ close: () => {
131
+ // 2026-08-31 MCP 会诊 P2:close ≠ 只杀进程——先置 closed + failAll(在途请求
132
+ // 立即得到 "Connection closed" 而非挂满 120s),再杀整个进程树。
133
+ closed = true
134
+ failAll("Connection closed")
135
+ if (child.exitCode === null) killTree(child)
136
+ },
137
+ isAlive: () => !closed && !spawnError && child.exitCode === null,
138
+ onDead,
139
+ }
86
140
  }
@@ -8,6 +8,16 @@ export function wsTransport(wsUrl, extraHeaders = {}) {
8
8
  const pending = new Map()
9
9
  let closed = false
10
10
  let ws = null
11
+ let deadFired = false
12
+ let deadListeners = new Set()
13
+
14
+ /** 2026-08-31 MCP 会诊 P5:意外死亡通知(非主动 close 的连接断开)。 */
15
+ const fireDead = (msg) => {
16
+ if (deadFired) return
17
+ deadFired = true
18
+ for (const cb of deadListeners) { try { cb(msg) } catch { /* listener error */ } }
19
+ }
20
+ const onDead = (cb) => { deadListeners.add(cb); return () => deadListeners.delete(cb) }
11
21
 
12
22
  const failAll = (message) => {
13
23
  for (const [, resolve] of pending) resolve({ id: null, error: { code: -32000, message } })
@@ -16,15 +26,22 @@ export function wsTransport(wsUrl, extraHeaders = {}) {
16
26
 
17
27
  const connect = () => {
18
28
  if (closed) throw new Error("MCP WebSocket connection closed")
19
- ws = new WebSocket(withAuthToken(wsUrl, extraHeaders.Authorization))
29
+ // 2026-08-31 MCP 会诊 #10:Authorization 经 subprotocol(bearer.<token>)传递,
30
+ // 不注入 URL query(防代理/网关日志泄露);无认证时 protocols 为空数组。
31
+ const { url: wsUrlResolved, protocols } = withAuthToken(wsUrl, extraHeaders.Authorization)
32
+ ws = new WebSocket(wsUrlResolved, protocols.length ? protocols : undefined)
20
33
 
21
34
  return new Promise((resolve, reject) => {
35
+ let settled = false
36
+ const settleErr = (err) => { if (!settled) { settled = true; reject(err) } }
22
37
  const timeout = setTimeout(() => {
23
- ws.close()
24
- reject(new Error(`WebSocket connect timeout: ${wsUrl}`))
38
+ settleErr(new Error(`WebSocket connect timeout: ${wsUrl}`))
39
+ try { ws.close() } catch { /* ignore */ }
25
40
  }, INIT_TIMEOUT_MS)
26
41
 
27
42
  ws.addEventListener("open", () => {
43
+ if (settled) return
44
+ settled = true
28
45
  clearTimeout(timeout)
29
46
  resolve()
30
47
  })
@@ -41,28 +58,45 @@ export function wsTransport(wsUrl, extraHeaders = {}) {
41
58
  })
42
59
 
43
60
  ws.addEventListener("error", (event) => {
61
+ const wasClosed = closed
44
62
  clearTimeout(timeout)
45
63
  closed = true
46
- const errMsg = event.message || "WebSocket error"
47
- if (pending.size > 0) {
48
- failAll(errMsg)
49
- } else {
50
- reject(new Error(errMsg))
51
- }
64
+ const errMsg = event.message || event.error?.message || "WebSocket error"
65
+ settleErr(new Error(errMsg))
66
+ failAll(errMsg)
67
+ if (!wasClosed) fireDead(errMsg)
52
68
  })
53
69
 
54
70
  ws.addEventListener("close", () => {
71
+ const wasClosed = closed
55
72
  clearTimeout(timeout)
56
73
  closed = true
74
+ // 2026-08-31 MCP 会诊 P1:握手期间 disconnect(close 先于 open 且无 error)——
75
+ // 原实现 close 只 clearTimeout + failAll,connect promise 永不 settle(启动挂死)。
76
+ // settled 标志保证 close 未开成必 reject。
77
+ settleErr(new Error("WebSocket closed before connection established"))
57
78
  failAll("WebSocket closed")
79
+ if (!wasClosed) fireDead("WebSocket closed")
58
80
  })
59
81
  })
60
82
  }
61
83
 
62
- const send = (method, params) => {
84
+ const send = (method, params, signal) => {
63
85
  if (closed) return Promise.reject(new Error("MCP WebSocket connection closed"))
64
86
  const id = rpcId()
65
- const promise = new Promise((resolve) => pending.set(id, resolve))
87
+ let resolveFn
88
+ const promise = new Promise((resolve) => { resolveFn = resolve; pending.set(id, resolve) })
89
+ // 2026-08-31 MCP 会诊 P7:abort 即刻作废 pending + 发 cancelled(原等满 120s)
90
+ if (signal) {
91
+ if (signal.aborted) return Promise.reject(new DOMException("Aborted", "AbortError"))
92
+ const onAbort = () => {
93
+ pending.delete(id)
94
+ try { notify("notifications/cancelled", { requestId: id }) } catch { /* ignore */ }
95
+ resolveFn({ id, error: { code: -32000, message: "Request cancelled by user" } })
96
+ }
97
+ signal.addEventListener("abort", onAbort, { once: true })
98
+ promise.finally(() => signal.removeEventListener("abort", onAbort)).catch?.(() => {})
99
+ }
66
100
  try {
67
101
  ws.send(JSON.stringify({ jsonrpc: "2.0", id, method, params }))
68
102
  } catch (error) {
@@ -84,5 +118,5 @@ export function wsTransport(wsUrl, extraHeaders = {}) {
84
118
  try { ws?.close() } catch { /* ignore */ }
85
119
  }
86
120
 
87
- return { send, notify, close, connect }
121
+ return { send, notify, close, connect, isAlive: () => !closed && ws?.readyState === WebSocket.OPEN, onDead }
88
122
  }
package/src/mcp.mjs CHANGED
@@ -7,29 +7,159 @@ 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
+
11
+ /** Race a pending MCP request against an abort signal — a hung MCP server must not
12
+ * hold the turn hostage. signal absent → passthrough. */
13
+ async function sendWithSignal(promise, signal) {
14
+ if (!signal) return promise
15
+ return new Promise((resolve, reject) => {
16
+ const onAbort = () => {
17
+ const e = new DOMException("The operation was aborted", "AbortError")
18
+ e.reason = signal.reason
19
+ reject(e)
20
+ }
21
+ if (signal.aborted) return onAbort()
22
+ signal.addEventListener("abort", onAbort, { once: true })
23
+ promise.then(
24
+ (v) => { signal.removeEventListener("abort", onAbort); resolve(v) },
25
+ (e) => { signal.removeEventListener("abort", onAbort); reject(e) },
26
+ )
27
+ })
28
+ }
29
+
10
30
  // ---- MCP lifecycle ----
11
31
 
12
- function buildTools(mcpTools, transport, config) {
32
+ /** 2026-08-31 MCP 会诊 P5:CLI session 注册表(serverName → session)。
33
+ * session.state.transport 可变(重连替换);buildTools 的 execute 动态取
34
+ * session.state.transport——server 崩溃后无需重建 agent.tools 即自愈。 */
35
+ const _sessions = new Map()
36
+ /** 退避重连进行中(serverName → promise)——与 vscode 语义对齐;延迟表见 _mcpHooks。 */
37
+ const _reconnecting = new Map()
38
+
39
+ /** 2026-08-31 MCP 会诊 P5:测试钩子(退避延迟可替换,惯例同 rate.mjs _rateHooks)。
40
+ * scheduleReconnect 与 ensureAlive 读这里的 delay/reconnectDelays——测试可注入微秒级延迟。 */
41
+ export const _mcpHooks = {
42
+ delay: (ms) => new Promise((r) => setTimeout(r, ms)),
43
+ reconnectDelays: [1000, 2000, 4000, 8000],
44
+ }
45
+
46
+ /** 按 config 创建并完成握手的 transport(findTransportConfig 与 vscode 对齐)。 */
47
+ async function createConnectedTransport(config, serverName) {
48
+ let transport
49
+ if (config.wsUrl) {
50
+ transport = wsTransport(config.wsUrl, config.headers ?? {})
51
+ await transport.connect()
52
+ } else if (config.url) {
53
+ transport = httpTransport(config.url, config.headers ?? {})
54
+ try {
55
+ await transport.openSSE()
56
+ } catch {
57
+ // Server doesn't support GET SSE — degrade to pure Streamable HTTP POST mode
58
+ }
59
+ } else {
60
+ transport = stdioTransport(config.command, config.args ?? [], config.env)
61
+ }
62
+ const mcpTools = await doInitialize(transport, serverName)
63
+ return { transport, mcpTools }
64
+ }
65
+
66
+ /** onDead → 后台退避重连;成功替换 session.state.transport(tools 闭包动态引用,
67
+ * agent.tools 无需重建);失败静默(下次 execute 前置检查再试)。 */
68
+ function scheduleReconnect(name, config) {
69
+ if (_reconnecting.has(name)) return _reconnecting.get(name)
70
+ const p = (async () => {
71
+ let lastErr
72
+ for (const delayMs of _mcpHooks.reconnectDelays) {
73
+ await _mcpHooks.delay(delayMs)
74
+ try {
75
+ const { transport } = await createConnectedTransport(config, name)
76
+ const session = _sessions.get(name)
77
+ if (!session || session.closed) { transport.close(); return false }
78
+ attachSession(session, transport)
79
+ return true
80
+ } catch (error) {
81
+ lastErr = error
82
+ }
83
+ }
84
+ console.error(`[mcp] ${name} reconnect failed after ${_mcpHooks.reconnectDelays.length} attempts: ${lastErr?.message ?? lastErr}`)
85
+ return false
86
+ })().finally(() => _reconnecting.delete(name))
87
+ _reconnecting.set(name, p)
88
+ return p
89
+ }
90
+
91
+ /** 给 session 绑定一个新 transport(建连 onDead 钩子 → 自愈链)。 */
92
+ function attachSession(session, transport) {
93
+ session.state.transport = transport
94
+ transport.onDead?.(() => {
95
+ if (session.state.transport !== transport) return // 已再替换,旧钩子作废
96
+ scheduleReconnect(session.config.name ?? session.config.command ?? session.config.url ?? session.config.wsUrl, session.config)
97
+ })
98
+ }
99
+
100
+ function buildTools(mcpTools, session, config) {
13
101
  const prefix = config.name ? `${config.name}_` : "mcp_"
14
- return mcpTools.map((t) => ({
15
- name: sanitizeToolName(prefix + t.name),
16
- description: t.description ?? `MCP tool: ${t.name}`,
17
- parameters: t.inputSchema ?? { type: "object", properties: {} },
18
- readonly: false,
19
- async execute(args) {
20
- const resp = await transport.send("tools/call", { name: t.name, arguments: args })
21
- if (resp.error) throw new Error(`MCP tool "${t.name}": ${resp.error.message}`)
22
- const content = resp.result?.content ?? []
23
- return content
24
- .map((c) => (c.type === "text" ? c.text : c.type === "resource" ? `[resource: ${c.resource?.uri}]` : JSON.stringify(c)))
25
- .join("\n") || "(no output)"
26
- },
27
- _mcpTransport: transport,
28
- _mcpName: config.name,
29
- }))
102
+ // 2026-08-31 MCP 会诊 P6:sanitize 碰撞/空名防御 + schema/description 类型守卫 + 输出防御
103
+ const seen = new Set()
104
+ const out = []
105
+ for (const t of mcpTools) {
106
+ const rawName = sanitizeToolName(prefix + t.name)
107
+ if (!rawName || rawName === "mcp_") continue
108
+ let name = rawName
109
+ for (let n = 2; seen.has(name); n++) name = `${rawName}_${n}`
110
+ seen.add(name)
111
+ out.push({
112
+ name,
113
+ description: typeof t.description === "string" ? t.description : `MCP tool: ${t.name}`,
114
+ parameters: (t.inputSchema && typeof t.inputSchema === "object") ? t.inputSchema : { type: "object", properties: {} },
115
+ readonly: false,
116
+ async execute(args, ctx) {
117
+ // 2026-08-31 会诊 #11:上层 signal 中断 + send 第 3 参底层取消(pending 即刻作废)
118
+ // P5:执行前动态取 session.transport——死亡时等重连 promise/触发一次重连再试
119
+ const transport = await ensureAlive(session)
120
+ const send = transport.send("tools/call", { name: t.name, arguments: args }, ctx?.signal)
121
+ const resp = await sendWithSignal(send, ctx?.signal)
122
+ if (resp.error) throw new Error(`MCP tool "${t.name}": ${resp.error.message}`)
123
+ if (resp.result?.isError) throw new Error(`MCP tool "${t.name}": ${extractMcpText(resp.result.content) || "(server reported an error)"}`)
124
+ return truncateMcpOutput(extractMcpText(resp.result?.content ?? [])) || "(no output)"
125
+ },
126
+ _mcpTransport: session.state.transport,
127
+ _mcpName: config.name,
128
+ })
129
+ }
130
+ return out
30
131
  }
31
132
 
32
- async function doInitialize(transport, name) {
133
+ /** P5:拿到活的 transport——死连接等待/触发重连;耗尽后抛错(工具错误透给模型)。 */
134
+ async function ensureAlive(session) {
135
+ const t = session.state.transport
136
+ if (t?.isAlive?.()) return t
137
+ const name = session.config.name ?? session.config.command ?? session.config.url ?? session.config.wsUrl
138
+ let reconnect = _reconnecting.get(name)
139
+ if (!reconnect) reconnect = scheduleReconnect(name, session.config)
140
+ const ok = await reconnect
141
+ if (!ok || !session.state.transport?.isAlive?.()) {
142
+ throw new Error(`MCP server "${name}" is unavailable (reconnect failed)`)
143
+ }
144
+ return session.state.transport
145
+ }
146
+
147
+ /** MCP content 数组 → 文本(非数组/元素非对象防御)。 */
148
+ function extractMcpText(content) {
149
+ if (!Array.isArray(content)) return typeof content === "string" ? content : JSON.stringify(content)
150
+ return content
151
+ .filter((c) => c && typeof c === "object")
152
+ .map((c) => (c.type === "text" ? c.text : c.type === "resource" ? `[resource: ${c.resource?.uri}]` : JSON.stringify(c)))
153
+ .join("\n")
154
+ }
155
+
156
+ /** 输出截断(32KB 上限,防 server 回 10MB 撑爆上下文)。 */
157
+ function truncateMcpOutput(text) {
158
+ if (text.length <= 32_000) return text
159
+ return text.slice(0, 32_000) + "\n[… truncated: " + (text.length - 32_000) + " chars omitted]"
160
+ }
161
+
162
+ async function doInitialize(transport, _name) {
33
163
  const initResp = await withTimeout(
34
164
  transport.send("initialize", {
35
165
  protocolVersion: "2024-11-05",
@@ -41,68 +171,77 @@ async function doInitialize(transport, name) {
41
171
  if (initResp.error) throw new Error(`initialize error: ${initResp.error.message}`)
42
172
  transport.notify?.("notifications/initialized", {})
43
173
 
44
- const toolsResp = await transport.send("tools/list", {})
45
- if (toolsResp.error) throw new Error(`tools/list failed: ${toolsResp.error.message}`)
46
- return toolsResp.result?.tools ?? []
174
+ // 2026-08-31 MCP 会诊 P6:tools/list 分页被忽略(nextCursor 多页工具静默丢失)——
175
+ // 循环跟随 cursor 直到 server 不再返回(上限 20 页防死循环)。
176
+ const tools = []
177
+ let cursor
178
+ for (let page = 0; page < 20; page++) {
179
+ const toolsResp = await transport.send("tools/list", cursor ? { cursor } : {})
180
+ if (toolsResp.error) throw new Error(`tools/list failed: ${toolsResp.error.message}`)
181
+ tools.push(...(toolsResp.result?.tools ?? []))
182
+ cursor = toolsResp.result?.nextCursor
183
+ if (!cursor) break
184
+ }
185
+ return tools
47
186
  }
48
187
 
49
- /** Connect to an MCP server (stdio/http/ws), initialize, and return built tool wrappers */
188
+ /** Connect to an MCP server (stdio/http/ws), initialize, and return built tool wrappers.
189
+ * 2026-08-31 MCP 会诊 P5:session 幂等——同 name 活连接复用(make-agent 每进程一次,
190
+ * cmd-mcp 重复 add 同一 server 不会双实例),崩溃后下次调用/execute 自愈。 */
50
191
  export async function connectMcpServer(config) {
51
- if (config.wsUrl) {
52
- const transport = wsTransport(config.wsUrl, config.headers ?? {})
53
- try {
54
- await transport.connect()
55
- const mcpTools = await doInitialize(transport, config.name ?? config.wsUrl)
56
- return buildTools(mcpTools, transport, config)
57
- } catch (error) {
58
- transport.close()
59
- throw error
60
- }
61
- }
192
+ if (!config || (!config.command && !config.url && !config.wsUrl))
193
+ throw new Error(`MCP server "${config?.name ?? ""}": needs either 'wsUrl' (websocket), 'command' (stdio), or 'url' (http)`)
194
+ 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])
62
196
 
63
- if (config.url) {
64
- const transport = httpTransport(config.url, config.headers ?? {})
65
- try {
66
- await transport.openSSE()
67
- } catch {
68
- // Server doesn't support GET (pure Streamable HTTP POST): degrade to no-SSE mode
197
+ const existing = _sessions.get(name)
198
+ if (existing && !existing.closed) {
199
+ const sameConfig = existing.configFingerprint === configFingerprint
200
+ if (sameConfig && existing.state.transport?.isAlive?.()) {
201
+ return existing.state.tools
69
202
  }
70
- try {
71
- const mcpTools = await doInitialize(transport, config.name ?? config.url)
72
- return buildTools(mcpTools, transport, config)
73
- } catch (error) {
74
- transport.close()
75
- throw error
203
+ if (!sameConfig) {
204
+ // config 变更:主动关闭旧连接(不触发 onDead 重连)并丢弃 session
205
+ existing.closed = true
206
+ try { existing.state.transport?.close() } catch { /* ignore */ }
207
+ _sessions.delete(name)
76
208
  }
77
209
  }
78
210
 
79
- if (config.command) {
80
- const transport = stdioTransport(config.command, config.args ?? [], config.env)
81
- try {
82
- const mcpTools = await doInitialize(transport, config.name ?? config.command)
83
- return buildTools(mcpTools, transport, config)
84
- } catch (error) {
85
- transport.close()
86
- throw error
87
- }
211
+ const { transport, mcpTools } = await createConnectedTransport(config, name)
212
+ const session = {
213
+ config, configFingerprint,
214
+ state: { transport, tools: null },
215
+ closed: false,
88
216
  }
89
-
90
- throw new Error(`MCP server "${config.name}": needs either 'wsUrl' (websocket), 'command' (stdio), or 'url' (http)`)
217
+ session.state.tools = buildTools(mcpTools, session, config)
218
+ attachSession(session, transport)
219
+ _sessions.set(name, session)
220
+ return session.state.tools
91
221
  }
92
222
 
93
223
  /** Close all MCP transport connections on an agent's tools */
94
224
  export function closeAllMcp(agent) {
95
225
  for (const t of agent.tools) {
96
- if (t._mcpTransport) t._mcpTransport.close()
226
+ if (t._mcpTransport) closeSession(t._mcpName)
97
227
  }
98
228
  }
99
229
 
230
+ /** 主动关闭 session(不触发 onDead 重连):transport close + 登记关闭标记 + 清注册表。 */
231
+ function closeSession(name) {
232
+ const session = _sessions.get(name)
233
+ if (!session) return
234
+ session.closed = true
235
+ try { session.state.transport?.close() } catch { /* ignore */ }
236
+ _sessions.delete(name)
237
+ }
238
+
100
239
  /** Remove MCP tools belonging to a specific server from the agent's tool list */
101
240
  export function removeMcpTools(agent, serverName) {
102
241
  const keep = []
103
242
  for (const t of agent.tools) {
104
243
  if (t._mcpName === serverName) {
105
- if (t._mcpTransport) t._mcpTransport.close()
244
+ closeSession(serverName)
106
245
  } else {
107
246
  keep.push(t)
108
247
  }