@silicaclaw/cli 1.0.0-beta.3 → 1.0.0-beta.31

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 (81) hide show
  1. package/CHANGELOG.md +93 -0
  2. package/INSTALL.md +41 -4
  3. package/README.md +80 -6
  4. package/apps/local-console/public/index.html +1283 -251
  5. package/apps/local-console/src/server.ts +108 -31
  6. package/docs/CLOUDFLARE_RELAY.md +61 -0
  7. package/docs/NEW_USER_INSTALL.md +166 -0
  8. package/docs/NEW_USER_OPERATIONS.md +265 -0
  9. package/package.json +6 -1
  10. package/packages/core/dist/crypto.d.ts +6 -0
  11. package/packages/core/dist/crypto.js +50 -0
  12. package/packages/core/dist/directory.d.ts +17 -0
  13. package/packages/core/dist/directory.js +145 -0
  14. package/packages/core/dist/identity.d.ts +2 -0
  15. package/packages/core/dist/identity.js +18 -0
  16. package/packages/core/dist/index.d.ts +11 -0
  17. package/packages/core/dist/index.js +27 -0
  18. package/packages/core/dist/indexing.d.ts +6 -0
  19. package/packages/core/dist/indexing.js +43 -0
  20. package/packages/core/dist/presence.d.ts +4 -0
  21. package/packages/core/dist/presence.js +23 -0
  22. package/packages/core/dist/profile.d.ts +4 -0
  23. package/packages/core/dist/profile.js +39 -0
  24. package/packages/core/dist/publicProfileSummary.d.ts +70 -0
  25. package/packages/core/dist/publicProfileSummary.js +103 -0
  26. package/packages/core/dist/socialConfig.d.ts +99 -0
  27. package/packages/core/dist/socialConfig.js +288 -0
  28. package/packages/core/dist/socialResolver.d.ts +46 -0
  29. package/packages/core/dist/socialResolver.js +237 -0
  30. package/packages/core/dist/socialTemplate.d.ts +2 -0
  31. package/packages/core/dist/socialTemplate.js +88 -0
  32. package/packages/core/dist/types.d.ts +37 -0
  33. package/packages/core/dist/types.js +2 -0
  34. package/packages/core/src/socialConfig.ts +8 -7
  35. package/packages/core/src/socialResolver.ts +17 -5
  36. package/packages/network/dist/abstractions/messageEnvelope.d.ts +28 -0
  37. package/packages/network/dist/abstractions/messageEnvelope.js +36 -0
  38. package/packages/network/dist/abstractions/peerDiscovery.d.ts +43 -0
  39. package/packages/network/dist/abstractions/peerDiscovery.js +2 -0
  40. package/packages/network/dist/abstractions/topicCodec.d.ts +4 -0
  41. package/packages/network/dist/abstractions/topicCodec.js +2 -0
  42. package/packages/network/dist/abstractions/transport.d.ts +36 -0
  43. package/packages/network/dist/abstractions/transport.js +2 -0
  44. package/packages/network/dist/codec/jsonMessageEnvelopeCodec.d.ts +5 -0
  45. package/packages/network/dist/codec/jsonMessageEnvelopeCodec.js +24 -0
  46. package/packages/network/dist/codec/jsonTopicCodec.d.ts +5 -0
  47. package/packages/network/dist/codec/jsonTopicCodec.js +12 -0
  48. package/packages/network/dist/discovery/heartbeatPeerDiscovery.d.ts +28 -0
  49. package/packages/network/dist/discovery/heartbeatPeerDiscovery.js +144 -0
  50. package/packages/network/dist/index.d.ts +14 -0
  51. package/packages/network/dist/index.js +30 -0
  52. package/packages/network/dist/localEventBus.d.ts +9 -0
  53. package/packages/network/dist/localEventBus.js +47 -0
  54. package/packages/network/dist/mock.d.ts +8 -0
  55. package/packages/network/dist/mock.js +24 -0
  56. package/packages/network/dist/realPreview.d.ts +105 -0
  57. package/packages/network/dist/realPreview.js +327 -0
  58. package/packages/network/dist/relayPreview.d.ts +166 -0
  59. package/packages/network/dist/relayPreview.js +430 -0
  60. package/packages/network/dist/transport/udpLanBroadcastTransport.d.ts +23 -0
  61. package/packages/network/dist/transport/udpLanBroadcastTransport.js +153 -0
  62. package/packages/network/dist/types.d.ts +6 -0
  63. package/packages/network/dist/types.js +2 -0
  64. package/packages/network/dist/webrtcPreview.d.ts +163 -0
  65. package/packages/network/dist/webrtcPreview.js +844 -0
  66. package/packages/network/src/index.ts +1 -0
  67. package/packages/network/src/relayPreview.ts +552 -0
  68. package/packages/storage/dist/index.d.ts +3 -0
  69. package/packages/storage/dist/index.js +19 -0
  70. package/packages/storage/dist/jsonRepo.d.ts +7 -0
  71. package/packages/storage/dist/jsonRepo.js +29 -0
  72. package/packages/storage/dist/repos.d.ts +21 -0
  73. package/packages/storage/dist/repos.js +41 -0
  74. package/packages/storage/dist/socialRuntimeRepo.d.ts +5 -0
  75. package/packages/storage/dist/socialRuntimeRepo.js +52 -0
  76. package/packages/storage/src/socialRuntimeRepo.ts +4 -4
  77. package/packages/storage/tsconfig.json +6 -1
  78. package/scripts/quickstart.sh +314 -36
  79. package/scripts/silicaclaw-cli.mjs +458 -24
  80. package/scripts/silicaclaw-gateway.mjs +467 -0
  81. package/scripts/webrtc-signaling-server.mjs +89 -5
