thincoder 0.12.52 → 0.12.53

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.
package/CHANGELOG.md CHANGED
@@ -1,4 +1,23 @@
1
1
  ## [0.12.52] — 2026-08-31
2
+ ## [0.12.53] — 2026-08-31
3
+
4
+ ### Added
5
+
6
+ - **TUI 展开块内滚动**(2026-08-31 用户需求):折叠区块 60% 高度封顶保留,正文改为滚动视口——`state._foldScroll` 记每块窗口起点;**滚轮命中块内容行 → 块内 ±3 行**(未命中走会话滚动),▲/▼ 控制行点击翻窗(step=winH 快速跳转);**穿出语义**:块顶滚上/块底滚下 → 交还会话滚动(会话顶懒加载可达);锚定补偿:暂停流式跟随期间内容增长按 convLen 增量补偿 scroll(读的位置不漂移)。**滚动读全文、永不截断**
7
+ - **流式跟随尾部**:`state._followTail` 默认 true——输出活动期间渲染前钉底;用户上滚(PgUp/滚轮)暂停跟随,PgDn/滚回底部/新提交消息恢复
8
+ - **工具顺手度**(2026-08-31):① insert_after 精确判定(本 session 写入记录受影响区,未受影响区直接插入不逼重 read、受影响区拒绝保护栏);② edit 数组形态 `edits: [{path, old_string, new_string}, ...]` 一次多文件原子替换;③ dispatch 拦截工具执行期间 console.log/console.error 回显给模型(异常路径同样回显);④ 写入工具返回带上下文窗口(edit/insert_after/hashline_edit 返回写入点 ±3 行带行号——模型自检行号语义,防"行号漂移死循环")
9
+
10
+ ### Changed
11
+
12
+ - **懒加载滚动到头自动加载**(2026-08-31 用户约定修复):恢复会话向上滚动到会话顶部自动加载更早一页(原只挂 PgUp 键=违约);`HISTORY_PAGE_MESSAGES` 50→20(单页更平顺,vscode parity)
13
+ - **三层渲染缓存**(懒加载卡顿根治):行级 wrapRowsCached + 段级 _lineSegCache(覆盖普通行/工具块/frozenSubTask/frozenAdvisor)——loadOlder 后 rebuild 111ms→5-8ms 平坦(不随已加载历史增长)
14
+ - **折叠 key 身份化**(会诊三家共识):`long-${i}`/`fold-${foldCounter++}`/`advisor-done-${i}` 位置键全部改 `_lineId` 派生(loadOlder unshift 后展开态/块内滚动 offset 不串位)
15
+ - **视口数学单源** `convViewport`(渲染+鼠标命中共用):短会话顶部补 pad 后命中整体偏移的存量 bug 修复(点击折叠头/滚轮落空或错行)
16
+
17
+ ### Fixed
18
+
19
+ - **块内滚动穿出缺陷**:滚到块顶/块尾后滚轮永远命中该块、穿不出 → 会话顶/懒加载不可达("经过展开块滚不到顶")——显式边界判定穿出
20
+ - **懒加载只挂 PgUp 键**(小键盘无 PgUp 用户等于无入口)——滚轮滚到会话顶同样触发
2
21
 
3
22
  ### Fixed
4
23
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "thincoder",
3
- "version": "0.12.52",
3
+ "version": "0.12.53",
4
4
  "description": "Thin coding agent - zero dependencies, no build step, Node.js native. Sharp code, zero bloat.",
