@goodandready/dsh-cron 0.2.0 → 0.2.2

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.
@@ -16,7 +16,7 @@
16
16
  - Поисковая строка; сводная статистика (активные задачи, запуски, токены, стоимость).
17
17
  - Карточки задач: статус-переключатель, название, расписание (человекочитаемое + raw cron), действия (запуск, редактирование, удаление).
18
18
  - Блок «Рекомендуемые задачи»: готовые шаблоны (Daily digest, Weekly review, Follow-up monitor) в один клик.
19
- - Карточка настроек в слоте settings.plugin.item, key = namespace `dsh-cron`; запасной путь — settings.section.
19
+ - Карточка настроек в слоте settings.plugin.item, key = namespace `dsh-cron`; отдельный раздел настроек не используется (#102).
20
20
  - LLM Tools: cron_create_task (+ alias cron_schedule_task), cron_list_tasks, cron_pause_task, cron_resume_task, cron_delete_task, cron_run_task.
21
21
  - API: HTTP эндпоинты /dsh-cron/* (tasks, models, chat/start, settings, telegram/test, kanban/test, tasks/:id/actions, legacy action/:id/:action). Мутирующие эндпоинты отклоняют cross-origin запросы; script-задачи по HTTP требуют заголовок x-dsh-cron-confirm.
22
22
  - Chat / Slash Commands: отсутствуют (ранее заявленные /cron-команды не были реализованы и удалены из документации; решение 2026-09-09).
@@ -46,7 +46,7 @@
46
46
  - SettingsModal: настройки Telegram/Kanban с тестами доставки.
47
47
  - TaskItem: строка задачи с переключателем состояния и действиями.
48
48
  - RecommendationCard: плашка с готовым шаблоном.
49
- - CronSettingsCard: карточка параметров плагина в настройках.
49
+ - CronSettingsCard: карточка параметров плагина в настройках; свёрнута по умолчанию, шапка-кнопка с шевроном разворачивает тело (aria-expanded), кнопка «Открыть панель задач» — внутри раскрытого тела (решение 2026-09-10, #100).
50
50
  - States:
51
51
  - Loading: индикатор загрузки списка/истории.
52
52
  - Empty: дружелюбный пустой экран со списком рекомендаций.
@@ -63,7 +63,8 @@
63
63
  ## Locked Design Decisions
64
64
  - 2026-09-09 — Пакет надёжности ядра (v0.1.24): буфер shell-задач 10МБ, атомарное сохранение с PID, аудит пропущенных запусков при рестарте (missed), фоновый поллинг UI (8с).
65
65
  - 2026-09-03 — Публичный скоуп @goodandready/dsh-cron; оверлей через mountSidebarEntry/mountScreen аналогично dsh-kanban; двойная кнопка «Создать ⌄».
66
- - 2026-09-09 — Слот карточки настроек: settings.plugin.item с key/namespace `dsh-cron` (совпадение с серверной регистрацией); settings.section — только запасной путь. Причина: контракт слота настроек (#85).
66
+ - 2026-09-09 — Слот карточки настроек: settings.plugin.item с key/namespace `dsh-cron` (совпадение с серверной регистрацией). Причина: контракт слота настроек (#85). Changed 2026-09-10 (#102): settings.section fallback удалён — карточка только во вкладке плагинов.
67
+ - 2026-09-10 — Значения настроек пишутся через зарегистрированный settings-скоуп (`scope.set` → `watch` → store); REST /dsh-cron/settings остаётся транспортом для карточки и headless-сценариев; карточка проверяет статус снапшота и не рисует поля до его получения (#102).
67
68
  - 2026-09-09 — Английский — канонический язык строк; словарь STRINGS.en регистрируется в ctx.locale; русский — через translation-плагин. Причина: стандарт DSH-плагинов (#87).
68
69
  - 2026-09-09 — Same-origin проверка мутирующих эндпоинтов, лимит тела 1 МБ, confirm-заголовок для script-задач. Причина: закрытие CSRF→RCE поверхности (#86).
69
70
  - 2026-09-09 — Стилевая изоляция: динамические <style> с data-dsh-plugin="dsh-cron"; цвета только через токены темы + единый блок plugin-переменных. Причина: защита от очистки стилей соседями и поддержка светлой темы (#91).
package/lib/client.js CHANGED
@@ -119,7 +119,9 @@ window.__ModuleLoader__.load({
119
119
  'form.notifyOnlyFailure': 'Only on failures (onlyOnFailure / silent on success)',
120
120
  'form.nextRunLabel': 'Next run: ',
121
121
  'form.notScheduled': 'not scheduled',
122
- 'settings.sectionLabel': 'Cron tasks',
122
+ 'settings.loading': 'Loading settings...',
123
+ 'settings.unavailable': 'Settings are unavailable right now.',
124
+ 'settings.retry': 'Retry',
123
125
  'settings.cardTitle': '⏰ Task scheduler (Cron)',
124
126
  'settings.cardDesc': 'Scheduled task execution (cron/intervals), background agent scenarios and Telegram/Kanban integration.',
125
127
  'settings.openPanel': 'Open task panel',
@@ -170,20 +172,65 @@ window.__ModuleLoader__.load({
170
172
  };
171
173
  const T = (key, vars) => translate(key, vars);
172
174
 
173
- // Status colors: core semantic tokens first, plugin fallbacks as a single
174
- // point of change (Issue #91 — no scattered hardcoded hex).
175
+ // Core chevron primitive when the build ships it; own SVG fallback otherwise.
176
+ let CoreChevron = null;
177
+ try {
178
+ const primitives = require('@deepseek-ai/dsh-client-ui-primitives');
179
+ CoreChevron = primitives && primitives.IconChevronDownOutline14;
180
+ } catch (e) {}
181
+ const chevronNode = () => (CoreChevron ? React.createElement(CoreChevron) : React.createElement('span', { style: { display: 'flex', alignItems: 'center' }, dangerouslySetInnerHTML: { __html: ICON_CHEVRON_DOWN } }));
182
+
183
+ // Own React error boundary (#106): the core's SlotErrorBoundary renders an
184
+ // empty div on crash, so a slot failure would silently vanish. Ours keeps
185
+ // the failure visible with a retry.
186
+ function createErrorBoundary() {
187
+ if (!React || typeof React.Component !== 'function') {
188
+ return function NoopBoundary(props) { return (props && props.children) || null; };
189
+ }
190
+ return class ErrorBoundary extends React.Component {
191
+ constructor(props) {
192
+ super(props);
193
+ this.state = { hasError: false, error: null };
194
+ }
195
+ static getDerivedStateFromError(error) {
196
+ return { hasError: true, error };
197
+ }
198
+ componentDidCatch(error, errorInfo) {
199
+ console.error('[dsh-cron] React error:', error, errorInfo);
200
+ }
201
+ render() {
202
+ if (this.state.hasError) {
203
+ return React.createElement('div', { className: 'dsh-cron-status-banner', 'data-type': 'error', style: { margin: '12px 0' } },
204
+ React.createElement('div', { style: { fontWeight: 600, marginBottom: '6px' } }, '⚠️ Cron UI error:'),
205
+ React.createElement('div', { style: { fontSize: '12px', wordBreak: 'break-all' } }, String((this.state.error && this.state.error.message) || this.state.error)),
206
+ React.createElement('button', {
207
+ type: 'button',
208
+ className: 'dsh-cron-btn-secondary',
209
+ style: { marginTop: '10px', fontSize: '12px', padding: '4px 10px' },
210
+ onClick: () => this.setState({ hasError: false, error: null })
211
+ }, 'Retry')
212
+ );
213
+ }
214
+ return (this.props && this.props.children) || null;
215
+ }
216
+ };
217
+ }
218
+ const ErrorBoundary = createErrorBoundary();
219
+
220
+ // Status colors: core semantic state tokens first (same set dsh-clinebot
221
+ // uses), plugin fallbacks as a single point of change (#105).
175
222
  const STYLES = `
176
223
  :root {
177
- --dsh-cron-success: var(--dsw-alias-label-success, #10b981);
178
- --dsh-cron-success-bg: var(--dsw-alias-bg-success, rgba(16, 185, 129, 0.15));
179
- --dsh-cron-danger: var(--dsw-alias-label-danger, #ef4444);
180
- --dsh-cron-danger-bg: var(--dsw-alias-bg-danger, rgba(239, 68, 68, 0.15));
181
- --dsh-cron-info: var(--dsw-alias-label-info, #60a5fa);
182
- --dsh-cron-info-bg: var(--dsw-alias-bg-info, rgba(59, 130, 246, 0.15));
183
- --dsh-cron-accent: var(--dsw-alias-label-accent, #c084fc);
184
- --dsh-cron-accent-bg: var(--dsw-alias-bg-accent, rgba(168, 85, 247, 0.15));
185
- --dsh-cron-warning: var(--dsw-alias-label-warning, #eab308);
186
- --dsh-cron-warning-bg: var(--dsw-alias-bg-warning, rgba(234, 179, 8, 0.15));
224
+ --dsh-cron-success: var(--dsw-alias-state-success-primary, #10b981);
225
+ --dsh-cron-success-bg: rgba(16, 185, 129, 0.1);
226
+ --dsh-cron-danger: var(--dsw-alias-state-error-primary, #ef4444);
227
+ --dsh-cron-danger-bg: rgba(239, 68, 68, 0.1);
228
+ --dsh-cron-info: var(--dsw-alias-state-brand-primary, #60a5fa);
229
+ --dsh-cron-info-bg: rgba(96, 165, 250, 0.12);
230
+ --dsh-cron-accent: var(--dsw-alias-state-info-primary, #c084fc);
231
+ --dsh-cron-accent-bg: rgba(168, 85, 247, 0.12);
232
+ --dsh-cron-warning: var(--dsw-alias-state-warning-primary, #eab308);
233
+ --dsh-cron-warning-bg: rgba(234, 179, 8, 0.12);
187
234
  }
188
235
  [data-pane=conversation], [class*=centerCol] {
189
236
  position: relative !important;
@@ -208,43 +255,46 @@ window.__ModuleLoader__.load({
208
255
  .dsh-cron-entry-label { text-overflow: ellipsis; overflow: hidden; }
209
256
  [data-dsh-frame][data-sidebar-collapsed] .dsh-cron-entry-label { display: none; }
210
257
 
211
- .dsh-cron-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; background: var(--dsw-alias-bg-base, #171717); color: var(--dsw-alias-label-primary, #ededed); overflow-y: auto; padding: 32px 48px; z-index: 100; box-sizing: border-box; }
212
- .dsh-cron-container { max-width: 860px; width: 100%; margin: 0 auto; }
258
+ .dsh-cron-overlay { position: absolute; inset: 0; display: flex; flex-direction: column; background: var(--dsw-alias-bg-base, #171717); color: var(--dsw-alias-label-primary, #ededed); overflow-y: auto; padding: 24px 48px 32px; z-index: 100; box-sizing: border-box; }
259
+ .dsh-cron-container { max-width: 960px; width: 100%; margin: 0 auto; display: flex; flex-direction: column; gap: 20px; padding: 8px 0 32px; box-sizing: border-box; }
213
260
 
214
- .dsh-cron-top-bar { display: flex; align-items: center; gap: 12px; margin-bottom: 20px; }
261
+ .dsh-cron-top-bar { display: flex; align-items: center; gap: 12px; }
215
262
  .dsh-cron-back-btn { appearance: none; font: inherit; cursor: pointer; color: var(--dsw-alias-label-secondary, #9ca3af); background: 0 0; border: 1px solid var(--dsw-alias-border-l2, #383838); border-radius: 8px; align-items: center; gap: 6px; padding: 4px 12px; font-size: 13px; display: inline-flex; flex: none; height: 32px; box-sizing: border-box; }
216
- .dsh-cron-back-btn:hover { color: var(--dsw-alias-label-primary, #ededed); background: var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,0.06)); }
263
+ .dsh-cron-back-btn:hover { color: var(--dsw-alias-label-primary, #ededed); background: var(--dsw-alias-bg-layer-4, var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,0.06))); }
217
264
 
218
- .dsh-cron-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: 24px; }
219
- .dsh-cron-title { font-size: 24px; font-weight: 600; letter-spacing: -0.02em; margin-bottom: 6px; }
220
- .dsh-cron-subtitle { font-size: 14px; color: var(--dsw-alias-label-secondary, #9ca3af); }
221
- .dsh-cron-create-btn { appearance: none; display: inline-flex; align-items: center; gap: 6px; background: var(--dsw-alias-bg-layer-4, #262626); color: var(--dsw-alias-label-primary, #fff); border: 1px solid var(--dsw-alias-border-l2, #383838); border-radius: 9999px; padding: 8px 16px; font-size: 14px; font-weight: 500; cursor: pointer; position: relative; }
265
+ .dsh-cron-header { display: flex; justify-content: space-between; align-items: flex-start; gap: 12px; padding-bottom: 16px; border-bottom: 1px solid var(--dsw-alias-border-l2, #383838); }
266
+ .dsh-cron-title { font-size: 22px; font-weight: 700; letter-spacing: -0.02em; margin-bottom: 6px; }
267
+ .dsh-cron-subtitle { font-size: 14px; color: var(--dsw-alias-label-secondary, #9ca3af); line-height: 1.5; }
268
+ .dsh-cron-create-btn { appearance: none; display: inline-flex; align-items: center; gap: 6px; background: var(--dsw-alias-bg-layer-2, #262626); color: var(--dsw-alias-label-primary, #fff); border: 1px solid var(--dsw-alias-border-l2, #383838); border-radius: 9999px; padding: 8px 16px; font-size: 14px; font-weight: 500; cursor: pointer; position: relative; }
222
269
  .dsh-cron-create-btn:hover { background: var(--dsw-alias-bg-layer-hover, var(--dsw-alias-interactive-bg-hover, #333)); }
223
270
  .dsh-cron-dropdown { position: absolute; top: calc(100% + 6px); right: 0; width: 200px; background: var(--dsw-alias-bg-layer-3, #212121); border: 1px solid var(--dsw-alias-border-l2, #383838); border-radius: 12px; padding: 6px; box-shadow: 0 10px 25px rgba(0,0,0,0.5); z-index: 200; }
224
271
  .dsh-cron-dropdown-item { display: flex; align-items: center; gap: 10px; width: 100%; padding: 10px 12px; background: transparent; border: none; border-radius: 8px; color: var(--dsw-alias-label-primary, #ededed); font-size: 13.5px; text-align: left; cursor: pointer; }
225
272
  .dsh-cron-dropdown-item:hover { background: var(--dsw-alias-interactive-bg-hover, #2a2a2a); }
226
- .dsh-cron-search-bar { position: relative; margin-bottom: 20px; }
227
- .dsh-cron-search-input { width: 100%; background: var(--dsw-alias-bg-layer-3, #1f1f1f); border: 1px solid var(--dsw-alias-border-l2, #333); border-radius: 10px; padding: 10px 14px 10px 38px; color: var(--dsw-alias-label-primary, inherit); font-size: 14px; outline: none; box-sizing: border-box; }
273
+ .dsh-cron-search-bar { position: relative; margin-bottom: 0; }
274
+ .dsh-cron-search-input { width: 100%; height: 36px; background: var(--dsw-alias-bg-layer-2, #1f1f1f); border: 1px solid var(--dsw-alias-border-l2, #333); border-radius: 8px; padding: 0 12px 0 38px; color: var(--dsw-alias-label-primary, inherit); font-size: 13px; outline: none; box-sizing: border-box; }
275
+ .dsh-cron-search-input:focus { outline: none; border-color: var(--dsh-cron-info); }
228
276
  .dsh-cron-search-icon { position: absolute; left: 12px; top: 50%; transform: translateY(-50%); color: var(--dsw-alias-label-tertiary, #666); }
229
- .dsh-cron-tabs { display: flex; gap: 8px; margin-bottom: 24px; }
230
- .dsh-cron-tab { appearance: none; background: transparent; border: none; padding: 6px 14px; border-radius: 9999px; color: var(--dsw-alias-label-secondary, #9ca3af); font-size: 13.5px; cursor: pointer; }
231
- .dsh-cron-tab[data-active="true"] { background: var(--dsw-alias-bg-layer-4, #2a2a2a); color: var(--dsw-alias-label-primary, #fff); font-weight: 500; }
232
- .dsh-cron-task-list { display: flex; flex-direction: column; gap: 12px; margin-bottom: 40px; }
277
+ .dsh-cron-tabs { display: flex; flex-wrap: wrap; gap: 8px; }
278
+ .dsh-cron-tab { appearance: none; background: transparent; border: 1px solid var(--dsw-alias-border-l2, #383838); padding: 4px 12px; border-radius: 999px; color: var(--dsw-alias-label-secondary, #9ca3af); font-size: 12px; font-weight: 500; cursor: pointer; }
279
+ .dsh-cron-tab[data-active="true"] { background: var(--dsw-alias-bg-layer-4, #2a2a2a); color: var(--dsw-alias-label-primary, #fff); border-color: var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2, #383838)); }
280
+ .dsh-cron-task-list { display: flex; flex-direction: column; gap: 12px; margin-bottom: 0; }
233
281
  .dsh-cron-task-item { display: flex; align-items: center; gap: 16px; padding: 14px 18px; background: var(--dsw-alias-bg-layer-3, #1e1e1e); border: 1px solid var(--dsw-alias-border-l2, #2e2e2e); border-radius: 12px; cursor: pointer; transition: background 0.15s, border-color 0.15s; }
234
- .dsh-cron-task-item:hover, .dsh-cron-task-item:focus-visible { background: var(--dsw-alias-bg-layer-4, #242424); border-color: var(--dsw-alias-border-l1, #444); outline: none; }
235
- .dsh-cron-task-prompt-preview { font-size: 12.5px; color: var(--dsw-alias-label-tertiary, #888); margin-top: 5px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; max-width: 520px; line-height: 1.3; }
236
- .dsh-cron-type-tag { display: inline-block; font-size: 10.5px; padding: 1px 6px; border-radius: 4px; text-transform: uppercase; font-weight: 600; margin-left: 8px; vertical-align: middle; }
237
- .dsh-cron-task-model-tag { display: inline-block; font-size: 11px; padding: 2px 7px; border-radius: 4px; background: var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,0.06)); color: var(--dsw-alias-label-secondary, #aaa); margin-left: 8px; vertical-align: middle; }
282
+ .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; }
283
+ .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; }
284
+ .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; }
285
+ .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; }
238
286
  .dsh-cron-task-actions { display: flex; gap: 8px; }
239
- .dsh-cron-icon-btn { background: transparent; border: 1px solid var(--dsw-alias-border-l2, #333); border-radius: 8px; padding: 6px 12px; font-size: 12.5px; color: var(--dsw-alias-label-secondary, #aaa); cursor: pointer; }
240
- .dsh-cron-icon-btn:hover { background: var(--dsw-alias-interactive-bg-hover, #2a2a2a); color: var(--dsw-alias-label-primary, #fff); }
241
- .dsh-cron-task-status-btn { background: none; border: 1px solid var(--dsw-alias-border-l1, #444); border-radius: 50%; width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; color: var(--dsw-alias-label-tertiary, #888); cursor: pointer; flex-shrink: 0; }
287
+ .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; }
288
+ .dsh-cron-icon-btn:hover { background: var(--dsw-alias-bg-layer-4, #2a2a2a); border-color: var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2, #444)); }
289
+ .dsh-cron-icon-btn.dsh-cron-danger { color: var(--dsh-cron-danger); border-color: var(--dsh-cron-danger-bg); }
290
+ .dsh-cron-icon-btn.dsh-cron-danger:hover { background: var(--dsh-cron-danger-bg); border-color: var(--dsh-cron-danger); }
291
+ .dsh-cron-task-status-btn { background: none; border: 1px solid var(--dsw-alias-border-l2, #444); border-radius: 50%; width: 28px; height: 28px; display: flex; align-items: center; justify-content: center; color: var(--dsw-alias-label-tertiary, #888); cursor: pointer; flex-shrink: 0; }
242
292
  .dsh-cron-task-status-btn[data-active="true"] { color: var(--dsh-cron-success); border-color: var(--dsh-cron-success); }
243
- .dsh-cron-recs-title { font-size: 17px; font-weight: 600; margin-bottom: 14px; }
293
+ .dsh-cron-recs-title { font-size: 16px; font-weight: 600; margin-bottom: 14px; }
244
294
  .dsh-cron-recs-list { display: flex; flex-direction: column; gap: 10px; }
245
- .dsh-cron-rec-card { display: flex; align-items: center; gap: 16px; padding: 16px 20px; background: var(--dsw-alias-bg-layer-3, #1c1c1c); border: 1px solid var(--dsw-alias-border-l1, #282828); border-radius: 12px; cursor: pointer; transition: background 0.15s; }
246
- .dsh-cron-rec-card:hover, .dsh-cron-rec-card:focus-visible { background: var(--dsw-alias-bg-layer-4, #242424); border-color: var(--dsw-alias-border-l2, #383838); outline: none; }
247
- .dsh-cron-rec-icon { width: 36px; height: 36px; border-radius: 10px; background: var(--dsw-alias-interactive-bg-hover, rgba(255,255,255,0.03)); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
295
+ .dsh-cron-rec-card { display: flex; align-items: center; gap: 16px; padding: 14px 18px; background: var(--dsw-alias-bg-layer-3, #1c1c1c); border: 1px solid var(--dsw-alias-border-l2, #282828); border-radius: 12px; cursor: pointer; transition: background 0.15s; }
296
+ .dsh-cron-rec-card:hover, .dsh-cron-rec-card:focus-visible { background: var(--dsw-alias-bg-layer-4, #242424); border-color: var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2, #383838)); outline: none; }
297
+ .dsh-cron-rec-icon { width: 36px; height: 36px; border-radius: 10px; background: var(--dsw-alias-bg-layer-2, rgba(255,255,255,0.03)); display: flex; align-items: center; justify-content: center; flex-shrink: 0; }
248
298
  .dsh-cron-rec-content { flex: 1; min-width: 0; }
249
299
  .dsh-cron-rec-title { font-size: 14.5px; font-weight: 500; margin-bottom: 4px; }
250
300
  .dsh-cron-rec-time { font-size: 13px; color: var(--dsw-alias-label-secondary, #9ca3af); font-weight: normal; margin-left: 8px; }
@@ -255,14 +305,17 @@ window.__ModuleLoader__.load({
255
305
  .dsh-cron-modal-title { font-size: 18px; font-weight: 600; margin-bottom: 18px; }
256
306
  .dsh-cron-form-group { margin-bottom: 16px; }
257
307
  .dsh-cron-form-group label { display: block; font-size: 13px; color: var(--dsw-alias-label-secondary, #aaa); margin-bottom: 6px; }
258
- .dsh-cron-form-group input, .dsh-cron-form-group textarea, .dsh-cron-form-group select { width: 100%; background: var(--dsw-alias-bg-base, #141414); border: 1px solid var(--dsw-alias-border-l2, #333); border-radius: 8px; padding: 9px 12px; color: var(--dsw-alias-label-primary, #fff); font-size: 13.5px; outline: none; box-sizing: border-box; }
308
+ .dsh-cron-form-group input, .dsh-cron-form-group textarea, .dsh-cron-form-group select { width: 100%; background: var(--dsw-alias-bg-layer-2, #141414); border: 1px solid var(--dsw-alias-border-l2, #333); border-radius: 8px; padding: 8px 12px; color: var(--dsw-alias-label-primary, #fff); font-size: 13px; outline: none; box-sizing: border-box; }
309
+ .dsh-cron-form-group input:focus, .dsh-cron-form-group textarea:focus, .dsh-cron-form-group select:focus { outline: none; border-color: var(--dsh-cron-info); }
259
310
  .dsh-cron-form-group select { cursor: pointer; }
260
311
  .dsh-cron-form-group textarea { min-height: 80px; resize: vertical; }
261
312
  .dsh-cron-form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; }
262
313
  .dsh-cron-modal-foot { display: flex; justify-content: flex-end; align-items: center; gap: 10px; margin-top: 20px; }
263
- .dsh-cron-btn-primary { background: var(--dsw-alias-label-primary, #ededed); color: var(--dsw-alias-bg-base, #111); border: none; border-radius: 8px; padding: 7px 16px; font-size: 13.5px; font-weight: 500; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; }
314
+ .dsh-cron-btn-primary { background: var(--dsw-alias-label-primary, #ededed); color: var(--dsw-alias-bg-layer-3, #111); border: 1px solid transparent; border-radius: 8px; padding: 7px 14px; font-size: 13px; font-weight: 500; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; }
315
+ .dsh-cron-btn-primary:hover:not(:disabled) { opacity: 0.88; }
264
316
  .dsh-cron-btn-primary:disabled { opacity: 0.5; cursor: not-allowed; }
265
- .dsh-cron-btn-secondary { background: transparent; color: var(--dsw-alias-label-secondary, #999); border: 1px solid var(--dsw-alias-border-l2, #383838); border-radius: 8px; padding: 7px 16px; font-size: 13.5px; cursor: pointer; }
317
+ .dsh-cron-btn-secondary { appearance: none; font: inherit; background: var(--dsw-alias-bg-layer-2, transparent); color: var(--dsw-alias-label-primary, #999); border: 1px solid var(--dsw-alias-border-l2, #383838); border-radius: 8px; padding: 7px 14px; font-size: 13px; font-weight: 500; cursor: pointer; display: inline-flex; align-items: center; gap: 6px; transition: all 0.15s ease; }
318
+ .dsh-cron-btn-secondary:hover:not(:disabled) { background: var(--dsw-alias-bg-layer-4, #2a2a2a); border-color: var(--dsw-alias-label-dimmed, var(--dsw-alias-border-l2, #444)); }
266
319
  .dsh-cron-chat-box { position: relative; background: var(--dsw-alias-bg-base, #141414); border: 1px solid var(--dsw-alias-border-l2, #333); border-radius: 12px; padding: 12px; }
267
320
  .dsh-cron-chat-input { width: 100%; background: transparent; border: none; color: var(--dsw-alias-label-primary, #fff); font-size: 14px; min-height: 100px; resize: vertical; outline: none; box-sizing: border-box; line-height: 1.5; }
268
321
  .dsh-cron-chat-foot { display: flex; justify-content: space-between; align-items: center; margin-top: 8px; padding-top: 8px; border-top: 1px solid var(--dsw-alias-border-l1, #222); }
@@ -271,10 +324,15 @@ window.__ModuleLoader__.load({
271
324
  .dsh-cron-send-btn:hover:not(:disabled) { transform: translateY(-1px); }
272
325
  .dsh-cron-send-btn:disabled { opacity: 0.4; cursor: not-allowed; }
273
326
 
274
- .dsh-cron-card { background: var(--dsw-alias-bg-layer-2, #1f1f1f); border: 1px solid var(--dsw-alias-border-l1, #333); border-radius: 12px; padding: 18px 20px; margin-bottom: 16px; }
327
+ .dsh-cron-card { background: var(--dsw-alias-bg-layer-3, #1f1f1f); border: 1px solid var(--dsw-alias-border-l2, #333); border-radius: 12px; padding: 18px 20px; margin-bottom: 0; display: flex; flex-direction: column; gap: 14px; }
328
+ .dsh-cron-card-head-btn { appearance: none; width: 100%; font: inherit; color: inherit; text-align: left; cursor: pointer; background: 0 0; border: 0; border-radius: 12px; display: flex; align-items: center; gap: 12px; padding: 0; }
329
+ .dsh-cron-card-head-btn .dsh-cron-card-title { flex: 1; }
330
+ .dsh-cron-card-title { font-size: 16px; font-weight: 600; color: var(--dsw-alias-label-primary, #fff); }
331
+ .dsh-cron-chev { margin-left: auto; flex: none; color: var(--dsw-alias-label-tertiary, #888); transition: transform 0.16s; display: flex; align-items: center; }
332
+ .dsh-cron-chev-open { transform: rotate(180deg); }
333
+ .dsh-cron-card-body { border-top: 1px solid var(--dsw-alias-border-l2, #333); margin-top: 2px; padding-top: 14px; }
275
334
  .dsh-cron-card-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 12px; }
276
- .dsh-cron-card-title { font-size: 15px; font-weight: 600; color: var(--dsw-alias-label-primary, #fff); }
277
- .dsh-cron-card-desc { font-size: 13px; color: var(--dsw-alias-label-secondary, #888); margin-bottom: 16px; line-height: 1.4; }
335
+ .dsh-cron-card-desc { font-size: 13px; color: var(--dsw-alias-label-secondary, #888); line-height: 1.4; }
278
336
 
279
337
  .dsh-cron-modal-nav { display: flex; gap: 4px; border-bottom: 1px solid var(--dsw-alias-border-l2, #2e2e2e); margin-bottom: 18px; padding-bottom: 2px; }
280
338
  .dsh-cron-modal-tab { appearance: none; background: transparent; border: none; border-bottom: 2px solid transparent; padding: 8px 14px; font-size: 13.5px; color: var(--dsw-alias-label-tertiary, #888); cursor: pointer; font-weight: 500; transition: color 0.15s, border-color 0.15s; }
@@ -283,21 +341,21 @@ window.__ModuleLoader__.load({
283
341
  .dsh-cron-history-list { display: flex; flex-direction: column; gap: 10px; max-height: 420px; overflow-y: auto; padding-right: 4px; }
284
342
  .dsh-cron-history-item { background: var(--dsw-alias-bg-base, #161616); border: 1px solid var(--dsw-alias-border-l1, #2a2a2a); border-radius: 8px; padding: 12px 14px; font-size: 12.5px; }
285
343
  .dsh-cron-history-head { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
286
- .dsh-cron-history-status { font-weight: 600; font-size: 11.5px; text-transform: uppercase; padding: 2px 6px; border-radius: 4px; }
287
- .dsh-cron-history-status[data-status="success"] { background: var(--dsh-cron-success-bg); color: var(--dsh-cron-success); }
288
- .dsh-cron-history-status[data-status="error"], .dsh-cron-history-status[data-status="timeout"] { background: var(--dsh-cron-danger-bg); color: var(--dsh-cron-danger); }
289
- .dsh-cron-history-status[data-status="skipped"], .dsh-cron-history-status[data-status="missed"] { background: var(--dsh-cron-warning-bg); color: var(--dsh-cron-warning); }
344
+ .dsh-cron-history-status { font-weight: 500; font-size: 11px; text-transform: uppercase; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--dsw-alias-border-l2, #383838); }
345
+ .dsh-cron-history-status[data-status="success"] { background: var(--dsh-cron-success-bg); color: var(--dsh-cron-success); border-color: var(--dsh-cron-success); }
346
+ .dsh-cron-history-status[data-status="error"], .dsh-cron-history-status[data-status="timeout"] { background: var(--dsh-cron-danger-bg); color: var(--dsh-cron-danger); border-color: var(--dsh-cron-danger); }
347
+ .dsh-cron-history-status[data-status="skipped"], .dsh-cron-history-status[data-status="missed"] { background: var(--dsh-cron-warning-bg); color: var(--dsh-cron-warning); border-color: var(--dsh-cron-warning); }
290
348
  .dsh-cron-history-time { color: var(--dsw-alias-label-tertiary, #777); font-size: 12px; }
291
349
  .dsh-cron-history-output { background: var(--dsw-alias-bg-base, #0c0c0c); border: 1px solid var(--dsw-alias-border-l1, #222); border-radius: 6px; padding: 8px 10px; font-family: monospace; font-size: 12px; color: var(--dsw-alias-label-secondary, #bbb); max-height: 140px; overflow-y: auto; white-space: pre-wrap; word-break: break-all; margin-top: 6px; }
292
350
  .dsh-cron-history-empty { text-align: center; padding: 36px 0; color: var(--dsw-alias-label-tertiary, #666); font-size: 13.5px; }
293
351
 
294
- .dsh-cron-stats-bar { display: grid; grid-template-columns: repeat(4, 1fr); gap: 10px; margin-bottom: 18px; }
295
- .dsh-cron-stat-card { background: var(--dsw-alias-bg-layer-3, rgba(255, 255, 255, 0.02)); border: 1px solid var(--dsw-alias-border-l1, #222); border-radius: 8px; padding: 10px 14px; }
296
- .dsh-cron-stat-val { font-size: 16px; font-weight: 600; color: var(--dsw-alias-label-primary, #fff); }
297
- .dsh-cron-stat-lbl { font-size: 11.5px; color: var(--dsw-alias-label-tertiary, #777); margin-top: 2px; }
298
- .dsh-cron-oneshot-tag { font-size: 11px; padding: 2px 6px; border-radius: 4px; background: var(--dsh-cron-accent-bg); color: var(--dsh-cron-accent); font-weight: 500; }
299
- .dsh-cron-cost-tag { font-size: 11px; padding: 2px 6px; border-radius: 4px; background: var(--dsh-cron-success-bg); color: var(--dsh-cron-success); font-weight: 500; }
300
- .dsh-cron-tokens-tag { font-size: 11px; padding: 2px 6px; border-radius: 4px; background: var(--dsh-cron-info-bg); color: var(--dsh-cron-info); }
352
+ .dsh-cron-stats-bar { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 10px; margin-bottom: 0; }
353
+ .dsh-cron-stat-card { background: var(--dsw-alias-bg-layer-2, rgba(255, 255, 255, 0.02)); border: 1px solid var(--dsw-alias-border-l2, #222); border-radius: 8px; padding: 12px 14px; display: flex; flex-direction: column; gap: 4px; }
354
+ .dsh-cron-stat-val { font-size: 18px; font-weight: 700; color: var(--dsw-alias-label-primary, #fff); }
355
+ .dsh-cron-stat-lbl { font-size: 12px; color: var(--dsw-alias-label-secondary, #777); margin-top: 0; }
356
+ .dsh-cron-oneshot-tag { display: inline-flex; align-items: center; font-size: 11px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--dsh-cron-accent); background: var(--dsh-cron-accent-bg); color: var(--dsh-cron-accent); font-weight: 500; }
357
+ .dsh-cron-cost-tag { display: inline-flex; align-items: center; font-size: 11px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--dsh-cron-success); background: var(--dsh-cron-success-bg); color: var(--dsh-cron-success); font-weight: 500; }
358
+ .dsh-cron-tokens-tag { display: inline-flex; align-items: center; font-size: 11px; padding: 2px 8px; border-radius: 999px; border: 1px solid var(--dsh-cron-info); background: var(--dsh-cron-info-bg); color: var(--dsh-cron-info); }
301
359
  .dsh-cron-checkbox-row { display: flex; align-items: center; gap: 8px; font-size: 13px; color: var(--dsw-alias-label-secondary, #ccc); cursor: pointer; user-select: none; }
302
360
  .dsh-cron-checkbox-row input { width: 15px; height: 15px; margin: 0; cursor: pointer; accent-color: var(--dsw-alias-label-primary, #ededed); }
303
361
  .dsh-cron-hint { font-size: 12px; color: var(--dsw-alias-label-tertiary, #888); margin-top: 4px; }
@@ -930,7 +988,7 @@ window.__ModuleLoader__.load({
930
988
  }, React.createElement('span', svgProps(ICON_EDIT))),
931
989
  React.createElement('button', {
932
990
  type: 'button',
933
- className: 'dsh-cron-icon-btn',
991
+ className: 'dsh-cron-icon-btn dsh-cron-danger',
934
992
  title: T_KEY(t, 'actions.delete'),
935
993
  'aria-label': T_KEY(t, 'actions.delete'),
936
994
  onClick: () => handleDelete(task.id)
@@ -1355,24 +1413,36 @@ window.__ModuleLoader__.load({
1355
1413
  const [hasDefault, setHasDefault] = React.useState(false);
1356
1414
  const [saved, setSaved] = React.useState(false);
1357
1415
  const [testInfo, setTestInfo] = React.useState(null);
1416
+ // Collapsed by default, head is the toggle (canonical card contract, #100)
1417
+ const [cardOpen, setCardOpen] = React.useState(false);
1418
+
1419
+ // Settings snapshot status (#102): never render phantom inputs before
1420
+ // the snapshot arrives; offer a retry when it is unavailable.
1421
+ const [loadState, setLoadState] = React.useState('loading');
1422
+ const [reloadTick, setReloadTick] = React.useState(0);
1358
1423
 
1359
1424
  React.useEffect(() => {
1360
1425
  let active = true;
1426
+ setLoadState('loading');
1361
1427
  fetch('/dsh-cron/settings')
1362
1428
  .then(r => r.json())
1363
1429
  .then(data => {
1364
- if (active && data && data.ok && data.settings) {
1430
+ if (!active) return;
1431
+ if (data && data.ok && data.settings) {
1365
1432
  setToken(data.settings.botToken || '');
1366
1433
  setChatId(data.settings.chatId || '');
1367
1434
  setNotify(Boolean(data.settings.notifyTelegram));
1368
1435
  setOnlyFailure(Boolean(data.settings.onlyOnFailure));
1369
1436
  setKanbanUrl(data.settings.kanbanBaseUrl || 'http://127.0.0.1:3000');
1370
1437
  setHasDefault(Boolean(data.settings.hasDefaultCredentials));
1438
+ setLoadState('ready');
1439
+ } else {
1440
+ setLoadState('error');
1371
1441
  }
1372
1442
  })
1373
- .catch(() => {});
1443
+ .catch(() => { if (active) setLoadState('error'); });
1374
1444
  return () => { active = false; };
1375
- }, []);
1445
+ }, [reloadTick]);
1376
1446
 
1377
1447
  const saveSettings = async (e) => {
1378
1448
  if (e) e.preventDefault();
@@ -1434,19 +1504,44 @@ window.__ModuleLoader__.load({
1434
1504
  }
1435
1505
  };
1436
1506
 
1507
+ if (loadState !== 'ready') {
1508
+ return React.createElement('div', { className: 'dsh-cron-card' },
1509
+ React.createElement('div', { className: 'dsh-cron-card-title' }, T_KEY(t, 'settings.cardTitle')),
1510
+ React.createElement('div', {
1511
+ className: 'dsh-cron-status-banner',
1512
+ 'data-type': loadState === 'error' ? 'error' : 'info',
1513
+ style: { marginTop: '10px', display: 'flex', justifyContent: 'space-between', alignItems: 'center', gap: '10px' }
1514
+ },
1515
+ React.createElement('span', null, loadState === 'error' ? T_KEY(t, 'settings.unavailable') : T_KEY(t, 'settings.loading')),
1516
+ loadState === 'error' && React.createElement('button', {
1517
+ type: 'button',
1518
+ className: 'dsh-cron-btn-secondary',
1519
+ onClick: () => setReloadTick(x => x + 1)
1520
+ }, T_KEY(t, 'settings.retry'))
1521
+ )
1522
+ );
1523
+ }
1524
+
1437
1525
  return React.createElement('div', { className: 'dsh-cron-card' },
1438
- React.createElement('div', { className: 'dsh-cron-card-head' },
1526
+ React.createElement('button', {
1527
+ type: 'button',
1528
+ className: 'dsh-cron-card-head-btn',
1529
+ 'aria-expanded': cardOpen ? 'true' : 'false',
1530
+ onClick: () => setCardOpen(!cardOpen)
1531
+ },
1439
1532
  React.createElement('span', { className: 'dsh-cron-card-title' }, T_KEY(t, 'settings.cardTitle')),
1440
- React.createElement('button', {
1441
- type: 'button',
1442
- className: 'dsh-cron-btn-primary',
1443
- onClick: () => { if (toggle) toggle.set(true); }
1444
- }, T_KEY(t, 'settings.openPanel'))
1445
- ),
1446
- React.createElement('div', { className: 'dsh-cron-card-desc', style: { marginBottom: '16px' } },
1447
- T_KEY(t, 'settings.cardDesc')
1533
+ React.createElement('span', { className: 'dsh-cron-chev' + (cardOpen ? ' dsh-cron-chev-open' : '') }, chevronNode())
1448
1534
  ),
1449
- React.createElement('form', { onSubmit: saveSettings, style: { display: 'flex', flexDirection: 'column', gap: '12px' } },
1535
+ cardOpen && React.createElement('div', { className: 'dsh-cron-card-body' },
1536
+ React.createElement('div', { className: 'dsh-cron-card-desc' }, T_KEY(t, 'settings.cardDesc')),
1537
+ React.createElement('div', { style: { display: 'flex', justifyContent: 'flex-end', marginBottom: '12px' } },
1538
+ React.createElement('button', {
1539
+ type: 'button',
1540
+ className: 'dsh-cron-btn-primary',
1541
+ onClick: () => { if (toggle) toggle.set(true); }
1542
+ }, T_KEY(t, 'settings.openPanel'))
1543
+ ),
1544
+ React.createElement('form', { onSubmit: saveSettings, style: { display: 'flex', flexDirection: 'column', gap: '12px' } },
1450
1545
  React.createElement('div', { className: 'dsh-cron-form-group', style: { marginBottom: 0 } },
1451
1546
  React.createElement('label', null, T_KEY(t, 'settings.tokenLabel')),
1452
1547
  React.createElement('input', {
@@ -1516,7 +1611,8 @@ window.__ModuleLoader__.load({
1516
1611
  }, T_KEY(t, 'settings.testKanban'))
1517
1612
  )
1518
1613
  )
1519
- );
1614
+ )
1615
+ );
1520
1616
  }
1521
1617
 
1522
1618
  function nativeButton() {
@@ -1613,7 +1709,7 @@ window.__ModuleLoader__.load({
1613
1709
  column.appendChild(container);
1614
1710
  if (ReactDOM && typeof ReactDOM.createRoot === 'function') {
1615
1711
  root = ReactDOM.createRoot(container);
1616
- root.render(React.createElement(CronScreen, { ctx, toggle, onClose: () => toggle.set(false), t: translate }));
1712
+ root.render(React.createElement(ErrorBoundary, null, React.createElement(CronScreen, { ctx, toggle, onClose: () => toggle.set(false), t: translate })));
1617
1713
  } else if (ReactDOM && typeof ReactDOM.render === 'function') {
1618
1714
  ReactDOM.render(React.createElement(CronScreen, { ctx, toggle, onClose: () => toggle.set(false), t: translate }), container);
1619
1715
  }
@@ -1696,23 +1792,20 @@ window.__ModuleLoader__.load({
1696
1792
 
1697
1793
  const toggle = createToggle();
1698
1794
 
1795
+ // The settings card lives ONLY in the plugins settings tab (#102): no
1796
+ // top-level section fallback — the flat sidebar list is a shared core
1797
+ // resource, and the card slot is proven on current builds (#85).
1798
+ function SettingsCardWithBoundary(props) {
1799
+ return React.createElement(ErrorBoundary, null, CronSettingsCard(props));
1800
+ }
1699
1801
  const cardOk = registerIntoMount(ctx, 'settings.plugin.item', {
1700
1802
  name: 'settings.plugin.item',
1701
1803
  key: NS,
1702
1804
  locale: NS,
1703
1805
  inject: () => ({ ctx, toggle }),
1704
- }, CronSettingsCard);
1806
+ }, SettingsCardWithBoundary);
1705
1807
  if (!cardOk) {
1706
- // Fallback for builds without the settings.plugin.item mount: a
1707
- // dedicated section so the settings are never lost (#85).
1708
- registerIntoMount(ctx, 'settings.section', {
1709
- name: 'settings.section',
1710
- id: PLUGIN_ID,
1711
- order: 34,
1712
- locale: NS,
1713
- label: () => translate('settings.sectionLabel'),
1714
- inject: () => ({ ctx, toggle }),
1715
- }, CronSettingsCard);
1808
+ console.warn('[dsh-cron] settings.plugin.item slot unavailable — settings card not registered');
1716
1809
  }
1717
1810
 
1718
1811
  const chipOk = registerIntoMount(ctx, 'conversation.session.header.utilities', {
@@ -1722,7 +1815,7 @@ window.__ModuleLoader__.load({
1722
1815
  locale: NS,
1723
1816
  inject: () => ({ ctx, toggle }),
1724
1817
  }, CronChip);
1725
- const cardSlot = cardOk ? 'settings.plugin.item' : 'settings.section';
1818
+ const cardSlot = cardOk ? 'settings.plugin.item' : undefined;
1726
1819
  const chipSlot = chipOk ? 'conversation.session.header.utilities' : undefined;
1727
1820
 
1728
1821
  function CronChip(props) {
package/lib/http-utils.js CHANGED
@@ -8,12 +8,15 @@ export const MAX_BODY_BYTES = 1024 * 1024;
8
8
  export const SCRIPT_CONFIRM_HEADER = 'x-dsh-cron-confirm';
9
9
 
10
10
  /**
11
- * Parse a JSON request body with a hard size cap.
11
+ * Parse a JSON request body with a hard byte size cap.
12
+ * Chunks are accumulated as Buffers so multi-byte UTF-8 characters split
13
+ * across chunk boundaries decode correctly (#106, #97 follow-up).
12
14
  * Rejects with err.statusCode = 413 when the body exceeds the limit.
13
15
  */
14
16
  export function parseJsonBody(req, limit = MAX_BODY_BYTES) {
15
17
  return new Promise((resolve, reject) => {
16
- let data = '';
18
+ const chunks = [];
19
+ let bytes = 0;
17
20
  let settled = false;
18
21
  const fail = (err) => {
19
22
  if (settled) return;
@@ -22,17 +25,20 @@ export function parseJsonBody(req, limit = MAX_BODY_BYTES) {
22
25
  };
23
26
  req.on('data', (chunk) => {
24
27
  if (settled) return;
25
- data += chunk;
26
- if (data.length > limit) {
28
+ const buf = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
29
+ bytes += buf.length;
30
+ if (bytes > limit) {
27
31
  fail(Object.assign(new Error('Request body exceeds the allowed size'), { statusCode: 413 }));
28
32
  req.destroy();
33
+ return;
29
34
  }
35
+ chunks.push(buf);
30
36
  });
31
37
  req.on('end', () => {
32
38
  if (settled) return;
33
39
  settled = true;
34
40
  try {
35
- resolve(data ? JSON.parse(data) : {});
41
+ resolve(chunks.length ? JSON.parse(Buffer.concat(chunks).toString('utf8')) : {});
36
42
  } catch (err) {
37
43
  reject(err);
38
44
  }
package/lib/index.js CHANGED
@@ -66,6 +66,37 @@ async function readBody(req, res) {
66
66
  }
67
67
  }
68
68
 
69
+ /**
70
+ * Apply settings writes through the registered settings scope so the core
71
+ * snapshot stays authoritative (#102). Returns null when the settings
72
+ * service is unavailable (headless profiles fall back to store-only writes).
73
+ */
74
+ export async function applySettingsToScope(settingsScope, body) {
75
+ if (!settingsScope || typeof settingsScope.set !== 'function') return null;
76
+ const errors = [];
77
+ for (const key of ['botToken', 'chatId', 'notifyTelegram', 'onlyOnFailure', 'kanbanBaseUrl']) {
78
+ if (!Object.prototype.hasOwnProperty.call(body, key)) continue;
79
+ try {
80
+ await settingsScope.set(key, body[key]);
81
+ } catch (e) {
82
+ errors.push(key + ': ' + ((e && e.message) || e));
83
+ }
84
+ }
85
+ return errors;
86
+ }
87
+
88
+ /**
89
+ * Drop values that must never re-enter the store from the client: a masked
90
+ * secret echoed back by the settings UI would overwrite the real token.
91
+ */
92
+ export function sanitizeSettingsPayload(body) {
93
+ const payload = Object.assign({}, body);
94
+ if (typeof payload.botToken === 'string' && (payload.botToken.includes('••') || payload.botToken.includes('****'))) {
95
+ delete payload.botToken;
96
+ }
97
+ return payload;
98
+ }
99
+
69
100
  /**
70
101
  * Shared implementation for the /dsh-cron/tasks... and /dsh-cron/action...
71
102
  * route families — the #66 alias is kept alive by the same code path instead
@@ -73,9 +104,9 @@ async function readBody(req, res) {
73
104
  *
74
105
  * IMPORTANT: prefix routes must be registered WITHOUT a trailing slash —
75
106
  * with one, the core degrades matching to exact-path equality and every
76
- * deeper path unrouted (#96).
107
+ * deeper path unrouted (#96). Exported for unit tests.
77
108
  */
78
- function createCronApiHandler(store, scheduler, collection) {
109
+ export function createCronApiHandler(store, scheduler, collection) {
79
110
  return async function handleCronApi(req, res) {
80
111
  try {
81
112
  const url = new URL(req.url, 'http://127.0.0.1');
@@ -155,6 +186,10 @@ function createCronApiHandler(store, scheduler, collection) {
155
186
  }
156
187
 
157
188
  if (req.method === 'POST' && action === 'run') {
189
+ if (!store.get(id)) {
190
+ sendJson(res, 404, { ok: false, error: 'Task not found' });
191
+ return;
192
+ }
158
193
  await scheduler.triggerManualRun(id);
159
194
  sendJson(res, 200, { ok: true, task: store.get(id) });
160
195
  return;
@@ -240,9 +275,9 @@ function createCronApiHandler(store, scheduler, collection) {
240
275
 
241
276
  /**
242
277
  * Single implementation behind the cron_create_task / cron_schedule_task
243
- * alias pair (#70, #92).
278
+ * alias pair (#70, #92). Exported for unit tests.
244
279
  */
245
- function executeCreateTask(store, scheduler, args) {
280
+ export function executeCreateTask(store, scheduler, args) {
246
281
  const taskType = args.type === 'script' ? 'script' : 'llm';
247
282
  const parsed = parseScheduleExpression(args.schedule);
248
283
  const task = store.set({
@@ -303,19 +338,26 @@ export function apply(ctx, config) {
303
338
  const runner = new SessionRunner(ctx);
304
339
  const scheduler = new TaskScheduler(store, (task, opts) => runner.execute(task, opts));
305
340
 
306
- // Register settings in Harness settings service with Config schema and initial state from store (#71)
341
+ // Register settings in the Harness settings service (#71, #102): the scope
342
+ // is the source of truth for the core, scope.watch mirrors values into the
343
+ // plugin store, and REST writes go through scope.set so nothing bypasses
344
+ // the settings service.
345
+ let cronSettingsScope = null;
307
346
  ctx.inject(['settings'], (sctx) => {
308
347
  try {
309
- const scope = sctx.settings?.register?.('dsh-cron', Config, {
348
+ cronSettingsScope = sctx.settings.register('dsh-cron', Config, {
310
349
  base: store.getSettings()
311
350
  });
312
- if (scope && typeof scope.watch === 'function') {
313
- scope.watch((updated) => {
351
+ if (cronSettingsScope && typeof cronSettingsScope.watch === 'function') {
352
+ cronSettingsScope.watch((updated) => {
314
353
  if (updated && typeof updated === 'object') {
315
354
  store.saveSettings(updated);
316
355
  }
317
356
  });
318
357
  }
358
+ sctx.effect(() => () => {
359
+ cronSettingsScope = null;
360
+ });
319
361
  } catch (e) {
320
362
  console.warn('[dsh-cron] settings registration warning:', e.message);
321
363
  }
@@ -407,8 +449,14 @@ export function apply(ctx, config) {
407
449
  if (rejectCrossOrigin(req, res)) return;
408
450
  const { body, error } = await readBody(req, res);
409
451
  if (error) return;
410
- store.saveSettings(body);
411
- sendJson(res, 200, { ok: true, settings: store.getClientSettings() });
452
+ // Masked secrets echoed back by the client must never overwrite the
453
+ // stored token (store.saveSettings guards this too, #86-era).
454
+ const payload = sanitizeSettingsPayload(body);
455
+ const scopeErrors = await applySettingsToScope(cronSettingsScope, payload);
456
+ if (scopeErrors === null) {
457
+ store.saveSettings(payload);
458
+ }
459
+ sendJson(res, 200, { ok: true, settings: store.getClientSettings(), scopeErrors: scopeErrors || undefined });
412
460
  return;
413
461
  }
414
462
  sendJson(res, 405, { ok: false, error: 'Method not allowed' });
package/lib/scheduler.js CHANGED
@@ -434,40 +434,7 @@ export class TaskScheduler {
434
434
  }
435
435
  }
436
436
 
437
- removeTask(taskId) {
438
- if (this.jobs.has(taskId)) {
439
- this.jobs.get(taskId).stop();
440
- this.jobs.delete(taskId);
441
- }
442
- if (this.timers.has(taskId)) {
443
- clearTimeout(this.timers.get(taskId));
444
- this.timers.delete(taskId);
445
- }
446
- const running = this.running.get(taskId);
447
- if (running) {
448
- try {
449
- running.controller.abort(new Error('Task deleted'));
450
- } catch {}
451
- this.running.delete(taskId);
452
- }
453
- return this.store.delete(taskId);
454
- }
455
-
456
437
  triggerManualRun(taskId) {
457
438
  return this.runTask(taskId);
458
439
  }
459
-
460
- getNextRun(schedule) {
461
- try {
462
- const parsed = parseScheduleExpression(schedule);
463
- if (parsed.isOneShot) {
464
- return parsed.targetTimestamp;
465
- }
466
- const tempJob = new Cron(parsed.cronPattern);
467
- const next = tempJob.nextRun();
468
- return next ? next.getTime() : null;
469
- } catch {
470
- return null;
471
- }
472
- }
473
440
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Scheduled cron tasks, background automation and agent execution for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",