grok-telegram-bot 2.3.0 → 2.3.1

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.
package/CHANGELOG.md CHANGED
@@ -9,6 +9,23 @@ The latest section is published verbatim as the GitHub Release notes by
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [2.3.1] - 2026-07-19
13
+
14
+ ### Fixed
15
+
16
+ - **Stable account rotation across concurrent chats.** Account selection is now
17
+ serialized around the single shared Grok CLI process. Chats reuse the working
18
+ account selected by another session and create their own ACP session instead
19
+ of starting competing rotation loops that repeatedly restart the agent.
20
+ - **Session failures no longer condemn valid accounts.** `unknown session id`,
21
+ agent-restart/process-exit, and headless-auth initialization errors now trigger
22
+ a session rebind/retry on the active account rather than quarantining or
23
+ cycling functional logins.
24
+ - **Reliable restart propagation.** Credential imports, manual switches,
25
+ reauthentication, and automatic rotation notify every chat runtime after the
26
+ shared process restarts; new session binding waits until candidate validation
27
+ completes.
28
+
12
29
  ## [2.3.0] - 2026-07-17
13
30
 
14
31
  ### Added
@@ -761,6 +778,7 @@ from a single chat and switch between them, on a redesigned, compact menu.
761
778
  diffs, MarkdownV2 rendering, scheduled tasks, multi-image prompts, and a
762
779
  cross-platform 24/7 background service.
763
780
 
781
+ [2.3.1]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.3.1
764
782
  [2.3.0]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.3.0
765
783
  [2.2.4]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.2.4
766
784
  [2.2.3]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.2.3
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "grok-telegram-bot",
3
- "version": "2.3.0",
3
+ "version": "2.3.1",
4
4
  "description": "Control the official Grok Build CLI from Telegram over the Agent Client Protocol (ACP). Sign in with your xAI account, switch projects, resume sessions, stream responses with diffs, queue follow-ups, manage multiple sign-ins, and run 24/7 as a cross-platform background service.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -30,6 +30,12 @@ export interface RotationTarget {
30
30
  label: string;
31
31
  }
32
32
 
