agent-sh 0.9.0 → 0.10.1

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 (88) hide show
  1. package/README.md +25 -30
  2. package/dist/agent/agent-loop.d.ts +43 -6
  3. package/dist/agent/agent-loop.js +817 -157
  4. package/dist/agent/conversation-state.d.ts +72 -21
  5. package/dist/agent/conversation-state.js +364 -151
  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 +10 -13
  17. package/dist/agent/token-budget.js +6 -46
  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 -2
  26. package/dist/context-manager.d.ts +16 -19
  27. package/dist/context-manager.js +48 -152
  28. package/dist/core.js +27 -6
  29. package/dist/event-bus.d.ts +59 -3
  30. package/dist/executor.d.ts +4 -3
  31. package/dist/executor.js +18 -15
  32. package/dist/extension-loader.js +75 -17
  33. package/dist/extensions/agent-backend.d.ts +8 -7
  34. package/dist/extensions/agent-backend.js +72 -50
  35. package/dist/extensions/index.js +0 -2
  36. package/dist/extensions/slash-commands.js +14 -9
  37. package/dist/extensions/tui-renderer.js +67 -80
  38. package/dist/index.js +25 -6
  39. package/dist/settings.d.ts +39 -16
  40. package/dist/settings.js +51 -11
  41. package/dist/shell/input-handler.d.ts +2 -1
  42. package/dist/shell/input-handler.js +84 -76
  43. package/dist/shell/shell.js +19 -2
  44. package/dist/types.d.ts +15 -0
  45. package/dist/utils/ansi.d.ts +7 -0
  46. package/dist/utils/ansi.js +69 -8
  47. package/dist/utils/box-frame.js +8 -2
  48. package/dist/utils/compositor.d.ts +5 -0
  49. package/dist/utils/compositor.js +31 -3
  50. package/dist/utils/diff-renderer.d.ts +9 -0
  51. package/dist/utils/diff-renderer.js +221 -143
  52. package/dist/utils/diff.d.ts +21 -2
  53. package/dist/utils/diff.js +165 -89
  54. package/dist/utils/handler-registry.d.ts +5 -0
  55. package/dist/utils/handler-registry.js +6 -0
  56. package/dist/utils/line-editor.d.ts +11 -1
  57. package/dist/utils/line-editor.js +44 -5
  58. package/dist/utils/markdown.js +23 -8
  59. package/dist/utils/package-version.d.ts +1 -0
  60. package/dist/utils/package-version.js +10 -0
  61. package/dist/utils/shell-output-spill.d.ts +2 -0
  62. package/dist/utils/shell-output-spill.js +81 -0
  63. package/dist/utils/tool-display.d.ts +1 -1
  64. package/dist/utils/tool-display.js +4 -4
  65. package/examples/extensions/ash-acp-bridge/src/index.ts +4 -1
  66. package/examples/extensions/ash-mcp-bridge/index.ts +13 -3
  67. package/examples/extensions/claude-code-bridge/README.md +14 -0
  68. package/examples/extensions/claude-code-bridge/index.ts +204 -145
  69. package/examples/extensions/claude-code-bridge/package.json +1 -0
  70. package/examples/extensions/interactive-prompts.ts +39 -25
  71. package/examples/extensions/overlay-agent.ts +3 -3
  72. package/examples/extensions/peer-mesh.ts +115 -0
  73. package/examples/extensions/pi-bridge/README.md +16 -0
  74. package/examples/extensions/pi-bridge/index.ts +9 -155
  75. package/examples/extensions/questionnaire.ts +16 -5
  76. package/examples/extensions/subagents.ts +19 -4
  77. package/examples/extensions/terminal-buffer.ts +163 -0
  78. package/examples/extensions/user-shell.ts +136 -0
  79. package/examples/extensions/web-access.ts +8 -0
  80. package/package.json +36 -2
  81. package/dist/agent/tools/display.d.ts +0 -13
  82. package/dist/agent/tools/display.js +0 -70
  83. package/dist/agent/tools/user-shell.d.ts +0 -13
  84. package/dist/agent/tools/user-shell.js +0 -87
  85. package/dist/extensions/shell-recall.d.ts +0 -9
  86. package/dist/extensions/shell-recall.js +0 -8
  87. package/dist/extensions/terminal-buffer.d.ts +0 -14
  88. package/dist/extensions/terminal-buffer.js +0 -134
