@grinev/opencode-telegram-bot 0.1.1 → 0.2.1

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.
@@ -3,6 +3,7 @@ import { createBot } from "../bot/index.js";
3
3
  import { config } from "../config.js";
4
4
  import { loadSettings } from "../settings/manager.js";
5
5
  import { processManager } from "../process/manager.js";
6
+ import { warmupSessionDirectoryCache } from "../session/cache-manager.js";
6
7
  import { getRuntimeMode } from "../runtime/mode.js";
7
8
  import { logger } from "../utils/logger.js";
8
9
  async function getBotVersion() {
@@ -25,6 +26,7 @@ export async function startBotApp() {
25
26
  logger.debug(`[Runtime] Application start mode: ${mode}`);
26
27
  await loadSettings();
27
28
  await processManager.initialize();
29
+ await warmupSessionDirectoryCache();
28
30
  const bot = createBot();
29
31
  const webhookInfo = await bot.api.getWebhookInfo();
30
32
  if (webhookInfo.url) {
@@ -1,5 +1,6 @@
1
1
  import { opencodeClient } from "../../opencode/client.js";
2
2
  import { setCurrentSession } from "../../session/manager.js";
3
+ import { ingestSessionInfoForCache } from "../../session/cache-manager.js";
3
4
  import { getCurrentProject } from "../../settings/manager.js";
4
5
  import { pinnedMessageManager } from "../../pinned/manager.js";
5
6
  import { keyboardManager } from "../../keyboard/manager.js";
@@ -30,6 +31,7 @@ export async function newCommand(ctx) {
30
31
  directory: currentProject.worktree,
31
32
  };
32
33
  setCurrentSession(sessionInfo);
34
+ await ingestSessionInfoForCache(session);
33
35
  // Initialize pinned message manager and create pinned message
34
36
  if (!pinnedMessageManager.isInitialized()) {
35
37
  pinnedMessageManager.initialize(ctx.api, ctx.chat.id);
@@ -1,6 +1,7 @@
1
1
  import { InlineKeyboard } from "grammy";
2
2
  import { setCurrentProject, getCurrentProject } from "../../settings/manager.js";
3
3
  import { getProjects } from "../../project/manager.js";
4
+ import { syncSessionDirectoryCache } from "../../session/cache-manager.js";
4
5
  import { clearSession } from "../../session/manager.js";
5
6
  import { summaryAggregator } from "../../summary/aggregator.js";
6
7
  import { pinnedMessageManager } from "../../pinned/manager.js";
@@ -12,6 +13,7 @@ import { createMainKeyboard } from "../utils/keyboard.js";
12
13
  import { logger } from "../../utils/logger.js";
13
14
  import { t } from "../../i18n/index.js";
14
15
  const MAX_INLINE_BUTTON_LABEL_LENGTH = 64;
16
+ const MAX_PROJECTS_TO_SHOW = 10;
15
17
  function formatProjectButtonLabel(label, isActive) {
16
18
  const prefix = isActive ? "✅ " : "";
17
19
  const availableLength = MAX_INLINE_BUTTON_LABEL_LENGTH - prefix.length;
@@ -22,14 +24,16 @@ function formatProjectButtonLabel(label, isActive) {
22
24
  }
23
25
  export async function projectsCommand(ctx) {
24
26
  try {
27
+ await syncSessionDirectoryCache();
25
28
  const projects = await getProjects();
26
- if (projects.length === 0) {
29
+ const projectsToShow = projects.slice(0, MAX_PROJECTS_TO_SHOW);
30
+ if (projectsToShow.length === 0) {
27
31
  await ctx.reply(t("projects.empty"));
28
32
  return;
29
33
  }
30
34
  const keyboard = new InlineKeyboard();
31
35
  const currentProject = getCurrentProject();
32
- projects.forEach((project, index) => {
36
+ projectsToShow.forEach((project, index) => {
33
37
  const isActive = currentProject &&
34
38
  (project.id === currentProject.id || project.worktree === currentProject.worktree);
35
39
  const label = project.name
@@ -2,6 +2,7 @@ import { InlineKeyboard } from "grammy";
2
2
  import { permissionManager } from "../../permission/manager.js";
3
3
  import { opencodeClient } from "../../opencode/client.js";
4
4
  import { getCurrentProject } from "../../settings/manager.js";
5
+ import { getCurrentSession } from "../../session/manager.js";
5
6
  import { summaryAggregator } from "../../summary/aggregator.js";
6
7
  import { logger } from "../../utils/logger.js";
7
8
  import { safeBackgroundTask } from "../../utils/safe-background-task.js";
@@ -69,8 +70,10 @@ export async function handlePermissionCallback(ctx) {
69
70
  async function handlePermissionReply(ctx, reply) {
70
71
  const requestID = permissionManager.getRequestID();
71
72
  const currentProject = getCurrentProject();
73
+ const currentSession = getCurrentSession();
72
74
  const chatId = ctx.chat?.id;
73
- if (!requestID || !currentProject || !chatId) {
75
+ const directory = currentSession?.directory ?? currentProject?.worktree;
76
+ if (!requestID || !directory || !chatId) {
74
77
  await ctx.answerCallbackQuery({
75
78
  text: t("permission.no_active_request_callback"),
76
79
  show_alert: true,
@@ -94,7 +97,7 @@ async function handlePermissionReply(ctx, reply) {
94
97
  taskName: "permission.reply",
95
98
  task: () => opencodeClient.permission.reply({
96
99
  requestID,
97
- directory: currentProject.worktree,
100
+ directory,
98
101
  reply,
99
102
  }),
100
103
  onSuccess: ({ error }) => {
@@ -2,6 +2,7 @@ import { InlineKeyboard } from "grammy";
2
2
  import { questionManager } from "../../question/manager.js";
3
3
  import { opencodeClient } from "../../opencode/client.js";
4
4
  import { getCurrentProject } from "../../settings/manager.js";
5
+ import { getCurrentSession } from "../../session/manager.js";
5
6
  import { summaryAggregator } from "../../summary/aggregator.js";
6
7
  import { logger } from "../../utils/logger.js";
7
8
  import { safeBackgroundTask } from "../../utils/safe-background-task.js";
@@ -194,9 +195,11 @@ async function showPollSummary(bot, chatId) {
194
195
  }
195
196
  async function sendAllAnswersToAgent(bot, chatId) {
196
197
  const currentProject = getCurrentProject();
198
+ const currentSession = getCurrentSession();
197
199
  const requestID = questionManager.getRequestID();
198
200
  const totalQuestions = questionManager.getTotalQuestions();
199
- if (!currentProject) {
201
+ const directory = currentSession?.directory ?? currentProject?.worktree;
202
+ if (!directory) {
200
203
  logger.error("[QuestionHandler] No project for sending answers");
201
204
  await bot.sendMessage(chatId, t("question.no_active_project"));
202
205
  return;
@@ -233,7 +236,7 @@ async function sendAllAnswersToAgent(bot, chatId) {
233
236
  taskName: "question.reply",
234
237
  task: () => opencodeClient.question.reply({
235
238
  requestID,
236
- directory: currentProject.worktree,
239
+ directory,
237
240
  answers: allAnswers,
238
241
  }),
239
242
  onSuccess: ({ error }) => {
package/dist/bot/index.js CHANGED
@@ -8,6 +8,7 @@ import { BOT_COMMANDS } from "./commands/definitions.js";
8
8
  import { startCommand } from "./commands/start.js";
9
9
  import { helpCommand } from "./commands/help.js";
10
10
  import { statusCommand } from "./commands/status.js";
11
+ import { MODEL_BUTTON_TEXT_PATTERN, VARIANT_BUTTON_TEXT_PATTERN } from "./message-patterns.js";
11
12
  import { sessionsCommand, handleSessionSelect } from "./commands/sessions.js";
12
13
  import { newCommand } from "./commands/new.js";
13
14
  import { projectsCommand, handleProjectSelect } from "./commands/projects.js";
@@ -23,12 +24,14 @@ import { handleModelSelect, showModelSelectionMenu } from "./handlers/model.js";
23
24
  import { handleVariantSelect, showVariantSelectionMenu } from "./handlers/variant.js";
24
25
  import { handleContextButtonPress, handleCompactConfirm, handleCompactCancel, } from "./handlers/context.js";
25
26
  import { questionManager } from "../question/manager.js";
27
+ import { permissionManager } from "../permission/manager.js";
26
28
  import { keyboardManager } from "../keyboard/manager.js";
27
- import { subscribeToEvents } from "../opencode/events.js";
29
+ import { stopEventListening, subscribeToEvents } from "../opencode/events.js";
28
30
  import { summaryAggregator } from "../summary/aggregator.js";
29
31
  import { formatSummary, formatToolInfo } from "../summary/formatter.js";
30
32
  import { opencodeClient } from "../opencode/client.js";
31
- import { getCurrentSession, setCurrentSession } from "../session/manager.js";
33
+ import { clearSession, getCurrentSession, setCurrentSession } from "../session/manager.js";
34
+ import { ingestSessionInfoForCache } from "../session/cache-manager.js";
32
35
  import { getCurrentProject } from "../settings/manager.js";
33
36
  import { getStoredAgent } from "../agent/manager.js";
34
37
  import { getStoredModel } from "../model/manager.js";
@@ -36,6 +39,7 @@ import { formatVariantForButton } from "../variant/manager.js";
36
39
  import { createMainKeyboard } from "./utils/keyboard.js";
37
40
  import { logger } from "../utils/logger.js";
38
41
  import { safeBackgroundTask } from "../utils/safe-background-task.js";
42
+ import { formatErrorDetails } from "../utils/error-format.js";
39
43
  import { pinnedMessageManager } from "../pinned/manager.js";
40
44
  import { t } from "../i18n/index.js";
41
45
  let botInstance = null;
@@ -66,10 +70,9 @@ async function ensureCommandsInitialized(ctx, next) {
66
70
  }
67
71
  await next();
68
72
  }
69
- async function ensureEventSubscription() {
70
- const currentProject = getCurrentProject();
71
- if (!currentProject) {
72
- logger.error("No current project found for event subscription");
73
+ async function ensureEventSubscription(directory) {
74
+ if (!directory) {
75
+ logger.error("No directory found for event subscription");
73
76
  return;
74
77
  }
75
78
  summaryAggregator.setOnComplete(async (sessionId, messageText) => {
@@ -254,8 +257,17 @@ async function ensureEventSubscription() {
254
257
  logger.error("[Bot] Error updating keyboard context:", err);
255
258
  }
256
259
  });
257
- logger.info(`[Bot] Subscribing to OpenCode events for project: ${currentProject.worktree}`);
258
- subscribeToEvents(currentProject.worktree, (event) => {
260
+ logger.info(`[Bot] Subscribing to OpenCode events for project: ${directory}`);
261
+ subscribeToEvents(directory, (event) => {
262
+ if (event.type === "session.created" || event.type === "session.updated") {
263
+ const info = event.properties.info;
264
+ if (info?.directory) {
265
+ safeBackgroundTask({
266
+ taskName: `session.cache.${event.type}`,
267
+ task: () => ingestSessionInfoForCache(info),
268
+ });
269
+ }
270
+ }
259
271
  summaryAggregator.processEvent(event);
260
272
  }).catch((err) => {
261
273
  logger.error("Failed to subscribe to events:", err);
@@ -280,6 +292,23 @@ async function isSessionBusy(sessionId, directory) {
280
292
  return false;
281
293
  }
282
294
  }
295
+ async function resetMismatchedSessionContext() {
296
+ stopEventListening();
297
+ summaryAggregator.clear();
298
+ questionManager.clear();
299
+ permissionManager.clear();
300
+ clearSession();
301
+ keyboardManager.clearContext();
302
+ if (!pinnedMessageManager.isInitialized()) {
303
+ return;
304
+ }
305
+ try {
306
+ await pinnedMessageManager.clear();
307
+ }
308
+ catch (err) {
309
+ logger.error("[Bot] Failed to clear pinned message during session reset:", err);
310
+ }
311
+ }
283
312
  export function createBot() {
284
313
  const bot = new Bot(config.telegram.token);
285
314
  // Heartbeat for diagnostics: verify the event loop is not blocked
@@ -369,8 +398,8 @@ export function createBot() {
369
398
  }
370
399
  });
371
400
  // Handle Reply Keyboard button press (model selector)
372
- // Pattern: "ProviderName/ModelName" (may contain spaces, dots, parentheses, etc.)
373
- bot.hears(/^[A-Za-z0-9\-\. ]+\/[A-Za-z0-9\-\. ()]+\.{0,3}$/, async (ctx) => {
401
+ // Model button text is produced by formatModelForButton() and always starts with "🤖 ".
402
+ bot.hears(MODEL_BUTTON_TEXT_PATTERN, async (ctx) => {
374
403
  logger.debug(`[Bot] Model button pressed: ${ctx.message?.text}`);
375
404
  try {
376
405
  await showModelSelectionMenu(ctx);
@@ -392,7 +421,8 @@ export function createBot() {
392
421
  }
393
422
  });
394
423
  // Handle Reply Keyboard button press (variant selector)
395
- bot.hears(/^💭 \w+$/, async (ctx) => {
424
+ // Keep support for both legacy "💭" and current "💡" prefix.
425
+ bot.hears(VARIANT_BUTTON_TEXT_PATTERN, async (ctx) => {
396
426
  logger.debug(`[Bot] Variant button pressed: ${ctx.message?.text}`);
397
427
  try {
398
428
  await showVariantSelectionMenu(ctx);
@@ -450,7 +480,6 @@ export function createBot() {
450
480
  await ctx.reply(t("bot.project_not_selected"));
451
481
  return;
452
482
  }
453
- await ensureEventSubscription();
454
483
  botInstance = bot;
455
484
  chatIdInstance = ctx.chat.id;
456
485
  // Initialize pinned message manager if not already
@@ -460,6 +489,12 @@ export function createBot() {
460
489
  // Initialize keyboard manager if not already
461
490
  keyboardManager.initialize(bot.api, ctx.chat.id);
462
491
  let currentSession = getCurrentSession();
492
+ if (currentSession && currentSession.directory !== currentProject.worktree) {
493
+ logger.warn(`[Bot] Session/project mismatch detected. sessionDirectory=${currentSession.directory}, projectDirectory=${currentProject.worktree}. Resetting session context.`);
494
+ await resetMismatchedSessionContext();
495
+ await ctx.reply(t("bot.session_reset_project_mismatch"));
496
+ return;
497
+ }
463
498
  if (!currentSession) {
464
499
  await ctx.reply(t("bot.creating_session"));
465
500
  const { data: session, error } = await opencodeClient.session.create({
@@ -476,6 +511,7 @@ export function createBot() {
476
511
  directory: currentProject.worktree,
477
512
  };
478
513
  setCurrentSession(currentSession);
514
+ await ingestSessionInfoForCache(session);
479
515
  // Create pinned message for new session
480
516
  try {
481
517
  await pinnedMessageManager.onSessionChange(session.id, session.title);
@@ -504,6 +540,7 @@ export function createBot() {
504
540
  }
505
541
  }
506
542
  }
543
+ await ensureEventSubscription(currentSession.directory);
507
544
  summaryAggregator.setSession(currentSession.id);
508
545
  summaryAggregator.setBotAndChatId(bot, ctx.chat.id);
509
546
  const sessionIsBusy = await isSessionBusy(currentSession.id, currentSession.directory);
@@ -517,7 +554,7 @@ export function createBot() {
517
554
  const storedModel = getStoredModel();
518
555
  const promptOptions = {
519
556
  sessionID: currentSession.id,
520
- directory: currentProject.worktree,
557
+ directory: currentSession.directory,
521
558
  parts: [{ type: "text", text }],
522
559
  agent: currentAgent,
523
560
  };
@@ -542,18 +579,20 @@ export function createBot() {
542
579
  task: () => opencodeClient.session.prompt(promptOptions),
543
580
  onSuccess: ({ error }) => {
544
581
  if (error) {
545
- logger.error("OpenCode API error:", JSON.stringify(error, null, 2));
582
+ const details = formatErrorDetails(error);
583
+ logger.error("OpenCode API error:", error);
546
584
  // Send the error via API directly because ctx is no longer available
547
585
  void bot.api
548
586
  .sendMessage(ctx.chat.id, t("bot.prompt_send_error_detailed", {
549
- details: JSON.stringify(error),
587
+ details,
550
588
  }))
551
589
  .catch(() => { });
552
590
  return;
553
591
  }
554
592
  logger.info("[Bot] session.prompt completed");
555
593
  },
556
- onError: () => {
594
+ onError: (error) => {
595
+ logger.error("[Bot] session.prompt background task failed:", error);
557
596
  void bot.api.sendMessage(ctx.chat.id, t("bot.prompt_send_error")).catch(() => { });
558
597
  },
559
598
  });
@@ -0,0 +1,3 @@
1
+ export const MODEL_BUTTON_TEXT_PATTERN = /^🤖\s.+$/;
2
+ // Keep support for both legacy "💭" and current "💡" prefix.
3
+ export const VARIANT_BUTTON_TEXT_PATTERN = /^(💡|💭)\s.+$/;
package/dist/i18n/en.js CHANGED
@@ -26,6 +26,7 @@ export const en = {
26
26
  "bot.create_session_error": "🔴 Failed to create session. Try /new or check server status with /status.",
27
27
  "bot.session_created": "✅ Session created: {title}",
28
28
  "bot.session_busy": "⏳ Agent is already running a task. Wait for completion or use /stop to interrupt current run.",
29
+ "bot.session_reset_project_mismatch": "⚠️ Active session does not match the selected project, so it was reset. Use /sessions to pick one or /new to create a new session.",
29
30
  "bot.prompt_send_error_detailed": "🔴 Failed to send request.\n\nDetails: {details}",
30
31
  "bot.prompt_send_error": "🔴 An error occurred while sending request to OpenCode.",
31
32
  "status.header_running": "🟢 **OpenCode Server is running**",
@@ -47,7 +48,7 @@ export const en = {
47
48
  "status.session_not_selected": "📋 Current session: not selected",
48
49
  "status.session_hint": "Use /sessions to select one or /new to create one",
49
50
  "status.server_unavailable": "🔴 OpenCode Server is unavailable\n\nUse /opencode_start to start the server.",
50
- "projects.empty": "📭 No projects found.",
51
+ "projects.empty": "📭 No projects found.\n\nOpen a directory in OpenCode and create at least one session, then it will appear here.",
51
52
  "projects.select": "Select a project:",
52
53
  "projects.select_with_current": "Select a project:\n\nCurrent: 🏗 {project}",
53
54
  "projects.fetch_error": "🔴 OpenCode Server is unavailable or an error occurred while loading projects.",
package/dist/i18n/ru.js CHANGED
@@ -26,6 +26,7 @@ export const ru = {
26
26
  "bot.create_session_error": "🔴 Не удалось создать сессию. Попробуйте команду /new или проверьте статус сервера /status.",
27
27
  "bot.session_created": "✅ Сессия создана: {title}",
28
28
  "bot.session_busy": "⏳ Агент уже выполняет задачу. Дождитесь завершения или используйте /stop, чтобы прервать текущий запуск.",
29
+ "bot.session_reset_project_mismatch": "⚠️ Активная сессия не соответствует выбранному проекту, поэтому была сброшена. Используйте /sessions для выбора или /new для создания новой сессии.",
29
30
  "bot.prompt_send_error_detailed": "🔴 Ошибка при отправке запроса.\n\nДетали: {details}",
30
31
  "bot.prompt_send_error": "🔴 Произошла ошибка при отправке запроса в OpenCode.",
31
32
  "status.header_running": "🟢 **OpenCode Server запущен**",
@@ -47,7 +48,7 @@ export const ru = {
47
48
  "status.session_not_selected": "📋 Текущая сессия: не выбрана",
48
49
  "status.session_hint": "Используйте /sessions для выбора или /new для создания",
49
50
  "status.server_unavailable": "🔴 OpenCode Server недоступен\n\nИспользуйте /opencode_start для запуска сервера.",
50
- "projects.empty": "📭 Проектов нет.",
51
+ "projects.empty": "📭 Проектов нет.\n\nОткройте директорию в OpenCode и создайте хотя бы одну сессию, после этого она появится здесь.",
51
52
  "projects.select": "Выберите проект:",
52
53
  "projects.select_with_current": "Выберите проект:\n\nТекущий: 🏗 {project}",
53
54
  "projects.fetch_error": "🔴 OpenCode Server недоступен или произошла ошибка при получении списка проектов.",
@@ -1,12 +1,38 @@
1
1
  import { opencodeClient } from "./client.js";
2
2
  import { logger } from "../utils/logger.js";
3
+ const RECONNECT_BASE_DELAY_MS = 1000;
4
+ const RECONNECT_MAX_DELAY_MS = 15000;
5
+ const FATAL_NO_STREAM_ERROR = "No stream returned from event subscription";
3
6
  let eventStream = null;
4
7
  let eventCallback = null;
5
8
  let isListening = false;
6
9
  let activeDirectory = null;
7
10
  let streamAbortController = null;
11
+ function getReconnectDelayMs(attempt) {
12
+ const exponentialDelay = RECONNECT_BASE_DELAY_MS * Math.pow(2, Math.max(0, attempt - 1));
13
+ return Math.min(exponentialDelay, RECONNECT_MAX_DELAY_MS);
14
+ }
15
+ function waitWithAbort(ms, signal) {
16
+ return new Promise((resolve) => {
17
+ if (signal.aborted) {
18
+ resolve(false);
19
+ return;
20
+ }
21
+ const onAbort = () => {
22
+ clearTimeout(timeout);
23
+ signal.removeEventListener("abort", onAbort);
24
+ resolve(false);
25
+ };
26
+ const timeout = setTimeout(() => {
27
+ signal.removeEventListener("abort", onAbort);
28
+ resolve(true);
29
+ }, ms);
30
+ signal.addEventListener("abort", onAbort, { once: true });
31
+ });
32
+ }
8
33
  export async function subscribeToEvents(directory, callback) {
9
34
  if (isListening && activeDirectory === directory) {
35
+ eventCallback = callback;
10
36
  logger.debug(`Event listener already running for ${directory}`);
11
37
  return;
12
38
  }
@@ -23,24 +49,59 @@ export async function subscribeToEvents(directory, callback) {
23
49
  isListening = true;
24
50
  streamAbortController = controller;
25
51
  try {
26
- const result = await opencodeClient.event.subscribe({ directory }, { signal: controller.signal });
27
- if (!result.stream) {
28
- throw new Error("No stream returned from event subscription");
29
- }
30
- eventStream = result.stream;
31
- for await (const event of eventStream) {
32
- if (!isListening || activeDirectory !== directory) {
33
- logger.debug(`Event listener stopped or changed directory, breaking loop`);
34
- break;
52
+ let reconnectAttempt = 0;
53
+ while (isListening && activeDirectory === directory && !controller.signal.aborted) {
54
+ try {
55
+ const result = await opencodeClient.event.subscribe({ directory }, { signal: controller.signal });
56
+ if (!result.stream) {
57
+ throw new Error(FATAL_NO_STREAM_ERROR);
58
+ }
59
+ reconnectAttempt = 0;
60
+ eventStream = result.stream;
61
+ for await (const event of eventStream) {
62
+ if (!isListening || activeDirectory !== directory || controller.signal.aborted) {
63
+ logger.debug(`Event listener stopped or changed directory, breaking loop`);
64
+ break;
65
+ }
66
+ // CRITICAL: Explicitly yield to the event loop BEFORE processing the event
67
+ // This allows grammY to handle getUpdates between SSE events
68
+ await new Promise((resolve) => setImmediate(resolve));
69
+ if (eventCallback) {
70
+ // Use setImmediate to avoid blocking the event loop
71
+ // and let grammY process incoming Telegram updates
72
+ const callbackSnapshot = eventCallback;
73
+ setImmediate(() => callbackSnapshot(event));
74
+ }
75
+ }
76
+ eventStream = null;
77
+ if (!isListening || activeDirectory !== directory || controller.signal.aborted) {
78
+ break;
79
+ }
80
+ reconnectAttempt++;
81
+ const reconnectDelay = getReconnectDelayMs(reconnectAttempt);
82
+ logger.warn(`Event stream ended for ${directory}, reconnecting in ${reconnectDelay}ms (attempt=${reconnectAttempt})`);
83
+ const shouldContinue = await waitWithAbort(reconnectDelay, controller.signal);
84
+ if (!shouldContinue) {
85
+ break;
86
+ }
35
87
  }
36
- // CRITICAL: Explicitly yield to the event loop BEFORE processing the event
37
- // This allows grammY to handle getUpdates between SSE events
38
- await new Promise((resolve) => setImmediate(resolve));
39
- if (eventCallback) {
40
- // Use setImmediate to avoid blocking the event loop
41
- // and let grammY process incoming Telegram updates
42
- const callback = eventCallback;
43
- setImmediate(() => callback(event));
88
+ catch (error) {
89
+ eventStream = null;
90
+ if (controller.signal.aborted || !isListening || activeDirectory !== directory) {
91
+ logger.info("Event listener aborted");
92
+ return;
93
+ }
94
+ if (error instanceof Error && error.message === FATAL_NO_STREAM_ERROR) {
95
+ logger.error("Event stream fatal error:", error);
96
+ throw error;
97
+ }
98
+ reconnectAttempt++;
99
+ const reconnectDelay = getReconnectDelayMs(reconnectAttempt);
100
+ logger.error(`Event stream error for ${directory}, reconnecting in ${reconnectDelay}ms (attempt=${reconnectAttempt})`, error);
101
+ const shouldContinue = await waitWithAbort(reconnectDelay, controller.signal);
102
+ if (!shouldContinue) {
103
+ break;
104
+ }
44
105
  }
45
106
  }
46
107
  }
@@ -1,14 +1,74 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import path from "node:path";
1
3
  import { opencodeClient } from "../opencode/client.js";
4
+ import { getCachedSessionProjects } from "../session/cache-manager.js";
5
+ import { logger } from "../utils/logger.js";
6
+ async function isLinkedGitWorktree(worktree) {
7
+ if (worktree === "/") {
8
+ return false;
9
+ }
10
+ const gitPath = path.join(worktree, ".git");
11
+ try {
12
+ const gitStat = await stat(gitPath);
13
+ if (!gitStat.isFile()) {
14
+ return false;
15
+ }
16
+ const gitPointer = (await readFile(gitPath, "utf-8")).trim();
17
+ const match = gitPointer.match(/^gitdir:\s*(.+)$/i);
18
+ if (!match) {
19
+ return false;
20
+ }
21
+ const gitDir = path.resolve(worktree, match[1].trim()).replace(/\\/g, "/").toLowerCase();
22
+ return gitDir.includes("/.git/worktrees/");
23
+ }
24
+ catch {
25
+ return false;
26
+ }
27
+ }
28
+ function worktreeKey(worktree) {
29
+ if (process.platform === "win32") {
30
+ return worktree.toLowerCase();
31
+ }
32
+ return worktree;
33
+ }
2
34
  export async function getProjects() {
3
35
  const { data: projects, error } = await opencodeClient.project.list();
4
36
  if (error || !projects) {
5
37
  throw error || new Error("No data received from server");
6
38
  }
7
- return projects.map((project) => ({
39
+ const apiProjects = projects.map((project) => ({
8
40
  id: project.id,
9
41
  worktree: project.worktree,
10
42
  name: project.name || project.worktree,
43
+ lastUpdated: project.time?.updated ?? 0,
11
44
  }));
45
+ const cachedProjects = await getCachedSessionProjects();
46
+ const mergedByWorktree = new Map();
47
+ for (const apiProject of apiProjects) {
48
+ mergedByWorktree.set(worktreeKey(apiProject.worktree), apiProject);
49
+ }
50
+ for (const cachedProject of cachedProjects) {
51
+ const key = worktreeKey(cachedProject.worktree);
52
+ const existing = mergedByWorktree.get(key);
53
+ if (existing) {
54
+ if ((cachedProject.lastUpdated ?? 0) > existing.lastUpdated) {
55
+ existing.lastUpdated = cachedProject.lastUpdated;
56
+ }
57
+ continue;
58
+ }
59
+ mergedByWorktree.set(key, {
60
+ id: cachedProject.id,
61
+ worktree: cachedProject.worktree,
62
+ name: cachedProject.name,
63
+ lastUpdated: cachedProject.lastUpdated ?? 0,
64
+ });
65
+ }
66
+ const projectList = Array.from(mergedByWorktree.values()).sort((left, right) => right.lastUpdated - left.lastUpdated);
67
+ const linkedWorktreeFlags = await Promise.all(projectList.map((project) => isLinkedGitWorktree(project.worktree)));
68
+ const visibleProjects = projectList.filter((_, index) => !linkedWorktreeFlags[index]);
69
+ const hiddenLinkedWorktrees = projectList.length - visibleProjects.length;
70
+ logger.debug(`[ProjectManager] Projects resolved: api=${projects.length}, cached=${cachedProjects.length}, hiddenLinkedWorktrees=${hiddenLinkedWorktrees}, total=${visibleProjects.length}`);
71
+ return visibleProjects.map(({ id, worktree, name }) => ({ id, worktree, name }));
12
72
  }
13
73
  export async function getProjectById(id) {
14
74
  const projects = await getProjects();
@@ -0,0 +1,400 @@
1
+ import { createHash } from "node:crypto";
2
+ import path from "node:path";
3
+ import Database from "better-sqlite3";
4
+ import { opencodeClient } from "../opencode/client.js";
5
+ import { getSessionDirectoryCache, setSessionDirectoryCache } from "../settings/manager.js";
6
+ import { logger } from "../utils/logger.js";
7
+ const CACHE_VERSION = 1;
8
+ const INITIAL_WARMUP_LIMIT = 1000;
9
+ const INCREMENTAL_SYNC_LIMIT = 1000;
10
+ const MAX_CACHED_DIRECTORIES = 10;
11
+ const SYNC_SAFETY_WINDOW_MS = 60_000;
12
+ const SYNC_COOLDOWN_MS = 60_000;
13
+ const STORAGE_FALLBACK_SCAN_LIMIT = 200;
14
+ const SQLITE_FALLBACK_QUERY_LIMIT = 200;
15
+ const EMPTY_CACHE = {
16
+ version: CACHE_VERSION,
17
+ lastSyncedUpdatedAt: 0,
18
+ directories: [],
19
+ };
20
+ function createEmptyCacheData() {
21
+ return {
22
+ version: EMPTY_CACHE.version,
23
+ lastSyncedUpdatedAt: EMPTY_CACHE.lastSyncedUpdatedAt,
24
+ directories: [],
25
+ };
26
+ }
27
+ let cacheData = createEmptyCacheData();
28
+ let cacheLoaded = false;
29
+ let syncInFlight = null;
30
+ let lastSyncAttemptAt = 0;
31
+ let persistQueue = Promise.resolve();
32
+ function worktreeKey(worktree) {
33
+ if (process.platform === "win32") {
34
+ return worktree.toLowerCase();
35
+ }
36
+ return worktree;
37
+ }
38
+ function isValidWorktree(worktree) {
39
+ const trimmed = worktree.trim();
40
+ return trimmed.length > 0 && trimmed !== "/";
41
+ }
42
+ function normalizeCacheData(raw) {
43
+ if (!raw || typeof raw !== "object") {
44
+ return createEmptyCacheData();
45
+ }
46
+ const value = raw;
47
+ const lastSyncedUpdatedAt = typeof value.lastSyncedUpdatedAt === "number" && Number.isFinite(value.lastSyncedUpdatedAt)
48
+ ? value.lastSyncedUpdatedAt
49
+ : 0;
50
+ const directories = Array.isArray(value.directories)
51
+ ? value.directories
52
+ .filter((item) => Boolean(item) &&
53
+ typeof item === "object" &&
54
+ typeof item.worktree === "string" &&
55
+ typeof item.lastUpdated === "number")
56
+ .map((item) => ({
57
+ worktree: item.worktree.trim(),
58
+ lastUpdated: item.lastUpdated,
59
+ }))
60
+ .filter((item) => isValidWorktree(item.worktree))
61
+ : [];
62
+ const data = {
63
+ version: CACHE_VERSION,
64
+ lastSyncedUpdatedAt,
65
+ directories,
66
+ };
67
+ dedupeAndTrimDirectories(data);
68
+ return data;
69
+ }
70
+ function dedupeAndTrimDirectories(data) {
71
+ const unique = new Map();
72
+ for (const item of data.directories) {
73
+ const key = worktreeKey(item.worktree);
74
+ const existing = unique.get(key);
75
+ if (!existing || existing.lastUpdated < item.lastUpdated) {
76
+ unique.set(key, item);
77
+ }
78
+ }
79
+ data.directories = Array.from(unique.values())
80
+ .sort((a, b) => b.lastUpdated - a.lastUpdated)
81
+ .slice(0, MAX_CACHED_DIRECTORIES);
82
+ }
83
+ async function ensureCacheLoaded() {
84
+ if (cacheLoaded) {
85
+ return;
86
+ }
87
+ const storedCache = getSessionDirectoryCache();
88
+ cacheData = normalizeCacheData(storedCache);
89
+ cacheLoaded = true;
90
+ logger.debug(`[SessionCache] Loaded ${cacheData.directories.length} directories from settings.sessionDirectoryCache`);
91
+ }
92
+ function queuePersist() {
93
+ persistQueue = persistQueue
94
+ .catch(() => {
95
+ // Keep queue chain alive if previous write failed.
96
+ })
97
+ .then(async () => {
98
+ try {
99
+ await setSessionDirectoryCache(cacheData);
100
+ }
101
+ catch (error) {
102
+ logger.error("[SessionCache] Failed to persist sessions cache", error);
103
+ }
104
+ });
105
+ return persistQueue;
106
+ }
107
+ function upsertDirectory(worktree, lastUpdated) {
108
+ if (!isValidWorktree(worktree)) {
109
+ return false;
110
+ }
111
+ const normalizedWorktree = worktree.trim();
112
+ const key = worktreeKey(normalizedWorktree);
113
+ const existingIndex = cacheData.directories.findIndex((item) => worktreeKey(item.worktree) === key);
114
+ if (existingIndex >= 0) {
115
+ const existing = cacheData.directories[existingIndex];
116
+ if (existing.lastUpdated >= lastUpdated) {
117
+ return false;
118
+ }
119
+ cacheData.directories[existingIndex] = {
120
+ worktree: existing.worktree,
121
+ lastUpdated,
122
+ };
123
+ }
124
+ else {
125
+ cacheData.directories.push({
126
+ worktree: normalizedWorktree,
127
+ lastUpdated,
128
+ });
129
+ }
130
+ dedupeAndTrimDirectories(cacheData);
131
+ return true;
132
+ }
133
+ function buildListParams() {
134
+ const hasWatermark = cacheData.lastSyncedUpdatedAt > 0;
135
+ if (!hasWatermark) {
136
+ return { limit: INITIAL_WARMUP_LIMIT };
137
+ }
138
+ return {
139
+ limit: INCREMENTAL_SYNC_LIMIT,
140
+ start: Math.max(0, cacheData.lastSyncedUpdatedAt - SYNC_SAFETY_WINDOW_MS),
141
+ };
142
+ }
143
+ function createVirtualProjectId(worktree) {
144
+ const hash = createHash("sha1").update(worktree).digest("hex").slice(0, 16);
145
+ return `dir_${hash}`;
146
+ }
147
+ async function runSync() {
148
+ await ensureCacheLoaded();
149
+ const params = buildListParams();
150
+ const { data: sessions, error } = await opencodeClient.session.list(params);
151
+ if (error || !sessions) {
152
+ throw error || new Error("No session list received from server");
153
+ }
154
+ let changed = false;
155
+ let maxUpdated = cacheData.lastSyncedUpdatedAt;
156
+ for (const session of sessions) {
157
+ const updatedAt = session.time?.updated ?? Date.now();
158
+ if (upsertDirectory(session.directory, updatedAt)) {
159
+ changed = true;
160
+ }
161
+ if (updatedAt > maxUpdated) {
162
+ maxUpdated = updatedAt;
163
+ }
164
+ }
165
+ if (maxUpdated !== cacheData.lastSyncedUpdatedAt) {
166
+ cacheData.lastSyncedUpdatedAt = maxUpdated;
167
+ changed = true;
168
+ }
169
+ if (changed) {
170
+ await queuePersist();
171
+ }
172
+ logger.debug(`[SessionCache] Synced sessions: fetched=${sessions.length}, directories=${cacheData.directories.length}, lastSyncedUpdatedAt=${cacheData.lastSyncedUpdatedAt}`);
173
+ }
174
+ function getStorageRootCandidates(pathInfo) {
175
+ const candidates = new Set();
176
+ if (pathInfo.home) {
177
+ candidates.add(path.join(pathInfo.home, ".local", "share", "opencode"));
178
+ }
179
+ if (pathInfo.state) {
180
+ const normalizedState = pathInfo.state.replace(/[\\/]+$/, "");
181
+ const lowerState = normalizedState.toLowerCase();
182
+ const marker = `${path.sep}state${path.sep}opencode`;
183
+ const lowerMarker = marker.toLowerCase();
184
+ if (lowerState.endsWith(lowerMarker)) {
185
+ const prefix = normalizedState.slice(0, normalizedState.length - marker.length);
186
+ candidates.add(path.join(prefix, "share", "opencode"));
187
+ }
188
+ }
189
+ return Array.from(candidates);
190
+ }
191
+ function getPathApi() {
192
+ return opencodeClient.path;
193
+ }
194
+ async function getStorageRootsFromApi() {
195
+ const pathApi = getPathApi();
196
+ if (!pathApi?.get) {
197
+ return [];
198
+ }
199
+ const { data: pathInfo, error } = await pathApi.get();
200
+ if (error || !pathInfo) {
201
+ return [];
202
+ }
203
+ return getStorageRootCandidates(pathInfo);
204
+ }
205
+ async function querySessionDirectoriesFromSqlite(dbPath) {
206
+ try {
207
+ const db = new Database(dbPath, {
208
+ readonly: true,
209
+ fileMustExist: true,
210
+ });
211
+ try {
212
+ const rows = db
213
+ .prepare(`
214
+ SELECT directory, MAX(time_updated) AS updated
215
+ FROM session
216
+ GROUP BY directory
217
+ ORDER BY updated DESC
218
+ LIMIT ?
219
+ `)
220
+ .all(SQLITE_FALLBACK_QUERY_LIMIT);
221
+ return rows
222
+ .filter((item) => Boolean(item) && typeof item.directory === "string")
223
+ .map((item) => ({
224
+ worktree: item.directory,
225
+ lastUpdated: typeof item.updated === "number" && Number.isFinite(item.updated) ? item.updated : 0,
226
+ }));
227
+ }
228
+ finally {
229
+ db.close();
230
+ }
231
+ }
232
+ catch (error) {
233
+ logger.debug(`[SessionCache] Failed to read sqlite fallback at ${dbPath}`, error);
234
+ }
235
+ return null;
236
+ }
237
+ async function ingestFromSqliteSessionDatabase() {
238
+ await ensureCacheLoaded();
239
+ const fs = await import("node:fs/promises");
240
+ const roots = await getStorageRootsFromApi();
241
+ for (const root of roots) {
242
+ const dbPath = path.join(root, "opencode.db");
243
+ try {
244
+ await fs.access(dbPath);
245
+ }
246
+ catch {
247
+ continue;
248
+ }
249
+ const rows = await querySessionDirectoriesFromSqlite(dbPath);
250
+ if (!rows || rows.length === 0) {
251
+ continue;
252
+ }
253
+ let changed = false;
254
+ let maxUpdated = cacheData.lastSyncedUpdatedAt;
255
+ for (const row of rows) {
256
+ if (upsertDirectory(row.worktree, row.lastUpdated)) {
257
+ changed = true;
258
+ }
259
+ if (row.lastUpdated > maxUpdated) {
260
+ maxUpdated = row.lastUpdated;
261
+ }
262
+ }
263
+ if (maxUpdated !== cacheData.lastSyncedUpdatedAt) {
264
+ cacheData.lastSyncedUpdatedAt = maxUpdated;
265
+ changed = true;
266
+ }
267
+ if (changed) {
268
+ await queuePersist();
269
+ }
270
+ logger.debug(`[SessionCache] SQLite fallback loaded: db=${dbPath}, rows=${rows.length}, directories=${cacheData.directories.length}`);
271
+ return;
272
+ }
273
+ }
274
+ async function ingestFromGlobalSessionStorage() {
275
+ await ensureCacheLoaded();
276
+ const fs = await import("node:fs/promises");
277
+ const candidates = await getStorageRootsFromApi();
278
+ for (const storageRoot of candidates) {
279
+ const globalDir = path.join(storageRoot, "storage", "session", "global");
280
+ try {
281
+ const entries = await fs.readdir(globalDir, { withFileTypes: true });
282
+ const sessionFiles = entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json"));
283
+ const withMtime = await Promise.all(sessionFiles.map(async (entry) => {
284
+ const fullPath = path.join(globalDir, entry.name);
285
+ const stat = await fs.stat(fullPath);
286
+ return { fullPath, mtimeMs: stat.mtimeMs };
287
+ }));
288
+ const sorted = withMtime
289
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)
290
+ .slice(0, STORAGE_FALLBACK_SCAN_LIMIT);
291
+ let changed = false;
292
+ let maxUpdated = cacheData.lastSyncedUpdatedAt;
293
+ for (const file of sorted) {
294
+ try {
295
+ const raw = await fs.readFile(file.fullPath, "utf-8");
296
+ const session = JSON.parse(raw);
297
+ if (!session.directory) {
298
+ continue;
299
+ }
300
+ const updated = session.time?.updated ?? Math.trunc(file.mtimeMs);
301
+ if (upsertDirectory(session.directory, updated)) {
302
+ changed = true;
303
+ }
304
+ if (updated > maxUpdated) {
305
+ maxUpdated = updated;
306
+ }
307
+ }
308
+ catch {
309
+ // Ignore malformed session files.
310
+ }
311
+ }
312
+ if (maxUpdated !== cacheData.lastSyncedUpdatedAt) {
313
+ cacheData.lastSyncedUpdatedAt = maxUpdated;
314
+ changed = true;
315
+ }
316
+ if (changed) {
317
+ await queuePersist();
318
+ }
319
+ logger.debug(`[SessionCache] Storage fallback loaded: root=${storageRoot}, scanned=${sorted.length}, directories=${cacheData.directories.length}`);
320
+ return;
321
+ }
322
+ catch {
323
+ // Try next candidate path.
324
+ }
325
+ }
326
+ }
327
+ export async function warmupSessionDirectoryCache() {
328
+ await syncSessionDirectoryCache({ force: true });
329
+ try {
330
+ await ingestFromSqliteSessionDatabase();
331
+ }
332
+ catch (error) {
333
+ logger.warn("[SessionCache] Failed sqlite fallback warmup", error);
334
+ }
335
+ try {
336
+ await ingestFromGlobalSessionStorage();
337
+ }
338
+ catch (error) {
339
+ logger.warn("[SessionCache] Failed storage fallback warmup", error);
340
+ }
341
+ }
342
+ export async function syncSessionDirectoryCache(options) {
343
+ await ensureCacheLoaded();
344
+ if (!options?.force && Date.now() - lastSyncAttemptAt < SYNC_COOLDOWN_MS) {
345
+ return;
346
+ }
347
+ if (syncInFlight) {
348
+ return syncInFlight;
349
+ }
350
+ syncInFlight = runSync()
351
+ .then(() => {
352
+ lastSyncAttemptAt = Date.now();
353
+ })
354
+ .catch((error) => {
355
+ logger.warn("[SessionCache] Failed to sync sessions cache", error);
356
+ lastSyncAttemptAt = 0;
357
+ })
358
+ .finally(() => {
359
+ syncInFlight = null;
360
+ });
361
+ return syncInFlight;
362
+ }
363
+ export async function getCachedSessionDirectories() {
364
+ await ensureCacheLoaded();
365
+ return cacheData.directories.map((item) => ({ ...item }));
366
+ }
367
+ export async function getCachedSessionProjects() {
368
+ const directories = await getCachedSessionDirectories();
369
+ return directories.map((item) => ({
370
+ id: createVirtualProjectId(item.worktree),
371
+ worktree: item.worktree,
372
+ name: item.worktree,
373
+ lastUpdated: item.lastUpdated,
374
+ }));
375
+ }
376
+ export async function upsertSessionDirectory(worktree, lastUpdated = Date.now()) {
377
+ await ensureCacheLoaded();
378
+ if (!upsertDirectory(worktree, lastUpdated)) {
379
+ return;
380
+ }
381
+ if (lastUpdated > cacheData.lastSyncedUpdatedAt) {
382
+ cacheData.lastSyncedUpdatedAt = lastUpdated;
383
+ }
384
+ await queuePersist();
385
+ }
386
+ export async function ingestSessionInfoForCache(session) {
387
+ const directory = session.directory;
388
+ if (!directory) {
389
+ return;
390
+ }
391
+ const updated = session.time?.updated ?? Date.now();
392
+ await upsertSessionDirectory(directory, updated);
393
+ }
394
+ export function __resetSessionDirectoryCacheForTests() {
395
+ cacheData = createEmptyCacheData();
396
+ cacheLoaded = false;
397
+ syncInFlight = null;
398
+ lastSyncAttemptAt = 0;
399
+ persistQueue = Promise.resolve();
400
+ }
@@ -17,15 +17,24 @@ async function readSettingsFile() {
17
17
  return {};
18
18
  }
19
19
  }
20
+ let settingsWriteQueue = Promise.resolve();
20
21
  function writeSettingsFile(settings) {
21
- import("fs/promises").then((fs) => {
22
- const settingsFilePath = getSettingsFilePath();
23
- fs.mkdir(path.dirname(settingsFilePath), { recursive: true })
24
- .then(() => fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2)))
25
- .catch((err) => {
22
+ settingsWriteQueue = settingsWriteQueue
23
+ .catch(() => {
24
+ // Keep write queue alive after failed writes.
25
+ })
26
+ .then(async () => {
27
+ try {
28
+ const fs = await import("fs/promises");
29
+ const settingsFilePath = getSettingsFilePath();
30
+ await fs.mkdir(path.dirname(settingsFilePath), { recursive: true });
31
+ await fs.writeFile(settingsFilePath, JSON.stringify(settings, null, 2));
32
+ }
33
+ catch (err) {
26
34
  logger.error("[SettingsManager] Error writing settings file:", err);
27
- });
35
+ }
28
36
  });
37
+ return settingsWriteQueue;
29
38
  }
30
39
  let currentSettings = {};
31
40
  export function getCurrentProject() {
@@ -33,66 +42,81 @@ export function getCurrentProject() {
33
42
  }
34
43
  export function setCurrentProject(projectInfo) {
35
44
  currentSettings.currentProject = projectInfo;
36
- writeSettingsFile(currentSettings);
45
+ void writeSettingsFile(currentSettings);
37
46
  }
38
47
  export function clearProject() {
39
48
  currentSettings.currentProject = undefined;
40
- writeSettingsFile(currentSettings);
49
+ void writeSettingsFile(currentSettings);
41
50
  }
42
51
  export function getCurrentSession() {
43
52
  return currentSettings.currentSession;
44
53
  }
45
54
  export function setCurrentSession(sessionInfo) {
46
55
  currentSettings.currentSession = sessionInfo;
47
- writeSettingsFile(currentSettings);
56
+ void writeSettingsFile(currentSettings);
48
57
  }
49
58
  export function clearSession() {
50
59
  currentSettings.currentSession = undefined;
51
- writeSettingsFile(currentSettings);
60
+ void writeSettingsFile(currentSettings);
52
61
  }
53
62
  export function getCurrentAgent() {
54
63
  return currentSettings.currentAgent;
55
64
  }
56
65
  export function setCurrentAgent(agentName) {
57
66
  currentSettings.currentAgent = agentName;
58
- writeSettingsFile(currentSettings);
67
+ void writeSettingsFile(currentSettings);
59
68
  }
60
69
  export function clearCurrentAgent() {
61
70
  currentSettings.currentAgent = undefined;
62
- writeSettingsFile(currentSettings);
71
+ void writeSettingsFile(currentSettings);
63
72
  }
64
73
  export function getCurrentModel() {
65
74
  return currentSettings.currentModel;
66
75
  }
67
76
  export function setCurrentModel(modelInfo) {
68
77
  currentSettings.currentModel = modelInfo;
69
- writeSettingsFile(currentSettings);
78
+ void writeSettingsFile(currentSettings);
70
79
  }
71
80
  export function clearCurrentModel() {
72
81
  currentSettings.currentModel = undefined;
73
- writeSettingsFile(currentSettings);
82
+ void writeSettingsFile(currentSettings);
74
83
  }
75
84
  export function getPinnedMessageId() {
76
85
  return currentSettings.pinnedMessageId;
77
86
  }
78
87
  export function setPinnedMessageId(messageId) {
79
88
  currentSettings.pinnedMessageId = messageId;
80
- writeSettingsFile(currentSettings);
89
+ void writeSettingsFile(currentSettings);
81
90
  }
82
91
  export function clearPinnedMessageId() {
83
92
  currentSettings.pinnedMessageId = undefined;
84
- writeSettingsFile(currentSettings);
93
+ void writeSettingsFile(currentSettings);
85
94
  }
86
95
  export function getServerProcess() {
87
96
  return currentSettings.serverProcess;
88
97
  }
89
98
  export function setServerProcess(processInfo) {
90
99
  currentSettings.serverProcess = processInfo;
91
- writeSettingsFile(currentSettings);
100
+ void writeSettingsFile(currentSettings);
92
101
  }
93
102
  export function clearServerProcess() {
94
103
  currentSettings.serverProcess = undefined;
95
- writeSettingsFile(currentSettings);
104
+ void writeSettingsFile(currentSettings);
105
+ }
106
+ export function getSessionDirectoryCache() {
107
+ return currentSettings.sessionDirectoryCache;
108
+ }
109
+ export function setSessionDirectoryCache(cache) {
110
+ currentSettings.sessionDirectoryCache = cache;
111
+ return writeSettingsFile(currentSettings);
112
+ }
113
+ export function clearSessionDirectoryCache() {
114
+ currentSettings.sessionDirectoryCache = undefined;
115
+ void writeSettingsFile(currentSettings);
116
+ }
117
+ export function __resetSettingsForTests() {
118
+ currentSettings = {};
119
+ settingsWriteQueue = Promise.resolve();
96
120
  }
97
121
  export async function loadSettings() {
98
122
  currentSettings = await readSettingsFile();
@@ -0,0 +1,29 @@
1
+ const DEFAULT_MAX_ERROR_DETAILS_LENGTH = 1500;
2
+ function clipText(value, maxLength) {
3
+ if (value.length <= maxLength) {
4
+ return value;
5
+ }
6
+ return `${value.slice(0, Math.max(0, maxLength - 3))}...`;
7
+ }
8
+ export function formatErrorDetails(error, maxLength = DEFAULT_MAX_ERROR_DETAILS_LENGTH) {
9
+ let details = "";
10
+ if (error instanceof Error) {
11
+ details = error.stack ?? `${error.name}: ${error.message}`;
12
+ }
13
+ else if (typeof error === "string") {
14
+ details = error;
15
+ }
16
+ else {
17
+ try {
18
+ details = JSON.stringify(error, null, 2);
19
+ }
20
+ catch {
21
+ details = String(error);
22
+ }
23
+ }
24
+ const normalized = details.trim();
25
+ if (!normalized || normalized === "{}" || normalized === "[object Object]") {
26
+ return "unknown error";
27
+ }
28
+ return clipText(normalized, maxLength);
29
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grinev/opencode-telegram-bot",
3
- "version": "0.1.1",
3
+ "version": "0.2.1",
4
4
  "description": "Telegram bot client for OpenCode to run and monitor coding tasks from chat.",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",
@@ -53,10 +53,12 @@
53
53
  "dependencies": {
54
54
  "@grammyjs/menu": "^1.3.1",
55
55
  "@opencode-ai/sdk": "^1.1.21",
56
+ "better-sqlite3": "^12.6.2",
56
57
  "dotenv": "^17.2.3",
57
58
  "grammy": "^1.39.2"
58
59
  },
59
60
  "devDependencies": {
61
+ "@types/better-sqlite3": "^7.6.13",
60
62
  "@types/node": "^25.0.8",
61
63
  "@typescript-eslint/eslint-plugin": "^8.53.0",
62
64
  "@typescript-eslint/parser": "^8.53.0",