@goodandready/dsh-cron 0.1.23 → 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,14 +45,18 @@ 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');
52
52
  return new Promise((resolve, reject) => {
53
+ const shellPath = process.platform === 'win32' ? undefined : (process.env.SHELL || '/bin/bash');
53
54
  const child = exec(task.prompt, {
54
55
  cwd: process.cwd(),
55
56
  signal,
57
+ maxBuffer: 10 * 1024 * 1024,
58
+ shell: shellPath,
59
+ env: { ...process.env },
56
60
  }, (err, stdout, stderr) => {
57
61
  if (err) {
58
62
  if (signal && signal.aborted) {
@@ -97,50 +101,77 @@ export class SessionRunner {
97
101
 
98
102
  const defaultSel = this.ctx.get?.('agentDefaultModel')?.currentSelection?.();
99
103
  const provider = task.provider || defaultSel?.provider;
100
- 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
+ };
101
123
 
102
124
  const cwd = process.cwd();
103
- const handle = await this.ctx.agents.create({
104
- sessionId: mintSessionId(),
105
- meta: { cwd },
106
- agentOptions: provider && model ? { provider, model } : undefined,
107
- });
108
-
109
- if (signal && signal.aborted) {
110
- throw (signal.reason || new Error('Aborted'));
111
- }
112
-
113
- await handle.agent.whenIdle();
114
- handle.agent.followup(createUserMessage({
115
- content: [{ type: 'text', text: task.prompt }],
116
- source: { kind: 'plugin', plugin: 'dsh-cron', form: 'cron-execute' },
117
- }));
125
+ let handle = null;
126
+ try {
127
+ handle = await this.ctx.agents.create({
128
+ sessionId: mintSessionId(),
129
+ meta: { cwd, ephemeral: true, internal: true },
130
+ agentOptions: provider && model ? { provider, model } : undefined,
131
+ });
118
132
 
119
- // Await agent turn completion (#67)
120
- if (typeof handle.agent.whenIdle === 'function') {
121
- await handle.agent.whenIdle();
122
- }
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
+ }));
123
138
 
124
- // Try extracting token usage from session history if available
125
- let usage = { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 };
126
- try {
127
- const sessionUsage = handle.agent.session?.usage || handle.agent.usage;
128
- if (sessionUsage) {
129
- usage = {
130
- inputTokens: sessionUsage.inputTokens || sessionUsage.promptTokens || 0,
131
- outputTokens: sessionUsage.outputTokens || sessionUsage.completionTokens || 0,
132
- cacheReadTokens: sessionUsage.cacheReadTokens || sessionUsage.cachedTokens || 0,
133
- };
139
+ // Await agent turn completion (#67), abortable on timeout/overlap (#88)
140
+ if (typeof handle.agent.whenIdle === 'function') {
141
+ await raceAbort(handle.agent.whenIdle());
134
142
  }
135
- } catch {}
136
143
 
137
- 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 {}
138
156
 
139
- return {
140
- output: `[dsh-cron] Agent finished turn for task "${task.title}" (session: ${handle.agent.session?.id || 'cron'})`,
141
- usage,
142
- costUsd
143
- };
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
+ };
164
+ } finally {
165
+ try {
166
+ if (handle && typeof handle.dispose === 'function') {
167
+ await handle.dispose();
168
+ } else if (handle && handle.agent && typeof handle.agent.destroy === 'function') {
169
+ await handle.agent.destroy();
170
+ }
171
+ } catch (disposeErr) {
172
+ console.warn('[dsh-cron] session dispose notice:', disposeErr.message);
173
+ }
174
+ }
144
175
  } catch (err) {
145
176
  console.error(`[dsh-cron] agent execution failed:`, err.message);
146
177
  throw err;
@@ -154,7 +185,7 @@ export class SessionRunner {
154
185
  title: `[Cron] ${task.title}`,
155
186
  ephemeral: task.delivery === 'isolated',
156
187
  });
157
-
188
+
158
189
  if (session && typeof session.send === 'function') {
159
190
  const result = await session.send(task.prompt);
160
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
 
@@ -135,8 +136,23 @@ export class TaskScheduler {
135
136
 
136
137
  start() {
137
138
  const tasks = this.store.list();
139
+ const now = Date.now();
138
140
  for (const task of tasks) {
139
141
  if (task.status === 'active') {
142
+ // Detect missed execution if daemon was offline (threshold: > 5 minutes)
143
+ if (task.nextRunAt && (now - task.nextRunAt) > 5 * 60 * 1000) {
144
+ const missedAt = task.nextRunAt;
145
+ console.warn(`[dsh-cron] Task "${task.title}" (${task.id}) missed scheduled run at ${new Date(missedAt).toISOString()}`);
146
+ this.store.recordRun(task.id, {
147
+ at: missedAt,
148
+ status: 'missed',
149
+ durationMs: 0,
150
+ output: `Skipped: the service was offline or restarting at the scheduled time (${new Date(missedAt).toISOString()})`,
151
+ error: null,
152
+ usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
153
+ costUsd: 0,
154
+ });
155
+ }
140
156
  this.scheduleTask(task);
141
157
  }
142
158
  }
@@ -159,7 +175,7 @@ export class TaskScheduler {
159
175
 
160
176
  for (const [, run] of this.running.entries()) {
161
177
  try {
162
- run.controller.abort(new Error('Планировщик остановлен'));
178
+ run.controller.abort(new Error('Scheduler stopped'));
163
179
  } catch {}
164
180
  }
165
181
  this.running.clear();
@@ -230,7 +246,7 @@ export class TaskScheduler {
230
246
  at: Date.now(),
231
247
  status: 'skipped',
232
248
  durationMs: 0,
233
- output: 'Запуск пропущен: предыдущий процесс ещё не завершился (overlapPolicy: skip)',
249
+ output: 'Skipped: the previous run is still in progress (overlapPolicy: skip)',
234
250
  error: null,
235
251
  usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
236
252
  costUsd: 0,
@@ -239,7 +255,7 @@ export class TaskScheduler {
239
255
  }
240
256
  if (overlapPolicy === 'replace') {
241
257
  console.log(`[dsh-cron] Task "${task.title}" (${taskId}) is already running, canceling current run (overlapPolicy: replace)`);
242
- active.controller.abort(new Error('Прервано из-за политики overlap: replace'));
258
+ active.controller.abort(new Error('Aborted by overlap policy: replace'));
243
259
  }
244
260
  if (overlapPolicy === 'queue') {
245
261
  console.log(`[dsh-cron] Task "${task.title}" (${taskId}) is already running, queueing next run (overlapPolicy: queue)`);
@@ -324,14 +340,14 @@ export class TaskScheduler {
324
340
  if (shouldCreateKanbanCard(task, runInfo)) {
325
341
  const settings = typeof this.store.getSettings === 'function' ? this.store.getSettings() : {};
326
342
  const isErr = runInfo.status === 'error' || runInfo.status === 'timeout';
327
- const cardTitle = isErr ? `[Сбой cron] ${task.title}` : `[Cron завершен] ${task.title}`;
343
+ const cardTitle = isErr ? `[Cron failure] ${task.title}` : `[Cron completed] ${task.title}`;
328
344
  const cardBody = [
329
- `**Задача cron:** ${task.title} (\`${task.id}\`)`,
330
- `**Расписание:** ${task.scheduleText || task.schedule}`,
331
- `**Статус:** ${runInfo.status}`,
332
- `**Время запуска:** ${new Date(runInfo.at).toLocaleString('ru-RU')}`,
333
- runInfo.error ? `\n**Ошибка:**\n\`\`\`\n${runInfo.error}\n\`\`\`` : '',
334
- 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\`\`\`` : '',
335
351
  ].filter(Boolean).join('\n\n');
336
352
 
337
353
  createKanbanCard({
@@ -391,7 +407,7 @@ export class TaskScheduler {
391
407
  const running = this.running.get(taskId);
392
408
  if (running) {
393
409
  try {
394
- running.controller.abort(new Error('Задача приостановлена'));
410
+ running.controller.abort(new Error('Task paused'));
395
411
  } catch {}
396
412
  this.running.delete(taskId);
397
413
  }
@@ -430,7 +446,7 @@ export class TaskScheduler {
430
446
  const running = this.running.get(taskId);
431
447
  if (running) {
432
448
  try {
433
- running.controller.abort(new Error('Задача удалена'));
449
+ running.controller.abort(new Error('Task deleted'));
434
450
  } catch {}
435
451
  this.running.delete(taskId);
436
452
  }
package/lib/store.js CHANGED
@@ -13,6 +13,7 @@ export class TaskStore {
13
13
  this.tasks = new Map();
14
14
  this.history = new Map();
15
15
  this.settings = {};
16
+ this._savePromise = Promise.resolve();
16
17
  this.init();
17
18
  }
18
19
 
@@ -57,7 +58,9 @@ export class TaskStore {
57
58
  history: Object.fromEntries(this.history.entries()),
58
59
  settings: this.settings || {},
59
60
  };
60
- const tmp = this.filePath + '.tmp.' + Date.now();
61
+ // Unique tmp file per save call with process PID and random token to eliminate file collision
62
+ const rand = Math.random().toString(36).slice(2, 8);
63
+ const tmp = `${this.filePath}.tmp.${process.pid}_${Date.now()}_${rand}`;
61
64
  fs.writeFileSync(tmp, JSON.stringify(data, null, 2), 'utf-8');
62
65
  fs.renameSync(tmp, this.filePath);
63
66
  } catch (err) {
@@ -147,7 +150,7 @@ export class TaskStore {
147
150
  ...prev,
148
151
  ...task,
149
152
  id,
150
- title: task.title !== undefined ? task.title : (prev.title || 'Новая задача'),
153
+ title: task.title !== undefined ? task.title : (prev.title || 'New task'),
151
154
  schedule: task.schedule !== undefined ? task.schedule : (prev.schedule || '0 9 * * *'),
152
155
  scheduleText: task.scheduleText !== undefined ? task.scheduleText : (prev.scheduleText || task.schedule),
153
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.23",
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",