@@ -0,0 +1,467 @@
1
+ #!/usr/bin/env node
2
+
3
+ import { spawn, spawnSync } from "node:child_process";
4
+ import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
+ import { homedir } from "node:os";
6
+ import { dirname, join, resolve } from "node:path";
7
+ import { fileURLToPath } from "node:url";
8
+
9
+ const __filename = fileURLToPath(import.meta.url);
10
+ const __dirname = dirname(__filename);
11
+ const ROOT_DIR = resolve(__dirname, "..");
12
+
13
+ const argv = process.argv.slice(2);
14
+ const cmd = String(argv[0] || "help").toLowerCase();
15
+
16
+ const COLOR = {
17
+ reset: "\x1b[0m",
18
+ bold: "\x1b[1m",
19
+ dim: "\x1b[2m",
20
+ orange: "\x1b[38;5;208m",
21
+ green: "\x1b[32m",
22
+ yellow: "\x1b[33m",
23
+ red: "\x1b[31m",
24
+ };
25
+
26
+ function useColor() {
27
+ return Boolean(process.stdout.isTTY && !process.env.NO_COLOR);
28
+ }
29
+
30
+ function paint(text, ...styles) {
31
+ if (!useColor() || styles.length === 0) return text;
32
+ return `${styles.join("")}${text}${COLOR.reset}`;
33
+ }
34
+
35
+ function headline() {
36
+ const pkgPath = join(ROOT_DIR, "package.json");
37
+ let version = "unknown";
38
+ if (existsSync(pkgPath)) {
39
+ try {
40
+ const pkg = JSON.parse(String(readFileSync(pkgPath, "utf8")));
41
+ version = String(pkg.version || "unknown");
42
+ } catch {
43
+ version = "unknown";
44
+ }
45
+ } else if (existsSync(join(ROOT_DIR, "VERSION"))) {
46
+ version = String(readFileSync(join(ROOT_DIR, "VERSION"), "utf8")).trim() || "unknown";
47
+ }
48
+ console.log(`${paint("SilicaClaw", COLOR.bold, COLOR.orange)} ${paint(version, COLOR.dim)}`);
49
+ console.log(paint("Public identity and discovery for OpenClaw agents.", COLOR.dim));
50
+ }
51
+
52
+ function kv(label, value) {
53
+ console.log(`${paint(label.padEnd(14), COLOR.dim)} ${value}`);
54
+ }
55
+
56
+ function section(title) {
57
+ console.log(paint(title, COLOR.bold));
58
+ }
59
+
60
+ function readJson(file) {
61
+ try {
62
+ return JSON.parse(String(readFileSync(file, "utf8")));
63
+ } catch {
64
+ return null;
65
+ }
66
+ }
67
+
68
+ function isSilicaClawDir(dir) {
69
+ const pkgPath = join(dir, "package.json");
70
+ if (!existsSync(pkgPath)) return false;
71
+ const pkg = readJson(pkgPath);
72
+ if (!pkg || typeof pkg !== "object") return false;
73
+ const name = String(pkg.name || "");
74
+ if (name === "@silicaclaw/cli" || name === "silicaclaw") return true;
75
+ const scripts = pkg.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {};
76
+ return Boolean(scripts.gateway || scripts["local-console"] || scripts["public-explorer"]);
77
+ }
78
+
79
+ function parseFlag(name, fallback = "") {
80
+ const prefix = `--${name}=`;
81
+ for (const item of argv) {
82
+ if (item.startsWith(prefix)) return item.slice(prefix.length);
83
+ }
84
+ return fallback;
85
+ }
86
+
87
+ function hasFlag(name) {
88
+ return argv.includes(`--${name}`);
89
+ }
90
+
91
+ function detectAppDir() {
92
+ const envDir = process.env.SILICACLAW_APP_DIR;
93
+ if (envDir && isSilicaClawDir(envDir)) {
94
+ return resolve(envDir);
95
+ }
96
+
97
+ const cwd = process.cwd();
98
+ if (isSilicaClawDir(cwd)) {
99
+ return resolve(cwd);
100
+ }
101
+
102
+ const homeCandidate = join(homedir(), "silicaclaw");
103
+ if (isSilicaClawDir(homeCandidate)) {
104
+ return resolve(homeCandidate);
105
+ }
106
+
107
+ return ROOT_DIR;
108
+ }
109
+
110
+ const APP_DIR = detectAppDir();
111
+ const STATE_DIR = join(APP_DIR, ".silicaclaw", "gateway");
112
+ const CONSOLE_PID_FILE = join(STATE_DIR, "local-console.pid");
113
+ const CONSOLE_LOG_FILE = join(STATE_DIR, "local-console.log");
114
+ const SIGNALING_PID_FILE = join(STATE_DIR, "signaling.pid");
115
+ const SIGNALING_LOG_FILE = join(STATE_DIR, "signaling.log");
116
+ const STATE_FILE = join(STATE_DIR, "state.json");
117
+
118
+ function ensureStateDir() {
119
+ mkdirSync(STATE_DIR, { recursive: true });
120
+ }
121
+
122
+ function readPid(file) {
123
+ if (!existsSync(file)) return null;
124
+ const text = String(readFileSync(file, "utf8")).trim();
125
+ const pid = Number(text);
126
+ return Number.isFinite(pid) && pid > 0 ? pid : null;
127
+ }
128
+
129
+ function isRunning(pid) {
130
+ if (!pid) return false;
131
+ try {
132
+ process.kill(pid, 0);
133
+ return true;
134
+ } catch {
135
+ return false;
136
+ }
137
+ }
138
+
139
+ function removeFileIfExists(path) {
140
+ if (existsSync(path)) rmSync(path, { force: true });
141
+ }
142
+
143
+ async function stopPid(pid, name) {
144
+ if (!pid || !isRunning(pid)) return;
145
+ try {
146
+ process.kill(pid, "SIGTERM");
147
+ } catch {
148
+ return;
149
+ }
150
+ const start = Date.now();
151
+ while (Date.now() - start < 5000) {
152
+ if (!isRunning(pid)) return;
153
+ await new Promise((r) => setTimeout(r, 200));
154
+ }
155
+ if (isRunning(pid)) {
156
+ try {
157
+ process.kill(pid, "SIGKILL");
158
+ } catch {
159
+ // ignore
160
+ }
161
+ }
162
+ if (isRunning(pid)) {
163
+ console.error(`${paint("Failed to stop", COLOR.bold, COLOR.red)} ${name} (pid=${pid})`);
164
+ }
165
+ }
166
+
167
+ function parseMode(raw) {
168
+ const mode = String(raw || "local").trim().toLowerCase();
169
+ if (mode === "lan" || mode === "global-preview" || mode === "local") return mode;
170
+ return "local";
171
+ }
172
+
173
+ function adapterForMode(mode) {
174
+ if (mode === "lan") return "real-preview";
175
+ if (mode === "global-preview") return "relay-preview";
176
+ return "local-event-bus";
177
+ }
178
+
179
+ function parseUrlHostPort(url) {
180
+ try {
181
+ const u = new URL(url);
182
+ return {
183
+ host: u.hostname || "",
184
+ port: Number(u.port || 4510),
185
+ };
186
+ } catch {
187
+ return { host: "", port: 4510 };
188
+ }
189
+ }
190
+
191
+ function spawnBackground(command, args, env, logFile, pidFile) {
192
+ const outFd = openSync(logFile, "a");
193
+ const child = spawn(command, args, {
194
+ cwd: APP_DIR,
195
+ env: { ...process.env, ...env },
196
+ detached: true,
197
+ stdio: ["ignore", outFd, outFd],
198
+ });
199
+ child.unref();
200
+ writeFileSync(pidFile, String(child.pid));
201
+ return child.pid;
202
+ }
203
+
204
+ function writeState(state) {
205
+ ensureStateDir();
206
+ writeFileSync(STATE_FILE, JSON.stringify(state, null, 2));
207
+ }
208
+
209
+ function readState() {
210
+ if (!existsSync(STATE_FILE)) return null;
211
+ try {
212
+ return JSON.parse(readFileSync(STATE_FILE, "utf8"));
213
+ } catch {
214
+ return null;
215
+ }
216
+ }
217
+
218
+ function printHelp() {
219
+ headline();
220
+ console.log("");
221
+ section("Gateway");
222
+ kv("Start", "silicaclaw gateway start");
223
+ kv("Stop", "silicaclaw gateway stop");
224
+ kv("Restart", "silicaclaw gateway restart");
225
+ kv("Status", "silicaclaw gateway status");
226
+ kv("Logs", "silicaclaw gateway logs local-console");
227
+ }
228
+
229
+ function buildStatusPayload() {
230
+ const localPid = readPid(CONSOLE_PID_FILE);
231
+ const sigPid = readPid(SIGNALING_PID_FILE);
232
+ const state = readState();
233
+ return {
234
+ app_dir: APP_DIR,
235
+ mode: state?.mode || "unknown",
236
+ adapter: state?.adapter || "unknown",
237
+ local_console: {
238
+ pid: localPid,
239
+ running: isRunning(localPid),
240
+ log_file: CONSOLE_LOG_FILE,
241
+ },
242
+ signaling: {
243
+ pid: sigPid,
244
+ running: isRunning(sigPid),
245
+ log_file: SIGNALING_LOG_FILE,
246
+ url: state?.signaling_url || null,
247
+ room: state?.room || null,
248
+ },
249
+ updated_at: state?.updated_at || null,
250
+ };
251
+ }
252
+
253
+ function showStatusJson() {
254
+ console.log(JSON.stringify(buildStatusPayload(), null, 2));
255
+ }
256
+
257
+ function showStatusHuman() {
258
+ const payload = buildStatusPayload();
259
+ headline();
260
+ console.log("");
261
+ kv(
262
+ "Status",
263
+ payload.local_console.running
264
+ ? `${paint("running", COLOR.green)} · ${payload.mode}`
265
+ : `${paint("stopped", COLOR.dim)} · ${payload.mode}`
266
+ );
267
+ if (payload.mode === "global-preview") {
268
+ kv("Relay", payload.signaling.url || "https://relay.silicaclaw.com");
269
+ kv("Room", payload.signaling.room || "silicaclaw-global-preview");
270
+ }
271
+ if (payload.local_console.running) kv("Open", "http://localhost:4310");
272
+ return payload;
273
+ }
274
+
275
+ function printConnectionSummary(status, verb = "Started") {
276
+ if (!status?.local_console?.running) return;
277
+ headline();
278
+ console.log("");
279
+ kv("Status", `${verb.toLowerCase()} · ${status.mode}`);
280
+ kv("Console", `http://localhost:4310`);
281
+ if (status.mode === "global-preview") {
282
+ const signalingUrl = status?.signaling?.url || "https://relay.silicaclaw.com";
283
+ const room = status?.signaling?.room || "silicaclaw-global-preview";
284
+ kv("Relay", signalingUrl);
285
+ kv("Room", room);
286
+ }
287
+ }
288
+
289
+ function listeningProcessOnPort(port) {
290
+ try {
291
+ const pidRes = spawnSync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], {
292
+ encoding: "utf8",
293
+ stdio: ["ignore", "pipe", "ignore"],
294
+ });
295
+ const pid = String(pidRes.stdout || "")
296
+ .split(/\r?\n/)
297
+ .map((s) => s.trim())
298
+ .find(Boolean);
299
+ if (!pid) return null;
300
+
301
+ const cmdRes = spawnSync("ps", ["-p", pid, "-o", "command="], {
302
+ encoding: "utf8",
303
+ stdio: ["ignore", "pipe", "ignore"],
304
+ });
305
+ const command = String(cmdRes.stdout || "").trim() || "unknown";
306
+ return { pid, command };
307
+ } catch {
308
+ return null;
309
+ }
310
+ }
311
+
312
+ function printStopSummary() {
313
+ const localListener = listeningProcessOnPort(4310);
314
+ const signalingListener = listeningProcessOnPort(4510);
315
+ headline();
316
+ console.log("");
317
+ kv("Status", "stopped");
318
+ if (!localListener) {
319
+ kv("Console", paint("stopped", COLOR.green));
320
+ } else {
321
+ kv("Console", `${paint("still busy", COLOR.yellow)} (pid=${localListener.pid})`);
322
+ kv("Inspect", "lsof -nP -iTCP:4310 -sTCP:LISTEN");
323
+ kv("Stop pid", `kill ${localListener.pid}`);
324
+ }
325
+ if (!signalingListener) {
326
+ kv("Relay port", paint("stopped", COLOR.green));
327
+ } else {
328
+ kv("Relay port", `${paint("still busy", COLOR.yellow)} (pid=${signalingListener.pid})`);
329
+ kv("Inspect", "lsof -nP -iTCP:4510 -sTCP:LISTEN");
330
+ kv("Stop pid", `kill ${signalingListener.pid}`);
331
+ }
332
+ }
333
+
334
+ function tailFile(file, lines = 80) {
335
+ if (!existsSync(file)) {
336
+ headline();
337
+ console.log("");
338
+ kv("Logs", "not found");
339
+ return;
340
+ }
341
+ headline();
342
+ console.log("");
343
+ kv("Logs", file);
344
+ console.log("");
345
+ const text = String(readFileSync(file, "utf8"));
346
+ const out = text.split(/\r?\n/).slice(-lines).join("\n");
347
+ console.log(out);
348
+ }
349
+
350
+ async function stopAll() {
351
+ const localPid = readPid(CONSOLE_PID_FILE);
352
+ const sigPid = readPid(SIGNALING_PID_FILE);
353
+ await stopPid(localPid, "local-console");
354
+ await stopPid(sigPid, "signaling");
355
+ removeFileIfExists(CONSOLE_PID_FILE);
356
+ removeFileIfExists(SIGNALING_PID_FILE);
357
+ writeState({
358
+ ...(readState() || {}),
359
+ updated_at: Date.now(),
360
+ });
361
+ }
362
+
363
+ function startAll() {
364
+ ensureStateDir();
365
+
366
+ const mode = parseMode(parseFlag("mode", process.env.NETWORK_MODE || "global-preview"));
367
+ const adapter = adapterForMode(mode);
368
+ const signalingUrl = parseFlag("signaling-url", process.env.WEBRTC_SIGNALING_URL || "https://relay.silicaclaw.com");
369
+ const room = parseFlag("room", process.env.WEBRTC_ROOM || "silicaclaw-global-preview");
370
+ const shouldDisableSignaling = hasFlag("no-signaling");
371
+
372
+ const currentLocalPid = readPid(CONSOLE_PID_FILE);
373
+ const currentSigPid = readPid(SIGNALING_PID_FILE);
374
+ let localPid = currentLocalPid;
375
+ if (!isRunning(currentLocalPid)) {
376
+ removeFileIfExists(CONSOLE_PID_FILE);
377
+ const env = {
378
+ NETWORK_ADAPTER: adapter,
379
+ NETWORK_MODE: mode,
380
+ WEBRTC_SIGNALING_URL: signalingUrl,
381
+ WEBRTC_ROOM: room,
382
+ };
383
+ localPid = spawnBackground("npm", ["run", "local-console"], env, CONSOLE_LOG_FILE, CONSOLE_PID_FILE);
384
+ }
385
+
386
+ const { host, port } = parseUrlHostPort(signalingUrl);
387
+ const shouldAutoStartSignaling =
388
+ mode === "global-preview" &&
389
+ !shouldDisableSignaling &&
390
+ (host === "localhost" || host === "127.0.0.1");
391
+
392
+ let signalingPid = currentSigPid;
393
+ if (shouldAutoStartSignaling) {
394
+ if (!isRunning(currentSigPid)) {
395
+ removeFileIfExists(SIGNALING_PID_FILE);
396
+ signalingPid = spawnBackground(
397
+ "npm",
398
+ ["run", "webrtc-signaling"],
399
+ { PORT: String(port) },
400
+ SIGNALING_LOG_FILE,
401
+ SIGNALING_PID_FILE,
402
+ );
403
+ }
404
+ }
405
+
406
+ writeState({
407
+ app_dir: APP_DIR,
408
+ mode,
409
+ adapter,
410
+ signaling_url: signalingUrl,
411
+ room,
412
+ updated_at: Date.now(),
413
+ });
414
+ return { localPid, signalingPid: shouldAutoStartSignaling ? signalingPid : null };
415
+ }
416
+
417
+ async function main() {
418
+ if (cmd === "help" || cmd === "-h" || cmd === "--help") {
419
+ printHelp();
420
+ return;
421
+ }
422
+ if (cmd === "status") {
423
+ if (hasFlag("json")) {
424
+ showStatusJson();
425
+ } else {
426
+ showStatusHuman();
427
+ }
428
+ return;
429
+ }
430
+ if (cmd === "start") {
431
+ startAll();
432
+ const status = buildStatusPayload();
433
+ printConnectionSummary(status, "Started");
434
+ return;
435
+ }
436
+ if (cmd === "stop") {
437
+ await stopAll();
438
+ printStopSummary();
439
+ return;
440
+ }
441
+ if (cmd === "restart") {
442
+ await stopAll();
443
+ startAll();
444
+ const status = buildStatusPayload();
445
+ printConnectionSummary(status, "Restarted");
446
+ return;
447
+ }
448
+ if (cmd === "logs") {
449
+ const target = String(argv[1] || "local-console");
450
+ if (target === "signaling") {
451
+ tailFile(SIGNALING_LOG_FILE);
452
+ return;
453
+ }
454
+ tailFile(CONSOLE_LOG_FILE);
455
+ return;
456
+ }
457
+ printHelp();
458
+ process.exitCode = 1;
459
+ }
460
+
461
+ main().catch((error) => {
462
+ headline();
463
+ console.log("");
464
+ console.error(paint("Gateway command failed", COLOR.bold, COLOR.red));
465
+ console.error(error?.message || String(error));
466
+ process.exit(1);
467
+ });
@@ -5,8 +5,9 @@ import { randomUUID, createHash } from 'crypto';
5
5
  const port = Number(process.env.PORT || process.env.WEBRTC_SIGNALING_PORT || 4510);
