grok-telegram-bot 2.2.2 โ†’ 2.2.4

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
@@ -7,7 +7,42 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
  The latest section is published verbatim as the GitHub Release notes by
8
8
  `.github/workflows/release.yml` when a `vX.Y.Z` tag is pushed.
9
9
 
10
- ## [Unreleased]
10
+ ## [Unreleased]
11
+
12
+ ## [2.2.4] - 2026-07-16
13
+
14
+ ### Fixed
15
+
16
+ - **๐Ÿšซ Rotate immediately on Grok access denial.** API `403 Forbidden` / `Access
17
+ denied` responses now bypass same-account retry backoff, mark the failed
18
+ login with `โš ๏ธ`, and rotate to the next eligible saved account. If the active
19
+ host login was not already saved (for example after an external sign-in or
20
+ token refresh), it is captured first so the warning is visible and persists.
21
+ - **๐Ÿ” Isolated Grok Telegram identity.** The Grok bot now takes its Telegram
22
+ token from its own instance `.env` before considering an inherited process
23
+ environment value. This prevents a machine-wide token for a sibling bot from
24
+ making Grok poll as Codex/Kiro/OpenCode and causing Telegram conflicts.
25
+ - **โš ๏ธ Persistent account warnings.** Account access/quota failures are shown
26
+ in `/accounts`, excluded from later automatic rotations, and can be manually
27
+ re-enabled after the account is repaired.
28
+ - **๐Ÿ›Ÿ No false success for silent ACP turns.** A Grok ACP completion with no
29
+ text, thought, or tool update is now treated as a recoverable agent failure
30
+ instead of reporting `Done ยท no text output`.
31
+
32
+ ## [2.2.3] - 2026-07-13
33
+
34
+ ### Fixed
35
+
36
+ - **๐Ÿ’ณ Instant account rotate on 402 balance exhausted.** Grok Build
37
+ `Payment Required` / `usage balance exhausted` errors (usually wrapped as ACP
38
+ Internal error `[-32603]`) are no longer treated as transient. Same-account
39
+ backoff retries are skipped. With **auto-rotate ON**, the bot immediately:
40
+ stops the Grok CLI โ†’ swaps `~/.grok/auth.json` โ†’ restarts the CLI with
41
+ headless `cached_token` auth โ†’ opens a fresh session โ†’ retries the same
42
+ prompt. If that account fails, it switches to the next saved account (one
43
+ pass). If all accounts fail (or auto-rotate is OFF), the turn stops with a
44
+ clear error. Telegram shows
45
+ `Account switched to โ€ฆ because of: โ€ฆ` plus a CLI-restart notice.
11
46
 
12
47
  ## [2.2.2] - 2026-07-12
13
48
 
package/README.md CHANGED
@@ -40,7 +40,7 @@ re-architected for the Grok Build CLI and extended into a full multi-session cli
40
40
  | ๐Ÿ“ˆ **Task progress bar** | The agent appends a `{progress: N%}` marker; the bot hides it and shows a **green 0โ€“100% loading bar** on the live message, in the status panel, and on session cards (`SHOW_PROGRESS`). |
41
41
  | ๐Ÿ” **Sign in from chat** | `/reauth` signs you in without a terminal โ€” **๐Ÿ”‘ Sign in** runs headless `grok login --device-auth` (link/code streams to your chat, no host browser), or **๐Ÿ“ฅ Import** an existing on-host login; the agent restarts under the new identity. |
42
42
  | ๐Ÿ‘ฅ **Multiple accounts** | `/accounts` saves several Grok **sign-ins** (custom names) and switches between them in a tap โ€” **stops the agent โ†’ replaces `~/.grok/auth.json` โ†’ restarts headlessly** (never opens a browser). |
43
- | ๐Ÿ” **Auto-rotate on give-up** | When a turn exhausts its retries, optionally cycle through your other saved accounts once and retry on each โ€” the first that works wins (toggle in `/accounts`). Same headless auth.json swap. |
43
+ | ๐Ÿ” **Auto-rotate on give-up** | When a turn exhausts its retries (or hits **402 balance exhausted** with no same-account retry), optionally cycle through your other saved accounts once: stop CLI โ†’ swap `auth.json` โ†’ restart + re-auth โ†’ retry. First that works wins (toggle in `/accounts`). |
44
44
  | โœ… **Auto-approve tools** | By default, ACP permission prompts are auto-approved for the **session** (`AUTO_APPROVE_PERMISSIONS`). Turn both that and `GROK_TRUST_ALL_TOOLS` off for interactive Approve/Deny โ€” those prompts are **pinned** until you act. |
45
45
  | ๐Ÿช™ **Credits & usage** | The `โœ… Done` line and `/usage` show credits used (when Grok reports them), turns this session, and account info. |
46
46
  | โŒจ๏ธ **Typing indicator** | Stays on for the whole turn, even through long tool chains. |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "grok-telegram-bot",
