grok-telegram-bot 2.4.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.
Files changed (81) hide show
  1. package/.env.example +38 -2
  2. package/CHANGELOG.md +190 -1
  3. package/README.md +60 -15
  4. package/docs/GROUP.md +260 -0
  5. package/docs/INSTALL.md +3 -0
  6. package/package.json +4 -4
  7. package/src/app/lifetime-flag.ts +20 -0
  8. package/src/app/settings-store.ts +47 -8
  9. package/src/app/types.ts +38 -1
  10. package/src/app/updater.ts +24 -3
  11. package/src/bot/auth.ts +100 -15
  12. package/src/bot/bot.ts +193 -17
  13. package/src/bot/chat-controller.ts +181 -18
  14. package/src/bot/commands.ts +69 -29
  15. package/src/bot/deps.ts +3 -0
  16. package/src/bot/group-memory.ts +339 -0
  17. package/src/bot/handlers/accounts.ts +7 -0
  18. package/src/bot/handlers/control.ts +85 -32
  19. package/src/bot/handlers/document.ts +31 -4
  20. package/src/bot/handlers/forum.ts +217 -0
  21. package/src/bot/handlers/menu.ts +86 -24
  22. package/src/bot/handlers/message.ts +247 -27
  23. package/src/bot/handlers/photo.ts +126 -16
  24. package/src/bot/handlers/running.ts +150 -24
  25. package/src/bot/handlers/session-card.ts +13 -5
  26. package/src/bot/handlers/sessions.ts +68 -18
  27. package/src/bot/handlers/voice.ts +52 -7
  28. package/src/bot/image-return.ts +11 -5
  29. package/src/bot/manager-context.ts +208 -0
  30. package/src/bot/manager-jobs.ts +142 -0
  31. package/src/bot/menu/ephemeral.ts +16 -3
  32. package/src/bot/menu/keyboard.ts +53 -14
  33. package/src/bot/menu/refresh.ts +3 -1
  34. package/src/bot/menu/status-panel.ts +12 -6
  35. package/src/bot/permission-service.ts +19 -0
  36. package/src/bot/prompt-anchor.ts +299 -0
  37. package/src/bot/prompt-content.ts +8 -0
  38. package/src/bot/registry.ts +94 -1
  39. package/src/bot/scope.ts +95 -0
  40. package/src/bot/session-runtime.ts +1280 -183
  41. package/src/bot/suggestions.ts +91 -31
  42. package/src/bot/telegram-actions.ts +1130 -0
  43. package/src/bot/telegram-bots.ts +496 -0
  44. package/src/bot/telegram-io.ts +97 -10
  45. package/src/cli.ts +2 -0
  46. package/src/config.ts +201 -2
  47. package/src/forum/bind-path.ts +146 -0
  48. package/src/forum/manager.ts +652 -0
  49. package/src/forum/project-icon.ts +142 -0
  50. package/src/forum/thread.ts +49 -0
  51. package/src/forum/topic-store.ts +114 -0
  52. package/src/forum/types.ts +29 -0
  53. package/src/grok/client.ts +130 -28
  54. package/src/index.ts +205 -75
  55. package/src/projects/manager.ts +16 -3
  56. package/src/render/chunk.ts +17 -10
  57. package/src/render/hashtags.ts +5 -1
  58. package/src/render/manager-directive.ts +137 -0
  59. package/src/render/session-comment.ts +74 -7
  60. package/src/render/telegram-bridge.ts +464 -0
  61. package/src/render/tool-call.ts +56 -37
  62. package/src/service/platform.ts +44 -7
  63. package/src/service/windows.ts +16 -4
  64. package/src/sessions/history.ts +68 -9
  65. package/src/sessions/process.ts +7 -0
  66. package/src/sessions/types.ts +2 -2
  67. package/src/stream/streamer.ts +62 -15
  68. package/scripts/analyze-jsonl.ts +0 -33
  69. package/scripts/delayed-restart.ps1 +0 -29
  70. package/scripts/probe-exit-response-shape.py +0 -77
  71. package/scripts/probe-plan-exit.py +0 -60
  72. package/scripts/probe-plan-exit2.py +0 -48
  73. package/scripts/probe-plan-fields.py +0 -41
  74. package/scripts/probe-plan-fields2.py +0 -58
  75. package/scripts/probe-plan-response-path.py +0 -48
  76. package/scripts/sample-claude-tooluse.ts +0 -21
  77. package/scripts/sample-kiro-events.ts +0 -31
  78. package/scripts/smoke-exit-plan.ts +0 -274
  79. package/scripts/smoke-exit-shapes.ts +0 -252
  80. package/scripts/smoke-import.mjs +0 -82
  81. package/scripts/smoke-import.ts +0 -73
