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.
- package/README.md +53 -84
- package/bin/cli.js +1717 -6
- package/components.json +18 -18
- package/package.json +98 -109
- package/postcss.config.js +3 -3
- package/scripts/install.ps1 +1 -1
- package/scripts/install.sh +1 -1
- package/scripts/postinstall.js +47 -58
- package/shared/ai.ts +118 -202
- package/shared/config.ts +37 -39
- package/shared/logger.ts +6 -14
- package/shared/paths.ts +10 -16
- package/shared/relay.ts +89 -98
- package/supervisor/backend.ts +77 -89
- package/supervisor/chat/ARCHITECTURE.md +13 -14
- package/supervisor/chat/OnboardWizard.tsx +2194 -3419
- package/supervisor/chat/fluxy-main.tsx +495 -633
- package/supervisor/chat/fluxy.html +24 -36
- package/supervisor/chat/onboard-main.tsx +8 -8
- package/supervisor/chat/onboard.html +10 -13
- package/supervisor/chat/src/components/Chat/AudioBubble.tsx +94 -108
- package/supervisor/chat/src/components/Chat/ChatView.tsx +14 -36
- package/supervisor/chat/src/components/Chat/ImageLightbox.tsx +71 -89
- package/supervisor/chat/src/components/Chat/InputBar.tsx +498 -728
- package/supervisor/chat/src/components/Chat/MessageBubble.tsx +154 -246
- package/supervisor/chat/src/components/Chat/MessageList.tsx +114 -138
- package/supervisor/chat/src/components/Chat/TypingIndicator.tsx +38 -50
- package/supervisor/chat/src/components/LoginScreen.tsx +245 -296
- package/supervisor/chat/src/hooks/useChat.ts +210 -259
- package/supervisor/chat/src/hooks/useFluxyChat.ts +232 -322
- package/supervisor/chat/src/hooks/useSpeechRecognition.ts +126 -148
- package/supervisor/chat/src/lib/auth.ts +20 -23
- package/supervisor/chat/src/lib/ws-client.ts +113 -125
- package/supervisor/chat/src/styles/globals.css +76 -85
- package/supervisor/file-saver.ts +39 -52
- package/supervisor/fluxy-agent.ts +213 -292
- package/supervisor/index.ts +895 -1263
- package/supervisor/scheduler.ts +237 -299
- package/supervisor/tunnel.ts +113 -167
- package/supervisor/vite-dev.ts +51 -61
- package/supervisor/widget.js +135 -147
- package/supervisor/worker.ts +45 -49
- package/tsconfig.json +18 -24
- package/vite.config.ts +48 -55
- package/vite.fluxy.config.ts +34 -34
- package/worker/claude-auth.ts +202 -265
- package/worker/codex-auth.ts +144 -181
- package/worker/db.ts +63 -191
- package/worker/index.ts +601 -842
- package/workspace/MYSELF.md +1 -1
- package/workspace/PULSE.json +6 -6
- package/workspace/backend/index.ts +16 -19
- package/workspace/client/index.html +47 -60
- package/workspace/client/public/manifest.json +21 -21
- package/workspace/client/public/sw.js +29 -31
- package/workspace/client/src/App.tsx +91 -136
- package/workspace/client/src/components/Dashboard/DashboardPage.tsx +68 -72
- package/workspace/client/src/components/ErrorBoundary.tsx +11 -11
- package/workspace/client/src/components/Layout/DashboardLayout.tsx +35 -35
- package/workspace/client/src/components/Layout/Footer.tsx +12 -12
- package/workspace/client/src/components/Layout/MobileNav.tsx +23 -23
- package/workspace/client/src/components/Layout/Sidebar.tsx +81 -81
- package/workspace/client/src/components/ui/avatar.tsx +85 -85
- package/workspace/client/src/components/ui/badge.tsx +40 -41
- package/workspace/client/src/components/ui/button.tsx +56 -57
- package/workspace/client/src/components/ui/card.tsx +75 -75
- package/workspace/client/src/components/ui/dialog.tsx +114 -117
- package/workspace/client/src/components/ui/dropdown-menu.tsx +191 -199
- package/workspace/client/src/components/ui/input.tsx +17 -17
- package/workspace/client/src/components/ui/scroll-area.tsx +47 -47
- package/workspace/client/src/components/ui/select.tsx +145 -151
- package/workspace/client/src/components/ui/separator.tsx +21 -21
- package/workspace/client/src/components/ui/sheet.tsx +102 -102
- package/workspace/client/src/components/ui/skeleton.tsx +10 -10
- package/workspace/client/src/components/ui/switch.tsx +26 -26
- package/workspace/client/src/components/ui/tabs.tsx +69 -69
- package/workspace/client/src/components/ui/textarea.tsx +14 -14
- package/workspace/client/src/components/ui/tooltip.tsx +37 -37
- package/workspace/client/src/lib/utils.ts +1 -1
- package/workspace/client/src/main.tsx +3 -3
- package/workspace/client/src/styles/globals.css +76 -85
- package/workspace/skills/code-reviewer/.claude-plugin/plugin.json +3 -3
- package/workspace/skills/code-reviewer/skills/code-reviewer/SKILL.md +0 -6
- package/workspace/skills/daily-standup/.claude-plugin/plugin.json +3 -3
- package/workspace/skills/daily-standup/skills/daily-standup/SKILL.md +0 -7
- package/workspace/skills/workspace-helper/.claude-plugin/plugin.json +3 -3
- package/workspace/skills/workspace-helper/skills/workspace-helper/SKILL.md +0 -2
- package/bin/cli.backup.js +0 -2138
- package/cli/commands/daemon.ts +0 -42
- package/cli/commands/init.ts +0 -32
- package/cli/commands/start.ts +0 -113
- package/cli/commands/tunnel.temp.ts +0 -206
- package/cli/commands/tunnel.ts +0 -227
- package/cli/commands/update.ts +0 -163
- package/cli/core/base-adapter.ts +0 -156
- package/cli/core/cloudflared.ts +0 -113
- package/cli/core/config.ts +0 -73
- package/cli/core/os-detector.ts +0 -43
- package/cli/core/server.ts +0 -109
- package/cli/core/types.ts +0 -15
- package/cli/index.ts +0 -72
- package/cli/platforms/darwin.ts +0 -146
- package/cli/platforms/index.ts +0 -21
- package/cli/platforms/linux.ts +0 -156
- package/cli/platforms/win32.ts +0 -34
- package/cli/utils/ui.ts +0 -37
package/supervisor/scheduler.ts
CHANGED
|
@@ -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
|
-
|
|
19
|
-
|
|
20
|
-
|
|
18
|
+
enabled: boolean;
|
|
19
|
+
intervalMinutes: number;
|
|
20
|
+
quietHours: { start: string; end: string };
|
|
21
21
|
}
|
|
22
22
|
|
|
23
23
|
interface CronConfig {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
id: string;
|
|
25
|
+
schedule: string;
|
|
26
|
+
task: string;
|
|
27
|
+
enabled: boolean;
|
|
28
|
+
oneShot?: boolean;
|
|
29
29
|
}
|
|
30
30
|
|
|
31
31
|
interface SchedulerOpts {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
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
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
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
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
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
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
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
|
-
|
|
334
|
-
|
|
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
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
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
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
303
|
+
if (intervalHandle) {
|
|
304
|
+
clearInterval(intervalHandle);
|
|
305
|
+
intervalHandle = null;
|
|
306
|
+
}
|
|
307
|
+
schedulerOpts = null;
|
|
308
|
+
log.info('[scheduler] Stopped');
|
|
371
309
|
}
|