agent-sh 0.7.0 → 0.9.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 +28 -33
- package/dist/agent/agent-loop.d.ts +31 -8
- package/dist/agent/agent-loop.js +277 -66
- package/dist/agent/conversation-state.d.ts +41 -9
- package/dist/agent/conversation-state.js +340 -17
- package/dist/agent/history-file.d.ts +36 -0
- package/dist/agent/history-file.js +167 -0
- package/dist/agent/nuclear-form.d.ts +41 -0
- package/dist/agent/nuclear-form.js +176 -0
- package/dist/agent/system-prompt.d.ts +4 -5
- package/dist/agent/system-prompt.js +16 -11
- package/dist/agent/token-budget.d.ts +13 -0
- package/dist/agent/token-budget.js +50 -0
- package/dist/agent/tool-protocol.d.ts +83 -0
- package/dist/agent/tool-protocol.js +386 -0
- package/dist/agent/tools/user-shell.js +4 -1
- package/dist/agent/types.d.ts +21 -1
- package/dist/context-manager.d.ts +0 -1
- package/dist/context-manager.js +5 -110
- package/dist/core.d.ts +7 -7
- package/dist/core.js +76 -180
- package/dist/event-bus.d.ts +40 -0
- package/dist/event-bus.js +20 -1
- package/dist/extension-loader.d.ts +5 -0
- package/dist/extension-loader.js +104 -17
- package/dist/extensions/agent-backend.d.ts +13 -0
- package/dist/extensions/agent-backend.js +167 -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 +25 -0
- package/dist/extensions/slash-commands.d.ts +1 -1
- package/dist/extensions/slash-commands.js +44 -1
- package/dist/extensions/terminal-buffer.d.ts +1 -1
- package/dist/extensions/terminal-buffer.js +22 -8
- package/dist/extensions/tui-renderer.js +177 -122
- package/dist/index.js +14 -20
- package/dist/settings.d.ts +25 -2
- package/dist/settings.js +25 -4
- package/dist/{input-handler.d.ts → shell/input-handler.d.ts} +1 -1
- package/dist/{input-handler.js → shell/input-handler.js} +60 -43
- 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} +24 -6
- package/dist/types.d.ts +49 -32
- package/dist/utils/ansi.d.ts +10 -0
- package/dist/utils/ansi.js +27 -0
- package/dist/utils/compositor.d.ts +62 -0
- package/dist/utils/compositor.js +88 -0
- package/dist/utils/diff-renderer.js +92 -4
- package/dist/utils/floating-panel.d.ts +34 -3
- package/dist/utils/floating-panel.js +315 -82
- package/dist/utils/handler-registry.d.ts +26 -10
- package/dist/utils/handler-registry.js +52 -16
- package/dist/utils/line-editor.d.ts +32 -3
- package/dist/utils/line-editor.js +218 -36
- package/dist/utils/markdown.d.ts +1 -0
- package/dist/utils/markdown.js +4 -4
- package/dist/utils/message-utils.d.ts +35 -0
- package/dist/utils/message-utils.js +75 -0
- package/dist/utils/terminal-buffer.d.ts +9 -1
- package/dist/utils/terminal-buffer.js +31 -2
- package/dist/utils/tool-display.d.ts +1 -0
- package/dist/utils/tool-display.js +1 -1
- 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 +571 -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 +154 -0
- package/examples/extensions/ash-mcp-bridge/package.json +9 -0
- package/examples/extensions/claude-code-bridge/index.ts +77 -1
- package/examples/extensions/interactive-prompts.ts +82 -110
- package/examples/extensions/overlay-agent.ts +84 -38
- package/examples/extensions/peer-mesh.ts +450 -0
- package/examples/extensions/pi-bridge/index.ts +87 -2
- package/examples/extensions/questionnaire.ts +249 -0
- package/examples/extensions/tmux-pane.ts +307 -0
- package/examples/extensions/web-access.ts +327 -0
- package/package.json +9 -1
- package/dist/extensions/overlay-agent.d.ts +0 -11
- package/dist/extensions/overlay-agent.js +0 -43
- package/examples/extensions/terminal-buffer.ts +0 -184
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Web Access extension — web search & content extraction for agent-sh.
|
|
3
|
+
*
|
|
4
|
+
* Provides two tools:
|
|
5
|
+
* - web_search: Search the web via Exa MCP (free, no API key)
|
|
6
|
+
* - web_fetch: Extract page content as clean markdown
|
|
7
|
+
* Fallback chain: Z.AI reader → Jina Reader → direct fetch
|
|
8
|
+
*
|
|
9
|
+
* Optional: ZAI_API_KEY environment variable (for Z.AI reader, best quality)
|
|
10
|
+
*
|
|
11
|
+
* Optional configuration (~/.agent-sh/settings.json):
|
|
12
|
+
* {
|
|
13
|
+
* "web-access": {
|
|
14
|
+
* "timeout": 30000,
|
|
15
|
+
* "searchNumResults": 5
|
|
16
|
+
* }
|
|
17
|
+
* }
|
|
18
|
+
*
|
|
19
|
+
* Inspired by: https://github.com/nicobailon/pi-web-access
|
|
20
|
+
*/
|
|
21
|
+
import type { ExtensionContext } from "agent-sh/types";
|
|
22
|
+
|
|
23
|
+
// ── Constants ────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
const EXA_MCP_URL = "https://mcp.exa.ai/mcp";
|
|
26
|
+
|
|
27
|
+
const ZAI_BASE = "https://api.z.ai";
|
|
28
|
+
const ZAI_READER_PATH = "/api/mcp/web_reader/mcp";
|
|
29
|
+
|
|
30
|
+
const JINA_READER_URL = "https://r.jina.ai";
|
|
31
|
+
|
|
32
|
+
// ── Exa MCP search (free, no key, no session) ───────────────────────
|
|
33
|
+
|
|
34
|
+
async function exaSearch(
|
|
35
|
+
query: string,
|
|
36
|
+
numResults: number,
|
|
37
|
+
timeout: number,
|
|
38
|
+
): Promise<string> {
|
|
39
|
+
const res = await fetch(EXA_MCP_URL, {
|
|
40
|
+
method: "POST",
|
|
41
|
+
headers: {
|
|
42
|
+
"Content-Type": "application/json",
|
|
43
|
+
Accept: "application/json, text/event-stream",
|
|
44
|
+
},
|
|
45
|
+
body: JSON.stringify({
|
|
46
|
+
jsonrpc: "2.0",
|
|
47
|
+
id: 1,
|
|
48
|
+
method: "tools/call",
|
|
49
|
+
params: {
|
|
50
|
+
name: "web_search_exa",
|
|
51
|
+
arguments: {
|
|
52
|
+
query,
|
|
53
|
+
numResults,
|
|
54
|
+
type: "auto",
|
|
55
|
+
livecrawl: "fallback",
|
|
56
|
+
contextMaxCharacters: 3000,
|
|
57
|
+
},
|
|
58
|
+
},
|
|
59
|
+
}),
|
|
60
|
+
signal: AbortSignal.timeout(timeout),
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
if (!res.ok) {
|
|
64
|
+
throw new Error(`Exa MCP ${res.status}: ${(await res.text()).slice(0, 200)}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const body = await res.text();
|
|
68
|
+
|
|
69
|
+
// Parse SSE or JSON response
|
|
70
|
+
let parsed: any = null;
|
|
71
|
+
for (const line of body.split("\n")) {
|
|
72
|
+
if (!line.startsWith("data:")) continue;
|
|
73
|
+
const payload = line.slice(line.charAt(5) === " " ? 6 : 5).trim();
|
|
74
|
+
if (!payload) continue;
|
|
75
|
+
try {
|
|
76
|
+
const candidate = JSON.parse(payload);
|
|
77
|
+
if (candidate?.result || candidate?.error) { parsed = candidate; break; }
|
|
78
|
+
} catch { /* skip */ }
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (!parsed) {
|
|
82
|
+
try { parsed = JSON.parse(body); } catch { /* skip */ }
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!parsed) throw new Error("Exa MCP returned empty response");
|
|
86
|
+
if (parsed.error) throw new Error(parsed.error.message ?? JSON.stringify(parsed.error));
|
|
87
|
+
if (parsed.result?.isError) {
|
|
88
|
+
const msg = parsed.result.content?.find((c: any) => c.type === "text")?.text;
|
|
89
|
+
throw new Error(msg ?? "Exa MCP returned an error");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const text = parsed.result?.content?.find(
|
|
93
|
+
(c: any) => c.type === "text" && c.text?.trim(),
|
|
94
|
+
)?.text;
|
|
95
|
+
|
|
96
|
+
if (!text) throw new Error("Exa MCP returned empty content");
|
|
97
|
+
return text;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// ── Z.AI MCP reader (requires API key + session) ────────────────────
|
|
101
|
+
|
|
102
|
+
let zaiRpcId = 0;
|
|
103
|
+
const zaiSessionId = { current: "" };
|
|
104
|
+
|
|
105
|
+
async function zaiMcpPost(
|
|
106
|
+
apiKey: string,
|
|
107
|
+
body: Record<string, unknown>,
|
|
108
|
+
timeout: number,
|
|
109
|
+
): Promise<any> {
|
|
110
|
+
const headers: Record<string, string> = {
|
|
111
|
+
"Content-Type": "application/json",
|
|
112
|
+
Accept: "application/json, text/event-stream",
|
|
113
|
+
Authorization: `Bearer ${apiKey}`,
|
|
114
|
+
};
|
|
115
|
+
if (zaiSessionId.current) headers["mcp-session-id"] = zaiSessionId.current;
|
|
116
|
+
|
|
117
|
+
const res = await fetch(`${ZAI_BASE}${ZAI_READER_PATH}`, {
|
|
118
|
+
method: "POST",
|
|
119
|
+
headers,
|
|
120
|
+
body: JSON.stringify(body),
|
|
121
|
+
signal: AbortSignal.timeout(timeout),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
if (!res.ok) throw new Error(`Z.AI MCP ${res.status}`);
|
|
125
|
+
|
|
126
|
+
const sid = res.headers.get("mcp-session-id");
|
|
127
|
+
if (sid) zaiSessionId.current = sid;
|
|
128
|
+
|
|
129
|
+
const ct = res.headers.get("content-type") ?? "";
|
|
130
|
+
if (ct.includes("text/event-stream")) {
|
|
131
|
+
const text = await res.text();
|
|
132
|
+
for (const line of text.split("\n")) {
|
|
133
|
+
if (!line.startsWith("data:")) continue;
|
|
134
|
+
const payload = line.slice(line.charAt(5) === " " ? 6 : 5);
|
|
135
|
+
if (!payload) continue;
|
|
136
|
+
const parsed = JSON.parse(payload);
|
|
137
|
+
if (parsed.error) throw new Error(parsed.error.message);
|
|
138
|
+
return parsed.result;
|
|
139
|
+
}
|
|
140
|
+
throw new Error("No data in Z.AI SSE response");
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const json = await res.json();
|
|
144
|
+
const response = Array.isArray(json) ? json[0] : json;
|
|
145
|
+
if (response?.error) throw new Error(response.error.message);
|
|
146
|
+
return response?.result;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async function zaiRead(apiKey: string, url: string, timeout: number): Promise<string> {
|
|
150
|
+
// Initialize session if needed
|
|
151
|
+
if (!zaiSessionId.current) {
|
|
152
|
+
await zaiMcpPost(apiKey, {
|
|
153
|
+
jsonrpc: "2.0", id: ++zaiRpcId, method: "initialize",
|
|
154
|
+
params: {
|
|
155
|
+
protocolVersion: "2024-11-05", capabilities: {},
|
|
156
|
+
clientInfo: { name: "ash-web-access", version: "1.0.0" },
|
|
157
|
+
},
|
|
158
|
+
}, timeout);
|
|
159
|
+
await zaiMcpPost(apiKey, {
|
|
160
|
+
jsonrpc: "2.0", method: "notifications/initialized",
|
|
161
|
+
}, timeout);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const result = await zaiMcpPost(apiKey, {
|
|
165
|
+
jsonrpc: "2.0", id: ++zaiRpcId, method: "tools/call",
|
|
166
|
+
params: { name: "webReader", arguments: { url } },
|
|
167
|
+
}, timeout);
|
|
168
|
+
|
|
169
|
+
// Unwrap double-encoded JSON response
|
|
170
|
+
const textBlock = result?.content?.find((c: any) => c.type === "text" && c.text);
|
|
171
|
+
if (!textBlock) return JSON.stringify(result, null, 2);
|
|
172
|
+
|
|
173
|
+
let data: any;
|
|
174
|
+
try {
|
|
175
|
+
data = JSON.parse(textBlock.text);
|
|
176
|
+
if (typeof data === "string") data = JSON.parse(data);
|
|
177
|
+
} catch {
|
|
178
|
+
return textBlock.text;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (data && typeof data === "object" && !Array.isArray(data)) {
|
|
182
|
+
const title = data.title ? `# ${data.title}\n\n` : "";
|
|
183
|
+
const source = data.url ? `**Source:** ${data.url}\n\n` : "";
|
|
184
|
+
const body = data.content ?? data.markdown ?? data.text ?? JSON.stringify(data, null, 2);
|
|
185
|
+
return `${title}${source}${body}`;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
return typeof data === "string" ? data : JSON.stringify(data, null, 2);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// ── Jina Reader (free, no key) ───────────────────────────────────────
|
|
192
|
+
|
|
193
|
+
async function jinaRead(url: string, timeout: number): Promise<string> {
|
|
194
|
+
const res = await fetch(`${JINA_READER_URL}/${url}`, {
|
|
195
|
+
headers: { Accept: "text/markdown", "X-Return-Format": "markdown" },
|
|
196
|
+
signal: AbortSignal.timeout(timeout),
|
|
197
|
+
});
|
|
198
|
+
if (!res.ok) throw new Error(`Jina Reader ${res.status}`);
|
|
199
|
+
return res.text();
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ── Direct fetch (last resort) ───────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
async function directFetch(url: string, timeout: number): Promise<string> {
|
|
205
|
+
const res = await fetch(url, {
|
|
206
|
+
headers: {
|
|
207
|
+
"User-Agent":
|
|
208
|
+
"Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 " +
|
|
209
|
+
"(KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36",
|
|
210
|
+
},
|
|
211
|
+
signal: AbortSignal.timeout(timeout),
|
|
212
|
+
redirect: "follow",
|
|
213
|
+
});
|
|
214
|
+
if (!res.ok) throw new Error(`HTTP ${res.status}: ${res.statusText}`);
|
|
215
|
+
const ct = res.headers.get("content-type") ?? "";
|
|
216
|
+
if (ct.includes("application/json")) return JSON.stringify(await res.json(), null, 2);
|
|
217
|
+
return res.text();
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
// ── Extension entry point ────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
export default function activate(ctx: ExtensionContext) {
|
|
223
|
+
const apiKey = process.env.ZAI_API_KEY ?? "";
|
|
224
|
+
|
|
225
|
+
const config = ctx.getExtensionSettings("web-access", {
|
|
226
|
+
timeout: 30000,
|
|
227
|
+
searchNumResults: 5,
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const timeout = config.timeout ?? 30000;
|
|
231
|
+
const numResults = config.searchNumResults ?? 5;
|
|
232
|
+
|
|
233
|
+
// ── Tool: web_search (Exa MCP, free) ────────────────────────────
|
|
234
|
+
|
|
235
|
+
ctx.registerTool({
|
|
236
|
+
name: "web_search",
|
|
237
|
+
displayName: "Web Search",
|
|
238
|
+
description:
|
|
239
|
+
"Search the web and return results with titles, URLs, and content snippets. " +
|
|
240
|
+
"Free, no API key required. Powered by Exa.",
|
|
241
|
+
input_schema: {
|
|
242
|
+
type: "object" as const,
|
|
243
|
+
properties: {
|
|
244
|
+
query: {
|
|
245
|
+
type: "string",
|
|
246
|
+
description: "The search query",
|
|
247
|
+
},
|
|
248
|
+
numResults: {
|
|
249
|
+
type: "number",
|
|
250
|
+
description: `Number of results (default: ${numResults}, max: 10)`,
|
|
251
|
+
},
|
|
252
|
+
},
|
|
253
|
+
required: ["query"],
|
|
254
|
+
},
|
|
255
|
+
async execute(args: { query: string; numResults?: number }) {
|
|
256
|
+
const n = Math.min(args.numResults ?? numResults, 10);
|
|
257
|
+
try {
|
|
258
|
+
const results = await exaSearch(args.query, n, timeout);
|
|
259
|
+
return { content: results, exitCode: 0, isError: false };
|
|
260
|
+
} catch (err: any) {
|
|
261
|
+
return { content: `Search failed: ${err.message}`, exitCode: 1, isError: true };
|
|
262
|
+
}
|
|
263
|
+
},
|
|
264
|
+
formatCall(args: { query: string }) {
|
|
265
|
+
return `Searching: "${args.query}"`;
|
|
266
|
+
},
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// ── Tool: web_fetch ─────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
ctx.registerTool({
|
|
272
|
+
name: "web_fetch",
|
|
273
|
+
displayName: "Web Fetch",
|
|
274
|
+
description:
|
|
275
|
+
"Fetch a URL and extract its content as clean markdown. " +
|
|
276
|
+
"Handles web pages, articles, and documentation. " +
|
|
277
|
+
"Uses Z.AI reader (best quality), Jina Reader, or direct fetch as fallback.",
|
|
278
|
+
input_schema: {
|
|
279
|
+
type: "object" as const,
|
|
280
|
+
properties: {
|
|
281
|
+
url: {
|
|
282
|
+
type: "string",
|
|
283
|
+
description: "The URL to fetch",
|
|
284
|
+
},
|
|
285
|
+
raw: {
|
|
286
|
+
type: "boolean",
|
|
287
|
+
description:
|
|
288
|
+
"If true, fetch raw content directly (useful for JSON APIs, raw text files)",
|
|
289
|
+
},
|
|
290
|
+
},
|
|
291
|
+
required: ["url"],
|
|
292
|
+
},
|
|
293
|
+
async execute(args: { url: string; raw?: boolean }) {
|
|
294
|
+
if (args.raw) {
|
|
295
|
+
try {
|
|
296
|
+
const content = await directFetch(args.url, timeout);
|
|
297
|
+
return { content, exitCode: 0, isError: false };
|
|
298
|
+
} catch (err: any) {
|
|
299
|
+
return { content: `Fetch failed: ${err.message}`, exitCode: 1, isError: true };
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Fallback chain: Z.AI reader → Jina Reader → direct fetch
|
|
304
|
+
if (apiKey) {
|
|
305
|
+
try {
|
|
306
|
+
const content = await zaiRead(apiKey, args.url, timeout);
|
|
307
|
+
return { content, exitCode: 0, isError: false };
|
|
308
|
+
} catch { /* fall through */ }
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
try {
|
|
312
|
+
const content = await jinaRead(args.url, timeout);
|
|
313
|
+
return { content, exitCode: 0, isError: false };
|
|
314
|
+
} catch { /* fall through */ }
|
|
315
|
+
|
|
316
|
+
try {
|
|
317
|
+
const content = await directFetch(args.url, timeout);
|
|
318
|
+
return { content, exitCode: 0, isError: false };
|
|
319
|
+
} catch (err: any) {
|
|
320
|
+
return { content: `All fetch methods failed: ${err.message}`, exitCode: 1, isError: true };
|
|
321
|
+
}
|
|
322
|
+
},
|
|
323
|
+
formatCall(args: { url: string }) {
|
|
324
|
+
return `Fetching: ${args.url}`;
|
|
325
|
+
},
|
|
326
|
+
});
|
|
327
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "agent-sh",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.0",
|
|
4
4
|
"description": "A shell-first terminal where AI is one keystroke away",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "dist/core.js",
|
|
@@ -26,6 +26,14 @@
|
|
|
26
26
|
"types": "./dist/settings.d.ts",
|
|
27
27
|
"default": "./dist/settings.js"
|
|
28
28
|
},
|
|
29
|
+
"./extension-loader": {
|
|
30
|
+
"types": "./dist/extension-loader.d.ts",
|
|
31
|
+
"default": "./dist/extension-loader.js"
|
|
32
|
+
},
|
|
33
|
+
"./extensions": {
|
|
34
|
+
"types": "./dist/extensions/index.d.ts",
|
|
35
|
+
"default": "./dist/extensions/index.js"
|
|
36
|
+
},
|
|
29
37
|
"./utils/stream-transform": {
|
|
30
38
|
"types": "./dist/utils/stream-transform.d.ts",
|
|
31
39
|
"default": "./dist/utils/stream-transform.js"
|
|
@@ -1,11 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Built-in overlay agent.
|
|
3
|
-
*
|
|
4
|
-
* Provides a hotkey (Ctrl+\) to summon the agent from anywhere — even
|
|
5
|
-
* inside vim, htop, or ssh. Composites a floating response box on top
|
|
6
|
-
* of the current terminal content.
|
|
7
|
-
*
|
|
8
|
-
* Requires: npm install @xterm/headless@5.5.0 @xterm/addon-serialize@0.13.0
|
|
9
|
-
*/
|
|
10
|
-
import type { ExtensionContext } from "../types.js";
|
|
11
|
-
export default function activate({ bus, createFloatingPanel }: ExtensionContext): void;
|
|
@@ -1,43 +0,0 @@
|
|
|
1
|
-
const BOLD = "\x1b[1m";
|
|
2
|
-
const CYAN = "\x1b[36m";
|
|
3
|
-
const RESET = "\x1b[0m";
|
|
4
|
-
export default function activate({ bus, createFloatingPanel }) {
|
|
5
|
-
const panel = createFloatingPanel({
|
|
6
|
-
trigger: "\x1c", // Ctrl+\
|
|
7
|
-
dimBackground: true,
|
|
8
|
-
autoDismissMs: 2000,
|
|
9
|
-
});
|
|
10
|
-
// ── Panel lifecycle ────────────────────────────────────────
|
|
11
|
-
panel.handlers.advise("panel:submit", (_next, query) => {
|
|
12
|
-
panel.setActive();
|
|
13
|
-
panel.appendLine(`${CYAN}${BOLD}❯${RESET} ${query}`);
|
|
14
|
-
panel.appendLine("");
|
|
15
|
-
bus.emit("agent:submit", { query });
|
|
16
|
-
});
|
|
17
|
-
// ── Stream agent response into panel ───────────────────────
|
|
18
|
-
bus.on("agent:response-chunk", (e) => {
|
|
19
|
-
if (!panel.active)
|
|
20
|
-
return;
|
|
21
|
-
for (const block of e.blocks) {
|
|
22
|
-
if (block.type === "text" && block.text) {
|
|
23
|
-
panel.appendText(block.text);
|
|
24
|
-
}
|
|
25
|
-
}
|
|
26
|
-
});
|
|
27
|
-
bus.on("agent:tool-started", (e) => {
|
|
28
|
-
if (!panel.active)
|
|
29
|
-
return;
|
|
30
|
-
panel.appendLine(`▶ ${e.title}${e.displayDetail ? " " + e.displayDetail : ""}`);
|
|
31
|
-
});
|
|
32
|
-
bus.on("agent:tool-completed", (e) => {
|
|
33
|
-
if (!panel.active)
|
|
34
|
-
return;
|
|
35
|
-
const mark = e.exitCode === 0 ? " ✓" : ` ✗ exit ${e.exitCode}`;
|
|
36
|
-
panel.updateLastLine((line) => line + mark);
|
|
37
|
-
});
|
|
38
|
-
bus.on("agent:processing-done", () => {
|
|
39
|
-
if (!panel.active)
|
|
40
|
-
return;
|
|
41
|
-
panel.setDone();
|
|
42
|
-
});
|
|
43
|
-
}
|
|
@@ -1,184 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Terminal buffer extension.
|
|
3
|
-
*
|
|
4
|
-
* Maintains a headless xterm.js terminal fed from raw PTY data.
|
|
5
|
-
* Provides an accurate, clean-text snapshot of the terminal screen
|
|
6
|
-
* that the agent can use for context — handling ANSI codes, cursor
|
|
7
|
-
* movement, alternate screen (vim/htop), and line wrapping correctly.
|
|
8
|
-
*
|
|
9
|
-
* Registers two agent tools:
|
|
10
|
-
* - terminal_read: get the current screen contents + cursor position
|
|
11
|
-
* - terminal_keys: send raw keystrokes into the user's live PTY
|
|
12
|
-
*
|
|
13
|
-
* Together these let the agent operate inside interactive programs
|
|
14
|
-
* (vim, htop, less, etc.) by reading the screen and typing keys.
|
|
15
|
-
*
|
|
16
|
-
* Requires: npm install @xterm/headless@5.5.0 @xterm/addon-serialize@0.13.0
|
|
17
|
-
*
|
|
18
|
-
* Usage:
|
|
19
|
-
* agent-sh -e ./examples/extensions/terminal-buffer.ts
|
|
20
|
-
*
|
|
21
|
-
* # Or copy to ~/.agent-sh/extensions/ for permanent use:
|
|
22
|
-
* cp examples/extensions/terminal-buffer.ts ~/.agent-sh/extensions/
|
|
23
|
-
*/
|
|
24
|
-
import type { ExtensionContext } from "agent-sh/types";
|
|
25
|
-
|
|
26
|
-
/** Wait for PTY output to settle after sending keystrokes. */
|
|
27
|
-
function settle(ms = 100): Promise<void> {
|
|
28
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
29
|
-
}
|
|
30
|
-
|
|
31
|
-
/** Interpret C-style escape sequences in a string (e.g. \r → CR, \x1b → ESC). */
|
|
32
|
-
function interpretEscapes(str: string): string {
|
|
33
|
-
return str.replace(/\\(x[0-9a-fA-F]{2}|r|n|t|\\|0)/g, (_, seq: string) => {
|
|
34
|
-
if (seq === "r") return "\r";
|
|
35
|
-
if (seq === "n") return "\n";
|
|
36
|
-
if (seq === "t") return "\t";
|
|
37
|
-
if (seq === "\\") return "\\";
|
|
38
|
-
if (seq === "0") return "\0";
|
|
39
|
-
if (seq.startsWith("x")) return String.fromCharCode(parseInt(seq.slice(1), 16));
|
|
40
|
-
return seq;
|
|
41
|
-
});
|
|
42
|
-
}
|
|
43
|
-
|
|
44
|
-
export default function activate({ bus, terminalBuffer: tb, registerTool }: ExtensionContext): void {
|
|
45
|
-
if (!tb) {
|
|
46
|
-
console.warn("terminal-buffer: @xterm/headless not installed — extension disabled");
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// ── Agent tools ─────────────────────────────────────────────
|
|
51
|
-
// Context injection is intentionally NOT done here — the terminal
|
|
52
|
-
// buffer content would bloat every agent message. The agent can
|
|
53
|
-
// call terminal_read on demand, and the overlay extension injects
|
|
54
|
-
// context only when the overlay is active.
|
|
55
|
-
|
|
56
|
-
registerTool({
|
|
57
|
-
name: "terminal_read",
|
|
58
|
-
description:
|
|
59
|
-
"Read the current terminal screen contents. Returns clean text (ANSI stripped) " +
|
|
60
|
-
"with cursor position and whether an alternate-screen program (vim, htop, less) is active. " +
|
|
61
|
-
"Use this to see what the user sees before sending keystrokes with terminal_keys.",
|
|
62
|
-
input_schema: {
|
|
63
|
-
type: "object",
|
|
64
|
-
properties: {},
|
|
65
|
-
},
|
|
66
|
-
showOutput: true,
|
|
67
|
-
|
|
68
|
-
getDisplayInfo: () => ({
|
|
69
|
-
kind: "read" as const,
|
|
70
|
-
icon: "⊞",
|
|
71
|
-
locations: [],
|
|
72
|
-
}),
|
|
73
|
-
|
|
74
|
-
async execute() {
|
|
75
|
-
const { text, altScreen, cursorX, cursorY } = tb.readScreen();
|
|
76
|
-
const info = [
|
|
77
|
-
altScreen ? "mode: alternate screen" : "mode: normal",
|
|
78
|
-
`cursor: row=${cursorY} col=${cursorX}`,
|
|
79
|
-
].join(", ");
|
|
80
|
-
|
|
81
|
-
return {
|
|
82
|
-
content: `[${info}]\n\n${text}`,
|
|
83
|
-
exitCode: 0,
|
|
84
|
-
isError: false,
|
|
85
|
-
};
|
|
86
|
-
},
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
registerTool({
|
|
90
|
-
name: "terminal_keys",
|
|
91
|
-
description:
|
|
92
|
-
"Send keystrokes to the user's live terminal. The keys are written directly to the PTY " +
|
|
93
|
-
"as if the user typed them. Use escape sequences for special keys:\n" +
|
|
94
|
-
" - Escape: \\x1b\n" +
|
|
95
|
-
" - Enter/Return: \\r\n" +
|
|
96
|
-
" - Tab: \\t\n" +
|
|
97
|
-
" - Ctrl+C: \\x03\n" +
|
|
98
|
-
" - Ctrl+D: \\x04\n" +
|
|
99
|
-
" - Ctrl+Z: \\x1a\n" +
|
|
100
|
-
" - Arrow keys: \\x1b[A (up), \\x1b[B (down), \\x1b[C (right), \\x1b[D (left)\n" +
|
|
101
|
-
" - Backspace: \\x7f\n\n" +
|
|
102
|
-
"Example: to quit vim without saving, send keys=\"\\x1b:q!\\r\" (Escape, :q!, Enter).\n" +
|
|
103
|
-
"Always call terminal_read after sending keys to verify the result.",
|
|
104
|
-
input_schema: {
|
|
105
|
-
type: "object",
|
|
106
|
-
properties: {
|
|
107
|
-
keys: {
|
|
108
|
-
type: "string",
|
|
109
|
-
description:
|
|
110
|
-
"The keystrokes to send. Use \\x1b for Escape, \\r for Enter, \\t for Tab, " +
|
|
111
|
-
"\\x03 for Ctrl+C, etc. Regular characters are sent as-is.",
|
|
112
|
-
},
|
|
113
|
-
settle_ms: {
|
|
114
|
-
type: "number",
|
|
115
|
-
description:
|
|
116
|
-
"Milliseconds to wait after sending keys for the terminal to settle before " +
|
|
117
|
-
"returning (default: 150). Increase for slow programs.",
|
|
118
|
-
},
|
|
119
|
-
},
|
|
120
|
-
required: ["keys"],
|
|
121
|
-
},
|
|
122
|
-
showOutput: false,
|
|
123
|
-
|
|
124
|
-
getDisplayInfo: (args) => ({
|
|
125
|
-
kind: "execute" as const,
|
|
126
|
-
icon: "⌨",
|
|
127
|
-
locations: [],
|
|
128
|
-
}),
|
|
129
|
-
|
|
130
|
-
formatCall: (args) => {
|
|
131
|
-
const keys = args.keys as string;
|
|
132
|
-
// Show a readable version of the keys — handle both literal
|
|
133
|
-
// escape strings (\\x1b) and actual bytes (\x1b)
|
|
134
|
-
return keys
|
|
135
|
-
.replace(/\\x1b|\x1b/g, "ESC")
|
|
136
|
-
.replace(/\\r|\r/g, "⏎")
|
|
137
|
-
.replace(/\\n|\n/g, "↵")
|
|
138
|
-
.replace(/\\t|\t/g, "TAB")
|
|
139
|
-
.replace(/\\x03|\x03/g, "^C")
|
|
140
|
-
.replace(/\\x04|\x04/g, "^D")
|
|
141
|
-
.replace(/\\x7f|\x7f/g, "BS");
|
|
142
|
-
},
|
|
143
|
-
|
|
144
|
-
async execute(args) {
|
|
145
|
-
const raw = args.keys as string;
|
|
146
|
-
const keys = interpretEscapes(raw);
|
|
147
|
-
const settleMs = (args.settle_ms as number) ?? 150;
|
|
148
|
-
|
|
149
|
-
// Force PTY output visible so the user sees the program's response.
|
|
150
|
-
// Stays visible for the rest of agent processing — Shell resets
|
|
151
|
-
// paused=false on processing-done anyway.
|
|
152
|
-
bus.emit("shell:stdout-show", {});
|
|
153
|
-
process.stdout.write("\n");
|
|
154
|
-
bus.emit("shell:pty-write", { data: keys });
|
|
155
|
-
|
|
156
|
-
// Wait for the terminal to process the keystrokes and render
|
|
157
|
-
await settle(settleMs);
|
|
158
|
-
|
|
159
|
-
// Return the screen state after the keystrokes
|
|
160
|
-
const { text, altScreen, cursorX, cursorY } = tb.readScreen();
|
|
161
|
-
const info = [
|
|
162
|
-
altScreen ? "mode: alternate screen" : "mode: normal",
|
|
163
|
-
`cursor: row=${cursorY} col=${cursorX}`,
|
|
164
|
-
].join(", ");
|
|
165
|
-
|
|
166
|
-
return {
|
|
167
|
-
content: `Keys sent. Screen after:\n[${info}]\n\n${text}`,
|
|
168
|
-
exitCode: 0,
|
|
169
|
-
isError: false,
|
|
170
|
-
};
|
|
171
|
-
},
|
|
172
|
-
});
|
|
173
|
-
|
|
174
|
-
// ── Bus snapshot for other extensions ───────────────────────
|
|
175
|
-
|
|
176
|
-
bus.on("shell:buffer-request", () => {
|
|
177
|
-
const { text, altScreen, cursorX, cursorY } = tb.readScreen();
|
|
178
|
-
bus.emit("shell:buffer-snapshot", {
|
|
179
|
-
text,
|
|
180
|
-
altScreen,
|
|
181
|
-
cursor: { x: cursorX, y: cursorY },
|
|
182
|
-
});
|
|
183
|
-
});
|
|
184
|
-
}
|