@grinev/opencode-telegram-bot 0.19.3 → 0.20.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.
@@ -4,6 +4,7 @@ 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 { interactionManager } from "../../interaction/manager.js";
7
8
  import { keyboardManager } from "../../keyboard/manager.js";
8
9
  import { appendInlineMenuCancelButton, ensureActiveInlineMenu, replyWithInlineMenu, } from "../handlers/inline-menu.js";
9
10
  import { isForegroundBusy, replyBusyBlocked } from "../utils/busy-guard.js";
@@ -12,9 +13,22 @@ import { safeBackgroundTask } from "../../utils/safe-background-task.js";
12
13
  import { config } from "../../config.js";
13
14
  import { getDateLocale, t } from "../../i18n/index.js";
14
15
  import { attachToSession } from "../../attach/service.js";
16
+ import { renderAssistantFinalPartsSafe } from "../utils/assistant-rendering.js";
17
+ import { sendRenderedBotPart } from "../utils/telegram-text.js";
15
18
  const SESSION_CALLBACK_PREFIX = "session:";
16
19
  const SESSION_PAGE_CALLBACK_PREFIX = "session:page:";
20
+ const BACKGROUND_SESSION_CALLBACK_PREFIX = "background-session:";
17
21
  const SESSION_FETCH_EXTRA_COUNT = 1;
22
+ const BACKGROUND_SESSION_KIND_CALLBACK_MARKERS = {
23
+ assistant_response: "a",
24
+ question_asked: "q",
25
+ permission_asked: "p",
26
+ };
27
+ const BACKGROUND_SESSION_KIND_BY_CALLBACK_MARKER = {
28
+ a: "assistant_response",
29
+ q: "question_asked",
30
+ p: "permission_asked",
31
+ };
18
32
  function buildSessionPageCallback(page) {
19
33
  return `${SESSION_PAGE_CALLBACK_PREFIX}${page}`;
20
34
  }
@@ -39,6 +53,35 @@ function parseSessionIdCallback(data) {
39
53
  const sessionId = data.slice(SESSION_CALLBACK_PREFIX.length);
40
54
  return sessionId.length > 0 ? sessionId : null;
41
55
  }
