agent-sh 0.12.9 → 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.d.ts +4 -3
- package/dist/agent/agent-loop.js +31 -16
- package/dist/agent/history-file.d.ts +30 -1
- package/dist/agent/history-file.js +40 -17
- package/dist/agent/nuclear-form.d.ts +7 -0
- package/dist/agent/nuclear-form.js +19 -0
- package/dist/core.d.ts +2 -0
- package/dist/core.js +4 -0
- package/dist/event-bus.d.ts +8 -1
- package/dist/extensions/agent-backend.js +16 -0
- package/dist/extensions/openai.d.ts +5 -3
- package/dist/extensions/openai.js +23 -20
- package/dist/extensions/openrouter.js +6 -0
- package/dist/extensions/slash-commands.js +15 -9
- package/dist/init.js +1 -3
- package/dist/settings.d.ts +5 -0
- package/dist/settings.js +1 -0
- package/dist/types.d.ts +9 -0
- 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
|
@@ -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
|
}
|