agent-sh 0.3.1 → 0.5.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 +66 -96
- package/dist/agent/agent-loop.d.ts +85 -0
- package/dist/agent/agent-loop.js +611 -0
- package/dist/agent/conversation-state.d.ts +27 -0
- package/dist/agent/conversation-state.js +59 -0
- package/dist/agent/index.d.ts +11 -0
- package/dist/agent/index.js +9 -0
- package/dist/agent/skills.d.ts +25 -0
- package/dist/agent/skills.js +186 -0
- package/dist/agent/subagent.d.ts +37 -0
- package/dist/agent/subagent.js +117 -0
- package/dist/agent/system-prompt.d.ts +14 -0
- package/dist/agent/system-prompt.js +98 -0
- package/dist/agent/tool-registry.d.ts +15 -0
- package/dist/agent/tool-registry.js +30 -0
- package/dist/agent/tools/bash.d.ts +7 -0
- package/dist/agent/tools/bash.js +62 -0
- package/dist/agent/tools/edit-file.d.ts +2 -0
- package/dist/agent/tools/edit-file.js +95 -0
- package/dist/agent/tools/glob.d.ts +2 -0
- package/dist/agent/tools/glob.js +55 -0
- package/dist/agent/tools/grep.d.ts +2 -0
- package/dist/agent/tools/grep.js +77 -0
- package/dist/agent/tools/list-skills.d.ts +2 -0
- package/dist/agent/tools/list-skills.js +28 -0
- package/dist/agent/tools/ls.d.ts +2 -0
- package/dist/agent/tools/ls.js +43 -0
- package/dist/agent/tools/read-file.d.ts +2 -0
- package/dist/agent/tools/read-file.js +55 -0
- package/dist/agent/tools/user-shell.d.ts +13 -0
- package/dist/agent/tools/user-shell.js +57 -0
- package/dist/agent/tools/write-file.d.ts +2 -0
- package/dist/agent/tools/write-file.js +74 -0
- package/dist/agent/types.d.ts +44 -0
- package/dist/agent/types.js +1 -0
- package/dist/core.d.ts +24 -14
- package/dist/core.js +260 -36
- package/dist/event-bus.d.ts +84 -14
- package/dist/event-bus.js +10 -1
- package/dist/extension-loader.js +12 -1
- package/dist/extensions/command-suggest.d.ts +10 -0
- package/dist/extensions/command-suggest.js +41 -0
- package/dist/extensions/slash-commands.d.ts +1 -1
- package/dist/extensions/slash-commands.js +161 -64
- package/dist/extensions/tui-renderer.js +111 -53
- package/dist/index.js +124 -120
- package/dist/input-handler.d.ts +17 -8
- package/dist/input-handler.js +152 -45
- package/dist/output-parser.d.ts +7 -0
- package/dist/output-parser.js +27 -0
- package/dist/settings.d.ts +53 -2
- package/dist/settings.js +45 -2
- package/dist/shell.js +36 -27
- package/dist/types.d.ts +46 -6
- package/dist/utils/box-frame.d.ts +3 -1
- package/dist/utils/box-frame.js +12 -5
- package/dist/utils/line-editor.js +4 -0
- package/dist/utils/llm-client.d.ts +45 -0
- package/dist/utils/llm-client.js +60 -0
- package/dist/utils/markdown.js +2 -2
- package/dist/utils/stream-transform.js +20 -47
- package/dist/utils/tool-display.js +15 -5
- package/examples/extensions/claude-code-bridge/README.md +35 -0
- package/examples/extensions/claude-code-bridge/index.ts +198 -0
- package/examples/extensions/claude-code-bridge/package.json +11 -0
- package/examples/extensions/openrouter.ts +87 -0
- package/examples/extensions/pi-bridge/README.md +35 -0
- package/examples/extensions/pi-bridge/index.ts +265 -0
- package/examples/extensions/pi-bridge/package.json +13 -0
- package/examples/extensions/subagents.ts +87 -0
- package/package.json +3 -5
- package/dist/acp-client.d.ts +0 -100
- package/dist/acp-client.js +0 -656
- package/dist/extensions/shell-exec.d.ts +0 -24
- package/dist/extensions/shell-exec.js +0 -188
- package/dist/mcp-server.d.ts +0 -13
- package/dist/mcp-server.js +0 -234
- package/examples/pi-agent-sh.ts +0 -166
package/dist/acp-client.js
DELETED
|
@@ -1,656 +0,0 @@
|
|
|
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
|
-
autoCancelled = false;
|
|
26
|
-
pendingToolCounter = 0;
|
|
27
|
-
agentInfo = null;
|
|
28
|
-
modes = [];
|
|
29
|
-
currentModeId = null;
|
|
30
|
-
constructor(opts) {
|
|
31
|
-
this.bus = opts.bus;
|
|
32
|
-
this.contextManager = opts.contextManager;
|
|
33
|
-
this.config = opts.config;
|
|
34
|
-
this.fileWatcher = new FileWatcher(process.cwd());
|
|
35
|
-
}
|
|
36
|
-
async start() {
|
|
37
|
-
this.log(`Starting agent: ${this.config.agentCommand} ${this.config.agentArgs.join(" ")}`);
|
|
38
|
-
// Spawn the agent subprocess with the user's full shell environment
|
|
39
|
-
// (includes vars from .zshrc/.bashrc that process.env may not have).
|
|
40
|
-
// Merge in any runtime env vars set by extensions (e.g. AGENT_SH_SOCKET)
|
|
41
|
-
// that weren't present when shellEnv was captured at startup.
|
|
42
|
-
const baseEnv = this.config.shellEnv ?? process.env;
|
|
43
|
-
const agentEnv = { ...baseEnv };
|
|
44
|
-
for (const [k, v] of Object.entries(process.env)) {
|
|
45
|
-
if (v !== undefined && !(k in agentEnv)) {
|
|
46
|
-
agentEnv[k] = v;
|
|
47
|
-
}
|
|
48
|
-
}
|
|
49
|
-
this.agentProcess = spawn(this.config.agentCommand, this.config.agentArgs, {
|
|
50
|
-
stdio: ["pipe", "pipe", process.env.DEBUG ? "inherit" : "ignore"],
|
|
51
|
-
env: agentEnv,
|
|
52
|
-
});
|
|
53
|
-
// Catch spawn errors (ENOENT, EACCES, etc.) before proceeding
|
|
54
|
-
await new Promise((resolve, reject) => {
|
|
55
|
-
const onError = (err) => {
|
|
56
|
-
reject(new Error(`Failed to start agent "${this.config.agentCommand}": ${err.message}`));
|
|
57
|
-
};
|
|
58
|
-
this.agentProcess.on("error", onError);
|
|
59
|
-
// spawn errors fire on next tick — wait for that, then detach the listener
|
|
60
|
-
setTimeout(() => {
|
|
61
|
-
this.agentProcess.removeListener("error", onError);
|
|
62
|
-
resolve();
|
|
63
|
-
}, 100);
|
|
64
|
-
});
|
|
65
|
-
this.log("Agent process spawned");
|
|
66
|
-
this.agentProcess.on("exit", (code) => {
|
|
67
|
-
this.bus.emit("agent:error", { message: `Agent process exited with code ${code}` });
|
|
68
|
-
this.connection = null;
|
|
69
|
-
this.sessionId = null;
|
|
70
|
-
});
|
|
71
|
-
if (!this.agentProcess.stdin || !this.agentProcess.stdout) {
|
|
72
|
-
throw new Error("Failed to get agent process stdio");
|
|
73
|
-
}
|
|
74
|
-
// Create ACP stream from the agent's stdio
|
|
75
|
-
const output = Writable.toWeb(this.agentProcess.stdin);
|
|
76
|
-
const input = Readable.toWeb(this.agentProcess.stdout);
|
|
77
|
-
const stream = acp.ndJsonStream(output, input);
|
|
78
|
-
this.log("Creating ACP connection");
|
|
79
|
-
// Create the client-side connection, providing our Client handler
|
|
80
|
-
this.connection = new acp.ClientSideConnection((_agent) => this.createClientHandler(), stream);
|
|
81
|
-
// Initialize the connection with timeout
|
|
82
|
-
this.log("Sending initialize request");
|
|
83
|
-
const initTimeoutMs = 30000; // 30 seconds
|
|
84
|
-
const initResponse = await Promise.race([
|
|
85
|
-
this.connection.initialize({
|
|
86
|
-
protocolVersion: acp.PROTOCOL_VERSION,
|
|
87
|
-
clientInfo: { name: "agent-sh", version: "0.1.0" },
|
|
88
|
-
clientCapabilities: {
|
|
89
|
-
terminal: true,
|
|
90
|
-
fs: {
|
|
91
|
-
readTextFile: true,
|
|
92
|
-
writeTextFile: true,
|
|
93
|
-
},
|
|
94
|
-
},
|
|
95
|
-
}),
|
|
96
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error(`Initialize timeout after ${initTimeoutMs}ms`)), initTimeoutMs)),
|
|
97
|
-
]);
|
|
98
|
-
this.log("Initialize successful");
|
|
99
|
-
// Store agent info for display
|
|
100
|
-
if (initResponse.agentInfo) {
|
|
101
|
-
this.agentInfo = {
|
|
102
|
-
name: initResponse.agentInfo.name || this.config.agentCommand,
|
|
103
|
-
version: initResponse.agentInfo.version || "unknown"
|
|
104
|
-
};
|
|
105
|
-
this.log(`Agent info: ${this.agentInfo.name} v${this.agentInfo.version}`);
|
|
106
|
-
}
|
|
107
|
-
// Create a session — let extensions add MCP servers via pipe
|
|
108
|
-
const cwd = this.contextManager.getCwd();
|
|
109
|
-
this.log(`Creating new session with cwd: ${cwd}`);
|
|
110
|
-
const sessionConfig = this.bus.emitPipe("session:configure", {
|
|
111
|
-
cwd,
|
|
112
|
-
mcpServers: [],
|
|
113
|
-
});
|
|
114
|
-
const sessionTimeoutMs = 30000; // 30 seconds
|
|
115
|
-
const sessionResponse = await Promise.race([
|
|
116
|
-
this.connection.newSession({
|
|
117
|
-
cwd: sessionConfig.cwd,
|
|
118
|
-
mcpServers: sessionConfig.mcpServers,
|
|
119
|
-
}),
|
|
120
|
-
new Promise((_, reject) => setTimeout(() => reject(new Error(`newSession timeout after ${sessionTimeoutMs}ms`)), sessionTimeoutMs)),
|
|
121
|
-
]);
|
|
122
|
-
this.sessionId = sessionResponse.sessionId;
|
|
123
|
-
this.log(`Session created: ${this.sessionId}`);
|
|
124
|
-
// Parse session modes (thinking level, etc.)
|
|
125
|
-
this.updateModes(sessionResponse);
|
|
126
|
-
// Listen for mode cycle requests from input handler
|
|
127
|
-
this.bus.on("config:cycle", () => this.cycleMode());
|
|
128
|
-
}
|
|
129
|
-
/**
|
|
130
|
-
* Send a user query to the agent.
|
|
131
|
-
*/
|
|
132
|
-
async sendPrompt(query) {
|
|
133
|
-
if (!this.connection || !this.sessionId) {
|
|
134
|
-
this.bus.emit("agent:error", { message: "Not connected to agent" });
|
|
135
|
-
return;
|
|
136
|
-
}
|
|
137
|
-
this.promptInProgress = true;
|
|
138
|
-
this.bus.emit("agent:processing-start", {});
|
|
139
|
-
await this.fileWatcher.snapshot();
|
|
140
|
-
this.currentResponseText = "";
|
|
141
|
-
this.autoCancelled = false;
|
|
142
|
-
let cancelled = false;
|
|
143
|
-
// Emit agent query event (TUI renders echo+spinner, ContextManager records it)
|
|
144
|
-
this.bus.emit("agent:query", { query });
|
|
145
|
-
// Build structured context from ContextManager
|
|
146
|
-
const contextBlock = this.contextManager.getContext();
|
|
147
|
-
try {
|
|
148
|
-
this.log("sending prompt...");
|
|
149
|
-
const response = await this.connection.prompt({
|
|
150
|
-
sessionId: this.sessionId,
|
|
151
|
-
prompt: [
|
|
152
|
-
{
|
|
153
|
-
type: "text",
|
|
154
|
-
text: contextBlock + "\n" + query,
|
|
155
|
-
},
|
|
156
|
-
],
|
|
157
|
-
});
|
|
158
|
-
this.log(`prompt resolved: stopReason=${response.stopReason}`);
|
|
159
|
-
if (response.stopReason === "cancelled") {
|
|
160
|
-
cancelled = true;
|
|
161
|
-
if (!this.autoCancelled) {
|
|
162
|
-
this.bus.emit("agent:cancelled", {});
|
|
163
|
-
}
|
|
164
|
-
}
|
|
165
|
-
}
|
|
166
|
-
catch (err) {
|
|
167
|
-
this.log(`prompt error: ${err}`);
|
|
168
|
-
this.bus.emit("agent:error", {
|
|
169
|
-
message: err instanceof Error ? err.message : String(err),
|
|
170
|
-
});
|
|
171
|
-
}
|
|
172
|
-
finally {
|
|
173
|
-
this.log("restoring shell mode");
|
|
174
|
-
if (!cancelled) {
|
|
175
|
-
this.bus.emitTransform("agent:response-done", {
|
|
176
|
-
response: this.currentResponseText,
|
|
177
|
-
});
|
|
178
|
-
}
|
|
179
|
-
this.lastResponseText = this.currentResponseText;
|
|
180
|
-
// Show diff previews for files the agent modified via its own tools
|
|
181
|
-
// (modifications via fs/writeTextFile are already handled inline)
|
|
182
|
-
if (this.promptInProgress) {
|
|
183
|
-
await this.showPendingFileChanges();
|
|
184
|
-
}
|
|
185
|
-
this.bus.emit("agent:processing-done", {});
|
|
186
|
-
this.promptInProgress = false;
|
|
187
|
-
}
|
|
188
|
-
}
|
|
189
|
-
/**
|
|
190
|
-
* Silently cancel the prompt after a shell tool completes.
|
|
191
|
-
* Unlike user-initiated cancel(), this doesn't show "(cancelled)" —
|
|
192
|
-
* the tool already ran, we just skip the unnecessary LLM follow-up.
|
|
193
|
-
*/
|
|
194
|
-
autoCancel() {
|
|
195
|
-
if (!this.connection || !this.sessionId || !this.promptInProgress)
|
|
196
|
-
return;
|
|
197
|
-
this.log("auto-cancel: shell tool completed, skipping LLM follow-up");
|
|
198
|
-
this.autoCancelled = true;
|
|
199
|
-
this.connection.cancel({ sessionId: this.sessionId }).catch(() => { });
|
|
200
|
-
}
|
|
201
|
-
/**
|
|
202
|
-
* Cancel the current prompt and force-recover shell mode.
|
|
203
|
-
*/
|
|
204
|
-
async cancel() {
|
|
205
|
-
this.log("cancel requested");
|
|
206
|
-
// Kill all running terminal sessions
|
|
207
|
-
for (const session of this.terminalSessions.values()) {
|
|
208
|
-
if (!session.done)
|
|
209
|
-
killSession(session);
|
|
210
|
-
}
|
|
211
|
-
if (this.connection && this.sessionId && this.promptInProgress) {
|
|
212
|
-
try {
|
|
213
|
-
await this.connection.cancel({ sessionId: this.sessionId });
|
|
214
|
-
}
|
|
215
|
-
catch {
|
|
216
|
-
// Cancellation is best-effort
|
|
217
|
-
}
|
|
218
|
-
}
|
|
219
|
-
// Force-recover shell regardless of prompt state
|
|
220
|
-
if (this.promptInProgress) {
|
|
221
|
-
this.bus.emit("agent:cancelled", {});
|
|
222
|
-
}
|
|
223
|
-
this.bus.emit("agent:processing-done", {});
|
|
224
|
-
this.promptInProgress = false;
|
|
225
|
-
}
|
|
226
|
-
/**
|
|
227
|
-
* Start a new ACP session, clearing agent-side conversation history.
|
|
228
|
-
*/
|
|
229
|
-
async resetSession() {
|
|
230
|
-
if (!this.connection)
|
|
231
|
-
return;
|
|
232
|
-
const sessionConfig = this.bus.emitPipe("session:configure", {
|
|
233
|
-
cwd: this.contextManager.getCwd(),
|
|
234
|
-
mcpServers: [],
|
|
235
|
-
});
|
|
236
|
-
const sessionResponse = await this.connection.newSession({
|
|
237
|
-
cwd: sessionConfig.cwd,
|
|
238
|
-
mcpServers: sessionConfig.mcpServers,
|
|
239
|
-
});
|
|
240
|
-
this.sessionId = sessionResponse.sessionId;
|
|
241
|
-
this.lastResponseText = "";
|
|
242
|
-
this.currentResponseText = "";
|
|
243
|
-
this.updateModes(sessionResponse);
|
|
244
|
-
}
|
|
245
|
-
/**
|
|
246
|
-
* Get the text of the last agent response (for /copy).
|
|
247
|
-
*/
|
|
248
|
-
getLastResponseText() {
|
|
249
|
-
return this.lastResponseText;
|
|
250
|
-
}
|
|
251
|
-
/**
|
|
252
|
-
* Get agent information for display.
|
|
253
|
-
*/
|
|
254
|
-
getAgentInfo() {
|
|
255
|
-
return this.agentInfo;
|
|
256
|
-
}
|
|
257
|
-
getModel() {
|
|
258
|
-
return this.config.model;
|
|
259
|
-
}
|
|
260
|
-
/**
|
|
261
|
-
* Get the current mode (e.g. thinking level).
|
|
262
|
-
*/
|
|
263
|
-
getCurrentMode() {
|
|
264
|
-
if (!this.currentModeId)
|
|
265
|
-
return null;
|
|
266
|
-
return this.modes.find((m) => m.id === this.currentModeId) ?? null;
|
|
267
|
-
}
|
|
268
|
-
/**
|
|
269
|
-
* Check if agent is connected.
|
|
270
|
-
*/
|
|
271
|
-
isConnected() {
|
|
272
|
-
// Consider connected if we have a connection and agent info
|
|
273
|
-
// Session ID may not be set yet if we're still initializing
|
|
274
|
-
return this.connection !== null && this.agentInfo !== null;
|
|
275
|
-
}
|
|
276
|
-
/**
|
|
277
|
-
* Parse modes from a session response and notify listeners.
|
|
278
|
-
*/
|
|
279
|
-
updateModes(response) {
|
|
280
|
-
const modes = response.modes;
|
|
281
|
-
if (!modes)
|
|
282
|
-
return;
|
|
283
|
-
if (modes.availableModes) {
|
|
284
|
-
this.modes = modes.availableModes.map((m) => ({
|
|
285
|
-
id: m.id,
|
|
286
|
-
name: m.name || m.id,
|
|
287
|
-
}));
|
|
288
|
-
}
|
|
289
|
-
if (modes.currentModeId) {
|
|
290
|
-
this.currentModeId = modes.currentModeId;
|
|
291
|
-
}
|
|
292
|
-
this.bus.emit("config:changed", {});
|
|
293
|
-
}
|
|
294
|
-
/**
|
|
295
|
-
* Cycle to the next session mode.
|
|
296
|
-
*/
|
|
297
|
-
async cycleMode() {
|
|
298
|
-
if (!this.connection || !this.sessionId || this.modes.length === 0)
|
|
299
|
-
return;
|
|
300
|
-
const currentIdx = this.modes.findIndex((m) => m.id === this.currentModeId);
|
|
301
|
-
const nextIdx = (currentIdx + 1) % this.modes.length;
|
|
302
|
-
const nextMode = this.modes[nextIdx];
|
|
303
|
-
try {
|
|
304
|
-
await this.connection.setSessionMode({
|
|
305
|
-
sessionId: this.sessionId,
|
|
306
|
-
modeId: nextMode.id,
|
|
307
|
-
});
|
|
308
|
-
this.currentModeId = nextMode.id;
|
|
309
|
-
this.bus.emit("config:changed", {});
|
|
310
|
-
}
|
|
311
|
-
catch (err) {
|
|
312
|
-
this.log(`Failed to set mode: ${err}`);
|
|
313
|
-
}
|
|
314
|
-
}
|
|
315
|
-
log(msg) {
|
|
316
|
-
if (process.env.DEBUG) {
|
|
317
|
-
process.stderr.write(`[agent-sh] ${msg}\n`);
|
|
318
|
-
}
|
|
319
|
-
}
|
|
320
|
-
/**
|
|
321
|
-
* Create the Client handler that responds to agent requests.
|
|
322
|
-
*/
|
|
323
|
-
createClientHandler() {
|
|
324
|
-
return {
|
|
325
|
-
// Required: handle session update notifications (streaming)
|
|
326
|
-
// Errors must not propagate — the ACP SDK returns them as error
|
|
327
|
-
// responses to the agent, which can stall the stream.
|
|
328
|
-
sessionUpdate: async (params) => {
|
|
329
|
-
try {
|
|
330
|
-
this.handleSessionUpdate(params);
|
|
331
|
-
}
|
|
332
|
-
catch (err) {
|
|
333
|
-
this.log(`Error in sessionUpdate handler: ${err instanceof Error ? err.stack : err}`);
|
|
334
|
-
}
|
|
335
|
-
},
|
|
336
|
-
// Required: handle permission requests
|
|
337
|
-
requestPermission: async (params) => {
|
|
338
|
-
return this.handleRequestPermission(params);
|
|
339
|
-
},
|
|
340
|
-
// Optional: terminal operations
|
|
341
|
-
createTerminal: async (params) => {
|
|
342
|
-
return this.handleCreateTerminal(params);
|
|
343
|
-
},
|
|
344
|
-
terminalOutput: async (params) => {
|
|
345
|
-
return this.handleTerminalOutput(params);
|
|
346
|
-
},
|
|
347
|
-
waitForTerminalExit: async (params) => {
|
|
348
|
-
return this.handleWaitForTerminalExit(params);
|
|
349
|
-
},
|
|
350
|
-
killTerminal: async (params) => {
|
|
351
|
-
return this.handleKillTerminal(params);
|
|
352
|
-
},
|
|
353
|
-
releaseTerminal: async (params) => {
|
|
354
|
-
return this.handleReleaseTerminal(params);
|
|
355
|
-
},
|
|
356
|
-
// Optional: filesystem operations
|
|
357
|
-
readTextFile: async (params) => {
|
|
358
|
-
return this.handleReadTextFile(params);
|
|
359
|
-
},
|
|
360
|
-
writeTextFile: async (params) => {
|
|
361
|
-
return this.handleWriteTextFile(params);
|
|
362
|
-
},
|
|
363
|
-
};
|
|
364
|
-
}
|
|
365
|
-
// ── Session update handler ─────────────────────────────────────
|
|
366
|
-
handleSessionUpdate(params) {
|
|
367
|
-
// Suppress rendering during initialization / between prompts
|
|
368
|
-
if (!this.promptInProgress)
|
|
369
|
-
return;
|
|
370
|
-
const update = params.update;
|
|
371
|
-
switch (update.sessionUpdate) {
|
|
372
|
-
case "agent_message_chunk": {
|
|
373
|
-
const content = update.content;
|
|
374
|
-
if (content.type === "text") {
|
|
375
|
-
this.currentResponseText += content.text;
|
|
376
|
-
this.bus.emitTransform("agent:response-chunk", { text: content.text });
|
|
377
|
-
}
|
|
378
|
-
break;
|
|
379
|
-
}
|
|
380
|
-
case "agent_thought_chunk": {
|
|
381
|
-
const thought = update.content;
|
|
382
|
-
if (thought.type === "text" && thought.text) {
|
|
383
|
-
this.bus.emitTransform("agent:thinking-chunk", { text: thought.text });
|
|
384
|
-
}
|
|
385
|
-
break;
|
|
386
|
-
}
|
|
387
|
-
case "tool_call": {
|
|
388
|
-
const toolId = update.toolCallId || `tool-${this.pendingToolCounter++}`;
|
|
389
|
-
const payload = {
|
|
390
|
-
title: update.title,
|
|
391
|
-
toolCallId: toolId,
|
|
392
|
-
kind: update.kind ?? undefined,
|
|
393
|
-
locations: update.locations?.map((l) => ({ path: l.path, line: l.line })),
|
|
394
|
-
rawInput: update.rawInput,
|
|
395
|
-
};
|
|
396
|
-
const defer = this.pendingToolCalls.size > 0;
|
|
397
|
-
this.pendingToolCalls.set(toolId, {
|
|
398
|
-
title: update.title ?? "",
|
|
399
|
-
deferredPayload: defer ? payload : undefined,
|
|
400
|
-
});
|
|
401
|
-
if (!defer) {
|
|
402
|
-
this.bus.emit("agent:tool-started", payload);
|
|
403
|
-
}
|
|
404
|
-
break;
|
|
405
|
-
}
|
|
406
|
-
case "tool_call_update": {
|
|
407
|
-
const toolId = update.toolCallId;
|
|
408
|
-
const toolInfo = toolId ? this.pendingToolCalls.get(toolId) : undefined;
|
|
409
|
-
const toolTitle = toolInfo?.title;
|
|
410
|
-
if (update.status === "completed" || update.status === "failed") {
|
|
411
|
-
// Emit deferred tool-started before output (parallel tools)
|
|
412
|
-
if (toolInfo?.deferredPayload) {
|
|
413
|
-
this.bus.emit("agent:tool-started", toolInfo.deferredPayload);
|
|
414
|
-
toolInfo.deferredPayload = undefined;
|
|
415
|
-
}
|
|
416
|
-
// Show content only on final status. Skip tools whose output the
|
|
417
|
-
// user already sees (user_shell → PTY) or is agent-only (shell_recall).
|
|
418
|
-
const skipOutput = toolTitle === "user_shell" || toolTitle === "shell_recall";
|
|
419
|
-
if (!skipOutput && update.content && Array.isArray(update.content)) {
|
|
420
|
-
for (const block of update.content) {
|
|
421
|
-
if (block.type === "content" && block.content?.type === "text" && block.content.text) {
|
|
422
|
-
this.bus.emitTransform("agent:tool-output-chunk", { chunk: block.content.text });
|
|
423
|
-
}
|
|
424
|
-
}
|
|
425
|
-
}
|
|
426
|
-
const exitCode = update.status === "completed" ? 0 : 1;
|
|
427
|
-
if (toolId && this.pendingToolCalls.has(toolId)) {
|
|
428
|
-
this.pendingToolCalls.delete(toolId);
|
|
429
|
-
this.bus.emit("agent:tool-completed", {
|
|
430
|
-
toolCallId: toolId,
|
|
431
|
-
exitCode,
|
|
432
|
-
rawOutput: update.rawOutput,
|
|
433
|
-
});
|
|
434
|
-
}
|
|
435
|
-
else if (!toolId) {
|
|
436
|
-
this.bus.emit("agent:tool-completed", { exitCode, rawOutput: update.rawOutput });
|
|
437
|
-
}
|
|
438
|
-
// Auto-cancel after shell tools complete — the command already
|
|
439
|
-
// ran in the user's PTY, no need for a second LLM round trip.
|
|
440
|
-
// The result is captured in shell context / shell_recall.
|
|
441
|
-
if (toolTitle === "user_shell" && update.status === "completed") {
|
|
442
|
-
this.autoCancel();
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
break;
|
|
446
|
-
}
|
|
447
|
-
case "current_mode_update": {
|
|
448
|
-
const modeId = update.currentModeId;
|
|
449
|
-
if (modeId) {
|
|
450
|
-
this.currentModeId = modeId;
|
|
451
|
-
this.bus.emit("config:changed", {});
|
|
452
|
-
}
|
|
453
|
-
break;
|
|
454
|
-
}
|
|
455
|
-
default:
|
|
456
|
-
break;
|
|
457
|
-
}
|
|
458
|
-
}
|
|
459
|
-
// ── Permission handler ─────────────────────────────────────────
|
|
460
|
-
async handleRequestPermission(params) {
|
|
461
|
-
const title = params.toolCall.title ?? "Unknown action";
|
|
462
|
-
const result = await this.bus.emitPipeAsync("permission:request", {
|
|
463
|
-
kind: "tool-call",
|
|
464
|
-
title,
|
|
465
|
-
metadata: {
|
|
466
|
-
options: params.options.map((o) => ({
|
|
467
|
-
optionId: o.optionId,
|
|
468
|
-
kind: o.kind,
|
|
469
|
-
})),
|
|
470
|
-
},
|
|
471
|
-
decision: {
|
|
472
|
-
outcome: "selected",
|
|
473
|
-
optionId: (params.options.find((o) => o.kind === "allow_once") ?? params.options[0])?.optionId,
|
|
474
|
-
},
|
|
475
|
-
});
|
|
476
|
-
return { outcome: result.decision };
|
|
477
|
-
}
|
|
478
|
-
// ── Terminal handlers (isolated execution via child_process) ────
|
|
479
|
-
async handleCreateTerminal(params) {
|
|
480
|
-
const fullCommand = params.args?.length
|
|
481
|
-
? `${params.command} ${params.args.join(" ")}`
|
|
482
|
-
: params.command;
|
|
483
|
-
const cwd = params.cwd ?? this.contextManager.getCwd();
|
|
484
|
-
// Let extensions intercept before spawning a real process
|
|
485
|
-
const intercept = this.bus.emitPipe("agent:terminal-intercept", {
|
|
486
|
-
command: fullCommand,
|
|
487
|
-
cwd,
|
|
488
|
-
intercepted: false,
|
|
489
|
-
output: "",
|
|
490
|
-
});
|
|
491
|
-
if (intercept.intercepted) {
|
|
492
|
-
const id = `t${++this.terminalCounter}`;
|
|
493
|
-
const session = {
|
|
494
|
-
id,
|
|
495
|
-
command: fullCommand,
|
|
496
|
-
output: intercept.output,
|
|
497
|
-
exitCode: 0,
|
|
498
|
-
done: true,
|
|
499
|
-
truncated: false,
|
|
500
|
-
process: null,
|
|
501
|
-
};
|
|
502
|
-
this.terminalSessions.set(id, session);
|
|
503
|
-
this.terminalDonePromises.set(id, Promise.resolve());
|
|
504
|
-
return { terminalId: id };
|
|
505
|
-
}
|
|
506
|
-
this.bus.emit("agent:tool-call", {
|
|
507
|
-
tool: fullCommand,
|
|
508
|
-
args: { command: params.command, args: params.args, cwd },
|
|
509
|
-
});
|
|
510
|
-
const id = `t${++this.terminalCounter}`;
|
|
511
|
-
const { session, done } = executeCommand({
|
|
512
|
-
command: fullCommand,
|
|
513
|
-
cwd,
|
|
514
|
-
env: this.config.shellEnv,
|
|
515
|
-
timeout: 60_000,
|
|
516
|
-
maxOutputBytes: 256 * 1024,
|
|
517
|
-
onOutput: (chunk) => {
|
|
518
|
-
// Stream output into the box in real-time (strip ANSI for display)
|
|
519
|
-
this.bus.emit("agent:tool-output-chunk", { chunk: stripAnsi(chunk) });
|
|
520
|
-
},
|
|
521
|
-
});
|
|
522
|
-
session.id = id;
|
|
523
|
-
this.terminalSessions.set(id, session);
|
|
524
|
-
this.terminalDonePromises.set(id, done);
|
|
525
|
-
return { terminalId: id };
|
|
526
|
-
}
|
|
527
|
-
async handleTerminalOutput(params) {
|
|
528
|
-
const session = this.terminalSessions.get(params.terminalId);
|
|
529
|
-
if (!session) {
|
|
530
|
-
return { output: "", truncated: false };
|
|
531
|
-
}
|
|
532
|
-
return {
|
|
533
|
-
output: session.output,
|
|
534
|
-
truncated: session.truncated,
|
|
535
|
-
...(session.done && {
|
|
536
|
-
exitStatus: { exitCode: session.exitCode },
|
|
537
|
-
}),
|
|
538
|
-
};
|
|
539
|
-
}
|
|
540
|
-
async handleWaitForTerminalExit(params) {
|
|
541
|
-
const session = this.terminalSessions.get(params.terminalId);
|
|
542
|
-
if (!session) {
|
|
543
|
-
return { exitCode: -1 };
|
|
544
|
-
}
|
|
545
|
-
if (!session.done) {
|
|
546
|
-
const done = this.terminalDonePromises.get(params.terminalId);
|
|
547
|
-
if (done)
|
|
548
|
-
await done;
|
|
549
|
-
}
|
|
550
|
-
this.bus.emit("agent:tool-output", {
|
|
551
|
-
tool: session.command ?? "",
|
|
552
|
-
output: session.output,
|
|
553
|
-
exitCode: session.exitCode,
|
|
554
|
-
});
|
|
555
|
-
return { exitCode: session.exitCode ?? -1 };
|
|
556
|
-
}
|
|
557
|
-
async handleKillTerminal(params) {
|
|
558
|
-
const session = this.terminalSessions.get(params.terminalId);
|
|
559
|
-
if (session && !session.done) {
|
|
560
|
-
killSession(session);
|
|
561
|
-
}
|
|
562
|
-
return {};
|
|
563
|
-
}
|
|
564
|
-
async handleReleaseTerminal(params) {
|
|
565
|
-
this.terminalSessions.delete(params.terminalId);
|
|
566
|
-
this.terminalDonePromises.delete(params.terminalId);
|
|
567
|
-
return {};
|
|
568
|
-
}
|
|
569
|
-
// ── Filesystem handlers ────────────────────────────────────────
|
|
570
|
-
async handleReadTextFile(params) {
|
|
571
|
-
try {
|
|
572
|
-
let content = await fs.readFile(params.path, "utf-8");
|
|
573
|
-
if (params.line != null || params.limit != null) {
|
|
574
|
-
const lines = content.split("\n");
|
|
575
|
-
const start = (params.line ?? 1) - 1;
|
|
576
|
-
const end = params.limit != null ? start + params.limit : lines.length;
|
|
577
|
-
content = lines.slice(start, end).join("\n");
|
|
578
|
-
}
|
|
579
|
-
return { content };
|
|
580
|
-
}
|
|
581
|
-
catch (err) {
|
|
582
|
-
throw new Error(`Failed to read ${params.path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
async handleWriteTextFile(params) {
|
|
586
|
-
// Read original content for diff preview
|
|
587
|
-
let original = null;
|
|
588
|
-
try {
|
|
589
|
-
original = await fs.readFile(params.path, "utf-8");
|
|
590
|
-
}
|
|
591
|
-
catch {
|
|
592
|
-
// File doesn't exist yet — will show as "new file"
|
|
593
|
-
}
|
|
594
|
-
const diff = computeDiff(original, params.content);
|
|
595
|
-
// Identical content — nothing to do
|
|
596
|
-
if (diff.isIdentical)
|
|
597
|
-
return {};
|
|
598
|
-
// Extensions can gate this — default is auto-approve (yolo mode)
|
|
599
|
-
const result = await this.bus.emitPipeAsync("permission:request", {
|
|
600
|
-
kind: "file-write",
|
|
601
|
-
title: params.path,
|
|
602
|
-
metadata: { path: params.path, diff, content: params.content },
|
|
603
|
-
decision: { approved: true },
|
|
604
|
-
});
|
|
605
|
-
if (!result.decision.approved) {
|
|
606
|
-
throw new Error(`User rejected modification: ${params.path}`);
|
|
607
|
-
}
|
|
608
|
-
// Write the file
|
|
609
|
-
try {
|
|
610
|
-
await fs.writeFile(params.path, params.content, "utf-8");
|
|
611
|
-
this.fileWatcher.approve(path.resolve(params.path), params.content);
|
|
612
|
-
return {};
|
|
613
|
-
}
|
|
614
|
-
catch (err) {
|
|
615
|
-
throw new Error(`Failed to write ${params.path}: ${err instanceof Error ? err.message : String(err)}`);
|
|
616
|
-
}
|
|
617
|
-
}
|
|
618
|
-
// ── Diff preview for non-ACP file changes ────────────────────
|
|
619
|
-
/**
|
|
620
|
-
* After the agent finishes, check all tracked files for changes
|
|
621
|
-
* made via the agent's own tools (not through fs/writeTextFile)
|
|
622
|
-
* and show interactive diff previews.
|
|
623
|
-
*/
|
|
624
|
-
async showPendingFileChanges() {
|
|
625
|
-
const changes = await this.fileWatcher.detectChanges();
|
|
626
|
-
if (changes.length === 0)
|
|
627
|
-
return;
|
|
628
|
-
for (const change of changes) {
|
|
629
|
-
const diff = computeDiff(change.before, change.after);
|
|
630
|
-
if (diff.isIdentical)
|
|
631
|
-
continue;
|
|
632
|
-
const result = await this.bus.emitPipeAsync("permission:request", {
|
|
633
|
-
kind: "file-write",
|
|
634
|
-
title: change.relPath,
|
|
635
|
-
metadata: { path: change.relPath, diff, content: change.after },
|
|
636
|
-
decision: { approved: true },
|
|
637
|
-
});
|
|
638
|
-
if (result.decision.approved) {
|
|
639
|
-
this.fileWatcher.approve(change.path, change.after);
|
|
640
|
-
}
|
|
641
|
-
else {
|
|
642
|
-
await this.fileWatcher.revert(change.path);
|
|
643
|
-
}
|
|
644
|
-
}
|
|
645
|
-
}
|
|
646
|
-
// ── Cleanup ────────────────────────────────────────────────────
|
|
647
|
-
kill() {
|
|
648
|
-
for (const session of this.terminalSessions.values()) {
|
|
649
|
-
if (!session.done)
|
|
650
|
-
killSession(session);
|
|
651
|
-
}
|
|
652
|
-
if (this.agentProcess && !this.agentProcess.killed) {
|
|
653
|
-
this.agentProcess.kill("SIGTERM");
|
|
654
|
-
}
|
|
655
|
-
}
|
|
656
|
-
}
|
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Shell exec extension.
|
|
3
|
-
*
|
|
4
|
-
* Runs a Unix domain socket server speaking JSON-RPC 2.0 that external
|
|
5
|
-
* tools (MCP server, pi extensions, etc.) connect to for interacting
|
|
6
|
-
* with the user's live PTY shell.
|
|
7
|
-
*
|
|
8
|
-
* Also registers the MCP server via the `session:configure` pipe so
|
|
9
|
-
* ACP agents discover the `user_shell` tool automatically.
|
|
10
|
-
*
|
|
11
|
-
* This extension has no direct PTY or Shell knowledge — it communicates
|
|
12
|
-
* exclusively through the bus, following the headless-core philosophy.
|
|
13
|
-
*
|
|
14
|
-
* ## Socket protocol (JSON-RPC 2.0, newline-delimited)
|
|
15
|
-
*
|
|
16
|
-
* shell/exec { command: string } → { output, cwd }
|
|
17
|
-
* shell/cwd {} → { cwd }
|
|
18
|
-
* shell/info {} → { busy, shell }
|
|
19
|
-
* shell/recall { operation, ... } → { result }
|
|
20
|
-
*/
|
|
21
|
-
import type { ExtensionContext } from "../types.js";
|
|
22
|
-
export default function activate({ bus, contextManager }: ExtensionContext, opts: {
|
|
23
|
-
socketPath: string;
|
|
24
|
-
}): void;
|