nanobazaar-cli 2.0.3 → 2.0.4

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
@@ -4,6 +4,11 @@ All notable changes to `nanobazaar-cli` are documented in this file.
4
4
 
5
5
  This project follows Semantic Versioning.
6
6
 
7
+ ## [2.0.4] - 2026-02-11
8
+
9
+ ### Fixed
10
+ - `nanobazaar watch` now reconnects SSE loops with backoff + jitter and includes health logs.
11
+
7
12
  ## [2.0.3] - 2026-02-09
8
13
 
9
14
  ### Changed
package/bin/nanobazaar CHANGED
@@ -32,6 +32,11 @@ const CONFIG_BASE_DIR = XDG_CONFIG_HOME || path.join(HOME_DIR, '.config');
32
32
  const STATE_DEFAULT = path.join(CONFIG_BASE_DIR, 'nanobazaar', 'nanobazaar.json');
33
33
  const STATE_LOCK_RETRY_MS = 50;
34
34
  const STATE_LOCK_TIMEOUT_MS = 5000;
35
+ const WATCH_RECONNECT_BASE_MS = 1000;
36
+ const WATCH_RECONNECT_FACTOR = 2;
37
+ const WATCH_RECONNECT_CAP_MS = 60000;
38
+ const WATCH_RECONNECT_JITTER_RATIO = 0.2;
39
+ const WATCH_RECONNECT_STABLE_WINDOW_MS = 30000;
35
40
  let STATE_LOCK_SLEEP = null;
36
41
 
