grok-telegram-bot 2.4.0 → 2.5.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 (78) hide show
  1. package/.env.example +38 -2
  2. package/CHANGELOG.md +119 -1
  3. package/README.md +58 -15
  4. package/docs/GROUP.md +225 -0
  5. package/docs/INSTALL.md +3 -0
  6. package/package.json +1 -1
  7. package/src/app/lifetime-flag.ts +20 -0
  8. package/src/app/settings-store.ts +47 -8
  9. package/src/app/types.ts +12 -1
  10. package/src/app/updater.ts +24 -3
  11. package/src/bot/auth.ts +96 -15
  12. package/src/bot/bot.ts +122 -15
  13. package/src/bot/chat-controller.ts +52 -18
  14. package/src/bot/commands.ts +69 -29
  15. package/src/bot/deps.ts +3 -0
  16. package/src/bot/group-memory.ts +159 -0
  17. package/src/bot/handlers/accounts.ts +7 -0
  18. package/src/bot/handlers/control.ts +85 -32
  19. package/src/bot/handlers/document.ts +31 -4
  20. package/src/bot/handlers/forum.ts +207 -0
  21. package/src/bot/handlers/menu.ts +86 -24
  22. package/src/bot/handlers/message.ts +101 -21
  23. package/src/bot/handlers/photo.ts +123 -16
  24. package/src/bot/handlers/running.ts +150 -24
  25. package/src/bot/handlers/session-card.ts +13 -5
  26. package/src/bot/handlers/sessions.ts +68 -18
  27. package/src/bot/handlers/voice.ts +52 -7
  28. package/src/bot/image-return.ts +8 -5
  29. package/src/bot/menu/ephemeral.ts +13 -3
  30. package/src/bot/menu/keyboard.ts +53 -14
  31. package/src/bot/menu/refresh.ts +3 -1
  32. package/src/bot/menu/status-panel.ts +12 -6
  33. package/src/bot/permission-service.ts +19 -0
  34. package/src/bot/prompt-anchor.ts +300 -0
  35. package/src/bot/prompt-content.ts +3 -0
  36. package/src/bot/registry.ts +94 -1
  37. package/src/bot/scope.ts +94 -0
  38. package/src/bot/session-runtime.ts +647 -158
  39. package/src/bot/suggestions.ts +91 -31
  40. package/src/bot/telegram-actions.ts +440 -0
  41. package/src/bot/telegram-bots.ts +495 -0
  42. package/src/bot/telegram-io.ts +94 -10
  43. package/src/cli.ts +2 -0
  44. package/src/config.ts +201 -2
  45. package/src/forum/bind-path.ts +146 -0
  46. package/src/forum/manager.ts +651 -0
  47. package/src/forum/project-icon.ts +142 -0
  48. package/src/forum/thread.ts +16 -0
  49. package/src/forum/topic-store.ts +114 -0
  50. package/src/forum/types.ts +29 -0
  51. package/src/grok/client.ts +130 -28
  52. package/src/index.ts +205 -75
  53. package/src/projects/manager.ts +16 -3
  54. package/src/render/chunk.ts +17 -10
  55. package/src/render/hashtags.ts +5 -1
  56. package/src/render/session-comment.ts +64 -7
  57. package/src/render/telegram-bridge.ts +360 -0
  58. package/src/render/tool-call.ts +56 -37
  59. package/src/service/platform.ts +44 -7
  60. package/src/service/windows.ts +16 -4
  61. package/src/sessions/history.ts +50 -9
  62. package/src/sessions/process.ts +7 -0
  63. package/src/sessions/types.ts +2 -2
  64. package/src/stream/streamer.ts +17 -6
  65. package/scripts/analyze-jsonl.ts +0 -33
  66. package/scripts/delayed-restart.ps1 +0 -29
  67. package/scripts/probe-exit-response-shape.py +0 -77
  68. package/scripts/probe-plan-exit.py +0 -60
  69. package/scripts/probe-plan-exit2.py +0 -48
  70. package/scripts/probe-plan-fields.py +0 -41
  71. package/scripts/probe-plan-fields2.py +0 -58
  72. package/scripts/probe-plan-response-path.py +0 -48
  73. package/scripts/sample-claude-tooluse.ts +0 -21
  74. package/scripts/sample-kiro-events.ts +0 -31
  75. package/scripts/smoke-exit-plan.ts +0 -274
  76. package/scripts/smoke-exit-shapes.ts +0 -252
  77. package/scripts/smoke-import.mjs +0 -82
  78. package/scripts/smoke-import.ts +0 -73
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Telegram bridge protocol: first-prompt directive + parse/strip of agent
3
+ * JSON action blocks (`{"telegram":[...]}`) from assistant responses.
4
+ *
5
+ * Keep TELEGRAM_BRIDGE_DIRECTIVE tidy-idempotent (no trailing spaces / 3+ blank
6
+ * lines, no digit `{progress:…}` tokens) so history cleaners can strip by
7
+ * exact match after extractProgress/tidy.
8
+ */
9
+ import type { PromptInput } from "../app/types.js";
10
+
11
+ /** Marker prefix for first-prompt teaching block (used by strip / history). */
12
+ export const TELEGRAM_BRIDGE_MARKER = "TELEGRAM BRIDGE (how to work in this chat):";
13
+
14
+ /** Marker for results injected back into the agent after actions run. */
15
+ export const TELEGRAM_BRIDGE_RESULTS_MARKER =
16
+ "TELEGRAM BRIDGE RESULTS (system — use these facts; do not re-emit the same request unless needed):";
17
+
18
+ /** Max actions accepted from one agent turn (create + path + several prompts). */
19
+ export const TELEGRAM_ACTION_MAX = 9;
20
+ /** Max bot_command actions per turn. */
21
+ export const TELEGRAM_BOT_COMMAND_MAX = 2;
22
+ /** Max send_prompt actions per turn (cross-topic work). */
23
+ export const TELEGRAM_SEND_PROMPT_MAX = 5;
24
+
25
+ export type TelegramAction =
26
+ | { action: "create_topic"; name: string; path?: string }
27
+ | { action: "set_path"; topic: string; path: string }
28
+ | { action: "send_prompt"; topic: string; prompt: string; newSession?: boolean }
29
+ | { action: "search_memory"; query: string; limit?: number }
30
+ | { action: "list_bots" }
31
+ | { action: "bot_command"; bot: string; command: string; args?: string };
32
+
33
+ export interface TelegramActionExtract {
34
+ actions: TelegramAction[];
35
+ /** Text with telegram action fences removed. */
36
+ cleaned: string;
37
+ }
38
+
39
+ /**
40
+ * Static teaching block. Dynamic capability lines are appended by
41
+ * {@link buildTelegramBridgeDirective}.
42
+ */
43
+ export const TELEGRAM_BRIDGE_DIRECTIVE_BASE = [
44
+ TELEGRAM_BRIDGE_MARKER,
45
+ "You are driven by the Grok Telegram Bot bridge over ACP. Beyond normal coding tools you may request Telegram-side actions by putting a fenced JSON block in your response (language json preferred), BEFORE the final progress marker line.",
46
+ "",
47
+ "Format (one block; multiple actions allowed, run in order — up to 9):",
48
+ "```json",
49
+ '{ "telegram": [',
50
+ ' { "action": "create_topic", "name": "Topic title", "path": "optional absolute project path or exact catalog name" },',
51
+ ' { "action": "set_path", "topic": "Topic title or #threadId", "path": "absolute path or exact catalog name" },',
52
+ ' { "action": "send_prompt", "topic": "Topic title or #threadId", "prompt": "work for that topic", "new_session": false },',
53
+ ' { "action": "search_memory", "query": "keywords about past work", "limit": 8 },',
54
+ ' { "action": "list_bots" },',
55
+ ' { "action": "bot_command", "bot": "username_without_at", "command": "status", "args": "optional" }',
56
+ "] }",
57
+ "```",
58
+ "",
59
+ "Actions:",
60
+ "- create_topic — new forum topic; optional path binds the project immediately.",
61
+ " Absolute paths that do not exist yet are created on disk (new project flow).",
62
+ "- set_path — bind/rebind an existing topic to a project path (absolute dir or exact catalog name).",
63
+ " Absolute paths that do not exist yet are created on disk.",
64
+ "- send_prompt — start or queue a prompt in another topic's session (does not wait for that turn to finish).",
65
+ " From General/AI Chat (GROK_WORKSPACE) you can create a project topic, set_path, then send_prompt with multi-step work.",
66
+ " topic = exact topic title, #threadId, \"general\", or \"ai chat\". Optional new_session=true starts a fresh session there.",
67
+ "- search_memory — search indexed forum topics + session titles/comments/history.",
68
+ "- list_bots — list allowlisted sibling Telegram bots and command catalogs.",
69
+ "- bot_command — /command@bot; waits for that bot to settle. NOT a Done; timeouts return ok=false.",
70
+ "",
71
+ "Example (from General): create project topic + path + kick off work there:",
72
+ '```json',
73
+ '{ "telegram": [',
74
+ ' { "action": "create_topic", "name": "MyApp", "path": "H:\\\\Projects\\\\MyApp" },',
75
+ ' { "action": "send_prompt", "topic": "MyApp", "prompt": "1) scaffold\\n2) tests\\n3) README" }',
76
+ "] }",
77
+ "```",
78
+ "",
79
+ "After you emit these actions, the bridge runs them and may send TELEGRAM BRIDGE RESULTS. Use those facts; do not invent replies.",
80
+ "The bridge strips the JSON fence from the user-visible message. Prefer plain prose for the user; put protocol only in the fence.",
81
+ "Do not spam actions. Prefer one orchestration block per turn.",
82
+ ].join("\n");
83
+
84
+ export interface TelegramBridgeCaps {
85
+ forumReady: boolean;
86
+ topicGroupId?: number;
87
+ allowedBots: string[];
88
+ /** username → command list for first-prompt teaching */
89
+ botCommands?: Record<string, Array<{ command: string; description?: string }>>;
90
+ }
91
+
92
+ /** Full first-prompt directive including live capabilities. */
93
+ export function buildTelegramBridgeDirective(caps: TelegramBridgeCaps): string {
94
+ const lines = [TELEGRAM_BRIDGE_DIRECTIVE_BASE, "", "Capabilities right now:"];
95
+ if (caps.forumReady && caps.topicGroupId !== undefined) {
96
+ lines.push(
97
+ `- Forum topics: READY (group ${caps.topicGroupId}). You may create_topic, set_path, and send_prompt.`,
98
+ `- General / AI Chat sessions use GROK_WORKSPACE; other topics use their bound project path.`,
99
+ );
100
+ } else if (caps.topicGroupId !== undefined) {
101
+ lines.push(
102
+ `- Forum topics: NOT READY (group ${caps.topicGroupId} configured but setup failed or bot is not admin). Do not rely on create_topic / set_path / send_prompt.`,
103
+ );
104
+ } else {
105
+ lines.push("- Forum topics: OFF (TOPIC_GROUP_ID unset). create_topic / set_path / send_prompt will fail.");
106
+ }
107
+ if (caps.allowedBots.length > 0) {
108
+ lines.push(
109
+ `- Sibling bots (allowlist): ${caps.allowedBots.map((b) => "@" + b).join(", ")}. Use list_bots / bot_command like MCP.`,
110
+ );
111
+ for (const u of caps.allowedBots) {
112
+ const cmds = caps.botCommands?.[u];
113
+ if (cmds && cmds.length > 0) {
114
+ lines.push(
115
+ ` - @${u}: ${cmds
116
+ .map((c) => (c.description ? `/${c.command} (${c.description})` : `/${c.command}`))
117
+ .join(", ")}`,
118
+ );
119
+ }
120
+ }
121
+ } else {
122
+ lines.push(
123
+ "- Sibling bots: none configured (ALLOWED_TELEGRAM_BOTS empty). list_bots returns empty; bot_command disabled.",
124
+ );
125
+ }
126
+ lines.push("- search_memory: always available against bot-owned session/topic indexes.");
127
+ return lines.join("\n");
128
+ }
129
+
130
+ /**
131
+ * Prepend the telegram bridge teaching block (idempotent if already present).
132
+ * Call after complexity wrapping so it sits between complexity and user task body.
133
+ */
134
+ export function wrapTelegramBridgePrompt(input: PromptInput, directive: string): PromptInput {
135
+ const body = input.text.trim() || "(see attached media / files)";
136
+ if (body.includes(TELEGRAM_BRIDGE_MARKER)) return input;
137
+ // Prefer inserting after "User task:\n" when complexity wrapper is present.
138
+ const marker = "User task:";
139
+ const idx = body.indexOf(marker);
140
+ if (idx !== -1) {
141
+ const before = body.slice(0, idx + marker.length);
142
+ const after = body.slice(idx + marker.length);
143
+ return {
144
+ ...input,
145
+ text: `${before}\n\n${directive}\n\nUser task (continued):\n${after.trimStart()}`,
146
+ };
147
+ }
148
+ return {
149
+ ...input,
150
+ text: `${directive}\n\n${body}`,
151
+ };
152
+ }
153
+
154
+ /** True when text is the bridge results follow-up (meta; skip recheck). */
155
+ export function isTelegramBridgeResultsPrompt(text: string): boolean {
156
+ return text.trimStart().startsWith(TELEGRAM_BRIDGE_RESULTS_MARKER);
157
+ }
158
+
159
+ /** Build the meta prompt that feeds action results back to the agent. */
160
+ export function buildTelegramBridgeResultsPrompt(results: unknown[]): string {
161
+ const payload = JSON.stringify({ results }, null, 2);
162
+ return [
163
+ TELEGRAM_BRIDGE_RESULTS_MARKER,
164
+ "```json",
165
+ payload,
166
+ "```",
167
+ "Continue the user's task with this information. Do not ask the user to paste results. Do not re-emit the same telegram actions unless something failed and a retry is useful.",
168
+ ].join("\n");
169
+ }
170
+
171
+ /**
172
+ * Extract telegram actions from fenced JSON blocks and strip those fences from
173
+ * the visible text. Non-telegram fences are left intact.
174
+ */
175
+ export function extractTelegramActions(text: string): TelegramActionExtract {
176
+ if (!text) return { actions: [], cleaned: text };
177
+ const actions: TelegramAction[] = [];
178
+ // Match complete fenced blocks (``` or ```json etc.).
179
+ const fenceRe = /```(?:json|JSON)?\s*\r?\n([\s\S]*?)```/g;
180
+ let cleaned = text.replace(fenceRe, (full, body: string) => {
181
+ const parsed = tryParseTelegramFence(body);
182
+ if (!parsed) return full;
183
+ for (const a of parsed) {
184
+ if (actions.length >= TELEGRAM_ACTION_MAX) break;
185
+ actions.push(a);
186
+ }
187
+ return "";
188
+ });
189
+ // Hide a trailing incomplete ```json … telegram block mid-stream.
190
+ cleaned = cleaned.replace(/```(?:json|JSON)?\s*\r?\n[\s\S]*$/i, (tail) => {
191
+ if (
192
+ /"telegram"\s*:/i.test(tail) ||
193
+ /"action"\s*:\s*"(?:create_topic|set_path|send_prompt|search_memory|list_bots|bot_command)"/i.test(
194
+ tail,
195
+ )
196
+ ) {
197
+ return "";
198
+ }
199
+ return tail;
200
+ });
201
+ cleaned = tidy(cleaned);
202
+ return { actions: capBotCommands(actions), cleaned };
203
+ }
204
+
205
+ /** Strip only (no need for actions) — streamer path. */
206
+ export function stripTelegramActionFences(text: string): string {
207
+ return extractTelegramActions(text).cleaned;
208
+ }
209
+
210
+ function tryParseTelegramFence(body: string): TelegramAction[] | undefined {
211
+ const raw = body.trim();
212
+ if (!raw) return undefined;
213
+ let parsed: unknown;
214
+ try {
215
+ parsed = JSON.parse(raw);
216
+ } catch {
217
+ return undefined;
218
+ }
219
+ const list = coerceActionList(parsed);
220
+ if (!list) return undefined;
221
+ const out: TelegramAction[] = [];
222
+ for (const item of list) {
223
+ const a = normalizeAction(item);
224
+ if (a) out.push(a);
225
+ }
226
+ return out.length > 0 ? out : undefined;
227
+ }
228
+
229
+ function coerceActionList(parsed: unknown): unknown[] | undefined {
230
+ if (Array.isArray(parsed)) {
231
+ if (parsed.length === 0) return undefined;
232
+ // Bare array only if every element looks like an action.
233
+ if (parsed.every((x) => x && typeof x === "object" && "action" in (x as object))) {
234
+ return parsed;
235
+ }
236
+ return undefined;
237
+ }
238
+ if (!parsed || typeof parsed !== "object") return undefined;
239
+ const rec = parsed as Record<string, unknown>;
240
+ if ("telegram" in rec) {
241
+ const t = rec.telegram;
242
+ if (Array.isArray(t)) return t;
243
+ if (t && typeof t === "object") return [t];
244
+ return undefined;
245
+ }
246
+ // Single action object at top level.
247
+ if (typeof rec.action === "string") return [rec];
248
+ return undefined;
249
+ }
250
+
251
+ function normalizeAction(item: unknown): TelegramAction | undefined {
252
+ if (!item || typeof item !== "object") return undefined;
253
+ const rec = item as Record<string, unknown>;
254
+ const action = String(rec.action ?? rec.type ?? "")
255
+ .trim()
256
+ .toLowerCase()
257
+ .replace(/-/g, "_");
258
+
259
+ switch (action) {
260
+ case "create_topic": {
261
+ const name = String(rec.name ?? rec.title ?? "").trim();
262
+ if (!name) return undefined;
263
+ const path = String(rec.path ?? rec.project_path ?? rec.projectPath ?? "").trim();
264
+ return path
265
+ ? { action: "create_topic", name: name.slice(0, 128), path: path.slice(0, 500) }
266
+ : { action: "create_topic", name: name.slice(0, 128) };
267
+ }
268
+ case "set_path":
269
+ case "bind_path":
270
+ case "bind_topic": {
271
+ const topic = String(rec.topic ?? rec.name ?? rec.thread ?? rec.thread_id ?? rec.threadId ?? "").trim();
272
+ const path = String(rec.path ?? rec.project_path ?? rec.projectPath ?? "").trim();
273
+ if (!topic || !path) return undefined;
274
+ return {
275
+ action: "set_path",
276
+ topic: topic.slice(0, 128),
277
+ path: path.slice(0, 500),
278
+ };
279
+ }
280
+ case "send_prompt":
281
+ case "topic_prompt":
282
+ case "prompt_topic": {
283
+ const topic = String(rec.topic ?? rec.name ?? rec.thread ?? rec.thread_id ?? rec.threadId ?? "").trim();
284
+ const prompt = String(rec.prompt ?? rec.text ?? rec.message ?? "").trim();
285
+ if (!topic || !prompt) return undefined;
286
+ const newSessionRaw = rec.new_session ?? rec.newSession ?? rec.fresh;
287
+ const newSession =
288
+ newSessionRaw === true ||
289
+ newSessionRaw === 1 ||
290
+ /^(1|true|yes|y)$/i.test(String(newSessionRaw ?? "").trim());
291
+ return {
292
+ action: "send_prompt",
293
+ topic: topic.slice(0, 128),
294
+ prompt: prompt.slice(0, 4000),
295
+ newSession: newSession || undefined,
296
+ };
297
+ }
298
+ case "search_memory":
299
+ case "search":
300
+ case "memory_search": {
301
+ const query = String(rec.query ?? rec.q ?? rec.text ?? "").trim();
302
+ if (!query) return undefined;
303
+ let limit = Number(rec.limit ?? rec.max ?? 8);
304
+ if (!Number.isFinite(limit)) limit = 8;
305
+ limit = Math.max(1, Math.min(20, Math.round(limit)));
306
+ return { action: "search_memory", query: query.slice(0, 300), limit };
307
+ }
308
+ case "list_bots":
309
+ case "bots":
310
+ return { action: "list_bots" };
311
+ case "bot_command":
312
+ case "call_bot":
313
+ case "invoke_bot": {
314
+ const bot = normalizeUsername(String(rec.bot ?? rec.username ?? ""));
315
+ const command = String(rec.command ?? rec.cmd ?? "")
316
+ .trim()
317
+ .replace(/^\//, "");
318
+ if (!bot || !command) return undefined;
319
+ const args = String(rec.args ?? rec.arguments ?? rec.text ?? "").trim();
320
+ return {
321
+ action: "bot_command",
322
+ bot,
323
+ command: command.slice(0, 64),
324
+ args: args ? args.slice(0, 500) : undefined,
325
+ };
326
+ }
327
+ default:
328
+ return undefined;
329
+ }
330
+ }
331
+
332
+ function capBotCommands(actions: TelegramAction[]): TelegramAction[] {
333
+ let botCmds = 0;
334
+ let sendPrompts = 0;
335
+ const out: TelegramAction[] = [];
336
+ for (const a of actions) {
337
+ if (a.action === "bot_command") {
338
+ if (botCmds >= TELEGRAM_BOT_COMMAND_MAX) continue;
339
+ botCmds++;
340
+ }
341
+ if (a.action === "send_prompt") {
342
+ if (sendPrompts >= TELEGRAM_SEND_PROMPT_MAX) continue;
343
+ sendPrompts++;
344
+ }
345
+ out.push(a);
346
+ if (out.length >= TELEGRAM_ACTION_MAX) break;
347
+ }
348
+ return out;
349
+ }
350
+
351
+ export function normalizeUsername(raw: string): string {
352
+ return raw.trim().replace(/^@/, "").toLowerCase();
353
+ }
354
+
355
+ function tidy(s: string): string {
356
+ return s
357
+ .replace(/[ \t]+\n/g, "\n")
358
+ .replace(/\n{3,}/g, "\n\n")
359
+ .replace(/\s+$/g, "");
360
+ }
@@ -159,7 +159,24 @@ function formatByKind(
159
159
  }
160
160
  }
161
161
 
162
- // ---- helpers for code fences ----
162
+ // ---- helpers for code fences / path labels ----
163
+
164
+ /**
165
+ * File paths in **bold** break Telegram MarkdownV2 (Windows `\`, dots, long
166
+ * session paths). Always put paths in inline code; keep only the verb bold.
167
+ * ✏️ **Edit** `C:\Users\…\plan.md`
168
+ */
169
+ function pathCode(path: string | undefined | null, fallback = "file"): string {
170
+ const p = (path || "").trim() || fallback;
171
+ // Inline code cannot contain raw backticks unescaped in our MD pipeline;
172
+ // replace rare ` in paths so the span stays closed.
173
+ return "`" + p.replace(/`/g, "'") + "`";
174
+ }
175
+
176
+ /** `**Edit** \`path\`` style header fragment (no leading emoji). */
177
+ function boldVerbPath(verb: string, path: string | undefined | null, fallback = "file"): string {
178
+ return "**" + verb + "** " + pathCode(path, fallback);
179
+ }
163
180
 
164
181
  /** Fence that lengthens itself when the body contains backticks (avoids MD break). */
165
182
  function fence(text: string, lang?: string): string {
@@ -197,8 +214,8 @@ function formatExecute(
197
214
  ): string {
198
215
  const cmd = extractCommand(raw);
199
216
  const cwd = strOf(raw.cwd) || strOf(raw.working_directory) || strOf(raw.workingDirectory);
200
- const title = "Run command" + (cwd ? " in " + truncate(cwd, 80) : "");
201
- let out = "\u{1F4BB} **" + title + "**" + tail;
217
+ let out = "\u{1F4BB} **Run command**" + tail;
218
+ if (cwd) out += " in " + pathCode(truncate(cwd, 80));
202
219
  if (toolName && toolName !== "execute" && toolName !== "shell" && toolName !== "bash") {
203
220
  out += `\n tool: \`${toolName}\``;
204
221
  }
@@ -226,8 +243,7 @@ function formatEdit(
226
243
  toolName: string,
227
244
  ): string {
228
245
  const path = extractPath(raw);
229
- const title = "Edit " + (path || "file");
230
- let out = "\u270F\uFE0F **" + title + "**" + tail;
246
+ let out = "\u270F\uFE0F " + boldVerbPath("Edit", path) + tail;
231
247
  if (toolName && !/edit|replace/i.test(toolName)) out += `\n tool: \`${toolName}\``;
232
248
  if (opts.showDiffs) {
233
249
  const diff = buildEditDiff(u, raw, opts.diffMaxLines);
@@ -242,7 +258,7 @@ function formatEdit(
242
258
  function formatWrite(u: SessionUpdate, kind: string, raw: Record<string, unknown>, tail: string): string {
243
259
  const path = extractPath(raw);
244
260
  const verb = kind === "create" ? "Create" : "Write";
245
- let out = "\u{1F4DD} **" + verb + " " + (path || "file") + "**" + tail;
261
+ let out = "\u{1F4DD} " + boldVerbPath(verb, path) + tail;
246
262
  const content = extractContent(raw) || extractToolOutput(u);
247
263
  if (content) {
248
264
  out += "\n" + fence(truncateMiddleLines(content, OUTPUT_PREVIEW_LINES, CONTENT_PREVIEW_MAX), detectLang(path)) + "\n";
@@ -258,14 +274,13 @@ function formatRead(
258
274
  ): string {
259
275
  const path = extractPath(raw, u);
260
276
  const range = extractReadRange(raw);
261
- let title = "Read " + (path || "file");
262
277
  const parts: string[] = [];
263
278
  if (range.startLine) parts.push("line " + range.startLine);
264
279
  if (range.offset) parts.push("offset " + range.offset);
265
280
  if (range.limit) parts.push("limit " + range.limit);
266
281
  if (range.pages) parts.push("pages " + range.pages);
267
- if (parts.length) title += " (" + parts.join(", ") + ")";
268
- let out = "\u{1F4D6} **" + title + "**" + tail;
282
+ let out = "\u{1F4D6} " + boldVerbPath("Read", path) + tail;
283
+ if (parts.length) out += " (" + parts.join(", ") + ")";
269
284
  if (toolName && toolName !== "read" && toolName !== "read_file") {
270
285
  out += `\n tool: \`${toolName}\``;
271
286
  }
@@ -292,11 +307,11 @@ function formatList(
292
307
  strOf(raw.target_directory) ||
293
308
  strOf(raw.targetDirectory) ||
294
309
  ".";
295
- let out = "\u{1F4C1} **List " + truncate(path, 120) + "**" + tail;
310
+ let out = "\u{1F4C1} " + boldVerbPath("List", truncate(path, 120), ".") + tail;
296
311
  if (toolName) out += `\n tool: \`${toolName}\``;
297
312
  const filters = extractFilters(raw);
298
- if (filters.include) out += "\n include: " + filters.include;
299
- if (filters.exclude) out += "\n exclude: " + filters.exclude;
313
+ if (filters.include) out += "\n include: `" + filters.include.replace(/`/g, "'") + "`";
314
+ if (filters.exclude) out += "\n exclude: `" + filters.exclude.replace(/`/g, "'") + "`";
300
315
  const body = extractToolOutput(u);
301
316
  if (body) {
302
317
  out += "\n" + fence(truncateMiddleLines(body, OUTPUT_PREVIEW_LINES, OUTPUT_PREVIEW_MAX)) + "\n";
@@ -314,16 +329,16 @@ function formatSearch(
314
329
  const path = extractSearchPath(raw);
315
330
  const filters = extractFilters(raw);
316
331
  const isGlob = /glob/i.test(toolName) || (!!filters.include && !query);
317
- let title = isGlob ? "Glob" : "Search";
318
- if (query) title += ": " + truncate(query, 120);
319
- else if (path) title += " " + path;
320
- let out = "\u{1F50E} **" + title + "**" + tail;
332
+ const verb = isGlob ? "Glob" : "Search";
333
+ let out = "\u{1F50E} **" + verb + "**" + tail;
334
+ if (query) out += " " + pathCode(truncate(query, 120), "query");
335
+ else if (path) out += " " + pathCode(truncate(path, 120));
321
336
  if (toolName && toolName !== "search" && toolName !== "grep") {
322
337
  out += `\n tool: \`${toolName}\``;
323
338
  }
324
- if (path && !(query && query.includes(path))) out += "\n \u{1F4C2} in: " + truncate(path, 100);
325
- if (filters.include) out += "\n \u{1F4C1} include: " + filters.include;
326
- if (filters.exclude) out += "\n \u{1F6AB} exclude: " + filters.exclude;
339
+ if (path && query) out += "\n \u{1F4C2} in: " + pathCode(truncate(path, 100));
340
+ if (filters.include) out += "\n \u{1F4C1} include: " + pathCode(filters.include);
341
+ if (filters.exclude) out += "\n \u{1F6AB} exclude: " + pathCode(filters.exclude);
327
342
  if (raw.case_sensitive !== undefined) {
328
343
  out += "\n case-sensitive: " + (raw.case_sensitive ? "yes" : "no");
329
344
  }
@@ -338,7 +353,7 @@ function formatSearch(
338
353
 
339
354
  function formatDelete(raw: Record<string, unknown>, tail: string): string {
340
355
  const path = extractPath(raw);
341
- return "\u{1F5D1}\uFE0F **Delete " + (path || "file") + "**" + tail;
356
+ return "\u{1F5D1}\uFE0F " + boldVerbPath("Delete", path) + tail;
342
357
  }
343
358
 
344
359
  function formatMove(kind: string, raw: Record<string, unknown>, tail: string): string {
@@ -352,20 +367,20 @@ function formatMove(kind: string, raw: Record<string, unknown>, tail: string): s
352
367
  "**" +
353
368
  tail +
354
369
  "\n \u{1F4C4} " +
355
- truncate(src, 100) +
370
+ pathCode(truncate(src, 100)) +
356
371
  "\n \u27A1\uFE0F " +
357
- truncate(dst, 100)
372
+ pathCode(truncate(dst, 100))
358
373
  );
359
374
  }
360
- return "\u{1F4E6} **" + verb + " " + (src || dst || "file") + "**" + tail;
375
+ return "\u{1F4E6} " + boldVerbPath(verb, src || dst) + tail;
361
376
  }
362
377
 
363
378
  function formatFetch(u: SessionUpdate, raw: Record<string, unknown>, tail: string): string {
364
379
  const url = extractUrl(raw);
365
380
  const method = strOf(raw.method) || strOf(raw.verb) || "GET";
366
- let title = "Fetch URL";
367
- if (url) title = "Fetch " + truncate(url, 200);
368
- let out = "\u{1F310} **" + title + "**" + tail;
381
+ let out = url
382
+ ? "\u{1F310} **Fetch** " + pathCode(truncate(url, 200), "URL") + tail
383
+ : "\u{1F310} **Fetch URL**" + tail;
369
384
  if (method && method !== "GET") out += "\n method: " + method;
370
385
  const headers = raw.headers;
371
386
  if (headers && typeof headers === "object") {
@@ -384,9 +399,8 @@ function formatFetch(u: SessionUpdate, raw: Record<string, unknown>, tail: strin
384
399
  function formatWebSearch(u: SessionUpdate, raw: Record<string, unknown>, tail: string): string {
385
400
  const query = extractSearchQuery(raw) || extractUrl(raw);
386
401
  const count = strOf(raw.count) || strOf(raw.num) || strOf(raw.num_results) || numStr(raw.num_results);
387
- let title = "Web search";
388
- if (query) title += ": " + truncate(query, 150);
389
- let out = "\u{1F310} **" + title + "**" + tail;
402
+ let out = "\u{1F310} **Web search**" + tail;
403
+ if (query) out += " " + pathCode(truncate(query, 150), "query");
390
404
  if (count) out += "\n results: " + count;
391
405
  const result = extractToolOutput(u);
392
406
  if (result) {
@@ -428,7 +442,7 @@ function formatImage(raw: Record<string, unknown>, tail: string, toolName: strin
428
442
  const path = extractPath(raw) || strOf(raw.image) || strOf(raw.output);
429
443
  let out = "\u{1F5BC}\uFE0F **Image**" + tail;
430
444
  if (toolName) out += `\n tool: \`${toolName}\``;
431
- if (path) out += "\n " + truncate(path, 200);
445
+ if (path) out += "\n " + pathCode(truncate(path, 200));
432
446
  const prompt = strOf(raw.prompt);
433
447
  if (prompt) out += "\n prompt: " + truncate(prompt, 200);
434
448
  return out;
@@ -482,27 +496,32 @@ function formatGeneric(
482
496
  const path = extractPath(raw);
483
497
  const cmd = extractCommand(raw);
484
498
  const query = extractSearchQuery(raw);
485
- // Never show a bare "Other" / "Tool call" — prefer real tool name / path / cmd.
499
+ // Never show a bare "Other" / "Tool call" — prefer real tool name. Paths/queries
500
+ // go in inline code (not bold) so Windows paths cannot break MarkdownV2.
486
501
  let label =
487
502
  (toolName && !/^tool[_ ]?call$/i.test(toolName) ? toolName : "") ||
488
503
  (u.title && !/^other$/i.test(u.title.trim()) && !/^tool[_ ]?call$/i.test(u.title.trim())
489
504
  ? u.title.trim()
490
505
  : "") ||
491
- (path ? capitalize(kind !== "other" ? kind : "use") + " " + path : "") ||
506
+ (path ? capitalize(kind !== "other" ? kind : "use") : "") ||
492
507
  (cmd ? "Run command" : "") ||
493
- (query ? "Search: " + truncate(query, 80) : "") ||
508
+ (query ? "Search" : "") ||
494
509
  (kind && kind !== "other" ? capitalize(kind) : "") ||
495
510
  (toolName || "Tool");
496
511
  if (/^other$/i.test(label) || /^tool[_ ]?call$/i.test(label)) {
497
- label = toolName && !/^tool[_ ]?call$/i.test(toolName) ? toolName : path || cmd || query || "Tool";
512
+ label = toolName && !/^tool[_ ]?call$/i.test(toolName) ? toolName : "Tool";
513
+ }
514
+ // Titles that accidentally embed a path stay short: drop path from bold label.
515
+ if (path && label.includes(path)) {
516
+ label = label.replace(path, "").replace(/\s+/g, " ").trim() || capitalize(kind !== "other" ? kind : "Tool");
498
517
  }
499
518
 
500
519
  let out = icon + " **" + label + "**" + tail;
501
520
  if (toolName && toolName !== label) out += `\n tool: \`${toolName}\``;
502
- if (path && !label.includes(path)) out += "\n \u{1F4C4} " + truncate(path, 120);
521
+ if (path) out += "\n \u{1F4C4} " + pathCode(truncate(path, 120));
503
522
  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 {
523
+ else if (query) out += "\n query: " + pathCode(truncate(query, 150), "query");
524
+ else if (!path && !cmd) {
506
525
  const args = formatArgLines(raw);
507
526
  if (args) out += "\n" + fence(truncateMiddle(args, PREVIEW_MAX)) + "\n";
508
527
  }
@@ -2,7 +2,7 @@
2
2
  * Platform detection, launch-spec construction, and a small command runner
3
3
  * shared by the per-OS service controllers.
4
4
  */
5
- import { execFileSync } from "node:child_process";
5
+ import { execFileSync, spawn } from "node:child_process";
6
6
  import { join } from "node:path";
7
7
  import { PROJECT_ROOT, INSTANCE_DIR } from "../config.js";
8
8
 
@@ -47,18 +47,55 @@ export function buildLaunchSpec(): LaunchSpec {
47
47
  };
48
48
  }
49
49
 
50
- /** Run a command, returning combined output. Throws on non-zero exit. */
51
- export function run(cmd: string, args: string[]): string {
52
- return execFileSync(cmd, args, { encoding: "utf-8", stdio: ["ignore", "pipe", "pipe"] });
50
+ /** Default max wait for schtasks/systemctl/powershell helpers — never hang the CLI. */
51
+ const RUN_TIMEOUT_MS = 45_000;
52
+
53
+ /** Run a command, returning combined output. Throws on non-zero exit or timeout. */
54
+ export function run(cmd: string, args: string[], timeoutMs = RUN_TIMEOUT_MS): string {
55
+ return execFileSync(cmd, args, {
56
+ encoding: "utf-8",
57
+ stdio: ["ignore", "pipe", "pipe"],
58
+ timeout: timeoutMs,
59
+ });
53
60
  }
54
61
 
55
62
  /** Run a command, swallowing errors and returning { ok, out }. */
56
- export function runSafe(cmd: string, args: string[]): { ok: boolean; out: string } {
63
+ export function runSafe(cmd: string, args: string[], timeoutMs = RUN_TIMEOUT_MS): { ok: boolean; out: string } {
57
64
  try {
58
- return { ok: true, out: run(cmd, args) };
65
+ return { ok: true, out: run(cmd, args, timeoutMs) };
59
66
  } catch (e) {
60
- const err = e as { stdout?: Buffer | string; stderr?: Buffer | string; message?: string };
67
+ const err = e as {
68
+ stdout?: Buffer | string;
69
+ stderr?: Buffer | string;
70
+ message?: string;
71
+ killed?: boolean;
72
+ code?: string;
73
+ };
74
+ // Node sets killed=true when the timeout option aborts the child.
75
+ if (err.killed || err.code === "ETIMEDOUT") {
76
+ return { ok: false, out: `timed out after ${timeoutMs}ms: ${cmd} ${args.join(" ")}` };
77
+ }
61
78
  const out = String(err.stdout ?? "") + String(err.stderr ?? "") || err.message || "failed";
62
79
  return { ok: false, out };
63
80
  }
64
81
  }
82
+
83
+ /**
84
+ * Fire-and-forget process (detached). Use for forever-restart supervisors
85
+ * (Windows VBS loop) so `grok-tg install|start|restart` does not hang waiting
86
+ * for a process that never exits.
87
+ */
88
+ export function launchDetached(cmd: string, args: string[]): { ok: boolean; out: string } {
89
+ try {
90
+ const child = spawn(cmd, args, {
91
+ detached: true,
92
+ stdio: "ignore",
93
+ windowsHide: true,
94
+ });
95
+ child.unref();
96
+ if (child.pid == null) return { ok: false, out: "spawn produced no pid" };
97
+ return { ok: true, out: `pid ${child.pid}` };
98
+ } catch (e) {
99
+ return { ok: false, out: (e as Error).message || "spawn failed" };
100
+ }
101
+ }