grok-telegram-bot 2.2.4 → 2.3.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
@@ -132,9 +132,12 @@ QUIET_NOTIFICATIONS=true
132
132
  # LOG_DIR=
133
133
  # LOG_FILE=
134
134
 
135
- # ── Voice messages (speech-to-text) ──────────────────────────────────────────
136
- # Any OpenAI/Whisper-compatible transcription endpoint. xAI offers one too:
137
- # STT_API_URL=https://api.x.ai/v1
135
+ # ── Voice messages (optional) ────────────────────────────────────────────────
136
+ # Voice is ONLY available when STT is configured. Grok Build CLI over ACP does
137
+ # not accept audio content blocks, so without STT the bot replies that voice
138
+ # isn't configured. Use any OpenAI/Whisper-compatible transcription endpoint
139
+ # (including xAI STT if your gateway exposes /audio/transcriptions):
140
+ # STT_API_URL=https://api.openai.com/v1
138
141
  # STT_API_KEY=
139
142
  # STT_MODEL=whisper-1
140
- # STT_LANGUAGE=
143
+ # STT_LANGUAGE= # blank = auto-detect
package/CHANGELOG.md CHANGED
@@ -7,29 +7,57 @@ 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]
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
10
+ ## [Unreleased]
11
+
12
+ ## [2.3.0] - 2026-07-17
13
+
14
+ ### Added
15
+
16
+ - **⚡ Instant project switch.** Choosing a project no longer waits on ACP
17
+ `session/new`. The bot switches cwd immediately and creates the live session
18
+ lazily on the first message. Reuses an existing controlled runtime for the
19
+ same path when available.
20
+ - **🖼 Image-output prompt rules.** When agent image return is enabled, prompts
21
+ include rules to keep generated files in the Grok session media folder
22
+ (`images/` / `assets/`) or project `images/`, and to report absolute paths.
23
+
24
+ ### Fixed
25
+
26
+ - **🖼 Agent images as Telegram files.** Generated images are sent with
27
+ `sendDocument` (downloadable file) instead of compressed `sendPhoto`. Discovery
28
+ now scans Grok session `images/` **and** `assets/`, plus project `images/` and
29
+ tool/text paths (Imagine was writing under `images/` while the bot only scanned
30
+ `assets/`).
31
+ - **⏱ Stale callback queries.** Callback answers are safe/idempotent; expired
32
+ Telegram query IDs no longer spam ERROR logs. Handlers answer before slow ACP
33
+ work (stop / model / agent / project open).
34
+ - **🎙 Voice requires STT again.** Voice / audio / video notes only work when
35
+ `STT_API_URL` is set (optional `STT_API_KEY`). Without STT the bot replies that
36
+ voice isn't configured — the CLI cannot accept ACP audio blocks.
37
+ - **🔄 Non-blocking session handoff.** Backgrounding a session no longer waits
38
+ on streamer finalize / `prepare()` mid-switch, so the bot stays responsive.
39
+
40
+ ## [2.2.4] - 2026-07-16
41
+
42
+ ### Fixed
43
+
44
+ - **🚫 Rotate immediately on Grok access denial.** API `403 Forbidden` / `Access
45
+ denied` responses now bypass same-account retry backoff, mark the failed
46
+ login with `⚠️`, and rotate to the next eligible saved account. If the active
47
+ host login was not already saved (for example after an external sign-in or
48
+ token refresh), it is captured first so the warning is visible and persists.
49
+ - **🔐 Isolated Grok Telegram identity.** The Grok bot now takes its Telegram
50
+ token from its own instance `.env` before considering an inherited process
51
+ environment value. This prevents a machine-wide token for a sibling bot from
52
+ making Grok poll as Codex/Kiro/OpenCode and causing Telegram conflicts.
53
+ - **⚠️ Persistent account warnings.** Account access/quota failures are shown
54
+ in `/accounts`, excluded from later automatic rotations, and can be manually
55
+ re-enabled after the account is repaired.
56
+ - **🛟 No false success for silent ACP turns.** A Grok ACP completion with no
57
+ text, thought, or tool update is now treated as a recoverable agent failure
58
+ instead of reporting `Done · no text output`.
59
+
60
+ ## [2.2.3] - 2026-07-13
33
61
 
34
62
  ### Fixed
35
63
 
@@ -733,6 +761,9 @@ from a single chat and switch between them, on a redesigned, compact menu.
733
761
  diffs, MarkdownV2 rendering, scheduled tasks, multi-image prompts, and a
734
762
  cross-platform 24/7 background service.
735
763
 
764
+ [2.3.0]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.3.0
765
+ [2.2.4]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.2.4
766
+ [2.2.3]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.2.3
736
767
  [2.2.2]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.2.2
737
768
  [2.2.1]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.2.1
738
769
  [2.2.0]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.2.0
package/README.md CHANGED
@@ -275,16 +275,25 @@ caption. The bot downloads them and attaches them all to the prompt as image
275
275
  content blocks, so the agent can analyze them together. Images sent while Grok
276
276
  is busy are queued with your next turn.
277
277
 
278
- **Images come back too:** when the agent produces images during a turn (e.g.
279
- takes screenshots while testing an app), the bot detects the freshly-written
280
- files and sends them back to Telegram automatically (`SEND_AGENT_IMAGES`).
278
+ **Images come back too:** when the agent produces images during a turn — Imagine
279
+ `image_gen` / `image_edit` (written under
280
+ `~/.grok/sessions/<cwd>/<session>/images/` and `…/assets/`), screenshots, or
281
+ files under `<project>/images/` — the bot detects the freshly-written files and
282
+ sends them back to Telegram as **downloadable documents** (`SEND_AGENT_IMAGES`,
283
+ default on). Prompts also include image-output rules so the agent keeps gens in
284
+ the session media folder and reports absolute paths.
281
285
 
282
286
  ## 🎙 Sending voice
283
287
 
284
- Send a voice note (or audio file) and the bot transcribes it and runs it as a
285
- prompt. Configure any OpenAI/Whisper-compatible endpoint via `STT_API_URL` in
286
- `.env`; leave `STT_LANGUAGE` blank for automatic detection (English, Russian,
287
- Romanian/Moldovan, and ~100 more).
288
+ Send a voice note, audio file, or video note and the bot runs it as a prompt
289
+ **only when STT is configured**.
290
+
291
+ Set any OpenAI/Whisper-compatible endpoint via `STT_API_URL` (and `STT_API_KEY`
292
+ if needed). The bot transcribes, shows the quote, and submits plain text.
293
+ Without `STT_API_URL`, voice is rejected with a short “not configured” message
294
+ — Grok Build CLI rejects ACP `audio` content blocks, so raw audio cannot be
295
+ heard by the agent. Leave `STT_LANGUAGE` blank for automatic detection
296
+ (English, Russian, Romanian/Moldovan, and ~100 more).
288
297
 
289
298
  ## 📎 Sending files
290
299
 
@@ -530,7 +539,8 @@ user. See [SECURITY.md](./SECURITY.md) for the full model.
530
539
  - [x] Multi-image prompts (albums)
531
540
  - [x] Combined, throttled output (anti-spam)
532
541
  - [x] 24/7 cross-platform background service
533
- - [x] Voice messages → speech-to-text → prompt (multi-language)
542
+ - [x] Voice messages → STT (`STT_API_URL`) text prompt (disabled without STT)
543
+ - [x] Agent-generated images → Telegram document files (session `images/`/`assets/`)
534
544
  - [x] Context-usage % in the status panel
535
545
  - [x] Inline approvals — approve/deny risky tools from buttons (non trust-all mode)
