agent-sh 0.8.0 → 0.10.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 +27 -43
- package/dist/agent/agent-loop.d.ts +69 -6
- package/dist/agent/agent-loop.js +954 -153
- package/dist/agent/conversation-state.d.ts +74 -21
- package/dist/agent/conversation-state.js +361 -150
- package/dist/agent/history-file.d.ts +13 -4
- package/dist/agent/history-file.js +110 -36
- package/dist/agent/nuclear-form.d.ts +28 -3
- package/dist/agent/nuclear-form.js +88 -6
- package/dist/agent/skills.d.ts +2 -4
- package/dist/agent/skills.js +10 -4
- package/dist/agent/subagent.d.ts +23 -0
- package/dist/agent/subagent.js +53 -11
- package/dist/agent/system-prompt.d.ts +37 -5
- package/dist/agent/system-prompt.js +100 -67
- package/dist/{token-budget.d.ts → agent/token-budget.d.ts} +5 -4
- package/dist/{token-budget.js → agent/token-budget.js} +15 -20
- package/dist/agent/tool-protocol.d.ts +105 -0
- package/dist/agent/tool-protocol.js +551 -0
- package/dist/agent/tools/bash.js +3 -3
- package/dist/agent/tools/edit-file.js +9 -6
- package/dist/agent/tools/glob.js +4 -2
- package/dist/agent/tools/grep.js +27 -3
- package/dist/agent/tools/ls.js +5 -6
- package/dist/agent/types.d.ts +22 -2
- package/dist/context-manager.d.ts +17 -0
- package/dist/context-manager.js +37 -4
- package/dist/core.d.ts +7 -7
- package/dist/core.js +99 -196
- package/dist/event-bus.d.ts +85 -2
- package/dist/event-bus.js +20 -1
- package/dist/executor.d.ts +4 -3
- package/dist/executor.js +18 -15
- package/dist/extension-loader.d.ts +5 -0
- package/dist/extension-loader.js +143 -19
- package/dist/extensions/agent-backend.d.ts +14 -0
- package/dist/extensions/agent-backend.js +188 -0
- package/dist/extensions/command-suggest.d.ts +3 -3
- package/dist/extensions/command-suggest.js +4 -3
- package/dist/extensions/index.d.ts +19 -0
- package/dist/extensions/index.js +24 -0
- package/dist/extensions/slash-commands.d.ts +1 -1
- package/dist/extensions/slash-commands.js +30 -10
- package/dist/extensions/tui-renderer.js +117 -113
- package/dist/index.js +39 -26
- package/dist/settings.d.ts +40 -3
- package/dist/settings.js +57 -10
- package/dist/{input-handler.d.ts → shell/input-handler.d.ts} +3 -2
- package/dist/{input-handler.js → shell/input-handler.js} +111 -85
- package/dist/{output-parser.d.ts → shell/output-parser.d.ts} +1 -1
- package/dist/{output-parser.js → shell/output-parser.js} +1 -1
- package/dist/{shell.d.ts → shell/shell.d.ts} +8 -2
- package/dist/{shell.js → shell/shell.js} +39 -8
- package/dist/types.d.ts +61 -10
- package/dist/utils/ansi.d.ts +5 -0
- package/dist/utils/ansi.js +1 -1
- package/dist/utils/compositor.d.ts +67 -0
- package/dist/utils/compositor.js +116 -0
- package/dist/utils/diff-renderer.d.ts +9 -0
- package/dist/utils/diff-renderer.js +312 -146
- package/dist/utils/diff.d.ts +21 -2
- package/dist/utils/diff.js +165 -89
- package/dist/utils/floating-panel.d.ts +2 -0
- package/dist/utils/floating-panel.js +30 -14
- package/dist/utils/handler-registry.d.ts +31 -10
- package/dist/utils/handler-registry.js +58 -16
- package/dist/utils/line-editor.d.ts +33 -3
- package/dist/utils/line-editor.js +221 -44
- package/dist/utils/markdown.d.ts +1 -0
- package/dist/utils/markdown.js +1 -1
- package/dist/utils/message-utils.d.ts +35 -0
- package/dist/utils/message-utils.js +75 -0
- package/dist/utils/terminal-buffer.d.ts +5 -1
- package/dist/utils/terminal-buffer.js +18 -2
- package/dist/utils/tool-display.d.ts +1 -1
- package/dist/utils/tool-display.js +4 -4
- package/dist/utils/tool-interactive.d.ts +12 -0
- package/dist/utils/tool-interactive.js +53 -0
- package/examples/extensions/ash-acp-bridge/README.md +39 -0
- package/examples/extensions/ash-acp-bridge/package.json +23 -0
- package/examples/extensions/ash-acp-bridge/src/index.ts +574 -0
- package/examples/extensions/ash-acp-bridge/tsconfig.json +14 -0
- package/examples/extensions/ash-mcp-bridge/README.md +72 -0
- package/examples/extensions/ash-mcp-bridge/index.ts +164 -0
- package/examples/extensions/ash-mcp-bridge/package.json +9 -0
- package/examples/extensions/claude-code-bridge/index.ts +198 -51
- package/examples/extensions/claude-code-bridge/package.json +1 -0
- package/examples/extensions/interactive-prompts.ts +98 -112
- package/examples/extensions/overlay-agent.ts +84 -38
- package/examples/extensions/peer-mesh.ts +565 -0
- package/examples/extensions/pi-bridge/index.ts +2 -2
- package/examples/extensions/questionnaire.ts +260 -0
- package/examples/extensions/subagents.ts +19 -4
- package/examples/extensions/terminal-buffer.ts +32 -53
- package/examples/extensions/tmux-pane.ts +307 -0
- package/examples/extensions/user-shell.ts +136 -0
- package/examples/extensions/web-access.ts +335 -0
- package/package.json +44 -2
- package/dist/agent/tools/display.d.ts +0 -13
- package/dist/agent/tools/display.js +0 -70
- package/dist/agent/tools/user-shell.d.ts +0 -13
- package/dist/agent/tools/user-shell.js +0 -87
- package/dist/extensions/overlay-agent.d.ts +0 -14
- package/dist/extensions/overlay-agent.js +0 -147
- package/dist/extensions/terminal-buffer.d.ts +0 -14
- package/dist/extensions/terminal-buffer.js +0 -125
|
@@ -0,0 +1,307 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tmux side-pane extension.
|
|
3
|
+
*
|
|
4
|
+
* Two modes:
|
|
5
|
+
* /split — agent output renders in the side pane, queries typed
|
|
6
|
+
* in the main shell (> prompt).
|
|
7
|
+
* /rsplit — reverse split: the side pane has its own input prompt,
|
|
8
|
+
* the agent can see and control the main pane via
|
|
9
|
+
* terminal_read / terminal_keys.
|
|
10
|
+
*
|
|
11
|
+
* Both modes use createRemoteSession() which handles compositor
|
|
12
|
+
* routing, shell lifecycle, and chrome suppression automatically.
|
|
13
|
+
*
|
|
14
|
+
* Usage:
|
|
15
|
+
* ash -e ./examples/extensions/tmux-pane.ts
|
|
16
|
+
*
|
|
17
|
+
* # Or install permanently
|
|
18
|
+
* cp examples/extensions/tmux-pane.ts ~/.agent-sh/extensions/
|
|
19
|
+
*/
|
|
20
|
+
import * as fs from "node:fs";
|
|
21
|
+
import * as net from "node:net";
|
|
22
|
+
import * as os from "node:os";
|
|
23
|
+
import * as path from "node:path";
|
|
24
|
+
import { execSync, spawn } from "node:child_process";
|
|
25
|
+
import type { ExtensionContext, RenderSurface, RemoteSession } from "agent-sh/types";
|
|
26
|
+
|
|
27
|
+
// ── Helpers ─────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
function inTmux(): boolean {
|
|
30
|
+
return !!process.env.TMUX;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function tmux(...args: string[]): string {
|
|
34
|
+
return execSync(
|
|
35
|
+
"tmux " + args.map(a => `'${a.replace(/'/g, "'\\''")}'`).join(" "),
|
|
36
|
+
{ encoding: "utf-8" },
|
|
37
|
+
).trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getPaneWidth(paneId: string): number {
|
|
41
|
+
try {
|
|
42
|
+
return parseInt(tmux("display-message", "-p", "-t", paneId, "#{pane_width}"), 10) || 80;
|
|
43
|
+
} catch {
|
|
44
|
+
return 80;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function paneExists(paneId: string): boolean {
|
|
49
|
+
try {
|
|
50
|
+
tmux("display-message", "-p", "-t", paneId, "#{pane_id}");
|
|
51
|
+
return true;
|
|
52
|
+
} catch {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// ── Chat client script (runs in rsplit pane) ────────────────────
|
|
58
|
+
|
|
59
|
+
const CHAT_CLIENT_SCRIPT = `
|
|
60
|
+
const net = require("net");
|
|
61
|
+
const readline = require("readline");
|
|
62
|
+
|
|
63
|
+
const sockPath = process.argv[2];
|
|
64
|
+
if (!sockPath) { console.error("No socket path"); process.exit(1); }
|
|
65
|
+
|
|
66
|
+
const sock = net.createConnection(sockPath);
|
|
67
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
68
|
+
|
|
69
|
+
sock.on("data", (data) => {
|
|
70
|
+
readline.clearLine(process.stdout, 0);
|
|
71
|
+
readline.cursorTo(process.stdout, 0);
|
|
72
|
+
process.stdout.write(data.toString());
|
|
73
|
+
rl.prompt(true);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
sock.on("end", () => process.exit(0));
|
|
77
|
+
sock.on("error", () => process.exit(1));
|
|
78
|
+
|
|
79
|
+
rl.setPrompt("\\x1b[36m❯\\x1b[0m ");
|
|
80
|
+
rl.prompt();
|
|
81
|
+
|
|
82
|
+
rl.on("line", (line) => {
|
|
83
|
+
const trimmed = line.trim();
|
|
84
|
+
if (!trimmed) { rl.prompt(); return; }
|
|
85
|
+
sock.write(trimmed + "\\n");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
rl.on("close", () => { sock.end(); process.exit(0); });
|
|
89
|
+
`;
|
|
90
|
+
|
|
91
|
+
// ── Surface factory ─────────────────────────────────────────────
|
|
92
|
+
|
|
93
|
+
function createSurface(
|
|
94
|
+
paneId: string,
|
|
95
|
+
ttyFd: fs.WriteStream,
|
|
96
|
+
socketClient: () => net.Socket | undefined,
|
|
97
|
+
): RenderSurface {
|
|
98
|
+
let cachedWidth = getPaneWidth(paneId);
|
|
99
|
+
let lastWidthCheck = Date.now();
|
|
100
|
+
|
|
101
|
+
return {
|
|
102
|
+
write(text: string): void {
|
|
103
|
+
// In rsplit mode, route through socket so client can manage prompt
|
|
104
|
+
const c = socketClient();
|
|
105
|
+
if (c && !c.destroyed) {
|
|
106
|
+
try { c.write(text); } catch {}
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
// In split mode (or fallback), write directly to tty
|
|
110
|
+
if (ttyFd.destroyed) return;
|
|
111
|
+
try { ttyFd.write(text); } catch {}
|
|
112
|
+
},
|
|
113
|
+
writeLine(line: string): void {
|
|
114
|
+
this.write(line + "\n");
|
|
115
|
+
},
|
|
116
|
+
get columns(): number {
|
|
117
|
+
const now = Date.now();
|
|
118
|
+
if (now - lastWidthCheck > 2000) {
|
|
119
|
+
cachedWidth = getPaneWidth(paneId);
|
|
120
|
+
lastWidthCheck = now;
|
|
121
|
+
}
|
|
122
|
+
return cachedWidth;
|
|
123
|
+
},
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// ── Pane state ──────────────────────────────────────────────────
|
|
128
|
+
|
|
129
|
+
type PaneMode = "split" | "rsplit";
|
|
130
|
+
|
|
131
|
+
interface PaneState {
|
|
132
|
+
mode: PaneMode;
|
|
133
|
+
paneId: string;
|
|
134
|
+
ttyFd: fs.WriteStream;
|
|
135
|
+
session: RemoteSession;
|
|
136
|
+
// rsplit-mode only
|
|
137
|
+
server?: net.Server;
|
|
138
|
+
client?: net.Socket;
|
|
139
|
+
sockPath?: string;
|
|
140
|
+
scriptPath?: string;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ── Extension ───────────────────────────────────────────────────
|
|
144
|
+
|
|
145
|
+
export default function activate(ctx: ExtensionContext): void {
|
|
146
|
+
const { bus, registerCommand, registerInstruction, createRemoteSession } = ctx;
|
|
147
|
+
|
|
148
|
+
if (!inTmux()) return;
|
|
149
|
+
|
|
150
|
+
let state: PaneState | null = null;
|
|
151
|
+
|
|
152
|
+
registerInstruction("Tmux Interactive Session", [
|
|
153
|
+
"When the dynamic context includes `interactive-session: true`, the user is chatting",
|
|
154
|
+
"with you in a side pane next to their terminal. They may have a program running in",
|
|
155
|
+
"the other pane (vim, htop, a REPL, etc.). In this mode:",
|
|
156
|
+
"- Use terminal_read to see what's on their screen.",
|
|
157
|
+
"- Use terminal_keys to interact with their running program.",
|
|
158
|
+
"- Use user_shell only for standalone commands, not for interacting with what's on screen.",
|
|
159
|
+
"- Keep responses concise.",
|
|
160
|
+
].join("\n"));
|
|
161
|
+
|
|
162
|
+
// ── Open / close ──────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
function openSplit(): void {
|
|
165
|
+
if (state) close();
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
const paneId = tmux(
|
|
169
|
+
"split-window", "-h", "-l", "45%",
|
|
170
|
+
"-P", "-F", "#{pane_id}", "cat",
|
|
171
|
+
).trim();
|
|
172
|
+
execSync("sleep 0.1");
|
|
173
|
+
|
|
174
|
+
const tty = tmux("display-message", "-p", "-t", paneId, "#{pane_tty}");
|
|
175
|
+
const ttyFd = fs.createWriteStream(tty, { flags: "w" });
|
|
176
|
+
ttyFd.on("error", () => destroyStale());
|
|
177
|
+
|
|
178
|
+
const surface = createSurface(paneId, ttyFd, () => undefined);
|
|
179
|
+
const session = createRemoteSession({ surface });
|
|
180
|
+
|
|
181
|
+
state = { mode: "split", paneId, ttyFd, session };
|
|
182
|
+
surface.writeLine("\x1b[2m── agent output ──\x1b[0m\n");
|
|
183
|
+
bus.emit("ui:info", { message: "Split pane opened (/split to close, /rsplit for interactive)." });
|
|
184
|
+
} catch (e) {
|
|
185
|
+
bus.emit("ui:error", {
|
|
186
|
+
message: `Failed to open split: ${e instanceof Error ? e.message : String(e)}`,
|
|
187
|
+
});
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
function openRsplit(): void {
|
|
192
|
+
if (state) close();
|
|
193
|
+
|
|
194
|
+
try {
|
|
195
|
+
const sockPath = path.join(os.tmpdir(), `agent-sh-chat-${process.pid}.sock`);
|
|
196
|
+
try { fs.unlinkSync(sockPath); } catch {}
|
|
197
|
+
|
|
198
|
+
let client: net.Socket | undefined;
|
|
199
|
+
|
|
200
|
+
const server = net.createServer((conn) => {
|
|
201
|
+
client = conn;
|
|
202
|
+
if (state) state.client = conn;
|
|
203
|
+
conn.on("data", (data) => {
|
|
204
|
+
for (const line of data.toString().split("\n")) {
|
|
205
|
+
const trimmed = line.trim();
|
|
206
|
+
if (trimmed) session.submit(trimmed);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
conn.on("end", () => { client = undefined; if (state) state.client = undefined; });
|
|
210
|
+
conn.on("error", () => { client = undefined; if (state) state.client = undefined; });
|
|
211
|
+
});
|
|
212
|
+
server.listen(sockPath);
|
|
213
|
+
|
|
214
|
+
const scriptPath = path.join(os.tmpdir(), `agent-sh-chat-${process.pid}.js`);
|
|
215
|
+
fs.writeFileSync(scriptPath, CHAT_CLIENT_SCRIPT);
|
|
216
|
+
|
|
217
|
+
const paneId = tmux(
|
|
218
|
+
"split-window", "-h", "-l", "45%",
|
|
219
|
+
"-P", "-F", "#{pane_id}",
|
|
220
|
+
"node", scriptPath, sockPath,
|
|
221
|
+
).trim();
|
|
222
|
+
execSync("sleep 0.2");
|
|
223
|
+
|
|
224
|
+
const tty = tmux("display-message", "-p", "-t", paneId, "#{pane_tty}");
|
|
225
|
+
const ttyFd = fs.createWriteStream(tty, { flags: "w" });
|
|
226
|
+
ttyFd.on("error", () => destroyStale());
|
|
227
|
+
|
|
228
|
+
const surface = createSurface(paneId, ttyFd, () => client);
|
|
229
|
+
const session = createRemoteSession({
|
|
230
|
+
surface,
|
|
231
|
+
suppressQueryBox: true,
|
|
232
|
+
interactive: true,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
state = { mode: "rsplit", paneId, ttyFd, session, server, client, sockPath, scriptPath };
|
|
236
|
+
bus.emit("ui:info", { message: "Reverse split opened (/rsplit to close, /split for output-only)." });
|
|
237
|
+
} catch (e) {
|
|
238
|
+
bus.emit("ui:error", {
|
|
239
|
+
message: `Failed to open rsplit: ${e instanceof Error ? e.message : String(e)}`,
|
|
240
|
+
});
|
|
241
|
+
if (state) close();
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function close(): void {
|
|
246
|
+
if (!state) return;
|
|
247
|
+
const s = state;
|
|
248
|
+
state = null;
|
|
249
|
+
|
|
250
|
+
s.session.close();
|
|
251
|
+
if (s.client) { try { s.client.end(); } catch {} }
|
|
252
|
+
if (s.server) { try { s.server.close(); } catch {} }
|
|
253
|
+
try { s.ttyFd.end(); } catch {}
|
|
254
|
+
try { tmux("kill-pane", "-t", s.paneId); } catch {}
|
|
255
|
+
if (s.sockPath) { try { fs.unlinkSync(s.sockPath); } catch {} }
|
|
256
|
+
if (s.scriptPath) { try { fs.unlinkSync(s.scriptPath); } catch {} }
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function destroyStale(): void {
|
|
260
|
+
if (!state) return;
|
|
261
|
+
const s = state;
|
|
262
|
+
state = null;
|
|
263
|
+
|
|
264
|
+
s.session.close();
|
|
265
|
+
if (s.client) { try { s.client.end(); } catch {} }
|
|
266
|
+
if (s.server) { try { s.server.close(); } catch {} }
|
|
267
|
+
try { s.ttyFd.end(); } catch {}
|
|
268
|
+
if (s.sockPath) { try { fs.unlinkSync(s.sockPath); } catch {} }
|
|
269
|
+
if (s.scriptPath) { try { fs.unlinkSync(s.scriptPath); } catch {} }
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ── Commands ──────────────────────────────────────────────────
|
|
273
|
+
|
|
274
|
+
registerCommand("split", "Toggle tmux side pane for agent output", (args) => {
|
|
275
|
+
const cmd = args.trim().toLowerCase();
|
|
276
|
+
if (cmd === "close") return close();
|
|
277
|
+
if (cmd === "open") return openSplit();
|
|
278
|
+
if (state?.mode === "split") close(); else openSplit();
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
registerCommand("rsplit", "Toggle interactive tmux side pane (reverse split)", (args) => {
|
|
282
|
+
const cmd = args.trim().toLowerCase();
|
|
283
|
+
if (cmd === "close") return close();
|
|
284
|
+
if (cmd === "open") return openRsplit();
|
|
285
|
+
if (state?.mode === "rsplit") close(); else openRsplit();
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
// ── Lifecycle events ──────────────────────────────────────────
|
|
289
|
+
|
|
290
|
+
// In split mode, redraw prompt immediately after query submit.
|
|
291
|
+
bus.on("agent:query", () => {
|
|
292
|
+
if (state?.mode !== "split") return;
|
|
293
|
+
setImmediate(() => bus.emit("shell:pty-write", { data: "\n" }));
|
|
294
|
+
});
|
|
295
|
+
|
|
296
|
+
// In rsplit mode, re-prompt the client after agent finishes.
|
|
297
|
+
bus.on("agent:processing-done", () => {
|
|
298
|
+
if (!state) return;
|
|
299
|
+
if (!paneExists(state.paneId)) { destroyStale(); return; }
|
|
300
|
+
if (state.mode === "rsplit" && state.client && !state.client.destroyed) {
|
|
301
|
+
state.client.write("\n");
|
|
302
|
+
}
|
|
303
|
+
state.session.surface.writeLine("");
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
process.on("exit", () => { if (state) close(); });
|
|
307
|
+
}
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* User shell extension.
|
|
3
|
+
*
|
|
4
|
+
* Registers the user_shell tool, which runs commands in the user's live PTY
|
|
5
|
+
* shell — affecting real shell state (cd, export, source). Also registers
|
|
6
|
+
* system prompt guidance so the agent knows when to use it.
|
|
7
|
+
*
|
|
8
|
+
* Without this extension, the agent only has the isolated bash tool.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* agent-sh -e ./examples/extensions/user-shell.ts
|
|
12
|
+
*
|
|
13
|
+
* # Or copy to ~/.agent-sh/extensions/ for permanent use:
|
|
14
|
+
* cp examples/extensions/user-shell.ts ~/.agent-sh/extensions/
|
|
15
|
+
*/
|
|
16
|
+
import type { ExtensionContext } from "agent-sh/types";
|
|
17
|
+
import type { ToolDefinition } from "agent-sh/agent/types";
|
|
18
|
+
|
|
19
|
+
export default function activate(ctx: ExtensionContext): void {
|
|
20
|
+
const { bus, registerTool, registerInstruction } = ctx;
|
|
21
|
+
const getCwd = () => ctx.contextManager.getCwd();
|
|
22
|
+
|
|
23
|
+
// ── Tool ───────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
registerTool(createUserShellTool({ getCwd, bus }));
|
|
26
|
+
|
|
27
|
+
// ── System prompt guidance ─────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
registerInstruction("user-shell-guide", `# user_shell Tool Guide
|
|
30
|
+
|
|
31
|
+
You have access to user_shell, which runs commands in the user's live shell (PTY).
|
|
32
|
+
- user_shell affects real shell state (cd, export, source).
|
|
33
|
+
- The user sees output directly — do not repeat or summarize it.
|
|
34
|
+
- Use it for: cd, export, source, installing packages, starting servers, git commands.
|
|
35
|
+
- Set return_output=true only if you need to inspect the result.
|
|
36
|
+
- When the user asks to see, list, view, or display anything, use user_shell.
|
|
37
|
+
Internal tools (bash, read, ls, etc.) run in an isolated subprocess — the user cannot see their output.
|
|
38
|
+
- Only use internal tools when you need to reason about content silently.`);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function createUserShellTool(opts: {
|
|
42
|
+
getCwd: () => string;
|
|
43
|
+
bus: ExtensionContext["bus"];
|
|
44
|
+
}): ToolDefinition {
|
|
45
|
+
return {
|
|
46
|
+
name: "user_shell",
|
|
47
|
+
description:
|
|
48
|
+
"Run a complete, non-interactive command in the user's live shell (cd, export, install packages, start servers, git commands). " +
|
|
49
|
+
"Use this for commands that have side effects or that the user wants to see. Output is shown directly to the user but NOT returned " +
|
|
50
|
+
"to you by default — set return_output=true if you need to inspect the result. " +
|
|
51
|
+
"Do NOT use this to interact with programs that are already running in the terminal — use terminal_keys/terminal_read instead.",
|
|
52
|
+
input_schema: {
|
|
53
|
+
type: "object",
|
|
54
|
+
properties: {
|
|
55
|
+
command: {
|
|
56
|
+
type: "string",
|
|
57
|
+
description: "Command to execute in user's shell",
|
|
58
|
+
},
|
|
59
|
+
timeout: {
|
|
60
|
+
type: "number",
|
|
61
|
+
description: "Timeout in seconds (default: 30)",
|
|
62
|
+
},
|
|
63
|
+
return_output: {
|
|
64
|
+
type: "boolean",
|
|
65
|
+
default: false,
|
|
66
|
+
description:
|
|
67
|
+
"Whether to return the command output to you. Default false — output is shown directly to the user. Set true only if you need to inspect the result to answer a question.",
|
|
68
|
+
},
|
|
69
|
+
},
|
|
70
|
+
required: ["command"],
|
|
71
|
+
},
|
|
72
|
+
|
|
73
|
+
showOutput: false,
|
|
74
|
+
modifiesFiles: true,
|
|
75
|
+
|
|
76
|
+
getDisplayInfo: () => ({
|
|
77
|
+
kind: "execute",
|
|
78
|
+
icon: "▷",
|
|
79
|
+
locations: [],
|
|
80
|
+
}),
|
|
81
|
+
|
|
82
|
+
async execute(args) {
|
|
83
|
+
const command = args.command as string;
|
|
84
|
+
const timeoutSec = (args.timeout as number) ?? 30;
|
|
85
|
+
const returnOutput = (args.return_output as boolean) ?? false;
|
|
86
|
+
|
|
87
|
+
// Execute via the shell-exec extension's async pipe with timeout
|
|
88
|
+
let result: { output: string; exitCode: number | null; [k: string]: unknown };
|
|
89
|
+
try {
|
|
90
|
+
const execPromise = opts.bus.emitPipeAsync(
|
|
91
|
+
"shell:exec-request",
|
|
92
|
+
{
|
|
93
|
+
command,
|
|
94
|
+
output: "",
|
|
95
|
+
cwd: opts.getCwd(),
|
|
96
|
+
exitCode: null as number | null,
|
|
97
|
+
done: false,
|
|
98
|
+
},
|
|
99
|
+
);
|
|
100
|
+
const timeoutPromise = new Promise<never>((_, reject) =>
|
|
101
|
+
setTimeout(() => reject(new Error("timeout")), timeoutSec * 1000),
|
|
102
|
+
);
|
|
103
|
+
result = await Promise.race([execPromise, timeoutPromise]);
|
|
104
|
+
} catch (err) {
|
|
105
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
106
|
+
if (msg === "timeout") {
|
|
107
|
+
return {
|
|
108
|
+
content: `Command timed out after ${timeoutSec}s.`,
|
|
109
|
+
exitCode: -1,
|
|
110
|
+
isError: true,
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
return { content: `Error: ${msg}`, exitCode: -1, isError: true };
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const exitCode = result.exitCode ?? 0;
|
|
117
|
+
const isError = exitCode !== 0 && exitCode !== null;
|
|
118
|
+
|
|
119
|
+
if (returnOutput) {
|
|
120
|
+
return {
|
|
121
|
+
content: result.output || "(no output)",
|
|
122
|
+
exitCode,
|
|
123
|
+
isError,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
return {
|
|
128
|
+
content: isError
|
|
129
|
+
? `Command failed with exit code ${exitCode}.`
|
|
130
|
+
: "Command executed.",
|
|
131
|
+
exitCode,
|
|
132
|
+
isError,
|
|
133
|
+
};
|
|
134
|
+
},
|
|
135
|
+
};
|
|
136
|
+
}
|