@goodandready/dsh-cron 0.2.16 → 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,13 @@ 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
+
395
402
  ### 28. In-App One-Click Auto-Updater & Error Resilience (v0.2.16, #147, #155, #156)
396
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`).
397
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.
package/README.ru.md CHANGED
@@ -268,6 +268,13 @@ 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
+
271
278
  ### 28. Модуль самообновления в 1 клик и устойчивость к ошибкам (v0.2.16, #147, #155, #156)
272
279
  - **Самообновление плагина (#147)**: Добавлен встроенный модуль обновления (`lib/updater.js`) с маршрутом `/api/dsh-cron/update` и карточкой в настройках Web UI. Автоматически опрашивает npm registry, корректно сравнивает semver-версии (включая пререлизы) и обновляет `@goodandready/dsh-cron` на лету через CLI DSH без необходимости входа по SSH. Запросы POST защищены проверкой источника (`rejectCrossOrigin`).
273
280
  - **Устранение скрытых сбоев (#156)**: Ликвидированы пустые `catch`: незавершённые откаты транзакций импорта логируются с уровнем `warn`, причины fallback для динамических модулей ядра фиксируются в отладочном журнале, а при сбое открытия сессии пользователь получает понятное уведомление.
package/README.zh.md CHANGED
@@ -391,6 +391,13 @@ 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
+
394
401
  ### 28. 应用内一键自更新与错误容错增强 (v0.2.16, #147, #155, #156)
395
402
  - **插件自更新模块 (#147)**:新增应用内一键自更新机制 (`lib/updater.js`)、`/api/dsh-cron/update` 接口与设置面板专属卡片。自动轮询 npmjs 仓库最新版本,精准比对 Semver 版本号(含预发布版本),通过 DSH CLI 就地升级 `@goodandready/dsh-cron`,无需 SSH 终端操作。POST 请求受跨域与来源保护 (`rejectCrossOrigin`)。
396
403
  - **消除静默异常与错误追踪 (#156)**:全面消除空 `catch` 异常压制:任务导入事务回滚失败记录 `warn` 级别日志、动态核心模块降级原因记录诊断日志、会话自动唤出失败向用户呈现友好提示。
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();
@@ -1113,6 +1023,102 @@ window.__ModuleLoader__.load({
1113
1023
  return (typeof t === 'function' ? t : translate)(key, vars);
1114
1024
  }
1115
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). */
1116
1122
  function CronScreen(props) {
1117
1123
  const { ctx, toggle, onClose } = props;
1118
1124
  const t = props.t || translate;
@@ -2217,11 +2223,11 @@ window.__ModuleLoader__.load({
2217
2223
  title: T_KEY(t, 'list.configManagedHint')
2218
2224
  }, T_KEY(t, 'list.configManaged')) : null,
2219
2225
  task.lastDurationMs > 0 ? React.createElement('span', { className: 'dsh-cron-cost-tag', title: 'Last execution duration' }, task.lastDurationMs + 'ms') : null,
2220
- 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,
2221
- 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,
2222
- 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,
2223
- 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,
2224
- 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,
2225
2231
  task.totalCostUsd > 0 ? React.createElement('span', { className: 'dsh-cron-cost-tag' }, '$' + task.totalCostUsd.toFixed(4)) : null,
2226
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
2227
2233
  ),
@@ -3320,7 +3326,7 @@ window.__ModuleLoader__.load({
3320
3326
  const [updateState, setUpdateState] = React.useState({
3321
3327
  checking: false,
3322
3328
  updating: false,
3323
- currentVersion: '0.2.16',
3329
+ currentVersion: '0.2.17',
3324
3330
  latestVersion: undefined,
3325
3331
  updateAvailable: false,
3326
3332
  notice: null,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.2.16",
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",