package/src/bot/bot.ts CHANGED
@@ -22,7 +22,7 @@ import { Scheduler } from "../tasks/scheduler.js";
22
22
  import { TaskStore } from "../tasks/store.js";
23
23
  import { createAuthMiddleware } from "./auth.js";
24
24
  import { isStaleCallbackError, safeCallbackMiddleware } from "./callback.js";
25
- import { COMMANDS } from "./commands.js";
25
+ import { COMMANDS, GROUP_COMMANDS } from "./commands.js";
26
26
  import { type BotDeps, MenuCache } from "./deps.js";
27
27
  import { registerControl } from "./handlers/control.js";
28
28
  import { registerDocuments } from "./handlers/document.js";
@@ -43,6 +43,7 @@ import { registerSystem } from "./handlers/system.js";
43
43
  import { registerTasks, registerWizardInput } from "./handlers/tasks.js";
44
44
  import { registerUsage } from "./handlers/usage.js";
45
45
  import { registerVoice } from "./handlers/voice.js";
46
+ import { registerForum } from "./handlers/forum.js";
46
47
  import { StatusPanel } from "./menu/status-panel.js";
47
48
  import { sendMarkdownDoc } from "./telegram-io.js";
48
49
  import { Ephemeral } from "./menu/ephemeral.js";
@@ -50,6 +51,8 @@ import { BAR_LABELS } from "./menu/keyboard.js";
50
51
  import { PermissionService } from "./permission-service.js";
51
52
  import { RuntimeRegistry } from "./registry.js";
52
53
  import { TaskWizard } from "./wizard/task-wizard.js";
54
+ import { ForumManager } from "../forum/manager.js";
55
+ import { TelegramBotService } from "./telegram-bots.js";
53
56
 
54
57
  const log = createLogger("bot");
55
58
 
@@ -99,13 +102,81 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
99
102
  const statusPanel = new StatusPanel(bot.api, settings, registry);
100
103
  registry.setRefresher((chatId) => void statusPanel.refresh(chatId));
101
104
 
105
+ const projects = new ProjectManager(cfg.projectRoots);
106
+ const forum =
107
+ cfg.topicGroupId !== undefined
108
+ ? new ForumManager(bot.api, cfg, projects)
109
+ : undefined;
110
+
111
+ // Sibling bots + memory/topic actions for the agent telegram JSON bridge.
112
+ const telegramBots = new TelegramBotService(bot.api, cfg);
113
+ telegramBots.attachToBot(bot);
114
+ registry.setBridge({
115
+ store,
116
+ forum,
117
+ bots: telegramBots,
118
+ // Cross-topic orchestration: General can create a topic and send_prompt there.
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
+ }) => {
130
+ if (cfg.topicGroupId === undefined) {
131
+ throw new Error("TOPIC_GROUP_ID unset");
132
+ }
133
+ const groupId = cfg.topicGroupId;
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
+ }
142
+ if (newSession) {
143
+ const rt = await controller.addNew(cwd, projectName);
144
+ if (reportBack) rt.setReportBack(reportBack);
145
+ const outcome = await rt.submit(textPrompt(prompt));
146
+ return { outcome, sessionId: rt.sessionId };
147
+ }
148
+ const rt = controller.foreground();
149
+ if (reportBack) rt.setReportBack(reportBack);
150
+ const outcome = await rt.submit(textPrompt(prompt));
151
+ return { outcome, sessionId: rt.sessionId };
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
+ },
171
+ });
172
+
102
173
  const deps: BotDeps = {
103
174
  api: bot.api,
104
175
  cfg,
105
176
  acp,
106
177
  registry,
107
178
  store,
108
- projects: new ProjectManager(cfg.projectRoots),
179
+ projects,
109
180
  menuCache: new MenuCache(),
110
181
  settings,
111
182
  statusPanel,
@@ -121,6 +192,7 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
121
192
  }),
122
193
  usage: new UsageService(cfg.grokCliPath),
