@goodandready/dsh-cron 0.1.20 → 0.1.21

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
@@ -268,7 +268,9 @@ const React = window.React || require('react');
268
268
  const [settingsChatId, setSettingsChatId] = React.useState('');
269
269
  const [settingsNotifyGlobal, setSettingsNotifyGlobal] = React.useState(false);
270
270
  const [settingsOnlyOnFailureGlobal, setSettingsOnlyOnFailureGlobal] = React.useState(false);
271
+ const [settingsKanbanUrl, setSettingsKanbanUrl] = React.useState('http://127.0.0.1:3000');
271
272
  const [settingsHasDefault, setSettingsHasDefault] = React.useState(false);
273
+ const [settingsHasCustomBotToken, setSettingsHasCustomBotToken] = React.useState(false);
272
274
  const [testStatus, setTestStatus] = React.useState(null);
273
275
 
274
276
  // DSH dialog state
@@ -283,40 +285,55 @@ const React = window.React || require('react');
283
285
 
284
286
  React.useEffect(() => {
285
287
  const onKey = (e) => {
286
- if (e.key !== 'Escape') return;
287
- if (settingsModalOpen) { setSettingsModalOpen(false); return; }
288
- if (manualModalOpen) { setManualModalOpen(false); return; }
289
- if (dshModalOpen) { setDshModalOpen(false); return; }
290
- if (dropdownOpen) { setDropdownOpen(false); return; }
291
- if (onClose) onClose();
288
+ if (e.key === 'Escape') {
289
+ if (dropdownOpen) { setDropdownOpen(false); return; }
290
+ if (manualModalOpen) { setManualModalOpen(false); return; }
291
+ if (settingsModalOpen) { setSettingsModalOpen(false); return; }
292
+ if (dshModalOpen && !dshLoading) { setDshModalOpen(false); return; }
293
+ if (onClose) onClose();
294
+ else toggle.set(false);
295
+ }
292
296
  };
293
- document.addEventListener('keydown', onKey);
294
- return () => document.removeEventListener('keydown', onKey);
297
+ window.addEventListener('keydown', onKey);
298
+ return () => window.removeEventListener('keydown', onKey);
295
299
  }, [manualModalOpen, settingsModalOpen, dshModalOpen, dropdownOpen, onClose]);
296
300
 
297
301
  const fetchTasks = async () => {
302
+ setLoading(true);
298
303
  try {
299
- const res = await fetch('/dsh-cron/tasks?status=' + tab + '&query=' + encodeURIComponent(query));
304
+ const res = await fetch('/dsh-cron/tasks?status=' + encodeURIComponent(tab) + '&query=' + encodeURIComponent(query));
300
305
  const data = await res.json();
301
306
  if (data && data.ok) {
302
307
  setTasks(data.tasks || []);
303
- setRecs(data.recommendations || []);
304
- if (data.stats) setStats(data.stats);
308
+ setRecommendations(data.recommendations || []);
309
+ setStats(data.stats || null);
305
310
  }
306
- } catch (err) {}
311
+ } catch (err) {
312
+ console.error('[dsh-cron] fetch tasks error:', err);
313
+ } finally {
314
+ setLoading(false);
315
+ }
307
316
  };
308
317
 
