@agentunion/fastaun 0.2.19 → 0.2.20

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.
Files changed (77) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/_packed_docs/CHANGELOG.md +23 -0
  3. package/_packed_docs/agent.md/SCHEMA.md +173 -0
  4. package/_packed_docs/agent.md/examples/codeagent-claudecode.md +61 -0
  5. package/_packed_docs/agent.md/examples/human-developer.md +60 -0
  6. package/_packed_docs/agent.md/examples/openclaw-lobster.md +52 -0
  7. package/_packed_docs/agent.md/examples/signed-openclaw-lobster.md +43 -0
  8. package/_packed_docs/protocol/00-/346/200/273/350/247/210/344/270/216/345/210/206/345/261/202.md +205 -0
  9. package/_packed_docs/protocol/00A-/350/256/276/350/256/241/345/216/237/345/210/231-/344/270/272Agent/350/200/214/347/224/237.md +197 -0
  10. package/_packed_docs/protocol/01-/350/272/253/344/273/275/344/270/216/345/207/255/350/257/201/345/215/217/350/256/256-auth.md +549 -0
  11. package/_packed_docs/protocol/02-/350/257/201/344/271/246/344/270/216/344/277/241/344/273/273/344/275/223/347/263/273.md +810 -0
  12. package/_packed_docs/protocol/03-Gateway-/350/277/236/346/216/245/346/250/241/345/274/217.md +262 -0
  13. package/_packed_docs/protocol/04-Peer-/345/255/220/345/215/217/350/256/256.md +180 -0
  14. package/_packed_docs/protocol/05-Relay-/345/255/220/345/215/217/350/256/256.md +164 -0
  15. package/_packed_docs/protocol/06-/346/234/215/345/212/241/345/215/217/350/256/256.md +1135 -0
  16. package/_packed_docs/protocol/07-/351/224/231/350/257/257/347/240/201/344/270/216/347/212/266/346/200/201/346/234/272.md +234 -0
  17. package/_packed_docs/protocol/08-AUN-E2EE-Group.md +900 -0
  18. package/_packed_docs/protocol/08-AUN-E2EE.md +413 -0
  19. package/_packed_docs/protocol/09-/345/256/211/345/205/250/350/200/203/350/231/221.md +316 -0
  20. package/_packed_docs/protocol/10-Group-/345/255/220/345/215/217/350/256/256.md +804 -0
  21. package/_packed_docs/protocol/11-Storage-/345/255/220/345/215/217/350/256/256.md +271 -0
  22. package/_packed_docs/protocol/12-Stream-/345/255/220/345/215/217/350/256/256.md +329 -0
  23. package/_packed_docs/protocol/13-Agent/350/241/214/344/270/272/350/247/204/350/214/203.md +141 -0
  24. package/_packed_docs/protocol/14-/344/272/244/344/272/222/346/234/272/345/210/266-/345/223/215/345/272/224/346/250/241/345/274/217/344/270/216/350/207/252/344/270/273/346/250/241/345/274/217.md +170 -0
  25. package/_packed_docs/protocol/README.md +71 -0
  26. package/_packed_docs/protocol/agent.md/SCHEMA.md +118 -0
  27. package/_packed_docs/protocol/agent.md/examples/codeagent-claudecode.md +61 -0
  28. package/_packed_docs/protocol/agent.md/examples/human-developer.md +60 -0
  29. package/_packed_docs/protocol/agent.md/examples/openclaw-lobster.md +52 -0
  30. package/_packed_docs/protocol/aun-docs-guide.md +49 -0
  31. package/_packed_docs/protocol/index.md +114 -0
  32. package/_packed_docs/protocol//350/215/211/346/241/210-agent.md/347/255/276/345/220/215/345/215/217/350/256/256.md +205 -0
  33. package/_packed_docs/protocol//350/215/211/346/241/210-/346/213/222/347/273/235/344/277/241/345/217/267/345/215/217/350/256/256.md +249 -0
  34. package/_packed_docs/protocol//351/231/204/345/275/225A-/346/234/257/350/257/255/350/241/250.md +337 -0
  35. package/_packed_docs/protocol//351/231/204/345/275/225B-/346/211/251/345/261/225/346/200/247/346/214/207/345/215/227.md +80 -0
  36. package/_packed_docs/protocol//351/231/204/345/275/225C-/347/247/201/351/222/245/347/256/241/347/220/206/344/270/216/350/272/253/344/273/275/346/201/242/345/244/215.md +704 -0
  37. package/_packed_docs/protocol//351/231/204/345/275/225D-Root_CA_/346/262/273/347/220/206/346/234/272/345/210/266.md +620 -0
  38. package/_packed_docs/protocol//351/231/204/345/275/225E-Root_CA_/345/207/206/345/205/245/346/265/201/347/250/213.md +605 -0
  39. package/_packed_docs/protocol//351/231/204/345/275/225F-Issuer_CA_/347/224/263/350/257/267/346/265/201/347/250/213.md +548 -0
  40. package/_packed_docs/protocol//351/231/204/345/275/225G-AID_/345/255/244/345/204/277/351/242/204/351/230/262/344/270/216/346/225/221/346/217/264/346/234/272/345/210/266.md +513 -0
  41. package/_packed_docs/protocol//351/231/204/345/275/225H-Identity/346/234/215/345/212/241/345/256/236/347/216/260/346/214/207/345/215/227.md +619 -0
  42. package/_packed_docs/protocol//351/231/204/345/275/225I-/350/267/250/345/237/237/346/266/210/346/201/257/350/267/257/347/224/261/345/256/236/347/216/260/346/214/207/345/215/227.md +492 -0
  43. package/_packed_docs/protocol//351/231/204/345/275/225J-/345/256/242/346/210/267/347/253/257/346/216/245/345/205/245/347/244/272/344/276/213.md +402 -0
  44. package/_packed_docs/protocol//351/231/204/345/275/225K-Agent_Web/345/217/221/347/216/260/345/215/217/350/256/256.md +130 -0
  45. package/_packed_docs/protocol//351/231/204/345/275/225L-E2EE/345/256/236/347/216/260/346/214/207/345/215/227.md +267 -0
  46. package/_packed_docs/protocol//351/231/204/345/275/225M-JWT/350/256/244/350/257/201/345/256/236/347/216/260/346/214/207/345/215/227.md +367 -0
  47. package/_packed_docs/sdk/01-/345/277/253/351/200/237/345/274/200/345/247/213.md +223 -0
  48. package/_packed_docs/sdk/02-WebSocket/345/215/217/350/256/256.md +354 -0
  49. package/_packed_docs/sdk/03-/346/240/270/345/277/203/346/246/202/345/277/265.md +172 -0
  50. package/_packed_docs/sdk/04-/350/277/236/346/216/245/344/270/216/350/256/244/350/257/201.md +373 -0
  51. package/_packed_docs/sdk/05-E2EE/345/212/240/345/257/206/351/200/232/344/277/241.md +611 -0
  52. package/_packed_docs/sdk/06-API/346/211/213/345/206/214.md +1152 -0
  53. package/_packed_docs/sdk/07-/351/224/231/350/257/257/345/244/204/347/220/206.md +150 -0
  54. package/_packed_docs/sdk/08-/346/234/200/344/275/263/345/256/236/350/267/265.md +89 -0
  55. package/_packed_docs/sdk/09-custody-api-manual.md +445 -0
  56. package/_packed_docs/sdk/09-group-rpc-manual.md +1895 -0
  57. package/_packed_docs/sdk/09-message-rpc-manual.md +597 -0
  58. package/_packed_docs/sdk/09-meta-rpc-manual.md +142 -0
  59. package/_packed_docs/sdk/09-payload-reference.md +702 -0
  60. package/_packed_docs/sdk/09-storage-rpc-manual.md +408 -0
  61. package/_packed_docs/sdk/09-stream-rpc-manual.md +275 -0
  62. package/_packed_docs/sdk/AUN_DOCS_GUIDE.md +72 -0
  63. package/_packed_docs/sdk/INDEX.md +131 -0
  64. package/_packed_docs/sdk/README.md +307 -0
  65. package/dist/auth.d.ts +2 -1
  66. package/dist/auth.js +13 -11
  67. package/dist/auth.js.map +1 -1
  68. package/dist/client.d.ts +32 -5
  69. package/dist/client.js +187 -99
  70. package/dist/client.js.map +1 -1
  71. package/dist/namespaces/auth.d.ts +1 -0
  72. package/dist/namespaces/auth.js +20 -6
  73. package/dist/namespaces/auth.js.map +1 -1
  74. package/dist/transport.d.ts +10 -0
  75. package/dist/transport.js +24 -0
  76. package/dist/transport.js.map +1 -1
  77. package/package.json +45 -42
