fluxy-bot 0.8.9 → 0.9.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 (106) hide show
  1. package/README.md +53 -84
  2. package/bin/cli.js +1717 -6
  3. package/components.json +18 -18
  4. package/package.json +98 -109
  5. package/postcss.config.js +3 -3
  6. package/scripts/install.ps1 +1 -1
  7. package/scripts/install.sh +1 -1
  8. package/scripts/postinstall.js +47 -58
  9. package/shared/ai.ts +118 -202
  10. package/shared/config.ts +37 -39
  11. package/shared/logger.ts +6 -14
  12. package/shared/paths.ts +10 -16
  13. package/shared/relay.ts +89 -98
  14. package/supervisor/backend.ts +77 -89
  15. package/supervisor/chat/ARCHITECTURE.md +13 -14
  16. package/supervisor/chat/OnboardWizard.tsx +2194 -3419
  17. package/supervisor/chat/fluxy-main.tsx +495 -633
  18. package/supervisor/chat/fluxy.html +24 -36
  19. package/supervisor/chat/onboard-main.tsx +8 -8
  20. package/supervisor/chat/onboard.html +10 -13
  21. package/supervisor/chat/src/components/Chat/AudioBubble.tsx +94 -108
  22. package/supervisor/chat/src/components/Chat/ChatView.tsx +14 -36
  23. package/supervisor/chat/src/components/Chat/ImageLightbox.tsx +71 -89
  24. package/supervisor/chat/src/components/Chat/InputBar.tsx +498 -728
  25. package/supervisor/chat/src/components/Chat/MessageBubble.tsx +154 -246
  26. package/supervisor/chat/src/components/Chat/MessageList.tsx +114 -138
  27. package/supervisor/chat/src/components/Chat/TypingIndicator.tsx +38 -50
  28. package/supervisor/chat/src/components/LoginScreen.tsx +245 -296
  29. package/supervisor/chat/src/hooks/useChat.ts +210 -259
  30. package/supervisor/chat/src/hooks/useFluxyChat.ts +232 -322
  31. package/supervisor/chat/src/hooks/useSpeechRecognition.ts +126 -148
  32. package/supervisor/chat/src/lib/auth.ts +20 -23
  33. package/supervisor/chat/src/lib/ws-client.ts +113 -125
  34. package/supervisor/chat/src/styles/globals.css +76 -85
  35. package/supervisor/file-saver.ts +39 -52
  36. package/supervisor/fluxy-agent.ts +213 -292
  37. package/supervisor/index.ts +895 -1263
  38. package/supervisor/scheduler.ts +237 -299
  39. package/supervisor/tunnel.ts +113 -167
  40. package/supervisor/vite-dev.ts +51 -61
  41. package/supervisor/widget.js +135 -147
  42. package/supervisor/worker.ts +45 -49
  43. package/tsconfig.json +18 -24
  44. package/vite.config.ts +48 -55
  45. package/vite.fluxy.config.ts +34 -34
  46. package/worker/claude-auth.ts +202 -265
  47. package/worker/codex-auth.ts +144 -181
  48. package/worker/db.ts +63 -191
  49. package/worker/index.ts +601 -842
  50. package/workspace/MYSELF.md +1 -1
  51. package/workspace/PULSE.json +6 -6
  52. package/workspace/backend/index.ts +16 -19
  53. package/workspace/client/index.html +47 -60
  54. package/workspace/client/public/manifest.json +21 -21
  55. package/workspace/client/public/sw.js +29 -31
  56. package/workspace/client/src/App.tsx +91 -136
  57. package/workspace/client/src/components/Dashboard/DashboardPage.tsx +68 -72
  58. package/workspace/client/src/components/ErrorBoundary.tsx +11 -11
  59. package/workspace/client/src/components/Layout/DashboardLayout.tsx +35 -35
  60. package/workspace/client/src/components/Layout/Footer.tsx +12 -12
  61. package/workspace/client/src/components/Layout/MobileNav.tsx +23 -23
  62. package/workspace/client/src/components/Layout/Sidebar.tsx +81 -81
  63. package/workspace/client/src/components/ui/avatar.tsx +85 -85
  64. package/workspace/client/src/components/ui/badge.tsx +40 -41
  65. package/workspace/client/src/components/ui/button.tsx +56 -57
  66. package/workspace/client/src/components/ui/card.tsx +75 -75
  67. package/workspace/client/src/components/ui/dialog.tsx +114 -117
  68. package/workspace/client/src/components/ui/dropdown-menu.tsx +191 -199
  69. package/workspace/client/src/components/ui/input.tsx +17 -17
  70. package/workspace/client/src/components/ui/scroll-area.tsx +47 -47
  71. package/workspace/client/src/components/ui/select.tsx +145 -151
  72. package/workspace/client/src/components/ui/separator.tsx +21 -21
  73. package/workspace/client/src/components/ui/sheet.tsx +102 -102
  74. package/workspace/client/src/components/ui/skeleton.tsx +10 -10
  75. package/workspace/client/src/components/ui/switch.tsx +26 -26
  76. package/workspace/client/src/components/ui/tabs.tsx +69 -69
  77. package/workspace/client/src/components/ui/textarea.tsx +14 -14
  78. package/workspace/client/src/components/ui/tooltip.tsx +37 -37
  79. package/workspace/client/src/lib/utils.ts +1 -1
  80. package/workspace/client/src/main.tsx +3 -3
  81. package/workspace/client/src/styles/globals.css +76 -85
  82. package/workspace/skills/code-reviewer/.claude-plugin/plugin.json +3 -3
  83. package/workspace/skills/code-reviewer/skills/code-reviewer/SKILL.md +0 -6
  84. package/workspace/skills/daily-standup/.claude-plugin/plugin.json +3 -3
  85. package/workspace/skills/daily-standup/skills/daily-standup/SKILL.md +0 -7
  86. package/workspace/skills/workspace-helper/.claude-plugin/plugin.json +3 -3
  87. package/workspace/skills/workspace-helper/skills/workspace-helper/SKILL.md +0 -2
  88. package/bin/cli.backup.js +0 -2138
  89. package/cli/commands/daemon.ts +0 -42
  90. package/cli/commands/init.ts +0 -32
  91. package/cli/commands/start.ts +0 -113
  92. package/cli/commands/tunnel.temp.ts +0 -206
  93. package/cli/commands/tunnel.ts +0 -227
  94. package/cli/commands/update.ts +0 -163
  95. package/cli/core/base-adapter.ts +0 -156
  96. package/cli/core/cloudflared.ts +0 -113
  97. package/cli/core/config.ts +0 -73
  98. package/cli/core/os-detector.ts +0 -43
  99. package/cli/core/server.ts +0 -109
  100. package/cli/core/types.ts +0 -15
  101. package/cli/index.ts +0 -72
  102. package/cli/platforms/darwin.ts +0 -146
  103. package/cli/platforms/index.ts +0 -21
  104. package/cli/platforms/linux.ts +0 -156
  105. package/cli/platforms/win32.ts +0 -34
  106. package/cli/utils/ui.ts +0 -37
