grok-telegram-bot 2.5.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.
@@ -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`);
@@ -756,7 +847,8 @@ export class SessionRuntime {
756
847
  if (
757
848
  input.skipSelfRecheck ||
758
849
  isSelfRecheckPrompt(input.text) ||
759
- isTelegramBridgeResultsPrompt(input.text)
850
+ isTelegramBridgeResultsPrompt(input.text) ||
851
+ isManagerWorkReportPrompt(input.text)
760
852
  ) {
761
853
  return false;
762
854
  }
@@ -774,6 +866,30 @@ export class SessionRuntime {
774
866
  return true;
775
867
  }
776
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
+
777
893
  /**
778
894
  * Stop the current turn for this runtime only.
779
895
  * Soft ACP cancel + session-scoped force-complete; never kills the shared
@@ -903,7 +1019,12 @@ export class SessionRuntime {
903
1019
  private async runTurn(input: PromptInput): Promise<void> {
904
1020
  // Apply before any turn bookkeeping so card previews / logs see the wrapped
905
1021
  // text the same way the agent does (also covers flushQueue first messages).
906
- input = this.applyFirstPromptSteering(input);
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
+ }
907
1028
 
908
1029
  this.busy = true;
909
1030
  this.cancelled = false;
@@ -915,19 +1036,29 @@ export class SessionRuntime {
915
1036
  this.turnExpectDone = false;
916
1037
  this.turnDonePinged = false;
917
1038
  this.isSelfRecheckTurn = isSelfRecheckPrompt(input.text);
918
- // Meta turns (recheck, bridge results, auto-suggestion batches) never arm another recheck.
1039
+ // Meta turns (recheck, bridge results, work reports) never arm another recheck.
919
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
+ }
920
1049
  this.skipSelfRecheck =
921
1050
  !!input.skipSelfRecheck ||
922
1051
  this.isSelfRecheckTurn ||
923
- isBridgeResults;
1052
+ isBridgeResults ||
1053
+ isWorkReport ||
1054
+ this.managerMode;
924
1055
  // Fresh user work resets bridge-chain depth + suggestion anchors.
925
- if (!this.isSelfRecheckTurn && !isBridgeResults) {
1056
+ if (!this.isSelfRecheckTurn && !isBridgeResults && !isWorkReport) {
926
1057
  this.bridgeResultDepth = 0;
927
1058
  }
928
1059
  // Fresh user work resets suggestion anchors; recheck / bridge results keep the original ask.
929
1060
  // Strip complexity/reply wrappers so suggestions + recheck see the real ask.
930
- if (!this.isSelfRecheckTurn && !isBridgeResults) {
1061
+ if (!this.isSelfRecheckTurn && !isBridgeResults && !isWorkReport) {
931
1062
  this.suggestionUserText = stripDirectiveWrappers(input.text) || input.text;
932
1063
  this.preRecheckAssistantText = "";
933
1064
  this.preRecheckFileOps = new Map();
@@ -942,6 +1073,15 @@ export class SessionRuntime {
942
1073
  this.setSessionComment(preview);
943
1074
  }
944
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);
1084
+ }
945
1085
  this.shownToolIds = new Set();
946
1086
  this.toolCallCache = new Map();
947
1087
  this.fileOps = new Map();
@@ -961,9 +1101,33 @@ export class SessionRuntime {
961
1101
  // A new streamed turn supersedes any transient "follow" watch of this same
962
1102
  // session's previous in-flight turn (avoids duplicated output).
963
1103
  if (this.watchIsFollow) this.stopWatch();
964
- 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;
965
1110
  const startedAt = Date.now();
966
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
+ }
967
1131
  this.streamer = live
968
1132
  ? new ResponseStreamer(
969
1133
  this.api,
@@ -971,13 +1135,30 @@ export class SessionRuntime {
971
1135
  this.cfg.streamThrottleMs,
972
1136
  this.turnReplyTo,
973
1137
  this.hashtags(),
974
- (pct) => this.setProgress(pct),
975
- this.cfg.progressFallback,
1138
+ this.managerMode ? undefined : (pct) => this.setProgress(pct),
1139
+ this.managerMode ? false : this.cfg.progressFallback,
976
1140
  startedAt,
977
1141
  this.messageThreadId,
1142
+ this.managerMode
1143
+ ? {
1144
+ proseOnly: true,
1145
+ showProgressBar: false,
1146
+ seedMessageId: thinkingMsgId,
1147
+ }
1148
+ : undefined,
978
1149
  )
979
1150
  : undefined;
980
- 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();
981
1162
  this.activity(true);
982
1163
  this.changed();
983
1164
  this.imageScanText = "";
@@ -986,8 +1167,13 @@ export class SessionRuntime {
986
1167
  const content = buildContentBlocks(input, {
987
1168
  reasoning: reasoningDirective(this.reasoning),
988
1169
  priming: this.primingContext,
989
- imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
990
- 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,
991
1177
  });
992
1178
  this.primingContext = undefined;
993
1179
 
@@ -1054,8 +1240,8 @@ export class SessionRuntime {
1054
1240
  // spam to the chat (live step / status panel only).
1055
1241
  const pingDone =
1056
1242
  canPing && (this.foreground || !hasQueued) && !queuedBridgeResults;
1057
- // Expect a Done this turn unless we defer for recheck (bridge already excluded).
1058
- this.turnExpectDone = pingDone;
1243
+ // Manager uses notify/finishManagerUserFacing — never arm Done safety-net spam.
1244
+ this.turnExpectDone = pingDone && !this.managerMode;
1059
1245
 
1060
1246
  // One-shot self-recheck: only after a real *user* turn (not meta/auto),
1061
1247
  // with idle queue. skipSelfRecheck blocks loops after recheck / auto-batch.
@@ -1120,16 +1306,47 @@ export class SessionRuntime {
1120
1306
  }
1121
1307
  }
1122
1308
 
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
+
1123
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 {
1124
1340
  // Build Done text *now* (after quiet decision) so a cancel during the
1125
1341
  // recheck-decision wait shows ⏹ Stopped, not a stale ✅ Done head.
1126
1342
  let doneText = this.isSelfRecheckTurn
1127
- ? this.completionMessageSplit(final.result?.stopReason, startedAt, streamedOutput)
1128
- : this.completionMessage(final.result?.stopReason, startedAt, streamedOutput);
1343
+ ? this.completionMessageSplit(final.result?.stopReason, startedAt, streamedOutput)
1344
+ : this.completionMessage(final.result?.stopReason, startedAt, streamedOutput);
1129
1345
  // 1) Always send Done FIRST — never block the completion ping on the
1130
1346
  // quiet suggestions prompt (which can hang and look like "no Done").
1131
1347
  let doneMsgId: number | undefined;
1132
- if (pingDone) {
1348
+ const shouldPingDone = pingDone;
1349
+ if (shouldPingDone && doneText.trim()) {
1133
1350
  doneMsgId = await this.notify(doneText, {
1134
1351
  loud: true,
1135
1352
  replyTo: this.turnReplyTo,
@@ -1137,30 +1354,51 @@ export class SessionRuntime {
1137
1354
  });
1138
1355
  if (doneMsgId !== undefined) this.turnDonePinged = true;
1139
1356
  }
1140
- // 2) Suggestions after Done: edit the Done message (or send a follow-up).
1357
+ // 2) Suggestions: project topics keep Done-edit UX.
1141
1358
  if (final.result && !this.cancelled && !hasQueued) {
1142
1359
  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) {
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) {
1150
1369
  await this.enhanceDoneMessage(doneMsgId, sug.text, sug.markup ?? switchKb);
1151
1370
  }
1152
1371
  } catch (e) {
1153
1372
  log.debug(`suggestions after Done failed: ${(e as Error).message}`);
1154
1373
  }
1155
1374
  }
1375
+ }
1156
1376
  // Clear frozen first-turn ops after final Done (recheck path done).
1157
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
+ });
1158
1387
  }
1159
1388
  // queuedBridgeResults: stay quiet in chat — agent gets results via queue.
1160
1389
  } 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) {
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.
1164
1402
  const switchKb = this.switchKeyboard();
1165
1403
  const pingDone = canPing && (this.foreground || !hasQueued);
1166
1404
  this.turnExpectDone = pingDone;
@@ -1189,6 +1427,13 @@ export class SessionRuntime {
1189
1427
  }
1190
1428
  }
1191
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
+ });
1192
1437
  } else {
1193
1438
  const transient = isTransientError(final.error);
1194
1439
  const liveMsg = this.errorMessage(final.error, startedAt, final.attempts, transient);
@@ -1201,6 +1446,11 @@ export class SessionRuntime {
1201
1446
  });
1202
1447
  if (id !== undefined) this.turnDonePinged = true;
1203
1448
  }
1449
+ await this.maybeReportBackToManager({
1450
+ ok: false,
1451
+ cancelled: false,
1452
+ error: final.error.message,
1453
+ });
1204
1454
  }
1205
1455
  }
1206
1456
  } catch (err) {
@@ -1253,6 +1503,14 @@ export class SessionRuntime {
1253
1503
  }
1254
1504
  }
1255
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
+ });
1256
1514
  } else {
1257
1515
  const msg = `\u274C Error after ${fmtDuration(Date.now() - startedAt)}: ${errMsg}`;
1258
1516
  this.lastCompletion = msg;
@@ -1267,11 +1525,17 @@ export class SessionRuntime {
1267
1525
  });
1268
1526
  if (id !== undefined) this.turnDonePinged = true;
1269
1527
  }
1528
+ await this.maybeReportBackToManager({
1529
+ ok: false,
1530
+ cancelled: this.cancelled,
1531
+ error: errMsg,
1532
+ });
1270
1533
  }
1271
1534
  } finally {
1272
1535
  // Safety net: turn completed with an expected Done ping that never landed
1273
1536
  // (notify failed, hung path, etc.). Never block queue flush on this.
1274
- if (this.turnExpectDone && !this.turnDonePinged) {
1537
+ // Manager never uses this path (turnExpectDone is false in manager mode).
1538
+ if (this.turnExpectDone && !this.turnDonePinged && !this.managerMode) {
1275
1539
  const fallback =
1276
1540
  this.lastCompletion?.trim() ||
1277
1541
  `\u2705 Done \u00B7 ${fmtDuration(Date.now() - startedAt)}`;
@@ -1358,20 +1622,47 @@ export class SessionRuntime {
1358
1622
  cfg: this.cfg,
1359
1623
  chatId: this.chatId,
1360
1624
  messageThreadId: this.messageThreadId,
1625
+ replyToMessageId: this.turnReplyTo,
1361
1626
  forum: this.bridge.forum,
1362
1627
  store: this.bridge.store,
1363
1628
  bots: this.bridge.bots,
1364
1629
  submitTopicPrompt: this.bridge.submitTopicPrompt,
1630
+ managerMode: this.managerMode,
1631
+ managerUserAskPreview: this.suggestionUserText || cleanUserPreview(this.turnUserText, 400),
1365
1632
  });
1366
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
+
1367
1657
  // 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.
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.
1369
1660
  const durableActions = new Set(["create_topic", "set_path", "send_prompt"]);
1370
1661
  const notes = results
1371
1662
  .filter((r) => durableActions.has(r.action) && r.userNote?.trim())
1372
1663
  .map((r) => r.userNote!)
1373
1664
  .filter(Boolean);
1374
- if (notes.length > 0 && this.foreground) {
1665
+ if (notes.length > 0 && this.foreground && !this.managerMode) {
1375
1666
  await this.notify(notes.join("\n"), {
1376
1667
  loud: true,
1377
1668
  replyTo: this.turnReplyTo,
@@ -1418,7 +1709,7 @@ export class SessionRuntime {
1418
1709
  doneText: string,
1419
1710
  switchKb: InlineKeyboard | undefined,
1420
1711
  opts?: { autoQueue?: boolean },
1421
- ): Promise<{ text: string; markup?: InlineKeyboard }> {
1712
+ ): Promise<{ text: string; markup?: InlineKeyboard; suggestions?: Suggestion[] }> {
1422
1713
  if (!this.cfg.suggestionsEnabled || !this.sessionId) {
1423
1714
  return { text: doneText, markup: switchKb };
1424
1715
  }
@@ -1428,6 +1719,8 @@ export class SessionRuntime {
1428
1719
  } catch (e) {
1429
1720
  log.debug(`suggestions fetch failed: ${(e as Error).message}`);
1430
1721
  }
1722
+ // Manager: keep 1–4 short follow-ups only.
1723
+ if (this.managerMode) suggestions = suggestions.slice(0, 4);
1431
1724
  if (suggestions.length === 0) return { text: doneText, markup: switchKb };
1432
1725
 
1433
1726
  const batchId = ++this.suggestionBatchSeq;
@@ -1445,12 +1738,13 @@ export class SessionRuntime {
1445
1738
  let banner: string;
1446
1739
  if (auto.length > 0) {
1447
1740
  const batched = formatBatchedSuggestionsPrompt(auto);
1448
- 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}`);
1449
1743
  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")}`;
