@bolloon/bolloon-agent 0.4.23 → 0.4.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.
Files changed (48) hide show
  1. package/dist/agents/execution-supervisor.js +391 -0
  2. package/dist/agents/goal-store.js +451 -0
  3. package/dist/agents/pi-harness.js +263 -0
  4. package/dist/agents/pi-sdk.js +568 -126
  5. package/dist/agents/run-store.js +772 -0
  6. package/dist/agents/runner-resolver.js +225 -0
  7. package/dist/agents/skills-manager.js +435 -0
  8. package/dist/agents/supervisor-host.js +249 -0
  9. package/dist/cron/tick-lock.js +1 -1
  10. package/dist/index.js +428 -22
  11. package/dist/ios/agent-delegate-server.js +58 -12
  12. package/dist/ios/icons/icon-1024x1024.png +0 -0
  13. package/dist/ios/icons/icon-1024x1024.webp +0 -0
  14. package/dist/ios/icons/icon-216x216.png +0 -0
  15. package/dist/ios/icons/icon-216x216.webp +0 -0
  16. package/dist/ios/index.html +95 -18
  17. package/dist/ios/manifest.json +1 -1
  18. package/dist/ios/mobile-agent.js +244 -5
  19. package/dist/ios/mobile-chain.js +481 -0
  20. package/dist/ios/mobile-core.js +25177 -24664
  21. package/dist/ios/mobile-helia.js +683 -0
  22. package/dist/ios/mobile-ipfs.js +680 -0
  23. package/dist/ios/mobile-orbit.js +268 -0
  24. package/dist/ios/mobile-p2p.js +130 -1
  25. package/dist/ios/mobile-social.js +624 -0
  26. package/dist/ios/mobile-sync.js +193 -0
  27. package/dist/ios/mobile-trade.js +492 -0
  28. package/dist/ios/mobile-wallet.js +11 -0
  29. package/dist/ios/mobile.css +39 -0
  30. package/dist/ios/mobile.html +95 -18
  31. package/dist/ios/mobile.js +1054 -55
  32. package/dist/ios/routes-x402-info.js +194 -0
  33. package/dist/ios/server.js +350 -9
  34. package/dist/ios/sw.js +26 -2
  35. package/dist/web/agent-delegate-server.js +58 -12
  36. package/dist/web/icons/icon-1024x1024.png +0 -0
  37. package/dist/web/icons/icon-1024x1024.webp +0 -0
  38. package/dist/web/icons/icon-216x216.png +0 -0
  39. package/dist/web/icons/icon-216x216.webp +0 -0
  40. package/dist/web/manifest.json +1 -1
  41. package/dist/web/mobile-agent.js +197 -3
  42. package/dist/web/mobile-core.js +16417 -16090
  43. package/dist/web/mobile-privacy.js +185 -0
  44. package/dist/web/mobile.css +15 -0
  45. package/dist/web/mobile.html +21 -1
  46. package/dist/web/mobile.js +179 -6
  47. package/dist/web/server.js +437 -4
  48. package/package.json +2 -2