5
5
  "keywords": [
6
6
  "ai",
@@ -7,6 +7,7 @@ import { findProvider, specForModel } from "../config.mjs"
7
7
  import { toOpenAISchema } from "../tools/index.mjs"
8
8
  import { prepareAdvisorMessages } from "../advisor.mjs"
9
9
  import { appendCitationReport } from "./citations.mjs"
10
+ import { describeToolArgs } from "../tui/tool-args.mjs"
10
11
 
11
12
  const MAX_ADVISOR_TURNS = 100
12
13
  // Mechanical convergence cap: the protocol assumes up to 5 rounds suffice
@@ -97,16 +98,12 @@ function advisorToolsFor(agent) {
97
98
  // Test seam: the tool set is pure (agent.memory → code_search inclusion).
98
99
  export { advisorToolsFor as _advisorToolsFor }
99
100
 
100
- /** Compact one-line summary of tool args for panel progress lines.
101
- * Picks the most identifying field; falls back to truncated JSON. */
102
- function summarizeToolArgs(args) {
103
- // e.g. "read src/x.mjs", "grep foo src/", "ls docs" action first when present
104
- const parts = [args.action, args.path ?? args.pattern ?? args.command].filter((v) => v != null)
105
- let s = parts.length > 0 ? parts.map(String).join(" ") : JSON.stringify(args)
106
- s = s.replace(/\s+/g, " ").trim()
107
- return s.length > 80 ? s.slice(0, 79) + "…" : s
108
- }
109
-
101
+ /**
102
+ * Tool-call progress line summary delegates to the single source describeToolArgs
103
+ * (../tui/tool-args.mjs) — the same function main-agent tool blocks and subagent
104
+ * blocks use. 2026-08-31: replaced the local picker (action/path/pattern/command-only)
105
+ * so advisor progress lines show the quoted-path forms everywhere else.
106
+ */
110
107
  /**
111
108
  * Render the ordered review timeline — thinking / tool progress / final text
112
109
  * interleaved EXACTLY as emitted, so the persisted record shows the review
@@ -246,7 +243,8 @@ async function runAdvisorToolLoop(provider, messages, onOutput, signal, agent, c
246
243
  continue
247
244
  }
248
245
 
249
- onTool(`\n→ ${tc.name} ${summarizeToolArgs(args)}\n`)
246
+ const argsLine = describeToolArgs(tc.name, args)
247
+ onTool(`\n→ ${tc.name}${argsLine ? " " + argsLine : ""}\n`)
250
248
  let result
251
249
  if (!tool) {
252
250
  result = `Error: unknown tool "${tc.name}". Available: ${[...toolByName.keys()].join(", ")}`
@@ -156,6 +156,13 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
156
156
  : "Error: no permission handler configured — this tool requires user approval but the current context doesn't support interaction (e.g. subagent or non-TUI mode)"
157
157
  return { ...item, result: reason, ok: false }
158
158
  }
159
+ // 2026-08-31 工具顺手度(用户批准"做吧"):dispatch 拦截工具执行期间的
160
+ // console.log/console.error——工具的探查/调试输出(原本只到终端、模型看不到)
161
+ // 收集后附在工具结果后回显给模型。bash 工具的输出走子进程回显(onOutput),
162
+ // 不走 dispatch console——拦截安全。嵌套 dispatch(subagent)各自拦截/恢复,
163
+ // 捕获分离(父恢复原始后子的拦截期间父捕获停止、子恢复后父继续)——正确。
164
+ // 声明在 try 之外:catch 块(异常路径)也要访问(报错前的探查输出回显)。
165
+ const capturedConsole = []
159
166
  try {
160
167
  // Snapshot for undo before side-effect tools (setupOutputPanel already fired in Phase 1)
161
168
  if (!item.tool?.readonly && item.args) {
@@ -170,26 +177,40 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
170
177
  return { ...item, result: routed.result, ok: true }
171
178
  }
172
179
  }
173
- const rawResult = await item.tool.execute(item.args, {
174
- cwd: agent.cwd,
175
- agent,
176
- depth,
177
- signal,
178
- callbacks,
179
- onOutput: (chunk) => callbacks.onToolOutput?.(item.toolCall.name, chunk, item.toolCall.id),
180
- onQuestion: callbacks.onQuestion,
181
- onPermissionRequest: callbacks.onPermissionRequest,
182
- })
180
+ const origConsoleLog = console.log
181
+ const origConsoleErr = console.error
182
+ console.log = (...a) => capturedConsole.push(a.map(String).join(" "))
183
+ console.error = (...a) => capturedConsole.push("[err] " + a.map(String).join(" "))
184
+ let rawResult
185
+ try {
186
+ rawResult = await item.tool.execute(item.args, {
187
+ cwd: agent.cwd,
188
+ agent,
189
+ depth,
190
+ signal,
191
+ callbacks,
192
+ onOutput: (chunk) => callbacks.onToolOutput?.(item.toolCall.name, chunk, item.toolCall.id),
193
+ onQuestion: callbacks.onQuestion,
194
+ onPermissionRequest: callbacks.onPermissionRequest,
195
+ })
196
+ } finally {
197
+ console.log = origConsoleLog
198
+ console.error = origConsoleErr
199
+ }
183
200
  if (rawResult === undefined) throw new Error(`Tool "${item.toolCall.name}" returned undefined — all tools must return a string value`)
184
201
  const raw = String(rawResult)
185
202
  // Multimodal tools keep the raw result (base64 images ride the multimodal
186
203
  // channel); everything else offloads oversized text to disk. Flag-driven, not
187
204
  // name-driven (consult P3, 2026-08-30).
188
205
  const result = item.tool?.multimodal ? raw : await offloadToolResult(raw, item.toolCall.id)
189
- callbacks.onToolResult?.(item.toolCall.name, result, item.toolCall.id)
206
+ // 2026-08-31:工具执行期间捕获的 console 输出附在结果后回显(模型视野)
207
+ const resultWithConsole = capturedConsole.length > 0
208
+ ? `${result}\n[console during ${item.toolCall.name}]\n${capturedConsole.join("\n")}`
209
+ : result
210
+ callbacks.onToolResult?.(item.toolCall.name, resultWithConsole, item.toolCall.id)
190
211
  // PostToolUse hooks: fire-and-forget (result not awaited on hook failure)
191
212
  runHooks("PostToolUse", { agent, toolName: item.toolCall.name, toolArgs: item.args, result: raw }).catch(() => {})
192
- return { ...item, result, ok: true }
213
+ return { ...item, result: resultWithConsole, ok: true }
193
214
  } catch (error) {
194
215
  // Persist to ~/.thincoder/tool-errors/ for post-mortem; only pass message to the model (stack traces confuse LLMs and may leak paths)
195
216
  logToolError(item.toolCall.name, item.args, error)
@@ -205,7 +226,11 @@ export async function executeToolCalls(agent, toolByName, toolCalls, callbacks,
205
226
  if (item.args.pattern) ctxParts.push(`pattern=${item.args.pattern}`)
206
227
  if (item.args.command) ctxParts.push(`cmd=${item.args.command.slice(0, 80)}`)
207
228
  const ctx = ctxParts.length > 0 ? ` [${ctxParts.join(", ")}]` : ""
208
- return { ...item, result: `Error: ${error.message}${ctx}`, ok: false }
229
+ // 2026-08-31:异常路径同样回显捕获的 console(工具报错前的探查输出最有价值)
230
+ const consolePart = capturedConsole.length > 0
231
+ ? `\n[console during ${item.toolCall.name}]\n${capturedConsole.join("\n")}`
232
+ : ""
233
+ return { ...item, result: `Error: ${error.message}${ctx}${consolePart}`, ok: false }
209
234
  }
210
235
  }
211
236
 
package/src/agent.mjs CHANGED
@@ -10,6 +10,7 @@ import { join, dirname } from "node:path"
10
10
  import { fileURLToPath } from "node:url"
11
11
  import { executeToolCalls } from "./agent/dispatch.mjs"
12
12
  import { recordToolResults } from "./agent/record-results.mjs"
13
+ import { FILE_MUTATORS } from "./agent/helpers.mjs"
13
14
  import { prepareRun } from "./agent/setup.mjs"
14
15
  import { injectPostTurn } from "./agent/post-turn.mjs"
15
16
  import { handleCompletion } from "./agent/completion.mjs"
@@ -256,6 +257,20 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
256
257
  throw e
257
258
  }
258
259
 
260
+ // 内置工具(Responses web_search)结果本地化:服务端已执行——入历史为 tool 消息,
261
+ // 模型下一轮可见;全量回传时 transport 依 tool_call_id 前缀还原 web_search_call item。
262
+ // 注意:服务端 item id 是 msg_xxx 非 web_search_call_ 前缀——必须合成前缀(toItems 识别锚点),
263
+ // 原始 id 存入 content(真机冒烟 2026-08-31:直接用 msg_xxx 会被转成 function_call_output
264
+ // 与服务端不配对,属蒙对)。
265
+ for (const btr of response.builtinToolResults ?? []) {
266
+ if (!btr?.id) continue
267
+ pushReal(agent, {
268
+ role: "tool",
269
+ tool_call_id: `web_search_call_${btr.id}`,
270
+ content: JSON.stringify({ id: btr.id, query: btr.query ?? "", sources: btr.sources ?? [], status: btr.status ?? "completed" }),
271
+ })
272
+ }
273
+
259
274
  // Stream rule triggered mid-generation (action: "abort"): halt current output,
260
275
  // inject rule's message as a reminder, and retry from the same context.
261
276
  if (response.ruleTriggered) {
@@ -357,6 +372,25 @@ export async function runAgent(agent, input, callbacks = {}, { depth = 0, signal
357
372
  // Ctrl+I interrupt during tool execution: skip committing partial results —
358
373
  // the tool failure messages would mislead the model. Inject the interrupt and retry.
359
374
  if (signal?.reason?.interrupt) {
375
+ // 中断变更记账(2026-08-31 评审 #4):此分支的工具已全部执行完成(磁盘已变,execute 已完成),
376
+ // 真实结果按语义不进历史(placeholder 替代)——但变更必须记账:否则 guard 看到
377
+ // "本轮未改代码" 放行,评审/verify 门禁被绕过(文件改了却没评审)。
378
+ for (const { toolCall, ok } of results) {
379
+ const tool = toolByName.get(toolCall.name)
380
+ if (!ok || !tool || !FILE_MUTATORS.has(toolCall.name)) continue
381
+ agent._mutatedThisRun = true
382
+ agent._calledAdvisorThisRun = false
383
+ agent._verifiedThisRun = false
384
+ agent._verifyPassed = undefined
385
+ try {
386
+ const args = JSON.parse(toolCall.arguments)
387
+ const paths = tool.touchedPaths ? tool.touchedPaths(args) : [args.path]
388
+ for (const p of paths) {
389
+ const abs = join(agent.cwd, p)
390
+ if (!agent._touchedFiles.includes(abs)) agent._touchedFiles.push(abs)
391
+ }
392
+ } catch { /* 畸形 args 不影响记账(touchedFiles 尽力而为) */ }
393
+ }
360
394
  // The assistant tool_calls were already committed above (L347) — a strict
