agent-sh 0.14.1 → 0.14.3

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 (65) hide show
  1. package/dist/agent/agent-loop.d.ts +1 -1
  2. package/dist/agent/agent-loop.js +42 -31
  3. package/dist/agent/conversation-state.d.ts +3 -2
  4. package/dist/agent/conversation-state.js +20 -3
  5. package/dist/agent/events.d.ts +2 -0
  6. package/dist/agent/host-types.d.ts +3 -0
  7. package/dist/agent/index.js +2 -1
  8. package/dist/agent/subagent.d.ts +1 -1
  9. package/dist/agent/subagent.js +5 -1
  10. package/dist/agent/tool-protocol.d.ts +2 -2
  11. package/dist/agent/tool-protocol.js +5 -4
  12. package/dist/agent/tools/glob.d.ts +1 -1
  13. package/dist/agent/tools/glob.js +4 -2
  14. package/dist/agent/tools/grep.d.ts +1 -1
  15. package/dist/agent/tools/grep.js +4 -2
  16. package/dist/agent/tools/ls.d.ts +1 -1
  17. package/dist/agent/tools/ls.js +4 -2
  18. package/dist/agent/tools/read-file.d.ts +1 -1
  19. package/dist/agent/tools/read-file.js +30 -2
  20. package/dist/agent/types.d.ts +11 -1
  21. package/dist/agent/types.js +6 -1
  22. package/dist/cli/index.js +0 -0
  23. package/dist/core/index.d.ts +1 -1
  24. package/dist/core/settings.d.ts +3 -0
  25. package/dist/core/settings.js +2 -2
  26. package/dist/shell/events.d.ts +2 -0
  27. package/dist/shell/index.d.ts +6 -0
  28. package/dist/shell/index.js +10 -10
  29. package/dist/shell/output-parser.d.ts +11 -22
  30. package/dist/shell/output-parser.js +16 -34
  31. package/dist/shell/shell-context.d.ts +3 -6
  32. package/dist/shell/shell-context.js +15 -7
  33. package/dist/shell/shell.d.ts +4 -0
  34. package/dist/shell/shell.js +18 -30
  35. package/dist/shell/strategies/types.d.ts +6 -0
  36. package/dist/shell/strategies/zsh.js +7 -0
  37. package/dist/shell/terminal.d.ts +33 -0
  38. package/dist/shell/terminal.js +62 -0
  39. package/examples/extensions/ash-scheme/index.ts +2170 -0
  40. package/examples/extensions/ash-scheme/package.json +11 -0
  41. package/examples/extensions/ash-scheme-render.ts +58 -0
  42. package/examples/extensions/ashi/README.md +36 -26
  43. package/examples/extensions/ashi/package.json +9 -1
  44. package/examples/extensions/ashi/src/capture.ts +1 -0
  45. package/examples/extensions/ashi/src/cli.ts +53 -11
  46. package/examples/extensions/ashi/src/commands.ts +2 -20
  47. package/examples/extensions/ashi/src/compaction.ts +25 -96
  48. package/examples/extensions/ashi/src/components.ts +64 -166
  49. package/examples/extensions/ashi/src/default-schema-renderers.ts +232 -0
  50. package/examples/extensions/ashi/src/display-config.ts +21 -22
  51. package/examples/extensions/ashi/src/frontend.ts +355 -118
  52. package/examples/extensions/ashi/src/hooks.ts +47 -63
  53. package/examples/extensions/ashi/src/multi-session-store.ts +44 -3
  54. package/examples/extensions/ashi/src/schema.ts +386 -0
  55. package/examples/extensions/ashi/src/session-store.ts +115 -17
  56. package/examples/extensions/ashi/src/shell-mode.ts +52 -0
  57. package/examples/extensions/ashi/src/status-footer.ts +41 -6
  58. package/examples/extensions/ashi/src/theme.ts +2 -1
  59. package/examples/extensions/ashi-compact-llm.ts +93 -0
  60. package/examples/extensions/claude-code-bridge/index.ts +2 -0
  61. package/examples/extensions/opencode-bridge/index.ts +3 -0
  62. package/examples/extensions/opencode-provider.ts +252 -0
  63. package/examples/extensions/pi-bridge/index.ts +1 -0
  64. package/package.json +16 -1
  65. package/examples/extensions/ashi/src/default-renderers.ts +0 -171
