agent-sh 0.8.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.
Files changed (74) hide show
  1. package/README.md +25 -34
  2. package/dist/agent/agent-loop.d.ts +29 -6
  3. package/dist/agent/agent-loop.js +177 -59
  4. package/dist/agent/conversation-state.d.ts +3 -1
  5. package/dist/agent/conversation-state.js +6 -2
  6. package/dist/agent/nuclear-form.js +5 -4
  7. package/dist/agent/system-prompt.d.ts +4 -5
  8. package/dist/agent/system-prompt.js +12 -28
  9. package/dist/{token-budget.js → agent/token-budget.js} +1 -1
  10. package/dist/agent/tool-protocol.d.ts +83 -0
  11. package/dist/agent/tool-protocol.js +386 -0
  12. package/dist/agent/types.d.ts +21 -1
  13. package/dist/core.d.ts +7 -7
  14. package/dist/core.js +76 -194
  15. package/dist/event-bus.d.ts +26 -0
  16. package/dist/event-bus.js +20 -1
  17. package/dist/extension-loader.d.ts +5 -0
  18. package/dist/extension-loader.js +104 -17
  19. package/dist/extensions/agent-backend.d.ts +13 -0
  20. package/dist/extensions/agent-backend.js +167 -0
  21. package/dist/extensions/command-suggest.d.ts +3 -3
  22. package/dist/extensions/command-suggest.js +4 -3
  23. package/dist/extensions/index.d.ts +19 -0
  24. package/dist/extensions/index.js +25 -0
  25. package/dist/extensions/slash-commands.d.ts +1 -1
  26. package/dist/extensions/slash-commands.js +16 -1
  27. package/dist/extensions/terminal-buffer.d.ts +1 -1
  28. package/dist/extensions/terminal-buffer.js +13 -4
  29. package/dist/extensions/tui-renderer.js +63 -43
  30. package/dist/index.js +14 -20
  31. package/dist/settings.d.ts +6 -0
  32. package/dist/settings.js +4 -1
  33. package/dist/{input-handler.d.ts → shell/input-handler.d.ts} +1 -1
  34. package/dist/{input-handler.js → shell/input-handler.js} +60 -43
  35. package/dist/{output-parser.d.ts → shell/output-parser.d.ts} +1 -1
  36. package/dist/{output-parser.js → shell/output-parser.js} +1 -1
  37. package/dist/{shell.d.ts → shell/shell.d.ts} +8 -2
  38. package/dist/{shell.js → shell/shell.js} +20 -6
  39. package/dist/types.d.ts +49 -10
  40. package/dist/utils/compositor.d.ts +62 -0
  41. package/dist/utils/compositor.js +88 -0
  42. package/dist/utils/diff-renderer.js +92 -4
  43. package/dist/utils/floating-panel.d.ts +2 -0
  44. package/dist/utils/floating-panel.js +30 -14
  45. package/dist/utils/handler-registry.d.ts +26 -10
  46. package/dist/utils/handler-registry.js +52 -16
  47. package/dist/utils/line-editor.d.ts +23 -3
  48. package/dist/utils/line-editor.js +180 -42
  49. package/dist/utils/markdown.d.ts +1 -0
  50. package/dist/utils/markdown.js +1 -1
  51. package/dist/utils/message-utils.d.ts +35 -0
  52. package/dist/utils/message-utils.js +75 -0
  53. package/dist/utils/terminal-buffer.d.ts +5 -1
  54. package/dist/utils/terminal-buffer.js +18 -2
  55. package/dist/utils/tool-interactive.d.ts +12 -0
  56. package/dist/utils/tool-interactive.js +53 -0
  57. package/examples/extensions/ash-acp-bridge/README.md +39 -0
  58. package/examples/extensions/ash-acp-bridge/package.json +23 -0
  59. package/examples/extensions/ash-acp-bridge/src/index.ts +571 -0
  60. package/examples/extensions/ash-acp-bridge/tsconfig.json +14 -0
  61. package/examples/extensions/ash-mcp-bridge/README.md +72 -0
  62. package/examples/extensions/ash-mcp-bridge/index.ts +154 -0
  63. package/examples/extensions/ash-mcp-bridge/package.json +9 -0
  64. package/examples/extensions/interactive-prompts.ts +82 -110
  65. package/examples/extensions/overlay-agent.ts +84 -38
  66. package/examples/extensions/peer-mesh.ts +450 -0
  67. package/examples/extensions/questionnaire.ts +249 -0
  68. package/examples/extensions/tmux-pane.ts +307 -0
  69. package/examples/extensions/web-access.ts +327 -0
  70. package/package.json +9 -1
  71. package/dist/extensions/overlay-agent.d.ts +0 -14
  72. package/dist/extensions/overlay-agent.js +0 -147
  73. package/examples/extensions/terminal-buffer.ts +0 -184
  74. /package/dist/{token-budget.d.ts → agent/token-budget.d.ts} +0 -0
@@ -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,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.8.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,14 +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
- * Rendering reuses the shared tui:render-* handlers so that extensions
9
- * advising those handlers affect both the main TUI and the overlay.
10
- *
11
- * Requires: npm install @xterm/headless@5.5.0 @xterm/addon-serialize@0.13.0
12
- */
13
- import type { ExtensionContext } from "../types.js";
14
- export default function activate(ctx: ExtensionContext): void;