@@ -15,24 +15,24 @@ const PULSE_FILE = path.join(WORKSPACE_DIR, 'PULSE.json');
15
15
  const CRONS_FILE = path.join(WORKSPACE_DIR, 'CRONS.json');
16
16
 
17
17
  interface PulseConfig {
18
- enabled: boolean;
19
- intervalMinutes: number;
20
- quietHours: { start: string; end: string };
18
+ enabled: boolean;
19
+ intervalMinutes: number;
20
+ quietHours: { start: string; end: string };
21
21
  }
22
22
 
23
23
  interface CronConfig {
24
- id: string;
25
- schedule: string;
26
- task: string;
27
- enabled: boolean;
28
- oneShot?: boolean;
24
+ id: string;
25
+ schedule: string;
26
+ task: string;
27
+ enabled: boolean;
28
+ oneShot?: boolean;
29
29
  }
30
30
 
31
31
  interface SchedulerOpts {
32
- broadcastFluxy: (type: string, data: any) => void;
33
- workerApi: (path: string, method?: string, body?: any) => Promise<any>;
34
- restartBackend: () => void;
35
- getModel: () => string;
32
+ broadcastFluxy: (type: string, data: any) => void;
33
+ workerApi: (path: string, method?: string, body?: any) => Promise<any>;
34
+ restartBackend: () => void;
35
+ getModel: () => string;
36
36
  }
