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.
- package/README.md +14 -21
- package/dist/agent/agent-loop.d.ts +43 -3
- package/dist/agent/agent-loop.js +811 -128
- package/dist/agent/conversation-state.d.ts +72 -21
- package/dist/agent/conversation-state.js +357 -150
- package/dist/agent/history-file.d.ts +13 -4
- package/dist/agent/history-file.js +110 -36
- package/dist/agent/nuclear-form.d.ts +28 -3
- package/dist/agent/nuclear-form.js +84 -3
- package/dist/agent/skills.d.ts +2 -4
- package/dist/agent/skills.js +10 -4
- package/dist/agent/subagent.d.ts +23 -0
- package/dist/agent/subagent.js +53 -11
- package/dist/agent/system-prompt.d.ts +34 -1
- package/dist/agent/system-prompt.js +96 -47
- package/dist/agent/token-budget.d.ts +5 -4
- package/dist/agent/token-budget.js +14 -19
- package/dist/agent/tool-protocol.d.ts +23 -1
- package/dist/agent/tool-protocol.js +169 -4
- package/dist/agent/tools/bash.js +3 -3
- package/dist/agent/tools/edit-file.js +9 -6
- package/dist/agent/tools/glob.js +4 -2
- package/dist/agent/tools/grep.js +27 -3
- package/dist/agent/tools/ls.js +5 -6
- package/dist/agent/types.d.ts +1 -1
- package/dist/context-manager.d.ts +17 -0
- package/dist/context-manager.js +37 -4
- package/dist/core.js +27 -6
- package/dist/event-bus.d.ts +59 -2
- package/dist/executor.d.ts +4 -3
- package/dist/executor.js +18 -15
- package/dist/extension-loader.js +50 -13
- package/dist/extensions/agent-backend.d.ts +8 -7
- package/dist/extensions/agent-backend.js +69 -48
- package/dist/extensions/index.js +0 -1
- package/dist/extensions/slash-commands.js +14 -9
- package/dist/extensions/tui-renderer.js +62 -78
- package/dist/index.js +25 -6
- package/dist/settings.d.ts +36 -5
- package/dist/settings.js +53 -9
- package/dist/shell/input-handler.d.ts +2 -1
- package/dist/shell/input-handler.js +82 -73
- package/dist/shell/shell.js +19 -2
- package/dist/types.d.ts +12 -0
- package/dist/utils/ansi.d.ts +5 -0
- package/dist/utils/ansi.js +1 -1
- package/dist/utils/compositor.d.ts +5 -0
- package/dist/utils/compositor.js +31 -3
- package/dist/utils/diff-renderer.d.ts +9 -0
- package/dist/utils/diff-renderer.js +221 -143
- package/dist/utils/diff.d.ts +21 -2
- package/dist/utils/diff.js +165 -89
- package/dist/utils/handler-registry.d.ts +5 -0
- package/dist/utils/handler-registry.js +6 -0
- package/dist/utils/line-editor.d.ts +11 -1
- package/dist/utils/line-editor.js +44 -5
- package/dist/utils/tool-display.d.ts +1 -1
- package/dist/utils/tool-display.js +4 -4
- package/examples/extensions/ash-acp-bridge/src/index.ts +4 -1
- package/examples/extensions/ash-mcp-bridge/index.ts +13 -3
- package/examples/extensions/claude-code-bridge/index.ts +198 -51
- package/examples/extensions/claude-code-bridge/package.json +1 -0
- package/examples/extensions/interactive-prompts.ts +39 -25
- package/examples/extensions/overlay-agent.ts +3 -3
- package/examples/extensions/peer-mesh.ts +115 -0
- package/examples/extensions/pi-bridge/index.ts +2 -2
- package/examples/extensions/questionnaire.ts +16 -5
- package/examples/extensions/subagents.ts +19 -4
- package/examples/extensions/terminal-buffer.ts +163 -0
- package/examples/extensions/user-shell.ts +136 -0
- package/examples/extensions/web-access.ts +8 -0
- package/package.json +36 -2
- package/dist/agent/tools/display.d.ts +0 -13
- package/dist/agent/tools/display.js +0 -70
- package/dist/agent/tools/user-shell.d.ts +0 -13
- package/dist/agent/tools/user-shell.js +0 -87
- package/dist/extensions/terminal-buffer.d.ts +0 -14
- 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
|
|
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
|
|
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
|
|
24
|
-
import
|
|
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: [
|
|
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-
|
|
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 === "
|
|
187
|
-
const
|
|
188
|
-
if (
|
|
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
|
|
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
|
|
214
|
-
|
|
215
|
-
|
|
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
|
});
|
|
@@ -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(
|
|
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
|
-
|
|
92
|
-
|
|
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 "
|
|
20
|
-
import type { RenderSurface } from "
|
|
21
|
-
import { FloatingPanel } from "
|
|
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 "
|
|
27
|
-
import type { EventBus } from "
|
|
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
|
-
"
|
|
64
|
-
"
|
|
65
|
-
"
|
|
66
|
-
"
|
|
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: {
|