@goodandready/dsh-cron 0.2.15 → 0.2.17

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
@@ -392,6 +392,20 @@ Developer-facing, no behaviour change. `parseScheduleExpression` was split into
392
392
 
393
393
 
394
394
 
395
+ ### 29. Client Modular Decomposition & Theme Standardization (v0.2.17, #149, #150)
396
+ - **Modular Client Architecture (#150)**: Decomposed monolithic `lib/client.js` (~3950 lines) into 14 focused, single-responsibility fragments under `lib/client-src/` (none exceeding 580 lines). Integrated zero-dependency build script `scripts/build-client.mjs` wired into `package.json` (`build:client`, `pretest`). Development fragments are excluded from npm distribution via `"files": ["lib/*.js", ...]`.
397
+ - **DSH Theme Tokens & Visual Standardization (#149)**: Replaced inline styles on task type badges (`onSuccess`, `onFailure`, `heartbeat`, `targetSession`, `preflight`) with dedicated `.dsh-cron-tag-*` CSS classes powered by semantic `--dsh-cron-*` theme variables. Modal overlay now adapts dynamically using `var(--dsw-alias-bg-mask, rgba(0, 0, 0, 0.75))`, and keyframe pulse animations use theme variables without hardcoded RGBA.
398
+ - **Label Governance & Triage Audit (#95)**: Standardized 100% of repository issues and triage on the canonical repo-level label set.
399
+
400
+ ---
401
+
402
+ ### 28. In-App One-Click Auto-Updater & Error Resilience (v0.2.16, #147, #155, #156)
403
+ - **Plugin Self-Updater (#147)**: Added in-app one-click self-updating module (`lib/updater.js`) with `/api/dsh-cron/update` endpoint and dedicated Settings UI card. Automatically queries npm registry, compares semver versions including pre-releases, and upgrades `@goodandready/dsh-cron` in-place through the DSH CLI without manual SSH sessions. POST updates are protected via origin validation (`rejectCrossOrigin`).
404
+ - **Silent Failure Elimination (#156)**: Replaced silent empty catches with diagnostic logging: uncompleted import transaction rollbacks are surfaced to system logs with warning severity, dynamic core module fallback reasons are logged, and session opening failures inform the user with actionable notifications.
405
+ - **Dead Code & Export Hygiene (#155)**: Cleaned unused legacy helpers (`CHANNEL_LABELS`, `makeInspectAsk`), stripped unnecessary exports from 16 internal modules, and wired `supportsSilentRule` directly into execution pipeline.
406
+
407
+ ---
408
+
395
409
  ## 📦 Installation
396
410
 
397
411
  Install into your DeepSeek Harness web profile:
@@ -503,6 +517,7 @@ All endpoints are served by the DSH web server under `/dsh-cron/`. Read endpoint
503
517
  | `POST` | `/dsh-cron/kanban/test` | Create a Kanban connectivity-test card |
504
518
  | `*` | `/dsh-cron/action/:id/:action` | Legacy alias for the task action routes (`run`, `toggle`, `delete`, `history`) |
505
519
  | `GET` | `/dsh-cron/metrics` | Prometheus text exposition of task and run counters — never prompts or output (#53) |
520
+ | `GET` / `POST` | `/api/dsh-cron/update` | One-click plugin self-updater: query registry version and in-place upgrade (#147) |
506
521
  | `GET` / `POST` | `/dsh-cron/api/tasks` | External token-guarded surface: list / create-or-update (#54) |
507
522
  | `GET` / `DELETE` | `/dsh-cron/api/tasks/:id` | External token-guarded surface: read / delete (#54) |
508
523
  | `POST` | `/dsh-cron/api/tasks/:id/run` | External token-guarded surface: force a run (#54) |
package/README.ru.md CHANGED
@@ -268,6 +268,20 @@ cron_create_task({
268
268
 
269
269
 
270
270
 
271
+ ### 29. Модульная декомпозиция клиента и стандартизация тем оформления (v0.2.17, #149, #150)
272
+ - **Модульная архитектура клиентской части (#150)**: Монолитный файл `lib/client.js` (~3950 строк) декомпозирован на 14 независимых специализированных модулей в каталоге `lib/client-src/` (каждый строго <= 580 строк). Сборка `lib/client.js` выполняется легковесным скриптом `scripts/build-client.mjs` перед запуском тестов и сборки. Исходные фрагменты исключены из npm-дистрибутива через `"files": ["lib/*.js", ...]`.
273
+ - **Семантические токены тем DSH (#149)**: Инлайн-стили тегов типа задач (`onSuccess`, `onFailure`, `heartbeat`, `targetSession`, `preflight`) переведены на выделенные CSS-классы `.dsh-cron-tag-*` на базе переменных темы `--dsh-cron-*`. Оверлей модальных окон переведен на адаптивную маску `var(--dsw-alias-bg-mask, rgba(0, 0, 0, 0.75))`, а анимация пульсации избавлена от захардкоженных значений RGBA.
274
+ - **Аудит меток репозитория (#95)**: Подтверждена 100% консистентность использования канонического репозиторного набора меток и фильтрации задач.
275
+
276
+ ---
277
+
278
+ ### 28. Модуль самообновления в 1 клик и устойчивость к ошибкам (v0.2.16, #147, #155, #156)
279
+ - **Самообновление плагина (#147)**: Добавлен встроенный модуль обновления (`lib/updater.js`) с маршрутом `/api/dsh-cron/update` и карточкой в настройках Web UI. Автоматически опрашивает npm registry, корректно сравнивает semver-версии (включая пререлизы) и обновляет `@goodandready/dsh-cron` на лету через CLI DSH без необходимости входа по SSH. Запросы POST защищены проверкой источника (`rejectCrossOrigin`).
280
+ - **Устранение скрытых сбоев (#156)**: Ликвидированы пустые `catch`: незавершённые откаты транзакций импорта логируются с уровнем `warn`, причины fallback для динамических модулей ядра фиксируются в отладочном журнале, а при сбое открытия сессии пользователь получает понятное уведомление.
281
+ - **Гигиена кода и экспортов (#155)**: Удалены устаревшие неиспользуемые сущности (`CHANNEL_LABELS`, `makeInspectAsk`), снят лишний модификатор `export` с 16 внутренних функций и констант, а метод `supportsSilentRule` напрямую подключен в боевой пайплайн проверки тихих правил.
282
+
283
+ ---
284
+
271
285
  ## 📦 Установка
272
286
 
273
287
  ```bash
@@ -500,6 +514,7 @@ bash deploy.sh verify [exact-version]
500
514
  | `POST` | `/dsh-cron/kanban/test` | Тестовая карточка в Kanban |
501
515
  | `*` | `/dsh-cron/action/:id/:action` | Legacy-алиас действий над задачей (`run`, `toggle`, `delete`, `history`) |
502
516
  | `GET` | `/dsh-cron/metrics` | Текст в формате Prometheus: счётчики задач и запусков — без промптов и вывода (#53) |
517
+ | `GET` / `POST` | `/api/dsh-cron/update` | Самообновление плагина в 1 клик: запрос версии в реестре и обновление на лету (#147) |
503
518
  | `GET` / `POST` | `/dsh-cron/api/tasks` | Внешняя поверхность под токеном: список / создание-обновление (#54) |
504
519
  | `GET` / `DELETE` | `/dsh-cron/api/tasks/:id` | Внешняя поверхность под токеном: чтение / удаление (#54) |
505
520
  | `POST` | `/dsh-cron/api/tasks/:id/run` | Внешняя поверхность под токеном: принудительный запуск (#54) |
package/README.zh.md CHANGED
@@ -391,6 +391,20 @@ bash deploy.sh verify [exact-version]
391
391
 
392
392
 
393
393
 
394
+ ### 29. 客户端模块化解耦与原生主题标准化 (v0.2.17, #149, #150)
395
+ - **客户端模块化架构 (#150)**: 将庞大的单文件 `lib/client.js` (约 3950 行) 拆分为 `lib/client-src/` 下的 14 个高内聚模块文件 (各模块严格 <= 580 行)。集成零依赖构建脚本 `scripts/build-client.mjs` 并接入 `package.json` (`build:client`, `pretest`),通过 `"files": ["lib/*.js", ...]` 避免开发源码冗余打包进 npm 发布包。
396
+ - **DSH 语义化主题变量对齐 (#149)**: 将任务类型标签 (`onSuccess`, `onFailure`, `heartbeat`, `targetSession`, `preflight`) 的内联样式全部替换为基于主题变量的 `.dsh-cron-tag-*` 类;模态框遮罩层接入自适应主题遮罩变量 `var(--dsw-alias-bg-mask, rgba(0, 0, 0, 0.75))`,移除脉冲动画关键帧中的硬编码 RGBA。
397
+ - **标签治理与工单审计 (#95)**: 审计并确认全仓库 100% 统一规范使用仓库级标签集。
398
+
399
+ ---
400
+
401
+ ### 28. 应用内一键自更新与错误容错增强 (v0.2.16, #147, #155, #156)
402
+ - **插件自更新模块 (#147)**:新增应用内一键自更新机制 (`lib/updater.js`)、`/api/dsh-cron/update` 接口与设置面板专属卡片。自动轮询 npmjs 仓库最新版本,精准比对 Semver 版本号(含预发布版本),通过 DSH CLI 就地升级 `@goodandready/dsh-cron`,无需 SSH 终端操作。POST 请求受跨域与来源保护 (`rejectCrossOrigin`)。
403
+ - **消除静默异常与错误追踪 (#156)**:全面消除空 `catch` 异常压制:任务导入事务回滚失败记录 `warn` 级别日志、动态核心模块降级原因记录诊断日志、会话自动唤出失败向用户呈现友好提示。
404
+ - **死代码清理与模块导出精简 (#155)**:清理未引用的旧版实体 (`CHANNEL_LABELS`, `makeInspectAsk`),收回 16 个内部工具函数的暴露权限,并将 `supportsSilentRule` 接入静默规则核心执行流水线。
405
+
406
+ ---
407
+
394
408
  ## 📦 安装
395
409
 
396
410
  ```bash
@@ -500,6 +514,7 @@ dsh-cron:
500
514
  | `POST` | `/dsh-cron/kanban/test` | 创建 Kanban 连通性测试卡片 |
501
515
  | `*` | `/dsh-cron/action/:id/:action` | 任务操作路由的兼容别名(`run`、`toggle`、`delete`、`history`) |
502
516
  | `GET` | `/dsh-cron/metrics` | Prometheus 文本格式的任务与运行计数 —— 不含提示词与输出(#53) |
517
+ | `GET` / `POST` | `/api/dsh-cron/update` | 插件一键自更新:查询仓库最新版本并就地平滑升级 (#147) |
503
518
  | `GET` / `POST` | `/dsh-cron/api/tasks` | 令牌保护的外部接口:列表 / 创建或更新(#54) |
504
519
  | `GET` / `DELETE` | `/dsh-cron/api/tasks/:id` | 令牌保护的外部接口:读取 / 删除(#54) |
505
520
  | `POST` | `/dsh-cron/api/tasks/:id/run` | 令牌保护的外部接口:强制执行(#54) |
package/lib/api.js CHANGED
@@ -343,7 +343,9 @@ function applyImportPlan({ store, scheduler, plan }) {
343
343
  try {
344
344
  if (entry.previous) store.set(entry.previous);
345
345
  else store.delete(entry.id);
346
- } catch (_) {}
346
+ } catch (rollbackErr) {
347
+ console.warn(`[dsh-cron] import rollback failed for task "${entry.id}":`, rollbackErr?.message || rollbackErr);
348
+ }
347
349
  }
348
350
  return { imported: [], error: `Import failed and was rolled back: ${writeErr.message}` };
349
351
  }
package/lib/channels.js CHANGED
@@ -13,22 +13,12 @@ import { resolveTemplateText, truncateText } from './templates.js';
13
13
 
14
14
  export const CHANNEL_IDS = ['telegram', 'kanban', 'discord', 'slack', 'ntfy', 'bark', 'pushplus', 'tts', 'gitea'];
15
15
 
16
- export const CHANNEL_LABELS = {
17
- telegram: 'Telegram',
18
- kanban: 'dsh-kanban card',
19
- discord: 'Discord webhook',
20
- slack: 'Slack webhook',
21
- ntfy: 'ntfy push',
22
- bark: 'Bark push',
23
- pushplus: 'PushPlus',
24
- tts: 'Voice via dsh-tts',
25
- gitea: 'Gitea issue',
26
- };
16
+ // CHANNEL_LABELS removed (#155) — localized via lib/client.js
27
17
 
28
18
  const isFailed = (status) => status === 'error' || status === 'timeout';
29
19
 
30
20
  /** The message text for a channel: custom template, else built-in defaults. */
31
- export function messageTextFor(channelId, task, runInfo, settings = {}) {
21
+ function messageTextFor(channelId, task, runInfo, settings = {}) {
32
22
  const templates = (settings && settings.channelTemplates) || {};
33
23
  const template = templates[channelId] || settings.template || '';
34
24
  return resolveTemplateText({ template, task, runInfo });
@@ -247,7 +237,7 @@ async function postJson(http, url, headers, body, channelId, signal, timeoutMs)
247
237
  * One handler per channel. Each returns a small detail object and throws a
248
238
  * descriptive Error on failure; the router turns that into a failure entry.
249
239
  */
250
- export const CHANNEL_HANDLERS = {
240
+ const CHANNEL_HANDLERS = {
251
241
  async telegram({ task, runInfo, settings, secrets, http, signal, timeoutMs }) {
252
242
  const botToken = secrets.botToken || '';
253
243
  const chatId = String(settings.chatId || '').trim();
package/lib/chat-start.js CHANGED
@@ -24,7 +24,9 @@ export async function chatStartHandler(ctx, req, res, parseJsonBody, sendJson, r
24
24
  if (llmModule && typeof llmModule.createUserMessage === 'function') {
25
25
  createUserMessage = llmModule.createUserMessage;
26
26
  }
27
- } catch {}
27
+ } catch (err) {
28
+ (ctx.logger || console).debug?.('[dsh-cron] @deepseek-ai/dsh-llm createUserMessage unavailable, using identity fallback:', err?.message || err);
29
+ }
28
30
 
29
31
  let mintSessionId = () => `cron-setup-${randomUUID()}`;
30
32
  try {
@@ -33,7 +35,9 @@ export async function chatStartHandler(ctx, req, res, parseJsonBody, sendJson, r
33
35
  const sid = mintSessionId();
34
36
  mintSessionId = () => sessionModule.SessionId(sid);
35
37
  }
36
- } catch {}
38
+ } catch (err) {
39
+ (ctx.logger || console).debug?.('[dsh-cron] @deepseek-ai/dsh-session SessionId unavailable, using string fallback:', err?.message || err);
40
+ }
37
41
 
38
42
  const defaultSel = ctx.get('agentDefaultModel')?.currentSelection?.();
39
43
  const provider = body.provider || defaultSel?.provider;
package/lib/client.js CHANGED
@@ -44,6 +44,17 @@ window.__ModuleLoader__.load({
44
44
  // Locale dictionary: English is the canonical source language (Issue #87).
45
45
  // Russian and other languages are provided at runtime by the translation
46
46
  // plugin; no per-language duplicates are stored here.
47
+ // Core chevron primitive when the build ships it; own SVG fallback otherwise.
48
+ let CoreChevron = null;
49
+ try {
50
+ const primitives = require('@deepseek-ai/dsh-client-ui-primitives');
51
+ CoreChevron = primitives && primitives.IconChevronDownOutline14;
52
+ } catch (e) {}
53
+ const chevronNode = () => (CoreChevron ? React.createElement(CoreChevron) : React.createElement('span', { style: { display: 'flex', alignItems: 'center' }, dangerouslySetInnerHTML: { __html: ICON_CHEVRON_DOWN } }));
54
+
55
+ // Own React error boundary (#106): the core's SlotErrorBoundary renders an
56
+ // empty div on crash, so a slot failure would silently vanish. Ours keeps
57
+ // the failure visible with a retry.
47
58
  const STRINGS = {
48
59
  en: {
49
60
  'sidebar.label': 'Scheduled tasks',
@@ -588,214 +599,6 @@ window.__ModuleLoader__.load({
588
599
  };
589
600
  const T = (key, vars) => translate(key, vars);
590
601
 
591
- // Core chevron primitive when the build ships it; own SVG fallback otherwise.
592
- let CoreChevron = null;
593
- try {
594
- const primitives = require('@deepseek-ai/dsh-client-ui-primitives');
595
- CoreChevron = primitives && primitives.IconChevronDownOutline14;
596
- } catch (e) {}
597
- const chevronNode = () => (CoreChevron ? React.createElement(CoreChevron) : React.createElement('span', { style: { display: 'flex', alignItems: 'center' }, dangerouslySetInnerHTML: { __html: ICON_CHEVRON_DOWN } }));
598
-
599
- // Own React error boundary (#106): the core's SlotErrorBoundary renders an
600
- // empty div on crash, so a slot failure would silently vanish. Ours keeps
601
- // the failure visible with a retry.
602
- function createErrorBoundary() {
603
- if (!React || typeof React.Component !== 'function') {
604
- return function NoopBoundary(props) { return (props && props.children) || null; };
605
- }
606
- return class ErrorBoundary extends React.Component {
607
- constructor(props) {
608
- super(props);
609
- this.state = { hasError: false, error: null };
610
- }
611
- static getDerivedStateFromError(error) {
612
- return { hasError: true, error };
613
- }
614
- componentDidCatch(error, errorInfo) {
615
- console.error('[dsh-cron] React error:', error, errorInfo);
616
- }
617
- render() {
618
- if (this.state.hasError) {
619
- return React.createElement('div', { className: 'dsh-cron-status-banner', 'data-type': 'error', style: { margin: '12px 0' } },
620
- React.createElement('div', { style: { fontWeight: 600, marginBottom: '6px' } }, '⚠️ Cron UI error:'),
621
- React.createElement('div', { style: { fontSize: '12px', wordBreak: 'break-all' } }, String((this.state.error && this.state.error.message) || this.state.error)),
622
- React.createElement('button', {
623
- type: 'button',
624
- className: 'dsh-cron-btn-secondary',
625
- style: { marginTop: '10px', fontSize: '12px', padding: '4px 10px' },
626
- onClick: () => this.setState({ hasError: false, error: null })
627
- }, 'Retry')
628
- );
629
- }
630
- return (this.props && this.props.children) || null;
631
- }
632
- };
633
- }
634
- const ErrorBoundary = createErrorBoundary();
635
-
636
- const AGENT_FORM_TYPES = ['llm', 'skill', 'workflow'];
637
- const CODE_FORM_TYPES = ['script', 'node', 'python', 'ssh', 'docker'];
638
-
639
- // Delivery channels (#26) — must stay in sync with lib/channels.js.
640
- const DELIVERY_CHANNELS = ['telegram', 'kanban', 'discord', 'slack', 'ntfy', 'bark', 'pushplus', 'tts', 'gitea'];
641
- const CHANNEL_LABEL_KEYS = {
642
- telegram: 'form.channelTelegram',
643
- kanban: 'form.channelKanban',
644
- discord: 'form.channelDiscord',
645
- slack: 'form.channelSlack',
646
- ntfy: 'form.channelNtfy',
647
- bark: 'form.channelBark',
648
- pushplus: 'form.channelPushplus',
649
- tts: 'form.channelTts',
650
- gitea: 'form.channelGitea',
651
- };
652
-
653
- // Non-secret delivery settings shown in the settings panel. Secrets are
654
- // referenced by credential name only (#51) — never typed as values.
655
- const DELIVERY_SETTING_FIELDS = [
656
- { key: 'botTokenRef', labelKey: 'settings.botTokenRefLabel', placeholderKey: 'settings.botTokenRefPlaceholder', group: 'secrets' },
657
- { key: 'ntfyTokenRef', labelKey: 'settings.ntfyTokenRefLabel', group: 'secrets' },
658
- { key: 'pushplusTokenRef', labelKey: 'settings.pushplusTokenRefLabel', group: 'secrets' },
659
- { key: 'giteaTokenRef', labelKey: 'settings.giteaTokenRefLabel', group: 'secrets' },
660
- // The external API is the one surface where a typed secret is the point:
661
- // a CI job cannot present a credential reference (#54).
662
- { key: 'apiToken', labelKey: 'settings.apiTokenLabel', placeholderKey: 'settings.apiTokenPlaceholder', group: 'secrets' },
663
- { key: 'discordWebhookUrl', labelKey: 'settings.discordLabel', group: 'channels' },
664
- { key: 'slackWebhookUrl', labelKey: 'settings.slackLabel', group: 'channels' },
665
- { key: 'ntfyUrl', labelKey: 'settings.ntfyUrlLabel', group: 'channels' },
666
- { key: 'ntfyTopic', labelKey: 'settings.ntfyTopicLabel', group: 'channels' },
667
- { key: 'barkServerUrl', labelKey: 'settings.barkServerLabel', group: 'channels' },
668
- { key: 'barkKey', labelKey: 'settings.barkKeyLabel', group: 'channels' },
669
- { key: 'pushplusUrl', labelKey: 'settings.pushplusUrlLabel', group: 'channels' },
670
- { key: 'ttsBaseUrl', labelKey: 'settings.ttsBaseUrlLabel', group: 'channels' },
671
- { key: 'giteaBaseUrl', labelKey: 'settings.giteaBaseUrlLabel', group: 'channels' },
672
- { key: 'giteaRepo', labelKey: 'settings.giteaRepoLabel', group: 'channels' },
673
- { key: 'deliveryTimeoutMs', labelKey: 'settings.deliveryTimeoutMsLabel', group: 'channels', numeric: true, defaultValue: 15000, min: 1000 },
674
- { key: 'silentRuleModel', labelKey: 'settings.silentRuleModelLabel', group: 'automation' },
675
- { key: 'inspectorModel', labelKey: 'settings.inspectorModelLabel', group: 'automation' },
676
- ];
677
- const DELIVERY_SETTING_KEYS = DELIVERY_SETTING_FIELDS.map((f) => f.key);
678
- // The global template plus the per-channel override map (server model).
679
- const TEMPLATE_SETTING_KEYS = ['template', 'channelTemplates'];
680
-
681
- /** Collect every delivery key the settings endpoints may return. */
682
- function pickDeliverySettings(source) {
683
- const out = {};
684
- for (const key of DELIVERY_SETTING_KEYS.concat(TEMPLATE_SETTING_KEYS)) {
685
- if (source && source[key] !== undefined && source[key] !== null) out[key] = source[key];
686
- }
687
- if (out.channelTemplates && typeof out.channelTemplates === 'object') {
688
- out.channelTemplates = Object.assign({}, out.channelTemplates);
689
- }
690
- return out;
691
- }
692
-
693
- /**
694
- * Shared delivery form used by both the task panel modal and the plugin
695
- * settings card, so a new channel is added in exactly one place.
696
- * Sections collapse via the canonical aria-expanded head contract.
697
- */
698
- function renderDeliverySettings({ T, values, onChange, onChannelTemplate, sections, onToggleSection }) {
699
- const val = (key) => (values[key] === undefined || values[key] === null ? '' : String(values[key]));
700
- const field = (f) => React.createElement('div', { className: 'dsh-cron-form-group', key: f.key, style: { marginBottom: '10px' } },
701
- React.createElement('label', null, T(f.labelKey)),
702
- React.createElement('input', f.numeric ? {
703
- type: 'number',
704
- min: f.min !== undefined ? f.min : 1000,
705
- step: 1000,
706
- value: val(f.key),
707
- placeholder: String(f.defaultValue !== undefined ? f.defaultValue : 15000),
708
- // #115: clearing the field must send the explicit default. Deleting the
709
- // key would leave the previously stored value in place (the settings
710
- // scope skips absent keys), so the field would silently keep 1 ms.
711
- onChange: (e) => onChange(f.key, e.target.value === ''
712
- ? (f.defaultValue !== undefined ? f.defaultValue : 15000)
713
- : Number(e.target.value)),
714
- } : {
715
- type: 'text',
716
- value: val(f.key),
717
- placeholder: f.placeholderKey ? T(f.placeholderKey) : '',
718
- onChange: (e) => onChange(f.key, e.target.value)
719
- })
720
- );
721
- const section = (key, titleKey, children) => {
722
- const open = Boolean(sections[key]);
723
- return React.createElement('div', { className: 'dsh-cron-section', key },
724
- React.createElement('button', {
725
- type: 'button',
726
- className: 'dsh-cron-section-head',
727
- 'aria-expanded': open ? 'true' : 'false',
728
- onClick: () => onToggleSection(key)
729
- },
730
- React.createElement('span', null, T(titleKey)),
731
- React.createElement('span', { className: 'dsh-cron-section-chevron', 'data-open': open ? 'true' : 'false' }, '▾')
732
- ),
733
- open && React.createElement('div', { className: 'dsh-cron-section-body' }, children)
734
- );
735
- };
736
-
737
- return [
738
- section('secrets', 'settings.secretsSection', [
739
- React.createElement('div', { className: 'dsh-cron-hint', key: 'hint', style: { marginBottom: '10px' } }, T('settings.secretsHint')),
740
- ...DELIVERY_SETTING_FIELDS.filter((f) => f.group === 'secrets').map(field),
741
- ]),
742
- section('channels', 'settings.deliverySection', [
743
- React.createElement('div', { className: 'dsh-cron-form-row', key: 'row' },
744
- ...DELIVERY_SETTING_FIELDS.filter((f) => f.group === 'channels').map(field)
745
- ),
746
- ]),
747
- section('automation', 'settings.automationSection', [
748
- React.createElement('div', { className: 'dsh-cron-hint', key: 'hint', style: { marginBottom: '10px' } }, T('settings.automationHint')),
749
- React.createElement('div', { className: 'dsh-cron-form-row', key: 'row' },
750
- ...DELIVERY_SETTING_FIELDS.filter((f) => f.group === 'automation').map(field)
751
- ),
752
- ]),
753
- section('templates', 'settings.templatesSection', [
754
- React.createElement('div', { className: 'dsh-cron-form-group', key: 'global', style: { marginBottom: '10px' } },
755
- React.createElement('label', null, T('settings.templateLabel')),
756
- React.createElement('input', {
757
- type: 'text',
758
- value: val('template'),
759
- onChange: (e) => onChange('template', e.target.value)
760
- }),
761
- React.createElement('div', { className: 'dsh-cron-hint' }, T('settings.templateHint'))
762
- ),
763
- React.createElement('div', { className: 'dsh-cron-form-row', key: 'perchannel' },
764
- ...DELIVERY_CHANNELS.map((id) => React.createElement('div', { className: 'dsh-cron-form-group', key: id, style: { marginBottom: '10px' } },
765
- React.createElement('label', null, T('settings.channelTemplateLabel', { channel: T(CHANNEL_LABEL_KEYS[id]) })),
766
- React.createElement('input', {
767
- type: 'text',
768
- value: (values.channelTemplates && values.channelTemplates[id]) || '',
769
- onChange: (e) => onChannelTemplate(id, e.target.value)
770
- })
771
- ))
772
- ),
773
- ]),
774
- ];
775
- }
776
-
777
- /** Parse a KEY=VALUE textarea into the task env object (#38). */
778
- function parseEnvText(text) {
779
- const env = {};
780
- for (const rawLine of String(text || '').split('\n')) {
781
- const line = rawLine.trim();
782
- if (!line || line.startsWith('#')) continue;
783
- const idx = line.indexOf('=');
784
- if (idx <= 0) continue;
785
- const key = line.slice(0, idx).trim();
786
- const value = line.slice(idx + 1).trim();
787
- if (key) env[key] = value;
788
- }
789
- return Object.keys(env).length ? env : undefined;
790
- }
791
-
792
- function formatEnvText(env) {
793
- if (!env || typeof env !== 'object') return '';
794
- return Object.entries(env).map(([k, v]) => `${k}=${v}`).join('\n');
795
- }
796
-
797
- // Status colors: core semantic state tokens first (same set dsh-clinebot
798
- // uses), plugin fallbacks as a single point of change (#105).
799
602
  const STYLES = `
800
603
  :root {
801
604
  --dsh-cron-success: var(--dsw-alias-state-success-primary, #10b981);
@@ -865,6 +668,12 @@ window.__ModuleLoader__.load({
865
668
  .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; }
866
669
  .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; }
867
670
  .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; }
671
+ .dsh-cron-tag-success { background: var(--dsh-cron-info-bg); color: var(--dsh-cron-info); border-color: var(--dsh-cron-info); }
672
+ .dsh-cron-tag-failure { background: var(--dsh-cron-danger-bg); color: var(--dsh-cron-danger); border-color: var(--dsh-cron-danger); }
673
+ .dsh-cron-tag-heartbeat { background: var(--dsh-cron-accent-bg); color: var(--dsh-cron-accent); border-color: var(--dsh-cron-accent); }
674
+ .dsh-cron-tag-session { background: var(--dsh-cron-info-bg); color: var(--dsh-cron-info); border-color: var(--dsh-cron-info); }
675
+ .dsh-cron-tag-preflight { background: var(--dsh-cron-warning-bg); color: var(--dsh-cron-warning); border-color: var(--dsh-cron-warning); }
676
+
868
677
  .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; }
869
678
  .dsh-cron-task-actions { display: flex; gap: 8px; }
870
679
  .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; }
@@ -883,7 +692,7 @@ window.__ModuleLoader__.load({
883
692
  .dsh-cron-rec-time { font-size: 13px; color: var(--dsw-alias-label-secondary, #9ca3af); font-weight: normal; margin-left: 8px; }
884
693
  .dsh-cron-rec-desc { font-size: 13px; color: var(--dsw-alias-label-tertiary, #71717a); }
885
694
 
886
- .dsh-cron-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.75); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 16px; box-sizing: border-box; overflow-y: auto; }
695
+ .dsh-cron-modal-overlay { position: fixed; inset: 0; background: var(--dsw-alias-bg-mask, rgba(0, 0, 0, 0.75)); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 1000; padding: 16px; box-sizing: border-box; overflow-y: auto; }
887
696
  .dsh-cron-modal { background: var(--dsw-alias-bg-layer-2, #1f1f1f); border: 1px solid var(--dsw-alias-border-l2, #333); border-radius: 16px; width: 100%; max-width: 540px; max-height: min(90vh, calc(100vh - 36px)); overflow-y: auto; padding: 24px; box-shadow: 0 20px 40px rgba(0,0,0,0.6); box-sizing: border-box; }
888
697
  .dsh-cron-modal-scroll { max-width: 640px; max-height: min(90vh, calc(100vh - 36px)); overflow-y: auto; }
889
698
  .dsh-cron-modal::-webkit-scrollbar { width: 6px; }
@@ -963,7 +772,7 @@ window.__ModuleLoader__.load({
963
772
  .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); }
964
773
  .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); }
965
774
  .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); }
966
- @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); } }
775
+ @keyframes dsh-cron-pulse-ring { 0% { box-shadow: 0 0 0 0 var(--dsh-cron-success-bg); } 70% { box-shadow: 0 0 0 9px transparent; } 100% { box-shadow: 0 0 0 0 transparent; } }
967
776
  .dsh-cron-pulse { animation: dsh-cron-pulse-ring 1.6s infinite; border-color: var(--dsh-cron-success); color: var(--dsh-cron-success); }
968
777
 
969
778
  /* #34: sidebar job list */
@@ -1026,6 +835,107 @@ window.__ModuleLoader__.load({
1026
835
  el.textContent = STYLES;
1027
836
  }
1028
837
 
838
+ function createErrorBoundary() {
839
+ if (!React || typeof React.Component !== 'function') {
840
+ return function NoopBoundary(props) { return (props && props.children) || null; };
841
+ }
842
+ return class ErrorBoundary extends React.Component {
843
+ constructor(props) {
844
+ super(props);
845
+ this.state = { hasError: false, error: null };
846
+ }
847
+ static getDerivedStateFromError(error) {
848
+ return { hasError: true, error };
849
+ }
850
+ componentDidCatch(error, errorInfo) {
851
+ console.error('[dsh-cron] React error:', error, errorInfo);
852
+ }
853
+ render() {
854
+ if (this.state.hasError) {
855
+ return React.createElement('div', { className: 'dsh-cron-status-banner', 'data-type': 'error', style: { margin: '12px 0' } },
856
+ React.createElement('div', { style: { fontWeight: 600, marginBottom: '6px' } }, '⚠️ Cron UI error:'),
857
+ React.createElement('div', { style: { fontSize: '12px', wordBreak: 'break-all' } }, String((this.state.error && this.state.error.message) || this.state.error)),
858
+ React.createElement('button', {
859
+ type: 'button',
860
+ className: 'dsh-cron-btn-secondary',
861
+ style: { marginTop: '10px', fontSize: '12px', padding: '4px 10px' },
862
+ onClick: () => this.setState({ hasError: false, error: null })
863
+ }, 'Retry')
864
+ );
865
+ }
866
+ return (this.props && this.props.children) || null;
867
+ }
868
+ };
869
+ }
870
+ const ErrorBoundary = createErrorBoundary();
871
+
872
+ const AGENT_FORM_TYPES = ['llm', 'skill', 'workflow'];
873
+ const CODE_FORM_TYPES = ['script', 'node', 'python', 'ssh', 'docker'];
874
+
875
+ // Delivery channels (#26) — must stay in sync with lib/channels.js.
876
+ const DELIVERY_CHANNELS = ['telegram', 'kanban', 'discord', 'slack', 'ntfy', 'bark', 'pushplus', 'tts', 'gitea'];
877
+ const CHANNEL_LABEL_KEYS = {
878
+ telegram: 'form.channelTelegram',
879
+ kanban: 'form.channelKanban',
880
+ discord: 'form.channelDiscord',
881
+ slack: 'form.channelSlack',
882
+ ntfy: 'form.channelNtfy',
883
+ bark: 'form.channelBark',
884
+ pushplus: 'form.channelPushplus',
885
+ tts: 'form.channelTts',
886
+ gitea: 'form.channelGitea',
887
+ };
888
+
889
+ // Non-secret delivery settings shown in the settings panel. Secrets are
890
+ // referenced by credential name only (#51) — never typed as values.
891
+ const DELIVERY_SETTING_FIELDS = [
892
+ { key: 'botTokenRef', labelKey: 'settings.botTokenRefLabel', placeholderKey: 'settings.botTokenRefPlaceholder', group: 'secrets' },
893
+ { key: 'ntfyTokenRef', labelKey: 'settings.ntfyTokenRefLabel', group: 'secrets' },
894
+ { key: 'pushplusTokenRef', labelKey: 'settings.pushplusTokenRefLabel', group: 'secrets' },
895
+ { key: 'giteaTokenRef', labelKey: 'settings.giteaTokenRefLabel', group: 'secrets' },
896
+ // The external API is the one surface where a typed secret is the point:
897
+ // a CI job cannot present a credential reference (#54).
898
+ { key: 'apiToken', labelKey: 'settings.apiTokenLabel', placeholderKey: 'settings.apiTokenPlaceholder', group: 'secrets' },
899
+ { key: 'discordWebhookUrl', labelKey: 'settings.discordLabel', group: 'channels' },
900
+ { key: 'slackWebhookUrl', labelKey: 'settings.slackLabel', group: 'channels' },
901
+ { key: 'ntfyUrl', labelKey: 'settings.ntfyUrlLabel', group: 'channels' },
902
+ { key: 'ntfyTopic', labelKey: 'settings.ntfyTopicLabel', group: 'channels' },
903
+ { key: 'barkServerUrl', labelKey: 'settings.barkServerLabel', group: 'channels' },
904
+ { key: 'barkKey', labelKey: 'settings.barkKeyLabel', group: 'channels' },
905
+ { key: 'pushplusUrl', labelKey: 'settings.pushplusUrlLabel', group: 'channels' },
906
+ { key: 'ttsBaseUrl', labelKey: 'settings.ttsBaseUrlLabel', group: 'channels' },
907
+ { key: 'giteaBaseUrl', labelKey: 'settings.giteaBaseUrlLabel', group: 'channels' },
908
+ { key: 'giteaRepo', labelKey: 'settings.giteaRepoLabel', group: 'channels' },
909
+ { key: 'deliveryTimeoutMs', labelKey: 'settings.deliveryTimeoutMsLabel', group: 'channels', numeric: true, defaultValue: 15000, min: 1000 },
910
+ { key: 'silentRuleModel', labelKey: 'settings.silentRuleModelLabel', group: 'automation' },
911
+ { key: 'inspectorModel', labelKey: 'settings.inspectorModelLabel', group: 'automation' },
912
+ ];
913
+ const DELIVERY_SETTING_KEYS = DELIVERY_SETTING_FIELDS.map((f) => f.key);
914
+ // The global template plus the per-channel override map (server model).
915
+ const TEMPLATE_SETTING_KEYS = ['template', 'channelTemplates'];
916
+
917
+ /** Collect every delivery key the settings endpoints may return. */
918
+ function parseEnvText(text) {
919
+ const env = {};
920
+ for (const rawLine of String(text || '').split('\n')) {
921
+ const line = rawLine.trim();
922
+ if (!line || line.startsWith('#')) continue;
923
+ const idx = line.indexOf('=');
924
+ if (idx <= 0) continue;
925
+ const key = line.slice(0, idx).trim();
926
+ const value = line.slice(idx + 1).trim();
927
+ if (key) env[key] = value;
928
+ }
929
+ return Object.keys(env).length ? env : undefined;
930
+ }
931
+
932
+ function formatEnvText(env) {
933
+ if (!env || typeof env !== 'object') return '';
934
+ return Object.entries(env).map(([k, v]) => `${k}=${v}`).join('\n');
935
+ }
936
+
937
+ // Status colors: core semantic state tokens first (same set dsh-clinebot
938
+ // uses), plugin fallbacks as a single point of change (#105).
1029
939
  function createToggle() {
1030
940
  let open = false;
1031
941
  const listeners = new Set();
@@ -1088,6 +998,7 @@ window.__ModuleLoader__.load({
1088
998
  }
1089
999
  }
1090
1000
  }
1001
+ console.warn('[dsh-cron] could not open session in workspace:', sessionId);
1091
1002
  return false;
1092
1003
  }
1093
1004
 
@@ -1112,6 +1023,102 @@ window.__ModuleLoader__.load({
1112
1023
  return (typeof t === 'function' ? t : translate)(key, vars);
1113
1024
  }
1114
1025
 
1026
+ function pickDeliverySettings(source) {
1027
+ const out = {};
1028
+ for (const key of DELIVERY_SETTING_KEYS.concat(TEMPLATE_SETTING_KEYS)) {
1029
+ if (source && source[key] !== undefined && source[key] !== null) out[key] = source[key];
1030
+ }
1031
+ if (out.channelTemplates && typeof out.channelTemplates === 'object') {
1032
+ out.channelTemplates = Object.assign({}, out.channelTemplates);
1033
+ }
1034
+ return out;
1035
+ }
1036
+
1037
+ /**
1038
+ * Shared delivery form used by both the task panel modal and the plugin
1039
+ * settings card, so a new channel is added in exactly one place.
1040
+ * Sections collapse via the canonical aria-expanded head contract.
1041
+ */
1042
+ function renderDeliverySettings({ T, values, onChange, onChannelTemplate, sections, onToggleSection }) {
1043
+ const val = (key) => (values[key] === undefined || values[key] === null ? '' : String(values[key]));
1044
+ const field = (f) => React.createElement('div', { className: 'dsh-cron-form-group', key: f.key, style: { marginBottom: '10px' } },
1045
+ React.createElement('label', null, T(f.labelKey)),
1046
+ React.createElement('input', f.numeric ? {
1047
+ type: 'number',
1048
+ min: f.min !== undefined ? f.min : 1000,
1049
+ step: 1000,
1050
+ value: val(f.key),
1051
+ placeholder: String(f.defaultValue !== undefined ? f.defaultValue : 15000),
1052
+ // #115: clearing the field must send the explicit default. Deleting the
1053
+ // key would leave the previously stored value in place (the settings
1054
+ // scope skips absent keys), so the field would silently keep 1 ms.
1055
+ onChange: (e) => onChange(f.key, e.target.value === ''
1056
+ ? (f.defaultValue !== undefined ? f.defaultValue : 15000)
1057
+ : Number(e.target.value)),
1058
+ } : {
1059
+ type: 'text',
1060
+ value: val(f.key),
1061
+ placeholder: f.placeholderKey ? T(f.placeholderKey) : '',
1062
+ onChange: (e) => onChange(f.key, e.target.value)
1063
+ })
1064
+ );
1065
+ const section = (key, titleKey, children) => {
1066
+ const open = Boolean(sections[key]);
1067
+ return React.createElement('div', { className: 'dsh-cron-section', key },
1068
+ React.createElement('button', {
1069
+ type: 'button',
1070
+ className: 'dsh-cron-section-head',
1071
+ 'aria-expanded': open ? 'true' : 'false',
1072
+ onClick: () => onToggleSection(key)
1073
+ },
1074
+ React.createElement('span', null, T(titleKey)),
1075
+ React.createElement('span', { className: 'dsh-cron-section-chevron', 'data-open': open ? 'true' : 'false' }, '▾')
1076
+ ),
1077
+ open && React.createElement('div', { className: 'dsh-cron-section-body' }, children)
1078
+ );
1079
+ };
1080
+
1081
+ return [
1082
+ section('secrets', 'settings.secretsSection', [
1083
+ React.createElement('div', { className: 'dsh-cron-hint', key: 'hint', style: { marginBottom: '10px' } }, T('settings.secretsHint')),
1084
+ ...DELIVERY_SETTING_FIELDS.filter((f) => f.group === 'secrets').map(field),
1085
+ ]),
1086
+ section('channels', 'settings.deliverySection', [
1087
+ React.createElement('div', { className: 'dsh-cron-form-row', key: 'row' },
1088
+ ...DELIVERY_SETTING_FIELDS.filter((f) => f.group === 'channels').map(field)
1089
+ ),
1090
+ ]),
1091
+ section('automation', 'settings.automationSection', [
1092
+ React.createElement('div', { className: 'dsh-cron-hint', key: 'hint', style: { marginBottom: '10px' } }, T('settings.automationHint')),
1093
+ React.createElement('div', { className: 'dsh-cron-form-row', key: 'row' },
1094
+ ...DELIVERY_SETTING_FIELDS.filter((f) => f.group === 'automation').map(field)
1095
+ ),
1096
+ ]),
1097
+ section('templates', 'settings.templatesSection', [
1098
+ React.createElement('div', { className: 'dsh-cron-form-group', key: 'global', style: { marginBottom: '10px' } },
1099
+ React.createElement('label', null, T('settings.templateLabel')),
1100
+ React.createElement('input', {
1101
+ type: 'text',
1102
+ value: val('template'),
1103
+ onChange: (e) => onChange('template', e.target.value)
1104
+ }),
1105
+ React.createElement('div', { className: 'dsh-cron-hint' }, T('settings.templateHint'))
1106
+ ),
1107
+ React.createElement('div', { className: 'dsh-cron-form-row', key: 'perchannel' },
1108
+ ...DELIVERY_CHANNELS.map((id) => React.createElement('div', { className: 'dsh-cron-form-group', key: id, style: { marginBottom: '10px' } },
1109
+ React.createElement('label', null, T('settings.channelTemplateLabel', { channel: T(CHANNEL_LABEL_KEYS[id]) })),
1110
+ React.createElement('input', {
1111
+ type: 'text',
1112
+ value: (values.channelTemplates && values.channelTemplates[id]) || '',
1113
+ onChange: (e) => onChannelTemplate(id, e.target.value)
1114
+ })
1115
+ ))
1116
+ ),
1117
+ ]),
1118
+ ];
1119
+ }
1120
+
1121
+ /** Parse a KEY=VALUE textarea into the task env object (#38). */
1115
1122
  function CronScreen(props) {
1116
1123
  const { ctx, toggle, onClose } = props;
1117
1124
  const t = props.t || translate;
@@ -2216,11 +2223,11 @@ window.__ModuleLoader__.load({
2216
2223
  title: T_KEY(t, 'list.configManagedHint')
2217
2224
  }, T_KEY(t, 'list.configManaged')) : null,
2218
2225
  task.lastDurationMs > 0 ? React.createElement('span', { className: 'dsh-cron-cost-tag', title: 'Last execution duration' }, task.lastDurationMs + 'ms') : null,
2219
- task.onSuccess ? React.createElement('span', { className: 'dsh-cron-type-tag', title: 'On success: triggers ' + task.onSuccess, style: { background: 'rgba(56, 189, 248, 0.15)', color: '#38bdf8' } }, '➜ ' + task.onSuccess) : null,
2220
- task.onFailure ? React.createElement('span', { className: 'dsh-cron-type-tag', title: 'On failure: triggers ' + task.onFailure, style: { background: 'rgba(239, 68, 68, 0.15)', color: '#ef4444' } }, '↳ ' + task.onFailure) : null,
2221
- task.heartbeatIntervalSeconds > 0 ? React.createElement('span', { className: 'dsh-cron-type-tag', title: 'Heartbeat: every ' + task.heartbeatIntervalSeconds + 's', style: { background: 'rgba(168, 85, 247, 0.15)', color: '#a855f7' } }, '💓 ' + task.heartbeatIntervalSeconds + 's') : null,
2222
- task.targetSessionId ? React.createElement('span', { className: 'dsh-cron-type-tag', title: 'Target session: ' + task.targetSessionId + (task.targetSessionReset && task.targetSessionReset !== 'never' ? ' (' + task.targetSessionReset + ')' : ''), style: { background: 'rgba(99, 102, 241, 0.15)', color: '#818cf8' } }, '🧵 ' + task.targetSessionId) : null,
2223
- task.preflightType && task.preflightType !== 'none' ? React.createElement('span', { className: 'dsh-cron-type-tag', title: 'Preflight: ' + task.preflightType, style: { background: 'rgba(234, 179, 8, 0.15)', color: '#eab308' } }, '🛡️ ' + task.preflightType) : null,
2226
+ task.onSuccess ? React.createElement('span', { className: 'dsh-cron-type-tag dsh-cron-tag-success', title: 'On success: triggers ' + task.onSuccess }, '➜ ' + task.onSuccess) : null,
2227
+ task.onFailure ? React.createElement('span', { className: 'dsh-cron-type-tag dsh-cron-tag-failure', title: 'On failure: triggers ' + task.onFailure }, '↳ ' + task.onFailure) : null,
2228
+ task.heartbeatIntervalSeconds > 0 ? React.createElement('span', { className: 'dsh-cron-type-tag dsh-cron-tag-heartbeat', title: 'Heartbeat: every ' + task.heartbeatIntervalSeconds + 's' }, '💓 ' + task.heartbeatIntervalSeconds + 's') : null,
2229
+ 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,
2230
+ task.preflightType && task.preflightType !== 'none' ? React.createElement('span', { className: 'dsh-cron-type-tag dsh-cron-tag-preflight', title: 'Preflight: ' + task.preflightType }, '🛡️ ' + task.preflightType) : null,
2224
2231
  task.totalCostUsd > 0 ? React.createElement('span', { className: 'dsh-cron-cost-tag' }, '$' + task.totalCostUsd.toFixed(4)) : null,
2225
2232
  task.totalTokens > 0 ? React.createElement('span', { className: 'dsh-cron-tokens-tag' }, (task.totalTokens > 1000 ? Math.round(task.totalTokens / 1000) + 'k' : task.totalTokens) + ' tok') : null
2226
2233
  ),
@@ -3316,6 +3323,65 @@ window.__ModuleLoader__.load({
3316
3323
  const [testInfo, setTestInfo] = React.useState(null);
3317
3324
  // Collapsed by default, head is the toggle (canonical card contract, #100)
3318
3325
  const [cardOpen, setCardOpen] = React.useState(false);
3326
+ const [updateState, setUpdateState] = React.useState({
3327
+ checking: false,
3328
+ updating: false,
3329
+ currentVersion: '0.2.17',
3330
+ latestVersion: undefined,
3331
+ updateAvailable: false,
3332
+ notice: null,
3333
+ error: null
3334
+ });
3335
+
3336
+ const checkUpdates = async () => {
3337
+ setUpdateState(prev => ({ ...prev, checking: true, error: null, notice: null }));
3338
+ try {
3339
+ const res = await fetch('/api/dsh-cron/update');
3340
+ const data = await res.json();
3341
+ if (data && data.currentVersion) {
3342
+ setUpdateState(prev => ({
3343
+ ...prev,
3344
+ checking: false,
3345
+ currentVersion: data.currentVersion,
3346
+ latestVersion: data.latestVersion,
3347
+ updateAvailable: Boolean(data.updateAvailable),
3348
+ error: data.latestCheckFailed ? 'Registry check failed' : null
3349
+ }));
3350
+ } else {
3351
+ setUpdateState(prev => ({ ...prev, checking: false, error: data.error || 'Check failed' }));
3352
+ }
3353
+ } catch (err) {
3354
+ setUpdateState(prev => ({ ...prev, checking: false, error: err.message }));
3355
+ }
3356
+ };
3357
+
3358
+ const runUpdate = async () => {
3359
+ setUpdateState(prev => ({ ...prev, updating: true, error: null, notice: null }));
3360
+ try {
3361
+ const res = await fetch('/api/dsh-cron/update', {
3362
+ method: 'POST',
3363
+ headers: { 'Content-Type': 'application/json', 'x-dsh-plugin-update': '1' }
3364
+ });
3365
+ const data = await res.json();
3366
+ if (data && data.updated) {
3367
+ setUpdateState(prev => ({
3368
+ ...prev,
3369
+ updating: false,
3370
+ updateAvailable: false,
3371
+ currentVersion: data.updatedVersion || data.latestVersion,
3372
+ notice: T_KEY(t, 'updater.success', { version: data.updatedVersion || data.latestVersion })
3373
+ }));
3374
+ } else {
3375
+ setUpdateState(prev => ({
3376
+ ...prev,
3377
+ updating: false,
3378
+ error: data.error || data.message || 'Update failed'
3379
+ }));
3380
+ }
3381
+ } catch (err) {
3382
+ setUpdateState(prev => ({ ...prev, updating: false, error: err.message }));
3383
+ }
3384
+ };
3319
3385
 
3320
3386
  // Settings snapshot status (#102): never render phantom inputs before
3321
3387
  // the snapshot arrives; offer a retry when it is unavailable.
@@ -3521,6 +3587,57 @@ window.__ModuleLoader__.load({
3521
3587
  'data-type': testInfo.success ? 'success' : 'error',
3522
3588
  style: { margin: 0, padding: '8px 12px' }
3523
3589
  }, testInfo.message),
3590
+ React.createElement('div', {
3591
+ className: 'dsh-cron-form-group',
3592
+ style: {
3593
+ background: 'var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.03))',
3594
+ border: '1px solid var(--dsw-alias-border-l2, rgba(255,255,255,0.08))',
3595
+ borderRadius: '8px',
3596
+ padding: '14px',
3597
+ marginBottom: '16px'
3598
+ }
3599
+ },
3600
+ React.createElement('div', {
3601
+ style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '8px' }
3602
+ },
3603
+ React.createElement('div', { style: { fontWeight: '600', fontSize: '14px' } }, `🚀 ${T_KEY(t, 'updater.title')}`),
3604
+ React.createElement('div', { style: { display: 'flex', gap: '8px' } },
3605
+ React.createElement('button', {
3606
+ type: 'button',
3607
+ className: 'dsh-cron-btn-secondary',
3608
+ style: { padding: '4px 10px', fontSize: '12px' },
3609
+ disabled: updateState.checking || updateState.updating,
3610
+ onClick: checkUpdates
3611
+ }, updateState.checking ? T_KEY(t, 'updater.checking') : T_KEY(t, 'updater.btnCheck')),
3612
+ updateState.updateAvailable ? React.createElement('button', {
3613
+ type: 'button',
3614
+ className: 'dsh-cron-btn-primary',
3615
+ style: { padding: '4px 10px', fontSize: '12px' },
3616
+ disabled: updateState.updating,
3617
+ onClick: runUpdate
3618
+ }, updateState.updating ? T_KEY(t, 'updater.updating') : T_KEY(t, 'updater.btnUpdate', { version: updateState.latestVersion })) : null
3619
+ )
3620
+ ),
3621
+ React.createElement('div', {
3622
+ style: { fontSize: '12px', color: 'var(--dsw-alias-label-secondary, #999)', marginBottom: '8px' }
3623
+ }, T_KEY(t, 'updater.desc')),
3624
+ React.createElement('div', {
3625
+ style: { display: 'flex', alignItems: 'center', gap: '12px', fontSize: '13px' }
3626
+ },
3627
+ React.createElement('span', { style: { color: 'var(--dsw-alias-label-secondary, #aaa)' } },
3628
+ T_KEY(t, 'updater.current', { version: updateState.currentVersion })
3629
+ ),
3630
+ updateState.checking ? React.createElement('span', { className: 'dsh-cron-badge dsh-cron-badge-info' }, T_KEY(t, 'updater.checking')) :
3631
+ updateState.updateAvailable ? React.createElement('span', { className: 'dsh-cron-badge dsh-cron-badge-warn' }, T_KEY(t, 'updater.available', { version: updateState.latestVersion })) :
3632
+ updateState.currentVersion && !updateState.error ? React.createElement('span', { className: 'dsh-cron-badge dsh-cron-badge-ok' }, `✓ ${T_KEY(t, 'updater.upToDate')}`) : null
3633
+ ),
3634
+ updateState.notice ? React.createElement('div', {
3635
+ style: { marginTop: '8px', color: 'var(--dsw-alias-success, #10b981)', fontSize: '12px' }
3636
+ }, updateState.notice) : null,
3637
+ updateState.error ? React.createElement('div', {
3638
+ style: { marginTop: '8px', color: 'var(--dsw-alias-error, #ef4444)', fontSize: '12px' }
3639
+ }, updateState.error) : null
3640
+ ),
3524
3641
  React.createElement('div', { style: { display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap', marginTop: '4px' } },
3525
3642
  React.createElement('button', {
3526
3643
  type: 'submit',
@@ -13,7 +13,7 @@
13
13
  import { askModel, parseJsonAnswer, resolveAskTarget } from './llm-ask.js';
14
14
 
15
15
  /** Agent-mediated tasks have a prompt worth revising. */
16
- export const INSPECTABLE_TASK_TYPES = ['llm', 'skill', 'workflow'];
16
+ const INSPECTABLE_TASK_TYPES = ['llm', 'skill', 'workflow'];
17
17
 
18
18
  export function supportsInspection(task) {
19
19
  const type = String((task && task.type) || 'llm');
@@ -98,10 +98,4 @@ export async function inspectFailure({ task, runInfo, ask, preferredModel, timeo
98
98
  }
99
99
  }
100
100
 
101
- /** Ask helper bound to a context, used by the scheduler. */
102
- export function makeInspectAsk(ctx) {
103
- return async ({ prompt, system, preferredModel, maxTokens, timeoutMs, task }) => {
104
- const target = resolveAskTarget(ctx, task || {}, preferredModel);
105
- return askModel(ctx, { ...target, prompt, system, maxTokens, timeoutMs });
106
- };
107
- }
101
+ // makeInspectAsk removed (#155) — scheduler uses makeAsk(ctx) from lib/llm-ask.js
package/lib/index.js CHANGED
@@ -1,3 +1,5 @@
1
+ import { registerPluginUpdater } from './updater.js';
2
+ import { credentialRefStatus } from './secrets.js';
1
3
  import { TaskStore } from './store.js';
2
4
  import { TaskScheduler, parseScheduleExpression } from './scheduler.js';
3
5
  import { SessionRunner } from './runner.js';
@@ -165,6 +167,15 @@ export function sanitizeSettingsPayload(body) {
165
167
  delete payload[key];
166
168
  }
167
169
  }
170
+ for (const key of ['botTokenRef', 'barkKeyRef', 'giteaTokenRef']) {
171
+ if (key in payload && typeof payload[key] === 'string' && payload[key].trim()) {
172
+ const refCheck = credentialRefStatus(payload[key]);
173
+ if (!refCheck.ok) {
174
+ console.warn(`[dsh-cron] invalid credential reference in "${key}": ${refCheck.error}`);
175
+ delete payload[key];
176
+ }
177
+ }
178
+ }
168
179
  for (const key of TaskStore.FORBIDDEN_SETTING_KEYS) {
169
180
  if (key in payload) {
170
181
  console.warn(`[dsh-cron] refusing raw secret "${key}" from the client — use a credential reference`);
@@ -378,6 +389,13 @@ export function apply(ctx, config) {
378
389
  // #48: the recipe hub is the single source of the panel's suggestions.
379
390
  const recommendations = recipeRecommendations();
380
391
 
392
+ // Auto-updater route (#147)
393
+ ctx.effect(() => registerPluginUpdater(ctx, {
394
+ endpoint: '/api/dsh-cron/update',
395
+ packageName: '@goodandready/dsh-cron',
396
+ manifestUrl: new URL('../package.json', import.meta.url),
397
+ }), 'dsh-cron: /api/dsh-cron/update');
398
+
381
399
  // 1. REST API — collection (list/create) + item actions in one prefix
382
400
  // handler per route family; registered WITHOUT the trailing slash (#96)
383
401
  ctx.effect(() => ctx.webServer.register({
package/lib/recipes.js CHANGED
@@ -34,7 +34,7 @@ export const RECIPE_CATEGORIES = [
34
34
  * packages, users and permissions, scheduling and the network edge, power state
35
35
  * and writes outside the working tree.
36
36
  */
37
- export const DESTRUCTIVE_PATTERNS = [
37
+ const DESTRUCTIVE_PATTERNS = [
38
38
  /\brm\b/i,
39
39
  /\bunlink\b/i,
40
40
  /\bshred\b/i,
package/lib/runtimes.js CHANGED
@@ -10,7 +10,7 @@ import fs from 'node:fs';
10
10
  import path from 'node:path';
11
11
 
12
12
  export const EXTERNAL_RUNTIMES = ['script', 'node', 'python', 'http', 'ssh', 'docker'];
13
- export const AGENT_RUNTIMES = ['llm', 'skill', 'workflow'];
13
+ const AGENT_RUNTIMES = ['llm', 'skill', 'workflow'];
14
14
  export const TASK_TYPES = [...AGENT_RUNTIMES, ...EXTERNAL_RUNTIMES];
15
15
 
16
16
  /** Types that execute code and therefore need the HTTP confirm header. */
@@ -56,7 +56,7 @@ export function buildNodeInvocation(task, cwd) {
56
56
  }
57
57
 
58
58
  /** Find the Python interpreter, preferring a task virtualenv, then the workspace one. */
59
- export function detectPython(task, cwd) {
59
+ function detectPython(task, cwd) {
60
60
  if (task.pythonPath) return task.pythonPath;
61
61
  const candidates = [];
62
62
  if (process.env.VIRTUAL_ENV) {
package/lib/scheduler.js CHANGED
@@ -6,10 +6,10 @@ import { applySilentRule } from './silent-rule.js';
6
6
  import { inspectFailure } from './failure-inspector.js';
7
7
 
8
8
  /** Task types that talk to a model and can therefore use a fallback model. */
9
- export const AGENT_TASK_TYPES = ['llm', 'skill', 'workflow'];
9
+ const AGENT_TASK_TYPES = ['llm', 'skill', 'workflow'];
10
10
 
11
11
  /** Sum two usage records, so a fallback attempt is not lost in accounting. */
12
- export function addUsage(a, b) {
12
+ function addUsage(a, b) {
13
13
  return {
14
14
  inputTokens: (a?.inputTokens || 0) + (b?.inputTokens || 0),
15
15
  outputTokens: (a?.outputTokens || 0) + (b?.outputTokens || 0),
@@ -15,7 +15,7 @@
15
15
  import { askModel, parseJsonAnswer, resolveAskTarget } from './llm-ask.js';
16
16
 
17
17
  /** Task types whose runs produce output a rule can judge. */
18
- export const SILENT_RULE_TASK_TYPES = ['script', 'node', 'python', 'http'];
18
+ const SILENT_RULE_TASK_TYPES = ['script', 'node', 'python', 'http'];
19
19
 
20
20
  export function supportsSilentRule(task) {
21
21
  return SILENT_RULE_TASK_TYPES.includes(String(task && task.type ? task.type : 'script'));
@@ -64,6 +64,9 @@ export async function applySilentRule({ task, runInfo, ask, preferredModel, time
64
64
  if (!task || !task.silentRule || String(task.silentRule).trim() === '') {
65
65
  return { skipped: false, reason: '' };
66
66
  }
67
+ if (!supportsSilentRule(task)) {
68
+ return { skipped: false, reason: '', note: 'task type does not support silent rule' };
69
+ }
67
70
  if ((runInfo && runInfo.status) !== 'success') {
68
71
  return { skipped: false, reason: '', note: 'the rule only judges successful runs' };
69
72
  }
package/lib/telegram.js CHANGED
@@ -79,7 +79,7 @@ export function getDshDefaultTelegramCredentials() {
79
79
  /**
80
80
  * Escape markdown special characters for Telegram legacy markdown.
81
81
  */
82
- export function escapeMarkdown(str) {
82
+ function escapeMarkdown(str) {
83
83
  if (!str) return '';
84
84
  return String(str).replace(/[_*[\]()~`>#+\-=|{}.!]/g, '\\$&');
85
85
  }
package/lib/updater.js ADDED
@@ -0,0 +1,237 @@
1
+ import { spawn } from 'node:child_process';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
+ import { readFile } from 'node:fs/promises';
4
+ import { homedir } from 'node:os';
5
+ import { basename, dirname, isAbsolute, resolve } from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { rejectCrossOrigin } from './http-utils.js';
8
+
9
+ const UPDATE_TIMEOUT_MS = 10 * 60_000;
10
+ const VERSION_CACHE_MS = 5 * 60_000;
11
+ let latestCache = null;
12
+
13
+ function validProfileName(value) {
14
+ return typeof value === 'string' && value !== '' && value !== '.' && value !== '..'
15
+ && !value.includes('/') && !value.includes('\\') && !/[\0-\x1f\x7f]/.test(value);
16
+ }
17
+
18
+ function profileNameFromArgv(argv) {
19
+ for (let index = 2; index < argv.length; index += 1) {
20
+ if (argv[index] === '--profile' || argv[index] === '-p') return argv[index + 1];
21
+ if (argv[index]?.startsWith('--profile=')) return argv[index].slice('--profile='.length);
22
+ }
23
+ return argv[2] === 'web' ? 'web' : undefined;
24
+ }
25
+
26
+ function findDshCliEntry() {
27
+ const value = process.argv[1];
28
+ if (value === undefined || value === '') return undefined;
29
+ const entry = value.startsWith('file:') ? fileURLToPath(value) : resolve(process.cwd(), value);
30
+ if (!existsSync(entry)) return undefined;
31
+ for (let directory = dirname(entry); ; directory = dirname(directory)) {
32
+ const manifestPath = resolve(directory, 'package.json');
33
+ if (existsSync(manifestPath)) {
34
+ try {
35
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
36
+ const bin = typeof manifest.bin === 'string'
37
+ ? manifest.bin
38
+ : typeof manifest.bin === 'object' && manifest.bin !== null
39
+ ? manifest.bin.dsh
40
+ : undefined;
41
+ if (manifest.name === '@deepseek-ai/dsh' && typeof bin === 'string'
42
+ && !isAbsolute(bin) && resolve(directory, bin) === resolve(entry)) return entry;
43
+ } catch {
44
+ // Continue searching parent directories
45
+ }
46
+ }
47
+ const parent = dirname(directory);
48
+ if (parent === directory) return undefined;
49
+ }
50
+ }
51
+
52
+ export function runtime() {
53
+ const profileDir = resolve(process.env.DSH_PROFILE_DIR
54
+ ?? resolve(homedir(), '.dsh', 'profiles', 'web'));
55
+ const selected = profileNameFromArgv(process.argv);
56
+ const profileName = validProfileName(selected)
57
+ ? selected
58
+ : validProfileName(basename(profileDir)) ? basename(profileDir) : 'web';
59
+ const cliEntry = findDshCliEntry();
60
+ return cliEntry === undefined ? { profileName, profileDir } : { profileName, profileDir, cliEntry };
61
+ }
62
+
63
+ export function parseSemver(value) {
64
+ const match = /^v?(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?(?:\+[0-9A-Za-z.-]+)?$/.exec(String(value || '').trim());
65
+ if (match === null) return undefined;
66
+ return {
67
+ core: [Number(match[1]), Number(match[2]), Number(match[3])],
68
+ prerelease: match[4]?.split('.') ?? [],
69
+ };
70
+ }
71
+
72
+ function comparePrerelease(left, right) {
73
+ if (left.length === 0 || right.length === 0) return left.length === right.length ? 0 : left.length === 0 ? 1 : -1;
74
+ const length = Math.max(left.length, right.length);
75
+ for (let index = 0; index < length; index += 1) {
76
+ const a = left[index];
77
+ const b = right[index];
78
+ if (a === undefined || b === undefined) return a === b ? 0 : a === undefined ? -1 : 1;
79
+ if (a === b) continue;
80
+ const aNumeric = /^\d+$/.test(a);
81
+ const bNumeric = /^\d+$/.test(b);
82
+ if (aNumeric && bNumeric) {
83
+ const aNumber = BigInt(a);
84
+ const bNumber = BigInt(b);
85
+ if (aNumber !== bNumber) return aNumber > bNumber ? 1 : -1;
86
+ continue;
87
+ }
88
+ if (aNumeric !== bNumeric) return aNumeric ? -1 : 1;
89
+ return a > b ? 1 : -1;
90
+ }
91
+ return 0;
92
+ }
93
+
94
+ export function isNewerVersion(currentValue, candidateValue) {
95
+ const current = parseSemver(currentValue);
96
+ const candidate = parseSemver(candidateValue);
97
+ if (current === undefined || candidate === undefined) return false;
98
+ for (let index = 0; index < 3; index += 1) {
99
+ if (candidate.core[index] !== current.core[index]) return candidate.core[index] > current.core[index];
100
+ }
101
+ return comparePrerelease(candidate.prerelease, current.prerelease) > 0;
102
+ }
103
+
104
+ export async function latestVersion(packageName, registry = 'https://registry.npmjs.org') {
105
+ if (latestCache?.packageName === packageName && latestCache.registry === registry && Date.now() < latestCache.expiresAt) {
106
+ return latestCache.version;
107
+ }
108
+ try {
109
+ const cleanReg = registry.replace(/\/$/, '');
110
+ const response = await fetch(`${cleanReg}/${encodeURIComponent(packageName)}/latest`, {
111
+ signal: AbortSignal.timeout(8000),
112
+ headers: { accept: 'application/json' },
113
+ });
114
+ if (!response.ok) return undefined;
115
+ const value = await response.json();
116
+ if (typeof value.version !== 'string' || value.version === '') return undefined;
117
+ latestCache = { packageName, registry, version: value.version, expiresAt: Date.now() + VERSION_CACHE_MS };
118
+ return value.version;
119
+ } catch {
120
+ return undefined;
121
+ }
122
+ }
123
+
124
+ export async function currentVersion(manifestUrl) {
125
+ const raw = await readFile(manifestUrl, 'utf8');
126
+ const value = JSON.parse(raw);
127
+ if (typeof value.version !== 'string' || value.version === '') throw new Error('Cannot read current plugin version.');
128
+ return value.version;
129
+ }
130
+
131
+ export async function getUpdateStatus(options, target = runtime()) {
132
+ const current = await currentVersion(options.manifestUrl);
133
+ const latest = await latestVersion(options.packageName, options.registry ?? 'https://registry.npmjs.org');
134
+ return {
135
+ packageName: options.packageName,
136
+ currentVersion: current,
137
+ ...(latest === undefined ? {} : { latestVersion: latest }),
138
+ latestCheckFailed: latest === undefined,
139
+ updateAvailable: latest !== undefined && isNewerVersion(current, latest),
140
+ profileName: target.profileName,
141
+ canAutoUpdate: target.cliEntry !== undefined,
142
+ };
143
+ }
144
+
145
+ export async function installExact(target, packageSpec, options = {}) {
146
+ if (target.cliEntry === undefined) throw new Error('Automatic update is unavailable in this runtime.');
147
+ return new Promise((resolvePromise, reject) => {
148
+ // IMPORTANT: As required by #147, do NOT add minimumReleaseAge bypass
149
+ const args = [
150
+ target.cliEntry, 'plugin', '--profile', target.profileName, 'add',
151
+ packageSpec,
152
+ `--registry=${options.registry ?? 'https://registry.npmjs.org/'}`,
153
+ ];
154
+ const child = spawn(process.execPath, args, {
155
+ cwd: target.profileDir,
156
+ windowsHide: true,
157
+ stdio: ['ignore', 'pipe', 'pipe'],
158
+ env: { ...process.env, NO_COLOR: '1' },
159
+ });
160
+ let detail = '';
161
+ child.stdout?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4000); });
162
+ child.stderr?.on('data', chunk => { detail = (detail + String(chunk)).slice(-4000); });
163
+ const timer = setTimeout(() => {
164
+ child.kill();
165
+ reject(new Error('Update timed out; use the normal DSH update flow.'));
166
+ }, UPDATE_TIMEOUT_MS);
167
+ child.once('error', error => { clearTimeout(timer); reject(error); });
168
+ child.once('exit', code => {
169
+ clearTimeout(timer);
170
+ if (code === 0) resolvePromise();
171
+ else reject(new Error(detail.trim() || `Update exited with code ${String(code)}.`));
172
+ });
173
+ });
174
+ }
175
+
176
+ export function registerPluginUpdater(ctx, options) {
177
+ let installing = false;
178
+ return ctx.webServer.register({
179
+ kind: 'exact',
180
+ path: options.endpoint || '/api/dsh-cron/update',
181
+ handler: async (req, res) => {
182
+ try {
183
+ const target = runtime();
184
+ if (req.method === 'GET' || req.method === 'HEAD') {
185
+ const payload = await getUpdateStatus(options, target);
186
+ res.writeHead(200, {
187
+ 'content-type': 'application/json; charset=utf-8',
188
+ 'cache-control': 'no-store',
189
+ });
190
+ res.end(req.method === 'HEAD' ? undefined : JSON.stringify(payload));
191
+ return;
192
+ }
193
+ if (req.method !== 'POST') {
194
+ res.writeHead(405, { allow: 'GET, HEAD, POST' });
195
+ res.end();
196
+ return;
197
+ }
198
+ if (rejectCrossOrigin(req, res)) {
199
+ return;
200
+ }
201
+ if (installing) {
202
+ res.writeHead(409, { 'content-type': 'application/json; charset=utf-8' });
203
+ res.end(JSON.stringify({ ok: false, error: 'This plugin is already updating.' }));
204
+ return;
205
+ }
206
+ installing = true;
207
+ try {
208
+ const before = await getUpdateStatus(options, target);
209
+ if (before.latestVersion === undefined) {
210
+ res.writeHead(503, { 'content-type': 'application/json; charset=utf-8' });
211
+ res.end(JSON.stringify({ ok: false, error: 'The latest version is temporarily unavailable.' }));
212
+ return;
213
+ }
214
+ if (!before.updateAvailable) {
215
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
216
+ res.end(JSON.stringify({ ...before, updated: false, message: 'Already up to date.' }));
217
+ return;
218
+ }
219
+ await installExact(target, `${options.packageName}@${before.latestVersion}`, options);
220
+ res.writeHead(200, { 'content-type': 'application/json; charset=utf-8' });
221
+ res.end(JSON.stringify({
222
+ ...before,
223
+ updated: true,
224
+ updatedVersion: before.latestVersion,
225
+ restartRequired: true,
226
+ }));
227
+ } finally {
228
+ installing = false;
229
+ }
230
+ } catch (error) {
231
+ (ctx.logger || console).warn?.(`[dsh-cron] plugin updater failed: ${String(error)}`);
232
+ res.writeHead(503, { 'content-type': 'application/json; charset=utf-8' });
233
+ res.end(JSON.stringify({ ok: false, error: 'Plugin update failed; see server logs.' }));
234
+ }
235
+ },
236
+ });
237
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.2.15",
3
+ "version": "0.2.17",
4
4
  "description": "Scheduled cron tasks, background automation and agent execution for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",
@@ -11,13 +11,15 @@
11
11
  "./package.json": "./package.json"
12
12
  },
13
13
  "files": [
14
- "lib/",
14
+ "lib/*.js",
15
15
  "cordis.patch.yml",
16
16
  "README.md",
17
17
  "LICENSE"
18
18
  ],
19
19
  "scripts": {
20
- "test": "node --test test/*.test.mjs"
20
+ "test": "node --test test/*.test.mjs",
21
+ "build:client": "node scripts/build-client.mjs",
22
+ "pretest": "node scripts/build-client.mjs"
21
23
  },
22
24
  "keywords": [
23
25
  "dsh",