grok-telegram-bot 2.4.0 → 2.6.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 (81) hide show
  1. package/.env.example +38 -2
  2. package/CHANGELOG.md +190 -1
  3. package/README.md +60 -15
  4. package/docs/GROUP.md +260 -0
  5. package/docs/INSTALL.md +3 -0
  6. package/package.json +4 -4
  7. package/src/app/lifetime-flag.ts +20 -0
  8. package/src/app/settings-store.ts +47 -8
  9. package/src/app/types.ts +38 -1
  10. package/src/app/updater.ts +24 -3
  11. package/src/bot/auth.ts +100 -15
  12. package/src/bot/bot.ts +193 -17
  13. package/src/bot/chat-controller.ts +181 -18
  14. package/src/bot/commands.ts +69 -29
  15. package/src/bot/deps.ts +3 -0
  16. package/src/bot/group-memory.ts +339 -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 +217 -0
  21. package/src/bot/handlers/menu.ts +86 -24
  22. package/src/bot/handlers/message.ts +247 -27
  23. package/src/bot/handlers/photo.ts +126 -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 +11 -5
  29. package/src/bot/manager-context.ts +208 -0
  30. package/src/bot/manager-jobs.ts +142 -0
  31. package/src/bot/menu/ephemeral.ts +16 -3
  32. package/src/bot/menu/keyboard.ts +53 -14
  33. package/src/bot/menu/refresh.ts +3 -1
  34. package/src/bot/menu/status-panel.ts +12 -6
  35. package/src/bot/permission-service.ts +19 -0
  36. package/src/bot/prompt-anchor.ts +299 -0
  37. package/src/bot/prompt-content.ts +8 -0
  38. package/src/bot/registry.ts +94 -1
  39. package/src/bot/scope.ts +95 -0
  40. package/src/bot/session-runtime.ts +1280 -183
  41. package/src/bot/suggestions.ts +91 -31
  42. package/src/bot/telegram-actions.ts +1130 -0
  43. package/src/bot/telegram-bots.ts +496 -0
  44. package/src/bot/telegram-io.ts +97 -10
  45. package/src/cli.ts +2 -0
  46. package/src/config.ts +201 -2
  47. package/src/forum/bind-path.ts +146 -0
  48. package/src/forum/manager.ts +652 -0
  49. package/src/forum/project-icon.ts +142 -0
  50. package/src/forum/thread.ts +49 -0
  51. package/src/forum/topic-store.ts +114 -0
  52. package/src/forum/types.ts +29 -0
  53. package/src/grok/client.ts +130 -28
  54. package/src/index.ts +205 -75
  55. package/src/projects/manager.ts +16 -3
  56. package/src/render/chunk.ts +17 -10
  57. package/src/render/hashtags.ts +5 -1
  58. package/src/render/manager-directive.ts +137 -0
  59. package/src/render/session-comment.ts +74 -7
  60. package/src/render/telegram-bridge.ts +464 -0
  61. package/src/render/tool-call.ts +56 -37
  62. package/src/service/platform.ts +44 -7
  63. package/src/service/windows.ts +16 -4
  64. package/src/sessions/history.ts +68 -9
  65. package/src/sessions/process.ts +7 -0
  66. package/src/sessions/types.ts +2 -2
  67. package/src/stream/streamer.ts +62 -15
  68. package/scripts/analyze-jsonl.ts +0 -33
  69. package/scripts/delayed-restart.ps1 +0 -29
  70. package/scripts/probe-exit-response-shape.py +0 -77
  71. package/scripts/probe-plan-exit.py +0 -60
  72. package/scripts/probe-plan-exit2.py +0 -48
  73. package/scripts/probe-plan-fields.py +0 -41
  74. package/scripts/probe-plan-fields2.py +0 -58
  75. package/scripts/probe-plan-response-path.py +0 -48
  76. package/scripts/sample-claude-tooluse.ts +0 -21
  77. package/scripts/sample-kiro-events.ts +0 -31
  78. package/scripts/smoke-exit-plan.ts +0 -274
  79. package/scripts/smoke-exit-shapes.ts +0 -252
  80. package/scripts/smoke-import.mjs +0 -82
  81. package/scripts/smoke-import.ts +0 -73
@@ -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
+ }
@@ -8,7 +8,7 @@
8
8
  */
