agent-sh 0.9.0 → 0.10.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 (78) hide show
  1. package/README.md +14 -21
  2. package/dist/agent/agent-loop.d.ts +43 -3
  3. package/dist/agent/agent-loop.js +811 -128
  4. package/dist/agent/conversation-state.d.ts +72 -21
  5. package/dist/agent/conversation-state.js +357 -150
  6. package/dist/agent/history-file.d.ts +13 -4
  7. package/dist/agent/history-file.js +110 -36
  8. package/dist/agent/nuclear-form.d.ts +28 -3
  9. package/dist/agent/nuclear-form.js +84 -3
  10. package/dist/agent/skills.d.ts +2 -4
  11. package/dist/agent/skills.js +10 -4
  12. package/dist/agent/subagent.d.ts +23 -0
  13. package/dist/agent/subagent.js +53 -11
  14. package/dist/agent/system-prompt.d.ts +34 -1
  15. package/dist/agent/system-prompt.js +96 -47
  16. package/dist/agent/token-budget.d.ts +5 -4
  17. package/dist/agent/token-budget.js +14 -19
  18. package/dist/agent/tool-protocol.d.ts +23 -1
  19. package/dist/agent/tool-protocol.js +169 -4
  20. package/dist/agent/tools/bash.js +3 -3
  21. package/dist/agent/tools/edit-file.js +9 -6
  22. package/dist/agent/tools/glob.js +4 -2
  23. package/dist/agent/tools/grep.js +27 -3
  24. package/dist/agent/tools/ls.js +5 -6
  25. package/dist/agent/types.d.ts +1 -1
  26. package/dist/context-manager.d.ts +17 -0
  27. package/dist/context-manager.js +37 -4
  28. package/dist/core.js +27 -6
  29. package/dist/event-bus.d.ts +59 -2
  30. package/dist/executor.d.ts +4 -3
  31. package/dist/executor.js +18 -15
  32. package/dist/extension-loader.js +50 -13
  33. package/dist/extensions/agent-backend.d.ts +8 -7
  34. package/dist/extensions/agent-backend.js +69 -48
  35. package/dist/extensions/index.js +0 -1
  36. package/dist/extensions/slash-commands.js +14 -9
  37. package/dist/extensions/tui-renderer.js +62 -78
  38. package/dist/index.js +25 -6
  39. package/dist/settings.d.ts +36 -5
  40. package/dist/settings.js +53 -9
  41. package/dist/shell/input-handler.d.ts +2 -1
  42. package/dist/shell/input-handler.js +82 -73
  43. package/dist/shell/shell.js +19 -2
  44. package/dist/types.d.ts +12 -0
  45. package/dist/utils/ansi.d.ts +5 -0
  46. package/dist/utils/ansi.js +1 -1
  47. package/dist/utils/compositor.d.ts +5 -0
  48. package/dist/utils/compositor.js +31 -3
  49. package/dist/utils/diff-renderer.d.ts +9 -0
  50. package/dist/utils/diff-renderer.js +221 -143
  51. package/dist/utils/diff.d.ts +21 -2
  52. package/dist/utils/diff.js +165 -89
  53. package/dist/utils/handler-registry.d.ts +5 -0
  54. package/dist/utils/handler-registry.js +6 -0
  55. package/dist/utils/line-editor.d.ts +11 -1
  56. package/dist/utils/line-editor.js +44 -5
  57. package/dist/utils/tool-display.d.ts +1 -1
  58. package/dist/utils/tool-display.js +4 -4
  59. package/examples/extensions/ash-acp-bridge/src/index.ts +4 -1
  60. package/examples/extensions/ash-mcp-bridge/index.ts +13 -3
  61. package/examples/extensions/claude-code-bridge/index.ts +198 -51
  62. package/examples/extensions/claude-code-bridge/package.json +1 -0
  63. package/examples/extensions/interactive-prompts.ts +39 -25
  64. package/examples/extensions/overlay-agent.ts +3 -3
  65. package/examples/extensions/peer-mesh.ts +115 -0
  66. package/examples/extensions/pi-bridge/index.ts +2 -2
  67. package/examples/extensions/questionnaire.ts +16 -5
  68. package/examples/extensions/subagents.ts +19 -4
  69. package/examples/extensions/terminal-buffer.ts +163 -0
  70. package/examples/extensions/user-shell.ts +136 -0
  71. package/examples/extensions/web-access.ts +8 -0
  72. package/package.json +36 -2
  73. package/dist/agent/tools/display.d.ts +0 -13
  74. package/dist/agent/tools/display.js +0 -70
  75. package/dist/agent/tools/user-shell.d.ts +0 -13
  76. package/dist/agent/tools/user-shell.js +0 -87
  77. package/dist/extensions/terminal-buffer.d.ts +0 -14
  78. package/dist/extensions/terminal-buffer.js +0 -134