@@ -65,6 +65,17 @@ export default async function activate(ctx: any): Promise<void> {
65
65
  }
66
66
  }
67
67
 
68
+ // Contribute connected servers to the startup banner
69
+ bus.onPipe("banner:collect", (e) => {
70
+ if (connected.length > 0) {
71
+ e.sections.push({
72
+ label: "MCP Servers",
73
+ items: connected.map((s) => s.name),
74
+ });
75
+ }
76
+ return e;
77
+ });
78
+
68
79
  // Clean up on exit
69
80
  bus.on("app:quit", () => {
70
81
  for (const server of connected) {
@@ -84,6 +95,7 @@ async function connectServer(
84
95
  command: config.command,
85
96
  args: config.args,
86
97
  env: { ...process.env, ...config.env } as Record<string, string>,
98
+ stderr: "pipe",
87
99
  });
88
100
 
89
101
  const client = new Client({ name: `ash-${name}`, version: "0.1.0" });
@@ -146,9 +158,7 @@ async function connectServer(
146
158
  });
147
159
  }
148
160
 
149
- ctx.bus.emit("ui:info", {
150
- message: `mcp-bridge: "${name}" connected (${tools.length} tools)`,
151
- });
161
+ // ui:info suppressed — connection is silent by default
152
162
 
153
163
  return { name, client, transport };
154
164
  }
@@ -33,3 +33,17 @@ Or switch at runtime:
33
33
 
34
34
  - `ANTHROPIC_API_KEY` must be set in your environment
35
35
  - Claude Code manages its own model selection — no model configuration needed in agent-sh
36
+
37
+ ## What this bridge is
38
+
39
+ A pure protocol translator between the Claude Agent SDK's event stream and agent-sh's bus events. Claude Code uses its own built-in tools exactly as the SDK ships them (`Read`, `Edit`, `Write`, `Bash`, `Glob`, `Grep`). The bridge adds no tools of its own.
40
+
41
+ ## What this bridge intentionally does NOT bundle
42
+
43
+ Three PTY-access tools are left out on purpose:
44
+
45
+ - `terminal_read` — observe the user's live terminal screen
46
+ - `terminal_keys` — send keystrokes to the user's PTY
47
+ - `user_shell` — run commands in the user's live shell with lasting `cd`/`export`/`source` effects
48
+
49
+ These are opt-in capabilities that belong in their own extensions. If you want any of them with Claude Code, write a companion extension that uses the SDK's `tool()` + `createSdkMcpServer()` to expose them as MCP tools, and extend the bridge (or fork it) to attach that MCP server to the SDK's `query()` options.
@@ -2,148 +2,76 @@
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
6
- * handles its own model selection, tool execution, and permissions.
5
+ * session. Claude Code handles its own model selection, tool execution, and
6
+ * permissions the bridge is a pure protocol translator between the SDK's
7
+ * event stream and agent-sh's bus events.
7
8
  *
8
- * Setup:
9
- * npm install @anthropic-ai/claude-agent-sdk
9
+ * PTY-access tools (`terminal_read`, `terminal_keys`, `user_shell`) are
10
+ * intentionally NOT bundled here. If you want Claude Code to observe or
11
+ * drive the user's live terminal, load a companion extension that
12
+ * registers those tools as MCP tools the SDK can consume.
13
+ *
14
+ * Setup (from repo root):
15
+ * npm run build && npm link # register local agent-sh globally
16
+ * cd examples/extensions/claude-code-bridge
17
+ * npm install && npm link agent-sh # link local dev copy
10
18
  *
11
19
  * Usage:
12
20
  * agent-sh -e examples/extensions/claude-code-bridge
13
21
  *
14
22
  * Requires: Claude Code CLI installed and authenticated (claude login).
15
23
  */
