otherwise-cli 0.1.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 (81) hide show
  1. package/README.md +193 -0
  2. package/bin/otherwise.js +5 -0
  3. package/frontend/404.html +84 -0
  4. package/frontend/assets/OpenDyslexic3-Bold-CDyRs55Y.ttf +0 -0
  5. package/frontend/assets/OpenDyslexic3-Regular-CIBXa4WE.ttf +0 -0
  6. package/frontend/assets/__vite-browser-external-BIHI7g3E.js +1 -0
  7. package/frontend/assets/conversational-worker-CeKiciGk.js +2929 -0
  8. package/frontend/assets/dictation-worker-D0aYfq8b.js +29 -0
  9. package/frontend/assets/gemini-color-CgSQmmva.png +0 -0
  10. package/frontend/assets/index-BLux5ps4.js +21 -0
  11. package/frontend/assets/index-Blh8_TEM.js +5272 -0
  12. package/frontend/assets/index-BpQ1PuKu.js +18 -0
  13. package/frontend/assets/index-Df737c8w.css +1 -0
  14. package/frontend/assets/index-xaYHL6wb.js +113 -0
  15. package/frontend/assets/ort-wasm-simd-threaded.asyncify-BynIiDiv.wasm +0 -0
  16. package/frontend/assets/ort-wasm-simd-threaded.jsep-B0T3yYHD.wasm +0 -0
  17. package/frontend/assets/transformers-tULNc5V3.js +31 -0
  18. package/frontend/assets/tts-worker-DPJWqT7N.js +2899 -0
  19. package/frontend/assets/voice-mode-worker-GzvIE_uh.js +2927 -0
  20. package/frontend/assets/worker-2d5ABSLU.js +31 -0
  21. package/frontend/banner.png +0 -0
  22. package/frontend/favicon.svg +3 -0
  23. package/frontend/google55e5ec47ee14a5f8.html +1 -0
  24. package/frontend/index.html +234 -0
  25. package/frontend/manifest.json +17 -0
  26. package/frontend/pdf.worker.min.mjs +21 -0
  27. package/frontend/robots.txt +5 -0
  28. package/frontend/sitemap.xml +27 -0
  29. package/package.json +81 -0
  30. package/src/agent/index.js +1066 -0
  31. package/src/agent/location.js +51 -0
  32. package/src/agent/prompt.js +548 -0
  33. package/src/agent/tools.js +4372 -0
  34. package/src/browser/detect.js +68 -0
  35. package/src/browser/session.js +1109 -0
  36. package/src/config.js +137 -0
  37. package/src/email/client.js +503 -0
  38. package/src/index.js +557 -0
  39. package/src/inference/anthropic.js +113 -0
  40. package/src/inference/google.js +373 -0
  41. package/src/inference/index.js +81 -0
  42. package/src/inference/ollama.js +383 -0
  43. package/src/inference/openai.js +140 -0
  44. package/src/inference/openrouter.js +378 -0
  45. package/src/inference/xai.js +200 -0
  46. package/src/logBridge.js +9 -0
  47. package/src/models.js +146 -0
  48. package/src/remote/client.js +225 -0
  49. package/src/scheduler/cron.js +243 -0
  50. package/src/server.js +3876 -0
  51. package/src/storage/db.js +1135 -0
  52. package/src/storage/supabase.js +364 -0
  53. package/src/tunnel/cloudflare.js +241 -0
  54. package/src/ui/components/App.jsx +687 -0
  55. package/src/ui/components/BrowserSelect.jsx +111 -0
  56. package/src/ui/components/FilePicker.jsx +472 -0
  57. package/src/ui/components/Header.jsx +444 -0
  58. package/src/ui/components/HelpPanel.jsx +173 -0
  59. package/src/ui/components/HistoryPanel.jsx +158 -0
  60. package/src/ui/components/MessageList.jsx +235 -0
  61. package/src/ui/components/ModelSelector.jsx +304 -0
  62. package/src/ui/components/PromptInput.jsx +515 -0
  63. package/src/ui/components/StreamingResponse.jsx +134 -0
  64. package/src/ui/components/ThinkingIndicator.jsx +365 -0
  65. package/src/ui/components/ToolExecution.jsx +714 -0
  66. package/src/ui/components/index.js +82 -0
  67. package/src/ui/context/TerminalContext.jsx +150 -0
  68. package/src/ui/context/index.js +13 -0
  69. package/src/ui/hooks/index.js +16 -0
  70. package/src/ui/hooks/useChatState.js +675 -0
  71. package/src/ui/hooks/useCommands.js +280 -0
  72. package/src/ui/hooks/useFileAttachments.js +216 -0
  73. package/src/ui/hooks/useKeyboardShortcuts.js +173 -0
  74. package/src/ui/hooks/useNotifications.js +185 -0
  75. package/src/ui/hooks/useTerminalSize.js +151 -0
  76. package/src/ui/hooks/useWebSocket.js +273 -0
  77. package/src/ui/index.js +94 -0
  78. package/src/ui/ink-runner.js +22 -0
  79. package/src/ui/utils/formatters.js +424 -0
  80. package/src/ui/utils/index.js +6 -0
  81. package/src/ui/utils/markdown.js +166 -0
