grok-telegram-bot 2.5.0 → 2.6.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/CHANGELOG.md CHANGED
@@ -7,6 +7,76 @@ 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
+ ## [2.6.0] - 2026-08-30
11
+
12
+ General becomes a chat-like **manager** for the forum group: memory-first
13
+ routing, `send_prompt` into project topics with job report-back, and **visible
14
+ short replies** (the previous notify-only quiet path looked like the bot was
15
+ dead). Also bumps runtime deps (`tsx`, `grammy`).
16
+
17
+ ### Added
18
+
19
+ - **General manager mode.** The forum **General** topic (`thread id 1`) is an
20
+ OpenClaw-style orchestrator, not a coding workspace. It keeps user messages,
21
+ starts a **new parallel session** per message (reply-to continues the same
22
+ session), streams **prose only** (no tools / progress / Done spam), and
23
+ dispatches real work into project topics.
24
+ - **Manager directive + auto context.** First General prompt injects how to
25
+ behave (delegate, memory-first, no git-first). Every user turn also gets a
26
+ capped **MANAGER CONTEXT** block: topic catalog, recent General chat, ranked
27
+ memory hits, and in-process manager jobs.
28
+ - **Manager jobs + work reports.** `send_prompt` from General registers a job
29
+ (`list_jobs`). When the child topic turn finishes, General is woken with a
30
+ structured `MANAGER WORK REPORT` so the manager can tell you done / failed.
31
+ - **Bridge: `notify`, `list_topics`, `list_jobs`, `session_id`.**
32
+ - `notify` — optional extra ping (not required in General; prose is shown).
33
+ - `list_topics` / `list_jobs` — catalog and dispatch status for the agent.
34
+ - `send_prompt.session_id` — resume a **specific** Grok session (full UUID or
35
+ short prefix from memory). Topic may be omitted; path → forum topic is
36
+ inferred. Placeholder topics (`…`) are rejected / inferred from memory.
37
+ - **Group memory ranking.** `search_memory` scores **relevance + recency**
38
+ (newest session per project path wins) so “last modifications” does not
39
+ surface stale Done notes. Hits include `[Xm/h/d ago]` stamps.
40
+ - **Outbound General thread helper.** Telegram rejects `message_thread_id: 1`.
41
+ Sends/edits to General omit that field (`outboundThreadExtra`); inbound
42
+ routing still treats missing thread id as General.
43
+
44
+ ### Changed
45
+
46
+ - **General UX.** User messages are **kept** (not replaced by prompt anchors).
47
+ Replies thread to the user message. A `Starting…` / `Thinking…` bubble is
48
+ edited in place into the short answer. Independent General messages no longer
49
+ coalesce into one queued turn.
50
+ - **Prose is the user-facing channel.** The manager is told the user **sees**
51
+ free-form chat text. `notify` is optional. Bridge-result follow-ups (after
52
+ `search_memory` / `list_topics`) stream the real answer instead of staying
53
+ silent. If the model emits no prose, the Thinking bubble is replaced with a
54
+ short fallback — never deleted leaving an empty chat.
55
+ - **Streamer prose-only mode.** General streams agent text only (no thoughts,
56
+ tools, plan board, or progress bar), seeded on the Thinking placeholder.
57
+ - **Forum send path.** `sendMessage` / stream edits / auth deny / prompt
58
+ anchors / photos / docs use `outboundThreadExtra` so General posts succeed.
59
+ - **Dependencies.** `tsx` `^4.19.2` → `^4.23.13`; `grammy` and `@types/node`
60
+ updated to current 1.x / 22.x wanted versions.
61
+
62
+ ### Fixed
63
+
64
+ - **Bot not responding in General after manager-mode work.** Notify-only +
65
+ deleting `Thinking…` when `search_memory` chained made every answer vanish.
66
+ General now streams short prose, keeps the status bubble across bridge
67
+ follow-ups, and never drops the placeholder without a visible line.
68
+ - **`message_thread_id: 1` rejected by Telegram** (“message thread not found”)
69
+ on General sends — omit the field for General; keep it for real topics.
70
+ - **Manager context path trap.** Recent General history no longer treats every
71
+ session under the workspace root as General chat (title + exact cwd only).
72
+
73
+ ### Docs
74
+
75
+ - **[docs/GROUP.md](./docs/GROUP.md)** — General = manager, memory-first
76
+ dispatch, `session_id`, `list_jobs`, prose vs `notify`.
77
+ - **AGENTS.md** — General manager conventions for future changes.
78
+ - **README** — General manager + expanded bridge actions.
79
+
10
80
  ## [2.5.0] - 2026-08-05
