grok-telegram-bot 2.4.0 → 2.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (81) hide show
  1. package/.env.example +38 -2
  2. package/CHANGELOG.md +190 -1
  3. package/README.md +60 -15
  4. package/docs/GROUP.md +260 -0
  5. package/docs/INSTALL.md +3 -0
  6. package/package.json +4 -4
  7. package/src/app/lifetime-flag.ts +20 -0
  8. package/src/app/settings-store.ts +47 -8
  9. package/src/app/types.ts +38 -1
  10. package/src/app/updater.ts +24 -3
  11. package/src/bot/auth.ts +100 -15
  12. package/src/bot/bot.ts +193 -17
  13. package/src/bot/chat-controller.ts +181 -18
  14. package/src/bot/commands.ts +69 -29
  15. package/src/bot/deps.ts +3 -0
  16. package/src/bot/group-memory.ts +339 -0
  17. package/src/bot/handlers/accounts.ts +7 -0
  18. package/src/bot/handlers/control.ts +85 -32
  19. package/src/bot/handlers/document.ts +31 -4
  20. package/src/bot/handlers/forum.ts +217 -0
  21. package/src/bot/handlers/menu.ts +86 -24
  22. package/src/bot/handlers/message.ts +247 -27
  23. package/src/bot/handlers/photo.ts +126 -16
  24. package/src/bot/handlers/running.ts +150 -24
  25. package/src/bot/handlers/session-card.ts +13 -5
  26. package/src/bot/handlers/sessions.ts +68 -18
  27. package/src/bot/handlers/voice.ts +52 -7
  28. package/src/bot/image-return.ts +11 -5
  29. package/src/bot/manager-context.ts +208 -0
  30. package/src/bot/manager-jobs.ts +142 -0
  31. package/src/bot/menu/ephemeral.ts +16 -3
  32. package/src/bot/menu/keyboard.ts +53 -14
  33. package/src/bot/menu/refresh.ts +3 -1
  34. package/src/bot/menu/status-panel.ts +12 -6
  35. package/src/bot/permission-service.ts +19 -0
  36. package/src/bot/prompt-anchor.ts +299 -0
  37. package/src/bot/prompt-content.ts +8 -0
  38. package/src/bot/registry.ts +94 -1
  39. package/src/bot/scope.ts +95 -0
  40. package/src/bot/session-runtime.ts +1280 -183
  41. package/src/bot/suggestions.ts +91 -31
  42. package/src/bot/telegram-actions.ts +1130 -0
  43. package/src/bot/telegram-bots.ts +496 -0
  44. package/src/bot/telegram-io.ts +97 -10
  45. package/src/cli.ts +2 -0
  46. package/src/config.ts +201 -2
  47. package/src/forum/bind-path.ts +146 -0
  48. package/src/forum/manager.ts +652 -0
  49. package/src/forum/project-icon.ts +142 -0
  50. package/src/forum/thread.ts +49 -0
  51. package/src/forum/topic-store.ts +114 -0
  52. package/src/forum/types.ts +29 -0
  53. package/src/grok/client.ts +130 -28
  54. package/src/index.ts +205 -75
  55. package/src/projects/manager.ts +16 -3
  56. package/src/render/chunk.ts +17 -10
  57. package/src/render/hashtags.ts +5 -1
  58. package/src/render/manager-directive.ts +137 -0
  59. package/src/render/session-comment.ts +74 -7
  60. package/src/render/telegram-bridge.ts +464 -0
  61. package/src/render/tool-call.ts +56 -37
  62. package/src/service/platform.ts +44 -7
  63. package/src/service/windows.ts +16 -4
  64. package/src/sessions/history.ts +68 -9
  65. package/src/sessions/process.ts +7 -0
  66. package/src/sessions/types.ts +2 -2
  67. package/src/stream/streamer.ts +62 -15
  68. package/scripts/analyze-jsonl.ts +0 -33
  69. package/scripts/delayed-restart.ps1 +0 -29
  70. package/scripts/probe-exit-response-shape.py +0 -77
  71. package/scripts/probe-plan-exit.py +0 -60
  72. package/scripts/probe-plan-exit2.py +0 -48
  73. package/scripts/probe-plan-fields.py +0 -41
  74. package/scripts/probe-plan-fields2.py +0 -58
  75. package/scripts/probe-plan-response-path.py +0 -48
  76. package/scripts/sample-claude-tooluse.ts +0 -21
  77. package/scripts/sample-kiro-events.ts +0 -31
  78. package/scripts/smoke-exit-plan.ts +0 -274
  79. package/scripts/smoke-exit-shapes.ts +0 -252
  80. package/scripts/smoke-import.mjs +0 -82
  81. package/scripts/smoke-import.ts +0 -73
@@ -23,7 +23,7 @@ import { type PromptInput, type ReasoningEffort, textPrompt } from "../app/types
23
23
  import { createLogger } from "../logger.js";
24
24
  import { buildTranscript, readHistory } from "../sessions/history.js";
25
25
  import { sessionHashtags } from "../render/hashtags.js";
26
- import { PROGRESS_DIRECTIVE } from "../render/progress.js";
26
+ import { extractProgress, PROGRESS_DIRECTIVE } from "../render/progress.js";
27
27
  import { buildPriming, recentTranscript } from "./session-fork.js";
28
28
  import { TailWatcher } from "../sessions/tail.js";
29
29
  import type { HistoryEntry } from "../sessions/types.js";
@@ -62,6 +62,33 @@ import {
62
62
  type Suggestion,
63
63
  suggestionsKeyboard,
64
64
  } from "./suggestions.js";