9
9
  import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
10
10
  import { join } from "node:path";
11
- import { runSafe } from "./platform.js";
11
+ import { launchDetached, runSafe } from "./platform.js";
12
12
  import type { LaunchSpec, ServiceController, ServiceResult } from "./types.js";
13
13
 
14
14
  const TASK = "GrokTelegramBot";
@@ -105,7 +105,11 @@ export const windowsController: ServiceController = {
105
105
  } catch (e) {
106
106
  return fail(`Startup-folder install failed: ${(e as Error).message}`);
107
107
  }
108
- if (!isRunning(spec)) runSafe("wscript.exe", [startupVbs]); // launch now
108
+ // Detached: the VBS is a forever-restart loop — never wait on it (hangs install/start/restart).
109
+ if (!isRunning(spec)) {
110
+ const launched = launchDetached("wscript.exe", [startupVbs]);
111
+ if (!launched.ok) return fail(`Installed launcher but failed to start: ${launched.out}`);
112
+ }
109
113
  return ok(
110
114
  `Installed via the Startup folder — starts hidden at logon, no admin needed — and launched it.\n` +
111
115
  `(Tip: run "grok-tg install" from an elevated terminal to use a hidden Scheduled Task instead.)`,
@@ -124,13 +128,21 @@ export const windowsController: ServiceController = {
124
128
  async start(spec) {
125
129
  if (isRunning(spec)) return ok("Already running.");
126
130
  if (taskInstalled()) {
131
+ // schtasks /Run returns once the task is queued (does not wait for the bot).
127
132
  const res = runSafe("schtasks", ["/Run", "/TN", TASK]);
128
133
  return res.ok ? ok("Started.") : fail(res.out);
129
134
  }
130
135
  const startupVbs = startupVbsPath();
131
136
  if (startupVbs && existsSync(startupVbs)) {
132
- runSafe("wscript.exe", [startupVbs]);
133
- return ok("Started.");
137
+ // Forever-restart VBS — must be detached or this CLI never returns.
138
+ const launched = launchDetached("wscript.exe", [startupVbs]);
139
+ return launched.ok ? ok("Started.") : fail(launched.out);
140
+ }
141
+ // Local run-service.vbs in the package dir (task points here when elevated).
142
+ const local = vbsPath(spec);
143
+ if (existsSync(local)) {
144
+ const launched = launchDetached("wscript.exe", [local]);
145
+ return launched.ok ? ok("Started.") : fail(launched.out);
134
146
  }
135
147
  return fail(`Not installed. Run "grok-tg install" first.`);
136
148
  },
@@ -5,8 +5,20 @@
5
5
  import { closeSync, openSync, readSync, statSync } from "node:fs";
6
6
  import { IMAGE_OUTPUT_DIRECTIVE } from "../render/image-output.js";
7
7
  import { extractProgress, PROGRESS_DIRECTIVE } from "../render/progress.js";
8
+ import {
9
+ extractTelegramActions,
10
+ TELEGRAM_BRIDGE_MARKER,
11
+ TELEGRAM_BRIDGE_RESULTS_MARKER,
12
+ } from "../render/telegram-bridge.js";
13
+ import {
14
+ MANAGER_DIRECTIVE_MARKER,
15
+ MANAGER_WORK_REPORT_MARKER,
16
+ } from "../render/manager-directive.js";
8
17
  import type { HistoryEntry, HistoryRole } from "./types.js";
9
18
 
19
+ /** Keep in sync with bot/manager-context.ts (avoid sessions→bot import). */
20
+ const MANAGER_CONTEXT_MARKER = "MANAGER CONTEXT (auto — use before dispatching work):";
21
+
10
22
  const TAIL_WINDOWS = [256 * 1024, 1024 * 1024, 4 * 1024 * 1024]; // grow until entries found
11
23
 
12
24
  interface RawEvent {
@@ -49,6 +61,7 @@ export function jsonlMtimeMs(jsonlPath: string): number {
49
61
  /**
50
62
  * Best-effort card blurb from the tail of a session log: last assistant prose
51
63
  * (what was solved), else last user prompt. Skips import-confirm noise.
64
+ * @deprecated Prefer {@link readLastUserPrompt} for session card comments.
52
65
  */
53
66
  export function readLastCardSummary(jsonlPath: string, maxEntries = 30): string {
54
67
  const entries = readHistory(jsonlPath, maxEntries);
@@ -71,7 +84,24 @@ export function readLastCardSummary(jsonlPath: string, maxEntries = 30): string
71
84
  return "";
72
85
  }
73
86
 
74
- function cleanCardProse(raw: string, max = 200): string {
87
+ /**
88
+ * Last user prompt from the session log for card comments (newest → oldest).
89
+ * Strips complexity wrappers / import-confirm noise. Empty when none found.
90
+ */
91
+ export function readLastUserPrompt(jsonlPath: string, maxEntries = 40, maxLen = 250): string {
92
+ const entries = readHistory(jsonlPath, maxEntries);
93
+ for (let i = entries.length - 1; i >= 0; i--) {
94
+ const e = entries[i]!;
95
+ if (e.role !== "user" || !e.text.trim()) continue;
96
+ const t = cleanCardProse(e.text, maxLen);
97
+ if (!t) continue;
98
+ if (/session import complete/i.test(t)) continue;
99
+ return t;
100
+ }
101
+ return "";
102
+ }
103
+
104
+ function cleanCardProse(raw: string, max = 250): string {
75
105
  let t = extractProgress(raw).cleaned;
76
106
  t = t.replace(/```[\s\S]*?```/g, " ");
77
107
  t = t.replace(/^COMPLEXITY \(decide yourself[\s\S]*?User task:\s*/i, "");
@@ -203,23 +233,52 @@ function toEntry(ev: RawEvent): HistoryEntry | undefined {
203
233
  function cleanStoredText(text: string): string {
204
234
  if (!text) return text;
205
235
  let t = extractProgress(text).cleaned;
236
+ t = extractTelegramActions(t).cleaned;
206
237
  if (t.includes(PROGRESS_DIRECTIVE)) t = t.split(PROGRESS_DIRECTIVE).join("").trim();
207
238
  if (t.includes(IMAGE_OUTPUT_DIRECTIVE)) t = t.split(IMAGE_OUTPUT_DIRECTIVE).join("").trim();
208
- // Strip first-prompt auto-complexity steering (and legacy forced-complex wrapper)
209
- // so history / cards show the real user task, not bot plumbing.
210
- const taskMarker = "User task:";
211
- const ti = t.lastIndexOf(taskMarker);
212
- if (
213
- ti !== -1 &&
214
- (/^COMPLEXITY \(decide yourself/i.test(t) || /^TASK COMPLEXITY:/i.test(t))
239
+ // Prefer "User task (continued):" BEFORE plain "User task:" — the continued
240
+ // marker contains the substring "User task:", so lastIndexOf("User task:")
241
+ // would slice into "(continued):…" and leak bridge teaching into cards/logs.
242
+ const cont = "User task (continued):";
243
+ const ci = t.lastIndexOf(cont);
244
+ if (ci !== -1) {
245
+ t = t.slice(ci + cont.length).trim();
246
+ } else if (
247
+ /^COMPLEXITY \(decide yourself/i.test(t) ||
248
+ /^TASK COMPLEXITY:/i.test(t)
215
249
  ) {
216
- t = t.slice(ti + taskMarker.length).trim();
250
+ const taskMarker = "User task:";
251
+ const ti = t.indexOf(taskMarker);
252
+ if (ti !== -1) t = t.slice(ti + taskMarker.length).trim();
253
+ }
254
+ // Strip leftover telegram bridge teaching if still present (directive-only wrap).
255
+ if (t.includes(TELEGRAM_BRIDGE_MARKER)) {
256
+ const mi = t.indexOf(TELEGRAM_BRIDGE_MARKER);
257
+ if (mi === 0) {
258
+ const after = t.slice(TELEGRAM_BRIDGE_MARKER.length);
259
+ const dbl = after.search(/\n\n(?![-*`])/);
260
+ t = dbl !== -1 ? after.slice(dbl).trim() : "";
261
+ } else {
262
+ t = t.slice(0, mi).trim();
263
+ }
217
264
  }
218
265
  // Drop removed/quiet meta-prompts if they landed in history.
219
266
  if (/^Session status update \(meta only\)/i.test(t.trim())) t = "";
220
267
  if (/^FOLLOW-UP SUGGESTIONS \(meta only\)/i.test(t.trim())) t = "";
221
268
  if (/^SELF-RECHECK DECISION \(meta only\)/i.test(t.trim())) t = "";
222
269
  if (/^SELF-RECHECK \(automatic quality pass/i.test(t.trim())) t = "";
270
+ if (t.trimStart().startsWith(TELEGRAM_BRIDGE_RESULTS_MARKER)) t = "";
271
+ if (t.trimStart().startsWith(MANAGER_WORK_REPORT_MARKER)) t = "";
272
+ // Manager directive / context: keep only the user message tail.
273
+ if (t.includes(MANAGER_CONTEXT_MARKER)) {
274
+ const split = t.split(/\n---\n\n/);
275
+ if (split.length > 1) t = split.slice(1).join("\n---\n\n").trim();
276
+ }
277
+ if (t.includes(MANAGER_DIRECTIVE_MARKER)) {
278
+ const um = "User message:";
279
+ const ui = t.lastIndexOf(um);
280
+ if (ui !== -1) t = t.slice(ui + um.length).trim();
281
+ }
223
282
  return t;
224
283
  }
225
284
 
@@ -16,6 +16,13 @@ const log = createLogger("sessions:process");
16
16
  */
17
17
  export function killPid(pid: number): boolean {
18
18
  if (!Number.isInteger(pid) || pid <= 0) return false;
19
+ // Never kill this bot process (or a mistaken self-target). Session locks for
20
+ // multiplexed ACP turns store the child agent pid, not node — but a recycled
21
+ // or mis-attributed lock must not take the Telegram poller down.
22
+ if (pid === process.pid) {
23
+ log.warn(`refusing to kill pid ${pid} (this bot process)`);
24
+ return false;
25
+ }
19
26
  try {
20
27
  if (process.platform === "win32") {
21
28
  execFileSync("taskkill", ["/F", "/T", "/PID", String(pid)], { stdio: "ignore" });
@@ -14,8 +14,8 @@ export interface SessionMeta {
14
14
  /** Size of the .jsonl history in bytes (proxy for conversation length). */
15
15
  historyBytes: number;
16
16
  /**
17
- * Short status line for cards: current step while working, or chat summary
18
- * when idle (persisted by the bot after turns).
17
+ * Short status for cards: last user prompt (persisted by the bot). While a
18
+ * turn is live, runtime may append last agent thinking as a second line.
19
19
  */
20
20
  comment?: string;
21
21
  }
@@ -15,8 +15,10 @@ import { chunkMarkdown } from "../render/chunk.js";
15
15
  import { toTelegramMarkdown } from "../render/markdown.js";
16
16
  import { extractProgress, progressBar } from "../render/progress.js";
17
17
  import { estimateProgress } from "../render/progress-estimate.js";
18
+ import { stripTelegramActionFences } from "../render/telegram-bridge.js";
18
19
  import { truncateMiddle } from "../render/truncate.js";
19
20
  import { safeEdit, safeSend } from "../bot/telegram-io.js";
21
+ import { outboundThreadExtra } from "../forum/thread.js";
20
22
 
21
23
  const SOFT_LIMIT = 3500;
22
24
  /** Display budget for a thinking block (middle-truncated; session context keeps all). */
@@ -30,6 +32,18 @@ interface Seg {
30
32
  toolId?: string;
31
33
  }
32
34
 
35
+ export interface StreamerOptions {
36
+ /** Chat-like mode: drop thoughts/tools/plan; only stream agent prose. */
37
+ proseOnly?: boolean;
38
+ /** When false, never render a progress bar (manager chat). Default true. */
39
+ showProgressBar?: boolean;
40
+ /**
41
+ * Pre-posted message id to edit in place (e.g. General "Thinking…" placeholder).
42
+ * Avoids a separate bubble when the first real tokens arrive.
43
+ */
44
+ seedMessageId?: number;
45
+ }
46
+
33
47
  export class ResponseStreamer {
34
48
  private readonly segs: Seg[] = [];
35
49
  private sealedIdx = 0;
@@ -53,6 +67,8 @@ export class ResponseStreamer {
53
67
  * the progress bar when set — done / in-progress / pending steps.
54
68
  */
55
69
  private planMarkdown: string | undefined;
70
+ private readonly proseOnly: boolean;
71
+ private readonly showProgressBar: boolean;
56
72
 
57
73
  constructor(
58
74
  private readonly api: Api,
@@ -65,7 +81,14 @@ export class ResponseStreamer {
65
81
  private readonly fallbackEnabled = false,
66
82
  /** Turn start time, used by the fallback's elapsed-time signal. */
67
83
  private readonly turnStartedAt = Date.now(),
68
- ) {}
84
+ /** Forum topic thread — required so stream edits land in the right topic. */
85
+ private readonly messageThreadId?: number,
86
+ opts?: StreamerOptions,
87
+ ) {
88
+ this.proseOnly = !!opts?.proseOnly;
89
+ this.showProgressBar = opts?.showProgressBar !== false;
90
+ if (opts?.seedMessageId !== undefined) this.liveId = opts.seedMessageId;
91
+ }
69
92
 
70
93
  /** Replace the hashtag footer (used after a logical fork swaps the session id
71
94
  * mid-turn, so the streamed response carries the NEW session's tags). */
@@ -73,15 +96,26 @@ export class ResponseStreamer {
73
96
  this.footer = footer;
74
97
  }
75
98
 
99
+ /** Seed/replace the live bubble id (General Thinking… placeholder). */
100
+ seedLiveMessage(messageId: number): void {
101
+ this.liveId = messageId;
102
+ }
103
+
104
+ /** Current live Telegram message id (for attaching suggestions after finalize). */
105
+ get liveMessageId(): number | undefined {
106
+ return this.liveId;
107
+ }
108
+
76
109
  /** "\n\n<footer>" appended to every finished message bubble (e.g. hashtags). */
77
110
  private footerSuffix(): string {
78
111
  return this.footer ? `\n\n${this.footer}` : "";
79
112
  }
80
113
 
81
- /** Strip `{progress: N%}` markers from rendered text, remembering the latest
82
- * value (sticky across flushes) and notifying the owner when it changes. */
114
+ /** Strip `{progress: N%}` markers and telegram action JSON fences from
115
+ * rendered text, remembering the latest progress value. */
83
116
  private captureProgress(text: string): string {
84
- const { value, cleaned } = extractProgress(text);
117
+ const withoutTg = stripTelegramActionFences(text);
118
+ const { value, cleaned } = extractProgress(withoutTg);
85
119
  if (value !== undefined) this.setProgressValue(value, true);
86
120
  return cleaned;
87
121
  }
@@ -90,6 +124,7 @@ export class ResponseStreamer {
90
124
  * and notifying the owner on change. Agent markers are authoritative: once
91
125
  * one arrives, the bot fallback stops contributing. */
92
126
  private setProgressValue(pct: number, fromAgent: boolean): void {
127
+ if (!this.showProgressBar) return;
93
128
  if (fromAgent) this.agentReported = true;
94
129
  const next = Math.max(this.progress ?? 0, Math.round(pct));
95
130
  if (next === this.progress) return;
@@ -104,7 +139,7 @@ export class ResponseStreamer {
104
139
  /** Advance the fallback estimate from real activity signals, but only while
105
140
  * the agent itself hasn't reported a value. No-op when fallback is off. */
106
141
  private applyFallback(): void {
107
- if (!this.fallbackEnabled || this.agentReported) return;
142
+ if (!this.showProgressBar || !this.fallbackEnabled || this.agentReported) return;
108
143
  const est = estimateProgress({
109
144
  toolCalls: this.toolCalls,
110
145
  outputChars: this.outChars,
@@ -117,16 +152,24 @@ export class ResponseStreamer {
117
152
  /** Called when the turn finishes successfully: if the agent never reported
118
153
  * its own progress, fill the fallback bar to 100. No-op otherwise. */
119
154
  completeFallback(): void {
120
- if (!this.fallbackEnabled || this.agentReported) return;
155
+ if (!this.showProgressBar || !this.fallbackEnabled || this.agentReported) return;
121
156
  this.setProgressValue(100, false);
122
157
  }
123
158
 
159
+ private threadExtra(): Record<string, unknown> {
160
+ // Never send message_thread_id=1 (General) — Telegram rejects it.
161
+ return outboundThreadExtra(this.messageThreadId);
162
+ }
163
+
124
164
  /** reply_parameters threading EVERY message of the turn to the user's prompt,
125
165
  * so the whole response (all bubbles, tool calls and continuations) stays in
126
- * one thread — not just the first message. */
166
+ * one thread — not just the first message. Also carries forum topic id. */
127
167
  private replyExtra(): Record<string, unknown> {
128
- if (this.replyTo === undefined) return {};
129
- return { reply_parameters: { message_id: this.replyTo, allow_sending_without_reply: true } };
168
+ const extra: Record<string, unknown> = { ...this.threadExtra() };
169
+ if (this.replyTo !== undefined) {
170
+ extra.reply_parameters = { message_id: this.replyTo, allow_sending_without_reply: true };
171
+ }
172
+ return extra;
130
173
  }
131
174
 
132
175
  appendOutput(text: string): void {
@@ -137,7 +180,7 @@ export class ResponseStreamer {
137
180
  }
138
181
 
139
182
  appendThought(text: string): void {
140
- if (!text) return;
183
+ if (!text || this.proseOnly) return;
141
184
  this.thoughtChars += text.length;
142
185
  this.merge("think", text);
143
186
  this.schedule();
@@ -148,7 +191,7 @@ export class ResponseStreamer {
148
191
  * for ACP tool calls that stream progress/output under a stable toolCallId.
149
192
  */
150
193
  addTool(rawMarkdown: string): void {
151
- if (!rawMarkdown) return;
194
+ if (!rawMarkdown || this.proseOnly) return;
152
195
  this.toolCalls += 1;
153
196
  this.segs.push({ kind: "tool", text: rawMarkdown });
154
197
  this.schedule();
@@ -161,6 +204,7 @@ export class ResponseStreamer {
161
204
  */
162
205
  /** Replace the live plan board (or clear with empty/undefined). */
163
206
  setPlan(markdown: string | undefined): void {
207
+ if (this.proseOnly) return;
164
208
  const next = markdown?.trim() ? markdown.trim() : undefined;
165
209
  if (next === this.planMarkdown) return;
166
210
  this.planMarkdown = next;
@@ -168,7 +212,7 @@ export class ResponseStreamer {
168
212
  }
169
213
 
170
214
  upsertTool(toolId: string | undefined, rawMarkdown: string): void {
171
- if (!rawMarkdown) return;
215
+ if (!rawMarkdown || this.proseOnly) return;
172
216
  const id = (toolId || "").trim();
173
217
  if (id) {
174
218
  // Replace any existing segment with this id (newest first; includes rare
@@ -193,8 +237,9 @@ export class ResponseStreamer {
193
237
  this.schedule();
194
238
  }
195
239
 
240
+ /** True when agent prose/tools/thoughts were appended (not just a seed bubble). */
196
241
  get hasOutput(): boolean {
197
- return this.liveId !== undefined || this.segs.some((s) => s.text.trim().length > 0);
242
+ return this.segs.some((s) => s.text.trim().length > 0);
198
243
  }
199
244
 
200
245
  async finalize(): Promise<void> {
@@ -202,6 +247,8 @@ export class ResponseStreamer {
202
247
  if (this.timer) clearTimeout(this.timer);
203
248
  this.timer = undefined;
204
249
  await this.flush(true);
250
+ // Seeded Thinking… with zero agent text: leave the placeholder for the
251
+ // owner to replace (fallback reply) or keep while bridge results chain.
205
252
  }
206
253
 
207
254
  // ── internals ──────────────────────────────────────────────────────────────
@@ -240,8 +287,8 @@ export class ResponseStreamer {
240
287
  // Live bubble: body → plan (always above progress) → progress bar → footer.
241
288
  const parts: string[] = [];
242
289
  if (base.trim()) parts.push(base);
243
- if (this.planMarkdown) parts.push(this.planMarkdown);
244
- if (this.progress !== undefined) parts.push(progressBar(this.progress));
290
+ if (!this.proseOnly && this.planMarkdown) parts.push(this.planMarkdown);
291
+ if (this.showProgressBar && this.progress !== undefined) parts.push(progressBar(this.progress));
245
292
  if (parts.length === 0) return;
246
293
  const src = `${parts.join("\n\n")}${this.footerSuffix()}`;
247
294
  const rendered = toTelegramMarkdown(src);