fluxy-bot 0.8.9 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +53 -84
  2. package/bin/cli.js +1717 -6
  3. package/components.json +18 -18
  4. package/package.json +98 -109
  5. package/postcss.config.js +3 -3
  6. package/scripts/install.ps1 +1 -1
  7. package/scripts/install.sh +1 -1
  8. package/scripts/postinstall.js +47 -58
  9. package/shared/ai.ts +118 -202
  10. package/shared/config.ts +37 -39
  11. package/shared/logger.ts +6 -14
  12. package/shared/paths.ts +10 -16
  13. package/shared/relay.ts +89 -98
  14. package/supervisor/backend.ts +77 -89
  15. package/supervisor/chat/ARCHITECTURE.md +13 -14
  16. package/supervisor/chat/OnboardWizard.tsx +2194 -3419
  17. package/supervisor/chat/fluxy-main.tsx +495 -633
  18. package/supervisor/chat/fluxy.html +24 -36
  19. package/supervisor/chat/onboard-main.tsx +8 -8
  20. package/supervisor/chat/onboard.html +10 -13
  21. package/supervisor/chat/src/components/Chat/AudioBubble.tsx +94 -108
  22. package/supervisor/chat/src/components/Chat/ChatView.tsx +14 -36
  23. package/supervisor/chat/src/components/Chat/ImageLightbox.tsx +71 -89
  24. package/supervisor/chat/src/components/Chat/InputBar.tsx +498 -728
  25. package/supervisor/chat/src/components/Chat/MessageBubble.tsx +154 -246
  26. package/supervisor/chat/src/components/Chat/MessageList.tsx +114 -138
  27. package/supervisor/chat/src/components/Chat/TypingIndicator.tsx +38 -50
  28. package/supervisor/chat/src/components/LoginScreen.tsx +245 -296
  29. package/supervisor/chat/src/hooks/useChat.ts +210 -259
  30. package/supervisor/chat/src/hooks/useFluxyChat.ts +232 -322
  31. package/supervisor/chat/src/hooks/useSpeechRecognition.ts +126 -148
  32. package/supervisor/chat/src/lib/auth.ts +20 -23
  33. package/supervisor/chat/src/lib/ws-client.ts +113 -125
  34. package/supervisor/chat/src/styles/globals.css +76 -85
  35. package/supervisor/file-saver.ts +39 -52
  36. package/supervisor/fluxy-agent.ts +213 -292
  37. package/supervisor/index.ts +895 -1263
  38. package/supervisor/scheduler.ts +237 -299
  39. package/supervisor/tunnel.ts +113 -167
  40. package/supervisor/vite-dev.ts +51 -61
  41. package/supervisor/widget.js +135 -147
  42. package/supervisor/worker.ts +45 -49
  43. package/tsconfig.json +18 -24
  44. package/vite.config.ts +48 -55
  45. package/vite.fluxy.config.ts +34 -34
  46. package/worker/claude-auth.ts +202 -265
  47. package/worker/codex-auth.ts +144 -181
  48. package/worker/db.ts +63 -191
  49. package/worker/index.ts +601 -842
  50. package/workspace/MYSELF.md +1 -1
  51. package/workspace/PULSE.json +6 -6
  52. package/workspace/backend/index.ts +16 -19
  53. package/workspace/client/index.html +47 -60
  54. package/workspace/client/public/manifest.json +21 -21
  55. package/workspace/client/public/sw.js +29 -31
  56. package/workspace/client/src/App.tsx +91 -136
  57. package/workspace/client/src/components/Dashboard/DashboardPage.tsx +68 -72
  58. package/workspace/client/src/components/ErrorBoundary.tsx +11 -11
  59. package/workspace/client/src/components/Layout/DashboardLayout.tsx +35 -35
  60. package/workspace/client/src/components/Layout/Footer.tsx +12 -12
  61. package/workspace/client/src/components/Layout/MobileNav.tsx +23 -23
  62. package/workspace/client/src/components/Layout/Sidebar.tsx +81 -81
  63. package/workspace/client/src/components/ui/avatar.tsx +85 -85
  64. package/workspace/client/src/components/ui/badge.tsx +40 -41
  65. package/workspace/client/src/components/ui/button.tsx +56 -57
  66. package/workspace/client/src/components/ui/card.tsx +75 -75
  67. package/workspace/client/src/components/ui/dialog.tsx +114 -117
  68. package/workspace/client/src/components/ui/dropdown-menu.tsx +191 -199
  69. package/workspace/client/src/components/ui/input.tsx +17 -17
  70. package/workspace/client/src/components/ui/scroll-area.tsx +47 -47
  71. package/workspace/client/src/components/ui/select.tsx +145 -151
  72. package/workspace/client/src/components/ui/separator.tsx +21 -21
  73. package/workspace/client/src/components/ui/sheet.tsx +102 -102
  74. package/workspace/client/src/components/ui/skeleton.tsx +10 -10
  75. package/workspace/client/src/components/ui/switch.tsx +26 -26
  76. package/workspace/client/src/components/ui/tabs.tsx +69 -69
  77. package/workspace/client/src/components/ui/textarea.tsx +14 -14
  78. package/workspace/client/src/components/ui/tooltip.tsx +37 -37
  79. package/workspace/client/src/lib/utils.ts +1 -1
  80. package/workspace/client/src/main.tsx +3 -3
  81. package/workspace/client/src/styles/globals.css +76 -85
  82. package/workspace/skills/code-reviewer/.claude-plugin/plugin.json +3 -3
  83. package/workspace/skills/code-reviewer/skills/code-reviewer/SKILL.md +0 -6
  84. package/workspace/skills/daily-standup/.claude-plugin/plugin.json +3 -3
  85. package/workspace/skills/daily-standup/skills/daily-standup/SKILL.md +0 -7
  86. package/workspace/skills/workspace-helper/.claude-plugin/plugin.json +3 -3
  87. package/workspace/skills/workspace-helper/skills/workspace-helper/SKILL.md +0 -2
  88. package/bin/cli.backup.js +0 -2138
  89. package/cli/commands/daemon.ts +0 -42
  90. package/cli/commands/init.ts +0 -32
  91. package/cli/commands/start.ts +0 -113
  92. package/cli/commands/tunnel.temp.ts +0 -206
  93. package/cli/commands/tunnel.ts +0 -227
  94. package/cli/commands/update.ts +0 -163
  95. package/cli/core/base-adapter.ts +0 -156
  96. package/cli/core/cloudflared.ts +0 -113
  97. package/cli/core/config.ts +0 -73
  98. package/cli/core/os-detector.ts +0 -43
  99. package/cli/core/server.ts +0 -109
  100. package/cli/core/types.ts +0 -15
  101. package/cli/index.ts +0 -72
  102. package/cli/platforms/darwin.ts +0 -146
  103. package/cli/platforms/index.ts +0 -21
  104. package/cli/platforms/linux.ts +0 -156
  105. package/cli/platforms/win32.ts +0 -34
  106. package/cli/utils/ui.ts +0 -37
