@kkelly-offical/kkcode 0.1.7 → 0.2.3-preview.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 (166) hide show
  1. package/LICENSE +674 -674
  2. package/README.md +474 -387
  3. package/package.json +50 -46
  4. package/src/agent/agent.mjs +228 -220
  5. package/src/agent/custom-agent-loader.mjs +6 -3
  6. package/src/agent/generator.mjs +2 -2
  7. package/src/agent/prompt/assistant.txt +12 -0
  8. package/src/agent/prompt/bug-hunter.txt +89 -89
  9. package/src/agent/prompt/frontend-designer.txt +58 -58
  10. package/src/agent/prompt/guide.txt +1 -1
  11. package/src/agent/prompt/longagent-blueprint-agent.txt +83 -83
  12. package/src/agent/prompt/longagent-coding-agent.txt +37 -37
  13. package/src/agent/prompt/longagent-debugging-agent.txt +46 -46
  14. package/src/agent/prompt/longagent-preview-agent.txt +63 -63
  15. package/src/command/custom-commands.mjs +2 -2
  16. package/src/commands/agent.mjs +1 -1
  17. package/src/commands/background.mjs +145 -4
  18. package/src/commands/chat.mjs +117 -76
  19. package/src/commands/config.mjs +148 -1
  20. package/src/commands/doctor.mjs +30 -6
  21. package/src/commands/init.mjs +32 -6
  22. package/src/commands/longagent.mjs +117 -0
  23. package/src/commands/mcp.mjs +275 -43
  24. package/src/commands/permission.mjs +1 -1
  25. package/src/commands/session.mjs +195 -140
  26. package/src/commands/skill.mjs +63 -0
  27. package/src/commands/theme.mjs +1 -1
  28. package/src/commands/update.mjs +32 -0
  29. package/src/config/defaults.mjs +289 -260
  30. package/src/config/import-config.mjs +1 -1
  31. package/src/config/load-config.mjs +61 -4
  32. package/src/config/schema.mjs +604 -574
  33. package/src/context.mjs +4 -1
  34. package/src/core/constants.mjs +97 -91
  35. package/src/core/types.mjs +1 -1
  36. package/src/github/api.mjs +78 -78
  37. package/src/github/auth.mjs +294 -286
  38. package/src/github/flow.mjs +298 -298
  39. package/src/github/workspace.mjs +225 -212
  40. package/src/index.mjs +87 -82
  41. package/src/knowledge/frontend-aesthetics.txt +38 -38
  42. package/src/mcp/client-http.mjs +139 -141
  43. package/src/mcp/client-sse.mjs +297 -288
  44. package/src/mcp/client-stdio.mjs +534 -533
  45. package/src/mcp/constants.mjs +4 -2
  46. package/src/mcp/registry.mjs +498 -479
  47. package/src/mcp/stdio-framing.mjs +135 -133
  48. package/src/mcp/tool-result.mjs +24 -24
  49. package/src/observability/edit-diagnostics.mjs +449 -0
  50. package/src/observability/index.mjs +42 -42
  51. package/src/observability/metrics.mjs +165 -137
  52. package/src/observability/tracer.mjs +137 -137
  53. package/src/onboarding.mjs +209 -0
  54. package/src/orchestration/background-manager.mjs +567 -372
  55. package/src/orchestration/background-worker.mjs +419 -305
  56. package/src/orchestration/interruption-reason.mjs +21 -0
  57. package/src/orchestration/longagent-manager.mjs +197 -171
  58. package/src/orchestration/stage-scheduler.mjs +733 -728
  59. package/src/orchestration/subagent-router.mjs +7 -1
  60. package/src/orchestration/task-scheduler.mjs +219 -7
  61. package/src/permission/engine.mjs +1 -1
  62. package/src/permission/exec-policy.mjs +370 -370
  63. package/src/permission/file-edit-policy.mjs +108 -0
  64. package/src/permission/prompt.mjs +1 -1
  65. package/src/permission/rules.mjs +116 -7
  66. package/src/plugin/builtin-hooks/post-edit-format.mjs +2 -1
  67. package/src/plugin/builtin-hooks/post-edit-typecheck.mjs +104 -40
  68. package/src/plugin/hook-bus.mjs +19 -5
  69. package/src/plugin/manifest-loader.mjs +222 -0
  70. package/src/provider/anthropic.mjs +396 -390
  71. package/src/provider/ollama.mjs +7 -1
  72. package/src/provider/openai.mjs +382 -340
  73. package/src/provider/retry-policy.mjs +74 -68
  74. package/src/provider/router.mjs +242 -241
  75. package/src/provider/sse.mjs +104 -104
  76. package/src/provider/wizard.mjs +556 -0
  77. package/src/repl/capability-facade.mjs +30 -0
  78. package/src/repl/command-surface.mjs +23 -0
  79. package/src/repl/controller-entry.mjs +40 -0
  80. package/src/repl/core-shell.mjs +208 -0
  81. package/src/repl/dialog-router.mjs +87 -0
  82. package/src/repl/input-engine.mjs +76 -0
  83. package/src/repl/keymap.mjs +7 -0
  84. package/src/repl/operator-surface.mjs +15 -0
  85. package/src/repl/permission-flow.mjs +49 -0
  86. package/src/repl/runtime-facade.mjs +36 -0
  87. package/src/repl/slash-router.mjs +62 -0
  88. package/src/repl/state-store.mjs +29 -0
  89. package/src/repl/turn-controller.mjs +58 -0
  90. package/src/repl/verification.mjs +23 -0
  91. package/src/repl.mjs +3371 -2981
  92. package/src/rules/load-rules.mjs +3 -3
  93. package/src/runtime.mjs +1 -1
  94. package/src/session/agent-transaction.mjs +86 -0
  95. package/src/session/checkpoint.mjs +302 -302
  96. package/src/session/compaction.mjs +298 -298
  97. package/src/session/engine.mjs +417 -232
  98. package/src/session/longagent-4stage.mjs +467 -460
  99. package/src/session/longagent-hybrid.mjs +1344 -1097
  100. package/src/session/longagent-plan.mjs +376 -365
  101. package/src/session/longagent-project-memory.mjs +53 -53
  102. package/src/session/longagent-scaffold.mjs +291 -291
  103. package/src/session/longagent-task-bus.mjs +138 -54
  104. package/src/session/longagent-utils.mjs +828 -472
  105. package/src/session/longagent.mjs +911 -900
  106. package/src/session/loop.mjs +1005 -930
  107. package/src/session/prompt/agent.txt +25 -25
  108. package/src/session/prompt/anthropic.txt +150 -150
  109. package/src/session/prompt/beast.txt +1 -1
  110. package/src/session/prompt/plan.txt +31 -31
  111. package/src/session/prompt/qwen.txt +46 -46
  112. package/src/session/recovery.mjs +21 -0
  113. package/src/session/rollback.mjs +196 -195
  114. package/src/session/routing-observability.mjs +72 -0
  115. package/src/session/runtime-state.mjs +47 -0
  116. package/src/session/store.mjs +523 -519
  117. package/src/session/system-prompt.mjs +308 -273
  118. package/src/session/task-validator.mjs +267 -267
  119. package/src/session/usability-gates.mjs +2 -2
  120. package/src/skill/builtin/commit.mjs +64 -64
  121. package/src/skill/builtin/design.mjs +76 -76
  122. package/src/skill/generator.mjs +18 -2
  123. package/src/skill/registry.mjs +642 -390
  124. package/src/storage/audit-store.mjs +18 -11
  125. package/src/storage/event-log.mjs +7 -1
  126. package/src/storage/ghost-commit-store.mjs +243 -245
  127. package/src/storage/paths.mjs +17 -0
  128. package/src/theme/default-theme.mjs +1 -1
  129. package/src/theme/markdown.mjs +4 -0
  130. package/src/theme/schema.mjs +1 -1
  131. package/src/theme/status-bar.mjs +162 -158
  132. package/src/tool/audit-wrapper.mjs +18 -2
  133. package/src/tool/edit-transaction.mjs +23 -0
  134. package/src/tool/executor.mjs +26 -1
  135. package/src/tool/file-read-state.mjs +65 -0
  136. package/src/tool/git-auto.mjs +526 -526
  137. package/src/tool/git-full-auto.mjs +487 -478
  138. package/src/tool/mutation-guard.mjs +54 -0
  139. package/src/tool/prompt/edit.txt +3 -3
  140. package/src/tool/prompt/multiedit.txt +1 -0
  141. package/src/tool/prompt/notebookedit.txt +2 -1
  142. package/src/tool/prompt/patch.txt +25 -24
  143. package/src/tool/prompt/read.txt +3 -3
  144. package/src/tool/prompt/sysinfo.txt +29 -0
  145. package/src/tool/prompt/task.txt +66 -4
  146. package/src/tool/prompt/write.txt +2 -2
  147. package/src/tool/question-prompt.mjs +99 -93
  148. package/src/tool/registry.mjs +1701 -1343
  149. package/src/tool/task-tool.mjs +14 -6
  150. package/src/ui/activity-renderer.mjs +667 -664
  151. package/src/ui/repl-background-panel.mjs +7 -0
  152. package/src/ui/repl-capability-panel.mjs +9 -0
  153. package/src/ui/repl-dashboard.mjs +54 -4
  154. package/src/ui/repl-help.mjs +110 -0
  155. package/src/ui/repl-operator-panel.mjs +12 -0
  156. package/src/ui/repl-route-feedback.mjs +35 -0
  157. package/src/ui/repl-status-view.mjs +76 -0
  158. package/src/ui/repl-task-panel.mjs +5 -0
  159. package/src/ui/repl-transcript-panel.mjs +56 -0
  160. package/src/ui/repl-turn-summary.mjs +135 -0
  161. package/src/update/checker.mjs +184 -0
  162. package/src/usage/pricing.mjs +122 -121
  163. package/src/usage/usage-meter.mjs +1 -0
  164. package/src/util/git.mjs +562 -519
  165. package/src/util/template.mjs +6 -1
  166. package/src/version.mjs +3 -0