37
42
  function requireFetch() {
@@ -2905,11 +2910,44 @@ function appendEvents(state, events) {
2905
2910
  }
2906
2911
 
2907
2912
  function backoffDelayMs(attempt, opts) {
2908
- const baseMs = opts && opts.baseMs ? opts.baseMs : 500;
2909
- const maxMs = opts && opts.maxMs ? opts.maxMs : 30000;
2910
- const exp = Math.min(maxMs, baseMs * (2 ** Math.max(0, attempt)));
2911
- const jittered = exp * (0.5 + Math.random());
2912
- return Math.min(maxMs, Math.max(baseMs, Math.floor(jittered)));
2913
+ const baseMs = opts && opts.baseMs ? opts.baseMs : WATCH_RECONNECT_BASE_MS;
2914
+ const factor = opts && opts.factor ? opts.factor : WATCH_RECONNECT_FACTOR;
2915
+ const capMs = opts && (opts.capMs || opts.maxMs) ? (opts.capMs || opts.maxMs) : WATCH_RECONNECT_CAP_MS;
2916
+ const jitterRatio = opts && typeof opts.jitterRatio === 'number'
2917
+ ? Math.min(1, Math.max(0, opts.jitterRatio))
2918
+ : WATCH_RECONNECT_JITTER_RATIO;
2919
+ const randomFn = opts && typeof opts.random === 'function' ? opts.random : Math.random;
2920
+ const safeAttempt = Math.max(0, Math.floor(attempt));
2921
+ const exponent = Math.min(capMs, baseMs * (factor ** safeAttempt));
2922
+ const jitterOffset = jitterRatio === 0 ? 0 : ((randomFn() * 2) - 1) * jitterRatio;
2923
+ const jittered = exponent * (1 + jitterOffset);
2924
+ return Math.min(capMs, Math.max(baseMs, Math.round(jittered)));
2925
+ }
2926
+
2927
+ function planReconnect(attempt, opts) {
2928
+ const connectedDurationMs = opts && typeof opts.connectedDurationMs === 'number'
2929
+ ? Math.max(0, Math.floor(opts.connectedDurationMs))
2930
+ : null;
2931
+ const stableWindowMs = opts && opts.stableWindowMs ? opts.stableWindowMs : WATCH_RECONNECT_STABLE_WINDOW_MS;
2932
+ const reason = opts && opts.reason ? String(opts.reason) : 'unknown';
2933
+ let effectiveAttempt = Math.max(0, Math.floor(attempt));
2934
+ let reset = false;
2935
+
2936
+ if (connectedDurationMs !== null && connectedDurationMs >= stableWindowMs && effectiveAttempt > 0) {
2937
+ effectiveAttempt = 0;
2938
+ reset = true;
2939
+ }
2940
+
2941
+ const delayMs = backoffDelayMs(effectiveAttempt, opts && opts.backoff);
2942
+ return {
2943
+ attemptNumber: effectiveAttempt + 1,
2944
+ nextAttempt: effectiveAttempt + 1,
2945
+ delayMs,
2946
+ reason,
2947
+ reset,
2948
+ connectedDurationMs,
2949
+ stableWindowMs,
2950
+ };
2913
2951
  }
2914
2952
 
2915
2953
  function sleep(ms, signal) {
@@ -3112,7 +3150,25 @@ async function runWatch(argv) {
3112
3150
  console.error(`[watch] state_path=${statePath}`);
3113
3151
  console.error(`[watch] stream_path=${streamPath}`);
3114
3152
 
3115
- let attempt = 0;
3153
+ console.error(`[watch] streams=${streams.join(',')}`);
3154
+ console.error(`[watch] safety_poll_interval_seconds=${safetyIntervalSeconds}`);
3155
+ console.error(`[watch] reconnect_backoff_base_ms=${WATCH_RECONNECT_BASE_MS}`);
3156
+ console.error(`[watch] reconnect_backoff_factor=${WATCH_RECONNECT_FACTOR}`);
3157
+ console.error(`[watch] reconnect_backoff_cap_ms=${WATCH_RECONNECT_CAP_MS}`);
3158
+ console.error(`[watch] reconnect_backoff_jitter_ratio=${WATCH_RECONNECT_JITTER_RATIO}`);
3159
+ console.error(`[watch] reconnect_stable_window_ms=${WATCH_RECONNECT_STABLE_WINDOW_MS}`);
3160
+
3161
+ const safetyTimer = setInterval(() => {
3162
+ if (signal.aborted) {
3163
+ return;
3164
+ }
3165
+ void runPollLoop('safety');
3166
+ }, safetyIntervalSeconds * 1000);
3167
+
3168
+ // Kick off an initial poll so the watcher is useful even if wakeups are missed.
3169
+ void runPollLoop('startup');
3170
+
3171
+ let reconnectAttempt = 0;
3116
3172
  function isWakeEvent(evt) {
3117
3173
  if (!evt) {
3118
3174
  return false;
@@ -3151,8 +3207,8 @@ async function runWatch(argv) {
3151
3207
  throw new Error(`SSE connect failed (${response.status}): ${text || response.statusText}`);
3152
3208
  }
3153
3209
 
3154
- attempt = 0;
3155
3210
  console.error('[watch] connected');
3211
+ const connectedAt = Date.now();
3156
3212
 
3157
3213
  await consumeSseStream(response.body, {
3158
3214
  signal,
@@ -3173,17 +3229,28 @@ async function runWatch(argv) {
3173
3229
  break;
3174
3230
  }
3175
3231
 
3176
- console.error('[watch] disconnected');
3232
+ const disconnectedDurationMs = Math.max(0, Date.now() - connectedAt);
3233
+ const disconnectPlan = planReconnect(reconnectAttempt, {
3234
+ reason: 'SSE stream disconnected',
3235
+ connectedDurationMs: disconnectedDurationMs,
3236
+ });
3237
+ reconnectAttempt = disconnectPlan.nextAttempt;
3238
+ if (disconnectPlan.reset) {
3239
+ console.error(`[watch] reconnect backoff reset after stable connection (${disconnectPlan.connectedDurationMs}ms >= ${disconnectPlan.stableWindowMs}ms)`);
3240
+ }
3241
+ console.error(`[watch] reconnect health: attempt=${disconnectPlan.attemptNumber} delay_ms=${disconnectPlan.delayMs} reason=${disconnectPlan.reason} connected_ms=${disconnectPlan.connectedDurationMs}`);
3242
+ await sleep(disconnectPlan.delayMs, signal);
3177
3243
  } catch (err) {
3178
3244
  if (signal.aborted) {
3179
3245
  break;
3180
3246
  }
3181
3247
  const message = err && err.message ? err.message : String(err);
3182
- const delayMs = backoffDelayMs(attempt);
3183
- attempt += 1;
3184
- console.error(`[watch] sse error: ${message}`);
3185
- console.error(`[watch] reconnecting in ${Math.round(delayMs / 1000)}s`);
3186
- await sleep(delayMs, signal);
3248
+ const errorPlan = planReconnect(reconnectAttempt, {
3249
+ reason: message,
3250
+ });
3251
+ reconnectAttempt = errorPlan.nextAttempt;
3252
+ console.error(`[watch] reconnect health: attempt=${errorPlan.attemptNumber} delay_ms=${errorPlan.delayMs} reason=${errorPlan.reason} connected_ms=unknown`);
3253
+ await sleep(errorPlan.delayMs, signal);
3187
3254
  }
3188
3255
  }
3189
3256
 
@@ -3429,7 +3496,19 @@ async function main() {
3429
3496
  }
3430
3497
  }
3431
3498
 
3432
- main().catch((err) => {
3433
- console.error(err.message || err);
3434
- process.exit(1);
3435
- });
3499
+ if (require.main === module) {
3500
+ main().catch((err) => {
3501
+ console.error(err.message || err);
3502
+ process.exit(1);
3503
+ });
3504
+ } else {
3505
+ module.exports = {
3506
+ backoffDelayMs,
3507
+ planReconnect,
3508
+ WATCH_RECONNECT_BASE_MS,
3509
+ WATCH_RECONNECT_FACTOR,
3510
+ WATCH_RECONNECT_CAP_MS,
3511
+ WATCH_RECONNECT_JITTER_RATIO,
3512
+ WATCH_RECONNECT_STABLE_WINDOW_MS,
3513
+ };
3514
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nanobazaar-cli",
3
- "version": "2.0.3",
3
+ "version": "2.0.4",
4
4
  "description": "NanoBazaar CLI for the NanoBazaar Relay and OpenClaw skill.",
5
5
  "homepage": "https://github.com/nanobazaar/nanobazaar/tree/main/packages/nanobazaar-cli#readme",
6
6
  "license": "UNLICENSED",