@@ -39,12 +39,30 @@ export interface CompactionEntry {
39
39
  id: string;
40
40
  parentId: string;
41
41
  timestamp: number;
42
- summary: string;
42
+ summary?: string;
43
43
  firstKeptId: string;
44
44
  tokensBefore: number;
45
45
  }
46
46
 
47
- export type SessionEntry = SessionHeaderEntry | MessageEntry | CompactionEntry;
47
+ /** Omitted from buildMessages the agent already saw it via <shell_events>
48
+ * (or didn't, if private). The frontend replays it for scrollback fidelity. */
49
+ export interface ShellExchangeEntry {
50
+ type: "shell-exchange";
51
+ id: string;
52
+ parentId: string;
53
+ timestamp: number;
54
+ command: string;
55
+ output: string;
56
+ exitCode: number | null;
57
+ cwd?: string;
58
+ private?: boolean;
59
+ }
60
+
61
+ export type SessionEntry =
62
+ | SessionHeaderEntry
63
+ | MessageEntry
64
+ | CompactionEntry
65
+ | ShellExchangeEntry;
48
66
 
49
67
  export interface SessionMeta {
50
68
  name?: string;
@@ -55,8 +73,63 @@ export function newEntryId(): string {
55
73
  return crypto.randomBytes(4).toString("hex");
56
74
  }
57
75
 
58
- /** One session = one JSONL file (entries) + sidecar files for leaf & meta.
59
- * Tree is implicit via parentId pointers; entries kept in memory after load. */
76
+ function extractText(content: unknown): string {
77
+ if (typeof content === "string") return content;
78
+ if (Array.isArray(content)) {
79
+ return content.map((p) => {
80
+ if (typeof p === "string") return p;
81
+ const part = p as { text?: string; content?: string };
82
+ return part?.text ?? part?.content ?? "";
83
+ }).join(" ");
84
+ }
85
+ return "";
86
+ }
87
+
88
+ function snippet(text: string, max: number): string {
89
+ const cleaned = String(text ?? "").replace(/\s+/g, " ").trim();
90
+ if (cleaned.length <= max) return cleaned || "(empty)";
91
+ return cleaned.slice(0, max) + "…";
92
+ }
93
+
94
+ export function summarizeMessage(m: AgentMessage): string {
95
+ const role = m.role ?? "?";
96
+ if (role === "assistant" && Array.isArray(m.tool_calls) && m.tool_calls.length > 0) {
97
+ const tools = m.tool_calls.map((tc) => {
98
+ const name = tc.function?.name ?? "tool";
99
+ const args = tc.function?.arguments;
100
+ return args ? `${name}(${snippet(args, 200)})` : name;
101
+ }).join(", ");
102
+ const text = extractText(m.content);
103
+ const prefix = text ? `${snippet(text, 400)} → ` : "";
104
+ return `assistant: ${prefix}called ${tools}`;
105
+ }
106
+ if (role === "tool") {
107
+ const text = typeof m.content === "string" ? m.content : extractText(m.content);
108
+ const isErr = /^error\b|: error\b/i.test(text.slice(0, 200));
109
+ return `tool result: ${snippet(text, isErr ? 1000 : 400)}`;
110
+ }
111
+ if (role === "user") {
112
+ return `user: ${snippet(extractText(m.content), 1000)}`;
113
+ }
114
+ return `${role}: ${snippet(extractText(m.content), 500)}`;
115
+ }
116
+
117
+ /** For displayed user text. Loops because both wrappers can stack at the head. */
118
+ export function stripContextWrappers(content: string): string {
119
+ let out = content;
120
+ for (;;) {
121
+ const next = out.replace(/^\s*<(query_context|dynamic_context)>[\s\S]*?<\/\1>\s*/, "");
122
+ if (next === out) return out;
123
+ out = next;
124
+ }
125
+ }
126
+
127
+ export function renderEvictedSummary(evicted: AgentMessage[]): string {
128
+ const lines = evicted.map((m) => `- ${summarizeMessage(m)}`);
129
+ return `${lines.length} message(s) elided\n${lines.join("\n")}`;
130
+ }
131
+
132
+ /** Tree is implicit via parentId pointers; entries are kept in memory after load. */
60
133
  export class SessionStore {
61
134
  private entriesPath: string;
62
135
  private leafPath: string;
@@ -124,9 +197,6 @@ export class SessionStore {
124
197
  this.persistMeta();
125
198
  }
126
199
 
127
- /** Append messages as a chain of MessageEntry, each parented at the
128
- * previously appended id (starting from current leaf). Returns the new
129
- * entry ids in order. */
130
200
  async appendMessages(messages: AgentMessage[]): Promise<string[]> {
131
201
  if (messages.length === 0) return [];
132
202
  this.flushHeader();
@@ -152,7 +222,33 @@ export class SessionStore {
152
222
  return newIds;
153
223
  }
154
224
 
155
- async appendCompaction(summary: string, firstKeptId: string, tokensBefore: number): Promise<string> {
225
+ async appendShellExchange(e: {
226
+ command: string;
227
+ output: string;
228
+ exitCode: number | null;
229
+ cwd?: string;
230
+ private?: boolean;
231
+ }): Promise<string> {
232
+ this.flushHeader();
233
+ const entry: ShellExchangeEntry = {
234
+ type: "shell-exchange",
235
+ id: newEntryId(),
236
+ parentId: this.activeLeaf,
237
+ timestamp: Date.now(),
238
+ command: e.command,
239
+ output: e.output,
240
+ exitCode: e.exitCode,
241
+ ...(e.cwd !== undefined ? { cwd: e.cwd } : {}),
242
+ ...(e.private ? { private: true } : {}),
243
+ };
244
+ this.entries.set(entry.id, entry);
245
+ this.activeLeaf = entry.id;
246
+ await fsp.appendFile(this.entriesPath, JSON.stringify(entry) + "\n");
247
+ this.persistLeaf();
248
+ return entry.id;
249
+ }
250
+
251
+ async appendCompaction(firstKeptId: string, tokensBefore: number, summary?: string): Promise<string> {
156
252
  if (!this.entries.has(firstKeptId)) throw new Error(`firstKeptId unknown: ${firstKeptId}`);
157
253
  this.flushHeader();
158
254
  const e: CompactionEntry = {
@@ -160,9 +256,9 @@ export class SessionStore {
160
256
  id: newEntryId(),
161
257
  parentId: this.activeLeaf,
162
258
  timestamp: Date.now(),
163
- summary,
164
259
  firstKeptId,
165
260
  tokensBefore,
261
+ ...(summary !== undefined ? { summary } : {}),
166
262
  };
167
263
  this.entries.set(e.id, e);
168
264
  this.activeLeaf = e.id;
@@ -171,7 +267,7 @@ export class SessionStore {
171
267
  return e.id;
172
268
  }
173
269
 
174
- /** Walk parent pointers from a leaf back to the root. Returns oldest-first. */
270
+ /** Oldest-first walk from leaf to root. */
175
271
  getBranch(leafId: string = this.activeLeaf): SessionEntry[] {
176
272
  const out: SessionEntry[] = [];
177
273
  const seen = new Set<string>();
@@ -186,9 +282,7 @@ export class SessionStore {
186
282
  return out.reverse();
187
283
  }
188
284
 
189
- /** Reconstruct the live message array for the active leaf, honoring the
190
- * latest compaction on the branch (summary + kept tail). Mirrors pi's
191
- * buildSessionContext. */
285
+ /** Honors the latest compaction on the branch (summary + kept tail). */
192
286
  buildMessages(leafId: string = this.activeLeaf): AgentMessage[] {
193
287
  const branch = this.getBranch(leafId);
194
288
  let compactionIdx = -1;
@@ -203,9 +297,14 @@ export class SessionStore {
203
297
  const c = branch[compactionIdx] as CompactionEntry;
204
298
  const firstKeptIdx = branch.findIndex((e) => e.id === c.firstKeptId);
205
299
  const keepFrom = firstKeptIdx >= 0 ? firstKeptIdx : 0;
300
+ const summary = c.summary ?? renderEvictedSummary(
301
+ branch.slice(0, keepFrom)
302
+ .filter((e): e is MessageEntry => e.type === "message")
303
+ .map((e) => e.message),
304
+ );
206
305
  const out: AgentMessage[] = [{
207
306
  role: "user",
208
- content: `[Compacted conversation summary]\n${c.summary}`,
307
+ content: `[Compacted conversation summary]\n${summary}`,
209
308
  }];
210
309
  for (let i = keepFrom; i < branch.length; i++) {
211
310
  const e = branch[i]!;
@@ -214,12 +313,11 @@ export class SessionStore {
214
313
  return out;
215
314
  }
216
315
 
217
- /** A short, human-friendly preview for picker rows. Uses the first user
218
- * message's text when available, else the session id. */
219
316
  getPreview(): string {
220
317
  for (const e of this.entries.values()) {
221
318
  if (e.type === "message" && e.message.role === "user") {
222
- const txt = typeof e.message.content === "string" ? e.message.content : "";
319
+ const raw = typeof e.message.content === "string" ? e.message.content : "";
320
+ const txt = stripContextWrappers(raw);
223
321
  if (txt) return txt.slice(0, 80);
224
322
  }
225
323
  }
@@ -0,0 +1,52 @@
1
+ export interface ChangeHandlerResult {
2
+ mode: boolean;
3
+ /** When set, caller must `editor.setText(replaceText)` to strip the `!`. */
4
+ replaceText?: string;
5
+ /** Whether the next submit (if in shell mode) is marked private. */
6
+ pendingPrivate: boolean;
7
+ }
8
+
9
+ /** Strips `!` (entry), `!!` (entry + private), in-mode `!` (upgrade to private).
10
+ * Shell mode is sticky — exit only via the Backspace-on-empty intercept;
11
+ * auto-exit on empty text would fire during pi-tui's pre-emptive onChange("")
12
+ * inside Editor.submitValue() and misroute the submit. pendingPrivate is
13
+ * sticky for the same reason. */
14
+ export function deriveChangeHandlerResult(
15
+ mode: boolean,
16
+ pendingPrivate: boolean,
17
+ text: string,
18
+ ): ChangeHandlerResult {
19
+ if (!mode && text.startsWith("!!")) {
20
+ return { mode: true, replaceText: text.slice(2), pendingPrivate: true };
21
+ }
22
+ if (!mode && text.startsWith("!")) {
23
+ return { mode: true, replaceText: text.slice(1), pendingPrivate: false };
24
+ }
25
+ if (mode && text.startsWith("!")) {
26
+ return { mode: true, replaceText: text.slice(1), pendingPrivate: true };
27
+ }
28
+ return { mode, pendingPrivate: pendingPrivate && mode };
29
+ }
30
+
31
+ export type SubmitAction =
32
+ | { kind: "noop" }
33
+ | { kind: "shell"; line: string; private: boolean }
34
+ | { kind: "command"; name: string; args: string }
35
+ | { kind: "agent"; query: string };
36
+
37
+ export function classifySubmit(
38
+ text: string,
39
+ shellMode: boolean,
40
+ pendingPrivate: boolean,
41
+ ): SubmitAction {
42
+ const query = text.trim();
43
+ if (!query) return { kind: "noop" };
44
+ if (shellMode) return { kind: "shell", line: query, private: pendingPrivate };
45
+ if (query.startsWith("/")) {
46
+ const sp = query.indexOf(" ");
47
+ const name = sp === -1 ? query : query.slice(0, sp);
48
+ const args = sp === -1 ? "" : query.slice(sp + 1).trim();
49
+ return { kind: "command", name, args };
50
+ }
51
+ return { kind: "agent", query };
52
+ }
@@ -1,4 +1,5 @@
1
- import { Container, Text } from "@earendil-works/pi-tui";
1
+ import { basename } from "node:path";
2
+ import { Container, Text, visibleWidth } from "@earendil-works/pi-tui";
2
3
  import { theme } from "./theme.js";
3
4
 
4
5
  interface StatusFields {
@@ -11,11 +12,13 @@ interface StatusFields {
11
12
  tokens?: number;
12
13
  compactions?: number;
13
14
  thinking?: string;
15
+ shellMode?: "off" | "on" | "private";
14
16
  }
15
17
 
16
18
  export class StatusFooter extends Container {
17
19
  private text: Text;
18
20
  private fields: StatusFields = {};
21
+ private lastWidth = 0;
19
22
 
20
23
  constructor() {
21
24
  super();
@@ -25,10 +28,41 @@ export class StatusFooter extends Container {
25
28
 
26
29
  update(patch: Partial<StatusFields>): void {
27
30
  this.fields = { ...this.fields, ...patch };
28
- this.repaint();
31
+ this.repaint(this.lastWidth);
29
32
  }
30
33
 
31
- private repaint(): void {
34
+ render(width: number): string[] {
35
+ if (width !== this.lastWidth) {
36
+ this.lastWidth = width;
37
+ this.repaint(width);
38
+ }
39
+ return super.render(width);
40
+ }
41
+
42
+ private repaint(width: number): void {
43
+ const contentWidth = width > 0 ? Math.max(1, width - 2) : 0;
44
+ const right = this.buildRight();
45
+ const rightWidth = visibleWidth(right);
46
+ const join = (left: string): string => {
47
+ if (!right) return left;
48
+ const leftWidth = visibleWidth(left);
49
+ const gap = Math.max(1, contentWidth - leftWidth - rightWidth);
50
+ return `${left}${" ".repeat(gap)}${right}`;
51
+ };
52
+ const full = this.buildLine("full");
53
+ const fullFits = contentWidth === 0
54
+ || visibleWidth(full) + (right ? rightWidth + 1 : 0) <= contentWidth;
55
+ this.text.setText(fullFits ? join(full) : join(this.buildLine("basename")));
56
+ }
57
+
58
+ private buildRight(): string {
59
+ const mode = this.fields.shellMode;
60
+ if (mode === "on") return theme.fg("bashMode", "▸ shell");
61
+ if (mode === "private") return theme.fg("bashModePrivate", "▸ shell · private");
62
+ return "";
63
+ }
64
+
65
+ private buildLine(cwdMode: "full" | "basename"): string {
32
66
  const { model, provider, contextWindow, cwd, branch, leaf, tokens, compactions, thinking } = this.fields;
33
67
  const sep = theme.fg("dim", " | ");
34
68
  const parts: string[] = [];
@@ -39,7 +73,7 @@ export class StatusFooter extends Container {
39
73
  } else if (provider) {
40
74
  parts.push(theme.fg("muted", `@${provider}`));
41
75
  }
42
- if (cwd) parts.push(theme.fg("muted", shortenCwd(cwd)));
76
+ if (cwd) parts.push(theme.fg("muted", formatCwd(cwd, cwdMode)));
43
77
  if (branch) parts.push(theme.fg("muted", `⎇ ${branch}`));
44
78
  if (leaf != null && leaf > 0) parts.push(theme.fg("muted", `#${leaf}`));
45
79
  if (tokens != null) {
@@ -48,11 +82,12 @@ export class StatusFooter extends Container {
48
82
  parts.push(`${theme.fg("muted", tokStr)}${pct}`);
49
83
  }
50
84
  if (compactions && compactions > 0) parts.push(theme.fg("muted", `⊟ ${compactions}`));
51
- this.text.setText(parts.length === 0 ? "" : parts.join(sep));
85
+ return parts.length === 0 ? "" : parts.join(sep);
52
86
  }
53
87
  }
54
88
 
55
- function shortenCwd(cwd: string): string {
89
+ function formatCwd(cwd: string, mode: "full" | "basename"): string {
90
+ if (mode === "basename") return basename(cwd) || cwd;
56
91
  const home = process.env.HOME;
57
92
  if (home && cwd.startsWith(`${home}/`)) return `~/${cwd.slice(home.length + 1)}`;
58
93
  if (home && cwd === home) return "~";
@@ -53,7 +53,8 @@ const RAW = {
53
53
  toolDiffAdded: "green",
54
54
  toolDiffRemoved: "red",
55
55
  toolDiffContext: "gray",
56
- bashMode: "green",
56
+ bashMode: "yellow",
57
+ bashModePrivate: "green",
57
58
  } as const;
58
59
 
59
60
  export type ThemeColor = keyof typeof RAW;
@@ -0,0 +1,93 @@
1
+ /**
2
+ * Replaces ashi's default deterministic compaction summary with an
3
+ * LLM-generated structured one. Advises `ashi:compact:build-summary`;
4
+ * orchestration stays in ashi. Falls back to deterministic when the LLM
5
+ * is unavailable or the call fails.
6
+ */
7
+ import type { AgentContext } from "agent-sh/types";
8
+
9
+ interface AgentMessage {
10
+ role: "system" | "user" | "assistant" | "tool";
11
+ content?: unknown;
12
+ tool_calls?: { function?: { name?: string; arguments?: string } }[];
13
+ }
14
+
15
+ const SUMMARY_PROMPT = `You are compacting a coding-agent conversation so the agent can continue with limited context.
16
+
17
+ Produce a Markdown summary using EXACTLY this structure:
18
+
19
+ ## Goal
20
+ [What the user is trying to accomplish, one or two sentences]
21
+
22
+ ## Constraints & Preferences
23
+ - [Bulleted user requirements / preferences expressed so far]
24
+
25
+ ## Progress
26
+ ### Done
27
+ - [x] [Completed work]
28
+
29
+ ### In Progress
30
+ - [ ] [Active work and current sub-goal]
31
+
32
+ ### Blocked
33
+ - [Issues, or "None"]
34
+
35
+ ## Key Decisions
36
+ - **[Decision]**: [Rationale]
37
+
38
+ ## Next Steps
39
+ 1. [What should happen next]
40
+
41
+ ## Critical Context
42
+ - [Specific paths, names, identifiers, or data the agent must remember]
43
+
44
+ Be concrete. Quote file paths, function names, error strings verbatim when relevant. Do not invent details that aren't in the conversation.`;
45
+
46
+ export default function activate(ctx: AgentContext): void {
47
+ ctx.advise(
48
+ "ashi:compact:build-summary",
49
+ async (next: (...a: unknown[]) => unknown, evicted: AgentMessage[]) => {
50
+ const llm = ctx.agent?.llm;
51
+ if (!llm?.available) return next(evicted);
52
+ try {
53
+ const summary = await llm.ask({
54
+ system: SUMMARY_PROMPT,
55
+ query: buildQuery(evicted),
56
+ maxTokens: 16384,
57
+ reasoningEffort: "low",
58
+ });
59
+ return summary.trim();
60
+ } catch (e) {
61
+ ctx.bus.emit("ui:error", {
62
+ message: `ashi-compact-llm: LLM failed (${(e as Error).message}); falling back to deterministic summary`,
63
+ });
64
+ return next(evicted);
65
+ }
66
+ },
67
+ );
68
+ }
69
+
70
+ function buildQuery(messages: AgentMessage[]): string {
71
+ const lines: string[] = ["Conversation to summarize:"];
72
+ for (const m of messages) {
73
+ const text = typeof m.content === "string" ? m.content : "";
74
+ if (m.role === "user") lines.push(`[User]: ${text}`);
75
+ else if (m.role === "assistant") {
76
+ if (text) lines.push(`[Assistant]: ${text}`);
77
+ if (m.tool_calls) {
78
+ for (const t of m.tool_calls) {
79
+ const args = t.function?.arguments ?? "";
80
+ lines.push(`[Assistant tool call]: ${t.function?.name ?? "?"}(${truncate(args, 400)})`);
81
+ }
82
+ }
83
+ } else if (m.role === "tool") {
84
+ lines.push(`[Tool result]: ${truncate(text, 2000)}`);
85
+ }
86
+ }
87
+ return lines.join("\n");
88
+ }
89
+
90
+ function truncate(s: string, max: number): string {
91
+ if (s.length <= max) return s;
92
+ return s.slice(0, max) + `\n[…truncated ${s.length - max} chars…]`;
93
+ }
@@ -140,6 +140,7 @@ export default function activate(ctx: ExtensionContext): void {
140
140
  const kind = toolKind(meta.name);
141
141
  bus.emit("agent:tool-started", {
142
142
  title: meta.name,
143
+ name: meta.name,
143
144
  toolCallId: meta.id,
144
145
  kind,
145
146
  icon: toolIcon(meta.name),
@@ -176,6 +177,7 @@ export default function activate(ctx: ExtensionContext): void {
176
177
  const kind = toolKind(b.name);
177
178
  bus.emit("agent:tool-started", {
178
179
  title: b.name,
180
+ name: b.name,
179
181
  toolCallId: b.id,
180
182
  kind,
181
183
  icon: toolIcon(b.name),
@@ -147,6 +147,7 @@ export default function activate(ctx: ExtensionContext): void {
147
147
  announcedTools.add(callID);
148
148
  bus.emit("agent:tool-started", {
149
149
  title: toolName,
150
+ name: toolName,
150
151
  toolCallId: callID,
151
152
  kind,
152
153
  locations: toolLocations(state.input ?? {}),
@@ -290,6 +291,7 @@ export default function activate(ctx: ExtensionContext): void {
290
291
  : req.questions.map((q, i) => `${q.header || `Q${i + 1}`}: ${q.question}`).join("; ");
291
292
  bus.emit("agent:tool-started", {
292
293
  title: "question",
294
+ name: "question",
293
295
  toolCallId: callID,
294
296
  kind: "execute",
295
297
  displayDetail: detail,
@@ -355,6 +357,7 @@ export default function activate(ctx: ExtensionContext): void {
355
357
  const summary = opts?.note ? `denied (${opts.note})` : `denied: ${detail}`;
356
358
  bus.emit("agent:tool-started", {
357
359
  title: "permission",
360
+ name: "permission",
358
361
  toolCallId: callID,
359
362
  kind: "execute",
360
363
  displayDetail: detail,