@bolloon/bolloon-agent 0.3.24 → 0.3.26

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.
@@ -138,7 +138,7 @@ function isValidWalletAddress(addr) {
138
138
  // 2026-07-06: loadChannels / saveChannels / loadSession / saveSession / loadTheme / saveTheme
139
139
  // 已抽到 ./server-storage.ts — 这里只保留旧引用供 createWebServer 内部闭包使用
140
140
  // TODO: 后续让所有调用走 import, 然后删除这段冗余定义
141
- import { loadChannels as _loadChannels, saveChannels as _saveChannels, loadSession as _loadSession, saveSession as _saveSession, loadTheme as _loadTheme, saveTheme as _saveTheme, getLastChannelsWriteAt, } from './server-storage.js';
141
+ import { loadChannels as _loadChannels, saveChannels as _saveChannels, loadSession as _loadSession, saveSession as _saveSession, loadTheme as _loadTheme, saveTheme as _saveTheme, getLastChannelsWriteAt, updateChannels, } from './server-storage.js';
142
142
  // 包装成闭包内可用的形式, 保持 createWebServer 内代码不用改
143
143
  const loadChannels = _loadChannels;
144
144
  const saveChannels = _saveChannels;
@@ -151,6 +151,10 @@ let sseClients = new Set();
151
151
  // v3: 远端 channel UI 元数据缓存 — key: peerId, value: sanitize 过的 channel 列表
152
152
  // in-memory only, 进程重启清空 (judgment 内容永远不在这里)
153
153
  let remoteChannelCache = new Map();
154
+ // 2026-08-02: 待处理好友申请 (pending friend requests) — 收到 agent.friend.request 时暂存,
155
+ // 人类通过 UI 处理, 智能体通过工具 list_pending_friend_requests / accept_friend_request 处理。
156
+ // key: requestId, value: 申请详情 (含 fromPublicKey / name / message / 备注)
157
+ const pendingFriendRequests = new Map();
154
158
  // 2026-07-05: 一次性 prompt 附加块 — key: channelId, value: 下一次 LLM prompt 时 prepend 的内容
155
159
  // 用于 manifest-loader 加载对方能力后, 仅影响本次对话, 不污染主 prompt
156
160
  const nextPromptHints = new Map();
@@ -217,6 +221,48 @@ async function persistRemoteChannelCache() {
217
221
  }
218
222
  // 启动时立即同步读一次 (异步, 不阻塞)
219
223
  loadRemoteChannelCacheFromDisk();
224
+ // 2026-08-02: pending 好友申请持久化 — 重启后智能体/人类仍能看到未处理的申请
225
+ const PENDING_FRIEND_REQ_FILE = `${process.env.HOME || '/tmp'}/.bolloon/pending-friend-requests.json`;
226
+ async function loadPendingFriendRequestsFromDisk() {
227
+ try {
228
+ const { readFile } = await import('fs/promises');
229
+ const { existsSync } = await import('fs');
230
+ if (!existsSync(PENDING_FRIEND_REQ_FILE))
231
+ return;
232
+ const raw = await readFile(PENDING_FRIEND_REQ_FILE, 'utf-8');
233
+ const obj = JSON.parse(raw);
234
+ if (obj && typeof obj === 'object') {
235
+ for (const [reqId, r] of Object.entries(obj)) {
236
+ if (r && typeof r === 'object' && r.fromPublicKey) {
237
+ pendingFriendRequests.set(reqId, r);
238
+ }
239
+ }
240
+ console.log(`[v3-friend] 从磁盘恢复 ${pendingFriendRequests.size} 个待处理好友申请`);
241
+ }
242
+ }
243
+ catch (err) {
244
+ console.warn('[v3-friend] 恢复 pending 好友申请失败 (非致命):', err.message);
245
+ }
246
+ }
247
+ async function persistPendingFriendRequests() {
248
+ try {
249
+ const { writeFile, mkdir } = await import('fs/promises');
250
+ const { existsSync } = await import('fs');
251
+ if (!existsSync(`${process.env.HOME || '/tmp'}/.bolloon`)) {
252
+ await mkdir(`${process.env.HOME || '/tmp'}/.bolloon`, { recursive: true });
253
+ }
254
+ const obj = {};
255
+ for (const [reqId, r] of pendingFriendRequests.entries()) {
256
+ obj[reqId] = r;
257
+ }
258
+ await writeFile(PENDING_FRIEND_REQ_FILE, JSON.stringify(obj, null, 2), 'utf-8');
259
+ }
260
+ catch (err) {
261
+ console.warn('[v3-friend] 持久化 pending 好友申请失败 (非致命):', err.message);
262
+ }
263
+ }
264
+ // 启动时恢复 (异步, 不阻塞主流程)
265
+ loadPendingFriendRequestsFromDisk();
220
266
  // v3: P2PDirect 引用 (Hyperswarm 薄包装) - 模块级, 因为 web server 闭包里不可用
221
267
  let v3P2PRef = null;
222
268
  // 2026-07-21: 智能体社交心跳实例 (beacon + 自主决策发起对话), data 事件处理器会引用它
@@ -459,12 +505,14 @@ async function handleV3P2PMessage(parsed, conn, comm) {
459
505
  if (op === 'agent.chat.send') {
460
506
  // B 端发来: 在 A 节点上对 channelId 跑 LLM, 结果回 B
461
507
  // judgment 永远在 A 节点 (buildJudgmentHint 已经用 bound_judgment_ids)
462
- const { channelId, text, fromPublicKey } = parsed.payload || {};
508
+ const { channelId, text, fromPublicKey, autoInvokeTools } = parsed.payload || {};
463
509
  if (!channelId || !text) {
464
510
  console.warn(`[v3] agent.chat.send 缺少 channelId/text`);
465
511
  return;
466
512
  }
467
513
  const senderKey = fromPublicKey || peerKey;
514
+ // 2026-08-02: 发送方工具开关 (P2P 对话栏的 🔧 toggle) — 显式 false 时远端消息不调工具
515
+ const remoteToolsOverride = typeof autoInvokeTools === 'boolean' ? autoInvokeTools : null;
468
516
  // 2026-07-29: Dunbar Tier 检查 — 远端 agent 不能触发禁区工具
469
517
  const tierState = await loadPeerTier(senderKey);
470
518
  if (tierState.tier === 'blocked') {
@@ -588,7 +636,11 @@ async function handleV3P2PMessage(parsed, conn, comm) {
588
636
  // 2026-06-15: P2P 远端访客路径也用显式 marker 包裹 text
589
637
  // 2026-06-15 二次修: 把 text 放在最前 (与主路径 server.ts:1868 对齐),
590
638
  // 避免 LLM 被 judgmentHint 末尾的 "..." 误判为整个 input 被截断
591
- const fullPrompt = `【本轮用户请求】\n${text}\n【请求结束】\n\n${visitorHint}${dirHint}${judgmentHint}\n`;
639
+ // 2026-08-02: 发送方工具开关 (P2P 🔧 toggle) — false 时禁止本轮工具调用
640
+ const toolsGateHint = remoteToolsOverride === false
641
+ ? '\n[系统指令] 本轮对话**禁止调用任何工具**, 直接基于已有知识回答即可.\n\n'
642
+ : '';
643
+ const fullPrompt = `【本轮用户请求】\n${text}\n【请求结束】\n\n${visitorHint}${dirHint}${toolsGateHint}${judgmentHint}\n`;
592
644
  let fullResponse = '';
593
645
  // v3 新增: 流式 token 节流推给 B — 让 B 看到过程
594
646
  let lastFlushAt = 0;
@@ -610,6 +662,26 @@ async function handleV3P2PMessage(parsed, conn, comm) {
610
662
  comm.sendToConnection(conn.id, msg).catch(() => { });
611
663
  }
612
664
  }
665
+ // 2026-08-02 fix: 远端路径转发 step 事件 (工具调用过程) — 之前只转 token,
666
+ // B 端 P2P 对话看不到工具调用步骤. 复用 agent.chat.thinking 的 phase=step 通道.
667
+ else if (event.type === 'step_start' || event.type === 'step_done' || event.type === 'step_error') {
668
+ const msg = JSON.stringify({
669
+ v: 3, op: 'agent.chat.thinking',
670
+ payload: {
671
+ channelId,
672
+ phase: 'step',
673
+ stepType: event.type,
674
+ tool: event.tool,
675
+ content: event.content,
676
+ success: event.success,
677
+ output: event.output,
678
+ error: event.error,
679
+ args: event.args,
680
+ fromPublicKey: v3P2PRef?.getPublicKey() || ''
681
+ }
682
+ });
683
+ comm.sendToConnection(conn.id, msg).catch(() => { });
684
+ }
613
685
  };
614
686
  const agent = await getAgentForChannel(channelId, ch.did || '', ch.name, ch.didDocRef);
615
687
  fullResponse = await agent.promptStream(fullPrompt, streamCallback, undefined, channelId);
@@ -1499,7 +1571,11 @@ export async function createWebServer(port = 3000, options = {}) {
1499
1571
  if (parsed.op === 'agent.heartbeat') {
1500
1572
  agentHeartbeat?.handleIncoming('agent.heartbeat', parsed.payload, evt.fromPublicKey);
1501
1573
  // 2026-07-29: 收到心跳也记录交互 (Dunbar 自动归类)
1502
- recordInteraction(evt.fromPublicKey).catch(() => { });
1574
+ // 2026-08-02 fix: 之前不传 text → inferOpponentMove('') = defect,
1575
+ // heartbeat 被误判为背叛 → trustScore 持续下跌 → peer 跌入 blocked,
1576
+ // 导致对端消息被拒 (❌ 您已被本地系统加入通信黑名单)。
1577
+ // 改为传存活信号文本, 让机器协议消息判为 cooperate (在线维持连接 = 合作)。
1578
+ recordInteraction(evt.fromPublicKey, 'heartbeat 存活信号(自动)').catch(() => { });
1503
1579
  return;
1504
1580
  }
1505
1581
  // v3 新增: B 端收到 A 的 thinking (开始 + 流式 token)
@@ -1578,11 +1654,27 @@ export async function createWebServer(port = 3000, options = {}) {
1578
1654
  // v3 新增: 好友申请 RPC — 任何对端可以发, 推到前端 UI 让用户接受
1579
1655
  if (parsed.op === 'agent.friend.request') {
1580
1656
  console.log(`[v3-friend] 收到 ${evt.fromPublicKey.substring(0, 12)}... 的好友申请: ${parsed.payload?.name || '(无名字)'}`);
1657
+ const reqFromName = parsed.payload?.name || ('peer-' + evt.fromPublicKey.substring(0, 8));
1658
+ const reqMessage = parsed.payload?.message || '想加你为 P2P 好友';
1659
+ // 2026-08-02: 备注 = 申请消息或显式 note 字段 (自我介绍 / 来源), 存 pending 供智能体工具查询
1660
+ const reqNote = parsed.payload?.note || reqMessage;
1661
+ if (parsed.payload?.requestId) {
1662
+ pendingFriendRequests.set(parsed.payload.requestId, {
1663
+ requestId: parsed.payload.requestId,
1664
+ fromPublicKey: evt.fromPublicKey,
1665
+ fromName: reqFromName,
1666
+ message: reqMessage,
1667
+ note: reqNote,
1668
+ receivedAt: Date.now(),
1669
+ });
1670
+ persistPendingFriendRequests(); // 2026-08-02: 落盘, 重启不丢
1671
+ }
1581
1672
  broadcast({
1582
1673
  type: 'friend-request',
1583
1674
  fromPublicKey: evt.fromPublicKey,
1584
- fromName: parsed.payload?.name || ('peer-' + evt.fromPublicKey.substring(0, 8)),
1585
- message: parsed.payload?.message || '想加你为 P2P 好友',
1675
+ fromName: reqFromName,
1676
+ message: reqMessage,
1677
+ note: reqNote, // 2026-08-02: 透传备注给 UI
1586
1678
  requestId: parsed.payload?.requestId, // 2026-06-10: 透传 requestId 给前端
1587
1679
  timestamp: Date.now()
1588
1680
  }, 'p2p-global');
@@ -2278,6 +2370,26 @@ ${goalDesc}
2278
2370
  const identity = await loadOrCreateUserIdentity();
2279
2371
  res.json(identity);
2280
2372
  });
2373
+ // 2026-08-02: 修改左下角用户名 (写回 ~/.bolloon/identity/user.json)
2374
+ app.put('/api/user/identity', async (req, res) => {
2375
+ try {
2376
+ const { name } = req.body || {};
2377
+ const newName = String(name || '').trim().slice(0, 40);
2378
+ if (!newName)
2379
+ return res.status(400).json({ error: 'name 必填' });
2380
+ const identity = await loadOrCreateUserIdentity();
2381
+ identity.name = newName;
2382
+ userIdentityCache = identity;
2383
+ const { mkdirSync, writeFileSync } = await import('fs');
2384
+ mkdirSync(IDENTITY_DIR, { recursive: true });
2385
+ writeFileSync(`${IDENTITY_DIR}/user.json`, JSON.stringify(identity, null, 2), { mode: 0o600 });
2386
+ console.log(`[user-identity] 名字已更新: ${newName}`);
2387
+ res.json(identity);
2388
+ }
2389
+ catch (err) {
2390
+ res.status(500).json({ error: err.message });
2391
+ }
2392
+ });
2281
2393
  // 2026-07-01 (v0.2.6): 前后端分离核心 — 后端切 LLM 输出为结构化 segments
2282
2394
  // - POST /api/segment-reply { reply, knownTools }
2283
2395
  // - 返回 ChatSegment[] (think / text / env_details / tool_call / final)
@@ -2457,6 +2569,33 @@ ${goalDesc}
2457
2569
  if (!text) {
2458
2570
  return res.status(400).json({ error: 'No text provided' });
2459
2571
  }
2572
+ // 2026-08-02: / 斜杠命令路由 — 用户输入 /plan /todo /task 等快捷命令时,
2573
+ // 直接把命令转成结构化指令注入 contextHint, 引导 LLM 调用对应工具.
2574
+ // 前端 / 菜单插入的是 "/plan 目标; 步骤" 这类文本, 这里解析成提示.
2575
+ let slashCommandHint = '';
2576
+ try {
2577
+ const slashMatch = String(text).match(/^\/([a-z-]+)\s*(.*)$/is);
2578
+ if (slashMatch) {
2579
+ const [, cmdRaw, cmdArgs] = slashMatch;
2580
+ const cmd = cmdRaw.toLowerCase();
2581
+ const argText = String(cmdArgs || '').trim();
2582
+ const hints = {
2583
+ plan: `用户输入了 /plan 命令, 参数: "${argText}". 请调用 create_plan 工具: goal=参数中 ";" 前的部分 (或整句), steps=";" 后的步骤列表 (逗号分隔). 若参数为空, 先向用户确认目标和步骤.`,
2584
+ todo: `用户输入了 /todo 命令, 参数: "${argText}". 请调用 update_plan 勾选步骤状态: 格式 "计划ID; 步骤ID; done|blocked". 参数不完整就向用户要.`,
2585
+ review: `用户输入了 /review 命令, 参数: "${argText}". 请调用 review_plan: 格式 "计划ID; 审查总结". 参数不完整就向用户要.`,
2586
+ task: `用户输入了 /task 命令, 参数: "${argText}". 请调用 create_task 创建任务.`,
2587
+ goal: `用户输入了 /goal 命令, 参数: "${argText}". 请调用 park_goal 暂停目标.`,
2588
+ skill: `用户输入了 /skill 命令, 参数: "${argText}". 请调用 create_skill 沉淀技能.`,
2589
+ 'add-friend': `用户输入了 /add-friend 命令, 参数: "${argText}". 请调用 add_friend_by_id 添加好友 (参数: 64位 hex 公钥 + 可选备注).`,
2590
+ help: `用户输入了 /help. 请简要列出可用命令: /plan /todo /review /task /goal /skill /add-friend, 各一句话说明.`,
2591
+ };
2592
+ if (hints[cmd]) {
2593
+ slashCommandHint = `[系统上下文] 快捷命令路由:\n${hints[cmd]}\n\n`;
2594
+ console.log(`[slash] 命令 /${cmd} 参数="${argText.slice(0, 80)}"`);
2595
+ }
2596
+ }
2597
+ }
2598
+ catch { /* 命令解析失败忽略 */ }
2460
2599
  // 2026-07-15 修 Bug 3: 拖拽附件 — LLM 在 contextHint 里看到文件清单, 用户文本保持可读
2461
2600
  // 替代方案: 把 [attachment:id] 标记塞 text 里, 这里解析回 attachments 数组
2462
2601
  let parsedAttachments = [];
@@ -2501,7 +2640,11 @@ ${goalDesc}
2501
2640
  // 提前捕获 wallet/autoTools 到本地变量, 避免下面 try 块内的 inner const channel
2502
2641
  // (line ~638) 与这里外层的 const channel 形成 shadowing 让 TS 误报"使用前未声明"
2503
2642
  const boundWalletAddress = channel?.walletAddress;
2504
- const autoToolsEnabled = channel?.autoInvokeTools !== false; // 默认开启
2643
+ // 2026-08-02: 支持 per-message 覆盖 前端"发送默认配置"toggle 传 autoInvokeTools,
2644
+ // 有则用消息级设置, 没有则回落到 channel 配置 (默认开启)
2645
+ const autoToolsEnabled = typeof req.body?.autoInvokeTools === 'boolean'
2646
+ ? req.body.autoInvokeTools
2647
+ : channel?.autoInvokeTools !== false;
2505
2648
  // 捕获外层 channel 到独立变量, 避免被 try 块内 (line 740+) 的 const channel 遮蔽
2506
2649
  const channelForJudgment = channel;
2507
2650
  // per-channel queue 检查: 已在跑就入队, 等当前跑完自动接上
@@ -2619,6 +2762,9 @@ ${goalDesc}
2619
2762
  console.log(`[消息处理] 开始处理用户消息, channelId: ${channelId}, sessionId: ${currentSessionId}`);
2620
2763
  // 将真实 DID 作为上下文前缀,让 AI 使用真实的 DID 而不是自己编造的
2621
2764
  let contextHint = '';
2765
+ // 2026-08-02: slash 命令提示 (在 /message 开头解析, 这里注入)
2766
+ if (slashCommandHint)
2767
+ contextHint += slashCommandHint;
2622
2768
  if (realChannelDid)
2623
2769
  contextHint += `[系统上下文] 当前频道名称: ${realChannelName}, 你的真实 DID: ${realChannelDid}\n`;
2624
2770
  // v3 新增: 标识发送方 — 让 AI 分清内部 owner vs 远端访客
@@ -2712,6 +2858,68 @@ ${goalDesc}
2712
2858
  }
2713
2859
  contextHint += '回复时应自然体现这个角色 (不要硬搬原文, 像这个角色说话即可).\n\n';
2714
2860
  }
2861
+ // 2026-08-02: memory 回读 — 把本 channel 的历史记忆摘要注入 contextHint.
2862
+ // memory-compressor 每次 /message 后把增量摘要 append 到
2863
+ // ~/.bolloon/memory/<agentId>/sessions/<safe-channel>__<safe-session>.summary.md,
2864
+ // 但之前只写不读, 对话完全无记忆. 这里取尾部最近 3 段增量摘要回灌给 LLM.
2865
+ try {
2866
+ const { getSessionSummaryPath, getMemoryDir, sanitizeAgentId } = await import('../bootstrap/memory-compressor.js');
2867
+ const { readFile, readdir } = await import('fs/promises');
2868
+ const { join } = await import('path');
2869
+ const agentId = channelForJudgment?.agentId;
2870
+ if (agentId) {
2871
+ // 优先读当前 channel 的 summary (如果有)
2872
+ const currentSummary = getSessionSummaryPath(agentId, channelId, currentSessionId);
2873
+ const summariesToRead = [];
2874
+ try {
2875
+ const raw = await readFile(currentSummary, 'utf-8');
2876
+ if (raw.trim())
2877
+ summariesToRead.push(raw);
2878
+ }
2879
+ catch { /* 当前 channel 还没有摘要 */ }
2880
+ // 再兜底读同 agent 其他 channel 的最近摘要 (跨 channel 记忆)
2881
+ if (summariesToRead.length === 0) {
2882
+ try {
2883
+ const sessionsDir = join(getMemoryDir(agentId), 'sessions');
2884
+ const files = (await readdir(sessionsDir)).filter(f => f.endsWith('.summary.md'));
2885
+ // 按文件名排序取最近 2 个 (文件名含时间戳, 近似排序)
2886
+ const recent = files.sort().slice(-2);
2887
+ for (const f of recent) {
2888
+ if (f.includes(sanitizeAgentId(channelId)))
2889
+ continue; // 跳过已读的当前 channel
2890
+ try {
2891
+ const raw = await readFile(join(sessionsDir, f), 'utf-8');
2892
+ if (raw.trim())
2893
+ summariesToRead.push(raw);
2894
+ }
2895
+ catch { /* 单个摘要读失败跳过 */ }
2896
+ }
2897
+ }
2898
+ catch { /* 无 sessions 目录 */ }
2899
+ }
2900
+ if (summariesToRead.length > 0) {
2901
+ const memBlock = summariesToRead.map(s => s.trim().slice(-1500)).join('\n\n---\n\n');
2902
+ contextHint += `[系统上下文] 本 channel 的历史记忆 (来自 memory-compressor 摘要, 帮助你延续之前的对话, 引用而非复述):\n${memBlock.slice(-2500)}\n\n`;
2903
+ }
2904
+ }
2905
+ }
2906
+ catch (memErr) {
2907
+ // 静默失败 — memory 回读不是核心, 失败不阻塞
2908
+ }
2909
+ // 2026-08-02: plan 回读 — 把进行中的计划注入 contextHint (在 memory 之后)
2910
+ // 配合 create_plan / update_plan / review_plan 工具, 让 LLM 每次对话都能看到
2911
+ // 未完成的计划并继续推进.
2912
+ try {
2913
+ const { listActivePlans, planToContext } = await import('../agents/plan-store.js');
2914
+ const plans = await listActivePlans();
2915
+ if (plans.length > 0) {
2916
+ const plansBlock = plans.slice(0, 3).map(p => planToContext(p)).join('\n\n');
2917
+ contextHint += `[系统上下文] 进行中的计划 (来自 plan-store, 执行中每完成一步调 update_plan 勾选):\n${plansBlock}\n\n`;
2918
+ }
2919
+ }
2920
+ catch (planErr) {
2921
+ // 静默失败
2922
+ }
2715
2923
  const linkedIds = channelForJudgment?.linkedDocumentIds;
2716
2924
  if (Array.isArray(linkedIds) && linkedIds.length > 0) {
2717
2925
  try {
@@ -2974,7 +3182,13 @@ ${goalDesc}
2974
3182
  // 现在改名逻辑挪到 /api/agent-rename 端点, 用户主动触发才跑
2975
3183
  if (channel) {
2976
3184
  channel.updatedAt = new Date().toISOString();
2977
- await saveChannels(channels);
3185
+ // 2026-08-02 fix: 原子写入, 防并发覆盖丢 channel
3186
+ await updateChannels((chs) => {
3187
+ const c = chs.find(x => x.id === channelId);
3188
+ if (c)
3189
+ c.updatedAt = channel.updatedAt;
3190
+ return chs;
3191
+ });
2978
3192
  }
2979
3193
  broadcast({ type: 'done' }, channelId);
2980
3194
  // D 触发: AI 被动捕获判断力 (后台异步, 不阻塞主对话)
@@ -3065,6 +3279,35 @@ ${goalDesc}
3065
3279
  console.warn(`[web] saveCurrentSession failed (non-fatal): ${saveErr.message?.slice(0, 100)}`);
3066
3280
  }
3067
3281
  }
3282
+ // 2026-08-02: run-end skill 候选扫描 (fire-and-forget, 不阻塞 finally)
3283
+ // 从本轮 lastSteps 提取连续成功的工具调用模式, 写入 ~/.bolloon/skill-candidates/.
3284
+ // agent 之后可调 list_skill_candidates / promote_skill 决定是否转正.
3285
+ try {
3286
+ const steps = runState.lastSteps || [];
3287
+ const okTools = steps.filter(s => s.status === 'ok' && s.name && s.name !== 'system');
3288
+ if (okTools.length >= 2) {
3289
+ setImmediate(async () => {
3290
+ try {
3291
+ const { writeSkillCandidate } = await import('../agents/skill-writer.js');
3292
+ const toolNames = okTools.map(s => s.name).slice(0, 5).join(', ');
3293
+ const body = `## 背景\n本轮对话连续成功调用了 ${okTools.length} 个工具: ${toolNames}.\n\n## 流程\n${okTools.map(s => `1. 调用 ${s.name}${s.output ? ': ' + String(s.output).slice(0, 120) : ''}`).join('\n')}\n\n## 注意事项\n- 工具名以 list_skills / get_operation_logs 的实际注册名为准\n- 沉淀为正式 skill 前请人工确认流程可复用\n`;
3294
+ const candName = `auto-${okTools[0].name}-${Date.now().toString(36)}`;
3295
+ const file = await writeSkillCandidate({
3296
+ name: candName,
3297
+ description: `自动候选: ${okTools.length} 个工具连续成功 (${toolNames})`,
3298
+ body,
3299
+ source: `channel:${channelId}`,
3300
+ timestamp: new Date().toISOString(),
3301
+ });
3302
+ console.log(`[skill-candidates] 写入候选 ${file} (${okTools.length} tools)`);
3303
+ }
3304
+ catch (candErr) {
3305
+ console.warn('[skill-candidates] 写入失败 (non-fatal):', candErr?.message?.slice(0, 150));
3306
+ }
3307
+ });
3308
+ }
3309
+ }
3310
+ catch { /* 非致命 */ }
3068
3311
  }
3069
3312
  });