@@ -2,11 +2,13 @@
2
2
  * Claude Code bridge — runs Claude Code Agent SDK in-process as agent-sh's backend.
3
3
  *
4
4
  * Uses the official @anthropic-ai/claude-agent-sdk to spawn a Claude Code
5
- * session with a custom user_shell MCP tool for PTY access. Claude Code
5
+ * session with custom MCP tools for PTY access. Claude Code
6
6
  * handles its own model selection, tool execution, and permissions.
7
7
  *
8
- * Setup:
9
- * npm install @anthropic-ai/claude-agent-sdk
8
+ * Setup (from repo root):
9
+ * npm run build && npm link # register local agent-sh globally
10
+ * cd examples/extensions/claude-code-bridge
11
+ * npm install && npm link agent-sh # link local dev copy
10
12
  *
11
13
  * Usage:
12
14
  * agent-sh -e examples/extensions/claude-code-bridge
@@ -20,8 +22,11 @@ import {
20
22
  type Query,
21
23
  } from "@anthropic-ai/claude-agent-sdk";
22
24
  import { z } from "zod";
23
- import type { ExtensionContext } from "../../src/types.js";
24
- import type { EventBus } from "../../src/event-bus.js";
25
+ import { readFile } from "node:fs/promises";
26
+ import { resolve } from "node:path";
27
+ import type { ExtensionContext } from "agent-sh/types";
28
+ import type { EventBus } from "agent-sh/event-bus";
29
+ import { computeDiff, type DiffResult } from "agent-sh/utils/diff";
25
30
 
26
31
  // ── Helpers ──────────────────────────────────────────────────────
