@iinm/plain-agent 1.8.4 → 1.8.5

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 (84) hide show
  1. package/bin/plain +1 -1
  2. package/package.json +7 -5
  3. package/sandbox/bin/plain-sandbox +13 -0
  4. package/src/agent.d.ts +52 -0
  5. package/src/agent.mjs +204 -0
  6. package/src/agentLoop.mjs +419 -0
  7. package/src/agentState.mjs +41 -0
  8. package/src/claudeCodePlugin.mjs +164 -0
  9. package/src/cliArgs.mjs +175 -0
  10. package/src/cliBatch.mjs +147 -0
  11. package/src/cliCommands.mjs +283 -0
  12. package/src/cliCompleter.mjs +227 -0
  13. package/src/cliCost.mjs +309 -0
  14. package/src/cliFormatter.mjs +413 -0
  15. package/src/cliInteractive.mjs +529 -0
  16. package/src/cliInterruptTransform.mjs +51 -0
  17. package/src/cliMuteTransform.mjs +26 -0
  18. package/src/cliPasteTransform.mjs +183 -0
  19. package/src/config.d.ts +36 -0
  20. package/src/config.mjs +197 -0
  21. package/src/context/loadAgentRoles.mjs +294 -0
  22. package/src/context/loadPrompts.mjs +337 -0
  23. package/src/context/loadUserMessageContext.mjs +147 -0
  24. package/src/costTracker.mjs +210 -0
  25. package/src/env.mjs +44 -0
  26. package/src/main.mjs +281 -0
  27. package/src/mcpClient.mjs +351 -0
  28. package/src/mcpIntegration.mjs +160 -0
  29. package/src/model.d.ts +109 -0
  30. package/src/modelCaller.mjs +32 -0
  31. package/src/modelDefinition.d.ts +92 -0
  32. package/src/prompt.mjs +138 -0
  33. package/src/providers/anthropic.d.ts +248 -0
  34. package/src/providers/anthropic.mjs +587 -0
  35. package/src/providers/bedrock.d.ts +249 -0
  36. package/src/providers/bedrock.mjs +700 -0
  37. package/src/providers/gemini.d.ts +208 -0
  38. package/src/providers/gemini.mjs +754 -0
  39. package/src/providers/openai.d.ts +281 -0
  40. package/src/providers/openai.mjs +544 -0
  41. package/src/providers/openaiCompatible.d.ts +147 -0
  42. package/src/providers/openaiCompatible.mjs +652 -0
  43. package/src/providers/platform/awsSigV4.mjs +184 -0
  44. package/src/providers/platform/azure.mjs +42 -0
  45. package/src/providers/platform/bedrock.mjs +78 -0
  46. package/src/providers/platform/googleCloud.mjs +34 -0
  47. package/src/subagent.mjs +265 -0
  48. package/src/tmpfile.mjs +27 -0
  49. package/src/tool.d.ts +74 -0
  50. package/src/toolExecutor.mjs +236 -0
  51. package/src/toolInputValidator.mjs +183 -0
  52. package/src/toolUseApprover.mjs +99 -0
  53. package/src/tools/askURL.mjs +209 -0
  54. package/src/tools/askWeb.mjs +208 -0
  55. package/src/tools/compactContext.d.ts +4 -0
  56. package/src/tools/compactContext.mjs +87 -0
  57. package/src/tools/execCommand.d.ts +22 -0
  58. package/src/tools/execCommand.mjs +200 -0
  59. package/src/tools/patchFile.d.ts +4 -0
  60. package/src/tools/patchFile.mjs +133 -0
  61. package/src/tools/switchToMainAgent.d.ts +3 -0
  62. package/src/tools/switchToMainAgent.mjs +43 -0
  63. package/src/tools/switchToSubagent.d.ts +4 -0
  64. package/src/tools/switchToSubagent.mjs +59 -0
  65. package/src/tools/tmuxCommand.d.ts +14 -0
  66. package/src/tools/tmuxCommand.mjs +194 -0
  67. package/src/tools/writeFile.d.ts +4 -0
  68. package/src/tools/writeFile.mjs +56 -0
  69. package/src/usageStore.mjs +167 -0
  70. package/src/utils/evalJSONConfig.mjs +72 -0
  71. package/src/utils/matchValue.d.ts +6 -0
  72. package/src/utils/matchValue.mjs +40 -0
  73. package/src/utils/noThrow.mjs +31 -0
  74. package/src/utils/notify.mjs +29 -0
  75. package/src/utils/parseFileRange.mjs +18 -0
  76. package/src/utils/readFileRange.mjs +33 -0
  77. package/src/utils/retryOnError.mjs +41 -0
  78. package/src/voiceInput.mjs +61 -0
  79. package/src/voiceInputGemini.mjs +105 -0
  80. package/src/voiceInputOpenAI.mjs +104 -0
  81. package/src/voiceInputSession.mjs +543 -0
  82. package/src/voiceToggleKey.mjs +62 -0
  83. package/dist/main.mjs +0 -473
  84. package/dist/main.mjs.map +0 -7
