grok-telegram-bot 2.3.1 → 2.5.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 (88) hide show
  1. package/.env.example +64 -2
  2. package/CHANGELOG.md +156 -1
  3. package/README.md +58 -15
  4. package/docs/GROUP.md +225 -0
  5. package/docs/INSTALL.md +3 -0
  6. package/package.json +1 -1
  7. package/src/app/accounts.ts +84 -0
  8. package/src/app/instance-lock.ts +6 -0
  9. package/src/app/lifetime-flag.ts +20 -0
  10. package/src/app/settings-store.ts +47 -8
  11. package/src/app/types.ts +30 -2
  12. package/src/app/updater.ts +38 -6
  13. package/src/app/usage.ts +204 -7
  14. package/src/bot/account-rotator.ts +10 -0
  15. package/src/bot/auth.ts +96 -15
  16. package/src/bot/bot.ts +154 -11
  17. package/src/bot/chat-controller.ts +82 -13
  18. package/src/bot/commands.ts +69 -27
  19. package/src/bot/complexity-gate.ts +69 -0
  20. package/src/bot/deps.ts +22 -0
  21. package/src/bot/group-memory.ts +159 -0
  22. package/src/bot/handlers/accounts.ts +58 -1
  23. package/src/bot/handlers/control.ts +85 -32
  24. package/src/bot/handlers/document.ts +31 -4
  25. package/src/bot/handlers/forum.ts +207 -0
  26. package/src/bot/handlers/import-session.ts +290 -0
  27. package/src/bot/handlers/menu.ts +102 -61
  28. package/src/bot/handlers/message.ts +102 -21
  29. package/src/bot/handlers/photo.ts +123 -16
  30. package/src/bot/handlers/running.ts +172 -16
  31. package/src/bot/handlers/session-card.ts +20 -0
  32. package/src/bot/handlers/sessions.ts +76 -15
  33. package/src/bot/handlers/usage.ts +118 -16
  34. package/src/bot/handlers/voice.ts +52 -7
  35. package/src/bot/image-return.ts +8 -5
  36. package/src/bot/menu/ephemeral.ts +13 -3
  37. package/src/bot/menu/keyboard.ts +54 -14
  38. package/src/bot/menu/refresh.ts +3 -1
  39. package/src/bot/menu/status-panel.ts +25 -6
  40. package/src/bot/permission-service.ts +19 -0
  41. package/src/bot/prompt-anchor.ts +300 -0
  42. package/src/bot/prompt-content.ts +7 -0
  43. package/src/bot/registry.ts +94 -1
  44. package/src/bot/scope.ts +94 -0
  45. package/src/bot/session-fork.ts +11 -0
  46. package/src/bot/session-runtime.ts +1254 -83
  47. package/src/bot/suggestions.ts +489 -0
  48. package/src/bot/telegram-actions.ts +440 -0
  49. package/src/bot/telegram-bots.ts +495 -0
  50. package/src/bot/telegram-io.ts +94 -10
  51. package/src/cli.ts +2 -0
  52. package/src/config.ts +242 -2
  53. package/src/forum/bind-path.ts +146 -0
  54. package/src/forum/manager.ts +651 -0
  55. package/src/forum/project-icon.ts +142 -0
  56. package/src/forum/thread.ts +16 -0
  57. package/src/forum/topic-store.ts +114 -0
  58. package/src/forum/types.ts +29 -0
  59. package/src/grok/client.ts +214 -37
  60. package/src/grok/plan-approval.ts +72 -0
  61. package/src/grok/session-log.ts +16 -0
  62. package/src/grok/types.ts +21 -2
  63. package/src/import/build-import.ts +132 -0
  64. package/src/import/history-readers.ts +681 -0
  65. package/src/import/list-running.ts +100 -0
  66. package/src/import/sources.ts +78 -0
  67. package/src/index.ts +315 -30
  68. package/src/projects/manager.ts +16 -3
  69. package/src/render/chunk.ts +17 -10
  70. package/src/render/diff.ts +11 -2
  71. package/src/render/file-summary.ts +31 -1
  72. package/src/render/hashtags.ts +5 -1
  73. package/src/render/markdown.ts +293 -35
  74. package/src/render/plan.ts +127 -0
  75. package/src/render/session-comment.ts +318 -0
  76. package/src/render/telegram-bridge.ts +360 -0
  77. package/src/render/tool-call-detail.ts +400 -19
  78. package/src/render/tool-call-merge.ts +115 -0
  79. package/src/render/tool-call.ts +444 -162
  80. package/src/render/truncate.ts +85 -0
  81. package/src/service/platform.ts +44 -7
  82. package/src/service/windows.ts +30 -6
  83. package/src/sessions/history.ts +98 -0
  84. package/src/sessions/process.ts +7 -0
  85. package/src/sessions/store.ts +3 -0
  86. package/src/sessions/types.ts +5 -0
  87. package/src/stream/streamer.ts +90 -15
  88. package/src/tasks/runner.ts +4 -3
