grok-telegram-bot 2.3.1 → 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 (88) hide show
  1. package/.env.example +64 -2
  2. package/CHANGELOG.md +156 -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/accounts.ts +84 -0
  8. package/src/app/instance-lock.ts +6 -0
  9. package/src/app/lifetime-flag.ts +20 -0
  10. package/src/app/settings-store.ts +47 -8
  11. package/src/app/types.ts +30 -2
  12. package/src/app/updater.ts +38 -6
  13. package/src/app/usage.ts +204 -7
  14. package/src/bot/account-rotator.ts +10 -0
  15. package/src/bot/auth.ts +96 -15
  16. package/src/bot/bot.ts +154 -11
  17. package/src/bot/chat-controller.ts +82 -13
  18. package/src/bot/commands.ts +69 -27
  19. package/src/bot/complexity-gate.ts +69 -0
  20. package/src/bot/deps.ts +22 -0
  21. package/src/bot/group-memory.ts +159 -0
  22. package/src/bot/handlers/accounts.ts +58 -1
  23. package/src/bot/handlers/control.ts +85 -32
  24. package/src/bot/handlers/document.ts +31 -4
  25. package/src/bot/handlers/forum.ts +207 -0
  26. package/src/bot/handlers/import-session.ts +290 -0
  27. package/src/bot/handlers/menu.ts +102 -61
  28. package/src/bot/handlers/message.ts +102 -21
  29. package/src/bot/handlers/photo.ts +123 -16
  30. package/src/bot/handlers/running.ts +172 -16
  31. package/src/bot/handlers/session-card.ts +20 -0
  32. package/src/bot/handlers/sessions.ts +76 -15
  33. package/src/bot/handlers/usage.ts +118 -16
  34. package/src/bot/handlers/voice.ts +52 -7
  35. package/src/bot/image-return.ts +8 -5
  36. package/src/bot/menu/ephemeral.ts +13 -3
  37. package/src/bot/menu/keyboard.ts +54 -14
  38. package/src/bot/menu/refresh.ts +3 -1
  39. package/src/bot/menu/status-panel.ts +25 -6
  40. package/src/bot/permission-service.ts +19 -0
  41. package/src/bot/prompt-anchor.ts +300 -0
  42. package/src/bot/prompt-content.ts +7 -0
  43. package/src/bot/registry.ts +94 -1
  44. package/src/bot/scope.ts +94 -0
  45. package/src/bot/session-fork.ts +11 -0
  46. package/src/bot/session-runtime.ts +1254 -83
  47. package/src/bot/suggestions.ts +489 -0
  48. package/src/bot/telegram-actions.ts +440 -0
  49. package/src/bot/telegram-bots.ts +495 -0
  50. package/src/bot/telegram-io.ts +94 -10
  51. package/src/cli.ts +2 -0
  52. package/src/config.ts +242 -2
  53. package/src/forum/bind-path.ts +146 -0
  54. package/src/forum/manager.ts +651 -0
  55. package/src/forum/project-icon.ts +142 -0
  56. package/src/forum/thread.ts +16 -0
  57. package/src/forum/topic-store.ts +114 -0
  58. package/src/forum/types.ts +29 -0
  59. package/src/grok/client.ts +214 -37
  60. package/src/grok/plan-approval.ts +72 -0
  61. package/src/grok/session-log.ts +16 -0
  62. package/src/grok/types.ts +21 -2
  63. package/src/import/build-import.ts +132 -0
  64. package/src/import/history-readers.ts +681 -0
  65. package/src/import/list-running.ts +100 -0
  66. package/src/import/sources.ts +78 -0
  67. package/src/index.ts +315 -30
  68. package/src/projects/manager.ts +16 -3
  69. package/src/render/chunk.ts +17 -10
  70. package/src/render/diff.ts +11 -2
  71. package/src/render/file-summary.ts +31 -1
  72. package/src/render/hashtags.ts +5 -1
  73. package/src/render/markdown.ts +293 -35
  74. package/src/render/plan.ts +127 -0
  75. package/src/render/session-comment.ts +318 -0
  76. package/src/render/telegram-bridge.ts +360 -0
  77. package/src/render/tool-call-detail.ts +400 -19
  78. package/src/render/tool-call-merge.ts +115 -0
  79. package/src/render/tool-call.ts +444 -162
  80. package/src/render/truncate.ts +85 -0
  81. package/src/service/platform.ts +44 -7
  82. package/src/service/windows.ts +30 -6
  83. package/src/sessions/history.ts +98 -0
  84. package/src/sessions/process.ts +7 -0
  85. package/src/sessions/store.ts +3 -0
  86. package/src/sessions/types.ts +5 -0
  87. package/src/stream/streamer.ts +90 -15
  88. package/src/tasks/runner.ts +4 -3