@@ -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, WORKSPACE_DIR } from '../shared/paths.js';
9
+ import { PKG_DIR } from '../shared/paths.js';
14
10
  import { log } from '../shared/logger.js';
15
- import {
16
- startTunnel,
17
- stopTunnel,
18
- isTunnelAlive,
19
- restartTunnel,
20
- startNamedTunnel,
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
- log.info('Building fluxy chat UI (first run)...');
61
- try {
62
- execSync('npx vite build --config vite.fluxy.config.ts', {
63
- cwd: PKG_DIR,
64
- stdio: 'ignore'
65
- });
66
- log.ok('Fluxy chat UI built');
67
- } catch (err) {
68
- log.warn(
69
- `Failed to build fluxy chat UI: ${err instanceof Error ? err.message : err}`
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
- '.html': 'text/html',
76
- '.js': 'application/javascript',
77
- '.css': 'text/css',
78
- '.png': 'image/png',
79
- '.jpg': 'image/jpeg',
80
- '.svg': 'image/svg+xml',
81
- '.webm': 'video/webm',
82
- '.woff2': 'font/woff2',
83
- '.json': 'application/json'
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
- const config = loadConfig();
132
- const workerPort = getWorkerPort(config.port);
133
- const backendPort = getBackendPort(config.port);
134
-
135
- // Create HTTP server first (Vite needs it for HMR WebSocket)
136
- // The request handler is set up later via server.on('request')
137
- const server = http.createServer();
138
-
139
- // Start Vite dev server — pass supervisor server so Vite attaches HMR WebSocket directly
140
- console.log('[supervisor] Starting Vite dev server...');
141
- const vitePorts = await startViteDevServers(config.port, server);
142
- console.log(`[supervisor] Vite ready — dashboard :${vitePorts.dashboard}`);
143
- console.log(
144
- `[supervisor] Upgrade listeners on server: ${server.listenerCount('upgrade')}`
145
- );
146
-
147
- // Ensure file storage dirs exist
148
- ensureFileDirs();
149
-
150
- // Fluxy's AI brain
151
- let ai: AiProvider | null = null;
152
- if (
153
- config.ai.provider &&
154
- (config.ai.apiKey || config.ai.provider === 'ollama')
155
- ) {
156
- ai = createProvider(
157
- config.ai.provider,
158
- config.ai.apiKey,
159
- config.ai.baseUrl
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
- // Fluxy chat conversations (in-memory for now)
164
- const conversations = new Map<WebSocket, ChatMessage[]>();
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
- /** Broadcast to all fluxy WS clients EXCEPT sender */
185
- function broadcastFluxyExcept(sender: WebSocket, type: string, data: any) {
186
- const msg = JSON.stringify({ type, data });
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
- // ── Auth middleware ──
194
- const tokenCache = new Map<string, number>(); // token → expiry timestamp
195
- const TOKEN_CACHE_TTL = 60_000; // 60s
196
-
197
- async function validateToken(token: string): Promise<boolean> {
198
- const cached = tokenCache.get(token);
199
- if (cached && cached > Date.now()) return true;
200
-
201
- try {
202
- const res = await fetch(
203
- `http://127.0.0.1:${workerPort}/api/portal/validate-token`,
204
- {
205
- method: 'POST',
206
- headers: { 'Content-Type': 'application/json' },
207
- body: JSON.stringify({ token })
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
- let authRequiredCache: { value: boolean; expires: number } | null = null;
169
+ let authRequiredCache: { value: boolean; expires: number } | null = null;
223
170
 
224
- async function isAuthRequired(): Promise<boolean> {
225
- if (authRequiredCache && authRequiredCache.expires > Date.now())
226
- return authRequiredCache.value;
227
- try {
228
- const res = await fetch(
229
- `http://127.0.0.1:${workerPort}/api/onboard/status`
230
- );
231
- const data = (await res.json()) as { portalConfigured: boolean };
232
- const required = !!data.portalConfigured;
233
- authRequiredCache = {
234
- value: required,
235
- expires: Date.now() + 30_000
236
- };
237
- return required;
238
- } catch {
239
- return false;
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
- const AUTH_EXEMPT_ROUTES = [
244
- 'POST /api/portal/login',
245
- 'GET /api/portal/login',
246
- 'POST /api/portal/validate-token',
247
- 'GET /api/portal/validate-token',
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
- // HTTP request handler proxies to Vite dev servers + worker API
276
- server.on('request', async (req, res) => {
277
- // Fluxy widget — served directly (not part of Vite build)
278
- if (req.url === '/fluxy/widget.js') {
279
- console.log('[supervisor] Serving /fluxy/widget.js directly');
280
- res.writeHead(200, {
281
- 'Content-Type': 'application/javascript',
282
- 'Cache-Control': 'no-cache'
283
- });
284
- res.end(fs.readFileSync(paths.widgetJs));
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
- // Service worker — served from embedded constant (supervisor/ is always updated)
289
- if (req.url === '/sw.js' || req.url === '/fluxy/sw.js') {
290
- res.writeHead(200, {
291
- 'Content-Type': 'application/javascript',
292
- 'Cache-Control': 'no-cache'
293
- });
294
- res.end(SW_JS);
295
- return;
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
- // App API routes → proxy to user's backend server
299
- if (req.url?.startsWith('/app/api')) {
300
- const backendPath = req.url.replace(/^\/app\/api/, '') || '/';
301
- console.log(
302
- `[supervisor] backend :${backendPort} | ${req.method} ${backendPath}`
303
- );
304
- if (!isBackendAlive()) {
305
- console.log('[supervisor] Backend down — returning 503');
306
- res.writeHead(503, { 'Content-Type': 'application/json' });
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
- const proxy = http.request(
312
- {
313
- host: '127.0.0.1',
314
- port: backendPort,
315
- path: backendPath,
316
- method: req.method,
317
- headers: req.headers
318
- },
319
- proxyRes => {
320
- res.writeHead(proxyRes.statusCode!, proxyRes.headers);
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
- // API routes → proxy to worker
337
- if (req.url?.startsWith('/api')) {
338
- console.log(
339
- `[supervisor] → worker :${workerPort} | ${req.method} ${req.url}`
340
- );
341
- if (!isWorkerAlive()) {
342
- console.log('[supervisor] Worker down — returning 503');
343
- res.writeHead(503, { 'Content-Type': 'text/html' });
344
- res.end(RECOVERING_HTML);
345
- return;
346
- }
347
-
348
- // Auth check for mutation routes (POST/PUT/DELETE) — GET/HEAD are read-only, skip auth
349
- const method = req.method || 'GET';
350
- if (
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
- // Fluxy routes → serve pre-built static files from dist-fluxy/
397
- // Note: must check '/fluxy/' (with slash) to avoid matching '/fluxy_tilts.webm' etc.
398
- if (req.url === '/fluxy' || req.url?.startsWith('/fluxy/')) {
399
- // Strip /fluxy prefix and resolve file path
400
- let filePath = req.url!.replace(/^\/fluxy\/?/, '') || 'fluxy.html';
401
- // Strip query strings (e.g. ?v=xxx)
402
- filePath = filePath.split('?')[0];
403
- const fullPath = path.join(DIST_FLUXY, filePath);
404
-
405
- // Security: prevent directory traversal
406
- if (!fullPath.startsWith(DIST_FLUXY)) {
407
- res.writeHead(403);
408
- res.end('Forbidden');
409
- return;
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
- try {
413
- const stat = fs.statSync(fullPath);
414
- if (stat.isFile()) {
415
- const ext = path.extname(fullPath);
416
- const mime = MIME_TYPES[ext] || 'application/octet-stream';
417
- // HTML files: no-cache so rebuilds are picked up immediately
418
- // Hashed assets (.js, .css): immutable caching
419
- const cacheControl =
420
- ext === '.html'
421
- ? 'no-cache'
422
- : 'public, max-age=31536000, immutable';
423
- res.writeHead(200, {
424
- 'Content-Type': mime,
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
- // Everything else → proxy to dashboard Vite dev server
440
- console.log(
441
- `[supervisor] dashboard Vite :${vitePorts.dashboard} | ${req.method} ${req.url}`
442
- );
443
- const proxy = http.request(
444
- {
445
- host: '127.0.0.1',
446
- port: vitePorts.dashboard,
447
- path: req.url,
448
- method: req.method,
449
- headers: req.headers
450
- },
451
- proxyRes => {
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
- // WebSocket: Fluxy chat + proxy worker WS
468
- const fluxyWss = new WebSocketServer({ noServer: true });
355
+ // WebSocket: Fluxy chat + proxy worker WS
356
+ const fluxyWss = new WebSocketServer({ noServer: true });
469
357
 
470
- /** Send a message to all connected fluxy WS clients */
471
- function broadcastFluxy(type: string, data: any = {}) {
472
- const msg = JSON.stringify({ type, data });
473
- for (const client of fluxyWss.clients) {
474
- if (client.readyState === WebSocket.OPEN) client.send(msg);
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
- fluxyWss.on('connection', ws => {
479
- log.info('Fluxy chat connected');
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
- // Heartbeat
502
- if (rawStr === 'ping') {
503
- if (ws.readyState === WebSocket.OPEN) ws.send('pong');
504
- return;
505
- }
386
+ // Heartbeat
387
+ if (rawStr === 'ping') {
388
+ if (ws.readyState === WebSocket.OPEN) ws.send('pong');
389
+ return;
390
+ }
506
391
 
507
- const msg = JSON.parse(rawStr);
392
+ const msg = JSON.parse(rawStr);
508
393
 
509
- // Whisper transcription via WebSocket (bypasses relay POST issues)
510
- if (msg.type === 'whisper:transcribe') {
511
- (async () => {
512
- try {
513
- const result = await workerApi(
514
- '/api/whisper/transcribe',
515
- 'POST',
516
- { audio: msg.data.audio }
517
- );
518
- if (ws.readyState === WebSocket.OPEN) {
519
- ws.send(
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
- // Save settings via WebSocket (bypasses relay POST issues)
541
- if (msg.type === 'settings:save') {
542
- (async () => {
543
- try {
544
- const result = await workerApi(
545
- '/api/onboard',
546
- 'POST',
547
- msg.data
548
- );
549
- if (ws.readyState === WebSocket.OPEN) {
550
- ws.send(
551
- JSON.stringify({
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
- // Switch tunnel mode at runtime (off ↔ quick)
575
- if (msg.type === 'tunnel:switch') {
576
- (async () => {
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
- const newMode = msg.data?.mode as 'off' | 'quick';
579
- const cfg = loadConfig();
580
-
581
- if (newMode === 'off') {
582
- // Stop tunnel, heartbeat, relay, watchdog
583
- stopHeartbeat();
584
- if (cfg.relay?.token) {
585
- try {
586
- await disconnect(cfg.relay.token);
587
- } catch {}
588
- }
589
- stopTunnel();
590
- if (watchdogInterval) {
591
- clearInterval(watchdogInterval);
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
- return;
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
- // New protocol: { type: 'user:message', data: { content, conversationId? } }
727
- if (msg.type === 'user:message') {
728
- const data = msg.data || {};
729
- const content = data.content;
730
- if (!content) return;
731
- if (data.conversationId) convId = data.conversationId;
732
-
733
- // Re-read config on each message so post-onboard changes are picked up
734
- const freshConfig = loadConfig();
735
- const freshAi =
736
- freshConfig.ai.provider &&
737
- (freshConfig.ai.apiKey ||
738
- freshConfig.ai.provider === 'ollama')
739
- ? createProvider(
740
- freshConfig.ai.provider,
741
- freshConfig.ai.apiKey,
742
- freshConfig.ai.baseUrl
743
- )
744
- : null;
745
-
746
- log.info(
747
- `[fluxy] provider=${freshConfig.ai.provider}, model=${freshConfig.ai.model}`
748
- );
749
-
750
- // Route Anthropic through Agent SDK (uses OAuth token, not API key)
751
- if (freshConfig.ai.provider === 'anthropic') {
752
- // Server-side persistence: create or reuse DB conversation, save user message
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
- // Other providers: use ai.chat() with conversation history
961
- const history = conversations.get(ws) || [];
962
- history.push({ role: 'user', content });
963
-
964
- if (!freshAi) {
965
- if (ws.readyState === WebSocket.OPEN) {
966
- ws.send(
967
- JSON.stringify({
968
- type: 'bot:error',
969
- data: {
970
- error: 'AI not configured. Set up your provider first.'
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
- ws.send(
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
- freshAi.chat(
988
- [
989
- {
990
- role: 'system',
991
- content:
992
- 'You are Fluxy, a helpful AI assistant. You help users manage and customize their self-hosted bot.'
993
- },
994
- ...history
995
- ],
996
- freshConfig.ai.model,
997
- token => {
998
- if (ws.readyState === WebSocket.OPEN)
999
- ws.send(
1000
- JSON.stringify({
1001
- type: 'bot:token',
1002
- data: { token, conversationId: convId }
1003
- })
1004
- );
1005
- },
1006
- full => {
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
- if (msg.type === 'user:stop') {
1033
- stopFluxyAgentQuery(convId);
1034
- return;
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
- if (msg.type === 'user:clear-context') {
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
- try {
1040
- clientConvs.delete(ws);
1041
- await workerApi('/api/context/clear', 'POST');
1042
- } catch (err: any) {
1043
- log.warn(`[fluxy] Clear context error: ${err.message}`);
1044
- }
1045
- // Broadcast clear to ALL clients
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
- return;
1049
- }
1050
- });
670
+ }
1051
671
 
1052
- ws.on('close', () => {
1053
- conversations.delete(ws);
1054
- clientConvs.delete(ws);
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
- // Fluxy chat WebSocket — Vite HMR is handled automatically (hmr.server = this server)
1059
- server.on('upgrade', async (req, socket: net.Socket, head) => {
1060
- console.log(
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 (!req.url?.startsWith('/fluxy/ws')) {
1065
- console.log('[supervisor] Letting Vite handle this upgrade');
1066
- return;
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
- // Auth check for WebSocket
1070
- const needsAuth = await isAuthRequired();
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
- console.log('[supervisor] → Fluxy chat WebSocket');
1083
- fluxyWss.handleUpgrade(req, socket, head, ws =>
1084
- fluxyWss.emit('connection', ws, req)
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
- // Start
1089
- server.on('error', (err: NodeJS.ErrnoException) => {
1090
- if (err.code === 'EADDRINUSE') {
1091
- log.error(
1092
- `Port ${config.port} is already in use. Stop the other process or change the port in ${paths.config}`
1093
- );
1094
- } else {
1095
- log.error(`Server error: ${err.message}`);
1096
- }
1097
- process.exit(1);
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
- server.listen(config.port, () => {
1101
- log.ok(`Supervisor on http://localhost:${config.port}`);
1102
- log.ok(`Fluxy chat at http://localhost:${config.port}/fluxy`);
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
- // Track whether an agent query is active file watcher defers to bot:done during turns
1109
- let agentQueryActive = false;
1110
- let pendingBackendRestart = false; // Set when file watcher fires during agent turn
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
- // Run fluxy update in a detached process so it survives daemon stop/restart
1114
- function runDeferredUpdate() {
1115
- const cliPath = path.join(PKG_DIR, 'bin', 'cli.js');
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
- // Spawn worker + backend
1132
- spawnWorker(workerPort);
1133
- spawnBackend(backendPort);
1134
-
1135
- // Start pulse/cron scheduler
1136
- startScheduler({
1137
- broadcastFluxy,
1138
- workerApi,
1139
- restartBackend: async () => {
1140
- resetBackendRestarts();
1141
- await stopBackend();
1142
- spawnBackend(backendPort);
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
- // Watch workspace files for changes — auto-restart backend
1148
- // Catches edits from VS Code, CLI, or any external tool.
1149
- // During agent turns, defers to bot:done (avoids mid-turn restarts).
1150
- const workspaceDir = WORKSPACE_DIR;
1151
- const backendDir = path.join(workspaceDir, 'backend');
1152
- let backendRestartTimer: ReturnType<typeof setTimeout> | null = null;
1153
-
1154
- function scheduleBackendRestart(reason: string) {
1155
- if (agentQueryActive) {
1156
- // Agent is working — don't restart now, flag it for bot:done
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
- // Watch backend/ for code changes
1170
- const backendWatcher = fs.watch(
1171
- backendDir,
1172
- { recursive: true },
1173
- (_event, filename) => {
1174
- if (!filename || !filename.match(/\.(ts|js|json)$/)) return;
1175
- scheduleBackendRestart(`Backend file changed: ${filename}`);
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
- // Watch workspace root for .env changes and .restart trigger
1180
- const workspaceWatcher = fs.watch(workspaceDir, (_event, filename) => {
1181
- if (!filename) return;
1182
- if (filename === '.env') {
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
- // Tunnel
1207
- let tunnelUrl: string | null = null;
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
- if (config.tunnel.mode === 'quick') {
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
- tunnelUrl = await startTunnel(config.port);
1212
- log.ok(`Tunnel: ${tunnelUrl}`);
1213
- console.log(`__TUNNEL_URL__=${tunnelUrl}`);
1214
-
1215
- // Persist tunnel URL so the worker can read it after handle registration
1216
- config.tunnelUrl = tunnelUrl;
1217
- saveConfig(config);
1218
-
1219
- // Wait for local server to be reachable before telling the relay.
1220
- // Probes localhost directly avoids macOS issue where server can't
1221
- // reach itself through the Cloudflare tunnel URL.
1222
- let tunnelReady = false;
1223
- log.info('Readiness probe: waiting for local server...');
1224
- for (let i = 0; i < 30; i++) {
1225
- try {
1226
- const res = await fetch(
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
- // Now register tunnel URL with relay and start heartbeats
1251
- if (config.relay?.token) {
1252
- try {
1253
- await updateTunnelUrl(config.relay.token, tunnelUrl);
1254
- startHeartbeat(config.relay.token, tunnelUrl);
1255
- if (config.relay.url) {
1256
- log.ok(`Relay: ${config.relay.url}`);
1257
- console.log(`__RELAY_URL__=${config.relay.url}`);
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
- log.warn(`Tunnel: ${err instanceof Error ? err.message : err}`);
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
- if (config.tunnel.mode === 'named') {
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
- await startNamedTunnel(
1275
- config.tunnel.configPath!,
1276
- config.tunnel.name!
1277
- );
1278
- tunnelUrl = `https://${config.tunnel.domain}`;
1279
- log.ok(`Named tunnel: ${tunnelUrl}`);
1280
- console.log(`__TUNNEL_URL__=${tunnelUrl}`);
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
- log.warn(
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
- // Tunnel watchdog — detects sleep/wake + periodic health checks
1322
- let watchdogInterval: ReturnType<typeof setInterval> | null = null;
1323
- if (tunnelUrl) {
1324
- let lastTick = Date.now();
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
- // Shutdown
1381
- const shutdown = async () => {
1382
- log.info('Shutting down...');
1383
- stopScheduler();
1384
- backendWatcher.close();
1385
- workspaceWatcher.close();
1386
- if (backendRestartTimer) clearTimeout(backendRestartTimer);
1387
- if (watchdogInterval) clearInterval(watchdogInterval);
1388
- stopHeartbeat();
1389
- const latestConfig = loadConfig();
1390
- if (latestConfig.relay?.token) {
1391
- await disconnect(latestConfig.relay.token);
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
- // Clear persisted tunnel URL so stale values aren't reused
1394
- delete latestConfig.tunnelUrl;
1395
- saveConfig(latestConfig);
1396
- stopWorker();
1397
- await stopBackend();
1398
- stopTunnel();
1399
- console.log('[supervisor] Stopping Vite dev servers...');
1400
- await stopViteDevServers();
1401
- server.close();
1402
- process.exit(0);
1403
- };
1404
- process.on('SIGINT', () => shutdown());
1405
- process.on('SIGTERM', () => shutdown());
1406
-
1407
- return tunnelUrl;
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
- log.error('Fatal', err);
1412
- process.exit(1);
1042
+ startSupervisor().catch((err) => {
1043
+ log.error('Fatal', err);
1044
+ process.exit(1);
1413
1045
  });