@goodandready/dsh-cron 0.2.18 → 0.2.20

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
@@ -69,188 +69,26 @@ import { applyConfigSync, isConfigOwned, configOwnedMessage } from './config-job
69
69
  import { createExternalApiHandler, EXTERNAL_API_PREFIX } from './external-api.js';
70
70
  export { createCronApiHandler };
71
71
 
72
- export const name = '@goodandready/dsh-cron';
73
- export const inject = ['tools', 'webServer', 'settings', 'llm', 'agents', 'agentDefaultModel', 'credentials'];
74
-
75
- export const Config = z.object({
76
- botToken: z.string().role('secret').default('').description('Legacy Telegram Bot API token (prefer botTokenRef with the DSH credentials service)'),
77
- botTokenRef: z.string().default('').description('Name of the DSH credential that holds the Telegram bot token'),
78
- chatId: z.string().default('').description('Telegram chat ID that receives task reports'),
79
- notifyTelegram: z.boolean().default(false).description('Deliver task reports to Telegram for every task'),
80
- onlyOnFailure: z.boolean().default(false).description('Deliver reports only for failed runs'),
81
- kanbanBaseUrl: z.string().default('http://127.0.0.1:3000').description('Base URL of the dsh-kanban HTTP API'),
82
- defaultTimezone: z.string().default('').description('Default IANA time zone for schedules (empty = server local)'),
83
- maxConcurrent: z.number().default(0).description('Max parallel task runs (0 = unlimited)'),
84
- heartbeatUrl: z.string().default('').description('Dead man\'s snitch URL pinged on the heartbeat interval'),
85
- heartbeatIntervalSec: z.number().default(0).description('Heartbeat ping interval in seconds (0 = off)'),
86
- // --- Delivery channels (#20-#23, #26, #28, #47) ---
87
- template: z.string().default('').description('Global notification template; placeholders: {title} {status} {output} {error} {duration} {schedule} {time} {tokens} {cost}'),
88
- channelTemplates: z.object({}).default({}).description('Per-channel template overrides keyed by channel id'),
89
- silentRuleModel: z.string().default('').description('Model used to evaluate a task silent rule (empty = the task model)'),
90
- inspectorModel: z.string().default('').description('Model used to diagnose failed runs (empty = the task model)'),
91
- 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'),
92
- discordWebhookUrl: z.string().default('').description('Discord webhook URL'),
93
- slackWebhookUrl: z.string().default('').description('Slack incoming webhook URL'),
94
- ntfyUrl: z.string().default('https://ntfy.sh').description('ntfy server base URL'),
95
- ntfyTopic: z.string().default('').description('ntfy topic for push notifications'),
96
- ntfyTokenRef: z.string().default('').description('Credential name for the ntfy access token'),
97
- barkServerUrl: z.string().default('https://api.day.app').description('Bark server base URL'),
98
- barkKey: z.string().default('').description('Bark device key'),
99
- pushplusTokenRef: z.string().default('').description('Credential name for the PushPlus token'),
100
- pushplusUrl: z.string().default('https://www.pushplus.plus/send').description('PushPlus send endpoint (override for a self-hosted proxy)'),
101
- ttsBaseUrl: z.string().default('http://127.0.0.1:3080').description('Base URL of the harness used to reach the dsh-tts speak route'),
102
- giteaBaseUrl: z.string().default('').description('Gitea base URL for alert issues'),
103
- giteaRepo: z.string().default('').description('Gitea repository (owner/name) for alert issues'),
104
- giteaTokenRef: z.string().default('').description('Credential name for the Gitea API token'),
105
- prReviewerEnabled: z.boolean().default(false).description('Enable automated PR Reviewer scheduled agent (#33)'),
106
- // --- Declarative jobs (#50, ADR-0001) ---
107
- // The array is intentionally permissive: each entry is validated on its own
108
- // with a precise message (see lib/config-jobs.js), so one broken entry cannot
109
- // reject the whole profile config. Declared jobs are owned by the config:
110
- // they are created or updated at startup, and removed when they disappear
111
- // from the file. Tasks created in the UI, over the API or by an agent tool
112
- // are never touched.
113
- jobs: z.array(z.any()).default([]).description('Static jobs owned by the config; each entry needs id, title, schedule and (for agent types) prompt'),
114
- // --- External REST API (#54, ADR-0001) ---
115
- apiToken: z.string().role('secret').default('').description('Bearer token for the external /dsh-cron/api/* surface (empty = the surface answers 503)'),
116
- });
117
-
118
-
119
- export const SETTINGS_SYNC_KEYS = [
120
- 'botToken', 'botTokenRef', 'chatId', 'notifyTelegram', 'onlyOnFailure', 'kanbanBaseUrl',
121
- 'template', 'channelTemplates', 'deliveryTimeoutMs', 'silentRuleModel', 'inspectorModel',
122
- 'discordWebhookUrl', 'slackWebhookUrl',
123
- 'ntfyUrl', 'ntfyTopic', 'ntfyTokenRef',
124
- 'barkServerUrl', 'barkKey',
125
- 'pushplusTokenRef',
126
- 'pushplusUrl',
127
- 'ttsBaseUrl',
128
- 'giteaBaseUrl', 'giteaRepo', 'giteaTokenRef', 'prReviewerEnabled',
129
- 'apiToken',
130
- ];
72
+ import {
73
+ Config,
74
+ SETTINGS_SYNC_KEYS,
75
+ applySettingsToScope,
76
+ sanitizeSettingsPayload,
77
+ } from './settings.js';
78
+ import { registerRoutes } from './routes.js';
131
79
 
