somratpro commited on
Commit
f41cf76
Β·
1 Parent(s): 40855ff

refactor: migrate dashboard to /dashboard and implement internal reverse proxy for gateway traffic

Browse files
Files changed (5) hide show
  1. Dockerfile +3 -1
  2. README.md +6 -5
  3. health-server.js +211 -64
  4. keep-alive.sh +2 -2
  5. start.sh +7 -2
Dockerfile CHANGED
@@ -35,8 +35,10 @@ RUN ln -s /home/node/.openclaw/openclaw-app/openclaw.mjs /usr/local/bin/openclaw
35
  # Copy HuggingClaw files
36
  COPY --chown=1000:1000 dns-fix.js /opt/dns-fix.js
37
  COPY --chown=1000:1000 health-server.js /home/node/app/health-server.js
 
38
  COPY --chown=1000:1000 start.sh /home/node/app/start.sh
39
  COPY --chown=1000:1000 keep-alive.sh /home/node/app/keep-alive.sh
 
40
  COPY --chown=1000:1000 workspace-sync.py /home/node/app/workspace-sync.py
41
  RUN chmod +x /home/node/app/start.sh /home/node/app/keep-alive.sh
42
 
@@ -48,6 +50,6 @@ ENV HOME=/home/node \
48
 
49
  WORKDIR /home/node/app
50
 
51
- EXPOSE 7860
52
 
53
  CMD ["/home/node/app/start.sh"]
 
35
  # Copy HuggingClaw files
36
  COPY --chown=1000:1000 dns-fix.js /opt/dns-fix.js
37
  COPY --chown=1000:1000 health-server.js /home/node/app/health-server.js
38
+ COPY --chown=1000:1000 iframe-fix.cjs /home/node/app/iframe-fix.cjs
39
  COPY --chown=1000:1000 start.sh /home/node/app/start.sh
40
  COPY --chown=1000:1000 keep-alive.sh /home/node/app/keep-alive.sh
41
+ COPY --chown=1000:1000 wa-guardian.js /home/node/app/wa-guardian.js
42
  COPY --chown=1000:1000 workspace-sync.py /home/node/app/workspace-sync.py
43
  RUN chmod +x /home/node/app/start.sh /home/node/app/keep-alive.sh
44
 
 
50
 
51
  WORKDIR /home/node/app
52
 
53
+ EXPOSE 7861
54
 
55
  CMD ["/home/node/app/start.sh"]
README.md CHANGED
@@ -4,7 +4,8 @@ emoji: 🦞
4
  colorFrom: blue
5
  colorTo: purple
6
  sdk: docker
7
- app_port: 7860
 
8
  pinned: true
9
  license: mit
10
  ---
@@ -95,7 +96,7 @@ After restarting, the bot should appear online on Telegram.
95
 
96
  To use WhatsApp:
97
 
98
- 1. Visit your Space's Dashboard (Port 7861) and click **πŸš€ Open Control UI**.
99
  2. In the Control UI, go to **Channels** β†’ **WhatsApp** β†’ **Login**.
100
  3. Scan the QR code with your phone. πŸ“±
101
 
@@ -110,7 +111,7 @@ Optionally set `BACKUP_DATASET_NAME` (default: `huggingclaw-backup`) to choose t
110
 
111
  ## πŸ“Š Dashboard & Monitoring
112
 
113
- HuggingClaw now features a beautiful web dashboard. Access it by visiting your Space's URL on port 7861 or the internal health endpoint:
114
 
115
  - **Uptime Tracking:** Real-time uptime monitoring.
116
  - **Sync Status:** Visual indicators for workspace backup operations.
@@ -165,7 +166,7 @@ See `.env.example` for complete settings. Key environment variables:
165
  | Variable | Default | Description |
166
  |--------------------|----------|-------------------------------------|
167
  | `OPENCLAW_VERSION` | `latest` | Pin a specific OpenClaw version |
168
- | `HEALTH_PORT` | `7861` | Internal health endpoint port |
169
 
170
  ## πŸ€– LLM Providers
171
 
@@ -226,7 +227,7 @@ cp .env.example .env
226
 
227
  ```bash
228
  docker build -t huggingclaw .
229
- docker run -p 7860:7860 --env-file .env huggingclaw
230
  ```
231
 
232
  **Without Docker:**
 
4
  colorFrom: blue
