@goodandready/dsh-cron 0.2.4 → 0.2.6

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/index.js CHANGED
@@ -16,7 +16,53 @@ import {
16
16
  } from './http-utils.js';
17
17
  import { TASK_TYPES, CODE_EXECUTING_TYPES, normalizeTaskType } from './runtimes.js';
18
18
  import { resolveTelegramSecrets, resolveCredentialValue } from './secrets.js';
19
- import { deliverRun, CHANNEL_IDS } from './channels.js';
19
+ import {
20
+ PATCHABLE_TASK_FIELDS,
21
+ DUPLICATE_TASK_FIELDS,
22
+ RESERVED_TASK_IDS,
23
+ EXPORT_SECRET_BEARING_FIELDS,
24
+ validateTaskType,
25
+ buildDuplicateTask,
26
+ buildTaskExport,
27
+ validateImportDocument,
28
+ hasCodeExecutingTask,
29
+ planImport,
30
+ TASK_EXPORT_KIND,
31
+ TASK_EXPORT_VERSION,
32
+ EXPORT_TASK_FIELDS,
33
+ IMPORT_STRATEGIES,
34
+ } from './task-transfer.js';
35
+
36
+ // Re-exported for backward compatibility: tests and tools import these names
37
+ // from lib/index.js.
38
+ export {
39
+ PATCHABLE_TASK_FIELDS,
40
+ DUPLICATE_TASK_FIELDS,
41
+ RESERVED_TASK_IDS,
42
+ EXPORT_SECRET_BEARING_FIELDS,
43
+ validateTaskType,
44
+ buildDuplicateTask,
45
+ buildTaskExport,
46
+ validateImportDocument,
47
+ hasCodeExecutingTask,
48
+ planImport,
49
+ TASK_EXPORT_KIND,
50
+ TASK_EXPORT_VERSION,
51
+ EXPORT_TASK_FIELDS,
52
+ IMPORT_STRATEGIES,
53
+ };
54
+ import { deliverRun, CHANNEL_IDS, resolveDeliveryTimeoutMs, MIN_DELIVERY_TIMEOUT_MS } from './channels.js';
55
+ import { sendJson, rejectCrossOrigin, readBody } from './http-utils.js';
56
+ import { recipeRecommendations, recipesByCategory } from './recipes.js';
57
+ import { applyTaskPatch, describeTaskPatch } from './task-patch.js';
58
+ import { makeAsk } from './silent-rule.js';
59
+
60
+ // The REST surface lives in lib/api.js. It is IMPORTED (the plugin body calls
61
+ // it) and re-exported for tests and tools that import it from the entry point —
62
+ // a bare re-export creates no local binding and the plugin then fails to load
63
+ // with "createCronApiHandler is not defined".
64
+ import { createCronApiHandler } from './api.js';
65
+ export { createCronApiHandler };
20
66
 
21
67
  export const name = '@goodandready/dsh-cron';
22
68
  export const inject = ['tools', 'webServer', 'settings', 'llm', 'agents', 'agentDefaultModel', 'credentials'];
