agent-sh 0.8.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 (106) hide show
  1. package/README.md +27 -43
  2. package/dist/agent/agent-loop.d.ts +69 -6
  3. package/dist/agent/agent-loop.js +954 -153
  4. package/dist/agent/conversation-state.d.ts +74 -21
  5. package/dist/agent/conversation-state.js +361 -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 +88 -6
  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 +37 -5
  15. package/dist/agent/system-prompt.js +100 -67
  16. package/dist/{token-budget.d.ts → agent/token-budget.d.ts} +5 -4
  17. package/dist/{token-budget.js → agent/token-budget.js} +15 -20
  18. package/dist/agent/tool-protocol.d.ts +105 -0
  19. package/dist/agent/tool-protocol.js +551 -0
  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 +22 -2
  26. package/dist/context-manager.d.ts +17 -0
  27. package/dist/context-manager.js +37 -4
  28. package/dist/core.d.ts +7 -7
  29. package/dist/core.js +99 -196
  30. package/dist/event-bus.d.ts +85 -2
  31. package/dist/event-bus.js +20 -1
  32. package/dist/executor.d.ts +4 -3
  33. package/dist/executor.js +18 -15
  34. package/dist/extension-loader.d.ts +5 -0
  35. package/dist/extension-loader.js +143 -19
  36. package/dist/extensions/agent-backend.d.ts +14 -0
  37. package/dist/extensions/agent-backend.js +188 -0
  38. package/dist/extensions/command-suggest.d.ts +3 -3
  39. package/dist/extensions/command-suggest.js +4 -3
  40. package/dist/extensions/index.d.ts +19 -0
  41. package/dist/extensions/index.js +24 -0
  42. package/dist/extensions/slash-commands.d.ts +1 -1
  43. package/dist/extensions/slash-commands.js +30 -10
  44. package/dist/extensions/tui-renderer.js +117 -113
  45. package/dist/index.js +39 -26
  46. package/dist/settings.d.ts +40 -3
  47. package/dist/settings.js +57 -10
  48. package/dist/{input-handler.d.ts → shell/input-handler.d.ts} +3 -2
  49. package/dist/{input-handler.js → shell/input-handler.js} +111 -85
  50. package/dist/{output-parser.d.ts → shell/output-parser.d.ts} +1 -1
  51. package/dist/{output-parser.js → shell/output-parser.js} +1 -1
  52. package/dist/{shell.d.ts → shell/shell.d.ts} +8 -2
  53. package/dist/{shell.js → shell/shell.js} +39 -8
  54. package/dist/types.d.ts +61 -10
  55. package/dist/utils/ansi.d.ts +5 -0
  56. package/dist/utils/ansi.js +1 -1
  57. package/dist/utils/compositor.d.ts +67 -0
  58. package/dist/utils/compositor.js +116 -0
  59. package/dist/utils/diff-renderer.d.ts +9 -0
  60. package/dist/utils/diff-renderer.js +312 -146
  61. package/dist/utils/diff.d.ts +21 -2
  62. package/dist/utils/diff.js +165 -89
  63. package/dist/utils/floating-panel.d.ts +2 -0
  64. package/dist/utils/floating-panel.js +30 -14
  65. package/dist/utils/handler-registry.d.ts +31 -10
  66. package/dist/utils/handler-registry.js +58 -16
  67. package/dist/utils/line-editor.d.ts +33 -3
  68. package/dist/utils/line-editor.js +221 -44
  69. package/dist/utils/markdown.d.ts +1 -0
  70. package/dist/utils/markdown.js +1 -1
  71. package/dist/utils/message-utils.d.ts +35 -0
  72. package/dist/utils/message-utils.js +75 -0
  73. package/dist/utils/terminal-buffer.d.ts +5 -1
  74. package/dist/utils/terminal-buffer.js +18 -2
  75. package/dist/utils/tool-display.d.ts +1 -1
  76. package/dist/utils/tool-display.js +4 -4
  77. package/dist/utils/tool-interactive.d.ts +12 -0
  78. package/dist/utils/tool-interactive.js +53 -0
  79. package/examples/extensions/ash-acp-bridge/README.md +39 -0
  80. package/examples/extensions/ash-acp-bridge/package.json +23 -0
  81. package/examples/extensions/ash-acp-bridge/src/index.ts +574 -0
  82. package/examples/extensions/ash-acp-bridge/tsconfig.json +14 -0
  83. package/examples/extensions/ash-mcp-bridge/README.md +72 -0
  84. package/examples/extensions/ash-mcp-bridge/index.ts +164 -0
  85. package/examples/extensions/ash-mcp-bridge/package.json +9 -0
  86. package/examples/extensions/claude-code-bridge/index.ts +198 -51
  87. package/examples/extensions/claude-code-bridge/package.json +1 -0
  88. package/examples/extensions/interactive-prompts.ts +98 -112
  89. package/examples/extensions/overlay-agent.ts +84 -38
  90. package/examples/extensions/peer-mesh.ts +565 -0
  91. package/examples/extensions/pi-bridge/index.ts +2 -2
  92. package/examples/extensions/questionnaire.ts +260 -0
  93. package/examples/extensions/subagents.ts +19 -4
  94. package/examples/extensions/terminal-buffer.ts +32 -53
  95. package/examples/extensions/tmux-pane.ts +307 -0
  96. package/examples/extensions/user-shell.ts +136 -0
  97. package/examples/extensions/web-access.ts +335 -0
  98. package/package.json +44 -2
  99. package/dist/agent/tools/display.d.ts +0 -13
  100. package/dist/agent/tools/display.js +0 -70
  101. package/dist/agent/tools/user-shell.d.ts +0 -13
  102. package/dist/agent/tools/user-shell.js +0 -87
  103. package/dist/extensions/overlay-agent.d.ts +0 -14
  104. package/dist/extensions/overlay-agent.js +0 -147
  105. package/dist/extensions/terminal-buffer.d.ts +0 -14
  106. package/dist/extensions/terminal-buffer.js +0 -125