132
- /**
133
- * Apply settings writes through the registered settings scope so the core
134
- * snapshot stays authoritative (#102). Returns null when the settings
135
- * service is unavailable (headless profiles fall back to store-only writes).
136
- */
137
- export async function applySettingsToScope(settingsScope, body) {
138
- if (!settingsScope || typeof settingsScope.set !== 'function') return null;
139
- const errors = [];
140
- for (const key of SETTINGS_SYNC_KEYS) {
141
- if (!Object.prototype.hasOwnProperty.call(body, key)) continue;
142
- try {
143
- await settingsScope.set(key, body[key]);
144
- } catch (e) {
145
- errors.push(key + ': ' + ((e && e.message) || e));
146
- }
147
- }
148
- return errors;
149
- }
80
+ export {
81
+ Config,
82
+ SETTINGS_SYNC_KEYS,
83
+ applySettingsToScope,
84
+ sanitizeSettingsPayload,
85
+ };
150
86
 
151
- /**
152
- * Drop values that must never re-enter the store from the client:
153
- *
154
- * - a masked secret echoed back by the settings UI would overwrite the real
155
- * value (bot token, webhook URLs, Bark key);
156
- * - the raw credential keys are refused outright — the *Ref fields are the
157
- * supported way to configure them.
158
- *
159
- * Applied before both the settings scope and the store, because the scope is
160
- * the source of truth and would otherwise persist a raw or masked value.
161
- */
162
- export function sanitizeSettingsPayload(body) {
163
- const payload = Object.assign({}, body);
164
- for (const key of TaskStore.MASKED_SETTING_KEYS) {
165
- const val = payload[key];
166
- if (typeof val === 'string' && (val.includes('••') || val.includes('****'))) {
167
- delete payload[key];
168
- }
169
- }
170
- for (const key of ['botTokenRef', 'barkKeyRef', 'giteaTokenRef']) {
171
- if (key in payload && typeof payload[key] === 'string' && payload[key].trim()) {
172
- const refCheck = credentialRefStatus(payload[key]);
173
- if (!refCheck.ok) {
174
- console.warn(`[dsh-cron] invalid credential reference in "${key}": ${refCheck.error}`);
175
- delete payload[key];
176
- }
177
- }
178
- }
179
- for (const key of TaskStore.FORBIDDEN_SETTING_KEYS) {
180
- if (key in payload) {
181
- console.warn(`[dsh-cron] refusing raw secret "${key}" from the client — use a credential reference`);
182
- delete payload[key];
183
- }
184
- }
185
- // #115: normalise the delivery deadline here, before it reaches the settings
186
- // scope, so a value like 1 ms can never be persisted and then fail every
187
- // delivery before the request is even sent.
188
- if ('deliveryTimeoutMs' in payload) {
189
- const raw = payload.deliveryTimeoutMs;
190
- const n = Number(raw);
191
- if (raw === '' || raw === null || !Number.isFinite(n) || n <= 0) {
192
- delete payload.deliveryTimeoutMs;
193
- } else {
194
- payload.deliveryTimeoutMs = resolveDeliveryTimeoutMs(n);
195
- }
196
- }
197
- return payload;
198
- }
87
+ export const name = '@goodandready/dsh-cron';
88
+ export const inject = ['tools', 'webServer', 'settings', 'llm', 'agents', 'agentDefaultModel', 'credentials'];
199
89
 
