grok-telegram-bot 2.0.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 (106) hide show
  1. package/.env.example +135 -0
  2. package/CHANGELOG.md +598 -0
  3. package/LICENSE +21 -0
  4. package/README.md +644 -0
  5. package/bin/grok-tg.mjs +21 -0
  6. package/docs/INSTALL.md +153 -0
  7. package/docs/UPGRADE.md +253 -0
  8. package/docs/ops/RELEASE_CHECKLIST.md +39 -0
  9. package/package.json +74 -0
  10. package/scripts/setup.mjs +116 -0
  11. package/src/agents/catalog.ts +58 -0
  12. package/src/app/accounts.ts +162 -0
  13. package/src/app/auth-service.ts +136 -0
  14. package/src/app/grok-credentials.ts +103 -0
  15. package/src/app/instance-lock.ts +139 -0
  16. package/src/app/json-store.ts +54 -0
  17. package/src/app/reasoning.ts +30 -0
  18. package/src/app/settings-store.ts +38 -0
  19. package/src/app/stt.ts +53 -0
  20. package/src/app/types.ts +56 -0
  21. package/src/app/updater.ts +234 -0
  22. package/src/app/usage.ts +38 -0
  23. package/src/app/version.ts +41 -0
  24. package/src/bot/account-rotator.ts +52 -0
  25. package/src/bot/auth.ts +38 -0
  26. package/src/bot/bot.ts +225 -0
  27. package/src/bot/chat-controller.ts +317 -0
  28. package/src/bot/commands.ts +52 -0
  29. package/src/bot/deps.ts +67 -0
  30. package/src/bot/file-ingest.ts +190 -0
  31. package/src/bot/handlers/accounts.ts +220 -0
  32. package/src/bot/handlers/auth.ts +64 -0
  33. package/src/bot/handlers/control.ts +103 -0
  34. package/src/bot/handlers/document.ts +112 -0
  35. package/src/bot/handlers/history.ts +63 -0
  36. package/src/bot/handlers/kill.ts +54 -0
  37. package/src/bot/handlers/mcp.ts +206 -0
  38. package/src/bot/handlers/menu.ts +220 -0
  39. package/src/bot/handlers/message.ts +103 -0
  40. package/src/bot/handlers/photo.ts +123 -0
  41. package/src/bot/handlers/projects.ts +183 -0
  42. package/src/bot/handlers/running.ts +181 -0
  43. package/src/bot/handlers/session-card.ts +81 -0
  44. package/src/bot/handlers/session-kill.ts +95 -0
  45. package/src/bot/handlers/sessions.ts +148 -0
  46. package/src/bot/handlers/system.ts +51 -0
  47. package/src/bot/handlers/tasks.ts +224 -0
  48. package/src/bot/handlers/usage.ts +38 -0
  49. package/src/bot/handlers/voice.ts +55 -0
  50. package/src/bot/image-return.ts +69 -0
  51. package/src/bot/menu/ephemeral.ts +117 -0
  52. package/src/bot/menu/keyboard.ts +49 -0
  53. package/src/bot/menu/refresh.ts +13 -0
  54. package/src/bot/menu/status-panel.ts +173 -0
  55. package/src/bot/permission-service.ts +149 -0
  56. package/src/bot/prompt-content.ts +64 -0
  57. package/src/bot/prompt-retry.ts +70 -0
  58. package/src/bot/reauth-controller.ts +297 -0
  59. package/src/bot/registry.ts +186 -0
  60. package/src/bot/reply-context.ts +77 -0
  61. package/src/bot/session-fork.ts +35 -0
  62. package/src/bot/session-runtime.ts +1048 -0
  63. package/src/bot/telegram-io.ts +109 -0
  64. package/src/bot/typing.ts +35 -0
  65. package/src/bot/wizard/task-wizard.ts +214 -0
  66. package/src/cli.ts +126 -0
  67. package/src/config.ts +248 -0
  68. package/src/grok/client.ts +617 -0
  69. package/src/grok/models.ts +50 -0
  70. package/src/grok/session-log.ts +148 -0
  71. package/src/grok/transport.ts +51 -0
  72. package/src/grok/types.ts +136 -0
  73. package/src/index.ts +84 -0
  74. package/src/logger.ts +78 -0
  75. package/src/mcp/config.ts +120 -0
  76. package/src/mcp/probe.ts +218 -0
  77. package/src/mcp/types.ts +68 -0
  78. package/src/projects/manager.ts +99 -0
  79. package/src/render/chunk.ts +57 -0
  80. package/src/render/diff.ts +48 -0
  81. package/src/render/escape.ts +22 -0
  82. package/src/render/file-summary.ts +111 -0
  83. package/src/render/hashtags.ts +34 -0
  84. package/src/render/markdown.ts +130 -0
  85. package/src/render/progress-estimate.ts +63 -0
  86. package/src/render/progress.ts +80 -0
  87. package/src/render/subagent.ts +75 -0
  88. package/src/render/tool-call.ts +196 -0
  89. package/src/service/index.ts +24 -0
  90. package/src/service/linux.ts +85 -0
  91. package/src/service/macos.ts +101 -0
  92. package/src/service/platform.ts +64 -0
  93. package/src/service/types.ts +36 -0
  94. package/src/service/windows.ts +198 -0
  95. package/src/sessions/history.ts +225 -0
  96. package/src/sessions/process.ts +30 -0
  97. package/src/sessions/store.ts +133 -0
  98. package/src/sessions/tail.ts +86 -0
  99. package/src/sessions/types.ts +26 -0
  100. package/src/stream/streamer.ts +261 -0
  101. package/src/tasks/runner.ts +82 -0
  102. package/src/tasks/schedule.ts +142 -0
  103. package/src/tasks/scheduler.ts +53 -0
  104. package/src/tasks/store.ts +80 -0
  105. package/src/tasks/types.ts +33 -0
  106. package/tsconfig.json +19 -0