37
37
 
38
38
  // State
@@ -42,330 +42,268 @@ let intervalHandle: ReturnType<typeof setInterval> | null = null;
42
42
  let schedulerOpts: SchedulerOpts | null = null;
43
43
 
44
44
  function readPulseConfig(): PulseConfig {
45
- try {
46
- const raw = fs.readFileSync(PULSE_FILE, 'utf-8');
47
- const parsed = JSON.parse(raw);
48
- return {
49
- enabled: !!parsed.enabled,
50
- intervalMinutes: parsed.intervalMinutes || 30,
51
- quietHours: parsed.quietHours || { start: '23:00', end: '07:00' }
52
- };
53
- } catch {
54
- return {
55
- enabled: false,
56
- intervalMinutes: 30,
57
- quietHours: { start: '23:00', end: '07:00' }
58
- };
59
- }
45
+ try {
46
+ const raw = fs.readFileSync(PULSE_FILE, 'utf-8');
47
+ const parsed = JSON.parse(raw);
48
+ return {
49
+ enabled: !!parsed.enabled,
50
+ intervalMinutes: parsed.intervalMinutes || 30,
51
+ quietHours: parsed.quietHours || { start: '23:00', end: '07:00' },
52
+ };
53
+ } catch {
54
+ return { enabled: false, intervalMinutes: 30, quietHours: { start: '23:00', end: '07:00' } };
55
+ }
60
56
  }
61
57
 
62
58
  function readCronsConfig(): CronConfig[] {
63
- try {
64
- const raw = fs.readFileSync(CRONS_FILE, 'utf-8');
65
- const parsed = JSON.parse(raw);
66
- return Array.isArray(parsed) ? parsed : [];
67
- } catch {
68
- return [];
69
- }
59
+ try {
60
+ const raw = fs.readFileSync(CRONS_FILE, 'utf-8');
61
+ const parsed = JSON.parse(raw);
62
+ return Array.isArray(parsed) ? parsed : [];
63
+ } catch {
64
+ return [];
65
+ }
70
66
  }
71
67
 
72
68
  /** Write crons back to disk (for oneShot cleanup) */
73
69
  function writeCronsConfig(crons: CronConfig[]) {
74
- try {
75
- fs.writeFileSync(
76
- CRONS_FILE,
77
- JSON.stringify(crons, null, 2) + '\n',
78
- 'utf-8'
79
- );
80
- } catch (err: any) {
81
- log.warn(`[scheduler] Failed to write CRONS.json: ${err.message}`);
82
- }
70
+ try {
71
+ fs.writeFileSync(CRONS_FILE, JSON.stringify(crons, null, 2) + '\n', 'utf-8');
72
+ } catch (err: any) {
73
+ log.warn(`[scheduler] Failed to write CRONS.json: ${err.message}`);
74
+ }
83
75
  }
84
76
 