@@ -8,6 +8,10 @@
8
8
  * rapid consecutive text messages per chat within a short debounce window
9
9
  * (`MESSAGE_BATCH_MS`) into a single prompt — one submission, one confirmation.
10
10
  *
11
+ * User messages are replaced by a bot-owned prompt anchor (`#prompt_<id>`) so
12
+ * the chat shows immediate life while CLI/ACP starts, and all AI replies thread
13
+ * to that anchor with the same searchable tag.
14
+ *
11
15
  * While a turn is running, the combined message is queued and runs
12
16
  * automatically when the current turn finishes.
13
17
  * (Wizard input and menu-button text are intercepted by earlier handlers.)
@@ -15,88 +19,165 @@
15
19
  import type { Bot } from "grammy";
16
20
  import { textPrompt } from "../../app/types.js";
17
21
  import { createLogger } from "../../logger.js";
22
+ import { batchKey, forumThreadId } from "../../forum/thread.js";
18
23
  import type { BotDeps } from "../deps.js";
24
+ import { adoptUserPrompt } from "../prompt-anchor.js";
19
25
  import { extractReplyContext } from "../reply-context.js";
26
+ import { resolveForumRuntime } from "./forum.js";
20
27
 
21
28
  const log = createLogger("message");
22
29
 
23
- /** A pending burst of text messages from one chat, awaiting coalescing. */
30
+ /** A pending burst of text messages from one chat/topic, awaiting coalescing. */
24
31
  interface TextBatch {
25
32
  parts: string[];
26
33
  ids: number[];
34
+ /** Forum topic thread id (undefined for private chats / General without id). */
35
+ threadId?: number;
27
36
  /** Reference content if the burst began as a reply to another message. */
28
37
  quoted?: string;
29
38
  timer: NodeJS.Timeout;
30
39
  }
31
40
 
32
41
  export function registerMessages(bot: Bot, deps: BotDeps): void {
33
- const batches = new Map<number, TextBatch>();
42
+ const batches = new Map<string, TextBatch>();
34
43
  const windowMs = deps.cfg.messageBatchMs;
35
44
 
36
- const arm = (chatId: number): NodeJS.Timeout =>
37
- setTimeout(() => void flush(deps, batches, chatId), windowMs);
45
+ const arm = (key: string): NodeJS.Timeout =>
46
+ setTimeout(() => void flush(deps, batches, key), windowMs);
38
47
 
39
48
  bot.on("message:text", async (ctx) => {
40
49
  const text = ctx.message.text;
41
50
  if (!text.trim()) return;
51
+ // Slash commands are handled by bot.command / menu — never batch as agent prompts.
52
+ // (Otherwise /forum_setup and /help would also hit the debounce → agent path.)
53
+ if (!text.includes("\n") && text.startsWith("/")) return;
54
+
42
55
  const chatId = ctx.chat.id;
43
56
  const id = ctx.message.message_id;
57
+ const rawThreadId = ctx.message.message_thread_id;
58
+ const isForum = Boolean(deps.forum?.isActiveForumChat(chatId));
59
+ const threadId = isForum ? forumThreadId(rawThreadId) : rawThreadId;
44
60
  const quoted = extractReplyContext(ctx);
61
+ const key = batchKey(chatId, rawThreadId, isForum);
45
62
 
46
- const batch = batches.get(chatId);
63
+ const batch = batches.get(key);
47
64
  if (batch) {
48
65
  clearTimeout(batch.timer);
49
66
  batch.parts.push(text);
50
67
  batch.ids.push(id);
51
68
  if (quoted && !batch.quoted) batch.quoted = quoted;
52
- batch.timer = arm(chatId);
69
+ batch.timer = arm(key);
53
70
  return;
54
71
  }
55
- batches.set(chatId, { parts: [text], ids: [id], quoted, timer: arm(chatId) });
72
+ batches.set(key, {
73
+ parts: [text],
74
+ ids: [id],
75
+ threadId,
76
+ quoted,
77
+ timer: arm(key),
78
+ });
56
79
  });
57
80
  }
58
81
 
59
82
  /** Coalesce a chat's buffered parts into one prompt and submit it once. */
