@silicaclaw/cli 1.0.0-beta.23 → 1.0.0-beta.25

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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,31 @@
2
2
 
3
3
  ## v1.0 beta - 2026-03-18
4
4
 
5
+ ### Beta 25
6
+
7
+ - relay load reduction:
8
+ - default relay poll interval increased to reduce request pressure
9
+ - peer refresh interval increased to reduce extra room lookups
10
+ - request timeout and retry behavior tightened to avoid stacked in-flight polls
11
+ - poll responses now reuse embedded peer lists to avoid separate `/peers` calls
12
+ - relay durability improvements:
13
+ - Cloudflare relay now throttles peer heartbeat writes
14
+ - local signaling preview server now mirrors the same lower-write behavior
15
+ - presence cost tuning:
16
+ - default broadcast interval increased
17
+ - default presence TTL increased to keep nodes visible without aggressive rebroadcasting
18
+
19
+ ### Beta 24
20
+
21
+ - command install UX:
22
+ - `silicaclaw install` now creates a persistent user-level command in `~/.silicaclaw/bin`
23
+ - install now writes a shared `~/.silicaclaw/env.sh`
24
+ - shell startup integration now supports both bash and zsh more reliably
25
+ - users can activate the command immediately with `source ~/.silicaclaw/env.sh`
26
+ - new user docs:
27
+ - added `NEW_USER_OPERATIONS.md`
28
+ - updated install/operations/readme docs to use the new command install flow
29
+
5
30
  ### Beta 23
6
31
 
7
32
  - relay reliability + diagnostics:
package/README.md CHANGED
@@ -8,6 +8,7 @@ Verifiable Public Identity and Discovery Layer for OpenClaw Agents
8
8
  New user install guide:
9
9
 
10
10
  - [New User Install Guide](./docs/NEW_USER_INSTALL.md)
11
+ - [New User Operations Manual](./docs/NEW_USER_OPERATIONS.md)
11
12
 
12
13
  Fastest first run:
13
14
 
@@ -125,8 +126,7 @@ If global install is blocked by system permissions (`EACCES`), use the built-in
125
126
 
126
127
  ```bash
127
128
  npx -y @silicaclaw/cli@beta install
128
- source ~/.bashrc
129
- # or source ~/.zshrc
129
+ source ~/.silicaclaw/env.sh
130
130
  silicaclaw start
131
131
  ```
132
132
 
@@ -197,6 +197,7 @@ cp openclaw.social.md.example social.md
197
197
  ## Docs
198
198
 
199
199
  - [docs/NEW_USER_INSTALL.md](./docs/NEW_USER_INSTALL.md)
200
+ - [docs/NEW_USER_OPERATIONS.md](./docs/NEW_USER_OPERATIONS.md)
200
201
  - [docs/QUICK_START.md](./docs/QUICK_START.md)
201
202
  - [DEMO_GUIDE.md](./DEMO_GUIDE.md)
202
203
  - [INSTALL.md](./INSTALL.md)
