nanobazaar-cli 2.0.0 → 2.0.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.
package/CHANGELOG.md CHANGED
@@ -4,6 +4,20 @@ 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.2] - 2026-02-08
8
+
9
+ ### Fixed
10
+ - `job mark-paid` now defaults to an idempotency key derived from the request payload (prevents `409 idempotency collision` when retrying with updated evidence).
11
+
12
+ ### Added
13
+ - `NBR_IDEMPOTENCY_KEY` env override for commands that accept `--idempotency-key` (`job charge|mark-paid|deliver|reissue-charge`).
14
+
15
+ ## [2.0.1] - 2026-02-08
16
+
17
+ ### Changed
18
+ - `nanobazaar watch` is now notifier-only: it keeps an SSE connection and wakes OpenClaw on relay wake events + a safety interval, but does not poll or ack.
19
+ - Added `--safety-wake-interval` (alias for `--safety-poll-interval`) to reflect notifier semantics.
20
+
7
21
  ## [2.0.0] - 2026-02-08
8
22
 
9
23
  ### Added
package/bin/nanobazaar CHANGED
@@ -74,6 +74,35 @@ function sha256Hex(buffer) {
74
74
  return crypto.createHash('sha256').update(buffer).digest('hex');
75
75
  }
76
76
 
77
+ function stableJsonNormalize(value) {
78
+ if (value === undefined) {
79
+ return null;
80
+ }
81
+ if (value === null || typeof value !== 'object') {
82
+ return value;
83
+ }
84
+ if (typeof value.toJSON === 'function') {
85
+ return stableJsonNormalize(value.toJSON());
86
+ }
87
+ if (Array.isArray(value)) {
88
+ return value.map((entry) => stableJsonNormalize(entry));
89
+ }
90
+ const out = {};
91
+ for (const key of Object.keys(value).sort()) {
92
+ const entry = value[key];
93
+ // Match JSON.stringify: omit undefined values in objects.
94
+ if (entry === undefined) {
95
+ continue;
96
+ }
97
+ out[key] = stableJsonNormalize(entry);
98
+ }
99
+ return out;
100
+ }
101
+
102
+ function stableJsonStringify(value) {
103
+ return JSON.stringify(stableJsonNormalize(value));
104
+ }
105
+
77
106
  function loadState(filePath) {
78
107
  try {
79
108
  const raw = fs.readFileSync(filePath, 'utf8');
@@ -289,6 +318,17 @@ function getEnvValue(name) {
289
318
  return value && value.trim() ? value.trim() : '';
290
319
  }
291
320
 
321
+ function resolveIdempotencyKey(flags, fallback) {
322
+ if (flags && flags.idempotencyKey) {
323
+ return String(flags.idempotencyKey);
324
+ }
325
+ const envKey = getEnvValue('NBR_IDEMPOTENCY_KEY');
326
+ if (envKey) {
327
+ return envKey;
328
+ }
329
+ return fallback ? String(fallback) : '';
330
+ }
331
+
292
332
  function expandHomePath(value) {
293
333
  if (!value) {
294
334
  return value;
@@ -1235,11 +1275,11 @@ Commands:
1235
1275
  Poll events and optionally ack
1236
1276
  poll ack --up-to-event-id <id>
1237
1277
  Advance the server-side poll cursor (used for 410 resync)
1238
- watch [--stream-path /v0/stream] [--safety-poll-interval <seconds>]
1278
+ watch [--stream-path /v0/stream] [--safety-wake-interval <seconds>]
1239
1279
  [--state-path <path>] [--openclaw-bin <bin>] [--event-text <text>]
1240
- [--mode now|next] [--no-openclaw] [--no-fetch-payloads]
1241
- Maintain SSE connection; poll on wake + on safety interval.
1242
- Triggers an OpenClaw wakeup when new events are persisted.
1280
+ [--mode now|next] [--no-openclaw]
1281
+ Maintain SSE connection; wake OpenClaw on relay wake events + on a safety interval.
1282
+ Does not poll or ack (OpenClaw should run /nanobazaar poll).
1243
1283
 
1244
1284
  Global flags:
1245
1285
  --help Show this help
@@ -1990,7 +2030,7 @@ async function runJobCharge(argv) {
1990
2030
  method: 'POST',
1991
2031
  path: `/v0/jobs/${jobId}/charge`,
1992
2032
  body: payload,
1993
- idempotencyKey: String(flags.idempotencyKey || chargeId),
2033
+ idempotencyKey: resolveIdempotencyKey(flags, chargeId),
1994
2034
  relayUrl: config.relay_url,
1995
2035
  keys,
1996
2036
  identity,
@@ -2060,11 +2100,15 @@ async function runJobMarkPaid(argv) {
2060
2100
  const {keys} = requireKeys(state);
2061
2101
  const identity = deriveIdentity(keys);
2062
2102
 
2103
+ const requestBody = payload && Object.keys(payload).length > 0 ? payload : undefined;
2104
+ const bodyHash = sha256Hex(stableJsonStringify(requestBody)).slice(0, 16);
2105
+ const defaultIdempotencyKey = `mark_paid:${jobId}:${bodyHash}`;
2106
+
2063
2107
  const result = await signedRequest({
2064
2108
  method: 'POST',
2065
2109
  path: `/v0/jobs/${jobId}/mark_paid`,
2066
- body: payload && Object.keys(payload).length > 0 ? payload : undefined,
2067
- idempotencyKey: String(flags.idempotencyKey || `mark_paid:${jobId}`),
2110
+ body: requestBody,
2111
+ idempotencyKey: resolveIdempotencyKey(flags, defaultIdempotencyKey),
2068
2112
  relayUrl: config.relay_url,
2069
2113
  keys,
2070
2114
  identity,
@@ -2144,7 +2188,7 @@ async function runJobDeliver(argv) {
2144
2188
  method: 'POST',
2145
2189
  path: `/v0/jobs/${jobId}/deliver`,
2146
2190
  body: {payload: built.envelope},
2147
- idempotencyKey: String(flags.idempotencyKey || payloadId),
2191
+ idempotencyKey: resolveIdempotencyKey(flags, payloadId),
2148
2192
  relayUrl: config.relay_url,
2149
2193
  keys,
2150
2194
  identity,
@@ -2272,7 +2316,7 @@ async function runJobReissueCharge(argv) {
2272
2316
  method: 'POST',
2273
2317
  path: `/v0/jobs/${jobId}/charge/reissue`,
2274
2318
  body: payload,
2275
- idempotencyKey: String(flags.idempotencyKey || payload.charge_id),
2319
+ idempotencyKey: resolveIdempotencyKey(flags, payload.charge_id),
2276
2320
  relayUrl: config.relay_url,
2277
2321
  keys,
2278
2322
  identity,
@@ -2987,14 +3031,15 @@ async function runWatch(argv) {
2987
3031
  throw new Error('Local fswatch wakeups are removed. Remove --fswatch-bin/--debounce-ms and retry.');
2988
3032
  }
2989
3033
 
2990
- const safetyIntervalSeconds = flags.safetyPollInterval
2991
- ? parsePositiveInt(flags.safetyPollInterval, '--safety-poll-interval')
2992
- : 180;
3034
+ if (flags.ack !== undefined || flags.fetchPayloads !== undefined || flags.limit !== undefined || flags.output !== undefined || flags.printPolls !== undefined || flags.types !== undefined) {
3035
+ throw new Error('watch no longer polls. Remove polling flags (--no-ack/--no-fetch-payloads/--limit/--types/--print-polls/--output) and retry.');
3036
+ }
2993
3037
 
2994
- const printPolls = !!flags.printPolls;
2995
- const pollLimit = parseOptionalPositiveInt(flags.limit, '--limit')
2996
- ?? parseOptionalPositiveInt(config.poll_limit, 'NBR_POLL_LIMIT');
2997
- const pollTypes = flags.types || config.poll_types;
3038
+ const safetyIntervalSeconds = flags.safetyWakeInterval
3039
+ ? parsePositiveInt(flags.safetyWakeInterval, '--safety-wake-interval')
3040
+ : flags.safetyPollInterval
3041
+ ? parsePositiveInt(flags.safetyPollInterval, '--safety-poll-interval')
3042
+ : 180;
2998
3043
 
2999
3044
  const openclawBin = String(flags.openclawBin || 'openclaw');
3000
3045
  const mode = String(flags.mode || 'now');
@@ -3005,22 +3050,18 @@ async function runWatch(argv) {
3005
3050
  relay_url: config.relay_url,
3006
3051
  state_path: statePath,
3007
3052
  stream_path: streamPath,
3008
- safety_poll_interval_seconds: safetyIntervalSeconds,
3009
- ack: flags.ack !== false,
3010
- poll_limit: pollLimit ?? null,
3011
- poll_types: pollTypes ?? null,
3012
- print_polls: printPolls,
3053
+ safety_wake_interval_seconds: safetyIntervalSeconds,
3013
3054
  openclaw_enabled: openclawEnabled,
3014
3055
  });
3015
3056
 
3016
- let pollInFlight = false;
3017
- let pollQueued = false;
3018
- let queuedReason = null;
3019
3057
  let openclawMissing = false;
3058
+ let wakeInFlight = false;
3059
+ let wakeQueued = false;
3060
+ let queuedReason = null;
3020
3061
 
3021
3062
  function triggerOpenclawWake(reason) {
3022
3063
  if (!openclawEnabled || openclawMissing) {
3023
- return;
3064
+ return false;
3024
3065
  }
3025
3066
  if (debugLog) {
3026
3067
  debugLog('openclaw wake', {reason, openclaw_bin: openclawBin, mode, event_text: eventText});
@@ -3032,53 +3073,30 @@ async function runWatch(argv) {
3032
3073
  if (debugLog) {
3033
3074
  debugLog('openclaw wake failed', result.error && result.error.message ? result.error.message : String(result.error));
3034
3075
  }
3076
+ return false;
3035
3077
  }
3078
+ return true;
3036
3079
  }
3037
3080
 
3038
- async function runPollLoop(reason) {
3081
+ async function runWakeLoop(reason) {
3039
3082
  queuedReason = reason;
3040
- pollQueued = true;
3041
- if (pollInFlight) {
3083
+ wakeQueued = true;
3084
+ if (wakeInFlight) {
3042
3085
  return;
3043
3086
  }
3044
- pollInFlight = true;
3087
+ wakeInFlight = true;
3045
3088
  try {
3046
- while (pollQueued) {
3047
- pollQueued = false;
3089
+ while (wakeQueued) {
3090
+ wakeQueued = false;
3048
3091
  const label = queuedReason || reason;
3049
3092
  queuedReason = null;
3050
- try {
3051
- const {response, events, addedEvents, ackedId} = await pollOnce({
3052
- config,
3053
- state,
3054
- statePath,
3055
- keys,
3056
- identity,
3057
- since: undefined,
3058
- limit: pollLimit,
3059
- types: pollTypes,
3060
- ack: flags.ack !== false,
3061
- fetchPayloads: flags.fetchPayloads !== false,
3062
- quiet: true,
3063
- compact: !!flags.compact,
3064
- debugLog,
3065
- });
3066
3093
 
3067
- if (printPolls && response) {
3068
- printJson(response, !!flags.compact);
3069
- }
3070
-
3071
- console.error(`[watch] poll ok (${label}): events=${events.length} added=${addedEvents} ack=${flags.ack === false ? 'disabled' : ackedId}`);
3072
- if (addedEvents > 0) {
3073
- triggerOpenclawWake(label);
3074
- }
3075
- } catch (err) {
3076
- console.error(`[watch] poll error (${label}): ${err && err.message ? err.message : String(err)}`);
3077
- // Keep running; polling is idempotent and can be retried on the next wake/safety interval.
3078
- }
3094
+ // Best-effort: waking OpenClaw can be retried on the next wake/safety interval.
3095
+ const didWake = triggerOpenclawWake(label);
3096
+ console.error(`[watch] wake ${didWake ? 'ok' : 'skipped'} (${label})`);
3079
3097
  }
3080
3098
  } finally {
3081
- pollInFlight = false;
3099
+ wakeInFlight = false;
3082
3100
  }
3083
3101
  }
3084
3102
 
@@ -3096,17 +3114,17 @@ async function runWatch(argv) {
3096
3114
  console.error(`[watch] relay=${config.relay_url}`);
3097
3115
  console.error(`[watch] state_path=${statePath}`);
3098
3116
  console.error(`[watch] stream_path=${streamPath}`);
3099
- console.error(`[watch] safety_poll_interval_seconds=${safetyIntervalSeconds}`);
3117
+ console.error(`[watch] safety_wake_interval_seconds=${safetyIntervalSeconds}`);
3100
3118
 
3101
3119
  const safetyTimer = setInterval(() => {
3102
3120
  if (signal.aborted) {
3103
3121
  return;
3104
3122
  }
3105
- void runPollLoop('safety');
3123
+ void runWakeLoop('safety');
3106
3124
  }, safetyIntervalSeconds * 1000);
3107
3125
 
3108
- // Kick off an initial poll so the watcher is useful even if wakeups are missed.
3109
- void runPollLoop('startup');
3126
+ // Kick off an initial wake so the agent can poll even if wakeups are missed.
3127
+ void runWakeLoop('startup');
3110
3128
 
3111
3129
  let attempt = 0;
3112
3130
  function isWakeEvent(evt) {
@@ -3160,7 +3178,7 @@ async function runWatch(argv) {
3160
3178
  });
3161
3179
  if (isWakeEvent(evt)) {
3162
3180
  debugLog('wake detected', {});
3163
- void runPollLoop('wake');
3181
+ void runWakeLoop('wake');
3164
3182
  }
3165
3183
  },
3166
3184
  });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nanobazaar-cli",
3
- "version": "2.0.0",
3
+ "version": "2.0.2",
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",