16
- import {
17
- query,
18
- tool,
19
- createSdkMcpServer,
20
- type Query,
21
- } from "@anthropic-ai/claude-agent-sdk";
22
- import { z } from "zod";
23
- import type { ExtensionContext } from "../../src/types.js";
24
- import type { EventBus } from "../../src/event-bus.js";
25
-
26
- // ── Helpers ──────────────────────────────────────────────────────
27
- function interpretEscapes(str: string): string {
28
- return str.replace(/\\(x[0-9a-fA-F]{2}|r|n|t|\\|0)/g, (_, seq: string) => {
29
- if (seq === "r") return "\r";
30
- if (seq === "n") return "\n";
31
- if (seq === "t") return "\t";
32
- if (seq === "\\") return "\\";
33
- if (seq === "0") return "\0";
34
- if (seq.startsWith("x")) return String.fromCharCode(parseInt(seq.slice(1), 16));
35
- return seq;
36
- });
37
- }
38
-
39
- function settle(ms = 100): Promise<void> {
40
- return new Promise((resolve) => setTimeout(resolve, ms));
41
- }
42
-
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
- // ── terminal_read MCP tool ────────────────────────────────────────
77
- function createTerminalReadTool(ctx: ExtensionContext) {
78
- return tool(
79
- "terminal_read",
80
- "Read the current terminal screen contents. Returns clean text (ANSI stripped) " +
81
- "with cursor position and whether an alternate-screen program (vim, htop, less) is active. " +
82
- "Use this to see what the user sees before sending keystrokes with terminal_keys.",
83
- {},
84
- async () => {
85
- const tb = ctx.terminalBuffer;
86
- if (!tb) return { content: [{ type: "text" as const, text: "terminal buffer not available" }] };
87
- const { text, altScreen, cursorX, cursorY } = tb.readScreen();
88
- const info = [
89
- altScreen ? "mode: alternate screen" : "mode: normal",
90
- `cursor: row=${cursorY} col=${cursorX}`,
91
- ].join(", ");
92
- return { content: [{ type: "text" as const, text: `[${info}]\n\n${text}` }] };
93
- },
94
- );
95
- }
96
-
97
- // ── terminal_keys MCP tool ───────────────────────────────────────
98
- function createTerminalKeysTool(bus: EventBus, ctx: ExtensionContext) {
99
- return tool(
100
- "terminal_keys",
101
- "Send keystrokes to the user's live terminal. The keys are written directly to the PTY " +
102
- "as if the user typed them. Use escape sequences for special keys:\n" +
103
- " - Escape: \\x1b - Enter: \\r - Tab: \\t\n" +
104
- " - Ctrl+C: \\x03 - Arrow keys: \\x1b[A/B/C/D - Backspace: \\x7f\n" +
105
- "Example: to quit vim without saving, send keys=\"\\x1b:q!\\r\".\n" +
106
- "Always call terminal_read after sending keys to verify the result.",
107
- {
108
- keys: z.string().describe("Keystrokes to send (use \\x1b for Escape, \\r for Enter, etc.)"),
109
- settle_ms: z.number().optional().describe("Wait time in ms after sending keys (default: 150)"),
110
- },
111
- async (args) => {
112
- const keys = interpretEscapes(args.keys);
113
- const settleMs = args.settle_ms ?? 150;
114
- bus.emit("shell:stdout-show", {});
115
- process.stdout.write("\n");
116
- bus.emit("shell:pty-write", { data: keys });
117
- await settle(settleMs);
118
-
119
- const tb = ctx.terminalBuffer;
120
- if (!tb) return { content: [{ type: "text" as const, text: "Keys sent." }] };
121
- const { text, altScreen, cursorX, cursorY } = tb.readScreen();
122
- const info = [
123
- altScreen ? "mode: alternate screen" : "mode: normal",
124
- `cursor: row=${cursorY} col=${cursorX}`,
125
- ].join(", ");
126
- return { content: [{ type: "text" as const, text: `Keys sent. Screen after:\n[${info}]\n\n${text}` }] };
127
- },
128
- );
129
- }
24
+ import { query, type Query } from "@anthropic-ai/claude-agent-sdk";
25
+ import { readFile } from "node:fs/promises";
26
+ import { resolve } from "node:path";
27
+ import type { ExtensionContext } from "agent-sh/types";
28
+ import { computeDiff, type DiffResult } from "agent-sh/utils/diff";
130
29
 
