agent-sh 0.12.26 → 0.12.27

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.
@@ -0,0 +1,405 @@
1
+ /**
2
+ * tunnel-vision — observe a remote shell session through the user's ssh PTY.
3
+ *
4
+ * Workflow:
5
+ * 1. User runs `/tunnel-snippet` to print a small bash/zsh snippet.
6
+ * 2. User ssh's into a remote host and pastes the snippet.
7
+ * 3. The snippet installs preexec/precmd hooks that emit OSC lifecycle
8
+ * markers tagged with the *local* instanceId. The local OutputParser
9
+ * honors these markers as if they came from a local shell, so
10
+ * foregroundBusy flips correctly at each remote prompt cycle — that
11
+ * is what lets the user enter agent mode (`>`) while ssh'd in.
12
+ * 4. While bound, the agent gets a `pty_send` tool that types commands
13
+ * into the user's interactive ssh session and reads back captured
14
+ * output. The binding survives reload_extensions; ssh-exit auto-
15
+ * teardown is detected by watching raw OSC 7 from the local outer
16
+ * shell after ssh terminates.
17
+ *
18
+ * Markers used:
19
+ * - OSC 9997 / 9999: agent-sh's standard preexec / prompt lifecycle
20
+ * markers (instanceId-tagged).
21
+ * - OSC 9996: tunnel-vision-only BIND marker (`vt=1` tag) — purely for
22
+ * this extension's binding-state machine; the local OutputParser does
23
+ * not process it.
24
+ *
25
+ * Usage:
26
+ * ash -e ./examples/extensions/tunnel-vision.ts
27
+ *
28
+ * # Or install permanently
29
+ * cp examples/extensions/tunnel-vision.ts ~/.agent-sh/extensions/
30
+ */
31
+ import * as fs from "node:fs";
32
+ import * as path from "node:path";
33
+ import type { ExtensionContext } from "agent-sh/types";
34
+
35
+ const BIND_RE = /\x1b\]9996;vt=([^;]+);BIND;([^;]*);([^\x07]*)\x07/;
36
+ const OSC7_RE = /\x1b\]7;file:\/\/[^/]*(\/[^\x07\x1b]*)/;
37
+
38
+ interface Binding {
39
+ host: string;
40
+ cwd: string;
41
+ startedAt: number;
42
+ }
43
+
44
+ interface RingEntry {
45
+ command: string;
46
+ output: string;
47
+ cwd: string;
48
+ ts: number;
49
+ }
50
+
51
+ let binding: Binding | null = null;
52
+ const ring: RingEntry[] = [];
53
+ const RING_SIZE = 5;
54
+
55
+ let pendingExec: {
56
+ resolve: (output: string) => void;
57
+ timer: NodeJS.Timeout;
58
+ buffer: string;
59
+ } | null = null;
60
+ let lastPtyDataAt = 0;
61
+ const IDLE_MS = 500;
62
+ const PARTIAL_TAIL_BYTES = 2048;
63
+ let bindingFile = "";
64
+
65
+ function pushRing(entry: RingEntry): void {
66
+ ring.push(entry);
67
+ while (ring.length > RING_SIZE) ring.shift();
68
+ }
69
+
70
+ function persistBinding(): void {
71
+ if (!bindingFile) return;
72
+ try {
73
+ if (binding) {
74
+ fs.writeFileSync(bindingFile, JSON.stringify(binding), "utf-8");
75
+ } else {
76
+ try { fs.unlinkSync(bindingFile); } catch { /* not present */ }
77
+ }
78
+ } catch { /* persistence is best-effort */ }
79
+ }
80
+
81
+ function loadBinding(): void {
82
+ if (!bindingFile) return;
83
+ try {
84
+ const raw = fs.readFileSync(bindingFile, "utf-8");
85
+ const parsed = JSON.parse(raw) as Binding;
86
+ if (parsed && typeof parsed.host === "string") {
87
+ binding = parsed;
88
+ }
89
+ } catch { /* nothing persisted */ }
90
+ }
91
+
92
+ function stripAnsiBasic(s: string): string {
93
+ return s.replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, "").replace(/\x1b\][^\x07]*\x07/g, "");
94
+ }
95
+
96
+ function snippet(localIid: string): string {
97
+ // Bash branch uses an `armed` flag (bash-preexec.sh pattern) so the
98
+ // DEBUG trap only fires for user commands, not for commands inside
99
+ // PROMPT_COMMAND itself. zsh uses native preexec/precmd hooks.
100
+ return [
101
+ `if [ -n "$BASH_VERSION" ]; then`,
102
+ ` __vt_armed=0`,
103
+ ' __vt_orig_pc="${PROMPT_COMMAND:-}"',
104
+ ` __vt_preexec(){ [[ $__vt_armed -eq 1 ]] || return; __vt_armed=0; printf '\\033]9997;id=${localIid};%s\\007' "$BASH_COMMAND"; }`,
105
+ ` __vt_precmd(){ printf '\\033]9999;id=${localIid};PROMPT\\007'; [ -z "$__vt_orig_pc" ] || eval "$__vt_orig_pc"; }`,
106
+ ` PROMPT_COMMAND='__vt_precmd; __vt_armed=1'`,
107
+ ` trap '__vt_preexec' DEBUG`,
108
+ ` bind -m emacs '"\\e[9999~":redraw-current-line' 2>/dev/null`,
109
+ ` bind -m vi-insert '"\\e[9999~":redraw-current-line' 2>/dev/null`,
110
+ ` bind -m vi-command '"\\e[9999~":redraw-current-line' 2>/dev/null`,
111
+ `elif [ -n "$ZSH_VERSION" ]; then`,
112
+ ` preexec(){ printf '\\033]9997;id=${localIid};%s\\007' "$1"; }`,
113
+ ` precmd(){ printf '\\033]9999;id=${localIid};PROMPT\\007'; }`,
114
+ ` __vt_redraw(){ zle reset-prompt; }; zle -N __vt_redraw; bindkey '\\e[9999~' __vt_redraw`,
115
+ `fi`,
116
+ `printf '\\033]9996;vt=1;BIND;%s;%s\\007' "$(hostname)" "$PWD"`,
117
+ ].join("\n");
118
+ }
119
+
120
+ function renderInjection(): string {
121
+ if (!binding) return "";
122
+ const lines: string[] = [];
123
+ lines.push(`# Tunnel-vision active`);
124
+ lines.push(``);
125
+ lines.push(`Observing remote shell on **${binding.host}** (cwd: \`${binding.cwd}\`).`);
126
+ if (ring.length > 0) {
127
+ lines.push(``);
128
+ lines.push(`Recent commands the user ran there:`);
129
+ for (const e of ring) {
130
+ const outFirst = e.output.split("\n").slice(0, 3).map(l => ` ${l}`).join("\n");
131
+ lines.push(` $ ${e.command}`);
132
+ if (outFirst) lines.push(outFirst);
133
+ }
134
+ }
135
+ lines.push(``);
136
+ lines.push(`Run a command there with the \`pty_send\` tool. Stay aware: bytes you send appear in the user's terminal as if typed.`);
137
+ return lines.join("\n");
138
+ }
139
+
140
+ export default function activate(ctx: ExtensionContext): void {
141
+ bindingFile = path.join(ctx.getStoragePath("tunnel-vision"), "binding.json");
142
+ loadBinding();
143
+
144
+ ctx.bus.on("shell:pty-data", ({ raw }) => {
145
+ lastPtyDataAt = Date.now();
146
+ if (pendingExec) {
147
+ pendingExec.buffer += raw;
148
+ if (pendingExec.buffer.length > PARTIAL_TAIL_BYTES * 4) {
149
+ pendingExec.buffer = pendingExec.buffer.slice(-PARTIAL_TAIL_BYTES * 4);
150
+ }
151
+ }
152
+ const m = BIND_RE.exec(raw);
153
+ if (m) {
154
+ binding = {
155
+ host: m[2] || "unknown",
156
+ cwd: m[3] || "",
157
+ startedAt: Date.now(),
158
+ };
159
+ persistBinding();
160
+ }
161
+
162
+ // Auto-teardown on ssh exit: while ssh runs, the local outer shell is
163
+ // paused — no local OSC 7 emissions. When ssh exits, the outer shell
164
+ // unblocks and its rcfile precmd emits OSC 7 with the local cwd. The
165
+ // OutputParser's cwd-change event is gated by an equality check (cwd
166
+ // before/after ssh is the same), so we detect raw OSC 7 here. Remote
167
+ // shells that emit OSC 7 carry a remote path → won't match local cwd
168
+ // → no false trigger.
169
+ if (binding) {
170
+ const o7 = OSC7_RE.exec(raw);
171
+ if (o7) {
172
+ try {
173
+ const sawPath = decodeURIComponent(o7[1]);
174
+ if (sawPath === (ctx.call("cwd") as string)) {
175
+ binding = null;
176
+ ring.length = 0;
177
+ persistBinding();
178
+ if (pendingExec) {
179
+ clearTimeout(pendingExec.timer);
180
+ pendingExec.resolve("[tunnel-vision: ssh session ended]");
181
+ pendingExec = null;
182
+ }
183
+ }
184
+ } catch { /* malformed OSC 7 — ignore */ }
185
+ }
186
+ }
187
+ });
188
+
189
+ // While bound, every shell:command-done is a remote command finishing
190
+ // (local outer prompts won't fire while ssh is running).
191
+ ctx.bus.on("shell:command-done", ({ command, output, cwd }) => {
192
+ if (!binding) return;
193
+ if (!command) return;
194
+ pushRing({ command, output, cwd: cwd || binding.cwd, ts: Date.now() });
195
+ if (pendingExec) {
196
+ clearTimeout(pendingExec.timer);
197
+ pendingExec.resolve(output || "[no output]");
198
+ pendingExec = null;
199
+ }
200
+ });
201
+
202
+ ctx.registerCommand("/tunnel-end", "Clear the active tunnel-vision binding", () => {
203
+ if (!binding) {
204
+ ctx.bus.emit("ui:info", { message: "No active tunnel-vision binding." });
205
+ return;
206
+ }
207
+ const host = binding.host;
208
+ binding = null;
209
+ ring.length = 0;
210
+ persistBinding();
211
+ if (pendingExec) {
212
+ clearTimeout(pendingExec.timer);
213
+ pendingExec.resolve("[tunnel-vision: binding cleared]");
214
+ pendingExec = null;
215
+ }
216
+ ctx.bus.emit("ui:info", { message: `Tunnel-vision binding to ${host} cleared.` });
217
+ });
218
+
219
+ ctx.registerInstruction("tunnel-vision",
220
+ `# Tunnel-vision — driving a remote shell through pty_send
221
+
222
+ When tunnel-vision is active (you'll see a "Tunnel-vision active" block in
223
+ context), the remote machine has no agent-sh — your only handle is
224
+ \`pty_send\`, which types into the user's interactive ssh session. The
225
+ PTY is serial (one command at a time, chain with \`&&\`); the user sees
226
+ every byte you send. Built-in tools like \`read_file\`, \`edit_file\`,
227
+ \`grep\`, \`glob\` operate on **your local filesystem**, not the remote —
228
+ do not use them to touch remote paths.
229
+
230
+ ## Editing files on the remote — pattern ladder
231
+
232
+ Pick the lightest pattern that fits. Each step up costs more typing or
233
+ more bytes through the PTY.
234
+
235
+ 1. **Pattern edits — \`sed -i\`.** Best for one-line swaps, config
236
+ toggles, version bumps. Atomic, no payload concerns.
237
+ \`pty_send({command: "sed -i 's/old/new/g' /path/file"})\`
238
+
239
+ *Portability gotcha:* GNU sed (Linux) accepts \`sed -i\` with no
240
+ argument. BSD sed (macOS) requires an empty backup-suffix arg:
241
+ \`sed -i '' 's/old/new/g' file\`. If you don't know the OS, check
242
+ first with \`uname\` or use the portable form
243
+ \`sed -i.bak '...' file && rm file.bak\` which works on both.
244
+
245
+ 2. **Small whole-file writes — here-doc.** Best for creating or
246
+ rewriting files under ~2 KB. Use a single-quoted delimiter so
247
+ \`$vars\` and backticks aren't expanded.
248
+ \`pty_send({command: "cat > /tmp/x.sh <<'ASH_EOF'\\n#!/bin/bash\\necho hi\\nASH_EOF"})\`
249
+
250
+ 3. **Structural edits — diff + patch.** Closest to local \`edit_file\`.
251
+ Build the new version locally, \`diff -u old new > /tmp/p.patch\`,
252
+ \`scp /tmp/p.patch user@host:/tmp/\`, then
253
+ \`pty_send({command: "patch /path/file < /tmp/p.patch"})\`. \`patch\`
254
+ fails loudly if the target drifted — that's good.
255
+
256
+ 4. **Binaries or anything large — move bytes, don't type them.**
257
+ \`scp\`, \`rsync\`, or \`curl\` from a known source. PTY typing is a
258
+ last resort: payload caps, escape hazards, slow.
259
+
260
+ 5. **Editor-driven (\`vim\`, \`nano\`).** Avoid. Brittle to any
261
+ unexpected prompt, paging, or autocomplete. Reserve for appliance
262
+ CLIs that don't offer scripting.
263
+
264
+ ## Reading remote state
265
+
266
+ \`pty_send({command: "cat /path"})\`, \`ls -la\`, \`grep -r ...\`, etc.
267
+ Output comes back captured. **For files > a few hundred lines, default to
268
+ \`head -50\` / \`tail -50\` / \`sed -n '100,150p'\` / \`grep\` to bound
269
+ output.** Dumping a 5000-line log through the PTY is slow and floods
270
+ your context. If you genuinely need the whole file, scp it to local
271
+ first and then \`read_file\` it.
272
+
273
+ ## Chain aggressively
274
+
275
+ The PTY is serial — each \`pty_send\` is a roundtrip. Bundle related
276
+ operations into one call: \`pwd && ls -la && cat README.md && hostname\`.
277
+ Pre-plan 3-5 step explorations rather than sending one command at a
278
+ time. If you genuinely need to react to output before the next step,
279
+ use one call per step but keep each call doing as much as it can.
280
+
281
+ ## Interrupting
282
+
283
+ If a remote command runs long and you need to abort, send Ctrl-C:
284
+ \`pty_send({command: "\\x03", force: true})\`. \`force: true\` is
285
+ fire-and-forget — it bypasses both the serial mutex and the idle gate,
286
+ so it works even when your own previous \`pty_send\` is still pending.
287
+ The original pending call resolves naturally when the remote prompt
288
+ returns after the interrupt. \`\\x04\` (Ctrl-D / EOF) follows the same
289
+ pattern.
290
+
291
+ Do not use \`force: true\` for normal commands — they need the
292
+ prompt-wait to capture output.`
293
+ );
294
+
295
+ ctx.registerCommand("/tunnel-snippet", "Print the tunnel-vision snippet to paste on a remote shell", () => {
296
+ const s = snippet(ctx.instanceId);
297
+ ctx.bus.emit("ui:info", {
298
+ message:
299
+ `Paste this on the remote shell after sshing in (works for bash and zsh):\n\n${s}\n\n` +
300
+ `Once pasted, every command you run there is observable to your local agent, ` +
301
+ `and \`>\` will enter agent mode at remote prompts.`,
302
+ });
303
+ });
304
+
305
+ ctx.registerTool({
306
+ name: "pty_send",
307
+ description:
308
+ "Run a command in the user's interactive remote shell session (tunnel-vision). " +
309
+ "Only available while a remote binding is active (user has sshed in and pasted " +
310
+ "the tunnel-vision snippet). The command is written to the PTY and is visible " +
311
+ "in the user's terminal as if typed. Use sparingly and announce intent first. " +
312
+ "\n\nSERIAL ONLY — DO NOT call pty_send in parallel. The remote shell is a single " +
313
+ "interactive PTY; only one command can run at a time. Concurrent calls fail " +
314
+ "immediately. Chain instead in ONE call using `&&`, `;`, or `|` — e.g. " +
315
+ "`pty_send({command: \"pwd && ls -la && hostname\"})`." +
316
+ "\n\nIDLE GATE — between agent commands, pty_send refuses to write if the " +
317
+ "PTY has been active in the last 500ms (user is typing, or a non-agent " +
318
+ "command is finishing). Wait and retry." +
319
+ "\n\nFORCE MODE — `force: true` is a fire-and-forget bypass. It skips both " +
320
+ "the mutex and the idle gate, writes bytes immediately, and returns without " +
321
+ "waiting for a prompt. Use it ONLY for control bytes that interrupt or signal:" +
322
+ "\n - Ctrl-C: `pty_send({command: \"\\x03\", force: true})` — kill a runaway " +
323
+ "command (works even when your own previous pty_send is still pending)" +
324
+ "\n - Ctrl-D: `pty_send({command: \"\\x04\", force: true})` — send EOF" +
325
+ "\nDo NOT use force for normal commands — they need the prompt-wait to capture " +
326
+ "output.",
327
+ input_schema: {
328
+ type: "object",
329
+ properties: {
330
+ command: {
331
+ type: "string",
332
+ description: "Shell command to run on the remote host. Single line; no embedded newlines.",
333
+ },
334
+ force: {
335
+ type: "boolean",
336
+ description: "Bypass the idle gate. Use only for sending control characters (Ctrl-C) to interrupt a running command.",
337
+ },
338
+ },
339
+ required: ["command"],
340
+ },
341
+ formatCall: (args) => {
342
+ const cmd = typeof args.command === "string" ? args.command : "";
343
+ return `pty_send: ${cmd.slice(0, 80)}`;
344
+ },
345
+ getDisplayInfo: () => ({ kind: "execute" as const }),
346
+ execute: async (args) => {
347
+ if (!binding) {
348
+ return { content: "No tunnel-vision binding active. User must paste the /tunnel-snippet on a remote shell first.", exitCode: 1, isError: true };
349
+ }
350
+ const force = args.force === true;
351
+ const command = typeof args.command === "string" ? args.command : "";
352
+ if (!command) {
353
+ return { content: "Provide a command.", exitCode: 1, isError: true };
354
+ }
355
+
356
+ if (force) {
357
+ ctx.bus.emit("shell:pty-write", { data: command });
358
+ const note = pendingExec
359
+ ? "Existing pending command will resolve when remote prompt returns."
360
+ : "No pending command — bytes sent without waiting for prompt.";
361
+ return { content: `[pty_send: force-sent ${command.length} byte(s) without \\r. ${note}]`, exitCode: 0, isError: false };
362
+ }
363
+
364
+ if (pendingExec) {
365
+ return { content: "Remote shell is busy with another pty_send. To interrupt, call pty_send with force:true and a control byte (e.g., \"\\x03\" for Ctrl-C).", exitCode: 1, isError: true };
366
+ }
367
+ const idleFor = Date.now() - lastPtyDataAt;
368
+ if (idleFor < IDLE_MS) {
369
+ return {
370
+ content: `[pty_send: PTY active ${idleFor}ms ago (< ${IDLE_MS}ms idle gate). User is likely typing or a non-agent command is finishing. Wait and retry.]`,
371
+ exitCode: 1,
372
+ isError: true,
373
+ };
374
+ }
375
+
376
+ return new Promise((resolve) => {
377
+ const timer = setTimeout(() => {
378
+ if (pendingExec) {
379
+ const buf = pendingExec.buffer;
380
+ pendingExec = null;
381
+ const tail = stripAnsiBasic(buf.slice(-PARTIAL_TAIL_BYTES)).trim();
382
+ const partial = tail
383
+ ? `\n\nPartial PTY activity captured (last ~${Math.min(tail.length, PARTIAL_TAIL_BYTES)} bytes, ANSI stripped):\n${tail}`
384
+ : "\n\nNo PTY activity since the command was sent — likely stuck (e.g., shell waiting on heredoc continuation, or the command produced no output and is still running).";
385
+ resolve({
386
+ content: `[pty_send: timed out after 60s waiting for remote prompt]${partial}`,
387
+ exitCode: 1,
388
+ isError: true,
389
+ });
390
+ }
391
+ }, 60_000);
392
+ pendingExec = {
393
+ timer,
394
+ buffer: "",
395
+ resolve: (output) => resolve({ content: output || "[no output]", exitCode: 0, isError: false }),
396
+ };
397
+ ctx.bus.emit("shell:pty-write", { data: command + "\r" });
398
+ });
399
+ },
400
+ });
401
+
402
+ ctx.registerContextProducer("tunnel-vision", () =>
403
+ binding ? renderInjection() : null,
404
+ );
405
+ }
@@ -4,9 +4,7 @@
4
4
  * Provides two tools:
