@grinev/opencode-telegram-bot 0.17.0 → 0.19.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 (39) hide show
  1. package/.env.example +22 -1
  2. package/README.md +35 -6
  3. package/dist/app/start-bot-app.js +4 -0
  4. package/dist/attach/manager.js +66 -0
  5. package/dist/attach/service.js +171 -0
  6. package/dist/bot/commands/abort.js +0 -4
  7. package/dist/bot/commands/commands.js +13 -3
  8. package/dist/bot/commands/definitions.js +1 -0
  9. package/dist/bot/commands/mcps.js +394 -0
  10. package/dist/bot/commands/new.js +10 -18
  11. package/dist/bot/commands/opencode-start.js +2 -10
  12. package/dist/bot/commands/sessions.js +19 -20
  13. package/dist/bot/commands/start.js +2 -0
  14. package/dist/bot/handlers/prompt.js +26 -31
  15. package/dist/bot/index.js +82 -10
  16. package/dist/bot/middleware/interaction-guard.js +1 -1
  17. package/dist/bot/utils/busy-guard.js +3 -2
  18. package/dist/bot/utils/external-user-input.js +46 -0
  19. package/dist/bot/utils/switch-project.js +2 -0
  20. package/dist/config.js +25 -6
  21. package/dist/external-input/suppression.js +54 -0
  22. package/dist/i18n/de.js +34 -0
  23. package/dist/i18n/en.js +34 -0
  24. package/dist/i18n/es.js +34 -0
  25. package/dist/i18n/fr.js +34 -0
  26. package/dist/i18n/ru.js +34 -0
  27. package/dist/i18n/zh.js +34 -0
  28. package/dist/interaction/guard.js +2 -1
  29. package/dist/opencode/auto-restart.js +92 -0
  30. package/dist/opencode/events.js +13 -1
  31. package/dist/opencode/process.js +18 -1
  32. package/dist/pinned/manager.js +39 -0
  33. package/dist/summary/aggregator.js +89 -9
  34. package/dist/summary/formatter.js +2 -2
  35. package/dist/summary/markdown-to-telegram-v2.js +99 -0
  36. package/dist/summary/subagent-formatter.js +23 -2
  37. package/dist/telegram/render/inline-renderer.js +18 -0
  38. package/dist/tts/client.js +94 -19
  39. package/package.json +2 -2