@@ -0,0 +1,85 @@
1
+ /**
2
+ * Display-only truncation helpers.
3
+ *
4
+ * Long tool outputs, diffs, and reasoning are shortened for Telegram so messages
5
+ * stay readable and under size limits. Session history / agent context is
6
+ * unaffected — these helpers never touch what is stored on disk for context.
7
+ */
8
+
9
+ /** Ellipsis used in truncated regions (single character). */
10
+ const ELLIPSIS = "\u2026";
11
+
12
+ /**
13
+ * Keep the start of `text` when longer than `max` (classic head truncate).
14
+ * Prefer {@link truncateMiddle} for long command/diff/reasoning bodies.
15
+ */
16
+ export function truncateHead(text: string, max: number): string {
17
+ if (max <= 0) return "";
18
+ if (text.length <= max) return text;
19
+ if (max === 1) return ELLIPSIS;
20
+ return text.slice(0, max - 1) + ELLIPSIS;
21
+ }
22
+
23
+ /**
24
+ * Keep both ends of a long string, cutting the middle with an explicit note.
25
+ * Head ~40% / tail ~60% of the budget so recent terminal output stays visible.
26
+ */
27
+ export function truncateMiddle(text: string, max: number): string {
28
+ if (max <= 0) return "";
29
+ if (text.length <= max) return text;
30
+ if (max <= 24) return truncateHead(text, max);
31
+
32
+ const omitted = text.length;
33
+ const marker = `\n${ELLIPSIS} (${omitted.toLocaleString("en-US")} chars total; middle omitted) ${ELLIPSIS}\n`;
34
+ const budget = max - marker.length;
35
+ if (budget < 8) return truncateHead(text, max);
36
+
37
+ const headLen = Math.max(4, Math.floor(budget * 0.4));
38
+ const tailLen = Math.max(4, budget - headLen);
39
+ // Avoid overlapping when max is only slightly under length.
40
+ if (headLen + tailLen >= text.length) return text;
41
+ return text.slice(0, headLen) + marker + text.slice(text.length - tailLen);
42
+ }
43
+
44
+ /**
45
+ * Middle-truncate a multi-line block by whole lines when possible (better for
46
+ * diffs and command output). Falls back to character middle-truncate.
47
+ */
48
+ export function truncateMiddleLines(text: string, maxLines: number, maxChars = 12_000): string {
49
+ const lines = text.split("\n");
50
+ let body: string;
51
+ if (lines.length <= maxLines) {
52
+ body = text;
53
+ } else {
54
+ const headN = Math.max(1, Math.floor(maxLines * 0.45));
55
+ const tailN = Math.max(1, maxLines - headN);
56
+ const omitted = lines.length - headN - tailN;
57
+ body = [
58
+ ...lines.slice(0, headN),
59
+ `${ELLIPSIS} (${omitted} lines omitted) ${ELLIPSIS}`,
60
+ ...lines.slice(lines.length - tailN),
61
+ ].join("\n");
62
+ }
63
+ return truncateMiddle(body, maxChars);
64
+ }
65
+
66
+ /**
67
+ * Live terminal display: keep the first output line + the last `tailLines`
68
+ * lines so long command runs update in place without spamming new blocks.
69
+ * Display-only — full text stays in the agent session / tool snapshot.
70
+ */
71
+ export function formatLiveTerminalOutput(full: string, tailLines = 12, maxChars = 3500): string {
72
+ const normalized = full.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
73
+ const lines = normalized.split("\n");
74
+ // Drop trailing empty lines for a cleaner tail.
75
+ while (lines.length > 1 && lines[lines.length - 1] === "") lines.pop();
76
+ if (lines.length === 0) return "";
77
+ if (lines.length <= tailLines + 1) {
78
+ return truncateMiddle(lines.join("\n"), maxChars);
79
+ }
80
+ const first = lines[0]!;
81
+ const tail = lines.slice(-tailLines);
82
+ const omitted = lines.length - 1 - tailLines;
83
+ const body = [first, `${ELLIPSIS} (${omitted} lines omitted; live tail) ${ELLIPSIS}`, ...tail].join("\n");
84
+ return truncateMiddle(body, maxChars);
85
+ }
@@ -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
  },
