@goodandready/dsh-cron 0.2.20 → 0.2.22

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/README.md CHANGED
@@ -97,6 +97,8 @@ Click the clock icon in the DSH sidebar (positioned next to the new-session butt
97
97
  * **1-Click Preset Templates**: scaffold common workflows like *Daily digest*, *Weekly review*, and *Follow-up monitor*.
98
98
  * **Execution History**: open any task card to review previous runs — timestamps, durations, statuses (success / failed / timeout / skipped / missed), outputs, and errors.
99
99
  * **Aggregated Stats Bar**: live dashboard with active task count, total runs, total token consumption, and the estimated dollar spend.
100
+ * **Quick Schedule Presets**: quickly select standard cadence presets (`15m`, `1h`, `Daily 09:00`, `Weekdays`, `Weekly Mon`) directly within the modal task schedule editor with instant natural-language preview.
101
+ * **Auto-Pause & Burn Guard Badges**: task cards prominently display alert badges when a task is paused by the Token & Cost Burn Guard with the specific reason indicated.
100
102
 
101
103
  ### 2. "Create with DSH" Dialog
102
104
  Transform natural language into a scheduled job without guessing cron syntax:
@@ -159,8 +161,9 @@ Every task picks its own runtime; non-LLM runtimes need no model and consume no
159
161
  * **Environment variables** — a per-task `env` map (KEY VALUE per line in the UI) applied to external runtimes; secrets do not belong here.
160
162
  * **Workspaces and worktrees** — bind a task to a harness workspace (`workspaceId`) and, for code-modifying agent tasks, run it in an isolated git worktree (`worktree`, `keepWorktree`).
161
163
 
162
- ### 7. Cost Control: Fallback Model
163
- A task can run on the cheap model by default and still finish on the strong one: set `fallbackModel` (and optionally `fallbackProvider`) and a failed run — `error` or `timeout` — is retried **once** on that model before the ordinary retry backoff applies. History records which model produced the result and whether the fallback was used, usage and cost of both attempts are summed, and the `{model}` template variable renders the model that finished the run. Only agent-mediated tasks (`llm`, `skill`, `workflow`) can use a fallback.
164
+ ### 7. Cost Control: Fallback Model & Burn Guard
165
+ * **Fallback Model** — A task can run on the cheap model by default and still finish on the strong one: set `fallbackModel` (and optionally `fallbackProvider`) and a failed run — `error` or `timeout` — is retried **once** on that model before the ordinary retry backoff applies. History records which model produced the result and whether the fallback was used, usage and cost of both attempts are summed, and the `{model}` template variable renders the model that finished the run. Only agent-mediated tasks (`llm`, `skill`, `workflow`) can use a fallback.
166
+ * **Token & Cost Burn Guard** — Prevent runaway spending by configuring per-task limits: `costLimitUsd` (lifetime spend limit in USD), `dailyCostLimitUsd` (rolling 24-hour spend limit in USD), and `tokenLimit` (lifetime token limit). If a task exceeds any threshold, execution is halted, the task is automatically paused with `pausedReason` (`cost_limit_exceeded`, `daily_cost_limit_exceeded`, or `token_limit_exceeded`), and an alert notification is dispatched across all active channels.
164
167
 
165
168
  ### 8. Session Integration & Permissions
166
169
  * **Per-task permission presets** — `default`, `read-only`, `workspace-write`, or `full` are applied to the task's agent session before the prompt runs.
@@ -188,6 +191,14 @@ A finished run is delivered to every channel configured for the task — Telegra
188
191
  * **Voice** — `dsh-tts` speaks the report through its HTTP route (`ttsBaseUrl`, default `http://127.0.0.1:3080`).
189
192
  * **Gitea** — opens an issue with the run report (`giteaBaseUrl`, `giteaRepo`, token credential); failures are labelled `cron`, `bug`, `alert`.
190
193
  * **Test dispatch button** — verify Telegram connectivity on the spot before scheduling critical jobs.
194
+ * **Interactive Telegram Bot Commands** — Remotely manage and monitor tasks via Telegram webhook (`/dsh-cron/api/telegram-webhook`):
195
+ * `/status` — general scheduler health, uptime, active/paused task counts.
196
+ * `/tasks` — list configured tasks with schedule and state.
197
+ * `/run <id>` — trigger immediate out-of-order execution of a task.
198
+ * `/pause <id>` and `/resume <id>` — pause or resume a task schedule.
199
+ * `/log <id>` — view the most recent run output and execution details.
200
+ * `/help` — display available bot commands.
201
+ Configured via incoming webhook and restricted to chat/user IDs specified in `telegramAllowedChatIds`.
191
202
 
192
203
  ### 12. Kanban Integration & Cost Meter
193
204
  * **Automatic Kanban cards** — with `kanbanMode` set to `on_failure` or `always`, the plugin creates cards in `dsh-kanban` (`on_failure` → *Backlog* on `error`/`timeout`; `always` → *Done*/*Backlog* on completion).
@@ -337,7 +348,7 @@ Developer-facing, no behaviour change. `parseScheduleExpression` was split into
337
348
 
338
349
  ### 22. Automation, Task Chaining & Observability Pack (Added in v0.2.10, #137)
339
350
  - **Two-Way Telegram Interactive Controls**: Run completion notifications include inline keyboard buttons (`🚀 Run Now`, `⏸️ Pause` / `▶️ Resume`, `📋 Last Output`). Actions are securely routed via `POST /dsh-cron/telegram/webhook` with Chat ID authorization matching plugin settings or harness defaults.
340
- - **Task Chaining & Pipelines**: Tasks can declare `onSuccess` and `onFailure` downstream task triggers. Upstream output is automatically forwarded to child tasks via `$DSH_PREV_OUTPUT` environment variable for shell/script tasks and `{{prevOutput}}` variable interpolation in LLM prompts. Infinite execution loops are strictly prevented with a recursion depth limit (max 5 consecutive executions).
351
+ - **Task Chaining Context & Dynamic Variables**: Tasks can declare `onSuccess` and `onFailure` downstream task triggers. Upstream output and execution metadata are forwarded to child tasks via `$DSH_PREV_OUTPUT`, `$DSH_PREV_TASK_ID`, and `$DSH_PREV_STATUS` environment variables for shell tasks, and `{{prev.output}}` (or `{{prevOutput}}`), `{{prev.taskId}}`, and `{{prev.status}}` variable interpolation in LLM prompts. Prompts also support dynamic runtime interpolation for `{{date}}`, `{{time}}`, `{{datetime}}`, `{{timestamp}}`, `{{year}}`, `{{month}}`, `{{day}}`, `{{taskId}}`, `{{taskName}}`, and `{{runCount}}`. Recursion depth is strictly bounded to prevent loops.
341
352
  - **Structured LLM Actions**: Autonomous model runs can output structured JSON directives to trigger secondary tasks, dispatch channel notifications, or open issues. Controlled via `llmActionsEnabled: false` settings toggle (strictly disabled by default).
342
353
  - **History Archival & Latency Insights**: Active task store retains the most recent 100 runs for instant performance, while older runs are archived in `tasks_archive.json`. New REST endpoints `GET /dsh-cron/tasks/:id/archive` and `GET /dsh-cron/tasks/:id/stats` expose historical records and aggregated latency statistics. Task UI displays execution duration latency badges with color thresholds (<5s green, <30s yellow, >=30s red).
343
354
  - **Enriched Prometheus Observability**: The `/dsh-cron/metrics` endpoint exports the active concurrency gauge `dsh_cron_concurrent_running`, per-task prompt/completion token consumption counters `dsh_cron_task_tokens_total{task,model,type}`, and per-task cost estimation counters `dsh_cron_task_cost_usd_total{task,model}`.
@@ -493,6 +504,7 @@ dsh-cron:
493
504
  | `pushplusUrl` / `pushplusTokenRef` | `string` | `"https://www.pushplus.plus/send"` / `""` | PushPlus endpoint (override for a self-hosted proxy) and token credential name |
494
505
  | `ttsBaseUrl` | `string` | `"http://127.0.0.1:3080"` | Base URL of the `dsh-tts` plugin used for voice announcements |
495
506
  | `giteaBaseUrl` / `giteaRepo` / `giteaTokenRef` | `string` | `""` | Gitea channel: base URL, `owner/repo`, and the credential name of the API token |
507
+ | `telegramAllowedChatIds` | `string` | `""` | Comma-separated list of Telegram chat or user IDs authorized to execute interactive bot commands |
496
508
  | `apiToken` | `string` | `""` | Bearer token for the external `/dsh-cron/api/*` surface. Stored as a secret field and returned masked; empty disables the surface (503), a wrong value answers 401 |
497
509
 
498
510
  Notes:
package/README.ru.md CHANGED
@@ -96,6 +96,8 @@ graph TD
96
96
  * **Мгновенные действия**: немедленный запуск (**Запустить**), пауза/возобновление расписания, удаление с подтверждением.
97
97
  * **Готовые шаблоны в один клик**: *Ежедневная сводка*, *Еженедельный обзор*, *Мониторинг дальнейших действий*.
98
98
  * **История запусков**: в карточке задачи — время, длительность и статусы предыдущих запусков (успех / сбой / таймаут / пропуск / пропущен по простою), вывод и ошибки.
99
+ * **Быстрые пресеты расписания**: выбор типовых интервалов в 1 клик (`15m`, `1h`, `Daily 09:00`, `Weekdays`, `Weekly Mon`) прямо в редакторе расписания с мгновенным обновлением человекопонятного описания.
100
+ * **Бейджи автопаузы и защиты бюджета**: карточки задач наглядно отображают предупреждающий бейдж с точной причиной при автоматической приостановке защитой расходов (Burn Guard) или ручной паузе.
99
101
  * **Сводная статистика**: активные задачи, всего запусков, израсходованные токены и оценочная стоимость в долларах.
100
102
 
101
103
  ### 2. Диалог «Создать с DSH»
@@ -158,8 +160,9 @@ cron_create_task({
158
160
  * **Переменные окружения** — карта `env` на задачу (в UI — строки KEY VALUE) для внешних рантаймов; секретам здесь не место.
159
161
  * **Workspace и worktree** — привязка задачи к workspace харнесса (`workspaceId`) и, для изменяющих код агентских задач, запуск в изолированном git worktree (`worktree`, `keepWorktree`).
160
162
 
161
- ### 7. Экономия: fallback-модель
162
- Задача может идти на дешёвой модели по умолчанию и всё же завершиться на сильной: задайте `fallbackModel` (и при необходимости `fallbackProvider`), и сбойный запуск (`error` или `timeout`) один раз повторится на этой модели, прежде чем включится обычный retry с задержкой. В истории видно, какая модель произвела результат и был ли использован fallback; расход и стоимость обеих попыток суммируются; переменная шаблона `{model}` подставляет модель, завершившую запуск. Fallback доступен только агентским типам (`llm`, `skill`, `workflow`).
163
+ ### 7. Экономия: fallback-модель и защита бюджета (Burn Guard)
164
+ * **Fallback-модель** — задача может идти на дешёвой модели по умолчанию и всё же завершиться на сильной: задайте `fallbackModel` (и при необходимости `fallbackProvider`), и сбойный запуск (`error` или `timeout`) один раз повторится на этой модели, прежде чем включится обычный retry с задержкой. В истории видно, какая модель произвела результат и был ли использован fallback; расход и стоимость обеих попыток суммируются; переменная шаблона `{model}` подставляет модель, завершившую запуск. Fallback доступен только агентским типам (`llm`, `skill`, `workflow`).
165
+ * **Защита бюджета токенов и расходов (Burn Guard)** — предотвращение неконтролируемых трат через индивидуальные лимиты задачи: `costLimitUsd` (общий лимит расходов в USD), `dailyCostLimitUsd` (суточный лимит за последние 24 часа в USD) и `tokenLimit` (лимит суммарных токенов). При превышении любого порога выполнение прекращается, задача автоматически переводится в паузу с фиксацией `pausedReason` (`cost_limit_exceeded`, `daily_cost_limit_exceeded`, `token_limit_exceeded`), а во все активные каналы отправляется тревожное оповещение.
163
166
 
164
167
  ### 8. Интеграция сессий и права
165
168
  * **Permission-пресеты на задачу** — `default`, `read-only`, `workspace-write` или `full` применяются к сессии агента перед запуском промпта.
@@ -213,7 +216,7 @@ cron_create_task({
213
216
 
214
217
  ### 22. Автоматизация, цепочки задач и наблюдаемость (v0.2.10, #137)
215
218
  - **Двухсторонний интерактивный Telegram**: Кнопки действий под уведомлениями (`🚀 Run Now`, `⏸️ Pause`, `📋 Last Output`), вебхук `POST /dsh-cron/telegram/webhook` с валидацией прав по Chat ID и откликом `answerCallbackQuery`.
216
- - **Цепочки задач и конвейеры**: Триггеры `onSuccess` и `onFailure` для связывания задач. Передача вывода родительской задачи в переменную `$DSH_PREV_OUTPUT` (для shell) и `{{prevOutput}}` (для LLM). Ограничение глубины (максимум 5 уровней) против зацикливания.
219
+ - **Контекст цепочек задач и динамические переменные**: Триггеры `onSuccess` и `onFailure` для связывания задач. Результаты работы и метаданные родительской задачи передаются дочерним через переменные окружения `$DSH_PREV_OUTPUT`, `$DSH_PREV_TASK_ID`, `$DSH_PREV_STATUS` для shell/script задач и через подстановку `{{prev.output}}` (или `{{prevOutput}}`), `{{prev.taskId}}`, `{{prev.status}}` в LLM-промптах. Кроме того, в промптах поддерживаются динамические runtime-переменные: `{{date}}`, `{{time}}`, `{{datetime}}`, `{{timestamp}}`, `{{year}}`, `{{month}}`, `{{day}}`, `{{taskId}}`, `{{taskName}}` и `{{runCount}}`. Глубина рекурсии строго ограничена для защиты от зацикливания.
217
220
  - **Структурированные действия LLM**: Парсер директив модели (`trigger_task`, `notify`, `create_issue`) под опцией `llmActionsEnabled: false`.
218
221
  - **Архивация и задержка в UI**: REST API `/dsh-cron/tasks/:id/archive` с пагинацией и статистика `/stats`. Бейджи латентности на карточках задач (<5с зелёный, <30с жёлтый, ≥30с красный).
219
222
  - **Расширенные Prometheus-метрики**: Gauge `dsh_cron_concurrent_running`, счетчики токенов и стоимости в USD на задачу.
@@ -490,6 +493,7 @@ bash deploy.sh verify [exact-version]
490
493
  | `pushplusUrl` / `pushplusTokenRef` | `string` | `"https://www.pushplus.plus/send"` / `""` | Endpoint PushPlus (переопределяется для self-hosted прокси) и имя credential токена |
491
494
  | `ttsBaseUrl` | `string` | `"http://127.0.0.1:3080"` | Базовый URL плагина `dsh-tts` для голосовых объявлений |
492
495
  | `giteaBaseUrl` / `giteaRepo` / `giteaTokenRef` | `string` | `""` | Канал Gitea: базовый URL, `owner/repo` и имя credential API-токена |
496
+ | `telegramAllowedChatIds` | `string` | `""` | Список chat ID или user ID Telegram через запятую, авторизованных для интерактивных команд бота |
493
497
  | `apiToken` | `string` | `""` | Bearer-токен внешней поверхности `/dsh-cron/api/*`. Секретное поле, отдаётся замаскированным; пусто отключает поверхность (503), неверное значение — 401 |
494
498
 
495
499
  Примечания:
package/README.zh.md CHANGED
@@ -96,6 +96,8 @@ graph TD
96
96
  * **即时操作**:立即运行(**Run Now**)、暂停/恢复调度、带确认的删除。
97
97
  * **一键预设模板**:*每日摘要*、*每周回顾*、*待办监控*。
98
98
  * **运行历史**:打开任务卡片查看历史运行 —— 时间、耗时、状态(成功 / 失败 / 超时 / 跳过 / 错过)、输出与错误。
99
+ * **快捷计划预设**:在任务编辑弹窗中通过预设按钮一键填入常用频率(`15m`、`1h`、`Daily 09:00`、`Weekdays`、`Weekly Mon`),并即时更新自然语言预览。
100
+ * **自动暂停与预算保护徽章**:当任务因预算熔断机制(Burn Guard)自动暂停或手动暂停时,任务卡片上显著展示包含具体原因的状态徽章。
99
101
  * **汇总统计栏**:活跃任务数、总运行次数、总 token 消耗与估算美元成本。
100
102
 
101
103
  ### 2. “由 DSH 创建”对话框
@@ -158,8 +160,9 @@ cron_create_task({
158
160
  * **环境变量** —— 按任务的 `env` 映射(界面中每行 KEY VALUE)应用于外部运行时;请勿在此存放密钥。
159
161
  * **工作区与 worktree** —— 将任务绑定到 Harness 工作区(`workspaceId`);对会修改代码的智能体任务,可在隔离的 git worktree 中运行(`worktree`、`keepWorktree`)。
160
162
 
161
- ### 7. 成本控制:回退模型
162
- 任务可以默认使用便宜模型,失败时改用更强模型完成:设置 `fallbackModel`(可选 `fallbackProvider`),失败(`error` 或 `timeout`)的运行会在该模型上重试一次,之后才进入常规重试退避。历史记录会标明最终产出结果的模型以及是否使用了回退,两次尝试的用量与成本都会累计,模板变量 `{model}` 渲染完成运行的模型。回退仅适用于智能体类型(`llm`、`skill`、`workflow`)。
163
+ ### 7. 成本控制:回退模型与支出保护(Burn Guard)
164
+ * **回退模型** —— 任务可以默认使用便宜模型,失败时改用更强模型完成:设置 `fallbackModel`(可选 `fallbackProvider`),失败(`error` 或 `timeout`)的运行会在该模型上重试一次,之后才进入常规重试退避。历史记录会标明最终产出结果的模型以及是否使用了回退,两次尝试的用量与成本都会累计,模板变量 `{model}` 渲染完成运行的模型。回退仅适用于智能体类型(`llm`、`skill`、`workflow`)。
165
+ * **Token 与成本支出保护(Burn Guard)** —— 为任务配置严格预算上限:`costLimitUsd`(总支出美元上限)、`dailyCostLimitUsd`(24小时滚动支出上限)和 `tokenLimit`(Token总数上限)。一旦达到任一阈值,任务将自动暂停并记录 `pausedReason`(`cost_limit_exceeded`、`daily_cost_limit_exceeded` 或 `token_limit_exceeded`),同时向所有配置的通知渠道发送报警通知。
163
166
 
164
167
  ### 8. 会话集成与权限
165
168
  * **按任务的权限预设** —— `default`、`read-only`、`workspace-write` 或 `full` 在提示词执行前应用于任务会话。
@@ -336,7 +339,7 @@ bash deploy.sh verify [exact-version]
336
339
 
337
340
  ### 22. 自动化、任务链与可观测性包(v0.2.10,#137)
338
341
  - **Telegram 双向交互控制**:任务通知附带内嵌操作按钮(`🚀 立即运行`、`⏸️ 暂停/恢复`、`📋 最新日志`)。由 `POST /dsh-cron/telegram/webhook` 处理,严格鉴权 Chat ID 并调用 `answerCallbackQuery` 反馈。
339
- - **任务管道与级联触发**:配置 `onSuccess` 与 `onFailure` 下游触发器。上游输出自动注入子任务环境变量 `$DSH_PREV_OUTPUT`,LLM 任务支持 `{{prevOutput}}` 插值。内置最大 5 级深度递归防护,杜绝死循环。
342
+ - **任务链上下文与动态变量插值**:配置 `onSuccess` 与 `onFailure` 下游触发器。父任务的执行结果与元数据自动传递给子任务,在 Shell 任务中提供 `$DSH_PREV_OUTPUT`、`$DSH_PREV_TASK_ID`、`$DSH_PREV_STATUS` 环境变量,在 LLM Prompt 中支持 `{{prev.output}}`(或 `{{prevOutput}}`)、`{{prev.taskId}}`、`{{prev.status}}` 占位符插值。Prompt 额外支持动态运行时时间与元数据变量:`{{date}}`、`{{time}}`、`{{datetime}}`、`{{timestamp}}`、`{{year}}`、`{{month}}`、`{{day}}`、`{{taskId}}`、`{{taskName}}`、`{{runCount}}`。内置最大 5 级深度递归防护,杜绝死循环。
340
343
  - **模型结构化动作指令**:自主分析任务可输出 JSON 指令触发级联任务(`trigger_task`)、定向告警(`notify`)或创建 Issue。受 `llmActionsEnabled: false` 严格保护。
341
344
  - **历史归档与延迟洞察**:REST 接口 `GET /dsh-cron/tasks/:id/archive`(支持分页)与 `GET /dsh-cron/tasks/:id/stats`;UI 任务卡片展示耗时彩色徽章(<5s 绿,<30s 黄,≥30s 红)。
342
345
  - **Prometheus 监控增强**:`/dsh-cron/metrics` 导出当前活动并发量 `dsh_cron_concurrent_running`、各任务 Token 计数器及成本预估指标。
@@ -490,6 +493,7 @@ dsh-cron:
490
493
  | `pushplusUrl` / `pushplusTokenRef` | `string` | `"https://www.pushplus.plus/send"` / `""` | PushPlus 端点(可指向自建代理)与 token 凭据名称 |
491
494
  | `ttsBaseUrl` | `string` | `"http://127.0.0.1:3080"` | 用于语音播报的 `dsh-tts` 基础地址 |
492
495
  | `giteaBaseUrl` / `giteaRepo` / `giteaTokenRef` | `string` | `""` | Gitea 渠道:基础地址、`owner/repo` 与 API token 的凭据名称 |
496
+ | `telegramAllowedChatIds` | `string` | `""` | 允许执行交互式机器人命令的 Telegram Chat ID 或 User ID(英文逗号分隔) |
493
497
  | `apiToken` | `string` | `""` | 外部 `/dsh-cron/api/*` 接口的 Bearer 令牌。保密字段,返回时掩码;为空时接口返回 503,错误值返回 401 |
494
498
 
495
499
  说明:
@@ -1,5 +1,7 @@
1
1
  import { sendJson, readBody } from './http-utils.js';
2
2
  import { answerTelegramCallbackQuery, sendTelegramMessage, getDshDefaultTelegramCredentials } from './telegram.js';
3
+ import { handleTelegramCommand } from './telegram-commands.js';
4
+ import { bestEffort } from './best-effort.js';
3
5
 
4
6
  const NOT_ALLOWED = { ok: false, error: 'Method not allowed' };
5
7
 
@@ -11,9 +13,36 @@ export async function handleTelegramWebhook({ store, scheduler, req, res }) {
11
13
  const { body, error } = await readBody(req, res);
12
14
  if (error || !body) return;
13
15
 
16
+ const settings = typeof store.getSettings === 'function' ? store.getSettings() : {};
17
+ const defaults = getDshDefaultTelegramCredentials();
18
+ const botToken = settings.botToken || defaults.botToken;
19
+ const allowedChatId = String(settings.chatId || defaults.chatId || '').trim();
20
+
21
+ // 1. Text command message (#175)
22
+ const message = body.message;
23
+ if (message && typeof message.text === 'string' && message.text.trim().startsWith('/')) {
24
+ const fromId = String(message.from?.id || '');
25
+ const chatId = String(message.chat?.id || fromId);
26
+ if (allowedChatId && chatId !== allowedChatId && fromId !== allowedChatId) {
27
+ console.warn(`[dsh-cron] unauthorized telegram command from chat ${chatId} / user ${fromId}`);
28
+ sendJson(res, 403, { ok: false, error: 'Forbidden' });
29
+ return;
30
+ }
31
+ const result = await handleTelegramCommand({
32
+ store,
33
+ scheduler,
34
+ text: message.text,
35
+ chatId,
36
+ botToken,
37
+ });
38
+ sendJson(res, 200, result || { ok: true });
39
+ return;
40
+ }
41
+
42
+ // 2. Inline callback query (#137)
14
43
  const callbackQuery = body.callback_query;
15
44
  if (!callbackQuery) {
16
- sendJson(res, 200, { ok: true });
45
+ sendJson(res, 200, { ok: true, ignored: true });
17
46
  return;
18
47
  }
19
48
 
@@ -22,11 +51,6 @@ export async function handleTelegramWebhook({ store, scheduler, req, res }) {
22
51
  const fromId = String(callbackQuery.from?.id || '');
23
52
  const chatId = String(callbackQuery.message?.chat?.id || fromId);
24
53
 
25
- const settings = typeof store.getSettings === 'function' ? store.getSettings() : {};
26
- const defaults = getDshDefaultTelegramCredentials();
27
- const botToken = settings.botToken || defaults.botToken;
28
- const allowedChatId = String(settings.chatId || defaults.chatId || '').trim();
29
-
30
54
  if (allowedChatId && chatId !== allowedChatId && fromId !== allowedChatId) {
31
55
  console.warn(`[dsh-cron] unauthorized telegram callback from chat ${chatId} / user ${fromId}`);
32
56
  if (botToken) {
@@ -70,19 +94,13 @@ export async function handleTelegramWebhook({ store, scheduler, req, res }) {
70
94
  }
71
95
  } else if (verb === 'pause') {
72
96
  const isPaused = task.status === 'paused';
73
- const newStatus = isPaused ? 'active' : 'paused';
74
- if (typeof store.update === 'function') {
75
- store.update(taskId, { status: newStatus });
76
- } else {
77
- store.set({ ...task, status: newStatus });
78
- }
79
97
  if (isPaused) {
80
- scheduler.resume(taskId);
98
+ scheduler.resumeTask(taskId);
81
99
  if (botToken) {
82
100
  await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: `▶️ Resumed: ${task.title}` });
83
101
  }
84
102
  } else {
85
- scheduler.pause(taskId);
103
+ scheduler.pauseTask(taskId, 'Paused via Telegram button');
86
104
  if (botToken) {
87
105
  await answerTelegramCallbackQuery({ botToken, callbackQueryId: queryId, text: `⏸ Paused: ${task.title}` });
88
106
  }
@@ -104,7 +122,9 @@ export async function handleTelegramWebhook({ store, scheduler, req, res }) {
104
122
  chatId,
105
123
  text: `*Full Output for ${task.title}:*\n\`\`\`\n${summary.slice(0, 3000)}\n\`\`\``,
106
124
  parseMode: 'Markdown',
107
- }).catch(() => {});
125
+ }).catch(() => {
126
+ bestEffort('telegram-full-output', () => {}, scheduler?.logger);
127
+ });
108
128
  }
109
129
  }
110
130
  }
package/lib/api.js CHANGED
@@ -432,6 +432,10 @@ function mergeExecutionFields(current, body, parsed) {
432
432
  skillName: body.skillName !== undefined ? String(body.skillName).trim() : (current ? current.skillName : ''),
433
433
  workflowName: body.workflowName !== undefined ? String(body.workflowName).trim() : (current ? current.workflowName : ''),
434
434
  oneShot: Boolean((parsed && parsed.isOneShot) || (body && body.oneShot)),
435
+ costLimitUsd: body.costLimitUsd !== undefined ? (body.costLimitUsd === null ? null : (Number(body.costLimitUsd) > 0 ? Number(body.costLimitUsd) : null)) : (current ? current.costLimitUsd : undefined),
436
+ dailyCostLimitUsd: body.dailyCostLimitUsd !== undefined ? (body.dailyCostLimitUsd === null ? null : (Number(body.dailyCostLimitUsd) > 0 ? Number(body.dailyCostLimitUsd) : null)) : (current ? current.dailyCostLimitUsd : undefined),
437
+ tokenLimit: body.tokenLimit !== undefined ? (body.tokenLimit === null ? null : (Number(body.tokenLimit) > 0 ? Number(body.tokenLimit) : null)) : (current ? current.tokenLimit : undefined),
438
+ pausedReason: body.pausedReason !== undefined ? (body.pausedReason ? String(body.pausedReason) : null) : (current ? current.pausedReason : undefined),
435
439
  };
436
440
  }
437
441
 
@@ -0,0 +1,153 @@
1
+ import { bestEffort } from './best-effort.js';
2
+
3
+ /**
4
+ * Token & Cost Burn Guard (#173).
5
+ * Monitors task execution cost and token usage to prevent runaway costs
6
+ * by automatically pausing the offending task and sending an alert.
7
+ */
8
+
9
+ /**
10
+ * Calculate the rolling sum of run costs within the given time window (default 24h).
11
+ * @param {Array} runs
12
+ * @param {number} windowMs
13
+ * @param {number} now
14
+ * @returns {number}
15
+ */
16
+ export function calculateRollingCost(runs = [], windowMs = 86400000, now = Date.now()) {
17
+ const cutoff = now - windowMs;
18
+ let total = 0;
19
+ for (const r of runs) {
20
+ if (r && typeof r.at === 'number' && r.at >= cutoff) {
21
+ total += Number(r.costUsd) || 0;
22
+ }
23
+ }
24
+ return Number(total.toFixed(6));
25
+ }
26
+
27
+ /**
28
+ * Calculate the rolling sum of tokens within the given time window.
29
+ * @param {Array} runs
30
+ * @param {number} windowMs
31
+ * @param {number} now
32
+ * @returns {number}
33
+ */
34
+ export function calculateRollingTokens(runs = [], windowMs = 86400000, now = Date.now()) {
35
+ const cutoff = now - windowMs;
36
+ let total = 0;
37
+ for (const r of runs) {
38
+ if (r && typeof r.at === 'number' && r.at >= cutoff) {
39
+ const u = r.usage;
40
+ const tokens = (u?.inputTokens || 0) + (u?.outputTokens || 0) + (u?.cacheReadTokens || 0);
41
+ total += tokens;
42
+ }
43
+ }
44
+ return total;
45
+ }
46
+
47
+ /**
48
+ * Check if a task has exceeded any of its configured budget limits.
49
+ * @param {object} task
50
+ * @param {Array} runs
51
+ * @param {number} now
52
+ * @returns {{ exceeded: boolean, type?: string, reason?: string, current?: number, limit?: number }}
53
+ */
54
+ export function checkTaskBudgetLimits(task, runs = [], now = Date.now()) {
55
+ if (!task) return { exceeded: false };
56
+
57
+ // 1. Total cumulative cost limit in USD
58
+ const costLimit = Number(task.costLimitUsd);
59
+ if (costLimit > 0) {
60
+ const totalCost = Number(task.totalCostUsd || 0);
61
+ if (totalCost >= costLimit) {
62
+ return {
63
+ exceeded: true,
64
+ type: 'costLimitUsd',
65
+ reason: `Cumulative cost limit exceeded: $${totalCost.toFixed(4)} >= $${costLimit.toFixed(4)}`,
66
+ current: totalCost,
67
+ limit: costLimit,
68
+ };
69
+ }
70
+ }
71
+
72
+ // 2. Rolling 24h daily cost limit in USD
73
+ const dailyCostLimit = Number(task.dailyCostLimitUsd);
74
+ if (dailyCostLimit > 0) {
75
+ const dailyCost = calculateRollingCost(runs, 86400000, now);
76
+ if (dailyCost >= dailyCostLimit) {
77
+ return {
78
+ exceeded: true,
79
+ type: 'dailyCostLimitUsd',
80
+ reason: `24h daily cost limit exceeded: $${dailyCost.toFixed(4)} >= $${dailyCostLimit.toFixed(4)}`,
81
+ current: dailyCost,
82
+ limit: dailyCostLimit,
83
+ };
84
+ }
85
+ }
86
+
87
+ // 3. Total cumulative token limit
88
+ const tokenLimit = Number(task.tokenLimit);
89
+ if (tokenLimit > 0) {
90
+ const totalTokens = Number(task.totalTokens || 0);
91
+ if (totalTokens >= tokenLimit) {
92
+ return {
93
+ exceeded: true,
94
+ type: 'tokenLimit',
95
+ reason: `Token limit exceeded: ${totalTokens} >= ${tokenLimit}`,
96
+ current: totalTokens,
97
+ limit: tokenLimit,
98
+ };
99
+ }
100
+ }
101
+
102
+ return { exceeded: false };
103
+ }
104
+
105
+ /**
106
+ * Format a human-readable alert message for Telegram and notification channels.
107
+ * @param {object} task
108
+ * @param {object} guard
109
+ * @returns {string}
110
+ */
111
+ export function formatBurnGuardAlert(task, guard) {
112
+ const title = task?.title || task?.id || 'Unknown task';
113
+ return `⚠️ [dsh-cron] Task "${title}" auto-paused by Burn Guard: ${guard.reason}`;
114
+ }
115
+
116
+ /**
117
+ * Check task budget limits after a run completes. If exceeded, pause the task and alert.
118
+ * @param {object} scheduler
119
+ * @param {string} taskId
120
+ * @param {object} outcome
121
+ * @returns {Promise<object|null>}
122
+ */
123
+ export async function checkAndApplyBurnGuard(scheduler, taskId, outcome) {
124
+ const task = scheduler.store.get(taskId);
125
+ if (!task || task.status !== 'active') return null;
126
+
127
+ const runs = (scheduler.store.history && scheduler.store.history.get(taskId)) || [];
128
+ const guard = checkTaskBudgetLimits(task, runs);
129
+ if (!guard.exceeded) return null;
130
+
131
+ console.warn(`[dsh-cron] Burn Guard triggered for task "${task.title}" (${taskId}): ${guard.reason}`);
132
+ scheduler.pauseTask(taskId, guard.reason);
133
+
134
+ const alertMessage = formatBurnGuardAlert(task, guard);
135
+ try {
136
+ if (typeof scheduler.deliverNotifications === 'function') {
137
+ const alertRunInfo = {
138
+ output: alertMessage,
139
+ error: guard.reason,
140
+ status: 'error',
141
+ costUsd: outcome?.costUsd || 0,
142
+ durationMs: outcome?.durationMs || 0,
143
+ model: task.model || '',
144
+ };
145
+ await scheduler.deliverNotifications({ ...task, status: 'paused', pausedReason: guard.reason }, alertRunInfo);
146
+ }
147
+ } catch (err) {
148
+ bestEffort('burn-guard-notify', () => {}, scheduler.logger);
149
+ }
150
+
151
+ return guard;
152
+ }
153
+
package/lib/client.js CHANGED
@@ -2267,7 +2267,8 @@ window.__ModuleLoader__.load({
2267
2267
  task.targetSessionId ? React.createElement('span', { className: 'dsh-cron-type-tag dsh-cron-tag-session', title: 'Target session: ' + task.targetSessionId + (task.targetSessionReset && task.targetSessionReset !== 'never' ? ' (' + task.targetSessionReset + ')' : '') }, '🧵 ' + task.targetSessionId) : null,
2268
2268
  task.preflightType && task.preflightType !== 'none' ? React.createElement('span', { className: 'dsh-cron-type-tag dsh-cron-tag-preflight', title: 'Preflight: ' + task.preflightType }, '🛡️ ' + task.preflightType) : null,
2269
2269
  task.totalCostUsd > 0 ? React.createElement('span', { className: 'dsh-cron-cost-tag' }, '$' + task.totalCostUsd.toFixed(4)) : null,
2270
- task.totalTokens > 0 ? React.createElement('span', { className: 'dsh-cron-tokens-tag' }, (task.totalTokens > 1000 ? Math.round(task.totalTokens / 1000) + 'k' : task.totalTokens) + ' tok') : null
2270
+ task.totalTokens > 0 ? React.createElement('span', { className: 'dsh-cron-tokens-tag' }, (task.totalTokens > 1000 ? Math.round(task.totalTokens / 1000) + 'k' : task.totalTokens) + ' tok') : null,
2271
+ task.status === 'paused' && task.pausedReason ? React.createElement('span', { className: 'dsh-cron-type-tag dsh-cron-tag-failure', title: task.pausedReason }, '🛑 ' + task.pausedReason) : null
2271
2272
  ),
2272
2273
  React.createElement('div', { className: 'dsh-cron-task-sched' },
2273
2274
  task.scheduleText || task.schedule,
@@ -2513,6 +2514,21 @@ window.__ModuleLoader__.load({
2513
2514
  placeholder: T_KEY(t, 'form.schedulePlaceholder'),
2514
2515
  onChange: (e) => setFormSchedule(e.target.value)
2515
2516
  }),
2517
+ React.createElement('div', { style: { display: 'flex', gap: '5px', flexWrap: 'wrap', marginTop: '5px' } },
2518
+ [
2519
+ { label: '15m', expr: '*/15 * * * *' },
2520
+ { label: '1h', expr: '0 * * * *' },
2521
+ { label: 'Daily 09:00', expr: '0 9 * * *' },
2522
+ { label: 'Weekdays', expr: '0 9 * * 1-5' },
2523
+ { label: 'Weekly Mon', expr: '0 9 * * 1' },
2524
+ ].map((p) => React.createElement('button', {
2525
+ key: p.label,
2526
+ type: 'button',
2527
+ className: 'dsh-cron-btn-secondary',
2528
+ style: { padding: '2px 7px', fontSize: '11px', borderRadius: '4px' },
2529
+ onClick: () => { setFormSchedule(p.expr); handlePreviewSchedule(p.expr); }
2530
+ }, p.label))
2531
+ ),
2516
2532
  React.createElement('div', { style: { display: 'flex', gap: '8px', alignItems: 'center', marginTop: '6px' } },
2517
2533
  React.createElement('button', {
2518
2534
  type: 'button',
@@ -3364,7 +3380,7 @@ window.__ModuleLoader__.load({
3364
3380
  const [updateState, setUpdateState] = React.useState({
3365
3381
  checking: false,
3366
3382
  updating: false,
3367
- currentVersion: '0.2.19',
3383
+ currentVersion: '0.2.22',
3368
3384
  latestVersion: undefined,
3369
3385
  updateAvailable: false,
3370
3386
  notice: null,
@@ -4052,8 +4068,23 @@ window.__ModuleLoader__.load({
4052
4068
  function SettingsCardWithBoundary(props) {
4053
4069
  return React.createElement(ErrorBoundary, null, CronSettingsCard(props));
4054
4070
  }
4055
- // Row seat first (the seat the current core renders), legacy settings.plugin.item
4056
- // after it as a fallback (#102: still no top-level section fallback).
4071
+ // List seat (plugins.item): the seat the Plugins page renders as the plugin's own
4072
+ // page with its configuration. The label is a static string on purpose — it is
4073
+ // resolved while the page renders, and a locale lookup there would take the whole
4074
+ // client batch down with it.
4075
+ const itemOk = registerIntoMount(ctx, 'plugins.item', {
4076
+ name: 'plugins.item',
4077
+ id: ROW_ID,
4078
+ order: 60,
4079
+ label: () => 'Cron',
4080
+ locale: NS,
4081
+ inject: () => ({ ctx, toggle }),
4082
+ }, SettingsCardWithBoundary);
4083
+ if (!itemOk) {
4084
+ console.warn('[dsh-cron] plugins.item slot unavailable — plugin page card not registered');
4085
+ }
4086
+ // Row seat and the legacy settings.plugin.item seat stay as fallbacks
4087
+ // (#102: still no top-level section fallback).
4057
4088
  const rowOk = registerIntoMount(ctx, 'plugins.row.config', {
4058
4089
  name: 'plugins.row.config',
4059
4090
  key: ROW_CONFIG_KEY,
@@ -0,0 +1,102 @@
1
+ import { formatDuration } from './templates.js';
2
+
3
+ function formatDate(d) {
4
+ const year = d.getUTCFullYear();
5
+ const month = String(d.getUTCMonth() + 1).padStart(2, '0');
6
+ const day = String(d.getUTCDate()).padStart(2, '0');
7
+ return `${year}-${month}-${day}`;
8
+ }
9
+
10
+ function formatTime(d) {
11
+ const hours = String(d.getUTCHours()).padStart(2, '0');
12
+ const minutes = String(d.getUTCMinutes()).padStart(2, '0');
13
+ const seconds = String(d.getUTCSeconds()).padStart(2, '0');
14
+ return `${hours}:${minutes}:${seconds}`;
15
+ }
16
+
17
+ /**
18
+ * Build dictionary of prompt interpolation variables for a task and run options.
19
+ */
20
+ export function buildPromptContext(task = {}, options = {}) {
21
+ const now = options.now instanceof Date ? options.now : new Date();
22
+ const prevOutput = options.prevOutput != null ? String(options.prevOutput) : '';
23
+ const prevStatus = options.prevStatus != null ? String(options.prevStatus) : '';
24
+ const prevTaskId = options.prevTaskId != null ? String(options.prevTaskId) : '';
25
+ const prevDuration = formatDuration(options.prevDurationMs);
26
+ const prevCost = options.prevCostUsd != null ? `$${Number(options.prevCostUsd || 0).toFixed(4)}` : '';
27
+
28
+ return {
29
+ // Chaining context
30
+ 'prev.output': prevOutput,
31
+ 'prev_output': prevOutput,
32
+ 'prev.status': prevStatus,
33
+ 'prev_status': prevStatus,
34
+ 'prev.taskId': prevTaskId,
35
+ 'prev.task_id': prevTaskId,
36
+ 'prev_task_id': prevTaskId,
37
+ 'prev.duration': prevDuration,
38
+ 'prev_duration': prevDuration,
39
+ 'prev.cost': prevCost,
40
+ 'prev_cost': prevCost,
41
+
42
+ // Time context
43
+ 'date': formatDate(now),
44
+ 'time': formatTime(now),
45
+ 'now': now.toISOString(),
46
+ 'datetime': now.toISOString(),
47
+ 'iso': now.toISOString(),
48
+ 'timestamp': String(now.getTime()),
49
+
50
+ // Task metadata
51
+ 'task.id': task.id || '',
52
+ 'task_id': task.id || '',
53
+ 'task.title': task.title || '',
54
+ 'task_title': task.title || '',
55
+ };
56
+ }
57
+
58
+ /**
59
+ * Replace {{var}} or {var} placeholders with values from context.
60
+ * Supports dot notation like {{prev.output}}. Unknown variables are left untouched.
61
+ */
62
+ export function interpolateTaskPrompt(text, context = {}) {
63
+ if (typeof text !== 'string') return text;
64
+ if (!text) return '';
65
+
66
+ return text.replace(/\{\{([a-zA-Z0-9_.]+)\}\}/g, (match, varName) => {
67
+ if (Object.prototype.hasOwnProperty.call(context, varName)) {
68
+ const val = context[varName];
69
+ return val == null ? '' : String(val);
70
+ }
71
+ return match;
72
+ }).replace(/\{([a-zA-Z0-9_.]+)\}/g, (match, varName) => {
73
+ if (Object.prototype.hasOwnProperty.call(context, varName)) {
74
+ const val = context[varName];
75
+ return val == null ? '' : String(val);
76
+ }
77
+ return match;
78
+ });
79
+ }
80
+
81
+ /**
82
+ * Resolve a task copy with interpolated prompt and executable fields.
83
+ */
84
+ export function resolveTaskForExecution(task, options = {}) {
85
+ if (!task || typeof task !== 'object') return task;
86
+ const context = buildPromptContext(task, options);
87
+
88
+ const resolved = { ...task };
89
+ if (typeof resolved.prompt === 'string') {
90
+ resolved.prompt = interpolateTaskPrompt(resolved.prompt, context);
91
+ }
92
+ if (typeof resolved.script === 'string') {
93
+ resolved.script = interpolateTaskPrompt(resolved.script, context);
94
+ }
95
+ if (typeof resolved.command === 'string') {
96
+ resolved.command = interpolateTaskPrompt(resolved.command, context);
97
+ }
98
+ if (typeof resolved.url === 'string') {
99
+ resolved.url = interpolateTaskPrompt(resolved.url, context);
100
+ }
101
+ return resolved;
102
+ }
@@ -1,3 +1,5 @@
1
+ import { checkAndApplyBurnGuard } from './burn-guard.js';
2
+ import { resolveTaskForExecution } from './prompt-interpolation.js';
1
3
  import { bestEffort } from './best-effort.js';
2
4
  import { exec } from 'node:child_process';
3
5
  import fs from 'node:fs';
@@ -92,8 +94,9 @@ export async function executeOnce(scheduler, task, signal, options = {}) {
92
94
  fallback: false,
93
95
  };
94
96
  if (typeof scheduler.executeFn !== 'function') return result;
97
+ const executableTask = resolveTaskForExecution(task, options);
95
98
  try {
96
- const res = await scheduler.executeFn(task, { signal, ...options });
99
+ const res = await scheduler.executeFn(executableTask, { signal, ...options });
97
100
  if (typeof res === 'object' && res !== null) {
98
101
  result.output = res.output || '';
99
102
  result.usage = res.usage || result.usage;
@@ -431,6 +434,9 @@ export async function handleCompleteRun(scheduler, task, taskId, currentRun, out
431
434
  if (!silent.skipped) await scheduler.deliverNotifications(task, runInfo);
432
435
  scheduler.finishRun(task, taskId, outcome.status, queuedCount);
433
436
 
437
+ const guard = await checkAndApplyBurnGuard(scheduler, taskId, outcome);
438
+ if (guard && guard.exceeded) return;
439
+
434
440
  while (scheduler.queue.length > 0 && scheduler.running.size < scheduler.maxConcurrent) {
435
441
  const nextItem = scheduler.queue.shift();
436
442
  if (!nextItem) break;
@@ -480,6 +486,9 @@ export async function handleCompleteRun(scheduler, task, taskId, currentRun, out
480
486
  chainDepth: currentDepth + 1,
481
487
  prevOutput: outcome.output || outcome.error || '',
482
488
  prevTaskId: task.id,
489
+ prevStatus: outcome.status,
490
+ prevDurationMs: Date.now() - start,
491
+ prevCostUsd: outcome.costUsd || 0,
483
492
  }).catch((err) => {
484
493
  console.warn(`[dsh-cron] chained task ${targetId} failed to trigger:`, err.message);
485
494
  });
@@ -71,11 +71,14 @@ export async function checkHeartbeats(scheduler) {
71
71
  /**
72
72
  * Pause a task and abort any active execution.
73
73
  */
74
- export function pauseTask(scheduler, taskId) {
74
+ export function pauseTask(scheduler, taskId, reason = null) {
75
75
  const task = scheduler.store.get(taskId);
76
76
  if (!task) return null;
77
77
  task.status = 'paused';
78
78
  task.nextRunAt = null;
79
+ if (reason) {
80
+ task.pausedReason = reason;
81
+ }
79
82
  scheduler.clearRetryTimer(taskId);
80
83
  if (scheduler.jobs.has(taskId)) {
81
84
  scheduler.jobs.get(taskId).stop();
@@ -123,6 +126,7 @@ export function resumeTask(scheduler, taskId) {
123
126
  const task = scheduler.store.get(taskId);
124
127
  if (!task) return null;
125
128
  task.status = 'active';
129
+ delete task.pausedReason;
126
130
  scheduler.scheduleTask(task);
127
131
  scheduler.store.set(task);
128
132
  return task;
package/lib/scheduler.js CHANGED
@@ -503,8 +503,8 @@ export class TaskScheduler {
503
503
  return finishRun(this, task, taskId, status, queuedCount);
504
504
  }
505
505
 
506
- pauseTask(taskId) {
507
- return pauseTask(this, taskId);
506
+ pauseTask(taskId, reason) {
507
+ return pauseTask(this, taskId, reason);
508
508
  }
509
509
 
510
510
  removeTask(taskId) {
@@ -49,6 +49,9 @@ export function executeCreateTask(store, scheduler, args) {
49
49
  skillName: args.skillName ? String(args.skillName).trim() : '',
50
50
  workflowName: args.workflowName ? String(args.workflowName).trim() : '',
51
51
  oneShot: Boolean(parsed.isOneShot),
52
+ costLimitUsd: Number(args.costLimitUsd) > 0 ? Number(args.costLimitUsd) : undefined,
53
+ dailyCostLimitUsd: Number(args.dailyCostLimitUsd) > 0 ? Number(args.dailyCostLimitUsd) : undefined,
54
+ tokenLimit: Number(args.tokenLimit) > 0 ? Number(args.tokenLimit) : undefined,
52
55
  });
53
56
  scheduler.scheduleTask(task);
54
57
  return {
package/lib/task-patch.js CHANGED
@@ -51,6 +51,15 @@ export function buildTaskPatch(current, body) {
51
51
  patch.scheduleText = (body && body.scheduleText) || parsed.humanText;
52
52
  patch.oneShot = Boolean(parsed.isOneShot || (body && body.oneShot));
53
53
  }
54
+ if (patch.costLimitUsd !== undefined) {
55
+ patch.costLimitUsd = patch.costLimitUsd === null ? null : (Number(patch.costLimitUsd) > 0 ? Number(patch.costLimitUsd) : null);
56
+ }
57
+ if (patch.dailyCostLimitUsd !== undefined) {
58
+ patch.dailyCostLimitUsd = patch.dailyCostLimitUsd === null ? null : (Number(patch.dailyCostLimitUsd) > 0 ? Number(patch.dailyCostLimitUsd) : null);
59
+ }
60
+ if (patch.tokenLimit !== undefined) {
61
+ patch.tokenLimit = patch.tokenLimit === null ? null : (Number(patch.tokenLimit) > 0 ? Number(patch.tokenLimit) : null);
62
+ }
54
63
  return { ok: true, patch };
55
64
  }
56
65
 
@@ -70,10 +70,14 @@ export const PATCHABLE_TASK_FIELDS = [
70
70
  'template',
71
71
  'status',
72
72
  'oneShot',
73
+ 'costLimitUsd',
74
+ 'dailyCostLimitUsd',
75
+ 'tokenLimit',
76
+ 'pausedReason',
73
77
  ];
74
78
 
75
79
  /** Fields a duplicate inherits; anything else is runtime state, not config. */
76
- export const DUPLICATE_TASK_FIELDS = PATCHABLE_TASK_FIELDS.filter((key) => key !== 'status');
80
+ export const DUPLICATE_TASK_FIELDS = PATCHABLE_TASK_FIELDS.filter((key) => key !== 'status' && key !== 'pausedReason');
77
81
 
78
82
  /**
79
83
  * Task ids that would shadow the collection routes under /dsh-cron/tasks.
@@ -0,0 +1,277 @@
1
+ import { sendTelegramMessage } from './telegram.js';
2
+ import { calculateRollingCost, calculateRollingTokens } from './burn-guard.js';
3
+ import { bestEffort } from './best-effort.js';
4
+
5
+ /**
6
+ * Parse a text string into a Telegram bot command and its arguments (#175).
7
+ * Strips leading slash and bot username suffix (e.g. /status@bot_name -> status).
8
+ * @param {string} text
9
+ * @returns {{ command: string, args: string[], raw: string } | null}
10
+ */
11
+ export function parseTelegramCommand(text) {
12
+ if (!text || typeof text !== 'string') return null;
13
+ const trimmed = text.trim();
14
+ if (!trimmed.startsWith('/')) return null;
15
+
16
+ const [cmdToken, ...args] = trimmed.split(/\s+/);
17
+ const command = cmdToken.slice(1).split('@')[0].toLowerCase();
18
+ if (!command) return null;
19
+
20
+ return { command, args, raw: trimmed };
21
+ }
22
+
23
+ /**
24
+ * Format status message for /status command.
25
+ */
26
+ export function formatStatusMessage({ store, scheduler }) {
27
+ const tasks = (typeof store?.list === 'function' ? store.list() : []) || [];
28
+ const active = tasks.filter((t) => t.status === 'active').length;
29
+ const paused = tasks.filter((t) => t.status === 'paused').length;
30
+ const running = typeof scheduler?.runningCount === 'function' ? scheduler.runningCount() : 0;
31
+
32
+ const runningNames = [];
33
+ if (scheduler?.running) {
34
+ for (const id of scheduler.running.keys()) {
35
+ const t = store.get(id);
36
+ runningNames.push(t ? t.title : id);
37
+ }
38
+ }
39
+
40
+ let dailyCost = 0;
41
+ let dailyTokens = 0;
42
+ const now = Date.now();
43
+ if (store?.history) {
44
+ for (const [, runs] of store.history.entries()) {
45
+ dailyCost += calculateRollingCost(runs, 86400000, now);
46
+ dailyTokens += calculateRollingTokens(runs, 86400000, now);
47
+ }
48
+ }
49
+
50
+ const stats = typeof store?.getAggregatedStats === 'function' ? store.getAggregatedStats() : {};
51
+
52
+ const lines = [
53
+ '📊 *DSH Cron — Scheduler Status*',
54
+ '',
55
+ '⚙️ *Tasks:*',
56
+ `• Total: ${tasks.length}`,
57
+ `• Active: 🟢 ${active}`,
58
+ `• Paused: ⏸ ${paused}`,
59
+ `• Running now: 🔄 ${running}${runningNames.length ? ' (' + runningNames.join(', ') + ')' : ''}`,
60
+ '',
61
+ '💰 *Cost & Tokens (last 24 hours):*',
62
+ `• Estimated cost: $${dailyCost.toFixed(4)}`,
63
+ `• Tokens: ${dailyTokens.toLocaleString()}`,
64
+ '',
65
+ '📈 *History totals:*',
66
+ `• Total runs: ${stats.totalRuns || 0}`,
67
+ `• Succeeded: ${stats.successfulRuns || 0}`,
68
+ `• Failed: ${stats.failedRuns || 0}`,
69
+ ];
70
+
71
+ return lines.join('\n');
72
+ }
73
+
74
+ /**
75
+ * Format tasks list message for /tasks command.
76
+ */
77
+ export function formatTasksMessage(tasks, scheduler) {
78
+ if (!tasks || !tasks.length) {
79
+ return '📋 *DSH Cron Tasks:*\n\nTask list is empty.';
80
+ }
81
+
82
+ const items = tasks.slice(0, 20).map((t) => {
83
+ const isRunning = typeof scheduler?.isRunning === 'function' && scheduler.isRunning(t.id);
84
+ const statusIcon = isRunning ? '🔄' : (t.status === 'active' ? '🟢' : '⏸');
85
+ const next = t.nextRunAt
86
+ ? new Date(t.nextRunAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
87
+ : (t.status === 'paused' ? 'paused' : '—');
88
+ const reason = t.pausedReason ? ` (${t.pausedReason.slice(0, 40)})` : '';
89
+ return `${statusIcon} *${t.title || 'Untitled'}* (\`${t.id}\`)\n ⏰ ${t.scheduleText || t.schedule || '—'} | next: ${next}${reason}`;
90
+ });
91
+
92
+ const lines = [
93
+ '📋 *DSH Cron Tasks:*',
94
+ '',
95
+ ...items,
96
+ ];
97
+
98
+ if (tasks.length > 20) {
99
+ lines.push('', `_...and ${tasks.length - 20} more tasks_`);
100
+ }
101
+
102
+ return lines.join('\n\n');
103
+ }
104
+
105
+ /**
106
+ * Find a task by id or partial match.
107
+ */
108
+ function findTask(store, query) {
109
+ if (!query || typeof store?.get !== 'function') return null;
110
+ const q = String(query).trim();
111
+ const direct = store.get(q);
112
+ if (direct) return direct;
113
+
114
+ const list = typeof store.list === 'function' ? store.list() : [];
115
+ return list.find((t) => t.id.toLowerCase() === q.toLowerCase() || t.title.toLowerCase() === q.toLowerCase()) || null;
116
+ }
117
+
118
+ /**
119
+ * Dispatch and handle a Telegram text command.
120
+ */
121
+ export async function handleTelegramCommand({
122
+ store,
123
+ scheduler,
124
+ text,
125
+ chatId,
126
+ botToken,
127
+ fetchFn = globalThis.fetch,
128
+ }) {
129
+ const parsed = parseTelegramCommand(text);
130
+ if (!parsed) return { ok: false, error: 'Not a command' };
131
+
132
+ const { command, args } = parsed;
133
+
134
+ const reply = async (msgText, replyMarkup) => {
135
+ if (!botToken || !chatId) return;
136
+ try {
137
+ await sendTelegramMessage({
138
+ botToken,
139
+ chatId,
140
+ text: msgText,
141
+ parseMode: 'Markdown',
142
+ replyMarkup,
143
+ fetchFn,
144
+ });
145
+ } catch (err) {
146
+ bestEffort('telegram-cmd-reply', () => {}, scheduler?.logger);
147
+ }
148
+ };
149
+
150
+ switch (command) {
151
+ case 'start':
152
+ case 'help': {
153
+ const help = [
154
+ '🤖 *DSH Cron Control Commands:*',
155
+ '',
156
+ '• `/status` — summary of daemon status, tasks and 24h cost',
157
+ '• `/tasks` — list of configured tasks and schedules',
158
+ '• `/run <id>` — trigger immediate manual execution',
159
+ '• `/pause <id>` — pause a scheduled task',
160
+ '• `/resume <id>` — resume a paused task',
161
+ '• `/log <id>` — show output from the last execution',
162
+ '• `/help` — this help reference',
163
+ ].join('\n');
164
+ await reply(help);
165
+ return { ok: true, command };
166
+ }
167
+
168
+ case 'status': {
169
+ const statusText = formatStatusMessage({ store, scheduler });
170
+ await reply(statusText);
171
+ return { ok: true, command };
172
+ }
173
+
174
+ case 'tasks': {
175
+ const tasks = typeof store?.list === 'function' ? store.list() : [];
176
+ const tasksText = formatTasksMessage(tasks, scheduler);
177
+ await reply(tasksText);
178
+ return { ok: true, command };
179
+ }
180
+
181
+ case 'run': {
182
+ const targetId = args[0];
183
+ if (!targetId) {
184
+ await reply('⚠️ *Please specify task ID:*\n`/run <task_id>`');
185
+ return { ok: false, error: 'Missing task id' };
186
+ }
187
+ const task = findTask(store, targetId);
188
+ if (!task) {
189
+ await reply(`❌ Task \`${targetId}\` not found.`);
190
+ return { ok: false, error: 'Task not found' };
191
+ }
192
+ try {
193
+ await scheduler.triggerManualRun(task.id);
194
+ await reply(`🚀 Triggered immediate execution for *${task.title}* (\`${task.id}\`)`);
195
+ return { ok: true, command, taskId: task.id };
196
+ } catch (err) {
197
+ await reply(`❌ Execution error for *${task.title}*: ${err.message}`);
198
+ return { ok: false, error: err.message };
199
+ }
200
+ }
201
+
202
+ case 'pause': {
203
+ const targetId = args[0];
204
+ if (!targetId) {
205
+ await reply('⚠️ *Please specify task ID:*\n`/pause <task_id>`');
206
+ return { ok: false, error: 'Missing task id' };
207
+ }
208
+ const task = findTask(store, targetId);
209
+ if (!task) {
210
+ await reply(`❌ Task \`${targetId}\` not found.`);
211
+ return { ok: false, error: 'Task not found' };
212
+ }
213
+ scheduler.pauseTask(task.id, 'Paused via Telegram');
214
+ await reply(`⏸ Task *${task.title}* (\`${task.id}\`) paused.`);
215
+ return { ok: true, command, taskId: task.id };
216
+ }
217
+
218
+ case 'resume': {
219
+ const targetId = args[0];
220
+ if (!targetId) {
221
+ await reply('⚠️ *Please specify task ID:*\n`/resume <task_id>`');
222
+ return { ok: false, error: 'Missing task id' };
223
+ }
224
+ const task = findTask(store, targetId);
225
+ if (!task) {
226
+ await reply(`❌ Task \`${targetId}\` not found.`);
227
+ return { ok: false, error: 'Task not found' };
228
+ }
229
+ scheduler.resumeTask(task.id);
230
+ await reply(`▶️ Task *${task.title}* (\`${task.id}\`) resumed.`);
231
+ return { ok: true, command, taskId: task.id };
232
+ }
233
+
234
+ case 'log': {
235
+ const targetId = args[0];
236
+ if (!targetId) {
237
+ await reply('⚠️ *Please specify task ID:*\n`/log <task_id>`');
238
+ return { ok: false, error: 'Missing task id' };
239
+ }
240
+ const task = findTask(store, targetId);
241
+ if (!task) {
242
+ await reply(`❌ Task \`${targetId}\` not found.`);
243
+ return { ok: false, error: 'Task not found' };
244
+ }
245
+ const history = typeof store.getHistory === 'function' ? store.getHistory(task.id, 1) : [];
246
+ const lastRun = history && history[0];
247
+ if (!lastRun) {
248
+ await reply(`ℹ️ No recorded runs for task *${task.title}*.`);
249
+ return { ok: true, command, empty: true };
250
+ }
251
+
252
+ const isSuccess = lastRun.status === 'success';
253
+ const statusIcon = isSuccess ? '✅' : '❌';
254
+ const costStr = lastRun.costUsd > 0 ? ` | $${lastRun.costUsd.toFixed(4)}` : '';
255
+ const content = lastRun.error || lastRun.output || 'Empty output';
256
+ const snippet = content.slice(0, 1500);
257
+
258
+ const logMsg = [
259
+ `📄 *Last run log: ${task.title}*`,
260
+ `*Status:* ${statusIcon} ${lastRun.status} (${lastRun.durationMs}ms${costStr})`,
261
+ '',
262
+ '```',
263
+ snippet,
264
+ '```',
265
+ ].join('\n');
266
+
267
+ await reply(logMsg);
268
+ return { ok: true, command, taskId: task.id };
269
+ }
270
+
271
+ default: {
272
+ await reply(`❓ Unknown command \`/${command}\`. Type /help for assistance.`);
273
+ return { ok: false, error: 'Unknown command' };
274
+ }
275
+ }
276
+ }
277
+
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.2.20",
3
+ "version": "0.2.22",
4
4
  "description": "Scheduled cron tasks, background automation and agent execution for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",