grok-telegram-bot 2.2.3 → 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 +7 -4
- package/CHANGELOG.md +51 -0
- package/README.md +18 -8
- package/package.json +1 -1
- package/src/app/accounts.ts +35 -0
- package/src/app/stt.ts +5 -2
- package/src/app/types.ts +16 -2
- package/src/bot/account-rotator.ts +22 -1
- package/src/bot/bot.ts +10 -0
- package/src/bot/callback.ts +74 -0
- package/src/bot/chat-controller.ts +90 -8
- package/src/bot/handlers/accounts.ts +15 -4
- package/src/bot/handlers/menu.ts +22 -5
- package/src/bot/handlers/projects.ts +11 -5
- package/src/bot/handlers/voice.ts +21 -4
- package/src/bot/image-return.ts +118 -14
- package/src/bot/prompt-content.ts +24 -5
- package/src/bot/session-runtime.ts +56 -10
- package/src/config.ts +8 -4
- package/src/grok/client.ts +39 -3
- package/src/grok/types.ts +18 -3
- package/src/render/image-output.ts +12 -0
- package/src/sessions/history.ts +2 -0
- package/scripts/_tmp-release.mjs +0 -16
package/.env.example
CHANGED
|
@@ -132,9 +132,12 @@ QUIET_NOTIFICATIONS=true
|
|
|
132
132
|
# LOG_DIR=
|
|
133
133
|
# LOG_FILE=
|
|
134
134
|
|
|
135
|
-
# ── Voice messages (
|
|
136
|
-
#
|
|
137
|
-
#
|
|
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
|
@@ -9,6 +9,54 @@ The latest section is published verbatim as the GitHub Release notes by
|
|
|
9
9
|
|
|
10
10
|
## [Unreleased]
|
|
11
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
|
+
|
|
12
60
|
## [2.2.3] - 2026-07-13
|
|
13
61
|
|
|
14
62
|
### Fixed
|
|
@@ -713,6 +761,9 @@ from a single chat and switch between them, on a redesigned, compact menu.
|
|
|
713
761
|
diffs, MarkdownV2 rendering, scheduled tasks, multi-image prompts, and a
|
|
714
762
|
cross-platform 24/7 background service.
|
|
715
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
|
|
716
767
|
[2.2.2]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.2.2
|
|
717
768
|
[2.2.1]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.2.1
|
|
718
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
|
|
279
|
-
|
|
280
|
-
|
|
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
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
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 →
|
|
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.
|
|
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/accounts.ts
CHANGED
|
@@ -32,6 +32,11 @@ export interface StoredAccount {
|
|
|
32
32
|
startUrl?: string;
|
|
33
33
|
accountType?: string;
|
|
34
34
|
region?: string;
|
|
35
|
+
/** Excluded from automatic rotation after an account-specific quota/billing failure. */
|
|
36
|
+
warning?: {
|
|
37
|
+
reason: string;
|
|
38
|
+
markedAt: string;
|
|
39
|
+
};
|
|
35
40
|
}
|
|
36
41
|
|
|
37
42
|
interface AccountsData {
|
|
@@ -171,6 +176,9 @@ export class AccountManager {
|
|
|
171
176
|
email: (email && email.includes("@") ? email : undefined) || existing?.email,
|
|
172
177
|
startUrl: (email && email.includes("@") ? email : undefined) || existing?.email || existing?.startUrl,
|
|
173
178
|
savedAt: new Date().toISOString(),
|
|
179
|
+
// Automatic pre-rotation snapshots must not silently re-enable an
|
|
180
|
+
// account that was quarantined after a quota/billing failure.
|
|
181
|
+
warning: existing?.warning,
|
|
174
182
|
};
|
|
175
183
|
this.store.update((d) => {
|
|
176
184
|
const idx = d.accounts.findIndex((a) => a.id === id);
|
|
@@ -227,6 +235,33 @@ export class AccountManager {
|
|
|
227
235
|
return updated;
|
|
228
236
|
}
|
|
229
237
|
|
|
238
|
+
/** Mark an account as unsuitable for future automatic rotations. */
|
|
239
|
+
markWarning(id: string, reason: string): StoredAccount | undefined {
|
|
240
|
+
let updated: StoredAccount | undefined;
|
|
241
|
+
this.store.update((d) => {
|
|
242
|
+
const account = d.accounts.find((a) => a.id === id);
|
|
243
|
+
if (account) {
|
|
244
|
+
account.warning = { reason, markedAt: new Date().toISOString() };
|
|
245
|
+
updated = account;
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
if (updated) log.warn(`marked account ${updated.label} with rotation warning: ${reason}`);
|
|
249
|
+
return updated;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
/** Re-allow a manually restored account to participate in auto-rotation. */
|
|
253
|
+
clearWarning(id: string): StoredAccount | undefined {
|
|
254
|
+
let updated: StoredAccount | undefined;
|
|
255
|
+
this.store.update((d) => {
|
|
256
|
+
const account = d.accounts.find((a) => a.id === id);
|
|
257
|
+
if (account?.warning) {
|
|
258
|
+
delete account.warning;
|
|
259
|
+
updated = account;
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
return updated;
|
|
263
|
+
}
|
|
264
|
+
|
|
230
265
|
async forget(id: string): Promise<boolean> {
|
|
231
266
|
const existed = !!this.get(id);
|
|
232
267
|
await rm(this.snapshotPath(id), { force: true }).catch(() => {});
|
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
|
-
/**
|
|
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
|
}
|
|
@@ -37,6 +37,8 @@ export interface AccountRotator {
|
|
|
37
37
|
targets(): Promise<RotationTarget[]>;
|
|
38
38
|
/** Make a saved account active (swap auth.json + re-bind). Throws on error. */
|
|
39
39
|
activate(id: string): Promise<void>;
|
|
40
|
+
/** Quarantine an account after an account-specific failure. Undefined means active. */
|
|
41
|
+
markFailed(id: string | undefined, reason: string): Promise<void>;
|
|
40
42
|
}
|
|
41
43
|
|
|
42
44
|
export class AccountRotatorImpl implements AccountRotator {
|
|
@@ -52,7 +54,26 @@ export class AccountRotatorImpl implements AccountRotator {
|
|
|
52
54
|
async targets(): Promise<RotationTarget[]> {
|
|
53
55
|
const list = this.accounts.list();
|
|
54
56
|
const activeId = this.accounts.activeAccountId();
|
|
55
|
-
return list
|
|
57
|
+
return list
|
|
58
|
+
.filter((a) => a.id !== activeId && !a.warning)
|
|
59
|
+
.map((a) => ({ id: a.id, label: a.label }));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async markFailed(id: string | undefined, reason: string): Promise<void> {
|
|
63
|
+
let accountId = id ?? this.accounts.activeAccountId();
|
|
64
|
+
if (!accountId) {
|
|
65
|
+
// The host may have been signed in or had its token refreshed outside the
|
|
66
|
+
// bot, so no saved snapshot matches yet. Capture it before marking; this
|
|
67
|
+
// guarantees the actual failed login appears with a warning in /accounts
|
|
68
|
+
// and is excluded from the target list below.
|
|
69
|
+
try {
|
|
70
|
+
accountId = (await this.accounts.captureCurrent()).id;
|
|
71
|
+
} catch (error) {
|
|
72
|
+
log.warn("could not capture active account for rotation warning:", (error as Error).message);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
this.accounts.markWarning(accountId, reason);
|
|
56
77
|
}
|
|
57
78
|
|
|
58
79
|
/**
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
135
|
-
|
|
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
|
-
|
|
200
|
+
void this.background(this.fg);
|
|
152
201
|
this.fg = rt;
|
|
153
202
|
await rt.setForeground(true);
|
|
154
|
-
await
|
|
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
|
-
|
|
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
|
+
}
|
|
@@ -17,7 +17,7 @@ import type { BotDeps } from "../deps.js";
|
|
|
17
17
|
const log = createLogger("accounts");
|
|
18
18
|
|
|
19
19
|
function accountLine(a: StoredAccount, active: boolean): string {
|
|
20
|
-
const mark = active ? "\u2705 " : "\u{1F464} ";
|
|
20
|
+
const mark = a.warning ? "\u26A0\uFE0F " : active ? "\u2705 " : "\u{1F464} ";
|
|
21
21
|
return `${mark}${a.label}`;
|
|
22
22
|
}
|
|
23
23
|
|
|
@@ -51,15 +51,18 @@ async function view(deps: BotDeps, note?: string): Promise<{ text: string; keybo
|
|
|
51
51
|
if (list.length === 0) {
|
|
52
52
|
lines.push("No saved accounts yet.", "", "Save the current login below, or sign in via /reauth.");
|
|
53
53
|
} else {
|
|
54
|
-
for (const a of list)
|
|
54
|
+
for (const a of list) {
|
|
55
|
+
lines.push(accountLine(a, a.id === active));
|
|
56
|
+
if (a.warning) lines.push(" \u2514 Skipped by auto-rotate after an account access or quota error.");
|
|
57
|
+
}
|
|
55
58
|
}
|
|
56
59
|
const rotate = deps.accounts.autoRotateEnabled();
|
|
57
60
|
lines.push(
|
|
58
61
|
"",
|
|
59
62
|
`\u{1F501} Auto-rotate on errors: ${rotate ? "ON" : "OFF"}`,
|
|
60
63
|
rotate
|
|
61
|
-
? " \u2514 On
|
|
62
|
-
: " \u2514 Turns stay on the active account (
|
|
64
|
+
? " \u2514 On account access/quota errors: stop CLI, swap auth, restart, retry each eligible account once. \u26A0\uFE0F accounts are skipped."
|
|
65
|
+
: " \u2514 Turns stay on the active account (account access/quota errors stop immediately).",
|
|
63
66
|
);
|
|
64
67
|
if (note) lines.push("", note);
|
|
65
68
|
|
|
@@ -70,6 +73,7 @@ async function view(deps: BotDeps, note?: string): Promise<{ text: string; keybo
|
|
|
70
73
|
.text("\u270F\uFE0F", `acct:rename:${a.id}`)
|
|
71
74
|
.text("\u{1F5D1}", `acct:del:${a.id}`)
|
|
72
75
|
.row();
|
|
76
|
+
if (a.warning) kb.text(`\u26A0\uFE0F Re-enable ${trim(a.label)}`, `acct:clearwarning:${a.id}`).row();
|
|
73
77
|
}
|
|
74
78
|
kb.text("\u{1F4BE} Save current login", "acct:save").text("\u270F\uFE0F Save as\u2026", "acct:saveas").row();
|
|
75
79
|
kb.text("\u{1F4E5} Import existing", "acct:import").text("\u{1F511} Sign in\u2026", "acct:login").row();
|
|
@@ -161,6 +165,13 @@ export function registerAccounts(bot: Bot, deps: BotDeps): void {
|
|
|
161
165
|
await promptName(ctx, "rename", ctx.match![1]!);
|
|
162
166
|
});
|
|
163
167
|
|
|
168
|
+
bot.callbackQuery(/^acct:clearwarning:(.+)$/, async (ctx) => {
|
|
169
|
+
const id = ctx.match![1]!;
|
|
170
|
+
const account = deps.accounts.clearWarning(id);
|
|
171
|
+
await ctx.answerCallbackQuery({ text: account ? "Account re-enabled for auto-rotate" : "No warning to clear" });
|
|
172
|
+
await rerender(ctx, deps, account ? `\u2705 ${account.label} can be used by auto-rotate again.` : undefined);
|
|
173
|
+
});
|
|
174
|
+
|
|
164
175
|
bot.callbackQuery("acct:close", async (ctx) => {
|
|
165
176
|
await ctx.answerCallbackQuery();
|
|
166
177
|
await deps.ephemeral.drop(ctx);
|
package/src/bot/handlers/menu.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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 {
|