@@ -168,20 +180,32 @@ function entryOf(spec: LaunchSpec): string {
168
180
  }
169
181
 
170
182
  function vbsLauncher(spec: LaunchSpec): string {
183
+ // Forever-restart loop: wait for the bot process to exit, pause, relaunch.
184
+ // Prevents silent death when the node process crashes. GROK_TG_SUPERVISED=1
185
+ // tells the bot a supervisor will relaunch it (auto-update exits cleanly).
171
186
  const cmd = `""${spec.nodePath}"" ${spec.args.map((a) => `""${a}""`).join(" ")}`;
172
187
  return [
173
188
  'Set sh = CreateObject("WScript.Shell")',
174
189
  `sh.CurrentDirectory = "${spec.cwd}"`,
175
- `sh.Run "${cmd}", 0, False`,
190
+ 'Set env = sh.Environment("PROCESS")',
191
+ 'env("GROK_TG_SUPERVISED") = "1"',
192
+ "Do",
193
+ ` sh.Run "${cmd}", 0, True`,
194
+ " WScript.Sleep 3000",
195
+ "Loop",
176
196
  ].join("\r\n");
177
197
  }
178
198
 
179
199
  function killScript(entry: string): string {
180
200
  const safe = entry.replace(/'/g, "''");
201
+ // Kill the forever-restart VBS host *before* node, otherwise the 3s loop
202
+ // respawns the bot right after "stop". Targets our service / Startup launchers.
181
203
  return [
204
+ `$vbs = Get-CimInstance Win32_Process -Filter "Name='wscript.exe' OR Name='cscript.exe'" | Where-Object { $_.CommandLine -like '*GrokTelegramBot.vbs*' -or $_.CommandLine -like '*run-service.vbs*' };`,
205
+ `$vbs | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue };`,
182
206
  `$p = Get-CimInstance Win32_Process -Filter "Name='node.exe'" | Where-Object { $_.CommandLine -like '*${safe}*' };`,
183
207
  `$p | ForEach-Object { Stop-Process -Id $_.ProcessId -Force -ErrorAction SilentlyContinue };`,
184
- `"killed " + (@($p).Count)`,
208
+ `"killed " + (@($p).Count) + " node, " + (@($vbs).Count) + " vbs"`,
185
209
  ].join(" ");
186
210
  }
187
211
 
@@ -5,6 +5,11 @@
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";
8
13
  import type { HistoryEntry, HistoryRole } from "./types.js";
9
14
 
10
15
  const TAIL_WINDOWS = [256 * 1024, 1024 * 1024, 4 * 1024 * 1024]; // grow until entries found
@@ -46,6 +51,66 @@ export function jsonlMtimeMs(jsonlPath: string): number {
46
51
  }
47
52
  }
48
53
 
