grok-telegram-bot 2.3.1 → 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 (67) hide show
  1. package/.env.example +26 -0
  2. package/CHANGELOG.md +37 -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 +10 -0
  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 +51 -1
  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/session-fork.ts +11 -0
  41. package/src/bot/session-runtime.ts +740 -58
  42. package/src/bot/suggestions.ts +429 -0
  43. package/src/config.ts +41 -0
  44. package/src/grok/client.ts +91 -16
  45. package/src/grok/plan-approval.ts +72 -0
  46. package/src/grok/session-log.ts +16 -0
  47. package/src/grok/types.ts +21 -2
  48. package/src/import/build-import.ts +132 -0
  49. package/src/import/history-readers.ts +681 -0
  50. package/src/import/list-running.ts +100 -0
  51. package/src/import/sources.ts +78 -0
  52. package/src/index.ts +179 -24
  53. package/src/render/diff.ts +11 -2
  54. package/src/render/file-summary.ts +31 -1
  55. package/src/render/markdown.ts +293 -35
  56. package/src/render/plan.ts +127 -0
  57. package/src/render/session-comment.ts +261 -0
  58. package/src/render/tool-call-detail.ts +400 -19
  59. package/src/render/tool-call-merge.ts +115 -0
  60. package/src/render/tool-call.ts +405 -142
  61. package/src/render/truncate.ts +85 -0
  62. package/src/service/windows.ts +14 -2
  63. package/src/sessions/history.ts +57 -0
  64. package/src/sessions/store.ts +3 -0
  65. package/src/sessions/types.ts +5 -0
  66. package/src/stream/streamer.ts +73 -9
  67. 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,67 @@ 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 {
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";
38
79
  import {
39
80
  backoffSchedule,
40
81
  fmtSeconds,
@@ -85,6 +126,8 @@ export class SessionRuntime {
85
126
  private streamer: ResponseStreamer | undefined;
86
127
  private readonly typing: TypingIndicator;
87
128
  private shownToolIds = new Set<string>();
129
+ /** toolCallId → merged snapshot so completed updates keep title/args. */
130
+ private toolCallCache = new Map<string, ToolSnapshot>();
88
131
  /** Files touched this turn (path -> operation), tracked even in background so
89
132
  * the completion message can summarise what changed. */
90
133
  private fileOps = new Map<string, FileOp>();
@@ -127,6 +170,49 @@ export class SessionRuntime {
127
170
  /** Optional multi-account rotator: when a turn gives up, cycle through the
128
171
  * other saved logins once and retry on each. Injected by the registry. */
129
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;
130
216
 
131
217
  constructor(
132
218
  private readonly api: Api,
@@ -176,11 +262,53 @@ export class SessionRuntime {
176
262
  return this.lastCompletion;
177
263
  }
178
264
 
179
- /** Latest task-completion % (0–100) parsed this turn, or undefined if none. */
265
+ /** Latest task-completion % (0100) parsed this turn, or undefined if none. */
180
266
  get taskProgress(): number | undefined {
181
267
  return this.progress;
182
268
  }
183
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
+
184
312
  /** Record a new progress value and refresh the status panel / cards. The bar
185
313
  * is monotonic within a turn (it's reset to undefined when a new turn starts),
186
314
  * so a streamer recreated mid-turn can't make it jump backwards. */
@@ -191,6 +319,36 @@ export class SessionRuntime {
191
319
  this.changed();
192
320
  }
193
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
+
194
352
  /** Searchable hashtag footer for this session (project В· session В· model В·
195
353
  * reasoning) — appended to every AI-output surface for this session. */
196
354
  get tags(): string {
@@ -212,6 +370,10 @@ export class SessionRuntime {
212
370
  // Any transient follow-watch of this session is now superseded.
213
371
  if (this.watchIsFollow) this.stopWatch();
214
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
+ }
215
377
  this.typing.start();
216
378
  }
217
379
  } else {
@@ -274,6 +436,10 @@ export class SessionRuntime {
274
436
  this.rebindPending = false;
275
437
  this.cwd = cwd;
276
438
  this.projectName = projectName;
439
+ this.turnCount = 0;
440
+ this.lastReportedCredits = 0;
441
+ this.liveStep = undefined;
442
+ this.sessionComment = undefined;
277
443
  await this.applySessionPrefs();
278
444
  this.persist();
279
445
  this.sessionChanged();
@@ -298,6 +464,7 @@ export class SessionRuntime {
298
464
  this.rebindPending = false;
299
465
  this.cwd = cwd;
300
466
  this.projectName = projectName;
467
+ this.loadPersistedComment();
301
468
  this.persist();
302
469
  log.info(`chat ${this.chatId} -> resumed session ${sessionId} @ ${cwd}`);
303
470
  this.changed();
@@ -320,6 +487,18 @@ export class SessionRuntime {
320
487
  }
321
488
  }
322
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
+
323
502
  startWatch(jsonlPath: string, follow = false): void {
324
503
  this.stopWatch();
325
504
  this.watchIsFollow = follow;
@@ -399,7 +578,7 @@ export class SessionRuntime {
399
578
  }
400
579
  }
401
580
 
402
- // в”Ђв”Ђ prompting в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
581
+ // ── prompting ────────────────────────────────────────────────────────────
403
582
 
404
583
  async submit(input: PromptInput): Promise<"ran" | "queued"> {
405
584
  await this.ensureSession();
@@ -408,10 +587,43 @@ export class SessionRuntime {
408
587
  this.changed();
409
588
  return "queued";
410
589
  }
411
- 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);
412
599
  return "ran";
413
600
  }
414
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
+
415
627
  async cancel(): Promise<boolean> {
416
628
  if (!this.busy || !this.sessionId) return false;
417
629
  this.cancelled = true;
@@ -462,10 +674,11 @@ export class SessionRuntime {
462
674
  /** Reload a persisted session, retrying flaky failures with a short backoff.
463
675
  * Returns true once loaded, false after the attempts are exhausted. */
464
676
  private async rebindWithRetries(sessionId: string, attempts = 4): Promise<boolean> {
465
- const delays = [400, 1200, 3000]; // ≈4.6s total before giving up
677
+ const delays = [400, 1200, 3000]; // ~4.6s total before giving up
466
678
  for (let i = 0; i < attempts; i++) {
467
679
  try {
468
680
  await this.acp.loadSession(sessionId, this.cwd);
681
+ this.loadPersistedComment();
469
682
  return true;
470
683
  } catch (err) {
471
684
  log.warn(
@@ -501,10 +714,34 @@ export class SessionRuntime {
501
714
  this.busy = true;
502
715
  this.cancelled = false;
503
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
+ }
504
729
  this.shownToolIds = new Set();
730
+ this.toolCallCache = new Map();
505
731
  this.fileOps = new Map();
506
732
  this.subagentShown = new Map();
507
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
+ );
508
745
  // A new streamed turn supersedes any transient "follow" watch of this same
509
746
  // session's previous in-flight turn (avoids duplicated output).
510
747
  if (this.watchIsFollow) this.stopWatch();
@@ -545,7 +782,7 @@ export class SessionRuntime {
545
782
  if (resumed) final = resumed;
546
783
  const streamedOutput = this.streamer?.hasOutput ?? false;
547
784
  // 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).
785
+ // no-op when the agent reported its own progress its value is kept).
549
786
  if (final.result && !this.cancelled) this.streamer?.completeFallback();
550
787
  if (this.streamer) await this.streamer.finalize();
551
788
  if (this.foreground) await this.sendTurnImages();
@@ -554,31 +791,219 @@ export class SessionRuntime {
554
791
  // the foreground turn, or a background turn when NOTIFY_OTHER_SESSIONS is on.
555
792
  const canPing = this.foreground || this.cfg.notifyOtherSessions;
556
793
  // 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.
794
+ // interim "Done" only the final, queue-empty turn announces completion.
558
795
  const hasQueued = this.queue.length > 0;
559
796
  const switchKb = this.switchKeyboard();
560
- 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
+ }
561
832
  if (final.result || this.cancelled) {
562
- const live = this.completionMessage(final.result?.stopReason, startedAt, streamedOutput);
833
+ const liveMsg = this.completionMessage(final.result?.stopReason, startedAt, streamedOutput);
563
834
  const pingDone = canPing && (this.foreground || !hasQueued);
564
- 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
+ }
565
907
  } 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 });
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
+ }
569
937
  }
570
938
  } catch (err) {
571
939
  // Unexpected failure outside the prompt path (e.g. while finalizing).
572
940
  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() });
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
+ }
578
1001
  }
579
1002
  } finally {
580
1003
  this.typing.stop();
581
1004
  this.streamer = undefined;
1005
+ this.capturingQuiet = false;
1006
+ this.quietCaptureBuf = "";
582
1007
  this.busy = false;
583
1008
  this.activity(false);
584
1009
  // The in-flight turn we may have been following live is over.
@@ -587,6 +1012,9 @@ export class SessionRuntime {
587
1012
  // the bar is removed from the status panel, session cards and switch
588
1013
  // messages. The finished streamed bubble keeps its own (frozen) bar.
589
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;
590
1018
  this.changed();
591
1019
  }
592
1020
 
@@ -601,6 +1029,183 @@ export class SessionRuntime {
601
1029
  }
602
1030
  }
603
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
+
604
1209
  /**
605
1210
  * Show subagent ("crew") status transitions for the given (already
606
1211
  * chat-attributed) subagents, so the user sees progress while the main agent
@@ -1022,6 +1627,28 @@ export class SessionRuntime {
1022
1627
  return `\u{1F4E8} From other session ${this.sessionTag()}\n${head}\n${summarizeFileOpsShort(this.fileOps)}\n\n${tags}`;
1023
1628
  }
1024
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
+
1025
1652
  /** The compact one-line status of a finished turn (no "end_turn" noise). */
1026
1653
  private doneHead(stopReason: string | undefined, startedAt: number, streamedOutput: boolean): string {
1027
1654
  const elapsed = fmtDuration(Date.now() - startedAt);
@@ -1077,7 +1704,16 @@ export class SessionRuntime {
1077
1704
 
1078
1705
  private async flushQueue(): Promise<void> {
1079
1706
  if (this.queue.length === 0 || this.busy) return;
1080
- 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));
1081
1717
  if (this.foreground) await this.notify("\u25B6\uFE0F Processing queued message\u2026");
1082
1718
  void this.runTurn(batch);
1083
1719
  }
@@ -1087,36 +1723,73 @@ export class SessionRuntime {
1087
1723
  this.sessionUpdateCount++;
1088
1724
  const kind = update.sessionUpdate;
1089
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
+
1090
1735
  // Accumulate the turn's file-change summary + image-scan text even when this
1091
1736
  // session is in the background (its output isn't streamed here, but the
1092
1737
  // completion message still reports what changed / which images were made).
1093
1738
  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);
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);
1099
1748
  }
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);
1749
+ const ct = contentText(mergedEarly.content);
1750
+ if (ct) this.imageScanText += " " + ct;
1751
+ const fo = fileOpFromUpdate(mergedEarly);
1103
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);
1104
1756
  } else if (kind === "agent_message_chunk") {
1105
- const text = update.content?.text;
1106
- 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
+ }
1107
1774
  }
