@iinm/plain-agent 1.7.13 → 1.7.15
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 +19 -5
- package/config/agents.predefined/sandbox-configurator.md +44 -76
- package/config/config.predefined.json +32 -24
- package/package.json +3 -3
- package/src/agent.mjs +22 -0
- package/src/agentLoop.mjs +44 -0
- package/src/cliCommands.mjs +13 -0
- package/src/cliCompleter.mjs +5 -0
- package/src/cliFormatter.mjs +11 -0
- package/src/cliInteractive.mjs +76 -24
- package/src/cliInterruptTransform.mjs +34 -0
- package/src/cliPasteTransform.mjs +134 -80
- package/src/main.mjs +2 -0
- package/src/subagent.mjs +9 -0
- package/src/tools/compactContext.d.ts +4 -0
- package/src/tools/compactContext.mjs +87 -0
package/README.md
CHANGED
|
@@ -46,7 +46,7 @@ Create the configuration.
|
|
|
46
46
|
// ~/.config/plain-agent/config.local.json
|
|
47
47
|
{
|
|
48
48
|
"model": "gpt-5.4+thinking-high",
|
|
49
|
-
// "model": "claude-sonnet-4-6+thinking-
|
|
49
|
+
// "model": "claude-sonnet-4-6+thinking-high",
|
|
50
50
|
|
|
51
51
|
// Configure the providers you want to use
|
|
52
52
|
"platforms": [
|
|
@@ -191,7 +191,7 @@ Create the configuration.
|
|
|
191
191
|
},
|
|
192
192
|
{
|
|
193
193
|
"name": "claude-sonnet-4-6",
|
|
194
|
-
"variant": "thinking-
|
|
194
|
+
"variant": "thinking-high-bedrock-jp",
|
|
195
195
|
"platform": {
|
|
196
196
|
"name": "bedrock",
|
|
197
197
|
"variant": "jp"
|
|
@@ -201,7 +201,8 @@ Create the configuration.
|
|
|
201
201
|
"config": {
|
|
202
202
|
"model": "jp.anthropic.claude-sonnet-4-6",
|
|
203
203
|
"max_tokens": 32768,
|
|
204
|
-
"thinking": { "type": "
|
|
204
|
+
"thinking": { "type": "adaptive" },
|
|
205
|
+
"output_config": { "effort": "high" }
|
|
205
206
|
}
|
|
206
207
|
},
|
|
207
208
|
"cost": {
|
|
@@ -239,6 +240,18 @@ plain
|
|
|
239
240
|
plain -m <model+variant>
|
|
240
241
|
```
|
|
241
242
|
|
|
243
|
+
(Optional) Set up a sandbox for your project with the `sandbox-configurator` agent.
|
|
244
|
+
|
|
245
|
+
```
|
|
246
|
+
/agents:sandbox-configurator Set up a sandbox for this project
|
|
247
|
+
```
|
|
248
|
+
|
|
249
|
+
After the agent finishes, run the generated setup script once to build the sandbox image and install dependencies.
|
|
250
|
+
|
|
251
|
+
```sh
|
|
252
|
+
./.plain-agent/setup.sh
|
|
253
|
+
```
|
|
254
|
+
|
|
242
255
|
Run in batch mode (non-interactive).
|
|
243
256
|
In batch mode, config files are not loaded automatically. Only the files specified with `--config` are loaded.
|
|
244
257
|
|
|
@@ -271,6 +284,7 @@ The agent can use the following tools to assist with tasks:
|
|
|
271
284
|
- **ask_url**: Use one or more provided URLs to answer a question. Include the URLs in your question. (requires Google API key or Vertex AI configuration).
|
|
272
285
|
- **delegate_to_subagent**: Delegate a subtask to a subagent. The agent switches to a subagent role within the same conversation, focusing on the specified goal.
|
|
273
286
|
- **report_as_subagent**: Report completion and return to the main agent. Used by subagents to communicate results and restore the main agent role. After reporting, the subagent's conversation history is removed from the context.
|
|
287
|
+
- **compact_context**: Compact the conversation context by discarding prior messages and reloading task state from a memory file. Use when the context has grown large but the task is not yet complete. Can also be invoked via the `/compact` slash command.
|
|
274
288
|
|
|
275
289
|
## Directory Structure
|
|
276
290
|
|
|
@@ -331,7 +345,7 @@ The agent loads configuration files in the following order. Settings in later fi
|
|
|
331
345
|
},
|
|
332
346
|
"sandbox": {
|
|
333
347
|
"command": "plain-sandbox",
|
|
334
|
-
"args": ["--
|
|
348
|
+
"args": ["--allow-write", "--skip-build", "--keep-alive", "30"],
|
|
335
349
|
"separator": "--",
|
|
336
350
|
"rules": [
|
|
337
351
|
{
|
|
@@ -394,7 +408,7 @@ The agent loads configuration files in the following order. Settings in later fi
|
|
|
394
408
|
// https://github.com/iinm/plain-agent/tree/main/sandbox
|
|
395
409
|
"sandbox": {
|
|
396
410
|
"command": "plain-sandbox",
|
|
397
|
-
"args": ["--
|
|
411
|
+
"args": ["--allow-write", "--skip-build", "--keep-alive", "30"],
|
|
398
412
|
// separator is inserted between sandbox flags and the user command to prevent bypasses
|
|
399
413
|
"separator": "--",
|
|
400
414
|
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Analyzes the project and
|
|
2
|
+
description: Analyzes the project and generates sandbox configuration files (run.sh, setup.sh) tailored to the project's needs.
|
|
3
3
|
---
|
|
4
4
|
|
|
5
|
-
You are a sandbox builder. You analyze the project and generate sandbox configuration files so that commands run in an isolated Docker container.
|
|
5
|
+
You are a sandbox builder. You analyze the project and generate sandbox configuration files so that commands run in an isolated Docker container using the `plain-sandbox` preset image.
|
|
6
6
|
|
|
7
7
|
## Overview
|
|
8
8
|
|
|
@@ -19,48 +19,40 @@ Before generating anything, analyze the project to determine:
|
|
|
19
19
|
|
|
20
20
|
### 1a. Runtime & Tools
|
|
21
21
|
|
|
22
|
-
Detect the project type and determine which runtimes to install via mise
|
|
22
|
+
Detect the project type and determine which runtimes to install via mise. Use the runtime's bundled package managers instead of installing them separately via mise (e.g. Node.js ships with npm; use `corepack enable` for yarn/pnpm).
|
|
23
23
|
|
|
24
|
-
| File found | mise install commands |
|
|
25
|
-
|
|
26
|
-
| `package.json` | `mise use node@<version>`
|
|
27
|
-
| `
|
|
28
|
-
| `package.json` + `yarn.lock` | Add `mise use yarn@latest` |
|
|
29
|
-
| `package.json` + `pnpm-lock.yaml` | Add `mise use pnpm@latest` |
|
|
30
|
-
| `requirements.txt` or `pyproject.toml` | `mise use python@<version>` (check `.python-version`, else 3.12) |
|
|
31
|
-
| `go.mod` | `mise use go@<version>` (check `go.mod` for version directive) |
|
|
32
|
-
| `Cargo.toml` | `mise use rust@latest` |
|
|
33
|
-
| Multiple of the above | All detected runtimes |
|
|
24
|
+
| File found | mise install commands | Version source |
|
|
25
|
+
|---|---|---|
|
|
26
|
+
| `package.json` | `mise use node@<version>` | `.nvmrc` / `.node-version` / `package.json` (`engines.node`) |
|
|
27
|
+
| `requirements.txt` or `pyproject.toml` | `mise use python@<version>` | `.python-version` / `pyproject.toml` (`requires-python`) |
|
|
34
28
|
|
|
35
29
|
Also check for common dev tools:
|
|
36
|
-
- `
|
|
37
|
-
|
|
30
|
+
- `*.tf` files or `.terraform-version` → `mise use terraform@<version>` (version source: `.terraform-version`)
|
|
31
|
+
|
|
32
|
+
If a version cannot be determined from the files above, **ask the user which version to use** rather than falling back to a default.
|
|
38
33
|
|
|
39
34
|
### 1b. Volume Candidates
|
|
40
35
|
|
|
41
|
-
Detect directories that should use Docker volumes
|
|
36
|
+
Detect directories that should use Docker volumes. A Docker volume is preferred over a host bind mount for `node_modules` because:
|
|
37
|
+
|
|
38
|
+
- `node_modules` contains many thousands of small files, and bind-mounting it into the container is slow on macOS/Windows (file sync overhead).
|
|
39
|
+
- Native modules compiled for the host OS/arch can be incompatible with the Linux container, so keeping container-side `node_modules` isolated avoids conflicts.
|
|
42
40
|
|
|
43
41
|
| Project type | Cache volumes | Dependency volumes |
|
|
44
42
|
|---|---|---|
|
|
45
|
-
| Node.js | `plain-sandbox--global--home-npm:/home/sandbox/.npm` | `node_modules` (per package.json dir if monorepo) |
|
|
43
|
+
| Node.js | `plain-sandbox--global--home-npm:/home/sandbox/.npm` | `node_modules` (per `package.json` dir if monorepo) |
|
|
46
44
|
| Python | `plain-sandbox--global--home-pip:/home/sandbox/.cache/pip` | — |
|
|
47
|
-
| Go | `plain-sandbox--global--home-go-pkg:/home/sandbox/go/pkg/mod` | — |
|
|
48
|
-
| Rust | `plain-sandbox--global--home-cargo:/home/sandbox/.cargo/registry` | — |
|
|
49
45
|
|
|
50
|
-
For monorepo detection: if multiple `package.json` files exist (excluding `node_modules`), treat as monorepo and create a volume per `node_modules` directory.
|
|
46
|
+
For monorepo detection: if multiple `package.json` files exist (excluding `node_modules`), treat as a monorepo and create a volume per `node_modules` directory.
|
|
51
47
|
|
|
52
48
|
### 1c. Setup Install Commands
|
|
53
49
|
|
|
54
50
|
| Project type | Install command |
|
|
55
51
|
|---|---|
|
|
56
52
|
| Node.js (npm) | `npm ci` (or `npm install` if no lockfile) |
|
|
57
|
-
| Node.js (yarn) | `yarn install --frozen-lockfile` |
|
|
58
|
-
| Node.js (pnpm) | `pnpm install --frozen-lockfile` |
|
|
53
|
+
| Node.js (yarn) | `corepack enable && yarn install --frozen-lockfile` |
|
|
54
|
+
| Node.js (pnpm) | `corepack enable && pnpm install --frozen-lockfile` |
|
|
59
55
|
| Python | `pip install -r requirements.txt` or `pip install .` |
|
|
60
|
-
| Go | `go mod download` |
|
|
61
|
-
| Rust | `cargo build` |
|
|
62
|
-
|
|
63
|
-
If multiple project types, include all relevant commands.
|
|
64
56
|
|
|
65
57
|
## Step 2: Confirm with User
|
|
66
58
|
|
|
@@ -77,45 +69,7 @@ Ask only one additional question:
|
|
|
77
69
|
|
|
78
70
|
## Step 3: Generate run.sh
|
|
79
71
|
|
|
80
|
-
Generate `.plain-agent/sandbox/run.sh`.
|
|
81
|
-
|
|
82
|
-
### Monorepo handling:
|
|
83
|
-
|
|
84
|
-
If multiple `package.json` files exist, dynamically create volumes for each `node_modules`:
|
|
85
|
-
|
|
86
|
-
```bash
|
|
87
|
-
# Create volumes for each node_modules directory
|
|
88
|
-
for path in $(fd package.json --max-depth 3 | sed -E 's,package.json$,node_modules,'); do
|
|
89
|
-
mkdir -p "$path"
|
|
90
|
-
options+=("--volume" "$path")
|
|
91
|
-
done
|
|
92
|
-
```
|
|
93
|
-
|
|
94
|
-
### Git worktree handling:
|
|
95
|
-
|
|
96
|
-
Always include this block after the options array, before `plain-sandbox`:
|
|
97
|
-
|
|
98
|
-
```bash
|
|
99
|
-
# Mount main worktree if using git worktrees
|
|
100
|
-
git_root=$(git rev-parse --show-toplevel 2>/dev/null || true)
|
|
101
|
-
if test -n "$git_root" && test -f "$git_root/.git"; then
|
|
102
|
-
main_worktree_path=$(sed -E 's,^gitdir: (.+)/.git/.+,\1,' < "$git_root/.git")
|
|
103
|
-
options+=("--mount-writable" "$main_worktree_path:$main_worktree_path")
|
|
104
|
-
fi
|
|
105
|
-
```
|
|
106
|
-
|
|
107
|
-
### gitconfig handling:
|
|
108
|
-
|
|
109
|
-
Include this block only if the user confirmed:
|
|
110
|
-
|
|
111
|
-
```bash
|
|
112
|
-
# Mount gitconfig
|
|
113
|
-
if test -f "$HOME/.gitconfig"; then
|
|
114
|
-
options+=("--mount-readonly" "$HOME/.gitconfig:/home/sandbox/.gitconfig")
|
|
115
|
-
fi
|
|
116
|
-
```
|
|
117
|
-
|
|
118
|
-
### Complete run.sh example (Node.js project):
|
|
72
|
+
Generate `.plain-agent/sandbox/run.sh`. Use the following Node.js example as the template and adapt volumes for other runtimes from the table in Step 1b.
|
|
119
73
|
|
|
120
74
|
```bash
|
|
121
75
|
#!/usr/bin/env bash
|
|
@@ -128,6 +82,13 @@ options=(
|
|
|
128
82
|
--volume node_modules
|
|
129
83
|
)
|
|
130
84
|
|
|
85
|
+
# Monorepo: create a volume for each node_modules directory.
|
|
86
|
+
# Include only when multiple package.json files exist.
|
|
87
|
+
# for path in $(fd package.json --max-depth 3 | sed -E 's,package.json$,node_modules,'); do
|
|
88
|
+
# mkdir -p "$path"
|
|
89
|
+
# options+=("--volume" "$path")
|
|
90
|
+
# done
|
|
91
|
+
|
|
131
92
|
# Mount main worktree if using git worktrees
|
|
132
93
|
git_root=$(git rev-parse --show-toplevel 2>/dev/null || true)
|
|
133
94
|
if test -n "$git_root" && test -f "$git_root/.git"; then
|
|
@@ -135,7 +96,7 @@ if test -n "$git_root" && test -f "$git_root/.git"; then
|
|
|
135
96
|
options+=("--mount-writable" "$main_worktree_path:$main_worktree_path")
|
|
136
97
|
fi
|
|
137
98
|
|
|
138
|
-
# Mount gitconfig
|
|
99
|
+
# Mount gitconfig (include only if the user confirmed)
|
|
139
100
|
if test -f "$HOME/.gitconfig"; then
|
|
140
101
|
options+=("--mount-readonly" "$HOME/.gitconfig:/home/sandbox/.gitconfig")
|
|
141
102
|
fi
|
|
@@ -145,7 +106,7 @@ plain-sandbox "${options[@]}" "$@"
|
|
|
145
106
|
|
|
146
107
|
## Step 4: Generate setup.sh
|
|
147
108
|
|
|
148
|
-
Generate `.plain-agent/setup.sh
|
|
109
|
+
Generate `.plain-agent/setup.sh`. Use the following Node.js example and replace `node@lts` / `npm ci` with the commands chosen in Step 1.
|
|
149
110
|
|
|
150
111
|
```bash
|
|
151
112
|
#!/usr/bin/env bash
|
|
@@ -154,24 +115,31 @@ set -eu -o pipefail
|
|
|
154
115
|
|
|
155
116
|
this_dir="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
156
117
|
|
|
157
|
-
# Setup sandbox (install
|
|
118
|
+
# Setup sandbox (install runtime and dependencies with network access)
|
|
158
119
|
"$this_dir/sandbox/run.sh" --verbose --allow-net 0.0.0.0/0 mise use node@lts
|
|
159
120
|
"$this_dir/sandbox/run.sh" --verbose --allow-net 0.0.0.0/0 npm ci
|
|
160
121
|
|
|
161
|
-
# Setup host
|
|
122
|
+
# Setup host
|
|
162
123
|
npm ci
|
|
163
124
|
```
|
|
164
125
|
|
|
165
|
-
|
|
126
|
+
`--allow-net 0.0.0.0/0` is needed only during setup for downloading packages. It should NOT be in run.sh for normal usage.
|
|
166
127
|
|
|
167
128
|
## Step 5: Show config.json Example
|
|
168
129
|
|
|
169
|
-
After generating all files,
|
|
170
|
-
|
|
171
|
-
```
|
|
172
|
-
Add the following to your .plain-agent/config.json:
|
|
130
|
+
After generating all files, instruct the user to add the following to their `.plain-agent/config.json`:
|
|
173
131
|
|
|
132
|
+
```json
|
|
174
133
|
{
|
|
134
|
+
"autoApproval": {
|
|
135
|
+
"patterns": [
|
|
136
|
+
{
|
|
137
|
+
"toolName": "exec_command",
|
|
138
|
+
"input": { "command": { "$regex": "^(gh|docker)$" } },
|
|
139
|
+
"action": "ask"
|
|
140
|
+
}
|
|
141
|
+
]
|
|
142
|
+
},
|
|
175
143
|
"sandbox": {
|
|
176
144
|
"command": ".plain-agent/sandbox/run.sh",
|
|
177
145
|
"args": ["--skip-build", "--keep-alive", "30"],
|
|
@@ -186,7 +154,7 @@ Add the following to your .plain-agent/config.json:
|
|
|
186
154
|
}
|
|
187
155
|
```
|
|
188
156
|
|
|
189
|
-
If the project already has a `.plain-agent/config.json`, show only the
|
|
157
|
+
If the project already has a `.plain-agent/config.json`, show only the keys that should be added/merged. Remind the user:
|
|
190
158
|
- `--skip-build` assumes the image is already built (run `setup.sh` first to build)
|
|
191
159
|
- `--keep-alive 30` reuses the container for 30 seconds between commands for performance
|
|
192
|
-
- `
|
|
160
|
+
- `gh` and `docker` run unsandboxed (host access needed), so they should also be set to `ask` in `autoApproval` to avoid being auto-approved alongside other shell commands. Place this `ask` pattern before any broad `allow` pattern for `exec_command`, since `autoApproval` patterns are evaluated in order and the first match wins.
|
|
@@ -181,7 +181,7 @@
|
|
|
181
181
|
},
|
|
182
182
|
{
|
|
183
183
|
"name": "claude-sonnet-4-6",
|
|
184
|
-
"variant": "thinking-
|
|
184
|
+
"variant": "thinking-high",
|
|
185
185
|
"platform": {
|
|
186
186
|
"name": "anthropic",
|
|
187
187
|
"variant": "default",
|
|
@@ -192,7 +192,8 @@
|
|
|
192
192
|
"config": {
|
|
193
193
|
"model": "claude-sonnet-4-6",
|
|
194
194
|
"max_tokens": 32768,
|
|
195
|
-
"thinking": { "type": "
|
|
195
|
+
"thinking": { "type": "adaptive" },
|
|
196
|
+
"output_config": { "effort": "high" }
|
|
196
197
|
}
|
|
197
198
|
},
|
|
198
199
|
"cost": {
|
|
@@ -208,7 +209,7 @@
|
|
|
208
209
|
},
|
|
209
210
|
{
|
|
210
211
|
"name": "claude-sonnet-4-6",
|
|
211
|
-
"variant": "thinking-
|
|
212
|
+
"variant": "thinking-max",
|
|
212
213
|
"platform": {
|
|
213
214
|
"name": "anthropic",
|
|
214
215
|
"variant": "default",
|
|
@@ -219,7 +220,8 @@
|
|
|
219
220
|
"config": {
|
|
220
221
|
"model": "claude-sonnet-4-6",
|
|
221
222
|
"max_tokens": 64000,
|
|
222
|
-
"thinking": { "type": "
|
|
223
|
+
"thinking": { "type": "adaptive" },
|
|
224
|
+
"output_config": { "effort": "max" }
|
|
223
225
|
}
|
|
224
226
|
},
|
|
225
227
|
"cost": {
|
|
@@ -234,8 +236,8 @@
|
|
|
234
236
|
}
|
|
235
237
|
},
|
|
236
238
|
{
|
|
237
|
-
"name": "claude-opus-4-
|
|
238
|
-
"variant": "thinking-
|
|
239
|
+
"name": "claude-opus-4-7",
|
|
240
|
+
"variant": "thinking-high",
|
|
239
241
|
"platform": {
|
|
240
242
|
"name": "anthropic",
|
|
241
243
|
"variant": "default",
|
|
@@ -244,9 +246,10 @@
|
|
|
244
246
|
"model": {
|
|
245
247
|
"format": "anthropic",
|
|
246
248
|
"config": {
|
|
247
|
-
"model": "claude-opus-4-
|
|
249
|
+
"model": "claude-opus-4-7",
|
|
248
250
|
"max_tokens": 32768,
|
|
249
|
-
"thinking": { "type": "
|
|
251
|
+
"thinking": { "type": "adaptive" },
|
|
252
|
+
"output_config": { "effort": "high" }
|
|
250
253
|
}
|
|
251
254
|
},
|
|
252
255
|
"cost": {
|
|
@@ -261,8 +264,8 @@
|
|
|
261
264
|
}
|
|
262
265
|
},
|
|
263
266
|
{
|
|
264
|
-
"name": "claude-opus-4-
|
|
265
|
-
"variant": "thinking-
|
|
267
|
+
"name": "claude-opus-4-7",
|
|
268
|
+
"variant": "thinking-max",
|
|
266
269
|
"platform": {
|
|
267
270
|
"name": "anthropic",
|
|
268
271
|
"variant": "default",
|
|
@@ -271,9 +274,10 @@
|
|
|
271
274
|
"model": {
|
|
272
275
|
"format": "anthropic",
|
|
273
276
|
"config": {
|
|
274
|
-
"model": "claude-opus-4-
|
|
277
|
+
"model": "claude-opus-4-7",
|
|
275
278
|
"max_tokens": 64000,
|
|
276
|
-
"thinking": { "type": "
|
|
279
|
+
"thinking": { "type": "adaptive" },
|
|
280
|
+
"output_config": { "effort": "max" }
|
|
277
281
|
}
|
|
278
282
|
},
|
|
279
283
|
"cost": {
|
|
@@ -342,7 +346,7 @@
|
|
|
342
346
|
},
|
|
343
347
|
{
|
|
344
348
|
"name": "claude-sonnet-4-6",
|
|
345
|
-
"variant": "thinking-
|
|
349
|
+
"variant": "thinking-high-bedrock",
|
|
346
350
|
"platform": {
|
|
347
351
|
"name": "bedrock",
|
|
348
352
|
"variant": "default"
|
|
@@ -352,7 +356,8 @@
|
|
|
352
356
|
"config": {
|
|
353
357
|
"model": "global.anthropic.claude-sonnet-4-6",
|
|
354
358
|
"max_tokens": 32768,
|
|
355
|
-
"thinking": { "type": "
|
|
359
|
+
"thinking": { "type": "adaptive" },
|
|
360
|
+
"output_config": { "effort": "high" }
|
|
356
361
|
}
|
|
357
362
|
},
|
|
358
363
|
"cost": {
|
|
@@ -368,7 +373,7 @@
|
|
|
368
373
|
},
|
|
369
374
|
{
|
|
370
375
|
"name": "claude-sonnet-4-6",
|
|
371
|
-
"variant": "thinking-
|
|
376
|
+
"variant": "thinking-max-bedrock",
|
|
372
377
|
"platform": {
|
|
373
378
|
"name": "bedrock",
|
|
374
379
|
"variant": "default"
|
|
@@ -378,7 +383,8 @@
|
|
|
378
383
|
"config": {
|
|
379
384
|
"model": "global.anthropic.claude-sonnet-4-6",
|
|
380
385
|
"max_tokens": 64000,
|
|
381
|
-
"thinking": { "type": "
|
|
386
|
+
"thinking": { "type": "adaptive" },
|
|
387
|
+
"output_config": { "effort": "max" }
|
|
382
388
|
}
|
|
383
389
|
},
|
|
384
390
|
"cost": {
|
|
@@ -393,8 +399,8 @@
|
|
|
393
399
|
}
|
|
394
400
|
},
|
|
395
401
|
{
|
|
396
|
-
"name": "claude-opus-4-
|
|
397
|
-
"variant": "thinking-
|
|
402
|
+
"name": "claude-opus-4-7",
|
|
403
|
+
"variant": "thinking-high-bedrock",
|
|
398
404
|
"platform": {
|
|
399
405
|
"name": "bedrock",
|
|
400
406
|
"variant": "default"
|
|
@@ -402,9 +408,10 @@
|
|
|
402
408
|
"model": {
|
|
403
409
|
"format": "anthropic",
|
|
404
410
|
"config": {
|
|
405
|
-
"model": "global.anthropic.claude-opus-4-
|
|
411
|
+
"model": "global.anthropic.claude-opus-4-7",
|
|
406
412
|
"max_tokens": 32768,
|
|
407
|
-
"thinking": { "type": "
|
|
413
|
+
"thinking": { "type": "adaptive" },
|
|
414
|
+
"output_config": { "effort": "high" }
|
|
408
415
|
}
|
|
409
416
|
},
|
|
410
417
|
"cost": {
|
|
@@ -419,8 +426,8 @@
|
|
|
419
426
|
}
|
|
420
427
|
},
|
|
421
428
|
{
|
|
422
|
-
"name": "claude-opus-4-
|
|
423
|
-
"variant": "thinking-
|
|
429
|
+
"name": "claude-opus-4-7",
|
|
430
|
+
"variant": "thinking-max-bedrock",
|
|
424
431
|
"platform": {
|
|
425
432
|
"name": "bedrock",
|
|
426
433
|
"variant": "default"
|
|
@@ -428,9 +435,10 @@
|
|
|
428
435
|
"model": {
|
|
429
436
|
"format": "anthropic",
|
|
430
437
|
"config": {
|
|
431
|
-
"model": "global.anthropic.claude-opus-4-
|
|
438
|
+
"model": "global.anthropic.claude-opus-4-7",
|
|
432
439
|
"max_tokens": 64000,
|
|
433
|
-
"thinking": { "type": "
|
|
440
|
+
"thinking": { "type": "adaptive" },
|
|
441
|
+
"output_config": { "effort": "max" }
|
|
434
442
|
}
|
|
435
443
|
},
|
|
436
444
|
"cost": {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@iinm/plain-agent",
|
|
3
|
-
"version": "1.7.
|
|
3
|
+
"version": "1.7.15",
|
|
4
4
|
"description": "A lightweight CLI-based coding agent",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
},
|
|
37
37
|
"dependencies": {
|
|
38
38
|
"@aws-crypto/sha256-js": "^5.2.0",
|
|
39
|
-
"@aws-sdk/credential-providers": "^3.
|
|
39
|
+
"@aws-sdk/credential-providers": "^3.1030.0",
|
|
40
40
|
"@cfworker/json-schema": "^4.1.1",
|
|
41
41
|
"@modelcontextprotocol/client": "^2.0.0-alpha.2",
|
|
42
42
|
"@smithy/protocol-http": "^5.3.13",
|
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
"js-yaml": "^4.1.1"
|
|
46
46
|
},
|
|
47
47
|
"devDependencies": {
|
|
48
|
-
"@biomejs/biome": "^2.4.
|
|
48
|
+
"@biomejs/biome": "^2.4.12",
|
|
49
49
|
"@types/js-yaml": "^4.0.9",
|
|
50
50
|
"@types/node": "^22.19.17",
|
|
51
51
|
"typescript": "^5.9.3"
|
package/src/agent.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @import { Agent, AgentConfig, AgentEventEmitter, UserEventEmitter } from "./agent"
|
|
3
3
|
* @import { Tool, ToolDefinition } from "./tool"
|
|
4
|
+
* @import { CompactContextInput } from "./tools/compactContext"
|
|
4
5
|
* @import { DelegateToSubagentInput } from "./tools/delegateToSubagent"
|
|
5
6
|
* @import { ReportAsSubagentInput } from "./tools/reportAsSubagent"
|
|
6
7
|
*/
|
|
@@ -13,6 +14,10 @@ import { createCostTracker } from "./costTracker.mjs";
|
|
|
13
14
|
import { MESSAGES_DUMP_FILE_PATH } from "./env.mjs";
|
|
14
15
|
import { createSubagentManager } from "./subagent.mjs";
|
|
15
16
|
import { createToolExecutor } from "./toolExecutor.mjs";
|
|
17
|
+
import {
|
|
18
|
+
compactContextToolName,
|
|
19
|
+
readMemoryForCompaction,
|
|
20
|
+
} from "./tools/compactContext.mjs";
|
|
16
21
|
import { delegateToSubagentToolName } from "./tools/delegateToSubagent.mjs";
|
|
17
22
|
import { reportAsSubagentToolName } from "./tools/reportAsSubagent.mjs";
|
|
18
23
|
|
|
@@ -89,6 +94,20 @@ export function createAgent({
|
|
|
89
94
|
return result.memoryContent;
|
|
90
95
|
};
|
|
91
96
|
|
|
97
|
+
/**
|
|
98
|
+
* @param {Record<string, unknown>} rawInput
|
|
99
|
+
*/
|
|
100
|
+
const compactContextImpl = async (rawInput) => {
|
|
101
|
+
if (subagentManager.isSubagentActive()) {
|
|
102
|
+
return new Error(
|
|
103
|
+
"compact_context cannot be used while running as a subagent. " +
|
|
104
|
+
"Call report_as_subagent to return to the main agent first.",
|
|
105
|
+
);
|
|
106
|
+
}
|
|
107
|
+
const input = /** @type {CompactContextInput} */ (rawInput);
|
|
108
|
+
return await readMemoryForCompaction(input);
|
|
109
|
+
};
|
|
110
|
+
|
|
92
111
|
/** @type {Map<string, Tool>} */
|
|
93
112
|
const toolByName = new Map();
|
|
94
113
|
for (const tool of tools) {
|
|
@@ -98,6 +117,9 @@ export function createAgent({
|
|
|
98
117
|
if (tool.def.name === reportAsSubagentToolName && tool.injectImpl) {
|
|
99
118
|
tool.injectImpl(reportAsSubagentImpl);
|
|
100
119
|
}
|
|
120
|
+
if (tool.def.name === compactContextToolName && tool.injectImpl) {
|
|
121
|
+
tool.injectImpl(compactContextImpl);
|
|
122
|
+
}
|
|
101
123
|
toolByName.set(tool.def.name, tool);
|
|
102
124
|
}
|
|
103
125
|
|
package/src/agentLoop.mjs
CHANGED
|
@@ -7,6 +7,37 @@
|
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
9
|
import { styleText } from "node:util";
|
|
10
|
+
import { compactContextToolName } from "./tools/compactContext.mjs";
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* If compact_context was called successfully, discard the prior conversation
|
|
14
|
+
* (keeping only the system prompt) and append the tool result as a standard
|
|
15
|
+
* user message so the model can resume from the reloaded memory file.
|
|
16
|
+
* @param {StateManager} stateManager
|
|
17
|
+
* @param {MessageContentToolUse[]} toolUseParts
|
|
18
|
+
* @param {MessageContentToolResult[]} toolResults
|
|
19
|
+
* @returns {boolean} true if compact was applied
|
|
20
|
+
*/
|
|
21
|
+
function applyCompactContextIfCalled(stateManager, toolUseParts, toolResults) {
|
|
22
|
+
const compactToolUse = toolUseParts.find(
|
|
23
|
+
(t) => t.toolName === compactContextToolName,
|
|
24
|
+
);
|
|
25
|
+
if (!compactToolUse) return false;
|
|
26
|
+
|
|
27
|
+
const compactResult = toolResults.find(
|
|
28
|
+
(r) => r.toolUseId === compactToolUse.toolUseId,
|
|
29
|
+
);
|
|
30
|
+
if (!compactResult || compactResult.isError) return false;
|
|
31
|
+
|
|
32
|
+
const systemMessage = stateManager.getMessageAt(0);
|
|
33
|
+
if (!systemMessage) return false;
|
|
34
|
+
|
|
35
|
+
stateManager.setMessages([systemMessage]);
|
|
36
|
+
stateManager.appendMessages([
|
|
37
|
+
{ role: "user", content: compactResult.content },
|
|
38
|
+
]);
|
|
39
|
+
return true;
|
|
40
|
+
}
|
|
10
41
|
|
|
11
42
|
/**
|
|
12
43
|
* @typedef {Object} PauseSignal
|
|
@@ -200,6 +231,12 @@ export function createAgentLoop({
|
|
|
200
231
|
|
|
201
232
|
const toolResults = executionResult.results;
|
|
202
233
|
|
|
234
|
+
if (
|
|
235
|
+
applyCompactContextIfCalled(stateManager, toolUseParts, toolResults)
|
|
236
|
+
) {
|
|
237
|
+
continue;
|
|
238
|
+
}
|
|
239
|
+
|
|
203
240
|
const result = subagentManager.processToolResults(
|
|
204
241
|
toolUseParts,
|
|
205
242
|
toolResults,
|
|
@@ -300,6 +337,13 @@ export function createInputHandler(context) {
|
|
|
300
337
|
}
|
|
301
338
|
|
|
302
339
|
const toolResults = executionResult.results;
|
|
340
|
+
|
|
341
|
+
if (
|
|
342
|
+
applyCompactContextIfCalled(stateManager, toolUseParts, toolResults)
|
|
343
|
+
) {
|
|
344
|
+
return;
|
|
345
|
+
}
|
|
346
|
+
|
|
303
347
|
const result = subagentManager.processToolResults(
|
|
304
348
|
toolUseParts,
|
|
305
349
|
toolResults,
|
package/src/cliCommands.mjs
CHANGED
|
@@ -149,6 +149,19 @@ export function createCommandHandler({
|
|
|
149
149
|
return "prompt";
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
// /compact
|
|
153
|
+
if (inputTrimmed.toLowerCase() === "/compact") {
|
|
154
|
+
const message = [
|
|
155
|
+
'System: This prompt was invoked as "/compact".',
|
|
156
|
+
"",
|
|
157
|
+
"Compact the conversation context:",
|
|
158
|
+
"1. Update the memory file for the current task so it fully captures the task overview, progress, decisions, and next steps in a self-contained way.",
|
|
159
|
+
'2. Then call the "compact_context" tool alone with that memory file path and a brief reason.',
|
|
160
|
+
].join("\n");
|
|
161
|
+
userEventEmitter.emit("userInput", [{ type: "text", text: message }]);
|
|
162
|
+
return "continue";
|
|
163
|
+
}
|
|
164
|
+
|
|
152
165
|
// /agents or /agents:id
|
|
153
166
|
if (inputTrimmed === "/agents") {
|
|
154
167
|
const agentRoles = await loadAgentRoles(claudeCodePlugins);
|
package/src/cliCompleter.mjs
CHANGED
|
@@ -34,6 +34,11 @@ export const SLASH_COMMANDS = [
|
|
|
34
34
|
{ name: "/dump", description: "Save current messages to a JSON file" },
|
|
35
35
|
{ name: "/load", description: "Load messages from a JSON file" },
|
|
36
36
|
{ name: "/cost", description: "Display session cost and token usage" },
|
|
37
|
+
{
|
|
38
|
+
name: "/compact",
|
|
39
|
+
description:
|
|
40
|
+
"Ask the agent to compact the context by reloading from a memory file",
|
|
41
|
+
},
|
|
37
42
|
];
|
|
38
43
|
|
|
39
44
|
/**
|
package/src/cliFormatter.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* @import { Message, MessageContentToolUse, MessageContentToolResult, ProviderTokenUsage } from "./model"
|
|
3
|
+
* @import { CompactContextInput } from "./tools/compactContext"
|
|
3
4
|
* @import { ExecCommandInput } from "./tools/execCommand"
|
|
4
5
|
* @import { PatchFileInput } from "./tools/patchFile"
|
|
5
6
|
* @import { WriteFileInput } from "./tools/writeFile"
|
|
@@ -95,6 +96,16 @@ export function formatToolUse(toolUse) {
|
|
|
95
96
|
].join("\n");
|
|
96
97
|
}
|
|
97
98
|
|
|
99
|
+
if (toolName === "compact_context") {
|
|
100
|
+
/** @type {Partial<CompactContextInput>} */
|
|
101
|
+
const compactContextInput = input;
|
|
102
|
+
return [
|
|
103
|
+
`tool: ${toolName}`,
|
|
104
|
+
`memoryPath: ${compactContextInput.memoryPath}`,
|
|
105
|
+
`reason: ${compactContextInput.reason}`,
|
|
106
|
+
].join("\n");
|
|
107
|
+
}
|
|
108
|
+
|
|
98
109
|
if (toolName === "report_as_subagent") {
|
|
99
110
|
/** @type {Partial<import("./tools/reportAsSubagent").ReportAsSubagentInput>} */
|
|
100
111
|
const reportAsSubagentInput = input;
|
package/src/cliInteractive.mjs
CHANGED
|
@@ -12,10 +12,8 @@ import {
|
|
|
12
12
|
formatProviderTokenUsage,
|
|
13
13
|
printMessage,
|
|
14
14
|
} from "./cliFormatter.mjs";
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
resolvePastePlaceholders,
|
|
18
|
-
} from "./cliPasteTransform.mjs";
|
|
15
|
+
import { createInterruptTransform } from "./cliInterruptTransform.mjs";
|
|
16
|
+
import { createPasteHandler } from "./cliPasteTransform.mjs";
|
|
19
17
|
import { notify } from "./utils/notify.mjs";
|
|
20
18
|
|
|
21
19
|
const HELP_MESSAGE = [
|
|
@@ -82,7 +80,7 @@ export function startInteractiveSession({
|
|
|
82
80
|
subagentName: "",
|
|
83
81
|
};
|
|
84
82
|
|
|
85
|
-
const getCliPrompt = (subagentName = "") =>
|
|
83
|
+
const getCliPrompt = (subagentName = "", flashMessage = "") =>
|
|
86
84
|
[
|
|
87
85
|
"",
|
|
88
86
|
styleText(
|
|
@@ -92,6 +90,7 @@ export function startInteractiveSession({
|
|
|
92
90
|
`session: ${sessionId} | model: ${modelName} | sandbox: ${sandbox ? "on" : "off"}`,
|
|
93
91
|
].join(" "),
|
|
94
92
|
),
|
|
93
|
+
...(flashMessage ? [flashMessage] : []),
|
|
95
94
|
"> ",
|
|
96
95
|
].join("\n");
|
|
97
96
|
|
|
@@ -116,37 +115,91 @@ export function startInteractiveSession({
|
|
|
116
115
|
process.exit(0);
|
|
117
116
|
};
|
|
118
117
|
|
|
119
|
-
// Double-press exit confirmation
|
|
120
|
-
let
|
|
118
|
+
// Double-press Ctrl-D exit confirmation
|
|
119
|
+
let lastCtrlDAttempt = 0;
|
|
121
120
|
const EXIT_CONFIRM_TIMEOUT = 1500;
|
|
122
121
|
|
|
122
|
+
/** @type {import("node:readline").Interface} */
|
|
123
|
+
let cli;
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* Clear the current readline input line and redraw the prompt.
|
|
127
|
+
* Also aborts multi-line input mode if active.
|
|
128
|
+
*/
|
|
129
|
+
const resetInput = () => {
|
|
130
|
+
if (state.multiLineBuffer !== null) {
|
|
131
|
+
state.multiLineBuffer = null;
|
|
132
|
+
cli.setPrompt(currentCliPrompt);
|
|
133
|
+
}
|
|
134
|
+
cli.write(null, { ctrl: true, name: "a" }); // move to line start
|
|
135
|
+
cli.write(null, { ctrl: true, name: "k" }); // delete to line end
|
|
136
|
+
cli.prompt();
|
|
137
|
+
};
|
|
138
|
+
|
|
123
139
|
const handleCtrlC = () => {
|
|
124
|
-
//
|
|
140
|
+
// Agent turn: pause auto-approve; do not clear input.
|
|
125
141
|
if (!state.turn) {
|
|
126
142
|
agentCommands.pauseAutoApprove();
|
|
127
143
|
console.log(
|
|
128
144
|
styleText(
|
|
129
145
|
"yellow",
|
|
130
|
-
"\n
|
|
146
|
+
"\n\n⚠️ Ctrl-C: Auto-approve paused. Finishing current tool...\nPress Ctrl-D twice to exit.\n",
|
|
147
|
+
),
|
|
148
|
+
);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// User turn: clear current input. On empty input, show exit hint.
|
|
153
|
+
const hasInput = cli.line.length > 0 || state.multiLineBuffer !== null;
|
|
154
|
+
if (hasInput) {
|
|
155
|
+
resetInput();
|
|
156
|
+
} else {
|
|
157
|
+
cli.setPrompt(
|
|
158
|
+
getCliPrompt(
|
|
159
|
+
state.subagentName,
|
|
160
|
+
styleText("yellow", "Press Ctrl-D twice to exit"),
|
|
131
161
|
),
|
|
132
162
|
);
|
|
163
|
+
cli.prompt();
|
|
164
|
+
}
|
|
165
|
+
// Reset Ctrl-D confirmation when Ctrl-C is pressed
|
|
166
|
+
lastCtrlDAttempt = 0;
|
|
167
|
+
};
|
|
168
|
+
|
|
169
|
+
const handleCtrlD = () => {
|
|
170
|
+
// User turn with non-empty input: ignore Ctrl-D entirely.
|
|
171
|
+
if (state.turn && (cli.line.length > 0 || state.multiLineBuffer !== null)) {
|
|
133
172
|
return;
|
|
134
173
|
}
|
|
135
174
|
|
|
136
175
|
const now = Date.now();
|
|
137
|
-
if (now -
|
|
176
|
+
if (now - lastCtrlDAttempt < EXIT_CONFIRM_TIMEOUT) {
|
|
138
177
|
handleExit();
|
|
139
178
|
return;
|
|
140
179
|
}
|
|
141
|
-
|
|
142
|
-
|
|
180
|
+
lastCtrlDAttempt = now;
|
|
181
|
+
if (state.turn) {
|
|
182
|
+
cli.setPrompt(
|
|
183
|
+
getCliPrompt(
|
|
184
|
+
state.subagentName,
|
|
185
|
+
styleText("yellow", "Press Ctrl-D again to exit."),
|
|
186
|
+
),
|
|
187
|
+
);
|
|
188
|
+
cli.prompt();
|
|
189
|
+
} else {
|
|
190
|
+
console.log(styleText("yellow", "\n\n⚠️ Press Ctrl-D again to exit.\n"));
|
|
191
|
+
}
|
|
143
192
|
};
|
|
144
193
|
|
|
145
|
-
//
|
|
146
|
-
|
|
194
|
+
// Pre-readline pipeline:
|
|
195
|
+
// stdin -> interrupt (Ctrl-C / Ctrl-D) -> paste (bracketed paste) -> readline
|
|
196
|
+
const interrupt = createInterruptTransform({
|
|
197
|
+
onCtrlC: handleCtrlC,
|
|
198
|
+
onCtrlD: handleCtrlD,
|
|
199
|
+
});
|
|
200
|
+
const paste = createPasteHandler();
|
|
147
201
|
|
|
148
|
-
|
|
149
|
-
process.stdin.pipe(pasteTransform);
|
|
202
|
+
process.stdin.pipe(interrupt).pipe(paste.transform);
|
|
150
203
|
|
|
151
204
|
// Enable bracketed paste mode
|
|
152
205
|
if (process.stdout.isTTY) {
|
|
@@ -154,9 +207,8 @@ export function startInteractiveSession({
|
|
|
154
207
|
}
|
|
155
208
|
|
|
156
209
|
let currentCliPrompt = getCliPrompt();
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
input: pasteTransform,
|
|
210
|
+
cli = readline.createInterface({
|
|
211
|
+
input: paste.transform,
|
|
160
212
|
output: process.stdout,
|
|
161
213
|
prompt: currentCliPrompt,
|
|
162
214
|
completer: createCompleter(() => cli, claudeCodePlugins),
|
|
@@ -199,7 +251,7 @@ export function startInteractiveSession({
|
|
|
199
251
|
state.turn = false;
|
|
200
252
|
|
|
201
253
|
// Resolve paste placeholders to original content
|
|
202
|
-
const resolvedInput =
|
|
254
|
+
const resolvedInput = paste.resolvePlaceholders(input);
|
|
203
255
|
const inputTrimmed = resolvedInput.trim();
|
|
204
256
|
|
|
205
257
|
if (inputTrimmed.length === 0) {
|
|
@@ -281,13 +333,13 @@ export function startInteractiveSession({
|
|
|
281
333
|
|
|
282
334
|
agentEventEmitter.on("toolUseRequest", () => {
|
|
283
335
|
cli.setPrompt(
|
|
284
|
-
|
|
336
|
+
getCliPrompt(
|
|
337
|
+
state.subagentName,
|
|
285
338
|
styleText(
|
|
286
339
|
"yellow",
|
|
287
|
-
"
|
|
340
|
+
"Approve tool calls? (y = allow once, Y = allow in this session, or feedback)",
|
|
288
341
|
),
|
|
289
|
-
|
|
290
|
-
].join("\n"),
|
|
342
|
+
),
|
|
291
343
|
);
|
|
292
344
|
});
|
|
293
345
|
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Transform } from "node:stream";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Create a Transform that intercepts Ctrl-C (0x03) and Ctrl-D (0x04). When
|
|
5
|
+
* either byte is seen anywhere in a chunk, the corresponding callback is
|
|
6
|
+
* invoked and the entire chunk is dropped so that downstream consumers (e.g.
|
|
7
|
+
* readline) never observe it. All other input flows through unchanged.
|
|
8
|
+
*
|
|
9
|
+
* If both bytes appear in the same chunk, Ctrl-C is handled first.
|
|
10
|
+
*
|
|
11
|
+
* @param {object} handlers
|
|
12
|
+
* @param {() => void} handlers.onCtrlC - Called when Ctrl-C is detected
|
|
13
|
+
* @param {() => void} handlers.onCtrlD - Called when Ctrl-D is detected
|
|
14
|
+
* @returns {Transform}
|
|
15
|
+
*/
|
|
16
|
+
export function createInterruptTransform({ onCtrlC, onCtrlD }) {
|
|
17
|
+
return new Transform({
|
|
18
|
+
transform(chunk, _encoding, callback) {
|
|
19
|
+
const data = chunk.toString("utf8");
|
|
20
|
+
if (data.includes("\x03")) {
|
|
21
|
+
onCtrlC();
|
|
22
|
+
callback();
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
if (data.includes("\x04")) {
|
|
26
|
+
onCtrlD();
|
|
27
|
+
callback();
|
|
28
|
+
return;
|
|
29
|
+
}
|
|
30
|
+
this.push(chunk);
|
|
31
|
+
callback();
|
|
32
|
+
},
|
|
33
|
+
});
|
|
34
|
+
}
|
|
@@ -4,11 +4,21 @@ import { Transform } from "node:stream";
|
|
|
4
4
|
const BRACKETED_PASTE_START = "\x1b[200~";
|
|
5
5
|
const BRACKETED_PASTE_END = "\x1b[201~";
|
|
6
6
|
|
|
7
|
-
//
|
|
8
|
-
|
|
7
|
+
// Time to wait for a continuation paste chunk before flushing the paste buffer.
|
|
8
|
+
// Some terminals split large pastes into multiple bracketed paste sequences
|
|
9
|
+
// (e.g. `\x1b[200~...\x1b[201~\x1b[200~...\x1b[201~`) that arrive back-to-back.
|
|
10
|
+
// Holding the paste briefly lets us merge them into a single placeholder.
|
|
11
|
+
const PASTE_MERGE_WINDOW_MS = 20;
|
|
12
|
+
|
|
13
|
+
// Paste state machine:
|
|
14
|
+
// IDLE - normal passthrough
|
|
15
|
+
// PASTE - inside a BRACKETED_PASTE_START ... BRACKETED_PASTE_END sequence
|
|
16
|
+
// PENDING - just saw an END; waiting to see if the next data continues the
|
|
17
|
+
// paste (another START immediately follows) or not.
|
|
18
|
+
/** @typedef {"IDLE" | "PASTE" | "PENDING"} PasteState */
|
|
9
19
|
|
|
10
20
|
/**
|
|
11
|
-
* Generate a short hash for paste reference
|
|
21
|
+
* Generate a short hash for paste reference.
|
|
12
22
|
* @param {string} content
|
|
13
23
|
* @returns {string}
|
|
14
24
|
*/
|
|
@@ -23,107 +33,151 @@ function generatePasteHash(content) {
|
|
|
23
33
|
}
|
|
24
34
|
|
|
25
35
|
/**
|
|
26
|
-
*
|
|
27
|
-
* @
|
|
28
|
-
*
|
|
36
|
+
* @typedef {object} PasteHandler
|
|
37
|
+
* @property {Transform} transform
|
|
38
|
+
* Transform stream to pipe stdin through. Emits placeholders for multi-line
|
|
39
|
+
* pastes and raw text for single-line pastes / typed input.
|
|
40
|
+
* @property {(input: string) => string} resolvePlaceholders
|
|
41
|
+
* Given a string containing placeholders produced by `transform`, append a
|
|
42
|
+
* `<context id="pasted#HASH">...</context>` block for each referenced paste
|
|
43
|
+
* and consume the stored content. Unknown placeholders are left untouched.
|
|
29
44
|
*/
|
|
30
|
-
export function resolvePastePlaceholders(input) {
|
|
31
|
-
/** @type {string[]} */
|
|
32
|
-
const contexts = [];
|
|
33
|
-
|
|
34
|
-
// Collect paste content for context tags while keeping placeholders
|
|
35
|
-
const text = input.replace(
|
|
36
|
-
/\[Pasted text #([a-f0-9]{6}),/g,
|
|
37
|
-
(match, hash) => {
|
|
38
|
-
const content = pastedContentStore.get(hash);
|
|
39
|
-
if (content !== undefined) {
|
|
40
|
-
pastedContentStore.delete(hash); // Clean up after use
|
|
41
|
-
contexts.push(`<context id="pasted#${hash}">\n${content}\n</context>`);
|
|
42
|
-
}
|
|
43
|
-
return match; // Keep placeholder in text
|
|
44
|
-
},
|
|
45
|
-
);
|
|
46
|
-
|
|
47
|
-
// Append contexts to the end of input
|
|
48
|
-
if (contexts.length > 0) {
|
|
49
|
-
return [text, ...contexts].join("\n\n");
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
return text;
|
|
53
|
-
}
|
|
54
45
|
|
|
55
46
|
/**
|
|
56
|
-
* Create a
|
|
57
|
-
*
|
|
58
|
-
*
|
|
47
|
+
* Create a bracketed-paste handler. The handler owns its own content store so
|
|
48
|
+
* pastes from one handler instance cannot interfere with another (and state
|
|
49
|
+
* does not leak across tests).
|
|
50
|
+
*
|
|
51
|
+
* @returns {PasteHandler}
|
|
59
52
|
*/
|
|
60
|
-
export function
|
|
61
|
-
|
|
53
|
+
export function createPasteHandler() {
|
|
54
|
+
/** @type {Map<string, string>} */
|
|
55
|
+
const pastedContentStore = new Map();
|
|
56
|
+
|
|
57
|
+
/** @type {PasteState} */
|
|
58
|
+
let state = "IDLE";
|
|
62
59
|
let pasteBuffer = "";
|
|
60
|
+
/** @type {NodeJS.Timeout | null} */
|
|
61
|
+
let mergeTimer = null;
|
|
62
|
+
/** @type {Transform} */
|
|
63
|
+
let transform;
|
|
63
64
|
|
|
64
|
-
|
|
65
|
+
const clearMergeTimer = () => {
|
|
66
|
+
if (mergeTimer) {
|
|
67
|
+
clearTimeout(mergeTimer);
|
|
68
|
+
mergeTimer = null;
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const flushPasteBuffer = () => {
|
|
73
|
+
clearMergeTimer();
|
|
74
|
+
if (pasteBuffer) {
|
|
75
|
+
// Strip a trailing newline so a paste like "foo\n" is treated as single-line.
|
|
76
|
+
const trimmed = pasteBuffer.replace(/\n$/, "");
|
|
77
|
+
if (trimmed.includes("\n")) {
|
|
78
|
+
// Multi-line: emit a placeholder and stash the content for later.
|
|
79
|
+
const hash = generatePasteHash(pasteBuffer);
|
|
80
|
+
pastedContentStore.set(hash, pasteBuffer);
|
|
81
|
+
const lineCount = pasteBuffer.split("\n").length;
|
|
82
|
+
transform.push(`[Pasted text #${hash}, ${lineCount} lines]`);
|
|
83
|
+
} else {
|
|
84
|
+
transform.push(trimmed);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
pasteBuffer = "";
|
|
88
|
+
state = "IDLE";
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
transform = new Transform({
|
|
65
92
|
transform(chunk, _encoding, callback) {
|
|
66
93
|
/** @type {string} */
|
|
67
94
|
let data = chunk.toString("utf8");
|
|
68
95
|
|
|
69
|
-
// Handle Ctrl-C and Ctrl-D
|
|
70
|
-
if (data.includes("\x03") || data.includes("\x04")) {
|
|
71
|
-
onCtrlC();
|
|
72
|
-
callback();
|
|
73
|
-
return;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
96
|
while (data.length > 0) {
|
|
77
|
-
if (
|
|
97
|
+
if (state === "PASTE") {
|
|
78
98
|
const endIdx = data.indexOf(BRACKETED_PASTE_END);
|
|
79
|
-
if (endIdx
|
|
80
|
-
|
|
99
|
+
if (endIdx === -1) {
|
|
100
|
+
pasteBuffer += data;
|
|
101
|
+
data = "";
|
|
102
|
+
} else {
|
|
103
|
+
// End of (this chunk of) paste. Hold briefly in case another paste
|
|
104
|
+
// chunk follows immediately and should be merged.
|
|
81
105
|
pasteBuffer += data.slice(0, endIdx);
|
|
82
106
|
data = data.slice(endIdx + BRACKETED_PASTE_END.length);
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
if (!trimmedPaste.includes("\n")) {
|
|
92
|
-
this.push(trimmedPaste);
|
|
93
|
-
} else {
|
|
94
|
-
// For multi-line paste, use placeholder
|
|
95
|
-
const hash = generatePasteHash(pasteBuffer);
|
|
96
|
-
pastedContentStore.set(hash, pasteBuffer);
|
|
97
|
-
const lines = pasteBuffer.split("\n");
|
|
98
|
-
this.push(`[Pasted text #${hash}, ${lines.length} lines]`);
|
|
99
|
-
}
|
|
100
|
-
}
|
|
101
|
-
pasteBuffer = "";
|
|
107
|
+
state = "PENDING";
|
|
108
|
+
}
|
|
109
|
+
} else if (state === "PENDING") {
|
|
110
|
+
if (data.startsWith(BRACKETED_PASTE_START)) {
|
|
111
|
+
// Continuation of the previous paste; keep appending to pasteBuffer.
|
|
112
|
+
data = data.slice(BRACKETED_PASTE_START.length);
|
|
113
|
+
clearMergeTimer();
|
|
114
|
+
state = "PASTE";
|
|
102
115
|
} else {
|
|
103
|
-
//
|
|
104
|
-
|
|
105
|
-
break;
|
|
116
|
+
// Not a continuation; flush, then re-process this data as IDLE.
|
|
117
|
+
flushPasteBuffer();
|
|
106
118
|
}
|
|
107
119
|
} else {
|
|
120
|
+
// IDLE
|
|
108
121
|
const startIdx = data.indexOf(BRACKETED_PASTE_START);
|
|
109
|
-
if (startIdx
|
|
110
|
-
// Start of paste
|
|
111
|
-
// Output any data before the paste
|
|
112
|
-
if (startIdx > 0) {
|
|
113
|
-
this.push(data.slice(0, startIdx));
|
|
114
|
-
}
|
|
115
|
-
data = data.slice(startIdx + BRACKETED_PASTE_START.length);
|
|
116
|
-
inPasteMode = true;
|
|
117
|
-
pasteBuffer = "";
|
|
118
|
-
} else {
|
|
119
|
-
// Normal data
|
|
122
|
+
if (startIdx === -1) {
|
|
120
123
|
this.push(data);
|
|
121
|
-
|
|
124
|
+
data = "";
|
|
125
|
+
} else {
|
|
126
|
+
this.push(data.slice(0, startIdx));
|
|
127
|
+
data = data.slice(startIdx + BRACKETED_PASTE_START.length);
|
|
128
|
+
state = "PASTE";
|
|
122
129
|
}
|
|
123
130
|
}
|
|
124
131
|
}
|
|
125
132
|
|
|
133
|
+
// If the chunk ended while still waiting for a possible continuation,
|
|
134
|
+
// schedule a short timer to flush the pending paste if nothing arrives.
|
|
135
|
+
if (state === "PENDING" && !mergeTimer) {
|
|
136
|
+
mergeTimer = setTimeout(() => {
|
|
137
|
+
mergeTimer = null;
|
|
138
|
+
flushPasteBuffer();
|
|
139
|
+
}, PASTE_MERGE_WINDOW_MS);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
callback();
|
|
143
|
+
},
|
|
144
|
+
|
|
145
|
+
flush(callback) {
|
|
146
|
+
if (state === "PENDING") {
|
|
147
|
+
flushPasteBuffer();
|
|
148
|
+
}
|
|
126
149
|
callback();
|
|
127
150
|
},
|
|
128
151
|
});
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* @param {string} input
|
|
155
|
+
* @returns {string}
|
|
156
|
+
*/
|
|
157
|
+
const resolvePlaceholders = (input) => {
|
|
158
|
+
/** @type {string[]} */
|
|
159
|
+
const contexts = [];
|
|
160
|
+
|
|
161
|
+
// Collect paste content for context tags while keeping placeholders.
|
|
162
|
+
const text = input.replace(
|
|
163
|
+
/\[Pasted text #([a-f0-9]{6}),/g,
|
|
164
|
+
(match, hash) => {
|
|
165
|
+
const content = pastedContentStore.get(hash);
|
|
166
|
+
if (content !== undefined) {
|
|
167
|
+
pastedContentStore.delete(hash); // Clean up after use
|
|
168
|
+
contexts.push(
|
|
169
|
+
`<context id="pasted#${hash}">\n${content}\n</context>`,
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
return match; // Keep placeholder in text
|
|
173
|
+
},
|
|
174
|
+
);
|
|
175
|
+
|
|
176
|
+
if (contexts.length > 0) {
|
|
177
|
+
return [text, ...contexts].join("\n\n");
|
|
178
|
+
}
|
|
179
|
+
return text;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
return { transform, resolvePlaceholders };
|
|
129
183
|
}
|
package/src/main.mjs
CHANGED
|
@@ -24,6 +24,7 @@ import { createModelCaller } from "./modelCaller.mjs";
|
|
|
24
24
|
import { createPrompt } from "./prompt.mjs";
|
|
25
25
|
import { createAskURLTool } from "./tools/askURL.mjs";
|
|
26
26
|
import { createAskWebTool } from "./tools/askWeb.mjs";
|
|
27
|
+
import { createCompactContextTool } from "./tools/compactContext.mjs";
|
|
27
28
|
import { createDelegateToSubagentTool } from "./tools/delegateToSubagent.mjs";
|
|
28
29
|
import { createExecCommandTool } from "./tools/execCommand.mjs";
|
|
29
30
|
import { createPatchFileTool } from "./tools/patchFile.mjs";
|
|
@@ -164,6 +165,7 @@ if (cliArgs.subcommand.type === "install-claude-code-plugins") {
|
|
|
164
165
|
writeFileTool,
|
|
165
166
|
createPatchFileTool(),
|
|
166
167
|
createTmuxCommandTool({ sandbox: appConfig.sandbox }),
|
|
168
|
+
createCompactContextTool(),
|
|
167
169
|
createDelegateToSubagentTool(),
|
|
168
170
|
createReportAsSubagentTool(),
|
|
169
171
|
];
|
package/src/subagent.mjs
CHANGED
|
@@ -246,9 +246,18 @@ export function createSubagentManager(agentRoles, handlers) {
|
|
|
246
246
|
return { messages: truncatedMessages, newMessage };
|
|
247
247
|
}
|
|
248
248
|
|
|
249
|
+
/**
|
|
250
|
+
* Whether the main agent is currently running as a subagent.
|
|
251
|
+
* @returns {boolean}
|
|
252
|
+
*/
|
|
253
|
+
function isSubagentActive() {
|
|
254
|
+
return subagents.length > 0;
|
|
255
|
+
}
|
|
256
|
+
|
|
249
257
|
return {
|
|
250
258
|
delegateToSubagent,
|
|
251
259
|
reportAsSubagent,
|
|
252
260
|
processToolResults,
|
|
261
|
+
isSubagentActive,
|
|
253
262
|
};
|
|
254
263
|
}
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @import { Tool, ToolImplementation } from '../tool'
|
|
3
|
+
* @import { CompactContextInput } from './compactContext'
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import fs from "node:fs/promises";
|
|
7
|
+
import path from "node:path";
|
|
8
|
+
import { AGENT_MEMORY_DIR } from "../env.mjs";
|
|
9
|
+
import { noThrow } from "../utils/noThrow.mjs";
|
|
10
|
+
|
|
11
|
+
export const compactContextToolName = "compact_context";
|
|
12
|
+
|
|
13
|
+
/** @returns {Tool} */
|
|
14
|
+
export function createCompactContextTool() {
|
|
15
|
+
/** @type {ToolImplementation} */
|
|
16
|
+
let impl = async () => {
|
|
17
|
+
throw new Error("Not implemented");
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** @type {Tool} */
|
|
21
|
+
const tool = {
|
|
22
|
+
def: {
|
|
23
|
+
name: compactContextToolName,
|
|
24
|
+
description:
|
|
25
|
+
"Discard prior messages and reload task state from a memory file.",
|
|
26
|
+
inputSchema: {
|
|
27
|
+
type: "object",
|
|
28
|
+
properties: {
|
|
29
|
+
memoryPath: {
|
|
30
|
+
type: "string",
|
|
31
|
+
description: `Path to the memory file under ${AGENT_MEMORY_DIR}/.`,
|
|
32
|
+
},
|
|
33
|
+
reason: {
|
|
34
|
+
type: "string",
|
|
35
|
+
description: "The reason for compacting the context.",
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
required: ["memoryPath", "reason"],
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
|
|
42
|
+
// Implementation is injected by the agent so it can access subagent
|
|
43
|
+
// state (compact_context is not allowed during subagent execution).
|
|
44
|
+
get impl() {
|
|
45
|
+
return impl;
|
|
46
|
+
},
|
|
47
|
+
|
|
48
|
+
injectImpl(fn) {
|
|
49
|
+
impl = fn;
|
|
50
|
+
},
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return tool;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Read a memory file and return the compact_context tool result string.
|
|
58
|
+
* Validates that the memoryPath is within the project memory directory.
|
|
59
|
+
* @param {CompactContextInput} input
|
|
60
|
+
* @returns {Promise<string | Error>}
|
|
61
|
+
*/
|
|
62
|
+
export async function readMemoryForCompaction(input) {
|
|
63
|
+
return await noThrow(async () => {
|
|
64
|
+
const absolutePath = path.resolve(input.memoryPath);
|
|
65
|
+
const memoryDir = path.resolve(AGENT_MEMORY_DIR);
|
|
66
|
+
const relativePath = path.relative(memoryDir, absolutePath);
|
|
67
|
+
if (relativePath.startsWith("..") || path.isAbsolute(relativePath)) {
|
|
68
|
+
return new Error(
|
|
69
|
+
`Access denied: memoryPath must be within ${AGENT_MEMORY_DIR}`,
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const memoryContent = await fs.readFile(absolutePath, {
|
|
74
|
+
encoding: "utf-8",
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
return [
|
|
78
|
+
"Context compacted. Prior conversation has been discarded.",
|
|
79
|
+
`Reason: ${input.reason}`,
|
|
80
|
+
`Memory file: ${input.memoryPath}`,
|
|
81
|
+
"",
|
|
82
|
+
"Resume the task using the memory file contents below.",
|
|
83
|
+
"",
|
|
84
|
+
memoryContent,
|
|
85
|
+
].join("\n");
|
|
86
|
+
});
|
|
87
|
+
}
|