skimpyclaw 0.3.14 → 0.4.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.
Files changed (222) hide show
  1. package/README.md +47 -37
  2. package/dist/__tests__/adapter-types.test.d.ts +4 -0
  3. package/dist/__tests__/adapter-types.test.js +63 -0
  4. package/dist/__tests__/anthropic-adapter.test.d.ts +4 -0
  5. package/dist/__tests__/anthropic-adapter.test.js +264 -0
  6. package/dist/__tests__/api.test.js +0 -1
  7. package/dist/__tests__/cli.integration.test.js +2 -4
  8. package/dist/__tests__/cli.test.js +0 -1
  9. package/dist/__tests__/code-agents-notifications.test.js +137 -0
  10. package/dist/__tests__/code-agents-parser.test.js +19 -1
  11. package/dist/__tests__/code-agents-preflight.test.js +3 -28
  12. package/dist/__tests__/code-agents-utils.test.js +34 -9
  13. package/dist/__tests__/code-agents-worktrees.test.js +116 -0
  14. package/dist/__tests__/codex-adapter.test.js +184 -0
  15. package/dist/__tests__/codex-auth.test.js +66 -0
  16. package/dist/__tests__/codex-provider-gating.test.js +35 -0
  17. package/dist/__tests__/codex-unified-loop.test.js +111 -0
  18. package/dist/__tests__/config-security.test.js +127 -0
  19. package/dist/__tests__/config.test.js +23 -0
  20. package/dist/__tests__/context-manager.test.js +243 -164
  21. package/dist/__tests__/cron-run.test.js +250 -0
  22. package/dist/__tests__/cron.test.js +12 -38
  23. package/dist/__tests__/digests.test.js +67 -0
  24. package/dist/__tests__/discord-attachments.test.js +211 -0
  25. package/dist/__tests__/discord-docs.test.d.ts +1 -0
  26. package/dist/__tests__/discord-docs.test.js +27 -0
  27. package/dist/__tests__/discord-thread-agents.test.d.ts +1 -0
  28. package/dist/__tests__/discord-thread-agents.test.js +115 -0
  29. package/dist/__tests__/discord-thread-context.test.d.ts +1 -0
  30. package/dist/__tests__/discord-thread-context.test.js +42 -0
  31. package/dist/__tests__/doctor.formatters.test.js +4 -4
  32. package/dist/__tests__/doctor.index.test.js +1 -1
  33. package/dist/__tests__/doctor.runner.test.js +3 -15
  34. package/dist/__tests__/env-sanitizer.test.d.ts +1 -0
  35. package/dist/__tests__/env-sanitizer.test.js +45 -0
  36. package/dist/__tests__/exec-approval.test.js +61 -0
  37. package/dist/__tests__/fetch-tool.test.d.ts +1 -0
  38. package/dist/__tests__/fetch-tool.test.js +85 -0
  39. package/dist/__tests__/gateway-status-auth.test.d.ts +1 -0
  40. package/dist/__tests__/gateway-status-auth.test.js +72 -0
  41. package/dist/__tests__/heartbeat.test.js +3 -3
  42. package/dist/__tests__/interactive-sessions.test.d.ts +1 -0
  43. package/dist/__tests__/interactive-sessions.test.js +96 -0
  44. package/dist/__tests__/langfuse.test.js +6 -18
  45. package/dist/__tests__/model-selection.test.js +3 -4
  46. package/dist/__tests__/providers-init.test.js +2 -8
  47. package/dist/__tests__/providers-routing.test.js +1 -1
  48. package/dist/__tests__/providers-utils.test.js +13 -3
  49. package/dist/__tests__/sessions.test.js +14 -10
  50. package/dist/__tests__/setup.test.js +12 -29
  51. package/dist/__tests__/skills.test.js +10 -7
  52. package/dist/__tests__/stream-formatter.test.d.ts +1 -0
  53. package/dist/__tests__/stream-formatter.test.js +114 -0
  54. package/dist/__tests__/token-efficiency.test.js +131 -15
  55. package/dist/__tests__/tool-loop.test.d.ts +4 -0
  56. package/dist/__tests__/tool-loop.test.js +505 -0
  57. package/dist/__tests__/tools.test.js +101 -276
  58. package/dist/__tests__/utils.test.d.ts +1 -0
  59. package/dist/__tests__/utils.test.js +14 -0
  60. package/dist/__tests__/voice.test.js +21 -0
  61. package/dist/agent.js +35 -4
  62. package/dist/api.js +113 -37
  63. package/dist/channels/discord/attachments.d.ts +50 -0
  64. package/dist/channels/discord/attachments.js +137 -0
  65. package/dist/channels/discord/delegation.d.ts +5 -0
  66. package/dist/channels/discord/delegation.js +136 -0
  67. package/dist/channels/discord/handlers.js +694 -7
  68. package/dist/channels/discord/index.d.ts +16 -1
  69. package/dist/channels/discord/index.js +64 -1
  70. package/dist/channels/discord/thread-agents.d.ts +54 -0
  71. package/dist/channels/discord/thread-agents.js +323 -0
  72. package/dist/channels/discord/threads.d.ts +58 -0
  73. package/dist/channels/discord/threads.js +192 -0
  74. package/dist/channels/discord/types.js +4 -2
  75. package/dist/channels/discord/utils.d.ts +16 -0
  76. package/dist/channels/discord/utils.js +86 -6
  77. package/dist/channels/telegram/index.d.ts +1 -1
  78. package/dist/channels/telegram/types.js +1 -1
  79. package/dist/channels/telegram/utils.js +9 -3
  80. package/dist/channels.d.ts +1 -1
  81. package/dist/cli.js +20 -400
  82. package/dist/code-agents/executor.d.ts +1 -1
  83. package/dist/code-agents/executor.js +101 -45
  84. package/dist/code-agents/index.d.ts +2 -7
  85. package/dist/code-agents/index.js +111 -80
  86. package/dist/code-agents/interactive-resume.d.ts +6 -0
  87. package/dist/code-agents/interactive-resume.js +98 -0
  88. package/dist/code-agents/interactive-sessions.d.ts +20 -0
  89. package/dist/code-agents/interactive-sessions.js +132 -0
  90. package/dist/code-agents/parser.js +5 -1
  91. package/dist/code-agents/registry.d.ts +7 -1
  92. package/dist/code-agents/registry.js +11 -23
  93. package/dist/code-agents/stream-formatter.d.ts +8 -0
  94. package/dist/code-agents/stream-formatter.js +92 -0
  95. package/dist/code-agents/types.d.ts +16 -24
  96. package/dist/code-agents/utils.d.ts +35 -11
  97. package/dist/code-agents/utils.js +349 -95
  98. package/dist/code-agents/worktrees.d.ts +37 -0
  99. package/dist/code-agents/worktrees.js +116 -0
  100. package/dist/config.d.ts +2 -4
  101. package/dist/config.js +123 -23
  102. package/dist/cron.d.ts +1 -6
  103. package/dist/cron.js +175 -82
  104. package/dist/dashboard/assets/index-B345aOO-.js +65 -0
  105. package/dist/dashboard/assets/index-ZWK4dalJ.css +1 -0
  106. package/dist/dashboard/index.html +2 -2
  107. package/dist/digests.d.ts +1 -0
  108. package/dist/digests.js +132 -42
  109. package/dist/doctor/checks.d.ts +0 -3
  110. package/dist/doctor/checks.js +1 -108
  111. package/dist/doctor/runner.js +1 -4
  112. package/dist/env-sanitizer.d.ts +2 -0
  113. package/dist/env-sanitizer.js +61 -0
  114. package/dist/exec-approval.d.ts +11 -1
  115. package/dist/exec-approval.js +17 -4
  116. package/dist/gateway.d.ts +3 -1
  117. package/dist/gateway.js +17 -7
  118. package/dist/heartbeat.js +1 -6
  119. package/dist/langfuse.js +3 -29
  120. package/dist/model-selection.js +3 -1
  121. package/dist/providers/adapter.d.ts +118 -0
  122. package/dist/providers/adapter.js +6 -0
  123. package/dist/providers/adapters/anthropic-adapter.d.ts +22 -0
  124. package/dist/providers/adapters/anthropic-adapter.js +204 -0
  125. package/dist/providers/adapters/codex-adapter.d.ts +26 -0
  126. package/dist/providers/adapters/codex-adapter.js +203 -0
  127. package/dist/providers/anthropic.d.ts +1 -0
  128. package/dist/providers/anthropic.js +10 -272
  129. package/dist/providers/codex.d.ts +21 -0
  130. package/dist/providers/codex.js +149 -330
  131. package/dist/providers/content.d.ts +1 -1
  132. package/dist/providers/content.js +2 -2
  133. package/dist/providers/context-manager.d.ts +18 -6
  134. package/dist/providers/context-manager.js +199 -223
  135. package/dist/providers/index.d.ts +9 -1
  136. package/dist/providers/index.js +73 -64
  137. package/dist/providers/loop-utils.d.ts +20 -0
  138. package/dist/providers/loop-utils.js +30 -0
  139. package/dist/providers/tool-loop.d.ts +12 -0
  140. package/dist/providers/tool-loop.js +251 -0
  141. package/dist/providers/utils.d.ts +19 -3
  142. package/dist/providers/utils.js +100 -29
  143. package/dist/secure-store.d.ts +8 -0
  144. package/dist/secure-store.js +80 -0
  145. package/dist/service.js +3 -28
  146. package/dist/sessions.d.ts +3 -0
  147. package/dist/sessions.js +147 -18
  148. package/dist/setup-templates.js +13 -25
  149. package/dist/setup.d.ts +10 -6
  150. package/dist/setup.js +84 -292
  151. package/dist/skills.js +3 -11
  152. package/dist/tools/agent-delegation.d.ts +19 -0
  153. package/dist/tools/agent-delegation.js +49 -0
  154. package/dist/tools/bash-tool.js +89 -34
  155. package/dist/tools/definitions.d.ts +199 -302
  156. package/dist/tools/definitions.js +70 -123
  157. package/dist/tools/execute-context.d.ts +13 -4
  158. package/dist/tools/fetch-tool.js +109 -13
  159. package/dist/tools/file-tools.js +7 -1
  160. package/dist/tools.d.ts +7 -7
  161. package/dist/tools.js +133 -151
  162. package/dist/types.d.ts +37 -30
  163. package/dist/utils.js +4 -6
  164. package/dist/voice.d.ts +1 -1
  165. package/dist/voice.js +17 -4
  166. package/package.json +33 -23
  167. package/templates/TOOLS.md +0 -27
  168. package/dist/__tests__/audit.test.js +0 -122
  169. package/dist/__tests__/code-agents-orchestrator.test.js +0 -216
  170. package/dist/__tests__/code-agents-sandbox.test.js +0 -163
  171. package/dist/__tests__/orchestrator.test.js +0 -425
  172. package/dist/__tests__/sandbox-bridge.test.js +0 -116
  173. package/dist/__tests__/sandbox-manager.test.js +0 -144
  174. package/dist/__tests__/sandbox-mount-security.test.js +0 -139
  175. package/dist/__tests__/sandbox-runtime.test.js +0 -176
  176. package/dist/__tests__/subagent.test.js +0 -240
  177. package/dist/__tests__/telegram.test.js +0 -42
  178. package/dist/code-agents/orchestrator.d.ts +0 -29
  179. package/dist/code-agents/orchestrator.js +0 -694
  180. package/dist/code-agents/worktree.d.ts +0 -40
  181. package/dist/code-agents/worktree.js +0 -215
  182. package/dist/dashboard/assets/index-BoTHPby4.js +0 -65
  183. package/dist/dashboard/assets/index-D4mufvBg.css +0 -1
  184. package/dist/dashboard.d.ts +0 -8
  185. package/dist/dashboard.js +0 -4071
  186. package/dist/discord.d.ts +0 -8
  187. package/dist/discord.js +0 -792
  188. package/dist/mcp-context-a8c.d.ts +0 -13
  189. package/dist/mcp-context-a8c.js +0 -34
  190. package/dist/orchestrator.d.ts +0 -15
  191. package/dist/orchestrator.js +0 -676
  192. package/dist/providers/openai.d.ts +0 -10
  193. package/dist/providers/openai.js +0 -355
  194. package/dist/sandbox/bridge.d.ts +0 -5
  195. package/dist/sandbox/bridge.js +0 -63
  196. package/dist/sandbox/index.d.ts +0 -5
  197. package/dist/sandbox/index.js +0 -4
  198. package/dist/sandbox/manager.d.ts +0 -7
  199. package/dist/sandbox/manager.js +0 -100
  200. package/dist/sandbox/mount-security.d.ts +0 -12
  201. package/dist/sandbox/mount-security.js +0 -122
  202. package/dist/sandbox/runtime.d.ts +0 -39
  203. package/dist/sandbox/runtime.js +0 -192
  204. package/dist/sandbox-utils.d.ts +0 -6
  205. package/dist/sandbox-utils.js +0 -36
  206. package/dist/subagent.d.ts +0 -19
  207. package/dist/subagent.js +0 -407
  208. package/dist/telegram.d.ts +0 -2
  209. package/dist/telegram.js +0 -11
  210. package/dist/tools/browser-tool.d.ts +0 -3
  211. package/dist/tools/browser-tool.js +0 -266
  212. package/sandbox/Dockerfile +0 -40
  213. /package/dist/__tests__/{audit.test.d.ts → code-agents-notifications.test.d.ts} +0 -0
  214. /package/dist/__tests__/{code-agents-orchestrator.test.d.ts → code-agents-worktrees.test.d.ts} +0 -0
  215. /package/dist/__tests__/{code-agents-sandbox.test.d.ts → codex-adapter.test.d.ts} +0 -0
  216. /package/dist/__tests__/{orchestrator.test.d.ts → codex-auth.test.d.ts} +0 -0
  217. /package/dist/__tests__/{sandbox-bridge.test.d.ts → codex-provider-gating.test.d.ts} +0 -0
  218. /package/dist/__tests__/{sandbox-manager.test.d.ts → codex-unified-loop.test.d.ts} +0 -0
  219. /package/dist/__tests__/{sandbox-mount-security.test.d.ts → config-security.test.d.ts} +0 -0
  220. /package/dist/__tests__/{sandbox-runtime.test.d.ts → cron-run.test.d.ts} +0 -0
  221. /package/dist/__tests__/{subagent.test.d.ts → digests.test.d.ts} +0 -0
  222. /package/dist/__tests__/{telegram.test.d.ts → discord-attachments.test.d.ts} +0 -0