@@ -0,0 +1,14 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "outDir": "dist",
7
+ "rootDir": "src",
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "declaration": true,
11
+ "skipLibCheck": true
12
+ },
13
+ "include": ["src"]
14
+ }
@@ -0,0 +1,72 @@
1
+ # ash-mcp-bridge
2
+
3
+ Connects any MCP (Model Context Protocol) server to ash. Spawns servers as child processes over stdio, discovers their tools, and registers each as a native ash tool.
4
+
5
+ ## Setup
6
+
7
+ ```bash
8
+ cp -r examples/extensions/ash-mcp-bridge ~/.agent-sh/extensions/
9
+ cd ~/.agent-sh/extensions/ash-mcp-bridge && npm install
10
+ ```
11
+
12
+ ## Configuration
13
+
14
+ Add server definitions to `~/.agent-sh/settings.json`:
15
+
16
+ ```json
17
+ {
18
+ "mcp-bridge": {
19
+ "servers": {
20
+ "vision": {
21
+ "command": "npx",
22
+ "args": ["-y", "@z_ai/mcp-server"],
23
+ "env": {
24
+ "Z_AI_API_KEY": "your-key",
25
+ "Z_AI_MODE": "ZAI"
26
+ }
27
+ }
28
+ }
29
+ }
30
+ }
31
+ ```
32
+
33
+ Each server entry:
34
+
35
+ | Field | Type | Description |
36
+ |-------|------|-------------|
37
+ | `command` | `string` | Executable to spawn (e.g. `npx`, `node`) |
38
+ | `args` | `string[]` | Command arguments |
39
+ | `env` | `Record<string, string>` | Extra environment variables (merged with `process.env`) |
40
+
41
+ ## How it works
42
+
43
+ On activation, the extension:
44
+
45
+ 1. Reads `mcp-bridge.servers` from settings
46
+ 2. Spawns each server as a child process with stdio transport
47
+ 3. Connects via the MCP SDK client
48
+ 4. Calls `listTools()` to discover available tools
49
+ 5. Registers each tool as `mcp_{server}_{tool}` (e.g. `mcp_vision_image_analysis`)
50
+
51
+ Tools are then available to the agent like any built-in tool.
52
+
53
+ ## Example: Z.AI Vision
54
+
55
+ ```json
56
+ {
57
+ "mcp-bridge": {
58
+ "servers": {
59
+ "vision": {
60
+ "command": "npx",
61
+ "args": ["-y", "@z_ai/mcp-server"],
62
+ "env": {
63
+ "Z_AI_API_KEY": "your-key",
64
+ "Z_AI_MODE": "ZAI"
65
+ }
66
+ }
67
+ }
68
+ }
69
+ }
70
+ ```
71
+
72
+ This gives the agent access to tools like `mcp_vision_image_analysis`, `mcp_vision_ui_to_artifact`, `mcp_vision_extract_text_from_screenshot`, etc.
@@ -0,0 +1,164 @@
1
+ /**
2
+ * MCP Bridge — connects external MCP servers to agent-sh.
3
+ *
4
+ * Spawns MCP servers as child processes, discovers their tools,
5
+ * and registers each tool as an agent-sh ToolDefinition.
6
+ *
7
+ * Configure in ~/.agent-sh/settings.json:
8
+ *
9
+ * {
10
+ * "extensions": ["./path/to/mcp-bridge"],
11
+ * "mcp-bridge": {
12
+ * "servers": {
13
+ * "vision": {
14
+ * "command": "npx",
15
+ * "args": ["-y", "@z_ai/mcp-server"],
16
+ * "env": {
17
+ * "Z_AI_API_KEY": "your-key",
18
+ * "Z_AI_MODE": "ZAI"
19
+ * }
20
+ * }
21
+ * }
22
+ * }
23
+ * }
24
+ */
25
+
26
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
27
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
28
+
29
+ interface McpServerConfig {
30
+ command: string;
31
+ args?: string[];
32
+ env?: Record<string, string>;
33
+ }
34
+
35
+ interface McpBridgeSettings {
36
+ servers: Record<string, McpServerConfig>;
37
+ }
38
+
39
+ interface ConnectedServer {
40
+ name: string;
41
+ client: Client;
42
+ transport: StdioClientTransport;
43
+ }
44
+
45
+ export default async function activate(ctx: any): Promise<void> {
46
+ const { bus } = ctx;
47
+
48
+ const settings = ctx.getExtensionSettings("mcp-bridge", {
49
+ servers: {},
50
+ }) as McpBridgeSettings;
51
+
52
+ const serverEntries = Object.entries(settings.servers);
53
+ if (serverEntries.length === 0) return;
54
+
55
+ const connected: ConnectedServer[] = [];
56
+
57
+ for (const [name, config] of serverEntries) {
58
+ try {
59
+ const server = await connectServer(name, config, ctx);
60
+ connected.push(server);
61
+ } catch (err: any) {
62
+ bus.emit("ui:info", {
63
+ message: `mcp-bridge: failed to connect "${name}": ${err.message}`,
64
+ });
65
+ }
66
+ }
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
+
79
+ // Clean up on exit
80
+ bus.on("app:quit", () => {
81
+ for (const server of connected) {
82
+ try {
83
+ server.transport.close();
84
+ } catch {}
85
+ }
86
+ });
87
+ }
88
+
89
+ async function connectServer(
90
+ name: string,
91
+ config: McpServerConfig,
92
+ ctx: any,
93
+ ): Promise<ConnectedServer> {
94
+ const transport = new StdioClientTransport({
95
+ command: config.command,
96
+ args: config.args,
97
+ env: { ...process.env, ...config.env } as Record<string, string>,
98
+ stderr: "pipe",
99
+ });
100
+
101
+ const client = new Client({ name: `ash-${name}`, version: "0.1.0" });
102
+ await client.connect(transport);
103
+
104
+ // Discover and register tools
105
+ const { tools } = await client.listTools();
106
+ for (const tool of tools) {
107
+ const toolName = `mcp_${name}_${tool.name}`;
108
+ ctx.registerTool({
109
+ name: toolName,
110
+ displayName: tool.name,
111
+ description: `[${name}] ${tool.description ?? ""}`,
112
+ input_schema: tool.inputSchema as Record<string, unknown>,
113
+
114
+ async execute(args: Record<string, unknown>) {
115
+ try {
116
+ const result = await client.callTool({
117
+ name: tool.name,
118
+ arguments: args,
119
+ });
120
+
121
+ const text = (result.content as any[])
122
+ .map((c: any) => {
123
+ if (c.type === "text") return c.text;
124
+ if (c.type === "image") return `[image: ${c.mimeType}]`;
125
+ return JSON.stringify(c);
126
+ })
127
+ .join("\n");
128
+
129
+ return {
130
+ content: text,
131
+ exitCode: result.isError ? 1 : 0,
132
+ isError: !!result.isError,
133
+ };
134
+ } catch (err: any) {
135
+ return {
136
+ content: `MCP error: ${err.message}`,
137
+ exitCode: 1,
138
+ isError: true,
139
+ };
140
+ }
141
+ },
142
+
143
+ getDisplayInfo() {
144
+ return { kind: "execute" as const };
145
+ },
146
+
147
+ formatCall(args: Record<string, unknown>) {
148
+ // Show a compact summary of the args
149
+ const keys = Object.keys(args);
150
+ if (keys.length === 0) return tool.name;
151
+ const first = args[keys[0]];
152
+ const preview =
153
+ typeof first === "string"
154
+ ? first.slice(0, 60) + (first.length > 60 ? "…" : "")
155
+ : JSON.stringify(first).slice(0, 60);
156
+ return `${tool.name}: ${preview}`;
157
+ },
158
+ });
159
+ }
160
+
161
+ // ui:info suppressed — connection is silent by default
162
+
163
+ return { name, client, transport };
164
+ }
@@ -0,0 +1,9 @@
1
+ {
2
+ "name": "ash-mcp-bridge",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "type": "module",
6
+ "dependencies": {
7
+ "@modelcontextprotocol/sdk": "^1.12.1"
8
+ }
9
+ }
@@ -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
  }