85
77
  function isInQuietHours(quietHours: { start: string; end: string }): boolean {
86
- const now = new Date();
87
- const [startH, startM] = quietHours.start.split(':').map(Number);
88
- const [endH, endM] = quietHours.end.split(':').map(Number);
89
- const currentMinutes = now.getHours() * 60 + now.getMinutes();
90
- const startMinutes = startH * 60 + startM;
91
- const endMinutes = endH * 60 + endM;
92
-
93
- if (startMinutes <= endMinutes) {
94
- return currentMinutes >= startMinutes && currentMinutes < endMinutes;
95
- } else {
96
- return currentMinutes >= startMinutes || currentMinutes < endMinutes;
97
- }
78
+ const now = new Date();
79
+ const [startH, startM] = quietHours.start.split(':').map(Number);
80
+ const [endH, endM] = quietHours.end.split(':').map(Number);
81
+ const currentMinutes = now.getHours() * 60 + now.getMinutes();
82
+ const startMinutes = startH * 60 + startM;
83
+ const endMinutes = endH * 60 + endM;
84
+
85
+ if (startMinutes <= endMinutes) {
86
+ return currentMinutes >= startMinutes && currentMinutes < endMinutes;
87
+ } else {
88
+ return currentMinutes >= startMinutes || currentMinutes < endMinutes;
89
+ }
98
90
  }
99
91
 
100
92
  /** Check if a cron schedule has no future occurrences (expired one-shot) */
101
93
  function cronIsExpired(schedule: string): boolean {
102
- try {
103
- const interval = CronExpressionParser.parse(schedule);
104
- interval.next(); // throws if no future occurrence
105
- return false;
106
- } catch {
107
- return true;
108
- }
94
+ try {
95
+ const interval = CronExpressionParser.parse(schedule);
96
+ interval.next(); // throws if no future occurrence
97
+ return false;
98
+ } catch {
99
+ return true;
100
+ }
109
101
  }
110
102
 
111
103
  function cronMatchesNow(schedule: string): boolean {
112
- try {
113
- const interval = CronExpressionParser.parse(schedule);
114
- const prev = interval.prev().toDate();
115
- const now = new Date();
116
- return (
117
- prev.getFullYear() === now.getFullYear() &&
118
- prev.getMonth() === now.getMonth() &&
119
- prev.getDate() === now.getDate() &&
120
- prev.getHours() === now.getHours() &&
121
- prev.getMinutes() === now.getMinutes()
122
- );
123
- } catch {
124
- return false;
125
- }
104
+ try {
105
+ const interval = CronExpressionParser.parse(schedule);
106
+ const prev = interval.prev().toDate();
107
+ const now = new Date();
108
+ return (
109
+ prev.getFullYear() === now.getFullYear() &&
110
+ prev.getMonth() === now.getMonth() &&
111
+ prev.getDate() === now.getDate() &&
112
+ prev.getHours() === now.getHours() &&
113
+ prev.getMinutes() === now.getMinutes()
114
+ );
115
+ } catch {
116
+ return false;
117
+ }
126
118
  }
127
119
 
