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