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/index.ts
CHANGED
|
@@ -4,51 +4,16 @@ import fs from 'fs';
|
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import { WebSocketServer, WebSocket } from 'ws';
|
|
6
6
|
import { loadConfig, saveConfig } from '../shared/config.js';
|
|
7
|
-
import {
|
|
8
|
-
createProvider,
|
|
9
|
-
type AiProvider,
|
|
10
|
-
type ChatMessage
|
|
11
|
-
} from '../shared/ai.js';
|
|
7
|
+
import { createProvider, type AiProvider, type ChatMessage } from '../shared/ai.js';
|
|
12
8
|
import { paths } from '../shared/paths.js';
|
|
13
|
-
import { PKG_DIR
|
|
9
|
+
import { PKG_DIR } from '../shared/paths.js';
|
|
14
10
|
import { log } from '../shared/logger.js';
|
|
15
|
-
import {
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
restartNamedTunnel
|
|
22
|
-
} from './tunnel.js';
|
|
23
|
-
import {
|
|
24
|
-
spawnWorker,
|
|
25
|
-
stopWorker,
|
|
26
|
-
getWorkerPort,
|
|
27
|
-
isWorkerAlive
|
|
28
|
-
} from './worker.js';
|
|
29
|
-
import {
|
|
30
|
-
spawnBackend,
|
|
31
|
-
stopBackend,
|
|
32
|
-
getBackendPort,
|
|
33
|
-
isBackendAlive,
|
|
34
|
-
resetBackendRestarts
|
|
35
|
-
} from './backend.js';
|
|
36
|
-
import {
|
|
37
|
-
updateTunnelUrl,
|
|
38
|
-
startHeartbeat,
|
|
39
|
-
stopHeartbeat,
|
|
40
|
-
disconnect
|
|
41
|
-
} from '../shared/relay.js';
|
|
42
|
-
import {
|
|
43
|
-
startFluxyAgentQuery,
|
|
44
|
-
stopFluxyAgentQuery,
|
|
45
|
-
type RecentMessage
|
|
46
|
-
} from './fluxy-agent.js';
|
|
47
|
-
import {
|
|
48
|
-
ensureFileDirs,
|
|
49
|
-
saveAttachment,
|
|
50
|
-
type SavedFile
|
|
51
|
-
} from './file-saver.js';
|
|
11
|
+
import { startTunnel, stopTunnel, isTunnelAlive, restartTunnel, startNamedTunnel, restartNamedTunnel } from './tunnel.js';
|
|
12
|
+
import { spawnWorker, stopWorker, getWorkerPort, isWorkerAlive } from './worker.js';
|
|
13
|
+
import { spawnBackend, stopBackend, getBackendPort, isBackendAlive, resetBackendRestarts } from './backend.js';
|
|
14
|
+
import { updateTunnelUrl, startHeartbeat, stopHeartbeat, disconnect } from '../shared/relay.js';
|
|
15
|
+
import { startFluxyAgentQuery, stopFluxyAgentQuery, type RecentMessage } from './fluxy-agent.js';
|
|
16
|
+
import { ensureFileDirs, saveAttachment, type SavedFile } from './file-saver.js';
|
|
52
17
|
import { startViteDevServers, stopViteDevServers } from './vite-dev.js';
|
|
53
18
|
import { startScheduler, stopScheduler } from './scheduler.js';
|
|
54
19
|
import { execSync, spawn as cpSpawn } from 'child_process';
|
|
@@ -57,30 +22,28 @@ const DIST_FLUXY = path.join(PKG_DIR, 'dist-fluxy');
|
|
|
57
22
|
|
|
58
23
|
// Ensure dist-fluxy exists (postinstall may have failed silently)
|
|
59
24
|
if (!fs.existsSync(DIST_FLUXY)) {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
);
|
|
71
|
-
}
|
|
25
|
+
log.info('Building fluxy chat UI (first run)...');
|
|
26
|
+
try {
|
|
27
|
+
execSync('npx vite build --config vite.fluxy.config.ts', {
|
|
28
|
+
cwd: PKG_DIR,
|
|
29
|
+
stdio: 'ignore',
|
|
30
|
+
});
|
|
31
|
+
log.ok('Fluxy chat UI built');
|
|
32
|
+
} catch (err) {
|
|
33
|
+
log.warn(`Failed to build fluxy chat UI: ${err instanceof Error ? err.message : err}`);
|
|
34
|
+
}
|
|
72
35
|
}
|
|
73
36
|
|
|
74
37
|
const MIME_TYPES: Record<string, string> = {
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
38
|
+
'.html': 'text/html',
|
|
39
|
+
'.js': 'application/javascript',
|
|
40
|
+
'.css': 'text/css',
|
|
41
|
+
'.png': 'image/png',
|
|
42
|
+
'.jpg': 'image/jpeg',
|
|
43
|
+
'.svg': 'image/svg+xml',
|
|
44
|
+
'.webm': 'video/webm',
|
|
45
|
+
'.woff2': 'font/woff2',
|
|
46
|
+
'.json': 'application/json',
|
|
84
47
|
};
|
|
85
48
|
|
|
86
49
|
// Service worker content — embedded here so it ships with supervisor/ (always updated)
|
|
@@ -128,1286 +91,955 @@ div{text-align:center}h1{font-size:18px;margin-bottom:8px;color:#e2e8f0}p{font-s
|
|
|
128
91
|
<script src="/fluxy/widget.js"></script></body></html>`;
|
|
129
92
|
|
|
130
93
|
export async function startSupervisor() {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
94
|
+
const config = loadConfig();
|
|
95
|
+
const workerPort = getWorkerPort(config.port);
|
|
96
|
+
const backendPort = getBackendPort(config.port);
|
|
97
|
+
|
|
98
|
+
// Create HTTP server first (Vite needs it for HMR WebSocket)
|
|
99
|
+
// The request handler is set up later via server.on('request')
|
|
100
|
+
const server = http.createServer();
|
|
101
|
+
|
|
102
|
+
// Start Vite dev server — pass supervisor server so Vite attaches HMR WebSocket directly
|
|
103
|
+
console.log('[supervisor] Starting Vite dev server...');
|
|
104
|
+
const vitePorts = await startViteDevServers(config.port, server);
|
|
105
|
+
console.log(`[supervisor] Vite ready — dashboard :${vitePorts.dashboard}`);
|
|
106
|
+
console.log(`[supervisor] Upgrade listeners on server: ${server.listenerCount('upgrade')}`);
|
|
107
|
+
|
|
108
|
+
// Ensure file storage dirs exist
|
|
109
|
+
ensureFileDirs();
|
|
110
|
+
|
|
111
|
+
// Fluxy's AI brain
|
|
112
|
+
let ai: AiProvider | null = null;
|
|
113
|
+
if (config.ai.provider && (config.ai.apiKey || config.ai.provider === 'ollama')) {
|
|
114
|
+
ai = createProvider(config.ai.provider, config.ai.apiKey, config.ai.baseUrl);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Fluxy chat conversations (in-memory for now)
|
|
118
|
+
const conversations = new Map<WebSocket, ChatMessage[]>();
|
|
119
|
+
|
|
120
|
+
// Track active DB conversation per WS client
|
|
121
|
+
const clientConvs = new Map<WebSocket, string>();
|
|
122
|
+
|
|
123
|
+
// Track current stream state so reconnecting clients get caught up
|
|
124
|
+
let currentStreamConvId: string | null = null;
|
|
125
|
+
let currentStreamBuffer = '';
|
|
126
|
+
|
|
127
|
+
/** Call worker API endpoints */
|
|
128
|
+
async function workerApi(path: string, method = 'GET', body?: any) {
|
|
129
|
+
const opts: RequestInit = { method, headers: { 'Content-Type': 'application/json' } };
|
|
130
|
+
if (body) opts.body = JSON.stringify(body);
|
|
131
|
+
const res = await fetch(`http://127.0.0.1:${workerPort}${path}`, opts);
|
|
132
|
+
return res.json();
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** Broadcast to all fluxy WS clients EXCEPT sender */
|
|
136
|
+
function broadcastFluxyExcept(sender: WebSocket, type: string, data: any) {
|
|
137
|
+
const msg = JSON.stringify({ type, data });
|
|
138
|
+
for (const client of fluxyWss.clients) {
|
|
139
|
+
if (client !== sender && client.readyState === WebSocket.OPEN) client.send(msg);
|
|
161
140
|
}
|
|
141
|
+
}
|
|
162
142
|
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
// Track active DB conversation per WS client
|
|
167
|
-
const clientConvs = new Map<WebSocket, string>();
|
|
168
|
-
|
|
169
|
-
// Track current stream state so reconnecting clients get caught up
|
|
170
|
-
let currentStreamConvId: string | null = null;
|
|
171
|
-
let currentStreamBuffer = '';
|
|
172
|
-
|
|
173
|
-
/** Call worker API endpoints */
|
|
174
|
-
async function workerApi(path: string, method = 'GET', body?: any) {
|
|
175
|
-
const opts: RequestInit = {
|
|
176
|
-
method,
|
|
177
|
-
headers: { 'Content-Type': 'application/json' }
|
|
178
|
-
};
|
|
179
|
-
if (body) opts.body = JSON.stringify(body);
|
|
180
|
-
const res = await fetch(`http://127.0.0.1:${workerPort}${path}`, opts);
|
|
181
|
-
return res.json();
|
|
182
|
-
}
|
|
143
|
+
// ── Auth middleware ──
|
|
144
|
+
const tokenCache = new Map<string, number>(); // token → expiry timestamp
|
|
145
|
+
const TOKEN_CACHE_TTL = 60_000; // 60s
|
|
183
146
|
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
for (const client of fluxyWss.clients) {
|
|
188
|
-
if (client !== sender && client.readyState === WebSocket.OPEN)
|
|
189
|
-
client.send(msg);
|
|
190
|
-
}
|
|
191
|
-
}
|
|
147
|
+
async function validateToken(token: string): Promise<boolean> {
|
|
148
|
+
const cached = tokenCache.get(token);
|
|
149
|
+
if (cached && cached > Date.now()) return true;
|
|
192
150
|
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
}
|
|
209
|
-
);
|
|
210
|
-
const data = (await res.json()) as { valid: boolean };
|
|
211
|
-
if (data.valid) {
|
|
212
|
-
tokenCache.set(token, Date.now() + TOKEN_CACHE_TTL);
|
|
213
|
-
return true;
|
|
214
|
-
}
|
|
215
|
-
tokenCache.delete(token);
|
|
216
|
-
return false;
|
|
217
|
-
} catch {
|
|
218
|
-
return false;
|
|
219
|
-
}
|
|
151
|
+
try {
|
|
152
|
+
const res = await fetch(`http://127.0.0.1:${workerPort}/api/portal/validate-token`, {
|
|
153
|
+
method: 'POST',
|
|
154
|
+
headers: { 'Content-Type': 'application/json' },
|
|
155
|
+
body: JSON.stringify({ token }),
|
|
156
|
+
});
|
|
157
|
+
const data = await res.json() as { valid: boolean };
|
|
158
|
+
if (data.valid) {
|
|
159
|
+
tokenCache.set(token, Date.now() + TOKEN_CACHE_TTL);
|
|
160
|
+
return true;
|
|
161
|
+
}
|
|
162
|
+
tokenCache.delete(token);
|
|
163
|
+
return false;
|
|
164
|
+
} catch {
|
|
165
|
+
return false;
|
|
220
166
|
}
|
|
167
|
+
}
|
|
221
168
|
|
|
222
|
-
|
|
169
|
+
let authRequiredCache: { value: boolean; expires: number } | null = null;
|
|
223
170
|
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
171
|
+
async function isAuthRequired(): Promise<boolean> {
|
|
172
|
+
if (authRequiredCache && authRequiredCache.expires > Date.now()) return authRequiredCache.value;
|
|
173
|
+
try {
|
|
174
|
+
const res = await fetch(`http://127.0.0.1:${workerPort}/api/onboard/status`);
|
|
175
|
+
const data = await res.json() as { portalConfigured: boolean };
|
|
176
|
+
const required = !!data.portalConfigured;
|
|
177
|
+
authRequiredCache = { value: required, expires: Date.now() + 30_000 };
|
|
178
|
+
return required;
|
|
179
|
+
} catch {
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
const AUTH_EXEMPT_ROUTES = [
|
|
185
|
+
'POST /api/portal/login',
|
|
186
|
+
'GET /api/portal/login',
|
|
187
|
+
'POST /api/portal/validate-token',
|
|
188
|
+
'GET /api/portal/validate-token',
|
|
189
|
+
'GET /api/onboard/status',
|
|
190
|
+
'GET /api/health',
|
|
191
|
+
'POST /api/onboard',
|
|
192
|
+
'GET /api/push/vapid-public-key',
|
|
193
|
+
'GET /api/push/status',
|
|
194
|
+
'POST /api/auth/claude/start',
|
|
195
|
+
'POST /api/auth/claude/exchange',
|
|
196
|
+
'GET /api/auth/claude/status',
|
|
197
|
+
'POST /api/auth/codex/start',
|
|
198
|
+
'POST /api/auth/codex/cancel',
|
|
199
|
+
'GET /api/auth/codex/status',
|
|
200
|
+
'POST /api/portal/totp/setup',
|
|
201
|
+
'POST /api/portal/totp/verify-setup',
|
|
202
|
+
'POST /api/portal/totp/disable',
|
|
203
|
+
'GET /api/portal/totp/status',
|
|
204
|
+
'GET /api/portal/login/totp',
|
|
205
|
+
'POST /api/portal/devices/revoke',
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
function isExemptRoute(method: string, url: string): boolean {
|
|
209
|
+
const path = url.split('?')[0];
|
|
210
|
+
return AUTH_EXEMPT_ROUTES.some((r) => {
|
|
211
|
+
const [m, p] = r.split(' ');
|
|
212
|
+
return method === m && path === p;
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
// HTTP request handler — proxies to Vite dev servers + worker API
|
|
217
|
+
server.on('request', async (req, res) => {
|
|
218
|
+
// Fluxy widget — served directly (not part of Vite build)
|
|
219
|
+
if (req.url === '/fluxy/widget.js') {
|
|
220
|
+
console.log('[supervisor] Serving /fluxy/widget.js directly');
|
|
221
|
+
res.writeHead(200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-cache' });
|
|
222
|
+
res.end(fs.readFileSync(paths.widgetJs));
|
|
223
|
+
return;
|
|
241
224
|
}
|
|
242
225
|
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
'GET /api/onboard/status',
|
|
249
|
-
'GET /api/health',
|
|
250
|
-
'POST /api/onboard',
|
|
251
|
-
'GET /api/push/vapid-public-key',
|
|
252
|
-
'GET /api/push/status',
|
|
253
|
-
'POST /api/auth/claude/start',
|
|
254
|
-
'POST /api/auth/claude/exchange',
|
|
255
|
-
'GET /api/auth/claude/status',
|
|
256
|
-
'POST /api/auth/codex/start',
|
|
257
|
-
'POST /api/auth/codex/cancel',
|
|
258
|
-
'GET /api/auth/codex/status',
|
|
259
|
-
'POST /api/portal/totp/setup',
|
|
260
|
-
'POST /api/portal/totp/verify-setup',
|
|
261
|
-
'POST /api/portal/totp/disable',
|
|
262
|
-
'GET /api/portal/totp/status',
|
|
263
|
-
'GET /api/portal/login/totp',
|
|
264
|
-
'POST /api/portal/devices/revoke'
|
|
265
|
-
];
|
|
266
|
-
|
|
267
|
-
function isExemptRoute(method: string, url: string): boolean {
|
|
268
|
-
const path = url.split('?')[0];
|
|
269
|
-
return AUTH_EXEMPT_ROUTES.some(r => {
|
|
270
|
-
const [m, p] = r.split(' ');
|
|
271
|
-
return method === m && path === p;
|
|
272
|
-
});
|
|
226
|
+
// Service worker — served from embedded constant (supervisor/ is always updated)
|
|
227
|
+
if (req.url === '/sw.js' || req.url === '/fluxy/sw.js') {
|
|
228
|
+
res.writeHead(200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-cache' });
|
|
229
|
+
res.end(SW_JS);
|
|
230
|
+
return;
|
|
273
231
|
}
|
|
274
232
|
|
|
275
|
-
//
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
return;
|
|
286
|
-
}
|
|
233
|
+
// App API routes → proxy to user's backend server
|
|
234
|
+
if (req.url?.startsWith('/app/api')) {
|
|
235
|
+
const backendPath = req.url.replace(/^\/app\/api/, '') || '/';
|
|
236
|
+
console.log(`[supervisor] → backend :${backendPort} | ${req.method} ${backendPath}`);
|
|
237
|
+
if (!isBackendAlive()) {
|
|
238
|
+
console.log('[supervisor] Backend down — returning 503');
|
|
239
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
240
|
+
res.end(JSON.stringify({ error: 'Backend is starting...' }));
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
287
243
|
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
}
|
|
244
|
+
const proxy = http.request(
|
|
245
|
+
{ host: '127.0.0.1', port: backendPort, path: backendPath, method: req.method, headers: req.headers },
|
|
246
|
+
(proxyRes) => {
|
|
247
|
+
res.writeHead(proxyRes.statusCode!, proxyRes.headers);
|
|
248
|
+
proxyRes.pipe(res);
|
|
249
|
+
},
|
|
250
|
+
);
|
|
251
|
+
proxy.on('error', (e) => {
|
|
252
|
+
console.error(`[supervisor] Backend proxy error: ${req.url}`, e.message);
|
|
253
|
+
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
254
|
+
res.end(JSON.stringify({ error: 'Backend unavailable' }));
|
|
255
|
+
});
|
|
256
|
+
req.pipe(proxy);
|
|
257
|
+
return;
|
|
258
|
+
}
|
|
297
259
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
res.end(JSON.stringify({ error: 'Backend is starting...' }));
|
|
308
|
-
return;
|
|
309
|
-
}
|
|
260
|
+
// API routes → proxy to worker
|
|
261
|
+
if (req.url?.startsWith('/api')) {
|
|
262
|
+
console.log(`[supervisor] → worker :${workerPort} | ${req.method} ${req.url}`);
|
|
263
|
+
if (!isWorkerAlive()) {
|
|
264
|
+
console.log('[supervisor] Worker down — returning 503');
|
|
265
|
+
res.writeHead(503, { 'Content-Type': 'text/html' });
|
|
266
|
+
res.end(RECOVERING_HTML);
|
|
267
|
+
return;
|
|
268
|
+
}
|
|
310
269
|
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
proxyRes.pipe(res);
|
|
322
|
-
}
|
|
323
|
-
);
|
|
324
|
-
proxy.on('error', e => {
|
|
325
|
-
console.error(
|
|
326
|
-
`[supervisor] Backend proxy error: ${req.url}`,
|
|
327
|
-
e.message
|
|
328
|
-
);
|
|
329
|
-
res.writeHead(503, { 'Content-Type': 'application/json' });
|
|
330
|
-
res.end(JSON.stringify({ error: 'Backend unavailable' }));
|
|
331
|
-
});
|
|
332
|
-
req.pipe(proxy);
|
|
270
|
+
// Auth check for mutation routes (POST/PUT/DELETE) — GET/HEAD are read-only, skip auth
|
|
271
|
+
const method = req.method || 'GET';
|
|
272
|
+
if (method !== 'GET' && method !== 'HEAD' && !isExemptRoute(method, req.url || '')) {
|
|
273
|
+
const needsAuth = await isAuthRequired();
|
|
274
|
+
if (needsAuth) {
|
|
275
|
+
const authHeader = req.headers['authorization'];
|
|
276
|
+
const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
|
|
277
|
+
if (!token || !(await validateToken(token))) {
|
|
278
|
+
res.writeHead(401, { 'Content-Type': 'application/json' });
|
|
279
|
+
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
333
280
|
return;
|
|
281
|
+
}
|
|
334
282
|
}
|
|
283
|
+
}
|
|
335
284
|
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
method !== 'GET' &&
|
|
352
|
-
method !== 'HEAD' &&
|
|
353
|
-
!isExemptRoute(method, req.url || '')
|
|
354
|
-
) {
|
|
355
|
-
const needsAuth = await isAuthRequired();
|
|
356
|
-
if (needsAuth) {
|
|
357
|
-
const authHeader = req.headers['authorization'];
|
|
358
|
-
const token = authHeader?.startsWith('Bearer ')
|
|
359
|
-
? authHeader.slice(7)
|
|
360
|
-
: null;
|
|
361
|
-
if (!token || !(await validateToken(token))) {
|
|
362
|
-
res.writeHead(401, {
|
|
363
|
-
'Content-Type': 'application/json'
|
|
364
|
-
});
|
|
365
|
-
res.end(JSON.stringify({ error: 'Unauthorized' }));
|
|
366
|
-
return;
|
|
367
|
-
}
|
|
368
|
-
}
|
|
369
|
-
}
|
|
370
|
-
|
|
371
|
-
const proxy = http.request(
|
|
372
|
-
{
|
|
373
|
-
host: '127.0.0.1',
|
|
374
|
-
port: workerPort,
|
|
375
|
-
path: req.url,
|
|
376
|
-
method: req.method,
|
|
377
|
-
headers: req.headers
|
|
378
|
-
},
|
|
379
|
-
proxyRes => {
|
|
380
|
-
res.writeHead(proxyRes.statusCode!, proxyRes.headers);
|
|
381
|
-
proxyRes.pipe(res);
|
|
382
|
-
}
|
|
383
|
-
);
|
|
384
|
-
proxy.on('error', e => {
|
|
385
|
-
console.error(
|
|
386
|
-
`[supervisor] Worker proxy error: ${req.url}`,
|
|
387
|
-
e.message
|
|
388
|
-
);
|
|
389
|
-
res.writeHead(503, { 'Content-Type': 'text/html' });
|
|
390
|
-
res.end(RECOVERING_HTML);
|
|
391
|
-
});
|
|
392
|
-
req.pipe(proxy);
|
|
393
|
-
return;
|
|
394
|
-
}
|
|
285
|
+
const proxy = http.request(
|
|
286
|
+
{ host: '127.0.0.1', port: workerPort, path: req.url, method: req.method, headers: req.headers },
|
|
287
|
+
(proxyRes) => {
|
|
288
|
+
res.writeHead(proxyRes.statusCode!, proxyRes.headers);
|
|
289
|
+
proxyRes.pipe(res);
|
|
290
|
+
},
|
|
291
|
+
);
|
|
292
|
+
proxy.on('error', (e) => {
|
|
293
|
+
console.error(`[supervisor] Worker proxy error: ${req.url}`, e.message);
|
|
294
|
+
res.writeHead(503, { 'Content-Type': 'text/html' });
|
|
295
|
+
res.end(RECOVERING_HTML);
|
|
296
|
+
});
|
|
297
|
+
req.pipe(proxy);
|
|
298
|
+
return;
|
|
299
|
+
}
|
|
395
300
|
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
301
|
+
// Fluxy routes → serve pre-built static files from dist-fluxy/
|
|
302
|
+
// Note: must check '/fluxy/' (with slash) to avoid matching '/fluxy_tilts.webm' etc.
|
|
303
|
+
if (req.url === '/fluxy' || req.url?.startsWith('/fluxy/')) {
|
|
304
|
+
// Strip /fluxy prefix and resolve file path
|
|
305
|
+
let filePath = req.url!.replace(/^\/fluxy\/?/, '') || 'fluxy.html';
|
|
306
|
+
// Strip query strings (e.g. ?v=xxx)
|
|
307
|
+
filePath = filePath.split('?')[0];
|
|
308
|
+
const fullPath = path.join(DIST_FLUXY, filePath);
|
|
309
|
+
|
|
310
|
+
// Security: prevent directory traversal
|
|
311
|
+
if (!fullPath.startsWith(DIST_FLUXY)) {
|
|
312
|
+
res.writeHead(403);
|
|
313
|
+
res.end('Forbidden');
|
|
314
|
+
return;
|
|
315
|
+
}
|
|
411
316
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
'Cache-Control': cacheControl
|
|
426
|
-
});
|
|
427
|
-
fs.createReadStream(fullPath).pipe(res);
|
|
428
|
-
} else {
|
|
429
|
-
res.writeHead(404);
|
|
430
|
-
res.end('Not found');
|
|
431
|
-
}
|
|
432
|
-
} catch {
|
|
433
|
-
res.writeHead(404);
|
|
434
|
-
res.end('Not found');
|
|
435
|
-
}
|
|
436
|
-
return;
|
|
317
|
+
try {
|
|
318
|
+
const stat = fs.statSync(fullPath);
|
|
319
|
+
if (stat.isFile()) {
|
|
320
|
+
const ext = path.extname(fullPath);
|
|
321
|
+
const mime = MIME_TYPES[ext] || 'application/octet-stream';
|
|
322
|
+
// HTML files: no-cache so rebuilds are picked up immediately
|
|
323
|
+
// Hashed assets (.js, .css): immutable caching
|
|
324
|
+
const cacheControl = ext === '.html' ? 'no-cache' : 'public, max-age=31536000, immutable';
|
|
325
|
+
res.writeHead(200, { 'Content-Type': mime, 'Cache-Control': cacheControl });
|
|
326
|
+
fs.createReadStream(fullPath).pipe(res);
|
|
327
|
+
} else {
|
|
328
|
+
res.writeHead(404);
|
|
329
|
+
res.end('Not found');
|
|
437
330
|
}
|
|
331
|
+
} catch {
|
|
332
|
+
res.writeHead(404);
|
|
333
|
+
res.end('Not found');
|
|
334
|
+
}
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
438
337
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
res.writeHead(proxyRes.statusCode!, proxyRes.headers);
|
|
453
|
-
proxyRes.pipe(res);
|
|
454
|
-
}
|
|
455
|
-
);
|
|
456
|
-
proxy.on('error', e => {
|
|
457
|
-
console.error(
|
|
458
|
-
`[supervisor] Dashboard Vite proxy error: ${req.url}`,
|
|
459
|
-
e.message
|
|
460
|
-
);
|
|
461
|
-
res.writeHead(503, { 'Content-Type': 'text/html' });
|
|
462
|
-
res.end(RECOVERING_HTML);
|
|
463
|
-
});
|
|
464
|
-
req.pipe(proxy);
|
|
338
|
+
// Everything else → proxy to dashboard Vite dev server
|
|
339
|
+
console.log(`[supervisor] → dashboard Vite :${vitePorts.dashboard} | ${req.method} ${req.url}`);
|
|
340
|
+
const proxy = http.request(
|
|
341
|
+
{ host: '127.0.0.1', port: vitePorts.dashboard, path: req.url, method: req.method, headers: req.headers },
|
|
342
|
+
(proxyRes) => {
|
|
343
|
+
res.writeHead(proxyRes.statusCode!, proxyRes.headers);
|
|
344
|
+
proxyRes.pipe(res);
|
|
345
|
+
},
|
|
346
|
+
);
|
|
347
|
+
proxy.on('error', (e) => {
|
|
348
|
+
console.error(`[supervisor] Dashboard Vite proxy error: ${req.url}`, e.message);
|
|
349
|
+
res.writeHead(503, { 'Content-Type': 'text/html' });
|
|
350
|
+
res.end(RECOVERING_HTML);
|
|
465
351
|
});
|
|
352
|
+
req.pipe(proxy);
|
|
353
|
+
});
|
|
466
354
|
|
|
467
|
-
|
|
468
|
-
|
|
355
|
+
// WebSocket: Fluxy chat + proxy worker WS
|
|
356
|
+
const fluxyWss = new WebSocketServer({ noServer: true });
|
|
469
357
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
358
|
+
/** Send a message to all connected fluxy WS clients */
|
|
359
|
+
function broadcastFluxy(type: string, data: any = {}) {
|
|
360
|
+
const msg = JSON.stringify({ type, data });
|
|
361
|
+
for (const client of fluxyWss.clients) {
|
|
362
|
+
if (client.readyState === WebSocket.OPEN) client.send(msg);
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
fluxyWss.on('connection', (ws) => {
|
|
367
|
+
log.info('Fluxy chat connected');
|
|
368
|
+
let convId = Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
369
|
+
conversations.set(ws, []);
|
|
370
|
+
|
|
371
|
+
// Send current streaming state so reconnecting clients can catch up
|
|
372
|
+
if (agentQueryActive && currentStreamConvId) {
|
|
373
|
+
ws.send(JSON.stringify({
|
|
374
|
+
type: 'chat:state',
|
|
375
|
+
data: {
|
|
376
|
+
streaming: true,
|
|
377
|
+
conversationId: currentStreamConvId,
|
|
378
|
+
buffer: currentStreamBuffer,
|
|
379
|
+
},
|
|
380
|
+
}));
|
|
476
381
|
}
|
|
477
382
|
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
let convId =
|
|
481
|
-
Math.random().toString(36).slice(2) + Date.now().toString(36);
|
|
482
|
-
conversations.set(ws, []);
|
|
483
|
-
|
|
484
|
-
// Send current streaming state so reconnecting clients can catch up
|
|
485
|
-
if (agentQueryActive && currentStreamConvId) {
|
|
486
|
-
ws.send(
|
|
487
|
-
JSON.stringify({
|
|
488
|
-
type: 'chat:state',
|
|
489
|
-
data: {
|
|
490
|
-
streaming: true,
|
|
491
|
-
conversationId: currentStreamConvId,
|
|
492
|
-
buffer: currentStreamBuffer
|
|
493
|
-
}
|
|
494
|
-
})
|
|
495
|
-
);
|
|
496
|
-
}
|
|
497
|
-
|
|
498
|
-
ws.on('message', raw => {
|
|
499
|
-
const rawStr = raw.toString();
|
|
383
|
+
ws.on('message', (raw) => {
|
|
384
|
+
const rawStr = raw.toString();
|
|
500
385
|
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
386
|
+
// Heartbeat
|
|
387
|
+
if (rawStr === 'ping') {
|
|
388
|
+
if (ws.readyState === WebSocket.OPEN) ws.send('pong');
|
|
389
|
+
return;
|
|
390
|
+
}
|
|
506
391
|
|
|
507
|
-
|
|
392
|
+
const msg = JSON.parse(rawStr);
|
|
508
393
|
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
JSON.stringify({
|
|
521
|
-
type: 'whisper:result',
|
|
522
|
-
data: result
|
|
523
|
-
})
|
|
524
|
-
);
|
|
525
|
-
}
|
|
526
|
-
} catch (err: any) {
|
|
527
|
-
if (ws.readyState === WebSocket.OPEN) {
|
|
528
|
-
ws.send(
|
|
529
|
-
JSON.stringify({
|
|
530
|
-
type: 'whisper:result',
|
|
531
|
-
data: { error: err.message }
|
|
532
|
-
})
|
|
533
|
-
);
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
})();
|
|
537
|
-
return;
|
|
394
|
+
// Whisper transcription via WebSocket (bypasses relay POST issues)
|
|
395
|
+
if (msg.type === 'whisper:transcribe') {
|
|
396
|
+
(async () => {
|
|
397
|
+
try {
|
|
398
|
+
const result = await workerApi('/api/whisper/transcribe', 'POST', { audio: msg.data.audio });
|
|
399
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
400
|
+
ws.send(JSON.stringify({ type: 'whisper:result', data: result }));
|
|
401
|
+
}
|
|
402
|
+
} catch (err: any) {
|
|
403
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
404
|
+
ws.send(JSON.stringify({ type: 'whisper:result', data: { error: err.message } }));
|
|
538
405
|
}
|
|
406
|
+
}
|
|
407
|
+
})();
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
539
410
|
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
type: 'settings:saved',
|
|
553
|
-
data: result
|
|
554
|
-
})
|
|
555
|
-
);
|
|
556
|
-
}
|
|
557
|
-
} catch (err: any) {
|
|
558
|
-
log.error(
|
|
559
|
-
`[fluxy] settings:save failed: ${err.message}`
|
|
560
|
-
);
|
|
561
|
-
if (ws.readyState === WebSocket.OPEN) {
|
|
562
|
-
ws.send(
|
|
563
|
-
JSON.stringify({
|
|
564
|
-
type: 'settings:save-error',
|
|
565
|
-
data: { error: err.message }
|
|
566
|
-
})
|
|
567
|
-
);
|
|
568
|
-
}
|
|
569
|
-
}
|
|
570
|
-
})();
|
|
571
|
-
return;
|
|
411
|
+
// Save settings via WebSocket (bypasses relay POST issues)
|
|
412
|
+
if (msg.type === 'settings:save') {
|
|
413
|
+
(async () => {
|
|
414
|
+
try {
|
|
415
|
+
const result = await workerApi('/api/onboard', 'POST', msg.data);
|
|
416
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
417
|
+
ws.send(JSON.stringify({ type: 'settings:saved', data: result }));
|
|
418
|
+
}
|
|
419
|
+
} catch (err: any) {
|
|
420
|
+
log.error(`[fluxy] settings:save failed: ${err.message}`);
|
|
421
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
422
|
+
ws.send(JSON.stringify({ type: 'settings:save-error', data: { error: err.message } }));
|
|
572
423
|
}
|
|
424
|
+
}
|
|
425
|
+
})();
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
573
428
|
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
429
|
+
// Switch tunnel mode at runtime (off ↔ quick)
|
|
430
|
+
if (msg.type === 'tunnel:switch') {
|
|
431
|
+
(async () => {
|
|
432
|
+
try {
|
|
433
|
+
const newMode = msg.data?.mode as 'off' | 'quick';
|
|
434
|
+
const cfg = loadConfig();
|
|
435
|
+
|
|
436
|
+
if (newMode === 'off') {
|
|
437
|
+
// Stop tunnel, heartbeat, relay, watchdog
|
|
438
|
+
stopHeartbeat();
|
|
439
|
+
if (cfg.relay?.token) {
|
|
440
|
+
try { await disconnect(cfg.relay.token); } catch {}
|
|
441
|
+
}
|
|
442
|
+
stopTunnel();
|
|
443
|
+
if (watchdogInterval) { clearInterval(watchdogInterval); watchdogInterval = null; }
|
|
444
|
+
tunnelUrl = null;
|
|
445
|
+
cfg.tunnel.mode = 'off';
|
|
446
|
+
delete cfg.tunnelUrl;
|
|
447
|
+
saveConfig(cfg);
|
|
448
|
+
log.ok('[tunnel:switch] Switched to off — tunnel stopped');
|
|
449
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
450
|
+
ws.send(JSON.stringify({ type: 'tunnel:switched', data: { mode: 'off' } }));
|
|
451
|
+
}
|
|
452
|
+
} else if (newMode === 'quick') {
|
|
453
|
+
// Start tunnel
|
|
454
|
+
const newUrl = await startTunnel(cfg.port);
|
|
455
|
+
tunnelUrl = newUrl;
|
|
456
|
+
cfg.tunnel.mode = 'quick';
|
|
457
|
+
cfg.tunnelUrl = newUrl;
|
|
458
|
+
saveConfig(cfg);
|
|
459
|
+
log.ok(`[tunnel:switch] Tunnel started: ${newUrl}`);
|
|
460
|
+
|
|
461
|
+
// Wait for local server readiness
|
|
462
|
+
for (let i = 0; i < 15; i++) {
|
|
463
|
+
try {
|
|
464
|
+
const res = await fetch(`http://127.0.0.1:${cfg.port}/api/health?_cb=${Date.now()}`, {
|
|
465
|
+
signal: AbortSignal.timeout(3000),
|
|
466
|
+
});
|
|
467
|
+
if (res.ok) break;
|
|
468
|
+
} catch {}
|
|
469
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// Reconnect relay if handle exists
|
|
473
|
+
if (cfg.relay?.token) {
|
|
474
|
+
try {
|
|
475
|
+
await updateTunnelUrl(cfg.relay.token, newUrl);
|
|
476
|
+
startHeartbeat(cfg.relay.token, newUrl);
|
|
477
|
+
log.ok('[tunnel:switch] Relay reconnected');
|
|
478
|
+
} catch (err) {
|
|
479
|
+
log.warn(`[tunnel:switch] Relay reconnect: ${err instanceof Error ? err.message : err}`);
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
// Re-create watchdog
|
|
484
|
+
if (watchdogInterval) clearInterval(watchdogInterval);
|
|
485
|
+
let lastTick = Date.now();
|
|
486
|
+
let healthCounter = 0;
|
|
487
|
+
watchdogInterval = setInterval(async () => {
|
|
488
|
+
const now = Date.now();
|
|
489
|
+
const wakeGap = now - lastTick > 60_000;
|
|
490
|
+
const periodicCheck = ++healthCounter % 10 === 0;
|
|
491
|
+
lastTick = now;
|
|
492
|
+
if (wakeGap || periodicCheck) {
|
|
493
|
+
const alive = await isTunnelAlive(tunnelUrl!, cfg.port);
|
|
494
|
+
if (!alive) {
|
|
495
|
+
log.warn('Tunnel dead, restarting...');
|
|
577
496
|
try {
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
watchdogInterval = null;
|
|
593
|
-
}
|
|
594
|
-
tunnelUrl = null;
|
|
595
|
-
cfg.tunnel.mode = 'off';
|
|
596
|
-
delete cfg.tunnelUrl;
|
|
597
|
-
saveConfig(cfg);
|
|
598
|
-
log.ok(
|
|
599
|
-
'[tunnel:switch] Switched to off — tunnel stopped'
|
|
600
|
-
);
|
|
601
|
-
if (ws.readyState === WebSocket.OPEN) {
|
|
602
|
-
ws.send(
|
|
603
|
-
JSON.stringify({
|
|
604
|
-
type: 'tunnel:switched',
|
|
605
|
-
data: { mode: 'off' }
|
|
606
|
-
})
|
|
607
|
-
);
|
|
608
|
-
}
|
|
609
|
-
} else if (newMode === 'quick') {
|
|
610
|
-
// Start tunnel
|
|
611
|
-
const newUrl = await startTunnel(cfg.port);
|
|
612
|
-
tunnelUrl = newUrl;
|
|
613
|
-
cfg.tunnel.mode = 'quick';
|
|
614
|
-
cfg.tunnelUrl = newUrl;
|
|
615
|
-
saveConfig(cfg);
|
|
616
|
-
log.ok(`[tunnel:switch] Tunnel started: ${newUrl}`);
|
|
617
|
-
|
|
618
|
-
// Wait for local server readiness
|
|
619
|
-
for (let i = 0; i < 15; i++) {
|
|
620
|
-
try {
|
|
621
|
-
const res = await fetch(
|
|
622
|
-
`http://127.0.0.1:${cfg.port}/api/health?_cb=${Date.now()}`,
|
|
623
|
-
{
|
|
624
|
-
signal: AbortSignal.timeout(3000)
|
|
625
|
-
}
|
|
626
|
-
);
|
|
627
|
-
if (res.ok) break;
|
|
628
|
-
} catch {}
|
|
629
|
-
await new Promise(r => setTimeout(r, 1000));
|
|
630
|
-
}
|
|
631
|
-
|
|
632
|
-
// Reconnect relay if handle exists
|
|
633
|
-
if (cfg.relay?.token) {
|
|
634
|
-
try {
|
|
635
|
-
await updateTunnelUrl(
|
|
636
|
-
cfg.relay.token,
|
|
637
|
-
newUrl
|
|
638
|
-
);
|
|
639
|
-
startHeartbeat(cfg.relay.token, newUrl);
|
|
640
|
-
log.ok('[tunnel:switch] Relay reconnected');
|
|
641
|
-
} catch (err) {
|
|
642
|
-
log.warn(
|
|
643
|
-
`[tunnel:switch] Relay reconnect: ${err instanceof Error ? err.message : err}`
|
|
644
|
-
);
|
|
645
|
-
}
|
|
646
|
-
}
|
|
647
|
-
|
|
648
|
-
// Re-create watchdog
|
|
649
|
-
if (watchdogInterval)
|
|
650
|
-
clearInterval(watchdogInterval);
|
|
651
|
-
let lastTick = Date.now();
|
|
652
|
-
let healthCounter = 0;
|
|
653
|
-
watchdogInterval = setInterval(async () => {
|
|
654
|
-
const now = Date.now();
|
|
655
|
-
const wakeGap = now - lastTick > 60_000;
|
|
656
|
-
const periodicCheck =
|
|
657
|
-
++healthCounter % 10 === 0;
|
|
658
|
-
lastTick = now;
|
|
659
|
-
if (wakeGap || periodicCheck) {
|
|
660
|
-
const alive = await isTunnelAlive(
|
|
661
|
-
tunnelUrl!,
|
|
662
|
-
cfg.port
|
|
663
|
-
);
|
|
664
|
-
if (!alive) {
|
|
665
|
-
log.warn('Tunnel dead, restarting...');
|
|
666
|
-
try {
|
|
667
|
-
const restartedUrl =
|
|
668
|
-
await restartTunnel(cfg.port);
|
|
669
|
-
await new Promise(r =>
|
|
670
|
-
setTimeout(r, 3000)
|
|
671
|
-
);
|
|
672
|
-
tunnelUrl = restartedUrl;
|
|
673
|
-
const latestCfg = loadConfig();
|
|
674
|
-
latestCfg.tunnelUrl = restartedUrl;
|
|
675
|
-
saveConfig(latestCfg);
|
|
676
|
-
if (latestCfg.relay?.token) {
|
|
677
|
-
stopHeartbeat();
|
|
678
|
-
startHeartbeat(
|
|
679
|
-
latestCfg.relay.token,
|
|
680
|
-
restartedUrl
|
|
681
|
-
);
|
|
682
|
-
await updateTunnelUrl(
|
|
683
|
-
latestCfg.relay.token,
|
|
684
|
-
restartedUrl
|
|
685
|
-
);
|
|
686
|
-
}
|
|
687
|
-
log.ok(
|
|
688
|
-
`Tunnel restored: ${restartedUrl}`
|
|
689
|
-
);
|
|
690
|
-
} catch (err) {
|
|
691
|
-
log.error(
|
|
692
|
-
`Tunnel restart failed: ${err instanceof Error ? err.message : err}`
|
|
693
|
-
);
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
}
|
|
697
|
-
}, 30_000);
|
|
698
|
-
|
|
699
|
-
if (ws.readyState === WebSocket.OPEN) {
|
|
700
|
-
ws.send(
|
|
701
|
-
JSON.stringify({
|
|
702
|
-
type: 'tunnel:switched',
|
|
703
|
-
data: {
|
|
704
|
-
mode: 'quick',
|
|
705
|
-
tunnelUrl: newUrl
|
|
706
|
-
}
|
|
707
|
-
})
|
|
708
|
-
);
|
|
709
|
-
}
|
|
710
|
-
}
|
|
711
|
-
} catch (err: any) {
|
|
712
|
-
log.error(`[tunnel:switch] Error: ${err.message}`);
|
|
713
|
-
if (ws.readyState === WebSocket.OPEN) {
|
|
714
|
-
ws.send(
|
|
715
|
-
JSON.stringify({
|
|
716
|
-
type: 'tunnel:switch-error',
|
|
717
|
-
data: { error: err.message }
|
|
718
|
-
})
|
|
719
|
-
);
|
|
720
|
-
}
|
|
497
|
+
const restartedUrl = await restartTunnel(cfg.port);
|
|
498
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
499
|
+
tunnelUrl = restartedUrl;
|
|
500
|
+
const latestCfg = loadConfig();
|
|
501
|
+
latestCfg.tunnelUrl = restartedUrl;
|
|
502
|
+
saveConfig(latestCfg);
|
|
503
|
+
if (latestCfg.relay?.token) {
|
|
504
|
+
stopHeartbeat();
|
|
505
|
+
startHeartbeat(latestCfg.relay.token, restartedUrl);
|
|
506
|
+
await updateTunnelUrl(latestCfg.relay.token, restartedUrl);
|
|
507
|
+
}
|
|
508
|
+
log.ok(`Tunnel restored: ${restartedUrl}`);
|
|
509
|
+
} catch (err) {
|
|
510
|
+
log.error(`Tunnel restart failed: ${err instanceof Error ? err.message : err}`);
|
|
721
511
|
}
|
|
722
|
-
|
|
723
|
-
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
}, 30_000);
|
|
515
|
+
|
|
516
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
517
|
+
ws.send(JSON.stringify({ type: 'tunnel:switched', data: { mode: 'quick', tunnelUrl: newUrl } }));
|
|
518
|
+
}
|
|
724
519
|
}
|
|
520
|
+
} catch (err: any) {
|
|
521
|
+
log.error(`[tunnel:switch] Error: ${err.message}`);
|
|
522
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
523
|
+
ws.send(JSON.stringify({ type: 'tunnel:switch-error', data: { error: err.message } }));
|
|
524
|
+
}
|
|
525
|
+
}
|
|
526
|
+
})();
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
725
529
|
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
746
|
-
|
|
747
|
-
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
751
|
-
|
|
752
|
-
|
|
753
|
-
(async () => {
|
|
754
|
-
// Save attachments to disk (before try so it's accessible in startFluxyAgentQuery below)
|
|
755
|
-
let savedFiles: SavedFile[] = [];
|
|
756
|
-
if (data.attachments?.length) {
|
|
757
|
-
for (const att of data.attachments) {
|
|
758
|
-
try {
|
|
759
|
-
savedFiles.push(saveAttachment(att));
|
|
760
|
-
} catch (err: any) {
|
|
761
|
-
log.warn(
|
|
762
|
-
`[fluxy] File save error: ${err.message}`
|
|
763
|
-
);
|
|
764
|
-
}
|
|
765
|
-
}
|
|
766
|
-
}
|
|
767
|
-
|
|
768
|
-
try {
|
|
769
|
-
// Check if we have an existing conversation for this client
|
|
770
|
-
let dbConvId = clientConvs.get(ws);
|
|
771
|
-
if (!dbConvId) {
|
|
772
|
-
// Check if there's a current conversation set in settings
|
|
773
|
-
const ctx = await workerApi(
|
|
774
|
-
'/api/context/current'
|
|
775
|
-
);
|
|
776
|
-
if (ctx.conversationId) {
|
|
777
|
-
dbConvId = ctx.conversationId;
|
|
778
|
-
} else {
|
|
779
|
-
// Create a new conversation
|
|
780
|
-
const conv = await workerApi(
|
|
781
|
-
'/api/conversations',
|
|
782
|
-
'POST',
|
|
783
|
-
{
|
|
784
|
-
title: content.slice(0, 80),
|
|
785
|
-
model: freshConfig.ai.model
|
|
786
|
-
}
|
|
787
|
-
);
|
|
788
|
-
dbConvId = conv.id;
|
|
789
|
-
await workerApi(
|
|
790
|
-
'/api/context/set',
|
|
791
|
-
'POST',
|
|
792
|
-
{ conversationId: dbConvId }
|
|
793
|
-
);
|
|
794
|
-
}
|
|
795
|
-
clientConvs.set(ws, dbConvId!);
|
|
796
|
-
// Notify client of the conversation ID
|
|
797
|
-
if (ws.readyState === WebSocket.OPEN) {
|
|
798
|
-
ws.send(
|
|
799
|
-
JSON.stringify({
|
|
800
|
-
type: 'chat:conversation-created',
|
|
801
|
-
data: { conversationId: dbConvId }
|
|
802
|
-
})
|
|
803
|
-
);
|
|
804
|
-
}
|
|
805
|
-
}
|
|
806
|
-
convId = dbConvId!;
|
|
807
|
-
|
|
808
|
-
// Save user message to DB (include attachment metadata as JSON string)
|
|
809
|
-
const meta: any = { model: freshConfig.ai.model };
|
|
810
|
-
if (savedFiles.length) {
|
|
811
|
-
meta.attachments = JSON.stringify(
|
|
812
|
-
savedFiles.map(f => ({
|
|
813
|
-
type: f.type,
|
|
814
|
-
name: f.name,
|
|
815
|
-
mediaType: f.mediaType,
|
|
816
|
-
filePath: f.relPath
|
|
817
|
-
}))
|
|
818
|
-
);
|
|
819
|
-
}
|
|
820
|
-
await workerApi(
|
|
821
|
-
`/api/conversations/${convId}/messages`,
|
|
822
|
-
'POST',
|
|
823
|
-
{
|
|
824
|
-
role: 'user',
|
|
825
|
-
content,
|
|
826
|
-
meta
|
|
827
|
-
}
|
|
828
|
-
);
|
|
829
|
-
|
|
830
|
-
// Broadcast user message to other clients
|
|
831
|
-
broadcastFluxyExcept(ws, 'chat:sync', {
|
|
832
|
-
conversationId: convId,
|
|
833
|
-
message: {
|
|
834
|
-
role: 'user',
|
|
835
|
-
content,
|
|
836
|
-
timestamp: new Date().toISOString()
|
|
837
|
-
}
|
|
838
|
-
});
|
|
839
|
-
} catch (err: any) {
|
|
840
|
-
log.warn(
|
|
841
|
-
`[fluxy] DB persist error: ${err.message}`
|
|
842
|
-
);
|
|
843
|
-
}
|
|
844
|
-
|
|
845
|
-
// Fetch agent/user names and recent messages in parallel
|
|
846
|
-
let botName = 'Fluxy',
|
|
847
|
-
humanName = 'Human';
|
|
848
|
-
let recentMessages: RecentMessage[] = [];
|
|
849
|
-
try {
|
|
850
|
-
const [status, recentRaw] = await Promise.all([
|
|
851
|
-
workerApi(
|
|
852
|
-
'/api/onboard/status'
|
|
853
|
-
) as Promise<any>,
|
|
854
|
-
workerApi(
|
|
855
|
-
`/api/conversations/${convId}/messages/recent?limit=20`
|
|
856
|
-
) as Promise<any[]>
|
|
857
|
-
]);
|
|
858
|
-
botName = status.agentName || 'Fluxy';
|
|
859
|
-
humanName = status.userName || 'Human';
|
|
860
|
-
|
|
861
|
-
// Filter to user/assistant only, exclude the last entry (current message already sent as SDK prompt)
|
|
862
|
-
if (Array.isArray(recentRaw)) {
|
|
863
|
-
const filtered = recentRaw.filter(
|
|
864
|
-
(m: any) =>
|
|
865
|
-
m.role === 'user' ||
|
|
866
|
-
m.role === 'assistant'
|
|
867
|
-
);
|
|
868
|
-
// Slice off the last entry — it's the current user message
|
|
869
|
-
if (filtered.length > 0) {
|
|
870
|
-
recentMessages = filtered
|
|
871
|
-
.slice(0, -1)
|
|
872
|
-
.map((m: any) => ({
|
|
873
|
-
role: m.role as
|
|
874
|
-
| 'user'
|
|
875
|
-
| 'assistant',
|
|
876
|
-
content: m.content
|
|
877
|
-
}));
|
|
878
|
-
}
|
|
879
|
-
}
|
|
880
|
-
} catch {}
|
|
881
|
-
|
|
882
|
-
// Start agent query
|
|
883
|
-
agentQueryActive = true;
|
|
884
|
-
currentStreamConvId = convId;
|
|
885
|
-
currentStreamBuffer = '';
|
|
886
|
-
startFluxyAgentQuery(
|
|
887
|
-
convId,
|
|
888
|
-
content,
|
|
889
|
-
freshConfig.ai.model,
|
|
890
|
-
(type, eventData) => {
|
|
891
|
-
// Track stream buffer for reconnecting clients
|
|
892
|
-
if (type === 'bot:token' && eventData.token) {
|
|
893
|
-
currentStreamBuffer += eventData.token;
|
|
894
|
-
}
|
|
895
|
-
|
|
896
|
-
// Intercept bot:done — Vite HMR handles file changes automatically
|
|
897
|
-
if (type === 'bot:done') {
|
|
898
|
-
agentQueryActive = false;
|
|
899
|
-
currentStreamConvId = null;
|
|
900
|
-
currentStreamBuffer = '';
|
|
901
|
-
// Restart if agent used file tools OR file watcher detected changes during the turn
|
|
902
|
-
if (
|
|
903
|
-
eventData.usedFileTools ||
|
|
904
|
-
pendingBackendRestart
|
|
905
|
-
) {
|
|
906
|
-
console.log(
|
|
907
|
-
'[supervisor] Agent turn ended — restarting backend'
|
|
908
|
-
);
|
|
909
|
-
pendingBackendRestart = false;
|
|
910
|
-
resetBackendRestarts();
|
|
911
|
-
stopBackend().then(() =>
|
|
912
|
-
spawnBackend(backendPort)
|
|
913
|
-
);
|
|
914
|
-
}
|
|
915
|
-
// Run deferred update if agent requested one
|
|
916
|
-
if (pendingUpdate) {
|
|
917
|
-
pendingUpdate = false;
|
|
918
|
-
runDeferredUpdate();
|
|
919
|
-
}
|
|
920
|
-
return; // don't forward bot:done to client
|
|
921
|
-
}
|
|
922
|
-
|
|
923
|
-
// Save assistant response to DB + clear stream state
|
|
924
|
-
if (type === 'bot:response') {
|
|
925
|
-
currentStreamBuffer = '';
|
|
926
|
-
(async () => {
|
|
927
|
-
try {
|
|
928
|
-
await workerApi(
|
|
929
|
-
`/api/conversations/${convId}/messages`,
|
|
930
|
-
'POST',
|
|
931
|
-
{
|
|
932
|
-
role: 'assistant',
|
|
933
|
-
content: eventData.content,
|
|
934
|
-
meta: {
|
|
935
|
-
model: freshConfig.ai
|
|
936
|
-
.model
|
|
937
|
-
}
|
|
938
|
-
}
|
|
939
|
-
);
|
|
940
|
-
} catch (err: any) {
|
|
941
|
-
log.warn(
|
|
942
|
-
`[fluxy] DB persist bot response error: ${err.message}`
|
|
943
|
-
);
|
|
944
|
-
}
|
|
945
|
-
})();
|
|
946
|
-
}
|
|
947
|
-
|
|
948
|
-
// Stream all events to every connected client
|
|
949
|
-
broadcastFluxy(type, eventData);
|
|
950
|
-
},
|
|
951
|
-
data.attachments,
|
|
952
|
-
savedFiles,
|
|
953
|
-
{ botName, humanName },
|
|
954
|
-
recentMessages
|
|
955
|
-
);
|
|
956
|
-
})();
|
|
957
|
-
return;
|
|
530
|
+
// New protocol: { type: 'user:message', data: { content, conversationId? } }
|
|
531
|
+
if (msg.type === 'user:message') {
|
|
532
|
+
const data = msg.data || {};
|
|
533
|
+
const content = data.content;
|
|
534
|
+
if (!content) return;
|
|
535
|
+
if (data.conversationId) convId = data.conversationId;
|
|
536
|
+
|
|
537
|
+
// Re-read config on each message so post-onboard changes are picked up
|
|
538
|
+
const freshConfig = loadConfig();
|
|
539
|
+
const freshAi = (freshConfig.ai.provider && (freshConfig.ai.apiKey || freshConfig.ai.provider === 'ollama'))
|
|
540
|
+
? createProvider(freshConfig.ai.provider, freshConfig.ai.apiKey, freshConfig.ai.baseUrl)
|
|
541
|
+
: null;
|
|
542
|
+
|
|
543
|
+
log.info(`[fluxy] provider=${freshConfig.ai.provider}, model=${freshConfig.ai.model}`);
|
|
544
|
+
|
|
545
|
+
// Route Anthropic through Agent SDK (uses OAuth token, not API key)
|
|
546
|
+
if (freshConfig.ai.provider === 'anthropic') {
|
|
547
|
+
// Server-side persistence: create or reuse DB conversation, save user message
|
|
548
|
+
(async () => {
|
|
549
|
+
// Save attachments to disk (before try so it's accessible in startFluxyAgentQuery below)
|
|
550
|
+
let savedFiles: SavedFile[] = [];
|
|
551
|
+
if (data.attachments?.length) {
|
|
552
|
+
for (const att of data.attachments) {
|
|
553
|
+
try {
|
|
554
|
+
savedFiles.push(saveAttachment(att));
|
|
555
|
+
} catch (err: any) {
|
|
556
|
+
log.warn(`[fluxy] File save error: ${err.message}`);
|
|
958
557
|
}
|
|
558
|
+
}
|
|
559
|
+
}
|
|
959
560
|
|
|
960
|
-
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
if
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
);
|
|
974
|
-
}
|
|
975
|
-
return;
|
|
561
|
+
try {
|
|
562
|
+
// Check if we have an existing conversation for this client
|
|
563
|
+
let dbConvId = clientConvs.get(ws);
|
|
564
|
+
if (!dbConvId) {
|
|
565
|
+
// Check if there's a current conversation set in settings
|
|
566
|
+
const ctx = await workerApi('/api/context/current');
|
|
567
|
+
if (ctx.conversationId) {
|
|
568
|
+
dbConvId = ctx.conversationId;
|
|
569
|
+
} else {
|
|
570
|
+
// Create a new conversation
|
|
571
|
+
const conv = await workerApi('/api/conversations', 'POST', { title: content.slice(0, 80), model: freshConfig.ai.model });
|
|
572
|
+
dbConvId = conv.id;
|
|
573
|
+
await workerApi('/api/context/set', 'POST', { conversationId: dbConvId });
|
|
976
574
|
}
|
|
977
|
-
|
|
575
|
+
clientConvs.set(ws, dbConvId!);
|
|
576
|
+
// Notify client of the conversation ID
|
|
978
577
|
if (ws.readyState === WebSocket.OPEN) {
|
|
979
|
-
|
|
980
|
-
JSON.stringify({
|
|
981
|
-
type: 'bot:typing',
|
|
982
|
-
data: { conversationId: convId }
|
|
983
|
-
})
|
|
984
|
-
);
|
|
578
|
+
ws.send(JSON.stringify({ type: 'chat:conversation-created', data: { conversationId: dbConvId } }));
|
|
985
579
|
}
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
history.push({ role: 'assistant', content: full });
|
|
1008
|
-
if (ws.readyState === WebSocket.OPEN)
|
|
1009
|
-
ws.send(
|
|
1010
|
-
JSON.stringify({
|
|
1011
|
-
type: 'bot:response',
|
|
1012
|
-
data: {
|
|
1013
|
-
conversationId: convId,
|
|
1014
|
-
content: full
|
|
1015
|
-
}
|
|
1016
|
-
})
|
|
1017
|
-
);
|
|
1018
|
-
},
|
|
1019
|
-
err => {
|
|
1020
|
-
if (ws.readyState === WebSocket.OPEN)
|
|
1021
|
-
ws.send(
|
|
1022
|
-
JSON.stringify({
|
|
1023
|
-
type: 'bot:error',
|
|
1024
|
-
data: { error: err.message }
|
|
1025
|
-
})
|
|
1026
|
-
);
|
|
1027
|
-
}
|
|
1028
|
-
);
|
|
1029
|
-
return;
|
|
580
|
+
}
|
|
581
|
+
convId = dbConvId!;
|
|
582
|
+
|
|
583
|
+
// Save user message to DB (include attachment metadata as JSON string)
|
|
584
|
+
const meta: any = { model: freshConfig.ai.model };
|
|
585
|
+
if (savedFiles.length) {
|
|
586
|
+
meta.attachments = JSON.stringify(savedFiles.map((f) => ({
|
|
587
|
+
type: f.type, name: f.name, mediaType: f.mediaType, filePath: f.relPath,
|
|
588
|
+
})));
|
|
589
|
+
}
|
|
590
|
+
await workerApi(`/api/conversations/${convId}/messages`, 'POST', {
|
|
591
|
+
role: 'user', content, meta,
|
|
592
|
+
});
|
|
593
|
+
|
|
594
|
+
// Broadcast user message to other clients
|
|
595
|
+
broadcastFluxyExcept(ws, 'chat:sync', {
|
|
596
|
+
conversationId: convId,
|
|
597
|
+
message: { role: 'user', content, timestamp: new Date().toISOString() },
|
|
598
|
+
});
|
|
599
|
+
} catch (err: any) {
|
|
600
|
+
log.warn(`[fluxy] DB persist error: ${err.message}`);
|
|
1030
601
|
}
|
|
1031
602
|
|
|
1032
|
-
|
|
1033
|
-
|
|
1034
|
-
|
|
1035
|
-
|
|
603
|
+
// Fetch agent/user names and recent messages in parallel
|
|
604
|
+
let botName = 'Fluxy', humanName = 'Human';
|
|
605
|
+
let recentMessages: RecentMessage[] = [];
|
|
606
|
+
try {
|
|
607
|
+
const [status, recentRaw] = await Promise.all([
|
|
608
|
+
workerApi('/api/onboard/status') as Promise<any>,
|
|
609
|
+
workerApi(`/api/conversations/${convId}/messages/recent?limit=20`) as Promise<any[]>,
|
|
610
|
+
]);
|
|
611
|
+
botName = status.agentName || 'Fluxy';
|
|
612
|
+
humanName = status.userName || 'Human';
|
|
613
|
+
|
|
614
|
+
// Filter to user/assistant only, exclude the last entry (current message already sent as SDK prompt)
|
|
615
|
+
if (Array.isArray(recentRaw)) {
|
|
616
|
+
const filtered = recentRaw
|
|
617
|
+
.filter((m: any) => m.role === 'user' || m.role === 'assistant');
|
|
618
|
+
// Slice off the last entry — it's the current user message
|
|
619
|
+
if (filtered.length > 0) {
|
|
620
|
+
recentMessages = filtered.slice(0, -1).map((m: any) => ({
|
|
621
|
+
role: m.role as 'user' | 'assistant',
|
|
622
|
+
content: m.content,
|
|
623
|
+
}));
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
} catch {}
|
|
1036
627
|
|
|
1037
|
-
|
|
628
|
+
// Start agent query
|
|
629
|
+
agentQueryActive = true;
|
|
630
|
+
currentStreamConvId = convId;
|
|
631
|
+
currentStreamBuffer = '';
|
|
632
|
+
startFluxyAgentQuery(convId, content, freshConfig.ai.model, (type, eventData) => {
|
|
633
|
+
// Track stream buffer for reconnecting clients
|
|
634
|
+
if (type === 'bot:token' && eventData.token) {
|
|
635
|
+
currentStreamBuffer += eventData.token;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
// Intercept bot:done — Vite HMR handles file changes automatically
|
|
639
|
+
if (type === 'bot:done') {
|
|
640
|
+
agentQueryActive = false;
|
|
641
|
+
currentStreamConvId = null;
|
|
642
|
+
currentStreamBuffer = '';
|
|
643
|
+
// Restart if agent used file tools OR file watcher detected changes during the turn
|
|
644
|
+
if (eventData.usedFileTools || pendingBackendRestart) {
|
|
645
|
+
console.log('[supervisor] Agent turn ended — restarting backend');
|
|
646
|
+
pendingBackendRestart = false;
|
|
647
|
+
resetBackendRestarts();
|
|
648
|
+
stopBackend().then(() => spawnBackend(backendPort));
|
|
649
|
+
}
|
|
650
|
+
// Run deferred update if agent requested one
|
|
651
|
+
if (pendingUpdate) {
|
|
652
|
+
pendingUpdate = false;
|
|
653
|
+
runDeferredUpdate();
|
|
654
|
+
}
|
|
655
|
+
return; // don't forward bot:done to client
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// Save assistant response to DB + clear stream state
|
|
659
|
+
if (type === 'bot:response') {
|
|
660
|
+
currentStreamBuffer = '';
|
|
1038
661
|
(async () => {
|
|
1039
|
-
|
|
1040
|
-
|
|
1041
|
-
|
|
1042
|
-
}
|
|
1043
|
-
|
|
1044
|
-
}
|
|
1045
|
-
|
|
1046
|
-
broadcastFluxy('chat:cleared');
|
|
662
|
+
try {
|
|
663
|
+
await workerApi(`/api/conversations/${convId}/messages`, 'POST', {
|
|
664
|
+
role: 'assistant', content: eventData.content, meta: { model: freshConfig.ai.model },
|
|
665
|
+
});
|
|
666
|
+
} catch (err: any) {
|
|
667
|
+
log.warn(`[fluxy] DB persist bot response error: ${err.message}`);
|
|
668
|
+
}
|
|
1047
669
|
})();
|
|
1048
|
-
|
|
1049
|
-
}
|
|
1050
|
-
});
|
|
670
|
+
}
|
|
1051
671
|
|
|
1052
|
-
|
|
1053
|
-
|
|
1054
|
-
|
|
1055
|
-
|
|
1056
|
-
|
|
672
|
+
// Stream all events to every connected client
|
|
673
|
+
broadcastFluxy(type, eventData);
|
|
674
|
+
}, data.attachments, savedFiles, { botName, humanName }, recentMessages);
|
|
675
|
+
})();
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
1057
678
|
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1061
|
-
`[supervisor] WebSocket upgrade: ${req.url} | protocol=${req.headers['sec-websocket-protocol'] || 'none'}`
|
|
1062
|
-
);
|
|
679
|
+
// Other providers: use ai.chat() with conversation history
|
|
680
|
+
const history = conversations.get(ws) || [];
|
|
681
|
+
history.push({ role: 'user', content });
|
|
1063
682
|
|
|
1064
|
-
if (!
|
|
1065
|
-
|
|
1066
|
-
|
|
683
|
+
if (!freshAi) {
|
|
684
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
685
|
+
ws.send(JSON.stringify({ type: 'bot:error', data: { error: 'AI not configured. Set up your provider first.' } }));
|
|
686
|
+
}
|
|
687
|
+
return;
|
|
1067
688
|
}
|
|
1068
689
|
|
|
1069
|
-
|
|
1070
|
-
|
|
1071
|
-
if (needsAuth) {
|
|
1072
|
-
const urlObj = new URL(req.url, `http://${req.headers.host}`);
|
|
1073
|
-
const token = urlObj.searchParams.get('token');
|
|
1074
|
-
if (!token || !(await validateToken(token))) {
|
|
1075
|
-
console.log('[supervisor] WS auth failed — rejecting');
|
|
1076
|
-
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
1077
|
-
socket.destroy();
|
|
1078
|
-
return;
|
|
1079
|
-
}
|
|
690
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
691
|
+
ws.send(JSON.stringify({ type: 'bot:typing', data: { conversationId: convId } }));
|
|
1080
692
|
}
|
|
1081
693
|
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
|
|
694
|
+
freshAi.chat(
|
|
695
|
+
[{ role: 'system', content: 'You are Fluxy, a helpful AI assistant. You help users manage and customize their self-hosted bot.' }, ...history],
|
|
696
|
+
freshConfig.ai.model,
|
|
697
|
+
(token) => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'bot:token', data: { token, conversationId: convId } })); },
|
|
698
|
+
(full) => {
|
|
699
|
+
history.push({ role: 'assistant', content: full });
|
|
700
|
+
if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'bot:response', data: { conversationId: convId, content: full } }));
|
|
701
|
+
},
|
|
702
|
+
(err) => { if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'bot:error', data: { error: err.message } })); },
|
|
1085
703
|
);
|
|
1086
|
-
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
1087
706
|
|
|
1088
|
-
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
|
|
1092
|
-
|
|
1093
|
-
|
|
1094
|
-
|
|
1095
|
-
|
|
1096
|
-
|
|
1097
|
-
|
|
707
|
+
if (msg.type === 'user:stop') {
|
|
708
|
+
stopFluxyAgentQuery(convId);
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
if (msg.type === 'user:clear-context') {
|
|
713
|
+
(async () => {
|
|
714
|
+
try {
|
|
715
|
+
clientConvs.delete(ws);
|
|
716
|
+
await workerApi('/api/context/clear', 'POST');
|
|
717
|
+
} catch (err: any) {
|
|
718
|
+
log.warn(`[fluxy] Clear context error: ${err.message}`);
|
|
719
|
+
}
|
|
720
|
+
// Broadcast clear to ALL clients
|
|
721
|
+
broadcastFluxy('chat:cleared');
|
|
722
|
+
})();
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
1098
725
|
});
|
|
1099
726
|
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
if (config.tunnel.mode === 'off') {
|
|
1104
|
-
console.log('__READY__');
|
|
1105
|
-
}
|
|
727
|
+
ws.on('close', () => {
|
|
728
|
+
conversations.delete(ws);
|
|
729
|
+
clientConvs.delete(ws);
|
|
1106
730
|
});
|
|
731
|
+
});
|
|
1107
732
|
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
let pendingUpdate = false; // Set when .update file is created during agent turn
|
|
733
|
+
// Fluxy chat WebSocket — Vite HMR is handled automatically (hmr.server = this server)
|
|
734
|
+
server.on('upgrade', async (req, socket: net.Socket, head) => {
|
|
735
|
+
console.log(`[supervisor] WebSocket upgrade: ${req.url} | protocol=${req.headers['sec-websocket-protocol'] || 'none'}`);
|
|
1112
736
|
|
|
1113
|
-
|
|
1114
|
-
|
|
1115
|
-
|
|
1116
|
-
log.info('Deferred update triggered — running fluxy update...');
|
|
1117
|
-
try {
|
|
1118
|
-
const child = cpSpawn(process.execPath, [cliPath, 'update'], {
|
|
1119
|
-
detached: true,
|
|
1120
|
-
stdio: 'ignore',
|
|
1121
|
-
env: { ...process.env }
|
|
1122
|
-
});
|
|
1123
|
-
child.unref();
|
|
1124
|
-
} catch (err) {
|
|
1125
|
-
log.error(
|
|
1126
|
-
`Deferred update failed: ${err instanceof Error ? err.message : err}`
|
|
1127
|
-
);
|
|
1128
|
-
}
|
|
737
|
+
if (!req.url?.startsWith('/fluxy/ws')) {
|
|
738
|
+
console.log('[supervisor] → Letting Vite handle this upgrade');
|
|
739
|
+
return;
|
|
1129
740
|
}
|
|
1130
741
|
|
|
1131
|
-
//
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
|
|
1142
|
-
|
|
1143
|
-
},
|
|
1144
|
-
getModel: () => loadConfig().ai.model
|
|
1145
|
-
});
|
|
742
|
+
// Auth check for WebSocket
|
|
743
|
+
const needsAuth = await isAuthRequired();
|
|
744
|
+
if (needsAuth) {
|
|
745
|
+
const urlObj = new URL(req.url, `http://${req.headers.host}`);
|
|
746
|
+
const token = urlObj.searchParams.get('token');
|
|
747
|
+
if (!token || !(await validateToken(token))) {
|
|
748
|
+
console.log('[supervisor] WS auth failed — rejecting');
|
|
749
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
750
|
+
socket.destroy();
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
}
|
|
1146
754
|
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1152
|
-
|
|
1153
|
-
|
|
1154
|
-
|
|
1155
|
-
|
|
1156
|
-
|
|
1157
|
-
pendingBackendRestart = true;
|
|
1158
|
-
return;
|
|
1159
|
-
}
|
|
1160
|
-
if (backendRestartTimer) clearTimeout(backendRestartTimer);
|
|
1161
|
-
backendRestartTimer = setTimeout(async () => {
|
|
1162
|
-
log.info(`[watcher] ${reason} — restarting backend...`);
|
|
1163
|
-
resetBackendRestarts();
|
|
1164
|
-
await stopBackend();
|
|
1165
|
-
spawnBackend(backendPort);
|
|
1166
|
-
}, 1000);
|
|
755
|
+
console.log('[supervisor] → Fluxy chat WebSocket');
|
|
756
|
+
fluxyWss.handleUpgrade(req, socket, head, (ws) => fluxyWss.emit('connection', ws, req));
|
|
757
|
+
});
|
|
758
|
+
|
|
759
|
+
// Start
|
|
760
|
+
server.on('error', (err: NodeJS.ErrnoException) => {
|
|
761
|
+
if (err.code === 'EADDRINUSE') {
|
|
762
|
+
log.error(`Port ${config.port} is already in use. Stop the other process or change the port in ${paths.config}`);
|
|
763
|
+
} else {
|
|
764
|
+
log.error(`Server error: ${err.message}`);
|
|
1167
765
|
}
|
|
766
|
+
process.exit(1);
|
|
767
|
+
});
|
|
1168
768
|
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
}
|
|
1177
|
-
);
|
|
769
|
+
server.listen(config.port, () => {
|
|
770
|
+
log.ok(`Supervisor on http://localhost:${config.port}`);
|
|
771
|
+
log.ok(`Fluxy chat at http://localhost:${config.port}/fluxy`);
|
|
772
|
+
if (config.tunnel.mode === 'off') {
|
|
773
|
+
console.log('__READY__');
|
|
774
|
+
}
|
|
775
|
+
});
|
|
1178
776
|
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
scheduleBackendRestart('.env changed');
|
|
1184
|
-
}
|
|
1185
|
-
if (filename === '.restart') {
|
|
1186
|
-
// Consume the trigger file
|
|
1187
|
-
try {
|
|
1188
|
-
fs.unlinkSync(path.join(workspaceDir, '.restart'));
|
|
1189
|
-
} catch {}
|
|
1190
|
-
scheduleBackendRestart('.restart trigger');
|
|
1191
|
-
}
|
|
1192
|
-
if (filename === '.update') {
|
|
1193
|
-
// Consume the trigger file
|
|
1194
|
-
try {
|
|
1195
|
-
fs.unlinkSync(path.join(workspaceDir, '.update'));
|
|
1196
|
-
} catch {}
|
|
1197
|
-
if (agentQueryActive) {
|
|
1198
|
-
pendingUpdate = true;
|
|
1199
|
-
log.info('Update requested — deferring until agent turn ends');
|
|
1200
|
-
} else {
|
|
1201
|
-
runDeferredUpdate();
|
|
1202
|
-
}
|
|
1203
|
-
}
|
|
1204
|
-
});
|
|
777
|
+
// Track whether an agent query is active — file watcher defers to bot:done during turns
|
|
778
|
+
let agentQueryActive = false;
|
|
779
|
+
let pendingBackendRestart = false; // Set when file watcher fires during agent turn
|
|
780
|
+
let pendingUpdate = false; // Set when .update file is created during agent turn
|
|
1205
781
|
|
|
1206
|
-
|
|
1207
|
-
|
|
782
|
+
// Run fluxy update in a detached process so it survives daemon stop/restart
|
|
783
|
+
function runDeferredUpdate() {
|
|
784
|
+
const cliPath = path.join(PKG_DIR, 'bin', 'cli.js');
|
|
785
|
+
log.info('Deferred update triggered — running fluxy update...');
|
|
786
|
+
try {
|
|
787
|
+
const child = cpSpawn(process.execPath, [cliPath, 'update'], {
|
|
788
|
+
detached: true,
|
|
789
|
+
stdio: 'ignore',
|
|
790
|
+
env: { ...process.env },
|
|
791
|
+
});
|
|
792
|
+
child.unref();
|
|
793
|
+
} catch (err) {
|
|
794
|
+
log.error(`Deferred update failed: ${err instanceof Error ? err.message : err}`);
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
|
|
798
|
+
// Spawn worker + backend
|
|
799
|
+
spawnWorker(workerPort);
|
|
800
|
+
spawnBackend(backendPort);
|
|
801
|
+
|
|
802
|
+
// Start pulse/cron scheduler
|
|
803
|
+
startScheduler({
|
|
804
|
+
broadcastFluxy,
|
|
805
|
+
workerApi,
|
|
806
|
+
restartBackend: async () => {
|
|
807
|
+
resetBackendRestarts();
|
|
808
|
+
await stopBackend();
|
|
809
|
+
spawnBackend(backendPort);
|
|
810
|
+
},
|
|
811
|
+
getModel: () => loadConfig().ai.model,
|
|
812
|
+
});
|
|
813
|
+
|
|
814
|
+
// Watch workspace files for changes — auto-restart backend
|
|
815
|
+
// Catches edits from VS Code, CLI, or any external tool.
|
|
816
|
+
// During agent turns, defers to bot:done (avoids mid-turn restarts).
|
|
817
|
+
const workspaceDir = path.join(PKG_DIR, 'workspace');
|
|
818
|
+
const backendDir = path.join(workspaceDir, 'backend');
|
|
819
|
+
let backendRestartTimer: ReturnType<typeof setTimeout> | null = null;
|
|
820
|
+
|
|
821
|
+
function scheduleBackendRestart(reason: string) {
|
|
822
|
+
if (agentQueryActive) {
|
|
823
|
+
// Agent is working — don't restart now, flag it for bot:done
|
|
824
|
+
pendingBackendRestart = true;
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
if (backendRestartTimer) clearTimeout(backendRestartTimer);
|
|
828
|
+
backendRestartTimer = setTimeout(async () => {
|
|
829
|
+
log.info(`[watcher] ${reason} — restarting backend...`);
|
|
830
|
+
resetBackendRestarts();
|
|
831
|
+
await stopBackend();
|
|
832
|
+
spawnBackend(backendPort);
|
|
833
|
+
}, 1000);
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// Watch backend/ for code changes
|
|
837
|
+
const backendWatcher = fs.watch(backendDir, { recursive: true }, (_event, filename) => {
|
|
838
|
+
if (!filename || !filename.match(/\.(ts|js|json)$/)) return;
|
|
839
|
+
scheduleBackendRestart(`Backend file changed: ${filename}`);
|
|
840
|
+
});
|
|
841
|
+
|
|
842
|
+
// Watch workspace root for .env changes and .restart trigger
|
|
843
|
+
const workspaceWatcher = fs.watch(workspaceDir, (_event, filename) => {
|
|
844
|
+
if (!filename) return;
|
|
845
|
+
if (filename === '.env') {
|
|
846
|
+
scheduleBackendRestart('.env changed');
|
|
847
|
+
}
|
|
848
|
+
if (filename === '.restart') {
|
|
849
|
+
// Consume the trigger file
|
|
850
|
+
try { fs.unlinkSync(path.join(workspaceDir, '.restart')); } catch {}
|
|
851
|
+
scheduleBackendRestart('.restart trigger');
|
|
852
|
+
}
|
|
853
|
+
if (filename === '.update') {
|
|
854
|
+
// Consume the trigger file
|
|
855
|
+
try { fs.unlinkSync(path.join(workspaceDir, '.update')); } catch {}
|
|
856
|
+
if (agentQueryActive) {
|
|
857
|
+
pendingUpdate = true;
|
|
858
|
+
log.info('Update requested — deferring until agent turn ends');
|
|
859
|
+
} else {
|
|
860
|
+
runDeferredUpdate();
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
});
|
|
864
|
+
|
|
865
|
+
// Tunnel
|
|
866
|
+
let tunnelUrl: string | null = null;
|
|
1208
867
|
|
|
1209
|
-
|
|
868
|
+
if (config.tunnel.mode === 'quick') {
|
|
869
|
+
try {
|
|
870
|
+
tunnelUrl = await startTunnel(config.port);
|
|
871
|
+
log.ok(`Tunnel: ${tunnelUrl}`);
|
|
872
|
+
console.log(`__TUNNEL_URL__=${tunnelUrl}`);
|
|
873
|
+
|
|
874
|
+
// Persist tunnel URL so the worker can read it after handle registration
|
|
875
|
+
config.tunnelUrl = tunnelUrl;
|
|
876
|
+
saveConfig(config);
|
|
877
|
+
|
|
878
|
+
// Wait for local server to be reachable before telling the relay.
|
|
879
|
+
// Probes localhost directly — avoids macOS issue where server can't
|
|
880
|
+
// reach itself through the Cloudflare tunnel URL.
|
|
881
|
+
let tunnelReady = false;
|
|
882
|
+
log.info('Readiness probe: waiting for local server...');
|
|
883
|
+
for (let i = 0; i < 30; i++) {
|
|
1210
884
|
try {
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1215
|
-
|
|
1216
|
-
|
|
1217
|
-
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1222
|
-
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
`http://127.0.0.1:${config.port}/api/health?_cb=${Date.now()}`,
|
|
1228
|
-
{
|
|
1229
|
-
signal: AbortSignal.timeout(3000)
|
|
1230
|
-
}
|
|
1231
|
-
);
|
|
1232
|
-
log.info(`Readiness probe #${i + 1}: ${res.status}`);
|
|
1233
|
-
if (res.ok) {
|
|
1234
|
-
tunnelReady = true;
|
|
1235
|
-
break;
|
|
1236
|
-
}
|
|
1237
|
-
} catch (err) {
|
|
1238
|
-
log.info(
|
|
1239
|
-
`Readiness probe #${i + 1}: ${err instanceof Error ? err.message : 'error'}`
|
|
1240
|
-
);
|
|
1241
|
-
}
|
|
1242
|
-
await new Promise(r => setTimeout(r, 1000));
|
|
1243
|
-
}
|
|
1244
|
-
if (!tunnelReady) {
|
|
1245
|
-
log.warn(
|
|
1246
|
-
'Local server readiness probe timed out — updating relay anyway'
|
|
1247
|
-
);
|
|
1248
|
-
}
|
|
885
|
+
const res = await fetch(`http://127.0.0.1:${config.port}/api/health?_cb=${Date.now()}`, {
|
|
886
|
+
signal: AbortSignal.timeout(3000),
|
|
887
|
+
});
|
|
888
|
+
log.info(`Readiness probe #${i + 1}: ${res.status}`);
|
|
889
|
+
if (res.ok) {
|
|
890
|
+
tunnelReady = true;
|
|
891
|
+
break;
|
|
892
|
+
}
|
|
893
|
+
} catch (err) {
|
|
894
|
+
log.info(`Readiness probe #${i + 1}: ${err instanceof Error ? err.message : 'error'}`);
|
|
895
|
+
}
|
|
896
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
897
|
+
}
|
|
898
|
+
if (!tunnelReady) {
|
|
899
|
+
log.warn('Local server readiness probe timed out — updating relay anyway');
|
|
900
|
+
}
|
|
1249
901
|
|
|
1250
|
-
|
|
1251
|
-
|
|
1252
|
-
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
} catch (err) {
|
|
1260
|
-
log.warn(
|
|
1261
|
-
`Relay: ${err instanceof Error ? err.message : err}`
|
|
1262
|
-
);
|
|
1263
|
-
}
|
|
1264
|
-
}
|
|
1265
|
-
console.log('__READY__');
|
|
902
|
+
// Now register tunnel URL with relay and start heartbeats
|
|
903
|
+
if (config.relay?.token) {
|
|
904
|
+
try {
|
|
905
|
+
await updateTunnelUrl(config.relay.token, tunnelUrl);
|
|
906
|
+
startHeartbeat(config.relay.token, tunnelUrl);
|
|
907
|
+
if (config.relay.url) {
|
|
908
|
+
log.ok(`Relay: ${config.relay.url}`);
|
|
909
|
+
console.log(`__RELAY_URL__=${config.relay.url}`);
|
|
910
|
+
}
|
|
1266
911
|
} catch (err) {
|
|
1267
|
-
|
|
1268
|
-
console.log('__TUNNEL_FAILED__');
|
|
912
|
+
log.warn(`Relay: ${err instanceof Error ? err.message : err}`);
|
|
1269
913
|
}
|
|
914
|
+
}
|
|
915
|
+
console.log('__READY__');
|
|
916
|
+
} catch (err) {
|
|
917
|
+
log.warn(`Tunnel: ${err instanceof Error ? err.message : err}`);
|
|
918
|
+
console.log('__TUNNEL_FAILED__');
|
|
1270
919
|
}
|
|
920
|
+
}
|
|
1271
921
|
|
|
1272
|
-
|
|
922
|
+
if (config.tunnel.mode === 'named') {
|
|
923
|
+
try {
|
|
924
|
+
await startNamedTunnel(config.tunnel.configPath!, config.tunnel.name!);
|
|
925
|
+
tunnelUrl = `https://${config.tunnel.domain}`;
|
|
926
|
+
log.ok(`Named tunnel: ${tunnelUrl}`);
|
|
927
|
+
console.log(`__TUNNEL_URL__=${tunnelUrl}`);
|
|
928
|
+
|
|
929
|
+
config.tunnelUrl = tunnelUrl;
|
|
930
|
+
saveConfig(config);
|
|
931
|
+
|
|
932
|
+
// Readiness probe — check local server (not tunnel URL, avoids macOS self-reach issue)
|
|
933
|
+
let tunnelReady = false;
|
|
934
|
+
log.info('Readiness probe: waiting for local server...');
|
|
935
|
+
for (let i = 0; i < 30; i++) {
|
|
1273
936
|
try {
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
config.tunnelUrl = tunnelUrl;
|
|
1283
|
-
saveConfig(config);
|
|
1284
|
-
|
|
1285
|
-
// Readiness probe — check local server (not tunnel URL, avoids macOS self-reach issue)
|
|
1286
|
-
let tunnelReady = false;
|
|
1287
|
-
log.info('Readiness probe: waiting for local server...');
|
|
1288
|
-
for (let i = 0; i < 30; i++) {
|
|
1289
|
-
try {
|
|
1290
|
-
const res = await fetch(
|
|
1291
|
-
`http://127.0.0.1:${config.port}/api/health?_cb=${Date.now()}`,
|
|
1292
|
-
{
|
|
1293
|
-
signal: AbortSignal.timeout(3000)
|
|
1294
|
-
}
|
|
1295
|
-
);
|
|
1296
|
-
log.info(`Readiness probe #${i + 1}: ${res.status}`);
|
|
1297
|
-
if (res.ok) {
|
|
1298
|
-
tunnelReady = true;
|
|
1299
|
-
break;
|
|
1300
|
-
}
|
|
1301
|
-
} catch (err) {
|
|
1302
|
-
log.info(
|
|
1303
|
-
`Readiness probe #${i + 1}: ${err instanceof Error ? err.message : 'error'}`
|
|
1304
|
-
);
|
|
1305
|
-
}
|
|
1306
|
-
await new Promise(r => setTimeout(r, 1000));
|
|
1307
|
-
}
|
|
1308
|
-
if (!tunnelReady) {
|
|
1309
|
-
log.warn('Local server readiness probe timed out');
|
|
1310
|
-
}
|
|
1311
|
-
|
|
1312
|
-
console.log('__READY__');
|
|
937
|
+
const res = await fetch(`http://127.0.0.1:${config.port}/api/health?_cb=${Date.now()}`, {
|
|
938
|
+
signal: AbortSignal.timeout(3000),
|
|
939
|
+
});
|
|
940
|
+
log.info(`Readiness probe #${i + 1}: ${res.status}`);
|
|
941
|
+
if (res.ok) {
|
|
942
|
+
tunnelReady = true;
|
|
943
|
+
break;
|
|
944
|
+
}
|
|
1313
945
|
} catch (err) {
|
|
1314
|
-
|
|
1315
|
-
`Named tunnel: ${err instanceof Error ? err.message : err}`
|
|
1316
|
-
);
|
|
1317
|
-
console.log('__TUNNEL_FAILED__');
|
|
946
|
+
log.info(`Readiness probe #${i + 1}: ${err instanceof Error ? err.message : 'error'}`);
|
|
1318
947
|
}
|
|
1319
|
-
|
|
948
|
+
await new Promise(r => setTimeout(r, 1000));
|
|
949
|
+
}
|
|
950
|
+
if (!tunnelReady) {
|
|
951
|
+
log.warn('Local server readiness probe timed out');
|
|
952
|
+
}
|
|
1320
953
|
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
let healthCounter = 0;
|
|
1326
|
-
|
|
1327
|
-
watchdogInterval = setInterval(async () => {
|
|
1328
|
-
const now = Date.now();
|
|
1329
|
-
const wakeGap = now - lastTick > 60_000;
|
|
1330
|
-
const periodicCheck = ++healthCounter % 10 === 0;
|
|
1331
|
-
lastTick = now; // Update immediately so concurrent ticks don't see a stale gap
|
|
1332
|
-
|
|
1333
|
-
if (wakeGap || periodicCheck) {
|
|
1334
|
-
const alive = await isTunnelAlive(tunnelUrl!, config.port);
|
|
1335
|
-
if (!alive) {
|
|
1336
|
-
log.warn('Tunnel dead, restarting...');
|
|
1337
|
-
try {
|
|
1338
|
-
if (config.tunnel.mode === 'named') {
|
|
1339
|
-
// Named tunnel: restart process, URL doesn't change
|
|
1340
|
-
await restartNamedTunnel(
|
|
1341
|
-
config.tunnel.configPath!,
|
|
1342
|
-
config.tunnel.name!
|
|
1343
|
-
);
|
|
1344
|
-
log.ok(`Named tunnel restored: ${tunnelUrl}`);
|
|
1345
|
-
} else {
|
|
1346
|
-
// Quick tunnel: restart and get new URL
|
|
1347
|
-
const newUrl = await restartTunnel(config.port);
|
|
1348
|
-
|
|
1349
|
-
// Brief pause to let cloudflared establish the tunnel
|
|
1350
|
-
await new Promise(r => setTimeout(r, 3000));
|
|
1351
|
-
|
|
1352
|
-
tunnelUrl = newUrl;
|
|
1353
|
-
|
|
1354
|
-
// Re-read config from disk — relay token may have been added during onboarding
|
|
1355
|
-
const latestCfg = loadConfig();
|
|
1356
|
-
latestCfg.tunnelUrl = newUrl;
|
|
1357
|
-
saveConfig(latestCfg);
|
|
1358
|
-
|
|
1359
|
-
if (latestCfg.relay?.token) {
|
|
1360
|
-
stopHeartbeat();
|
|
1361
|
-
startHeartbeat(latestCfg.relay.token, newUrl);
|
|
1362
|
-
await updateTunnelUrl(
|
|
1363
|
-
latestCfg.relay.token,
|
|
1364
|
-
newUrl
|
|
1365
|
-
);
|
|
1366
|
-
log.ok(`Relay updated with new tunnel URL`);
|
|
1367
|
-
}
|
|
1368
|
-
log.ok(`Tunnel restored: ${newUrl}`);
|
|
1369
|
-
}
|
|
1370
|
-
} catch (err) {
|
|
1371
|
-
log.error(
|
|
1372
|
-
`Tunnel restart failed: ${err instanceof Error ? err.message : err}`
|
|
1373
|
-
);
|
|
1374
|
-
}
|
|
1375
|
-
}
|
|
1376
|
-
}
|
|
1377
|
-
}, 30_000);
|
|
954
|
+
console.log('__READY__');
|
|
955
|
+
} catch (err) {
|
|
956
|
+
log.warn(`Named tunnel: ${err instanceof Error ? err.message : err}`);
|
|
957
|
+
console.log('__TUNNEL_FAILED__');
|
|
1378
958
|
}
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
959
|
+
}
|
|
960
|
+
|
|
961
|
+
// Tunnel watchdog — detects sleep/wake + periodic health checks
|
|
962
|
+
let watchdogInterval: ReturnType<typeof setInterval> | null = null;
|
|
963
|
+
if (tunnelUrl) {
|
|
964
|
+
let lastTick = Date.now();
|
|
965
|
+
let healthCounter = 0;
|
|
966
|
+
|
|
967
|
+
watchdogInterval = setInterval(async () => {
|
|
968
|
+
const now = Date.now();
|
|
969
|
+
const wakeGap = now - lastTick > 60_000;
|
|
970
|
+
const periodicCheck = ++healthCounter % 10 === 0;
|
|
971
|
+
lastTick = now; // Update immediately so concurrent ticks don't see a stale gap
|
|
972
|
+
|
|
973
|
+
if (wakeGap || periodicCheck) {
|
|
974
|
+
const alive = await isTunnelAlive(tunnelUrl!, config.port);
|
|
975
|
+
if (!alive) {
|
|
976
|
+
log.warn('Tunnel dead, restarting...');
|
|
977
|
+
try {
|
|
978
|
+
if (config.tunnel.mode === 'named') {
|
|
979
|
+
// Named tunnel: restart process, URL doesn't change
|
|
980
|
+
await restartNamedTunnel(config.tunnel.configPath!, config.tunnel.name!);
|
|
981
|
+
log.ok(`Named tunnel restored: ${tunnelUrl}`);
|
|
982
|
+
} else {
|
|
983
|
+
// Quick tunnel: restart and get new URL
|
|
984
|
+
const newUrl = await restartTunnel(config.port);
|
|
985
|
+
|
|
986
|
+
// Brief pause to let cloudflared establish the tunnel
|
|
987
|
+
await new Promise(r => setTimeout(r, 3000));
|
|
988
|
+
|
|
989
|
+
tunnelUrl = newUrl;
|
|
990
|
+
|
|
991
|
+
// Re-read config from disk — relay token may have been added during onboarding
|
|
992
|
+
const latestCfg = loadConfig();
|
|
993
|
+
latestCfg.tunnelUrl = newUrl;
|
|
994
|
+
saveConfig(latestCfg);
|
|
995
|
+
|
|
996
|
+
if (latestCfg.relay?.token) {
|
|
997
|
+
stopHeartbeat();
|
|
998
|
+
startHeartbeat(latestCfg.relay.token, newUrl);
|
|
999
|
+
await updateTunnelUrl(latestCfg.relay.token, newUrl);
|
|
1000
|
+
log.ok(`Relay updated with new tunnel URL`);
|
|
1001
|
+
}
|
|
1002
|
+
log.ok(`Tunnel restored: ${newUrl}`);
|
|
1003
|
+
}
|
|
1004
|
+
} catch (err) {
|
|
1005
|
+
log.error(`Tunnel restart failed: ${err instanceof Error ? err.message : err}`);
|
|
1006
|
+
}
|
|
1392
1007
|
}
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
|
|
1008
|
+
}
|
|
1009
|
+
}, 30_000);
|
|
1010
|
+
}
|
|
1011
|
+
|
|
1012
|
+
// Shutdown
|
|
1013
|
+
const shutdown = async () => {
|
|
1014
|
+
log.info('Shutting down...');
|
|
1015
|
+
stopScheduler();
|
|
1016
|
+
backendWatcher.close();
|
|
1017
|
+
workspaceWatcher.close();
|
|
1018
|
+
if (backendRestartTimer) clearTimeout(backendRestartTimer);
|
|
1019
|
+
if (watchdogInterval) clearInterval(watchdogInterval);
|
|
1020
|
+
stopHeartbeat();
|
|
1021
|
+
const latestConfig = loadConfig();
|
|
1022
|
+
if (latestConfig.relay?.token) {
|
|
1023
|
+
await disconnect(latestConfig.relay.token);
|
|
1024
|
+
}
|
|
1025
|
+
// Clear persisted tunnel URL so stale values aren't reused
|
|
1026
|
+
delete latestConfig.tunnelUrl;
|
|
1027
|
+
saveConfig(latestConfig);
|
|
1028
|
+
stopWorker();
|
|
1029
|
+
await stopBackend();
|
|
1030
|
+
stopTunnel();
|
|
1031
|
+
console.log('[supervisor] Stopping Vite dev servers...');
|
|
1032
|
+
await stopViteDevServers();
|
|
1033
|
+
server.close();
|
|
1034
|
+
process.exit(0);
|
|
1035
|
+
};
|
|
1036
|
+
process.on('SIGINT', () => shutdown());
|
|
1037
|
+
process.on('SIGTERM', () => shutdown());
|
|
1038
|
+
|
|
1039
|
+
return tunnelUrl;
|
|
1408
1040
|
}
|
|
1409
1041
|
|
|
1410
|
-
startSupervisor().catch(err => {
|
|
1411
|
-
|
|
1412
|
-
|
|
1042
|
+
startSupervisor().catch((err) => {
|
|
1043
|
+
log.error('Fatal', err);
|
|
1044
|
+
process.exit(1);
|
|
1413
1045
|
});
|