@iinm/plain-agent 1.7.14 → 1.7.16

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
@@ -4,21 +4,28 @@
4
4
 
5
5
  # Plain Agent
6
6
 
7
- A lightweight CLI-based coding agent.
7
+ A lightweight CLI-based coding agent with zero framework dependencies.
8
8
 
9
- - **Safety controls** — Configure approval rules and sandboxing for safe execution
10
- - **Multi-provider** — Supports Anthropic, OpenAI, Gemini, Bedrock, Azure, Vertex AI, and more
11
- - **Sequential subagent delegation** — Delegate subtasks to specialized subagents with full visibility
12
- - **MCP support** — Connect to external MCP servers to extend available tools
13
- - **Claude Code compatible** — Reuse Claude Code plugins, agents, commands, and skills
9
+ ## Why Plain Agent?
14
10
 
15
- ## Safety Controls
11
+ - **Multi-provider** — Use Claude, GPT, Gemini, or any OpenAI-compatible model.
12
+ Switch providers without changing your workflow.
13
+ - **Fine-grained approval rules** — Auto-approve commands by name, arguments,
14
+ and file paths using regex patterns
15
+ ([`config.predefined.json`](https://github.com/iinm/plain-agent/blob/main/config/config.predefined.json)).
16
+ - **Path validation** — File paths must stay within the working directory
17
+ and git-ignored files (`.env`, etc.) are blocked.
18
+ - **Sandboxed execution** — Run the agent's shell commands inside a Docker
19
+ container with network access restricted to allowlisted destinations
20
+ (e.g., `registry.npmjs.org` only for `npm install`).
21
+ - **Extensible** — Define prompts and subagents in Markdown.
22
+ Connect MCP servers. Reuse Claude Code plugins.
16
23
 
17
- **Auto-Approval**: Tools with no side effects and no sensitive data access are automatically approved based on patterns defined in [`config.predefined.json#autoApproval`](https://github.com/iinm/plain-agent/blob/main/config/config.predefined.json).
24
+ ## Limitations
18
25
 
19
- **Path Validation**: All file paths in tool inputs are validated to remain within the working directory and under git control.
20
-
21
- ⚠️ `write_file` and `patch_file` require explicit path arguments. However, `exec_command` can run arbitrary code where file access cannot be validated. Use a sandbox for stronger isolation.
26
+ - **Sequential subagent execution** Subagents run one at a time rather than
27
+ in parallel. The trade-off is full visibility: every step is streamed to
28
+ your terminal so you can follow exactly what each subagent is doing.
22
29
 
23
30
  ## Requirements
24
31
 
@@ -46,7 +53,7 @@ Create the configuration.
46
53
  // ~/.config/plain-agent/config.local.json
47
54
  {
48
55
  "model": "gpt-5.4+thinking-high",
49
- // "model": "claude-sonnet-4-6+thinking-16k",
56
+ // "model": "claude-sonnet-4-6+thinking-high",
50
57
 
51
58
  // Configure the providers you want to use
52
59
  "platforms": [
@@ -67,32 +74,11 @@ Create the configuration.
67
74
  "variant": "default",
68
75
  "apiKey": "FIXME"
69
76
  },
70
- {
71
- // Requires Azure CLI to get access token
72
- "name": "azure",
73
- "variant": "openai",
74
- "baseURL": "https://<resource>.openai.azure.com/openai",
75
- // Optional
76
- "azureConfigDir": "/home/xxx/.azure-for-agent"
77
- },
78
- {
79
- "name": "bedrock",
80
- "variant": "default",
81
- "baseURL": "https://bedrock-runtime.<region>.amazonaws.com",
82
- "awsProfile": "FIXME"
83
- },
84
- {
85
- // Requires gcloud CLI to get authentication token
86
- "name": "vertex-ai",
87
- "variant": "default",
88
- "baseURL": "https://aiplatform.googleapis.com/v1beta1/projects/<project>/locations/<location>",
89
- // Optional
90
- "account": "<service_account_email>"
91
- }
92
77
  ],
93
78
 
94
79
  // Optional
95
80
  "tools": {
81
+ // askWeb: Searches the web to answer questions requiring up-to-date information or external sources.
96
82
  "askWeb": {
97
83
  "provider": "gemini",
98
84
  "apiKey": "FIXME",
@@ -108,6 +94,8 @@ Create the configuration.
108
94
  // "account": "<service_account_email>"
109
95
  },
110
96
 
97
+ // askURL: Answers questions based on provided URL content.
98
+ // Directly injecting URL content into context is not supported to prevent prompt injection.
111
99
  "askURL": {
112
100
  "provider": "gemini",
113
101
  "apiKey": "FIXME"
@@ -129,7 +117,40 @@ Create the configuration.
129
117
  ```
130
118
 
131
119
  <details>
132
- <summary><b>Other provider examples</b></summary>
120
+ <summary><b>Azure / Bedrock / Vertex AI provider examples</b></summary>
121
+
122
+ ```js
123
+ {
124
+ "platforms": [
125
+ {
126
+ // Requires Azure CLI to get access token
127
+ "name": "azure",
128
+ "variant": "openai",
129
+ "baseURL": "https://<resource>.openai.azure.com/openai",
130
+ // Optional
131
+ "azureConfigDir": "/home/xxx/.azure-for-agent"
132
+ },
133
+ {
134
+ "name": "bedrock",
135
+ "variant": "default",
136
+ "baseURL": "https://bedrock-runtime.<region>.amazonaws.com",
137
+ "awsProfile": "FIXME"
138
+ },
139
+ {
140
+ // Requires gcloud CLI to get authentication token
141
+ "name": "vertex-ai",
142
+ "variant": "default",
143
+ "baseURL": "https://aiplatform.googleapis.com/v1beta1/projects/<project>/locations/<location>",
144
+ // Optional
145
+ "account": "<service_account_email>"
146
+ }
147
+ ]
148
+ }
149
+ ```
150
+ </details>
151
+
152
+ <details>
153
+ <summary><b>OpenAI compatible provider examples</b></summary>
133
154
 
134
155
  ```js
135
156
  {
@@ -284,6 +305,7 @@ The agent can use the following tools to assist with tasks:
284
305
  - **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).
285
306
  - **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.
286
307
  - **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.
308
+ - **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.
287
309
 
288
310
  ## Directory Structure
289
311
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@iinm/plain-agent",
3
- "version": "1.7.14",
3
+ "version": "1.7.16",
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.1026.0",
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.10",
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,
@@ -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);
@@ -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
  /**
@@ -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"
@@ -10,6 +11,49 @@
10
11
  import { styleText } from "node:util";
11
12
  import { createPatch } from "diff";
12
13
 
14
+ /** Length above which a single-line arg forces block-form rendering. */
15
+ const ARG_BLOCK_LENGTH_THRESHOLD = 60;
16
+
17
+ /**
18
+ * Format an args array for display.
19
+ * Uses compact JSON for short single-line args; switches to a YAML-style
20
+ * block form when any arg contains newlines or exceeds
21
+ * {@link ARG_BLOCK_LENGTH_THRESHOLD} characters so that long scripts passed
22
+ * to `bash -c`, `python -c`, `node -e`, etc. stay readable.
23
+ * @param {unknown} args
24
+ * @returns {string}
25
+ */
26
+ export function formatArgs(args) {
27
+ if (!Array.isArray(args) || args.length === 0) {
28
+ return `args: ${JSON.stringify(args ?? [])}`;
29
+ }
30
+
31
+ const needsBlock = args.some(
32
+ (a) =>
33
+ typeof a === "string" &&
34
+ (a.includes("\n") || a.length > ARG_BLOCK_LENGTH_THRESHOLD),
35
+ );
36
+ if (!needsBlock) {
37
+ return `args: ${JSON.stringify(args)}`;
38
+ }
39
+
40
+ const lines = ["args:"];
41
+ for (const arg of args) {
42
+ if (
43
+ typeof arg === "string" &&
44
+ (arg.includes("\n") || arg.length > ARG_BLOCK_LENGTH_THRESHOLD)
45
+ ) {
46
+ lines.push(" - |");
47
+ for (const line of arg.split("\n")) {
48
+ lines.push(` ${line}`);
49
+ }
50
+ } else {
51
+ lines.push(` - ${JSON.stringify(arg)}`);
52
+ }
53
+ }
54
+ return lines.join("\n");
55
+ }
56
+
13
57
  /**
14
58
  * Format tool use for display.
15
59
  * @param {MessageContentToolUse} toolUse
@@ -24,7 +68,7 @@ export function formatToolUse(toolUse) {
24
68
  return [
25
69
  `tool: ${toolName}`,
26
70
  `command: ${JSON.stringify(execCommandInput.command)}`,
27
- `args: ${JSON.stringify(execCommandInput.args)}`,
71
+ formatArgs(execCommandInput.args),
28
72
  ].join("\n");
29
73
  }
30
74
 
@@ -81,7 +125,7 @@ export function formatToolUse(toolUse) {
81
125
  return [
82
126
  `tool: ${toolName}`,
83
127
  `command: ${tmuxCommandInput.command}`,
84
- `args: ${JSON.stringify(tmuxCommandInput.args)}`,
128
+ formatArgs(tmuxCommandInput.args),
85
129
  ].join("\n");
86
130
  }
87
131
 
@@ -95,6 +139,16 @@ export function formatToolUse(toolUse) {
95
139
  ].join("\n");
96
140
  }
97
141
 
142
+ if (toolName === "compact_context") {
143
+ /** @type {Partial<CompactContextInput>} */
144
+ const compactContextInput = input;
145
+ return [
146
+ `tool: ${toolName}`,
147
+ `memoryPath: ${compactContextInput.memoryPath}`,
148
+ `reason: ${compactContextInput.reason}`,
149
+ ].join("\n");
150
+ }
151
+
98
152
  if (toolName === "report_as_subagent") {
99
153
  /** @type {Partial<import("./tools/reportAsSubagent").ReportAsSubagentInput>} */
100
154
  const reportAsSubagentInput = input;
@@ -12,10 +12,8 @@ import {
12
12
  formatProviderTokenUsage,
13
13
  printMessage,
14
14
  } from "./cliFormatter.mjs";
15
- import {
16
- createPasteTransform,
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 lastExitAttempt = 0;
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
- // If agent is running, pause auto-approve instead of exiting
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 Ctrl-C: Auto-approve paused. Finishing current tool...",
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 - lastExitAttempt < EXIT_CONFIRM_TIMEOUT) {
176
+ if (now - lastCtrlDAttempt < EXIT_CONFIRM_TIMEOUT) {
138
177
  handleExit();
139
178
  return;
140
179
  }
141
- lastExitAttempt = now;
142
- console.log(styleText("yellow", "\nPress Ctrl-C or Ctrl-D again to exit."));
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
- // Create a transform stream to handle bracketed paste before readline
146
- const pasteTransform = createPasteTransform(handleCtrlC);
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
- // Set up transformed stdin for readline
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
- /** @type {import("node:readline").Interface} */
158
- const cli = readline.createInterface({
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 = resolvePastePlaceholders(input);
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
- "\nApprove tool calls? (y = allow once, Y = allow in this session, or feedback)",
340
+ "Approve tool calls? (y = allow once, Y = allow in this session, or feedback)",
288
341
  ),
289
- currentCliPrompt,
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
- // Store for pasted content
8
- const pastedContentStore = new Map();
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,52 +33,30 @@ function generatePasteHash(content) {
23
33
  }
24
34
 
25
35
  /**
26
- * Resolve paste placeholders and append context tags
27
- * @param {string} input
28
- * @returns {string}
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
-
55
- // Time to wait for a continuation paste chunk before flushing the paste buffer.
56
- // Some terminals split large pastes into multiple bracketed paste sequences
57
- // (e.g. `\x1b[200~...\x1b[201~\x1b[200~...\x1b[201~`) that arrive back-to-back.
58
- // Holding the paste briefly lets us merge them into a single placeholder.
59
- const PASTE_MERGE_WINDOW_MS = 20;
60
45
 
61
46
  /**
62
- * Create a Transform stream to handle bracketed paste before readline.
63
- * @param {() => void} onCtrlC - Called when Ctrl-C or Ctrl-D is detected
64
- * @returns {Transform}
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}
65
52
  */
66
- export function createPasteTransform(onCtrlC) {
67
- let inPasteMode = false;
53
+ export function createPasteHandler() {
54
+ /** @type {Map<string, string>} */
55
+ const pastedContentStore = new Map();
56
+
57
+ /** @type {PasteState} */
58
+ let state = "IDLE";
68
59
  let pasteBuffer = "";
69
- // True when a paste just ended and we are waiting to see if the next data
70
- // continues it (i.e. starts with another BRACKETED_PASTE_START).
71
- let awaitingMerge = false;
72
60
  /** @type {NodeJS.Timeout | null} */
73
61
  let mergeTimer = null;
74
62
  /** @type {Transform} */
@@ -81,25 +69,23 @@ export function createPasteTransform(onCtrlC) {
81
69
  }
82
70
  };
83
71
 
84
- const flushPaste = () => {
72
+ const flushPasteBuffer = () => {
85
73
  clearMergeTimer();
86
- awaitingMerge = false;
87
74
  if (pasteBuffer) {
88
- // Remove trailing newline for single-line paste detection
89
- const trimmedPaste = pasteBuffer.replace(/\n$/, "");
90
-
91
- // For single-line paste, insert directly without placeholder
92
- if (!trimmedPaste.includes("\n")) {
93
- transform.push(trimmedPaste);
94
- } else {
95
- // For multi-line paste, use placeholder
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.
96
79
  const hash = generatePasteHash(pasteBuffer);
97
80
  pastedContentStore.set(hash, pasteBuffer);
98
- const lines = pasteBuffer.split("\n");
99
- transform.push(`[Pasted text #${hash}, ${lines.length} lines]`);
81
+ const lineCount = pasteBuffer.split("\n").length;
82
+ transform.push(`[Pasted text #${hash}, ${lineCount} lines]`);
83
+ } else {
84
+ transform.push(trimmed);
100
85
  }
101
86
  }
102
87
  pasteBuffer = "";
88
+ state = "IDLE";
103
89
  };
104
90
 
105
91
  transform = new Transform({
@@ -107,65 +93,49 @@ export function createPasteTransform(onCtrlC) {
107
93
  /** @type {string} */
108
94
  let data = chunk.toString("utf8");
109
95
 
110
- // Handle Ctrl-C and Ctrl-D
111
- if (data.includes("\x03") || data.includes("\x04")) {
112
- onCtrlC();
113
- callback();
114
- return;
115
- }
116
-
117
96
  while (data.length > 0) {
118
- if (inPasteMode) {
97
+ if (state === "PASTE") {
119
98
  const endIdx = data.indexOf(BRACKETED_PASTE_END);
120
- if (endIdx !== -1) {
121
- // End of (this chunk of) paste. Hold the buffer briefly in case
122
- // another paste chunk follows immediately and should be merged.
123
- pasteBuffer += data.slice(0, endIdx);
124
- data = data.slice(endIdx + BRACKETED_PASTE_END.length);
125
- inPasteMode = false;
126
- awaitingMerge = true;
127
- } else {
128
- // Still in paste mode
99
+ if (endIdx === -1) {
129
100
  pasteBuffer += data;
130
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.
105
+ pasteBuffer += data.slice(0, endIdx);
106
+ data = data.slice(endIdx + BRACKETED_PASTE_END.length);
107
+ state = "PENDING";
131
108
  }
132
- } else if (awaitingMerge) {
133
- // If the next data starts with another paste start marker, treat it
134
- // as a continuation of the previous paste and merge.
109
+ } else if (state === "PENDING") {
135
110
  if (data.startsWith(BRACKETED_PASTE_START)) {
111
+ // Continuation of the previous paste; keep appending to pasteBuffer.
136
112
  data = data.slice(BRACKETED_PASTE_START.length);
137
- inPasteMode = true;
138
- awaitingMerge = false;
139
113
  clearMergeTimer();
114
+ state = "PASTE";
140
115
  } else {
141
- // Not a continuation; flush pending paste, then process this data.
142
- flushPaste();
116
+ // Not a continuation; flush, then re-process this data as IDLE.
117
+ flushPasteBuffer();
143
118
  }
144
119
  } else {
120
+ // IDLE
145
121
  const startIdx = data.indexOf(BRACKETED_PASTE_START);
146
- if (startIdx !== -1) {
147
- // Start of paste
148
- // Output any data before the paste
149
- if (startIdx > 0) {
150
- this.push(data.slice(0, startIdx));
151
- }
152
- data = data.slice(startIdx + BRACKETED_PASTE_START.length);
153
- inPasteMode = true;
154
- pasteBuffer = "";
155
- } else {
156
- // Normal data
122
+ if (startIdx === -1) {
157
123
  this.push(data);
158
124
  data = "";
125
+ } else {
126
+ this.push(data.slice(0, startIdx));
127
+ data = data.slice(startIdx + BRACKETED_PASTE_START.length);
128
+ state = "PASTE";
159
129
  }
160
130
  }
161
131
  }
162
132
 
163
- // If the chunk ended while still awaiting a continuation, schedule a
164
- // short timer to flush the pending paste if nothing else arrives.
165
- if (awaitingMerge && !mergeTimer) {
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) {
166
136
  mergeTimer = setTimeout(() => {
167
137
  mergeTimer = null;
168
- flushPaste();
138
+ flushPasteBuffer();
169
139
  }, PASTE_MERGE_WINDOW_MS);
170
140
  }
171
141
 
@@ -173,12 +143,41 @@ export function createPasteTransform(onCtrlC) {
173
143
  },
174
144
 
175
145
  flush(callback) {
176
- if (awaitingMerge) {
177
- flushPaste();
146
+ if (state === "PENDING") {
147
+ flushPasteBuffer();
178
148
  }
179
149
  callback();
180
150
  },
181
151
  });
182
152
 
183
- return transform;
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 };
184
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";
@@ -122,7 +123,7 @@ if (cliArgs.subcommand.type === "install-claude-code-plugins") {
122
123
  }),
123
124
  );
124
125
 
125
- for (const { serverName, tools, cleanup } of mcpResults) {
126
+ for (const { serverName, tools, stderrLogPath, cleanup } of mcpResults) {
126
127
  mcpTools.push(...tools);
127
128
  mcpCleanups.push(cleanup);
128
129
  if (!isBatchMode) {
@@ -132,6 +133,7 @@ if (cliArgs.subcommand.type === "install-claude-code-plugins") {
132
133
  `✅ Successfully connected to MCP server: ${serverName}`,
133
134
  ),
134
135
  );
136
+ console.log(` ⤷ stderr log: ${stderrLogPath}`);
135
137
  }
136
138
  }
137
139
  }
@@ -164,6 +166,7 @@ if (cliArgs.subcommand.type === "install-claude-code-plugins") {
164
166
  writeFileTool,
165
167
  createPatchFileTool(),
166
168
  createTmuxCommandTool({ sandbox: appConfig.sandbox }),
169
+ createCompactContextTool(),
167
170
  createDelegateToSubagentTool(),
168
171
  createReportAsSubagentTool(),
169
172
  ];
package/src/mcp.mjs CHANGED
@@ -15,6 +15,7 @@ const OUTPUT_MAX_LENGTH = 1024 * 8;
15
15
  /**
16
16
  * @typedef {Object} SetupMCPServrResult
17
17
  * @property {Tool[]} tools
18
+ * @property {string} stderrLogPath
18
19
  * @property {() => Promise<void>} cleanup
19
20
  */
20
21
 
@@ -26,7 +27,7 @@ const OUTPUT_MAX_LENGTH = 1024 * 8;
26
27
  export async function setupMCPServer(serverName, serverConfig) {
27
28
  const { options, ...params } = serverConfig;
28
29
 
29
- const { client, cleanup } = await startMCPServer({
30
+ const { client, stderrLogPath, cleanup } = await startMCPServer({
30
31
  serverName,
31
32
  params,
32
33
  });
@@ -41,6 +42,7 @@ export async function setupMCPServer(serverName, serverConfig) {
41
42
 
42
43
  return {
43
44
  tools,
45
+ stderrLogPath,
44
46
  cleanup: async () => {
45
47
  cleanup();
46
48
  await client.close();
@@ -56,7 +58,7 @@ export async function setupMCPServer(serverName, serverConfig) {
56
58
 
57
59
  /**
58
60
  * @param {MCPClientOptions} options - The options for the client.
59
- * @returns {Promise<{client: Client; cleanup: () => void}>} - The MCP client and cleanup function.
61
+ * @returns {Promise<{client: Client; stderrLogPath: string; cleanup: () => void}>} - The MCP client, stderr log path, and cleanup function.
60
62
  */
61
63
  async function startMCPServer(options) {
62
64
  const mcpClient = await import("@modelcontextprotocol/client");
@@ -88,6 +90,7 @@ async function startMCPServer(options) {
88
90
 
89
91
  return {
90
92
  client,
93
+ stderrLogPath: logPath,
91
94
  cleanup: () => {
92
95
  stderrLogFile.close();
93
96
  },
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,4 @@
1
+ export type CompactContextInput = {
2
+ memoryPath: string;
3
+ reason: string;
4
+ };
@@ -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
+ }