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.
Files changed (86) hide show
  1. package/README.md +28 -33
  2. package/dist/agent/agent-loop.d.ts +31 -8
  3. package/dist/agent/agent-loop.js +277 -66
  4. package/dist/agent/conversation-state.d.ts +41 -9
  5. package/dist/agent/conversation-state.js +340 -17
  6. package/dist/agent/history-file.d.ts +36 -0
  7. package/dist/agent/history-file.js +167 -0
  8. package/dist/agent/nuclear-form.d.ts +41 -0
  9. package/dist/agent/nuclear-form.js +176 -0
  10. package/dist/agent/system-prompt.d.ts +4 -5
  11. package/dist/agent/system-prompt.js +16 -11
  12. package/dist/agent/token-budget.d.ts +13 -0
  13. package/dist/agent/token-budget.js +50 -0
  14. package/dist/agent/tool-protocol.d.ts +83 -0
  15. package/dist/agent/tool-protocol.js +386 -0
  16. package/dist/agent/tools/user-shell.js +4 -1
  17. package/dist/agent/types.d.ts +21 -1
  18. package/dist/context-manager.d.ts +0 -1
  19. package/dist/context-manager.js +5 -110
  20. package/dist/core.d.ts +7 -7
  21. package/dist/core.js +76 -180
  22. package/dist/event-bus.d.ts +40 -0
  23. package/dist/event-bus.js +20 -1
  24. package/dist/extension-loader.d.ts +5 -0
  25. package/dist/extension-loader.js +104 -17
  26. package/dist/extensions/agent-backend.d.ts +13 -0
  27. package/dist/extensions/agent-backend.js +167 -0
  28. package/dist/extensions/command-suggest.d.ts +3 -3
  29. package/dist/extensions/command-suggest.js +4 -3
  30. package/dist/extensions/index.d.ts +19 -0
  31. package/dist/extensions/index.js +25 -0
  32. package/dist/extensions/slash-commands.d.ts +1 -1
  33. package/dist/extensions/slash-commands.js +44 -1
  34. package/dist/extensions/terminal-buffer.d.ts +1 -1
  35. package/dist/extensions/terminal-buffer.js +22 -8
  36. package/dist/extensions/tui-renderer.js +177 -122
  37. package/dist/index.js +14 -20
  38. package/dist/settings.d.ts +25 -2
  39. package/dist/settings.js +25 -4
  40. package/dist/{input-handler.d.ts → shell/input-handler.d.ts} +1 -1
  41. package/dist/{input-handler.js → shell/input-handler.js} +60 -43
  42. package/dist/{output-parser.d.ts → shell/output-parser.d.ts} +1 -1
  43. package/dist/{output-parser.js → shell/output-parser.js} +1 -1
  44. package/dist/{shell.d.ts → shell/shell.d.ts} +8 -2
  45. package/dist/{shell.js → shell/shell.js} +24 -6
  46. package/dist/types.d.ts +49 -32
  47. package/dist/utils/ansi.d.ts +10 -0
  48. package/dist/utils/ansi.js +27 -0
  49. package/dist/utils/compositor.d.ts +62 -0
  50. package/dist/utils/compositor.js +88 -0
  51. package/dist/utils/diff-renderer.js +92 -4
  52. package/dist/utils/floating-panel.d.ts +34 -3
  53. package/dist/utils/floating-panel.js +315 -82
  54. package/dist/utils/handler-registry.d.ts +26 -10
  55. package/dist/utils/handler-registry.js +52 -16
  56. package/dist/utils/line-editor.d.ts +32 -3
  57. package/dist/utils/line-editor.js +218 -36
  58. package/dist/utils/markdown.d.ts +1 -0
  59. package/dist/utils/markdown.js +4 -4
  60. package/dist/utils/message-utils.d.ts +35 -0
  61. package/dist/utils/message-utils.js +75 -0
  62. package/dist/utils/terminal-buffer.d.ts +9 -1
  63. package/dist/utils/terminal-buffer.js +31 -2
  64. package/dist/utils/tool-display.d.ts +1 -0
  65. package/dist/utils/tool-display.js +1 -1
  66. package/dist/utils/tool-interactive.d.ts +12 -0
  67. package/dist/utils/tool-interactive.js +53 -0
  68. package/examples/extensions/ash-acp-bridge/README.md +39 -0
  69. package/examples/extensions/ash-acp-bridge/package.json +23 -0
  70. package/examples/extensions/ash-acp-bridge/src/index.ts +571 -0
  71. package/examples/extensions/ash-acp-bridge/tsconfig.json +14 -0
  72. package/examples/extensions/ash-mcp-bridge/README.md +72 -0
  73. package/examples/extensions/ash-mcp-bridge/index.ts +154 -0
  74. package/examples/extensions/ash-mcp-bridge/package.json +9 -0
  75. package/examples/extensions/claude-code-bridge/index.ts +77 -1
  76. package/examples/extensions/interactive-prompts.ts +82 -110
  77. package/examples/extensions/overlay-agent.ts +84 -38
  78. package/examples/extensions/peer-mesh.ts +450 -0
  79. package/examples/extensions/pi-bridge/index.ts +87 -2
  80. package/examples/extensions/questionnaire.ts +249 -0
  81. package/examples/extensions/tmux-pane.ts +307 -0
  82. package/examples/extensions/web-access.ts +327 -0
  83. package/package.json +9 -1
  84. package/dist/extensions/overlay-agent.d.ts +0 -11
  85. package/dist/extensions/overlay-agent.js +0 -43
  86. 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
- // ── agent-sh context injected via tool promptGuidelines + promptSnippet ──
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
  };