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.
@@ -1,9 +1,10 @@
1
1
  /**
2
- * PermissionService — turns Grok's ACP `session/request_permission` into inline
3
- * Approve/Deny buttons. It names the session that needs approval, sends the
4
- * prompt WITH sound (it requires interaction), and — when the request belongs to
5
- * a *background* session adds a "🔀 Switch to it" button. The Allow/Deny
6
- * buttons resolve the request in place, without switching.
2
+ * PermissionService — turns Grok's ACP `session/request_permission` into either
3
+ * an automatic session-level approval (default) or inline Approve/Deny buttons.
4
+ *
5
+ * Auto-approve prefers "allow for this session" / "always allow" options so the
6
+ * agent stops re-prompting mid-turn. Interactive mode is only used when
7
+ * auto-approve is off (GROK_TRUST_ALL_TOOLS=false and AUTO_APPROVE_PERMISSIONS=false).
7
8
  */
8
9
  import type { Api } from "grammy";
9
10
  import { InlineKeyboard } from "grammy";
@@ -35,17 +36,31 @@ interface Pending {
35
36
  export class PermissionService {
36
37
  private readonly pending = new Map<string, Pending>();
37
38
  private seq = 0;
39
+ /** When true, every permission request is auto-approved (session-scope preferred). */
40
+ autoApprove: boolean;
38
41
 
39
42
  constructor(
40
43
  private readonly api: Api,
41
44
  private readonly registry: RuntimeRegistry,
42
- ) {}
45
+ autoApprove = true,
46
+ ) {
47
+ this.autoApprove = autoApprove;
48
+ }
43
49
 
44
- /** Handle a permission request: ask the owning chat, or auto-allow if none. */
50
+ /** Handle a permission request: auto-approve (default), ask the chat, or allow if unattended. */
45
51
  async handle(params: RequestPermissionParams): Promise<PermissionOutcome> {
52
+ if (this.autoApprove) {
53
+ const decision = autoDecideSession(params);
54
+ log.info(
55
+ `auto-approved permission for session ${params.sessionId.slice(0, 8)} ` +
56
+ `(${params.toolCall?.kind ?? "tool"}: ${params.toolCall?.title ?? "?"})`,
57
+ );
58
+ return decision;
59
+ }
60
+
46
61
  const desc = this.registry.describeSession(params.sessionId);
47
62
  const chatId = desc.chatId;
48
- if (chatId === undefined) return autoDecide(params); // unattended (e.g. scheduled task / orphan subagent)
63
+ if (chatId === undefined) return autoDecideSession(params); // unattended (scheduled / orphan)
49
64
 
50
65
  const reqId = String(++this.seq);
51
66
  const isForeground = !desc.subagent && this.registry.get(chatId).sessionId === params.sessionId;
@@ -75,7 +90,7 @@ export class PermissionService {
75
90
  messageId = msg.message_id;
76
91
  } catch (e) {
77
92
  log.warn("failed to send permission prompt:", (e as Error).message);
78
- return autoDecide(params);
93
+ return autoDecideSession(params);
79
94
  }
80
95
 
81
96
  return new Promise<PermissionOutcome>((resolve) => {
@@ -136,14 +151,43 @@ function describe(
136
151
 
137
152
  function buttonLabel(o: { name: string; kind?: string }): string {
138
153
  const k = `${o.kind ?? ""} ${o.name}`.toLowerCase();
139
- const icon = /reject|deny|no|cancel/.test(k) ? "\u26D4" : /always|all/.test(k) ? "\u2705\u267E\uFE0F" : "\u2705";
154
+ const icon = /reject|deny|no|cancel/.test(k) ? "\u26D4" : /always|all|session/.test(k) ? "\u2705\u267E\uFE0F" : "\u2705";
140
155
  return `${icon} ${o.name}`;
141
156
  }
142
157
 
143
- /** Pick an allow option when nobody can be asked (otherwise cancel). */
144
- function autoDecide(params: RequestPermissionParams): PermissionOutcome {
145
- const allow = params.options.find((o) => /allow|approve|yes|once/i.test(`${o.kind ?? ""} ${o.name}`));
146
- return allow
147
- ? { outcome: { outcome: "selected", optionId: allow.optionId } }
158
+ /**
159
+ * Score an allow-option. Higher is better for bot auto-approve:
160
+ * 4 always allow all sessions / forever
161
+ * 3 — allow for this session (preferred default)
162
+ * 2 allow always (unscoped)
163
+ * 1 — allow once / approve / yes
164
+ * 0 — not an allow option (reject/deny)
165
+ */
166
+ function allowScore(o: { name: string; kind?: string }): number {
167
+ const k = `${o.kind ?? ""} ${o.name}`.toLowerCase();
168
+ if (/reject|deny|cancel|no\b|block/.test(k)) return 0;
169
+ if (/all.?sessions|always_allow_all|forever|unrestricted/.test(k)) return 4;
170
+ if (/this.?session|session|allow_session|always_allow_session/.test(k)) return 3;
171
+ if (/always|allow_always|allow.?all\b/.test(k)) return 2;
172
+ if (/allow|approve|yes|once|ok\b/.test(k)) return 1;
173
+ return 0;
174
+ }
175
+
176
+ /**
177
+ * Pick the best allow option, preferring "this session" / "always" so the agent
178
+ * stops re-prompting. Falls back to cancelled only when no allow option exists.
179
+ */
180
+ export function autoDecideSession(params: RequestPermissionParams): PermissionOutcome {
181
+ let best: { optionId: string; score: number } | undefined;
182
+ for (const o of params.options) {
183
+ const score = allowScore(o);
184
+ if (score <= 0) continue;
185
+ if (!best || score > best.score) best = { optionId: o.optionId, score };
186
+ }
187
+ if (best) return { outcome: { outcome: "selected", optionId: best.optionId } };
188
+ // Last resort: first option if present (agent convention: allow first).
189
+ const first = params.options[0];
190
+ return first
191
+ ? { outcome: { outcome: "selected", optionId: first.optionId } }
148
192
  : { outcome: { outcome: "cancelled" } };
149
193
  }
@@ -233,14 +233,14 @@ export class ReauthController {
233
233
  return (
234
234
  "\u{1F510} Sign in to Grok\n\n" +
235
235
  "Grok signs in with your xAI account (SuperGrok / X Premium+).\n\n" +
236
- "\u2022 \u{1F511} Sign in \u2014 runs `grok login`; if a link/code appears, open it to approve.\n" +
236
+ "\u2022 \u{1F511} Sign in \u2014 runs `grok login --device-auth` (no browser); open the link/code here to approve.\n" +
237
237
  "\u2022 \u{1F4E5} Import existing \u2014 use a `grok login` already done on this machine."
238
238
  );
239
239
  case "login": {
240
- const lines = ["\u{1F511} Signing in to Grok\u2026", ""];
240
+ const lines = ["\u{1F511} Signing in to Grok (device code, no browser)\u2026", ""];
241
241
  if (s.url) lines.push(`\u{1F517} Open this link to approve:\n${s.url}`, "");
242
242
  if (s.code) lines.push(`\u{1F522} Verification code: ${s.code}`, "");
243
- if (!s.url && !s.code) lines.push("Starting sign-in (a browser may open on the host)\u2026", "");
243
+ if (!s.url && !s.code) lines.push("Starting headless device-code sign-in\u2026", "");
244
244
  lines.push(`${loader} Waiting\u2026`);
245
245
  return lines.join("\n");
246
246
  }
@@ -1,5 +1,5 @@
1
1
  /**
2
- * SessionRuntime binds one Telegram chat to one Grok ACP session and drives
2
+ * SessionRuntime — binds one Telegram chat to one Grok ACP session and drives
3
3
  * the prompt/stream lifecycle, typing indicator, follow-up queue, live watch,
4
4
  * and per-chat preferences (project, agent, model, reasoning). State persists
5
5
  * to the settings store so it survives restarts.
@@ -54,7 +54,7 @@ const sleep = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms
54
54
  const RESUME_INSTRUCTION =
55
55
  "Your previous response was interrupted by a transient service error (the model stream was throttled), " +
56
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 " +
57
+ "Do NOT repeat any file edits, commands, or other tool calls you already completed — their results are " +
58
58
  "already in this conversation. If you had already fully answered, just briefly conclude.";
59
59
 
60
60
  export class SessionRuntime {
@@ -82,7 +82,7 @@ export class SessionRuntime {
82
82
  /** Subagent sessionId -> last status key shown this turn (dedupe). */
83
83
  private subagentShown = new Map<string, string>();
84
84
  private turnStartedAt = 0;
85
- /** Count of completed (non-cancelled) turns this session shown in /usage. */
85
+ /** Count of completed (non-cancelled) turns this session — shown in /usage. */
86
86
  private turnCount = 0;
87
87
  /** Telegram message id of the current turn's prompt, so replies thread to it. */
88
88
  private turnReplyTo: number | undefined;
@@ -93,7 +93,7 @@ export class SessionRuntime {
93
93
  private watcher: TailWatcher | undefined;
94
94
  /** True when the active watch is a transient "follow" of this session's own
95
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. */
96
+ * another session — follow-watches are auto-stopped when a new turn streams. */
97
97
  private watchIsFollow = false;
98
98
  private rebindPending = false;
99
99
  private sessionLive = false;
@@ -159,7 +159,7 @@ export class SessionRuntime {
159
159
  return this.lastCompletion;
160
160
  }
161
161
 
162
- /** Latest task-completion % (0100) parsed this turn, or undefined if none. */
162
+ /** Latest task-completion % (0–100) parsed this turn, or undefined if none. */
163
163
  get taskProgress(): number | undefined {
164
164
  return this.progress;
165
165
  }
@@ -174,8 +174,8 @@ export class SessionRuntime {
174
174
  this.changed();
175
175
  }
176
176
 
177
- /** Searchable hashtag footer for this session (project · session · model ·
178
- * reasoning) appended to every AI-output surface for this session. */
177
+ /** Searchable hashtag footer for this session (project В· session В· model В·
178
+ * reasoning) — appended to every AI-output surface for this session. */
179
179
  get tags(): string {
180
180
  return this.hashtags();
181
181
  }
@@ -189,7 +189,7 @@ export class SessionRuntime {
189
189
  if (value) {
190
190
  // A turn was started here and is still in flight, but its streamer was
191
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
192
+ // the remaining chunks/thoughts/tools just like a normal live turn — we
193
193
  // own the agent's session/update events, so no tail-watch is needed.
194
194
  if (this.busy && !this.streamer) {
195
195
  // Any transient follow-watch of this session is now superseded.
@@ -234,7 +234,7 @@ export class SessionRuntime {
234
234
  this.stopWatch();
235
235
  }
236
236
 
237
- // ── sessions ───────────────────────────────────────────────────────────────
237
+ // в”Ђв”Ђ sessions в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
238
238
 
239
239
  async startNewSession(cwd: string, projectName?: string): Promise<void> {
240
240
  if (this.busy) await this.cancel();
@@ -314,7 +314,7 @@ export class SessionRuntime {
314
314
  return true;
315
315
  }
316
316
 
317
- // ── preferences ──────────────────────────────────────────────────────────
317
+ // в”Ђв”Ђ preferences в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
318
318
 
319
319
  async setModelPref(modelId: string): Promise<{ ok: boolean; error?: string }> {
320
320
  // Persist the choice always; only talk to Grok when a session is live in
@@ -378,7 +378,7 @@ export class SessionRuntime {
378
378
  }
379
379
  }
380
380
 
381
- // ── prompting ──────────────────────────────────────────────────────────────
381
+ // в”Ђв”Ђ prompting в”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђв”Ђ
382
382
 
383
383
  async submit(input: PromptInput): Promise<"ran" | "queued"> {
384
384
  await this.ensureSession();
@@ -425,7 +425,7 @@ export class SessionRuntime {
425
425
  // The session genuinely can't be reloaded (its exclusive lock is held,
426
426
  // or its log/metadata is gone). Never silently drop the conversation:
427
427
  // fork a linked continuation primed with the recent transcript so the
428
- // thread survives including any question the agent had just asked.
428
+ // thread survives — including any question the agent had just asked.
429
429
  // forkFromLostSession() only throws if the agent is fully down, in which
430
430
  // case we leave rebindPending set so the next message retries cleanly.
431
431
  await this.forkFromLostSession(this.sessionId);
@@ -438,7 +438,7 @@ export class SessionRuntime {
438
438
  /** Reload a persisted session, retrying flaky failures with a short backoff.
439
439
  * Returns true once loaded, false after the attempts are exhausted. */
440
440
  private async rebindWithRetries(sessionId: string, attempts = 4): Promise<boolean> {
441
- const delays = [400, 1200, 3000]; // 4.6s total before giving up
441
+ const delays = [400, 1200, 3000]; // ≈4.6s total before giving up
442
442
  for (let i = 0; i < attempts; i++) {
443
443
  try {
444
444
  await this.acp.loadSession(sessionId, this.cwd);
@@ -518,7 +518,7 @@ export class SessionRuntime {
518
518
  if (resumed) final = resumed;
519
519
  const streamedOutput = this.streamer?.hasOutput ?? false;
520
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).
521
+ // no-op when the agent reported its own progress — its value is kept).
522
522
  if (final.result && !this.cancelled) this.streamer?.completeFallback();
523
523
  if (this.streamer) await this.streamer.finalize();
524
524
  if (this.foreground) await this.sendTurnImages();
@@ -527,7 +527,7 @@ export class SessionRuntime {
527
527
  // the foreground turn, or a background turn when NOTIFY_OTHER_SESSIONS is on.
528
528
  const canPing = this.foreground || this.cfg.notifyOtherSessions;
529
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.
530
+ // interim "Done" — only the final, queue-empty turn announces completion.
531
531
  const hasQueued = this.queue.length > 0;
532
532
  const switchKb = this.switchKeyboard();
533
533
  if (final.result && !this.cancelled) this.turnCount++;
@@ -594,11 +594,11 @@ export class SessionRuntime {
594
594
  }
595
595
 
596
596
  /**
597
- * True when a prompt failure is attributable to an exhausted context window
597
+ * True when a prompt failure is attributable to an exhausted context window —
598
598
  * either the error message says so, or this session's last-known context
599
599
  * usage is at/above the configured fork threshold. Such failures won't clear
600
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
601
+ * surfaces as a plain "-32603 … throttled"), so the session must be compacted
602
602
  * by forking a fresh, smaller continuation.
603
603
  */
604
604
  private isContextRelatedFailure(error: Error): boolean {
@@ -663,7 +663,7 @@ export class SessionRuntime {
663
663
  /**
664
664
  * Auto-rotate-on-give-up. When a turn has failed (retries exhausted, auto-fork
665
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
666
+ * accounts once — switching login + restarting the agent, then retrying the
667
667
  * same prompt on a fresh session for each. The first account that succeeds
668
668
  * wins and stays active; if every account fails we return a single combined
669
669
  * error listing what each one reported. Bounded to ONE pass (no infinite
@@ -719,14 +719,14 @@ export class SessionRuntime {
719
719
  errors.push(`\u2022 ${t.label}: ${last.error?.message ?? "failed"}`);
720
720
  }
721
721
 
722
- // One full cycle done and still failing stop with a combined report.
722
+ // One full cycle done and still failing — stop with a combined report.
723
723
  const combined = new Error(`Tried ${targets.length + 1} account(s), all failed:\n${errors.join("\n")}`);
724
724
  return { error: combined, attempts: last.attempts };
725
725
  }
726
726
 
727
727
  /**
728
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,
729
+ * traffic" / -32603) with an exponential backoff (6s в†’ 12s в†’ 24s в†’ 48s в†’ 60s,
730
730
  * then give up). The real error is shown to the user on every failed attempt.
731
731
  *
732
732
  * We only retry while the turn has produced **no streamed output** (so tools
@@ -748,7 +748,7 @@ export class SessionRuntime {
748
748
  const error = err as Error;
749
749
  const canRecover = !this.cancelled && !(this.streamer?.hasOutput ?? false);
750
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.
751
+ // oversized prompt — skip the backoff and let auto-fork compact it now.
752
752
  const forkInstead = canRecover && this.cfg.autoForkOnError && this.isContextRelatedFailure(error);
753
753
  const willRetry =
754
754
  attempt <= delays.length &&
@@ -784,8 +784,8 @@ export class SessionRuntime {
784
784
  * The pre-stream paths (retry / auto-fork / account-rotate) all bail once any
785
785
  * output exists, because re-sending the original prompt would re-execute the
786
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
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
789
  * using the same exponential backoff so a throttle has time to clear. The
790
790
  * open streamer keeps appending, so the reply is completed in place.
791
791
  *
@@ -800,7 +800,7 @@ export class SessionRuntime {
800
800
  if (!(this.streamer?.hasOutput ?? false)) return undefined;
801
801
  if (!isTransientError(final.error)) return undefined;
802
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
803
+ // again each attempt) — don't burn the backoff; surface the error so the
804
804
  // user can fork/compact. Resume targets transient throttles on a session
805
805
  // that still has headroom.
806
806
  if (this.isContextRelatedFailure(final.error)) return undefined;
@@ -878,7 +878,7 @@ export class SessionRuntime {
878
878
  const meta = this.contextInfo();
879
879
  const ctx = meta?.contextUsagePercentage;
880
880
  const ctxStr = ctx !== undefined ? ` \u00B7 ctx ${ctx.toFixed(0)}%` : "";
881
- // Credits consumed this turn only shown when Grok actually reports it
881
+ // Credits consumed this turn — only shown when Grok actually reports it
882
882
  // (not part of ACP today; degrades to nothing rather than guessing).
883
883
  const credits = meta?.credits;
884
884
  const creditStr = credits !== undefined ? ` \u00B7 \u{1FA99} ${fmtCredits(credits)}` : "";
@@ -898,7 +898,7 @@ export class SessionRuntime {
898
898
  return `\u{1F4E8} From other session ${this.sessionTag()}\n${summary}${shortFiles}\n\n${tags}`;
899
899
  }
900
900
 
901
- /** "[project · 1a2b3c4d]" identifies which background session a ping is from. */
901
+ /** "[project · 1a2b3c4d]" — identifies which background session a ping is from. */
902
902
  private sessionTag(): string {
903
903
  const name = this.projectName || basename(this.cwd) || "session";
904
904
  const id = this.sessionId ? ` \u00B7 ${this.sessionId.slice(0, 8)}` : "";
@@ -962,9 +962,29 @@ export class SessionRuntime {
962
962
  }
963
963
  if (kind === "tool_call" || kind === "tool_call_update") {
964
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);
965
+ const id = update.toolCallId || "";
966
+ const status = (update.status || "").toLowerCase();
967
+
968
+ if (kind === "tool_call_update") {
969
+ // Skip "in_progress"/"pending" duplicates of an already-shown initial call.
970
+ if (status === "pending" || status === "in_progress") {
971
+ const hasNewContent =
972
+ Array.isArray(update.content_blocks) && update.content_blocks.length > 0;
973
+ if (!hasNewContent) return;
974
+ }
975
+ // For completed/failed: show once (so the user sees the final status).
976
+ const doneKey = (id || update.title || "") + ":done";
977
+ if (status === "completed" || status === "failed") {
978
+ if (this.shownToolIds.has(doneKey)) return;
979
+ this.shownToolIds.add(doneKey);
980
+ }
981
+ } else {
982
+ // Initial tool_call: dedupe by id to avoid double-showing.
983
+ const shownKey = id || `tool_call:${update.title ?? ""}`;
984
+ if (this.shownToolIds.has(shownKey)) return;
985
+ this.shownToolIds.add(shownKey);
986
+ }
987
+
968
988
  const md = formatToolCall(update, {
969
989
  showDiffs: this.cfg.showEditDiffs,
970
990
  diffMaxLines: this.cfg.diffMaxLines,
@@ -1019,7 +1039,7 @@ export class SessionRuntime {
1019
1039
  .map((e) => {
1020
1040
  const icon = WATCH_ICON[e.role] ?? "\u2022";
1021
1041
  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;
1042
+ const text = e.text.length > WATCH_ENTRY_MAX ? e.text.slice(0, WATCH_ENTRY_MAX) + " …" : e.text;
1023
1043
  return `${icon} ${text}`;
1024
1044
  })
1025
1045
  .filter(Boolean)
package/src/config.ts CHANGED
@@ -99,6 +99,13 @@ export interface AppConfig {
99
99
  * --agent flag headlessly). */
100
100
  agent?: string;
101
101
  trustAllTools: boolean;
102
+ /**
103
+ * Auto-approve ACP `session/request_permission` prompts (prefer "allow for
104
+ * this session"). Defaults true — Telegram bots shouldn't block on tool
105
+ * approvals. Set false (and GROK_TRUST_ALL_TOOLS=false) for interactive
106
+ * Approve/Deny buttons.
107
+ */
108
+ autoApprovePermissions: boolean;
102
109
  projectRoots: string[];
103
110
  streamThrottleMs: number;
104
111
  messageBatchMs: number;
@@ -183,6 +190,8 @@ export function loadConfig(): AppConfig {
183
190
  maxToolRounds: num(process.env.GROK_MAX_TOOL_ROUNDS, 400),
184
191
  agent: process.env.GROK_AGENT?.trim() || undefined,
185
192
  trustAllTools: bool(process.env.GROK_TRUST_ALL_TOOLS, true),
193
+ // Default true: auto-approve with session-scope when the agent still asks.
194
+ autoApprovePermissions: bool(process.env.AUTO_APPROVE_PERMISSIONS, true),
186
195
  projectRoots: [...new Set(roots)],
187
196
  streamThrottleMs: num(process.env.STREAM_THROTTLE_MS, 1500),
188
197
  messageBatchMs: nonNegNum(process.env.MESSAGE_BATCH_MS, 800),
@@ -13,6 +13,7 @@
13
13
  import { type ChildProcessWithoutNullStreams, spawn } from "node:child_process";
14
14
  import { EventEmitter } from "node:events";
15
15
  import { createLogger } from "../logger.js";
16
+ import { hasLogin } from "../app/grok-credentials.js";
16
17
  import { contextWindowFor, DEFAULT_MODEL, KNOWN_MODELS } from "./models.js";
17
18
  import { PROGRESS_DIRECTIVE } from "../render/progress.js";
18
19
  import { SessionLog } from "./session-log.js";
@@ -76,6 +77,47 @@ function shortJson(v: unknown): string {
76
77
  }
77
78
  }
78
79
 
80
+ /** Auth methods that open a browser / interactive UI — never use from the bot. */
81
+ const BROWSER_AUTH_RE = /grok\.com|browser|oauth|interactive|web.?login/i;
82
+
83
+ /**
84
+ * Pick a headless-safe auth method. Prefer `cached_token` (auth.json) so
85
+ * multi-account rotation works by swapping that file; then `xai.api_key` when
86
+ * an API key is configured. Never falls back to browser methods.
87
+ */
88
+ export function pickHeadlessAuthMethod(
89
+ methods: Array<{ id: string; name?: string }>,
90
+ hasApiKey: boolean,
91
+ ): string | undefined {
92
+ const ids = methods.map((m) => m.id);
93
+ const safe = (id: string) => !BROWSER_AUTH_RE.test(id) && !/login|sign.?in/i.test(id);
94
+ if (ids.includes("cached_token") && safe("cached_token")) return "cached_token";
95
+ if (hasApiKey && ids.includes("xai.api_key") && safe("xai.api_key")) return "xai.api_key";
96
+ // Any other non-browser, non-key method the agent advertises.
97
+ return ids.find((id) => safe(id) && id !== "xai.api_key");
98
+ }
99
+
100
+ /** Auto-pick an allow option for permission requests (prefer session/always). */
101
+ function pickAllowOption(
102
+ opts: Array<{ optionId: string; name?: string; kind?: string }>,
103
+ ): PermissionOutcome {
104
+ let best: { optionId: string; score: number } | undefined;
105
+ for (const o of opts) {
106
+ const k = `${o.kind ?? ""} ${o.name ?? ""}`.toLowerCase();
107
+ let score = 0;
108
+ if (/reject|deny|cancel|no\b|block/.test(k)) score = 0;
109
+ else if (/all.?sessions|always_allow_all|forever/.test(k)) score = 4;
110
+ else if (/this.?session|session|allow_session/.test(k)) score = 3;
111
+ else if (/always|allow_always|allow.?all\b/.test(k)) score = 2;
112
+ else if (/allow|approve|yes|once|ok\b/.test(k)) score = 1;
113
+ if (score > 0 && (!best || score > best.score)) best = { optionId: o.optionId, score };
114
+ }
115
+ if (best) return { outcome: { outcome: "selected", optionId: best.optionId } };
116
+ return opts[0]
117
+ ? { outcome: { outcome: "selected", optionId: opts[0].optionId } }
118
+ : { outcome: { outcome: "cancelled" } };
119
+ }
120
+
79
121
  export interface GrokClientOptions {
80
122
  grokCliPath: string;
81
123
  workspace: string;
@@ -161,8 +203,13 @@ export class GrokClient extends EventEmitter {
161
203
  }
162
204
 
163
205
  private async connect(): Promise<void> {
164
- const args = ["agent", "stdio", "--no-auto-update"];
206
+ // `--always-approve` is a `grok agent` option (not `grok agent stdio`),
207
+ // so it must come before the `stdio` subcommand. `--no-leader` keeps auth
208
+ // process-local so swapping ~/.grok/auth.json + restart actually picks up
209
+ // the new token. `--no-auto-update` was removed in grok 0.2.x (exit 2).
210
+ const args = ["agent", "--no-leader"];
165
211
  if (this.opts.trustAllTools) args.push("--always-approve");
212
+ args.push("stdio");
166
213
 
167
214
  log.info(`spawning: ${this.opts.grokCliPath} ${args.join(" ")}`);
168
215
  const env = { ...process.env };
@@ -193,7 +240,7 @@ export class GrokClient extends EventEmitter {
193
240
  const init = (await this.request("initialize", {
194
241
  protocolVersion: 1,
195
242
  clientCapabilities: { fs: { readTextFile: false, writeTextFile: false }, terminal: false },
196
- clientInfo: { name: "grok-telegram-bot", version: "2.0.0" },
243
+ clientInfo: { name: "grok-telegram-bot", version: "2.2.0" },
197
244
  })) as InitializeResult;
198
245
 
199
246
  this.agentInfo = init.agentInfo ?? { name: "grok" };
@@ -202,19 +249,29 @@ export class GrokClient extends EventEmitter {
202
249
  this.subagents = [];
203
250
  this.pendingStages = [];
204
251
 
205
- // Authenticate: prefer the cached `grok login` token; fall back to API key.
206
- const methods = new Set((init.authMethods ?? []).map((m) => m.id));
207
- this.authMethodId =
208
- this.opts.apiKey && methods.has("xai.api_key")
209
- ? "xai.api_key"
210
- : methods.has("cached_token")
211
- ? "cached_token"
212
- : (init.authMethods?.[0]?.id ?? undefined);
213
- if (this.authMethodId) {
252
+ // Authenticate headlessly only. NEVER pick browser methods (e.g. "grok.com")
253
+ // those open a browser and hang/kill the bot host. Prefer cached_token
254
+ // from ~/.grok/auth.json, then xai.api_key when configured.
255
+ this.authMethodId = pickHeadlessAuthMethod(init.authMethods ?? [], !!this.opts.apiKey);
256
+ if (!this.authMethodId) {
257
+ // Never fall back to browser methods (e.g. grok.com) — that opens a
258
+ // browser and freezes headless hosts. Boot unauthenticated so /reauth works.
259
+ log.warn(
260
+ "No headless Grok auth method available. Run `grok login` / /reauth, or set XAI_API_KEY. " +
261
+ "Refusing browser-based auth methods.",
262
+ );
263
+ } else {
214
264
  try {
215
265
  await this.request("authenticate", { methodId: this.authMethodId, _meta: { headless: true } });
216
266
  } catch (e) {
217
- log.warn(`authenticate (${this.authMethodId}) failed: ${(e as Error).message}`);
267
+ const msg = (e as Error).message;
268
+ log.warn(`authenticate (${this.authMethodId}) failed: ${msg}`);
269
+ // If a login (or API key) is present, auth should have worked — surface
270
+ // the error so account switch/rotation doesn't silently keep a dead agent.
271
+ // If nothing is configured yet, soft-fail so the bot can still boot for /reauth.
272
+ if (hasLogin() || this.opts.apiKey) {
273
+ throw new Error(`Grok authenticate (${this.authMethodId}) failed: ${msg}`);
274
+ }
218
275
  }
219
276
  }
220
277
  log.info(`connected: ${this.agentInfo?.name ?? "grok"} ${this.agentInfo?.version ?? ""}`.trim());
@@ -515,11 +572,9 @@ export class GrokClient extends EventEmitter {
515
572
  if (method === "session/request_permission" && this.permissionHandler) {
516
573
  result = await this.permissionHandler(params as unknown as RequestPermissionParams);
517
574
  } else if (method === "session/request_permission") {
518
- // No handler (trust-all): auto-approve the first option, else cancel.
519
- const opts = (params.options as Array<{ optionId: string }>) ?? [];
520
- result = opts[0]
521
- ? { outcome: { outcome: "selected", optionId: opts[0].optionId } }
522
- : { outcome: { outcome: "cancelled" } };
575
+ // No handler: auto-approve, preferring session-scope / always options.
576
+ const opts = (params.options as Array<{ optionId: string; name?: string; kind?: string }>) ?? [];
577
+ result = pickAllowOption(opts);
523
578
  } else {
524
579
  // We advertise no fs/terminal capabilities, so the agent shouldn't ask.
525
580
  throw new GrokError(`unsupported client method: ${method}`, -32601);