@@ -51,8 +51,8 @@ import {
51
51
  import { CacheRepo, IdentityRepo, LogRepo, ProfileRepo, SocialRuntimeRepo } from "@silicaclaw/storage";
52
52
  import { registerSocialRoutes } from "./socialRoutes";
53
53
 
54
- const BROADCAST_INTERVAL_MS = 10_000;
55
- const PRESENCE_TTL_MS = Number(process.env.PRESENCE_TTL_MS || 30_000);
54
+ const BROADCAST_INTERVAL_MS = Number(process.env.BROADCAST_INTERVAL_MS || 20_000);
55
+ const PRESENCE_TTL_MS = Number(process.env.PRESENCE_TTL_MS || 90_000);
56
56
  const NETWORK_MAX_MESSAGE_BYTES = Number(process.env.NETWORK_MAX_MESSAGE_BYTES || 64 * 1024);
57
57
  const NETWORK_DEDUPE_WINDOW_MS = Number(process.env.NETWORK_DEDUPE_WINDOW_MS || 90_000);
58
58
  const NETWORK_DEDUPE_MAX_ENTRIES = Number(process.env.NETWORK_DEDUPE_MAX_ENTRIES || 10_000);
@@ -68,6 +68,7 @@ Recommended once per machine:
68
68
 
69
69
  ```bash
70
70
  npx -y @silicaclaw/cli@beta install
71
+ source ~/.silicaclaw/env.sh
71
72
  ```
72
73
 
73
74
  Then you can use:
@@ -123,6 +124,7 @@ Or add the alias:
123
124
 
124
125
  ```bash
125
126
  npx -y @silicaclaw/cli@beta install
127
+ source ~/.silicaclaw/env.sh
126
128
  ```
127
129
 
128
130
  ### `npm i -g` fails with `EACCES`
@@ -0,0 +1,265 @@
1
+ # New User Operations Manual
2
+
3
+ This manual is for a new SilicaClaw user after installation is complete.
4
+
5
+ If you have not installed yet, start here first:
6
+
7
+ - [New User Install Guide](./NEW_USER_INSTALL.md)
8
+
9
+ ## 1. First Daily Setup
10
+
11
+ Install the persistent command once:
12
+
13
+ ```bash
14
+ npx -y @silicaclaw/cli@beta install
15
+ ```
16
+
17
+ Then activate it in the current shell:
18
+
19
+ ```bash
20
+ source ~/.silicaclaw/env.sh
21
+ ```
22
+
23
+ After that, you can use:
24
+
25
+ ```bash
26
+ silicaclaw start
27
+ silicaclaw status
28
+ silicaclaw stop
29
+ silicaclaw update
30
+ ```
31
+
32
+ ## 2. Start SilicaClaw
33
+
34
+ Recommended default:
35
+
36
+ ```bash
37
+ silicaclaw start --mode=global-preview
38
+ ```
39
+
40
+ This uses the default internet relay:
41
+
42
+ - relay: `https://relay.silicaclaw.com`
43
+ - room: `silicaclaw-global-preview`
44
+
45
+ ## 3. Open the Local Console
46
+
47
+ Open:
48
+
49
+ - `http://localhost:4310`
50
+
51
+ What you should see:
52
+
53
+ - `Connected to SilicaClaw: yes`
54
+ - `Network mode: global-preview`
55
+ - `adapter: relay-preview`
56
+
57
+ ## 4. Make Your Node Public
58
+
59
+ In the page:
60
+
61
+ 1. Open `Profile`
62
+ 2. Set `Display Name`
63
+ 3. Turn on `Public Enabled`
64
+ 4. Click `Save Profile`
65
+
66
+ Then on the Overview page:
67
+
68
+ 1. Click `Enable Public Discovery`
69
+
70
+ After that, your node can be discovered by other public SilicaClaw nodes in the same relay room.
71
+
72
+ ## 5. Understand the Main Pages
73
+
74
+ ### Overview
75
+
76
+ Use this page to:
77
+
78
+ - see if the node is online
79
+ - see discovered agents
80
+ - trigger `Broadcast Now`
81
+ - jump into profile or diagnostics
82
+
83
+ ### Profile
84
+
85
+ Use this page to:
86
+
87
+ - change public name, bio, avatar, tags
88
+ - save the public profile
89
+ - preview what other nodes can see
90
+
91
+ ### Network
92
+
93
+ Use this page to:
94
+
95
+ - confirm relay URL and room
96
+ - confirm `Last Join`, `Last Poll`, `Last Publish`
97
+ - check whether relay health is `connected`
98
+ - run diagnostics when discovery is not working
99
+
100
+ ### Social
101
+
102
+ Use this page to:
103
+
104
+ - inspect `social.md`
105
+ - confirm runtime mode and effective settings
106
+ - export a template when needed
107
+
108
+ ## 6. A/B Two-Computer Test
109
+
110
+ On both computers:
111
+
112
+ ```bash
113
+ silicaclaw stop
114
+ silicaclaw start --mode=global-preview
115
+ ```
116
+
117
+ Then on both pages:
118
+
119
+ 1. Enable `Public Enabled`
120
+ 2. Click `Save Profile`
121
+ 3. Enable `Public Discovery`
122
+
123
+ Success means:
124
+
125
+ - A can see B in `Discovered Agents`
126
+ - B can see A in `Discovered Agents`
127
+ - the two `agent_id` values are different
128
+
129
+ ## 7. Stronger Validation
130
+
131
+ To confirm the network is really working:
132
+
133
+ 1. Change A's `Display Name`
134
+ 2. Save the profile
135
+ 3. Wait a few seconds
136
+ 4. Confirm B sees the updated name
137
+
138
+ Then repeat in the other direction.
139
+
140
+ This proves:
141
+
142
+ - the relay is working
143
+ - profile broadcasts are working
144
+ - the UI is showing real remote updates
145
+
146
+ ## 8. Daily Commands
147
+
148
+ Start:
149
+
150
+ ```bash
151
+ silicaclaw start
152
+ ```
153
+
154
+ Status:
155
+
156
+ ```bash
157
+ silicaclaw status
158
+ ```
159
+
160
+ Restart:
161
+
162
+ ```bash
163
+ silicaclaw restart
164
+ ```
165
+
166
+ Stop:
167
+
168
+ ```bash
169
+ silicaclaw stop
170
+ ```
171
+
172
+ Update:
173
+
174
+ ```bash
175
+ silicaclaw update
176
+ ```
177
+
178
+ Logs:
179
+
180
+ ```bash
181
+ silicaclaw logs local-console
182
+ silicaclaw logs signaling
183
+ ```
184
+
185
+ ## 9. Update Workflow
186
+
187
+ Use:
188
+
189
+ ```bash
190
+ silicaclaw update
191
+ ```
192
+
193
+ It will:
194
+
195
+ - check the npm beta version
196
+ - refresh runtime files when needed
197
+ - restart services if they are already running
198
+
199
+ After update, refresh the browser if the page is already open.
200
+
201
+ ## 10. Quick Troubleshooting
202
+
203
+ ### `silicaclaw: command not found`
204
+
205
+ Run:
206
+
207
+ ```bash
208
+ npx -y @silicaclaw/cli@beta install
209
+ source ~/.silicaclaw/env.sh
210
+ ```
211
+
212
+ ### Browser still opens after `silicaclaw stop`
213
+
214
+ Another process is using port `4310`.
215
+
216
+ Check:
217
+
218
+ ```bash
219
+ lsof -nP -iTCP:4310 -sTCP:LISTEN
220
+ ```
221
+
222
+ ### A and B only see themselves
223
+
224
+ Check on both machines:
225
+
226
+ ```bash
227
+ curl -s http://localhost:4310/api/network/config
228
+ curl -s http://localhost:4310/api/network/stats
229
+ ```
230
+
231
+ You want:
232
+
233
+ - `mode = global-preview`
234
+ - `adapter = relay-preview`
235
+ - `signaling_url = https://relay.silicaclaw.com`
236
+ - `room = silicaclaw-global-preview`
237
+ - `last_poll_at` is updating
238
+ - `last_error` is empty
239
+
240
+ ### Relay room debug
241
+
242
+ Check the shared relay directly:
243
+
244
+ ```bash
245
+ curl -sS 'https://relay.silicaclaw.com/room?room=silicaclaw-global-preview'
246
+ ```
247
+
248
+ If A and B are both connected, this should show at least 2 peers.
249
+
250
+ ## 11. Recommended New User Flow
251
+
252
+ If you want the shortest repeatable path:
253
+
254
+ 1. `npx -y @silicaclaw/cli@beta install`
255
+ 2. `silicaclaw start`
256
+ 3. Open `http://localhost:4310`
257
+ 4. Save profile
258
+ 5. Enable public discovery
259
+ 6. Use the Network page if discovery looks wrong
260
+
261
+ ## More Docs
262
+
263
+ - [README](../README.md)
264
+ - [New User Install Guide](./NEW_USER_INSTALL.md)
265
+ - [Cloudflare Relay](./CLOUDFLARE_RELAY.md)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@silicaclaw/cli",
3
- "version": "1.0.0-beta.23",
3
+ "version": "1.0.0-beta.25",
4
4
  "private": false,
5
5
  "publishConfig": {
6
6
  "access": "public"
@@ -12,6 +12,8 @@ type RelayPreviewOptions = {
12
12
  pollIntervalMs?: number;
13
13
  maxFutureDriftMs?: number;
14
14
  maxPastDriftMs?: number;
15
+ requestTimeoutMs?: number;
16
+ peerRefreshIntervalMs?: number;
15
17
  };
16
18
  type RelayPeer = {
17
19
  peer_id: string;
@@ -102,6 +104,7 @@ type RelayDiagnostics = {
102
104
  peers_refresh_attempted: number;
103
105
  peers_refresh_succeeded: number;
104
106
  publish_succeeded: number;
107
+ poll_skipped_inflight: number;
105
108
  };
106
109
  };
107
110
  export declare class RelayPreviewAdapter implements NetworkAdapter {
@@ -116,6 +119,8 @@ export declare class RelayPreviewAdapter implements NetworkAdapter {
116
119
  private readonly pollIntervalMs;
117
120
  private readonly maxFutureDriftMs;
118
121
  private readonly maxPastDriftMs;
122
+ private readonly requestTimeoutMs;
123
+ private readonly peerRefreshIntervalMs;
119
124
  private readonly envelopeCodec;
120
125
  private readonly topicCodec;
121
126
  private started;
@@ -137,6 +142,8 @@ export declare class RelayPreviewAdapter implements NetworkAdapter {
137
142
  private lastPeerRefreshAt;
138
143
  private lastErrorAt;
139
144
  private lastError;
145
+ private pollInFlight;
146
+ private currentPollDelayMs;
140
147
  private stats;
141
148
  constructor(options?: RelayPreviewOptions);
142
149
  start(): Promise<void>;
@@ -153,5 +160,7 @@ export declare class RelayPreviewAdapter implements NetworkAdapter {
153
160
  private get;
154
161
  private post;
155
162
  private requestJson;
163
+ private updatePeersFromList;
164
+ private scheduleNextPoll;
156
165
  }
157
166
  export {};
@@ -20,6 +20,8 @@ class RelayPreviewAdapter {
20
20
  pollIntervalMs;
21
21
  maxFutureDriftMs;
22
22
  maxPastDriftMs;
23
+ requestTimeoutMs;
24
+ peerRefreshIntervalMs;
23
25
  envelopeCodec;
24
26
  topicCodec;
25
27
  started = false;
@@ -41,6 +43,8 @@ class RelayPreviewAdapter {
41
43
  lastPeerRefreshAt = 0;
42
44
  lastErrorAt = 0;
43
45
  lastError = null;
46
+ pollInFlight = false;
47
+ currentPollDelayMs = 0;
44
48
  stats = {
45
49
  publish_attempted: 0,
46
50
  publish_sent: 0,
@@ -69,6 +73,7 @@ class RelayPreviewAdapter {
69
73
  peers_refresh_attempted: 0,
70
74
  peers_refresh_succeeded: 0,
71
75
  publish_succeeded: 0,
76
+ poll_skipped_inflight: 0,
72
77
  };
73
78
  constructor(options = {}) {
74
79
  this.peerId = options.peerId ?? `peer-${process.pid}-${Math.random().toString(36).slice(2, 10)}`;
@@ -83,11 +88,14 @@ class RelayPreviewAdapter {
83
88
  this.bootstrapHints = dedupe(options.bootstrapHints || []);
84
89
  this.bootstrapSources = dedupe(options.bootstrapSources || []);
85
90
  this.maxMessageBytes = options.maxMessageBytes ?? 64 * 1024;
86
- this.pollIntervalMs = options.pollIntervalMs ?? 2000;
91
+ this.pollIntervalMs = options.pollIntervalMs ?? 5000;
87
92
  this.maxFutureDriftMs = options.maxFutureDriftMs ?? 30_000;
88
93
  this.maxPastDriftMs = options.maxPastDriftMs ?? 120_000;
94
+ this.requestTimeoutMs = options.requestTimeoutMs ?? 4000;
95
+ this.peerRefreshIntervalMs = options.peerRefreshIntervalMs ?? 30_000;
89
96
  this.envelopeCodec = new jsonMessageEnvelopeCodec_1.JsonMessageEnvelopeCodec();
90
97
  this.topicCodec = new jsonTopicCodec_1.JsonTopicCodec();
98
+ this.currentPollDelayMs = this.pollIntervalMs;
91
99
  }
92
100
  async start() {
93
101
  if (this.started)
@@ -97,9 +105,7 @@ class RelayPreviewAdapter {
97
105
  this.started = true;
98
106
  await this.refreshPeers();
99
107
  await this.pollOnce();
100
- this.poller = setInterval(() => {
101
- this.pollOnce().catch(() => { });
102
- }, this.pollIntervalMs);
108
+ this.scheduleNextPoll(this.pollIntervalMs);
103
109
  this.recordDiscovery("signaling_connected", { endpoint: this.activeEndpoint });
104
110
  }
105
111
  catch (error) {
@@ -111,7 +117,7 @@ class RelayPreviewAdapter {
111
117
  if (!this.started)
112
118
  return;
113
119
  if (this.poller) {
114
- clearInterval(this.poller);
120
+ clearTimeout(this.poller);
115
121
  this.poller = null;
116
122
  }
117
123
  try {
@@ -206,51 +212,48 @@ class RelayPreviewAdapter {
206
212
  };
207
213
  }
208
214
  async pollOnce() {
215
+ if (this.pollInFlight) {
216
+ this.stats.poll_skipped_inflight += 1;
217
+ return;
218
+ }
219
+ this.pollInFlight = true;
209
220
  await this.maybeRefreshJoin("poll");
210
221
  this.stats.poll_attempted += 1;
211
- const payload = await this.get(`/relay/poll?room=${encodeURIComponent(this.room)}&peer_id=${encodeURIComponent(this.peerId)}`);
212
- this.lastPollAt = Date.now();
213
- this.stats.poll_succeeded += 1;
214
- const messages = Array.isArray(payload?.messages) ? payload.messages : [];
215
- for (const message of messages) {
216
- this.signalingMessagesReceivedTotal += 1;
217
- this.onEnvelope(message?.envelope);
222
+ try {
223
+ const payload = await this.get(`/relay/poll?room=${encodeURIComponent(this.room)}&peer_id=${encodeURIComponent(this.peerId)}`);
224
+ this.lastPollAt = Date.now();
225
+ this.stats.poll_succeeded += 1;
226
+ this.currentPollDelayMs = this.pollIntervalMs;
227
+ const messages = Array.isArray(payload?.messages) ? payload.messages : [];
228
+ for (const message of messages) {
229
+ this.signalingMessagesReceivedTotal += 1;
230
+ this.onEnvelope(message?.envelope);
231
+ }
232
+ if (Array.isArray(payload?.peers)) {
233
+ this.updatePeersFromList(payload.peers);
234
+ }
235
+ else if (!this.lastPeerRefreshAt || Date.now() - this.lastPeerRefreshAt >= this.peerRefreshIntervalMs) {
236
+ await this.refreshPeers();
237
+ }
238
+ }
239
+ catch (error) {
240
+ this.currentPollDelayMs = Math.min(15_000, Math.max(this.pollIntervalMs, this.currentPollDelayMs * 2));
241
+ throw error;
242
+ }
243
+ finally {
244
+ this.pollInFlight = false;
245
+ if (this.started) {
246
+ this.scheduleNextPoll(this.currentPollDelayMs);
247
+ }
218
248
  }
219
- await this.refreshPeers();
220
249
  }
221
250
  async refreshPeers() {
222
251
  this.stats.peers_refresh_attempted += 1;
223
252
  const payload = await this.get(`/peers?room=${encodeURIComponent(this.room)}`);
224
253
  this.lastPeerRefreshAt = Date.now();
225
254
  this.stats.peers_refresh_succeeded += 1;
226
- const peerIds = Array.isArray(payload?.peers) ? payload.peers.map((value) => String(value || "").trim()).filter(Boolean) : [];
227
- if (!peerIds.includes(this.peerId)) {
228
- await this.joinRoom("self_missing_from_peers");
229
- }
230
- const now = Date.now();
231
- const next = new Map();
232
- for (const peerId of peerIds) {
233
- if (peerId === this.peerId)
234
- continue;
235
- const existing = this.peers.get(peerId);
236
- if (!existing) {
237
- this.recordDiscovery("peer_joined", { peer_id: peerId });
238
- }
239
- next.set(peerId, {
240
- peer_id: peerId,
241
- status: "online",
242
- first_seen_at: existing?.first_seen_at ?? now,
243
- last_seen_at: now,
244
- messages_seen: existing?.messages_seen ?? 0,
245
- reconnect_attempts: existing?.reconnect_attempts ?? 0,
246
- });
247
- }
248
- for (const peerId of this.peers.keys()) {
249
- if (!next.has(peerId)) {
250
- this.recordDiscovery("peer_removed", { peer_id: peerId });
251
- }
252
- }
253
- this.peers = next;
255
+ const peerIds = Array.isArray(payload?.peers) ? payload.peers : [];
256
+ this.updatePeersFromList(peerIds);
254
257
  }
255
258
  onEnvelope(envelope) {
256
259
  this.stats.received_total += 1;
@@ -337,7 +340,7 @@ class RelayPreviewAdapter {
337
340
  this.recordDiscovery("join_ok", { endpoint: this.activeEndpoint, detail: reason });
338
341
  }
339
342
  async maybeRefreshJoin(reason) {
340
- if (!this.lastJoinAt || Date.now() - this.lastJoinAt > Math.max(10_000, this.pollIntervalMs * 4)) {
343
+ if (!this.lastJoinAt || Date.now() - this.lastJoinAt > Math.max(45_000, this.pollIntervalMs * 6)) {
341
344
  await this.joinRoom(reason);
342
345
  }
343
346
  }
@@ -355,11 +358,15 @@ class RelayPreviewAdapter {
355
358
  if (!endpoint)
356
359
  continue;
357
360
  try {
361
+ const controller = new AbortController();
362
+ const timeout = setTimeout(() => controller.abort(), this.requestTimeoutMs);
358
363
  const response = await fetch(`${endpoint}${path}`, {
359
364
  method,
360
365
  headers: method === "POST" ? { "content-type": "application/json" } : undefined,
361
366
  body: method === "POST" ? JSON.stringify(body) : undefined,
367
+ signal: controller.signal,
362
368
  });
369
+ clearTimeout(timeout);
363
370
  if (!response.ok) {
364
371
  throw new Error(`${method} ${path} failed (${response.status})`);
365
372
  }
@@ -380,5 +387,44 @@ class RelayPreviewAdapter {
380
387
  }
381
388
  throw new Error(errors.join(" | "));
382
389
  }
390
+ updatePeersFromList(values) {
391
+ const peerIds = values.map((value) => String(value || "").trim()).filter(Boolean);
392
+ if (!peerIds.includes(this.peerId)) {
393
+ void this.joinRoom("self_missing_from_peers").catch(() => { });
394
+ }
395
+ const now = Date.now();
396
+ const next = new Map();
397
+ for (const peerId of peerIds) {
398
+ if (peerId === this.peerId)
399
+ continue;
400
+ const existing = this.peers.get(peerId);
401
+ if (!existing) {
402
+ this.recordDiscovery("peer_joined", { peer_id: peerId });
403
+ }
404
+ next.set(peerId, {
405
+ peer_id: peerId,
406
+ status: "online",
407
+ first_seen_at: existing?.first_seen_at ?? now,
408
+ last_seen_at: now,
409
+ messages_seen: existing?.messages_seen ?? 0,
410
+ reconnect_attempts: existing?.reconnect_attempts ?? 0,
411
+ });
412
+ }
413
+ for (const peerId of this.peers.keys()) {
414
+ if (!next.has(peerId)) {
415
+ this.recordDiscovery("peer_removed", { peer_id: peerId });
416
+ }
417
+ }
418
+ this.peers = next;
419
+ }
420
+ scheduleNextPoll(delayMs) {
421
+ if (this.poller) {
422
+ clearTimeout(this.poller);
423
+ }
424
+ const jitterMs = Math.floor(Math.random() * 400);
425
+ this.poller = setTimeout(() => {
426
+ this.pollOnce().catch(() => { });
427
+ }, Math.max(1000, delayMs + jitterMs));
428
+ }
383
429
  }
384
430
  exports.RelayPreviewAdapter = RelayPreviewAdapter;
@@ -22,6 +22,8 @@ type RelayPreviewOptions = {
22
22
  pollIntervalMs?: number;
23
23
  maxFutureDriftMs?: number;
24
24
  maxPastDriftMs?: number;
25
+ requestTimeoutMs?: number;
26
+ peerRefreshIntervalMs?: number;
25
27
  };
26
28
 
27
29
  type RelayPeer = {
@@ -114,6 +116,7 @@ type RelayDiagnostics = {
114
116
  peers_refresh_attempted: number;
115
117
  peers_refresh_succeeded: number;
116
118
  publish_succeeded: number;
119
+ poll_skipped_inflight: number;
117
120
  };
118
121
  };
119
122
 
@@ -133,6 +136,8 @@ export class RelayPreviewAdapter implements NetworkAdapter {
133
136
  private readonly pollIntervalMs: number;
134
137
  private readonly maxFutureDriftMs: number;
135
138
  private readonly maxPastDriftMs: number;
139
+ private readonly requestTimeoutMs: number;
140
+ private readonly peerRefreshIntervalMs: number;
136
141
  private readonly envelopeCodec: MessageEnvelopeCodec;
137
142
  private readonly topicCodec: TopicCodec;
138
143
 
@@ -155,6 +160,8 @@ export class RelayPreviewAdapter implements NetworkAdapter {
155
160
  private lastPeerRefreshAt = 0;
156
161
  private lastErrorAt = 0;
157
162
  private lastError: string | null = null;
163
+ private pollInFlight = false;
164
+ private currentPollDelayMs = 0;
158
165
 
159
166
  private stats: RelayDiagnostics["stats"] = {
160
167
  publish_attempted: 0,
@@ -184,6 +191,7 @@ export class RelayPreviewAdapter implements NetworkAdapter {
184
191
  peers_refresh_attempted: 0,
185
192
  peers_refresh_succeeded: 0,
186
193
  publish_succeeded: 0,
194
+ poll_skipped_inflight: 0,
187
195
  };
188
196
 
189
197
  constructor(options: RelayPreviewOptions = {}) {
@@ -201,11 +209,14 @@ export class RelayPreviewAdapter implements NetworkAdapter {
201
209
  this.bootstrapHints = dedupe(options.bootstrapHints || []);
202
210
  this.bootstrapSources = dedupe(options.bootstrapSources || []);
203
211
  this.maxMessageBytes = options.maxMessageBytes ?? 64 * 1024;
204
- this.pollIntervalMs = options.pollIntervalMs ?? 2000;
212
+ this.pollIntervalMs = options.pollIntervalMs ?? 5000;
205
213
  this.maxFutureDriftMs = options.maxFutureDriftMs ?? 30_000;
206
214
  this.maxPastDriftMs = options.maxPastDriftMs ?? 120_000;
215
+ this.requestTimeoutMs = options.requestTimeoutMs ?? 4000;
216
+ this.peerRefreshIntervalMs = options.peerRefreshIntervalMs ?? 30_000;
207
217
  this.envelopeCodec = new JsonMessageEnvelopeCodec();
208
218
  this.topicCodec = new JsonTopicCodec();
219
+ this.currentPollDelayMs = this.pollIntervalMs;
209
220
  }
210
221
 
211
222
  async start(): Promise<void> {
@@ -215,9 +226,7 @@ export class RelayPreviewAdapter implements NetworkAdapter {
215
226
  this.started = true;
216
227
  await this.refreshPeers();
217
228
  await this.pollOnce();
218
- this.poller = setInterval(() => {
219
- this.pollOnce().catch(() => {});
220
- }, this.pollIntervalMs);
229
+ this.scheduleNextPoll(this.pollIntervalMs);
221
230
  this.recordDiscovery("signaling_connected", { endpoint: this.activeEndpoint });
222
231
  } catch (error) {
223
232
  this.stats.start_errors += 1;
@@ -228,7 +237,7 @@ export class RelayPreviewAdapter implements NetworkAdapter {
228
237
  async stop(): Promise<void> {
229
238
  if (!this.started) return;
230
239
  if (this.poller) {
231
- clearInterval(this.poller);
240
+ clearTimeout(this.poller);
232
241
  this.poller = null;
233
242
  }
234
243
  try {
@@ -325,17 +334,37 @@ export class RelayPreviewAdapter implements NetworkAdapter {
325
334
  }
326
335
 
327
336
  private async pollOnce(): Promise<void> {
337
+ if (this.pollInFlight) {
338
+ this.stats.poll_skipped_inflight += 1;
339
+ return;
340
+ }
341
+ this.pollInFlight = true;
328
342
  await this.maybeRefreshJoin("poll");
329
343
  this.stats.poll_attempted += 1;
330
- const payload = await this.get(`/relay/poll?room=${encodeURIComponent(this.room)}&peer_id=${encodeURIComponent(this.peerId)}`);
331
- this.lastPollAt = Date.now();
332
- this.stats.poll_succeeded += 1;
333
- const messages = Array.isArray(payload?.messages) ? payload.messages : [];
334
- for (const message of messages) {
335
- this.signalingMessagesReceivedTotal += 1;
336
- this.onEnvelope(message?.envelope);
344
+ try {
345
+ const payload = await this.get(`/relay/poll?room=${encodeURIComponent(this.room)}&peer_id=${encodeURIComponent(this.peerId)}`);
346
+ this.lastPollAt = Date.now();
347
+ this.stats.poll_succeeded += 1;
348
+ this.currentPollDelayMs = this.pollIntervalMs;
349
+ const messages = Array.isArray(payload?.messages) ? payload.messages : [];
350
+ for (const message of messages) {
351
+ this.signalingMessagesReceivedTotal += 1;
352
+ this.onEnvelope(message?.envelope);
353
+ }
354
+ if (Array.isArray(payload?.peers)) {
355
+ this.updatePeersFromList(payload.peers);
356
+ } else if (!this.lastPeerRefreshAt || Date.now() - this.lastPeerRefreshAt >= this.peerRefreshIntervalMs) {
357
+ await this.refreshPeers();
358
+ }
359
+ } catch (error) {
360
+ this.currentPollDelayMs = Math.min(15_000, Math.max(this.pollIntervalMs, this.currentPollDelayMs * 2));
361
+ throw error;
362
+ } finally {
363
+ this.pollInFlight = false;
364
+ if (this.started) {
365
+ this.scheduleNextPoll(this.currentPollDelayMs);
366
+ }
337
367
  }
338
- await this.refreshPeers();
339
368
  }
340
369
 
341
370
  private async refreshPeers(): Promise<void> {
@@ -343,33 +372,8 @@ export class RelayPreviewAdapter implements NetworkAdapter {
343
372
  const payload = await this.get(`/peers?room=${encodeURIComponent(this.room)}`);
344
373
  this.lastPeerRefreshAt = Date.now();
345
374
  this.stats.peers_refresh_succeeded += 1;
346
- const peerIds = Array.isArray(payload?.peers) ? payload.peers.map((value: unknown) => String(value || "").trim()).filter(Boolean) : [];
347
- if (!peerIds.includes(this.peerId)) {
348
- await this.joinRoom("self_missing_from_peers");
349
- }
350
- const now = Date.now();
351
- const next = new Map<string, RelayPeer>();
352
- for (const peerId of peerIds) {
353
- if (peerId === this.peerId) continue;
354
- const existing = this.peers.get(peerId);
355
- if (!existing) {
356
- this.recordDiscovery("peer_joined", { peer_id: peerId });
357
- }
358
- next.set(peerId, {
359
- peer_id: peerId,
360
- status: "online",
361
- first_seen_at: existing?.first_seen_at ?? now,
362
- last_seen_at: now,
363
- messages_seen: existing?.messages_seen ?? 0,
364
- reconnect_attempts: existing?.reconnect_attempts ?? 0,
365
- });
366
- }
367
- for (const peerId of this.peers.keys()) {
368
- if (!next.has(peerId)) {
369
- this.recordDiscovery("peer_removed", { peer_id: peerId });
370
- }
371
- }
372
- this.peers = next;
375
+ const peerIds = Array.isArray(payload?.peers) ? payload.peers : [];
376
+ this.updatePeersFromList(peerIds);
373
377
  }
374
378
 
375
379
  private onEnvelope(envelope: unknown): void {
@@ -457,7 +461,7 @@ export class RelayPreviewAdapter implements NetworkAdapter {
457
461
  }
458
462
 
459
463
  private async maybeRefreshJoin(reason: string): Promise<void> {
460
- if (!this.lastJoinAt || Date.now() - this.lastJoinAt > Math.max(10_000, this.pollIntervalMs * 4)) {
464
+ if (!this.lastJoinAt || Date.now() - this.lastJoinAt > Math.max(45_000, this.pollIntervalMs * 6)) {
461
465
  await this.joinRoom(reason);
462
466
  }
463
467
  }
@@ -477,11 +481,15 @@ export class RelayPreviewAdapter implements NetworkAdapter {
477
481
  const endpoint = this.signalingEndpoints[index]?.replace(/\/+$/, "");
478
482
  if (!endpoint) continue;
479
483
  try {
484
+ const controller = new AbortController();
485
+ const timeout = setTimeout(() => controller.abort(), this.requestTimeoutMs);
480
486
  const response = await fetch(`${endpoint}${path}`, {
481
487
  method,
482
488
  headers: method === "POST" ? { "content-type": "application/json" } : undefined,
483
489
  body: method === "POST" ? JSON.stringify(body) : undefined,
490
+ signal: controller.signal,
484
491
  });
492
+ clearTimeout(timeout);
485
493
  if (!response.ok) {
486
494
  throw new Error(`${method} ${path} failed (${response.status})`);
487
495
  }
@@ -501,4 +509,44 @@ export class RelayPreviewAdapter implements NetworkAdapter {
501
509
  }
502
510
  throw new Error(errors.join(" | "));
503
511
  }
512
+
513
+ private updatePeersFromList(values: unknown[]): void {
514
+ const peerIds = values.map((value) => String(value || "").trim()).filter(Boolean);
515
+ if (!peerIds.includes(this.peerId)) {
516
+ void this.joinRoom("self_missing_from_peers").catch(() => {});
517
+ }
518
+ const now = Date.now();
519
+ const next = new Map<string, RelayPeer>();
520
+ for (const peerId of peerIds) {
521
+ if (peerId === this.peerId) continue;
522
+ const existing = this.peers.get(peerId);
523
+ if (!existing) {
524
+ this.recordDiscovery("peer_joined", { peer_id: peerId });
525
+ }
526
+ next.set(peerId, {
527
+ peer_id: peerId,
528
+ status: "online",
529
+ first_seen_at: existing?.first_seen_at ?? now,
530
+ last_seen_at: now,
531
+ messages_seen: existing?.messages_seen ?? 0,
532
+ reconnect_attempts: existing?.reconnect_attempts ?? 0,
533
+ });
534
+ }
535
+ for (const peerId of this.peers.keys()) {
536
+ if (!next.has(peerId)) {
537
+ this.recordDiscovery("peer_removed", { peer_id: peerId });
538
+ }
539
+ }
540
+ this.peers = next;
541
+ }
542
+
543
+ private scheduleNextPoll(delayMs: number): void {
544
+ if (this.poller) {
545
+ clearTimeout(this.poller);
546
+ }
547
+ const jitterMs = Math.floor(Math.random() * 400);
548
+ this.poller = setTimeout(() => {
549
+ this.pollOnce().catch(() => {});
550
+ }, Math.max(1000, delayMs + jitterMs));
551
+ }
504
552
  }
@@ -84,6 +84,10 @@ function userShimPath() {
84
84
  return resolve(userShimDir(), "silicaclaw");
85
85
  }
86
86
 
87
+ function userEnvFile() {
88
+ return resolve(homedir(), ".silicaclaw", "env.sh");
89
+ }
90
+
87
91
  function ensureLineInFile(filePath, block) {
88
92
  const current = existsSync(filePath) ? readFileSync(filePath, "utf8") : "";
89
93
  if (current.includes(block.trim())) {
@@ -95,17 +99,54 @@ function ensureLineInFile(filePath, block) {
95
99
  return true;
96
100
  }
97
101
 
102
+ function shellInitTargets() {
103
+ const home = homedir();
104
+ const shell = String(process.env.SHELL || "");
105
+ const targets = [];
106
+ const add = (filePath) => {
107
+ if (!targets.includes(filePath)) {
108
+ targets.push(filePath);
109
+ }
110
+ };
111
+
112
+ if (shell.endsWith("/zsh") || process.env.ZSH_VERSION || existsSync(resolve(home, ".zshrc"))) {
113
+ add(resolve(home, ".zshrc"));
114
+ }
115
+
116
+ // Bash login shells on macOS often read .bash_profile instead of .bashrc.
117
+ if (
118
+ shell.endsWith("/bash") ||
119
+ process.env.BASH_VERSION ||
120
+ existsSync(resolve(home, ".bashrc")) ||
121
+ existsSync(resolve(home, ".bash_profile"))
122
+ ) {
123
+ add(resolve(home, ".bashrc"));
124
+ add(resolve(home, ".bash_profile"));
125
+ }
126
+
127
+ if (targets.length === 0) {
128
+ add(preferredShellRcFile());
129
+ }
130
+ return targets;
131
+ }
132
+
98
133
  function installPersistentCommand() {
99
134
  const binDir = userShimDir();
100
135
  const shimPath = userShimPath();
101
- const rcFile = preferredShellRcFile();
102
- const markerBlock = [
103
- "# >>> silicaclaw >>>",
136
+ const envFile = userEnvFile();
137
+ const envBlock = [
138
+ "#!/usr/bin/env bash",
104
139
  'export PATH="$HOME/.silicaclaw/bin:$PATH"',
140
+ "",
141
+ ].join("\n");
142
+ const rcBlock = [
143
+ "# >>> silicaclaw >>>",
144
+ '[ -f "$HOME/.silicaclaw/env.sh" ] && . "$HOME/.silicaclaw/env.sh"',
105
145
  "# <<< silicaclaw <<<",
106
146
  ].join("\n");
107
147
 
108
148
  mkdirSync(binDir, { recursive: true });
149
+ writeFileSync(envFile, envBlock, { encoding: "utf8", mode: 0o755 });
109
150
  writeFileSync(
110
151
  shimPath,
111
152
  [
@@ -116,18 +157,23 @@ function installPersistentCommand() {
116
157
  ].join("\n"),
117
158
  { encoding: "utf8", mode: 0o755 }
118
159
  );
119
- const rcUpdated = ensureLineInFile(rcFile, markerBlock);
160
+ const rcFiles = shellInitTargets();
161
+ const updatedFiles = [];
162
+ for (const filePath of rcFiles) {
163
+ if (ensureLineInFile(filePath, rcBlock)) {
164
+ updatedFiles.push(filePath);
165
+ }
166
+ }
120
167
 
121
168
  console.log("Installed persistent `silicaclaw` command.");
122
169
  console.log(`- shim: ${shimPath}`);
123
- console.log(`- shell rc: ${rcFile}`);
170
+ console.log(`- env: ${envFile}`);
171
+ console.log(`- shell init: ${rcFiles.join(", ")}`);
124
172
  console.log("");
125
- if (rcUpdated) {
126
- console.log("To activate in the current shell:");
127
- console.log(`source "${rcFile}"`);
128
- } else {
129
- console.log("Shell PATH entry already existed.");
130
- console.log(`If needed, run: source "${rcFile}"`);
173
+ console.log("To activate in the current shell:");
174
+ console.log(`source "${envFile}"`);
175
+ if (updatedFiles.length === 0) {
176
+ console.log("Shell startup files were already configured.");
131
177
  }
132
178
  }
133
179
 
@@ -5,6 +5,7 @@ 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
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();
@@ -90,10 +91,13 @@ function cleanupRoom(roomId) {
90
91
  }
91
92
 
92
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;
93
97
  if (!room.peers.has(peerId)) {
94
- room.peers.set(peerId, { last_seen_at: now() });
98
+ room.peers.set(peerId, { last_seen_at: ts });
95
99
  } else {
96
- room.peers.get(peerId).last_seen_at = now();
100
+ room.peers.get(peerId).last_seen_at = shouldWrite ? ts : previous;
97
101
  }
98
102
  if (!room.queues.has(peerId)) {
99
103
  room.queues.set(peerId, []);
@@ -101,6 +105,7 @@ function touchPeer(room, peerId) {
101
105
  if (!room.relay_queues.has(peerId)) {
102
106
  room.relay_queues.set(peerId, []);
103
107
  }
108
+ return shouldWrite;
104
109
  }
105
110
 
106
111
  function isValidSignalPayload(body) {
@@ -198,7 +203,7 @@ const server = http.createServer(async (req, res) => {
198
203
 
199
204
  const queue = room.queues.get(peerId) || [];
200
205
  room.queues.set(peerId, []);
201
- return json(res, 200, { ok: true, messages: queue });
206
+ return json(res, 200, { ok: true, messages: queue, peers: Array.from(room.peers.keys()) });
202
207
  }
203
208
 
204
209
  if (req.method === 'GET' && url.pathname === '/relay/poll') {
@@ -215,7 +220,7 @@ const server = http.createServer(async (req, res) => {
215
220
 
216
221
  const queue = room.relay_queues.get(peerId) || [];
217
222
  room.relay_queues.set(peerId, []);
218
- return json(res, 200, { ok: true, messages: queue });
223
+ return json(res, 200, { ok: true, messages: queue, peers: Array.from(room.peers.keys()) });
219
224
  }
220
225
 
221
226
  if (req.method === 'POST' && url.pathname === '/join') {