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
package/src/index.ts CHANGED
@@ -6,35 +6,114 @@
6
6
  * Lifetime rules (critical):
7
7
  * - Prefer staying up over clean-but-dead exits.
8
8
  * - Uncaught errors are logged; the process keeps polling Telegram.
9
- * - grammY long-poll is restarted on transport/network death.
10
- * - Supervised launches (GROK_TG_SUPERVISED=1 / service) may exit for relaunch;
11
- * bare/manual runs re-exec when possible rather than dying silently.
9
+ * - grammY long-poll is restarted on transport/network death, 429, 409, etc.
10
+ * - Never exit without a clear stderr + log line.
11
+ * - Interactive `npm start` stays in-process (no silent detach re-exec).
12
+ * - Supervised launches (GROK_TG_SUPERVISED=1) may exit for external relaunch.
12
13
  */
13
- import { spawn } from "node:child_process";
14
14
  import { join } from "node:path";
15
15
  import type { Bot } from "grammy";
16
+ import { GrammyError, HttpError } from "grammy";
16
17
  import { GrokClient } from "./grok/client.js";
17
18
  import { createBot } from "./bot/bot.js";
18
- import { CANONICAL_DIR, INSTANCE_DIR, loadConfig } from "./config.js";
19
+ import { CANONICAL_DIR, loadConfig } from "./config.js";
19
20
  import { InstanceLock } from "./app/instance-lock.js";
21
+ import {
22
+ isIntentionalShutdown,
23
+ markIntentionalShutdown,
24
+ } from "./app/lifetime-flag.js";
20
25
  import { createLogger, enableFileLogging, setLogLevel } from "./logger.js";
21
26
 
