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.
- package/dist/agent/agent-loop.js +2 -2
- package/dist/agent/conversation-state.js +22 -1
- package/dist/install.js +84 -4
- package/dist/utils/floating-panel.d.ts +16 -4
- package/dist/utils/floating-panel.js +209 -66
- package/examples/extensions/emacs-buffer.ts +364 -0
- package/examples/extensions/overlay-agent.ts +28 -5
- package/examples/extensions/terminal-buffer.ts +174 -33
- package/examples/extensions/tunnel-vision.ts +405 -0
- package/examples/extensions/web-access.ts +3 -108
- package/package.json +1 -1
|
@@ -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:
|
|
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
|
|
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:
|
|
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 };
|