package/dist/client.js CHANGED
@@ -11,6 +11,7 @@
11
11
  * - 群组 E2EE 全自动编排(建群/加人/踢人/退出)
12
12
  */
13
13
  import * as crypto from 'node:crypto';
14
+ import * as fs from 'node:fs';
14
15
  import * as http from 'node:http';
15
16
  import * as https from 'node:https';
16
17
  import { join } from 'node:path';
@@ -81,7 +82,7 @@ const DEFAULT_SESSION_OPTIONS = {
81
82
  },
82
83
  timeouts: {
83
84
  connect: 5.0,
84
- call: 10.0,
85
+ call: 35.0,
85
86
  http: 30.0,
86
87
  },
87
88
  };
@@ -93,6 +94,20 @@ const GROUP_ROTATION_RETRY_MAX_DELAY_MS = 300_000;
93
94
  const PENDING_DECRYPT_LIMIT = 100;
94
95
  const PUSHED_SEQS_LIMIT = 50_000;
95
96
  const PENDING_ORDERED_LIMIT = 50_000;
97
+ // 心跳间隔下/上限(秒)。0 = 关闭心跳;负值视为 0;其余值 clamp 到 [10, 600]。
98
+ // 服务端通过 hello.heartbeat_interval 与 meta.ping pong 中的同名字段下发。
99
+ const HEARTBEAT_MIN_INTERVAL_SECONDS = 10;
100
+ const HEARTBEAT_MAX_INTERVAL_SECONDS = 600;
101
+ function clampHeartbeatInterval(value) {
102
+ const n = Number(value);
103
+ if (!Number.isFinite(n) || n <= 0)
104
+ return 0;
105
+ if (n < HEARTBEAT_MIN_INTERVAL_SECONDS)
106
+ return HEARTBEAT_MIN_INTERVAL_SECONDS;
107
+ if (n > HEARTBEAT_MAX_INTERVAL_SECONDS)
108
+ return HEARTBEAT_MAX_INTERVAL_SECONDS;
109
+ return n;
110
+ }
96
111
  // P1-23: 非幂等方法使用更长超时(35s),避免 SDK 10s 超时 < gateway 30s 处理时间