6
6
  const PEER_STALE_MS = Number(process.env.WEBRTC_SIGNALING_PEER_STALE_MS || 120000);
7
7
  const SIGNAL_DEDUPE_WINDOW_MS = Number(process.env.WEBRTC_SIGNALING_DEDUPE_WINDOW_MS || 60000);
8
+ const TOUCH_WRITE_INTERVAL_MS = Number(process.env.WEBRTC_SIGNALING_TOUCH_WRITE_INTERVAL_MS || 30000);
8
9
 
9
- /** @type {Map<string, {peers: Map<string, {last_seen_at:number}>, queues: Map<string, any[]>, signal_fingerprints: Map<string, number>}>} */
10
+ /** @type {Map<string, {peers: Map<string, {last_seen_at:number}>, queues: Map<string, any[]>, relay_queues: Map<string, any[]>, signal_fingerprints: Map<string, number>}>} */
10
11
  const rooms = new Map();
11
12
 
12
13
  const counters = {
@@ -24,6 +25,7 @@ function getRoom(roomId) {
24
25
  rooms.set(id, {
25
26
  peers: new Map(),
26
27
  queues: new Map(),
28
+ relay_queues: new Map(),
27
29
  signal_fingerprints: new Map(),
28
30
  });
29
31
  }
@@ -73,6 +75,7 @@ function cleanupRoom(roomId) {
73
75
  if (peer.last_seen_at < threshold) {
74
76
  room.peers.delete(peerId);
75
77
  room.queues.delete(peerId);
78
+ room.relay_queues.delete(peerId);
76
79
  counters.stale_peers_cleaned_total += 1;
77
80
  }
78
81
  }
@@ -88,14 +91,21 @@ function cleanupRoom(roomId) {
88
91
  }
89
92
 
90
93
  function touchPeer(room, peerId) {
94
+ const ts = now();
95
+ const previous = room.peers.get(peerId)?.last_seen_at || 0;
96
+ const shouldWrite = !previous || ts - previous >= TOUCH_WRITE_INTERVAL_MS;
91
97
  if (!room.peers.has(peerId)) {
92
- room.peers.set(peerId, { last_seen_at: now() });
98
+ room.peers.set(peerId, { last_seen_at: ts });
93
99
  } else {
94
- room.peers.get(peerId).last_seen_at = now();
100
+ room.peers.get(peerId).last_seen_at = shouldWrite ? ts : previous;
95
101
  }
96
102
  if (!room.queues.has(peerId)) {
97
103
  room.queues.set(peerId, []);
98
104
  }
105
+ if (!room.relay_queues.has(peerId)) {
106
+ room.relay_queues.set(peerId, []);
107
+ }
108
+ return shouldWrite;
99
109
  }
100
110
 
101
111
  function isValidSignalPayload(body) {
@@ -148,7 +158,35 @@ const server = http.createServer(async (req, res) => {
148
158
  const roomId = String(url.searchParams.get('room') || 'silicaclaw-room');
149
159
  const room = getRoom(roomId);
150
160
  cleanupRoom(roomId);
151
- return json(res, 200, { ok: true, peers: Array.from(room.peers.keys()) });
161
+ return json(res, 200, {
162
+ ok: true,
163
+ room: roomId,
164
+ peer_count: room.peers.size,
165
+ peers: Array.from(room.peers.keys()),
166
+ peer_details: Array.from(room.peers.entries()).map(([peerId, peer]) => ({
167
+ peer_id: peerId,
168
+ last_seen_at: peer.last_seen_at,
169
+ signal_queue_size: (room.queues.get(peerId) || []).length,
170
+ relay_queue_size: (room.relay_queues.get(peerId) || []).length,
171
+ })),
172
+ });
173
+ }
174
+
175
+ if (req.method === 'GET' && url.pathname === '/room') {
176
+ const roomId = String(url.searchParams.get('room') || 'silicaclaw-room');
177
+ const room = getRoom(roomId);
178
+ cleanupRoom(roomId);
179
+ return json(res, 200, {
180
+ ok: true,
181
+ room: roomId,
182
+ peer_count: room.peers.size,
183
+ peers: Array.from(room.peers.entries()).map(([peerId, peer]) => ({
184
+ peer_id: peerId,
185
+ last_seen_at: peer.last_seen_at,
186
+ signal_queue_size: (room.queues.get(peerId) || []).length,
187
+ relay_queue_size: (room.relay_queues.get(peerId) || []).length,
188
+ })),
189
+ });
152
190
  }
153
191
 
154
192
  if (req.method === 'GET' && url.pathname === '/poll') {
@@ -165,7 +203,24 @@ const server = http.createServer(async (req, res) => {
165
203
 
166
204
  const queue = room.queues.get(peerId) || [];
167
205
  room.queues.set(peerId, []);
168
- return json(res, 200, { ok: true, messages: queue });
206
+ return json(res, 200, { ok: true, messages: queue, peers: Array.from(room.peers.keys()) });
207
+ }
208
+
209
+ if (req.method === 'GET' && url.pathname === '/relay/poll') {
210
+ const roomId = String(url.searchParams.get('room') || 'silicaclaw-room');
211
+ const peerId = String(url.searchParams.get('peer_id') || '');
212
+ if (!peerId) {
213
+ counters.invalid_payload_total += 1;
214
+ return json(res, 400, { ok: false, error: 'missing_peer_id' });
215
+ }
216
+
217
+ const room = getRoom(roomId);
218
+ touchPeer(room, peerId);
219
+ cleanupRoom(roomId);
220
+
221
+ const queue = room.relay_queues.get(peerId) || [];
222
+ room.relay_queues.set(peerId, []);
223
+ return json(res, 200, { ok: true, messages: queue, peers: Array.from(room.peers.keys()) });
169
224
  }
170
225
 
171
226
  if (req.method === 'POST' && url.pathname === '/join') {
@@ -193,6 +248,7 @@ const server = http.createServer(async (req, res) => {
193
248
  if (peerId) {
194
249
  room.peers.delete(peerId);
195
250
  room.queues.delete(peerId);
251
+ room.relay_queues.delete(peerId);
196
252
  counters.leave_total += 1;
197
253
  } else {
198
254
  counters.invalid_payload_total += 1;
@@ -240,6 +296,34 @@ const server = http.createServer(async (req, res) => {
240
296
  return json(res, 200, { ok: true });
241
297
  }
242
298
 
299
+ if (req.method === 'POST' && url.pathname === '/relay/publish') {
300
+ const body = await parseBody(req);
301
+ const roomId = String(body.room || 'silicaclaw-room');
302
+ const peerId = String(body.peer_id || '');
303
+ const envelope = body.envelope;
304
+ if (!peerId || typeof envelope !== 'object' || envelope === null) {
305
+ counters.invalid_payload_total += 1;
306
+ return json(res, 400, { ok: false, error: 'invalid_relay_payload' });
307
+ }
308
+
309
+ const room = getRoom(roomId);
310
+ touchPeer(room, peerId);
311
+
312
+ for (const targetPeerId of room.peers.keys()) {
313
+ if (targetPeerId === peerId) continue;
314
+ if (!room.relay_queues.has(targetPeerId)) room.relay_queues.set(targetPeerId, []);
315
+ room.relay_queues.get(targetPeerId).push({
316
+ id: String(body.id || randomUUID()),
317
+ room: roomId,
318
+ from_peer_id: peerId,
319
+ envelope,
320
+ at: now(),
321
+ });
322
+ }
323
+
324
+ return json(res, 200, { ok: true, delivered_to: Math.max(0, room.peers.size - 1) });
325
+ }
326
+
243
327
  return json(res, 404, { ok: false, error: 'not_found' });
244
328
  });
245
329