54
+ /**
55
+ * Best-effort card blurb from the tail of a session log: last assistant prose
56
+ * (what was solved), else last user prompt. Skips import-confirm noise.
57
+ * @deprecated Prefer {@link readLastUserPrompt} for session card comments.
58
+ */
59
+ export function readLastCardSummary(jsonlPath: string, maxEntries = 30): string {
60
+ const entries = readHistory(jsonlPath, maxEntries);
61
+ if (entries.length === 0) return "";
62
+ // Walk newest → oldest for a useful assistant conclusion.
63
+ for (let i = entries.length - 1; i >= 0; i--) {
64
+ const e = entries[i]!;
65
+ if (e.role === "assistant" && e.text.trim()) {
66
+ const t = cleanCardProse(e.text);
67
+ if (t.length >= 20) return t;
68
+ }
69
+ }
70
+ for (let i = entries.length - 1; i >= 0; i--) {
71
+ const e = entries[i]!;
72
+ if (e.role === "user" && e.text.trim()) {
73
+ const t = cleanCardProse(e.text);
74
+ if (t && !/session import complete/i.test(t)) return t;
75
+ }
76
+ }
77
+ return "";
78
+ }
79
+
80
+ /**
81
+ * Last user prompt from the session log for card comments (newest → oldest).
82
+ * Strips complexity wrappers / import-confirm noise. Empty when none found.
83
+ */
84
+ export function readLastUserPrompt(jsonlPath: string, maxEntries = 40, maxLen = 250): string {
85
+ const entries = readHistory(jsonlPath, maxEntries);
86
+ for (let i = entries.length - 1; i >= 0; i--) {
87
+ const e = entries[i]!;
88
+ if (e.role !== "user" || !e.text.trim()) continue;
89
+ const t = cleanCardProse(e.text, maxLen);
90
+ if (!t) continue;
91
+ if (/session import complete/i.test(t)) continue;
92
+ return t;
93
+ }
94
+ return "";
95
+ }
96
+
97
+ function cleanCardProse(raw: string, max = 250): string {
98
+ let t = extractProgress(raw).cleaned;
99
+ t = t.replace(/```[\s\S]*?```/g, " ");
100
+ t = t.replace(/^COMPLEXITY \(decide yourself[\s\S]*?User task:\s*/i, "");
101
+ t = t.replace(/^TASK COMPLEXITY:[\s\S]*?User task:\s*/i, "");
102
+ if (/^Session status update \(meta only\)/i.test(t.trim())) return "";
103
+ t = t.replace(/\s+/g, " ").trim();
104
+ if (t.length <= max) return t;
105
+ // Prefer the ending (conclusions).
106
+ if (t.length > max + 40) {
107
+ const tail = t.slice(-max + 1);
108
+ const sp = tail.indexOf(" ");
109
+ return "\u2026" + (sp > 0 && sp < 30 ? tail.slice(sp + 1) : tail);
110
+ }
111
+ return t.slice(0, max - 1) + "\u2026";
112
+ }
113
+
49
114
  /** The first user prompt in a session log (read from the start), or "". */
50
115
  export function readFirstPrompt(jsonlPath: string, maxBytes = 256 * 1024): string {
51
116
  let size: number;
@@ -161,8 +226,41 @@ function toEntry(ev: RawEvent): HistoryEntry | undefined {
161
226
  function cleanStoredText(text: string): string {
162
227
  if (!text) return text;
163
228
  let t = extractProgress(text).cleaned;
229
+ t = extractTelegramActions(t).cleaned;
164
230
  if (t.includes(PROGRESS_DIRECTIVE)) t = t.split(PROGRESS_DIRECTIVE).join("").trim();
165
231
  if (t.includes(IMAGE_OUTPUT_DIRECTIVE)) t = t.split(IMAGE_OUTPUT_DIRECTIVE).join("").trim();
232
+ // Prefer "User task (continued):" BEFORE plain "User task:" — the continued
233
+ // marker contains the substring "User task:", so lastIndexOf("User task:")
234
+ // would slice into "(continued):…" and leak bridge teaching into cards/logs.
235
+ const cont = "User task (continued):";
236
+ const ci = t.lastIndexOf(cont);
237
+ if (ci !== -1) {
238
+ t = t.slice(ci + cont.length).trim();
239
+ } else if (
240
+ /^COMPLEXITY \(decide yourself/i.test(t) ||
241
+ /^TASK COMPLEXITY:/i.test(t)
242
+ ) {
243
+ const taskMarker = "User task:";
244
+ const ti = t.indexOf(taskMarker);
245
+ if (ti !== -1) t = t.slice(ti + taskMarker.length).trim();
246
+ }
247
+ // Strip leftover telegram bridge teaching if still present (directive-only wrap).
248
+ if (t.includes(TELEGRAM_BRIDGE_MARKER)) {
249
+ const mi = t.indexOf(TELEGRAM_BRIDGE_MARKER);
250
+ if (mi === 0) {
251
+ const after = t.slice(TELEGRAM_BRIDGE_MARKER.length);
252
+ const dbl = after.search(/\n\n(?![-*`])/);
253
+ t = dbl !== -1 ? after.slice(dbl).trim() : "";
254
+ } else {
255
+ t = t.slice(0, mi).trim();
256
+ }
257
+ }
258
+ // Drop removed/quiet meta-prompts if they landed in history.
259
+ if (/^Session status update \(meta only\)/i.test(t.trim())) t = "";
260
+ if (/^FOLLOW-UP SUGGESTIONS \(meta only\)/i.test(t.trim())) t = "";
261
+ if (/^SELF-RECHECK DECISION \(meta only\)/i.test(t.trim())) t = "";
262
+ if (/^SELF-RECHECK \(automatic quality pass/i.test(t.trim())) t = "";
263
+ if (t.trimStart().startsWith(TELEGRAM_BRIDGE_RESULTS_MARKER)) t = "";
166
264
  return t;
167
265
  }
168
266
 
@@ -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" });
@@ -17,6 +17,7 @@ interface RawSessionJson {
17
17
  created_at?: string;
18
18
  updated_at?: string;
19
19
  session_created_reason?: string;
20
+ comment?: string;
20
21
  }
21
22
 
22
23
  interface RawLock {
@@ -93,6 +94,7 @@ export class SessionStore {
93
94
  /* no history yet */
94
95
  }
95
96
 
97
+ const comment = (raw.comment || "").trim();
96
98
  return {
97
99
  sessionId,
98
100
  cwd: raw.cwd || "",
@@ -103,6 +105,7 @@ export class SessionStore {
103
105
  lockPid,
104
106
  active,
105
107
  historyBytes,
108
+ comment: comment || undefined,
106
109
  };
107
110
  }
108
111
 
@@ -13,6 +13,11 @@ export interface SessionMeta {
13
13
  active: boolean;
14
14
  /** Size of the .jsonl history in bytes (proxy for conversation length). */
15
15
  historyBytes: number;
16
+ /**
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
+ */
20
+ comment?: string;
16
21
  }
17
22
 
18
23
  export type HistoryRole = "user" | "assistant" | "tool" | "system";
@@ -15,15 +15,20 @@ 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";
19
+ import { truncateMiddle } from "../render/truncate.js";
18
20
  import { safeEdit, safeSend } from "../bot/telegram-io.js";
19
21
 
20
22
  const SOFT_LIMIT = 3500;
21
- const THINK_TAIL = 500;
23
+ /** Display budget for a thinking block (middle-truncated; session context keeps all). */
24
+ const THINK_DISPLAY_MAX = 2800;
22
25
 
23
26
  type SegKind = "out" | "think" | "tool";
24
27
  interface Seg {
25
28
  kind: SegKind;
26
29
  text: string;
30
+ /** When set, later tool updates replace this segment instead of appending. */
31
+ toolId?: string;
27
32
  }
28
33
 
29
34
  export class ResponseStreamer {
@@ -44,6 +49,11 @@ export class ResponseStreamer {
44
49
  private toolCalls = 0;
45
50
  private outChars = 0;
46
51
  private thoughtChars = 0;
52
+ /**
53
+ * Active plan board (ACP sessionUpdate "plan"). Always rendered just above
54
+ * the progress bar when set — done / in-progress / pending steps.
55
+ */
56
+ private planMarkdown: string | undefined;
47
57
 
48
58
  constructor(
49
59
  private readonly api: Api,
@@ -56,6 +66,8 @@ export class ResponseStreamer {
56
66
  private readonly fallbackEnabled = false,
57
67
  /** Turn start time, used by the fallback's elapsed-time signal. */
58
68
  private readonly turnStartedAt = Date.now(),
69
+ /** Forum topic thread — required so stream edits land in the right topic. */
70
+ private readonly messageThreadId?: number,
59
71
  ) {}
60
72
 
61
73
  /** Replace the hashtag footer (used after a logical fork swaps the session id
@@ -69,10 +81,11 @@ export class ResponseStreamer {
69
81
  return this.footer ? `\n\n${this.footer}` : "";
70
82
  }
71
83
 
72
- /** Strip `{progress: N%}` markers from rendered text, remembering the latest
73
- * value (sticky across flushes) and notifying the owner when it changes. */
84
+ /** Strip `{progress: N%}` markers and telegram action JSON fences from
85
+ * rendered text, remembering the latest progress value. */
74
86
  private captureProgress(text: string): string {
75
- const { value, cleaned } = extractProgress(text);
87
+ const withoutTg = stripTelegramActionFences(text);
88
+ const { value, cleaned } = extractProgress(withoutTg);
76
89
  if (value !== undefined) this.setProgressValue(value, true);
77
90
  return cleaned;
78
91
  }
@@ -112,12 +125,19 @@ export class ResponseStreamer {
112
125
  this.setProgressValue(100, false);
113
126
  }
114
127
 
128
+ private threadExtra(): Record<string, unknown> {
129
+ return this.messageThreadId !== undefined ? { message_thread_id: this.messageThreadId } : {};
130
+ }
131
+
115
132
  /** reply_parameters threading EVERY message of the turn to the user's prompt,
116
133
  * so the whole response (all bubbles, tool calls and continuations) stays in
117
- * one thread — not just the first message. */
134
+ * one thread — not just the first message. Also carries forum topic id. */
118
135
  private replyExtra(): Record<string, unknown> {
119
- if (this.replyTo === undefined) return {};
120
- return { reply_parameters: { message_id: this.replyTo, allow_sending_without_reply: true } };
136
+ const extra: Record<string, unknown> = { ...this.threadExtra() };
137
+ if (this.replyTo !== undefined) {
138
+ extra.reply_parameters = { message_id: this.replyTo, allow_sending_without_reply: true };
139
+ }
140
+ return extra;
121
141
  }
122
142
 
123
143
  appendOutput(text: string): void {
@@ -134,6 +154,10 @@ export class ResponseStreamer {
134
154
  this.schedule();
135
155
  }
136
156
 
157
+ /**
158
+ * Append a one-shot tool card (no live updates). Prefer {@link upsertTool}
159
+ * for ACP tool calls that stream progress/output under a stable toolCallId.
160
+ */
137
161
  addTool(rawMarkdown: string): void {
138
162
  if (!rawMarkdown) return;
139
163
  this.toolCalls += 1;
@@ -141,6 +165,45 @@ export class ResponseStreamer {
141
165
  this.schedule();
142
166
  }
143
167
 
168
+ /**
169
+ * Insert or replace a tool card keyed by toolCallId so one command/edit stays
170
+ * a single Telegram block that auto-updates (no spam of new code sections).
171
+ * Full tool results remain in the agent session; this is display-only.
172
+ */
173
+ /** Replace the live plan board (or clear with empty/undefined). */
174
+ setPlan(markdown: string | undefined): void {
175
+ const next = markdown?.trim() ? markdown.trim() : undefined;
176
+ if (next === this.planMarkdown) return;
177
+ this.planMarkdown = next;
178
+ this.schedule();
179
+ }
180
+
181
+ upsertTool(toolId: string | undefined, rawMarkdown: string): void {
182
+ if (!rawMarkdown) return;
183
+ const id = (toolId || "").trim();
184
+ if (id) {
185
+ // Replace any existing segment with this id (newest first; includes rare
186
+ // sealed-region matches so we don't keep stale text in the segs model).
187
+ for (let i = this.segs.length - 1; i >= 0; i--) {
188
+ const s = this.segs[i]!;
189
+ if (s.kind === "tool" && s.toolId === id) {
190
+ if (s.text === rawMarkdown) return;
191
+ s.text = rawMarkdown;
192
+ // If the card lives only in a sealed bubble, also ensure a live copy
193
+ // so the user sees the latest output on the current message.
194
+ if (i < this.sealedIdx) {
195
+ this.segs.push({ kind: "tool", text: rawMarkdown, toolId: id });
196
+ }
197
+ this.schedule();
198
+ return;
199
+ }
200
+ }
201
+ }
202
+ this.toolCalls += 1;
203
+ this.segs.push({ kind: "tool", text: rawMarkdown, toolId: id || undefined });
204
+ this.schedule();
205
+ }
206
+
144
207
  get hasOutput(): boolean {
145
208
  return this.liveId !== undefined || this.segs.some((s) => s.text.trim().length > 0);
146
209
  }
@@ -182,13 +245,15 @@ export class ResponseStreamer {
182
245
  await this.sealOverflow();
183
246
  const base = this.captureProgress(renderSegs(this.segs.slice(this.sealedIdx)));
184
247
  this.applyFallback();
185
- // Never send an empty / progress-only bubble. The bar is appended only to
186
- // real streamed content; the live status panel shows the standalone bar.
187
- if (!base.trim()) return;
188
- // The live (still-streaming) bubble carries the hashtag footer AND a fresh
189
- // progress bar at the bottom (sealed bubbles below get neither bar).
190
- const parts: string[] = [base];
248
+ // Never send an empty / progress-only bubble. Plan alone is allowed so the
249
+ // board is visible as soon as the agent publishes steps.
250
+ if (!base.trim() && !this.planMarkdown) return;
251
+ // Live bubble: body → plan (always above progress) → progress bar → footer.
252
+ const parts: string[] = [];
253
+ if (base.trim()) parts.push(base);
254
+ if (this.planMarkdown) parts.push(this.planMarkdown);
191
255
  if (this.progress !== undefined) parts.push(progressBar(this.progress));
256
+ if (parts.length === 0) return;
192
257
  const src = `${parts.join("\n\n")}${this.footerSuffix()}`;
193
258
  const rendered = toTelegramMarkdown(src);
194
259
  const chunks = chunkMarkdown(rendered);
@@ -255,7 +320,17 @@ function renderSegs(segs: Seg[]): string {
255
320
  function quoteThought(text: string): string {
256
321
  const t = text.trim();
257
322
  if (!t) return "";
258
- const short = t.length > THINK_TAIL ? "…" + t.slice(-THINK_TAIL) : t;
323
+ // Keep both ends of long reasoning so early investigation is not lost in the UI.
324
+ // Truncation is display-only — the agent session retains every thought token.
325
+ // Neutralize fence markers and half-open emphasis so thinking never breaks
326
+ // MarkdownV2 parsing of the surrounding live message.
327
+ const safe = t
328
+ .replace(/```+/g, "'''")
329
+ .replace(/\*\*/g, "")
330
+ .replace(/__/g, "")
331
+ .replace(/~~/g, "");
332
+ const short = truncateMiddle(safe, THINK_DISPLAY_MAX);
259
333
  const lines = short.split("\n");
260
- return lines.map((l, i) => (i === 0 ? `> 💭 *thinking:* ${l}` : `> ${l}`)).join("\n");
334
+ // Plain "thinking:" (no nested *bold*) — nested markers break mid-stream.
335
+ return lines.map((l, i) => (i === 0 ? `> \u{1F4AD} thinking: ${l}` : `> ${l}`)).join("\n");
261
336
  }
@@ -6,7 +6,7 @@
6
6
  import type { Api } from "grammy";
7
7
  import { basename } from "node:path";
8
8
  import type { GrokClient } from "../grok/client.js";
9
- import type { SessionUpdate } from "../grok/types.js";
9
+ import { contentText, type SessionUpdate } from "../grok/types.js";
10
10
  import { createLogger } from "../logger.js";
11
11
  import { sendMarkdownDoc } from "../bot/telegram-io.js";
12
12
  import type { Task } from "./types.js";
@@ -29,8 +29,9 @@ export class TaskRunner {
29
29
 
30
30
  const listener = (sid: string, u: SessionUpdate): void => {
31
31
  if (sid !== sessionId) return;
32
- if (u.sessionUpdate === "agent_message_chunk" && typeof u.content?.text === "string") {
33
- text += u.content.text;
32
+ if (u.sessionUpdate === "agent_message_chunk") {
33
+ const t = contentText(u.content);
34
+ if (t) text += t;
34
35
  } else if (u.sessionUpdate === "tool_call") {
35
36
  const id = u.toolCallId || u.title || String(tools);
36
37
  if (!seen.has(id)) {