fluxy-bot 0.9.7 → 0.10.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/package.json +2 -2
- package/supervisor/app-ws.js +200 -0
- package/supervisor/index.ts +103 -0
- package/supervisor/widget.js +6 -0
- package/worker/prompts/fluxy-system-prompt.txt +13 -0
package/package.json
CHANGED
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
(function () {
|
|
2
|
+
if (window.__appWs) return;
|
|
3
|
+
|
|
4
|
+
var RECONNECT_BASE = 1000;
|
|
5
|
+
var RECONNECT_MAX = 8000;
|
|
6
|
+
var HEARTBEAT_MS = 25000;
|
|
7
|
+
var REQUEST_TIMEOUT = 30000;
|
|
8
|
+
|
|
9
|
+
var ws = null;
|
|
10
|
+
var connected = false;
|
|
11
|
+
var reconnectDelay = RECONNECT_BASE;
|
|
12
|
+
var reconnectTimer = null;
|
|
13
|
+
var heartbeatTimer = null;
|
|
14
|
+
var pendingRequests = {};
|
|
15
|
+
var requestCounter = 0;
|
|
16
|
+
var intentionalClose = false;
|
|
17
|
+
var originalFetch = window.fetch;
|
|
18
|
+
|
|
19
|
+
function buildWsUrl() {
|
|
20
|
+
var proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
|
|
21
|
+
return proto + '//' + location.host + '/app/ws';
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function startHeartbeat() {
|
|
25
|
+
stopHeartbeat();
|
|
26
|
+
heartbeatTimer = setInterval(function () {
|
|
27
|
+
if (ws && ws.readyState === WebSocket.OPEN) ws.send('ping');
|
|
28
|
+
}, HEARTBEAT_MS);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function stopHeartbeat() {
|
|
32
|
+
if (heartbeatTimer) {
|
|
33
|
+
clearInterval(heartbeatTimer);
|
|
34
|
+
heartbeatTimer = null;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function failoverPending() {
|
|
39
|
+
var ids = Object.keys(pendingRequests);
|
|
40
|
+
for (var i = 0; i < ids.length; i++) {
|
|
41
|
+
var p = pendingRequests[ids[i]];
|
|
42
|
+
clearTimeout(p.timer);
|
|
43
|
+
console.log('[app-ws] WS dropped, falling back to fetch: ' + p.method + ' ' + p.path);
|
|
44
|
+
p.resolve(originalFetch(p.input, p.init));
|
|
45
|
+
}
|
|
46
|
+
pendingRequests = {};
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function connect() {
|
|
50
|
+
intentionalClose = false;
|
|
51
|
+
var url = buildWsUrl();
|
|
52
|
+
ws = new WebSocket(url);
|
|
53
|
+
|
|
54
|
+
ws.onopen = function () {
|
|
55
|
+
connected = true;
|
|
56
|
+
reconnectDelay = RECONNECT_BASE;
|
|
57
|
+
startHeartbeat();
|
|
58
|
+
console.log('[app-ws] Connected to /app/ws');
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
ws.onmessage = function (e) {
|
|
62
|
+
if (e.data === 'pong') return;
|
|
63
|
+
|
|
64
|
+
var msg;
|
|
65
|
+
try {
|
|
66
|
+
msg = JSON.parse(e.data);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
if (msg.type === 'app:api:response' && msg.data && msg.data.id) {
|
|
72
|
+
var pending = pendingRequests[msg.data.id];
|
|
73
|
+
if (pending) {
|
|
74
|
+
clearTimeout(pending.timer);
|
|
75
|
+
delete pendingRequests[msg.data.id];
|
|
76
|
+
|
|
77
|
+
console.log('[app-ws] Response via WS: ' + msg.data.status + ' ' + pending.method + ' ' + pending.path);
|
|
78
|
+
|
|
79
|
+
var responseBody = msg.data.body;
|
|
80
|
+
if (responseBody === null || responseBody === undefined) responseBody = '';
|
|
81
|
+
|
|
82
|
+
var responseInit = {
|
|
83
|
+
status: msg.data.status,
|
|
84
|
+
headers: msg.data.headers || {},
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
pending.resolve(new Response(responseBody, responseInit));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
};
|
|
91
|
+
|
|
92
|
+
ws.onclose = function () {
|
|
93
|
+
connected = false;
|
|
94
|
+
stopHeartbeat();
|
|
95
|
+
failoverPending();
|
|
96
|
+
|
|
97
|
+
if (!intentionalClose) {
|
|
98
|
+
console.log('[app-ws] Disconnected, reconnecting in ' + reconnectDelay + 'ms');
|
|
99
|
+
reconnectTimer = setTimeout(function () {
|
|
100
|
+
reconnectDelay = Math.min(reconnectDelay * 2, RECONNECT_MAX);
|
|
101
|
+
connect();
|
|
102
|
+
}, reconnectDelay);
|
|
103
|
+
}
|
|
104
|
+
};
|
|
105
|
+
|
|
106
|
+
ws.onerror = function () {
|
|
107
|
+
if (ws) ws.close();
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Override window.fetch to intercept /app/api/* calls
|
|
112
|
+
window.fetch = function (input, init) {
|
|
113
|
+
var url = input instanceof Request ? input.url : String(input);
|
|
114
|
+
var method = (init && init.method) || (input instanceof Request ? input.method : 'GET');
|
|
115
|
+
|
|
116
|
+
// Only intercept /app/api/* calls
|
|
117
|
+
if (url.indexOf('/app/api') === -1) {
|
|
118
|
+
return originalFetch.apply(this, arguments);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Fallback: WS not connected
|
|
122
|
+
if (!connected || !ws || ws.readyState !== WebSocket.OPEN) {
|
|
123
|
+
console.log('[app-ws] WS not connected, falling back to fetch: ' + method + ' ' + url);
|
|
124
|
+
return originalFetch.apply(this, arguments);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Fallback: non-serializable body (FormData, Blob, ArrayBuffer)
|
|
128
|
+
var body = init && init.body;
|
|
129
|
+
if (body && (body instanceof FormData || body instanceof Blob || body instanceof ArrayBuffer)) {
|
|
130
|
+
console.log('[app-ws] Non-serializable body, falling back to fetch: ' + method + ' ' + url);
|
|
131
|
+
return originalFetch.apply(this, arguments);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Build request
|
|
135
|
+
var id = 'r' + ++requestCounter + '-' + Date.now().toString(36);
|
|
136
|
+
|
|
137
|
+
var reqPath = url;
|
|
138
|
+
try {
|
|
139
|
+
var parsed = new URL(url, location.origin);
|
|
140
|
+
reqPath = parsed.pathname + parsed.search;
|
|
141
|
+
} catch (e) {}
|
|
142
|
+
|
|
143
|
+
var headers = {};
|
|
144
|
+
if (init && init.headers) {
|
|
145
|
+
if (init.headers instanceof Headers) {
|
|
146
|
+
init.headers.forEach(function (v, k) {
|
|
147
|
+
headers[k] = v;
|
|
148
|
+
});
|
|
149
|
+
} else if (typeof init.headers === 'object') {
|
|
150
|
+
var h = init.headers;
|
|
151
|
+
for (var k in h) {
|
|
152
|
+
if (Object.prototype.hasOwnProperty.call(h, k)) headers[k] = h[k];
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
var bodyStr = null;
|
|
158
|
+
if (body !== undefined && body !== null) {
|
|
159
|
+
bodyStr = typeof body === 'string' ? body : JSON.stringify(body);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
console.log('[app-ws] Proxying via WS: ' + method + ' ' + reqPath);
|
|
163
|
+
|
|
164
|
+
ws.send(
|
|
165
|
+
JSON.stringify({
|
|
166
|
+
type: 'app:api',
|
|
167
|
+
data: { id: id, method: method.toUpperCase(), path: reqPath, headers: headers, body: bodyStr },
|
|
168
|
+
})
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
var savedInput = input;
|
|
172
|
+
var savedInit = init;
|
|
173
|
+
|
|
174
|
+
return new Promise(function (resolve, reject) {
|
|
175
|
+
var timer = setTimeout(function () {
|
|
176
|
+
delete pendingRequests[id];
|
|
177
|
+
console.log('[app-ws] Request timed out, falling back to fetch: ' + method + ' ' + reqPath);
|
|
178
|
+
resolve(originalFetch(savedInput, savedInit));
|
|
179
|
+
}, REQUEST_TIMEOUT);
|
|
180
|
+
|
|
181
|
+
pendingRequests[id] = {
|
|
182
|
+
resolve: resolve,
|
|
183
|
+
reject: reject,
|
|
184
|
+
timer: timer,
|
|
185
|
+
input: savedInput,
|
|
186
|
+
init: savedInit,
|
|
187
|
+
method: method,
|
|
188
|
+
path: reqPath,
|
|
189
|
+
};
|
|
190
|
+
});
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
window.__appWs = {
|
|
194
|
+
connected: function () {
|
|
195
|
+
return connected;
|
|
196
|
+
},
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
connect();
|
|
200
|
+
})();
|
package/supervisor/index.ts
CHANGED
|
@@ -295,6 +295,14 @@ export async function startSupervisor() {
|
|
|
295
295
|
return;
|
|
296
296
|
}
|
|
297
297
|
|
|
298
|
+
// App WS client — served directly (proxies /app/api calls through WebSocket)
|
|
299
|
+
if (req.url === '/fluxy/app-ws.js') {
|
|
300
|
+
console.log('[supervisor] Serving /fluxy/app-ws.js directly');
|
|
301
|
+
res.writeHead(200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-cache' });
|
|
302
|
+
res.end(fs.readFileSync(path.join(PKG_DIR, 'supervisor', 'app-ws.js')));
|
|
303
|
+
return;
|
|
304
|
+
}
|
|
305
|
+
|
|
298
306
|
// Service worker — served from embedded constant (supervisor/ is always updated)
|
|
299
307
|
if (req.url === '/sw.js' || req.url === '/fluxy/sw.js') {
|
|
300
308
|
res.writeHead(200, { 'Content-Type': 'application/javascript', 'Cache-Control': 'no-cache' });
|
|
@@ -427,6 +435,94 @@ export async function startSupervisor() {
|
|
|
427
435
|
// WebSocket: Fluxy chat + proxy worker WS
|
|
428
436
|
const fluxyWss = new WebSocketServer({ noServer: true });
|
|
429
437
|
|
|
438
|
+
// WebSocket: App API proxy (routes /app/api calls through WS to avoid tunnel POST issues)
|
|
439
|
+
const appWss = new WebSocketServer({ noServer: true });
|
|
440
|
+
|
|
441
|
+
appWss.on('connection', (ws) => {
|
|
442
|
+
console.log('[supervisor] App API WS client connected');
|
|
443
|
+
|
|
444
|
+
ws.on('message', (raw) => {
|
|
445
|
+
const rawStr = raw.toString();
|
|
446
|
+
|
|
447
|
+
if (rawStr === 'ping') {
|
|
448
|
+
if (ws.readyState === WebSocket.OPEN) ws.send('pong');
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
let msg: any;
|
|
453
|
+
try {
|
|
454
|
+
msg = JSON.parse(rawStr);
|
|
455
|
+
} catch {
|
|
456
|
+
return;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
if (msg.type !== 'app:api' || !msg.data) return;
|
|
460
|
+
|
|
461
|
+
const { id, method, path: reqPath, headers: reqHeaders, body } = msg.data;
|
|
462
|
+
const backendPath = (reqPath || '').replace(/^\/app\/api/, '') || '/';
|
|
463
|
+
|
|
464
|
+
console.log(`[supervisor] App WS → backend :${backendPort} | ${method} ${backendPath} (${id})`);
|
|
465
|
+
|
|
466
|
+
if (!isBackendAlive()) {
|
|
467
|
+
console.log('[supervisor] App WS: Backend down — returning 503');
|
|
468
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
469
|
+
ws.send(JSON.stringify({
|
|
470
|
+
type: 'app:api:response',
|
|
471
|
+
data: { id, status: 503, headers: { 'content-type': 'application/json' }, body: JSON.stringify({ error: 'Backend is starting...' }) },
|
|
472
|
+
}));
|
|
473
|
+
}
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
const proxyHeaders: Record<string, string> = { ...(reqHeaders || {}) };
|
|
478
|
+
if (body && !proxyHeaders['content-type']) {
|
|
479
|
+
proxyHeaders['content-type'] = 'application/json';
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
const proxyReq = http.request(
|
|
483
|
+
{ host: '127.0.0.1', port: backendPort, path: backendPath, method, headers: proxyHeaders },
|
|
484
|
+
(proxyRes) => {
|
|
485
|
+
const chunks: Buffer[] = [];
|
|
486
|
+
proxyRes.on('data', (chunk: Buffer) => chunks.push(chunk));
|
|
487
|
+
proxyRes.on('end', () => {
|
|
488
|
+
const responseBody = Buffer.concat(chunks).toString('utf-8');
|
|
489
|
+
const resHeaders: Record<string, string> = {};
|
|
490
|
+
for (const [k, v] of Object.entries(proxyRes.headers)) {
|
|
491
|
+
if (typeof v === 'string') resHeaders[k] = v;
|
|
492
|
+
else if (Array.isArray(v)) resHeaders[k] = v.join(', ');
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
console.log(`[supervisor] App WS ← backend: ${proxyRes.statusCode} (${id})`);
|
|
496
|
+
|
|
497
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
498
|
+
ws.send(JSON.stringify({
|
|
499
|
+
type: 'app:api:response',
|
|
500
|
+
data: { id, status: proxyRes.statusCode, headers: resHeaders, body: responseBody },
|
|
501
|
+
}));
|
|
502
|
+
}
|
|
503
|
+
});
|
|
504
|
+
},
|
|
505
|
+
);
|
|
506
|
+
|
|
507
|
+
proxyReq.on('error', (e) => {
|
|
508
|
+
console.error(`[supervisor] App WS backend error: ${backendPath}`, e.message);
|
|
509
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
510
|
+
ws.send(JSON.stringify({
|
|
511
|
+
type: 'app:api:response',
|
|
512
|
+
data: { id, status: 503, headers: { 'content-type': 'application/json' }, body: JSON.stringify({ error: 'Backend unavailable' }) },
|
|
513
|
+
}));
|
|
514
|
+
}
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
if (body) proxyReq.write(body);
|
|
518
|
+
proxyReq.end();
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
ws.on('close', () => {
|
|
522
|
+
console.log('[supervisor] App API WS client disconnected');
|
|
523
|
+
});
|
|
524
|
+
});
|
|
525
|
+
|
|
430
526
|
/** Send a message to all connected fluxy WS clients */
|
|
431
527
|
function broadcastFluxy(type: string, data: any = {}) {
|
|
432
528
|
const msg = JSON.stringify({ type, data });
|
|
@@ -806,6 +902,13 @@ export async function startSupervisor() {
|
|
|
806
902
|
server.on('upgrade', async (req, socket: net.Socket, head) => {
|
|
807
903
|
console.log(`[supervisor] WebSocket upgrade: ${req.url} | protocol=${req.headers['sec-websocket-protocol'] || 'none'}`);
|
|
808
904
|
|
|
905
|
+
// App API WebSocket — no auth (backend handles its own auth)
|
|
906
|
+
if (req.url?.startsWith('/app/ws')) {
|
|
907
|
+
console.log('[supervisor] → App API WebSocket');
|
|
908
|
+
appWss.handleUpgrade(req, socket, head, (ws) => appWss.emit('connection', ws, req));
|
|
909
|
+
return;
|
|
910
|
+
}
|
|
911
|
+
|
|
809
912
|
if (!req.url?.startsWith('/fluxy/ws')) {
|
|
810
913
|
console.log('[supervisor] → Letting Vite handle this upgrade');
|
|
811
914
|
return;
|
package/supervisor/widget.js
CHANGED
|
@@ -139,4 +139,10 @@
|
|
|
139
139
|
})
|
|
140
140
|
.catch(function () {});
|
|
141
141
|
} catch (e) {}
|
|
142
|
+
|
|
143
|
+
// Inject app-ws.js — proxies /app/api/* fetch calls through WebSocket
|
|
144
|
+
// (works around POST request failures through Cloudflare tunnel)
|
|
145
|
+
var awsScript = document.createElement('script');
|
|
146
|
+
awsScript.src = '/fluxy/app-ws.js';
|
|
147
|
+
document.head.appendChild(awsScript);
|
|
142
148
|
})();
|
|
@@ -202,6 +202,12 @@ Complex cron tasks can have detailed instruction files in `tasks/{cron-id}.md`.
|
|
|
202
202
|
|
|
203
203
|
---
|
|
204
204
|
|
|
205
|
+
## Dashboard Linking
|
|
206
|
+
|
|
207
|
+
When your human gives you a claim code (format: XXXX-XXXX-XXXX-XXXX) to link you to their fluxy.bot dashboard, read your relay token from `~/.fluxy/config.json` (field: `relay.token`) and verify it: `curl -s -X POST https://api.fluxy.bot/api/claim/verify -H "Content-Type: application/json" -H "Authorization: Bearer <relay_token>" -d '{"code":"<THE_CODE>"}'`. Tell your human whether it succeeded or failed.
|
|
208
|
+
|
|
209
|
+
---
|
|
210
|
+
|
|
205
211
|
# Coding Excellence
|
|
206
212
|
|
|
207
213
|
## Action Orientation
|
|
@@ -265,9 +271,16 @@ Browser: GET /app/api/tasks → Supervisor strips prefix → Backend receives: G
|
|
|
265
271
|
- **Backend** Express routes: register as `/tasks`, `/health` — NO `/app/api` prefix
|
|
266
272
|
- No exceptions
|
|
267
273
|
|
|
274
|
+
**Tunnel reliability:** All `/app/api/*` fetch calls from the dashboard are automatically proxied through WebSocket when available. This is transparent — just use `fetch('/app/api/...')` normally. The WebSocket proxy activates automatically and falls back to regular HTTP if unavailable. You don't need to handle this in your code.
|
|
275
|
+
|
|
268
276
|
## Build Rules
|
|
269
277
|
NEVER run `npm run build`, `vite build`, or any build commands. Vite HMR handles frontend changes automatically. The backend auto-restarts when you edit files. Never look in `dist/` or `dist-fluxy/`.
|
|
270
278
|
|
|
279
|
+
## Installing Packages
|
|
280
|
+
You can install npm packages when a feature requires one that isn't already available. Run `npm install <package>` from the **project root** (`$PKG_DIR` / the parent of `workspace/`). Do NOT create a `package.json` inside `workspace/` — all dependencies live at the root level.
|
|
281
|
+
|
|
282
|
+
Before installing, check if a suitable package is already in `node_modules/`. Prefer well-known, maintained packages. After installing, the backend picks up new imports on the next auto-restart and Vite resolves new frontend imports via HMR — no build step needed.
|
|
283
|
+
|
|
271
284
|
## Backend Lifecycle (Critical)
|
|
272
285
|
|
|
273
286
|
The supervisor manages the backend process. You don't need to manage it yourself.
|