grok-telegram-bot 2.3.0 → 2.4.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 (68) hide show
  1. package/.env.example +26 -0
  2. package/CHANGELOG.md +55 -0
  3. package/package.json +1 -1
  4. package/scripts/analyze-jsonl.ts +33 -0
  5. package/scripts/delayed-restart.ps1 +29 -0
  6. package/scripts/probe-exit-response-shape.py +77 -0
  7. package/scripts/probe-plan-exit.py +60 -0
  8. package/scripts/probe-plan-exit2.py +48 -0
  9. package/scripts/probe-plan-fields.py +41 -0
  10. package/scripts/probe-plan-fields2.py +58 -0
  11. package/scripts/probe-plan-response-path.py +48 -0
  12. package/scripts/sample-claude-tooluse.ts +21 -0
  13. package/scripts/sample-kiro-events.ts +31 -0
  14. package/scripts/smoke-exit-plan.ts +274 -0
  15. package/scripts/smoke-exit-shapes.ts +252 -0
  16. package/scripts/smoke-import.mjs +82 -0
  17. package/scripts/smoke-import.ts +73 -0
  18. package/src/app/accounts.ts +84 -0
  19. package/src/app/instance-lock.ts +6 -0
  20. package/src/app/types.ts +19 -2
  21. package/src/app/updater.ts +17 -6
  22. package/src/app/usage.ts +204 -7
  23. package/src/bot/account-rotator.ts +71 -2
  24. package/src/bot/bot.ts +36 -0
  25. package/src/bot/chat-controller.ts +35 -0
  26. package/src/bot/commands.ts +2 -0
  27. package/src/bot/complexity-gate.ts +69 -0
  28. package/src/bot/deps.ts +19 -0
  29. package/src/bot/handlers/accounts.ts +55 -5
  30. package/src/bot/handlers/import-session.ts +290 -0
  31. package/src/bot/handlers/menu.ts +17 -38
  32. package/src/bot/handlers/message.ts +1 -0
  33. package/src/bot/handlers/running.ts +35 -5
  34. package/src/bot/handlers/session-card.ts +12 -0
  35. package/src/bot/handlers/sessions.ts +14 -3
  36. package/src/bot/handlers/usage.ts +118 -16
  37. package/src/bot/menu/keyboard.ts +5 -4
  38. package/src/bot/menu/status-panel.ts +19 -6
  39. package/src/bot/prompt-content.ts +4 -0
  40. package/src/bot/reauth-controller.ts +2 -2
  41. package/src/bot/session-fork.ts +11 -0
  42. package/src/bot/session-runtime.ts +831 -64
  43. package/src/bot/suggestions.ts +429 -0
  44. package/src/config.ts +41 -0
  45. package/src/grok/client.ts +106 -20
  46. package/src/grok/plan-approval.ts +72 -0
  47. package/src/grok/session-log.ts +16 -0
  48. package/src/grok/types.ts +21 -2
  49. package/src/import/build-import.ts +132 -0
  50. package/src/import/history-readers.ts +681 -0
  51. package/src/import/list-running.ts +100 -0
  52. package/src/import/sources.ts +78 -0
  53. package/src/index.ts +179 -24
  54. package/src/render/diff.ts +11 -2
  55. package/src/render/file-summary.ts +31 -1
  56. package/src/render/markdown.ts +293 -35
  57. package/src/render/plan.ts +127 -0
  58. package/src/render/session-comment.ts +261 -0
  59. package/src/render/tool-call-detail.ts +400 -19
  60. package/src/render/tool-call-merge.ts +115 -0
  61. package/src/render/tool-call.ts +405 -142
  62. package/src/render/truncate.ts +85 -0
  63. package/src/service/windows.ts +14 -2
  64. package/src/sessions/history.ts +57 -0
  65. package/src/sessions/store.ts +3 -0
  66. package/src/sessions/types.ts +5 -0
  67. package/src/stream/streamer.ts +73 -9
  68. package/src/tasks/runner.ts +4 -3
@@ -4,36 +4,78 @@
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,
11
11
  isAccountRotationError,
12
12
  isContextExhaustedError,
13
+ isSessionLifecycleError,
13
14
  isTransientError,
14
15
  type SessionMetadata,
15
16
  } from "../grok/client.js";
16
17
  import type { AccountRotator } from "./account-rotator.js";
17
- import type { ContentBlock, PromptResult, SessionUpdate } from "../grok/types.js";
18
+ import { contentText, type ContentBlock, type PromptResult, type SessionUpdate } from "../grok/types.js";
18
19
  import type { AppConfig } from "../config.js";
19
20
  import { reasoningDirective } from "../app/reasoning.js";
20
21
  import type { SettingsStore } from "../app/settings-store.js";
21
22
  import { type PromptInput, type ReasoningEffort, textPrompt } from "../app/types.js";
22
23
  import { createLogger } from "../logger.js";
23
- import { buildTranscript } from "../sessions/history.js";
24
+ import { buildTranscript, readHistory } from "../sessions/history.js";
24
25
  import { sessionHashtags } from "../render/hashtags.js";
25
26
  import { PROGRESS_DIRECTIVE } from "../render/progress.js";
26
27
  import { buildPriming, recentTranscript } from "./session-fork.js";
27
28
  import { TailWatcher } from "../sessions/tail.js";
28
29
  import type { HistoryEntry } from "../sessions/types.js";
29
30
  import { formatToolCall } from "../render/tool-call.js";
30
- 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";
31
45
  import { isActiveStatus, renderSubagentTransition, statusKey } from "../render/subagent.js";
32
46
  import type { PendingStage, SubagentInfo } from "../grok/types.js";
33
47
  import { ResponseStreamer } from "../stream/streamer.js";
34
48
  import { IMAGE_OUTPUT_DIRECTIVE } from "../render/image-output.js";
35
49
  import { collectTurnImagePaths, sendImages } from "./image-return.js";