309
- const loadModels = async (providerToFetch) => {
318
+ const loadModels = async (providerName) => {
310
319
  try {
311
- const url = '/dsh-cron/models' + (providerToFetch ? '?provider=' + encodeURIComponent(providerToFetch) : '');
320
+ const url = providerName ? '/dsh-cron/models?provider=' + encodeURIComponent(providerName) : '/dsh-cron/models';
312
321
  const res = await fetch(url);
313
322
  const data = await res.json();
314
- if (data) {
315
- if (data.providers) setProviders(data.providers);
316
- if (data.models) setModels(data.models);
317
- if (data.current) setDefaultModelInfo(data.current);
323
+ if (data && data.ok) {
324
+ if (!providerName) {
325
+ setProviders(data.providers || []);
326
+ setDefaultModelInfo(data.defaultModel || null);
327
+ if (data.defaultModel?.models) {
328
+ setModels(data.defaultModel.models);
329
+ }
330
+ } else {
331
+ setModels(data.models || []);
332
+ }
318
333
  }
319
- } catch (err) {}
334
+ } catch (err) {
335
+ console.error('[dsh-cron] load models error:', err);
336
+ }
320
337
  };
321
338
 
322
339
  React.useEffect(() => { fetchTasks(); }, [tab, query]);
@@ -324,14 +341,14 @@ const React = window.React || require('react');
324
341
 
325
342
  const handleToggleTask = async (id) => {
326
343
  try {
327
- await fetch('/dsh-cron/action/' + id + '/toggle', { method: 'POST' });
344
+ await fetch('/dsh-cron/tasks/' + encodeURIComponent(id) + '/toggle', { method: 'POST' });
328
345
  fetchTasks();
329
346
  } catch (err) {}
330
347
  };
331
348
 
332
349
  const handleRunNow = async (id) => {
333
350
  try {
334
- await fetch('/dsh-cron/action/' + id + '/run', { method: 'POST' });
351
+ await fetch('/dsh-cron/tasks/' + encodeURIComponent(id) + '/run', { method: 'POST' });
335
352
  alert('Задача запущена');
336
353
  fetchTasks();
337
354
  } catch (err) {}
@@ -340,7 +357,7 @@ const React = window.React || require('react');
340
357
  const handleDelete = async (id) => {
341
358
  if (!confirm('Удалить эту задачу?')) return;
342
359
  try {
343
- await fetch('/dsh-cron/action/' + id + '/delete', { method: 'POST' });
360
+ await fetch('/dsh-cron/tasks/' + encodeURIComponent(id), { method: 'DELETE' });
344
361
  fetchTasks();
345
362
  } catch (err) {}
346
363
  };
@@ -356,7 +373,9 @@ const React = window.React || require('react');
356
373
  setSettingsChatId(data.settings.chatId || '');
357
374
  setSettingsNotifyGlobal(Boolean(data.settings.notifyTelegram));
358
375
  setSettingsOnlyOnFailureGlobal(Boolean(data.settings.onlyOnFailure));
376
+ setSettingsKanbanUrl(data.settings.kanbanBaseUrl || 'http://127.0.0.1:3000');
359
377
  setSettingsHasDefault(Boolean(data.settings.hasDefaultCredentials));
378
+ setSettingsHasCustomBotToken(Boolean(data.settings.hasCustomBotToken));
360
379
  }
361
380
  } catch (e) {}
362
381
  };
@@ -371,7 +390,8 @@ const React = window.React || require('react');
371
390
  botToken: settingsBotToken,
372
391
  chatId: settingsChatId,
373
392
  notifyTelegram: settingsNotifyGlobal,
374
- onlyOnFailure: settingsOnlyOnFailureGlobal
393
+ onlyOnFailure: settingsOnlyOnFailureGlobal,
394
+ kanbanBaseUrl: settingsKanbanUrl,
375
395
  })
376
396
  });
377
397
  const data = await res.json();
