@silicaclaw/cli 1.0.0-beta.8 → 2026.3.18-2

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,134 @@ 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 printStopSummary() {
318
+ const localListener = listeningProcessOnPort(4310);
319
+ const signalingListener = listeningProcessOnPort(4510);
320
+ headline();
321
+ console.log("");
322
+ kv("Status", "stopped");
323
+ if (!localListener) {
324
+ kv("Console", paint("stopped", COLOR.green));
325
+ } else {
326
+ kv("Console", `${paint("still busy", COLOR.yellow)} (pid=${localListener.pid})`);
327
+ kv("Inspect", "lsof -nP -iTCP:4310 -sTCP:LISTEN");
328
+ kv("Stop pid", `kill ${localListener.pid}`);
329
+ }
330
+ if (!signalingListener) {
331
+ kv("Relay port", paint("stopped", COLOR.green));
332
+ } else {
333
+ kv("Relay port", `${paint("still busy", COLOR.yellow)} (pid=${signalingListener.pid})`);
334
+ kv("Inspect", "lsof -nP -iTCP:4510 -sTCP:LISTEN");
335
+ kv("Stop pid", `kill ${signalingListener.pid}`);
336
+ }
196
337
  }
197
338
 
198
339
  function tailFile(file, lines = 80) {
199
340
  if (!existsSync(file)) {
200
- console.log(`log file not found: ${file}`);
341
+ headline();
342
+ console.log("");
343
+ kv("Logs", "not found");
201
344
  return;
202
345
  }
346
+ headline();
347
+ console.log("");
348
+ kv("Logs", file);
349
+ console.log("");
203
350
  const text = String(readFileSync(file, "utf8"));
204
351
  const out = text.split(/\r?\n/).slice(-lines).join("\n");
205
352
  console.log(out);
@@ -216,23 +363,21 @@ async function stopAll() {
216
363
  ...(readState() || {}),
217
364
  updated_at: Date.now(),
218
365
  });
219
- console.log("gateway stopped");
220
366
  }
221
367
 
222
368
  function startAll() {
223
369
  ensureStateDir();
224
370
 
225
- const mode = parseMode(parseFlag("mode", process.env.NETWORK_MODE || "local"));
371
+ const mode = parseMode(parseFlag("mode", process.env.NETWORK_MODE || "global-preview"));
226
372
  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");
373
+ const signalingUrl = parseFlag("signaling-url", process.env.WEBRTC_SIGNALING_URL || "https://relay.silicaclaw.com");
374
+ const room = parseFlag("room", process.env.WEBRTC_ROOM || "silicaclaw-global-preview");
229
375
  const shouldDisableSignaling = hasFlag("no-signaling");
230
376
 
231
377
  const currentLocalPid = readPid(CONSOLE_PID_FILE);
232
378
  const currentSigPid = readPid(SIGNALING_PID_FILE);
233
- if (isRunning(currentLocalPid)) {
234
- console.log(`local-console already running (pid=${currentLocalPid})`);
235
- } else {
379
+ let localPid = currentLocalPid;
380
+ if (!isRunning(currentLocalPid)) {
236
381
  removeFileIfExists(CONSOLE_PID_FILE);
237
382
  const env = {
238
383
  NETWORK_ADAPTER: adapter,
@@ -240,8 +385,7 @@ function startAll() {
240
385
  WEBRTC_SIGNALING_URL: signalingUrl,
241
386
  WEBRTC_ROOM: room,
242
387
  };
243
- const pid = spawnBackground("npm", ["run", "local-console"], env, CONSOLE_LOG_FILE, CONSOLE_PID_FILE);
244
- console.log(`local-console started (pid=${pid})`);
388
+ localPid = spawnBackground("npm", ["run", "local-console"], env, CONSOLE_LOG_FILE, CONSOLE_PID_FILE);
245
389
  }
246
390
 
247
391
  const { host, port } = parseUrlHostPort(signalingUrl);
@@ -250,19 +394,17 @@ function startAll() {
250
394
  !shouldDisableSignaling &&
251
395
  (host === "localhost" || host === "127.0.0.1");
252
396
 
397
+ let signalingPid = currentSigPid;
253
398
  if (shouldAutoStartSignaling) {
254
- if (isRunning(currentSigPid)) {
255
- console.log(`signaling already running (pid=${currentSigPid})`);
256
- } else {
399
+ if (!isRunning(currentSigPid)) {
257
400
  removeFileIfExists(SIGNALING_PID_FILE);
258
- const pid = spawnBackground(
401
+ signalingPid = spawnBackground(
259
402
  "npm",
260
403
  ["run", "webrtc-signaling"],
261
404
  { PORT: String(port) },
262
405
  SIGNALING_LOG_FILE,
263
406
  SIGNALING_PID_FILE,
264
407
  );
265
- console.log(`signaling started (pid=${pid}, port=${port})`);
266
408
  }
267
409
  }
268
410
 
@@ -274,6 +416,7 @@ function startAll() {
274
416
  room,
275
417
  updated_at: Date.now(),
276
418
  });
419
+ return { localPid, signalingPid: shouldAutoStartSignaling ? signalingPid : null };
277
420
  }
278
421
 
279
422
  async function main() {
@@ -282,23 +425,29 @@ async function main() {
282
425
  return;
283
426
  }
284
427
  if (cmd === "status") {
285
- showStatus();
428
+ if (hasFlag("json")) {
429
+ showStatusJson();
430
+ } else {
431
+ showStatusHuman();
432
+ }
286
433
  return;
287
434
  }
288
435
  if (cmd === "start") {
289
436
  startAll();
290
- showStatus();
437
+ const status = buildStatusPayload();
438
+ printConnectionSummary(status, "Started");
291
439
  return;
292
440
  }
293
441
  if (cmd === "stop") {
294
442
  await stopAll();
295
- showStatus();
443
+ printStopSummary();
296
444
  return;
297
445
  }
298
446
  if (cmd === "restart") {
299
447
  await stopAll();
300
448
  startAll();
301
- showStatus();
449
+ const status = buildStatusPayload();
450
+ printConnectionSummary(status, "Restarted");
302
451
  return;
303
452
  }
304
453
  if (cmd === "logs") {
@@ -315,7 +464,9 @@ async function main() {
315
464
  }
316
465
 
317
466
  main().catch((error) => {
467
+ headline();
468
+ console.log("");
469
+ console.error(paint("Gateway command failed", COLOR.bold, COLOR.red));
318
470
  console.error(error?.message || String(error));
319
471
  process.exit(1);
320
472
  });
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