200
- export function executeCreateTask(store, scheduler, args) {
201
- const taskType = normalizeTaskType(args.type);
202
- const typeError = validateTaskType(taskType, args);
203
- if (typeError) throw new Error(typeError);
204
- const parsed = parseScheduleExpression(args.schedule);
205
- const task = store.set({
206
- title: args.title,
207
- schedule: parsed.cronPattern || args.schedule,
208
- scheduleText: parsed.humanText,
209
- prompt: args.prompt,
210
- type: taskType,
211
- delivery: args.delivery || 'isolated',
212
- status: 'active',
213
- provider: args.provider,
214
- model: args.model,
215
- notifyTelegram: Boolean(args.notifyTelegram),
216
- onlyOnFailure: Boolean(args.onlyOnFailure),
217
- timeoutSeconds: Number(args.timeoutSeconds) || 1800,
218
- overlapPolicy: args.overlapPolicy || 'skip',
219
- timezone: args.timezone ? String(args.timezone).trim() : '',
220
- misfirePolicy: ['skip', 'runOnce', 'catchUpAll'].includes(args.misfirePolicy) ? args.misfirePolicy : 'skip',
221
- maxRetries: Number(args.maxRetries) > 0 ? Number(args.maxRetries) : 0,
222
- retryBackoffMs: Number(args.retryBackoffMs) > 0 ? Number(args.retryBackoffMs) : 30000,
223
- permissionPreset: ['default', 'read-only', 'workspace-write', 'full'].includes(args.permissionPreset) ? args.permissionPreset : 'default',
224
- kanbanMode: args.kanbanMode || 'none',
225
- channels: Array.isArray(args.channels) ? args.channels.filter((c) => CHANNEL_IDS.includes(c)) : undefined,
226
- template: args.template ? String(args.template) : '',
227
- env: args.env && typeof args.env === 'object' ? args.env : undefined,
228
- cwd: args.cwd ? String(args.cwd).trim() : '',
229
- workspaceId: args.workspaceId ? String(args.workspaceId).trim() : '',
230
- worktree: Boolean(args.worktree),
231
- keepWorktree: Boolean(args.keepWorktree),
232
- httpMethod: args.httpMethod ? String(args.httpMethod).toUpperCase() : 'GET',
233
- httpUrl: args.httpUrl ? String(args.httpUrl).trim() : '',
234
- httpHeaders: args.httpHeaders,
235
- httpBody: args.httpBody !== undefined ? String(args.httpBody) : '',
236
- sshProfileId: args.sshProfileId ? String(args.sshProfileId).trim() : '',
237
- sshTarget: args.sshTarget ? String(args.sshTarget).trim() : '',
238
- sshPort: Number(args.sshPort) || 0,
239
- sshKeyPath: args.sshKeyPath ? String(args.sshKeyPath).trim() : '',
240
- dockerImage: args.dockerImage ? String(args.dockerImage).trim() : '',
241
- pythonPath: args.pythonPath ? String(args.pythonPath).trim() : '',
242
- nodePath: args.nodePath ? String(args.nodePath).trim() : '',
243
- skillName: args.skillName ? String(args.skillName).trim() : '',
244
- workflowName: args.workflowName ? String(args.workflowName).trim() : '',
245
- oneShot: Boolean(parsed.isOneShot),
246
- });
247
- scheduler.scheduleTask(task);
248
- return {
249
- success: true,
250
- message: `Task "${task.title}" scheduled successfully (${task.scheduleText})`,
251
- task
252
- };
253
- }
90
+ import { executeCreateTask } from './task-create.js';
91
+ export { executeCreateTask };
254
92
 
