agent-sh 0.7.0 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +28 -33
- package/dist/agent/agent-loop.d.ts +31 -8
- package/dist/agent/agent-loop.js +277 -66
- package/dist/agent/conversation-state.d.ts +41 -9
- package/dist/agent/conversation-state.js +340 -17
- package/dist/agent/history-file.d.ts +36 -0
- package/dist/agent/history-file.js +167 -0
- package/dist/agent/nuclear-form.d.ts +41 -0
- package/dist/agent/nuclear-form.js +176 -0
- package/dist/agent/system-prompt.d.ts +4 -5
- package/dist/agent/system-prompt.js +16 -11
- package/dist/agent/token-budget.d.ts +13 -0
- package/dist/agent/token-budget.js +50 -0
- package/dist/agent/tool-protocol.d.ts +83 -0
- package/dist/agent/tool-protocol.js +386 -0
- package/dist/agent/tools/user-shell.js +4 -1
- package/dist/agent/types.d.ts +21 -1
- package/dist/context-manager.d.ts +0 -1
- package/dist/context-manager.js +5 -110
- package/dist/core.d.ts +7 -7
- package/dist/core.js +76 -180
- package/dist/event-bus.d.ts +40 -0
- package/dist/event-bus.js +20 -1
- package/dist/extension-loader.d.ts +5 -0
- package/dist/extension-loader.js +104 -17
- package/dist/extensions/agent-backend.d.ts +13 -0
- package/dist/extensions/agent-backend.js +167 -0
- package/dist/extensions/command-suggest.d.ts +3 -3
- package/dist/extensions/command-suggest.js +4 -3
- package/dist/extensions/index.d.ts +19 -0
- package/dist/extensions/index.js +25 -0
- package/dist/extensions/slash-commands.d.ts +1 -1
- package/dist/extensions/slash-commands.js +44 -1
- package/dist/extensions/terminal-buffer.d.ts +1 -1
- package/dist/extensions/terminal-buffer.js +22 -8
- package/dist/extensions/tui-renderer.js +177 -122
- package/dist/index.js +14 -20
- package/dist/settings.d.ts +25 -2
- package/dist/settings.js +25 -4
- package/dist/{input-handler.d.ts → shell/input-handler.d.ts} +1 -1
- package/dist/{input-handler.js → shell/input-handler.js} +60 -43
- package/dist/{output-parser.d.ts → shell/output-parser.d.ts} +1 -1
- package/dist/{output-parser.js → shell/output-parser.js} +1 -1
- package/dist/{shell.d.ts → shell/shell.d.ts} +8 -2
- package/dist/{shell.js → shell/shell.js} +24 -6
- package/dist/types.d.ts +49 -32
- package/dist/utils/ansi.d.ts +10 -0
- package/dist/utils/ansi.js +27 -0
- package/dist/utils/compositor.d.ts +62 -0
- package/dist/utils/compositor.js +88 -0
- package/dist/utils/diff-renderer.js +92 -4
- package/dist/utils/floating-panel.d.ts +34 -3
- package/dist/utils/floating-panel.js +315 -82
- package/dist/utils/handler-registry.d.ts +26 -10
- package/dist/utils/handler-registry.js +52 -16
- package/dist/utils/line-editor.d.ts +32 -3
- package/dist/utils/line-editor.js +218 -36
- package/dist/utils/markdown.d.ts +1 -0
- package/dist/utils/markdown.js +4 -4
- package/dist/utils/message-utils.d.ts +35 -0
- package/dist/utils/message-utils.js +75 -0
- package/dist/utils/terminal-buffer.d.ts +9 -1
- package/dist/utils/terminal-buffer.js +31 -2
- package/dist/utils/tool-display.d.ts +1 -0
- package/dist/utils/tool-display.js +1 -1
- package/dist/utils/tool-interactive.d.ts +12 -0
- package/dist/utils/tool-interactive.js +53 -0
- package/examples/extensions/ash-acp-bridge/README.md +39 -0
- package/examples/extensions/ash-acp-bridge/package.json +23 -0
- package/examples/extensions/ash-acp-bridge/src/index.ts +571 -0
- package/examples/extensions/ash-acp-bridge/tsconfig.json +14 -0
- package/examples/extensions/ash-mcp-bridge/README.md +72 -0
- package/examples/extensions/ash-mcp-bridge/index.ts +154 -0
- package/examples/extensions/ash-mcp-bridge/package.json +9 -0
- package/examples/extensions/claude-code-bridge/index.ts +77 -1
- package/examples/extensions/interactive-prompts.ts +82 -110
- package/examples/extensions/overlay-agent.ts +84 -38
- package/examples/extensions/peer-mesh.ts +450 -0
- package/examples/extensions/pi-bridge/index.ts +87 -2
- package/examples/extensions/questionnaire.ts +249 -0
- package/examples/extensions/tmux-pane.ts +307 -0
- package/examples/extensions/web-access.ts +327 -0
- package/package.json +9 -1
- package/dist/extensions/overlay-agent.d.ts +0 -11
- package/dist/extensions/overlay-agent.js +0 -43
- package/examples/extensions/terminal-buffer.ts +0 -184
|
@@ -0,0 +1,450 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Peer mesh — cross-instance communication for agent-sh.
|
|
3
|
+
*
|
|
4
|
+
* Lets all running ash instances discover each other and communicate.
|
|
5
|
+
* Inspired by Ray's @ray.remote: opt-in exposure of named handlers,
|
|
6
|
+
* transparent network calls, automatic discovery.
|
|
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
|
|
14
|
+
*
|
|
15
|
+
* Usage:
|
|
16
|
+
* ash -e ./examples/extensions/peer-mesh.ts
|
|
17
|
+
*
|
|
18
|
+
* # Or install permanently
|
|
19
|
+
* 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
|
+
*/
|
|
26
|
+
import * as fs from "node:fs";
|
|
27
|
+
import * as net from "node:net";
|
|
28
|
+
import * as os from "node:os";
|
|
29
|
+
import * as path from "node:path";
|
|
30
|
+
import type { ExtensionContext } from "agent-sh/types";
|
|
31
|
+
|
|
32
|
+
// ── Types ──────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
interface PeerInfo {
|
|
35
|
+
id: string;
|
|
36
|
+
pid: number;
|
|
37
|
+
cwd: string;
|
|
38
|
+
socketPath: string;
|
|
39
|
+
startTime: number;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
interface RpcRequest {
|
|
43
|
+
method: string;
|
|
44
|
+
args: unknown[];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
interface RpcResponse {
|
|
48
|
+
ok: boolean;
|
|
49
|
+
result?: unknown;
|
|
50
|
+
error?: string;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ── Paths ──────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
const PEERS_DIR = path.join(os.homedir(), ".agent-sh", "peers");
|
|
56
|
+
|
|
57
|
+
function peerFilePath(id: string): string {
|
|
58
|
+
return path.join(PEERS_DIR, `${id}.json`);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function socketPath(pid: number): string {
|
|
62
|
+
return path.join(os.tmpdir(), `agent-sh-peer-${pid}.sock`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// ── PeerServer ─────────────────────────────────────────────────
|
|
66
|
+
|
|
67
|
+
class PeerServer {
|
|
68
|
+
private server: net.Server | null = null;
|
|
69
|
+
private exposed = new Set<string>();
|
|
70
|
+
private readonly info: PeerInfo;
|
|
71
|
+
private readonly callHandler: (name: string, ...args: unknown[]) => unknown;
|
|
72
|
+
|
|
73
|
+
constructor(
|
|
74
|
+
instanceId: string,
|
|
75
|
+
cwd: string,
|
|
76
|
+
callHandler: (name: string, ...args: unknown[]) => unknown,
|
|
77
|
+
) {
|
|
78
|
+
this.callHandler = callHandler;
|
|
79
|
+
this.info = {
|
|
80
|
+
id: instanceId,
|
|
81
|
+
pid: process.pid,
|
|
82
|
+
cwd,
|
|
83
|
+
socketPath: socketPath(process.pid),
|
|
84
|
+
startTime: Date.now(),
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ── Lifecycle ──────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
start(): void {
|
|
91
|
+
// Ensure peers directory exists
|
|
92
|
+
fs.mkdirSync(PEERS_DIR, { recursive: true });
|
|
93
|
+
|
|
94
|
+
// Clean up stale socket
|
|
95
|
+
try { fs.unlinkSync(this.info.socketPath); } catch {}
|
|
96
|
+
|
|
97
|
+
// Start Unix socket server
|
|
98
|
+
this.server = net.createServer((conn) => this.handleConnection(conn));
|
|
99
|
+
this.server.on("error", () => {}); // swallow server errors
|
|
100
|
+
this.server.listen(this.info.socketPath);
|
|
101
|
+
|
|
102
|
+
// Register peer file
|
|
103
|
+
fs.writeFileSync(peerFilePath(this.info.id), JSON.stringify(this.info));
|
|
104
|
+
|
|
105
|
+
// Cleanup on exit
|
|
106
|
+
const cleanup = () => this.stop();
|
|
107
|
+
process.on("exit", cleanup);
|
|
108
|
+
process.on("SIGTERM", () => { cleanup(); process.exit(0); });
|
|
109
|
+
process.on("SIGINT", () => { cleanup(); process.exit(0); });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
stop(): void {
|
|
113
|
+
if (this.server) {
|
|
114
|
+
try { this.server.close(); } catch {}
|
|
115
|
+
this.server = null;
|
|
116
|
+
}
|
|
117
|
+
try { fs.unlinkSync(this.info.socketPath); } catch {}
|
|
118
|
+
try { fs.unlinkSync(peerFilePath(this.info.id)); } catch {}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// ── Expose / discover / call ───────────────────────────────
|
|
122
|
+
|
|
123
|
+
expose(name: string): void {
|
|
124
|
+
this.exposed.add(name);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
discover(): PeerInfo[] {
|
|
128
|
+
const peers: PeerInfo[] = [];
|
|
129
|
+
let entries: string[];
|
|
130
|
+
try { entries = fs.readdirSync(PEERS_DIR); } catch { return []; }
|
|
131
|
+
|
|
132
|
+
for (const entry of entries) {
|
|
133
|
+
if (!entry.endsWith(".json")) continue;
|
|
134
|
+
try {
|
|
135
|
+
const raw = fs.readFileSync(path.join(PEERS_DIR, entry), "utf-8");
|
|
136
|
+
const info: PeerInfo = JSON.parse(raw);
|
|
137
|
+
// Skip self
|
|
138
|
+
if (info.id === this.info.id) continue;
|
|
139
|
+
// Check if process is alive
|
|
140
|
+
try { process.kill(info.pid, 0); } catch {
|
|
141
|
+
// Stale — prune
|
|
142
|
+
try { fs.unlinkSync(path.join(PEERS_DIR, entry)); } catch {}
|
|
143
|
+
continue;
|
|
144
|
+
}
|
|
145
|
+
peers.push(info);
|
|
146
|
+
} catch {
|
|
147
|
+
// Malformed file — skip
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
return peers;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async call(peerId: string, method: string, ...args: unknown[]): Promise<unknown> {
|
|
154
|
+
// Find peer socket path
|
|
155
|
+
const peers = this.discover();
|
|
156
|
+
const peer = peers.find((p) => p.id === peerId);
|
|
157
|
+
if (!peer) throw new Error(`Peer "${peerId}" not found`);
|
|
158
|
+
|
|
159
|
+
return this.callSocket(peer.socketPath, method, args);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// ── Private ────────────────────────────────────────────────
|
|
163
|
+
|
|
164
|
+
private handleConnection(conn: net.Socket): void {
|
|
165
|
+
let buffer = "";
|
|
166
|
+
conn.on("data", (data) => {
|
|
167
|
+
buffer += data.toString();
|
|
168
|
+
const newlineIdx = buffer.indexOf("\n");
|
|
169
|
+
if (newlineIdx === -1) return;
|
|
170
|
+
|
|
171
|
+
const line = buffer.slice(0, newlineIdx);
|
|
172
|
+
buffer = buffer.slice(newlineIdx + 1);
|
|
173
|
+
|
|
174
|
+
let response: RpcResponse;
|
|
175
|
+
try {
|
|
176
|
+
const req: RpcRequest = JSON.parse(line);
|
|
177
|
+
if (!this.exposed.has(req.method)) {
|
|
178
|
+
response = { ok: false, error: `Handler "${req.method}" is not exposed` };
|
|
179
|
+
} else {
|
|
180
|
+
const result = this.callHandler(req.method, ...(req.args ?? []));
|
|
181
|
+
response = { ok: true, result };
|
|
182
|
+
}
|
|
183
|
+
} catch (e) {
|
|
184
|
+
response = { ok: false, error: e instanceof Error ? e.message : String(e) };
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
try {
|
|
188
|
+
conn.write(JSON.stringify(response) + "\n");
|
|
189
|
+
} catch {}
|
|
190
|
+
conn.end();
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
conn.on("error", () => {});
|
|
194
|
+
// Timeout in case client hangs
|
|
195
|
+
conn.setTimeout(5000, () => conn.destroy());
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
private callSocket(sockPath: string, method: string, args: unknown[]): Promise<unknown> {
|
|
199
|
+
return new Promise((resolve, reject) => {
|
|
200
|
+
const conn = net.createConnection(sockPath);
|
|
201
|
+
let buffer = "";
|
|
202
|
+
let settled = false;
|
|
203
|
+
|
|
204
|
+
const settle = (fn: () => void) => {
|
|
205
|
+
if (settled) return;
|
|
206
|
+
settled = true;
|
|
207
|
+
fn();
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
conn.on("connect", () => {
|
|
211
|
+
const req: RpcRequest = { method, args };
|
|
212
|
+
conn.write(JSON.stringify(req) + "\n");
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
conn.on("data", (data) => {
|
|
216
|
+
buffer += data.toString();
|
|
217
|
+
const newlineIdx = buffer.indexOf("\n");
|
|
218
|
+
if (newlineIdx === -1) return;
|
|
219
|
+
|
|
220
|
+
const line = buffer.slice(0, newlineIdx);
|
|
221
|
+
try {
|
|
222
|
+
const resp: RpcResponse = JSON.parse(line);
|
|
223
|
+
settle(() => resp.ok ? resolve(resp.result) : reject(new Error(resp.error)));
|
|
224
|
+
} catch (e) {
|
|
225
|
+
settle(() => reject(e));
|
|
226
|
+
}
|
|
227
|
+
conn.end();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
conn.on("error", (e) => settle(() => reject(e)));
|
|
231
|
+
conn.setTimeout(5000, () => settle(() => {
|
|
232
|
+
reject(new Error("Peer call timed out"));
|
|
233
|
+
conn.destroy();
|
|
234
|
+
}));
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// ── Extension ──────────────────────────────────────────────────
|
|
240
|
+
|
|
241
|
+
export default function activate(ctx: ExtensionContext): void {
|
|
242
|
+
const { bus, contextManager, registerCommand, registerTool, registerInstruction, define } = ctx;
|
|
243
|
+
const startTime = Date.now();
|
|
244
|
+
|
|
245
|
+
const server = new PeerServer(ctx.instanceId, contextManager.getCwd(), (...args) => ctx.call(...args));
|
|
246
|
+
server.start();
|
|
247
|
+
|
|
248
|
+
// ── Standard handlers (define + expose) ────────────────────
|
|
249
|
+
|
|
250
|
+
define("peer:info", () => ({
|
|
251
|
+
id: ctx.instanceId,
|
|
252
|
+
pid: process.pid,
|
|
253
|
+
cwd: contextManager.getCwd(),
|
|
254
|
+
uptime: Math.round((Date.now() - startTime) / 1000),
|
|
255
|
+
}));
|
|
256
|
+
server.expose("peer:info");
|
|
257
|
+
|
|
258
|
+
define("peer:terminal-read", () => {
|
|
259
|
+
const tb = ctx.terminalBuffer;
|
|
260
|
+
if (!tb) return { text: "(terminal buffer not available)", altScreen: false };
|
|
261
|
+
return tb.readScreen({ includeScrollback: true });
|
|
262
|
+
});
|
|
263
|
+
server.expose("peer:terminal-read");
|
|
264
|
+
|
|
265
|
+
define("peer:context-recent", (n: number = 15) => contextManager.getRecentSummary(n));
|
|
266
|
+
server.expose("peer:context-recent");
|
|
267
|
+
|
|
268
|
+
define("peer:context-search", (query: string) => contextManager.search(query));
|
|
269
|
+
server.expose("peer:context-search");
|
|
270
|
+
|
|
271
|
+
// ── Handler registry API (for other extensions) ────────────
|
|
272
|
+
|
|
273
|
+
define("peer:discover", () => server.discover());
|
|
274
|
+
define("peer:call", (peerId: string, method: string, ...args: unknown[]) =>
|
|
275
|
+
server.call(peerId, method, ...args));
|
|
276
|
+
define("peer:expose", (name: string) => server.expose(name));
|
|
277
|
+
|
|
278
|
+
// ── Agent tools ────────────────────────────────────────────
|
|
279
|
+
|
|
280
|
+
registerTool({
|
|
281
|
+
name: "peers",
|
|
282
|
+
description: "List all running agent-sh instances that can be communicated with.",
|
|
283
|
+
input_schema: { type: "object", properties: {}, required: [] },
|
|
284
|
+
showOutput: false,
|
|
285
|
+
getDisplayInfo: () => ({ kind: "search" as const }),
|
|
286
|
+
formatCall: () => "discovering peers",
|
|
287
|
+
|
|
288
|
+
async execute() {
|
|
289
|
+
const peers = server.discover();
|
|
290
|
+
if (peers.length === 0) {
|
|
291
|
+
return { content: "No other agent-sh instances found.", exitCode: 0, isError: false };
|
|
292
|
+
}
|
|
293
|
+
const lines = peers.map((p) =>
|
|
294
|
+
`- id: ${p.id}, pid: ${p.pid}, cwd: ${p.cwd}, uptime: ${Math.round((Date.now() - p.startTime) / 1000)}s`
|
|
295
|
+
);
|
|
296
|
+
return {
|
|
297
|
+
content: `Found ${peers.length} peer(s):\n${lines.join("\n")}`,
|
|
298
|
+
exitCode: 0,
|
|
299
|
+
isError: false,
|
|
300
|
+
};
|
|
301
|
+
},
|
|
302
|
+
|
|
303
|
+
formatResult: (_args, result) => ({
|
|
304
|
+
summary: result.content.startsWith("No") ? "none found" : result.content.split("\n")[0],
|
|
305
|
+
}),
|
|
306
|
+
});
|
|
307
|
+
|
|
308
|
+
registerTool({
|
|
309
|
+
name: "peer_terminal",
|
|
310
|
+
description: "Read the terminal screen content of another running agent-sh instance. Shows what is currently visible on their terminal.",
|
|
311
|
+
input_schema: {
|
|
312
|
+
type: "object",
|
|
313
|
+
properties: {
|
|
314
|
+
peer_id: { type: "string", description: "The instance ID of the peer (from the peers tool)." },
|
|
315
|
+
},
|
|
316
|
+
required: ["peer_id"],
|
|
317
|
+
},
|
|
318
|
+
showOutput: false,
|
|
319
|
+
getDisplayInfo: () => ({ kind: "read" as const }),
|
|
320
|
+
formatCall: (args) => `peer ${args.peer_id}`,
|
|
321
|
+
|
|
322
|
+
async execute(args) {
|
|
323
|
+
try {
|
|
324
|
+
const screen = await server.call(args.peer_id as string, "peer:terminal-read") as any;
|
|
325
|
+
const text = screen?.text?.trim() || "(empty screen)";
|
|
326
|
+
const alt = screen?.altScreen ? " [alternate screen active]" : "";
|
|
327
|
+
return {
|
|
328
|
+
content: `Terminal content from peer ${args.peer_id}${alt}:\n\n${text}`,
|
|
329
|
+
exitCode: 0,
|
|
330
|
+
isError: false,
|
|
331
|
+
};
|
|
332
|
+
} catch (e) {
|
|
333
|
+
return {
|
|
334
|
+
content: `Failed to read peer terminal: ${e instanceof Error ? e.message : String(e)}`,
|
|
335
|
+
exitCode: 1,
|
|
336
|
+
isError: true,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
},
|
|
340
|
+
|
|
341
|
+
formatResult: (_args, result) => ({
|
|
342
|
+
summary: result.isError ? "failed" : `${result.content.split("\n").length - 2} lines`,
|
|
343
|
+
}),
|
|
344
|
+
});
|
|
345
|
+
|
|
346
|
+
registerTool({
|
|
347
|
+
name: "peer_history",
|
|
348
|
+
description: "Get the recent shell command history from another running agent-sh instance.",
|
|
349
|
+
input_schema: {
|
|
350
|
+
type: "object",
|
|
351
|
+
properties: {
|
|
352
|
+
peer_id: { type: "string", description: "The instance ID of the peer." },
|
|
353
|
+
count: { type: "number", description: "Number of recent exchanges to return (default: 15)." },
|
|
354
|
+
},
|
|
355
|
+
required: ["peer_id"],
|
|
356
|
+
},
|
|
357
|
+
showOutput: false,
|
|
358
|
+
getDisplayInfo: () => ({ kind: "read" as const }),
|
|
359
|
+
formatCall: (args) => `peer ${args.peer_id}`,
|
|
360
|
+
|
|
361
|
+
async execute(args) {
|
|
362
|
+
try {
|
|
363
|
+
const n = (args.count as number) || 15;
|
|
364
|
+
const summary = await server.call(args.peer_id as string, "peer:context-recent", n) as string;
|
|
365
|
+
return { content: summary || "(no history)", exitCode: 0, isError: false };
|
|
366
|
+
} catch (e) {
|
|
367
|
+
return {
|
|
368
|
+
content: `Failed to read peer history: ${e instanceof Error ? e.message : String(e)}`,
|
|
369
|
+
exitCode: 1,
|
|
370
|
+
isError: true,
|
|
371
|
+
};
|
|
372
|
+
}
|
|
373
|
+
},
|
|
374
|
+
|
|
375
|
+
formatResult: (_args, result) => ({
|
|
376
|
+
summary: result.isError ? "failed" : `${result.content.split("\n").length} lines`,
|
|
377
|
+
}),
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
registerTool({
|
|
381
|
+
name: "peer_search",
|
|
382
|
+
description: "Search another agent-sh instance's shell context by keyword or regex.",
|
|
383
|
+
input_schema: {
|
|
384
|
+
type: "object",
|
|
385
|
+
properties: {
|
|
386
|
+
peer_id: { type: "string", description: "The instance ID of the peer." },
|
|
387
|
+
query: { type: "string", description: "Search query (keyword or regex)." },
|
|
388
|
+
},
|
|
389
|
+
required: ["peer_id", "query"],
|
|
390
|
+
},
|
|
391
|
+
showOutput: false,
|
|
392
|
+
getDisplayInfo: () => ({ kind: "search" as const }),
|
|
393
|
+
formatCall: (args) => `peer ${args.peer_id}: "${args.query}"`,
|
|
394
|
+
|
|
395
|
+
async execute(args) {
|
|
396
|
+
try {
|
|
397
|
+
const results = await server.call(
|
|
398
|
+
args.peer_id as string, "peer:context-search", args.query as string,
|
|
399
|
+
) as string;
|
|
400
|
+
return { content: results || "(no matches)", exitCode: 0, isError: false };
|
|
401
|
+
} catch (e) {
|
|
402
|
+
return {
|
|
403
|
+
content: `Failed to search peer context: ${e instanceof Error ? e.message : String(e)}`,
|
|
404
|
+
exitCode: 1,
|
|
405
|
+
isError: true,
|
|
406
|
+
};
|
|
407
|
+
}
|
|
408
|
+
},
|
|
409
|
+
|
|
410
|
+
formatResult: (_args, result) => ({
|
|
411
|
+
summary: result.isError ? "failed" : `${result.content.split("\n").length} lines`,
|
|
412
|
+
}),
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
// ── Slash command ──────────────────────────────────────────
|
|
416
|
+
|
|
417
|
+
registerCommand("peers", "List running agent-sh peer instances", () => {
|
|
418
|
+
const peers = server.discover();
|
|
419
|
+
if (peers.length === 0) {
|
|
420
|
+
bus.emit("ui:info", { message: "No peers found." });
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const lines = peers.map((p) => {
|
|
424
|
+
const uptime = Math.round((Date.now() - p.startTime) / 1000);
|
|
425
|
+
return ` ${p.id} pid=${p.pid} cwd=${p.cwd} ${uptime}s`;
|
|
426
|
+
});
|
|
427
|
+
bus.emit("ui:info", { message: `Peers:\n${lines.join("\n")}` });
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// ── System prompt instruction ──────────────────────────────
|
|
431
|
+
|
|
432
|
+
registerInstruction("Peer Mesh", [
|
|
433
|
+
"You have access to a peer mesh — other running agent-sh instances on this machine.",
|
|
434
|
+
"Use the `peers` tool to discover them, then:",
|
|
435
|
+
"- `peer_terminal` to see what's on another terminal's screen",
|
|
436
|
+
"- `peer_history` to see what commands they ran recently",
|
|
437
|
+
"- `peer_search` to search their shell context by keyword",
|
|
438
|
+
"When the user references 'the other terminal' or 'my other shell', use these tools.",
|
|
439
|
+
].join("\n"));
|
|
440
|
+
|
|
441
|
+
// ── Update CWD in peer file on directory change ────────────
|
|
442
|
+
|
|
443
|
+
bus.on("shell:cwd-change", ({ cwd }) => {
|
|
444
|
+
try {
|
|
445
|
+
const info: PeerInfo = JSON.parse(fs.readFileSync(peerFilePath(ctx.instanceId), "utf-8"));
|
|
446
|
+
info.cwd = cwd;
|
|
447
|
+
fs.writeFileSync(peerFilePath(ctx.instanceId), JSON.stringify(info));
|
|
448
|
+
} catch {}
|
|
449
|
+
});
|
|
450
|
+
}
|
|
@@ -26,7 +26,22 @@ import { Type } from "@sinclair/typebox";
|
|
|
26
26
|
import type { ExtensionContext } from "../../src/types.js";
|
|
27
27
|
import type { EventBus } from "../../src/event-bus.js";
|
|
28
28
|
|
|
29
|
-
// ──
|
|
29
|
+
// ── Helpers ──────────────────────────────────────────────────────
|
|
30
|
+
function interpretEscapes(str: string): string {
|
|
31
|
+
return str.replace(/\\(x[0-9a-fA-F]{2}|r|n|t|\\|0)/g, (_, seq: string) => {
|
|
32
|
+
if (seq === "r") return "\r";
|
|
33
|
+
if (seq === "n") return "\n";
|
|
34
|
+
if (seq === "t") return "\t";
|
|
35
|
+
if (seq === "\\") return "\\";
|
|
36
|
+
if (seq === "0") return "\0";
|
|
37
|
+
if (seq.startsWith("x")) return String.fromCharCode(parseInt(seq.slice(1), 16));
|
|
38
|
+
return seq;
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function settle(ms = 100): Promise<void> {
|
|
43
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
44
|
+
}
|
|
30
45
|
|
|
31
46
|
// ── user_shell as a pi ToolDefinition ─────────────────────────────
|
|
32
47
|
function createUserShellToolDef(bus: EventBus) {
|
|
@@ -81,12 +96,82 @@ function createUserShellToolDef(bus: EventBus) {
|
|
|
81
96
|
};
|
|
82
97
|
}
|
|
83
98
|
|
|
99
|
+
// ── terminal_read as a pi ToolDefinition ─────────────────────────
|
|
100
|
+
function createTerminalReadToolDef(ctx: ExtensionContext) {
|
|
101
|
+
return {
|
|
102
|
+
name: "terminal_read",
|
|
103
|
+
label: "terminal_read",
|
|
104
|
+
description:
|
|
105
|
+
"Read the current terminal screen contents. Returns clean text (ANSI stripped) " +
|
|
106
|
+
"with cursor position and whether an alternate-screen program (vim, htop, less) is active.",
|
|
107
|
+
promptSnippet: "Read the terminal screen to see what the user sees.",
|
|
108
|
+
promptGuidelines: [
|
|
109
|
+
"Use terminal_read to see the current terminal screen before sending keystrokes.",
|
|
110
|
+
"Check altScreen to know if a full-screen program (vim, htop) is running.",
|
|
111
|
+
],
|
|
112
|
+
parameters: Type.Object({}),
|
|
113
|
+
async execute() {
|
|
114
|
+
const tb = ctx.terminalBuffer;
|
|
115
|
+
if (!tb) return { content: [{ type: "text", text: "terminal buffer not available" }], details: undefined };
|
|
116
|
+
const { text, altScreen, cursorX, cursorY } = tb.readScreen();
|
|
117
|
+
const info = [
|
|
118
|
+
altScreen ? "mode: alternate screen" : "mode: normal",
|
|
119
|
+
`cursor: row=${cursorY} col=${cursorX}`,
|
|
120
|
+
].join(", ");
|
|
121
|
+
return { content: [{ type: "text", text: `[${info}]\n\n${text}` }], details: undefined };
|
|
122
|
+
},
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── terminal_keys as a pi ToolDefinition ─────────────────────────
|
|
127
|
+
function createTerminalKeysToolDef(bus: EventBus, ctx: ExtensionContext) {
|
|
128
|
+
return {
|
|
129
|
+
name: "terminal_keys",
|
|
130
|
+
label: "terminal_keys",
|
|
131
|
+
description:
|
|
132
|
+
"Send keystrokes to the user's live terminal as if the user typed them. " +
|
|
133
|
+
"Use escape sequences: \\x1b for Escape, \\r for Enter, \\t for Tab, " +
|
|
134
|
+
"\\x03 for Ctrl+C, \\x1b[A/B/C/D for arrow keys, \\x7f for Backspace. " +
|
|
135
|
+
"Example: \\x1b:q!\\r to quit vim. Always call terminal_read after.",
|
|
136
|
+
promptSnippet: "Send keystrokes to interactive programs in the terminal.",
|
|
137
|
+
promptGuidelines: [
|
|
138
|
+
"Use terminal_keys to type into interactive programs (vim, htop, less).",
|
|
139
|
+
"Always call terminal_read after sending keys to verify the result.",
|
|
140
|
+
],
|
|
141
|
+
parameters: Type.Object({
|
|
142
|
+
keys: Type.String({ description: "Keystrokes to send (use \\x1b for Escape, \\r for Enter, etc.)" }),
|
|
143
|
+
settle_ms: Type.Optional(
|
|
144
|
+
Type.Number({ description: "Wait time in ms after sending keys (default: 150)" }),
|
|
145
|
+
),
|
|
146
|
+
}),
|
|
147
|
+
async execute(_toolCallId: string, params: any) {
|
|
148
|
+
const keys = interpretEscapes(params.keys);
|
|
149
|
+
const settleMs = params.settle_ms ?? 150;
|
|
150
|
+
bus.emit("shell:stdout-show", {});
|
|
151
|
+
process.stdout.write("\n");
|
|
152
|
+
bus.emit("shell:pty-write", { data: keys });
|
|
153
|
+
await settle(settleMs);
|
|
154
|
+
|
|
155
|
+
const tb = ctx.terminalBuffer;
|
|
156
|
+
if (!tb) return { content: [{ type: "text", text: "Keys sent." }], details: undefined };
|
|
157
|
+
const { text, altScreen, cursorX, cursorY } = tb.readScreen();
|
|
158
|
+
const info = [
|
|
159
|
+
altScreen ? "mode: alternate screen" : "mode: normal",
|
|
160
|
+
`cursor: row=${cursorY} col=${cursorX}`,
|
|
161
|
+
].join(", ");
|
|
162
|
+
return { content: [{ type: "text", text: `Keys sent. Screen after:\n[${info}]\n\n${text}` }], details: undefined };
|
|
163
|
+
},
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
84
167
|
// ── Extension entry point ─────────────────────────────────────────
|
|
85
168
|
export default function activate(ctx: ExtensionContext): void {
|
|
86
169
|
const { bus } = ctx;
|
|
87
170
|
const cwd = process.cwd();
|
|
88
171
|
|
|
89
172
|
const userShellTool = createUserShellToolDef(bus);
|
|
173
|
+
const termReadTool = createTerminalReadToolDef(ctx);
|
|
174
|
+
const termKeysTool = createTerminalKeysToolDef(bus, ctx);
|
|
90
175
|
|
|
91
176
|
// ── Boot pi session (async — register backend synchronously first) ──
|
|
92
177
|
let session: any = null;
|
|
@@ -105,7 +190,7 @@ export default function activate(ctx: ExtensionContext): void {
|
|
|
105
190
|
const result = await createAgentSessionFromServices({
|
|
106
191
|
services,
|
|
107
192
|
sessionManager: opts.sessionManager ?? sessionManager,
|
|
108
|
-
customTools: [userShellTool],
|
|
193
|
+
customTools: [userShellTool, termReadTool, termKeysTool],
|
|
109
194
|
});
|
|
110
195
|
return { ...result, services };
|
|
111
196
|
};
|