1744
+ `\n\n\u2705 Auto Approved:\n${lines.join("\n")}` +
1745
+ (this.managerMode ? "" : `\n(need \u2265 ${thr}%)`);
1452
1746
  text += autoBlock;
1453
- banner = `\u{1F4A1} Suggestions (auto-running ${auto.length} as one prompt):\n${lines.join("\n")}`;
1747
+ banner = `\u2705 Auto Approved:\n${lines.join("\n")}`;
1454
1748
  // Single queue entry — agent executes 1) 2) 3) in one turn.
1455
1749
  // skipSelfRecheck: auto-follow-ups must not arm another recheck cycle.
1456
1750
  this.queue.push(
@@ -1461,7 +1755,9 @@ export class SessionRuntime {
1461
1755
  );
1462
1756
  this.changed();
1463
1757
  } else {
1464
- 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:";
1465
1761
  banner = "\u{1F4A1} Suggestions \u2014 tap one to continue:";
1466
1762
  }
1467
1763
 
@@ -1469,8 +1765,168 @@ export class SessionRuntime {
1469
1765
  // the same keyboard). Cleared when a new turn starts.
1470
1766
  this.pendingSuggestions = { batchId, suggestions, banner };
1471
1767
 
1472
- const markup = suggestionsKeyboard(batchId, suggestions, switchKb);
1473
- 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
+ }
1474
1930
  }
