agent-sh 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +659 -0
- package/dist/acp-client.d.ts +76 -0
- package/dist/acp-client.js +507 -0
- package/dist/context-manager.d.ts +45 -0
- package/dist/context-manager.js +405 -0
- package/dist/core.d.ts +41 -0
- package/dist/core.js +76 -0
- package/dist/event-bus.d.ts +140 -0
- package/dist/event-bus.js +79 -0
- package/dist/executor.d.ts +31 -0
- package/dist/executor.js +116 -0
- package/dist/extension-loader.d.ts +16 -0
- package/dist/extension-loader.js +164 -0
- package/dist/extensions/file-autocomplete.d.ts +2 -0
- package/dist/extensions/file-autocomplete.js +63 -0
- package/dist/extensions/shell-recall.d.ts +9 -0
- package/dist/extensions/shell-recall.js +8 -0
- package/dist/extensions/slash-commands.d.ts +2 -0
- package/dist/extensions/slash-commands.js +105 -0
- package/dist/extensions/tui-renderer.d.ts +2 -0
- package/dist/extensions/tui-renderer.js +354 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +159 -0
- package/dist/input-handler.d.ts +48 -0
- package/dist/input-handler.js +302 -0
- package/dist/output-parser.d.ts +55 -0
- package/dist/output-parser.js +166 -0
- package/dist/shell.d.ts +54 -0
- package/dist/shell.js +219 -0
- package/dist/types.d.ts +71 -0
- package/dist/types.js +1 -0
- package/dist/utils/ansi.d.ts +12 -0
- package/dist/utils/ansi.js +23 -0
- package/dist/utils/box-frame.d.ts +21 -0
- package/dist/utils/box-frame.js +60 -0
- package/dist/utils/diff-renderer.d.ts +20 -0
- package/dist/utils/diff-renderer.js +506 -0
- package/dist/utils/diff.d.ts +24 -0
- package/dist/utils/diff.js +122 -0
- package/dist/utils/file-watcher.d.ts +31 -0
- package/dist/utils/file-watcher.js +101 -0
- package/dist/utils/markdown.d.ts +39 -0
- package/dist/utils/markdown.js +248 -0
- package/dist/utils/palette.d.ts +32 -0
- package/dist/utils/palette.js +36 -0
- package/dist/utils/tool-display.d.ts +33 -0
- package/dist/utils/tool-display.js +141 -0
- package/examples/extensions/interactive-prompts.ts +161 -0
- package/examples/extensions/solarized-theme.ts +27 -0
- package/package.json +72 -0
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { EventBus } from "./event-bus.js";
|
|
2
|
+
import type { ContextManager } from "./context-manager.js";
|
|
3
|
+
import type { AgentShellConfig } from "./types.js";
|
|
4
|
+
export declare class AcpClient {
|
|
5
|
+
private agentProcess;
|
|
6
|
+
private connection;
|
|
7
|
+
private sessionId;
|
|
8
|
+
private bus;
|
|
9
|
+
private contextManager;
|
|
10
|
+
private config;
|
|
11
|
+
private promptInProgress;
|
|
12
|
+
private currentResponseText;
|
|
13
|
+
private lastResponseText;
|
|
14
|
+
private terminalSessions;
|
|
15
|
+
private terminalDonePromises;
|
|
16
|
+
private terminalCounter;
|
|
17
|
+
private fileWatcher;
|
|
18
|
+
private pendingToolCalls;
|
|
19
|
+
private pendingToolCounter;
|
|
20
|
+
private agentInfo;
|
|
21
|
+
constructor(opts: {
|
|
22
|
+
bus: EventBus;
|
|
23
|
+
contextManager: ContextManager;
|
|
24
|
+
config: AgentShellConfig;
|
|
25
|
+
});
|
|
26
|
+
start(): Promise<void>;
|
|
27
|
+
/**
|
|
28
|
+
* Send a user query to the agent.
|
|
29
|
+
*/
|
|
30
|
+
sendPrompt(query: string): Promise<void>;
|
|
31
|
+
/**
|
|
32
|
+
* Cancel the current prompt and force-recover shell mode.
|
|
33
|
+
*/
|
|
34
|
+
cancel(): Promise<void>;
|
|
35
|
+
/**
|
|
36
|
+
* Start a new ACP session, clearing agent-side conversation history.
|
|
37
|
+
*/
|
|
38
|
+
resetSession(): Promise<void>;
|
|
39
|
+
/**
|
|
40
|
+
* Get the text of the last agent response (for /copy).
|
|
41
|
+
*/
|
|
42
|
+
getLastResponseText(): string;
|
|
43
|
+
/**
|
|
44
|
+
* Get agent information for display.
|
|
45
|
+
*/
|
|
46
|
+
getAgentInfo(): {
|
|
47
|
+
name: string;
|
|
48
|
+
version: string;
|
|
49
|
+
} | null;
|
|
50
|
+
getModel(): string | undefined;
|
|
51
|
+
/**
|
|
52
|
+
* Check if agent is connected.
|
|
53
|
+
*/
|
|
54
|
+
isConnected(): boolean;
|
|
55
|
+
private log;
|
|
56
|
+
/**
|
|
57
|
+
* Create the Client handler that responds to agent requests.
|
|
58
|
+
*/
|
|
59
|
+
private createClientHandler;
|
|
60
|
+
private handleSessionUpdate;
|
|
61
|
+
private handleRequestPermission;
|
|
62
|
+
private handleCreateTerminal;
|
|
63
|
+
private handleTerminalOutput;
|
|
64
|
+
private handleWaitForTerminalExit;
|
|
65
|
+
private handleKillTerminal;
|
|
66
|
+
private handleReleaseTerminal;
|
|
67
|
+
private handleReadTextFile;
|
|
68
|
+
private handleWriteTextFile;
|
|
69
|
+
/**
|
|
70
|
+
* After the agent finishes, check all tracked files for changes
|
|
71
|
+
* made via the agent's own tools (not through fs/writeTextFile)
|
|
72
|
+
* and show interactive diff previews.
|
|
73
|
+
*/
|
|
74
|
+
private showPendingFileChanges;
|
|
75
|
+
kill(): void;
|
|
76
|
+
}
|
|
@@ -0,0 +1,507 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { Readable, Writable } from "node:stream";
|
|
3
|
+
import * as fs from "node:fs/promises";
|
|
4
|
+
import * as acp from "@agentclientprotocol/sdk";
|
|
5
|
+
import { executeCommand, killSession } from "./executor.js";
|
|
6
|
+
import { computeDiff } from "./utils/diff.js";
|
|
7
|
+
import { FileWatcher } from "./utils/file-watcher.js";
|
|
8
|
+
import * as path from "node:path";
|
|
9
|
+
import { stripAnsi } from "./utils/ansi.js";
|
|
10
|
+
export class AcpClient {
|
|
11
|
+
agentProcess = null;
|
|
12
|
+
connection = null;
|
|
13
|
+
sessionId = null;
|
|
14
|
+
bus;
|
|
15
|
+
contextManager;
|
|
16
|
+
config;
|
|
17
|
+
promptInProgress = false;
|
|
18
|
+
currentResponseText = "";
|
|
19
|
+
lastResponseText = "";
|
|
20
|
+
terminalSessions = new Map();
|
|
21
|
+
terminalDonePromises = new Map();
|
|
22
|
+
terminalCounter = 0;
|
|
23
|
+
fileWatcher;
|
|
24
|
+
pendingToolCalls = new Map();
|
|
25
|
+
pendingToolCounter = 0;
|
|
26
|
+
agentInfo = null;
|
|
27
|
+
constructor(opts) {
|
|
28
|
+
this.bus = opts.bus;
|
|
29
|
+
this.contextManager = opts.contextManager;
|
|
30
|
+
this.config = opts.config;
|
|
31
|
+
this.fileWatcher = new FileWatcher(process.cwd());
|
|
32
|
+
}
|
|
33
|
+
async start() {
|
|
34
|
+
this.log(`Starting agent: ${this.config.agentCommand} ${this.config.agentArgs.join(" ")}`);
|
|
35
|
+
// Spawn the agent subprocess
|
|
36
|
+
// Spawn the agent — wait briefly to catch ENOENT and other spawn errors
|
|
37
|
+
this.agentProcess = spawn(this.config.agentCommand, this.config.agentArgs, {
|
|
38
|
+
stdio: ["pipe", "pipe", process.env.DEBUG ? "inherit" : "ignore"],
|
|
39
|
+
});
|
|
40
|
+
// Catch spawn errors (ENOENT, EACCES, etc.) before proceeding
|
|
41
|
+
await new Promise((resolve, reject) => {
|
|
42
|
+
const onError = (err) => {
|
|
43
|
+
reject(new Error(`Failed to start agent "${this.config.agentCommand}": ${err.message}`));
|
|
44
|
+
};
|
|
45
|
+
this.agentProcess.on("error", onError);
|
|
46
|
+
// spawn errors fire on next tick — wait for that, then detach the listener
|
|
47
|
+
setTimeout(() => {
|
|
48
|
+
this.agentProcess.removeListener("error", onError);
|
|
49
|
+
resolve();
|
|
50
|
+
}, 100);
|
|
51
|
+
});
|
|
52
|
+
this.log("Agent process spawned");
|
|
53
|
+
this.agentProcess.on("exit", (code) => {
|
|
54
|
+
this.bus.emit("agent:error", { message: `Agent process exited with code ${code}` });
|
|
55
|
+
this.connection = null;
|
|
56
|
+
this.sessionId = null;
|
|
57
|
+
});
|
|
58
|
+
if (!this.agentProcess.stdin || !this.agentProcess.stdout) {
|
|
59
|
+
throw new Error("Failed to get agent process stdio");
|
|
60
|
+
}
|
|
61
|
+
// Create ACP stream from the agent's stdio
|
|
62
|
+
const output = Writable.toWeb(this.agentProcess.stdin);
|
|
63
|
+
const input = Readable.toWeb(this.agentProcess.stdout);
|
|
64
|
+
const stream = acp.ndJsonStream(output, input);
|
|
65
|
+
this.log("Creating ACP connection");
|
|
66
|
+
// Create the client-side connection, providing our Client handler
|
|
67
|
+
this.connection = new acp.ClientSideConnection((_agent) => this.createClientHandler(), stream);
|
|
68
|
+
// Initialize the connection
|
|
69
|
+
this.log("Sending initialize request");
|
|
70
|
+
const initResponse = await this.connection.initialize({
|
|
71
|
+
protocolVersion: acp.PROTOCOL_VERSION,
|
|
72
|
+
clientInfo: { name: "agent-sh", version: "0.1.0" },
|
|
73
|
+
clientCapabilities: {
|
|
74
|
+
terminal: true,
|
|
75
|
+
fs: {
|
|
76
|
+
readTextFile: true,
|
|
77
|
+
writeTextFile: true,
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
this.log("Initialize successful");
|
|
82
|
+
// Store agent info for display
|
|
83
|
+
if (initResponse.agentInfo) {
|
|
84
|
+
this.agentInfo = {
|
|
85
|
+
name: initResponse.agentInfo.name || this.config.agentCommand,
|
|
86
|
+
version: initResponse.agentInfo.version || "unknown"
|
|
87
|
+
};
|
|
88
|
+
this.log(`Agent info: ${this.agentInfo.name} v${this.agentInfo.version}`);
|
|
89
|
+
}
|
|
90
|
+
// Create a session
|
|
91
|
+
const cwd = this.contextManager.getCwd();
|
|
92
|
+
this.log(`Creating new session with cwd: ${cwd}`);
|
|
93
|
+
const sessionResponse = await this.connection.newSession({
|
|
94
|
+
cwd,
|
|
95
|
+
mcpServers: [],
|
|
96
|
+
});
|
|
97
|
+
this.sessionId = sessionResponse.sessionId;
|
|
98
|
+
this.log(`Session created: ${this.sessionId}`);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Send a user query to the agent.
|
|
102
|
+
*/
|
|
103
|
+
async sendPrompt(query) {
|
|
104
|
+
if (!this.connection || !this.sessionId) {
|
|
105
|
+
this.bus.emit("agent:error", { message: "Not connected to agent" });
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
this.promptInProgress = true;
|
|
109
|
+
this.bus.emit("agent:processing-start", {});
|
|
110
|
+
await this.fileWatcher.snapshot();
|
|
111
|
+
this.currentResponseText = "";
|
|
112
|
+
let cancelled = false;
|
|
113
|
+
// Emit agent query event (TUI renders echo+spinner, ContextManager records it)
|
|
114
|
+
this.bus.emit("agent:query", { query });
|
|
115
|
+
// Build structured context from ContextManager
|
|
116
|
+
const contextBlock = this.contextManager.getContext();
|
|
117
|
+
try {
|
|
118
|
+
this.log("sending prompt...");
|
|
119
|
+
const response = await this.connection.prompt({
|
|
120
|
+
sessionId: this.sessionId,
|
|
121
|
+
prompt: [
|
|
122
|
+
{
|
|
123
|
+
type: "text",
|
|
124
|
+
text: contextBlock + "\n" + query,
|
|
125
|
+
},
|
|
126
|
+
],
|
|
127
|
+
});
|
|
128
|
+
this.log(`prompt resolved: stopReason=${response.stopReason}`);
|
|
129
|
+
if (response.stopReason === "cancelled") {
|
|
130
|
+
cancelled = true;
|
|
131
|
+
this.bus.emit("agent:cancelled", {});
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
this.log(`prompt error: ${err}`);
|
|
136
|
+
this.bus.emit("agent:error", {
|
|
137
|
+
message: err instanceof Error ? err.message : String(err),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
finally {
|
|
141
|
+
this.log("restoring shell mode");
|
|
142
|
+
if (!cancelled) {
|
|
143
|
+
this.bus.emit("agent:response-done", {
|
|
144
|
+
response: this.currentResponseText,
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
this.lastResponseText = this.currentResponseText;
|
|
148
|
+
// Show diff previews for files the agent modified via its own tools
|
|
149
|
+
// (modifications via fs/writeTextFile are already handled inline)
|
|
150
|
+
if (this.promptInProgress) {
|
|
151
|
+
await this.showPendingFileChanges();
|
|
152
|
+
}
|
|
153
|
+
this.bus.emit("agent:processing-done", {});
|
|
154
|
+
this.promptInProgress = false;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Cancel the current prompt and force-recover shell mode.
|
|
159
|
+
*/
|
|
160
|
+
async cancel() {
|
|
161
|
+
this.log("cancel requested");
|
|
162
|
+
// Kill all running terminal sessions
|
|
163
|
+
for (const session of this.terminalSessions.values()) {
|
|
164
|
+
if (!session.done)
|
|
165
|
+
killSession(session);
|
|
166
|
+
}
|
|
167
|
+
if (this.connection && this.sessionId && this.promptInProgress) {
|
|
168
|
+
try {
|
|
169
|
+
await this.connection.cancel({ sessionId: this.sessionId });
|
|
170
|
+
}
|
|
171
|
+
catch {
|
|
172
|
+
// Cancellation is best-effort
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
// Force-recover shell regardless of prompt state
|
|
176
|
+
if (this.promptInProgress) {
|
|
177
|
+
this.bus.emit("agent:cancelled", {});
|
|
178
|
+
}
|
|
179
|
+
this.bus.emit("agent:processing-done", {});
|
|
180
|
+
this.promptInProgress = false;
|
|
181
|
+
}
|
|
182
|
+
/**
|
|
183
|
+
* Start a new ACP session, clearing agent-side conversation history.
|
|
184
|
+
*/
|
|
185
|
+
async resetSession() {
|
|
186
|
+
if (!this.connection)
|
|
187
|
+
return;
|
|
188
|
+
const sessionResponse = await this.connection.newSession({
|
|
189
|
+
cwd: this.contextManager.getCwd(),
|
|
190
|
+
mcpServers: [],
|
|
191
|
+
});
|
|
192
|
+
this.sessionId = sessionResponse.sessionId;
|
|
193
|
+
this.lastResponseText = "";
|
|
194
|
+
this.currentResponseText = "";
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Get the text of the last agent response (for /copy).
|
|
198
|
+
*/
|
|
199
|
+
getLastResponseText() {
|
|
200
|
+
return this.lastResponseText;
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Get agent information for display.
|
|
204
|
+
*/
|
|
205
|
+
getAgentInfo() {
|
|
206
|
+
return this.agentInfo;
|
|
207
|
+
}
|
|
208
|
+
getModel() {
|
|
209
|
+
return this.config.model;
|
|
210
|
+
}
|
|
211
|
+
/**
|
|
212
|
+
* Check if agent is connected.
|
|
213
|
+
*/
|
|
214
|
+
isConnected() {
|
|
215
|
+
// Consider connected if we have a connection and agent info
|
|
216
|
+
// Session ID may not be set yet if we're still initializing
|
|
217
|
+
return this.connection !== null && this.agentInfo !== null;
|
|
218
|
+
}
|
|
219
|
+
log(msg) {
|
|
220
|
+
if (process.env.DEBUG) {
|
|
221
|
+
process.stderr.write(`[agent-sh] ${msg}\n`);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Create the Client handler that responds to agent requests.
|
|
226
|
+
*/
|
|
227
|
+
createClientHandler() {
|
|
228
|
+
return {
|
|
229
|
+
// Required: handle session update notifications (streaming)
|
|
230
|
+
sessionUpdate: async (params) => {
|
|
231
|
+
this.handleSessionUpdate(params);
|
|
232
|
+
},
|
|
233
|
+
// Required: handle permission requests
|
|
234
|
+
requestPermission: async (params) => {
|
|
235
|
+
return this.handleRequestPermission(params);
|
|
236
|
+
},
|
|
237
|
+
// Optional: terminal operations
|
|
238
|
+
createTerminal: async (params) => {
|
|
239
|
+
return this.handleCreateTerminal(params);
|
|
240
|
+
},
|
|
241
|
+
terminalOutput: async (params) => {
|
|
242
|
+
return this.handleTerminalOutput(params);
|
|
243
|
+
},
|
|
244
|
+
waitForTerminalExit: async (params) => {
|
|
245
|
+
return this.handleWaitForTerminalExit(params);
|
|
246
|
+
},
|
|
247
|
+
killTerminal: async (params) => {
|
|
248
|
+
return this.handleKillTerminal(params);
|
|
249
|
+
},
|
|
250
|
+
releaseTerminal: async (params) => {
|
|
251
|
+
return this.handleReleaseTerminal(params);
|
|
252
|
+
},
|
|
253
|
+
// Optional: filesystem operations
|
|
254
|
+
readTextFile: async (params) => {
|
|
255
|
+
return this.handleReadTextFile(params);
|
|
256
|
+
},
|
|
257
|
+
writeTextFile: async (params) => {
|
|
258
|
+
return this.handleWriteTextFile(params);
|
|
259
|
+
},
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
// ── Session update handler ─────────────────────────────────────
|
|
263
|
+
handleSessionUpdate(params) {
|
|
264
|
+
// Suppress rendering during initialization / between prompts
|
|
265
|
+
if (!this.promptInProgress)
|
|
266
|
+
return;
|
|
267
|
+
const update = params.update;
|
|
268
|
+
switch (update.sessionUpdate) {
|
|
269
|
+
case "agent_message_chunk": {
|
|
270
|
+
const content = update.content;
|
|
271
|
+
if (content.type === "text") {
|
|
272
|
+
this.currentResponseText += content.text;
|
|
273
|
+
this.bus.emit("agent:response-chunk", { text: content.text });
|
|
274
|
+
}
|
|
275
|
+
break;
|
|
276
|
+
}
|
|
277
|
+
case "agent_thought_chunk": {
|
|
278
|
+
const thought = update.content;
|
|
279
|
+
if (thought.type === "text" && thought.text) {
|
|
280
|
+
this.bus.emit("agent:thinking-chunk", { text: thought.text });
|
|
281
|
+
}
|
|
282
|
+
break;
|
|
283
|
+
}
|
|
284
|
+
case "tool_call": {
|
|
285
|
+
// Use toolCallId if available, otherwise generate a simple ID
|
|
286
|
+
const toolId = update.toolCallId || `tool-${this.pendingToolCounter++}`;
|
|
287
|
+
this.pendingToolCalls.set(toolId, true);
|
|
288
|
+
this.bus.emit("agent:tool-started", { title: update.title, toolCallId: toolId });
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
case "tool_call_update": {
|
|
292
|
+
// Only show result when the tool completes, don't show tool call again
|
|
293
|
+
if (update.status === "completed" || update.status === "failed") {
|
|
294
|
+
const toolId = update.toolCallId;
|
|
295
|
+
const exitCode = update.status === "completed" ? 0 : 1;
|
|
296
|
+
if (toolId && this.pendingToolCalls.has(toolId)) {
|
|
297
|
+
this.pendingToolCalls.delete(toolId);
|
|
298
|
+
this.bus.emit("agent:tool-completed", { toolCallId: toolId, exitCode });
|
|
299
|
+
}
|
|
300
|
+
else if (!toolId) {
|
|
301
|
+
this.bus.emit("agent:tool-completed", { exitCode });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
break;
|
|
305
|
+
}
|
|
306
|
+
default:
|
|
307
|
+
// Ignore other update types for now
|
|
308
|
+
break;
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
// ── Permission handler ─────────────────────────────────────────
|
|
312
|
+
async handleRequestPermission(params) {
|
|
313
|
+
const title = params.toolCall.title ?? "Unknown action";
|
|
314
|
+
const result = await this.bus.emitPipeAsync("permission:request", {
|
|
315
|
+
kind: "tool-call",
|
|
316
|
+
title,
|
|
317
|
+
metadata: {
|
|
318
|
+
options: params.options.map((o) => ({
|
|
319
|
+
optionId: o.optionId,
|
|
320
|
+
kind: o.kind,
|
|
321
|
+
})),
|
|
322
|
+
},
|
|
323
|
+
decision: {
|
|
324
|
+
outcome: "selected",
|
|
325
|
+
optionId: (params.options.find((o) => o.kind === "allow_once") ?? params.options[0])?.optionId,
|
|
326
|
+
},
|
|
327
|
+
});
|
|
328
|
+
return { outcome: result.decision };
|
|
329
|
+
}
|
|
330
|
+
// ── Terminal handlers (isolated execution via child_process) ────
|
|
331
|
+
async handleCreateTerminal(params) {
|
|
332
|
+
const fullCommand = params.args?.length
|
|
333
|
+
? `${params.command} ${params.args.join(" ")}`
|
|
334
|
+
: params.command;
|
|
335
|
+
const cwd = params.cwd ?? this.contextManager.getCwd();
|
|
336
|
+
// Let extensions intercept before spawning a real process
|
|
337
|
+
const intercept = this.bus.emitPipe("agent:terminal-intercept", {
|
|
338
|
+
command: fullCommand,
|
|
339
|
+
cwd,
|
|
340
|
+
intercepted: false,
|
|
341
|
+
output: "",
|
|
342
|
+
});
|
|
343
|
+
if (intercept.intercepted) {
|
|
344
|
+
const id = `t${++this.terminalCounter}`;
|
|
345
|
+
const session = {
|
|
346
|
+
id,
|
|
347
|
+
command: fullCommand,
|
|
348
|
+
output: intercept.output,
|
|
349
|
+
exitCode: 0,
|
|
350
|
+
done: true,
|
|
351
|
+
truncated: false,
|
|
352
|
+
process: null,
|
|
353
|
+
};
|
|
354
|
+
this.terminalSessions.set(id, session);
|
|
355
|
+
this.terminalDonePromises.set(id, Promise.resolve());
|
|
356
|
+
return { terminalId: id };
|
|
357
|
+
}
|
|
358
|
+
this.bus.emit("agent:tool-call", {
|
|
359
|
+
tool: fullCommand,
|
|
360
|
+
args: { command: params.command, args: params.args, cwd },
|
|
361
|
+
});
|
|
362
|
+
const id = `t${++this.terminalCounter}`;
|
|
363
|
+
const { session, done } = executeCommand({
|
|
364
|
+
command: fullCommand,
|
|
365
|
+
cwd,
|
|
366
|
+
timeout: 60_000,
|
|
367
|
+
maxOutputBytes: 256 * 1024,
|
|
368
|
+
onOutput: (chunk) => {
|
|
369
|
+
// Stream output into the box in real-time (strip ANSI for display)
|
|
370
|
+
this.bus.emit("agent:tool-output-chunk", { chunk: stripAnsi(chunk) });
|
|
371
|
+
},
|
|
372
|
+
});
|
|
373
|
+
session.id = id;
|
|
374
|
+
this.terminalSessions.set(id, session);
|
|
375
|
+
this.terminalDonePromises.set(id, done);
|
|
376
|
+
return { terminalId: id };
|
|
377
|
+
}
|
|
378
|
+
async handleTerminalOutput(params) {
|
|
379
|
+
const session = this.terminalSessions.get(params.terminalId);
|
|
380
|
+
if (!session) {
|
|
381
|
+
return { output: "", truncated: false };
|
|
382
|
+
}
|
|
383
|
+
return {
|
|
384
|
+
output: session.output,
|
|
385
|
+
truncated: session.truncated,
|
|
386
|
+
...(session.done && {
|
|
387
|
+
exitStatus: { exitCode: session.exitCode },
|
|
388
|
+
}),
|
|
389
|
+
};
|
|
390
|
+
}
|
|
391
|
+
async handleWaitForTerminalExit(params) {
|
|
392
|
+
const session = this.terminalSessions.get(params.terminalId);
|
|
393
|
+
if (!session) {
|
|
394
|
+
return { exitCode: -1 };
|
|
395
|
+
}
|
|
396
|
+
if (!session.done) {
|
|
397
|
+
const done = this.terminalDonePromises.get(params.terminalId);
|
|
398
|
+
if (done)
|
|
399
|
+
await done;
|
|
400
|
+
}
|
|
401
|
+
this.bus.emit("agent:tool-output", {
|
|
402
|
+
tool: session.command ?? "",
|
|
403
|
+
output: session.output,
|
|
404
|
+
exitCode: session.exitCode,
|
|
405
|
+
});
|
|
406
|
+
return { exitCode: session.exitCode ?? -1 };
|
|
407
|
+
}
|
|
408
|
+
async handleKillTerminal(params) {
|
|
409
|
+
const session = this.terminalSessions.get(params.terminalId);
|
|
410
|
+
if (session && !session.done) {
|
|
411
|
+
killSession(session);
|
|
412
|
+
}
|
|
413
|
+
return {};
|
|
414
|
+
}
|
|
415
|
+
async handleReleaseTerminal(params) {
|
|
416
|
+
this.terminalSessions.delete(params.terminalId);
|
|
417
|
+
this.terminalDonePromises.delete(params.terminalId);
|
|
418
|
+
return {};
|
|
419
|
+
}
|
|
420
|
+
// ── Filesystem handlers ────────────────────────────────────────
|
|
421
|
+
async handleReadTextFile(params) {
|
|
422
|
+
try {
|
|
423
|
+
let content = await fs.readFile(params.path, "utf-8");
|
|
424
|
+
if (params.line != null || params.limit != null) {
|
|
425
|
+
const lines = content.split("\n");
|
|
426
|
+
const start = (params.line ?? 1) - 1;
|
|
427
|
+
const end = params.limit != null ? start + params.limit : lines.length;
|
|
428
|
+
content = lines.slice(start, end).join("\n");
|
|
429
|
+
}
|
|
430
|
+
return { content };
|
|
431
|
+
}
|
|
432
|
+
catch (err) {
|
|
433
|
+
throw new Error(`Failed to read ${params.path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
async handleWriteTextFile(params) {
|
|
437
|
+
// Read original content for diff preview
|
|
438
|
+
let original = null;
|
|
439
|
+
try {
|
|
440
|
+
original = await fs.readFile(params.path, "utf-8");
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
// File doesn't exist yet — will show as "new file"
|
|
444
|
+
}
|
|
445
|
+
const diff = computeDiff(original, params.content);
|
|
446
|
+
// Identical content — nothing to do
|
|
447
|
+
if (diff.isIdentical)
|
|
448
|
+
return {};
|
|
449
|
+
// Extensions can gate this — default is auto-approve (yolo mode)
|
|
450
|
+
const result = await this.bus.emitPipeAsync("permission:request", {
|
|
451
|
+
kind: "file-write",
|
|
452
|
+
title: params.path,
|
|
453
|
+
metadata: { path: params.path, diff, content: params.content },
|
|
454
|
+
decision: { approved: true },
|
|
455
|
+
});
|
|
456
|
+
if (!result.decision.approved) {
|
|
457
|
+
throw new Error(`User rejected modification: ${params.path}`);
|
|
458
|
+
}
|
|
459
|
+
// Write the file
|
|
460
|
+
try {
|
|
461
|
+
await fs.writeFile(params.path, params.content, "utf-8");
|
|
462
|
+
this.fileWatcher.approve(path.resolve(params.path), params.content);
|
|
463
|
+
return {};
|
|
464
|
+
}
|
|
465
|
+
catch (err) {
|
|
466
|
+
throw new Error(`Failed to write ${params.path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
// ── Diff preview for non-ACP file changes ────────────────────
|
|
470
|
+
/**
|
|
471
|
+
* After the agent finishes, check all tracked files for changes
|
|
472
|
+
* made via the agent's own tools (not through fs/writeTextFile)
|
|
473
|
+
* and show interactive diff previews.
|
|
474
|
+
*/
|
|
475
|
+
async showPendingFileChanges() {
|
|
476
|
+
const changes = await this.fileWatcher.detectChanges();
|
|
477
|
+
if (changes.length === 0)
|
|
478
|
+
return;
|
|
479
|
+
for (const change of changes) {
|
|
480
|
+
const diff = computeDiff(change.before, change.after);
|
|
481
|
+
if (diff.isIdentical)
|
|
482
|
+
continue;
|
|
483
|
+
const result = await this.bus.emitPipeAsync("permission:request", {
|
|
484
|
+
kind: "file-write",
|
|
485
|
+
title: change.relPath,
|
|
486
|
+
metadata: { path: change.relPath, diff, content: change.after },
|
|
487
|
+
decision: { approved: true },
|
|
488
|
+
});
|
|
489
|
+
if (result.decision.approved) {
|
|
490
|
+
this.fileWatcher.approve(change.path, change.after);
|
|
491
|
+
}
|
|
492
|
+
else {
|
|
493
|
+
await this.fileWatcher.revert(change.path);
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
// ── Cleanup ────────────────────────────────────────────────────
|
|
498
|
+
kill() {
|
|
499
|
+
for (const session of this.terminalSessions.values()) {
|
|
500
|
+
if (!session.done)
|
|
501
|
+
killSession(session);
|
|
502
|
+
}
|
|
503
|
+
if (this.agentProcess && !this.agentProcess.killed) {
|
|
504
|
+
this.agentProcess.kill("SIGTERM");
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import type { EventBus } from "./event-bus.js";
|
|
2
|
+
export declare class ContextManager {
|
|
3
|
+
private exchanges;
|
|
4
|
+
private nextId;
|
|
5
|
+
private currentCwd;
|
|
6
|
+
private sessionStart;
|
|
7
|
+
private pendingToolCalls;
|
|
8
|
+
constructor(bus: EventBus);
|
|
9
|
+
getCwd(): string;
|
|
10
|
+
/**
|
|
11
|
+
* Build the <shell_context> block for the agent prompt.
|
|
12
|
+
* Pipeline: window → truncate → format
|
|
13
|
+
*/
|
|
14
|
+
getContext(budget?: number): string;
|
|
15
|
+
/**
|
|
16
|
+
* Regex/keyword search across all exchanges. Returns formatted results.
|
|
17
|
+
*/
|
|
18
|
+
search(query: string): string;
|
|
19
|
+
/**
|
|
20
|
+
* Return full untruncated content for specific exchange IDs.
|
|
21
|
+
*/
|
|
22
|
+
expand(ids: number[]): string;
|
|
23
|
+
/**
|
|
24
|
+
* One-line summaries of last N exchanges.
|
|
25
|
+
*/
|
|
26
|
+
getRecentSummary(n?: number): string;
|
|
27
|
+
/**
|
|
28
|
+
* Parse and handle __shell_recall commands.
|
|
29
|
+
*/
|
|
30
|
+
handleRecallCommand(command: string): string;
|
|
31
|
+
/**
|
|
32
|
+
* Clear exchange history (used by /clear command).
|
|
33
|
+
*/
|
|
34
|
+
clear(): void;
|
|
35
|
+
private applyWindow;
|
|
36
|
+
private applyTruncation;
|
|
37
|
+
private formatContext;
|
|
38
|
+
private addExchange;
|
|
39
|
+
private formatExchangeTruncated;
|
|
40
|
+
private truncateForRecall;
|
|
41
|
+
private formatExchangeFull;
|
|
42
|
+
private exchangeOneLiner;
|
|
43
|
+
private exchangeSearchText;
|
|
44
|
+
private exchangeSize;
|
|
45
|
+
}
|