@iinm/plain-agent 1.12.1 → 1.13.1
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 +16 -10
- package/package.json +5 -4
- package/sandbox/bin/plain-sandbox +16 -9
- package/src/agentLoop.mjs +7 -0
- package/src/cli/args.mjs +36 -1
- package/src/cli/interactive.mjs +1 -1
- package/src/main.mjs +27 -0
package/README.md
CHANGED
|
@@ -1,10 +1,8 @@
|
|
|
1
1
|
# Plain Agent
|
|
2
2
|
|
|
3
|
-
[](https://deepwiki.com/iinm/plain-agent)
|
|
4
|
-
[](https://www.npmjs.com/package/@iinm/plain-agent)
|
|
5
|
-
[](https://packagephobia.com/result?p=@iinm/plain-agent)
|
|
6
|
-
[](https://socket.dev/npm/package/@iinm/plain-agent)
|
|
7
3
|
[](https://github.com/iinm/plain-agent/actions/workflows/github-code-scanning/codeql)
|
|
4
|
+
[](https://socket.dev/npm/package/@iinm/plain-agent)
|
|
5
|
+
[](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", "--
|
|
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", "--
|
|
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", "--
|
|
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": "--",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iinm/plain-agent",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.13.1",
|
|
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
|
-
[--
|
|
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
|
-
--
|
|
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
|
|
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
|
-
--
|
|
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
|
-
|
|
508
|
-
|
|
509
|
-
|
|
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 "$
|
|
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/agentLoop.mjs
CHANGED
|
@@ -206,6 +206,13 @@ export function createAgentLoop({
|
|
|
206
206
|
break;
|
|
207
207
|
}
|
|
208
208
|
|
|
209
|
+
// Ctrl-C during model call: skip execution and ask for approval
|
|
210
|
+
if (pauseSignal.isPaused()) {
|
|
211
|
+
pauseSignal.reset();
|
|
212
|
+
agentEventEmitter.emit("toolUseRequest", toolUseParts.length);
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
|
|
209
216
|
const executionResult = await toolExecutor.executeBatch(toolUseParts);
|
|
210
217
|
|
|
211
218
|
if (!executionResult.success) {
|
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/cli/interactive.mjs
CHANGED
|
@@ -188,7 +188,7 @@ export function startInteractiveSession({
|
|
|
188
188
|
console.error(
|
|
189
189
|
styleText(
|
|
190
190
|
"yellow",
|
|
191
|
-
"\n\n⚠️ Ctrl-C: Auto-approve paused
|
|
191
|
+
"\n\n⚠️ Ctrl-C: Auto-approve paused.\nPress Ctrl-D twice to exit.\n",
|
|
192
192
|
),
|
|
193
193
|
);
|
|
194
194
|
return;
|
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) {
|