@@ -439,7 +459,7 @@ const React = window.React || require('react');
439
459
  const loadTaskHistory = async (taskId) => {
440
460
  setHistoryLoading(true);
441
461
  try {
442
- const res = await fetch('/dsh-cron/action/' + encodeURIComponent(taskId) + '/history');
462
+ const res = await fetch('/dsh-cron/tasks/' + encodeURIComponent(taskId) + '/history');
443
463
  const data = await res.json();
444
464
  if (data.ok) {
445
465
  setTaskHistory(data.history || []);
@@ -486,6 +506,7 @@ const React = window.React || require('react');
486
506
  try {
487
507
  const payload = {
488
508
  id: formId || undefined,
509
+ status: selectedTaskMeta ? selectedTaskMeta.status : 'active',
489
510
  title: formTitle,
490
511
  schedule: formSchedule,
491
512
  prompt: formPrompt,
@@ -1094,6 +1115,93 @@ const React = window.React || require('react');
1094
1115
 
1095
1116
  function CronSettingsCard(props) {
1096
1117
  const { ctx, toggle } = props;
1118
+ const [token, setToken] = React.useState('');
1119
+ const [chatId, setChatId] = React.useState('');
1120
+ const [notify, setNotify] = React.useState(false);
1121
+ const [onlyFailure, setOnlyFailure] = React.useState(false);
1122
+ const [kanbanUrl, setKanbanUrl] = React.useState('http://127.0.0.1:3000');
1123
+ const [hasDefault, setHasDefault] = React.useState(false);
1124
+ const [saved, setSaved] = React.useState(false);
1125
+ const [testInfo, setTestInfo] = React.useState(null);
1126
+
1127
+ React.useEffect(() => {
1128
+ let active = true;
1129
+ fetch('/dsh-cron/settings')
1130
+ .then(r => r.json())
1131
+ .then(data => {
1132
+ if (active && data && data.ok && data.settings) {
1133
+ setToken(data.settings.botToken || '');
1134
+ setChatId(data.settings.chatId || '');
1135
+ setNotify(Boolean(data.settings.notifyTelegram));
1136
+ setOnlyFailure(Boolean(data.settings.onlyOnFailure));
1137
+ setKanbanUrl(data.settings.kanbanBaseUrl || 'http://127.0.0.1:3000');
1138
+ setHasDefault(Boolean(data.settings.hasDefaultCredentials));
1139
+ }
1140
+ })
1141
+ .catch(() => {});
1142
+ return () => { active = false; };
1143
+ }, []);
1144
+
1145
+ const saveSettings = async (e) => {
1146
+ if (e) e.preventDefault();
1147
+ try {
1148
+ const res = await fetch('/dsh-cron/settings', {
1149
+ method: 'POST',
1150
+ headers: { 'Content-Type': 'application/json' },
1151
+ body: JSON.stringify({
1152
+ botToken: token,
1153
+ chatId,
1154
+ notifyTelegram: notify,
1155
+ onlyOnFailure: onlyFailure,
1156
+ kanbanBaseUrl: kanbanUrl
1157
+ })
1158
+ });
1159
+ const data = await res.json();
1160
+ if (data && data.ok) {
1161
+ setSaved(true);
1162
+ setTimeout(() => setSaved(false), 2500);
1163
+ }
1164
+ } catch (err) {}
1165
+ };
1166
+
1167
+ const testTelegram = async () => {
1168
+ setTestInfo({ loading: true, message: 'Отправка...' });
1169
+ try {
1170
+ const res = await fetch('/dsh-cron/telegram/test', {
1171
+ method: 'POST',
1172
+ headers: { 'Content-Type': 'application/json' },
1173
+ body: JSON.stringify({ botToken: token, chatId })
1174
+ });
1175
+ const data = await res.json();
1176
+ if (data && data.ok) {
1177
+ setTestInfo({ success: true, message: '✅ Успешно доставлено в Telegram' });
1178
+ } else {
1179
+ setTestInfo({ success: false, message: '❌ ' + (data?.error || 'Сбой отправки') });
1180
+ }
1181
+ } catch (err) {
1182
+ setTestInfo({ success: false, message: '❌ ' + err.message });
1183
+ }
1184
+ };
1185
+
1186
+ const testKanban = async () => {
1187
+ setTestInfo({ loading: true, message: 'Проверка Kanban...' });
1188
+ try {
1189
+ const res = await fetch('/dsh-cron/kanban/test', {
1190
+ method: 'POST',
1191
+ headers: { 'Content-Type': 'application/json' },
1192
+ body: JSON.stringify({ kanbanBaseUrl: kanbanUrl })
1193
+ });
1194
+ const data = await res.json();
1195
+ if (data && data.ok) {
1196
+ setTestInfo({ success: true, message: '✅ Карточка в Kanban создана' });
1197
+ } else {
1198
+ setTestInfo({ success: false, message: '❌ ' + (data?.error || 'Ошибка Kanban') });
1199
+ }
1200
+ } catch (err) {
1201
+ setTestInfo({ success: false, message: '❌ ' + err.message });
1202
+ }
1203
+ };
1204
+
1097
1205
  return React.createElement('div', { className: 'dsh-cron-card' },
1098
1206
  React.createElement('div', { className: 'dsh-cron-card-head' },
1099
1207
  React.createElement('span', { className: 'dsh-cron-card-title' }, '⏰ Планировщик задач (Cron)'),
@@ -1101,10 +1209,80 @@ const React = window.React || require('react');
1101
1209
  type: 'button',
1102
1210
  className: 'dsh-cron-btn-primary',
1103
1211
  onClick: () => { if (toggle) toggle.set(true); }
1104
- }, 'Открыть запланированные задачи')
1212
+ }, 'Открыть панель задач')
1213
+ ),
1214
+ React.createElement('div', { className: 'dsh-cron-card-desc', style: { marginBottom: '16px' } },
1215
+ 'Автоматическое выполнение задач по расписанию (cron/интервалы), фоновые сценарии агентов и интеграция с Telegram и Kanban.'
1105
1216
  ),
1106
- React.createElement('div', { className: 'dsh-cron-card-desc' },
1107
- 'Автоматическое выполнение задач в фоновом режиме по заданному расписанию (cron/интервалы), отслеживание логов и интеграция с чатом через команду /cron.'
1217
+ React.createElement('form', { onSubmit: saveSettings, style: { display: 'flex', flexDirection: 'column', gap: '12px' } },
1218
+ React.createElement('div', { className: 'dsh-cron-form-group', style: { marginBottom: 0 } },
1219
+ React.createElement('label', null, 'Telegram Bot Token'),
1220
+ React.createElement('input', {
1221
+ type: 'password',
1222
+ value: token,
1223
+ placeholder: hasDefault ? 'Используется бот из настроек DSH (или введите свой)' : '123456789:ABCdefGhIJKlmNoPQRsTUVwxyZ',
1224
+ onChange: (e) => setToken(e.target.value)
1225
+ }),
1226
+ hasDefault && React.createElement('div', { className: 'dsh-cron-hint' }, '✓ Найден настроенный бот в профиле DSH')
1227
+ ),
1228
+ React.createElement('div', { className: 'dsh-cron-form-group', style: { marginBottom: 0 } },
1229
+ React.createElement('label', null, 'Telegram Chat ID'),
1230
+ React.createElement('input', {
1231
+ value: chatId,
1232
+ placeholder: 'Например: 345678901',
1233
+ onChange: (e) => setChatId(e.target.value)
1234
+ })
1235
+ ),
1236
+ React.createElement('div', { className: 'dsh-cron-form-group', style: { marginBottom: 0 } },
1237
+ React.createElement('label', null, 'Kanban Base URL'),
1238
+ React.createElement('input', {
1239
+ value: kanbanUrl,
1240
+ placeholder: 'http://127.0.0.1:3000',
1241
+ onChange: (e) => setKanbanUrl(e.target.value)
1242
+ })
1243
+ ),
1244
+ React.createElement('div', { style: { background: 'rgba(255,255,255,0.02)', border: '1px solid #2e2e2e', borderRadius: '6px', padding: '10px 12px', display: 'flex', flexDirection: 'column', gap: '8px' } },
1245
+ React.createElement('label', { className: 'dsh-cron-checkbox-row' },
1246
+ React.createElement('input', {
1247
+ type: 'checkbox',
1248
+ checked: notify,
1249
+ onChange: (e) => setNotify(e.target.checked)
1250
+ }),
1251
+ React.createElement('span', null, 'Глобально отправлять отчеты всех задач в Telegram')
1252
+ ),
1253
+ React.createElement('label', { className: 'dsh-cron-checkbox-row' },
1254
+ React.createElement('input', {
1255
+ type: 'checkbox',
1256
+ checked: onlyFailure,
1257
+ onChange: (e) => setOnlyFailure(e.target.checked)
1258
+ }),
1259
+ React.createElement('span', null, 'Уведомлять только при ошибках')
1260
+ )
1261
+ ),
1262
+ testInfo && testInfo.message && React.createElement('div', {
1263
+ className: 'dsh-cron-status-banner',
1264
+ 'data-type': testInfo.success ? 'success' : 'error',
1265
+ style: { margin: 0, padding: '8px 12px' }
1266
+ }, testInfo.message),
1267
+ React.createElement('div', { style: { display: 'flex', gap: '8px', alignItems: 'center', flexWrap: 'wrap', marginTop: '4px' } },
1268
+ React.createElement('button', {
1269
+ type: 'submit',
1270
+ className: 'dsh-cron-btn-primary',
1271
+ style: { minWidth: '140px' }
1272
+ }, saved ? '✓ Сохранено' : 'Сохранить настройки'),
1273
+ React.createElement('button', {
1274
+ type: 'button',
1275
+ className: 'dsh-cron-btn-secondary',
1276
+ onClick: testTelegram,
1277
+ disabled: testInfo?.loading
1278
+ }, '🔔 Тест Telegram'),
1279
+ React.createElement('button', {
1280
+ type: 'button',
1281
+ className: 'dsh-cron-btn-secondary',
1282
+ onClick: testKanban,
1283
+ disabled: testInfo?.loading
1284
+ }, '📋 Тест Kanban')
1285
+ )
1108
1286
  )
1109
1287
  );
1110
1288
  }
package/lib/index.js CHANGED
@@ -7,10 +7,19 @@ import { sendTelegramMessage } from './telegram.js';
7
7
  import { createKanbanCard } from './integrations.js';
8
8
  import { defineTool } from '@deepseek-ai/dsh-tools';
9
9
  import { randomUUID } from 'node:crypto';
10
+ import z from '@deepseek-ai/schemastery';
10
11
 
11
12
  export const name = '@goodandready/dsh-cron';
12
13
  export const inject = ['tools', 'webServer', 'settings', 'llm', 'agents', 'agentDefaultModel'];
13
14
 
15
+ export const Config = z.object({
16
+ botToken: z.string().role('secret').default('').description('Telegram Bot Token для отправки отчетов'),
17
+ chatId: z.string().default('').description('Telegram Chat ID получателя'),
18
+ notifyTelegram: z.boolean().default(false).description('Включить глобальную отправку отчетов в Telegram'),
19
+ onlyOnFailure: z.boolean().default(false).description('Отправлять отчеты в Telegram только при ошибках'),
20
+ kanbanBaseUrl: z.string().default('http://127.0.0.1:3000').description('Базовый URL Kanban API'),
21
+ });
22
+
14
23
  function parseJsonBody(req) {
15
24
  return new Promise((resolve, reject) => {
16
25
  let data = '';
@@ -36,6 +45,24 @@ export function apply(ctx, config) {
36
45
  const runner = new SessionRunner(ctx);
37
46
  const scheduler = new TaskScheduler(store, (task, opts) => runner.execute(task, opts));
38
47
 
48
+ // Register settings in Harness settings service with Config schema and initial state from store (#71)
49
+ ctx.inject(['settings'], (sctx) => {
50
+ try {
51
+ const scope = sctx.settings?.register?.('@goodandready/dsh-cron', Config, {
52
+ base: store.getSettings()
53
+ });
54
+ if (scope && typeof scope.watch === 'function') {
55
+ scope.watch((updated) => {
56
+ if (updated && typeof updated === 'object') {
57
+ store.saveSettings(updated);
58
+ }
59
+ });
60
+ }
61
+ } catch (e) {
62
+ console.warn('[dsh-cron] settings registration warning:', e.message);
63
+ }
64
+ });
65
+
39
66
  scheduler.start();
40
67
 
41
68
  ctx.effect(() => {
@@ -98,25 +125,33 @@ export function apply(ctx, config) {
98
125
  return;
99
126
  }
100
127
  const parsed = parseScheduleExpression(body.schedule);
128
+ const current = body.id ? store.get(body.id) : null;
129
+ const resolvedStatus = body.status || (current ? current.status : 'active');
130
+ const taskType = body.type === 'script' ? 'script' : 'llm';
131
+
101
132
  const task = store.set({
102
133
  id: body.id,
103
134
  title: body.title,
104
135
  schedule: parsed.cronPattern || body.schedule,
105
136
  scheduleText: body.scheduleText || parsed.humanText,
106
137
  prompt: body.prompt,
107
- type: body.type || 'prompt',
138
+ type: taskType,
108
139
  delivery: body.delivery || 'current',
109
- status: body.status || 'active',
140
+ status: resolvedStatus,
110
141
  provider: body.provider || undefined,
111
142
  model: body.model || undefined,
112
- notifyTelegram: body.notifyTelegram !== undefined ? Boolean(body.notifyTelegram) : false,
113
- onlyOnFailure: body.onlyOnFailure !== undefined ? Boolean(body.onlyOnFailure) : false,
114
- timeoutSeconds: body.timeoutSeconds !== undefined ? Number(body.timeoutSeconds) : 1800,
115
- overlapPolicy: body.overlapPolicy || 'skip',
116
- kanbanMode: body.kanbanMode || 'none',
143
+ notifyTelegram: body.notifyTelegram !== undefined ? Boolean(body.notifyTelegram) : (current ? current.notifyTelegram : false),
144
+ onlyOnFailure: body.onlyOnFailure !== undefined ? Boolean(body.onlyOnFailure) : (current ? current.onlyOnFailure : false),
145
+ timeoutSeconds: body.timeoutSeconds !== undefined ? Number(body.timeoutSeconds) : (current ? current.timeoutSeconds : 1800),
146
+ overlapPolicy: body.overlapPolicy || (current ? current.overlapPolicy : 'skip'),
147
+ kanbanMode: body.kanbanMode || (current ? current.kanbanMode : 'none'),
117
148
  oneShot: Boolean(parsed.isOneShot || body.oneShot),
118
149
  });
119
- scheduler.scheduleTask(task);
150
+ if (task.status === 'active') {
151
+ scheduler.scheduleTask(task);
152
+ } else {
153
+ scheduler.pauseTask(task.id);
154
+ }
120
155
  sendJson(res, 200, { ok: true, task });
121
156
  return;
122
157
  }
@@ -153,13 +188,13 @@ export function apply(ctx, config) {
153
188
  handler: async (req, res) => {
154
189
  try {
155
190
  if (req.method === 'GET') {
156
- sendJson(res, 200, { ok: true, settings: store.getSettings() });
191
+ sendJson(res, 200, { ok: true, settings: store.getClientSettings() });
157
192
  return;
158
193
  }
159
194
  if (req.method === 'POST') {
160
195
  const body = await parseJsonBody(req);
161
- const updated = store.saveSettings(body);
162
- sendJson(res, 200, { ok: true, settings: updated });
196
+ store.saveSettings(body);
197
+ sendJson(res, 200, { ok: true, settings: store.getClientSettings() });
163
198
  return;
164
199
  }
165
200
  sendJson(res, 405, { ok: false, error: 'Method not allowed' });
@@ -282,6 +317,17 @@ export function apply(ctx, config) {
282
317
  return;
283
318
  }
284
319
 
320
+ if (req.method === 'POST' && (action === 'toggle' || (!action && url.searchParams.get('action') === 'toggle'))) {
321
+ const current = store.get(id);
322
+ if (!current) {
323
+ sendJson(res, 404, { ok: false, error: 'Задача не найдена' });
324
+ return;
325
+ }
326
+ const task = scheduler.toggleTask(id);
327
+ sendJson(res, 200, { ok: true, task });
328
+ return;
329
+ }
330
+
285
331
  if (req.method === 'PATCH') {
286
332
  const body = await parseJsonBody(req);
287
333
  const current = store.get(id);
@@ -289,15 +335,24 @@ export function apply(ctx, config) {
289
335
  sendJson(res, 404, { ok: false, error: 'Задача не найдена' });
290
336
  return;
291
337
  }
292
- const task = store.set({ ...current, ...body, id });
338
+ let patchData = { ...body };
339
+ if (body.schedule) {
340
+ const parsed = parseScheduleExpression(body.schedule);
341
+ patchData.schedule = parsed.cronPattern || body.schedule;
342
+ patchData.scheduleText = body.scheduleText || parsed.humanText;
343
+ patchData.oneShot = Boolean(parsed.isOneShot || body.oneShot);
344
+ }
345
+ const task = store.set({ ...current, ...patchData, id });
293
346
  if (task.status === 'active') {
294
347
  scheduler.scheduleTask(task);
348
+ } else {
349
+ scheduler.pauseTask(id);
295
350
  }
296
351
  sendJson(res, 200, { ok: true, task });
297
352
  return;
298
353
  }
299
354
 
300
- if (req.method === 'DELETE') {
355
+ if (req.method === 'DELETE' || (req.method === 'POST' && action === 'delete')) {
301
356
  const current = store.get(id);
302
357
  if (current) {
303
358
  scheduler.pauseTask(id);
@@ -314,6 +369,64 @@ export function apply(ctx, config) {
314
369
  }
315
370
  }), 'dsh-cron: /tasks/:id');
316
371
 
372
+ // Alias route /dsh-cron/action/:id/:action for backward compatibility (#66)
373
+ ctx.effect(() => ctx.webServer.register({
374
+ kind: 'prefix',
375
+ path: '/dsh-cron/action/',
376
+ handler: async (req, res) => {
377
+ try {
378
+ const url = new URL(req.url, 'http://127.0.0.1');
379
+ const parts = url.pathname.split('/').filter(Boolean);
380
+ const id = parts[2];
381
+ const action = parts[3];
382
+
383
+ if (!id) {
384
+ sendJson(res, 400, { ok: false, error: 'ID задачи не указан' });
385
+ return;
386
+ }
387
+
388
+ if (action === 'history' || (req.method === 'GET' && !action)) {
389
+ const limit = parseInt(url.searchParams.get('limit') || '20', 10);
390
+ const history = store.getHistory(id, limit);
391
+ sendJson(res, 200, { ok: true, history });
392
+ return;
393
+ }
394
+
395
+ if (action === 'run') {
396
+ await scheduler.triggerManualRun(id);
397
+ const task = store.get(id);
398
+ sendJson(res, 200, { ok: true, task });
399
+ return;
400
+ }
401
+
402
+ if (action === 'toggle') {
403
+ const current = store.get(id);
404
+ if (!current) {
405
+ sendJson(res, 404, { ok: false, error: 'Задача не найдена' });
406
+ return;
407
+ }
408
+ const task = scheduler.toggleTask(id);
409
+ sendJson(res, 200, { ok: true, task });
410
+ return;
411
+ }
412
+
413
+ if (action === 'delete') {
414
+ const current = store.get(id);
415
+ if (current) {
416
+ scheduler.pauseTask(id);
417
+ store.delete(id);
418
+ }
419
+ sendJson(res, 200, { ok: true });
420
+ return;
421
+ }
422
+
423
+ sendJson(res, 405, { ok: false, error: 'Action not supported' });
424
+ } catch (err) {
425
+ sendJson(res, 500, { ok: false, error: err.message });
426
+ }
427
+ }
428
+ }), 'dsh-cron: /action/:id/:action');
429
+
317
430
  // 2. Tools
318
431
  ctx.tools.register(defineTool({
319
432
  name: 'cron_create_task',
@@ -345,13 +458,73 @@ export function apply(ctx, config) {
345
458
  render: (_args, val) => [{ type: 'text', text: val.message || JSON.stringify(val) }]
346
459
  },
347
460
  execute: async (args) => {
461
+ const taskType = args.type === 'script' ? 'script' : 'llm';
462
+ const parsed = parseScheduleExpression(args.schedule);
463
+ const task = store.set({
464
+ title: args.title,
465
+ schedule: parsed.cronPattern || args.schedule,
466
+ scheduleText: parsed.humanText,
467
+ prompt: args.prompt,
468
+ type: taskType,
469
+ delivery: args.delivery || 'isolated',
470
+ status: 'active',
471
+ provider: args.provider,
472
+ model: args.model,
473
+ notifyTelegram: Boolean(args.notifyTelegram),
474
+ onlyOnFailure: Boolean(args.onlyOnFailure),
475
+ timeoutSeconds: Number(args.timeoutSeconds) || 1800,
476
+ overlapPolicy: args.overlapPolicy || 'skip',
477
+ kanbanMode: args.kanbanMode || 'none',
478
+ oneShot: Boolean(parsed.isOneShot),
479
+ });
480
+ scheduler.scheduleTask(task);
481
+ return {
482
+ success: true,
483
+ message: `Задача "${task.title}" успешно запланирована (${task.scheduleText})`,
484
+ task
485
+ };
486
+ }
487
+ }));
488
+
489
+ // Alias tool cron_schedule_task for compatibility with agent prompts (#70)
490
+ ctx.tools.register(defineTool({
491
+ name: 'cron_schedule_task',
492
+ description: 'Алиас для cron_create_task: создать новую запланированную задачу cron',
493
+ parameters: {
494
+ title: { type: 'string', description: 'Краткое название задачи', required: true },
495
+ schedule: { type: 'string', description: 'Расписание: cron, интервал или разово', required: true },
496
+ prompt: { type: 'string', description: 'Промпт/инструкция для агента при запуске', required: true },
497
+ type: { type: 'string', enum: ['llm', 'prompt', 'script'], description: 'Тип задачи' },
498
+ delivery: { type: 'string', enum: ['current', 'isolated'], description: 'Режим запуска' },
499
+ provider: { type: 'string', description: 'Провайдер модели (опционально)' },
500
+ model: { type: 'string', description: 'Модель (опционально)' },
501
+ notifyTelegram: { type: 'boolean', description: 'Отправлять отчет в Telegram' },
502
+ onlyOnFailure: { type: 'boolean', description: 'Отправлять отчет только при сбоях' },
503
+ timeoutSeconds: { type: 'number', description: 'Лимит времени выполнения в секундах' },
504
+ overlapPolicy: { type: 'string', enum: ['skip', 'queue', 'replace'], description: 'Политика перекрытия' },
505
+ kanbanMode: { type: 'string', enum: ['none', 'on_failure', 'always'], description: 'Создавать карточку в Kanban' },
506
+ },
507
+ output: {
508
+ schema: {
509
+ type: 'object',
510
+ additionalProperties: true,
511
+ properties: {
512
+ success: { type: 'boolean' },
513
+ message: { type: 'string' },
514
+ task: { type: 'object', additionalProperties: true }
515
+ }
516
+ },
517
+ render: (_args, val) => [{ type: 'text', text: val.message || JSON.stringify(val) }]
518
+ },
519
+ execute: async (args) => {
520
+ const taskType = args.type === 'script' ? 'script' : 'llm';
348
521
  const parsed = parseScheduleExpression(args.schedule);
349
522
  const task = store.set({
350
523
  title: args.title,
351
524
  schedule: parsed.cronPattern || args.schedule,
352
525
  scheduleText: parsed.humanText,
353
526
  prompt: args.prompt,
354
- type: args.type || 'prompt',
527
+ type: taskType,
355
528
  delivery: args.delivery || 'isolated',
356
529
  status: 'active',
357
530
  provider: args.provider,
package/lib/prompt.js CHANGED
@@ -11,7 +11,7 @@ export function buildAgentCronPrompt(userText) {
11
11
 
12
12
  ВАЖНЕЙШИЕ ПРАВИЛА ДЛЯ АГЕНТА:
13
13
  1. СТРОГИЙ ЗАПРЕТ НА СПЕШКУ:
14
- КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО вызывать инструмент \`cron_schedule_task\` в первом же ответе!
14
+ КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО вызывать инструмент \`cron_create_task\` (или \`cron_schedule_task\`) в первом же ответе!
15
15
  Ты НЕ имеешь права создавать задачу, пока не задашь пользователю уточняющие вопросы и не получишь его подтверждение.
16
16
 
17
17
  2. ПЕРВЫЙ ВОПРОС: LLM vs NO-LLM Shell (КРИТИЧЕСКИ ВАЖНО):
@@ -44,7 +44,7 @@ export function buildAgentCronPrompt(userText) {
44
44
  - Задай недостающие вопросы и попроси подтвердить создание задачи.
45
45
 
46
46
  5. СОЗДАНИЕ ЗАДАЧИ:
47
- Только ПОСЛЕ ТОГО, как пользователь ответит и подтвердит параметры, вызови инструмент \`cron_schedule_task\`, передав:
47
+ Только ПОСЛЕ ТОГО, как пользователь ответит и подтвердит параметры, вызови инструмент \`cron_create_task\`, передав:
48
48
  - title: понятное название задачи
49
49
  - schedule: согласованное cron-выражение
50
50
  - prompt: финальная shell-команда/путь к скрипту (для type="script") ИЛИ подробная инструкция с Silent Rule (для type="llm")
package/lib/runner.js CHANGED
@@ -82,14 +82,18 @@ export class SessionRunner {
82
82
  }
83
83
  } catch {}
84
84
 
85
- let mintSessionId = () => `cron-exec-${task.id}-${randomUUID()}`;
85
+ let sessionModule = null;
86
86
  try {
87
- const sessionModule = await import('@deepseek-ai/dsh-session');
87
+ sessionModule = await import('@deepseek-ai/dsh-session');
88
+ } catch {}
89
+
90
+ const mintSessionId = () => {
91
+ const sid = `cron-exec-${task.id}-${randomUUID()}`;
88
92
  if (sessionModule && typeof sessionModule.SessionId === 'function') {
89
- const sid = mintSessionId();
90
- mintSessionId = () => sessionModule.SessionId(sid);
93
+ return sessionModule.SessionId(sid);
91
94
  }
92
- } catch {}
95
+ return sid;
96
+ };
93
97
 
94
98
  const defaultSel = this.ctx.get?.('agentDefaultModel')?.currentSelection?.();
95
99
  const provider = task.provider || defaultSel?.provider;
@@ -112,6 +116,11 @@ export class SessionRunner {
112
116
  source: { kind: 'plugin', plugin: 'dsh-cron', form: 'cron-execute' },
113
117
  }));
114
118
 
119
+ // Await agent turn completion (#67)
120
+ if (typeof handle.agent.whenIdle === 'function') {
121
+ await handle.agent.whenIdle();
122
+ }
123
+
115
124
  // Try extracting token usage from session history if available
116
125
  let usage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 };
117
126
  try {
@@ -128,7 +137,7 @@ export class SessionRunner {
128
137
  const costUsd = estimateTokenCost(model, usage);
129
138
 
130
139
  return {
131
- output: `[dsh-cron] Agent started session ${handle.agent.session?.id || 'cron'}`,
140
+ output: `[dsh-cron] Agent finished turn for task "${task.title}" (session: ${handle.agent.session?.id || 'cron'})`,
132
141
  usage,
133
142
  costUsd
134
143
  };
package/lib/scheduler.js CHANGED
@@ -275,8 +275,12 @@ export class TaskScheduler {
275
275
  }
276
276
  }
277
277
  } catch (err) {
278
- status = 'error';
279
278
  error = err.message || String(err);
279
+ if (error.toLowerCase().includes('timed out') || error.toLowerCase().includes('timeout')) {
280
+ status = 'timeout';
281
+ } else {
282
+ status = 'error';
283
+ }
280
284
  } finally {
281
285
  const durationMs = Date.now() - start;
282
286
  const runInfo = {
@@ -404,6 +408,16 @@ export class TaskScheduler {
404
408
  return task;
405
409
  }
406
410
 
411
+ toggleTask(taskId) {
412
+ const task = this.store.get(taskId);
413
+ if (!task) return null;
414
+ if (task.status === 'active') {
415
+ return this.pauseTask(taskId);
416
+ } else {
417
+ return this.resumeTask(taskId);
418
+ }
419
+ }
420
+
407
421
  removeTask(taskId) {
408
422
  if (this.jobs.has(taskId)) {
409
423
  this.jobs.get(taskId).stop();
package/lib/store.js CHANGED
@@ -77,10 +77,39 @@ export class TaskStore {
77
77
  };
78
78
  }
79
79
 
80
+ getClientSettings() {
81
+ const raw = this.getSettings();
82
+ const isCustom = Boolean(this.settings.botToken);
83
+ const hasToken = Boolean(raw.botToken);
84
+ const masked = hasToken ? (raw.botToken.length > 8 ? raw.botToken.slice(0, 4) + '••••••••' + raw.botToken.slice(-3) : '••••••••') : '';
85
+ return {
86
+ botToken: masked,
87
+ botTokenMasked: masked,
88
+ hasBotToken: hasToken,
89
+ hasCustomBotToken: isCustom,
90
+ chatId: raw.chatId,
91
+ notifyTelegram: raw.notifyTelegram,
92
+ onlyOnFailure: raw.onlyOnFailure,
93
+ hasDefaultCredentials: raw.hasDefaultCredentials,
94
+ kanbanBaseUrl: raw.kanbanBaseUrl,
95
+ };
96
+ }
97
+
80
98
  saveSettings(newSettings = {}) {
99
+ let botTokenToSave = this.settings.botToken || '';
100
+ if (newSettings.botToken !== undefined) {
101
+ const val = String(newSettings.botToken).trim();
102
+ // If client sends masked token or empty string while custom token existed, don't overwrite with dots
103
+ if (val && !val.includes('••') && !val.includes('****')) {
104
+ botTokenToSave = val;
105
+ } else if (val === '') {
106
+ botTokenToSave = '';
107
+ }
108
+ }
109
+
81
110
  this.settings = {
82
111
  ...this.settings,
83
- botToken: newSettings.botToken !== undefined ? String(newSettings.botToken).trim() : (this.settings.botToken || ''),
112
+ botToken: botTokenToSave,
84
113
  chatId: newSettings.chatId !== undefined ? String(newSettings.chatId).trim() : (this.settings.chatId || ''),
85
114
  notifyTelegram: newSettings.notifyTelegram !== undefined ? Boolean(newSettings.notifyTelegram) : Boolean(this.settings.notifyTelegram),
86
115
  onlyOnFailure: newSettings.onlyOnFailure !== undefined ? Boolean(newSettings.onlyOnFailure) : Boolean(this.settings.onlyOnFailure),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.1.20",
3
+ "version": "0.1.21",
4
4
  "description": "Scheduled cron tasks, background automation and agent execution for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",