536
546
  - [x] Session auto-approve + pinned permission prompts
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "grok-telegram-bot",
3
- "version": "2.2.4",
3
+ "version": "2.3.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",
package/src/app/stt.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  /**
2
2
  * Speech-to-text via any OpenAI/Whisper-compatible endpoint.
3
3
  *
4
+ * Required for Telegram voice / audio / video notes: Grok Build CLI over ACP
5
+ * does not accept audio content blocks, so without STT_API_URL the bot rejects
6
+ * voice with a "not configured" message instead of attaching unusable audio.
7
+ *
4
8
  * Language handling: when STT_LANGUAGE is unset, Whisper auto-detects the
5
- * spoken language (covers English, Russian, Romanian/Moldovan, and ~100 more),
6
- * so multilingual voice notes work out of the box.
9
+ * spoken language (covers English, Russian, Romanian/Moldovan, and ~100 more).
7
10
  */
8
11
  import { createLogger } from "../logger.js";
9
12
 
package/src/app/types.ts CHANGED
@@ -37,10 +37,24 @@ export interface PromptImage {
37
37
  mimeType: string;
38
38
  }
39
39
 
40
- /** A unit of work submitted to the agent: text plus optional images. */
40
+ /**
41
+ * A file the agent can open (ACP `resource_link`). Used for voice notes and
42
+ * other binaries when we cannot embed the media as a first-class content type
43
+ * (Grok CLI currently rejects ACP `audio` blocks).
44
+ */
45
+ export interface PromptResourceLink {
46
+ uri: string;
47
+ name: string;
48
+ mimeType?: string;
49
+ size?: number;
50
+ }
51
+
52
+ /** A unit of work submitted to the agent: text plus optional images / links. */
41
53
  export interface PromptInput {
42
54
  text: string;
43
55
  images: PromptImage[];
56
+ /** Optional file references (voice notes, binaries). */
57
+ resourceLinks?: PromptResourceLink[];
44
58
  /** Telegram message id of the prompt, so the reply threads to it. */
45
59
  replyTo?: number;
46
60
  /**
@@ -52,5 +66,5 @@ export interface PromptInput {
52
66
  }
53
67
 
54
68
  export function textPrompt(text: string, replyTo?: number, quotedText?: string): PromptInput {
55
- return { text, images: [], replyTo, quotedText };
69
+ return { text, images: [], resourceLinks: [], replyTo, quotedText };
56
70
  }
package/src/bot/bot.ts CHANGED
@@ -20,6 +20,7 @@ import { TaskRunner } from "../tasks/runner.js";
20
20
  import { Scheduler } from "../tasks/scheduler.js";
21
21
  import { TaskStore } from "../tasks/store.js";
22
22
  import { createAuthMiddleware } from "./auth.js";
23
+ import { isStaleCallbackError, safeCallbackMiddleware } from "./callback.js";
23
24
  import { COMMANDS } from "./commands.js";
24
25
  import { type BotDeps, MenuCache } from "./deps.js";
25
26
  import { registerControl } from "./handlers/control.js";
@@ -139,6 +140,9 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
139
140
  bot.on("message:pinned_message", (ctx) => void ctx.deleteMessage().catch(() => {}));
140
141
 
141
142
  bot.use(createAuthMiddleware(cfg));
143
+ // Answer callback queries safely: never throw on stale IDs, auto-answer if a
144
+ // handler forgets (prevents the loading spinner + unhandled 400 noise).
145
+ bot.use(safeCallbackMiddleware());
142
146
 
143
147
  // Keep history clean: after handling, delete the user's command (/…) and
144
148
  // persistent-bar button taps. Plain prompts and wizard input are kept.
@@ -188,6 +192,12 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
188
192
  registerMessages(bot, deps); // catch-all text prompt — keep last
189
193
 
190
194
  bot.catch((err) => {
195
+ // Stale callback answers are expected when the bot was busy past Telegram's
196
+ // ~timeout — middleware already swallows most of them; keep noise out of ERROR.
197
+ if (isStaleCallbackError(err.error)) {
198
+ log.debug("stale callback query:", err.error instanceof Error ? err.error.message : err.error);
199
+ return;
200
+ }
191
201
  log.error("unhandled bot error:", err.error instanceof Error ? err.error.message : err.error);
192
202
  });
193
203
 
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Callback-query helpers — Telegram requires answerCallbackQuery within a few
3
+ * seconds of the tap. Stale/expired IDs throw 400; without a catch those become
4
+ * unhandled bot errors and the client spinner never clears.
5
+ */
6
+ import { type Context, type MiddlewareFn, GrammyError } from "grammy";
7
+
8
+ /** True when Telegram rejected the answer because the query expired or is invalid. */
9
+ export function isStaleCallbackError(err: unknown): boolean {
10
+ if (!(err instanceof GrammyError)) return false;
11
+ if (err.error_code !== 400 && err.error_code !== 440) return false;
12
+ return /query is too old|query ID is invalid|response timeout expired/i.test(err.description);
13
+ }
14
+
15
+ type AnswerParams = Parameters<Context["answerCallbackQuery"]>[0];
16
+
17
+ /**
18
+ * Idempotent, non-throwing answer for a single handler (when middleware is not
19
+ * in play). Safe to call after slow work — stale queries are swallowed.
20
+ */
21
+ export async function safeAnswerCallbackQuery(ctx: Context, params?: AnswerParams): Promise<boolean> {
22
+ try {
23
+ await ctx.answerCallbackQuery(params);
24
+ return true;
25
+ } catch (err) {
26
+ if (isStaleCallbackError(err)) return false;
27
+ throw err;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Patch every callback update so:
33
+ * 1. answerCallbackQuery never throws on stale/expired query IDs,
34
+ * 2. double-answers are no-ops,
35
+ * 3. if a handler forgets to answer, we answer once in `finally` so the
36
+ * client spinner always stops (best-effort; may already be too late).
37
+ */
38
+ export function safeCallbackMiddleware(): MiddlewareFn<Context> {
39
+ return async (ctx, next) => {
40
+ if (!ctx.callbackQuery) {
41
+ await next();
42
+ return;
43
+ }
44
+
45
+ let answered = false;
46
+ const original = ctx.answerCallbackQuery.bind(ctx);
47
+
48
+ ctx.answerCallbackQuery = (async (params?: AnswerParams) => {
49
+ if (answered) return true;
50
+ answered = true;
51
+ try {
52
+ return await original(params);
53
+ } catch (err) {
54
+ if (isStaleCallbackError(err)) return true;
55
+ // Leave answered=true so a retry storm can't spam Telegram; surface
56
+ // unexpected errors to bot.catch for visibility.
57
+ throw err;
58
+ }
59
+ }) as Context["answerCallbackQuery"];
60
+
61
+ try {
62
+ await next();
63
+ } finally {
64
+ if (!answered) {
65
+ try {
66
+ await original();
67
+ } catch {
68
+ /* stale or network — spinner already gone */
69
+ }
70
+ answered = true;
71
+ }
72
+ }
73
+ };
74
+ }
@@ -79,20 +79,69 @@ export class ChatController {
79
79
  }));
80
80
  }
81
81
 
82
- /** Start a brand-new session and bring it to the foreground. */
82
+ /** Start a brand-new session and bring it to the foreground.
83
+ * Always binds a live ACP session (`session/new`) — use {@link switchProject}
84
+ * when you only need to change the working directory (instant). */
83
85
  async addNew(cwd: string, projectName?: string): Promise<SessionRuntime> {
84
86
  this.ensureRestored();
85
87
  const prevFg = this.fg;
86
88
  const rt = this.create({ cwd, projectName });
87
89
  this.runtimes.push(rt);
88
90
  this.fg = rt;
89
- await this.background(prevFg);
91
+ // Fire-and-forget: finalizing the previous streamer must not block the new
92
+ // session bind (that was a major source of "bot freezes on switch").
93
+ void this.background(prevFg);
90
94
  await rt.startNewSession(cwd, projectName);
91
95
  this.markSeen(rt);
92
96
  this.persist();
93
97
  return rt;
94
98
  }
