@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,183 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import fs from "node:fs";
3
+ import path from "node:path";
4
+ import {
5
+ AGENT_MEMORY_DIR,
6
+ AGENT_TMP_DIR,
7
+ CLAUDE_CODE_PLUGIN_DIR,
8
+ } from "./env.mjs";
9
+ import { noThrowSync } from "./utils/noThrow.mjs";
10
+
11
+ /**
12
+ * @param {unknown} input
13
+ * @returns {boolean}
14
+ */
15
+ export function isSafeToolInput(input) {
16
+ if (["number", "boolean", "undefined"].includes(typeof input)) {
17
+ return true;
18
+ }
19
+
20
+ if (typeof input === "string") {
21
+ return isSafeToolInputItem(input);
22
+ }
23
+
24
+ if (Array.isArray(input)) {
25
+ return input.every((item) => isSafeToolInput(item));
26
+ }
27
+
28
+ if (typeof input === "object") {
29
+ if (input === null) {
30
+ return true;
31
+ }
32
+ return Object.values(input).every((value) => isSafeToolInput(value));
33
+ }
34
+
35
+ return false;
36
+ }
37
+
38
+ /**
39
+ * @param {string} arg
40
+ * @returns {boolean}
41
+ */
42
+ export function isSafeToolInputItem(arg) {
43
+ const workingDir = process.cwd();
44
+
45
+ // Note: An argument can be a command option (e.g., '-l').
46
+ // It will then create an absolute path like `/path/to/project/-l`.
47
+ const absPath = path.resolve(arg);
48
+
49
+ const realPath = resolveRealPath(absPath, workingDir);
50
+ if (!realPath) {
51
+ return false;
52
+ }
53
+
54
+ // Disallow paths outside the working directory (WITHOUT EXCEPTION)
55
+ if (!isInsideWorkingDirectory(realPath, workingDir)) {
56
+ return false;
57
+ }
58
+
59
+ // Disallow any input that contains ".." as a path segment (directory traversal)
60
+ // Example:
61
+ // - When write_file is allowed for ^safe-dir/.+
62
+ // - "safe-dir/../unsafe-path" should be disallowed
63
+ if (arg.split(path.sep).includes("..")) {
64
+ return false;
65
+ }
66
+
67
+ // Allow safe path even if git-ignored.
68
+ if (isSafePath(realPath)) {
69
+ return true;
70
+ }
71
+
72
+ // Deny git ignored files (which may contain sensitive information or should not be accessed)
73
+ return !isGitIgnored(realPath);
74
+ }
75
+
76
+ /**
77
+ * @param {string} absPath
78
+ * @param {string} workingDir
79
+ * @returns {string | null}
80
+ */
81
+ function resolveRealPath(absPath, workingDir) {
82
+ const realPathResult = noThrowSync(() => fs.realpathSync(absPath));
83
+ if (!(realPathResult instanceof Error)) {
84
+ return realPathResult;
85
+ }
86
+
87
+ // realpathSync can fail if the path (or its target) doesn't exist.
88
+ // Manually follow symlink chain for broken links to ensure they don't point outside.
89
+ let currentPath = absPath;
90
+ const seen = new Set();
91
+ const MAX_SYMLINK_DEPTH = 10;
92
+
93
+ for (let depth = 0; depth < MAX_SYMLINK_DEPTH; depth++) {
94
+ if (seen.has(currentPath)) {
95
+ return null; // Circular link
96
+ }
97
+ seen.add(currentPath);
98
+
99
+ // Check if the current path is a symbolic link.
100
+ const lstats = noThrowSync(() => fs.lstatSync(currentPath));
101
+ if (lstats instanceof Error || !lstats.isSymbolicLink()) {
102
+ break; // Not a symlink or doesn't exist; stop traversal.
103
+ }
104
+
105
+ // Read the target path the symlink points to.
106
+ const target = noThrowSync(() => fs.readlinkSync(currentPath));
107
+ if (typeof target !== "string") {
108
+ break; // Failed to read the link; stop traversal.
109
+ }
110
+
111
+ currentPath = path.resolve(path.dirname(currentPath), target);
112
+
113
+ // If at any point it goes outside, we stop and use this path for the check.
114
+ if (!isInsideWorkingDirectory(currentPath, workingDir)) {
115
+ return currentPath;
116
+ }
117
+ }
118
+
119
+ if (seen.size >= MAX_SYMLINK_DEPTH) {
120
+ return null; // Too deep
121
+ }
122
+
123
+ return currentPath;
124
+ }
125
+
126
+ /**
127
+ * @param {string} targetPath
128
+ * @param {string} workingDir
129
+ * @returns {boolean}
130
+ */
131
+ function isInsideWorkingDirectory(targetPath, workingDir) {
132
+ return (
133
+ targetPath === workingDir ||
134
+ targetPath.startsWith(`${workingDir}${path.sep}`)
135
+ );
136
+ }
137
+
138
+ /**
139
+ * @param {string} targetPath
140
+ * @returns {boolean}
141
+ */
142
+ function isSafePath(targetPath) {
143
+ const safePaths = [AGENT_MEMORY_DIR, AGENT_TMP_DIR, CLAUDE_CODE_PLUGIN_DIR];
144
+
145
+ for (const safePath of safePaths) {
146
+ const safeAbsPath = path.resolve(safePath);
147
+ if (
148
+ targetPath === safeAbsPath ||
149
+ targetPath.startsWith(`${safeAbsPath}${path.sep}`)
150
+ ) {
151
+ return true;
152
+ }
153
+ }
154
+
155
+ return false;
156
+ }
157
+
158
+ /**
159
+ * @param {string} absPath
160
+ * @returns {boolean}
161
+ */
162
+ function isGitIgnored(absPath) {
163
+ try {
164
+ execFileSync("git", ["check-ignore", "--no-index", "-q", absPath], {
165
+ stdio: ["ignore", "ignore", "ignore"],
166
+ });
167
+ // The path is ignored (exit code 0)
168
+ return true;
169
+ } catch (error) {
170
+ if (
171
+ error instanceof Error &&
172
+ "status" in error &&
173
+ typeof error.status === "number" &&
174
+ error.status === 1
175
+ ) {
176
+ // Path is not ignored
177
+ return false;
178
+ }
179
+ // Other errors (e.g., status 128 if not a git repo or other git error)
180
+ // We treat this as "effectively ignored" to be safe.
181
+ return true;
182
+ }
183
+ }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * @import { ToolUseApprover, ToolUseApproverConfig, ToolUseDecision, ToolUsePattern } from './tool'
3
+ * @import { MessageContentToolUse } from './model'
4
+ */
5
+
6
+ import { isSafeToolInput } from "./toolInputValidator.mjs";
7
+ import { matchValue } from "./utils/matchValue.mjs";
8
+
9
+ /**
10
+ * @param {ToolUseApproverConfig} config
11
+ * @returns {ToolUseApprover}
12
+ */
13
+ export function createToolUseApprover({
14
+ patterns,
15
+ maxApprovals: max,
16
+ defaultAction,
17
+ maskApprovalInput,
18
+ }) {
19
+ const state = {
20
+ approvalCount: 0,
21
+ /** @type {ToolUsePattern[]} */
22
+ allowedToolUseInSession: [],
23
+ };
24
+
25
+ /** @returns {void} */
26
+ function resetApprovalCount() {
27
+ state.approvalCount = 0;
28
+ }
29
+
30
+ /**
31
+ * @param {MessageContentToolUse} toolUse
32
+ * @returns {ToolUseDecision}
33
+ */
34
+ function isAllowedToolUse(toolUse) {
35
+ const toolUseToMatch = {
36
+ toolName: toolUse.toolName,
37
+ input: toolUse.input,
38
+ };
39
+
40
+ for (const pattern of [...patterns, ...state.allowedToolUseInSession]) {
41
+ const patternToMatch = {
42
+ toolName: pattern.toolName,
43
+ ...(pattern.input !== undefined && { input: pattern.input }),
44
+ };
45
+
46
+ if (!matchValue(toolUseToMatch, patternToMatch)) {
47
+ continue;
48
+ }
49
+
50
+ const action = pattern.action ?? defaultAction;
51
+
52
+ if (!["allow", "deny", "ask"].includes(action)) {
53
+ return {
54
+ action: "ask",
55
+ };
56
+ }
57
+
58
+ if (action === "deny") {
59
+ return {
60
+ action: "deny",
61
+ reason: pattern.reason,
62
+ };
63
+ }
64
+
65
+ if (action === "allow") {
66
+ const maskedInput = maskApprovalInput(toolUse.toolName, toolUse.input);
67
+ if (isSafeToolInput(maskedInput)) {
68
+ state.approvalCount += 1;
69
+ return state.approvalCount <= max
70
+ ? { action: "allow" }
71
+ : { action: "ask" };
72
+ }
73
+ }
74
+
75
+ return { action };
76
+ }
77
+
78
+ return { action: defaultAction };
79
+ }
80
+
81
+ /**
82
+ * @param {MessageContentToolUse} toolUse
83
+ * @returns {void}
84
+ */
85
+ function allowToolUse(toolUse) {
86
+ state.allowedToolUseInSession.push({
87
+ toolName: toolUse.toolName,
88
+ input: maskApprovalInput(toolUse.toolName, toolUse.input),
89
+ action: "allow",
90
+ });
91
+ }
92
+
93
+ return {
94
+ isAllowedToolUse,
95
+ allowToolUse,
96
+ resetApprovalCount,
97
+ };
98
+ }
@@ -0,0 +1,135 @@
1
+ /**
2
+ * @import { Tool } from '../tool'
3
+ */
4
+
5
+ import { styleText } from "node:util";
6
+ import { getGoogleCloudAccessToken } from "../providers/platform/googleCloud.mjs";
7
+ import { noThrow } from "../utils/noThrow.mjs";
8
+
9
+ /**
10
+ * @typedef {Object} AskGoogleToolOptions
11
+ * @property {"vertex-ai"=} platform
12
+ * @property {string=} baseURL
13
+ * @property {string=} apiKey - API key for Google AI Studio
14
+ * @property {string=} account - The Google Cloud account to use for Vertex AI
15
+ * @property {string=} model
16
+ */
17
+
18
+ /**
19
+ * @typedef {Object} AskGoogleInput
20
+ * @property {string} question
21
+ */
22
+
23
+ /**
24
+ * @param {AskGoogleToolOptions} config
25
+ * @returns {Tool}
26
+ */
27
+ export function createAskGoogleTool(config) {
28
+ /**
29
+ * @param {AskGoogleInput} input
30
+ * @param {number} retryCount
31
+ * @returns {Promise<string | Error>}
32
+ */
33
+ async function askGoogle(input, retryCount = 0) {
34
+ const model = config.model ?? "gemini-3-flash-preview";
35
+ const url =
36
+ config.platform === "vertex-ai" && config.baseURL
37
+ ? `${config.baseURL}/publishers/google/models/${model}:generateContent`
38
+ : config.baseURL
39
+ ? `${config.baseURL}/models/${model}:generateContent`
40
+ : `https://generativelanguage.googleapis.com/v1beta/models/${model}:generateContent`;
41
+
42
+ /** @type {Record<string,string>} */
43
+ const authHeader =
44
+ config.platform === "vertex-ai"
45
+ ? {
46
+ Authorization: `Bearer ${await getGoogleCloudAccessToken(config.account)}`,
47
+ }
48
+ : {
49
+ "x-goog-api-key": config.apiKey ?? "",
50
+ };
51
+
52
+ const data = {
53
+ contents: [
54
+ {
55
+ role: "user",
56
+ parts: [
57
+ {
58
+ text: `I need a comprehensive answer to this question. Please note that I don't have access to external URLs, so include all relevant facts, data, or explanations directly in your response. Avoid referencing links I can't open.
59
+
60
+ Question: ${input.question}`,
61
+ },
62
+ ],
63
+ },
64
+ ],
65
+ tools: [
66
+ {
67
+ google_search: {},
68
+ },
69
+ ],
70
+ };
71
+
72
+ const response = await fetch(url, {
73
+ method: "POST",
74
+ headers: {
75
+ ...authHeader,
76
+ "Content-Type": "application/json",
77
+ },
78
+ body: JSON.stringify(data),
79
+ signal: AbortSignal.timeout(120 * 1000),
80
+ });
81
+
82
+ if (response.status === 429 || response.status >= 500) {
83
+ const interval = Math.min(2 * 2 ** retryCount, 16);
84
+ console.error(
85
+ styleText(
86
+ "yellow",
87
+ `Google API returned ${response.status}. Retrying in ${interval} seconds...`,
88
+ ),
89
+ );
90
+ await new Promise((resolve) => setTimeout(resolve, interval * 1000));
91
+ return askGoogle(input, retryCount + 1);
92
+ }
93
+
94
+ if (!response.ok) {
95
+ return new Error(
96
+ `Failed to ask Google: status=${response.status}, body=${await response.text()}`,
97
+ );
98
+ }
99
+
100
+ const body = await response.json();
101
+
102
+ const answer = body.candidates?.[0]?.content?.parts?.[0]?.text;
103
+
104
+ if (typeof answer !== "string") {
105
+ return new Error(
106
+ `Unexpected response format from Google: ${JSON.stringify(body)}`,
107
+ );
108
+ }
109
+
110
+ return answer;
111
+ }
112
+
113
+ return {
114
+ def: {
115
+ name: "ask_google",
116
+ description: "Ask Google a question using natural language",
117
+ inputSchema: {
118
+ type: "object",
119
+ properties: {
120
+ question: {
121
+ type: "string",
122
+ description: "The question to ask Google",
123
+ },
124
+ },
125
+ required: ["question"],
126
+ },
127
+ },
128
+
129
+ /**
130
+ * @param {AskGoogleInput} input
131
+ * @returns {Promise<string | Error>}
132
+ */
133
+ impl: async (input) => await noThrow(async () => askGoogle(input, 0)),
134
+ };
135
+ }
@@ -0,0 +1,4 @@
1
+ export type DelegateToSubagentInput = {
2
+ name: string;
3
+ goal: string;
4
+ };
@@ -0,0 +1,48 @@
1
+ /**
2
+ * @import { Tool, ToolImplementation } from '../tool'
3
+ */
4
+
5
+ export const delegateToSubagentToolName = "delegate_to_subagent";
6
+
7
+ /** @returns {Tool} */
8
+ export function createDelegateToSubagentTool() {
9
+ /** @type {ToolImplementation} */
10
+ let impl = async () => {
11
+ throw new Error("Not implemented");
12
+ };
13
+
14
+ /** @type {Tool} */
15
+ const tool = {
16
+ def: {
17
+ name: delegateToSubagentToolName,
18
+ description:
19
+ "Delegate a subtask to a subagent. You inherit the current context and work on the delegated goal.",
20
+ inputSchema: {
21
+ type: "object",
22
+ properties: {
23
+ name: {
24
+ type: "string",
25
+ description:
26
+ "Role or name of the subagent. Use 'custom:' prefix for ad-hoc roles.",
27
+ },
28
+ goal: {
29
+ type: "string",
30
+ description: "The goal or task for the subagent to achieve.",
31
+ },
32
+ },
33
+ required: ["name", "goal"],
34
+ },
35
+ },
36
+
37
+ // Implementation will be injected by the agent to access its state
38
+ get impl() {
39
+ return impl;
40
+ },
41
+
42
+ injectImpl(fn) {
43
+ impl = fn;
44
+ },
45
+ };
46
+
47
+ return tool;
48
+ }
@@ -0,0 +1,22 @@
1
+ export type ExecCommandInput = {
2
+ command: string;
3
+ args?: string[];
4
+ };
5
+
6
+ export type ExecCommandConfig = {
7
+ sandbox?: ExecCommandSanboxConfig;
8
+ };
9
+
10
+ export type ExecCommandSanboxConfig = {
11
+ command: string;
12
+ args?: string[];
13
+ separator?: string;
14
+ rules?: {
15
+ pattern: {
16
+ command: string;
17
+ args?: string[];
18
+ };
19
+ mode: "sandbox" | "unsandboxed";
20
+ additionalArgs?: string[];
21
+ }[];
22
+ };
@@ -0,0 +1,200 @@
1
+ /**
2
+ * @import { Tool } from '../tool'
3
+ * @import { ExecCommandConfig, ExecCommandInput, ExecCommandSanboxConfig } from './execCommand'
4
+ */
5
+
6
+ import { execFile } from "node:child_process";
7
+ import { writeTmpFile } from "../tmpfile.mjs";
8
+ import { matchValue } from "../utils/matchValue.mjs";
9
+ import { noThrow } from "../utils/noThrow.mjs";
10
+
11
+ const OUTPUT_MAX_LENGTH = 1024 * 8;
12
+ const OUTPUT_TRUNCATED_LENGTH = 1024 * 2;
13
+
14
+ /**
15
+ * @param {ExecCommandConfig=} config
16
+ * @returns {Tool}
17
+ */
18
+ export function createExecCommandTool(config) {
19
+ /** @type {Tool} */
20
+ return {
21
+ def: {
22
+ name: "exec_command",
23
+ description: "Run a command without shell interpretation.",
24
+ inputSchema: {
25
+ type: "object",
26
+ properties: {
27
+ command: {
28
+ description: "The executable name or path. e.g., rg",
29
+ type: "string",
30
+ },
31
+ args: {
32
+ // Gemini 3 flashが command: rg, args: [rg, ...] のようにargsにコマンドを含めることがある
33
+ description:
34
+ "Array of arguments to pass to the command. Do not include the command name itself in this array.",
35
+ type: "array",
36
+ items: {
37
+ type: "string",
38
+ },
39
+ },
40
+ },
41
+ required: ["command"],
42
+ },
43
+ },
44
+
45
+ validateInput: (input) => {
46
+ if (typeof input.command !== "string") {
47
+ return new Error("command must be a string");
48
+ }
49
+
50
+ // Example: fd<arg_key>args</arg_key><arg_value>[... (GLM-5)
51
+ if (input.command.match(/[<>]/)) {
52
+ return new Error(
53
+ `invalid tool use format: command=${JSON.stringify(input.command)}`,
54
+ );
55
+ }
56
+
57
+ if (input.command.startsWith("-")) {
58
+ return new Error("command must not start with '-'");
59
+ }
60
+
61
+ if (input.args && !Array.isArray(input.args)) {
62
+ return new Error("args must be an array of strings");
63
+ }
64
+
65
+ return;
66
+ },
67
+
68
+ /**
69
+ * @param {ExecCommandInput} input
70
+ * @returns {Promise<string | Error>}
71
+ */
72
+ impl: async (input) =>
73
+ await noThrow(async () => {
74
+ const { command, args } = config?.sandbox
75
+ ? rewriteInputForSandbox(input, config.sandbox)
76
+ : input;
77
+ return new Promise((resolve, _reject) => {
78
+ const child = execFile(
79
+ command,
80
+ args,
81
+ {
82
+ shell: false,
83
+ env: {
84
+ PWD: process.env.PWD,
85
+ PATH: process.env.PATH,
86
+ HOME: process.env.HOME,
87
+ },
88
+ timeout: 5 * 60 * 1000,
89
+ },
90
+ async (err, stdout, stderr) => {
91
+ /**
92
+ * @param {string} content
93
+ * @param {string} type
94
+ * @returns {Promise<string>}
95
+ */
96
+ const formatOutput = async (content, type) => {
97
+ if (content.length <= OUTPUT_MAX_LENGTH) {
98
+ return content;
99
+ }
100
+
101
+ let fileExtension = "txt";
102
+ try {
103
+ JSON.parse(content);
104
+ fileExtension = "json";
105
+ } catch {
106
+ // not JSON
107
+ }
108
+
109
+ const prefix = `exec_command-${type}`;
110
+ const filePath = await writeTmpFile(
111
+ content,
112
+ prefix,
113
+ fileExtension,
114
+ );
115
+ const lineCount = content.split("\n").length;
116
+
117
+ const head = content.slice(0, OUTPUT_TRUNCATED_LENGTH);
118
+ const tail = content.slice(
119
+ Math.max(content.length - OUTPUT_TRUNCATED_LENGTH, 0),
120
+ );
121
+
122
+ return [
123
+ `Content is too large (${content.length} characters, ${lineCount} lines). Saved to ${filePath}.`,
124
+ `<truncated_output part="start" length="${OUTPUT_TRUNCATED_LENGTH}" total_length="${content.length}">\n${head}\n</truncated_output>`,
125
+ `<truncated_output part="end" length="${OUTPUT_TRUNCATED_LENGTH}" total_length="${content.length}">\n${tail}</truncated_output>\n`,
126
+ ].join("\n\n");
127
+ };
128
+
129
+ const stdoutOrMessage = await formatOutput(stdout, "stdout");
130
+ const stderrOrMessage = await formatOutput(stderr, "stderr");
131
+
132
+ const result = [
133
+ stdoutOrMessage
134
+ ? `<stdout>\n${stdoutOrMessage}</stdout>`
135
+ : "<stdout></stdout>",
136
+ "",
137
+ stderrOrMessage
138
+ ? `<stderr>\n${stderrOrMessage}</stderr>`
139
+ : "<stderr></stderr>",
140
+ ];
141
+
142
+ if (err) {
143
+ // rg: 何もマッチしない場合は exit status != 0 になるので無視
144
+ const ignoreError = [command, ...(args || [])].includes("rg");
145
+ if (!ignoreError) {
146
+ // err.message が長過ぎる場合は先頭を表示
147
+ const errMessageTruncated = err.message.slice(
148
+ 0,
149
+ OUTPUT_TRUNCATED_LENGTH,
150
+ );
151
+ const isErrMessageTruncated =
152
+ err.message.length > OUTPUT_MAX_LENGTH;
153
+ result.push(
154
+ `\n<error>\n${err.name}: ${errMessageTruncated}${isErrMessageTruncated ? "... (Message truncated)" : ""}</error>`,
155
+ );
156
+ }
157
+ }
158
+ return resolve(result.join("\n"));
159
+ },
160
+ );
161
+ child.stdin?.end();
162
+ });
163
+ }),
164
+ };
165
+ }
166
+
167
+ /**
168
+ * @param {ExecCommandInput} input
169
+ * @param {ExecCommandSanboxConfig} sandbox
170
+ * @returns {ExecCommandInput}
171
+ */
172
+ function rewriteInputForSandbox(input, sandbox) {
173
+ const matchedRule = (sandbox.rules || []).find((rule) =>
174
+ matchValue(input, rule.pattern),
175
+ );
176
+
177
+ if (matchedRule?.mode === "unsandboxed") {
178
+ return input;
179
+ }
180
+
181
+ const args = [
182
+ ...(sandbox.args || []),
183
+ ...(matchedRule?.additionalArgs || []),
184
+ ];
185
+
186
+ if (sandbox.separator) {
187
+ args.push(sandbox.separator);
188
+ }
189
+
190
+ args.push(input.command);
191
+
192
+ if (input.args) {
193
+ args.push(...input.args);
194
+ }
195
+
196
+ return {
197
+ command: sandbox.command,
198
+ args,
199
+ };
200
+ }