grok-telegram-bot 2.5.0 → 2.7.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 (50) hide show
  1. package/.env.example +13 -0
  2. package/CHANGELOG.md +106 -0
  3. package/README.md +20 -5
  4. package/docs/GROUP.md +39 -4
  5. package/docs/INSTALL.md +2 -0
  6. package/package.json +4 -4
  7. package/scripts/setup.mjs +20 -3
  8. package/src/app/instance.ts +223 -0
  9. package/src/app/types.ts +34 -1
  10. package/src/bot/ask-user-service.ts +226 -0
  11. package/src/bot/auth.ts +5 -1
  12. package/src/bot/bot.ts +105 -4
  13. package/src/bot/chat-controller.ts +129 -0
  14. package/src/bot/commands.ts +22 -2
  15. package/src/bot/group-memory.ts +192 -12
  16. package/src/bot/handlers/forum.ts +16 -6
  17. package/src/bot/handlers/grok-slash.ts +336 -0
  18. package/src/bot/handlers/message.ts +165 -25
  19. package/src/bot/handlers/photo.ts +4 -1
  20. package/src/bot/handlers/system.ts +63 -1
  21. package/src/bot/image-return.ts +4 -1
  22. package/src/bot/manager-context.ts +208 -0
  23. package/src/bot/manager-jobs.ts +142 -0
  24. package/src/bot/menu/ephemeral.ts +4 -1
  25. package/src/bot/plan-exit-service.ts +169 -0
  26. package/src/bot/prompt-anchor.ts +2 -3
  27. package/src/bot/prompt-content.ts +5 -0
  28. package/src/bot/registry.ts +11 -2
  29. package/src/bot/scope.ts +9 -8
  30. package/src/bot/session-runtime.ts +665 -55
  31. package/src/bot/telegram-actions.ts +728 -38
  32. package/src/bot/telegram-bots.ts +2 -1
  33. package/src/bot/telegram-io.ts +4 -1
  34. package/src/cli.ts +43 -7
  35. package/src/config.ts +35 -25
  36. package/src/forum/manager.ts +2 -1
  37. package/src/forum/thread.ts +33 -0
  38. package/src/grok/client.ts +29 -5
  39. package/src/grok/plan-approval.ts +8 -0
  40. package/src/index.ts +4 -0
  41. package/src/render/manager-directive.ts +137 -0
  42. package/src/render/session-comment.ts +10 -0
  43. package/src/render/telegram-bridge.ts +118 -14
  44. package/src/service/linux.ts +21 -15
  45. package/src/service/macos.ts +20 -15
  46. package/src/service/platform.ts +12 -3
  47. package/src/service/types.ts +6 -0
  48. package/src/service/windows.ts +31 -22
  49. package/src/sessions/history.ts +18 -0
  50. package/src/stream/streamer.ts +46 -10
@@ -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";
@@ -63,14 +63,30 @@ import {
63
63
  suggestionsKeyboard,
64
64
  } from "./suggestions.js";
65
65
  import type { ForumManager } from "../forum/manager.js";
66
+ import { isGeneralThread, outboundThreadExtra } from "../forum/thread.js";
66
67
  import type { SessionStore } from "../sessions/store.js";
67
68
  import type { TelegramBotService } from "./telegram-bots.js";
68
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";
69
84
  import {
70
85
  buildTelegramBridgeDirective,
71
86
  buildTelegramBridgeResultsPrompt,
72
87
  extractTelegramActions,
73
88
  isTelegramBridgeResultsPrompt,
89
+ stripTelegramActionFences,
74
90
  wrapTelegramBridgePrompt,
75
91
  } from "../render/telegram-bridge.js";
