@iinm/plain-agent 1.0.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.
Files changed (79) hide show
  1. package/.config/agents.library/code-simplifier.md +5 -0
  2. package/.config/agents.library/qa-engineer.md +74 -0
  3. package/.config/agents.library/software-architect.md +278 -0
  4. package/.config/agents.predefined/worker.md +3 -0
  5. package/.config/config.predefined.json +825 -0
  6. package/.config/prompts.library/code-review.md +8 -0
  7. package/.config/prompts.library/feature-dev.md +6 -0
  8. package/.config/prompts.predefined/shortcuts/commit-by-user.md +9 -0
  9. package/.config/prompts.predefined/shortcuts/commit.md +10 -0
  10. package/.config/prompts.predefined/shortcuts/general-question.md +6 -0
  11. package/LICENSE +21 -0
  12. package/README.md +624 -0
  13. package/bin/plain +3 -0
  14. package/bin/plain-interrupt +6 -0
  15. package/bin/plain-notify-desktop +19 -0
  16. package/bin/plain-notify-terminal-bell +3 -0
  17. package/package.json +57 -0
  18. package/sandbox/bin/plain-sandbox +972 -0
  19. package/src/agent.d.ts +48 -0
  20. package/src/agent.mjs +159 -0
  21. package/src/agentLoop.mjs +369 -0
  22. package/src/agentState.mjs +41 -0
  23. package/src/cliArgs.mjs +45 -0
  24. package/src/cliFormatter.mjs +217 -0
  25. package/src/cliInteractive.mjs +739 -0
  26. package/src/config.d.ts +48 -0
  27. package/src/config.mjs +168 -0
  28. package/src/context/consumeInterruptMessage.mjs +30 -0
  29. package/src/context/loadAgentRoles.mjs +272 -0
  30. package/src/context/loadPrompts.mjs +312 -0
  31. package/src/context/loadUserMessageContext.mjs +147 -0
  32. package/src/env.mjs +46 -0
  33. package/src/main.mjs +202 -0
  34. package/src/mcp.mjs +202 -0
  35. package/src/model.d.ts +109 -0
  36. package/src/modelCaller.mjs +29 -0
  37. package/src/modelDefinition.d.ts +73 -0
  38. package/src/prompt.mjs +128 -0
  39. package/src/providers/anthropic.d.ts +248 -0
  40. package/src/providers/anthropic.mjs +596 -0
  41. package/src/providers/gemini.d.ts +208 -0
  42. package/src/providers/gemini.mjs +752 -0
  43. package/src/providers/openai.d.ts +281 -0
  44. package/src/providers/openai.mjs +551 -0
  45. package/src/providers/openaiCompatible.d.ts +147 -0
  46. package/src/providers/openaiCompatible.mjs +658 -0
  47. package/src/providers/platform/azure.mjs +42 -0
  48. package/src/providers/platform/bedrock.mjs +74 -0
  49. package/src/providers/platform/googleCloud.mjs +34 -0
  50. package/src/subagent.mjs +247 -0
  51. package/src/tmpfile.mjs +27 -0
  52. package/src/tool.d.ts +74 -0
  53. package/src/toolExecutor.mjs +236 -0
  54. package/src/toolInputValidator.mjs +183 -0
  55. package/src/toolUseApprover.mjs +98 -0
  56. package/src/tools/askGoogle.mjs +135 -0
  57. package/src/tools/delegateToSubagent.d.ts +4 -0
  58. package/src/tools/delegateToSubagent.mjs +48 -0
  59. package/src/tools/execCommand.d.ts +22 -0
  60. package/src/tools/execCommand.mjs +200 -0
  61. package/src/tools/fetchWebPage.mjs +96 -0
  62. package/src/tools/patchFile.d.ts +4 -0
  63. package/src/tools/patchFile.mjs +96 -0
  64. package/src/tools/reportAsSubagent.d.ts +3 -0
  65. package/src/tools/reportAsSubagent.mjs +44 -0
  66. package/src/tools/tavilySearch.d.ts +6 -0
  67. package/src/tools/tavilySearch.mjs +57 -0
  68. package/src/tools/tmuxCommand.d.ts +14 -0
  69. package/src/tools/tmuxCommand.mjs +194 -0
  70. package/src/tools/writeFile.d.ts +4 -0
  71. package/src/tools/writeFile.mjs +56 -0
  72. package/src/utils/evalJSONConfig.mjs +48 -0
  73. package/src/utils/matchValue.d.ts +6 -0
  74. package/src/utils/matchValue.mjs +40 -0
  75. package/src/utils/noThrow.mjs +31 -0
  76. package/src/utils/notify.mjs +28 -0
  77. package/src/utils/parseFileRange.mjs +18 -0
  78. package/src/utils/readFileRange.mjs +33 -0
  79. package/src/utils/retryOnError.mjs +41 -0