95
99
 
100
+ /**
101
+ * Switch the chat to a project directory **without** waiting on ACP.
102
+ * - Reuses an existing controlled runtime for the same path when possible.
103
+ * - Does **not** call `session/new` — the live session is created lazily on
104
+ * the first prompt / prepare (via `ensureSession`).
105
+ * This keeps the project picker responsive even while another turn is running.
106
+ */
107
+ async switchProject(cwd: string, projectName?: string): Promise<SessionRuntime> {
108
+ this.ensureRestored();
109
+ const key = normPath(cwd);
110
+ const same = this.runtimes.filter((r) => normPath(r.cwd) === key);
111
+ // Prefer the current FG if it already points here, else the most recent match.
112
+ const existing = same.find((r) => r === this.fg) ?? same.at(-1);
113
+
114
+ if (existing) {
115
+ if (projectName) existing.projectName = projectName;
116
+ if (existing === this.fg) {
117
+ this.persist();
118
+ return existing;
119
+ }
120
+ if (existing.sessionId) {
121
+ // Fast path: switchTo no longer awaits ACP re-bind.
122
+ const sw = await this.switchTo(existing.sessionId);
123
+ return sw?.rt ?? existing;
124
+ }
125
+ void this.background(this.fg);
126
+ this.fg = existing;
127
+ await existing.setForeground(true);
128
+ this.persist();
129
+ return existing;
130
+ }
131
+
132
+ const prevFg = this.fg;
133
+ const rt = this.create({ cwd, projectName });
134
+ this.runtimes.push(rt);
135
+ this.fg = rt;
136
+ void this.background(prevFg);
137
+ // Drop other never-used project placeholders (no session yet) so rapid
138
+ // project browsing can't accumulate infinite idle runtimes/listeners.
139
+ this.pruneUnusedPlaceholders(rt);
140
+ // No startNewSession — sessionId stays undefined until the first message.
141
+ this.persist();
142
+ return rt;
143
+ }
144
+
96
145
  /**
97
146
  * Connect to a session with resume-or-fork semantics (used by /sessions),
98
147
  * adding it as a controlled session and bringing it to the foreground.
@@ -114,7 +163,7 @@ export class ChatController {
114
163
  const rt = this.create({ cwd, projectName, sessionId });
115
164
  this.runtimes.push(rt);
116
165
  this.fg = rt;
117
- await this.background(prevFg);
166
+ void this.background(prevFg);
118
167
  const result = await rt.attach(sessionId, cwd, projectName, priorEntries);
119
168
  this.markSeen(rt);
120
169
  this.persist();
@@ -131,8 +180,8 @@ export class ChatController {
131
180
  const rt = this.create({ cwd, projectName, sessionId });
132
181
  this.runtimes.push(rt);
133
182
  this.fg = rt;
134
- await this.background(prevFg);
135
- await rt.prepare().catch(() => {});
183
+ void this.background(prevFg);
184
+ // Lazy re-bind on first prompt (rebindPending); don't block the resume UI.
136
185
  const path = this.store.jsonlPath(sessionId);
137
186
  const unread = readHistory(path, 12);
138
187
  this.lastRead.set(sessionId, jsonlSize(path));
@@ -148,10 +197,12 @@ export class ChatController {
148
197
  if (rt === this.fg) {
149
198
  return { rt, sessionId, projectName: rt.projectName, busy: rt.isBusy, unread: [], firstView: false, alreadyForeground: true };
150
199
  }
151
- await this.background(this.fg);
200
+ void this.background(this.fg);
152
201
  this.fg = rt;
153
202
  await rt.setForeground(true);
154
- await rt.prepare().catch(() => {});
203
+ // Do NOT await prepare()/loadSession here — re-bind is lazy on the next
204
+ // prompt (rebindPending). Awaiting ACP mid-switch freezes the bot when the
205
+ // agent is busy with another turn.
155
206
 
156
207
  const path = this.store.jsonlPath(sessionId);
157
208
  const seen = this.lastRead.get(sessionId);
@@ -222,8 +273,23 @@ export class ChatController {
222
273
  seen.add(cs.sessionId);
223
274
  this.runtimes.push(this.create({ cwd: cs.projectPath, projectName: cs.projectName, sessionId: cs.sessionId }));
224
275
  }
276
+ // Lazy project switches persist projectPath without a sessionId. If the
277
+ // saved project is not among controlled sessions, recreate an unbound FG
278
+ // so a restart lands on the project the user last chose.
279
+ if (s.projectPath) {
280
+ const key = normPath(s.projectPath);
281
+ const hasProject = this.runtimes.some((r) => normPath(r.cwd) === key);
282
+ if (!hasProject) {
283
+ this.runtimes.push(this.create({ cwd: s.projectPath, projectName: s.projectName }));
284
+ }
285
+ }
225
286
  if (this.runtimes.length > 0) {
226
- const fg = this.runtimes.find((r) => r.sessionId === s.foregroundSessionId) ?? this.runtimes[0]!;
287
+ let fg = this.runtimes.find((r) => r.sessionId && r.sessionId === s.foregroundSessionId);
288
+ if (!fg && s.projectPath) {
289
+ const key = normPath(s.projectPath);
290
+ fg = this.runtimes.find((r) => normPath(r.cwd) === key);
291
+ }
292
+ fg = fg ?? this.runtimes[0]!;
227
293
  for (const r of this.runtimes) void r.setForeground(r === fg);
228
294
  this.fg = fg;
229
295
  }
@@ -284,6 +350,17 @@ export class ChatController {
284
350
  await rt.setForeground(false);
285
351
  }
286
352
 
353
+ /** Remove idle runtimes that never bound an ACP session (lazy project taps). */
354
+ private pruneUnusedPlaceholders(keep: SessionRuntime): void {
355
+ for (let i = this.runtimes.length - 1; i >= 0; i--) {
356
+ const r = this.runtimes[i]!;
357
+ if (r === keep || r === this.fg) continue;
358
+ if (r.sessionId || r.isBusy) continue;
359
+ r.dispose();
360
+ this.runtimes.splice(i, 1);
361
+ }
362
+ }
363
+
287
364
  private markSeen(rt: SessionRuntime): void {
288
365
  if (rt.sessionId) this.lastRead.set(rt.sessionId, jsonlSize(this.store.jsonlPath(rt.sessionId)));
289
366
  }
@@ -315,3 +392,8 @@ export class ChatController {
315
392
  });
316
393
  }
317
394
  }