3070
3313
  // ---------- 频道元数据后台修复队列 ----------
@@ -3271,7 +3514,19 @@ ${goalDesc}
3271
3514
  catch {
3272
3515
  // IPFS 不可用, 跳过 — 下次再试
3273
3516
  }
3274
- await saveChannels(channels);
3517
+ // 2026-08-02 fix: 原子写入 — DID 修复是异步队列, 与创建/改名的裸 saveChannels
3518
+ // 并发时用旧数组覆盖 (lost update), 这是"重启后 channel 消失"的根因之一.
3519
+ await updateChannels((chs) => {
3520
+ const c = chs.find(x => x.id === channelId);
3521
+ if (c) {
3522
+ c.did = channel.did;
3523
+ c.publicKey = channel.publicKey;
3524
+ c.cid = channel.cid;
3525
+ c.didDocRef = channel.didDocRef;
3526
+ c.updatedAt = new Date().toISOString();
3527
+ }
3528
+ return chs;
3529
+ });
3275
3530
  }
3276
3531
  // 频道列表响应缓存: 短时间内重复请求走缓存, 避免每次重读 + 重序列化 channels.json
3277
3532
  // 跨作用域 (saveChannels 在模块顶层, 本函数在 createWebServer 内) 用 lastChannelsWriteAt 协调失效
