@thincoder/core 0.9.1

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 (203) hide show
  1. package/abort-provenance.mjs +116 -0
  2. package/advisor/citations.mjs +139 -0
  3. package/advisor/compaction.mjs +174 -0
  4. package/advisor/convergence.mjs +80 -0
  5. package/advisor/history.mjs +77 -0
  6. package/advisor/loop.mjs +288 -0
  7. package/advisor/messages.mjs +299 -0
  8. package/advisor/notice.mjs +141 -0
  9. package/advisor/project-context.mjs +197 -0
  10. package/advisor/repos.mjs +150 -0
  11. package/advisor/run.mjs +190 -0
  12. package/advisor/truncate.mjs +57 -0
  13. package/advisor.mjs +281 -0
  14. package/agent/child-marks.mjs +24 -0
  15. package/agent/completion.mjs +145 -0
  16. package/agent/dispatch.mjs +493 -0
  17. package/agent/family-tools.mjs +174 -0
  18. package/agent/helpers.mjs +412 -0
  19. package/agent/post-turn.mjs +70 -0
  20. package/agent/record-results.mjs +174 -0
  21. package/agent/relay-prefix.mjs +39 -0
  22. package/agent/run-stages.mjs +244 -0
  23. package/agent/setup-reminders.mjs +199 -0
  24. package/agent/setup.mjs +234 -0
  25. package/agent/spawn-child.mjs +258 -0
  26. package/agent/suspension.mjs +240 -0
  27. package/agent/write-gate.mjs +87 -0
  28. package/agent-tools/advisor-async.mjs +481 -0
  29. package/agent-tools/advisor-settle.mjs +240 -0
  30. package/agent-tools/advisor.mjs +280 -0
  31. package/agent-tools/async-discard.mjs +143 -0
  32. package/agent-tools/async-settle.mjs +299 -0
  33. package/agent-tools/batch-segment.mjs +265 -0
  34. package/agent-tools/child-permission.mjs +45 -0
  35. package/agent-tools/consult.mjs +471 -0
  36. package/agent-tools/design-token.mjs +117 -0
  37. package/agent-tools/digest-budget.mjs +76 -0
  38. package/agent-tools/eng.mjs +102 -0
  39. package/agent-tools/escalate-async.mjs +302 -0
  40. package/agent-tools/goal.mjs +119 -0
  41. package/agent-tools/panel-blocks.mjs +24 -0
  42. package/agent-tools/parent-channel.mjs +231 -0
  43. package/agent-tools/plan.mjs +86 -0
  44. package/agent-tools/read-history.mjs +309 -0
  45. package/agent-tools/recent-changes.mjs +24 -0
  46. package/agent-tools/review-facts.mjs +31 -0
  47. package/agent-tools/settings.mjs +268 -0
  48. package/agent-tools/skill.mjs +63 -0
  49. package/agent-tools/spawn-gates.mjs +109 -0
  50. package/agent-tools/subagent-actions.mjs +496 -0
  51. package/agent-tools/subagent-async.mjs +456 -0
  52. package/agent-tools/subagent-panel.mjs +160 -0
  53. package/agent-tools/subagent-run.mjs +208 -0
  54. package/agent-tools/subagent-scheduler.mjs +446 -0
  55. package/agent-tools/subagent-spawn.mjs +478 -0
  56. package/agent-tools/subagent.mjs +419 -0
  57. package/agent-tools/task.mjs +87 -0
  58. package/agent-tools/timer.mjs +46 -0
  59. package/agent-tools/verify.mjs +295 -0
  60. package/agent-tools.mjs +23 -0
  61. package/agent.mjs +430 -0
  62. package/auto-think.mjs +115 -0
  63. package/compress-form.mjs +24 -0
  64. package/config-io.mjs +277 -0
  65. package/config-migrate.mjs +178 -0
  66. package/config-presets.mjs +49 -0
  67. package/config.mjs +419 -0
  68. package/context.mjs +495 -0
  69. package/conventions.mjs +223 -0
  70. package/embedding.mjs +120 -0
  71. package/escape.mjs +152 -0
  72. package/expand-home.mjs +16 -0
  73. package/explore-distill.mjs +152 -0
  74. package/generate-title.mjs +123 -0
  75. package/git/checkpoint.mjs +448 -0
  76. package/git/gitmem.mjs +100 -0
  77. package/history-window.mjs +179 -0
  78. package/hooks.mjs +108 -0
  79. package/i18n.mjs +106 -0
  80. package/index-bin.mjs +48 -0
  81. package/index-discover.mjs +176 -0
  82. package/ledger-cmd.mjs +209 -0
  83. package/ledger-db.mjs +86 -0
  84. package/ledger-surface.mjs +76 -0
  85. package/ledger.mjs +202 -0
  86. package/log.mjs +195 -0
  87. package/manifest.mjs +338 -0
  88. package/markdown.mjs +106 -0
  89. package/mcp/helpers.mjs +51 -0
  90. package/mcp/transport-http.mjs +248 -0
  91. package/mcp/transport-stdio.mjs +140 -0
  92. package/mcp/transport-ws.mjs +122 -0
  93. package/mcp.mjs +295 -0
  94. package/memory/code-index.mjs +219 -0
  95. package/memory/code-sync.mjs +427 -0
  96. package/memory/core.mjs +318 -0
  97. package/memory/delete.mjs +242 -0
  98. package/memory/docs.mjs +431 -0
  99. package/memory/file-walk.mjs +109 -0
  100. package/memory/origin.mjs +24 -0
  101. package/memory/scan.mjs +177 -0
  102. package/memory/schema.mjs +460 -0
  103. package/memory.mjs +21 -0
  104. package/model-ref.mjs +66 -0
  105. package/model-specs.mjs +277 -0
  106. package/package.json +32 -0
  107. package/peer-domains.mjs +265 -0
  108. package/peer-instances.mjs +178 -0
  109. package/permission.mjs +79 -0
  110. package/process-probe.mjs +315 -0
  111. package/prompt-files.mjs +113 -0
  112. package/prompt-overlays.mjs +78 -0
  113. package/prompts/advisor-design.md +43 -0
  114. package/prompts/advisor-round1.md +41 -0
  115. package/prompts/advisor-round2.md +46 -0
  116. package/prompts/advisor-round3.md +42 -0
  117. package/prompts/common.md +158 -0
  118. package/prompts/consult-base.md +19 -0
  119. package/prompts/discipline-engineering.md +123 -0
  120. package/prompts/discipline-normal.md +206 -0
  121. package/prompts/persona-coder.md +21 -0
  122. package/prompts/persona-eng-coder.md +41 -0
  123. package/prompts/persona-eng-designer.md +80 -0
  124. package/prompts/persona-engineering.md +160 -0
  125. package/prompts/persona-explore.md +15 -0
  126. package/prompts/persona-normal.md +35 -0
  127. package/prompts/persona-plan.md +27 -0
  128. package/provider/anthropic.mjs +225 -0
  129. package/provider/core.mjs +491 -0
  130. package/provider/errors.mjs +101 -0
  131. package/provider/google.mjs +257 -0
  132. package/provider/index.mjs +7 -0
  133. package/provider/list-models.mjs +163 -0
  134. package/provider/normalize.mjs +81 -0
  135. package/provider/rate.mjs +168 -0
  136. package/provider/responses.mjs +495 -0
  137. package/provider/retry.mjs +88 -0
  138. package/provider/sse.mjs +264 -0
  139. package/provider/wait-status.mjs +59 -0
  140. package/proxy.mjs +274 -0
  141. package/rules.mjs +53 -0
  142. package/session-gc.mjs +248 -0
  143. package/session-guard.mjs +59 -0
  144. package/session-lifecycle.mjs +305 -0
  145. package/session-migrate.mjs +48 -0
  146. package/session-rename.mjs +38 -0
  147. package/session-segments.mjs +99 -0
  148. package/session-slot-write.mjs +168 -0
  149. package/session-slots-manifest.mjs +264 -0
  150. package/session-slots.mjs +298 -0
  151. package/session-store.mjs +441 -0
  152. package/session.mjs +244 -0
  153. package/skills.mjs +234 -0
  154. package/text-budget.mjs +79 -0
  155. package/token-ttl.mjs +285 -0
  156. package/tool-docs/apply_patch.md +15 -0
  157. package/tool-docs/bash.md +38 -0
  158. package/tool-docs/delete.md +13 -0
  159. package/tool-docs/edit.md +30 -0
  160. package/tool-docs/execute.md +21 -0
  161. package/tool-docs/fetch.md +12 -0
  162. package/tool-docs/file_ops.md +17 -0
  163. package/tool-docs/get_current_time.md +8 -0
  164. package/tool-docs/git.md +54 -0
  165. package/tool-docs/glob.md +11 -0
  166. package/tool-docs/grep.md +19 -0
  167. package/tool-docs/hashline_edit.md +14 -0
  168. package/tool-docs/insert_after.md +15 -0
  169. package/tool-docs/lint.md +10 -0
  170. package/tool-docs/ls.md +12 -0
  171. package/tool-docs/lsp.md +10 -0
  172. package/tool-docs/process.md +10 -0
  173. package/tool-docs/question.md +16 -0
  174. package/tool-docs/read.md +20 -0
  175. package/tool-docs/read_image.md +8 -0
  176. package/tool-docs/tree.md +14 -0
  177. package/tool-docs/wait_for.md +22 -0
  178. package/tool-docs/websearch.md +16 -0
  179. package/tool-docs/write.md +11 -0
  180. package/tools/bash.mjs +276 -0
  181. package/tools/edit-batch.mjs +204 -0
  182. package/tools/edit-diff.mjs +388 -0
  183. package/tools/exec-run.mjs +43 -0
  184. package/tools/execute.mjs +243 -0
  185. package/tools/file.mjs +464 -0
  186. package/tools/git-checkpoint.mjs +143 -0
  187. package/tools/git-ext.mjs +173 -0
  188. package/tools/git.mjs +415 -0
  189. package/tools/glob-dialect.mjs +130 -0
  190. package/tools/index.mjs +76 -0
  191. package/tools/linter.mjs +120 -0
  192. package/tools/lsp.mjs +335 -0
  193. package/tools/ops.mjs +293 -0
  194. package/tools/patch.mjs +290 -0
  195. package/tools/question.mjs +26 -0
  196. package/tools/repomap.mjs +314 -0
  197. package/tools/search.mjs +248 -0
  198. package/tools/shared.mjs +467 -0
  199. package/tools/tree.mjs +81 -0
  200. package/tools/web.mjs +224 -0
  201. package/tools/write-path.mjs +191 -0
  202. package/traces/trace-store.mjs +303 -0
  203. package/undo-stack.mjs +47 -0