11
81
 
12
82
  Forum project topics, cross-topic Telegram bridge, prompt anchors, and a more
@@ -931,6 +1001,7 @@ from a single chat and switch between them, on a redesigned, compact menu.
931
1001
  diffs, MarkdownV2 rendering, scheduled tasks, multi-image prompts, and a
932
1002
  cross-platform 24/7 background service.
933
1003
 
1004
+ [2.6.0]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.6.0
934
1005
  [2.5.0]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.5.0
935
1006
  [2.4.0]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.4.0
936
1007
  [2.3.1]: https://github.com/artickc/grok-telegram-bot/releases/tag/v2.3.1
package/README.md CHANGED
@@ -27,8 +27,9 @@ re-architected for the Grok Build CLI and extended into a full multi-session cli
27
27
  | Capability | What it does |
28
28
  |---|---|
29
29
  | 🗂 **Projects** | `/projects` browses your folders and runs Grok in the one you pick. |
30
- | 🏷 **Forum project topics** | Optional forum supergroup: one topic per project, **AI Chat** workspace, exact-name bind, `/forum_setup`. See **[docs/GROUP.md](./docs/GROUP.md)**. |
31
- | 🌉 **Telegram bridge** | Agent can `create_topic`, `set_path`, `send_prompt` across topics, `search_memory`, and call allowlisted sibling bots via JSON actions. |
30
+ | 🏷 **Forum project topics** | Optional forum supergroup: **General** manager, **AI Chat** workspace, one topic per project, exact-name bind, `/forum_setup`. See **[docs/GROUP.md](./docs/GROUP.md)**. |
31
+ | 🎛 **General manager** | Forum General is a chat-like orchestrator: memory-first, short replies, `send_prompt` into project topics, report-back when child work finishes. |
32
+ | 🌉 **Telegram bridge** | Agent can `create_topic`, `set_path`, `send_prompt` (optional `session_id`), `notify`, `search_memory`, `list_topics` / `list_jobs`, and call allowlisted sibling bots via JSON actions. |
32
33
  | 🔖 **Prompt anchors** | Each user prompt is re-posted with a searchable `#prompt_…` tag; stream/Done messages reply to that anchor (media re-attached). |
33
34
  | 🆕 **New session** | `/new` or the bar **New** button starts a fresh session; forum topics keep New on the topic menu. |
34
35
  | 💡 **Post-turn suggestions** | After Done, 1–3 scored follow-ups as buttons; high-score items can auto-queue as one multi-step prompt. |
@@ -66,7 +67,8 @@ re-architected for the Grok Build CLI and extended into a full multi-session cli
66
67
  | Connect Grok CLI to Telegram (ACP) | ✅ | ✅ |
67
68
  | Switch between projects | ✅ | ❌ |
68
69
  | **Forum topics = projects** + AI Chat workspace | ✅ | ❌ |
69
- | **Cross-topic agent bridge** (create / bind / send_prompt) | ✅ | ❌ |
70
+ | **General manager** (memory-first, dispatch + report-back) | ✅ | ❌ |
71
+ | **Cross-topic agent bridge** (create / bind / send_prompt / session_id) | ✅ | ❌ |
70
72
  | Resume saved sessions | ✅ | ❌ |
