grok-telegram-bot 2.2.4 → 2.3.1

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,74 @@ 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.1] - 2026-07-19
13
+
14
+ ### Fixed
15
+
16
+ - **Stable account rotation across concurrent chats.** Account selection is now
17
+ serialized around the single shared Grok CLI process. Chats reuse the working
18
+ account selected by another session and create their own ACP session instead
19
+ of starting competing rotation loops that repeatedly restart the agent.
20
+ - **Session failures no longer condemn valid accounts.** `unknown session id`,
21
+ agent-restart/process-exit, and headless-auth initialization errors now trigger
22
+ a session rebind/retry on the active account rather than quarantining or
23
+ cycling functional logins.
24
+ - **Reliable restart propagation.** Credential imports, manual switches,
25
+ reauthentication, and automatic rotation notify every chat runtime after the
26
+ shared process restarts; new session binding waits until candidate validation
27
+ completes.
28
+
29
+ ## [2.3.0] - 2026-07-17
30
+
31
+ ### Added
32
+
33
+ - **⚡ Instant project switch.** Choosing a project no longer waits on ACP
34
+ `session/new`. The bot switches cwd immediately and creates the live session
35
+ lazily on the first message. Reuses an existing controlled runtime for the
36
+ same path when available.
37
+ - **🖼 Image-output prompt rules.** When agent image return is enabled, prompts
38
+ include rules to keep generated files in the Grok session media folder
39
+ (`images/` / `assets/`) or project `images/`, and to report absolute paths.
40
+
41
+ ### Fixed
42
+
43
+ - **🖼 Agent images as Telegram files.** Generated images are sent with
44
+ `sendDocument` (downloadable file) instead of compressed `sendPhoto`. Discovery
45
+ now scans Grok session `images/` **and** `assets/`, plus project `images/` and
46
+ tool/text paths (Imagine was writing under `images/` while the bot only scanned
47
+ `assets/`).
48
+ - **⏱ Stale callback queries.** Callback answers are safe/idempotent; expired
49
+ Telegram query IDs no longer spam ERROR logs. Handlers answer before slow ACP
50
+ work (stop / model / agent / project open).
51
+ - **🎙 Voice requires STT again.** Voice / audio / video notes only work when
52
+ `STT_API_URL` is set (optional `STT_API_KEY`). Without STT the bot replies that
53
+ voice isn't configured — the CLI cannot accept ACP audio blocks.
54
+ - **🔄 Non-blocking session handoff.** Backgrounding a session no longer waits
55
+ on streamer finalize / `prepare()` mid-switch, so the bot stays responsive.
56
+
57
+ ## [2.2.4] - 2026-07-16
58
+
59
+ ### Fixed
60
+
61
+ - **🚫 Rotate immediately on Grok access denial.** API `403 Forbidden` / `Access
62
+ denied` responses now bypass same-account retry backoff, mark the failed
63
+ login with `⚠️`, and rotate to the next eligible saved account. If the active
64
+ host login was not already saved (for example after an external sign-in or
65
+ token refresh), it is captured first so the warning is visible and persists.
66
+ - **🔐 Isolated Grok Telegram identity.** The Grok bot now takes its Telegram
67
+ token from its own instance `.env` before considering an inherited process
68
+ environment value. This prevents a machine-wide token for a sibling bot from
69
+ making Grok poll as Codex/Kiro/OpenCode and causing Telegram conflicts.
70
+ - **⚠️ Persistent account warnings.** Account access/quota failures are shown
71
+ in `/accounts`, excluded from later automatic rotations, and can be manually
72
+ re-enabled after the account is repaired.
73
+ - **🛟 No false success for silent ACP turns.** A Grok ACP completion with no
74
+ text, thought, or tool update is now treated as a recoverable agent failure
75
+ instead of reporting `Done · no text output`.
76
+
77
+ ## [2.2.3] - 2026-07-13
33
78
 
34
79
  ### Fixed
35
80
 
@@ -733,6 +778,10 @@ from a single chat and switch between them, on a redesigned, compact menu.
733
778
  diffs, MarkdownV2 rendering, scheduled tasks, multi-image prompts, and a
734
779
  cross-platform 24/7 background service.
735
780
 
781
+ [2.3.1]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.3.1
782
+ [2.3.0]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.3.0
783
+ [2.2.4]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.2.4
784
+ [2.2.3]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.2.3
736
785
  [2.2.2]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.2.2
737
786
  [2.2.1]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.2.1
738
787
  [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.1",
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
  }
@@ -30,6 +30,12 @@ export interface RotationTarget {
30
30
  label: string;
31
31
  }
32
32
 
