@iinm/plain-agent 1.11.4 → 1.11.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.
package/README.md CHANGED
@@ -3,7 +3,7 @@
3
3
  [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/iinm/plain-agent)
4
4
  [![npm version](https://img.shields.io/npm/v/@iinm/plain-agent)](https://www.npmjs.com/package/@iinm/plain-agent)
5
5
  [![install size](https://packagephobia.com/badge?p=@iinm/plain-agent)](https://packagephobia.com/result?p=@iinm/plain-agent)
6
- [![Socket Badge](https://badge.socket.dev/npm/package/@iinm/plain-agent/1.11.4)](https://socket.dev/npm/package/@iinm/plain-agent)
6
+ [![Socket Badge](https://badge.socket.dev/npm/package/@iinm/plain-agent/1.11.6)](https://socket.dev/npm/package/@iinm/plain-agent)
7
7
  [![CodeQL](https://github.com/iinm/plain-agent/actions/workflows/github-code-scanning/codeql/badge.svg)](https://github.com/iinm/plain-agent/actions/workflows/github-code-scanning/codeql)
8
8
 
9
9
  A lightweight terminal-based coding agent focused on safety and low token cost
@@ -1676,7 +1676,7 @@
1676
1676
  },
1677
1677
 
1678
1678
  {
1679
- "name": "minimax-m2.7",
1679
+ "name": "minimax-m3",
1680
1680
  "variant": "fireworks",
1681
1681
  "platform": {
1682
1682
  "name": "openai-compatible",
@@ -1685,30 +1685,7 @@
1685
1685
  "model": {
1686
1686
  "format": "openai-messages",
1687
1687
  "config": {
1688
- "model": "accounts/fireworks/models/minimax-m2p7"
1689
- }
1690
- },
1691
- "cost": {
1692
- "currency": "USD",
1693
- "unit": "1M",
1694
- "prices": {
1695
- "prompt_tokens": 0.3,
1696
- "prompt_tokens_details.cached_tokens": -0.24,
1697
- "completion_tokens": 1.2
1698
- }
1699
- }
1700
- },
1701
- {
1702
- "name": "minimax-m2.7",
1703
- "variant": "novita",
1704
- "platform": {
1705
- "name": "openai-compatible",
1706
- "variant": "novita"
1707
- },
1708
- "model": {
1709
- "format": "openai-messages",
1710
- "config": {
1711
- "model": "minimax/minimax-m2.7"
1688
+ "model": "accounts/fireworks/models/minimax-m3"
1712
1689
  }
1713
1690
  },
1714
1691
  "cost": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iinm/plain-agent",
3
- "version": "1.11.4",
3
+ "version": "1.11.6",
4
4
  "description": "A lightweight terminal-based coding agent focused on safety and low token cost",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -18,6 +18,9 @@ import { noThrow } from "../utils/noThrow.mjs";
18
18
  /** Length above which a single-line arg forces block-form rendering. */
19
19
  const ARG_BLOCK_LENGTH_THRESHOLD = 60;
20
20
 
21
+ /** Total JSON length above which args are rendered in block form even if each individual arg is short. */
22
+ const ARGS_TOTAL_BLOCK_LENGTH_THRESHOLD = 160;
23
+
21
24
  /**
22
25
  * Format an args array for display.
23
26
  * Uses compact JSON for short single-line args; switches to a YAML-style
@@ -27,18 +30,20 @@ const ARG_BLOCK_LENGTH_THRESHOLD = 60;
27
30
  * @param {unknown} args
28
31
  * @returns {string}
29
32
  */
30
- export function formatArgs(args) {
33
+ export function formatCommandArgs(args) {
31
34
  if (!Array.isArray(args) || args.length === 0) {
32
35
  return `args: ${JSON.stringify(args ?? [])}`;
33
36
  }
34
37
 
35
- const needsBlock = args.some(
36
- (a) =>
37
- typeof a === "string" &&
38
- (a.includes("\n") || a.length > ARG_BLOCK_LENGTH_THRESHOLD),
39
- );
38
+ const needsBlock =
39
+ JSON.stringify(args).length > ARGS_TOTAL_BLOCK_LENGTH_THRESHOLD ||
40
+ args.some(
41
+ (a) =>
42
+ typeof a === "string" &&
43
+ (a.includes("\n") || a.length > ARG_BLOCK_LENGTH_THRESHOLD),
44
+ );
40
45
  if (!needsBlock) {
41
- return `args: ${JSON.stringify(args)}`;
46
+ return `args: ${highlightCommandArgs(JSON.stringify(args))}`;
42
47
  }
43
48
 
44
49
  const lines = ["args:"];
@@ -49,30 +54,60 @@ export function formatArgs(args) {
49
54
  ) {
50
55
  lines.push(" - |");
51
56
  for (const line of arg.split("\n")) {
52
- lines.push(` ${line}`);
57
+ lines.push(` ${highlightCommandArgs(line)}`);
53
58
  }
54
59
  } else {
55
- lines.push(` - ${JSON.stringify(arg)}`);
60
+ lines.push(` - ${highlightCommandArgs(JSON.stringify(arg))}`);
56
61
  }
57
62
  }
58
63
  return lines.join("\n");
59
64
  }
60
65
 
66
+ /**
67
+ * @param {string} args
68
+ * @returns {string}
69
+ */
70
+ function highlightCommandArgs(args) {
71
+ return (
72
+ args
73
+ // --foo
74
+ .replace(
75
+ /(^|\s|")(--[a-zA-Z0-9-]+)(\s|"|$)/gm,
76
+ (_, p1, p2, p3) => p1 + styleText("cyan", p2) + p3,
77
+ )
78
+ // -f
79
+ .replace(
80
+ /(^|\s|")(-[a-zA-Z]+)(\s|"|$)/gm,
81
+ (_, p1, p2, p3) => p1 + styleText("cyan", p2) + p3,
82
+ )
83
+ );
84
+ }
85
+
86
+ /**
87
+ * @import { SandboxModeProvider } from "../tool"
88
+ */
89
+
61
90
  /**
62
91
  * Format tool use for display.
63
92
  * @param {MessageContentToolUse} toolUse
93
+ * @param {{ execCommandTool?: SandboxModeProvider }} [options]
64
94
  * @returns {Promise<string>}
65
95
  */
66
- export async function formatToolUse(toolUse) {
96
+ export async function formatToolUse(toolUse, options = {}) {
67
97
  const { toolName, input } = toolUse;
68
98
 
69
99
  if (toolName === "exec_command") {
70
100
  /** @type {Partial<ExecCommandInput>} */
71
101
  const execCommandInput = input;
102
+ const mode = options.execCommandTool?.getSandboxMode?.(input);
103
+ const toolNameLine =
104
+ mode === "unsandboxed"
105
+ ? `${toolName}${styleText("yellow", " [unsandboxed]")}`
106
+ : toolName;
72
107
  return [
73
- `${toolName}`,
108
+ toolNameLine,
74
109
  `command: ${JSON.stringify(execCommandInput.command)}`,
75
- formatArgs(execCommandInput.args),
110
+ formatCommandArgs(execCommandInput.args),
76
111
  ].join("\n");
77
112
  }
78
113
 
@@ -117,7 +152,7 @@ export async function formatToolUse(toolUse) {
117
152
  return [
118
153
  `${toolName}`,
119
154
  `command: ${tmuxCommandInput.command}`,
120
- formatArgs(tmuxCommandInput.args),
155
+ formatCommandArgs(tmuxCommandInput.args),
121
156
  ].join("\n");
122
157
  }
123
158
 
@@ -355,9 +390,10 @@ export function formatCostForBatch(summary) {
355
390
  /**
356
391
  * Print a message to the console.
357
392
  * @param {Message} message
393
+ * @param {{ execCommandTool?: SandboxModeProvider }} [options]
358
394
  * @returns {Promise<void>}
359
395
  */
360
- export async function printMessage(message) {
396
+ export async function printMessage(message, options = {}) {
361
397
  switch (message.role) {
362
398
  case "assistant": {
363
399
  // console.log(styleText("bold", "\nAgent:"));
@@ -366,7 +402,7 @@ export async function printMessage(message) {
366
402
  (part) => part.type === "tool_use",
367
403
  );
368
404
  const formattedToolUses = await Promise.all(
369
- toolUseParts.map((part) => formatToolUse(part)),
405
+ toolUseParts.map((part) => formatToolUse(part, options)),
370
406
  );
371
407
  let toolUseIndex = 0;
372
408
  for (const part of message.content) {
@@ -385,7 +421,7 @@ export async function printMessage(message) {
385
421
  // console.log(part.text);
386
422
  // break;
387
423
  case "tool_use":
388
- console.log(styleText("bold", "\nTool call:"));
424
+ console.log(styleText("bold", "\nTool use:"));
389
425
  console.log(formattedToolUses[toolUseIndex++]);
390
426
  break;
391
427
  }
@@ -1,6 +1,7 @@
1
1
  /**
2
2
  * @import { UserEventEmitter, AgentEventEmitter, AgentCommands } from "../agent"
3
3
  * @import { ClaudeCodePlugin } from "../claudeCodePlugin.mjs"
4
+ * @import { Tool, SandboxModeProvider } from "../tool"
4
5
  * @import { VoiceInputConfig } from "../voice/input.mjs"
5
6
  * @import { VoiceSession } from "../voice/session.mjs"
6
7
  */
@@ -67,6 +68,7 @@ const HELP_MESSAGE = [
67
68
  * @property {() => Promise<void>} onStop
68
69
  * @property {ClaudeCodePlugin[]} [claudeCodePlugins]
69
70
  * @property {VoiceInputConfig} [voiceInput]
71
+ * @property {Tool & SandboxModeProvider} [execCommandTool]
70
72
  */
71
73
 
72
74
  /**
@@ -111,6 +113,7 @@ export function startInteractiveSession({
111
113
  onStop,
112
114
  claudeCodePlugins,
113
115
  voiceInput,
116
+ execCommandTool,
114
117
  }) {
115
118
  /** @type {{ turn: boolean, multiLineBuffer: string[] | null, subagentName: string, toolSpinnerIndex: number, toolSpinnerLastTime: number }} */
116
119
  const state = {
@@ -517,7 +520,7 @@ export function startInteractiveSession({
517
520
 
518
521
  agentEventEmitter.on("message", (message) => {
519
522
  enqueueOutput(() =>
520
- printMessage(message).catch((err) => {
523
+ printMessage(message, { execCommandTool }).catch((err) => {
521
524
  console.error(
522
525
  styleText("red", `Error rendering message: ${err.message}`),
523
526
  );
package/src/main.mjs CHANGED
@@ -308,8 +308,9 @@ export async function main(argv = process.argv) {
308
308
  skills: Array.from(prompts.values()).filter((p) => p.isSkill),
309
309
  });
310
310
 
311
+ const execCommandTool = createExecCommandTool({ sandbox: appConfig.sandbox });
311
312
  const builtinTools = [
312
- createExecCommandTool({ sandbox: appConfig.sandbox }),
313
+ execCommandTool,
313
314
  readFileTool,
314
315
  writeFileTool,
315
316
  createPatchFileTool(),
@@ -443,6 +444,7 @@ export async function main(argv = process.argv) {
443
444
  } else {
444
445
  startInteractiveSession({
445
446
  ...sessionOptions,
447
+ execCommandTool,
446
448
  notifyCmd: appConfig.notifyCmd,
447
449
  claudeCodePlugins: resolvePluginPaths(appConfig.claudeCodePlugins ?? []),
448
450
  voiceInput: appConfig.voiceInput,
@@ -145,7 +145,7 @@ export async function callAnthropicModel(
145
145
 
146
146
  const response = await runFetch();
147
147
 
148
- if (response.status === 429 || response.status >= 500) {
148
+ if ((response.status === 429 || response.status >= 500) && retryCount < 5) {
149
149
  const interval = Math.min(2 * 2 ** retryCount, 16);
150
150
  console.error(
151
151
  styleText(
@@ -107,7 +107,7 @@ export async function callBedrockConverseModel(
107
107
  (response.status === 429 ||
108
108
  response.status === 502 ||
109
109
  response.status === 503) &&
110
- retryCount < 3
110
+ retryCount < 5
111
111
  ) {
112
112
  const retryInterval = Math.min(2 * 2 ** retryCount, 16);
113
113
  console.error(
@@ -145,7 +145,10 @@ export function createCacheEnabledGeminiModelCaller(
145
145
  signal: AbortSignal.timeout(8 * 60 * 1000),
146
146
  });
147
147
 
148
- if (response.status === 429 || response.status >= 500) {
148
+ if (
149
+ (response.status === 429 || response.status >= 500) &&
150
+ retryCount < 5
151
+ ) {
149
152
  const interval = Math.min(2 * 2 ** retryCount, 16);
150
153
  console.error(
151
154
  styleText(
@@ -219,15 +222,20 @@ export function createCacheEnabledGeminiModelCaller(
219
222
  message instanceof GeminiNoCandidateError ||
220
223
  message instanceof GeminiMalformedFunctionCallError
221
224
  ) {
222
- const interval = Math.min(2 * 2 ** retryCount, 16);
223
- console.error(
224
- styleText(
225
- "yellow",
226
- `${message.name}: Retrying in ${interval} seconds...`,
227
- ),
225
+ if (retryCount < 5) {
226
+ const interval = Math.min(2 * 2 ** retryCount, 16);
227
+ console.error(
228
+ styleText(
229
+ "yellow",
230
+ `${message.name}: Retrying in ${interval} seconds...`,
231
+ ),
232
+ );
233
+ await new Promise((resolve) => setTimeout(resolve, interval * 1000));
234
+ return modelCaller(config, input, retryCount + 1);
235
+ }
236
+ return new Error(
237
+ `${message.name}: Retry limit (${retryCount}) exceeded: ${message.message}`,
228
238
  );
229
- await new Promise((resolve) => setTimeout(resolve, interval * 1000));
230
- return modelCaller(config, input, retryCount + 1);
231
239
  }
232
240
 
233
241
  // Create context cache for next request
@@ -68,7 +68,7 @@ export async function callOpenAIModel(
68
68
  });
69
69
 
70
70
  const retryInterval = Math.min(2 * 2 ** retryCount, 16);
71
- if (response.status === 429 || response.status >= 500) {
71
+ if ((response.status === 429 || response.status >= 500) && retryCount < 5) {
72
72
  console.error(
73
73
  styleText(
74
74
  "yellow",
@@ -179,7 +179,7 @@ export async function callOpenAICompatibleModel(
179
179
  maxAttempt: 5,
180
180
  });
181
181
 
182
- if (response.status === 429 || response.status >= 500) {
182
+ if ((response.status === 429 || response.status >= 500) && retryCount < 5) {
183
183
  console.error(
184
184
  styleText(
185
185
  "yellow",
@@ -236,18 +236,25 @@ export async function callOpenAICompatibleModel(
236
236
 
237
237
  const chatCompletion = convertOpenAIStreamDataToChatCompletion(dataList);
238
238
  if (chatCompletion instanceof Error) {
239
- console.error(
240
- styleText(
241
- "yellow",
242
- `Failed to process stream: ${chatCompletion.message}; Retry in ${retryInterval} seconds...`,
243
- ),
244
- );
245
- await new Promise((resolve) => setTimeout(resolve, retryInterval * 1000));
246
- return callOpenAICompatibleModel(
247
- platformConfig,
248
- modelConfig,
249
- input,
250
- retryCount + 1,
239
+ if (retryCount < 5) {
240
+ console.error(
241
+ styleText(
242
+ "yellow",
243
+ `Failed to process stream: ${chatCompletion.message}; Retry in ${retryInterval} seconds...`,
244
+ ),
245
+ );
246
+ await new Promise((resolve) =>
247
+ setTimeout(resolve, retryInterval * 1000),
248
+ );
249
+ return callOpenAICompatibleModel(
250
+ platformConfig,
251
+ modelConfig,
252
+ input,
253
+ retryCount + 1,
254
+ );
255
+ }
256
+ throw new Error(
257
+ `Failed to process OpenAI compatible stream after ${retryCount} retries: ${chatCompletion.message}`,
251
258
  );
252
259
  }
253
260
 
package/src/tool.d.ts CHANGED
@@ -10,6 +10,20 @@ export type Tool = {
10
10
  injectImpl?: (impl: ToolImplementation) => void;
11
11
  };
12
12
 
13
+ export type SandboxMode = "sandbox" | "unsandboxed" | null;
14
+
15
+ /**
16
+ * Implemented by tools that can report the sandbox mode for a given input.
17
+ * - `null` if the tool has no sandbox configuration
18
+ * - `"unsandboxed"` if a sandbox rule matched with `mode: "unsandboxed"`
19
+ * - `"sandbox"` otherwise (sandbox config exists, default execution is sandboxed)
20
+ * Used by the CLI to display an `[unsandboxed]` badge for tool calls that
21
+ * will execute outside the sandbox.
22
+ */
23
+ export type SandboxModeProvider = {
24
+ getSandboxMode: (input: unknown) => SandboxMode;
25
+ };
26
+
13
27
  export type ToolDefinition = {
14
28
  name: string;
15
29
  description: string;
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @import { Tool } from '../tool'
2
+ * @import { Tool, SandboxModeProvider } from '../tool'
3
3
  * @import { ExecCommandConfig, ExecCommandInput, ExecCommandSanboxConfig } from './execCommand'
4
4
  */
5
5
 
@@ -13,10 +13,10 @@ const OUTPUT_TRUNCATED_LENGTH = 1024 * 2;
13
13
 
14
14
  /**
15
15
  * @param {ExecCommandConfig=} config
16
- * @returns {Tool}
16
+ * @returns {Tool & SandboxModeProvider}
17
17
  */
18
18
  export function createExecCommandTool(config) {
19
- /** @type {Tool} */
19
+ /** @type {Tool & SandboxModeProvider} */
20
20
  return {
21
21
  def: {
22
22
  name: "exec_command",
@@ -190,6 +190,21 @@ Examples:
190
190
  child.stdin?.end();
191
191
  });
192
192
  }),
193
+
194
+ /**
195
+ * Report the sandbox mode for a given tool input. Mirrors
196
+ * `rewriteInputForSandbox`'s rule-matching logic so the CLI can preview
197
+ * the mode before execution.
198
+ * @param {unknown} input
199
+ * @returns {"sandbox" | "unsandboxed" | null}
200
+ */
201
+ getSandboxMode: (input) => {
202
+ if (!config?.sandbox) return null;
203
+ const matchedRule = (config.sandbox.rules || []).find((rule) =>
204
+ matchValue(/** @type {ExecCommandInput} */ (input), rule.pattern),
205
+ );
206
+ return matchedRule?.mode === "unsandboxed" ? "unsandboxed" : "sandbox";
207
+ },
193
208
  };
194
209
  }
195
210