grok-telegram-bot 2.0.0 → 2.2.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.
package/.env.example CHANGED
@@ -32,6 +32,11 @@ GROK_WORKSPACE=
32
32
  # Grok runs risky tools (file writes, shell commands) — ACP "ask" mode.
33
33
  GROK_TRUST_ALL_TOOLS=true
34
34
 
35
+ # Auto-approve ACP permission requests, preferring "allow for this session".
36
+ # Defaults true. Set false (and GROK_TRUST_ALL_TOOLS=false) for interactive
37
+ # Approve/Deny buttons in Telegram.
38
+ AUTO_APPROVE_PERMISSIONS=true
39
+
35
40
  # Comma-separated roots the /projects browser is allowed to list.
36
41
  # Supports ~ for home directory. Defaults to GROK_WORKSPACE's parent + home.
37
42
  # Example: H:\Lucru\Domains,C:\Lucru\Domains
package/CHANGELOG.md CHANGED
@@ -9,6 +9,89 @@ The latest section is published verbatim as the GitHub Release notes by
9
9
 
10
10
  ## [Unreleased]
11
11
 
12
+ ## [2.2.0] - 2026-07-12
13
+
14
+ The **reliable multi-account** release — account switch / auto-rotate now only
15
+ swaps `~/.grok/auth.json` and restarts the agent headlessly (never opens a
16
+ browser), and tool permission prompts are auto-approved for the session by
17
+ default.
18
+
19
+ ### Fixed
20
+
21
+ - **🔁 Account switch & auto-rotate no longer open a browser.** Switching or
22
+ auto-rotating accounts now: (1) stops the shared agent, (2) replaces
23
+ `~/.grok/auth.json` with the saved snapshot, (3) starts the agent and
24
+ authenticates with headless `cached_token` only. Browser auth methods such as
25
+ `grok.com` are never selected (they used to hang the host and kill Telegram).
26
+ - **🪪 Explicit `activeId` tracking** so rotation still knows which account is
27
+ live after silent token refreshes (token-hash drift no longer confuses the
28
+ target list).
29
+ - **🛡️ Auth failures after a switch are surfaced** instead of silently running
30
+ an unauthenticated agent.
31
+
32
+ ### Added
33
+
34
+ - **✅ Auto-approve permissions (session scope).** ACP
35
+ `session/request_permission` requests are auto-approved by default, preferring
36
+ “allow for this session” / “always allow” over “allow once”. Configure with
37
+ `AUTO_APPROVE_PERMISSIONS` (default `true`). Interactive Approve/Deny buttons
38
+ only when both `AUTO_APPROVE_PERMISSIONS=false` and `GROK_TRUST_ALL_TOOLS=false`.
39
+ - **`grok login --device-auth` for `/reauth`** — device-code sign-in streamed to
40
+ Telegram instead of opening a browser on the host.
41
+ - **`--no-leader`** on `grok agent` so auth is process-local and auth.json swaps
42
+ take effect on restart.
43
+
44
+ ### Changed
45
+
46
+ - Headless auth method selection prefers `cached_token` (multi-account) over
47
+ API key / browser methods.
48
+
49
+ ## [2.1.0] - 2026-07-10
50
+
51
+ The **"show me everything"** release — the bot now streams rich, real-time detail
52
+ for every tool the agent calls, so you can see exactly what's happening: which
53
+ files are being read, edited (with diffs), created, deleted or moved, which
54
+ searches run (pattern + scope + filters), which URLs are fetched, which shell
55
+ commands execute, and which MCP tools are invoked — each with its completion
56
+ status (✅ / ❌ / ⏳).
57
+
58
+ ### Added
59
+
60
+ - **🔍 Rich tool-call detail for every kind.** Previously most tool calls showed
61
+ only a bare icon + title line. Now each kind gets its own formatted detail:
62
+ - **Search** — query/pattern, search path (📂), include/exclude filters
63
+ (📁/🚫), case-sensitivity flag.
64
+ - **Read** — file path + line/offset/limit when present.
65
+ - **Edit** — file path + unified diff block with `+added / -removed` count.
66
+ - **Write / Create** — file path + content preview with automatic language
67
+ detection for syntax highlighting (TypeScript, Python, Go, Rust, etc.).
68
+ - **Delete** — the file being removed.
69
+ - **Move / Rename** — source path (📄) → destination path (➡️).
70
+ - **Execute** — the full command in a `bash` code block + working directory.
71
+ - **Fetch / web_fetch** — URL, HTTP method, headers, and body preview.
72
+ - **Web search** — query string + result count.
73
+ - **MCP calls** — server + method + a compact argument preview.
74
+ - **Generic / unknown** — description or message extracted from raw input.
75
+ - **✅ Status visibility for completed tool calls.** `tool_call_update`
76
+ notifications carrying `completed` or `failed` status are now shown (previously
77
+ they were silently deduped away because they shared the `toolCallId` of the
78
+ initial `tool_call`). You now see the final ✅ or ❌ for each tool action,
79
+ including diffs that arrive only in the completion update.
80
+ - **🧩 New `tool-call-detail.ts` module** — shared extractors for paths, search
81
+ queries, URLs, commands, file content, filters, and destination paths, with a
82
+ `normalizeKind()` that maps common aliases (`bash` → `execute`, `grep` →
83
+ `search`, `rename` → `move`, etc.) to canonical kinds.
84
+
85
+ ### Changed
86
+
87
+ - **`formatToolCall` rewritten** from a single switch to per-kind formatter
88
+ functions, each producing rich RAW markdown. Uses string concatenation instead
89
+ of template literals to avoid backtick-in-fence escaping issues.
90
+ - **`session-runtime.ts` dedup logic** refined: initial `tool_call` messages are
91
+ deduped by `toolCallId` (no duplicate); `tool_call_update` with `completed` /
92
+ `failed` status is shown once (keyed by `toolCallId:done`); `pending` /
93
+ `in_progress` updates are skipped unless they carry new `content_blocks`.
94
+
12
95
  ## [2.0.0] - 2026-07-09