56
+ function parseBackgroundSessionCallback(data) {
57
+ if (!data.startsWith(BACKGROUND_SESSION_CALLBACK_PREFIX)) {
58
+ return null;
59
+ }
60
+ const payload = data.slice(BACKGROUND_SESSION_CALLBACK_PREFIX.length);
61
+ const markerSeparatorIndex = payload.indexOf(":");
62
+ if (markerSeparatorIndex < 0) {
63
+ return payload.length > 0 ? { sessionId: payload, kind: null } : null;
64
+ }
65
+ const marker = payload.slice(0, markerSeparatorIndex);
66
+ const sessionId = payload.slice(markerSeparatorIndex + 1);
67
+ const kind = BACKGROUND_SESSION_KIND_BY_CALLBACK_MARKER[marker];
68
+ if (!kind || sessionId.length === 0) {
69
+ return null;
70
+ }
71
+ return { sessionId, kind };
72
+ }
73
+ async function removeCallbackReplyMarkup(ctx) {
74
+ try {
75
+ await ctx.editMessageReplyMarkup();
76
+ }
77
+ catch (err) {
78
+ logger.debug("[Sessions] Failed to remove background session button:", err);
79
+ }
80
+ }
81
+ export function buildBackgroundSessionOpenKeyboard(sessionId, kind) {
82
+ const marker = BACKGROUND_SESSION_KIND_CALLBACK_MARKERS[kind];
83
+ return new InlineKeyboard().text(t("background.open_session_button"), `${BACKGROUND_SESSION_CALLBACK_PREFIX}${marker}:${sessionId}`);
84
+ }
42
85
  function formatSessionsSelectText(page) {
43
86
  if (page === 0) {
44
87
  return t("sessions.select");
@@ -119,6 +162,140 @@ export async function sessionsCommand(ctx) {
119
162
  await ctx.reply(t("sessions.fetch_error"));
120
163
  }
121
164
  }
165
+ async function selectSessionById(ctx, deps, sessionId, options) {
166
+ const currentProject = getCurrentProject();
167
+ if (!currentProject) {
168
+ clearAllInteractionState("session_select_project_missing");
169
+ await ctx.answerCallbackQuery();
170
+ await ctx.reply(t("sessions.select_project_first"));
171
+ return;
172
+ }
173
+ const { data: session, error } = await opencodeClient.session.get({
174
+ sessionID: sessionId,
175
+ directory: currentProject.worktree,
176
+ });
177
+ if (error || !session) {
178
+ throw error || new Error("Failed to get session details");
179
+ }
180
+ logger.info(`[Bot] Session selected: id=${session.id}, title="${session.title}", project=${currentProject.worktree}, source=${options.source}`);
181
+ const sessionInfo = {
182
+ id: session.id,
183
+ title: session.title,
184
+ directory: currentProject.worktree,
185
+ };
186
+ setCurrentSession(sessionInfo);
187
+ clearAllInteractionState("session_switched");
188
+ await ctx.answerCallbackQuery();
189
+ let loadingMessageId = null;
190
+ if (ctx.chat) {
191
+ try {
192
+ const loadingMessage = await ctx.api.sendMessage(ctx.chat.id, t("sessions.loading_context"));
193
+ loadingMessageId = loadingMessage.message_id;
194
+ }
195
+ catch (err) {
196
+ logger.error("[Sessions] Failed to send loading message:", err);
197
+ }
198
+ }
199
+ try {
200
+ await attachToSession({
201
+ bot: deps.bot,
202
+ chatId: ctx.chat.id,
203
+ session: sessionInfo,
204
+ ensureEventSubscription: deps.ensureEventSubscription,
205
+ });
206
+ }
207
+ catch (err) {
208
+ if (loadingMessageId) {
209
+ try {
210
+ await ctx.api.deleteMessage(ctx.chat.id, loadingMessageId);
211
+ }
212
+ catch (deleteError) {
213
+ logger.debug("[Sessions] Failed to delete loading message after follow error:", deleteError);
214
+ }
215
+ }
216
+ logger.error("[Sessions] Error following selected session:", err);
217
+ throw err;
218
+ }
219
+ if (ctx.chat) {
220
+ const chatId = ctx.chat.id;
221
+ const currentAgent = await resolveProjectAgent();
222
+ keyboardManager.updateAgent(currentAgent);
223
+ const contextInfo = keyboardManager.getContextInfo();
224
+ if (contextInfo) {
225
+ keyboardManager.updateContext(contextInfo.tokensUsed, contextInfo.tokensLimit);
226
+ }
227
+ if (loadingMessageId) {
228
+ try {
229
+ await ctx.api.deleteMessage(chatId, loadingMessageId);
230
+ }
231
+ catch (err) {
232
+ logger.debug("[Sessions] Failed to delete loading message:", err);
233
+ }
234
+ }
235
+ const keyboard = keyboardManager.getKeyboard();
236
+ try {
237
+ await ctx.api.sendMessage(chatId, t("sessions.selected", { title: session.title }), {
238
+ reply_markup: keyboard,
239
+ });
240
+ }
241
+ catch (err) {
242
+ logger.error("[Sessions] Failed to send selection message:", err);
243
+ }
244
+ if (options.postSelectAction === "preview") {
245
+ safeBackgroundTask({
246
+ taskName: "sessions.sendPreview",
247
+ task: () => sendSessionPreview(ctx.api, chatId, null, session.title, session.id, currentProject.worktree),
248
+ });
249
+ }
250
+ if (options.postSelectAction === "latest_assistant_response") {
251
+ safeBackgroundTask({
252
+ taskName: "sessions.sendLatestAssistantResponse",
253
+ task: () => sendLatestAssistantResponse(ctx.api, chatId, session.id, currentProject.worktree),
254
+ });
255
+ }
256
+ }
257
+ if (options.removeCallbackReplyMarkup) {
258
+ await removeCallbackReplyMarkup(ctx);
259
+ }
260
+ if (options.deleteCallbackMessage) {
261
+ await ctx.deleteMessage();
262
+ }
263
+ }
264
+ function shouldBlockBackgroundSessionOpen() {
265
+ const activeInteraction = interactionManager.getSnapshot();
266
+ return activeInteraction !== null && activeInteraction.kind !== "inline";
267
+ }
268
+ export async function handleBackgroundSessionOpen(ctx, deps) {
269
+ const data = ctx.callbackQuery?.data;
270
+ if (!data) {
271
+ return false;
272
+ }
273
+ const payload = parseBackgroundSessionCallback(data);
274
+ if (!payload) {
275
+ return false;
276
+ }
277
+ if (isForegroundBusy()) {
278
+ await replyBusyBlocked(ctx);
279
+ return true;
280
+ }
281
+ if (shouldBlockBackgroundSessionOpen()) {
282
+ await ctx.answerCallbackQuery({ text: t("interaction.blocked.finish_current") }).catch(() => { });
283
+ return true;
284
+ }
285
+ try {
286
+ await selectSessionById(ctx, deps, payload.sessionId, {
287
+ source: "background_notification",
288
+ deleteCallbackMessage: false,
289
+ removeCallbackReplyMarkup: true,
290
+ postSelectAction: payload.kind === "assistant_response" ? "latest_assistant_response" : "none",
291
+ });
292
+ }
293
+ catch (error) {
294
+ logger.error("[Sessions] Error selecting background session:", error);
295
+ await ctx.answerCallbackQuery({ text: t("sessions.select_error"), show_alert: true }).catch(() => { });
296
+ }
297
+ return true;
298
+ }
122
299
  export async function handleSessionSelect(ctx, deps) {
123
300
  const callbackQuery = ctx.callbackQuery;
124
301
  if (!callbackQuery?.data || !callbackQuery.data.startsWith(SESSION_CALLBACK_PREFIX)) {
@@ -167,86 +344,12 @@ export async function handleSessionSelect(ctx, deps) {
167
344
  await ctx.answerCallbackQuery({ text: t("callback.processing_error") });
168
345
  return true;
169
346
  }
170
- const { data: session, error } = await opencodeClient.session.get({
171
- sessionID: sessionId,
172
- directory: currentProject.worktree,
347
+ await selectSessionById(ctx, deps, sessionId, {
348
+ source: "menu",
349
+ deleteCallbackMessage: true,
350
+ removeCallbackReplyMarkup: false,
351
+ postSelectAction: "preview",
173
352
  });
174
- if (error || !session) {
175
- throw error || new Error("Failed to get session details");
176
- }
177
- logger.info(`[Bot] Session selected: id=${session.id}, title="${session.title}", project=${currentProject.worktree}`);
178
- const sessionInfo = {
179
- id: session.id,
180
- title: session.title,
181
- directory: currentProject.worktree,
182
- };
183
- setCurrentSession(sessionInfo);
184
- clearAllInteractionState("session_switched");
185
- await ctx.answerCallbackQuery();
186
- let loadingMessageId = null;
187
- if (ctx.chat) {
188
- try {
189
- const loadingMessage = await ctx.api.sendMessage(ctx.chat.id, t("sessions.loading_context"));
190
- loadingMessageId = loadingMessage.message_id;
191
- }
192
- catch (err) {
193
- logger.error("[Sessions] Failed to send loading message:", err);
194
- }
195
- }
196
- try {
197
- await attachToSession({
198
- bot: deps.bot,
199
- chatId: ctx.chat.id,
200
- session: sessionInfo,
201
- ensureEventSubscription: deps.ensureEventSubscription,
202
- });
203
- }
204
- catch (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
- }
216
- if (ctx.chat) {
217
- const chatId = ctx.chat.id;
218
- const currentAgent = await resolveProjectAgent();
219
- keyboardManager.updateAgent(currentAgent);
220
- const contextInfo = keyboardManager.getContextInfo();
221
- if (contextInfo) {
222
- keyboardManager.updateContext(contextInfo.tokensUsed, contextInfo.tokensLimit);
223
- }
224
- // Delete loading message
225
- if (loadingMessageId) {
226
- try {
227
- await ctx.api.deleteMessage(chatId, loadingMessageId);
228
- }
229
- catch (err) {
230
- logger.debug("[Sessions] Failed to delete loading message:", err);
231
- }
232
- }
233
- // Send session selection confirmation with updated keyboard
234
- const keyboard = keyboardManager.getKeyboard();
235
- try {
236
- await ctx.api.sendMessage(chatId, t("sessions.selected", { title: session.title }), {
237
- reply_markup: keyboard,
238
- });
239
- }
240
- catch (err) {
241
- logger.error("[Sessions] Failed to send selection message:", err);
242
- }
243
- // Send preview asynchronously
244
- safeBackgroundTask({
245
- taskName: "sessions.sendPreview",
246
- task: () => sendSessionPreview(ctx.api, chatId, null, session.title, session.id, currentProject.worktree),
247
- });
248
- }
249
- await ctx.deleteMessage();
250
353
  }
251
354
  catch (error) {
252
355
  clearAllInteractionState("session_select_error");
@@ -257,17 +360,19 @@ export async function handleSessionSelect(ctx, deps) {
257
360
  return true;
258
361
  }
259
362
  const PREVIEW_MESSAGES_LIMIT = 6;
363
+ const LATEST_ASSISTANT_RESPONSE_MESSAGES_LIMIT = 20;
260
364
  const PREVIEW_ITEM_MAX_LENGTH = 420;
261
365
  const TELEGRAM_MESSAGE_LIMIT = 4096;
262
- function extractTextParts(parts) {
366
+ function extractTextParts(parts, options = {}) {
263
367
  const textParts = parts
264
368
  .filter((part) => part.type === "text" && typeof part.text === "string")
265
369
  .map((part) => part.text);
266
370
  if (textParts.length === 0) {
267
371
  return null;
268
372
  }
269
- const text = textParts.join("").trim();
270
- return text.length > 0 ? text : null;
373
+ const text = textParts.join("");
374
+ const normalizedText = options.trim === false ? text : text.trim();
375
+ return normalizedText.trim().length > 0 ? normalizedText : null;
271
376
  }
272
377
  function truncateText(text, maxLength) {
273
378
  if (text.length <= maxLength) {
@@ -351,3 +456,49 @@ async function sendSessionPreview(api, chatId, messageId, sessionTitle, sessionI
351
456
  logger.error("[Sessions] Failed to send session preview message:", err);
352
457
  }
353
458
  }
459
+ async function loadLatestAssistantResponse(sessionId, directory) {
460
+ try {
461
+ const { data: messages, error } = await opencodeClient.session.messages({
462
+ sessionID: sessionId,
463
+ directory,
464
+ limit: LATEST_ASSISTANT_RESPONSE_MESSAGES_LIMIT,
465
+ });
466
+ if (error || !messages) {
467
+ logger.warn("[Sessions] Failed to fetch latest assistant response:", error);
468
+ return null;
469
+ }
470
+ const latestResponse = messages.reduce((latest, message) => {
471
+ if (message.info.role !== "assistant" || message.info.summary) {
472
+ return latest;
473
+ }
474
+ const text = extractTextParts(message.parts, { trim: false });
475
+ if (!text) {
476
+ return latest;
477
+ }
478
+ const created = message.info.time?.created ?? 0;
479
+ if (!latest || created >= latest.created) {
480
+ return { text, created };
481
+ }
482
+ return latest;
483
+ }, null);
484
+ return latestResponse?.text ?? null;
485
+ }
486
+ catch (err) {
487
+ logger.error("[Sessions] Error loading latest assistant response:", err);
488
+ return null;
489
+ }
490
+ }
491
+ async function sendLatestAssistantResponse(api, chatId, sessionId, directory) {
492
+ const responseText = await loadLatestAssistantResponse(sessionId, directory);
493
+ if (!responseText) {
494
+ return;
495
+ }
496
+ const parts = renderAssistantFinalPartsSafe(responseText, TELEGRAM_MESSAGE_LIMIT);
497
+ for (const part of parts) {
498
+ await sendRenderedBotPart({
499
+ api,
500
+ chatId,
501
+ part,
502
+ });
503
+ }
504
+ }
@@ -122,7 +122,7 @@ export async function worktreeCommand(ctx) {
122
122
  await ctx.reply(t("worktree.fetch_error"));
123
123
  }
124
124
  }
125
- export async function handleWorktreeCallback(ctx) {
125
+ export async function handleWorktreeCallback(ctx, deps = {}) {
126
126
  const callbackQuery = ctx.callbackQuery;
127
127
  if (!callbackQuery?.data || !callbackQuery.data.startsWith(WORKTREE_CALLBACK_PREFIX)) {
128
128
  return false;
@@ -178,7 +178,12 @@ export async function handleWorktreeCallback(ctx) {
178
178
  logger.info(`[Bot] Worktree selected: ${selectedWorktree.path}`);
179
179
  await upsertSessionDirectory(selectedWorktree.path, Date.now());
180
180
  const projectInfo = await getProjectByWorktree(selectedWorktree.path);
181
- const replyKeyboard = await switchToProject(ctx, { ...projectInfo, name: selectedWorktree.path }, "worktree_switched");
181
+ const selectedProjectInfo = { ...projectInfo, name: selectedWorktree.path };
182
+ const replyKeyboard = deps.ensureEventSubscription
183
+ ? await switchToProject(ctx, selectedProjectInfo, "worktree_switched", {
184
+ ensureEventSubscription: deps.ensureEventSubscription,
185
+ })
186
+ : await switchToProject(ctx, selectedProjectInfo, "worktree_switched");
182
187
  await ctx.answerCallbackQuery();
183
188
  await ctx.reply(t("worktree.selected", { worktree: selectedWorktree.path }), {
184
189
  reply_markup: replyKeyboard,
@@ -11,6 +11,7 @@ const INLINE_MENU_KINDS = [
11
11
  "variant",
12
12
  "context",
13
13
  "open",
14
+ "ls",
14
15
  "worktree",
15
16
  ];
16
17
  function isInlineMenuKind(value) {
package/dist/bot/index.js CHANGED
@@ -13,12 +13,14 @@ import { startCommand } from "./commands/start.js";
13
13
  import { helpCommand } from "./commands/help.js";
14
14
  import { statusCommand } from "./commands/status.js";
15
15
  import { AGENT_MODE_BUTTON_TEXT_PATTERN, MODEL_BUTTON_TEXT_PATTERN, VARIANT_BUTTON_TEXT_PATTERN, } from "./message-patterns.js";
16
- import { sessionsCommand, handleSessionSelect } from "./commands/sessions.js";
16
+ import { buildBackgroundSessionOpenKeyboard, handleBackgroundSessionOpen, handleSessionSelect, sessionsCommand, } from "./commands/sessions.js";
17
17
  import { newCommand } from "./commands/new.js";
18
18
  import { projectsCommand, handleProjectSelect } from "./commands/projects.js";
19
19
  import { worktreeCommand, handleWorktreeCallback } from "./commands/worktree.js";
20
20
  import { openCommand, handleOpenCallback, clearOpenPathIndex } from "./commands/open.js";
21
+ import { clearLsPathIndex, handleLsCallback, lsCommand } from "./commands/ls.js";
21
22
  import { abortCommand } from "./commands/abort.js";
23
+ import { detachCommand } from "./commands/detach.js";
22
24
  import { opencodeStartCommand } from "./commands/opencode-start.js";
23
25
  import { opencodeStopCommand } from "./commands/opencode-stop.js";
24
26
  import { renameCommand, handleRenameCancel, handleRenameTextAnswer } from "./commands/rename.js";
@@ -52,6 +54,7 @@ import { safeBackgroundTask } from "../utils/safe-background-task.js";
52
54
  import { withTelegramRateLimitRetry } from "../utils/telegram-rate-limit-retry.js";
53
55
  import { pinnedMessageManager } from "../pinned/manager.js";
54
56
  import { t } from "../i18n/index.js";
57
+ import { getCurrentProject } from "../settings/manager.js";
55
58
  import { clearPromptResponseMode, processUserPrompt } from "./handlers/prompt.js";
56
59
  import { handleVoiceMessage } from "./handlers/voice.js";
57
60
  import { handleDocumentMessage } from "./handlers/document.js";
@@ -73,6 +76,7 @@ import { markAttachedSessionBusy, markAttachedSessionIdle, restoreAttachedCurren
73
76
  import { externalUserInputSuppressionManager } from "../external-input/suppression.js";
74
77
  import { prepareAssistantFinalStreamingPayload, prepareAssistantStreamingPayload, renderAssistantFinalPartsSafe, } from "./utils/assistant-rendering.js";
75
78
  import { deliverExternalUserInputNotification } from "./utils/external-user-input.js";
79
+ import { backgroundSessionTracker, } from "../background-session/tracker.js";
76
80
  let botInstance = null;
77
81
  let chatIdInstance = null;
78
82
  let commandsInitialized = false;
@@ -270,6 +274,35 @@ function getToolStreamKey(tool) {
270
274
  }
271
275
  return "default";
272
276
  }
277
+ function formatShortSessionId(sessionId) {
278
+ return sessionId.length <= 8 ? sessionId : sessionId.slice(0, 8);
279
+ }
280
+ function getBackgroundSessionLabel(notification) {
281
+ const title = notification.sessionTitle?.trim();
282
+ if (title) {
283
+ return title;
284
+ }
285
+ return t("background.session_fallback", { id: formatShortSessionId(notification.sessionId) });
286
+ }
287
+ function formatBackgroundSessionNotification(notification) {
288
+ const session = getBackgroundSessionLabel(notification);
289
+ switch (notification.kind) {
290
+ case "assistant_response":
291
+ return t("background.assistant_response", { session });
292
+ case "question_asked":
293
+ return t("background.question_asked", { session });
294
+ case "permission_asked":
295
+ return t("background.permission_asked", { session });
296
+ }
297
+ }
298
+ async function deliverBackgroundSessionNotification(notification) {
299
+ if (!botInstance || !chatIdInstance) {
300
+ return;
301
+ }
302
+ await botInstance.api.sendMessage(chatIdInstance, formatBackgroundSessionNotification(notification), {
303
+ reply_markup: buildBackgroundSessionOpenKeyboard(notification.sessionId, notification.kind),
304
+ });
305
+ }
273
306
  function getEventSessionId(event) {
274
307
  const properties = event.properties;
275
308
  return properties.sessionID || properties.info?.sessionID || properties.part?.sessionID || null;
@@ -279,7 +312,8 @@ function shouldMarkAttachedBusyFromEvent(event) {
279
312
  case "session.status":
280
313
  return event.properties.status?.type === "busy";
281
314
  case "message.updated": {
282
- const info = event.properties.info;
315
+ const info = event.properties
316
+ .info;
283
317
  return info?.role === "assistant" && !info.time?.completed;
284
318
  }
285
319
  case "message.part.updated":
@@ -322,6 +356,11 @@ async function ensureEventSubscription(directory) {
322
356
  return;
323
357
  }
324
358
  summaryAggregator.setTypingIndicatorEnabled(true);
359
+ backgroundSessionTracker.setDirectory(directory);
360
+ backgroundSessionTracker.setOnNotification(deliverBackgroundSessionNotification);
361
+ if (!config.bot.trackBackgroundSessions) {
362
+ backgroundSessionTracker.clear();
363
+ }
325
364
  summaryAggregator.setOnCleared(() => {
326
365
  toolMessageBatcher.clearAll("summary_aggregator_clear");
327
366
  toolCallStreamer.clearAll("summary_aggregator_clear");
@@ -763,7 +802,9 @@ async function ensureEventSubscription(directory) {
763
802
  subscribeToEvents(directory, (event) => {
764
803
  const attached = attachManager.getSnapshot();
765
804
  const eventSessionId = getEventSessionId(event);
766
- if (attached && eventSessionId === attached.sessionId && shouldMarkAttachedBusyFromEvent(event)) {
805
+ if (attached &&
806
+ eventSessionId === attached.sessionId &&
807
+ shouldMarkAttachedBusyFromEvent(event)) {
767
808
  void markAttachedSessionBusy(attached.sessionId);
768
809
  }
769
810
  if (event.type === "session.created" || event.type === "session.updated") {
@@ -775,6 +816,9 @@ async function ensureEventSubscription(directory) {
775
816
  });
776
817
  }
777
818
  }
819
+ if (config.bot.trackBackgroundSessions) {
820
+ backgroundSessionTracker.processEvent(event, getCurrentSession()?.id ?? null);
821
+ }
778
822
  summaryAggregator.processEvent(event);
779
823
  }).catch((err) => {
780
824
  logger.error("Failed to subscribe to events:", err);
@@ -785,6 +829,7 @@ export function createBot() {
785
829
  sessionCompletionTasks.clear();
786
830
  attachManager.clear("bot_startup");
787
831
  assistantRunState.clearAll("bot_startup");
832
+ backgroundSessionTracker.clear();
788
833
  if (heartbeatTimer) {
789
834
  clearInterval(heartbeatTimer);
790
835
  heartbeatTimer = null;
@@ -821,6 +866,12 @@ export function createBot() {
821
866
  });
822
867
  if (restored) {
823
868
  logger.info(`[Bot] Restored followed session after OpenCode ready: reason=${reason}`);
869
+ return;
870
+ }
871
+ const currentProject = getCurrentProject();
872
+ if (config.bot.trackBackgroundSessions && currentProject?.worktree) {
873
+ await ensureEventSubscription(currentProject.worktree);
874
+ logger.info(`[Bot] Started background session tracking after OpenCode ready: reason=${reason}, directory=${currentProject.worktree}`);
824
875
  }
825
876
  });
826
877
  // Heartbeat for diagnostics: verify the event loop is not blocked
@@ -880,9 +931,11 @@ export function createBot() {
880
931
  bot.command("projects", projectsCommand);
881
932
  bot.command("worktree", worktreeCommand);
882
933
  bot.command("open", openCommand);
934
+ bot.command("ls", lsCommand);
883
935
  bot.command("sessions", sessionsCommand);
884
936
  bot.command("new", (ctx) => newCommand(ctx, { bot, ensureEventSubscription }));
885
937
  bot.command("abort", abortCommand);
938
+ bot.command("detach", detachCommand);
886
939
  bot.command("task", taskCommand);
887
940
  bot.command("tasklist", taskListCommand);
888
941
  bot.command("rename", renameCommand);
@@ -898,15 +951,21 @@ export function createBot() {
898
951
  chatIdInstance = ctx.chat.id;
899
952
  }
900
953
  try {
954
+ const handledBackgroundSession = await handleBackgroundSessionOpen(ctx, {
955
+ bot,
956
+ ensureEventSubscription,
957
+ });
901
958
  const handledInlineCancel = await handleInlineMenuCancel(ctx);
902
959
  if (handledInlineCancel) {
903
960
  // Clean up path index when the open-directory menu is cancelled
904
961
  clearOpenPathIndex();
962
+ clearLsPathIndex();
905
963
  }
906
964
  const handledSession = await handleSessionSelect(ctx, { bot, ensureEventSubscription });
907
- const handledProject = await handleProjectSelect(ctx);
908
- const handledWorktree = await handleWorktreeCallback(ctx);
909
- const handledOpen = await handleOpenCallback(ctx);
965
+ const handledProject = await handleProjectSelect(ctx, { ensureEventSubscription });
966
+ const handledWorktree = await handleWorktreeCallback(ctx, { ensureEventSubscription });
967
+ const handledOpen = await handleOpenCallback(ctx, { ensureEventSubscription });
968
+ const handledLs = await handleLsCallback(ctx);
910
969
  const handledQuestion = await handleQuestionCallback(ctx);
911
970
  const handledPermission = await handlePermissionCallback(ctx);
912
971
  const handledAgent = await handleAgentSelect(ctx);
@@ -919,12 +978,14 @@ export function createBot() {
919
978
  const handledCommands = await handleCommandsCallback(ctx, { bot, ensureEventSubscription });
920
979
  const handledSkills = await handleSkillsCallback(ctx, { bot, ensureEventSubscription });
921
980
  const handledMcps = await handleMcpsCallback(ctx);
922
- logger.debug(`[Bot] Callback handled: inlineCancel=${handledInlineCancel}, session=${handledSession}, project=${handledProject}, worktree=${handledWorktree}, open=${handledOpen}, question=${handledQuestion}, permission=${handledPermission}, agent=${handledAgent}, model=${handledModel}, variant=${handledVariant}, compactConfirm=${handledCompactConfirm}, task=${handledTask}, taskList=${handledTaskList}, rename=${handledRenameCancel}, commands=${handledCommands}, skills=${handledSkills}, mcps=${handledMcps}`);
923
- if (!handledInlineCancel &&
981
+ logger.debug(`[Bot] Callback handled: backgroundSession=${handledBackgroundSession}, inlineCancel=${handledInlineCancel}, session=${handledSession}, project=${handledProject}, worktree=${handledWorktree}, open=${handledOpen}, ls=${handledLs}, question=${handledQuestion}, permission=${handledPermission}, agent=${handledAgent}, model=${handledModel}, variant=${handledVariant}, compactConfirm=${handledCompactConfirm}, task=${handledTask}, taskList=${handledTaskList}, rename=${handledRenameCancel}, commands=${handledCommands}, skills=${handledSkills}, mcps=${handledMcps}`);
982
+ if (!handledBackgroundSession &&
983
+ !handledInlineCancel &&
924
984
  !handledSession &&
925
985
  !handledProject &&
926
986
  !handledWorktree &&
927
987
  !handledOpen &&
988
+ !handledLs &&
928
989
  !handledQuestion &&
929
990
  !handledPermission &&
930
991
  !handledAgent &&
@@ -1156,6 +1217,7 @@ export function cleanupBotRuntime(reason) {
1156
1217
  unsubscribeReadyRestore = null;
1157
1218
  stopEventListening();
1158
1219
  summaryAggregator.clear();
1220
+ backgroundSessionTracker.clear();
1159
1221
  responseStreamer.clearAll(reason);
1160
1222
  toolCallStreamer.clearAll(reason);
1161
1223
  toolMessageBatcher.clearAll(reason);
@@ -0,0 +1,57 @@
1
+ import { InputFile } from "grammy";
2
+ import { promises as fs } from "node:fs";
3
+ import path from "node:path";
4
+ import { formatFileSize } from "./file-download.js";
5
+ import { logger } from "../../utils/logger.js";
6
+ import { t } from "../../i18n/index.js";
7
+ const MAX_FILE_SIZE_MB = 50;
8
+ function escapeHtml(text) {
9
+ return text
10
+ .replace(/&/g, "&amp;")
11
+ .replace(/</g, "&lt;")
12
+ .replace(/>/g, "&gt;")
13
+ .replace(/\"/g, "&quot;")
14
+ .replace(/'/g, "&#39;");
15
+ }
16
+ export async function sendDownloadedFile(ctx, filePath, options) {
17
+ try {
18
+ const stat = await fs.stat(filePath).catch(() => null);
19
+ if (!stat) {
20
+ await ctx.reply(`❌ ${t("commands.download.not_found")}: <code>${escapeHtml(filePath)}</code>`, {
21
+ parse_mode: "HTML",
22
+ });
23
+ return false;
24
+ }
25
+ if (!stat.isFile()) {
26
+ await ctx.reply(`❌ ${t("commands.download.not_file")}: <code>${escapeHtml(filePath)}</code>`, {
27
+ parse_mode: "HTML",
28
+ });
29
+ return false;
30
+ }
31
+ if (stat.size > MAX_FILE_SIZE_MB * 1024 * 1024) {
32
+ await ctx.reply(`❌ ${t("commands.download.file_too_large")}: ${formatFileSize(stat.size)} (max: ${MAX_FILE_SIZE_MB}MB)`);
33
+ return false;
34
+ }
35
+ const fileName = path.basename(filePath);
36
+ if (options?.announce !== false) {
37
+ await ctx.reply(`📥 ${t("commands.download.downloading")} <code>${escapeHtml(fileName)}</code>`, {
38
+ parse_mode: "HTML",
39
+ });
40
+ }
41
+ const fileContent = await fs.readFile(filePath);
42
+ const caption = `📄 <code>${escapeHtml(fileName)}</code>\n` +
43
+ `${t("commands.download.size")}: ${formatFileSize(stat.size)}\n` +
44
+ `${t("commands.download.modified")}: ${stat.mtime.toLocaleDateString()}`;
45
+ await ctx.replyWithDocument(new InputFile(fileContent, fileName), {
46
+ caption: caption.slice(0, 1024),
47
+ parse_mode: "HTML",
48
+ });
49
+ logger.info(`[Download] User ${ctx.from?.id} downloaded file: ${filePath}`);
50
+ return true;
51
+ }
52
+ catch (error) {
53
+ logger.error("[Download] Error:", error);
54
+ await ctx.reply(`❌ ${t("commands.download.error")}`);
55
+ return false;
56
+ }
57
+ }