128
120
  function triggerAgent(prompt: string, label: string, onComplete?: () => void) {
129
- if (!schedulerOpts) return;
130
- const { broadcastFluxy, workerApi, restartBackend, getModel } =
131
- schedulerOpts;
132
- const timestamp = Date.now();
133
- const convId = label.startsWith('pulse')
134
- ? `pulse-${timestamp}`
135
- : `cron-${label}-${timestamp}`;
136
- const model = getModel();
137
-
138
- log.info(`[scheduler] ${label} triggered — starting agent query`);
139
-
140
- (async () => {
141
- // Use the user's current conversation (or create one)
142
- let dbConvId: string | undefined;
143
- try {
144
- const ctx = await workerApi('/api/context/current');
145
- if (ctx.conversationId) {
146
- dbConvId = ctx.conversationId;
147
- } else {
148
- const conv = await workerApi('/api/conversations', 'POST', {
149
- title: `Chat`,
150
- model
151
- });
152
- dbConvId = conv.id;
153
- await workerApi('/api/context/set', 'POST', {
154
- conversationId: dbConvId
155
- });
156
- }
157
- } catch (err: any) {
158
- log.warn(
159
- `[scheduler] Failed to get/create conversation for ${label}: ${err.message}`
160
- );
161
- }
121
+ if (!schedulerOpts) return;
122
+ const { broadcastFluxy, workerApi, restartBackend, getModel } = schedulerOpts;
123
+ const timestamp = Date.now();
124
+ const convId = label.startsWith('pulse') ? `pulse-${timestamp}` : `cron-${label}-${timestamp}`;
125
+ const model = getModel();
162
126
 
163
- // Fetch bot name for push notification title
164
- let botName = 'Fluxy';
165
- try {
166
- const status = await workerApi('/api/onboard/status');
167
- if (status.agentName) botName = status.agentName;
168
- } catch {}
127
+ log.info(`[scheduler] ${label} triggered starting agent query`);
169
128
 
170
- let fullResponse = '';
129
+ (async () => {
130
+ // Use the user's current conversation (or create one)
131
+ let dbConvId: string | undefined;
132
+ try {
133
+ const ctx = await workerApi('/api/context/current');
134
+ if (ctx.conversationId) {
135
+ dbConvId = ctx.conversationId;
136
+ } else {
137
+ const conv = await workerApi('/api/conversations', 'POST', {
138
+ title: `Chat`,
139
+ model,
140
+ });
141
+ dbConvId = conv.id;
142
+ await workerApi('/api/context/set', 'POST', { conversationId: dbConvId });
143
+ }
144
+ } catch (err: any) {
145
+ log.warn(`[scheduler] Failed to get/create conversation for ${label}: ${err.message}`);
146
+ }
171
147
 
172
- startFluxyAgentQuery(convId, prompt, model, (type, eventData) => {
173
- if (type === 'bot:response') {
174
- fullResponse = eventData.content || '';
148
+ // Fetch bot name for push notification title
149
+ let botName = 'Fluxy';
150
+ try {
151
+ const status = await workerApi('/api/onboard/status');
152
+ if (status.agentName) botName = status.agentName;
153
+ } catch {}
154
+
155
+ let fullResponse = '';
156
+
157
+ startFluxyAgentQuery(convId, prompt, model, (type, eventData) => {
158
+ if (type === 'bot:response') {
159
+ fullResponse = eventData.content || '';
160
+ }
161
+
162
+ if (type === 'bot:done') {
163
+ // Extract <Message> blocks after agent turn completes
164
+ if (fullResponse) {
165
+ const messageRegex = /<Message(?:\s+([^>]*))?>(([\s\S]*?))<\/Message>/g;
166
+ let match;
167
+ while ((match = messageRegex.exec(fullResponse)) !== null) {
168
+ const attrs = match[1] || '';
169
+ const messageContent = match[2].trim();
170
+ const titleMatch = attrs.match(/title="([^"]*)"/);
171
+
172
+ log.info(`[scheduler] Agent message: ${messageContent.slice(0, 80)}...`);
173
+
174
+ const msgTimestamp = new Date().toISOString();
175
+
176
+ // Save to the user's conversation in DB
177
+ if (dbConvId) {
178
+ workerApi(`/api/conversations/${dbConvId}/messages`, 'POST', {
179
+ role: 'assistant',
180
+ content: messageContent,
181
+ meta: { model },
182
+ }).catch((err: any) => {
183
+ log.warn(`[scheduler] DB persist error: ${err.message}`);
184
+ });
175
185
  }
176
186
 
177
- if (type === 'bot:done') {
178
- // Extract <Message> blocks after agent turn completes
179
- if (fullResponse) {
180
- const messageRegex =
181
- /<Message(?:\s+([^>]*))?>(([\s\S]*?))<\/Message>/g;
182
- let match;
183
- while ((match = messageRegex.exec(fullResponse)) !== null) {
184
- const attrs = match[1] || '';
185
- const messageContent = match[2].trim();
186
- const titleMatch = attrs.match(/title="([^"]*)"/);
187
-
188
- log.info(
189
- `[scheduler] Agent message: ${messageContent.slice(0, 80)}...`
190
- );
191
-
192
- const msgTimestamp = new Date().toISOString();
193
-
194
- // Save to the user's conversation in DB
195
- if (dbConvId) {
196
- workerApi(
197
- `/api/conversations/${dbConvId}/messages`,
198
- 'POST',
199
- {
200
- role: 'assistant',
201
- content: messageContent,
202
- meta: { model }
203
- }
204
- ).catch((err: any) => {
205
- log.warn(
206
- `[scheduler] DB persist error: ${err.message}`
207
- );
208
- });
209
- }
210
-
211
- // Broadcast as a regular message to all connected clients
212
- broadcastFluxy('chat:sync', {
213
- conversationId: dbConvId,
214
- message: {
215
- role: 'assistant',
216
- content: messageContent,
217
- timestamp: msgTimestamp
218
- }
219
- });
220
-
221
- // Send push notification for closed tabs / locked devices
222
- workerApi('/api/push/send', 'POST', {
223
- title: titleMatch?.[1] || botName,
224
- body: messageContent.slice(0, 200),
225
- tag: `fluxy-${label}`,
226
- url: '/'
227
- })
228
- .then((r: any) => {
229
- log.info(
230
- `[scheduler] Push sent: ${r.sent}/${r.total}`
231
- );
232
- })
233
- .catch((err: any) => {
234
- log.warn(
235
- `[scheduler] Push send failed: ${err.message}`
236
- );
237
- });
238
- }
239
- }
240
-
241
- log.info(`[scheduler] ${label} agent query complete`);
242
- if (eventData.usedFileTools) {
243
- log.info(
244
- `[scheduler] File tools used — restarting backend`
245
- );
246
- restartBackend();
247
- }
248
- onComplete?.();
249
- }
187
+ // Broadcast as a regular message to all connected clients
188
+ broadcastFluxy('chat:sync', {
189
+ conversationId: dbConvId,
190
+ message: { role: 'assistant', content: messageContent, timestamp: msgTimestamp },
191
+ });
192
+
193
+ // Send push notification for closed tabs / locked devices
194
+ workerApi('/api/push/send', 'POST', {
195
+ title: titleMatch?.[1] || botName,
196
+ body: messageContent.slice(0, 200),
197
+ tag: `fluxy-${label}`,
198
+ url: '/',
199
+ }).then((r: any) => {
200
+ log.info(`[scheduler] Push sent: ${r.sent}/${r.total}`);
201
+ }).catch((err: any) => {
202
+ log.warn(`[scheduler] Push send failed: ${err.message}`);
203
+ });
204
+ }
205
+ }
250
206
 
251
- if (type === 'bot:error') {
252
- log.warn(
253
- `[scheduler] ${label} agent error: ${eventData.error}`
254
- );
255
- }
256
- });
257
- })();
207
+ log.info(`[scheduler] ${label} agent query complete`);
208
+ if (eventData.usedFileTools) {
209
+ log.info(`[scheduler] File tools used — restarting backend`);
210
+ restartBackend();
211
+ }
212
+ onComplete?.();
213
+ }
214
+
215
+ if (type === 'bot:error') {
216
+ log.warn(`[scheduler] ${label} agent error: ${eventData.error}`);
217
+ }
218
+ });
219
+ })();
258
220
  }
