@bolloon/bolloon-agent 0.3.25 → 0.3.27

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.
@@ -394,6 +394,81 @@ export function registerBuiltinTools(ctx) {
394
394
  }
395
395
  }
396
396
  });
397
+ // 2026-08-02: 远端 channel 工具 — 让本地智能体能获取远端 channel 列表 + 发送消息到远端
398
+ // (之前本地智能体看不到远端 channel, 无法 @ 远程智能体交流 — "工具没有给到位")
399
+ ctx.tools.set('list_remote_channels', {
400
+ name: 'list_remote_channels',
401
+ description: '列出 P2P 好友节点分享给你的远端 channel (远程智能体会话). 每个远端 channel 属于某个 peer, 你可以在回复中写 "@渠道名 消息内容" 或调用 send_to_remote_channel 给它们发消息.',
402
+ parameters: {},
403
+ execute: async () => {
404
+ try {
405
+ const port = process.env.PORT || '54188';
406
+ const res = await fetch(`http://127.0.0.1:${port}/api/remote-channels`);
407
+ if (!res.ok)
408
+ return { success: false, error: `HTTP ${res.status}` };
409
+ const data = await res.json();
410
+ const peers = data?.peers || [];
411
+ const lines = [];
412
+ let total = 0;
413
+ for (const p of peers) {
414
+ const chs = p.channels || [];
415
+ total += chs.length;
416
+ if (chs.length === 0)
417
+ continue;
418
+ lines.push(`👤 ${p.peerName || ('peer-' + String(p.peerId).substring(0, 8))} (${String(p.peerId).substring(0, 16)}…):`);
419
+ for (const c of chs) {
420
+ lines.push(` - @${c.name} (id=${c.id})`);
421
+ }
422
+ }
423
+ if (total === 0) {
424
+ return { success: true, output: '📭 当前没有远端 channel (没有好友分享 channel 给你, 或对方不在线). 可先用 add_friend_by_id 添加好友.' };
425
+ }
426
+ return { success: true, output: `🌐 ${total} 个远端 channel:\n${lines.join('\n')}\n\n在回复中写 "@渠道名 消息内容" 即可发送 (系统自动转发到对方节点).` };
427
+ }
428
+ catch (e) {
429
+ return { success: false, error: `获取远端 channel 失败: ${String(e.message || e)}` };
430
+ }
431
+ }
432
+ });
433
+ ctx.tools.set('send_to_remote_channel', {
434
+ name: 'send_to_remote_channel',
435
+ description: '发送消息到远端 channel (远程智能体会话, 属于某个 P2P 好友节点). 对方节点会在该 channel 上跑 LLM 处理你的消息并回复. 用 list_remote_channels 查看可用 channel 和 owner.',
436
+ parameters: {
437
+ targetPublicKey: '远端节点 publicKey (64 hex, 用 list_remote_channels 看 owner)',
438
+ channelId: '远端 channel id (用 list_remote_channels 查看)',
439
+ text: '消息内容 (必填)',
440
+ autoInvokeTools: '可选, 是否允许对方调用工具 (true/false, 默认 true)'
441
+ },
442
+ execute: async (args) => {
443
+ const targetPublicKey = String(args.targetPublicKey || '').trim();
444
+ const channelId = String(args.channelId || '').trim();
445
+ const text = String(args.text || '').trim();
446
+ if (!targetPublicKey || !channelId || !text) {
447
+ return { success: false, error: 'targetPublicKey, channelId, text 必填 (用 list_remote_channels 查)' };
448
+ }
449
+ try {
450
+ const port = process.env.PORT || '54188';
451
+ const res = await fetch(`http://127.0.0.1:${port}/api/remote-channels/chat-send`, {
452
+ method: 'POST',
453
+ headers: { 'Content-Type': 'application/json' },
454
+ body: JSON.stringify({
455
+ targetPublicKey,
456
+ channelId,
457
+ text,
458
+ ...(typeof args.autoInvokeTools === 'boolean' ? { autoInvokeTools: args.autoInvokeTools } : {}),
459
+ })
460
+ });
461
+ const data = await res.json();
462
+ if (!res.ok) {
463
+ return { success: false, error: `发送失败: ${data.error || `HTTP ${res.status}`}` };
464
+ }
465
+ return { success: true, output: `📨 消息已发送到远端 channel ${channelId} (${data.sent ? '已送达' : data.queued ? '对方不在线, 已入队, 上线后自动送达' : '未知状态'}). 对方智能体会回复, 可用 check_inbox 或稍后查看.` };
466
+ }
467
+ catch (e) {
468
+ return { success: false, error: `发送失败: ${String(e.message || e)}` };
469
+ }
470
+ }
471
+ });
397
472
  // delegate_to_engine — 把编码任务委派给本机已安装的其他 AI 编码智能体 CLI
398
473
  // (codex / claude-code / opencode / openclaw / hermes). 它们必须已安装且可达 PATH.
399
474
  // 实验 API 引擎 (experiment:xxx) 是供应商不是 CLI, 不支持委派, 工具会提示改用 import.
@@ -46,6 +46,8 @@ const TOOL_WHITELIST = new Set([
46
46
  'create_skill', 'update_skill', 'list_skill_candidates', 'promote_skill',
47
47
  // 2026-08-02: plan/todo/review 工具 (plan-store.ts)
48
48
  'create_plan', 'update_plan', 'review_plan', 'list_plans',
49
+ // 2026-08-02: 远端 channel 工具 (本地智能体 @ 远程交流)
50
+ 'list_remote_channels', 'send_to_remote_channel',
49
51
  ]);
50
52
  export const gateWhitelist = { gate: 'whitelist', allowed: true };
51
53
  /**
@@ -1689,7 +1689,72 @@
1689
1689
  v3GlobalEventSource.onmessage = (e) => {
1690
1690
  try {
1691
1691
  const msg = JSON.parse(e.data);
1692
- if (msg.type === "remote-chat-reply") {
1692
+ if (msg.type === "remote-chat-sent") {
1693
+ console.log("[v3] \u6536\u5230 remote-chat-sent:", msg.channelId, "|", String(msg.text || "").slice(0, 30));
1694
+ try {
1695
+ const pk = msg.fromPublicKey;
1696
+ const cid = msg.channelId;
1697
+ if (pk && cid && msg.text) {
1698
+ const key = `bolloon.rcmCache.${pk}.${cid}`;
1699
+ let arr = [];
1700
+ try {
1701
+ const raw = localStorage.getItem(key);
1702
+ if (raw) arr = JSON.parse(raw);
1703
+ } catch {
1704
+ arr = [];
1705
+ }
1706
+ if (!Array.isArray(arr)) arr = [];
1707
+ const entry = { type: "user", content: msg.text, timestamp: (/* @__PURE__ */ new Date()).toISOString(), source: "local-sent" };
1708
+ const dup = arr.some((m) => m.type === "user" && m.content === entry.content && m.source === "local-sent");
1709
+ if (!dup) {
1710
+ arr.push(entry);
1711
+ try {
1712
+ localStorage.setItem(key, JSON.stringify(arr.slice(-200)));
1713
+ } catch {
1714
+ }
1715
+ }
1716
+ }
1717
+ } catch {
1718
+ }
1719
+ const log = document.getElementById("rcm-log");
1720
+ if (!log) return;
1721
+ const modal = document.getElementById("remote-chat-modal");
1722
+ const openChannelId = modal ? modal.dataset.channelId : null;
1723
+ if (openChannelId && openChannelId !== msg.channelId) return;
1724
+ const prefix = `\u{1F464} \u6211 \u2192 \u8FDC\u7AEF${msg.peerName ? ` ${msg.peerName}` : ""}
1725
+
1726
+ `;
1727
+ addMessage2(prefix + (msg.text || ""), "user", false, log);
1728
+ if (msg.queued) {
1729
+ const qEl = document.createElement("div");
1730
+ qEl.className = "remote-chat-sysmsg remote-chat-sysmsg-info";
1731
+ qEl.textContent = "\u{1F4E4} \u5BF9\u65B9\u4E0D\u5728\u7EBF, \u6D88\u606F\u5DF2\u5165\u961F, \u4E0A\u7EBF\u540E\u81EA\u52A8\u9001\u8FBE";
1732
+ log.appendChild(qEl);
1733
+ }
1734
+ log.scrollTop = log.scrollHeight;
1735
+ } else if (msg.type === "remote-chat-step") {
1736
+ const log = document.getElementById("rcm-log");
1737
+ if (!log) return;
1738
+ const modal = document.getElementById("remote-chat-modal");
1739
+ const openChannelId = modal ? modal.dataset.channelId : null;
1740
+ if (openChannelId && openChannelId !== msg.channelId) return;
1741
+ let liveEl = document.getElementById("rcm-thinking-live-local");
1742
+ if (!liveEl) {
1743
+ liveEl = document.createElement("div");
1744
+ liveEl.id = "rcm-thinking-live-local";
1745
+ liveEl.className = "remote-chat-sysmsg remote-chat-sysmsg-info";
1746
+ log.appendChild(liveEl);
1747
+ }
1748
+ if (msg.stepType === "step_start") {
1749
+ liveEl.textContent = `\u{1F527} \u672C\u5730\u667A\u80FD\u4F53\u6B63\u5728\u8C03\u7528\u5DE5\u5177: ${msg.tool || "..."}`;
1750
+ } else if (msg.stepType === "step_done") {
1751
+ liveEl.textContent = `\u2705 \u672C\u5730\u5DE5\u5177\u8C03\u7528\u5B8C\u6210: ${msg.tool || ""}`;
1752
+ } else if (msg.stepType === "step_error") {
1753
+ liveEl.textContent = `\u274C \u672C\u5730\u5DE5\u5177\u8C03\u7528\u5931\u8D25: ${msg.tool || ""}`;
1754
+ liveEl.className = "remote-chat-sysmsg remote-chat-sysmsg-error";
1755
+ }
1756
+ log.scrollTop = log.scrollHeight;
1757
+ } else if (msg.type === "remote-chat-reply") {
1693
1758
  const log = document.getElementById("rcm-log");
1694
1759
  const thinkingEl = document.getElementById("rcm-thinking");
1695
1760
  if (thinkingEl) thinkingEl.style.display = "none";
@@ -1793,15 +1858,25 @@
1793
1858
  }