123
194
  accounts: new AccountManager(cfg.dataDir),
195
+ forum,
124
196
  };
125
197
 
126
198
  // Auto-rotate-on-give-up: let a stuck turn cycle through other saved logins.
@@ -135,6 +207,11 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
135
207
  onUnpinned: (chatId) => statusPanel.ensurePinned(chatId),
136
208
  });
137
209
  acp.permissionHandler = (p) => permissions.handle(p);
210
+ // /stop and /cancel must cancel pending interactive permissions for that
211
+ // session only (ACP requires cancelled outcomes) — never kill the agent.
212
+ acp.onSessionCancel = (sessionId) => {
213
+ permissions.cancelForSession(sessionId);
214
+ };
138
215
 
139
216
  // The bot pins/unpins the status panel, and Telegram emits a "pinned a
140
217
  // message" service message for each pin. Delete those so the chat stays clean
@@ -146,14 +223,16 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
146
223
  // handler forgets (prevents the loading spinner + unhandled 400 noise).
147
224
  bot.use(safeCallbackMiddleware());
148
225
 
149
- // Keep history clean: after handling, delete the user's command (/…) and
150
- // persistent-bar button taps. Plain prompts and wizard input are kept.
226
+ // Keep history clean: delete the user's command (/…) and persistent-bar
227
+ // button taps INSTANTLY (before handlers) so slow ACP/CLI work never leaves
228
+ // the raw slash sitting in chat. Handlers post bot status messages instead.
229
+ // Plain prompts are adopted separately (see prompt-anchor.ts).
151
230
  bot.on("message:text", async (ctx, next) => {
152
- await next();
153
231
  const text = ctx.message?.text ?? "";
154
232
  if (text.startsWith("/") || BAR_LABELS.includes(text)) {
155
- await ctx.deleteMessage().catch(() => {});
233
+ void ctx.deleteMessage().catch(() => {});
156
234
  }
235
+ await next();
157
236
  });
158
237
 
159
238
  bot.callbackQuery(/^perm:(\d+):(\d+)$/, async (ctx) => {
@@ -187,27 +266,90 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
187
266
  bot.callbackQuery(/^sug:(\d+):(\d+)$/, async (ctx) => {
188
267
  const batchId = Number(ctx.match![1]);
189
268
  const index = Number(ctx.match![2]);
190
- const rt = deps.registry.get(ctx.chat!.id);
191
- const text = rt.takeSuggestion(batchId, index);
192
- if (!text) {
269
+ const { resolveScope } = await import("./scope.js");
270
+ const { adoptUserPrompt } = await import("./prompt-anchor.js");
271
+ const { isGeneralThread } = await import("../forum/thread.js");
272
+ const scope = resolveScope(ctx, deps);
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) {
193
281
  await ctx.answerCallbackQuery({ text: "Suggestion expired", show_alert: true });
194
282
  return;
195
283
  }
284
+ const { rt, text } = hit;
196
285
  await ctx.answerCallbackQuery({ text: "Sending\u2026" });
197
286
  // Dim the keyboard so double-taps don't re-fire.
198
287
  await ctx.editMessageReplyMarkup({ reply_markup: { inline_keyboard: [] } }).catch(() => {});
199
288
  try {
200
- const outcome = await rt.submit(textPrompt(text, ctx.callbackQuery.message?.message_id));
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
+ }
315
+ const anchor =
316
+ chatId !== undefined
317
+ ? await adoptUserPrompt(deps.api, {
318
+ chatId,
319
+ text,
320
+ userMessageIds: [],
321
+ messageThreadId: scope.threadExtra.message_thread_id,
322
+ projectName: rt.projectName,
323
+ prefix: "\u{1F4A1} Suggestion",
324
+ })
325
+ : undefined;
326
+ const outcome = await rt.submit(
327
+ textPrompt(text, anchor?.replyTo ?? ctx.callbackQuery.message?.message_id, undefined, {
328
+ promptId: anchor?.promptId,
329
+ }),
330
+ );
201
331
  if (outcome === "queued") {
202
- await ctx.reply(`\u{1F4E5} Queued suggestion (position ${rt.queueLength}).`).catch(() => {});
332
+ const extra: Record<string, unknown> = { ...scope.threadExtra };
333
+ if (anchor?.replyTo !== undefined) {
334
+ extra.reply_parameters = {
335
+ message_id: anchor.replyTo,
336
+ allow_sending_without_reply: true,
337
+ };
338
+ }
339
+ await ctx
340
+ .reply(`\u{1F4E5} Queued suggestion (position ${rt.queueLength}).`, extra)
341
+ .catch(() => {});
203
342
  }
204
343
  } catch (e) {
205
- await ctx.reply(`\u274C Couldn't run suggestion: ${(e as Error).message}`).catch(() => {});
344
+ await ctx
345
+ .reply(`\u274C Couldn't run suggestion: ${(e as Error).message}`, scope.threadExtra)
346
+ .catch(() => {});
206
347
  }
207
348
  });
208
349
 
209
350
  registerMenu(bot, deps); // persistent-keyboard buttons (hears)
210
351
  registerWizardInput(bot, deps); // wizard text input (before commands)
352
+ if (forum) registerForum(bot, deps, forum);
211
353
  registerControl(bot, deps);
212
354
  registerProjects(bot, deps);
213
355
  registerSessions(bot, deps);
@@ -234,13 +376,40 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
234
376
  log.debug("stale callback query:", err.error instanceof Error ? err.error.message : err.error);
235
377
  return;
236
378
  }