33
+ export interface RotationState {
34
+ generation: number;
35
+ activeId?: string;
36
+ activeLabel?: string;
37
+ }
38
+
33
39
  export interface AccountRotator {
34
40
  /** Whether auto-rotate is switched on. */
35
41
  enabled(): boolean;
@@ -39,9 +45,19 @@ export interface AccountRotator {
39
45
  activate(id: string): Promise<void>;
40
46
  /** Quarantine an account after an account-specific failure. Undefined means active. */
41
47
  markFailed(id: string | undefined, reason: string): Promise<void>;
48
+ /** Process/account generation observed when a turn failed. */
49
+ state(): RotationState;
50
+ /** Serialize a complete rotation probe. `changed` means another chat already
51
+ * selected/restarted an account while this caller was waiting. */
52
+ withRotationLock<T>(observed: RotationState, run: (changed: boolean) => Promise<T>): Promise<T>;
53
+ /** Wait for an in-progress rotation probe before re-binding a stale session. */
54
+ waitForIdle(): Promise<void>;
42
55
  }
43
56
 
44
57
  export class AccountRotatorImpl implements AccountRotator {
58
+ private generation = 0;
59
+ private rotationTail: Promise<void> = Promise.resolve();
60
+
45
61
  constructor(
46
62
  private readonly accounts: AccountManager,
47
63
  private readonly acp: GrokClient,
@@ -51,6 +67,42 @@ export class AccountRotatorImpl implements AccountRotator {
51
67
  return this.accounts.autoRotateEnabled();
52
68
  }
53
69
 
70
+ state(): RotationState {
71
+ const activeId = this.accounts.activeAccountId();
72
+ return {
73
+ generation: this.generation,
74
+ activeId,
75
+ activeLabel: activeId ? this.accounts.get(activeId)?.label : undefined,
76
+ };
77
+ }
78
+
79
+ async withRotationLock<T>(observed: RotationState, run: (changed: boolean) => Promise<T>): Promise<T> {
80
+ const previous = this.rotationTail;
81
+ let release!: () => void;
82
+ this.rotationTail = new Promise<void>((resolve) => {
83
+ release = resolve;
84
+ });
85
+ await previous;
86
+ try {
87
+ const current = this.state();
88
+ const changed =
89
+ current.generation !== observed.generation || current.activeId !== observed.activeId;
90
+ return await run(changed);
91
+ } finally {
92
+ release();
93
+ }
94
+ }
95
+
96
+ async waitForIdle(): Promise<void> {
97
+ // Include work queued while we were waiting, not only the first captured
98
+ // promise, so callers never re-bind in the middle of a candidate switch.
99
+ for (;;) {
100
+ const pending = this.rotationTail;
101
+ await pending;
102
+ if (pending === this.rotationTail) return;
103
+ }
104
+ }
105
+
54
106
  async targets(): Promise<RotationTarget[]> {
55
107
  const list = this.accounts.list();
56
108
  const activeId = this.accounts.activeAccountId();
@@ -96,12 +148,19 @@ export class AccountRotatorImpl implements AccountRotator {
96
148
  log.info(`rotating: auth.json now ${meta.label}; starting Grok CLI + re-auth`);
97
149
  // start() → connect() → initialize + authenticate(cached_token) against
98
150
  // the freshly written auth.json. A live process would keep the old token.
99
- await this.acp.start();
151
+ await this.acp.start(true);
152
+ this.generation++;
100
153
  log.info(`rotating: Grok CLI up on ${meta.label}`);
101
154
  } catch (e) {
102
155
  // Best-effort recover the agent so the bot stays usable even if the
103
156
  // target login was bad.
104
- await this.acp.start().catch((err) => log.warn("post-rotate restart failed:", (err as Error).message));
157
+ await this.acp.stopAndWait().catch(() => {});
158
+ await this.acp
159
+ .start(true)
160
+ .then(() => {
161
+ this.generation++;
162
+ })
163
+ .catch((err) => log.warn("post-rotate restart failed:", (err as Error).message));
105
164
  throw e;
106
165
  }
107
166
  }
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
+ }
@@ -205,9 +205,9 @@ export function registerAccounts(bot: Bot, deps: BotDeps): void {
205
205
  // Import reuses the live auth.json — just re-bind the agent headlessly.
206
206
  try {
207
207
  await deps.acp.stopAndWait();
208
- await deps.acp.start();
208
+ await deps.acp.start(true);
209
209
  } catch (e) {
210
- await deps.acp.start().catch(() => {});
210
+ await deps.acp.start(true).catch(() => {});
211
211
  return void rerender(ctx, deps, `\u26A0\uFE0F Imported, but re-bind failed: ${(e as Error).message}`);
212
212
  }
213
213
  let note = `\u2705 Imported the current login${res.label ? ` (${res.label})` : ""}.`;
@@ -239,9 +239,9 @@ export function registerAccounts(bot: Bot, deps: BotDeps): void {
239
239
  try {
240
240
  meta = await deps.accounts.switchTo(id);
241
241
  // 3) Start agent; it authenticates headlessly with cached_token.
242
- await deps.acp.start();
242
+ await deps.acp.start(true);
243
243
  } catch (e) {
244
- await deps.acp.start().catch(() => {});
244
+ await deps.acp.start(true).catch(() => {});
245
245
  throw e;
246
246
  }
247
247
  const note = (await deps.usage.isLoggedIn())