@goodandready/dsh-cron 0.2.2 → 0.2.4

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/lib/client.js CHANGED
@@ -53,7 +53,7 @@ window.__ModuleLoader__.load({
53
53
  'actions.delete': 'Delete task',
54
54
  'actions.pause': 'Pause',
55
55
  'actions.resume': 'Resume',
56
- 'actions.settings': 'Telegram & Notifications',
56
+ 'actions.settings': 'Notifications & channels',
57
57
  'search.placeholder': 'Search scheduled tasks',
58
58
  'stats.activeTasks': 'Active tasks',
59
59
  'stats.totalRuns': 'Total runs',
@@ -94,7 +94,32 @@ window.__ModuleLoader__.load({
94
94
  'form.namePlaceholder': 'For example: dsh-tags-watch',
95
95
  'form.typeLabel': 'Execution type',
96
96
  'form.typeLlm': 'LLM (AI agent)',
97
- 'form.typeScript': 'NO-LLM (Shell command/script)',
97
+ 'form.typeScript': 'Shell command / script',
98
+ 'form.typeNode': 'Node.js',
99
+ 'form.typePython': 'Python',
100
+ 'form.typeHttp': 'HTTP request / webhook',
101
+ 'form.typeSsh': 'Remote SSH',
102
+ 'form.typeDocker': 'Docker container',
103
+ 'form.typeSkill': 'DSH Skill',
104
+ 'form.typeWorkflow': 'DSH Workflow',
105
+ 'form.envLabel': 'Environment variables (KEY=VALUE per line)',
106
+ 'form.envPlaceholder': 'NODE_ENV=production\nTZ=Europe/Berlin',
107
+ 'form.envHint': 'Applied to non-LLM runtimes only. Do not store secrets here.',
108
+ 'form.cwdLabel': 'Working directory (optional)',
109
+ 'form.workspaceLabel': 'Workspace id or path (optional)',
110
+ 'form.worktreeLabel': 'Run in an isolated git worktree',
111
+ 'form.keepWorktreeLabel': 'Keep the worktree after the run',
112
+ 'form.httpMethodLabel': 'HTTP method',
113
+ 'form.httpUrlLabel': 'URL',
114
+ 'form.httpHeadersLabel': 'Headers (JSON, optional)',
115
+ 'form.httpBodyLabel': 'Body (optional)',
116
+ 'form.sshProfileLabel': 'dsh-remote-workspace profile id',
117
+ 'form.sshProfileHint': 'Preferred: host and credentials stay in the remote-workspace plugin.',
118
+ 'form.sshTargetLabel': 'Fallback target (user@host, optional)',
119
+ 'form.sshKeyLabel': 'Private key path (fallback, optional)',
120
+ 'form.dockerImageLabel': 'Container image',
121
+ 'form.skillNameLabel': 'Skill name',
122
+ 'form.workflowNameLabel': 'Workflow name',
98
123
  'form.scheduleLabel': 'Schedule (cron or interval)',
99
124
  'form.schedulePlaceholder': '0 */6 * * * or every 2h or in 30m or at: 2026-09-05T15:00:00Z',
100
125
  'form.timeoutLabel': 'Execution timeout (seconds)',
@@ -114,18 +139,79 @@ window.__ModuleLoader__.load({
114
139
  'form.promptLabelLlm': 'Instruction / Prompt for the agent',
115
140
  'form.promptPlaceholderScript': 'curl -fsSL https://... || exit 1',
116
141
  'form.promptPlaceholderLlm': 'Describe exactly what the agent should do...',
142
+ 'form.timezoneLabel': 'Time zone (IANA, optional)',
143
+ 'form.timezonePlaceholder': 'e.g. Europe/Berlin (empty = server local)',
144
+ 'form.misfireLabel': 'On missed run (after downtime)',
145
+ 'form.misfireSkip': 'Skip the missed run (skip)',
146
+ 'form.misfireRunOnce': 'Run once late (runOnce)',
147
+ 'form.misfireCatchUp': 'Run late once and record the gap (catchUpAll)',
148
+ 'form.retriesLabel': 'Automatic retries on failure (0 = off)',
149
+ 'form.permissionLabel': 'Session permissions',
150
+ 'form.permissionDefault': 'Default (as configured in DSH)',
151
+ 'form.permissionReadonly': 'Read-only',
152
+ 'form.permissionWrite': 'Workspace write',
153
+ 'form.permissionFull': 'Full',
154
+ 'list.runningNow': 'Running now',
155
+ 'history.openSession': 'Open session',
117
156
  'form.notifyTitle': 'Telegram notifications',
118
157
  'form.notifyTelegram': 'Send the run report to Telegram',
119
158
  'form.notifyOnlyFailure': 'Only on failures (onlyOnFailure / silent on success)',
159
+ 'form.channelsTitle': 'Delivery channels',
160
+ 'form.channelsHint': 'Pick the channels for this task. An explicit selection overrides the Telegram switch above; leave everything unchecked to fall back to it.',
161
+ 'form.templateLabel': 'Message template (optional)',
162
+ 'form.templatePlaceholder': '⏰ {title} — {status} in {duration}',
163
+ 'form.templateHint': 'Variables: {title} {id} {status} {output} {error} {duration} {schedule} {time} {tokens} {cost}. Empty = built-in text.',
164
+ 'form.channelTelegram': 'Telegram',
165
+ 'form.channelKanban': 'dsh-kanban card',
166
+ 'form.channelDiscord': 'Discord',
167
+ 'form.channelSlack': 'Slack',
168
+ 'form.channelNtfy': 'ntfy',
169
+ 'form.channelBark': 'Bark',
170
+ 'form.channelPushplus': 'PushPlus',
171
+ 'form.channelEmail': 'Email',
172
+ 'form.channelTts': 'Voice (dsh-tts)',
173
+ 'form.channelGitea': 'Gitea issue',
174
+ 'settings.deliverySection': 'Delivery channels',
175
+ 'settings.secretsSection': 'Credentials (references)',
176
+ 'settings.secretsHint': 'Enter the NAME of a DSH credential, not the secret itself. Secrets stay in the credentials store.',
177
+ 'settings.telegramSection': 'Telegram',
178
+ 'settings.templatesSection': 'Message templates',
179
+ 'settings.channelTemplateLabel': 'Template for {channel}',
180
+ 'settings.botTokenRefLabel': 'Bot token credential name',
181
+ 'settings.botTokenRefPlaceholder': 'e.g. TELEGRAM_BOT_TOKEN',
182
+ 'settings.discordLabel': 'Discord webhook URL',
183
+ 'settings.slackLabel': 'Slack webhook URL',
184
+ 'settings.ntfyUrlLabel': 'ntfy server URL',
185
+ 'settings.ntfyTopicLabel': 'ntfy topic',
186
+ 'settings.ntfyTokenRefLabel': 'ntfy token credential name',
187
+ 'settings.barkServerLabel': 'Bark server URL',
188
+ 'settings.barkKeyLabel': 'Bark device key',
189
+ 'settings.pushplusTokenRefLabel': 'PushPlus token credential name',
190
+ 'settings.pushplusUrlLabel': 'PushPlus endpoint',
191
+ 'settings.smtpHostLabel': 'SMTP host',
192
+ 'settings.smtpPortLabel': 'SMTP port',
193
+ 'settings.smtpUserLabel': 'SMTP user',
194
+ 'settings.smtpPasswordRefLabel': 'SMTP password credential name',
195
+ 'settings.smtpFromLabel': 'From address',
196
+ 'settings.smtpToLabel': 'Recipients (comma separated)',
197
+ 'settings.smtpSecureLabel': 'SMTP over TLS (implicit)',
198
+ 'settings.ttsBaseUrlLabel': 'dsh-tts base URL',
199
+ 'settings.giteaBaseUrlLabel': 'Gitea base URL',
200
+ 'settings.giteaRepoLabel': 'Gitea repository (owner/repo)',
201
+ 'settings.giteaTokenRefLabel': 'Gitea token credential name',
202
+ 'settings.templateLabel': 'Default message template',
203
+ 'settings.templateHint': 'Applies to every channel without its own template. Empty = built-in text.',
204
+ 'settings.deliveryTimeoutMsLabel': 'Delivery timeout per channel (ms)',
205
+ 'settings.secretsOnCard': 'Credentials referenced from the settings panel are shown masked and stored by name.',
120
206
  'form.nextRunLabel': 'Next run: ',
121
207
  'form.notScheduled': 'not scheduled',
122
208
  'settings.loading': 'Loading settings...',
123
209
  'settings.unavailable': 'Settings are unavailable right now.',
124
210
  'settings.retry': 'Retry',
125
211
  'settings.cardTitle': '⏰ Task scheduler (Cron)',
126
- 'settings.cardDesc': 'Scheduled task execution (cron/intervals), background agent scenarios and Telegram/Kanban integration.',
212
+ 'settings.cardDesc': 'Scheduled task execution (cron/intervals), background agent scenarios, and delivery of run reports to Telegram, Discord, Slack, ntfy, Bark, PushPlus, email, voice and Gitea.',
127
213
  'settings.openPanel': 'Open task panel',
128
- 'settings.modalTitle': 'Telegram & notification settings',
214
+ 'settings.modalTitle': 'Notifications & delivery channels',
129
215
  'settings.tokenLabel': 'Telegram Bot Token',
130
216
  'settings.tokenPlaceholder': '123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ',
131
217
  'settings.tokenPlaceholderDefault': 'Using the bot configured in DSH settings (or enter your own)',
@@ -217,6 +303,169 @@ window.__ModuleLoader__.load({
217
303
  }
218
304
  const ErrorBoundary = createErrorBoundary();
219
305
 
306
+ const AGENT_FORM_TYPES = ['llm', 'skill', 'workflow'];
307
+ const CODE_FORM_TYPES = ['script', 'node', 'python', 'ssh', 'docker'];
308
+
309
+ // Delivery channels (#26) — must stay in sync with lib/channels.js.
310
+ const DELIVERY_CHANNELS = ['telegram', 'kanban', 'discord', 'slack', 'ntfy', 'bark', 'pushplus', 'email', 'tts', 'gitea'];
311
+ const CHANNEL_LABEL_KEYS = {
312
+ telegram: 'form.channelTelegram',
313
+ kanban: 'form.channelKanban',
314
+ discord: 'form.channelDiscord',
315
+ slack: 'form.channelSlack',
316
+ ntfy: 'form.channelNtfy',
317
+ bark: 'form.channelBark',
318
+ pushplus: 'form.channelPushplus',
319
+ email: 'form.channelEmail',
320
+ tts: 'form.channelTts',
321
+ gitea: 'form.channelGitea',
322
+ };
323
+
324
+ // Non-secret delivery settings shown in the settings panel. Secrets are
325
+ // referenced by credential name only (#51) — never typed as values.
326
+ const DELIVERY_SETTING_FIELDS = [
327
+ { key: 'botTokenRef', labelKey: 'settings.botTokenRefLabel', placeholderKey: 'settings.botTokenRefPlaceholder', group: 'secrets' },
328
+ { key: 'ntfyTokenRef', labelKey: 'settings.ntfyTokenRefLabel', group: 'secrets' },
329
+ { key: 'pushplusTokenRef', labelKey: 'settings.pushplusTokenRefLabel', group: 'secrets' },
330
+ { key: 'smtpPasswordRef', labelKey: 'settings.smtpPasswordRefLabel', group: 'secrets' },
331
+ { key: 'giteaTokenRef', labelKey: 'settings.giteaTokenRefLabel', group: 'secrets' },
332
+ { key: 'discordWebhookUrl', labelKey: 'settings.discordLabel', group: 'channels' },
333
+ { key: 'slackWebhookUrl', labelKey: 'settings.slackLabel', group: 'channels' },
334
+ { key: 'ntfyUrl', labelKey: 'settings.ntfyUrlLabel', group: 'channels' },
335
+ { key: 'ntfyTopic', labelKey: 'settings.ntfyTopicLabel', group: 'channels' },
336
+ { key: 'barkServerUrl', labelKey: 'settings.barkServerLabel', group: 'channels' },
337
+ { key: 'barkKey', labelKey: 'settings.barkKeyLabel', group: 'channels' },
338
+ { key: 'pushplusUrl', labelKey: 'settings.pushplusUrlLabel', group: 'channels' },
339
+ { key: 'smtpHost', labelKey: 'settings.smtpHostLabel', group: 'channels' },
340
+ { key: 'smtpPort', labelKey: 'settings.smtpPortLabel', group: 'channels' },
341
+ { key: 'smtpUser', labelKey: 'settings.smtpUserLabel', group: 'channels' },
342
+ { key: 'smtpFrom', labelKey: 'settings.smtpFromLabel', group: 'channels' },
343
+ { key: 'smtpTo', labelKey: 'settings.smtpToLabel', group: 'channels' },
344
+ { key: 'ttsBaseUrl', labelKey: 'settings.ttsBaseUrlLabel', group: 'channels' },
345
+ { key: 'giteaBaseUrl', labelKey: 'settings.giteaBaseUrlLabel', group: 'channels' },
346
+ { key: 'giteaRepo', labelKey: 'settings.giteaRepoLabel', group: 'channels' },
347
+ { key: 'deliveryTimeoutMs', labelKey: 'settings.deliveryTimeoutMsLabel', group: 'channels', numeric: true },
348
+ ];
349
+ const DELIVERY_TOGGLE_FIELDS = [
350
+ { key: 'smtpSecure', labelKey: 'settings.smtpSecureLabel' },
351
+ ];
352
+ const DELIVERY_SETTING_KEYS = DELIVERY_SETTING_FIELDS.map((f) => f.key).concat(DELIVERY_TOGGLE_FIELDS.map((f) => f.key));
353
+ // The global template plus the per-channel override map (server model).
354
+ const TEMPLATE_SETTING_KEYS = ['template', 'channelTemplates'];
355
+
356
+ /** Collect every delivery key the settings endpoints may return. */
357
+ function pickDeliverySettings(source) {
358
+ const out = {};
359
+ for (const key of DELIVERY_SETTING_KEYS.concat(TEMPLATE_SETTING_KEYS)) {
360
+ if (source && source[key] !== undefined && source[key] !== null) out[key] = source[key];
361
+ }
362
+ if (out.channelTemplates && typeof out.channelTemplates === 'object') {
363
+ out.channelTemplates = Object.assign({}, out.channelTemplates);
364
+ }
365
+ return out;
366
+ }
367
+
368
+ /**
369
+ * Shared delivery form used by both the task panel modal and the plugin
370
+ * settings card, so a new channel is added in exactly one place.
371
+ * Sections collapse via the canonical aria-expanded head contract.
372
+ */
373
+ function renderDeliverySettings({ T, values, onChange, onChannelTemplate, sections, onToggleSection }) {
374
+ const val = (key) => (values[key] === undefined || values[key] === null ? '' : String(values[key]));
375
+ const field = (f) => React.createElement('div', { className: 'dsh-cron-form-group', key: f.key, style: { marginBottom: '10px' } },
376
+ React.createElement('label', null, T(f.labelKey)),
377
+ React.createElement('input', f.numeric ? {
378
+ type: 'number',
379
+ min: 1000,
380
+ step: 1000,
381
+ value: val(f.key),
382
+ placeholder: '15000',
383
+ onChange: (e) => onChange(f.key, e.target.value === '' ? undefined : Number(e.target.value)),
384
+ } : {
385
+ type: 'text',
386
+ value: val(f.key),
387
+ placeholder: f.placeholderKey ? T(f.placeholderKey) : '',
388
+ onChange: (e) => onChange(f.key, e.target.value)
389
+ })
390
+ );
391
+ const section = (key, titleKey, children) => {
392
+ const open = Boolean(sections[key]);
393
+ return React.createElement('div', { className: 'dsh-cron-section', key },
394
+ React.createElement('button', {
395
+ type: 'button',
396
+ className: 'dsh-cron-section-head',
397
+ 'aria-expanded': open ? 'true' : 'false',
398
+ onClick: () => onToggleSection(key)
399
+ },
400
+ React.createElement('span', null, T(titleKey)),
401
+ React.createElement('span', { className: 'dsh-cron-section-chevron', 'data-open': open ? 'true' : 'false' }, '▾')
402
+ ),
403
+ open && React.createElement('div', { className: 'dsh-cron-section-body' }, children)
404
+ );
405
+ };
406
+
407
+ return [
408
+ section('secrets', 'settings.secretsSection', [
409
+ React.createElement('div', { className: 'dsh-cron-hint', key: 'hint', style: { marginBottom: '10px' } }, T('settings.secretsHint')),
410
+ ...DELIVERY_SETTING_FIELDS.filter((f) => f.group === 'secrets').map(field),
411
+ ]),
412
+ section('channels', 'settings.deliverySection', [
413
+ React.createElement('div', { className: 'dsh-cron-form-row', key: 'row' },
414
+ ...DELIVERY_SETTING_FIELDS.filter((f) => f.group === 'channels').map(field)
415
+ ),
416
+ ...DELIVERY_TOGGLE_FIELDS.map((f) => React.createElement('label', { className: 'dsh-cron-checkbox-row', key: f.key, style: { marginBottom: '10px' } },
417
+ React.createElement('input', {
418
+ type: 'checkbox',
419
+ checked: Boolean(values[f.key]),
420
+ onChange: (e) => onChange(f.key, e.target.checked)
421
+ }),
422
+ React.createElement('span', null, T(f.labelKey))
423
+ )),
424
+ ]),
425
+ section('templates', 'settings.templatesSection', [
426
+ React.createElement('div', { className: 'dsh-cron-form-group', key: 'global', style: { marginBottom: '10px' } },
427
+ React.createElement('label', null, T('settings.templateLabel')),
428
+ React.createElement('input', {
429
+ type: 'text',
430
+ value: val('template'),
431
+ onChange: (e) => onChange('template', e.target.value)
432
+ }),
433
+ React.createElement('div', { className: 'dsh-cron-hint' }, T('settings.templateHint'))
434
+ ),
435
+ React.createElement('div', { className: 'dsh-cron-form-row', key: 'perchannel' },
436
+ ...DELIVERY_CHANNELS.map((id) => React.createElement('div', { className: 'dsh-cron-form-group', key: id, style: { marginBottom: '10px' } },
437
+ React.createElement('label', null, T('settings.channelTemplateLabel', { channel: T(CHANNEL_LABEL_KEYS[id]) })),
438
+ React.createElement('input', {
439
+ type: 'text',
440
+ value: (values.channelTemplates && values.channelTemplates[id]) || '',
441
+ onChange: (e) => onChannelTemplate(id, e.target.value)
442
+ })
443
+ ))
444
+ ),
445
+ ]),
446
+ ];
447
+ }
448
+
449
+ /** Parse a KEY=VALUE textarea into the task env object (#38). */
450
+ function parseEnvText(text) {
451
+ const env = {};
452
+ for (const rawLine of String(text || '').split('\n')) {
453
+ const line = rawLine.trim();
454
+ if (!line || line.startsWith('#')) continue;
455
+ const idx = line.indexOf('=');
456
+ if (idx <= 0) continue;
457
+ const key = line.slice(0, idx).trim();
458
+ const value = line.slice(idx + 1).trim();
459
+ if (key) env[key] = value;
460
+ }
461
+ return Object.keys(env).length ? env : undefined;
462
+ }
463
+
464
+ function formatEnvText(env) {
465
+ if (!env || typeof env !== 'object') return '';
466
+ return Object.entries(env).map(([k, v]) => `${k}=${v}`).join('\n');
467
+ }
468
+
220
469
  // Status colors: core semantic state tokens first (same set dsh-clinebot
221
470
  // uses), plugin fallbacks as a single point of change (#105).
222
471
  const STYLES = `
@@ -302,6 +551,12 @@ window.__ModuleLoader__.load({
302
551
 
303
552
  .dsh-cron-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.7); backdrop-filter: blur(4px); display: flex; align-items: center; justify-content: center; z-index: 1000; }
304
553
  .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; padding: 24px; box-shadow: 0 20px 40px rgba(0,0,0,0.6); box-sizing: border-box; }
554
+ .dsh-cron-modal-scroll { max-width: 620px; max-height: 88vh; overflow-y: auto; }
555
+ .dsh-cron-section-head { display: flex; align-items: center; justify-content: space-between; width: 100%; background: none; border: none; padding: 0; margin-bottom: 10px; font: inherit; font-weight: 500; font-size: 12.5px; color: var(--dsw-alias-label-secondary, #aaa); cursor: pointer; }
556
+ .dsh-cron-section-head:hover { color: var(--dsw-alias-label-primary, #eee); }
557
+ .dsh-cron-section-chevron { transition: transform 0.15s ease; display: inline-block; }
558
+ .dsh-cron-section-chevron[data-open="false"] { transform: rotate(-90deg); }
559
+ .dsh-cron-section-body { padding-bottom: 4px; }
305
560
  .dsh-cron-modal-title { font-size: 18px; font-weight: 600; margin-bottom: 18px; }
306
561
  .dsh-cron-form-group { margin-bottom: 16px; }
307
562
  .dsh-cron-form-group label { display: block; font-size: 13px; color: var(--dsw-alias-label-secondary, #aaa); margin-bottom: 6px; }
@@ -359,10 +614,18 @@ window.__ModuleLoader__.load({
359
614
  .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; }
360
615
  .dsh-cron-checkbox-row input { width: 15px; height: 15px; margin: 0; cursor: pointer; accent-color: var(--dsw-alias-label-primary, #ededed); }
361
616
  .dsh-cron-hint { font-size: 12px; color: var(--dsw-alias-label-tertiary, #888); margin-top: 4px; }
617
+ .dsh-cron-channel-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 6px 12px; }
618
+ .dsh-cron-section { border-top: 1px solid var(--dsw-alias-border-l2, #2e2e2e); margin-top: 16px; padding-top: 12px; }
619
+ .dsh-cron-section-title { font-weight: 500; font-size: 12.5px; color: var(--dsw-alias-label-secondary, #aaa); margin-bottom: 10px; }
620
+ .dsh-cron-secret-row { display: flex; align-items: center; gap: 8px; }
621
+ .dsh-cron-secret-row input { flex: 1; }
622
+ .dsh-cron-badge-ref { font-size: 11px; padding: 2px 6px; border-radius: 4px; background: var(--dsw-alias-bg-layer-3, rgba(255,255,255,0.06)); color: var(--dsw-alias-label-tertiary, #888); white-space: nowrap; }
362
623
  .dsh-cron-status-banner { padding: 10px 14px; border-radius: 8px; font-size: 13px; margin-top: 12px; }
363
624
  .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); }
364
625
  .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); }
365
626
  .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); }
627
+ @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); } }
628
+ .dsh-cron-pulse { animation: dsh-cron-pulse-ring 1.6s infinite; border-color: var(--dsh-cron-success); color: var(--dsh-cron-success); }
366
629
  `;
367
630
 
368
631
  function ensureStyles() {
@@ -480,12 +743,49 @@ window.__ModuleLoader__.load({
480
743
  const [taskHistory, setTaskHistory] = React.useState([]);
481
744
  const [historyLoading, setHistoryLoading] = React.useState(false);
482
745
  const [formNotifyTelegram, setFormNotifyTelegram] = React.useState(false);
746
+ const [formChannels, setFormChannels] = React.useState([]);
747
+ const [formTemplate, setFormTemplate] = React.useState('');
483
748
  const [formOnlyOnFailure, setFormOnlyOnFailure] = React.useState(false);
484
749
  const [formTimeoutSeconds, setFormTimeoutSeconds] = React.useState(1800);
485
750
  const [formOverlapPolicy, setFormOverlapPolicy] = React.useState('skip');
486
751
  const [formKanbanMode, setFormKanbanMode] = React.useState('none');
752
+ const [formTimezone, setFormTimezone] = React.useState('');
753
+ const [formMisfirePolicy, setFormMisfirePolicy] = React.useState('skip');
754
+ const [formMaxRetries, setFormMaxRetries] = React.useState(0);
755
+ const [formPermissionPreset, setFormPermissionPreset] = React.useState('default');
756
+ const [formEnv, setFormEnv] = React.useState('');
757
+ const [formCwd, setFormCwd] = React.useState('');
758
+ const [formWorkspaceId, setFormWorkspaceId] = React.useState('');
759
+ const [formWorktree, setFormWorktree] = React.useState(false);
760
+ const [formKeepWorktree, setFormKeepWorktree] = React.useState(false);
761
+ const [formHttpMethod, setFormHttpMethod] = React.useState('GET');
762
+ const [formHttpUrl, setFormHttpUrl] = React.useState('');
763
+ const [formHttpHeaders, setFormHttpHeaders] = React.useState('');
764
+ const [formHttpBody, setFormHttpBody] = React.useState('');
765
+ const [formSshProfileId, setFormSshProfileId] = React.useState('');
766
+ const [formSshTarget, setFormSshTarget] = React.useState('');
767
+ const [formSshKeyPath, setFormSshKeyPath] = React.useState('');
768
+ const [formDockerImage, setFormDockerImage] = React.useState('');
769
+ const [formNodePath, setFormNodePath] = React.useState('');
770
+ const [formPythonPath, setFormPythonPath] = React.useState('');
771
+ const [formSkillName, setFormSkillName] = React.useState('');
772
+ const [formWorkflowName, setFormWorkflowName] = React.useState('');
487
773
  const [stats, setStats] = React.useState(null);
488
774
 
775
+ // Live execution indicator (#35): a light 1s tick only while some task
776
+ // is actually running.
777
+ const tasksRef = React.useRef([]);
778
+ const [nowTick, setNowTick] = React.useState(0);
779
+ React.useEffect(() => {
780
+ tasksRef.current = tasks;
781
+ }, [tasks]);
782
+ React.useEffect(() => {
783
+ const tick = setInterval(() => {
784
+ if (tasksRef.current.some((t2) => t2.running)) setNowTick((v) => v + 1);
785
+ }, 1000);
786
+ return () => clearInterval(tick);
787
+ }, []);
788
+
489
789
  // Settings modal state
490
790
  const [settingsModalOpen, setSettingsModalOpen] = React.useState(false);
491
791
  const [settingsBotToken, setSettingsBotToken] = React.useState('');
@@ -493,6 +793,8 @@ window.__ModuleLoader__.load({
493
793
  const [settingsNotifyGlobal, setSettingsNotifyGlobal] = React.useState(false);
494
794
  const [settingsOnlyOnFailureGlobal, setSettingsOnlyOnFailureGlobal] = React.useState(false);
495
795
  const [settingsKanbanUrl, setSettingsKanbanUrl] = React.useState('http://127.0.0.1:3000');
796
+ const [settingsDelivery, setSettingsDelivery] = React.useState({});
797
+ const [settingsSections, setSettingsSections] = React.useState({ channels: true });
496
798
  const [settingsHasDefault, setSettingsHasDefault] = React.useState(false);
497
799
  const [settingsHasCustomBotToken, setSettingsHasCustomBotToken] = React.useState(false);
498
800
  const [testStatus, setTestStatus] = React.useState(null);
@@ -620,6 +922,7 @@ window.__ModuleLoader__.load({
620
922
  setSettingsNotifyGlobal(Boolean(data.settings.notifyTelegram));
621
923
  setSettingsOnlyOnFailureGlobal(Boolean(data.settings.onlyOnFailure));
622
924
  setSettingsKanbanUrl(data.settings.kanbanBaseUrl || 'http://127.0.0.1:3000');
925
+ setSettingsDelivery(pickDeliverySettings(data.settings));
623
926
  setSettingsHasDefault(Boolean(data.settings.hasDefaultCredentials));
624
927
  setSettingsHasCustomBotToken(Boolean(data.settings.hasCustomBotToken));
625
928
  }
@@ -632,13 +935,13 @@ window.__ModuleLoader__.load({
632
935
  const res = await fetch('/dsh-cron/settings', {
633
936
  method: 'POST',
634
937
  headers: { 'Content-Type': 'application/json' },
635
- body: JSON.stringify({
938
+ body: JSON.stringify(Object.assign({
636
939
  botToken: settingsBotToken,
637
940
  chatId: settingsChatId,
638
941
  notifyTelegram: settingsNotifyGlobal,
639
942
  onlyOnFailure: settingsOnlyOnFailureGlobal,
640
943
  kanbanBaseUrl: settingsKanbanUrl,
641
- })
944
+ }, settingsDelivery))
642
945
  });
643
946
  const data = await res.json();
644
947
  if (data && data.ok) {
@@ -651,8 +954,35 @@ window.__ModuleLoader__.load({
651
954
  }
652
955
  };
653
956
 
654
- const handleTestTelegram = async () => {
655
- setTestStatus({ loading: true, message: T_KEY(t, 'settings.sending') });
957
+ /**
958
+ * Update one delivery setting without dropping the rest of the object.
959
+ * `undefined` removes the key so the server default applies again; an
960
+ * empty string is kept so a text field can be cleared on purpose.
961
+ */
962
+ const updateDelivery = (key, value) => {
963
+ setSettingsDelivery((prev) => {
964
+ const next = Object.assign({}, prev);
965
+ if (value === undefined) delete next[key];
966
+ else next[key] = value;
967
+ return next;
968
+ });
969
+ };
970
+
971
+ /** Per-channel template overrides live in a nested map on the server. */
972
+ const updateChannelTemplate = (channelId, value) => {
973
+ setSettingsDelivery((prev) => {
974
+ const templates = Object.assign({}, prev.channelTemplates || {});
975
+ if (value && value.trim()) templates[channelId] = value;
976
+ else delete templates[channelId];
977
+ return Object.assign({}, prev, { channelTemplates: templates });
978
+ });
979
+ };
980
+
981
+ const toggleDeliverySection = (key) => {
982
+ setSettingsSections((prev) => Object.assign({}, prev, { [key]: !prev[key] }));
983
+ };
984
+
985
+ const handleTestTelegram = async () => { setTestStatus({ loading: true, message: T_KEY(t, 'settings.sending') });
656
986
  try {
657
987
  const res = await fetch('/dsh-cron/telegram/test', {
658
988
  method: 'POST',
@@ -680,10 +1010,33 @@ window.__ModuleLoader__.load({
680
1010
  setFormType('llm');
681
1011
  setFormDelivery('isolated');
682
1012
  setFormNotifyTelegram(false);
1013
+ setFormChannels([]);
1014
+ setFormTemplate('');
683
1015
  setFormOnlyOnFailure(false);
684
1016
  setFormTimeoutSeconds(1800);
685
1017
  setFormOverlapPolicy('skip');
686
1018
  setFormKanbanMode('none');
1019
+ setFormTimezone('');
1020
+ setFormMisfirePolicy('skip');
1021
+ setFormMaxRetries(0);
1022
+ setFormPermissionPreset('default');
1023
+ setFormEnv('');
1024
+ setFormCwd('');
1025
+ setFormWorkspaceId('');
1026
+ setFormWorktree(false);
1027
+ setFormKeepWorktree(false);
1028
+ setFormHttpMethod('GET');
1029
+ setFormHttpUrl('');
1030
+ setFormHttpHeaders('');
1031
+ setFormHttpBody('');
1032
+ setFormSshProfileId('');
1033
+ setFormSshTarget('');
1034
+ setFormSshKeyPath('');
1035
+ setFormDockerImage('');
1036
+ setFormNodePath('');
1037
+ setFormPythonPath('');
1038
+ setFormSkillName('');
1039
+ setFormWorkflowName('');
687
1040
  if (preset) {
688
1041
  setFormTitle(preset.title || '');
689
1042
  setFormSchedule(preset.schedule || '');
@@ -730,10 +1083,33 @@ window.__ModuleLoader__.load({
730
1083
  setFormModel(task.model || '');
731
1084
  setFormDelivery(task.delivery || 'isolated');
732
1085
  setFormNotifyTelegram(Boolean(task.notifyTelegram));
1086
+ setFormChannels(Array.isArray(task.channels) ? task.channels.filter((c) => DELIVERY_CHANNELS.includes(c)) : []);
1087
+ setFormTemplate(task.template || '');
733
1088
  setFormOnlyOnFailure(Boolean(task.onlyOnFailure));
734
1089
  setFormTimeoutSeconds(task.timeoutSeconds !== undefined ? task.timeoutSeconds : 1800);
735
1090
  setFormOverlapPolicy(task.overlapPolicy || 'skip');
736
1091
  setFormKanbanMode(task.kanbanMode || 'none');
1092
+ setFormTimezone(task.timezone || '');
1093
+ setFormMisfirePolicy(task.misfirePolicy || 'skip');
1094
+ setFormMaxRetries(task.maxRetries !== undefined ? task.maxRetries : 0);
1095
+ setFormPermissionPreset(task.permissionPreset || 'default');
1096
+ setFormEnv(formatEnvText(task.env));
1097
+ setFormCwd(task.cwd || '');
1098
+ setFormWorkspaceId(task.workspaceId || '');
1099
+ setFormWorktree(Boolean(task.worktree));
1100
+ setFormKeepWorktree(Boolean(task.keepWorktree));
1101
+ setFormHttpMethod(task.httpMethod || 'GET');
1102
+ setFormHttpUrl(task.httpUrl || '');
1103
+ setFormHttpHeaders(typeof task.httpHeaders === 'string' ? task.httpHeaders : (task.httpHeaders ? JSON.stringify(task.httpHeaders) : ''));
1104
+ setFormHttpBody(task.httpBody || '');
1105
+ setFormSshProfileId(task.sshProfileId || '');
1106
+ setFormSshTarget(task.sshTarget || '');
1107
+ setFormSshKeyPath(task.sshKeyPath || '');
1108
+ setFormDockerImage(task.dockerImage || '');
1109
+ setFormNodePath(task.nodePath || '');
1110
+ setFormPythonPath(task.pythonPath || '');
1111
+ setFormSkillName(task.skillName || '');
1112
+ setFormWorkflowName(task.workflowName || '');
737
1113
  if (task.provider) loadModels(task.provider);
738
1114
  else if (defaultModelInfo?.provider) loadModels(defaultModelInfo.provider);
739
1115
  loadTaskHistory(task.id);
@@ -750,26 +1126,50 @@ window.__ModuleLoader__.load({
750
1126
  const handleSaveManual = async (e) => {
751
1127
  e.preventDefault();
752
1128
  try {
1129
+ const isAgentType = AGENT_FORM_TYPES.includes(formType);
753
1130
  const payload = {
754
1131
  id: formId || undefined,
755
1132
  status: selectedTaskMeta ? selectedTaskMeta.status : 'active',
756
1133
  title: formTitle,
757
1134
  schedule: formSchedule,
758
- prompt: formPrompt,
1135
+ prompt: formType === 'http' ? (formPrompt.trim() || formHttpUrl.trim()) : formPrompt,
759
1136
  type: formType,
760
1137
  delivery: formDelivery || 'isolated',
761
- provider: formType === 'script' ? undefined : (formProvider || undefined),
762
- model: formType === 'script' ? undefined : (formModel || undefined),
1138
+ provider: isAgentType ? (formProvider || undefined) : undefined,
1139
+ model: isAgentType ? (formModel || undefined) : undefined,
763
1140
  notifyTelegram: formNotifyTelegram,
1141
+ channels: formChannels,
1142
+ template: formTemplate.trim(),
764
1143
  onlyOnFailure: formOnlyOnFailure,
765
1144
  timeoutSeconds: Number(formTimeoutSeconds) || 1800,
766
1145
  overlapPolicy: formOverlapPolicy,
1146
+ timezone: formTimezone.trim(),
1147
+ misfirePolicy: formMisfirePolicy,
1148
+ maxRetries: Number(formMaxRetries) || 0,
1149
+ permissionPreset: formPermissionPreset,
767
1150
  kanbanMode: formKanbanMode,
1151
+ env: parseEnvText(formEnv),
1152
+ cwd: formCwd.trim(),
1153
+ workspaceId: formWorkspaceId.trim(),
1154
+ worktree: isAgentType ? Boolean(formWorktree) : false,
1155
+ keepWorktree: isAgentType ? Boolean(formKeepWorktree) : false,
1156
+ httpMethod: formHttpMethod,
1157
+ httpUrl: formHttpUrl.trim(),
1158
+ httpHeaders: formHttpHeaders.trim() || undefined,
1159
+ httpBody: formHttpBody,
1160
+ sshProfileId: formSshProfileId.trim(),
1161
+ sshTarget: formSshTarget.trim(),
1162
+ sshKeyPath: formSshKeyPath.trim(),
1163
+ dockerImage: formDockerImage.trim(),
1164
+ nodePath: formNodePath.trim(),
1165
+ pythonPath: formPythonPath.trim(),
1166
+ skillName: formSkillName.trim(),
1167
+ workflowName: formWorkflowName.trim(),
768
1168
  };
769
- // Cross-origin hardening: script tasks need the explicit confirm
770
- // header, which browsers cannot attach to forged cross-site posts (#86)
1169
+ // Cross-origin hardening: every code-executing type needs the explicit
1170
+ // confirm header, which forged cross-site posts cannot attach (#86)
771
1171
  const headers = { 'Content-Type': 'application/json' };
772
- if (formType === 'script') headers[SCRIPT_CONFIRM_HEADER] = 'script';
1172
+ if (CODE_FORM_TYPES.includes(formType)) headers[SCRIPT_CONFIRM_HEADER] = 'script';
773
1173
  const res = await fetch('/dsh-cron/tasks', {
774
1174
  method: 'POST',
775
1175
  headers,
@@ -940,7 +1340,7 @@ window.__ModuleLoader__.load({
940
1340
  },
941
1341
  React.createElement('button', {
942
1342
  type: 'button',
943
- className: 'dsh-cron-task-status-btn',
1343
+ className: 'dsh-cron-task-status-btn' + (task.running ? ' dsh-cron-pulse' : ''),
944
1344
  'data-active': task.status === 'active' ? 'true' : undefined,
945
1345
  title: task.status === 'active' ? T_KEY(t, 'actions.pause') : T_KEY(t, 'actions.resume'),
946
1346
  'aria-label': task.status === 'active' ? T_KEY(t, 'actions.pause') : T_KEY(t, 'actions.resume'),
@@ -962,7 +1362,10 @@ window.__ModuleLoader__.load({
962
1362
  ),
963
1363
  React.createElement('div', { className: 'dsh-cron-task-sched' },
964
1364
  task.scheduleText || task.schedule,
965
- task.nextRunAt ? T_KEY(t, 'list.next', { time: new Date(task.nextRunAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }) : ''
1365
+ task.nextRunAt ? T_KEY(t, 'list.next', { time: new Date(task.nextRunAt).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }) }) : '',
1366
+ task.running && task.runningSince
1367
+ ? ' · ' + T_KEY(t, 'list.runningNow') + ' ' + Math.max(0, Math.round((Date.now() - task.runningSince) / 1000)) + 's'
1368
+ : ''
966
1369
  ),
967
1370
  task.prompt ? React.createElement('div', {
968
1371
  className: 'dsh-cron-task-prompt-preview',
@@ -1085,7 +1488,13 @@ window.__ModuleLoader__.load({
1085
1488
  )
1086
1489
  ),
1087
1490
  run.output && React.createElement('div', { className: 'dsh-cron-history-output' }, run.output),
1088
- run.error && React.createElement('div', { className: 'dsh-cron-history-output', style: { color: 'var(--dsh-cron-danger)', borderColor: 'var(--dsh-cron-danger-bg)' } }, run.error)
1491
+ run.error && React.createElement('div', { className: 'dsh-cron-history-output', style: { color: 'var(--dsh-cron-danger)', borderColor: 'var(--dsh-cron-danger-bg)' } }, run.error),
1492
+ run.sessionId && React.createElement('button', {
1493
+ type: 'button',
1494
+ className: 'dsh-cron-btn-secondary',
1495
+ style: { marginTop: '8px', fontSize: '12px', padding: '4px 10px' },
1496
+ onClick: () => openSession(ctx, run.sessionId)
1497
+ }, T_KEY(t, 'history.openSession'))
1089
1498
  ))
1090
1499
  ),
1091
1500
  React.createElement('div', { className: 'dsh-cron-modal-foot', style: { marginTop: '18px' } },
@@ -1121,7 +1530,14 @@ window.__ModuleLoader__.load({
1121
1530
  onChange: (e) => setFormType(e.target.value)
1122
1531
  },
1123
1532
  React.createElement('option', { value: 'llm' }, T_KEY(t, 'form.typeLlm')),
1124
- React.createElement('option', { value: 'script' }, T_KEY(t, 'form.typeScript'))
1533
+ React.createElement('option', { value: 'script' }, T_KEY(t, 'form.typeScript')),
1534
+ React.createElement('option', { value: 'node' }, T_KEY(t, 'form.typeNode')),
1535
+ React.createElement('option', { value: 'python' }, T_KEY(t, 'form.typePython')),
1536
+ React.createElement('option', { value: 'http' }, T_KEY(t, 'form.typeHttp')),
1537
+ React.createElement('option', { value: 'ssh' }, T_KEY(t, 'form.typeSsh')),
1538
+ React.createElement('option', { value: 'docker' }, T_KEY(t, 'form.typeDocker')),
1539
+ React.createElement('option', { value: 'skill' }, T_KEY(t, 'form.typeSkill')),
1540
+ React.createElement('option', { value: 'workflow' }, T_KEY(t, 'form.typeWorkflow'))
1125
1541
  )
1126
1542
  ),
1127
1543
  React.createElement('div', { className: 'dsh-cron-form-group' },
@@ -1157,7 +1573,52 @@ window.__ModuleLoader__.load({
1157
1573
  )
1158
1574
  )
1159
1575
  ),
1576
+ React.createElement('div', { className: 'dsh-cron-form-row' },
1577
+ React.createElement('div', { className: 'dsh-cron-form-group' },
1578
+ React.createElement('label', null, T_KEY(t, 'form.timezoneLabel')),
1579
+ React.createElement('input', {
1580
+ value: formTimezone,
1581
+ placeholder: T_KEY(t, 'form.timezonePlaceholder'),
1582
+ onChange: (e) => setFormTimezone(e.target.value)
1583
+ })
1584
+ ),
1585
+ React.createElement('div', { className: 'dsh-cron-form-group' },
1586
+ React.createElement('label', null, T_KEY(t, 'form.misfireLabel')),
1587
+ React.createElement('select', {
1588
+ value: formMisfirePolicy,
1589
+ onChange: (e) => setFormMisfirePolicy(e.target.value)
1590
+ },
1591
+ React.createElement('option', { value: 'skip' }, T_KEY(t, 'form.misfireSkip')),
1592
+ React.createElement('option', { value: 'runOnce' }, T_KEY(t, 'form.misfireRunOnce')),
1593
+ React.createElement('option', { value: 'catchUpAll' }, T_KEY(t, 'form.misfireCatchUp'))
1594
+ )
1595
+ )
1596
+ ),
1597
+ React.createElement('div', { className: 'dsh-cron-form-row' },
1160
1598
  React.createElement('div', { className: 'dsh-cron-form-group' },
1599
+ React.createElement('label', null, T_KEY(t, 'form.retriesLabel')),
1600
+ React.createElement('input', {
1601
+ type: 'number',
1602
+ min: 0,
1603
+ value: formMaxRetries,
1604
+ placeholder: '0',
1605
+ onChange: (e) => setFormMaxRetries(e.target.value)
1606
+ })
1607
+ ),
1608
+ React.createElement('div', { className: 'dsh-cron-form-group' },
1609
+ React.createElement('label', null, T_KEY(t, 'form.permissionLabel')),
1610
+ React.createElement('select', {
1611
+ value: formPermissionPreset,
1612
+ onChange: (e) => setFormPermissionPreset(e.target.value)
1613
+ },
1614
+ React.createElement('option', { value: 'default' }, T_KEY(t, 'form.permissionDefault')),
1615
+ React.createElement('option', { value: 'read-only' }, T_KEY(t, 'form.permissionReadonly')),
1616
+ React.createElement('option', { value: 'workspace-write' }, T_KEY(t, 'form.permissionWrite')),
1617
+ React.createElement('option', { value: 'full' }, T_KEY(t, 'form.permissionFull'))
1618
+ )
1619
+ )
1620
+ ),
1621
+ React.createElement('div', { className: 'dsh-cron-form-group' },
1161
1622
  React.createElement('label', null, T_KEY(t, 'form.kanbanLabel')),
1162
1623
  React.createElement('select', {
1163
1624
  value: formKanbanMode,
@@ -1168,7 +1629,7 @@ window.__ModuleLoader__.load({
1168
1629
  React.createElement('option', { value: 'always' }, T_KEY(t, 'form.kanbanAlways'))
1169
1630
  )
1170
1631
  ),
1171
- formType !== 'script' && React.createElement('div', { className: 'dsh-cron-form-row' },
1632
+ AGENT_FORM_TYPES.includes(formType) && React.createElement('div', { className: 'dsh-cron-form-row' },
1172
1633
  React.createElement('div', { className: 'dsh-cron-form-group' },
1173
1634
  React.createElement('label', null, T_KEY(t, 'form.providerLabel')),
1174
1635
  React.createElement('select', {
@@ -1190,14 +1651,165 @@ window.__ModuleLoader__.load({
1190
1651
  )
1191
1652
  )
1192
1653
  ),
1193
- React.createElement('div', { className: 'dsh-cron-form-group' },
1194
- React.createElement('label', null, formType === 'script' ? T_KEY(t, 'form.promptLabelScript') : T_KEY(t, 'form.promptLabelLlm')),
1654
+ formType === 'http' && React.createElement('div', { className: 'dsh-cron-form-row' },
1655
+ React.createElement('div', { className: 'dsh-cron-form-group' },
1656
+ React.createElement('label', null, T_KEY(t, 'form.httpMethodLabel')),
1657
+ React.createElement('select', {
1658
+ value: formHttpMethod,
1659
+ onChange: (e) => setFormHttpMethod(e.target.value)
1660
+ },
1661
+ ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'HEAD'].map((m) => React.createElement('option', { key: m, value: m }, m))
1662
+ )
1663
+ ),
1664
+ React.createElement('div', { className: 'dsh-cron-form-group' },
1665
+ React.createElement('label', null, T_KEY(t, 'form.httpUrlLabel')),
1666
+ React.createElement('input', {
1667
+ required: true,
1668
+ value: formHttpUrl,
1669
+ placeholder: 'https://example.test/webhook',
1670
+ onChange: (e) => setFormHttpUrl(e.target.value)
1671
+ })
1672
+ )
1673
+ ),
1674
+ formType === 'http' && React.createElement('div', { className: 'dsh-cron-form-row' },
1675
+ React.createElement('div', { className: 'dsh-cron-form-group' },
1676
+ React.createElement('label', null, T_KEY(t, 'form.httpHeadersLabel')),
1677
+ React.createElement('input', {
1678
+ value: formHttpHeaders,
1679
+ placeholder: '{"Authorization":"Bearer ..."}',
1680
+ onChange: (e) => setFormHttpHeaders(e.target.value)
1681
+ })
1682
+ ),
1683
+ React.createElement('div', { className: 'dsh-cron-form-group' },
1684
+ React.createElement('label', null, T_KEY(t, 'form.httpBodyLabel')),
1685
+ React.createElement('input', {
1686
+ value: formHttpBody,
1687
+ onChange: (e) => setFormHttpBody(e.target.value)
1688
+ })
1689
+ )
1690
+ ),
1691
+ formType === 'ssh' && React.createElement('div', { className: 'dsh-cron-form-row' },
1692
+ React.createElement('div', { className: 'dsh-cron-form-group' },
1693
+ React.createElement('label', null, T_KEY(t, 'form.sshProfileLabel')),
1694
+ React.createElement('input', {
1695
+ value: formSshProfileId,
1696
+ placeholder: 'prod-web',
1697
+ onChange: (e) => setFormSshProfileId(e.target.value)
1698
+ }),
1699
+ React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.sshProfileHint'))
1700
+ ),
1701
+ React.createElement('div', { className: 'dsh-cron-form-group' },
1702
+ React.createElement('label', null, T_KEY(t, 'form.sshTargetLabel')),
1703
+ React.createElement('input', {
1704
+ value: formSshTarget,
1705
+ placeholder: 'root@10.0.0.5',
1706
+ onChange: (e) => setFormSshTarget(e.target.value)
1707
+ }),
1708
+ React.createElement('input', {
1709
+ style: { marginTop: '6px' },
1710
+ value: formSshKeyPath,
1711
+ placeholder: '~/.ssh/id_ed25519',
1712
+ onChange: (e) => setFormSshKeyPath(e.target.value)
1713
+ })
1714
+ )
1715
+ ),
1716
+ formType === 'docker' && React.createElement('div', { className: 'dsh-cron-form-group' },
1717
+ React.createElement('label', null, T_KEY(t, 'form.dockerImageLabel')),
1718
+ React.createElement('input', {
1719
+ required: true,
1720
+ value: formDockerImage,
1721
+ placeholder: 'alpine:3.20',
1722
+ onChange: (e) => setFormDockerImage(e.target.value)
1723
+ })
1724
+ ),
1725
+ formType === 'skill' && React.createElement('div', { className: 'dsh-cron-form-group' },
1726
+ React.createElement('label', null, T_KEY(t, 'form.skillNameLabel')),
1727
+ React.createElement('input', {
1728
+ value: formSkillName,
1729
+ placeholder: 'gitea',
1730
+ onChange: (e) => setFormSkillName(e.target.value)
1731
+ })
1732
+ ),
1733
+ formType === 'workflow' && React.createElement('div', { className: 'dsh-cron-form-group' },
1734
+ React.createElement('label', null, T_KEY(t, 'form.workflowNameLabel')),
1735
+ React.createElement('input', {
1736
+ value: formWorkflowName,
1737
+ placeholder: 'nightly-release',
1738
+ onChange: (e) => setFormWorkflowName(e.target.value)
1739
+ })
1740
+ ),
1741
+ (formType === 'node' || formType === 'python') && React.createElement('div', { className: 'dsh-cron-form-group' },
1742
+ React.createElement('label', null, formType === 'node' ? 'Node.js binary (optional)' : 'Python interpreter (optional)'),
1743
+ React.createElement('input', {
1744
+ value: formType === 'node' ? formNodePath : formPythonPath,
1745
+ placeholder: formType === 'node' ? 'auto' : '.venv/bin/python',
1746
+ onChange: (e) => (formType === 'node' ? setFormNodePath(e.target.value) : setFormPythonPath(e.target.value))
1747
+ })
1748
+ ),
1749
+ !AGENT_FORM_TYPES.includes(formType) && React.createElement('div', { className: 'dsh-cron-form-group' },
1750
+ React.createElement('label', null, T_KEY(t, 'form.envLabel')),
1751
+ React.createElement('textarea', {
1752
+ rows: 3,
1753
+ style: { fontFamily: 'monospace', fontSize: '12.5px' },
1754
+ value: formEnv,
1755
+ placeholder: T_KEY(t, 'form.envPlaceholder'),
1756
+ onChange: (e) => setFormEnv(e.target.value)
1757
+ }),
1758
+ React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.envHint'))
1759
+ ),
1760
+ React.createElement('div', { className: 'dsh-cron-form-row' },
1761
+ React.createElement('div', { className: 'dsh-cron-form-group' },
1762
+ React.createElement('label', null, T_KEY(t, 'form.cwdLabel')),
1763
+ React.createElement('input', {
1764
+ value: formCwd,
1765
+ placeholder: '/srv/app',
1766
+ onChange: (e) => setFormCwd(e.target.value)
1767
+ })
1768
+ ),
1769
+ React.createElement('div', { className: 'dsh-cron-form-group' },
1770
+ React.createElement('label', null, T_KEY(t, 'form.workspaceLabel')),
1771
+ React.createElement('input', {
1772
+ value: formWorkspaceId,
1773
+ onChange: (e) => setFormWorkspaceId(e.target.value)
1774
+ })
1775
+ )
1776
+ ),
1777
+ AGENT_FORM_TYPES.includes(formType) && React.createElement('div', { className: 'dsh-cron-form-group' },
1778
+ React.createElement('label', { className: 'dsh-cron-checkbox-row' },
1779
+ React.createElement('input', {
1780
+ type: 'checkbox',
1781
+ checked: formWorktree,
1782
+ onChange: (e) => setFormWorktree(e.target.checked)
1783
+ }),
1784
+ React.createElement('span', null, T_KEY(t, 'form.worktreeLabel'))
1785
+ ),
1786
+ formWorktree && React.createElement('label', { className: 'dsh-cron-checkbox-row', style: { marginLeft: '24px', marginTop: '6px' } },
1787
+ React.createElement('input', {
1788
+ type: 'checkbox',
1789
+ checked: formKeepWorktree,
1790
+ onChange: (e) => setFormKeepWorktree(e.target.checked)
1791
+ }),
1792
+ React.createElement('span', null, T_KEY(t, 'form.keepWorktreeLabel'))
1793
+ )
1794
+ ),
1795
+ formType !== 'http' && React.createElement('div', { className: 'dsh-cron-form-group' },
1796
+ React.createElement('label', null, AGENT_FORM_TYPES.includes(formType) ? T_KEY(t, 'form.promptLabelLlm') : T_KEY(t, 'form.promptLabelScript')),
1195
1797
  React.createElement('textarea', {
1196
1798
  required: true,
1197
1799
  rows: 9,
1198
1800
  style: { fontFamily: 'monospace', fontSize: '12.5px', lineHeight: '1.45' },
1199
1801
  value: formPrompt,
1200
- placeholder: formType === 'script' ? T_KEY(t, 'form.promptPlaceholderScript') : T_KEY(t, 'form.promptPlaceholderLlm'),
1802
+ placeholder: AGENT_FORM_TYPES.includes(formType)
1803
+ ? T_KEY(t, 'form.promptPlaceholderLlm')
1804
+ : formType === 'docker'
1805
+ ? 'python -V'
1806
+ : formType === 'ssh'
1807
+ ? 'uname -a'
1808
+ : formType === 'node'
1809
+ ? 'console.log("hello")'
1810
+ : formType === 'python'
1811
+ ? 'print("hello")'
1812
+ : T_KEY(t, 'form.promptPlaceholderScript'),
1201
1813
  onChange: (e) => setFormPrompt(e.target.value)
1202
1814
  })
1203
1815
  ),
@@ -1222,13 +1834,43 @@ window.__ModuleLoader__.load({
1222
1834
  }),
1223
1835
  React.createElement('span', null, T_KEY(t, 'form.notifyTelegram'))
1224
1836
  ),
1225
- formNotifyTelegram && React.createElement('label', { className: 'dsh-cron-checkbox-row', style: { marginLeft: '24px', marginTop: '6px' } },
1837
+ (formNotifyTelegram || formChannels.length > 0) && React.createElement('label', { className: 'dsh-cron-checkbox-row', style: { marginLeft: '24px', marginTop: '6px' } },
1226
1838
  React.createElement('input', {
1227
1839
  type: 'checkbox',
1228
1840
  checked: formOnlyOnFailure,
1229
1841
  onChange: (e) => setFormOnlyOnFailure(e.target.checked)
1230
1842
  }),
1231
1843
  React.createElement('span', null, T_KEY(t, 'form.notifyOnlyFailure'))
1844
+ ),
1845
+ React.createElement('div', { style: { marginTop: '12px', paddingTop: '10px', borderTop: '1px solid var(--dsw-alias-border-l2, #2e2e2e)' } },
1846
+ React.createElement('div', { style: { fontWeight: 500, fontSize: '12.5px', color: 'var(--dsw-alias-label-secondary, #aaa)', marginBottom: '8px' } }, T_KEY(t, 'form.channelsTitle')),
1847
+ React.createElement('div', { className: 'dsh-cron-channel-grid' },
1848
+ ...DELIVERY_CHANNELS.map((id) => React.createElement('label', { key: id, className: 'dsh-cron-checkbox-row' },
1849
+ React.createElement('input', {
1850
+ type: 'checkbox',
1851
+ checked: formChannels.indexOf(id) >= 0,
1852
+ onChange: (e) => {
1853
+ setFormChannels((prev) => {
1854
+ const set = new Set(prev);
1855
+ if (e.target.checked) set.add(id); else set.delete(id);
1856
+ return DELIVERY_CHANNELS.filter((c) => set.has(c));
1857
+ });
1858
+ }
1859
+ }),
1860
+ React.createElement('span', null, T_KEY(t, CHANNEL_LABEL_KEYS[id]))
1861
+ ))
1862
+ ),
1863
+ React.createElement('div', { className: 'dsh-cron-hint', style: { marginTop: '6px' } }, T_KEY(t, 'form.channelsHint'))
1864
+ ),
1865
+ React.createElement('div', { className: 'dsh-cron-form-group', style: { marginTop: '12px', marginBottom: 0 } },
1866
+ React.createElement('label', null, T_KEY(t, 'form.templateLabel')),
1867
+ React.createElement('input', {
1868
+ type: 'text',
1869
+ value: formTemplate,
1870
+ placeholder: T_KEY(t, 'form.templatePlaceholder'),
1871
+ onChange: (e) => setFormTemplate(e.target.value)
1872
+ }),
1873
+ React.createElement('div', { className: 'dsh-cron-hint' }, T_KEY(t, 'form.templateHint'))
1232
1874
  )
1233
1875
  ),
1234
1876
  selectedTaskMeta && React.createElement('div', {
@@ -1276,7 +1918,7 @@ window.__ModuleLoader__.load({
1276
1918
 
1277
1919
  // 2. Settings modal
1278
1920
  settingsModalOpen && React.createElement('div', { className: 'dsh-cron-modal-overlay', onClick: () => setSettingsModalOpen(false) },
1279
- React.createElement('div', { className: 'dsh-cron-modal', role: 'dialog', 'aria-modal': 'true', onClick: (e) => e.stopPropagation() },
1921
+ React.createElement('div', { className: 'dsh-cron-modal dsh-cron-modal-scroll', role: 'dialog', 'aria-modal': 'true', onClick: (e) => e.stopPropagation() },
1280
1922
  React.createElement('div', { className: 'dsh-cron-modal-head', style: { display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: '18px' } },
1281
1923
  React.createElement('div', { className: 'dsh-cron-modal-title', style: { margin: 0, display: 'flex', alignItems: 'center', gap: '8px' } },
1282
1924
  React.createElement('span', { style: { display: 'flex', alignItems: 'center', color: 'var(--dsh-cron-info)' }, ...iconHtml(ICON_TELEGRAM) }),
@@ -1325,6 +1967,14 @@ window.__ModuleLoader__.load({
1325
1967
  React.createElement('span', null, T_KEY(t, 'settings.globalOnlyFailure'))
1326
1968
  )
1327
1969
  ),
1970
+ ...renderDeliverySettings({
1971
+ T: (key, vars) => T_KEY(t, key, vars),
1972
+ values: settingsDelivery,
1973
+ onChange: updateDelivery,
1974
+ onChannelTemplate: updateChannelTemplate,
1975
+ sections: settingsSections,
1976
+ onToggleSection: toggleDeliverySection,
1977
+ }),
1328
1978
  React.createElement('div', { style: { display: 'flex', alignItems: 'center', justifyContent: 'space-between', marginBottom: '14px' } },
1329
1979
  React.createElement('button', {
1330
1980
  type: 'button',
@@ -1410,6 +2060,8 @@ window.__ModuleLoader__.load({
1410
2060
  const [notify, setNotify] = React.useState(false);
1411
2061
  const [onlyFailure, setOnlyFailure] = React.useState(false);
1412
2062
  const [kanbanUrl, setKanbanUrl] = React.useState('http://127.0.0.1:3000');
2063
+ const [delivery, setDelivery] = React.useState({});
2064
+ const [sections, setSections] = React.useState({ channels: true });
1413
2065
  const [hasDefault, setHasDefault] = React.useState(false);
1414
2066
  const [saved, setSaved] = React.useState(false);
1415
2067
  const [testInfo, setTestInfo] = React.useState(null);
@@ -1434,6 +2086,7 @@ window.__ModuleLoader__.load({
1434
2086
  setNotify(Boolean(data.settings.notifyTelegram));
1435
2087
  setOnlyFailure(Boolean(data.settings.onlyOnFailure));
1436
2088
  setKanbanUrl(data.settings.kanbanBaseUrl || 'http://127.0.0.1:3000');
2089
+ setDelivery(pickDeliverySettings(data.settings));
1437
2090
  setHasDefault(Boolean(data.settings.hasDefaultCredentials));
1438
2091
  setLoadState('ready');
1439
2092
  } else {
@@ -1450,13 +2103,13 @@ window.__ModuleLoader__.load({
1450
2103
  const res = await fetch('/dsh-cron/settings', {
1451
2104
  method: 'POST',
1452
2105
  headers: { 'Content-Type': 'application/json' },
1453
- body: JSON.stringify({
2106
+ body: JSON.stringify(Object.assign({
1454
2107
  botToken: token,
1455
2108
  chatId,
1456
2109
  notifyTelegram: notify,
1457
2110
  onlyOnFailure: onlyFailure,
1458
2111
  kanbanBaseUrl: kanbanUrl
1459
- })
2112
+ }, delivery))
1460
2113
  });
1461
2114
  const data = await res.json();
1462
2115
  if (data && data.ok) {
@@ -1586,6 +2239,24 @@ window.__ModuleLoader__.load({
1586
2239
  React.createElement('span', null, T_KEY(t, 'settings.globalOnlyFailure'))
1587
2240
  )
1588
2241
  ),
2242
+ ...renderDeliverySettings({
2243
+ T: (key, vars) => T_KEY(t, key, vars),
2244
+ values: delivery,
2245
+ onChange: (key, value) => setDelivery((prev) => {
2246
+ const next = Object.assign({}, prev);
2247
+ if (value === undefined) delete next[key];
2248
+ else next[key] = value;
2249
+ return next;
2250
+ }),
2251
+ onChannelTemplate: (channelId, value) => setDelivery((prev) => {
2252
+ const templates = Object.assign({}, prev.channelTemplates || {});
2253
+ if (value && value.trim()) templates[channelId] = value;
2254
+ else delete templates[channelId];
2255
+ return Object.assign({}, prev, { channelTemplates: templates });
2256
+ }),
2257
+ sections,
2258
+ onToggleSection: (key) => setSections((prev) => Object.assign({}, prev, { [key]: !prev[key] })),
2259
+ }),
1589
2260
  testInfo && testInfo.message && React.createElement('div', {
1590
2261
  className: 'dsh-cron-status-banner',
1591
2262
  'data-type': testInfo.success ? 'success' : 'error',