@grinev/opencode-telegram-bot 0.11.4 → 0.12.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.
package/dist/i18n/ru.js CHANGED
@@ -4,6 +4,8 @@ export const ru = {
4
4
  "cmd.description.stop": "Прервать текущее действие",
5
5
  "cmd.description.sessions": "Список сессий",
6
6
  "cmd.description.projects": "Список проектов",
7
+ "cmd.description.task": "Создать задачу по расписанию",
8
+ "cmd.description.tasklist": "Список задач по расписанию",
7
9
  "cmd.description.commands": "Пользовательские команды",
8
10
  "cmd.description.opencode_start": "Запустить OpenCode сервер",
9
11
  "cmd.description.opencode_stop": "Остановить OpenCode сервер",
@@ -30,7 +32,7 @@ export const ru = {
30
32
  "inline.cancelled_callback": "Отменено",
31
33
  "common.unknown": "неизвестна",
32
34
  "common.unknown_error": "неизвестная ошибка",
33
- "start.welcome": "👋 Добро пожаловать в OpenCode Telegram Bot!\n\nИспользуйте команды:\n/projects — выбрать проект\n/sessions — список сессий\n/new — новая сессия\n/status — статус\n/help — справка\n\nРежим, модель и вариант выбираются кнопками внизу.",
35
+ "start.welcome": "👋 Добро пожаловать в OpenCode Telegram Bot!\n\nИспользуйте команды:\n/projects — выбрать проект\n/sessions — список сессий\n/new — новая сессия\n/task — задача по расписанию\n/tasklist — список задач по расписанию\n/status — статус\n/help — справка\n\nРежим, модель и вариант выбираются кнопками внизу.",
34
36
  "help.keyboard_hint": "💡 Режим, модель, вариант и действия с контекстом доступны через нижние кнопки клавиатуры.",
35
37
  "help.text": "📖 **Справка**\n\n/status - Проверить статус сервера\n/sessions - Список сессий\n/new - Создать новую сессию\n/help - Справка",
36
38
  "bot.thinking": "💭 Думаю...",
@@ -247,6 +249,41 @@ export const ru = {
247
249
  "rename.blocked.expected_name": "⚠️ Введите новое название текстом или нажмите Отмена в сообщении переименования.",
248
250
  "rename.blocked.command_not_allowed": "⚠️ Эта команда недоступна, пока ожидается новое название сессии.",
249
251
  "rename.button.cancel": "❌ Отмена",
252
+ "task.prompt.schedule": "⏰ Отправьте расписание задачи обычным языком.\n\nПримеры:\n- каждые 5 минут\n- каждый день в 17:00\n- завтра в 12:00",
253
+ "task.schedule_empty": "⚠️ Расписание не может быть пустым.",
254
+ "task.parse.in_progress": "⏳ Распознаю расписание...",
255
+ "task.parse_error": "🔴 Не удалось распознать расписание.\n\n{message}\n\nОтправьте период еще раз в более явном виде.",
256
+ "task.schedule_preview": "✅ Расписание распознано\n\nКак я понял: {summary}\n{cronLine}Часовой пояс: {timezone}\nТип: {kind}\nСледующий запуск: {nextRunAt}",
257
+ "task.schedule_preview.cron": "Cron: {cron}",
258
+ "task.prompt.body": "📝 Теперь отправьте текст задачи, которую нужно выполнять по расписанию.",
259
+ "task.prompt_empty": "⚠️ Текст задачи не может быть пустым.",
260
+ "task.created": "✅ Задача по расписанию создана\n\nЗадача: {description}\nПроект: {project}\nМодель: {model}\nРасписание: {schedule}\n{cronLine}Следующий запуск: {nextRunAt}",
261
+ "task.created.cron": "Cron: {cron}",
262
+ "task.button.retry_schedule": "🔁 Ввести период заново",
263
+ "task.button.cancel": "❌ Отмена",
264
+ "task.retry_schedule_callback": "Возвращаю ввод периода...",
265
+ "task.cancel_callback": "Отменяю...",
266
+ "task.cancelled": "❌ Создание задачи по расписанию отменено.",
267
+ "task.inactive_callback": "Этот сценарий создания задачи уже неактивен",
268
+ "task.inactive": "⚠️ Сценарий создания задачи неактивен. Запустите /task снова.",
269
+ "task.blocked.expected_input": "⚠️ Сначала завершите создание задачи по расписанию: отправьте текст или используйте кнопку в сообщении с расписанием.",
270
+ "task.blocked.command_not_allowed": "⚠️ Эта команда недоступна, пока идет создание задачи по расписанию.",
271
+ "task.limit_reached": "⚠️ Достигнут лимит задач ({limit}). Сначала удалите одну из существующих задач по расписанию.",
272
+ "task.schedule_too_frequent": "Повторяющееся расписание слишком частое. Минимально допустимый интервал - один запуск в 5 минут.",
273
+ "task.kind.cron": "повторяющаяся",
274
+ "task.kind.once": "однократная",
275
+ "task.run.success": "⏰ Задача по расписанию выполнена: {description}\n\n{result}",
276
+ "task.run.error": "🔴 Ошибка выполнения задачи по расписанию: {description}\n\nОшибка: {error}",
277
+ "tasklist.empty": "📭 Задач по расписанию пока нет.",
278
+ "tasklist.select": "Выберите задачу по расписанию:",
279
+ "tasklist.details": "⏰ Задача по расписанию\n\nЗадача: {prompt}\nПроект: {project}\nРасписание: {schedule}\n{cronLine}Часовой пояс: {timezone}\nСледующий запуск: {nextRunAt}\nПоследний запуск: {lastRunAt}\nКоличество запусков: {runCount}",
280
+ "tasklist.details.cron": "Cron: {cron}",
281
+ "tasklist.button.delete": "🗑 Удалить",
282
+ "tasklist.button.cancel": "❌ Отмена",
283
+ "tasklist.deleted_callback": "Удалено",
284
+ "tasklist.cancelled_callback": "Отменено",
285
+ "tasklist.inactive_callback": "Это меню задач по расписанию уже неактивно",
286
+ "tasklist.load_error": "🔴 Не удалось загрузить задачи по расписанию.",
250
287
  "commands.select": "Выберите команду OpenCode:",
251
288
  "commands.empty": "📭 Для этого проекта нет доступных команд OpenCode.",
252
289
  "commands.fetch_error": "🔴 Не удалось загрузить список команд OpenCode.",
package/dist/i18n/zh.js CHANGED
@@ -4,6 +4,8 @@ export const zh = {
4
4
  "cmd.description.stop": "停止当前操作",
5
5
  "cmd.description.sessions": "列出会话",
6
6
  "cmd.description.projects": "列出项目",
7
+ "cmd.description.task": "创建定时任务",
8
+ "cmd.description.tasklist": "查看定时任务",
7
9
  "cmd.description.commands": "自定义命令",
8
10
  "cmd.description.opencode_start": "启动 OpenCode 服务器",
9
11
  "cmd.description.opencode_stop": "停止 OpenCode 服务器",
@@ -30,7 +32,7 @@ export const zh = {
30
32
  "inline.cancelled_callback": "已取消",
31
33
  "common.unknown": "未知",
32
34
  "common.unknown_error": "未知错误",
33
- "start.welcome": "👋 欢迎使用 OpenCode Telegram Bot!\n\n可用命令:\n/projects — 选择项目\n/sessions — 会话列表\n/new — 新建会话\n/status — 状态\n/help — 帮助\n\n请使用底部按钮选择模式、模型和变体。",
35
+ "start.welcome": "👋 欢迎使用 OpenCode Telegram Bot!\n\n可用命令:\n/projects — 选择项目\n/sessions — 会话列表\n/new — 新建会话\n/task — 定时任务\n/tasklist — 定时任务列表\n/status — 状态\n/help — 帮助\n\n请使用底部按钮选择模式、模型和变体。",
34
36
  "help.keyboard_hint": "💡 代理模式、模型、变体和上下文操作请使用底部键盘按钮。",
35
37
  "help.text": "📖 **帮助**\n\n/status - 查看服务器状态\n/sessions - 会话列表\n/new - 创建新会话\n/help - 帮助",
36
38
  "bot.thinking": "💭 思考中...",
@@ -247,6 +249,41 @@ export const zh = {
247
249
  "rename.blocked.expected_name": "⚠️ 请以文本输入新会话名称,或在重命名消息中点击取消。",
248
250
  "rename.blocked.command_not_allowed": "⚠️ 重命名等待新名称期间不可用此命令。",
249
251
  "rename.button.cancel": "❌ 取消",
252
+ "task.prompt.schedule": "⏰ 请用自然语言发送任务的时间安排。\n\n示例:\n- 每 5 分钟\n- 每天 17:00\n- 明天 12:00",
253
+ "task.schedule_empty": "⚠️ 时间安排不能为空。",
254
+ "task.parse.in_progress": "⏳ 正在解析时间安排...",
255
+ "task.parse_error": "🔴 无法解析时间安排。\n\n{message}\n\n请用更清晰的方式重新发送。",
256
+ "task.schedule_preview": "✅ 时间安排已解析\n\n理解为:{summary}\n{cronLine}时区:{timezone}\n类型:{kind}\n下次运行:{nextRunAt}",
257
+ "task.schedule_preview.cron": "Cron: {cron}",
258
+ "task.prompt.body": "📝 现在发送机器人按此时间安排需要执行的内容。",
259
+ "task.prompt_empty": "⚠️ 任务文本不能为空。",
260
+ "task.created": "✅ 定时任务已创建\n\n任务:{description}\n项目:{project}\n模型:{model}\n时间安排:{schedule}\n{cronLine}下次运行:{nextRunAt}",
261
+ "task.created.cron": "Cron: {cron}",
262
+ "task.button.retry_schedule": "🔁 重新输入时间安排",
263
+ "task.button.cancel": "❌ 取消",
264
+ "task.retry_schedule_callback": "正在重新输入时间安排...",
265
+ "task.cancel_callback": "正在取消...",
266
+ "task.cancelled": "❌ 定时任务创建已取消。",
267
+ "task.inactive_callback": "这个定时任务流程已失效",
268
+ "task.inactive": "⚠️ 定时任务创建流程未激活。请重新运行 /task。",
269
+ "task.blocked.expected_input": "⚠️ 请先完成当前定时任务设置:发送文本,或使用时间安排消息中的按钮。",
270
+ "task.blocked.command_not_allowed": "⚠️ 定时任务创建进行中时,此命令不可用。",
271
+ "task.limit_reached": "⚠️ 已达到任务数量上限({limit})。请先删除一个现有定时任务。",
272
+ "task.schedule_too_frequent": "重复任务过于频繁。最小允许间隔为每 5 分钟一次。",
273
+ "task.kind.cron": "重复",
274
+ "task.kind.once": "一次性",
275
+ "task.run.success": "⏰ 定时任务已完成: {description}\n\n{result}",
276
+ "task.run.error": "🔴 定时任务执行失败: {description}\n\n错误: {error}",
277
+ "tasklist.empty": "📭 还没有定时任务。",
278
+ "tasklist.select": "请选择一个定时任务:",
279
+ "tasklist.details": "⏰ 定时任务\n\n任务:{prompt}\n项目:{project}\n计划:{schedule}\n{cronLine}时区:{timezone}\n下次运行:{nextRunAt}\n上次运行:{lastRunAt}\n运行次数:{runCount}",
280
+ "tasklist.details.cron": "Cron: {cron}",
281
+ "tasklist.button.delete": "🗑 删除",
282
+ "tasklist.button.cancel": "❌ 取消",
283
+ "tasklist.deleted_callback": "已删除",
284
+ "tasklist.cancelled_callback": "已取消",
285
+ "tasklist.inactive_callback": "此定时任务菜单已失效",
286
+ "tasklist.load_error": "🔴 无法加载定时任务。",
250
287
  "commands.select": "请选择一个 OpenCode 命令:",
251
288
  "commands.empty": "📭 当前项目没有可用的 OpenCode 命令。",
252
289
  "commands.fetch_error": "🔴 加载 OpenCode 命令失败。",
@@ -1,21 +1,29 @@
1
1
  import { permissionManager } from "../permission/manager.js";
2
2
  import { questionManager } from "../question/manager.js";
3
3
  import { renameManager } from "../rename/manager.js";
4
+ import { taskCreationManager } from "../scheduled-task/creation-manager.js";
4
5
  import { interactionManager } from "./manager.js";
5
6
  import { logger } from "../utils/logger.js";
6
7
  export function clearAllInteractionState(reason) {
7
8
  const questionActive = questionManager.isActive();
8
9
  const permissionActive = permissionManager.isActive();
9
10
  const renameActive = renameManager.isWaitingForName();
11
+ const taskCreationActive = taskCreationManager.isActive();
10
12
  const interactionSnapshot = interactionManager.getSnapshot();
11
13
  questionManager.clear();
12
14
  permissionManager.clear();
13
15
  renameManager.clear();
16
+ taskCreationManager.clear();
14
17
  interactionManager.clear(reason);
15
- const hasAnyActiveState = questionActive || permissionActive || renameActive || interactionSnapshot !== null;
18
+ const hasAnyActiveState = questionActive ||
19
+ permissionActive ||
20
+ renameActive ||
21
+ taskCreationActive ||
22
+ interactionSnapshot !== null;
16
23
  const message = `[InteractionCleanup] Cleared state: reason=${reason}, ` +
17
24
  `questionActive=${questionActive}, permissionActive=${permissionActive}, ` +
18
- `renameActive=${renameActive}, interactionKind=${interactionSnapshot?.kind || "none"}`;
25
+ `renameActive=${renameActive}, taskCreationActive=${taskCreationActive}, ` +
26
+ `interactionKind=${interactionSnapshot?.kind || "none"}`;
19
27
  if (hasAnyActiveState) {
20
28
  logger.info(message);
21
29
  return;
@@ -62,6 +62,10 @@ function isAllowedRenameCancelCallback(ctx, state) {
62
62
  state.expectedInput === "text" &&
63
63
  ctx.callbackQuery?.data === "rename:cancel");
64
64
  }
65
+ function isAllowedTaskCallback(ctx, state) {
66
+ return (state.kind === "task" &&
67
+ (ctx.callbackQuery?.data === "task:cancel" || ctx.callbackQuery?.data === "task:retry-schedule"));
68
+ }
65
69
  export function resolveInteractionGuardDecision(ctx) {
66
70
  const state = interactionManager.getSnapshot();
67
71
  const { inputType, command } = classifyIncomingInput(ctx);
@@ -90,6 +94,9 @@ export function resolveInteractionGuardDecision(ctx) {
90
94
  if (inputType === "callback" && isAllowedRenameCancelCallback(ctx, state)) {
91
95
  return createAllowDecision(inputType, state, command);
92
96
  }
97
+ if (inputType === "callback" && isAllowedTaskCallback(ctx, state)) {
98
+ return createAllowDecision(inputType, state, command);
99
+ }
93
100
  if (state.expectedInput === inputType) {
94
101
  return createAllowDecision(inputType, state, command);
95
102
  }
@@ -0,0 +1,113 @@
1
+ import { cloneParsedTaskSchedule, cloneScheduledTaskModel } from "./types.js";
2
+ import { logger } from "../utils/logger.js";
3
+ function cloneState(state) {
4
+ return {
5
+ ...state,
6
+ model: cloneScheduledTaskModel(state.model),
7
+ parsedSchedule: state.parsedSchedule ? cloneParsedTaskSchedule(state.parsedSchedule) : null,
8
+ };
9
+ }
10
+ class TaskCreationManager {
11
+ state = null;
12
+ start(projectId, projectWorktree, model) {
13
+ this.state = {
14
+ stage: "awaiting_schedule",
15
+ projectId,
16
+ projectWorktree,
17
+ model: cloneScheduledTaskModel(model),
18
+ scheduleText: null,
19
+ parsedSchedule: null,
20
+ scheduleRequestMessageId: null,
21
+ previewMessageId: null,
22
+ promptRequestMessageId: null,
23
+ };
24
+ logger.info(`[TaskCreationManager] Started task creation flow for project=${projectWorktree}`);
25
+ return cloneState(this.state);
26
+ }
27
+ isActive() {
28
+ return this.state !== null;
29
+ }
30
+ isWaitingForSchedule() {
31
+ return this.state?.stage === "awaiting_schedule";
32
+ }
33
+ isParsingSchedule() {
34
+ return this.state?.stage === "parsing_schedule";
35
+ }
36
+ isWaitingForPrompt() {
37
+ return this.state?.stage === "awaiting_prompt";
38
+ }
39
+ getState() {
40
+ return this.state ? cloneState(this.state) : null;
41
+ }
42
+ setParsedSchedule(scheduleText, parsedSchedule, previewMessageId) {
43
+ if (!this.state) {
44
+ return null;
45
+ }
46
+ this.state = {
47
+ ...this.state,
48
+ stage: "awaiting_prompt",
49
+ scheduleText,
50
+ parsedSchedule: cloneParsedTaskSchedule(parsedSchedule),
51
+ scheduleRequestMessageId: null,
52
+ previewMessageId,
53
+ promptRequestMessageId: null,
54
+ };
55
+ logger.info("[TaskCreationManager] Parsed schedule and switched flow to prompt input");
56
+ return cloneState(this.state);
57
+ }
58
+ markScheduleParsing() {
59
+ if (!this.state) {
60
+ return null;
61
+ }
62
+ this.state = {
63
+ ...this.state,
64
+ stage: "parsing_schedule",
65
+ };
66
+ logger.info("[TaskCreationManager] Schedule parsing started");
67
+ return cloneState(this.state);
68
+ }
69
+ setPromptRequestMessageId(messageId) {
70
+ if (!this.state) {
71
+ return null;
72
+ }
73
+ this.state = {
74
+ ...this.state,
75
+ promptRequestMessageId: messageId,
76
+ };
77
+ return cloneState(this.state);
78
+ }
79
+ setScheduleRequestMessageId(messageId) {
80
+ if (!this.state) {
81
+ return null;
82
+ }
83
+ this.state = {
84
+ ...this.state,
85
+ scheduleRequestMessageId: messageId,
86
+ };
87
+ return cloneState(this.state);
88
+ }
89
+ resetSchedule() {
90
+ if (!this.state) {
91
+ return null;
92
+ }
93
+ this.state = {
94
+ ...this.state,
95
+ stage: "awaiting_schedule",
96
+ scheduleText: null,
97
+ parsedSchedule: null,
98
+ scheduleRequestMessageId: null,
99
+ previewMessageId: null,
100
+ promptRequestMessageId: null,
101
+ };
102
+ logger.info("[TaskCreationManager] Reset task creation flow back to schedule input");
103
+ return cloneState(this.state);
104
+ }
105
+ clear() {
106
+ if (!this.state) {
107
+ return;
108
+ }
109
+ logger.debug("[TaskCreationManager] Clearing task creation state");
110
+ this.state = null;
111
+ }
112
+ }
113
+ export const taskCreationManager = new TaskCreationManager();
@@ -0,0 +1,239 @@
1
+ const BADGE_DATE_LOCALE = "en-US";
2
+ const WEEKDAY_LABELS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
3
+ const zonedDateTimeFormatterCache = new Map();
4
+ const zonedYearFormatterCache = new Map();
5
+ function getZonedDateTimeFormatter(timezone) {
6
+ const cached = zonedDateTimeFormatterCache.get(timezone);
7
+ if (cached) {
8
+ return cached;
9
+ }
10
+ const formatter = new Intl.DateTimeFormat(BADGE_DATE_LOCALE, {
11
+ timeZone: timezone,
12
+ year: "numeric",
13
+ month: "short",
14
+ day: "numeric",
15
+ hour: "2-digit",
16
+ minute: "2-digit",
17
+ hour12: false,
18
+ hourCycle: "h23",
19
+ });
20
+ zonedDateTimeFormatterCache.set(timezone, formatter);
21
+ return formatter;
22
+ }
23
+ function getZonedYearFormatter(timezone) {
24
+ const cached = zonedYearFormatterCache.get(timezone);
25
+ if (cached) {
26
+ return cached;
27
+ }
28
+ const formatter = new Intl.DateTimeFormat(BADGE_DATE_LOCALE, {
29
+ timeZone: timezone,
30
+ year: "numeric",
31
+ });
32
+ zonedYearFormatterCache.set(timezone, formatter);
33
+ return formatter;
34
+ }
35
+ function getDateTimePart(parts, type) {
36
+ return parts.find((part) => part.type === type)?.value ?? "";
37
+ }
38
+ function getZonedDateParts(date, timezone) {
39
+ const parts = getZonedDateTimeFormatter(timezone).formatToParts(date);
40
+ const year = Number.parseInt(getDateTimePart(parts, "year"), 10);
41
+ const month = getDateTimePart(parts, "month");
42
+ const day = Number.parseInt(getDateTimePart(parts, "day"), 10);
43
+ const hour = Number.parseInt(getDateTimePart(parts, "hour"), 10);
44
+ const minute = Number.parseInt(getDateTimePart(parts, "minute"), 10);
45
+ if (!Number.isInteger(year) ||
46
+ !month ||
47
+ !Number.isInteger(day) ||
48
+ !Number.isInteger(hour) ||
49
+ !Number.isInteger(minute)) {
50
+ return null;
51
+ }
52
+ return {
53
+ year,
54
+ month,
55
+ day,
56
+ hour,
57
+ minute,
58
+ };
59
+ }
60
+ function padNumber(value) {
61
+ return value.toString().padStart(2, "0");
62
+ }
63
+ function formatTime(hour, minute) {
64
+ return `${padNumber(hour)}:${padNumber(minute)}`;
65
+ }
66
+ function formatOnceTaskBadge(runAt, timezone) {
67
+ const runAtDate = new Date(runAt);
68
+ if (Number.isNaN(runAtDate.getTime())) {
69
+ return runAt;
70
+ }
71
+ const runAtParts = getZonedDateParts(runAtDate, timezone);
72
+ if (!runAtParts) {
73
+ return runAt;
74
+ }
75
+ const currentYear = getZonedYearFormatter(timezone).format(new Date());
76
+ const includeYear = currentYear !== String(runAtParts.year);
77
+ const dateLabel = includeYear
78
+ ? `${runAtParts.day} ${runAtParts.month} ${runAtParts.year}`
79
+ : `${runAtParts.day} ${runAtParts.month}`;
80
+ return `${dateLabel} ${formatTime(runAtParts.hour, runAtParts.minute)}`;
81
+ }
82
+ function parseCronParts(cron) {
83
+ const parts = cron.trim().split(/\s+/);
84
+ if (parts.length !== 5) {
85
+ return null;
86
+ }
87
+ const [minute, hour, dayOfMonth, month, dayOfWeek] = parts;
88
+ return {
89
+ minute,
90
+ hour,
91
+ dayOfMonth,
92
+ month,
93
+ dayOfWeek,
94
+ };
95
+ }
96
+ function parseExactNumber(field, min, max) {
97
+ const trimmedField = field.trim();
98
+ if (!/^\d+$/.test(trimmedField)) {
99
+ return null;
100
+ }
101
+ const value = Number.parseInt(trimmedField, 10);
102
+ if (!Number.isInteger(value) || value < min || value > max) {
103
+ return null;
104
+ }
105
+ return value;
106
+ }
107
+ function parseEveryStep(field, minStep, maxStep) {
108
+ const match = field.trim().match(/^\*\/(\d+)$/);
109
+ if (!match) {
110
+ return null;
111
+ }
112
+ const step = Number.parseInt(match[1], 10);
113
+ if (!Number.isInteger(step) || step < minStep || step > maxStep) {
114
+ return null;
115
+ }
116
+ return step;
117
+ }
118
+ function normalizeWeekdayValue(token) {
119
+ const normalized = token.trim().toLowerCase();
120
+ const aliases = {
121
+ sun: 0,
122
+ mon: 1,
123
+ tue: 2,
124
+ wed: 3,
125
+ thu: 4,
126
+ fri: 5,
127
+ sat: 6,
128
+ };
129
+ if (normalized in aliases) {
130
+ return aliases[normalized];
131
+ }
132
+ const numericValue = parseExactNumber(normalized, 0, 7);
133
+ if (numericValue === null) {
134
+ return null;
135
+ }
136
+ return numericValue === 7 ? 0 : numericValue;
137
+ }
138
+ function parseWeekdaySet(field) {
139
+ const values = new Set();
140
+ for (const token of field.split(",")) {
141
+ const trimmedToken = token.trim();
142
+ if (!trimmedToken || trimmedToken.includes("/")) {
143
+ return null;
144
+ }
145
+ if (trimmedToken.includes("-")) {
146
+ const [startRaw, endRaw] = trimmedToken.split("-");
147
+ const start = normalizeWeekdayValue(startRaw);
148
+ const end = normalizeWeekdayValue(endRaw);
149
+ if (start === null || end === null || start > end) {
150
+ return null;
151
+ }
152
+ for (let value = start; value <= end; value += 1) {
153
+ values.add(value);
154
+ }
155
+ continue;
156
+ }
157
+ const value = normalizeWeekdayValue(trimmedToken);
158
+ if (value === null) {
159
+ return null;
160
+ }
161
+ values.add(value);
162
+ }
163
+ return values;
164
+ }
165
+ function hasExactValues(values, expectedValues) {
166
+ return (values.size === expectedValues.length && expectedValues.every((value) => values.has(value)));
167
+ }
168
+ function formatCronTaskBadge(cron) {
169
+ const parts = parseCronParts(cron);
170
+ if (!parts) {
171
+ return "cron";
172
+ }
173
+ const minute = parseExactNumber(parts.minute, 0, 59);
174
+ const hour = parseExactNumber(parts.hour, 0, 23);
175
+ const everyMinuteStep = parseEveryStep(parts.minute, 1, 59);
176
+ const everyHourStep = parseEveryStep(parts.hour, 1, 23);
177
+ const monthIsWildcard = parts.month === "*";
178
+ const dayOfMonthIsWildcard = parts.dayOfMonth === "*";
179
+ const dayOfWeekIsWildcard = parts.dayOfWeek === "*";
180
+ if (everyMinuteStep !== null &&
181
+ parts.hour === "*" &&
182
+ monthIsWildcard &&
183
+ dayOfMonthIsWildcard &&
184
+ dayOfWeekIsWildcard) {
185
+ return `${everyMinuteStep}m`;
186
+ }
187
+ if (minute !== null &&
188
+ everyHourStep !== null &&
189
+ monthIsWildcard &&
190
+ dayOfMonthIsWildcard &&
191
+ dayOfWeekIsWildcard) {
192
+ return minute === 0 ? `${everyHourStep}h` : `${everyHourStep}h :${padNumber(minute)}`;
193
+ }
194
+ if (minute !== null &&
195
+ parts.hour === "*" &&
196
+ monthIsWildcard &&
197
+ dayOfMonthIsWildcard &&
198
+ dayOfWeekIsWildcard) {
199
+ return minute === 0 ? "hourly" : `hourly :${padNumber(minute)}`;
200
+ }
201
+ if (minute !== null &&
202
+ hour !== null &&
203
+ monthIsWildcard &&
204
+ dayOfMonthIsWildcard &&
205
+ dayOfWeekIsWildcard) {
206
+ return `daily ${formatTime(hour, minute)}`;
207
+ }
208
+ if (minute !== null && hour !== null && monthIsWildcard && dayOfMonthIsWildcard) {
209
+ const weekdayValues = parseWeekdaySet(parts.dayOfWeek);
210
+ if (!weekdayValues) {
211
+ return "cron";
212
+ }
213
+ if (hasExactValues(weekdayValues, [1, 2, 3, 4, 5])) {
214
+ return `weekdays ${formatTime(hour, minute)}`;
215
+ }
216
+ if (hasExactValues(weekdayValues, [0, 6])) {
217
+ return `weekends ${formatTime(hour, minute)}`;
218
+ }
219
+ if (weekdayValues.size === 1) {
220
+ const [weekday] = weekdayValues;
221
+ return `${WEEKDAY_LABELS[weekday]} ${formatTime(hour, minute)}`;
222
+ }
223
+ }
224
+ const dayOfMonth = parseExactNumber(parts.dayOfMonth, 1, 31);
225
+ if (minute !== null &&
226
+ hour !== null &&
227
+ dayOfMonth !== null &&
228
+ monthIsWildcard &&
229
+ dayOfWeekIsWildcard) {
230
+ return `monthly ${dayOfMonth} ${formatTime(hour, minute)}`;
231
+ }
232
+ return "cron";
233
+ }
234
+ export function formatTaskListBadge(task) {
235
+ if (task.kind === "once") {
236
+ return formatOnceTaskBadge(task.runAt, task.timezone);
237
+ }
238
+ return formatCronTaskBadge(task.cron);
239
+ }
@@ -0,0 +1,87 @@
1
+ import { opencodeClient } from "../opencode/client.js";
2
+ import { logger } from "../utils/logger.js";
3
+ const SCHEDULED_TASK_AGENT = "build";
4
+ const SCHEDULED_TASK_SESSION_TITLE = "Scheduled task run";
5
+ function collectResponseText(parts) {
6
+ return parts
7
+ .filter((part) => part.type === "text" && typeof part.text === "string" && !part.ignored)
8
+ .map((part) => part.text)
9
+ .join("")
10
+ .trim();
11
+ }
12
+ function toErrorMessage(error) {
13
+ if (error instanceof Error && error.message.trim()) {
14
+ return error.message.trim();
15
+ }
16
+ if (typeof error === "string" && error.trim()) {
17
+ return error.trim();
18
+ }
19
+ return "Unknown scheduled task execution error";
20
+ }
21
+ export async function executeScheduledTask(task) {
22
+ const startedAt = new Date().toISOString();
23
+ let sessionId = null;
24
+ try {
25
+ const { data: session, error: createError } = await opencodeClient.session.create({
26
+ directory: task.projectWorktree,
27
+ title: SCHEDULED_TASK_SESSION_TITLE,
28
+ });
29
+ if (createError || !session) {
30
+ throw createError || new Error("Failed to create temporary scheduled task session");
31
+ }
32
+ sessionId = session.id;
33
+ const promptOptions = {
34
+ sessionID: session.id,
35
+ directory: session.directory,
36
+ parts: [{ type: "text", text: task.prompt }],
37
+ agent: SCHEDULED_TASK_AGENT,
38
+ };
39
+ if (task.model.providerID && task.model.modelID) {
40
+ promptOptions.model = {
41
+ providerID: task.model.providerID,
42
+ modelID: task.model.modelID,
43
+ };
44
+ }
45
+ if (task.model.variant) {
46
+ promptOptions.variant = task.model.variant;
47
+ }
48
+ const { data: response, error: promptError } = await opencodeClient.session.prompt(promptOptions);
49
+ if (promptError || !response) {
50
+ throw promptError || new Error("Scheduled task prompt execution failed");
51
+ }
52
+ const resultText = collectResponseText(response.parts);
53
+ if (!resultText) {
54
+ throw new Error("Scheduled task returned an empty assistant response");
55
+ }
56
+ return {
57
+ taskId: task.id,
58
+ status: "success",
59
+ startedAt,
60
+ finishedAt: new Date().toISOString(),
61
+ resultText,
62
+ errorMessage: null,
63
+ };
64
+ }
65
+ catch (error) {
66
+ const errorMessage = toErrorMessage(error);
67
+ logger.warn(`[ScheduledTaskExecutor] Task execution failed: id=${task.id}, message=${errorMessage}`);
68
+ return {
69
+ taskId: task.id,
70
+ status: "error",
71
+ startedAt,
72
+ finishedAt: new Date().toISOString(),
73
+ resultText: null,
74
+ errorMessage,
75
+ };
76
+ }
77
+ finally {
78
+ if (sessionId) {
79
+ try {
80
+ await opencodeClient.session.delete({ sessionID: sessionId });
81
+ }
82
+ catch (error) {
83
+ logger.warn(`[ScheduledTaskExecutor] Failed to delete temporary session: sessionId=${sessionId}`, error);
84
+ }
85
+ }
86
+ }
87
+ }
@@ -0,0 +1,32 @@
1
+ import { logger } from "../utils/logger.js";
2
+ class ForegroundSessionState {
3
+ activeSessionIds = new Set();
4
+ markBusy(sessionId) {
5
+ if (!sessionId) {
6
+ return;
7
+ }
8
+ this.activeSessionIds.add(sessionId);
9
+ logger.debug(`[ScheduledTaskForeground] Marked session busy: session=${sessionId}, count=${this.activeSessionIds.size}`);
10
+ }
11
+ markIdle(sessionId) {
12
+ if (!sessionId) {
13
+ return;
14
+ }
15
+ this.activeSessionIds.delete(sessionId);
16
+ logger.debug(`[ScheduledTaskForeground] Marked session idle: session=${sessionId}, count=${this.activeSessionIds.size}`);
17
+ }
18
+ isBusy() {
19
+ return this.activeSessionIds.size > 0;
20
+ }
21
+ clearAll(reason) {
22
+ if (this.activeSessionIds.size === 0) {
23
+ return;
24
+ }
25
+ logger.info(`[ScheduledTaskForeground] Cleared foreground busy state: reason=${reason}, count=${this.activeSessionIds.size}`);
26
+ this.activeSessionIds.clear();
27
+ }
28
+ __resetForTests() {
29
+ this.activeSessionIds.clear();
30
+ }
31
+ }
32
+ export const foregroundSessionState = new ForegroundSessionState();