@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.
- package/bin/plain +1 -1
- package/package.json +7 -5
- package/sandbox/bin/plain-sandbox +13 -0
- package/src/agent.d.ts +52 -0
- package/src/agent.mjs +204 -0
- package/src/agentLoop.mjs +419 -0
- package/src/agentState.mjs +41 -0
- package/src/claudeCodePlugin.mjs +164 -0
- package/src/cliArgs.mjs +175 -0
- package/src/cliBatch.mjs +147 -0
- package/src/cliCommands.mjs +283 -0
- package/src/cliCompleter.mjs +227 -0
- package/src/cliCost.mjs +309 -0
- package/src/cliFormatter.mjs +413 -0
- package/src/cliInteractive.mjs +529 -0
- package/src/cliInterruptTransform.mjs +51 -0
- package/src/cliMuteTransform.mjs +26 -0
- package/src/cliPasteTransform.mjs +183 -0
- package/src/config.d.ts +36 -0
- package/src/config.mjs +197 -0
- package/src/context/loadAgentRoles.mjs +294 -0
- package/src/context/loadPrompts.mjs +337 -0
- package/src/context/loadUserMessageContext.mjs +147 -0
- package/src/costTracker.mjs +210 -0
- package/src/env.mjs +44 -0
- package/src/main.mjs +281 -0
- package/src/mcpClient.mjs +351 -0
- package/src/mcpIntegration.mjs +160 -0
- package/src/model.d.ts +109 -0
- package/src/modelCaller.mjs +32 -0
- package/src/modelDefinition.d.ts +92 -0
- package/src/prompt.mjs +138 -0
- package/src/providers/anthropic.d.ts +248 -0
- package/src/providers/anthropic.mjs +587 -0
- package/src/providers/bedrock.d.ts +249 -0
- package/src/providers/bedrock.mjs +700 -0
- package/src/providers/gemini.d.ts +208 -0
- package/src/providers/gemini.mjs +754 -0
- package/src/providers/openai.d.ts +281 -0
- package/src/providers/openai.mjs +544 -0
- package/src/providers/openaiCompatible.d.ts +147 -0
- package/src/providers/openaiCompatible.mjs +652 -0
- package/src/providers/platform/awsSigV4.mjs +184 -0
- package/src/providers/platform/azure.mjs +42 -0
- package/src/providers/platform/bedrock.mjs +78 -0
- package/src/providers/platform/googleCloud.mjs +34 -0
- package/src/subagent.mjs +265 -0
- package/src/tmpfile.mjs +27 -0
- package/src/tool.d.ts +74 -0
- package/src/toolExecutor.mjs +236 -0
- package/src/toolInputValidator.mjs +183 -0
- package/src/toolUseApprover.mjs +99 -0
- package/src/tools/askURL.mjs +209 -0
- package/src/tools/askWeb.mjs +208 -0
- package/src/tools/compactContext.d.ts +4 -0
- package/src/tools/compactContext.mjs +87 -0
- package/src/tools/execCommand.d.ts +22 -0
- package/src/tools/execCommand.mjs +200 -0
- package/src/tools/patchFile.d.ts +4 -0
- package/src/tools/patchFile.mjs +133 -0
- package/src/tools/switchToMainAgent.d.ts +3 -0
- package/src/tools/switchToMainAgent.mjs +43 -0
- package/src/tools/switchToSubagent.d.ts +4 -0
- package/src/tools/switchToSubagent.mjs +59 -0
- package/src/tools/tmuxCommand.d.ts +14 -0
- package/src/tools/tmuxCommand.mjs +194 -0
- package/src/tools/writeFile.d.ts +4 -0
- package/src/tools/writeFile.mjs +56 -0
- package/src/usageStore.mjs +167 -0
- package/src/utils/evalJSONConfig.mjs +72 -0
- package/src/utils/matchValue.d.ts +6 -0
- package/src/utils/matchValue.mjs +40 -0
- package/src/utils/noThrow.mjs +31 -0
- package/src/utils/notify.mjs +29 -0
- package/src/utils/parseFileRange.mjs +18 -0
- package/src/utils/readFileRange.mjs +33 -0
- package/src/utils/retryOnError.mjs +41 -0
- package/src/voiceInput.mjs +61 -0
- package/src/voiceInputGemini.mjs +105 -0
- package/src/voiceInputOpenAI.mjs +104 -0
- package/src/voiceInputSession.mjs +543 -0
- package/src/voiceToggleKey.mjs +62 -0
- package/dist/main.mjs +0 -473
- package/dist/main.mjs.map +0 -7
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { Tool } from '../tool'
|
|
3
|
+
* @import { PatchFileInput } from './patchFile'
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fs from "node:fs/promises";
|
|
7
|
+
import { noThrow } from "../utils/noThrow.mjs";
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* @param {string} [nonce]
|
|
11
|
+
* @returns {Tool}
|
|
12
|
+
*/
|
|
13
|
+
export function createPatchFileTool(
|
|
14
|
+
nonce = Math.random().toString(36).substring(2, 5),
|
|
15
|
+
) {
|
|
16
|
+
return {
|
|
17
|
+
def: {
|
|
18
|
+
name: "patch_file",
|
|
19
|
+
description:
|
|
20
|
+
"Modify a file by replacing specific content with new content.",
|
|
21
|
+
inputSchema: {
|
|
22
|
+
type: "object",
|
|
23
|
+
properties: {
|
|
24
|
+
filePath: {
|
|
25
|
+
type: "string",
|
|
26
|
+
},
|
|
27
|
+
diff: {
|
|
28
|
+
description: `
|
|
29
|
+
Format:
|
|
30
|
+
<<< ${nonce} <<< SEARCH
|
|
31
|
+
old content
|
|
32
|
+
=== ${nonce} ===
|
|
33
|
+
new content
|
|
34
|
+
>>> ${nonce} >>> REPLACE
|
|
35
|
+
|
|
36
|
+
<<< ${nonce} <<< SEARCH
|
|
37
|
+
other old content
|
|
38
|
+
=== ${nonce} ===
|
|
39
|
+
other new content
|
|
40
|
+
>>> ${nonce} >>> REPLACE
|
|
41
|
+
|
|
42
|
+
- Content is searched as an exact match including indentation and line breaks.
|
|
43
|
+
- The first match found will be replaced if there are multiple matches.
|
|
44
|
+
`.trim(),
|
|
45
|
+
type: "string",
|
|
46
|
+
},
|
|
47
|
+
},
|
|
48
|
+
required: ["filePath", "diff"],
|
|
49
|
+
},
|
|
50
|
+
},
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {PatchFileInput} input
|
|
54
|
+
* @returns {Promise<string | Error>}
|
|
55
|
+
*/
|
|
56
|
+
impl: async (input) =>
|
|
57
|
+
await noThrow(async () => {
|
|
58
|
+
const { filePath, diff } = input;
|
|
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
|
+
|
|
86
|
+
const content = await fs.readFile(filePath, "utf8");
|
|
87
|
+
const matches = Array.from(
|
|
88
|
+
diff.matchAll(
|
|
89
|
+
new RegExp(
|
|
90
|
+
`<<< ${nonce} <<< SEARCH\\n(.*?)\\n=== ${nonce} ===\\n(.*?)\\n?>>> ${nonce} >>> REPLACE`,
|
|
91
|
+
"gs",
|
|
92
|
+
),
|
|
93
|
+
),
|
|
94
|
+
);
|
|
95
|
+
if (matches.length === 0) {
|
|
96
|
+
throw new Error(
|
|
97
|
+
`Invalid diff format. Each markers must include the nonce: <<< ${nonce} <<< SEARCH, === ${nonce} ===, >>> ${nonce} >>> REPLACE`,
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
let newContent = content;
|
|
101
|
+
for (const match of matches) {
|
|
102
|
+
const [_, search, replace] = match;
|
|
103
|
+
if (!newContent.includes(search)) {
|
|
104
|
+
throw new Error(
|
|
105
|
+
JSON.stringify(`Search content not found: ${search}`),
|
|
106
|
+
);
|
|
107
|
+
}
|
|
108
|
+
// Escape $ characters in replacement string to prevent interpretation of $& $1 $$ patterns
|
|
109
|
+
const escapedReplace = replace.replace(/\$/g, "$$$$");
|
|
110
|
+
if (replace === "" && newContent.includes(`${search}\n`)) {
|
|
111
|
+
newContent = newContent.replace(`${search}\n`, "");
|
|
112
|
+
} else if (replace === "" && newContent.includes(`\n${search}`)) {
|
|
113
|
+
newContent = newContent.replace(`\n${search}`, "");
|
|
114
|
+
} else {
|
|
115
|
+
newContent = newContent.replace(search, escapedReplace);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
await fs.writeFile(filePath, newContent);
|
|
119
|
+
return `Patched file: ${filePath}`;
|
|
120
|
+
}),
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* @param {Record<string, unknown>} input
|
|
124
|
+
* @returns {Record<string, unknown>}
|
|
125
|
+
*/
|
|
126
|
+
maskApprovalInput: (input) => {
|
|
127
|
+
const patchFileInput = /** @type {PatchFileInput} */ (input);
|
|
128
|
+
return {
|
|
129
|
+
filePath: patchFileInput.filePath,
|
|
130
|
+
};
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { Tool, ToolImplementation } from '../tool'
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const switchToMainAgentToolName = "switch_to_main_agent";
|
|
6
|
+
|
|
7
|
+
/** @returns {Tool} */
|
|
8
|
+
export function createSwitchToMainAgentTool() {
|
|
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: switchToMainAgentToolName,
|
|
18
|
+
description: "Switch back to the main agent role and report the result.",
|
|
19
|
+
inputSchema: {
|
|
20
|
+
type: "object",
|
|
21
|
+
properties: {
|
|
22
|
+
memoryPath: {
|
|
23
|
+
type: "string",
|
|
24
|
+
description:
|
|
25
|
+
"Path to the memory file containing the result of the subagent's task.",
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
required: ["memoryPath"],
|
|
29
|
+
},
|
|
30
|
+
},
|
|
31
|
+
|
|
32
|
+
// Implementation will be injected by the agent to access its state
|
|
33
|
+
get impl() {
|
|
34
|
+
return impl;
|
|
35
|
+
},
|
|
36
|
+
|
|
37
|
+
injectImpl(fn) {
|
|
38
|
+
impl = fn;
|
|
39
|
+
},
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
return tool;
|
|
43
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { Tool, ToolImplementation } from '../tool'
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
export const switchToSubagentToolName = "switch_to_subagent";
|
|
6
|
+
|
|
7
|
+
/** @returns {Tool} */
|
|
8
|
+
export function createSwitchToSubagentTool() {
|
|
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: switchToSubagentToolName,
|
|
18
|
+
description:
|
|
19
|
+
"Switch to a subagent role within the same conversation, focusing on the specified goal. You inherit the current context.",
|
|
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
|
+
* @param {Record<string, unknown>} input
|
|
48
|
+
* @returns {Record<string, unknown>}
|
|
49
|
+
*/
|
|
50
|
+
maskApprovalInput: (input) => {
|
|
51
|
+
const { name } = /** @type {{name: string}} */ (input);
|
|
52
|
+
return {
|
|
53
|
+
name,
|
|
54
|
+
};
|
|
55
|
+
},
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
return tool;
|
|
59
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import type { Tool } from "../tool";
|
|
2
|
+
import type { ExecCommandSanboxConfig } from "./execCommand";
|
|
3
|
+
|
|
4
|
+
export type TmuxCommandInput = {
|
|
5
|
+
command: string;
|
|
6
|
+
args?: string[];
|
|
7
|
+
};
|
|
8
|
+
|
|
9
|
+
export type TmuxCommandConfig = {
|
|
10
|
+
sandbox?: ExecCommandSanboxConfig;
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export function createTmuxCommandTool(config?: TmuxCommandConfig): Tool;
|
|
14
|
+
export const tmuxCommandTool: Tool;
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { Tool } from '../tool'
|
|
3
|
+
* @import { TmuxCommandConfig, TmuxCommandInput } from './tmuxCommand'
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { execFile } from "node:child_process";
|
|
7
|
+
import { noThrow } from "../utils/noThrow.mjs";
|
|
8
|
+
|
|
9
|
+
const OUTPUT_MAX_LENGTH = 1024 * 8;
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
+ * Sandbox-aware tmux command tool
|
|
13
|
+
+ * @param {TmuxCommandConfig=} config
|
|
14
|
+
+ * @returns {Tool}
|
|
15
|
+
+ */
|
|
16
|
+
export function createTmuxCommandTool(config) {
|
|
17
|
+
/** @type {Tool} */
|
|
18
|
+
return {
|
|
19
|
+
def: {
|
|
20
|
+
name: "tmux_command",
|
|
21
|
+
description: "Run a tmux command",
|
|
22
|
+
inputSchema: {
|
|
23
|
+
type: "object",
|
|
24
|
+
properties: {
|
|
25
|
+
command: {
|
|
26
|
+
description: "The tmux command to run",
|
|
27
|
+
type: "string",
|
|
28
|
+
},
|
|
29
|
+
args: {
|
|
30
|
+
description: "Arguments to pass to the tmux command",
|
|
31
|
+
type: "array",
|
|
32
|
+
items: {
|
|
33
|
+
type: "string",
|
|
34
|
+
},
|
|
35
|
+
},
|
|
36
|
+
},
|
|
37
|
+
required: ["command"],
|
|
38
|
+
},
|
|
39
|
+
},
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* @param {TmuxCommandInput} input
|
|
43
|
+
* @returns {Promise<string | Error>}
|
|
44
|
+
*/
|
|
45
|
+
impl: async (input) =>
|
|
46
|
+
await noThrow(async () => {
|
|
47
|
+
const { command } = input;
|
|
48
|
+
const args = input.args || [];
|
|
49
|
+
|
|
50
|
+
// tmuxはセミコロンを複数コマンドの区切りとして扱うためエスケープが必要
|
|
51
|
+
// LLMがこのルールを無視するのでここでエスケープする
|
|
52
|
+
if (command === "send-keys") {
|
|
53
|
+
for (let i = 1; i < args.length; i++) {
|
|
54
|
+
const arg = args[i];
|
|
55
|
+
if (arg.endsWith(";") && !arg.endsWith("\\;")) {
|
|
56
|
+
args[i] = `${arg.slice(0, -1)}\\;`;
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
const execFileOptions = {
|
|
62
|
+
shell: false,
|
|
63
|
+
env: {
|
|
64
|
+
PWD: process.env.PWD,
|
|
65
|
+
PATH: process.env.PATH,
|
|
66
|
+
HOME: process.env.HOME,
|
|
67
|
+
},
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* @param {{command: string, args: string[]}} input
|
|
72
|
+
* @returns {{command: string, args: string[]}}
|
|
73
|
+
*/
|
|
74
|
+
const useSandbox = ({ command, args }) => {
|
|
75
|
+
if (config?.sandbox) {
|
|
76
|
+
return {
|
|
77
|
+
command: config.sandbox.command,
|
|
78
|
+
args: [...(config.sandbox.args || []), command, ...args],
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
return { command, args };
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
const execFileTmuxCommandInput = useSandbox({
|
|
85
|
+
command: "tmux",
|
|
86
|
+
args: [command, ...args],
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
return new Promise((resolve, _reject) => {
|
|
90
|
+
execFile(
|
|
91
|
+
execFileTmuxCommandInput.command,
|
|
92
|
+
execFileTmuxCommandInput.args,
|
|
93
|
+
execFileOptions,
|
|
94
|
+
async (err, stdout, stderr) => {
|
|
95
|
+
// capture-pane の結果に空白の行が含まれることがあるためtrim する
|
|
96
|
+
const stdoutTruncated = stdout.trim().slice(-OUTPUT_MAX_LENGTH);
|
|
97
|
+
const isStdoutTruncated =
|
|
98
|
+
stdout.trim().length > OUTPUT_MAX_LENGTH;
|
|
99
|
+
const stderrTruncated = stderr.trim().slice(-OUTPUT_MAX_LENGTH);
|
|
100
|
+
const isStderrTruncated =
|
|
101
|
+
stderr.trim().length > OUTPUT_MAX_LENGTH;
|
|
102
|
+
const result = [
|
|
103
|
+
stdoutTruncated
|
|
104
|
+
? `<stdout>\n${isStdoutTruncated ? "(Output truncated) ..." : ""}${stdoutTruncated}\n</stdout>`
|
|
105
|
+
: "<stdout></stdout>",
|
|
106
|
+
"",
|
|
107
|
+
stderrTruncated
|
|
108
|
+
? `<stderr>\n${isStderrTruncated ? "(Output truncated) ..." : ""}${stderrTruncated}\n</stderr>`
|
|
109
|
+
: "<stderr></stderr>",
|
|
110
|
+
];
|
|
111
|
+
if (err) {
|
|
112
|
+
const errMessageTruncated = err.message.slice(
|
|
113
|
+
0,
|
|
114
|
+
OUTPUT_MAX_LENGTH,
|
|
115
|
+
);
|
|
116
|
+
const isErrMessageTruncated =
|
|
117
|
+
err.message.length > OUTPUT_MAX_LENGTH;
|
|
118
|
+
result.push(
|
|
119
|
+
`\n<error>\n${err.name}: ${errMessageTruncated}${isErrMessageTruncated ? "... (Message truncated)" : ""}</error>`,
|
|
120
|
+
);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (["new-session", "new", "new-window"].includes(command)) {
|
|
124
|
+
// show window list after creating a new session or window
|
|
125
|
+
const targetPosition = command.includes("window")
|
|
126
|
+
? args.indexOf("-t") + 1
|
|
127
|
+
: args.indexOf("-s") + 1;
|
|
128
|
+
const target = args[targetPosition];
|
|
129
|
+
|
|
130
|
+
const execFileTmuxListWindowInput = useSandbox({
|
|
131
|
+
command: "tmux",
|
|
132
|
+
args: ["list-windows", "-t", target],
|
|
133
|
+
});
|
|
134
|
+
const listWindowResult = await new Promise(
|
|
135
|
+
(resolve, _reject) => {
|
|
136
|
+
execFile(
|
|
137
|
+
execFileTmuxListWindowInput.command,
|
|
138
|
+
execFileTmuxListWindowInput.args,
|
|
139
|
+
execFileOptions,
|
|
140
|
+
(err, stdout, _stderr) => {
|
|
141
|
+
if (err) {
|
|
142
|
+
console.error(
|
|
143
|
+
`Failed to list tmux windows: ${err.message}, stack=${err.stack}`,
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
return resolve(stdout);
|
|
147
|
+
},
|
|
148
|
+
);
|
|
149
|
+
},
|
|
150
|
+
);
|
|
151
|
+
result.push(
|
|
152
|
+
`\n<tmux:list-windows>\n${listWindowResult}</tmux:list-windows>`,
|
|
153
|
+
);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (command === "send-keys") {
|
|
157
|
+
// capture the pane after sending keys
|
|
158
|
+
// wait for the command to be executed
|
|
159
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
160
|
+
const targetPosition = args.indexOf("-t") + 1;
|
|
161
|
+
const target = args[targetPosition];
|
|
162
|
+
const execFileTmuxCapturePaneInput = useSandbox({
|
|
163
|
+
command: "tmux",
|
|
164
|
+
args: ["capture-pane", "-p", "-t", target],
|
|
165
|
+
});
|
|
166
|
+
const captured = await new Promise((resolve, _reject) => {
|
|
167
|
+
execFile(
|
|
168
|
+
execFileTmuxCapturePaneInput.command,
|
|
169
|
+
execFileTmuxCapturePaneInput.args,
|
|
170
|
+
execFileOptions,
|
|
171
|
+
(err, stdout, _stderr) => {
|
|
172
|
+
if (err) {
|
|
173
|
+
console.error(
|
|
174
|
+
`Failed to capture tmux pane: ${err.message}, stack=${err.stack}`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
return resolve(stdout.trim());
|
|
178
|
+
},
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
const capturedTruncated = captured.slice(-OUTPUT_MAX_LENGTH);
|
|
182
|
+
const isCapturedTruncated = captured.length > OUTPUT_MAX_LENGTH;
|
|
183
|
+
result.push(
|
|
184
|
+
`\n<tmux:capture-pane target="${target}"">\n${isCapturedTruncated ? "(Output truncated) ..." : ""}${capturedTruncated}\n</tmux:capture-pane>`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return resolve(result.join("\n"));
|
|
189
|
+
},
|
|
190
|
+
);
|
|
191
|
+
});
|
|
192
|
+
}),
|
|
193
|
+
};
|
|
194
|
+
}
|