65
+ import type { ForumManager } from "../forum/manager.js";
66
+ import { isGeneralThread, outboundThreadExtra } from "../forum/thread.js";
67
+ import type { SessionStore } from "../sessions/store.js";
68
+ import type { TelegramBotService } from "./telegram-bots.js";
69
+ import { executeTelegramActions } from "./telegram-actions.js";
70
+ import {
71
+ buildManagerContextBlock,
72
+ injectManagerContext,
73
+ } from "./manager-context.js";
74
+ import {
75
+ bindJobSession,
76
+ updateManagerJob,
77
+ type ReportBackMeta,
78
+ } from "./manager-jobs.js";
79
+ import {
80
+ buildManagerWorkReportPrompt,
81
+ isManagerWorkReportPrompt,
82
+ wrapManagerDirective,
83
+ } from "../render/manager-directive.js";
84
+ import {
85
+ buildTelegramBridgeDirective,
86
+ buildTelegramBridgeResultsPrompt,
87
+ extractTelegramActions,
88
+ isTelegramBridgeResultsPrompt,
89
+ stripTelegramActionFences,
90
+ wrapTelegramBridgePrompt,
91
+ } from "../render/telegram-bridge.js";
65
92
  import {
66
93
  parsePlanUpdate,
67
94
  renderPlanMarkdown,
@@ -69,9 +96,11 @@ import {
69
96
  type PlanEntry,
70
97
  } from "../render/plan.js";
71
98
  import {
72
- buildLastTurnSummary,
99
+ buildSessionCardComment,
100
+ clampThinking,
73
101
  cleanCommentLine,
74
102
  cleanUserPreview,
103
+ COMMENT_MAX,
75
104
  stepFromThought,
76
105
  stepFromToolUpdate,
77
106
  stripDirectiveWrappers,
@@ -144,6 +173,8 @@ export class SessionRuntime {
144
173
  private turnCount = 0;
145
174
  /** Telegram message id of the current turn's prompt, so replies thread to it. */
146
175
  private turnReplyTo: number | undefined;
176
+ /** Short id for `#prompt_<id>` on all AI messages of this turn. */
177
+ private turnPromptId: string | undefined;
147
178
  private imageScanText = "";
148
179
  private sentImagesThisTurn = new Set<string>();
149
180
  /** Monotonic count used to reject ACP "success" responses with no turn updates. */
@@ -172,19 +203,77 @@ export class SessionRuntime {
172
203
  accountRotator: AccountRotator | undefined;
173
204
  /** Session ids that already received the first-prompt auto-complexity directive. */
174
205
  private complexitySteered = new Set<string>();
206
+ /** Session ids that already received the first-prompt Telegram bridge directive. */
207
+ private telegramBridgeSteered = new Set<string>();
208
+ /**
209
+ * How many TELEGRAM BRIDGE RESULTS follow-ups are chained after the current
210
+ * user turn. Caps infinite list_bots/bot_command loops; reset on real user work.
211
+ */
212
+ private bridgeResultDepth = 0;
213
+ /** Max sequential bridge result turns per user request. */
214
+ private static readonly BRIDGE_CHAIN_MAX = 4;
215
+ /**
216
+ * Optional Telegram bridge services (forum / session store / sibling bots).
217
+ * Injected by the registry after construct.
218
+ */
219
+ bridge?: {
220
+ store: SessionStore;
221
+ forum?: ForumManager;
222
+ bots: TelegramBotService;
223
+ /** Cross-topic prompt dispatch (create_topic → send_prompt orchestration). */
224
+ submitTopicPrompt?: import("./telegram-actions.js").SubmitTopicPromptFn;
225
+ /** Wake General manager with a work-report prompt. */
226
+ wakeManager?: (opts: {
227
+ originChatId: number;
228
+ originThreadId: number;
229
+ prompt: string;
230
+ }) => Promise<void>;
231
+ };
232
+ /**
233
+ * Report-back for the *current* turn chain (dispatch + recheck/suggestions).
234
+ * Set from PromptInput.reportBack at turn start; kept until queue drains.
235
+ */
236
+ private pendingReportBack: ReportBackMeta | undefined;
237
+ /**
238
+ * Staged by {@link setReportBack} and attached to the next {@link submit}
239
+ * so concurrent dispatches carry their own job through the queue.
240
+ */
241
+ private stagedReportBack: ReportBackMeta | undefined;
175
242
  /** Last credits total reported for this session (for per-turn delta accounting). */
176
243
  private lastReportedCredits = 0;
177
- /** Live "what is happening now" line while a turn is in flight. */
244
+ /** Live "what is happening now" line while a turn is in flight (tools/plan). */
178
245
  private liveStep: string | undefined;
179
- /** Idle card comment (AI/local summary of the chat after the last turn). */
246
+ /**
247
+ * Card comment on disk / idle: last user prompt (≤ COMMENT_MAX).
248
+ * While busy, {@link cardComment} also appends last agent thinking.
249
+ */
180
250
  private sessionComment: string | undefined;
251
+ /** Cleaned last user prompt for cards (not overwritten by self-recheck meta). */
252
+ private cardUserPrompt: string | undefined;
253
+ /** Accumulated agent_thought_chunk text for the current turn (card display). */
254
+ private cardThinking = "";
181
255
  /** User text of the turn currently running (for local card-comment fallback). */
182
256
  private turnUserText = "";
183
- /** Assistant prose streamed this turn — used to build the idle card summary. */
257
+ /** Assistant prose streamed this turn — used for suggestions / completion. */
184
258
  private turnAssistantText = "";
185
259
  /** Quiet meta capture (suggestions) — never stream to Telegram. */
186
260
  private capturingQuiet = false;
187
261
  private quietCaptureBuf = "";
262
+ /**
263
+ * General manager: Thinking… / Starting… bubble for this turn (deleted when
264
+ * silent, or replaced by a single fallback reply).
265
+ */
266
+ private managerStatusMsgId: number | undefined;
267
+ /** Count of successful notify actions this turn (user-facing messages). */
268
+ private managerNotifyCount = 0;
269
+ /** True when this turn already delivered at least one user-visible message. */
270
+ private managerUserVisible = false;
271
+ /**
272
+ * Done delivery bookkeeping for this turn: expect a loud Done ping, and whether
273
+ * one was successfully sent (finally forces a short Done if expected but missing).
274
+ */
275
+ private turnExpectDone = false;
276
+ private turnDonePinged = false;
188
277
  /** Batches of post-turn suggestions for inline-button callbacks. */
189
278
  private suggestionBatches = new Map<number, Suggestion[]>();
190
279
  private suggestionBatchSeq = 0;
@@ -214,20 +303,48 @@ export class SessionRuntime {
214
303
  */
215
304
  private skipSelfRecheck = false;
216
305
 
306
+ /**
307
+ * Forum topic thread id (message_thread_id). When set, all outbound messages
308
+ * for this runtime are posted into that topic.
309
+ */
310
+ readonly messageThreadId: number | undefined;
311
+ /** Settings storage key (`chatId` or `chatId:t{threadId}`). */
312
+ readonly settingsKey: string;
313
+ /**
314
+ * General topic only: OpenClaw-style manager chat (orchestrate, no coding UX).
315
+ */
316
+ readonly managerMode: boolean;
317
+ /**
318
+ * Optional: register Telegram message id → session for General reply-routing
319
+ * (user message, Done, stream bubbles). Wired by ChatController.
320
+ */
321
+ onTelegramMessageBound: ((messageId: number, sessionId: string) => void) | undefined;
322
+
217
323
  constructor(
218
324
  private readonly api: Api,
219
325
  private readonly chatId: number,
220
326
  private readonly acp: GrokClient,
221
327
  private readonly cfg: AppConfig,
222
328
  private readonly settings: SettingsStore,
223
- init?: { cwd: string; projectName?: string; sessionId?: string },
329
+ init?: {
330
+ cwd: string;
331
+ projectName?: string;
332
+ sessionId?: string;
333
+ messageThreadId?: number;
334
+ settingsKey?: string;
335
+ },
224
336
  ) {
337
+ this.messageThreadId = init?.messageThreadId;
338
+ this.settingsKey = init?.settingsKey ?? String(chatId);
339
+ // Only the forum General topic is the manager — not AI Chat / private DMs.
340
+ this.managerMode =
341
+ this.messageThreadId !== undefined && isGeneralThread(this.messageThreadId);
225
342
  if (init) {
226
343
  this.cwd = init.cwd;
227
344
  this.projectName = init.projectName;
228
345
  this.sessionId = init.sessionId;
229
346
  } else {
230
- const s = settings.get(chatId);
347
+ const s = settings.getKey(this.settingsKey);
231
348
  this.cwd = s.projectPath ?? cfg.workspace;
232
349
  this.projectName = s.projectName;
233
350
  this.sessionId = s.sessionId;
@@ -264,9 +381,27 @@ export class SessionRuntime {
264
381
 
265
382
  /** Latest task-completion % (0–100) parsed this turn, or undefined if none. */
266
383
  get taskProgress(): number | undefined {
384
+ // Manager chat never shows a progress bar.
385
+ if (this.managerMode) return undefined;
267
386
  return this.progress;
268
387
  }
269
388
 
389
+ /**
390
+ * Stage report-back for the next {@link submit} (General → project dispatch).
391
+ * Attached to that prompt so a second dispatch cannot steal the first job's
392
+ * completion report when the child session is busy/queued.
393
+ */
394
+ setReportBack(meta: ReportBackMeta): void {
395
+ if (this.stagedReportBack && this.stagedReportBack.jobId !== meta.jobId) {
396
+ updateManagerJob(this.stagedReportBack.jobId, {
397
+ status: "cancelled",
398
+ resultSummary: "superseded by a newer manager dispatch before start",
399
+ });
400
+ }
401
+ this.stagedReportBack = meta;
402
+ if (this.sessionId) bindJobSession(meta.jobId, this.sessionId);
403
+ }
404
+
270
405
  /**
271
406
  * Full plan board for the live stream / status panel (above the progress bar).
272
407
  * Empty when no plan is active this turn.
@@ -299,14 +434,23 @@ export class SessionRuntime {
299
434
  }
300
435
 
301
436
  /**
302
- * One-line status for Running/Sessions cards:
303
- * live step while busy, otherwise the last chat summary / comment.
437
+ * Session card comment:
438
+ * always — last user prompt (≤250)
439
+ * busy — plus last AI agent thinking on the next line (≤250)
304
440
  */
305
441
  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;
442
+ const user =
443
+ this.cardUserPrompt ||
444
+ this.sessionComment ||
445
+ (this.sessionId ? this.acp.sessionComment(this.sessionId) : undefined) ||
446
+ cleanUserPreview(this.suggestionUserText || this.turnUserText || "", COMMENT_MAX) ||
447
+ undefined;
448
+ const built = buildSessionCardComment({
449
+ userPrompt: user,
450
+ thinking: this.busy && this.cardThinking ? this.cardThinking : undefined,
451
+ busy: this.busy,
452
+ });
453
+ return built || undefined;
310
454
  }
311
455
 
312
456
  /** Record a new progress value and refresh the status panel / cards. The bar
@@ -319,7 +463,7 @@ export class SessionRuntime {
319
463
  this.changed();
320
464
  }
321
465
 
322
- /** Update the live step shown on session cards (throttled by equality). */
466
+ /** Update the live step (tools/plan) — kept for diagnostics; cards use user+thinking. */
323
467
  private setLiveStep(step: string | undefined): void {
324
468
  const next = step?.trim() ? cleanCommentLine(step) : undefined;
325
469
  if (next === this.liveStep) return;
@@ -327,9 +471,21 @@ export class SessionRuntime {
327
471
  this.changed();
328
472
  }
329
473
 
330
- /** Persist idle card comment (disk + memory) so /running and /sessions see it. */
474
+ /** Append thought text and refresh cards when the display line changes. */
475
+ private appendCardThinking(chunk: string): void {
476
+ const piece = chunk.replace(/\s+/g, " ").trim();
477
+ if (!piece) return;
478
+ const prevShown = this.cardThinking ? clampThinking(this.cardThinking, COMMENT_MAX) : "";
479
+ this.cardThinking = this.cardThinking ? `${this.cardThinking} ${piece}` : piece;
480
+ const nextShown = clampThinking(this.cardThinking, COMMENT_MAX);
481
+ if (nextShown !== prevShown) this.changed();
482
+ }
483
+
484
+ /** Persist last user prompt (disk + memory) so /running and /sessions see it. */
331
485
  private setSessionComment(comment: string | undefined): void {
332
- const next = comment?.trim() ? cleanCommentLine(comment) : undefined;
486
+ const next = comment?.trim()
487
+ ? cleanUserPreview(comment, COMMENT_MAX) || cleanCommentLine(comment, COMMENT_MAX)
488
+ : undefined;
333
489
  if (next === this.sessionComment) return;
334
490
  this.sessionComment = next;
335
491
  if (next && this.sessionId) {
@@ -342,11 +498,25 @@ export class SessionRuntime {
342
498
  this.changed();
343
499
  }
344
500
 
345
- /** Hydrate comment from disk after bind/resume. */
501
+ /** Keep disk/memory comment = last real user prompt after a turn ends. */
502
+ private persistCardUserPrompt(): void {
503
+ const prompt =
504
+ this.cardUserPrompt ||
505
+ cleanUserPreview(this.suggestionUserText || this.turnUserText || "", COMMENT_MAX);
506
+ if (prompt) {
507
+ this.cardUserPrompt = prompt;
508
+ this.setSessionComment(prompt);
509
+ }
510
+ }
511
+
512
+ /** Hydrate last user prompt from disk after bind/resume. */
346
513
  private loadPersistedComment(): void {
347
514
  if (!this.sessionId) return;
348
515
  const c = this.acp.sessionComment(this.sessionId);
349
- if (c) this.sessionComment = c;
516
+ if (c) {
517
+ this.sessionComment = c;
518
+ if (!this.cardUserPrompt) this.cardUserPrompt = cleanUserPreview(c, COMMENT_MAX) || c;
519
+ }
350
520
  }
351
521
 
352
522
  /** Searchable hashtag footer for this session (project В· session В· model В·
@@ -364,12 +534,23 @@ export class SessionRuntime {
364
534
  if (value) {
365
535
  // A turn was started here and is still in flight, but its streamer was
366
536
  // finalized when we went background. Recreate it and let onUpdate feed
367
- // the remaining chunks/thoughts/tools just like a normal live turn — we
368
- // own the agent's session/update events, so no tail-watch is needed.
537
+ // the remaining chunks/thoughts/tools just like a normal live turn.
538
+ // Recreate the streamer for the live turn (manager: prose-only).
369
539
  if (this.busy && !this.streamer) {
370
540
  // Any transient follow-watch of this session is now superseded.
371
541
  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);
542
+ this.streamer = new ResponseStreamer(
543
+ this.api,
544
+ this.chatId,
545
+ this.cfg.streamThrottleMs,
546
+ this.turnReplyTo,
547
+ this.hashtags(),
548
+ this.managerMode ? undefined : (pct) => this.setProgress(pct),
549
+ this.managerMode ? false : this.cfg.progressFallback,
550
+ this.turnStartedAt,
551
+ this.messageThreadId,
552
+ this.managerMode ? { proseOnly: true, showProgressBar: false } : undefined,
553
+ );
373
554
  // Restore the live plan board so steps stay visible above the progress bar.
374
555
  if (this.planEntries?.length) {
375
556
  this.streamer.setPlan(renderPlanMarkdown(this.planEntries));
@@ -379,6 +560,7 @@ export class SessionRuntime {
379
560
  } else {
380
561
  this.typing.stop();
381
562
  this.stopWatch();
563
+ // Seal live bubble when demoted (manager has no streamer).
382
564
  if (this.streamer) {
383
565
  // Finalize off the critical path so project/session switches never wait
384
566
  // on Telegram edits of the previous live stream.
@@ -390,13 +572,16 @@ export class SessionRuntime {
390
572
  this.changed();
391
573
  }
392
574
  get reasoning(): ReasoningEffort {
393
- return this.settings.get(this.chatId).reasoning;
575
+ return this.settings.getKey(this.settingsKey).reasoning;
394
576
  }
395
577
  get agent(): string | undefined {
396
- return this.settings.get(this.chatId).agent;
578
+ return this.settings.getKey(this.settingsKey).agent;
397
579
  }
398
580
  get model(): string | undefined {
399
- return this.settings.get(this.chatId).model;
581
+ return this.settings.getKey(this.settingsKey).model;
582
+ }
583
+ get preferredAccountId(): string | undefined {
584
+ return this.settings.getKey(this.settingsKey).preferredAccountId;
400
585
  }
401
586
 
402
587
  /** Latest context-usage % / effort / credits for the current session. */
@@ -440,6 +625,8 @@ export class SessionRuntime {
440
625
  this.lastReportedCredits = 0;
441
626
  this.liveStep = undefined;
442
627
  this.sessionComment = undefined;
628
+ this.cardUserPrompt = undefined;
629
+ this.cardThinking = "";
443
630
  await this.applySessionPrefs();
444
631
  this.persist();
445
632
  this.sessionChanged();
@@ -495,8 +682,8 @@ export class SessionRuntime {
495
682
  async startImportedSession(cwd: string, projectName: string | undefined, priming: string): Promise<void> {
496
683
  await this.startNewSession(cwd, projectName);
497
684
  if (priming.trim()) this.primingContext = priming;
498
- // Imported transcripts already have context — skip first-prompt complexity steering.
499
- this.markComplexitySteered();
685
+ // Imported transcripts already have context — skip first-prompt directives.
686
+ this.markFirstPromptSteered();
500
687
  }
501
688
 
502
689
  startWatch(jsonlPath: string, follow = false): void {
@@ -519,7 +706,7 @@ export class SessionRuntime {
519
706
  async setModelPref(modelId: string): Promise<{ ok: boolean; error?: string }> {
520
707
  // Persist the choice always; only talk to Grok when a session is live in
521
708
  // the current process (set_model on an unloaded session crashes the agent).
522
- this.settings.update(this.chatId, { model: modelId });
709
+ this.settings.updateKey(this.settingsKey, { model: modelId });
523
710
  if (modelId && this.sessionLive && this.sessionId) {
524
711
  if (!this.acp.hasModel(modelId)) return { ok: false, error: `unknown model: ${modelId}` };
525
712
  try {
@@ -534,7 +721,7 @@ export class SessionRuntime {
534
721
  }
535
722
 
536
723
  async setAgentPref(agent: string): Promise<void> {
537
- this.settings.update(this.chatId, { agent });
724
+ this.settings.updateKey(this.settingsKey, { agent });
538
725
  if (agent && this.sessionLive && this.sessionId && this.acp.hasMode(agent)) {
539
726
  try {
540
727
  await this.acp.setMode(this.sessionId, agent);
@@ -546,22 +733,29 @@ export class SessionRuntime {
546
733
  }
547
734
 
548
735
  setReasoningPref(effort: ReasoningEffort): void {
549
- this.settings.update(this.chatId, { reasoning: effort });
736
+ this.settings.updateKey(this.settingsKey, { reasoning: effort });
737
+ this.changed();
738
+ }
739
+
740
+ setPreferredAccountId(id: string | undefined): void {
741
+ this.settings.updateKey(this.settingsKey, { preferredAccountId: id || undefined });
742
+ // Force re-apply on next ensureSession when user changes preference.
743
+ this.preferredAccountApplied = undefined;
550
744
  this.changed();
551
745
  }
552
746
 
553
747
  private async applySessionPrefs(): Promise<void> {
554
- const s = this.settings.get(this.chatId);
748
+ const s = this.settings.getKey(this.settingsKey);
555
749
  // Drop any persisted model the agent doesn't actually offer (an unknown id
556
750
  // is silently accepted by set_model but then breaks the next prompt).
557
751
  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: "" });
752
+ log.warn(`clearing invalid persisted model "${s.model}" for scope ${this.settingsKey}`);
753
+ this.settings.updateKey(this.settingsKey, { model: "" });
560
754
  }
561
- const cur = this.settings.get(this.chatId);
755
+ const cur = this.settings.getKey(this.settingsKey);
562
756
  // Adopt the session's current agent (mode) when the user hasn't chosen one.
563
757
  if (!cur.agent && this.acp.currentModeId) {
564
- this.settings.update(this.chatId, { agent: this.acp.currentModeId });
758
+ this.settings.updateKey(this.settingsKey, { agent: this.acp.currentModeId });
565
759
  } else if (this.sessionId && cur.agent && this.acp.hasMode(cur.agent) && cur.agent !== this.acp.currentModeId) {
566
760
  try {
567
761
  await this.acp.setMode(this.sessionId, cur.agent);
@@ -582,40 +776,88 @@ export class SessionRuntime {
582
776
 
583
777
  async submit(input: PromptInput): Promise<"ran" | "queued"> {
584
778
  await this.ensureSession();
779
+ // Attach staged manager report-back to this prompt (FIFO through the queue).
780
+ let toSubmit = input;
781
+ if (this.stagedReportBack) {
782
+ const rb = this.stagedReportBack;
783
+ this.stagedReportBack = undefined;
784
+ toSubmit = input.reportBack ? input : { ...input, reportBack: rb };
785
+ if (this.sessionId) bindJobSession(rb.jobId, this.sessionId);
786
+ }
585
787
  if (this.busy) {
586
- this.queue.push(input);
788
+ this.queue.push(toSubmit);
587
789
  this.changed();
588
790
  return "queued";
589
791
  }
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);
792
+ // First-prompt steering is applied inside runTurn so queued first messages
793
+ // (and flushQueue) get the same complexity + telegram bridge directives.
794
+ void this.runTurn(toSubmit);
599
795
  return "ran";
600
796
  }
601
797
 
602
- private markComplexitySteered(): void {
603
- if (this.sessionId) this.complexitySteered.add(this.sessionId);
798
+ private markFirstPromptSteered(): void {
799
+ if (!this.sessionId) return;
800
+ this.complexitySteered.add(this.sessionId);
801
+ this.telegramBridgeSteered.add(this.sessionId);
802
+ }
803
+
804
+ private telegramBridgeDirective(): string {
805
+ return buildTelegramBridgeDirective({
806
+ forumReady: !!this.bridge?.forum?.isReady,
807
+ topicGroupId: this.cfg.topicGroupId,
808
+ allowedBots: this.cfg.allowedTelegramBots,
809
+ botCommands: this.cfg.telegramBotCommands,
810
+ managerMode: this.managerMode,
811
+ });
604
812
  }
605
813
 
606
814
  /**
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).
815
+ * Complexity + telegram bridge teaching on the first prompt of a brand-new
816
+ * conversation only (no prior user turns in this process / session jsonl).
817
+ * Manager mode uses MANAGER_DIRECTIVE instead of complexity/progress coding UX.
609
818
  */
610
- private shouldSteerComplexity(): boolean {
819
+ private applyFirstPromptSteering(input: PromptInput): PromptInput {
820
+ if (!this.shouldSteerFirstPrompt(input)) return input;
821
+ let toRun: PromptInput;
822
+ if (this.managerMode) {
823
+ toRun = wrapManagerDirective(input);
824
+ toRun = wrapTelegramBridgePrompt(toRun, this.telegramBridgeDirective());
825
+ this.markFirstPromptSteered();
826
+ log.info(`chat ${this.chatId}: first-prompt manager + telegram bridge applied`);
827
+ return toRun;
828
+ }
829
+ toRun = wrapAutoComplexityPrompt(input);
830
+ toRun = wrapTelegramBridgePrompt(toRun, this.telegramBridgeDirective());
831
+ this.markFirstPromptSteered();
832
+ log.info(`chat ${this.chatId}: first-prompt complexity + telegram bridge applied`);
833
+ return toRun;
834
+ }
835
+
836
+ /**
837
+ * Apply first-prompt directives only on a brand-new conversation (no prior
838
+ * user turns in this process / session jsonl).
839
+ */
840
+ private shouldSteerFirstPrompt(input: PromptInput): boolean {
611
841
  if (!this.sessionId) return false;
612
- if (this.complexitySteered.has(this.sessionId)) return false;
842
+ if (this.complexitySteered.has(this.sessionId) && this.telegramBridgeSteered.has(this.sessionId)) {
843
+ return false;
844
+ }
613
845
  if (this.turnCount > 0) return false;
846
+ // Never wrap meta follow-ups even if somehow first.
847
+ if (
848
+ input.skipSelfRecheck ||
849
+ isSelfRecheckPrompt(input.text) ||
850
+ isTelegramBridgeResultsPrompt(input.text) ||
851
+ isManagerWorkReportPrompt(input.text)
852
+ ) {
853
+ return false;
854
+ }
614
855
  try {
615
856
  const path = join(this.cfg.sessionsDir, `${this.sessionId}.jsonl`);
616
857
  const hist = readHistory(path, 8);
617
858
  if (hist.some((e) => e.role === "user" && e.text.trim().length > 0)) {
618
859
  this.complexitySteered.add(this.sessionId);
860
+ this.telegramBridgeSteered.add(this.sessionId);
619
861
  return false;
620
862
  }
621
863
  } catch {
@@ -624,9 +866,40 @@ export class SessionRuntime {
624
866
  return true;
625
867
  }
626
868
 
869
+ /** Memory + topic catalog inject for every real manager user turn. */
870
+ private applyManagerContext(input: PromptInput): PromptInput {
871
+ if (!this.managerMode) return input;
872
+ if (
873
+ isTelegramBridgeResultsPrompt(input.text) ||
874
+ isManagerWorkReportPrompt(input.text) ||
875
+ isSelfRecheckPrompt(input.text)
876
+ ) {
877
+ return input;
878
+ }
879
+ if (!this.bridge) return input;
880
+ const userText = stripDirectiveWrappers(input.text) || input.text;
881
+ const block = buildManagerContextBlock({
882
+ userText,
883
+ sessionsDir: this.cfg.sessionsDir,
884
+ store: this.bridge.store,
885
+ forum: this.bridge.forum,
886
+ });
887
+ return {
888
+ ...input,
889
+ text: injectManagerContext(input.text, block),
890
+ };
891
+ }
892
+
893
+ /**
894
+ * Stop the current turn for this runtime only.
895
+ * Soft ACP cancel + session-scoped force-complete; never kills the shared
896
+ * agent (that would stop every multiplexed chat and take the bot offline).
897
+ */
627
898
  async cancel(): Promise<boolean> {
628
899
  if (!this.busy || !this.sessionId) return false;
629
900
  this.cancelled = true;
901
+ // Clear queue of follow-ups for this turn? No — only stop the active turn;
902
+ // queued user messages remain so the user can flush later if they want.
630
903
  await this.acp.cancel(this.sessionId);
631
904
  return true;
632
905
  }
@@ -647,6 +920,7 @@ export class SessionRuntime {
647
920
  // Account rotation restarts the process globally. Do not bind a new chat
648
921
  // to a candidate account until the owner has finished probing it.
649
922
  await this.accountRotator?.waitForIdle();
923
+ await this.applyPreferredAccount();
650
924
  if (this.rebindPending && this.sessionId) {
651
925
  // The ACP process is frequently mid-restart the first time we re-bind
652
926
  // (auto-restart after a crash, or a fresh bot boot), so a single attempt
@@ -671,6 +945,38 @@ export class SessionRuntime {
671
945
  if (!this.sessionId) await this.startNewSession(this.cwd, this.projectName);
672
946
  }
673
947
 
948
+ /** Last preferred account we successfully aligned to (avoids activate thrash). */
949
+ private preferredAccountApplied?: string;
950
+
951
+ /**
952
+ * If this scope prefers a saved account and the process is on another login,
953
+ * switch before binding the session. Skips when another turn is in flight
954
+ * (account switch restarts the agent) or we already applied this preference.
955
+ */
956
+ private async applyPreferredAccount(): Promise<void> {
957
+ const preferred = this.preferredAccountId;
958
+ const rotator = this.accountRotator;
959
+ if (!preferred || !rotator) return;
960
+ const st = rotator.state();
961
+ if (st.activeId === preferred) {
962
+ this.preferredAccountApplied = preferred;
963
+ return;
964
+ }
965
+ // User cleared or changed preference — allow one more activate.
966
+ if (this.preferredAccountApplied === preferred) return;
967
+ if (this.acp.hasInflightPrompt()) {
968
+ log.debug(`scope ${this.settingsKey}: skip preferred account (turn in flight)`);
969
+ return;
970
+ }
971
+ try {
972
+ await rotator.activate(preferred);
973
+ this.preferredAccountApplied = preferred;
974
+ log.info(`scope ${this.settingsKey}: activated preferred account ${preferred.slice(0, 8)}`);
975
+ } catch (e) {
976
+ log.debug(`preferred account activate failed: ${(e as Error).message}`);
977
+ }
978
+ }
979
+
674
980
  /** Reload a persisted session, retrying flaky failures with a short backoff.
675
981
  * Returns true once loaded, false after the attempts are exhausted. */
676
982
  private async rebindWithRetries(sessionId: string, attempts = 4): Promise<boolean> {
@@ -711,20 +1017,70 @@ export class SessionRuntime {
711
1017
  }
712
1018
 
713
1019
  private async runTurn(input: PromptInput): Promise<void> {
1020
+ // Apply before any turn bookkeeping so card previews / logs see the wrapped
1021
+ // text the same way the agent does (also covers flushQueue first messages).
1022
+ try {
1023
+ input = this.applyFirstPromptSteering(input);
1024
+ input = this.applyManagerContext(input);
1025
+ } catch (e) {
1026
+ log.warn(`prompt steering/context failed: ${(e as Error).message}`);
1027
+ }
1028
+
714
1029
  this.busy = true;
715
1030
  this.cancelled = false;
716
1031
  this.turnReplyTo = input.replyTo;
1032
+ this.turnPromptId = input.promptId;
717
1033
  this.turnUserText = input.text;
718
1034
  this.turnAssistantText = "";
1035
+ this.cardThinking = "";
1036
+ this.turnExpectDone = false;
1037
+ this.turnDonePinged = false;
719
1038
  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.
1039
+ // Meta turns (recheck, bridge results, work reports) never arm another recheck.
1040
+ const isBridgeResults = isTelegramBridgeResultsPrompt(input.text);
1041
+ const isWorkReport = isManagerWorkReportPrompt(input.text);
1042
+ // Keep the Thinking… bubble across search_memory → results follow-ups so
1043
+ // the user is not left with a deleted placeholder and no reply.
1044
+ if (this.managerStatusMsgId !== undefined && !isBridgeResults) {
1045
+ const orphan = this.managerStatusMsgId;
1046
+ this.managerStatusMsgId = undefined;
1047
+ void this.deleteManagerStatus(orphan);
1048
+ }
1049
+ this.skipSelfRecheck =
1050
+ !!input.skipSelfRecheck ||
1051
+ this.isSelfRecheckTurn ||
1052
+ isBridgeResults ||
1053
+ isWorkReport ||
1054
+ this.managerMode;
1055
+ // Fresh user work resets bridge-chain depth + suggestion anchors.
1056
+ if (!this.isSelfRecheckTurn && !isBridgeResults && !isWorkReport) {
1057
+ this.bridgeResultDepth = 0;
1058
+ }
1059
+ // Fresh user work resets suggestion anchors; recheck / bridge results keep the original ask.
723
1060
  // Strip complexity/reply wrappers so suggestions + recheck see the real ask.
724
- if (!this.isSelfRecheckTurn) {
1061
+ if (!this.isSelfRecheckTurn && !isBridgeResults && !isWorkReport) {
725
1062
  this.suggestionUserText = stripDirectiveWrappers(input.text) || input.text;
726
1063
  this.preRecheckAssistantText = "";
727
1064
  this.preRecheckFileOps = new Map();
1065
+ // Card comment: last real user prompt (not self-recheck / empty meta).
1066
+ const preview = input.text.trim()
1067
+ ? cleanUserPreview(input.text, COMMENT_MAX)
1068
+ : input.images.length
1069
+ ? "Attached image(s)"
1070
+ : "";
1071
+ if (preview) {
1072
+ this.cardUserPrompt = preview;
1073
+ this.setSessionComment(preview);
1074
+ }
1075
+ }
1076
+ // Bind THIS prompt's report-back (manager dispatch). Meta follow-ups
1077
+ // (recheck / bridge results) omit reportBack and keep the prior job until
1078
+ // the queue drains and we report once.
1079
+ if (input.reportBack) {
1080
+ this.pendingReportBack = input.reportBack as ReportBackMeta;
1081
+ }
1082
+ if (this.pendingReportBack && this.sessionId) {
1083
+ bindJobSession(this.pendingReportBack.jobId, this.sessionId);
728
1084
  }
729
1085
  this.shownToolIds = new Set();
730
1086
  this.toolCallCache = new Map();
@@ -745,13 +1101,64 @@ export class SessionRuntime {
745
1101
  // A new streamed turn supersedes any transient "follow" watch of this same
746
1102
  // session's previous in-flight turn (avoids duplicated output).
747
1103
  if (this.watchIsFollow) this.stopWatch();
748
- const live = this.foreground;
1104
+ // Manager (General): stream short chat prose (no tools/progress). Work
1105
+ // reports stay quiet. Bridge-result follow-ups ARE user-facing — that is
1106
+ // usually when the manager answers after search_memory.
1107
+ const managerSilent =
1108
+ this.managerMode && (isWorkReport || this.isSelfRecheckTurn);
1109
+ const live = this.foreground && !managerSilent;
749
1110
  const startedAt = Date.now();
750
1111
  this.turnStartedAt = startedAt;
1112
+ this.managerNotifyCount = 0;
1113
+ this.managerUserVisible = false;
1114
+ // General: Starting… (from message handler) → Thinking… then stream into it.
1115
+ // Work-report wakes stay fully silent (no bubble).
1116
+ const managerMeta = managerSilent;
1117
+ let thinkingMsgId: number | undefined = input.seedMessageId ?? this.managerStatusMsgId;
1118
+ if (this.managerMode && !managerSilent && this.turnReplyTo !== undefined) {
1119
+ if (thinkingMsgId !== undefined) {
1120
+ await this.editManagerStatus(thinkingMsgId, "Thinking\u2026");
1121
+ } else {
1122
+ thinkingMsgId = await this.postManagerStatus(this.turnReplyTo, "Thinking\u2026");
1123
+ }
1124
+ this.managerStatusMsgId = thinkingMsgId;
1125
+ } else if (this.managerMode && managerSilent && thinkingMsgId !== undefined && !isBridgeResults) {
1126
+ // Drop Starting… leftover on silent work-report / recheck turns.
1127
+ await this.deleteManagerStatus(thinkingMsgId);
1128
+ thinkingMsgId = undefined;
1129
+ this.managerStatusMsgId = undefined;
1130
+ }
751
1131
  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)
1132
+ ? new ResponseStreamer(
1133
+ this.api,
1134
+ this.chatId,
1135
+ this.cfg.streamThrottleMs,
1136
+ this.turnReplyTo,
1137
+ this.hashtags(),
1138
+ this.managerMode ? undefined : (pct) => this.setProgress(pct),
1139
+ this.managerMode ? false : this.cfg.progressFallback,
1140
+ startedAt,
1141
+ this.messageThreadId,
1142
+ this.managerMode
1143
+ ? {
1144
+ proseOnly: true,
1145
+ showProgressBar: false,
1146
+ seedMessageId: thinkingMsgId,
1147
+ }
1148
+ : undefined,
1149
+ )
753
1150
  : undefined;
754
- if (live) this.typing.start();
1151
+ // Bind user message (+ status bubble) → session for reply routing.
1152
+ if (this.managerMode && this.sessionId) {
1153
+ if (this.turnReplyTo !== undefined) {
1154
+ this.onTelegramMessageBound?.(this.turnReplyTo, this.sessionId);
1155
+ }
1156
+ if (thinkingMsgId !== undefined) {
1157
+ this.onTelegramMessageBound?.(thinkingMsgId, this.sessionId);
1158
+ }
1159
+ }
1160
+ // Typing indicator for user-facing manager turns and live project streams.
1161
+ if (live || (this.managerMode && !managerMeta)) this.typing.start();
755
1162
  this.activity(true);
756
1163
  this.changed();
757
1164
  this.imageScanText = "";
@@ -760,8 +1167,13 @@ export class SessionRuntime {
760
1167
  const content = buildContentBlocks(input, {
761
1168
  reasoning: reasoningDirective(this.reasoning),
762
1169
  priming: this.primingContext,
763
- imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
764
- progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
1170
+ imageOutput:
1171
+ !this.managerMode && this.cfg.sendAgentImages
1172
+ ? IMAGE_OUTPUT_DIRECTIVE
1173
+ : undefined,
1174
+ // Manager chat: no progress spam; project topics keep the usual directive.
1175
+ progress:
1176
+ !this.managerMode && this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
765
1177
  });
766
1178
  this.primingContext = undefined;
767
1179
 
@@ -786,6 +1198,15 @@ export class SessionRuntime {
786
1198
  if (final.result && !this.cancelled) this.streamer?.completeFallback();
787
1199
  if (this.streamer) await this.streamer.finalize();
788
1200
  if (this.foreground) await this.sendTurnImages();
1201
+
1202
+ // Telegram bridge actions (JSON fences in the agent reply). Process on
1203
+ // normal turns AND bridge-results follow-ups so multi-step bot_command /
1204
+ // search chains work; depth cap prevents infinite loops.
1205
+ let queuedBridgeResults = false;
1206
+ if (final.result && !this.cancelled) {
1207
+ queuedBridgeResults = await this.processTelegramBridgeActions();
1208
+ }
1209
+
789
1210
  // Always build the completion (records `lastCompletion` so switching back
790
1211
  // to this session can replay its Done + summary). Only PING the chat for
791
1212
  // the foreground turn, or a background turn when NOTIFY_OTHER_SESSIONS is on.
@@ -798,49 +1219,40 @@ export class SessionRuntime {
798
1219
  this.turnCount++;
799
1220
  // Persist real per-account usage (turns + reported credits) for /accounts and /usage.
800
1221
  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);
1222
+ // Card comment: last user prompt (thinking cleared when idle).
1223
+ this.persistCardUserPrompt();
1224
+ this.cardThinking = "";
1225
+ // Keep live step while bridge/sibling-bot results are still queued —
1226
+ // clearing here made "Waiting for bot" vanish before the interim notify.
1227
+ if (!queuedBridgeResults) this.setLiveStep(undefined);
811
1228
  } 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
- );
1229
+ this.persistCardUserPrompt();
1230
+ this.cardThinking = "";
820
1231
  this.setLiveStep(undefined);
821
1232
  } 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
- );
1233
+ this.persistCardUserPrompt();
1234
+ this.cardThinking = "";
830
1235
  this.setLiveStep(undefined);
831
1236
  }
832
1237
  if (final.result || this.cancelled) {
833
- const liveMsg = this.completionMessage(final.result?.stopReason, startedAt, streamedOutput);
834
- const pingDone = canPing && (this.foreground || !hasQueued);
1238
+ // Bridge results in the queue mean "not Done yet" — never treat as a
1239
+ // completion ping even in the foreground. Do not post bridge status
1240
+ // spam to the chat (live step / status panel only).
1241
+ const pingDone =
1242
+ canPing && (this.foreground || !hasQueued) && !queuedBridgeResults;
1243
+ // Manager uses notify/finishManagerUserFacing — never arm Done safety-net spam.
1244
+ this.turnExpectDone = pingDone && !this.managerMode;
835
1245
 
836
1246
  // One-shot self-recheck: only after a real *user* turn (not meta/auto),
837
1247
  // with idle queue. skipSelfRecheck blocks loops after recheck / auto-batch.
838
1248
  // Also skipped when no files were modified, or when a quiet AI decision
839
1249
  // refuses (simple tasks, pure build, nothing worth re-verifying).
1250
+ // Delay recheck/Done when bridge results are queued (like self-recheck).
840
1251
  const wantSelfRecheck =
841
1252
  !!final.result &&
842
1253
  !this.cancelled &&
843
1254
  !hasQueued &&
1255
+ !queuedBridgeResults &&
844
1256
  this.cfg.selfRecheckEnabled &&
845
1257
  !this.skipSelfRecheck &&
846
1258
  !this.isSelfRecheckTurn;
@@ -853,6 +1265,13 @@ export class SessionRuntime {
853
1265
  this.preRecheckFileOps = cloneFileOps(this.fileOps);
854
1266
  this.setLiveStep("Deciding if self-recheck is needed\u2026");
855
1267
  this.changed();
1268
+ // Visible chat status so stream-complete is not mistaken for a silent exit.
1269
+ if (pingDone) {
1270
+ await this.notify(
1271
+ "\u{1F50D} Checking if a quality pass is needed\u2026",
1272
+ { loud: true, replyTo: this.turnReplyTo },
1273
+ );
1274
+ }
856
1275
  const recheck = await this.maybePlanSelfRecheck();
857
1276
  this.setLiveStep(undefined);
858
1277
  // User may cancel during the quiet decision call — first turn still
@@ -862,9 +1281,13 @@ export class SessionRuntime {
862
1281
  this.preRecheckAssistantText = "";
863
1282
  } else if (recheck) {
864
1283
  queuedRecheck = true;
1284
+ this.turnExpectDone = false; // final Done comes after the recheck turn
865
1285
  // Front of queue; mark skip so the recheck turn never re-arms itself.
866
1286
  this.queue.unshift(
867
- textPrompt(recheck, this.turnReplyTo, undefined, { skipSelfRecheck: true }),
1287
+ textPrompt(recheck, this.turnReplyTo, undefined, {
1288
+ skipSelfRecheck: true,
1289
+ promptId: this.turnPromptId,
1290
+ }),
868
1291
  );
869
1292
  this.changed();
870
1293
  if (pingDone) {
@@ -883,123 +1306,254 @@ export class SessionRuntime {
883
1306
  }
884
1307
  }
885
1308
 
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.
1309
+ // Manager: keep Thinking… while bridge results are chaining so the
1310
+ // follow-up turn can stream the real answer into the same bubble.
1311
+ if (this.managerMode && queuedBridgeResults && this.managerStatusMsgId !== undefined) {
1312
+ void this.editManagerStatus(this.managerStatusMsgId, "Thinking\u2026");
1313
+ }
1314
+
1315
+ if (!queuedRecheck && !queuedBridgeResults) {
1316
+ // Manager (General): quiet-by-default completion — no Done spam.
1317
+ // User-facing only via notify (already sent) or one fallback reply.
1318
+ if (this.managerMode) {
1319
+ await this.finishManagerUserFacing(managerMeta, final.result?.stopReason, startedAt);
1320
+ this.turnDonePinged = true;
1321
+ // Suggestions only when something was actually shown to the user.
1322
+ if (
1323
+ final.result &&
1324
+ !this.cancelled &&
1325
+ !hasQueued &&
1326
+ this.managerUserVisible &&
1327
+ !managerMeta
1328
+ ) {
1329
+ try {
1330
+ const baseForSug =
1331
+ cleanManagerVisibleText(this.turnAssistantText).slice(0, 500) || "Done";
1332
+ await this.collectAndApplySuggestions(baseForSug, undefined, {
1333
+ autoQueue: true,
1334
+ });
1335
+ } catch (e) {
1336
+ log.debug(`manager suggestions failed: ${(e as Error).message}`);
1337
+ }
1338
+ }
1339
+ } else {
1340
+ // Build Done text *now* (after quiet decision) so a cancel during the
1341
+ // recheck-decision wait shows ⏹ Stopped, not a stale ✅ Done head.
891
1342
  let doneText = this.isSelfRecheckTurn
892
- ? 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,
1343
+ ? this.completionMessageSplit(final.result?.stopReason, startedAt, streamedOutput)
1344
+ : this.completionMessage(final.result?.stopReason, startedAt, streamedOutput);
1345
+ // 1) Always send Done FIRST — never block the completion ping on the
1346
+ // quiet suggestions prompt (which can hang and look like "no Done").
1347
+ let doneMsgId: number | undefined;
1348
+ const shouldPingDone = pingDone;
1349
+ if (shouldPingDone && doneText.trim()) {
1350
+ doneMsgId = await this.notify(doneText, {
1351
+ loud: true,
1352
+ replyTo: this.turnReplyTo,
1353
+ replyMarkup: switchKb,
899
1354
  });
900
- doneText = sug.text;
901
- doneMarkup = sug.markup;
1355
+ if (doneMsgId !== undefined) this.turnDonePinged = true;
1356
+ }
1357
+ // 2) Suggestions: project topics keep Done-edit UX.
1358
+ if (final.result && !this.cancelled && !hasQueued) {
1359
+ try {
1360
+ const baseForSug = doneText;
1361
+ const sug = await this.collectAndApplySuggestions(
1362
+ baseForSug,
1363
+ switchKb,
1364
+ {
1365
+ autoQueue: this.foreground,
1366
+ },
1367
+ );
1368
+ if (shouldPingDone && sug.text !== doneText) {
1369
+ await this.enhanceDoneMessage(doneMsgId, sug.text, sug.markup ?? switchKb);
1370
+ }
1371
+ } catch (e) {
1372
+ log.debug(`suggestions after Done failed: ${(e as Error).message}`);
1373
+ }
1374
+ }
902
1375
  }
903
- if (pingDone) await this.notify(doneText, { loud: true, replyTo: this.turnReplyTo, replyMarkup: doneMarkup });
904
1376
  // Clear frozen first-turn ops after final Done (recheck path done).
905
1377
  if (this.isSelfRecheckTurn) this.preRecheckFileOps = new Map();
1378
+
1379
+ // Child work dispatched from General → wake manager with a report.
1380
+ // (Waits only for same-job meta follow-ups; see maybeReportBackToManager.)
1381
+ await this.maybeReportBackToManager({
1382
+ ok: !!final.result && !this.cancelled,
1383
+ cancelled: this.cancelled,
1384
+ stopReason: final.result?.stopReason,
1385
+ error: undefined,
1386
+ });
906
1387
  }
1388
+ // queuedBridgeResults: stay quiet in chat — agent gets results via queue.
907
1389
  } else if (final.error) {
908
- // If the self-recheck pass itself failed, still surface Done for the
909
- // original work (split files + suggestions) so the user is not stuck.
910
- if (this.isSelfRecheckTurn && !hasQueued) {
1390
+ // Manager: one short important message (or edit Thinking…); never Done spam.
1391
+ if (this.managerMode) {
1392
+ await this.finishManagerError(final.error.message, startedAt);
1393
+ this.turnDonePinged = true;
1394
+ await this.maybeReportBackToManager({
1395
+ ok: false,
1396
+ cancelled: false,
1397
+ error: final.error.message,
1398
+ });
1399
+ } else if (this.isSelfRecheckTurn && !hasQueued) {
1400
+ // If the self-recheck pass itself failed, still surface Done for the
1401
+ // original work (split files + suggestions) so the user is not stuck.
911
1402
  const switchKb = this.switchKeyboard();
912
1403
  const pingDone = canPing && (this.foreground || !hasQueued);
1404
+ this.turnExpectDone = pingDone;
913
1405
  let doneText =
914
1406
  this.completionMessageSplit(undefined, startedAt, streamedOutput) +
915
1407
  `\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,
1408
+ let doneMsgId: number | undefined;
1409
+ if (pingDone) {
1410
+ doneMsgId = await this.notify(doneText, {
1411
+ loud: true,
1412
+ replyTo: this.turnReplyTo,
1413
+ replyMarkup: switchKb,
919
1414
  });
920
- doneText = sug.text;
921
- if (pingDone) {
922
- await this.notify(doneText, {
923
- loud: true,
924
- replyTo: this.turnReplyTo,
925
- replyMarkup: sug.markup,
1415
+ if (doneMsgId !== undefined) this.turnDonePinged = true;
1416
+ }
1417
+ if (!this.cancelled) {
1418
+ try {
1419
+ const sug = await this.collectAndApplySuggestions(doneText, switchKb, {
1420
+ autoQueue: this.foreground,
926
1421
  });
1422
+ if (pingDone && sug.text !== doneText) {
1423
+ await this.enhanceDoneMessage(doneMsgId, sug.text, sug.markup ?? switchKb);
1424
+ }
1425
+ } catch (e) {
1426
+ log.debug(`suggestions after recheck-fail Done failed: ${(e as Error).message}`);
927
1427
  }
928
- } else if (pingDone) {
929
- await this.notify(doneText, { loud: true, replyTo: this.turnReplyTo, replyMarkup: switchKb });
930
1428
  }
931
1429
  this.preRecheckFileOps = new Map();
1430
+ // Self-recheck failed after real work — still report manager job if any.
1431
+ await this.maybeReportBackToManager({
1432
+ ok: true,
1433
+ cancelled: this.cancelled,
1434
+ stopReason: "self_recheck_failed",
1435
+ error: final.error.message,
1436
+ });
932
1437
  } else {
933
1438
  const transient = isTransientError(final.error);
934
1439
  const liveMsg = this.errorMessage(final.error, startedAt, final.attempts, transient);
935
- if (canPing) await this.notify(liveMsg, { loud: true, replyTo: this.turnReplyTo, replyMarkup: switchKb });
1440
+ this.turnExpectDone = canPing;
1441
+ if (canPing) {
1442
+ const id = await this.notify(liveMsg, {
1443
+ loud: true,
1444
+ replyTo: this.turnReplyTo,
1445
+ replyMarkup: switchKb,
1446
+ });
1447
+ if (id !== undefined) this.turnDonePinged = true;
1448
+ }
1449
+ await this.maybeReportBackToManager({
1450
+ ok: false,
1451
+ cancelled: false,
1452
+ error: final.error.message,
1453
+ });
936
1454
  }
937
1455
  }
938
1456
  } catch (err) {
939
1457
  // Unexpected failure outside the prompt path (e.g. while finalizing).
940
1458
  await this.streamer?.finalize().catch(() => {});
941
1459
  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
- );
1460
+ this.persistCardUserPrompt();
1461
+ this.cardThinking = "";
950
1462
  this.setLiveStep(undefined);
951
1463
  // If the self-recheck pass itself blew up, still surface Done for the
952
1464
  // original work (split files + suggestions) so the user is not stuck.
953
1465
  if (this.isSelfRecheckTurn && this.queue.length === 0) {
954
1466
  const switchKb = this.switchKeyboard();
955
1467
  const canPing = this.foreground || this.cfg.notifyOtherSessions;
1468
+ this.turnExpectDone = canPing;
956
1469
  let doneText =
957
1470
  this.completionMessageSplit(undefined, startedAt, this.streamer?.hasOutput ?? false) +
958
1471
  `\n\n\u26A0\uFE0F Self-recheck failed: ${errMsg}`;
959
1472
  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, {
1473
+ let doneMsgId: number | undefined;
1474
+ if (canPing) {
1475
+ doneMsgId = await this.notify(doneText, {
974
1476
  loud: true,
975
1477
  replyTo: this.turnReplyTo,
976
1478
  replyMarkup: switchKb,
977
1479
  });
1480
+ if (doneMsgId !== undefined) this.turnDonePinged = true;
1481
+ }
1482
+ if (!this.cancelled) {
1483
+ try {
1484
+ const sug = await this.collectAndApplySuggestions(doneText, switchKb, {
1485
+ autoQueue: this.foreground,
1486
+ });
1487
+ if (canPing && sug.text !== doneText) {
1488
+ await this.enhanceDoneMessage(doneMsgId, sug.text, sug.markup ?? switchKb);
1489
+ }
1490
+ } catch (e2) {
1491
+ log.debug(`suggestions after recheck catch failed: ${(e2 as Error).message}`);
1492
+ }
978
1493
  }
979
1494
  } catch (e2) {
980
1495
  log.debug(`recheck catch recovery failed: ${(e2 as Error).message}`);
981
- if (canPing) {
982
- await this.notify(doneText, {
1496
+ if (canPing && !this.turnDonePinged) {
1497
+ const id = await this.notify(doneText, {
983
1498
  loud: true,
984
1499
  replyTo: this.turnReplyTo,
985
1500
  replyMarkup: switchKb,
986
- }).catch(() => {});
1501
+ });
1502
+ if (id !== undefined) this.turnDonePinged = true;
987
1503
  }
988
1504
  }
989
1505
  this.preRecheckFileOps = new Map();
1506
+ } else if (this.managerMode) {
1507
+ await this.finishManagerError(errMsg, startedAt);
1508
+ this.turnDonePinged = true;
1509
+ await this.maybeReportBackToManager({
1510
+ ok: false,
1511
+ cancelled: this.cancelled,
1512
+ error: errMsg,
1513
+ });
990
1514
  } else {
991
1515
  const msg = `\u274C Error after ${fmtDuration(Date.now() - startedAt)}: ${errMsg}`;
992
1516
  this.lastCompletion = msg;
993
- if (this.foreground || this.cfg.notifyOtherSessions) {
1517
+ const canPing = this.foreground || this.cfg.notifyOtherSessions;
1518
+ this.turnExpectDone = canPing;
1519
+ if (canPing) {
994
1520
  const from = this.foreground ? "" : `\u{1F4E8} From other session ${this.sessionTag()}\n`;
995
- await this.notify(`${from}${msg}`, {
1521
+ const id = await this.notify(`${from}${msg}`, {
996
1522
  loud: true,
997
1523
  replyTo: this.turnReplyTo,
998
1524
  replyMarkup: this.switchKeyboard(),
999
1525
  });
1526
+ if (id !== undefined) this.turnDonePinged = true;
1000
1527
  }
1528
+ await this.maybeReportBackToManager({
1529
+ ok: false,
1530
+ cancelled: this.cancelled,
1531
+ error: errMsg,
1532
+ });
1001
1533
  }
1002
1534
  } finally {
1535
+ // Safety net: turn completed with an expected Done ping that never landed
1536
+ // (notify failed, hung path, etc.). Never block queue flush on this.
1537
+ // Manager never uses this path (turnExpectDone is false in manager mode).
1538
+ if (this.turnExpectDone && !this.turnDonePinged && !this.managerMode) {
1539
+ const fallback =
1540
+ this.lastCompletion?.trim() ||
1541
+ `\u2705 Done \u00B7 ${fmtDuration(Date.now() - startedAt)}`;
1542
+ const short =
1543
+ fallback.length > 3500 ? fallback.slice(0, 3499) + "\u2026" : fallback;
1544
+ try {
1545
+ const id = await this.notify(short, {
1546
+ loud: true,
1547
+ replyTo: this.turnReplyTo,
1548
+ replyMarkup: this.switchKeyboard(),
1549
+ });
1550
+ if (id !== undefined) this.turnDonePinged = true;
1551
+ else log.warn(`chat ${this.chatId}: Done safety-net notify failed`);
1552
+ } catch (e) {
1553
+ log.warn(`chat ${this.chatId}: Done safety-net error: ${(e as Error).message}`);
1554
+ }
1555
+ }
1556
+ this.turnExpectDone = false;
1003
1557
  this.typing.stop();
1004
1558
  this.streamer = undefined;
1005
1559
  this.capturingQuiet = false;
@@ -1013,7 +1567,8 @@ export class SessionRuntime {
1013
1567
  // messages. The finished streamed bubble keeps its own (frozen) bar.
1014
1568
  this.progress = undefined;
1015
1569
  this.planEntries = undefined;
1016
- // Prefer stored summary on cards once idle (clear live step if still set).
1570
+ // Idle cards show last user prompt only (clear live step / thinking).
1571
+ this.cardThinking = "";
1017
1572
  if (!this.liveStep || this.sessionComment) this.liveStep = undefined;
1018
1573
  this.changed();
1019
1574
  }
@@ -1029,6 +1584,122 @@ export class SessionRuntime {
1029
1584
  }
1030
1585
  }
1031
1586
 
1587
+ /**
1588
+ * Parse telegram JSON actions from the assistant reply, execute them, notify
1589
+ * the user, and queue a results follow-up for the agent when useful.
1590
+ * Returns true when a results prompt was queued (delay Done/recheck).
1591
+ */
1592
+ private async processTelegramBridgeActions(): Promise<boolean> {
1593
+ const { actions, cleaned } = extractTelegramActions(this.turnAssistantText);
1594
+ if (cleaned !== this.turnAssistantText) {
1595
+ this.turnAssistantText = cleaned;
1596
+ }
1597
+ if (actions.length === 0) return false;
1598
+ if (!this.bridge) {
1599
+ log.warn(`chat ${this.chatId}: telegram actions present but bridge not wired`);
1600
+ return false;
1601
+ }
1602
+
1603
+ const botCmds = actions.filter((a) => a.action === "bot_command");
1604
+ // bot_command means Grok ended its turn early to wait on a sibling bot —
1605
+ // this is NOT a Done. Keep busy; status panel live-step only (no chat spam).
1606
+ if (botCmds.length > 0) {
1607
+ const labels = botCmds
1608
+ .map((a) =>
1609
+ a.action === "bot_command" ? `@${a.bot} /${a.command}` : "",
1610
+ )
1611
+ .filter(Boolean)
1612
+ .join(", ");
1613
+ this.setLiveStep(`Waiting for sibling bot: ${labels}`);
1614
+ } else {
1615
+ this.setLiveStep("Running Telegram bridge actions\u2026");
1616
+ }
1617
+ this.changed();
1618
+ log.info(`chat ${this.chatId}: executing ${actions.length} telegram bridge action(s)`);
1619
+
1620
+ const results = await executeTelegramActions(actions, {
1621
+ api: this.api,
1622
+ cfg: this.cfg,
1623
+ chatId: this.chatId,
1624
+ messageThreadId: this.messageThreadId,
1625
+ replyToMessageId: this.turnReplyTo,
1626
+ forum: this.bridge.forum,
1627
+ store: this.bridge.store,
1628
+ bots: this.bridge.bots,
1629
+ submitTopicPrompt: this.bridge.submitTopicPrompt,
1630
+ managerMode: this.managerMode,
1631
+ managerUserAskPreview: this.suggestionUserText || cleanUserPreview(this.turnUserText, 400),
1632
+ });
1633
+
1634
+ // Count successful notify actions. Do not delete the live stream bubble
1635
+ // (same id as Thinking…) — that would wipe the user's visible reply.
1636
+ for (const r of results) {
1637
+ if (r.action === "notify" && r.ok) {
1638
+ this.managerNotifyCount++;
1639
+ this.managerUserVisible = true;
1640
+ const streamLive = this.streamer?.liveMessageId;
1641
+ if (
1642
+ this.managerStatusMsgId !== undefined &&
1643
+ this.managerStatusMsgId !== streamLive &&
1644
+ !this.streamer?.hasOutput
1645
+ ) {
1646
+ const sid = this.managerStatusMsgId;
1647
+ this.managerStatusMsgId = undefined;
1648
+ void this.deleteManagerStatus(sid);
1649
+ }
1650
+ const mid = (r.data as { messageId?: number } | undefined)?.messageId;
1651
+ if (mid !== undefined && this.sessionId) {
1652
+ this.onTelegramMessageBound?.(mid, this.sessionId);
1653
+ }
1654
+ }
1655
+ }
1656
+
1657
+ // Only announce durable side-effects in chat (topic create/bind/cross-prompt).
1658
+ // Manager mode stays quiet — use notify for user text; no auto notes.
1659
+ // search_memory / list_* / bot wait status stay silent — results go to the agent.
1660
+ const durableActions = new Set(["create_topic", "set_path", "send_prompt"]);
1661
+ const notes = results
1662
+ .filter((r) => durableActions.has(r.action) && r.userNote?.trim())
1663
+ .map((r) => r.userNote!)
1664
+ .filter(Boolean);
1665
+ if (notes.length > 0 && this.foreground && !this.managerMode) {
1666
+ await this.notify(notes.join("\n"), {
1667
+ loud: true,
1668
+ replyTo: this.turnReplyTo,
1669
+ });
1670
+ }
1671
+
1672
+ // Cap chained result turns so a model that re-emits list_bots forever cannot
1673
+ // block Done. Side-effects already ran; user notes were sent above.
1674
+ // Still queue once when we have bot_command errors so the agent can recover.
1675
+ if (this.bridgeResultDepth >= SessionRuntime.BRIDGE_CHAIN_MAX) {
1676
+ log.warn(
1677
+ `chat ${this.chatId}: telegram bridge chain depth ${this.bridgeResultDepth} — not re-queuing results`,
1678
+ );
1679
+ return false;
1680
+ }
1681
+
1682
+ // Feed results back so the agent can use search hits / bot replies / errors.
1683
+ const prompt = buildTelegramBridgeResultsPrompt(
1684
+ results.map((r) => ({
1685
+ action: r.action,
1686
+ ok: r.ok,
1687
+ data: r.data,
1688
+ error: r.error,
1689
+ })),
1690
+ );
1691
+ this.bridgeResultDepth++;
1692
+ this.queue.unshift(
1693
+ textPrompt(prompt, this.turnReplyTo, undefined, {
1694
+ skipSelfRecheck: true,
1695
+ promptId: this.turnPromptId,
1696
+ }),
1697
+ );
1698
+ this.setLiveStep("Feeding sibling-bot / bridge results to the agent\u2026");
1699
+ this.changed();
1700
+ return true;
1701
+ }
1702
+
1032
1703
  /**
1033
1704
  * Quietly ask for 1–3 follow-ups, attach buttons to the Done text, store them
1034
1705
  * for switch-replay, and optionally queue auto-approved items as **one**
@@ -1038,7 +1709,7 @@ export class SessionRuntime {
1038
1709
  doneText: string,
1039
1710
  switchKb: InlineKeyboard | undefined,
1040
1711
  opts?: { autoQueue?: boolean },
1041
- ): Promise<{ text: string; markup?: InlineKeyboard }> {
1712
+ ): Promise<{ text: string; markup?: InlineKeyboard; suggestions?: Suggestion[] }> {
1042
1713
  if (!this.cfg.suggestionsEnabled || !this.sessionId) {
1043
1714
  return { text: doneText, markup: switchKb };
1044
1715
  }
@@ -1048,6 +1719,8 @@ export class SessionRuntime {
1048
1719
  } catch (e) {
1049
1720
  log.debug(`suggestions fetch failed: ${(e as Error).message}`);
1050
1721
  }
1722
+ // Manager: keep 1–4 short follow-ups only.
1723
+ if (this.managerMode) suggestions = suggestions.slice(0, 4);
1051
1724
  if (suggestions.length === 0) return { text: doneText, markup: switchKb };
1052
1725
 
1053
1726
  const batchId = ++this.suggestionBatchSeq;
@@ -1065,18 +1738,26 @@ export class SessionRuntime {
1065
1738
  let banner: string;
1066
1739
  if (auto.length > 0) {
1067
1740
  const batched = formatBatchedSuggestionsPrompt(auto);
1068
- const lines = auto.map((s, i) => ` ${i + 1}) ${s.need}% \u2014 ${s.text}`);
1741
+ // Hard, visible auto-approve block (especially for General chat).
1742
+ const lines = auto.map((s) => `\u2022 ${s.text}`);
1069
1743
  const autoBlock =
1070
- `\n\n\u{1F4A1} Auto-running ${auto.length} suggestion${auto.length === 1 ? "" : "s"}` +
1071
- ` as one prompt (\u2265 ${thr}% need):\n${lines.join("\n")}`;
1744
+ `\n\n\u2705 Auto Approved:\n${lines.join("\n")}` +
1745
+ (this.managerMode ? "" : `\n(need \u2265 ${thr}%)`);
1072
1746
  text += autoBlock;
1073
- banner = `\u{1F4A1} Suggestions (auto-running ${auto.length} as one prompt):\n${lines.join("\n")}`;
1747
+ banner = `\u2705 Auto Approved:\n${lines.join("\n")}`;
1074
1748
  // Single queue entry — agent executes 1) 2) 3) in one turn.
1075
1749
  // skipSelfRecheck: auto-follow-ups must not arm another recheck cycle.
1076
- this.queue.push(textPrompt(batched, this.turnReplyTo, undefined, { skipSelfRecheck: true }));
1750
+ this.queue.push(
1751
+ textPrompt(batched, this.turnReplyTo, undefined, {
1752
+ skipSelfRecheck: true,
1753
+ promptId: this.turnPromptId,
1754
+ }),
1755
+ );
1077
1756
  this.changed();
1078
1757
  } else {
1079
- text += "\n\n\u{1F4A1} Suggestions \u2014 tap one to continue:";
1758
+ text += this.managerMode
1759
+ ? "\n\nTap a suggestion to continue:"
1760
+ : "\n\n\u{1F4A1} Suggestions \u2014 tap one to continue:";
1080
1761
  banner = "\u{1F4A1} Suggestions \u2014 tap one to continue:";
1081
1762
  }
1082
1763
 
@@ -1084,8 +1765,168 @@ export class SessionRuntime {
1084
1765
  // the same keyboard). Cleared when a new turn starts.
1085
1766
  this.pendingSuggestions = { batchId, suggestions, banner };
1086
1767
 
1087
- const markup = suggestionsKeyboard(batchId, suggestions, switchKb);
1088
- return { text, markup };
1768
+ // Keyboard: show remaining (non-auto) suggestions; if all auto, no buttons.
1769
+ const remaining = suggestions.filter((s) => !auto.some((a) => a.text === s.text));
1770
+ const markup =
1771
+ remaining.length > 0
1772
+ ? suggestionsKeyboard(batchId, remaining, switchKb)
1773
+ : switchKb;
1774
+ return { text, markup, suggestions };
1775
+ }
1776
+
1777
+ /** Post General status bubble (Starting… / Thinking…) — streamer edits it later. */
1778
+ private async postManagerStatus(
1779
+ replyTo: number,
1780
+ text: string,
1781
+ ): Promise<number | undefined> {
1782
+ try {
1783
+ const extra: Record<string, unknown> = {
1784
+ disable_notification: true,
1785
+ ...outboundThreadExtra(this.messageThreadId),
1786
+ reply_parameters: {
1787
+ message_id: replyTo,
1788
+ allow_sending_without_reply: true,
1789
+ },
1790
+ };
1791
+ const msg = await this.api.sendMessage(this.chatId, text, extra);
1792
+ return msg.message_id;
1793
+ } catch (e) {
1794
+ log.debug(`manager status "${text}" failed: ${(e as Error).message}`);
1795
+ return undefined;
1796
+ }
1797
+ }
1798
+
1799
+ private async editManagerStatus(messageId: number, text: string): Promise<void> {
1800
+ try {
1801
+ await this.api.editMessageText(this.chatId, messageId, text);
1802
+ } catch (e) {
1803
+ log.debug(`manager status edit failed: ${(e as Error).message}`);
1804
+ }
1805
+ }
1806
+
1807
+ private async deleteManagerStatus(messageId: number): Promise<void> {
1808
+ try {
1809
+ await this.api.deleteMessage(this.chatId, messageId);
1810
+ } catch (e) {
1811
+ log.debug(`manager status delete failed: ${(e as Error).message}`);
1812
+ }
1813
+ }
1814
+
1815
+ /** Surface a short important error in General; clear Thinking… bubble. */
1816
+ private async finishManagerError(errorMessage: string, startedAt: number): Promise<void> {
1817
+ const elapsed = fmtDuration(Date.now() - startedAt);
1818
+ const short =
1819
+ errorMessage.length > 280 ? errorMessage.slice(0, 277) + "\u2026" : errorMessage;
1820
+ const text = `\u274C ${short} \u00B7 ${elapsed}`;
1821
+ this.lastCompletion = text;
1822
+ if (this.managerStatusMsgId !== undefined) {
1823
+ await this.editManagerStatus(this.managerStatusMsgId, text);
1824
+ this.managerUserVisible = true;
1825
+ if (this.sessionId) this.onTelegramMessageBound?.(this.managerStatusMsgId, this.sessionId);
1826
+ this.managerStatusMsgId = undefined;
1827
+ return;
1828
+ }
1829
+ if (this.turnReplyTo !== undefined) {
1830
+ const id = await this.notify(text, { loud: true, replyTo: this.turnReplyTo });
1831
+ if (id !== undefined) this.managerUserVisible = true;
1832
+ }
1833
+ }
1834
+
1835
+ /**
1836
+ * General quiet completion:
1837
+ * - if notify already sent → drop Thinking… bubble
1838
+ * - else if direct user ask and clean prose → one short fallback message
1839
+ * - else → delete Thinking… and stay silent
1840
+ */
1841
+ private async finishManagerUserFacing(
1842
+ metaTurn: boolean,
1843
+ stopReason: string | undefined,
1844
+ startedAt: number,
1845
+ ): Promise<void> {
1846
+ const elapsed = fmtDuration(Date.now() - startedAt);
1847
+ if (this.cancelled || stopReason === "cancelled") {
1848
+ this.lastCompletion = `\u23F9 Stopped \u00B7 ${elapsed}`;
1849
+ // Only surface cancel if user was waiting on a visible bubble.
1850
+ if (this.managerStatusMsgId !== undefined && !metaTurn) {
1851
+ await this.editManagerStatus(this.managerStatusMsgId, this.lastCompletion);
1852
+ this.managerUserVisible = true;
1853
+ } else if (this.managerStatusMsgId !== undefined) {
1854
+ await this.deleteManagerStatus(this.managerStatusMsgId);
1855
+ }
1856
+ this.managerStatusMsgId = undefined;
1857
+ return;
1858
+ }
1859
+
1860
+ // Prose already landed in the Thinking… bubble via the streamer — keep it.
1861
+ if (this.streamer?.hasOutput) {
1862
+ this.managerUserVisible = true;
1863
+ this.lastCompletion =
1864
+ cleanManagerVisibleText(this.turnAssistantText).slice(0, 500) ||
1865
+ `\u2705 Done \u00B7 ${elapsed}`;
1866
+ this.managerStatusMsgId = undefined;
1867
+ return;
1868
+ }
1869
+
1870
+ if (this.managerNotifyCount > 0) {
1871
+ this.lastCompletion =
1872
+ cleanManagerVisibleText(this.turnAssistantText).slice(0, 500) ||
1873
+ `\u2705 Done \u00B7 ${elapsed}`;
1874
+ if (this.managerStatusMsgId !== undefined) {
1875
+ await this.deleteManagerStatus(this.managerStatusMsgId);
1876
+ this.managerStatusMsgId = undefined;
1877
+ }
1878
+ return;
1879
+ }
1880
+
1881
+ // Work-report / recheck: silent unless cancelled (handled above).
1882
+ if (metaTurn) {
1883
+ this.lastCompletion = `\u2705 Done \u00B7 ${elapsed}`;
1884
+ if (this.managerStatusMsgId !== undefined) {
1885
+ await this.deleteManagerStatus(this.managerStatusMsgId);
1886
+ this.managerStatusMsgId = undefined;
1887
+ }
1888
+ return;
1889
+ }
1890
+
1891
+ // Direct user ask: single fallback if the model emitted no visible prose.
1892
+ const cleaned = cleanManagerVisibleText(this.turnAssistantText);
1893
+ const fallback = pickManagerFallbackText(cleaned);
1894
+ if (fallback && this.managerStatusMsgId !== undefined) {
1895
+ await this.editManagerStatus(this.managerStatusMsgId, fallback);
1896
+ this.managerUserVisible = true;
1897
+ this.lastCompletion = fallback.slice(0, 500);
1898
+ if (this.sessionId) this.onTelegramMessageBound?.(this.managerStatusMsgId, this.sessionId);
1899
+ this.managerStatusMsgId = undefined;
1900
+ return;
1901
+ }
1902
+ if (fallback && this.turnReplyTo !== undefined) {
1903
+ const id = await this.notify(fallback, {
1904
+ loud: false,
1905
+ replyTo: this.turnReplyTo,
1906
+ });
1907
+ if (id !== undefined) {
1908
+ this.managerUserVisible = true;
1909
+ this.lastCompletion = fallback.slice(0, 500);
1910
+ if (this.sessionId) this.onTelegramMessageBound?.(id, this.sessionId);
1911
+ }
1912
+ } else if (this.managerStatusMsgId !== undefined) {
1913
+ // Never delete the placeholder leaving the user with no reply.
1914
+ await this.editManagerStatus(
1915
+ this.managerStatusMsgId,
1916
+ "Working on it \u2014 I\u2019ll report back here.",
1917
+ );
1918
+ this.managerUserVisible = true;
1919
+ this.lastCompletion = "Working on it";
1920
+ if (this.sessionId) this.onTelegramMessageBound?.(this.managerStatusMsgId, this.sessionId);
1921
+ this.managerStatusMsgId = undefined;
1922
+ return;
1923
+ } else {
1924
+ this.lastCompletion = `\u2705 Done \u00B7 ${elapsed}`;
1925
+ }
1926
+ if (this.managerStatusMsgId !== undefined) {
1927
+ await this.deleteManagerStatus(this.managerStatusMsgId);
1928
+ this.managerStatusMsgId = undefined;
1929
+ }
1089
1930
  }
1090
1931
 
1091
1932
  /**
@@ -1142,14 +1983,12 @@ export class SessionRuntime {
1142
1983
  ): Promise<ReturnType<typeof parseSelfRecheckDecision>> {
1143
1984
  if (!this.sessionId) return { needed: false, reason: "no session" };
1144
1985
  const prompt = buildSelfRecheckDecisionPrompt(user, did, filesSummary);
1145
- this.capturingQuiet = true;
1146
- this.quietCaptureBuf = "";
1147
1986
  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 = "";
1987
+ const raw = await this.runQuietPrompt(prompt);
1988
+ return parseSelfRecheckDecision(raw);
1989
+ } catch (e) {
1990
+ log.debug(`self-recheck decision quiet prompt failed: ${(e as Error).message}`);
1991
+ return { needed: false, reason: "decision prompt failed" };
1153
1992
  }
1154
1993
  }
1155
1994
 
@@ -1164,15 +2003,60 @@ export class SessionRuntime {
1164
2003
  const didParts = [this.preRecheckAssistantText, this.turnAssistantText].filter((s) => s?.trim());
1165
2004
  const did = didParts.join("\n") || this.turnAssistantText;
1166
2005
  const prompt = buildSuggestionsPrompt(user, did);
2006
+ try {
2007
+ const raw = await this.runQuietPrompt(prompt);
2008
+ return parseSuggestions(raw);
2009
+ } catch (e) {
2010
+ log.debug(`suggestions quiet prompt failed: ${(e as Error).message}`);
2011
+ return [];
2012
+ }
2013
+ }
2014
+
2015
+ /**
2016
+ * Run a quiet meta ACP prompt (JSON only) with a hard timeout.
2017
+ * On timeout: session/cancel so the shared agent is not stuck holding the
2018
+ * session (which would block Done forever). Does NOT set this.cancelled
2019
+ * (user /stop is separate). Timed-out / partial capture is discarded.
2020
+ */
2021
+ private async runQuietPrompt(text: string): Promise<string> {
2022
+ if (!this.sessionId) return "";
2023
+ const ms = Math.max(5_000, this.cfg.quietPromptTimeoutMs || 90_000);
1167
2024
  this.capturingQuiet = true;
1168
2025
  this.quietCaptureBuf = "";
2026
+ let timedOut = false;
2027
+ let settled = false;
2028
+ const timer = setTimeout(() => {
2029
+ // Ignore timer if the prompt already settled (avoids discarding a good JSON
2030
+ // reply that finished in the same tick as the timeout).
2031
+ if (settled) return;
2032
+ timedOut = true;
2033
+ log.warn(
2034
+ `chat ${this.chatId}: quiet meta prompt timed out after ${ms}ms — cancelling session prompt`,
2035
+ );
2036
+ // Session-scoped cancel only — never kill the shared agent process.
2037
+ void this.acp.cancel(this.sessionId!);
2038
+ }, ms);
2039
+ let buf = "";
1169
2040
  try {
1170
- await this.acp.prompt(this.sessionId, [{ type: "text", text: prompt }]);
1171
- return parseSuggestions(this.quietCaptureBuf);
2041
+ await this.acp.prompt(this.sessionId, [{ type: "text", text }]);
2042
+ settled = true;
2043
+ clearTimeout(timer);
2044
+ buf = this.quietCaptureBuf;
2045
+ } catch (e) {
2046
+ settled = true;
2047
+ clearTimeout(timer);
2048
+ buf = this.quietCaptureBuf;
2049
+ if (!timedOut) throw e;
2050
+ log.debug(`quiet prompt ended after timeout: ${(e as Error).message}`);
1172
2051
  } finally {
2052
+ clearTimeout(timer);
1173
2053
  this.capturingQuiet = false;
1174
2054
  this.quietCaptureBuf = "";
1175
2055
  }
2056
+ // Timed-out meta replies are often half-JSON — skip rather than act on garbage.
2057
+ // If we settled successfully before the timer fired, timedOut stays false.
2058
+ if (timedOut) return "";
2059
+ return buf;
1176
2060
  }
1177
2061
 
1178
2062
  /** Resolve a tapped suggestion button; returns the prompt text or undefined. */
@@ -1270,7 +2154,8 @@ export class SessionRuntime {
1270
2154
  reasoning: reasoningDirective(this.reasoning),
1271
2155
  priming: this.primingContext,
1272
2156
  imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
1273
- progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
2157
+ progress:
2158
+ !this.managerMode && this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
1274
2159
  });
1275
2160
  this.primingContext = undefined;
1276
2161
  log.info(
@@ -1333,7 +2218,8 @@ export class SessionRuntime {
1333
2218
  reasoning: reasoningDirective(this.reasoning),
1334
2219
  priming: transcript ? buildPriming(transcript) : undefined,
1335
2220
  imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
1336
- progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
2221
+ progress:
2222
+ !this.managerMode && this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
1337
2223
  });
1338
2224
  return this.runPromptWithRetries(forkContent);
1339
2225
  }
@@ -1385,7 +2271,8 @@ export class SessionRuntime {
1385
2271
  reasoning: reasoningDirective(this.reasoning),
1386
2272
  priming: transcript ? buildPriming(transcript) : undefined,
1387
2273
  imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
1388
- progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
2274
+ progress:
2275
+ !this.managerMode && this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
1389
2276
  });
1390
2277
  return this.runPromptWithRetries(content);
1391
2278
  }
@@ -1432,7 +2319,8 @@ export class SessionRuntime {
1432
2319
  reasoning: reasoningDirective(this.reasoning),
1433
2320
  priming: transcript ? buildPriming(transcript) : undefined,
1434
2321
  imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
1435
- progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
2322
+ progress:
2323
+ !this.managerMode && this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
1436
2324
  });
1437
2325
  log.info(
1438
2326
  `chat ${this.chatId} auto-rotating to account ${t.label}` +
@@ -1479,6 +2367,11 @@ export class SessionRuntime {
1479
2367
  try {
1480
2368
  const updatesBeforePrompt = this.sessionUpdateCount;
1481
2369
  const result = await this.acp.prompt(this.sessionId!, content);
2370
+ // User /stop force-complete or agent honouring session/cancel often
2371
+ // returns cancelled with zero session/update chunks — that is success.
2372
+ if (this.cancelled || result?.stopReason === "cancelled") {
2373
+ return { result: result ?? { stopReason: "cancelled" }, attempts: attempt };
2374
+ }
1482
2375
  // A healthy ACP turn emits at least one session/update (text, thought,
1483
2376
  // or tool event) before resolving session/prompt. Grok can otherwise
1484
2377
  // report a successful end-turn after an upstream model failure; never
@@ -1557,7 +2450,8 @@ export class SessionRuntime {
1557
2450
  const resumeContent = buildContentBlocks(textPrompt(RESUME_INSTRUCTION), {
1558
2451
  reasoning: reasoningDirective(this.reasoning),
1559
2452
  imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
1560
- progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
2453
+ progress:
2454
+ !this.managerMode && this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
1561
2455
  });
1562
2456
 
1563
2457
  let last = final;
@@ -1604,6 +2498,7 @@ export class SessionRuntime {
1604
2498
  already: this.sentImagesThisTurn,
1605
2499
  max: this.cfg.agentImagesMax,
1606
2500
  replyTo: this.turnReplyTo,
2501
+ messageThreadId: this.messageThreadId,
1607
2502
  });
1608
2503
  if (n > 0) log.info(`chat ${this.chatId}: sent ${n} agent image file(s)`);
1609
2504
  } catch {
@@ -1627,6 +2522,111 @@ export class SessionRuntime {
1627
2522
  return `\u{1F4E8} From other session ${this.sessionTag()}\n${head}\n${summarizeFileOpsShort(this.fileOps)}\n\n${tags}`;
1628
2523
  }
1629
2524
 
2525
+ /** Soft completion for General manager (no file-ops / progress spam). */
2526
+ private managerCompletionMessage(
2527
+ stopReason: string | undefined,
2528
+ startedAt: number,
2529
+ streamedOutput: boolean,
2530
+ ): string {
2531
+ const elapsed = fmtDuration(Date.now() - startedAt);
2532
+ if (this.cancelled || stopReason === "cancelled") {
2533
+ const msg = `\u23F9 Stopped \u00B7 ${elapsed}`;
2534
+ this.lastCompletion = msg;
2535
+ return streamedOutput ? "" : msg;
2536
+ }
2537
+ // When prose already streamed, no extra Done line (chat-like).
2538
+ if (streamedOutput) {
2539
+ this.lastCompletion = this.turnAssistantText.slice(0, 800) || `\u2705 Done \u00B7 ${elapsed}`;
2540
+ return "";
2541
+ }
2542
+ const msg = `\u2705 Done \u00B7 ${elapsed}`;
2543
+ this.lastCompletion = msg;
2544
+ return msg;
2545
+ }
2546
+
2547
+ /**
2548
+ * True when the next queued prompt is a continuation of the *same* manager
2549
+ * job (self-recheck / bridge results / suggestion batch), not a new user
2550
+ * ask or a different send_prompt dispatch.
2551
+ */
2552
+ private queueIsSameJobContinuation(jobId: string): boolean {
2553
+ if (this.queue.length === 0) return false;
2554
+ const next = this.queue[0]!;
2555
+ if (next.reportBack && next.reportBack.jobId !== jobId) return false;
2556
+ if (next.reportBack && next.reportBack.jobId === jobId) return true;
2557
+ // Meta continuations omit reportBack but keep the open job.
2558
+ return (
2559
+ !!next.skipSelfRecheck ||
2560
+ isSelfRecheckPrompt(next.text) ||
2561
+ isTelegramBridgeResultsPrompt(next.text) ||
2562
+ isManagerWorkReportPrompt(next.text)
2563
+ );
2564
+ }
2565
+
2566
+ /**
2567
+ * If this runtime was dispatched from General, wake the manager with a
2568
+ * structured WORK REPORT once for this job. Waits only for same-job meta
2569
+ * follow-ups; reports immediately when a different dispatch/user turn is next.
2570
+ */
2571
+ private async maybeReportBackToManager(opts: {
2572
+ ok: boolean;
2573
+ cancelled: boolean;
2574
+ stopReason?: string;
2575
+ error?: string;
2576
+ }): Promise<void> {
2577
+ const meta = this.pendingReportBack;
2578
+ if (!meta || !this.bridge?.wakeManager) return;
2579
+ // Same-job recheck / bridge results / suggestions still pending — wait.
2580
+ if (this.queueIsSameJobContinuation(meta.jobId)) return;
2581
+
2582
+ const status = opts.cancelled ? "cancelled" : opts.ok ? "done" : "failed";
2583
+ const assistantSummary = (
2584
+ this.turnAssistantText.trim() ||
2585
+ this.lastCompletion ||
2586
+ "(no assistant text)"
2587
+ ).replace(/\s+/g, " ").trim();
2588
+ const filesSummary =
2589
+ this.fileOps.size > 0 ? summarizeFileOpsShort(this.fileOps) : undefined;
2590
+
2591
+ updateManagerJob(meta.jobId, {
2592
+ status: status === "done" ? "done" : status === "cancelled" ? "cancelled" : "failed",
2593
+ resultSummary: assistantSummary.slice(0, 400),
2594
+ childSessionId: this.sessionId,
2595
+ });
2596
+
2597
+ const prompt = buildManagerWorkReportPrompt({
2598
+ jobId: meta.jobId,
2599
+ targetName: meta.targetName || this.projectName || basename(this.cwd),
2600
+ targetThreadId: this.messageThreadId ?? 0,
2601
+ targetPath: meta.targetPath || this.cwd,
2602
+ userAskPreview: meta.userAskPreview,
2603
+ dispatchPromptPreview: meta.dispatchPrompt,
2604
+ status,
2605
+ stopReason: opts.stopReason,
2606
+ error: opts.error,
2607
+ assistantSummary,
2608
+ filesSummary,
2609
+ childSessionId: this.sessionId,
2610
+ });
2611
+
2612
+ // Clear before await so a re-entry cannot double-report this job.
2613
+ this.pendingReportBack = undefined;
2614
+ try {
2615
+ await this.bridge.wakeManager({
2616
+ originChatId: meta.originChatId,
2617
+ originThreadId: meta.originThreadId,
2618
+ prompt,
2619
+ });
2620
+ log.info(
2621
+ `report-back job ${meta.jobId} → general (#${meta.originThreadId}) status=${status}`,
2622
+ );
2623
+ } catch (e) {
2624
+ log.warn(`report-back failed for job ${meta.jobId}: ${(e as Error).message}`);
2625
+ // Restore so a later turn might retry once if still attached.
2626
+ this.pendingReportBack = meta;
2627
+ }
2628
+ }
2629
+
1630
2630
  /**
1631
2631
  * Final Done after a self-recheck: head + split file lists (first turn vs recheck).
1632
2632
  */
@@ -1693,12 +2693,16 @@ export class SessionRuntime {
1693
2693
  }
1694
2694
 
1695
2695
  /** Searchable Telegram hashtags so you can pull up every message of a session
1696
- * or project by tapping the tag. */
2696
+ * or project (and this turn's prompt) by tapping the tag. */
1697
2697
  private hashtags(): string {
2698
+ // General manager: no tags (user requested clean chat). Reply routing uses
2699
+ // Telegram message-id → session map, not #sess_ footers.
2700
+ if (this.managerMode) return "";
1698
2701
  return sessionHashtags({
1699
2702
  projectName: this.projectName,
1700
2703
  cwd: this.cwd,
1701
2704
  sessionId: this.sessionId,
2705
+ promptId: this.turnPromptId,
1702
2706
  });
1703
2707
  }
1704
2708
 
@@ -1710,11 +2714,17 @@ export class SessionRuntime {
1710
2714
  const head = this.queue[0]!;
1711
2715
  const isMeta =
1712
2716
  !!head.skipSelfRecheck ||
1713
- isSelfRecheckPrompt(head.text);
2717
+ isSelfRecheckPrompt(head.text) ||
2718
+ isTelegramBridgeResultsPrompt(head.text);
1714
2719
  const batch = isMeta
1715
2720
  ? this.queue.shift()!
1716
2721
  : mergeInputs(this.queue.splice(0, this.queue.length));
1717
- if (this.foreground) await this.notify("\u25B6\uFE0F Processing queued message\u2026");
2722
+ // Real user follow-ups only — never spam chat for bridge/recheck meta turns.
2723
+ if (this.foreground && !isMeta) {
2724
+ await this.notify("\u25B6\uFE0F Processing queued message\u2026", {
2725
+ replyTo: batch.replyTo,
2726
+ });
2727
+ }
1718
2728
  void this.runTurn(batch);
1719
2729
  }
1720
2730
 
@@ -1761,7 +2771,10 @@ export class SessionRuntime {
1761
2771
  }
1762
2772
  } else if (kind === "agent_thought_chunk") {
1763
2773
  const text = contentText(update.content);
1764
- if (text?.trim()) this.setLiveStep(stepFromThought(text));
2774
+ if (text?.trim()) {
2775
+ this.appendCardThinking(text);
2776
+ this.setLiveStep(stepFromThought(text));
2777
+ }
1765
2778
  } else if (kind === "plan") {
1766
2779
  // Always track plan entries (background too) so switch-to-live restores the board.
1767
2780
  const entries = parsePlanUpdate(update);
@@ -1836,7 +2849,7 @@ export class SessionRuntime {
1836
2849
 
1837
2850
  private persist(): void {
1838
2851
  if (!this.foreground) return; // only the foreground session is the chat's restored default
1839
- this.settings.update(this.chatId, {
2852
+ this.settings.updateKey(this.settingsKey, {
1840
2853
  projectPath: this.cwd,
1841
2854
  projectName: this.projectName,
1842
2855
  sessionId: this.sessionId,
@@ -1859,20 +2872,65 @@ export class SessionRuntime {
1859
2872
  }
1860
2873
  }
1861
2874
 
2875
+ /**
2876
+ * Send a chat message. Returns Telegram message_id on success.
2877
+ * On failure, retries once truncated (~3500) without reply_markup so a long
2878
+ * Done / markup error cannot silently drop the completion ping.
2879
+ */
1862
2880
  private async notify(
1863
2881
  text: string,
1864
2882
  opts?: { loud?: boolean; replyTo?: number; replyMarkup?: InlineKeyboard },
2883
+ ): Promise<number | undefined> {
2884
+ const send = async (body: string, withMarkup: boolean): Promise<number | undefined> => {
2885
+ try {
2886
+ const extra: Record<string, unknown> = {
2887
+ ...(opts?.loud ? { disable_notification: false } : {}),
2888
+ // Never pass message_thread_id=1 (General) — Telegram rejects it.
2889
+ ...outboundThreadExtra(this.messageThreadId),
2890
+ };
2891
+ if (opts?.replyTo !== undefined) {
2892
+ extra.reply_parameters = { message_id: opts.replyTo, allow_sending_without_reply: true };
2893
+ }
2894
+ if (withMarkup && opts?.replyMarkup) extra.reply_markup = opts.replyMarkup;
2895
+ const msg = await this.api.sendMessage(this.chatId, body, extra);
2896
+ if (this.sessionId) this.onTelegramMessageBound?.(msg.message_id, this.sessionId);
2897
+ return msg.message_id;
2898
+ } catch (e) {
2899
+ log.debug("notify failed:", (e as Error).message);
2900
+ return undefined;
2901
+ }
2902
+ };
2903
+ const id = await send(text, true);
2904
+ if (id !== undefined) return id;
2905
+ const short = text.length > 3500 ? text.slice(0, 3499) + "\u2026" : text;
2906
+ return send(short, false);
2907
+ }
2908
+
2909
+ /**
2910
+ * After Done was already sent, attach suggestion text + buttons by editing
2911
+ * that message (or sending a follow-up if edit fails / no message id).
2912
+ */
2913
+ private async enhanceDoneMessage(
2914
+ messageId: number | undefined,
2915
+ text: string,
2916
+ markup: InlineKeyboard | undefined,
1865
2917
  ): 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 };
2918
+ if (messageId !== undefined) {
2919
+ try {
2920
+ // editMessageText does not need message_thread_id; keep markup only.
2921
+ const extra: Record<string, unknown> = {};
2922
+ if (markup) extra.reply_markup = markup;
2923
+ await this.api.editMessageText(this.chatId, messageId, text, extra);
2924
+ return;
2925
+ } catch (e) {
2926
+ log.debug("enhanceDoneMessage edit failed:", (e as Error).message);
1870
2927
  }
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
2928
  }
2929
+ await this.notify(text, {
2930
+ loud: false,
2931
+ replyTo: this.turnReplyTo,
2932
+ replyMarkup: markup,
2933
+ });
1876
2934
  }
1877
2935
 
1878
2936
  private async onWatchEntries(entries: HistoryEntry[]): Promise<void> {
@@ -1885,8 +2943,47 @@ export class SessionRuntime {
1885
2943
  })
1886
2944
  .filter(Boolean)
1887
2945
  .join("\n\n");
1888
- if (body.trim()) await sendMarkdownDoc(this.api, this.chatId, `${body}\n\n${this.tags}`);
2946
+ if (body.trim()) {
2947
+ await sendMarkdownDoc(this.api, this.chatId, `${body}\n\n${this.tags}`, {
2948
+ messageThreadId: this.messageThreadId,
2949
+ });
2950
+ }
2951
+ }
2952
+ }
2953
+
2954
+ /** Visible manager reply body (no progress markers / telegram action fences). */
2955
+ function cleanManagerVisibleText(raw: string): string {
2956
+ if (!raw?.trim()) return "";
2957
+ const withoutTg = stripTelegramActionFences(raw);
2958
+ return extractProgress(withoutTg).cleaned.trim();
2959
+ }
2960
+
2961
+ /**
2962
+ * One short user-facing fallback when General forgot `notify`.
2963
+ * Drops empty, table-spam, and pure status narration.
2964
+ */
2965
+ export function pickManagerFallbackText(cleaned: string): string | undefined {
2966
+ let t = cleaned.replace(/\r\n/g, "\n").trim();
2967
+ if (!t) return undefined;
2968
+ // Drop markdown tables and multi-line job dumps.
2969
+ if (/^\s*\|.+\|/m.test(t) && (t.match(/\|/g) || []).length >= 6) return undefined;
2970
+ // Collapse whitespace.
2971
+ t = t.replace(/\n{3,}/g, "\n\n").trim();
2972
+ // Prefer first 1–2 short paragraphs.
2973
+ const paras = t.split(/\n\n+/).map((p) => p.trim()).filter(Boolean);
2974
+ let out = paras.slice(0, 2).join("\n\n");
2975
+ if (out.length > 600) out = out.slice(0, 597) + "\u2026";
2976
+ // Ignore pure meta / empty placeholders.
2977
+ if (/^(thinking|starting|ok|done|\.+|\u2026)+$/i.test(out.trim())) return undefined;
2978
+ if (out.length < 2) return undefined;
2979
+ // Skip "Dispatching… / Sending to…" spam patterns if that is all we got.
2980
+ if (
2981
+ /^(dispatching|sending to|queued|cancelling|already running)\b/i.test(out) &&
2982
+ out.length < 280
2983
+ ) {
2984
+ return undefined;
1889
2985
  }
2986
+ return out;
1890
2987
  }
1891
2988
 
1892
2989
  /** Format an elapsed duration compactly (e.g. "8s", "2m 13s", "1h 4m"). */