131
30
  // ── Extension entry point ─────────────────────────────────────────
132
31
  export default function activate(ctx: ExtensionContext): void {
133
32
  const { bus } = ctx;
134
33
 
135
- const shellTool = createUserShellTool(bus);
136
- const termReadTool = createTerminalReadTool(ctx);
137
- const termKeysTool = createTerminalKeysTool(bus, ctx);
138
- const shellServer = createSdkMcpServer({
139
- name: "agent-sh",
140
- version: "1.0.0",
141
- tools: [shellTool, termReadTool, termKeysTool],
142
- });
143
-
144
34
  let activeQuery: Query | null = null;
145
35
  const listeners: Array<{ event: string; fn: Function }> = [];
146
36
 
37
+ // ── Tool display helpers ────────────────────────────────────────
38
+
39
+ /** Map Claude Code tool names to agent-sh display kinds. */
40
+ function toolKind(name: string): string {
41
+ if (name === "Read") return "read";
42
+ if (name === "Edit") return "edit";
43
+ if (name === "Write") return "write";
44
+ if (name === "Glob" || name === "Grep") return "search";
45
+ if (name === "Bash") return "execute";
46
+ return "execute";
47
+ }
48
+
49
+ /** Map Claude Code tool names to agent-sh display icons. */
50
+ function toolIcon(name: string): string | undefined {
51
+ if (name === "Read") return "◆";
52
+ if (name === "Edit") return "✎";
53
+ if (name === "Write") return "✎";
54
+ if (name === "Glob" || name === "Grep") return "⌕";
55
+ return undefined;
56
+ }
57
+
58
+ /** Extract file locations from tool input args. */
59
+ function toolLocations(input: Record<string, unknown>): { path: string; line?: number | null }[] | undefined {
60
+ const raw = input.file_path ?? input.path;
61
+ if (typeof raw !== "string") return undefined;
62
+ const line = (input.line_number ?? input.line ?? input.offset) as number | undefined;
63
+ return [{ path: raw, line: line ?? null }];
64
+ }
65
+
66
+ /** Format a compact display string for a tool call. */
67
+ function formatToolCall(name: string, input: Record<string, unknown>): string {
68
+ const str = (v: unknown) => typeof v === "string" ? v : "";
69
+ if (name === "Bash") return `$ ${str(input.command)}`;
70
+ if (name === "Read" || name === "Edit" || name === "Write") return str(input.file_path ?? input.path);
71
+ if (name === "Grep" || name === "Glob") return `${str(input.pattern)} ${str(input.path)}`.trim();
72
+ return name;
73
+ }
74
+
147
75
  const wireListeners = () => {
148
76
  const onSubmit = async ({ query: userQuery }: any) => {
149
77
  bus.emit("agent:query", { query: userQuery });
@@ -151,6 +79,14 @@ export default function activate(ctx: ExtensionContext): void {
151
79
 
152
80
  let fullResponseText = "";
153
81
  let streamed = false;
82
+ /** Track in-flight tool calls so we can emit tool-completed when results arrive. */
83
+ const pendingTools = new Map<string, { name: string; kind: string; input?: Record<string, unknown> }>();
84
+ /** Tool input JSON being streamed via input_json_delta events. */
85
+ const inputBuffers = new Map<number, string>();
86
+ /** Tool metadata per content block index (for correlating deltas). */
87
+ const blockMeta = new Map<number, { name: string; id: string }>();
88
+ /** Pre-edit file snapshots for diff display (Edit/Write tools). */
89
+ const fileSnapshots = new Map<string, string | null>();
154
90
 
155
91
  try {
156
92
  activeQuery = query({
@@ -162,17 +98,9 @@ export default function activate(ctx: ExtensionContext): void {
162
98
  preset: "claude_code",
163
99
  append:
164
100
  "You are running inside agent-sh, a terminal wrapper.\n" +
165
- "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.",
101
+ "Use your standard tools (Read, Edit, Write, Bash, Glob, Grep) for investigation.",
168
102
  },
169
- mcpServers: { "agent-sh": shellServer },
170
- allowedTools: [
171
- "mcp__agent-sh__user_shell",
172
- "mcp__agent-sh__terminal_read",
173
- "mcp__agent-sh__terminal_keys",
174
- "Read", "Edit", "Write", "Bash", "Glob", "Grep",
175
- ],
103
+ allowedTools: ["Read", "Edit", "Write", "Bash", "Glob", "Grep"],
176
104
  permissionMode: "acceptEdits",
177
105
  includePartialMessages: true,
178
106
  },
@@ -183,16 +111,56 @@ export default function activate(ctx: ExtensionContext): void {
183
111
  case "stream_event": {
184
112
  streamed = true;
185
113
  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) {
114
+ if (event.type === "content_block_start") {
115
+ const cb = (event as any).content_block;
116
+ if (cb?.type === "tool_use") {
117
+ blockMeta.set(event.index, { name: cb.name, id: cb.id });
118
+ inputBuffers.set(event.index, "");
119
+ }
120
+ } else if (event.type === "content_block_delta") {
121
+ const delta = (event as any).delta;
122
+ if (delta?.type === "text_delta" && delta.text) {
189
123
  bus.emitTransform("agent:response-chunk", {
190
124
  blocks: [{ type: "text" as const, text: delta.text }],
191
125
  });
192
126
  fullResponseText += delta.text;
193
- } else if (delta.type === "thinking_delta" && delta.thinking) {
127
+ } else if (delta?.type === "thinking_delta" && delta.thinking) {
194
128
  bus.emit("agent:thinking-chunk", { text: delta.thinking });
129
+ } else if (delta?.type === "input_json_delta" && delta.partial_json != null) {
130
+ // Accumulate tool input JSON as it streams in
131
+ const buf = inputBuffers.get(event.index) ?? "";
132
+ inputBuffers.set(event.index, buf + delta.partial_json);
195
133
  }
134
+ } else if (event.type === "content_block_stop") {
135
+ const meta = blockMeta.get(event.index);
136
+ const inputJson = inputBuffers.get(event.index);
137
+ if (meta && inputJson != null) {
138
+ blockMeta.delete(event.index);
139
+ inputBuffers.delete(event.index);
140
+
141
+ let input: Record<string, unknown> = {};
142
+ try { input = JSON.parse(inputJson || "{}"); } catch {}
143
+
144
+ const kind = toolKind(meta.name);
145
+ bus.emit("agent:tool-started", {
146
+ title: meta.name,
147
+ toolCallId: meta.id,
148
+ kind,
149
+ icon: toolIcon(meta.name),
150
+ locations: toolLocations(input),
151
+ rawInput: input,
152
+ displayDetail: formatToolCall(meta.name, input),
153
+ });
154
+ pendingTools.set(meta.id, { name: meta.name, kind, input });
155
+
156
+ // Snapshot file content before Edit/Write modifies it
157
+ if ((meta.name === "Edit" || meta.name === "Write") && typeof (input as any).file_path === "string") {
158
+ const absPath = resolve(process.cwd(), (input as any).file_path);
159
+ readFile(absPath, "utf-8")
160
+ .then(content => fileSnapshots.set(meta.id, content))
161
+ .catch(() => fileSnapshots.set(meta.id, null)); // file doesn't exist yet
162
+ }
163
+ }
196
164
  }
197
165
  break;
198
166
  }
@@ -206,24 +174,115 @@ export default function activate(ctx: ExtensionContext): void {
206
174
  blocks: [{ type: "text" as const, text: b.text }],
207
175
  });
208
176
  fullResponseText += b.text;
209
- } else if (b.type === "tool_use") {
177
+ } else if (b.type === "tool_use" && !streamed) {
178
+ // Non-streamed fallback: emit tool-started from full message
179
+ const input = (b.input ?? {}) as Record<string, unknown>;
180
+ const kind = toolKind(b.name);
210
181
  bus.emit("agent:tool-started", {
211
182
  title: b.name,
212
183
  toolCallId: b.id,
213
- kind: b.name.includes("shell") || b.name === "Bash"
214
- ? "execute"
215
- : "read",
184
+ kind,
185
+ icon: toolIcon(b.name),
186
+ locations: toolLocations(input),
187
+ rawInput: input,
188
+ displayDetail: formatToolCall(b.name, input),
216
189
  });
190
+ pendingTools.set(b.id, { name: b.name, kind, input });
191
+
192
+ // Snapshot file content before Edit/Write modifies it
193
+ if ((b.name === "Edit" || b.name === "Write") && typeof (input as any).file_path === "string") {
194
+ const absPath = resolve(process.cwd(), (input as any).file_path);
195
+ readFile(absPath, "utf-8")
196
+ .then(content => fileSnapshots.set(b.id, content))
197
+ .catch(() => fileSnapshots.set(b.id, null));
198
+ }
217
199
  }
218
200
  }
219
201
  break;
220
202
  }
221
203
 
204
+ case "user": {
205
+ // Tool results come back as user messages with tool_result content blocks
206
+ const msg = message.message as any;
207
+ if (msg?.content && Array.isArray(msg.content)) {
208
+ for (const block of msg.content) {
209
+ if (block.type === "tool_result") {
210
+ const toolUseId = block.tool_use_id as string;
211
+ const pending = pendingTools.get(toolUseId);
212
+ if (!pending) continue;
213
+ pendingTools.delete(toolUseId);
214
+
215
+ const isError = !!block.is_error;
216
+ const content = typeof block.content === "string"
217
+ ? block.content
218
+ : Array.isArray(block.content)
219
+ ? block.content.map((c: any) => c.text ?? JSON.stringify(c)).join("\n")
220
+ : "";
221
+
222
+ // Compute diff for Edit/Write tools
223
+ let resultDisplay: { summary?: string; body?: { kind: "diff"; diff: DiffResult; filePath: string } } | undefined;
224
+ if (!isError && (pending.name === "Edit" || pending.name === "Write")) {
225
+ const oldContent = fileSnapshots.get(toolUseId);
226
+ fileSnapshots.delete(toolUseId);
227
+ const filePath = (pending.input as any)?.file_path as string | undefined;
228
+ if (filePath) {
229
+ const absPath = resolve(process.cwd(), filePath);
230
+ try {
231
+ const newContent = await readFile(absPath, "utf-8");
232
+ const diff = computeDiff(oldContent, newContent);
233
+ if (!diff.isIdentical) {
234
+ const summary = diff.isNewFile
235
+ ? `+${diff.added}`
236
+ : `+${diff.added} -${diff.removed}`;
237
+ resultDisplay = {
238
+ summary,
239
+ body: { kind: "diff", diff, filePath: absPath },
240
+ };
241
+ }
242
+ } catch { /* file may not exist after failed edit */ }
243
+ }
244
+ } else {
245
+ fileSnapshots.delete(toolUseId);
246
+ }
247
+
248
+ const exitCode = isError ? 1 : 0;
249
+ bus.emitTransform("agent:tool-completed", {
250
+ toolCallId: toolUseId,
251
+ exitCode,
252
+ rawOutput: content,
253
+ kind: pending.kind,
254
+ resultDisplay,
255
+ });
256
+ bus.emit("agent:tool-output", {
257
+ tool: pending.name,
258
+ output: content,
259
+ exitCode,
260
+ });
261
+ }
262
+ }
263
+ }
264
+ break;
265
+ }
266
+
267
+ case "tool_progress":
268
+ // Tool still running — nothing to do, TUI spinner already active
269
+ break;
270
+
222
271
  case "result":
223
272
  break;
224
273
  }
225
274
  }
226
275
 
276
+ // Emit completion for any tools still pending (edge case: interrupted query)
277
+ for (const [id, pending] of pendingTools) {
278
+ bus.emitTransform("agent:tool-completed", {
279
+ toolCallId: id,
280
+ exitCode: 0,
281
+ rawOutput: "",
282
+ kind: pending.kind,
283
+ });
284
+ }
285
+
227
286
  bus.emitTransform("agent:response-done", {
228
287
  response: fullResponseText,
229
288
  });
@@ -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 {