agent-sh 0.12.10 → 0.12.11
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 +6 -2
- package/dist/event-bus.d.ts +4 -1
- package/dist/extensions/slash-commands.js +15 -9
- package/dist/init.js +1 -3
- package/examples/extensions/ollama.ts +96 -0
- package/examples/extensions/peer-mesh.ts +260 -248
- package/examples/extensions/rtk-proxy.ts +143 -0
- package/package.json +1 -1
- package/examples/extensions/openrouter.ts +0 -87
package/dist/agent/agent-loop.js
CHANGED
|
@@ -226,7 +226,10 @@ export class AgentLoop {
|
|
|
226
226
|
this.abortController?.abort(e.silent ? "silent" : undefined);
|
|
227
227
|
});
|
|
228
228
|
on("config:switch-model", ({ model: target }) => {
|
|
229
|
-
const
|
|
229
|
+
const atIdx = target.lastIndexOf("@");
|
|
230
|
+
const modelId = atIdx > 0 ? target.slice(0, atIdx) : target;
|
|
231
|
+
const providerHint = atIdx > 0 ? target.slice(atIdx + 1) : undefined;
|
|
232
|
+
const idx = this.modes.findIndex((m) => m.model === modelId && (!providerHint || m.provider === providerHint));
|
|
230
233
|
if (idx === -1) {
|
|
231
234
|
this.bus.emit("ui:error", { message: `Unknown model: ${target}` });
|
|
232
235
|
return;
|
|
@@ -259,7 +262,8 @@ export class AgentLoop {
|
|
|
259
262
|
});
|
|
260
263
|
this.bus.onPipe("config:get-models", (payload) => {
|
|
261
264
|
const models = this.modes.map((m) => ({ model: m.model, provider: m.provider ?? "" }));
|
|
262
|
-
const
|
|
265
|
+
const cur = this.modes[this.currentModeIndex];
|
|
266
|
+
const active = cur ? { model: cur.model, provider: cur.provider ?? "" } : null;
|
|
263
267
|
return { models, active };
|
|
264
268
|
});
|
|
265
269
|
on("config:set-thinking", ({ level }) => {
|
package/dist/event-bus.d.ts
CHANGED
|
@@ -37,11 +37,10 @@ export default function activate(ctx) {
|
|
|
37
37
|
handler: (args) => {
|
|
38
38
|
const name = args.trim();
|
|
39
39
|
if (!name) {
|
|
40
|
-
const {
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
: active ?? "none";
|
|
40
|
+
const { active } = bus.emitPipe("config:get-models", { models: [], active: null });
|
|
41
|
+
const label = active
|
|
42
|
+
? `${active.model}${active.provider ? ` [${active.provider}]` : ""}`
|
|
43
|
+
: "none";
|
|
45
44
|
bus.emit("ui:info", { message: `Model: ${label}` });
|
|
46
45
|
}
|
|
47
46
|
else {
|
|
@@ -180,13 +179,20 @@ export default function activate(ctx) {
|
|
|
180
179
|
return payload;
|
|
181
180
|
const partial = (payload.commandArgs ?? "").toLowerCase();
|
|
182
181
|
const { models, active } = bus.emitPipe("config:get-models", { models: [], active: null });
|
|
182
|
+
const counts = new Map();
|
|
183
|
+
for (const m of models)
|
|
184
|
+
counts.set(m.model, (counts.get(m.model) ?? 0) + 1);
|
|
183
185
|
const items = models
|
|
184
186
|
.filter((m) => m.model.toLowerCase().includes(partial))
|
|
185
187
|
.slice(0, 15)
|
|
186
|
-
.map((m) =>
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
188
|
+
.map((m) => {
|
|
189
|
+
const ambiguous = (counts.get(m.model) ?? 0) > 1 && m.provider;
|
|
190
|
+
const qualified = ambiguous ? `${m.model}@${m.provider}` : m.model;
|
|
191
|
+
return {
|
|
192
|
+
name: `/model ${qualified}`,
|
|
193
|
+
description: `${m.provider ? `[${m.provider}]` : ""}${active && m.model === active.model && m.provider === active.provider ? " (active)" : ""}`,
|
|
194
|
+
};
|
|
195
|
+
});
|
|
190
196
|
if (items.length === 0)
|
|
191
197
|
return payload;
|
|
192
198
|
return { ...payload, items: [...payload.items, ...items] };
|
package/dist/init.js
CHANGED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ollama provider extension — local daemon and Ollama Cloud.
|
|
3
|
+
*
|
|
4
|
+
* OLLAMA_API_KEY → Ollama Cloud (https://ollama.com)
|
|
5
|
+
* OLLAMA_HOST → local host override (default http://localhost:11434)
|
|
6
|
+
*
|
|
7
|
+
* Catalog comes from /api/tags; per-model context length is fetched
|
|
8
|
+
* from /api/show (model_info["${arch}.context_length"]). Chat goes
|
|
9
|
+
* through the OpenAI-compatible /v1/chat/completions shim.
|
|
10
|
+
*
|
|
11
|
+
* Setup (cloud):
|
|
12
|
+
* export OLLAMA_API_KEY="your-key"
|
|
13
|
+
*
|
|
14
|
+
* Setup (local):
|
|
15
|
+
* ollama serve # default http://localhost:11434
|
|
16
|
+
*
|
|
17
|
+
* Usage:
|
|
18
|
+
* agent-sh -e ./examples/extensions/ollama.ts
|
|
19
|
+
*
|
|
20
|
+
* # Or add to settings.json:
|
|
21
|
+
* { "extensions": ["./examples/extensions/ollama.ts"] }
|
|
22
|
+
*/
|
|
23
|
+
import type { ExtensionContext } from "agent-sh/types";
|
|
24
|
+
|
|
25
|
+
const ECHO_REASONING_PATTERNS: RegExp[] = [/deepseek/i];
|
|
26
|
+
|
|
27
|
+
export default function activate(ctx: ExtensionContext): void {
|
|
28
|
+
const apiKey = process.env.OLLAMA_API_KEY;
|
|
29
|
+
const host = apiKey
|
|
30
|
+
? "https://ollama.com"
|
|
31
|
+
: (process.env.OLLAMA_HOST ?? "http://localhost:11434").replace(/\/$/, "");
|
|
32
|
+
const id = apiKey ? "ollama-cloud" : "ollama";
|
|
33
|
+
|
|
34
|
+
// OpenAI SDK rejects an empty apiKey; the local daemon ignores the value.
|
|
35
|
+
const sdkKey = apiKey || "no-key";
|
|
36
|
+
const baseURL = `${host}/v1`;
|
|
37
|
+
const headers: Record<string, string> = {};
|
|
38
|
+
if (apiKey) headers.Authorization = `Bearer ${apiKey}`;
|
|
39
|
+
|
|
40
|
+
ctx.bus.emit("provider:register", { id, apiKey: sdkKey, baseURL, models: [] });
|
|
41
|
+
|
|
42
|
+
fetchCatalog(host, headers).then((models) => {
|
|
43
|
+
if (models.length === 0) return;
|
|
44
|
+
ctx.bus.emit("provider:register", {
|
|
45
|
+
id,
|
|
46
|
+
apiKey: sdkKey,
|
|
47
|
+
baseURL,
|
|
48
|
+
defaultModel: models[0]!.id,
|
|
49
|
+
models,
|
|
50
|
+
});
|
|
51
|
+
}).catch(() => { /* leave empty — user supplies via --model */ });
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function fetchCatalog(
|
|
55
|
+
host: string,
|
|
56
|
+
headers: Record<string, string>,
|
|
57
|
+
): Promise<{ id: string; contextWindow?: number; echoReasoning: boolean }[]> {
|
|
58
|
+
const tagsRes = await fetch(`${host}/api/tags`, { headers });
|
|
59
|
+
if (!tagsRes.ok) return [];
|
|
60
|
+
const tagsData = await tagsRes.json() as { models?: { name: string }[] };
|
|
61
|
+
const names = (tagsData.models ?? []).map((m) => m.name);
|
|
62
|
+
if (names.length === 0) return [];
|
|
63
|
+
|
|
64
|
+
const ctxs = await Promise.all(
|
|
65
|
+
names.map((name) => fetchContextLength(host, headers, name).catch(() => undefined)),
|
|
66
|
+
);
|
|
67
|
+
return names.map((name, i) => ({
|
|
68
|
+
id: name,
|
|
69
|
+
contextWindow: ctxs[i],
|
|
70
|
+
echoReasoning: ECHO_REASONING_PATTERNS.some((re) => re.test(name)),
|
|
71
|
+
}));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function fetchContextLength(
|
|
75
|
+
host: string,
|
|
76
|
+
headers: Record<string, string>,
|
|
77
|
+
name: string,
|
|
78
|
+
): Promise<number | undefined> {
|
|
79
|
+
const res = await fetch(`${host}/api/show`, {
|
|
80
|
+
method: "POST",
|
|
81
|
+
headers: { ...headers, "Content-Type": "application/json" },
|
|
82
|
+
body: JSON.stringify({ name }),
|
|
83
|
+
});
|
|
84
|
+
if (!res.ok) return undefined;
|
|
85
|
+
const data = await res.json() as { model_info?: Record<string, unknown> };
|
|
86
|
+
const info = data.model_info ?? {};
|
|
87
|
+
const arch = info["general.architecture"] as string | undefined;
|
|
88
|
+
if (arch) {
|
|
89
|
+
const ctx = info[`${arch}.context_length`];
|
|
90
|
+
if (typeof ctx === "number") return ctx;
|
|
91
|
+
}
|
|
92
|
+
for (const [k, v] of Object.entries(info)) {
|
|
93
|
+
if (k.endsWith(".context_length") && typeof v === "number") return v;
|
|
94
|
+
}
|
|
95
|
+
return undefined;
|
|
96
|
+
}
|
|
@@ -1,27 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Peer mesh — cross-instance
|
|
2
|
+
* Peer mesh — cross-instance discovery + RPC over Unix sockets.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
* What this extension provides:
|
|
9
|
-
* 1. PeerServer — Unix socket server + peer file registry + client
|
|
10
|
-
* 2. Standard exposed handlers — terminal read, context, search
|
|
11
|
-
* 3. Agent tools — peers, peer_terminal, peer_history, peer_search
|
|
12
|
-
* 4. Handler registry API — peer:call, peer:discover, peer:expose
|
|
13
|
-
* for other extensions to use
|
|
4
|
+
* Each running ash exposes a small set of named handlers; tools let the
|
|
5
|
+
* agent enumerate peers, read another peer's terminal, drive its keys,
|
|
6
|
+
* send messages, and ask synchronous questions.
|
|
14
7
|
*
|
|
15
8
|
* Usage:
|
|
16
9
|
* ash -e ./examples/extensions/peer-mesh.ts
|
|
17
|
-
*
|
|
18
|
-
* # Or install permanently
|
|
19
10
|
* cp examples/extensions/peer-mesh.ts ~/.agent-sh/extensions/
|
|
20
|
-
*
|
|
21
|
-
* Other extensions can depend on this via the handler registry:
|
|
22
|
-
* ctx.define("my:data", () => computeData());
|
|
23
|
-
* ctx.call("peer:expose", "my:data");
|
|
24
|
-
* const result = await ctx.call("peer:call", peerId, "my:data");
|
|
25
11
|
*/
|
|
26
12
|
import * as fs from "node:fs";
|
|
27
13
|
import * as net from "node:net";
|
|
@@ -29,8 +15,6 @@ import * as os from "node:os";
|
|
|
29
15
|
import * as path from "node:path";
|
|
30
16
|
import type { ExtensionContext } from "agent-sh/types";
|
|
31
17
|
|
|
32
|
-
// ── Types ──────────────────────────────────────────────────────
|
|
33
|
-
|
|
34
18
|
interface PeerInfo {
|
|
35
19
|
id: string;
|
|
36
20
|
pid: number;
|
|
@@ -39,20 +23,19 @@ interface PeerInfo {
|
|
|
39
23
|
startTime: number;
|
|
40
24
|
}
|
|
41
25
|
|
|
42
|
-
interface RpcRequest {
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
}
|
|
46
|
-
|
|
47
|
-
interface RpcResponse {
|
|
48
|
-
ok: boolean;
|
|
49
|
-
result?: unknown;
|
|
50
|
-
error?: string;
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
// ── Paths ──────────────────────────────────────────────────────
|
|
26
|
+
interface RpcRequest { method: string; args: unknown[]; }
|
|
27
|
+
interface RpcResponse { ok: boolean; result?: unknown; error?: string; }
|
|
28
|
+
interface InboxEntry { from: string; text: string; at: number; }
|
|
54
29
|
|
|
55
30
|
const PEERS_DIR = path.join(os.homedir(), ".agent-sh", "peers");
|
|
31
|
+
const MAX_SEND_BYTES = 2048;
|
|
32
|
+
const DEFAULT_TIMEOUT_MS = 5_000;
|
|
33
|
+
const ASK_TIMEOUT_MS = 120_000;
|
|
34
|
+
const ASK_QUEUE_MAX = 3;
|
|
35
|
+
const SETTLE_MS = 400;
|
|
36
|
+
const IDLE_GUARD_MS = 500;
|
|
37
|
+
const INBOX_MAX = 100;
|
|
38
|
+
const LONG_TIMEOUT_METHODS = new Set<string>(["peer:ask"]);
|
|
56
39
|
|
|
57
40
|
function peerFilePath(id: string): string {
|
|
58
41
|
return path.join(PEERS_DIR, `${id}.json`);
|
|
@@ -62,7 +45,18 @@ function socketPath(pid: number): string {
|
|
|
62
45
|
return path.join(os.tmpdir(), `agent-sh-peer-${pid}.sock`);
|
|
63
46
|
}
|
|
64
47
|
|
|
65
|
-
//
|
|
48
|
+
// Expand backslash escapes so callers can send Enter / Ctrl-keys via JSON.
|
|
49
|
+
function interpretEscapes(s: string): string {
|
|
50
|
+
return s.replace(/\\(x[0-9a-fA-F]{2}|r|n|t|\\|0)/g, (_, seq: string) => {
|
|
51
|
+
if (seq === "r") return "\r";
|
|
52
|
+
if (seq === "n") return "\n";
|
|
53
|
+
if (seq === "t") return "\t";
|
|
54
|
+
if (seq === "\\") return "\\";
|
|
55
|
+
if (seq === "0") return "\0";
|
|
56
|
+
if (seq.startsWith("x")) return String.fromCharCode(parseInt(seq.slice(1), 16));
|
|
57
|
+
return seq;
|
|
58
|
+
});
|
|
59
|
+
}
|
|
66
60
|
|
|
67
61
|
class PeerServer {
|
|
68
62
|
private server: net.Server | null = null;
|
|
@@ -85,24 +79,13 @@ class PeerServer {
|
|
|
85
79
|
};
|
|
86
80
|
}
|
|
87
81
|
|
|
88
|
-
// ── Lifecycle ──────────────────────────────────────────────
|
|
89
|
-
|
|
90
82
|
start(): void {
|
|
91
|
-
// Ensure peers directory exists
|
|
92
83
|
fs.mkdirSync(PEERS_DIR, { recursive: true });
|
|
93
|
-
|
|
94
|
-
// Clean up stale socket
|
|
95
84
|
try { fs.unlinkSync(this.info.socketPath); } catch {}
|
|
96
|
-
|
|
97
|
-
// Start Unix socket server
|
|
98
85
|
this.server = net.createServer((conn) => this.handleConnection(conn));
|
|
99
|
-
this.server.on("error", () => {});
|
|
86
|
+
this.server.on("error", () => {});
|
|
100
87
|
this.server.listen(this.info.socketPath);
|
|
101
|
-
|
|
102
|
-
// Register peer file
|
|
103
88
|
fs.writeFileSync(peerFilePath(this.info.id), JSON.stringify(this.info));
|
|
104
|
-
|
|
105
|
-
// Cleanup on exit
|
|
106
89
|
const cleanup = () => this.stop();
|
|
107
90
|
process.on("exit", cleanup);
|
|
108
91
|
process.on("SIGTERM", () => { cleanup(); process.exit(0); });
|
|
@@ -110,89 +93,68 @@ class PeerServer {
|
|
|
110
93
|
}
|
|
111
94
|
|
|
112
95
|
stop(): void {
|
|
113
|
-
if (this.server) {
|
|
114
|
-
try { this.server.close(); } catch {}
|
|
115
|
-
this.server = null;
|
|
116
|
-
}
|
|
96
|
+
if (this.server) { try { this.server.close(); } catch {} this.server = null; }
|
|
117
97
|
try { fs.unlinkSync(this.info.socketPath); } catch {}
|
|
118
98
|
try { fs.unlinkSync(peerFilePath(this.info.id)); } catch {}
|
|
119
99
|
}
|
|
120
100
|
|
|
121
|
-
|
|
101
|
+
expose(name: string): void { this.exposed.add(name); }
|
|
122
102
|
|
|
123
|
-
|
|
124
|
-
this.
|
|
103
|
+
updateCwd(cwd: string): void {
|
|
104
|
+
this.info.cwd = cwd;
|
|
105
|
+
try { fs.writeFileSync(peerFilePath(this.info.id), JSON.stringify(this.info)); } catch {}
|
|
125
106
|
}
|
|
126
107
|
|
|
127
108
|
discover(): PeerInfo[] {
|
|
128
109
|
const peers: PeerInfo[] = [];
|
|
129
110
|
let entries: string[];
|
|
130
111
|
try { entries = fs.readdirSync(PEERS_DIR); } catch { return []; }
|
|
131
|
-
|
|
132
112
|
for (const entry of entries) {
|
|
133
113
|
if (!entry.endsWith(".json")) continue;
|
|
134
114
|
try {
|
|
135
|
-
const
|
|
136
|
-
const info: PeerInfo = JSON.parse(raw);
|
|
137
|
-
// Skip self
|
|
115
|
+
const info: PeerInfo = JSON.parse(fs.readFileSync(path.join(PEERS_DIR, entry), "utf-8"));
|
|
138
116
|
if (info.id === this.info.id) continue;
|
|
139
|
-
// Check if process is alive
|
|
140
117
|
try { process.kill(info.pid, 0); } catch {
|
|
141
|
-
// Stale — prune
|
|
142
118
|
try { fs.unlinkSync(path.join(PEERS_DIR, entry)); } catch {}
|
|
143
119
|
continue;
|
|
144
120
|
}
|
|
145
121
|
peers.push(info);
|
|
146
|
-
} catch {
|
|
147
|
-
// Malformed file — skip
|
|
148
|
-
}
|
|
122
|
+
} catch { /* skip */ }
|
|
149
123
|
}
|
|
150
124
|
return peers;
|
|
151
125
|
}
|
|
152
126
|
|
|
153
127
|
async call(peerId: string, method: string, ...args: unknown[]): Promise<unknown> {
|
|
154
|
-
|
|
155
|
-
const peers = this.discover();
|
|
156
|
-
const peer = peers.find((p) => p.id === peerId);
|
|
128
|
+
const peer = this.discover().find((p) => p.id === peerId);
|
|
157
129
|
if (!peer) throw new Error(`Peer "${peerId}" not found`);
|
|
158
|
-
|
|
159
130
|
return this.callSocket(peer.socketPath, method, args);
|
|
160
131
|
}
|
|
161
132
|
|
|
162
|
-
// ── Private ────────────────────────────────────────────────
|
|
163
|
-
|
|
164
133
|
private handleConnection(conn: net.Socket): void {
|
|
165
134
|
let buffer = "";
|
|
166
|
-
conn.on("data", (data) => {
|
|
135
|
+
conn.on("data", async (data) => {
|
|
167
136
|
buffer += data.toString();
|
|
168
137
|
const newlineIdx = buffer.indexOf("\n");
|
|
169
138
|
if (newlineIdx === -1) return;
|
|
170
|
-
|
|
171
139
|
const line = buffer.slice(0, newlineIdx);
|
|
172
140
|
buffer = buffer.slice(newlineIdx + 1);
|
|
173
|
-
|
|
174
141
|
let response: RpcResponse;
|
|
175
142
|
try {
|
|
176
143
|
const req: RpcRequest = JSON.parse(line);
|
|
177
144
|
if (!this.exposed.has(req.method)) {
|
|
178
145
|
response = { ok: false, error: `Handler "${req.method}" is not exposed` };
|
|
179
146
|
} else {
|
|
180
|
-
const result = this.callHandler(req.method, ...(req.args ?? []));
|
|
147
|
+
const result = await this.callHandler(req.method, ...(req.args ?? []));
|
|
181
148
|
response = { ok: true, result };
|
|
182
149
|
}
|
|
183
150
|
} catch (e) {
|
|
184
151
|
response = { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
185
152
|
}
|
|
186
|
-
|
|
187
|
-
try {
|
|
188
|
-
conn.write(JSON.stringify(response) + "\n");
|
|
189
|
-
} catch {}
|
|
153
|
+
try { conn.write(JSON.stringify(response) + "\n"); } catch {}
|
|
190
154
|
conn.end();
|
|
191
155
|
});
|
|
192
|
-
|
|
193
156
|
conn.on("error", () => {});
|
|
194
|
-
|
|
195
|
-
conn.setTimeout(5000, () => conn.destroy());
|
|
157
|
+
conn.setTimeout(ASK_TIMEOUT_MS + 5_000, () => conn.destroy());
|
|
196
158
|
}
|
|
197
159
|
|
|
198
160
|
private callSocket(sockPath: string, method: string, args: unknown[]): Promise<unknown> {
|
|
@@ -200,44 +162,33 @@ class PeerServer {
|
|
|
200
162
|
const conn = net.createConnection(sockPath);
|
|
201
163
|
let buffer = "";
|
|
202
164
|
let settled = false;
|
|
203
|
-
|
|
204
|
-
const settle = (fn: () => void) => {
|
|
205
|
-
if (settled) return;
|
|
206
|
-
settled = true;
|
|
207
|
-
fn();
|
|
208
|
-
};
|
|
209
|
-
|
|
165
|
+
const settle = (fn: () => void) => { if (!settled) { settled = true; fn(); } };
|
|
210
166
|
conn.on("connect", () => {
|
|
211
167
|
const req: RpcRequest = { method, args };
|
|
212
168
|
conn.write(JSON.stringify(req) + "\n");
|
|
213
169
|
});
|
|
214
|
-
|
|
215
170
|
conn.on("data", (data) => {
|
|
216
171
|
buffer += data.toString();
|
|
217
172
|
const newlineIdx = buffer.indexOf("\n");
|
|
218
173
|
if (newlineIdx === -1) return;
|
|
219
|
-
|
|
220
|
-
const line = buffer.slice(0, newlineIdx);
|
|
221
174
|
try {
|
|
222
|
-
const resp: RpcResponse = JSON.parse(
|
|
175
|
+
const resp: RpcResponse = JSON.parse(buffer.slice(0, newlineIdx));
|
|
223
176
|
settle(() => resp.ok ? resolve(resp.result) : reject(new Error(resp.error)));
|
|
224
177
|
} catch (e) {
|
|
225
178
|
settle(() => reject(e));
|
|
226
179
|
}
|
|
227
180
|
conn.end();
|
|
228
181
|
});
|
|
229
|
-
|
|
230
182
|
conn.on("error", (e) => settle(() => reject(e)));
|
|
231
|
-
|
|
232
|
-
|
|
183
|
+
const timeout = LONG_TIMEOUT_METHODS.has(method) ? ASK_TIMEOUT_MS : DEFAULT_TIMEOUT_MS;
|
|
184
|
+
conn.setTimeout(timeout, () => settle(() => {
|
|
185
|
+
reject(new Error(`Peer call timed out after ${timeout}ms`));
|
|
233
186
|
conn.destroy();
|
|
234
187
|
}));
|
|
235
188
|
});
|
|
236
189
|
}
|
|
237
190
|
}
|
|
238
191
|
|
|
239
|
-
// ── Extension ──────────────────────────────────────────────────
|
|
240
|
-
|
|
241
192
|
export default function activate(ctx: ExtensionContext): void {
|
|
242
193
|
const { bus, contextManager, registerCommand, registerTool, registerInstruction, define } = ctx;
|
|
243
194
|
const startTime = Date.now();
|
|
@@ -245,7 +196,9 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
245
196
|
const server = new PeerServer(ctx.instanceId, contextManager.getCwd(), (...args) => ctx.call(...args));
|
|
246
197
|
server.start();
|
|
247
198
|
|
|
248
|
-
//
|
|
199
|
+
// Track PTY idle window so peer:terminal-send doesn't stomp on a busy shell.
|
|
200
|
+
let lastPtyOutputTs = startTime;
|
|
201
|
+
bus.on("shell:pty-data", () => { lastPtyOutputTs = Date.now(); });
|
|
249
202
|
|
|
250
203
|
define("peer:info", () => ({
|
|
251
204
|
id: ctx.instanceId,
|
|
@@ -262,69 +215,111 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
262
215
|
});
|
|
263
216
|
server.expose("peer:terminal-read");
|
|
264
217
|
|
|
218
|
+
define("peer:terminal-send", async (keys: string, requireIdleMs?: number, settleMs?: number) => {
|
|
219
|
+
if (typeof keys !== "string" || keys.length === 0) {
|
|
220
|
+
throw new Error("peer:terminal-send requires non-empty keys string");
|
|
221
|
+
}
|
|
222
|
+
if (Buffer.byteLength(keys, "utf-8") > MAX_SEND_BYTES) {
|
|
223
|
+
throw new Error(`keys payload exceeds ${MAX_SEND_BYTES} bytes`);
|
|
224
|
+
}
|
|
225
|
+
const threshold = typeof requireIdleMs === "number" ? requireIdleMs : IDLE_GUARD_MS;
|
|
226
|
+
const idleMs = Date.now() - lastPtyOutputTs;
|
|
227
|
+
if (idleMs < threshold) {
|
|
228
|
+
return { sent: false, reason: "not_idle", idle_ms: idleMs, required_ms: threshold };
|
|
229
|
+
}
|
|
230
|
+
bus.emit("shell:pty-write", { data: interpretEscapes(keys) });
|
|
231
|
+
await new Promise((r) => setTimeout(r, typeof settleMs === "number" ? settleMs : SETTLE_MS));
|
|
232
|
+
const tb = ctx.terminalBuffer;
|
|
233
|
+
return { sent: true, screen: tb ? tb.readScreen({ includeScrollback: false }) : null };
|
|
234
|
+
});
|
|
235
|
+
server.expose("peer:terminal-send");
|
|
236
|
+
|
|
265
237
|
define("peer:context-recent", (n: number = 15) => contextManager.getRecentSummary(n));
|
|
266
238
|
server.expose("peer:context-recent");
|
|
267
239
|
|
|
268
240
|
define("peer:context-search", (query: string) => contextManager.search(query));
|
|
269
241
|
server.expose("peer:context-search");
|
|
270
242
|
|
|
271
|
-
// ──
|
|
272
|
-
|
|
273
|
-
interface InboxEntry { from: string; text: string; at: number; }
|
|
243
|
+
// ── Inbox + drained turn ──────────────────────────────────────
|
|
274
244
|
const inbox: InboxEntry[] = [];
|
|
275
|
-
const INBOX_MAX = 100;
|
|
276
|
-
|
|
277
245
|
const pending: InboxEntry[] = [];
|
|
246
|
+
let busy = false;
|
|
247
|
+
|
|
248
|
+
function drainPending(): void {
|
|
249
|
+
if (busy || pending.length === 0) return;
|
|
250
|
+
const batch = pending.splice(0, pending.length);
|
|
251
|
+
const lines = batch.map((e) => `[from peer ${e.from}] ${e.text}`);
|
|
252
|
+
busy = true;
|
|
253
|
+
bus.emit("agent:submit", {
|
|
254
|
+
query: [
|
|
255
|
+
"You received message(s) from other peer(s) in the mesh:",
|
|
256
|
+
"",
|
|
257
|
+
...lines,
|
|
258
|
+
"",
|
|
259
|
+
"Decide whether to reply (via `peer_send`), act on the request, or note and continue.",
|
|
260
|
+
].join("\n"),
|
|
261
|
+
});
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
bus.on("agent:processing-done", () => { busy = false; setTimeout(drainPending, 100); });
|
|
278
265
|
|
|
279
266
|
define("peer:message", (from: string, text: string) => {
|
|
280
267
|
if (typeof from !== "string" || typeof text !== "string") {
|
|
281
268
|
throw new Error("peer:message requires (from: string, text: string)");
|
|
282
269
|
}
|
|
270
|
+
if (Buffer.byteLength(text, "utf-8") > MAX_SEND_BYTES) {
|
|
271
|
+
throw new Error(`text payload exceeds ${MAX_SEND_BYTES} bytes`);
|
|
272
|
+
}
|
|
283
273
|
const entry: InboxEntry = { from, text, at: Date.now() };
|
|
284
274
|
inbox.push(entry);
|
|
285
275
|
if (inbox.length > INBOX_MAX) inbox.splice(0, inbox.length - INBOX_MAX);
|
|
286
276
|
pending.push(entry);
|
|
287
|
-
bus.emit("peer:message-received", entry);
|
|
288
277
|
bus.emit("ui:info", { message: `[peer ${from}] ${text}` });
|
|
289
278
|
setTimeout(drainPending, 100);
|
|
290
279
|
return { ok: true };
|
|
291
280
|
});
|
|
292
281
|
server.expose("peer:message");
|
|
293
282
|
|
|
294
|
-
//
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
let busy = false;
|
|
298
|
-
|
|
299
|
-
function drainPending(): void {
|
|
300
|
-
if (busy || pending.length === 0) return;
|
|
301
|
-
const batch = pending.splice(0, pending.length);
|
|
302
|
-
const lines = batch.map((e) => `[from peer ${e.from}] ${e.text}`);
|
|
303
|
-
const query = [
|
|
304
|
-
"You received message(s) from other peer(s) in the mesh:",
|
|
305
|
-
"",
|
|
306
|
-
...lines,
|
|
307
|
-
"",
|
|
308
|
-
"Decide whether to reply (via `peer_send`), act on the request, or note and continue.",
|
|
309
|
-
].join("\n");
|
|
310
|
-
busy = true;
|
|
311
|
-
bus.emit("agent:submit", { query });
|
|
312
|
-
}
|
|
283
|
+
// ── Synchronous Q&A: peer A asks B, blocks until B's next turn answers ──
|
|
284
|
+
interface AskSlot { resolve: (answer: string) => void; reject: (e: Error) => void; from: string; question: string; }
|
|
285
|
+
const askQueue: AskSlot[] = [];
|
|
313
286
|
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
287
|
+
define("peer:ask", (from: string, question: string) => {
|
|
288
|
+
if (typeof from !== "string" || typeof question !== "string") {
|
|
289
|
+
throw new Error("peer:ask requires (from: string, question: string)");
|
|
290
|
+
}
|
|
291
|
+
if (askQueue.length >= ASK_QUEUE_MAX) {
|
|
292
|
+
throw new Error(`peer:ask queue full (max ${ASK_QUEUE_MAX})`);
|
|
293
|
+
}
|
|
294
|
+
if (Buffer.byteLength(question, "utf-8") > MAX_SEND_BYTES) {
|
|
295
|
+
throw new Error(`question payload exceeds ${MAX_SEND_BYTES} bytes`);
|
|
296
|
+
}
|
|
297
|
+
return new Promise<string>((resolve, reject) => {
|
|
298
|
+
const slot: AskSlot = { resolve, reject, from, question };
|
|
299
|
+
askQueue.push(slot);
|
|
300
|
+
const timer = setTimeout(() => {
|
|
301
|
+
const idx = askQueue.indexOf(slot);
|
|
302
|
+
if (idx >= 0) askQueue.splice(idx, 1);
|
|
303
|
+
reject(new Error("peer:ask timed out"));
|
|
304
|
+
}, ASK_TIMEOUT_MS);
|
|
305
|
+
bus.emit("ui:info", { message: `[peer ${from} asks] ${question}` });
|
|
306
|
+
bus.emit("agent:submit", {
|
|
307
|
+
query: [
|
|
308
|
+
`Peer ${from} asks: ${question}`,
|
|
309
|
+
"",
|
|
310
|
+
"Answer with `peer_answer` (your reply will be returned synchronously).",
|
|
311
|
+
].join("\n"),
|
|
312
|
+
});
|
|
313
|
+
// Once resolved/rejected by peer_answer, clear the timer.
|
|
314
|
+
const origResolve = slot.resolve;
|
|
315
|
+
const origReject = slot.reject;
|
|
316
|
+
slot.resolve = (a) => { clearTimeout(timer); origResolve(a); };
|
|
317
|
+
slot.reject = (e) => { clearTimeout(timer); origReject(e); };
|
|
318
|
+
});
|
|
317
319
|
});
|
|
320
|
+
server.expose("peer:ask");
|
|
318
321
|
|
|
319
|
-
// ──
|
|
320
|
-
|
|
321
|
-
define("peer:discover", () => server.discover());
|
|
322
|
-
define("peer:call", (peerId: string, method: string, ...args: unknown[]) =>
|
|
323
|
-
server.call(peerId, method, ...args));
|
|
324
|
-
define("peer:expose", (name: string) => server.expose(name));
|
|
325
|
-
|
|
326
|
-
// ── Agent tools ────────────────────────────────────────────
|
|
327
|
-
|
|
322
|
+
// ── Tools ─────────────────────────────────────────────────────
|
|
328
323
|
registerTool({
|
|
329
324
|
name: "peers",
|
|
330
325
|
description: "List all running agent-sh instances that can be communicated with.",
|
|
@@ -332,63 +327,85 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
332
327
|
showOutput: false,
|
|
333
328
|
getDisplayInfo: () => ({ kind: "search" as const }),
|
|
334
329
|
formatCall: () => "discovering peers",
|
|
335
|
-
|
|
336
330
|
async execute() {
|
|
337
331
|
const peers = server.discover();
|
|
338
332
|
if (peers.length === 0) {
|
|
339
333
|
return { content: "No other agent-sh instances found.", exitCode: 0, isError: false };
|
|
340
334
|
}
|
|
341
335
|
const lines = peers.map((p) =>
|
|
342
|
-
`- id: ${p.id}, pid: ${p.pid}, cwd: ${p.cwd}, uptime: ${Math.round((Date.now() - p.startTime) / 1000)}s
|
|
336
|
+
`- id: ${p.id}, pid: ${p.pid}, cwd: ${p.cwd}, uptime: ${Math.round((Date.now() - p.startTime) / 1000)}s`,
|
|
343
337
|
);
|
|
344
|
-
return {
|
|
345
|
-
content: `Found ${peers.length} peer(s):\n${lines.join("\n")}`,
|
|
346
|
-
exitCode: 0,
|
|
347
|
-
isError: false,
|
|
348
|
-
};
|
|
338
|
+
return { content: `Found ${peers.length} peer(s):\n${lines.join("\n")}`, exitCode: 0, isError: false };
|
|
349
339
|
},
|
|
350
|
-
|
|
351
|
-
formatResult: (_args, result) => ({
|
|
352
|
-
summary: result.content.startsWith("No") ? "none found" : result.content.split("\n")[0],
|
|
353
|
-
}),
|
|
340
|
+
formatResult: (_a, r) => ({ summary: r.content.startsWith("No") ? "none found" : r.content.split("\n")[0] }),
|
|
354
341
|
});
|
|
355
342
|
|
|
356
343
|
registerTool({
|
|
357
344
|
name: "peer_terminal",
|
|
358
|
-
description: "Read the terminal screen content of another running agent-sh instance.
|
|
345
|
+
description: "Read the terminal screen content of another running agent-sh instance.",
|
|
359
346
|
input_schema: {
|
|
360
347
|
type: "object",
|
|
361
|
-
properties: {
|
|
362
|
-
peer_id: { type: "string", description: "The instance ID of the peer (from the peers tool)." },
|
|
363
|
-
},
|
|
348
|
+
properties: { peer_id: { type: "string", description: "Instance ID of the peer (from `peers`)." } },
|
|
364
349
|
required: ["peer_id"],
|
|
365
350
|
},
|
|
366
351
|
showOutput: false,
|
|
367
352
|
getDisplayInfo: () => ({ kind: "read" as const }),
|
|
368
|
-
formatCall: (
|
|
369
|
-
|
|
353
|
+
formatCall: (a) => `peer ${a.peer_id}`,
|
|
370
354
|
async execute(args) {
|
|
371
355
|
try {
|
|
372
356
|
const screen = await server.call(args.peer_id as string, "peer:terminal-read") as any;
|
|
373
357
|
const text = screen?.text?.trim() || "(empty screen)";
|
|
374
358
|
const alt = screen?.altScreen ? " [alternate screen active]" : "";
|
|
375
|
-
return {
|
|
376
|
-
content: `Terminal content from peer ${args.peer_id}${alt}:\n\n${text}`,
|
|
377
|
-
exitCode: 0,
|
|
378
|
-
isError: false,
|
|
379
|
-
};
|
|
359
|
+
return { content: `Terminal content from peer ${args.peer_id}${alt}:\n\n${text}`, exitCode: 0, isError: false };
|
|
380
360
|
} catch (e) {
|
|
381
|
-
return {
|
|
382
|
-
content: `Failed to read peer terminal: ${e instanceof Error ? e.message : String(e)}`,
|
|
383
|
-
exitCode: 1,
|
|
384
|
-
isError: true,
|
|
385
|
-
};
|
|
361
|
+
return { content: `Failed to read peer terminal: ${e instanceof Error ? e.message : String(e)}`, exitCode: 1, isError: true };
|
|
386
362
|
}
|
|
387
363
|
},
|
|
364
|
+
formatResult: (_a, r) => ({ summary: r.isError ? "failed" : `${r.content.split("\n").length - 2} lines` }),
|
|
365
|
+
});
|
|
388
366
|
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
367
|
+
registerTool({
|
|
368
|
+
name: "peer_terminal_send",
|
|
369
|
+
description:
|
|
370
|
+
"Type keys into another peer's terminal PTY. Supports backslash escapes " +
|
|
371
|
+
"(`\\r` for Enter, `\\n`, `\\t`, `\\xNN` for raw bytes, `\\\\` for literal backslash). " +
|
|
372
|
+
"Refuses to send if the peer's PTY produced output within the idle threshold (default 500ms).",
|
|
373
|
+
input_schema: {
|
|
374
|
+
type: "object",
|
|
375
|
+
properties: {
|
|
376
|
+
peer_id: { type: "string", description: "Instance ID of the peer." },
|
|
377
|
+
keys: { type: "string", description: "Key sequence to send. Append `\\r` to submit a command." },
|
|
378
|
+
require_idle_ms: { type: "number", description: "Refuse if peer was active within this many ms (default 500)." },
|
|
379
|
+
settle_ms: { type: "number", description: "Wait this long after sending before reading the screen back (default 400)." },
|
|
380
|
+
},
|
|
381
|
+
required: ["peer_id", "keys"],
|
|
382
|
+
},
|
|
383
|
+
showOutput: false,
|
|
384
|
+
getDisplayInfo: () => ({ kind: "write" as const }),
|
|
385
|
+
formatCall: (a) => `peer ${a.peer_id}: ${JSON.stringify(String(a.keys).slice(0, 40))}`,
|
|
386
|
+
async execute(args) {
|
|
387
|
+
try {
|
|
388
|
+
const result = await server.call(
|
|
389
|
+
args.peer_id as string,
|
|
390
|
+
"peer:terminal-send",
|
|
391
|
+
args.keys,
|
|
392
|
+
args.require_idle_ms,
|
|
393
|
+
args.settle_ms,
|
|
394
|
+
) as { sent: boolean; reason?: string; idle_ms?: number; required_ms?: number; screen?: { text: string } | null };
|
|
395
|
+
if (!result.sent) {
|
|
396
|
+
return {
|
|
397
|
+
content: `Refused to send: peer is busy (${result.reason}; idle ${result.idle_ms}ms / required ${result.required_ms}ms).`,
|
|
398
|
+
exitCode: 1,
|
|
399
|
+
isError: true,
|
|
400
|
+
};
|
|
401
|
+
}
|
|
402
|
+
const screen = result.screen?.text?.trim() ?? "(no screen capture)";
|
|
403
|
+
return { content: `Sent. Screen after settle:\n\n${screen}`, exitCode: 0, isError: false };
|
|
404
|
+
} catch (e) {
|
|
405
|
+
return { content: `Failed to send keys: ${e instanceof Error ? e.message : String(e)}`, exitCode: 1, isError: true };
|
|
406
|
+
}
|
|
407
|
+
},
|
|
408
|
+
formatResult: (_a, r) => ({ summary: r.isError ? "failed" : "sent" }),
|
|
392
409
|
});
|
|
393
410
|
|
|
394
411
|
registerTool({
|
|
@@ -397,32 +414,23 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
397
414
|
input_schema: {
|
|
398
415
|
type: "object",
|
|
399
416
|
properties: {
|
|
400
|
-
peer_id: { type: "string"
|
|
401
|
-
count: { type: "number", description: "Number of recent exchanges
|
|
417
|
+
peer_id: { type: "string" },
|
|
418
|
+
count: { type: "number", description: "Number of recent exchanges (default 15)." },
|
|
402
419
|
},
|
|
403
420
|
required: ["peer_id"],
|
|
404
421
|
},
|
|
405
422
|
showOutput: false,
|
|
406
423
|
getDisplayInfo: () => ({ kind: "read" as const }),
|
|
407
|
-
formatCall: (
|
|
408
|
-
|
|
424
|
+
formatCall: (a) => `peer ${a.peer_id}`,
|
|
409
425
|
async execute(args) {
|
|
410
426
|
try {
|
|
411
|
-
const
|
|
412
|
-
const summary = await server.call(args.peer_id as string, "peer:context-recent", n) as string;
|
|
427
|
+
const summary = await server.call(args.peer_id as string, "peer:context-recent", (args.count as number) || 15) as string;
|
|
413
428
|
return { content: summary || "(no history)", exitCode: 0, isError: false };
|
|
414
429
|
} catch (e) {
|
|
415
|
-
return {
|
|
416
|
-
content: `Failed to read peer history: ${e instanceof Error ? e.message : String(e)}`,
|
|
417
|
-
exitCode: 1,
|
|
418
|
-
isError: true,
|
|
419
|
-
};
|
|
430
|
+
return { content: `Failed to read peer history: ${e instanceof Error ? e.message : String(e)}`, exitCode: 1, isError: true };
|
|
420
431
|
}
|
|
421
432
|
},
|
|
422
|
-
|
|
423
|
-
formatResult: (_args, result) => ({
|
|
424
|
-
summary: result.isError ? "failed" : `${result.content.split("\n").length} lines`,
|
|
425
|
-
}),
|
|
433
|
+
formatResult: (_a, r) => ({ summary: r.isError ? "failed" : `${r.content.split("\n").length} lines` }),
|
|
426
434
|
});
|
|
427
435
|
|
|
428
436
|
registerTool({
|
|
@@ -430,67 +438,43 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
430
438
|
description: "Search another agent-sh instance's shell context by keyword or regex.",
|
|
431
439
|
input_schema: {
|
|
432
440
|
type: "object",
|
|
433
|
-
properties: {
|
|
434
|
-
peer_id: { type: "string", description: "The instance ID of the peer." },
|
|
435
|
-
query: { type: "string", description: "Search query (keyword or regex)." },
|
|
436
|
-
},
|
|
441
|
+
properties: { peer_id: { type: "string" }, query: { type: "string" } },
|
|
437
442
|
required: ["peer_id", "query"],
|
|
438
443
|
},
|
|
439
444
|
showOutput: false,
|
|
440
445
|
getDisplayInfo: () => ({ kind: "search" as const }),
|
|
441
|
-
formatCall: (
|
|
442
|
-
|
|
446
|
+
formatCall: (a) => `peer ${a.peer_id}: "${a.query}"`,
|
|
443
447
|
async execute(args) {
|
|
444
448
|
try {
|
|
445
|
-
const results = await server.call(
|
|
446
|
-
args.peer_id as string, "peer:context-search", args.query as string,
|
|
447
|
-
) as string;
|
|
449
|
+
const results = await server.call(args.peer_id as string, "peer:context-search", args.query as string) as string;
|
|
448
450
|
return { content: results || "(no matches)", exitCode: 0, isError: false };
|
|
449
451
|
} catch (e) {
|
|
450
|
-
return {
|
|
451
|
-
content: `Failed to search peer context: ${e instanceof Error ? e.message : String(e)}`,
|
|
452
|
-
exitCode: 1,
|
|
453
|
-
isError: true,
|
|
454
|
-
};
|
|
452
|
+
return { content: `Failed to search peer context: ${e instanceof Error ? e.message : String(e)}`, exitCode: 1, isError: true };
|
|
455
453
|
}
|
|
456
454
|
},
|
|
457
|
-
|
|
458
|
-
formatResult: (_args, result) => ({
|
|
459
|
-
summary: result.isError ? "failed" : `${result.content.split("\n").length} lines`,
|
|
460
|
-
}),
|
|
455
|
+
formatResult: (_a, r) => ({ summary: r.isError ? "failed" : `${r.content.split("\n").length} lines` }),
|
|
461
456
|
});
|
|
462
457
|
|
|
463
458
|
registerTool({
|
|
464
459
|
name: "peer_send",
|
|
465
|
-
description: "Send a text message to another
|
|
460
|
+
description: "Send a text message to another peer. Appears in their UI and is queued for their next turn.",
|
|
466
461
|
input_schema: {
|
|
467
462
|
type: "object",
|
|
468
|
-
properties: {
|
|
469
|
-
peer_id: { type: "string", description: "The instance ID of the peer (from the peers tool)." },
|
|
470
|
-
text: { type: "string", description: "Message body." },
|
|
471
|
-
},
|
|
463
|
+
properties: { peer_id: { type: "string" }, text: { type: "string" } },
|
|
472
464
|
required: ["peer_id", "text"],
|
|
473
465
|
},
|
|
474
466
|
showOutput: false,
|
|
475
467
|
getDisplayInfo: () => ({ kind: "write" as const }),
|
|
476
|
-
formatCall: (
|
|
477
|
-
|
|
468
|
+
formatCall: (a) => `peer ${a.peer_id}: "${String(a.text).slice(0, 40)}"`,
|
|
478
469
|
async execute(args) {
|
|
479
470
|
try {
|
|
480
471
|
await server.call(args.peer_id as string, "peer:message", ctx.instanceId, args.text as string);
|
|
481
472
|
return { content: `Sent to peer ${args.peer_id}.`, exitCode: 0, isError: false };
|
|
482
473
|
} catch (e) {
|
|
483
|
-
return {
|
|
484
|
-
content: `Failed to send: ${e instanceof Error ? e.message : String(e)}`,
|
|
485
|
-
exitCode: 1,
|
|
486
|
-
isError: true,
|
|
487
|
-
};
|
|
474
|
+
return { content: `Failed to send: ${e instanceof Error ? e.message : String(e)}`, exitCode: 1, isError: true };
|
|
488
475
|
}
|
|
489
476
|
},
|
|
490
|
-
|
|
491
|
-
formatResult: (_args, result) => ({
|
|
492
|
-
summary: result.isError ? "failed" : "sent",
|
|
493
|
-
}),
|
|
477
|
+
formatResult: (_a, r) => ({ summary: r.isError ? "failed" : "sent" }),
|
|
494
478
|
});
|
|
495
479
|
|
|
496
480
|
registerTool({
|
|
@@ -498,41 +482,76 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
498
482
|
description: "Read recent messages received from other peers via peer_send.",
|
|
499
483
|
input_schema: {
|
|
500
484
|
type: "object",
|
|
501
|
-
properties: {
|
|
502
|
-
count: { type: "number", description: "Number of recent messages to return (default: 20)." },
|
|
503
|
-
},
|
|
485
|
+
properties: { count: { type: "number", description: "Max messages to return (default 20)." } },
|
|
504
486
|
required: [],
|
|
505
487
|
},
|
|
506
488
|
showOutput: false,
|
|
507
489
|
getDisplayInfo: () => ({ kind: "read" as const }),
|
|
508
490
|
formatCall: () => "reading inbox",
|
|
509
|
-
|
|
510
491
|
async execute(args) {
|
|
511
|
-
const
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
return { content: "(inbox empty)", exitCode: 0, isError: false };
|
|
515
|
-
}
|
|
516
|
-
const lines = recent.map((e) => {
|
|
517
|
-
const ago = Math.round((Date.now() - e.at) / 1000);
|
|
518
|
-
return `[${ago}s ago] ${e.from}: ${e.text}`;
|
|
519
|
-
});
|
|
492
|
+
const recent = inbox.slice(-((args.count as number) || 20));
|
|
493
|
+
if (recent.length === 0) return { content: "(inbox empty)", exitCode: 0, isError: false };
|
|
494
|
+
const lines = recent.map((e) => `[${Math.round((Date.now() - e.at) / 1000)}s ago] ${e.from}: ${e.text}`);
|
|
520
495
|
return { content: lines.join("\n"), exitCode: 0, isError: false };
|
|
521
496
|
},
|
|
497
|
+
formatResult: (_a, r) => ({ summary: r.content === "(inbox empty)" ? "empty" : `${r.content.split("\n").length} msg` }),
|
|
498
|
+
});
|
|
522
499
|
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
500
|
+
registerTool({
|
|
501
|
+
name: "peer_ask",
|
|
502
|
+
description:
|
|
503
|
+
"Ask another peer a question and wait synchronously for their answer. " +
|
|
504
|
+
"Blocks for up to 2 minutes; the peer responds via peer_answer.",
|
|
505
|
+
input_schema: {
|
|
506
|
+
type: "object",
|
|
507
|
+
properties: {
|
|
508
|
+
peer_id: { type: "string" },
|
|
509
|
+
question: { type: "string" },
|
|
510
|
+
},
|
|
511
|
+
required: ["peer_id", "question"],
|
|
512
|
+
},
|
|
513
|
+
showOutput: false,
|
|
514
|
+
getDisplayInfo: () => ({ kind: "search" as const }),
|
|
515
|
+
formatCall: (a) => `peer ${a.peer_id}: "${String(a.question).slice(0, 40)}"`,
|
|
516
|
+
async execute(args) {
|
|
517
|
+
try {
|
|
518
|
+
const answer = await server.call(args.peer_id as string, "peer:ask", ctx.instanceId, args.question as string) as string;
|
|
519
|
+
return { content: `Answer from peer ${args.peer_id}:\n\n${answer}`, exitCode: 0, isError: false };
|
|
520
|
+
} catch (e) {
|
|
521
|
+
return { content: `Ask failed: ${e instanceof Error ? e.message : String(e)}`, exitCode: 1, isError: true };
|
|
522
|
+
}
|
|
523
|
+
},
|
|
524
|
+
formatResult: (_a, r) => ({ summary: r.isError ? "failed" : "answered" }),
|
|
526
525
|
});
|
|
527
526
|
|
|
528
|
-
|
|
527
|
+
registerTool({
|
|
528
|
+
name: "peer_answer",
|
|
529
|
+
description:
|
|
530
|
+
"Resolve the oldest pending peer:ask question with this answer. " +
|
|
531
|
+
"Use only when responding to a peer-asked question received this turn.",
|
|
532
|
+
input_schema: {
|
|
533
|
+
type: "object",
|
|
534
|
+
properties: { answer: { type: "string" } },
|
|
535
|
+
required: ["answer"],
|
|
536
|
+
},
|
|
537
|
+
showOutput: false,
|
|
538
|
+
getDisplayInfo: () => ({ kind: "write" as const }),
|
|
539
|
+
formatCall: (a) => `"${String(a.answer).slice(0, 40)}"`,
|
|
540
|
+
async execute(args) {
|
|
541
|
+
const slot = askQueue.shift();
|
|
542
|
+
if (!slot) {
|
|
543
|
+
return { content: "No pending peer:ask question to answer.", exitCode: 1, isError: true };
|
|
544
|
+
}
|
|
545
|
+
slot.resolve(args.answer as string);
|
|
546
|
+
return { content: `Answered peer ${slot.from}.`, exitCode: 0, isError: false };
|
|
547
|
+
},
|
|
548
|
+
formatResult: (_a, r) => ({ summary: r.isError ? "no pending" : "answered" }),
|
|
549
|
+
});
|
|
529
550
|
|
|
551
|
+
// ── Slash command + system prompt + cwd sync ────────────────
|
|
530
552
|
registerCommand("peers", "List running agent-sh peer instances", () => {
|
|
531
553
|
const peers = server.discover();
|
|
532
|
-
if (peers.length === 0) {
|
|
533
|
-
bus.emit("ui:info", { message: "No peers found." });
|
|
534
|
-
return;
|
|
535
|
-
}
|
|
554
|
+
if (peers.length === 0) { bus.emit("ui:info", { message: "No peers found." }); return; }
|
|
536
555
|
const lines = peers.map((p) => {
|
|
537
556
|
const uptime = Math.round((Date.now() - p.startTime) / 1000);
|
|
538
557
|
return ` ${p.id} pid=${p.pid} cwd=${p.cwd} ${uptime}s`;
|
|
@@ -540,26 +559,19 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
540
559
|
bus.emit("ui:info", { message: `Peers:\n${lines.join("\n")}` });
|
|
541
560
|
});
|
|
542
561
|
|
|
543
|
-
// ── System prompt instruction ──────────────────────────────
|
|
544
|
-
|
|
545
562
|
registerInstruction("Peer Mesh", [
|
|
546
563
|
"You have access to a peer mesh — other running agent-sh instances on this machine.",
|
|
547
|
-
"Use
|
|
564
|
+
"Use `peers` to discover them, then:",
|
|
548
565
|
"- `peer_terminal` to see what's on another terminal's screen",
|
|
549
|
-
"- `
|
|
550
|
-
"- `
|
|
551
|
-
"- `
|
|
552
|
-
"- `
|
|
566
|
+
"- `peer_terminal_send` to type keys into their PTY (use `\\r` for Enter)",
|
|
567
|
+
"- `peer_history` to see recent commands they ran",
|
|
568
|
+
"- `peer_search` to search their shell context",
|
|
569
|
+
"- `peer_send` to deliver a one-way message",
|
|
570
|
+
"- `peer_inbox` to read messages others sent you",
|
|
571
|
+
"- `peer_ask` to ask a question and wait for their answer",
|
|
572
|
+
"- `peer_answer` to respond when another peer has asked you a question this turn",
|
|
553
573
|
"When the user references 'the other terminal' or 'my other shell', use these tools.",
|
|
554
574
|
].join("\n"));
|
|
555
575
|
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
bus.on("shell:cwd-change", ({ cwd }) => {
|
|
559
|
-
try {
|
|
560
|
-
const info: PeerInfo = JSON.parse(fs.readFileSync(peerFilePath(ctx.instanceId), "utf-8"));
|
|
561
|
-
info.cwd = cwd;
|
|
562
|
-
fs.writeFileSync(peerFilePath(ctx.instanceId), JSON.stringify(info));
|
|
563
|
-
} catch {}
|
|
564
|
-
});
|
|
576
|
+
bus.on("shell:cwd-change", ({ cwd }) => server.updateCwd(cwd));
|
|
565
577
|
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* rtk-proxy — transparently rewrites bash commands to `rtk <command>`
|
|
3
|
+
* so the LLM sees rtk's compressed output (60-90% token reduction on
|
|
4
|
+
* common dev commands: git, cargo, npm, jest, pytest, ls, grep, …).
|
|
5
|
+
*
|
|
6
|
+
* Demonstrates: `ctx.advise("tool:execute", …)` wrapping + line-buffered
|
|
7
|
+
* stream scrub.
|
|
8
|
+
*
|
|
9
|
+
* Compound commands like `cd X && pytest` rewrite the last segment only.
|
|
10
|
+
* Pipes, subshells, and redirects are skipped (unsafe to wrap).
|
|
11
|
+
*
|
|
12
|
+
* Requires the `rtk` binary on PATH (https://github.com/rtk-ai/rtk).
|
|
13
|
+
*
|
|
14
|
+
* Settings (~/.agent-sh/settings.json):
|
|
15
|
+
* { "rtk-proxy": { "enabled": true, "ultraCompact": false,
|
|
16
|
+
* "extraPrefixes": [], "excludePrefixes": [] } }
|
|
17
|
+
*
|
|
18
|
+
* Usage:
|
|
19
|
+
* ash -e ./examples/extensions/rtk-proxy.ts
|
|
20
|
+
* cp examples/extensions/rtk-proxy.ts ~/.agent-sh/extensions/
|
|
21
|
+
*/
|
|
22
|
+
import { execSync } from "node:child_process";
|
|
23
|
+
import type { ExtensionContext } from "agent-sh/types";
|
|
24
|
+
|
|
25
|
+
const DEFAULT_PREFIXES = new Set([
|
|
26
|
+
"git", "gh",
|
|
27
|
+
"ls", "tree", "find", "grep", "rg", "cat",
|
|
28
|
+
"cargo", "npm", "pnpm", "yarn",
|
|
29
|
+
"jest", "vitest", "pytest", "playwright",
|
|
30
|
+
"go", "ruff", "tsc", "eslint", "prettier", "biome",
|
|
31
|
+
"docker", "kubectl",
|
|
32
|
+
"aws",
|
|
33
|
+
"pip", "bundle", "rake", "rspec", "rubocop",
|
|
34
|
+
"golangci-lint", "next",
|
|
35
|
+
"prisma",
|
|
36
|
+
]);
|
|
37
|
+
|
|
38
|
+
// Pipes, subshells, redirections — unsafe to wrap. Compound operators
|
|
39
|
+
// (&&, ||, ;) are handled by splitting and rewriting only the last segment.
|
|
40
|
+
const UNSAFE_SEGMENT_RE = /[|`()$><]/;
|
|
41
|
+
|
|
42
|
+
function firstToken(cmd: string): string {
|
|
43
|
+
const m = cmd.trimStart().match(/^(\S+)/);
|
|
44
|
+
return m ? m[1] : "";
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// Caveat: textual split, no quoting awareness. A literal `&&` inside a
|
|
48
|
+
// quoted argument will split there. Acceptable today because no current
|
|
49
|
+
// prefix-token command takes args containing `&&`/`||`/`;`. If that
|
|
50
|
+
// changes, switch to a proper shell tokenizer.
|
|
51
|
+
function splitLastSegment(cmd: string): [string, string, string] | null {
|
|
52
|
+
const match = cmd.match(/^(.*)(&&|\|\||;)\s*(\S.*)$/s);
|
|
53
|
+
if (!match) return null;
|
|
54
|
+
return [match[1].trimEnd(), match[2], match[3]];
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function rewriteForRtk(cmd: string, prefixes: Set<string>, flag: string): string | null {
|
|
58
|
+
const tok = firstToken(cmd);
|
|
59
|
+
if (!tok || tok === "rtk") return null;
|
|
60
|
+
// Escape hatch: `command foo` forces raw passthrough.
|
|
61
|
+
if (tok === "command") return null;
|
|
62
|
+
|
|
63
|
+
const parts = splitLastSegment(cmd);
|
|
64
|
+
if (parts) {
|
|
65
|
+
const [prefix, sep, lastSeg] = parts;
|
|
66
|
+
if (UNSAFE_SEGMENT_RE.test(lastSeg)) return null;
|
|
67
|
+
if (!prefixes.has(firstToken(lastSeg))) return null;
|
|
68
|
+
return `${prefix} ${sep} RTK_TELEMETRY_DISABLED=1 rtk ${flag}${lastSeg}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (UNSAFE_SEGMENT_RE.test(cmd)) return null;
|
|
72
|
+
if (!prefixes.has(tok)) return null;
|
|
73
|
+
return `RTK_TELEMETRY_DISABLED=1 rtk ${flag}${cmd}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export default function activate(ctx: ExtensionContext) {
|
|
77
|
+
const config = ctx.getExtensionSettings("rtk-proxy", {
|
|
78
|
+
enabled: true,
|
|
79
|
+
ultraCompact: false,
|
|
80
|
+
extraPrefixes: [] as string[],
|
|
81
|
+
excludePrefixes: [] as string[],
|
|
82
|
+
});
|
|
83
|
+
if (!config.enabled) return;
|
|
84
|
+
|
|
85
|
+
try {
|
|
86
|
+
execSync("command -v rtk", { stdio: "ignore" });
|
|
87
|
+
} catch {
|
|
88
|
+
ctx.bus.emit("ui:info", {
|
|
89
|
+
message: "rtk-proxy: `rtk` binary not on PATH — extension inactive.",
|
|
90
|
+
});
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const prefixes = new Set([...DEFAULT_PREFIXES, ...config.extraPrefixes]);
|
|
95
|
+
for (const p of config.excludePrefixes) prefixes.delete(p);
|
|
96
|
+
const flag = config.ultraCompact ? "--ultra-compact " : "";
|
|
97
|
+
|
|
98
|
+
ctx.registerInstruction("rtk-proxy",
|
|
99
|
+
"The rtk-proxy extension transparently rewrites bash commands like " +
|
|
100
|
+
"`git status`, `cargo test`, `pytest` to their rtk-compressed equivalents " +
|
|
101
|
+
"before execution. Output will be condensed (errors/failures first, " +
|
|
102
|
+
"boilerplate stripped). For raw unfiltered output, prefix with `command ` " +
|
|
103
|
+
"(e.g. `command git log`) or pipe (`git log | cat`) — both skip the rewrite.",
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
// rtk prints a nag line when it sees ~/.claude/ but no hook. We're doing
|
|
107
|
+
// the rewrite ourselves, so strip the advisory from streamed + final output.
|
|
108
|
+
const NAG_RE = /^\[(?:rtk|warn)\][^\n]*No hook installed[^\n]*\n?/gm;
|
|
109
|
+
const scrub = (s: string) => s.replace(NAG_RE, "");
|
|
110
|
+
|
|
111
|
+
ctx.advise("tool:execute", async (next, toolCtx) => {
|
|
112
|
+
if (toolCtx.name !== "bash") return next(toolCtx);
|
|
113
|
+
const command = toolCtx.args?.command;
|
|
114
|
+
if (typeof command !== "string") return next(toolCtx);
|
|
115
|
+
|
|
116
|
+
const rewritten = rewriteForRtk(command, prefixes, flag);
|
|
117
|
+
if (rewritten === null) return next(toolCtx);
|
|
118
|
+
|
|
119
|
+
toolCtx.args = { ...toolCtx.args, command: rewritten };
|
|
120
|
+
|
|
121
|
+
// Line-buffer the stream so the nag-line scrub works across chunks.
|
|
122
|
+
const origOnChunk = toolCtx.onChunk;
|
|
123
|
+
if (origOnChunk) {
|
|
124
|
+
let buf = "";
|
|
125
|
+
toolCtx.onChunk = (chunk: string) => {
|
|
126
|
+
buf += chunk;
|
|
127
|
+
const lastNl = buf.lastIndexOf("\n");
|
|
128
|
+
if (lastNl !== -1) {
|
|
129
|
+
origOnChunk(scrub(buf.slice(0, lastNl + 1)));
|
|
130
|
+
buf = buf.slice(lastNl + 1);
|
|
131
|
+
}
|
|
132
|
+
};
|
|
133
|
+
const result = await next(toolCtx);
|
|
134
|
+
if (buf) origOnChunk(scrub(buf));
|
|
135
|
+
return { ...result, content: scrub(result.content) };
|
|
136
|
+
}
|
|
137
|
+
return next(toolCtx);
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
ctx.bus.emit("ui:info", {
|
|
141
|
+
message: `rtk-proxy active (${prefixes.size} command prefixes).`,
|
|
142
|
+
});
|
|
143
|
+
}
|
package/package.json
CHANGED
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* OpenRouter provider extension.
|
|
3
|
-
*
|
|
4
|
-
* Registers OpenRouter as a provider and fetches its full model catalog
|
|
5
|
-
* at startup. Models appear in /model autocomplete as "model [openrouter]"
|
|
6
|
-
* and are available for cycling with Shift+Tab.
|
|
7
|
-
*
|
|
8
|
-
* Model capabilities (reasoning, context window) are read from the
|
|
9
|
-
* OpenRouter API response — no hardcoded model lists.
|
|
10
|
-
*
|
|
11
|
-
* Setup:
|
|
12
|
-
* export OPENROUTER_API_KEY="your-key"
|
|
13
|
-
*
|
|
14
|
-
* Usage:
|
|
15
|
-
* agent-sh -e ./examples/extensions/openrouter.ts
|
|
16
|
-
*
|
|
17
|
-
* # Or add to settings.json:
|
|
18
|
-
* { "extensions": ["./examples/extensions/openrouter.ts"] }
|
|
19
|
-
*/
|
|
20
|
-
import type { ExtensionContext } from "agent-sh/types";
|
|
21
|
-
|
|
22
|
-
const BASE_URL = "https://openrouter.ai/api/v1";
|
|
23
|
-
const API_KEY = process.env.OPENROUTER_API_KEY ?? "";
|
|
24
|
-
|
|
25
|
-
/** Curated default models — used immediately while the full catalog loads. */
|
|
26
|
-
const DEFAULT_MODELS = [
|
|
27
|
-
"anthropic/claude-sonnet-4",
|
|
28
|
-
"google/gemini-2.5-pro-preview",
|
|
29
|
-
"openai/gpt-4.1",
|
|
30
|
-
"deepseek/deepseek-r1",
|
|
31
|
-
"meta-llama/llama-4-maverick",
|
|
32
|
-
];
|
|
33
|
-
|
|
34
|
-
interface OpenRouterModel {
|
|
35
|
-
id: string;
|
|
36
|
-
name: string;
|
|
37
|
-
context_length?: number;
|
|
38
|
-
supported_parameters?: string[];
|
|
39
|
-
pricing?: { prompt: string; completion: string };
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export default function activate({ bus }: ExtensionContext): void {
|
|
43
|
-
if (!API_KEY) {
|
|
44
|
-
bus.emit("ui:error", {
|
|
45
|
-
message: "OpenRouter extension: OPENROUTER_API_KEY not set. Skipping.",
|
|
46
|
-
});
|
|
47
|
-
return;
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
// Register provider immediately with curated defaults
|
|
51
|
-
bus.emit("provider:register", {
|
|
52
|
-
id: "openrouter",
|
|
53
|
-
apiKey: API_KEY,
|
|
54
|
-
baseURL: BASE_URL,
|
|
55
|
-
defaultModel: DEFAULT_MODELS[0],
|
|
56
|
-
models: DEFAULT_MODELS,
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
// Fetch full model catalog in background, re-register with capabilities
|
|
60
|
-
fetchModels().then((models) => {
|
|
61
|
-
if (models.length > 0) {
|
|
62
|
-
bus.emit("provider:register", {
|
|
63
|
-
id: "openrouter",
|
|
64
|
-
apiKey: API_KEY,
|
|
65
|
-
baseURL: BASE_URL,
|
|
66
|
-
defaultModel: DEFAULT_MODELS[0],
|
|
67
|
-
supportsReasoningEffort: true,
|
|
68
|
-
models: models.map((m) => ({
|
|
69
|
-
id: m.id,
|
|
70
|
-
reasoning: m.supported_parameters?.includes("reasoning") ?? false,
|
|
71
|
-
contextWindow: m.context_length,
|
|
72
|
-
})),
|
|
73
|
-
});
|
|
74
|
-
}
|
|
75
|
-
}).catch(() => {
|
|
76
|
-
// Silently fall back to curated defaults
|
|
77
|
-
});
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
async function fetchModels(): Promise<OpenRouterModel[]> {
|
|
81
|
-
const res = await fetch(`${BASE_URL}/models`, {
|
|
82
|
-
headers: { Authorization: `Bearer ${API_KEY}` },
|
|
83
|
-
});
|
|
84
|
-
if (!res.ok) return [];
|
|
85
|
-
const data = await res.json();
|
|
86
|
-
return (data.data ?? []) as OpenRouterModel[];
|
|
87
|
-
}
|