5
  colorTo: purple
6
  sdk: docker
7
+ app_port: 7861
8
+ base_path: /dashboard
9
  pinned: true
10
  license: mit
11
  ---
 
96
 
97
  To use WhatsApp:
98
 
99
+ 1. Visit your Space URL. It opens the dashboard at `/dashboard` by default, then click **Open Control UI**.
100
  2. In the Control UI, go to **Channels** β†’ **WhatsApp** β†’ **Login**.
101
  3. Scan the QR code with your phone. πŸ“±
102
 
 
111
 
112
  ## πŸ“Š Dashboard & Monitoring
113
 
114
+ HuggingClaw now features a built-in dashboard at `/dashboard`, served from the same public HF Space URL as the Control UI:
115
 
116
  - **Uptime Tracking:** Real-time uptime monitoring.
117
  - **Sync Status:** Visual indicators for workspace backup operations.
 
166
  | Variable | Default | Description |
167
  |--------------------|----------|-------------------------------------|
168
  | `OPENCLAW_VERSION` | `latest` | Pin a specific OpenClaw version |
169
+ | `HEALTH_PORT` | `7861` | Public dashboard / proxy port on HF Spaces |
170
 
171
  ## πŸ€– LLM Providers
172
 
 
227
 
228
  ```bash
229
  docker build -t huggingclaw .
230
+ docker run -p 7861:7861 --env-file .env huggingclaw
231
  ```
232
 
233
  **Without Docker:**
health-server.js CHANGED
@@ -1,57 +1,67 @@
1
- // Lightweight health server and dashboard on port 7861
2
  const http = require("http");
3
  const fs = require("fs");
 
4
 
5
- const PORT = process.env.HEALTH_PORT || 7861;
 
 
6
  const startTime = Date.now();
7
  const LLM_MODEL = process.env.LLM_MODEL || "Not Set";
8
- const GATEWAY_TOKEN = process.env.GATEWAY_TOKEN || "huggingclaw";
9
  const TELEGRAM_ENABLED = !!process.env.TELEGRAM_BOT_TOKEN;
10
 
