@goodandready/dsh-cron 0.2.1 → 0.2.3

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
@@ -122,9 +122,21 @@ Powered by `croner`, supporting standard 5-field cron expressions plus user-frie
122
122
  * `*/15 * * * *` — every 15 minutes
123
123
  * `0 0 * * 0` — every Sunday at midnight
124
124
  * `every 10m` / `every 2h` / `every 30s` — natural duration intervals
125
- * `daily` / `hourly` / `weekdays` shortcuts
125
+ * `daily` / `hourly` / `weekdays` shortcuts, plus standard `@hourly` / `@daily` / `@weekly` / `@monthly` / `@yearly` and `@every 30m`
126
+ * **Per-task time zones** — set an IANA zone (e.g. `Europe/Berlin`) on a task; without it the schedule follows the server's local time
126
127
  * **One-shot tasks**: `at: 2026-09-05T15:00:00Z` (exact ISO timestamp) or relative delays `in 20m` / `in 2h` (Russian aliases such as `через 15 минут` are accepted too). One-shot tasks flip to `completed` automatically after their single run and are listed under the **Completed** tab.
127
128
 
129
+ ### 5. Execution Reliability
130
+ * **Automatic retries** — set `maxRetries` and a base `retryBackoffMs` per task; failed runs (`error`/`timeout`) are retried with exponential backoff, and the attempt counter resets on success.
131
+ * **Misfire policies** — choose per task what happens when the daemon was offline at a scheduled time: `skip` (default — record the gap), `runOnce` (execute once, late), or `catchUpAll` (run late and record the gap). A missed one-shot under `skip` is retired as `completed` instead of firing stale.
132
+ * **Concurrency limit** — `maxConcurrent` (plugin setting) caps parallel runs; extra runs are recorded as `skipped` with a reason.
133
+ * **Live execution indicator** — the task list shows a pulsing status icon and a running timer for the task in flight.
134
+
135
+ ### 6. Session Integration & Permissions
136
+ * **Per-task permission presets** — `default`, `read-only`, `workspace-write`, or `full` are applied to the task's agent session before the prompt runs.
137
+ * **Session auto-archive** — isolated cron sessions are archived after each run (best-effort) so they do not clutter the chat list.
138
+ * **History → session navigation** — every LLM run records its session; open it straight from the run history entry.
139
+
128
140
  ### 5. Telegram Notifications & Delivery Routing
129
141
  Direct integration with the Telegram Bot API delivers execution reports and error traces straight to your messenger:
130
142
 
@@ -148,6 +160,10 @@ Prevent rogue processes from stacking concurrent duplicate executions:
148
160
 
149
161
  If the daemon was offline at a scheduled time, the run is recorded as `missed` on startup, so gaps in the history stay visible.
150
162
 
163
+ ### 8. Heartbeat Monitoring (#16-style dead man's switch)
164
+ * Set `heartbeatUrl` and `heartbeatIntervalSec` in the plugin settings and the scheduler pings that URL on schedule — an external monitor alerts when the pings stop.
165
+ * A built-in `GET /dsh-cron/heartbeat` endpoint reports liveness, active task count and the last run time for your own watchdogs.
166
+
151
167
  ---
152
168
 
153
169
  ## 📦 Installation
@@ -174,6 +190,10 @@ dsh-cron:
174
190
  notifyTelegram: false # deliver reports for every task globally
175
191
  onlyOnFailure: false # deliver reports only for failed runs
176
192
  kanbanBaseUrl: "http://127.0.0.1:3000" # dsh-kanban HTTP API base URL
193
+ defaultTimezone: "" # default IANA time zone for schedules (empty = server local)
194
+ maxConcurrent: 0 # max parallel task runs (0 = unlimited)
195
+ heartbeatUrl: "" # dead man's snitch URL pinged on the heartbeat interval
196
+ heartbeatIntervalSec: 0 # heartbeat ping interval in seconds (0 = off)
177
197
  ```
178
198
 
179
199
  ### Configuration Parameters
@@ -185,6 +205,10 @@ dsh-cron:
185
205
  | `notifyTelegram` | `boolean` | `false` | Global switch: deliver run reports to Telegram |
186
206
  | `onlyOnFailure` | `boolean` | `false` | Global switch: deliver reports only for `error`/`timeout` runs |
187
207
  | `kanbanBaseUrl` | `string` | `"http://127.0.0.1:3000"` | Base URL of the `dsh-kanban` HTTP API used for automatic card creation |
208
+ | `defaultTimezone` | `string` | `""` | Default IANA time zone for task schedules; empty = server local time |
209
+ | `maxConcurrent` | `number` | `0` | Cap on parallel task runs; extra runs are recorded as `skipped` (0 = unlimited) |
210
+ | `heartbeatUrl` | `string` | `""` | Dead man's snitch URL pinged every `heartbeatIntervalSec` while the scheduler is alive |
211
+ | `heartbeatIntervalSec` | `number` | `0` | Heartbeat ping interval in seconds (0 = disabled) |
188
212
 
189
213
  Notes:
190
214
 
@@ -212,7 +236,8 @@ All endpoints are served by the DSH web server under `/dsh-cron/`. Read endpoint
212
236
  | `GET` | `/dsh-cron/models` | List LLM providers; `?provider=<id>` lists models |
213
237
  | `POST` | `/dsh-cron/chat/start` | Start a "Create with DSH" agent session with the task-setup instructions |
214
238
  | `GET` | `/dsh-cron/settings` | Client-safe settings (token masked) |
215
- | `POST` | `/dsh-cron/settings` | Update integration settings |
239
+ | `POST` | `/dsh-cron/settings` | Update integration settings (applied through the settings service) |
240
+ | `GET` | `/dsh-cron/heartbeat` | Liveness probe: active task count, last run time, server time |
216
241
  | `POST` | `/dsh-cron/telegram/test` | Send a Telegram test message |
217
242
  | `POST` | `/dsh-cron/kanban/test` | Create a Kanban connectivity-test card |
218
243
  | `*` | `/dsh-cron/action/:id/:action` | Legacy alias for the task action routes (`run`, `toggle`, `delete`, `history`) |
package/docs/README.ru.md CHANGED
@@ -121,9 +121,21 @@ cron_create_task({
121
121
  * `*/15 * * * *` — каждые 15 минут
122
122
  * `0 0 * * 0` — каждое воскресенье в полночь
123
123
  * `every 10m` / `every 2h` / `every 30s` — естественные интервалы
124
- * алиасы `daily` / `hourly` / `weekdays` (`weekdays` → `0 9 * * 1-5`)
124
+ * алиасы `daily` / `hourly` / `weekdays`, а также стандартные `@hourly` / `@daily` / `@weekly` / `@monthly` / `@yearly` и `@every 30m`
125
+ * **Часовые пояса** — для задачи можно указать IANA-зону (например, `Europe/Berlin`); без неё расписание живёт в серверном времени
125
126
  * **Разовые задачи**: `at: 2026-09-05T15:00:00Z` (точный ISO-таймстемп) или относительные задержки `in 20m` / `in 2h` (принимаются и русские варианты вроде `через 15 минут`). После единственного запуска задача автоматически переходит в `completed` и отображается на табе **Завершённые**.
126
127
 
128
+ ### 5. Надёжность исполнения
129
+ * **Автоповторы** — `maxRetries` и база `retryBackoffMs` на задачу: упавшие запуски (error/timeout) повторяются с экспоненциальной задержкой, счётчик сбрасывается после успеха.
130
+ * **Misfire-политики** — что делать с пропущенным за время простоя запуском: `skip` (по умолчанию — записать пропуск), `runOnce` (выполнить один раз с опозданием) или `catchUpAll` (выполнить и зафиксировать пропуск). Пропущенный one-shot при `skip` уходит в `completed` без выполнения.
131
+ * **Лимит параллельности** — настройка `maxConcurrent` ограничивает число одновременных запусков; лишние помечаются `skipped` с причиной.
132
+ * **Живой индикатор** — в списке задач пульсирует статус и идёт таймер текущего запуска.
133
+
134
+ ### 6. Интеграция сессий и права
135
+ * **Permission-пресеты на задачу** — `default`, `read-only`, `workspace-write` или `full` применяются к сессии агента перед запуском промпта.
136
+ * **Автоархивация сессий** — изолированные cron-сессии архивируются после запуска (best-effort), не засоряя список чатов.
137
+ * **История → сессия** — каждый LLM-запуск хранит свою сессию; открыть диалог можно прямо из записи истории.
138
+
127
139
  ### 5. Уведомления Telegram
128
140
  Прямая интеграция с Telegram Bot API доставляет отчёты о запусках и трейсы ошибок в мессенджер:
129
141
 
@@ -170,6 +182,10 @@ dsh-cron:
170
182
  notifyTelegram: false # глобально отправлять отчёты о всех задачах
171
183
  onlyOnFailure: false # отправлять отчёты только при сбоях
172
184
  kanbanBaseUrl: "http://127.0.0.1:3000" # базовый URL HTTP API dsh-kanban
185
+ defaultTimezone: "" # IANA-зона по умолчанию (пусто = серверное время)
186
+ maxConcurrent: 0 # максимум параллельных запусков (0 = без лимита)
187
+ heartbeatUrl: "" # URL dead man's snitch, пингуется по интервалу
188
+ heartbeatIntervalSec: 0 # интервал heartbeat-пинга в секундах (0 = выключено)
173
189
  ```
174
190
 
175
191
  ### Параметры
@@ -181,11 +197,15 @@ dsh-cron:
181
197
  | `notifyTelegram` | `boolean` | `false` | Глобальный выключатель доставки отчётов в Telegram |
182
198
  | `onlyOnFailure` | `boolean` | `false` | Глобальный режим «только при сбоях» (`error`/`timeout`) |
183
199
  | `kanbanBaseUrl` | `string` | `"http://127.0.0.1:3000"` | Базовый URL HTTP API `dsh-kanban` для автоматических карточек |
200
+ | `defaultTimezone` | `string` | `""` | IANA-зона по умолчанию для расписаний; пусто = серверное время |
201
+ | `maxConcurrent` | `number` | `0` | Лимит параллельных запусков; лишние помечаются `skipped` (0 = без лимита) |
202
+ | `heartbeatUrl` | `string` | `""` | URL dead man's snitch, пингуемый каждый `heartbeatIntervalSec`, пока жив планировщик |
203
+ | `heartbeatIntervalSec` | `number` | `0` | Интервал heartbeat-пинга в секундах (0 = выключено) |
184
204
 
185
205
  Примечания:
186
206
 
187
207
  * История запусков ограничена **50 записями на задачу** (фиксировано); в записи хранится до 4000 символов вывода.
188
- * Задачи выполняются в **локальном часовом поясе сервера**; cron-выражения вычисляет `croner` по часам хоста.
208
+ * Задачи выполняются в **локальном часовом поясе сервера**, если для задачи не указана своя IANA-зона; cron-выражения вычисляет `croner` по часам хоста.
189
209
  * Задачи сохраняются в каталоге данных DSH (`cron/tasks.json`) и переживают перезапуск; пропущенные one-shot запуски обнаруживаются при старте.
190
210
 
191
211
  ---
@@ -208,7 +228,8 @@ dsh-cron:
208
228
  | `GET` | `/dsh-cron/models` | Список LLM-провайдеров; `?provider=<id>` — модели |
209
229
  | `POST` | `/dsh-cron/chat/start` | Старт агентской сессии «Создать с DSH» с инструкциями планировщика |
210
230
  | `GET` | `/dsh-cron/settings` | Настройки для клиента (токен замаскирован) |