97
112
  const NON_IDEMPOTENT_TIMEOUT_MS = 35_000;
98
113
  const NON_IDEMPOTENT_METHODS = new Set([
@@ -337,6 +352,7 @@ export class AUNClient {
337
352
  /** 当前实例上下文 */
338
353
  _deviceId;
339
354
  _slotId;
355
+ _connectedAt = 0;
340
356
  _connectDeliveryMode;
341
357
  _defaultConnectDeliveryMode;
342
358
  /** peer 证书缓存 */
@@ -346,13 +362,18 @@ export class AUNClient {
346
362
  _prekeyReplenished = new Set();
347
363
  // 当前活跃 prekey:只有这个 prekey 被消费时才触发 replenish 上传
348
364
  _activePrekeyId = '';
365
+ // 本地 agent.md 文件路径与对应 etag(quoted sha256 hex,与服务端 _agent_md_etag 一致)。
366
+ // 由 setLocalAgentMdPath() 设置;用于跟服务端 RPC 注入的 _meta.agent_md_etag 比对,
367
+ // 触发"本地未发布到服务端"或"服务端版本更新"的 UI 提示。
368
+ _localAgentMdPath = '';
369
+ _localAgentMdEtag = '';
370
+ // gateway 在 RPC envelope._meta.agent_md_etag 注入的服务端 etag;纯观察,无下游依赖。
371
+ _remoteAgentMdEtag = '';
349
372
  /** 消息序列号跟踪器(群消息 + P2P 空洞检测) */
350
373
  _seqTracker = new SeqTracker();
351
374
  _seqTrackerContext = null;
352
375
  /** 惰性群同步:已同步过的 group_id 集合 */
353
376
  _groupSynced = new Set();
354
- /** 惰性 P2P 同步:是否已同步过 */
355
- _p2pSynced = false;
356
377
  /** 补洞去重:已完成/进行中的 key -> 开始时间戳,防止重复 pull 同一区间 */
357
378
  _gapFillDone = new Map();
358
379
  /** 已发布到应用层的 seq 集合(按命名空间),补洞路径 publish 前检查以避免重复分发 */
@@ -432,6 +453,7 @@ export class AUNClient {
432
453
  verifySsl: this._configModel.verifySsl,
433
454
  logger: this._logger.for('aun_core.transport'),
434
455
  });
456
+ this._transport.setMetaObserver((meta) => this._observeRpcMeta(meta));
435
457
  this._e2ee = new E2EEManager({
436
458
  identityFn: () => this._identity ?? {},
437
459
  deviceIdFn: () => this._deviceId,
@@ -469,6 +491,65 @@ export class AUNClient {
469
491
  get aid() {
470
492
  return this._aid;
471
493
  }
494
+ /**
495
+ * 记录本地 agent.md 文件路径并一次性计算 etag(quoted sha256,与服务端一致)。
496
+ *
497
+ * - path 为空字符串:清除本地 path 与 etag。
498
+ * - 文件不存在 / 读取失败:清除 etag 并返回空串,不抛异常(应用可读 getLocalAgentMdEtag()
499
+ * 为空判断)。
500
+ * - 浏览器环境无文件系统:直接返回空串,记录 warn 日志。
501
+ * - 文件变更后需要重新调用 setLocalAgentMdPath() 触发重算(按设计:设置时一次性计算)。
502
+ *
503
+ * 返回当前 etag(quoted hex 或空串)。
504
+ */
505
+ setLocalAgentMdPath(path) {
506
+ const rawPath = String(path ?? '').trim();
507
+ if (!rawPath) {
508
+ this._localAgentMdPath = '';
509
+ this._localAgentMdEtag = '';
510
+ return '';
511
+ }
512
+ // 浏览器环境没有 fs,直接退回空串。Node 环境才尝试读文件。
513
+ const isNode = typeof process !== 'undefined' && !!process.versions?.node;
514
+ if (!isNode) {
515
+ this._clientLog.warn(`setLocalAgentMdPath skipped: not running in Node.js (path=${rawPath})`);
516
+ this._localAgentMdPath = rawPath;
517
+ this._localAgentMdEtag = '';
518
+ return '';
519
+ }
520
+ this._localAgentMdPath = rawPath;
521
+ try {
522
+ const data = fs.readFileSync(rawPath);
523
+ const digest = crypto.createHash('sha256').update(data).digest('hex');
524
+ this._localAgentMdEtag = `"${digest}"`;
525
+ }
526
+ catch (err) {
527
+ this._clientLog.warn(`setLocalAgentMdPath 读取失败 path=${rawPath} err=${err instanceof Error ? err.message : String(err)}`);
528
+ this._localAgentMdEtag = '';
529
+ }
530
+ return this._localAgentMdEtag;
531
+ }
532
+ /** 返回 setLocalAgentMdPath 计算的 etag;未设置或读取失败时返回空串。 */
533
+ getLocalAgentMdEtag() {
534
+ return this._localAgentMdEtag;
535
+ }
536
+ /**
537
+ * 返回 gateway 在最近一次 RPC envelope._meta 注入的服务端 agent.md etag。
538
+ *
539
+ * 未收到过则为空串;不阻塞调用,纯内存读。
540
+ */
541
+ getRemoteAgentMdEtag() {
542
+ return this._remoteAgentMdEtag;
543
+ }
544
+ /** transport 的 meta observer:吸收 gateway 注入的 _meta 字段。失败不影响业务。 */
545
+ _observeRpcMeta(meta) {
546
+ if (!meta || typeof meta !== 'object')
547
+ return;
548
+ const etag = String(meta.agent_md_etag ?? '').trim();
549
+ if (etag) {
550
+ this._remoteAgentMdEtag = etag;
551
+ }
552
+ }
472
553
  /** 连接状态 */
473
554
  get state() {
474
555
  return this._state;
@@ -519,7 +600,7 @@ export class AUNClient {
519
600
  this._sessionParams = normalized;
520
601
  this._sessionOptions = this._buildSessionOptions(normalized);
521
602
  const callTimeoutSec = this._sessionOptions.timeouts.call;
522
- this._transport.setTimeout(callTimeoutSec != null ? callTimeoutSec * 1000 : 10_000);
603
+ this._transport.setTimeout(callTimeoutSec != null ? callTimeoutSec * 1000 : 35_000);
523
604
  this._closing = false;
524
605
  this._clientLog.debug(`connect enter: gateway=${String(normalized.gateway ?? '')}, device_id=${this._deviceId}`);
525
606
  try {
@@ -666,6 +747,7 @@ export class AUNClient {
666
747
  return await this._sendEncrypted(p);
667
748
  }
668
749
  // encrypt=false:明文走通用 RPC 路径;protected_headers/headers 是信封元数据,加密与否都保留
750
+ this._maybeAppendEchoTraceSend(p);
669
751
  }
670
752
  // 自动加密:group.send 默认加密(encrypt 默认 True)
671
753
  if (method === 'group.send') {
@@ -674,6 +756,7 @@ export class AUNClient {
674
756
  if (encrypt) {
675
757
  return await this._sendGroupEncrypted(p);
676
758
  }
759
+ this._maybeAppendEchoTraceSend(p);
677
760
  }
678
761
  if (method === 'group.thought.put') {
679
762
  const encrypt = p.encrypt ?? true;
@@ -938,10 +1021,8 @@ export class AUNClient {
938
1021
  const persistRequired = Boolean(params.persist_required || params.durable);
939
1022
  const protectedHeaders = this._protectedHeadersFromParams(params);
940
1023
  this._clientLog.debug(`_sendEncrypted enter: to=${toAid}, message_id=${messageId}`);
941
- // 惰性同步:首次发送 P2P 消息时先 pull 一次
942
- if (!this._p2pSynced) {
943
- await this._lazySyncP2p();
944
- }
1024
+ // 惰性 P2P 同步由 connect/reconnect 完成后的 _fillP2pGap 异步触发,
1025
+ // 不再在 send 路径上 await(与 C++ FillP2PGap 行为对齐,避免阻塞用户发送)。
945
1026
  // 内部发送逻辑,refreshPeerMaterial=true 时强制清缓存重新拉取对端材料
946
1027
  const sendAttempt = async (refreshPeerMaterial = false) => {
947
1028
  const recipientPrekeys = refreshPeerMaterial
@@ -954,26 +1035,13 @@ export class AUNClient {
954
1035
  timestamp,
955
1036
  protectedHeaders,
956
1037
  });
957
- // 多设备过滤:只保留有有效 device_id 的可路由 prekey
958
- // 占位符 PREKEY_FALLBACK_DEVICE_ID 表示服务端未分配真实设备 ID,不可用于多设备路由
1038
+ // 统一 multi-device 路径:必须有 routable prekey
959
1039
  const routablePrekeys = recipientPrekeys.filter(pk => {
960
1040
  const did = String(pk.device_id ?? '').trim();
961
1041
  return did && did !== PREKEY_FALLBACK_DEVICE_ID;
962
1042
  });
963
- // 只要有 routable prekey 就走 multi_device 路径(即使只有 1 个 recipient device + 0 self copies)。
964
- // 这确保服务端为每个已注册设备存储副本,离线设备重连后能 pull 到。
965
- // single 路径仅在完全没有 routable prekey 时使用(legacy 兼容)。
966
- const canUseMultiDevice = routablePrekeys.length > 0;
967
- if (!canUseMultiDevice) {
968
- return await this._sendEncryptedSingle({
969
- toAid,
970
- payload,
971
- messageId,
972
- timestamp,
973
- prekey: routablePrekeys[0] ?? recipientPrekeys[0],
974
- persistRequired,
975
- protectedHeaders,
976
- });
1043
+ if (routablePrekeys.length === 0) {
1044
+ throw new Error(`no registered device prekeys for ${toAid}, cannot send encrypted message`);
977
1045
  }
978
1046
  const recipientCopies = await this._buildRecipientDeviceCopies({
979
1047
  toAid,
@@ -1025,39 +1093,6 @@ export class AUNClient {
1025
1093
  throw exc;
1026
1094
  }
1027
1095
  }
1028
- async _sendEncryptedSingle(opts) {
1029
- this._clientLog.debug(`_sendEncryptedSingle enter: to=${opts.toAid}, message_id=${opts.messageId}, has_prekey=${!!opts.prekey}, persist_required=${!!opts.persistRequired}`);
1030
- let prekey = opts.prekey ?? null;
1031
- if (!prekey) {
1032
- this._clientLog.debug(`_sendEncryptedSingle fetching peer prekey: to=${opts.toAid}`);
1033
- prekey = await this._fetchPeerPrekey(opts.toAid);
1034
- }
1035
- const peerCertFingerprint = typeof prekey?.cert_fingerprint === 'string' ? prekey.cert_fingerprint : undefined;
1036
- const peerCertPem = await this._fetchPeerCert(opts.toAid, peerCertFingerprint);
1037
- const [envelope, encryptResult] = this._encryptCopyPayload({
1038
- logicalToAid: opts.toAid,
1039
- payload: opts.payload,
1040
- peerCertPem,
1041
- prekey,
1042
- messageId: opts.messageId,
1043
- timestamp: opts.timestamp,
1044
- protectedHeaders: opts.protectedHeaders,
1045
- });
1046
- this._ensureEncryptResult(opts.toAid, encryptResult);
1047
- this._clientLog.debug(`_sendEncryptedSingle envelope built: to=${opts.toAid}, message_id=${opts.messageId}, scheme=${String(envelope?.scheme ?? '')}`);
1048
- const sendParams = {
1049
- to: opts.toAid,
1050
- payload: envelope,
1051
- type: 'e2ee.encrypted',
1052
- encrypted: true,
1053
- message_id: opts.messageId,
1054
- timestamp: opts.timestamp,
1055
- };
1056
- if (opts.persistRequired) {
1057
- sendParams.persist_required = true;
1058
- }
1059
- return await this._transport.call('message.send', sendParams);
1060
- }
1061
1096
  async _buildRecipientDeviceCopies(opts) {
1062
1097
  this._clientLog.debug(`_buildRecipientDeviceCopies enter: to=${opts.toAid}, message_id=${opts.messageId}, prekey_count=${opts.prekeys.length}`);
1063
1098
  const recipientCopies = [];
@@ -1349,33 +1384,6 @@ export class AUNClient {
1349
1384
  this._clientLog.warn(`lazy sync group ${groupId} failed: ${formatCaughtError(exc)}`);
1350
1385
  }
1351
1386
  }
1352
- /** 惰性同步:首次激活 P2P 通道时 pull 最近消息,建立 seq 基线 */
1353
- async _lazySyncP2p() {
1354
- this._p2pSynced = true;
1355
- if (!this._aid)
1356
- return;
1357
- try {
1358
- const ns = `p2p:${this._aid}`;
1359
- const afterSeq = this._seqTracker.getContiguousSeq(ns);
1360
- const result = await this._transport.call('message.pull', {
1361
- after_seq: afterSeq,
1362
- limit: 200,
1363
- });
1364
- const messages = Array.isArray(result?.messages) ? result.messages : [];
1365
- for (const msg of messages) {
1366
- const seq = msg?.seq;
1367
- if (seq != null)
1368
- this._seqTracker.onMessageSeq(ns, Number(seq));
1369
- }
1370
- if (messages.length > 0) {
1371
- this._saveSeqTrackerState();
1372
- this._clientLog.info(`lazy sync P2P: pull ${messages.length} messages, after_seq=${afterSeq}`);
1373
- }
1374
- }
1375
- catch (exc) {
1376
- this._clientLog.warn(`lazy sync P2P failed: ${formatCaughtError(exc)}`);
1377
- }
1378
- }
1379
1387
  _isGroupEpochTooOldError(exc) {
1380
1388
  const text = String(exc).toLowerCase();
1381
1389
  return text.includes('e2ee epoch too old') || text.includes('epoch below sender membership floor');
@@ -1774,7 +1782,6 @@ export class AUNClient {
1774
1782
  // P2P 空洞检测
1775
1783
  const seq = msg.seq;
1776
1784
  if (seq !== undefined && seq !== null && this._aid) {
1777
- this._p2pSynced = true; // 收到推送即视为已激活
1778
1785
  const ns = `p2p:${this._aid}`;
1779
1786
  const needPull = this._seqTracker.onMessageSeq(ns, seq);
1780
1787
  if (needPull) {
@@ -2108,8 +2115,64 @@ export class AUNClient {
2108
2115
  return this._attachCurrentInstanceContext(payload);
2109
2116
  }
2110
2117
  async _publishAppEvent(event, payload) {
2118
+ if ((event === 'message.received' || event === 'group.message_created') && isJsonObject(payload)) {
2119
+ this._maybeAppendEchoTraceReceive(payload);
2120
+ }
2121
+ // 注入本地/远端 agent.md etag,让应用层判断版本一致性;失败不影响业务。
2122
+ if (isJsonObject(payload)) {
2123
+ try {
2124
+ const localEtag = this._localAgentMdEtag || '';
2125
+ const remoteEtag = this._remoteAgentMdEtag || '';
2126
+ if (localEtag || remoteEtag) {
2127
+ const obj = payload;
2128
+ if (!('_agent_md' in obj)) {
2129
+ obj._agent_md = {
2130
+ local_etag: localEtag,
2131
+ remote_etag: remoteEtag,
2132
+ };
2133
+ }
2134
+ }
2135
+ }
2136
+ catch (err) {
2137
+ this._clientLog.debug(`agent_md etag inject skipped: ${err instanceof Error ? err.message : String(err)}`);
2138
+ }
2139
+ }
2111
2140
  await this._dispatcher.publish(event, this._normalizePublishedMessagePayload(event, payload));
2112
2141
  }
2142
+ _echoTimestamp() {
2143
+ const now = new Date();
2144
+ const hh = String(now.getHours()).padStart(2, '0');
2145
+ const mm = String(now.getMinutes()).padStart(2, '0');
2146
+ const ss = String(now.getSeconds()).padStart(2, '0');
2147
+ const ms = String(now.getMilliseconds()).padStart(3, '0');
2148
+ return `${hh}:${mm}:${ss}.${ms}`;
2149
+ }
2150
+ _isEchoPayload(payload) {
2151
+ if (!payload || typeof payload !== 'object' || Array.isArray(payload))
2152
+ return false;
2153
+ const text = payload.text;
2154
+ if (typeof text !== 'string' || text.length > 4096)
2155
+ return false;
2156
+ return text.split('\n', 1)[0].toLowerCase().includes('echo');
2157
+ }
2158
+ _maybeAppendEchoTraceSend(params) {
2159
+ const payload = params.payload;
2160
+ if (!this._isEchoPayload(payload))
2161
+ return;
2162
+ const uptime = this._connectedAt ? Math.floor((Date.now() - this._connectedAt) / 1000) : 0;
2163
+ const trace = `${this._echoTimestamp()} [AUN-SDK.send] aid=${this._aid ?? '-'} conn_uptime=${uptime}s`;
2164
+ params.payload = { ...payload, text: payload.text + '\n' + trace };
2165
+ }
2166
+ _maybeAppendEchoTraceReceive(msg) {
2167
+ if (msg.encrypted)
2168
+ return;
2169
+ const payload = msg.payload;
2170
+ if (!this._isEchoPayload(payload))
2171
+ return;
2172
+ const uptime = this._connectedAt ? Math.floor((Date.now() - this._connectedAt) / 1000) : 0;
2173
+ const trace = `${this._echoTimestamp()} [AUN-SDK.receive] aid=${this._aid ?? '-'} conn_uptime=${uptime}s`;
2174
+ msg.payload = { ...payload, text: payload.text + '\n' + trace };
2175
+ }
2113
2176
  _messageTargetsCurrentInstance(message) {
2114
2177
  if (!isJsonObject(message))
2115
2178
  return true;
@@ -4829,7 +4892,6 @@ export class AUNClient {
4829
4892
  this._pendingOrderedMsgs.clear();
4830
4893
  this._pendingDecryptMsgs.clear();
4831
4894
  this._groupSynced.clear();
4832
- this._p2pSynced = false;
4833
4895
  }
4834
4896
  _refreshSeqTrackerContext() {
4835
4897
  const nextContext = this._currentSeqTrackerContext();
@@ -4841,7 +4903,6 @@ export class AUNClient {
4841
4903
  this._pendingOrderedMsgs.clear();
4842
4904
  this._pendingDecryptMsgs.clear();
4843
4905
  this._groupSynced.clear();
4844
- this._p2pSynced = false;
4845
4906
  this._seqTrackerContext = nextContext;
4846
4907
  }
4847
4908
  /** 将 SeqTracker 状态保存到 keystore */
@@ -4967,10 +5028,13 @@ export class AUNClient {
4967
5028
  this._sessionParams.access_token = String(auth.token ?? params.access_token ?? '');
4968
5029
  }
4969
5030
  }
5031
+ if (isJsonObject(auth.hello) && 'heartbeat_interval' in auth.hello) {
5032
+ this._applyServerHeartbeatInterval(auth.hello.heartbeat_interval, 'auth');
5033
+ }
4970
5034
  }
4971
5035
  }
4972
5036
  else {
4973
- await this._auth.initializeWithToken(this._transport, challenge, String(params.access_token), {
5037
+ const hello = await this._auth.initializeWithToken(this._transport, challenge, String(params.access_token), {
4974
5038
  deviceId: this._deviceId,
4975
5039
  slotId: this._slotId,
4976
5040
  deliveryMode: this._connectDeliveryMode,
@@ -4979,8 +5043,12 @@ export class AUNClient {
4979
5043
  extraInfo,
4980
5044
  });
4981
5045
  this._syncIdentityAfterConnect(String(params.access_token));
5046
+ if (isJsonObject(hello) && 'heartbeat_interval' in hello) {
5047
+ this._applyServerHeartbeatInterval(hello.heartbeat_interval, 'auth');
5048
+ }
4982
5049
  }
4983
5050
  this._state = 'connected';
5051
+ this._connectedAt = Date.now();
4984
5052
  this._clientLog.debug(`auth complete, connection ready: aid=${this._aid ?? ''}, gateway=${gatewayUrl}`);
4985
5053
  await this._dispatcher.publish('connection.state', { state: this._state, gateway: gatewayUrl });
4986
5054
  // auth 阶段 aid 可能被 identity 覆盖(上方 this._aid = identity.aid);
@@ -5105,7 +5173,7 @@ export class AUNClient {
5105
5173
  _buildSessionOptions(params) {
5106
5174
  const connectionKind = String(params.connection_kind ?? 'long');
5107
5175
  const options = {
5108
- auto_reconnect: connectionKind === 'short' ? false : DEFAULT_SESSION_OPTIONS.auto_reconnect,
5176
+ auto_reconnect: DEFAULT_SESSION_OPTIONS.auto_reconnect,
5109
5177
  heartbeat_interval: DEFAULT_SESSION_OPTIONS.heartbeat_interval,
5110
5178
  token_refresh_before: DEFAULT_SESSION_OPTIONS.token_refresh_before,
5111
5179
  retry: { ...DEFAULT_SESSION_OPTIONS.retry },
@@ -5129,11 +5197,12 @@ export class AUNClient {
5129
5197
  // ── 内部:后台任务 ────────────────────────────────────────
5130
5198
  /** 启动所有后台任务 */
5131
5199
  _startBackgroundTasks() {
5132
- // 短连接生命周期短,禁用心跳与 token 刷新(不接收推送、不需要长期会话维护)
5133
- if (this._sessionOptions.connection_kind === 'short')
5134
- return;
5135
- this._startHeartbeatTask();
5136
- this._startTokenRefreshTask();
5200
+ // 短连接不启动 heartbeat 与 token 刷新(生命周期短,不需要长期会话维护);
5201
+ // auto_reconnect 仍允许,由 _sessionOptions.auto_reconnect 决定
5202
+ if (this._sessionOptions.connection_kind !== 'short') {
5203
+ this._startHeartbeatTask();
5204
+ this._startTokenRefreshTask();
5205
+ }
5137
5206
  this._startGroupEpochTasks();
5138
5207
  }
5139
5208
  /** 停止所有后台任务 */
@@ -5171,10 +5240,9 @@ export class AUNClient {
5171
5240
  _startHeartbeatTask() {
5172
5241
  if (this._heartbeatTimer !== null)
5173
5242
  return;
5174
- const rawIntervalSeconds = Number(this._sessionOptions.heartbeat_interval ?? DEFAULT_SESSION_OPTIONS.heartbeat_interval);
5175
- if (!Number.isFinite(rawIntervalSeconds) || rawIntervalSeconds <= 0)
5243
+ const interval = clampHeartbeatInterval(this._sessionOptions.heartbeat_interval ?? DEFAULT_SESSION_OPTIONS.heartbeat_interval);
5244
+ if (interval <= 0)
5176
5245
  return;
5177
- const interval = Math.max(rawIntervalSeconds, 30) * 1000;
5178
5246
  // M25: 把连续失败阈值从 3 次收窄到 2 次。既能容忍一次网络抖动/GC 暂停,
5179
5247
  // 又把半开连接的检测延迟从 3 个心跳周期降到 2 个。
5180
5248
  // 真正的 socket 死亡由 ws.on('close') 立即触发 _handleTransportDisconnect,
@@ -5184,8 +5252,12 @@ export class AUNClient {
5184
5252
  this._heartbeatTimer = setInterval(() => {
5185
5253
  if (this._closing || this._state !== 'connected')
5186
5254
  return;
5187
- this._transport.call('meta.ping', {}).then(() => {
5255
+ this._transport.call('meta.ping', {}).then((pong) => {
5188
5256
  consecutiveFailures = 0;
5257
+ // 服务端可在 pong 中下发新的 heartbeat_interval(秒,0=关闭)
5258
+ if (isJsonObject(pong) && 'heartbeat_interval' in pong) {
5259
+ this._applyServerHeartbeatInterval(pong.heartbeat_interval, 'pong');
5260
+ }
5189
5261
  }).catch((exc) => {
5190
5262
  consecutiveFailures++;
5191
5263
  this._clientLog.warn(`heartbeat failed (${consecutiveFailures}/${maxFailures}): ${formatCaughtError(exc)}`);
@@ -5195,12 +5267,28 @@ export class AUNClient {
5195
5267
  this._handleTransportDisconnect(exc instanceof Error ? exc : new Error(String(exc)));
5196
5268
  }
5197
5269
  });
5198
- }, interval);
5270
+ }, interval * 1000);
5199
5271
  // 允许 Node.js 进程在只剩定时器时退出
5200
5272
  if (this._heartbeatTimer && typeof this._heartbeatTimer === 'object' && 'unref' in this._heartbeatTimer) {
5201
5273
  this._heartbeatTimer.unref();
5202
5274
  }
5203
5275
  }
5276
+ /** 服务端通过 hello/pong 下发 heartbeat_interval;clamp 后写入 session_options 并按需重启心跳。 */
5277
+ _applyServerHeartbeatInterval(raw, source) {
5278
+ const newInterval = clampHeartbeatInterval(raw);
5279
+ const oldInterval = clampHeartbeatInterval(this._sessionOptions.heartbeat_interval);
5280
+ if (newInterval === oldInterval)
5281
+ return;
5282
+ this._sessionOptions.heartbeat_interval = newInterval;
5283
+ this._clientLog.debug(`heartbeat_interval updated by ${source}: ${oldInterval} -> ${newInterval}`);
5284
+ if (this._heartbeatTimer !== null) {
5285
+ clearInterval(this._heartbeatTimer);
5286
+ this._heartbeatTimer = null;
5287
+ }
5288
+ if (newInterval > 0 && this._state === 'connected' && !this._closing) {
5289
+ this._startHeartbeatTask();
5290
+ }
5291
+ }
5204
5292
  /** 启动 token 刷新任务 */
5205
5293
  _startTokenRefreshTask() {
5206
5294
  if (this._tokenRefreshTimer !== null)