grok-telegram-bot 2.3.1 → 2.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (88) hide show
  1. package/.env.example +64 -2
  2. package/CHANGELOG.md +156 -1
  3. package/README.md +58 -15
  4. package/docs/GROUP.md +225 -0
  5. package/docs/INSTALL.md +3 -0
  6. package/package.json +1 -1
  7. package/src/app/accounts.ts +84 -0
  8. package/src/app/instance-lock.ts +6 -0
  9. package/src/app/lifetime-flag.ts +20 -0
  10. package/src/app/settings-store.ts +47 -8
  11. package/src/app/types.ts +30 -2
  12. package/src/app/updater.ts +38 -6
  13. package/src/app/usage.ts +204 -7
  14. package/src/bot/account-rotator.ts +10 -0
  15. package/src/bot/auth.ts +96 -15
  16. package/src/bot/bot.ts +154 -11
  17. package/src/bot/chat-controller.ts +82 -13
  18. package/src/bot/commands.ts +69 -27
  19. package/src/bot/complexity-gate.ts +69 -0
  20. package/src/bot/deps.ts +22 -0
  21. package/src/bot/group-memory.ts +159 -0
  22. package/src/bot/handlers/accounts.ts +58 -1
  23. package/src/bot/handlers/control.ts +85 -32
  24. package/src/bot/handlers/document.ts +31 -4
  25. package/src/bot/handlers/forum.ts +207 -0
  26. package/src/bot/handlers/import-session.ts +290 -0
  27. package/src/bot/handlers/menu.ts +102 -61
  28. package/src/bot/handlers/message.ts +102 -21
  29. package/src/bot/handlers/photo.ts +123 -16
  30. package/src/bot/handlers/running.ts +172 -16
  31. package/src/bot/handlers/session-card.ts +20 -0
  32. package/src/bot/handlers/sessions.ts +76 -15
  33. package/src/bot/handlers/usage.ts +118 -16
  34. package/src/bot/handlers/voice.ts +52 -7
  35. package/src/bot/image-return.ts +8 -5
  36. package/src/bot/menu/ephemeral.ts +13 -3
  37. package/src/bot/menu/keyboard.ts +54 -14
  38. package/src/bot/menu/refresh.ts +3 -1
  39. package/src/bot/menu/status-panel.ts +25 -6
  40. package/src/bot/permission-service.ts +19 -0
  41. package/src/bot/prompt-anchor.ts +300 -0
  42. package/src/bot/prompt-content.ts +7 -0
  43. package/src/bot/registry.ts +94 -1
  44. package/src/bot/scope.ts +94 -0
  45. package/src/bot/session-fork.ts +11 -0
  46. package/src/bot/session-runtime.ts +1254 -83
  47. package/src/bot/suggestions.ts +489 -0
  48. package/src/bot/telegram-actions.ts +440 -0
  49. package/src/bot/telegram-bots.ts +495 -0
  50. package/src/bot/telegram-io.ts +94 -10
  51. package/src/cli.ts +2 -0
  52. package/src/config.ts +242 -2
  53. package/src/forum/bind-path.ts +146 -0
  54. package/src/forum/manager.ts +651 -0
  55. package/src/forum/project-icon.ts +142 -0
  56. package/src/forum/thread.ts +16 -0
  57. package/src/forum/topic-store.ts +114 -0
  58. package/src/forum/types.ts +29 -0
  59. package/src/grok/client.ts +214 -37
  60. package/src/grok/plan-approval.ts +72 -0
  61. package/src/grok/session-log.ts +16 -0
  62. package/src/grok/types.ts +21 -2
  63. package/src/import/build-import.ts +132 -0
  64. package/src/import/history-readers.ts +681 -0
  65. package/src/import/list-running.ts +100 -0
  66. package/src/import/sources.ts +78 -0
  67. package/src/index.ts +315 -30
  68. package/src/projects/manager.ts +16 -3
  69. package/src/render/chunk.ts +17 -10
  70. package/src/render/diff.ts +11 -2
  71. package/src/render/file-summary.ts +31 -1
  72. package/src/render/hashtags.ts +5 -1
  73. package/src/render/markdown.ts +293 -35
  74. package/src/render/plan.ts +127 -0
  75. package/src/render/session-comment.ts +318 -0
  76. package/src/render/telegram-bridge.ts +360 -0
  77. package/src/render/tool-call-detail.ts +400 -19
  78. package/src/render/tool-call-merge.ts +115 -0
  79. package/src/render/tool-call.ts +444 -162
  80. package/src/render/truncate.ts +85 -0
  81. package/src/service/platform.ts +44 -7
  82. package/src/service/windows.ts +30 -6
  83. package/src/sessions/history.ts +98 -0
  84. package/src/sessions/process.ts +7 -0
  85. package/src/sessions/store.ts +3 -0
  86. package/src/sessions/types.ts +5 -0
  87. package/src/stream/streamer.ts +90 -15
  88. package/src/tasks/runner.ts +4 -3
@@ -4,7 +4,7 @@
4
4
  * and per-chat preferences (project, agent, model, reasoning). State persists
5
5
  * to the settings store so it survives restarts.
6
6
  */
7
- import { basename } from "node:path";
7
+ import { basename, join } from "node:path";
8
8
  import { type Api, InlineKeyboard } from "grammy";