1475
1931
 
1476
1932
  /**
@@ -1698,7 +2154,8 @@ export class SessionRuntime {
1698
2154
  reasoning: reasoningDirective(this.reasoning),
1699
2155
  priming: this.primingContext,
1700
2156
  imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
1701
- progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
2157
+ progress:
2158
+ !this.managerMode && this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
1702
2159
  });
1703
2160
  this.primingContext = undefined;
1704
2161
  log.info(
@@ -1761,7 +2218,8 @@ export class SessionRuntime {
1761
2218
  reasoning: reasoningDirective(this.reasoning),
1762
2219
  priming: transcript ? buildPriming(transcript) : undefined,
1763
2220
  imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
1764
- progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
2221
+ progress:
2222
+ !this.managerMode && this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
1765
2223
  });
1766
2224
  return this.runPromptWithRetries(forkContent);
1767
2225
  }
@@ -1813,7 +2271,8 @@ export class SessionRuntime {
1813
2271
  reasoning: reasoningDirective(this.reasoning),
1814
2272
  priming: transcript ? buildPriming(transcript) : undefined,
1815
2273
  imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
1816
- progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
2274
+ progress:
2275
+ !this.managerMode && this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
1817
2276
  });
1818
2277
  return this.runPromptWithRetries(content);
1819
2278
  }
@@ -1860,7 +2319,8 @@ export class SessionRuntime {
1860
2319
  reasoning: reasoningDirective(this.reasoning),
1861
2320
  priming: transcript ? buildPriming(transcript) : undefined,
1862
2321
  imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
1863
- progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
2322
+ progress:
2323
+ !this.managerMode && this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
1864
2324
  });
1865
2325
  log.info(
1866
2326
  `chat ${this.chatId} auto-rotating to account ${t.label}` +
@@ -1990,7 +2450,8 @@ export class SessionRuntime {
1990
2450
  const resumeContent = buildContentBlocks(textPrompt(RESUME_INSTRUCTION), {
1991
2451
  reasoning: reasoningDirective(this.reasoning),
1992
2452
  imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
1993
- progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
2453
+ progress:
2454
+ !this.managerMode && this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
1994
2455
  });
1995
2456
 
1996
2457
  let last = final;
@@ -2061,6 +2522,111 @@ export class SessionRuntime {
2061
2522
  return `\u{1F4E8} From other session ${this.sessionTag()}\n${head}\n${summarizeFileOpsShort(this.fileOps)}\n\n${tags}`;
2062
2523
  }
2063
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
+
2064
2630
  /**
2065
2631
  * Final Done after a self-recheck: head + split file lists (first turn vs recheck).
2066
2632
  */