@@ -0,0 +1,312 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs/promises";
4
+ import path from "node:path";
5
+ import yaml from "js-yaml";
6
+ import {
7
+ AGENT_CACHE_DIR,
8
+ AGENT_PROJECT_METADATA_DIR,
9
+ AGENT_ROOT,
10
+ AGENT_USER_CONFIG_DIR,
11
+ CLAUDE_CODE_PLUGIN_DIR,
12
+ } from "../env.mjs";
13
+
14
+ /**
15
+ * @typedef {Object} Prompt
16
+ * @property {string} id
17
+ * @property {string} description
18
+ * @property {string} content
19
+ * @property {string} filePath
20
+ * @property {string} [import]
21
+ * @property {boolean} [userInvocable]
22
+ * @property {boolean} [isShortcut]
23
+ * @property {boolean} [isSkill]
24
+ */
25
+
26
+ /**
27
+ * Load all prompts from the predefined directories.
28
+ * @param {Array<{name: string, path: string}>} [claudeCodePlugins]
29
+ * @returns {Promise<Map<string, Prompt>>}
30
+ */
31
+ export async function loadPrompts(claudeCodePlugins) {
32
+ const promptDirs = [
33
+ {
34
+ dir: path.resolve(AGENT_ROOT, ".config", "prompts.predefined"),
35
+ idPrefix: "",
36
+ },
37
+ { dir: path.resolve(AGENT_USER_CONFIG_DIR, "prompts"), idPrefix: "" },
38
+ { dir: path.resolve(AGENT_PROJECT_METADATA_DIR, "prompts"), idPrefix: "" },
39
+ {
40
+ dir: path.resolve(process.cwd(), ".claude", "commands"),
41
+ idPrefix: "claude/commands:",
42
+ },
43
+ {
44
+ dir: path.resolve(process.cwd(), ".claude", "skills"),
45
+ idPrefix: "claude/skills:",
46
+ },
47
+ ];
48
+
49
+ // Add plugin directories if provided
50
+ if (claudeCodePlugins) {
51
+ for (const plugin of claudeCodePlugins) {
52
+ const pluginBase = path.join(CLAUDE_CODE_PLUGIN_DIR, plugin.path);
53
+
54
+ // Commands
55
+ promptDirs.push({
56
+ dir: path.join(pluginBase, "commands"),
57
+ idPrefix: `claude/${plugin.name}/commands:`,
58
+ });
59
+
60
+ // Skills
61
+ promptDirs.push({
62
+ dir: path.join(pluginBase, "skills"),
63
+ idPrefix: `claude/${plugin.name}/skills:`,
64
+ });
65
+ }
66
+ }
67
+
68
+ /** @type {Map<string, Prompt>} */
69
+ const prompts = new Map();
70
+
71
+ for (const { dir, idPrefix } of promptDirs) {
72
+ const files = await getMarkdownFiles(dir).catch((err) => {
73
+ if (err.code !== "ENOENT") {
74
+ console.warn(`Failed to list prompts in ${dir}:`, err);
75
+ }
76
+ return [];
77
+ });
78
+
79
+ for (const file of files) {
80
+ const fullPath = path.join(dir, file);
81
+ const content = await fs.readFile(fullPath, "utf-8").catch((err) => {
82
+ console.warn(`Failed to read prompt file ${fullPath}:`, err);
83
+ return null;
84
+ });
85
+
86
+ if (content === null) continue;
87
+
88
+ // Ignore all files in the skills/ directory except for SKILL.md.
89
+ if (fullPath.match(/\/skills\//) && !file.endsWith("/SKILL.md")) {
90
+ continue;
91
+ }
92
+
93
+ let prompt = parsePrompt(file, content, fullPath, idPrefix);
94
+ if (prompt.import) {
95
+ prompt = await mergeRemotePrompt(prompt, file, fullPath);
96
+ }
97
+
98
+ if (prompt.userInvocable === false) {
99
+ continue;
100
+ }
101
+
102
+ prompts.set(prompt.id, prompt);
103
+ }
104
+ }
105
+
106
+ return prompts;
107
+ }
108
+
109
+ /**
110
+ * Merges a remote prompt into a local prompt if an import URL is provided.
111
+ * @param {Prompt} localPrompt
112
+ * @param {string} relativePath
113
+ * @param {string} fullPath
114
+ * @returns {Promise<Prompt>}
115
+ */
116
+ async function mergeRemotePrompt(localPrompt, relativePath, fullPath) {
117
+ const importUrl = localPrompt.import;
118
+ if (!importUrl) {
119
+ return localPrompt;
120
+ }
121
+
122
+ const fetchedContent = await fetchAndCachePrompt(importUrl).catch((err) => {
123
+ console.warn(`Failed to fetch prompt from ${importUrl}:`, err);
124
+ return null;
125
+ });
126
+
127
+ if (!fetchedContent) {
128
+ return localPrompt;
129
+ }
130
+
131
+ const remotePrompt = parsePrompt(relativePath, fetchedContent, fullPath);
132
+
133
+ return {
134
+ ...remotePrompt,
135
+ ...localPrompt, // Local overrides
136
+ content: `${remotePrompt.content}\n\n---\n\n${localPrompt.content}`.trim(),
137
+ description: localPrompt.description || remotePrompt.description || "",
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Fetch a prompt from a URL and cache it.
143
+ * @param {string} url
144
+ * @returns {Promise<string>}
145
+ */
146
+ async function fetchAndCachePrompt(url) {
147
+ const hash = crypto.createHash("sha256").update(url).digest("hex");
148
+ const cacheDir = path.join(AGENT_CACHE_DIR, "prompts");
149
+ const cachePath = path.join(cacheDir, hash);
150
+
151
+ const cachedContent = await fs.readFile(cachePath, "utf-8").catch(() => null);
152
+ if (cachedContent !== null) {
153
+ return cachedContent;
154
+ }
155
+
156
+ const fetchedContent = await fetchContent(url);
157
+
158
+ // Attempt to cache, but don't block or fail on errors
159
+ fs.mkdir(cacheDir, { recursive: true })
160
+ .then(() => fs.writeFile(cachePath, fetchedContent, "utf-8"))
161
+ .catch((err) => {
162
+ console.warn(`Failed to write cache for ${url}:`, err);
163
+ });
164
+
165
+ return fetchedContent;
166
+ }
167
+
168
+ /**
169
+ * Fetch content from a URL.
170
+ * @param {string} url
171
+ * @returns {Promise<string>}
172
+ */
173
+ async function fetchContent(url) {
174
+ const githubMatch = url.match(
175
+ /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/blob\/([^/]+)\/(.+)$/,
176
+ );
177
+
178
+ if (githubMatch) {
179
+ const [, owner, repo, ref, path] = githubMatch;
180
+ const apiUrl = `repos/${owner}/${repo}/contents/${path}?ref=${ref}`;
181
+ try {
182
+ return execFileSync(
183
+ "gh",
184
+ ["api", "-H", "Accept: application/vnd.github.v3.raw", apiUrl],
185
+ { encoding: "utf-8" },
186
+ );
187
+ } catch (err) {
188
+ throw new Error(`Failed to fetch from GitHub via gh CLI: ${err}`);
189
+ }
190
+ }
191
+
192
+ const response = await fetch(url);
193
+ if (!response.ok) {
194
+ throw new Error(
195
+ `Failed to fetch prompt from ${url}: ${response.status} ${response.statusText}`,
196
+ );
197
+ }
198
+ return response.text();
199
+ }
200
+
201
+ /**
202
+ * Recursively get all markdown files in a directory.
203
+ * @param {string} dir
204
+ * @param {string} [baseDir]
205
+ * @returns {Promise<string[]>}
206
+ */
207
+ async function getMarkdownFiles(dir, baseDir = dir) {
208
+ const entries = await fs.readdir(dir, { withFileTypes: true });
209
+ const files = [];
210
+
211
+ for (const entry of entries) {
212
+ const fullPath = path.join(dir, entry.name);
213
+ let isDirectory = entry.isDirectory();
214
+ let isFile = entry.isFile();
215
+
216
+ if (entry.isSymbolicLink()) {
217
+ const stat = await fs.stat(fullPath).catch(() => null);
218
+ if (!stat) continue;
219
+ isDirectory = stat.isDirectory();
220
+ isFile = stat.isFile();
221
+ }
222
+
223
+ if (isDirectory) {
224
+ files.push(...(await getMarkdownFiles(fullPath, baseDir)));
225
+ } else if (isFile && entry.name.endsWith(".md")) {
226
+ files.push(path.relative(baseDir, fullPath));
227
+ }
228
+ }
229
+
230
+ return files;
231
+ }
232
+
233
+ /**
234
+ * Parse a prompt file content.
235
+ * @param {string} relativePath
236
+ * @param {string} fileContent
237
+ * @param {string} fullPath
238
+ * @param {string} [idPrefix=""]
239
+ * @returns {Prompt}
240
+ */
241
+ function parsePrompt(relativePath, fileContent, fullPath, idPrefix = "") {
242
+ const rawId = relativePath.replace(/\/SKILL\.md$/, "").replace(/\.md$/, "");
243
+ const isShortcut = rawId.startsWith("shortcuts/");
244
+ const id = isShortcut
245
+ ? idPrefix + rawId.replace(/^shortcuts\//, "")
246
+ : idPrefix + rawId;
247
+
248
+ // Match YAML frontmatter
249
+ const match = fileContent.match(
250
+ /^---\r?\n([\s\S]*?)\r?\n---\r?\n?([\s\S]*)$/,
251
+ );
252
+
253
+ if (!match) {
254
+ return {
255
+ id,
256
+ description: "",
257
+ content: fileContent.trim(),
258
+ filePath: fullPath,
259
+ isShortcut,
260
+ isSkill: relativePath.endsWith("SKILL.md"),
261
+ };
262
+ }
263
+
264
+ const content = match[2].trim();
265
+
266
+ /** @type {{description?:string; import?:string; "user-invocable"?:boolean}} */
267
+ let frontmatter;
268
+ try {
269
+ frontmatter =
270
+ /** @type {{description?:string; import?:string; "user-invocable"?:boolean}} */ (
271
+ yaml.load(match[1])
272
+ );
273
+ } catch (_err) {
274
+ return {
275
+ id,
276
+ description: parseFrontmatterField(match[1], "description") ?? "",
277
+ content,
278
+ filePath: fullPath,
279
+ import: parseFrontmatterField(match[1], "import"),
280
+ userInvocable:
281
+ parseFrontmatterField(match[1], "user-invocable") === "true" ||
282
+ undefined,
283
+ isShortcut,
284
+ isSkill: relativePath.endsWith("SKILL.md"),
285
+ };
286
+ }
287
+ const userInvocable = frontmatter["user-invocable"];
288
+
289
+ return {
290
+ id,
291
+ description: frontmatter.description ?? "",
292
+ content,
293
+ filePath: fullPath,
294
+ import: frontmatter.import,
295
+ userInvocable: userInvocable ?? undefined,
296
+ isShortcut,
297
+ isSkill: relativePath.endsWith("SKILL.md"),
298
+ };
299
+ }
300
+
301
+ /**
302
+ * Parse a field from YAML frontmatter.
303
+ * @param {string} frontmatter
304
+ * @param {string} field
305
+ * @returns {string | undefined}
306
+ */
307
+
308
+ function parseFrontmatterField(frontmatter, field) {
309
+ const regex = new RegExp(`^${field}:\\s*(.*)$`, "m");
310
+ const match = frontmatter.match(regex);
311
+ return match ? match[1].trim() : undefined;
312
+ }
@@ -0,0 +1,147 @@
1
+ /**
2
+ * @import { MessageContentText, MessageContentImage } from "../model";
3
+ */
4
+
5
+ import { readFile } from "node:fs/promises";
6
+ import path from "node:path";
7
+ import { styleText } from "node:util";
8
+ import { noThrow } from "../utils/noThrow.mjs";
9
+ import { parseFileRange } from "../utils/parseFileRange.mjs";
10
+ import { readFileRange } from "../utils/readFileRange.mjs";
11
+
12
+ /** @type {ReadonlyMap<string, string>} */
13
+ const IMAGE_MIME_TYPES = new Map([
14
+ [".png", "image/png"],
15
+ [".jpg", "image/jpeg"],
16
+ [".jpeg", "image/jpeg"],
17
+ [".gif", "image/gif"],
18
+ [".webp", "image/webp"],
19
+ ]);
20
+
21
+ /**
22
+ * @param {string} message
23
+ * @returns {Promise<(MessageContentText | MessageContentImage)[]>}
24
+ */
25
+ export async function loadUserMessageContext(message) {
26
+ const workingDir = process.cwd();
27
+
28
+ /** @type {string[]} */
29
+ const text = [];
30
+ /** @type {string[]} */
31
+ const contexts = [];
32
+ /** @type {MessageContentImage[]} */
33
+ const images = [];
34
+
35
+ let cursor = 0;
36
+ for (const match of message.matchAll(
37
+ /(^|\s)@(?:'([^']+)'|((?:\\ |[^\s])+))/g,
38
+ )) {
39
+ if (cursor < match.index) {
40
+ text.push(message.slice(cursor, match.index));
41
+ }
42
+ cursor = match.index + match[0].length;
43
+ const [entireMatch, leading, quoted, escaped] = match;
44
+ const reference = quoted ?? escaped.replace(/\\ /g, " ");
45
+
46
+ const ext = path.extname(reference).toLowerCase();
47
+ if (IMAGE_MIME_TYPES.has(ext)) {
48
+ const imageContent = await loadImageContent(reference);
49
+ if (imageContent instanceof Error) {
50
+ warn(`Failed to load image from ${reference}: ${imageContent.message}`);
51
+ text.push(entireMatch);
52
+ continue;
53
+ }
54
+ images.push(imageContent);
55
+ text.push(`${leading}[Image #${images.length}:${reference}]`);
56
+ continue;
57
+ }
58
+
59
+ const contextSnippet = await loadContextSnippet(reference, workingDir);
60
+ if (contextSnippet) {
61
+ contexts.push(contextSnippet);
62
+ }
63
+ text.push(entireMatch);
64
+ }
65
+
66
+ if (cursor < message.length) {
67
+ text.push(message.slice(cursor));
68
+ }
69
+
70
+ return [
71
+ { type: "text", text: [text.join(""), ...contexts].join("\n\n") },
72
+ ...images,
73
+ ];
74
+ }
75
+
76
+ /**
77
+ * @param {string} reference
78
+ * @param {string} workingDir
79
+ * @returns {Promise<string | null>}
80
+ */
81
+ async function loadContextSnippet(reference, workingDir) {
82
+ const fileRange = parseFileRange(reference);
83
+ if (fileRange instanceof Error) {
84
+ warn(`Failed to parse context reference ${reference}: ${fileRange}`);
85
+ return null;
86
+ }
87
+
88
+ const absolutePath = path.resolve(fileRange.filePath);
89
+ const relativePath = path.relative(workingDir, absolutePath);
90
+ if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
91
+ warn(
92
+ `Refusing to load context from outside working directory: ${absolutePath}`,
93
+ );
94
+ return null;
95
+ }
96
+
97
+ const fileContent = await readFileRange(fileRange);
98
+ if (fileContent instanceof Error) {
99
+ warn(`Failed to load context from ${reference}: ${fileContent}`);
100
+ return null;
101
+ }
102
+
103
+ return [`<context location="${reference}">`, fileContent, "</context>"].join(
104
+ "\n",
105
+ );
106
+ }
107
+
108
+ /**
109
+ * @param {string} imagePath
110
+ * @returns {Promise<MessageContentImage | Error>}
111
+ */
112
+ async function loadImageContent(imagePath) {
113
+ const absolutePath = path.resolve(imagePath);
114
+
115
+ return await noThrow(async () => {
116
+ const data = await readFile(absolutePath);
117
+ return {
118
+ type: "image",
119
+ data: data.toString("base64"),
120
+ mimeType: inferMimeType(absolutePath),
121
+ };
122
+ });
123
+ }
124
+
125
+ /**
126
+ * @param {string} filePath
127
+ * @returns {string}
128
+ */
129
+ function inferMimeType(filePath) {
130
+ const extension = path.extname(filePath).toLowerCase();
131
+ const mimeType = IMAGE_MIME_TYPES.get(extension);
132
+ if (!mimeType) {
133
+ throw new Error(
134
+ `Unsupported image extension: ${extension} (file: ${filePath})`,
135
+ );
136
+ }
137
+
138
+ return mimeType;
139
+ }
140
+
141
+ /**
142
+ * @param {string} message
143
+ * @returns {void}
144
+ */
145
+ function warn(message) {
146
+ console.warn(styleText("yellow", message));
147
+ }
package/src/env.mjs ADDED
@@ -0,0 +1,46 @@
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 TRUSTED_CONFIG_HASHES_DIR = path.join(
16
+ AGENT_CACHE_DIR,
17
+ "trusted-config-hashes",
18
+ );
19
+
20
+ export const AGENT_PROJECT_METADATA_DIR = ".plain-agent";
21
+
22
+ export const AGENT_MEMORY_DIR = path.join(AGENT_PROJECT_METADATA_DIR, "memory");
23
+ export const AGENT_TMP_DIR = path.join(AGENT_PROJECT_METADATA_DIR, "tmp");
24
+
25
+ export const CLAUDE_CODE_PLUGIN_DIR = path.join(
26
+ AGENT_PROJECT_METADATA_DIR,
27
+ "claude-code-plugins",
28
+ );
29
+
30
+ export const MESSAGES_DUMP_FILE_PATH = path.join(
31
+ AGENT_PROJECT_METADATA_DIR,
32
+ "messages.json",
33
+ );
34
+
35
+ export const AGENT_NOTIFY_CMD_DEFAULT = path.join(
36
+ AGENT_ROOT,
37
+ "bin",
38
+ "plain-notify-terminal-bell",
39
+ );
40
+
41
+ export const AGENT_INTERRUPT_MESSAGE_FILE_PATH = path.join(
42
+ AGENT_PROJECT_METADATA_DIR,
43
+ "interrupt-message.txt",
44
+ );
45
+
46
+ export const USER_NAME = process.env.USER || "unknown";
package/src/main.mjs ADDED
@@ -0,0 +1,202 @@
1
+ /**
2
+ * @import { Tool } from "./tool";
3
+ */
4
+
5
+ import { styleText } from "node:util";
6
+ import { createAgent } from "./agent.mjs";
7
+ import { parseCliArgs, printHelp } from "./cliArgs.mjs";
8
+ import { startInteractiveSession } from "./cliInteractive.mjs";
9
+ import { loadAppConfig } from "./config.mjs";
10
+ import { loadAgentRoles } from "./context/loadAgentRoles.mjs";
11
+ import { loadPrompts } from "./context/loadPrompts.mjs";
12
+ import {
13
+ AGENT_NOTIFY_CMD_DEFAULT,
14
+ AGENT_PROJECT_METADATA_DIR,
15
+ USER_NAME,
16
+ } from "./env.mjs";
17
+ import { setupMCPServer } from "./mcp.mjs";
18
+ import { createModelCaller } from "./modelCaller.mjs";
19
+ import { createPrompt } from "./prompt.mjs";
20
+ import { createAskGoogleTool } from "./tools/askGoogle.mjs";
21
+ import { createDelegateToSubagentTool } from "./tools/delegateToSubagent.mjs";
22
+ import { createExecCommandTool } from "./tools/execCommand.mjs";
23
+ import { fetchWebPageTool } from "./tools/fetchWebPage.mjs";
24
+ import { patchFileTool } from "./tools/patchFile.mjs";
25
+ import { createReportAsSubagentTool } from "./tools/reportAsSubagent.mjs";
26
+ import { createTavilySearchTool } from "./tools/tavilySearch.mjs";
27
+ import { createTmuxCommandTool } from "./tools/tmuxCommand.mjs";
28
+ import { writeFileTool } from "./tools/writeFile.mjs";
29
+ import { createToolUseApprover } from "./toolUseApprover.mjs";
30
+
31
+ const cliArgs = parseCliArgs(process.argv);
32
+ if (cliArgs.showHelp) {
33
+ printHelp();
34
+ }
35
+
36
+ (async () => {
37
+ const startTime = new Date();
38
+ const sessionId = [
39
+ startTime.toISOString().slice(0, 10),
40
+ `0${startTime.getHours()}`.slice(-2) +
41
+ `0${startTime.getMinutes()}`.slice(-2),
42
+ ].join("-");
43
+ const tmuxSessionId = `agent-${sessionId}`;
44
+ const { appConfig, loadedConfigPath } = await loadAppConfig();
45
+
46
+ if (loadedConfigPath.length > 0) {
47
+ console.log(styleText("green", "\nâš¡ Loaded configuration files"));
48
+ console.log(loadedConfigPath.map((p) => ` ⤷ ${p}`).join("\n"));
49
+ }
50
+
51
+ if (appConfig.sandbox) {
52
+ const sandboxStr = [
53
+ appConfig.sandbox.command,
54
+ ...(appConfig.sandbox.args || []),
55
+ ].join(" ");
56
+ console.log(styleText("green", "\n📦 Sandbox: on"));
57
+ console.log(` ⤷ ${sandboxStr}`);
58
+ } else {
59
+ console.log(styleText("yellow", "\n📦 Sandbox: off"));
60
+ }
61
+
62
+ /** @type {(() => Promise<void>)[]} */
63
+ const mcpCleanups = [];
64
+
65
+ /** @type {Tool[]} */
66
+ const mcpTools = [];
67
+ if (appConfig.mcpServers) {
68
+ const mcpServerEntries = Object.entries(appConfig.mcpServers);
69
+
70
+ console.log();
71
+ for (const [serverName] of mcpServerEntries) {
72
+ console.log(
73
+ styleText("blue", `🔌 Connecting to MCP server: ${serverName}...`),
74
+ );
75
+ }
76
+
77
+ const mcpResults = await Promise.all(
78
+ mcpServerEntries.map(async ([serverName, serverConfig]) => {
79
+ const result = await setupMCPServer(serverName, serverConfig);
80
+ return { serverName, ...result };
81
+ }),
82
+ );
83
+
84
+ for (const { serverName, tools, cleanup } of mcpResults) {
85
+ mcpTools.push(...tools);
86
+ mcpCleanups.push(cleanup);
87
+ console.log(
88
+ styleText(
89
+ "green",
90
+ `✅ Successfully connected to MCP server: ${serverName}`,
91
+ ),
92
+ );
93
+ }
94
+ }
95
+
96
+ const modelNameWithVariant = cliArgs.model || appConfig.model || "";
97
+ const agentRoles = await loadAgentRoles(appConfig.claudeCodePlugins);
98
+ const prompts = await loadPrompts(appConfig.claudeCodePlugins);
99
+
100
+ const prompt = createPrompt({
101
+ username: USER_NAME,
102
+ modelName: modelNameWithVariant,
103
+ sessionId,
104
+ tmuxSessionId,
105
+ workingDir: process.cwd(),
106
+ projectMetadataDir: AGENT_PROJECT_METADATA_DIR,
107
+ agentRoles,
108
+ skills: Array.from(prompts.values()).filter((p) => p.isSkill),
109
+ });
110
+
111
+ const builtinTools = [
112
+ createExecCommandTool({ sandbox: appConfig.sandbox }),
113
+ writeFileTool,
114
+ patchFileTool,
115
+ createTmuxCommandTool({ sandbox: appConfig.sandbox }),
116
+ fetchWebPageTool,
117
+ createDelegateToSubagentTool(),
118
+ createReportAsSubagentTool(),
119
+ ];
120
+
121
+ if (appConfig.tools?.tavily?.apiKey) {
122
+ builtinTools.push(createTavilySearchTool(appConfig.tools.tavily));
123
+ }
124
+
125
+ if (appConfig.tools?.askGoogle) {
126
+ builtinTools.push(
127
+ createAskGoogleTool({
128
+ platform: appConfig.tools.askGoogle.platform,
129
+ baseURL: appConfig.tools.askGoogle.baseURL,
130
+ apiKey: appConfig.tools.askGoogle.apiKey,
131
+ }),
132
+ );
133
+ }
134
+
135
+ const toolUseApprover = createToolUseApprover({
136
+ maxApprovals: appConfig.autoApproval?.maxApprovals || 50,
137
+ defaultAction: appConfig.autoApproval?.defaultAction || "ask",
138
+ patterns: appConfig.autoApproval?.patterns || [],
139
+ maskApprovalInput: (toolName, input) => {
140
+ for (const tool of builtinTools) {
141
+ if (tool.def.name === toolName && tool.maskApprovalInput) {
142
+ return tool.maskApprovalInput(input);
143
+ }
144
+ }
145
+ return input;
146
+ },
147
+ });
148
+
149
+ const [modelName, modelVariant] = modelNameWithVariant.split("+");
150
+ const modelDef = (appConfig.models ?? []).find(
151
+ (entry) => entry.name === modelName && entry.variant === modelVariant,
152
+ );
153
+ if (!modelDef) {
154
+ throw new Error(
155
+ `Model "${modelNameWithVariant}" not found in configuration.`,
156
+ );
157
+ }
158
+
159
+ const platform = (appConfig.platforms ?? []).find(
160
+ (entry) =>
161
+ entry.name === modelDef.platform.name &&
162
+ entry.variant === modelDef.platform.variant,
163
+ );
164
+ if (!platform) {
165
+ throw new Error(
166
+ `Platform ${modelDef.platform.name} variant=${modelDef.platform.variant} not found in configuration.`,
167
+ );
168
+ }
169
+
170
+ const { userEventEmitter, agentEventEmitter, agentCommands } = createAgent({
171
+ callModel: createModelCaller({
172
+ ...modelDef,
173
+ platform: {
174
+ ...modelDef.platform,
175
+ ...platform,
176
+ },
177
+ }),
178
+ prompt,
179
+ tools: [...builtinTools, ...mcpTools],
180
+ toolUseApprover,
181
+ agentRoles,
182
+ });
183
+
184
+ startInteractiveSession({
185
+ userEventEmitter,
186
+ agentEventEmitter,
187
+ agentCommands,
188
+ sessionId,
189
+ modelName: modelNameWithVariant,
190
+ notifyCmd: appConfig.notifyCmd || AGENT_NOTIFY_CMD_DEFAULT,
191
+ sandbox: Boolean(appConfig.sandbox),
192
+ onStop: async () => {
193
+ for (const cleanup of mcpCleanups) {
194
+ await cleanup();
195
+ }
196
+ },
197
+ claudeCodePlugins: appConfig.claudeCodePlugins,
198
+ });
199
+ })().catch((err) => {
200
+ console.error(err);
201
+ process.exit(1);
202
+ });