395
+
396
+ /** Path key for project matching (case / separators / trailing slash). */
397
+ function normPath(p: string): string {
398
+ return p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
399
+ }
@@ -56,8 +56,10 @@ export function registerMenu(bot: Bot, deps: BotDeps): void {
56
56
  bot.callbackQuery(/^agent:set:(\d+)$/, async (ctx) => {
57
57
  const mode = deps.acp.availableModes[Number(ctx.match![1])];
58
58
  if (!mode) return void ctx.answerCallbackQuery({ text: "Expired, tap Agent again." });
59
+ // Answer before ACP so a slow setMode never expires the callback query.
60
+ await ctx.answerCallbackQuery({ text: `\u{1F916} Agent: ${mode.name}` });
59
61
  await deps.registry.get(ctx.chat!.id).setAgentPref(mode.id);
60
- await confirm(ctx, deps, `\u{1F916} Agent: ${mode.name}`);
62
+ await confirmUi(ctx, deps);
61
63
  });
62
64
 
63
65
  // ── Reasoning ──────────────────────────────────────────────────────────────
@@ -71,12 +73,17 @@ export function registerMenu(bot: Bot, deps: BotDeps): void {
71
73
  bot.callbackQuery(/^model:set:(\d+)$/, async (ctx) => {
72
74
  const entry = deps.acp.availableModels[Number(ctx.match![1])];
73
75
  if (!entry) return void ctx.answerCallbackQuery({ text: "Expired, tap Model again." });
76
+ await ctx.answerCallbackQuery({ text: `\u{1F9E9} Model: ${entry.name}` });
74
77
  const res = await deps.registry.get(ctx.chat!.id).setModelPref(entry.modelId);
75
- await confirm(ctx, deps, res.ok ? `\u{1F9E9} Model: ${entry.name}` : `\u26A0\uFE0F Model set failed: ${res.error}`);
78
+ if (!res.ok) {
79
+ await ctx.reply(`\u26A0\uFE0F Model set failed: ${res.error}`).catch(() => {});
80
+ }
81
+ await confirmUi(ctx, deps);
76
82
  });
77
83
  bot.callbackQuery("model:clear", async (ctx) => {
84
+ await ctx.answerCallbackQuery({ text: "\u{1F9E9} Model: default" });
78
85
  await deps.registry.get(ctx.chat!.id).setModelPref("");
79
- await confirm(ctx, deps, "\u{1F9E9} Model: default");
86
+ await confirmUi(ctx, deps);
80
87
  });
81
88
  }
82
89
 
@@ -143,15 +150,25 @@ async function dispatchMenu(ctx: Context, deps: BotDeps, action: string): Promis
143
150
  } catch (e) {
144
151
  return void ctx.reply(`\u274C ${(e as Error).message}`);
145
152
  }
146
- case "stop":
147
- return void ctx.answerCallbackQuery({ text: (await rt.cancel()) ? "Cancelling\u2026" : "Nothing is running" });
153
+ case "stop": {
154
+ // Answer first so a slow cancel never times out the callback query.
155
+ await ctx.answerCallbackQuery({ text: rt.isBusy ? "Cancelling\u2026" : "Nothing is running" });
156
+ if (rt.isBusy) await rt.cancel();
157
+ return;
158
+ }
148
159
  default:
149
160
  return void ctx.answerCallbackQuery();
150
161
  }
151
162
  }
152
163
 