9
9
  import {
10
10
  type GrokClient,
@@ -15,26 +15,80 @@ import {
15
15
  type SessionMetadata,
16
16
  } from "../grok/client.js";
17
17
  import type { AccountRotator } from "./account-rotator.js";
18
- import type { ContentBlock, PromptResult, SessionUpdate } from "../grok/types.js";
18
+ import { contentText, type ContentBlock, type PromptResult, type SessionUpdate } from "../grok/types.js";
19
19
  import type { AppConfig } from "../config.js";
20
20
  import { reasoningDirective } from "../app/reasoning.js";
21
21
  import type { SettingsStore } from "../app/settings-store.js";
22
22
  import { type PromptInput, type ReasoningEffort, textPrompt } from "../app/types.js";
23
23
  import { createLogger } from "../logger.js";
24
- import { buildTranscript } from "../sessions/history.js";
24
+ import { buildTranscript, readHistory } from "../sessions/history.js";
25
25
  import { sessionHashtags } from "../render/hashtags.js";
26
26
  import { 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";
30
30
  import { formatToolCall } from "../render/tool-call.js";
31
- import { type FileOp, fileOpFromUpdate, mergeFileOp, summarizeFileOps, summarizeFileOpsShort } from "../render/file-summary.js";
31
+ import {
32
+ mergeToolSnapshot,
33
+ snapshotHasDetail,
34
+ type ToolSnapshot,
35
+ } from "../render/tool-call-merge.js";
36
+ import {
37
+ type FileOp,
38
+ cloneFileOps,
39
+ fileOpFromUpdate,
40
+ mergeFileOp,
41
+ summarizeFileOps,
42
+ summarizeFileOpsShort,
43
+ summarizeFileOpsSplit,
44
+ } from "../render/file-summary.js";
32
45
  import { isActiveStatus, renderSubagentTransition, statusKey } from "../render/subagent.js";
33
46
  import type { PendingStage, SubagentInfo } from "../grok/types.js";
34
47
  import { ResponseStreamer } from "../stream/streamer.js";
35
48
  import { IMAGE_OUTPUT_DIRECTIVE } from "../render/image-output.js";
36
49
  import { collectTurnImagePaths, sendImages } from "./image-return.js";
37
50
  import { buildContentBlocks, mergeInputs } from "./prompt-content.js";
51
+ import { wrapAutoComplexityPrompt } from "./complexity-gate.js";
52
+ import {
53
+ autoApproveSuggestions,
54
+ buildSelfRecheckDecisionPrompt,
55
+ buildSelfRecheckPrompt,
56
+ buildSuggestionsPrompt,
57
+ composeSelfRecheckTurn,
58
+ formatBatchedSuggestionsPrompt,
59
+ isSelfRecheckPrompt,
60
+ parseSelfRecheckDecision,
61
+ parseSuggestions,
62
+ type Suggestion,
63
+ suggestionsKeyboard,
64
+ } from "./suggestions.js";
65
+ import type { ForumManager } from "../forum/manager.js";
66
+ import type { SessionStore } from "../sessions/store.js";
67
+ import type { TelegramBotService } from "./telegram-bots.js";
68
+ import { executeTelegramActions } from "./telegram-actions.js";
69
+ import {
70
+ buildTelegramBridgeDirective,
71
+ buildTelegramBridgeResultsPrompt,
72
+ extractTelegramActions,
73
+ isTelegramBridgeResultsPrompt,
74
+ wrapTelegramBridgePrompt,
75
+ } from "../render/telegram-bridge.js";
76
+ import {
77
+ parsePlanUpdate,
78
+ renderPlanMarkdown,
79
+ renderPlanOneLine,
80
+ type PlanEntry,
81
+ } from "../render/plan.js";
82
+ import {
83
+ buildSessionCardComment,
84
+ clampThinking,
85
+ cleanCommentLine,
86
+ cleanUserPreview,
87
+ COMMENT_MAX,
88
+ stepFromThought,
89
+ stepFromToolUpdate,
90
+ stripDirectiveWrappers,
91
+ } from "../render/session-comment.js";
38
92
  import {
39
93
  backoffSchedule,
40
94
  fmtSeconds,
@@ -85,6 +139,8 @@ export class SessionRuntime {
85
139
  private streamer: ResponseStreamer | undefined;
86
140
  private readonly typing: TypingIndicator;
87
141
  private shownToolIds = new Set<string>();
142
+ /** toolCallId → merged snapshot so completed updates keep title/args. */
143
+ private toolCallCache = new Map<string, ToolSnapshot>();
88
144
  /** Files touched this turn (path -> operation), tracked even in background so
89
145
  * the completion message can summarise what changed. */
90
146
  private fileOps = new Map<string, FileOp>();
@@ -101,6 +157,8 @@ export class SessionRuntime {
101
157
  private turnCount = 0;
102
158
  /** Telegram message id of the current turn's prompt, so replies thread to it. */
103
159
  private turnReplyTo: number | undefined;
160
+ /** Short id for `#prompt_<id>` on all AI messages of this turn. */
161
+ private turnPromptId: string | undefined;
104
162
  private imageScanText = "";
105
163
  private sentImagesThisTurn = new Set<string>();
106
164
  /** Monotonic count used to reject ACP "success" responses with no turn updates. */
@@ -127,6 +185,90 @@ export class SessionRuntime {
127
185
  /** Optional multi-account rotator: when a turn gives up, cycle through the
128
186
  * other saved logins once and retry on each. Injected by the registry. */
129
187
  accountRotator: AccountRotator | undefined;
188
+ /** Session ids that already received the first-prompt auto-complexity directive. */
189
+ private complexitySteered = new Set<string>();
190
+ /** Session ids that already received the first-prompt Telegram bridge directive. */
191
+ private telegramBridgeSteered = new Set<string>();
192
+ /**
193
+ * How many TELEGRAM BRIDGE RESULTS follow-ups are chained after the current
194
+ * user turn. Caps infinite list_bots/bot_command loops; reset on real user work.
195
+ */
196
+ private bridgeResultDepth = 0;
197
+ /** Max sequential bridge result turns per user request. */
198
+ private static readonly BRIDGE_CHAIN_MAX = 4;
199
+ /**
200
+ * Optional Telegram bridge services (forum / session store / sibling bots).
201
+ * Injected by the registry after construct.
202
+ */
203
+ bridge?: {
204
+ store: SessionStore;
205
+ forum?: ForumManager;
206
+ bots: TelegramBotService;
207
+ /** Cross-topic prompt dispatch (create_topic → send_prompt orchestration). */
208
+ submitTopicPrompt?: import("./telegram-actions.js").SubmitTopicPromptFn;
209
+ };
210
+ /** Last credits total reported for this session (for per-turn delta accounting). */
211
+ private lastReportedCredits = 0;
212
+ /** Live "what is happening now" line while a turn is in flight (tools/plan). */
213
+ private liveStep: string | undefined;
214
+ /**
215
+ * Card comment on disk / idle: last user prompt (≤ COMMENT_MAX).
216
+ * While busy, {@link cardComment} also appends last agent thinking.
217
+ */
218
+ private sessionComment: string | undefined;
219
+ /** Cleaned last user prompt for cards (not overwritten by self-recheck meta). */
220
+ private cardUserPrompt: string | undefined;
221
+ /** Accumulated agent_thought_chunk text for the current turn (card display). */
222
+ private cardThinking = "";
223
+ /** User text of the turn currently running (for local card-comment fallback). */
224
+ private turnUserText = "";
225
+ /** Assistant prose streamed this turn — used for suggestions / completion. */
226
+ private turnAssistantText = "";
227
+ /** Quiet meta capture (suggestions) — never stream to Telegram. */
228
+ private capturingQuiet = false;
229
+ private quietCaptureBuf = "";
230
+ /**
231
+ * Done delivery bookkeeping for this turn: expect a loud Done ping, and whether
232
+ * one was successfully sent (finally forces a short Done if expected but missing).
233
+ */
234
+ private turnExpectDone = false;
235
+ private turnDonePinged = false;
236
+ /** Batches of post-turn suggestions for inline-button callbacks. */
237
+ private suggestionBatches = new Map<number, Suggestion[]>();
238
+ private suggestionBatchSeq = 0;
239
+ /**
240
+ * Last successful Done's suggestions — kept so a background "Done from other
241
+ * session" can carry buttons, and so switching back to this session re-shows
242
+ * them even if the user missed the notify (or notify was off).
243
+ */
244
+ private pendingSuggestions:
245
+ | { batchId: number; suggestions: Suggestion[]; banner: string }
246
+ | undefined;
247
+ /** Live ACP plan board for the current turn (done / in-progress / pending). */
248
+ private planEntries: PlanEntry[] | undefined;
249
+ /** True while the active turn is the automatic one-shot self-recheck pass. */
250
+ private isSelfRecheckTurn = false;
251
+ /**
252
+ * Original user prompt (and first-pass assistant text) for suggestions after
253
+ * a self-recheck turn, so follow-ups stay grounded in the real user request.
254
+ */
255
+ private suggestionUserText = "";
256
+ private preRecheckAssistantText = "";
257
+ /** File ops from the first turn, frozen before the self-recheck pass. */
258
+ private preRecheckFileOps = new Map<string, FileOp>();
259
+ /**
260
+ * When true, this turn must not schedule a self-recheck (meta / auto-queue /
261
+ * already-recheck). Set from PromptInput.skipSelfRecheck or recheck marker.
262
+ */
263
+ private skipSelfRecheck = false;
264
+
265
+ /**
266
+ * Forum topic thread id (message_thread_id). When set, all outbound messages
267
+ * for this runtime are posted into that topic.
268
+ */
269
+ readonly messageThreadId: number | undefined;
270
+ /** Settings storage key (`chatId` or `chatId:t{threadId}`). */
271
+ readonly settingsKey: string;
130
272
 
131
273
  constructor(
132
274
  private readonly api: Api,
@@ -134,14 +276,22 @@ export class SessionRuntime {
134
276
  private readonly acp: GrokClient,
135
277
  private readonly cfg: AppConfig,
136
278
  private readonly settings: SettingsStore,
137
- init?: { cwd: string; projectName?: string; sessionId?: string },
279
+ init?: {
280
+ cwd: string;
281
+ projectName?: string;
282
+ sessionId?: string;
283
+ messageThreadId?: number;
284
+ settingsKey?: string;
285
+ },
138
286
  ) {
287
+ this.messageThreadId = init?.messageThreadId;
288
+ this.settingsKey = init?.settingsKey ?? String(chatId);
139
289
  if (init) {
140
290
  this.cwd = init.cwd;
141
291
  this.projectName = init.projectName;
142
292
  this.sessionId = init.sessionId;
143
293
  } else {
144
- const s = settings.get(chatId);
294
+ const s = settings.getKey(this.settingsKey);
145
295
  this.cwd = s.projectPath ?? cfg.workspace;
146
296
  this.projectName = s.projectName;
147
297
  this.sessionId = s.sessionId;
@@ -176,11 +326,62 @@ export class SessionRuntime {
176
326
  return this.lastCompletion;
177
327
  }
178
328
 
179
- /** Latest task-completion % (0–100) parsed this turn, or undefined if none. */
329
+ /** Latest task-completion % (0–100) parsed this turn, or undefined if none. */
180
330
  get taskProgress(): number | undefined {
181
331
  return this.progress;
182
332
  }
183
333
 
334
+ /**
335
+ * Full plan board for the live stream / status panel (above the progress bar).
336
+ * Empty when no plan is active this turn.
337
+ */
338
+ get planBoard(): string | undefined {
339
+ if (!this.planEntries?.length) return undefined;
340
+ return renderPlanMarkdown(this.planEntries);
341
+ }
342
+
343
+ /** One-line plan summary for compact cards. */
344
+ get planSummary(): string | undefined {
345
+ if (!this.planEntries?.length) return undefined;
346
+ return renderPlanOneLine(this.planEntries);
347
+ }
348
+
349
+ /**
350
+ * Pending post-turn suggestions for switch replay / Done markup.
351
+ * Returns text + keyboard without clearing (taps still resolve via batch id).
352
+ */
353
+ peekPendingSuggestions():
354
+ | { text: string; markup: InlineKeyboard; batchId: number }
355
+ | undefined {
356
+ const p = this.pendingSuggestions;
357
+ if (!p?.suggestions.length) return undefined;
358
+ return {
359
+ text: p.banner,
360
+ markup: suggestionsKeyboard(p.batchId, p.suggestions),
361
+ batchId: p.batchId,
362
+ };
363
+ }
364
+
365
+ /**
366
+ * Session card comment:
367
+ * always — last user prompt (≤250)
368
+ * busy — plus last AI agent thinking on the next line (≤250)
369
+ */
370
+ get cardComment(): string | undefined {
371
+ const user =
372
+ this.cardUserPrompt ||
373
+ this.sessionComment ||
374
+ (this.sessionId ? this.acp.sessionComment(this.sessionId) : undefined) ||
375
+ cleanUserPreview(this.suggestionUserText || this.turnUserText || "", COMMENT_MAX) ||
376
+ undefined;
377
+ const built = buildSessionCardComment({
378
+ userPrompt: user,
379
+ thinking: this.busy && this.cardThinking ? this.cardThinking : undefined,
380
+ busy: this.busy,
381
+ });
382
+ return built || undefined;
383
+ }
384
+
184
385
  /** Record a new progress value and refresh the status panel / cards. The bar
185
386
  * is monotonic within a turn (it's reset to undefined when a new turn starts),
186
387
  * so a streamer recreated mid-turn can't make it jump backwards. */
@@ -191,6 +392,62 @@ export class SessionRuntime {
191
392
  this.changed();
192
393
  }
193
394
 
395
+ /** Update the live step (tools/plan) — kept for diagnostics; cards use user+thinking. */
396
+ private setLiveStep(step: string | undefined): void {
397
+ const next = step?.trim() ? cleanCommentLine(step) : undefined;
398
+ if (next === this.liveStep) return;
399
+ this.liveStep = next;
400
+ this.changed();
401
+ }
402
+
403
+ /** Append thought text and refresh cards when the display line changes. */
404
+ private appendCardThinking(chunk: string): void {
405
+ const piece = chunk.replace(/\s+/g, " ").trim();
406
+ if (!piece) return;
407
+ const prevShown = this.cardThinking ? clampThinking(this.cardThinking, COMMENT_MAX) : "";
408
+ this.cardThinking = this.cardThinking ? `${this.cardThinking} ${piece}` : piece;
409
+ const nextShown = clampThinking(this.cardThinking, COMMENT_MAX);
410
+ if (nextShown !== prevShown) this.changed();
411
+ }
412
+
413
+ /** Persist last user prompt (disk + memory) so /running and /sessions see it. */
414
+ private setSessionComment(comment: string | undefined): void {
415
+ const next = comment?.trim()
416
+ ? cleanUserPreview(comment, COMMENT_MAX) || cleanCommentLine(comment, COMMENT_MAX)
417
+ : undefined;
418
+ if (next === this.sessionComment) return;
419
+ this.sessionComment = next;
420
+ if (next && this.sessionId) {
421
+ try {
422
+ this.acp.setSessionComment(this.sessionId, next);
423
+ } catch {
424
+ /* non-fatal */
425
+ }
426
+ }
427
+ this.changed();
428
+ }
429
+
430
+ /** Keep disk/memory comment = last real user prompt after a turn ends. */
431
+ private persistCardUserPrompt(): void {
432
+ const prompt =
433
+ this.cardUserPrompt ||
434
+ cleanUserPreview(this.suggestionUserText || this.turnUserText || "", COMMENT_MAX);
435
+ if (prompt) {
436
+ this.cardUserPrompt = prompt;
437
+ this.setSessionComment(prompt);
438
+ }
439
+ }
440
+
441
+ /** Hydrate last user prompt from disk after bind/resume. */
442
+ private loadPersistedComment(): void {
443
+ if (!this.sessionId) return;
444
+ const c = this.acp.sessionComment(this.sessionId);
445
+ if (c) {
446
+ this.sessionComment = c;
447
+ if (!this.cardUserPrompt) this.cardUserPrompt = cleanUserPreview(c, COMMENT_MAX) || c;
448
+ }
449
+ }
450
+
194
451
  /** Searchable hashtag footer for this session (project В· session В· model В·
195
452
  * reasoning) — appended to every AI-output surface for this session. */
196
453
  get tags(): string {
@@ -211,7 +468,21 @@ export class SessionRuntime {
211
468
  if (this.busy && !this.streamer) {
212
469
  // Any transient follow-watch of this session is now superseded.
213
470
  if (this.watchIsFollow) this.stopWatch();
214
- this.streamer = new ResponseStreamer(this.api, this.chatId, this.cfg.streamThrottleMs, this.turnReplyTo, this.hashtags(), (pct) => this.setProgress(pct), this.cfg.progressFallback, this.turnStartedAt);
471
+ this.streamer = new ResponseStreamer(
472
+ this.api,
473
+ this.chatId,
474
+ this.cfg.streamThrottleMs,
475
+ this.turnReplyTo,
476
+ this.hashtags(),
477
+ (pct) => this.setProgress(pct),
478
+ this.cfg.progressFallback,
479
+ this.turnStartedAt,
480
+ this.messageThreadId,
481
+ );
482
+ // Restore the live plan board so steps stay visible above the progress bar.
483
+ if (this.planEntries?.length) {
484
+ this.streamer.setPlan(renderPlanMarkdown(this.planEntries));
485
+ }
215
486
  this.typing.start();
216
487
  }
217
488
  } else {
@@ -228,13 +499,16 @@ export class SessionRuntime {
228
499
  this.changed();
229
500
  }
230
501
  get reasoning(): ReasoningEffort {
231
- return this.settings.get(this.chatId).reasoning;
502
+ return this.settings.getKey(this.settingsKey).reasoning;
232
503
  }
233
504
  get agent(): string | undefined {
234
- return this.settings.get(this.chatId).agent;
505
+ return this.settings.getKey(this.settingsKey).agent;
235
506
  }
236
507
  get model(): string | undefined {
237
- return this.settings.get(this.chatId).model;
508
+ return this.settings.getKey(this.settingsKey).model;
509
+ }
510
+ get preferredAccountId(): string | undefined {
511
+ return this.settings.getKey(this.settingsKey).preferredAccountId;
238
512
  }
239
513
 
240
514
  /** Latest context-usage % / effort / credits for the current session. */
@@ -274,6 +548,12 @@ export class SessionRuntime {
274
548
  this.rebindPending = false;
275
549
  this.cwd = cwd;
276
550
  this.projectName = projectName;
551
+ this.turnCount = 0;
552
+ this.lastReportedCredits = 0;
553
+ this.liveStep = undefined;
554
+ this.sessionComment = undefined;
555
+ this.cardUserPrompt = undefined;
556
+ this.cardThinking = "";
277
557
  await this.applySessionPrefs();
278
558
  this.persist();
279
559
  this.sessionChanged();
@@ -298,6 +578,7 @@ export class SessionRuntime {
298
578
  this.rebindPending = false;
299
579
  this.cwd = cwd;
300
580
  this.projectName = projectName;
581
+ this.loadPersistedComment();
301
582
  this.persist();
302
583
  log.info(`chat ${this.chatId} -> resumed session ${sessionId} @ ${cwd}`);
303
584
  this.changed();
@@ -320,6 +601,18 @@ export class SessionRuntime {
320
601
  }
321
602
  }
322
603
 
604
+ /**
605
+ * Start a brand-new Grok session primed with a full foreign transcript
606
+ * (import from Kiro / OpenCode / Claude / Codex). Priming is applied on the
607
+ * next {@link submit} so the imported context becomes part of Grok's history.
608
+ */
609
+ async startImportedSession(cwd: string, projectName: string | undefined, priming: string): Promise<void> {
610
+ await this.startNewSession(cwd, projectName);
611
+ if (priming.trim()) this.primingContext = priming;
612
+ // Imported transcripts already have context — skip first-prompt directives.
613
+ this.markFirstPromptSteered();
614
+ }
615
+
323
616
  startWatch(jsonlPath: string, follow = false): void {
324
617
  this.stopWatch();
325
618
  this.watchIsFollow = follow;
@@ -340,7 +633,7 @@ export class SessionRuntime {
340
633
  async setModelPref(modelId: string): Promise<{ ok: boolean; error?: string }> {
341
634
  // Persist the choice always; only talk to Grok when a session is live in
342
635
  // the current process (set_model on an unloaded session crashes the agent).
343
- this.settings.update(this.chatId, { model: modelId });
636
+ this.settings.updateKey(this.settingsKey, { model: modelId });
344
637
  if (modelId && this.sessionLive && this.sessionId) {
345
638
  if (!this.acp.hasModel(modelId)) return { ok: false, error: `unknown model: ${modelId}` };
346
639
  try {
@@ -355,7 +648,7 @@ export class SessionRuntime {
355
648
  }
356
649
 
357
650
  async setAgentPref(agent: string): Promise<void> {
358
- this.settings.update(this.chatId, { agent });
651
+ this.settings.updateKey(this.settingsKey, { agent });
359
652
  if (agent && this.sessionLive && this.sessionId && this.acp.hasMode(agent)) {
360
653
  try {
361
654
  await this.acp.setMode(this.sessionId, agent);
@@ -367,22 +660,29 @@ export class SessionRuntime {
367
660
  }
368
661
 
369
662
  setReasoningPref(effort: ReasoningEffort): void {
370
- this.settings.update(this.chatId, { reasoning: effort });
663
+ this.settings.updateKey(this.settingsKey, { reasoning: effort });
664
+ this.changed();
665
+ }
666
+
667
+ setPreferredAccountId(id: string | undefined): void {
668
+ this.settings.updateKey(this.settingsKey, { preferredAccountId: id || undefined });
669
+ // Force re-apply on next ensureSession when user changes preference.
670
+ this.preferredAccountApplied = undefined;
371
671
  this.changed();
372
672
  }
373
673
 
374
674
  private async applySessionPrefs(): Promise<void> {
375
- const s = this.settings.get(this.chatId);
675
+ const s = this.settings.getKey(this.settingsKey);
376
676
  // Drop any persisted model the agent doesn't actually offer (an unknown id
377
677
  // is silently accepted by set_model but then breaks the next prompt).
378
678
  if (s.model && !this.acp.hasModel(s.model)) {
379
- log.warn(`clearing invalid persisted model "${s.model}" for chat ${this.chatId}`);
380
- this.settings.update(this.chatId, { model: "" });
679
+ log.warn(`clearing invalid persisted model "${s.model}" for scope ${this.settingsKey}`);
680
+ this.settings.updateKey(this.settingsKey, { model: "" });
381
681
  }
382
- const cur = this.settings.get(this.chatId);
682
+ const cur = this.settings.getKey(this.settingsKey);
383
683
  // Adopt the session's current agent (mode) when the user hasn't chosen one.
384
684
  if (!cur.agent && this.acp.currentModeId) {
385
- this.settings.update(this.chatId, { agent: this.acp.currentModeId });
685
+ this.settings.updateKey(this.settingsKey, { agent: this.acp.currentModeId });
386
686
  } else if (this.sessionId && cur.agent && this.acp.hasMode(cur.agent) && cur.agent !== this.acp.currentModeId) {
387
687
  try {
388
688
  await this.acp.setMode(this.sessionId, cur.agent);
@@ -399,7 +699,7 @@ export class SessionRuntime {
399
699
  }
400
700
  }
401
701
 
402
- // в”Ђв”Ђ prompting в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
702
+ // ── prompting ────────────────────────────────────────────────────────────
403
703
 
404
704
  async submit(input: PromptInput): Promise<"ran" | "queued"> {
405
705
  await this.ensureSession();
@@ -408,13 +708,82 @@ export class SessionRuntime {
408
708
  this.changed();
409
709
  return "queued";
410
710
  }
711
+ // First-prompt steering is applied inside runTurn so queued first messages
712
+ // (and flushQueue) get the same complexity + telegram bridge directives.
411
713
  void this.runTurn(input);
412
714
  return "ran";
413
715
  }
414
716
 
717
+ private markFirstPromptSteered(): void {
718
+ if (!this.sessionId) return;
719
+ this.complexitySteered.add(this.sessionId);
720
+ this.telegramBridgeSteered.add(this.sessionId);
721
+ }
722
+
723
+ private telegramBridgeDirective(): string {
724
+ return buildTelegramBridgeDirective({
725
+ forumReady: !!this.bridge?.forum?.isReady,
726
+ topicGroupId: this.cfg.topicGroupId,
727
+ allowedBots: this.cfg.allowedTelegramBots,
728
+ botCommands: this.cfg.telegramBotCommands,
729
+ });
730
+ }
731
+
732
+ /**
733
+ * Complexity + telegram bridge teaching on the first prompt of a brand-new
734
+ * conversation only (no prior user turns in this process / session jsonl).
735
+ */
736
+ private applyFirstPromptSteering(input: PromptInput): PromptInput {
737
+ if (!this.shouldSteerFirstPrompt(input)) return input;
738
+ let toRun = wrapAutoComplexityPrompt(input);
739
+ toRun = wrapTelegramBridgePrompt(toRun, this.telegramBridgeDirective());
740
+ this.markFirstPromptSteered();
741
+ log.info(`chat ${this.chatId}: first-prompt complexity + telegram bridge applied`);
742
+ return toRun;
743
+ }
744
+
745
+ /**
746
+ * Apply first-prompt directives only on a brand-new conversation (no prior
747
+ * user turns in this process / session jsonl).
748
+ */
749
+ private shouldSteerFirstPrompt(input: PromptInput): boolean {
750
+ if (!this.sessionId) return false;
751
+ if (this.complexitySteered.has(this.sessionId) && this.telegramBridgeSteered.has(this.sessionId)) {
752
+ return false;
753
+ }
754
+ if (this.turnCount > 0) return false;
755
+ // Never wrap meta follow-ups even if somehow first.
756
+ if (
757
+ input.skipSelfRecheck ||
758
+ isSelfRecheckPrompt(input.text) ||
759
+ isTelegramBridgeResultsPrompt(input.text)
760
+ ) {
761
+ return false;
762
+ }
763
+ try {
764
+ const path = join(this.cfg.sessionsDir, `${this.sessionId}.jsonl`);
765
+ const hist = readHistory(path, 8);
766
+ if (hist.some((e) => e.role === "user" && e.text.trim().length > 0)) {
767
+ this.complexitySteered.add(this.sessionId);
768
+ this.telegramBridgeSteered.add(this.sessionId);
769
+ return false;
770
+ }
771
+ } catch {
772
+ /* treat as fresh */
773
+ }
774
+ return true;
775
+ }
776
+
777
+ /**
778
+ * Stop the current turn for this runtime only.
779
+ * Soft ACP cancel + session-scoped force-complete; never kills the shared
780
+ * agent (that would stop every multiplexed chat and take the bot offline).
781
+ */
415
782
  async cancel(): Promise<boolean> {
416
783
  if (!this.busy || !this.sessionId) return false;
417
784
  this.cancelled = true;
785
+ // Clear queue of follow-ups for this turn? No — only stop the active turn;
786
+ // queued user messages remain so the user can flush later if they want.
418
787
  await this.acp.cancel(this.sessionId);
419
788
  return true;
420
789
  }
@@ -435,6 +804,7 @@ export class SessionRuntime {
435
804
  // Account rotation restarts the process globally. Do not bind a new chat
436
805
  // to a candidate account until the owner has finished probing it.
437
806
  await this.accountRotator?.waitForIdle();
807
+ await this.applyPreferredAccount();
438
808
  if (this.rebindPending && this.sessionId) {
439
809
  // The ACP process is frequently mid-restart the first time we re-bind
440
810
  // (auto-restart after a crash, or a fresh bot boot), so a single attempt
@@ -459,13 +829,46 @@ export class SessionRuntime {
459
829
  if (!this.sessionId) await this.startNewSession(this.cwd, this.projectName);
460
830
  }
461
831
 
832
+ /** Last preferred account we successfully aligned to (avoids activate thrash). */
833
+ private preferredAccountApplied?: string;
834
+
835
+ /**
836
+ * If this scope prefers a saved account and the process is on another login,
837
+ * switch before binding the session. Skips when another turn is in flight
838
+ * (account switch restarts the agent) or we already applied this preference.
839
+ */
840
+ private async applyPreferredAccount(): Promise<void> {
841
+ const preferred = this.preferredAccountId;
842
+ const rotator = this.accountRotator;
843
+ if (!preferred || !rotator) return;
844
+ const st = rotator.state();
845
+ if (st.activeId === preferred) {
846
+ this.preferredAccountApplied = preferred;
847
+ return;
848
+ }
849
+ // User cleared or changed preference — allow one more activate.
850
+ if (this.preferredAccountApplied === preferred) return;
851
+ if (this.acp.hasInflightPrompt()) {
852
+ log.debug(`scope ${this.settingsKey}: skip preferred account (turn in flight)`);
853
+ return;
854
+ }
855
+ try {
856
+ await rotator.activate(preferred);
857
+ this.preferredAccountApplied = preferred;
858
+ log.info(`scope ${this.settingsKey}: activated preferred account ${preferred.slice(0, 8)}`);
859
+ } catch (e) {
860
+ log.debug(`preferred account activate failed: ${(e as Error).message}`);
861
+ }
862
+ }
863
+
462
864
  /** Reload a persisted session, retrying flaky failures with a short backoff.
463
865
  * Returns true once loaded, false after the attempts are exhausted. */
464
866
  private async rebindWithRetries(sessionId: string, attempts = 4): Promise<boolean> {
465
- const delays = [400, 1200, 3000]; // ≈4.6s total before giving up
867
+ const delays = [400, 1200, 3000]; // ~4.6s total before giving up
466
868
  for (let i = 0; i < attempts; i++) {
467
869
  try {
468
870
  await this.acp.loadSession(sessionId, this.cwd);
871
+ this.loadPersistedComment();
469
872
  return true;
470
873
  } catch (err) {
471
874
  log.warn(
@@ -498,13 +901,63 @@ export class SessionRuntime {
498
901
  }
499
902
 
500
903
  private async runTurn(input: PromptInput): Promise<void> {
904
+ // Apply before any turn bookkeeping so card previews / logs see the wrapped
905
+ // text the same way the agent does (also covers flushQueue first messages).
906
+ input = this.applyFirstPromptSteering(input);
907
+
501
908
  this.busy = true;
502
909
  this.cancelled = false;
503
910
  this.turnReplyTo = input.replyTo;
911
+ this.turnPromptId = input.promptId;
912
+ this.turnUserText = input.text;
913
+ this.turnAssistantText = "";
914
+ this.cardThinking = "";
915
+ this.turnExpectDone = false;
916
+ this.turnDonePinged = false;
917
+ this.isSelfRecheckTurn = isSelfRecheckPrompt(input.text);
918
+ // Meta turns (recheck, bridge results, auto-suggestion batches) never arm another recheck.
919
+ const isBridgeResults = isTelegramBridgeResultsPrompt(input.text);
920
+ this.skipSelfRecheck =
921
+ !!input.skipSelfRecheck ||
922
+ this.isSelfRecheckTurn ||
923
+ isBridgeResults;
924
+ // Fresh user work resets bridge-chain depth + suggestion anchors.
925
+ if (!this.isSelfRecheckTurn && !isBridgeResults) {
926
+ this.bridgeResultDepth = 0;
927
+ }
928
+ // Fresh user work resets suggestion anchors; recheck / bridge results keep the original ask.
929
+ // Strip complexity/reply wrappers so suggestions + recheck see the real ask.
930
+ if (!this.isSelfRecheckTurn && !isBridgeResults) {
931
+ this.suggestionUserText = stripDirectiveWrappers(input.text) || input.text;
932
+ this.preRecheckAssistantText = "";
933
+ this.preRecheckFileOps = new Map();
934
+ // Card comment: last real user prompt (not self-recheck / empty meta).
935
+ const preview = input.text.trim()
936
+ ? cleanUserPreview(input.text, COMMENT_MAX)
937
+ : input.images.length
938
+ ? "Attached image(s)"
939
+ : "";
940
+ if (preview) {
941
+ this.cardUserPrompt = preview;
942
+ this.setSessionComment(preview);
943
+ }
944
+ }
504
945
  this.shownToolIds = new Set();
946
+ this.toolCallCache = new Map();
505
947
  this.fileOps = new Map();
506
948
  this.subagentShown = new Map();
507
949
  this.progress = undefined; // a new turn = a new task; clear the old bar
950
+ this.planEntries = undefined; // plan board is per-turn
951
+ this.pendingSuggestions = undefined; // new work supersedes previous Done suggestions
952
+ this.setLiveStep(
953
+ this.isSelfRecheckTurn
954
+ ? "Self-recheck: hunting bugs / incomplete logic\u2026"
955
+ : input.text.trim()
956
+ ? `Working: ${cleanUserPreview(input.text, 110)}`
957
+ : input.images.length
958
+ ? "Working on attached image(s)\u2026"
959
+ : "Working\u2026",
960
+ );
508
961
  // A new streamed turn supersedes any transient "follow" watch of this same
509
962
  // session's previous in-flight turn (avoids duplicated output).
510
963
  if (this.watchIsFollow) this.stopWatch();
@@ -512,7 +965,17 @@ export class SessionRuntime {
512
965
  const startedAt = Date.now();
513
966
  this.turnStartedAt = startedAt;
514
967
  this.streamer = live
515
- ? new ResponseStreamer(this.api, this.chatId, this.cfg.streamThrottleMs, this.turnReplyTo, this.hashtags(), (pct) => this.setProgress(pct), this.cfg.progressFallback, startedAt)
968
+ ? new ResponseStreamer(
969
+ this.api,
970
+ this.chatId,
971
+ this.cfg.streamThrottleMs,
972
+ this.turnReplyTo,
973
+ this.hashtags(),
974
+ (pct) => this.setProgress(pct),
975
+ this.cfg.progressFallback,
976
+ startedAt,
977
+ this.messageThreadId,
978
+ )
516
979
  : undefined;
517
980
  if (live) this.typing.start();
518
981
  this.activity(true);
@@ -545,40 +1008,292 @@ export class SessionRuntime {
545
1008
  if (resumed) final = resumed;
546
1009
  const streamedOutput = this.streamer?.hasOutput ?? false;
547
1010
  // On a successful, non-cancelled turn, top the fallback bar up to 100 (a
548
- // no-op when the agent reported its own progress — its value is kept).
1011
+ // no-op when the agent reported its own progress — its value is kept).
549
1012
  if (final.result && !this.cancelled) this.streamer?.completeFallback();
550
1013
  if (this.streamer) await this.streamer.finalize();
551
1014
  if (this.foreground) await this.sendTurnImages();
1015
+
1016
+ // Telegram bridge actions (JSON fences in the agent reply). Process on
1017
+ // normal turns AND bridge-results follow-ups so multi-step bot_command /
1018
+ // search chains work; depth cap prevents infinite loops.
1019
+ let queuedBridgeResults = false;
1020
+ if (final.result && !this.cancelled) {
1021
+ queuedBridgeResults = await this.processTelegramBridgeActions();
1022
+ }
1023
+
552
1024
  // Always build the completion (records `lastCompletion` so switching back
553
1025
  // to this session can replay its Done + summary). Only PING the chat for
554
1026
  // the foreground turn, or a background turn when NOTIFY_OTHER_SESSIONS is on.
555
1027
  const canPing = this.foreground || this.cfg.notifyOtherSessions;
556
1028
  // A background session about to run a queued follow-up shouldn't ping its
557
- // interim "Done" — only the final, queue-empty turn announces completion.
1029
+ // interim "Done" — only the final, queue-empty turn announces completion.
558
1030
  const hasQueued = this.queue.length > 0;
559
1031
  const switchKb = this.switchKeyboard();
560
- if (final.result && !this.cancelled) this.turnCount++;
1032
+ if (final.result && !this.cancelled) {
1033
+ this.turnCount++;
1034
+ // Persist real per-account usage (turns + reported credits) for /accounts and /usage.
1035
+ this.recordAccountUsage();
1036
+ // Card comment: last user prompt (thinking cleared when idle).
1037
+ this.persistCardUserPrompt();
1038
+ this.cardThinking = "";
1039
+ // Keep live step while bridge/sibling-bot results are still queued —
1040
+ // clearing here made "Waiting for bot" vanish before the interim notify.
1041
+ if (!queuedBridgeResults) this.setLiveStep(undefined);
1042
+ } else if (this.cancelled) {
1043
+ this.persistCardUserPrompt();
1044
+ this.cardThinking = "";
1045
+ this.setLiveStep(undefined);
1046
+ } else if (final.error) {
1047
+ this.persistCardUserPrompt();
1048
+ this.cardThinking = "";
1049
+ this.setLiveStep(undefined);
1050
+ }
561
1051
  if (final.result || this.cancelled) {
562
- const live = this.completionMessage(final.result?.stopReason, startedAt, streamedOutput);
563
- const pingDone = canPing && (this.foreground || !hasQueued);
564
- if (pingDone) await this.notify(live, { loud: true, replyTo: this.turnReplyTo, replyMarkup: switchKb });
1052
+ // Bridge results in the queue mean "not Done yet" — never treat as a
1053
+ // completion ping even in the foreground. Do not post bridge status
1054
+ // spam to the chat (live step / status panel only).
1055
+ const pingDone =
1056
+ canPing && (this.foreground || !hasQueued) && !queuedBridgeResults;
1057
+ // Expect a Done this turn unless we defer for recheck (bridge already excluded).
1058
+ this.turnExpectDone = pingDone;
1059
+
1060
+ // One-shot self-recheck: only after a real *user* turn (not meta/auto),
1061
+ // with idle queue. skipSelfRecheck blocks loops after recheck / auto-batch.
1062
+ // Also skipped when no files were modified, or when a quiet AI decision
1063
+ // refuses (simple tasks, pure build, nothing worth re-verifying).
1064
+ // Delay recheck/Done when bridge results are queued (like self-recheck).
1065
+ const wantSelfRecheck =
1066
+ !!final.result &&
1067
+ !this.cancelled &&
1068
+ !hasQueued &&
1069
+ !queuedBridgeResults &&
1070
+ this.cfg.selfRecheckEnabled &&
1071
+ !this.skipSelfRecheck &&
1072
+ !this.isSelfRecheckTurn;
1073
+
1074
+ let queuedRecheck = false;
1075
+ if (wantSelfRecheck) {
1076
+ this.preRecheckAssistantText = this.turnAssistantText;
1077
+ // Strip COMPLEXITY wrapper so recheck + suggestions see the real ask.
1078
+ this.suggestionUserText = stripDirectiveWrappers(this.turnUserText) || this.turnUserText;
1079
+ this.preRecheckFileOps = cloneFileOps(this.fileOps);
1080
+ this.setLiveStep("Deciding if self-recheck is needed\u2026");
1081
+ this.changed();
1082
+ // Visible chat status so stream-complete is not mistaken for a silent exit.
1083
+ if (pingDone) {
1084
+ await this.notify(
1085
+ "\u{1F50D} Checking if a quality pass is needed\u2026",
1086
+ { loud: true, replyTo: this.turnReplyTo },
1087
+ );
1088
+ }
1089
+ const recheck = await this.maybePlanSelfRecheck();
1090
+ this.setLiveStep(undefined);
1091
+ // User may cancel during the quiet decision call — first turn still
1092
+ // succeeded; never queue a recheck after cancel.
1093
+ if (this.cancelled) {
1094
+ this.preRecheckFileOps = new Map();
1095
+ this.preRecheckAssistantText = "";
1096
+ } else if (recheck) {
1097
+ queuedRecheck = true;
1098
+ this.turnExpectDone = false; // final Done comes after the recheck turn
1099
+ // Front of queue; mark skip so the recheck turn never re-arms itself.
1100
+ this.queue.unshift(
1101
+ textPrompt(recheck, this.turnReplyTo, undefined, {
1102
+ skipSelfRecheck: true,
1103
+ promptId: this.turnPromptId,
1104
+ }),
1105
+ );
1106
+ this.changed();
1107
+ if (pingDone) {
1108
+ // Interim status + first-turn file list (final Done comes after recheck).
1109
+ const firstFiles = summarizeFileOps(this.preRecheckFileOps, this.cwd);
1110
+ await this.notify(
1111
+ `\u{1F50D} Self-recheck \u2014 bugs, logic gaps, related follow-through (once)\u2026\n\n` +
1112
+ `\u{1F4C1} After first turn\n${firstFiles}`,
1113
+ { loud: true, replyTo: this.turnReplyTo, replyMarkup: switchKb },
1114
+ );
1115
+ }
1116
+ } else {
1117
+ // No recheck — clear frozen first-turn ops (nothing to split later).
1118
+ this.preRecheckFileOps = new Map();
1119
+ this.preRecheckAssistantText = "";
1120
+ }
1121
+ }
1122
+
1123
+ if (!queuedRecheck && !queuedBridgeResults) {
1124
+ // Build Done text *now* (after quiet decision) so a cancel during the
1125
+ // recheck-decision wait shows ⏹ Stopped, not a stale ✅ Done head.
1126
+ let doneText = this.isSelfRecheckTurn
1127
+ ? this.completionMessageSplit(final.result?.stopReason, startedAt, streamedOutput)
1128
+ : this.completionMessage(final.result?.stopReason, startedAt, streamedOutput);
1129
+ // 1) Always send Done FIRST — never block the completion ping on the
1130
+ // quiet suggestions prompt (which can hang and look like "no Done").
1131
+ let doneMsgId: number | undefined;
1132
+ if (pingDone) {
1133
+ doneMsgId = await this.notify(doneText, {
1134
+ loud: true,
1135
+ replyTo: this.turnReplyTo,
1136
+ replyMarkup: switchKb,
1137
+ });
1138
+ if (doneMsgId !== undefined) this.turnDonePinged = true;
1139
+ }
1140
+ // 2) Suggestions after Done: edit the Done message (or send a follow-up).
1141
+ if (final.result && !this.cancelled && !hasQueued) {
1142
+ try {
1143
+ const sug = await this.collectAndApplySuggestions(doneText, switchKb, {
1144
+ // Only auto-queue high-need follow-ups when the user is watching;
1145
+ // background sessions store buttons for the Done ping / switch replay.
1146
+ autoQueue: this.foreground,
1147
+ });
1148
+ // Only enhance when suggestions actually changed the Done body.
1149
+ if (pingDone && sug.text !== doneText) {
1150
+ await this.enhanceDoneMessage(doneMsgId, sug.text, sug.markup ?? switchKb);
1151
+ }
1152
+ } catch (e) {
1153
+ log.debug(`suggestions after Done failed: ${(e as Error).message}`);
1154
+ }
1155
+ }
1156
+ // Clear frozen first-turn ops after final Done (recheck path done).
1157
+ if (this.isSelfRecheckTurn) this.preRecheckFileOps = new Map();
1158
+ }
1159
+ // queuedBridgeResults: stay quiet in chat — agent gets results via queue.
565
1160
  } else if (final.error) {
566
- const transient = isTransientError(final.error);
567
- const live = this.errorMessage(final.error, startedAt, final.attempts, transient);
568
- if (canPing) await this.notify(live, { loud: true, replyTo: this.turnReplyTo, replyMarkup: switchKb });
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) {
1164
+ const switchKb = this.switchKeyboard();
1165
+ const pingDone = canPing && (this.foreground || !hasQueued);
1166
+ this.turnExpectDone = pingDone;
1167
+ let doneText =
1168
+ this.completionMessageSplit(undefined, startedAt, streamedOutput) +
1169
+ `\n\n\u26A0\uFE0F Self-recheck failed: ${final.error.message}`;
1170
+ let doneMsgId: number | undefined;
1171
+ if (pingDone) {
1172
+ doneMsgId = await this.notify(doneText, {
1173
+ loud: true,
1174
+ replyTo: this.turnReplyTo,
1175
+ replyMarkup: switchKb,
1176
+ });
1177
+ if (doneMsgId !== undefined) this.turnDonePinged = true;
1178
+ }
1179
+ if (!this.cancelled) {
1180
+ try {
1181
+ const sug = await this.collectAndApplySuggestions(doneText, switchKb, {
1182
+ autoQueue: this.foreground,
1183
+ });
1184
+ if (pingDone && sug.text !== doneText) {
1185
+ await this.enhanceDoneMessage(doneMsgId, sug.text, sug.markup ?? switchKb);
1186
+ }
1187
+ } catch (e) {
1188
+ log.debug(`suggestions after recheck-fail Done failed: ${(e as Error).message}`);
1189
+ }
1190
+ }
1191
+ this.preRecheckFileOps = new Map();
1192
+ } else {
1193
+ const transient = isTransientError(final.error);
1194
+ const liveMsg = this.errorMessage(final.error, startedAt, final.attempts, transient);
1195
+ this.turnExpectDone = canPing;
1196
+ if (canPing) {
1197
+ const id = await this.notify(liveMsg, {
1198
+ loud: true,
1199
+ replyTo: this.turnReplyTo,
1200
+ replyMarkup: switchKb,
1201
+ });
1202
+ if (id !== undefined) this.turnDonePinged = true;
1203
+ }
1204
+ }
569
1205
  }
570
1206
  } catch (err) {
571
1207
  // Unexpected failure outside the prompt path (e.g. while finalizing).
572
1208
  await this.streamer?.finalize().catch(() => {});
573
- const msg = `\u274C Error after ${fmtDuration(Date.now() - startedAt)}: ${(err as Error).message}`;
574
- this.lastCompletion = msg;
575
- if (this.foreground || this.cfg.notifyOtherSessions) {
576
- const from = this.foreground ? "" : `\u{1F4E8} From other session ${this.sessionTag()}\n`;
577
- await this.notify(`${from}${msg}`, { loud: true, replyTo: this.turnReplyTo, replyMarkup: this.switchKeyboard() });
1209
+ const errMsg = (err as Error).message;
1210
+ this.persistCardUserPrompt();
1211
+ this.cardThinking = "";
1212
+ this.setLiveStep(undefined);
1213
+ // If the self-recheck pass itself blew up, still surface Done for the
1214
+ // original work (split files + suggestions) so the user is not stuck.
1215
+ if (this.isSelfRecheckTurn && this.queue.length === 0) {
1216
+ const switchKb = this.switchKeyboard();
1217
+ const canPing = this.foreground || this.cfg.notifyOtherSessions;
1218
+ this.turnExpectDone = canPing;
1219
+ let doneText =
1220
+ this.completionMessageSplit(undefined, startedAt, this.streamer?.hasOutput ?? false) +
1221
+ `\n\n\u26A0\uFE0F Self-recheck failed: ${errMsg}`;
1222
+ try {
1223
+ let doneMsgId: number | undefined;
1224
+ if (canPing) {
1225
+ doneMsgId = await this.notify(doneText, {
1226
+ loud: true,
1227
+ replyTo: this.turnReplyTo,
1228
+ replyMarkup: switchKb,
1229
+ });
1230
+ if (doneMsgId !== undefined) this.turnDonePinged = true;
1231
+ }
1232
+ if (!this.cancelled) {
1233
+ try {
1234
+ const sug = await this.collectAndApplySuggestions(doneText, switchKb, {
1235
+ autoQueue: this.foreground,
1236
+ });
1237
+ if (canPing && sug.text !== doneText) {
1238
+ await this.enhanceDoneMessage(doneMsgId, sug.text, sug.markup ?? switchKb);
1239
+ }
1240
+ } catch (e2) {
1241
+ log.debug(`suggestions after recheck catch failed: ${(e2 as Error).message}`);
1242
+ }
1243
+ }
1244
+ } catch (e2) {
1245
+ log.debug(`recheck catch recovery failed: ${(e2 as Error).message}`);
1246
+ if (canPing && !this.turnDonePinged) {
1247
+ const id = await this.notify(doneText, {
1248
+ loud: true,
1249
+ replyTo: this.turnReplyTo,
1250
+ replyMarkup: switchKb,
1251
+ });
1252
+ if (id !== undefined) this.turnDonePinged = true;
1253
+ }
1254
+ }
1255
+ this.preRecheckFileOps = new Map();
1256
+ } else {
1257
+ const msg = `\u274C Error after ${fmtDuration(Date.now() - startedAt)}: ${errMsg}`;
1258
+ this.lastCompletion = msg;
1259
+ const canPing = this.foreground || this.cfg.notifyOtherSessions;
1260
+ this.turnExpectDone = canPing;
1261
+ if (canPing) {
1262
+ const from = this.foreground ? "" : `\u{1F4E8} From other session ${this.sessionTag()}\n`;
1263
+ const id = await this.notify(`${from}${msg}`, {
1264
+ loud: true,
1265
+ replyTo: this.turnReplyTo,
1266
+ replyMarkup: this.switchKeyboard(),
1267
+ });
1268
+ if (id !== undefined) this.turnDonePinged = true;
1269
+ }
578
1270
  }
579
1271
  } finally {
1272
+ // Safety net: turn completed with an expected Done ping that never landed
1273
+ // (notify failed, hung path, etc.). Never block queue flush on this.
1274
+ if (this.turnExpectDone && !this.turnDonePinged) {
1275
+ const fallback =
1276
+ this.lastCompletion?.trim() ||
1277
+ `\u2705 Done \u00B7 ${fmtDuration(Date.now() - startedAt)}`;
1278
+ const short =
1279
+ fallback.length > 3500 ? fallback.slice(0, 3499) + "\u2026" : fallback;
1280
+ try {
1281
+ const id = await this.notify(short, {
1282
+ loud: true,
1283
+ replyTo: this.turnReplyTo,
1284
+ replyMarkup: this.switchKeyboard(),
1285
+ });
1286
+ if (id !== undefined) this.turnDonePinged = true;
1287
+ else log.warn(`chat ${this.chatId}: Done safety-net notify failed`);
1288
+ } catch (e) {
1289
+ log.warn(`chat ${this.chatId}: Done safety-net error: ${(e as Error).message}`);
1290
+ }
1291
+ }
1292
+ this.turnExpectDone = false;
580
1293
  this.typing.stop();
581
1294
  this.streamer = undefined;
1295
+ this.capturingQuiet = false;
1296
+ this.quietCaptureBuf = "";
582
1297
  this.busy = false;
583
1298
  this.activity(false);
584
1299
  // The in-flight turn we may have been following live is over.
@@ -587,6 +1302,10 @@ export class SessionRuntime {
587
1302
  // the bar is removed from the status panel, session cards and switch
588
1303
  // messages. The finished streamed bubble keeps its own (frozen) bar.
589
1304
  this.progress = undefined;
1305
+ this.planEntries = undefined;
1306
+ // Idle cards show last user prompt only (clear live step / thinking).
1307
+ this.cardThinking = "";
1308
+ if (!this.liveStep || this.sessionComment) this.liveStep = undefined;
590
1309
  this.changed();
591
1310
  }
592
1311
 
@@ -601,6 +1320,320 @@ export class SessionRuntime {
601
1320
  }
602
1321
  }
603
1322
 
1323
+ /**
1324
+ * Parse telegram JSON actions from the assistant reply, execute them, notify
1325
+ * the user, and queue a results follow-up for the agent when useful.
1326
+ * Returns true when a results prompt was queued (delay Done/recheck).
1327
+ */
1328
+ private async processTelegramBridgeActions(): Promise<boolean> {
1329
+ const { actions, cleaned } = extractTelegramActions(this.turnAssistantText);
1330
+ if (cleaned !== this.turnAssistantText) {
1331
+ this.turnAssistantText = cleaned;
1332
+ }
1333
+ if (actions.length === 0) return false;
1334
+ if (!this.bridge) {
1335
+ log.warn(`chat ${this.chatId}: telegram actions present but bridge not wired`);
1336
+ return false;
1337
+ }
1338
+
1339
+ const botCmds = actions.filter((a) => a.action === "bot_command");
1340
+ // bot_command means Grok ended its turn early to wait on a sibling bot —
1341
+ // this is NOT a Done. Keep busy; status panel live-step only (no chat spam).
1342
+ if (botCmds.length > 0) {
1343
+ const labels = botCmds
1344
+ .map((a) =>
1345
+ a.action === "bot_command" ? `@${a.bot} /${a.command}` : "",
1346
+ )
1347
+ .filter(Boolean)
1348
+ .join(", ");
1349
+ this.setLiveStep(`Waiting for sibling bot: ${labels}`);
1350
+ } else {
1351
+ this.setLiveStep("Running Telegram bridge actions\u2026");
1352
+ }
1353
+ this.changed();
1354
+ log.info(`chat ${this.chatId}: executing ${actions.length} telegram bridge action(s)`);
1355
+
1356
+ const results = await executeTelegramActions(actions, {
1357
+ api: this.api,
1358
+ cfg: this.cfg,
1359
+ chatId: this.chatId,
1360
+ messageThreadId: this.messageThreadId,
1361
+ forum: this.bridge.forum,
1362
+ store: this.bridge.store,
1363
+ bots: this.bridge.bots,
1364
+ submitTopicPrompt: this.bridge.submitTopicPrompt,
1365
+ });
1366
+
1367
+ // Only announce durable side-effects in chat (topic create/bind/cross-prompt).
1368
+ // search_memory / list_bots / bot wait status stay silent — results go to the agent.
1369
+ const durableActions = new Set(["create_topic", "set_path", "send_prompt"]);
1370
+ const notes = results
1371
+ .filter((r) => durableActions.has(r.action) && r.userNote?.trim())
1372
+ .map((r) => r.userNote!)
1373
+ .filter(Boolean);
1374
+ if (notes.length > 0 && this.foreground) {
1375
+ await this.notify(notes.join("\n"), {
1376
+ loud: true,
1377
+ replyTo: this.turnReplyTo,
1378
+ });
1379
+ }
1380
+
1381
+ // Cap chained result turns so a model that re-emits list_bots forever cannot
1382
+ // block Done. Side-effects already ran; user notes were sent above.
1383
+ // Still queue once when we have bot_command errors so the agent can recover.
1384
+ if (this.bridgeResultDepth >= SessionRuntime.BRIDGE_CHAIN_MAX) {
1385
+ log.warn(
1386
+ `chat ${this.chatId}: telegram bridge chain depth ${this.bridgeResultDepth} — not re-queuing results`,
1387
+ );
1388
+ return false;
1389
+ }
1390
+
1391
+ // Feed results back so the agent can use search hits / bot replies / errors.
1392
+ const prompt = buildTelegramBridgeResultsPrompt(
1393
+ results.map((r) => ({
1394
+ action: r.action,
1395
+ ok: r.ok,
1396
+ data: r.data,
1397
+ error: r.error,
1398
+ })),
1399
+ );
1400
+ this.bridgeResultDepth++;
1401
+ this.queue.unshift(
1402
+ textPrompt(prompt, this.turnReplyTo, undefined, {
1403
+ skipSelfRecheck: true,
1404
+ promptId: this.turnPromptId,
1405
+ }),
1406
+ );
1407
+ this.setLiveStep("Feeding sibling-bot / bridge results to the agent\u2026");
1408
+ this.changed();
1409
+ return true;
1410
+ }
1411
+
1412
+ /**
1413
+ * Quietly ask for 1–3 follow-ups, attach buttons to the Done text, store them
1414
+ * for switch-replay, and optionally queue auto-approved items as **one**
1415
+ * numbered multi-step prompt (`1) …\n2) …`).
1416
+ */
1417
+ private async collectAndApplySuggestions(
1418
+ doneText: string,
1419
+ switchKb: InlineKeyboard | undefined,
1420
+ opts?: { autoQueue?: boolean },
1421
+ ): Promise<{ text: string; markup?: InlineKeyboard }> {
1422
+ if (!this.cfg.suggestionsEnabled || !this.sessionId) {
1423
+ return { text: doneText, markup: switchKb };
1424
+ }
1425
+ let suggestions: Suggestion[] = [];
1426
+ try {
1427
+ suggestions = await this.fetchSuggestionsQuiet();
1428
+ } catch (e) {
1429
+ log.debug(`suggestions fetch failed: ${(e as Error).message}`);
1430
+ }
1431
+ if (suggestions.length === 0) return { text: doneText, markup: switchKb };
1432
+
1433
+ const batchId = ++this.suggestionBatchSeq;
1434
+ this.suggestionBatches.set(batchId, suggestions);
1435
+ // Bound memory: keep last ~20 batches.
1436
+ if (this.suggestionBatches.size > 20) {
1437
+ const oldest = [...this.suggestionBatches.keys()].sort((a, b) => a - b)[0]!;
1438
+ this.suggestionBatches.delete(oldest);
1439
+ }
1440
+
1441
+ const thr = this.cfg.suggestionsAutoApprovePct;
1442
+ const autoQueue = opts?.autoQueue !== false;
1443
+ const auto = autoQueue ? autoApproveSuggestions(suggestions, thr) : [];
1444
+ let text = doneText;
1445
+ let banner: string;
1446
+ if (auto.length > 0) {
1447
+ const batched = formatBatchedSuggestionsPrompt(auto);
1448
+ const lines = auto.map((s, i) => ` ${i + 1}) ${s.need}% \u2014 ${s.text}`);
1449
+ 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")}`;
1452
+ text += autoBlock;
1453
+ banner = `\u{1F4A1} Suggestions (auto-running ${auto.length} as one prompt):\n${lines.join("\n")}`;
1454
+ // Single queue entry — agent executes 1) 2) 3) in one turn.
1455
+ // skipSelfRecheck: auto-follow-ups must not arm another recheck cycle.
1456
+ this.queue.push(
1457
+ textPrompt(batched, this.turnReplyTo, undefined, {
1458
+ skipSelfRecheck: true,
1459
+ promptId: this.turnPromptId,
1460
+ }),
1461
+ );
1462
+ this.changed();
1463
+ } else {
1464
+ text += "\n\n\u{1F4A1} Suggestions \u2014 tap one to continue:";
1465
+ banner = "\u{1F4A1} Suggestions \u2014 tap one to continue:";
1466
+ }
1467
+
1468
+ // Keep for switch-to-session replay (and for Done pings that already include
1469
+ // the same keyboard). Cleared when a new turn starts.
1470
+ this.pendingSuggestions = { batchId, suggestions, banner };
1471
+
1472
+ const markup = suggestionsKeyboard(batchId, suggestions, switchKb);
1473
+ return { text, markup };
1474
+ }
1475
+
1476
+ /**
1477
+ * Decide whether to queue a self-recheck turn.
1478
+ * - Hard skip when no files were modified this turn.
1479
+ * - Quiet AI decision: refuse (simple / not needed) or write recheck prompt.
1480
+ * - Returns the full recheck turn text, or undefined to skip.
1481
+ */
1482
+ private async maybePlanSelfRecheck(): Promise<string | undefined> {
1483
+ if (this.fileOps.size === 0) {
1484
+ log.info(`chat ${this.chatId}: self-recheck skipped (no files modified)`);
1485
+ return undefined;
1486
+ }
1487
+ if (this.cancelled) return undefined;
1488
+ const user =
1489
+ this.suggestionUserText ||
1490
+ stripDirectiveWrappers(this.turnUserText) ||
1491
+ this.turnUserText;
1492
+ const did = this.turnAssistantText;
1493
+ const files = summarizeFileOpsShort(this.fileOps);
1494
+
1495
+ let decision;
1496
+ try {
1497
+ decision = await this.fetchSelfRecheckDecisionQuiet(user, did, files);
1498
+ } catch (e) {
1499
+ log.debug(`self-recheck decision failed: ${(e as Error).message}; skipping`);
1500
+ return undefined;
1501
+ }
1502
+ if (this.cancelled) return undefined;
1503
+ if (!decision.needed) {
1504
+ log.info(
1505
+ `chat ${this.chatId}: self-recheck skipped by agent` +
1506
+ (decision.reason ? ` (${decision.reason})` : ""),
1507
+ );
1508
+ return undefined;
1509
+ }
1510
+
1511
+ // Optional env template overrides the AI-written body when set.
1512
+ if (this.cfg.selfRecheckPrompt) {
1513
+ return buildSelfRecheckPrompt(user, did, this.cfg.selfRecheckPrompt);
1514
+ }
1515
+ if (decision.prompt.trim()) {
1516
+ return composeSelfRecheckTurn(decision.prompt, user, did);
1517
+ }
1518
+ // needed=true but empty prompt — fall back to built-in default template.
1519
+ return buildSelfRecheckPrompt(user, did);
1520
+ }
1521
+
1522
+ /** Quiet JSON: should we recheck, and if so what prompt? Never streams. */
1523
+ private async fetchSelfRecheckDecisionQuiet(
1524
+ user: string,
1525
+ did: string,
1526
+ filesSummary: string,
1527
+ ): Promise<ReturnType<typeof parseSelfRecheckDecision>> {
1528
+ if (!this.sessionId) return { needed: false, reason: "no session" };
1529
+ const prompt = buildSelfRecheckDecisionPrompt(user, did, filesSummary);
1530
+ try {
1531
+ const raw = await this.runQuietPrompt(prompt);
1532
+ return parseSelfRecheckDecision(raw);
1533
+ } catch (e) {
1534
+ log.debug(`self-recheck decision quiet prompt failed: ${(e as Error).message}`);
1535
+ return { needed: false, reason: "decision prompt failed" };
1536
+ }
1537
+ }
1538
+
1539
+ /** Quiet JSON suggestion turn — never streams to Telegram. */
1540
+ private async fetchSuggestionsQuiet(): Promise<Suggestion[]> {
1541
+ if (!this.sessionId) return [];
1542
+ // Prefer the original user ask (before self-recheck) so need scores stay honest.
1543
+ const user =
1544
+ this.suggestionUserText ||
1545
+ stripDirectiveWrappers(this.turnUserText) ||
1546
+ this.turnUserText;
1547
+ const didParts = [this.preRecheckAssistantText, this.turnAssistantText].filter((s) => s?.trim());
1548
+ const did = didParts.join("\n") || this.turnAssistantText;
1549
+ const prompt = buildSuggestionsPrompt(user, did);
1550
+ try {
1551
+ const raw = await this.runQuietPrompt(prompt);
1552
+ return parseSuggestions(raw);
1553
+ } catch (e) {
1554
+ log.debug(`suggestions quiet prompt failed: ${(e as Error).message}`);
1555
+ return [];
1556
+ }
1557
+ }
1558
+
1559
+ /**
1560
+ * Run a quiet meta ACP prompt (JSON only) with a hard timeout.
1561
+ * On timeout: session/cancel so the shared agent is not stuck holding the
1562
+ * session (which would block Done forever). Does NOT set this.cancelled
1563
+ * (user /stop is separate). Timed-out / partial capture is discarded.
1564
+ */
1565
+ private async runQuietPrompt(text: string): Promise<string> {
1566
+ if (!this.sessionId) return "";
1567
+ const ms = Math.max(5_000, this.cfg.quietPromptTimeoutMs || 90_000);
1568
+ this.capturingQuiet = true;
1569
+ this.quietCaptureBuf = "";
1570
+ let timedOut = false;
1571
+ let settled = false;
1572
+ const timer = setTimeout(() => {
1573
+ // Ignore timer if the prompt already settled (avoids discarding a good JSON
1574
+ // reply that finished in the same tick as the timeout).
1575
+ if (settled) return;
1576
+ timedOut = true;
1577
+ log.warn(
1578
+ `chat ${this.chatId}: quiet meta prompt timed out after ${ms}ms — cancelling session prompt`,
1579
+ );
1580
+ // Session-scoped cancel only — never kill the shared agent process.
1581
+ void this.acp.cancel(this.sessionId!);
1582
+ }, ms);
1583
+ let buf = "";
1584
+ try {
1585
+ await this.acp.prompt(this.sessionId, [{ type: "text", text }]);
1586
+ settled = true;
1587
+ clearTimeout(timer);
1588
+ buf = this.quietCaptureBuf;
1589
+ } catch (e) {
1590
+ settled = true;
1591
+ clearTimeout(timer);
1592
+ buf = this.quietCaptureBuf;
1593
+ if (!timedOut) throw e;
1594
+ log.debug(`quiet prompt ended after timeout: ${(e as Error).message}`);
1595
+ } finally {
1596
+ clearTimeout(timer);
1597
+ this.capturingQuiet = false;
1598
+ this.quietCaptureBuf = "";
1599
+ }
1600
+ // Timed-out meta replies are often half-JSON — skip rather than act on garbage.
1601
+ // If we settled successfully before the timer fired, timedOut stays false.
1602
+ if (timedOut) return "";
1603
+ return buf;
1604
+ }
1605
+
1606
+ /** Resolve a tapped suggestion button; returns the prompt text or undefined. */
1607
+ takeSuggestion(batchId: number, index: number): string | undefined {
1608
+ const batch = this.suggestionBatches.get(batchId);
1609
+ if (!batch) return undefined;
1610
+ const s = batch[index];
1611
+ if (!s) return undefined;
1612
+ return s.text;
1613
+ }
1614
+
1615
+ /** Attribute a finished turn's credits/context to the active saved account. */
1616
+ private recordAccountUsage(): void {
1617
+ const meta = this.contextInfo();
1618
+ // Grok's metadata credits are typically a running session total — store the
1619
+ // per-turn delta so /accounts totals stay accurate across many turns.
1620
+ let turnCredits: number | undefined;
1621
+ if (typeof meta?.credits === "number" && Number.isFinite(meta.credits)) {
1622
+ const delta = meta.credits - this.lastReportedCredits;
1623
+ turnCredits = delta > 0 ? delta : meta.credits > 0 && this.lastReportedCredits === 0 ? meta.credits : undefined;
1624
+ if (meta.credits >= this.lastReportedCredits) this.lastReportedCredits = meta.credits;
1625
+ else this.lastReportedCredits = meta.credits; // reset if agent restarted counters
1626
+ }
1627
+ try {
1628
+ this.accountRotator?.recordTurnUsage({
1629
+ credits: turnCredits,
1630
+ contextPct: meta?.contextUsagePercentage,
1631
+ });
1632
+ } catch {
1633
+ /* non-fatal */
1634
+ }
1635
+ }
1636
+
604
1637
  /**
605
1638
  * Show subagent ("crew") status transitions for the given (already
606
1639
  * chat-attributed) subagents, so the user sees progress while the main agent
@@ -874,6 +1907,11 @@ export class SessionRuntime {
874
1907
  try {
875
1908
  const updatesBeforePrompt = this.sessionUpdateCount;
876
1909
  const result = await this.acp.prompt(this.sessionId!, content);
1910
+ // User /stop force-complete or agent honouring session/cancel often
1911
+ // returns cancelled with zero session/update chunks — that is success.
1912
+ if (this.cancelled || result?.stopReason === "cancelled") {
1913
+ return { result: result ?? { stopReason: "cancelled" }, attempts: attempt };
1914
+ }
877
1915
  // A healthy ACP turn emits at least one session/update (text, thought,
878
1916
  // or tool event) before resolving session/prompt. Grok can otherwise
879
1917
  // report a successful end-turn after an upstream model failure; never
@@ -999,6 +2037,7 @@ export class SessionRuntime {
999
2037
  already: this.sentImagesThisTurn,
1000
2038
  max: this.cfg.agentImagesMax,
1001
2039
  replyTo: this.turnReplyTo,
2040
+ messageThreadId: this.messageThreadId,
1002
2041
  });
1003
2042
  if (n > 0) log.info(`chat ${this.chatId}: sent ${n} agent image file(s)`);
1004
2043
  } catch {
@@ -1022,6 +2061,28 @@ export class SessionRuntime {
1022
2061
  return `\u{1F4E8} From other session ${this.sessionTag()}\n${head}\n${summarizeFileOpsShort(this.fileOps)}\n\n${tags}`;
1023
2062
  }
1024
2063
 
2064
+ /**
2065
+ * Final Done after a self-recheck: head + split file lists (first turn vs recheck).
2066
+ */
2067
+ private completionMessageSplit(
2068
+ stopReason: string | undefined,
2069
+ startedAt: number,
2070
+ streamedOutput: boolean,
2071
+ ): string {
2072
+ const head = this.doneHead(stopReason, startedAt, streamedOutput);
2073
+ const tags = this.hashtags();
2074
+ const files = summarizeFileOpsSplit(this.preRecheckFileOps, this.fileOps, this.cwd);
2075
+ const base = `${head}\n${files}`;
2076
+ this.lastCompletion = `${base}\n\n${tags}`;
2077
+ if (this.foreground) {
2078
+ return streamedOutput ? base : `${base}\n\n${tags}`;
2079
+ }
2080
+ return (
2081
+ `\u{1F4E8} From other session ${this.sessionTag()}\n${head}\n` +
2082
+ `${summarizeFileOpsShort(this.preRecheckFileOps)} \u2192 recheck ${summarizeFileOpsShort(this.fileOps)}\n\n${tags}`
2083
+ );
2084
+ }
2085
+
1025
2086
  /** The compact one-line status of a finished turn (no "end_turn" noise). */
1026
2087
  private doneHead(stopReason: string | undefined, startedAt: number, streamedOutput: boolean): string {
1027
2088
  const elapsed = fmtDuration(Date.now() - startedAt);
@@ -1066,19 +2127,35 @@ export class SessionRuntime {
1066
2127
  }
1067
2128
 
1068
2129
  /** Searchable Telegram hashtags so you can pull up every message of a session
1069
- * or project by tapping the tag. */
2130
+ * or project (and this turn's prompt) by tapping the tag. */
1070
2131
  private hashtags(): string {
1071
2132
  return sessionHashtags({
1072
2133
  projectName: this.projectName,
1073
2134
  cwd: this.cwd,
1074
2135
  sessionId: this.sessionId,
2136
+ promptId: this.turnPromptId,
1075
2137
  });
1076
2138
  }
1077
2139
 
1078
2140
  private async flushQueue(): Promise<void> {
1079
2141
  if (this.queue.length === 0 || this.busy) return;
1080
- const batch = mergeInputs(this.queue.splice(0, this.queue.length));
1081
- if (this.foreground) await this.notify("\u25B6\uFE0F Processing queued message\u2026");
2142
+ // Meta / system turns (self-recheck, auto-suggestion batches) must run
2143
+ // alone: merging them with user messages corrupts the prompt and can drop
2144
+ // the one-shot skipSelfRecheck guard via text concatenation.
2145
+ const head = this.queue[0]!;
2146
+ const isMeta =
2147
+ !!head.skipSelfRecheck ||
2148
+ isSelfRecheckPrompt(head.text) ||
2149
+ isTelegramBridgeResultsPrompt(head.text);
2150
+ const batch = isMeta
2151
+ ? this.queue.shift()!
2152
+ : mergeInputs(this.queue.splice(0, this.queue.length));
2153
+ // Real user follow-ups only — never spam chat for bridge/recheck meta turns.
2154
+ if (this.foreground && !isMeta) {
2155
+ await this.notify("\u25B6\uFE0F Processing queued message\u2026", {
2156
+ replyTo: batch.replyTo,
2157
+ });
2158
+ }
1082
2159
  void this.runTurn(batch);
1083
2160
  }
1084
2161
 
@@ -1087,36 +2164,76 @@ export class SessionRuntime {
1087
2164
  this.sessionUpdateCount++;
1088
2165
  const kind = update.sessionUpdate;
1089
2166
 
2167
+ // Quiet meta turns (follow-up suggestions): capture prose only, never stream.
2168
+ if (this.capturingQuiet) {
2169
+ if (kind === "agent_message_chunk") {
2170
+ const text = contentText(update.content);
2171
+ if (text) this.quietCaptureBuf += text;
2172
+ }
2173
+ return;
2174
+ }
2175
+
1090
2176
  // Accumulate the turn's file-change summary + image-scan text even when this
1091
2177
  // session is in the background (its output isn't streamed here, but the
1092
2178
  // completion message still reports what changed / which images were made).
1093
2179
  if (kind === "tool_call" || kind === "tool_call_update") {
1094
- if (update.rawInput) this.imageScanText += " " + JSON.stringify(update.rawInput);
1095
- if (update.title) this.imageScanText += " " + update.title;
1096
- // Tool results often carry the saved path only in content_blocks (Imagine).
1097
- if (Array.isArray(update.content_blocks)) {
1098
- this.imageScanText += " " + JSON.stringify(update.content_blocks);
2180
+ // Merge early so background live-step + file ops use full title/args.
2181
+ const tid = update.toolCallId || "";
2182
+ const mergedEarly = mergeToolSnapshot(tid ? this.toolCallCache.get(tid) : undefined, update);
2183
+ if (tid) this.toolCallCache.set(tid, mergedEarly);
2184
+
2185
+ if (mergedEarly.rawInput) this.imageScanText += " " + JSON.stringify(mergedEarly.rawInput);
2186
+ if (mergedEarly.title) this.imageScanText += " " + mergedEarly.title;
2187
+ if (Array.isArray(mergedEarly.content_blocks)) {
2188
+ this.imageScanText += " " + JSON.stringify(mergedEarly.content_blocks);
1099
2189
  }
1100
- // Some agents put free-form result text on `content`.
1101
- if (update.content?.text) this.imageScanText += " " + update.content.text;
1102
- const fo = fileOpFromUpdate(update);
2190
+ const ct = contentText(mergedEarly.content);
2191
+ if (ct) this.imageScanText += " " + ct;
2192
+ const fo = fileOpFromUpdate(mergedEarly);
1103
2193
  if (fo) this.fileOps.set(fo.path, mergeFileOp(this.fileOps.get(fo.path), fo.op));
2194
+ // Live card step — always, even for background sessions.
2195
+ const step = stepFromToolUpdate(mergedEarly);
2196
+ if (step) this.setLiveStep(step);
1104
2197
  } else if (kind === "agent_message_chunk") {
1105
- const text = update.content?.text;
1106
- if (typeof text === "string") this.imageScanText += text;
2198
+ const text = contentText(update.content);
2199
+ if (text) {
2200
+ this.imageScanText += text;
2201
+ this.turnAssistantText += text;
2202
+ }
2203
+ } else if (kind === "agent_thought_chunk") {
2204
+ const text = contentText(update.content);
2205
+ if (text?.trim()) {
2206
+ this.appendCardThinking(text);
2207
+ this.setLiveStep(stepFromThought(text));
2208
+ }
2209
+ } else if (kind === "plan") {
2210
+ // Always track plan entries (background too) so switch-to-live restores the board.
2211
+ const entries = parsePlanUpdate(update);
2212
+ if (entries?.length) {
2213
+ this.planEntries = entries;
2214
+ const one = renderPlanOneLine(entries);
2215
+ if (one) this.setLiveStep(one);
2216
+ this.changed();
2217
+ }
1107
2218
  }
1108
2219
 
1109
2220
  // Only the live foreground turn streams to Telegram.
1110
2221
  if (!this.foreground || !this.streamer) return;
1111
2222
 
2223
+ if (kind === "plan") {
2224
+ if (this.planEntries?.length) {
2225
+ this.streamer.setPlan(renderPlanMarkdown(this.planEntries));
2226
+ }
2227
+ return;
2228
+ }
1112
2229
  if (kind === "agent_message_chunk") {
1113
- const text = update.content?.text;
1114
- if (typeof text === "string") this.streamer.appendOutput(text);
2230
+ const text = contentText(update.content);
2231
+ if (text) this.streamer.appendOutput(text);
1115
2232
  return;
1116
2233
  }
1117
2234
  if (kind === "agent_thought_chunk") {
1118
- const text = update.content?.text;
1119
- if (typeof text === "string") this.streamer.appendThought(text);
2235
+ const text = contentText(update.content);
2236
+ if (text) this.streamer.appendThought(text);
1120
2237
  return;
1121
2238
  }
1122
2239
  if (kind === "tool_call" || kind === "tool_call_update") {
@@ -1124,37 +2241,46 @@ export class SessionRuntime {
1124
2241
  const id = update.toolCallId || "";
1125
2242
  const status = (update.status || "").toLowerCase();
1126
2243
 
1127
- if (kind === "tool_call_update") {
1128
- // Skip "in_progress"/"pending" duplicates of an already-shown initial call.
1129
- if (status === "pending" || status === "in_progress") {
1130
- const hasNewContent =
1131
- Array.isArray(update.content_blocks) && update.content_blocks.length > 0;
1132
- if (!hasNewContent) return;
1133
- }
1134
- // For completed/failed: show once (so the user sees the final status).
1135
- const doneKey = (id || update.title || "") + ":done";
1136
- if (status === "completed" || status === "failed") {
1137
- if (this.shownToolIds.has(doneKey)) return;
1138
- this.shownToolIds.add(doneKey);
1139
- }
1140
- } else {
1141
- // Initial tool_call: dedupe by id to avoid double-showing.
1142
- const shownKey = id || `tool_call:${update.title ?? ""}`;
1143
- if (this.shownToolIds.has(shownKey)) return;
1144
- this.shownToolIds.add(shownKey);
2244
+ // Snapshot already merged above for file-ops / live step.
2245
+ const merged = (id && this.toolCallCache.get(id)) || mergeToolSnapshot(undefined, update);
2246
+
2247
+ // Skip hollow shells with nothing useful yet.
2248
+ if (!snapshotHasDetail(merged) && status !== "completed" && status !== "failed") {
2249
+ return;
1145
2250
  }
1146
2251
 
1147
- const md = formatToolCall(update, {
2252
+ // Status-only mid-flight patches (no new content/input): skip if we already
2253
+ // painted this tool once — upsert would be a no-op anyway.
2254
+ if (kind === "tool_call_update" && (status === "pending" || status === "in_progress")) {
2255
+ const hasNewContent =
2256
+ (Array.isArray(update.content_blocks) && update.content_blocks.length > 0) ||
2257
+ (Array.isArray(update.content) && (update.content as unknown[]).length > 0) ||
2258
+ (!!update.rawInput && Object.keys(update.rawInput).length > 0) ||
2259
+ update.rawOutput !== undefined;
2260
+ const key = id || `tool_call:${update.title ?? ""}`;
2261
+ if (!hasNewContent && this.shownToolIds.has(key)) return;
2262
+ }
2263
+
2264
+ const md = formatToolCall(merged, {
1148
2265
  showDiffs: this.cfg.showEditDiffs,
1149
2266
  diffMaxLines: this.cfg.diffMaxLines,
1150
2267
  });
1151
- if (md) this.streamer.addTool(md);
2268
+ if (!md) return;
2269
+
2270
+ // One live card per toolCallId: replace in place as output streams
2271
+ // (no spam of new code sections). Session/agent context keeps full output.
2272
+ const key = id || `tool_call:${merged.title ?? merged.name ?? ""}`;
2273
+ this.shownToolIds.add(key);
2274
+ if (status === "completed" || status === "failed") {
2275
+ this.shownToolIds.add(key + ":done");
2276
+ }
2277
+ this.streamer.upsertTool(id || undefined, md);
1152
2278
  }
1153
2279
  }
1154
2280
 
1155
2281
  private persist(): void {
1156
2282
  if (!this.foreground) return; // only the foreground session is the chat's restored default
1157
- this.settings.update(this.chatId, {
2283
+ this.settings.updateKey(this.settingsKey, {
1158
2284
  projectPath: this.cwd,
1159
2285
  projectName: this.projectName,
1160
2286
  sessionId: this.sessionId,
@@ -1177,20 +2303,61 @@ export class SessionRuntime {
1177
2303
  }
1178
2304
  }
1179
2305
 
2306
+ /**
2307
+ * Send a chat message. Returns Telegram message_id on success.
2308
+ * On failure, retries once truncated (~3500) without reply_markup so a long
2309
+ * Done / markup error cannot silently drop the completion ping.
2310
+ */
1180
2311
  private async notify(
1181
2312
  text: string,
1182
2313
  opts?: { loud?: boolean; replyTo?: number; replyMarkup?: InlineKeyboard },
2314
+ ): Promise<number | undefined> {
2315
+ const send = async (body: string, withMarkup: boolean): Promise<number | undefined> => {
2316
+ try {
2317
+ const extra: Record<string, unknown> = opts?.loud ? { disable_notification: false } : {};
2318
+ if (this.messageThreadId !== undefined) extra.message_thread_id = this.messageThreadId;
2319
+ if (opts?.replyTo !== undefined) {
2320
+ extra.reply_parameters = { message_id: opts.replyTo, allow_sending_without_reply: true };
2321
+ }
2322
+ if (withMarkup && opts?.replyMarkup) extra.reply_markup = opts.replyMarkup;
2323
+ const msg = await this.api.sendMessage(this.chatId, body, extra);
2324
+ return msg.message_id;
2325
+ } catch (e) {
2326
+ log.debug("notify failed:", (e as Error).message);
2327
+ return undefined;
2328
+ }
2329
+ };
2330
+ const id = await send(text, true);
2331
+ if (id !== undefined) return id;
2332
+ const short = text.length > 3500 ? text.slice(0, 3499) + "\u2026" : text;
2333
+ return send(short, false);
2334
+ }
2335
+
2336
+ /**
2337
+ * After Done was already sent, attach suggestion text + buttons by editing
2338
+ * that message (or sending a follow-up if edit fails / no message id).
2339
+ */
2340
+ private async enhanceDoneMessage(
2341
+ messageId: number | undefined,
2342
+ text: string,
2343
+ markup: InlineKeyboard | undefined,
1183
2344
  ): Promise<void> {
1184
- try {
1185
- const extra: Record<string, unknown> = opts?.loud ? { disable_notification: false } : {};
1186
- if (opts?.replyTo !== undefined) {
1187
- extra.reply_parameters = { message_id: opts.replyTo, allow_sending_without_reply: true };
2345
+ if (messageId !== undefined) {
2346
+ try {
2347
+ const extra: Record<string, unknown> = {};
2348
+ if (this.messageThreadId !== undefined) extra.message_thread_id = this.messageThreadId;
2349
+ if (markup) extra.reply_markup = markup;
2350
+ await this.api.editMessageText(this.chatId, messageId, text, extra);
2351
+ return;
2352
+ } catch (e) {
2353
+ log.debug("enhanceDoneMessage edit failed:", (e as Error).message);
1188
2354
  }
1189
- if (opts?.replyMarkup) extra.reply_markup = opts.replyMarkup;
1190
- await this.api.sendMessage(this.chatId, text, extra);
1191
- } catch {
1192
- /* non-fatal */
1193
2355
  }
2356
+ await this.notify(text, {
2357
+ loud: false,
2358
+ replyTo: this.turnReplyTo,
2359
+ replyMarkup: markup,
2360
+ });
1194
2361
  }
1195
2362
 
1196
2363
  private async onWatchEntries(entries: HistoryEntry[]): Promise<void> {
@@ -1203,7 +2370,11 @@ export class SessionRuntime {
1203
2370
  })
1204
2371
  .filter(Boolean)
1205
2372
  .join("\n\n");
1206
- if (body.trim()) await sendMarkdownDoc(this.api, this.chatId, `${body}\n\n${this.tags}`);
2373
+ if (body.trim()) {
2374
+ await sendMarkdownDoc(this.api, this.chatId, `${body}\n\n${this.tags}`, {
2375
+ messageThreadId: this.messageThreadId,
2376
+ });
2377
+ }
1207
2378
  }
1208
2379
  }
1209
2380