255
93
  export const createTaskParameters = {
256
94
  title: { type: 'string', description: 'Short task name', required: true },
@@ -397,79 +235,15 @@ export function apply(ctx, config) {
397
235
  }), 'dsh-cron: /api/dsh-cron/update');
398
236
 
399
237
  // 1. REST API — collection (list/create) + item actions in one prefix
400
- // handler per route family; registered WITHOUT the trailing slash (#96)
401
- ctx.effect(() => ctx.webServer.register({
402
- kind: 'prefix',
403
- path: '/dsh-cron/tasks',
404
- handler: createCronApiHandler(store, scheduler, { recommendations })
405
- }), 'dsh-cron: /tasks[...]');
406
-
407
- // Alias route /dsh-cron/action/:id/:action for backward compatibility (#66)
408
- ctx.effect(() => ctx.webServer.register({
409
- kind: 'prefix',
410
- path: '/dsh-cron/action',
411
- handler: createCronApiHandler(store, scheduler, null)
412
- }), 'dsh-cron: /action/:id/:action');
413
-
414
- ctx.effect(() => ctx.webServer.register({
415
- kind: 'prefix',
416
- path: '/dsh-cron/telegram',
417
- handler: createCronApiHandler(store, scheduler, null)
418
- }), 'dsh-cron: /telegram[...]');
419
-
420
- ctx.effect(() => ctx.webServer.register({
421
- kind: 'prefix',
422
- path: '/dsh-cron/heartbeat-ping',
423
- handler: createCronApiHandler(store, scheduler, null)
424
- }), 'dsh-cron: /heartbeat-ping[...]');
425
-
426
- ctx.effect(() => ctx.webServer.register({
427
- kind: 'prefix',
428
- path: '/dsh-cron/schedule',
429
- handler: createCronApiHandler(store, scheduler, null)
430
- }), 'dsh-cron: /schedule[...]');
431
-
432
- // GET /dsh-cron/metrics (#53) — Prometheus text exposition
433
- ctx.effect(() => ctx.webServer.register({
434
- kind: 'exact',
435
- path: '/dsh-cron/metrics',
436
- handler: createMetricsHandler({ store, scheduler })
437
- }), 'dsh-cron: /metrics');
438
-
439
- // External REST API under /dsh-cron/api/* (#54, ADR-0001): the only surface
440
- // guarded by a bearer token, so CI and host automation can drive the
441
- // scheduler without a browser session. The token is read per request, so a
442
- // settings change applies without a restart.
443
- ctx.effect(() => ctx.webServer.register({
444
- kind: 'prefix',
445
- path: EXTERNAL_API_PREFIX,
446
- handler: createExternalApiHandler({
447
- store,
448
- scheduler,
449
- getToken: () => store.getSettings().apiToken || ''
450
- })
451
- }), 'dsh-cron: /api[...]');
452
-
453
- // GET /dsh-cron/models
454
- ctx.effect(() => ctx.webServer.register({
455
- kind: 'exact',
456
- path: '/dsh-cron/models',
457
- handler: async (req, res) => {
458
- await getModelsHandler(ctx, req, res, sendJson);
459
- }
460
- }), 'dsh-cron: /models');
461
-
462
- // POST /dsh-cron/chat/start
463
- ctx.effect(() => ctx.webServer.register({
464
- kind: 'exact',
465
- path: '/dsh-cron/chat/start',
466
- handler: async (req, res) => {
467
- if (req.method === 'POST' && rejectCrossOrigin(req, res)) return;
468
- await chatStartHandler(ctx, req, res, parseJsonBody, sendJson, randomUUID);
469
- }
470
- }), 'dsh-cron: /chat/start');
238
+ // 1. Web server routes (extracted to ./routes.js)
239
+ registerRoutes(ctx, {
240
+ store,
241
+ scheduler,
242
+ getCronSettingsScope: () => cronSettingsScope,
243
+ recommendations,
244
+ });
471
245
 