package/src/models.js ADDED
@@ -0,0 +1,146 @@
1
+ /**
2
+ * Centralized model definitions - shared between CLI and frontend
3
+ * This mirrors the ModelID.json structure
4
+ */
5
+
6
+ export const MODEL_DATA = {
7
+ localModels: [
8
+ { id: "Qwen3.5-4B", name: "Qwen 3.5 4B", type: ["reasoning", "image-input"], provider: "Local", parameters: "4B", maxTokens: 8192 },
9
+ { id: "Llama-3.2", name: "Llama-3.2", type: [""], provider: "Local", parameters: "1B", maxTokens: 8192 },
10
+ { id: "Janus-Pro", name: "Janus-Pro", type: ["image-input", "image-output"], provider: "Local", parameters: "1B", maxTokens: 4096 },
11
+ { id: "Phi-3.5", name: "Phi-3.5", type: [""], provider: "Local", parameters: "3.8B", maxTokens: 8192 },
12
+ { id: "SmoLlm", name: "SmoLlm", type: [""], provider: "Local", parameters: "1.7B", maxTokens: 4096 },
13
+ { id: "Deepseek-R1", name: "Deepseek-R1", type: ["reasoning"], provider: "Local", parameters: "1.5B", maxTokens: 8192 },
14
+ { id: "Zyphra-ZR1", name: "Zyphra-ZR1", type: ["reasoning"], provider: "Local", parameters: "1.5B", maxTokens: 8192 },
15
+ ],
16
+ apiModels: {
17
+ anthropic: [
18
+ { id: "claude-sonnet-4-20250514", name: "Claude 4 Sonnet", type: [""], provider: "Anthropic", maxTokens: 64000 },
19
+ { id: "claude-4-opus", name: "Claude 4 Opus", type: [""], provider: "Anthropic", maxTokens: 32000 },
20
+ { id: "claude-sonnet-4-5-20250929", name: "Claude 4.5 Sonnet", type: [""], provider: "Anthropic", maxTokens: 64000 },
21
+ { id: "claude-opus-4-5-20251101", name: "Claude 4.5 Opus", type: [""], provider: "Anthropic", maxTokens: 32000 },
22
+ ],
23
+ openai: [
24
+ { id: "gpt-4.1-2025-04-14", name: "GPT-4.1", type: [""], provider: "OpenAI", maxTokens: 16384 },
25
+ { id: "gpt-4o-2024-08-06", name: "GPT-4o", type: [""], provider: "OpenAI", maxTokens: 16384 },
26
+ { id: "gpt-5-2025-08-07", name: "GPT-5", type: ["reasoning", "web-search"], provider: "OpenAI", maxTokens: 32768 },
27
+ { id: "gpt-5-mini-2025-08-07", name: "GPT-5 Mini", type: ["web-search"], provider: "OpenAI", maxTokens: 16384 },
28
+ ],
29
+ google: [
30
+ { id: "gemini-2.5-pro", name: "Gemini 2.5 Pro", type: ["reasoning", "web-search"], provider: "Google", maxTokens: 65536 },
31
+ { id: "gemini-2.5-flash", name: "Gemini 2.5 Flash", type: ["reasoning", "web-search"], provider: "Google", maxTokens: 65536 },
32
+ { id: "gemini-2.5-flash-lite", name: "Gemini 2.5 Flash-Lite", type: ["web-search"], provider: "Google", maxTokens: 65536 },
33
+ { id: "gemini-3-pro-preview", name: "Gemini 3 Pro", type: ["reasoning", "web-search", "image-input"], provider: "Google", maxTokens: 65536 },
34
+ { id: "gemini-3-flash-preview", name: "Gemini 3 Flash", type: ["reasoning", "web-search"], provider: "Google", maxTokens: 65536 },
35
+ ],
36
+ xai: [
37
+ { id: "grok-3-mini", name: "Grok 3 Mini", type: ["reasoning"], provider: "xAI", maxTokens: 131072 },
38
+ { id: "grok-code-fast-1", name: "Grok Code Fast 1", type: ["reasoning"], provider: "xAI", maxTokens: 131072 },
39
+ { id: "grok-4-fast-reasoning", name: "Grok 4 Fast Reasoning", type: ["reasoning"], provider: "xAI", maxTokens: 131072 },
40
+ { id: "grok-4-fast-non-reasoning", name: "Grok 4 Fast", type: [], provider: "xAI", maxTokens: 131072 },
41
+ { id: "grok-4-1-fast", name: "Grok 4.1 Fast", type: [], provider: "xAI", maxTokens: 131072 },
42
+ { id: "grok-4-1-fast-reasoning", name: "Grok 4.1 Fast Reasoning", type: ["reasoning"], provider: "xAI", maxTokens: 131072 },
43
+ ],
44
+ // OpenRouter models are fetched dynamically from their API
45
+ // This placeholder array will be populated at runtime
46
+ openrouter: [],
47
+ },
48
+ };
49
+
50
+ // Cache for OpenRouter models (shared with inference module)
51
+ let openRouterModelsCache = [];
52
+
53
+ /**
54
+ * Set OpenRouter models (called when models are fetched from API)
55
+ * @param {Array} models - Array of OpenRouter model objects
56
+ */
57
+ export function setOpenRouterModels(models) {
58
+ openRouterModelsCache = models;
59
+ MODEL_DATA.apiModels.openrouter = models;
60
+ }
61
+
62
+ /**
63
+ * Get cached OpenRouter models
64
+ * @returns {Array} Cached OpenRouter models
65
+ */
66
+ export function getOpenRouterModels() {
67
+ return openRouterModelsCache;
68
+ }
69
+
70
+ /**
71
+ * Find a model by ID
72
+ * @param {string} modelId - Model ID to find
73
+ * @returns {Object|null} Model object or null
74
+ */
75
+ export function findModelById(modelId) {
76
+ // Check local models
77
+ const localModel = MODEL_DATA.localModels.find(m => m.id === modelId);
78
+ if (localModel) return localModel;
79
+
80
+ // Check API models
81
+ for (const models of Object.values(MODEL_DATA.apiModels)) {
82
+ const model = models.find(m => m.id === modelId);
83
+ if (model) return model;
84
+ }
85
+
86
+ return null;
87
+ }
88
+
89
+ /**
90
+ * Get provider for a model ID
91
+ * @param {string} modelId - Model ID
92
+ * @returns {string} Provider name
93
+ */
94
+ export function getProviderForModel(modelId) {
95
+ if (modelId.startsWith('openrouter:')) return 'openrouter';
96
+ if (modelId.startsWith('claude')) return 'anthropic';
97
+ if (modelId.startsWith('gpt') || modelId.startsWith('o1') || modelId.startsWith('o3')) return 'openai';
98
+ if (modelId.startsWith('gemini')) return 'google';
99
+ if (modelId.startsWith('grok')) return 'xai';
100
+
101
+ // Check local models
102
+ const localModel = MODEL_DATA.localModels.find(m => m.id === modelId);
103
+ if (localModel) return 'local';
104
+
105
+ // Check OpenRouter models
106
+ const orModel = MODEL_DATA.apiModels.openrouter.find(m => m.id === modelId);
107
+ if (orModel) return 'openrouter';
108
+
109
+ return 'unknown';
110
+ }
111
+
112
+ /**
113
+ * Get display name for a model
114
+ * @param {string} modelId - Model ID
115
+ * @returns {string} Display name
116
+ */
117
+ export function getModelDisplayName(modelId) {
118
+ const model = findModelById(modelId);
119
+ if (model) return model.name;
120
+
121
+ // Fallback: format the ID nicely
122
+ return modelId.replace(/-/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
123
+ }
124
+
125
+ /**
126
+ * Get max tokens for a model
127
+ * @param {string} modelId - Model ID
128
+ * @returns {number} Max tokens
129
+ */
130
+ export function getModelMaxTokens(modelId) {
131
+ const model = findModelById(modelId);
132
+ return model?.maxTokens || 8192;
133
+ }
134
+
135
+ /**
136
+ * Check if a model supports a feature
137
+ * @param {string} modelId - Model ID
138
+ * @param {string} feature - Feature to check (e.g., 'reasoning', 'web-search', 'image-input')
139
+ * @returns {boolean}
140
+ */
141
+ export function modelSupports(modelId, feature) {
142
+ const model = findModelById(modelId);
143
+ return model?.type?.includes(feature) || false;
144
+ }
145
+
146
+ export default MODEL_DATA;
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Outbound WebSocket client to connect the CLI to the backend (otherwise.ai router).
3
+ * Used for "remote" mode: user on otherwise.ai talks to this CLI via the backend.
4
+ * Connect with pairing token from device-code flow (otherwise connect → enter code at otherwise.ai/connect).
5
+ *
6
+ * Reconnection uses exponential backoff with jitter (2s → 60s cap). The pairing token
7
+ * is a long-lived JWT so it survives backend restarts — no need to re-enter a code.
8
+ *
9
+ * Ping/pong keep-alive prevents Azure ARR and other reverse proxies from closing
10
+ * idle WebSocket connections (sends ping every 25s, expects pong within 10s).
11
+ */
12
+
13
+ import WebSocket from "ws";
14
+
15
+ let remoteWs = null;
16
+ let reconnectTimeout = null;
17
+ let reconnectAttempt = 0;
18
+ const RECONNECT_BASE_MS = 2000;
19
+ const RECONNECT_MAX_MS = 60000;
20
+ const RECONNECT_JITTER = 0.3;
21
+ const STABLE_CONNECTION_MS = 15000;
22
+ const PING_INTERVAL_MS = 25000;
23
+ const PONG_TIMEOUT_MS = 10000;
24
+
25
+ const DEFAULT_BACKEND =
26
+ "https://gptez-acb8f4ggfrbkhtep.canadacentral-01.azurewebsites.net";
27
+
28
+ /**
29
+ * Get backend HTTP(S) base URL (no trailing slash). Used for REST API (device-code, pairing-token) and to derive WS URL.
30
+ * Env: OTHERWISE_BACKEND_URL (e.g. https://your-app.azurewebsites.net) overrides default.
31
+ * @returns {string} e.g. https://api.otherwise.ai or https://gptez-....azurewebsites.net
32
+ */
33
+ export function getBackendBaseUrl() {
34
+ if (typeof process !== "undefined" && process.env?.OTHERWISE_BACKEND_URL) {
35
+ return process.env.OTHERWISE_BACKEND_URL.replace(/\/$/, "");
36
+ }
37
+ return DEFAULT_BACKEND;
38
+ }
39
+
40
+ /**
41
+ * Get backend WebSocket URL from config or env.
42
+ * Uses OTHERWISE_BACKEND_URL if set (derives wss from https), else OTHERWISE_BACKEND_WS_URL, else default.
43
+ * @returns {string} e.g. wss://api.otherwise.ai/ws
44
+ */
45
+ export function getBackendWsUrl() {
46
+ if (typeof process !== "undefined" && process.env?.OTHERWISE_BACKEND_WS_URL) {
47
+ return process.env.OTHERWISE_BACKEND_WS_URL;
48
+ }
49
+ const base = getBackendBaseUrl();
50
+ const wsBase = base
51
+ .replace(/^http:\/\//, "ws://")
52
+ .replace(/^https:\/\//, "wss://");
53
+ return wsBase + (wsBase.endsWith("/ws") ? "" : "/ws");
54
+ }
55
+
56
+ function getReconnectDelay() {
57
+ const base = Math.min(
58
+ RECONNECT_BASE_MS * Math.pow(1.5, reconnectAttempt),
59
+ RECONNECT_MAX_MS,
60
+ );
61
+ const jitter = base * RECONNECT_JITTER * (Math.random() - 0.5) * 2;
62
+ return Math.round(base + jitter);
63
+ }
64
+
65
+ /**
66
+ * Connect to the backend as a CLI client using a pairing token (JWT, survives restarts).
67
+ * Messages from the backend are passed to onMessage(rawMessage, sendReply).
68
+ * sendReply(msg) sends a message back through the WebSocket (same JSON protocol as local /ws).
69
+ *
70
+ * @param {string} pairingToken - JWT from POST /api/pairing-token (user gets it from otherwise.ai/connect)
71
+ * @param {function} onMessage - (rawMessage: Buffer|string, sendReply: (msg: object) => void) => Promise<void>
72
+ * @param {object} options - { backendUrl?, onConnect?, onDisconnect?, onInvalidToken?, reconnect? }
73
+ * @returns {{ stop: function }} - call stop() to close the connection and stop reconnecting
74
+ */
75
+ export function connectToBackend(pairingToken, onMessage, options = {}) {
76
+ const backendUrl = options.backendUrl || getBackendWsUrl();
77
+ const baseUrl = backendUrl.replace(/^http/, "ws").replace(/\/$/, "");
78
+ const path = baseUrl.endsWith("/ws") ? baseUrl : `${baseUrl}/ws`;
79
+ const wsUrl = `${path}?role=cli&token=${encodeURIComponent(pairingToken)}`;
80
+ let stopped = false;
81
+ let pingInterval = null;
82
+ let pongTimeout = null;
83
+ let stableTimer = null;
84
+ let connectedAt = 0;
85
+
86
+ function clearTimers() {
87
+ if (pingInterval) {
88
+ clearInterval(pingInterval);
89
+ pingInterval = null;
90
+ }
91
+ if (pongTimeout) {
92
+ clearTimeout(pongTimeout);
93
+ pongTimeout = null;
94
+ }
95
+ if (stableTimer) {
96
+ clearTimeout(stableTimer);
97
+ stableTimer = null;
98
+ }
99
+ }
100
+
101
+ function startPingLoop(ws) {
102
+ clearTimers();
103
+ pingInterval = setInterval(() => {
104
+ if (!ws || ws.readyState !== WebSocket.OPEN) {
105
+ clearTimers();
106
+ return;
107
+ }
108
+ try {
109
+ ws.ping();
110
+ pongTimeout = setTimeout(() => {
111
+ console.log("[Remote] Pong timeout — closing stale connection");
112
+ try {
113
+ ws.terminate();
114
+ } catch (_) {}
115
+ }, PONG_TIMEOUT_MS);
116
+ } catch (_) {}
117
+ }, PING_INTERVAL_MS);
118
+ }
119
+
120
+ function stop() {
121
+ stopped = true;
122
+ clearTimers();
123
+ if (reconnectTimeout) {
124
+ clearTimeout(reconnectTimeout);
125
+ reconnectTimeout = null;
126
+ }
127
+ if (remoteWs) {
128
+ try {
129
+ remoteWs.close();
130
+ } catch (e) {}
131
+ remoteWs = null;
132
+ }
133
+ options.onDisconnect?.();
134
+ }
135
+
136
+ function sendReply(msg) {
137
+ if (remoteWs && remoteWs.readyState === WebSocket.OPEN) {
138
+ remoteWs.send(JSON.stringify(msg));
139
+ }
140
+ }
141
+
142
+ function scheduleReconnect() {
143
+ if (stopped || options.reconnect === false || !pairingToken) return;
144
+ const delay = getReconnectDelay();
145
+ reconnectAttempt++;
146
+ console.log(
147
+ `[Remote] Reconnecting in ${delay}ms (attempt ${reconnectAttempt})...`,
148
+ );
149
+ reconnectTimeout = setTimeout(connect, delay);
150
+ }
151
+
152
+ function connect() {
153
+ if (stopped) return;
154
+ if (remoteWs && remoteWs.readyState === WebSocket.OPEN) return;
155
+ console.log("[Remote] Connecting to backend...");
156
+ remoteWs = new WebSocket(wsUrl);
157
+
158
+ remoteWs.on("open", () => {
159
+ console.log("[Remote] Connected to backend");
160
+ connectedAt = Date.now();
161
+
162
+ stableTimer = setTimeout(() => {
163
+ reconnectAttempt = 0;
164
+ }, STABLE_CONNECTION_MS);
165
+
166
+ startPingLoop(remoteWs);
167
+ options.onConnect?.(sendReply);
168
+ });
169
+
170
+ remoteWs.on("pong", () => {
171
+ if (pongTimeout) {
172
+ clearTimeout(pongTimeout);
173
+ pongTimeout = null;
174
+ }
175
+ });
176
+
177
+ remoteWs.on("close", (code, reason) => {
178
+ const elapsed = connectedAt ? Date.now() - connectedAt : 0;
179
+ clearTimers();
180
+ remoteWs = null;
181
+ console.log(
182
+ `[Remote] Disconnected from backend code=${code} reason=${reason?.toString() || ""} after=${elapsed}ms`,
183
+ );
184
+ options.onDisconnect?.();
185
+ if (code === 4001) {
186
+ options.onInvalidToken?.();
187
+ return;
188
+ }
189
+ scheduleReconnect();
190
+ });
191
+
192
+ remoteWs.on("error", (err) => {
193
+ console.error("[Remote] WebSocket error:", err?.message || err);
194
+ });
195
+
196
+ remoteWs.on("message", (data) => {
197
+ try {
198
+ const raw =
199
+ typeof data === "string" ? data : data?.toString?.() || data;
200
+ const msg = typeof raw === "string" ? JSON.parse(raw) : raw;
201
+ if (msg && msg.type === "cli_connected") {
202
+ return;
203
+ }
204
+ Promise.resolve(onMessage(raw, sendReply)).catch((e) => {
205
+ console.error("[Remote] Message handler error:", e);
206
+ });
207
+ } catch (e) {
208
+ console.error("[Remote] Failed to parse message:", e);
209
+ }
210
+ });
211
+ }
212
+
213
+ connect();
214
+
215
+ return { stop };
216
+ }
217
+
218
+ /**
219
+ * Check if the CLI is currently connected to the backend (remote mode).
220
+ */
221
+ export function isConnectedToBackend() {
222
+ return remoteWs != null && remoteWs.readyState === WebSocket.OPEN;
223
+ }
224
+
225
+ export default { getBackendWsUrl, connectToBackend, isConnectedToBackend };
@@ -0,0 +1,243 @@
1
+ import cron from "node-cron";
2
+ import {
3
+ getScheduledTasks,
4
+ markScheduledTaskRun,
5
+ updateScheduledTaskNextRun,
6
+ getScheduledTask,
7
+ } from "../storage/db.js";
8
+ import { runScheduledTask } from "../agent/index.js";
9
+
10
+ // Store active cron jobs
11
+ const activeJobs = new Map();
12
+
13
+ // One-time tasks: id -> { timeoutId } (run once after N minutes)
14
+ let oneTimeId = 0;
15
+ const oneTimeTimeouts = new Map();
16
+
17
+ // Import silentMode check - we'll check process.env for a flag
18
+ const isSilent = () => process.env.SILENT_MODE === "true";
19
+ const schedulerLog = (...args) => {
20
+ if (!isSilent()) console.log(...args);
21
+ };
22
+
23
+ /**
24
+ * Calculate next run time for a cron expression
25
+ */
26
+ function getNextRunTime(cronExpression) {
27
+ try {
28
+ const interval = cron.getTasks().get(cronExpression);
29
+ // Use the cron-parser to calculate next run
30
+ // For simplicity, we'll just return the current time + 1 minute as placeholder
31
+ // In production, use a proper cron parser
32
+ return new Date(Date.now() + 60000).toISOString();
33
+ } catch {
34
+ return null;
35
+ }
36
+ }
37
+
38
+ /**
39
+ * Initialize scheduler and load all enabled tasks
40
+ * @param {object} config - Configuration
41
+ */
42
+ export function initScheduler(config) {
43
+ schedulerLog("[Scheduler] Initializing...");
44
+
45
+ try {
46
+ const tasks = getScheduledTasks(true); // Only enabled tasks
47
+
48
+ for (const task of tasks) {
49
+ scheduleTask(task, config);
50
+ }
51
+
52
+ console.log(`[Scheduler] Loaded ${tasks.length} scheduled tasks`);
53
+ } catch (err) {
54
+ console.error("[Scheduler] Error loading tasks:", err);
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Schedule a task for execution
60
+ * @param {object} task - Task from database
61
+ * @param {object} config - Configuration
62
+ */
63
+ export function scheduleTask(task, config) {
64
+ // Validate cron expression
65
+ if (!cron.validate(task.cron_expression)) {
66
+ console.error(
67
+ `[Scheduler] Invalid cron expression for task ${task.id}: ${task.cron_expression}`,
68
+ );
69
+ return false;
70
+ }
71
+
72
+ // Cancel existing job if any
73
+ if (activeJobs.has(task.id)) {
74
+ activeJobs.get(task.id).stop();
75
+ activeJobs.delete(task.id);
76
+ }
77
+
78
+ // Create new cron job
79
+ const job = cron.schedule(
80
+ task.cron_expression,
81
+ async () => {
82
+ console.log(`[Scheduler] Executing task ${task.id}: ${task.description}`);
83
+
84
+ try {
85
+ // Mark task as running
86
+ markScheduledTaskRun(task.id);
87
+
88
+ // Run the agent with the task description
89
+ const result = await runScheduledTask(task.description, config);
90
+
91
+ console.log(`[Scheduler] Task ${task.id} completed`);
92
+
93
+ // Update next run time
94
+ updateScheduledTaskNextRun(
95
+ task.id,
96
+ getNextRunTime(task.cron_expression),
97
+ );
98
+ } catch (err) {
99
+ console.error(`[Scheduler] Task ${task.id} failed:`, err);
100
+ }
101
+ },
102
+ {
103
+ scheduled: true,
104
+ timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
105
+ },
106
+ );
107
+
108
+ activeJobs.set(task.id, job);
109
+
110
+ // Update next run time in database
111
+ updateScheduledTaskNextRun(task.id, getNextRunTime(task.cron_expression));
112
+
113
+ console.log(`[Scheduler] Task ${task.id} scheduled: ${task.cron_expression}`);
114
+ return true;
115
+ }
116
+
117
+ /**
118
+ * Cancel a scheduled task
119
+ * @param {number} taskId - Task ID
120
+ */
121
+ export function cancelTask(taskId) {
122
+ if (activeJobs.has(taskId)) {
123
+ activeJobs.get(taskId).stop();
124
+ activeJobs.delete(taskId);
125
+ console.log(`[Scheduler] Task ${taskId} cancelled`);
126
+ return true;
127
+ }
128
+ return false;
129
+ }
130
+
131
+ /**
132
+ * Get status of all active jobs
133
+ */
134
+ export function getSchedulerStatus() {
135
+ return {
136
+ activeJobs: activeJobs.size,
137
+ jobs: Array.from(activeJobs.keys()),
138
+ };
139
+ }
140
+
141
+ /**
142
+ * Reload a task from database (call after creating/updating)
143
+ * @param {number} taskId - Task ID
144
+ * @param {object} config - Configuration
145
+ * @returns {boolean} - true if task was scheduled, false if invalid or disabled
146
+ */
147
+ export function reloadTask(taskId, config) {
148
+ const task = getScheduledTask(taskId);
149
+ if (!task || !task.enabled) {
150
+ schedulerLog(
151
+ "[Scheduler] reloadTask: task",
152
+ taskId,
153
+ "not found or disabled",
154
+ );
155
+ return false;
156
+ }
157
+ const ok = scheduleTask(task, config);
158
+ if (!ok) {
159
+ console.error(
160
+ "[Scheduler] reloadTask: failed to schedule task",
161
+ taskId,
162
+ "(invalid cron?)",
163
+ );
164
+ }
165
+ return ok;
166
+ }
167
+
168
+ /**
169
+ * Schedule a one-time task to run once after N minutes (for "in 5 minutes" style requests).
170
+ * Does not persist across CLI restarts.
171
+ * @param {string} description - What the task should do
172
+ * @param {number} runInMinutes - Minutes from now to run
173
+ * @param {object} config - Agent config
174
+ * @returns {number} - One-time task id (can be used to cancel via cancelOneTimeTask)
175
+ */
176
+ export function scheduleOneTimeTask(description, runInMinutes, config) {
177
+ const id = ++oneTimeId;
178
+ const ms = Math.max(1, runInMinutes) * 60 * 1000;
179
+ schedulerLog(
180
+ "[Scheduler] One-time task",
181
+ id,
182
+ "scheduled in",
183
+ runInMinutes,
184
+ "minute(s)",
185
+ );
186
+ const timeoutId = setTimeout(async () => {
187
+ oneTimeTimeouts.delete(id);
188
+ try {
189
+ console.log(
190
+ "[Scheduler] Executing one-time task",
191
+ id,
192
+ ":",
193
+ description.slice(0, 60) + (description.length > 60 ? "..." : ""),
194
+ );
195
+ await runScheduledTask(description, config);
196
+ console.log("[Scheduler] One-time task", id, "completed");
197
+ } catch (err) {
198
+ console.error("[Scheduler] One-time task", id, "failed:", err);
199
+ }
200
+ }, ms);
201
+ oneTimeTimeouts.set(id, { timeoutId });
202
+ return id;
203
+ }
204
+
205
+ /**
206
+ * Cancel a one-time scheduled task
207
+ */
208
+ export function cancelOneTimeTask(id) {
209
+ const entry = oneTimeTimeouts.get(id);
210
+ if (entry) {
211
+ clearTimeout(entry.timeoutId);
212
+ oneTimeTimeouts.delete(id);
213
+ schedulerLog("[Scheduler] One-time task", id, "cancelled");
214
+ return true;
215
+ }
216
+ return false;
217
+ }
218
+
219
+ /**
220
+ * Stop all scheduled tasks and one-time timeouts
221
+ */
222
+ export function stopScheduler() {
223
+ for (const [id, job] of activeJobs) {
224
+ job.stop();
225
+ }
226
+ activeJobs.clear();
227
+ for (const [id, entry] of oneTimeTimeouts) {
228
+ clearTimeout(entry.timeoutId);
229
+ }
230
+ oneTimeTimeouts.clear();
231
+ schedulerLog("[Scheduler] All tasks stopped");
232
+ }
233
+
234
+ export default {
235
+ initScheduler,
236
+ scheduleTask,
237
+ cancelTask,
238
+ getSchedulerStatus,
239
+ reloadTask,
240
+ stopScheduler,
241
+ scheduleOneTimeTask,
242
+ cancelOneTimeTask,
243
+ };