@silicaclaw/cli 1.0.0-beta.9 → 2026.3.18-3

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.
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { spawn } from "node:child_process";
3
+ import { spawn, spawnSync } from "node:child_process";
4
4
  import { existsSync, mkdirSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
5
5
  import { homedir } from "node:os";
6
6
  import { dirname, join, resolve } from "node:path";
@@ -13,6 +13,72 @@ const ROOT_DIR = resolve(__dirname, "..");
13
13
  const argv = process.argv.slice(2);
14
14
  const cmd = String(argv[0] || "help").toLowerCase();
15
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 displayVersion(raw) {
36
+ const text = String(raw || "unknown").trim() || "unknown";
37
+ return text.startsWith("v") ? text : `v${text}`;
38
+ }
39
+
40
+ function headline() {
41
+ const pkgPath = join(ROOT_DIR, "package.json");
42
+ let version = "unknown";
43
+ if (existsSync(pkgPath)) {
44
+ try {
45
+ const pkg = JSON.parse(String(readFileSync(pkgPath, "utf8")));
46
+ version = String(pkg.version || "unknown");
47
+ } catch {
48
+ version = "unknown";
49
+ }
50
+ }
51
+ console.log(`${paint("SilicaClaw", COLOR.bold, COLOR.orange)} ${paint(displayVersion(version), COLOR.dim)}`);
52
+ console.log(paint("Public identity and discovery for OpenClaw agents.", COLOR.dim));
53
+ }
54
+
55
+ function kv(label, value) {
56
+ console.log(`${paint(label.padEnd(14), COLOR.dim)} ${value}`);
57
+ }
58
+
59
+ function section(title) {
60
+ console.log(paint(title, COLOR.bold));
61
+ }
62
+
63
+ function readJson(file) {
64
+ try {
65
+ return JSON.parse(String(readFileSync(file, "utf8")));
66
+ } catch {
67
+ return null;
68
+ }
69
+ }
70
+
71
+ function isSilicaClawDir(dir) {
72
+ const pkgPath = join(dir, "package.json");
73
+ if (!existsSync(pkgPath)) return false;
74
+ const pkg = readJson(pkgPath);
75
+ if (!pkg || typeof pkg !== "object") return false;
76
+ const name = String(pkg.name || "");
77
+ if (name === "@silicaclaw/cli" || name === "silicaclaw") return true;
78
+ const scripts = pkg.scripts && typeof pkg.scripts === "object" ? pkg.scripts : {};
79
+ return Boolean(scripts.gateway || scripts["local-console"] || scripts["public-explorer"]);
80
+ }
81
+
16
82
  function parseFlag(name, fallback = "") {
17
83
  const prefix = `--${name}=`;
18
84
  for (const item of argv) {
@@ -27,17 +93,17 @@ function hasFlag(name) {
27
93
 
28
94
  function detectAppDir() {
29
95
  const envDir = process.env.SILICACLAW_APP_DIR;
30
- if (envDir && existsSync(join(envDir, "package.json"))) {
96
+ if (envDir && isSilicaClawDir(envDir)) {
31
97
  return resolve(envDir);
32
98
  }
33
99
 
34
100
  const cwd = process.cwd();
35
- if (existsSync(join(cwd, "package.json"))) {
101
+ if (isSilicaClawDir(cwd)) {
36
102
  return resolve(cwd);
37
103
  }
38
104
 
39
105
  const homeCandidate = join(homedir(), "silicaclaw");
40
- if (existsSync(join(homeCandidate, "package.json"))) {
106
+ if (isSilicaClawDir(homeCandidate)) {
41
107
  return resolve(homeCandidate);
42
108
  }
43
109
 
@@ -97,7 +163,7 @@ async function stopPid(pid, name) {
97
163
  }
98
164
  }
99
165
  if (isRunning(pid)) {
100
- console.error(`failed to stop ${name} (pid=${pid})`);
166
+ console.error(`${paint("Failed to stop", COLOR.bold, COLOR.red)} ${name} (pid=${pid})`);
101
167
  }
102
168
  }
103
169
 
@@ -109,7 +175,7 @@ function parseMode(raw) {
109
175
 
110
176
  function adapterForMode(mode) {
111
177
  if (mode === "lan") return "real-preview";
112
- if (mode === "global-preview") return "webrtc-preview";
178
+ if (mode === "global-preview") return "relay-preview";
113
179
  return "local-event-bus";
114
180
  }
115
181
 
@@ -153,53 +219,166 @@ function readState() {
153
219
  }
154
220
 
155
221
  function printHelp() {
156
- console.log(`
157
- SilicaClaw Gateway
158
-
159
- Usage:
160
- silicaclaw gateway start [--mode=local|lan|global-preview] [--signaling-url=http://host:4510] [--room=silicaclaw-demo]
161
- silicaclaw gateway stop
162
- silicaclaw gateway restart [--mode=...]
163
- silicaclaw gateway status
164
- silicaclaw gateway logs [local-console|signaling]
165
-
166
- Notes:
167
- - Default app dir: current directory; fallback: ~/silicaclaw
168
- - State dir: .silicaclaw/gateway
169
- - global-preview + localhost signaling URL will auto-start signaling server
170
- `.trim());
222
+ headline();
223
+ console.log("");
224
+ section("Gateway");
225
+ kv("Start", "silicaclaw gateway start");
226
+ kv("Stop", "silicaclaw gateway stop");
227
+ kv("Restart", "silicaclaw gateway restart");
228
+ kv("Status", "silicaclaw gateway status");
229
+ kv("Logs", "silicaclaw gateway logs local-console");
171
230
  }
172
231
 
173
- function showStatus() {
232
+ function buildStatusPayload() {
174
233
  const localPid = readPid(CONSOLE_PID_FILE);
175
234
  const sigPid = readPid(SIGNALING_PID_FILE);
176
235
  const state = readState();
177
- const payload = {
236
+ const localListener = listeningProcessOnPort(4310);
237
+ const signalingListener = listeningProcessOnPort(4510);
238
+ return {
178
239
  app_dir: APP_DIR,
179
240
  mode: state?.mode || "unknown",
180
241
  adapter: state?.adapter || "unknown",
181
242
  local_console: {
182
243
  pid: localPid,
183
- running: isRunning(localPid),
244
+ running: Boolean(localListener),
184
245
  log_file: CONSOLE_LOG_FILE,
185
246
  },
186
247
  signaling: {
187
248
  pid: sigPid,
188
- running: isRunning(sigPid),
249
+ running: Boolean(signalingListener),
189
250
  log_file: SIGNALING_LOG_FILE,
190
251
  url: state?.signaling_url || null,
191
252
  room: state?.room || null,
192
253
  },
193
254
  updated_at: state?.updated_at || null,
194
255
  };
195
- console.log(JSON.stringify(payload, null, 2));
256
+ }
257
+
258
+ function showStatusJson() {
259
+ console.log(JSON.stringify(buildStatusPayload(), null, 2));
260
+ }
261
+
262
+ function showStatusHuman() {
263
+ const payload = buildStatusPayload();
264
+ headline();
265
+ console.log("");
266
+ kv(
267
+ "Status",
268
+ payload.local_console.running
269
+ ? `${paint("running", COLOR.green)} · ${payload.mode}`
270
+ : `${paint("stopped", COLOR.dim)} · ${payload.mode}`
271
+ );
272
+ if (payload.mode === "global-preview") {
273
+ kv("Relay", payload.signaling.url || "https://relay.silicaclaw.com");
274
+ kv("Room", payload.signaling.room || "silicaclaw-global-preview");
275
+ }
276
+ if (payload.local_console.running) kv("Open", "http://localhost:4310");
277
+ return payload;
278
+ }
279
+
280
+ function printConnectionSummary(status, verb = "Started") {
281
+ if (!status?.local_console?.running) return;
282
+ headline();
283
+ console.log("");
284
+ kv("Status", `${verb.toLowerCase()} · ${status.mode}`);
285
+ kv("Console", `http://localhost:4310`);
286
+ if (status.mode === "global-preview") {
287
+ const signalingUrl = status?.signaling?.url || "https://relay.silicaclaw.com";
288
+ const room = status?.signaling?.room || "silicaclaw-global-preview";
289
+ kv("Relay", signalingUrl);
290
+ kv("Room", room);
291
+ }
292
+ }
293
+
294
+ function listeningProcessOnPort(port) {
295
+ try {
296
+ const pidRes = spawnSync("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], {
297
+ encoding: "utf8",
298
+ stdio: ["ignore", "pipe", "ignore"],
299
+ });
300
+ const pid = String(pidRes.stdout || "")
301
+ .split(/\r?\n/)
302
+ .map((s) => s.trim())
303
+ .find(Boolean);
304
+ if (!pid) return null;
305
+
306
+ const cmdRes = spawnSync("ps", ["-p", pid, "-o", "command="], {
307
+ encoding: "utf8",
308
+ stdio: ["ignore", "pipe", "ignore"],
309
+ });
310
+ const command = String(cmdRes.stdout || "").trim() || "unknown";
311
+ return { pid, command };
312
+ } catch {
313
+ return null;
314
+ }
315
+ }
316
+
317
+ function normalizePathForMatch(value) {
318
+ return String(value || "").replace(/\\/g, "/");
319
+ }
320
+
321
+ function isOwnedListener(listener, kind) {
322
+ if (!listener?.command) return false;
323
+ const command = normalizePathForMatch(listener.command).toLowerCase();
324
+ const appDir = normalizePathForMatch(APP_DIR).toLowerCase();
325
+ const rootDir = normalizePathForMatch(ROOT_DIR).toLowerCase();
326
+ const inWorkspace = command.includes(appDir) || command.includes(rootDir);
327
+ if (!inWorkspace) return false;
328
+ if (kind === "local-console") {
329
+ return (
330
+ command.includes("@silicaclaw/local-console") ||
331
+ command.includes("/apps/local-console/") ||
332
+ command.includes("src/server.ts") ||
333
+ command.includes("dist/server.js")
334
+ );
335
+ }
336
+ if (kind === "signaling") {
337
+ return command.includes("webrtc-signaling-server.mjs") || command.includes("npm run webrtc-signaling");
338
+ }
339
+ return false;
340
+ }
341
+
342
+ async function stopOwnedListener(port, kind) {
343
+ const listener = listeningProcessOnPort(port);
344
+ if (!listener || !isOwnedListener(listener, kind)) return false;
345
+ await stopPid(Number(listener.pid), kind);
346
+ return true;
347
+ }
348
+
349
+ function printStopSummary() {
350
+ const localListener = listeningProcessOnPort(4310);
351
+ const signalingListener = listeningProcessOnPort(4510);
352
+ headline();
353
+ console.log("");
354
+ kv("Status", "stopped");
355
+ if (!localListener) {
356
+ kv("Console", paint("stopped", COLOR.green));
357
+ } else {
358
+ kv("Console", `${paint("still busy", COLOR.yellow)} (pid=${localListener.pid})`);
359
+ kv("Inspect", "lsof -nP -iTCP:4310 -sTCP:LISTEN");
360
+ kv("Stop pid", `kill ${localListener.pid}`);
361
+ }
362
+ if (!signalingListener) {
363
+ kv("Relay port", paint("stopped", COLOR.green));
364
+ } else {
365
+ kv("Relay port", `${paint("still busy", COLOR.yellow)} (pid=${signalingListener.pid})`);
366
+ kv("Inspect", "lsof -nP -iTCP:4510 -sTCP:LISTEN");
367
+ kv("Stop pid", `kill ${signalingListener.pid}`);
368
+ }
196
369
  }
197
370
 
198
371
  function tailFile(file, lines = 80) {
199
372
  if (!existsSync(file)) {
200
- console.log(`log file not found: ${file}`);
373
+ headline();
374
+ console.log("");
375
+ kv("Logs", "not found");
201
376
  return;
202
377
  }
378
+ headline();
379
+ console.log("");
380
+ kv("Logs", file);
381
+ console.log("");
203
382
  const text = String(readFileSync(file, "utf8"));
204
383
  const out = text.split(/\r?\n/).slice(-lines).join("\n");
205
384
  console.log(out);
@@ -210,29 +389,29 @@ async function stopAll() {
210
389
  const sigPid = readPid(SIGNALING_PID_FILE);
211
390
  await stopPid(localPid, "local-console");
212
391
  await stopPid(sigPid, "signaling");
392
+ await stopOwnedListener(4310, "local-console");
393
+ await stopOwnedListener(4510, "signaling");
213
394
  removeFileIfExists(CONSOLE_PID_FILE);
214
395
  removeFileIfExists(SIGNALING_PID_FILE);
215
396
  writeState({
216
397
  ...(readState() || {}),
217
398
  updated_at: Date.now(),
218
399
  });
219
- console.log("gateway stopped");
220
400
  }
221
401
 
222
402
  function startAll() {
223
403
  ensureStateDir();
224
404
 
225
- const mode = parseMode(parseFlag("mode", process.env.NETWORK_MODE || "local"));
405
+ const mode = parseMode(parseFlag("mode", process.env.NETWORK_MODE || "global-preview"));
226
406
  const adapter = adapterForMode(mode);
227
- const signalingUrl = parseFlag("signaling-url", process.env.WEBRTC_SIGNALING_URL || "http://localhost:4510");
228
- const room = parseFlag("room", process.env.WEBRTC_ROOM || "silicaclaw-demo");
407
+ const signalingUrl = parseFlag("signaling-url", process.env.WEBRTC_SIGNALING_URL || "https://relay.silicaclaw.com");
408
+ const room = parseFlag("room", process.env.WEBRTC_ROOM || "silicaclaw-global-preview");
229
409
  const shouldDisableSignaling = hasFlag("no-signaling");
230
410
 
231
411
  const currentLocalPid = readPid(CONSOLE_PID_FILE);
232
412
  const currentSigPid = readPid(SIGNALING_PID_FILE);
233
- if (isRunning(currentLocalPid)) {
234
- console.log(`local-console already running (pid=${currentLocalPid})`);
235
- } else {
413
+ let localPid = currentLocalPid;
414
+ if (!isRunning(currentLocalPid)) {
236
415
  removeFileIfExists(CONSOLE_PID_FILE);
237
416
  const env = {
238
417
  NETWORK_ADAPTER: adapter,
@@ -240,8 +419,7 @@ function startAll() {
240
419
  WEBRTC_SIGNALING_URL: signalingUrl,
241
420
  WEBRTC_ROOM: room,
242
421
  };
243
- const pid = spawnBackground("npm", ["run", "local-console"], env, CONSOLE_LOG_FILE, CONSOLE_PID_FILE);
244
- console.log(`local-console started (pid=${pid})`);
422
+ localPid = spawnBackground("npm", ["run", "local-console"], env, CONSOLE_LOG_FILE, CONSOLE_PID_FILE);
245
423
  }
246
424
 
247
425
  const { host, port } = parseUrlHostPort(signalingUrl);
@@ -250,19 +428,17 @@ function startAll() {
250
428
  !shouldDisableSignaling &&
251
429
  (host === "localhost" || host === "127.0.0.1");
252
430
 
431
+ let signalingPid = currentSigPid;
253
432
  if (shouldAutoStartSignaling) {
254
- if (isRunning(currentSigPid)) {
255
- console.log(`signaling already running (pid=${currentSigPid})`);
256
- } else {
433
+ if (!isRunning(currentSigPid)) {
257
434
  removeFileIfExists(SIGNALING_PID_FILE);
258
- const pid = spawnBackground(
435
+ signalingPid = spawnBackground(
259
436
  "npm",
260
437
  ["run", "webrtc-signaling"],
261
438
  { PORT: String(port) },
262
439
  SIGNALING_LOG_FILE,
263
440
  SIGNALING_PID_FILE,
264
441
  );
265
- console.log(`signaling started (pid=${pid}, port=${port})`);
266
442
  }
267
443
  }
268
444
 
@@ -274,6 +450,7 @@ function startAll() {
274
450
  room,
275
451
  updated_at: Date.now(),
276
452
  });
453
+ return { localPid, signalingPid: shouldAutoStartSignaling ? signalingPid : null };
277
454
  }
278
455
 
279
456
  async function main() {
@@ -282,23 +459,29 @@ async function main() {
282
459
  return;
283
460
  }
284
461
  if (cmd === "status") {
285
- showStatus();
462
+ if (hasFlag("json")) {
463
+ showStatusJson();
464
+ } else {
465
+ showStatusHuman();
466
+ }
286
467
  return;
287
468
  }
288
469
  if (cmd === "start") {
289
470
  startAll();
290
- showStatus();
471
+ const status = buildStatusPayload();
472
+ printConnectionSummary(status, "Started");
291
473
  return;
292
474
  }
293
475
  if (cmd === "stop") {
294
476
  await stopAll();
295
- showStatus();
477
+ printStopSummary();
296
478
  return;
297
479
  }
298
480
  if (cmd === "restart") {
299
481
  await stopAll();
300
482
  startAll();
301
- showStatus();
483
+ const status = buildStatusPayload();
484
+ printConnectionSummary(status, "Restarted");
302
485
  return;
303
486
  }
304
487
  if (cmd === "logs") {
@@ -315,7 +498,9 @@ async function main() {
315
498
  }
316
499
 
317
500
  main().catch((error) => {
501
+ headline();
502
+ console.log("");
503
+ console.error(paint("Gateway command failed", COLOR.bold, COLOR.red));
318
504
  console.error(error?.message || String(error));
319
505
  process.exit(1);
320
506
  });
321
-
@@ -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