11
- const server = http.createServer((req, res) => {
12
- const uptime = Math.floor((Date.now() - startTime) / 1000);
13
- const uptimeHuman = `${Math.floor(uptime / 3600)}h ${Math.floor((uptime % 3600) / 60)}m`;
14
-
15
- if (req.url === "/health") {
16
- res.writeHead(200, { "Content-Type": "application/json" });
17
- res.end(
18
- JSON.stringify({
19
- status: "ok",
20
- uptime: uptime,
21
- uptimeHuman: uptimeHuman,
22
- timestamp: new Date().toISOString(),
23
- }),
24
- );
25
- return;
26
  }
27
-
28
- if (req.url === "/status") {
29
- let syncStatus = { status: "unknown", message: "No sync data yet" };
30
- try {
31
- if (fs.existsSync("/tmp/sync-status.json")) {
32
- syncStatus = JSON.parse(
33
- fs.readFileSync("/tmp/sync-status.json", "utf8"),
34
- );
35
- }
36
- } catch (e) {}
37
-
38
- res.writeHead(200, { "Content-Type": "application/json" });
39
- res.end(
40
- JSON.stringify({
41
- model: LLM_MODEL,
42
- whatsapp: true,
43
- telegram: TELEGRAM_ENABLED,
44
- sync: syncStatus,
45
- uptime: uptimeHuman,
46
- token: GATEWAY_TOKEN,
47
- }),
48
- );
49
- return;
50
- }
51
-
52
- if (req.url === "/") {
53
- res.writeHead(200, { "Content-Type": "text/html" });
54
- res.end(`
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
55
  <!DOCTYPE html>
56
  <html lang="en">
57
  <head>
@@ -72,7 +82,7 @@ const server = http.createServer((req, res) => {
72
  }
73
 
74
  * { box-sizing: border-box; margin: 0; padding: 0; }
75
-
76
  body {
77
  font-family: 'Outfit', sans-serif;
78
  background-color: var(--bg);
@@ -82,7 +92,7 @@ const server = http.createServer((req, res) => {
82
  align-items: center;
83
  min-height: 100vh;
84
  overflow: hidden;
85
- background-image:
86
  radial-gradient(at 0% 0%, rgba(59, 130, 246, 0.15) 0px, transparent 50%),
87
  radial-gradient(at 100% 0%, rgba(139, 92, 246, 0.15) 0px, transparent 50%);
88
  }
@@ -224,14 +234,13 @@ const server = http.createServer((req, res) => {
224
  }
225
 
226
  #sync-msg { color: var(--text); display: block; margin-top: 4px; }
227
-
228
  </style>
229
  </head>
230
  <body>
231
  <div class="dashboard">
232
  <header>
233
  <h1>🦞 HuggingClaw</h1>
234
- <p class="subtitle">Space Control Panel</p>
235
  </header>
236
 
237
  <div class="stats-grid">
@@ -251,7 +260,7 @@ const server = http.createServer((req, res) => {
251
  <span class="stat-label">Telegram</span>
252
  <span id="tg-status">Loading...</span>
253
  </div>
254
- <a href="/" class="stat-btn">πŸš€ Open Control UI</a>
255
  </div>
256
 
257
  <div class="stat-card" style="width: 100%;">
@@ -264,7 +273,7 @@ const server = http.createServer((req, res) => {
264
  </div>
265
 
266
  <div class="footer">
267
- &bull; Live updates every 10s &bull;
268
  </div>
269
  </div>
270
 
@@ -276,12 +285,12 @@ const server = http.createServer((req, res) => {
276
 
277
  document.getElementById('model-id').textContent = data.model;
278
  document.getElementById('uptime').textContent = data.uptime;
279
-
280
- document.getElementById('wa-status').innerHTML = data.whatsapp
281
  ? '<div class="status-badge status-online"><div class="pulse"></div>Active</div>'
282
  : '<div class="status-badge status-offline">Disabled</div>';
283
 
284
- document.getElementById('tg-status').innerHTML = data.telegram
285
  ? '<div class="status-badge status-online"><div class="pulse"></div>Active</div>'
286
  : '<div class="status-badge status-offline">Disabled</div>';
287
 
@@ -295,16 +304,13 @@ const server = http.createServer((req, res) => {
295
  } else if (syncData.status === 'syncing') {
296
  badgeClass = 'status-syncing';
297
  pulseHtml = '<div class="pulse" style="background:#3b82f6"></div>';
298
- } else if (syncData.status === 'error') {
299
- badgeClass = 'status-offline';
300
  }
301
 
302
- document.getElementById('sync-badge-container').innerHTML =
303
  '<div class="status-badge ' + badgeClass + '">' + pulseHtml + syncData.status.toUpperCase() + '</div>';
304
-
305
  document.getElementById('sync-time').textContent = syncData.timestamp || 'Never';
306
  document.getElementById('sync-msg').textContent = syncData.message || 'Waiting for first sync...';
307
-
308
  } catch (e) {
309
  console.error("Failed to fetch status", e);
310
  }
@@ -315,14 +321,155 @@ const server = http.createServer((req, res) => {
315
  </script>
316
  </body>
317
  </html>
318
- `);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
319
  return;
320
  }
321
 
322
- res.writeHead(404);
323
- res.end();
324
  });
325
 
326
  server.listen(PORT, "0.0.0.0", () => {
327
- console.log(`πŸ₯ Health server & Dashboard listening on port ${PORT}`);
 
 
328
  });
 
1
+ // Single public entrypoint for HF Spaces: local dashboard + reverse proxy to OpenClaw.
2
  const http = require("http");
3
  const fs = require("fs");
4
+ const net = require("net");
5
 
6
+ const PORT = Number(process.env.HEALTH_PORT || 7861);
7
+ const GATEWAY_PORT = Number(process.env.GATEWAY_PORT || 7860);
8
+ const GATEWAY_HOST = "127.0.0.1";
9
  const startTime = Date.now();
10
  const LLM_MODEL = process.env.LLM_MODEL || "Not Set";
 
11
  const TELEGRAM_ENABLED = !!process.env.TELEGRAM_BOT_TOKEN;
12
 