27
+ /** Last known reason for process end (read by exit/beforeExit handlers). */
28
+ let exitReason = "unknown";
29
+ /** Intentional exit in progress (SIGINT, fatal, lock, supervised relaunch). */
30
+ let shuttingDown = false;
31
+ /** beforeExit keep-alive armed at most once (avoid infinite 60s re-arm spam). */
32
+ let beforeExitKeepalive: ReturnType<typeof setInterval> | undefined;
33
+ /** Set immediately after config load; used by scream/shutdown. */
34
+ let log!: ReturnType<typeof createLogger>;
35
+
36
+ function noteExit(reason: string): void {
37
+ exitReason = reason;
38
+ }
39
+
40
+ function beginShutdown(reason: string): void {
41
+ shuttingDown = true;
42
+ markIntentionalShutdown(reason);
43
+ noteExit(reason);
44
+ if (beforeExitKeepalive) {
45
+ clearInterval(beforeExitKeepalive);
46
+ beforeExitKeepalive = undefined;
47
+ }
48
+ }
49
+
50
+ function scream(msg: string): void {
51
+ try {
52
+ process.stderr.write(`${msg}\n`);
53
+ } catch {
54
+ /* ignore */
55
+ }
56
+ try {
57
+ process.stdout.write(`${msg}\n`);
58
+ } catch {
59
+ /* ignore */
60
+ }
61
+ try {
62
+ log?.error(msg);
63
+ } catch {
64
+ /* ignore */
65
+ }
66
+ }
67
+
22
68
  async function main(): Promise<void> {
23
69
  process.stdout.write("\u{1F916} Grok Telegram Bot — starting…\n");
24
70
 
25
71
  const cfg = loadConfig();
26
72
  setLogLevel(cfg.logLevel);
27
73
  enableFileLogging(cfg.logFile);
28
- const log = createLogger("main");
74
+ log = createLogger("main");
75
+
76
+ // Always leave a trail — silent process death was the top reliability bug.
77
+ process.on("exit", (code) => {
78
+ const line = `[main] process exit code=${code} reason=${exitReason}`;
79
+ try {
80
+ // File logger may already be gone; still try console.
81
+ console.error(line);
82
+ } catch {
83
+ /* ignore */
84
+ }
85
+ });
86
+ process.on("beforeExit", (code) => {
87
+ // Never fight an intentional shutdown / fatal exit / updater re-exec.
88
+ if (shuttingDown || isIntentionalShutdown()) return;
89
+ if (beforeExitKeepalive) return; // already holding the process open
90
+ // Event loop emptied without an intentional shutdown — keep a handle alive
91
+ // so we don't vanish with code 0 and no explanation.
92
+ scream(
93
+ `\u26A0\uFE0F Event loop emptying (beforeExit code=${code}, reason=${exitReason}). ` +
94
+ `Keeping process alive — this usually means polling stopped unexpectedly.`,
95
+ );
96
+ noteExit(`beforeExit:${exitReason}`);
97
+ // One ref'd interval (not stacking setTimeouts that re-fire beforeExit forever).
98
+ beforeExitKeepalive = setInterval(() => {
99
+ if (shuttingDown || isIntentionalShutdown()) {
100
+ if (beforeExitKeepalive) clearInterval(beforeExitKeepalive);
101
+ return;
102
+ }
103
+ log?.warn(`beforeExit keepalive still holding process (reason=${exitReason})`);
104
+ }, 60_000);
105
+ beforeExitKeepalive.ref?.();
106
+ });
29
107
 
30
108
  // Single-instance guard: kill any ghost/duplicate already polling this token.
31
109
  const lock = new InstanceLock(cfg.token, join(CANONICAL_DIR, "locks"), process.env.GROK_TG_SUPERVISED === "1");
32
110
  if (cfg.singleInstance && !(await lock.acquire())) {
33
111
  const msg =
34
112
  "Another Grok Telegram Bot is already running for this token (a background service). Use `grok-tg restart`, or `grok-tg stop` first.";
113
+ beginShutdown("instance-lock-held");
35
114
  log.warn(msg);
36
- process.stdout.write(`\u26D4 ${msg}\n`);
37
- process.exit(0);
115
+ scream(`\u26D4 ${msg}`);
116
+ process.exit(1);
38
117
  }
39
118
 
40
119
  log.info("starting Grok Telegram Bot");
@@ -47,13 +126,15 @@ async function main(): Promise<void> {
47
126
  // partially inconsistent one. Registering these handlers overrides Node's
48
127
  // default "print and exit" for uncaughtException.
49
128
  process.on("uncaughtException", (err) => {
129
+ noteExit(`uncaughtException:${err.message}`);
50
130
  log.error("uncaughtException (process stays up):", err);
131
+ scream(`\u26A0\uFE0F uncaughtException (staying up): ${err.stack || err.message}`);
51
132
  });
52
133
  process.on("unhandledRejection", (reason) => {
53
- log.error(
54
- "unhandledRejection (process stays up):",
55
- reason instanceof Error ? reason : String(reason),
56
- );
134
+ const msg = reason instanceof Error ? reason.stack || reason.message : String(reason);
135
+ noteExit(`unhandledRejection:${msg.slice(0, 120)}`);
136
+ log.error("unhandledRejection (process stays up):", reason instanceof Error ? reason : String(reason));
137
+ scream(`\u26A0\uFE0F unhandledRejection (staying up): ${msg}`);
57
138
  });
58
139
 
59
140
  const grok = new GrokClient({
@@ -75,6 +156,7 @@ async function main(): Promise<void> {
75
156
  } catch (e) {
76
157
  const wait = Math.min(60_000, 1000 * 2 ** Math.min(attempt, 5));
77
158
  log.error(`Grok ACP start failed (attempt ${attempt}): ${(e as Error).message}; retry in ${wait}ms`);
159
+ scream(`\u26A0\uFE0F Grok ACP start failed (attempt ${attempt}): ${(e as Error).message}; retry in ${wait}ms`);
78
160
  await sleep(wait);
79
161
  }
80
162
  }
@@ -94,7 +176,9 @@ async function main(): Promise<void> {
94
176
  break;
95
177
  } catch (e) {
96
178
  const wait = Math.min(60_000, 1000 * 2 ** Math.min(attempt, 5));
97
- log.error(`createBot failed (attempt ${attempt}): ${(e as Error).message}; retry in ${wait}ms`);
179
+ const detail = formatTelegramError(e);
180
+ log.error(`createBot failed (attempt ${attempt}): ${detail}; retry in ${wait}ms`);
181
+ scream(`\u26A0\uFE0F createBot failed (attempt ${attempt}): ${detail}; retry in ${wait}ms`);
98
182
  await sleep(wait);
99
183
  }
100
184
  }
@@ -102,11 +186,11 @@ async function main(): Promise<void> {
102
186
  scheduler!.start();
103
187
  await updater!.start();
104
188
 
105
- let shuttingDown = false;
106
- const shutdown = (code: number): void => {
189
+ const shutdown = (code: number, reason: string): void => {
107
190
  if (shuttingDown) return;
108
- shuttingDown = true;
109
- log.info("shutting down…");
191
+ beginShutdown(reason);
192
+ log.info(`shutting down (code=${code}, reason=${reason})…`);
193
+ scream(`\u{1F6D1} Shutting down (code=${code}, reason=${reason})`);
110
194
  try {
111
195
  scheduler!.stop();
112
196
  } catch {
@@ -130,45 +214,66 @@ async function main(): Promise<void> {
130
214
 
131
215
  grok.on("restarted", () => log.info("Grok bridge re-bound; sessions continue on next message."));
132
216
 
133
- process.on("SIGINT", () => shutdown(0));
134
- process.on("SIGTERM", () => shutdown(0));
217
+ process.on("SIGINT", () => shutdown(0, "SIGINT"));
218
+ process.on("SIGTERM", () => shutdown(0, "SIGTERM"));
135
219
 
136
- // Refresh the single-instance lock periodically so a long-running process
137
- // stays clearly "alive" on disk (start time + pid).
138
- const lockHeartbeat = setInterval(() => {
220
+ // Keepalive + lock heartbeat — REF'd so the process cannot exit with an empty
221
+ // event loop while we still intend to run (unref was a silent-death footgun).
222
+ let botUsername = "?";
223
+ const keepalive = setInterval(() => {
139
224
  try {
140
- if (!shuttingDown) lock.touch();
141
- } catch {
142
- /* non-fatal */
225
+ if (shuttingDown || isIntentionalShutdown()) return;
226
+ lock.touch();
227
+ log.info(
228
+ `keepalive: alive as @${botUsername} polling=${bot!.isRunning()} ` +
229
+ `pid=${process.pid} uptime=${Math.floor(process.uptime())}s`,
230
+ );
231
+ } catch (e) {
232
+ log.warn(`keepalive tick failed: ${(e as Error).message}`);
143
233
  }
144
- }, 60_000);
145
- lockHeartbeat.unref?.();
146
-
147
- // Keep long-polling forever. On network / 409 / grammY fatal, wait and restart
148
- // the poller instead of ending the process (silent death was a critical bug).
149
- // If the inner loop still returns without shutdown, attempt self-relaunch; if
150
- // that fails, resume polling rather than dying.
151
- while (!shuttingDown) {
152
- await runPollingForever(bot!, log, () => shuttingDown);
153
- if (shuttingDown) break;
154
- log.error("Telegram polling loop exited unexpectedly; attempting self-relaunch");
155
- const replaced = await selfRelaunch(cfg.projectRoot, log);
156
- if (replaced) {
157
- clearInterval(lockHeartbeat);
158
- shutdown(1);
234
+ }, 5 * 60_000);
235
+ // Explicitly ref — do not unref.
236
+ keepalive.ref?.();
237
+
238
+ // Keep long-polling forever. On network / 409 / 429 / grammY fatal, wait and
239
+ // restart the poller in-process. Interactive runs never detach-relaunch
240
+ // (that looked like a silent death in the terminal).
241
+ while (!shuttingDown && !isIntentionalShutdown()) {
242
+ await runPollingForever(
243
+ bot!,
244
+ log,
245
+ () => shuttingDown || isIntentionalShutdown(),
246
+ (name) => {
247
+ botUsername = name;
248
+ },
249
+ );
250
+ if (shuttingDown || isIntentionalShutdown()) break;
251
+
252
+ // runPollingForever should only return when shuttingDown; if not, recover.
253
+ scream("\u26A0\uFE0F Telegram polling loop returned unexpectedly; recovering in-process");
254
+ log.error("Telegram polling loop returned unexpectedly; recovering in-process (no silent exit)");
255
+ noteExit("polling-loop-returned");
256
+
257
+ if (process.env.GROK_TG_SUPERVISED === "1") {
258
+ log.info("supervised mode — exiting for external relaunch");
259
+ noteExit("supervised-relaunch");
260
+ clearInterval(keepalive);
261
+ shutdown(1, "supervised-relaunch");
159
262
  return;
160
263
  }
161
- log.error("self-relaunch failed; resuming Telegram polling in 5s");
162
- await sleep(5000);
264
+
265
+ // Stay in this process — re-enter polling after a short pause.
266
+ await sleep(3000);
163
267
  }
164
- clearInterval(lockHeartbeat);
268
+ clearInterval(keepalive);
165
269
  }
166
270
 
167
- /** grammY start with automatic recovery. */
271
+ /** grammY start with automatic recovery and loud classification of API errors. */
168
272
  async function runPollingForever(
169
273
  bot: Bot,
170
274
  log: ReturnType<typeof createLogger>,
171
275
  isShuttingDown: () => boolean,
276
+ onOnline: (username: string) => void,
172
277
  ): Promise<void> {
173
278
  let attempt = 0;
174
279
  while (!isShuttingDown()) {
@@ -176,20 +281,25 @@ async function runPollingForever(
176
281
  attempt = 0;
177
282
  await bot.start({
178
283
  onStart: (info) => {
284
+ onOnline(info.username);
179
285
  log.info(`bot online as @${info.username}`);
180
286
  process.stdout.write(`\u2705 Online as @${info.username}. Send it a message on Telegram.\n`);
181
287
  },
182
288
  });
183
- // bot.start resolves when polling is stopped (bot.stop).
289
+ // bot.start resolves when polling is stopped (bot.stop) or loop ends.
184
290
  if (isShuttingDown()) return;
185
291
  log.warn("bot.start resolved without shutdown; restarting polling in 2s");
292
+ scream("\u26A0\uFE0F bot.start resolved without shutdown; restarting polling in 2s");
186
293
  await sleep(2000);
187
294
  } catch (e) {
188
295
  attempt++;
189
- const wait = Math.min(60_000, 2000 * 2 ** Math.min(attempt, 5));
190
- log.error(
191
- `Telegram polling failed (attempt ${attempt}): ${(e as Error).message}; retry in ${wait}ms`,
192
- );
296
+ const classified = classifyPollingError(e);
297
+ const wait = classified.waitMs ?? Math.min(60_000, 2000 * 2 ** Math.min(attempt, 5));
298
+ const line =
299
+ `Telegram polling failed (attempt ${attempt}, ${classified.kind}): ${classified.message}; ` +
300
+ `retry in ${wait}ms`;
301
+ log.error(line);
302
+ scream(`\u26A0\uFE0F ${line}`);
193
303
  try {
194
304
  await bot.stop();
195
305
  } catch {
@@ -200,32 +310,49 @@ async function runPollingForever(
200
310
  }
201
311
  }
202
312
 
203
- /**
204
- * Spawn a fresh bot process then let the caller exit (manual / non-supervised).
205
- * Returns true when a replacement was started (or supervised exit is expected).
206
- */
207
- async function selfRelaunch(projectRoot: string, log: ReturnType<typeof createLogger>): Promise<boolean> {
208
- if (process.env.GROK_TG_SUPERVISED === "1") {
209
- log.info("supervised mode — exiting for external relaunch");
210
- return true;
211
- }
212
- try {
213
- const child = spawn(
214
- process.execPath,
215
- ["--import", "tsx", join(projectRoot, "src", "index.ts"), "--instance", INSTANCE_DIR],
216
- { detached: true, stdio: "ignore", cwd: projectRoot, env: process.env },
217
- );
218
- child.unref();
219
- if (!child.pid) {
220
- log.error("self-relaunch spawn produced no pid — staying alive");
221
- return false;
313
+ function classifyPollingError(e: unknown): { kind: string; message: string; waitMs?: number } {
314
+ const message = formatTelegramError(e);
315
+ if (e instanceof GrammyError) {
316
+ if (e.error_code === 429) {
317
+ const ra = e.parameters?.retry_after;
318
+ const waitMs = typeof ra === "number" ? (ra + 1) * 1000 : 10_000;
319
+ return { kind: "429-rate-limit", message, waitMs };
320
+ }
321
+ if (e.error_code === 409) {
322
+ return {
323
+ kind: "409-conflict",
324
+ message: `${message} (another getUpdates consumer for this token?)`,
325
+ waitMs: 15_000,
326
+ };
327
+ }
328
+ if (e.error_code === 401) {
329
+ return {
330
+ kind: "401-unauthorized",
331
+ message: `${message} (check TELEGRAM_BOT_TOKEN)`,
332
+ waitMs: 60_000,
333
+ };
222
334
  }
223
- log.info(`spawned replacement pid ${child.pid}`);
224
- return true;
225
- } catch (e) {
226
- log.error(`self-relaunch failed: ${(e as Error).message} — staying alive`);
227
- return false;
335
+ return { kind: `grammy-${e.error_code}`, message };
336
+ }
337
+ if (e instanceof HttpError) {
338
+ return { kind: "http-network", message, waitMs: 5_000 };
339
+ }
340
+ const msg = (e as Error)?.message ?? String(e);
341
+ if (/ECONNRESET|ETIMEDOUT|EAI_AGAIN|socket hang|fetch failed|network/i.test(msg)) {
342
+ return { kind: "network", message: msg, waitMs: 5_000 };
343
+ }
344
+ return { kind: "unknown", message: msg };
345
+ }
346
+
347
+ function formatTelegramError(e: unknown): string {
348
+ if (e instanceof GrammyError) {
349
+ const ra = e.parameters?.retry_after;
350
+ const extra = typeof ra === "number" ? ` retry_after=${ra}s` : "";
351
+ return `${e.message} (code ${e.error_code}${extra})`;
228
352
  }
353
+ if (e instanceof HttpError) return `HttpError: ${e.message}`;
354
+ if (e instanceof Error) return e.message;
355
+ return String(e);
229
356
  }
230
357
 
231
358
  function sleep(ms: number): Promise<void> {
@@ -233,7 +360,10 @@ function sleep(ms: number): Promise<void> {
233
360
  }
234
361
 
235
362
  main().catch((err) => {
236
- console.error("Fatal:", err instanceof Error ? err.stack || err.message : err);
237
- // Delay exit so logs flush; VBS restart loop / supervisor can bring us back.
363
+ // Mark intentional exit so beforeExit keep-alive cannot swallow a real fatal.
364
+ beginShutdown(`fatal:${err instanceof Error ? err.message : String(err)}`);
365
+ const text = err instanceof Error ? err.stack || err.message : String(err);
366
+ console.error("Fatal:", text);
367
+ scream(`\u274C Fatal (exiting in 1s): ${text}`);
238
368
  setTimeout(() => process.exit(1), 1000);
239
369
  });
@@ -34,6 +34,13 @@ export class ProjectManager {
34
34
 
35
35
  /** List projects, de-duplicated by (case-insensitive) name. */
36
36
  list(limit = 100): ProjectEntry[] {
37
+ const out = this.listAll();
38
+ if (!Number.isFinite(limit) || limit <= 0 || limit >= out.length) return out;
39
+ return out.slice(0, limit);
40
+ }
41
+
42
+ /** Full catalog (no slice). Used by forum topic setup for 1000+ projects. */
43
+ listAll(): ProjectEntry[] {
37
44
  const byName = new Map<string, ProjectEntry>();
38
45
 
39
46
  for (const root of this.roots) {
@@ -62,17 +69,23 @@ export class ProjectManager {
62
69
 
63
70
  // Freshest first (directory mtime); callers may refine `lastUsed` with
64
71
  // session activity and re-sort. Alphabetical as a stable tiebreak.
65
- const out = [...byName.values()].sort(
72
+ return [...byName.values()].sort(
66
73
  (a, b) => b.lastUsed - a.lastUsed || a.name.localeCompare(b.name),
67
74
  );
68
- return out.slice(0, limit);
75
+ }
76
+
77
+ /** Exact catalog name match (case-insensitive). */
78
+ findExactByName(name: string): ProjectEntry | undefined {
79
+ const key = name.trim().toLowerCase();
80
+ if (!key) return undefined;
81
+ return this.listAll().find((p) => p.name.toLowerCase() === key);
69
82
  }
70
83
 
71
84
  /** Projects whose name contains the query (case-insensitive). */
72
85
  search(query: string, limit = 100): ProjectEntry[] {
73
86
  const q = query.trim().toLowerCase();
74
87
  if (!q) return this.list(limit);
75
- return this.list(1000)
88
+ return this.listAll()
76
89
  .filter((p) => p.name.toLowerCase().includes(q))
77
90
  .slice(0, limit);
78
91
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Split a MarkdownV2 string into Telegram-sized chunks (<= 4096 chars) without
3
- * breaking code fences. If a split happens inside a ``` block, the block is
4
- * closed before the boundary and reopened in the next chunk.
3
+ * breaking code fences. If a split happens inside a fenced block, the block is
4
+ * closed before the boundary and reopened in the next chunk (same tick length).
5
5
  */
6
6
  const LIMIT = 4000; // headroom under Telegram's 4096 hard limit
7
7
 
@@ -12,18 +12,19 @@ export function chunkMarkdown(text: string, limit = LIMIT): string[] {
12
12
  const chunks: string[] = [];
13
13
  let current: string[] = [];
14
14
  let size = 0;
15
- let fenceLang: string | null = null; // non-null => currently inside a fence
15
+ /** Open fence: tick count + optional lang; null when outside a fence. */
16
+ let openFence: { ticks: number; lang: string } | null = null;
16
17
 
17
18
  const flush = (): void => {
18
19
  if (current.length === 0) return;
19
20
  let body = current.join("\n");
20
- if (fenceLang !== null) body += "\n```"; // close dangling fence
21
+ if (openFence) body += "\n" + "`".repeat(openFence.ticks); // close dangling fence
21
22
  chunks.push(body);
22
23
  current = [];
23
24
  size = 0;
24
- if (fenceLang !== null) {
25
- // Reopen the fence at the top of the next chunk.
26
- const reopen = "```" + fenceLang;
25
+ if (openFence) {
26
+ // Reopen the fence at the top of the next chunk (preserve tick length).
27
+ const reopen = "`".repeat(openFence.ticks) + openFence.lang;
27
28
  current.push(reopen);
28
29
  size = reopen.length + 1;
29
30
  }
@@ -31,9 +32,9 @@ export function chunkMarkdown(text: string, limit = LIMIT): string[] {
31
32
 
32
33
  for (const rawLine of lines) {
33
34
  const line = rawLine;
34
- const fenceMatch = /^```(.*)$/.exec(line);
35
+ const fenceMatch = /^(```+)(.*)$/.exec(line);
35
36
 
36
- // Hard-split a single oversized line.
37
+ // Hard-split a single oversized line (never mid-fence marker line).
37
38
  if (line.length + 1 > limit && fenceMatch === null) {
38
39
  flush();
39
40
  for (let i = 0; i < line.length; i += limit) {
@@ -48,7 +49,13 @@ export function chunkMarkdown(text: string, limit = LIMIT): string[] {
48
49
  size += line.length + 1;
49
50
 
50
51
  if (fenceMatch) {
51
- fenceLang = fenceLang === null ? (fenceMatch[1] ?? "").trim() : null;
52
+ const ticks = fenceMatch[1]!.length;
53
+ const lang = (fenceMatch[2] ?? "").trim();
54
+ if (!openFence) {
55
+ openFence = { ticks, lang };
56
+ } else if (ticks >= openFence.ticks) {
57
+ openFence = null;
58
+ }
52
59
  }
53
60
  }
54
61
 
@@ -10,6 +10,8 @@ export interface TagInput {
10
10
  projectName?: string;
11
11
  cwd?: string;
12
12
  sessionId?: string;
13
+ /** Short id for a single user turn; becomes `#prompt_<id>`. */
14
+ promptId?: string;
13
15
  }
14
16
 
15
17
  /** Sanitise a value into a Telegram-safe hashtag body (letters/digits/_ only). */
@@ -25,10 +27,12 @@ export function tagSafe(v: string): string {
25
27
  /**
26
28
  * Build the hashtag footer. `#proj_` is always present; `#sess_` is added only
27
29
  * when the session id is known, so partial callers (e.g. a static /history view)
28
- * still tag consistently. Order: project · session.
30
+ * still tag consistently. `#prompt_` is added when a turn-level id is known.
31
+ * Order: project · session · prompt.
29
32
  */
30
33
  export function sessionHashtags(input: TagInput): string {
31
34
  const tags = [`#proj_${tagSafe(input.projectName || basename(input.cwd || "") || "none")}`];
32
35
  if (input.sessionId) tags.push(`#sess_${tagSafe(input.sessionId.slice(0, 8))}`);
36
+ if (input.promptId) tags.push(`#prompt_${tagSafe(input.promptId)}`);
33
37
  return tags.join(" ");
34
38
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Session "comment" shown on Running / Sessions cards:
3
- * • while busy — current step (tool / thinking / working on …)
4
- * • when idle — what the last turn solved (assistant result + files)
3
+ * • always — last user prompt (≤ COMMENT_MAX)
4
+ * • while busy — also last AI agent thinking (≤ COMMENT_MAX), second line
5
5
  *
6
6
  * Built only from data already in the turn — no extra agent prompt (those
7
7
  * leaked into chat/history). Display-only; never truncates agent context.
@@ -18,8 +18,8 @@ import {
18
18
  } from "./tool-call-detail.js";
19
19
  import type { FileOp } from "./file-summary.js";
20
20
 
21
- /** Max length of a card comment line (room for a real one-sentence outcome). */
22
- export const COMMENT_MAX = 200;
21
+ /** Max length of each card comment line (user prompt or thinking). */
22
+ export const COMMENT_MAX = 250;
23
23
 
24
24
  /**
25
25
  * Legacy marker for the removed silent AI card-summary prompt. Kept only so
@@ -55,13 +55,24 @@ export function stripDirectiveWrappers(raw: string): string {
55
55
  const marker = "User's new message:";
56
56
  const i = t.lastIndexOf(marker);
57
57
  if (i !== -1) t = t.slice(i + marker.length);
58
- t = t.replace(/^TASK COMPLEXITY:[\s\S]*?User task:\s*/i, "");
59
- t = t.replace(/^COMPLEXITY \(decide yourself[\s\S]*?User task:\s*/i, "");
58
+ // Continued marker first — it contains the substring "User task:".
59
+ const cont = "User task (continued):";
60
+ const ci = t.lastIndexOf(cont);
61
+ if (ci !== -1) {
62
+ t = t.slice(ci + cont.length);
63
+ } else {
64
+ t = t.replace(/^TASK COMPLEXITY:[\s\S]*?User task:\s*/i, "");
65
+ t = t.replace(/^COMPLEXITY \(decide yourself[\s\S]*?User task:\s*/i, "");
66
+ if (/TELEGRAM BRIDGE \(how to work/i.test(t)) {
67
+ t = t.replace(/TELEGRAM BRIDGE \(how to work[\s\S]*?(?=\n\n[A-Za-z]|$)/i, "");
68
+ }
69
+ }
70
+ if (/^TELEGRAM BRIDGE RESULTS \(system/i.test(t.trim())) t = "";
60
71
  return t.trim();
61
72
  }
62
73
 
63
74
  /** Strip bot directives so a user prompt is card-friendly. */
64
- export function cleanUserPreview(raw: string, max = 80): string {
75
+ export function cleanUserPreview(raw: string, max = COMMENT_MAX): string {
65
76
  let t = stripDirectiveWrappers(raw);
66
77
  // Import confirm prompts are noise on cards.
67
78
  if (/session import complete/i.test(t)) return "";
@@ -69,9 +80,55 @@ export function cleanUserPreview(raw: string, max = 80): string {
69
80
  if (/^SELF-RECHECK \(automatic quality pass/i.test(t)) return "Self-recheck";
70
81
  if (/^SELF-RECHECK DECISION \(meta only\)/i.test(t)) return "";
71
82
  if (/^FOLLOW-UP SUGGESTIONS \(meta only\)/i.test(t)) return "";
83
+ if (/^TELEGRAM BRIDGE RESULTS \(system/i.test(t)) return "Telegram bridge";
72
84
  return cleanCommentLine(t, max);
73
85
  }
74
86
 
87
+ /**
88
+ * Format the session-card comment body:
89
+ * idle → last user prompt (≤ max)
90
+ * busy → user prompt + last agent thinking on the next line (each ≤ max)
91
+ *
92
+ * Returns "" when neither line has content. Caller adds icons per line.
93
+ */
94
+ export function buildSessionCardComment(opts: {
95
+ userPrompt?: string;
96
+ thinking?: string;
97
+ busy?: boolean;
98
+ max?: number;
99
+ }): string {
100
+ const max = opts.max ?? COMMENT_MAX;
101
+ const user = opts.userPrompt?.trim()
102
+ ? cleanUserPreview(opts.userPrompt, max)
103
+ : "";
104
+ const thinkRaw = opts.busy && opts.thinking?.trim() ? opts.thinking.trim() : "";
105
+ const thinking = thinkRaw ? clampThinking(thinkRaw, max) : "";
106
+
107
+ if (user && thinking) return `${user}\n${thinking}`;
108
+ if (user) return user;
109
+ if (thinking) return thinking;
110
+ return "";
111
+ }
112
+
113
+ /**
114
+ * Prefer the latest portion of accumulated thought text (streaming chunks
115
+ * append; conclusions land at the end). Collapse whitespace and clamp.
116
+ */
117
+ export function clampThinking(raw: string, max = COMMENT_MAX): string {
118
+ let t = extractProgress(raw).cleaned;
119
+ t = t.replace(/```[\s\S]*?```/g, " ");
120
+ t = t.replace(/\s+/g, " ").trim();
121
+ if (!t) return "";
122
+ if (t.length <= max) return t;
123
+ // Prefer the ending (most recent thinking).
124
+ if (t.length > max + 40) {
125
+ const tail = t.slice(-(max - 1));
126
+ const sp = tail.indexOf(" ");
127
+ return "\u2026" + (sp > 0 && sp < 40 ? tail.slice(sp + 1) : tail);
128
+ }
129
+ return t.slice(0, max - 1) + "\u2026";
130
+ }
131
+
75
132
  /**
76
133
  * Pull a human-useful outcome snippet from the assistant's streamed reply.
77
134
  * Prefers the closing sentences (where conclusions land) over mid-turn "I'll…".