36
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 {
66
+ parsePlanUpdate,
67
+ renderPlanMarkdown,
68
+ renderPlanOneLine,
69
+ type PlanEntry,
70
+ } from "../render/plan.js";
71
+ import {
72
+ buildLastTurnSummary,
73
+ cleanCommentLine,
74
+ cleanUserPreview,
75
+ stepFromThought,
76
+ stepFromToolUpdate,
77
+ stripDirectiveWrappers,
78
+ } from "../render/session-comment.js";
37
79
  import {
38
80
  backoffSchedule,
39
81
  fmtSeconds,
@@ -84,6 +126,8 @@ export class SessionRuntime {
84
126
  private streamer: ResponseStreamer | undefined;
85
127
  private readonly typing: TypingIndicator;
86
128
  private shownToolIds = new Set<string>();
129
+ /** toolCallId → merged snapshot so completed updates keep title/args. */
130
+ private toolCallCache = new Map<string, ToolSnapshot>();
87
131
  /** Files touched this turn (path -> operation), tracked even in background so
88
132
  * the completion message can summarise what changed. */
89
133
  private fileOps = new Map<string, FileOp>();
@@ -126,6 +170,49 @@ export class SessionRuntime {
126
170
  /** Optional multi-account rotator: when a turn gives up, cycle through the
127
171
  * other saved logins once and retry on each. Injected by the registry. */
128
172
  accountRotator: AccountRotator | undefined;
173
+ /** Session ids that already received the first-prompt auto-complexity directive. */
174
+ private complexitySteered = new Set<string>();
175
+ /** Last credits total reported for this session (for per-turn delta accounting). */
176
+ private lastReportedCredits = 0;
177
+ /** Live "what is happening now" line while a turn is in flight. */
178
+ private liveStep: string | undefined;
179
+ /** Idle card comment (AI/local summary of the chat after the last turn). */
180
+ private sessionComment: string | undefined;
181
+ /** User text of the turn currently running (for local card-comment fallback). */
182
+ private turnUserText = "";
183
+ /** Assistant prose streamed this turn — used to build the idle card summary. */
184
+ private turnAssistantText = "";
185
+ /** Quiet meta capture (suggestions) — never stream to Telegram. */
186
+ private capturingQuiet = false;
187
+ private quietCaptureBuf = "";
188
+ /** Batches of post-turn suggestions for inline-button callbacks. */
189
+ private suggestionBatches = new Map<number, Suggestion[]>();
190
+ private suggestionBatchSeq = 0;
191
+ /**
192
+ * Last successful Done's suggestions — kept so a background "Done from other
193
+ * session" can carry buttons, and so switching back to this session re-shows
194
+ * them even if the user missed the notify (or notify was off).
195
+ */
196
+ private pendingSuggestions:
197
+ | { batchId: number; suggestions: Suggestion[]; banner: string }
198
+ | undefined;
199
+ /** Live ACP plan board for the current turn (done / in-progress / pending). */
200
+ private planEntries: PlanEntry[] | undefined;
201
+ /** True while the active turn is the automatic one-shot self-recheck pass. */
202
+ private isSelfRecheckTurn = false;
203
+ /**
204
+ * Original user prompt (and first-pass assistant text) for suggestions after
205
+ * a self-recheck turn, so follow-ups stay grounded in the real user request.
206
+ */
207
+ private suggestionUserText = "";
208
+ private preRecheckAssistantText = "";
209
+ /** File ops from the first turn, frozen before the self-recheck pass. */
210
+ private preRecheckFileOps = new Map<string, FileOp>();
211
+ /**
212
+ * When true, this turn must not schedule a self-recheck (meta / auto-queue /
213
+ * already-recheck). Set from PromptInput.skipSelfRecheck or recheck marker.
214
+ */
215
+ private skipSelfRecheck = false;
129
216
 
130
217
  constructor(
131
218
  private readonly api: Api,
@@ -175,11 +262,53 @@ export class SessionRuntime {
175
262
  return this.lastCompletion;
176
263
  }
177
264
 
178
- /** Latest task-completion % (0–100) parsed this turn, or undefined if none. */
265
+ /** Latest task-completion % (0100) parsed this turn, or undefined if none. */
179
266
  get taskProgress(): number | undefined {
180
267
  return this.progress;
181
268
  }
182
269
 
270
+ /**
271
+ * Full plan board for the live stream / status panel (above the progress bar).
272
+ * Empty when no plan is active this turn.
273
+ */
274
+ get planBoard(): string | undefined {
275
+ if (!this.planEntries?.length) return undefined;
276
+ return renderPlanMarkdown(this.planEntries);
277
+ }
278
+
279
+ /** One-line plan summary for compact cards. */
280
+ get planSummary(): string | undefined {
281
+ if (!this.planEntries?.length) return undefined;
282
+ return renderPlanOneLine(this.planEntries);
283
+ }
284
+
285
+ /**
286
+ * Pending post-turn suggestions for switch replay / Done markup.
287
+ * Returns text + keyboard without clearing (taps still resolve via batch id).
288
+ */
289
+ peekPendingSuggestions():
290
+ | { text: string; markup: InlineKeyboard; batchId: number }
291
+ | undefined {
292
+ const p = this.pendingSuggestions;
293
+ if (!p?.suggestions.length) return undefined;
294
+ return {
295
+ text: p.banner,
296
+ markup: suggestionsKeyboard(p.batchId, p.suggestions),
297
+ batchId: p.batchId,
298
+ };
299
+ }
300
+
301
+ /**
302
+ * One-line status for Running/Sessions cards:
303
+ * live step while busy, otherwise the last chat summary / comment.
304
+ */
305
+ get cardComment(): string | undefined {
306
+ if (this.busy && this.liveStep) return this.liveStep;
307
+ if (this.sessionComment) return this.sessionComment;
308
+ if (this.sessionId) return this.acp.sessionComment(this.sessionId);
309
+ return undefined;
310
+ }
311
+
183
312
  /** Record a new progress value and refresh the status panel / cards. The bar
184
313
  * is monotonic within a turn (it's reset to undefined when a new turn starts),
185
314
  * so a streamer recreated mid-turn can't make it jump backwards. */
@@ -190,6 +319,36 @@ export class SessionRuntime {
190
319
  this.changed();
191
320
  }
192
321
 
322
+ /** Update the live step shown on session cards (throttled by equality). */
323
+ private setLiveStep(step: string | undefined): void {
324
+ const next = step?.trim() ? cleanCommentLine(step) : undefined;
325
+ if (next === this.liveStep) return;
326
+ this.liveStep = next;
327
+ this.changed();
328
+ }
329
+
330
+ /** Persist idle card comment (disk + memory) so /running and /sessions see it. */
331
+ private setSessionComment(comment: string | undefined): void {
332
+ const next = comment?.trim() ? cleanCommentLine(comment) : undefined;
333
+ if (next === this.sessionComment) return;
334
+ this.sessionComment = next;
335
+ if (next && this.sessionId) {
336
+ try {
337
+ this.acp.setSessionComment(this.sessionId, next);
338
+ } catch {
339
+ /* non-fatal */
340
+ }
341
+ }
342
+ this.changed();
343
+ }
344
+
345
+ /** Hydrate comment from disk after bind/resume. */
346
+ private loadPersistedComment(): void {
347
+ if (!this.sessionId) return;
348
+ const c = this.acp.sessionComment(this.sessionId);
349
+ if (c) this.sessionComment = c;
350
+ }
351
+
193
352
  /** Searchable hashtag footer for this session (project В· session В· model В·
194
353
  * reasoning) — appended to every AI-output surface for this session. */
195
354
  get tags(): string {
@@ -211,6 +370,10 @@ export class SessionRuntime {
211
370
  // Any transient follow-watch of this session is now superseded.
212
371
  if (this.watchIsFollow) this.stopWatch();
213
372
  this.streamer = new ResponseStreamer(this.api, this.chatId, this.cfg.streamThrottleMs, this.turnReplyTo, this.hashtags(), (pct) => this.setProgress(pct), this.cfg.progressFallback, this.turnStartedAt);
373
+ // Restore the live plan board so steps stay visible above the progress bar.
374
+ if (this.planEntries?.length) {
375
+ this.streamer.setPlan(renderPlanMarkdown(this.planEntries));
376
+ }
214
377
  this.typing.start();
215
378
  }
216
379
  } else {
@@ -256,6 +419,7 @@ export class SessionRuntime {
256
419
  // в”Ђв”Ђ sessions в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
257
420
 
258
421
  async startNewSession(cwd: string, projectName?: string): Promise<void> {
422
+ await this.accountRotator?.waitForIdle();
259
423
  if (this.busy) await this.cancel();
260
424
  await this.bindNewSession(cwd, projectName);
261
425
  }
@@ -272,6 +436,10 @@ export class SessionRuntime {
272
436
  this.rebindPending = false;
273
437
  this.cwd = cwd;
274
438
  this.projectName = projectName;
439
+ this.turnCount = 0;
440
+ this.lastReportedCredits = 0;
441
+ this.liveStep = undefined;
442
+ this.sessionComment = undefined;
275
443
  await this.applySessionPrefs();
276
444
  this.persist();
277
445
  this.sessionChanged();
@@ -296,6 +464,7 @@ export class SessionRuntime {
296
464
  this.rebindPending = false;
297
465
  this.cwd = cwd;
298
466
  this.projectName = projectName;
467
+ this.loadPersistedComment();
299
468
  this.persist();
300
469
  log.info(`chat ${this.chatId} -> resumed session ${sessionId} @ ${cwd}`);
301
470
  this.changed();
@@ -318,6 +487,18 @@ export class SessionRuntime {
318
487
  }
319
488
  }
320
489
 
490
+ /**
491
+ * Start a brand-new Grok session primed with a full foreign transcript
492
+ * (import from Kiro / OpenCode / Claude / Codex). Priming is applied on the
493
+ * next {@link submit} so the imported context becomes part of Grok's history.
494
+ */
495
+ async startImportedSession(cwd: string, projectName: string | undefined, priming: string): Promise<void> {
496
+ await this.startNewSession(cwd, projectName);
497
+ if (priming.trim()) this.primingContext = priming;
498
+ // Imported transcripts already have context — skip first-prompt complexity steering.
499
+ this.markComplexitySteered();
500
+ }
501
+
321
502
  startWatch(jsonlPath: string, follow = false): void {
322
503
  this.stopWatch();
323
504
  this.watchIsFollow = follow;
@@ -397,7 +578,7 @@ export class SessionRuntime {
397
578
  }
398
579
  }
399
580
 
400
- // в”Ђв”Ђ prompting в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
581
+ // ── prompting ────────────────────────────────────────────────────────────
401
582
 
402
583
  async submit(input: PromptInput): Promise<"ran" | "queued"> {
403
584
  await this.ensureSession();
@@ -406,10 +587,43 @@ export class SessionRuntime {
406
587
  this.changed();
407
588
  return "queued";
408
589
  }
409
- void this.runTurn(input);
590
+ // First prompt of a fresh session: steer the agent to decide complexity
591
+ // itself (plan if complex, implement if simple) — never ask the user.
592
+ let toRun = input;
593
+ if (this.shouldSteerComplexity()) {
594
+ toRun = wrapAutoComplexityPrompt(input);
595
+ this.markComplexitySteered();
596
+ log.info(`chat ${this.chatId}: first-prompt auto-complexity steering applied`);
597
+ }
598
+ void this.runTurn(toRun);
410
599
  return "ran";
411
600
  }
412
601
 
602
+ private markComplexitySteered(): void {
603
+ if (this.sessionId) this.complexitySteered.add(this.sessionId);
604
+ }
605
+
606
+ /**
607
+ * Apply auto-complexity directive only on the first prompt of a brand-new
608
+ * conversation (no prior user turns in this process / session jsonl).
609
+ */
610
+ private shouldSteerComplexity(): boolean {
611
+ if (!this.sessionId) return false;
612
+ if (this.complexitySteered.has(this.sessionId)) return false;
613
+ if (this.turnCount > 0) return false;
614
+ try {
615
+ const path = join(this.cfg.sessionsDir, `${this.sessionId}.jsonl`);
616
+ const hist = readHistory(path, 8);
617
+ if (hist.some((e) => e.role === "user" && e.text.trim().length > 0)) {
618
+ this.complexitySteered.add(this.sessionId);
619
+ return false;
620
+ }
621
+ } catch {
622
+ /* treat as fresh */
623
+ }
624
+ return true;
625
+ }
626
+
413
627
  async cancel(): Promise<boolean> {
414
628
  if (!this.busy || !this.sessionId) return false;
415
629
  this.cancelled = true;
@@ -430,6 +644,9 @@ export class SessionRuntime {
430
644
  }
431
645
 
432
646
  private async ensureSession(): Promise<void> {
647
+ // Account rotation restarts the process globally. Do not bind a new chat
648
+ // to a candidate account until the owner has finished probing it.
649
+ await this.accountRotator?.waitForIdle();
433
650
  if (this.rebindPending && this.sessionId) {
434
651
  // The ACP process is frequently mid-restart the first time we re-bind
435
652
  // (auto-restart after a crash, or a fresh bot boot), so a single attempt
@@ -457,10 +674,11 @@ export class SessionRuntime {
457
674
  /** Reload a persisted session, retrying flaky failures with a short backoff.
458
675
  * Returns true once loaded, false after the attempts are exhausted. */
459
676
  private async rebindWithRetries(sessionId: string, attempts = 4): Promise<boolean> {
460
- const delays = [400, 1200, 3000]; // ≈4.6s total before giving up
677
+ const delays = [400, 1200, 3000]; // ~4.6s total before giving up
461
678
  for (let i = 0; i < attempts; i++) {
462
679
  try {
463
680
  await this.acp.loadSession(sessionId, this.cwd);
681
+ this.loadPersistedComment();
464
682
  return true;
465
683
  } catch (err) {
466
684
  log.warn(
@@ -496,10 +714,34 @@ export class SessionRuntime {
496
714
  this.busy = true;
497
715
  this.cancelled = false;
498
716
  this.turnReplyTo = input.replyTo;
717
+ this.turnUserText = input.text;
718
+ this.turnAssistantText = "";
719
+ this.isSelfRecheckTurn = isSelfRecheckPrompt(input.text);
720
+ // Meta turns (recheck, auto-suggestion batches) never arm another recheck.
721
+ this.skipSelfRecheck = !!input.skipSelfRecheck || this.isSelfRecheckTurn;
722
+ // Fresh user work resets suggestion anchors; recheck keeps the original ask.
723
+ // Strip complexity/reply wrappers so suggestions + recheck see the real ask.
724
+ if (!this.isSelfRecheckTurn) {
725
+ this.suggestionUserText = stripDirectiveWrappers(input.text) || input.text;
726
+ this.preRecheckAssistantText = "";
727
+ this.preRecheckFileOps = new Map();
728
+ }
499
729
  this.shownToolIds = new Set();
730
+ this.toolCallCache = new Map();
500
731
  this.fileOps = new Map();
501
732
  this.subagentShown = new Map();
502
733
  this.progress = undefined; // a new turn = a new task; clear the old bar
734
+ this.planEntries = undefined; // plan board is per-turn
735
+ this.pendingSuggestions = undefined; // new work supersedes previous Done suggestions
736
+ this.setLiveStep(
737
+ this.isSelfRecheckTurn
738
+ ? "Self-recheck: hunting bugs / incomplete logic\u2026"
739
+ : input.text.trim()
740
+ ? `Working: ${cleanUserPreview(input.text, 110)}`
741
+ : input.images.length
742
+ ? "Working on attached image(s)\u2026"
743
+ : "Working\u2026",
744
+ );
503
745
  // A new streamed turn supersedes any transient "follow" watch of this same
504
746
  // session's previous in-flight turn (avoids duplicated output).
505
747
  if (this.watchIsFollow) this.stopWatch();
@@ -525,8 +767,10 @@ export class SessionRuntime {
525
767
 
526
768
  try {
527
769
  const outcome = await this.runPromptWithRetries(content);
528
- const recovered = await this.maybeAutoFork(input, outcome);
529
- let final = recovered ?? outcome;
770
+ const rebound = await this.maybeRecoverAgentSession(input, outcome);
771
+ let final = rebound ?? outcome;
772
+ const recovered = await this.maybeAutoFork(input, final);
773
+ final = recovered ?? final;
530
774
  // Last resort: if the turn still failed, rotate through other saved
531
775
  // accounts (once) and retry on each until one works.
532
776
  const rotated = await this.maybeRotateAccount(input, final);
@@ -538,7 +782,7 @@ export class SessionRuntime {
538
782
  if (resumed) final = resumed;
539
783
  const streamedOutput = this.streamer?.hasOutput ?? false;
540
784
  // On a successful, non-cancelled turn, top the fallback bar up to 100 (a
541
- // no-op when the agent reported its own progress — its value is kept).
785
+ // no-op when the agent reported its own progress its value is kept).
542
786
  if (final.result && !this.cancelled) this.streamer?.completeFallback();
543
787
  if (this.streamer) await this.streamer.finalize();
544
788
  if (this.foreground) await this.sendTurnImages();
@@ -547,31 +791,219 @@ export class SessionRuntime {
547
791
  // the foreground turn, or a background turn when NOTIFY_OTHER_SESSIONS is on.
548
792
  const canPing = this.foreground || this.cfg.notifyOtherSessions;
549
793
  // A background session about to run a queued follow-up shouldn't ping its
550
- // interim "Done" — only the final, queue-empty turn announces completion.
794
+ // interim "Done" only the final, queue-empty turn announces completion.
551
795
  const hasQueued = this.queue.length > 0;
552
796
  const switchKb = this.switchKeyboard();
553
- if (final.result && !this.cancelled) this.turnCount++;
797
+ if (final.result && !this.cancelled) {
798
+ this.turnCount++;
799
+ // Persist real per-account usage (turns + reported credits) for /accounts and /usage.
800
+ this.recordAccountUsage();
801
+ // Card comment: what this turn solved (assistant result + files) — no extra agent call.
802
+ this.setSessionComment(
803
+ buildLastTurnSummary({
804
+ userText: this.turnUserText,
805
+ assistantText: this.turnAssistantText,
806
+ fileOps: this.fileOps,
807
+ stopReason: final.result.stopReason,
808
+ }),
809
+ );
810
+ this.setLiveStep(undefined);
811
+ } else if (this.cancelled) {
812
+ this.setSessionComment(
813
+ buildLastTurnSummary({
814
+ userText: this.turnUserText,
815
+ assistantText: this.turnAssistantText,
816
+ fileOps: this.fileOps,
817
+ cancelled: true,
818
+ }),
819
+ );
820
+ this.setLiveStep(undefined);
821
+ } else if (final.error) {
822
+ this.setSessionComment(
823
+ buildLastTurnSummary({
824
+ userText: this.turnUserText,
825
+ assistantText: this.turnAssistantText,
826
+ fileOps: this.fileOps,
827
+ error: final.error.message,
828
+ }),
829
+ );
830
+ this.setLiveStep(undefined);
831
+ }
554
832
  if (final.result || this.cancelled) {
555
- const live = this.completionMessage(final.result?.stopReason, startedAt, streamedOutput);
833
+ const liveMsg = this.completionMessage(final.result?.stopReason, startedAt, streamedOutput);
556
834
  const pingDone = canPing && (this.foreground || !hasQueued);
557
- if (pingDone) await this.notify(live, { loud: true, replyTo: this.turnReplyTo, replyMarkup: switchKb });
835
+
836
+ // One-shot self-recheck: only after a real *user* turn (not meta/auto),
837
+ // with idle queue. skipSelfRecheck blocks loops after recheck / auto-batch.
838
+ // Also skipped when no files were modified, or when a quiet AI decision
839
+ // refuses (simple tasks, pure build, nothing worth re-verifying).
840
+ const wantSelfRecheck =
841
+ !!final.result &&
842
+ !this.cancelled &&
843
+ !hasQueued &&
844
+ this.cfg.selfRecheckEnabled &&
845
+ !this.skipSelfRecheck &&
846
+ !this.isSelfRecheckTurn;
847
+
848
+ let queuedRecheck = false;
849
+ if (wantSelfRecheck) {
850
+ this.preRecheckAssistantText = this.turnAssistantText;
851
+ // Strip COMPLEXITY wrapper so recheck + suggestions see the real ask.
852
+ this.suggestionUserText = stripDirectiveWrappers(this.turnUserText) || this.turnUserText;
853
+ this.preRecheckFileOps = cloneFileOps(this.fileOps);
854
+ this.setLiveStep("Deciding if self-recheck is needed\u2026");
855
+ this.changed();
856
+ const recheck = await this.maybePlanSelfRecheck();
857
+ this.setLiveStep(undefined);
858
+ // User may cancel during the quiet decision call — first turn still
859
+ // succeeded; never queue a recheck after cancel.
860
+ if (this.cancelled) {
861
+ this.preRecheckFileOps = new Map();
862
+ this.preRecheckAssistantText = "";
863
+ } else if (recheck) {
864
+ queuedRecheck = true;
865
+ // Front of queue; mark skip so the recheck turn never re-arms itself.
866
+ this.queue.unshift(
867
+ textPrompt(recheck, this.turnReplyTo, undefined, { skipSelfRecheck: true }),
868
+ );
869
+ this.changed();
870
+ if (pingDone) {
871
+ // Interim status + first-turn file list (final Done comes after recheck).
872
+ const firstFiles = summarizeFileOps(this.preRecheckFileOps, this.cwd);
873
+ await this.notify(
874
+ `\u{1F50D} Self-recheck \u2014 bugs, logic gaps, related follow-through (once)\u2026\n\n` +
875
+ `\u{1F4C1} After first turn\n${firstFiles}`,
876
+ { loud: true, replyTo: this.turnReplyTo, replyMarkup: switchKb },
877
+ );
878
+ }
879
+ } else {
880
+ // No recheck — clear frozen first-turn ops (nothing to split later).
881
+ this.preRecheckFileOps = new Map();
882
+ this.preRecheckAssistantText = "";
883
+ }
884
+ }
885
+
886
+ if (!queuedRecheck) {
887
+ // Post-turn suggestions on successful, non-cancelled Done with idle queue —
888
+ // both foreground and background (so switch-to-session can re-show them).
889
+ let doneMarkup = switchKb;
890
+ // After a recheck pass, rebuild Done with split first-turn / recheck files.
891
+ let doneText = this.isSelfRecheckTurn
892
+ ? this.completionMessageSplit(final.result?.stopReason, startedAt, streamedOutput)
893
+ : liveMsg;
894
+ if (final.result && !this.cancelled && !hasQueued) {
895
+ const sug = await this.collectAndApplySuggestions(doneText, switchKb, {
896
+ // Only auto-queue high-need follow-ups when the user is watching;
897
+ // background sessions store buttons for the Done ping / switch replay.
898
+ autoQueue: this.foreground,
899
+ });
900
+ doneText = sug.text;
901
+ doneMarkup = sug.markup;
902
+ }
903
+ if (pingDone) await this.notify(doneText, { loud: true, replyTo: this.turnReplyTo, replyMarkup: doneMarkup });
904
+ // Clear frozen first-turn ops after final Done (recheck path done).
905
+ if (this.isSelfRecheckTurn) this.preRecheckFileOps = new Map();
906
+ }
558
907
  } else if (final.error) {
559
- const transient = isTransientError(final.error);
560
- const live = this.errorMessage(final.error, startedAt, final.attempts, transient);
561
- if (canPing) await this.notify(live, { loud: true, replyTo: this.turnReplyTo, replyMarkup: switchKb });
908
+ // If the self-recheck pass itself failed, still surface Done for the
909
+ // original work (split files + suggestions) so the user is not stuck.
910
+ if (this.isSelfRecheckTurn && !hasQueued) {
911
+ const switchKb = this.switchKeyboard();
912
+ const pingDone = canPing && (this.foreground || !hasQueued);
913
+ let doneText =
914
+ this.completionMessageSplit(undefined, startedAt, streamedOutput) +
915
+ `\n\n\u26A0\uFE0F Self-recheck failed: ${final.error.message}`;
916
+ if (!this.cancelled) {
917
+ const sug = await this.collectAndApplySuggestions(doneText, switchKb, {
918
+ autoQueue: this.foreground,
919
+ });
920
+ doneText = sug.text;
921
+ if (pingDone) {
922
+ await this.notify(doneText, {
923
+ loud: true,
924
+ replyTo: this.turnReplyTo,
925
+ replyMarkup: sug.markup,
926
+ });
927
+ }
928
+ } else if (pingDone) {
929
+ await this.notify(doneText, { loud: true, replyTo: this.turnReplyTo, replyMarkup: switchKb });
930
+ }
931
+ this.preRecheckFileOps = new Map();
932
+ } else {
933
+ const transient = isTransientError(final.error);
934
+ const liveMsg = this.errorMessage(final.error, startedAt, final.attempts, transient);
935
+ if (canPing) await this.notify(liveMsg, { loud: true, replyTo: this.turnReplyTo, replyMarkup: switchKb });
936
+ }
562
937
  }
563
938
  } catch (err) {
564
939
  // Unexpected failure outside the prompt path (e.g. while finalizing).
565
940
  await this.streamer?.finalize().catch(() => {});
566
- const msg = `\u274C Error after ${fmtDuration(Date.now() - startedAt)}: ${(err as Error).message}`;
567
- this.lastCompletion = msg;
568
- if (this.foreground || this.cfg.notifyOtherSessions) {
569
- const from = this.foreground ? "" : `\u{1F4E8} From other session ${this.sessionTag()}\n`;
570
- await this.notify(`${from}${msg}`, { loud: true, replyTo: this.turnReplyTo, replyMarkup: this.switchKeyboard() });
941
+ const errMsg = (err as Error).message;
942
+ this.setSessionComment(
943
+ buildLastTurnSummary({
944
+ userText: this.turnUserText,
945
+ assistantText: this.turnAssistantText,
946
+ fileOps: this.fileOps,
947
+ error: errMsg,
948
+ }),
949
+ );
950
+ this.setLiveStep(undefined);
951
+ // If the self-recheck pass itself blew up, still surface Done for the
952
+ // original work (split files + suggestions) so the user is not stuck.
953
+ if (this.isSelfRecheckTurn && this.queue.length === 0) {
954
+ const switchKb = this.switchKeyboard();
955
+ const canPing = this.foreground || this.cfg.notifyOtherSessions;
956
+ let doneText =
957
+ this.completionMessageSplit(undefined, startedAt, this.streamer?.hasOutput ?? false) +
958
+ `\n\n\u26A0\uFE0F Self-recheck failed: ${errMsg}`;
959
+ try {
960
+ if (!this.cancelled) {
961
+ const sug = await this.collectAndApplySuggestions(doneText, switchKb, {
962
+ autoQueue: this.foreground,
963
+ });
964
+ doneText = sug.text;
965
+ if (canPing) {
966
+ await this.notify(doneText, {
967
+ loud: true,
968
+ replyTo: this.turnReplyTo,
969
+ replyMarkup: sug.markup,
970
+ });
971
+ }
972
+ } else if (canPing) {
973
+ await this.notify(doneText, {
974
+ loud: true,
975
+ replyTo: this.turnReplyTo,
976
+ replyMarkup: switchKb,
977
+ });
978
+ }
979
+ } catch (e2) {
980
+ log.debug(`recheck catch recovery failed: ${(e2 as Error).message}`);
981
+ if (canPing) {
982
+ await this.notify(doneText, {
983
+ loud: true,
984
+ replyTo: this.turnReplyTo,
985
+ replyMarkup: switchKb,
986
+ }).catch(() => {});
987
+ }
988
+ }
989
+ this.preRecheckFileOps = new Map();
990
+ } else {
991
+ const msg = `\u274C Error after ${fmtDuration(Date.now() - startedAt)}: ${errMsg}`;
992
+ this.lastCompletion = msg;
993
+ if (this.foreground || this.cfg.notifyOtherSessions) {
994
+ const from = this.foreground ? "" : `\u{1F4E8} From other session ${this.sessionTag()}\n`;
995
+ await this.notify(`${from}${msg}`, {
996
+ loud: true,
997
+ replyTo: this.turnReplyTo,
998
+ replyMarkup: this.switchKeyboard(),
999
+ });
1000
+ }
571
1001
  }
572
1002
  } finally {
573
1003
  this.typing.stop();
574
1004
  this.streamer = undefined;
1005
+ this.capturingQuiet = false;
1006
+ this.quietCaptureBuf = "";
575
1007
  this.busy = false;
576
1008
  this.activity(false);
577
1009
  // The in-flight turn we may have been following live is over.
@@ -580,6 +1012,9 @@ export class SessionRuntime {
580
1012
  // the bar is removed from the status panel, session cards and switch
581
1013
  // messages. The finished streamed bubble keeps its own (frozen) bar.
582
1014
  this.progress = undefined;
1015
+ this.planEntries = undefined;
1016
+ // Prefer stored summary on cards once idle (clear live step if still set).
1017
+ if (!this.liveStep || this.sessionComment) this.liveStep = undefined;
583
1018
  this.changed();
584
1019
  }
585
1020
 
@@ -594,6 +1029,183 @@ export class SessionRuntime {
594
1029
  }
595
1030
  }
596
1031
 
1032
+ /**
1033
+ * Quietly ask for 1–3 follow-ups, attach buttons to the Done text, store them
1034
+ * for switch-replay, and optionally queue auto-approved items as **one**
1035
+ * numbered multi-step prompt (`1) …\n2) …`).
1036
+ */
1037
+ private async collectAndApplySuggestions(
1038
+ doneText: string,
1039
+ switchKb: InlineKeyboard | undefined,
1040
+ opts?: { autoQueue?: boolean },
1041
+ ): Promise<{ text: string; markup?: InlineKeyboard }> {
1042
+ if (!this.cfg.suggestionsEnabled || !this.sessionId) {
1043
+ return { text: doneText, markup: switchKb };
1044
+ }
1045
+ let suggestions: Suggestion[] = [];
1046
+ try {
1047
+ suggestions = await this.fetchSuggestionsQuiet();
1048
+ } catch (e) {
1049
+ log.debug(`suggestions fetch failed: ${(e as Error).message}`);
1050
+ }
1051
+ if (suggestions.length === 0) return { text: doneText, markup: switchKb };
1052
+
1053
+ const batchId = ++this.suggestionBatchSeq;
1054
+ this.suggestionBatches.set(batchId, suggestions);
1055
+ // Bound memory: keep last ~20 batches.
1056
+ if (this.suggestionBatches.size > 20) {
1057
+ const oldest = [...this.suggestionBatches.keys()].sort((a, b) => a - b)[0]!;
1058
+ this.suggestionBatches.delete(oldest);
1059
+ }
1060
+
1061
+ const thr = this.cfg.suggestionsAutoApprovePct;
1062
+ const autoQueue = opts?.autoQueue !== false;
1063
+ const auto = autoQueue ? autoApproveSuggestions(suggestions, thr) : [];
1064
+ let text = doneText;
1065
+ let banner: string;
1066
+ if (auto.length > 0) {
1067
+ const batched = formatBatchedSuggestionsPrompt(auto);
1068
+ const lines = auto.map((s, i) => ` ${i + 1}) ${s.need}% \u2014 ${s.text}`);
1069
+ const autoBlock =
1070
+ `\n\n\u{1F4A1} Auto-running ${auto.length} suggestion${auto.length === 1 ? "" : "s"}` +
1071
+ ` as one prompt (\u2265 ${thr}% need):\n${lines.join("\n")}`;
1072
+ text += autoBlock;
1073
+ banner = `\u{1F4A1} Suggestions (auto-running ${auto.length} as one prompt):\n${lines.join("\n")}`;
1074
+ // Single queue entry — agent executes 1) 2) 3) in one turn.
1075
+ // skipSelfRecheck: auto-follow-ups must not arm another recheck cycle.
1076
+ this.queue.push(textPrompt(batched, this.turnReplyTo, undefined, { skipSelfRecheck: true }));
1077
+ this.changed();
1078
+ } else {
1079
+ text += "\n\n\u{1F4A1} Suggestions \u2014 tap one to continue:";
1080
+ banner = "\u{1F4A1} Suggestions \u2014 tap one to continue:";
1081
+ }
1082
+
1083
+ // Keep for switch-to-session replay (and for Done pings that already include
1084
+ // the same keyboard). Cleared when a new turn starts.
1085
+ this.pendingSuggestions = { batchId, suggestions, banner };
1086
+
1087
+ const markup = suggestionsKeyboard(batchId, suggestions, switchKb);
1088
+ return { text, markup };
1089
+ }
1090
+
1091
+ /**
1092
+ * Decide whether to queue a self-recheck turn.
1093
+ * - Hard skip when no files were modified this turn.
1094
+ * - Quiet AI decision: refuse (simple / not needed) or write recheck prompt.
1095
+ * - Returns the full recheck turn text, or undefined to skip.
1096
+ */
1097
+ private async maybePlanSelfRecheck(): Promise<string | undefined> {
1098
+ if (this.fileOps.size === 0) {
1099
+ log.info(`chat ${this.chatId}: self-recheck skipped (no files modified)`);
1100
+ return undefined;
1101
+ }
1102
+ if (this.cancelled) return undefined;
1103
+ const user =
1104
+ this.suggestionUserText ||
1105
+ stripDirectiveWrappers(this.turnUserText) ||
1106
+ this.turnUserText;
1107
+ const did = this.turnAssistantText;
1108
+ const files = summarizeFileOpsShort(this.fileOps);
1109
+
1110
+ let decision;
1111
+ try {
1112
+ decision = await this.fetchSelfRecheckDecisionQuiet(user, did, files);
1113
+ } catch (e) {
1114
+ log.debug(`self-recheck decision failed: ${(e as Error).message}; skipping`);
1115
+ return undefined;
1116
+ }
1117
+ if (this.cancelled) return undefined;
1118
+ if (!decision.needed) {
1119
+ log.info(
1120
+ `chat ${this.chatId}: self-recheck skipped by agent` +
1121
+ (decision.reason ? ` (${decision.reason})` : ""),
1122
+ );
1123
+ return undefined;
1124
+ }
1125
+
1126
+ // Optional env template overrides the AI-written body when set.
1127
+ if (this.cfg.selfRecheckPrompt) {
1128
+ return buildSelfRecheckPrompt(user, did, this.cfg.selfRecheckPrompt);
1129
+ }
1130
+ if (decision.prompt.trim()) {
1131
+ return composeSelfRecheckTurn(decision.prompt, user, did);
1132
+ }
1133
+ // needed=true but empty prompt — fall back to built-in default template.
1134
+ return buildSelfRecheckPrompt(user, did);
1135
+ }
1136
+
1137
+ /** Quiet JSON: should we recheck, and if so what prompt? Never streams. */
1138
+ private async fetchSelfRecheckDecisionQuiet(
1139
+ user: string,
1140
+ did: string,
1141
+ filesSummary: string,
1142
+ ): Promise<ReturnType<typeof parseSelfRecheckDecision>> {
1143
+ if (!this.sessionId) return { needed: false, reason: "no session" };
1144
+ const prompt = buildSelfRecheckDecisionPrompt(user, did, filesSummary);
1145
+ this.capturingQuiet = true;
1146
+ this.quietCaptureBuf = "";
1147
+ try {
1148
+ await this.acp.prompt(this.sessionId, [{ type: "text", text: prompt }]);
1149
+ return parseSelfRecheckDecision(this.quietCaptureBuf);
1150
+ } finally {
1151
+ this.capturingQuiet = false;
1152
+ this.quietCaptureBuf = "";
1153
+ }
1154
+ }
1155
+
1156
+ /** Quiet JSON suggestion turn — never streams to Telegram. */
1157
+ private async fetchSuggestionsQuiet(): Promise<Suggestion[]> {
1158
+ if (!this.sessionId) return [];
1159
+ // Prefer the original user ask (before self-recheck) so need scores stay honest.
1160
+ const user =
1161
+ this.suggestionUserText ||
1162
+ stripDirectiveWrappers(this.turnUserText) ||
1163
+ this.turnUserText;
1164
+ const didParts = [this.preRecheckAssistantText, this.turnAssistantText].filter((s) => s?.trim());
1165
+ const did = didParts.join("\n") || this.turnAssistantText;
1166
+ const prompt = buildSuggestionsPrompt(user, did);
1167
+ this.capturingQuiet = true;
1168
+ this.quietCaptureBuf = "";
1169
+ try {
1170
+ await this.acp.prompt(this.sessionId, [{ type: "text", text: prompt }]);
1171
+ return parseSuggestions(this.quietCaptureBuf);
1172
+ } finally {
1173
+ this.capturingQuiet = false;
1174
+ this.quietCaptureBuf = "";
1175
+ }
1176
+ }
1177
+
1178
+ /** Resolve a tapped suggestion button; returns the prompt text or undefined. */
1179
+ takeSuggestion(batchId: number, index: number): string | undefined {
1180
+ const batch = this.suggestionBatches.get(batchId);
1181
+ if (!batch) return undefined;
1182
+ const s = batch[index];
1183
+ if (!s) return undefined;
1184
+ return s.text;
1185
+ }
1186
+
1187
+ /** Attribute a finished turn's credits/context to the active saved account. */
1188
+ private recordAccountUsage(): void {
1189
+ const meta = this.contextInfo();
1190
+ // Grok's metadata credits are typically a running session total — store the
1191
+ // per-turn delta so /accounts totals stay accurate across many turns.
1192
+ let turnCredits: number | undefined;
1193
+ if (typeof meta?.credits === "number" && Number.isFinite(meta.credits)) {
1194
+ const delta = meta.credits - this.lastReportedCredits;
1195
+ turnCredits = delta > 0 ? delta : meta.credits > 0 && this.lastReportedCredits === 0 ? meta.credits : undefined;
1196
+ if (meta.credits >= this.lastReportedCredits) this.lastReportedCredits = meta.credits;
1197
+ else this.lastReportedCredits = meta.credits; // reset if agent restarted counters
1198
+ }
1199
+ try {
1200
+ this.accountRotator?.recordTurnUsage({
1201
+ credits: turnCredits,
1202
+ contextPct: meta?.contextUsagePercentage,
1203
+ });
1204
+ } catch {
1205
+ /* non-fatal */
1206
+ }
1207
+ }
1208
+
597
1209
  /**
598
1210
  * Show subagent ("crew") status transitions for the given (already
599
1211
  * chat-attributed) subagents, so the user sees progress while the main agent
@@ -629,6 +1241,51 @@ export class SessionRuntime {
629
1241
  return pct !== undefined && pct >= threshold;
630
1242
  }
631
1243
 
1244
+ /** A shared-process restart invalidates this runtime's ACP session binding,
1245
+ * but says nothing about account health. Wait for any account probe to
1246
+ * settle, re-bind/fork this chat on the selected account, and retry once. */
1247
+ private async maybeRecoverAgentSession(
1248
+ input: PromptInput,
1249
+ outcome: { result?: PromptResult; error?: Error; attempts: number },
1250
+ ): Promise<{ result?: PromptResult; error?: Error; attempts: number } | undefined> {
1251
+ if (
1252
+ !outcome.error ||
1253
+ !isSessionLifecycleError(outcome.error) ||
1254
+ this.cancelled ||
1255
+ (this.streamer?.hasOutput ?? false)
1256
+ ) {
1257
+ return undefined;
1258
+ }
1259
+ try {
1260
+ await this.accountRotator?.waitForIdle();
1261
+ if (this.cancelled) return outcome;
1262
+ const previousId = this.sessionId;
1263
+ this.sessionLive = false;
1264
+ this.rebindPending = Boolean(previousId);
1265
+ await this.ensureSession();
1266
+ this.shownToolIds = new Set();
1267
+ this.subagentShown = new Map();
1268
+ this.streamer?.setFooter(this.hashtags());
1269
+ const retryContent = buildContentBlocks(input, {
1270
+ reasoning: reasoningDirective(this.reasoning),
1271
+ priming: this.primingContext,
1272
+ imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
1273
+ progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
1274
+ });
1275
+ this.primingContext = undefined;
1276
+ log.info(
1277
+ `chat ${this.chatId} recovered lifecycle error on the active account` +
1278
+ (previousId && this.sessionId !== previousId
1279
+ ? ` with fresh session ${this.sessionId?.slice(0, 8)}`
1280
+ : " by re-binding its session"),
1281
+ );
1282
+ return this.runPromptWithRetries(retryContent);
1283
+ } catch (error) {
1284
+ log.warn(`chat ${this.chatId} session recovery failed: ${(error as Error).message}`);
1285
+ return { error: error as Error, attempts: outcome.attempts };
1286
+ }
1287
+ }
1288
+
632
1289
  /**
633
1290
  * Auto-fork-on-error recovery. When a turn fails with a *transient* error (or
634
1291
  * a context-exhaustion error) and nothing was streamed to the user, the
@@ -700,25 +1357,57 @@ export class SessionRuntime {
700
1357
  ): Promise<{ result?: PromptResult; error?: Error; attempts: number } | undefined> {
701
1358
  const rotator = this.accountRotator;
702
1359
  if (!rotator?.enabled() || !final.error || this.cancelled) return undefined;
1360
+ if (isSessionLifecycleError(final.error)) return undefined;
1361
+ const originalError = final.error;
1362
+ const observed = rotator.state();
1363
+ return rotator.withRotationLock(observed, async (changed) => {
1364
+ if (this.cancelled) return final;
1365
+ if (changed) {
1366
+ if (this.streamer?.hasOutput ?? false) return undefined;
1367
+ const current = rotator.state();
1368
+ const transcript = this.sessionId ? recentTranscript(this.cfg.sessionsDir, this.sessionId) : undefined;
1369
+ if (this.foreground) {
1370
+ await this.notify(
1371
+ `\u{1F504} Reusing ${current.activeLabel ?? "the account selected by another chat"} with a fresh session…`,
1372
+ { replyTo: this.turnReplyTo },
1373
+ );
1374
+ }
1375
+ log.info(`chat ${this.chatId} reusing account generation ${current.generation} selected by another chat`);
1376
+ try {
1377
+ await this.bindNewSession(this.cwd, this.projectName);
1378
+ } catch (error) {
1379
+ return { error: error as Error, attempts: final.attempts };
1380
+ }
1381
+ this.shownToolIds = new Set();
1382
+ this.subagentShown = new Map();
1383
+ this.streamer?.setFooter(this.hashtags());
1384
+ const content = buildContentBlocks(input, {
1385
+ reasoning: reasoningDirective(this.reasoning),
1386
+ priming: transcript ? buildPriming(transcript) : undefined,
1387
+ imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
1388
+ progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
1389
+ });
1390
+ return this.runPromptWithRetries(content);
1391
+ }
703
1392
  // A quota-exhausted or access-denied response cannot be recovered by retrying
704
1393
  // this login. Quarantine it before choosing targets, so later rotations do not
705
1394
  // cycle back to a known-bad account. This intentionally happens before the
706
1395
  // partial-stream guard: we must not retry/rotate a partial reply, but its
707
1396
  // account still needs to be skipped during a future rotation.
708
- if (isAccountRotationError(final.error)) {
709
- await rotator.markFailed(undefined, final.error.message);
1397
+ if (isAccountRotationError(originalError)) {
1398
+ await rotator.markFailed(observed.activeId, originalError.message);
710
1399
  }
711
1400
  if (this.streamer?.hasOutput ?? false) return undefined;
712
1401
  const targets = await rotator.targets().catch(() => [] as { id: string; label: string }[]);
713
1402
  if (targets.length === 0) return undefined;
714
1403
 
715
1404
  const transcript = this.sessionId ? recentTranscript(this.cfg.sessionsDir, this.sessionId) : undefined;
716
- const errors: string[] = [`\u2022 previous: ${final.error.message}`];
1405
+ const errors: string[] = [`\u2022 previous: ${originalError.message}`];
717
1406
  let last = final;
718
1407
 
719
1408
  for (const t of targets) {
720
1409
  if (this.cancelled) return last;
721
- const failReason = last.error ?? final.error;
1410
+ const failReason = last.error ?? originalError;
722
1411
  if (this.foreground) {
723
1412
  await this.notify(formatAccountSwitchNotice(t.label, failReason), { replyTo: this.turnReplyTo });
724
1413
  }
@@ -767,6 +1456,7 @@ export class SessionRuntime {
767
1456
  // One full cycle done and still failing — stop with a combined report.
768
1457
  const combined = new Error(`Tried ${targets.length + 1} account(s), all failed:\n${errors.join("\n")}`);
769
1458
  return { error: combined, attempts: last.attempts };
1459
+ });
770
1460
  }
771
1461
 
772
1462
  /**
@@ -937,6 +1627,28 @@ export class SessionRuntime {
937
1627
  return `\u{1F4E8} From other session ${this.sessionTag()}\n${head}\n${summarizeFileOpsShort(this.fileOps)}\n\n${tags}`;
938
1628
  }
939
1629
 
1630
+ /**
1631
+ * Final Done after a self-recheck: head + split file lists (first turn vs recheck).
1632
+ */
1633
+ private completionMessageSplit(
1634
+ stopReason: string | undefined,
1635
+ startedAt: number,
1636
+ streamedOutput: boolean,
1637
+ ): string {
1638
+ const head = this.doneHead(stopReason, startedAt, streamedOutput);
1639
+ const tags = this.hashtags();
1640
+ const files = summarizeFileOpsSplit(this.preRecheckFileOps, this.fileOps, this.cwd);
1641
+ const base = `${head}\n${files}`;
1642
+ this.lastCompletion = `${base}\n\n${tags}`;
1643
+ if (this.foreground) {
1644
+ return streamedOutput ? base : `${base}\n\n${tags}`;
1645
+ }
1646
+ return (
1647
+ `\u{1F4E8} From other session ${this.sessionTag()}\n${head}\n` +
1648
+ `${summarizeFileOpsShort(this.preRecheckFileOps)} \u2192 recheck ${summarizeFileOpsShort(this.fileOps)}\n\n${tags}`
1649
+ );
1650
+ }
1651
+
940
1652
  /** The compact one-line status of a finished turn (no "end_turn" noise). */
941
1653
  private doneHead(stopReason: string | undefined, startedAt: number, streamedOutput: boolean): string {
942
1654
  const elapsed = fmtDuration(Date.now() - startedAt);
@@ -992,7 +1704,16 @@ export class SessionRuntime {
992
1704
 
993
1705
  private async flushQueue(): Promise<void> {
994
1706
  if (this.queue.length === 0 || this.busy) return;
995
- const batch = mergeInputs(this.queue.splice(0, this.queue.length));
1707
+ // Meta / system turns (self-recheck, auto-suggestion batches) must run
1708
+ // alone: merging them with user messages corrupts the prompt and can drop
1709
+ // the one-shot skipSelfRecheck guard via text concatenation.
1710
+ const head = this.queue[0]!;
1711
+ const isMeta =
1712
+ !!head.skipSelfRecheck ||
1713
+ isSelfRecheckPrompt(head.text);
1714
+ const batch = isMeta
1715
+ ? this.queue.shift()!
1716
+ : mergeInputs(this.queue.splice(0, this.queue.length));
996
1717
  if (this.foreground) await this.notify("\u25B6\uFE0F Processing queued message\u2026");
997
1718
  void this.runTurn(batch);
998
1719
  }
@@ -1002,36 +1723,73 @@ export class SessionRuntime {
1002
1723
  this.sessionUpdateCount++;
1003
1724
  const kind = update.sessionUpdate;
1004
1725
 
1726
+ // Quiet meta turns (follow-up suggestions): capture prose only, never stream.
1727
+ if (this.capturingQuiet) {
1728
+ if (kind === "agent_message_chunk") {
1729
+ const text = contentText(update.content);
1730
+ if (text) this.quietCaptureBuf += text;
1731
+ }
1732
+ return;
1733
+ }
1734
+
1005
1735
  // Accumulate the turn's file-change summary + image-scan text even when this
1006
1736
  // session is in the background (its output isn't streamed here, but the
1007
1737
  // completion message still reports what changed / which images were made).
1008
1738
  if (kind === "tool_call" || kind === "tool_call_update") {
1009
- if (update.rawInput) this.imageScanText += " " + JSON.stringify(update.rawInput);
1010
- if (update.title) this.imageScanText += " " + update.title;
1011
- // Tool results often carry the saved path only in content_blocks (Imagine).
1012
- if (Array.isArray(update.content_blocks)) {
1013
- this.imageScanText += " " + JSON.stringify(update.content_blocks);
1739
+ // Merge early so background live-step + file ops use full title/args.
1740
+ const tid = update.toolCallId || "";
1741
+ const mergedEarly = mergeToolSnapshot(tid ? this.toolCallCache.get(tid) : undefined, update);
1742
+ if (tid) this.toolCallCache.set(tid, mergedEarly);
1743
+
1744
+ if (mergedEarly.rawInput) this.imageScanText += " " + JSON.stringify(mergedEarly.rawInput);
1745
+ if (mergedEarly.title) this.imageScanText += " " + mergedEarly.title;
1746
+ if (Array.isArray(mergedEarly.content_blocks)) {
1747
+ this.imageScanText += " " + JSON.stringify(mergedEarly.content_blocks);
1014
1748
  }
1015
- // Some agents put free-form result text on `content`.
1016
- if (update.content?.text) this.imageScanText += " " + update.content.text;
1017
- const fo = fileOpFromUpdate(update);
1749
+ const ct = contentText(mergedEarly.content);
1750
+ if (ct) this.imageScanText += " " + ct;
1751
+ const fo = fileOpFromUpdate(mergedEarly);
1018
1752
  if (fo) this.fileOps.set(fo.path, mergeFileOp(this.fileOps.get(fo.path), fo.op));
1753
+ // Live card step — always, even for background sessions.
1754
+ const step = stepFromToolUpdate(mergedEarly);
1755
+ if (step) this.setLiveStep(step);
1019
1756
  } else if (kind === "agent_message_chunk") {
1020
- const text = update.content?.text;
1021
- if (typeof text === "string") this.imageScanText += text;
1757
+ const text = contentText(update.content);
1758
+ if (text) {
1759
+ this.imageScanText += text;
1760
+ this.turnAssistantText += text;
1761
+ }
1762
+ } else if (kind === "agent_thought_chunk") {
1763
+ const text = contentText(update.content);
1764
+ if (text?.trim()) this.setLiveStep(stepFromThought(text));
1765
+ } else if (kind === "plan") {
1766
+ // Always track plan entries (background too) so switch-to-live restores the board.
1767
+ const entries = parsePlanUpdate(update);
1768
+ if (entries?.length) {
1769
+ this.planEntries = entries;
1770
+ const one = renderPlanOneLine(entries);
1771
+ if (one) this.setLiveStep(one);
1772
+ this.changed();
1773
+ }
1022
1774
  }
1023
1775
 
1024
1776
  // Only the live foreground turn streams to Telegram.
1025
1777
  if (!this.foreground || !this.streamer) return;
1026
1778
 
1779
+ if (kind === "plan") {
1780
+ if (this.planEntries?.length) {
1781
+ this.streamer.setPlan(renderPlanMarkdown(this.planEntries));
1782
+ }
1783
+ return;
1784
+ }
1027
1785
  if (kind === "agent_message_chunk") {
1028
- const text = update.content?.text;
1029
- if (typeof text === "string") this.streamer.appendOutput(text);
1786
+ const text = contentText(update.content);
1787
+ if (text) this.streamer.appendOutput(text);
1030
1788
  return;
1031
1789
  }
1032
1790
  if (kind === "agent_thought_chunk") {
1033
- const text = update.content?.text;
1034
- if (typeof text === "string") this.streamer.appendThought(text);
1791
+ const text = contentText(update.content);
1792
+ if (text) this.streamer.appendThought(text);
1035
1793
  return;
1036
1794
  }
1037
1795
  if (kind === "tool_call" || kind === "tool_call_update") {
@@ -1039,31 +1797,40 @@ export class SessionRuntime {
1039
1797
  const id = update.toolCallId || "";
1040
1798
  const status = (update.status || "").toLowerCase();
1041
1799
 
1042
- if (kind === "tool_call_update") {
1043
- // Skip "in_progress"/"pending" duplicates of an already-shown initial call.
1044
- if (status === "pending" || status === "in_progress") {
1045
- const hasNewContent =
1046
- Array.isArray(update.content_blocks) && update.content_blocks.length > 0;
1047
- if (!hasNewContent) return;
1048
- }
1049
- // For completed/failed: show once (so the user sees the final status).
1050
- const doneKey = (id || update.title || "") + ":done";
1051
- if (status === "completed" || status === "failed") {
1052
- if (this.shownToolIds.has(doneKey)) return;
1053
- this.shownToolIds.add(doneKey);
1054
- }
1055
- } else {
1056
- // Initial tool_call: dedupe by id to avoid double-showing.
1057
- const shownKey = id || `tool_call:${update.title ?? ""}`;
1058
- if (this.shownToolIds.has(shownKey)) return;
1059
- this.shownToolIds.add(shownKey);
1800
+ // Snapshot already merged above for file-ops / live step.
1801
+ const merged = (id && this.toolCallCache.get(id)) || mergeToolSnapshot(undefined, update);
1802
+
1803
+ // Skip hollow shells with nothing useful yet.
1804
+ if (!snapshotHasDetail(merged) && status !== "completed" && status !== "failed") {
1805
+ return;
1806
+ }
1807
+
1808
+ // Status-only mid-flight patches (no new content/input): skip if we already
1809
+ // painted this tool once upsert would be a no-op anyway.
1810
+ if (kind === "tool_call_update" && (status === "pending" || status === "in_progress")) {
1811
+ const hasNewContent =
1812
+ (Array.isArray(update.content_blocks) && update.content_blocks.length > 0) ||
1813
+ (Array.isArray(update.content) && (update.content as unknown[]).length > 0) ||
1814
+ (!!update.rawInput && Object.keys(update.rawInput).length > 0) ||
1815
+ update.rawOutput !== undefined;
1816
+ const key = id || `tool_call:${update.title ?? ""}`;
1817
+ if (!hasNewContent && this.shownToolIds.has(key)) return;
1060
1818
  }
1061
1819
 
1062
- const md = formatToolCall(update, {
1820
+ const md = formatToolCall(merged, {
1063
1821
  showDiffs: this.cfg.showEditDiffs,
1064
1822
  diffMaxLines: this.cfg.diffMaxLines,
1065
1823
  });
1066
- if (md) this.streamer.addTool(md);
1824
+ if (!md) return;
1825
+
1826
+ // One live card per toolCallId: replace in place as output streams
1827
+ // (no spam of new code sections). Session/agent context keeps full output.
1828
+ const key = id || `tool_call:${merged.title ?? merged.name ?? ""}`;
1829
+ this.shownToolIds.add(key);
1830
+ if (status === "completed" || status === "failed") {
1831
+ this.shownToolIds.add(key + ":done");
1832
+ }
1833
+ this.streamer.upsertTool(id || undefined, md);
1067
1834
  }
1068
1835
  }
1069
1836
 
@@ -1103,8 +1870,8 @@ export class SessionRuntime {
1103
1870
  }
1104
1871
  if (opts?.replyMarkup) extra.reply_markup = opts.replyMarkup;
1105
1872
  await this.api.sendMessage(this.chatId, text, extra);
1106
- } catch {
1107
- /* non-fatal */
1873
+ } catch (e) {
1874
+ log.debug("notify failed:", (e as Error).message);
1108
1875
  }
1109
1876
  }
1110
1877