@@ -0,0 +1,51 @@
1
+ /**
2
+ * mcp/helpers.mjs — MCP shared utility functions and constants
3
+ */
4
+
5
+ export const INIT_TIMEOUT_MS = 30_000
6
+ export const CALL_TIMEOUT_MS = 120_000
7
+ export const ENDPOINT_WAIT_MS = 5_000
8
+
9
+ let nextRpcId = 0
10
+ /** Generate a unique incrementing RPC ID string */
11
+ export function rpcId() {
12
+ return String(++nextRpcId)
13
+ }
14
+
15
+ /** Race a promise against a timeout, rejecting after ms milliseconds */
16
+ export function withTimeout(promise, ms) {
17
+ let timer
18
+ const timeout = new Promise((_, reject) => {
19
+ timer = setTimeout(() => reject(new Error(`timeout after ${ms}ms`)), ms)
20
+ timer.unref?.()
21
+ })
22
+ return Promise.race([promise.finally(() => clearTimeout(timer)), timeout])
23
+ }
24
+
25
+ /** Quote a shell argument: wrap in double-quotes if it contains whitespace or quotes */
26
+ export function quoteArg(s) {
27
+ return /[\s"]/.test(s) ? `"${s.replace(/"/g, '""')}"` : s
28
+ }
29
+
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 为空数组表示无认证。 */
35
+ export function withAuthToken(wsUrl, authorization) {
36
+ if (!authorization) return { url: wsUrl, protocols: [] }
37
+ const token = authorization.replace(/^Bearer\s+/i, "")
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}`] }
46
+ }
47
+
48
+ /** Sanitize a tool name: replace non-alphanumeric chars with underscores, cap at 64 chars */
49
+ export function sanitizeToolName(name) {
50
+ return name.replace(/[^a-zA-Z0-9_-]/g, "_").slice(0, 64)
51
+ }
@@ -0,0 +1,248 @@
1
+ /**
2
+ * mcp/transport-http.mjs — MCP HTTP + SSE transport (Streamable HTTP)
3
+ */
4
+ import { rpcId, CALL_TIMEOUT_MS, ENDPOINT_WAIT_MS, INIT_TIMEOUT_MS, withTimeout } from "./helpers.mjs"
5
+
6
+ /** Create an MCP HTTP+SSE transport for Streamable HTTP servers */
7
+ export function httpTransport(baseURL, extraHeaders = {}) {
8
+ const url = baseURL.replace(/\/+$/, "")
9
+ let sessionId = null
10
+ let closed = false
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
15
+ let abortController = null
16
+ let postUrl = url
17
+ let legacySSE = false
18
+ let deadFired = false
19
+ let deadListeners = new Set()
20
+
21
+ /** 2026-08-31 MCP 会诊 P5:意外死亡通知(SSE 流断/error,非主动 close)。 */
22
+ const fireDead = (msg) => {
23
+ if (deadFired) return
24
+ deadFired = true
25
+ for (const cb of deadListeners) { try { cb(msg) } catch { /* listener error */ } }
26
+ }
27
+ const onDead = (cb) => { deadListeners.add(cb); return () => deadListeners.delete(cb) }
28
+
29
+ const headers = () => {
30
+ const h = { "Content-Type": "application/json", Accept: "text/event-stream, application/json", ...extraHeaders }
31
+ if (sessionId) h["Mcp-Session-Id"] = sessionId
32
+ return h
33
+ }
34
+
35
+ const pending = new Map()
36
+
37
+ async function* parseSSE(response) {
38
+ const reader = response.body.getReader()
39
+ const decoder = new TextDecoder()
40
+ let buf = ""
41
+ let current = { data: "", event: "message" }
42
+ try {
43
+ while (true) {
44
+ const { done, value } = await reader.read()
45
+ if (done) break
46
+ buf += decoder.decode(value, { stream: true })
47
+ const lines = buf.split("\n")
48
+ buf = lines.pop() ?? ""
49
+ for (const raw of lines) {
50
+ const line = raw.endsWith("\r") ? raw.slice(0, -1) : raw
51
+ if (line === "") {
52
+ if (current.data) {
53
+ yield { event: current.event, data: current.data.trimEnd() }
54
+ current = { data: "", event: "message" }
55
+ }
56
+ } else if (line.startsWith("data:")) {
57
+ current.data += (current.data ? "\n" : "") + line.slice(5).replace(/^ /, "")
58
+ } else if (line.startsWith("event:")) {
59
+ current.event = line.slice(6).trim()
60
+ }
61
+ }
62
+ }
63
+ } finally {
64
+ reader.releaseLock()
65
+ }
66
+ }
67
+
68
+ async function openSSE() {
69
+ if (closed) return
70
+ abortController?.abort()
71
+ abortController = new AbortController()
72
+ const signal = AbortSignal.any([abortController.signal, AbortSignal.timeout(INIT_TIMEOUT_MS)])
73
+ const resp = await fetch(url, {
74
+ method: "GET",
75
+ headers: { Accept: "text/event-stream", ...extraHeaders },
76
+ signal,
77
+ })
78
+ if (!resp.ok) throw new Error(`SSE connect failed: HTTP ${resp.status}`)
79
+ eventSource = parseSSE(resp)
80
+ let endpointReady
81
+ const gotEndpoint = new Promise((resolve) => { endpointReady = resolve })
82
+
83
+ ;(async () => {
84
+ try {
85
+ for await (const { event, data } of eventSource) {
86
+ if (closed) break
87
+ if (event === "endpoint") {
88
+ postUrl = new URL(data.trim(), url).href
89
+ legacySSE = true
90
+ endpointReady()
91
+ continue
92
+ }
93
+ try {
94
+ const msg = JSON.parse(data)
95
+ const resolver = pending.get(msg.id)
96
+ if (resolver) {
97
+ pending.delete(msg.id)
98
+ resolver(msg)
99
+ }
100
+ } catch { /* not JSON, ignore */ }
101
+ }
102
+ // 正常走完 = server 关闭了流(网络/对端退出)→ 非主动关闭视为死亡
103
+ if (!closed) fireDead("SSE stream ended")
104
+ } catch (error) {
105
+ const wasClosed = closed
106
+ if (!wasClosed) {
107
+ for (const [, resolve] of pending) resolve({ id: null, error: { code: -32000, message: `SSE error: ${error.message}` } })
108
+ pending.clear()
109
+ fireDead(`SSE error: ${error.message}`)
110
+ }
111
+ }
112
+ })()
113
+
114
+ const wait = new Promise((resolve) => {
115
+ const t = setTimeout(resolve, ENDPOINT_WAIT_MS)
116
+ t.unref?.()
117
+ })
118
+ await Promise.race([gotEndpoint, wait])
119
+ }
120
+
121
+ async function postRequest(method, params) {
122
+ const id = rpcId()
123
+ const body = JSON.stringify({ jsonrpc: "2.0", id, method, params })
124
+
125
+ if (legacySSE) {
126
+ // 2026-08-31 MCP 会诊 #10:legacy 模式下 POST 若直接带回 JSON-RPC body(违规 server,
127
+ // 规范是响应经 GET SSE 流回)——原实现对 resp.ok 什么都不做 → pending 挂满 120s。
128
+ // 空 body = 规范行为(等待 SSE 流);非空 JSON = 违规 server,直接解析 resolve。
129
+ return new Promise((resolve) => {
130
+ pending.set(id, resolve)
131
+ fetch(postUrl, { method: "POST", headers: headers(), body, signal: AbortSignal.timeout(CALL_TIMEOUT_MS) })
132
+ .then(async (resp) => {
133
+ if (!resp.ok) {
134
+ pending.delete(id)
135
+ resolve({ id, error: { code: -32000, message: `POST failed: HTTP ${resp.status}` } })
136
+ return
137
+ }
138
+ const raw = await resp.text().catch(() => "")
139
+ if (raw.trim()) {
140
+ try {
141
+ const msg = JSON.parse(raw)
142
+ if (msg.id === id) { pending.delete(id); resolve(msg); return }
143
+ } catch { /* ignore — pending 交给 SSE 流 */ }
144
+ }
145
+ // 空体或非本次 id 的 JSON:保持 pending 等 GET SSE 流回包
146
+ })
147
+ .catch((e) => {
148
+ pending.delete(id)
149
+ resolve({ id, error: { code: -32000, message: `POST failed: ${e.message}` } })
150
+ })
151
+ }).finally(() => pending.delete(id))
152
+ }
153
+
154
+ // 2026-08-31 MCP 会诊 P4:Streamable HTTP 规范路径(POST→202→GET SSE 回包)。
155
+ // 原实现非 legacy 分支从不注册 pending:202 空 body → resp.json() 抛 SyntaxError,
156
+ // 而 SSE 流里 pending.get(id) 永远 miss——每次调用挂满 120s。
157
+ // 现在两类通道统一:先注册 pending,POST 得到的结果(直接 body / SSE / 202 等待)
158
+ // 都经 pending resolve;SSE 流由 openSSE() 转发。200+JSON 直接 body 解析。
159
+ return new Promise((resolve) => {
160
+ pending.set(id, resolve)
161
+ fetch(postUrl, { method: "POST", headers: headers(), body, signal: AbortSignal.timeout(CALL_TIMEOUT_MS) })
162
+ .then(async (resp) => {
163
+ const ct = resp.headers.get("content-type") ?? ""
164
+ const newSessionId = resp.headers.get("Mcp-Session-Id")
165
+ if (newSessionId) sessionId = newSessionId
166
+ // 202: 已接受,响应将经 GET SSE 流回——pending 保持,由 openSSE 转发 resolve
167
+ if (resp.status === 202) return
168
+ if (!resp.ok) {
169
+ pending.delete(id)
170
+ resolve({ id, error: { code: -32000, message: `HTTP ${resp.status}` } })
171
+ return
172
+ }
173
+ if (ct.includes("text/event-stream")) {
174
+ // 响应体本身就是一条 SSE(一次性流):解析匹配该 id
175
+ try {
176
+ for await (const { data } of parseSSE(resp)) {
177
+ try {
178
+ const msg = JSON.parse(data)
179
+ if (msg.id === id) { pending.delete(id); resolve(msg); return }
180
+ } catch { /* skip */ }
181
+ }
182
+ pending.delete(id)
183
+ resolve({ id, error: { code: -32000, message: "No JSON-RPC response in SSE stream" } })
184
+ } catch (e) {
185
+ pending.delete(id)
186
+ resolve({ id, error: { code: -32000, message: `SSE response failed: ${e.message}` } })
187
+ }
188
+ return
189
+ }
190
+ // 直接 JSON body:拿掉 pending 立即解析
191
+ pending.delete(id)
192
+ try {
193
+ resolve(await resp.json())
194
+ } catch (e) {
195
+ resolve({ id, error: { code: -32000, message: `invalid JSON body: ${e.message}` } })
196
+ }
197
+ })
198
+ .catch((e) => {
199
+ if (pending.delete(id)) {
200
+ resolve({ id, error: { code: -32000, message: `POST failed: ${e.message}` } })
201
+ }
202
+ })
203
+ }).finally(() => pending.delete(id))
204
+ }
205
+
206
+ const send = async (method, params) => withTimeout(postRequest(method, params), CALL_TIMEOUT_MS)
207
+
208
+ const notify = (method, params) => {
209
+ fetch(postUrl, {
210
+ method: "POST",
211
+ headers: headers(),
212
+ body: JSON.stringify({ jsonrpc: "2.0", method, params }),
213
+ signal: AbortSignal.timeout(10_000),
214
+ }).catch((e) => {
215
+ // notify is fire-and-forget by design, but log network errors for debugging
216
+ if (e.name !== "AbortError") console.error(`[mcp] notify failed: ${e.message}`)
217
+ })
218
+ }
219
+
220
+ const close = () => {
221
+ closed = true
222
+ abortController?.abort()
223
+ if (sessionId) {
224
+ fetch(postUrl, {
225
+ method: "DELETE",
226
+ headers: { "Mcp-Session-Id": sessionId, ...extraHeaders },
227
+ signal: AbortSignal.timeout(5_000),
228
+ }).catch((e) => {
229
+ // close is best-effort cleanup; log but don't throw
230
+ if (e.name !== "AbortError") console.error(`[mcp] close DELETE failed: ${e.message}`)
231
+ })
232
+ sessionId = null
233
+ }
234
+ for (const [, resolve] of pending) resolve({ id: null, error: { code: -32000, message: "Connection closed" } })
235
+ pending.clear()
236
+ }
237
+
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
+ }
248
+ }
@@ -0,0 +1,140 @@
1
+ /**
2
+ * mcp/transport-stdio.mjs — MCP stdio transport
3
+ */
4
+ import { spawn } from "node:child_process"
5
+ import { rpcId, CALL_TIMEOUT_MS, withTimeout, quoteArg } from "./helpers.mjs"
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
+
24
+ /** Create an MCP stdio transport over a spawned child process.
25
+ * @param {Object} [env] — extra environment variables merged on top of process.env */
26
+ export function stdioTransport(command, args, env) {
27
+ const spawnOptions = { stdio: ["pipe", "pipe", "pipe"], windowsHide: true, env: { ...process.env, ...env } }
28
+ const child =
29
+ process.platform === "win32" && !/\.exe$/i.test(command)
30
+ ? spawn("cmd.exe", ["/d", "/s", "/c", [command, ...(args ?? [])].map(quoteArg).join(" ")], {
31
+ ...spawnOptions,
32
+ windowsVerbatimArguments: true,
33
+ })
34
+ : spawn(command, args ?? [], spawnOptions)
35
+
36
+ const pending = new Map()
37
+ const decoder = new TextDecoder()
38
+ let buffer = ""
39
+ let stderrTail = ""
40
+ let spawnError = null
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) }
53
+
54
+ const failAll = (message) => {
55
+ for (const [, resolve] of pending) resolve({ id: null, error: { code: -32000, message } })
56
+ pending.clear()
57
+ }
58
+
59
+ child.stdout.on("data", (chunk) => {
60
+ buffer += typeof chunk === "string" ? chunk : decoder.decode(chunk, { stream: true })
61
+ const lines = buffer.split("\n")
62
+ buffer = lines.pop() ?? ""
63
+ for (const line of lines) {
64
+ if (!line.trim()) continue
65
+ try {
66
+ const msg = JSON.parse(line)
67
+ const resolver = pending.get(msg.id)
68
+ if (resolver) {
69
+ pending.delete(msg.id)
70
+ resolver(msg)
71
+ }
72
+ } catch { /* non-JSON line, ignore */ }
73
+ }
74
+ })
75
+
76
+ child.stderr.on("data", (chunk) => {
77
+ stderrTail = (stderrTail + chunk.toString()).slice(-2000)
78
+ })
79
+
80
+ child.stdin.on("error", () => {})
81
+ child.on("error", (error) => {
82
+ spawnError = error
83
+ closed = true
84
+ failAll(`spawn failed: ${error.message}`)
85
+ })
86
+ child.on("close", () => {
87
+ const wasClosed = closed // 主动 close() 先置 closed → 意外事件不重复
88
+ closed = true
89
+ const lastLine = stderrTail.trim().split("\n").pop()
90
+ failAll(`Connection closed${lastLine ? ` | stderr: ${lastLine}` : ""}`)
91
+ if (!wasClosed) fireDead(`MCP server process exited${lastLine ? ` | stderr: ${lastLine}` : ""}`)
92
+ })
93
+
94
+ const send = (method, params, signal) => {
95
+ if (spawnError) return Promise.resolve({ id: null, error: { code: -32000, message: `spawn failed: ${spawnError.message}` } })
96
+ if (closed) return Promise.reject(new Error("MCP connection closed"))
97
+ const id = rpcId()
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
+ }
112
+ try {
113
+ child.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + "\n")
114
+ } catch (error) {
115
+ pending.delete(id)
116
+ return Promise.resolve({ id: null, error: { code: -32000, message: `stdin write failed: ${error.message}` } })
117
+ }
118
+ return withTimeout(promise, CALL_TIMEOUT_MS).finally(() => pending.delete(id))
119
+ }
120
+
121
+ const notify = (method, params) => {
122
+ if (closed) return
123
+ try {
124
+ child.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + "\n")
125
+ } catch { /* ignore */ }
126
+ }
127
+
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
+ }
140
+ }
@@ -0,0 +1,122 @@
1
+ /**
2
+ * mcp/transport-ws.mjs — MCP WebSocket transport
3
+ */
4
+ import { rpcId, INIT_TIMEOUT_MS, CALL_TIMEOUT_MS, withTimeout, withAuthToken } from "./helpers.mjs"
5
+
6
+ /** Create an MCP WebSocket transport */
7
+ export function wsTransport(wsUrl, extraHeaders = {}) {
8
+ const pending = new Map()
9
+ let closed = false
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) }
21
+
22
+ const failAll = (message) => {
23
+ for (const [, resolve] of pending) resolve({ id: null, error: { code: -32000, message } })
24
+ pending.clear()
25
+ }
26
+
27
+ const connect = () => {
28
+ if (closed) throw new Error("MCP WebSocket connection closed")
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)
33
+
34
+ return new Promise((resolve, reject) => {
35
+ let settled = false
36
+ const settleErr = (err) => { if (!settled) { settled = true; reject(err) } }
37
+ const timeout = setTimeout(() => {
38
+ settleErr(new Error(`WebSocket connect timeout: ${wsUrl}`))
39
+ try { ws.close() } catch { /* ignore */ }
40
+ }, INIT_TIMEOUT_MS)
41
+
42
+ ws.addEventListener("open", () => {
43
+ if (settled) return
44
+ settled = true
45
+ clearTimeout(timeout)
46
+ resolve()
47
+ })
48
+
49
+ ws.addEventListener("message", (event) => {
50
+ try {
51
+ const msg = JSON.parse(event.data.toString())
52
+ const resolver = pending.get(msg.id)
53
+ if (resolver) {
54
+ pending.delete(msg.id)
55
+ resolver(msg)
56
+ }
57
+ } catch { /* not JSON, ignore */ }
58
+ })
59
+
60
+ ws.addEventListener("error", (event) => {
61
+ const wasClosed = closed
62
+ clearTimeout(timeout)
63
+ closed = true
64
+ const errMsg = event.message || event.error?.message || "WebSocket error"
65
+ settleErr(new Error(errMsg))
66
+ failAll(errMsg)
67
+ if (!wasClosed) fireDead(errMsg)
68
+ })
69
+
70
+ ws.addEventListener("close", () => {
71
+ const wasClosed = closed
72
+ clearTimeout(timeout)
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"))
78
+ failAll("WebSocket closed")
79
+ if (!wasClosed) fireDead("WebSocket closed")
80
+ })
81
+ })
82
+ }
83
+
84
+ const send = (method, params, signal) => {
85
+ if (closed) return Promise.reject(new Error("MCP WebSocket connection closed"))
86
+ const id = rpcId()
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
+ }
100
+ try {
101
+ ws.send(JSON.stringify({ jsonrpc: "2.0", id, method, params }))
102
+ } catch (error) {
103
+ pending.delete(id)
104
+ return Promise.resolve({ id: null, error: { code: -32000, message: `ws send failed: ${error.message}` } })
105
+ }
106
+ return withTimeout(promise, CALL_TIMEOUT_MS).finally(() => pending.delete(id))
107
+ }
108
+
109
+ const notify = (method, params) => {
110
+ if (!closed && ws?.readyState === WebSocket.OPEN) {
111
+ ws.send(JSON.stringify({ jsonrpc: "2.0", method, params }))
112
+ }
113
+ }
114
+
115
+ const close = () => {
116
+ closed = true
117
+ failAll("Connection closed")
118
+ try { ws?.close() } catch { /* ignore */ }
119
+ }
120
+
121
+ return { send, notify, close, connect, isAlive: () => !closed && ws?.readyState === WebSocket.OPEN, onDead }
122
+ }