@@ -3443,6 +3698,14 @@ ${goalDesc}
3443
3698
  return res.status(400).json({ error: 'name and agentId required' });
3444
3699
  }
3445
3700
  const channels = await loadChannels();
3701
+ // 2026-08-02 fix: 同 agentId 下禁止重名 — 之前用户连点两次"新建智能体"生成两个同名
3702
+ // "智能体" channel, UI 无法区分 (分享栏名字对不上 id). 同 agentId 同名直接拒绝.
3703
+ const dupName = channels.find(c => c.agentId === agentId && c.name === name.trim());
3704
+ if (dupName) {
3705
+ return res.status(400).json({
3706
+ error: `同名智能体已存在 (${dupName.name}, id=${dupName.id}), 请换一个名字`
3707
+ });
3708
+ }
3446
3709
  const id = `ch_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
3447
3710
  // 校验钱包地址格式 (粗校验: 0x + 40 hex / Solana base58 / Sui 0x+64)
3448
3711
  const validWallet = isValidWalletAddress(walletAddress);
@@ -3508,8 +3771,13 @@ ${goalDesc}
3508
3771
  }]
3509
3772
  };
3510
3773
  console.log(`[创建频道] 先保存频道 ID: ${id}`);
3511
- channels.push(channel);
3512
- await saveChannels(channels);
3774
+ // 2026-08-02 fix: 用 updateChannels 原子写入 — 之前 channels.push + saveChannels(channels)
3775
+ // 是裸 read-modify-write, 与 /message updatedAt 保存等并发时会互相覆盖 (lost update),
3776
+ // 新 channel 可能被旧数组冲掉 → 重启后只剩一个 channel 的 bug.
3777
+ await updateChannels((chs) => {
3778
+ chs.push(channel);
3779
+ return chs;
3780
+ });
3513
3781
  await saveSession({ channelId: id, sessionId: 'default', messages: [], lastUpdated: new Date().toISOString() });
3514
3782
  // 2026-07-15 修 Bug 6: 同步把 agent 定义写进 ~/.bolloon/agents/agents.json
3515
3783
  // 之前 channel 只落 channels.json, 不会出现在 agents.json 里.
@@ -3578,7 +3846,18 @@ ${goalDesc}
3578
3846
  channel.sessions.push(session);
3579
3847
  channel.currentSessionId = sessionId;
3580
3848
  channel.updatedAt = new Date().toISOString();
3581
- await saveChannels(channels);
3849
+ // 2026-08-02 fix: 原子写入
3850
+ await updateChannels((chs) => {
3851
+ const c = chs.find(x => x.id === channelId);
3852
+ if (c) {
3853
+ if (!c.sessions)
3854
+ c.sessions = [];
3855
+ c.sessions.push(session);
3856
+ c.currentSessionId = sessionId;
3857
+ c.updatedAt = new Date().toISOString();
3858
+ }
3859
+ return chs;
3860
+ });
3582
3861
  await saveSession({ channelId, sessionId, messages: [], lastUpdated: new Date().toISOString() });
3583
3862
  res.json({ session, currentSessionId: sessionId });
3584
3863
  }
@@ -3617,7 +3896,15 @@ ${goalDesc}
3617
3896
  }
3618
3897
  channel.currentSessionId = sessionId;
3619
3898
  channel.updatedAt = new Date().toISOString();
3620
- await saveChannels(channels);
3899
+ // 2026-08-02 fix: 原子写入
3900
+ await updateChannels((chs) => {
3901
+ const c = chs.find(x => x.id === channelId);
3902
+ if (c) {
3903
+ c.currentSessionId = sessionId;
3904
+ c.updatedAt = new Date().toISOString();
3905
+ }
3906
+ return chs;
3907
+ });
3621
3908
  res.json({ ok: true, currentSessionId: sessionId });
3622
3909
  }
3623
3910
  catch (err) {
@@ -3649,7 +3936,20 @@ ${goalDesc}
3649
3936
  channel.currentSessionId = nextSession.id;
3650
3937
  }
3651
3938
  channel.updatedAt = new Date().toISOString();
3652
- await saveChannels(channels);
3939
+ // 2026-08-02 fix: 原子写入
3940
+ await updateChannels((chs) => {
3941
+ const c = chs.find(x => x.id === channelId);
3942
+ if (c && c.sessions) {
3943
+ const idx = c.sessions.findIndex(s => s.id === sessionId);
3944
+ if (idx >= 0)
3945
+ c.sessions.splice(idx, 1);
3946
+ if (c.currentSessionId === sessionId && c.sessions[0]) {
3947
+ c.currentSessionId = c.sessions[0].id;
3948
+ }
3949
+ c.updatedAt = new Date().toISOString();
3950
+ }
3951
+ return chs;
3952
+ });
3653
3953
  // 删除 session 文件
3654
3954
  try {
3655
3955
  await fs.unlink(path.join(SESSION_CACHE_PATH, `${channelId}:${sessionId}.json`));
@@ -3671,8 +3971,13 @@ ${goalDesc}
3671
3971
  return res.status(404).json({ error: 'Channel not found' });
3672
3972
  }
3673
3973
  const channel = channels[index];
3674
- channels.splice(index, 1);
3675
- await saveChannels(channels);
3974
+ // 2026-08-02 fix: 原子写入
3975
+ await updateChannels((chs) => {
3976
+ const idx = chs.findIndex(c => c.id === channelId);
3977
+ if (idx >= 0)
3978
+ chs.splice(idx, 1);
3979
+ return chs;
3980
+ });
3676
3981
  // 清理该 channel 名下所有的 session 文件 + 默认 session 文件
3677
3982
  const candidates = new Set([`${channelId}.json`]);
3678
3983
  if (channel.sessions) {
@@ -3799,7 +4104,37 @@ ${goalDesc}
3799
4104
  console.log(`[Channel ${channelId}] 自动生成 share_id: ${channel.share_id}`);
3800
4105
  }
3801
4106
  channel.updatedAt = new Date().toISOString();
3802
- await saveChannels(channels);
4107
+ // 2026-08-02 fix: 原子写入 — 之前回调只写 shared_with_peers/share_id, name 等字段
4108
+ // 只改了外层内存对象, 磁盘没落 → 改名后重启名字回退 / 与 UI 显示不一致 ("改名没修好")
4109
+ await updateChannels((chs) => {
4110
+ const c = chs.find(x => x.id === channelId);
4111
+ if (c) {
4112
+ if (typeof name === 'string' && name.trim())
4113
+ c.name = name.trim();
4114
+ if (persona !== undefined) {
4115
+ if (persona === null)
4116
+ c.persona = undefined;
4117
+ else if (typeof persona === 'object')
4118
+ c.persona = channel.persona;
4119
+ }
4120
+ if (Array.isArray(linkedDocumentIds))
4121
+ c.linkedDocumentIds = channel.linkedDocumentIds;
4122
+ if (walletAddress !== undefined) {
4123
+ c.walletAddress = channel.walletAddress;
4124
+ c.walletRegisteredAt = channel.walletRegisteredAt;
4125
+ }
4126
+ if (typeof autoInvokeTools === 'boolean')
4127
+ c.autoInvokeTools = channel.autoInvokeTools;
4128
+ if (bound_judgment_ids !== undefined)
4129
+ c.bound_judgment_ids = channel.bound_judgment_ids;
4130
+ if (shared_with_peers !== undefined)
4131
+ c.shared_with_peers = channel.shared_with_peers;
4132
+ if (!c.share_id)
4133
+ c.share_id = channel.share_id;
4134
+ c.updatedAt = new Date().toISOString();
4135
+ }
4136
+ return chs;
4137
+ });
3803
4138
  // v3 修复: 分享变更后立即广播给所有 peer, 不用等对方手动刷新
3804
4139
  if (shared_with_peers !== undefined) {
3805
4140
  v3BroadcastOwn().catch(err => console.error('[v3] broadcast after share update failed:', err));
@@ -3862,7 +4197,18 @@ ${goalDesc}
3862
4197
  channel.autoInvokeTools = autoInvokeTools;
3863
4198
  }
3864
4199
  channel.updatedAt = new Date().toISOString();
3865
- await saveChannels(channels);
4200
+ // 2026-08-02 fix: 原子写入
4201
+ await updateChannels((chs) => {
4202
+ const c = chs.find(x => x.id === channelId);
4203
+ if (c) {
4204
+ c.walletAddress = walletAddress;
4205
+ c.walletRegisteredAt = new Date().toISOString();
4206
+ if (typeof autoInvokeTools === 'boolean')
4207
+ c.autoInvokeTools = autoInvokeTools;
4208
+ c.updatedAt = new Date().toISOString();
4209
+ }
4210
+ return chs;
4211
+ });
3866
4212
  console.log(`[Wallet] channel ${channelId} 绑定钱包 ${channel.walletAddress} 到 DID ${did}`);
3867
4213
  res.json(channel);
3868
4214
  }
@@ -3891,7 +4237,18 @@ ${goalDesc}
3891
4237
  channel.autoPayEnabled = autoPayEnabled;
3892
4238
  }
3893
4239
  channel.updatedAt = new Date().toISOString();
3894
- await saveChannels(channels);
4240
+ // 2026-08-02 fix: 原子写入
4241
+ await updateChannels((chs) => {
4242
+ const c = chs.find(x => x.id === channelId);
4243
+ if (c) {
4244
+ c.encryptedPrivateKey = encryptedPrivateKey;
4245
+ c.encryptedPrivateKeyIv = encryptedPrivateKeyIv;
4246
+ if (typeof autoPayEnabled === 'boolean')
4247
+ c.autoPayEnabled = autoPayEnabled;
4248
+ c.updatedAt = new Date().toISOString();
4249
+ }
4250
+ return chs;
4251
+ });
3895
4252
  console.log(`[Wallet] channel ${channelId} 已存储加密私钥 (autoPay=${channel.autoPayEnabled})`);
3896
4253
  res.json(channel);
3897
4254
  }
@@ -3911,7 +4268,17 @@ ${goalDesc}
3911
4268
  channel.encryptedPrivateKeyIv = undefined;
3912
4269
  channel.autoPayEnabled = false;
3913
4270
  channel.updatedAt = new Date().toISOString();
3914
- await saveChannels(channels);
4271
+ // 2026-08-02 fix: 原子写入
4272
+ await updateChannels((chs) => {
4273
+ const c = chs.find(x => x.id === channelId);
4274
+ if (c) {
4275
+ c.encryptedPrivateKey = undefined;
4276
+ c.encryptedPrivateKeyIv = undefined;
4277
+ c.autoPayEnabled = false;
4278
+ c.updatedAt = new Date().toISOString();
4279
+ }
4280
+ return chs;
4281
+ });
3915
4282
  console.log(`[Wallet] channel ${channelId} 已清除加密私钥`);
3916
4283
  res.json(channel);
3917
4284
  }
@@ -3933,7 +4300,15 @@ ${goalDesc}
3933
4300
  return res.status(404).json({ error: 'Channel not found' });
3934
4301
  channel.autoPayEnabled = autoPayEnabled;
3935
4302
  channel.updatedAt = new Date().toISOString();
3936
- await saveChannels(channels);
4303
+ // 2026-08-02 fix: 原子写入
4304
+ await updateChannels((chs) => {
4305
+ const c = chs.find(x => x.id === channelId);
4306
+ if (c) {
4307
+ c.autoPayEnabled = autoPayEnabled;
4308
+ c.updatedAt = new Date().toISOString();
4309
+ }
4310
+ return chs;
4311
+ });
3937
4312
  console.log(`[Wallet] channel ${channelId} autoPay → ${autoPayEnabled}`);
3938
4313
  res.json(channel);
3939
4314
  }
@@ -4230,8 +4605,65 @@ ${goalDesc}
4230
4605
  });
4231
4606
  app.delete('/api/p2p-peers/:name', async (req, res) => {
4232
4607
  try {
4233
- const { removePeer } = await import('../network/known-peers.js');
4234
- await removePeer(req.params.name);
4608
+ const { removePeer, listPeers } = await import('../network/known-peers.js');
4609
+ // 2026-08-02: 删除好友时同时撤回分享 — 从所有 channel 的 shared_with_peers 移除该 peer
4610
+ // 参数支持 name 或 publicKey (陌生 peer 只有 publicKey, 不在 known_peers)
4611
+ const peers = await listPeers();
4612
+ const param = req.params.name;
4613
+ const target = peers.find(p => p.name === param) || peers.find(p => p.publicKey === param);
4614
+ if (target) {
4615
+ const { readFile, writeFile } = await import('fs/promises');
4616
+ const channelsPath = `${process.env.HOME || '/tmp'}/.bolloon/sessions/channels.json`;
4617
+ try {
4618
+ const raw = await readFile(channelsPath, 'utf-8');
4619
+ const chData = JSON.parse(raw);
4620
+ const chs = Array.isArray(chData) ? chData : (chData.channels || []);
4621
+ let changed = 0;
4622
+ for (const ch of chs) {
4623
+ if (Array.isArray(ch.shared_with_peers) && ch.shared_with_peers.includes(target.publicKey)) {
4624
+ ch.shared_with_peers = ch.shared_with_peers.filter((pk) => pk !== target.publicKey);
4625
+ changed++;
4626
+ }
4627
+ }
4628
+ if (changed > 0) {
4629
+ if (Array.isArray(chData))
4630
+ await writeFile(channelsPath, JSON.stringify(chs, null, 2), 'utf-8');
4631
+ else {
4632
+ chData.channels = chs;
4633
+ await writeFile(channelsPath, JSON.stringify(chData, null, 2), 'utf-8');
4634
+ }
4635
+ console.log(`[p2p-peers] 删除好友 ${param}: 撤回 ${changed} 个 channel 的分享`);
4636
+ }
4637
+ }
4638
+ catch (e) {
4639
+ console.warn('[p2p-peers] 撤回 channel 分享失败 (non-fatal):', e?.message?.slice(0, 120));
4640
+ }
4641
+ }
4642
+ // 2026-08-02: 同时清掉 remote channel cache (内存 + 磁盘) — 否则 topic 在线节点
4643
+ // 删除后仍以 "陌生 peer" (peer-xxx) 出现在 UI, 无法真正删除
4644
+ try {
4645
+ const targetPk = target?.publicKey || (param.length === 64 ? param : null);
4646
+ if (targetPk)
4647
+ remoteChannelCache.delete(targetPk);
4648
+ const { existsSync } = await import('fs');
4649
+ if (existsSync(REMOTE_CACHE_FILE)) {
4650
+ const { readFile, writeFile } = await import('fs/promises');
4651
+ const cacheObj = JSON.parse(await readFile(REMOTE_CACHE_FILE, 'utf-8'));
4652
+ if (cacheObj && typeof cacheObj === 'object') {
4653
+ const pkToDel = targetPk || Object.keys(cacheObj).find((k) => k === param);
4654
+ if (pkToDel && cacheObj[pkToDel]) {
4655
+ delete cacheObj[pkToDel];
4656
+ await writeFile(REMOTE_CACHE_FILE, JSON.stringify(cacheObj, null, 2), 'utf-8');
4657
+ console.log(`[p2p-peers] 已清 remote cache: ${pkToDel.substring(0, 12)}...`);
4658
+ }
4659
+ }
4660
+ }
4661
+ }
4662
+ catch (e) {
4663
+ console.warn('[p2p-peers] 清 remote cache 失败 (non-fatal):', e?.message?.slice(0, 120));
4664
+ }
4665
+ // 陌生 peer (只有 publicKey) 不在 known_peers, removePeer 无操作也 ok
4666
+ await removePeer(target?.name || param);
4235
4667
  res.json({ ok: true });
4236
4668
  }
4237
4669
  catch (err) {
@@ -4316,7 +4748,7 @@ ${goalDesc}
4316
4748
  if (!v3P2PRef) {
4317
4749
  return res.status(503).json({ ok: false, code: 'P2P_NOT_STARTED', error: 'P2PDirect not started' });
4318
4750
  }
4319
- const { targetPublicKey, name, message } = req.body || {};
4751
+ const { targetPublicKey, name, message, note } = req.body || {};
4320
4752
  if (!targetPublicKey || typeof targetPublicKey !== 'string' || targetPublicKey.length !== 64) {
4321
4753
  return res.status(400).json({ ok: false, code: 'BAD_REQUEST', error: 'targetPublicKey (64 hex) required' });
4322
4754
  }
@@ -4343,7 +4775,8 @@ ${goalDesc}
4343
4775
  requestId, // 2026-06-10: 加 requestId, ack 时回带
4344
4776
  fromPublicKey: myPk,
4345
4777
  name: peerName,
4346
- message: message || '想加你为 P2P 好友, 共享 channel 协作'
4778
+ message: message || '想加你为 P2P 好友, 共享 channel 协作',
4779
+ note: note || message || undefined, // 2026-08-02: 备注 (自我介绍/来源), 优先显式 note
4347
4780
  }
4348
4781
  });
4349
4782
  // 2026-06-10: 用 sendToWithWait, 等 conn 真就绪后再发, 默认 5s 超时
@@ -4366,17 +4799,53 @@ ${goalDesc}
4366
4799
  res.status(500).json({ ok: false, code: 'EXCEPTION', error: err.message });
4367
4800
  }
4368
4801
  });
4802
+ // v3: 待处理好友申请查询 (智能体工具 list_pending_friend_requests 用)
4803
+ // 用法: GET /api/friend-requests
4804
+ app.get('/api/friend-requests', async (_req, res) => {
4805
+ const list = Array.from(pendingFriendRequests.values()).map(r => ({
4806
+ requestId: r.requestId,
4807
+ fromPublicKey: r.fromPublicKey,
4808
+ fromName: r.fromName,
4809
+ message: r.message,
4810
+ note: r.note || '',
4811
+ receivedAt: r.receivedAt,
4812
+ }));
4813
+ res.json({ count: list.length, requests: list });
4814
+ });
4815
+ // v3: 忽略/拒绝一个待处理好友申请 (智能体工具 ignore_friend_request 用)
4816
+ // 用法: POST /api/friend-requests/ignore { requestId }
4817
+ app.post('/api/friend-requests/ignore', async (req, res) => {
4818
+ try {
4819
+ const { requestId } = req.body || {};
4820
+ if (!requestId || !pendingFriendRequests.has(requestId)) {
4821
+ return res.status(404).json({ error: `未找到 requestId=${requestId} 的申请` });
4822
+ }
4823
+ const r = pendingFriendRequests.get(requestId);
4824
+ pendingFriendRequests.delete(requestId);
4825
+ persistPendingFriendRequests(); // 2026-08-02: 落盘
4826
+ console.log(`[v3-friend] 忽略好友申请: ${r.fromName} (${r.fromPublicKey.substring(0, 12)}...)`);
4827
+ res.json({ ok: true, ignored: r.fromName });
4828
+ }
4829
+ catch (err) {
4830
+ res.status(500).json({ error: err.message });
4831
+ }
4832
+ });
4369
4833
  // v3: 接受对方的好友申请 — 把对方加为 known_peers, 立即推我的 channel 列表给 ta
4370
- // 用法: POST /api/friend-accept { fromPublicKey, name }
4834
+ // 用法: POST /api/friend-accept { fromPublicKey, name, requestId? }
4371
4835
  app.post('/api/friend-accept', async (req, res) => {
4372
4836
  try {
4373
4837
  if (!v3P2PRef) {
4374
4838
  return res.status(503).json({ error: 'P2PDirect not started' });
4375
4839
  }
4376
- const { fromPublicKey, name } = req.body || {};
4840
+ const { fromPublicKey, name, requestId } = req.body || {};
4377
4841
  if (!fromPublicKey || typeof fromPublicKey !== 'string' || fromPublicKey.length !== 64) {
4378
4842
  return res.status(400).json({ error: 'fromPublicKey (64 hex) required' });
4379
4843
  }
4844
+ // 2026-08-02: 接受后清掉 pending 条目 (若带了 requestId)
4845
+ if (requestId) {
4846
+ pendingFriendRequests.delete(requestId);
4847
+ persistPendingFriendRequests();
4848
+ }
4380
4849
  // 持久化
4381
4850
  const { addOrUpdatePeer, findNameByPublicKey } = await import('../network/known-peers.js');
4382
4851
  const existing = await findNameByPublicKey(fromPublicKey);
@@ -4409,7 +4878,7 @@ ${goalDesc}
4409
4878
  if (!v3P2PRef) {
4410
4879
  return res.status(503).json({ error: 'P2PDirect not started' });
4411
4880
  }
4412
- const { targetPublicKey, channelId, text } = req.body || {};
4881
+ const { targetPublicKey, channelId, text, autoInvokeTools } = req.body || {};
4413
4882
  if (!targetPublicKey || !channelId || !text) {
4414
4883
  return res.status(400).json({ error: 'targetPublicKey, channelId, text required' });
4415
4884
  }
@@ -4418,8 +4887,12 @@ ${goalDesc}
4418
4887
  }
4419
4888
  const fromPk = v3P2PRef.getPublicKey();
4420
4889
  // 2026-07-27: 改用 sendOrQueue (先尝试直发, 失败则入队, 不断线不丢)
4890
+ // 2026-08-02: 透传 autoInvokeTools (发送方工具开关设置, 只对本次远端消息生效)
4421
4891
  const { sendOrQueue } = await import('../network/p2p-outbox.js');
4422
- const r = await sendOrQueue(targetPublicKey, 'agent.chat.send', { channelId, text, fromPublicKey: fromPk }, v3P2PRef);
4892
+ const r = await sendOrQueue(targetPublicKey, 'agent.chat.send', {
4893
+ channelId, text, fromPublicKey: fromPk,
4894
+ ...(typeof autoInvokeTools === 'boolean' ? { autoInvokeTools } : {}),
4895
+ }, v3P2PRef);
4423
4896
  if (r === 'FAILED') {
4424
4897
  return res.status(502).json({ error: 'send failed: peer not reachable' });
4425
4898
  }
@@ -5043,6 +5516,12 @@ ${goalDesc}
5043
5516
  if (s?.abortController) {
5044
5517
  s.abortController.abort();
5045
5518
  console.log(`[abort] user aborted channel=${channelId}`);
5519
+ // 2026-08-02 fix: abort 后立即广播 done — 之前前端靠 1.5s 兜底 setTimeout 切回 idle,
5520
+ // 视觉上"点了没反应". 这里主动推 done, 前端 finalizeTimelineAsMessage + setSendMode('idle') 立刻生效.
5521
+ try {
5522
+ broadcast({ type: 'done' }, channelId);
5523
+ }
5524
+ catch { /* 广播失败不影响 abort 主流程 */ }
5046
5525
  return res.json({ ok: true, aborted: true });
5047
5526
  }
5048
5527
  res.json({ ok: true, aborted: false });