package/dist/cron.js CHANGED
@@ -2,15 +2,17 @@
2
2
  import { Cron } from 'croner';
3
3
  import { exec } from 'child_process';
4
4
  import { existsSync, mkdirSync, appendFileSync, readFileSync, watch } from 'fs';
5
- import { join } from 'path';
5
+ import { join, resolve } from 'path';
6
6
  import { getLogsDir, getConfigPath, loadConfig, resolveAllowedPaths } from './config.js';
7
7
  import { homedir } from 'node:os';
8
8
  import { runAgentTurn } from './agent.js';
9
9
  import { startTrace, addEvent, endTrace } from './audit.js';
10
10
  import { sendActiveChannelProactiveMessage, sendActiveChannelProactiveVoice, getActiveChannelId } from './channels.js';
11
+ import { sendToDiscordThread, sendToDiscordThreadWithVoice } from './channels/discord/index.js';
11
12
  import { parseAndSaveDigest } from './digests.js';
12
13
  import { synthesizeSpeech } from './voice.js';
13
14
  import { toErrorMessage } from './utils.js';
15
+ import { sanitizeCronEnv } from './env-sanitizer.js';
14
16
  function safeTimezone(tz) {
15
17
  const fallback = Intl.DateTimeFormat().resolvedOptions().timeZone || 'UTC';
16
18
  if (!tz)
@@ -24,7 +26,6 @@ function safeTimezone(tz) {
24
26
  return fallback;
25
27
  }
26
28
  }
