@goodandready/dsh-cron 0.1.24 → 0.2.0

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.
@@ -57,7 +57,6 @@ export async function createKanbanCard({
57
57
  method: 'POST',
58
58
  headers: {
59
59
  'Content-Type': 'application/json',
60
- 'Sec-Fetch-Site': 'same-origin',
61
60
  },
62
61
  body: JSON.stringify({
63
62
  title,
package/lib/prompt.js CHANGED
@@ -1,54 +1,56 @@
1
1
  /**
2
2
  * Builds the initial prompt for starting a session with DSH agent to configure a cron task.
3
- * @param {string} userText
3
+ * English is the canonical source language; the conversation language follows
4
+ * the user's DSH locale via the translation plugin.
5
+ * @param {string} userText
4
6
  * @returns {string}
5
7
  */
6
8
  export function buildAgentCronPrompt(userText) {
7
9
  const text = (userText || '').trim();
8
- return `Пользователь хочет настроить запланированную задачу (cron) через DSH Cron.
9
- Исходный запрос пользователя:
10
+ return `The user wants to set up a scheduled (cron) task via DSH Cron.
11
+ The user's original request:
10
12
  "${text}"
11
13
 
12
- ВАЖНЕЙШИЕ ПРАВИЛА ДЛЯ АГЕНТА:
13
- 1. СТРОГИЙ ЗАПРЕТ НА СПЕШКУ:
14
- КАТЕГОРИЧЕСКИ ЗАПРЕЩЕНО вызывать инструмент \`cron_create_task\` (или \`cron_schedule_task\`) в первом же ответе!
15
- Ты НЕ имеешь права создавать задачу, пока не задашь пользователю уточняющие вопросы и не получишь его подтверждение.
14
+ CRITICAL RULES FOR THE AGENT:
15
+ 1. NEVER RUSH:
16
+ It is STRICTLY FORBIDDEN to call the \`cron_create_task\` (or \`cron_schedule_task\`) tool in your first reply!
17
+ You MUST NOT create the task until you have asked clarifying questions and received the user's confirmation.
16
18
 
17
- 2. ПЕРВЫЙ ВОПРОС: LLM vs NO-LLM Shell (КРИТИЧЕСКИ ВАЖНО):
18
- - Оцени характер задачи:
19
- * Если задача ТРИВИАЛЬНАЯ (проверка RSS/Atom фида, проверка кода ответа HTTP, curl/ping/git check, мониторинг файла или службы) — ТЫ ОБЯЗАН РЕКОМЕНДОВАТЬ NO-LLM Shell (type: "script")!
20
- Поясни пользователю: "Для этой задачи языковая модель избыточна — простой bash-скрипт с curl/jq отработает за миллисекунды, надежно и не потратит ни единого токена. Рекомендую оформить как NO-LLM Shell-скрипт."
21
- * Если задача требует СЕМАНТИЧЕСКОГО АНАЛИЗА (обобщение писем, генерация отчета из неструктурированного текста, принятие смысловых решений) — предложи LLM (type: "llm").
22
- - Спроси пользователя, согласен ли он с предложенным типом задачи.
19
+ 2. FIRST QUESTION: LLM vs NO-LLM Shell (critical):
20
+ - Evaluate the nature of the task:
21
+ * If the task is TRIVIAL (checking an RSS/Atom feed, checking an HTTP status code, curl/ping/git checks, monitoring a file or a service) — you MUST recommend the NO-LLM Shell variant (type: "script")!
22
+ Explain to the user: "A language model is overkill for this task — a simple bash script with curl/jq completes in milliseconds, is more reliable, and costs zero tokens. I recommend the NO-LLM Shell script variant."
23
+ * If the task requires SEMANTIC ANALYSIS (summarizing messages, generating a report from unstructured text, making judgement calls) — propose an LLM task (type: "llm").
24
+ - Ask the user whether they agree with the proposed task type.
23
25
 
24
- 3. ЧТО ТЫ ОБЯЗАН УТОЧНИТЬ:
25
- - Если выбран NO-LLM Shell:
26
- * Предложи готовый shell-скрипт или команду для выполнения.
27
- * Уточни, подходит ли логика скрипта.
28
- - Если выбран LLM:
29
- * Предложи экономичную модель (например, \`deepseek/deepseek-v4-flash\` или \`Qwen/Qwen3.8-Flash\`).
30
- * Спроси, подходит ли эта модель.
31
- - Точное расписание (schedule):
32
- * Предложи конкретное выражение cron (например, "0 */6 * * *" для 4 раз в день или "0 9 * * 1-5" по будням) и понятное текстовое пояснение.
33
- - Silent Rule (Правило тишины / Уведомления):
34
- * Для проверок и мониторингов действует жесткое Silent Rule: если новых событий/изменений нет — ПОЛНАЯ ТИШИНА, никаких сообщений в чат/Telegram не отправляется вообще (exit 0 в скрипте или пустой ответ). Оповещение уходит ТОЛЬКО при обнаружении новинок или сбое.
35
- * Подтверди пользователю, что правило тишины будет включено.
26
+ 3. WHAT YOU MUST CLARIFY:
27
+ - If NO-LLM Shell was chosen:
28
+ * Propose a ready-made shell script or command to execute.
29
+ * Confirm that the script logic suits the user.
30
+ - If LLM was chosen:
31
+ * Propose an economical model from those available in the user's DSH installation and explain why it fits this task.
32
+ * Ask whether this model works for the user.
33
+ - The exact schedule:
34
+ * Propose a concrete cron expression (for example, "0 */6 * * *" for 4 times a day, or "0 9 * * 1-5" for weekdays) and a plain-language explanation.
35
+ - Silent Rule (notifications):
36
+ * For checks and monitoring, apply the strict Silent Rule: when there are NO new events or changes — FULL SILENCE, no messages to chat/Telegram at all (exit 0 in scripts, or an empty reply). An alert is sent ONLY when something new is found or a failure occurs.
37
+ * Confirm to the user that the silent rule will be enabled.
36
38
 
37
- 4. СТРУКТУРА ПЕРВОГО ОТВЕТА:
38
- - Кратко подтверди суть задачи.
39
- - Сформулируй проект карточки:
40
- • Тип: [NO-LLM Shell (рекомендуется) / LLM]
41
- • Расписание: [cron-выражение и понятное описание]
42
- • Реализация / Скрипт / Модель: [код скрипта или предлагаемая модель]
43
- • Silent Rule: [только при наличии изменений / сбое]
44
- - Задай недостающие вопросы и попроси подтвердить создание задачи.
39
+ 4. STRUCTURE OF THE FIRST REPLY:
40
+ - Briefly confirm the essence of the task.
41
+ - Present the draft task card:
42
+ • Type: [NO-LLM Shell (recommended) / LLM]
43
+ • Schedule: [cron expression and a plain-language description]
44
+ • Implementation / Script / Model: [script code or the proposed model]
45
+ • Silent Rule: [only on new events / failures]
46
+ - Ask any remaining questions and ask the user to confirm task creation.
45
47
 
46
- 5. СОЗДАНИЕ ЗАДАЧИ:
47
- Только ПОСЛЕ ТОГО, как пользователь ответит и подтвердит параметры, вызови инструмент \`cron_create_task\`, передав:
48
- - title: понятное название задачи
49
- - schedule: согласованное cron-выражение
50
- - prompt: финальная shell-команда/путь к скрипту (для type="script") ИЛИ подробная инструкция с Silent Rule (для type="llm")
51
- - type: "script" или "llm"
52
- - provider и model: согласованный провайдер и модель (только если type="llm")
48
+ 5. CREATING THE TASK:
49
+ Only AFTER the user replies and confirms the parameters, call the \`cron_create_task\` tool with:
50
+ - title: a clear task name
51
+ - schedule: the agreed cron expression
52
+ - prompt: the final shell command/script path (for type="script") OR detailed instructions including the Silent Rule (for type="llm")
53
+ - type: "script" or "llm"
54
+ - provider and model: the agreed provider and model (only if type="llm")
53
55
  - delivery: "isolated"`;
54
56
  }
package/lib/runner.js CHANGED
@@ -45,7 +45,7 @@ export class SessionRunner {
45
45
 
46
46
  async _run(task, signal) {
47
47
  console.log(`[dsh-cron] executing task "${task.title}" (${task.id})`);
48
-
48
+
49
49
  // 1. Direct shell / script task fallback (NO-LLM)
50
50
  if (task.type === 'script' || task.mode === 'no-llm') {
51
51
  const { exec } = await import('node:child_process');
@@ -101,7 +101,25 @@ export class SessionRunner {
101
101
 
102
102
  const defaultSel = this.ctx.get?.('agentDefaultModel')?.currentSelection?.();
103
103
  const provider = task.provider || defaultSel?.provider;
104
- const model = task.model || defaultSel?.model || 'deepseek-v4-flash';
104
+ const model = task.model || defaultSel?.model || undefined;
105
+
106
+ // The agent session cannot take the AbortSignal natively, so any abort
107
+ // (timeout, overlap replace, pause, scheduler stop) races against the
108
+ // session awaits and forces an immediate dispose (#88).
109
+ const abortError = () => (signal && signal.reason) || new Error('Task aborted');
110
+ const raceAbort = (promise) => {
111
+ if (!signal) return promise;
112
+ return Promise.race([
113
+ promise,
114
+ new Promise((_, reject) => {
115
+ if (signal.aborted) {
116
+ reject(abortError());
117
+ return;
118
+ }
119
+ signal.addEventListener('abort', () => reject(abortError()), { once: true });
120
+ }),
121
+ ]);
122
+ };
105
123
 
106
124
  const cwd = process.cwd();
107
125
  let handle = null;
@@ -112,41 +130,37 @@ export class SessionRunner {
112
130
  agentOptions: provider && model ? { provider, model } : undefined,
113
131
  });
114
132
 
115
- if (signal && signal.aborted) {
116
- throw (signal.reason || new Error('Aborted'));
117
- }
133
+ await raceAbort(handle.agent.whenIdle());
134
+ handle.agent.followup(createUserMessage({
135
+ content: [{ type: 'text', text: task.prompt }],
136
+ source: { kind: 'plugin', plugin: 'dsh-cron', form: 'cron-execute' },
137
+ }));
118
138
 
119
- await handle.agent.whenIdle();
120
- handle.agent.followup(createUserMessage({
121
- content: [{ type: 'text', text: task.prompt }],
122
- source: { kind: 'plugin', plugin: 'dsh-cron', form: 'cron-execute' },
123
- }));
124
-
125
- // Await agent turn completion (#67)
126
- if (typeof handle.agent.whenIdle === 'function') {
127
- await handle.agent.whenIdle();
128
- }
129
-
130
- // Try extracting token usage from session history if available
131
- let usage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 };
132
- try {
133
- const sessionUsage = handle.agent.session?.usage || handle.agent.usage;
134
- if (sessionUsage) {
135
- usage = {
136
- inputTokens: sessionUsage.inputTokens || sessionUsage.promptTokens || 0,
137
- outputTokens: sessionUsage.outputTokens || sessionUsage.completionTokens || 0,
138
- cacheReadTokens: sessionUsage.cacheReadTokens || sessionUsage.cachedTokens || 0,
139
- };
139
+ // Await agent turn completion (#67), abortable on timeout/overlap (#88)
140
+ if (typeof handle.agent.whenIdle === 'function') {
141
+ await raceAbort(handle.agent.whenIdle());
140
142
  }
141
- } catch {}
142
143
 
143
- const costUsd = estimateTokenCost(model, usage);
144
+ // Try extracting token usage from session history if available
145
+ let usage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 };
146
+ try {
147
+ const sessionUsage = handle.agent.session?.usage || handle.agent.usage;
148
+ if (sessionUsage) {
149
+ usage = {
150
+ inputTokens: sessionUsage.inputTokens || sessionUsage.promptTokens || 0,
151
+ outputTokens: sessionUsage.outputTokens || sessionUsage.completionTokens || 0,
152
+ cacheReadTokens: sessionUsage.cacheReadTokens || sessionUsage.cachedTokens || 0,
153
+ };
154
+ }
155
+ } catch {}
144
156
 
145
- return {
146
- output: `[dsh-cron] Agent finished turn for task "${task.title}" (session: ${handle.agent.session?.id || 'cron'})`,
147
- usage,
148
- costUsd
149
- };
157
+ const costUsd = estimateTokenCost(model, usage);
158
+
159
+ return {
160
+ output: `[dsh-cron] Agent finished turn for task "${task.title}" (session: ${handle.agent.session?.id || 'cron'})`,
161
+ usage,
162
+ costUsd
163
+ };
150
164
  } finally {
151
165
  try {
152
166
  if (handle && typeof handle.dispose === 'function') {
@@ -171,7 +185,7 @@ export class SessionRunner {
171
185
  title: `[Cron] ${task.title}`,
172
186
  ephemeral: task.delivery === 'isolated',
173
187
  });
174
-
188
+
175
189
  if (session && typeof session.send === 'function') {
176
190
  const result = await session.send(task.prompt);
177
191
  const out = typeof result === 'string' ? result : JSON.stringify(result);
package/lib/scheduler.js CHANGED
@@ -10,15 +10,15 @@ export function describeCron(cronPattern) {
10
10
  const parts = cronPattern.trim().split(/\s+/);
11
11
  if (parts.length === 5) {
12
12
  const [m, h, dom, mon, dow] = parts;
13
- if (m.startsWith('*/')) return `Каждые ${m.slice(2)} минут`;
13
+ if (m.startsWith('*/')) return `Every ${m.slice(2)} minutes`;
14
14
  if (dow === '1-5' && dom === '*' && mon === '*') {
15
- return `В будние дни в ${pad(h)}:${pad(m)}`;
15
+ return `Weekdays at ${pad(h)}:${pad(m)}`;
16
16
  }
17
17
  if (dow === '*' && dom === '*' && mon === '*') {
18
- return `Каждый день в ${pad(h)}:${pad(m)}`;
18
+ return `Every day at ${pad(h)}:${pad(m)}`;
19
19
  }
20
20
  if (dow === '5' && dom === '*' && mon === '*') {
21
- return `Пятницы в ${pad(h)}:${pad(m)}`;
21
+ return `Fridays at ${pad(h)}:${pad(m)}`;
22
22
  }
23
23
  }
24
24
  return cronPattern;
@@ -26,7 +26,7 @@ export function describeCron(cronPattern) {
26
26
 
27
27
  export function parseScheduleExpression(input) {
28
28
  const str = String(input || '').trim();
29
- if (!str) throw new Error('Строка расписания не должна быть пустой');
29
+ if (!str) throw new Error('Schedule expression must not be empty');
30
30
 
31
31
  // 1. One-shot ISO 8601 timestamp or explicit "at: <ISO/datetime>"
32
32
  const atPrefixMatch = str.match(/^(?:at:\s*|at\s+)(.+)$/i);
@@ -38,34 +38,35 @@ export function parseScheduleExpression(input) {
38
38
  cronPattern: null,
39
39
  isOneShot: true,
40
40
  targetTimestamp: targetMs,
41
- humanText: `Разово: ${parsedDate.toLocaleString('ru-RU')}`,
41
+ humanText: `One-shot at ${parsedDate.toISOString()}`,
42
42
  nextRun: targetMs
43
43
  };
44
44
  }
45
45
 
46
46
  // 2. Relative one-shot: "in 20m", "in 2h", "in 30s", "in 1d", "через 15 минут"
47
+ // Russian keywords are accepted input aliases, not display strings.
47
48
  const inRelMatch = str.match(/^(?:in\s+|через\s+)(\d+)\s*(s|sec|seconds?|m|min|minutes?|h|hr|hours?|d|days?|мин|минут|часа?|часов)?$/i);
48
49
  if (inRelMatch) {
49
50
  const num = parseInt(inRelMatch[1], 10);
50
51
  const unit = (inRelMatch[2] || 'm').toLowerCase();
51
52
  let delayMs = num * 60 * 1000;
52
- let unitText = `${num} мин`;
53
+ let unitText = `${num} min`;
53
54
  if (unit.startsWith('s')) {
54
55
  delayMs = num * 1000;
55
- unitText = `${num} сек`;
56
+ unitText = `${num} sec`;
56
57
  } else if (unit.startsWith('h') || unit.startsWith('час')) {
57
58
  delayMs = num * 3600 * 1000;
58
- unitText = `${num} ч`;
59
+ unitText = `${num} h`;
59
60
  } else if (unit.startsWith('d')) {
60
61
  delayMs = num * 86400 * 1000;
61
- unitText = `${num} дн`;
62
+ unitText = `${num} d`;
62
63
  }
63
64
  const targetMs = Date.now() + delayMs;
64
65
  return {
65
66
  cronPattern: null,
66
67
  isOneShot: true,
67
68
  targetTimestamp: targetMs,
68
- humanText: `Разово через ${unitText} (${new Date(targetMs).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })})`,
69
+ humanText: `One-shot in ${unitText} (at ${new Date(targetMs).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })})`,
69
70
  nextRun: targetMs
70
71
  };
71
72
  }
@@ -78,36 +79,36 @@ export function parseScheduleExpression(input) {
78
79
  if (unit.startsWith('m')) {
79
80
  return {
80
81
  cronPattern: `*/${num} * * * *`,
81
- humanText: `Каждые ${num} минут`,
82
+ humanText: `Every ${num} minutes`,
82
83
  isInterval: true,
83
84
  };
84
85
  }
85
86
  if (unit.startsWith('h')) {
86
87
  return {
87
88
  cronPattern: `0 */${num} * * *`,
88
- humanText: `Каждые ${num} часов`,
89
+ humanText: `Every ${num} hours`,
89
90
  isInterval: true,
90
91
  };
91
92
  }
92
93
  if (unit.startsWith('d')) {
93
94
  return {
94
95
  cronPattern: `0 0 */${num} * *`,
95
- humanText: `Каждые ${num} дней`,
96
+ humanText: `Every ${num} days`,
96
97
  isInterval: true,
97
98
  };
98
99
  }
99
100
  }
100
101
 
101
- // 4. Friendly recurring aliases
102
+ // 4. Friendly recurring aliases (English plus accepted Russian input aliases)
102
103
  const lower = str.toLowerCase();
103
104
  if (lower === 'daily' || lower === 'каждый день') {
104
- return { cronPattern: '0 9 * * *', humanText: 'Каждый день в 09:00' };
105
+ return { cronPattern: '0 9 * * *', humanText: 'Every day at 09:00' };
105
106
  }
106
107
  if (lower === 'weekdays' || lower === 'в будние дни' || lower === 'по будням') {
107
- return { cronPattern: '0 9 * * 1-5', humanText: 'В будние дни в 09:00' };
108
+ return { cronPattern: '0 9 * * 1-5', humanText: 'Weekdays at 09:00' };
108
109
  }
109
110
  if (lower === 'hourly' || lower === 'каждый час') {
110
- return { cronPattern: '0 * * * *', humanText: 'Каждый час' };
111
+ return { cronPattern: '0 * * * *', humanText: 'Every hour' };
111
112
  }
112
113
 
113
114
  // 5. Standard 5-field cron expression
@@ -120,7 +121,7 @@ export function parseScheduleExpression(input) {
120
121
  nextRun: next ? next.getTime() : null,
121
122
  };
122
123
  } catch (err) {
123
- throw new Error('Некорректное расписание cron: ' + err.message);
124
+ throw new Error('Invalid cron schedule: ' + err.message);
124
125
  }
125
126
  }
126
127
 
@@ -146,7 +147,7 @@ export class TaskScheduler {
146
147
  at: missedAt,
147
148
  status: 'missed',
148
149
  durationMs: 0,
149
- output: `Запуск пропущен: сервис был выключен или перезагружался (плановый запуск: ${new Date(missedAt).toLocaleString('ru-RU')})`,
150
+ output: `Skipped: the service was offline or restarting at the scheduled time (${new Date(missedAt).toISOString()})`,
150
151
  error: null,
151
152
  usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
152
153
  costUsd: 0,
@@ -174,7 +175,7 @@ export class TaskScheduler {
174
175
 
175
176
  for (const [, run] of this.running.entries()) {
176
177
  try {
177
- run.controller.abort(new Error('Планировщик остановлен'));
178
+ run.controller.abort(new Error('Scheduler stopped'));
178
179
  } catch {}
179
180
  }
180
181
  this.running.clear();
@@ -245,7 +246,7 @@ export class TaskScheduler {
245
246
  at: Date.now(),
246
247
  status: 'skipped',
247
248
  durationMs: 0,
248
- output: 'Запуск пропущен: предыдущий процесс ещё не завершился (overlapPolicy: skip)',
249
+ output: 'Skipped: the previous run is still in progress (overlapPolicy: skip)',
249
250
  error: null,
250
251
  usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
251
252
  costUsd: 0,
@@ -254,7 +255,7 @@ export class TaskScheduler {
254
255
  }
255
256
  if (overlapPolicy === 'replace') {
256
257
  console.log(`[dsh-cron] Task "${task.title}" (${taskId}) is already running, canceling current run (overlapPolicy: replace)`);
257
- active.controller.abort(new Error('Прервано из-за политики overlap: replace'));
258
+ active.controller.abort(new Error('Aborted by overlap policy: replace'));
258
259
  }
259
260
  if (overlapPolicy === 'queue') {
260
261
  console.log(`[dsh-cron] Task "${task.title}" (${taskId}) is already running, queueing next run (overlapPolicy: queue)`);
@@ -339,14 +340,14 @@ export class TaskScheduler {
339
340
  if (shouldCreateKanbanCard(task, runInfo)) {
340
341
  const settings = typeof this.store.getSettings === 'function' ? this.store.getSettings() : {};
341
342
  const isErr = runInfo.status === 'error' || runInfo.status === 'timeout';
342
- const cardTitle = isErr ? `[Сбой cron] ${task.title}` : `[Cron завершен] ${task.title}`;
343
+ const cardTitle = isErr ? `[Cron failure] ${task.title}` : `[Cron completed] ${task.title}`;
343
344
  const cardBody = [
344
- `**Задача cron:** ${task.title} (\`${task.id}\`)`,
345
- `**Расписание:** ${task.scheduleText || task.schedule}`,
346
- `**Статус:** ${runInfo.status}`,
347
- `**Время запуска:** ${new Date(runInfo.at).toLocaleString('ru-RU')}`,
348
- runInfo.error ? `\n**Ошибка:**\n\`\`\`\n${runInfo.error}\n\`\`\`` : '',
349
- runInfo.output ? `\n**Лог / Вывод:**\n\`\`\`\n${runInfo.output.slice(0, 1000)}\n\`\`\`` : '',
345
+ `**Cron task:** ${task.title} (\`${task.id}\`)`,
346
+ `**Schedule:** ${task.scheduleText || task.schedule}`,
347
+ `**Status:** ${runInfo.status}`,
348
+ `**Started at:** ${new Date(runInfo.at).toISOString()}`,
349
+ runInfo.error ? `\n**Error:**\n\`\`\`\n${runInfo.error}\n\`\`\`` : '',
350
+ runInfo.output ? `\n**Log / Output:**\n\`\`\`\n${runInfo.output.slice(0, 1000)}\n\`\`\`` : '',
350
351
  ].filter(Boolean).join('\n\n');
351
352
 
352
353
  createKanbanCard({
@@ -406,7 +407,7 @@ export class TaskScheduler {
406
407
  const running = this.running.get(taskId);
407
408
  if (running) {
408
409
  try {
409
- running.controller.abort(new Error('Задача приостановлена'));
410
+ running.controller.abort(new Error('Task paused'));
410
411
  } catch {}
411
412
  this.running.delete(taskId);
412
413
  }
@@ -445,7 +446,7 @@ export class TaskScheduler {
445
446
  const running = this.running.get(taskId);
446
447
  if (running) {
447
448
  try {
448
- running.controller.abort(new Error('Задача удалена'));
449
+ running.controller.abort(new Error('Task deleted'));
449
450
  } catch {}
450
451
  this.running.delete(taskId);
451
452
  }
package/lib/store.js CHANGED
@@ -150,7 +150,7 @@ export class TaskStore {
150
150
  ...prev,
151
151
  ...task,
152
152
  id,
153
- title: task.title !== undefined ? task.title : (prev.title || 'Новая задача'),
153
+ title: task.title !== undefined ? task.title : (prev.title || 'New task'),
154
154
  schedule: task.schedule !== undefined ? task.schedule : (prev.schedule || '0 9 * * *'),
155
155
  scheduleText: task.scheduleText !== undefined ? task.scheduleText : (prev.scheduleText || task.schedule),
156
156
  prompt: task.prompt !== undefined ? task.prompt : (prev.prompt || ''),
package/lib/telegram.js CHANGED
@@ -4,6 +4,8 @@ import os from 'node:os';
4
4
 
5
5
  /**
6
6
  * Extract simple key from YAML text without external dependencies.
7
+ * Best-effort fallback only: the primary source for cross-plugin defaults is
8
+ * the Harness settings/credentials service (#51, #93).
7
9
  */
8
10
  export function extractSettingFromYaml(text, section, key) {
9
11
  if (!text) return undefined;
@@ -37,17 +39,26 @@ export function extractFirstAllowedChatId(text) {
37
39
 
38
40
  /**
39
41
  * Read system DSH settings for fallback credentials.
42
+ * Fails soft with a logged warning so silent misconfiguration is visible (#93).
40
43
  */
41
44
  export function getDshDefaultTelegramCredentials() {
42
45
  try {
43
46
  const p = path.join(process.env.HOME || os.homedir(), '.dsh', 'settings.yaml');
44
47
  if (fs.existsSync(p)) {
45
- const raw = fs.readFileSync(p, 'utf-8');
48
+ let raw;
49
+ try {
50
+ raw = fs.readFileSync(p, 'utf-8');
51
+ } catch (readErr) {
52
+ console.warn('[dsh-cron] default Telegram credentials: cannot read DSH settings.yaml:', readErr.message);
53
+ return { botToken: undefined, chatId: undefined };
54
+ }
46
55
  const botToken = extractSettingFromYaml(raw, 'dsh-messenger-gateway', 'botToken');
47
56
  const chatId = extractFirstAllowedChatId(raw);
48
57
  return { botToken, chatId };
49
58
  }
50
- } catch {}
59
+ } catch (err) {
60
+ console.warn('[dsh-cron] default Telegram credentials lookup failed:', err.message);
61
+ }
51
62
  return { botToken: undefined, chatId: undefined };
52
63
  }
53
64
 
@@ -61,29 +72,30 @@ export function escapeMarkdown(str) {
61
72
 
62
73
  /**
63
74
  * Format task execution report for Telegram.
75
+ * Dynamic values are escaped so titles/schedules cannot break the markup (#93).
64
76
  */
65
77
  export function formatTaskTelegramMessage(task, runInfo) {
66
78
  const isSuccess = runInfo.status === 'success';
67
79
  const statusEmoji = isSuccess ? '✅' : '❌';
68
- const statusText = isSuccess ? 'Успешно' : 'Ошибка';
80
+ const statusText = isSuccess ? 'Success' : 'Failed';
69
81
  const duration = runInfo.durationMs != null ? `${runInfo.durationMs} ms` : '—';
70
- const title = task.title || 'Задача';
71
- const schedule = task.scheduleText || task.schedule || '';
82
+ const title = escapeMarkdown(task.title || 'Task');
83
+ const schedule = escapeMarkdown(task.scheduleText || task.schedule || '');
72
84
 
73
85
  const lines = [
74
86
  `⏰ *DSH Cron:* ${title}`,
75
- `*Статус:* ${statusEmoji} ${statusText}`,
76
- schedule ? `*Расписание:* ${schedule}` : null,
77
- `*Длительность:* ${duration}`,
87
+ `*Status:* ${statusEmoji} ${statusText}`,
88
+ schedule ? `*Schedule:* ${schedule}` : null,
89
+ `*Duration:* ${duration}`,
78
90
  ].filter(Boolean);
79
91
 
80
92
  if (runInfo.error) {
81
93
  const errText = String(runInfo.error).slice(0, 1500);
82
- lines.push('', '*Ошибка:*', '```', errText, '```');
94
+ lines.push('', '*Error:*', '```', errText, '```');
83
95
  } else if (runInfo.output && isSuccess) {
84
96
  const outText = String(runInfo.output).trim().slice(0, 2000);
85
97
  if (outText) {
86
- lines.push('', '*Вывод:*', '```', outText, '```');
98
+ lines.push('', '*Output:*', '```', outText, '```');
87
99
  }
88
100
  }
89
101
 
@@ -94,8 +106,8 @@ export function formatTaskTelegramMessage(task, runInfo) {
94
106
  * Send telegram message via Bot API.
95
107
  */
96
108
  export async function sendTelegramMessage({ botToken, chatId, text, parseMode = 'Markdown' }) {
97
- if (!botToken) throw new Error('Telegram botToken не указан');
98
- if (!chatId) throw new Error('Telegram chatId не указан');
109
+ if (!botToken) throw new Error('Telegram botToken is not configured');
110
+ if (!chatId) throw new Error('Telegram chatId is not configured');
99
111
 
100
112
  const url = `https://api.telegram.org/bot${botToken}/sendMessage`;
101
113
  const body = {
@@ -133,13 +145,15 @@ export async function sendTelegramMessage({ botToken, chatId, text, parseMode =
133
145
 
134
146
  /**
135
147
  * Determine if a notification should be sent for a given task and run result.
148
+ * "timeout" counts as a failure, matching the Kanban card policy (#89).
136
149
  */
137
150
  export function shouldNotifyTask(task, runInfo, globalSettings = {}) {
138
151
  const isEnabled = task.notifyTelegram ?? globalSettings.notifyTelegram ?? false;
139
152
  if (!isEnabled) return false;
140
153
 
154
+ const failed = runInfo.status === 'error' || runInfo.status === 'timeout';
141
155
  const onlyOnFail = task.onlyOnFailure ?? globalSettings.onlyOnFailure ?? false;
142
- if (onlyOnFail && runInfo.status !== 'error') {
156
+ if (onlyOnFail && !failed) {
143
157
  return false;
144
158
  }
145
159
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@goodandready/dsh-cron",
3
- "version": "0.1.24",
3
+ "version": "0.2.0",
4
4
  "description": "Scheduled cron tasks, background automation and agent execution for DeepSeek Harness.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",