aicq-openclaw-plugin 1.2.1 → 1.3.0

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/dist/index.js CHANGED
@@ -7212,9 +7212,9 @@ var require_nacl_util = __commonJS({
7212
7212
  }
7213
7213
  });
7214
7214
 
7215
- // node_modules/@aicq/crypto/dist/nacl.js
7215
+ // ../shared/crypto/dist/nacl.js
7216
7216
  var require_nacl = __commonJS({
7217
- "node_modules/@aicq/crypto/dist/nacl.js"(exports) {
7217
+ "../shared/crypto/dist/nacl.js"(exports) {
7218
7218
  "use strict";
7219
7219
  var __importDefault = exports && exports.__importDefault || function(mod) {
7220
7220
  return mod && mod.__esModule ? mod : { "default": mod };
@@ -7231,9 +7231,9 @@ var require_nacl = __commonJS({
7231
7231
  }
7232
7232
  });
7233
7233
 
7234
- // node_modules/@aicq/crypto/dist/keygen.js
7234
+ // ../shared/crypto/dist/keygen.js
7235
7235
  var require_keygen = __commonJS({
7236
- "node_modules/@aicq/crypto/dist/keygen.js"(exports) {
7236
+ "../shared/crypto/dist/keygen.js"(exports) {
7237
7237
  "use strict";
7238
7238
  Object.defineProperty(exports, "__esModule", { value: true });
7239
7239
  exports.generateSigningKeyPair = generateSigningKeyPair2;
@@ -7272,9 +7272,9 @@ var require_keygen = __commonJS({
7272
7272
  }
7273
7273
  });
7274
7274
 
7275
- // node_modules/@aicq/crypto/dist/signer.js
7275
+ // ../shared/crypto/dist/signer.js
7276
7276
  var require_signer = __commonJS({
7277
- "node_modules/@aicq/crypto/dist/signer.js"(exports) {
7277
+ "../shared/crypto/dist/signer.js"(exports) {
7278
7278
  "use strict";
7279
7279
  Object.defineProperty(exports, "__esModule", { value: true });
7280
7280
  exports.sign = sign;
@@ -7289,9 +7289,9 @@ var require_signer = __commonJS({
7289
7289
  }
7290
7290
  });
7291
7291
 
7292
- // node_modules/@aicq/crypto/dist/keyExchange.js
7292
+ // ../shared/crypto/dist/keyExchange.js
7293
7293
  var require_keyExchange = __commonJS({
7294
- "node_modules/@aicq/crypto/dist/keyExchange.js"(exports) {
7294
+ "../shared/crypto/dist/keyExchange.js"(exports) {
7295
7295
  "use strict";
7296
7296
  Object.defineProperty(exports, "__esModule", { value: true });
7297
7297
  exports.computeSharedSecret = computeSharedSecret2;
@@ -7342,9 +7342,9 @@ var require_keyExchange = __commonJS({
7342
7342
  }
7343
7343
  });
7344
7344
 
7345
- // node_modules/@aicq/crypto/dist/cipher.js
7345
+ // ../shared/crypto/dist/cipher.js
7346
7346
  var require_cipher = __commonJS({
7347
- "node_modules/@aicq/crypto/dist/cipher.js"(exports) {
7347
+ "../shared/crypto/dist/cipher.js"(exports) {
7348
7348
  "use strict";
7349
7349
  Object.defineProperty(exports, "__esModule", { value: true });
7350
7350
  exports.generateNonce = generateNonce;
@@ -7368,9 +7368,9 @@ var require_cipher = __commonJS({
7368
7368
  }
7369
7369
  });
7370
7370
 
7371
- // node_modules/@aicq/crypto/dist/message.js
7371
+ // ../shared/crypto/dist/message.js
7372
7372
  var require_message = __commonJS({
7373
- "node_modules/@aicq/crypto/dist/message.js"(exports) {
7373
+ "../shared/crypto/dist/message.js"(exports) {
7374
7374
  "use strict";
7375
7375
  Object.defineProperty(exports, "__esModule", { value: true });
7376
7376
  exports.createMessage = createMessage;
@@ -7484,9 +7484,9 @@ var require_message = __commonJS({
7484
7484
  }
7485
7485
  });
7486
7486
 
7487
- // node_modules/@aicq/crypto/dist/password.js
7487
+ // ../shared/crypto/dist/password.js
7488
7488
  var require_password = __commonJS({
7489
- "node_modules/@aicq/crypto/dist/password.js"(exports) {
7489
+ "../shared/crypto/dist/password.js"(exports) {
7490
7490
  "use strict";
7491
7491
  var __createBinding = exports && exports.__createBinding || (Object.create ? (function(o, m, k, k2) {
7492
7492
  if (k2 === void 0) k2 = k;
@@ -7564,9 +7564,9 @@ var require_password = __commonJS({
7564
7564
  }
7565
7565
  });
7566
7566
 
7567
- // node_modules/@aicq/crypto/dist/handshake.js
7567
+ // ../shared/crypto/dist/handshake.js
7568
7568
  var require_handshake = __commonJS({
7569
- "node_modules/@aicq/crypto/dist/handshake.js"(exports) {
7569
+ "../shared/crypto/dist/handshake.js"(exports) {
7570
7570
  "use strict";
7571
7571
  Object.defineProperty(exports, "__esModule", { value: true });
7572
7572
  exports.createHandshakeRequest = createHandshakeRequest2;
@@ -7675,9 +7675,9 @@ var require_handshake = __commonJS({
7675
7675
  }
7676
7676
  });
7677
7677
 
7678
- // node_modules/@aicq/crypto/dist/index.js
7678
+ // ../shared/crypto/dist/index.js
7679
7679
  var require_dist = __commonJS({
7680
- "node_modules/@aicq/crypto/dist/index.js"(exports) {
7680
+ "../shared/crypto/dist/index.js"(exports) {
7681
7681
  "use strict";
7682
7682
  Object.defineProperty(exports, "__esModule", { value: true });
7683
7683
  exports.completeHandshake = exports.createHandshakeResponse = exports.createHandshakeRequest = exports.decryptWithPassword = exports.encryptWithPassword = exports.decryptMessage = exports.encryptMessage = exports.parseMessage = exports.createMessage = exports.generateNonce = exports.decrypt = exports.encrypt = exports.deriveSessionKey = exports.computeSharedSecret = exports.verify = exports.sign = exports.getPublicKeyFingerprint = exports.deriveX25519FromEd25519 = exports.generateKeyExchangeKeyPair = exports.generateSigningKeyPair = exports.encodeBase64 = exports.decodeBase64 = exports.encodeUTF8 = exports.decodeUTF8 = exports.nacl = void 0;
@@ -7913,9 +7913,13 @@ var PluginStore = class {
7913
7913
  this.tempNumbers = [];
7914
7914
  this.pendingRequests = [];
7915
7915
  this.pendingHandshakes = /* @__PURE__ */ new Map();
7916
+ this.offlineMessages = [];
7916
7917
  this.dataDir = "";
7917
7918
  this.storePath = "";
7918
7919
  this.encryptionSalt = crypto3.randomBytes(16);
7920
+ this.saveTimer = null;
7921
+ this.saveDebounceMs = 1e3;
7922
+ this.dirty = false;
7919
7923
  }
7920
7924
  /**
7921
7925
  * Set the data directory for persistence.
@@ -7934,12 +7938,38 @@ var PluginStore = class {
7934
7938
  return deriveEncryptionKey(this.agentId || "default-aicq-node", this.encryptionSalt);
7935
7939
  }
7936
7940
  /**
7937
- * Save the current state to disk.
7941
+ * Mark the store as dirty and schedule a debounced save.
7942
+ * For operations that need immediate persistence (e.g., new keys), use saveNow().
7943
+ */
7944
+ markDirty() {
7945
+ this.dirty = true;
7946
+ if (!this.saveTimer) {
7947
+ this.saveTimer = setTimeout(() => {
7948
+ this.saveTimer = null;
7949
+ this.flushSave();
7950
+ }, this.saveDebounceMs);
7951
+ }
7952
+ }
7953
+ /**
7954
+ * Save the current state to disk immediately (atomic write).
7938
7955
  *
7939
- * Secret keys are encrypted at rest using AES-256-GCM with a key
7940
- * derived from the node ID and a stored random salt.
7956
+ * Uses write-to-temp + rename for crash safety. Secret keys are encrypted
7957
+ * at rest using AES-256-GCM with a key derived from the node ID and salt.
7941
7958
  */
7942
- save() {
7959
+ saveNow() {
7960
+ if (this.saveTimer) {
7961
+ clearTimeout(this.saveTimer);
7962
+ this.saveTimer = null;
7963
+ }
7964
+ this.flushSave();
7965
+ }
7966
+ /**
7967
+ * Internal save implementation — writes atomically via temp file + rename.
7968
+ */
7969
+ flushSave() {
7970
+ if (!this.dirty && this.saveTimer === null)
7971
+ return;
7972
+ this.dirty = false;
7943
7973
  if (!this.storePath)
7944
7974
  return;
7945
7975
  const encKey = this.getEncryptionKey();
@@ -7980,25 +8010,43 @@ var PluginStore = class {
7980
8010
  requesterId: p.requesterId,
7981
8011
  tempNumber: p.tempNumber,
7982
8012
  timestamp: p.timestamp.toISOString()
7983
- }))
8013
+ })),
8014
+ offlineMessages: this.offlineMessages
7984
8015
  };
7985
8016
  try {
7986
- fs2.writeFileSync(this.storePath, JSON.stringify(serialized, null, 2), "utf-8");
8017
+ const tmpPath = this.storePath + ".tmp";
8018
+ const jsonStr = JSON.stringify(serialized, null, 2);
8019
+ fs2.writeFileSync(tmpPath, jsonStr, "utf-8");
8020
+ fs2.renameSync(tmpPath, this.storePath);
7987
8021
  } catch (err) {
7988
8022
  console.error("[PluginStore] Failed to save state:", err);
7989
8023
  }
7990
8024
  }
8025
+ /**
8026
+ * @deprecated Use markDirty() or saveNow() for better performance.
8027
+ * Kept for backward compatibility — performs immediate save.
8028
+ */
8029
+ save() {
8030
+ this.saveNow();
8031
+ }
7991
8032
  /**
7992
8033
  * Load state from disk, if available.
7993
8034
  *
7994
8035
  * Decrypts secret keys using AES-256-GCM with the stored salt and
7995
- * node ID. Falls back to plaintext if the data was stored before
8036
+ * node ID. Falls back to plaintext if the data was stored before
7996
8037
  * encryption was added (backward compatibility).
7997
8038
  */
7998
8039
  load() {
7999
8040
  if (!this.storePath || !fs2.existsSync(this.storePath)) {
8000
8041
  return false;
8001
8042
  }
8043
+ const tmpPath = this.storePath + ".tmp";
8044
+ if (!fs2.existsSync(this.storePath) && fs2.existsSync(tmpPath)) {
8045
+ try {
8046
+ fs2.renameSync(tmpPath, this.storePath);
8047
+ } catch {
8048
+ }
8049
+ }
8002
8050
  try {
8003
8051
  const raw = fs2.readFileSync(this.storePath, "utf-8");
8004
8052
  const data = JSON.parse(raw);
@@ -8061,18 +8109,101 @@ var PluginStore = class {
8061
8109
  tempNumber: p.tempNumber,
8062
8110
  timestamp: new Date(p.timestamp)
8063
8111
  }));
8112
+ this.offlineMessages = (data.offlineMessages || []).map((m) => ({
8113
+ id: m.id,
8114
+ targetId: m.targetId,
8115
+ encryptedData: m.encryptedData,
8116
+ timestamp: m.timestamp,
8117
+ retryCount: m.retryCount || 0,
8118
+ maxRetries: m.maxRetries || 10
8119
+ }));
8120
+ if (fs2.existsSync(tmpPath)) {
8121
+ try {
8122
+ fs2.unlinkSync(tmpPath);
8123
+ } catch {
8124
+ }
8125
+ }
8064
8126
  return true;
8065
8127
  } catch (err) {
8066
8128
  console.error("[PluginStore] Failed to load state:", err);
8129
+ if (fs2.existsSync(tmpPath)) {
8130
+ console.info("[PluginStore] Attempting recovery from temp file...");
8131
+ try {
8132
+ fs2.renameSync(tmpPath, this.storePath);
8133
+ return this.load();
8134
+ } catch {
8135
+ console.error("[PluginStore] Recovery from temp file failed");
8136
+ }
8137
+ }
8067
8138
  return false;
8068
8139
  }
8069
8140
  }
8141
+ // ----------------------------------------------------------------
8142
+ // Offline message queue
8143
+ // ----------------------------------------------------------------
8144
+ /**
8145
+ * Add a message to the offline queue for later delivery.
8146
+ */
8147
+ enqueueOfflineMessage(targetId, encryptedData) {
8148
+ const msg = {
8149
+ id: v4_default(),
8150
+ targetId,
8151
+ encryptedData,
8152
+ timestamp: Date.now(),
8153
+ retryCount: 0,
8154
+ maxRetries: 10
8155
+ };
8156
+ this.offlineMessages.push(msg);
8157
+ this.markDirty();
8158
+ return msg;
8159
+ }
8160
+ /**
8161
+ * Dequeue the next pending offline message.
8162
+ */
8163
+ dequeueOfflineMessage() {
8164
+ return this.offlineMessages.shift();
8165
+ }
8166
+ /**
8167
+ * Get the number of pending offline messages.
8168
+ */
8169
+ getOfflineMessageCount() {
8170
+ return this.offlineMessages.length;
8171
+ }
8172
+ /**
8173
+ * Peek at the offline message queue without removing.
8174
+ */
8175
+ peekOfflineMessages() {
8176
+ return [...this.offlineMessages];
8177
+ }
8178
+ /**
8179
+ * Clear all pending offline messages.
8180
+ */
8181
+ clearOfflineMessages() {
8182
+ this.offlineMessages = [];
8183
+ this.markDirty();
8184
+ }
8185
+ /**
8186
+ * Remove expired offline messages (older than 24 hours).
8187
+ */
8188
+ cleanupExpiredOfflineMessages() {
8189
+ const now = Date.now();
8190
+ const threshold = 24 * 60 * 60 * 1e3;
8191
+ const before = this.offlineMessages.length;
8192
+ this.offlineMessages = this.offlineMessages.filter((m) => now - m.timestamp < threshold);
8193
+ if (this.offlineMessages.length !== before) {
8194
+ this.markDirty();
8195
+ }
8196
+ return before - this.offlineMessages.length;
8197
+ }
8198
+ // ----------------------------------------------------------------
8199
+ // Friend management
8200
+ // ----------------------------------------------------------------
8070
8201
  /**
8071
8202
  * Add a friend record.
8072
8203
  */
8073
8204
  addFriend(friend) {
8074
8205
  this.friends.set(friend.id, friend);
8075
- this.save();
8206
+ this.markDirty();
8076
8207
  }
8077
8208
  /**
8078
8209
  * Remove a friend by ID and clean up associated session.
@@ -8081,7 +8212,7 @@ var PluginStore = class {
8081
8212
  const removed = this.friends.delete(friendId);
8082
8213
  if (removed) {
8083
8214
  this.sessions.delete(friendId);
8084
- this.save();
8215
+ this.markDirty();
8085
8216
  }
8086
8217
  return removed;
8087
8218
  }
@@ -8097,6 +8228,9 @@ var PluginStore = class {
8097
8228
  getFriendCount() {
8098
8229
  return this.friends.size;
8099
8230
  }
8231
+ // ----------------------------------------------------------------
8232
+ // Session management
8233
+ // ----------------------------------------------------------------
8100
8234
  /**
8101
8235
  * Set a session for a peer.
8102
8236
  */
@@ -8106,7 +8240,7 @@ var PluginStore = class {
8106
8240
  if (friend) {
8107
8241
  friend.sessionKey = session.sessionKey;
8108
8242
  }
8109
- this.save();
8243
+ this.saveNow();
8110
8244
  }
8111
8245
  /**
8112
8246
  * Get a session by peer ID.
@@ -8120,12 +8254,15 @@ var PluginStore = class {
8120
8254
  removeSession(peerId) {
8121
8255
  return this.sessions.delete(peerId);
8122
8256
  }
8257
+ // ----------------------------------------------------------------
8258
+ // Temp number management
8259
+ // ----------------------------------------------------------------
8123
8260
  /**
8124
8261
  * Add a temp number record.
8125
8262
  */
8126
8263
  addTempNumber(number, expiresAt) {
8127
8264
  this.tempNumbers.push({ number, expiresAt });
8128
- this.save();
8265
+ this.markDirty();
8129
8266
  }
8130
8267
  /**
8131
8268
  * Revoke (remove) a temp number.
@@ -8134,7 +8271,7 @@ var PluginStore = class {
8134
8271
  const idx = this.tempNumbers.findIndex((t) => t.number === number);
8135
8272
  if (idx !== -1) {
8136
8273
  this.tempNumbers.splice(idx, 1);
8137
- this.save();
8274
+ this.markDirty();
8138
8275
  return true;
8139
8276
  }
8140
8277
  return false;
@@ -8147,15 +8284,18 @@ var PluginStore = class {
8147
8284
  const before = this.tempNumbers.length;
8148
8285
  this.tempNumbers = this.tempNumbers.filter((t) => t.expiresAt > now);
8149
8286
  if (this.tempNumbers.length !== before) {
8150
- this.save();
8287
+ this.markDirty();
8151
8288
  }
8152
8289
  }
8290
+ // ----------------------------------------------------------------
8291
+ // Pending friend requests
8292
+ // ----------------------------------------------------------------
8153
8293
  /**
8154
8294
  * Add a pending friend request.
8155
8295
  */
8156
8296
  addPendingRequest(request) {
8157
8297
  this.pendingRequests.push(request);
8158
- this.save();
8298
+ this.markDirty();
8159
8299
  }
8160
8300
  /**
8161
8301
  * Remove a pending friend request.
@@ -8164,11 +8304,14 @@ var PluginStore = class {
8164
8304
  const idx = this.pendingRequests.findIndex((p) => p.requesterId === requesterId);
8165
8305
  if (idx !== -1) {
8166
8306
  this.pendingRequests.splice(idx, 1);
8167
- this.save();
8307
+ this.markDirty();
8168
8308
  return true;
8169
8309
  }
8170
8310
  return false;
8171
8311
  }
8312
+ // ----------------------------------------------------------------
8313
+ // Handshake state
8314
+ // ----------------------------------------------------------------
8172
8315
  /**
8173
8316
  * Set a pending handshake state.
8174
8317
  */
@@ -8365,16 +8508,66 @@ var IdentityService = class {
8365
8508
 
8366
8509
  // dist/services/serverClient.js
8367
8510
  import WebSocket from "ws";
8511
+ var DEFAULT_CONFIG = {
8512
+ initialReconnectDelay: 1e3,
8513
+ maxReconnectDelay: 6e4,
8514
+ reconnectBackoffFactor: 2,
8515
+ heartbeatIntervalMs: 3e4,
8516
+ requestTimeoutMs: 3e4
8517
+ };
8368
8518
  var ServerClient = class {
8369
- constructor(serverUrl, store, logger) {
8519
+ constructor(serverUrl, store, logger, config2) {
8370
8520
  this.ws = null;
8371
8521
  this.wsReconnectTimer = null;
8372
8522
  this.heartbeatTimer = null;
8373
8523
  this.wsConnected = false;
8524
+ this.connectionState = "offline";
8525
+ this.reconnectDelay = DEFAULT_CONFIG.initialReconnectDelay;
8526
+ this.reconnectAttempts = 0;
8527
+ this.stateChangeCallbacks = [];
8374
8528
  this.wsHandlers = /* @__PURE__ */ new Map();
8375
8529
  this.serverUrl = serverUrl;
8376
8530
  this.store = store;
8377
8531
  this.logger = logger;
8532
+ this.config = { ...DEFAULT_CONFIG, ...config2 };
8533
+ }
8534
+ // ----------------------------------------------------------------
8535
+ // Connection state management
8536
+ // ----------------------------------------------------------------
8537
+ /**
8538
+ * Get the current connection state.
8539
+ */
8540
+ getConnectionState() {
8541
+ return this.connectionState;
8542
+ }
8543
+ /**
8544
+ * Register a callback for connection state changes.
8545
+ */
8546
+ onConnectionStateChange(callback) {
8547
+ this.stateChangeCallbacks.push(callback);
8548
+ }
8549
+ /**
8550
+ * Remove a connection state callback.
8551
+ */
8552
+ offConnectionStateChange(callback) {
8553
+ this.stateChangeCallbacks = this.stateChangeCallbacks.filter((cb) => cb !== callback);
8554
+ }
8555
+ /**
8556
+ * Update connection state and notify listeners.
8557
+ */
8558
+ setConnectionState(newState) {
8559
+ const previousState = this.connectionState;
8560
+ if (previousState === newState)
8561
+ return;
8562
+ this.connectionState = newState;
8563
+ this.logger.info(`[Server] Connection state: ${previousState} \u2192 ${newState}`);
8564
+ for (const callback of this.stateChangeCallbacks) {
8565
+ try {
8566
+ callback(newState, previousState);
8567
+ } catch (err) {
8568
+ this.logger.error("[Server] Connection state callback error:", err);
8569
+ }
8570
+ }
8378
8571
  }
8379
8572
  // ----------------------------------------------------------------
8380
8573
  // WebSocket connection
@@ -8383,15 +8576,42 @@ var ServerClient = class {
8383
8576
  * Connect to the server via WebSocket and start heartbeat.
8384
8577
  */
8385
8578
  connectWebSocket() {
8386
- const baseUrl = this.serverUrl.replace(/^http/, "ws");
8387
- const url = new URL(baseUrl + "/ws");
8388
- url.port = "443";
8389
- url.protocol = "wss:";
8390
- const wsUrl = url.toString();
8579
+ if (this.ws && (this.ws.readyState === WebSocket.OPEN || this.ws.readyState === WebSocket.CONNECTING)) {
8580
+ this.logger.debug("[Server] WebSocket already connecting/connected");
8581
+ return;
8582
+ }
8583
+ let wsUrl;
8584
+ try {
8585
+ const baseUrl = this.serverUrl.replace(/^http/, "ws");
8586
+ const url = new URL(baseUrl + "/ws");
8587
+ url.port = "443";
8588
+ url.protocol = "wss:";
8589
+ wsUrl = url.toString();
8590
+ } catch {
8591
+ wsUrl = this.serverUrl.replace(/^https?/, "wss") + "/ws";
8592
+ }
8391
8593
  this.logger.info("[Server] Connecting WebSocket to " + wsUrl);
8392
- this.ws = new WebSocket(wsUrl);
8594
+ this.setConnectionState("reconnecting");
8595
+ try {
8596
+ this.ws = new WebSocket(wsUrl);
8597
+ } catch (err) {
8598
+ this.logger.error("[Server] Failed to create WebSocket:", err);
8599
+ this.setConnectionState("offline");
8600
+ this.scheduleReconnect();
8601
+ return;
8602
+ }
8603
+ const connectTimeout = setTimeout(() => {
8604
+ if (this.ws && this.ws.readyState !== WebSocket.OPEN) {
8605
+ this.logger.warn("[Server] WebSocket connection timeout");
8606
+ this.ws.terminate();
8607
+ }
8608
+ }, this.config.requestTimeoutMs);
8393
8609
  this.ws.on("open", () => {
8610
+ clearTimeout(connectTimeout);
8394
8611
  this.wsConnected = true;
8612
+ this.reconnectDelay = this.config.initialReconnectDelay;
8613
+ this.reconnectAttempts = 0;
8614
+ this.setConnectionState("online");
8395
8615
  this.logger.info("[Server] WebSocket connected");
8396
8616
  this.wsSend({
8397
8617
  type: "online",
@@ -8409,29 +8629,30 @@ var ServerClient = class {
8409
8629
  }
8410
8630
  });
8411
8631
  this.ws.on("close", (code, reason) => {
8632
+ clearTimeout(connectTimeout);
8412
8633
  this.wsConnected = false;
8413
8634
  this.logger.info("[Server] WebSocket closed:", code, reason.toString());
8414
8635
  this.stopHeartbeat();
8636
+ this.setConnectionState("offline");
8415
8637
  this.scheduleReconnect();
8416
8638
  });
8417
8639
  this.ws.on("error", (err) => {
8640
+ clearTimeout(connectTimeout);
8418
8641
  this.logger.error("[Server] WebSocket error:", err.message);
8419
8642
  });
8420
8643
  }
8421
8644
  /**
8422
- * Disconnect the WebSocket.
8645
+ * Disconnect the WebSocket and stop reconnection.
8423
8646
  */
8424
8647
  disconnectWebSocket() {
8425
8648
  this.stopHeartbeat();
8426
- if (this.wsReconnectTimer) {
8427
- clearTimeout(this.wsReconnectTimer);
8428
- this.wsReconnectTimer = null;
8429
- }
8649
+ this.cancelReconnect();
8430
8650
  if (this.ws) {
8431
8651
  this.ws.close();
8432
8652
  this.ws = null;
8433
8653
  }
8434
8654
  this.wsConnected = false;
8655
+ this.setConnectionState("offline");
8435
8656
  }
8436
8657
  /**
8437
8658
  * Check if the WebSocket is connected.
@@ -8449,13 +8670,50 @@ var ServerClient = class {
8449
8670
  }
8450
8671
  /**
8451
8672
  * Send a JSON message over the WebSocket.
8673
+ * Returns true if the message was sent, false if offline.
8452
8674
  */
8453
8675
  wsSend(data) {
8454
8676
  if (this.ws && this.ws.readyState === WebSocket.OPEN) {
8455
8677
  this.ws.send(JSON.stringify(data));
8456
- } else {
8457
- this.logger.warn("[Server] Cannot send \u2014 WebSocket not open");
8678
+ return true;
8679
+ }
8680
+ this.logger.warn("[Server] Cannot send \u2014 WebSocket not open");
8681
+ return false;
8682
+ }
8683
+ // ----------------------------------------------------------------
8684
+ // Exponential backoff reconnection
8685
+ // ----------------------------------------------------------------
8686
+ /**
8687
+ * Schedule a reconnection attempt with exponential backoff.
8688
+ *
8689
+ * Delay formula: min(initialDelay * backoffFactor^attempts, maxDelay)
8690
+ * With jitter: delay * (0.75 + Math.random() * 0.5) to avoid thundering herd
8691
+ */
8692
+ scheduleReconnect() {
8693
+ if (this.wsReconnectTimer)
8694
+ return;
8695
+ const jitter = 0.75 + Math.random() * 0.5;
8696
+ const delay = Math.min(this.reconnectDelay * jitter, this.config.maxReconnectDelay);
8697
+ this.reconnectAttempts++;
8698
+ this.logger.info(`[Server] Reconnecting in ${Math.round(delay)}ms (attempt #${this.reconnectAttempts})`);
8699
+ this.setConnectionState("reconnecting");
8700
+ this.wsReconnectTimer = setTimeout(() => {
8701
+ this.wsReconnectTimer = null;
8702
+ this.logger.info("[Server] Attempting WebSocket reconnect...");
8703
+ this.connectWebSocket();
8704
+ }, delay);
8705
+ this.reconnectDelay = Math.min(this.reconnectDelay * this.config.reconnectBackoffFactor, this.config.maxReconnectDelay);
8706
+ }
8707
+ /**
8708
+ * Cancel a pending reconnection.
8709
+ */
8710
+ cancelReconnect() {
8711
+ if (this.wsReconnectTimer) {
8712
+ clearTimeout(this.wsReconnectTimer);
8713
+ this.wsReconnectTimer = null;
8458
8714
  }
8715
+ this.reconnectDelay = this.config.initialReconnectDelay;
8716
+ this.reconnectAttempts = 0;
8459
8717
  }
8460
8718
  // ----------------------------------------------------------------
8461
8719
  // REST API methods
@@ -8570,9 +8828,10 @@ var ServerClient = class {
8570
8828
  }
8571
8829
  /**
8572
8830
  * Send a relay message via the server (WebSocket fallback for P2P).
8831
+ * Returns true if sent, false if offline.
8573
8832
  */
8574
8833
  sendRelayMessage(targetId, payload) {
8575
- this.wsSend({
8834
+ return this.wsSend({
8576
8835
  type: "relay",
8577
8836
  targetId,
8578
8837
  payload,
@@ -8585,11 +8844,15 @@ var ServerClient = class {
8585
8844
  async fetchPost(path7, body) {
8586
8845
  const url = this.serverUrl + path7;
8587
8846
  try {
8847
+ const controller = new AbortController();
8848
+ const timeout = setTimeout(() => controller.abort(), this.config.requestTimeoutMs);
8588
8849
  const resp = await fetch(url, {
8589
8850
  method: "POST",
8590
8851
  headers: { "Content-Type": "application/json" },
8591
- body: JSON.stringify(body)
8852
+ body: JSON.stringify(body),
8853
+ signal: controller.signal
8592
8854
  });
8855
+ clearTimeout(timeout);
8593
8856
  if (!resp.ok) {
8594
8857
  const text = await resp.text();
8595
8858
  this.logger.error(`[Server] API error ${resp.status} on ${path7}: ${text}`);
@@ -8597,14 +8860,21 @@ var ServerClient = class {
8597
8860
  }
8598
8861
  return await resp.json();
8599
8862
  } catch (err) {
8600
- this.logger.error(`[Server] API request failed for ${path7}:`, err);
8863
+ if (err instanceof DOMException && err.name === "AbortError") {
8864
+ this.logger.error(`[Server] API request timeout for ${path7}`);
8865
+ } else {
8866
+ this.logger.error(`[Server] API request failed for ${path7}:`, err);
8867
+ }
8601
8868
  return null;
8602
8869
  }
8603
8870
  }
8604
8871
  async fetchGet(path7) {
8605
8872
  const url = this.serverUrl + path7;
8606
8873
  try {
8607
- const resp = await fetch(url);
8874
+ const controller = new AbortController();
8875
+ const timeout = setTimeout(() => controller.abort(), this.config.requestTimeoutMs);
8876
+ const resp = await fetch(url, { signal: controller.signal });
8877
+ clearTimeout(timeout);
8608
8878
  if (!resp.ok) {
8609
8879
  const text = await resp.text();
8610
8880
  this.logger.error(`[Server] API error ${resp.status} on ${path7}: ${text}`);
@@ -8612,18 +8882,26 @@ var ServerClient = class {
8612
8882
  }
8613
8883
  return await resp.json();
8614
8884
  } catch (err) {
8615
- this.logger.error(`[Server] GET request failed for ${path7}:`, err);
8885
+ if (err instanceof DOMException && err.name === "AbortError") {
8886
+ this.logger.error(`[Server] GET request timeout for ${path7}`);
8887
+ } else {
8888
+ this.logger.error(`[Server] GET request failed for ${path7}:`, err);
8889
+ }
8616
8890
  return null;
8617
8891
  }
8618
8892
  }
8619
8893
  async del(path7, body) {
8620
8894
  const url = this.serverUrl + path7;
8621
8895
  try {
8896
+ const controller = new AbortController();
8897
+ const timeout = setTimeout(() => controller.abort(), this.config.requestTimeoutMs);
8622
8898
  const resp = await fetch(url, {
8623
8899
  method: "DELETE",
8624
8900
  headers: { "Content-Type": "application/json" },
8625
- body: body ? JSON.stringify(body) : void 0
8901
+ body: body ? JSON.stringify(body) : void 0,
8902
+ signal: controller.signal
8626
8903
  });
8904
+ clearTimeout(timeout);
8627
8905
  return resp.ok;
8628
8906
  } catch (err) {
8629
8907
  this.logger.error(`[Server] DELETE request failed for ${path7}:`, err);
@@ -8633,11 +8911,15 @@ var ServerClient = class {
8633
8911
  async post(path7, body) {
8634
8912
  const url = this.serverUrl + path7;
8635
8913
  try {
8914
+ const controller = new AbortController();
8915
+ const timeout = setTimeout(() => controller.abort(), this.config.requestTimeoutMs);
8636
8916
  const resp = await fetch(url, {
8637
8917
  method: "POST",
8638
8918
  headers: { "Content-Type": "application/json" },
8639
- body: JSON.stringify(body)
8919
+ body: JSON.stringify(body),
8920
+ signal: controller.signal
8640
8921
  });
8922
+ clearTimeout(timeout);
8641
8923
  return resp.ok;
8642
8924
  } catch (err) {
8643
8925
  this.logger.error(`[Server] API request failed for ${path7}:`, err);
@@ -8658,9 +8940,10 @@ var ServerClient = class {
8658
8940
  }
8659
8941
  }
8660
8942
  startHeartbeat() {
8943
+ this.stopHeartbeat();
8661
8944
  this.heartbeatTimer = setInterval(() => {
8662
8945
  this.wsSend({ type: "ping", agentId: this.store.agentId, timestamp: Date.now() });
8663
- }, 3e4);
8946
+ }, this.config.heartbeatIntervalMs);
8664
8947
  }
8665
8948
  stopHeartbeat() {
8666
8949
  if (this.heartbeatTimer) {
@@ -8668,15 +8951,6 @@ var ServerClient = class {
8668
8951
  this.heartbeatTimer = null;
8669
8952
  }
8670
8953
  }
8671
- scheduleReconnect() {
8672
- if (this.wsReconnectTimer)
8673
- return;
8674
- this.wsReconnectTimer = setTimeout(() => {
8675
- this.wsReconnectTimer = null;
8676
- this.logger.info("[Server] Attempting WebSocket reconnect...");
8677
- this.connectWebSocket();
8678
- }, 5e3);
8679
- }
8680
8954
  };
8681
8955
 
8682
8956
  // dist/handshake/handshakeManager.js
@@ -9335,12 +9609,18 @@ var EncryptedChatChannel = class {
9335
9609
  this.api = null;
9336
9610
  this.dataDir = "";
9337
9611
  this.fileChunkBuffers = /* @__PURE__ */ new Map();
9612
+ this.flushingOffline = false;
9338
9613
  this.store = store;
9339
9614
  this.handshakeManager = handshakeManager;
9340
9615
  this.p2pManager = p2pManager;
9341
9616
  this.serverClient = serverClient;
9342
9617
  this.logger = logger;
9343
9618
  this.dataDir = dataDir;
9619
+ this.serverClient.onConnectionStateChange((newState, _prevState) => {
9620
+ if (newState === "online") {
9621
+ this.onReconnected();
9622
+ }
9623
+ });
9344
9624
  }
9345
9625
  /**
9346
9626
  * Set the OpenClaw API reference (for emitting events).
@@ -9348,6 +9628,17 @@ var EncryptedChatChannel = class {
9348
9628
  setAPI(api) {
9349
9629
  this.api = api;
9350
9630
  }
9631
+ /**
9632
+ * Called when the WebSocket connection is re-established.
9633
+ * Flushes all queued offline messages.
9634
+ */
9635
+ onReconnected() {
9636
+ const pendingCount = this.store.getOfflineMessageCount();
9637
+ if (pendingCount > 0) {
9638
+ this.logger.info(`[Chat] Back online \u2014 flushing ${pendingCount} queued offline messages`);
9639
+ this.flushOfflineMessages();
9640
+ }
9641
+ }
9351
9642
  /**
9352
9643
  * Handle an incoming encrypted message from a peer.
9353
9644
  *
@@ -9382,7 +9673,7 @@ var EncryptedChatChannel = class {
9382
9673
  });
9383
9674
  }
9384
9675
  friend.lastMessageAt = /* @__PURE__ */ new Date();
9385
- this.store.save();
9676
+ this.store.markDirty();
9386
9677
  }
9387
9678
  /**
9388
9679
  * Send an encrypted message to a peer.
@@ -9390,7 +9681,9 @@ var EncryptedChatChannel = class {
9390
9681
  * Encrypts with the session key, signs with the Ed25519 identity key,
9391
9682
  * and sends via P2P if available, falling back to WebSocket relay.
9392
9683
  *
9393
- * @returns true if the message was sent successfully
9684
+ * When offline, the message is queued for later delivery.
9685
+ *
9686
+ * @returns true if the message was sent or queued successfully
9394
9687
  */
9395
9688
  send(toId, plaintext) {
9396
9689
  const friend = this.store.getFriend(toId);
@@ -9408,14 +9701,26 @@ var EncryptedChatChannel = class {
9408
9701
  }
9409
9702
  const wireData = (0, import_crypto6.encryptMessage)(plaintext, sessionKey, this.store.identityKeys.secretKey, this.store.identityKeys.publicKey);
9410
9703
  const buf = Buffer.from(wireData);
9411
- if (this.p2pManager.isConnected(toId) && this.p2pManager.send(toId, buf)) {
9412
- this.logger.debug("[Chat] Sent message via P2P to " + toId);
9704
+ const encodedData = (0, import_crypto6.encodeBase64)(wireData);
9705
+ const isOnline = this.serverClient.isConnected();
9706
+ if (isOnline) {
9707
+ if (this.p2pManager.isConnected(toId) && this.p2pManager.send(toId, buf)) {
9708
+ this.logger.debug("[Chat] Sent message via P2P to " + toId);
9709
+ } else {
9710
+ const sent = this.serverClient.sendRelayMessage(toId, {
9711
+ channel: "encrypted-chat",
9712
+ data: encodedData
9713
+ });
9714
+ if (!sent) {
9715
+ this.logger.warn("[Chat] WebSocket send failed \u2014 queuing message for offline delivery");
9716
+ this.store.enqueueOfflineMessage(toId, encodedData);
9717
+ } else {
9718
+ this.logger.debug("[Chat] Sent message via relay to " + toId);
9719
+ }
9720
+ }
9413
9721
  } else {
9414
- this.serverClient.sendRelayMessage(toId, {
9415
- channel: "encrypted-chat",
9416
- data: (0, import_crypto6.encodeBase64)(wireData)
9417
- });
9418
- this.logger.debug("[Chat] Sent message via relay to " + toId);
9722
+ this.logger.info("[Chat] Offline \u2014 message queued for delivery to " + toId);
9723
+ this.store.enqueueOfflineMessage(toId, encodedData);
9419
9724
  }
9420
9725
  const session = this.store.getSession(toId);
9421
9726
  if (session) {
@@ -9423,10 +9728,71 @@ var EncryptedChatChannel = class {
9423
9728
  if (session.messageCount % 100 === 0 || Date.now() - session.createdAt.getTime() > 36e5) {
9424
9729
  this.handshakeManager.rotateSessionKey(toId);
9425
9730
  }
9426
- this.store.save();
9731
+ this.store.markDirty();
9427
9732
  }
9428
9733
  return true;
9429
9734
  }
9735
+ /**
9736
+ * Flush all queued offline messages.
9737
+ * Called automatically when connection is re-established.
9738
+ */
9739
+ flushOfflineMessages() {
9740
+ if (this.flushingOffline)
9741
+ return;
9742
+ this.flushingOffline = true;
9743
+ const flushNext = () => {
9744
+ const msg = this.store.dequeueOfflineMessage();
9745
+ if (!msg) {
9746
+ this.flushingOffline = false;
9747
+ const remaining = this.store.getOfflineMessageCount();
9748
+ if (remaining === 0) {
9749
+ this.logger.info("[Chat] All offline messages flushed");
9750
+ }
9751
+ return;
9752
+ }
9753
+ if (!this.serverClient.isConnected()) {
9754
+ this.store.offlineMessages.unshift(msg);
9755
+ this.flushingOffline = false;
9756
+ this.logger.warn("[Chat] Connection lost during offline flush");
9757
+ return;
9758
+ }
9759
+ const friend = this.store.getFriend(msg.targetId);
9760
+ let sessionKey = this.handshakeManager.getSessionKey(msg.targetId);
9761
+ if (!sessionKey && friend?.sessionKey) {
9762
+ sessionKey = friend.sessionKey;
9763
+ }
9764
+ if (!sessionKey) {
9765
+ this.logger.warn("[Chat] Skipping offline message to " + msg.targetId + " (no session key)");
9766
+ this.store.markDirty();
9767
+ setImmediate(flushNext);
9768
+ return;
9769
+ }
9770
+ const sent = this.serverClient.sendRelayMessage(msg.targetId, {
9771
+ channel: "encrypted-chat",
9772
+ data: msg.encryptedData
9773
+ });
9774
+ if (sent) {
9775
+ this.logger.debug("[Chat] Flushed offline message to " + msg.targetId);
9776
+ } else {
9777
+ this.logger.warn("[Chat] Failed to flush offline message to " + msg.targetId);
9778
+ msg.retryCount++;
9779
+ if (msg.retryCount < msg.maxRetries) {
9780
+ this.store.offlineMessages.unshift(msg);
9781
+ } else {
9782
+ this.logger.error("[Chat] Dropped offline message to " + msg.targetId + " (max retries exceeded)");
9783
+ }
9784
+ }
9785
+ this.store.markDirty();
9786
+ setImmediate(flushNext);
9787
+ };
9788
+ flushNext();
9789
+ }
9790
+ /**
9791
+ * Get the count of pending offline messages.
9792
+ */
9793
+ getPendingOfflineCount() {
9794
+ return this.store.getOfflineMessageCount();
9795
+ }
9430
9796
  /**
9431
9797
  * Handle an incoming file chunk from a peer.
9432
9798
  *
@@ -9511,6 +9877,7 @@ var EncryptedChatChannel = class {
9511
9877
  */
9512
9878
  cleanup() {
9513
9879
  this.fileChunkBuffers.clear();
9880
+ this.flushingOffline = false;
9514
9881
  }
9515
9882
  };
9516
9883
 
@@ -12832,6 +13199,9 @@ var plugin = definePluginEntry({
12832
13199
  } catch (e) {
12833
13200
  logger.warn("[Init] WS connect failed: " + (e instanceof Error ? e.message : e));
12834
13201
  }
13202
+ serverClient.onConnectionStateChange((newState, prevState) => {
13203
+ logger.info("[Init] Connection state changed: " + prevState + " \u2192 " + newState);
13204
+ });
12835
13205
  serverClient.onWsMessage("relay", (data) => {
12836
13206
  const msg = data;
12837
13207
  if (!msg?.payload)
@@ -12845,14 +13215,9 @@ var plugin = definePluginEntry({
12845
13215
  }
12846
13216
  });
12847
13217
  setInterval(() => {
12848
- if (!serverClient.isConnected()) {
12849
- try {
12850
- serverClient.connectWebSocket();
12851
- } catch (_e) {
12852
- }
12853
- }
13218
+ store.cleanupExpiredTempNumbers();
13219
+ store.cleanupExpiredOfflineMessages();
12854
13220
  }, 6e4);
12855
- setInterval(() => store.cleanupExpiredTempNumbers(), 6e4);
12856
13221
  api.registerTool({
12857
13222
  label: "AICQ Friend Manager",
12858
13223
  name: "chat-friend",
@@ -12997,10 +13362,12 @@ var plugin = definePluginEntry({
12997
13362
  try {
12998
13363
  return {
12999
13364
  connected: serverClient.isConnected(),
13365
+ connectionState: serverClient.getConnectionState(),
13000
13366
  agentId: aicqAgentId,
13001
13367
  fingerprint: identityService.getPublicKeyFingerprint(),
13002
13368
  friendCount: store.getFriendCount(),
13003
13369
  sessionCount: store.sessions.size,
13370
+ offlineMessageCount: store.getOfflineMessageCount(),
13004
13371
  serverUrl
13005
13372
  };
13006
13373
  } catch (err) {
@@ -1,8 +1,8 @@
1
1
  {
2
2
  "id": "aicq-chat",
3
3
  "name": "AICQ Encrypted Chat",
4
- "version": "1.2.0",
5
- "description": "End-to-end encrypted chat plugin supporting AI-AI, Human-AI communication with P2P messaging via Noise-XK handshake, Ed25519/X25519/AES-256-GCM encryption",
4
+ "version": "1.3.0",
5
+ "description": "End-to-end encrypted chat plugin supporting AI-AI, Human-AI communication with P2P messaging, offline queue, and Noise-XK handshake (Ed25519/X25519/AES-256-GCM)",
6
6
  "enabledByDefault": false,
7
7
  "channels": ["encrypted-chat"],
8
8
  "tools": ["chat-friend", "chat-send", "chat-export-key"],
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "aicq-openclaw-plugin",
3
- "version": "1.2.1",
4
- "description": "AICQ OpenClaw plugin - end-to-end encrypted P2P chat for AI agents",
3
+ "version": "1.3.0",
4
+ "description": "AICQ OpenClaw plugin - end-to-end encrypted P2P chat for AI agents with offline queue support",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",
7
7
  "files": [