60
- async function flush(deps: BotDeps, batches: Map<number, TextBatch>, chatId: number): Promise<void> {
61
- const batch = batches.get(chatId);
83
+ async function flush(deps: BotDeps, batches: Map<string, TextBatch>, key: string): Promise<void> {
84
+ const batch = batches.get(key);
62
85
  if (!batch) return;
63
- batches.delete(chatId);
86
+ batches.delete(key);
64
87
 
65
88
  // Telegram splits at 4096 chars, almost always on a line boundary, so
66
89
  // rejoining with a newline reconstructs the original text faithfully.
67
90
  const combined = batch.parts.join("\n").trim();
68
91
  if (!combined) return;
69
92
 
70
- // A lone, single-line "/something" is an unknown-command typo — guide the
71
- // user instead of forwarding it to the agent. Split content never trips
72
- // this: it arrives as multiple parts, and multi-line text is never a command.
93
+ const [chatIdStr] = key.split(":");
94
+ const chatId = Number(chatIdStr);
95
+ const threadId = batch.threadId;
96
+
97
+ // Defense-in-depth: never submit slash-only lines as agent prompts.
73
98
  if (batch.parts.length === 1 && !combined.includes("\n") && combined.startsWith("/")) {
74
- await send(deps, chatId, "Unknown command. Type /help to see what I can do.");
75
99
  return;
76
100
  }
77
101
 
78
- const rt = deps.registry.get(chatId);
102
+ let rt = deps.registry.get(chatId);
103
+ // Forum group: topic-scoped multi-session controller (model/reasoning/running).
104
+ if (deps.forum?.isActiveForumChat(chatId)) {
105
+ const resolved = await resolveForumRuntime(
106
+ deps,
107
+ deps.forum,
108
+ chatId,
109
+ threadId,
110
+ combined,
111
+ batch.ids[0]!,
112
+ );
113
+ if (resolved === "handled" || resolved === "ignore") return;
114
+ rt = resolved.rt;
115
+ // If nothing is selected / no session yet, ensure a new session is created
116
+ // on first message (ensureSession inside submit). If FG has no sessionId
117
+ // after a closed session, start a fresh one for this topic.
118
+ if (!rt.sessionId && !rt.isBusy) {
119
+ try {
120
+ await rt.startNewSession(rt.cwd, rt.projectName);
121
+ } catch {
122
+ /* ensureSession on submit will retry */
123
+ }
124
+ }
125
+ }
126
+
79
127
  const note = batch.parts.length > 1 ? ` (combined ${batch.parts.length} messages)` : "";
128
+ // Hoisted so a submit failure after a successful adopt can still thread the error.
129
+ let replyTo: number | undefined = batch.ids[0];
80
130
  try {
81
- // Thread the reply to the prompt message (the user's message is left intact;
82
- // the agent's response + Done reply to it, and carry searchable hashtags).
83
- const outcome = await rt.submit(textPrompt(combined, batch.ids[0], batch.quoted));
131
+ // Instant bot anchor: user sees the prompt adopted immediately while CLI warms.
132
+ // All AI output + Done reply to this message and carry #prompt_<id>.
133
+ const anchor = await adoptUserPrompt(deps.api, {
134
+ chatId,
135
+ text: combined,
136
+ userMessageIds: batch.ids,
137
+ messageThreadId: threadId,
138
+ projectName: rt.projectName,
139
+ prefix: "\u{1F4DD} Prompt",
140
+ });
141
+ replyTo = anchor?.replyTo ?? batch.ids[0];
142
+ const outcome = await rt.submit(
143
+ textPrompt(combined, replyTo, batch.quoted, { promptId: anchor?.promptId }),
144
+ );
84
145
  if (outcome === "queued") {
85
146
  await send(
86
147
  deps,
87
148
  chatId,
88
149
  `\u{1F4E5} Queued (position ${rt.queueLength})${note} \u2014 I'm still working on the previous task. It'll run next.`,
150
+ threadId,
151
+ replyTo,
89
152
  );
90
153
  }
154
+ // "ran": turn started; complexity is steered silently by the agent.
91
155
  } catch (err) {
92
156
  log.warn(`submit failed for chat ${chatId}: ${(err as Error).message}`);
93
- await send(deps, chatId, `\u274C Couldn't start your message: ${(err as Error).message}`);
157
+ await send(
158
+ deps,
159
+ chatId,
160
+ `\u274C Couldn't start your message: ${(err as Error).message}`,
161
+ threadId,
162
+ replyTo,
163
+ );
94
164
  }
95
165
  }
96
166
 
97
- async function send(deps: BotDeps, chatId: number, text: string): Promise<void> {
167
+ async function send(
168
+ deps: BotDeps,
169
+ chatId: number,
170
+ text: string,
171
+ threadId?: number,
172
+ replyTo?: number,
173
+ ): Promise<void> {
98
174
  try {
99
- await deps.api.sendMessage(chatId, text);
175
+ const extra: Record<string, unknown> = {};
176
+ if (threadId !== undefined) extra.message_thread_id = threadId;
177
+ if (replyTo !== undefined) {
178
+ extra.reply_parameters = { message_id: replyTo, allow_sending_without_reply: true };
179
+ }
180
+ await deps.api.sendMessage(chatId, text, extra);
100
181
  } catch {
101
182
  /* non-fatal */
102
183
  }
@@ -2,11 +2,15 @@
2
2
  * Photo & image-document handler. Downloads images (including multi-image
3
3
  * albums / media groups) and submits them to Grok as ACP image content blocks
4
4
  * alongside the caption text.
5
+ *
6
+ * User media messages are replaced by a bot prompt anchor (`#prompt_<id>`);
7
+ * the agent still receives the downloaded image bytes.
5
8
  */
6
9
  import type { Bot, Context } from "grammy";
7
10
  import type { PromptImage } from "../../app/types.js";
8
11
  import { createLogger } from "../../logger.js";
9
12
  import type { BotDeps } from "../deps.js";
13
+ import { type AdoptMediaItem, adoptUserPrompt } from "../prompt-anchor.js";
10
14
  import { extractReplyContext } from "../reply-context.js";
11
15
 
12
16
  const log = createLogger("photo");
@@ -15,19 +19,31 @@ const GROUP_DEBOUNCE_MS = 900;
15
19
  interface GroupBuffer {
16
20
  chatId: number;
17
21
  caption: string;
22
+ /** Agent-bound image bytes (may be shorter than media if a download failed). */
18
23
  images: PromptImage[];
19
- replyTo?: number;
24
+ /** Chat re-post descriptors (file_id + correct photo vs document kind). */
25
+ media: AdoptMediaItem[];
26
+ /** User Telegram message ids in this album (for delete after anchor). */
27
+ userMessageIds: number[];
20
28
  quoted?: string;
29
+ threadId?: number;
21
30
  timer: NodeJS.Timeout;
22
31
  }
23
32
 
24
33
  export function registerPhotos(bot: Bot, deps: BotDeps): void {
25
34
  const groups = new Map<string, GroupBuffer>();
26
35
 
27
- const onMedia = async (ctx: Context, image: PromptImage | undefined, caption: string): Promise<void> => {
28
- if (!image) return;
36
+ const onMedia = async (
37
+ ctx: Context,
38
+ image: PromptImage | undefined,
39
+ mediaItem: AdoptMediaItem | undefined,
40
+ caption: string,
41
+ ): Promise<void> => {
42
+ // Still re-post to chat when download fails — user must not lose the file.
43
+ if (!mediaItem?.fileId) return;
29
44
  const chatId = ctx.chat!.id;
30
- const replyTo = ctx.message?.message_id;
45
+ const msgId = ctx.message?.message_id;
46
+ const threadId = ctx.message?.message_thread_id;
31
47
  const quoted = extractReplyContext(ctx);
32
48
 
33
49
  // Don't hijack the task wizard.
@@ -36,9 +52,21 @@ export function registerPhotos(bot: Bot, deps: BotDeps): void {
36
52
  return;
37
53
  }
38
54
 
55
+ const images = image ? [image] : [];
56
+ const media = [mediaItem];
57
+
39
58
  const groupId = ctx.message?.media_group_id;
40
59
  if (!groupId) {
41
- await submit(deps, chatId, caption, [image], replyTo, quoted);
60
+ await submit(
61
+ deps,
62
+ chatId,
63
+ caption,
64
+ images,
65
+ media,
66
+ msgId !== undefined ? [msgId] : [],
67
+ quoted,
68
+ threadId,
69
+ );
42
70
  return;
43
71
  }
44
72
 
@@ -46,17 +74,21 @@ export function registerPhotos(bot: Bot, deps: BotDeps): void {
46
74
  const existing = groups.get(groupId);
47
75
  if (existing) {
48
76
  clearTimeout(existing.timer);
49
- existing.images.push(image);
77
+ if (image) existing.images.push(image);
78
+ existing.media.push(mediaItem);
50
79
  if (caption) existing.caption = caption;
51
80
  if (quoted && !existing.quoted) existing.quoted = quoted;
81
+ if (msgId !== undefined) existing.userMessageIds.push(msgId);
52
82
  existing.timer = setTimeout(() => flush(groups, groupId, deps), GROUP_DEBOUNCE_MS);
53
83
  } else {
54
84
  groups.set(groupId, {
55
85
  chatId,
56
86
  caption,
57
- images: [image],
58
- replyTo,
87
+ images,
88
+ media,
89
+ userMessageIds: msgId !== undefined ? [msgId] : [],
59
90
  quoted,
91
+ threadId,
60
92
  timer: setTimeout(() => flush(groups, groupId, deps), GROUP_DEBOUNCE_MS),
61
93
  });
62
94
  }
@@ -65,15 +97,33 @@ export function registerPhotos(bot: Bot, deps: BotDeps): void {
65
97
  bot.on("message:photo", async (ctx) => {
66
98
  const photos = ctx.message.photo;
67
99
  const largest = photos[photos.length - 1];
68
- const image = largest ? await download(ctx, largest.file_id, "image/jpeg", deps.cfg.token) : undefined;
69
- await onMedia(ctx, image, ctx.message.caption ?? "");
100
+ const fileId = largest?.file_id;
101
+ const image = fileId
102
+ ? await download(ctx, fileId, "image/jpeg", deps.cfg.token)
103
+ : undefined;
104
+ await onMedia(
105
+ ctx,
106
+ image,
107
+ fileId ? { type: "photo", fileId } : undefined,
108
+ ctx.message.caption ?? "",
109
+ );
70
110
  });
71
111
 
72
112
  bot.on("message:document", async (ctx, next) => {
73
113
  const doc = ctx.message.document;
74
114
  if (!doc.mime_type?.startsWith("image/")) return next(); // let document-handler logic pass
75
115
  const image = await download(ctx, doc.file_id, doc.mime_type, deps.cfg.token);
76
- await onMedia(ctx, image, ctx.message.caption ?? "");
116
+ // Image-as-document must re-post as document — photo file_ids are a different kind.
117
+ await onMedia(
118
+ ctx,
119
+ image,
120
+ {
121
+ type: "document",
122
+ fileId: doc.file_id,
123
+ fileName: doc.file_name ?? undefined,
124
+ },
125
+ ctx.message.caption ?? "",
126
+ );
77
127
  });
78
128
  }
79
129
 
@@ -81,7 +131,16 @@ async function flush(groups: Map<string, GroupBuffer>, groupId: string, deps: Bo
81
131
  const buf = groups.get(groupId);
82
132
  if (!buf) return;
83
133
  groups.delete(groupId);
84
- await submit(deps, buf.chatId, buf.caption, buf.images, buf.replyTo, buf.quoted);
134
+ await submit(
135
+ deps,
136
+ buf.chatId,
137
+ buf.caption,
138
+ buf.images,
139
+ buf.media,
140
+ buf.userMessageIds,
141
+ buf.quoted,
142
+ buf.threadId,
143
+ );
85
144
  }
86
145
 
87
146
  async function submit(
@@ -89,15 +148,63 @@ async function submit(
89
148
  chatId: number,
90
149
  caption: string,
91
150
  images: PromptImage[],
92
- replyTo?: number,
151
+ media: AdoptMediaItem[],
152
+ userMessageIds: number[],
93
153
  quoted?: string,
154
+ threadId?: number,
94
155
  ): Promise<void> {
95
- const rt = deps.registry.get(chatId);
96
- const outcome = await rt.submit({ text: caption, images, replyTo, quotedText: quoted });
156
+ let rt = deps.registry.get(chatId);
157
+ if (deps.forum?.isActiveForumChat(chatId) && threadId !== undefined) {
158
+ const { forumThreadId } = await import("../../forum/thread.js");
159
+ const tid = forumThreadId(threadId);
160
+ const resolved = deps.forum.resolveCwd(tid);
161
+ if (resolved) {
162
+ rt = deps.registry.getForumTopic(chatId, tid, resolved.cwd, resolved.projectName);
163
+ }
164
+ }
165
+
166
+ const n = Math.max(images.length, media.length);
167
+ const label =
168
+ n === 1
169
+ ? "\u{1F4F7} Image"
170
+ : `\u{1F4F7} ${n} images`;
171
+ const body = caption.trim()
172
+ ? caption
173
+ : n === 1
174
+ ? "(image attached)"
175
+ : `(${n} images attached)`;
176
+
177
+ const anchor = await adoptUserPrompt(deps.api, {
178
+ chatId,
179
+ text: body,
180
+ userMessageIds,
181
+ messageThreadId: threadId,
182
+ projectName: rt.projectName,
183
+ prefix: label,
184
+ media,
185
+ });
186
+
187
+ // Agent gets whatever bytes we could download (may be empty if download failed).
188
+ const outcome = await rt.submit({
189
+ text: caption,
190
+ images,
191
+ replyTo: anchor?.replyTo ?? userMessageIds[0],
192
+ promptId: anchor?.promptId,
193
+ quotedText: quoted,
194
+ });
97
195
  if (outcome === "queued") {
196
+ const extra: Record<string, unknown> =
197
+ threadId !== undefined ? { message_thread_id: threadId } : {};
198
+ if (anchor?.replyTo !== undefined) {
199
+ extra.reply_parameters = {
200
+ message_id: anchor.replyTo,
201
+ allow_sending_without_reply: true,
202
+ };
203
+ }
98
204
  await deps.api.sendMessage(
99
205
  chatId,
100
- `\u{1F4E5} Queued ${images.length} image${images.length > 1 ? "s" : ""} \u2014 will run after the current task.`,
206
+ `\u{1F4E5} Queued ${n} image${n > 1 ? "s" : ""} \u2014 will run after the current task.`,
207
+ extra,
101
208
  );
102
209
  }
103
210
  }
@@ -7,7 +7,7 @@ import { type Bot, type Context, InlineKeyboard } from "grammy";
7
7
  import type { RunningSession, SwitchResult } from "../chat-controller.js";
8
8
  import type { BotDeps } from "../deps.js";
9
9
  import type { HistoryEntry } from "../../sessions/types.js";
10
- import { jsonlMtimeMs, readFirstPrompt } from "../../sessions/history.js";
10
+ import { jsonlMtimeMs, readFirstPrompt, readLastUserPrompt } from "../../sessions/history.js";
11
11
  import { progressBar } from "../../render/progress.js";
12
12
  import { refreshMenu } from "../menu/refresh.js";
13
13
  import { sendMarkdownDoc } from "../telegram-io.js";
@@ -49,29 +49,51 @@ function cleanPrompt(raw: string): string {
49
49
  }
50
50
 
51
51
  /** Build a rich card (plain text, no MarkdownV2) + buttons for one controlled
52
- * session: Switch / History / Close. */
52
+ * session: Switch / History / Close.
53
+ *
54
+ * Comment:
55
+ * always — last user prompt (≤250)
56
+ * busy — plus last AI agent thinking on the next line (≤250)
57
+ */
53
58
  function buildRunningCard(s: RunningSession, deps: BotDeps, now: number): { text: string; kb: InlineKeyboard } {
54
59
  const dot = s.foreground ? "\u25B6\uFE0F" : s.busy ? "\u{1F7E0}" : "\u26AA";
55
60
  const state = s.foreground ? "foreground" : s.busy ? "working" : "idle";
56
61
 
57
62
  let when = "new";
58
- let prompt = "";
63
+ let lastUser = "";
64
+ let firstPrompt = "";
59
65
  if (s.sessionId) {
60
66
  const path = deps.store.jsonlPath(s.sessionId);
61
67
  const mtime = jsonlMtimeMs(path);
62
68
  if (mtime) when = timeAgo(now - mtime);
63
- prompt = cleanPrompt(readFirstPrompt(path));
69
+ lastUser = readLastUserPrompt(path, 40, 250);
70
+ firstPrompt = cleanPrompt(readFirstPrompt(path));
71
+ if (/session import complete/i.test(firstPrompt)) firstPrompt = "";
64
72
  }
65
73
 
74
+ const diskComment = s.sessionId ? deps.store.get(s.sessionId)?.comment?.trim() : undefined;
75
+ // Order: live runtime (user + thinking) → last user from history → disk → first prompt.
76
+ const comment =
77
+ (s.comment && s.comment.trim()) ||
78
+ lastUser ||
79
+ diskComment ||
80
+ firstPrompt;
81
+
66
82
  const meta = [when, state];
67
83
  if (s.busy) meta.push("\u23F3");
68
84
  if (s.unread > 0) meta.push(`${s.unread} \u{1F4EC} unread`);
69
85
 
70
- const lines = [
71
- `${dot} ${s.projectName}`,
72
- prompt ? `\u{1F4AC} \u201C${trunc(prompt, 120)}\u201D` : "\u{1F4AC} (no messages yet)",
73
- `\u{1F552} ${meta.join(" \u00B7 ")}`,
74
- ];
86
+ const lines = [`${dot} ${s.projectName}`];
87
+ if (comment) {
88
+ const parts = comment.split("\n").map((l) => l.trim()).filter(Boolean);
89
+ parts.forEach((part, i) => {
90
+ const icon = i === 0 ? (s.busy ? "\u23F3" : "\u{1F4AC}") : "\u{1F9E0}";
91
+ lines.push(`${icon} ${trunc(part, 250)}`);
92
+ });
93
+ } else {
94
+ lines.push("\u{1F4AC} (no messages yet)");
95
+ }
96
+ lines.push(`\u{1F552} ${meta.join(" \u00B7 ")}`);
75
97
  if (s.progress !== undefined) lines.push(`\u{1F4C8} ${progressBar(s.progress)}`);
76
98
  if (s.sessionId) lines.push(`\u{1F194} ${s.sessionId.slice(0, 8)}`);
77
99
 
@@ -88,14 +110,55 @@ function buildRunningCard(s: RunningSession, deps: BotDeps, now: number): { text
88
110
 
89
111
  export async function showRunning(ctx: Context, deps: BotDeps): Promise<void> {
90
112
  await deps.ephemeral.open(ctx);
91
- const list = dedupeBySession(deps.registry.controller(ctx.chat!.id).list());
113
+ const { resolveScope } = await import("../scope.js");
114
+ const scope = resolveScope(ctx, deps);
115
+ // Topic: only this topic's controlled sessions. Private: this chat + same-project forum sessions.
116
+ let list = dedupeBySession(scope.controller.list());
117
+ if (!scope.isForum) {
118
+ const path = scope.rt.cwd;
119
+ for (const fc of deps.registry.allForumControllers()) {
120
+ if (fc.fixedCwd && samePath(fc.fixedCwd, path)) {
121
+ list = dedupeBySession([...list, ...fc.list().map((s) => ({ ...s, projectName: `${s.projectName} \u00B7 topic` }))]);
122
+ }
123
+ }
124
+ } else {
125
+ // Also surface private-bot sessions for the same project path.
126
+ for (const chatId of deps.settings.chatIds()) {
127
+ if (chatId === scope.chatId) continue;
128
+ try {
129
+ const priv = deps.registry.controller(chatId);
130
+ for (const s of priv.list()) {
131
+ // Match by session cwd via store or name — use store meta if available.
132
+ if (s.sessionId) {
133
+ const meta = deps.store.get(s.sessionId);
134
+ if (meta?.cwd && samePath(meta.cwd, scope.rt.cwd)) {
135
+ list = dedupeBySession([
136
+ ...list,
137
+ { ...s, projectName: `${s.projectName} \u00B7 DM`, foreground: false },
138
+ ]);
139
+ }
140
+ }
141
+ }
142
+ } catch {
143
+ /* skip */
144
+ }
145
+ }
146
+ }
92
147
  if (list.length === 0) {
93
- await deps.ephemeral.reply(ctx, "No sessions controlled yet. Use \u{1F4C1} Project or /new to start one.");
148
+ await deps.ephemeral.reply(
149
+ ctx,
150
+ scope.isForum
151
+ ? "No sessions in this topic yet. Send a message or tap \u{1F195} New."
152
+ : "No sessions controlled yet. Use \u{1F4C1} Project or /new to start one.",
153
+ );
94
154
  return;
95
155
  }
96
156
  const now = Date.now();
97
157
  const shown = list.slice(0, CARD_LIMIT);
98
- await deps.ephemeral.reply(ctx, `\u{1F9ED} Sessions controlled by this chat (${list.length}) \u2014 tap \u{1F500} Switch on a card:`);
158
+ const header = scope.isForum
159
+ ? `\u{1F9ED} Running in topic **${scope.projectName ?? "topic"}** (${list.length})`
160
+ : `\u{1F9ED} Sessions controlled by this chat (${list.length}) \u2014 tap \u{1F500} Switch on a card:`;
161
+ await deps.ephemeral.reply(ctx, header);
99
162
  for (const s of shown) {
100
163
  const { text, kb } = buildRunningCard(s, deps, now);
101
164
  await deps.ephemeral.reply(ctx, text, { reply_markup: kb });
@@ -105,6 +168,11 @@ export async function showRunning(ctx: Context, deps: BotDeps): Promise<void> {
105
168
  }
106
169
  }
107
170
 
171
+ function samePath(a: string, b: string): boolean {
172
+ return a.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase() ===
173
+ b.replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
174
+ }
175
+
108
176
  /** Collapse any cards that share a session id (defensive — the controller
109
177
  * already prunes duplicate runtimes, but never show the same session twice). */
110
178
  function dedupeBySession(list: RunningSession[]): RunningSession[] {
@@ -119,14 +187,76 @@ function dedupeBySession(list: RunningSession[]): RunningSession[] {
119
187
 
120
188
  /** Switch the chat to a session and show its summary + unread. */
121
189
  export async function switchAndShow(ctx: Context, deps: BotDeps, sessionId: string): Promise<void> {
122
- const res = await deps.registry.controller(ctx.chat!.id).switchTo(sessionId);
190
+ const { resolveScope } = await import("../scope.js");
191
+ const scope = resolveScope(ctx, deps);
192
+ // Always bring the session into *this* scope's controller (never switch FG on
193
+ // another surface — that would dual-own one ACP session across controllers).
194
+ let res = await scope.controller.switchTo(sessionId);
195
+ if (!res) {
196
+ const meta = deps.store.get(sessionId);
197
+ let cwd = meta?.cwd;
198
+ let name = meta?.title || (cwd ? basenameSafe(cwd) : undefined);
199
+ if (!cwd) {
200
+ // Discover cwd from whatever controller currently lists it.
201
+ for (const c of [scope.controller, ...deps.registry.allForumControllers()]) {
202
+ const hit = c.list().find((s) => s.sessionId === sessionId);
203
+ if (hit) {
204
+ cwd = (c as { fixedCwd?: string }).fixedCwd || scope.rt.cwd;
205
+ name = hit.projectName || name;
206
+ break;
207
+ }
208
+ }
209
+ }
210
+ if (cwd) {
211
+ // Topic controllers are fixed-path — refuse foreign projects.
212
+ if (scope.controller.fixedCwd && !samePath(scope.controller.fixedCwd, cwd)) {
213
+ await ctx.reply(
214
+ "That session belongs to a different project than this topic.",
215
+ scope.threadExtra,
216
+ );
217
+ return;
218
+ }
219
+ // Drop dual ownership: release from any other controller first.
220
+ await releaseSessionElsewhere(deps, scope.controller, sessionId);
221
+ const hist = (await import("../../sessions/history.js")).readHistory(
222
+ deps.store.jsonlPath(sessionId),
223
+ );
224
+ await scope.controller.addAttach(sessionId, cwd, name || basenameSafe(cwd), hist);
225
+ res = await scope.controller.switchTo(sessionId);
226
+ }
227
+ }
123
228
  if (!res) {
124
- await ctx.reply("Session not found (it may have been closed).");
229
+ await ctx.reply("Session not found (it may have been closed).", scope.threadExtra);
125
230
  return;
126
231
  }
127
232
  await deliverSwitch(ctx, deps, res);
128
233
  }
129
234
 
235
+ /** Stop controlling a session on every controller except `keep`. */
236
+ async function releaseSessionElsewhere(
237
+ deps: BotDeps,
238
+ keep: import("../chat-controller.js").ChatController,
239
+ sessionId: string,
240
+ ): Promise<void> {
241
+ for (const c of deps.registry.allForumControllers()) {
242
+ if (c !== keep && c.findBySession(sessionId)) await c.close(sessionId);
243
+ }
244
+ for (const chatId of deps.settings.chatIds()) {
245
+ try {
246
+ const c = deps.registry.controller(chatId);
247
+ if (c !== keep && c.findBySession(sessionId)) await c.close(sessionId);
248
+ } catch {
249
+ /* skip */
250
+ }
251
+ }
252
+ }
253
+
254
+ function basenameSafe(p: string): string {
255
+ const n = p.replace(/\\/g, "/").replace(/\/+$/, "");
256
+ const i = n.lastIndexOf("/");
257
+ return i >= 0 ? n.slice(i + 1) : n;
258
+ }
259
+
130
260
  export function registerRunning(bot: Bot, deps: BotDeps): void {
131
261
  bot.command("running", (ctx) => showRunning(ctx, deps));
132
262
 
@@ -140,8 +270,24 @@ export function registerRunning(bot: Bot, deps: BotDeps): void {
140
270
 
141
271
  bot.callbackQuery(new RegExp(`^run:close:${UUID}$`), async (ctx) => {
142
272
  const id = ctx.match![1]!;
143
- await deps.registry.controller(ctx.chat!.id).close(id);
144
- await ctx.answerCallbackQuery({ text: "Closed" });
273
+ const { resolveScope } = await import("../scope.js");
274
+ const scope = resolveScope(ctx, deps);
275
+ let closed = await scope.controller.close(id);
276
+ // Card may show a session owned by the other surface — close on owner.
277
+ if (!closed) {
278
+ const fc = deps.registry.forumControllerForSession(id);
279
+ if (fc) closed = await fc.close(id);
280
+ }
281
+ if (!closed) {
282
+ for (const chatId of deps.settings.chatIds()) {
283
+ const c = deps.registry.controller(chatId);
284
+ if (c.findBySession(id)) {
285
+ closed = await c.close(id);
286
+ break;
287
+ }
288
+ }
289
+ }
290
+ await ctx.answerCallbackQuery({ text: closed ? "Closed" : "Not found" });
145
291
  await ctx.deleteMessage().catch(() => {}); // remove just this card
146
292
  });
147
293
  }
@@ -171,6 +317,16 @@ async function deliverSwitch(ctx: Context, deps: BotDeps, res: SwitchResult): Pr
171
317
  if (!res.busy && res.rt.lastTurnSummary) {
172
318
  await ctx.reply(res.rt.lastTurnSummary);
173
319
  }
320
+
321
+ // Re-show post-turn suggestions generated while this session was in the
322
+ // background (or if the user missed the Done notify). Buttons stay wired to
323
+ // the same batch ids so taps still submit the follow-up.
324
+ if (!res.busy) {
325
+ const sug = res.rt.peekPendingSuggestions();
326
+ if (sug) {
327
+ await ctx.reply(sug.text, { reply_markup: sug.markup }).catch(() => {});
328
+ }
329
+ }
174
330
  }
175
331
 
176
332
  function fmtEntry(e: HistoryEntry): string {