13
+ function getPathname(url) {
14
+ try {
15
+ return new URL(url, "http://localhost").pathname;
16
+ } catch {
17
+ return "/";
 
 
 
 
 
 
 
 
 
 
18
  }
19
+ }
20
+
21
+ function isDashboardRoute(pathname) {
22
+ return pathname === "/dashboard" || pathname === "/dashboard/";
23
+ }
24
+
25
+ function isLocalRoute(pathname) {
26
+ return (
27
+ pathname === "/health" ||
28
+ pathname === "/status" ||
29
+ isDashboardRoute(pathname)
30
+ );
31
+ }
32
+
33
+ function appendForwarded(existingValue, nextValue) {
34
+ const cleanNext = nextValue || "";
35
+ if (!existingValue) return cleanNext;
36
+ if (Array.isArray(existingValue))
37
+ return `${existingValue.join(", ")}, ${cleanNext}`;
38
+ return `${existingValue}, ${cleanNext}`;
39
+ }
40
+
41
+ function buildProxyHeaders(headers, remoteAddress) {
42
+ return {
43
+ ...headers,
44
+ host: headers.host || `${GATEWAY_HOST}:${GATEWAY_PORT}`,
45
+ "x-forwarded-for": appendForwarded(
46
+ headers["x-forwarded-for"],
47
+ remoteAddress,
48
+ ),
49
+ "x-forwarded-host": headers["x-forwarded-host"] || headers.host || "",
50
+ "x-forwarded-proto": headers["x-forwarded-proto"] || "https",
51
+ };
52
+ }
53
+
54
+ function readSyncStatus() {
55
+ try {
56
+ if (fs.existsSync("/tmp/sync-status.json")) {
57
+ return JSON.parse(fs.readFileSync("/tmp/sync-status.json", "utf8"));
58
+ }
59
+ } catch {}
60
+ return { status: "unknown", message: "No sync data yet" };
61
+ }
62
+
63
+ function renderDashboard() {
64
+ return `
65
  <!DOCTYPE html>
66
  <html lang="en">
67
  <head>
 
82
  }
83
 
84
  * { box-sizing: border-box; margin: 0; padding: 0; }
85
+
86
  body {
87
  font-family: 'Outfit', sans-serif;
88
  background-color: var(--bg);
 
92
  align-items: center;
93
  min-height: 100vh;
94
  overflow: hidden;
95
+ background-image:
96
  radial-gradient(at 0% 0%, rgba(59, 130, 246, 0.15) 0px, transparent 50%),
97
  radial-gradient(at 100% 0%, rgba(139, 92, 246, 0.15) 0px, transparent 50%);
98
  }
 
234
  }
235
 
236
  #sync-msg { color: var(--text); display: block; margin-top: 4px; }
 
237
  </style>
238
  </head>
239
  <body>
240
  <div class="dashboard">
241
  <header>
242
  <h1>🦞 HuggingClaw</h1>
243
+ <p class="subtitle">Space Dashboard</p>
244
  </header>
245
 
246
  <div class="stats-grid">
 
260
  <span class="stat-label">Telegram</span>
261
  <span id="tg-status">Loading...</span>
262
  </div>
263
+ <a href="/" class="stat-btn">Open Control UI</a>
264
  </div>
265
 
266
  <div class="stat-card" style="width: 100%;">
 
273
  </div>
274
 
275
  <div class="footer">
276
+ Live updates every 10s
277
  </div>
278
  </div>
279
 
 
285
 
286
  document.getElementById('model-id').textContent = data.model;
287
  document.getElementById('uptime').textContent = data.uptime;
288
+
289
+ document.getElementById('wa-status').innerHTML = data.whatsapp
290
  ? '<div class="status-badge status-online"><div class="pulse"></div>Active</div>'
291
  : '<div class="status-badge status-offline">Disabled</div>';
292
 
293
+ document.getElementById('tg-status').innerHTML = data.telegram
294
  ? '<div class="status-badge status-online"><div class="pulse"></div>Active</div>'
295
  : '<div class="status-badge status-offline">Disabled</div>';
296
 
 
304
  } else if (syncData.status === 'syncing') {
305
  badgeClass = 'status-syncing';
306
  pulseHtml = '<div class="pulse" style="background:#3b82f6"></div>';
 
 
307
  }
308
 
309
+ document.getElementById('sync-badge-container').innerHTML =
310
  '<div class="status-badge ' + badgeClass + '">' + pulseHtml + syncData.status.toUpperCase() + '</div>';
311
+
312
  document.getElementById('sync-time').textContent = syncData.timestamp || 'Never';
313
  document.getElementById('sync-msg').textContent = syncData.message || 'Waiting for first sync...';
 
314
  } catch (e) {
315
  console.error("Failed to fetch status", e);
316
  }
 
321
  </script>
322
  </body>
323
  </html>
324
+ `;
325
+ }
326
+
327
+ function proxyHttp(req, res) {
328
+ const proxyReq = http.request(
329
+ {
330
+ hostname: GATEWAY_HOST,
331
+ port: GATEWAY_PORT,
332
+ method: req.method,
333
+ path: req.url,
334
+ headers: buildProxyHeaders(req.headers, req.socket.remoteAddress),
335
+ },
336
+ (proxyRes) => {
337
+ res.writeHead(proxyRes.statusCode || 502, proxyRes.headers);
338
+ proxyRes.pipe(res);
339
+ },
340
+ );
341
+
342
+ proxyReq.on("error", (error) => {
343
+ res.writeHead(502, { "Content-Type": "application/json" });
344
+ res.end(
345
+ JSON.stringify({
346
+ status: "error",
347
+ message: "Gateway unavailable",
348
+ detail: error.message,
349
+ }),
350
+ );
351
+ });
352
+
353
+ req.pipe(proxyReq);
354
+ }
355
+
356
+ function serializeUpgradeHeaders(req, remoteAddress) {
357
+ const forwardedHeaders = [];
358
+
359
+ for (let i = 0; i < req.rawHeaders.length; i += 2) {
360
+ const name = req.rawHeaders[i];
361
+ const value = req.rawHeaders[i + 1];
362
+ const lower = name.toLowerCase();
363
+
364
+ if (
365
+ lower === "x-forwarded-for" ||
366
+ lower === "x-forwarded-host" ||
367
+ lower === "x-forwarded-proto"
368
+ ) {
369
+ continue;
370
+ }
371
+
372
+ forwardedHeaders.push(`${name}: ${value}`);
373
+ }
374
+
375
+ forwardedHeaders.push(
376
+ `X-Forwarded-For: ${appendForwarded(req.headers["x-forwarded-for"], remoteAddress)}`,
377
+ );
378
+ forwardedHeaders.push(
379
+ `X-Forwarded-Host: ${req.headers["x-forwarded-host"] || req.headers.host || ""}`,
380
+ );
381
+ forwardedHeaders.push(
382
+ `X-Forwarded-Proto: ${req.headers["x-forwarded-proto"] || "https"}`,
383
+ );
384
+
385
+ return forwardedHeaders;
386
+ }
387
+
388
+ function proxyUpgrade(req, socket, head) {
389
+ const proxySocket = net.connect(GATEWAY_PORT, GATEWAY_HOST);
390
+
391
+ proxySocket.on("connect", () => {
392
+ const requestLines = [
393
+ `${req.method} ${req.url} HTTP/${req.httpVersion}`,
394
+ ...serializeUpgradeHeaders(req, req.socket.remoteAddress),
395
+ "",
396
+ "",
397
+ ];
398
+
399
+ proxySocket.write(requestLines.join("\r\n"));
400
+
401
+ if (head && head.length > 0) {
402
+ proxySocket.write(head);
403
+ }
404
+
405
+ socket.pipe(proxySocket).pipe(socket);
406
+ });
407
+
408
+ proxySocket.on("error", () => {
409
+ if (socket.writable) {
410
+ socket.write("HTTP/1.1 502 Bad Gateway\r\nConnection: close\r\n\r\n");
411
+ }
412
+ socket.destroy();
413
+ });
414
+
415
+ socket.on("error", () => {
416
+ proxySocket.destroy();
417
+ });
418
+ }
419
+
420
+ const server = http.createServer((req, res) => {
421
+ const pathname = getPathname(req.url || "/");
422
+ const uptime = Math.floor((Date.now() - startTime) / 1000);
423
+ const uptimeHuman = `${Math.floor(uptime / 3600)}h ${Math.floor((uptime % 3600) / 60)}m`;
424
+
425
+ if (pathname === "/health") {
426
+ res.writeHead(200, { "Content-Type": "application/json" });
427
+ res.end(
428
+ JSON.stringify({
429
+ status: "ok",
430
+ uptime,
431
+ uptimeHuman,
432
+ timestamp: new Date().toISOString(),
433
+ }),
434
+ );
435
+ return;
436
+ }
437
+
438
+ if (pathname === "/status") {
439
+ res.writeHead(200, { "Content-Type": "application/json" });
440
+ res.end(
441
+ JSON.stringify({
442
+ model: LLM_MODEL,
443
+ whatsapp: true,
444
+ telegram: TELEGRAM_ENABLED,
445
+ sync: readSyncStatus(),
446
+ uptime: uptimeHuman,
447
+ }),
448
+ );
449
+ return;
450
+ }
451
+
452
+ if (isDashboardRoute(pathname)) {
453
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8" });
454
+ res.end(renderDashboard());
455
+ return;
456
+ }
457
+
458
+ proxyHttp(req, res);
459
+ });
460
+
461
+ server.on("upgrade", (req, socket, head) => {
462
+ const pathname = getPathname(req.url || "/");
463
+ if (isLocalRoute(pathname)) {
464
+ socket.destroy();
465
  return;
466
  }
467
 
468
+ proxyUpgrade(req, socket, head);
 
469
  });