@@ -35,7 +81,9 @@ export const Config = z.object({
35
81
  // --- Delivery channels (#20-#23, #26, #28, #47) ---
36
82
  template: z.string().default('').description('Global notification template; placeholders: {title} {status} {output} {error} {duration} {schedule} {time} {tokens} {cost}'),
37
83
  channelTemplates: z.object({}).default({}).description('Per-channel template overrides keyed by channel id'),
38
- deliveryTimeoutMs: z.number().default(15000).description('Per-channel delivery timeout in ms; a slower endpoint is recorded as a failure and does not block other channels or the next tick'),
84
+ silentRuleModel: z.string().default('').description('Model used to evaluate a task silent rule (empty = the task model)'),
85
+ inspectorModel: z.string().default('').description('Model used to diagnose failed runs (empty = the task model)'),
86
+ deliveryTimeoutMs: z.number().min(MIN_DELIVERY_TIMEOUT_MS).default(15000).description('Per-channel delivery timeout in ms (minimum 1000); a slower endpoint is recorded as a failure and does not block other channels or the next tick'),
39
87
  discordWebhookUrl: z.string().default('').description('Discord webhook URL'),
40
88
  slackWebhookUrl: z.string().default('').description('Slack incoming webhook URL'),
41
89
  ntfyUrl: z.string().default('https://ntfy.sh').description('ntfy server base URL'),
@@ -45,121 +93,21 @@ export const Config = z.object({
45
93
  barkKey: z.string().default('').description('Bark device key'),
46
94
  pushplusTokenRef: z.string().default('').description('Credential name for the PushPlus token'),
47
95
  pushplusUrl: z.string().default('https://www.pushplus.plus/send').description('PushPlus send endpoint (override for a self-hosted proxy)'),
48
- smtpHost: z.string().default('').description('SMTP host for email notifications'),
49
- smtpPort: z.number().default(587).description('SMTP port'),
50
- smtpSecure: z.boolean().default(false).description('Use implicit TLS for SMTP'),
51
- smtpUser: z.string().default('').description('SMTP user name'),
52
- smtpPasswordRef: z.string().default('').description('Credential name for the SMTP password'),
53
- smtpFrom: z.string().default('').description('From address for email notifications'),
54
- smtpTo: z.string().default('').description('Comma-separated recipient addresses'),
55
96
  ttsBaseUrl: z.string().default('http://127.0.0.1:3080').description('Base URL of the harness used to reach the dsh-tts speak route'),
56
97
  giteaBaseUrl: z.string().default('').description('Gitea base URL for alert issues'),
57
98
  giteaRepo: z.string().default('').description('Gitea repository (owner/name) for alert issues'),
58
99
  giteaTokenRef: z.string().default('').description('Credential name for the Gitea API token'),
59
100
  });
60
101
 
61
- const PATCHABLE_TASK_FIELDS = [
62
- 'title',
63
- 'schedule',
64
- 'scheduleText',
65
- 'prompt',
66
- 'type',
67
- 'delivery',
68
- 'provider',
69
- 'model',
70
- 'timezone',
71
- 'misfirePolicy',
72
- 'maxRetries',
73
- 'retryBackoffMs',
74
- 'permissionPreset',
75
- 'env',
76
- 'cwd',
77
- 'workspaceId',
78
- 'worktree',
79
- 'keepWorktree',
80
- 'httpMethod',
81
- 'httpUrl',
82
- 'httpHeaders',
83
- 'httpBody',
84
- 'sshTarget',
85
- 'sshPort',
86
- 'sshKeyPath',
87
- 'sshProfileId',
88
- 'dockerImage',
89
- 'pythonPath',
90
- 'nodePath',
91
- 'skillName',
92
- 'workflowName',
93
- 'notifyTelegram',
94
- 'onlyOnFailure',
95
- 'timeoutSeconds',
96
- 'overlapPolicy',
97
- 'kanbanMode',
98
- 'channels',
99
- 'template',
100
- 'status',
101
- 'oneShot',
102
- ];
103
-
104
- /**
105
- * Validate runtime-specific task configuration. Returns an error string or
106
- * null when the payload is usable for the given type.
107
- */
108
- export function validateTaskType(type, payload) {
109
- const t = normalizeTaskType(type);
110
- if (t === 'http') {
111
- const raw = String((payload && (payload.httpUrl || payload.prompt)) || '').trim();
112
- if (!raw) return 'HTTP tasks require a URL (httpUrl or prompt)';
113
- try {
114
- // eslint-disable-next-line no-new
115
- new URL(raw);
116
- } catch {
117
- return `Invalid HTTP URL: ${raw}`;
118
- }
119
- }
120
- if (t === 'ssh') {
121
- const hasProfile = Boolean(payload && String(payload.sshProfileId || '').trim());
122
- const hasTarget = Boolean(payload && String(payload.sshTarget || '').trim());
123
- if (!hasProfile && !hasTarget) return 'SSH tasks require sshProfileId (remote-workspace profile) or sshTarget (user@host)';
124
- }
125
- if (t === 'docker' && !String((payload && payload.dockerImage) || '').trim()) {
126
- return 'Docker tasks require dockerImage';
127
- }
128
- return null;
129
- }
130
-
131
- function sendJson(res, statusCode, data) {
132
- res.writeHead(statusCode, { 'Content-Type': 'application/json' });
133
- res.end(JSON.stringify(data));
134
- }
135
102
 
136
- function rejectCrossOrigin(req, res) {
137
- if (isCrossOrigin(req)) {
138
- sendJson(res, 403, { ok: false, error: 'Cross-origin request rejected' });
139
- return true;
140
- }
141
- return false;
142
- }
143
-
144
- async function readBody(req, res) {
145
- try {
146
- return { body: await parseJsonBody(req) };
147
- } catch (err) {
148
- sendJson(res, err.statusCode || 400, { ok: false, error: err.message });
149
- return { error: true };
150
- }
151
- }
152
-
153
- /** Settings keys synchronized between the store and the settings service. */
154
103
  export const SETTINGS_SYNC_KEYS = [
155
104
  'botToken', 'botTokenRef', 'chatId', 'notifyTelegram', 'onlyOnFailure', 'kanbanBaseUrl',
156
- 'template', 'channelTemplates', 'deliveryTimeoutMs',
105
+ 'template', 'channelTemplates', 'deliveryTimeoutMs', 'silentRuleModel', 'inspectorModel',
157
106
  'discordWebhookUrl', 'slackWebhookUrl',
158
107
  'ntfyUrl', 'ntfyTopic', 'ntfyTokenRef',
159
108
  'barkServerUrl', 'barkKey',
160
109
  'pushplusTokenRef',
161
110
  'pushplusUrl',
162
- 'smtpHost', 'smtpPort', 'smtpSecure', 'smtpUser', 'smtpPasswordRef', 'smtpFrom', 'smtpTo',
163
111
  'ttsBaseUrl',
164
112
  'giteaBaseUrl', 'giteaRepo', 'giteaTokenRef',
165
113
  ];
@@ -187,7 +135,7 @@ export async function applySettingsToScope(settingsScope, body) {
187
135
  * Drop values that must never re-enter the store from the client:
188
136
  *
189
137
  * - a masked secret echoed back by the settings UI would overwrite the real
190
- * value (bot token, SMTP password, webhook URLs, Bark key);
138
+ * value (bot token, webhook URLs, Bark key);
191
139
  * - the raw credential keys are refused outright — the *Ref fields are the
192
140
  * supported way to configure them.
193
141
  *
@@ -208,235 +156,21 @@ export function sanitizeSettingsPayload(body) {
208
156
  delete payload[key];
209
157
  }
210
158
  }
211
- return payload;
212
- }
213
-
214
- /**
215
- * Shared implementation for the /dsh-cron/tasks... and /dsh-cron/action...
216
- * route families — the #66 alias is kept alive by the same code path instead
217
- * of a duplicated handler.
218
- *
219
- * IMPORTANT: prefix routes must be registered WITHOUT a trailing slash —
220
- * with one, the core degrades matching to exact-path equality and every
221
- * deeper path unrouted (#96). Exported for unit tests.
222
- */
223
- export function createCronApiHandler(store, scheduler, collection) {
224
- return async function handleCronApi(req, res) {
225
- try {
226
- const url = new URL(req.url, 'http://127.0.0.1');
227
- const parts = url.pathname.split('/').filter(Boolean);
228
- const scope = parts[1]; // 'tasks' | 'action'
229
- const id = parts[2];
230
- const action = parts[3];
231
-
232
- // Collection level: /dsh-cron/tasks (list + create)
233
- if (!id) {
234
- if (scope === 'action' || !collection) {
235
- sendJson(res, 405, { ok: false, error: 'Method not allowed' });
236
- return;
237
- }
238
- if (req.method === 'GET') {
239
- const status = url.searchParams.get('status') || 'all';
240
- const query = url.searchParams.get('query') || '';
241
- const list = store.list({ status, query }).map((t) => ({
242
- ...t,
243
- running: scheduler.isRunning(t.id),
244
- runningSince: scheduler.runningSince(t.id),
245
- }));
246
- const stats = store.getAggregatedStats();
247
- sendJson(res, 200, { ok: true, tasks: list, recommendations: collection.recommendations, stats });
248
- return;
249
- }
250
- if (req.method === 'POST') {
251
- if (rejectCrossOrigin(req, res)) return;
252
- const { body, error } = await readBody(req, res);
253
- if (error) return;
254
- if (!body.title || !body.schedule || !body.prompt) {
255
- sendJson(res, 400, { ok: false, error: 'Fields title, schedule and prompt are required' });
256
- return;
257
- }
258
- const taskType = normalizeTaskType(body.type);
259
- if (CODE_EXECUTING_TYPES.includes(taskType) && req.headers[SCRIPT_CONFIRM_HEADER] !== 'script') {
260
- sendJson(res, 403, { ok: false, error: `Creating ${taskType} tasks over HTTP requires the x-dsh-cron-confirm: script header` });
261
- return;
262
- }
263
- const typeError = validateTaskType(taskType, body);
264
- if (typeError) {
265
- sendJson(res, 400, { ok: false, error: typeError });
266
- return;
267
- }
268
- const parsed = parseScheduleExpression(body.schedule);
269
- const current = body.id ? store.get(body.id) : null;
270
- const resolvedStatus = body.status || (current ? current.status : 'active');
271
-
272
- const task = store.set({
273
- id: body.id,
274
- title: body.title,
275
- schedule: parsed.cronPattern || body.schedule,
276
- scheduleText: body.scheduleText || parsed.humanText,
277
- prompt: body.prompt,
278
- type: taskType,
279
- delivery: body.delivery || 'current',
280
- status: resolvedStatus,
281
- provider: body.provider || undefined,
282
- model: body.model || undefined,
283
- notifyTelegram: body.notifyTelegram !== undefined ? Boolean(body.notifyTelegram) : (current ? current.notifyTelegram : false),
284
- onlyOnFailure: body.onlyOnFailure !== undefined ? Boolean(body.onlyOnFailure) : (current ? current.onlyOnFailure : false),
285
- timeoutSeconds: body.timeoutSeconds !== undefined ? Number(body.timeoutSeconds) : (current ? current.timeoutSeconds : 1800),
286
- overlapPolicy: body.overlapPolicy || (current ? current.overlapPolicy : 'skip'),
287
- kanbanMode: body.kanbanMode || (current ? current.kanbanMode : 'none'),
288
- timezone: body.timezone !== undefined ? String(body.timezone).trim() : (current ? current.timezone : ''),
289
- misfirePolicy: ['skip', 'runOnce', 'catchUpAll'].includes(body.misfirePolicy) ? body.misfirePolicy : (current ? (current.misfirePolicy || 'skip') : 'skip'),
290
- maxRetries: body.maxRetries !== undefined ? Math.max(0, Number(body.maxRetries) || 0) : (current ? (Number(current.maxRetries) || 0) : 0),
291
- retryBackoffMs: body.retryBackoffMs !== undefined ? Math.max(1000, Number(body.retryBackoffMs) || 30000) : (current ? (Number(current.retryBackoffMs) || 30000) : 30000),
292
- permissionPreset: ['default', 'read-only', 'workspace-write', 'full'].includes(body.permissionPreset) ? body.permissionPreset : (current ? (current.permissionPreset || 'default') : 'default'),
293
- env: body.env && typeof body.env === 'object' ? body.env : (current ? current.env : undefined),
294
- cwd: body.cwd !== undefined ? String(body.cwd).trim() : (current ? current.cwd : ''),
295
- channels: Array.isArray(body.channels)
296
- ? body.channels.filter((c) => CHANNEL_IDS.includes(c))
297
- : (current ? current.channels : undefined),
298
- template: body.template !== undefined ? String(body.template) : (current ? current.template : ''),
299
- workspaceId: body.workspaceId !== undefined ? String(body.workspaceId).trim() : (current ? current.workspaceId : ''),
300
- worktree: body.worktree !== undefined ? Boolean(body.worktree) : (current ? Boolean(current.worktree) : false),
301
- keepWorktree: body.keepWorktree !== undefined ? Boolean(body.keepWorktree) : (current ? Boolean(current.keepWorktree) : false),
302
- httpMethod: body.httpMethod ? String(body.httpMethod).toUpperCase() : (current ? current.httpMethod : 'GET'),
303
- httpUrl: body.httpUrl !== undefined ? String(body.httpUrl).trim() : (current ? current.httpUrl : ''),
304
- httpHeaders: body.httpHeaders !== undefined ? body.httpHeaders : (current ? current.httpHeaders : undefined),
305
- httpBody: body.httpBody !== undefined ? String(body.httpBody) : (current ? current.httpBody : ''),
306
- sshProfileId: body.sshProfileId !== undefined ? String(body.sshProfileId).trim() : (current ? current.sshProfileId : ''),
307
- sshTarget: body.sshTarget !== undefined ? String(body.sshTarget).trim() : (current ? current.sshTarget : ''),
308
- sshPort: body.sshPort !== undefined ? Number(body.sshPort) || 0 : (current ? current.sshPort : 0),
309
- sshKeyPath: body.sshKeyPath !== undefined ? String(body.sshKeyPath).trim() : (current ? current.sshKeyPath : ''),
310
- dockerImage: body.dockerImage !== undefined ? String(body.dockerImage).trim() : (current ? current.dockerImage : ''),
311
- pythonPath: body.pythonPath !== undefined ? String(body.pythonPath).trim() : (current ? current.pythonPath : ''),
312
- nodePath: body.nodePath !== undefined ? String(body.nodePath).trim() : (current ? current.nodePath : ''),
313
- skillName: body.skillName !== undefined ? String(body.skillName).trim() : (current ? current.skillName : ''),
314
- workflowName: body.workflowName !== undefined ? String(body.workflowName).trim() : (current ? current.workflowName : ''),
315
- oneShot: Boolean(parsed.isOneShot || body.oneShot),
316
- });
317
- if (task.status === 'active') {
318
- scheduler.scheduleTask(task);
319
- } else {
320
- scheduler.pauseTask(task.id);
321
- }
322
- sendJson(res, 200, { ok: true, task });
323
- return;
324
- }
325
- sendJson(res, 405, { ok: false, error: 'Method not allowed' });
326
- return;
327
- }
328
-
329
- // Item level: /dsh-cron/{tasks|action}/:id[/:action]
330
- if (req.method !== 'GET' && rejectCrossOrigin(req, res)) return;
331
-
332
- if (req.method === 'GET' && (action === 'history' || !action)) {
333
- const limit = parseInt(url.searchParams.get('limit') || '20', 10);
334
- sendJson(res, 200, { ok: true, history: store.getHistory(id, limit) });
335
- return;
336
- }
337
-
338
- if (req.method === 'POST' && action === 'run') {
339
- if (!store.get(id)) {
340
- sendJson(res, 404, { ok: false, error: 'Task not found' });
341
- return;
342
- }
343
- await scheduler.triggerManualRun(id);
344
- sendJson(res, 200, { ok: true, task: store.get(id) });
345
- return;
346
- }
347
-
348
- if (req.method === 'POST' && action === 'pause') {
349
- const task = scheduler.pauseTask(id);
350
- if (!task) {
351
- sendJson(res, 404, { ok: false, error: 'Task not found' });
352
- return;
353
- }
354
- sendJson(res, 200, { ok: true, task });
355
- return;
356
- }
357
-
358
- if (req.method === 'POST' && action === 'resume') {
359
- const task = scheduler.resumeTask(id);
360
- if (!task) {
361
- sendJson(res, 404, { ok: false, error: 'Task not found' });
362
- return;
363
- }
364
- sendJson(res, 200, { ok: true, task });
365
- return;
366
- }
367
-
368
- if (req.method === 'POST' && (action === 'toggle' || (!action && url.searchParams.get('action') === 'toggle'))) {
369
- const current = store.get(id);
370
- if (!current) {
371
- sendJson(res, 404, { ok: false, error: 'Task not found' });
372
- return;
373
- }
374
- const task = scheduler.toggleTask(id);
375
- sendJson(res, 200, { ok: true, task });
376
- return;
377
- }
378
-
379
- if (req.method === 'PATCH') {
380
- const current = store.get(id);
381
- if (!current) {
382
- sendJson(res, 404, { ok: false, error: 'Task not found' });
383
- return;
384
- }
385
- const { body, error } = await readBody(req, res);
386
- if (error) return;
387
- // Whitelist patchable fields so stats/timestamps stay server-owned (#90)
388
- const patchData = pickPatchableFields(body, PATCHABLE_TASK_FIELDS);
389
- if (patchData.type !== undefined) {
390
- patchData.type = normalizeTaskType(patchData.type);
391
- const switchingToCode = CODE_EXECUTING_TYPES.includes(patchData.type)
392
- && !CODE_EXECUTING_TYPES.includes(current.type);
393
- if (switchingToCode && req.headers[SCRIPT_CONFIRM_HEADER] !== 'script') {
394
- sendJson(res, 403, { ok: false, error: `Switching a task to the ${patchData.type} type over HTTP requires the x-dsh-cron-confirm: script header` });
395
- return;
396
- }
397
- const typeError = validateTaskType(patchData.type, { ...current, ...patchData });
398
- if (typeError) {
399
- sendJson(res, 400, { ok: false, error: typeError });
400
- return;
401
- }
402
- }
403
- if (patchData.schedule) {
404
- const parsed = parseScheduleExpression(patchData.schedule);
405
- patchData.schedule = parsed.cronPattern || patchData.schedule;
406
- patchData.scheduleText = body.scheduleText || parsed.humanText;
407
- patchData.oneShot = Boolean(parsed.isOneShot || body.oneShot);
408
- }
409
- const task = store.set({ ...current, ...patchData, id });
410
- if (task.status === 'active') {
411
- scheduler.scheduleTask(task);
412
- } else {
413
- scheduler.pauseTask(id);
414
- }
415
- sendJson(res, 200, { ok: true, task });
416
- return;
417
- }
418
-
419
- if (req.method === 'DELETE' || (req.method === 'POST' && action === 'delete')) {
420
- const current = store.get(id);
421
- if (current) {
422
- scheduler.pauseTask(id);
423
- store.delete(id);
424
- }
425
- sendJson(res, 200, { ok: true });
426
- return;
427
- }
428
-
429
- sendJson(res, 405, { ok: false, error: 'Method not allowed' });
430
- } catch (err) {
431
- sendJson(res, err.statusCode || 500, { ok: false, error: err.message });
159
+ // #115: normalise the delivery deadline here, before it reaches the settings
160
+ // scope, so a value like 1 ms can never be persisted and then fail every
161
+ // delivery before the request is even sent.
162
+ if ('deliveryTimeoutMs' in payload) {
163
+ const raw = payload.deliveryTimeoutMs;
164
+ const n = Number(raw);
165
+ if (raw === '' || raw === null || !Number.isFinite(n) || n <= 0) {
166
+ delete payload.deliveryTimeoutMs;
167
+ } else {
168
+ payload.deliveryTimeoutMs = resolveDeliveryTimeoutMs(n);
432
169
  }
433
- };
170
+ }
171
+ return payload;
434
172
  }
435
173
 
436
- /**
437
- * Single implementation behind the cron_create_task / cron_schedule_task
438
- * alias pair (#70, #92). Exported for unit tests.
439
- */
440
174
  export function executeCreateTask(store, scheduler, args) {
441
175
  const taskType = normalizeTaskType(args.type);
442
176
  const typeError = validateTaskType(taskType, args);
@@ -499,6 +233,10 @@ export const createTaskParameters = {
499
233
  type: { type: 'string', enum: TASK_TYPES, description: 'Task type: llm | script | node | python | http | ssh | docker | skill | workflow' },
500
234
  delivery: { type: 'string', enum: ['current', 'isolated'], description: 'Run mode: current (in the current chat) or isolated (separate session)' },
501
235
  provider: { type: 'string', description: 'Model provider (optional)' },
236
+ inspectOnFailure: { type: 'boolean', description: 'Diagnose failed runs with a model and store the diagnosis with the run (agent tasks)' },
237
+ silentRule: { type: 'string', description: 'Plain-language condition for staying silent on a successful run (script/node/python/http tasks)' },
238
+ fallbackModel: { type: 'string', description: 'Model tried once more when the primary model fails (llm/skill/workflow tasks)' },
239
+ fallbackProvider: { type: 'string', description: 'Provider for the fallback model (defaults to the task provider)' },
502
240
  model: { type: 'string', description: 'Model (optional)' },
503
241
  timezone: { type: 'string', description: 'IANA time zone for the schedule, e.g. "Europe/Berlin" (optional, default = server local)' },
504
242
  notifyTelegram: { type: 'boolean', description: 'Send the run report to Telegram' },
@@ -552,6 +290,7 @@ export function apply(ctx, config) {
552
290
  maxConcurrent: config && config.maxConcurrent,
553
291
  defaultTimezone: config && config.defaultTimezone,
554
292
  resolveSecrets: (settings) => resolveTelegramSecrets(ctx, settings),
293
+ askModel: makeAsk(ctx),
555
294
  deliver: (task, runInfo, settings) => deliverRun({
556
295
  task,
557
296
  runInfo,
@@ -609,35 +348,8 @@ export function apply(ctx, config) {
609
348
  };
610
349
  }, 'dsh-cron: lifecycle');
611
350
 
612
- const recommendations = [
613
- {
614
- id: 'rec_daily_digest',
615
- title: 'Daily digest',
616
- schedule: '0 8 * * 1-5',
617
- scheduleText: 'Weekdays at 08:00',
618
- prompt: 'Prepare a brief morning digest: review the list of active tasks, open Gitea tickets and outline the key priorities for today.',
619
- description: 'Start every weekday with a summary of calendar, unread mail and priorities',
620
- icon: 'bell'
621
- },
622
- {
623
- id: 'rec_weekly_review',
624
- title: 'Weekly review',
625
- schedule: '0 16 * * 5',
626
- scheduleText: 'Fridays at 16:00',
627
- prompt: 'Prepare a weekly report on the work done: the list of closed tasks, created PRs and the current status of ongoing projects.',
628
- description: 'Every Friday create a short report on the completed work',
629
- icon: 'clipboard'
630
- },
631
- {
632
- id: 'rec_monitor_status',
633
- title: 'Follow-up monitor',
634
- schedule: '0 9 * * 1-5',
635
- scheduleText: 'Weekdays at 09:00',
636
- prompt: 'Review recent project activity, build statuses and flag everything that requires developer attention.',
637
- description: 'Review recent project activity and flag anything that needs your attention',
638
- icon: 'activity'
639
- }
640
- ];
351
+ // #48: the recipe hub is the single source of the panel's suggestions.
352
+ const recommendations = recipeRecommendations();
641
353
 
642
354
  // 1. REST API — collection (list/create) + item actions in one prefix
643
355
  // handler per route family; registered WITHOUT the trailing slash (#96)
@@ -704,6 +416,25 @@ export function apply(ctx, config) {
704
416
  }
705
417
  }), 'dsh-cron: /settings');
706
418
 
419
+ // GET /dsh-cron/recipes (#48): the built-in recipe catalog.
420
+ ctx.effect(() => ctx.webServer.register({
421
+ kind: 'exact',
422
+ path: '/dsh-cron/recipes',
423
+ handler: async (req, res) => {
424
+ try {
425
+ if (req.method !== 'GET') {
426
+ sendJson(res, 405, { ok: false, error: 'Method not allowed' });
427
+ return;
428
+ }
429
+ const categories = recipesByCategory();
430
+ const recipes = categories.reduce((acc, category) => acc.concat(category.recipes), []);
431
+ sendJson(res, 200, { ok: true, categories, recipes });
432
+ } catch (err) {
433
+ sendJson(res, err.statusCode || 500, { ok: false, error: err.message });
434
+ }
435
+ }
436
+ }), 'dsh-cron: /recipes');
437
+
707
438
  // GET /dsh-cron/heartbeat (#16): liveness probe for external monitors.
708
439
  ctx.effect(() => ctx.webServer.register({
709
440
  kind: 'exact',
@@ -918,6 +649,87 @@ export function apply(ctx, config) {
918
649
  }
919
650
  }));
920
651
 
652
+ ctx.tools.register(defineTool({
653
+ name: 'cron_get_task',
654
+ description: 'Read the full configuration of one scheduled task, including fields that are not visible in the task list',
655
+ parameters: {
656
+ id: { type: 'string', description: 'Task identifier', required: true }
657
+ },
658
+ output: {
659
+ schema: {
660
+ type: 'object',
661
+ additionalProperties: true,
662
+ properties: {
663
+ success: { type: 'boolean' },
664
+ message: { type: 'string' },
665
+ task: { type: 'object', additionalProperties: true }
666
+ }
667
+ },
668
+ render: (_args, val) => [{ type: 'text', text: val.message }]
669
+ },
670
+ execute: async (args) => {
671
+ const task = store.get(args.id);
672
+ if (!task) return { success: false, message: 'Task not found' };
673
+ const summary = [
674
+ `Task "${task.title}" (${task.id})`,
675
+ `type: ${task.type || 'llm'}`,
676
+ `schedule: ${task.scheduleText || task.schedule}`,
677
+ `status: ${task.status}`,
678
+ task.type === 'llm' || !task.type ? `model: ${task.model || '(default)'}${task.fallbackModel ? ` (fallback: ${task.fallbackModel})` : ''}` : null,
679
+ `channels: ${Array.isArray(task.channels) && task.channels.length ? task.channels.join(', ') : '(legacy flags)'}`,
680
+ `prompt: ${String(task.prompt || '').slice(0, 500)}`,
681
+ ].filter(Boolean).join(String.fromCharCode(10));
682
+ return { success: true, message: summary, task };
683
+ }
684
+ }));
685
+
686
+ ctx.tools.register(defineTool({
687
+ name: 'cron_update_task',
688
+ description: 'Change an existing scheduled task. Ask the user what to change first. Switching a task to a code-executing type (script/node/python/ssh/docker) is refused unless confirmCodeSwitch is set, which requires the user to have agreed explicitly. Only the documented task fields can be changed.',
689
+ parameters: {
690
+ id: { type: 'string', description: 'Task identifier', required: true },
691
+ confirmCodeSwitch: { type: 'boolean', description: 'Set to true only after the user explicitly agreed to switch this task into a type that executes code' },
692
+ title: { type: 'string', description: 'New title' },
693
+ schedule: { type: 'string', description: 'New schedule (cron expression or interval)' },
694
+ prompt: { type: 'string', description: 'New prompt or command' },
695
+ type: { type: 'string', enum: TASK_TYPES, description: 'New execution type' },
696
+ model: { type: 'string', description: 'New model for agent tasks' },
697
+ fallbackModel: { type: 'string', description: 'New fallback model' },
698
+ provider: { type: 'string', description: 'New provider' },
699
+ channels: { type: 'array', items: { type: 'string', enum: CHANNEL_IDS }, description: 'New delivery channels' },
700
+ template: { type: 'string', description: 'New message template' },
701
+ onlyOnFailure: { type: 'boolean', description: 'Deliver only on failures' },
702
+ timeoutSeconds: { type: 'number', description: 'New execution timeout in seconds' },
703
+ overlapPolicy: { type: 'string', enum: ['skip', 'queue', 'replace'], description: 'New overlap policy' },
704
+ maxRetries: { type: 'number', description: 'New retry count' },
705
+ kanbanMode: { type: 'string', enum: ['none', 'on_failure', 'always'], description: 'New Kanban policy' },
706
+ timezone: { type: 'string', description: 'New IANA time zone' },
707
+ cwd: { type: 'string', description: 'New working directory' },
708
+ env: { type: 'object', additionalProperties: true, description: 'New environment variables (KEY: value)' }
709
+ },
710
+ output: {
711
+ schema: {
712
+ type: 'object',
713
+ additionalProperties: true,
714
+ properties: {
715
+ success: { type: 'boolean' },
716
+ message: { type: 'string' }
717
+ }
718
+ },
719
+ render: (_args, val) => [{ type: 'text', text: val.message }]
720
+ },
721
+ execute: async (args) => {
722
+ const { id, confirmCodeSwitch, ...patch } = args || {};
723
+ if (!id) return { success: false, message: 'A task id is required' };
724
+ const result = applyTaskPatch({ store, scheduler, id, body: patch, allowCodeSwitch: confirmCodeSwitch === true });
725
+ if (!result.ok) return { success: false, message: result.error };
726
+ return {
727
+ success: true,
728
+ message: `Task "${result.task.title}" updated (${result.task.scheduleText || result.task.schedule}). Changed: ${describeTaskPatch(result.task, result.patch)}`,
729
+ };
730
+ }
731
+ }));
732
+
921
733
  ctx.tools.register(defineTool({
922
734
  name: 'cron_run_task',
923
735
  description: 'Trigger a task immediately, out of band',