@iinm/plain-agent 1.7.24 → 1.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -6,7 +6,7 @@ A lightweight CLI-based coding agent.
6
6
  - **Compact system prompt** — Under 3.5 KB, keeping per-request overhead and cost low ([src/prompt.mjs](https://github.com/iinm/plain-agent/blob/main/src/prompt.mjs)).
7
7
  - **Fine-grained approval rules** — Auto-approve commands by name, arguments,
8
8
  and file paths using regex patterns
9
- ([`config.predefined.json#autoApproval`](https://github.com/iinm/plain-agent/blob/main/config/config.predefined.json)).
9
+ ([config.predefined.json#autoApproval](https://github.com/iinm/plain-agent/blob/main/config/config.predefined.json)).
10
10
  - **Path validation** — Restricts access to the working directory.
11
11
  Git-ignored and untracked files require explicit approval.
12
12
  - **Sandboxed execution** — Run the agent's shell commands inside a Docker
@@ -2,7 +2,7 @@
2
2
  description: Analyzes the project and generates sandbox configuration files (run.sh, setup.sh) tailored to the project's needs.
3
3
  ---
4
4
 
5
- You are a sandbox builder. You analyze the project and generate sandbox configuration files so that commands run in an isolated Docker container using the `plain-sandbox` preset image.
5
+ You are a sandbox configurator. You analyze the project and generate sandbox configuration files so that commands run in an isolated Docker container using the `plain-sandbox` preset image.
6
6
 
7
7
  ## Overview
8
8
 
@@ -11,8 +11,6 @@ You create the following files:
11
11
  - `.plain-agent/sandbox/run.sh` — Wrapper script for `plain-sandbox` with project-specific options
12
12
  - `.plain-agent/setup.sh` — Initial setup script for both sandbox and host
13
13
 
14
- You also show an example `sandbox` config for `.plain-agent/config.json`, but you **never modify** config.json directly.
15
-
16
14
  ## Step 1: Analyze the Project
17
15
 
18
16
  Before generating anything, analyze the project to determine:
@@ -1050,6 +1050,29 @@
1050
1050
  }
1051
1051
  }
1052
1052
  },
1053
+ {
1054
+ "name": "deepseek-v4-pro",
1055
+ "variant": "fireworks",
1056
+ "platform": {
1057
+ "name": "openai-compatible",
1058
+ "variant": "fireworks"
1059
+ },
1060
+ "model": {
1061
+ "format": "openai-messages",
1062
+ "config": {
1063
+ "model": "accounts/fireworks/models/deepseek-v4-pro"
1064
+ }
1065
+ },
1066
+ "cost": {
1067
+ "currency": "USD",
1068
+ "unit": "1M",
1069
+ "costs": {
1070
+ "prompt_tokens": 1.74,
1071
+ "prompt_tokens_details.cached_tokens": -1.6,
1072
+ "completion_tokens": 3.48
1073
+ }
1074
+ }
1075
+ },
1053
1076
 
1054
1077
  {
1055
1078
  "name": "minimax-m2.7",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iinm/plain-agent",
3
- "version": "1.7.24",
3
+ "version": "1.8.0",
4
4
  "description": "A lightweight CLI-based coding agent",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -33,12 +33,6 @@
33
33
  "fix": "npx @biomejs/biome check --fix"
34
34
  },
35
35
  "dependencies": {
36
- "@aws-crypto/sha256-js": "^5.2.0",
37
- "@aws-sdk/credential-providers": "^3.1030.0",
38
- "@cfworker/json-schema": "^4.1.1",
39
- "@modelcontextprotocol/client": "^2.0.0-alpha.2",
40
- "@smithy/protocol-http": "^5.3.13",
41
- "@smithy/signature-v4": "^5.3.13",
42
36
  "diff": "^8.0.4",
43
37
  "js-yaml": "^4.1.1"
44
38
  },
@@ -46,6 +40,6 @@
46
40
  "@biomejs/biome": "^2.4.12",
47
41
  "@types/js-yaml": "^4.0.9",
48
42
  "@types/node": "^22.19.17",
49
- "typescript": "^5.9.3"
43
+ "typescript": "^6.0.2"
50
44
  }
51
45
  }
package/src/agent.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import type { EventEmitter } from "node:events";
2
+ import type { AgentRole } from "./context/loadAgentRoles.mjs";
3
+ import type { CostConfig, CostSummary } from "./costTracker.mjs";
2
4
  import type {
3
5
  CallModel,
4
6
  Message,
@@ -8,8 +10,6 @@ import type {
8
10
  ProviderTokenUsage,
9
11
  } from "./model";
10
12
  import type { Tool, ToolUseApprover } from "./tool";
11
- import type { AgentRole } from "./context/loadAgentRoles.mjs";
12
- import type { CostSummary, CostConfig } from "./costTracker.mjs";
13
13
 
14
14
  export type Agent = {
15
15
  userEventEmitter: UserEventEmitter;
package/src/config.d.ts CHANGED
@@ -1,9 +1,9 @@
1
+ import { ClaudeCodePluginRepo } from "./claudeCodePlugin.mjs";
1
2
  import { ModelDefinition, PlatformConfig } from "./modelDefinition";
2
3
  import { ToolUsePattern } from "./tool";
3
4
  import { AskURLToolOptions } from "./tools/askURL.mjs";
4
5
  import { AskWebToolOptions } from "./tools/askWeb.mjs";
5
6
  import { ExecCommandSanboxConfig } from "./tools/execCommand";
6
- import { ClaudeCodePluginRepo } from "./claudeCodePlugin.mjs";
7
7
  import { VoiceInputConfig } from "./voiceInput.mjs";
8
8
 
9
9
  export type AppConfig = {
package/src/main.mjs CHANGED
@@ -16,7 +16,7 @@ import { loadAppConfig } from "./config.mjs";
16
16
  import { loadAgentRoles } from "./context/loadAgentRoles.mjs";
17
17
  import { loadPrompts } from "./context/loadPrompts.mjs";
18
18
  import { AGENT_PROJECT_METADATA_DIR, USER_NAME } from "./env.mjs";
19
- import { setupMCPServer } from "./mcp.mjs";
19
+ import { setupMCPServer } from "./mcpIntegration.mjs";
20
20
  import { createModelCaller } from "./modelCaller.mjs";
21
21
  import { createPrompt } from "./prompt.mjs";
22
22
  import { createAskURLTool } from "./tools/askURL.mjs";
@@ -164,9 +164,10 @@ if (cliArgs.subcommand.type === "cost") {
164
164
  const prompt = createPrompt({
165
165
  username: USER_NAME,
166
166
  modelName: modelNameWithVariant,
167
+ workingDir: process.cwd(),
168
+ today: new Date().toISOString().split("T")[0],
167
169
  sessionId,
168
170
  tmuxSessionId,
169
- workingDir: process.cwd(),
170
171
  projectMetadataDir: AGENT_PROJECT_METADATA_DIR,
171
172
  agentRoles,
172
173
  skills: Array.from(prompts.values()).filter((p) => p.isSkill),
@@ -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
+ }
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Mock MCP server for testing.
3
+ * Speaks JSON-RPC 2.0 over stdio.
4
+ * Handles: initialize, notifications/initialized, tools/list, tools/call.
5
+ */
6
+
7
+ import { createInterface } from "node:readline";
8
+
9
+ const rl = createInterface({ input: process.stdin });
10
+
11
+ rl.on("line", (line) => {
12
+ let msg;
13
+ try {
14
+ msg = JSON.parse(line);
15
+ } catch {
16
+ return;
17
+ }
18
+
19
+ if (msg.method === "initialize") {
20
+ respond(msg.id, {
21
+ protocolVersion: "2025-03-26",
22
+ capabilities: {},
23
+ serverInfo: { name: "mock", version: "0.0.0" },
24
+ });
25
+ return;
26
+ }
27
+
28
+ if (msg.method === "notifications/initialized") {
29
+ return;
30
+ }
31
+
32
+ if (msg.method === "tools/list") {
33
+ respond(msg.id, {
34
+ tools: [
35
+ {
36
+ name: "echo",
37
+ description: "Echo tool",
38
+ inputSchema: {
39
+ type: "object",
40
+ properties: { text: { type: "string" } },
41
+ },
42
+ },
43
+ {
44
+ name: "add",
45
+ description: "Add numbers",
46
+ inputSchema: {
47
+ type: "object",
48
+ properties: {
49
+ a: { type: "number" },
50
+ b: { type: "number" },
51
+ },
52
+ },
53
+ },
54
+ ],
55
+ });
56
+ return;
57
+ }
58
+
59
+ if (msg.method === "tools/call") {
60
+ const { name, arguments: args } = msg.params;
61
+ if (name === "echo") {
62
+ respond(msg.id, { content: [{ type: "text", text: args.text }] });
63
+ } else if (name === "add") {
64
+ respond(msg.id, {
65
+ content: [{ type: "text", text: String(args.a + args.b) }],
66
+ });
67
+ } else if (name === "error_tool") {
68
+ respondError(msg.id, -1, "tool failed");
69
+ }
70
+ }
71
+ });
72
+
73
+ /** @param {number} id @param {unknown} result */
74
+ function respond(id, result) {
75
+ process.stdout.write(`${JSON.stringify({ jsonrpc: "2.0", id, result })}\n`);
76
+ }
77
+
78
+ /** @param {number} id @param {number} code @param {string} message */
79
+ function respondError(id, code, message) {
80
+ process.stdout.write(
81
+ `${JSON.stringify({ jsonrpc: "2.0", id, error: { code, message } })}\n`,
82
+ );
83
+ }
@@ -1,15 +1,17 @@
1
1
  /**
2
- * @import { Client } from "@modelcontextprotocol/client";
3
2
  * @import { StructuredToolResultContent, Tool, ToolImplementation } from "./tool";
4
3
  * @import { MCPServerConfig } from "./config";
5
4
  */
6
5
 
7
- import { mkdir, open } from "node:fs/promises";
6
+ import { mkdir } from "node:fs/promises";
8
7
  import path from "node:path";
9
8
  import { AGENT_PROJECT_METADATA_DIR } from "./env.mjs";
9
+ import { createMCPClient } from "./mcpClient.mjs";
10
10
  import { writeTmpFile } from "./tmpfile.mjs";
11
11
  import { noThrow } from "./utils/noThrow.mjs";
12
12
 
13
+ /** @typedef {import("./mcpClient.mjs").MCPClient} MCPClient */
14
+
13
15
  const OUTPUT_MAX_LENGTH = 1024 * 8;
14
16
 
15
17
  /**
@@ -27,9 +29,14 @@ const OUTPUT_MAX_LENGTH = 1024 * 8;
27
29
  export async function setupMCPServer(serverName, serverConfig) {
28
30
  const { options, ...params } = serverConfig;
29
31
 
30
- const { client, stderrLogPath, cleanup } = await startMCPServer({
31
- serverName,
32
- params,
32
+ // Ensure log directory exists and open stderr log file
33
+ const logDir = path.join(AGENT_PROJECT_METADATA_DIR, "logs");
34
+ await mkdir(logDir, { recursive: true });
35
+ const logPath = path.join(logDir, `mcp--${serverName}.stderr`);
36
+
37
+ const client = await createMCPClient({
38
+ ...params,
39
+ stderr: logPath,
33
40
  });
34
41
 
35
42
  const tools = (await createMCPTools(serverName, client)).filter(
@@ -42,65 +49,17 @@ export async function setupMCPServer(serverName, serverConfig) {
42
49
 
43
50
  return {
44
51
  tools,
45
- stderrLogPath,
52
+ stderrLogPath: logPath,
46
53
  cleanup: async () => {
47
- cleanup();
48
54
  await client.close();
49
55
  },
50
56
  };
51
57
  }
52
58
 
53
- /**
54
- * @typedef {Object} MCPClientOptions
55
- * @property {string} serverName - The name of the MCP server.
56
- * @property {import("@modelcontextprotocol/client").StdioServerParameters} params - The transport to use for the client.
57
- */
58
-
59
- /**
60
- * @param {MCPClientOptions} options - The options for the client.
61
- * @returns {Promise<{client: Client; stderrLogPath: string; cleanup: () => void}>} - The MCP client, stderr log path, and cleanup function.
62
- */
63
- async function startMCPServer(options) {
64
- const mcpClient = await import("@modelcontextprotocol/client");
65
-
66
- const client = new mcpClient.Client({
67
- name: "undefined",
68
- version: "undefined",
69
- });
70
-
71
- const { env, ...restParams } = options.params;
72
- const defaultEnv = {
73
- PWD: process.env.PWD || "",
74
- PATH: process.env.PATH || "",
75
- HOME: process.env.HOME || "",
76
- };
77
-
78
- // Ensure log directory exists and open stderr log file
79
- const logDir = path.join(AGENT_PROJECT_METADATA_DIR, "logs");
80
- await mkdir(logDir, { recursive: true });
81
- const logPath = path.join(logDir, `mcp--${options.serverName}.stderr`);
82
- const stderrLogFile = await open(logPath, "a");
83
-
84
- const transport = new mcpClient.StdioClientTransport({
85
- ...restParams,
86
- env: env ? { ...defaultEnv, ...env } : undefined,
87
- stderr: stderrLogFile.fd,
88
- });
89
- await client.connect(transport);
90
-
91
- return {
92
- client,
93
- stderrLogPath: logPath,
94
- cleanup: () => {
95
- stderrLogFile.close();
96
- },
97
- };
98
- }
99
-
100
59
  /**
101
60
  * @param {string} serverName
102
- * @param {Client} client - The MCP client.
103
- * @returns {Promise<Tool[]>} - The list of tools.
61
+ * @param {MCPClient} client
62
+ * @returns {Promise<Tool[]>}
104
63
  */
105
64
  async function createMCPTools(serverName, client) {
106
65
  const { tools: mcpTools } = await client.listTools();
@@ -1,8 +1,8 @@
1
1
  import { AnthropicModelConfig } from "./providers/anthropic";
2
+ import { BedrockConverseModelConfig } from "./providers/bedrock";
2
3
  import { GeminiModelConfig } from "./providers/gemini";
3
4
  import { OpenAIModelConfig } from "./providers/openai";
4
5
  import { OpenAICompatibleModelConfig } from "./providers/openaiCompatible";
5
- import { BedrockConverseModelConfig } from "./providers/bedrock";
6
6
 
7
7
  export type ModelDefinition = {
8
8
  name: string;
package/src/prompt.mjs CHANGED
@@ -2,9 +2,10 @@
2
2
  * @typedef {object} PromptConfig
3
3
  * @property {string} username
4
4
  * @property {string} modelName
5
+ * @property {string} workingDir - The current working directory.
6
+ * @property {string} today - Today's date in YYYY-MM-DD format.
5
7
  * @property {string} sessionId
6
8
  * @property {string} tmuxSessionId
7
- * @property {string} workingDir - The current working directory.
8
9
  * @property {string} projectMetadataDir - The directory where memory files are stored.
9
10
  * @property {Map<string, import('./context/loadAgentRoles.mjs').AgentRole>} agentRoles - Available agent roles.
10
11
  * @property {{filePath: string, description: string}[]} skills
@@ -18,6 +19,7 @@ export function createPrompt({
18
19
  username,
19
20
  modelName,
20
21
  sessionId,
22
+ today,
21
23
  tmuxSessionId,
22
24
  workingDir,
23
25
  projectMetadataDir,
@@ -103,7 +105,7 @@ Discover and apply project-specific rules and reusable skills.
103
105
  ## AGENTS.md (falling back to CLAUDE.md if not found): Project-specific rules, conventions, and commands.
104
106
 
105
107
  Find: fd ["^(AGENTS|CLAUDE)\\.md$", "./", "--hidden", "--max-depth", "5"]
106
- Read from root to target: ./AGENTS.md → dir/AGENTS.md → dir/subdir/AGENTS.md
108
+ Read from the project root to the directory you're working in: ./AGENTS.md → dir/AGENTS.md → dir/subdir/AGENTS.md
107
109
  Apply rules when working in that directory
108
110
 
109
111
  ## SKILL.md: Reusable workflows with specialized knowledge
@@ -117,6 +119,7 @@ ${skillDescriptions}
117
119
  - User name: ${username}
118
120
  - Your model name: ${modelName}
119
121
  - Current working directory: ${workingDir}
122
+ - Today's date: ${today}
120
123
  - Session id: ${sessionId}
121
124
  - Tmux session id: ${tmuxSessionId}
122
125
  - Memory file path: ${projectMetadataDir}/memory/${sessionId}--<kebab-case-title>.md
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { styleText } from "node:util";
8
8
  import { noThrow } from "../utils/noThrow.mjs";
9
+ import { loadAwsCredentials, signAwsRequest } from "./platform/awsSigV4.mjs";
9
10
  import { readBedrockStreamEvents } from "./platform/bedrock.mjs";
10
11
  import { getGoogleCloudAccessToken } from "./platform/googleCloud.mjs";
11
12
 
@@ -108,39 +109,28 @@ export async function callAnthropicModel(
108
109
 
109
110
  // bedrock + sso profile
110
111
  const runFetchForBedrock = async () => {
111
- const { Sha256 } = await import("@aws-crypto/sha256-js");
112
- const { fromIni } = await import("@aws-sdk/credential-providers");
113
- const { HttpRequest } = await import("@smithy/protocol-http");
114
- const { SignatureV4 } = await import("@smithy/signature-v4");
115
-
116
112
  const region =
117
113
  url.match(/bedrock-runtime\.([\w-]+)\.amazonaws\.com/)?.[1] ?? "";
118
114
  const urlParsed = new URL(url);
119
115
  const { hostname, pathname } = urlParsed;
120
116
 
121
- const signer = new SignatureV4({
122
- credentials: fromIni({
123
- profile:
124
- platformConfig.name === "bedrock" ? platformConfig.awsProfile : "",
125
- }),
126
- region,
127
- service: "bedrock",
128
- sha256: Sha256,
129
- });
117
+ const credentials = await loadAwsCredentials(
118
+ platformConfig.name === "bedrock" ? platformConfig.awsProfile : "",
119
+ );
130
120
 
131
- const req = new HttpRequest({
132
- protocol: "https:",
133
- method: "POST",
134
- hostname,
135
- path: pathname,
136
- headers: {
137
- host: hostname,
138
- "Content-Type": "application/json",
121
+ const signed = signAwsRequest(
122
+ {
123
+ method: "POST",
124
+ hostname,
125
+ path: pathname,
126
+ headers: {
127
+ host: hostname,
128
+ "Content-Type": "application/json",
129
+ },
130
+ body: JSON.stringify(request),
139
131
  },
140
- body: JSON.stringify(request),
141
- });
142
-
143
- const signed = await signer.sign(req);
132
+ { region, service: "bedrock", credentials },
133
+ );
144
134
 
145
135
  return fetch(url, {
146
136
  method: signed.method,
@@ -6,6 +6,7 @@
6
6
 
7
7
  import { styleText } from "node:util";
8
8
  import { noThrow } from "../utils/noThrow.mjs";
9
+ import { loadAwsCredentials, signAwsRequest } from "./platform/awsSigV4.mjs";
9
10
  import { readBedrockStreamEvents } from "./platform/bedrock.mjs";
10
11
 
11
12
  /**
@@ -21,11 +22,6 @@ export async function callBedrockConverseModel(
21
22
  input,
22
23
  retryCount = 0,
23
24
  ) {
24
- const { Sha256 } = await import("@aws-crypto/sha256-js");
25
- const { fromIni } = await import("@aws-sdk/credential-providers");
26
- const { HttpRequest } = await import("@smithy/protocol-http");
27
- const { SignatureV4 } = await import("@smithy/signature-v4");
28
-
29
25
  return await noThrow(async () => {
30
26
  const messages = convertGenericMessageToBedrockFormat(input.messages);
31
27
  const cachedMessages = modelConfig.enablePromptCaching
@@ -75,29 +71,23 @@ export async function callBedrockConverseModel(
75
71
  const payload = JSON.stringify(request);
76
72
 
77
73
  // Sign request with AWS Signature V4
78
- const signer = new SignatureV4({
79
- credentials: fromIni({ profile: platformConfig.awsProfile }),
80
- region,
81
- service: "bedrock",
82
- sha256: Sha256,
83
- });
84
-
74
+ const credentials = await loadAwsCredentials(platformConfig.awsProfile);
85
75
  const urlParsed = new URL(url);
86
76
  const { hostname, pathname } = urlParsed;
87
77
 
88
- const req = new HttpRequest({
89
- protocol: "https:",
90
- method: "POST",
91
- hostname,
92
- path: pathname,
93
- headers: {
94
- host: hostname,
95
- "Content-Type": "application/json",
78
+ const signed = signAwsRequest(
79
+ {
80
+ method: "POST",
81
+ hostname,
82
+ path: pathname,
83
+ headers: {
84
+ host: hostname,
85
+ "Content-Type": "application/json",
86
+ },
87
+ body: payload,
96
88
  },
97
- body: payload,
98
- });
99
-
100
- const signed = await signer.sign(req);
89
+ { region, service: "bedrock", credentials },
90
+ );
101
91
 
102
92
  const response = await fetch(url, {
103
93
  method: signed.method,
@@ -7,6 +7,7 @@
7
7
  import { styleText } from "node:util";
8
8
  import { noThrow } from "../utils/noThrow.mjs";
9
9
  import { retryOnError } from "../utils/retryOnError.mjs";
10
+ import { loadAwsCredentials, signAwsRequest } from "./platform/awsSigV4.mjs";
10
11
  import { readBedrockStreamEvents } from "./platform/bedrock.mjs";
11
12
  import { getGoogleCloudAccessToken } from "./platform/googleCloud.mjs";
12
13
 
@@ -111,39 +112,28 @@ export async function callOpenAICompatibleModel(
111
112
 
112
113
  // bedrock + sso profile
113
114
  const runFetchForBedrock = async () => {
114
- const { Sha256 } = await import("@aws-crypto/sha256-js");
115
- const { fromIni } = await import("@aws-sdk/credential-providers");
116
- const { HttpRequest } = await import("@smithy/protocol-http");
117
- const { SignatureV4 } = await import("@smithy/signature-v4");
118
-
119
115
  const region =
120
116
  url.match(/bedrock-runtime\.([\w-]+)\.amazonaws\.com/)?.[1] ?? "";
121
117
  const urlParsed = new URL(url);
122
118
  const { hostname, pathname } = urlParsed;
123
119
 
124
- const signer = new SignatureV4({
125
- credentials: fromIni({
126
- profile:
127
- platformConfig.name === "bedrock" ? platformConfig.awsProfile : "",
128
- }),
129
- region,
130
- service: "bedrock",
131
- sha256: Sha256,
132
- });
120
+ const credentials = await loadAwsCredentials(
121
+ platformConfig.name === "bedrock" ? platformConfig.awsProfile : "",
122
+ );
133
123
 
134
- const req = new HttpRequest({
135
- protocol: "https:",
136
- method: "POST",
137
- hostname,
138
- path: pathname,
139
- headers: {
140
- host: hostname,
141
- "Content-Type": "application/json",
124
+ const signed = signAwsRequest(
125
+ {
126
+ method: "POST",
127
+ hostname,
128
+ path: pathname,
129
+ headers: {
130
+ host: hostname,
131
+ "Content-Type": "application/json",
132
+ },
133
+ body: JSON.stringify(request),
142
134
  },
143
- body: JSON.stringify(request),
144
- });
145
-
146
- const signed = await signer.sign(req);
135
+ { region, service: "bedrock", credentials },
136
+ );
147
137
 
148
138
  return fetch(url, {
149
139
  method: signed.method,
@@ -0,0 +1,161 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createHash, createHmac } from "node:crypto";
3
+
4
+ /**
5
+ * @typedef {{ accessKeyId: string, secretAccessKey: string, sessionToken?: string }} AwsCredentials
6
+ */
7
+
8
+ /**
9
+ * Load AWS credentials for the given profile using the AWS CLI.
10
+ * @param {string} profile
11
+ * @returns {Promise<AwsCredentials>}
12
+ */
13
+ export async function loadAwsCredentials(profile) {
14
+ /** @type {string} */
15
+ const stdout = await new Promise((resolve, reject) => {
16
+ execFile(
17
+ "aws",
18
+ ["configure", "export-credentials", "--profile", profile],
19
+ {
20
+ shell: false,
21
+ timeout: 30 * 1000,
22
+ },
23
+ (error, stdout, _stderr) => {
24
+ if (error) {
25
+ reject(error);
26
+ return;
27
+ }
28
+ resolve(stdout.trim());
29
+ },
30
+ );
31
+ });
32
+ const parsed = JSON.parse(stdout);
33
+ for (const key of ["AccessKeyId", "SecretAccessKey"]) {
34
+ if (!parsed[key] || typeof parsed[key] !== "string") {
35
+ throw new Error(
36
+ `AWS credentials output missing ${key}. Raw output: ${stdout.slice(0, 200)}`,
37
+ );
38
+ }
39
+ }
40
+ return {
41
+ accessKeyId: parsed.AccessKeyId,
42
+ secretAccessKey: parsed.SecretAccessKey,
43
+ ...(parsed.SessionToken && { sessionToken: parsed.SessionToken }),
44
+ };
45
+ }
46
+
47
+ /**
48
+ * Sign an HTTP request with AWS Signature V4.
49
+ *
50
+ * Known limitation: if duplicate header names with different casing exist
51
+ * (e.g. `Content-Type` and `content-type`), only the first match is used.
52
+ * Per AWS SigV4 spec, values should be combined with commas. Callers should
53
+ * ensure header names are unique (case-insensitive) before signing.
54
+ *
55
+ * @param {{
56
+ * method: string,
57
+ * hostname: string,
58
+ * path: string,
59
+ * headers: Record<string, string>,
60
+ * body: string,
61
+ * }} request
62
+ * @param {{
63
+ * region: string,
64
+ * service: string,
65
+ * credentials: AwsCredentials,
66
+ * }} options
67
+ * @returns {{ method: string, headers: Record<string, string>, body: string }}
68
+ */
69
+ export function signAwsRequest(request, options) {
70
+ const { method, hostname, path, headers, body } = request;
71
+ const { region, service, credentials } = options;
72
+
73
+ const now = new Date();
74
+ const amzDate = now
75
+ .toISOString()
76
+ .replace(/[-:]/g, "")
77
+ .replace(/\.\d{3}/, "");
78
+ const dateStamp = amzDate.slice(0, 8);
79
+
80
+ /** @type {Record<string, string>} */
81
+ const signedHeaders = { ...headers, host: hostname, "x-amz-date": amzDate };
82
+ if (credentials.sessionToken) {
83
+ signedHeaders["x-amz-security-token"] = credentials.sessionToken;
84
+ }
85
+
86
+ // Canonical headers: sorted, lowercased, trimmed
87
+ const sortedKeys = Object.keys(signedHeaders)
88
+ .map((k) => k.toLowerCase())
89
+ .sort();
90
+ const canonicalHeaders = sortedKeys
91
+ .map((k) => {
92
+ const original = Object.keys(signedHeaders).find(
93
+ (h) => h.toLowerCase() === k,
94
+ );
95
+ return `${k}:${signedHeaders[/** @type {string} */ (original)].trim()}`;
96
+ })
97
+ .join("\n");
98
+ const signedHeadersList = sortedKeys.join(";");
99
+
100
+ const payloadHash = sha256Hex(body || "");
101
+
102
+ // AWS SigV4 Canonical URI requires each path segment to be URI-encoded.
103
+ // URL.pathname returns a decoded string, so we need to re-encode it.
104
+ const canonicalUri = path
105
+ .split("/")
106
+ .map((segment) => encodeURIComponent(segment))
107
+ .join("/");
108
+
109
+ const canonicalRequest = [
110
+ method,
111
+ canonicalUri,
112
+ "", // query string (empty for POST)
113
+ `${canonicalHeaders}\n`,
114
+ signedHeadersList,
115
+ payloadHash,
116
+ ].join("\n");
117
+
118
+ // String to sign
119
+ const scope = `${dateStamp}/${region}/${service}/aws4_request`;
120
+ const stringToSign = [
121
+ "AWS4-HMAC-SHA256",
122
+ amzDate,
123
+ scope,
124
+ sha256Hex(canonicalRequest),
125
+ ].join("\n");
126
+
127
+ // Signing key
128
+ const kDate = hmacSha256(`AWS4${credentials.secretAccessKey}`, dateStamp);
129
+ const kRegion = hmacSha256(kDate, region);
130
+ const kService = hmacSha256(kRegion, service);
131
+ const kSigning = hmacSha256(kService, "aws4_request");
132
+
133
+ const signature = createHmac("sha256", kSigning)
134
+ .update(stringToSign, "utf-8")
135
+ .digest("hex");
136
+
137
+ const authorization = `AWS4-HMAC-SHA256 Credential=${credentials.accessKeyId}/${scope}, SignedHeaders=${signedHeadersList}, Signature=${signature}`;
138
+
139
+ return {
140
+ method,
141
+ headers: { ...signedHeaders, Authorization: authorization },
142
+ body,
143
+ };
144
+ }
145
+
146
+ /**
147
+ * @param {string} data
148
+ * @returns {string}
149
+ */
150
+ function sha256Hex(data) {
151
+ return createHash("sha256").update(data, "utf-8").digest("hex");
152
+ }
153
+
154
+ /**
155
+ * @param {string | Buffer} key
156
+ * @param {string} data
157
+ * @returns {Buffer}
158
+ */
159
+ function hmacSha256(key, data) {
160
+ return createHmac("sha256", key).update(data, "utf-8").digest();
161
+ }
@@ -57,6 +57,32 @@ other new content
57
57
  await noThrow(async () => {
58
58
  const { filePath, diff } = input;
59
59
 
60
+ // Validate marker counts: each block needs exactly one of each marker.
61
+ // Since nonce is random, duplicate markers mean the user accidentally
62
+ // included a marker line in their search/replace content (copy-paste error).
63
+ const searchMarker = `<<< ${nonce} <<< SEARCH`;
64
+ const sepMarker = `=== ${nonce} ===`;
65
+ const replaceMarker = `>>> ${nonce} >>> REPLACE`;
66
+ /** @type {(s: string, sub: string) => number} */
67
+ const count = (s, sub) => s.split(sub).length - 1;
68
+ const nSearch = count(diff, searchMarker);
69
+ const nSep = count(diff, sepMarker);
70
+ const nReplace = count(diff, replaceMarker);
71
+
72
+ if (nSearch !== nReplace) {
73
+ throw new Error(
74
+ `Mismatched block markers: found ${nSearch} "${searchMarker}" but ${nReplace} "${replaceMarker}". ` +
75
+ "Did you accidentally include a marker in your search/replace content?",
76
+ );
77
+ }
78
+ if (nSep !== nSearch) {
79
+ throw new Error(
80
+ `Each diff block needs exactly one "${sepMarker}" separator, ` +
81
+ `but found ${nSep} separators for ${nSearch} block(s). ` +
82
+ "Did you accidentally include the separator marker in your search/replace content?",
83
+ );
84
+ }
85
+
60
86
  const content = await fs.readFile(filePath, "utf8");
61
87
  const matches = Array.from(
62
88
  diff.matchAll(