@kkelly-offical/kkcode 0.1.7 → 0.2.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 (163) hide show
  1. package/LICENSE +674 -674
  2. package/README.md +452 -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/config/defaults.mjs +280 -260
  29. package/src/config/import-config.mjs +1 -1
  30. package/src/config/load-config.mjs +61 -4
  31. package/src/config/schema.mjs +591 -574
  32. package/src/context.mjs +4 -1
  33. package/src/core/constants.mjs +97 -91
  34. package/src/core/types.mjs +1 -1
  35. package/src/github/api.mjs +78 -78
  36. package/src/github/auth.mjs +294 -286
  37. package/src/github/flow.mjs +298 -298
  38. package/src/github/workspace.mjs +225 -212
  39. package/src/index.mjs +84 -82
  40. package/src/knowledge/frontend-aesthetics.txt +38 -38
  41. package/src/mcp/client-http.mjs +139 -141
  42. package/src/mcp/client-sse.mjs +297 -288
  43. package/src/mcp/client-stdio.mjs +534 -533
  44. package/src/mcp/constants.mjs +2 -2
  45. package/src/mcp/registry.mjs +498 -479
  46. package/src/mcp/stdio-framing.mjs +135 -133
  47. package/src/mcp/tool-result.mjs +24 -24
  48. package/src/observability/edit-diagnostics.mjs +449 -0
  49. package/src/observability/index.mjs +42 -42
  50. package/src/observability/metrics.mjs +165 -137
  51. package/src/observability/tracer.mjs +137 -137
  52. package/src/onboarding.mjs +209 -0
  53. package/src/orchestration/background-manager.mjs +567 -372
  54. package/src/orchestration/background-worker.mjs +419 -305
  55. package/src/orchestration/interruption-reason.mjs +21 -0
  56. package/src/orchestration/longagent-manager.mjs +197 -171
  57. package/src/orchestration/stage-scheduler.mjs +733 -728
  58. package/src/orchestration/subagent-router.mjs +7 -1
  59. package/src/orchestration/task-scheduler.mjs +219 -7
  60. package/src/permission/engine.mjs +1 -1
  61. package/src/permission/exec-policy.mjs +370 -370
  62. package/src/permission/file-edit-policy.mjs +108 -0
  63. package/src/permission/prompt.mjs +1 -1
  64. package/src/permission/rules.mjs +116 -7
  65. package/src/plugin/builtin-hooks/post-edit-format.mjs +2 -1
  66. package/src/plugin/builtin-hooks/post-edit-typecheck.mjs +104 -40
  67. package/src/plugin/hook-bus.mjs +19 -5
  68. package/src/plugin/manifest-loader.mjs +222 -0
  69. package/src/provider/anthropic.mjs +396 -390
  70. package/src/provider/ollama.mjs +7 -1
  71. package/src/provider/openai.mjs +382 -340
  72. package/src/provider/retry-policy.mjs +74 -68
  73. package/src/provider/router.mjs +242 -241
  74. package/src/provider/sse.mjs +104 -104
  75. package/src/provider/wizard.mjs +556 -0
  76. package/src/repl/capability-facade.mjs +30 -0
  77. package/src/repl/command-surface.mjs +23 -0
  78. package/src/repl/controller-entry.mjs +40 -0
  79. package/src/repl/core-shell.mjs +208 -0
  80. package/src/repl/dialog-router.mjs +87 -0
  81. package/src/repl/input-engine.mjs +76 -0
  82. package/src/repl/keymap.mjs +7 -0
  83. package/src/repl/operator-surface.mjs +15 -0
  84. package/src/repl/permission-flow.mjs +49 -0
  85. package/src/repl/runtime-facade.mjs +36 -0
  86. package/src/repl/slash-router.mjs +62 -0
  87. package/src/repl/state-store.mjs +29 -0
  88. package/src/repl/turn-controller.mjs +58 -0
  89. package/src/repl/verification.mjs +23 -0
  90. package/src/repl.mjs +3368 -2981
  91. package/src/rules/load-rules.mjs +3 -3
  92. package/src/runtime.mjs +1 -1
  93. package/src/session/agent-transaction.mjs +86 -0
  94. package/src/session/checkpoint.mjs +302 -302
  95. package/src/session/compaction.mjs +298 -298
  96. package/src/session/engine.mjs +417 -232
  97. package/src/session/longagent-4stage.mjs +467 -460
  98. package/src/session/longagent-hybrid.mjs +1344 -1097
  99. package/src/session/longagent-plan.mjs +376 -365
  100. package/src/session/longagent-project-memory.mjs +53 -53
  101. package/src/session/longagent-scaffold.mjs +291 -291
  102. package/src/session/longagent-task-bus.mjs +138 -54
  103. package/src/session/longagent-utils.mjs +828 -472
  104. package/src/session/longagent.mjs +911 -900
  105. package/src/session/loop.mjs +1005 -930
  106. package/src/session/prompt/agent.txt +25 -25
  107. package/src/session/prompt/anthropic.txt +150 -150
  108. package/src/session/prompt/beast.txt +1 -1
  109. package/src/session/prompt/plan.txt +31 -31
  110. package/src/session/prompt/qwen.txt +46 -46
  111. package/src/session/recovery.mjs +21 -0
  112. package/src/session/rollback.mjs +196 -195
  113. package/src/session/routing-observability.mjs +72 -0
  114. package/src/session/runtime-state.mjs +47 -0
  115. package/src/session/store.mjs +523 -519
  116. package/src/session/system-prompt.mjs +308 -273
  117. package/src/session/task-validator.mjs +267 -267
  118. package/src/session/usability-gates.mjs +2 -2
  119. package/src/skill/builtin/commit.mjs +64 -64
  120. package/src/skill/builtin/design.mjs +76 -76
  121. package/src/skill/generator.mjs +18 -2
  122. package/src/skill/registry.mjs +642 -390
  123. package/src/storage/audit-store.mjs +18 -11
  124. package/src/storage/event-log.mjs +7 -1
  125. package/src/storage/ghost-commit-store.mjs +243 -245
  126. package/src/storage/paths.mjs +13 -0
  127. package/src/theme/default-theme.mjs +1 -1
  128. package/src/theme/markdown.mjs +4 -0
  129. package/src/theme/schema.mjs +1 -1
  130. package/src/theme/status-bar.mjs +162 -158
  131. package/src/tool/audit-wrapper.mjs +18 -2
  132. package/src/tool/edit-transaction.mjs +23 -0
  133. package/src/tool/executor.mjs +26 -1
  134. package/src/tool/file-read-state.mjs +65 -0
  135. package/src/tool/git-auto.mjs +526 -526
  136. package/src/tool/git-full-auto.mjs +487 -478
  137. package/src/tool/mutation-guard.mjs +54 -0
  138. package/src/tool/prompt/edit.txt +3 -3
  139. package/src/tool/prompt/multiedit.txt +1 -0
  140. package/src/tool/prompt/notebookedit.txt +2 -1
  141. package/src/tool/prompt/patch.txt +25 -24
  142. package/src/tool/prompt/read.txt +3 -3
  143. package/src/tool/prompt/sysinfo.txt +29 -0
  144. package/src/tool/prompt/task.txt +66 -4
  145. package/src/tool/prompt/write.txt +2 -2
  146. package/src/tool/question-prompt.mjs +99 -93
  147. package/src/tool/registry.mjs +1701 -1343
  148. package/src/tool/task-tool.mjs +14 -6
  149. package/src/ui/activity-renderer.mjs +667 -664
  150. package/src/ui/repl-background-panel.mjs +7 -0
  151. package/src/ui/repl-capability-panel.mjs +9 -0
  152. package/src/ui/repl-dashboard.mjs +54 -4
  153. package/src/ui/repl-help.mjs +110 -0
  154. package/src/ui/repl-operator-panel.mjs +12 -0
  155. package/src/ui/repl-route-feedback.mjs +35 -0
  156. package/src/ui/repl-status-view.mjs +76 -0
  157. package/src/ui/repl-task-panel.mjs +5 -0
  158. package/src/ui/repl-transcript-panel.mjs +56 -0
  159. package/src/ui/repl-turn-summary.mjs +135 -0
  160. package/src/usage/pricing.mjs +122 -121
  161. package/src/usage/usage-meter.mjs +1 -0
  162. package/src/util/git.mjs +562 -519
  163. package/src/util/template.mjs +6 -1
@@ -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
+ }