33
+ export interface RotationState {
34
+ generation: number;
35
+ activeId?: string;
36
+ activeLabel?: string;
37
+ }
38
+
33
39
  export interface AccountRotator {
34
40
  /** Whether auto-rotate is switched on. */
35
41
  enabled(): boolean;
@@ -39,9 +45,19 @@ export interface AccountRotator {
39
45
  activate(id: string): Promise<void>;
40
46
  /** Quarantine an account after an account-specific failure. Undefined means active. */
41
47
  markFailed(id: string | undefined, reason: string): Promise<void>;
48
+ /** Process/account generation observed when a turn failed. */
49
+ state(): RotationState;
50
+ /** Serialize a complete rotation probe. `changed` means another chat already
51
+ * selected/restarted an account while this caller was waiting. */
52
+ withRotationLock<T>(observed: RotationState, run: (changed: boolean) => Promise<T>): Promise<T>;
53
+ /** Wait for an in-progress rotation probe before re-binding a stale session. */
54
+ waitForIdle(): Promise<void>;
42
55
  }
43
56
 
44
57
  export class AccountRotatorImpl implements AccountRotator {
58
+ private generation = 0;
59
+ private rotationTail: Promise<void> = Promise.resolve();
60
+
45
61
  constructor(
46
62
  private readonly accounts: AccountManager,
47
63
  private readonly acp: GrokClient,
@@ -51,6 +67,42 @@ export class AccountRotatorImpl implements AccountRotator {
51
67
  return this.accounts.autoRotateEnabled();
52
68
  }
53
69
 
70
+ state(): RotationState {
71
+ const activeId = this.accounts.activeAccountId();
72
+ return {
73
+ generation: this.generation,
74
+ activeId,
75
+ activeLabel: activeId ? this.accounts.get(activeId)?.label : undefined,
76
+ };
77
+ }
78
+
79
+ async withRotationLock<T>(observed: RotationState, run: (changed: boolean) => Promise<T>): Promise<T> {
80
+ const previous = this.rotationTail;
81
+ let release!: () => void;
82
+ this.rotationTail = new Promise<void>((resolve) => {
83
+ release = resolve;
84
+ });
85
+ await previous;
86
+ try {
87
+ const current = this.state();
88
+ const changed =
89
+ current.generation !== observed.generation || current.activeId !== observed.activeId;
90
+ return await run(changed);
91
+ } finally {
92
+ release();
93
+ }
94
+ }
95
+
96
+ async waitForIdle(): Promise<void> {
97
+ // Include work queued while we were waiting, not only the first captured
98
+ // promise, so callers never re-bind in the middle of a candidate switch.
99
+ for (;;) {
100
+ const pending = this.rotationTail;
101
+ await pending;
102
+ if (pending === this.rotationTail) return;
103
+ }
104
+ }
105
+
54
106
  async targets(): Promise<RotationTarget[]> {
55
107
  const list = this.accounts.list();
56
108
  const activeId = this.accounts.activeAccountId();
@@ -96,12 +148,19 @@ export class AccountRotatorImpl implements AccountRotator {
96
148
  log.info(`rotating: auth.json now ${meta.label}; starting Grok CLI + re-auth`);
97
149
  // start() → connect() → initialize + authenticate(cached_token) against
98
150
  // the freshly written auth.json. A live process would keep the old token.
99
- await this.acp.start();
151
+ await this.acp.start(true);
152
+ this.generation++;
100
153
  log.info(`rotating: Grok CLI up on ${meta.label}`);
101
154
  } catch (e) {
102
155
  // Best-effort recover the agent so the bot stays usable even if the
103
156
  // target login was bad.
104
- await this.acp.start().catch((err) => log.warn("post-rotate restart failed:", (err as Error).message));
157
+ await this.acp.stopAndWait().catch(() => {});
158
+ await this.acp
159
+ .start(true)
160
+ .then(() => {
161
+ this.generation++;
162
+ })
163
+ .catch((err) => log.warn("post-rotate restart failed:", (err as Error).message));
105
164
  throw e;
106
165
  }
107
166
  }
@@ -205,9 +205,9 @@ export function registerAccounts(bot: Bot, deps: BotDeps): void {
205
205
  // Import reuses the live auth.json — just re-bind the agent headlessly.
206
206
  try {
207
207
  await deps.acp.stopAndWait();
208
- await deps.acp.start();
208
+ await deps.acp.start(true);
209
209
  } catch (e) {
210
- await deps.acp.start().catch(() => {});
210
+ await deps.acp.start(true).catch(() => {});
211
211
  return void rerender(ctx, deps, `\u26A0\uFE0F Imported, but re-bind failed: ${(e as Error).message}`);
212
212
  }
213
213
  let note = `\u2705 Imported the current login${res.label ? ` (${res.label})` : ""}.`;
@@ -239,9 +239,9 @@ export function registerAccounts(bot: Bot, deps: BotDeps): void {
239
239
  try {
240
240
  meta = await deps.accounts.switchTo(id);
241
241
  // 3) Start agent; it authenticates headlessly with cached_token.
242
- await deps.acp.start();
242
+ await deps.acp.start(true);
243
243
  } catch (e) {
244
- await deps.acp.start().catch(() => {});
244
+ await deps.acp.start(true).catch(() => {});
245
245
  throw e;
246
246
  }
247
247
  const note = (await deps.usage.isLoggedIn())
@@ -180,7 +180,7 @@ export class ReauthController {
180
180
  }
181
181
  s.phase = "restarting";
182
182
  await this.render(s);
183
- await this.grok.start();
183
+ await this.grok.start(true);
184
184
  agentDown = false;
185
185
  s.accountLabel = accountLabel(await this.getAccount?.().catch(() => undefined));
186
186
  s.phase = "done";
@@ -191,7 +191,7 @@ export class ReauthController {
191
191
  } finally {
192
192
  s.abort = undefined;
193
193
  this.stopAnim(s);
194
- if (agentDown) await this.grok.start().catch((e) => log.warn("post-reauth restart failed:", (e as Error).message));
194
+ if (agentDown) await this.grok.start(true).catch((e) => log.warn("post-reauth restart failed:", (e as Error).message));
195
195
  await this.render(s);
196
196
  }
197
197
  }
@@ -10,6 +10,7 @@ import {
10
10
  type GrokClient,
11
11
  isAccountRotationError,
12
12
  isContextExhaustedError,
13
+ isSessionLifecycleError,
13
14
  isTransientError,
14
15
  type SessionMetadata,
15
16
  } from "../grok/client.js";
@@ -256,6 +257,7 @@ export class SessionRuntime {
256
257
  // в”Ђв”Ђ sessions в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
257
258
 
258
259
  async startNewSession(cwd: string, projectName?: string): Promise<void> {
260
+ await this.accountRotator?.waitForIdle();
259
261
  if (this.busy) await this.cancel();
260
262
  await this.bindNewSession(cwd, projectName);
261
263
  }
@@ -430,6 +432,9 @@ export class SessionRuntime {
430
432
  }
431
433
 
432
434
  private async ensureSession(): Promise<void> {
435
+ // Account rotation restarts the process globally. Do not bind a new chat
436
+ // to a candidate account until the owner has finished probing it.
437
+ await this.accountRotator?.waitForIdle();
433
438
  if (this.rebindPending && this.sessionId) {
434
439
  // The ACP process is frequently mid-restart the first time we re-bind
435
440
  // (auto-restart after a crash, or a fresh bot boot), so a single attempt
@@ -525,8 +530,10 @@ export class SessionRuntime {
525
530
 
526
531
  try {
527
532
  const outcome = await this.runPromptWithRetries(content);
528
- const recovered = await this.maybeAutoFork(input, outcome);
529
- let final = recovered ?? outcome;
533
+ const rebound = await this.maybeRecoverAgentSession(input, outcome);
534
+ let final = rebound ?? outcome;
535
+ const recovered = await this.maybeAutoFork(input, final);
536
+ final = recovered ?? final;
530
537
  // Last resort: if the turn still failed, rotate through other saved
531
538
  // accounts (once) and retry on each until one works.
532
539
  const rotated = await this.maybeRotateAccount(input, final);
@@ -629,6 +636,51 @@ export class SessionRuntime {
629
636
  return pct !== undefined && pct >= threshold;
630
637
  }
631
638
 
639
+ /** A shared-process restart invalidates this runtime's ACP session binding,
640
+ * but says nothing about account health. Wait for any account probe to
641
+ * settle, re-bind/fork this chat on the selected account, and retry once. */
642
+ private async maybeRecoverAgentSession(
643
+ input: PromptInput,
644
+ outcome: { result?: PromptResult; error?: Error; attempts: number },
645
+ ): Promise<{ result?: PromptResult; error?: Error; attempts: number } | undefined> {
646
+ if (
647
+ !outcome.error ||
648
+ !isSessionLifecycleError(outcome.error) ||
649
+ this.cancelled ||
650
+ (this.streamer?.hasOutput ?? false)
651
+ ) {
652
+ return undefined;
653
+ }
654
+ try {
655
+ await this.accountRotator?.waitForIdle();
656
+ if (this.cancelled) return outcome;
657
+ const previousId = this.sessionId;
658
+ this.sessionLive = false;
659
+ this.rebindPending = Boolean(previousId);
660
+ await this.ensureSession();
661
+ this.shownToolIds = new Set();
662
+ this.subagentShown = new Map();
663
+ this.streamer?.setFooter(this.hashtags());
664
+ const retryContent = buildContentBlocks(input, {
665
+ reasoning: reasoningDirective(this.reasoning),
666
+ priming: this.primingContext,
667
+ imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
668
+ progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
669
+ });
670
+ this.primingContext = undefined;
671
+ log.info(
672
+ `chat ${this.chatId} recovered lifecycle error on the active account` +
673
+ (previousId && this.sessionId !== previousId
674
+ ? ` with fresh session ${this.sessionId?.slice(0, 8)}`
675
+ : " by re-binding its session"),
676
+ );
677
+ return this.runPromptWithRetries(retryContent);
678
+ } catch (error) {
679
+ log.warn(`chat ${this.chatId} session recovery failed: ${(error as Error).message}`);
680
+ return { error: error as Error, attempts: outcome.attempts };
681
+ }
682
+ }
683
+
632
684
  /**
633
685
  * Auto-fork-on-error recovery. When a turn fails with a *transient* error (or
634
686
  * a context-exhaustion error) and nothing was streamed to the user, the
@@ -700,25 +752,57 @@ export class SessionRuntime {
700
752
  ): Promise<{ result?: PromptResult; error?: Error; attempts: number } | undefined> {
701
753
  const rotator = this.accountRotator;
702
754
  if (!rotator?.enabled() || !final.error || this.cancelled) return undefined;
755
+ if (isSessionLifecycleError(final.error)) return undefined;
756
+ const originalError = final.error;
757
+ const observed = rotator.state();
758
+ return rotator.withRotationLock(observed, async (changed) => {
759
+ if (this.cancelled) return final;
760
+ if (changed) {
761
+ if (this.streamer?.hasOutput ?? false) return undefined;
762
+ const current = rotator.state();
763
+ const transcript = this.sessionId ? recentTranscript(this.cfg.sessionsDir, this.sessionId) : undefined;
764
+ if (this.foreground) {
765
+ await this.notify(
766
+ `\u{1F504} Reusing ${current.activeLabel ?? "the account selected by another chat"} with a fresh session…`,
767
+ { replyTo: this.turnReplyTo },
768
+ );
769
+ }
770
+ log.info(`chat ${this.chatId} reusing account generation ${current.generation} selected by another chat`);
771
+ try {
772
+ await this.bindNewSession(this.cwd, this.projectName);
773
+ } catch (error) {
774
+ return { error: error as Error, attempts: final.attempts };
775
+ }
776
+ this.shownToolIds = new Set();
777
+ this.subagentShown = new Map();
778
+ this.streamer?.setFooter(this.hashtags());
779
+ const content = buildContentBlocks(input, {
780
+ reasoning: reasoningDirective(this.reasoning),
781
+ priming: transcript ? buildPriming(transcript) : undefined,
782
+ imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
783
+ progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
784
+ });
785
+ return this.runPromptWithRetries(content);
786
+ }
703
787
  // A quota-exhausted or access-denied response cannot be recovered by retrying
704
788
  // this login. Quarantine it before choosing targets, so later rotations do not
705
789
  // cycle back to a known-bad account. This intentionally happens before the
706
790
  // partial-stream guard: we must not retry/rotate a partial reply, but its
707
791
  // account still needs to be skipped during a future rotation.
708
- if (isAccountRotationError(final.error)) {
709
- await rotator.markFailed(undefined, final.error.message);
792
+ if (isAccountRotationError(originalError)) {
793
+ await rotator.markFailed(observed.activeId, originalError.message);
710
794
  }
711
795
  if (this.streamer?.hasOutput ?? false) return undefined;
712
796
  const targets = await rotator.targets().catch(() => [] as { id: string; label: string }[]);
713
797
  if (targets.length === 0) return undefined;
714
798
 
715
799
  const transcript = this.sessionId ? recentTranscript(this.cfg.sessionsDir, this.sessionId) : undefined;
716
- const errors: string[] = [`\u2022 previous: ${final.error.message}`];
800
+ const errors: string[] = [`\u2022 previous: ${originalError.message}`];
717
801
  let last = final;
718
802
 
719
803
  for (const t of targets) {
720
804
  if (this.cancelled) return last;
721
- const failReason = last.error ?? final.error;
805
+ const failReason = last.error ?? originalError;
722
806
  if (this.foreground) {
723
807
  await this.notify(formatAccountSwitchNotice(t.label, failReason), { replyTo: this.turnReplyTo });
724
808
  }
@@ -767,6 +851,7 @@ export class SessionRuntime {
767
851
  // One full cycle done and still failing — stop with a combined report.
768
852
  const combined = new Error(`Tried ${targets.length + 1} account(s), all failed:\n${errors.join("\n")}`);
769
853
  return { error: combined, attempts: last.attempts };
854
+ });
770
855
  }
771
856
 
772
857
  /**
@@ -59,6 +59,11 @@ const ACCOUNT_EXHAUSTED_RE =
59
59
  * saved login may be permitted, while same-account retries cannot help. */
60
60
  const ACCOUNT_ACCESS_DENIED_RE =
61
61
  /\b403\b|forbidden|access denied/i;
62
+ /** Process/session lifecycle failures are not evidence that the active login
63
+ * is bad. They require a session re-bind on the current process generation,
64
+ * never account rotation. */
65
+ const SESSION_LIFECYCLE_RE =
66
+ /unknown session id|grok agent is restarting|grok agent stdio exited|grok agent (?:is )?not running|agent connection (?:is )?closed|authentication required.{0,80}no auth method id provided/i;
62
67
 
63
68
  export class GrokError extends Error {
64
69
  constructor(
@@ -123,7 +128,14 @@ export function isAccountRotationError(err: Error): boolean {
123
128
  return false;
124
129
  }
125
130
 
131
+ export function isSessionLifecycleError(err: Error): boolean {
132
+ return SESSION_LIFECYCLE_RE.test(err.message);
133
+ }
134
+
126
135
  export function isTransientError(err: Error): boolean {
136
+ // Retrying the same stale session cannot recover a process-generation
137
+ // mismatch. SessionRuntime owns the immediate re-bind + one safe retry.
138
+ if (isSessionLifecycleError(err)) return false;
127
139
  // Quota exhaustion and access denial are permanent for this login — rotate,
128
140
  // never back off and retry the same credentials.
129
141
  if (isAccountRotationError(err)) return false;
@@ -265,9 +277,10 @@ export class GrokClient extends EventEmitter {
265
277
  this.availableModels = KNOWN_MODELS.map((m) => ({ modelId: m.modelId, name: m.name, description: m.description }));
266
278
  }
267
279
 
268
- async start(): Promise<void> {
280
+ async start(notifyRestarted = false): Promise<void> {
269
281
  this.stopped = false;
270
282
  await this.connect();
283
+ if (notifyRestarted) this.emit("restarted");
271
284
  }
272
285
 
273
286
  private async connect(): Promise<void> {
@@ -529,9 +542,7 @@ export class GrokClient extends EventEmitter {
529
542
  this.stopped = true;
530
543
  this.restartAttempts = 0;
531
544
  await this.killCurrent();
532
- this.stopped = false;
533
- await this.connect();
534
- this.emit("restarted");
545
+ await this.start(true);
535
546
  }
536
547
 
537
548
  private killCurrent(): Promise<void> {