@@ -0,0 +1,268 @@
1
+ /**
2
+ * 手机端 OrbitDB 本地副本 (库级复制 + 双向 merge)
3
+ *
4
+ * 电脑端跑 helia + OrbitDB (@orbitdb/core), 手机端(WebView)跑不了完整 libp2p 栈,
5
+ * 故手机端维护**同一批 store 的本地副本**:
6
+ * - 拉: GET 电脑端 store 全量条目 → 按确定性 LWW 规则合并进本地副本
7
+ * - 推: 本地独有的 / 本地胜出的条目 → POST 回电脑端 → 电脑端 put 进 OrbitDB
8
+ * (OrbitDB keyvalue 本身是 op-log 上的 LWW, 写穿后由它负责跨设备传播)
9
+ *
10
+ * 合并规则 (确定性, 两端各自算必得同一结果 → 收敛):
11
+ * ① 两边内容哈希相同 → 无变化
12
+ * ② 值内时间戳 (updatedAt/timestamp/ts) 大者胜
13
+ * ③ 时间戳相同 → 内容哈希字典序大者胜
14
+ *
15
+ * 本地副本是**离线可用**的: 断网也能读上次同步下来的全部数据.
16
+ */
17
+ const PREFIX = 'bolloon_orbit_replica:';
18
+ const URL_KEY = 'bolloon_desktop_base_url';
19
+ function ls() {
20
+ try {
21
+ return typeof localStorage !== 'undefined' ? localStorage : null;
22
+ }
23
+ catch {
24
+ return null;
25
+ }
26
+ }
27
+ // ============ 纯函数: 规范化 / 哈希 / 时间戳 ============
28
+ /** 递归键排序的稳定 JSON (同内容必得同串) */
29
+ export function stableJson(v) {
30
+ if (v === null || typeof v !== 'object')
31
+ return JSON.stringify(v ?? null);
32
+ if (Array.isArray(v))
33
+ return '[' + v.map(stableJson).join(',') + ']';
34
+ const keys = Object.keys(v).sort();
35
+ return '{' + keys.map((k) => JSON.stringify(k) + ':' + stableJson(v[k])).join(',') + '}';
36
+ }
37
+ /** 内容哈希 (djb2, 手机端自足; 只用于同端两值比较 → 不需要跨端一致) */
38
+ export function hashValue(v) {
39
+ const s = stableJson(v);
40
+ let h = 5381;
41
+ for (let i = 0; i < s.length; i++)
42
+ h = ((h << 5) + h + s.charCodeAt(i)) | 0;
43
+ return (h >>> 0).toString(16) + ':' + s.length.toString(16);
44
+ }
45
+ /** 值内时间戳 (没有 → 0, 交给哈希序兜底) */
46
+ export function valueTs(v) {
47
+ if (!v || typeof v !== 'object')
48
+ return 0;
49
+ const c = [v.updatedAt, v.timestamp, v.ts, v.createdAt];
50
+ for (const x of c) {
51
+ if (typeof x === 'number' && isFinite(x))
52
+ return x;
53
+ if (typeof x === 'string') {
54
+ const t = Date.parse(x);
55
+ if (!isNaN(t))
56
+ return t;
57
+ }
58
+ }
59
+ return 0;
60
+ }
61
+ /** 确定性胜负: 远端内容是否该覆盖本地 */
62
+ export function remoteWins(local, remoteValue, remoteHash) {
63
+ if (local.hash === remoteHash)
64
+ return false;
65
+ const rTs = valueTs(remoteValue);
66
+ if (rTs !== local.ts)
67
+ return rTs > local.ts;
68
+ return remoteHash > local.hash;
69
+ }
70
+ /**
71
+ * 合并一个 store: 本地副本 + 远端条目 → 新副本 / 需推回的条目 / 统计.
72
+ * 纯函数 (不改入参) — 两端各自调用必得同一结果.
73
+ */
74
+ export function mergeStore(local, remote, origin = 'desktop') {
75
+ const merged = {};
76
+ for (const [k, e] of Object.entries(local || {}))
77
+ merged[k] = { ...e };
78
+ let applied = 0;
79
+ let kept = 0;
80
+ const remoteHash = new Map();
81
+ for (const r of remote || []) {
82
+ if (!r || typeof r.key !== 'string')
83
+ continue;
84
+ const h = hashValue(r.value);
85
+ remoteHash.set(r.key, h);
86
+ const cur = merged[r.key];
87
+ if (!cur) {
88
+ merged[r.key] = { value: r.value, hash: h, ts: valueTs(r.value) || Date.now(), origin };
89
+ applied++;
90
+ continue;
91
+ }
92
+ if (remoteWins(cur, r.value, h)) {
93
+ merged[r.key] = { value: r.value, hash: h, ts: valueTs(r.value) || Date.now(), origin };
94
+ applied++;
95
+ }
96
+ else if (cur.hash !== h)
97
+ kept++;
98
+ }
99
+ // 本地独有 / 本地胜出 → 推回电脑端
100
+ const pushed = [];
101
+ for (const [k, e] of Object.entries(merged)) {
102
+ const rh = remoteHash.get(k);
103
+ if (rh === undefined || rh !== e.hash)
104
+ pushed.push({ key: k, value: e.value });
105
+ }
106
+ return { merged, pushed, applied, kept };
107
+ }
108
+ // ============ 本地副本读写 ============
109
+ export function getReplica(name) {
110
+ try {
111
+ const raw = ls()?.getItem(PREFIX + name);
112
+ if (!raw)
113
+ return { name, entries: {}, lastSyncTs: 0 };
114
+ const r = JSON.parse(raw);
115
+ return { name, address: r.address, entries: r.entries || {}, lastSyncTs: r.lastSyncTs || 0 };
116
+ }
117
+ catch {
118
+ return { name, entries: {}, lastSyncTs: 0 };
119
+ }
120
+ }
121
+ export function saveReplica(r) {
122
+ try {
123
+ ls()?.setItem(PREFIX + r.name, JSON.stringify(r));
124
+ }
125
+ catch { /* 存储满 → 忽略 */ }
126
+ }
127
+ /** 手机端本地写 (离线可写): 时间戳取当前 → 同步时胜出并推回电脑端 */
128
+ export function replicaPut(name, key, value) {
129
+ const r = getReplica(name);
130
+ r.entries[key] = { value, hash: hashValue(value), ts: Date.now(), origin: 'phone' };
131
+ saveReplica(r);
132
+ return r;
133
+ }
134
+ export function replicaAll(name) {
135
+ const r = getReplica(name);
136
+ return Object.entries(r.entries).map(([key, e]) => ({ key, value: e.value, hash: e.hash }));
137
+ }
138
+ export function replicaGet(name, key) {
139
+ const e = getReplica(name).entries[key];
140
+ return e ? e.value : null;
141
+ }
142
+ /** 已复制的 store 名列表 (记住曾经同步过的) */
143
+ export function replicaNames() {
144
+ const out = [];
145
+ try {
146
+ const s = ls();
147
+ if (!s)
148
+ return out;
149
+ for (let i = 0; i < s.length; i++) {
150
+ const k = s.key(i);
151
+ if (k && k.startsWith(PREFIX))
152
+ out.push(k.slice(PREFIX.length));
153
+ }
154
+ }
155
+ catch { /* 忽略 */ }
156
+ return out;
157
+ }
158
+ export function replicaStats() {
159
+ const names = replicaNames();
160
+ let entries = 0;
161
+ let lastSyncTs = 0;
162
+ for (const n of names) {
163
+ const r = getReplica(n);
164
+ entries += Object.keys(r.entries).length;
165
+ lastSyncTs = Math.max(lastSyncTs, r.lastSyncTs || 0);
166
+ }
167
+ return { stores: names.length, entries, lastSyncTs, names };
168
+ }
169
+ // ============ 与电脑端复制 ============
170
+ function baseUrlOf(baseUrl) {
171
+ try {
172
+ return String(baseUrl || ls()?.getItem(URL_KEY) || '').trim().replace(/\/+$/, '');
173
+ }
174
+ catch {
175
+ return '';
176
+ }
177
+ }
178
+ /** 列出电脑端可复制的 store */
179
+ export async function listDesktopStores(opts = {}) {
180
+ const base = baseUrlOf(opts.baseUrl);
181
+ if (!base)
182
+ return { ok: false, stores: [], error: '未配置电脑端地址' };
183
+ const f = opts.fetchImpl || (typeof fetch !== 'undefined' ? fetch : null);
184
+ if (!f)
185
+ return { ok: false, stores: [], error: '当前环境无 fetch' };
186
+ try {
187
+ const r = await f(`${base}/api/orbitdb/stores`);
188
+ if (!r.ok)
189
+ return { ok: false, stores: [], error: `电脑端返回 ${r.status}` };
190
+ const j = await r.json();
191
+ return { ok: true, stores: (j && j.stores) || [] };
192
+ }
193
+ catch (e) {
194
+ return { ok: false, stores: [], error: e?.message || String(e) };
195
+ }
196
+ }
197
+ /** 复制单个 store: 拉 → 合并 → 推回. 幂等, 反复调用安全. */
198
+ export async function replicateStore(name, opts = {}) {
199
+ const base = baseUrlOf(opts.baseUrl);
200
+ if (!base)
201
+ return { ok: false, store: name, error: '未配置电脑端地址' };
202
+ const f = opts.fetchImpl || (typeof fetch !== 'undefined' ? fetch : null);
203
+ if (!f)
204
+ return { ok: false, store: name, error: '当前环境无 fetch' };
205
+ try {
206
+ // ① 拉电脑端全量
207
+ const gr = await f(`${base}/api/orbitdb/entries?name=${encodeURIComponent(name)}`);
208
+ if (!gr.ok)
209
+ return { ok: false, store: name, error: `电脑端返回 ${gr.status}` };
210
+ const gj = await gr.json();
211
+ if (!gj || gj.ok === false)
212
+ return { ok: false, store: name, error: (gj && gj.error) || '读取失败' };
213
+ const remote = Array.isArray(gj.entries) ? gj.entries : [];
214
+ // ② 合并进本地副本
215
+ const local = getReplica(name);
216
+ const { merged, pushed, applied, kept } = mergeStore(local.entries, remote);
217
+ const next = { name, address: gj.address || local.address, entries: merged, lastSyncTs: Date.now() };
218
+ saveReplica(next);
219
+ // ③ 推回本地胜出/独有条目 (电脑端 put → OrbitDB 负责后续跨设备传播)
220
+ let pushedN = 0;
221
+ if (pushed.length) {
222
+ const pr = await f(`${base}/api/orbitdb/merge`, {
223
+ method: 'POST',
224
+ headers: { 'Content-Type': 'application/json' },
225
+ body: JSON.stringify({ name, entries: pushed }),
226
+ });
227
+ if (!pr.ok)
228
+ return { ok: false, store: name, error: `推回失败 ${pr.status}`, pulled: remote.length, applied, kept };
229
+ pushedN = pushed.length;
230
+ }
231
+ return { ok: true, store: name, pulled: remote.length, pushed: pushedN, applied, kept, total: Object.keys(merged).length };
232
+ }
233
+ catch (e) {
234
+ return { ok: false, store: name, error: e?.message || String(e) };
235
+ }
236
+ }
237
+ /** 复制电脑端全部 store (登录后自动 / 手动同步走这里) */
238
+ export async function replicateAll(opts = {}) {
239
+ const detail = [];
240
+ let names = opts.stores;
241
+ if (!names) {
242
+ const l = await listDesktopStores(opts);
243
+ if (!l.ok)
244
+ return { ok: false, stores: 0, pulled: 0, pushed: 0, entries: 0, error: l.error, detail };
245
+ names = l.stores.map((s) => s.name).filter(Boolean);
246
+ }
247
+ // 本地已有副本的 store 也要同步 (电脑端列表可能暂时读不到 → 保守保留)
248
+ const all = Array.from(new Set([...(names || []), ...replicaNames()]));
249
+ let pulled = 0;
250
+ let pushed = 0;
251
+ let entries = 0;
252
+ for (const n of all) {
253
+ const r = await replicateStore(n, opts);
254
+ detail.push(r);
255
+ if (r.ok) {
256
+ pulled += r.pulled || 0;
257
+ pushed += r.pushed || 0;
258
+ entries += r.total || 0;
259
+ }
260
+ }
261
+ const ok = detail.some((d) => d.ok);
262
+ return { ok, stores: all.length, pulled, pushed, entries, error: ok ? undefined : (detail[0] && detail[0].error), detail };
263
+ }
264
+ export default {
265
+ stableJson, hashValue, valueTs, remoteWins, mergeStore,
266
+ getReplica, saveReplica, replicaPut, replicaAll, replicaGet, replicaNames, replicaStats,
267
+ listDesktopStores, replicateStore, replicateAll,
268
+ };
@@ -21,9 +21,16 @@ import { gossipsub } from '@libp2p/gossipsub';
21
21
  import { circuitRelayTransport } from '@libp2p/circuit-relay-v2';
