@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 +57 -35
- 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 +56 -2
- package/src/cliInteractive.mjs +76 -24
- package/src/cliInterruptTransform.mjs +34 -0
- package/src/cliPasteTransform.mjs +97 -98
- package/src/main.mjs +4 -1
- package/src/mcp.mjs +5 -2
- 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
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
24
|
+
## Limitations
|
|
18
25
|
|
|
19
|
-
**
|
|
20
|
-
|
|
21
|
-
|
|
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-
|
|
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>
|
|
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.
|
|
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.
|
|
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"
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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;
|
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,52 +33,30 @@ 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
|
-
|
|
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
|
|
63
|
-
*
|
|
64
|
-
*
|
|
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
|
|
67
|
-
|
|
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
|
|
72
|
+
const flushPasteBuffer = () => {
|
|
85
73
|
clearMergeTimer();
|
|
86
|
-
awaitingMerge = false;
|
|
87
74
|
if (pasteBuffer) {
|
|
88
|
-
//
|
|
89
|
-
const
|
|
90
|
-
|
|
91
|
-
|
|
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
|
|
99
|
-
transform.push(`[Pasted text #${hash}, ${
|
|
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 (
|
|
97
|
+
if (state === "PASTE") {
|
|
119
98
|
const endIdx = data.indexOf(BRACKETED_PASTE_END);
|
|
120
|
-
if (endIdx
|
|
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 (
|
|
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
|
|
142
|
-
|
|
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
|
|
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
|
|
164
|
-
// short timer to flush the pending paste if nothing
|
|
165
|
-
if (
|
|
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
|
-
|
|
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 (
|
|
177
|
-
|
|
146
|
+
if (state === "PENDING") {
|
|
147
|
+
flushPasteBuffer();
|
|
178
148
|
}
|
|
179
149
|
callback();
|
|
180
150
|
},
|
|
181
151
|
});
|
|
182
152
|
|
|
183
|
-
|
|
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,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
|
+
}
|