153
164
  async function confirm(ctx: Context, deps: BotDeps, text: string): Promise<void> {
165
+ // Toast first (callback must be answered within ~seconds), then UI updates.
154
166
  await ctx.answerCallbackQuery({ text });
167
+ await confirmUi(ctx, deps);
168
+ }
169
+
170
+ /** Refresh status + reopen the main menu after a preference change. */
171
+ async function confirmUi(ctx: Context, deps: BotDeps): Promise<void> {
155
172
  try {
156
173
  await ctx.deleteMessage();
157
174
  } catch {
@@ -60,8 +60,11 @@ export async function sendProjectMenu(
60
60
  * "last used" is the latest of its directory mtime and the newest session
61
61
  * opened in it, so the project you worked in most recently floats to the top. */
62
62
  function sortByRecency(entries: ProjectEntry[], deps: BotDeps): ProjectEntry[] {
63
+ if (entries.length === 0) return entries;
64
+ // Cap the session scan — full directory walks get expensive as history grows,
65
+ // and only the freshest sessions matter for ranking.
63
66
  const recencyByCwd = new Map<string, number>();
64
- for (const s of deps.store.list(300)) {
67
+ for (const s of deps.store.list(80)) {
65
68
  const key = normCwd(s.cwd);
66
69
  if (!key) continue;
67
70
  const ms = Date.parse(s.updatedAt);
@@ -87,7 +90,8 @@ export async function showProjects(ctx: Context, deps: BotDeps, query?: string):
87
90
  if (create) {
88
91
  try {
89
92
  const entry = deps.projects.create(create[1]!);
90
- await deps.registry.controller(ctx.chat!.id).addNew(entry.path, entry.name);
93
+ // Instant switch — ACP session is created on the first message.
94
+ await deps.registry.controller(ctx.chat!.id).switchProject(entry.path, entry.name);
91
95
  await refreshMenu(ctx, deps, `\u2705 Created and opened ${entry.name}\n${entry.path} \u2014 send a message.`);
92
96
  } catch (e) {
93
97
  await deps.ephemeral.open(ctx);
@@ -132,7 +136,7 @@ async function openProjectPath(ctx: Context, deps: BotDeps, raw: string): Promis
132
136
  await deps.ephemeral.open(ctx);
133
137
  const name = basename(dir) || dir;
134
138
  try {
135
- await deps.registry.controller(ctx.chat!.id).addNew(dir, name);
139
+ await deps.registry.controller(ctx.chat!.id).switchProject(dir, name);
136
140
  await refreshMenu(ctx, deps, `\u{1F4C1} Now working in ${name}\n${dir} \u2014 send a message.`);
137
141
  } catch (e) {
138
142
  await deps.ephemeral.reply(ctx, `\u274C Could not open ${dir}: ${(e as Error).message}`);
@@ -171,10 +175,12 @@ export function registerProjects(bot: Bot, deps: BotDeps): void {
171
175
  await ctx.answerCallbackQuery({ text: "Selection expired, run /projects again." });
172
176
  return;
173
177
  }
174
- await ctx.answerCallbackQuery();
178
+ // Answer immediately (before any work) so Telegram never times out the query.
179
+ await ctx.answerCallbackQuery({ text: `Opening ${entry.name}\u2026` });
175
180
  await deps.ephemeral.clear(ctx.chat!.id); // remove the project picker
176
181
  try {
177
- await deps.registry.controller(ctx.chat!.id).addNew(entry.path, entry.name);
182
+ // Instant: no ACP session/new — live session is created on first message.
183
+ await deps.registry.controller(ctx.chat!.id).switchProject(entry.path, entry.name);
178
184
  await refreshMenu(ctx, deps, `\u{1F4C1} Now working in ${entry.name} \u2014 send a message.`);
179
185
  } catch (err) {
180
186
  await ctx.reply(`\u274C Could not open ${entry.name}: ${(err as Error).message}`);
@@ -1,6 +1,10 @@
1
1
  /**
2
2
  * Voice & audio handler — transcribes Telegram voice notes / audio files to
3
3
  * text (any language) and submits them as prompts.
4
+ *
5
+ * Requires STT_API_URL (and optionally STT_API_KEY). Grok Build CLI over ACP
6
+ * does not accept audio content blocks, so without an STT endpoint voice is
7
+ * disabled rather than attaching raw audio the agent cannot hear.
4
8
  */
5
9
  import type { Bot, Context } from "grammy";
6
10
  import { textPrompt } from "../../app/types.js";
@@ -18,7 +22,9 @@ export function registerVoice(bot: Bot, deps: BotDeps): void {
18
22
  return;
19
23
  }
20
24
  if (!deps.stt.enabled) {
21
- await ctx.reply("\u{1F399} Voice isn't configured. Set STT_API_URL (and STT_API_KEY) in .env.");
25
+ await ctx.reply(
26
+ "\u{1F399} Voice isn't configured. Set STT_API_URL (and STT_API_KEY if needed) in .env.",
27
+ );
22
28
  return;
23
29
  }
24
30
  await ctx.replyWithChatAction("typing").catch(() => {});
@@ -41,9 +47,20 @@ export function registerVoice(bot: Bot, deps: BotDeps): void {
41
47
  }
42
48
  };
43
49
 
44
- bot.on("message:voice", (ctx) => handle(ctx, ctx.message.voice.file_id, ctx.message.voice.mime_type || "audio/ogg", "voice.ogg"));
45
- bot.on("message:audio", (ctx) => handle(ctx, ctx.message.audio.file_id, ctx.message.audio.mime_type || "audio/mpeg", ctx.message.audio.file_name || "audio.mp3"));
46
- bot.on("message:video_note", (ctx) => handle(ctx, ctx.message.video_note.file_id, "video/mp4", "note.mp4"));
50
+ bot.on("message:voice", (ctx) =>
51
+ handle(ctx, ctx.message.voice.file_id, ctx.message.voice.mime_type || "audio/ogg", "voice.ogg"),
52
+ );
53
+ bot.on("message:audio", (ctx) =>
54
+ handle(
55
+ ctx,
56
+ ctx.message.audio.file_id,
57
+ ctx.message.audio.mime_type || "audio/mpeg",
58
+ ctx.message.audio.file_name || "audio.mp3",
59
+ ),
60
+ );
61
+ bot.on("message:video_note", (ctx) =>
62
+ handle(ctx, ctx.message.video_note.file_id, "video/mp4", "note.mp4"),
63
+ );
47
64
  }
48
65
 
49
66
  async function download(ctx: Context, fileId: string, token: string): Promise<Buffer | undefined> {
@@ -1,30 +1,125 @@
1
1
  /**
2
- * Agent image return — detects image files the agent produced this turn
3
- * (screenshots, diagrams…) from its output and tool inputs, and sends them
4
- * back to Telegram. Only fresh files (modified during the turn) are sent.
2
+ * Agent image return — finds image files the agent produced this turn
3
+ * (Imagine `image_gen` / `image_edit`, screenshots, diagrams…) and sends them
4
+ * back to Telegram as **downloadable files** (`sendDocument`).
5
+ *
6
+ * Discovery sources:
7
+ * 1. Paths mentioned in agent text / tool inputs (project-relative or absolute)
8
+ * 2. Fresh files under the Grok session media dirs
9
+ * (`~/.grok/sessions/<encoded-cwd>/<sessionId>/images/` and `…/assets/`)
10
+ * 3. Fresh files under `<cwd>/images/` (common short path reported by tools)
5
11
  */
6
12
  import { type Api, InputFile } from "grammy";
7
- import { existsSync, statSync } from "node:fs";
13
+ import { existsSync, readdirSync, statSync } from "node:fs";
14
+ import { homedir } from "node:os";
8
15
  import { basename, isAbsolute, join } from "node:path";
9
16
  import { createLogger } from "../logger.js";
10
17
 
18
+ // Re-export so existing import paths (`./image-return.js`) keep working.
19
+ export { IMAGE_OUTPUT_DIRECTIVE } from "../render/image-output.js";
20
+
11
21
  const log = createLogger("image-return");
12
22
 
13
- const PATH_RE = /[^\s"'`<>|()*\[\]]+\.(?:png|jpe?g|gif|webp|bmp)/gi;
14
- const PHOTO_EXT = new Set(["png", "jpg", "jpeg", "webp"]);
15
- const MAX_PHOTO_BYTES = 10 * 1024 * 1024;
23
+ /** Absolute / relative image path tokens in free text (Unix + Windows). */
24
+ const PATH_RE =
25
+ /(?:[A-Za-z]:[\\/]|\/|~[\\/]|\.{1,2}[\\/])?[^\s"'`<>|()*\[\]{}]+\.(?:png|jpe?g|gif|webp|bmp)/gi;
26
+ const IMAGE_EXT = new Set(["png", "jpg", "jpeg", "gif", "webp", "bmp"]);
16
27
  const MAX_FILE_BYTES = 45 * 1024 * 1024;
17
28
 
18
29
  /** Pull candidate image paths out of arbitrary text, resolved against cwd. */
19
30
  export function extractImagePaths(text: string, cwd: string): string[] {
20
31
  const out = new Set<string>();
21
32
  for (const m of text.matchAll(PATH_RE)) {
22
- const raw = m[0].replace(/[).,;:]+$/, "");
33
+ let raw = m[0].replace(/[).,;:]+$/, "");
34
+ if (raw.startsWith("~/") || raw.startsWith("~\\")) {
35
+ raw = join(homedir(), raw.slice(2));
36
+ }
23
37
  out.add(isAbsolute(raw) ? raw : join(cwd, raw));
24
38
  }
25
39
  return [...out];
26
40
  }
27
41
 
42
+ /**
43
+ * Grok stores per-session media under:
44
+ * ~/.grok/sessions/<encodeURIComponent(cwd)>/<sessionId>/{images,assets}/
45
+ * Imagine `image_gen` currently prefers `images/`; older runs used `assets/`.
46
+ */
47
+ export function grokSessionMediaRoot(cwd: string, sessionId: string): string {
48
+ return join(homedir(), ".grok", "sessions", encodeURIComponent(cwd), sessionId);
49
+ }
50
+
51
+ /** @deprecated Prefer grokSessionMediaDirs — kept for callers/tests that used assets. */
52
+ export function grokSessionAssetsDir(cwd: string, sessionId: string): string {
53
+ return join(grokSessionMediaRoot(cwd, sessionId), "assets");
54
+ }
55
+
56
+ /** Session folders where Imagine / tools drop generated images. */
57
+ export function grokSessionMediaDirs(cwd: string, sessionId: string): string[] {
58
+ const root = grokSessionMediaRoot(cwd, sessionId);
59
+ return [join(root, "images"), join(root, "assets")];
60
+ }
61
+
62
+ /** List image files under `dir` modified at/after `since` (non-recursive). */
63
+ export function listFreshImagesInDir(dir: string, since: number): string[] {
64
+ if (!existsSync(dir)) return [];
65
+ let names: string[];
66
+ try {
67
+ names = readdirSync(dir);
68
+ } catch {
69
+ return [];
70
+ }
71
+ const out: string[] = [];
72
+ for (const name of names) {
73
+ const path = join(dir, name);
74
+ const ext = name.toLowerCase().split(".").pop() ?? "";
75
+ if (!IMAGE_EXT.has(ext)) continue;
76
+ try {
77
+ const st = statSync(path);
78
+ if (!st.isFile() || st.size === 0 || st.size > MAX_FILE_BYTES) continue;
79
+ // 2s slack for clock skew / write completion.
80
+ if (st.mtimeMs < since - 2000) continue;
81
+ out.push(path);
82
+ } catch {
83
+ /* skip */
84
+ }
85
+ }
86
+ // Newest first so max-cap still keeps the latest gens.
87
+ return out.sort((a, b) => {
88
+ try {
89
+ return statSync(b).mtimeMs - statSync(a).mtimeMs;
90
+ } catch {
91
+ return 0;
92
+ }
93
+ });
94
+ }
95
+
96
+ /** Collect all image candidates for a turn from text + known asset locations. */
97
+ export function collectTurnImagePaths(opts: {
98
+ scanText: string;
99
+ cwd: string;
100
+ sessionId?: string;
101
+ since: number;
102
+ }): string[] {
103
+ const seen = new Set<string>();
104
+ const out: string[] = [];
105
+ const add = (paths: string[]) => {
106
+ for (const p of paths) {
107
+ if (seen.has(p)) continue;
108
+ seen.add(p);
109
+ out.push(p);
110
+ }
111
+ };
112
+
113
+ add(extractImagePaths(opts.scanText, opts.cwd));
114
+ add(listFreshImagesInDir(join(opts.cwd, "images"), opts.since));
115
+ if (opts.sessionId) {
116
+ for (const dir of grokSessionMediaDirs(opts.cwd, opts.sessionId)) {
117
+ add(listFreshImagesInDir(dir, opts.since));
118
+ }
119
+ }
120
+ return out;
121
+ }
122
+
28
123
  export interface SendImagesOptions {
29
124
  /** Only send files modified at/after this epoch ms (fresh this turn). */
30
125
  since: number;
@@ -32,9 +127,11 @@ export interface SendImagesOptions {
32
127
  already: Set<string>;
33
128
  /** Max images to send in this call. */
34
129
  max: number;
130
+ /** Optional Telegram message id to thread replies under. */
131
+ replyTo?: number;
35
132
  }
36
133
 
37
- /** Send the valid, fresh, not-yet-sent images. Returns how many were sent. */
134
+ /** Send the valid, fresh, not-yet-sent images as documents. Returns how many were sent. */
38
135
  export async function sendImages(
39
136
  api: Api,
40
137
  chatId: number,
@@ -42,6 +139,10 @@ export async function sendImages(
42
139
  opts: SendImagesOptions,
43
140
  ): Promise<number> {
44
141
  let sent = 0;
142
+ const replyExtra =
143
+ opts.replyTo !== undefined
144
+ ? { reply_parameters: { message_id: opts.replyTo, allow_sending_without_reply: true } }
145
+ : {};
45
146
  for (const path of paths) {
46
147
  if (sent >= opts.max) break;
47
148
  if (opts.already.has(path)) continue;
@@ -55,12 +156,15 @@ export async function sendImages(
55
156
  if (st.mtimeMs < opts.since - 2000) continue; // skip pre-existing files
56
157
  opts.already.add(path);
57
158
  try {
58
- const ext = path.toLowerCase().split(".").pop() ?? "";
59
- const asPhoto = PHOTO_EXT.has(ext) && st.size <= MAX_PHOTO_BYTES;
60
- const file = new InputFile(path);
61
- if (asPhoto) await api.sendPhoto(chatId, file, { caption: basename(path) });
62
- else await api.sendDocument(chatId, file, { caption: basename(path) });
159
+ // Always send as a document so Telegram delivers a downloadable file
160
+ // (not a compressed photo bubble).
161
+ const file = new InputFile(path, basename(path));
162
+ await api.sendDocument(chatId, file, {
163
+ caption: basename(path),
164
+ ...replyExtra,
165
+ });
63
166
  sent++;
167
+ log.debug(`sent document ${path}`);
64
168
  } catch (e) {
65
169
  log.debug(`failed to send ${path}:`, (e as Error).message);
66
170
  }
@@ -1,7 +1,7 @@
1
1
  /**
2
- * Build ACP prompt content blocks from a PromptInput (text + images), applying
3
- * the reasoning directive and any fork-priming context. Also merges multiple
4
- * queued inputs into one.
2
+ * Build ACP prompt content blocks from a PromptInput (text + images + optional
3
+ * resource links), applying the reasoning directive and any fork-priming
4
+ * context. Also merges multiple queued inputs into one.
5
5
  */
6
6
  import type { ContentBlock } from "../grok/types.js";
7
7
  import type { PromptInput } from "../app/types.js";
@@ -9,8 +9,10 @@ import type { PromptInput } from "../app/types.js";
9
9
  export interface ContentOptions {
10
10
  reasoning?: string;
11
11
  priming?: string;
12
- /** Appended at the very bottom so the agent emits a `{progress: N%}` marker. */
12
+ /** Appended so the agent emits a `{progress: N%}` marker. */
13
13
  progress?: string;
14
+ /** Appended so the agent keeps generated images in the session media folder. */
15
+ imageOutput?: string;
14
16
  }
15
17
 
16
18
  export function buildContentBlocks(input: PromptInput, opts: ContentOptions = {}): ContentBlock[] {
@@ -19,11 +21,23 @@ export function buildContentBlocks(input: PromptInput, opts: ContentOptions = {}
19
21
  for (const img of input.images) {
20
22
  blocks.push({ type: "image", data: img.data, mimeType: img.mimeType });
21
23
  }
24
+ for (const link of input.resourceLinks ?? []) {
25
+ blocks.push({
26
+ type: "resource_link",
27
+ uri: link.uri,
28
+ name: link.name,
29
+ mimeType: link.mimeType,
30
+ size: link.size,
31
+ });
32
+ }
22
33
 
23
34
  let text = input.text.trim();
24
35
  if (!text && input.images.length > 0) {
25
36
  text = input.images.length === 1 ? "Please analyze the attached image." : "Please analyze the attached images.";
26
37
  }
38
+ if (!text && (input.resourceLinks?.length ?? 0) > 0) {
39
+ text = "Please process the attached file(s).";
40
+ }
27
41
  if (input.quotedText?.trim()) {
28
42
  const quoted = input.quotedText.trim();
29
43
  const body = text || "(the user's reply carried no additional text)";
@@ -35,7 +49,11 @@ export function buildContentBlocks(input: PromptInput, opts: ContentOptions = {}
35
49
  if (opts.reasoning) {
36
50
  text = `(${opts.reasoning})\n\n${text}`;
37
51
  }
52
+ if (opts.imageOutput) {
53
+ text = `${text}\n\n${opts.imageOutput}`;
54
+ }
38
55
  if (opts.progress) {
56
+ // Progress last so its "marker is the final line" rule stays true.
39
57
  text = `${text}\n\n${opts.progress}`;
40
58
  }
41
59
 
@@ -43,7 +61,7 @@ export function buildContentBlocks(input: PromptInput, opts: ContentOptions = {}
43
61
  return blocks;
44
62
  }
45
63
 
46
- /** Merge queued inputs into a single prompt (concatenated text, all images). */
64
+ /** Merge queued inputs into a single prompt (concatenated text, all images/links). */
47
65
  export function mergeInputs(inputs: PromptInput[]): PromptInput {
48
66
  const quotes = inputs
49
67
  .map((i) => i.quotedText?.trim())
@@ -54,6 +72,7 @@ export function mergeInputs(inputs: PromptInput[]): PromptInput {
54
72
  .filter((t) => t.trim().length > 0)
55
73
  .join("\n\n"),
56
74
  images: inputs.flatMap((i) => i.images),
75
+ resourceLinks: inputs.flatMap((i) => i.resourceLinks ?? []),
57
76
  replyTo: inputs.find((i) => i.replyTo !== undefined)?.replyTo,
58
77
  quotedText: quotes.length > 0 ? [...new Set(quotes)].join("\n\n---\n\n") : undefined,
59
78
  };
@@ -7,8 +7,8 @@
7
7
  import { basename } from "node:path";
8
8
  import { type Api, InlineKeyboard } from "grammy";
9
9
  import {
10
- type GrokClient,
11
- isAccountRotationError,
10
+ type GrokClient,
11
+ isAccountRotationError,
12
12
  isContextExhaustedError,
13
13
  isTransientError,
14
14
  type SessionMetadata,
@@ -31,7 +31,8 @@ import { type FileOp, fileOpFromUpdate, mergeFileOp, summarizeFileOps, summarize
31
31
  import { isActiveStatus, renderSubagentTransition, statusKey } from "../render/subagent.js";
32
32
  import type { PendingStage, SubagentInfo } from "../grok/types.js";
33
33
  import { ResponseStreamer } from "../stream/streamer.js";
34
- import { extractImagePaths, sendImages } from "./image-return.js";
34
+ import { IMAGE_OUTPUT_DIRECTIVE } from "../render/image-output.js";
35
+ import { collectTurnImagePaths, sendImages } from "./image-return.js";
35
36
  import { buildContentBlocks, mergeInputs } from "./prompt-content.js";
36
37
  import {
37
38
  backoffSchedule,
@@ -99,10 +100,10 @@ export class SessionRuntime {
99
100
  private turnCount = 0;
100
101
  /** Telegram message id of the current turn's prompt, so replies thread to it. */
101
102
  private turnReplyTo: number | undefined;
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;
103
+ private imageScanText = "";
104
+ private sentImagesThisTurn = new Set<string>();
105
+ /** Monotonic count used to reject ACP "success" responses with no turn updates. */
106
+ private sessionUpdateCount = 0;
106
107
  private readonly listener: (sessionId: string, update: SessionUpdate) => void;
107
108
  private primingContext: string | undefined;
108
109
  private watcher: TailWatcher | undefined;
@@ -216,8 +217,11 @@ export class SessionRuntime {
216
217
  this.typing.stop();
217
218
  this.stopWatch();
218
219
  if (this.streamer) {
219
- await this.streamer.finalize().catch(() => {});
220
+ // Finalize off the critical path so project/session switches never wait
221
+ // on Telegram edits of the previous live stream.
222
+ const prev = this.streamer;
220
223
  this.streamer = undefined;
224
+ void prev.finalize().catch(() => {});
221
225
  }
222
226
  }
223
227
  this.changed();
@@ -514,6 +518,7 @@ export class SessionRuntime {
514
518
  const content = buildContentBlocks(input, {
515
519
  reasoning: reasoningDirective(this.reasoning),
516
520
  priming: this.primingContext,
521
+ imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
517
522
  progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
518
523
  });
519
524
  this.primingContext = undefined;
@@ -670,6 +675,7 @@ export class SessionRuntime {
670
675
  const forkContent = buildContentBlocks(input, {
671
676
  reasoning: reasoningDirective(this.reasoning),
672
677
  priming: transcript ? buildPriming(transcript) : undefined,
678
+ imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
673
679
  progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
674
680
  });
675
681
  return this.runPromptWithRetries(forkContent);
@@ -692,18 +698,18 @@ export class SessionRuntime {
692
698
  input: PromptInput,
693
699
  final: { result?: PromptResult; error?: Error; attempts: number },
694
700
  ): Promise<{ result?: PromptResult; error?: Error; attempts: number } | undefined> {
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 }[]);
701
+ const rotator = this.accountRotator;
702
+ if (!rotator?.enabled() || !final.error || this.cancelled) return undefined;
703
+ // A quota-exhausted or access-denied response cannot be recovered by retrying
704
+ // this login. Quarantine it before choosing targets, so later rotations do not
705
+ // cycle back to a known-bad account. This intentionally happens before the
706
+ // partial-stream guard: we must not retry/rotate a partial reply, but its
707
+ // account still needs to be skipped during a future rotation.
708
+ if (isAccountRotationError(final.error)) {
709
+ await rotator.markFailed(undefined, final.error.message);
710
+ }
711
+ if (this.streamer?.hasOutput ?? false) return undefined;
712
+ const targets = await rotator.targets().catch(() => [] as { id: string; label: string }[]);
707
713
  if (targets.length === 0) return undefined;
708
714
 
709
715
  const transcript = this.sessionId ? recentTranscript(this.cfg.sessionsDir, this.sessionId) : undefined;
@@ -736,25 +742,26 @@ export class SessionRuntime {
736
742
  const content = buildContentBlocks(input, {
737
743
  reasoning: reasoningDirective(this.reasoning),
738
744
  priming: transcript ? buildPriming(transcript) : undefined,
739
- progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
745
+ imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
746
+ progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
740
747
  });
741
748
  log.info(
742
749
  `chat ${this.chatId} auto-rotating to account ${t.label}` +
743
- (isAccountRotationError(failReason) ? " (previous account unavailable)" : ""),
750
+ (isAccountRotationError(failReason) ? " (previous account unavailable)" : ""),
744
751
  );
745
752
  // runPromptWithRetries already skips backoff for 402 / balance exhausted.
746
753
  last = await this.runPromptWithRetries(content);
747
- if (last.result && !this.cancelled) {
754
+ if (last.result && !this.cancelled) {
748
755
  if (this.foreground) {
749
756
  await this.notify(`\u2705 Recovered on ${t.label}.`, { replyTo: this.turnReplyTo });
750
757
  }
751
758
  return last;
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"}`);
759
+ }
760
+ if (last.error && isAccountRotationError(last.error)) {
761
+ await rotator.markFailed(t.id, last.error.message);
762
+ }
763
+ if (this.cancelled || (this.streamer?.hasOutput ?? false)) return last;
764
+ errors.push(`\u2022 ${t.label}: ${last.error?.message ?? "failed"}`);
758
765
  }
759
766
 
760
767
  // One full cycle done and still failing — stop with a combined report.
@@ -776,21 +783,21 @@ export class SessionRuntime {
776
783
  ): Promise<{ result?: PromptResult; error?: Error; attempts: number }> {
777
784
  const delays = this.cfg.promptRetryAttempts > 0 ? backoffSchedule(this.cfg.promptRetryAttempts) : [];
778
785
  const totalAttempts = delays.length + 1;
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 };
786
+ let attempt = 0;
787
+ for (;;) {
788
+ attempt++;
789
+ try {
790
+ const updatesBeforePrompt = this.sessionUpdateCount;
791
+ const result = await this.acp.prompt(this.sessionId!, content);
792
+ // A healthy ACP turn emits at least one session/update (text, thought,
793
+ // or tool event) before resolving session/prompt. Grok can otherwise
794
+ // report a successful end-turn after an upstream model failure; never
795
+ // present that as a completed user request.
796
+ await sleep(0);
797
+ if (this.sessionUpdateCount === updatesBeforePrompt) {
798
+ throw new Error("Empty agent response — Grok ended the turn without any output or tool activity");
799
+ }
800
+ return { result, attempts: attempt };
794
801
  } catch (err) {
795
802
  const error = err as Error;
796
803
  const canRecover = !this.cancelled && !(this.streamer?.hasOutput ?? false);
@@ -803,7 +810,7 @@ export class SessionRuntime {
803
810
  attempt <= delays.length &&
804
811
  canRecover &&
805
812
  !forkInstead &&
806
- !isAccountRotationError(error) &&
813
+ !isAccountRotationError(error) &&
807
814
  isTransientError(error);
808
815
  if (!willRetry) return { error, attempts: attempt };
809
816
  const waitMs = delays[attempt - 1]!;
@@ -859,6 +866,7 @@ export class SessionRuntime {
859
866
  const delays = this.cfg.promptRetryAttempts > 0 ? backoffSchedule(this.cfg.promptRetryAttempts) : [RETRY_BASE_MS];
860
867
  const resumeContent = buildContentBlocks(textPrompt(RESUME_INSTRUCTION), {
861
868
  reasoning: reasoningDirective(this.reasoning),
869
+ imageOutput: this.cfg.sendAgentImages ? IMAGE_OUTPUT_DIRECTIVE : undefined,
862
870
  progress: this.cfg.showProgress ? PROGRESS_DIRECTIVE : undefined,
863
871
  });
864
872
 
@@ -888,17 +896,26 @@ export class SessionRuntime {
888
896
  return last;
889
897
  }
890
898
 
891
- /** Send any fresh images the agent produced this turn (screenshots, etc.). */
899
+ /** Send any fresh images the agent produced this turn (Imagine, screenshots…). */
892
900
  private async sendTurnImages(): Promise<void> {
893
- if (!this.cfg.sendAgentImages || !this.imageScanText) return;
894
- const paths = extractImagePaths(this.imageScanText, this.cwd);
901
+ if (!this.cfg.sendAgentImages) return;
902
+ // Always check session images/ + assets/ even when the agent never named a
903
+ // path in text — image_gen writes under ~/.grok/sessions/.../images/.
904
+ const paths = collectTurnImagePaths({
905
+ scanText: this.imageScanText,
906
+ cwd: this.cwd,
907
+ sessionId: this.sessionId,
908
+ since: this.turnStartedAt,
909
+ });
895
910
  if (paths.length === 0) return;
896
911
  try {
897
- await sendImages(this.api, this.chatId, paths, {
912
+ const n = await sendImages(this.api, this.chatId, paths, {
898
913
  since: this.turnStartedAt,
899
914
  already: this.sentImagesThisTurn,
900
915
  max: this.cfg.agentImagesMax,
916
+ replyTo: this.turnReplyTo,
901
917
  });
918
+ if (n > 0) log.info(`chat ${this.chatId}: sent ${n} agent image file(s)`);
902
919
  } catch {
903
920
  /* non-fatal */
904
921
  }
@@ -980,10 +997,10 @@ export class SessionRuntime {
980
997
  void this.runTurn(batch);
981
998
  }
982
999
 
983
- private onUpdate(sessionId: string, update: SessionUpdate): void {
984
- if (!this.busy || sessionId !== this.sessionId) return;
985
- this.sessionUpdateCount++;
986
- const kind = update.sessionUpdate;
1000
+ private onUpdate(sessionId: string, update: SessionUpdate): void {
1001
+ if (!this.busy || sessionId !== this.sessionId) return;
1002
+ this.sessionUpdateCount++;
1003
+ const kind = update.sessionUpdate;
987
1004
 
988
1005
  // Accumulate the turn's file-change summary + image-scan text even when this
989
1006
  // session is in the background (its output isn't streamed here, but the
@@ -991,6 +1008,12 @@ export class SessionRuntime {
991
1008
  if (kind === "tool_call" || kind === "tool_call_update") {
992
1009
  if (update.rawInput) this.imageScanText += " " + JSON.stringify(update.rawInput);
993
1010
  if (update.title) this.imageScanText += " " + update.title;
1011
+ // Tool results often carry the saved path only in content_blocks (Imagine).
1012
+ if (Array.isArray(update.content_blocks)) {
1013
+ this.imageScanText += " " + JSON.stringify(update.content_blocks);
1014
+ }
1015
+ // Some agents put free-form result text on `content`.
1016
+ if (update.content?.text) this.imageScanText += " " + update.content.text;
994
1017
  const fo = fileOpFromUpdate(update);
995
1018
  if (fo) this.fileOps.set(fo.path, mergeFileOp(this.fileOps.get(fo.path), fo.op));
996
1019
  } else if (kind === "agent_message_chunk") {
@@ -15,6 +15,7 @@ import { EventEmitter } from "node:events";
15
15
  import { createLogger } from "../logger.js";
16
16
  import { hasLogin } from "../app/grok-credentials.js";
17
17
  import { contextWindowFor, DEFAULT_MODEL, KNOWN_MODELS } from "./models.js";
18
+ import { IMAGE_OUTPUT_DIRECTIVE } from "../render/image-output.js";
18
19
  import { PROGRESS_DIRECTIVE } from "../render/progress.js";
19
20
  import { SessionLog } from "./session-log.js";
20
21
  import { JsonRpcTransport } from "./transport.js";
@@ -728,8 +729,12 @@ export class GrokClient extends EventEmitter {
728
729
  * leading reasoning directive, fork/priming preamble) removed, for a clean log. */
729
730
  private cleanUserText(content: ContentBlock[]): string {
730
731
  let t = this.visibleText(content);
732
+ // Strip bot-injected appendices (image rules first, then progress — progress
733
+ // is always last when both are present).
731
734
  const pi = t.indexOf(PROGRESS_DIRECTIVE);
732
735
  if (pi !== -1) t = t.slice(0, pi).trimEnd();
736
+ const ii = t.indexOf(IMAGE_OUTPUT_DIRECTIVE);
737
+ if (ii !== -1) t = t.slice(0, ii).trimEnd();
733
738
  const marker = "User's new message:\n";
734
739
  const mi = t.lastIndexOf(marker);
735
740
  if (mi !== -1) t = t.slice(mi + marker.length);
package/src/grok/types.ts CHANGED
@@ -26,12 +26,23 @@ export interface JsonRpcNotification {
26
26
 
27
27
  export type JsonRpcMessage = JsonRpcResponse & JsonRpcNotification & { method?: string };
28
28
 
29
- /** A content block in a prompt or message. */
29
+ /** A content block in a prompt or message (ACP ContentBlock subset). */
30
30
  export interface ContentBlock {
31
- type: "text" | "image" | "resource";
31
+ type: "text" | "image" | "audio" | "resource" | "resource_link";
32
32
  text?: string;
33
33
  data?: string;
34
34
  mimeType?: string;
35
+ /** resource_link */
36
+ uri?: string;
37
+ name?: string;
38
+ size?: number;
39
+ /** embedded resource */
40
+ resource?: {
41
+ uri: string;
42
+ mimeType?: string;
43
+ text?: string;
44
+ blob?: string;
45
+ };
35
46
  [k: string]: unknown;
36
47
  }
37
48
 
@@ -47,7 +58,11 @@ export interface InitializeResult {
47
58
  authMethods?: AuthMethod[];
48
59
  agentCapabilities?: {
49
60
  loadSession?: boolean;
50
- promptCapabilities?: { image?: boolean };
61
+ promptCapabilities?: {
62
+ image?: boolean;
63
+ audio?: boolean;
64
+ embeddedContext?: boolean;
65
+ };
51
66
  };
52
67
  agentInfo?: { name?: string; version?: string };
53
68
  }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * Prompt appendix so the agent keeps generated images in the session media
3
+ * folder and mentions absolute paths (the bot delivers those as Telegram files).
4
+ *
5
+ * Keep tidy-idempotent (no trailing spaces / 3+ blank lines) so
6
+ * `cleanStoredText` can strip it by exact match after extractProgress/tidy.
7
+ */
8
+ export const IMAGE_OUTPUT_DIRECTIVE = [
9
+ "IMAGE OUTPUT RULES:",
10
+ "When generating images (image_gen / image_edit) or saving image files, write them under the current Grok session media folder (session images/ or assets/) or the project images/ directory — not random temp locations.",
11
+ "Always mention the absolute path of each image file you create in your reply so the client can deliver it as a downloadable Telegram file.",
12
+ ].join("\n");
@@ -3,6 +3,7 @@
3
3
  * Reads only the tail of large logs to stay fast.
4
4
  */
5
5
  import { closeSync, openSync, readSync, statSync } from "node:fs";
6
+ import { IMAGE_OUTPUT_DIRECTIVE } from "../render/image-output.js";
6
7
  import { extractProgress, PROGRESS_DIRECTIVE } from "../render/progress.js";
7
8
  import type { HistoryEntry, HistoryRole } from "./types.js";
8
9
 
@@ -161,6 +162,7 @@ function cleanStoredText(text: string): string {
161
162
  if (!text) return text;
162
163
  let t = extractProgress(text).cleaned;
163
164
  if (t.includes(PROGRESS_DIRECTIVE)) t = t.split(PROGRESS_DIRECTIVE).join("").trim();
165
+ if (t.includes(IMAGE_OUTPUT_DIRECTIVE)) t = t.split(IMAGE_OUTPUT_DIRECTIVE).join("").trim();
164
166
  return t;
165
167
  }
166
168