@@ -0,0 +1,1048 @@
1
+ /**
2
+ * SessionRuntime — binds one Telegram chat to one Grok ACP session and drives
3
+ * the prompt/stream lifecycle, typing indicator, follow-up queue, live watch,
4
+ * and per-chat preferences (project, agent, model, reasoning). State persists
5
+ * to the settings store so it survives restarts.
6
+ */
7
+ import { basename } from "node:path";
8
+ import { type Api, InlineKeyboard } from "grammy";
9
+ import { type GrokClient, isContextExhaustedError, isTransientError, type SessionMetadata } from "../grok/client.js";
10
+ import type { AccountRotator } from "./account-rotator.js";
11
+ import type { ContentBlock, PromptResult, SessionUpdate } from "../grok/types.js";
12
+ import type { AppConfig } from "../config.js";
13
+ import { reasoningDirective } from "../app/reasoning.js";
14
+ import type { SettingsStore } from "../app/settings-store.js";
15
+ import { type PromptInput, type ReasoningEffort, textPrompt } from "../app/types.js";
16
+ import { createLogger } from "../logger.js";
17
+ import { buildTranscript } from "../sessions/history.js";
18
+ import { sessionHashtags } from "../render/hashtags.js";
19
+ import { PROGRESS_DIRECTIVE } from "../render/progress.js";
20
+ import { buildPriming, recentTranscript } from "./session-fork.js";
21
+ import { TailWatcher } from "../sessions/tail.js";
22
+ import type { HistoryEntry } from "../sessions/types.js";
23
+ import { formatToolCall } from "../render/tool-call.js";
24
+ import { type FileOp, fileOpFromUpdate, mergeFileOp, summarizeFileOps, summarizeFileOpsShort } from "../render/file-summary.js";
25
+ import { isActiveStatus, renderSubagentTransition, statusKey } from "../render/subagent.js";
26
+ import type { PendingStage, SubagentInfo } from "../grok/types.js";
27
+ import { ResponseStreamer } from "../stream/streamer.js";
28
+ import { extractImagePaths, sendImages } from "./image-return.js";
29
+ import { buildContentBlocks, mergeInputs } from "./prompt-content.js";
30
+ import { backoffSchedule, fmtSeconds, formatErrorSummary, formatRetryNotice, RETRY_BASE_MS } from "./prompt-retry.js";
31
+ import { sendMarkdownDoc } from "./telegram-io.js";
32
+ import { TypingIndicator } from "./typing.js";
33
+
34
+ const log = createLogger("runtime");
35
+
36
+ const WATCH_ENTRY_MAX = 700;
37
+ const WATCH_ICON: Record<string, string> = {
38
+ user: "\u{1F464}",
39
+ assistant: "\u{1F916}",
40
+ tool: "\u{1F527}",
41
+ system: "\u2139\uFE0F",
42
+ };
43
+
44
+ export type AttachResult = "resumed" | "forked";
45
+
46
+ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms));
47
+
48
+ /**
49
+ * Continuation nudge sent to the SAME session to recover from a transient error
50
+ * that struck mid-stream. The partial reply + any completed tool results are
51
+ * already in the session history, so we ask the agent to finish WITHOUT redoing
52
+ * work (which is why we resume rather than re-send the original prompt).
53
+ */
54
+ const RESUME_INSTRUCTION =
55
+ "Your previous response was interrupted by a transient service error (the model stream was throttled), " +
56
+ "so your last turn did not finish. Continue from exactly where you stopped and complete the response. " +
57
+ "Do NOT repeat any file edits, commands, or other tool calls you already completed — their results are " +
58
+ "already in this conversation. If you had already fully answered, just briefly conclude.";
59
+
60
+ export class SessionRuntime {
61
+ sessionId: string | undefined;
62
+ cwd: string;
63
+ projectName: string | undefined;
64
+ /** Invoked whenever observable state changes (for the status panel). */
65
+ onStateChange: (() => void) | undefined;
66
+
67
+ private busy = false;
68
+ private cancelled = false;
69
+ private readonly queue: PromptInput[] = [];
70
+ private streamer: ResponseStreamer | undefined;
71
+ private readonly typing: TypingIndicator;
72
+ private shownToolIds = new Set<string>();
73
+ /** Files touched this turn (path -> operation), tracked even in background so
74
+ * the completion message can summarise what changed. */
75
+ private fileOps = new Map<string, FileOp>();
76
+ /** The full Done/summary of the most recent finished turn, replayed when you
77
+ * switch (back) into this session so you see how it ended. */
78
+ private lastCompletion: string | undefined;
79
+ /** Latest task-completion % parsed from the agent's `{progress: N%}` markers,
80
+ * shown as a bar in the status panel and session cards. Reset each turn. */
81
+ private progress: number | undefined;
82
+ /** Subagent sessionId -> last status key shown this turn (dedupe). */
83
+ private subagentShown = new Map<string, string>();
84
+ private turnStartedAt = 0;
85
+ /** Count of completed (non-cancelled) turns this session — shown in /usage. */
86
+ private turnCount = 0;
87
+ /** Telegram message id of the current turn's prompt, so replies thread to it. */
88
+ private turnReplyTo: number | undefined;
89
+ private imageScanText = "";
90
+ private sentImagesThisTurn = new Set<string>();
91
+ private readonly listener: (sessionId: string, update: SessionUpdate) => void;
92
+ private primingContext: string | undefined;
93
+ private watcher: TailWatcher | undefined;
94
+ /** True when the active watch is a transient "follow" of this session's own
95
+ * in-flight turn (started on switch) rather than an explicit /watch of
96
+ * another session — follow-watches are auto-stopped when a new turn streams. */
97
+ private watchIsFollow = false;
98
+ private rebindPending = false;
99
+ private sessionLive = false;
100
+ /** Only the foreground runtime streams to Telegram; background ones stay quiet
101
+ * (their output lands in the session's .jsonl and shows as "unread" on switch). */
102
+ private foreground = true;
103
+ private readonly restartListener: () => void;
104
+ /** Invoked when this runtime starts/stops a turn (for subagent attribution). */
105
+ onActivity: ((busy: boolean) => void) | undefined;
106
+ /** Invoked when this runtime adopts a *different* session id (new session or
107
+ * a logical fork), so the owning ChatController can re-persist its controlled
108
+ * list and mark the new session seen. */
109
+ onSessionChange: (() => void) | undefined;
110
+ /** Optional multi-account rotator: when a turn gives up, cycle through the
111
+ * other saved logins once and retry on each. Injected by the registry. */
112
+ accountRotator: AccountRotator | undefined;
113
+
114
+ constructor(
115
+ private readonly api: Api,
116
+ private readonly chatId: number,
117
+ private readonly acp: GrokClient,
118
+ private readonly cfg: AppConfig,
119
+ private readonly settings: SettingsStore,
120
+ init?: { cwd: string; projectName?: string; sessionId?: string },
121
+ ) {
122
+ if (init) {
123
+ this.cwd = init.cwd;
124
+ this.projectName = init.projectName;
125
+ this.sessionId = init.sessionId;
126
+ } else {
127
+ const s = settings.get(chatId);
128
+ this.cwd = s.projectPath ?? cfg.workspace;
129
+ this.projectName = s.projectName;
130
+ this.sessionId = s.sessionId;
131
+ }
132
+ if (this.sessionId) this.rebindPending = true; // lazily reload on first use
133
+
134
+ this.typing = new TypingIndicator(api, chatId);
135
+ this.listener = (sid, update) => this.onUpdate(sid, update);
136
+ this.acp.on("session-update", this.listener);
137
+ this.restartListener = () => {
138
+ this.sessionLive = false;
139
+ if (this.sessionId) this.rebindPending = true;
140
+ };
141
+ this.acp.on("restarted", this.restartListener);
142
+ }
143
+
144
+ get isBusy(): boolean {
145
+ return this.busy;
146
+ }
147
+ get queueLength(): number {
148
+ return this.queue.length;
149
+ }
150
+ get isWatching(): boolean {
151
+ return this.watcher?.running ?? false;
152
+ }
153
+ get isForeground(): boolean {
154
+ return this.foreground;
155
+ }
156
+
157
+ /** The Done/summary of this session's most recent finished turn, if any. */
158
+ get lastTurnSummary(): string | undefined {
159
+ return this.lastCompletion;
160
+ }
161
+
162
+ /** Latest task-completion % (0–100) parsed this turn, or undefined if none. */
163
+ get taskProgress(): number | undefined {
164
+ return this.progress;
165
+ }
166
+
167
+ /** Record a new progress value and refresh the status panel / cards. The bar
168
+ * is monotonic within a turn (it's reset to undefined when a new turn starts),
169
+ * so a streamer recreated mid-turn can't make it jump backwards. */
170
+ private setProgress(pct: number): void {
171
+ const next = Math.max(this.progress ?? 0, pct);
172
+ if (next === this.progress) return;
173
+ this.progress = next;
174
+ this.changed();
175
+ }
176
+
177
+ /** Searchable hashtag footer for this session (project · session · model ·
178
+ * reasoning) — appended to every AI-output surface for this session. */
179
+ get tags(): string {
180
+ return this.hashtags();
181
+ }
182
+
183
+ /** Switch live-streaming on/off. Going background seals any in-flight turn;
184
+ * returning to the foreground while a turn is still running resumes RICH
185
+ * live streaming (thinking / tools / prose) rather than a degraded tail. */
186
+ async setForeground(value: boolean): Promise<void> {
187
+ if (this.foreground === value) return;
188
+ this.foreground = value;
189
+ if (value) {
190
+ // A turn was started here and is still in flight, but its streamer was
191
+ // finalized when we went background. Recreate it and let onUpdate feed
192
+ // the remaining chunks/thoughts/tools just like a normal live turn — we
193
+ // own the agent's session/update events, so no tail-watch is needed.
194
+ if (this.busy && !this.streamer) {
195
+ // Any transient follow-watch of this session is now superseded.
196
+ if (this.watchIsFollow) this.stopWatch();
197
+ this.streamer = new ResponseStreamer(this.api, this.chatId, this.cfg.streamThrottleMs, this.turnReplyTo, this.hashtags(), (pct) => this.setProgress(pct), this.cfg.progressFallback, this.turnStartedAt);
198
+ this.typing.start();
199
+ }
200
+ } else {
201
+ this.typing.stop();
202
+ this.stopWatch();
203
+ if (this.streamer) {
204
+ await this.streamer.finalize().catch(() => {});
205
+ this.streamer = undefined;
206
+ }
207
+ }
208
+ this.changed();
209
+ }
210
+ get reasoning(): ReasoningEffort {
211
+ return this.settings.get(this.chatId).reasoning;
212
+ }
213
+ get agent(): string | undefined {
214
+ return this.settings.get(this.chatId).agent;
215
+ }
216
+ get model(): string | undefined {
217
+ return this.settings.get(this.chatId).model;
218
+ }
219
+
220
+ /** Latest context-usage % / effort / credits for the current session. */
221
+ contextInfo(): SessionMetadata | undefined {
222
+ return this.acp.metadataFor(this.sessionId);
223
+ }
224
+
225
+ /** Number of turns (prompts) this runtime has completed this session. */
226
+ get turns(): number {
227
+ return this.turnCount;
228
+ }
229
+
230
+ dispose(): void {
231
+ this.acp.off("session-update", this.listener);
232
+ this.acp.off("restarted", this.restartListener);
233
+ this.typing.stop();
234
+ this.stopWatch();
235
+ }
236
+
237
+ // ── sessions ───────────────────────────────────────────────────────────────
238
+
239
+ async startNewSession(cwd: string, projectName?: string): Promise<void> {
240
+ if (this.busy) await this.cancel();
241
+ await this.bindNewSession(cwd, projectName);
242
+ }
243
+
244
+ /**
245
+ * Create a fresh live session and adopt it. Does NOT cancel an in-flight turn
246
+ * (the caller decides), so the auto-fork-on-error path can swap to a clean
247
+ * session mid-turn without flagging the turn as user-cancelled.
248
+ */
249
+ private async bindNewSession(cwd: string, projectName?: string): Promise<void> {
250
+ this.stopWatch();
251
+ this.sessionId = await this.acp.newSession(cwd);
252
+ this.sessionLive = true;
253
+ this.rebindPending = false;
254
+ this.cwd = cwd;
255
+ this.projectName = projectName;
256
+ await this.applySessionPrefs();
257
+ this.persist();
258
+ this.sessionChanged();
259
+ log.info(`chat ${this.chatId} -> new session ${this.sessionId} @ ${cwd}`);
260
+ this.changed();
261
+ }
262
+
263
+ /** Ensure a session is live in the current ACP process (used before menus). */
264
+ async prepare(): Promise<void> {
265
+ await this.ensureSession();
266
+ }
267
+
268
+ async resumeSession(sessionId: string, cwd: string, projectName?: string): Promise<void> {
269
+ if (!this.acp.supportsLoadSession) {
270
+ throw new Error("This Grok CLI build does not support loading sessions.");
271
+ }
272
+ if (this.busy) await this.cancel();
273
+ this.stopWatch();
274
+ await this.acp.loadSession(sessionId, cwd);
275
+ this.sessionId = sessionId;
276
+ this.sessionLive = true;
277
+ this.rebindPending = false;
278
+ this.cwd = cwd;
279
+ this.projectName = projectName;
280
+ this.persist();
281
+ log.info(`chat ${this.chatId} -> resumed session ${sessionId} @ ${cwd}`);
282
+ this.changed();
283
+ }
284
+
285
+ async attach(
286
+ sessionId: string,
287
+ cwd: string,
288
+ projectName: string | undefined,
289
+ priorEntries: HistoryEntry[],
290
+ ): Promise<AttachResult> {
291
+ try {
292
+ await this.resumeSession(sessionId, cwd, projectName);
293
+ return "resumed";
294
+ } catch (err) {
295
+ log.warn(`load failed (${(err as Error).message}); forking ${sessionId.slice(0, 8)}`);
296
+ await this.startNewSession(cwd, projectName);
297
+ if (priorEntries.length > 0) this.primingContext = buildPriming(buildTranscript(priorEntries));
298
+ return "forked";
299
+ }
300
+ }
301
+
302
+ startWatch(jsonlPath: string, follow = false): void {
303
+ this.stopWatch();
304
+ this.watchIsFollow = follow;
305
+ this.watcher = new TailWatcher(jsonlPath, (entries) => void this.onWatchEntries(entries));
306
+ this.watcher.start(true);
307
+ }
308
+
309
+ stopWatch(): boolean {
310
+ if (!this.watcher) return false;
311
+ this.watcher.stop();
312
+ this.watcher = undefined;
313
+ this.watchIsFollow = false;
314
+ return true;
315
+ }
316
+
317
+ // ── preferences ──────────────────────────────────────────────────────────
318
+
319
+ async setModelPref(modelId: string): Promise<{ ok: boolean; error?: string }> {
320
+ // Persist the choice always; only talk to Grok when a session is live in
321
+ // the current process (set_model on an unloaded session crashes the agent).
322
+ this.settings.update(this.chatId, { model: modelId });
323
+ if (modelId && this.sessionLive && this.sessionId) {
324
+ if (!this.acp.hasModel(modelId)) return { ok: false, error: `unknown model: ${modelId}` };
325
+ try {
326
+ await this.acp.setModel(this.sessionId, modelId);
327
+ } catch (e) {
328
+ this.changed();
329
+ return { ok: false, error: (e as Error).message };
330
+ }
331
+ }
332
+ this.changed();
333
+ return { ok: true };
334
+ }
335
+
336
+ async setAgentPref(agent: string): Promise<void> {
337
+ this.settings.update(this.chatId, { agent });
338
+ if (agent && this.sessionLive && this.sessionId && this.acp.hasMode(agent)) {
339
+ try {
340
+ await this.acp.setMode(this.sessionId, agent);
341
+ } catch (e) {
342
+ log.warn(`set_mode(${agent}) failed: ${(e as Error).message}`);
343
+ }
344
+ }
345
+ this.changed();
346
+ }
347
+
348
+ setReasoningPref(effort: ReasoningEffort): void {
349
+ this.settings.update(this.chatId, { reasoning: effort });
350
+ this.changed();
351
+ }
352
+
353
+ private async applySessionPrefs(): Promise<void> {
354
+ const s = this.settings.get(this.chatId);
355
+ // Drop any persisted model the agent doesn't actually offer (an unknown id
356
+ // is silently accepted by set_model but then breaks the next prompt).
357
+ if (s.model && !this.acp.hasModel(s.model)) {
358
+ log.warn(`clearing invalid persisted model "${s.model}" for chat ${this.chatId}`);
359
+ this.settings.update(this.chatId, { model: "" });
360
+ }
361
+ const cur = this.settings.get(this.chatId);
362
+ // Adopt the session's current agent (mode) when the user hasn't chosen one.
363
+ if (!cur.agent && this.acp.currentModeId) {
364
+ this.settings.update(this.chatId, { agent: this.acp.currentModeId });
365
+ } else if (this.sessionId && cur.agent && this.acp.hasMode(cur.agent) && cur.agent !== this.acp.currentModeId) {
366
+ try {
367
+ await this.acp.setMode(this.sessionId, cur.agent);
368
+ } catch (e) {
369
+ log.debug(`apply agent failed: ${(e as Error).message}`);
370
+ }
371
+ }
372
+ if (this.sessionId && cur.model && this.acp.hasModel(cur.model)) {
373
+ try {
374
+ await this.acp.setModel(this.sessionId, cur.model);
375
+ } catch (e) {
376
+ log.debug(`apply model failed: ${(e as Error).message}`);
377
+ }
378
+ }
379
+ }
380
+
381
+ // ── prompting ──────────────────────────────────────────────────────────────
382
+
383
+ async submit(input: PromptInput): Promise<"ran" | "queued"> {
384
+ await this.ensureSession();
385
+ if (this.busy) {
386
+ this.queue.push(input);
387
+ this.changed();
388
+ return "queued";
389
+ }
390
+ void this.runTurn(input);
391
+ return "ran";
392
+ }
393
+
394
+ async cancel(): Promise<boolean> {
395
+ if (!this.busy || !this.sessionId) return false;
396
+ this.cancelled = true;
397
+ await this.acp.cancel(this.sessionId);
398
+ return true;
399
+ }
400
+
401
+ clearQueue(): number {
402
+ const n = this.queue.length;
403
+ this.queue.length = 0;
404
+ this.changed();
405
+ return n;
406
+ }
407
+
408
+ drainQueueToPrompt(): PromptInput | undefined {
409
+ if (this.queue.length === 0) return undefined;
410
+ return mergeInputs(this.queue.splice(0, this.queue.length));
411
+ }
412
+
413
+ private async ensureSession(): Promise<void> {
414
+ if (this.rebindPending && this.sessionId) {
415
+ // The ACP process is frequently mid-restart the first time we re-bind
416
+ // (auto-restart after a crash, or a fresh bot boot), so a single attempt
417
+ // is flaky. Retry briefly before giving up.
418
+ if (await this.rebindWithRetries(this.sessionId)) {
419
+ this.sessionLive = true;
420
+ this.rebindPending = false;
421
+ await this.applySessionPrefs();
422
+ log.info(`chat ${this.chatId} re-bound session ${this.sessionId.slice(0, 8)}`);
423
+ return;
424
+ }
425
+ // The session genuinely can't be reloaded (its exclusive lock is held,
426
+ // or its log/metadata is gone). Never silently drop the conversation:
427
+ // fork a linked continuation primed with the recent transcript so the
428
+ // thread survives — including any question the agent had just asked.
429
+ // forkFromLostSession() only throws if the agent is fully down, in which
430
+ // case we leave rebindPending set so the next message retries cleanly.
431
+ await this.forkFromLostSession(this.sessionId);
432
+ this.rebindPending = false;
433
+ return;
434
+ }
435
+ if (!this.sessionId) await this.startNewSession(this.cwd, this.projectName);
436
+ }
437
+
438
+ /** Reload a persisted session, retrying flaky failures with a short backoff.
439
+ * Returns true once loaded, false after the attempts are exhausted. */
440
+ private async rebindWithRetries(sessionId: string, attempts = 4): Promise<boolean> {
441
+ const delays = [400, 1200, 3000]; // ≈4.6s total before giving up
442
+ for (let i = 0; i < attempts; i++) {
443
+ try {
444
+ await this.acp.loadSession(sessionId, this.cwd);
445
+ return true;
446
+ } catch (err) {
447
+ log.warn(
448
+ `re-bind ${sessionId.slice(0, 8)} attempt ${i + 1}/${attempts} failed: ${(err as Error).message}`,
449
+ );
450
+ if (i === attempts - 1) return false;
451
+ await sleep(delays[Math.min(i, delays.length - 1)]!);
452
+ }
453
+ }
454
+ return false;
455
+ }
456
+
457
+ /** Continue a session we could not reload by forking a fresh one primed with
458
+ * the lost session's recent transcript, so no context is dropped. */
459
+ private async forkFromLostSession(lostId: string): Promise<void> {
460
+ const transcript = recentTranscript(this.cfg.sessionsDir, lostId);
461
+ log.warn(
462
+ `chat ${this.chatId} could not reload ${lostId.slice(0, 8)}; forking a linked continuation` +
463
+ (transcript ? " (primed with recent transcript)" : ""),
464
+ );
465
+ await this.startNewSession(this.cwd, this.projectName); // sets a fresh, live sessionId
466
+ if (transcript) this.primingContext = buildPriming(transcript);
467
+ if (this.foreground) {
468
+ await this.notify(
469
+ transcript
470
+ ? "\u{1F517} Couldn't reopen the previous session, so I started a linked continuation primed with the recent transcript \u2014 we can keep going from where we left off."
471
+ : "\u{1F517} Couldn't reopen the previous session, so I started a fresh one here.",
472
+ );
473
+ }
474
+ }
475
+
476
+ private async runTurn(input: PromptInput): Promise<void> {
477
+ this.busy = true;
478
+ this.cancelled = false;
479
+ this.turnReplyTo = input.replyTo;
480
+ this.shownToolIds = new Set();
481
+ this.fileOps = new Map();
482
+ this.subagentShown = new Map();
483
+ this.progress = undefined; // a new turn = a new task; clear the old bar
484
+ // A new streamed turn supersedes any transient "follow" watch of this same
485
+ // session's previous in-flight turn (avoids duplicated output).
486
+ if (this.watchIsFollow) this.stopWatch();
487
+ const live = this.foreground;
488
+ const startedAt = Date.now();
489
+ this.turnStartedAt = startedAt;
490
+ this.streamer = live
491
+ ? new ResponseStreamer(this.api, this.chatId, this.cfg.streamThrottleMs, this.turnReplyTo, this.hashtags(), (pct) => this.setProgress(pct), this.cfg.progressFallback, startedAt)
492
+ : undefined;
493
+ if (live) this.typing.start();
494
+ this.activity(true);
495
+ this.changed();
496
+ this.imageScanText = "";
497
+ this.sentImagesThisTurn = new Set();
498
+
499
+ const content = buildContentBlocks(input, {
500
+ reasoning: reasoningDirective(this.reasoning),
501
+ priming: this.primingContext,
502
+ progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
503
+ });
504
+ this.primingContext = undefined;
505
+
506
+ try {
507
+ const outcome = await this.runPromptWithRetries(content);
508
+ const recovered = await this.maybeAutoFork(input, outcome);
509
+ let final = recovered ?? outcome;
510
+ // Last resort: if the turn still failed, rotate through other saved
511
+ // accounts (once) and retry on each until one works.
512
+ const rotated = await this.maybeRotateAccount(input, final);
513
+ if (rotated) final = rotated;
514
+ // A transient error that struck AFTER streaming began skips the paths
515
+ // above (they must not re-run already-executed tools). Recover by asking
516
+ // the SAME session to CONTINUE from where it stopped, with backoff.
517
+ const resumed = await this.maybeResumeAfterStream(final);
518
+ if (resumed) final = resumed;
519
+ const streamedOutput = this.streamer?.hasOutput ?? false;
520
+ // On a successful, non-cancelled turn, top the fallback bar up to 100 (a
521
+ // no-op when the agent reported its own progress — its value is kept).
522
+ if (final.result && !this.cancelled) this.streamer?.completeFallback();
523
+ if (this.streamer) await this.streamer.finalize();
524
+ if (this.foreground) await this.sendTurnImages();
525
+ // Always build the completion (records `lastCompletion` so switching back
526
+ // to this session can replay its Done + summary). Only PING the chat for
527
+ // the foreground turn, or a background turn when NOTIFY_OTHER_SESSIONS is on.
528
+ const canPing = this.foreground || this.cfg.notifyOtherSessions;
529
+ // A background session about to run a queued follow-up shouldn't ping its
530
+ // interim "Done" — only the final, queue-empty turn announces completion.
531
+ const hasQueued = this.queue.length > 0;
532
+ const switchKb = this.switchKeyboard();
533
+ if (final.result && !this.cancelled) this.turnCount++;
534
+ if (final.result || this.cancelled) {
535
+ const live = this.completionMessage(final.result?.stopReason, startedAt, streamedOutput);
536
+ const pingDone = canPing && (this.foreground || !hasQueued);
537
+ if (pingDone) await this.notify(live, { loud: true, replyTo: this.turnReplyTo, replyMarkup: switchKb });
538
+ } else if (final.error) {
539
+ const transient = isTransientError(final.error);
540
+ const live = this.errorMessage(final.error, startedAt, final.attempts, transient);
541
+ if (canPing) await this.notify(live, { loud: true, replyTo: this.turnReplyTo, replyMarkup: switchKb });
542
+ }
543
+ } catch (err) {
544
+ // Unexpected failure outside the prompt path (e.g. while finalizing).
545
+ await this.streamer?.finalize().catch(() => {});
546
+ const msg = `\u274C Error after ${fmtDuration(Date.now() - startedAt)}: ${(err as Error).message}`;
547
+ this.lastCompletion = msg;
548
+ if (this.foreground || this.cfg.notifyOtherSessions) {
549
+ const from = this.foreground ? "" : `\u{1F4E8} From other session ${this.sessionTag()}\n`;
550
+ await this.notify(`${from}${msg}`, { loud: true, replyTo: this.turnReplyTo, replyMarkup: this.switchKeyboard() });
551
+ }
552
+ } finally {
553
+ this.typing.stop();
554
+ this.streamer = undefined;
555
+ this.busy = false;
556
+ this.activity(false);
557
+ // The in-flight turn we may have been following live is over.
558
+ if (this.watchIsFollow) this.stopWatch();
559
+ // Turn ended (done / stopped / error): drop the live task-progress value so
560
+ // the bar is removed from the status panel, session cards and switch
561
+ // messages. The finished streamed bubble keeps its own (frozen) bar.
562
+ this.progress = undefined;
563
+ this.changed();
564
+ }
565
+
566
+ await this.flushQueue();
567
+ }
568
+
569
+ private activity(busy: boolean): void {
570
+ try {
571
+ this.onActivity?.(busy);
572
+ } catch {
573
+ /* non-fatal */
574
+ }
575
+ }
576
+
577
+ /**
578
+ * Show subagent ("crew") status transitions for the given (already
579
+ * chat-attributed) subagents, so the user sees progress while the main agent
580
+ * waits on them. No-op unless this runtime is the live foreground turn.
581
+ */
582
+ renderSubagents(subagents: SubagentInfo[], _pending: PendingStage[]): void {
583
+ if (!this.cfg.showSubagents) return;
584
+ if (!this.foreground || !this.busy || !this.streamer) return;
585
+ for (const s of subagents) {
586
+ const key = statusKey(s);
587
+ const prev = this.subagentShown.get(s.sessionId);
588
+ if (prev === key) continue;
589
+ const kind: "start" | "status" = prev === undefined && isActiveStatus(key) ? "start" : "status";
590
+ this.subagentShown.set(s.sessionId, key);
591
+ const md = renderSubagentTransition(s, kind);
592
+ if (md) this.streamer.addTool(md);
593
+ }
594
+ }
595
+
596
+ /**
597
+ * True when a prompt failure is attributable to an exhausted context window —
598
+ * either the error message says so, or this session's last-known context
599
+ * usage is at/above the configured fork threshold. Such failures won't clear
600
+ * by retrying the same oversized prompt (throttling on a near-full session
601
+ * surfaces as a plain "-32603 … throttled"), so the session must be compacted
602
+ * by forking a fresh, smaller continuation.
603
+ */
604
+ private isContextRelatedFailure(error: Error): boolean {
605
+ if (isContextExhaustedError(error)) return true;
606
+ const threshold = this.cfg.autoForkContextPct;
607
+ if (threshold <= 0) return false;
608
+ const pct = this.contextInfo()?.contextUsagePercentage;
609
+ return pct !== undefined && pct >= threshold;
610
+ }
611
+
612
+ /**
613
+ * Auto-fork-on-error recovery. When a turn fails with a *transient* error (or
614
+ * a context-exhaustion error) and nothing was streamed to the user, the
615
+ * session is throttled / context-exhausted / stuck. We "logically fork" it:
616
+ * open a fresh session in the same project primed with the recent transcript
617
+ * (the old session is dropped from this chat), then retry the SAME message
618
+ * once on the clean session. For context-exhausted sessions the retry backoff
619
+ * is skipped upstream so this fires immediately. Returns the retried outcome,
620
+ * or undefined when no fork was attempted.
621
+ */
622
+ private async maybeAutoFork(
623
+ input: PromptInput,
624
+ outcome: { result?: PromptResult; error?: Error; attempts: number },
625
+ ): Promise<{ result?: PromptResult; error?: Error; attempts: number } | undefined> {
626
+ if (!this.cfg.autoForkOnError || !outcome.error || !this.sessionId) return undefined;
627
+ if (this.cancelled || (this.streamer?.hasOutput ?? false)) return undefined;
628
+ const contextRelated = this.isContextRelatedFailure(outcome.error);
629
+ if (!isTransientError(outcome.error) && !contextRelated) return undefined;
630
+
631
+ const lostId = this.sessionId;
632
+ const transcript = recentTranscript(this.cfg.sessionsDir, lostId);
633
+ if (this.foreground) {
634
+ const reason = contextRelated
635
+ ? "That session's context looks full \u2014 compacting into a fresh continuation and retrying"
636
+ : "That session looks exhausted or stuck \u2014 forking a fresh continuation and retrying";
637
+ await this.notify(
638
+ `\u26A0\uFE0F ${outcome.error.message}\n\n\u{1F517} ${reason}${transcript ? " (primed with the recent transcript)" : ""}\u2026`,
639
+ { replyTo: this.turnReplyTo },
640
+ );
641
+ }
642
+ try {
643
+ await this.bindNewSession(this.cwd, this.projectName); // new live id; old session dropped
644
+ } catch (e) {
645
+ log.warn(`auto-fork failed (agent down?): ${(e as Error).message}`);
646
+ return undefined;
647
+ }
648
+ log.info(
649
+ `chat ${this.chatId} auto-forked ${lostId.slice(0, 8)} -> ${this.sessionId!.slice(0, 8)} after ${contextRelated ? "context-exhaustion" : "transient"} error`,
650
+ );
651
+ // Reset per-turn render state so the retry streams cleanly on the new session.
652
+ this.shownToolIds = new Set();
653
+ this.subagentShown = new Map();
654
+ this.streamer?.setFooter(this.hashtags()); // streamed reply tags the NEW session
655
+ const forkContent = buildContentBlocks(input, {
656
+ reasoning: reasoningDirective(this.reasoning),
657
+ priming: transcript ? buildPriming(transcript) : undefined,
658
+ progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
659
+ });
660
+ return this.runPromptWithRetries(forkContent);
661
+ }
662
+
663
+ /**
664
+ * Auto-rotate-on-give-up. When a turn has failed (retries exhausted, auto-fork
665
+ * didn't recover it) and nothing was streamed, cycle through the OTHER saved
666
+ * accounts once — switching login + restarting the agent, then retrying the
667
+ * same prompt on a fresh session for each. The first account that succeeds
668
+ * wins and stays active; if every account fails we return a single combined
669
+ * error listing what each one reported. Bounded to ONE pass (no infinite
670
+ * loop). No-op unless the rotator is enabled and other accounts exist.
671
+ */
672
+ private async maybeRotateAccount(
673
+ input: PromptInput,
674
+ final: { result?: PromptResult; error?: Error; attempts: number },
675
+ ): Promise<{ result?: PromptResult; error?: Error; attempts: number } | undefined> {
676
+ const rotator = this.accountRotator;
677
+ if (!rotator?.enabled() || !final.error || this.cancelled) return undefined;
678
+ if (this.streamer?.hasOutput ?? false) return undefined;
679
+ const targets = await rotator.targets().catch(() => [] as { id: string; label: string }[]);
680
+ if (targets.length === 0) return undefined;
681
+
682
+ const transcript = this.sessionId ? recentTranscript(this.cfg.sessionsDir, this.sessionId) : undefined;
683
+ const errors: string[] = [`\u2022 previous: ${final.error.message}`];
684
+ let last = final;
685
+
686
+ for (const t of targets) {
687
+ if (this.cancelled) return last;
688
+ if (this.foreground) {
689
+ await this.notify(`\u{1F501} Auto-rotating accounts \u2014 trying ${t.label}\u2026`, { replyTo: this.turnReplyTo });
690
+ }
691
+ try {
692
+ await rotator.activate(t.id); // switch login + restart the shared agent
693
+ } catch (e) {
694
+ errors.push(`\u2022 ${t.label}: couldn't switch \u2014 ${(e as Error).message}`);
695
+ continue;
696
+ }
697
+ try {
698
+ await this.bindNewSession(this.cwd, this.projectName); // fresh session on the new login
699
+ } catch (e) {
700
+ errors.push(`\u2022 ${t.label}: no session \u2014 ${(e as Error).message}`);
701
+ continue;
702
+ }
703
+ // Reset per-turn render state so the retry streams cleanly.
704
+ this.shownToolIds = new Set();
705
+ this.subagentShown = new Map();
706
+ this.streamer?.setFooter(this.hashtags());
707
+ const content = buildContentBlocks(input, {
708
+ reasoning: reasoningDirective(this.reasoning),
709
+ priming: transcript ? buildPriming(transcript) : undefined,
710
+ progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
711
+ });
712
+ log.info(`chat ${this.chatId} auto-rotating to account ${t.label}`);
713
+ last = await this.runPromptWithRetries(content);
714
+ if (last.result && !this.cancelled) {
715
+ if (this.foreground) await this.notify(`\u2705 Recovered on ${t.label}.`, { replyTo: this.turnReplyTo });
716
+ return last;
717
+ }
718
+ if (this.cancelled || (this.streamer?.hasOutput ?? false)) return last;
719
+ errors.push(`\u2022 ${t.label}: ${last.error?.message ?? "failed"}`);
720
+ }
721
+
722
+ // One full cycle done and still failing — stop with a combined report.
723
+ const combined = new Error(`Tried ${targets.length + 1} account(s), all failed:\n${errors.join("\n")}`);
724
+ return { error: combined, attempts: last.attempts };
725
+ }
726
+
727
+ /**
728
+ * Run the prompt, retrying *transient* agent errors (e.g. "high volume of
729
+ * traffic" / -32603) with an exponential backoff (6s → 12s → 24s → 48s → 60s,
730
+ * then give up). The real error is shown to the user on every failed attempt.
731
+ *
732
+ * We only retry while the turn has produced **no streamed output** (so tools
733
+ * aren't re-run and text isn't duplicated) and the user hasn't cancelled.
734
+ * Returns the result, or the last error once retries are exhausted.
735
+ */
736
+ private async runPromptWithRetries(
737
+ content: ContentBlock[],
738
+ ): Promise<{ result?: PromptResult; error?: Error; attempts: number }> {
739
+ const delays = this.cfg.promptRetryAttempts > 0 ? backoffSchedule(this.cfg.promptRetryAttempts) : [];
740
+ const totalAttempts = delays.length + 1;
741
+ let attempt = 0;
742
+ for (;;) {
743
+ attempt++;
744
+ try {
745
+ const result = await this.acp.prompt(this.sessionId!, content);
746
+ return { result, attempts: attempt };
747
+ } catch (err) {
748
+ const error = err as Error;
749
+ const canRecover = !this.cancelled && !(this.streamer?.hasOutput ?? false);
750
+ // A context-exhausted session won't recover by retrying the same
751
+ // oversized prompt — skip the backoff and let auto-fork compact it now.
752
+ const forkInstead = canRecover && this.cfg.autoForkOnError && this.isContextRelatedFailure(error);
753
+ const willRetry =
754
+ attempt <= delays.length &&
755
+ canRecover &&
756
+ !forkInstead &&
757
+ isTransientError(error);
758
+ if (!willRetry) return { error, attempts: attempt };
759
+ const waitMs = delays[attempt - 1]!;
760
+ if (this.foreground) {
761
+ await this.notify(formatRetryNotice(error, attempt + 1, totalAttempts, waitMs), {
762
+ replyTo: this.turnReplyTo,
763
+ });
764
+ }
765
+ if (await this.interruptibleSleep(waitMs)) return { error, attempts: attempt };
766
+ }
767
+ }
768
+ }
769
+
770
+ /** Sleep that returns true early if the user cancels the turn meanwhile. */
771
+ private async interruptibleSleep(ms: number): Promise<boolean> {
772
+ const step = 500;
773
+ for (let waited = 0; waited < ms; waited += step) {
774
+ if (this.cancelled) return true;
775
+ await sleep(Math.min(step, ms - waited));
776
+ }
777
+ return this.cancelled;
778
+ }
779
+
780
+ /**
781
+ * Recover from a transient error (throttle / internal error / dropped
782
+ * response stream) that struck AFTER the turn already started streaming.
783
+ *
784
+ * The pre-stream paths (retry / auto-fork / account-rotate) all bail once any
785
+ * output exists, because re-sending the original prompt would re-execute the
786
+ * tools that already ran (duplicate/destructive side effects). Instead we ask
787
+ * the SAME session to CONTINUE from where it stopped — its partial reply and
788
+ * any completed tool results are already in history, so nothing is repeated —
789
+ * using the same exponential backoff so a throttle has time to clear. The
790
+ * open streamer keeps appending, so the reply is completed in place.
791
+ *
792
+ * Returns the recovered outcome, or `undefined` when this path doesn't apply
793
+ * (feature off, no error, cancelled, nothing streamed, or non-transient).
794
+ */
795
+ private async maybeResumeAfterStream(
796
+ final: { result?: PromptResult; error?: Error; attempts: number },
797
+ ): Promise<{ result?: PromptResult; error?: Error; attempts: number } | undefined> {
798
+ if (!this.cfg.resumeOnStreamError || !final.error || this.cancelled || !this.sessionId) return undefined;
799
+ // Only for the post-stream case; the pre-stream paths own the rest.
800
+ if (!(this.streamer?.hasOutput ?? false)) return undefined;
801
+ if (!isTransientError(final.error)) return undefined;
802
+ // A context-full session won't recover by continuing (it'll just throttle
803
+ // again each attempt) — don't burn the backoff; surface the error so the
804
+ // user can fork/compact. Resume targets transient throttles on a session
805
+ // that still has headroom.
806
+ if (this.isContextRelatedFailure(final.error)) return undefined;
807
+
808
+ const sessionId = this.sessionId;
809
+ const delays = this.cfg.promptRetryAttempts > 0 ? backoffSchedule(this.cfg.promptRetryAttempts) : [RETRY_BASE_MS];
810
+ const resumeContent = buildContentBlocks(textPrompt(RESUME_INSTRUCTION), {
811
+ reasoning: reasoningDirective(this.reasoning),
812
+ progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
813
+ });
814
+
815
+ let last = final;
816
+ let attempts = final.attempts;
817
+ for (let i = 0; i < delays.length; i++) {
818
+ if (this.cancelled) return last;
819
+ const waitMs = delays[i]!;
820
+ if (this.foreground) {
821
+ await this.notify(
822
+ `\u26A0\uFE0F ${last.error!.message}\n\n\u{1F501} The reply was cut off mid-stream \u2014 resuming in ${fmtSeconds(waitMs)} (attempt ${i + 1} of ${delays.length})\u2026`,
823
+ { replyTo: this.turnReplyTo },
824
+ );
825
+ }
826
+ if (await this.interruptibleSleep(waitMs)) return last;
827
+ attempts++; // this resume prompt is one more attempt for the turn
828
+ try {
829
+ const result = await this.acp.prompt(sessionId, resumeContent);
830
+ log.info(`chat ${this.chatId} resumed after mid-stream ${last.error!.message.slice(0, 40)} (attempt ${i + 1})`);
831
+ return { result, attempts };
832
+ } catch (err) {
833
+ last = { error: err as Error, attempts };
834
+ // If the follow-up fails for a NON-transient reason, stop early.
835
+ if (!isTransientError(last.error!)) return last;
836
+ }
837
+ }
838
+ return last;
839
+ }
840
+
841
+ /** Send any fresh images the agent produced this turn (screenshots, etc.). */
842
+ private async sendTurnImages(): Promise<void> {
843
+ if (!this.cfg.sendAgentImages || !this.imageScanText) return;
844
+ const paths = extractImagePaths(this.imageScanText, this.cwd);
845
+ if (paths.length === 0) return;
846
+ try {
847
+ await sendImages(this.api, this.chatId, paths, {
848
+ since: this.turnStartedAt,
849
+ already: this.sentImagesThisTurn,
850
+ max: this.cfg.agentImagesMax,
851
+ });
852
+ } catch {
853
+ /* non-fatal */
854
+ }
855
+ }
856
+
857
+ /** Build the "turn finished" message and record `lastCompletion` (the full
858
+ * in-session version with the file list). Foreground gets the full version;
859
+ * a background turn gets a labelled "other session" ping with short counts. */
860
+ private completionMessage(stopReason: string | undefined, startedAt: number, streamedOutput: boolean): string {
861
+ const head = this.doneHead(stopReason, startedAt, streamedOutput);
862
+ const tags = this.hashtags();
863
+ const base = `${head}\n${summarizeFileOps(this.fileOps, this.cwd)}`;
864
+ this.lastCompletion = `${base}\n\n${tags}`; // switch-replay stays searchable
865
+ if (this.foreground) {
866
+ // The streamed response already carries the tag footer; only add tags to
867
+ // the Done line when there was no response to tag (tool-only / no output).
868
+ return streamedOutput ? base : `${base}\n\n${tags}`;
869
+ }
870
+ return `\u{1F4E8} From other session ${this.sessionTag()}\n${head}\n${summarizeFileOpsShort(this.fileOps)}\n\n${tags}`;
871
+ }
872
+
873
+ /** The compact one-line status of a finished turn (no "end_turn" noise). */
874
+ private doneHead(stopReason: string | undefined, startedAt: number, streamedOutput: boolean): string {
875
+ const elapsed = fmtDuration(Date.now() - startedAt);
876
+ if (this.cancelled || stopReason === "cancelled") return `\u23F9 Stopped \u00B7 ${elapsed}`;
877
+ const reason = stopReason && stopReason !== "end_turn" ? ` \u00B7 ${stopReason}` : "";
878
+ const meta = this.contextInfo();
879
+ const ctx = meta?.contextUsagePercentage;
880
+ const ctxStr = ctx !== undefined ? ` \u00B7 ctx ${ctx.toFixed(0)}%` : "";
881
+ // Credits consumed this turn — only shown when Grok actually reports it
882
+ // (not part of ACP today; degrades to nothing rather than guessing).
883
+ const credits = meta?.credits;
884
+ const creditStr = credits !== undefined ? ` \u00B7 \u{1FA99} ${fmtCredits(credits)}` : "";
885
+ // Only claim "no text output" when we were actually streaming (foreground).
886
+ const noOut = this.foreground && !streamedOutput ? " \u00B7 no text output" : "";
887
+ return `\u2705 Done${reason} \u00B7 ${elapsed}${ctxStr}${creditStr}${noOut}`;
888
+ }
889
+
890
+ /** Build the turn-failed message and record `lastCompletion`. */
891
+ private errorMessage(error: Error, startedAt: number, attempts: number, transient: boolean): string {
892
+ const summary = formatErrorSummary(error, fmtDuration(Date.now() - startedAt), attempts, transient);
893
+ const files = this.fileOps.size > 0 ? `\n${summarizeFileOps(this.fileOps, this.cwd)}` : "";
894
+ const tags = this.hashtags();
895
+ this.lastCompletion = `${summary}${files}\n\n${tags}`;
896
+ if (this.foreground) return this.lastCompletion;
897
+ const shortFiles = this.fileOps.size > 0 ? `\n${summarizeFileOpsShort(this.fileOps)}` : "";
898
+ return `\u{1F4E8} From other session ${this.sessionTag()}\n${summary}${shortFiles}\n\n${tags}`;
899
+ }
900
+
901
+ /** "[project · 1a2b3c4d]" — identifies which background session a ping is from. */
902
+ private sessionTag(): string {
903
+ const name = this.projectName || basename(this.cwd) || "session";
904
+ const id = this.sessionId ? ` \u00B7 ${this.sessionId.slice(0, 8)}` : "";
905
+ return `[${name}${id}]`;
906
+ }
907
+
908
+ /** Inline keyboard offering to switch to this session, attached to background
909
+ * ("From other session") pings so you can jump straight in. Foreground turns
910
+ * are already in view, so they get no button. */
911
+ private switchKeyboard(): InlineKeyboard | undefined {
912
+ if (this.foreground || !this.sessionId) return undefined;
913
+ return new InlineKeyboard().text("\u{1F500} Switch to this session", `run:switch:${this.sessionId}`);
914
+ }
915
+
916
+ /** Searchable Telegram hashtags so you can pull up every message of a session
917
+ * or project by tapping the tag. */
918
+ private hashtags(): string {
919
+ return sessionHashtags({
920
+ projectName: this.projectName,
921
+ cwd: this.cwd,
922
+ sessionId: this.sessionId,
923
+ });
924
+ }
925
+
926
+ private async flushQueue(): Promise<void> {
927
+ if (this.queue.length === 0 || this.busy) return;
928
+ const batch = mergeInputs(this.queue.splice(0, this.queue.length));
929
+ if (this.foreground) await this.notify("\u25B6\uFE0F Processing queued message\u2026");
930
+ void this.runTurn(batch);
931
+ }
932
+
933
+ private onUpdate(sessionId: string, update: SessionUpdate): void {
934
+ if (!this.busy || sessionId !== this.sessionId) return;
935
+ const kind = update.sessionUpdate;
936
+
937
+ // Accumulate the turn's file-change summary + image-scan text even when this
938
+ // session is in the background (its output isn't streamed here, but the
939
+ // completion message still reports what changed / which images were made).
940
+ if (kind === "tool_call" || kind === "tool_call_update") {
941
+ if (update.rawInput) this.imageScanText += " " + JSON.stringify(update.rawInput);
942
+ if (update.title) this.imageScanText += " " + update.title;
943
+ const fo = fileOpFromUpdate(update);
944
+ if (fo) this.fileOps.set(fo.path, mergeFileOp(this.fileOps.get(fo.path), fo.op));
945
+ } else if (kind === "agent_message_chunk") {
946
+ const text = update.content?.text;
947
+ if (typeof text === "string") this.imageScanText += text;
948
+ }
949
+
950
+ // Only the live foreground turn streams to Telegram.
951
+ if (!this.foreground || !this.streamer) return;
952
+
953
+ if (kind === "agent_message_chunk") {
954
+ const text = update.content?.text;
955
+ if (typeof text === "string") this.streamer.appendOutput(text);
956
+ return;
957
+ }
958
+ if (kind === "agent_thought_chunk") {
959
+ const text = update.content?.text;
960
+ if (typeof text === "string") this.streamer.appendThought(text);
961
+ return;
962
+ }
963
+ if (kind === "tool_call" || kind === "tool_call_update") {
964
+ if (!this.cfg.showToolCalls) return;
965
+ const id = update.toolCallId || `${kind}:${update.title ?? ""}`;
966
+ if (this.shownToolIds.has(id)) return;
967
+ this.shownToolIds.add(id);
968
+ const md = formatToolCall(update, {
969
+ showDiffs: this.cfg.showEditDiffs,
970
+ diffMaxLines: this.cfg.diffMaxLines,
971
+ });
972
+ if (md) this.streamer.addTool(md);
973
+ }
974
+ }
975
+
976
+ private persist(): void {
977
+ if (!this.foreground) return; // only the foreground session is the chat's restored default
978
+ this.settings.update(this.chatId, {
979
+ projectPath: this.cwd,
980
+ projectName: this.projectName,
981
+ sessionId: this.sessionId,
982
+ });
983
+ }
984
+
985
+ private changed(): void {
986
+ try {
987
+ this.onStateChange?.();
988
+ } catch {
989
+ /* non-fatal */
990
+ }
991
+ }
992
+
993
+ private sessionChanged(): void {
994
+ try {
995
+ this.onSessionChange?.();
996
+ } catch {
997
+ /* non-fatal */
998
+ }
999
+ }
1000
+
1001
+ private async notify(
1002
+ text: string,
1003
+ opts?: { loud?: boolean; replyTo?: number; replyMarkup?: InlineKeyboard },
1004
+ ): Promise<void> {
1005
+ try {
1006
+ const extra: Record<string, unknown> = opts?.loud ? { disable_notification: false } : {};
1007
+ if (opts?.replyTo !== undefined) {
1008
+ extra.reply_parameters = { message_id: opts.replyTo, allow_sending_without_reply: true };
1009
+ }
1010
+ if (opts?.replyMarkup) extra.reply_markup = opts.replyMarkup;
1011
+ await this.api.sendMessage(this.chatId, text, extra);
1012
+ } catch {
1013
+ /* non-fatal */
1014
+ }
1015
+ }
1016
+
1017
+ private async onWatchEntries(entries: HistoryEntry[]): Promise<void> {
1018
+ const body = entries
1019
+ .map((e) => {
1020
+ const icon = WATCH_ICON[e.role] ?? "\u2022";
1021
+ if (e.role === "tool") return `${icon} ${e.tool ? `\`${e.tool}\`` : "tool"}`;
1022
+ const text = e.text.length > WATCH_ENTRY_MAX ? e.text.slice(0, WATCH_ENTRY_MAX) + " …" : e.text;
1023
+ return `${icon} ${text}`;
1024
+ })
1025
+ .filter(Boolean)
1026
+ .join("\n\n");
1027
+ if (body.trim()) await sendMarkdownDoc(this.api, this.chatId, `${body}\n\n${this.tags}`);
1028
+ }
1029
+ }
1030
+
1031
+ /** Format an elapsed duration compactly (e.g. "8s", "2m 13s", "1h 4m"). */
1032
+ function fmtDuration(ms: number): string {
1033
+ const s = Math.round(ms / 1000);
1034
+ if (s < 60) return `${s}s`;
1035
+ const m = Math.floor(s / 60);
1036
+ if (m < 60) return `${m}m ${s % 60}s`;
1037
+ return `${Math.floor(m / 60)}h ${m % 60}m`;
1038
+ }
1039
+
1040
+ /** Format a credits/cost figure compactly (drops noise decimals). */
1041
+ function fmtCredits(n: number): string {
1042
+ if (!Number.isFinite(n)) return String(n);
1043
+ if (Number.isInteger(n)) return n.toLocaleString("en-US");
1044
+ return n.toFixed(2);
1045
+ }
1046
+
1047
+ /** Convenience for callers that only have text. */
1048
+ export { textPrompt };