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.
- package/README.md +193 -0
- package/bin/otherwise.js +5 -0
- package/frontend/404.html +84 -0
- package/frontend/assets/OpenDyslexic3-Bold-CDyRs55Y.ttf +0 -0
- package/frontend/assets/OpenDyslexic3-Regular-CIBXa4WE.ttf +0 -0
- package/frontend/assets/__vite-browser-external-BIHI7g3E.js +1 -0
- package/frontend/assets/conversational-worker-CeKiciGk.js +2929 -0
- package/frontend/assets/dictation-worker-D0aYfq8b.js +29 -0
- package/frontend/assets/gemini-color-CgSQmmva.png +0 -0
- package/frontend/assets/index-BLux5ps4.js +21 -0
- package/frontend/assets/index-Blh8_TEM.js +5272 -0
- package/frontend/assets/index-BpQ1PuKu.js +18 -0
- package/frontend/assets/index-Df737c8w.css +1 -0
- package/frontend/assets/index-xaYHL6wb.js +113 -0
- package/frontend/assets/ort-wasm-simd-threaded.asyncify-BynIiDiv.wasm +0 -0
- package/frontend/assets/ort-wasm-simd-threaded.jsep-B0T3yYHD.wasm +0 -0
- package/frontend/assets/transformers-tULNc5V3.js +31 -0
- package/frontend/assets/tts-worker-DPJWqT7N.js +2899 -0
- package/frontend/assets/voice-mode-worker-GzvIE_uh.js +2927 -0
- package/frontend/assets/worker-2d5ABSLU.js +31 -0
- package/frontend/banner.png +0 -0
- package/frontend/favicon.svg +3 -0
- package/frontend/google55e5ec47ee14a5f8.html +1 -0
- package/frontend/index.html +234 -0
- package/frontend/manifest.json +17 -0
- package/frontend/pdf.worker.min.mjs +21 -0
- package/frontend/robots.txt +5 -0
- package/frontend/sitemap.xml +27 -0
- package/package.json +81 -0
- package/src/agent/index.js +1066 -0
- package/src/agent/location.js +51 -0
- package/src/agent/prompt.js +548 -0
- package/src/agent/tools.js +4372 -0
- package/src/browser/detect.js +68 -0
- package/src/browser/session.js +1109 -0
- package/src/config.js +137 -0
- package/src/email/client.js +503 -0
- package/src/index.js +557 -0
- package/src/inference/anthropic.js +113 -0
- package/src/inference/google.js +373 -0
- package/src/inference/index.js +81 -0
- package/src/inference/ollama.js +383 -0
- package/src/inference/openai.js +140 -0
- package/src/inference/openrouter.js +378 -0
- package/src/inference/xai.js +200 -0
- package/src/logBridge.js +9 -0
- package/src/models.js +146 -0
- package/src/remote/client.js +225 -0
- package/src/scheduler/cron.js +243 -0
- package/src/server.js +3876 -0
- package/src/storage/db.js +1135 -0
- package/src/storage/supabase.js +364 -0
- package/src/tunnel/cloudflare.js +241 -0
- package/src/ui/components/App.jsx +687 -0
- package/src/ui/components/BrowserSelect.jsx +111 -0
- package/src/ui/components/FilePicker.jsx +472 -0
- package/src/ui/components/Header.jsx +444 -0
- package/src/ui/components/HelpPanel.jsx +173 -0
- package/src/ui/components/HistoryPanel.jsx +158 -0
- package/src/ui/components/MessageList.jsx +235 -0
- package/src/ui/components/ModelSelector.jsx +304 -0
- package/src/ui/components/PromptInput.jsx +515 -0
- package/src/ui/components/StreamingResponse.jsx +134 -0
- package/src/ui/components/ThinkingIndicator.jsx +365 -0
- package/src/ui/components/ToolExecution.jsx +714 -0
- package/src/ui/components/index.js +82 -0
- package/src/ui/context/TerminalContext.jsx +150 -0
- package/src/ui/context/index.js +13 -0
- package/src/ui/hooks/index.js +16 -0
- package/src/ui/hooks/useChatState.js +675 -0
- package/src/ui/hooks/useCommands.js +280 -0
- package/src/ui/hooks/useFileAttachments.js +216 -0
- package/src/ui/hooks/useKeyboardShortcuts.js +173 -0
- package/src/ui/hooks/useNotifications.js +185 -0
- package/src/ui/hooks/useTerminalSize.js +151 -0
- package/src/ui/hooks/useWebSocket.js +273 -0
- package/src/ui/index.js +94 -0
- package/src/ui/ink-runner.js +22 -0
- package/src/ui/utils/formatters.js +424 -0
- package/src/ui/utils/index.js +6 -0
- 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
|
+
};
|