22
22
  import { ping } from '@libp2p/ping';
23
23
  import { multiaddr as createMultiaddr } from '@multiformats/multiaddr';
24
+ /**
25
+ * 2026-09-11: relay v2 的 hop 协议 —— 中继在 identify 里广播它。
26
+ * 客户端认证「对端是中继」就靠这个协议名 (@libp2p/circuit-relay-v2 RELAY_V2_HOP_CODEC)。
27
+ */
28
+ export const RELAY_HOP_PROTOCOL = '/libp2p/circuit/relay/0.2.0/hop';
24
29
  let node = null;
25
30
  let state = { connected: false, peerCount: 0, peerIds: [] };
26
31
  const msgHandlers = [];
32
+ /** 中继预约结果 (最近一次) */
33
+ let relayReservationResults = [];
27
34
  /** 持久化好友地址 (localStorage), 供 addMobilePeer 写入 + startMobileP2P 自动重连 */
28
35
  let peerAddrs = [];
29
36
  function loadPeerAddrs() {
@@ -46,7 +53,18 @@ export async function startMobileP2P(cfg = {}) {
46
53
  if (node)
47
54
  return state;
48
55
  try {
56
+ // 已知的中继 → 直接作为「configured」listen 地址 (传输层在 start 里就会预约,
57
+ // 不依赖 identify → relay discovery 拓扑的时机)。
58
+ const relayListen = (cfg.relayAddrs || [])
59
+ .map((a) => (a || '').trim().replace(/\/p2p-circuit\/?$/, ''))
60
+ .filter((a) => /\/p2p\/[^/]+$/.test(a))
61
+ .map((a) => `${a}/p2p-circuit`);
49
62
  node = await createLibp2p({
63
+ // 2026-09-11: 手机唯一的「可被拨入」途径 = 向中继预约。
64
+ // listen /p2p-circuit 会在传输层 reserveRelay() 一个待预约名额; 之后只要连上
65
+ // 一个广播了 hop 协议的中继, 就会自动预约并拿到 <relay>/p2p-circuit/p2p/<本机>。
66
+ // (WebView 不能 listen ip4/ip6 → 只有这一类 listen 项是有意义的。)
67
+ addresses: { listen: ['/p2p-circuit', ...relayListen] },
50
68
  transports: [webSockets(), circuitRelayTransport()],
51
69
  connectionEncrypters: [noise()],
52
70
  streamMuxers: [yamux()],
@@ -108,6 +126,20 @@ export async function startMobileP2P(cfg = {}) {
108
126
  console.warn(`[mobile-p2p] failed to dial saved ${addr}:`, String(e).slice(0, 100));
109
127
  }
110
128
  }
129
+ // 中继预约 (手机唯一的入站途径): 拨上中继还不够 —— 必须真的预约到名额,
130
+ // 才会有 <relay>/p2p-circuit/p2p/<本机> 这个可拨入地址。
131
+ // relayAddrs 已在 createLibp2p 的 addresses.listen 里作为 configured 中继 → start 时
132
+ // 传输层已经预约; 这里只对「还没拿到地址」的中继再补一次运行时预约 (兜底)。
133
+ for (const addr of cfg.relayAddrs || []) {
134
+ if (getMobileCircuitAddrs().length > 0)
135
+ break;
136
+ const r = await reserveMobileRelay(addr);
137
+ console.log(`[mobile-p2p] relay reserve ${addr} → ok=${r.ok} circuitAddrs=${r.circuitAddrs.length}` +
138
+ (r.error ? ` err=${String(r.error).slice(0, 140)}` : ''));
139
+ }
140
+ if (getMobileCircuitAddrs().length > 0) {
141
+ console.log(`[mobile-p2p] 可拨入地址: ${getMobileCircuitAddrs().join(' , ')}`);
142
+ }
111
143
  refreshState();
112
144
  return state;
113
145
  }
@@ -214,6 +246,97 @@ export function getMobileP2PConnections() {
214
246
  export function listMobilePeerAddrs() {
215
247
  return [...peerAddrs];
216
248
  }
249
+ // ─────────────────── 2026-09-11: 中继预约 (手机唯一的入站途径) ───────────────────
250
+ /** 手机只能拨 ws/wss → 可拨入地址里带 ws 段的排前面 (UI 只展示第一条) */
251
+ const wsFirstRank = (a) => {
252
+ const parts = a.split('/');
253
+ return parts.includes('ws') || parts.includes('wss') ? 0 : 1;
254
+ };
255
+ /**
256
+ * 本机**可拨入地址** —— getMultiaddrs() 里带 `/p2p-circuit` 的项。
257
+ * 形如 `/ip4/192.168.1.5/tcp/8765/ws/p2p/<中继PeerId>/p2p-circuit/p2p/<本机PeerId>`。
258
+ * 没有可用中继时返回 `[]` —— 这是**正常**的 (只代表暂时不能被别人拨入), 不是失败。
259
+ * ws/wss 地址排在前面 (WebView 拨不了裸 tcp)。
260
+ */
261
+ export function getMobileCircuitAddrs() {
262
+ if (!node)
263
+ return [];
264
+ try {
265
+ return (node.getMultiaddrs() || [])
266
+ .map((a) => (typeof a?.toString === 'function' ? a.toString() : String(a)))
267
+ .filter((a) => a.includes('/p2p-circuit'))
268
+ .sort((a, b) => wsFirstRank(a) - wsFirstRank(b));
269
+ }
270
+ catch {
271
+ return [];
272
+ }
273
+ }
274
+ /** 已经预约上的中继 PeerId 列表 (从 /p2p-circuit 地址里解析中继那一段) */
275
+ export function getMobileRelays() {
276
+ const out = [];
277
+ for (const a of getMobileCircuitAddrs()) {
278
+ const head = a.split('/p2p-circuit')[0] || '';
279
+ const ids = head.match(/\/p2p\/([^/]+)/g) || [];
280
+ const last = ids[ids.length - 1];
281
+ const relay = last ? last.slice('/p2p/'.length) : '';
282
+ if (relay && !out.includes(relay))
283
+ out.push(relay);
284
+ }
285
+ return out;
286
+ }
287
+ /** 最近一次各中继地址的预约结果 (UI 用来显示「预约失败: 原因」) */
288
+ export function getMobileRelayReservations() {
289
+ return relayReservationResults.map((r) => ({ ...r }));
290
+ }
291
+ /**
292
+ * 显式向一个中继预约 (circuit relay v2 reservation)。
293
+ *
294
+ * ⚠️ 血的教训 (2026-09-11, 实测): **js-libp2p 3.x 的 node 上没有 `listen()` 方法**
295
+ * (`libp2p.d.ts` 只有 dial/start/stop/…; listen 地址只能在 createLibp2p 的
296
+ * `addresses.listen` 里给)。所以运行时新增中继的正确 API 是
297
+ * `node.components.transportManager.listen([ma])` —— 这正是 circuit-relay 传输
298
+ * 自己在 `onStop` 里用的那一层 (内部但公开, interface-internal 的 TransportManager)。
299
+ *
300
+ * 地址必须带 `/p2p/<中继PeerId>` (预约要知道中继是谁); 不带直接报错, 不静默失败。
301
+ * 更稳的做法是在 `startMobileP2P({ relayAddrs })` 时就把它放进 `addresses.listen`
302
+ * (那边走的是 'configured' 预约路径, 与连接时机无关)。
303
+ */
304
+ export async function reserveMobileRelay(relayAddr) {
305
+ const raw = (relayAddr || '').trim();
306
+ if (!node)
307
+ return { ok: false, circuitAddrs: [], error: 'P2P 节点未启动' };
308
+ if (!raw)
309
+ return { ok: false, circuitAddrs: [], error: '中继地址为空' };
310
+ const base = raw.replace(/\/p2p-circuit\/?$/, '');
311
+ if (!/\/p2p\/[^/]+$/.test(base)) {
312
+ const err = '中继地址必须带 /p2p/<中继PeerId> (没有 PeerId 无法预约)';
313
+ relayReservationResults.push({ addr: raw, ok: false, error: err });
314
+ return { ok: false, circuitAddrs: [], error: err };
315
+ }
316
+ try {
317
+ const listenAddr = createMultiaddr(`${base}/p2p-circuit`);
318
+ if (typeof node.listen === 'function') {
319
+ // 老/新版本有 node.listen 就直接用
320
+ await node.listen(listenAddr);
321
+ }
322
+ else if (node.components?.transportManager?.listen) {
323
+ await node.components.transportManager.listen([listenAddr]);
324
+ }
325
+ else {
326
+ throw new Error('本版本 libp2p 既无 node.listen() 也无 components.transportManager.listen() —— 中继地址必须在 createLibp2p 的 addresses.listen 里给');
327
+ }
328
+ const circuitAddrs = getMobileCircuitAddrs();
329
+ const relay = getMobileRelays().slice(-1)[0];
330
+ relayReservationResults.push({ addr: raw, ok: circuitAddrs.length > 0, relay, error: circuitAddrs.length ? undefined : '预约返回但未拿到 /p2p-circuit 地址' });
331
+ return { ok: circuitAddrs.length > 0, circuitAddrs };
332
+ }
333
+ catch (e) {
334
+ const err = String(e?.message || e).slice(0, 240);
335
+ relayReservationResults.push({ addr: raw, ok: false, error: err });
336
+ console.warn('[mobile-p2p] relay reservation failed:', err);
337
+ return { ok: false, circuitAddrs: getMobileCircuitAddrs(), error: err };
338
+ }
339
+ }
217
340
  /**
218
341
  * 添加 P2P 好友 (按 multiaddr 地址 dial 连接)。
219
342
  * 校验 → 记录到 localStorage (startMobileP2P 会自动重连) → 若节点已启动则立即 dial。
@@ -245,6 +368,12 @@ export async function addMobilePeer(addr) {
245
368
  }
246
369
  }
247
370
  // 暴露同步状态读取 (mobile-core.network.status 用)
371
+ // 2026-09-11: 带上中继可观测项 —— circuitAddrs = 本机可被拨入的地址, relays = 已预约的中继
248
372
  if (typeof globalThis !== 'undefined') {
249
- globalThis.__mobileP2PStateSync = () => ({ ...state });
373
+ globalThis.__mobileP2PStateSync = () => ({
374
+ ...state,
375
+ circuitAddrs: getMobileCircuitAddrs(),
376
+ relays: getMobileRelays(),
377
+ relayReservations: getMobileRelayReservations(),
378
+ });
250
379
  }