nothumanallowed 9.5.2 → 9.7.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.
package/src/config.mjs CHANGED
@@ -283,8 +283,6 @@ export function setConfigValue(key, value) {
283
283
  'my-role': 'profile.role',
284
284
  'role': 'profile.role',
285
285
  'profile-notes': 'profile.notes',
286
- 'language': 'language',
287
- 'lang': 'language',
288
286
  };
289
287
 
290
288
  const resolved = aliases[key] || key;
package/src/constants.mjs CHANGED
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'url';
5
5
  const __filename = fileURLToPath(import.meta.url);
6
6
  const __dirname = path.dirname(__filename);
7
7
 
8
- export const VERSION = '9.5.2';
8
+ export const VERSION = '9.7.0';
9
9
  export const BASE_URL = 'https://nothumanallowed.com/cli';
10
10
  export const API_BASE = 'https://nothumanallowed.com/api/v1';
11
11
 
@@ -11,9 +11,8 @@ import os from 'os';
11
11
  import { saveTokens, loadTokens, deleteTokens } from './token-store.mjs';
12
12
  import { info, ok, fail, warn } from '../ui.mjs';
13
13
 
14
- // NHA published OAuth client — same credentials as the mobile app
15
- const DEFAULT_CLIENT_ID = '740354841334-o0l6rhkn900jtjtfcls3bkr2n9f1l3tt.apps.googleusercontent.com';
16
- const DEFAULT_CLIENT_SECRET = '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';
14
+ // NHA published OAuth client (Desktop app type client_id is not a secret)
15
+ const DEFAULT_CLIENT_ID = ''; // Will be set when Google Cloud project is verified
17
16
  const SCOPES = [
18
17
  'https://www.googleapis.com/auth/gmail.modify',
19
18
  'https://www.googleapis.com/auth/gmail.send',
@@ -80,7 +79,7 @@ function waitForCallback(state, port) {
80
79
  return;
81
80
  }
82
81
 
83
- if (!code || !returnedState?.startsWith(state.split('|')[0])) {
82
+ if (!code || returnedState !== state) {
84
83
  res.writeHead(400, { 'Content-Type': 'text/html' });
85
84
  res.end('<html><body><h2>Invalid callback</h2><p>Missing code or state mismatch.</p></body></html>');
86
85
  server.close();
@@ -160,7 +159,20 @@ async function getUserEmail(accessToken) {
160
159
  */
161
160
  export async function runAuthFlow(config) {
162
161
  const clientId = config.google?.clientId || DEFAULT_CLIENT_ID;
163
- const clientSecret = config.google?.clientSecret || DEFAULT_CLIENT_SECRET;
162
+ const clientSecret = config.google?.clientSecret || '';
163
+
164
+ if (!clientId) {
165
+ fail('Google OAuth client ID not configured.');
166
+ info('Get credentials from Google Cloud Console:');
167
+ info(' 1. Go to https://console.cloud.google.com/apis/credentials');
168
+ info(' 2. Create an OAuth 2.0 Client ID (Desktop app type)');
169
+ info(' 3. Enable Gmail API and Calendar API');
170
+ info(' 4. Run:');
171
+ info(' nha config set google-client-id YOUR_CLIENT_ID');
172
+ info(' nha config set google-client-secret YOUR_CLIENT_SECRET');
173
+ info(' 5. Run: nha google auth');
174
+ return false;
175
+ }
164
176
 
165
177
  // Find available port
166
178
  let port = 0;
@@ -180,16 +192,13 @@ export async function runAuthFlow(config) {
180
192
  return false;
181
193
  }
182
194
 
183
- // Use our server as OAuth redirect (same as mobile app)
184
- // The callback page extracts the code and redirects to localhost
185
- const serverRedirectUri = 'https://nothumanallowed.com/auth/callback';
186
- const localCallbackUrl = `http://127.0.0.1:${port}/callback`;
195
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
187
196
  const { verifier, challenge } = generatePKCE();
188
- const state = crypto.randomBytes(32).toString('hex') + `|localhost:${port}`;
197
+ const state = crypto.randomBytes(32).toString('hex');
189
198
 
190
199
  const authUrl = new URL('https://accounts.google.com/o/oauth2/v2/auth');
191
200
  authUrl.searchParams.set('client_id', clientId);
192
- authUrl.searchParams.set('redirect_uri', serverRedirectUri);
201
+ authUrl.searchParams.set('redirect_uri', redirectUri);
193
202
  authUrl.searchParams.set('response_type', 'code');
194
203
  authUrl.searchParams.set('scope', SCOPES);
195
204
  authUrl.searchParams.set('state', state);
@@ -206,7 +215,7 @@ export async function runAuthFlow(config) {
206
215
  const { code } = await waitForCallback(state, port);
207
216
  info('Authorization code received. Exchanging for tokens...');
208
217
 
209
- const tokenData = await exchangeCode(code, verifier, clientId, clientSecret, serverRedirectUri);
218
+ const tokenData = await exchangeCode(code, verifier, clientId, clientSecret, redirectUri);
210
219
  const email = await getUserEmail(tokenData.access_token);
211
220
 
212
221
  const tokens = {
@@ -276,141 +276,110 @@ export async function callLLM(config, systemPrompt, userMessage, opts = {}) {
276
276
  }
277
277
 
278
278
  /**
279
- * Call an LLM provider with streaming enabled.
280
- * Calls onToken(chunk) for each token, returns full text at the end.
281
- * @returns {Promise<string>} The full LLM response text.
279
+ * Call LLM with multimodal (vision) messages — supports image content.
280
+ * Uses the provider's native vision format.
281
+ * @param {object} config
282
+ * @param {Array} messages - Array of { role, content } where content can be string or array of content blocks
283
+ * @returns {Promise<string>}
282
284
  */
283
- export async function callLLMStream(config, systemPrompt, userMessage, onToken, opts = {}) {
284
- const provider = opts.provider || config.llm.provider || 'anthropic';
285
- const model = opts.model || config.llm.model || null;
285
+ export async function callLLMVision(config, messages) {
286
+ const provider = config.llm.provider || 'anthropic';
287
+ const model = config.llm.model || null;
286
288
  const apiKey = getApiKey(config, provider);
287
- if (!apiKey) throw new Error(`No API key for ${provider}`);
288
-
289
- const callFn = getProviderCall(provider);
290
- if (!callFn) throw new Error(`Unknown provider: ${provider}`);
291
-
292
- // Gemini and Cohere don't support streaming — fall back to non-streaming
293
- if (provider === 'gemini' || provider === 'cohere') {
294
- const text = await callFn(apiKey, model, systemPrompt, userMessage, false);
295
- if (onToken) onToken(text);
296
- return text;
297
- }
298
-
299
- const format = provider === 'anthropic' ? 'anthropic' : 'openai';
300
- const body = buildRequestBody(provider, model, systemPrompt, userMessage, true);
301
- const url = getProviderUrl(provider, model, apiKey);
302
- const headers = getProviderHeaders(provider, apiKey);
303
-
304
- const res = await fetch(url, {
305
- method: 'POST',
306
- headers,
307
- body: JSON.stringify(body),
308
- });
309
- if (!res.ok) {
310
- const err = await res.text();
311
- throw new Error(`${provider} ${res.status}: ${err}`);
312
- }
313
-
314
- return streamSSEWithCallback(res, format, onToken);
315
- }
289
+ if (!apiKey) throw new Error(`No API key for ${provider}. Vision requires Claude, GPT-4, or Gemini.`);
316
290
 
317
- /** Build request body for a provider */
318
- function buildRequestBody(provider, model, systemPrompt, userMessage, stream) {
319
291
  if (provider === 'anthropic') {
320
- return {
292
+ // Anthropic format: system separate, messages with content blocks
293
+ const systemMsg = messages.find(m => m.role === 'system');
294
+ const userMsgs = messages.filter(m => m.role !== 'system');
295
+
296
+ // Convert OpenAI-style image_url to Anthropic format
297
+ const anthropicMessages = userMsgs.map(m => {
298
+ if (typeof m.content === 'string') return m;
299
+ const blocks = m.content.map(block => {
300
+ if (block.type === 'text') return block;
301
+ if (block.type === 'image_url') {
302
+ const url = block.image_url.url;
303
+ const match = url.match(/^data:image\/(png|jpeg|gif|webp);base64,(.+)$/);
304
+ if (match) {
305
+ return { type: 'image', source: { type: 'base64', media_type: `image/${match[1]}`, data: match[2] } };
306
+ }
307
+ }
308
+ return block;
309
+ });
310
+ return { role: m.role, content: blocks };
311
+ });
312
+
313
+ const body = {
321
314
  model: model || 'claude-sonnet-4-20250514',
322
- max_tokens: 8192,
323
- system: systemPrompt,
324
- messages: [{ role: 'user', content: userMessage }],
325
- stream,
315
+ max_tokens: 4096,
316
+ system: systemMsg?.content || '',
317
+ messages: anthropicMessages,
326
318
  };
319
+ const res = await fetch('https://api.anthropic.com/v1/messages', {
320
+ method: 'POST',
321
+ headers: { 'Content-Type': 'application/json', 'x-api-key': apiKey, 'anthropic-version': '2023-06-01' },
322
+ body: JSON.stringify(body),
323
+ });
324
+ if (!res.ok) throw new Error(`Anthropic vision ${res.status}: ${await res.text()}`);
325
+ const data = await res.json();
326
+ return data.content?.[0]?.text || '';
327
327
  }
328
- // OpenAI-compatible format (OpenAI, DeepSeek, Grok, Mistral)
329
- const modelDefaults = {
330
- openai: 'gpt-4o',
331
- deepseek: 'deepseek-chat',
332
- grok: 'grok-3-latest',
333
- mistral: 'mistral-large-latest',
334
- };
335
- return {
336
- model: model || modelDefaults[provider] || 'gpt-4o',
337
- max_tokens: 8192,
338
- messages: [
339
- { role: 'system', content: systemPrompt },
340
- { role: 'user', content: userMessage },
341
- ],
342
- stream,
343
- };
344
- }
345
-
346
- /** Get provider API URL */
347
- function getProviderUrl(provider, model, apiKey) {
348
- const urls = {
349
- anthropic: 'https://api.anthropic.com/v1/messages',
350
- openai: 'https://api.openai.com/v1/chat/completions',
351
- deepseek: 'https://api.deepseek.com/v1/chat/completions',
352
- grok: 'https://api.x.ai/v1/chat/completions',
353
- mistral: 'https://api.mistral.ai/v1/chat/completions',
354
- };
355
- return urls[provider] || urls.openai;
356
- }
357
328
 
358
- /** Get provider request headers */
359
- function getProviderHeaders(provider, apiKey) {
360
- if (provider === 'anthropic') {
361
- return {
362
- 'Content-Type': 'application/json',
363
- 'x-api-key': apiKey,
364
- 'anthropic-version': '2023-06-01',
365
- };
329
+ if (provider === 'openai' || provider === 'deepseek' || provider === 'grok' || provider === 'mistral') {
330
+ // OpenAI-compatible format — works with GPT-4V, DeepSeek VL, etc.
331
+ const url = provider === 'openai' ? 'https://api.openai.com/v1/chat/completions'
332
+ : provider === 'deepseek' ? 'https://api.deepseek.com/chat/completions'
333
+ : provider === 'grok' ? 'https://api.x.ai/v1/chat/completions'
334
+ : 'https://api.mistral.ai/v1/chat/completions';
335
+
336
+ const visionModel = model || (provider === 'openai' ? 'gpt-4o' : model);
337
+ const body = { model: visionModel, max_tokens: 4096, messages };
338
+ const res = await fetch(url, {
339
+ method: 'POST',
340
+ headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${apiKey}` },
341
+ body: JSON.stringify(body),
342
+ });
343
+ if (!res.ok) throw new Error(`${provider} vision ${res.status}: ${await res.text()}`);
344
+ const data = await res.json();
345
+ return data.choices?.[0]?.message?.content || '';
366
346
  }
367
- return {
368
- 'Content-Type': 'application/json',
369
- 'Authorization': `Bearer ${apiKey}`,
370
- };
371
- }
372
-
373
- /** SSE stream parser with onToken callback (does NOT write to stdout directly) */
374
- async function streamSSEWithCallback(res, format, onToken) {
375
- const reader = res.body.getReader();
376
- const decoder = new TextDecoder();
377
- let buffer = '';
378
- let fullText = '';
379
-
380
- while (true) {
381
- const { done, value } = await reader.read();
382
- if (done) break;
383
-
384
- buffer += decoder.decode(value, { stream: true });
385
- const lines = buffer.split('\n');
386
- buffer = lines.pop() || '';
387
-
388
- for (const line of lines) {
389
- if (!line.startsWith('data: ')) continue;
390
- const data = line.slice(6).trim();
391
- if (data === '[DONE]') continue;
392
-
393
- try {
394
- const json = JSON.parse(data);
395
- let chunk = '';
396
347
 
397
- if (format === 'anthropic') {
398
- if (json.type === 'content_block_delta') {
399
- chunk = json.delta?.text || '';
348
+ if (provider === 'gemini') {
349
+ // Gemini format inline_data with base64
350
+ const systemMsg = messages.find(m => m.role === 'system');
351
+ const userMsgs = messages.filter(m => m.role !== 'system');
352
+ const parts = [];
353
+ for (const msg of userMsgs) {
354
+ if (typeof msg.content === 'string') {
355
+ parts.push({ text: msg.content });
356
+ } else {
357
+ for (const block of msg.content) {
358
+ if (block.type === 'text') parts.push({ text: block.text });
359
+ if (block.type === 'image_url') {
360
+ const match = block.image_url.url.match(/^data:image\/(.*?);base64,(.+)$/);
361
+ if (match) parts.push({ inline_data: { mime_type: `image/${match[1]}`, data: match[2] } });
400
362
  }
401
- } else {
402
- chunk = json.choices?.[0]?.delta?.content || '';
403
- }
404
-
405
- if (chunk) {
406
- fullText += chunk;
407
- if (onToken) onToken(chunk);
408
363
  }
409
- } catch {}
364
+ }
410
365
  }
366
+
367
+ const geminiModel = model || 'gemini-2.0-flash';
368
+ const body = {
369
+ contents: [{ parts }],
370
+ systemInstruction: systemMsg ? { parts: [{ text: systemMsg.content }] } : undefined,
371
+ };
372
+ const res = await fetch(`https://generativelanguage.googleapis.com/v1beta/models/${geminiModel}:generateContent?key=${apiKey}`, {
373
+ method: 'POST',
374
+ headers: { 'Content-Type': 'application/json' },
375
+ body: JSON.stringify(body),
376
+ });
377
+ if (!res.ok) throw new Error(`Gemini vision ${res.status}: ${await res.text()}`);
378
+ const data = await res.json();
379
+ return data.candidates?.[0]?.content?.parts?.[0]?.text || '';
411
380
  }
412
381
 
413
- return fullText;
382
+ throw new Error(`Vision not supported for provider: ${provider}. Use Claude, GPT-4, or Gemini.`);
414
383
  }
415
384
 
416
385
  /**
@@ -36,8 +36,237 @@ const STATE_FILE = path.join(DAEMON_DIR, 'state.json');
36
36
  const LOG_FILE = path.join(DAEMON_DIR, 'daemon.log');
37
37
  const BRIEFS_DIR = path.join(NHA_DIR, 'ops', 'briefs');
38
38
  const INSIGHTS_DIR = path.join(NHA_DIR, 'ops', 'insights');
39
+ const CRON_FILE = path.join(DAEMON_DIR, 'cron.json');
40
+ const HEARTBEAT_FILE = path.join(DAEMON_DIR, 'heartbeat.json');
39
41
  const WS_PORT = 3848;
40
42
 
43
+ // ── Cron / Heartbeat Persistence ────────────────────────────────────────────
44
+
45
+ function loadCronJobs() {
46
+ if (!fs.existsSync(CRON_FILE)) return [];
47
+ try { return JSON.parse(fs.readFileSync(CRON_FILE, 'utf-8')); } catch { return []; }
48
+ }
49
+
50
+ function saveCronJobs(jobs) {
51
+ fs.mkdirSync(DAEMON_DIR, { recursive: true });
52
+ fs.writeFileSync(CRON_FILE, JSON.stringify(jobs, null, 2), { mode: 0o600 });
53
+ }
54
+
55
+ function loadHeartbeats() {
56
+ if (!fs.existsSync(HEARTBEAT_FILE)) return [];
57
+ try { return JSON.parse(fs.readFileSync(HEARTBEAT_FILE, 'utf-8')); } catch { return []; }
58
+ }
59
+
60
+ function saveHeartbeats(beats) {
61
+ fs.mkdirSync(DAEMON_DIR, { recursive: true });
62
+ fs.writeFileSync(HEARTBEAT_FILE, JSON.stringify(beats, null, 2), { mode: 0o600 });
63
+ }
64
+
65
+ /**
66
+ * Parse a human-readable schedule into a cron-like spec.
67
+ * Supports:
68
+ * "every 5m" / "every 2h" / "every 30min"
69
+ * "every monday 9am" / "every friday 17:00"
70
+ * "every day 8:30" / "daily 9am"
71
+ * "every hour"
72
+ * "at 14:00" (once daily)
73
+ *
74
+ * Returns { type: 'interval'|'daily'|'weekly', intervalMs?, hour?, minute?, dayOfWeek? }
75
+ */
76
+ function parseSchedule(schedule) {
77
+ const s = schedule.toLowerCase().trim();
78
+
79
+ // Interval: "every 5m", "every 2h", "every 30min", "every 1 hour"
80
+ const intervalMatch = s.match(/every\s+(\d+)\s*(m(?:in(?:utes?)?)?|h(?:ours?)?|s(?:ec(?:onds?)?)?)/);
81
+ if (intervalMatch) {
82
+ const num = parseInt(intervalMatch[1]);
83
+ const unit = intervalMatch[2][0]; // m, h, or s
84
+ const ms = unit === 'h' ? num * 3_600_000 : unit === 'm' ? num * 60_000 : num * 1_000;
85
+ return { type: 'interval', intervalMs: Math.max(ms, 60_000) }; // min 1 minute
86
+ }
87
+
88
+ // "every hour"
89
+ if (s === 'every hour' || s === 'hourly') {
90
+ return { type: 'interval', intervalMs: 3_600_000 };
91
+ }
92
+
93
+ // Weekly: "every monday 9am", "every friday 17:00"
94
+ const weeklyMatch = s.match(/every\s+(monday|tuesday|wednesday|thursday|friday|saturday|sunday)\s+(?:at\s+)?(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/);
95
+ if (weeklyMatch) {
96
+ const days = { sunday: 0, monday: 1, tuesday: 2, wednesday: 3, thursday: 4, friday: 5, saturday: 6 };
97
+ let hour = parseInt(weeklyMatch[2]);
98
+ const minute = parseInt(weeklyMatch[3] || '0');
99
+ const ampm = weeklyMatch[4];
100
+ if (ampm === 'pm' && hour < 12) hour += 12;
101
+ if (ampm === 'am' && hour === 12) hour = 0;
102
+ return { type: 'weekly', dayOfWeek: days[weeklyMatch[1]], hour, minute };
103
+ }
104
+
105
+ // Daily: "every day 8:30", "daily 9am", "at 14:00"
106
+ const dailyMatch = s.match(/(?:every\s*day|daily|at)\s+(\d{1,2})(?::(\d{2}))?\s*(am|pm)?/);
107
+ if (dailyMatch) {
108
+ let hour = parseInt(dailyMatch[1]);
109
+ const minute = parseInt(dailyMatch[2] || '0');
110
+ const ampm = dailyMatch[3];
111
+ if (ampm === 'pm' && hour < 12) hour += 12;
112
+ if (ampm === 'am' && hour === 12) hour = 0;
113
+ return { type: 'daily', hour, minute };
114
+ }
115
+
116
+ return null;
117
+ }
118
+
119
+ /**
120
+ * Check if a cron job should run now (within the current minute).
121
+ */
122
+ function shouldRunNow(spec, lastRun) {
123
+ const now = new Date();
124
+
125
+ if (spec.type === 'interval') {
126
+ if (!lastRun) return true;
127
+ return (now.getTime() - lastRun) >= spec.intervalMs;
128
+ }
129
+
130
+ if (spec.type === 'daily') {
131
+ const nowMinutes = now.getHours() * 60 + now.getMinutes();
132
+ const targetMinutes = spec.hour * 60 + spec.minute;
133
+ // Run if we're in the right minute and haven't run today
134
+ if (Math.abs(nowMinutes - targetMinutes) > 1) return false;
135
+ if (lastRun) {
136
+ const lastRunDate = new Date(lastRun);
137
+ if (lastRunDate.toDateString() === now.toDateString()) return false;
138
+ }
139
+ return true;
140
+ }
141
+
142
+ if (spec.type === 'weekly') {
143
+ if (now.getDay() !== spec.dayOfWeek) return false;
144
+ const nowMinutes = now.getHours() * 60 + now.getMinutes();
145
+ const targetMinutes = spec.hour * 60 + spec.minute;
146
+ if (Math.abs(nowMinutes - targetMinutes) > 1) return false;
147
+ if (lastRun) {
148
+ const lastRunDate = new Date(lastRun);
149
+ const daysDiff = (now.getTime() - lastRunDate.getTime()) / 86_400_000;
150
+ if (daysDiff < 1) return false;
151
+ }
152
+ return true;
153
+ }
154
+
155
+ return false;
156
+ }
157
+
158
+ /**
159
+ * Add a cron job. Returns the job object.
160
+ */
161
+ export function addCronJob(schedule, prompt, options = {}) {
162
+ const spec = parseSchedule(schedule);
163
+ if (!spec) return { ok: false, error: `Cannot parse schedule: "${schedule}"` };
164
+
165
+ const jobs = loadCronJobs();
166
+ const job = {
167
+ id: crypto.randomUUID(),
168
+ schedule,
169
+ spec,
170
+ prompt,
171
+ agent: options.agent || null, // null = auto-route
172
+ notify: options.notify !== false, // default: send notification
173
+ enabled: true,
174
+ createdAt: new Date().toISOString(),
175
+ lastRun: null,
176
+ lastResult: null,
177
+ runCount: 0,
178
+ };
179
+ jobs.push(job);
180
+ saveCronJobs(jobs);
181
+
182
+ // Notify daemon if running (it will pick up changes on next tick)
183
+ if (isRunning()) {
184
+ wsBroadcast({ type: 'cron_added', data: { id: job.id, schedule, prompt } });
185
+ }
186
+
187
+ return { ok: true, job };
188
+ }
189
+
190
+ /**
191
+ * Remove a cron job by ID or index.
192
+ */
193
+ export function removeCronJob(idOrIndex) {
194
+ const jobs = loadCronJobs();
195
+ let removed;
196
+ if (typeof idOrIndex === 'number' || /^\d+$/.test(idOrIndex)) {
197
+ const idx = parseInt(idOrIndex) - 1; // 1-based
198
+ if (idx < 0 || idx >= jobs.length) return { ok: false, error: 'Invalid job index' };
199
+ removed = jobs.splice(idx, 1)[0];
200
+ } else {
201
+ const idx = jobs.findIndex(j => j.id === idOrIndex);
202
+ if (idx === -1) return { ok: false, error: 'Job not found' };
203
+ removed = jobs.splice(idx, 1)[0];
204
+ }
205
+ saveCronJobs(jobs);
206
+ return { ok: true, removed };
207
+ }
208
+
209
+ /**
210
+ * List all cron jobs.
211
+ */
212
+ export function listCronJobs() {
213
+ return loadCronJobs();
214
+ }
215
+
216
+ /**
217
+ * Add a heartbeat (recurring prompt at fixed interval).
218
+ * Convenience wrapper over cron with interval spec.
219
+ */
220
+ export function addHeartbeat(intervalStr, prompt, options = {}) {
221
+ return addCronJob(`every ${intervalStr}`, prompt, options);
222
+ }
223
+
224
+ /**
225
+ * Execute cron jobs that are due. Called from daemon loop.
226
+ */
227
+ async function executeDueCronJobs(config) {
228
+ const jobs = loadCronJobs();
229
+ let changed = false;
230
+
231
+ for (const job of jobs) {
232
+ if (!job.enabled) continue;
233
+ if (!shouldRunNow(job.spec, job.lastRun)) continue;
234
+
235
+ log(`[Cron] Executing: "${job.prompt}" (schedule: ${job.schedule})`);
236
+
237
+ try {
238
+ const agent = job.agent || 'conductor';
239
+ const result = await callAgent(config, agent, job.prompt);
240
+ job.lastRun = Date.now();
241
+ job.lastResult = result.slice(0, 500); // cap stored result
242
+ job.runCount++;
243
+ changed = true;
244
+
245
+ log(`[Cron] Completed: "${job.prompt}" → ${result.slice(0, 100)}...`);
246
+
247
+ // Broadcast result to connected clients
248
+ wsBroadcast({
249
+ type: 'cron_result',
250
+ timestamp: new Date().toISOString(),
251
+ data: { id: job.id, schedule: job.schedule, prompt: job.prompt, result: result.slice(0, 500) },
252
+ });
253
+
254
+ // Send notification if enabled
255
+ if (job.notify) {
256
+ await notify('Scheduled Task', `${job.prompt}\n\n${result.slice(0, 200)}`, config);
257
+ }
258
+ } catch (err) {
259
+ log(`[Cron] Error executing "${job.prompt}": ${err.message}`);
260
+ job.lastRun = Date.now();
261
+ job.lastResult = `ERROR: ${err.message}`;
262
+ job.runCount++;
263
+ changed = true;
264
+ }
265
+ }
266
+
267
+ if (changed) saveCronJobs(jobs);
268
+ }
269
+
41
270
  // ── Daemon Control ─────────────────────────────────────────────────────────
42
271
 
43
272
  /**
@@ -837,6 +1066,13 @@ async function daemonLoop() {
837
1066
  }
838
1067
  }
839
1068
 
1069
+ // ── User-defined Cron Jobs ────────────────────────────
1070
+ try {
1071
+ await executeDueCronJobs(config);
1072
+ } catch (err) {
1073
+ log(`[Cron] Execution error: ${err.message}`);
1074
+ }
1075
+
840
1076
  // Reset daily flags at midnight
841
1077
  if (currentTime === '00:00') {
842
1078
  todayPlanDone = false;