472
- // GET & POST /dsh-cron/settings
246
+ // GET & POST /dsh-cron/settings (#102)
473
247
  ctx.effect(() => ctx.webServer.register({
474
248
  kind: 'exact',
475
249
  path: '/dsh-cron/settings',
@@ -483,8 +257,6 @@ export function apply(ctx, config) {
483
257
  if (rejectCrossOrigin(req, res)) return;
484
258
  const { body, error } = await readBody(req, res);
485
259
  if (error) return;
486
- // Masked secrets echoed back by the client must never overwrite the
487
- // stored token (store.saveSettings guards this too, #86-era).
488
260
  const payload = sanitizeSettingsPayload(body);
489
261
  const scopeErrors = await applySettingsToScope(cronSettingsScope, payload);
490
262
  if (scopeErrors === null) {
@@ -500,114 +272,6 @@ export function apply(ctx, config) {
500
272
  }
501
273
  }), 'dsh-cron: /settings');
502
274
 
503
- // GET /dsh-cron/recipes (#48): the built-in recipe catalog.
504
- ctx.effect(() => ctx.webServer.register({
505
- kind: 'exact',
506
- path: '/dsh-cron/recipes',
507
- handler: async (req, res) => {
508
- try {
509
- if (req.method !== 'GET') {
510
- sendJson(res, 405, { ok: false, error: 'Method not allowed' });
511
- return;
512
- }
513
- const categories = recipesByCategory();
514
- const recipes = categories.reduce((acc, category) => acc.concat(category.recipes), []);
515
- sendJson(res, 200, { ok: true, categories, recipes });
516
- } catch (err) {
517
- sendJson(res, err.statusCode || 500, { ok: false, error: err.message });
518
- }
519
- }
520
- }), 'dsh-cron: /recipes');
521
-
522
- // GET /dsh-cron/heartbeat (#16): liveness probe for external monitors.
523
- ctx.effect(() => ctx.webServer.register({
524
- kind: 'exact',
525
- path: '/dsh-cron/heartbeat',
526
- handler: async (req, res) => {
527
- try {
528
- const tasks = store.list({ status: 'active' });
529
- const lastRuns = tasks.map((t) => t.lastRunAt || 0);
530
- sendJson(res, 200, {
531
- ok: true,
532
- activeTasks: tasks.length,
533
- running: scheduler.runningCount(),
534
- lastRunAt: lastRuns.length ? Math.max(...lastRuns) : null,
535
- serverTime: Date.now(),
536
- });
537
- } catch (err) {
538
- sendJson(res, 500, { ok: false, error: err.message });
539
- }
540
- }
541
- }), 'dsh-cron: /heartbeat');
542
-
543
- // POST /dsh-cron/telegram/test
544
- ctx.effect(() => ctx.webServer.register({
545
- kind: 'exact',
546
- path: '/dsh-cron/telegram/test',
547
- handler: async (req, res) => {
548
- try {
549
- if (req.method !== 'POST') {
550
- sendJson(res, 405, { ok: false, error: 'Method not allowed' });
551
- return;
552
- }
553
- if (rejectCrossOrigin(req, res)) return;
554
- const { body, error } = await readBody(req, res);
555
- if (error) return;
556
- const settings = store.getSettings();
557
- const botToken = body.botToken || settings.botToken;
558
- const chatId = body.chatId || settings.chatId;
559
-
560
- if (!botToken || !chatId) {
561
- sendJson(res, 400, { ok: false, error: 'botToken or chatId is not configured' });
562
- return;
563
- }
564
-
565
- const text = `🔔 *dsh-cron test notification*\n\nConnection between DSH Cron and Telegram is working! Checked at: \`${new Date().toISOString()}\``;
566
- const replyMarkup = createTelegramTaskKeyboard(task, runInfo);
567
- await sendTelegramMessage({ botToken, chatId, text, replyMarkup });
568
- sendJson(res, 200, { ok: true, message: 'Test message sent successfully' });
569
- } catch (err) {
570
- sendJson(res, 500, { ok: false, error: err.message });
571
- }
572
- }
573
- }), 'dsh-cron: /telegram/test');
574
-
575
- // POST /dsh-cron/kanban/test
576
- ctx.effect(() => ctx.webServer.register({
577
- kind: 'exact',
578
- path: '/dsh-cron/kanban/test',
579
- handler: async (req, res) => {
580
- try {
581
- if (req.method !== 'POST') {
582
- sendJson(res, 405, { ok: false, error: 'Method not allowed' });
583
- return;
584
- }
585
- if (rejectCrossOrigin(req, res)) return;
586
- const { body, error } = await readBody(req, res);
587
- if (error) return;
588
- const settings = store.getSettings();
589
- const kanbanBaseUrl = body.kanbanBaseUrl || settings.kanbanBaseUrl || 'http://127.0.0.1:3000';
590
-
591
- const result = await createKanbanCard({
592
- title: '[Test] dsh-cron -> Kanban connectivity check',
593
- body: `Test card created automatically by the \`dsh-cron\` module at ${new Date().toISOString()}.`,
594
- board: 'main',
595
- column: 'backlog',
596
- labels: ['cron', 'test'],
597
- kanbanBaseUrl
598
- });
599
-
600
- if (result.success) {
601
- sendJson(res, 200, { ok: true, task: result.task });
602
- } else {
603
- sendJson(res, 500, { ok: false, error: result.error });
604
- }
605
- } catch (err) {
606
- sendJson(res, 500, { ok: false, error: err.message });
607
- }
608
- }
609
- }), 'dsh-cron: /kanban/test');
610
-
611
275
  // 2. Tools
612
276
  ctx.tools.register(defineTool({
613
277
  name: 'cron_create_task',
@@ -1,3 +1,4 @@
1
+ import { bestEffort } from './best-effort.js';
1
2
  import fs from 'node:fs';
2
3
 
3
4
  /**
@@ -14,10 +15,10 @@ export function parseLlmActionDirectives(text) {
14
15
  while ((match = blockRegex.exec(text)) !== null) {
15
16
  const raw = match[1].trim();
16
17
  if ((raw.startsWith('{') && raw.endsWith('}')) || (raw.startsWith('[') && raw.endsWith(']'))) {
17
- try {
18
+ bestEffort('parse-directive-json', () => {
18
19
  const parsed = JSON.parse(raw);
19
20
  collectDirectives(parsed, directives);
20
- } catch {}
21
+ });
21
22
  }
22
23
  }
23
24
 
@@ -26,10 +27,10 @@ export function parseLlmActionDirectives(text) {
26
27
  const inlineMatch = /\{(?:[^{}]|(\{(?:[^{}]|\{[^{}]*\})*\}))*"dsh_action[s]?"(?:[^{}]|(\{(?:[^{}]|\{[^{}]*\})*\}))*\}/gi;
27
28
  let im;
28
29
  while ((im = inlineMatch.exec(text)) !== null) {
29
- try {
30
+ bestEffort('parse-inline-directive', () => {
30
31
  const parsed = JSON.parse(im[0]);
31
32
  collectDirectives(parsed, directives);
32
- } catch {}
33
+ });
33
34
  }
34
35
  }
35
36
 
@@ -116,7 +117,7 @@ export async function executeLlmActionDirectives({ directives, task, runInfo, sc
116
117
  async function tryCreateGiteaIssue({ title, body, repo = 'goodandready/dsh-cron', fetchFn }) {
117
118
  try {
118
119
  let token = process.env.GITEA_TOKEN;
119
- const credPath = '/mnt/external/Project/DEV/.gitea-agent-credentials.json';
120
+ const credPath = process.env.GITEA_CREDENTIALS_PATH || '';
120
121
  if (!token && fs.existsSync(credPath)) {
121
122
  const data = JSON.parse(fs.readFileSync(credPath, 'utf-8'));
122
123
  token = data?.agents?.antigravity?.token || data?.token;
@@ -136,6 +137,8 @@ async function tryCreateGiteaIssue({ title, body, repo = 'goodandready/dsh-cron'
136
137
  const json = await res.json().catch(() => ({}));
137
138
  return { number: json.number, url: json.html_url };
138
139
  }
139
- } catch {}
140
+ } catch (err) {
141
+ /* ignore Gitea creation errors, best-effort */
142
+ }
140
143
  return null;
141
144
  }
package/lib/llm-ask.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { bestEffort } from './best-effort.js';
1
2
  /**
2
3
  * Small helper for asking the configured model a short question (#44, #43).
3
4
  *
@@ -26,11 +27,11 @@ function getLlm(ctx) {
26
27
  /** Provider and model for an auxiliary question: task first, then the default. */
27
28
  export function resolveAskTarget(ctx, task = {}, preferredModel = '') {
28
29
  let defaultSelection = null;
29
- try {
30
- defaultSelection = ctx && typeof ctx.get === 'function'
30
+ defaultSelection = bestEffort('resolve-default-model', () => {
31
+ return ctx && typeof ctx.get === 'function'
31
32
  ? ctx.get('agentDefaultModel')?.currentSelection?.() || null
32
33
  : null;
33
- } catch {}
34
+ }) || null;
34
35
  return {
35
36
  provider: task.provider || defaultSelection?.provider || '',
36
37
  model: preferredModel || task.model || defaultSelection?.model || '',
@@ -60,12 +61,10 @@ function isCleanFinish(kind) {
60
61
  async function buildUserMessage(text) {
61
62
  const content = [{ type: 'text', text }];
62
63
  const source = { kind: 'plugin', plugin: 'dsh-cron', form: 'helper-call' };
63
- try {
64
- const mod = await import('@deepseek-ai/dsh-llm');
65
- if (mod && typeof mod.createUserMessage === 'function') {
66
- return mod.createUserMessage({ content, source });
67
- }
68
- } catch {}
64
+ const mod = await bestEffort('import-dsh-llm', () => import('@deepseek-ai/dsh-llm'));
65
+ if (mod && typeof mod.createUserMessage === 'function') {
66
+ return mod.createUserMessage({ content, source });
67
+ }
69
68
  return { role: 'user', content, source };
70
69
  }
71
70
 
package/lib/recipes.js CHANGED
@@ -207,7 +207,9 @@ export const RECIPES = [
207
207
 
208
208
  /** Detached copy of the catalog, safe to hand to the UI. */
209
209
  export function listRecipes() {
210
- return RECIPES.map((recipe) => ({ ...recipe, channels: [...(recipe.channels || [])] }));
210
+ const destructive = findDestructiveRecipe();
211
+ const safeRecipes = destructive ? RECIPES.filter((r) => r.id !== destructive.id) : RECIPES;
212
+ return safeRecipes.map((recipe) => ({ ...recipe, channels: [...(recipe.channels || [])] }));
211
213
  }
212
214
 
213
215
  export function recipesByCategory() {