71
73
  | Attach to **live** PC sessions (watch / fork) | ✅ | ❌ |
72
74
  | **Kill a session by PID** (or all at once) | ✅ | ❌ |
package/docs/GROUP.md CHANGED
@@ -67,11 +67,24 @@ When the bot is later promoted to admin, it re-probes automatically (`my_chat_me
67
67
 
68
68
  | Topic | Working directory | Typical use |
69
69
  |---|---|---|
70
- | **General** / **AI Chat** | `GROK_WORKSPACE` | Orchestration, cross-project planning, agent bridge actions |
70
+ | **General** | `GROK_WORKSPACE` | **Manager chat** (OpenClaw-style): routes work, memory-first, no coding spam |
71
+ | **AI Chat** | `GROK_WORKSPACE` | Normal coding/conversation in the workspace |
71
72
  | **Project topic** | Bound project path | All coding work for that folder |
72
73
  | **User-created topic** | Bound after name/path match | Ad-hoc projects or new folders |
73
74
 
74
- Messages you send **inside a topic** are prompts for a session whose `cwd` is
75
+ ### General = manager
76
+
77
+ Messages in **General** drive a chat-like orchestrator, not a coding agent:
78
+
79
+ 1. User asks in General (e.g. “fix login in MyApp”).
80
+ 2. Bot uses **memory + topic catalog** (auto-injected) and may call `search_memory` / `list_topics`.
81
+ 3. It replies briefly (“OK — I’ll start … in **MyApp**”) with **no progress bars / tool dumps**.
82
+ 4. It **dispatches** via `create_topic` / `set_path` / `send_prompt` into the right project topic.
83
+ 5. When that child turn finishes, the bridge **wakes General** with a `MANAGER WORK REPORT` so the manager summarizes success/fail for you.
84
+
85
+ Real implementation stays in **project topics**. General should not edit app code.
86
+
87
+ Messages you send **inside a project topic** are prompts for a session whose `cwd` is
75
88
  that topic’s path. Menus, model/reasoning picks, `/sessions`, `/running`, and
76
89
  Stop are **topic-scoped** so one project does not steal another’s session.
77
90
 
@@ -148,8 +161,11 @@ may feed results back as a quiet system turn (not a second Done).
148
161
  |---|---|
149
162
  | `create_topic` | New forum topic; optional `path` binds immediately |
150
163
  | `set_path` | Bind/rebind topic by title or `#threadId` |
151
- | `send_prompt` | Inject a prompt into another topic (`ran` / `queued`; optional `new_session`) |
152
- | `search_memory` | Search topic + session indexes |
164
+ | `send_prompt` | Inject a prompt into another topic (`ran` / `queued`; optional `new_session`, **`session_id`** to resume a specific session) |
165
+ | `notify` | Optional extra ping. In **General** the user already sees short chat prose; `notify` is not required. |
166
+ | `search_memory` | Search topic + session indexes (memory-first) |
167
+ | `list_topics` | List mapped forum topics (name, `#id`, path) |
168
+ | `list_jobs` | List recent General → project dispatches |
153
169
  | `list_bots` | List allowlisted sibling bots |
154
170
  | `bot_command` | Call `/command@bot` and wait for that bot to settle |
155
171
 
@@ -174,6 +190,25 @@ Example (from General / AI Chat):
174
190
 
175
191
  `topic` may be the exact title, `#threadId`, `general`, or `ai chat`.
176
192
 
193
+ To **resume a related session** found via memory (not the topic's currently open session):
194
+
195
+ ```json
196
+ {
197
+ "telegram": [
198
+ {
199
+ "action": "send_prompt",
200
+ "topic": "MyApp",
201
+ "session_id": "019fc9ec",
202
+ "prompt": "Continue: apply the follow-up fix."
203
+ }
204
+ ]
205
+ }
206
+ ```
207
+
208
+ `session_id` may be a full UUID or a short prefix from memory hits. Without it, the bridge uses the topic's foreground session.
209
+
210
+ `topic` must be an **exact** mapped title or `#threadId` (never placeholders like `…`). If only the session is known, omit `topic` — the bridge maps `session.cwd` → forum topic.
211
+
177
212
  ---
178
213
 
179
214
  ## Sibling bots (optional)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "grok-telegram-bot",
3
- "version": "2.5.0",
3
+ "version": "2.6.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",
@@ -60,15 +60,15 @@
60
60
  "dependencies": {
61
61
  "diff": "^7.0.0",
62
62
  "dotenv": "^16.4.7",
63
- "grammy": "^1.30.0",
64
- "tsx": "^4.19.2"
63
+ "grammy": "^1.46.0",
64
+ "tsx": "^4.23.13"
65
65
  },
66
66
  "optionalDependencies": {
67
67
  "@homebridge/node-pty-prebuilt-multiarch": "0.13.1"
68
68
  },
69
69
  "devDependencies": {
70
70
  "@types/diff": "^7.0.0",
71
- "@types/node": "^22.10.0",
71
+ "@types/node": "^22.20.1",
72
72
  "typescript": "^5.7.2"
73
73
  }
74
74
  }
package/src/app/types.ts CHANGED
@@ -78,13 +78,37 @@ export interface PromptInput {
78
78
  * trigger another self-recheck — only real user prompts do (once each).
79
79
  */
80
80
  skipSelfRecheck?: boolean;
81
+ /**
82
+ * Manager dispatch metadata for this prompt only (General → project).
83
+ * Carried through the queue so concurrent send_prompt jobs do not steal
84
+ * each other's report-back. Shape matches bot/manager-jobs ReportBackMeta.
85
+ */
86
+ reportBack?: {
87
+ jobId: string;
88
+ originChatId: number;
89
+ originThreadId: number;
90
+ userAskPreview: string;
91
+ targetName: string;
92
+ targetPath: string;
93
+ dispatchPrompt: string;
94
+ };
95
+ /**
96
+ * Pre-posted status bubble (General: "Starting…") that the turn edits to
97
+ * "Thinking…" then streams the agent reply into.
98
+ */
99
+ seedMessageId?: number;
81
100
  }
82
101
 
83
102
  export function textPrompt(
84
103
  text: string,
85
104
  replyTo?: number,
86
105
  quotedText?: string,
87
- opts?: { skipSelfRecheck?: boolean; promptId?: string },
106
+ opts?: {
107
+ skipSelfRecheck?: boolean;
108
+ promptId?: string;
109
+ reportBack?: PromptInput["reportBack"];
110
+ seedMessageId?: number;
111
+ },
88
112
  ): PromptInput {
89
113
  return {
90
114
  text,
@@ -94,5 +118,7 @@ export function textPrompt(
94
118
  promptId: opts?.promptId,
95
119
  quotedText,
96
120
  skipSelfRecheck: opts?.skipSelfRecheck,
121
+ reportBack: opts?.reportBack,
122
+ seedMessageId: opts?.seedMessageId,
97
123
  };
98
124
  }
package/src/bot/auth.ts CHANGED
@@ -112,7 +112,11 @@ async function denyUnauthorized(
112
112
  }
113
113
  if (!ctx.chat || isGroupChat(ctx)) return; // groups: ignore quietly
114
114
  const threadId = m && "message_thread_id" in m ? m.message_thread_id : undefined;
115
- const extra = threadId !== undefined ? { message_thread_id: threadId as number } : {};
115
+ // Omit General (1) Bot API rejects message_thread_id=1.
116
+ const extra =
117
+ threadId !== undefined && threadId !== 1
118
+ ? { message_thread_id: threadId as number }
119
+ : {};
116
120
  await ctx
117
121
  .reply("\u26D4 Not authorized. Ask the bot owner to add your Telegram ID to ALLOWED_USERS.", extra)
118
122
  .catch(() => {});
package/src/bot/bot.ts CHANGED
@@ -116,21 +116,58 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
116
116
  forum,
117
117
  bots: telegramBots,
118
118
  // Cross-topic orchestration: General can create a topic and send_prompt there.
119
- submitTopicPrompt: async ({ threadId, cwd, projectName, prompt, newSession }) => {
119
+ // sessionId resumes a specific Grok session (memory follow-up) instead of
120
+ // dumping into whatever is currently open in that topic.
121
+ submitTopicPrompt: async ({
122
+ threadId,
123
+ cwd,
124
+ projectName,
125
+ prompt,
126
+ newSession,
127
+ sessionId,
128
+ reportBack,
129
+ }) => {
120
130
  if (cfg.topicGroupId === undefined) {
121
131
  throw new Error("TOPIC_GROUP_ID unset");
122
132
  }
123
133
  const groupId = cfg.topicGroupId;
124
134
  const controller = registry.forumController(groupId, threadId, cwd, projectName);
135
+ // Explicit resume wins over newSession / foreground.
136
+ if (sessionId) {
137
+ const sw = await controller.addResume(sessionId, cwd, projectName);
138
+ if (reportBack) sw.rt.setReportBack(reportBack);
139
+ const outcome = await sw.rt.submit(textPrompt(prompt));
140
+ return { outcome, sessionId: sw.rt.sessionId ?? sessionId };
141
+ }
125
142
  if (newSession) {
126
143
  const rt = await controller.addNew(cwd, projectName);
144
+ if (reportBack) rt.setReportBack(reportBack);
127
145
  const outcome = await rt.submit(textPrompt(prompt));
128
146
  return { outcome, sessionId: rt.sessionId };
129
147
  }
130
148
  const rt = controller.foreground();
149
+ if (reportBack) rt.setReportBack(reportBack);
131
150
  const outcome = await rt.submit(textPrompt(prompt));
132
151
  return { outcome, sessionId: rt.sessionId };
133
152
  },
153
+ // Child topic Done → wake General manager with a WORK REPORT.
154
+ wakeManager: async ({ originChatId, originThreadId, prompt }) => {
155
+ if (cfg.topicGroupId === undefined) {
156
+ throw new Error("TOPIC_GROUP_ID unset");
157
+ }
158
+ const groupId = cfg.topicGroupId;
159
+ // origin is always General in manager mode; bind workspace.
160
+ const controller = registry.forumController(
161
+ originChatId || groupId,
162
+ originThreadId,
163
+ cfg.workspace,
164
+ "General",
165
+ );
166
+ const rt = controller.foreground();
167
+ await rt.submit(
168
+ textPrompt(prompt, undefined, undefined, { skipSelfRecheck: true }),
169
+ );
170
+ },
134
171
  });
135
172
 
136
173
  const deps: BotDeps = {
@@ -231,18 +268,50 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
231
268
  const index = Number(ctx.match![2]);
232
269
  const { resolveScope } = await import("./scope.js");
233
270
  const { adoptUserPrompt } = await import("./prompt-anchor.js");
271
+ const { isGeneralThread } = await import("../forum/thread.js");
234
272
  const scope = resolveScope(ctx, deps);
235
- const rt = scope.rt;
236
- const text = rt.takeSuggestion(batchId, index);
237
- if (!text) {
273
+ // General may have parallel sessions — find the runtime that owns this batch.
274
+ const hit =
275
+ scope.controller.takeSuggestionAnywhere(batchId, index) ??
276
+ (() => {
277
+ const t = scope.rt.takeSuggestion(batchId, index);
278
+ return t ? { rt: scope.rt, text: t } : undefined;
279
+ })();
280
+ if (!hit) {
238
281
  await ctx.answerCallbackQuery({ text: "Suggestion expired", show_alert: true });
239
282
  return;
240
283
  }
284
+ const { rt, text } = hit;
241
285
  await ctx.answerCallbackQuery({ text: "Sending\u2026" });
242
286
  // Dim the keyboard so double-taps don't re-fire.
243
287
  await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {});
244
288
  try {
245
289
  const chatId = ctx.chat?.id;
290
+ const isGeneral = isGeneralThread(scope.threadId);
291
+ const sugMsgId = ctx.callbackQuery.message?.message_id;
292
+ // General: keep chat clean — no anchor overwrite; continue same session
293
+ // and reply to the message that carried the buttons.
294
+ if (isGeneral) {
295
+ if (rt.sessionId && sugMsgId !== undefined) {
296
+ scope.controller.bindTelegramMessage(sugMsgId, rt.sessionId);
297
+ }
298
+ const outcome = await rt.submit(
299
+ textPrompt(text, sugMsgId, undefined, { promptId: undefined }),
300
+ );
301
+ if (outcome === "queued") {
302
+ const extra: Record<string, unknown> = { ...scope.threadExtra };
303
+ if (sugMsgId !== undefined) {
304
+ extra.reply_parameters = {
305
+ message_id: sugMsgId,
306
+ allow_sending_without_reply: true,
307
+ };
308
+ }
309
+ await deps.api
310
+ .sendMessage(chatId!, "\u{1F4E5} Queued on that thread.", extra)
311
+ .catch(() => {});
312
+ }
313
+ return;
314
+ }
246
315
  const anchor =
247
316
  chatId !== undefined
248
317
  ? await adoptUserPrompt(deps.api, {
@@ -63,6 +63,12 @@ export class ChatController {
63
63
  /** Telegram bridge (forum / memory / sibling bots); set by the registry. */
64
64
  bridge?: ChatBridgeServices;
65
65
 
66
+ /**
67
+ * General manager: map Telegram message ids (user + bot) → session id so a
68
+ * reply-to continues the same ACP session instead of spawning a new one.
69
+ */
70
+ private readonly telegramMsgSessions = new Map<number, string>();
71
+
66
72
  constructor(
67
73
  private readonly api: Api,
68
74
  private readonly chatId: number,
@@ -128,6 +134,105 @@ export class ChatController {
128
134
  return rt;
129
135
  }
130
136
 
137
+ /**
138
+ * General manager: spawn a fresh session for one user message without
139
+ * killing an in-flight sibling's Telegram stream (parallel prompts).
140
+ */
141
+ async addParallel(cwd: string, projectName?: string): Promise<SessionRuntime> {
142
+ return this.addNew(cwd, projectName);
143
+ }
144
+
145
+ /** Remember that a Telegram message belongs to a Grok session (reply routing). */
146
+ bindTelegramMessage(messageId: number, sessionId: string): void {
147
+ if (!messageId || !sessionId) return;
148
+ this.telegramMsgSessions.set(messageId, sessionId);
149
+ // Cap map growth (keep newest ~500).
150
+ if (this.telegramMsgSessions.size > 500) {
151
+ const drop = this.telegramMsgSessions.size - 500;
152
+ let i = 0;
153
+ for (const k of this.telegramMsgSessions.keys()) {
154
+ this.telegramMsgSessions.delete(k);
155
+ if (++i >= drop) break;
156
+ }
157
+ }
158
+ }
159
+
160
+ /** Resolve a controlled runtime from a Telegram message id (reply-to). */
161
+ runtimeForTelegramMessage(messageId: number | undefined): SessionRuntime | undefined {
162
+ if (messageId === undefined) return undefined;
163
+ this.ensureRestored();
164
+ const sid = this.telegramMsgSessions.get(messageId);
165
+ if (sid) {
166
+ const byId = this.runtimes.find((r) => r.sessionId === sid);
167
+ if (byId) return byId;
168
+ }
169
+ return undefined;
170
+ }
171
+
172
+ /**
173
+ * Resolve session from `#sess_xxxxxxxx` in a replied-to bot message body
174
+ * (works across process restarts when the in-memory map is cold).
175
+ */
176
+ runtimeForSessionTag(text: string | undefined): SessionRuntime | undefined {
177
+ if (!text) return undefined;
178
+ this.ensureRestored();
179
+ const tag = parseSessTag(text);
180
+ if (!tag) return undefined;
181
+ return this.runtimes.find((r) => r.sessionId && sessionMatchesTag(r.sessionId, tag));
182
+ }
183
+
184
+ /**
185
+ * Cold-start / map-miss: resolve continue target from reply message id, #sess_
186
+ * tag on controlled runtimes, or disk session list (attach into this controller).
187
+ */
188
+ async resolveContinueFromReply(opts: {
189
+ replyToMessageId?: number;
190
+ replyToText?: string;
191
+ cwd: string;
192
+ projectName?: string;
193
+ }): Promise<SessionRuntime | undefined> {
194
+ this.ensureRestored();
195
+ const byMsg = this.runtimeForTelegramMessage(opts.replyToMessageId);
196
+ if (byMsg) return byMsg;
197
+ const byTag = this.runtimeForSessionTag(opts.replyToText);
198
+ if (byTag) return byTag;
199
+
200
+ const tag = parseSessTag(opts.replyToText);
201
+ if (!tag) return undefined;
202
+ // Disk fallback: find full session id, then resume into this controller.
203
+ let metas;
204
+ try {
205
+ metas = this.store.list(80);
206
+ } catch {
207
+ return undefined;
208
+ }
209
+ const meta = metas.find((m) => sessionMatchesTag(m.sessionId, tag));
210
+ if (!meta) return undefined;
211
+ try {
212
+ const sw = await this.addResume(
213
+ meta.sessionId,
214
+ meta.cwd || opts.cwd,
215
+ opts.projectName,
216
+ );
217
+ return sw.rt;
218
+ } catch {
219
+ return undefined;
220
+ }
221
+ }
222
+
223
+ /** Find any runtime that still holds this suggestion batch (General parallel). */
224
+ takeSuggestionAnywhere(
225
+ batchId: number,
226
+ index: number,
227
+ ): { rt: SessionRuntime; text: string } | undefined {
228
+ this.ensureRestored();
229
+ for (const r of this.runtimes) {
230
+ const text = r.takeSuggestion(batchId, index);
231
+ if (text) return { rt: r, text };
232
+ }
233
+ return undefined;
234
+ }
235
+
131
236
  /**
132
237
  * Switch the chat to a project directory **without** waiting on ACP.
133
238
  * - Reuses an existing controlled runtime for the same path when possible.
@@ -410,6 +515,9 @@ export class ChatController {
410
515
  this.markSeen(rt);
411
516
  this.persist();
412
517
  };
518
+ rt.onTelegramMessageBound = (messageId, sessionId) => {
519
+ this.bindTelegramMessage(messageId, sessionId);
520
+ };
413
521
  return rt;
414
522
  }
415
523
 
@@ -466,3 +574,24 @@ export class ChatController {
466
574
  function normPath(p: string): string {
467
575
  return p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
468
576
  }
577
+
578
+ /** Extract `#sess_xxxxxxxx` from bot message text/caption. */
579
+ function parseSessTag(text: string | undefined): string | undefined {
580
+ if (!text) return undefined;
581
+ const m = text.match(/#sess_([a-z0-9]{6,12})/i);
582
+ return m?.[1]?.toLowerCase();
583
+ }
584
+
585
+ /** Match full session UUID (or short form) to a #sess_ tag body. */
586
+ function sessionMatchesTag(sessionId: string, tag: string): boolean {
587
+ const compact = sessionId.replace(/-/g, "").toLowerCase();
588
+ const short = sessionId.slice(0, 8).replace(/[^a-z0-9]/gi, "").toLowerCase();
589
+ const t = tag.toLowerCase();
590
+ return (
591
+ short === t ||
592
+ short.startsWith(t) ||
593
+ t.startsWith(short) ||
594
+ compact.startsWith(t) ||
595
+ sessionId.toLowerCase().startsWith(t)
596
+ );
597
+ }