5
5
  * - web_search: Search the web via Exa MCP (free, no API key)
6
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)
7
+ * Fallback chain: Jina Reader → direct fetch
10
8
  *
11
9
  * Optional configuration (~/.agent-sh/settings.json):
12
10
  * {
@@ -24,9 +22,6 @@ import type { ExtensionContext } from "agent-sh/types";
24
22
 
25
23
  const EXA_MCP_URL = "https://mcp.exa.ai/mcp";
26
24
 
27
- const ZAI_BASE = "https://api.z.ai";
28
- const ZAI_READER_PATH = "/api/mcp/web_reader/mcp";
29
-
30
25
  const JINA_READER_URL = "https://r.jina.ai";
31
26
 
32
27
  // ── Exa MCP search (free, no key, no session) ───────────────────────
@@ -97,97 +92,6 @@ async function exaSearch(
97
92
  return text;
98
93
  }
99
94
 
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
95
  // ── Jina Reader (free, no key) ───────────────────────────────────────
192
96
 
193
97
  async function jinaRead(url: string, timeout: number): Promise<string> {
@@ -220,8 +124,6 @@ async function directFetch(url: string, timeout: number): Promise<string> {
220
124
  // ── Extension entry point ────────────────────────────────────────────
221
125
 
222
126
  export default function activate(ctx: ExtensionContext) {
223
- const apiKey = process.env.ZAI_API_KEY ?? "";
224
-
225
127
  const config = ctx.getExtensionSettings("web-access", {
226
128
  timeout: 30000,
227
129
  searchNumResults: 5,
@@ -282,7 +184,7 @@ export default function activate(ctx: ExtensionContext) {
282
184
  description:
283
185
  "Fetch a URL and extract its content as clean markdown. " +
284
186
  "Handles web pages, articles, and documentation. " +
285
- "Uses Z.AI reader (best quality), Jina Reader, or direct fetch as fallback.",
187
+ "Uses Jina Reader, with direct fetch as fallback.",
286
188
  input_schema: {
287
189
  type: "object" as const,
288
190
  properties: {
@@ -308,14 +210,7 @@ export default function activate(ctx: ExtensionContext) {
308
210
  }
309
211
  }
310
212
 
311
- // Fallback chain: Z.AI reader → Jina Reader → direct fetch
312
- if (apiKey) {
313
- try {
314
- const content = await zaiRead(apiKey, args.url, timeout);
315
- return { content, exitCode: 0, isError: false };
316
- } catch { /* fall through */ }
317
- }
318
-
213
+ // Fallback chain: Jina Reader → direct fetch
319
214
  try {
320
215
  const content = await jinaRead(args.url, timeout);
321
216
  return { content, exitCode: 0, isError: false };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-sh",
3
- "version": "0.12.26",
3
+ "version": "0.12.27",
4
4
  "description": "A shell-first terminal where AI is one keystroke away",
5
5
  "type": "module",
6
6
  "main": "dist/core.js",