@@ -1,533 +1,534 @@
1
- import { spawn } from "node:child_process"
2
- import { McpError } from "../core/errors.mjs"
3
- import { EventBus } from "../core/events.mjs"
4
- import { EVENT_TYPES } from "../core/constants.mjs"
5
- import { createStdioFramingDecoder, encodeRpcMessage } from "./stdio-framing.mjs"
6
- import { normalizeToolResult } from "./tool-result.mjs"
7
- import { MCP_PROTOCOL_VERSION, MCP_CLIENT_INFO } from "./constants.mjs"
8
-
9
- const VALID_FRAMING = new Set(["auto", "content-length", "newline"])
10
- const VALID_HEALTH_METHOD = new Set(["auto", "ping", "tools_list"])
11
-
12
- function normalizeFraming(value) {
13
- const framing = String(value || "auto").toLowerCase()
14
- return VALID_FRAMING.has(framing) ? framing : "auto"
15
- }
16
-
17
- function normalizeHealthMethod(value) {
18
- const method = String(value || "auto").toLowerCase()
19
- return VALID_HEALTH_METHOD.has(method) ? method : "auto"
20
- }
21
-
22
- function classifySpawnError(error) {
23
- const code = String(error?.code || "").toUpperCase()
24
- const msg = String(error?.message || error || "")
25
- if (code === "ENOENT" || code === "EACCES") return "spawn_failed"
26
- if (msg.includes("ENOENT") || msg.includes("EACCES") || msg.includes("spawn")) return "spawn_failed"
27
- return "unknown"
28
- }
29
-
30
- export function createStdioMcpClient(serverName, config = {}) {
31
- const command = config.command
32
- const cmdArgs = Array.isArray(config.args) ? config.args : []
33
- const envOverrides = config.env || {}
34
- const startupTimeoutMs = Math.max(100, Number(config.startup_timeout_ms || 5000))
35
- const requestTimeoutMs = Math.max(100, Number(config.request_timeout_ms || config.timeout_ms || 30000))
36
- const healthCheckMethod = normalizeHealthMethod(config.health_check_method)
37
- const configuredFraming = normalizeFraming(config.framing)
38
- const isWindows = process.platform === "win32"
39
- const explicitShell = config.shell === true || (config.shell !== false && isWindows)
40
-
41
- let executable
42
- let spawnArgs
43
- if (Array.isArray(command)) {
44
- executable = command[0]
45
- spawnArgs = command.slice(1)
46
- } else {
47
- executable = command
48
- spawnArgs = cmdArgs
49
- }
50
-
51
- if (!executable) {
52
- throw new McpError(`mcp server "${serverName}" missing command`, {
53
- reason: "spawn_failed",
54
- server: serverName,
55
- phase: "startup"
56
- })
57
- }
58
-
59
- const maxReconnectAttempts = Number(config.max_reconnect_attempts ?? 5)
60
- const circuitResetMs = Number(config.circuit_reset_ms ?? 60000)
61
-
62
- let child = null
63
- let lifecycle = "closed"
64
- let nextId = 1
65
- let initialized = false
66
- let activeFraming = configuredFraming === "auto" ? "content-length" : configuredFraming
67
- let decoder = createStdioFramingDecoder({
68
- framing: configuredFraming === "auto" ? "auto" : activeFraming
69
- })
70
- let malformedSeen = false
71
- let malformedSnippet = ""
72
- let stderrLines = []
73
- let stderrTotalBytes = 0
74
- let ignoreClose = false
75
- let reconnectAttempts = 0
76
- let circuitState = "closed" // "closed" | "open" | "half_open"
77
- let circuitOpenedAt = 0
78
- let wasEverInitialized = false
79
-
80
- const pending = new Map()
81
-
82
- function resetRuntime() {
83
- decoder = createStdioFramingDecoder({
84
- framing: configuredFraming === "auto" ? "auto" : activeFraming
85
- })
86
- malformedSeen = false
87
- malformedSnippet = ""
88
- stderrLines = []
89
- stderrTotalBytes = 0
90
- }
91
-
92
- function appendStderr(chunk) {
93
- const text = String(chunk || "").trim()
94
- if (!text) return
95
- stderrTotalBytes += Buffer.byteLength(chunk)
96
- stderrLines.push(text)
97
- if (stderrLines.length > 32) stderrLines = stderrLines.slice(stderrLines.length - 32)
98
- }
99
-
100
- function rejectPending(reason, message, details = {}) {
101
- for (const [, entry] of pending) {
102
- clearTimeout(entry.timer)
103
- entry.reject(
104
- new McpError(message, {
105
- reason,
106
- server: serverName,
107
- action: entry.method,
108
- phase: entry.phase || details.phase || "request",
109
- stderrSnippet: stderrLines.join(" | ") || undefined,
110
- ...details
111
- })
112
- )
113
- }
114
- pending.clear()
115
- }
116
-
117
- function cleanupChild() {
118
- child = null
119
- initialized = false
120
- lifecycle = "closed"
121
- }
122
-
123
- async function startProcess() {
124
- if (child && lifecycle !== "closed") return
125
-
126
- resetRuntime()
127
- lifecycle = "starting"
128
- ignoreClose = false
129
-
130
- await new Promise((resolve, reject) => {
131
- let settled = false
132
- const proc = spawn(executable, spawnArgs, {
133
- stdio: ["pipe", "pipe", "pipe"],
134
- env: { ...process.env, ...envOverrides },
135
- windowsHide: true,
136
- shell: explicitShell
137
- })
138
- child = proc
139
-
140
- const timer = setTimeout(() => {
141
- if (settled) return
142
- settled = true
143
- try { proc.kill() } catch {}
144
- reject(
145
- new McpError(`mcp server "${serverName}" startup timeout after ${startupTimeoutMs}ms`, {
146
- reason: "timeout",
147
- server: serverName,
148
- phase: "startup"
149
- })
150
- )
151
- }, startupTimeoutMs)
152
-
153
- proc.once("spawn", () => {
154
- if (settled) return
155
- settled = true
156
- clearTimeout(timer)
157
- lifecycle = "running"
158
- resolve()
159
- })
160
-
161
- proc.once("error", (err) => {
162
- const reason = classifySpawnError(err)
163
- if (!settled) {
164
- settled = true
165
- clearTimeout(timer)
166
- reject(
167
- new McpError(`mcp server "${serverName}" process error: ${err.message}`, {
168
- reason,
169
- server: serverName,
170
- phase: "startup"
171
- })
172
- )
173
- } else {
174
- rejectPending(reason, `mcp server "${serverName}" process error: ${err.message}`, { phase: "request" })
175
- }
176
- })
177
-
178
- proc.stdout.on("data", (chunk) => {
179
- let payloads = []
180
- try {
181
- payloads = decoder.push(chunk)
182
- } catch (error) {
183
- malformedSeen = true
184
- malformedSnippet = String(error.message || "invalid framing").slice(0, 240)
185
- return
186
- }
187
-
188
- for (const payload of payloads) {
189
- let msg
190
- try {
191
- msg = JSON.parse(payload)
192
- } catch {
193
- malformedSeen = true
194
- malformedSnippet = String(payload || "").slice(0, 240)
195
- continue
196
- }
197
-
198
- if (msg?.id != null && pending.has(msg.id)) {
199
- const entry = pending.get(msg.id)
200
- pending.delete(msg.id)
201
- clearTimeout(entry.timer)
202
- if (msg.error) {
203
- entry.reject(
204
- new McpError(
205
- `mcp server "${serverName}" error: ${msg.error.message || JSON.stringify(msg.error)}`,
206
- {
207
- reason: "bad_response",
208
- server: serverName,
209
- action: entry.method,
210
- phase: entry.phase,
211
- code: msg.error.code,
212
- stderrSnippet: stderrLines.join(" | ") || undefined
213
- }
214
- )
215
- )
216
- } else {
217
- const elapsed = Date.now() - entry.startedAt
218
- EventBus.emit({
219
- type: EVENT_TYPES.MCP_REQUEST,
220
- payload: { server: serverName, action: entry.method, elapsed, transport: "stdio" }
221
- }).catch(() => {})
222
- entry.resolve(msg.result ?? {})
223
- }
224
- }
225
- }
226
- })
227
-
228
- proc.stderr.on("data", (chunk) => appendStderr(chunk))
229
-
230
- proc.on("close", (code, signal) => {
231
- if (ignoreClose) {
232
- cleanupChild()
233
- return
234
- }
235
- const reason = malformedSeen ? "bad_response" : "server_crash"
236
- const extra = malformedSeen && malformedSnippet
237
- ? `; malformed stdout: ${malformedSnippet}`
238
- : ""
239
- rejectPending(
240
- reason,
241
- `mcp server "${serverName}" process exited unexpectedly (code=${code ?? "null"}, signal=${signal || "null"})${extra}`,
242
- { phase: lifecycle === "starting" ? "startup" : "request" }
243
- )
244
- cleanupChild()
245
- })
246
- })
247
- }
248
-
249
- const shutdownTimeoutMs = Number(config.shutdown_timeout_ms || 5000)
250
-
251
- async function shutdownProcess() {
252
- if (!child) return
253
- const proc = child
254
- lifecycle = "stopping"
255
- ignoreClose = true
256
- try { proc.kill() } catch {}
257
- rejectPending("unknown", `mcp server "${serverName}" shutdown`, { phase: "shutdown" })
258
- await new Promise((resolve) => {
259
- const killTimer = setTimeout(() => {
260
- try { proc.kill("SIGKILL") } catch {}
261
- resolve()
262
- }, shutdownTimeoutMs)
263
- proc.once("close", () => {
264
- clearTimeout(killTimer)
265
- resolve()
266
- })
267
- })
268
- cleanupChild()
269
- }
270
-
271
- async function ensureAlive() {
272
- // Circuit breaker: open state rejects immediately
273
- if (circuitState === "open") {
274
- if (Date.now() - circuitOpenedAt >= circuitResetMs) {
275
- circuitState = "half_open"
276
- } else {
277
- throw new McpError(`mcp server "${serverName}" circuit breaker open`, {
278
- reason: "server_crash", server: serverName, phase: "request"
279
- })
280
- }
281
- }
282
-
283
- // Only attempt lazy reconnect if we were previously initialized
284
- if ((lifecycle === "closed" || lifecycle === "stopping") && wasEverInitialized) {
285
- const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000)
286
- if (reconnectAttempts > 0) {
287
- await new Promise((r) => setTimeout(r, delay))
288
- }
289
- try {
290
- initialized = false
291
- await startProcess()
292
- await initializeOnce()
293
- reconnectAttempts = 0
294
- if (circuitState === "half_open") {
295
- circuitState = "closed"
296
- EventBus.emit({ type: EVENT_TYPES.MCP_CIRCUIT_CLOSE, payload: { server: serverName } }).catch(() => {})
297
- }
298
- EventBus.emit({ type: EVENT_TYPES.MCP_RECONNECT, payload: { server: serverName, success: true } }).catch(() => {})
299
- } catch (error) {
300
- reconnectAttempts++
301
- if (circuitState === "half_open" || reconnectAttempts >= maxReconnectAttempts) {
302
- circuitState = "open"
303
- circuitOpenedAt = Date.now()
304
- EventBus.emit({ type: EVENT_TYPES.MCP_CIRCUIT_OPEN, payload: { server: serverName, attempts: reconnectAttempts } }).catch(() => {})
305
- }
306
- EventBus.emit({ type: EVENT_TYPES.MCP_RECONNECT, payload: { server: serverName, success: false, attempt: reconnectAttempts } }).catch(() => {})
307
- throw error
308
- }
309
- return
310
- }
311
-
312
- // Normal first-time startup
313
- await startProcess()
314
- }
315
-
316
- async function sendRequest(method, params = {}, { phase = "request", timeoutMs = requestTimeoutMs, signal = null } = {}) {
317
- if (signal?.aborted) {
318
- throw new McpError(`mcp server "${serverName}" request cancelled`, {
319
- reason: "timeout", server: serverName, action: method, phase
320
- })
321
- }
322
- await ensureAlive()
323
- if (nextId > Number.MAX_SAFE_INTEGER - 1) nextId = 1
324
- const id = nextId++
325
- const payload = { jsonrpc: "2.0", id, method, params }
326
-
327
- return new Promise((resolve, reject) => {
328
- const startedAt = Date.now()
329
- let settled = false
330
-
331
- function settle() {
332
- if (settled) return false
333
- settled = true
334
- if (signal) signal.removeEventListener("abort", onAbort)
335
- return true
336
- }
337
-
338
- function onAbort() {
339
- if (!settle()) return
340
- clearTimeout(timer)
341
- pending.delete(id)
342
- sendNotification("notifications/cancelled", { requestId: id, reason: "client_cancelled" })
343
- reject(new McpError(`mcp server "${serverName}" request cancelled`, {
344
- reason: "timeout", server: serverName, action: method, phase
345
- }))
346
- }
347
-
348
- const timer = setTimeout(() => {
349
- if (!settle()) return
350
- pending.delete(id)
351
- reject(
352
- new McpError(`mcp server "${serverName}" timed out after ${timeoutMs}ms on "${method}"`, {
353
- reason: "timeout",
354
- server: serverName,
355
- action: method,
356
- phase
357
- })
358
- )
359
- }, timeoutMs)
360
-
361
- if (signal) signal.addEventListener("abort", onAbort, { once: true })
362
-
363
- pending.set(id, {
364
- resolve: (v) => { if (settle()) { clearTimeout(timer); resolve(v) } },
365
- reject: (e) => { if (settle()) { clearTimeout(timer); reject(e) } },
366
- timer, method, phase, startedAt
367
- })
368
- try {
369
- const wireFraming = configuredFraming === "auto" ? activeFraming : configuredFraming
370
- child.stdin.write(encodeRpcMessage(payload, wireFraming))
371
- } catch (error) {
372
- clearTimeout(timer)
373
- pending.delete(id)
374
- reject(
375
- new McpError(`mcp server "${serverName}" stdin write failed: ${error.message}`, {
376
- reason: "server_crash",
377
- server: serverName,
378
- action: method,
379
- phase
380
- })
381
- )
382
- }
383
- })
384
- }
385
-
386
- function sendNotification(method, params = {}) {
387
- if (!child || lifecycle === "closed") return
388
- const payload = { jsonrpc: "2.0", method, params }
389
- try {
390
- const wireFraming = configuredFraming === "auto" ? activeFraming : configuredFraming
391
- child.stdin.write(encodeRpcMessage(payload, wireFraming))
392
- } catch {
393
- // best effort
394
- }
395
- }
396
-
397
- async function initializeOnce() {
398
- if (initialized) return
399
- const initParams = {
400
- protocolVersion: MCP_PROTOCOL_VERSION,
401
- capabilities: {},
402
- clientInfo: MCP_CLIENT_INFO
403
- }
404
-
405
- if (configuredFraming === "auto") {
406
- let lastError = null
407
- let needRestart = false
408
- for (const candidate of ["content-length", "newline"]) {
409
- activeFraming = candidate
410
- if (needRestart) {
411
- await shutdownProcess()
412
- }
413
- try {
414
- decoder.reset()
415
- await sendRequest("initialize", initParams, { phase: "initialize" })
416
- sendNotification("notifications/initialized")
417
- initialized = true
418
- wasEverInitialized = true
419
- return
420
- } catch (error) {
421
- lastError = error
422
- needRestart = true
423
- }
424
- }
425
- throw lastError || new McpError(`mcp server "${serverName}" failed to initialize`, {
426
- reason: "unknown",
427
- server: serverName,
428
- phase: "initialize"
429
- })
430
- }
431
-
432
- await sendRequest("initialize", initParams, { phase: "initialize" })
433
- sendNotification("notifications/initialized")
434
- initialized = true
435
- wasEverInitialized = true
436
- }
437
-
438
- async function healthPingOrTools() {
439
- if (healthCheckMethod === "ping") {
440
- await sendRequest("ping", {}, { phase: "request" })
441
- return
442
- }
443
- if (healthCheckMethod === "tools_list") {
444
- await sendRequest("tools/list", {}, { phase: "request" })
445
- return
446
- }
447
-
448
- try {
449
- await sendRequest("ping", {}, { phase: "request" })
450
- } catch (error) {
451
- if (!["bad_response", "protocol_error", "unknown"].includes(error.reason)) throw error
452
- await sendRequest("tools/list", {}, { phase: "request" })
453
- }
454
- }
455
-
456
- return {
457
- serverName,
458
- transport: "stdio",
459
-
460
- async health() {
461
- try {
462
- await initializeOnce()
463
- await healthPingOrTools()
464
- return {
465
- ok: true,
466
- reason: "ok",
467
- framing: configuredFraming === "auto" ? activeFraming : configuredFraming
468
- }
469
- } catch (error) {
470
- return {
471
- ok: false,
472
- error: error.message,
473
- reason: error.reason || "unknown",
474
- phase: error.details?.phase || "unknown",
475
- framing: configuredFraming === "auto" ? activeFraming : configuredFraming
476
- }
477
- }
478
- },
479
-
480
- async listTools() {
481
- await initializeOnce()
482
- const out = await sendRequest("tools/list")
483
- return Array.isArray(out?.tools) ? out.tools : []
484
- },
485
-
486
- async listPrompts() {
487
- await initializeOnce()
488
- try {
489
- const out = await sendRequest("prompts/list")
490
- return Array.isArray(out?.prompts) ? out.prompts : []
491
- } catch {
492
- return []
493
- }
494
- },
495
-
496
- async getPrompt(name, args = {}) {
497
- await initializeOnce()
498
- return sendRequest("prompts/get", { name, arguments: args })
499
- },
500
-
501
- async listResources() {
502
- await initializeOnce()
503
- try {
504
- const out = await sendRequest("resources/list")
505
- return Array.isArray(out?.resources) ? out.resources : []
506
- } catch {
507
- return []
508
- }
509
- },
510
-
511
- async listTemplates() {
512
- await initializeOnce()
513
- try {
514
- const out = await sendRequest("resources/templates/list")
515
- return Array.isArray(out?.templates) ? out.templates : []
516
- } catch {
517
- return []
518
- }
519
- },
520
-
521
- async callTool(name, args = {}, signal = null) {
522
- await initializeOnce()
523
- const result = await sendRequest("tools/call", { name, arguments: args }, { signal })
524
- return normalizeToolResult(result, serverName, name)
525
- },
526
-
527
- shutdown() {
528
- sendNotification("notifications/cancelled", { reason: "shutdown" })
529
- shutdownProcess().catch(() => {})
530
- pending.clear()
531
- }
532
- }
533
- }
1
+ import { spawn } from "node:child_process"
2
+ import { McpError } from "../core/errors.mjs"
3
+ import { EventBus } from "../core/events.mjs"
4
+ import { EVENT_TYPES } from "../core/constants.mjs"
5
+ import { createStdioFramingDecoder, encodeRpcMessage } from "./stdio-framing.mjs"
6
+ import { normalizeToolResult } from "./tool-result.mjs"
7
+ import { MCP_PROTOCOL_VERSION, MCP_CLIENT_INFO } from "./constants.mjs"
8
+
9
+ const VALID_FRAMING = new Set(["auto", "content-length", "newline"])
10
+ const VALID_HEALTH_METHOD = new Set(["auto", "ping", "tools_list"])
11
+
12
+ function normalizeFraming(value) {
13
+ const framing = String(value || "auto").toLowerCase()
14
+ return VALID_FRAMING.has(framing) ? framing : "auto"
15
+ }
16
+
17
+ function normalizeHealthMethod(value) {
18
+ const method = String(value || "auto").toLowerCase()
19
+ return VALID_HEALTH_METHOD.has(method) ? method : "auto"
20
+ }
21
+
22
+ function classifySpawnError(error) {
23
+ const code = String(error?.code || "").toUpperCase()
24
+ const msg = String(error?.message || error || "")
25
+ if (code === "ENOENT" || code === "EACCES") return "spawn_failed"
26
+ if (msg.includes("ENOENT") || msg.includes("EACCES") || msg.includes("spawn")) return "spawn_failed"
27
+ return "unknown"
28
+ }
29
+
30
+ export function createStdioMcpClient(serverName, config = {}) {
31
+ const command = config.command
32
+ const cmdArgs = Array.isArray(config.args) ? config.args : []
33
+ const envOverrides = config.env || {}
34
+ const startupTimeoutMs = Math.max(100, Number(config.startup_timeout_ms || 5000))
35
+ const requestTimeoutMs = Math.max(100, Number(config.request_timeout_ms || config.timeout_ms || 30000))
36
+ const healthCheckMethod = normalizeHealthMethod(config.health_check_method)
37
+ const configuredFraming = normalizeFraming(config.framing)
38
+ const isWindows = process.platform === "win32"
39
+ const explicitShell = config.shell === true || (config.shell !== false && isWindows)
40
+
41
+ let executable
42
+ let spawnArgs
43
+ if (Array.isArray(command)) {
44
+ executable = command[0]
45
+ spawnArgs = command.slice(1)
46
+ } else {
47
+ executable = command
48
+ spawnArgs = cmdArgs
49
+ }
50
+
51
+ if (!executable) {
52
+ throw new McpError(`mcp server "${serverName}" missing command`, {
53
+ reason: "spawn_failed",
54
+ server: serverName,
55
+ phase: "startup"
56
+ })
57
+ }
58
+
59
+ const maxReconnectAttempts = Number(config.max_reconnect_attempts ?? 5)
60
+ const circuitResetMs = Number(config.circuit_reset_ms ?? 60000)
61
+
62
+ let child = null
63
+ let lifecycle = "closed"
64
+ let nextId = 1
65
+ let initialized = false
66
+ let activeFraming = configuredFraming === "auto" ? "content-length" : configuredFraming
67
+ let decoder = createStdioFramingDecoder({
68
+ framing: configuredFraming === "auto" ? "auto" : activeFraming
69
+ })
70
+ let malformedSeen = false
71
+ let malformedSnippet = ""
72
+ let stderrLines = []
73
+ let stderrTotalBytes = 0
74
+ let ignoreClose = false
75
+ let reconnectAttempts = 0
76
+ let circuitState = "closed" // "closed" | "open" | "half_open"
77
+ let circuitOpenedAt = 0
78
+ let wasEverInitialized = false
79
+
80
+ const pending = new Map()
81
+
82
+ function resetRuntime() {
83
+ decoder = createStdioFramingDecoder({
84
+ framing: configuredFraming === "auto" ? "auto" : activeFraming
85
+ })
86
+ malformedSeen = false
87
+ malformedSnippet = ""
88
+ stderrLines = []
89
+ stderrTotalBytes = 0
90
+ }
91
+
92
+ function appendStderr(chunk) {
93
+ const text = String(chunk || "").trim()
94
+ if (!text) return
95
+ stderrTotalBytes += Buffer.byteLength(chunk)
96
+ stderrLines.push(text)
97
+ if (stderrLines.length > 32) stderrLines = stderrLines.slice(stderrLines.length - 32)
98
+ }
99
+
100
+ function rejectPending(reason, message, details = {}) {
101
+ for (const [, entry] of pending) {
102
+ clearTimeout(entry.timer)
103
+ entry.reject(
104
+ new McpError(message, {
105
+ reason,
106
+ server: serverName,
107
+ action: entry.method,
108
+ phase: entry.phase || details.phase || "request",
109
+ stderrSnippet: stderrLines.join(" | ") || undefined,
110
+ ...details
111
+ })
112
+ )
113
+ }
114
+ pending.clear()
115
+ }
116
+
117
+ function cleanupChild() {
118
+ child = null
119
+ initialized = false
120
+ lifecycle = "closed"
121
+ }
122
+
123
+ async function startProcess() {
124
+ if (child && lifecycle !== "closed") return
125
+
126
+ resetRuntime()
127
+ lifecycle = "starting"
128
+ ignoreClose = false
129
+
130
+ await new Promise((resolve, reject) => {
131
+ let settled = false
132
+ const proc = spawn(executable, spawnArgs, {
133
+ stdio: ["pipe", "pipe", "pipe"],
134
+ env: { ...process.env, ...envOverrides },
135
+ windowsHide: true,
136
+ shell: explicitShell
137
+ })
138
+ child = proc
139
+
140
+ const timer = setTimeout(() => {
141
+ if (settled) return
142
+ settled = true
143
+ try { proc.kill() } catch {}
144
+ reject(
145
+ new McpError(`mcp server "${serverName}" startup timeout after ${startupTimeoutMs}ms`, {
146
+ reason: "timeout",
147
+ server: serverName,
148
+ phase: "startup"
149
+ })
150
+ )
151
+ }, startupTimeoutMs)
152
+
153
+ proc.once("spawn", () => {
154
+ if (settled) return
155
+ settled = true
156
+ clearTimeout(timer)
157
+ lifecycle = "running"
158
+ resolve()
159
+ })
160
+
161
+ proc.once("error", (err) => {
162
+ const reason = classifySpawnError(err)
163
+ if (!settled) {
164
+ settled = true
165
+ clearTimeout(timer)
166
+ reject(
167
+ new McpError(`mcp server "${serverName}" process error: ${err.message}`, {
168
+ reason,
169
+ server: serverName,
170
+ phase: "startup"
171
+ })
172
+ )
173
+ } else {
174
+ rejectPending(reason, `mcp server "${serverName}" process error: ${err.message}`, { phase: "request" })
175
+ }
176
+ })
177
+
178
+ proc.stdout.on("data", (chunk) => {
179
+ let payloads = []
180
+ try {
181
+ payloads = decoder.push(chunk)
182
+ } catch (error) {
183
+ malformedSeen = true
184
+ malformedSnippet = String(error.message || "invalid framing").slice(0, 240)
185
+ return
186
+ }
187
+
188
+ for (const payload of payloads) {
189
+ let msg
190
+ try {
191
+ msg = JSON.parse(payload)
192
+ } catch {
193
+ malformedSeen = true
194
+ malformedSnippet = String(payload || "").slice(0, 240)
195
+ continue
196
+ }
197
+
198
+ if (msg?.id != null && pending.has(msg.id)) {
199
+ const entry = pending.get(msg.id)
200
+ pending.delete(msg.id)
201
+ clearTimeout(entry.timer)
202
+ if (msg.error) {
203
+ entry.reject(
204
+ new McpError(
205
+ `mcp server "${serverName}" error: ${msg.error.message || JSON.stringify(msg.error)}`,
206
+ {
207
+ reason: "bad_response",
208
+ server: serverName,
209
+ action: entry.method,
210
+ phase: entry.phase,
211
+ code: msg.error.code,
212
+ stderrSnippet: stderrLines.join(" | ") || undefined
213
+ }
214
+ )
215
+ )
216
+ } else {
217
+ const elapsed = Date.now() - entry.startedAt
218
+ EventBus.emit({
219
+ type: EVENT_TYPES.MCP_REQUEST,
220
+ payload: { server: serverName, action: entry.method, elapsed, transport: "stdio" }
221
+ }).catch(() => {})
222
+ entry.resolve(msg.result ?? {})
223
+ }
224
+ }
225
+ }
226
+ })
227
+
228
+ proc.stderr.on("data", (chunk) => appendStderr(chunk))
229
+
230
+ proc.on("close", (code, signal) => {
231
+ if (ignoreClose) {
232
+ cleanupChild()
233
+ return
234
+ }
235
+ const reason = malformedSeen ? "bad_response" : "server_crash"
236
+ const extra = malformedSeen && malformedSnippet
237
+ ? `; malformed stdout: ${malformedSnippet}`
238
+ : ""
239
+ rejectPending(
240
+ reason,
241
+ `mcp server "${serverName}" process exited unexpectedly (code=${code ?? "null"}, signal=${signal || "null"})${extra}`,
242
+ { phase: lifecycle === "starting" ? "startup" : "request" }
243
+ )
244
+ cleanupChild()
245
+ })
246
+ })
247
+ }
248
+
249
+ const shutdownTimeoutMs = Number(config.shutdown_timeout_ms || 5000)
250
+
251
+ async function shutdownProcess() {
252
+ if (!child) return
253
+ const proc = child
254
+ lifecycle = "stopping"
255
+ ignoreClose = true
256
+ try { proc.kill() } catch {}
257
+ rejectPending("unknown", `mcp server "${serverName}" shutdown`, { phase: "shutdown" })
258
+ await new Promise((resolve) => {
259
+ const killTimer = setTimeout(() => {
260
+ try { proc.kill("SIGKILL") } catch {}
261
+ resolve()
262
+ }, shutdownTimeoutMs)
263
+ proc.once("close", () => {
264
+ clearTimeout(killTimer)
265
+ resolve()
266
+ })
267
+ })
268
+ cleanupChild()
269
+ }
270
+
271
+ async function ensureAlive() {
272
+ // Circuit breaker: open state rejects immediately
273
+ if (circuitState === "open") {
274
+ if (Date.now() - circuitOpenedAt >= circuitResetMs) {
275
+ circuitState = "half_open"
276
+ } else {
277
+ throw new McpError(`mcp server "${serverName}" circuit breaker open`, {
278
+ reason: "server_crash", server: serverName, phase: "request"
279
+ })
280
+ }
281
+ }
282
+
283
+ // Only attempt lazy reconnect if we were previously initialized
284
+ if ((lifecycle === "closed" || lifecycle === "stopping") && wasEverInitialized) {
285
+ const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000)
286
+ if (reconnectAttempts > 0) {
287
+ await new Promise((r) => setTimeout(r, delay))
288
+ }
289
+ try {
290
+ initialized = false
291
+ await startProcess()
292
+ await initializeOnce()
293
+ reconnectAttempts = 0
294
+ if (circuitState === "half_open") {
295
+ circuitState = "closed"
296
+ EventBus.emit({ type: EVENT_TYPES.MCP_CIRCUIT_CLOSE, payload: { server: serverName } }).catch(() => {})
297
+ }
298
+ EventBus.emit({ type: EVENT_TYPES.MCP_RECONNECT, payload: { server: serverName, success: true } }).catch(() => {})
299
+ } catch (error) {
300
+ reconnectAttempts++
301
+ if (circuitState === "half_open" || reconnectAttempts >= maxReconnectAttempts) {
302
+ circuitState = "open"
303
+ circuitOpenedAt = Date.now()
304
+ EventBus.emit({ type: EVENT_TYPES.MCP_CIRCUIT_OPEN, payload: { server: serverName, attempts: reconnectAttempts } }).catch(() => {})
305
+ }
306
+ EventBus.emit({ type: EVENT_TYPES.MCP_RECONNECT, payload: { server: serverName, success: false, attempt: reconnectAttempts } }).catch(() => {})
307
+ throw error
308
+ }
309
+ return
310
+ }
311
+
312
+ // Normal first-time startup
313
+ await startProcess()
314
+ }
315
+
316
+ async function sendRequest(method, params = {}, { phase = "request", timeoutMs = requestTimeoutMs, signal = null } = {}) {
317
+ if (signal?.aborted) {
318
+ throw new McpError(`mcp server "${serverName}" request cancelled`, {
319
+ reason: "timeout", server: serverName, action: method, phase
320
+ })
321
+ }
322
+ await ensureAlive()
323
+ if (nextId > Number.MAX_SAFE_INTEGER - 1) nextId = 1
324
+ const id = nextId++
325
+ const payload = { jsonrpc: "2.0", id, method, params }
326
+
327
+ return new Promise((resolve, reject) => {
328
+ const startedAt = Date.now()
329
+ let settled = false
330
+
331
+ function settle() {
332
+ if (settled) return false
333
+ settled = true
334
+ if (signal) signal.removeEventListener("abort", onAbort)
335
+ return true
336
+ }
337
+
338
+ function onAbort() {
339
+ if (!settle()) return
340
+ clearTimeout(timer)
341
+ pending.delete(id)
342
+ sendNotification("notifications/cancelled", { requestId: id, reason: "client_cancelled" })
343
+ reject(new McpError(`mcp server "${serverName}" request cancelled`, {
344
+ reason: "timeout", server: serverName, action: method, phase
345
+ }))
346
+ }
347
+
348
+ const timer = setTimeout(() => {
349
+ if (!settle()) return
350
+ pending.delete(id)
351
+ reject(
352
+ new McpError(`mcp server "${serverName}" timed out after ${timeoutMs}ms on "${method}"`, {
353
+ reason: "timeout",
354
+ server: serverName,
355
+ action: method,
356
+ phase
357
+ })
358
+ )
359
+ }, timeoutMs)
360
+
361
+ if (signal) signal.addEventListener("abort", onAbort, { once: true })
362
+
363
+ pending.set(id, {
364
+ resolve: (v) => { if (settle()) { clearTimeout(timer); resolve(v) } },
365
+ reject: (e) => { if (settle()) { clearTimeout(timer); reject(e) } },
366
+ timer, method, phase, startedAt
367
+ })
368
+ try {
369
+ const wireFraming = configuredFraming === "auto" ? activeFraming : configuredFraming
370
+ child.stdin.write(encodeRpcMessage(payload, wireFraming))
371
+ } catch (error) {
372
+ if (!settle()) return
373
+ clearTimeout(timer)
374
+ pending.delete(id)
375
+ reject(
376
+ new McpError(`mcp server "${serverName}" stdin write failed: ${error.message}`, {
377
+ reason: "server_crash",
378
+ server: serverName,
379
+ action: method,
380
+ phase
381
+ })
382
+ )
383
+ }
384
+ })
385
+ }
386
+
387
+ function sendNotification(method, params = {}) {
388
+ if (!child || lifecycle === "closed") return
389
+ const payload = { jsonrpc: "2.0", method, params }
390
+ try {
391
+ const wireFraming = configuredFraming === "auto" ? activeFraming : configuredFraming
392
+ child.stdin.write(encodeRpcMessage(payload, wireFraming))
393
+ } catch {
394
+ // best effort
395
+ }
396
+ }
397
+
398
+ async function initializeOnce() {
399
+ if (initialized) return
400
+ const initParams = {
401
+ protocolVersion: MCP_PROTOCOL_VERSION,
402
+ capabilities: {},
403
+ clientInfo: MCP_CLIENT_INFO
404
+ }
405
+
406
+ if (configuredFraming === "auto") {
407
+ let lastError = null
408
+ let needRestart = false
409
+ for (const candidate of ["content-length", "newline"]) {
410
+ activeFraming = candidate
411
+ if (needRestart) {
412
+ await shutdownProcess()
413
+ }
414
+ try {
415
+ decoder.reset()
416
+ await sendRequest("initialize", initParams, { phase: "initialize" })
417
+ sendNotification("notifications/initialized")
418
+ initialized = true
419
+ wasEverInitialized = true
420
+ return
421
+ } catch (error) {
422
+ lastError = error
423
+ needRestart = true
424
+ }
425
+ }
426
+ throw lastError || new McpError(`mcp server "${serverName}" failed to initialize`, {
427
+ reason: "unknown",
428
+ server: serverName,
429
+ phase: "initialize"
430
+ })
431
+ }
432
+
433
+ await sendRequest("initialize", initParams, { phase: "initialize" })
434
+ sendNotification("notifications/initialized")
435
+ initialized = true
436
+ wasEverInitialized = true
437
+ }
438
+
439
+ async function healthPingOrTools() {
440
+ if (healthCheckMethod === "ping") {
441
+ await sendRequest("ping", {}, { phase: "request" })
442
+ return
443
+ }
444
+ if (healthCheckMethod === "tools_list") {
445
+ await sendRequest("tools/list", {}, { phase: "request" })
446
+ return
447
+ }
448
+
449
+ try {
450
+ await sendRequest("ping", {}, { phase: "request" })
451
+ } catch (error) {
452
+ if (!["bad_response", "protocol_error", "unknown"].includes(error.reason)) throw error
453
+ await sendRequest("tools/list", {}, { phase: "request" })
454
+ }
455
+ }
456
+
457
+ return {
458
+ serverName,
459
+ transport: "stdio",
460
+
461
+ async health() {
462
+ try {
463
+ await initializeOnce()
464
+ await healthPingOrTools()
465
+ return {
466
+ ok: true,
467
+ reason: "ok",
468
+ framing: configuredFraming === "auto" ? activeFraming : configuredFraming
469
+ }
470
+ } catch (error) {
471
+ return {
472
+ ok: false,
473
+ error: error.message,
474
+ reason: error.reason || "unknown",
475
+ phase: error.details?.phase || "unknown",
476
+ framing: configuredFraming === "auto" ? activeFraming : configuredFraming
477
+ }
478
+ }
479
+ },
480
+
481
+ async listTools() {
482
+ await initializeOnce()
483
+ const out = await sendRequest("tools/list")
484
+ return Array.isArray(out?.tools) ? out.tools : []
485
+ },
486
+
487
+ async listPrompts() {
488
+ await initializeOnce()
489
+ try {
490
+ const out = await sendRequest("prompts/list")
491
+ return Array.isArray(out?.prompts) ? out.prompts : []
492
+ } catch {
493
+ return []
494
+ }
495
+ },
496
+
497
+ async getPrompt(name, args = {}) {
498
+ await initializeOnce()
499
+ return sendRequest("prompts/get", { name, arguments: args })
500
+ },
501
+
502
+ async listResources() {
503
+ await initializeOnce()
504
+ try {
505
+ const out = await sendRequest("resources/list")
506
+ return Array.isArray(out?.resources) ? out.resources : []
507
+ } catch {
508
+ return []
509
+ }
510
+ },
511
+
512
+ async listTemplates() {
513
+ await initializeOnce()
514
+ try {
515
+ const out = await sendRequest("resources/templates/list")
516
+ return Array.isArray(out?.templates) ? out.templates : []
517
+ } catch {
518
+ return []
519
+ }
520
+ },
521
+
522
+ async callTool(name, args = {}, signal = null) {
523
+ await initializeOnce()
524
+ const result = await sendRequest("tools/call", { name, arguments: args }, { signal })
525
+ return normalizeToolResult(result, serverName, name)
526
+ },
527
+
528
+ shutdown() {
529
+ sendNotification("notifications/cancelled", { reason: "shutdown" })
530
+ rejectPending("shutdown", `mcp server "${serverName}" shutdown`, { phase: "shutdown" })
531
+ shutdownProcess().catch(() => {})
532
+ }
533
+ }
534
+ }