grok-telegram-bot 2.3.0 → 2.4.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 (68) hide show
  1. package/.env.example +26 -0
  2. package/CHANGELOG.md +55 -0
  3. package/package.json +1 -1
  4. package/scripts/analyze-jsonl.ts +33 -0
  5. package/scripts/delayed-restart.ps1 +29 -0
  6. package/scripts/probe-exit-response-shape.py +77 -0
  7. package/scripts/probe-plan-exit.py +60 -0
  8. package/scripts/probe-plan-exit2.py +48 -0
  9. package/scripts/probe-plan-fields.py +41 -0
  10. package/scripts/probe-plan-fields2.py +58 -0
  11. package/scripts/probe-plan-response-path.py +48 -0
  12. package/scripts/sample-claude-tooluse.ts +21 -0
  13. package/scripts/sample-kiro-events.ts +31 -0
  14. package/scripts/smoke-exit-plan.ts +274 -0
  15. package/scripts/smoke-exit-shapes.ts +252 -0
  16. package/scripts/smoke-import.mjs +82 -0
  17. package/scripts/smoke-import.ts +73 -0
  18. package/src/app/accounts.ts +84 -0
  19. package/src/app/instance-lock.ts +6 -0
  20. package/src/app/types.ts +19 -2
  21. package/src/app/updater.ts +17 -6
  22. package/src/app/usage.ts +204 -7
  23. package/src/bot/account-rotator.ts +71 -2
  24. package/src/bot/bot.ts +36 -0
  25. package/src/bot/chat-controller.ts +35 -0
  26. package/src/bot/commands.ts +2 -0
  27. package/src/bot/complexity-gate.ts +69 -0
  28. package/src/bot/deps.ts +19 -0
  29. package/src/bot/handlers/accounts.ts +55 -5
  30. package/src/bot/handlers/import-session.ts +290 -0
  31. package/src/bot/handlers/menu.ts +17 -38
  32. package/src/bot/handlers/message.ts +1 -0
  33. package/src/bot/handlers/running.ts +35 -5
  34. package/src/bot/handlers/session-card.ts +12 -0
  35. package/src/bot/handlers/sessions.ts +14 -3
  36. package/src/bot/handlers/usage.ts +118 -16
  37. package/src/bot/menu/keyboard.ts +5 -4
  38. package/src/bot/menu/status-panel.ts +19 -6
  39. package/src/bot/prompt-content.ts +4 -0
  40. package/src/bot/reauth-controller.ts +2 -2
  41. package/src/bot/session-fork.ts +11 -0
  42. package/src/bot/session-runtime.ts +831 -64
  43. package/src/bot/suggestions.ts +429 -0
  44. package/src/config.ts +41 -0
  45. package/src/grok/client.ts +106 -20
  46. package/src/grok/plan-approval.ts +72 -0
  47. package/src/grok/session-log.ts +16 -0
  48. package/src/grok/types.ts +21 -2
  49. package/src/import/build-import.ts +132 -0
  50. package/src/import/history-readers.ts +681 -0
  51. package/src/import/list-running.ts +100 -0
  52. package/src/import/sources.ts +78 -0
  53. package/src/index.ts +179 -24
  54. package/src/render/diff.ts +11 -2
  55. package/src/render/file-summary.ts +31 -1
  56. package/src/render/markdown.ts +293 -35
  57. package/src/render/plan.ts +127 -0
  58. package/src/render/session-comment.ts +261 -0
  59. package/src/render/tool-call-detail.ts +400 -19
  60. package/src/render/tool-call-merge.ts +115 -0
  61. package/src/render/tool-call.ts +405 -142
  62. package/src/render/truncate.ts +85 -0
  63. package/src/service/windows.ts +14 -2
  64. package/src/sessions/history.ts +57 -0
  65. package/src/sessions/store.ts +3 -0
  66. package/src/sessions/types.ts +5 -0
  67. package/src/stream/streamer.ts +73 -9
  68. package/src/tasks/runner.ts +4 -3
@@ -1,13 +1,17 @@
1
- /**
1
+ /**
2
2
  * Format ACP tool-call updates into clear, RAW markdown blocks so they read
3
3
  * distinctly from the agent's prose and thinking. Each tool kind gets its own
4
4
  * rich detail: commands in bash blocks, diffs in diff blocks, search queries,
5
- * file paths, URLs, content previews, move/rename source+dest, delete paths.
5
+ * file paths (with offset/limit), URLs, content previews, MCP args.
6
+ *
7
+ * Grok often reports kind "other" with title/name `read_file` / `grep` / etc.
8
+ * {@link resolveToolIdentity} maps those to real display kinds so users never
9
+ * see a bare "Other" or a false "Call MCP: read_file".
6
10
  */
7
11
  import type { SessionUpdate } from "../grok/types.js";
8
12
  import { renderUnifiedDiff } from "./diff.js";
