@iinm/plain-agent 1.8.4 → 1.8.6

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 (85) hide show
  1. package/bin/plain +1 -1
  2. package/package.json +8 -9
  3. package/sandbox/bin/plain-sandbox +13 -0
  4. package/src/agent.d.ts +52 -0
  5. package/src/agent.mjs +204 -0
  6. package/src/agentLoop.mjs +419 -0
  7. package/src/agentState.mjs +41 -0
  8. package/src/claudeCodePlugin.mjs +164 -0
  9. package/src/cliArgs.mjs +175 -0
  10. package/src/cliBatch.mjs +147 -0
  11. package/src/cliCommands.mjs +283 -0
  12. package/src/cliCompleter.mjs +227 -0
  13. package/src/cliCost.mjs +309 -0
  14. package/src/cliFormatter.mjs +518 -0
  15. package/src/cliInteractive.mjs +533 -0
  16. package/src/cliInterruptTransform.mjs +51 -0
  17. package/src/cliMuteTransform.mjs +26 -0
  18. package/src/cliPasteTransform.mjs +183 -0
  19. package/src/config.d.ts +36 -0
  20. package/src/config.mjs +197 -0
  21. package/src/context/loadAgentRoles.mjs +267 -0
  22. package/src/context/loadPrompts.mjs +303 -0
  23. package/src/context/loadUserMessageContext.mjs +147 -0
  24. package/src/costTracker.mjs +210 -0
  25. package/src/env.mjs +44 -0
  26. package/src/main.mjs +281 -0
  27. package/src/mcpClient.mjs +351 -0
  28. package/src/mcpIntegration.mjs +160 -0
  29. package/src/model.d.ts +109 -0
  30. package/src/modelCaller.mjs +32 -0
  31. package/src/modelDefinition.d.ts +92 -0
  32. package/src/prompt.mjs +138 -0
  33. package/src/providers/anthropic.d.ts +248 -0
  34. package/src/providers/anthropic.mjs +587 -0
  35. package/src/providers/bedrock.d.ts +249 -0
  36. package/src/providers/bedrock.mjs +700 -0
  37. package/src/providers/gemini.d.ts +208 -0
  38. package/src/providers/gemini.mjs +754 -0
  39. package/src/providers/openai.d.ts +281 -0
  40. package/src/providers/openai.mjs +544 -0
  41. package/src/providers/openaiCompatible.d.ts +147 -0
  42. package/src/providers/openaiCompatible.mjs +652 -0
  43. package/src/providers/platform/awsSigV4.mjs +184 -0
  44. package/src/providers/platform/azure.mjs +42 -0
  45. package/src/providers/platform/bedrock.mjs +78 -0
  46. package/src/providers/platform/googleCloud.mjs +34 -0
  47. package/src/subagent.mjs +265 -0
  48. package/src/tmpfile.mjs +27 -0
  49. package/src/tool.d.ts +74 -0
  50. package/src/toolExecutor.mjs +236 -0
  51. package/src/toolInputValidator.mjs +183 -0
  52. package/src/toolUseApprover.mjs +99 -0
  53. package/src/tools/askURL.mjs +209 -0
  54. package/src/tools/askWeb.mjs +208 -0
  55. package/src/tools/compactContext.d.ts +4 -0
  56. package/src/tools/compactContext.mjs +87 -0
  57. package/src/tools/execCommand.d.ts +22 -0
  58. package/src/tools/execCommand.mjs +200 -0
  59. package/src/tools/patchFile.d.ts +4 -0
  60. package/src/tools/patchFile.mjs +133 -0
  61. package/src/tools/switchToMainAgent.d.ts +3 -0
  62. package/src/tools/switchToMainAgent.mjs +43 -0
  63. package/src/tools/switchToSubagent.d.ts +4 -0
  64. package/src/tools/switchToSubagent.mjs +59 -0
  65. package/src/tools/tmuxCommand.d.ts +14 -0
  66. package/src/tools/tmuxCommand.mjs +194 -0
  67. package/src/tools/writeFile.d.ts +4 -0
  68. package/src/tools/writeFile.mjs +56 -0
  69. package/src/usageStore.mjs +167 -0
  70. package/src/utils/evalJSONConfig.mjs +72 -0
  71. package/src/utils/matchValue.d.ts +6 -0
  72. package/src/utils/matchValue.mjs +40 -0
  73. package/src/utils/noThrow.mjs +31 -0
  74. package/src/utils/notify.mjs +29 -0
  75. package/src/utils/parseFileRange.mjs +18 -0
  76. package/src/utils/parseFrontmatter.mjs +19 -0
  77. package/src/utils/readFileRange.mjs +33 -0
  78. package/src/utils/retryOnError.mjs +41 -0
  79. package/src/voiceInput.mjs +61 -0
  80. package/src/voiceInputGemini.mjs +105 -0
  81. package/src/voiceInputOpenAI.mjs +104 -0
  82. package/src/voiceInputSession.mjs +543 -0
  83. package/src/voiceToggleKey.mjs +62 -0
  84. package/dist/main.mjs +0 -473
  85. 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,4 @@
1
+ export type PatchFileInput = {
2
+ filePath: string;
3
+ diff: string;
4
+ };
@@ -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,3 @@
1
+ export type SwitchToMainAgentInput = {
2
+ memoryPath: string;
3
+ };
@@ -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,4 @@
1
+ export type SwitchToSubagentInput = {
2
+ name: string;
3
+ goal: string;
4
+ };
@@ -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
+ }
@@ -0,0 +1,4 @@
1
+ export type WriteFileInput = {
2
+ filePath: string;
3
+ content: string;
4
+ };