@@ -2129,6 +2695,9 @@ export class SessionRuntime {
2129
2695
  /** Searchable Telegram hashtags so you can pull up every message of a session
2130
2696
  * or project (and this turn's prompt) by tapping the tag. */
2131
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 "";
2132
2701
  return sessionHashtags({
2133
2702
  projectName: this.projectName,
2134
2703
  cwd: this.cwd,
@@ -2314,13 +2883,17 @@ export class SessionRuntime {
2314
2883
  ): Promise<number | undefined> {
2315
2884
  const send = async (body: string, withMarkup: boolean): Promise<number | undefined> => {
2316
2885
  try {
2317
- const extra: Record<string, unknown> = opts?.loud ? { disable_notification: false } : {};
2318
- if (this.messageThreadId !== undefined) extra.message_thread_id = this.messageThreadId;
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
+ };
2319
2891
  if (opts?.replyTo !== undefined) {
2320
2892
  extra.reply_parameters = { message_id: opts.replyTo, allow_sending_without_reply: true };
2321
2893
  }
2322
2894
  if (withMarkup && opts?.replyMarkup) extra.reply_markup = opts.replyMarkup;
2323
2895
  const msg = await this.api.sendMessage(this.chatId, body, extra);
2896
+ if (this.sessionId) this.onTelegramMessageBound?.(msg.message_id, this.sessionId);
2324
2897
  return msg.message_id;
2325
2898
  } catch (e) {
2326
2899
  log.debug("notify failed:", (e as Error).message);
@@ -2344,8 +2917,8 @@ export class SessionRuntime {
2344
2917
  ): Promise<void> {
2345
2918
  if (messageId !== undefined) {
2346
2919
  try {
2920
+ // editMessageText does not need message_thread_id; keep markup only.
2347
2921
  const extra: Record<string, unknown> = {};
2348
- if (this.messageThreadId !== undefined) extra.message_thread_id = this.messageThreadId;
2349
2922
  if (markup) extra.reply_markup = markup;
2350
2923
  await this.api.editMessageText(this.chatId, messageId, text, extra);
2351
2924
  return;
@@ -2378,6 +2951,41 @@ export class SessionRuntime {
2378
2951
  }
2379
2952
  }
2380
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;
2985
+ }
2986
+ return out;
2987
+ }
2988
+
2381
2989
  /** Format an elapsed duration compactly (e.g. "8s", "2m 13s", "1h 4m"). */
2382
2990
  function fmtDuration(ms: number): string {
2383
2991
  const s = Math.round(ms / 1000);