470
 
471
  server.listen(PORT, "0.0.0.0", () => {
472
+ console.log(
473
+ `Health server listening on port ${PORT}; proxying gateway traffic to ${GATEWAY_HOST}:${GATEWAY_PORT}`,
474
+ );
475
  });
keep-alive.sh CHANGED
@@ -18,8 +18,8 @@ if [ -z "$SPACE_HOST" ]; then
18
  exit 0
19
  fi
20
 
21
- # Ping the Space URL β€” any HTTP response (even 404) counts as activity
22
- PING_URL="https://${SPACE_HOST}"
23
 
24
  echo "πŸ’“ Keep-alive started: pinging ${PING_URL} every ${INTERVAL}s"
25
 
 
18
  exit 0
19
  fi
20
 
21
+ # Ping the health endpoint so we keep the Space warm without touching the gateway
22
+ PING_URL="https://${SPACE_HOST}/health"
23
 
24
  echo "πŸ’“ Keep-alive started: pinging ${PING_URL} every ${INTERVAL}s"
25
 
start.sh CHANGED
@@ -271,6 +271,7 @@ fi
271
  if [ -n "$SPACE_HOST" ]; then
272
  printf " β”‚ %-40s β”‚\n" "Keep-alive: βœ… every ${KEEP_ALIVE_INTERVAL:-300}s"