1794
1859
  }
1795
1860
  } else if (msg.type === "cross-mention-received") {
1861
+ const log = document.getElementById("rcm-log");
1862
+ const rcmModal = document.getElementById("remote-chat-modal");
1863
+ const rcmOpenId = rcmModal ? rcmModal.dataset.channelId : null;
1864
+ if (log && (!msg.targetChannelId || !rcmOpenId || rcmOpenId === msg.targetChannelId)) {
1865
+ const fromTxt = msg.source === "ai-mention-remote" ? `\u8FDC\u7AEF\u667A\u80FD\u4F53 ${msg.originChannelName ? `(${msg.originChannelName})` : ""}` : `${msg.originChannelName} (\u672C\u5730)`;
1866
+ addMessage2(`\u{1F4E1} ${fromTxt}
1867
+
1868
+ ${msg.text || ""}`, "ai", false, log);
1869
+ log.scrollTop = log.scrollHeight;
1870
+ }
1796
1871
  const allModals = document.querySelectorAll('.rcm-mention-toast, [id^="rcm-log"]');
1797
- for (const log of allModals) {
1798
- if (!log.id) continue;
1872
+ for (const logEl of allModals) {
1873
+ if (!logEl.id) continue;
1799
1874
  const toast = document.createElement("div");
1800
1875
  toast.style.cssText = "margin:6px 0;padding:8px 10px;background:#fce7f3;border-left:3px solid #ec4899;border-radius:4px;font-size:12px;color:#831843;";
1801
1876
  const fromTxt = msg.source === "ai-mention-remote" ? `\u8FDC\u7AEF\u8282\u70B9 ${(msg.fromPublicKey || "").substring(0, 8)}\u2026 \u7684 ${msg.originChannelName}` : `${msg.originChannelName} (\u672C\u5730)`;
1802
1877
  toast.innerHTML = `\u{1F4E1} <b>${fromTxt}</b> @-mention \u2192 \u5F53\u524D channel: <i>${escapeHtml2((msg.text || "").slice(0, 100))}</i>${msg.text && msg.text.length > 100 ? "\u2026" : ""}`;
1803
- log.appendChild(toast);
1804
- log.scrollTop = log.scrollHeight;
1878
+ logEl.appendChild(toast);
1879
+ logEl.scrollTop = logEl.scrollHeight;
1805
1880
  }
1806
1881
  } else if (msg.type === "remote-channel-update") {
1807
1882
  const peerId = msg.peerId;
@@ -4971,7 +5046,7 @@ ${data.error || "channel not found"}`, "error");
4971
5046
  function openRemoteChannelChat(peerPublicKey, channelId, channelName) {
4972
5047
  document.getElementById("remote-chat-modal")?.remove();
4973
5048
  const html = `
4974
- <div id="remote-chat-modal" class="remote-chat-overlay">
5049
+ <div id="remote-chat-modal" class="remote-chat-overlay" data-channel-id="${escapeHtml2(channelId)}" data-peer-id="${escapeHtml2(peerPublicKey)}">
4975
5050
  <div class="remote-chat-shell">
4976
5051
  <div class="remote-chat-header">
4977
5052
  <div style="flex:1;min-width:0;">
@@ -5093,7 +5168,11 @@ ${data.error || "channel not found"}`, "error");
5093
5168
  const type = m.type === "user" ? "user" : "ai";
5094
5169
  let prefix = "";
5095
5170
  if (m.type === "user") {
5096
- if (m.source === "remote") {
5171
+ if (m.source === "local-sent") {
5172
+ prefix = `\u{1F464} \u6211 \u2192 \u8FDC\u7AEF
5173
+
5174
+ `;
5175
+ } else if (m.source === "remote") {
5097
5176
  prefix = `\u{1F310} \u8FDC\u7AEF\u8BBF\u5BA2${m.fromPublicKey ? " (" + m.fromPublicKey.substring(0, 8) + "\u2026)" : ""}
5098
5177
 
5099
5178
  `;
@@ -5103,9 +5182,19 @@ ${data.error || "channel not found"}`, "error");
5103
5182
  `;
5104
5183
  }
5105
5184
  } else {
5106
- prefix = `\u{1F916} A \u7684 LLM
5185
+ if (m.source === "remote-reply") {
5186
+ prefix = `\u{1F916} \u8FDC\u7AEF\u56DE\u590D
5187
+
5188
+ `;
5189
+ } else if (m.source === "ai-mention-remote") {
5190
+ prefix = `\u{1F4E1} \u8FDC\u7AEF\u667A\u80FD\u4F53 ${m.originChannelName ? `(${m.originChannelName})` : ""}
5191
+
5192
+ `;
5193
+ } else {
5194
+ prefix = `\u{1F916} A \u7684 LLM
5107
5195
 
5108
5196
  `;
5197
+ }
5109
5198
  }
5110
5199
  addMessage2(prefix + (m.content || ""), type, false, log);
5111
5200
  }
@@ -5123,12 +5212,33 @@ ${data.error || "channel not found"}`, "error");
5123
5212
  writeRcmCache(cacheMsgs);
5124
5213
  } catch {
5125
5214
  }
5215
+ try {
5216
+ const cachedAll = readRcmCache();
5217
+ const localSent = cachedAll.filter((m) => m.source === "local-sent");
5218
+ if (localSent.length > 0) {
5219
+ const remoteContents = new Set(msgs.map((m) => m.content));
5220
+ for (const m of localSent) {
5221
+ if (remoteContents.has(m.content)) continue;
5222
+ const dup = Array.from(log.querySelectorAll(".message-user")).some(
5223
+ (el) => (el.textContent || "").includes(m.content)
5224
+ );
5225
+ if (dup) continue;
5226
+ addMessage2(`\u{1F464} \u6211 \u2192 \u8FDC\u7AEF
5227
+
5228
+ ${m.content}`, "user", false, log);
5229
+ }
5230
+ setTimeout(() => {
5231
+ log.scrollTop = log.scrollHeight;
5232
+ }, 50);
5233
+ }
5234
+ } catch {
5235
+ }
5126
5236
  }
5127
5237
  const doSend = async () => {
5128
5238
  const text = inputEl.value.trim();
5129
5239
  if (!text) return;
5130
5240
  append(text, "user");
5131
- cacheRemoteMessage(peerPublicKey, channelId, { type: "user", content: text, timestamp: (/* @__PURE__ */ new Date()).toISOString() });
5241
+ cacheRemoteMessage(peerPublicKey, channelId, { type: "user", content: text, timestamp: (/* @__PURE__ */ new Date()).toISOString(), source: "local-sent" });
5132
5242
  inputEl.value = "";
5133
5243
  sendBtn2.disabled = true;
5134
5244
  sendBtn2.textContent = "...";
@@ -6046,7 +6156,10 @@ ${data.error || "channel not found"}`, "error");
6046
6156
  autoInvokeTools