259
221
 
260
222
  function tick() {
261
- const now = Date.now();
262
-
263
- // ── Pulse check ──
264
- const pulse = readPulseConfig();
265
- if (pulse.enabled && !isInQuietHours(pulse.quietHours)) {
266
- const elapsed = now - lastPulseTime;
267
- const intervalMs = pulse.intervalMinutes * 60 * 1000;
268
- if (elapsed >= intervalMs) {
269
- lastPulseTime = now;
270
- triggerAgent('<PULSE/>', 'pulse');
271
- }
223
+ const now = Date.now();
224
+
225
+ // ── Pulse check ──
226
+ const pulse = readPulseConfig();
227
+ if (pulse.enabled && !isInQuietHours(pulse.quietHours)) {
228
+ const elapsed = now - lastPulseTime;
229
+ const intervalMs = pulse.intervalMinutes * 60 * 1000;
230
+ if (elapsed >= intervalMs) {
231
+ lastPulseTime = now;
232
+ triggerAgent('<PULSE/>', 'pulse');
272
233
  }
273
-
274
- // ── Cron check ──
275
- const crons = readCronsConfig();
276
-
277
- for (const cron of crons) {
278
- if (!cron.enabled || !cron.id || !cron.schedule) continue;
279
-
280
- const matches = cronMatchesNow(cron.schedule);
281
- const nowDate = new Date();
282
- log.info(
283
- `[scheduler] Cron "${cron.id}" schedule="${cron.schedule}" matches=${matches} now=${nowDate.getHours()}:${String(nowDate.getMinutes()).padStart(2, '0')}`
284
- );
285
-
286
- if (matches) {
287
- const lastRun = lastCronRuns.get(cron.id) || 0;
288
- const oneMinuteAgo = now - 60_000;
289
- if (lastRun < oneMinuteAgo) {
290
- lastCronRuns.set(cron.id, now);
291
- // One-shots: defer removal until agent completes (so agent can still read CRONS.json)
292
- const onComplete = cron.oneShot
293
- ? () => {
294
- log.info(
295
- `[scheduler] Removing fired one-shot cron: ${cron.id}`
296
- );
297
- const fresh = readCronsConfig().filter(
298
- c => c.id !== cron.id
299
- );
300
- writeCronsConfig(fresh);
301
- // Also remove the task file if it exists
302
- try {
303
- fs.unlinkSync(
304
- path.join(
305
- WORKSPACE_DIR,
306
- 'tasks',
307
- `${cron.id}.md`
308
- )
309
- );
310
- } catch {}
311
- }
312
- : undefined;
313
- // Inject task file content if tasks/{id}.md exists
314
- let cronPrompt = `<CRON>${cron.id}</CRON>`;
315
- try {
316
- const taskFile = path.join(
317
- WORKSPACE_DIR,
318
- 'tasks',
319
- `${cron.id}.md`
320
- );
321
- const taskContent = fs
322
- .readFileSync(taskFile, 'utf-8')
323
- .trim();
324
- if (taskContent) {
325
- cronPrompt += `\n<CRON_TASK_DETAIL>\n${taskContent}\n</CRON_TASK_DETAIL>`;
326
- }
327
- } catch {}
328
- triggerAgent(cronPrompt, cron.id, onComplete);
329
- }
330
- }
234
+ }
235
+
236
+ // ── Cron check ──
237
+ const crons = readCronsConfig();
238
+
239
+ for (const cron of crons) {
240
+ if (!cron.enabled || !cron.id || !cron.schedule) continue;
241
+
242
+ const matches = cronMatchesNow(cron.schedule);
243
+ const nowDate = new Date();
244
+ log.info(`[scheduler] Cron "${cron.id}" schedule="${cron.schedule}" matches=${matches} now=${nowDate.getHours()}:${String(nowDate.getMinutes()).padStart(2,'0')}`);
245
+
246
+ if (matches) {
247
+ const lastRun = lastCronRuns.get(cron.id) || 0;
248
+ const oneMinuteAgo = now - 60_000;
249
+ if (lastRun < oneMinuteAgo) {
250
+ lastCronRuns.set(cron.id, now);
251
+ // One-shots: defer removal until agent completes (so agent can still read CRONS.json)
252
+ const onComplete = cron.oneShot ? () => {
253
+ log.info(`[scheduler] Removing fired one-shot cron: ${cron.id}`);
254
+ const fresh = readCronsConfig().filter((c) => c.id !== cron.id);
255
+ writeCronsConfig(fresh);
256
+ // Also remove the task file if it exists
257
+ try {
258
+ fs.unlinkSync(path.join(WORKSPACE_DIR, 'tasks', `${cron.id}.md`));
259
+ } catch {}
260
+ } : undefined;
261
+ // Inject task file content if tasks/{id}.md exists
262
+ let cronPrompt = `<CRON>${cron.id}</CRON>`;
263
+ try {
264
+ const taskFile = path.join(WORKSPACE_DIR, 'tasks', `${cron.id}.md`);
265
+ const taskContent = fs.readFileSync(taskFile, 'utf-8').trim();
266
+ if (taskContent) {
267
+ cronPrompt += `\n<CRON_TASK_DETAIL>\n${taskContent}\n</CRON_TASK_DETAIL>`;
268
+ }
269
+ } catch {}
270
+ triggerAgent(cronPrompt, cron.id, onComplete);
271
+ }
331
272
  }
273
+ }
274
+
275
+ // ── Cleanup: remove expired one-shots (schedule in the past, never gonna fire) ──
276
+ if (crons.length > 0) {
277
+ const cleaned = crons.filter((c) => {
278
+ if (c.oneShot && cronIsExpired(c.schedule)) {
279
+ log.info(`[scheduler] cronIsExpired=true for "${c.id}" schedule="${c.schedule}"`);
280
+ log.info(`[scheduler] Removing expired one-shot cron: ${c.id}`);
281
+ try {
282
+ fs.unlinkSync(path.join(WORKSPACE_DIR, 'tasks', `${c.id}.md`));
283
+ } catch {}
284
+ return false;
285
+ }
286
+ return true;
287
+ });
332
288
 
333
- // ── Cleanup: remove expired one-shots (schedule in the past, never gonna fire) ──
334
- if (crons.length > 0) {
335
- const cleaned = crons.filter(c => {
336
- if (c.oneShot && cronIsExpired(c.schedule)) {
337
- log.info(
338
- `[scheduler] cronIsExpired=true for "${c.id}" schedule="${c.schedule}"`
339
- );
340
- log.info(`[scheduler] Removing expired one-shot cron: ${c.id}`);
341
- try {
342
- fs.unlinkSync(
343
- path.join(WORKSPACE_DIR, 'tasks', `${c.id}.md`)
344
- );
345
- } catch {}
346
- return false;
347
- }
348
- return true;
349
- });
350
-
351
- if (cleaned.length !== crons.length) {
352
- writeCronsConfig(cleaned);
353
- }
289
+ if (cleaned.length !== crons.length) {
290
+ writeCronsConfig(cleaned);
354
291
  }
292
+ }
355
293
  }
356
294
 
357
295
  export function startScheduler(opts: SchedulerOpts) {
358
- schedulerOpts = opts;
359
- lastPulseTime = Date.now();
360
- intervalHandle = setInterval(tick, 60_000);
361
- log.info('[scheduler] Started — checking every 60s');
296
+ schedulerOpts = opts;
297
+ lastPulseTime = Date.now();
298
+ intervalHandle = setInterval(tick, 60_000);
299
+ log.info('[scheduler] Started — checking every 60s');
362
300
  }
363
301
 
364
302
  export function stopScheduler() {
365
- if (intervalHandle) {
366
- clearInterval(intervalHandle);
367
- intervalHandle = null;
368
- }
369
- schedulerOpts = null;
370
- log.info('[scheduler] Stopped');
303
+ if (intervalHandle) {
304
+ clearInterval(intervalHandle);
305
+ intervalHandle = null;
306
+ }
307
+ schedulerOpts = null;
308
+ log.info('[scheduler] Stopped');
371
309
  }