361
395
  // provider 400s on dangling tool_calls, so synthesize placeholder tool
362
396
  // results BEFORE the interrupt message (tool result must immediately
@@ -60,11 +60,17 @@ export async function assembleAgent() {
60
60
  if (existsSync(mcpJsonPath)) {
61
61
  const mcpJson = JSON.parse(readFileSync(mcpJsonPath, "utf8"))
62
62
  if (mcpJson.mcpServers && typeof mcpJson.mcpServers === "object") {
63
- const configNames = new Set(mcpServers.map((s) => s.name))
64
- for (const [name, server] of Object.entries(mcpJson.mcpServers)) {
65
- if (configNames.has(name)) continue // config.json takes priority
66
- if (!server || typeof server !== "object") continue
67
- mcpServers.push({ name, ...server })
63
+ // 2026-08-31 MCP 会诊 #10:数组型 mcpServers 不是规范形态——Object.entries 会产出
64
+ // "0"/"1" 数字名(变成工具前缀 "0_tool"),必须跳过;server 条目嵌套数组同理。
65
+ if (Array.isArray(mcpJson.mcpServers)) {
66
+ console.error("[mcp] .mcp.json: mcpServers must be a plain object, got array — skipped")
67
+ } else {
68
+ const configNames = new Set(mcpServers.map((s) => s.name))
69
+ for (const [name, server] of Object.entries(mcpJson.mcpServers)) {
70
+ if (configNames.has(name)) continue // config.json takes priority
71
+ if (!server || typeof server !== "object" || Array.isArray(server)) continue
72
+ mcpServers.push({ name, ...server })
73
+ }
68
74
  }
69
75
  }
70
76
  }
@@ -27,13 +27,22 @@ export function quoteArg(s) {
27
27
  return /[\s"]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
28
28
  }
29
29
 
30
- /** Append a Bearer token as a query parameter to a WebSocket URL */
30
+ /** Convert an Authorization Bearer token into a WebSocket subprotocol.
31
+ * 2026-08-31 MCP 会诊 #10:原实现把 token 塞进 URL query——代理/网关日志会泄露凭证,
32
+ * 且无标准依据。Node 内置 WebSocket(undici)无法自定义请求头,MCP 生态的标准替代
33
+ * 通道是 subprotocol(`bearer.<token>`)。用户 URL 自带的 query token 不动(兼容)。
34
+ * @returns {{ url: string, protocols: string[] }} — protocols 为空数组表示无认证。 */
31
35
  export function withAuthToken(wsUrl, authorization) {
32
- if (!authorization) return wsUrl
36
+ if (!authorization) return { url: wsUrl, protocols: [] }
33
37
  const token = authorization.replace(/^Bearer\s+/i, "")
34
- const u = new URL(wsUrl)
35
- u.searchParams.set("token", token)
36
- return u.href
38
+ let u
39
+ try {
40
+ u = new URL(wsUrl)
41
+ } catch {
42
+ throw new Error(`Invalid WebSocket URL: ${String(wsUrl).slice(0, 120)}`)
43
+ }
44
+ // subprotocol 不进入 URL/日志,token 不再注入 query
45
+ return { url: u.href, protocols: [`bearer.${token}`] }
37
46
  }
38
47
 
39
48
  /** Sanitize a tool name: replace non-alphanumeric chars with underscores, cap at 64 chars */
@@ -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
  }