211
- | `POST` | `/dsh-cron/settings` | Обновление настроек интеграций |
231
+ | `POST` | `/dsh-cron/settings` | Обновление настроек интеграций (через службу настроек) |
232
+ | `GET` | `/dsh-cron/heartbeat` | Probe живости: число активных задач, время последнего запуска |
212
233
  | `POST` | `/dsh-cron/telegram/test` | Тестовое сообщение в Telegram |
213
234
  | `POST` | `/dsh-cron/kanban/test` | Тестовая карточка в Kanban |
214
235
  | `*` | `/dsh-cron/action/:id/:action` | Legacy-алиас действий над задачей (`run`, `toggle`, `delete`, `history`) |
package/docs/README.zh.md CHANGED
@@ -121,9 +121,16 @@ cron_create_task({
121
121
  * `*/15 * * * *` —— 每 15 分钟
122
122
  * `0 0 * * 0` —— 每周日午夜
123
123
  * `every 10m` / `every 2h` / `every 30s` —— 自然语言间隔
124
- * `daily` / `hourly` / `weekdays` 快捷方式
124
+ * `daily` / `hourly` / `weekdays` 快捷方式,以及标准 `@hourly` / `@daily` / `@weekly` / `@monthly` / `@yearly` 与 `@every 30m`
125
+ * **任务级时区** —— 可为任务设置 IANA 时区(如 `Europe/Berlin`);未设置时按服务器本地时间调度
125
126
  * **一次性任务**:`at: 2026-09-05T15:00:00Z`(精确 ISO 时间戳)或相对延时 `in 20m` / `in 2h`(也接受 `через 15 минут` 之类的俄语输入)。一次性任务在单次运行后自动转为 `completed`,显示在 **已完成** 标签下。
126
127
 
128
+ ### 5. 执行可靠性
129
+ * **自动重试** —— 按任务设置 `maxRetries` 与基础 `retryBackoffMs`:失败(`error`/`timeout`)的运行按指数退避自动重试,成功后计数归零。
130
+ * **Misfire 策略** —— 选择守护进程离线期间错过的运行如何处理:`skip`(默认 —— 记录缺口)、`runOnce`(迟执行一次)或 `catchUpAll`(迟执行并记录缺口)。`skip` 下错过的一次性任务直接转为 `completed`,不再过期触发。
131
+ * **并发上限** —— 插件设置 `maxConcurrent` 限制并行运行数;超出的运行记录为 `skipped` 并附原因。
132
+ * **实时执行指示** —— 任务列表中的脉冲状态图标与运行计时器。
133
+
127
134
  ### 5. Telegram 通知与投递路由
128
135
  通过与 Telegram Bot API 的直接集成,将执行报告与错误跟踪推送到你的即时通讯工具:
129
136
 
@@ -146,6 +153,10 @@ cron_create_task({
146
153
 
147
154
  如果守护进程在计划时刻处于离线状态,启动时该次运行会被记录为 `missed`,历史空档始终可见。
148
155
 
156
+ ### 8. 心跳监控(Dead man's switch)
157
+ * 在插件设置中配置 `heartbeatUrl` 与 `heartbeatIntervalSec`,调度器会按间隔 GET 该地址 —— 外部监控可在心跳停止时告警。
158
+ * 内置 `GET /dsh-cron/heartbeat` 端点返回存活状态、活跃任务数与最近运行时间,便于自建看门狗。
159
+
149
160
  ---
150
161
 
151
162
  ## 📦 安装
@@ -170,6 +181,10 @@ dsh-cron:
170
181
  notifyTelegram: false # 全局投递所有任务的报告
171
182
  onlyOnFailure: false # 仅失败时投递报告
172
183
  kanbanBaseUrl: "http://127.0.0.1:3000" # dsh-kanban HTTP API 基础地址
184
+ defaultTimezone: "" # 默认 IANA 时区(空 = 服务器本地)
185
+ maxConcurrent: 0 # 最大并行运行数(0 = 不限)
186
+ heartbeatUrl: "" # 心跳上报 URL(dead man's snitch)
187
+ heartbeatIntervalSec: 0 # 心跳间隔秒数(0 = 关闭)
173
188
  ```
174
189
 
175
190
  ### 配置参数
@@ -181,6 +196,10 @@ dsh-cron:
181
196
  | `notifyTelegram` | `boolean` | `false` | 全局开关:向 Telegram 投递运行报告 |
182
197
  | `onlyOnFailure` | `boolean` | `false` | 全局开关:仅对 `error`/`timeout` 运行投递报告 |
183
198
  | `kanbanBaseUrl` | `string` | `"http://127.0.0.1:3000"` | 用于自动卡片的 `dsh-kanban` HTTP API 基础地址 |
199
+ | `defaultTimezone` | `string` | `""` | 任务调度的默认 IANA 时区;空 = 服务器本地时间 |
200
+ | `maxConcurrent` | `number` | `0` | 并行运行上限;超出的运行记录为 `skipped`(0 = 不限) |
201
+ | `heartbeatUrl` | `string` | `""` | 心跳上报 URL,调度器存活期间按 `heartbeatIntervalSec` 间隔 GET |
202
+ | `heartbeatIntervalSec` | `number` | `0` | 心跳间隔秒数(0 = 关闭) |
184
203
 
185
204
  说明:
186
205
 
@@ -208,7 +227,8 @@ dsh-cron:
208
227
  | `GET` | `/dsh-cron/models` | 列出 LLM 提供方;`?provider=<id>` 列出模型 |
209
228
  | `POST` | `/dsh-cron/chat/start` | 启动带任务配置指令的“由 DSH 创建”智能体会话 |
210
229
  | `GET` | `/dsh-cron/settings` | 客户端安全设置(令牌掩码显示) |
211
- | `POST` | `/dsh-cron/settings` | 更新集成设置 |
230
+ | `POST` | `/dsh-cron/settings` | 更新集成设置(通过设置服务应用) |
231
+ | `GET` | `/dsh-cron/heartbeat` | 存活探针:活跃任务数与最近运行时间 |
212
232
  | `POST` | `/dsh-cron/telegram/test` | 发送 Telegram 测试消息 |
213
233
  | `POST` | `/dsh-cron/kanban/test` | 创建 Kanban 连通性测试卡片 |
214
234
  | `*` | `/dsh-cron/action/:id/:action` | 任务操作路由的兼容别名(`run`、`toggle`、`delete`、`history`) |
package/lib/client.js CHANGED
@@ -114,6 +114,20 @@ window.__ModuleLoader__.load({
114
114
  'form.promptLabelLlm': 'Instruction / Prompt for the agent',
115
115
  'form.promptPlaceholderScript': 'curl -fsSL https://... || exit 1',
116
116
  'form.promptPlaceholderLlm': 'Describe exactly what the agent should do...',
117
+ 'form.timezoneLabel': 'Time zone (IANA, optional)',
118
+ 'form.timezonePlaceholder': 'e.g. Europe/Berlin (empty = server local)',
119
+ 'form.misfireLabel': 'On missed run (after downtime)',
120
+ 'form.misfireSkip': 'Skip the missed run (skip)',
121
+ 'form.misfireRunOnce': 'Run once late (runOnce)',
122
+ 'form.misfireCatchUp': 'Run late once and record the gap (catchUpAll)',
123
+ 'form.retriesLabel': 'Automatic retries on failure (0 = off)',
124
+ 'form.permissionLabel': 'Session permissions',
125
+ 'form.permissionDefault': 'Default (as configured in DSH)',
126
+ 'form.permissionReadonly': 'Read-only',
127
+ 'form.permissionWrite': 'Workspace write',
128
+ 'form.permissionFull': 'Full',
129
+ 'list.runningNow': 'Running now',
130
+ 'history.openSession': 'Open session',
117
131
  'form.notifyTitle': 'Telegram notifications',
118
132
  'form.notifyTelegram': 'Send the run report to Telegram',
119
133
  'form.notifyOnlyFailure': 'Only on failures (onlyOnFailure / silent on success)',
@@ -180,20 +194,57 @@ window.__ModuleLoader__.load({
180
194
  } catch (e) {}
181
195
  const chevronNode = () => (CoreChevron ? React.createElement(CoreChevron) : React.createElement('span', { style: { display: 'flex', alignItems: 'center' }, dangerouslySetInnerHTML: { __html: ICON_CHEVRON_DOWN } }));
182
196
 
183
- // Status colors: core semantic tokens first, plugin fallbacks as a single
184
- // point of change (Issue #91 — no scattered hardcoded hex).
197
+ // Own React error boundary (#106): the core's SlotErrorBoundary renders an
198
+ // empty div on crash, so a slot failure would silently vanish. Ours keeps
199
+ // the failure visible with a retry.
200
+ function createErrorBoundary() {
201
+ if (!React || typeof React.Component !== 'function') {
202
+ return function NoopBoundary(props) { return (props && props.children) || null; };
203
+ }
204
+ return class ErrorBoundary extends React.Component {
205
+ constructor(props) {
206
+ super(props);
207
+ this.state = { hasError: false, error: null };
208
+ }
209
+ static getDerivedStateFromError(error) {
210
+ return { hasError: true, error };
211
+ }
212
+ componentDidCatch(error, errorInfo) {
213
+ console.error('[dsh-cron] React error:', error, errorInfo);
214
+ }
215
+ render() {
216
+ if (this.state.hasError) {
217
+ return React.createElement('div', { className: 'dsh-cron-status-banner', 'data-type': 'error', style: { margin: '12px 0' } },
218
+ React.createElement('div', { style: { fontWeight: 600, marginBottom: '6px' } }, '⚠️ Cron UI error:'),
219
+ React.createElement('div', { style: { fontSize: '12px', wordBreak: 'break-all' } }, String((this.state.error && this.state.error.message) || this.state.error)),
220
+ React.createElement('button', {
221
+ type: 'button',
222
+ className: 'dsh-cron-btn-secondary',
223
+ style: { marginTop: '10px', fontSize: '12px', padding: '4px 10px' },
224
+ onClick: () => this.setState({ hasError: false, error: null })
225
+ }, 'Retry')
226
+ );
227
+ }
228
+ return (this.props && this.props.children) || null;
229
+ }
230
+ };
231
+ }
232
+ const ErrorBoundary = createErrorBoundary();
233
+
234
+ // Status colors: core semantic state tokens first (same set dsh-clinebot
235
+ // uses), plugin fallbacks as a single point of change (#105).
185
236
  const STYLES = `
186
237
  :root {
187
- --dsh-cron-success: var(--dsw-alias-label-success, #10b981);
188
- --dsh-cron-success-bg: var(--dsw-alias-bg-success, rgba(16, 185, 129, 0.15));
189
- --dsh-cron-danger: var(--dsw-alias-label-danger, #ef4444);
190
- --dsh-cron-danger-bg: var(--dsw-alias-bg-danger, rgba(239, 68, 68, 0.15));
191
- --dsh-cron-info: var(--dsw-alias-label-info, #60a5fa);
192
- --dsh-cron-info-bg: var(--dsw-alias-bg-info, rgba(59, 130, 246, 0.15));
193
- --dsh-cron-accent: var(--dsw-alias-label-accent, #c084fc);
194
- --dsh-cron-accent-bg: var(--dsw-alias-bg-accent, rgba(168, 85, 247, 0.15));
195
- --dsh-cron-warning: var(--dsw-alias-label-warning, #eab308);
196
- --dsh-cron-warning-bg: var(--dsw-alias-bg-warning, rgba(234, 179, 8, 0.15));
238
+ --dsh-cron-success: var(--dsw-alias-state-success-primary, #10b981);
239
+ --dsh-cron-success-bg: rgba(16, 185, 129, 0.1);
240
+ --dsh-cron-danger: var(--dsw-alias-state-error-primary, #ef4444);
241
+ --dsh-cron-danger-bg: rgba(239, 68, 68, 0.1);
242
+ --dsh-cron-info: var(--dsw-alias-state-brand-primary, #60a5fa);
243
+ --dsh-cron-info-bg: rgba(96, 165, 250, 0.12);
244
+ --dsh-cron-accent: var(--dsw-alias-state-info-primary, #c084fc);
245
+ --dsh-cron-accent-bg: rgba(168, 85, 247, 0.12);
246
+ --dsh-cron-warning: var(--dsw-alias-state-warning-primary, #eab308);
247
+ --dsh-cron-warning-bg: rgba(234, 179, 8, 0.12);
197
248
  }
198
249
  [data-pane=conversation], [class*=centerCol] {
199
250
  position: relative !important;
@@ -218,43 +269,46 @@ window.__ModuleLoader__.load({
218
269
  .dsh-cron-entry-label { text-overflow: ellipsis; overflow: hidden; }
219
270
  [data-dsh-frame][data-sidebar-collapsed] .dsh-cron-entry-label { display: none; }
220
271
 
221
- .dsh-cron-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; background: var(--dsw-alias-bg-base, #171717); color: var(--dsw-alias-label-primary, #ededed); overflow-y: auto; padding: 32px 48px; z-index: 100; box-sizing: border-box; }
222
- .dsh-cron-container { max-width: 860px; width: 100%; margin: 0 auto; }
272
+ .dsh-cron-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; background: var(--dsw-alias-bg-base, #171717); color: var(--dsw-alias-label-primary, #ededed); overflow-y: auto; padding: 24px 48px 32px; z-index: 100; box-sizing: border-box; }
273
+ .dsh-cron-container { max-width: 960px; width: 100%; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; padding: 8px 0 32px; box-sizing: border-box; }
223
274
 
224
- .dsh-cron-top-bar { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
275
+ .dsh-cron-top-bar { display: flex; align-items: center; gap: 12px; }
225
276
  .dsh-cron-back-btn { appearance: none; font: inherit; cursor: pointer; color: var(--dsw-alias-label-secondary, #9ca3af); background: 0 0; border: 1px solid var(--dsw-alias-border-l2, #383838); border-radius: 8px; align-items: center; gap: 6px; padding: 4px 12px; font-size: 13px; display: inline-flex; flex: none; height: 32px; box-sizing: border-box; }
226
- .dsh-cron-back-btn:hover { color: var(--dsw-alias-label-primary, #ededed); background: var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,0.06)); }
277
+ .dsh-cron-back-btn:hover { color: var(--dsw-alias-label-primary, #ededed); background: var(--dsw-alias-bg-layer-4, var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,0.06))); }
227
278
 
228
- .dsh-cron-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 24px; }
229
- .dsh-cron-title { font-size: 24px; font-weight: 600; letter-spacing: -0.02em; margin-bottom: 6px; }
230
- .dsh-cron-subtitle { font-size: 14px; color: var(--dsw-alias-label-secondary, #9ca3af); }
231
- .dsh-cron-create-btn { appearance: none; display: inline-flex; align-items: center; gap: 6px; background: var(--dsw-alias-bg-layer-4, #262626); color: var(--dsw-alias-label-primary, #fff); border: 1px solid var(--dsw-alias-border-l2, #383838); border-radius: 9999px; padding: 8px 16px; font-size: 14px; font-weight: 500; cursor: pointer; position: relative; }
279
+ .dsh-cron-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; padding-bottom: 16px; border-bottom: 1px solid var(--dsw-alias-border-l2, #383838); }
280
+ .dsh-cron-title { font-size: 22px; font-weight: 700; letter-spacing: -0.02em; margin-bottom: 6px; }
281
+ .dsh-cron-subtitle { font-size: 14px; color: var(--dsw-alias-label-secondary, #9ca3af); line-height: 1.5; }
282
+ .dsh-cron-create-btn { appearance: none; display: inline-flex; align-items: center; gap: 6px; background: var(--dsw-alias-bg-layer-2, #262626); color: var(--dsw-alias-label-primary, #fff); border: 1px solid var(--dsw-alias-border-l2, #383838); border-radius: 9999px; padding: 8px 16px; font-size: 14px; font-weight: 500; cursor: pointer; position: relative; }
232
283
  .dsh-cron-create-btn:hover { background: var(--dsw-alias-bg-layer-hover, var(--dsw-alias-interactive-bg-hover, #333)); }
233
284
  .dsh-cron-dropdown { position: absolute; top: calc(100% + 6px); right: 0; width: 200px; background: var(--dsw-alias-bg-layer-3, #212121); border: 1px solid var(--dsw-alias-border-l2, #383838); border-radius: 12px; padding: 6px; box-shadow: 0 10px 25px rgba(0,0,0,0.5); z-index: 200; }
234
285
  .dsh-cron-dropdown-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 10px 12px; background: transparent; border: none; border-radius: 8px; color: var(--dsw-alias-label-primary, #ededed); font-size: 13.5px; text-align: left; cursor: pointer; }
235
286
  .dsh-cron-dropdown-item:hover { background: var(--dsw-alias-interactive-bg-hover, #2a2a2a); }
236
- .dsh-cron-search-bar { position: relative; margin-bottom: 20px; }
237
- .dsh-cron-search-input { width: 100%; background: var(--dsw-alias-bg-layer-3, #1f1f1f); border: 1px solid var(--dsw-alias-border-l2, #333); border-radius: 10px; padding: 10px 14px 10px 38px; color: var(--dsw-alias-label-primary, inherit); font-size: 14px; outline: none; box-sizing: border-box; }
287
+ .dsh-cron-search-bar { position: relative; margin-bottom: 0; }
288
+ .dsh-cron-search-input { width: 100%; height: 36px; background: var(--dsw-alias-bg-layer-2, #1f1f1f); border: 1px solid var(--dsw-alias-border-l2, #333); border-radius: 8px; padding: 0 12px 0 38px; color: var(--dsw-alias-label-primary, inherit); font-size: 13px; outline: none; box-sizing: border-box; }
289
+ .dsh-cron-search-input:focus { outline: none; border-color: var(--dsh-cron-info); }
238
290
  .dsh-cron-search-icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--dsw-alias-label-tertiary, #666); }
239
- .dsh-cron-tabs { display: flex; gap: 8px; margin-bottom: 24px; }
240
- .dsh-cron-tab { appearance: none; background: transparent; border: none; padding: 6px 14px; border-radius: 9999px; color: var(--dsw-alias-label-secondary, #9ca3af); font-size: 13.5px; cursor: pointer; }
241
- .dsh-cron-tab[data-active="true"] { background: var(--dsw-alias-bg-layer-4, #2a2a2a); color: var(--dsw-alias-label-primary, #fff); font-weight: 500; }
242
- .dsh-cron-task-list { display: flex; flex-direction: column; gap: 12px; margin-bottom: 40px; }
291
+ .dsh-cron-tabs { display: flex; flex-wrap: wrap; gap: 8px; }
292
+ .dsh-cron-tab { appearance: none; background: transparent; border: 1px solid var(--dsw-alias-border-l2, #383838); padding: 4px 12px; border-radius: 999px; color: var(--dsw-alias-label-secondary, #9ca3af); font-size: 12px; font-weight: 500; cursor: pointer; }
293
+ .dsh-cron-tab[data-active="true"] { background: var(--dsw-alias-bg-layer-4, #2a2a2a); color: var(--dsw-alias-label-primary, #fff); border-color: var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2, #383838)); }
294
+ .dsh-cron-task-list { display: flex; flex-direction: column; gap: 12px; margin-bottom: 0; }
243
295
  .dsh-cron-task-item { display: flex; align-items: center; gap: 16px; padding: 14px 18px; background: var(--dsw-alias-bg-layer-3, #1e1e1e); border: 1px solid var(--dsw-alias-border-l2, #2e2e2e); border-radius: 12px; cursor: pointer; transition: background 0.15s, border-color 0.15s; }
244
- .dsh-cron-task-item:hover, .dsh-cron-task-item:focus-visible { background: var(--dsw-alias-bg-layer-4, #242424); border-color: var(--dsw-alias-border-l1, #444); outline: none; }
245
- .dsh-cron-task-prompt-preview { font-size: 12.5px; color: var(--dsw-alias-label-tertiary, #888); margin-top: 5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 520px; line-height: 1.3; }
246
- .dsh-cron-type-tag { display: inline-block; font-size: 10.5px; padding: 1px 6px; border-radius: 4px; text-transform: uppercase; font-weight: 600; margin-left: 8px; vertical-align: middle; }
247
- .dsh-cron-task-model-tag { display: inline-block; font-size: 11px; padding: 2px 7px; border-radius: 4px; background: var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,0.06)); color: var(--dsw-alias-label-secondary, #aaa); margin-left: 8px; vertical-align: middle; }
296
+ .dsh-cron-task-item:hover, .dsh-cron-task-item:focus-visible { background: var(--dsw-alias-bg-layer-4, #242424); border-color: var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l1, #444)); outline: none; }
297
+ .dsh-cron-task-prompt-preview { font-size: 12.5px; color: var(--dsw-alias-label-secondary, #888); margin-top: 5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 520px; line-height: 1.3; }
298
+ .dsh-cron-type-tag { display: inline-flex; align-items: center; font-size: 11px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--dsw-alias-border-l2, #383838); text-transform: uppercase; font-weight: 500; margin-left: 8px; vertical-align: middle; }
299
+ .dsh-cron-task-model-tag { display: inline-flex; align-items: center; font-size: 11px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--dsw-alias-border-l2, #383838); background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.06)); color: var(--dsw-alias-label-secondary, #aaa); margin-left: 8px; vertical-align: middle; }
248
300
  .dsh-cron-task-actions { display: flex; gap: 8px; }
249
- .dsh-cron-icon-btn { background: transparent; border: 1px solid var(--dsw-alias-border-l2, #333); border-radius: 8px; padding: 6px 12px; font-size: 12.5px; color: var(--dsw-alias-label-secondary, #aaa); cursor: pointer; }
250
- .dsh-cron-icon-btn:hover { background: var(--dsw-alias-interactive-bg-hover, #2a2a2a); color: var(--dsw-alias-label-primary, #fff); }
251
- .dsh-cron-task-status-btn { background: none; border: 1px solid var(--dsw-alias-border-l1, #444); border-radius: 50%; width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; color: var(--dsw-alias-label-tertiary, #888); cursor: pointer; flex-shrink: 0; }
301
+ .dsh-cron-icon-btn { appearance: none; font: inherit; background: var(--dsw-alias-bg-layer-2, transparent); border: 1px solid var(--dsw-alias-border-l2, #333); border-radius: 8px; padding: 6px 12px; font-size: 12.5px; font-weight: 500; color: var(--dsw-alias-label-primary, #aaa); cursor: pointer; display: inline-flex; align-items: center; gap: 6px; transition: all 0.15s ease; }
302
+ .dsh-cron-icon-btn:hover { background: var(--dsw-alias-bg-layer-4, #2a2a2a); border-color: var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2, #444)); }
303
+ .dsh-cron-icon-btn.dsh-cron-danger { color: var(--dsh-cron-danger); border-color: var(--dsh-cron-danger-bg); }
304
+ .dsh-cron-icon-btn.dsh-cron-danger:hover { background: var(--dsh-cron-danger-bg); border-color: var(--dsh-cron-danger); }
305
+ .dsh-cron-task-status-btn { background: none; border: 1px solid var(--dsw-alias-border-l2, #444); border-radius: 50%; width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; color: var(--dsw-alias-label-tertiary, #888); cursor: pointer; flex-shrink: 0; }
252
306
  .dsh-cron-task-status-btn[data-active="true"] { color: var(--dsh-cron-success); border-color: var(--dsh-cron-success); }
253
- .dsh-cron-recs-title { font-size: 17px; font-weight: 600; margin-bottom: 14px; }
307
+ .dsh-cron-recs-title { font-size: 16px; font-weight: 600; margin-bottom: 14px; }
254
308
  .dsh-cron-recs-list { display: flex; flex-direction: column; gap: 10px; }
255
- .dsh-cron-rec-card { display: flex; align-items: center; gap: 16px; padding: 16px 20px; background: var(--dsw-alias-bg-layer-3, #1c1c1c); border: 1px solid var(--dsw-alias-border-l1, #282828); border-radius: 12px; cursor: pointer; transition: background 0.15s; }
256
- .dsh-cron-rec-card:hover, .dsh-cron-rec-card:focus-visible { background: var(--dsw-alias-bg-layer-4, #242424); border-color: var(--dsw-alias-border-l2, #383838); outline: none; }
257
- .dsh-cron-rec-icon { width: 36px; height: 36px; border-radius: 10px; background: var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,0.03)); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
309
+ .dsh-cron-rec-card { display: flex; align-items: center; gap: 16px; padding: 14px 18px; background: var(--dsw-alias-bg-layer-3, #1c1c1c); border: 1px solid var(--dsw-alias-border-l2, #282828); border-radius: 12px; cursor: pointer; transition: background 0.15s; }
310
+ .dsh-cron-rec-card:hover, .dsh-cron-rec-card:focus-visible { background: var(--dsw-alias-bg-layer-4, #242424); border-color: var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2, #383838)); outline: none; }
311
+ .dsh-cron-rec-icon { width: 36px; height: 36px; border-radius: 10px; background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.03)); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
258
312
  .dsh-cron-rec-content { flex: 1; min-width: 0; }
259
313
  .dsh-cron-rec-title { font-size: 14.5px; font-weight: 500; margin-bottom: 4px; }
260
314
  .dsh-cron-rec-time { font-size: 13px; color: var(--dsw-alias-label-secondary, #9ca3af); font-weight: normal; margin-left: 8px; }
@@ -265,14 +319,17 @@ window.__ModuleLoader__.load({
265
319
  .dsh-cron-modal-title { font-size: 18px; font-weight: 600; margin-bottom: 18px; }
266
320
  .dsh-cron-form-group { margin-bottom: 16px; }
267
321
  .dsh-cron-form-group label { display: block; font-size: 13px; color: var(--dsw-alias-label-secondary, #aaa); margin-bottom: 6px; }
268
- .dsh-cron-form-group input, .dsh-cron-form-group textarea, .dsh-cron-form-group select { width: 100%; background: var(--dsw-alias-bg-base, #141414); border: 1px solid var(--dsw-alias-border-l2, #333); border-radius: 8px; padding: 9px 12px; color: var(--dsw-alias-label-primary, #fff); font-size: 13.5px; outline: none; box-sizing: border-box; }
322
+ .dsh-cron-form-group input, .dsh-cron-form-group textarea, .dsh-cron-form-group select { width: 100%; background: var(--dsw-alias-bg-layer-2, #141414); border: 1px solid var(--dsw-alias-border-l2, #333); border-radius: 8px; padding: 8px 12px; color: var(--dsw-alias-label-primary, #fff); font-size: 13px; outline: none; box-sizing: border-box; }
323
+ .dsh-cron-form-group input:focus, .dsh-cron-form-group textarea:focus, .dsh-cron-form-group select:focus { outline: none; border-color: var(--dsh-cron-info); }
269
324
  .dsh-cron-form-group select { cursor: pointer; }
270
325
  .dsh-cron-form-group textarea { min-height: 80px; resize: vertical; }
271
326
  .dsh-cron-form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
272
327
  .dsh-cron-modal-foot { display: flex; justify-content: flex-end; align-items: center; gap: 10px; margin-top: 20px; }
273
- .dsh-cron-btn-primary { background: var(--dsw-alias-label-primary, #ededed); color: var(--dsw-alias-bg-base, #111); border: none; border-radius: 8px; padding: 7px 16px; font-size: 13.5px; font-weight: 500; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; }
328
+ .dsh-cron-btn-primary { background: var(--dsw-alias-label-primary, #ededed); color: var(--dsw-alias-bg-layer-3, #111); border: 1px solid transparent; border-radius: 8px; padding: 7px 14px; font-size: 13px; font-weight: 500; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; }
329
+ .dsh-cron-btn-primary:hover:not(:disabled) { opacity: 0.88; }
274
330
  .dsh-cron-btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
275
- .dsh-cron-btn-secondary { background: transparent; color: var(--dsw-alias-label-secondary, #999); border: 1px solid var(--dsw-alias-border-l2, #383838); border-radius: 8px; padding: 7px 16px; font-size: 13.5px; cursor: pointer; }
331
+ .dsh-cron-btn-secondary { appearance: none; font: inherit; background: var(--dsw-alias-bg-layer-2, transparent); color: var(--dsw-alias-label-primary, #999); border: 1px solid var(--dsw-alias-border-l2, #383838); border-radius: 8px; padding: 7px 14px; font-size: 13px; font-weight: 500; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; transition: all 0.15s ease; }
332
+ .dsh-cron-btn-secondary:hover:not(:disabled) { background: var(--dsw-alias-bg-layer-4, #2a2a2a); border-color: var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2, #444)); }
276
333
  .dsh-cron-chat-box { position: relative; background: var(--dsw-alias-bg-base, #141414); border: 1px solid var(--dsw-alias-border-l2, #333); border-radius: 12px; padding: 12px; }
277
334
  .dsh-cron-chat-input { width: 100%; background: transparent; border: none; color: var(--dsw-alias-label-primary, #fff); font-size: 14px; min-height: 100px; resize: vertical; outline: none; box-sizing: border-box; line-height: 1.5; }
278
335
  .dsh-cron-chat-foot { display: flex; justify-content: space-between; align-items: center; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--dsw-alias-border-l1, #222); }
@@ -281,15 +338,15 @@ window.__ModuleLoader__.load({
281
338
  .dsh-cron-send-btn:hover:not(:disabled) { transform: translateY(-1px); }
282
339
  .dsh-cron-send-btn:disabled { opacity: 0.4; cursor: not-allowed; }
283
340
 
284
- .dsh-cron-card { background: var(--dsw-alias-bg-layer-2, #1f1f1f); border: 1px solid var(--dsw-alias-border-l1, #333); border-radius: 12px; padding: 14px 20px; margin-bottom: 16px; }
285
- .dsh-cron-card-head-btn { appearance: none; width: 100%; font: inherit; color: inherit; text-align: left; cursor: pointer; background: 0 0; border: 0; border-radius: 12px; display: flex; align-items: center; gap: 12px; padding: 4px 0; }
341
+ .dsh-cron-card { background: var(--dsw-alias-bg-layer-3, #1f1f1f); border: 1px solid var(--dsw-alias-border-l2, #333); border-radius: 12px; padding: 18px 20px; margin-bottom: 0; display: flex; flex-direction: column; gap: 14px; }
342
+ .dsh-cron-card-head-btn { appearance: none; width: 100%; font: inherit; color: inherit; text-align: left; cursor: pointer; background: 0 0; border: 0; border-radius: 12px; display: flex; align-items: center; gap: 12px; padding: 0; }
286
343
  .dsh-cron-card-head-btn .dsh-cron-card-title { flex: 1; }
287
- .dsh-cron-card-title { font-size: 15px; font-weight: 600; color: var(--dsw-alias-label-primary, #fff); }
344
+ .dsh-cron-card-title { font-size: 16px; font-weight: 600; color: var(--dsw-alias-label-primary, #fff); }
288
345
  .dsh-cron-chev { margin-left: auto; flex: none; color: var(--dsw-alias-label-tertiary, #888); transition: transform 0.16s; display: flex; align-items: center; }
289
346
  .dsh-cron-chev-open { transform: rotate(180deg); }
290
- .dsh-cron-card-body { border-top: 1px solid var(--dsw-alias-border-l1, #333); margin-top: 12px; padding-top: 14px; }
347
+ .dsh-cron-card-body { border-top: 1px solid var(--dsw-alias-border-l2, #333); margin-top: 2px; padding-top: 14px; }
291
348
  .dsh-cron-card-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
292
- .dsh-cron-card-desc { font-size: 13px; color: var(--dsw-alias-label-secondary, #888); margin-bottom: 16px; line-height: 1.4; }
349
+ .dsh-cron-card-desc { font-size: 13px; color: var(--dsw-alias-label-secondary, #888); line-height: 1.4; }
293
350
 
294
351
  .dsh-cron-modal-nav { display: flex; gap: 4px; border-bottom: 1px solid var(--dsw-alias-border-l2, #2e2e2e); margin-bottom: 18px; padding-bottom: 2px; }
295
352
  .dsh-cron-modal-tab { appearance: none; background: transparent; border: none; border-bottom: 2px solid transparent; padding: 8px 14px; font-size: 13.5px; color: var(--dsw-alias-label-tertiary, #888); cursor: pointer; font-weight: 500; transition: color 0.15s, border-color 0.15s; }
@@ -298,21 +355,21 @@ window.__ModuleLoader__.load({
298
355
  .dsh-cron-history-list { display: flex; flex-direction: column; gap: 10px; max-height: 420px; overflow-y: auto; padding-right: 4px; }
299
356
  .dsh-cron-history-item { background: var(--dsw-alias-bg-base, #161616); border: 1px solid var(--dsw-alias-border-l1, #2a2a2a); border-radius: 8px; padding: 12px 14px; font-size: 12.5px; }
300
357
  .dsh-cron-history-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
301
- .dsh-cron-history-status { font-weight: 600; font-size: 11.5px; text-transform: uppercase; padding: 2px 6px; border-radius: 4px; }
302
- .dsh-cron-history-status[data-status="success"] { background: var(--dsh-cron-success-bg); color: var(--dsh-cron-success); }
303
- .dsh-cron-history-status[data-status="error"], .dsh-cron-history-status[data-status="timeout"] { background: var(--dsh-cron-danger-bg); color: var(--dsh-cron-danger); }
304
- .dsh-cron-history-status[data-status="skipped"], .dsh-cron-history-status[data-status="missed"] { background: var(--dsh-cron-warning-bg); color: var(--dsh-cron-warning); }
358
+ .dsh-cron-history-status { font-weight: 500; font-size: 11px; text-transform: uppercase; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--dsw-alias-border-l2, #383838); }
359
+ .dsh-cron-history-status[data-status="success"] { background: var(--dsh-cron-success-bg); color: var(--dsh-cron-success); border-color: var(--dsh-cron-success); }
360
+ .dsh-cron-history-status[data-status="error"], .dsh-cron-history-status[data-status="timeout"] { background: var(--dsh-cron-danger-bg); color: var(--dsh-cron-danger); border-color: var(--dsh-cron-danger); }
361
+ .dsh-cron-history-status[data-status="skipped"], .dsh-cron-history-status[data-status="missed"] { background: var(--dsh-cron-warning-bg); color: var(--dsh-cron-warning); border-color: var(--dsh-cron-warning); }
305
362
  .dsh-cron-history-time { color: var(--dsw-alias-label-tertiary, #777); font-size: 12px; }
306
363
  .dsh-cron-history-output { background: var(--dsw-alias-bg-base, #0c0c0c); border: 1px solid var(--dsw-alias-border-l1, #222); border-radius: 6px; padding: 8px 10px; font-family: monospace; font-size: 12px; color: var(--dsw-alias-label-secondary, #bbb); max-height: 140px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; margin-top: 6px; }
307
364
  .dsh-cron-history-empty { text-align: center; padding: 36px 0; color: var(--dsw-alias-label-tertiary, #666); font-size: 13.5px; }
308
365
 
309
- .dsh-cron-stats-bar { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 18px; }
310
- .dsh-cron-stat-card { background: var(--dsw-alias-bg-layer-3, rgba(255, 255, 255, 0.02)); border: 1px solid var(--dsw-alias-border-l1, #222); border-radius: 8px; padding: 10px 14px; }
311
- .dsh-cron-stat-val { font-size: 16px; font-weight: 600; color: var(--dsw-alias-label-primary, #fff); }
312
- .dsh-cron-stat-lbl { font-size: 11.5px; color: var(--dsw-alias-label-tertiary, #777); margin-top: 2px; }
313
- .dsh-cron-oneshot-tag { font-size: 11px; padding: 2px 6px; border-radius: 4px; background: var(--dsh-cron-accent-bg); color: var(--dsh-cron-accent); font-weight: 500; }
314
- .dsh-cron-cost-tag { font-size: 11px; padding: 2px 6px; border-radius: 4px; background: var(--dsh-cron-success-bg); color: var(--dsh-cron-success); font-weight: 500; }
315
- .dsh-cron-tokens-tag { font-size: 11px; padding: 2px 6px; border-radius: 4px; background: var(--dsh-cron-info-bg); color: var(--dsh-cron-info); }
366
+ .dsh-cron-stats-bar { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-bottom: 0; }
367
+ .dsh-cron-stat-card { background: var(--dsw-alias-bg-layer-2, rgba(255, 255, 255, 0.02)); border: 1px solid var(--dsw-alias-border-l2, #222); border-radius: 8px; padding: 12px 14px; display: flex; flex-direction: column; gap: 4px; }
368
+ .dsh-cron-stat-val { font-size: 18px; font-weight: 700; color: var(--dsw-alias-label-primary, #fff); }
369
+ .dsh-cron-stat-lbl { font-size: 12px; color: var(--dsw-alias-label-secondary, #777); margin-top: 0; }
370
+ .dsh-cron-oneshot-tag { display: inline-flex; align-items: center; font-size: 11px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--dsh-cron-accent); background: var(--dsh-cron-accent-bg); color: var(--dsh-cron-accent); font-weight: 500; }
371
+ .dsh-cron-cost-tag { display: inline-flex; align-items: center; font-size: 11px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--dsh-cron-success); background: var(--dsh-cron-success-bg); color: var(--dsh-cron-success); font-weight: 500; }
372
+ .dsh-cron-tokens-tag { display: inline-flex; align-items: center; font-size: 11px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--dsh-cron-info); background: var(--dsh-cron-info-bg); color: var(--dsh-cron-info); }
316
373
  .dsh-cron-checkbox-row { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--dsw-alias-label-secondary, #ccc); cursor: pointer; user-select: none; }
317
374
  .dsh-cron-checkbox-row input { width: 15px; height: 15px; margin: 0; cursor: pointer; accent-color: var(--dsw-alias-label-primary, #ededed); }
318
375
  .dsh-cron-hint { font-size: 12px; color: var(--dsw-alias-label-tertiary, #888); margin-top: 4px; }
@@ -320,6 +377,8 @@ window.__ModuleLoader__.load({
320
377
  .dsh-cron-status-banner[data-type="success"] { background: var(--dsh-cron-success-bg); border: 1px solid var(--dsh-cron-success); color: var(--dsh-cron-success); }
321
378
  .dsh-cron-status-banner[data-type="error"] { background: var(--dsh-cron-danger-bg); border: 1px solid var(--dsh-cron-danger); color: var(--dsh-cron-danger); }
322
379
  .dsh-cron-status-banner[data-type="info"] { background: var(--dsh-cron-info-bg); border: 1px solid var(--dsh-cron-info); color: var(--dsh-cron-info); }
380
+ @keyframes dsh-cron-pulse-ring { 0% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.5); } 70% { box-shadow: 0 0 0 9px rgba(16, 185, 129, 0); } 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); } }
381
+ .dsh-cron-pulse { animation: dsh-cron-pulse-ring 1.6s infinite; border-color: var(--dsh-cron-success); color: var(--dsh-cron-success); }
323
382
  `;
324
383
 
325
384
  function ensureStyles() {
@@ -441,8 +500,26 @@ window.__ModuleLoader__.load({
441
500
  const [formTimeoutSeconds, setFormTimeoutSeconds] = React.useState(1800);
442
501
  const [formOverlapPolicy, setFormOverlapPolicy] = React.useState('skip');
443
502
  const [formKanbanMode, setFormKanbanMode] = React.useState('none');
503
+ const [formTimezone, setFormTimezone] = React.useState('');
504
+ const [formMisfirePolicy, setFormMisfirePolicy] = React.useState('skip');
505
+ const [formMaxRetries, setFormMaxRetries] = React.useState(0);
506
+ const [formPermissionPreset, setFormPermissionPreset] = React.useState('default');
444
507
  const [stats, setStats] = React.useState(null);
445
508
 
509
+ // Live execution indicator (#35): a light 1s tick only while some task
510
+ // is actually running.
511
+ const tasksRef = React.useRef([]);
512
+ const [nowTick, setNowTick] = React.useState(0);
513
+ React.useEffect(() => {
514
+ tasksRef.current = tasks;
515
+ }, [tasks]);
516
+ React.useEffect(() => {
517
+ const tick = setInterval(() => {
518
+ if (tasksRef.current.some((t2) => t2.running)) setNowTick((v) => v + 1);
519
+ }, 1000);
520
+ return () => clearInterval(tick);
521
+ }, []);
522
+
446
523
  // Settings modal state
447
524
  const [settingsModalOpen, setSettingsModalOpen] = React.useState(false);
448
525
  const [settingsBotToken, setSettingsBotToken] = React.useState('');
@@ -641,6 +718,10 @@ window.__ModuleLoader__.load({
641
718
  setFormTimeoutSeconds(1800);
642
719
  setFormOverlapPolicy('skip');
643
720
  setFormKanbanMode('none');
721
+ setFormTimezone('');
722
+ setFormMisfirePolicy('skip');
723
+ setFormMaxRetries(0);
724
+ setFormPermissionPreset('default');
644
725
  if (preset) {
645
726
  setFormTitle(preset.title || '');
646
727
  setFormSchedule(preset.schedule || '');
@@ -691,6 +772,10 @@ window.__ModuleLoader__.load({
691
772
  setFormTimeoutSeconds(task.timeoutSeconds !== undefined ? task.timeoutSeconds : 1800);
692
773
  setFormOverlapPolicy(task.overlapPolicy || 'skip');
693
774
  setFormKanbanMode(task.kanbanMode || 'none');
775
+ setFormTimezone(task.timezone || '');
776
+ setFormMisfirePolicy(task.misfirePolicy || 'skip');
777
+ setFormMaxRetries(task.maxRetries !== undefined ? task.maxRetries : 0);
778
+ setFormPermissionPreset(task.permissionPreset || 'default');
694
779
  if (task.provider) loadModels(task.provider);
695
780
  else if (defaultModelInfo?.provider) loadModels(defaultModelInfo.provider);
696
781
  loadTaskHistory(task.id);
@@ -721,6 +806,10 @@ window.__ModuleLoader__.load({
721
806
  onlyOnFailure: formOnlyOnFailure,
722
807
  timeoutSeconds: Number(formTimeoutSeconds) || 1800,
723
808
  overlapPolicy: formOverlapPolicy,
809
+ timezone: formTimezone.trim(),
810
+ misfirePolicy: formMisfirePolicy,
811
+ maxRetries: Number(formMaxRetries) || 0,
812
+ permissionPreset: formPermissionPreset,
724
813
  kanbanMode: formKanbanMode,
725
814
  };
726
815
  // Cross-origin hardening: script tasks need the explicit confirm
@@ -897,7 +986,7 @@ window.__ModuleLoader__.load({
897
986
  },
898
987
  React.createElement('button', {
899
988
  type: 'button',
900
- className: 'dsh-cron-task-status-btn',
989
+ className: 'dsh-cron-task-status-btn' + (task.running ? ' dsh-cron-pulse' : ''),
901
990
  'data-active': task.status === 'active' ? 'true' : undefined,
902
991
  title: task.status === 'active' ? T_KEY(t, 'actions.pause') : T_KEY(t, 'actions.resume'),
903
992
  'aria-label': task.status === 'active' ? T_KEY(t, 'actions.pause') : T_KEY(t, 'actions.resume'),
@@ -919,7 +1008,10 @@ window.__ModuleLoader__.load({
919
1008
  ),
920
1009
  React.createElement('div', { className: 'dsh-cron-task-sched' },
921
1010
  task.scheduleText || task.schedule,
922
- task.nextRunAt ? T_KEY(t, 'list.next', { time: new Date(task.nextRunAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }) : ''
1011
+ task.nextRunAt ? T_KEY(t, 'list.next', { time: new Date(task.nextRunAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }) : '',
1012
+ task.running && task.runningSince
1013
+ ? ' · ' + T_KEY(t, 'list.runningNow') + ' ' + Math.max(0, Math.round((Date.now() - task.runningSince) / 1000)) + 's'
1014
+ : ''
923
1015
  ),
924
1016
  task.prompt ? React.createElement('div', {
925
1017
  className: 'dsh-cron-task-prompt-preview',
@@ -945,7 +1037,7 @@ window.__ModuleLoader__.load({
945
1037
  }, React.createElement('span', svgProps(ICON_EDIT))),
946
1038
  React.createElement('button', {
947
1039
  type: 'button',
948
- className: 'dsh-cron-icon-btn',
1040
+ className: 'dsh-cron-icon-btn dsh-cron-danger',
949
1041
  title: T_KEY(t, 'actions.delete'),
950
1042
  'aria-label': T_KEY(t, 'actions.delete'),
951
1043
  onClick: () => handleDelete(task.id)
@@ -1042,7 +1134,13 @@ window.__ModuleLoader__.load({
1042
1134
  )
1043
1135
  ),
1044
1136
  run.output && React.createElement('div', { className: 'dsh-cron-history-output' }, run.output),
1045
- run.error && React.createElement('div', { className: 'dsh-cron-history-output', style: { color: 'var(--dsh-cron-danger)', borderColor: 'var(--dsh-cron-danger-bg)' } }, run.error)
1137
+ run.error && React.createElement('div', { className: 'dsh-cron-history-output', style: { color: 'var(--dsh-cron-danger)', borderColor: 'var(--dsh-cron-danger-bg)' } }, run.error),
1138
+ run.sessionId && React.createElement('button', {
1139
+ type: 'button',
1140
+ className: 'dsh-cron-btn-secondary',
1141
+ style: { marginTop: '8px', fontSize: '12px', padding: '4px 10px' },
1142
+ onClick: () => openSession(ctx, run.sessionId)
1143
+ }, T_KEY(t, 'history.openSession'))
1046
1144
  ))
1047
1145
  ),
1048
1146
  React.createElement('div', { className: 'dsh-cron-modal-foot', style: { marginTop: '18px' } },
@@ -1114,7 +1212,52 @@ window.__ModuleLoader__.load({
1114
1212
  )
1115
1213
  )
1116
1214
  ),
1215
+ React.createElement('div', { className: 'dsh-cron-form-row' },
1216
+ React.createElement('div', { className: 'dsh-cron-form-group' },
1217
+ React.createElement('label', null, T_KEY(t, 'form.timezoneLabel')),
1218
+ React.createElement('input', {
1219
+ value: formTimezone,
1220
+ placeholder: T_KEY(t, 'form.timezonePlaceholder'),
1221
+ onChange: (e) => setFormTimezone(e.target.value)
1222
+ })
1223
+ ),
1224
+ React.createElement('div', { className: 'dsh-cron-form-group' },
1225
+ React.createElement('label', null, T_KEY(t, 'form.misfireLabel')),
1226
+ React.createElement('select', {
1227
+ value: formMisfirePolicy,
1228
+ onChange: (e) => setFormMisfirePolicy(e.target.value)
1229
+ },
1230
+ React.createElement('option', { value: 'skip' }, T_KEY(t, 'form.misfireSkip')),
1231
+ React.createElement('option', { value: 'runOnce' }, T_KEY(t, 'form.misfireRunOnce')),
1232
+ React.createElement('option', { value: 'catchUpAll' }, T_KEY(t, 'form.misfireCatchUp'))
1233
+ )
1234
+ )
1235
+ ),
1236
+ React.createElement('div', { className: 'dsh-cron-form-row' },
1117
1237
  React.createElement('div', { className: 'dsh-cron-form-group' },
1238
+ React.createElement('label', null, T_KEY(t, 'form.retriesLabel')),
1239
+ React.createElement('input', {
1240
+ type: 'number',
1241
+ min: 0,
1242
+ value: formMaxRetries,
1243
+ placeholder: '0',
1244
+ onChange: (e) => setFormMaxRetries(e.target.value)
1245
+ })
1246
+ ),
1247
+ React.createElement('div', { className: 'dsh-cron-form-group' },
1248
+ React.createElement('label', null, T_KEY(t, 'form.permissionLabel')),
1249
+ React.createElement('select', {
1250
+ value: formPermissionPreset,
1251
+ onChange: (e) => setFormPermissionPreset(e.target.value)
1252
+ },
1253
+ React.createElement('option', { value: 'default' }, T_KEY(t, 'form.permissionDefault')),
1254
+ React.createElement('option', { value: 'read-only' }, T_KEY(t, 'form.permissionReadonly')),
1255
+ React.createElement('option', { value: 'workspace-write' }, T_KEY(t, 'form.permissionWrite')),
1256
+ React.createElement('option', { value: 'full' }, T_KEY(t, 'form.permissionFull'))
1257
+ )
1258
+ )
1259
+ ),
1260
+ React.createElement('div', { className: 'dsh-cron-form-group' },
1118
1261
  React.createElement('label', null, T_KEY(t, 'form.kanbanLabel')),
1119
1262
  React.createElement('select', {
1120
1263
  value: formKanbanMode,
@@ -1666,7 +1809,7 @@ window.__ModuleLoader__.load({
1666
1809
  column.appendChild(container);
1667
1810
  if (ReactDOM && typeof ReactDOM.createRoot === 'function') {
1668
1811
  root = ReactDOM.createRoot(container);
1669
- root.render(React.createElement(CronScreen, { ctx, toggle, onClose: () => toggle.set(false), t: translate }));
1812
+ root.render(React.createElement(ErrorBoundary, null, React.createElement(CronScreen, { ctx, toggle, onClose: () => toggle.set(false), t: translate })));
1670
1813
  } else if (ReactDOM && typeof ReactDOM.render === 'function') {
1671
1814
  ReactDOM.render(React.createElement(CronScreen, { ctx, toggle, onClose: () => toggle.set(false), t: translate }), container);
1672
1815
  }
@@ -1752,12 +1895,15 @@ window.__ModuleLoader__.load({
1752
1895
  // The settings card lives ONLY in the plugins settings tab (#102): no
1753
1896
  // top-level section fallback — the flat sidebar list is a shared core
1754
1897
  // resource, and the card slot is proven on current builds (#85).
1898
+ function SettingsCardWithBoundary(props) {
1899
+ return React.createElement(ErrorBoundary, null, CronSettingsCard(props));
1900
+ }
1755
1901
  const cardOk = registerIntoMount(ctx, 'settings.plugin.item', {
1756
1902
  name: 'settings.plugin.item',
1757
1903
  key: NS,
1758
1904
  locale: NS,
1759
1905
  inject: () => ({ ctx, toggle }),
1760
- }, CronSettingsCard);
1906
+ }, SettingsCardWithBoundary);
1761
1907
  if (!cardOk) {
1762
1908
  console.warn('[dsh-cron] settings.plugin.item slot unavailable — settings card not registered');
1763
1909
  }
package/lib/http-utils.js CHANGED
@@ -8,12 +8,15 @@ export const MAX_BODY_BYTES = 1024 * 1024;
8
8
  export const SCRIPT_CONFIRM_HEADER = 'x-dsh-cron-confirm';
9
9
 
10
10
  /**
11
- * Parse a JSON request body with a hard size cap.
11
+ * Parse a JSON request body with a hard byte size cap.
12
+ * Chunks are accumulated as Buffers so multi-byte UTF-8 characters split
13
+ * across chunk boundaries decode correctly (#106, #97 follow-up).
12
14
  * Rejects with err.statusCode = 413 when the body exceeds the limit.
13
15
  */
14
16
  export function parseJsonBody(req, limit = MAX_BODY_BYTES) {
15
17
  return new Promise((resolve, reject) => {
16
- let data = '';
18
+ const chunks = [];
19
+ let bytes = 0;
17
20
  let settled = false;
18
21
  const fail = (err) => {
19
22
  if (settled) return;
@@ -22,17 +25,20 @@ export function parseJsonBody(req, limit = MAX_BODY_BYTES) {
22
25
  };
23
26
  req.on('data', (chunk) => {
24
27
  if (settled) return;
25
- data += chunk;
26
- if (data.length > limit) {
28
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
29
+ bytes += buf.length;
30
+ if (bytes > limit) {
27
31
  fail(Object.assign(new Error('Request body exceeds the allowed size'), { statusCode: 413 }));
28
32
  req.destroy();
33
+ return;
29
34
  }
35
+ chunks.push(buf);
30
36
  });
31
37
  req.on('end', () => {
32
38
  if (settled) return;
33
39
  settled = true;
34
40
  try {
35
- resolve(data ? JSON.parse(data) : {});
41
+ resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {});
36
42
  } catch (err) {
37
43
  reject(err);
38
44
  }
package/lib/index.js CHANGED
@@ -24,6 +24,10 @@ export const Config = z.object({
24
24
  notifyTelegram: z.boolean().default(false).description('Deliver task reports to Telegram for every task'),
25
25
  onlyOnFailure: z.boolean().default(false).description('Deliver reports only for failed runs'),
26
26
  kanbanBaseUrl: z.string().default('http://127.0.0.1:3000').description('Base URL of the dsh-kanban HTTP API'),
27
+ defaultTimezone: z.string().default('').description('Default IANA time zone for schedules (empty = server local)'),
28
+ maxConcurrent: z.number().default(0).description('Max parallel task runs (0 = unlimited)'),
29
+ heartbeatUrl: z.string().default('').description('Dead man\'s snitch URL pinged on the heartbeat interval'),
30
+ heartbeatIntervalSec: z.number().default(0).description('Heartbeat ping interval in seconds (0 = off)'),
27
31
  });
28
32
 
29
33
  const PATCHABLE_TASK_FIELDS = [
@@ -35,6 +39,11 @@ const PATCHABLE_TASK_FIELDS = [
35
39
  'delivery',
36
40
  'provider',
37
41
  'model',
42
+ 'timezone',
43
+ 'misfirePolicy',
44
+ 'maxRetries',
45
+ 'retryBackoffMs',
46
+ 'permissionPreset',
38
47
  'notifyTelegram',
39
48
  'onlyOnFailure',
40
49
  'timeoutSeconds',
@@ -71,7 +80,7 @@ async function readBody(req, res) {
71
80
  * snapshot stays authoritative (#102). Returns null when the settings
72
81
  * service is unavailable (headless profiles fall back to store-only writes).
73
82
  */
74
- async function applySettingsToScope(settingsScope, body) {
83
+ export async function applySettingsToScope(settingsScope, body) {
75
84
  if (!settingsScope || typeof settingsScope.set !== 'function') return null;
76
85
  const errors = [];
77
86
  for (const key of ['botToken', 'chatId', 'notifyTelegram', 'onlyOnFailure', 'kanbanBaseUrl']) {
@@ -85,6 +94,18 @@ async function applySettingsToScope(settingsScope, body) {
85
94
  return errors;
86
95
  }
87
96
 
97
+ /**
98
+ * Drop values that must never re-enter the store from the client: a masked
99
+ * secret echoed back by the settings UI would overwrite the real token.
100
+ */
101
+ export function sanitizeSettingsPayload(body) {
102
+ const payload = Object.assign({}, body);
103
+ if (typeof payload.botToken === 'string' && (payload.botToken.includes('••') || payload.botToken.includes('****'))) {
104
+ delete payload.botToken;
105
+ }
106
+ return payload;
107
+ }
108
+
88
109
  /**
89
110
  * Shared implementation for the /dsh-cron/tasks... and /dsh-cron/action...
90
111
  * route families — the #66 alias is kept alive by the same code path instead
@@ -92,9 +113,9 @@ async function applySettingsToScope(settingsScope, body) {
92
113
  *
93
114
  * IMPORTANT: prefix routes must be registered WITHOUT a trailing slash —
94
115
  * with one, the core degrades matching to exact-path equality and every
95
- * deeper path unrouted (#96).
116
+ * deeper path unrouted (#96). Exported for unit tests.
96
117
  */
97
- function createCronApiHandler(store, scheduler, collection) {
118
+ export function createCronApiHandler(store, scheduler, collection) {
98
119
  return async function handleCronApi(req, res) {
99
120
  try {
100
121
  const url = new URL(req.url, 'http://127.0.0.1');
@@ -112,7 +133,11 @@ function createCronApiHandler(store, scheduler, collection) {
112
133
  if (req.method === 'GET') {
113
134
  const status = url.searchParams.get('status') || 'all';
114
135
  const query = url.searchParams.get('query') || '';
115
- const list = store.list({ status, query });
136
+ const list = store.list({ status, query }).map((t) => ({
137
+ ...t,
138
+ running: scheduler.isRunning(t.id),
139
+ runningSince: scheduler.runningSince(t.id),
140
+ }));
116
141
  const stats = store.getAggregatedStats();
117
142
  sendJson(res, 200, { ok: true, tasks: list, recommendations: collection.recommendations, stats });
118
143
  return;
@@ -150,6 +175,11 @@ function createCronApiHandler(store, scheduler, collection) {
150
175
  timeoutSeconds: body.timeoutSeconds !== undefined ? Number(body.timeoutSeconds) : (current ? current.timeoutSeconds : 1800),
151
176
  overlapPolicy: body.overlapPolicy || (current ? current.overlapPolicy : 'skip'),
152
177
  kanbanMode: body.kanbanMode || (current ? current.kanbanMode : 'none'),
178
+ timezone: body.timezone !== undefined ? String(body.timezone).trim() : (current ? current.timezone : ''),
179
+ misfirePolicy: ['skip', 'runOnce', 'catchUpAll'].includes(body.misfirePolicy) ? body.misfirePolicy : (current ? (current.misfirePolicy || 'skip') : 'skip'),
180
+ maxRetries: body.maxRetries !== undefined ? Math.max(0, Number(body.maxRetries) || 0) : (current ? (Number(current.maxRetries) || 0) : 0),
181
+ retryBackoffMs: body.retryBackoffMs !== undefined ? Math.max(1000, Number(body.retryBackoffMs) || 30000) : (current ? (Number(current.retryBackoffMs) || 30000) : 30000),
182
+ permissionPreset: ['default', 'read-only', 'workspace-write', 'full'].includes(body.permissionPreset) ? body.permissionPreset : (current ? (current.permissionPreset || 'default') : 'default'),
153
183
  oneShot: Boolean(parsed.isOneShot || body.oneShot),
154
184
  });
155
185
  if (task.status === 'active') {
@@ -174,6 +204,10 @@ function createCronApiHandler(store, scheduler, collection) {
174
204
  }
175
205
 
176
206
  if (req.method === 'POST' && action === 'run') {
207
+ if (!store.get(id)) {
208
+ sendJson(res, 404, { ok: false, error: 'Task not found' });
209
+ return;
210
+ }
177
211
  await scheduler.triggerManualRun(id);
178
212
  sendJson(res, 200, { ok: true, task: store.get(id) });
179
213
  return;
@@ -259,9 +293,9 @@ function createCronApiHandler(store, scheduler, collection) {
259
293
 
260
294
  /**
261
295
  * Single implementation behind the cron_create_task / cron_schedule_task
262
- * alias pair (#70, #92).
296
+ * alias pair (#70, #92). Exported for unit tests.
263
297
  */
264
- function executeCreateTask(store, scheduler, args) {
298
+ export function executeCreateTask(store, scheduler, args) {
265
299
  const taskType = args.type === 'script' ? 'script' : 'llm';
266
300
  const parsed = parseScheduleExpression(args.schedule);
267
301
  const task = store.set({
@@ -278,6 +312,11 @@ function executeCreateTask(store, scheduler, args) {
278
312
  onlyOnFailure: Boolean(args.onlyOnFailure),
279
313
  timeoutSeconds: Number(args.timeoutSeconds) || 1800,
280
314
  overlapPolicy: args.overlapPolicy || 'skip',
315
+ timezone: args.timezone ? String(args.timezone).trim() : '',
316
+ misfirePolicy: ['skip', 'runOnce', 'catchUpAll'].includes(args.misfirePolicy) ? args.misfirePolicy : 'skip',
317
+ maxRetries: Number(args.maxRetries) > 0 ? Number(args.maxRetries) : 0,
318
+ retryBackoffMs: Number(args.retryBackoffMs) > 0 ? Number(args.retryBackoffMs) : 30000,
319
+ permissionPreset: ['default', 'read-only', 'workspace-write', 'full'].includes(args.permissionPreset) ? args.permissionPreset : 'default',
281
320
  kanbanMode: args.kanbanMode || 'none',
282
321
  oneShot: Boolean(parsed.isOneShot),
283
322
  });
@@ -291,16 +330,21 @@ function executeCreateTask(store, scheduler, args) {
291
330
 
292
331
  const createTaskParameters = {
293
332
  title: { type: 'string', description: 'Short task name', required: true },
294
- schedule: { type: 'string', description: 'Schedule: cron ("0 9 * * *"), interval ("every 2h") or one-shot ("in 30m", "at: 2026-09-05T15:00:00Z")', required: true },
333
+ schedule: { type: 'string', description: 'Schedule: cron ("0 9 * * *"), shorthand ("@daily", "@every 30m"), interval ("every 2h") or one-shot ("in 30m", "at: 2026-09-05T15:00:00Z")', required: true },
295
334
  prompt: { type: 'string', description: 'Prompt/instruction for the agent at run time', required: true },
296
335
  type: { type: 'string', enum: ['prompt', 'script'], description: 'Task type' },
297
336
  delivery: { type: 'string', enum: ['current', 'isolated'], description: 'Run mode: current (in the current chat) or isolated (separate session)' },
298
337
  provider: { type: 'string', description: 'Model provider (optional)' },
299
338
  model: { type: 'string', description: 'Model (optional)' },
339
+ timezone: { type: 'string', description: 'IANA time zone for the schedule, e.g. "Europe/Berlin" (optional, default = server local)' },
300
340
  notifyTelegram: { type: 'boolean', description: 'Send the run report to Telegram' },
301
341
  onlyOnFailure: { type: 'boolean', description: 'Send reports only for failures' },
302
342
  timeoutSeconds: { type: 'number', description: 'Execution time limit in seconds' },
303
343
  overlapPolicy: { type: 'string', enum: ['skip', 'queue', 'replace'], description: 'Overlap policy' },
344
+ misfirePolicy: { type: 'string', enum: ['skip', 'runOnce', 'catchUpAll'], description: 'What to do with a run missed during downtime' },
345
+ maxRetries: { type: 'number', description: 'Automatic retry attempts on failure (0 = off)' },
346
+ retryBackoffMs: { type: 'number', description: 'Base backoff between retries in ms, doubled per attempt' },
347
+ permissionPreset: { type: 'string', enum: ['default', 'read-only', 'workspace-write', 'full'], description: 'Permission preset applied to the task session' },
304
348
  kanbanMode: { type: 'string', enum: ['none', 'on_failure', 'always'], description: 'Create a dsh-kanban card for runs' },
305
349
  };
306
350
 
@@ -320,7 +364,10 @@ const createTaskOutput = {
320
364
  export function apply(ctx, config) {
321
365
  const store = new TaskStore();
322
366
  const runner = new SessionRunner(ctx);
323
- const scheduler = new TaskScheduler(store, (task, opts) => runner.execute(task, opts));
367
+ const scheduler = new TaskScheduler(store, (task, opts) => runner.execute(task, opts), {
368
+ maxConcurrent: config && config.maxConcurrent,
369
+ defaultTimezone: config && config.defaultTimezone,
370
+ });
324
371
 
325
372
  // Register settings in the Harness settings service (#71, #102): the scope
326
373
  // is the source of truth for the core, scope.watch mirrors values into the
@@ -339,6 +386,9 @@ export function apply(ctx, config) {
339
386
  }
340
387
  });
341
388
  }
389
+ sctx.effect(() => () => {
390
+ cronSettingsScope = null;
391
+ });
342
392
  } catch (e) {
343
393
  console.warn('[dsh-cron] settings registration warning:', e.message);
344
394
  }
@@ -346,8 +396,21 @@ export function apply(ctx, config) {
346
396
 
347
397
  scheduler.start();
348
398
 
399
+ // Heartbeat / dead man's snitch (#16): an optional periodic GET ping so an
400
+ // external monitor can alert when this scheduler stops responding.
401
+ let heartbeatTimer = null;
402
+ if (config && config.heartbeatUrl && Number(config.heartbeatIntervalSec) > 0) {
403
+ const intervalMs = Number(config.heartbeatIntervalSec) * 1000;
404
+ heartbeatTimer = setInterval(() => {
405
+ fetch(config.heartbeatUrl, { method: 'GET' }).catch((err) => {
406
+ console.warn('[dsh-cron] heartbeat ping failed:', err.message);
407
+ });
408
+ }, intervalMs);
409
+ }
410
+
349
411
  ctx.effect(() => {
350
412
  return () => {
413
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
351
414
  scheduler.stopAll();
352
415
  };
353
416
  }, 'dsh-cron: lifecycle');
@@ -432,10 +495,7 @@ export function apply(ctx, config) {
432
495
  if (error) return;
433
496
  // Masked secrets echoed back by the client must never overwrite the
434
497
  // stored token (store.saveSettings guards this too, #86-era).
435
- const payload = Object.assign({}, body);
436
- if (typeof payload.botToken === 'string' && (payload.botToken.includes('••') || payload.botToken.includes('****'))) {
437
- delete payload.botToken;
438
- }
498
+ const payload = sanitizeSettingsPayload(body);
439
499
  const scopeErrors = await applySettingsToScope(cronSettingsScope, payload);
440
500
  if (scopeErrors === null) {
441
501
  store.saveSettings(payload);
@@ -450,6 +510,27 @@ export function apply(ctx, config) {
450
510
  }
451
511
  }), 'dsh-cron: /settings');
452
512
 
513
+ // GET /dsh-cron/heartbeat (#16): liveness probe for external monitors.
514
+ ctx.effect(() => ctx.webServer.register({
515
+ kind: 'exact',
516
+ path: '/dsh-cron/heartbeat',
517
+ handler: async (req, res) => {
518
+ try {
519
+ const tasks = store.list({ status: 'active' });
520
+ const lastRuns = tasks.map((t) => t.lastRunAt || 0);
521
+ sendJson(res, 200, {
522
+ ok: true,
523
+ activeTasks: tasks.length,
524
+ running: scheduler.runningCount(),
525
+ lastRunAt: lastRuns.length ? Math.max(...lastRuns) : null,
526
+ serverTime: Date.now(),
527
+ });
528
+ } catch (err) {
529
+ sendJson(res, 500, { ok: false, error: err.message });
530
+ }
531
+ }
532
+ }), 'dsh-cron: /heartbeat');
533
+
453
534
  // POST /dsh-cron/telegram/test
454
535
  ctx.effect(() => ctx.webServer.register({
455
536
  kind: 'exact',
package/lib/runner.js CHANGED
@@ -130,6 +130,18 @@ export class SessionRunner {
130
130
  agentOptions: provider && model ? { provider, model } : undefined,
131
131
  });
132
132
 
133
+ // Per-job permission preset (#32): read-only | workspace-write | full.
134
+ if (task.permissionPreset && task.permissionPreset !== 'default') {
135
+ try {
136
+ const presets = this.ctx.get && this.ctx.get('permissionPresets');
137
+ if (presets && typeof presets.set === 'function') {
138
+ presets.set(handle.agent.session, task.permissionPreset);
139
+ }
140
+ } catch (ppErr) {
141
+ console.warn('[dsh-cron] permission preset not applied:', ppErr.message);
142
+ }
143
+ }
144
+
133
145
  await raceAbort(handle.agent.whenIdle());
134
146
  handle.agent.followup(createUserMessage({
135
147
  content: [{ type: 'text', text: task.prompt }],
@@ -159,9 +171,14 @@ export class SessionRunner {
159
171
  return {
160
172
  output: `[dsh-cron] Agent finished turn for task "${task.title}" (session: ${handle.agent.session?.id || 'cron'})`,
161
173
  usage,
162
- costUsd
174
+ costUsd,
175
+ sessionId: handle.agent.session?.id || null
163
176
  };
164
177
  } finally {
178
+ let archivedSessionId = null;
179
+ try {
180
+ archivedSessionId = (handle && handle.agent && handle.agent.session && handle.agent.session.id) || null;
181
+ } catch {}
165
182
  try {
166
183
  if (handle && typeof handle.dispose === 'function') {
167
184
  await handle.dispose();
@@ -171,6 +188,17 @@ export class SessionRunner {
171
188
  } catch (disposeErr) {
172
189
  console.warn('[dsh-cron] session dispose notice:', disposeErr.message);
173
190
  }
191
+ // Best-effort: keep ephemeral cron sessions out of the main chat
192
+ // list (#30). The archive call degrades silently when the core
193
+ // does not expose it.
194
+ if (archivedSessionId && this.ctx && this.ctx.get) {
195
+ try {
196
+ const sessions = this.ctx.get('sessions');
197
+ if (sessions && typeof sessions.archive === 'function') {
198
+ sessions.archive(archivedSessionId);
199
+ }
200
+ } catch {}
201
+ }
174
202
  }
175
203
  } catch (err) {
176
204
  console.error(`[dsh-cron] agent execution failed:`, err.message);
package/lib/scheduler.js CHANGED
@@ -99,8 +99,19 @@ export function parseScheduleExpression(input) {
99
99
  }
100
100
  }
101
101
 
102
- // 4. Friendly recurring aliases (English plus accepted Russian input aliases)
102
+ // 4. Standard @-shorthands (#15)
103
103
  const lower = str.toLowerCase();
104
+ if (lower === '@hourly') return { cronPattern: '0 * * * *', humanText: 'Every hour' };
105
+ if (lower === '@daily' || lower === '@midnight') return { cronPattern: '0 0 * * *', humanText: 'Every day at 00:00' };
106
+ if (lower === '@weekly') return { cronPattern: '0 0 * * 0', humanText: 'Every week on Sunday at 00:00' };
107
+ if (lower === '@monthly') return { cronPattern: '0 0 1 * *', humanText: 'Every month on the 1st at 00:00' };
108
+ if (lower === '@yearly' || lower === '@annually') return { cronPattern: '0 0 1 1 *', humanText: 'Every year on Jan 1 at 00:00' };
109
+ const everyAlias = str.match(/^@every\s+(\d+)\s*(sec|seconds?|m|min|minutes?|h|hr|hours?|d|days?)$/i);
110
+ if (everyAlias) {
111
+ return parseScheduleExpression(`every ${everyAlias[1]} ${everyAlias[2]}`);
112
+ }
113
+
114
+ // 5. Friendly recurring aliases (English plus accepted Russian input aliases)
104
115
  if (lower === 'daily' || lower === 'каждый день') {
105
116
  return { cronPattern: '0 9 * * *', humanText: 'Every day at 09:00' };
106
117
  }
@@ -126,35 +137,75 @@ export function parseScheduleExpression(input) {
126
137
  }
127
138
 
128
139
  export class TaskScheduler {
129
- constructor(store, executeFn) {
140
+ constructor(store, executeFn, options = {}) {
130
141
  this.store = store;
131
142
  this.executeFn = executeFn;
143
+ this.maxConcurrent = Number(options.maxConcurrent) > 0 ? Number(options.maxConcurrent) : 0; // 0 = unlimited (#52)
144
+ this.defaultTimezone = options.defaultTimezone || ''; // #13
132
145
  this.jobs = new Map(); // taskId -> Cron instance
133
146
  this.timers = new Map(); // taskId -> setTimeout handle (for one-shot tasks)
147
+ this.retryTimers = new Map(); // taskId -> setTimeout handle (for #18 retries)
134
148
  this.running = new Map(); // taskId -> { controller, startedAt, queueCount }
135
149
  }
136
150
 
151
+ isRunning(taskId) {
152
+ return this.running.has(taskId);
153
+ }
154
+
155
+ runningCount() {
156
+ return this.running.size;
157
+ }
158
+
159
+ runningSince(taskId) {
160
+ const run = this.running.get(taskId);
161
+ return run ? run.startedAt : null;
162
+ }
163
+
164
+ clearRetryTimer(taskId) {
165
+ const timer = this.retryTimers.get(taskId);
166
+ if (timer !== undefined) {
167
+ clearTimeout(timer);
168
+ this.retryTimers.delete(taskId);
169
+ }
170
+ }
171
+
137
172
  start() {
138
173
  const tasks = this.store.list();
139
174
  const now = Date.now();
140
175
  for (const task of tasks) {
141
- if (task.status === 'active') {
142
- // Detect missed execution if daemon was offline (threshold: > 5 minutes)
143
- if (task.nextRunAt && (now - task.nextRunAt) > 5 * 60 * 1000) {
144
- const missedAt = task.nextRunAt;
145
- console.warn(`[dsh-cron] Task "${task.title}" (${task.id}) missed scheduled run at ${new Date(missedAt).toISOString()}`);
146
- this.store.recordRun(task.id, {
147
- at: missedAt,
148
- status: 'missed',
149
- durationMs: 0,
150
- output: `Skipped: the service was offline or restarting at the scheduled time (${new Date(missedAt).toISOString()})`,
151
- error: null,
152
- usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
153
- costUsd: 0,
154
- });
176
+ if (task.status !== 'active') continue;
177
+ // Detect missed execution if daemon was offline (threshold: > 5 minutes)
178
+ const missed = task.nextRunAt && (now - task.nextRunAt) > 5 * 60 * 1000;
179
+ if (missed) {
180
+ const missedAt = task.nextRunAt;
181
+ const policy = task.misfirePolicy || 'skip'; // #12: skip | runOnce | catchUpAll
182
+ console.warn(`[dsh-cron] Task "${task.title}" (${task.id}) missed scheduled run at ${new Date(missedAt).toISOString()} (misfire: ${policy})`);
183
+ this.store.recordRun(task.id, {
184
+ at: missedAt,
185
+ status: 'missed',
186
+ durationMs: 0,
187
+ output: `Skipped: the service was offline or restarting at the scheduled time (${new Date(missedAt).toISOString()})`,
188
+ error: null,
189
+ usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
190
+ costUsd: 0,
191
+ });
192
+ if (policy === 'skip') {
193
+ // A missed one-shot has no future slot left: retire it instead of
194
+ // arming a timer in the past.
195
+ if (task.oneShot) {
196
+ task.status = 'completed';
197
+ this.store.set(task);
198
+ continue;
199
+ }
200
+ this.scheduleTask(task);
201
+ continue;
155
202
  }
203
+ // runOnce / catchUpAll: run late exactly once, then resume the schedule.
156
204
  this.scheduleTask(task);
205
+ setImmediate(() => this.runTask(task.id));
206
+ continue;
157
207
  }
208
+ this.scheduleTask(task);
158
209
  }
159
210
  }
160
211
 
@@ -173,6 +224,13 @@ export class TaskScheduler {
173
224
  }
174
225
  this.timers.clear();
175
226
 
227
+ for (const [id, timer] of this.retryTimers.entries()) {
228
+ try {
229
+ clearTimeout(timer);
230
+ } catch {}
231
+ }
232
+ this.retryTimers.clear();
233
+
176
234
  for (const [, run] of this.running.entries()) {
177
235
  try {
178
236
  run.controller.abort(new Error('Scheduler stopped'));
@@ -193,9 +251,11 @@ export class TaskScheduler {
193
251
  }
194
252
 
195
253
  if (task.status !== 'active') return;
254
+ this.clearRetryTimer(task.id);
196
255
 
197
256
  try {
198
257
  const parsed = parseScheduleExpression(task.schedule);
258
+ const timezone = task.timezone || this.defaultTimezone || undefined;
199
259
 
200
260
  // Handle One-shot task
201
261
  if (parsed.isOneShot) {
@@ -216,7 +276,7 @@ export class TaskScheduler {
216
276
  }
217
277
 
218
278
  // Handle Recurring Cron job
219
- const job = new Cron(parsed.cronPattern, { protect: true }, async () => {
279
+ const job = new Cron(parsed.cronPattern, { protect: true, ...(timezone ? { timezone } : {}) }, async () => {
220
280
  await this.runTask(task.id);
221
281
  });
222
282
 
@@ -235,6 +295,23 @@ export class TaskScheduler {
235
295
  const task = this.store.get(taskId);
236
296
  if (!task) return;
237
297
 
298
+ // Global concurrency throttle (#52): a run that would exceed the limit is
299
+ // skipped and recorded; already-running tasks are never throttled against
300
+ // themselves.
301
+ if (!this.running.has(taskId) && this.maxConcurrent > 0 && this.running.size >= this.maxConcurrent) {
302
+ console.log(`[dsh-cron] Task "${task.title}" (${taskId}) skipped: concurrency limit (${this.maxConcurrent})`);
303
+ this.store.recordRun(taskId, {
304
+ at: Date.now(),
305
+ status: 'skipped',
306
+ durationMs: 0,
307
+ output: `Skipped: concurrency limit reached (${this.maxConcurrent} parallel runs)`,
308
+ error: null,
309
+ usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
310
+ costUsd: 0,
311
+ });
312
+ return;
313
+ }
314
+
238
315
  const overlapPolicy = task.overlapPolicy || 'skip';
239
316
 
240
317
  // Check overlap with currently running task
@@ -278,6 +355,7 @@ export class TaskScheduler {
278
355
  let error = null;
279
356
  let usage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 };
280
357
  let costUsd = 0;
358
+ let sessionId = null;
281
359
 
282
360
  try {
283
361
  if (typeof this.executeFn === 'function') {
@@ -286,6 +364,7 @@ export class TaskScheduler {
286
364
  output = res.output || '';
287
365
  usage = res.usage || usage;
288
366
  costUsd = res.costUsd || 0;
367
+ sessionId = res.sessionId || null;
289
368
  } else {
290
369
  output = String(res || '');
291
370
  }
@@ -307,6 +386,7 @@ export class TaskScheduler {
307
386
  error,
308
387
  usage,
309
388
  costUsd,
389
+ sessionId,
310
390
  };
311
391
  this.store.recordRun(taskId, runInfo);
312
392
 
@@ -383,6 +463,25 @@ export class TaskScheduler {
383
463
  }
384
464
  }
385
465
 
466
+ // 4. Retry with exponential backoff on failure (#18)
467
+ const failed = status === 'error' || status === 'timeout';
468
+ const maxRetries = Number(task.maxRetries) > 0 ? Number(task.maxRetries) : 0;
469
+ if (failed && maxRetries > 0 && (task.attempts || 0) < maxRetries && task.status !== 'paused') {
470
+ task.attempts = (task.attempts || 0) + 1;
471
+ const backoffMs = (Number(task.retryBackoffMs) > 0 ? Number(task.retryBackoffMs) : 30000) * Math.pow(2, task.attempts - 1);
472
+ const attempt = task.attempts;
473
+ this.store.set(task);
474
+ this.clearRetryTimer(taskId);
475
+ this.retryTimers.set(taskId, setTimeout(() => {
476
+ this.retryTimers.delete(taskId);
477
+ this.runTask(taskId);
478
+ }, backoffMs));
479
+ console.log(`[dsh-cron] retry ${attempt}/${maxRetries} for task "${task.title}" in ${backoffMs}ms`);
480
+ } else if (!failed && task.attempts) {
481
+ task.attempts = 0;
482
+ this.store.set(task);
483
+ }
484
+
386
485
  if (queuedCount > 0) {
387
486
  setImmediate(() => {
388
487
  this.runTask(taskId);
@@ -396,6 +495,7 @@ export class TaskScheduler {
396
495
  if (!task) return null;
397
496
  task.status = 'paused';
398
497
  task.nextRunAt = null;
498
+ this.clearRetryTimer(taskId);
399
499
  if (this.jobs.has(taskId)) {
400
500
  this.jobs.get(taskId).stop();
401
501
  this.jobs.delete(taskId);
@@ -434,40 +534,7 @@ export class TaskScheduler {
434
534
  }
435
535
  }
436
536
 
437
- removeTask(taskId) {
438
- if (this.jobs.has(taskId)) {
439
- this.jobs.get(taskId).stop();
440
- this.jobs.delete(taskId);
441
- }
442
- if (this.timers.has(taskId)) {
443
- clearTimeout(this.timers.get(taskId));
444
- this.timers.delete(taskId);
445
- }
446
- const running = this.running.get(taskId);
447
- if (running) {
448
- try {
449
- running.controller.abort(new Error('Task deleted'));
450
- } catch {}
451
- this.running.delete(taskId);
452
- }
453
- return this.store.delete(taskId);
454
- }
455
-
456
537
  triggerManualRun(taskId) {
457
538
  return this.runTask(taskId);
458
539
  }
459
-
460
- getNextRun(schedule) {
461
- try {
462
- const parsed = parseScheduleExpression(schedule);
463
- if (parsed.isOneShot) {
464
- return parsed.targetTimestamp;
465
- }
466
- const tempJob = new Cron(parsed.cronPattern);
467
- const next = tempJob.nextRun();
468
- return next ? next.getTime() : null;
469
- } catch {
470
- return null;
471
- }
472
- }
473
540
  }
package/lib/store.js CHANGED
@@ -212,6 +212,7 @@ export class TaskStore {
212
212
  error: runInfo.error ? String(runInfo.error).slice(0, 1000) : null,
213
213
  usage,
214
214
  costUsd,
215
+ sessionId: runInfo.sessionId || null,
215
216
  });
216
217
  if (runs.length > 50) runs.length = 50;
217
218
  this.history.set(id, runs);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Scheduled cron tasks, background automation and agent execution for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",