@grinev/opencode-telegram-bot 0.19.0 → 0.19.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.
@@ -153,11 +153,15 @@ export async function handleProjectSelect(ctx) {
153
153
  if (!callbackQuery?.data) {
154
154
  return false;
155
155
  }
156
+ const page = parseProjectPageCallback(callbackQuery.data);
157
+ const isProjectSelection = callbackQuery.data.startsWith("project:");
158
+ if (page === null && !isProjectSelection) {
159
+ return false;
160
+ }
156
161
  if (isForegroundBusy()) {
157
162
  await replyBusyBlocked(ctx);
158
163
  return true;
159
164
  }
160
- const page = parseProjectPageCallback(callbackQuery.data);
161
165
  if (page !== null) {
162
166
  const isActiveMenu = await ensureActiveInlineMenu(ctx, "project");
163
167
  if (!isActiveMenu) {
@@ -182,9 +186,6 @@ export async function handleProjectSelect(ctx) {
182
186
  }
183
187
  return true;
184
188
  }
185
- if (!callbackQuery.data.startsWith("project:")) {
186
- return false;
187
- }
188
189
  const projectId = callbackQuery.data.replace("project:", "");
189
190
  const isActiveMenu = await ensureActiveInlineMenu(ctx, "project");
190
191
  if (!isActiveMenu) {
@@ -108,10 +108,12 @@ function sortTasks(tasks) {
108
108
  });
109
109
  }
110
110
  function formatTaskDetails(task) {
111
+ const variant = task.model.variant ? ` (${task.model.variant})` : "";
112
+ const model = `${task.model.providerID}/${task.model.modelID}${variant}`;
111
113
  const cronLine = task.kind === "cron" ? `${t("tasklist.details.cron", { cron: task.cron })}\n` : "";
112
114
  return t("tasklist.details", {
113
115
  prompt: task.prompt,
114
- project: task.projectWorktree,
116
+ project: `${task.projectWorktree}\n${t("status.line.model", { model })}`,
115
117
  schedule: task.scheduleSummary,
116
118
  cronLine,
117
119
  timezone: task.timezone,
@@ -197,7 +197,7 @@ export async function processUserPrompt(ctx, text, deps, fileParts = [], options
197
197
  promptLength: text.length,
198
198
  fileCount: fileParts.length,
199
199
  };
200
- logger.info(`[Bot] Calling session.prompt (fire-and-forget) with agent=${currentAgent}, fileCount=${fileParts.length}...`);
200
+ logger.info(`[Bot] Calling session.promptAsync (start-only) with agent=${currentAgent}, fileCount=${fileParts.length}...`);
201
201
  foregroundSessionState.markBusy(currentSession.id);
202
202
  await markAttachedSessionBusy(currentSession.id);
203
203
  assistantRunState.startRun(currentSession.id, {
@@ -210,13 +210,14 @@ export async function processUserPrompt(ctx, text, deps, fileParts = [], options
210
210
  if (text.trim().length > 0) {
211
211
  externalUserInputSuppressionManager.register(currentSession.id, text);
212
212
  }
213
- // CRITICAL: DO NOT wait for session.prompt to complete.
214
- // If we wait, the handler will not finish and grammY will not call getUpdates,
215
- // which blocks receiving button callback_query updates.
216
- // The processing result will arrive via SSE events.
213
+ // CRITICAL: Use the async prompt start endpoint here.
214
+ // session.prompt streams the full assistant response and can outlive the original
215
+ // Telegram message handler, which turns late transport failures into misleading
216
+ // "failed to send" messages even after the run has already started.
217
+ // The actual assistant result still arrives via the SSE event subscription.
217
218
  safeBackgroundTask({
218
- taskName: "session.prompt",
219
- task: () => opencodeClient.session.prompt(promptOptions),
219
+ taskName: "session.promptAsync",
220
+ task: () => opencodeClient.session.promptAsync(promptOptions),
220
221
  onSuccess: ({ error }) => {
221
222
  if (error) {
222
223
  foregroundSessionState.markIdle(currentSession.id);
@@ -224,14 +225,14 @@ export async function processUserPrompt(ctx, text, deps, fileParts = [], options
224
225
  assistantRunState.clearRun(currentSession.id, "session_prompt_api_error");
225
226
  clearPromptResponseMode(currentSession.id);
226
227
  const details = formatErrorDetails(error, 6000);
227
- logger.error("[Bot] OpenCode API returned an error for session.prompt", promptErrorLogContext);
228
- logger.error("[Bot] session.prompt error details:", details);
229
- logger.error("[Bot] session.prompt raw API error object:", error);
228
+ logger.error("[Bot] OpenCode API returned an error for session.promptAsync", promptErrorLogContext);
229
+ logger.error("[Bot] session.promptAsync error details:", details);
230
+ logger.error("[Bot] session.promptAsync raw API error object:", error);
230
231
  // Send user-friendly error via API directly because ctx is no longer available
231
232
  void bot.api.sendMessage(ctx.chat.id, t("bot.prompt_send_error")).catch(() => { });
232
233
  return;
233
234
  }
234
- logger.info("[Bot] session.prompt completed");
235
+ logger.info("[Bot] session.promptAsync accepted");
235
236
  },
236
237
  onError: (error) => {
237
238
  foregroundSessionState.markIdle(currentSession.id);
@@ -239,9 +240,9 @@ export async function processUserPrompt(ctx, text, deps, fileParts = [], options
239
240
  assistantRunState.clearRun(currentSession.id, "session_prompt_background_error");
240
241
  clearPromptResponseMode(currentSession.id);
241
242
  const details = formatErrorDetails(error, 6000);
242
- logger.error("[Bot] session.prompt background task failed", promptErrorLogContext);
243
- logger.error("[Bot] session.prompt background failure details:", details);
244
- logger.error("[Bot] session.prompt raw background error object:", error);
243
+ logger.error("[Bot] session.promptAsync background task failed", promptErrorLogContext);
244
+ logger.error("[Bot] session.promptAsync background failure details:", details);
245
+ logger.error("[Bot] session.promptAsync raw background error object:", error);
245
246
  void bot.api.sendMessage(ctx.chat.id, t("bot.prompt_send_error")).catch(() => { });
246
247
  },
247
248
  });
package/dist/i18n/de.js CHANGED
@@ -320,6 +320,8 @@ export const de = {
320
320
  "task.kind.once": "einmalig",
321
321
  "task.run.success": "⏰ Geplante Aufgabe abgeschlossen: {description}",
322
322
  "task.run.error": "🔴 Geplante Aufgabe fehlgeschlagen: {description}\n\nFehler: {error}",
323
+ "task.run.error.interactive_question": "Die geplante Aufgabe hat eine interaktive Frage gestellt und kann unbeaufsichtigt nicht fortfahren.",
324
+ "task.run.error.interactive_permission": "Die geplante Aufgabe hat eine interaktive Berechtigung angefordert und kann unbeaufsichtigt nicht fortfahren.",
323
325
  "tasklist.empty": "📭 Noch keine geplanten Aufgaben.",
324
326
  "tasklist.select": "Wähle eine geplante Aufgabe:",
325
327
  "tasklist.details": "⏰ Geplante Aufgabe\n\nAufgabe: {prompt}\nProjekt: {project}\nZeitplan: {schedule}\n{cronLine}Zeitzone: {timezone}\nNächster Lauf: {nextRunAt}\nLetzter Lauf: {lastRunAt}\nAnzahl Läufe: {runCount}",
package/dist/i18n/en.js CHANGED
@@ -320,6 +320,8 @@ export const en = {
320
320
  "task.kind.once": "one-time",
321
321
  "task.run.success": "⏰ Scheduled task completed: {description}",
322
322
  "task.run.error": "🔴 Scheduled task failed: {description}\n\nError: {error}",
323
+ "task.run.error.interactive_question": "Scheduled task requested an interactive question and cannot continue unattended.",
324
+ "task.run.error.interactive_permission": "Scheduled task requested interactive permission and cannot continue unattended.",
323
325
  "tasklist.empty": "📭 No scheduled tasks yet.",
324
326
  "tasklist.select": "Select a scheduled task:",
325
327
  "tasklist.details": "⏰ Scheduled task\n\nTask: {prompt}\nProject: {project}\nSchedule: {schedule}\n{cronLine}Timezone: {timezone}\nNext run: {nextRunAt}\nLast run: {lastRunAt}\nRun count: {runCount}",
package/dist/i18n/es.js CHANGED
@@ -320,6 +320,8 @@ export const es = {
320
320
  "task.kind.once": "única",
321
321
  "task.run.success": "⏰ Tarea programada completada: {description}",
322
322
  "task.run.error": "🔴 La tarea programada falló: {description}\n\nError: {error}",
323
+ "task.run.error.interactive_question": "La tarea programada solicitó una pregunta interactiva y no puede continuar sin supervisión.",
324
+ "task.run.error.interactive_permission": "La tarea programada solicitó un permiso interactivo y no puede continuar sin supervisión.",
323
325
  "tasklist.empty": "📭 Aún no hay tareas programadas.",
324
326
  "tasklist.select": "Elige una tarea programada:",
325
327
  "tasklist.details": "⏰ Tarea programada\n\nTarea: {prompt}\nProyecto: {project}\nHorario: {schedule}\n{cronLine}Zona horaria: {timezone}\nPróxima ejecución: {nextRunAt}\nÚltima ejecución: {lastRunAt}\nNúmero de ejecuciones: {runCount}",
package/dist/i18n/fr.js CHANGED
@@ -320,6 +320,8 @@ export const fr = {
320
320
  "task.kind.once": "ponctuelle",
321
321
  "task.run.success": "⏰ Tâche planifiée terminée : {description}",
322
322
  "task.run.error": "🔴 Échec de la tâche planifiée : {description}\n\nErreur : {error}",
323
+ "task.run.error.interactive_question": "La tâche planifiée a demandé une question interactive et ne peut pas continuer sans intervention.",
324
+ "task.run.error.interactive_permission": "La tâche planifiée a demandé une autorisation interactive et ne peut pas continuer sans intervention.",
323
325
  "tasklist.empty": "📭 Aucune tâche planifiée pour le moment.",
324
326
  "tasklist.select": "Sélectionnez une tâche planifiée :",
325
327
  "tasklist.details": "⏰ Tâche planifiée\n\nTâche : {prompt}\nProjet : {project}\nPlanning : {schedule}\n{cronLine}Fuseau horaire : {timezone}\nProchaine exécution : {nextRunAt}\nDernière exécution : {lastRunAt}\nNombre d'exécutions : {runCount}",
package/dist/i18n/ru.js CHANGED
@@ -320,6 +320,8 @@ export const ru = {
320
320
  "task.kind.once": "однократная",
321
321
  "task.run.success": "⏰ Задача по расписанию выполнена: {description}",
322
322
  "task.run.error": "🔴 Ошибка выполнения задачи по расписанию: {description}\n\nОшибка: {error}",
323
+ "task.run.error.interactive_question": "Задача по расписанию задала интерактивный вопрос и не может продолжить выполнение без участия пользователя.",
324
+ "task.run.error.interactive_permission": "Задача по расписанию запросила интерактивное разрешение и не может продолжить выполнение без участия пользователя.",
323
325
  "tasklist.empty": "📭 Задач по расписанию пока нет.",
324
326
  "tasklist.select": "Выберите задачу по расписанию:",
325
327
  "tasklist.details": "⏰ Задача по расписанию\n\nЗадача: {prompt}\nПроект: {project}\nРасписание: {schedule}\n{cronLine}Часовой пояс: {timezone}\nСледующий запуск: {nextRunAt}\nПоследний запуск: {lastRunAt}\nКоличество запусков: {runCount}",
package/dist/i18n/zh.js CHANGED
@@ -320,6 +320,8 @@ export const zh = {
320
320
  "task.kind.once": "一次性",
321
321
  "task.run.success": "⏰ 定时任务已完成: {description}",
322
322
  "task.run.error": "🔴 定时任务执行失败: {description}\n\n错误: {error}",
323
+ "task.run.error.interactive_question": "定时任务请求了交互式问题,无法在无人值守时继续。",
324
+ "task.run.error.interactive_permission": "定时任务请求了交互式权限,无法在无人值守时继续。",
323
325
  "tasklist.empty": "📭 还没有定时任务。",
324
326
  "tasklist.select": "请选择一个定时任务:",
325
327
  "tasklist.details": "⏰ 定时任务\n\n任务:{prompt}\n项目:{project}\n计划:{schedule}\n{cronLine}时区:{timezone}\n下次运行:{nextRunAt}\n上次运行:{lastRunAt}\n运行次数:{runCount}",
@@ -1,12 +1,30 @@
1
1
  import { config } from "../config.js";
2
+ import { t } from "../i18n/index.js";
2
3
  import { opencodeClient } from "../opencode/client.js";
3
4
  import { logger } from "../utils/logger.js";
4
- const SCHEDULED_TASK_AGENT = "build";
5
+ export const SCHEDULED_TASK_AGENT = "build";
5
6
  const SCHEDULED_TASK_SESSION_TITLE = "Scheduled task run";
6
7
  const EXECUTION_POLL_INTERVAL_MS = 2000;
7
8
  const MAX_IDLE_POLLS_WITHOUT_RESULT = 3;
9
+ const COMPLETED_EMPTY_RESULT_RECHECK_INTERVAL_MS = 500;
10
+ const MAX_COMPLETED_EMPTY_RESULT_RECHECKS = 3;
8
11
  const MODELS_DOCS_URL = "https://opencode.ai/docs/config/#models";
9
12
  const EXECUTION_TIMEOUT_ERROR_PREFIX = "Scheduled task exceeded bot execution timeout";
13
+ const INTERACTIVE_PERMISSION_REJECT_MESSAGE = "Scheduled task cannot continue because it requires interactive permission.";
14
+ class ScheduledTaskEmptyAssistantResponseError extends Error {
15
+ constructor() {
16
+ super("Scheduled task returned an empty assistant response");
17
+ this.name = "ScheduledTaskEmptyAssistantResponseError";
18
+ }
19
+ }
20
+ class ScheduledTaskInteractiveRequestError extends Error {
21
+ constructor(kind) {
22
+ super(t(kind === "question"
23
+ ? "task.run.error.interactive_question"
24
+ : "task.run.error.interactive_permission"));
25
+ this.name = "ScheduledTaskInteractiveRequestError";
26
+ }
27
+ }
10
28
  function collectResponseText(parts) {
11
29
  return parts
12
30
  .filter((part) => part.type === "text" && typeof part.text === "string" && !part.ignored)
@@ -69,7 +87,7 @@ function sleep(ms) {
69
87
  function findLatestAssistantMessage(messages) {
70
88
  for (let index = messages.length - 1; index >= 0; index -= 1) {
71
89
  const message = messages[index];
72
- if (message?.info.role === "assistant") {
90
+ if (message?.info.role === "assistant" && !message.info.summary) {
73
91
  return message;
74
92
  }
75
93
  }
@@ -77,7 +95,7 @@ function findLatestAssistantMessage(messages) {
77
95
  }
78
96
  function extractAssistantResult(message) {
79
97
  if (!message) {
80
- return { resultText: null, errorMessage: null, completed: false };
98
+ return { resultText: null, errorMessage: null, completed: false, message: null };
81
99
  }
82
100
  const errorMessage = extractErrorMessage(message.info.error);
83
101
  if (errorMessage) {
@@ -85,6 +103,7 @@ function extractAssistantResult(message) {
85
103
  resultText: null,
86
104
  errorMessage: normalizeScheduledTaskErrorMessage(errorMessage),
87
105
  completed: true,
106
+ message,
88
107
  };
89
108
  }
90
109
  const resultText = collectResponseText(message.parts);
@@ -92,8 +111,116 @@ function extractAssistantResult(message) {
92
111
  resultText,
93
112
  errorMessage: null,
94
113
  completed: Boolean(message.info.time?.completed),
114
+ message,
95
115
  };
96
116
  }
117
+ function summarizeAssistantParts(parts) {
118
+ return parts.map((part) => ({
119
+ id: part.id,
120
+ type: part.type,
121
+ ignored: part.ignored,
122
+ ...(typeof part.text === "string" ? { textLength: part.text.length } : {}),
123
+ ...(part.tool ? { tool: part.tool } : {}),
124
+ ...(part.state?.status ? { status: part.state.status } : {}),
125
+ }));
126
+ }
127
+ function logEmptyAssistantResponseDiagnostics(taskId, sessionId, directory, message, readCount) {
128
+ logger.warn("[ScheduledTaskExecutor] Empty completed assistant response diagnostics", {
129
+ taskId,
130
+ sessionId,
131
+ directory,
132
+ readCount,
133
+ assistantMessage: message
134
+ ? {
135
+ id: message.info.id,
136
+ completed: Boolean(message.info.time?.completed),
137
+ summary: Boolean(message.info.summary),
138
+ errorMessage: extractErrorMessage(message.info.error),
139
+ parts: summarizeAssistantParts(message.parts),
140
+ }
141
+ : null,
142
+ });
143
+ }
144
+ async function loadPendingInteractiveRequest(sessionId, directory) {
145
+ const [questionsResult, permissionsResult] = await Promise.all([
146
+ opencodeClient.question.list({ directory }),
147
+ opencodeClient.permission.list({ directory }),
148
+ ]);
149
+ if (questionsResult.error) {
150
+ logger.warn(`[ScheduledTaskExecutor] Failed to list pending questions: sessionId=${sessionId}`, questionsResult.error);
151
+ }
152
+ const question = questionsResult.data?.find((request) => request.sessionID === sessionId);
153
+ if (question) {
154
+ return { kind: "question", request: question };
155
+ }
156
+ if (permissionsResult.error) {
157
+ logger.warn(`[ScheduledTaskExecutor] Failed to list pending permissions: sessionId=${sessionId}`, permissionsResult.error);
158
+ }
159
+ const permission = permissionsResult.data?.find((request) => request.sessionID === sessionId);
160
+ if (permission) {
161
+ return { kind: "permission", request: permission };
162
+ }
163
+ return null;
164
+ }
165
+ async function rejectInteractiveRequest(request, directory) {
166
+ try {
167
+ if (request.kind === "question") {
168
+ const { error } = await opencodeClient.question.reject({
169
+ requestID: request.request.id,
170
+ directory,
171
+ });
172
+ if (error) {
173
+ logger.warn(`[ScheduledTaskExecutor] Failed to reject pending question: requestId=${request.request.id}`, error);
174
+ }
175
+ return;
176
+ }
177
+ const { error } = await opencodeClient.permission.reply({
178
+ requestID: request.request.id,
179
+ directory,
180
+ reply: "reject",
181
+ message: INTERACTIVE_PERMISSION_REJECT_MESSAGE,
182
+ });
183
+ if (error) {
184
+ logger.warn(`[ScheduledTaskExecutor] Failed to reject pending permission: requestId=${request.request.id}`, error);
185
+ }
186
+ }
187
+ catch (error) {
188
+ logger.warn(`[ScheduledTaskExecutor] Failed to reject pending interactive request: requestId=${request.request.id}`, error);
189
+ }
190
+ }
191
+ async function abortScheduledTaskSession(sessionId, directory) {
192
+ try {
193
+ const { error } = await opencodeClient.session.abort({ sessionID: sessionId, directory });
194
+ if (error) {
195
+ logger.warn(`[ScheduledTaskExecutor] Failed to abort interactive scheduled task session: sessionId=${sessionId}`, error);
196
+ }
197
+ }
198
+ catch (error) {
199
+ logger.warn(`[ScheduledTaskExecutor] Failed to abort interactive scheduled task session: sessionId=${sessionId}`, error);
200
+ }
201
+ }
202
+ async function failIfInteractiveRequest(taskId, sessionId, directory) {
203
+ const interactiveRequest = await loadPendingInteractiveRequest(sessionId, directory);
204
+ if (!interactiveRequest) {
205
+ return;
206
+ }
207
+ logger.warn("[ScheduledTaskExecutor] Scheduled task requested interactive action", {
208
+ taskId,
209
+ sessionId,
210
+ directory,
211
+ kind: interactiveRequest.kind,
212
+ requestId: interactiveRequest.request.id,
213
+ ...(interactiveRequest.kind === "question"
214
+ ? { questionCount: interactiveRequest.request.questions?.length ?? 0 }
215
+ : {
216
+ permission: interactiveRequest.request.permission,
217
+ patterns: interactiveRequest.request.patterns,
218
+ }),
219
+ });
220
+ await rejectInteractiveRequest(interactiveRequest, directory);
221
+ await abortScheduledTaskSession(sessionId, directory);
222
+ throw new ScheduledTaskInteractiveRequestError(interactiveRequest.kind);
223
+ }
97
224
  async function loadAssistantResult(sessionId, directory) {
98
225
  const { data: messages, error: messagesError } = await opencodeClient.session.messages({
99
226
  sessionID: sessionId,
@@ -104,14 +231,16 @@ async function loadAssistantResult(sessionId, directory) {
104
231
  }
105
232
  return extractAssistantResult(findLatestAssistantMessage(messages));
106
233
  }
107
- async function waitForScheduledTaskResult(sessionId, directory) {
234
+ async function waitForScheduledTaskResult(taskId, sessionId, directory) {
108
235
  const startedAtMs = Date.now();
109
236
  const executionTimeoutMs = getExecutionTimeoutMs();
110
237
  let idlePollsWithoutResult = 0;
238
+ let completedEmptyResultReadCount = 0;
111
239
  while (true) {
112
240
  if (Date.now() - startedAtMs >= executionTimeoutMs) {
113
241
  throw new Error(createExecutionTimeoutMessage());
114
242
  }
243
+ await failIfInteractiveRequest(taskId, sessionId, directory);
115
244
  const assistantResult = await loadAssistantResult(sessionId, directory);
116
245
  if (assistantResult.errorMessage) {
117
246
  throw new Error(assistantResult.errorMessage);
@@ -120,8 +249,15 @@ async function waitForScheduledTaskResult(sessionId, directory) {
120
249
  if (assistantResult.resultText) {
121
250
  return assistantResult.resultText;
122
251
  }
123
- throw new Error("Scheduled task returned an empty assistant response");
252
+ completedEmptyResultReadCount += 1;
253
+ if (completedEmptyResultReadCount > MAX_COMPLETED_EMPTY_RESULT_RECHECKS) {
254
+ logEmptyAssistantResponseDiagnostics(taskId, sessionId, directory, assistantResult.message, completedEmptyResultReadCount);
255
+ throw new ScheduledTaskEmptyAssistantResponseError();
256
+ }
257
+ await sleep(COMPLETED_EMPTY_RESULT_RECHECK_INTERVAL_MS);
258
+ continue;
124
259
  }
260
+ completedEmptyResultReadCount = 0;
125
261
  const { data: statuses, error: statusError } = await opencodeClient.session.status({
126
262
  directory,
127
263
  });
@@ -138,7 +274,13 @@ async function waitForScheduledTaskResult(sessionId, directory) {
138
274
  if (confirmedAssistantResult.resultText) {
139
275
  return confirmedAssistantResult.resultText;
140
276
  }
141
- throw new Error("Scheduled task returned an empty assistant response");
277
+ completedEmptyResultReadCount += 1;
278
+ if (completedEmptyResultReadCount > MAX_COMPLETED_EMPTY_RESULT_RECHECKS) {
279
+ logEmptyAssistantResponseDiagnostics(taskId, sessionId, directory, confirmedAssistantResult.message, completedEmptyResultReadCount);
280
+ throw new ScheduledTaskEmptyAssistantResponseError();
281
+ }
282
+ await sleep(COMPLETED_EMPTY_RESULT_RECHECK_INTERVAL_MS);
283
+ continue;
142
284
  }
143
285
  idlePollsWithoutResult += 1;
144
286
  if (idlePollsWithoutResult >= MAX_IDLE_POLLS_WITHOUT_RESULT) {
@@ -154,6 +296,7 @@ async function waitForScheduledTaskResult(sessionId, directory) {
154
296
  export async function executeScheduledTask(task) {
155
297
  const startedAt = new Date().toISOString();
156
298
  let sessionId = null;
299
+ let deleteTemporarySession = true;
157
300
  try {
158
301
  const { data: session, error: createError } = await opencodeClient.session.create({
159
302
  directory: task.projectWorktree,
@@ -182,7 +325,7 @@ export async function executeScheduledTask(task) {
182
325
  if (promptError) {
183
326
  throw promptError || new Error("Scheduled task prompt execution failed");
184
327
  }
185
- const resultText = await waitForScheduledTaskResult(session.id, session.directory);
328
+ const resultText = await waitForScheduledTaskResult(task.id, session.id, session.directory);
186
329
  return {
187
330
  taskId: task.id,
188
331
  status: "success",
@@ -194,6 +337,10 @@ export async function executeScheduledTask(task) {
194
337
  }
195
338
  catch (error) {
196
339
  const errorMessage = toErrorMessage(error);
340
+ if (error instanceof ScheduledTaskEmptyAssistantResponseError && sessionId) {
341
+ deleteTemporarySession = false;
342
+ logger.warn(`[ScheduledTaskExecutor] Keeping temporary session for inspection: id=${task.id}, sessionId=${sessionId}`);
343
+ }
197
344
  logger.warn(`[ScheduledTaskExecutor] Task execution failed: id=${task.id}, message=${errorMessage}`);
198
345
  return {
199
346
  taskId: task.id,
@@ -205,7 +352,7 @@ export async function executeScheduledTask(task) {
205
352
  };
206
353
  }
207
354
  finally {
208
- if (sessionId) {
355
+ if (sessionId && deleteTemporarySession) {
209
356
  try {
210
357
  await opencodeClient.session.delete({ sessionID: sessionId });
211
358
  }
@@ -4,7 +4,8 @@ import { t } from "../i18n/index.js";
4
4
  import { logger } from "../utils/logger.js";
5
5
  import { safeBackgroundTask } from "../utils/safe-background-task.js";
6
6
  import { sendBotText } from "../bot/utils/telegram-text.js";
7
- import { executeScheduledTask } from "./executor.js";
7
+ import { formatAssistantRunFooter } from "../bot/utils/assistant-run-footer.js";
8
+ import { executeScheduledTask, SCHEDULED_TASK_AGENT } from "./executor.js";
8
9
  import { foregroundSessionState } from "./foreground-state.js";
9
10
  import { computeNextRunAt, isTaskDue } from "./next-run.js";
10
11
  import { getScheduledTask, listScheduledTasks, removeScheduledTask, replaceScheduledTasks, updateScheduledTask, } from "./store.js";
@@ -40,7 +41,15 @@ function normalizeTaskPrompt(prompt) {
40
41
  }
41
42
  return `${normalized.slice(0, TASK_DESCRIPTION_PREVIEW_LENGTH)}...`;
42
43
  }
43
- function buildSuccessDelivery(task, runAt, resultText) {
44
+ function calculateElapsedMs(startedAt, finishedAt) {
45
+ const startedAtMs = Date.parse(startedAt);
46
+ const finishedAtMs = Date.parse(finishedAt);
47
+ if (Number.isNaN(startedAtMs) || Number.isNaN(finishedAtMs)) {
48
+ return 0;
49
+ }
50
+ return finishedAtMs - startedAtMs;
51
+ }
52
+ function buildSuccessDelivery(task, startedAt, runAt, resultText) {
44
53
  return {
45
54
  taskId: task.id,
46
55
  scheduleSummary: task.scheduleSummary,
@@ -51,6 +60,12 @@ function buildSuccessDelivery(task, runAt, resultText) {
51
60
  description: normalizeTaskPrompt(task.prompt),
52
61
  }),
53
62
  resultText,
63
+ footerText: formatAssistantRunFooter({
64
+ agent: SCHEDULED_TASK_AGENT,
65
+ providerID: task.model.providerID,
66
+ modelID: task.model.modelID,
67
+ elapsedMs: calculateElapsedMs(startedAt, runAt),
68
+ }),
54
69
  };
55
70
  }
56
71
  function buildErrorDelivery(task, runAt, errorMessage) {
@@ -280,7 +295,7 @@ export class ScheduledTaskRuntime {
280
295
  try {
281
296
  const result = await executeScheduledTask(runningTask);
282
297
  if (result.status === "success") {
283
- await this.handleSuccessfulExecution(runningTask, result.finishedAt, result.resultText || "");
298
+ await this.handleSuccessfulExecution(runningTask, result.startedAt, result.finishedAt, result.resultText || "");
284
299
  }
285
300
  else {
286
301
  await this.handleFailedExecution(runningTask, result.finishedAt, result.errorMessage || "Unknown error");
@@ -290,8 +305,8 @@ export class ScheduledTaskRuntime {
290
305
  this.runningTaskIds.delete(taskId);
291
306
  }
292
307
  }
293
- async handleSuccessfulExecution(task, finishedAt, resultText) {
294
- const delivery = buildSuccessDelivery(task, finishedAt, resultText);
308
+ async handleSuccessfulExecution(task, startedAt, finishedAt, resultText) {
309
+ const delivery = buildSuccessDelivery(task, startedAt, finishedAt, resultText);
295
310
  if (task.kind === "once") {
296
311
  await removeScheduledTask(task.id);
297
312
  this.removeTask(task.id);
@@ -357,12 +372,22 @@ export class ScheduledTaskRuntime {
357
372
  ? buildScheduledTaskSuccessMessageParts(delivery)
358
373
  : [delivery.notificationText];
359
374
  const format = delivery.status === "success" ? getScheduledTaskDeliveryFormat() : "raw";
375
+ const suppressResultNotification = delivery.status === "success" && Boolean(delivery.footerText);
360
376
  for (const part of messageParts) {
361
377
  await sendBotText({
362
378
  api: this.botApi,
363
379
  chatId: this.chatId,
364
380
  text: part,
365
381
  format,
382
+ ...(suppressResultNotification ? { options: { disable_notification: true } } : {}),
383
+ });
384
+ }
385
+ if (delivery.status === "success" && delivery.footerText) {
386
+ await sendBotText({
387
+ api: this.botApi,
388
+ chatId: this.chatId,
389
+ text: delivery.footerText,
390
+ format: "raw",
366
391
  });
367
392
  }
368
393
  return true;
@@ -136,9 +136,8 @@ function upsertDirectory(worktree, lastUpdated) {
136
136
  dedupeAndTrimDirectories(cacheData);
137
137
  return true;
138
138
  }
139
- function buildListParams() {
140
- const hasWatermark = cacheData.lastSyncedUpdatedAt > 0;
141
- if (!hasWatermark) {
139
+ function buildListParams(options) {
140
+ if (options?.force || cacheData.lastSyncedUpdatedAt === 0) {
142
141
  return { limit: INITIAL_WARMUP_LIMIT };
143
142
  }
144
143
  return {
@@ -194,24 +193,38 @@ function isServerUnavailableError(error) {
194
193
  }
195
194
  return false;
196
195
  }
197
- async function runSync() {
196
+ async function runSync(options) {
198
197
  await ensureCacheLoaded();
199
- const params = buildListParams();
198
+ const shouldPrune = options?.force || cacheData.lastSyncedUpdatedAt === 0;
199
+ const params = buildListParams(options);
200
200
  const { data: sessions, error } = await opencodeClient.session.list(params);
201
201
  if (error || !sessions) {
202
202
  throw error || new Error("No session list received from server");
203
203
  }
204
204
  let changed = false;
205
205
  let maxUpdated = cacheData.lastSyncedUpdatedAt;
206
+ const seenDirectories = new Set();
206
207
  for (const session of sessions) {
207
208
  const updatedAt = session.time?.updated ?? Date.now();
208
209
  if (upsertDirectory(session.directory, updatedAt)) {
209
210
  changed = true;
210
211
  }
212
+ if (session.directory && isValidWorktree(session.directory)) {
213
+ seenDirectories.add(worktreeKey(session.directory.trim()));
214
+ }
211
215
  if (updatedAt > maxUpdated) {
212
216
  maxUpdated = updatedAt;
213
217
  }
214
218
  }
219
+ const responseIsTruncated = sessions.length >= INITIAL_WARMUP_LIMIT;
220
+ if (shouldPrune && !responseIsTruncated) {
221
+ const before = cacheData.directories.length;
222
+ cacheData.directories = cacheData.directories.filter((d) => seenDirectories.has(worktreeKey(d.worktree)));
223
+ if (cacheData.directories.length !== before) {
224
+ changed = true;
225
+ logger.info(`[SessionCache] Pruned ${before - cacheData.directories.length} stale directories from cache`);
226
+ }
227
+ }
215
228
  if (maxUpdated !== cacheData.lastSyncedUpdatedAt) {
216
229
  cacheData.lastSyncedUpdatedAt = maxUpdated;
217
230
  changed = true;
@@ -397,7 +410,7 @@ export async function syncSessionDirectoryCache(options) {
397
410
  if (syncInFlight) {
398
411
  return syncInFlight;
399
412
  }
400
- syncInFlight = runSync()
413
+ syncInFlight = runSync(options)
401
414
  .then(() => {
402
415
  lastSyncAttemptAt = Date.now();
403
416
  })
@@ -16,6 +16,7 @@ const LOG_LEVELS = {
16
16
  let logStream = null;
17
17
  let logFilePath = null;
18
18
  let initializePromise = null;
19
+ let cleanupPromise = null;
19
20
  let streamErrorReported = false;
20
21
  function normalizeLogLevel(value) {
21
22
  if (value in LOG_LEVELS) {
@@ -96,6 +97,9 @@ function getInstalledLogFileName() {
96
97
  function getLogFileName(mode) {
97
98
  return mode === "installed" ? getInstalledLogFileName() : getSourcesLogFileName();
98
99
  }
100
+ function getLogFilePathForMode(logsDirPath, mode) {
101
+ return path.join(logsDirPath, getLogFileName(mode));
102
+ }
99
103
  function getLogFilePattern(mode) {
100
104
  if (mode === "installed") {
101
105
  return /^bot-\d{4}-\d{2}-\d{2}\.log$/;
@@ -160,11 +164,49 @@ async function cleanupOldLogs(logsDirPath, mode) {
160
164
  }
161
165
  }));
162
166
  }
167
+ function cleanupOldLogsInBackground(logsDirPath, mode) {
168
+ if (cleanupPromise) {
169
+ return;
170
+ }
171
+ cleanupPromise = cleanupOldLogs(logsDirPath, mode)
172
+ .catch((error) => {
173
+ reportLoggerInternalError(`Failed to clean up old logs in ${logsDirPath}.`, error);
174
+ })
175
+ .finally(() => {
176
+ cleanupPromise = null;
177
+ });
178
+ }
179
+ function rotateInstalledLogIfNeeded() {
180
+ const mode = getRuntimeMode();
181
+ if (mode !== "installed" || !logFilePath) {
182
+ return;
183
+ }
184
+ const runtimePaths = getRuntimePaths();
185
+ const nextLogFilePath = getLogFilePathForMode(runtimePaths.logsDirPath, mode);
186
+ if (logFilePath === nextLogFilePath) {
187
+ return;
188
+ }
189
+ try {
190
+ fs.mkdirSync(runtimePaths.logsDirPath, { recursive: true });
191
+ fs.appendFileSync(nextLogFilePath, "");
192
+ ensureLogStream(nextLogFilePath);
193
+ cleanupOldLogsInBackground(runtimePaths.logsDirPath, mode);
194
+ }
195
+ catch (error) {
196
+ reportLoggerInternalError(`Failed to rotate file logging to ${nextLogFilePath}.`, error);
197
+ closeLogStream();
198
+ logFilePath = null;
199
+ }
200
+ }
163
201
  function writeToFile(line) {
164
202
  if (!logStream) {
165
203
  return;
166
204
  }
167
205
  try {
206
+ rotateInstalledLogIfNeeded();
207
+ if (!logStream) {
208
+ return;
209
+ }
168
210
  logStream.write(`${line}\n`);
169
211
  }
170
212
  catch (error) {
@@ -179,7 +221,7 @@ async function initializeLoggerInternal() {
179
221
  const mode = getRuntimeMode();
180
222
  try {
181
223
  await fsPromises.mkdir(runtimePaths.logsDirPath, { recursive: true });
182
- const nextLogFilePath = path.join(runtimePaths.logsDirPath, getLogFileName(mode));
224
+ const nextLogFilePath = getLogFilePathForMode(runtimePaths.logsDirPath, mode);
183
225
  await fsPromises.appendFile(nextLogFilePath, "");
184
226
  ensureLogStream(nextLogFilePath);
185
227
  await cleanupOldLogs(runtimePaths.logsDirPath, mode);
@@ -207,6 +249,9 @@ export function getLogFilePath() {
207
249
  return logFilePath;
208
250
  }
209
251
  export async function __flushLoggerForTests() {
252
+ if (cleanupPromise) {
253
+ await cleanupPromise;
254
+ }
210
255
  if (!logStream) {
211
256
  return;
212
257
  }
@@ -222,6 +267,7 @@ export async function __flushLoggerForTests() {
222
267
  }
223
268
  export function __resetLoggerForTests() {
224
269
  initializePromise = null;
270
+ cleanupPromise = null;
225
271
  logFilePath = null;
226
272
  streamErrorReported = false;
227
273
  closeLogStream();
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@grinev/opencode-telegram-bot",
3
- "version": "0.19.0",
3
+ "version": "0.19.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",