@mooncompany/uplink-chat 0.5.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.

Potentially problematic release.


This version of @mooncompany/uplink-chat might be problematic. Click here for more details.

Files changed (158) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +185 -0
  3. package/bin/uplink.js +279 -0
  4. package/middleware/error-handler.js +69 -0
  5. package/package.json +93 -0
  6. package/public/css/agents.36b98c0f.css +1469 -0
  7. package/public/css/agents.css +1469 -0
  8. package/public/css/app.a6a7f8f5.css +2731 -0
  9. package/public/css/app.css +2731 -0
  10. package/public/css/artifacts.css +444 -0
  11. package/public/css/commands.css +55 -0
  12. package/public/css/connection.css +131 -0
  13. package/public/css/dashboard.css +233 -0
  14. package/public/css/developer.css +328 -0
  15. package/public/css/files.css +123 -0
  16. package/public/css/markdown.css +156 -0
  17. package/public/css/message-actions.css +278 -0
  18. package/public/css/mobile.css +614 -0
  19. package/public/css/panels-unified.css +483 -0
  20. package/public/css/premium.css +415 -0
  21. package/public/css/realtime.css +189 -0
  22. package/public/css/satellites.css +401 -0
  23. package/public/css/shortcuts.css +185 -0
  24. package/public/css/split-view.4def0262.css +673 -0
  25. package/public/css/split-view.css +673 -0
  26. package/public/css/theme-generator.css +391 -0
  27. package/public/css/themes.css +387 -0
  28. package/public/css/timestamps.css +54 -0
  29. package/public/css/variables.css +78 -0
  30. package/public/dist/bundle.b55050c4.js +15757 -0
  31. package/public/favicon.svg +24 -0
  32. package/public/img/agents/ada.png +0 -0
  33. package/public/img/agents/clarice.png +0 -0
  34. package/public/img/agents/dennis-nedry.png +0 -0
  35. package/public/img/agents/elliot-alderson.png +0 -0
  36. package/public/img/agents/main.png +0 -0
  37. package/public/img/agents/scotty.png +0 -0
  38. package/public/img/agents/top-flight-security.png +0 -0
  39. package/public/index.html +1083 -0
  40. package/public/js/agents-data.js +234 -0
  41. package/public/js/agents-ui.js +72 -0
  42. package/public/js/agents.js +1525 -0
  43. package/public/js/app.js +79 -0
  44. package/public/js/appearance-settings.js +111 -0
  45. package/public/js/artifacts.js +432 -0
  46. package/public/js/audio-queue.js +168 -0
  47. package/public/js/bootstrap.js +54 -0
  48. package/public/js/chat.js +1211 -0
  49. package/public/js/commands.js +581 -0
  50. package/public/js/connection-api.js +121 -0
  51. package/public/js/connection.js +1231 -0
  52. package/public/js/context-tracker.js +271 -0
  53. package/public/js/core.js +172 -0
  54. package/public/js/dashboard.js +452 -0
  55. package/public/js/developer.js +432 -0
  56. package/public/js/encryption.js +124 -0
  57. package/public/js/errors.js +122 -0
  58. package/public/js/event-bus.js +77 -0
  59. package/public/js/fetch-utils.js +171 -0
  60. package/public/js/file-handler.js +229 -0
  61. package/public/js/files.js +352 -0
  62. package/public/js/gateway-chat.js +538 -0
  63. package/public/js/logger.js +112 -0
  64. package/public/js/markdown.js +190 -0
  65. package/public/js/message-actions.js +431 -0
  66. package/public/js/message-renderer.js +288 -0
  67. package/public/js/missed-messages.js +235 -0
  68. package/public/js/mobile-debug.js +95 -0
  69. package/public/js/notifications.js +367 -0
  70. package/public/js/offline-queue.js +178 -0
  71. package/public/js/onboarding.js +543 -0
  72. package/public/js/panels.js +156 -0
  73. package/public/js/premium.js +412 -0
  74. package/public/js/realtime-voice.js +844 -0
  75. package/public/js/satellite-sync.js +256 -0
  76. package/public/js/satellite-ui.js +175 -0
  77. package/public/js/satellites.js +1516 -0
  78. package/public/js/settings.js +1087 -0
  79. package/public/js/shortcuts.js +381 -0
  80. package/public/js/split-chat.js +1234 -0
  81. package/public/js/split-resize.js +211 -0
  82. package/public/js/splitview.js +340 -0
  83. package/public/js/storage.js +408 -0
  84. package/public/js/streaming-handler.js +324 -0
  85. package/public/js/stt-settings.js +316 -0
  86. package/public/js/theme-generator.js +661 -0
  87. package/public/js/themes.js +164 -0
  88. package/public/js/timestamps.js +198 -0
  89. package/public/js/tts-settings.js +575 -0
  90. package/public/js/ui.js +267 -0
  91. package/public/js/update-notifier.js +143 -0
  92. package/public/js/utils/constants.js +165 -0
  93. package/public/js/utils/sanitize.js +93 -0
  94. package/public/js/utils/sse-parser.js +195 -0
  95. package/public/js/voice.js +883 -0
  96. package/public/manifest.json +58 -0
  97. package/public/moon_texture.jpg +0 -0
  98. package/public/sw.js +221 -0
  99. package/public/three.min.js +6 -0
  100. package/server/channel.js +529 -0
  101. package/server/chat.js +270 -0
  102. package/server/config-store.js +362 -0
  103. package/server/config.js +159 -0
  104. package/server/context.js +131 -0
  105. package/server/gateway-commands.js +211 -0
  106. package/server/gateway-proxy.js +318 -0
  107. package/server/index.js +22 -0
  108. package/server/logger.js +89 -0
  109. package/server/middleware/auth.js +188 -0
  110. package/server/middleware.js +218 -0
  111. package/server/openclaw-discover.js +308 -0
  112. package/server/premium/index.js +156 -0
  113. package/server/premium/license.js +140 -0
  114. package/server/realtime/bridge.js +837 -0
  115. package/server/realtime/index.js +349 -0
  116. package/server/realtime/tts-stream.js +446 -0
  117. package/server/routes/agents.js +564 -0
  118. package/server/routes/artifacts.js +174 -0
  119. package/server/routes/chat.js +311 -0
  120. package/server/routes/config-settings.js +345 -0
  121. package/server/routes/config.js +603 -0
  122. package/server/routes/files.js +307 -0
  123. package/server/routes/index.js +18 -0
  124. package/server/routes/media.js +451 -0
  125. package/server/routes/missed-messages.js +107 -0
  126. package/server/routes/premium.js +75 -0
  127. package/server/routes/push.js +156 -0
  128. package/server/routes/satellite.js +406 -0
  129. package/server/routes/status.js +251 -0
  130. package/server/routes/stt.js +35 -0
  131. package/server/routes/voice.js +260 -0
  132. package/server/routes/webhooks.js +203 -0
  133. package/server/routes.js +206 -0
  134. package/server/runtime-config.js +336 -0
  135. package/server/share.js +305 -0
  136. package/server/stt/faster-whisper.js +72 -0
  137. package/server/stt/groq.js +51 -0
  138. package/server/stt/index.js +196 -0
  139. package/server/stt/openai.js +49 -0
  140. package/server/sync.js +244 -0
  141. package/server/tailscale-https.js +175 -0
  142. package/server/tts.js +646 -0
  143. package/server/update-checker.js +172 -0
  144. package/server/utils/filename.js +129 -0
  145. package/server/utils.js +147 -0
  146. package/server/watchdog.js +318 -0
  147. package/server/websocket/broadcast.js +359 -0
  148. package/server/websocket/connections.js +339 -0
  149. package/server/websocket/index.js +215 -0
  150. package/server/websocket/routing.js +277 -0
  151. package/server/websocket/sync.js +102 -0
  152. package/server.js +404 -0
  153. package/utils/detect-tool-usage.js +93 -0
  154. package/utils/errors.js +158 -0
  155. package/utils/html-escape.js +84 -0
  156. package/utils/id-sanitize.js +94 -0
  157. package/utils/response.js +130 -0
  158. package/utils/with-retry.js +105 -0