27
- import { ensureContainer, SANDBOX_DEFAULTS, sandboxBash } from './sandbox/index.js';
28
29
  const scheduledJobs = new Map();
29
30
  let configWatcher = null;
30
31
  function getCronLogDir() {
@@ -58,12 +59,97 @@ function appendCronLogLine(jobId, line) {
58
59
  }
59
60
  // Track currently running jobs for status queries
60
61
  const runningJobs = new Map();
62
+ const activeExecutions = new Set();
61
63
  export function getCronRunStatus() {
62
64
  return {
63
65
  running: Array.from(runningJobs.keys()),
64
66
  recent: Array.from(runningJobs.values()),
65
67
  };
66
68
  }
69
+ /**
70
+ * Send a cron notification to the configured target.
71
+ * If discordThreadId is set, only routes to that thread.
72
+ * Active-channel fallback only applies when no discordThreadId is configured.
73
+ * Optionally includes voice attachment if voiceBuffer and voiceFormat are provided.
74
+ * Returns true if sent successfully.
75
+ */
76
+ async function sendCronNotification(config, message, discordThreadId, voiceBuffer, voiceFormat) {
77
+ if (discordThreadId) {
78
+ try {
79
+ // Use voice-enabled sender for Discord threads if voice is provided
80
+ if (voiceBuffer && voiceFormat) {
81
+ const sent = await sendToDiscordThreadWithVoice(discordThreadId, message, voiceBuffer, voiceFormat);
82
+ if (sent)
83
+ return true;
84
+ console.error(`[cron] Failed to send to Discord thread ${discordThreadId} with voice; active-channel fallback disabled for thread-targeted jobs`);
85
+ return false;
86
+ }
87
+ const sent = await sendToDiscordThread(discordThreadId, message);
88
+ if (sent)
89
+ return true;
90
+ console.error(`[cron] Failed to send to Discord thread ${discordThreadId}; active-channel fallback disabled for thread-targeted jobs`);
91
+ return false;
92
+ }
93
+ catch (err) {
94
+ const errorText = err instanceof Error ? err.message : String(err);
95
+ console.error(`[cron] Discord thread delivery failed for ${discordThreadId}; active-channel fallback disabled: ${errorText}`);
96
+ return false;
97
+ }
98
+ }
99
+ // Fallback to active channel for non-thread-targeted jobs
100
+ if (voiceBuffer && voiceFormat) {
101
+ // Send voice to active channel if provided
102
+ await sendActiveChannelProactiveVoice(config, voiceBuffer, voiceFormat);
103
+ }
104
+ return sendActiveChannelProactiveMessage(config, message);
105
+ }
106
+ function resolveDiscordThreadTarget(jobDef) {
107
+ const threadId = jobDef.payload.discordThreadId?.trim();
108
+ if (!threadId)
109
+ return undefined;
110
+ // Discord snowflakes are numeric IDs. Validate eagerly so invalid config falls back cleanly.
111
+ if (!/^\d{17,20}$/.test(threadId)) {
112
+ console.warn(`[cron] Invalid discordThreadId for job "${jobDef.id}": ${JSON.stringify(jobDef.payload.discordThreadId)}`);
113
+ return undefined;
114
+ }
115
+ return threadId;
116
+ }
117
+ const CRON_AGENT_RETRY_DELAYS_MS = [5000, 15000];
118
+ export function isRetryableCronAgentError(err) {
119
+ const message = toErrorMessage(err).toLowerCase();
120
+ return [
121
+ 'codex api 503',
122
+ 'upstream connect error',
123
+ 'connection refused',
124
+ 'connection reset',
125
+ 'remote connection failure',
126
+ 'fetch failed',
127
+ 'econnreset',
128
+ 'econnrefused',
129
+ 'etimedout',
130
+ 'overloaded_error',
131
+ 'temporarily unavailable',
132
+ ].some(pattern => message.includes(pattern));
133
+ }
134
+ async function sleep(ms) {
135
+ await new Promise(resolve => setTimeout(resolve, ms));
136
+ }
137
+ async function runCronAgentTurnWithRetry(jobId, run) {
138
+ for (let attempt = 0; attempt <= CRON_AGENT_RETRY_DELAYS_MS.length; attempt++) {
139
+ try {
140
+ return await run();
141
+ }
142
+ catch (err) {
143
+ if (attempt >= CRON_AGENT_RETRY_DELAYS_MS.length || !isRetryableCronAgentError(err)) {
144
+ throw err;
145
+ }
146
+ const delayMs = CRON_AGENT_RETRY_DELAYS_MS[attempt];
147
+ appendCronLogLine(jobId, `Agent turn transient failure; retrying in ${(delayMs / 1000).toFixed(0)}s (${attempt + 1}/${CRON_AGENT_RETRY_DELAYS_MS.length}): ${toErrorMessage(err).slice(0, 180)}`);
148
+ await sleep(delayMs);
149
+ }
150
+ }
151
+ throw new Error('Unreachable cron retry state');
152
+ }
67
153
  export function initCron(config) {
68
154
  // Clear existing jobs
69
155
  for (const job of scheduledJobs.values()) {
@@ -109,6 +195,10 @@ export function initCron(config) {
109
195
  }
110
196
  }
111
197
  function scheduleJob(jobDef, config) {
198
+ if (jobDef.enabled === false) {
199
+ console.log(`[cron] Skipping disabled job: ${jobDef.id}`);
200
+ return;
201
+ }
112
202
  if (jobDef.schedule.kind !== 'cron') {
113
203
  console.warn(`[cron] Unsupported schedule kind: ${jobDef.schedule.kind}`);
114
204
  return;
@@ -128,6 +218,13 @@ function scheduleJob(jobDef, config) {
128
218
  console.log(`[cron] Scheduled: ${jobDef.name} (${jobDef.schedule.expr})`);
129
219
  }
130
220
  async function executeJobPayload(jobDef, config) {
221
+ if (activeExecutions.has(jobDef.id)) {
222
+ const message = `Skipping overlapping run for job "${jobDef.id}" (previous run still active)`;
223
+ console.warn(`[cron] ${message}`);
224
+ appendCronLogLine(jobDef.id, `=== SKIPPED: overlap guard (${jobDef.id}) ===`);
225
+ return;
226
+ }
227
+ activeExecutions.add(jobDef.id);
131
228
  const logEntry = {
132
229
  jobId: jobDef.id,
133
230
  jobName: jobDef.name,
@@ -135,13 +232,16 @@ async function executeJobPayload(jobDef, config) {
135
232
  status: 'running',
136
233
  };
137
234
  runningJobs.set(jobDef.id, logEntry);
235
+ const discordThreadId = resolveDiscordThreadTarget(jobDef);
236
+ // Track synthesized voice for final notification
237
+ let synthesizedVoice = null;
138
238
  // Log start immediately
139
239
  appendCronLogLine(jobDef.id, `=== STARTED: ${jobDef.name} (${jobDef.id}) ===`);
140
240
  appendCronLogLine(jobDef.id, `Model: ${jobDef.model || 'default'}`);
141
241
  appendCronLogLine(jobDef.id, `Payload: ${jobDef.payload.kind}`);
142
242
  // Notify channel at start
143
243
  try {
144
- await sendActiveChannelProactiveMessage(config, `🔄 Cron starting: ${jobDef.name}`);
244
+ await sendCronNotification(config, `🔄 Cron starting: ${jobDef.name}`, discordThreadId);
145
245
  }
146
246
  catch {
147
247
  // Non-critical
@@ -157,14 +257,14 @@ async function executeJobPayload(jobDef, config) {
157
257
  bashTimeout: 15000,
158
258
  };
159
259
  const tools = jobDef.payload.tools
160
- ? { ...jobDef.payload.tools, allowedPaths: jobDef.payload.tools.allowedPaths?.length ? jobDef.payload.tools.allowedPaths : resolveAllowedPaths(config) }
260
+ ? { ...jobDef.payload.tools, allowedPaths: resolveAllowedPaths(config, jobDef.payload.tools.allowedPaths) }
161
261
  : defaultTools;
162
- const response = await runAgentTurn(config.agents.default, message, config, jobDef.model, tools, undefined, {
262
+ const response = await runCronAgentTurnWithRetry(jobDef.id, () => runAgentTurn(config.agents.default, message, config, jobDef.model, tools, undefined, {
163
263
  channel: getActiveChannelId() || 'telegram',
164
264
  trigger: 'cron',
165
265
  sessionId: jobDef.id,
166
266
  metadata: { jobName: jobDef.name, isCronJob: true },
167
- });
267
+ }));
168
268
  appendCronLogLine(jobDef.id, `Agent turn completed (${response.length} chars)`);
169
269
  // Parse dual output (voice + text) if delimiters present
170
270
  const { voice: voicePortion, text: textPortion } = parseDualOutput(response);
@@ -173,29 +273,39 @@ async function executeJobPayload(jobDef, config) {
173
273
  }
174
274
  // Use text portion for log output and notifications
175
275
  logEntry.output = textPortion.slice(0, 5000);
176
- // Post-run guard for pr-review job
177
- if (jobDef.id === 'pr-review') {
178
- const guardAlert = validatePrReviewOutput(textPortion);
179
- if (guardAlert) {
180
- appendCronLogLine(jobDef.id, guardAlert);
181
- try {
182
- await sendActiveChannelProactiveMessage(config, guardAlert);
183
- }
184
- catch {
185
- // Non-critical
186
- }
187
- }
188
- }
189
276
  // Parse and save digest from the text portion
190
277
  try {
191
- parseAndSaveDigest(jobDef.id, jobDef.name, textPortion);
278
+ const digest = parseAndSaveDigest(jobDef.id, jobDef.name, textPortion);
192
279
  appendCronLogLine(jobDef.id, 'Digest saved');
280
+ if (digest.articles.length > 0) {
281
+ const digestMessage = digest.summary ?? textPortion;
282
+ if (!digestMessage || !digestMessage.trim()) {
283
+ appendCronLogLine(jobDef.id, 'Digest message is empty, skipping send');
284
+ }
285
+ else {
286
+ try {
287
+ const sent = await sendCronNotification(config, digestMessage, discordThreadId);
288
+ if (sent) {
289
+ appendCronLogLine(jobDef.id, `Digest sent to chat (${digestMessage.length} chars)`);
290
+ }
291
+ else if (discordThreadId) {
292
+ appendCronLogLine(jobDef.id, `Digest thread delivery failed (threadId=${discordThreadId}); no active-channel fallback`);
293
+ }
294
+ else {
295
+ appendCronLogLine(jobDef.id, 'Failed to send digest to chat');
296
+ }
297
+ }
298
+ catch {
299
+ appendCronLogLine(jobDef.id, 'Failed to send digest to chat');
300
+ }
301
+ }
302
+ }
193
303
  }
194
304
  catch (digestErr) {
195
305
  const errMsg = digestErr instanceof Error ? digestErr.message : String(digestErr);
196
306
  appendCronLogLine(jobDef.id, `Failed to save digest: ${errMsg}`);
197
307
  }
198
- // Synthesize and send voice if configured
308
+ // Synthesize voice if configured (stored for final notification)
199
309
  if (jobDef.payload.sendAsVoice && config.voice) {
200
310
  try {
201
311
  // Use voice portion if available, fall back to text
@@ -203,13 +313,9 @@ async function executeJobPayload(jobDef, config) {
203
313
  appendCronLogLine(jobDef.id, `Synthesizing voice (${voicePortion ? 'voice portion' : 'full text fallback'})...`);
204
314
  const speech = await synthesizeSpeech(voiceContent, config.voice);
205
315
  appendCronLogLine(jobDef.id, `Voice synthesized (${speech.format}, ${speech.provider}, ${speech.buffer.length} bytes)`);
206
- const sent = await sendActiveChannelProactiveVoice(config, speech.buffer, speech.format);
207
- if (sent) {
208
- appendCronLogLine(jobDef.id, 'Voice message sent to active channel');
209
- }
210
- else {
211
- appendCronLogLine(jobDef.id, 'No active channel for voice message');
212
- }
316
+ // Store for final notification instead of sending immediately
317
+ synthesizedVoice = { buffer: speech.buffer, format: speech.format };
318
+ appendCronLogLine(jobDef.id, 'Voice stored for final notification');
213
319
  }
214
320
  catch (voiceErr) {
215
321
  const errMsg = voiceErr instanceof Error ? voiceErr.message : String(voiceErr);
@@ -236,6 +342,15 @@ async function executeJobPayload(jobDef, config) {
236
342
  durationMs: 0,
237
343
  });
238
344
  await endTrace(scriptTraceId, 'ok');
345
+ // Parse and save digest from script output (same as agentTurn path)
346
+ try {
347
+ const digest = parseAndSaveDigest(jobDef.id, jobDef.name, output);
348
+ appendCronLogLine(jobDef.id, `Digest saved: ${digest.articles.length} articles`);
349
+ }
350
+ catch (digestErr) {
351
+ const errMsg = digestErr instanceof Error ? digestErr.message : String(digestErr);
352
+ appendCronLogLine(jobDef.id, `Failed to save digest: ${errMsg}`);
353
+ }
239
354
  }
240
355
  catch (scriptErr) {
241
356
  const scriptErrMsg = scriptErr instanceof Error ? scriptErr.message : String(scriptErr);
@@ -263,9 +378,10 @@ async function executeJobPayload(jobDef, config) {
263
378
  logEntry.durationMs = new Date(logEntry.finishedAt).getTime() - new Date(logEntry.startedAt).getTime();
264
379
  writeCronLog(logEntry);
265
380
  runningJobs.delete(jobDef.id);
381
+ activeExecutions.delete(jobDef.id);
266
382
  const elapsed = (logEntry.durationMs / 1000).toFixed(1);
267
383
  console.log(`[cron] Job ${jobDef.id} finished: ${logEntry.status} (${elapsed}s)`);
268
- // Notify active channel
384
+ // Notify active channel with optional voice attachment
269
385
  try {
270
386
  const icon = logEntry.status === 'success' ? '✅' : logEntry.status === 'timeout' ? '⏰' : '❌';
271
387
  let notification = `${icon} Cron: ${jobDef.name} — ${logEntry.status} (${elapsed}s)`;
@@ -279,7 +395,20 @@ async function executeJobPayload(jobDef, config) {
279
395
  : logEntry.output;
280
396
  notification += `\n\n${output}`;
281
397
  }
282
- await sendActiveChannelProactiveMessage(config, notification);
398
+ // Include synthesized voice if available
399
+ if (synthesizedVoice) {
400
+ appendCronLogLine(jobDef.id, `Sending notification with voice (${synthesizedVoice.format})`);
401
+ const sent = await sendCronNotification(config, notification, discordThreadId, synthesizedVoice.buffer, synthesizedVoice.format);
402
+ if (!sent && discordThreadId) {
403
+ appendCronLogLine(jobDef.id, `Final thread notification delivery failed (threadId=${discordThreadId}); no active-channel fallback`);
404
+ }
405
+ }
406
+ else {
407
+ const sent = await sendCronNotification(config, notification, discordThreadId);
408
+ if (!sent && discordThreadId) {
409
+ appendCronLogLine(jobDef.id, `Final thread notification delivery failed (threadId=${discordThreadId}); no active-channel fallback`);
410
+ }
411
+ }
283
412
  }
284
413
  catch (notifyErr) {
285
414
  console.error(`[cron] Failed to send notification: ${notifyErr}`);
@@ -296,19 +425,6 @@ async function executeScript(jobDef, config) {
296
425
  throw new Error(`Working directory does not exist: ${cwd}`);
297
426
  }
298
427
  const timeoutMs = jobDef.payload.timeoutMs || 600000; // 10 min default
299
- // Sandbox routing for script payloads
300
- const sandboxCfg = config.sandbox;
301
- if (sandboxCfg?.enabled) {
302
- const merged = { ...SANDBOX_DEFAULTS, ...sandboxCfg };
303
- const containerName = await ensureContainer(`cron-${jobDef.id}`, merged, jobDef.payload.tools?.allowedPaths || []);
304
- console.log(`[cron:script] Running in sandbox container: ${containerName}`);
305
- console.log(`[cron:script] Running: ${script.slice(0, 100)}${script.length > 100 ? '...' : ''}`);
306
- if (cwd)
307
- console.log(`[cron:script] cwd: ${cwd}`);
308
- const output = await sandboxBash(containerName, script, cwd, timeoutMs);
309
- console.log(`[cron:script] Sandbox completed (${output.length} chars)`);
310
- return output;
311
- }
312
428
  return new Promise((resolve, reject) => {
313
429
  const startTime = Date.now();
314
430
  console.log(`[cron:script] Running: ${script.slice(0, 100)}${script.length > 100 ? '...' : ''}`);
@@ -317,7 +433,7 @@ async function executeScript(jobDef, config) {
317
433
  const child = exec(script, {
318
434
  cwd: cwd || undefined,
319
435
  timeout: timeoutMs,
320
- env: { ...process.env },
436
+ env: sanitizeCronEnv(),
321
437
  maxBuffer: 10 * 1024 * 1024, // 10MB output buffer
322
438
  }, (error, stdout, stderr) => {
323
439
  const elapsed = ((Date.now() - startTime) / 1000).toFixed(1);
@@ -352,20 +468,21 @@ function resolveMessageSource(message) {
352
468
  const trimmed = message.trim();
353
469
  if (!trimmed.endsWith('.md'))
354
470
  return message;
355
- // Expand ~ to home directory
471
+ const promptsRoot = resolve(homedir(), '.skimpyclaw', 'prompts');
356
472
  const resolved = trimmed.startsWith('~/')
357
- ? join(homedir(), trimmed.slice(2))
358
- : trimmed;
473
+ ? resolve(homedir(), trimmed.slice(2))
474
+ : trimmed.startsWith('/')
475
+ ? resolve(trimmed)
476
+ : resolve(promptsRoot, trimmed);
477
+ const insidePromptsRoot = resolved === promptsRoot || resolved.startsWith(`${promptsRoot}/`);
478
+ if (!insidePromptsRoot) {
479
+ console.warn(`[cron] Rejected prompt path outside ~/.skimpyclaw/prompts: ${trimmed}`);
480
+ return message;
481
+ }
359
482
  if (existsSync(resolved)) {
360
483
  console.log(`[cron] Loading prompt from file: ${resolved}`);
361
484
  return readFileSync(resolved, 'utf-8');
362
485
  }
363
- // Fallback: check ~/.skimpyclaw/prompts/ directory
364
- const promptsDir = join(homedir(), '.skimpyclaw', 'prompts', trimmed);
365
- if (existsSync(promptsDir)) {
366
- console.log(`[cron] Loading prompt from prompts dir: ${promptsDir}`);
367
- return readFileSync(promptsDir, 'utf-8');
368
- }
369
486
  console.warn(`[cron] Could not resolve prompt file: ${trimmed}`);
370
487
  // Not a valid file path — treat as regular message text
371
488
  return message;
@@ -393,35 +510,6 @@ export function parseDualOutput(response) {
393
510
  text: text || response,
394
511
  };
395
512
  }
396
- /**
397
- * Post-run guard for the pr-review cron job.
398
- * Validates that the agent actually used code_with_agent when PRs were found.
399
- * Non-throwing — logs a warning and returns an alert message (or null if OK).
400
- */
401
- export function validatePrReviewOutput(output) {
402
- // Check for the machine-readable result line
403
- const resultMatch = output.match(/\[PR_REVIEW_RESULT:\s*(.+?)\]/);
404
- if (!resultMatch) {
405
- return '⚠️ PR Pre-Review: Missing [PR_REVIEW_RESULT] line in output. The agent may not have followed the prompt correctly.';
406
- }
407
- const resultLine = resultMatch[1].trim();
408
- // NO_CANDIDATES is fine — nothing to review
409
- if (resultLine === 'NO_CANDIDATES') {
410
- return null;
411
- }
412
- // Parse CANDIDATES=N CODE_AGENT_CALLS=M BLOCKED=B
413
- const candidatesMatch = resultLine.match(/CANDIDATES=(\d+)/);
414
- const callsMatch = resultLine.match(/CODE_AGENT_CALLS=(\d+)/);
415
- const blockedMatch = resultLine.match(/BLOCKED=(\d+)/);
416
- const candidates = candidatesMatch ? parseInt(candidatesMatch[1], 10) : 0;
417
- const calls = callsMatch ? parseInt(callsMatch[1], 10) : 0;
418
- const blocked = blockedMatch ? parseInt(blockedMatch[1], 10) : 0;
419
- // If there were candidates but zero code_with_agent calls (and not all blocked), alert
420
- if (candidates > 0 && calls === 0 && blocked < candidates) {
421
- return `⚠️ PR Pre-Review: Found ${candidates} PR candidate(s) but code_with_agent was never called (blocked: ${blocked}). The agent likely wrote inline commentary instead of delegating.`;
422
- }
423
- return null;
424
- }
425
513
  function expandVariables(message) {
426
514
  const now = new Date();
427
515
  const date = now.toLocaleDateString('en-US', {
@@ -429,8 +517,13 @@ function expandVariables(message) {
429
517
  day: 'numeric',
430
518
  year: 'numeric',
431
519
  });
520
+ const mm = String(now.getMonth() + 1).padStart(2, '0');
521
+ const dd = String(now.getDate()).padStart(2, '0');
522
+ const yyyy = now.getFullYear();
523
+ const dateMmDdYyyy = `${mm}-${dd}-${yyyy}`;
432
524
  return message
433
525
  .replace(/\{\{date\}\}/g, date)
526
+ .replace(/\{\{date_mm-dd-yyyy\}\}/g, dateMmDdYyyy)
434
527
  .replace(/\{\{time\}\}/g, now.toLocaleTimeString())
435
528
  .replace(/\{\{iso\}\}/g, now.toISOString());
436
529
  }