237
- log.error("unhandled bot error:", err.error instanceof Error ? err.error.message : err.error);
379
+ const e = err.error;
380
+ if (e instanceof Error) {
381
+ // Include Grammy error_code when present (429 / 403 / 409, etc.) for diagnosis.
382
+ const codeNum = (e as unknown as { error_code?: number }).error_code;
383
+ const code = typeof codeNum === "number" ? ` (code ${codeNum})` : "";
384
+ log.error(`unhandled bot error${code}:`, e.stack || e.message);
385
+ } else {
386
+ log.error("unhandled bot error:", e);
387
+ }
388
+ // Never rethrow — a middleware failure must not take down long polling.
238
389
  });
239
390
 
240
- try {
241
- await bot.api.setMyCommands(COMMANDS);
242
- } catch (e) {
243
- log.warn("setMyCommands failed:", (e as Error).message);
391
+ // Scoped command menus: private = full sorted list; groups = short list with
392
+ // cancel/menu first (reply keyboard is unreliable in forum topics).
393
+ const registerCommands = async (
394
+ commands: typeof COMMANDS,
395
+ scope?: { type: string; chat_id?: number },
396
+ label = "default",
397
+ ): Promise<void> => {
398
+ try {
399
+ await bot.api.setMyCommands(commands, scope ? { scope: scope as never } : undefined);
400
+ } catch (e) {
401
+ log.warn(`setMyCommands (${label}) failed:`, (e as Error).message);
402
+ }
403
+ };
404
+ await registerCommands(COMMANDS, undefined, "default");
405
+ await registerCommands(COMMANDS, { type: "all_private_chats" }, "private");
406
+ await registerCommands(GROUP_COMMANDS, { type: "all_group_chats" }, "groups");
407
+ if (cfg.topicGroupId !== undefined) {
408
+ await registerCommands(
409
+ GROUP_COMMANDS,
410
+ { type: "chat", chat_id: cfg.topicGroupId },
411
+ `chat:${cfg.topicGroupId}`,
412
+ );
244
413
  }
245
414
 
246
415
  const updater = new Updater({
@@ -278,5 +447,12 @@ export async function createBot(cfg: AppConfig, acp: GrokClient): Promise<BotBun
278
447
  // Remove any navigation surface left over from before a restart.
279
448
  void deps.ephemeral.cleanupAll().catch(() => {});
280
449
 
450
+ // Forum project topics: ensure AI Chat + optional catalog topics (best-effort).
451
+ if (forum) {
452
+ void forum.ensureSetup().catch((e) => {
453
+ log.warn(`forum setup failed: ${(e as Error).message}`);
454
+ });
455
+ }
456
+
281
457
  return { bot, registry, scheduler: new Scheduler(tasks, taskRunner), updater };
282
458
  }
@@ -23,10 +23,7 @@ export interface RunningSession {
23
23
  unread: number;
24
24
  /** Latest task-completion % (0–100) for this session, if known. */
25
25
  progress?: number;
26
- /**
27
- * Card comment: live step while working, chat summary when idle
28
- * ("what is happening / what was done").
29
- */
26
+ /** Card comment: last user prompt (+ last AI thinking while busy). */
30
27
  comment?: string;
31
28
  }
32
29
 
@@ -40,11 +37,37 @@ export interface SwitchResult {
40
37
  alreadyForeground: boolean;
41
38
  }
42
39
 
40
+ export interface ChatControllerOpts {
41
+ /** Forum topic thread — all runtimes post into this topic. */
42
+ messageThreadId?: number;
43
+ /** Settings key (`chatId` or `chatId:t{thread}`). Defaults to String(chatId). */
44
+ settingsKey?: string;
45
+ /** Fixed project path (forum topics): never switch away via project picker. */
46
+ fixedCwd?: string;
47
+ fixedProjectName?: string;
48
+ }
49
+
50
+ /** Optional Telegram bridge services attached to every runtime in this chat. */
51
+ export type ChatBridgeServices = SessionRuntime["bridge"];
52
+
43
53
  export class ChatController {
44
54
  private readonly runtimes: SessionRuntime[] = [];
45
55
  private fg: SessionRuntime | undefined;
46
56
  private readonly lastRead = new Map<string, number>();
47
57
  private restored = false;
58
+ readonly settingsKey: string;
59
+ readonly messageThreadId: number | undefined;
60
+ readonly fixedCwd: string | undefined;
61
+ readonly fixedProjectName: string | undefined;
62
+
63
+ /** Telegram bridge (forum / memory / sibling bots); set by the registry. */
64
+ bridge?: ChatBridgeServices;
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>();
48
71
 
49
72
  constructor(
50
73
  private readonly api: Api,
@@ -56,14 +79,22 @@ export class ChatController {
56
79
  private readonly refresh: (chatId: number) => void,
57
80
  private readonly notifyActivity: (busy: boolean) => void,
58
81
  private readonly getRotator?: () => AccountRotator | undefined,
59
- ) {}
82
+ opts?: ChatControllerOpts,
83
+ ) {
84
+ this.messageThreadId = opts?.messageThreadId;
85
+ this.settingsKey = opts?.settingsKey ?? String(chatId);
86
+ this.fixedCwd = opts?.fixedCwd;
87
+ this.fixedProjectName = opts?.fixedProjectName;
88
+ }
60
89
 
61
90
  /** The current foreground runtime (created/restored lazily). */
62
91
  foreground(): SessionRuntime {
63
92
  this.ensureRestored();
64
93
  if (!this.fg) {
65
- const s = this.settings.get(this.chatId);
66
- const rt = this.create({ cwd: s.projectPath ?? this.cfg.workspace, projectName: s.projectName, sessionId: s.sessionId });
94
+ const s = this.settings.getKey(this.settingsKey);
95
+ const cwd = this.fixedCwd ?? s.projectPath ?? this.cfg.workspace;
96
+ const name = this.fixedProjectName ?? s.projectName;
97
+ const rt = this.create({ cwd, projectName: name, sessionId: s.sessionId });
67
98
  this.runtimes.push(rt);
68
99
  this.fg = rt;
69
100
  }
@@ -103,6 +134,105 @@ export class ChatController {
103
134
  return rt;
104
135
  }
105
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
+
106
236
  /**
107
237
  * Switch the chat to a project directory **without** waiting on ACP.
108
238
  * - Reuses an existing controlled runtime for the same path when possible.
@@ -279,7 +409,7 @@ export class ChatController {
279
409
  return this.runtimes.find((r) => r.sessionId === sessionId)?.taskProgress;
280
410
  }
281
411
 
282
- /** Live step / chat summary for a controlled session id. */
412
+ /** Last user prompt (+ thinking when busy) for a controlled session id. */
283
413
  commentFor(sessionId?: string): string | undefined {
284
414
  if (!sessionId) return undefined;
285
415
  this.ensureRestored();
@@ -301,27 +431,31 @@ export class ChatController {
301
431
  private ensureRestored(): void {
302
432
  if (this.restored) return;
303
433
  this.restored = true;
304
- const s = this.settings.get(this.chatId);
434
+ const s = this.settings.getKey(this.settingsKey);
305
435
  const seen = new Set<string>();
306
436
  for (const cs of s.controlledSessions ?? []) {
307
437
  if (!cs.sessionId || seen.has(cs.sessionId)) continue; // never restore the same session twice
308
438
  seen.add(cs.sessionId);
439
+ // Forum topics only restore sessions for the fixed project path.
440
+ if (this.fixedCwd && normPath(cs.projectPath) !== normPath(this.fixedCwd)) continue;
309
441
  this.runtimes.push(this.create({ cwd: cs.projectPath, projectName: cs.projectName, sessionId: cs.sessionId }));
310
442
  }
311
443
  // Lazy project switches persist projectPath without a sessionId. If the
312
444
  // saved project is not among controlled sessions, recreate an unbound FG
313
445
  // so a restart lands on the project the user last chose.
314
- if (s.projectPath) {
315
- const key = normPath(s.projectPath);
446
+ const homePath = this.fixedCwd ?? s.projectPath;
447
+ const homeName = this.fixedProjectName ?? s.projectName;
448
+ if (homePath) {
449
+ const key = normPath(homePath);
316
450
  const hasProject = this.runtimes.some((r) => normPath(r.cwd) === key);
317
451
  if (!hasProject) {
318
- this.runtimes.push(this.create({ cwd: s.projectPath, projectName: s.projectName }));
452
+ this.runtimes.push(this.create({ cwd: homePath, projectName: homeName, sessionId: s.sessionId }));
319
453
  }
320
454
  }
321
455
  if (this.runtimes.length > 0) {
322
456
  let fg = this.runtimes.find((r) => r.sessionId && r.sessionId === s.foregroundSessionId);
323
- if (!fg && s.projectPath) {
324
- const key = normPath(s.projectPath);
457
+ if (!fg && homePath) {
458
+ const key = normPath(homePath);
325
459
  fg = this.runtimes.find((r) => normPath(r.cwd) === key);
326
460
  }
327
461
  fg = fg ?? this.runtimes[0]!;
@@ -365,10 +499,15 @@ export class ChatController {
365
499
  }
366
500
 
367
501
  private create(init: { cwd: string; projectName?: string; sessionId?: string }): SessionRuntime {
368
- const rt = new SessionRuntime(this.api, this.chatId, this.acp, this.cfg, this.settings, init);
502
+ const rt = new SessionRuntime(this.api, this.chatId, this.acp, this.cfg, this.settings, {
503
+ ...init,
504
+ messageThreadId: this.messageThreadId,
505
+ settingsKey: this.settingsKey,
506
+ });
369
507
  rt.onStateChange = () => this.refresh(this.chatId);
370
508
  rt.onActivity = (busy) => this.notifyActivity(busy);
371
509
  rt.accountRotator = this.getRotator?.();
510
+ rt.bridge = this.bridge;
372
511
  // A logical fork (auto-fork-on-error / lost-session recovery) swaps the
373
512
  // runtime's session id in place — re-persist the controlled list with the
374
513
  // new id and treat the fresh session as already-seen.
@@ -376,6 +515,9 @@ export class ChatController {
376
515
  this.markSeen(rt);
377
516
  this.persist();
378
517
  };
518
+ rt.onTelegramMessageBound = (messageId, sessionId) => {
519
+ this.bindTelegramMessage(messageId, sessionId);
520
+ };
379
521
  return rt;
380
522
  }
381
523
 
@@ -415,15 +557,15 @@ export class ChatController {
415
557
  seen.add(r.sessionId);
416
558
  controlled.push({ sessionId: r.sessionId, projectPath: r.cwd, projectName: r.projectName });
417
559
  }
418
- this.settings.update(this.chatId, {
560
+ this.settings.updateKey(this.settingsKey, {
419
561
  controlledSessions: controlled,
420
562
  foregroundSessionId: this.fg?.sessionId,
421
563
  // Keep the single-session restore fields aligned with the foreground so
422
564
  // the pinned status panel and a fresh restore never show a project that
423
565
  // belongs to a different (previously-foreground) session.
424
566
  sessionId: this.fg?.sessionId,
425
- projectPath: this.fg?.cwd,
426
- projectName: this.fg?.projectName,
567
+ projectPath: this.fixedCwd ?? this.fg?.cwd,
568
+ projectName: this.fixedProjectName ?? this.fg?.projectName,
427
569
  });
428
570
  }
429
571
  }
@@ -432,3 +574,24 @@ export class ChatController {
432
574
  function normPath(p: string): string {
433
575
  return p.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
434
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
+ }