13
96
 
14
97
  The **Grok Build** release — the bot now drives the official **xAI Grok Build
@@ -586,6 +669,9 @@ from a single chat and switch between them, on a redesigned, compact menu.
586
669
  diffs, MarkdownV2 rendering, scheduled tasks, multi-image prompts, and a
587
670
  cross-platform 24/7 background service.
588
671
 
672
+ [2.2.0]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.2.0
673
+ [2.1.0]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.1.0
674
+ [2.0.0]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.0.0
589
675
  [1.7.1]: https://github.com/artickc/grok-telegram-bot/releases/tag/v1.7.1
590
676
  [1.7.0]: https://github.com/artickc/grok-telegram-bot/releases/tag/v1.7.0
591
677
  [1.6.0]: https://github.com/artickc/grok-telegram-bot/releases/tag/v1.6.0
package/README.md CHANGED
@@ -17,8 +17,8 @@ account (SuperGrok / X Premium+), send a message from anywhere, and watch Grok
17
17
  plan, read files, run commands, and edit code on your machine — with live typing
18
18
  indicators, clean Telegram markdown, and unified edit diffs.
19
19
 
20
- Inspired by [`ajitnk-lab/kiro-acp-telegram-bot`](https://github.com/ajitnk-lab/kiro-acp-telegram-bot)
21
- and extended into a full multi-session client.
20
+ A fork of [`artickc/kiro-telegram-bot`](https://github.com/artickc/kiro-telegram-bot),
21
+ re-architected for the Grok Build CLI and extended into a full multi-session client.
22
22
 
23
23
  ---
24
24
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "grok-telegram-bot",
3
- "version": "2.0.0",
3
+ "version": "2.2.0",
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",
@@ -0,0 +1,89 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { spawnSync } from "node:child_process";
3
+
4
+ const files = [
5
+ ".env.example",
6
+ "CHANGELOG.md",
7
+ "src/app/accounts.ts",
8
+ "src/app/auth-service.ts",
9
+ "src/bot/account-rotator.ts",
10
+ "src/bot/bot.ts",
11
+ "src/bot/handlers/accounts.ts",
12
+ "src/bot/permission-service.ts",
13
+ "src/bot/reauth-controller.ts",
14
+ "src/config.ts",
15
+ "src/grok/client.ts",
16
+ "test/auth-and-permissions.test.ts",
17
+ ];
18
+
19
+ const owner = "artickc";
20
+ const repo = "grok-telegram-bot";
21
+ const branch = "feat/fix-rotation-and-auto-approve";
22
+ const message =
23
+ "fix: account rotation swaps auth.json headlessly; auto-approve session permissions";
24
+
25
+ // eslint-disable-next-line no-control-regex
26
+ const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
27
+
28
+ function ghApi(method, path, body) {
29
+ const args = ["api", "-X", method, path];
30
+ const opts = {
31
+ encoding: "utf8",
32
+ maxBuffer: 50 * 1024 * 1024,
33
+ shell: false,
34
+ env: { ...process.env, NO_COLOR: "1", CLICOLOR: "0", FORCE_COLOR: "0", GH_FORCE_TTY: "0" },
35
+ };
36
+ if (body !== undefined) {
37
+ args.push("--input", "-");
38
+ opts.input = JSON.stringify(body);
39
+ }
40
+ const r = spawnSync("gh", args, opts);
41
+ if (r.status !== 0) {
42
+ throw new Error(`gh api ${method} ${path} failed (${r.status}): ${r.stderr || r.stdout}`);
43
+ }
44
+ const text = (r.stdout || "").replace(ANSI_RE, "").trim();
45
+ if (!text) return {};
46
+ try {
47
+ return JSON.parse(text);
48
+ } catch (e) {
49
+ throw new Error(`JSON parse failed for ${method} ${path}: ${text.slice(0, 200)}`);
50
+ }
51
+ }
52
+
53
+ const refPath = `repos/${owner}/${repo}/git/ref/heads/${branch}`;
54
+ const ref = ghApi("GET", refPath);
55
+ const baseSha = ref.object.sha;
56
+ console.log("base", baseSha);
57
+
58
+ const baseCommit = ghApi("GET", `repos/${owner}/${repo}/git/commits/${baseSha}`);
59
+ const baseTree = baseCommit.tree.sha;
60
+
61
+ const tree = [];
62
+ for (const path of files) {
63
+ const content = readFileSync(path, "utf8");
64
+ const blob = ghApi("POST", `repos/${owner}/${repo}/git/blobs`, {
65
+ content: Buffer.from(content, "utf8").toString("base64"),
66
+ encoding: "base64",
67
+ });
68
+ tree.push({ path, mode: "100644", type: "blob", sha: blob.sha });
69
+ console.log("blob", path, blob.sha.slice(0, 8));
70
+ }
71
+
72
+ const newTree = ghApi("POST", `repos/${owner}/${repo}/git/trees`, {
73
+ base_tree: baseTree,
74
+ tree,
75
+ });
76
+ console.log("tree", newTree.sha);
77
+
78
+ const commit = ghApi("POST", `repos/${owner}/${repo}/git/commits`, {
79
+ message,
80
+ tree: newTree.sha,
81
+ parents: [baseSha],
82
+ });
83
+ console.log("commit", commit.sha);
84
+
85
+ ghApi("PATCH", `repos/${owner}/${repo}/git/refs/heads/${branch}`, {
86
+ sha: commit.sha,
87
+ force: false,
88
+ });
89
+ console.log("updated", branch, "->", commit.sha);
@@ -0,0 +1,87 @@
1
+ /**
2
+ * Bump package.json on main + create vX.Y.Z tag via the GitHub API
3
+ * (local git push/commit 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.0";
12
+ const tag = `v${version}`;
13
+ const message = `v${version} — headless account rotation + auto-approve session permissions`;
14
+
15
+ // eslint-disable-next-line no-control-regex
16
+ const ANSI_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
17
+
18
+ function ghApi(method, path, body) {
19
+ const args = ["api", "-X", method, path];
20
+ const opts = {
21
+ encoding: "utf8",
22
+ maxBuffer: 50 * 1024 * 1024,
23
+ shell: false,
24
+ env: { ...process.env, NO_COLOR: "1", CLICOLOR: "0", FORCE_COLOR: "0", GH_FORCE_TTY: "0" },
25
+ };
26
+ if (body !== undefined) {
27
+ args.push("--input", "-");
28
+ opts.input = JSON.stringify(body);
29
+ }
30
+ const r = spawnSync("gh", args, opts);
31
+ if (r.status !== 0) {
32
+ throw new Error(`gh api ${method} ${path} failed (${r.status}): ${r.stderr || r.stdout}`);
33
+ }
34
+ const text = (r.stdout || "").replace(ANSI_RE, "").trim();
35
+ if (!text) return {};
36
+ return JSON.parse(text);
37
+ }
38
+
39
+ const ref = ghApi("GET", `repos/${owner}/${repo}/git/ref/heads/${branch}`);
40
+ const baseSha = ref.object.sha;
41
+ const baseCommit = ghApi("GET", `repos/${owner}/${repo}/git/commits/${baseSha}`);
42
+ const baseTree = baseCommit.tree.sha;
43
+ console.log("base", baseSha);
44
+
45
+ const content = readFileSync("package.json", "utf8");
46
+ const blob = ghApi("POST", `repos/${owner}/${repo}/git/blobs`, {
47
+ content: Buffer.from(content, "utf8").toString("base64"),
48
+ encoding: "base64",
49
+ });
50
+
51
+ const newTree = ghApi("POST", `repos/${owner}/${repo}/git/trees`, {
52
+ base_tree: baseTree,
53
+ tree: [{ path: "package.json", mode: "100644", type: "blob", sha: blob.sha }],
54
+ });
55
+
56
+ const commit = ghApi("POST", `repos/${owner}/${repo}/git/commits`, {
57
+ message,
58
+ tree: newTree.sha,
59
+ parents: [baseSha],
60
+ });
61
+ console.log("commit", commit.sha);
62
+
63
+ ghApi("PATCH", `repos/${owner}/${repo}/git/refs/heads/${branch}`, {
64
+ sha: commit.sha,
65
+ force: false,
66
+ });
67
+ console.log("updated", branch, "->", commit.sha);
68
+
69
+ // Annotated tag for release workflow.
70
+ const tagObj = ghApi("POST", `repos/${owner}/${repo}/git/tags`, {
71
+ tag,
72
+ message: message,
73
+ object: commit.sha,
74
+ type: "commit",
75
+ tagger: {
76
+ name: "artickc",
77
+ email: "artickc@users.noreply.github.com",
78
+ date: new Date().toISOString(),
79
+ },
80
+ });
81
+ console.log("tag object", tagObj.sha);
82
+
83
+ ghApi("POST", `repos/${owner}/${repo}/git/refs`, {
84
+ ref: `refs/tags/${tag}`,
85
+ sha: tagObj.sha,
86
+ });
87
+ console.log("created tag", tag);
@@ -11,7 +11,7 @@
11
11
  * Snapshots are copies of auth.json under `<dataDir>/accounts/` (git-ignored).
12
12
  * The index stores only a label + token hash, never the token itself.
13
13
  */
14
- import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises";
14
+ import { mkdir, readFile, rm, writeFile } from "node:fs/promises";
15
15
  import { join } from "node:path";
16
16
  import { createLogger } from "../logger.js";
17
17
  import { JsonStore } from "./json-store.js";
@@ -37,6 +37,8 @@ export interface StoredAccount {
37
37
  interface AccountsData {
38
38
  accounts: StoredAccount[];
39
39
  autoRotate?: boolean;
40
+ /** Explicitly tracked active account id (survives token-hash drift after refresh). */
41
+ activeId?: string;
40
42
  }
41
43
 
42
44
  function makeId(): string {
@@ -73,11 +75,18 @@ export class AccountManager {
73
75
  return this.store.get().accounts.find((a) => a.email === key || a.startUrl === key || a.loginId === key);
74
76
  }
75
77
 
76
- /** Id of the saved account matching the currently active sign-in, by token hash. */
78
+ /**
79
+ * Id of the currently active saved account. Prefers the last switch target
80
+ * (`activeId`), then falls back to matching the live auth.json token hash.
81
+ */
77
82
  activeAccountId(): string | undefined {
83
+ const data = this.store.get();
84
+ if (data.activeId && data.accounts.some((a) => a.id === data.activeId)) {
85
+ return data.activeId;
86
+ }
78
87
  const lid = loginId();
79
88
  if (!lid) return undefined;
80
- return this.store.get().accounts.find((a) => a.loginId === lid)?.id;
89
+ return data.accounts.find((a) => a.loginId === lid)?.id;
81
90
  }
82
91
 
83
92
  get(id: string): StoredAccount | undefined {
@@ -98,23 +107,36 @@ export class AccountManager {
98
107
  const lid = loginId();
99
108
  if (!lid) throw new Error("No browser sign-in to save (an XAI_API_KEY-only login can't be snapshotted).");
100
109
  await mkdir(this.dir, { recursive: true });
110
+ // Prefer a usable token in the live file; reject empty/corrupt auth.json.
111
+ const raw = await readFile(grokAuthPath(), "utf-8").catch(() => undefined);
112
+ if (!raw?.trim()) throw new Error("auth.json is empty or missing — run /reauth first.");
113
+ try {
114
+ JSON.parse(raw);
115
+ } catch {
116
+ throw new Error("auth.json is not valid JSON — run /reauth to repair it.");
117
+ }
101
118
  const label = customLabel?.trim() || loginLabel() || `account ${lid.slice(0, 6)}`;
102
119
  const email = loginLabel();
103
- const existing = this.store.get().accounts.find((a) => a.loginId === lid);
120
+ // Match by token hash first; fall back to the currently marked active slot
121
+ // when the user is re-saving after a silent token refresh.
122
+ const existing =
123
+ this.store.get().accounts.find((a) => a.loginId === lid) ??
124
+ (this.store.get().activeId ? this.get(this.store.get().activeId!) : undefined);
104
125
  const id = existing?.id ?? makeId();
105
- await copyFile(grokAuthPath(), this.snapshotPath(id));
126
+ await writeFile(this.snapshotPath(id), raw, "utf-8");
106
127
  const meta: StoredAccount = {
107
128
  id,
108
- label,
129
+ label: customLabel?.trim() || existing?.label || label,
109
130
  loginId: lid,
110
- email,
111
- startUrl: email,
131
+ email: email || existing?.email,
132
+ startUrl: email || existing?.email,
112
133
  savedAt: new Date().toISOString(),
113
134
  };
114
135
  this.store.update((d) => {
115
136
  const idx = d.accounts.findIndex((a) => a.id === id);
116
137
  if (idx >= 0) d.accounts[idx] = meta;
117
138
  else d.accounts.push(meta);
139
+ d.activeId = id;
118
140
  });
119
141
  log.info(`captured account ${meta.label} (${id})`);
120
142
  return meta;
@@ -122,18 +144,32 @@ export class AccountManager {
122
144
 
123
145
  /**
124
146
  * Make a saved account the active sign-in by copying its snapshot over
125
- * auth.json. The caller restarts the ACP agent so the new identity takes
126
- * effect. Throws when the snapshot is missing.
147
+ * auth.json. The caller MUST stop the ACP agent first (so it cannot rewrite
148
+ * auth.json mid-swap), then restart after this returns. Never opens a
149
+ * browser — pure file replace. Throws when the snapshot is missing/invalid.
127
150
  */
128
151
  async switchTo(id: string): Promise<StoredAccount> {
129
152
  const meta = this.get(id);
130
153
  if (!meta) throw new Error("That account is no longer saved.");
131
154
  const snap = this.snapshotPath(id);
132
155
  const raw = await readFile(snap, "utf-8").catch(() => undefined);
133
- if (!raw) throw new Error(`Saved login for ${meta.label} is missing — re-add it.`);
156
+ if (!raw?.trim()) throw new Error(`Saved login for ${meta.label} is missing — re-add it.`);
157
+ let parsed: unknown;
158
+ try {
159
+ parsed = JSON.parse(raw);
160
+ } catch {
161
+ throw new Error(`Saved login for ${meta.label} is corrupt — re-save it via /accounts.`);
162
+ }
163
+ // Sanity-check: snapshot must look like auth.json (object with at least one key).
164
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed) || Object.keys(parsed as object).length === 0) {
165
+ throw new Error(`Saved login for ${meta.label} has no token — re-save it via /accounts.`);
166
+ }
134
167
  await mkdir(join(grokAuthPath(), ".."), { recursive: true });
135
168
  await writeFile(grokAuthPath(), raw, "utf-8");
136
- log.info(`switched active login to ${meta.label} (${id})`);
169
+ this.store.update((d) => {
170
+ d.activeId = id;
171
+ });
172
+ log.info(`switched active login to ${meta.label} (${id}) — auth.json replaced`);
137
173
  return meta;
138
174
  }
139
175
 
@@ -156,6 +192,7 @@ export class AccountManager {
156
192
  await rm(this.snapshotPath(id), { force: true }).catch(() => {});
157
193
  this.store.update((d) => {
158
194
  d.accounts = d.accounts.filter((a) => a.id !== id);
195
+ if (d.activeId === id) d.activeId = undefined;
159
196
  });
160
197
  return existed;
161
198
  }
@@ -68,7 +68,10 @@ export class AuthService {
68
68
  if (signal?.aborted) return resolve({ ok: false, code: null, cancelled: true });
69
69
  let proc;
70
70
  try {
71
- proc = spawn(this.grokCliPath, ["login"], { stdio: ["ignore", "pipe", "pipe"] });
71
+ // Prefer device-code login so a headless/service host never opens a
72
+ // browser window (which hangs the bot). The verification URL + code are
73
+ // streamed to Telegram via onOutput.
74
+ proc = spawn(this.grokCliPath, ["login", "--device-auth"], { stdio: ["ignore", "pipe", "pipe"] });
72
75
  } catch (e) {
73
76
  onOutput(`error: ${(e as Error).message}`);
74
77
  return resolve({ ok: false, code: null });
@@ -1,52 +1,79 @@
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,
4
- * retrying the same prompt on each — useful when the active account is
5
- * throttled, out of quota, or its backend keeps returning "dispatch failure".
6
- *
7
- * The rotation is bounded to a SINGLE pass over the saved accounts (no infinite
8
- * loop): each account is tried once; the first that succeeds wins and stays
9
- * active, otherwise the runtime reports the error gathered from every account.
10
- *
11
- * Account switching is process-global (one machine → one active Grok login), so
12
- * a rotation restarts the shared agent and affects every chat — intended, since
13
- * the whole point is to move everyone onto a working login.
14
- */
15
- import type { GrokClient } from "../grok/client.js";
16
- import type { AccountManager } from "../app/accounts.js";
17
-
18
- export interface RotationTarget {
19
- id: string;
20
- label: string;
21
- }
22
-
23
- export interface AccountRotator {
24
- /** Whether auto-rotate is switched on. */
25
- enabled(): boolean;
26
- /** Saved accounts to try, EXCLUDING the one that's currently active. */
27
- targets(): Promise<RotationTarget[]>;
28
- /** Make a saved account active (swap the sign-in + re-bind). Throws on error. */
29
- activate(id: string): Promise<void>;
30
- }
31
-
32
- export class AccountRotatorImpl implements AccountRotator {
33
- constructor(
34
- private readonly accounts: AccountManager,
35
- private readonly acp: GrokClient,
36
- ) {}
37
-
38
- enabled(): boolean {
39
- return this.accounts.autoRotateEnabled();
40
- }
41
-
42
- async targets(): Promise<RotationTarget[]> {
43
- const list = this.accounts.list();
44
- const activeId = this.accounts.activeAccountId();
45
- return list.filter((a) => a.id !== activeId).map((a) => ({ id: a.id, label: a.label }));
46
- }
47
-
48
- async activate(id: string): Promise<void> {
49
- await this.accounts.switchTo(id);
50
- await this.acp.restart();
51
- }
52
- }
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,
4
+ * retrying the same prompt on each — useful when the active account is
5
+ * throttled, out of quota, or its backend keeps returning "dispatch failure".
6
+ *
7
+ * The rotation is bounded to a SINGLE pass over the saved accounts (no infinite
8
+ * loop): each account is tried once; the first that succeeds wins and stays
9
+ * active, otherwise the runtime reports the error gathered from every account.
10
+ *
11
+ * Account switching is process-global (one machine → one active Grok login), so
12
+ * a rotation restarts the shared agent and affects every chat — intended, since
13
+ * the whole point is to move everyone onto a working login.
14
+ *
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`.
17
+ */
18
+ import type { GrokClient } from "../grok/client.js";
19
+ import type { AccountManager } from "../app/accounts.js";
20
+ import { createLogger } from "../logger.js";
21
+
22
+ const log = createLogger("account-rotator");
23
+
24
+ export interface RotationTarget {
25
+ id: string;
26
+ label: string;
27
+ }
28
+
29
+ export interface AccountRotator {
30
+ /** Whether auto-rotate is switched on. */
31
+ enabled(): boolean;
32
+ /** Saved accounts to try, EXCLUDING the one that's currently active. */
33
+ targets(): Promise<RotationTarget[]>;
34
+ /** Make a saved account active (swap auth.json + re-bind). Throws on error. */
35
+ activate(id: string): Promise<void>;
36
+ }
37
+
38
+ export class AccountRotatorImpl implements AccountRotator {
39
+ constructor(
40
+ private readonly accounts: AccountManager,
41
+ private readonly acp: GrokClient,
42
+ ) {}
43
+
44
+ enabled(): boolean {
45
+ return this.accounts.autoRotateEnabled();
46
+ }
47
+
48
+ async targets(): Promise<RotationTarget[]> {
49
+ const list = this.accounts.list();
50
+ const activeId = this.accounts.activeAccountId();
51
+ return list.filter((a) => a.id !== activeId).map((a) => ({ id: a.id, label: a.label }));
52
+ }
53
+
54
+ /**
55
+ * Pure file-based account swap:
56
+ * 1. Stop the shared agent (so it cannot rewrite auth.json),
57
+ * 2. Copy the saved snapshot over ~/.grok/auth.json,
58
+ * 3. Start the agent and authenticate with `cached_token` (headless).
59
+ * Never launches a browser.
60
+ */
61
+ async activate(id: string): Promise<void> {
62
+ // Snapshot the current login first so we never lose it mid-rotation.
63
+ await this.accounts.captureCurrent().catch((e) => {
64
+ log.warn("pre-rotate capture failed (continuing):", (e as Error).message);
65
+ });
66
+ log.info(`rotating: stopping agent before auth.json swap (${id})`);
67
+ await this.acp.stopAndWait();
68
+ try {
69
+ const meta = await this.accounts.switchTo(id);
70
+ log.info(`rotating: auth.json now ${meta.label}; starting agent`);
71
+ await this.acp.start();
72
+ } catch (e) {
73
+ // Best-effort recover the agent so the bot stays usable even if the
74
+ // target login was bad.
75
+ await this.acp.start().catch((err) => log.warn("post-rotate restart failed:", (err as Error).message));
76
+ throw e;
77
+ }
78
+ }
79
+ }
package/src/bot/bot.ts CHANGED
@@ -123,8 +123,10 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
123
123
  // Auto-rotate-on-give-up: let a stuck turn cycle through other saved logins.
124
124
  registry.setAccountRotator(new AccountRotatorImpl(deps.accounts, acp));
125
125
 
126
- // Inline approvals: when NOT in trust-all mode, Grok asks before risky tools.
127
- const permissions = new PermissionService(bot.api, registry);
126
+ // Permission handling: default is auto-approve (prefer "this session" / always).
127
+ // Interactive Approve/Deny buttons only when both trust-all and auto-approve are off.
128
+ const autoApprovePerms = cfg.autoApprovePermissions || cfg.trustAllTools;
129
+ const permissions = new PermissionService(bot.api, registry, autoApprovePerms);
128
130
  acp.permissionHandler = (p) => permissions.handle(p);
129
131
 
130
132
  // The bot pins/unpins the status panel, and Telegram emits a "pinned a
@@ -175,9 +175,12 @@ export function registerAccounts(bot: Bot, deps: BotDeps): void {
175
175
  await ctx.answerCallbackQuery({ text: "Importing…" });
176
176
  const res = await auth.importExisting();
177
177
  if (!res.ok) return void rerender(ctx, deps, `\u274C ${res.error ?? "Import failed."}`);
178
+ // Import reuses the live auth.json — just re-bind the agent headlessly.
178
179
  try {
179
- await deps.acp.restart();
180
+ await deps.acp.stopAndWait();
181
+ await deps.acp.start();
180
182
  } catch (e) {
183
+ await deps.acp.start().catch(() => {});
181
184
  return void rerender(ctx, deps, `\u26A0\uFE0F Imported, but re-bind failed: ${(e as Error).message}`);
182
185
  }
183
186
  let note = `\u2705 Imported the current login${res.label ? ` (${res.label})` : ""}.`;
@@ -196,10 +199,24 @@ export function registerAccounts(bot: Bot, deps: BotDeps): void {
196
199
  if (reason) return void ctx.answerCallbackQuery({ text: reason, show_alert: true });
197
200
  await ctx.answerCallbackQuery({ text: "Switching…" });
198
201
  try {
199
- await deps.accounts.captureCurrent().catch(() => {}); // don't lose the current login
200
- const meta = await deps.accounts.switchTo(id);
201
- await ctx.editMessageText(`\u{1F504} Switching to ${meta.label}\u2026 restarting agent`).catch(() => {});
202
- await deps.acp.restart();
202
+ // 1) Snapshot the current login so it isn't lost.
203
+ await deps.accounts.captureCurrent().catch(() => {});
204
+ const target = deps.accounts.get(id);
205
+ await ctx
206
+ .editMessageText(`\u{1F504} Switching to ${target?.label ?? "account"}\u2026 replacing auth.json + restarting agent`)
207
+ .catch(() => {});
208
+ // 2) Stop agent BEFORE writing auth.json (avoids the live process
209
+ // overwriting / racing the file, and never opens a browser).
210
+ await deps.acp.stopAndWait();
211
+ let meta;
212
+ try {
213
+ meta = await deps.accounts.switchTo(id);
214
+ // 3) Start agent; it authenticates headlessly with cached_token.
215
+ await deps.acp.start();
216
+ } catch (e) {
217
+ await deps.acp.start().catch(() => {});
218
+ throw e;
219
+ }
203
220
  const note = (await deps.usage.isLoggedIn())
204
221
  ? `\u2705 Now signed in as ${meta.label}. Your next message runs on this account.`
205
222
  : `\u26A0\uFE0F Switched to ${meta.label}, but no usable login is active. ${UNSUPPORTED_LOGIN_HELP}`;