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
@@ -62,6 +62,17 @@ import {
62
62
  type Suggestion,
63
63
  suggestionsKeyboard,
64
64
  } from "./suggestions.js";
65
+ import type { ForumManager } from "../forum/manager.js";
66
+ import type { SessionStore } from "../sessions/store.js";
67
+ import type { TelegramBotService } from "./telegram-bots.js";
68
+ import { executeTelegramActions } from "./telegram-actions.js";
69
+ import {
70
+ buildTelegramBridgeDirective,
71
+ buildTelegramBridgeResultsPrompt,
72
+ extractTelegramActions,
73
+ isTelegramBridgeResultsPrompt,
74
+ wrapTelegramBridgePrompt,
75
+ } from "../render/telegram-bridge.js";
65
76
  import {
66
77
  parsePlanUpdate,
67
78
  renderPlanMarkdown,
@@ -69,9 +80,11 @@ import {
69
80
  type PlanEntry,
70
81
  } from "../render/plan.js";
71
82
  import {
72
- buildLastTurnSummary,
83
+ buildSessionCardComment,
84
+ clampThinking,
73
85
  cleanCommentLine,
74
86
  cleanUserPreview,
87
+ COMMENT_MAX,
75
88
  stepFromThought,
76
89
  stepFromToolUpdate,
77
90
  stripDirectiveWrappers,
@@ -144,6 +157,8 @@ export class SessionRuntime {
144
157
  private turnCount = 0;
145
158
  /** Telegram message id of the current turn's prompt, so replies thread to it. */
146
159
  private turnReplyTo: number | undefined;
160
+ /** Short id for `#prompt_<id>` on all AI messages of this turn. */
161
+ private turnPromptId: string | undefined;
147
162
  private imageScanText = "";
148
163
  private sentImagesThisTurn = new Set<string>();
149
164
  /** Monotonic count used to reject ACP "success" responses with no turn updates. */
@@ -172,19 +187,52 @@ export class SessionRuntime {
172
187
  accountRotator: AccountRotator | undefined;
173
188
  /** Session ids that already received the first-prompt auto-complexity directive. */
174
189
  private complexitySteered = new Set<string>();
190
+ /** Session ids that already received the first-prompt Telegram bridge directive. */
191
+ private telegramBridgeSteered = new Set<string>();
192
+ /**
193
+ * How many TELEGRAM BRIDGE RESULTS follow-ups are chained after the current
194
+ * user turn. Caps infinite list_bots/bot_command loops; reset on real user work.
195
+ */
196
+ private bridgeResultDepth = 0;
197
+ /** Max sequential bridge result turns per user request. */
198
+ private static readonly BRIDGE_CHAIN_MAX = 4;
199
+ /**
200
+ * Optional Telegram bridge services (forum / session store / sibling bots).
201
+ * Injected by the registry after construct.
202
+ */
203
+ bridge?: {
204
+ store: SessionStore;
205
+ forum?: ForumManager;
206
+ bots: TelegramBotService;
207
+ /** Cross-topic prompt dispatch (create_topic → send_prompt orchestration). */
208
+ submitTopicPrompt?: import("./telegram-actions.js").SubmitTopicPromptFn;
209
+ };
175
210
  /** Last credits total reported for this session (for per-turn delta accounting). */
176
211
  private lastReportedCredits = 0;
177
- /** Live "what is happening now" line while a turn is in flight. */
212
+ /** Live "what is happening now" line while a turn is in flight (tools/plan). */
178
213
  private liveStep: string | undefined;
179
- /** Idle card comment (AI/local summary of the chat after the last turn). */
214
+ /**
215
+ * Card comment on disk / idle: last user prompt (≤ COMMENT_MAX).
216
+ * While busy, {@link cardComment} also appends last agent thinking.
217
+ */
180
218
  private sessionComment: string | undefined;
219
+ /** Cleaned last user prompt for cards (not overwritten by self-recheck meta). */
220
+ private cardUserPrompt: string | undefined;
221
+ /** Accumulated agent_thought_chunk text for the current turn (card display). */
222
+ private cardThinking = "";
181
223
  /** User text of the turn currently running (for local card-comment fallback). */
182
224
  private turnUserText = "";
183
- /** Assistant prose streamed this turn — used to build the idle card summary. */
225
+ /** Assistant prose streamed this turn — used for suggestions / completion. */
184
226
  private turnAssistantText = "";
185
227
  /** Quiet meta capture (suggestions) — never stream to Telegram. */
186
228
  private capturingQuiet = false;
187
229
  private quietCaptureBuf = "";
230
+ /**
231
+ * Done delivery bookkeeping for this turn: expect a loud Done ping, and whether
232
+ * one was successfully sent (finally forces a short Done if expected but missing).
233
+ */
234
+ private turnExpectDone = false;
235
+ private turnDonePinged = false;
188
236
  /** Batches of post-turn suggestions for inline-button callbacks. */
189
237
  private suggestionBatches = new Map<number, Suggestion[]>();
190
238
  private suggestionBatchSeq = 0;
@@ -214,20 +262,36 @@ export class SessionRuntime {
214
262
  */
215
263
  private skipSelfRecheck = false;
216
264
 
265
+ /**
266
+ * Forum topic thread id (message_thread_id). When set, all outbound messages
267
+ * for this runtime are posted into that topic.
268
+ */
269
+ readonly messageThreadId: number | undefined;
270
+ /** Settings storage key (`chatId` or `chatId:t{threadId}`). */
271
+ readonly settingsKey: string;
272
+
217
273
  constructor(
218
274
  private readonly api: Api,
219
275
  private readonly chatId: number,
220
276
  private readonly acp: GrokClient,
221
277
  private readonly cfg: AppConfig,
222
278
  private readonly settings: SettingsStore,
223
- init?: { cwd: string; projectName?: string; sessionId?: string },
279
+ init?: {
280
+ cwd: string;
281
+ projectName?: string;
282
+ sessionId?: string;
283
+ messageThreadId?: number;
284
+ settingsKey?: string;
285
+ },
224
286
  ) {
287
+ this.messageThreadId = init?.messageThreadId;
288
+ this.settingsKey = init?.settingsKey ?? String(chatId);
225
289
  if (init) {
226
290
  this.cwd = init.cwd;
227
291
  this.projectName = init.projectName;
228
292
  this.sessionId = init.sessionId;
229
293
  } else {
230
- const s = settings.get(chatId);
294
+ const s = settings.getKey(this.settingsKey);
231
295
  this.cwd = s.projectPath ?? cfg.workspace;
232
296
  this.projectName = s.projectName;
233
297
  this.sessionId = s.sessionId;
@@ -299,14 +363,23 @@ export class SessionRuntime {
299
363
  }
300
364
 
301
365
  /**
302
- * One-line status for Running/Sessions cards:
303
- * live step while busy, otherwise the last chat summary / comment.
366
+ * Session card comment:
367
+ * always last user prompt (≤250)
368
+ * busy — plus last AI agent thinking on the next line (≤250)
304
369
  */
305
370
  get cardComment(): string | undefined {
306
- if (this.busy && this.liveStep) return this.liveStep;
307
- if (this.sessionComment) return this.sessionComment;
308
- if (this.sessionId) return this.acp.sessionComment(this.sessionId);
309
- return undefined;
371
+ const user =
372
+ this.cardUserPrompt ||
373
+ this.sessionComment ||
374
+ (this.sessionId ? this.acp.sessionComment(this.sessionId) : undefined) ||
375
+ cleanUserPreview(this.suggestionUserText || this.turnUserText || "", COMMENT_MAX) ||
376
+ undefined;
377
+ const built = buildSessionCardComment({
378
+ userPrompt: user,
379
+ thinking: this.busy && this.cardThinking ? this.cardThinking : undefined,
380
+ busy: this.busy,
381
+ });
382
+ return built || undefined;
310
383
  }
311
384
 
312
385
  /** Record a new progress value and refresh the status panel / cards. The bar
@@ -319,7 +392,7 @@ export class SessionRuntime {
319
392
  this.changed();
320
393
  }
321
394
 
322
- /** Update the live step shown on session cards (throttled by equality). */
395
+ /** Update the live step (tools/plan) kept for diagnostics; cards use user+thinking. */
323
396
  private setLiveStep(step: string | undefined): void {
324
397
  const next = step?.trim() ? cleanCommentLine(step) : undefined;
325
398
  if (next === this.liveStep) return;
@@ -327,9 +400,21 @@ export class SessionRuntime {
327
400
  this.changed();
328
401
  }
329
402
 
330
- /** Persist idle card comment (disk + memory) so /running and /sessions see it. */
403
+ /** Append thought text and refresh cards when the display line changes. */
404
+ private appendCardThinking(chunk: string): void {
405
+ const piece = chunk.replace(/\s+/g, " ").trim();
406
+ if (!piece) return;
407
+ const prevShown = this.cardThinking ? clampThinking(this.cardThinking, COMMENT_MAX) : "";
408
+ this.cardThinking = this.cardThinking ? `${this.cardThinking} ${piece}` : piece;
409
+ const nextShown = clampThinking(this.cardThinking, COMMENT_MAX);
410
+ if (nextShown !== prevShown) this.changed();
411
+ }
412
+
413
+ /** Persist last user prompt (disk + memory) so /running and /sessions see it. */
331
414
  private setSessionComment(comment: string | undefined): void {
332
- const next = comment?.trim() ? cleanCommentLine(comment) : undefined;
415
+ const next = comment?.trim()
416
+ ? cleanUserPreview(comment, COMMENT_MAX) || cleanCommentLine(comment, COMMENT_MAX)
417
+ : undefined;
333
418
  if (next === this.sessionComment) return;
334
419
  this.sessionComment = next;
335
420
  if (next && this.sessionId) {
@@ -342,11 +427,25 @@ export class SessionRuntime {
342
427
  this.changed();
343
428
  }
344
429
 
345
- /** Hydrate comment from disk after bind/resume. */
430
+ /** Keep disk/memory comment = last real user prompt after a turn ends. */
431
+ private persistCardUserPrompt(): void {
432
+ const prompt =
433
+ this.cardUserPrompt ||
434
+ cleanUserPreview(this.suggestionUserText || this.turnUserText || "", COMMENT_MAX);
435
+ if (prompt) {
436
+ this.cardUserPrompt = prompt;
437
+ this.setSessionComment(prompt);
438
+ }
439
+ }
440
+
441
+ /** Hydrate last user prompt from disk after bind/resume. */
346
442
  private loadPersistedComment(): void {
347
443
  if (!this.sessionId) return;
348
444
  const c = this.acp.sessionComment(this.sessionId);
349
- if (c) this.sessionComment = c;
445
+ if (c) {
446
+ this.sessionComment = c;
447
+ if (!this.cardUserPrompt) this.cardUserPrompt = cleanUserPreview(c, COMMENT_MAX) || c;
448
+ }
350
449
  }
351
450
 
352
451
  /** Searchable hashtag footer for this session (project В· session В· model В·
@@ -369,7 +468,17 @@ export class SessionRuntime {
369
468
  if (this.busy && !this.streamer) {
370
469
  // Any transient follow-watch of this session is now superseded.
371
470
  if (this.watchIsFollow) this.stopWatch();
372
- this.streamer = new ResponseStreamer(this.api, this.chatId, this.cfg.streamThrottleMs, this.turnReplyTo, this.hashtags(), (pct) => this.setProgress(pct), this.cfg.progressFallback, this.turnStartedAt);
471
+ this.streamer = new ResponseStreamer(
472
+ this.api,
473
+ this.chatId,
474
+ this.cfg.streamThrottleMs,
475
+ this.turnReplyTo,
476
+ this.hashtags(),
477
+ (pct) => this.setProgress(pct),
478
+ this.cfg.progressFallback,
479
+ this.turnStartedAt,
480
+ this.messageThreadId,
481
+ );
373
482
  // Restore the live plan board so steps stay visible above the progress bar.
374
483
  if (this.planEntries?.length) {
375
484
  this.streamer.setPlan(renderPlanMarkdown(this.planEntries));
@@ -390,13 +499,16 @@ export class SessionRuntime {
390
499
  this.changed();
391
500
  }
392
501
  get reasoning(): ReasoningEffort {
393
- return this.settings.get(this.chatId).reasoning;
502
+ return this.settings.getKey(this.settingsKey).reasoning;
394
503
  }
395
504
  get agent(): string | undefined {
396
- return this.settings.get(this.chatId).agent;
505
+ return this.settings.getKey(this.settingsKey).agent;
397
506
  }
398
507
  get model(): string | undefined {
399
- return this.settings.get(this.chatId).model;
508
+ return this.settings.getKey(this.settingsKey).model;
509
+ }
510
+ get preferredAccountId(): string | undefined {
511
+ return this.settings.getKey(this.settingsKey).preferredAccountId;
400
512
  }
401
513
 
402
514
  /** Latest context-usage % / effort / credits for the current session. */
@@ -440,6 +552,8 @@ export class SessionRuntime {
440
552
  this.lastReportedCredits = 0;
441
553
  this.liveStep = undefined;
442
554
  this.sessionComment = undefined;
555
+ this.cardUserPrompt = undefined;
556
+ this.cardThinking = "";
443
557
  await this.applySessionPrefs();
444
558
  this.persist();
445
559
  this.sessionChanged();
@@ -495,8 +609,8 @@ export class SessionRuntime {
495
609
  async startImportedSession(cwd: string, projectName: string | undefined, priming: string): Promise<void> {
496
610
  await this.startNewSession(cwd, projectName);
497
611
  if (priming.trim()) this.primingContext = priming;
498
- // Imported transcripts already have context — skip first-prompt complexity steering.
499
- this.markComplexitySteered();
612
+ // Imported transcripts already have context — skip first-prompt directives.
613
+ this.markFirstPromptSteered();
500
614
  }
501
615
 
502
616
  startWatch(jsonlPath: string, follow = false): void {
@@ -519,7 +633,7 @@ export class SessionRuntime {
519
633
  async setModelPref(modelId: string): Promise<{ ok: boolean; error?: string }> {
520
634
  // Persist the choice always; only talk to Grok when a session is live in
521
635
  // the current process (set_model on an unloaded session crashes the agent).
522
- this.settings.update(this.chatId, { model: modelId });
636
+ this.settings.updateKey(this.settingsKey, { model: modelId });
523
637
  if (modelId && this.sessionLive && this.sessionId) {
524
638
  if (!this.acp.hasModel(modelId)) return { ok: false, error: `unknown model: ${modelId}` };
525
639
  try {
@@ -534,7 +648,7 @@ export class SessionRuntime {
534
648
  }
535
649
 
536
650
  async setAgentPref(agent: string): Promise<void> {
537
- this.settings.update(this.chatId, { agent });
651
+ this.settings.updateKey(this.settingsKey, { agent });
538
652
  if (agent && this.sessionLive && this.sessionId && this.acp.hasMode(agent)) {
539
653
  try {
540
654
  await this.acp.setMode(this.sessionId, agent);
@@ -546,22 +660,29 @@ export class SessionRuntime {
546
660
  }
547
661
 
548
662
  setReasoningPref(effort: ReasoningEffort): void {
549
- this.settings.update(this.chatId, { reasoning: effort });
663
+ this.settings.updateKey(this.settingsKey, { reasoning: effort });
664
+ this.changed();
665
+ }
666
+
667
+ setPreferredAccountId(id: string | undefined): void {
668
+ this.settings.updateKey(this.settingsKey, { preferredAccountId: id || undefined });
669
+ // Force re-apply on next ensureSession when user changes preference.
670
+ this.preferredAccountApplied = undefined;
550
671
  this.changed();
551
672
  }
552
673
 
553
674
  private async applySessionPrefs(): Promise<void> {
554
- const s = this.settings.get(this.chatId);
675
+ const s = this.settings.getKey(this.settingsKey);
555
676
  // Drop any persisted model the agent doesn't actually offer (an unknown id
556
677
  // is silently accepted by set_model but then breaks the next prompt).
557
678
  if (s.model && !this.acp.hasModel(s.model)) {
558
- log.warn(`clearing invalid persisted model "${s.model}" for chat ${this.chatId}`);
559
- this.settings.update(this.chatId, { model: "" });
679
+ log.warn(`clearing invalid persisted model "${s.model}" for scope ${this.settingsKey}`);
680
+ this.settings.updateKey(this.settingsKey, { model: "" });
560
681
  }
561
- const cur = this.settings.get(this.chatId);
682
+ const cur = this.settings.getKey(this.settingsKey);
562
683
  // Adopt the session's current agent (mode) when the user hasn't chosen one.
563
684
  if (!cur.agent && this.acp.currentModeId) {
564
- this.settings.update(this.chatId, { agent: this.acp.currentModeId });
685
+ this.settings.updateKey(this.settingsKey, { agent: this.acp.currentModeId });
565
686
  } else if (this.sessionId && cur.agent && this.acp.hasMode(cur.agent) && cur.agent !== this.acp.currentModeId) {
566
687
  try {
567
688
  await this.acp.setMode(this.sessionId, cur.agent);
@@ -587,35 +708,64 @@ export class SessionRuntime {
587
708
  this.changed();
588
709
  return "queued";
589
710
  }
590
- // First prompt of a fresh session: steer the agent to decide complexity
591
- // itself (plan if complex, implement if simple) never ask the user.
592
- let toRun = input;
593
- if (this.shouldSteerComplexity()) {
594
- toRun = wrapAutoComplexityPrompt(input);
595
- this.markComplexitySteered();
596
- log.info(`chat ${this.chatId}: first-prompt auto-complexity steering applied`);
597
- }
598
- void this.runTurn(toRun);
711
+ // First-prompt steering is applied inside runTurn so queued first messages
712
+ // (and flushQueue) get the same complexity + telegram bridge directives.
713
+ void this.runTurn(input);
599
714
  return "ran";
600
715
  }
601
716
 
602
- private markComplexitySteered(): void {
603
- if (this.sessionId) this.complexitySteered.add(this.sessionId);
717
+ private markFirstPromptSteered(): void {
718
+ if (!this.sessionId) return;
719
+ this.complexitySteered.add(this.sessionId);
720
+ this.telegramBridgeSteered.add(this.sessionId);
721
+ }
722
+
723
+ private telegramBridgeDirective(): string {
724
+ return buildTelegramBridgeDirective({
725
+ forumReady: !!this.bridge?.forum?.isReady,
726
+ topicGroupId: this.cfg.topicGroupId,
727
+ allowedBots: this.cfg.allowedTelegramBots,
728
+ botCommands: this.cfg.telegramBotCommands,
729
+ });
730
+ }
731
+
732
+ /**
733
+ * Complexity + telegram bridge teaching on the first prompt of a brand-new
734
+ * conversation only (no prior user turns in this process / session jsonl).
735
+ */
736
+ private applyFirstPromptSteering(input: PromptInput): PromptInput {
737
+ if (!this.shouldSteerFirstPrompt(input)) return input;
738
+ let toRun = wrapAutoComplexityPrompt(input);
739
+ toRun = wrapTelegramBridgePrompt(toRun, this.telegramBridgeDirective());
740
+ this.markFirstPromptSteered();
741
+ log.info(`chat ${this.chatId}: first-prompt complexity + telegram bridge applied`);
742
+ return toRun;
604
743
  }
605
744
 
606
745
  /**
607
- * Apply auto-complexity directive only on the first prompt of a brand-new
608
- * conversation (no prior user turns in this process / session jsonl).
746
+ * Apply first-prompt directives only on a brand-new conversation (no prior
747
+ * user turns in this process / session jsonl).
609
748
  */
610
- private shouldSteerComplexity(): boolean {
749
+ private shouldSteerFirstPrompt(input: PromptInput): boolean {
611
750
  if (!this.sessionId) return false;
612
- if (this.complexitySteered.has(this.sessionId)) return false;
751
+ if (this.complexitySteered.has(this.sessionId) && this.telegramBridgeSteered.has(this.sessionId)) {
752
+ return false;
753
+ }
613
754
  if (this.turnCount > 0) return false;
755
+ // Never wrap meta follow-ups even if somehow first.
756
+ if (
757
+ input.skipSelfRecheck ||
758
+ isSelfRecheckPrompt(input.text) ||
759
+ isTelegramBridgeResultsPrompt(input.text)
760
+ ) {
761
+ return false;
762
+ }
614
763
  try {
615
764
  const path = join(this.cfg.sessionsDir, `${this.sessionId}.jsonl`);
616
765
  const hist = readHistory(path, 8);
617
766
  if (hist.some((e) => e.role === "user" && e.text.trim().length > 0)) {
618
767
  this.complexitySteered.add(this.sessionId);
768
+ this.telegramBridgeSteered.add(this.sessionId);
619
769
  return false;
620
770
  }
621
771
  } catch {
@@ -624,9 +774,16 @@ export class SessionRuntime {
624
774
  return true;
625
775
  }
626
776
 
777
+ /**
778
+ * Stop the current turn for this runtime only.
779
+ * Soft ACP cancel + session-scoped force-complete; never kills the shared
780
+ * agent (that would stop every multiplexed chat and take the bot offline).
781
+ */
627
782
  async cancel(): Promise<boolean> {
628
783
  if (!this.busy || !this.sessionId) return false;
629
784
  this.cancelled = true;
785
+ // Clear queue of follow-ups for this turn? No — only stop the active turn;
786
+ // queued user messages remain so the user can flush later if they want.
630
787
  await this.acp.cancel(this.sessionId);
631
788
  return true;
632
789
  }
@@ -647,6 +804,7 @@ export class SessionRuntime {
647
804
  // Account rotation restarts the process globally. Do not bind a new chat
648
805
  // to a candidate account until the owner has finished probing it.
649
806
  await this.accountRotator?.waitForIdle();
807
+ await this.applyPreferredAccount();
650
808
  if (this.rebindPending && this.sessionId) {
651
809
  // The ACP process is frequently mid-restart the first time we re-bind
652
810
  // (auto-restart after a crash, or a fresh bot boot), so a single attempt
@@ -671,6 +829,38 @@ export class SessionRuntime {
671
829
  if (!this.sessionId) await this.startNewSession(this.cwd, this.projectName);
672
830
  }
673
831
 
832
+ /** Last preferred account we successfully aligned to (avoids activate thrash). */
833
+ private preferredAccountApplied?: string;
834
+
835
+ /**
836
+ * If this scope prefers a saved account and the process is on another login,
837
+ * switch before binding the session. Skips when another turn is in flight
838
+ * (account switch restarts the agent) or we already applied this preference.
839
+ */
840
+ private async applyPreferredAccount(): Promise<void> {
841
+ const preferred = this.preferredAccountId;
842
+ const rotator = this.accountRotator;
843
+ if (!preferred || !rotator) return;
844
+ const st = rotator.state();
845
+ if (st.activeId === preferred) {
846
+ this.preferredAccountApplied = preferred;
847
+ return;
848
+ }
849
+ // User cleared or changed preference — allow one more activate.
850
+ if (this.preferredAccountApplied === preferred) return;
851
+ if (this.acp.hasInflightPrompt()) {
852
+ log.debug(`scope ${this.settingsKey}: skip preferred account (turn in flight)`);
853
+ return;
854
+ }
855
+ try {
856
+ await rotator.activate(preferred);
857
+ this.preferredAccountApplied = preferred;
858
+ log.info(`scope ${this.settingsKey}: activated preferred account ${preferred.slice(0, 8)}`);
859
+ } catch (e) {
860
+ log.debug(`preferred account activate failed: ${(e as Error).message}`);
861
+ }
862
+ }
863
+
674
864
  /** Reload a persisted session, retrying flaky failures with a short backoff.
675
865
  * Returns true once loaded, false after the attempts are exhausted. */
676
866
  private async rebindWithRetries(sessionId: string, attempts = 4): Promise<boolean> {
@@ -711,20 +901,46 @@ export class SessionRuntime {
711
901
  }
712
902
 
713
903
  private async runTurn(input: PromptInput): Promise<void> {
904
+ // Apply before any turn bookkeeping so card previews / logs see the wrapped
905
+ // text the same way the agent does (also covers flushQueue first messages).
906
+ input = this.applyFirstPromptSteering(input);
907
+
714
908
  this.busy = true;
715
909
  this.cancelled = false;
716
910
  this.turnReplyTo = input.replyTo;
911
+ this.turnPromptId = input.promptId;
717
912
  this.turnUserText = input.text;
718
913
  this.turnAssistantText = "";
914
+ this.cardThinking = "";
915
+ this.turnExpectDone = false;
916
+ this.turnDonePinged = false;
719
917
  this.isSelfRecheckTurn = isSelfRecheckPrompt(input.text);
720
- // Meta turns (recheck, auto-suggestion batches) never arm another recheck.
721
- this.skipSelfRecheck = !!input.skipSelfRecheck || this.isSelfRecheckTurn;
722
- // Fresh user work resets suggestion anchors; recheck keeps the original ask.
918
+ // Meta turns (recheck, bridge results, auto-suggestion batches) never arm another recheck.
919
+ const isBridgeResults = isTelegramBridgeResultsPrompt(input.text);
920
+ this.skipSelfRecheck =
921
+ !!input.skipSelfRecheck ||
922
+ this.isSelfRecheckTurn ||
923
+ isBridgeResults;
924
+ // Fresh user work resets bridge-chain depth + suggestion anchors.
925
+ if (!this.isSelfRecheckTurn && !isBridgeResults) {
926
+ this.bridgeResultDepth = 0;
927
+ }
928
+ // Fresh user work resets suggestion anchors; recheck / bridge results keep the original ask.
723
929
  // Strip complexity/reply wrappers so suggestions + recheck see the real ask.
724
- if (!this.isSelfRecheckTurn) {
930
+ if (!this.isSelfRecheckTurn && !isBridgeResults) {
725
931
  this.suggestionUserText = stripDirectiveWrappers(input.text) || input.text;
726
932
  this.preRecheckAssistantText = "";
727
933
  this.preRecheckFileOps = new Map();
934
+ // Card comment: last real user prompt (not self-recheck / empty meta).
935
+ const preview = input.text.trim()
936
+ ? cleanUserPreview(input.text, COMMENT_MAX)
937
+ : input.images.length
938
+ ? "Attached image(s)"
939
+ : "";
940
+ if (preview) {
941
+ this.cardUserPrompt = preview;
942
+ this.setSessionComment(preview);
943
+ }
728
944
  }
729
945
  this.shownToolIds = new Set();
730
946
  this.toolCallCache = new Map();
@@ -749,7 +965,17 @@ export class SessionRuntime {
749
965
  const startedAt = Date.now();
750
966
  this.turnStartedAt = startedAt;
751
967
  this.streamer = live
752
- ? new ResponseStreamer(this.api, this.chatId, this.cfg.streamThrottleMs, this.turnReplyTo, this.hashtags(), (pct) => this.setProgress(pct), this.cfg.progressFallback, startedAt)
968
+ ? new ResponseStreamer(
969
+ this.api,
970
+ this.chatId,
971
+ this.cfg.streamThrottleMs,
972
+ this.turnReplyTo,
973
+ this.hashtags(),
974
+ (pct) => this.setProgress(pct),
975
+ this.cfg.progressFallback,
976
+ startedAt,
977
+ this.messageThreadId,
978
+ )
753
979
  : undefined;
754
980
  if (live) this.typing.start();
755
981
  this.activity(true);
@@ -786,6 +1012,15 @@ export class SessionRuntime {
786
1012
  if (final.result && !this.cancelled) this.streamer?.completeFallback();
787
1013
  if (this.streamer) await this.streamer.finalize();
788
1014
  if (this.foreground) await this.sendTurnImages();
1015
+
1016
+ // Telegram bridge actions (JSON fences in the agent reply). Process on
1017
+ // normal turns AND bridge-results follow-ups so multi-step bot_command /
1018
+ // search chains work; depth cap prevents infinite loops.
1019
+ let queuedBridgeResults = false;
1020
+ if (final.result && !this.cancelled) {
1021
+ queuedBridgeResults = await this.processTelegramBridgeActions();
1022
+ }
1023
+
789
1024
  // Always build the completion (records `lastCompletion` so switching back
790
1025
  // to this session can replay its Done + summary). Only PING the chat for
791
1026
  // the foreground turn, or a background turn when NOTIFY_OTHER_SESSIONS is on.
@@ -798,49 +1033,40 @@ export class SessionRuntime {
798
1033
  this.turnCount++;
799
1034
  // Persist real per-account usage (turns + reported credits) for /accounts and /usage.
800
1035
  this.recordAccountUsage();
801
- // Card comment: what this turn solved (assistant result + files) — no extra agent call.
802
- this.setSessionComment(
803
- buildLastTurnSummary({
804
- userText: this.turnUserText,
805
- assistantText: this.turnAssistantText,
806
- fileOps: this.fileOps,
807
- stopReason: final.result.stopReason,
808
- }),
809
- );
810
- this.setLiveStep(undefined);
1036
+ // Card comment: last user prompt (thinking cleared when idle).
1037
+ this.persistCardUserPrompt();
1038
+ this.cardThinking = "";
1039
+ // Keep live step while bridge/sibling-bot results are still queued —
1040
+ // clearing here made "Waiting for bot" vanish before the interim notify.
1041
+ if (!queuedBridgeResults) this.setLiveStep(undefined);
811
1042
  } else if (this.cancelled) {
812
- this.setSessionComment(
813
- buildLastTurnSummary({
814
- userText: this.turnUserText,
815
- assistantText: this.turnAssistantText,
816
- fileOps: this.fileOps,
817
- cancelled: true,
818
- }),
819
- );
1043
+ this.persistCardUserPrompt();
1044
+ this.cardThinking = "";
820
1045
  this.setLiveStep(undefined);
821
1046
  } else if (final.error) {
822
- this.setSessionComment(
823
- buildLastTurnSummary({
824
- userText: this.turnUserText,
825
- assistantText: this.turnAssistantText,
826
- fileOps: this.fileOps,
827
- error: final.error.message,
828
- }),
829
- );
1047
+ this.persistCardUserPrompt();
1048
+ this.cardThinking = "";
830
1049
  this.setLiveStep(undefined);
831
1050
  }
832
1051
  if (final.result || this.cancelled) {
833
- const liveMsg = this.completionMessage(final.result?.stopReason, startedAt, streamedOutput);
834
- const pingDone = canPing && (this.foreground || !hasQueued);
1052
+ // Bridge results in the queue mean "not Done yet" — never treat as a
1053
+ // completion ping even in the foreground. Do not post bridge status
1054
+ // spam to the chat (live step / status panel only).
1055
+ const pingDone =
1056
+ canPing && (this.foreground || !hasQueued) && !queuedBridgeResults;
1057
+ // Expect a Done this turn unless we defer for recheck (bridge already excluded).
1058
+ this.turnExpectDone = pingDone;
835
1059
 
836
1060
  // One-shot self-recheck: only after a real *user* turn (not meta/auto),
837
1061
  // with idle queue. skipSelfRecheck blocks loops after recheck / auto-batch.
838
1062
  // Also skipped when no files were modified, or when a quiet AI decision
839
1063
  // refuses (simple tasks, pure build, nothing worth re-verifying).
1064
+ // Delay recheck/Done when bridge results are queued (like self-recheck).
840
1065
  const wantSelfRecheck =
841
1066
  !!final.result &&
842
1067
  !this.cancelled &&
843
1068
  !hasQueued &&
1069
+ !queuedBridgeResults &&
844
1070
  this.cfg.selfRecheckEnabled &&
845
1071
  !this.skipSelfRecheck &&
846
1072
  !this.isSelfRecheckTurn;
@@ -853,6 +1079,13 @@ export class SessionRuntime {
853
1079
  this.preRecheckFileOps = cloneFileOps(this.fileOps);
854
1080
  this.setLiveStep("Deciding if self-recheck is needed\u2026");
855
1081
  this.changed();
1082
+ // Visible chat status so stream-complete is not mistaken for a silent exit.
1083
+ if (pingDone) {
1084
+ await this.notify(
1085
+ "\u{1F50D} Checking if a quality pass is needed\u2026",
1086
+ { loud: true, replyTo: this.turnReplyTo },
1087
+ );
1088
+ }
856
1089
  const recheck = await this.maybePlanSelfRecheck();
857
1090
  this.setLiveStep(undefined);
858
1091
  // User may cancel during the quiet decision call — first turn still
@@ -862,9 +1095,13 @@ export class SessionRuntime {
862
1095
  this.preRecheckAssistantText = "";
863
1096
  } else if (recheck) {
864
1097
  queuedRecheck = true;
1098
+ this.turnExpectDone = false; // final Done comes after the recheck turn
865
1099
  // Front of queue; mark skip so the recheck turn never re-arms itself.
866
1100
  this.queue.unshift(
867
- textPrompt(recheck, this.turnReplyTo, undefined, { skipSelfRecheck: true }),
1101
+ textPrompt(recheck, this.turnReplyTo, undefined, {
1102
+ skipSelfRecheck: true,
1103
+ promptId: this.turnPromptId,
1104
+ }),
868
1105
  );
869
1106
  this.changed();
870
1107
  if (pingDone) {
@@ -883,123 +1120,176 @@ export class SessionRuntime {
883
1120
  }
884
1121
  }
885
1122
 
886
- if (!queuedRecheck) {
887
- // Post-turn suggestions on successful, non-cancelled Done with idle queue
888
- // both foreground and background (so switch-to-session can re-show them).
889
- let doneMarkup = switchKb;
890
- // After a recheck pass, rebuild Done with split first-turn / recheck files.
1123
+ if (!queuedRecheck && !queuedBridgeResults) {
1124
+ // Build Done text *now* (after quiet decision) so a cancel during the
1125
+ // recheck-decision wait shows Stopped, not a stale ✅ Done head.
891
1126
  let doneText = this.isSelfRecheckTurn
892
1127
  ? this.completionMessageSplit(final.result?.stopReason, startedAt, streamedOutput)
893
- : liveMsg;
894
- if (final.result && !this.cancelled && !hasQueued) {
895
- const sug = await this.collectAndApplySuggestions(doneText, switchKb, {
896
- // Only auto-queue high-need follow-ups when the user is watching;
897
- // background sessions store buttons for the Done ping / switch replay.
898
- autoQueue: this.foreground,
1128
+ : this.completionMessage(final.result?.stopReason, startedAt, streamedOutput);
1129
+ // 1) Always send Done FIRST — never block the completion ping on the
1130
+ // quiet suggestions prompt (which can hang and look like "no Done").
1131
+ let doneMsgId: number | undefined;
1132
+ if (pingDone) {
1133
+ doneMsgId = await this.notify(doneText, {
1134
+ loud: true,
1135
+ replyTo: this.turnReplyTo,
1136
+ replyMarkup: switchKb,
899
1137
  });
900
- doneText = sug.text;
901
- doneMarkup = sug.markup;
1138
+ if (doneMsgId !== undefined) this.turnDonePinged = true;
1139
+ }
1140
+ // 2) Suggestions after Done: edit the Done message (or send a follow-up).
1141
+ if (final.result && !this.cancelled && !hasQueued) {
1142
+ try {
1143
+ const sug = await this.collectAndApplySuggestions(doneText, switchKb, {
1144
+ // Only auto-queue high-need follow-ups when the user is watching;
1145
+ // background sessions store buttons for the Done ping / switch replay.
1146
+ autoQueue: this.foreground,
1147
+ });
1148
+ // Only enhance when suggestions actually changed the Done body.
1149
+ if (pingDone && sug.text !== doneText) {
1150
+ await this.enhanceDoneMessage(doneMsgId, sug.text, sug.markup ?? switchKb);
1151
+ }
1152
+ } catch (e) {
1153
+ log.debug(`suggestions after Done failed: ${(e as Error).message}`);
1154
+ }
902
1155
  }
903
- if (pingDone) await this.notify(doneText, { loud: true, replyTo: this.turnReplyTo, replyMarkup: doneMarkup });
904
1156
  // Clear frozen first-turn ops after final Done (recheck path done).
905
1157
  if (this.isSelfRecheckTurn) this.preRecheckFileOps = new Map();
906
1158
  }
1159
+ // queuedBridgeResults: stay quiet in chat — agent gets results via queue.
907
1160
  } else if (final.error) {
908
1161
  // If the self-recheck pass itself failed, still surface Done for the
909
1162
  // original work (split files + suggestions) so the user is not stuck.
910
1163
  if (this.isSelfRecheckTurn && !hasQueued) {
911
1164
  const switchKb = this.switchKeyboard();
912
1165
  const pingDone = canPing && (this.foreground || !hasQueued);
1166
+ this.turnExpectDone = pingDone;
913
1167
  let doneText =
914
1168
  this.completionMessageSplit(undefined, startedAt, streamedOutput) +
915
1169
  `\n\n\u26A0\uFE0F Self-recheck failed: ${final.error.message}`;
916
- if (!this.cancelled) {
917
- const sug = await this.collectAndApplySuggestions(doneText, switchKb, {
918
- autoQueue: this.foreground,
1170
+ let doneMsgId: number | undefined;
1171
+ if (pingDone) {
1172
+ doneMsgId = await this.notify(doneText, {
1173
+ loud: true,
1174
+ replyTo: this.turnReplyTo,
1175
+ replyMarkup: switchKb,
919
1176
  });
920
- doneText = sug.text;
921
- if (pingDone) {
922
- await this.notify(doneText, {
923
- loud: true,
924
- replyTo: this.turnReplyTo,
925
- replyMarkup: sug.markup,
1177
+ if (doneMsgId !== undefined) this.turnDonePinged = true;
1178
+ }
1179
+ if (!this.cancelled) {
1180
+ try {
1181
+ const sug = await this.collectAndApplySuggestions(doneText, switchKb, {
1182
+ autoQueue: this.foreground,
926
1183
  });
1184
+ if (pingDone && sug.text !== doneText) {
1185
+ await this.enhanceDoneMessage(doneMsgId, sug.text, sug.markup ?? switchKb);
1186
+ }
1187
+ } catch (e) {
1188
+ log.debug(`suggestions after recheck-fail Done failed: ${(e as Error).message}`);
927
1189
  }
928
- } else if (pingDone) {
929
- await this.notify(doneText, { loud: true, replyTo: this.turnReplyTo, replyMarkup: switchKb });
930
1190
  }
931
1191
  this.preRecheckFileOps = new Map();
932
1192
  } else {
933
1193
  const transient = isTransientError(final.error);
934
1194
  const liveMsg = this.errorMessage(final.error, startedAt, final.attempts, transient);
935
- if (canPing) await this.notify(liveMsg, { loud: true, replyTo: this.turnReplyTo, replyMarkup: switchKb });
1195
+ this.turnExpectDone = canPing;
1196
+ if (canPing) {
1197
+ const id = await this.notify(liveMsg, {
1198
+ loud: true,
1199
+ replyTo: this.turnReplyTo,
1200
+ replyMarkup: switchKb,
1201
+ });
1202
+ if (id !== undefined) this.turnDonePinged = true;
1203
+ }
936
1204
  }
937
1205
  }
938
1206
  } catch (err) {
939
1207
  // Unexpected failure outside the prompt path (e.g. while finalizing).
940
1208
  await this.streamer?.finalize().catch(() => {});
941
1209
  const errMsg = (err as Error).message;
942
- this.setSessionComment(
943
- buildLastTurnSummary({
944
- userText: this.turnUserText,
945
- assistantText: this.turnAssistantText,
946
- fileOps: this.fileOps,
947
- error: errMsg,
948
- }),
949
- );
1210
+ this.persistCardUserPrompt();
1211
+ this.cardThinking = "";
950
1212
  this.setLiveStep(undefined);
951
1213
  // If the self-recheck pass itself blew up, still surface Done for the
952
1214
  // original work (split files + suggestions) so the user is not stuck.
953
1215
  if (this.isSelfRecheckTurn && this.queue.length === 0) {
954
1216
  const switchKb = this.switchKeyboard();
955
1217
  const canPing = this.foreground || this.cfg.notifyOtherSessions;
1218
+ this.turnExpectDone = canPing;
956
1219
  let doneText =
957
1220
  this.completionMessageSplit(undefined, startedAt, this.streamer?.hasOutput ?? false) +
958
1221
  `\n\n\u26A0\uFE0F Self-recheck failed: ${errMsg}`;
959
1222
  try {
960
- if (!this.cancelled) {
961
- const sug = await this.collectAndApplySuggestions(doneText, switchKb, {
962
- autoQueue: this.foreground,
963
- });
964
- doneText = sug.text;
965
- if (canPing) {
966
- await this.notify(doneText, {
967
- loud: true,
968
- replyTo: this.turnReplyTo,
969
- replyMarkup: sug.markup,
970
- });
971
- }
972
- } else if (canPing) {
973
- await this.notify(doneText, {
1223
+ let doneMsgId: number | undefined;
1224
+ if (canPing) {
1225
+ doneMsgId = await this.notify(doneText, {
974
1226
  loud: true,
975
1227
  replyTo: this.turnReplyTo,
976
1228
  replyMarkup: switchKb,
977
1229
  });
1230
+ if (doneMsgId !== undefined) this.turnDonePinged = true;
1231
+ }
1232
+ if (!this.cancelled) {
1233
+ try {
1234
+ const sug = await this.collectAndApplySuggestions(doneText, switchKb, {
1235
+ autoQueue: this.foreground,
1236
+ });
1237
+ if (canPing && sug.text !== doneText) {
1238
+ await this.enhanceDoneMessage(doneMsgId, sug.text, sug.markup ?? switchKb);
1239
+ }
1240
+ } catch (e2) {
1241
+ log.debug(`suggestions after recheck catch failed: ${(e2 as Error).message}`);
1242
+ }
978
1243
  }
979
1244
  } catch (e2) {
980
1245
  log.debug(`recheck catch recovery failed: ${(e2 as Error).message}`);
981
- if (canPing) {
982
- await this.notify(doneText, {
1246
+ if (canPing && !this.turnDonePinged) {
1247
+ const id = await this.notify(doneText, {
983
1248
  loud: true,
984
1249
  replyTo: this.turnReplyTo,
985
1250
  replyMarkup: switchKb,
986
- }).catch(() => {});
1251
+ });
1252
+ if (id !== undefined) this.turnDonePinged = true;
987
1253
  }
988
1254
  }
989
1255
  this.preRecheckFileOps = new Map();
990
1256
  } else {
991
1257
  const msg = `\u274C Error after ${fmtDuration(Date.now() - startedAt)}: ${errMsg}`;
992
1258
  this.lastCompletion = msg;
993
- if (this.foreground || this.cfg.notifyOtherSessions) {
1259
+ const canPing = this.foreground || this.cfg.notifyOtherSessions;
1260
+ this.turnExpectDone = canPing;
1261
+ if (canPing) {
994
1262
  const from = this.foreground ? "" : `\u{1F4E8} From other session ${this.sessionTag()}\n`;
995
- await this.notify(`${from}${msg}`, {
1263
+ const id = await this.notify(`${from}${msg}`, {
996
1264
  loud: true,
997
1265
  replyTo: this.turnReplyTo,
998
1266
  replyMarkup: this.switchKeyboard(),
999
1267
  });
1268
+ if (id !== undefined) this.turnDonePinged = true;
1000
1269
  }
1001
1270
  }
1002
1271
  } finally {
1272
+ // Safety net: turn completed with an expected Done ping that never landed
1273
+ // (notify failed, hung path, etc.). Never block queue flush on this.
1274
+ if (this.turnExpectDone && !this.turnDonePinged) {
1275
+ const fallback =
1276
+ this.lastCompletion?.trim() ||
1277
+ `\u2705 Done \u00B7 ${fmtDuration(Date.now() - startedAt)}`;
1278
+ const short =
1279
+ fallback.length > 3500 ? fallback.slice(0, 3499) + "\u2026" : fallback;
1280
+ try {
1281
+ const id = await this.notify(short, {
1282
+ loud: true,
1283
+ replyTo: this.turnReplyTo,
1284
+ replyMarkup: this.switchKeyboard(),
1285
+ });
1286
+ if (id !== undefined) this.turnDonePinged = true;
1287
+ else log.warn(`chat ${this.chatId}: Done safety-net notify failed`);
1288
+ } catch (e) {
1289
+ log.warn(`chat ${this.chatId}: Done safety-net error: ${(e as Error).message}`);
1290
+ }
1291
+ }
1292
+ this.turnExpectDone = false;
1003
1293
  this.typing.stop();
1004
1294
  this.streamer = undefined;
1005
1295
  this.capturingQuiet = false;
@@ -1013,7 +1303,8 @@ export class SessionRuntime {
1013
1303
  // messages. The finished streamed bubble keeps its own (frozen) bar.
1014
1304
  this.progress = undefined;
1015
1305
  this.planEntries = undefined;
1016
- // Prefer stored summary on cards once idle (clear live step if still set).
1306
+ // Idle cards show last user prompt only (clear live step / thinking).
1307
+ this.cardThinking = "";
1017
1308
  if (!this.liveStep || this.sessionComment) this.liveStep = undefined;
1018
1309
  this.changed();
1019
1310
  }
@@ -1029,6 +1320,95 @@ export class SessionRuntime {
1029
1320
  }
1030
1321
  }
1031
1322
 
1323
+ /**
1324
+ * Parse telegram JSON actions from the assistant reply, execute them, notify
1325
+ * the user, and queue a results follow-up for the agent when useful.
1326
+ * Returns true when a results prompt was queued (delay Done/recheck).
1327
+ */
1328
+ private async processTelegramBridgeActions(): Promise<boolean> {
1329
+ const { actions, cleaned } = extractTelegramActions(this.turnAssistantText);
1330
+ if (cleaned !== this.turnAssistantText) {
1331
+ this.turnAssistantText = cleaned;
1332
+ }
1333
+ if (actions.length === 0) return false;
1334
+ if (!this.bridge) {
1335
+ log.warn(`chat ${this.chatId}: telegram actions present but bridge not wired`);
1336
+ return false;
1337
+ }
1338
+
1339
+ const botCmds = actions.filter((a) => a.action === "bot_command");
1340
+ // bot_command means Grok ended its turn early to wait on a sibling bot —
1341
+ // this is NOT a Done. Keep busy; status panel live-step only (no chat spam).
1342
+ if (botCmds.length > 0) {
1343
+ const labels = botCmds
1344
+ .map((a) =>
1345
+ a.action === "bot_command" ? `@${a.bot} /${a.command}` : "",
1346
+ )
1347
+ .filter(Boolean)
1348
+ .join(", ");
1349
+ this.setLiveStep(`Waiting for sibling bot: ${labels}`);
1350
+ } else {
1351
+ this.setLiveStep("Running Telegram bridge actions\u2026");
1352
+ }
1353
+ this.changed();
1354
+ log.info(`chat ${this.chatId}: executing ${actions.length} telegram bridge action(s)`);
1355
+
1356
+ const results = await executeTelegramActions(actions, {
1357
+ api: this.api,
1358
+ cfg: this.cfg,
1359
+ chatId: this.chatId,
1360
+ messageThreadId: this.messageThreadId,
1361
+ forum: this.bridge.forum,
1362
+ store: this.bridge.store,
1363
+ bots: this.bridge.bots,
1364
+ submitTopicPrompt: this.bridge.submitTopicPrompt,
1365
+ });
1366
+
1367
+ // Only announce durable side-effects in chat (topic create/bind/cross-prompt).
1368
+ // search_memory / list_bots / bot wait status stay silent — results go to the agent.
1369
+ const durableActions = new Set(["create_topic", "set_path", "send_prompt"]);
1370
+ const notes = results
1371
+ .filter((r) => durableActions.has(r.action) && r.userNote?.trim())
1372
+ .map((r) => r.userNote!)
1373
+ .filter(Boolean);
1374
+ if (notes.length > 0 && this.foreground) {
1375
+ await this.notify(notes.join("\n"), {
1376
+ loud: true,
1377
+ replyTo: this.turnReplyTo,
1378
+ });
1379
+ }
1380
+
1381
+ // Cap chained result turns so a model that re-emits list_bots forever cannot
1382
+ // block Done. Side-effects already ran; user notes were sent above.
1383
+ // Still queue once when we have bot_command errors so the agent can recover.
1384
+ if (this.bridgeResultDepth >= SessionRuntime.BRIDGE_CHAIN_MAX) {
1385
+ log.warn(
1386
+ `chat ${this.chatId}: telegram bridge chain depth ${this.bridgeResultDepth} — not re-queuing results`,
1387
+ );
1388
+ return false;
1389
+ }
1390
+
1391
+ // Feed results back so the agent can use search hits / bot replies / errors.
1392
+ const prompt = buildTelegramBridgeResultsPrompt(
1393
+ results.map((r) => ({
1394
+ action: r.action,
1395
+ ok: r.ok,
1396
+ data: r.data,
1397
+ error: r.error,
1398
+ })),
1399
+ );
1400
+ this.bridgeResultDepth++;
1401
+ this.queue.unshift(
1402
+ textPrompt(prompt, this.turnReplyTo, undefined, {
1403
+ skipSelfRecheck: true,
1404
+ promptId: this.turnPromptId,
1405
+ }),
1406
+ );
1407
+ this.setLiveStep("Feeding sibling-bot / bridge results to the agent\u2026");
1408
+ this.changed();
1409
+ return true;
1410
+ }
1411
+
1032
1412
  /**
1033
1413
  * Quietly ask for 1–3 follow-ups, attach buttons to the Done text, store them
1034
1414
  * for switch-replay, and optionally queue auto-approved items as **one**
@@ -1073,7 +1453,12 @@ export class SessionRuntime {
1073
1453
  banner = `\u{1F4A1} Suggestions (auto-running ${auto.length} as one prompt):\n${lines.join("\n")}`;
1074
1454
  // Single queue entry — agent executes 1) 2) 3) in one turn.
1075
1455
  // skipSelfRecheck: auto-follow-ups must not arm another recheck cycle.
1076
- this.queue.push(textPrompt(batched, this.turnReplyTo, undefined, { skipSelfRecheck: true }));
1456
+ this.queue.push(
1457
+ textPrompt(batched, this.turnReplyTo, undefined, {
1458
+ skipSelfRecheck: true,
1459
+ promptId: this.turnPromptId,
1460
+ }),
1461
+ );
1077
1462
  this.changed();
1078
1463
  } else {
1079
1464
  text += "\n\n\u{1F4A1} Suggestions \u2014 tap one to continue:";
@@ -1142,14 +1527,12 @@ export class SessionRuntime {
1142
1527
  ): Promise<ReturnType<typeof parseSelfRecheckDecision>> {
1143
1528
  if (!this.sessionId) return { needed: false, reason: "no session" };
1144
1529
  const prompt = buildSelfRecheckDecisionPrompt(user, did, filesSummary);
1145
- this.capturingQuiet = true;
1146
- this.quietCaptureBuf = "";
1147
1530
  try {
1148
- await this.acp.prompt(this.sessionId, [{ type: "text", text: prompt }]);
1149
- return parseSelfRecheckDecision(this.quietCaptureBuf);
1150
- } finally {
1151
- this.capturingQuiet = false;
1152
- this.quietCaptureBuf = "";
1531
+ const raw = await this.runQuietPrompt(prompt);
1532
+ return parseSelfRecheckDecision(raw);
1533
+ } catch (e) {
1534
+ log.debug(`self-recheck decision quiet prompt failed: ${(e as Error).message}`);
1535
+ return { needed: false, reason: "decision prompt failed" };
1153
1536
  }
1154
1537
  }
1155
1538
 
@@ -1164,15 +1547,60 @@ export class SessionRuntime {
1164
1547
  const didParts = [this.preRecheckAssistantText, this.turnAssistantText].filter((s) => s?.trim());
1165
1548
  const did = didParts.join("\n") || this.turnAssistantText;
1166
1549
  const prompt = buildSuggestionsPrompt(user, did);
1550
+ try {
1551
+ const raw = await this.runQuietPrompt(prompt);
1552
+ return parseSuggestions(raw);
1553
+ } catch (e) {
1554
+ log.debug(`suggestions quiet prompt failed: ${(e as Error).message}`);
1555
+ return [];
1556
+ }
1557
+ }
1558
+
1559
+ /**
1560
+ * Run a quiet meta ACP prompt (JSON only) with a hard timeout.
1561
+ * On timeout: session/cancel so the shared agent is not stuck holding the
1562
+ * session (which would block Done forever). Does NOT set this.cancelled
1563
+ * (user /stop is separate). Timed-out / partial capture is discarded.
1564
+ */
1565
+ private async runQuietPrompt(text: string): Promise<string> {
1566
+ if (!this.sessionId) return "";
1567
+ const ms = Math.max(5_000, this.cfg.quietPromptTimeoutMs || 90_000);
1167
1568
  this.capturingQuiet = true;
1168
1569
  this.quietCaptureBuf = "";
1570
+ let timedOut = false;
1571
+ let settled = false;
1572
+ const timer = setTimeout(() => {
1573
+ // Ignore timer if the prompt already settled (avoids discarding a good JSON
1574
+ // reply that finished in the same tick as the timeout).
1575
+ if (settled) return;
1576
+ timedOut = true;
1577
+ log.warn(
1578
+ `chat ${this.chatId}: quiet meta prompt timed out after ${ms}ms — cancelling session prompt`,
1579
+ );
1580
+ // Session-scoped cancel only — never kill the shared agent process.
1581
+ void this.acp.cancel(this.sessionId!);
1582
+ }, ms);
1583
+ let buf = "";
1169
1584
  try {
1170
- await this.acp.prompt(this.sessionId, [{ type: "text", text: prompt }]);
1171
- return parseSuggestions(this.quietCaptureBuf);
1585
+ await this.acp.prompt(this.sessionId, [{ type: "text", text }]);
1586
+ settled = true;
1587
+ clearTimeout(timer);
1588
+ buf = this.quietCaptureBuf;
1589
+ } catch (e) {
1590
+ settled = true;
1591
+ clearTimeout(timer);
1592
+ buf = this.quietCaptureBuf;
1593
+ if (!timedOut) throw e;
1594
+ log.debug(`quiet prompt ended after timeout: ${(e as Error).message}`);
1172
1595
  } finally {
1596
+ clearTimeout(timer);
1173
1597
  this.capturingQuiet = false;
1174
1598
  this.quietCaptureBuf = "";
1175
1599
  }
1600
+ // Timed-out meta replies are often half-JSON — skip rather than act on garbage.
1601
+ // If we settled successfully before the timer fired, timedOut stays false.
1602
+ if (timedOut) return "";
1603
+ return buf;
1176
1604
  }
1177
1605
 
1178
1606
  /** Resolve a tapped suggestion button; returns the prompt text or undefined. */
@@ -1479,6 +1907,11 @@ export class SessionRuntime {
1479
1907
  try {
1480
1908
  const updatesBeforePrompt = this.sessionUpdateCount;
1481
1909
  const result = await this.acp.prompt(this.sessionId!, content);
1910
+ // User /stop force-complete or agent honouring session/cancel often
1911
+ // returns cancelled with zero session/update chunks — that is success.
1912
+ if (this.cancelled || result?.stopReason === "cancelled") {
1913
+ return { result: result ?? { stopReason: "cancelled" }, attempts: attempt };
1914
+ }
1482
1915
  // A healthy ACP turn emits at least one session/update (text, thought,
1483
1916
  // or tool event) before resolving session/prompt. Grok can otherwise
1484
1917
  // report a successful end-turn after an upstream model failure; never
@@ -1604,6 +2037,7 @@ export class SessionRuntime {
1604
2037
  already: this.sentImagesThisTurn,
1605
2038
  max: this.cfg.agentImagesMax,
1606
2039
  replyTo: this.turnReplyTo,
2040
+ messageThreadId: this.messageThreadId,
1607
2041
  });
1608
2042
  if (n > 0) log.info(`chat ${this.chatId}: sent ${n} agent image file(s)`);
1609
2043
  } catch {
@@ -1693,12 +2127,13 @@ export class SessionRuntime {
1693
2127
  }
1694
2128
 
1695
2129
  /** Searchable Telegram hashtags so you can pull up every message of a session
1696
- * or project by tapping the tag. */
2130
+ * or project (and this turn's prompt) by tapping the tag. */
1697
2131
  private hashtags(): string {
1698
2132
  return sessionHashtags({
1699
2133
  projectName: this.projectName,
1700
2134
  cwd: this.cwd,
1701
2135
  sessionId: this.sessionId,
2136
+ promptId: this.turnPromptId,
1702
2137
  });
1703
2138
  }
1704
2139
 
@@ -1710,11 +2145,17 @@ export class SessionRuntime {
1710
2145
  const head = this.queue[0]!;
1711
2146
  const isMeta =
1712
2147
  !!head.skipSelfRecheck ||
1713
- isSelfRecheckPrompt(head.text);
2148
+ isSelfRecheckPrompt(head.text) ||
2149
+ isTelegramBridgeResultsPrompt(head.text);
1714
2150
  const batch = isMeta
1715
2151
  ? this.queue.shift()!
1716
2152
  : mergeInputs(this.queue.splice(0, this.queue.length));
1717
- if (this.foreground) await this.notify("\u25B6\uFE0F Processing queued message\u2026");
2153
+ // Real user follow-ups only never spam chat for bridge/recheck meta turns.
2154
+ if (this.foreground && !isMeta) {
2155
+ await this.notify("\u25B6\uFE0F Processing queued message\u2026", {
2156
+ replyTo: batch.replyTo,
2157
+ });
2158
+ }
1718
2159
  void this.runTurn(batch);
1719
2160
  }
1720
2161
 
@@ -1761,7 +2202,10 @@ export class SessionRuntime {
1761
2202
  }
1762
2203
  } else if (kind === "agent_thought_chunk") {
1763
2204
  const text = contentText(update.content);
1764
- if (text?.trim()) this.setLiveStep(stepFromThought(text));
2205
+ if (text?.trim()) {
2206
+ this.appendCardThinking(text);
2207
+ this.setLiveStep(stepFromThought(text));
2208
+ }
1765
2209
  } else if (kind === "plan") {
1766
2210
  // Always track plan entries (background too) so switch-to-live restores the board.
1767
2211
  const entries = parsePlanUpdate(update);
@@ -1836,7 +2280,7 @@ export class SessionRuntime {
1836
2280
 
1837
2281
  private persist(): void {
1838
2282
  if (!this.foreground) return; // only the foreground session is the chat's restored default
1839
- this.settings.update(this.chatId, {
2283
+ this.settings.updateKey(this.settingsKey, {
1840
2284
  projectPath: this.cwd,
1841
2285
  projectName: this.projectName,
1842
2286
  sessionId: this.sessionId,
@@ -1859,20 +2303,61 @@ export class SessionRuntime {
1859
2303
  }
1860
2304
  }
1861
2305
 
2306
+ /**
2307
+ * Send a chat message. Returns Telegram message_id on success.
2308
+ * On failure, retries once truncated (~3500) without reply_markup so a long
2309
+ * Done / markup error cannot silently drop the completion ping.
2310
+ */
1862
2311
  private async notify(
1863
2312
  text: string,
1864
2313
  opts?: { loud?: boolean; replyTo?: number; replyMarkup?: InlineKeyboard },
2314
+ ): Promise<number | undefined> {
2315
+ const send = async (body: string, withMarkup: boolean): Promise<number | undefined> => {
2316
+ try {
2317
+ const extra: Record<string, unknown> = opts?.loud ? { disable_notification: false } : {};
2318
+ if (this.messageThreadId !== undefined) extra.message_thread_id = this.messageThreadId;
2319
+ if (opts?.replyTo !== undefined) {
2320
+ extra.reply_parameters = { message_id: opts.replyTo, allow_sending_without_reply: true };
2321
+ }
2322
+ if (withMarkup && opts?.replyMarkup) extra.reply_markup = opts.replyMarkup;
2323
+ const msg = await this.api.sendMessage(this.chatId, body, extra);
2324
+ return msg.message_id;
2325
+ } catch (e) {
2326
+ log.debug("notify failed:", (e as Error).message);
2327
+ return undefined;
2328
+ }
2329
+ };
2330
+ const id = await send(text, true);
2331
+ if (id !== undefined) return id;
2332
+ const short = text.length > 3500 ? text.slice(0, 3499) + "\u2026" : text;
2333
+ return send(short, false);
2334
+ }
2335
+
2336
+ /**
2337
+ * After Done was already sent, attach suggestion text + buttons by editing
2338
+ * that message (or sending a follow-up if edit fails / no message id).
2339
+ */
2340
+ private async enhanceDoneMessage(
2341
+ messageId: number | undefined,
2342
+ text: string,
2343
+ markup: InlineKeyboard | undefined,
1865
2344
  ): Promise<void> {
1866
- try {
1867
- const extra: Record<string, unknown> = opts?.loud ? { disable_notification: false } : {};
1868
- if (opts?.replyTo !== undefined) {
1869
- extra.reply_parameters = { message_id: opts.replyTo, allow_sending_without_reply: true };
2345
+ if (messageId !== undefined) {
2346
+ try {
2347
+ const extra: Record<string, unknown> = {};
2348
+ if (this.messageThreadId !== undefined) extra.message_thread_id = this.messageThreadId;
2349
+ if (markup) extra.reply_markup = markup;
2350
+ await this.api.editMessageText(this.chatId, messageId, text, extra);
2351
+ return;
2352
+ } catch (e) {
2353
+ log.debug("enhanceDoneMessage edit failed:", (e as Error).message);
1870
2354
  }
1871
- if (opts?.replyMarkup) extra.reply_markup = opts.replyMarkup;
1872
- await this.api.sendMessage(this.chatId, text, extra);
1873
- } catch (e) {
1874
- log.debug("notify failed:", (e as Error).message);
1875
2355
  }
2356
+ await this.notify(text, {
2357
+ loud: false,
2358
+ replyTo: this.turnReplyTo,
2359
+ replyMarkup: markup,
2360
+ });
1876
2361
  }
1877
2362
 
1878
2363
  private async onWatchEntries(entries: HistoryEntry[]): Promise<void> {
@@ -1885,7 +2370,11 @@ export class SessionRuntime {
1885
2370
  })
1886
2371
  .filter(Boolean)
1887
2372
  .join("\n\n");
1888
- if (body.trim()) await sendMarkdownDoc(this.api, this.chatId, `${body}\n\n${this.tags}`);
2373
+ if (body.trim()) {
2374
+ await sendMarkdownDoc(this.api, this.chatId, `${body}\n\n${this.tags}`, {
2375
+ messageThreadId: this.messageThreadId,
2376
+ });
2377
+ }
1889
2378
  }
1890
2379
  }
1891
2380