@@ -0,0 +1,394 @@
1
+ import { InlineKeyboard } from "grammy";
2
+ import { opencodeClient } from "../../opencode/client.js";
3
+ import { getCurrentProject } from "../../settings/manager.js";
4
+ import { interactionManager } from "../../interaction/manager.js";
5
+ import { logger } from "../../utils/logger.js";
6
+ import { t } from "../../i18n/index.js";
7
+ const MCPS_CALLBACK_PREFIX = "mcps:";
8
+ const MCPS_CALLBACK_SELECT_PREFIX = `${MCPS_CALLBACK_PREFIX}select:`;
9
+ const MCPS_CALLBACK_TOGGLE = `${MCPS_CALLBACK_PREFIX}toggle`;
10
+ const MCPS_CALLBACK_BACK = `${MCPS_CALLBACK_PREFIX}back`;
11
+ const MCPS_CALLBACK_CANCEL = `${MCPS_CALLBACK_PREFIX}cancel`;
12
+ const MAX_INLINE_BUTTON_LABEL_LENGTH = 64;
13
+ function normalizeDirectoryForApi(directory) {
14
+ return directory.replace(/\\/g, "/");
15
+ }
16
+ function getCallbackMessageId(ctx) {
17
+ const message = ctx.callbackQuery?.message;
18
+ if (!message || !("message_id" in message)) {
19
+ return null;
20
+ }
21
+ const messageId = message.message_id;
22
+ return typeof messageId === "number" ? messageId : null;
23
+ }
24
+ function getStatusLabel(status) {
25
+ switch (status.status) {
26
+ case "connected":
27
+ return t("mcps.status.connected");
28
+ case "disabled":
29
+ return t("mcps.status.disabled");
30
+ case "failed":
31
+ return t("mcps.status.failed");
32
+ case "needs_auth":
33
+ return t("mcps.status.needs_auth");
34
+ case "needs_client_registration":
35
+ return t("mcps.status.needs_client_registration");
36
+ default:
37
+ return t("common.unknown");
38
+ }
39
+ }
40
+ function getStatusEmoji(status) {
41
+ switch (status.status) {
42
+ case "connected":
43
+ return "🟢";
44
+ case "disabled":
45
+ return "🔴";
46
+ case "failed":
47
+ return "⚠️";
48
+ case "needs_auth":
49
+ return "🔒";
50
+ case "needs_client_registration":
51
+ return "🔒";
52
+ default:
53
+ return "❓";
54
+ }
55
+ }
56
+ function formatMcpButtonLabel(server) {
57
+ const rawLabel = `${getStatusEmoji(server.status)} ${server.name}`;
58
+ if (rawLabel.length <= MAX_INLINE_BUTTON_LABEL_LENGTH) {
59
+ return rawLabel;
60
+ }
61
+ return `${rawLabel.slice(0, MAX_INLINE_BUTTON_LABEL_LENGTH - 3)}...`;
62
+ }
63
+ function buildMcpsListKeyboard(servers) {
64
+ const keyboard = new InlineKeyboard();
65
+ servers.forEach((server, index) => {
66
+ keyboard
67
+ .text(formatMcpButtonLabel(server), `${MCPS_CALLBACK_SELECT_PREFIX}${index}`)
68
+ .row();
69
+ });
70
+ keyboard.text(t("inline.button.cancel"), MCPS_CALLBACK_CANCEL);
71
+ return keyboard;
72
+ }
73
+ function buildMcpsDetailKeyboard(server) {
74
+ const keyboard = new InlineKeyboard();
75
+ let hasToggleButton = false;
76
+ if (server.status.status === "connected") {
77
+ keyboard.text(t("mcps.button.disable"), MCPS_CALLBACK_TOGGLE);
78
+ hasToggleButton = true;
79
+ }
80
+ else if (server.status.status === "disabled" || server.status.status === "failed") {
81
+ keyboard.text(t("mcps.button.enable"), MCPS_CALLBACK_TOGGLE);
82
+ hasToggleButton = true;
83
+ }
84
+ if (hasToggleButton) {
85
+ keyboard.row();
86
+ }
87
+ keyboard.text(t("mcps.button.back"), MCPS_CALLBACK_BACK);
88
+ keyboard.text(t("inline.button.cancel"), MCPS_CALLBACK_CANCEL);
89
+ return keyboard;
90
+ }
91
+ function buildMcpsDetailText(server) {
92
+ const lines = [];
93
+ lines.push(t("mcps.detail.title", { name: server.name }));
94
+ lines.push("");
95
+ lines.push(t("mcps.detail.status", { status: getStatusLabel(server.status) }));
96
+ if (server.status.status === "failed" || server.status.status === "needs_client_registration") {
97
+ lines.push(t("mcps.detail.error", { error: server.status.error }));
98
+ }
99
+ if (server.status.status === "needs_auth" || server.status.status === "needs_client_registration") {
100
+ lines.push("");
101
+ lines.push(t("mcps.auth_required"));
102
+ }
103
+ return lines.join("\n");
104
+ }
105
+ function parseMcpsServers(value) {
106
+ if (!value || typeof value !== "object") {
107
+ return null;
108
+ }
109
+ if (Array.isArray(value)) {
110
+ const servers = [];
111
+ for (const item of value) {
112
+ if (!item || typeof item !== "object") {
113
+ return null;
114
+ }
115
+ const name = item.name;
116
+ const status = item.status;
117
+ if (typeof name !== "string" || !status || typeof status !== "object") {
118
+ return null;
119
+ }
120
+ const s = status;
121
+ if (typeof s.status !== "string") {
122
+ return null;
123
+ }
124
+ const mcpStatus = { status: s.status };
125
+ if ("error" in s && typeof s.error === "string") {
126
+ mcpStatus.error = s.error;
127
+ }
128
+ servers.push({ name, status: mcpStatus });
129
+ }
130
+ return servers;
131
+ }
132
+ const entries = Object.entries(value);
133
+ const servers = [];
134
+ for (const [name, status] of entries) {
135
+ if (!status || typeof status !== "object") {
136
+ return null;
137
+ }
138
+ const s = status;
139
+ if (typeof s.status !== "string") {
140
+ return null;
141
+ }
142
+ const mcpStatus = { status: s.status };
143
+ if ("error" in s && typeof s.error === "string") {
144
+ mcpStatus.error = s.error;
145
+ }
146
+ servers.push({ name, status: mcpStatus });
147
+ }
148
+ return servers;
149
+ }
150
+ function parseMcpsMetadata(state) {
151
+ if (!state || state.kind !== "custom") {
152
+ return null;
153
+ }
154
+ const flow = state.metadata.flow;
155
+ const stage = state.metadata.stage;
156
+ const messageId = state.metadata.messageId;
157
+ const projectDirectory = state.metadata.projectDirectory;
158
+ if (flow !== "mcps" || typeof messageId !== "number" || typeof projectDirectory !== "string") {
159
+ return null;
160
+ }
161
+ const servers = parseMcpsServers(state.metadata.servers);
162
+ if (!servers) {
163
+ return null;
164
+ }
165
+ if (stage === "list") {
166
+ return {
167
+ flow,
168
+ stage,
169
+ messageId,
170
+ projectDirectory,
171
+ servers,
172
+ };
173
+ }
174
+ if (stage === "detail") {
175
+ const serverName = state.metadata.serverName;
176
+ if (typeof serverName !== "string" || !serverName.trim()) {
177
+ return null;
178
+ }
179
+ return {
180
+ flow,
181
+ stage,
182
+ messageId,
183
+ projectDirectory,
184
+ serverName,
185
+ servers,
186
+ };
187
+ }
188
+ return null;
189
+ }
190
+ function clearMcpsInteraction(reason) {
191
+ const metadata = parseMcpsMetadata(interactionManager.getSnapshot());
192
+ if (metadata) {
193
+ interactionManager.clear(reason);
194
+ }
195
+ }
196
+ function parseSelectIndex(data) {
197
+ if (!data.startsWith(MCPS_CALLBACK_SELECT_PREFIX)) {
198
+ return null;
199
+ }
200
+ const index = Number(data.slice(MCPS_CALLBACK_SELECT_PREFIX.length));
201
+ if (!Number.isInteger(index) || index < 0) {
202
+ return null;
203
+ }
204
+ return index;
205
+ }
206
+ async function getMcpServerList(projectDirectory) {
207
+ const { data, error } = await opencodeClient.mcp.status({
208
+ directory: normalizeDirectoryForApi(projectDirectory),
209
+ });
210
+ if (error || !data) {
211
+ throw error || new Error("No MCP status data received");
212
+ }
213
+ const servers = parseMcpsServers(data);
214
+ if (!servers) {
215
+ throw new Error("Invalid MCP status data format");
216
+ }
217
+ return servers;
218
+ }
219
+ async function toggleMcpServer(projectDirectory, serverName, enable) {
220
+ const params = {
221
+ name: serverName,
222
+ directory: normalizeDirectoryForApi(projectDirectory),
223
+ };
224
+ if (enable) {
225
+ const { error } = await opencodeClient.mcp.connect(params);
226
+ if (error) {
227
+ throw error;
228
+ }
229
+ }
230
+ else {
231
+ const { error } = await opencodeClient.mcp.disconnect(params);
232
+ if (error) {
233
+ throw error;
234
+ }
235
+ }
236
+ }
237
+ export async function mcpsCommand(ctx) {
238
+ try {
239
+ const currentProject = getCurrentProject();
240
+ if (!currentProject) {
241
+ await ctx.reply(t("bot.project_not_selected"));
242
+ return;
243
+ }
244
+ const servers = await getMcpServerList(currentProject.worktree);
245
+ if (servers.length === 0) {
246
+ await ctx.reply(t("mcps.empty"));
247
+ return;
248
+ }
249
+ const keyboard = buildMcpsListKeyboard(servers);
250
+ const message = await ctx.reply(t("mcps.select"), {
251
+ reply_markup: keyboard,
252
+ });
253
+ interactionManager.start({
254
+ kind: "custom",
255
+ expectedInput: "callback",
256
+ metadata: {
257
+ flow: "mcps",
258
+ stage: "list",
259
+ messageId: message.message_id,
260
+ projectDirectory: currentProject.worktree,
261
+ servers,
262
+ },
263
+ });
264
+ }
265
+ catch (error) {
266
+ logger.error("[Mcps] Error fetching MCP servers list:", error);
267
+ await ctx.reply(t("mcps.fetch_error"));
268
+ }
269
+ }
270
+ export async function handleMcpsCallback(ctx) {
271
+ const data = ctx.callbackQuery?.data;
272
+ if (!data || !data.startsWith(MCPS_CALLBACK_PREFIX)) {
273
+ return false;
274
+ }
275
+ const metadata = parseMcpsMetadata(interactionManager.getSnapshot());
276
+ const callbackMessageId = getCallbackMessageId(ctx);
277
+ if (!metadata || callbackMessageId === null || metadata.messageId !== callbackMessageId) {
278
+ await ctx.answerCallbackQuery({ text: t("inline.inactive_callback"), show_alert: true });
279
+ return true;
280
+ }
281
+ try {
282
+ if (data === MCPS_CALLBACK_CANCEL) {
283
+ clearMcpsInteraction("mcps_cancelled");
284
+ await ctx.answerCallbackQuery({ text: t("inline.cancelled_callback") });
285
+ await ctx.deleteMessage().catch(() => { });
286
+ return true;
287
+ }
288
+ if (data === MCPS_CALLBACK_BACK) {
289
+ if (metadata.stage !== "detail") {
290
+ await ctx.answerCallbackQuery({ text: t("callback.processing_error"), show_alert: true });
291
+ return true;
292
+ }
293
+ const servers = await getMcpServerList(metadata.projectDirectory);
294
+ const keyboard = buildMcpsListKeyboard(servers);
295
+ await ctx.editMessageText(t("mcps.select"), { reply_markup: keyboard });
296
+ await ctx.answerCallbackQuery();
297
+ interactionManager.transition({
298
+ expectedInput: "callback",
299
+ metadata: {
300
+ flow: "mcps",
301
+ stage: "list",
302
+ messageId: metadata.messageId,
303
+ projectDirectory: metadata.projectDirectory,
304
+ servers,
305
+ },
306
+ });
307
+ return true;
308
+ }
309
+ if (data === MCPS_CALLBACK_TOGGLE) {
310
+ if (metadata.stage !== "detail") {
311
+ await ctx.answerCallbackQuery({ text: t("callback.processing_error"), show_alert: true });
312
+ return true;
313
+ }
314
+ const serverName = metadata.serverName;
315
+ const server = metadata.servers.find((s) => s.name === serverName);
316
+ if (!server) {
317
+ await ctx.answerCallbackQuery({ text: t("inline.inactive_callback"), show_alert: true });
318
+ return true;
319
+ }
320
+ const enable = server.status.status !== "connected";
321
+ await ctx.answerCallbackQuery({ text: enable ? t("mcps.enabling") : t("mcps.disabling") });
322
+ await toggleMcpServer(metadata.projectDirectory, serverName, enable);
323
+ const updatedServers = await getMcpServerList(metadata.projectDirectory);
324
+ const updatedServer = updatedServers.find((s) => s.name === serverName);
325
+ if (!updatedServer) {
326
+ await ctx.editMessageText(t("mcps.select"), {
327
+ reply_markup: buildMcpsListKeyboard(updatedServers),
328
+ });
329
+ interactionManager.transition({
330
+ expectedInput: "callback",
331
+ metadata: {
332
+ flow: "mcps",
333
+ stage: "list",
334
+ messageId: metadata.messageId,
335
+ projectDirectory: metadata.projectDirectory,
336
+ servers: updatedServers,
337
+ },
338
+ });
339
+ return true;
340
+ }
341
+ await ctx.editMessageText(buildMcpsDetailText(updatedServer), {
342
+ reply_markup: buildMcpsDetailKeyboard(updatedServer),
343
+ });
344
+ interactionManager.transition({
345
+ expectedInput: "callback",
346
+ metadata: {
347
+ flow: "mcps",
348
+ stage: "detail",
349
+ messageId: metadata.messageId,
350
+ projectDirectory: metadata.projectDirectory,
351
+ serverName: updatedServer.name,
352
+ servers: updatedServers,
353
+ },
354
+ });
355
+ return true;
356
+ }
357
+ if (data.startsWith(MCPS_CALLBACK_SELECT_PREFIX)) {
358
+ if (metadata.stage !== "list") {
359
+ await ctx.answerCallbackQuery({ text: t("callback.processing_error"), show_alert: true });
360
+ return true;
361
+ }
362
+ const serverIndex = parseSelectIndex(data);
363
+ const server = serverIndex === null ? undefined : metadata.servers[serverIndex];
364
+ if (!server) {
365
+ await ctx.answerCallbackQuery({ text: t("inline.inactive_callback"), show_alert: true });
366
+ return true;
367
+ }
368
+ await ctx.answerCallbackQuery();
369
+ await ctx.editMessageText(buildMcpsDetailText(server), {
370
+ reply_markup: buildMcpsDetailKeyboard(server),
371
+ });
372
+ interactionManager.transition({
373
+ expectedInput: "callback",
374
+ metadata: {
375
+ flow: "mcps",
376
+ stage: "detail",
377
+ messageId: metadata.messageId,
378
+ projectDirectory: metadata.projectDirectory,
379
+ serverName: server.name,
380
+ servers: metadata.servers,
381
+ },
382
+ });
383
+ return true;
384
+ }
385
+ await ctx.answerCallbackQuery({ text: t("callback.unknown_command") });
386
+ return true;
387
+ }
388
+ catch (error) {
389
+ logger.error("[Mcps] Error handling MCP callback:", error);
390
+ clearMcpsInteraction("mcps_callback_error");
391
+ await ctx.answerCallbackQuery({ text: t("mcps.toggle_error") }).catch(() => { });
392
+ return true;
393
+ }
394
+ }
@@ -3,8 +3,6 @@ import { setCurrentSession } from "../../session/manager.js";
3
3
  import { ingestSessionInfoForCache } from "../../session/cache-manager.js";