9
13
  import {
10
- normalizeKind,
14
+ resolveToolIdentity,
11
15
  extractPath,
12
16
  extractDestPath,
13
17
  extractSearchQuery,
@@ -16,28 +20,40 @@ import {
16
20
  extractCommand,
17
21
  extractContent,
18
22
  extractFilters,
23
+ extractReadRange,
24
+ extractToolOutput,
25
+ extractToolName,
26
+ formatArgLines,
19
27
  truncate,
20
28
  collectContent,
21
29
  gatherPaths,
22
30
  PREVIEW_MAX,
23
31
  CONTENT_PREVIEW_MAX,
32
+ OUTPUT_PREVIEW_MAX,
33
+ OUTPUT_PREVIEW_LINES,
34
+ type ToolDisplayKind,
24
35
  } from "./tool-call-detail.js";
36
+ import { formatLiveTerminalOutput, truncateMiddle, truncateMiddleLines } from "./truncate.js";
25
37
 
26
38
  const KIND_ICON: Record<string, string> = {
27
- read: "\u{1F4D6}",
28
- edit: "\u270F\uFE0F",
29
- write: "\u{1F4DD}",
39
+ read: "\u{1F4D6}", // 📖
40
+ edit: "\u270F\uFE0F", // ✏️
41
+ write: "\u{1F4DD}", // 📝
30
42
  create: "\u{1F4DD}",
31
- execute: "\u{1F4BB}",
32
- search: "\u{1F50E}",
33
- delete: "\u{1F5D1}\uFE0F",
34
- move: "\u{1F4E6}",
43
+ execute: "\u{1F4BB}", // 💻
44
+ search: "\u{1F50E}", // 🔎
45
+ delete: "\u{1F5D1}\uFE0F", // 🗑️
46
+ move: "\u{1F4E6}", // 📦
35
47
  rename: "\u{1F4E6}",
36
- fetch: "\u{1F310}",
48
+ fetch: "\u{1F310}", // 🌐
37
49
  web_search: "\u{1F310}",
38
50
  web_fetch: "\u{1F310}",
39
- think: "\u{1F4AD}",
40
- other: "\u{1F527}",
51
+ list: "\u{1F4C1}", // 📁
52
+ todo: "\u2705", // ✅
53
+ think: "\u{1F4AD}", // 💭
54
+ image: "\u{1F5BC}\uFE0F", // 🖼️
55
+ mcp: "\u{1F9E9}", // 🧩
56
+ other: "\u{1F527}", // 🔧
41
57
  };
42
58
 
43
59
  const STATUS_ICON: Record<string, string> = {
@@ -54,39 +70,74 @@ export interface ToolFormatOptions {
54
70
 
55
71
  /** Returns a RAW markdown block describing the tool call, or "" to skip. */
56
72
  export function formatToolCall(u: SessionUpdate, opts: ToolFormatOptions): string {
57
- const kind = normalizeKind(u.kind);
58
- const raw = (u.rawInput || {}) as Record<string, unknown>;
73
+ const raw = flattenRawInput(u);
74
+ // Inject path from ACP locations when rawInput is thin.
75
+ if (u.locations?.[0]?.path && !raw.path && !raw.target_file && !raw.file_path) {
76
+ raw.path = u.locations[0].path;
77
+ if (u.locations[0].line != null && raw.start_line == null) raw.start_line = u.locations[0].line;
78
+ }
79
+ const id = resolveToolIdentity(u, raw);
59
80
  const status = u.status ? (STATUS_ICON[u.status] ?? "") : "";
60
81
  const tail = status ? " " + status : "";
61
82
 
62
- // Skill load
63
- if (kind !== "edit" && kind !== "delete" && kind !== "move" && kind !== "write" && kind !== "create") {
83
+ // Plan mode enter/exit — surface clearly (exit failures used to look opaque).
84
+ const planCard = formatPlanModeTool(u, raw, id.toolName, tail);
85
+ if (planCard) return planCard;
86
+
87
+ // Skill load (SKILL.md path) — not a normal edit.
88
+ if (id.kind !== "edit" && id.kind !== "delete" && id.kind !== "move" && id.kind !== "write" && id.kind !== "create") {
64
89
  const skill = detectSkill(u, raw);
65
90
  if (skill) return "\u{1F4DA} **Loaded skill: " + skill + "**" + tail;
66
91
  }
67
92
 
68
- // MCP / extension tool call
69
- const mcp = detectMcp(u, raw, kind);
70
- if (mcp) {
71
- const label = mcp.server ? "Call MCP " + mcp.server + ": " + mcp.method : "Call MCP: " + mcp.method;
72
- let out = "\u{1F9E9} **" + label + "**" + tail;
73
- const argPreview = mcpArgPreview(raw);
74
- if (argPreview) out += "\n" + fence(argPreview) + "\n";
93
+ // Real MCP (namespaced) badge + rich method-specific detail when possible.
94
+ if (id.isMcp) {
95
+ const label = id.mcpServer
96
+ ? `\u{1F9E9} **MCP ${id.mcpServer} \u00B7 ${id.mcpMethod || id.toolName}**${tail}`
97
+ : `\u{1F9E9} **MCP: ${id.mcpMethod || id.toolName}**${tail}`;
98
+ // Prefer rich formatter for known methods (read/search/…); keep MCP header.
99
+ if (id.kind !== "mcp" && id.kind !== "other") {
100
+ const body = formatByKind(id.kind, u, raw, "", opts, id.mcpMethod || id.toolName);
101
+ return label + "\n" + body;
102
+ }
103
+ let out = label;
104
+ const args = formatArgLines(raw);
105
+ if (args) out += "\n" + fence(truncateMiddle(args, PREVIEW_MAX)) + "\n";
106
+ const result = extractToolOutput(u);
107
+ if (result) {
108
+ out +=
109
+ "\n**Output:**\n" +
110
+ fence(truncateMiddleLines(result, OUTPUT_PREVIEW_LINES, OUTPUT_PREVIEW_MAX)) +
111
+ "\n";
112
+ }
75
113
  return out;
76
114
  }
77
115
 
116
+ return formatByKind(id.kind, u, raw, tail, opts, id.toolName);
117
+ }
118
+
119
+ function formatByKind(
120
+ kind: ToolDisplayKind | string,
121
+ u: SessionUpdate,
122
+ raw: Record<string, unknown>,
123
+ tail: string,
124
+ opts: ToolFormatOptions,
125
+ toolName: string,
126
+ ): string {
78
127
  switch (kind) {
79
128
  case "execute":
80
- return formatExecute(raw, tail);
129
+ return formatExecute(u, raw, tail, toolName);
81
130
  case "edit":
82
- return formatEdit(u, raw, tail, opts);
131
+ return formatEdit(u, raw, tail, opts, toolName);
83
132
  case "write":
84
133
  case "create":
85
- return formatWrite(kind, raw, tail);
134
+ return formatWrite(u, kind, raw, tail);
86
135
  case "read":
87
- return formatRead(raw, tail);
136
+ return formatRead(u, raw, tail, toolName);
137
+ case "list":
138
+ return formatList(u, raw, tail, toolName);
88
139
  case "search":
89
- return formatSearch(raw, tail);
140
+ return formatSearch(u, raw, tail, toolName);
90
141
  case "delete":
91
142
  return formatDelete(raw, tail);
92
143
  case "move":
@@ -94,37 +145,90 @@ export function formatToolCall(u: SessionUpdate, opts: ToolFormatOptions): strin
94
145
  return formatMove(kind, raw, tail);
95
146
  case "fetch":
96
147
  case "web_fetch":
97
- return formatFetch(raw, tail);
148
+ return formatFetch(u, raw, tail);
98
149
  case "web_search":
99
- return formatWebSearch(raw, tail);
150
+ return formatWebSearch(u, raw, tail);
151
+ case "todo":
152
+ return formatTodo(raw, tail, toolName);
153
+ case "think":
154
+ return formatThink(raw, tail, toolName, u);
155
+ case "image":
156
+ return formatImage(raw, tail, toolName);
100
157
  default:
101
- return formatGeneric(u, raw, tail, kind);
158
+ return formatGeneric(u, raw, tail, kind, toolName);
102
159
  }
103
160
  }
104
161
 
105
- // ---- helpers for code fences (avoid backtick-in-template-literal issues) ----
162
+ // ---- helpers for code fences ----
106
163
 
107
- /** Wrap text in a fenced code block with optional language. */
164
+ /** Fence that lengthens itself when the body contains backticks (avoids MD break). */
108
165
  function fence(text: string, lang?: string): string {
109
- const marker = "```";
166
+ let tickLen = 3;
167
+ const runs = text.match(/`+/g);
168
+ if (runs) {
169
+ const max = Math.max(...runs.map((r) => r.length));
170
+ if (max >= tickLen) tickLen = max + 1;
171
+ }
172
+ const marker = "`".repeat(tickLen);
110
173
  return marker + (lang || "") + "\n" + text + "\n" + marker;
111
174
  }
112
175
 
176
+ /** Merge rawInput with nested arguments/input objects Grok sometimes uses. */
177
+ function flattenRawInput(u: SessionUpdate): Record<string, unknown> {
178
+ const raw = { ...((u.rawInput || {}) as Record<string, unknown>) };
179
+ for (const key of ["arguments", "input", "args", "parameters"]) {
180
+ const nested = raw[key];
181
+ if (nested && typeof nested === "object" && !Array.isArray(nested)) {
182
+ for (const [k, v] of Object.entries(nested as Record<string, unknown>)) {
183
+ if (raw[k] === undefined) raw[k] = v;
184
+ }
185
+ }
186
+ }
187
+ return raw;
188
+ }
189
+
113
190
  // ---- per-kind formatters ----
114
191
 
115
- function formatExecute(raw: Record<string, unknown>, tail: string): string {
192
+ function formatExecute(
193
+ u: SessionUpdate,
194
+ raw: Record<string, unknown>,
195
+ tail: string,
196
+ toolName: string,
197
+ ): string {
116
198
  const cmd = extractCommand(raw);
117
- const cwd = strOf(raw.cwd);
199
+ const cwd = strOf(raw.cwd) || strOf(raw.working_directory) || strOf(raw.workingDirectory);
118
200
  const title = "Run command" + (cwd ? " in " + truncate(cwd, 80) : "");
119
201
  let out = "\u{1F4BB} **" + title + "**" + tail;
120
- if (cmd) out += "\n" + fence(truncate(cmd, PREVIEW_MAX), "bash") + "\n";
202
+ if (toolName && toolName !== "execute" && toolName !== "shell" && toolName !== "bash") {
203
+ out += `\n tool: \`${toolName}\``;
204
+ }
205
+ if (cmd) out += "\n" + fence(truncateMiddle(cmd, PREVIEW_MAX), "bash") + "\n";
206
+ else {
207
+ // No command field — dump args so the user still sees what ran.
208
+ const args = formatArgLines(raw);
209
+ if (args) out += "\n" + fence(truncateMiddle(args, PREVIEW_MAX)) + "\n";
210
+ }
211
+ // Display: first output line + live tail (one block, updated in place via upsert).
212
+ // Full stdout remains in the agent session / merged tool snapshot.
213
+ const result = extractToolOutput(u);
214
+ if (result) {
215
+ const live = formatLiveTerminalOutput(result, 12, OUTPUT_PREVIEW_MAX);
216
+ out += "\n**Output:**\n" + fence(live) + "\n";
217
+ }
121
218
  return out;
122
219
  }
123
220
 
124
- function formatEdit(u: SessionUpdate, raw: Record<string, unknown>, tail: string, opts: ToolFormatOptions): string {
221
+ function formatEdit(
222
+ u: SessionUpdate,
223
+ raw: Record<string, unknown>,
224
+ tail: string,
225
+ opts: ToolFormatOptions,
226
+ toolName: string,
227
+ ): string {
125
228
  const path = extractPath(raw);
126
229
  const title = "Edit " + (path || "file");
127
230
  let out = "\u270F\uFE0F **" + title + "**" + tail;
231
+ if (toolName && !/edit|replace/i.test(toolName)) out += `\n tool: \`${toolName}\``;
128
232
  if (opts.showDiffs) {
129
233
  const diff = buildEditDiff(u, raw, opts.diffMaxLines);
130
234
  if (diff && diff.block) {
@@ -135,44 +239,100 @@ function formatEdit(u: SessionUpdate, raw: Record<string, unknown>, tail: string
135
239
  return out;
136
240
  }
137
241
 
138
- function formatWrite(kind: string, raw: Record<string, unknown>, tail: string): string {
242
+ function formatWrite(u: SessionUpdate, kind: string, raw: Record<string, unknown>, tail: string): string {
139
243
  const path = extractPath(raw);
140
244
  const verb = kind === "create" ? "Create" : "Write";
141
245
  let out = "\u{1F4DD} **" + verb + " " + (path || "file") + "**" + tail;
142
- const content = extractContent(raw);
246
+ const content = extractContent(raw) || extractToolOutput(u);
143
247
  if (content) {
144
- out += "\n" + fence(truncate(content, CONTENT_PREVIEW_MAX), detectLang(path)) + "\n";
248
+ out += "\n" + fence(truncateMiddleLines(content, OUTPUT_PREVIEW_LINES, CONTENT_PREVIEW_MAX), detectLang(path)) + "\n";
145
249
  }
146
250
  return out;
147
251
  }
148
252
 
149
- function formatRead(raw: Record<string, unknown>, tail: string): string {
150
- const path = extractPath(raw);
151
- const lines = strOf(raw.start_line) || strOf(raw.line);
152
- const offset = strOf(raw.offset);
153
- const limit = strOf(raw.limit);
253
+ function formatRead(
254
+ u: SessionUpdate,
255
+ raw: Record<string, unknown>,
256
+ tail: string,
257
+ toolName: string,
258
+ ): string {
259
+ const path = extractPath(raw, u);
260
+ const range = extractReadRange(raw);
154
261
  let title = "Read " + (path || "file");
155
262
  const parts: string[] = [];
156
- if (lines) parts.push("line " + lines);
157
- if (offset) parts.push("offset " + offset);
158
- if (limit) parts.push("limit " + limit);
263
+ if (range.startLine) parts.push("line " + range.startLine);
264
+ if (range.offset) parts.push("offset " + range.offset);
265
+ if (range.limit) parts.push("limit " + range.limit);
266
+ if (range.pages) parts.push("pages " + range.pages);
159
267
  if (parts.length) title += " (" + parts.join(", ") + ")";
160
- return "\u{1F4D6} **" + title + "**" + tail;
268
+ let out = "\u{1F4D6} **" + title + "**" + tail;
269
+ if (toolName && toolName !== "read" && toolName !== "read_file") {
270
+ out += `\n tool: \`${toolName}\``;
271
+ }
272
+ // Extra range detail lines for clarity when many fields present.
273
+ if (parts.length >= 2) {
274
+ out += "\n " + parts.join(" \u00B7 ");
275
+ }
276
+ const body = extractToolOutput(u);
277
+ if (body) {
278
+ out += "\n" + fence(truncateMiddleLines(body, OUTPUT_PREVIEW_LINES, OUTPUT_PREVIEW_MAX), detectLang(path)) + "\n";
279
+ }
280
+ return out;
281
+ }
282
+
283
+ function formatList(
284
+ u: SessionUpdate,
285
+ raw: Record<string, unknown>,
286
+ tail: string,
287
+ toolName: string,
288
+ ): string {
289
+ const path =
290
+ extractPath(raw) ||
291
+ extractSearchPath(raw) ||
292
+ strOf(raw.target_directory) ||
293
+ strOf(raw.targetDirectory) ||
294
+ ".";
295
+ let out = "\u{1F4C1} **List " + truncate(path, 120) + "**" + tail;
296
+ if (toolName) out += `\n tool: \`${toolName}\``;
297
+ const filters = extractFilters(raw);
298
+ if (filters.include) out += "\n include: " + filters.include;
299
+ if (filters.exclude) out += "\n exclude: " + filters.exclude;
300
+ const body = extractToolOutput(u);
301
+ if (body) {
302
+ out += "\n" + fence(truncateMiddleLines(body, OUTPUT_PREVIEW_LINES, OUTPUT_PREVIEW_MAX)) + "\n";
303
+ }
304
+ return out;
161
305
  }
162
306
 
163
- function formatSearch(raw: Record<string, unknown>, tail: string): string {
307
+ function formatSearch(
308
+ u: SessionUpdate,
309
+ raw: Record<string, unknown>,
310
+ tail: string,
311
+ toolName: string,
312
+ ): string {
164
313
  const query = extractSearchQuery(raw);
165
314
  const path = extractSearchPath(raw);
166
315
  const filters = extractFilters(raw);
167
- let title = "Search";
316
+ const isGlob = /glob/i.test(toolName) || (!!filters.include && !query);
317
+ let title = isGlob ? "Glob" : "Search";
168
318
  if (query) title += ": " + truncate(query, 120);
169
319
  else if (path) title += " " + path;
170
320
  let out = "\u{1F50E} **" + title + "**" + tail;
171
- if (path && !query.includes(path)) out += "\n \u{1F4C2} in: " + truncate(path, 100);
321
+ if (toolName && toolName !== "search" && toolName !== "grep") {
322
+ out += `\n tool: \`${toolName}\``;
323
+ }
324
+ if (path && !(query && query.includes(path))) out += "\n \u{1F4C2} in: " + truncate(path, 100);
172
325
  if (filters.include) out += "\n \u{1F4C1} include: " + filters.include;
173
326
  if (filters.exclude) out += "\n \u{1F6AB} exclude: " + filters.exclude;
174
- if (raw.case_sensitive !== undefined)
327
+ if (raw.case_sensitive !== undefined) {
175
328
  out += "\n case-sensitive: " + (raw.case_sensitive ? "yes" : "no");
329
+ }
330
+ if (raw.multiline !== undefined) out += "\n multiline: " + (raw.multiline ? "yes" : "no");
331
+ if (raw.type) out += "\n type: " + String(raw.type);
332
+ const hits = extractToolOutput(u);
333
+ if (hits) {
334
+ out += "\n" + fence(truncateMiddleLines(hits, OUTPUT_PREVIEW_LINES, OUTPUT_PREVIEW_MAX)) + "\n";
335
+ }
176
336
  return out;
177
337
  }
178
338
 
@@ -186,12 +346,21 @@ function formatMove(kind: string, raw: Record<string, unknown>, tail: string): s
186
346
  const dst = extractDestPath(raw);
187
347
  const verb = kind === "rename" ? "Rename" : "Move";
188
348
  if (src && dst) {
189
- return "\u{1F4E6} **" + verb + "**" + tail + "\n \u{1F4C4} " + truncate(src, 100) + "\n \u27A1\uFE0F " + truncate(dst, 100);
349
+ return (
350
+ "\u{1F4E6} **" +
351
+ verb +
352
+ "**" +
353
+ tail +
354
+ "\n \u{1F4C4} " +
355
+ truncate(src, 100) +
356
+ "\n \u27A1\uFE0F " +
357
+ truncate(dst, 100)
358
+ );
190
359
  }
191
360
  return "\u{1F4E6} **" + verb + " " + (src || dst || "file") + "**" + tail;
192
361
  }
193
362
 
194
- function formatFetch(raw: Record<string, unknown>, tail: string): string {
363
+ function formatFetch(u: SessionUpdate, raw: Record<string, unknown>, tail: string): string {
195
364
  const url = extractUrl(raw);
196
365
  const method = strOf(raw.method) || strOf(raw.verb) || "GET";
197
366
  let title = "Fetch URL";
@@ -205,26 +374,142 @@ function formatFetch(raw: Record<string, unknown>, tail: string): string {
205
374
  }
206
375
  const body = strOf(raw.body) || strOf(raw.data);
207
376
  if (body) out += "\n body: " + truncate(body, 200);
377
+ const result = extractToolOutput(u);
378
+ if (result) {
379
+ out += "\n" + fence(truncateMiddleLines(result, OUTPUT_PREVIEW_LINES, OUTPUT_PREVIEW_MAX)) + "\n";
380
+ }
208
381
  return out;
209
382
  }
210
383
 
211
- function formatWebSearch(raw: Record<string, unknown>, tail: string): string {
384
+ function formatWebSearch(u: SessionUpdate, raw: Record<string, unknown>, tail: string): string {
212
385
  const query = extractSearchQuery(raw) || extractUrl(raw);
213
- const count = strOf(raw.count) || strOf(raw.num) || strOf(raw.num_results);
386
+ const count = strOf(raw.count) || strOf(raw.num) || strOf(raw.num_results) || numStr(raw.num_results);
214
387
  let title = "Web search";
215
388
  if (query) title += ": " + truncate(query, 150);
216
389
  let out = "\u{1F310} **" + title + "**" + tail;
217
390
  if (count) out += "\n results: " + count;
391
+ const result = extractToolOutput(u);
392
+ if (result) {
393
+ out += "\n" + fence(truncateMiddleLines(result, OUTPUT_PREVIEW_LINES, OUTPUT_PREVIEW_MAX)) + "\n";
394
+ }
395
+ return out;
396
+ }
397
+
398
+ function formatTodo(raw: Record<string, unknown>, tail: string, toolName: string): string {
399
+ let out = "\u2705 **Todos**" + tail;
400
+ if (toolName) out += `\n tool: \`${toolName}\``;
401
+ const args = formatArgLines(raw);
402
+ if (args) out += "\n" + fence(truncateMiddle(args, PREVIEW_MAX)) + "\n";
218
403
  return out;
219
404
  }
220
405
 
221
- function formatGeneric(u: SessionUpdate, raw: Record<string, unknown>, tail: string, kind: string): string {
406
+ function formatThink(
407
+ raw: Record<string, unknown>,
408
+ tail: string,
409
+ toolName: string,
410
+ u: SessionUpdate,
411
+ ): string {
412
+ const desc =
413
+ strOf(raw.description) ||
414
+ strOf(raw.prompt) ||
415
+ strOf(raw.message) ||
416
+ strOf(raw.task) ||
417
+ u.title ||
418
+ toolName ||
419
+ "subagent";
420
+ let out = "\u{1F4AD} **Delegate / think**" + tail + "\n " + truncate(desc, 300);
421
+ if (toolName) out += `\n tool: \`${toolName}\``;
422
+ const args = formatArgLines(raw);
423
+ if (args) out += "\n" + fence(truncateMiddle(args, PREVIEW_MAX)) + "\n";
424
+ return out;
425
+ }
426
+
427
+ function formatImage(raw: Record<string, unknown>, tail: string, toolName: string): string {
428
+ const path = extractPath(raw) || strOf(raw.image) || strOf(raw.output);
429
+ let out = "\u{1F5BC}\uFE0F **Image**" + tail;
430
+ if (toolName) out += `\n tool: \`${toolName}\``;
431
+ if (path) out += "\n " + truncate(path, 200);
432
+ const prompt = strOf(raw.prompt);
433
+ if (prompt) out += "\n prompt: " + truncate(prompt, 200);
434
+ return out;
435
+ }
436
+
437
+ /**
438
+ * Enter/exit plan mode tools — show status + failure reason (e.g. client
439
+ * disconnected when the bridge did not answer the plan-approval reverse-request).
440
+ */
441
+ function formatPlanModeTool(
442
+ u: SessionUpdate,
443
+ raw: Record<string, unknown>,
444
+ toolName: string,
445
+ tail: string,
446
+ ): string | undefined {
447
+ const name = (toolName || u.title || "").toLowerCase();
448
+ const variant = String(raw.variant ?? raw.action ?? "").toLowerCase();
449
+ const isEnter =
450
+ name.includes("enter_plan") ||
451
+ name === "plan: enter" ||
452
+ variant.includes("enterplan");
453
+ const isExit =
454
+ name.includes("exit_plan") ||
455
+ name === "plan: exit" ||
456
+ variant.includes("exitplan");
457
+ if (!isEnter && !isExit) return undefined;
458
+
459
+ const icon = isExit ? "\u{1F4CB}" : "\u{1F4D1}";
460
+ const label = isExit ? "Plan: Exit" : "Plan: Enter";
461
+ let out = `${icon} **${label}**${tail}`;
462
+ if (variant) out += `\n variant: \`${String(raw.variant ?? raw.action)}\``;
463
+ const result = extractToolOutput(u);
464
+ if (result) {
465
+ // Prefer a short, visible failure/success reason over a huge dump.
466
+ const short = truncateMiddle(result.trim(), 500);
467
+ out += "\n" + fence(short) + "\n";
468
+ } else if ((u.status || "").toLowerCase() === "failed") {
469
+ out += "\n \u274C Plan approval failed (bridge should auto-approve exit).";
470
+ }
471
+ return out;
472
+ }
473
+
474
+ function formatGeneric(
475
+ u: SessionUpdate,
476
+ raw: Record<string, unknown>,
477
+ tail: string,
478
+ kind: string,
479
+ toolName: string,
480
+ ): string {
222
481
  const icon = KIND_ICON[kind] ?? KIND_ICON.other;
223
482
  const path = extractPath(raw);
224
- const title = u.title || (path ? capitalize(kind) + " " + path : capitalize(kind));
225
- let out = icon + " **" + title + "**" + tail;
226
- const desc = strOf(raw.description) || strOf(raw.message) || strOf(raw.prompt);
227
- if (desc) out += "\n " + truncate(desc, 300);
483
+ const cmd = extractCommand(raw);
484
+ const query = extractSearchQuery(raw);
485
+ // Never show a bare "Other" / "Tool call" — prefer real tool name / path / cmd.
486
+ let label =
487
+ (toolName && !/^tool[_ ]?call$/i.test(toolName) ? toolName : "") ||
488
+ (u.title && !/^other$/i.test(u.title.trim()) && !/^tool[_ ]?call$/i.test(u.title.trim())
489
+ ? u.title.trim()
490
+ : "") ||
491
+ (path ? capitalize(kind !== "other" ? kind : "use") + " " + path : "") ||
492
+ (cmd ? "Run command" : "") ||
493
+ (query ? "Search: " + truncate(query, 80) : "") ||
494
+ (kind && kind !== "other" ? capitalize(kind) : "") ||
495
+ (toolName || "Tool");
496
+ if (/^other$/i.test(label) || /^tool[_ ]?call$/i.test(label)) {
497
+ label = toolName && !/^tool[_ ]?call$/i.test(toolName) ? toolName : path || cmd || query || "Tool";
498
+ }
499
+
500
+ let out = icon + " **" + label + "**" + tail;
501
+ if (toolName && toolName !== label) out += `\n tool: \`${toolName}\``;
502
+ if (path && !label.includes(path)) out += "\n \u{1F4C4} " + truncate(path, 120);
503
+ if (cmd) out += "\n" + fence(truncateMiddle(cmd, PREVIEW_MAX), "bash") + "\n";
504
+ else if (query && !label.includes(query)) out += "\n query: " + truncate(query, 150);
505
+ else {
506
+ const args = formatArgLines(raw);
507
+ if (args) out += "\n" + fence(truncateMiddle(args, PREVIEW_MAX)) + "\n";
508
+ }
509
+ const result = extractToolOutput(u);
510
+ if (result) {
511
+ out += "\n" + fence(truncateMiddleLines(result, OUTPUT_PREVIEW_LINES, OUTPUT_PREVIEW_MAX)) + "\n";
512
+ }
228
513
  return out;
229
514
  }
230
515
 
@@ -235,7 +520,7 @@ function buildEditDiff(u: SessionUpdate, raw: Record<string, unknown>, maxLines:
235
520
  const diffBlock = blocks.find((b) => b.type === "diff");
236
521
  if (diffBlock) {
237
522
  return renderUnifiedDiff({
238
- path: strOf(diffBlock.path) || strOf(raw.path) || "file",
523
+ path: strOf(diffBlock.path) || extractPath(raw) || "file",
239
524
  oldText: typeof diffBlock.oldText === "string" ? diffBlock.oldText : "",
240
525
  newText: typeof diffBlock.newText === "string" ? diffBlock.newText : "",
241
526
  maxLines,
@@ -244,11 +529,16 @@ function buildEditDiff(u: SessionUpdate, raw: Record<string, unknown>, maxLines:
244
529
  const oldStr = strOf(raw.old_str) || strOf(raw.oldStr) || strOf(raw.old_string) || strOf(raw.find);
245
530
  const newStr = strOf(raw.new_str) || strOf(raw.newStr) || strOf(raw.new_string) || strOf(raw.replace);
246
531
  if (oldStr || newStr) {
247
- return renderUnifiedDiff({ path: strOf(raw.path) || "file", oldText: oldStr, newText: newStr, maxLines });
532
+ return renderUnifiedDiff({
533
+ path: extractPath(raw) || "file",
534
+ oldText: oldStr,
535
+ newText: newStr,
536
+ maxLines,
537
+ });
248
538
  }
249
539
  const content = strOf(raw.file_text) || strOf(raw.content) || strOf(raw.text);
250
540
  if (content) {
251
- return renderUnifiedDiff({ path: strOf(raw.path) || "file", oldText: "", newText: content, maxLines });
541
+ return renderUnifiedDiff({ path: extractPath(raw) || "file", oldText: "", newText: content, maxLines });
252
542
  }
253
543
  return undefined;
254
544
  }
@@ -258,62 +548,52 @@ function buildEditDiff(u: SessionUpdate, raw: Record<string, unknown>, maxLines:
258
548
  function detectLang(path: string): string {
259
549
  const ext = (path.split(".").pop() || "").toLowerCase();
260
550
  const MAP: Record<string, string> = {
261
- ts: "typescript", tsx: "tsx", js: "javascript", jsx: "jsx",
262
- py: "python", go: "go", rs: "rust", java: "java",
263
- c: "c", cpp: "cpp", h: "c", hpp: "cpp",
264
- cs: "csharp", rb: "ruby", php: "php", swift: "swift",
265
- kt: "kotlin", scala: "scala", sh: "bash", bash: "bash",
266
- sql: "sql", html: "html", css: "css", scss: "scss",
267
- json: "json", yaml: "yaml", yml: "yaml", xml: "xml",
268
- md: "markdown", toml: "toml", ini: "ini", cfg: "ini",
269
- vue: "vue", svelte: "svelte", dart: "dart", lua: "lua",
270
- r: "r", pl: "perl", ps1: "powershell",
551
+ ts: "typescript",
552
+ tsx: "tsx",
553
+ js: "javascript",
554
+ jsx: "jsx",
555
+ py: "python",
556
+ go: "go",
557
+ rs: "rust",
558
+ java: "java",
559
+ c: "c",
560
+ cpp: "cpp",
561
+ h: "c",
562
+ hpp: "cpp",
563
+ cs: "csharp",
564
+ rb: "ruby",
565
+ php: "php",
566
+ swift: "swift",
567
+ kt: "kotlin",
568
+ scala: "scala",
569
+ sh: "bash",
570
+ bash: "bash",
571
+ sql: "sql",
572
+ html: "html",
573
+ css: "css",
574
+ scss: "scss",
575
+ json: "json",
576
+ yaml: "yaml",
577
+ yml: "yaml",
578
+ xml: "xml",
579
+ md: "markdown",
580
+ toml: "toml",
581
+ ini: "ini",
582
+ cfg: "ini",
583
+ vue: "vue",
584
+ svelte: "svelte",
585
+ dart: "dart",
586
+ lua: "lua",
587
+ r: "r",
588
+ pl: "perl",
589
+ ps1: "powershell",
271
590
  };
272
591
  return MAP[ext] || "";
273
592
  }
274
593
 
275
- // ---- MCP helpers ----
276
-
277
- /** Compact one-line-per-key preview of an MCP call's arguments. */
278
- function mcpArgPreview(raw: Record<string, unknown>): string {
279
- const SKIP = new Set(["tool_name", "toolName", "name", "tool", "type", "_meta"]);
280
- const lines: string[] = [];
281
- for (const [key, val] of Object.entries(raw)) {
282
- if (SKIP.has(key)) continue;
283
- let s: string;
284
- if (typeof val === "string") s = val;
285
- else if (typeof val === "number" || typeof val === "boolean") s = String(val);
286
- else {
287
- try { s = JSON.stringify(val); } catch { s = String(val); }
288
- }
289
- if (s.length > 200) s = s.slice(0, 199) + "\u2026";
290
- lines.push(key + ": " + s);
291
- }
292
- return truncate(lines.join("\n"), PREVIEW_MAX);
293
- }
594
+ // ---- skill ----
294
595
 
295
- /** Built-in Grok tools that must never be labelled as MCP calls. */
296
- const BUILTIN_TOOLS = new Set([
297
- "read", "write", "shell", "grep", "glob", "web_fetch", "web_search", "fs_read",
298
- "fs_write", "fs_replace", "fs_search", "execute_bash", "report_issue", "use_aws",
299
- "todo_list", "introspect", "knowledge", "thinking", "summary", "subagent",
300
- "edit", "create", "delete", "move", "rename", "execute", "search", "fetch",
301
- ]);
302
- /** Tool kinds that are first-class file/shell operations (never MCP). */
303
- const FILE_KINDS = new Set([
304
- "read", "edit", "execute", "search", "delete", "move", "write", "create",
305
- "rename", "fetch", "web_fetch", "web_search",
306
- ]);
307
- /** `.../skills/<name>/SKILL.md` - the signature of loading a skill. */
308
596
  const SKILL_RE = /[\\/]skills[\\/]([^\\/]+)[\\/]SKILL\.md$/i;
309
- /** Namespaced MCP tool-name shapes - [, server, method]. */
310
- const MCP_NS = [
311
- /^@([a-z0-9._-]+)[/_]{1,3}(.+)$/i,
312
- /^([a-z0-9.-]+)___(.+)$/i,
313
- /^([a-z0-9.-]+)__(.+)$/i,
314
- /^([a-z0-9.-]+)\/(.+)$/i,
315
- /^([a-z0-9-]+)\.(.+)$/i,
316
- ];
317
597
 
318
598
  function detectSkill(u: SessionUpdate, raw: Record<string, unknown>): string | undefined {
319
599
  for (const p of gatherPaths(u, raw)) {
@@ -323,34 +603,17 @@ function detectSkill(u: SessionUpdate, raw: Record<string, unknown>): string | u
323
603
  return undefined;
324
604
  }
325
605
 
326
- function detectMcp(
327
- u: SessionUpdate,
328
- raw: Record<string, unknown>,
329
- kind: string,
330
- ): { server?: string; method: string } | undefined {
331
- const name = mcpToolName(u, raw);
332
- if (!name) return undefined;
333
- for (const re of MCP_NS) {
334
- const m = re.exec(name);
335
- if (m) return { server: m[1]!, method: m[2]! };
336
- }
337
- if (!BUILTIN_TOOLS.has(name.toLowerCase()) && !FILE_KINDS.has(kind)) {
338
- return { method: name };
339
- }
340
- return undefined;
341
- }
342
-
343
- function mcpToolName(u: SessionUpdate, raw: Record<string, unknown>): string {
344
- const explicit = strOf(raw.tool_name) || strOf(raw.toolName) || strOf(raw.name) || strOf(raw.tool);
345
- if (explicit) return explicit;
346
- const t = (u.title || "").trim();
347
- return /^[@a-z0-9._/-]+$/i.test(t) && !t.includes(":") ? t : "";
348
- }
349
-
350
606
  function capitalize(s: string): string {
351
607
  return s.length ? s[0]!.toUpperCase() + s.slice(1) : s;
352
608
  }
353
609
 
354
610
  function strOf(v: unknown): string {
355
611
  return typeof v === "string" ? v : "";
356
- }
612
+ }
613
+
614
+ function numStr(v: unknown): string {
615
+ return typeof v === "number" && Number.isFinite(v) ? String(v) : "";
616
+ }
617
+
618
+ // Re-export for tests / step-from-tool helpers.
619
+ export { extractToolName, resolveToolIdentity };