package/src/env.mjs ADDED
@@ -0,0 +1,44 @@
1
+ import os from "node:os";
2
+ import path from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+
5
+ const filename = fileURLToPath(import.meta.url);
6
+ export const AGENT_ROOT = path.dirname(path.dirname(filename));
7
+
8
+ export const AGENT_USER_CONFIG_DIR = path.join(
9
+ os.homedir(),
10
+ ".config",
11
+ "plain-agent",
12
+ );
13
+ export const AGENT_CACHE_DIR = path.join(os.homedir(), ".cache", "plain-agent");
14
+
15
+ export const AGENT_DATA_DIR = path.join(
16
+ os.homedir(),
17
+ ".local",
18
+ "share",
19
+ "plain-agent",
20
+ );
21
+
22
+ export const USAGE_LOG_PATH = path.join(AGENT_DATA_DIR, "usage.jsonl");
23
+
24
+ export const TRUSTED_CONFIG_HASHES_DIR = path.join(
25
+ AGENT_CACHE_DIR,
26
+ "trusted-config-hashes",
27
+ );
28
+
29
+ export const AGENT_PROJECT_METADATA_DIR = ".plain-agent";
30
+
31
+ export const AGENT_MEMORY_DIR = path.join(AGENT_PROJECT_METADATA_DIR, "memory");
32
+ export const AGENT_TMP_DIR = path.join(AGENT_PROJECT_METADATA_DIR, "tmp");
33
+
34
+ export const CLAUDE_CODE_PLUGIN_DIR = path.join(
35
+ AGENT_PROJECT_METADATA_DIR,
36
+ "claude-code-plugins",
37
+ );
38
+
39
+ export const MESSAGES_DUMP_FILE_PATH = path.join(
40
+ AGENT_PROJECT_METADATA_DIR,
41
+ "messages.json",
42
+ );
43
+
44
+ export const USER_NAME = process.env.USER || "unknown";
package/src/main.mjs ADDED
@@ -0,0 +1,281 @@
1
+ /**
2
+ * @import { Tool } from "./tool";
3
+ */
4
+
5
+ import { styleText } from "node:util";
6
+ import { createAgent } from "./agent.mjs";
7
+ import {
8
+ installClaudeCodePlugins,
9
+ resolvePluginPaths,
10
+ } from "./claudeCodePlugin.mjs";
11
+ import { parseCliArgs, printHelp } from "./cliArgs.mjs";
12
+ import { startBatchSession } from "./cliBatch.mjs";
13
+ import { runCostCommand } from "./cliCost.mjs";
14
+ import { startInteractiveSession } from "./cliInteractive.mjs";
15
+ import { loadAppConfig } from "./config.mjs";
16
+ import { loadAgentRoles } from "./context/loadAgentRoles.mjs";
17
+ import { loadPrompts } from "./context/loadPrompts.mjs";
18
+ import { AGENT_PROJECT_METADATA_DIR, USER_NAME } from "./env.mjs";
19
+ import { setupMCPServer } from "./mcpIntegration.mjs";
20
+ import { createModelCaller } from "./modelCaller.mjs";
21
+ import { createPrompt } from "./prompt.mjs";
22
+ import { createAskURLTool } from "./tools/askURL.mjs";
23
+ import { createAskWebTool } from "./tools/askWeb.mjs";
24
+ import { createCompactContextTool } from "./tools/compactContext.mjs";
25
+ import { createExecCommandTool } from "./tools/execCommand.mjs";
26
+ import { createPatchFileTool } from "./tools/patchFile.mjs";
27
+ import { createSwitchToMainAgentTool } from "./tools/switchToMainAgent.mjs";
28
+ import { createSwitchToSubagentTool } from "./tools/switchToSubagent.mjs";
29
+ import { createTmuxCommandTool } from "./tools/tmuxCommand.mjs";
30
+ import { writeFileTool } from "./tools/writeFile.mjs";
31
+ import { createToolUseApprover } from "./toolUseApprover.mjs";
32
+
33
+ const cliArgs = parseCliArgs(process.argv);
34
+ if (cliArgs.subcommand.type === "help") {
35
+ printHelp();
36
+ }
37
+
38
+ if (cliArgs.subcommand.type === "list-models") {
39
+ const { appConfig } = await loadAppConfig({ skipTrustCheck: true });
40
+ if (!appConfig.models || appConfig.models.length === 0) {
41
+ console.error("No models found in configuration.");
42
+ process.exit(1);
43
+ }
44
+ for (const model of appConfig.models) {
45
+ const platform = model.platform;
46
+ console.log(
47
+ `${model.name}+${model.variant} (platform: ${platform.name}+${platform.variant})`,
48
+ );
49
+ }
50
+ process.exit(0);
51
+ }
52
+
53
+ if (cliArgs.subcommand.type === "install-claude-code-plugins") {
54
+ await installClaudeCodePlugins();
55
+ process.exit(0);
56
+ }
57
+
58
+ if (cliArgs.subcommand.type === "cost") {
59
+ try {
60
+ const exitCode = await runCostCommand({
61
+ from: cliArgs.subcommand.from,
62
+ to: cliArgs.subcommand.to,
63
+ });
64
+ process.exit(exitCode);
65
+ } catch (err) {
66
+ const message = err instanceof Error ? err.message : String(err);
67
+ console.error(message);
68
+ process.exit(1);
69
+ }
70
+ }
71
+
72
+ (async () => {
73
+ const startTime = new Date();
74
+ const sessionId = [
75
+ `${startTime.getFullYear()}-${`0${startTime.getMonth() + 1}`.slice(-2)}-${`0${startTime.getDate()}`.slice(-2)}`,
76
+ `0${startTime.getHours()}`.slice(-2) +
77
+ `0${startTime.getMinutes()}`.slice(-2),
78
+ ].join("-");
79
+ const tmuxSessionId = `agent-${sessionId}`;
80
+
81
+ const isBatchMode = cliArgs.subcommand.type === "batch";
82
+ const configFiles =
83
+ cliArgs.subcommand.type === "batch" ||
84
+ cliArgs.subcommand.type === "interactive"
85
+ ? cliArgs.subcommand.config
86
+ : [];
87
+
88
+ const { appConfig, loadedConfigPath } = await loadAppConfig({
89
+ skipUserConfig: isBatchMode,
90
+ skipTrustCheck: isBatchMode,
91
+ configFiles,
92
+ });
93
+
94
+ // In batch mode, skip human-readable output
95
+ if (!isBatchMode) {
96
+ if (loadedConfigPath.length > 0) {
97
+ console.log(styleText("green", "\n⚡ Loaded configuration files"));
98
+ console.log(loadedConfigPath.map((p) => ` ⤷ ${p}`).join("\n"));
99
+ }
100
+
101
+ if (appConfig.sandbox) {
102
+ const sandboxStr = [
103
+ appConfig.sandbox.command,
104
+ ...(appConfig.sandbox.args || []),
105
+ ].join(" ");
106
+ console.log(styleText("green", "\n📦 Sandbox: on"));
107
+ console.log(` ⤷ ${sandboxStr}`);
108
+ } else {
109
+ console.log(styleText("yellow", "\n📦 Sandbox: off"));
110
+ }
111
+ }
112
+
113
+ /** @type {(() => Promise<void>)[]} */
114
+ const mcpCleanups = [];
115
+
116
+ /** @type {Tool[]} */
117
+ const mcpTools = [];
118
+ if (appConfig.mcpServers) {
119
+ const mcpServerEntries = Object.entries(appConfig.mcpServers);
120
+
121
+ if (!isBatchMode) {
122
+ console.log();
123
+ for (const [serverName] of mcpServerEntries) {
124
+ console.log(
125
+ styleText("blue", `🔌 Connecting to MCP server: ${serverName}...`),
126
+ );
127
+ }
128
+ }
129
+
130
+ const mcpResults = await Promise.all(
131
+ mcpServerEntries.map(async ([serverName, serverConfig]) => {
132
+ const result = await setupMCPServer(serverName, serverConfig);
133
+ return { serverName, ...result };
134
+ }),
135
+ );
136
+
137
+ for (const { serverName, tools, stderrLogPath, cleanup } of mcpResults) {
138
+ mcpTools.push(...tools);
139
+ mcpCleanups.push(cleanup);
140
+ if (!isBatchMode) {
141
+ console.log(
142
+ styleText(
143
+ "green",
144
+ `✅ Successfully connected to MCP server: ${serverName}`,
145
+ ),
146
+ );
147
+ console.log(` ⤷ stderr log: ${stderrLogPath}`);
148
+ }
149
+ }
150
+ }
151
+
152
+ const modelFromConfig = appConfig.model || "";
153
+ const modelFromArgs =
154
+ cliArgs.subcommand.type === "batch" ||
155
+ cliArgs.subcommand.type === "interactive"
156
+ ? cliArgs.subcommand.model
157
+ : null;
158
+ const modelNameWithVariant = modelFromArgs || modelFromConfig;
159
+
160
+ const pluginPaths = resolvePluginPaths(appConfig.claudeCodePlugins ?? []);
161
+ const [prompts, agentRoles] = await Promise.all([
162
+ loadPrompts(pluginPaths),
163
+ loadAgentRoles(pluginPaths),
164
+ ]);
165
+
166
+ const prompt = createPrompt({
167
+ username: USER_NAME,
168
+ modelName: modelNameWithVariant,
169
+ workingDir: process.cwd(),
170
+ today: new Date().toISOString().split("T")[0],
171
+ sessionId,
172
+ tmuxSessionId,
173
+ projectMetadataDir: AGENT_PROJECT_METADATA_DIR,
174
+ agentRoles,
175
+ skills: Array.from(prompts.values()).filter((p) => p.isSkill),
176
+ });
177
+
178
+ const builtinTools = [
179
+ createExecCommandTool({ sandbox: appConfig.sandbox }),
180
+ writeFileTool,
181
+ createPatchFileTool(),
182
+ createTmuxCommandTool({ sandbox: appConfig.sandbox }),
183
+ createCompactContextTool(),
184
+ createSwitchToSubagentTool(),
185
+ createSwitchToMainAgentTool(),
186
+ ];
187
+
188
+ if (appConfig.tools?.askWeb) {
189
+ builtinTools.push(createAskWebTool(appConfig.tools.askWeb));
190
+ }
191
+
192
+ if (appConfig.tools?.askURL) {
193
+ builtinTools.push(createAskURLTool(appConfig.tools.askURL));
194
+ }
195
+
196
+ const toolUseApprover = createToolUseApprover({
197
+ maxApprovals: appConfig.autoApproval?.maxApprovals || 50,
198
+ defaultAction: appConfig.autoApproval?.defaultAction || "ask",
199
+ patterns: appConfig.autoApproval?.patterns || [],
200
+ maskApprovalInput: (toolName, input) => {
201
+ for (const tool of builtinTools) {
202
+ if (tool.def.name === toolName && tool.maskApprovalInput) {
203
+ return tool.maskApprovalInput(input);
204
+ }
205
+ }
206
+ return input;
207
+ },
208
+ });
209
+
210
+ const [modelName, modelVariant] = modelNameWithVariant.split("+");
211
+ const modelDef = (appConfig.models ?? []).find(
212
+ (entry) => entry.name === modelName && entry.variant === modelVariant,
213
+ );
214
+ if (!modelDef) {
215
+ throw new Error(
216
+ `Model "${modelNameWithVariant}" not found in configuration.`,
217
+ );
218
+ }
219
+
220
+ const platform = (appConfig.platforms ?? []).find(
221
+ (entry) =>
222
+ entry.name === modelDef.platform.name &&
223
+ entry.variant === modelDef.platform.variant,
224
+ );
225
+ if (!platform) {
226
+ throw new Error(
227
+ `Platform ${modelDef.platform.name} variant=${modelDef.platform.variant} not found in configuration.`,
228
+ );
229
+ }
230
+
231
+ const { userEventEmitter, agentEventEmitter, agentCommands } = createAgent({
232
+ callModel: createModelCaller({
233
+ ...modelDef,
234
+ platform: {
235
+ ...modelDef.platform,
236
+ ...platform,
237
+ },
238
+ }),
239
+ prompt,
240
+ tools: [...builtinTools, ...mcpTools],
241
+ toolUseApprover,
242
+ agentRoles,
243
+ modelCostConfig: modelDef.cost,
244
+ });
245
+
246
+ const sessionOptions = {
247
+ userEventEmitter,
248
+ agentEventEmitter,
249
+ agentCommands,
250
+ sessionId,
251
+ modelName: modelNameWithVariant,
252
+ sandbox: Boolean(appConfig.sandbox),
253
+ startTime,
254
+ onStop: async () => {
255
+ for (const cleanup of mcpCleanups) {
256
+ await cleanup();
257
+ }
258
+ },
259
+ };
260
+
261
+ if (cliArgs.subcommand.type === "batch") {
262
+ const task = cliArgs.subcommand.task;
263
+ if (!task) {
264
+ throw new Error("Batch task is required in batch mode");
265
+ }
266
+ await startBatchSession({
267
+ ...sessionOptions,
268
+ task,
269
+ });
270
+ } else {
271
+ startInteractiveSession({
272
+ ...sessionOptions,
273
+ notifyCmd: appConfig.notifyCmd,
274
+ claudeCodePlugins: resolvePluginPaths(appConfig.claudeCodePlugins ?? []),
275
+ voiceInput: appConfig.voiceInput,
276
+ });
277
+ }
278
+ })().catch((err) => {
279
+ console.error(err);
280
+ process.exit(1);
281
+ });
@@ -0,0 +1,351 @@
1
+ import { spawn } from "node:child_process";
2
+ import { closeSync, openSync } from "node:fs";
3
+ import { createInterface } from "node:readline";
4
+
5
+ /**
6
+ * @typedef {Object} CreateMCPClientOptions
7
+ * @property {string} command
8
+ * @property {string[]} [args]
9
+ * @property {Record<string, string>} [env]
10
+ * @property {"inherit" | "ignore" | "pipe" | string} [stderr]
11
+ * @property {string} [protocolVersion]
12
+ * @property {{ name: string, version: string }} [clientInfo]
13
+ * @property {Record<string, unknown>} [capabilities]
14
+ * @property {(method: string, params?: unknown) => void} [onNotification]
15
+ * Currently unused by callers, but provided for MCP protocol compliance (e.g. notifications/progress).
16
+ */
17
+
18
+ /**
19
+ * Spawn an MCP server process and return an initialized client.
20
+ * @param {CreateMCPClientOptions} options
21
+ * @returns {Promise<MCPClient>}
22
+ */
23
+ export async function createMCPClient(options) {
24
+ const transport = new StdioTransport(options.command, options.args, {
25
+ env: options.env,
26
+ stderr: options.stderr,
27
+ onNotification: options.onNotification,
28
+ });
29
+
30
+ const client = new MCPClient(transport);
31
+ try {
32
+ await client.initialize({
33
+ protocolVersion: options.protocolVersion,
34
+ clientInfo: options.clientInfo,
35
+ capabilities: options.capabilities,
36
+ });
37
+ } catch (err) {
38
+ await client.close().catch(() => {});
39
+ throw err;
40
+ }
41
+ return client;
42
+ }
43
+
44
+ /**
45
+ * MCP protocol client.
46
+ * Delegates transport concerns to a transport object.
47
+ */
48
+ export class MCPClient {
49
+ /** @type {StdioTransport} */
50
+ #transport;
51
+ #closed = false;
52
+
53
+ /**
54
+ * @param {StdioTransport} transport
55
+ */
56
+ constructor(transport) {
57
+ this.#transport = transport;
58
+ }
59
+
60
+ /**
61
+ * @param {Object} [options]
62
+ * @param {string} [options.protocolVersion]
63
+ * @param {{ name: string, version: string }} [options.clientInfo]
64
+ * @param {Record<string, unknown>} [options.capabilities]
65
+ * @returns {Promise<any>}
66
+ */
67
+ async initialize(options = {}) {
68
+ if (this.#closed) {
69
+ throw new Error("MCP client is closed");
70
+ }
71
+ const result = await this.#transport.request("initialize", {
72
+ protocolVersion: options.protocolVersion ?? "2025-03-26",
73
+ capabilities: options.capabilities ?? {},
74
+ clientInfo: options.clientInfo ?? {
75
+ name: "unknown",
76
+ version: "0.0.0",
77
+ },
78
+ });
79
+ this.#transport.notify("notifications/initialized");
80
+ return result;
81
+ }
82
+
83
+ /**
84
+ * @returns {Promise<{ tools: Array<{ name: string, description?: string, inputSchema: Record<string, unknown> }> }>}
85
+ */
86
+ async listTools() {
87
+ if (this.#closed) {
88
+ throw new Error("MCP client is closed");
89
+ }
90
+ return this.#transport.request("tools/list", {});
91
+ }
92
+
93
+ /**
94
+ * @param {{ name: string, arguments?: Record<string, unknown> }} params
95
+ * @returns {Promise<{ content?: Array<{ type: string, text?: string, data?: string, mimeType?: string }>, isError?: boolean }>}
96
+ */
97
+ async callTool(params) {
98
+ if (this.#closed) {
99
+ throw new Error("MCP client is closed");
100
+ }
101
+ return this.#transport.request("tools/call", params);
102
+ }
103
+
104
+ async close() {
105
+ if (this.#closed) return;
106
+ this.#closed = true;
107
+ await this.#transport.close();
108
+ }
109
+ }
110
+
111
+ /**
112
+ * @typedef {Object} StdioTransportOptions
113
+ * @property {Record<string, string>} [env]
114
+ * @property {"inherit" | "ignore" | "pipe" | string} [stderr]
115
+ * @property {(method: string, params?: unknown) => void} [onNotification]
116
+ */
117
+
118
+ /**
119
+ * JSON-RPC 2.0 transport over stdio.
120
+ * Manages the child process lifecycle and message passing.
121
+ */
122
+ export class StdioTransport {
123
+ /** @type {import("node:child_process").ChildProcess} */
124
+ #process;
125
+ /** @type {import("node:readline").Interface} */
126
+ #rl;
127
+ #nextId = 1;
128
+ /** @type {Map<number, { resolve: (value: any) => void, reject: (reason: any) => void, timer: NodeJS.Timeout }>} */
129
+ #pendingRequests = new Map();
130
+ #closed = false;
131
+ /** @type {Error | undefined} */
132
+ #earlyExitError;
133
+ /** @type {((line: string) => void)} */
134
+ #onLine;
135
+ /** @type {((code: number | null) => void)} */
136
+ #onClose;
137
+ /** @type {((err: Error) => void)} */
138
+ #onError;
139
+ /** @type {number | undefined} */
140
+ #stderrFd;
141
+ /** @type {((method: string, params?: unknown) => void) | undefined} */
142
+ // Currently unused by callers, but provided for MCP protocol compliance (e.g. notifications/progress).
143
+ #onNotification;
144
+
145
+ /**
146
+ * @param {string} command
147
+ * @param {string[]} [args]
148
+ * @param {StdioTransportOptions} [options]
149
+ */
150
+ constructor(command, args, options = {}) {
151
+ const defaultEnv = {
152
+ PWD: process.env.PWD || "",
153
+ PATH: process.env.PATH || "",
154
+ HOME: process.env.HOME || "",
155
+ };
156
+
157
+ /** @type {"inherit" | "ignore" | "pipe" | number} */
158
+ let stderrValue = "ignore";
159
+ if (
160
+ options.stderr === "inherit" ||
161
+ options.stderr === "ignore" ||
162
+ options.stderr === "pipe"
163
+ ) {
164
+ stderrValue = options.stderr;
165
+ } else if (typeof options.stderr === "string") {
166
+ this.#stderrFd = openSync(options.stderr, "a");
167
+ stderrValue = this.#stderrFd;
168
+ }
169
+
170
+ const childProcess = spawn(command, args || [], {
171
+ env: { ...defaultEnv, ...options.env },
172
+ stdio: /** @type {import("node:child_process").StdioOptions} */ ([
173
+ "pipe",
174
+ "pipe",
175
+ stderrValue,
176
+ ]),
177
+ });
178
+
179
+ this.#process = childProcess;
180
+ this.#onNotification = options.onNotification;
181
+
182
+ if (!childProcess.stdout) {
183
+ throw new Error("MCP server stdout is not available");
184
+ }
185
+ this.#rl = createInterface({ input: childProcess.stdout });
186
+
187
+ this.#onLine = (line) => this.#handleLine(line);
188
+ this.#rl.on("line", this.#onLine);
189
+
190
+ this.#onClose = (code) => this.#handleProcessClose(code);
191
+ childProcess.on("close", this.#onClose);
192
+
193
+ this.#onError = (err) => this.#handleProcessError(err);
194
+ childProcess.on("error", this.#onError);
195
+ }
196
+
197
+ /**
198
+ * @returns {import("node:child_process").ChildProcess}
199
+ */
200
+ get process() {
201
+ return this.#process;
202
+ }
203
+
204
+ /**
205
+ * @param {string} line
206
+ */
207
+ #handleLine(line) {
208
+ if (!line.trim()) return;
209
+ try {
210
+ const msg = JSON.parse(line);
211
+ if (!("id" in msg)) {
212
+ this.#onNotification?.(msg.method, msg.params);
213
+ return;
214
+ }
215
+ if (this.#pendingRequests.has(msg.id)) {
216
+ const pending = this.#pendingRequests.get(msg.id);
217
+ if (!pending) return;
218
+ this.#pendingRequests.delete(msg.id);
219
+ clearTimeout(pending.timer);
220
+ if (msg.error) {
221
+ pending.reject(
222
+ new Error(msg.error.message || JSON.stringify(msg.error)),
223
+ );
224
+ } else {
225
+ pending.resolve(msg.result);
226
+ }
227
+ }
228
+ } catch {
229
+ // Ignore non-JSON lines
230
+ }
231
+ }
232
+
233
+ /**
234
+ * @param {number | null} code
235
+ */
236
+ #handleProcessClose(code) {
237
+ const err = new Error(`MCP server exited with code ${code}`);
238
+ this.#earlyExitError = err;
239
+ this.#rejectAllPending(err);
240
+ }
241
+
242
+ /**
243
+ * @param {Error} err
244
+ */
245
+ #handleProcessError(err) {
246
+ this.#earlyExitError = err;
247
+ this.#rejectAllPending(err);
248
+ }
249
+
250
+ /**
251
+ * @param {Error} error
252
+ */
253
+ #rejectAllPending(error) {
254
+ for (const [, { reject, timer }] of this.#pendingRequests) {
255
+ clearTimeout(timer);
256
+ reject(error);
257
+ }
258
+ this.#pendingRequests.clear();
259
+ }
260
+
261
+ /**
262
+ * @param {string} method
263
+ * @param {Record<string, unknown>} [params]
264
+ * @param {number} [timeoutMs]
265
+ * @returns {Promise<any>}
266
+ */
267
+ request(method, params, timeoutMs = 30000) {
268
+ if (this.#closed) {
269
+ return Promise.reject(new Error("MCP client is closed"));
270
+ }
271
+ if (this.#earlyExitError) {
272
+ return Promise.reject(this.#earlyExitError);
273
+ }
274
+ const id = this.#nextId++;
275
+ return new Promise((resolve, reject) => {
276
+ const timer = setTimeout(() => {
277
+ this.#pendingRequests.delete(id);
278
+ reject(new Error(`Request ${method} timed out after ${timeoutMs}ms`));
279
+ }, timeoutMs);
280
+
281
+ this.#pendingRequests.set(id, { resolve, reject, timer });
282
+ const msg = JSON.stringify({ jsonrpc: "2.0", id, method, params });
283
+ this.#process.stdin?.write(`${msg}\n`, (err) => {
284
+ if (err) {
285
+ clearTimeout(timer);
286
+ this.#pendingRequests.delete(id);
287
+ reject(err);
288
+ }
289
+ });
290
+ });
291
+ }
292
+
293
+ /**
294
+ * @param {string} method
295
+ * @param {Record<string, unknown>} [params]
296
+ */
297
+ notify(method, params) {
298
+ if (this.#closed || this.#earlyExitError) return;
299
+ const msg = JSON.stringify(
300
+ params ? { jsonrpc: "2.0", method, params } : { jsonrpc: "2.0", method },
301
+ );
302
+ this.#process.stdin?.write(`${msg}\n`, () => {
303
+ // Ignore write errors in notifications
304
+ });
305
+ }
306
+
307
+ /**
308
+ * @param {NodeJS.Signals} [signal]
309
+ * @param {number} [timeoutMs]
310
+ * @returns {Promise<void>}
311
+ */
312
+ async close(signal = "SIGTERM", timeoutMs = 5000) {
313
+ if (this.#closed) return;
314
+ this.#closed = true;
315
+ this.#rejectAllPending(new Error("MCP client is closed"));
316
+ this.#rl.off("line", this.#onLine);
317
+ this.#rl.close();
318
+ this.#process.stdin?.end();
319
+ this.#process.off("close", this.#onClose);
320
+ this.#process.off("error", this.#onError);
321
+
322
+ const closePromise = new Promise((resolve) => {
323
+ if (
324
+ this.#process.exitCode !== null ||
325
+ this.#process.signalCode !== null
326
+ ) {
327
+ resolve(undefined);
328
+ return;
329
+ }
330
+ const timer = setTimeout(() => {
331
+ this.#process.kill("SIGKILL");
332
+ resolve(undefined);
333
+ }, timeoutMs);
334
+ this.#process.once("close", () => {
335
+ clearTimeout(timer);
336
+ resolve(undefined);
337
+ });
338
+ });
339
+
340
+ this.#process.kill(signal);
341
+ await closePromise;
342
+
343
+ if (this.#stderrFd !== undefined) {
344
+ try {
345
+ closeSync(this.#stderrFd);
346
+ } catch {
347
+ // ignore
348
+ }
349
+ }
350
+ }
351
+ }