273
  printf " β”‚ %-40s β”‚\n" "Control UI: https://${SPACE_HOST}"
 
274
  else
275
  printf " β”‚ %-40s β”‚\n" "Keep-alive: ⏸️ local mode"
276
  fi
@@ -325,12 +326,16 @@ export LLM_MODEL="$LLM_MODEL"
325
  node /home/node/app/health-server.js &
326
  HEALTH_PID=$!
327
 
328
- # 11. Start WhatsApp Guardian (Automates pairing)
 
 
 
 
329
  node /home/node/app/wa-guardian.js &
330
  GUARDIAN_PID=$!
331
  echo "πŸ›‘οΈ WhatsApp Guardian started (PID: $GUARDIAN_PID)"
332
 
333
- # 12. Start Workspace Sync
334
  python3 -u /home/node/app/workspace-sync.py &
335
 
336
  # ── Launch gateway ──
 
271
  if [ -n "$SPACE_HOST" ]; then
272
  printf " β”‚ %-40s β”‚\n" "Keep-alive: βœ… every ${KEEP_ALIVE_INTERVAL:-300}s"
273
  printf " β”‚ %-40s β”‚\n" "Control UI: https://${SPACE_HOST}"
274
+ printf " β”‚ %-40s β”‚\n" "Dashboard: https://${SPACE_HOST}/dashboard"
275
  else
276
  printf " β”‚ %-40s β”‚\n" "Keep-alive: ⏸️ local mode"
277
  fi
 
326
  node /home/node/app/health-server.js &
327
  HEALTH_PID=$!
328
 
329
+ # 11. Start HF keep-alive
330
+ /home/node/app/keep-alive.sh &
331
+ KEEP_ALIVE_PID=$!
332
+
333
+ # 12. Start WhatsApp Guardian (Automates pairing)
334
  node /home/node/app/wa-guardian.js &
335
  GUARDIAN_PID=$!
336
  echo "πŸ›‘οΈ WhatsApp Guardian started (PID: $GUARDIAN_PID)"
337
 
338
+ # 13. Start Workspace Sync
339
  python3 -u /home/node/app/workspace-sync.py &
340
 
341
  # ── Launch gateway ──