@iinm/plain-agent 1.12.0 → 1.13.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.
package/README.md CHANGED
@@ -1,10 +1,8 @@
1
1
  # Plain Agent
2
2
 
3
- [![Ask DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/iinm/plain-agent)
4
- [![npm version](https://img.shields.io/npm/v/@iinm/plain-agent)](https://www.npmjs.com/package/@iinm/plain-agent)
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.12.0)](https://socket.dev/npm/package/@iinm/plain-agent)
7
3
  [![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)
4
+ [![Socket Badge](https://badge.socket.dev/npm/package/@iinm/plain-agent/1.13.0)](https://socket.dev/npm/package/@iinm/plain-agent)
5
+ [![install size](https://packagephobia.com/badge?p=@iinm/plain-agent)](https://packagephobia.com/result?p=@iinm/plain-agent)
8
6
 
9
7
  A lightweight terminal-based coding agent focused on safety and low token cost
10
8
 
@@ -189,9 +187,8 @@ A Docker-based wrapper called `plain-sandbox` is included, but the interface is
189
187
  {
190
188
  "sandbox": {
191
189
  // Commands are wrapped and executed with this command
192
- // Build the image before first use: plain-sandbox --verbose echo done
193
190
  "command": "plain-sandbox",
194
- "args": ["--allow-write", "--mount-readonly", ".plain-agent/config.json", "--skip-build", "--keep-alive", "30"],
191
+ "args": ["--allow-write", "--mount-readonly", ".plain-agent/config.json", "--keep-alive", "30"],
195
192
  // ↑ --mount-readonly: prevents the agent from overwriting its own config
196
193
  // separator is inserted between sandbox flags and the user command to prevent bypasses
197
194
  "separator": "--",
@@ -544,6 +541,17 @@ plain resume --list
544
541
  plain resume 2026-05-10-0803-a7k
545
542
  ```
546
543
 
544
+ Launch the sandbox command using the app config's sandbox settings.
545
+ Arguments before `--` are flags for the `plain` CLI itself (e.g. `-c` to load a
546
+ config file). Arguments after `--` are passed through to the sandbox command as-is.
547
+
548
+ ```sh
549
+ plain sandbox -- --tty zsh
550
+
551
+ # Or specify a config file explicitly
552
+ plain sandbox -c .plain-agent/config.sandbox.json -- --tty zsh
553
+ ```
554
+
547
555
  ## Configuration
548
556
 
549
557
  Files are loaded in the following order. Settings in later files override earlier ones.
@@ -618,9 +626,8 @@ Files are loaded in the following order. Settings in later files override earlie
618
626
  ]
619
627
  },
620
628
  "sandbox": {
621
- // Build the image before first use: plain-sandbox --verbose echo done
622
629
  "command": "plain-sandbox",
623
- "args": ["--allow-write", "--mount-readonly", ".plain-agent/config.json", "--skip-build", "--keep-alive", "30"],
630
+ "args": ["--allow-write", "--mount-readonly", ".plain-agent/config.json", "--keep-alive", "30"],
624
631
  // ↑ --mount-readonly: prevents the agent from overwriting its own config
625
632
  "separator": "--"
626
633
  }
@@ -691,9 +698,8 @@ Files are loaded in the following order. Settings in later files override earlie
691
698
  // Sandbox environment for the exec_command and tmux_command tools
692
699
  "sandbox": {
693
700
  // Commands are wrapped and executed with this command
694
- // Build the image before first use: plain-sandbox --verbose echo done
695
701
  "command": "plain-sandbox",
696
- "args": ["--allow-write", "--mount-readonly", ".plain-agent/config.json", "--skip-build", "--keep-alive", "30"],
702
+ "args": ["--allow-write", "--mount-readonly", ".plain-agent/config.json", "--keep-alive", "30"],
697
703
  // ↑ --mount-readonly: prevents the agent from overwriting its own config
698
704
  // separator is inserted between sandbox flags and the user command to prevent bypasses
699
705
  "separator": "--",
@@ -1543,29 +1543,6 @@
1543
1543
  }
1544
1544
  }
1545
1545
  },
1546
- {
1547
- "name": "glm-5.2",
1548
- "variant": "novita",
1549
- "platform": {
1550
- "name": "openai-compatible",
1551
- "variant": "novita"
1552
- },
1553
- "model": {
1554
- "format": "openai-messages",
1555
- "config": {
1556
- "model": "zai-org/glm-5.2"
1557
- }
1558
- },
1559
- "cost": {
1560
- "currency": "USD",
1561
- "unit": "1M",
1562
- "prices": {
1563
- "prompt_tokens": 1.4,
1564
- "prompt_tokens_details.cached_tokens": -1.14,
1565
- "completion_tokens": 4.4
1566
- }
1567
- }
1568
- },
1569
1546
 
1570
1547
  {
1571
1548
  "name": "kimi-k2.6",
@@ -1605,53 +1582,7 @@
1605
1582
  }
1606
1583
  }
1607
1584
  },
1608
- {
1609
- "name": "kimi-k2.7-code",
1610
- "variant": "novita",
1611
- "platform": {
1612
- "name": "openai-compatible",
1613
- "variant": "novita"
1614
- },
1615
- "model": {
1616
- "format": "openai-messages",
1617
- "config": {
1618
- "model": "moonshotai/kimi-k2.7-code"
1619
- }
1620
- },
1621
- "cost": {
1622
- "currency": "USD",
1623
- "unit": "1M",
1624
- "prices": {
1625
- "prompt_tokens": 0.95,
1626
- "prompt_tokens_details.cached_tokens": -0.76,
1627
- "completion_tokens": 4
1628
- }
1629
- }
1630
- },
1631
1585
 
1632
- {
1633
- "name": "deepseek-v4-pro",
1634
- "variant": "novita",
1635
- "platform": {
1636
- "name": "openai-compatible",
1637
- "variant": "novita"
1638
- },
1639
- "model": {
1640
- "format": "openai-messages",
1641
- "config": {
1642
- "model": "deepseek/deepseek-v4-pro"
1643
- }
1644
- },
1645
- "cost": {
1646
- "currency": "USD",
1647
- "unit": "1M",
1648
- "prices": {
1649
- "prompt_tokens": 1.74,
1650
- "prompt_tokens_details.cached_tokens": -1.595,
1651
- "completion_tokens": 3.48
1652
- }
1653
- }
1654
- },
1655
1586
  {
1656
1587
  "name": "deepseek-v4-pro",
1657
1588
  "variant": "fireworks",
@@ -1714,29 +1645,6 @@
1714
1645
  }
1715
1646
  }
1716
1647
  },
1717
- {
1718
- "name": "minimax-m3",
1719
- "variant": "novita",
1720
- "platform": {
1721
- "name": "openai-compatible",
1722
- "variant": "novita"
1723
- },
1724
- "model": {
1725
- "format": "openai-messages",
1726
- "config": {
1727
- "model": "minimax/minimax-m3"
1728
- }
1729
- },
1730
- "cost": {
1731
- "currency": "USD",
1732
- "unit": "1M",
1733
- "prices": {
1734
- "prompt_tokens": 0.3,
1735
- "prompt_tokens_details.cached_tokens": -0.24,
1736
- "completion_tokens": 1.2
1737
- }
1738
- }
1739
- },
1740
1648
 
1741
1649
  {
1742
1650
  "name": "qwen3.7-plus",
@@ -1761,29 +1669,6 @@
1761
1669
  }
1762
1670
  }
1763
1671
  },
1764
- {
1765
- "name": "qwen3.7-max",
1766
- "variant": "novita",
1767
- "platform": {
1768
- "name": "openai-compatible",
1769
- "variant": "novita"
1770
- },
1771
- "model": {
1772
- "format": "openai-messages",
1773
- "config": {
1774
- "model": "qwen/qwen3.7-max"
1775
- }
1776
- },
1777
- "cost": {
1778
- "currency": "USD",
1779
- "unit": "1M",
1780
- "prices": {
1781
- "prompt_tokens": 1.5625,
1782
- "prompt_tokens_details.cached_tokens": -1.4375,
1783
- "completion_tokens": 3.75
1784
- }
1785
- }
1786
- },
1787
1672
 
1788
1673
  {
1789
1674
  "name": "nova-2-lite",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iinm/plain-agent",
3
- "version": "1.12.0",
3
+ "version": "1.13.0",
4
4
  "description": "A lightweight terminal-based coding agent focused on safety and low token cost",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -26,6 +26,7 @@
26
26
  "!src/**/*.test.mjs",
27
27
  "!src/**/*.test.*.mjs",
28
28
  "!src/**/*.playground.mjs",
29
+ "!src/test-reporter.mjs",
29
30
  "!src/**/test/"
30
31
  ],
31
32
  "engines": {
@@ -33,9 +34,9 @@
33
34
  },
34
35
  "scripts": {
35
36
  "check": "npm run lint && tsc && npm run test && npm run test:e2e && npm run test:predefined-approval",
36
- "test": "node --test 'src/**/*.test.mjs'",
37
- "test:e2e": "node --test --test-timeout=30000 'e2e/**/*.test.mjs'",
38
- "coverage": "c8 --exclude='src/**/*.test.mjs' --exclude='e2e/**' node --test --test-force-exit 'src/**/*.test.mjs' 'e2e/**/*.test.mjs'",
37
+ "test": "node --test --test-reporter=./src/test-reporter.mjs 'src/**/*.test.mjs'",
38
+ "test:e2e": "node --test --test-reporter=./src/test-reporter.mjs --test-timeout=30000 'e2e/**/*.test.mjs'",
39
+ "coverage": "c8 --exclude='src/**/*.test.mjs' --exclude='e2e/**' node --test --test-reporter=./src/test-reporter.mjs --test-force-exit 'src/**/*.test.mjs' 'e2e/**/*.test.mjs'",
39
40
  "test:predefined-approval": "bash -c 'set -e; mkdir -p tmp; cd tmp; env HOME=. ../bin/plain test-approval'",
40
41
  "lint": "npx @biomejs/biome check",
41
42
  "fix": "npx @biomejs/biome check --fix",
@@ -23,7 +23,7 @@ Usage: $SCRIPT_NAME [--dockerfile FILE]
23
23
  [--publish [HOST_ADDRESS:]HOST_PORT:CONTAINER_PORT]
24
24
  [--tty]
25
25
  [--no-cache]
26
- [--skip-build]
26
+ [--rebuild]
27
27
  [--verbose] [--dry-run]
28
28
  [--keep-alive SECONDS]
29
29
  COMMAND
@@ -69,8 +69,8 @@ Options:
69
69
  If no host address is given, ports bind to 127.0.0.1 by default.
70
70
 
71
71
  --tty Allocate a pseudo-TTY for the container.
72
- --no-cache Disables cache when building the image.
73
- --skip-build Skips building the image; assumes it already exists.
72
+ --no-cache Disables cache when building the image (forces rebuild).
73
+ --rebuild Rebuild the image even if it already exists (cache is still used).
74
74
  --verbose Output verbose logs to stderr.
75
75
  --dry-run Does not execute the command; just prints it to stdout.
76
76
  --keep-alive SECONDS Keep the container running after COMMAND finishes, so it can be reused.
@@ -151,7 +151,7 @@ main() {
151
151
  local env_file=""
152
152
  local allocate_tty="no"
153
153
  local no_cache="no"
154
- local skip_build="no"
154
+ local rebuild="no"
155
155
  local platform=""
156
156
  local keep_alive_seconds="0"
157
157
 
@@ -199,7 +199,7 @@ main() {
199
199
  --env-file ) env_file="$2"; shift 2 ;;
200
200
  --tty ) allocate_tty="yes"; shift ;;
201
201
  --no-cache ) no_cache="yes"; shift ;;
202
- --skip-build ) skip_build="yes"; shift ;;
202
+ --rebuild ) rebuild="yes"; shift ;;
203
203
  --verbose ) VERBOSE="yes"; shift ;;
204
204
  --dry-run ) DRY_RUN="yes"; shift ;;
205
205
  --keep-alive ) keep_alive_seconds="$2"; shift 2 ;;
@@ -504,9 +504,14 @@ EOF
504
504
 
505
505
  trap 'on_exit' EXIT
506
506
 
507
- if test "$skip_build" = "yes"; then
508
- log "Skip building docker image: $IMAGE_NAME"
509
- else
507
+ local should_build="yes"
508
+ if test "$no_cache" = "yes" -o "$rebuild" = "yes"; then
509
+ should_build="yes"
510
+ elif test "$DRY_RUN" = "no" && docker image inspect "${IMAGE_NAME}:${image_tag}" &> /dev/null; then
511
+ should_build="no"
512
+ fi
513
+
514
+ if test "$should_build" = "yes"; then
510
515
  log "Building docker image: $IMAGE_NAME"
511
516
  if test -n "$dockerfile"; then
512
517
  run docker build "${docker_build_opts[@]}" "$docker_build_context"
@@ -514,9 +519,11 @@ EOF
514
519
  run docker build "${docker_build_opts[@]}" - \
515
520
  < <("$SCRIPT_PATH" print_preset_dockerfile)
516
521
  fi
522
+ else
523
+ log "Image already exists, skipping build: $IMAGE_NAME"
517
524
  fi
518
525
 
519
- if test "$skip_build" = "yes" -a "$(docker inspect --type container "$container_name" --format "{{ .State.Running }}" 2> /dev/null)" = "true"; then
526
+ if test "$DRY_RUN" = "no" && test "$(docker inspect --type container "$container_name" --format "{{ .State.Running }}" 2> /dev/null)" = "true"; then
520
527
  log "Container is already running. Reusing $container_name"
521
528
  else
522
529
  log "Stopping any existing container: $container_name"
package/src/cli/args.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  /**
2
- * @typedef {HelpSubcommand | InteractiveSubcommand | BatchSubcommand | ListModelsSubcommand | InstallClaudeCodePluginsSubcommand | CostSubcommand | ResumeSubcommand | TestApprovalSubcommand} Subcommand
2
+ * @typedef {HelpSubcommand | InteractiveSubcommand | BatchSubcommand | ListModelsSubcommand | InstallClaudeCodePluginsSubcommand | CostSubcommand | ResumeSubcommand | TestApprovalSubcommand | SandboxSubcommand} Subcommand
3
3
  */
4
4
 
5
5
  /**
@@ -37,6 +37,12 @@
37
37
  * @typedef {{ type: 'resume', sessionId: string | null, list: boolean, config: string[] }} ResumeSubcommand
38
38
  */
39
39
 
40
+ /**
41
+ * Launch the sandbox command using the app config's sandbox settings.
42
+ * Arguments after `--` are passed through to the sandbox command as-is.
43
+ * @typedef {{ type: 'sandbox', config: string[], passthroughArgs: string[] }} SandboxSubcommand
44
+ */
45
+
40
46
  /**
41
47
  * @typedef {Object} CliArgs
42
48
  * @property {Subcommand} subcommand - The subcommand to execute
@@ -172,6 +178,31 @@ export function parseCliArgs(argv) {
172
178
  };
173
179
  }
174
180
 
181
+ if (subcommandName === "sandbox") {
182
+ const sandboxArgs = args.slice(1);
183
+ /** @type {string[]} */
184
+ const config = [];
185
+ /** @type {string[]} */
186
+ const passthroughArgs = [];
187
+
188
+ for (let i = 0; i < sandboxArgs.length; i++) {
189
+ if (sandboxArgs[i] === "--") {
190
+ passthroughArgs.push(...sandboxArgs.slice(i + 1));
191
+ break;
192
+ }
193
+ if (sandboxArgs[i] === "-c" || sandboxArgs[i] === "--config") {
194
+ if (sandboxArgs[i + 1]) {
195
+ config.push(sandboxArgs[i + 1]);
196
+ i++;
197
+ }
198
+ }
199
+ }
200
+
201
+ return {
202
+ subcommand: { type: "sandbox", config, passthroughArgs },
203
+ };
204
+ }
205
+
175
206
  if (subcommandName === "cost") {
176
207
  const costArgs = args.slice(1);
177
208
  let from = null;
@@ -208,6 +239,7 @@ export function printHelp(exitCode = 0) {
208
239
  Usage: plain [options]
209
240
  plain batch [options] <task>
210
241
  plain resume [<sessionId>] [--list]
242
+ plain sandbox [-c <file>...] -- [sandbox args...]
211
243
  plain cost [--from YYYY-MM-DD] [--to YYYY-MM-DD]
212
244
  plain list-models
213
245
  plain install-claude-code-plugins
@@ -227,6 +259,9 @@ Subcommands:
227
259
  see resumable sessions. Switching models is
228
260
  not supported (-m is rejected).
229
261
  test-approval Run auto-approval rule tests defined in config.
262
+ sandbox Launch the sandbox command using the app
263
+ config's sandbox settings. Arguments after --
264
+ are passed through to the sandbox command as-is.
230
265
  cost Show aggregated token cost per day for a period.
231
266
  Defaults to the first day of the current month
232
267
  through today.
package/src/main.mjs CHANGED
@@ -3,6 +3,7 @@
3
3
  * @import { SessionState } from "./sessionStore.mjs";
4
4
  */
5
5
 
6
+ import { spawn } from "node:child_process";
6
7
  import { randomInt } from "node:crypto";
7
8
  import { styleText } from "node:util";
8
9
  import { createAgent } from "./agent.mjs";
@@ -90,6 +91,32 @@ export async function main(argv = process.argv) {
90
91
  process.exit(exitCode);
91
92
  }
92
93
 
94
+ if (cliArgs.subcommand.type === "sandbox") {
95
+ const { appConfig } = await loadAppConfig({
96
+ configFiles: cliArgs.subcommand.config,
97
+ });
98
+ if (!appConfig.sandbox) {
99
+ console.error(
100
+ "sandbox setting not found in config. Define a sandbox section in your config or specify one with -c.",
101
+ );
102
+ process.exit(1);
103
+ }
104
+ const sandbox = appConfig.sandbox;
105
+ const argsToRun = [
106
+ ...(sandbox.args || []),
107
+ ...cliArgs.subcommand.passthroughArgs,
108
+ ];
109
+ const child = spawn(sandbox.command, argsToRun, { stdio: "inherit" });
110
+ child.on("error", (err) => {
111
+ console.error(`Failed to start sandbox: ${err.message}`);
112
+ process.exit(1);
113
+ });
114
+ child.on("exit", (code) => {
115
+ process.exit(code ?? 0);
116
+ });
117
+ return;
118
+ }
119
+
93
120
  if (cliArgs.subcommand.type === "resume" && cliArgs.subcommand.list) {
94
121
  const sessions = await listSessions();
95
122
  if (sessions.length === 0) {