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 +5 -0
- package/CHANGELOG.md +86 -0
- package/README.md +2 -2
- package/package.json +1 -1
- package/scripts/_push-branch.mjs +89 -0
- package/scripts/_release-version.mjs +87 -0
- package/src/app/accounts.ts +49 -12
- package/src/app/auth-service.ts +4 -1
- package/src/bot/account-rotator.ts +79 -52
- package/src/bot/bot.ts +4 -2
- package/src/bot/handlers/accounts.ts +22 -5
- package/src/bot/permission-service.ts +59 -15
- package/src/bot/reauth-controller.ts +3 -3
- package/src/bot/session-runtime.ts +50 -30
- package/src/config.ts +9 -0
- package/src/grok/client.ts +72 -17
- package/src/render/tool-call-detail.ts +170 -0
- package/src/render/tool-call.ts +259 -99
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* PermissionService — turns Grok's ACP `session/request_permission` into
|
|
3
|
-
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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
|
-
/**
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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 % (0
|
|
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
|
|
178
|
-
* reasoning)
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
|
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]; //
|
|
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
|
|
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"
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
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 ||
|
|
966
|
-
|
|
967
|
-
|
|
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) + "
|
|
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),
|
package/src/grok/client.ts
CHANGED
|
@@ -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
|
-
|
|
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.
|
|
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
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
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
|
-
|
|
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
|
|
519
|
-
const opts = (params.options as Array<{ optionId: string }>) ?? [];
|
|
520
|
-
result = opts
|
|
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);
|