4
4
  import { getCurrentProject } from "../../settings/manager.js";
5
5
  import { clearAllInteractionState } from "../../interaction/cleanup.js";
6
- import { summaryAggregator } from "../../summary/aggregator.js";
7
- import { pinnedMessageManager } from "../../pinned/manager.js";
8
6
  import { keyboardManager } from "../../keyboard/manager.js";
9
7
  import { getStoredAgent, resolveProjectAgent } from "../../agent/manager.js";
10
8
  import { getStoredModel } from "../../model/manager.js";
@@ -13,7 +11,8 @@ import { createMainKeyboard } from "../utils/keyboard.js";
13
11
  import { isForegroundBusy, replyBusyBlocked } from "../utils/busy-guard.js";
14
12
  import { logger } from "../../utils/logger.js";
15
13
  import { t } from "../../i18n/index.js";
16
- export async function newCommand(ctx) {
14
+ import { attachToSession } from "../../attach/service.js";
15
+ export async function newCommand(ctx, deps) {
17
16
  try {
18
17
  if (isForegroundBusy()) {
19
18
  await replyBusyBlocked(ctx);
@@ -38,27 +37,20 @@ export async function newCommand(ctx) {
38
37
  directory: currentProject.worktree,
39
38
  };
40
39
  setCurrentSession(sessionInfo);
41
- summaryAggregator.clear();
42
40
  clearAllInteractionState("session_created");
43
41
  await ingestSessionInfoForCache(session);
44
- // Initialize pinned message manager and create pinned message
45
- if (!pinnedMessageManager.isInitialized()) {
46
- pinnedMessageManager.initialize(ctx.api, ctx.chat.id);
47
- }
48
- // Initialize keyboard manager if not already
49
- keyboardManager.initialize(ctx.api, ctx.chat.id);
50
- try {
51
- await pinnedMessageManager.onSessionChange(session.id, session.title);
52
- }
53
- catch (err) {
54
- logger.error("[Bot] Error creating pinned message:", err);
55
- }
42
+ await attachToSession({
43
+ bot: deps.bot,
44
+ chatId: ctx.chat.id,
45
+ session: sessionInfo,
46
+ ensureEventSubscription: deps.ensureEventSubscription,
47
+ });
56
48
  // Get current state for keyboard
57
49
  const currentAgent = await resolveProjectAgent(getStoredAgent());
58
50
  const currentModel = getStoredModel();
59
- const contextInfo = pinnedMessageManager.getContextInfo();
60
- const variantName = formatVariantForButton(currentModel.variant || "default");
61
51
  keyboardManager.updateAgent(currentAgent);
52
+ const contextInfo = keyboardManager.getContextInfo();
53
+ const variantName = formatVariantForButton(currentModel.variant || "default");
62
54
  const keyboard = createMainKeyboard(currentAgent, currentModel, contextInfo ?? undefined, variantName);
63
55
  await ctx.reply(t("new.created", { title: session.title }), {
64
56
  reply_markup: keyboard,
@@ -1,7 +1,6 @@
1
- import { spawn } from "node:child_process";
2
1
  import { config } from "../../config.js";
3
2
  import { opencodeClient } from "../../opencode/client.js";
4
- import { resolveLocalOpencodeTarget } from "../../opencode/process.js";
3
+ import { resolveLocalOpencodeTarget, startLocalOpencodeServer } from "../../opencode/process.js";
5
4
  import { logger } from "../../utils/logger.js";
6
5
  import { t } from "../../i18n/index.js";
7
6
  import { editBotText } from "../utils/telegram-text.js";
@@ -50,14 +49,7 @@ export async function opencodeStartCommand(ctx) {
50
49
  // Server not accessible, continue with start.
51
50
  }
52
51
  const statusMessage = await ctx.reply(t("opencode_start.starting"));
53
- const isWindows = process.platform === "win32";
54
- const command = isWindows ? "cmd.exe" : "opencode";
55
- const args = isWindows ? ["/c", "opencode", "serve"] : ["serve"];
56
- const childProcess = spawn(command, args, {
57
- detached: true,
58
- stdio: "ignore",
59
- windowsHide: isWindows,
60
- });
52
+ const childProcess = startLocalOpencodeServer(localTarget);
61
53
  childProcess.once("error", (error) => {
62
54
  logger.error("[Bot] OpenCode server process failed to start", error);
63
55
  });
@@ -4,8 +4,6 @@ import { resolveProjectAgent } from "../../agent/manager.js";
4
4
  import { setCurrentSession } from "../../session/manager.js";
5
5
  import { getCurrentProject } from "../../settings/manager.js";
6
6
  import { clearAllInteractionState } from "../../interaction/cleanup.js";
7
- import { summaryAggregator } from "../../summary/aggregator.js";
8
- import { pinnedMessageManager } from "../../pinned/manager.js";
9
7
  import { keyboardManager } from "../../keyboard/manager.js";
10
8
  import { appendInlineMenuCancelButton, ensureActiveInlineMenu, replyWithInlineMenu, } from "../handlers/inline-menu.js";
11
9
  import { isForegroundBusy, replyBusyBlocked } from "../utils/busy-guard.js";
@@ -13,6 +11,7 @@ import { logger } from "../../utils/logger.js";
13
11
  import { safeBackgroundTask } from "../../utils/safe-background-task.js";
14
12
  import { config } from "../../config.js";
15
13
  import { getDateLocale, t } from "../../i18n/index.js";
14
+ import { attachToSession } from "../../attach/service.js";
16
15
  const SESSION_CALLBACK_PREFIX = "session:";
17
16
  const SESSION_PAGE_CALLBACK_PREFIX = "session:page:";
18
17
  const SESSION_FETCH_EXTRA_COUNT = 1;
@@ -120,7 +119,7 @@ export async function sessionsCommand(ctx) {
120
119
  await ctx.reply(t("sessions.fetch_error"));
121
120
  }
122
121
  }
123
- export async function handleSessionSelect(ctx) {
122
+ export async function handleSessionSelect(ctx, deps) {
124
123
  const callbackQuery = ctx.callbackQuery;
125
124
  if (!callbackQuery?.data || !callbackQuery.data.startsWith(SESSION_CALLBACK_PREFIX)) {
126
125
  return false;
@@ -182,7 +181,6 @@ export async function handleSessionSelect(ctx) {
182
181
  directory: currentProject.worktree,
183
182
  };
184
183
  setCurrentSession(sessionInfo);
185
- summaryAggregator.clear();
186
184
  clearAllInteractionState("session_switched");
187
185
  await ctx.answerCallbackQuery();
188
186
  let loadingMessageId = null;
@@ -195,30 +193,31 @@ export async function handleSessionSelect(ctx) {
195
193
  logger.error("[Sessions] Failed to send loading message:", err);
196
194
  }
197
195
  }
198
- // Initialize pinned message manager if not already
199
- if (!pinnedMessageManager.isInitialized() && ctx.chat) {
200
- pinnedMessageManager.initialize(ctx.api, ctx.chat.id);
201
- }
202
- // Initialize keyboard manager if not already
203
- if (ctx.chat) {
204
- keyboardManager.initialize(ctx.api, ctx.chat.id);
205
- }
206
196
  try {
207
- // Create new pinned message for this session
208
- await pinnedMessageManager.onSessionChange(session.id, session.title);
209
- // Load context from session history (for existing sessions)
210
- // Wait for it to complete so keyboard has correct context
211
- await pinnedMessageManager.loadContextFromHistory(session.id, currentProject.worktree);
197
+ await attachToSession({
198
+ bot: deps.bot,
199
+ chatId: ctx.chat.id,
200
+ session: sessionInfo,
201
+ ensureEventSubscription: deps.ensureEventSubscription,
202
+ });
212
203
  }
213
204
  catch (err) {
214
- logger.error("[Bot] Error initializing pinned message:", err);
205
+ if (loadingMessageId) {
206
+ try {
207
+ await ctx.api.deleteMessage(ctx.chat.id, loadingMessageId);
208
+ }
209
+ catch (deleteError) {
210
+ logger.debug("[Sessions] Failed to delete loading message after follow error:", deleteError);
211
+ }
212
+ }
213
+ logger.error("[Sessions] Error following selected session:", err);
214
+ throw err;
215
215
  }
216
216
  if (ctx.chat) {
217
217
  const chatId = ctx.chat.id;
218
218
  const currentAgent = await resolveProjectAgent();
219
219
  keyboardManager.updateAgent(currentAgent);
220
- // Update keyboard with loaded context (callback executes async via setImmediate, so update manually)
221
- const contextInfo = pinnedMessageManager.getContextInfo();
220
+ const contextInfo = keyboardManager.getContextInfo();
222
221
  if (contextInfo) {
223
222
  keyboardManager.updateContext(contextInfo.tokensUsed, contextInfo.tokensLimit);
224
223
  }
@@ -10,6 +10,7 @@ import { foregroundSessionState } from "../../scheduled-task/foreground-state.js
10
10
  import { abortCurrentOperation } from "./abort.js";
11
11
  import { t } from "../../i18n/index.js";
12
12
  import { assistantRunState } from "../assistant-run-state.js";
13
+ import { detachAttachedSession } from "../../attach/service.js";
13
14
  export async function startCommand(ctx) {
14
15
  if (ctx.chat) {
15
16
  if (!pinnedMessageManager.isInitialized()) {
@@ -18,6 +19,7 @@ export async function startCommand(ctx) {
18
19
  keyboardManager.initialize(ctx.api, ctx.chat.id);
19
20
  }
20
21
  await abortCurrentOperation(ctx, { notifyUser: false });
22
+ detachAttachedSession("start_command_reset");
21
23
  foregroundSessionState.clearAll("start_command_reset");
22
24
  assistantRunState.clearAll("start_command_reset");
23
25
  clearSession();