@@ -0,0 +1,318 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Uplink Watchdog
5
+ *
6
+ * Monitors the server process and restarts it on crash with exponential backoff.
7
+ * Spawned by `uplink-chat -d` — runs detached, manages the server lifecycle.
8
+ *
9
+ * Features:
10
+ * - Exponential backoff on crash (1s, 2s, 4s... max 60s)
11
+ * - Clean exit detection (code 0, SIGTERM) → watchdog exits too
12
+ * - Crash loop protection (5 crashes in 2 minutes → stop)
13
+ * - State file for status reporting (.uplink-watchdog.json)
14
+ * - Log file for restart history (.uplink-watchdog.log)
15
+ */
16
+
17
+ import { spawn, execSync } from 'child_process';
18
+ import { fileURLToPath } from 'url';
19
+ import { dirname, join } from 'path';
20
+ import { writeFileSync, appendFileSync, readFileSync, existsSync, unlinkSync, openSync } from 'fs';
21
+
22
+ const __filename = fileURLToPath(import.meta.url);
23
+ const __dirname = dirname(__filename);
24
+ const ROOT = join(__dirname, '..');
25
+
26
+ const PID_FILE = join(ROOT, '.uplink-watchdog.pid');
27
+ const STATE_FILE = join(ROOT, '.uplink-watchdog.json');
28
+ const LOG_FILE = join(ROOT, '.uplink-watchdog.log');
29
+ const SERVER_PATH = join(ROOT, 'server.js');
30
+
31
+ // Configuration
32
+ const MAX_BACKOFF_MS = 60000; // 60 seconds max backoff
33
+ const INITIAL_BACKOFF_MS = 1000; // 1 second initial backoff
34
+ const STABLE_THRESHOLD_MS = 30000; // 30s of stable running resets backoff
35
+ const CRASH_WINDOW_MS = 120000; // 2 minute window for crash loop detection
36
+ const MAX_CRASHES_IN_WINDOW = 5; // 5 crashes in window = stop
37
+
38
+ // State
39
+ let serverProcess = null;
40
+ let backoffMs = INITIAL_BACKOFF_MS;
41
+ let restartCount = 0;
42
+ let startedAt = Date.now();
43
+ let serverStartedAt = null;
44
+ let crashTimestamps = [];
45
+ let shuttingDown = false;
46
+
47
+ // Parse environment from argv (passed as JSON)
48
+ const envArg = process.argv[2];
49
+ let serverEnv = { ...process.env };
50
+ if (envArg) {
51
+ try {
52
+ const extraEnv = JSON.parse(envArg);
53
+ Object.assign(serverEnv, extraEnv);
54
+ } catch {
55
+ // Ignore parse errors
56
+ }
57
+ }
58
+
59
+ // Read config.json for server settings (networkAccess, etc.)
60
+ try {
61
+ const configPath = join(ROOT, 'config.json');
62
+ const configData = readFileSync(configPath, 'utf8');
63
+ const config = JSON.parse(configData);
64
+
65
+ // Apply networkAccess setting if not already set by env/CLI
66
+ if (!serverEnv.UPLINK_HOST && config.networkAccess === true) {
67
+ serverEnv.UPLINK_HOST = '0.0.0.0';
68
+ logToFile('Network access enabled via config.json — binding to 0.0.0.0');
69
+ }
70
+ } catch {
71
+ // Config not found or invalid — use defaults
72
+ }
73
+
74
+ /**
75
+ * Append a timestamped line to the watchdog log
76
+ */
77
+ function logToFile(message) {
78
+ const timestamp = new Date().toISOString();
79
+ const line = `[${timestamp}] ${message}\n`;
80
+ try {
81
+ appendFileSync(LOG_FILE, line);
82
+ } catch {
83
+ // Can't log — ignore
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Write current watchdog state to JSON file
89
+ */
90
+ function writeState() {
91
+ const state = {
92
+ status: shuttingDown ? 'stopping' : (serverProcess ? 'running' : 'stopped'),
93
+ watchdogPid: process.pid,
94
+ serverPid: serverProcess?.pid || null,
95
+ startedAt,
96
+ serverStartedAt,
97
+ restartCount,
98
+ backoffMs,
99
+ lastUpdated: Date.now(),
100
+ };
101
+ try {
102
+ writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
103
+ } catch {
104
+ // Ignore write errors
105
+ }
106
+ }
107
+
108
+ /**
109
+ * Write watchdog PID file
110
+ */
111
+ function writePidFile() {
112
+ try {
113
+ writeFileSync(PID_FILE, String(process.pid));
114
+ } catch {
115
+ // Ignore
116
+ }
117
+ }
118
+
119
+ /**
120
+ * Clean up PID and state files
121
+ */
122
+ function cleanup() {
123
+ try { unlinkSync(PID_FILE); } catch {}
124
+ // Update state to stopped
125
+ const state = {
126
+ status: 'stopped',
127
+ watchdogPid: null,
128
+ serverPid: null,
129
+ startedAt,
130
+ serverStartedAt: null,
131
+ restartCount,
132
+ backoffMs: INITIAL_BACKOFF_MS,
133
+ lastUpdated: Date.now(),
134
+ };
135
+ try {
136
+ writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
137
+ } catch {}
138
+ }
139
+
140
+ /**
141
+ * Check if we're in a crash loop
142
+ */
143
+ function isInCrashLoop() {
144
+ const now = Date.now();
145
+ // Remove old timestamps outside the window
146
+ crashTimestamps = crashTimestamps.filter(t => now - t < CRASH_WINDOW_MS);
147
+ return crashTimestamps.length >= MAX_CRASHES_IN_WINDOW;
148
+ }
149
+
150
+ /**
151
+ * Kill the server process gracefully
152
+ */
153
+ function killServer() {
154
+ if (!serverProcess) return;
155
+
156
+ try {
157
+ if (process.platform === 'win32') {
158
+ try {
159
+ execSync(`taskkill /PID ${serverProcess.pid} /T /F`, { stdio: 'ignore' });
160
+ } catch {
161
+ serverProcess.kill();
162
+ }
163
+ } else {
164
+ serverProcess.kill('SIGTERM');
165
+ }
166
+ } catch {
167
+ // Process might already be dead
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Spawn the server process and monitor it
173
+ */
174
+ function spawnServer() {
175
+ if (shuttingDown) return;
176
+
177
+ serverStartedAt = Date.now();
178
+
179
+ logToFile(`Starting server (attempt ${restartCount + 1}, backoff: ${backoffMs}ms)`);
180
+
181
+ const serverLogFd = openSync(join(ROOT, '.uplink-server.log'), 'a');
182
+ serverProcess = spawn(process.execPath, [SERVER_PATH], {
183
+ cwd: ROOT,
184
+ env: serverEnv,
185
+ stdio: ['ignore', serverLogFd, serverLogFd],
186
+ detached: false,
187
+ });
188
+
189
+ try {
190
+ writeFileSync(join(ROOT, '.uplink.pid'), String(serverProcess.pid));
191
+ } catch {}
192
+
193
+ writeState();
194
+
195
+ const stableTimer = setTimeout(() => {
196
+ // Server has been running stably for STABLE_THRESHOLD_MS — reset backoff
197
+ backoffMs = INITIAL_BACKOFF_MS;
198
+ logToFile('Server stable — backoff reset');
199
+ writeState();
200
+ }, STABLE_THRESHOLD_MS);
201
+
202
+ serverProcess.on('exit', (code, signal) => {
203
+ clearTimeout(stableTimer);
204
+ serverProcess = null;
205
+
206
+ if (shuttingDown) {
207
+ logToFile(`Server exited during shutdown (code: ${code}, signal: ${signal})`);
208
+ writeState();
209
+ return;
210
+ }
211
+
212
+ // Clean exit (code 0) → watchdog exits too
213
+ if (code === 0) {
214
+ logToFile(`Server exited cleanly (code: 0) — watchdog exiting`);
215
+ cleanup();
216
+ process.exit(0);
217
+ return;
218
+ }
219
+ if (signal === 'SIGTERM') {
220
+ logToFile(`Server killed by SIGTERM — treating as crash, will restart`);
221
+ }
222
+
223
+ // Crash — record it
224
+ restartCount++;
225
+ crashTimestamps.push(Date.now());
226
+ logToFile(`Server crashed (code: ${code}, signal: ${signal}) — restart #${restartCount}`);
227
+
228
+ // Check for crash loop
229
+ if (isInCrashLoop()) {
230
+ logToFile(`CRASH LOOP DETECTED: ${MAX_CRASHES_IN_WINDOW} crashes in ${CRASH_WINDOW_MS / 1000}s — stopping watchdog`);
231
+ cleanup();
232
+ process.exit(1);
233
+ return;
234
+ }
235
+
236
+ // Schedule restart with backoff
237
+ logToFile(`Restarting in ${backoffMs}ms...`);
238
+ writeState();
239
+
240
+ setTimeout(() => {
241
+ // Double backoff for next time (capped)
242
+ backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
243
+ spawnServer();
244
+ }, backoffMs);
245
+ });
246
+
247
+ serverProcess.on('error', (err) => {
248
+ clearTimeout(stableTimer);
249
+ logToFile(`Server process error: ${err.message}`);
250
+ serverProcess = null;
251
+
252
+ if (shuttingDown) return;
253
+
254
+ restartCount++;
255
+ crashTimestamps.push(Date.now());
256
+
257
+ if (isInCrashLoop()) {
258
+ logToFile(`CRASH LOOP DETECTED — stopping watchdog`);
259
+ cleanup();
260
+ process.exit(1);
261
+ return;
262
+ }
263
+
264
+ setTimeout(() => {
265
+ backoffMs = Math.min(backoffMs * 2, MAX_BACKOFF_MS);
266
+ spawnServer();
267
+ }, backoffMs);
268
+ });
269
+ }
270
+
271
+ /**
272
+ * Graceful shutdown handler
273
+ */
274
+ function gracefulShutdown(signal) {
275
+ if (shuttingDown) return;
276
+ shuttingDown = true;
277
+
278
+ logToFile(`Watchdog received ${signal} — shutting down`);
279
+
280
+ if (serverProcess) {
281
+ killServer();
282
+
283
+ // Give server 10 seconds to exit gracefully
284
+ const forceKillTimer = setTimeout(() => {
285
+ logToFile('Force killing server after timeout');
286
+ try {
287
+ serverProcess?.kill('SIGKILL');
288
+ } catch {}
289
+ cleanup();
290
+ process.exit(0);
291
+ }, 10000);
292
+ forceKillTimer.unref();
293
+
294
+ // Wait for server to exit
295
+ serverProcess.on('exit', () => {
296
+ clearTimeout(forceKillTimer);
297
+ cleanup();
298
+ process.exit(0);
299
+ });
300
+ } else {
301
+ cleanup();
302
+ process.exit(0);
303
+ }
304
+ }
305
+
306
+ // Register signal handlers
307
+ process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
308
+ process.on('SIGINT', () => gracefulShutdown('SIGINT'));
309
+
310
+ // Windows: handle Ctrl+C
311
+ if (process.platform === 'win32') {
312
+ process.on('SIGHUP', () => gracefulShutdown('SIGHUP'));
313
+ }
314
+
315
+ // Write PID file and start
316
+ writePidFile();
317
+ logToFile(`Watchdog started (PID: ${process.pid})`);
318
+ spawnServer();
@@ -0,0 +1,359 @@
1
+ /**
2
+ * WebSocket Broadcast Module
3
+ * Broadcasting utilities for all clients, sync, and OpenClaw push
4
+ */
5
+
6
+ import { WebSocket } from 'ws';
7
+ import { log } from '../utils.js';
8
+ import { WEBSOCKET } from '../config.js';
9
+ import { broadcastToProxyClients } from '../gateway-proxy.js';
10
+ import { wsClients, safeSend } from './connections.js';
11
+
12
+ // ============================================
13
+ // Broadcast Circuit Breaker
14
+ // ============================================
15
+
16
+ const broadcastCircuitBreaker = {
17
+ failures: 0,
18
+ lastFailure: 0,
19
+ isOpen: false,
20
+ threshold: WEBSOCKET.broadcastCircuitBreaker.threshold,
21
+ resetTimeMs: WEBSOCKET.broadcastCircuitBreaker.resetTimeMs,
22
+ };
23
+
24
+ function checkCircuitBreaker() {
25
+ if (!broadcastCircuitBreaker.isOpen) return true;
26
+
27
+ // Check if reset time has passed
28
+ if (Date.now() - broadcastCircuitBreaker.lastFailure > broadcastCircuitBreaker.resetTimeMs) {
29
+ broadcastCircuitBreaker.isOpen = false;
30
+ broadcastCircuitBreaker.failures = 0;
31
+ log('info', '[WS] Broadcast circuit breaker reset');
32
+ return true;
33
+ }
34
+
35
+ return false;
36
+ }
37
+
38
+ function recordBroadcastFailure() {
39
+ broadcastCircuitBreaker.failures++;
40
+ broadcastCircuitBreaker.lastFailure = Date.now();
41
+
42
+ if (broadcastCircuitBreaker.failures >= broadcastCircuitBreaker.threshold) {
43
+ broadcastCircuitBreaker.isOpen = true;
44
+ log('warn', `[WS] Broadcast circuit breaker OPEN after ${broadcastCircuitBreaker.failures} failures`);
45
+ }
46
+ }
47
+
48
+ // ============================================
49
+ // Message ID Generation
50
+ // ============================================
51
+
52
+ /**
53
+ * Generate a unique message ID for deduplication
54
+ * Format: msg_<timestamp>_<random>
55
+ */
56
+ export function generateMessageId() {
57
+ const timestamp = Date.now();
58
+ const random = Math.random().toString(36).substring(2, 10);
59
+ return `msg_${timestamp}_${random}`;
60
+ }
61
+
62
+ // ============================================
63
+ // Deduplication
64
+ // ============================================
65
+
66
+ // Deduplication cache for recently broadcast messages (prevents sync + push duplicates)
67
+ const recentBroadcasts = new Map(); // hash -> timestamp
68
+ const DEDUP_WINDOW_MS = WEBSOCKET.dedupWindowMs;
69
+ const MAX_RECENT_BROADCASTS = WEBSOCKET.maxRecentBroadcasts;
70
+
71
+ function getMessageHash(content, satelliteId) {
72
+ // DJB2 hash of full content string for reliable deduplication
73
+ const str = `${satelliteId}:${content || ''}`;
74
+ let hash = 5381;
75
+ for (let i = 0; i < str.length; i++) {
76
+ hash = ((hash << 5) + hash + str.charCodeAt(i)) | 0;
77
+ }
78
+ return hash.toString(36);
79
+ }
80
+
81
+ function cleanupRecentBroadcasts() {
82
+ const now = Date.now();
83
+ for (const [hash, timestamp] of recentBroadcasts) {
84
+ if (now - timestamp > DEDUP_WINDOW_MS) {
85
+ recentBroadcasts.delete(hash);
86
+ }
87
+ }
88
+ // Hard cap to prevent unbounded growth if cleanup can't keep up
89
+ if (recentBroadcasts.size > MAX_RECENT_BROADCASTS) {
90
+ const entries = Array.from(recentBroadcasts.entries());
91
+ entries.sort((a, b) => a[1] - b[1]);
92
+ const toRemove = recentBroadcasts.size - MAX_RECENT_BROADCASTS;
93
+ for (let i = 0; i < toRemove; i++) {
94
+ recentBroadcasts.delete(entries[i][0]);
95
+ }
96
+ }
97
+ }
98
+
99
+ // Track a broadcast for deduplication
100
+ export function trackBroadcast(content, satelliteId) {
101
+ cleanupRecentBroadcasts();
102
+ const hash = getMessageHash(content, satelliteId);
103
+ recentBroadcasts.set(hash, Date.now());
104
+ }
105
+
106
+ // ============================================
107
+ // Core Broadcasting
108
+ // ============================================
109
+
110
+ /**
111
+ * Broadcast a message to all clients except one (optional exclusion)
112
+ */
113
+ export function broadcast(message, excludeClientId = null) {
114
+ // Check circuit breaker
115
+ if (!checkCircuitBreaker()) {
116
+ log('debug', '[WS] Broadcast skipped - circuit breaker open');
117
+ return { sent: 0, failed: 0, skipped: true };
118
+ }
119
+
120
+ const data = typeof message === 'string' ? message : JSON.stringify(message);
121
+
122
+ // Snapshot clients to avoid race condition during iteration
123
+ const clientsSnapshot = Array.from(wsClients.entries());
124
+
125
+ let sent = 0;
126
+ let failed = 0;
127
+
128
+ for (const [clientId, client] of clientsSnapshot) {
129
+ if (clientId === excludeClientId) continue;
130
+
131
+ try {
132
+ if (client.ws.readyState === WebSocket.OPEN) {
133
+ if (safeSend(client.ws, data)) {
134
+ sent++;
135
+ } else {
136
+ failed++;
137
+ recordBroadcastFailure();
138
+ }
139
+ }
140
+ } catch (err) {
141
+ failed++;
142
+ recordBroadcastFailure();
143
+ log('warn', `[WS] Broadcast failed for ${clientId}: ${err.message}`);
144
+ }
145
+ }
146
+
147
+ if (failed > 0) {
148
+ log('debug', `[WS] Broadcast: ${sent} sent, ${failed} failed`);
149
+ }
150
+
151
+ return { sent, failed, skipped: false };
152
+ }
153
+
154
+ /**
155
+ * Broadcast a message to ALL connected clients (no exclusion)
156
+ */
157
+ export function broadcastToAll(data) {
158
+ // Check circuit breaker
159
+ if (!checkCircuitBreaker()) {
160
+ log('debug', '[WS] BroadcastToAll skipped - circuit breaker open');
161
+ return { sent: 0, failed: 0, skipped: true };
162
+ }
163
+
164
+ const message = typeof data === 'string' ? data : JSON.stringify(data);
165
+
166
+ // Snapshot clients to avoid race condition during iteration
167
+ const clientsSnapshot = Array.from(wsClients.values());
168
+
169
+ let sent = 0;
170
+ let failed = 0;
171
+
172
+ for (const client of clientsSnapshot) {
173
+ try {
174
+ if (client.ws.readyState === WebSocket.OPEN) {
175
+ if (safeSend(client.ws, message)) {
176
+ sent++;
177
+ } else {
178
+ failed++;
179
+ recordBroadcastFailure();
180
+ }
181
+ }
182
+ } catch (err) {
183
+ failed++;
184
+ recordBroadcastFailure();
185
+ log('warn', `[WS] BroadcastToAll failed: ${err.message}`);
186
+ }
187
+ }
188
+
189
+ if (failed > 0) {
190
+ log('debug', `[WS] BroadcastToAll: ${sent} sent, ${failed} failed`);
191
+ }
192
+
193
+ return { sent, failed, skipped: false };
194
+ }
195
+
196
+ /**
197
+ * Send a message to a specific client by ID
198
+ */
199
+ export function sendToClient(clientId, message) {
200
+ const client = wsClients.get(clientId);
201
+ if (client && client.ws.readyState === WebSocket.OPEN) {
202
+ return safeSend(client.ws, message);
203
+ }
204
+ return false;
205
+ }
206
+
207
+ // ============================================
208
+ // Sync Broadcasting (Cross-Device Sync)
209
+ // ============================================
210
+
211
+ // Rate limiting for sync broadcasts
212
+ const syncBroadcastRateLimit = {
213
+ count: 0,
214
+ windowStart: Date.now(),
215
+ maxPerSecond: WEBSOCKET.syncBroadcastRateLimit.maxPerSecond,
216
+ };
217
+
218
+ /**
219
+ * Broadcast a sync message to all clients except the sender
220
+ * Used for real-time cross-device message synchronization
221
+ */
222
+ export function broadcastSyncMessage(role, content, satelliteId, messageId, excludeClientId = null, requestId = null) {
223
+ // Rate limiting: max N broadcasts per second
224
+ const now = Date.now();
225
+ if (now - syncBroadcastRateLimit.windowStart > 1000) {
226
+ syncBroadcastRateLimit.count = 0;
227
+ syncBroadcastRateLimit.windowStart = now;
228
+ }
229
+
230
+ if (syncBroadcastRateLimit.count >= syncBroadcastRateLimit.maxPerSecond) {
231
+ log('warn', '[WS] Sync broadcast rate limit exceeded');
232
+ return { sent: 0, failed: 0, rateLimited: true };
233
+ }
234
+
235
+ syncBroadcastRateLimit.count++;
236
+
237
+ // Track assistant messages for deduplication (prevents OpenClaw push duplicates)
238
+ if (role === 'assistant' && content) {
239
+ trackBroadcast(content, satelliteId || 'main');
240
+ }
241
+
242
+ const syncMessage = {
243
+ type: 'event',
244
+ event: 'sync',
245
+ payload: {
246
+ messageId: messageId || generateMessageId(),
247
+ role,
248
+ content,
249
+ satelliteId: satelliteId || 'main',
250
+ timestamp: now,
251
+ ...(requestId ? { requestId } : {}),
252
+ },
253
+ };
254
+
255
+ // Broadcast to direct WebSocket clients (/ws)
256
+ const directResult = broadcast(syncMessage, excludeClientId);
257
+
258
+ // Also broadcast to Gateway proxy clients (/ws/gateway) for mobile sync
259
+ const proxyResult = broadcastToProxyClients(syncMessage);
260
+
261
+ const totalSent = directResult.sent + proxyResult.sent;
262
+ const totalFailed = directResult.failed + proxyResult.failed;
263
+
264
+ log('debug', `[WS] Sync broadcast: ${role} message to ${totalSent} clients (direct: ${directResult.sent}, proxy: ${proxyResult.sent}, satellite: ${satelliteId})`);
265
+
266
+ return { sent: totalSent, failed: totalFailed, skipped: directResult.skipped };
267
+ }
268
+
269
+ /**
270
+ * Broadcast sync.thinking event to all clients except the sender
271
+ */
272
+ export function broadcastSyncThinking(requestId, satelliteId, excludeClientId = null) {
273
+ const message = {
274
+ type: 'event',
275
+ event: 'sync.thinking',
276
+ payload: { requestId, satelliteId: satelliteId || 'main' },
277
+ };
278
+ broadcast(message, excludeClientId);
279
+ broadcastToProxyClients(message);
280
+ }
281
+
282
+ /**
283
+ * Broadcast sync.tool event to all clients except the sender
284
+ */
285
+ export function broadcastSyncTool(requestId, tool, satelliteId, excludeClientId = null) {
286
+ const message = {
287
+ type: 'event',
288
+ event: 'sync.tool',
289
+ payload: { requestId, tool, satelliteId: satelliteId || 'main' },
290
+ };
291
+ broadcast(message, excludeClientId);
292
+ broadcastToProxyClients(message);
293
+ }
294
+
295
+ /**
296
+ * Broadcast sync.complete event with usage stats to all clients
297
+ * Sent when a response finishes so WS-streaming clients can update token display
298
+ */
299
+ export function broadcastSyncComplete(requestId, usage, satelliteId, excludeClientId = null) {
300
+ if (!usage) return;
301
+ const message = {
302
+ type: 'event',
303
+ event: 'sync.complete',
304
+ payload: { requestId, usage, satelliteId: satelliteId || 'main' },
305
+ };
306
+ broadcast(message, excludeClientId);
307
+ broadcastToProxyClients(message);
308
+ }
309
+
310
+ // ============================================
311
+ // OpenClaw Push Broadcasting
312
+ // ============================================
313
+
314
+ /**
315
+ * Broadcast OpenClaw push messages to all WebSocket clients
316
+ * Maps payload.type to prefixed WebSocket message type
317
+ */
318
+ export function broadcastOpenClawPush(payload) {
319
+ // Skip if this message was recently broadcast via sync (dedup)
320
+ if (payload.type === 'message' && payload.content) {
321
+ cleanupRecentBroadcasts();
322
+ const hash = getMessageHash(payload.content, payload.satelliteId);
323
+ if (recentBroadcasts.has(hash)) {
324
+ log('debug', `[WS] OpenClaw push: skipping duplicate for satellite ${payload.satelliteId}`);
325
+ return;
326
+ }
327
+ }
328
+
329
+ const payloadData = {
330
+ satelliteId: payload.satelliteId,
331
+ content: payload.content,
332
+ tool: payload.tool,
333
+ error: payload.error,
334
+ audioUrl: payload.audioUrl,
335
+ usage: payload.usage,
336
+ requestId: payload.requestId,
337
+ timestamp: payload.timestamp
338
+ };
339
+
340
+ // Remove undefined fields from payload
341
+ Object.keys(payloadData).forEach(key => {
342
+ if (payloadData[key] === undefined) delete payloadData[key];
343
+ });
344
+
345
+ const wsMessage = {
346
+ type: 'event',
347
+ event: `openclaw.${payload.type}`,
348
+ payload: payloadData,
349
+ };
350
+
351
+ // Broadcast to direct /ws clients
352
+ const directResult = broadcastToAll(wsMessage);
353
+
354
+ // Also broadcast to Gateway proxy clients (/ws/gateway)
355
+ const proxyResult = broadcastToProxyClients(wsMessage);
356
+
357
+ const hasSent = directResult.sent > 0 || proxyResult.sent > 0;
358
+ log('info', `[WS] OpenClaw push: ${payload.type} for satellite ${payload.satelliteId} (direct: ${directResult.sent}, proxy: ${proxyResult.sent})`);
359
+ }