1108
1775
 
1109
1776
  // Only the live foreground turn streams to Telegram.
1110
1777
  if (!this.foreground || !this.streamer) return;
1111
1778
 
1779
+ if (kind === "plan") {
1780
+ if (this.planEntries?.length) {
1781
+ this.streamer.setPlan(renderPlanMarkdown(this.planEntries));
1782
+ }
1783
+ return;
1784
+ }
1112
1785
  if (kind === "agent_message_chunk") {
1113
- const text = update.content?.text;
1114
- if (typeof text === "string") this.streamer.appendOutput(text);
1786
+ const text = contentText(update.content);
1787
+ if (text) this.streamer.appendOutput(text);
1115
1788
  return;
1116
1789
  }
1117
1790
  if (kind === "agent_thought_chunk") {
1118
- const text = update.content?.text;
1119
- if (typeof text === "string") this.streamer.appendThought(text);
1791
+ const text = contentText(update.content);
1792
+ if (text) this.streamer.appendThought(text);
1120
1793
  return;
1121
1794
  }
1122
1795
  if (kind === "tool_call" || kind === "tool_call_update") {
@@ -1124,31 +1797,40 @@ export class SessionRuntime {
1124
1797
  const id = update.toolCallId || "";
1125
1798
  const status = (update.status || "").toLowerCase();
1126
1799
 
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);
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;
1145
1806
  }
1146
1807
 
1147
- const md = formatToolCall(update, {
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;
1818
+ }
1819
+
1820
+ const md = formatToolCall(merged, {
1148
1821
  showDiffs: this.cfg.showEditDiffs,
1149
1822
  diffMaxLines: this.cfg.diffMaxLines,
1150
1823
  });
1151
- 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);
1152
1834
  }
1153
1835
  }
1154
1836
 
@@ -1188,8 +1870,8 @@ export class SessionRuntime {
1188
1870
  }
1189
1871
  if (opts?.replyMarkup) extra.reply_markup = opts.replyMarkup;
1190
1872
  await this.api.sendMessage(this.chatId, text, extra);
1191
- } catch {
1192
- /* non-fatal */
1873
+ } catch (e) {
1874
+ log.debug("notify failed:", (e as Error).message);
1193
1875
  }
1194
1876
  }
1195
1877