6047
6157
  })
6048
6158
  });
6049
- if (!res.ok) throw new Error("create failed");
6159
+ if (!res.ok) {
6160
+ const errData = await res.json().catch(() => ({}));
6161
+ throw new Error(errData.error || `HTTP ${res.status}`);
6162
+ }
6050
6163
  const channel = await res.json();
6051
6164
  channels.push(channel);
6052
6165
  renderChannels();
@@ -24,13 +24,20 @@ export function getLastChannelsWriteAt() {
24
24
  }
25
25
  /** 对 channels 执行原子化的 read-modify-write, 自带互斥锁 */
26
26
  export async function updateChannels(fn) {
27
- channelsLock = channelsLock.then(async () => {
27
+ const run = channelsLock.then(async () => {
28
28
  const chs = await rawLoadChannels();
29
29
  const result = fn(chs);
30
30
  await rawSaveChannels(result);
31
31
  return result;
32
32
  });
33
- return channelsLock;
33
+ // 2026-08-02 fix: 失败不毒化锁链 — 之前 channelsLock = channelsLock.then(...),
34
+ // 某次 fn 抛错 → 整个链变 rejected → 之后所有 updateChannels 直接 reject,
35
+ // fn 不再执行 → UI 创建 channel 偶发"不落盘" (静默丢失).
36
+ channelsLock = run.catch((e) => {
37
+ console.error('[updateChannels] 失败 (已隔离, 不影响后续操作):', e?.message || e);
38
+ return undefined;
39
+ });
40
+ return run;
34
41
  }
35
42
  async function rawLoadChannels() {
36
43
  try {
@@ -151,6 +151,9 @@ 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: channel 运行状态 (running/queue/abort/续看) — 模块级提升,
155
+ // triggerRemoteFollowup (模块级) 也要访问. createWebServer 启动时 clear.
156
+ let channelRunState = new Map();
154
157
  // 2026-08-02: 待处理好友申请 (pending friend requests) — 收到 agent.friend.request 时暂存,
155
158
  // 人类通过 UI 处理, 智能体通过工具 list_pending_friend_requests / accept_friend_request 处理。
156
159
  // key: requestId, value: 申请详情 (含 fromPublicKey / name / message / 备注)
@@ -274,6 +277,49 @@ let watchdogRef = null;
274
277
  const v3PendingHistoryGets = new Map();
275
278
  let channelSessions = new Map(); // key: channelId
276
279
  let sessionMessages = new Map(); // key: channelId + sessionId
280
+ // ============ 2026-08-02: 远端对话本地镜像 (替代 localStorage 缓存) ============
281
+ // 问题: localStorage 5MB 上限 + 同步阻塞 + 每浏览器独立; 且对方离线时 chat-history RPC 拉不到.
282
+ // 方案: 服务端磁盘镜像 ~/.bolloon/remote-chat-logs/<peerPk>__<channelId>.json —
283
+ // · 本地 @ 发出 (remote-chat-sent) → 写镜像 (source: local-sent)
284
+ // · 收到回复 (chat.reply) → 写镜像 (source: remote-reply)
285
+ // · chat-history API 先读镜像 (立即返回, 离线可读), 后台 RPC 增量合并对端历史
286
+ const REMOTE_CHAT_LOG_DIR = `${process.env.HOME || '/tmp'}/.bolloon/remote-chat-logs`;
287
+ async function readRemoteChatLog(peerPk, channelId) {
288
+ try {
289
+ const { readFile } = await import('fs/promises');
290
+ const p = `${REMOTE_CHAT_LOG_DIR}/${peerPk}__${channelId}.json`;
291
+ const raw = await readFile(p, 'utf-8');
292
+ const arr = JSON.parse(raw);
293
+ return Array.isArray(arr) ? arr : [];
294
+ }
295
+ catch {
296
+ return [];
297
+ }
298
+ }
299
+ async function appendRemoteChatLog(peerPk, channelId, entry) {
300
+ try {
301
+ const { mkdir, readFile, writeFile } = await import('fs/promises');
302
+ await mkdir(REMOTE_CHAT_LOG_DIR, { recursive: true });
303
+ const p = `${REMOTE_CHAT_LOG_DIR}/${peerPk}__${channelId}.json`;
304
+ let arr = [];
305
+ try {
306
+ arr = JSON.parse(await readFile(p, 'utf-8'));
307
+ }
308
+ catch { }
309
+ if (!Array.isArray(arr))
310
+ arr = [];
311
+ // 去重: 同 source + content + timestamp 跳过
312
+ const dup = arr.some(m => m.source === entry.source && m.content === entry.content && m.timestamp === entry.timestamp);
313
+ if (!dup) {
314
+ arr.push(entry);
315
+ // 防无限增长: 保留最近 500 条
316
+ if (arr.length > 500)
317
+ arr = arr.slice(-500);
318
+ await writeFile(p, JSON.stringify(arr, null, 2), 'utf-8');
319
+ }
320
+ }
321
+ catch { /* 镜像失败不阻塞 */ }
322
+ }
277
323
  /**
278
324
  * v3 重做: 构造 channel 的两路 judgment prompt 片段
279
325
  * 路 1: 用户在盾牌里手动绑定的 judgment (channel.bound_judgment_ids)
@@ -325,9 +371,12 @@ function isSharedWith(ch, peerPublicKey) {
325
371
  */
326
372
  async function routeMentionsInReply(originChannelId, replyText, localChannels, remoteChannels) {
327
373
  const results = [];
328
- // 解析: 匹配 @渠道名 后面跟一段文字 (到下一个 @ 行尾)
374
+ // 解析: 匹配 @渠道名 后面跟一段文字 (到行尾 / 下一个 @ / 结束)
329
375
  // 渠道名: 中文/英文/数字/下划线/连字符, 1-30 字符
330
- const regex = /@([一-龥A-Za-z0-9_\-]{1,30})\s+([^\n@]+?)(?=(?:\s*@[一-龥A-Za-z0-9_\-]{1,30}\s)|$)/g;
376
+ // 2026-08-02 fix: 文字部分 [^\n]+? 不跨行 + lookahead 支持 \n 边界 —
377
+ // 原来 lookahead 只认 @ 或 $, AI 回复里 "@渠道名 消息\n\n(解释...)" 尾随说明行
378
+ // 会导致匹配失败, @ 转发静默失效
379
+ const regex = /@([一-龥A-Za-z0-9_\-]{1,30})\s+([^\n]+?)(?=\n|\s*@[一-龥A-Za-z0-9_\-]{1,30}\s|$)/g;
331
380
  const matches = [...replyText.matchAll(regex)];
332
381
  if (matches.length === 0)
333
382
  return results;
@@ -404,10 +453,56 @@ async function routeMentionsInReply(originChannelId, replyText, localChannels, r
404
453
  const r = await sendOrQueue(ownerPk, 'agent.cross.post', rpcPayload, v3P2PRef);
405
454
  if (r === 'SENT') {
406
455
  console.log(`[v3-cross] (${originChannelName}) @${targetName} → 远端 peer ${ownerPk.substring(0, 12)}... (channelId=${remoteTarget.id})`);
456
+ // 2026-08-02: 激活远端协作续看 — 本地智能体 @ 远端后, 收到对方回复时多看一次
457
+ // (Validator: 判断完成 or 继续), remoteChannelId 用于 reply 事件匹配, maxRounds=3 防死循环
458
+ const rs = channelRunState.get(originChannelId);
459
+ if (rs && !rs.remoteFollowup) {
460
+ rs.remoteFollowup = { rounds: 0, maxRounds: 3, remoteChannelId: remoteTarget.id };
461
+ console.log(`[v3-followup] ${originChannelId} 激活远端协作续看 → ${remoteTarget.id} (maxRounds=3)`);
462
+ }
463
+ // 2026-08-02: @ 消息也要显示在 P2P 对话框 — broadcast remote-chat-sent,
464
+ // 前端 rcm-log 打开且匹配 channelId 时显示"我 → 远端"这条消息
465
+ // 注意: 不能传 'p2p-global' 第二参 — broadcast 会用第二参覆盖 payload.channelId
466
+ broadcast({
467
+ type: 'remote-chat-sent',
468
+ channelId: remoteTarget.id,
469
+ fromPublicKey: ownerPk,
470
+ text,
471
+ originChannelId,
472
+ originChannelName,
473
+ peerName: remoteTarget.name,
474
+ sent: true,
475
+ });
476
+ // 写本地镜像 (替代 localStorage) — 对方离线时对话记录也可读
477
+ appendRemoteChatLog(ownerPk, remoteTarget.id, {
478
+ type: 'user', content: text, timestamp: new Date().toISOString(), source: 'local-sent',
479
+ }).catch(() => { });
407
480
  results.push({ targetName, targetId: remoteTarget.id, source: 'remote', text, status: 'sent' });
408
481
  }
409
482
  else if (r === 'QUEUED') {
410
483
  console.log(`[v3-cross] (${originChannelName}) @${targetName} → 远端 peer ${ownerPk.substring(0, 12)}... 已入队 (对方不在线)`);
484
+ // 入队也激活 — 对方上线后会回复, 同样续看
485
+ const rs = channelRunState.get(originChannelId);
486
+ if (rs && !rs.remoteFollowup) {
487
+ rs.remoteFollowup = { rounds: 0, maxRounds: 3, remoteChannelId: remoteTarget.id };
488
+ console.log(`[v3-followup] ${originChannelId} 激活远端协作续看 (入队 → ${remoteTarget.id})`);
489
+ }
490
+ // 入队也广播 (对方不在线, 显示"已入队")
491
+ broadcast({
492
+ type: 'remote-chat-sent',
493
+ channelId: remoteTarget.id,
494
+ fromPublicKey: ownerPk,
495
+ text,
496
+ originChannelId,
497
+ originChannelName,
498
+ peerName: remoteTarget.name,
499
+ sent: false,
500
+ queued: true,
501
+ });
502
+ // 写本地镜像 (入队也记录)
503
+ appendRemoteChatLog(ownerPk, remoteTarget.id, {
504
+ type: 'user', content: text, timestamp: new Date().toISOString(), source: 'local-sent',
505
+ }).catch(() => { });
411
506
  results.push({ targetName, targetId: remoteTarget.id, source: 'remote', text, status: 'queued' });
412
507
  }
413
508
  else {
@@ -425,6 +520,121 @@ async function routeMentionsInReply(originChannelId, replyText, localChannels, r
425
520
  }
426
521
  return results;
427
522
  }
523
+ /**
524
+ * 2026-08-02: 远端协作续看 — 收到远端回复后, 本地智能体"多看一次回复".
525
+ * 流程 (与 /message 一致): 构建 context (注入远端回复 + 渠道目录) → promptStream →
526
+ * routeMentionsInReply (LLM 若 @ 则继续转发) → broadcast 显示.
527
+ * LLM 判断: 任务未完成 → 回复里 @ 继续 (下次回复再续看); 完成 → 总结, 协作结束.
528
+ * 防死循环: roundsLeft 由调用方控制 (triggerRemoteFollowup 结束时若还有轮次, 保留
529
+ * remoteFollowup; 否则清除).
530
+ */
531
+ async function triggerRemoteFollowup(channelId, remoteReply, fromPublicKey, roundsLeft) {
532
+ try {
533
+ const channels = await loadChannels();
534
+ const ch = channels.find((c) => c.id === channelId);
535
+ if (!ch)
536
+ return;
537
+ const agent = await getAgentForChannel(channelId, ch.did || '', ch.name, ch.didDocRef);
538
+ if (!agent)
539
+ return;
540
+ // 构建 context: 远端回复 + 渠道目录 (让 LLM 知道可以 @ 谁继续)
541
+ let contextHint = `[系统上下文] 当前频道名称: ${ch.name}\n`;
542
+ contextHint += `[系统上下文] 你通过 P2P 给远端智能体发了消息, 对方回复如下. 请判断协作是否可以继续:\n`;
543
+ contextHint += ` 对方回复: ${remoteReply.slice(0, 1500)}\n\n`;
544
+ contextHint += `[系统上下文] 协作规则 (本轮为自动续看, 非用户直接消息):\n`;
545
+ contextHint += ` 1. 如果对方回复解决了问题/任务完成 → 给用户总结结果, 不要继续发消息.\n`;
546
+ contextHint += ` 2. 如果对方回复不完整/需要进一步协作 → 在回复中写 "@渠道名 继续的内容" 继续协作 (剩余续看轮次: ${Math.max(0, roundsLeft)}).\n`;
547
+ contextHint += ` 3. 最多再继续 ${Math.max(0, roundsLeft)} 轮, 之后必须总结收尾.\n\n`;
548
+ // 渠道目录 (本地跳过自己 + 远端)
549
+ try {
550
+ const localChs = await loadChannels();
551
+ const remoteForDir = [];
552
+ for (const [peerPk, list] of remoteChannelCache.entries()) {
553
+ for (const rc of list)
554
+ remoteForDir.push({ ...rc, _ownerPublicKey: peerPk });
555
+ }
556
+ if (localChs.length > 0 || remoteForDir.length > 0) {
557
+ contextHint += '[系统上下文] 可用渠道 (回复中写 "@渠道名 消息内容" 可给它们发消息):\n';
558
+ for (const c of localChs) {
559
+ if (c.id === channelId)
560
+ continue;
561
+ contextHint += ` - [本地] @${c.name} (id=${c.id})\n`;
562
+ }
563
+ for (const c of remoteForDir) {
564
+ contextHint += ` - [远端, owner=${(c._ownerPublicKey || '').substring(0, 8)}…] @${c.name} (id=${c.id})\n`;
565
+ }
566
+ contextHint += '\n';
567
+ }
568
+ }
569
+ catch { /* 目录失败不阻塞 */ }
570
+ const markedPrompt = `【自动续看】远端智能体的回复需要你判断是否继续协作.\n【远端回复】\n${remoteReply.slice(0, 2000)}\n【回复结束】\n\n${contextHint}`;
571
+ // streamCallback: 广播 thinking/step (让用户看到续看过程), 不广播 token 流
572
+ const streamCallback = (event) => {
573
+ if (event?.type === 'used_judgments')
574
+ return;
575
+ if (event.type === 'step_start' || event.type === 'step_done' || event.type === 'step_error') {
576
+ broadcast({ type: 'followup-step', ...event, channelId }, channelId);
577
+ }
578
+ };
579
+ const fullResponse = await agent.promptStream(markedPrompt, streamCallback, undefined, channelId);
580
+ if (!fullResponse.trim())
581
+ return;
582
+ // 广播续看结果给 UI
583
+ broadcast({
584
+ type: 'ai',
585
+ content: `🔄 远端智能体回复 (来自 ${fromPublicKey.substring(0, 10)}…):\n${remoteReply.slice(0, 600)}\n\n---\n\n${fullResponse}`,
586
+ followup: true,
587
+ }, channelId);
588
+ // 存 session (作为 ai 消息)
589
+ try {
590
+ const existing = await loadSession(channelId, ch.currentSessionId || 'default');
591
+ const session = existing || { channelId, sessionId: 'default', messages: [], lastUpdated: '' };
592
+ session.messages.push({
593
+ id: `msg-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
594
+ type: 'ai',
595
+ content: fullResponse,
596
+ timestamp: new Date().toISOString(),
597
+ source: 'followup',
598
+ });
599
+ session.lastUpdated = new Date().toISOString();
600
+ await saveSession(session);
601
+ }
602
+ catch { /* 存失败不阻塞 */ }
603
+ // 路由 @ 转发 (LLM 若决定继续, 回复里会有 @渠道名)
604
+ try {
605
+ const localChs = await loadChannels();
606
+ const remoteForRoute = [];
607
+ for (const [peerPk, list] of remoteChannelCache.entries()) {
608
+ for (const rc of list)
609
+ remoteForRoute.push({ ...rc, _ownerPublicKey: peerPk });
610
+ }
611
+ const mentions = await routeMentionsInReply(channelId, fullResponse, localChs, remoteForRoute);
612
+ const didContinue = mentions.some((m) => m.status === 'sent' || m.status === 'queued');
613
+ if (!didContinue) {
614
+ // LLM 决定结束 → 清除续看状态
615
+ const rs = channelRunState.get(channelId);
616
+ if (rs)
617
+ rs.remoteFollowup = undefined;
618
+ console.log(`[v3-followup] ${channelId} 本地智能体决定结束协作 (无继续 @)`);
619
+ }
620
+ else {
621
+ console.log(`[v3-followup] ${channelId} 本地智能体继续协作 (${mentions.length} 个 @ 转发)`);
622
+ }
623
+ }
624
+ catch (routeErr) {
625
+ console.warn('[v3-followup] 路由 @ 失败:', routeErr?.message?.slice(0, 100));
626
+ const rs = channelRunState.get(channelId);
627
+ if (rs)
628
+ rs.remoteFollowup = undefined;
629
+ }
630
+ }
631
+ catch (err) {
632
+ console.error(`[v3-followup] ${channelId} 续看失败:`, err?.message?.slice(0, 200));
633
+ const rs = channelRunState.get(channelId);
634
+ if (rs)
635
+ rs.remoteFollowup = undefined;
636
+ }
637
+ }
428
638
  /**
429
639
  * v3: 处理 Hyperswarm 通道收到的 v3 RPC 消息
430
640
  * 设计: 用 HyperswarmCommunicator (DHT topic 自动发现) 取代 iroh 直接 connect
@@ -1383,6 +1593,8 @@ function checkStaleLock(startPort) {
1383
1593
  }
1384
1594
  export async function createWebServer(port = 3000, options = {}) {
1385
1595
  selfImproveEnabled = options.selfImprove ?? false;
1596
+ // 2026-08-02: channelRunState 是模块级 (triggerRemoteFollowup 访问), 每次启动清空避免残留
1597
+ channelRunState.clear();
1386
1598
  // 防止 P2P DHT 超时等错误导致进程崩溃
1387
1599
  process.on('unhandledRejection', (reason, promise) => {
1388
1600
  console.error('[警告] 未处理的 Promise 拒绝:', reason);
@@ -1407,6 +1619,66 @@ export async function createWebServer(port = 3000, options = {}) {
1407
1619
  resetAgentSession();
1408
1620
  // 初始化 LLM(从配置文件读取 MiniMax 配置)
1409
1621
  initMinimax();
1622
+ // 2026-08-02 fix: 启动自愈 — 从 agents.json 恢复 channels.json 缺失的 channel.
1623
+ // 背景: UI 创建 channel 偶发不落盘 / 历史并发覆盖丢 channel, 但 agents.json 里
1624
+ // agent 的 channelId + name 还在 (session 文件也在) → 启动时自动恢复, 智能体不再"消失".
1625
+ // 2026-08-02 v2: 抽成函数 healMissingChannels() — 启动 + GET /channels 时都调用.
1626
+ // 之前只启动时跑一次: 启动时 channel 还在 → 跳过, 之后运行中丢失 → 永远不恢复
1627
+ // (用户报告"每次刷新和 build 都会消失" — 刷新后 GET /channels 触发恢复即可自愈).
1628
+ async function healMissingChannels() {
1629
+ try {
1630
+ const { existsSync } = await import('fs');
1631
+ const { readFile } = await import('fs/promises');
1632
+ const agentsFile = `${process.env.HOME || '/tmp'}/.bolloon/agents/agents.json`;
1633
+ const sessionsDir = `${process.env.HOME || '/tmp'}/.bolloon/sessions/cache`;
1634
+ if (!existsSync(agentsFile))
1635
+ return 0;
1636
+ const agentsRaw = await readFile(agentsFile, 'utf-8');
1637
+ const agentsArr = JSON.parse(agentsRaw);
1638
+ const arr = Array.isArray(agentsArr) ? agentsArr : [];
1639
+ const chs = await loadChannels();
1640
+ const knownIds = new Set(chs.map((c) => c.id));
1641
+ let healed = 0;
1642
+ for (const a of arr) {
1643
+ const cid = a && a.channelId;
1644
+ if (!cid || knownIds.has(cid))
1645
+ continue;
1646
+ // 有 session 文件才算可恢复 (说明确实创建过)
1647
+ const hasSession = existsSync(`${sessionsDir}/${cid}:default.json`) || existsSync(`${sessionsDir}/${cid}.json`);
1648
+ if (!hasSession)
1649
+ continue;
1650
+ const restored = {
1651
+ id: cid,
1652
+ name: a.name || `Agent-${String(cid).slice(-6)}`,
1653
+ agentId: a.id,
1654
+ createdAt: a.createdAt || new Date().toISOString(),
1655
+ updatedAt: new Date().toISOString(),
1656
+ currentSessionId: 'default',
1657
+ sessions: [{ id: 'default', name: 'Default', createdAt: new Date().toISOString(), messageCount: 0, preview: '' }],
1658
+ did: a.did || undefined,
1659
+ };
1660
+ await updateChannels((all) => {
1661
+ if (!all.some((c) => c.id === cid))
1662
+ all.push(restored);
1663
+ return all;
1664
+ });
1665
+ knownIds.add(cid);
1666
+ healed++;
1667
+ console.log(`[自愈] 恢复 channel: ${cid} (${restored.name}, agent=${a.id})`);
1668
+ }
1669
+ if (healed > 0)
1670
+ console.log(`[自愈] 共恢复 ${healed} 个丢失的 channel`);
1671
+ return healed;
1672
+ }
1673
+ catch (healErr) {
1674
+ console.warn('[自愈] channel 恢复失败 (非致命):', healErr?.message?.slice(0, 120));
1675
+ return 0;
1676
+ }
1677
+ }
1678
+ // 启动时自愈一次
1679
+ await healMissingChannels().catch(() => { });
1680
+ // 2026-08-02: GET /channels 运行中自愈的节流时间戳
1681
+ let lastHealAt = 0;
1410
1682
  // ==================== P2P DIAP 身份初始化 ====================
1411
1683
  let p2pIdentity = {
1412
1684
  did: '',
@@ -1559,12 +1831,54 @@ export async function createWebServer(port = 3000, options = {}) {
1559
1831
  session.lastUpdated = new Date().toISOString();
1560
1832
  await saveSession(session);
1561
1833
  console.log(`[v3] chat.reply 已持久化到 session (${replyChannelId}): ${replyText.substring(0, 40)}...`);
1834
+ // 2026-08-02: 写本地镜像 (替代 localStorage) — 离线也能看到对方回复
1835
+ appendRemoteChatLog(evt.fromPublicKey, replyChannelId, {
1836
+ type: 'ai', content: replyText, timestamp: new Date().toISOString(), source: 'remote-reply',
1837
+ }).catch(() => { });
1562
1838
  }
1563
1839
  catch (e) {
1564
1840
  console.warn('[v3] chat.reply 持久化失败:', e?.message?.substring(0, 100));
1565
1841
  }
1566
1842
  }).catch(() => { });
1567
1843
  }
1844
+ // 2026-08-02: 远端协作续看 — 用户 @ 过远端 / 智能体发过 send_to_remote 的 channel,
1845
+ // 收到对方回复时本地智能体"多看一次回复": LLM 判断任务是否完成, 未完成可 @ 继续.
1846
+ // 防死循环: maxRounds 上限 (默认 3), 达到后结束协作.
1847
+ // replyChannelId 是远端 channel id, 需遍历 channelRunState 匹配 remoteFollowup.remoteChannelId
1848
+ if (replyChannelId && replyText) {
1849
+ try {
1850
+ let matchedLocalId = null;
1851
+ let matchedRs = null;
1852
+ for (const [cid, rs] of channelRunState.entries()) {
1853
+ if (rs?.remoteFollowup?.remoteChannelId === replyChannelId) {
1854
+ matchedLocalId = cid;
1855
+ matchedRs = rs;
1856
+ break;
1857
+ }
1858
+ }
1859
+ // 兜底: 若 replyChannelId 本身是本地 channel (旧协议), 直接用它
1860
+ if (!matchedRs && channelRunState.has(replyChannelId) && channelRunState.get(replyChannelId)?.remoteFollowup) {
1861
+ matchedLocalId = replyChannelId;
1862
+ matchedRs = channelRunState.get(replyChannelId);
1863
+ }
1864
+ if (matchedRs && matchedRs.remoteFollowup && !matchedRs.running && matchedLocalId) {
1865
+ const fu = matchedRs.remoteFollowup;
1866
+ fu.rounds += 1;
1867
+ if (fu.rounds <= fu.maxRounds) {
1868
+ console.log(`[v3-followup] ${matchedLocalId} 收到远端回复 (round ${fu.rounds}/${fu.maxRounds}), 本地智能体续看...`);
1869
+ // 异步触发本地智能体处理回复 (不阻塞 data 事件循环)
1870
+ void triggerRemoteFollowup(matchedLocalId, replyText, evt.fromPublicKey, fu.maxRounds - fu.rounds);
1871
+ }
1872
+ else {
1873
+ console.log(`[v3-followup] ${matchedLocalId} 达到续看上限 (${fu.maxRounds}), 结束协作`);
1874
+ matchedRs.remoteFollowup = undefined;
1875
+ }
1876
+ }
1877
+ }
1878
+ catch (fuErr) {
1879
+ console.warn('[v3-followup] 续看调度失败 (非致命):', fuErr?.message?.slice(0, 100));
1880
+ }
1881
+ }
1568
1882
  return;
1569
1883
  }
1570
1884
  // 2026-07-21: 社交心跳 beacon — 远端智能体宣告存活/能力, 更新本地 liveness
@@ -2725,6 +3039,23 @@ ${goalDesc}
2725
3039
  broadcast({ type: 'status', tool: event.tool, content: event.content }, channelId);
2726
3040
  broadcast({ type: 'workflow_step', step: event.tool || '系统', content: event.content }, channelId);
2727
3041
  console.log(`[SSE 广播] workflow_step: step=${event.tool}, content="${event.content?.substring(0, 80)}..."`);
3042
+ // 2026-08-02: 本地 @ 远端时, 工作流步骤也推给 P2P 对话框 (rcm-log)
3043
+ // — 用户报告"进程看不到": 本地智能体执行过程 (即使没调工具, 只有 status/tool 事件)
3044
+ // 也要在 P2P 对话框显示, 让用户看到"进程"
3045
+ try {
3046
+ const rs = channelRunState.get(channelId);
3047
+ if (rs?.remoteFollowup?.remoteChannelId) {
3048
+ broadcast({
3049
+ type: 'remote-chat-step',
3050
+ channelId: rs.remoteFollowup.remoteChannelId,
3051
+ stepType: 'step_start',
3052
+ tool: event.tool || '工作流',
3053
+ content: event.content,
3054
+ localStep: true,
3055
+ });
3056
+ }
3057
+ }
3058
+ catch { /* 非致命 */ }
2728
3059
  }
2729
3060
  else if (event.type === 'step_start' || event.type === 'step_done' || event.type === 'step_error') {
2730
3061
  // 2026-06-15: 步骤状态机事件 — 原样转发 (前端 step-timeline 组件订阅)
@@ -2737,6 +3068,26 @@ ${goalDesc}
2737
3068
  error: event.error,
2738
3069
  args: event.args,
2739
3070
  }, channelId);
3071
+ // 2026-08-02: 对称显示 — 本地智能体 @ 远端时, 本地工具调用过程也推给 P2P 对话框
3072
+ // (rcm-log): remote-chat-step 事件带 remoteChannelId, 前端按对话框匹配显示
3073
+ try {
3074
+ const rs = channelRunState.get(channelId);
3075
+ if (rs?.remoteFollowup?.remoteChannelId) {
3076
+ broadcast({
3077
+ type: 'remote-chat-step',
3078
+ channelId: rs.remoteFollowup.remoteChannelId,
3079
+ stepType: event.type,
3080
+ tool: event.tool,
3081
+ content: event.content,
3082
+ success: event.success,
3083
+ output: event.output,
3084
+ error: event.error,
3085
+ args: event.args,
3086
+ localStep: true, // 标记: 这是本地智能体的工具过程 (区别于对方转发的)
3087
+ });
3088
+ }
3089
+ }
3090
+ catch { /* 非致命 */ }
2740
3091
  // 2026-06-16: 累积 step 到 runState, 供 /api/loop/inspect 读取
2741
3092
  try {
2742
3093
  if (event.type === 'step_done' || event.type === 'step_error') {
@@ -2760,6 +3111,32 @@ ${goalDesc}
2760
3111
  }
2761
3112
  };
2762
3113
  console.log(`[消息处理] 开始处理用户消息, channelId: ${channelId}, sessionId: ${currentSessionId}`);
3114
+ // 2026-08-02 fix: 预激活远端协作续看 — 用户消息含 @远端 时立即激活 remoteFollowup.
3115
+ // 之前只在 routeMentionsInReply (AI 回复后) 激活 → 首次 @ 时本地智能体的工具调用
3116
+ // (step 事件) 发生在激活前, P2P 对话框看不到本地工具过程 (用户报告"进程看不到").
3117
+ // 现在收到消息即激活 → promptStream 期间的工具 step 也能 broadcast remote-chat-step.
3118
+ try {
3119
+ const mentionMatch = /@([一-龥A-Za-z0-9_\-]{1,30})/.exec(text);
3120
+ if (mentionMatch && remoteChannelCache.size > 0) {
3121
+ const targetName = mentionMatch[1];
3122
+ let hitRemoteId = null;
3123
+ for (const list of remoteChannelCache.values()) {
3124
+ const rc = list.find((c) => c.name === targetName);
3125
+ if (rc) {
3126
+ hitRemoteId = String(rc.id);
3127
+ break;
3128
+ }
3129
+ }
3130
+ if (hitRemoteId) {
3131
+ const rs = channelRunState.get(channelId);
3132
+ if (rs && !rs.remoteFollowup) {
3133
+ rs.remoteFollowup = { rounds: 0, maxRounds: 3, remoteChannelId: hitRemoteId };
3134
+ console.log(`[v3-followup] ${channelId} 预激活远端协作续看 → ${hitRemoteId} (消息含 @${targetName})`);
3135
+ }
3136
+ }
3137
+ }
3138
+ }
3139
+ catch { /* 预激活失败不阻塞 */ }
2763
3140
  // 将真实 DID 作为上下文前缀,让 AI 使用真实的 DID 而不是自己编造的
2764
3141
  let contextHint = '';
2765
3142
  // 2026-08-02: slash 命令提示 (在 /message 开头解析, 这里注入)
@@ -2778,6 +3155,33 @@ ${goalDesc}
2778
3155
  else {
2779
3156
  contextHint += `[系统上下文] 自动工具调用已关闭: 每次执行工具前必须先与用户确认。\n`;
2780
3157
  }
3158
+ // 2026-08-02 fix: 本地路径注入远端 channel 目录 (dirHint) — 之前只有远端路径 (agent.chat.send)
3159
+ // 有 dirHint, 本地智能体对话时看不到远端 channel 列表 → 无法 @ 远程智能体交流.
3160
+ // 现在注入: 可用渠道列表 (本地 + 远端), 让 LLM 知道 @ 谁、发什么.
3161
+ try {
3162
+ const localChs = await loadChannels();
3163
+ const remoteForDir = [];
3164
+ for (const [peerPk, list] of remoteChannelCache.entries()) {
3165
+ for (const ch of list) {
3166
+ remoteForDir.push({ ...ch, _ownerPublicKey: peerPk });
3167
+ }
3168
+ }
3169
+ if (localChs.length > 0 || remoteForDir.length > 0) {
3170
+ contextHint += '[系统上下文] 可用渠道 (你可以在回复中写 "@渠道名 消息内容" 给它们发消息, 消息会持久化到目标 channel 的 session):\n';
3171
+ for (const c of localChs) {
3172
+ if (c.id === channelId)
3173
+ continue; // 跳过自己
3174
+ contextHint += ` - [本地] @${c.name} (id=${c.id})\n`;
3175
+ }
3176
+ for (const c of remoteForDir) {
3177
+ contextHint += ` - [远端, owner=${(c._ownerPublicKey || '').substring(0, 8)}…] @${c.name} (id=${c.id})\n`;
3178
+ }
3179
+ contextHint += '语法: 在回复中写 "@渠道名 我要说的话" 即可, 系统会自动转发。\n\n';
3180
+ }
3181
+ }
3182
+ catch (dirErr) {
3183
+ // 静默 — dirHint 不是核心
3184
+ }
2781
3185
  // v3: 注入 channel 绑定的判断力 (judgment_ids)
2782
3186
  // 这是 v3 的核心 — channel 跑 LLM 时, 它的判断力 = 绑定的 judgment 列表
2783
3187
  const judgmentHint = await buildJudgmentHint(channelForJudgment, channelId);
@@ -3317,7 +3721,7 @@ ${goalDesc}
3317
3721
  const didFixQueue = new Set(); // 待修复的 channelId
3318
3722
  let didFixRunning = false;
3319
3723
  let didFixTimer = null;
3320
- const channelRunState = new Map();
3724
+ // 2026-08-02: channelRunState 已提升为模块级 (triggerRemoteFollowup 也访问), 这里复用
3321
3725
  function getOrCreateRunState(channelId) {
3322
3726
  let s = channelRunState.get(channelId);
3323
3727
  if (!s) {
@@ -3557,6 +3961,14 @@ ${goalDesc}
3557
3961
  }
3558
3962
  app.get('/channels', async (_req, res) => {
3559
3963
  try {
3964
+ // 2026-08-02 fix: 运行中自愈 — 每次拉取前检查丢失的 channel (从 agents.json 恢复).
3965
+ // 解决"刷新/build 后 channel 消失": 刷新即触发 GET /channels → 丢失的自动回来
3966
+ // 节流: 5s 内只跑一次 (heal 内部有文件 IO)
3967
+ const nowMs = Date.now();
3968
+ if (nowMs - (lastHealAt || 0) > 5000) {
3969
+ lastHealAt = nowMs;
3970
+ await healMissingChannels().catch(() => { });
3971
+ }
3560
3972
  // 2026-06-17: 缓存命中 → 0 行;未命中 → 1 行 summary (上面 console.log proxy 已吃掉 [API] /channels 等旧日志)
3561
3973
  const t0 = Date.now();
3562
3974
  const now = t0;
@@ -3698,6 +4110,14 @@ ${goalDesc}
3698
4110
  return res.status(400).json({ error: 'name and agentId required' });
3699
4111
  }
3700
4112
  const channels = await loadChannels();
4113
+ // 2026-08-02 fix: 同 agentId 下禁止重名 — 之前用户连点两次"新建智能体"生成两个同名
4114
+ // "智能体" channel, UI 无法区分 (分享栏名字对不上 id). 同 agentId 同名直接拒绝.
4115
+ const dupName = channels.find(c => c.agentId === agentId && c.name === name.trim());
4116
+ if (dupName) {
4117
+ return res.status(400).json({
4118
+ error: `同名智能体已存在 (${dupName.name}, id=${dupName.id}), 请换一个名字`
4119
+ });
4120
+ }
3701
4121
  const id = `ch_${Date.now()}_${Math.random().toString(36).substring(2, 8)}`;
3702
4122
  // 校验钱包地址格式 (粗校验: 0x + 40 hex / Solana base58 / Sui 0x+64)
3703
4123
  const validWallet = isValidWalletAddress(walletAddress);
@@ -3802,6 +4222,29 @@ ${goalDesc}
3802
4222
  await fs.writeFile(agentsPath, JSON.stringify(arr, null, 2), 'utf-8');
3803
4223
  console.log(`[创建频道] agent 写进 agents.json: name=${name} id=${agentId}`);
3804
4224
  }
4225
+ else {
4226
+ // 2026-08-02 fix: agent 已存在时更新 channelId + name — 之前 exists 直接跳过,
4227
+ // 用户复用同一 agentId 新建 channel 时, agents.json 的 channelId 仍指向旧 channel
4228
+ // (可能已删除), P2P manifest / 恢复逻辑拿到的关联是错的 → "智能体消失"
4229
+ const existing = arr.find(a => a && a.id === agentId);
4230
+ const oldCid = existing?.channelId;
4231
+ let changed = false;
4232
+ if (existing) {
4233
+ if (existing.channelId !== id) {
4234
+ existing.channelId = id;
4235
+ changed = true;
4236
+ }
4237
+ if (existing.name !== name) {
4238
+ existing.name = name;
4239
+ changed = true;
4240
+ }
4241
+ existing.lastActive = new Date().toISOString();
4242
+ }
4243
+ if (changed) {
4244
+ await fs.writeFile(agentsPath, JSON.stringify(arr, null, 2), 'utf-8');
4245
+ console.log(`[创建频道] agent ${agentId} channelId 更新: ${oldCid} → ${id} (name=${name})`);
4246
+ }
4247
+ }
3805
4248
  }
3806
4249
  catch (e) {
3807
4250
  console.warn('[创建频道] 写 agents.json 失败 (非致命):', e?.message?.slice(0, 120));
@@ -3985,6 +4428,9 @@ ${goalDesc}
3985
4428
  // 之前 v0.3.6 (Bug 6) 创建频道时同步往 agents.json append, 删频道却没删回来
3986
4429
  // → agents.json 里残留孤儿 agent, 重启后 loadLocalSubAgents 还能读到这些 — 看起来像"删不掉"
3987
4430
  // 修法: 用 channel.agentId 找, 同步从 agents.json 删一条
4431
+ // 2026-08-02 二次修: 只在没有其他 channel 复用该 agentId 时才删 agent —
4432
+ // 之前无条件 filter(a.id === channel.agentId), 多个 channel 共享 agentId (用户复用"智能体" id)
4433
+ // 时, 删一个 channel 把 agent 也清了 → 其他 channel 变孤儿 → "智能体消失"
3988
4434
  try {
3989
4435
  const agentsPath = path.join(process.env.HOME || '/tmp', '.bolloon', 'agents', 'agents.json');
3990
4436
  const raw = await fs.readFile(agentsPath, 'utf-8').catch(() => '');
@@ -3996,11 +4442,25 @@ ${goalDesc}
3996
4442
  catch { }
3997
4443
  if (!Array.isArray(arr))
3998
4444
  arr = [];
4445
+ // 检查是否有其他 channel 还引用这个 agentId (含刚删的这条: 用删除后的 channels 判断)
4446
+ const remainingChannels = await loadChannels();
4447
+ const agentIdStillUsed = remainingChannels.some((c) => c.id !== channelId && c.agentId === channel.agentId);
3999
4448
  const before = arr.length;
4000
- arr = arr.filter(a => !(a && (a.id === channel.agentId || a.channelId === channelId)));
4449
+ // 只删: channelId 精确匹配的 agent 条目 agentId 无其他 channel 使用时才按 agentId 删
4450
+ arr = arr.filter(a => {
4451
+ if (!a)
4452
+ return false;
4453
+ if (a.channelId === channelId)
4454
+ return false; // 精确指向被删 channel
4455
+ if (a.id === channel.agentId && agentIdStillUsed)
4456
+ return true; // 共享中, 保留
4457
+ if (a.id === channel.agentId && !agentIdStillUsed)
4458
+ return false; // 无引用, 删
4459
+ return true;
4460
+ });
4001
4461
  if (arr.length !== before) {
4002
4462
  await fs.writeFile(agentsPath, JSON.stringify(arr, null, 2), 'utf-8');
4003
- console.log(`[删除频道] agents.json 清掉 ${before - arr.length} 条 orphan agent (channel=${channelId})`);
4463
+ console.log(`[删除频道] agents.json 清掉 ${before - arr.length} 条 agent (channel=${channelId}, agentId=${channel.agentId}, 仍被使用=${agentIdStillUsed})`);
4004
4464
  }
4005
4465
  }
4006
4466
  }
@@ -4096,12 +4556,31 @@ ${goalDesc}
4096
4556
  console.log(`[Channel ${channelId}] 自动生成 share_id: ${channel.share_id}`);
4097
4557
  }
4098
4558
  channel.updatedAt = new Date().toISOString();
4099
- // 2026-08-02 fix: 原子写入
4559
+ // 2026-08-02 fix: 原子写入 — 之前回调只写 shared_with_peers/share_id, name 等字段
4560
+ // 只改了外层内存对象, 磁盘没落 → 改名后重启名字回退 / 与 UI 显示不一致 ("改名没修好")
4100
4561
  await updateChannels((chs) => {
4101
4562
  const c = chs.find(x => x.id === channelId);
4102
4563
  if (c) {
4564
+ if (typeof name === 'string' && name.trim())
4565
+ c.name = name.trim();
4566
+ if (persona !== undefined) {
4567
+ if (persona === null)
4568
+ c.persona = undefined;
4569
+ else if (typeof persona === 'object')
4570
+ c.persona = channel.persona;
4571
+ }
4572
+ if (Array.isArray(linkedDocumentIds))
4573
+ c.linkedDocumentIds = channel.linkedDocumentIds;
4574
+ if (walletAddress !== undefined) {
4575
+ c.walletAddress = channel.walletAddress;
4576
+ c.walletRegisteredAt = channel.walletRegisteredAt;
4577
+ }
4578
+ if (typeof autoInvokeTools === 'boolean')
4579
+ c.autoInvokeTools = channel.autoInvokeTools;
4580
+ if (bound_judgment_ids !== undefined)
4581
+ c.bound_judgment_ids = channel.bound_judgment_ids;
4103
4582
  if (shared_with_peers !== undefined)
4104
- c.shared_with_peers = shared_with_peers;
4583
+ c.shared_with_peers = channel.shared_with_peers;
4105
4584
  if (!c.share_id)
4106
4585
  c.share_id = channel.share_id;
4107
4586
  c.updatedAt = new Date().toISOString();
@@ -4884,43 +5363,67 @@ ${goalDesc}
4884
5363
  // 实现: B → POST 给 A 一个 agent.history.get RPC → A 把 session 返回 → B 渲染
4885
5364
  app.get('/api/remote-channels/chat-history', async (req, res) => {
4886
5365
  try {
4887
- if (!v3P2PRef) {
4888
- return res.status(503).json({ error: 'P2PDirect not started' });
4889
- }
4890
5366
  const targetPublicKey = String(req.query.targetPublicKey || '');
4891
5367
  const channelId = String(req.query.channelId || '');
4892
5368
  if (!targetPublicKey || !channelId) {
4893
5369
  return res.status(400).json({ error: 'targetPublicKey, channelId required' });
4894
5370
  }
4895
- // 通过 RPC A session — A 端收到后异步回复
4896
- const fromPk = v3P2PRef.getPublicKey();
4897
- const rpcId = `hist-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
4898
- const msg = JSON.stringify({
4899
- v: 3,
4900
- op: 'agent.history.get',
4901
- payload: { rpcId, channelId, fromPublicKey: fromPk }
4902
- });
4903
- const ok = v3P2PRef.sendTo(targetPublicKey, msg);
4904
- if (!ok) {
4905
- return res.status(502).json({ error: 'peer not connected' });
4906
- }
4907
- // 等待 A 异步回复 (15s timeout) — 用一个 Promise 等
4908
- const result = await new Promise((resolve, reject) => {
4909
- const timer = setTimeout(() => {
4910
- v3PendingHistoryGets.delete(rpcId);
4911
- reject(new Error('A 端 15s 内未回复, 可能未分享该 channel'));
4912
- }, 15000);
4913
- v3PendingHistoryGets.set(rpcId, {
4914
- resolve: (data) => { clearTimeout(timer); resolve(data); },
4915
- reject: (err) => { clearTimeout(timer); reject(err); }
5371
+ // 2026-08-02: 本地镜像优先 立即返回本地记录 (对方离线也有), 不用等 RPC
5372
+ const mirror = await readRemoteChatLog(targetPublicKey, channelId);
5373
+ if (mirror.length > 0 && !v3P2PRef) {
5374
+ return res.json({ messages: mirror, source: 'mirror', judgments: { bound: [], candidates: [] } });
5375
+ }
5376
+ // 有 P2P: 后台 RPC 拉对端合并 (不阻塞响应 — 镜像先返回, RPC 结果下次刷新拿到)
5377
+ if (v3P2PRef) {
5378
+ const fromPk = v3P2PRef.getPublicKey();
5379
+ const rpcId = `hist-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
5380
+ const msg = JSON.stringify({
5381
+ v: 3,
5382
+ op: 'agent.history.get',
5383
+ payload: { rpcId, channelId, fromPublicKey: fromPk }
4916
5384
  });
4917
- });
4918
- console.log(`[v3] chat-history 从 ${targetPublicKey.substring(0, 12)}... 拉到 ${(result.messages || []).length} 条`);
4919
- res.json(result);
5385
+ const ok = v3P2PRef.sendTo(targetPublicKey, msg);
5386
+ if (!ok && mirror.length === 0) {
5387
+ return res.status(502).json({ error: 'peer not connected (本地也无缓存)' });
5388
+ }
5389
+ if (ok && mirror.length === 0) {
5390
+ // 无本地镜像 → 等 RPC (15s)
5391
+ try {
5392
+ const result = await new Promise((resolve, reject) => {
5393
+ const timer = setTimeout(() => {
5394
+ v3PendingHistoryGets.delete(rpcId);
5395
+ reject(new Error('A 端 15s 内未回复'));
5396
+ }, 15000);
5397
+ v3PendingHistoryGets.set(rpcId, {
5398
+ resolve: (data) => { clearTimeout(timer); resolve(data); },
5399
+ reject: (err) => { clearTimeout(timer); reject(err); }
5400
+ });
5401
+ });
5402
+ // 把对端历史写进本地镜像 (增量缓存)
5403
+ const remoteMsgs = result.messages || [];
5404
+ for (const m of remoteMsgs) {
5405
+ await appendRemoteChatLog(targetPublicKey, channelId, {
5406
+ type: m.type === 'user' ? 'user' : 'ai',
5407
+ content: m.content || '',
5408
+ timestamp: m.timestamp || new Date().toISOString(),
5409
+ source: m.source || 'remote',
5410
+ });
5411
+ }
5412
+ return res.json({ ...result, messages: remoteMsgs, source: 'rpc' });
5413
+ }
5414
+ catch (err) {
5415
+ return res.status(504).json({ error: err.message });
5416
+ }
5417
+ }
5418
+ // 有本地镜像 + 有 P2P: 返回镜像 (RPC 结果由前端 15s 刷新轮询拿)
5419
+ return res.json({ messages: mirror, source: 'mirror', judgments: { bound: [], candidates: [] } });
5420
+ }
5421
+ // 无 P2P
5422
+ return res.json({ messages: mirror, source: 'mirror', judgments: { bound: [], candidates: [] } });
4920
5423
  }
4921
5424
  catch (err) {
4922
5425
  console.error('[v3] chat-history 失败:', err.message);
4923
- res.status(504).json({ error: err.message });
5426
+ res.status(500).json({ error: err.message });
4924
5427
  }
4925
5428
  });
4926
5429
  // 获取已连接的节点
@@ -6285,7 +6788,11 @@ function broadcast(data, channelId) {
6285
6788
  const msgId = (data.type === 'ai' || data.type === 'user')
6286
6789
  ? nextMsgId(channelId)
6287
6790
  : `evt_${Date.now()}_${crypto.randomBytes(4).toString('hex')}`;
6288
- const envelope = { ...data, channelId, seq, msgId };
6791
+ // 2026-08-02 fix: 第二参 channelId undefined 时, 不要用 undefined 覆盖 data.channelId —
6792
+ // 否则 payload 自带的 channelId (如 remote-chat-sent 的远端 channel id) 会丢, 前端无法匹配对话框
6793
+ const envelope = channelId !== undefined
6794
+ ? { ...data, channelId, seq, msgId }
6795
+ : { ...data, seq, msgId };
6289
6796
  const message = `data: ${JSON.stringify(envelope)}\n\n`;
6290
6797
  console.log(`[broadcast] type=${data.type}, channelId=${channelId}, seq=${seq}, msgId=${msgId}, clients=${sseClients.size}`);
6291
6798
  for (const client of sseClients) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bolloon/bolloon-agent",
3
- "version": "0.3.25",
3
+ "version": "0.3.27",
4
4
  "type": "module",
5
5
  "description": "P2P AI Document Agent - 全局安装后执行 `bolloon` 启动产品",
6
6
  "main": "dist/cli-entry.js",