27
32
  function interpretEscapes(str: string): string {
@@ -40,39 +45,6 @@ function settle(ms = 100): Promise<void> {
40
45
  return new Promise((resolve) => setTimeout(resolve, ms));
41
46
  }
42
47
 
43
- // ── user_shell MCP tool ───────────────────────────────────────────
44
- function createUserShellTool(bus: EventBus) {
45
- let liveCwd = process.cwd();
46
- bus.on("shell:cwd-change", ({ cwd }) => { liveCwd = cwd; });
47
-
48
- return tool(
49
- "user_shell",
50
- "Run a command with lasting effects in the user's live shell (cd, export, " +
51
- "install packages, start servers) or show output the user wants to see. " +
52
- "Set return_output=true only if you need to inspect the result.",
53
- {
54
- command: z.string().describe("Command to execute in user's shell"),
55
- return_output: z.boolean().optional().describe(
56
- "Whether to return the command output. Default false.",
57
- ),
58
- },
59
- async (args) => {
60
- const result = await bus.emitPipeAsync("shell:exec-request", {
61
- command: args.command,
62
- output: "",
63
- cwd: liveCwd,
64
- done: false,
65
- });
66
-
67
- const text = args.return_output
68
- ? result.output || "(no output)"
69
- : "Command executed.";
70
-
71
- return { content: [{ type: "text" as const, text }] };
72
- },
73
- );
74
- }
75
-
76
48
  // ── terminal_read MCP tool ────────────────────────────────────────
77
49
  function createTerminalReadTool(ctx: ExtensionContext) {
78
50
  return tool(
@@ -132,18 +104,56 @@ function createTerminalKeysTool(bus: EventBus, ctx: ExtensionContext) {
132
104
  export default function activate(ctx: ExtensionContext): void {
133
105
  const { bus } = ctx;
134
106
 
135
- const shellTool = createUserShellTool(bus);
136
107
  const termReadTool = createTerminalReadTool(ctx);
137
108
  const termKeysTool = createTerminalKeysTool(bus, ctx);
138
109
  const shellServer = createSdkMcpServer({
139
110
  name: "agent-sh",
140
111
  version: "1.0.0",
141
- tools: [shellTool, termReadTool, termKeysTool],
112
+ tools: [termReadTool, termKeysTool],
142
113
  });
143
114
 
144
115
  let activeQuery: Query | null = null;
145
116
  const listeners: Array<{ event: string; fn: Function }> = [];
146
117
 
118
+ // ── Tool display helpers ────────────────────────────────────────
119
+
120
+ /** Map Claude Code tool names to agent-sh display kinds. */
121
+ function toolKind(name: string): string {
122
+ if (name === "Read" || name.includes("terminal_read")) return "read";
123
+ if (name === "Edit") return "edit";
124
+ if (name === "Write") return "write";
125
+ if (name === "Glob" || name === "Grep") return "search";
126
+ if (name === "Bash" || name.includes("terminal_keys")) return "execute";
127
+ return "execute";
128
+ }
129
+
130
+ /** Map Claude Code tool names to agent-sh display icons. */
131
+ function toolIcon(name: string): string | undefined {
132
+ if (name === "Read") return "◆";
133
+ if (name === "Edit") return "✎";
134
+ if (name === "Write") return "✎";
135
+ if (name === "Glob" || name === "Grep") return "⌕";
136
+ return undefined;
137
+ }
138
+
139
+ /** Extract file locations from tool input args. */
140
+ function toolLocations(input: Record<string, unknown>): { path: string; line?: number | null }[] | undefined {
141
+ const raw = input.file_path ?? input.path;
142
+ if (typeof raw !== "string") return undefined;
143
+ const line = (input.line_number ?? input.line ?? input.offset) as number | undefined;
144
+ return [{ path: raw, line: line ?? null }];
145
+ }
146
+
147
+ /** Format a compact display string for a tool call. */
148
+ function formatToolCall(name: string, input: Record<string, unknown>): string {
149
+ const str = (v: unknown) => typeof v === "string" ? v : "";
150
+ if (name === "Bash") return `$ ${str(input.command)}`;
151
+ if (name === "Read" || name === "Edit" || name === "Write") return str(input.file_path ?? input.path);
152
+ if (name === "Grep" || name === "Glob") return `${str(input.pattern)} ${str(input.path)}`.trim();
153
+ if (name.includes("terminal_keys")) return str(input.keys);
154
+ return name;
155
+ }
156
+
147
157
  const wireListeners = () => {
148
158
  const onSubmit = async ({ query: userQuery }: any) => {
149
159
  bus.emit("agent:query", { query: userQuery });
@@ -151,6 +161,14 @@ export default function activate(ctx: ExtensionContext): void {
151
161
 
152
162
  let fullResponseText = "";
153
163
  let streamed = false;
164
+ /** Track in-flight tool calls so we can emit tool-completed when results arrive. */
165
+ const pendingTools = new Map<string, { name: string; kind: string; input?: Record<string, unknown> }>();
166
+ /** Tool input JSON being streamed via input_json_delta events. */
167
+ const inputBuffers = new Map<number, string>();
168
+ /** Tool metadata per content block index (for correlating deltas). */
169
+ const blockMeta = new Map<number, { name: string; id: string }>();
170
+ /** Pre-edit file snapshots for diff display (Edit/Write tools). */
171
+ const fileSnapshots = new Map<string, string | null>();
154
172
 
155
173
  try {
156
174
  activeQuery = query({
@@ -163,12 +181,10 @@ export default function activate(ctx: ExtensionContext): void {
163
181
  append:
164
182
  "You are running inside agent-sh, a terminal wrapper.\n" +
165
183
  "Use your standard tools (Read, Edit, Write, Bash, Glob, Grep) for investigation.\n" +
166
- "Use mcp__agent-sh__user_shell to run commands in the user's live shell when they ask to see output or need lasting effects (cd, install, start servers).\n" +
167
- "Default to standard tools. Use user_shell when the user is the intended audience for the output or the command has real effects.",
184
+ "Use mcp__agent-sh__terminal_read and mcp__agent-sh__terminal_keys to observe and interact with the user's live terminal.",
168
185
  },
169
186
  mcpServers: { "agent-sh": shellServer },
170
187
  allowedTools: [
171
- "mcp__agent-sh__user_shell",
172
188
  "mcp__agent-sh__terminal_read",
173
189
  "mcp__agent-sh__terminal_keys",
174
190
  "Read", "Edit", "Write", "Bash", "Glob", "Grep",
@@ -183,16 +199,56 @@ export default function activate(ctx: ExtensionContext): void {
183
199
  case "stream_event": {
184
200
  streamed = true;
185
201
  const event = message.event;
186
- if (event.type === "content_block_delta") {
187
- const delta = event.delta as any;
188
- if (delta.type === "text_delta" && delta.text) {
202
+ if (event.type === "content_block_start") {
203
+ const cb = (event as any).content_block;
204
+ if (cb?.type === "tool_use") {
205
+ blockMeta.set(event.index, { name: cb.name, id: cb.id });
206
+ inputBuffers.set(event.index, "");
207
+ }
208
+ } else if (event.type === "content_block_delta") {
209
+ const delta = (event as any).delta;
210
+ if (delta?.type === "text_delta" && delta.text) {
189
211
  bus.emitTransform("agent:response-chunk", {
190
212
  blocks: [{ type: "text" as const, text: delta.text }],
191
213
  });
192
214
  fullResponseText += delta.text;
193
- } else if (delta.type === "thinking_delta" && delta.thinking) {
215
+ } else if (delta?.type === "thinking_delta" && delta.thinking) {
194
216
  bus.emit("agent:thinking-chunk", { text: delta.thinking });
217
+ } else if (delta?.type === "input_json_delta" && delta.partial_json != null) {
218
+ // Accumulate tool input JSON as it streams in
219
+ const buf = inputBuffers.get(event.index) ?? "";
220
+ inputBuffers.set(event.index, buf + delta.partial_json);
195
221
  }
222
+ } else if (event.type === "content_block_stop") {
223
+ const meta = blockMeta.get(event.index);
224
+ const inputJson = inputBuffers.get(event.index);
225
+ if (meta && inputJson != null) {
226
+ blockMeta.delete(event.index);
227
+ inputBuffers.delete(event.index);
228
+
229
+ let input: Record<string, unknown> = {};
230
+ try { input = JSON.parse(inputJson || "{}"); } catch {}
231
+
232
+ const kind = toolKind(meta.name);
233
+ bus.emit("agent:tool-started", {
234
+ title: meta.name,
235
+ toolCallId: meta.id,
236
+ kind,
237
+ icon: toolIcon(meta.name),
238
+ locations: toolLocations(input),
239
+ rawInput: input,
240
+ displayDetail: formatToolCall(meta.name, input),
241
+ });
242
+ pendingTools.set(meta.id, { name: meta.name, kind, input });
243
+
244
+ // Snapshot file content before Edit/Write modifies it
245
+ if ((meta.name === "Edit" || meta.name === "Write") && typeof (input as any).file_path === "string") {
246
+ const absPath = resolve(process.cwd(), (input as any).file_path);
247
+ readFile(absPath, "utf-8")
248
+ .then(content => fileSnapshots.set(meta.id, content))
249
+ .catch(() => fileSnapshots.set(meta.id, null)); // file doesn't exist yet
250
+ }
251
+ }
196
252
  }
197
253
  break;
198
254
  }
@@ -206,24 +262,115 @@ export default function activate(ctx: ExtensionContext): void {
206
262
  blocks: [{ type: "text" as const, text: b.text }],
207
263
  });
208
264
  fullResponseText += b.text;
209
- } else if (b.type === "tool_use") {
265
+ } else if (b.type === "tool_use" && !streamed) {
266
+ // Non-streamed fallback: emit tool-started from full message
267
+ const input = (b.input ?? {}) as Record<string, unknown>;
268
+ const kind = toolKind(b.name);
210
269
  bus.emit("agent:tool-started", {
211
270
  title: b.name,
212
271
  toolCallId: b.id,
213
- kind: b.name.includes("shell") || b.name === "Bash"
214
- ? "execute"
215
- : "read",
272
+ kind,
273
+ icon: toolIcon(b.name),
274
+ locations: toolLocations(input),
275
+ rawInput: input,
276
+ displayDetail: formatToolCall(b.name, input),
216
277
  });
278
+ pendingTools.set(b.id, { name: b.name, kind, input });
279
+
280
+ // Snapshot file content before Edit/Write modifies it
281
+ if ((b.name === "Edit" || b.name === "Write") && typeof (input as any).file_path === "string") {
282
+ const absPath = resolve(process.cwd(), (input as any).file_path);
283
+ readFile(absPath, "utf-8")
284
+ .then(content => fileSnapshots.set(b.id, content))
285
+ .catch(() => fileSnapshots.set(b.id, null));
286
+ }
217
287
  }
218
288
  }
219
289
  break;
220
290
  }
221
291
 
292
+ case "user": {
293
+ // Tool results come back as user messages with tool_result content blocks
294
+ const msg = message.message as any;
295
+ if (msg?.content && Array.isArray(msg.content)) {
296
+ for (const block of msg.content) {
297
+ if (block.type === "tool_result") {
298
+ const toolUseId = block.tool_use_id as string;
299
+ const pending = pendingTools.get(toolUseId);
300
+ if (!pending) continue;
301
+ pendingTools.delete(toolUseId);
302
+
303
+ const isError = !!block.is_error;
304
+ const content = typeof block.content === "string"
305
+ ? block.content
306
+ : Array.isArray(block.content)
307
+ ? block.content.map((c: any) => c.text ?? JSON.stringify(c)).join("\n")
308
+ : "";
309
+
310
+ // Compute diff for Edit/Write tools
311
+ let resultDisplay: { summary?: string; body?: { kind: "diff"; diff: DiffResult; filePath: string } } | undefined;
312
+ if (!isError && (pending.name === "Edit" || pending.name === "Write")) {
313
+ const oldContent = fileSnapshots.get(toolUseId);
314
+ fileSnapshots.delete(toolUseId);
315
+ const filePath = (pending.input as any)?.file_path as string | undefined;
316
+ if (filePath) {
317
+ const absPath = resolve(process.cwd(), filePath);
318
+ try {
319
+ const newContent = await readFile(absPath, "utf-8");
320
+ const diff = computeDiff(oldContent, newContent);
321
+ if (!diff.isIdentical) {
322
+ const summary = diff.isNewFile
323
+ ? `+${diff.added}`
324
+ : `+${diff.added} -${diff.removed}`;
325
+ resultDisplay = {
326
+ summary,
327
+ body: { kind: "diff", diff, filePath: absPath },
328
+ };
329
+ }
330
+ } catch { /* file may not exist after failed edit */ }
331
+ }
332
+ } else {
333
+ fileSnapshots.delete(toolUseId);
334
+ }
335
+
336
+ const exitCode = isError ? 1 : 0;
337
+ bus.emitTransform("agent:tool-completed", {
338
+ toolCallId: toolUseId,
339
+ exitCode,
340
+ rawOutput: content,
341
+ kind: pending.kind,
342
+ resultDisplay,
343
+ });
344
+ bus.emit("agent:tool-output", {
345
+ tool: pending.name,
346
+ output: content,
347
+ exitCode,
348
+ });
349
+ }
350
+ }
351
+ }
352
+ break;
353
+ }
354
+
355
+ case "tool_progress":
356
+ // Tool still running — nothing to do, TUI spinner already active
357
+ break;
358
+
222
359
  case "result":
223
360
  break;
224
361
  }
225
362
  }
226
363
 
364
+ // Emit completion for any tools still pending (edge case: interrupted query)
365
+ for (const [id, pending] of pendingTools) {
366
+ bus.emitTransform("agent:tool-completed", {
367
+ toolCallId: id,
368
+ exitCode: 0,
369
+ rawOutput: "",
370
+ kind: pending.kind,
371
+ });
372
+ }
373
+
227
374
  bus.emitTransform("agent:response-done", {
228
375
  response: fullResponseText,
229
376
  });
@@ -6,6 +6,7 @@
6
6
  "main": "index.ts",
7
7
  "dependencies": {
8
8
  "@anthropic-ai/claude-agent-sdk": "^0.2.0",
9
+ "agent-sh": "^0.9.0",
9
10
  "zod": "^4.0.0"
10
11
  }
11
12
  }
@@ -18,9 +18,44 @@ import { palette as p } from "agent-sh/utils/palette.js";
18
18
  import type { ExtensionContext } from "agent-sh/types";
19
19
  import type { ToolUI } from "agent-sh/agent/types.js";
20
20
 
21
- export default function activate({ bus }: ExtensionContext) {
21
+ export default function activate(ctx: ExtensionContext) {
22
22
  let autoApproveWrites = false;
23
23
 
24
+ // Advise the TUI diff renderer to add permission prompt framing.
25
+ // This replaces the default plain diff box with one that has a warning
26
+ // border and key hints, so only one diff box is shown (not two).
27
+ ctx.advise("tui:render-diff", (next, filePath: string, diff: any, width: number) => {
28
+ const boxW = Math.min(84, width);
29
+ const contentW = boxW - 4;
30
+ const MAX_DISPLAY = 25;
31
+
32
+ const stats = diff.isNewFile
33
+ ? `(+${diff.added} lines)`
34
+ : `(+${diff.added} / -${diff.removed})`;
35
+ const title = diff.isNewFile
36
+ ? `new: ${filePath} ${stats}`
37
+ : `${filePath} ${stats}`;
38
+
39
+ const diffLines = renderDiff(diff, {
40
+ width: contentW,
41
+ filePath,
42
+ maxLines: MAX_DISPLAY,
43
+ trueColor: true,
44
+ mode: "unified",
45
+ });
46
+ const content = ["", ...diffLines.slice(1), ""];
47
+
48
+ return renderBoxFrame(content, {
49
+ width: boxW,
50
+ style: "rounded",
51
+ borderColor: p.warning,
52
+ title,
53
+ footer: [` ${p.bold}[y] Apply [n] Skip [a] Don't ask again${p.reset}`],
54
+ });
55
+ });
56
+
57
+ const { bus } = ctx;
58
+
24
59
  bus.onPipeAsync("permission:request", async (payload) => {
25
60
  const ui = payload.ui as ToolUI | undefined;
26
61
  if (!ui) return payload;
@@ -82,36 +117,15 @@ async function handleToolCall(payload: any, ui: ToolUI) {
82
117
  }
83
118
 
84
119
  async function handleFileWrite(payload: any, ui: ToolUI) {
85
- const diff = payload.metadata.diff;
86
- const filePath = payload.metadata.path ?? payload.title;
87
-
88
120
  const answer = await ui.custom<"approve" | "approve_all" | "reject">({
89
121
  render(width) {
90
122
  const boxW = Math.min(84, width);
91
- const contentW = boxW - 4;
92
- const MAX_DISPLAY = 25;
93
-
94
- const stats = diff.isNewFile
95
- ? `(+${diff.added} lines)`
96
- : `(+${diff.added} / -${diff.removed})`;
97
- const title = diff.isNewFile
98
- ? `new: ${filePath} ${stats}`
99
- : `${filePath} ${stats}`;
100
-
101
- const diffLines = renderDiff(diff, {
102
- width: contentW,
103
- filePath,
104
- maxLines: MAX_DISPLAY,
105
- trueColor: true,
106
- mode: "unified",
107
- });
108
- const content = ["", ...diffLines.slice(1), ""];
109
-
110
- return renderBoxFrame(content, {
123
+ // Just show the prompt actions — the diff itself was already rendered
124
+ // by our advise on "tui:render-diff".
125
+ return renderBoxFrame([], {
111
126
  width: boxW,
112
127
  style: "rounded",
113
128
  borderColor: p.warning,
114
- title,
115
129
  footer: [` ${p.bold}[y] Apply [n] Skip [a] Don't ask again${p.reset}`],
116
130
  });
117
131
  },
@@ -16,9 +16,9 @@
16
16
  *
17
17
  * Requires: npm install @xterm/headless@5.5.0 @xterm/addon-serialize@0.13.0
18
18
  */
19
- import type { ExtensionContext, RemoteSession } from "../../src/types.js";
20
- import type { RenderSurface } from "../../src/utils/compositor.js";
21
- import { FloatingPanel } from "../../src/utils/floating-panel.js";
19
+ import type { ExtensionContext, RemoteSession } from "agent-sh/types";
20
+ import type { RenderSurface } from "agent-sh/utils/compositor";
21
+ import { FloatingPanel } from "agent-sh/utils/floating-panel";
22
22
 
23
23
  /** Adapt a FloatingPanel to the RenderSurface interface. */
24
24
  function createPanelSurface(panel: FloatingPanel): RenderSurface {
@@ -268,6 +268,54 @@ export default function activate(ctx: ExtensionContext): void {
268
268
  define("peer:context-search", (query: string) => contextManager.search(query));
269
269
  server.expose("peer:context-search");
270
270
 
271
+ // ── Inter-peer messaging ───────────────────────────────────
272
+
273
+ interface InboxEntry { from: string; text: string; at: number; }
274
+ const inbox: InboxEntry[] = [];
275
+ const INBOX_MAX = 100;
276
+
277
+ const pending: InboxEntry[] = [];
278
+
279
+ define("peer:message", (from: string, text: string) => {
280
+ if (typeof from !== "string" || typeof text !== "string") {
281
+ throw new Error("peer:message requires (from: string, text: string)");
282
+ }
283
+ const entry: InboxEntry = { from, text, at: Date.now() };
284
+ inbox.push(entry);
285
+ if (inbox.length > INBOX_MAX) inbox.splice(0, inbox.length - INBOX_MAX);
286
+ pending.push(entry);
287
+ bus.emit("peer:message-received", entry);
288
+ bus.emit("ui:info", { message: `[peer ${from}] ${text}` });
289
+ setTimeout(drainPending, 100);
290
+ return { ok: true };
291
+ });
292
+ server.expose("peer:message");
293
+
294
+ // Drain pending peer messages by injecting a synthetic user turn.
295
+ // Only one submission per processing cycle — wait for agent:processing-done
296
+ // before releasing the next batch.
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
+ }
313
+
314
+ bus.on("agent:processing-done", () => {
315
+ busy = false;
316
+ setTimeout(drainPending, 100);
317
+ });
318
+
271
319
  // ── Handler registry API (for other extensions) ────────────
272
320
 
273
321
  define("peer:discover", () => server.discover());
@@ -412,6 +460,71 @@ export default function activate(ctx: ExtensionContext): void {
412
460
  }),
413
461
  });
414
462
 
463
+ registerTool({
464
+ name: "peer_send",
465
+ description: "Send a text message to another running agent-sh peer. The peer will see it in their UI and on their next turn.",
466
+ input_schema: {
467
+ 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
+ },
472
+ required: ["peer_id", "text"],
473
+ },
474
+ showOutput: false,
475
+ getDisplayInfo: () => ({ kind: "write" as const }),
476
+ formatCall: (args) => `peer ${args.peer_id}: "${String(args.text).slice(0, 40)}"`,
477
+
478
+ async execute(args) {
479
+ try {
480
+ await server.call(args.peer_id as string, "peer:message", ctx.instanceId, args.text as string);
481
+ return { content: `Sent to peer ${args.peer_id}.`, exitCode: 0, isError: false };
482
+ } catch (e) {
483
+ return {
484
+ content: `Failed to send: ${e instanceof Error ? e.message : String(e)}`,
485
+ exitCode: 1,
486
+ isError: true,
487
+ };
488
+ }
489
+ },
490
+
491
+ formatResult: (_args, result) => ({
492
+ summary: result.isError ? "failed" : "sent",
493
+ }),
494
+ });
495
+
496
+ registerTool({
497
+ name: "peer_inbox",
498
+ description: "Read recent messages received from other peers via peer_send.",
499
+ input_schema: {
500
+ type: "object",
501
+ properties: {
502
+ count: { type: "number", description: "Number of recent messages to return (default: 20)." },
503
+ },
504
+ required: [],
505
+ },
506
+ showOutput: false,
507
+ getDisplayInfo: () => ({ kind: "read" as const }),
508
+ formatCall: () => "reading inbox",
509
+
510
+ async execute(args) {
511
+ const n = (args.count as number) || 20;
512
+ const recent = inbox.slice(-n);
513
+ if (recent.length === 0) {
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
+ });
520
+ return { content: lines.join("\n"), exitCode: 0, isError: false };
521
+ },
522
+
523
+ formatResult: (_args, result) => ({
524
+ summary: result.content === "(inbox empty)" ? "empty" : `${result.content.split("\n").length} msg`,
525
+ }),
526
+ });
527
+
415
528
  // ── Slash command ──────────────────────────────────────────
416
529
 
417
530
  registerCommand("peers", "List running agent-sh peer instances", () => {
@@ -435,6 +548,8 @@ export default function activate(ctx: ExtensionContext): void {
435
548
  "- `peer_terminal` to see what's on another terminal's screen",
436
549
  "- `peer_history` to see what commands they ran recently",
437
550
  "- `peer_search` to search their shell context by keyword",
551
+ "- `peer_send` to deliver a text message to another peer (appears in their UI)",
552
+ "- `peer_inbox` to read messages other peers have sent you",
438
553
  "When the user references 'the other terminal' or 'my other shell', use these tools.",
439
554
  ].join("\n"));
440
555
 
@@ -23,8 +23,8 @@ import {
23
23
  SessionManager,
24
24
  } from "@mariozechner/pi-coding-agent";
25
25
  import { Type } from "@sinclair/typebox";
26
- import type { ExtensionContext } from "../../src/types.js";
27
- import type { EventBus } from "../../src/event-bus.js";
26
+ import type { ExtensionContext } from "agent-sh/types";
27
+ import type { EventBus } from "agent-sh/event-bus";
28
28
 
29
29
  // ── Helpers ──────────────────────────────────────────────────────
30
30
  function interpretEscapes(str: string): string {
@@ -55,15 +55,26 @@ interface QuestionnaireResult {
55
55
 
56
56
  // ── Extension ────────────────────────────────────────────────────
57
57
 
58
- export default function activate({ registerTool }: ExtensionContext) {
58
+ export default function activate({ registerTool, registerInstruction }: ExtensionContext) {
59
+ registerInstruction("questionnaire", [
60
+ "# When to use the questionnaire tool",
61
+ "ALWAYS use the `questionnaire` tool instead of asking the user a question in plain text when:",
62
+ "- You need the user to choose from specific options (e.g. frameworks, approaches, yes/no decisions)",
63
+ "- You are unsure about a preference and can enumerate reasonable choices",
64
+ "- The user's answer determines a significant branch in your behavior",
65
+ "",
66
+ "Do NOT just list options in your reply prose — call the questionnaire tool so the user can select interactively.",
67
+ "This applies to single questions too (yes/no, pick-one).",
68
+ ].join("\n"));
69
+
59
70
  registerTool({
60
71
  name: "questionnaire",
61
72
  displayName: "questionnaire",
62
73
  description:
63
- "Ask the user one or more questions with selectable options. " +
64
- "Use for clarifying requirements, getting preferences, or confirming decisions. " +
65
- "For single questions, shows a simple option list. " +
66
- "For multiple questions, shows a tab-based interface.",
74
+ "Present the user with one or more questions with selectable options and wait for their interactive response. " +
75
+ "PREFER THIS over asking questions in plain text — it gives the user an interactive picker (arrow keys + enter). " +
76
+ "Use for: clarifying requirements, getting preferences, confirming decisions, choosing between options. " +
77
+ "Single question → simple option list. Multiple questions tab-based multi-page interface.",
67
78
  input_schema: {
68
79
  type: "object",
69
80
  properties: {