3
- "version": "2.2.2",
3
+ "version": "2.2.4",
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",
@@ -32,6 +32,11 @@ export interface StoredAccount {
32
32
  startUrl?: string;
33
33
  accountType?: string;
34
34
  region?: string;
35
+ /** Excluded from automatic rotation after an account-specific quota/billing failure. */
36
+ warning?: {
37
+ reason: string;
38
+ markedAt: string;
39
+ };
35
40
  }
36
41
 
37
42
  interface AccountsData {
@@ -171,6 +176,9 @@ export class AccountManager {
171
176
  email: (email && email.includes("@") ? email : undefined) || existing?.email,
172
177
  startUrl: (email && email.includes("@") ? email : undefined) || existing?.email || existing?.startUrl,
173
178
  savedAt: new Date().toISOString(),
179
+ // Automatic pre-rotation snapshots must not silently re-enable an
180
+ // account that was quarantined after a quota/billing failure.
181
+ warning: existing?.warning,
174
182
  };
175
183
  this.store.update((d) => {
176
184
  const idx = d.accounts.findIndex((a) => a.id === id);
@@ -227,6 +235,33 @@ export class AccountManager {
227
235
  return updated;
228
236
  }
229
237
 
238
+ /** Mark an account as unsuitable for future automatic rotations. */
239
+ markWarning(id: string, reason: string): StoredAccount | undefined {
240
+ let updated: StoredAccount | undefined;
241
+ this.store.update((d) => {
242
+ const account = d.accounts.find((a) => a.id === id);
243
+ if (account) {
244
+ account.warning = { reason, markedAt: new Date().toISOString() };
245
+ updated = account;
246
+ }
247
+ });
248
+ if (updated) log.warn(`marked account ${updated.label} with rotation warning: ${reason}`);
249
+ return updated;
250
+ }
251
+
252
+ /** Re-allow a manually restored account to participate in auto-rotation. */
253
+ clearWarning(id: string): StoredAccount | undefined {
254
+ let updated: StoredAccount | undefined;
255
+ this.store.update((d) => {
256
+ const account = d.accounts.find((a) => a.id === id);
257
+ if (account?.warning) {
258
+ delete account.warning;
259
+ updated = account;
260
+ }
261
+ });
262
+ return updated;
263
+ }
264
+
230
265
  async forget(id: string): Promise<boolean> {
231
266
  const existed = !!this.get(id);
232
267
  await rm(this.snapshotPath(id), { force: true }).catch(() => {});
@@ -1,6 +1,7 @@
1
1
  /**
2
- * Auto-rotate-on-give-up. When a turn exhausts its retries (and auto-fork can't
3
- * recover it), the runtime can cycle through the OTHER saved Grok accounts,
2
+ * Auto-rotate-on-give-up. When a turn exhausts its retries (or fails immediately
3
+ * with a permanent billing error like HTTP 402 balance exhausted) and auto-fork
4
+ * can't recover it, the runtime can cycle through the OTHER saved Grok accounts,
4
5
  * retrying the same prompt on each โ€” useful when the active account is
5
6
  * throttled, out of quota, or its backend keeps returning "dispatch failure".
6
7
  *
@@ -12,8 +13,11 @@
12
13
  * a rotation restarts the shared agent and affects every chat โ€” intended, since
13
14
  * the whole point is to move everyone onto a working login.
14
15
  *
15
- * CRITICAL: switch = stop agent โ†’ replace ~/.grok/auth.json โ†’ start agent with
16
- * headless `cached_token` auth. Never opens a browser / never runs `grok login`.
16
+ * CRITICAL: every activate() MUST fully restart the CLI so the new auth applies:
17
+ * 1. stop agent (`stopAndWait`) โ€” process must exit so it cannot rewrite auth,
18
+ * 2. replace ~/.grok/auth.json with the saved snapshot,
19
+ * 3. start agent + `authenticate({ methodId: "cached_token" })` headlessly.
20
+ * Never opens a browser / never runs `grok login`.
17
21
  */
18
22
  import type { GrokClient } from "../grok/client.js";
19
23
  import type { AccountManager } from "../app/accounts.js";
@@ -33,6 +37,8 @@ export interface AccountRotator {
33
37
  targets(): Promise<RotationTarget[]>;
34
38
  /** Make a saved account active (swap auth.json + re-bind). Throws on error. */
35
39
  activate(id: string): Promise<void>;
40
+ /** Quarantine an account after an account-specific failure. Undefined means active. */
41
+ markFailed(id: string | undefined, reason: string): Promise<void>;
36
42
  }
37
43
 
38
44
  export class AccountRotatorImpl implements AccountRotator {
@@ -48,14 +54,34 @@ export class AccountRotatorImpl implements AccountRotator {
48
54
  async targets(): Promise<RotationTarget[]> {
49
55
  const list = this.accounts.list();
50
56
  const activeId = this.accounts.activeAccountId();
51
- return list.filter((a) => a.id !== activeId).map((a) => ({ id: a.id, label: a.label }));
57
+ return list
58
+ .filter((a) => a.id !== activeId && !a.warning)
59
+ .map((a) => ({ id: a.id, label: a.label }));
60
+ }
61
+
62
+ async markFailed(id: string | undefined, reason: string): Promise<void> {
63
+ let accountId = id ?? this.accounts.activeAccountId();
64
+ if (!accountId) {
65
+ // The host may have been signed in or had its token refreshed outside the
66
+ // bot, so no saved snapshot matches yet. Capture it before marking; this
67
+ // guarantees the actual failed login appears with a warning in /accounts
68
+ // and is excluded from the target list below.
69
+ try {
70
+ accountId = (await this.accounts.captureCurrent()).id;
71
+ } catch (error) {
72
+ log.warn("could not capture active account for rotation warning:", (error as Error).message);
73
+ return;
74
+ }
75
+ }
76
+ this.accounts.markWarning(accountId, reason);
52
77
  }
53
78
 
54
79
  /**
55
- * Pure file-based account swap:
56
- * 1. Stop the shared agent (so it cannot rewrite auth.json),
80
+ * Pure file-based account swap with a full CLI restart so the new token is
81
+ * loaded (agent is process-local with `--no-leader`):
82
+ * 1. Stop the shared agent and wait for exit (so it cannot rewrite auth.json),
57
83
  * 2. Copy the saved snapshot over ~/.grok/auth.json,
58
- * 3. Start the agent and authenticate with `cached_token` (headless).
84
+ * 3. Start a fresh agent process and authenticate with `cached_token`.
59
85
  * Never launches a browser.
60
86
  */
61
87
  async activate(id: string): Promise<void> {
@@ -63,12 +89,15 @@ export class AccountRotatorImpl implements AccountRotator {
63
89
  await this.accounts.captureCurrent().catch((e) => {
64
90
  log.warn("pre-rotate capture failed (continuing):", (e as Error).message);
65
91
  });
66
- log.info(`rotating: stopping agent before auth.json swap (${id})`);
92
+ log.info(`rotating: stopping Grok CLI before auth.json swap (${id})`);
67
93
  await this.acp.stopAndWait();
68
94
  try {
69
95
  const meta = await this.accounts.switchTo(id);
70
- log.info(`rotating: auth.json now ${meta.label}; starting agent`);
96
+ log.info(`rotating: auth.json now ${meta.label}; starting Grok CLI + re-auth`);
97
+ // start() โ†’ connect() โ†’ initialize + authenticate(cached_token) against
98
+ // the freshly written auth.json. A live process would keep the old token.
71
99
  await this.acp.start();
100
+ log.info(`rotating: Grok CLI up on ${meta.label}`);
72
101
  } catch (e) {
73
102
  // Best-effort recover the agent so the bot stays usable even if the
74
103
  // target login was bad.
@@ -17,7 +17,7 @@ import type { BotDeps } from "../deps.js";
17
17
  const log = createLogger("accounts");
18
18
 
19
19
  function accountLine(a: StoredAccount, active: boolean): string {
20
- const mark = active ? "\u2705 " : "\u{1F464} ";
20
+ const mark = a.warning ? "\u26A0\uFE0F " : active ? "\u2705 " : "\u{1F464} ";
21
21
  return `${mark}${a.label}`;
22
22
  }
23
23
 
@@ -51,13 +51,18 @@ async function view(deps: BotDeps, note?: string): Promise<{ text: string; keybo
51
51
  if (list.length === 0) {
52
52
  lines.push("No saved accounts yet.", "", "Save the current login below, or sign in via /reauth.");
53
53
  } else {
54
- for (const a of list) lines.push(accountLine(a, a.id === active));
54
+ for (const a of list) {
55
+ lines.push(accountLine(a, a.id === active));
56
+ if (a.warning) lines.push(" \u2514 Skipped by auto-rotate after an account access or quota error.");
57
+ }
55
58
  }
56
59
  const rotate = deps.accounts.autoRotateEnabled();
57
60
  lines.push(
58
61
  "",
59
62
  `\u{1F501} Auto-rotate on errors: ${rotate ? "ON" : "OFF"}`,
60
- rotate ? " \u2514 If a turn gives up, it cycles through the other accounts once." : " \u2514 Turns stay on the active account.",
63
+ rotate
64
+ ? " \u2514 On account access/quota errors: stop CLI, swap auth, restart, retry each eligible account once. \u26A0\uFE0F accounts are skipped."
65
+ : " \u2514 Turns stay on the active account (account access/quota errors stop immediately).",
61
66
  );
62
67
  if (note) lines.push("", note);
63
68
 
@@ -68,6 +73,7 @@ async function view(deps: BotDeps, note?: string): Promise<{ text: string; keybo
68
73
  .text("\u270F\uFE0F", `acct:rename:${a.id}`)
69
74
  .text("\u{1F5D1}", `acct:del:${a.id}`)
70
75
  .row();
76
+ if (a.warning) kb.text(`\u26A0\uFE0F Re-enable ${trim(a.label)}`, `acct:clearwarning:${a.id}`).row();
71
77
  }
72
78
  kb.text("\u{1F4BE} Save current login", "acct:save").text("\u270F\uFE0F Save as\u2026", "acct:saveas").row();
73
79
  kb.text("\u{1F4E5} Import existing", "acct:import").text("\u{1F511} Sign in\u2026", "acct:login").row();
@@ -159,6 +165,13 @@ export function registerAccounts(bot: Bot, deps: BotDeps): void {
159
165
  await promptName(ctx, "rename", ctx.match![1]!);
160
166
  });
161
167
 
168
+ bot.callbackQuery(/^acct:clearwarning:(.+)$/, async (ctx) => {
169
+ const id = ctx.match![1]!;
170
+ const account = deps.accounts.clearWarning(id);
171
+ await ctx.answerCallbackQuery({ text: account ? "Account re-enabled for auto-rotate" : "No warning to clear" });
172
+ await rerender(ctx, deps, account ? `\u2705 ${account.label} can be used by auto-rotate again.` : undefined);
173
+ });
174
+
162
175
  bot.callbackQuery("acct:close", async (ctx) => {
163
176
  await ctx.answerCallbackQuery();
164
177
  await deps.ephemeral.drop(ctx);
@@ -68,3 +68,30 @@ export function formatErrorSummary(error: Error, elapsed: string, attempts: numb
68
68
  }
69
69
  return `\u274C Gave up after ${attempts} attempts over ${elapsed}.\nLast error: ${error.message}${tip}`;
70
70
  }
71
+
72
+ /**
73
+ * Compact, human-facing reason for an account switch (Telegram status line).
74
+ * Prefers a short billing/quota phrase when present; otherwise truncates.
75
+ */
76
+ export function shortSwitchReason(error: Error, max = 140): string {
77
+ const raw = error.message.replace(/\s+/g, " ").trim();
78
+ const known =
79
+ raw.match(/Grok Build usage balance exhausted/i)?.[0] ??
80
+ raw.match(/usage balance exhausted/i)?.[0] ??
81
+ raw.match(/balance exhausted/i)?.[0] ??
82
+ raw.match(/Payment Required/i)?.[0] ??
83
+ raw.match(/quota exceeded/i)?.[0] ??
84
+ raw.match(/out of (?:credits|quota|balance)/i)?.[0];
85
+ const text = known ?? raw;
86
+ return text.length > max ? `${text.slice(0, max - 1)}\u2026` : text;
87
+ }
88
+
89
+ /** Shown when auto-rotate swaps login mid-turn and retries on the new account. */
90
+ export function formatAccountSwitchNotice(label: string, error: Error): string {
91
+ return [
92
+ `\u{1F504} Account switched to ${label}`,
93
+ `because of: ${shortSwitchReason(error)}`,
94
+ "",
95
+ "Restarting Grok CLI with the new login and retrying\u2026",
96
+ ].join("\n");
97
+ }
@@ -6,7 +6,13 @@
6
6
  */
7
7
  import { basename } from "node:path";
8
8
  import { type Api, InlineKeyboard } from "grammy";
9
- import { type GrokClient, isContextExhaustedError, isTransientError, type SessionMetadata } from "../grok/client.js";
9
+ import {
10
+ type GrokClient,
11
+ isAccountRotationError,
12
+ isContextExhaustedError,
13
+ isTransientError,
14
+ type SessionMetadata,
15
+ } from "../grok/client.js";
10
16
  import type { AccountRotator } from "./account-rotator.js";
11
17
  import type { ContentBlock, PromptResult, SessionUpdate } from "../grok/types.js";
12
18
  import type { AppConfig } from "../config.js";
@@ -27,7 +33,14 @@ import type { PendingStage, SubagentInfo } from "../grok/types.js";
27
33
  import { ResponseStreamer } from "../stream/streamer.js";
28
34
  import { extractImagePaths, sendImages } from "./image-return.js";
29
35
  import { buildContentBlocks, mergeInputs } from "./prompt-content.js";
30
- import { backoffSchedule, fmtSeconds, formatErrorSummary, formatRetryNotice, RETRY_BASE_MS } from "./prompt-retry.js";
36
+ import {
37
+ backoffSchedule,
38
+ fmtSeconds,
39
+ formatAccountSwitchNotice,
40
+ formatErrorSummary,
41
+ formatRetryNotice,
42
+ RETRY_BASE_MS,
43
+ } from "./prompt-retry.js";
31
44
  import { sendMarkdownDoc } from "./telegram-io.js";
32
45
  import { TypingIndicator } from "./typing.js";
33
46
 
@@ -86,8 +99,10 @@ export class SessionRuntime {
86
99
  private turnCount = 0;
87
100
  /** Telegram message id of the current turn's prompt, so replies thread to it. */
88
101
  private turnReplyTo: number | undefined;
89
- private imageScanText = "";
90
- private sentImagesThisTurn = new Set<string>();
102
+ private imageScanText = "";
103
+ private sentImagesThisTurn = new Set<string>();
104
+ /** Monotonic count used to reject ACP "success" responses with no turn updates. */
105
+ private sessionUpdateCount = 0;
91
106
  private readonly listener: (sessionId: string, update: SessionUpdate) => void;
92
107
  private primingContext: string | undefined;
93
108
  private watcher: TailWatcher | undefined;
@@ -661,22 +676,34 @@ export class SessionRuntime {
661
676
  }
662
677
 
663
678
  /**
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
679
+ * Auto-rotate-on-give-up. When a turn has failed (retries exhausted / billing
680
+ * 402 with no same-account retry, auto-fork didn't recover) and nothing was
681
+ * streamed, cycle through the OTHER saved accounts once โ€” each step:
682
+ * stop Grok CLI โ†’ replace ~/.grok/auth.json โ†’ start CLI + headless auth โ†’
683
+ * open a fresh session โ†’ retry the same prompt.
684
+ * The first account that succeeds wins and stays active; if every account
685
+ * fails we stop with a combined error. Bounded to ONE pass (no infinite
670
686
  * loop). No-op unless the rotator is enabled and other accounts exist.
687
+ *
688
+ * Billing/quota failures (402 balance exhausted) skip backoff retries on
689
+ * each account and rotate instantly so the turn continues without a long wait.
671
690
  */
672
691
  private async maybeRotateAccount(
673
692
  input: PromptInput,
674
693
  final: { result?: PromptResult; error?: Error; attempts: number },
675
694
  ): 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 }[]);
695
+ const rotator = this.accountRotator;
696
+ if (!rotator?.enabled() || !final.error || this.cancelled) return undefined;
697
+ // A quota-exhausted or access-denied response cannot be recovered by retrying
698
+ // this login. Quarantine it before choosing targets, so later rotations do not
699
+ // cycle back to a known-bad account. This intentionally happens before the
700
+ // partial-stream guard: we must not retry/rotate a partial reply, but its
701
+ // account still needs to be skipped during a future rotation.
702
+ if (isAccountRotationError(final.error)) {
703
+ await rotator.markFailed(undefined, final.error.message);
704
+ }
705
+ if (this.streamer?.hasOutput ?? false) return undefined;
706
+ const targets = await rotator.targets().catch(() => [] as { id: string; label: string }[]);
680
707
  if (targets.length === 0) return undefined;
681
708
 
682
709
  const transcript = this.sessionId ? recentTranscript(this.cfg.sessionsDir, this.sessionId) : undefined;
@@ -685,11 +712,13 @@ export class SessionRuntime {
685
712
 
686
713
  for (const t of targets) {
687
714
  if (this.cancelled) return last;
715
+ const failReason = last.error ?? final.error;
688
716
  if (this.foreground) {
689
- await this.notify(`\u{1F501} Auto-rotating accounts \u2014 trying ${t.label}\u2026`, { replyTo: this.turnReplyTo });
717
+ await this.notify(formatAccountSwitchNotice(t.label, failReason), { replyTo: this.turnReplyTo });
690
718
  }
691
719
  try {
692
- await rotator.activate(t.id); // switch login + restart the shared agent
720
+ // stop CLI โ†’ swap auth.json โ†’ start CLI + authenticate(cached_token)
721
+ await rotator.activate(t.id);
693
722
  } catch (e) {
694
723
  errors.push(`\u2022 ${t.label}: couldn't switch \u2014 ${(e as Error).message}`);
695
724
  continue;
@@ -709,17 +738,26 @@ export class SessionRuntime {
709
738
  priming: transcript ? buildPriming(transcript) : undefined,
710
739
  progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
711
740
  });
712
- log.info(`chat ${this.chatId} auto-rotating to account ${t.label}`);
741
+ log.info(
742
+ `chat ${this.chatId} auto-rotating to account ${t.label}` +
743
+ (isAccountRotationError(failReason) ? " (previous account unavailable)" : ""),
744
+ );
745
+ // runPromptWithRetries already skips backoff for 402 / balance exhausted.
713
746
  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 });
747
+ if (last.result && !this.cancelled) {
748
+ if (this.foreground) {
749
+ await this.notify(`\u2705 Recovered on ${t.label}.`, { replyTo: this.turnReplyTo });
750
+ }
716
751
  return last;
717
- }
718
- if (this.cancelled || (this.streamer?.hasOutput ?? false)) return last;
719
- errors.push(`\u2022 ${t.label}: ${last.error?.message ?? "failed"}`);
752
+ }
753
+ if (last.error && isAccountRotationError(last.error)) {
754
+ await rotator.markFailed(t.id, last.error.message);
755
+ }
756
+ if (this.cancelled || (this.streamer?.hasOutput ?? false)) return last;
757
+ errors.push(`\u2022 ${t.label}: ${last.error?.message ?? "failed"}`);
720
758
  }
721
759
 
722
- // One full cycle done and still failing ะฒะ‚โ€ stop with a combined report.
760
+ // One full cycle done and still failing โ€” stop with a combined report.
723
761
  const combined = new Error(`Tried ${targets.length + 1} account(s), all failed:\n${errors.join("\n")}`);
724
762
  return { error: combined, attempts: last.attempts };
725
763
  }
@@ -738,22 +776,34 @@ export class SessionRuntime {
738
776
  ): Promise<{ result?: PromptResult; error?: Error; attempts: number }> {
739
777
  const delays = this.cfg.promptRetryAttempts > 0 ? backoffSchedule(this.cfg.promptRetryAttempts) : [];
740
778
  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 };
779
+ let attempt = 0;
780
+ for (;;) {
781
+ attempt++;
782
+ try {
783
+ const updatesBeforePrompt = this.sessionUpdateCount;
784
+ const result = await this.acp.prompt(this.sessionId!, content);
785
+ // A healthy ACP turn emits at least one session/update (text, thought,
786
+ // or tool event) before resolving session/prompt. Grok can otherwise
787
+ // report a successful end-turn after an upstream model failure; never
788
+ // present that as a completed user request.
789
+ await sleep(0);
790
+ if (this.sessionUpdateCount === updatesBeforePrompt) {
791
+ throw new Error("Empty agent response โ€” Grok ended the turn without any output or tool activity");
792
+ }
793
+ return { result, attempts: attempt };
747
794
  } catch (err) {
748
795
  const error = err as Error;
749
796
  const canRecover = !this.cancelled && !(this.streamer?.hasOutput ?? false);
750
797
  // A context-exhausted session won't recover by retrying the same
751
- // oversized prompt ะฒะ‚โ€ skip the backoff and let auto-fork compact it now.
798
+ // oversized prompt โ€” skip the backoff and let auto-fork compact it now.
752
799
  const forkInstead = canRecover && this.cfg.autoForkOnError && this.isContextRelatedFailure(error);
800
+ // Billing/quota 402 (balance exhausted) is permanent for this login โ€”
801
+ // never backoff-retry; surface immediately so auto-rotate can switch.
753
802
  const willRetry =
754
803
  attempt <= delays.length &&
755
804
  canRecover &&
756
805
  !forkInstead &&
806
+ !isAccountRotationError(error) &&
757
807
  isTransientError(error);
758
808
  if (!willRetry) return { error, attempts: attempt };
759
809
  const waitMs = delays[attempt - 1]!;
@@ -930,9 +980,10 @@ export class SessionRuntime {
930
980
  void this.runTurn(batch);
931
981
  }
932
982
 
933
- private onUpdate(sessionId: string, update: SessionUpdate): void {
934
- if (!this.busy || sessionId !== this.sessionId) return;
935
- const kind = update.sessionUpdate;
983
+ private onUpdate(sessionId: string, update: SessionUpdate): void {
984
+ if (!this.busy || sessionId !== this.sessionId) return;
985
+ this.sessionUpdateCount++;
986
+ const kind = update.sessionUpdate;
936
987
 
937
988
  // Accumulate the turn's file-change summary + image-scan text even when this
938
989
  // session is in the background (its output isn't streamed here, but the
package/src/config.ts CHANGED
@@ -44,9 +44,10 @@ function resolveInstanceDir(): string {
44
44
  return CANONICAL_DIR;
45
45
  }
46
46
 
47
- // Load .env from the resolved instance directory. dotenv does NOT override
48
- // variables already present in the environment (the launcher/service env wins).
49
- loadDotenv({ path: ENV_PATH });
47
+ // Load .env from the resolved instance directory. Keep the parsed values as
48
+ // well: a machine-wide TELEGRAM_BOT_TOKEN may belong to a sibling bot (Codex,
49
+ // Kiro, etc.) and must never override this Grok instance's identity.
50
+ const instanceEnv = loadDotenv({ path: ENV_PATH }).parsed ?? {};
50
51
 
51
52
  function expandHome(p: string): string {
52
53
  if (p === "~") return homedir();
@@ -146,7 +147,10 @@ export interface AppConfig {
146
147
  }
147
148
 
148
149
  export function loadConfig(): AppConfig {
149
- const token = (process.env.TELEGRAM_BOT_TOKEN || "").trim();
150
+ // Telegram long polling permits one consumer per token. Prefer the token in
151
+ // this bot's own instance file over a globally inherited environment value,
152
+ // otherwise a Grok process can accidentally poll as a sibling bot.
153
+ const token = (instanceEnv.TELEGRAM_BOT_TOKEN || process.env.TELEGRAM_BOT_TOKEN || "").trim();
150
154
  if (!token) {
151
155
  throw new Error(
152
156
  "TELEGRAM_BOT_TOKEN is missing. Copy .env.example to .env and set it (run `npm run setup`).",
@@ -43,9 +43,21 @@ export interface SessionMetadata {
43
43
 
44
44
  const TRANSIENT_CODES = new Set([-32603, -32500, -32000, 500, 502, 503, 504, 429]);
45
45
  const TRANSIENT_RE =
46
- /internal error|high volume|experiencing|overloaded|temporar|unavailable|rate.?limit|too many requests|try again|capacity|dispatch failure|response stream|connection (?:reset|closed|refused|error)|reset by peer|broken pipe|socket hang ?up|econnreset|econnrefused|enotfound|eai_again|etimedout|\b50[234]\b|\b429\b/i;
46
+ /internal error|high volume|experiencing|overloaded|temporar|unavailable|rate.?limit|too many requests|try again|capacity|dispatch failure|response stream|empty agent response|connection (?:reset|closed|refused|error)|reset by peer|broken pipe|socket hang ?up|econnreset|econnrefused|enotfound|eai_again|etimedout|\b50[234]\b|\b429\b/i;
47
47
  const CONTEXT_EXHAUSTED_RE =
48
48
  /context (?:length|window|limit|size|overflow)|maximum context|input (?:is )?too long|prompt (?:is )?too long|too many (?:input )?tokens|token limit|exceeds? (?:the )?(?:maximum|context|token)|reduce the (?:length|size)|context.{0,24}exhaust/i;
49
+ /**
50
+ * Permanent-for-this-login billing/quota failures (e.g. HTTP 402 "Grok Build
51
+ * usage balance exhausted"). These ride inside ACP Internal error [-32603] but
52
+ * must NOT be backoff-retried on the same account โ€” only account rotation can
53
+ * recover.
54
+ */
55
+ const ACCOUNT_EXHAUSTED_RE =
56
+ /\b402\b|payment required|balance exhausted|usage balance|out of (?:credits|quota|balance)|insufficient (?:credits|balance|quota)|quota exceeded|no (?:remaining )?credits/i;
57
+ /** Account-level authorization failures from the Grok CLI proxy. A different
58
+ * saved login may be permitted, while same-account retries cannot help. */
59
+ const ACCOUNT_ACCESS_DENIED_RE =
60
+ /\b403\b|forbidden|access denied/i;
49
61
 
50
62
  export class GrokError extends Error {
51
63
  constructor(
@@ -58,7 +70,62 @@ export class GrokError extends Error {
58
70
  }
59
71
  }
60
72
 
73
+ /**
74
+ * True when the failure is a billing/quota exhaustion for the active Grok
75
+ * login (HTTP 402, "balance exhausted", etc.). Same-account retries cannot
76
+ * help; the turn should stop (or auto-rotate to another saved account).
77
+ */
78
+ export function isAccountExhaustedError(err: Error): boolean {
79
+ if (ACCOUNT_EXHAUSTED_RE.test(err.message)) return true;
80
+ const data = (err as GrokError).data;
81
+ if (!data || typeof data !== "object") return false;
82
+ const d = data as Record<string, unknown>;
83
+ const status = d.http_status ?? d.status ?? d.statusCode;
84
+ if (status === 402 || status === "402") return true;
85
+ if (typeof d.message === "string" && ACCOUNT_EXHAUSTED_RE.test(d.message)) return true;
86
+ // Nested JSON sometimes lands as a stringified payload inside `data`.
87
+ for (const v of Object.values(d)) {
88
+ if (typeof v === "string" && ACCOUNT_EXHAUSTED_RE.test(v)) return true;
89
+ if (v && typeof v === "object") {
90
+ const nested = v as Record<string, unknown>;
91
+ const ns = nested.http_status ?? nested.status ?? nested.statusCode;
92
+ if (ns === 402 || ns === "402") return true;
93
+ if (typeof nested.message === "string" && ACCOUNT_EXHAUSTED_RE.test(nested.message)) return true;
94
+ }
95
+ }
96
+ return false;
97
+ }
98
+
99
+ /**
100
+ * True when the active saved login cannot serve the request: either its Grok
101
+ * Build quota is exhausted (402), or the proxy rejects it as unauthorized
102
+ * (403 / Forbidden / Access denied). Both must skip same-account backoff and
103
+ * trigger account rotation when enabled.
104
+ */
105
+ export function isAccountRotationError(err: Error): boolean {
106
+ if (isAccountExhaustedError(err) || ACCOUNT_ACCESS_DENIED_RE.test(err.message)) return true;
107
+ const data = (err as GrokError).data;
108
+ if (!data || typeof data !== "object") return false;
109
+ const d = data as Record<string, unknown>;
110
+ const status = d.http_status ?? d.status ?? d.statusCode;
111
+ if (status === 403 || status === "403") return true;
112
+ if (typeof d.message === "string" && ACCOUNT_ACCESS_DENIED_RE.test(d.message)) return true;
113
+ for (const value of Object.values(d)) {
114
+ if (typeof value === "string" && ACCOUNT_ACCESS_DENIED_RE.test(value)) return true;
115
+ if (value && typeof value === "object") {
116
+ const nested = value as Record<string, unknown>;
117
+ const nestedStatus = nested.http_status ?? nested.status ?? nested.statusCode;
118
+ if (nestedStatus === 403 || nestedStatus === "403") return true;
119
+ if (typeof nested.message === "string" && ACCOUNT_ACCESS_DENIED_RE.test(nested.message)) return true;
120
+ }
121
+ }
122
+ return false;
123
+ }
124
+
61
125
  export function isTransientError(err: Error): boolean {
126
+ // Quota exhaustion and access denial are permanent for this login โ€” rotate,
127
+ // never back off and retry the same credentials.
128
+ if (isAccountRotationError(err)) return false;
62
129
  const code = (err as GrokError).code;
63
130
  if (typeof code === "number" && TRANSIENT_CODES.has(code)) return true;
64
131
  return TRANSIENT_RE.test(err.message);
@@ -1,99 +0,0 @@
1
- /**
2
- * Push current working-tree release files to main + create annotated tag.
3
- * (Local git push is restricted in this environment.)
4
- */
5
- import { readFileSync } from "node:fs";
6
- import { spawnSync } from "node:child_process";
7
-
8
- const owner = "artickc";
9
- const repo = "grok-telegram-bot";
10
- const branch = "main";
11
- const version = "2.2.2";
12
- const tag = `v${version}`;
13
- const message = "release: v2.2.2 โ€” account identity, docs, 2.2.x multi-account batch";
14
- const tagMessage =
15
- "v2.2.2 โ€” multi-account headless rotation, session auto-approve, pinned permissions, account identity";
16
-
17
- const files = [
18
- ".gitignore",
19
- "CHANGELOG.md",
20
- "README.md",
21
- "package.json",
22
- "src/app/accounts.ts",
23
- "src/app/grok-credentials.ts",
24
- "src/bot/handlers/accounts.ts",
25
- "test/credentials.test.ts",
26
- ];
27
-
28
- // eslint-disable-next-line no-control-regex
29
- const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
30
-
31
- function ghApi(method, path, body) {
32
- const args = ["api", "-X", method, path];
33
- const opts = {
34
- encoding: "utf8",
35
- maxBuffer: 50 * 1024 * 1024,
36
- shell: false,
37
- env: { ...process.env, NO_COLOR: "1", CLICOLOR: "0", FORCE_COLOR: "0", GH_FORCE_TTY: "0" },
38
- };
39
- if (body !== undefined) {
40
- args.push("--input", "-");
41
- opts.input = JSON.stringify(body);
42
- }
43
- const r = spawnSync("gh", args, opts);
44
- if (r.status !== 0) {
45
- throw new Error(`gh api ${method} ${path} failed (${r.status}): ${r.stderr || r.stdout}`);
46
- }
47
- const text = (r.stdout || "").replace(ANSI_RE, "").trim();
48
- if (!text) return {};
49
- return JSON.parse(text);
50
- }
51
-
52
- const ref = ghApi("GET", `repos/${owner}/${repo}/git/ref/heads/${branch}`);
53
- const baseSha = ref.object.sha;
54
- const baseCommit = ghApi("GET", `repos/${owner}/${repo}/git/commits/${baseSha}`);
55
- const baseTree = baseCommit.tree.sha;
56
- console.log("base", baseSha);
57
-
58
- const tree = [];
59
- for (const path of files) {
60
- const content = readFileSync(path, "utf8");
61
- const blob = ghApi("POST", `repos/${owner}/${repo}/git/blobs`, {
62
- content: Buffer.from(content, "utf8").toString("base64"),
63
- encoding: "base64",
64
- });
65
- tree.push({ path, mode: "100644", type: "blob", sha: blob.sha });
66
- console.log("blob", path, blob.sha.slice(0, 8));
67
- }
68
-
69
- const newTree = ghApi("POST", `repos/${owner}/${repo}/git/trees`, {
70
- base_tree: baseTree,
71
- tree,
72
- });
73
- const commit = ghApi("POST", `repos/${owner}/${repo}/git/commits`, {
74
- message,
75
- tree: newTree.sha,
76
- parents: [baseSha],
77
- });
78
- ghApi("PATCH", `repos/${owner}/${repo}/git/refs/heads/${branch}`, {
79
- sha: commit.sha,
80
- force: false,
81
- });
82
- console.log("main ->", commit.sha);
83
-
84
- const tagObj = ghApi("POST", `repos/${owner}/${repo}/git/tags`, {
85
- tag,
86
- message: tagMessage,
87
- object: commit.sha,
88
- type: "commit",
89
- tagger: {
90
- name: "artickc",
91
- email: "artickc@users.noreply.github.com",
92
- date: new Date().toISOString(),
93
- },
94
- });
95
- ghApi("POST", `repos/${owner}/${repo}/git/refs`, {
96
- ref: `refs/tags/${tag}`,
97
- sha: tagObj.sha,
98
- });
99
- console.log("tag", tag, "->", commit.sha);