76
92
  import {
@@ -206,7 +222,23 @@ export class SessionRuntime {
206
222
  bots: TelegramBotService;
207
223
  /** Cross-topic prompt dispatch (create_topic → send_prompt orchestration). */
208
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>;
209
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;
210
242
  /** Last credits total reported for this session (for per-turn delta accounting). */
211
243
  private lastReportedCredits = 0;
212
244
  /** Live "what is happening now" line while a turn is in flight (tools/plan). */
@@ -227,6 +259,15 @@ export class SessionRuntime {
227
259
  /** Quiet meta capture (suggestions) — never stream to Telegram. */
228
260
  private capturingQuiet = false;
229
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;
230
271
  /**
231
272
  * Done delivery bookkeeping for this turn: expect a loud Done ping, and whether
232
273
  * one was successfully sent (finally forces a short Done if expected but missing).
@@ -269,6 +310,15 @@ export class SessionRuntime {
269
310
  readonly messageThreadId: number | undefined;
270
311
  /** Settings storage key (`chatId` or `chatId:t{threadId}`). */
271
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;
272
322
 
273
323
  constructor(
274
324
  private readonly api: Api,
@@ -286,6 +336,9 @@ export class SessionRuntime {
286
336
  ) {
287
337
  this.messageThreadId = init?.messageThreadId;
288
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);
289
342
  if (init) {
290
343
  this.cwd = init.cwd;
291
344
  this.projectName = init.projectName;
@@ -328,9 +381,27 @@ export class SessionRuntime {
328
381
 
329
382
  /** Latest task-completion % (0–100) parsed this turn, or undefined if none. */
330
383
  get taskProgress(): number | undefined {
384
+ // Manager chat never shows a progress bar.
385
+ if (this.managerMode) return undefined;
331
386
  return this.progress;
332
387
  }
333
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
+
334
405
  /**
335
406
  * Full plan board for the live stream / status panel (above the progress bar).
336
407
  * Empty when no plan is active this turn.
@@ -463,8 +534,8 @@ export class SessionRuntime {
463
534
  if (value) {
464
535
  // A turn was started here and is still in flight, but its streamer was
465
536
  // finalized when we went background. Recreate it and let onUpdate feed
466
- // the remaining chunks/thoughts/tools just like a normal live turn — we
467
- // 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).
468
539
  if (this.busy && !this.streamer) {
469
540
  // Any transient follow-watch of this session is now superseded.
470
541
  if (this.watchIsFollow) this.stopWatch();
@@ -474,10 +545,11 @@ export class SessionRuntime {
474
545
  this.cfg.streamThrottleMs,
475
546
  this.turnReplyTo,
476
547
  this.hashtags(),
477
- (pct) => this.setProgress(pct),
478
- this.cfg.progressFallback,
548
+ this.managerMode ? undefined : (pct) => this.setProgress(pct),
549
+ this.managerMode ? false : this.cfg.progressFallback,
479
550
  this.turnStartedAt,
480
551
  this.messageThreadId,
552
+ this.managerMode ? { proseOnly: true, showProgressBar: false } : undefined,
481
553
  );
482
554
  // Restore the live plan board so steps stay visible above the progress bar.
483
555
  if (this.planEntries?.length) {
@@ -488,6 +560,7 @@ export class SessionRuntime {
488
560
  } else {
489
561
  this.typing.stop();
490
562
  this.stopWatch();
563
+ // Seal live bubble when demoted (manager has no streamer).
491
564
  if (this.streamer) {
492
565
  // Finalize off the critical path so project/session switches never wait
493
566
  // on Telegram edits of the previous live stream.
@@ -703,14 +776,22 @@ export class SessionRuntime {
703
776
 
704
777
  async submit(input: PromptInput): Promise<"ran" | "queued"> {
705
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
+ }
706
787
  if (this.busy) {
707
- this.queue.push(input);
788
+ this.queue.push(toSubmit);
708
789
  this.changed();
709
790
  return "queued";
710
791
  }
711
792
  // First-prompt steering is applied inside runTurn so queued first messages
712
793
  // (and flushQueue) get the same complexity + telegram bridge directives.
713
- void this.runTurn(input);
794
+ void this.runTurn(toSubmit);
714
795
  return "ran";
715
796
  }
716
797
 
@@ -726,16 +807,26 @@ export class SessionRuntime {
726
807
  topicGroupId: this.cfg.topicGroupId,
727
808
  allowedBots: this.cfg.allowedTelegramBots,
728
809
  botCommands: this.cfg.telegramBotCommands,
810
+ managerMode: this.managerMode,
729
811
  });
730
812
  }
731
813
 
732
814
  /**
733
815
  * Complexity + telegram bridge teaching on the first prompt of a brand-new
734
816
  * conversation only (no prior user turns in this process / session jsonl).
817
+ * Manager mode uses MANAGER_DIRECTIVE instead of complexity/progress coding UX.
735
818
  */
736
819
  private applyFirstPromptSteering(input: PromptInput): PromptInput {
737
820
  if (!this.shouldSteerFirstPrompt(input)) return input;
738
- let toRun = wrapAutoComplexityPrompt(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);
739
830
  toRun = wrapTelegramBridgePrompt(toRun, this.telegramBridgeDirective());
740
831
  this.markFirstPromptSteered();
741
832
  log.info(`chat ${this.chatId}: first-prompt complexity + telegram bridge applied`);
@@ -755,8 +846,10 @@ export class SessionRuntime {
755
846
  // Never wrap meta follow-ups even if somehow first.
756
847
  if (
757
848
  input.skipSelfRecheck ||
849
+ input.rawSlashCommand ||
758
850
  isSelfRecheckPrompt(input.text) ||
759
- isTelegramBridgeResultsPrompt(input.text)
851
+ isTelegramBridgeResultsPrompt(input.text) ||
852
+ isManagerWorkReportPrompt(input.text)
760
853
  ) {
761
854
  return false;
762
855
  }
@@ -774,6 +867,31 @@ export class SessionRuntime {
774
867
  return true;
775
868
  }
776
869
 
870
+ /** Memory + topic catalog inject for every real manager user turn. */
871
+ private applyManagerContext(input: PromptInput): PromptInput {
872
+ if (!this.managerMode) return input;
873
+ if (
874
+ input.rawSlashCommand ||
875
+ isTelegramBridgeResultsPrompt(input.text) ||
876
+ isManagerWorkReportPrompt(input.text) ||
877
+ isSelfRecheckPrompt(input.text)
878
+ ) {
879
+ return input;
880
+ }
881
+ if (!this.bridge) return input;
882
+ const userText = stripDirectiveWrappers(input.text) || input.text;
883
+ const block = buildManagerContextBlock({
884
+ userText,
885
+ sessionsDir: this.cfg.sessionsDir,
886
+ store: this.bridge.store,
887
+ forum: this.bridge.forum,
888
+ });
889
+ return {
890
+ ...input,
891
+ text: injectManagerContext(input.text, block),
892
+ };
893
+ }
894
+
777
895
  /**
778
896
  * Stop the current turn for this runtime only.
779
897
  * Soft ACP cancel + session-scoped force-complete; never kills the shared
@@ -903,7 +1021,12 @@ export class SessionRuntime {
903
1021
  private async runTurn(input: PromptInput): Promise<void> {
904
1022
  // Apply before any turn bookkeeping so card previews / logs see the wrapped
905
1023
  // text the same way the agent does (also covers flushQueue first messages).
906
- input = this.applyFirstPromptSteering(input);
1024
+ try {
1025
+ input = this.applyFirstPromptSteering(input);
1026
+ input = this.applyManagerContext(input);
1027
+ } catch (e) {
1028
+ log.warn(`prompt steering/context failed: ${(e as Error).message}`);
1029
+ }
907
1030
 
908
1031
  this.busy = true;
909
1032
  this.cancelled = false;
@@ -915,19 +1038,29 @@ export class SessionRuntime {
915
1038
  this.turnExpectDone = false;
916
1039
  this.turnDonePinged = false;
917
1040
  this.isSelfRecheckTurn = isSelfRecheckPrompt(input.text);
918
- // Meta turns (recheck, bridge results, auto-suggestion batches) never arm another recheck.
1041
+ // Meta turns (recheck, bridge results, work reports) never arm another recheck.
919
1042
  const isBridgeResults = isTelegramBridgeResultsPrompt(input.text);
1043
+ const isWorkReport = isManagerWorkReportPrompt(input.text);
1044
+ // Keep the Thinking… bubble across search_memory → results follow-ups so
1045
+ // the user is not left with a deleted placeholder and no reply.
1046
+ if (this.managerStatusMsgId !== undefined && !isBridgeResults) {
1047
+ const orphan = this.managerStatusMsgId;
1048
+ this.managerStatusMsgId = undefined;
1049
+ void this.deleteManagerStatus(orphan);
1050
+ }
920
1051
  this.skipSelfRecheck =
921
1052
  !!input.skipSelfRecheck ||
922
1053
  this.isSelfRecheckTurn ||
923
- isBridgeResults;
1054
+ isBridgeResults ||
1055
+ isWorkReport ||
1056
+ this.managerMode;
924
1057
  // Fresh user work resets bridge-chain depth + suggestion anchors.
925
- if (!this.isSelfRecheckTurn && !isBridgeResults) {
1058
+ if (!this.isSelfRecheckTurn && !isBridgeResults && !isWorkReport) {
926
1059
  this.bridgeResultDepth = 0;
927
1060
  }
928
1061
  // Fresh user work resets suggestion anchors; recheck / bridge results keep the original ask.
929
1062
  // Strip complexity/reply wrappers so suggestions + recheck see the real ask.
930
- if (!this.isSelfRecheckTurn && !isBridgeResults) {
1063
+ if (!this.isSelfRecheckTurn && !isBridgeResults && !isWorkReport) {
931
1064
  this.suggestionUserText = stripDirectiveWrappers(input.text) || input.text;
932
1065
  this.preRecheckAssistantText = "";
933
1066
  this.preRecheckFileOps = new Map();
@@ -942,6 +1075,15 @@ export class SessionRuntime {
942
1075
  this.setSessionComment(preview);
943
1076
  }
944
1077
  }
1078
+ // Bind THIS prompt's report-back (manager dispatch). Meta follow-ups
1079
+ // (recheck / bridge results) omit reportBack and keep the prior job until
1080
+ // the queue drains and we report once.
1081
+ if (input.reportBack) {
1082
+ this.pendingReportBack = input.reportBack as ReportBackMeta;
1083
+ }
1084
+ if (this.pendingReportBack && this.sessionId) {
1085
+ bindJobSession(this.pendingReportBack.jobId, this.sessionId);
1086
+ }
945
1087
  this.shownToolIds = new Set();
946
1088
  this.toolCallCache = new Map();
947
1089
  this.fileOps = new Map();
@@ -961,9 +1103,33 @@ export class SessionRuntime {
961
1103
  // A new streamed turn supersedes any transient "follow" watch of this same
962
1104
  // session's previous in-flight turn (avoids duplicated output).
963
1105
  if (this.watchIsFollow) this.stopWatch();
964
- const live = this.foreground;
1106
+ // Manager (General): stream short chat prose (no tools/progress). Work
1107
+ // reports stay quiet. Bridge-result follow-ups ARE user-facing — that is
1108
+ // usually when the manager answers after search_memory.
1109
+ const managerSilent =
1110
+ this.managerMode && (isWorkReport || this.isSelfRecheckTurn);
1111
+ const live = this.foreground && !managerSilent;
965
1112
  const startedAt = Date.now();
966
1113
  this.turnStartedAt = startedAt;
1114
+ this.managerNotifyCount = 0;
1115
+ this.managerUserVisible = false;
1116
+ // General: Starting… (from message handler) → Thinking… then stream into it.
1117
+ // Work-report wakes stay fully silent (no bubble).
1118
+ const managerMeta = managerSilent;
1119
+ let thinkingMsgId: number | undefined = input.seedMessageId ?? this.managerStatusMsgId;
1120
+ if (this.managerMode && !managerSilent && this.turnReplyTo !== undefined) {
1121
+ if (thinkingMsgId !== undefined) {
1122
+ await this.editManagerStatus(thinkingMsgId, "Thinking\u2026");
1123
+ } else {
1124
+ thinkingMsgId = await this.postManagerStatus(this.turnReplyTo, "Thinking\u2026");
1125
+ }
1126
+ this.managerStatusMsgId = thinkingMsgId;
1127
+ } else if (this.managerMode && managerSilent && thinkingMsgId !== undefined && !isBridgeResults) {
1128
+ // Drop Starting… leftover on silent work-report / recheck turns.
1129
+ await this.deleteManagerStatus(thinkingMsgId);
1130
+ thinkingMsgId = undefined;
1131
+ this.managerStatusMsgId = undefined;
1132
+ }
967
1133
  this.streamer = live
968
1134
  ? new ResponseStreamer(
969
1135
  this.api,
@@ -971,13 +1137,30 @@ export class SessionRuntime {
971
1137
  this.cfg.streamThrottleMs,
972
1138
  this.turnReplyTo,
973
1139
  this.hashtags(),
974
- (pct) => this.setProgress(pct),
975
- this.cfg.progressFallback,
1140
+ this.managerMode ? undefined : (pct) => this.setProgress(pct),
1141
+ this.managerMode ? false : this.cfg.progressFallback,
976
1142
  startedAt,
977
1143
  this.messageThreadId,
1144
+ this.managerMode
1145
+ ? {
1146
+ proseOnly: true,
1147
+ showProgressBar: false,
1148
+ seedMessageId: thinkingMsgId,
1149
+ }
1150
+ : undefined,
978
1151
  )
979
1152
  : undefined;
980
- if (live) this.typing.start();
1153
+ // Bind user message (+ status bubble) → session for reply routing.
1154
+ if (this.managerMode && this.sessionId) {
1155
+ if (this.turnReplyTo !== undefined) {
1156
+ this.onTelegramMessageBound?.(this.turnReplyTo, this.sessionId);
1157
+ }
1158
+ if (thinkingMsgId !== undefined) {
1159
+ this.onTelegramMessageBound?.(thinkingMsgId, this.sessionId);
1160
+ }
1161
+ }
1162
+ // Typing indicator for user-facing manager turns and live project streams.
1163
+ if (live || (this.managerMode && !managerMeta)) this.typing.start();
981
1164
  this.activity(true);
982
1165
  this.changed();
983
1166
  this.imageScanText = "";
@@ -986,8 +1169,13 @@ export class SessionRuntime {
986
1169
  const content = buildContentBlocks(input, {
987
1170
  reasoning: reasoningDirective(this.reasoning),
988
1171
  priming: this.primingContext,
989
- imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
990
- progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
1172
+ imageOutput:
1173
+ !this.managerMode && this.cfg.sendAgentImages
1174
+ ? IMAGE_OUTPUT_DIRECTIVE
1175
+ : undefined,
1176
+ // Manager chat: no progress spam; project topics keep the usual directive.
1177
+ progress:
1178
+ !this.managerMode && this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
991
1179
  });
992
1180
  this.primingContext = undefined;
993
1181
 
@@ -1054,8 +1242,8 @@ export class SessionRuntime {
1054
1242
  // spam to the chat (live step / status panel only).
1055
1243
  const pingDone =
1056
1244
  canPing && (this.foreground || !hasQueued) && !queuedBridgeResults;
1057
- // Expect a Done this turn unless we defer for recheck (bridge already excluded).
1058
- this.turnExpectDone = pingDone;
1245
+ // Manager uses notify/finishManagerUserFacing — never arm Done safety-net spam.
1246
+ this.turnExpectDone = pingDone && !this.managerMode;
1059
1247
 
1060
1248
  // One-shot self-recheck: only after a real *user* turn (not meta/auto),
1061
1249
  // with idle queue. skipSelfRecheck blocks loops after recheck / auto-batch.
@@ -1120,16 +1308,47 @@ export class SessionRuntime {
1120
1308
  }
1121
1309
  }
1122
1310
 
1311
+ // Manager: keep Thinking… while bridge results are chaining so the
1312
+ // follow-up turn can stream the real answer into the same bubble.
1313
+ if (this.managerMode && queuedBridgeResults && this.managerStatusMsgId !== undefined) {
1314
+ void this.editManagerStatus(this.managerStatusMsgId, "Thinking\u2026");
1315
+ }
1316
+
1123
1317
  if (!queuedRecheck && !queuedBridgeResults) {
1318
+ // Manager (General): quiet-by-default completion — no Done spam.
1319
+ // User-facing only via notify (already sent) or one fallback reply.
1320
+ if (this.managerMode) {
1321
+ await this.finishManagerUserFacing(managerMeta, final.result?.stopReason, startedAt);
1322
+ this.turnDonePinged = true;
1323
+ // Suggestions only when something was actually shown to the user.
1324
+ if (
1325
+ final.result &&
1326
+ !this.cancelled &&
1327
+ !hasQueued &&
1328
+ this.managerUserVisible &&
1329
+ !managerMeta
1330
+ ) {
1331
+ try {
1332
+ const baseForSug =
1333
+ cleanManagerVisibleText(this.turnAssistantText).slice(0, 500) || "Done";
1334
+ await this.collectAndApplySuggestions(baseForSug, undefined, {
1335
+ autoQueue: true,
1336
+ });
1337
+ } catch (e) {
1338
+ log.debug(`manager suggestions failed: ${(e as Error).message}`);
1339
+ }
1340
+ }
1341
+ } else {
1124
1342
  // Build Done text *now* (after quiet decision) so a cancel during the
1125
1343
  // recheck-decision wait shows ⏹ Stopped, not a stale ✅ Done head.
1126
1344
  let doneText = this.isSelfRecheckTurn
1127
- ? this.completionMessageSplit(final.result?.stopReason, startedAt, streamedOutput)
1128
- : this.completionMessage(final.result?.stopReason, startedAt, streamedOutput);
1345
+ ? this.completionMessageSplit(final.result?.stopReason, startedAt, streamedOutput)
1346
+ : this.completionMessage(final.result?.stopReason, startedAt, streamedOutput);
1129
1347
  // 1) Always send Done FIRST — never block the completion ping on the
1130
1348
  // quiet suggestions prompt (which can hang and look like "no Done").
1131
1349
  let doneMsgId: number | undefined;
1132
- if (pingDone) {
1350
+ const shouldPingDone = pingDone;
1351
+ if (shouldPingDone && doneText.trim()) {
1133
1352
  doneMsgId = await this.notify(doneText, {
1134
1353
  loud: true,
1135
1354
  replyTo: this.turnReplyTo,
@@ -1137,30 +1356,51 @@ export class SessionRuntime {
1137
1356
  });
1138
1357
  if (doneMsgId !== undefined) this.turnDonePinged = true;
1139
1358
  }
1140
- // 2) Suggestions after Done: edit the Done message (or send a follow-up).
1359
+ // 2) Suggestions: project topics keep Done-edit UX.
1141
1360
  if (final.result && !this.cancelled && !hasQueued) {
1142
1361
  try {
1143
- const sug = await this.collectAndApplySuggestions(doneText, switchKb, {
1144
- // Only auto-queue high-need follow-ups when the user is watching;
1145
- // background sessions store buttons for the Done ping / switch replay.
1146
- autoQueue: this.foreground,
1147
- });
1148
- // Only enhance when suggestions actually changed the Done body.
1149
- if (pingDone && sug.text !== doneText) {
1362
+ const baseForSug = doneText;
1363
+ const sug = await this.collectAndApplySuggestions(
1364
+ baseForSug,
1365
+ switchKb,
1366
+ {
1367
+ autoQueue: this.foreground,
1368
+ },
1369
+ );
1370
+ if (shouldPingDone && sug.text !== doneText) {
1150
1371
  await this.enhanceDoneMessage(doneMsgId, sug.text, sug.markup ?? switchKb);
1151
1372
  }
1152
1373
  } catch (e) {
1153
1374
  log.debug(`suggestions after Done failed: ${(e as Error).message}`);
1154
1375
  }
1155
1376
  }
1377
+ }
1156
1378
  // Clear frozen first-turn ops after final Done (recheck path done).
1157
1379
  if (this.isSelfRecheckTurn) this.preRecheckFileOps = new Map();
1380
+
1381
+ // Child work dispatched from General → wake manager with a report.
1382
+ // (Waits only for same-job meta follow-ups; see maybeReportBackToManager.)
1383
+ await this.maybeReportBackToManager({
1384
+ ok: !!final.result && !this.cancelled,
1385
+ cancelled: this.cancelled,
1386
+ stopReason: final.result?.stopReason,
1387
+ error: undefined,
1388
+ });
1158
1389
  }
1159
1390
  // queuedBridgeResults: stay quiet in chat — agent gets results via queue.
1160
1391
  } else if (final.error) {
1161
- // If the self-recheck pass itself failed, still surface Done for the
1162
- // original work (split files + suggestions) so the user is not stuck.
1163
- if (this.isSelfRecheckTurn && !hasQueued) {
1392
+ // Manager: one short important message (or edit Thinking…); never Done spam.
1393
+ if (this.managerMode) {
1394
+ await this.finishManagerError(final.error.message, startedAt);
1395
+ this.turnDonePinged = true;
1396
+ await this.maybeReportBackToManager({
1397
+ ok: false,
1398
+ cancelled: false,
1399
+ error: final.error.message,
1400
+ });
1401
+ } else if (this.isSelfRecheckTurn && !hasQueued) {
1402
+ // If the self-recheck pass itself failed, still surface Done for the
1403
+ // original work (split files + suggestions) so the user is not stuck.
1164
1404
  const switchKb = this.switchKeyboard();
1165
1405
  const pingDone = canPing && (this.foreground || !hasQueued);
1166
1406
  this.turnExpectDone = pingDone;
@@ -1189,6 +1429,13 @@ export class SessionRuntime {
1189
1429
  }
1190
1430
  }
1191
1431
  this.preRecheckFileOps = new Map();
1432
+ // Self-recheck failed after real work — still report manager job if any.
1433
+ await this.maybeReportBackToManager({
1434
+ ok: true,
1435
+ cancelled: this.cancelled,
1436
+ stopReason: "self_recheck_failed",
1437
+ error: final.error.message,
1438
+ });
1192
1439
  } else {
1193
1440
  const transient = isTransientError(final.error);
1194
1441
  const liveMsg = this.errorMessage(final.error, startedAt, final.attempts, transient);
@@ -1201,6 +1448,11 @@ export class SessionRuntime {
1201
1448
  });
1202
1449
  if (id !== undefined) this.turnDonePinged = true;
1203
1450
  }
1451
+ await this.maybeReportBackToManager({
1452
+ ok: false,
1453
+ cancelled: false,
1454
+ error: final.error.message,
1455
+ });
1204
1456
  }
1205
1457
  }
1206
1458
  } catch (err) {
@@ -1253,6 +1505,14 @@ export class SessionRuntime {
1253
1505
  }
1254
1506
  }
1255
1507
  this.preRecheckFileOps = new Map();
1508
+ } else if (this.managerMode) {
1509
+ await this.finishManagerError(errMsg, startedAt);
1510
+ this.turnDonePinged = true;
1511
+ await this.maybeReportBackToManager({
1512
+ ok: false,
1513
+ cancelled: this.cancelled,
1514
+ error: errMsg,
1515
+ });
1256
1516
  } else {
1257
1517
  const msg = `\u274C Error after ${fmtDuration(Date.now() - startedAt)}: ${errMsg}`;
1258
1518
  this.lastCompletion = msg;
@@ -1267,11 +1527,17 @@ export class SessionRuntime {
1267
1527
  });
1268
1528
  if (id !== undefined) this.turnDonePinged = true;
1269
1529
  }
1530
+ await this.maybeReportBackToManager({
1531
+ ok: false,
1532
+ cancelled: this.cancelled,
1533
+ error: errMsg,
1534
+ });
1270
1535
  }
1271
1536
  } finally {
1272
1537
  // Safety net: turn completed with an expected Done ping that never landed
1273
1538
  // (notify failed, hung path, etc.). Never block queue flush on this.
1274
- if (this.turnExpectDone && !this.turnDonePinged) {
1539
+ // Manager never uses this path (turnExpectDone is false in manager mode).
1540
+ if (this.turnExpectDone && !this.turnDonePinged && !this.managerMode) {
1275
1541
  const fallback =
1276
1542
  this.lastCompletion?.trim() ||
1277
1543
  `\u2705 Done \u00B7 ${fmtDuration(Date.now() - startedAt)}`;
@@ -1358,20 +1624,47 @@ export class SessionRuntime {
1358
1624
  cfg: this.cfg,
1359
1625
  chatId: this.chatId,
1360
1626
  messageThreadId: this.messageThreadId,
1627
+ replyToMessageId: this.turnReplyTo,
1361
1628
  forum: this.bridge.forum,
1362
1629
  store: this.bridge.store,
1363
1630
  bots: this.bridge.bots,
1364
1631
  submitTopicPrompt: this.bridge.submitTopicPrompt,
1632
+ managerMode: this.managerMode,
1633
+ managerUserAskPreview: this.suggestionUserText || cleanUserPreview(this.turnUserText, 400),
1365
1634
  });
1366
1635
 
1636
+ // Count successful notify actions. Do not delete the live stream bubble
1637
+ // (same id as Thinking…) — that would wipe the user's visible reply.
1638
+ for (const r of results) {
1639
+ if (r.action === "notify" && r.ok) {
1640
+ this.managerNotifyCount++;
1641
+ this.managerUserVisible = true;
1642
+ const streamLive = this.streamer?.liveMessageId;
1643
+ if (
1644
+ this.managerStatusMsgId !== undefined &&
1645
+ this.managerStatusMsgId !== streamLive &&
1646
+ !this.streamer?.hasOutput
1647
+ ) {
1648
+ const sid = this.managerStatusMsgId;
1649
+ this.managerStatusMsgId = undefined;
1650
+ void this.deleteManagerStatus(sid);
1651
+ }
1652
+ const mid = (r.data as { messageId?: number } | undefined)?.messageId;
1653
+ if (mid !== undefined && this.sessionId) {
1654
+ this.onTelegramMessageBound?.(mid, this.sessionId);
1655
+ }
1656
+ }
1657
+ }
1658
+
1367
1659
  // Only announce durable side-effects in chat (topic create/bind/cross-prompt).
1368
- // search_memory / list_bots / bot wait status stay silent — results go to the agent.
1660
+ // Manager mode stays quiet — use notify for user text; no auto notes.
1661
+ // search_memory / list_* / bot wait status stay silent — results go to the agent.
1369
1662
  const durableActions = new Set(["create_topic", "set_path", "send_prompt"]);
1370
1663
  const notes = results
1371
1664
  .filter((r) => durableActions.has(r.action) && r.userNote?.trim())
1372
1665
  .map((r) => r.userNote!)
1373
1666
  .filter(Boolean);
1374
- if (notes.length > 0 && this.foreground) {
1667
+ if (notes.length > 0 && this.foreground && !this.managerMode) {
1375
1668
  await this.notify(notes.join("\n"), {
1376
1669
  loud: true,
1377
1670
  replyTo: this.turnReplyTo,
@@ -1418,7 +1711,7 @@ export class SessionRuntime {
1418
1711
  doneText: string,
1419
1712
  switchKb: InlineKeyboard | undefined,
1420
1713
  opts?: { autoQueue?: boolean },
1421
- ): Promise<{ text: string; markup?: InlineKeyboard }> {
1714
+ ): Promise<{ text: string; markup?: InlineKeyboard; suggestions?: Suggestion[] }> {
1422
1715
  if (!this.cfg.suggestionsEnabled || !this.sessionId) {
1423
1716
  return { text: doneText, markup: switchKb };
1424
1717
  }
@@ -1428,6 +1721,8 @@ export class SessionRuntime {
1428
1721
  } catch (e) {
1429
1722
  log.debug(`suggestions fetch failed: ${(e as Error).message}`);
1430
1723
  }
1724
+ // Manager: keep 1–4 short follow-ups only.
1725
+ if (this.managerMode) suggestions = suggestions.slice(0, 4);
1431
1726
  if (suggestions.length === 0) return { text: doneText, markup: switchKb };
1432
1727
 
1433
1728
  const batchId = ++this.suggestionBatchSeq;
@@ -1445,12 +1740,13 @@ export class SessionRuntime {
1445
1740
  let banner: string;
1446
1741
  if (auto.length > 0) {
1447
1742
  const batched = formatBatchedSuggestionsPrompt(auto);
1448
- const lines = auto.map((s, i) => ` ${i + 1}) ${s.need}% \u2014 ${s.text}`);
1743
+ // Hard, visible auto-approve block (especially for General chat).
1744
+ const lines = auto.map((s) => `\u2022 ${s.text}`);
1449
1745
  const autoBlock =
1450
- `\n\n\u{1F4A1} Auto-running ${auto.length} suggestion${auto.length === 1 ? "" : "s"}` +
1451
- ` as one prompt (\u2265 ${thr}% need):\n${lines.join("\n")}`;
1746
+ `\n\n\u2705 Auto Approved:\n${lines.join("\n")}` +
1747
+ (this.managerMode ? "" : `\n(need \u2265 ${thr}%)`);
1452
1748
  text += autoBlock;
1453
- banner = `\u{1F4A1} Suggestions (auto-running ${auto.length} as one prompt):\n${lines.join("\n")}`;
1749
+ banner = `\u2705 Auto Approved:\n${lines.join("\n")}`;
1454
1750
  // Single queue entry — agent executes 1) 2) 3) in one turn.
1455
1751
  // skipSelfRecheck: auto-follow-ups must not arm another recheck cycle.
1456
1752
  this.queue.push(
@@ -1461,7 +1757,9 @@ export class SessionRuntime {
1461
1757
  );
1462
1758
  this.changed();
1463
1759
  } else {
1464
- text += "\n\n\u{1F4A1} Suggestions \u2014 tap one to continue:";
1760
+ text += this.managerMode
1761
+ ? "\n\nTap a suggestion to continue:"
1762
+ : "\n\n\u{1F4A1} Suggestions \u2014 tap one to continue:";
1465
1763
  banner = "\u{1F4A1} Suggestions \u2014 tap one to continue:";
1466
1764
  }
1467
1765
 
@@ -1469,8 +1767,168 @@ export class SessionRuntime {
1469
1767
  // the same keyboard). Cleared when a new turn starts.
1470
1768
  this.pendingSuggestions = { batchId, suggestions, banner };
1471
1769
 
1472
- const markup = suggestionsKeyboard(batchId, suggestions, switchKb);
1473
- return { text, markup };
1770
+ // Keyboard: show remaining (non-auto) suggestions; if all auto, no buttons.
1771
+ const remaining = suggestions.filter((s) => !auto.some((a) => a.text === s.text));
1772
+ const markup =
1773
+ remaining.length > 0
1774
+ ? suggestionsKeyboard(batchId, remaining, switchKb)
1775
+ : switchKb;
1776
+ return { text, markup, suggestions };
1777
+ }
1778
+
1779
+ /** Post General status bubble (Starting… / Thinking…) — streamer edits it later. */
1780
+ private async postManagerStatus(
1781
+ replyTo: number,
1782
+ text: string,
1783
+ ): Promise<number | undefined> {
1784
+ try {
1785
+ const extra: Record<string, unknown> = {
1786
+ disable_notification: true,
1787
+ ...outboundThreadExtra(this.messageThreadId),
1788
+ reply_parameters: {
1789
+ message_id: replyTo,
1790
+ allow_sending_without_reply: true,
1791
+ },
1792
+ };
1793
+ const msg = await this.api.sendMessage(this.chatId, text, extra);
1794
+ return msg.message_id;
1795
+ } catch (e) {
1796
+ log.debug(`manager status "${text}" failed: ${(e as Error).message}`);
1797
+ return undefined;
1798
+ }
1799
+ }
1800
+
1801
+ private async editManagerStatus(messageId: number, text: string): Promise<void> {
1802
+ try {
1803
+ await this.api.editMessageText(this.chatId, messageId, text);
1804
+ } catch (e) {
1805
+ log.debug(`manager status edit failed: ${(e as Error).message}`);
1806
+ }
1807
+ }
1808
+
1809
+ private async deleteManagerStatus(messageId: number): Promise<void> {
1810
+ try {
1811
+ await this.api.deleteMessage(this.chatId, messageId);
1812
+ } catch (e) {
1813
+ log.debug(`manager status delete failed: ${(e as Error).message}`);
1814
+ }
1815
+ }
1816
+
1817
+ /** Surface a short important error in General; clear Thinking… bubble. */
1818
+ private async finishManagerError(errorMessage: string, startedAt: number): Promise<void> {
1819
+ const elapsed = fmtDuration(Date.now() - startedAt);
1820
+ const short =
1821
+ errorMessage.length > 280 ? errorMessage.slice(0, 277) + "\u2026" : errorMessage;
1822
+ const text = `\u274C ${short} \u00B7 ${elapsed}`;
1823
+ this.lastCompletion = text;
1824
+ if (this.managerStatusMsgId !== undefined) {
1825
+ await this.editManagerStatus(this.managerStatusMsgId, text);
1826
+ this.managerUserVisible = true;
1827
+ if (this.sessionId) this.onTelegramMessageBound?.(this.managerStatusMsgId, this.sessionId);
1828
+ this.managerStatusMsgId = undefined;
1829
+ return;
1830
+ }
1831
+ if (this.turnReplyTo !== undefined) {
1832
+ const id = await this.notify(text, { loud: true, replyTo: this.turnReplyTo });
1833
+ if (id !== undefined) this.managerUserVisible = true;
1834
+ }
1835
+ }
1836
+
1837
+ /**
1838
+ * General quiet completion:
1839
+ * - if notify already sent → drop Thinking… bubble
1840
+ * - else if direct user ask and clean prose → one short fallback message
1841
+ * - else → delete Thinking… and stay silent
1842
+ */
1843
+ private async finishManagerUserFacing(
1844
+ metaTurn: boolean,
1845
+ stopReason: string | undefined,
1846
+ startedAt: number,
1847
+ ): Promise<void> {
1848
+ const elapsed = fmtDuration(Date.now() - startedAt);
1849
+ if (this.cancelled || stopReason === "cancelled") {
1850
+ this.lastCompletion = `\u23F9 Stopped \u00B7 ${elapsed}`;
1851
+ // Only surface cancel if user was waiting on a visible bubble.
1852
+ if (this.managerStatusMsgId !== undefined && !metaTurn) {
1853
+ await this.editManagerStatus(this.managerStatusMsgId, this.lastCompletion);
1854
+ this.managerUserVisible = true;
1855
+ } else if (this.managerStatusMsgId !== undefined) {
1856
+ await this.deleteManagerStatus(this.managerStatusMsgId);
1857
+ }
1858
+ this.managerStatusMsgId = undefined;
1859
+ return;
1860
+ }
1861
+
1862
+ // Prose already landed in the Thinking… bubble via the streamer — keep it.
1863
+ if (this.streamer?.hasOutput) {
1864
+ this.managerUserVisible = true;
1865
+ this.lastCompletion =
1866
+ cleanManagerVisibleText(this.turnAssistantText).slice(0, 500) ||
1867
+ `\u2705 Done \u00B7 ${elapsed}`;
1868
+ this.managerStatusMsgId = undefined;
1869
+ return;
1870
+ }
1871
+
1872
+ if (this.managerNotifyCount > 0) {
1873
+ this.lastCompletion =
1874
+ cleanManagerVisibleText(this.turnAssistantText).slice(0, 500) ||
1875
+ `\u2705 Done \u00B7 ${elapsed}`;
1876
+ if (this.managerStatusMsgId !== undefined) {
1877
+ await this.deleteManagerStatus(this.managerStatusMsgId);
1878
+ this.managerStatusMsgId = undefined;
1879
+ }
1880
+ return;
1881
+ }
1882
+
1883
+ // Work-report / recheck: silent unless cancelled (handled above).
1884
+ if (metaTurn) {
1885
+ this.lastCompletion = `\u2705 Done \u00B7 ${elapsed}`;
1886
+ if (this.managerStatusMsgId !== undefined) {
1887
+ await this.deleteManagerStatus(this.managerStatusMsgId);
1888
+ this.managerStatusMsgId = undefined;
1889
+ }
1890
+ return;
1891
+ }
1892
+
1893
+ // Direct user ask: single fallback if the model emitted no visible prose.
1894
+ const cleaned = cleanManagerVisibleText(this.turnAssistantText);
1895
+ const fallback = pickManagerFallbackText(cleaned);
1896
+ if (fallback && this.managerStatusMsgId !== undefined) {
1897
+ await this.editManagerStatus(this.managerStatusMsgId, fallback);
1898
+ this.managerUserVisible = true;
1899
+ this.lastCompletion = fallback.slice(0, 500);
1900
+ if (this.sessionId) this.onTelegramMessageBound?.(this.managerStatusMsgId, this.sessionId);
1901
+ this.managerStatusMsgId = undefined;
1902
+ return;
1903
+ }
1904
+ if (fallback && this.turnReplyTo !== undefined) {
1905
+ const id = await this.notify(fallback, {
1906
+ loud: false,
1907
+ replyTo: this.turnReplyTo,
1908
+ });
1909
+ if (id !== undefined) {
1910
+ this.managerUserVisible = true;
1911
+ this.lastCompletion = fallback.slice(0, 500);
1912
+ if (this.sessionId) this.onTelegramMessageBound?.(id, this.sessionId);
1913
+ }
1914
+ } else if (this.managerStatusMsgId !== undefined) {
1915
+ // Never delete the placeholder leaving the user with no reply.
1916
+ await this.editManagerStatus(
1917
+ this.managerStatusMsgId,
1918
+ "Working on it \u2014 I\u2019ll report back here.",
1919
+ );
1920
+ this.managerUserVisible = true;
1921
+ this.lastCompletion = "Working on it";
1922
+ if (this.sessionId) this.onTelegramMessageBound?.(this.managerStatusMsgId, this.sessionId);
1923
+ this.managerStatusMsgId = undefined;
1924
+ return;
1925
+ } else {
1926
+ this.lastCompletion = `\u2705 Done \u00B7 ${elapsed}`;
1927
+ }
1928
+ if (this.managerStatusMsgId !== undefined) {
1929
+ await this.deleteManagerStatus(this.managerStatusMsgId);
1930
+ this.managerStatusMsgId = undefined;
1931
+ }
1474
1932
  }
1475
1933
 
1476
1934
  /**
@@ -1698,7 +2156,8 @@ export class SessionRuntime {
1698
2156
  reasoning: reasoningDirective(this.reasoning),
1699
2157
  priming: this.primingContext,
1700
2158
  imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
1701
- progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
2159
+ progress:
2160
+ !this.managerMode && this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
1702
2161
  });
1703
2162
  this.primingContext = undefined;
1704
2163
  log.info(
@@ -1761,7 +2220,8 @@ export class SessionRuntime {
1761
2220
  reasoning: reasoningDirective(this.reasoning),
1762
2221
  priming: transcript ? buildPriming(transcript) : undefined,
1763
2222
  imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
1764
- progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
2223
+ progress:
2224
+ !this.managerMode && this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
1765
2225
  });
1766
2226
  return this.runPromptWithRetries(forkContent);
1767
2227
  }
@@ -1813,7 +2273,8 @@ export class SessionRuntime {
1813
2273
  reasoning: reasoningDirective(this.reasoning),
1814
2274
  priming: transcript ? buildPriming(transcript) : undefined,
1815
2275
  imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
1816
- progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
2276
+ progress:
2277
+ !this.managerMode && this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
1817
2278
  });
1818
2279
  return this.runPromptWithRetries(content);
1819
2280
  }
@@ -1860,7 +2321,8 @@ export class SessionRuntime {
1860
2321
  reasoning: reasoningDirective(this.reasoning),
1861
2322
  priming: transcript ? buildPriming(transcript) : undefined,
1862
2323
  imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
1863
- progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
2324
+ progress:
2325
+ !this.managerMode && this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
1864
2326
  });
1865
2327
  log.info(
1866
2328
  `chat ${this.chatId} auto-rotating to account ${t.label}` +
@@ -1990,7 +2452,8 @@ export class SessionRuntime {
1990
2452
  const resumeContent = buildContentBlocks(textPrompt(RESUME_INSTRUCTION), {
1991
2453
  reasoning: reasoningDirective(this.reasoning),
1992
2454
  imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
1993
- progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
2455
+ progress:
2456
+ !this.managerMode && this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
1994
2457
  });
1995
2458
 
1996
2459
  let last = final;
@@ -2061,6 +2524,111 @@ export class SessionRuntime {
2061
2524
  return `\u{1F4E8} From other session ${this.sessionTag()}\n${head}\n${summarizeFileOpsShort(this.fileOps)}\n\n${tags}`;
2062
2525
  }
2063
2526
 
2527
+ /** Soft completion for General manager (no file-ops / progress spam). */
2528
+ private managerCompletionMessage(
2529
+ stopReason: string | undefined,
2530
+ startedAt: number,
2531
+ streamedOutput: boolean,
2532
+ ): string {
2533
+ const elapsed = fmtDuration(Date.now() - startedAt);
2534
+ if (this.cancelled || stopReason === "cancelled") {
2535
+ const msg = `\u23F9 Stopped \u00B7 ${elapsed}`;
2536
+ this.lastCompletion = msg;
2537
+ return streamedOutput ? "" : msg;
2538
+ }
2539
+ // When prose already streamed, no extra Done line (chat-like).
2540
+ if (streamedOutput) {
2541
+ this.lastCompletion = this.turnAssistantText.slice(0, 800) || `\u2705 Done \u00B7 ${elapsed}`;
2542
+ return "";
2543
+ }
2544
+ const msg = `\u2705 Done \u00B7 ${elapsed}`;
2545
+ this.lastCompletion = msg;
2546
+ return msg;
2547
+ }
2548
+
2549
+ /**
2550
+ * True when the next queued prompt is a continuation of the *same* manager
2551
+ * job (self-recheck / bridge results / suggestion batch), not a new user
2552
+ * ask or a different send_prompt dispatch.
2553
+ */
2554
+ private queueIsSameJobContinuation(jobId: string): boolean {
2555
+ if (this.queue.length === 0) return false;
2556
+ const next = this.queue[0]!;
2557
+ if (next.reportBack && next.reportBack.jobId !== jobId) return false;
2558
+ if (next.reportBack && next.reportBack.jobId === jobId) return true;
2559
+ // Meta continuations omit reportBack but keep the open job.
2560
+ return (
2561
+ !!next.skipSelfRecheck ||
2562
+ isSelfRecheckPrompt(next.text) ||
2563
+ isTelegramBridgeResultsPrompt(next.text) ||
2564
+ isManagerWorkReportPrompt(next.text)
2565
+ );
2566
+ }
2567
+
2568
+ /**
2569
+ * If this runtime was dispatched from General, wake the manager with a
2570
+ * structured WORK REPORT once for this job. Waits only for same-job meta
2571
+ * follow-ups; reports immediately when a different dispatch/user turn is next.
2572
+ */
2573
+ private async maybeReportBackToManager(opts: {
2574
+ ok: boolean;
2575
+ cancelled: boolean;
2576
+ stopReason?: string;
2577
+ error?: string;
2578
+ }): Promise<void> {
2579
+ const meta = this.pendingReportBack;
2580
+ if (!meta || !this.bridge?.wakeManager) return;
2581
+ // Same-job recheck / bridge results / suggestions still pending — wait.
2582
+ if (this.queueIsSameJobContinuation(meta.jobId)) return;
2583
+
2584
+ const status = opts.cancelled ? "cancelled" : opts.ok ? "done" : "failed";
2585
+ const assistantSummary = (
2586
+ this.turnAssistantText.trim() ||
2587
+ this.lastCompletion ||
2588
+ "(no assistant text)"
2589
+ ).replace(/\s+/g, " ").trim();
2590
+ const filesSummary =
2591
+ this.fileOps.size > 0 ? summarizeFileOpsShort(this.fileOps) : undefined;
2592
+
2593
+ updateManagerJob(meta.jobId, {
2594
+ status: status === "done" ? "done" : status === "cancelled" ? "cancelled" : "failed",
2595
+ resultSummary: assistantSummary.slice(0, 400),
2596
+ childSessionId: this.sessionId,
2597
+ });
2598
+
2599
+ const prompt = buildManagerWorkReportPrompt({
2600
+ jobId: meta.jobId,
2601
+ targetName: meta.targetName || this.projectName || basename(this.cwd),
2602
+ targetThreadId: this.messageThreadId ?? 0,
2603
+ targetPath: meta.targetPath || this.cwd,
2604
+ userAskPreview: meta.userAskPreview,
2605
+ dispatchPromptPreview: meta.dispatchPrompt,
2606
+ status,
2607
+ stopReason: opts.stopReason,
2608
+ error: opts.error,
2609
+ assistantSummary,
2610
+ filesSummary,
2611
+ childSessionId: this.sessionId,
2612
+ });
2613
+
2614
+ // Clear before await so a re-entry cannot double-report this job.
2615
+ this.pendingReportBack = undefined;
2616
+ try {
2617
+ await this.bridge.wakeManager({
2618
+ originChatId: meta.originChatId,
2619
+ originThreadId: meta.originThreadId,
2620
+ prompt,
2621
+ });
2622
+ log.info(
2623
+ `report-back job ${meta.jobId} → general (#${meta.originThreadId}) status=${status}`,
2624
+ );
2625
+ } catch (e) {
2626
+ log.warn(`report-back failed for job ${meta.jobId}: ${(e as Error).message}`);
2627
+ // Restore so a later turn might retry once if still attached.
2628
+ this.pendingReportBack = meta;
2629
+ }
2630
+ }
2631
+
2064
2632
  /**
2065
2633
  * Final Done after a self-recheck: head + split file lists (first turn vs recheck).
2066
2634
  */
@@ -2129,6 +2697,9 @@ export class SessionRuntime {
2129
2697
  /** Searchable Telegram hashtags so you can pull up every message of a session
2130
2698
  * or project (and this turn's prompt) by tapping the tag. */
2131
2699
  private hashtags(): string {
2700
+ // General manager: no tags (user requested clean chat). Reply routing uses
2701
+ // Telegram message-id → session map, not #sess_ footers.
2702
+ if (this.managerMode) return "";
2132
2703
  return sessionHashtags({
2133
2704
  projectName: this.projectName,
2134
2705
  cwd: this.cwd,
@@ -2314,13 +2885,17 @@ export class SessionRuntime {
2314
2885
  ): Promise<number | undefined> {
2315
2886
  const send = async (body: string, withMarkup: boolean): Promise<number | undefined> => {
2316
2887
  try {
2317
- const extra: Record<string, unknown> = opts?.loud ? { disable_notification: false } : {};
2318
- if (this.messageThreadId !== undefined) extra.message_thread_id = this.messageThreadId;
2888
+ const extra: Record<string, unknown> = {
2889
+ ...(opts?.loud ? { disable_notification: false } : {}),
2890
+ // Never pass message_thread_id=1 (General) — Telegram rejects it.
2891
+ ...outboundThreadExtra(this.messageThreadId),
2892
+ };
2319
2893
  if (opts?.replyTo !== undefined) {
2320
2894
  extra.reply_parameters = { message_id: opts.replyTo, allow_sending_without_reply: true };
2321
2895
  }
2322
2896
  if (withMarkup && opts?.replyMarkup) extra.reply_markup = opts.replyMarkup;
2323
2897
  const msg = await this.api.sendMessage(this.chatId, body, extra);
2898
+ if (this.sessionId) this.onTelegramMessageBound?.(msg.message_id, this.sessionId);
2324
2899
  return msg.message_id;
2325
2900
  } catch (e) {
2326
2901
  log.debug("notify failed:", (e as Error).message);
@@ -2344,8 +2919,8 @@ export class SessionRuntime {
2344
2919
  ): Promise<void> {
2345
2920
  if (messageId !== undefined) {
2346
2921
  try {
2922
+ // editMessageText does not need message_thread_id; keep markup only.
2347
2923
  const extra: Record<string, unknown> = {};
2348
- if (this.messageThreadId !== undefined) extra.message_thread_id = this.messageThreadId;
2349
2924
  if (markup) extra.reply_markup = markup;
2350
2925
  await this.api.editMessageText(this.chatId, messageId, text, extra);
2351
2926
  return;
@@ -2378,6 +2953,41 @@ export class SessionRuntime {
2378
2953
  }
2379
2954
  }
2380
2955
 
2956
+ /** Visible manager reply body (no progress markers / telegram action fences). */
2957
+ function cleanManagerVisibleText(raw: string): string {
2958
+ if (!raw?.trim()) return "";
2959
+ const withoutTg = stripTelegramActionFences(raw);
2960
+ return extractProgress(withoutTg).cleaned.trim();
2961
+ }
2962
+
2963
+ /**
2964
+ * One short user-facing fallback when General forgot `notify`.
2965
+ * Drops empty, table-spam, and pure status narration.
2966
+ */
2967
+ export function pickManagerFallbackText(cleaned: string): string | undefined {
2968
+ let t = cleaned.replace(/\r\n/g, "\n").trim();
2969
+ if (!t) return undefined;
2970
+ // Drop markdown tables and multi-line job dumps.
2971
+ if (/^\s*\|.+\|/m.test(t) && (t.match(/\|/g) || []).length >= 6) return undefined;
2972
+ // Collapse whitespace.
2973
+ t = t.replace(/\n{3,}/g, "\n\n").trim();
2974
+ // Prefer first 1–2 short paragraphs.
2975
+ const paras = t.split(/\n\n+/).map((p) => p.trim()).filter(Boolean);
2976
+ let out = paras.slice(0, 2).join("\n\n");
2977
+ if (out.length > 600) out = out.slice(0, 597) + "\u2026";
2978
+ // Ignore pure meta / empty placeholders.
2979
+ if (/^(thinking|starting|ok|done|\.+|\u2026)+$/i.test(out.trim())) return undefined;
2980
+ if (out.length < 2) return undefined;
2981
+ // Skip "Dispatching… / Sending to…" spam patterns if that is all we got.
2982
+ if (
2983
+ /^(dispatching|sending to|queued|cancelling|already running)\b/i.test(out) &&
2984
+ out.length < 280
2985
+ ) {
2986
+ return undefined;
2987
+ }
2988
+ return out;
2989
+ }
2990
+
2381
2991
  /** Format an elapsed duration compactly (e.g. "8s", "2m 13s", "1h 4m"). */
2382
2992
  function fmtDuration(ms: number): string {
2383
2993
  const s = Math.round(ms / 1000);