@bolloon/bolloon-agent 0.3.23 → 0.3.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.
package/dist/index.js CHANGED
@@ -15,7 +15,8 @@ import { createSubAgentManager } from './agents/subagent-manager.js';
15
15
  import { getGlobalSharedContext } from './social/global-shared-context.js';
16
16
  import { createBollharnessIntegration } from './bollharness-integration/index.js';
17
17
  import * as readline from 'readline';
18
- import { printBanner, renderDialog, renderUserMessage, renderAgentMessage, renderToolCallListItem, renderToolCallBody, renderToolCallsFooter, termWidth, brandArtLines, boxTop, boxRow, boxBottom, dispWidth } from './cli/loading-tui.js';
18
+ import { printBanner, renderDialog, renderUserMessage, renderAgentMessage, renderToolCallListItem, termWidth } from './cli/loading-tui.js';
19
+ import { startInk, stopInk, inkAppendLine as appendLine, inkSetStatus, inkSetThinking } from './cli/ink-app.js';
19
20
  // 启动自动检查更新:后台、节流、检测到新版本自动安装(可被 --no-update / BOLLOON_SKIP_UPDATE 关闭)
20
21
  import { createRequire } from 'module';
21
22
  const _require = createRequire(import.meta.url);
@@ -94,12 +95,12 @@ const s = {
94
95
  let i = 0;
95
96
  let dots = 0;
96
97
  const frame = frames[0];
97
- process.stdout.write(` ${frame} 思考...`);
98
+ appendLine(` ${frame} 思考...`);
98
99
  return setInterval(() => {
99
100
  i = (i + 1) % frames.length;
100
101
  dots = (dots + 1) % 4;
101
102
  const dotStr = '.'.repeat(dots || 1);
102
- process.stdout.write(`\r ${frames[i]} 思考${dotStr} `);
103
+ appendLine(`\r ${frames[i]} 思考${dotStr} `);
103
104
  }, 600);
104
105
  },
105
106
  clearThinking: (interval) => {
@@ -185,7 +186,7 @@ function publishDID(name, kp) {
185
186
  }
186
187
  catch (e) {
187
188
  // 一次失败直接放弃 — 本地模式运行就够了, 不重试
188
- process.stdout.write(` ${YELLOW}⚠ IPFS 发布失败 (${e?.message?.slice(0, 80) || 'unknown'}), 本地模式运行${RESET}\n`);
189
+ appendLine(` ${YELLOW}⚠ IPFS 发布失败 (${e?.message?.slice(0, 80) || 'unknown'}), 本地模式运行${RESET}`);
189
190
  s.step(2, 5, '发布 DID → IPFS', 'warn');
190
191
  resolve({});
191
192
  }
@@ -353,13 +354,12 @@ function rpcErr(code, msg) {
353
354
  // CLI with persistent bottom prompt
354
355
  // 2026-07-28: 改用 readline.createInterface + replReadline 循环
355
356
  let isRunning = false;
357
+ let cliContextPct = 0;
356
358
  let queueMode = false;
357
359
  const pendingQueue = [];
358
- // 底部状态栏数据
359
360
  let cliStartTime = 0;
360
361
  let cliModelName = '…';
361
362
  let cliAgentName = '…';
362
- let cliContextPct = 0;
363
363
  function fmtDuration(ms) {
364
364
  const s = Math.floor(ms / 1000);
365
365
  const m = Math.floor(s / 60);
@@ -377,16 +377,21 @@ function statusBarLine() {
377
377
  const bar = C_OK + '█'.repeat(filled) + C_DIM + '░'.repeat(barLen - filled) + RESET;
378
378
  return `${C_ACCENT}${cliModelName}${RESET}${C_DIM} │${RESET} ${cliAgentName} ${C_DIM}│${RESET} ⏱ ${C_ACCENT}${dur}${RESET} ${C_DIM}│${RESET} ${bar} ${C_DIM}${cliContextPct}%${RESET}`;
379
379
  }
380
- function startCLI(comm) {
380
+ async function startCLI(comm) {
381
381
  isRunning = true;
382
- // CLI 模式下过滤 [xxx] 内部日志
383
- const _origLog = console.log;
384
- const _origWarn = console.warn;
385
- const _isInternal = (args) => args.length && typeof args[0] === 'string' && /^\[[A-Za-z _\-.]+/.test(args[0]);
386
- console.log = (...args) => { if (_isInternal(args))
387
- return; _origLog.apply(console, args); };
388
- console.warn = (...args) => { if (_isInternal(args))
389
- return; _origWarn.apply(console, args); };
382
+ // CLI 模式下静音所有 console.log/warn
383
+ // (Ink 用自己的 render 引擎, console.log 输出会污染终端)
384
+ console.log = () => { };
385
+ console.warn = () => { };
386
+ // 同时过滤 process.stdout.write 阻止 [xxx] 前缀的输出
387
+ const _origStdout = process.stdout.write.bind(process.stdout);
388
+ process.stdout.write = ((chunk, ...rest) => {
389
+ const s = typeof chunk === 'string' ? chunk : String(chunk);
390
+ // 过滤以 [ 开头的行 (agent 内部日志)
391
+ if (s.trimStart().startsWith('['))
392
+ return true;
393
+ return _origStdout(chunk, ...rest);
394
+ });
390
395
  let peerCount = 0;
391
396
  try {
392
397
  peerCount = comm.getConnections().length;
@@ -407,111 +412,96 @@ function startCLI(comm) {
407
412
  cliModelName = foundProvider ? foundProvider[1] : '未配置';
408
413
  cliAgentName = agentIdentity?.name || 'bolloon';
409
414
  cliStartTime = Date.now();
410
- process.stdout.write(`${C_DIM}输入 !cmd 执行终端 · /queue 入队 · /help 帮助${RESET}\n\n`);
411
- // 启动框: logo 左对齐(无状态栏 状态栏在输入框上方)
412
- const art = brandArtLines();
413
- const maxArt = art.reduce((m, l) => Math.max(m, dispWidth(l)), 0);
414
- const boxW = Math.min(termWidth() - 2, Math.max(40, maxArt) + 4);
415
- process.stdout.write(boxTop('Bolloon Agent', boxW) + '\n');
416
- for (const l of art)
417
- process.stdout.write(boxRow(l, boxW, 'left') + '\n');
418
- process.stdout.write(boxBottom(boxW) + '\n\n');
419
- // readline 输入循环(底部没有独立状态栏)
420
- replReadline(comm);
421
- }
422
- async function replReadline(comm) {
423
- const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
424
- const SEP = '\x1b[90m─';
425
- const RST = '\x1b[0m';
426
- while (isRunning) {
427
- const tw = process.stdout.columns || 80;
428
- const sepLine = SEP.repeat(tw) + RST;
429
- const prefix = queueMode ? `${C_WARN}▸${RST}` : `${C_ACCENT}❯${RST}`;
430
- const raw = await new Promise(resolve => rl.question(`\n${sepLine}\n${statusBarLine()}\n${sepLine}\n${prefix} `, resolve));
431
- const trimmed = raw.trim();
432
- // 清除 readline echo 行, 避免与 renderUserMessage 重复
433
- process.stdout.write('\r\x1b[K');
434
- process.stdout.write(`\n${sepLine}\n\n\n\n\n`);
435
- if (!trimmed)
436
- continue;
437
- if (!isRunning)
438
- break;
439
- await processInput(trimmed, comm);
440
- }
441
- rl.close();
442
- process.stdout.write(`\n${CYAN}👋 再见!${RESET}\n`);
443
- process.stdin.destroy();
415
+ // 进入 Ink TUI 输入循环
416
+ const initialStatus = `${C_ACCENT}${cliModelName}\x1b[0m\x1b[90m │\x1b[0m ${cliAgentName} \x1b[90m│\x1b[0m ⏱ 0s`;
417
+ const getStatus = () => {
418
+ const dur = Math.floor((Date.now() - cliStartTime) / 1000);
419
+ const barLen = 12;
420
+ const filled = Math.round((cliContextPct / 100) * barLen);
421
+ const bar = '█'.repeat(filled) + '░'.repeat(barLen - filled);
422
+ return `${C_ACCENT}${cliModelName}\x1b[0m\x1b[90m │\x1b[0m ${cliAgentName} \x1b[90m│\x1b[0m ⏱ ${dur}s\x1b[90m │\x1b[0m ${bar} ${cliContextPct}%`;
423
+ };
424
+ startInk((text) => { processInput(text, comm); }, initialStatus, getStatus);
425
+ // Wait on a promise that resolves on Ctrl+C
426
+ await new Promise(() => { });
427
+ stopInk();
428
+ appendLine(`\n${CYAN}👋 再见!${RESET}`);
444
429
  comm.stop();
445
430
  }
446
431
  async function processInput(input, comm) {
447
432
  const trimmed = input.trim();
433
+ // TUI tool call state (local to this invocation)
434
+ const tuiToolCalls = [];
435
+ let tuiToolCounter = 0;
436
+ // each iteration
437
+ let lastToolEvent = null;
448
438
  // !command — 直接执行终端命令
449
439
  if (trimmed.startsWith('!')) {
450
440
  const cmd = trimmed.slice(1).trim();
451
441
  if (!cmd) {
452
- process.stdout.write(`${C_DIM}!<命令> 执行终端命令, 如 !ls -la${RESET}\n`);
442
+ appendLine(`${C_DIM}!<命令> 执行终端命令, 如 !ls -la${RESET}`);
453
443
  return;
454
444
  }
455
- process.stdout.write(`${C_DIM}── $ ${cmd}${RESET}\n`);
445
+ appendLine(`${C_DIM}── $ ${cmd}${RESET}`);
456
446
  try {
457
447
  const { execSync } = await import('child_process');
458
448
  const out = execSync(cmd, { timeout: 30000, encoding: 'utf-8', cwd: process.cwd() });
459
- process.stdout.write(`${C_DIM}${out || '(无输出)'}${RESET}`);
449
+ appendLine(`${C_DIM}${out || '(无输出)'}${RESET}`);
460
450
  }
461
451
  catch (e) {
462
- process.stdout.write(`${C_ERROR}${e.stderr || e.message}${RESET}\n`);
452
+ appendLine(`${C_ERROR}${e.stderr || e.message}${RESET}`);
463
453
  }
464
- process.stdout.write(`${C_DIM}──${RESET}\n`);
454
+ appendLine(`${C_DIM}──${RESET}`);
465
455
  return;
466
456
  }
467
457
  // /queue — 切换队列模式
468
458
  if (trimmed.toLowerCase() === '/queue') {
469
459
  queueMode = !queueMode;
470
- process.stdout.write(`${C_WARN}队列 ${queueMode ? '开启' : '关闭'}${RESET} (${pendingQueue.length} 条)\n`);
460
+ appendLine(`${C_WARN}队列 ${queueMode ? '开启' : '关闭'}${RESET} (${pendingQueue.length} 条)`);
471
461
  return;
472
462
  }
473
463
  // /dequeue — 出队一条
474
464
  if (trimmed.toLowerCase() === '/dequeue' || trimmed.toLowerCase() === '/dq') {
475
465
  const next = pendingQueue.shift();
476
466
  if (next)
477
- process.stdout.write(`${C_WARN}出队:${RESET} ${next}\n`);
467
+ appendLine(`${C_WARN}出队:${RESET} ${next}`);
478
468
  else
479
- process.stdout.write(`${C_DIM}队列为空${RESET}\n`);
469
+ appendLine(`${C_DIM}队列为空${RESET}`);
480
470
  return;
481
471
  }
482
472
  // 队列模式: 入队
483
473
  if (queueMode) {
484
474
  pendingQueue.push(trimmed);
485
- process.stdout.write(`${C_WARN}[${pendingQueue.length}]${RESET} 已入队\n`);
475
+ appendLine(`${C_WARN}[${pendingQueue.length}]${RESET} 已入队`);
486
476
  return;
487
477
  }
488
478
  // 队列非空: 也入队末尾 (排队执行)
489
479
  if (pendingQueue.length > 0) {
490
480
  pendingQueue.push(trimmed);
491
- process.stdout.write(`${C_WARN}[队列 ${pendingQueue.length}]${RESET} 已入队, 执行完当前后自动运行\n`);
481
+ appendLine(`${C_WARN}[队列 ${pendingQueue.length}]${RESET} 已入队, 执行完当前后自动运行`);
492
482
  return;
493
483
  }
494
484
  if (trimmed.toLowerCase() === '/help' || trimmed === 'help') {
495
- process.stdout.write(`${C_DIM}命令:${RESET}\n`);
496
- process.stdout.write(` ${C_ACCENT}!<cmd>${RESET} 执行终端命令 ${C_DIM}如 !ls -la${RESET}\n`);
497
- process.stdout.write(` ${C_ACCENT}/queue${RESET} 切换队列模式 ${C_DIM}输入排队, 当前结束后自动执行${RESET}\n`);
498
- process.stdout.write(` ${C_ACCENT}/dequeue${RESET} 出队一条\n`);
499
- process.stdout.write(` ${C_ACCENT}peers${RESET} 查看 P2P 节点\n`);
500
- process.stdout.write(` ${C_ACCENT}iroh${RESET} 查看 iroh 状态\n`);
501
- process.stdout.write(` ${C_ACCENT}add_friend${RESET} 添加好友\n`);
502
- process.stdout.write(` ${C_ACCENT}exit${RESET} 退出\n`);
485
+ appendLine(`${C_DIM}命令:${RESET}`);
486
+ appendLine(` ${C_ACCENT}!<cmd>${RESET} 执行终端命令 ${C_DIM}如 !ls -la${RESET}`);
487
+ appendLine(` ${C_ACCENT}/queue${RESET} 切换队列模式 ${C_DIM}输入排队, 当前结束后自动执行${RESET}`);
488
+ appendLine(` ${C_ACCENT}/dequeue${RESET} 出队一条`);
489
+ appendLine(` ${C_ACCENT}peers${RESET} 查看 P2P 节点`);
490
+ appendLine(` ${C_ACCENT}iroh${RESET} 查看 iroh 状态`);
491
+ appendLine(` ${C_ACCENT}add_friend${RESET} 添加好友`);
492
+ appendLine(` ${C_ACCENT}exit${RESET} 退出`);
503
493
  return;
504
494
  }
505
495
  if (trimmed === '退出' || trimmed === 'exit' || trimmed === 'quit') {
506
- process.stdout.write(`\n${CYAN}👋 再见!${RESET}\n`);
496
+ appendLine(`\n${CYAN}👋 再见!${RESET}`);
507
497
  isRunning = false;
508
498
  return;
509
499
  }
510
500
  if (trimmed.toLowerCase() === 'peers') {
511
501
  const peers = comm.getConnections();
512
- process.stdout.write(`${GRAY}已连接节点: ${peers.length}${RESET}\n`);
502
+ appendLine(`${GRAY}已连接节点: ${peers.length}${RESET}`);
513
503
  for (const c of peers) {
514
- process.stdout.write(` ${GRAY}·${RESET} ${c.publicKey.substring(0, 16)}...\n`);
504
+ appendLine(` ${GRAY}·${RESET} ${c.publicKey.substring(0, 16)}...`);
515
505
  }
516
506
  return;
517
507
  }
@@ -519,25 +509,25 @@ async function processInput(input, comm) {
519
509
  const nodeId = irohTransport.getNodeId();
520
510
  const running = irohTransport.isRunning();
521
511
  const peers = irohTransport.getPeers();
522
- process.stdout.write(`${GRAY}iroh 状态:${RESET}\n`);
523
- process.stdout.write(` ${GRAY}运行中:${RESET} ${running ? '是' : '否'}\n`);
524
- process.stdout.write(` ${GRAY}Node ID:${RESET} ${nodeId ? nodeId.substring(0, 24) + '...' : 'N/A'}\n`);
525
- process.stdout.write(` ${GRAY}已知节点:${RESET} ${peers.length}\n`);
512
+ appendLine(`${GRAY}iroh 状态:${RESET}`);
513
+ appendLine(` ${GRAY}运行中:${RESET} ${running ? '是' : '否'}`);
514
+ appendLine(` ${GRAY}Node ID:${RESET} ${nodeId ? nodeId.substring(0, 24) + '...' : 'N/A'}`);
515
+ appendLine(` ${GRAY}已知节点:${RESET} ${peers.length}`);
526
516
  if (hybridMessenger) {
527
- process.stdout.write(` ${GRAY}HybridMessenger:${RESET} 就绪\n`);
517
+ appendLine(` ${GRAY}HybridMessenger:${RESET} 就绪`);
528
518
  }
529
519
  return;
530
520
  }
531
521
  if (trimmed.toLowerCase().startsWith('add_friend ') || trimmed.toLowerCase() === 'add_friend') {
532
522
  const parts = trimmed.split(/\s+/);
533
523
  if (parts.length < 2 || (parts.length === 2 && parts[1].length !== 64)) {
534
- process.stdout.write(`${GRAY}用法: add_friend <64字符hex publicKey> [备注名]\n${RESET}`);
535
- process.stdout.write(`${GRAY}示例: add_friend a1b2c3d4e5f6... 同事-张磊\n${RESET}`);
524
+ appendLine(`${GRAY}用法: add_friend <64字符hex publicKey> [备注名]\n${RESET}`);
525
+ appendLine(`${GRAY}示例: add_friend a1b2c3d4e5f6... 同事-张磊\n${RESET}`);
536
526
  return;
537
527
  }
538
528
  const pk = parts[1];
539
529
  const name = parts.slice(2).join(' ') || '';
540
- process.stdout.write(`${GRAY}正在发送好友申请给 ${pk.substring(0, 16)}...${RESET}\n`);
530
+ appendLine(`${GRAY}正在发送好友申请给 ${pk.substring(0, 16)}...${RESET}`);
541
531
  try {
542
532
  const port = process.env.PORT || '54188';
543
533
  const res = await fetch(`http://127.0.0.1:${port}/api/friend-request`, {
@@ -548,88 +538,77 @@ async function processInput(input, comm) {
548
538
  const data = await res.json();
549
539
  if (!res.ok) {
550
540
  const reason = data.code === 'NO_CONN' ? '对方未在线, 已本地记住, 等对方上线后自动重连' : (data.error || '请求失败');
551
- process.stdout.write(`${MAGENTA}✗ 添加好友失败: ${reason}${RESET}\n`);
541
+ appendLine(`${MAGENTA}✗ 添加好友失败: ${reason}${RESET}`);
552
542
  if (data.persistedAs)
553
- process.stdout.write(`${GRAY}本地已保存为: ${data.persistedAs}${RESET}\n`);
543
+ appendLine(`${GRAY}本地已保存为: ${data.persistedAs}${RESET}`);
554
544
  }
555
545
  else {
556
- process.stdout.write(`${GREEN}✓ 好友申请已发送给 ${data.persistedAs || name || pk.substring(0, 12)}...${RESET}\n`);
546
+ appendLine(`${GREEN}✓ 好友申请已发送给 ${data.persistedAs || name || pk.substring(0, 12)}...${RESET}`);
557
547
  }
558
548
  }
559
549
  catch (err) {
560
- process.stdout.write(`${MAGENTA}✗ 添加好友失败: ${err.message || String(err)}${RESET}\n`);
550
+ appendLine(`${MAGENTA}✗ 添加好友失败: ${err.message || String(err)}${RESET}`);
561
551
  }
562
552
  return;
563
553
  }
564
554
  try {
565
555
  // 双横线分割
566
- process.stdout.write(`${C_DIM}${'─'.repeat(8)} · ${'─'.repeat(8)}${RESET}\n`);
567
- process.stdout.write(renderUserMessage(trimmed) + '\n');
556
+ appendLine(`${C_DIM}${'─'.repeat(8)} · ${'─'.repeat(8)}${RESET}`);
557
+ appendLine(renderUserMessage(trimmed));
558
+ // 启动思考动画
559
+ inkSetThinking(true);
568
560
  const a = await getAgent();
569
561
  const boxW = Math.min(termWidth() - 2, 76);
570
- const toolCalls = [];
571
- let toolCounter = 0;
572
- // 启动 thinking 加载动画 (颜文字), 第一个工具事件或 prompt 完成后停止
573
- let thinkingInterval = null;
574
- const stopThinking = () => {
575
- if (thinkingInterval) {
576
- s.clearThinking(thinkingInterval);
577
- thinkingInterval = null;
578
- }
579
- };
580
- thinkingInterval = s.Thinking();
581
- const onStream = (e) => {
582
- if (e.type === 'step_start') {
583
- toolCounter++;
584
- toolCalls.push({ tool: e.tool, args: e.args, _t: Date.now() });
585
- }
586
- else if (e.type === 'step_done' || e.type === 'step_error') {
587
- stopThinking();
588
- const p = toolCalls.shift();
589
- const doneItem = {
590
- tool: e.tool ?? p?.tool ?? '?',
591
- args: p?.args,
592
- status: e.type === 'step_done' ? 'ok' : 'error',
593
- output: e.output,
594
- error: e.error,
595
- durationMs: p ? Date.now() - p._t : undefined,
596
- };
597
- process.stdout.write(renderToolCallListItem(doneItem, toolCalls.length + 1, toolCounter) + '\n');
598
- const bodyText = e.type === 'step_done' ? e.output : e.error;
599
- if (bodyText && bodyText.length > 0) {
600
- const bodyRendered = renderToolCallBody(doneItem, boxW);
601
- if (bodyRendered)
602
- process.stdout.write(bodyRendered + '\n');
603
- }
604
- // 全部完成时打印 footer
605
- if (toolCalls.length === 0 && toolCounter > 0) {
606
- process.stdout.write(renderToolCallsFooter(toolCounter) + '\n');
562
+ // 工具调用显示由 tui-shell 的 onStream handler 处理
563
+ const response = await a.prompt(trimmed, {
564
+ onStream: (e) => {
565
+ if (e.type === 'step_start') {
566
+ tuiToolCounter++;
567
+ tuiToolCalls.push({ tool: e.tool || '?', args: e.args, _t: Date.now() });
568
+ }
569
+ else if (e.type === 'step_done' || e.type === 'step_error') {
570
+ const p = tuiToolCalls.shift();
571
+ const doneItem = {
572
+ tool: e.tool ?? (p?.tool ?? '?'),
573
+ args: p?.args,
574
+ status: e.type === 'step_done' ? 'ok' : 'error',
575
+ output: e.output,
576
+ error: e.error,
577
+ durationMs: p ? Date.now() - p._t : undefined,
578
+ };
579
+ appendLine(renderToolCallListItem(doneItem, tuiToolCalls.length + 1, tuiToolCounter));
607
580
  }
608
581
  }
609
- };
610
- const response = await a.prompt(trimmed, { onStream });
611
- stopThinking();
612
- // 智能体回复框 (圆角)
613
- process.stdout.write(renderAgentMessage(response) + '\n');
614
- // 更新底部状态栏: 上下文进度
582
+ });
583
+ // 智能体回复框
584
+ appendLine(renderAgentMessage(response));
585
+ // 停止思考动画
586
+ inkSetThinking(false);
587
+ // 更新状态栏: 上下文进度
615
588
  try {
616
- // 用 messageHistory 长度推断上下文占用
617
589
  const msgLen = JSON.stringify(a.messageHistory ?? []).length;
618
590
  cliContextPct = Math.min(100, Math.round((msgLen / 240_000) * 100));
591
+ const dur = Math.floor((Date.now() - cliStartTime) / 1000);
592
+ const barLen = 12;
593
+ const filled = Math.round((cliContextPct / 100) * barLen);
594
+ const bar = '█'.repeat(filled) + '░'.repeat(barLen - filled);
595
+ const statusText = `${C_ACCENT}${cliModelName}\x1b[0m\x1b[90m │\x1b[0m ${cliAgentName} \x1b[90m│\x1b[0m ⏱ ${dur}s \x1b[90m│\x1b[0m ${msgLen.toLocaleString()}B/240K │ ${bar} ${cliContextPct}%`;
596
+ inkSetStatus(statusText);
619
597
  }
620
598
  catch { /* 降级容忍 */ }
621
- // 双横线分隔 + 自动消费队列
622
- process.stdout.write(`${C_DIM}${'─'.repeat(8)} · ${'─'.repeat(8)}${RESET}\n`);
599
+ // 自动消费队列
623
600
  if (pendingQueue.length > 0) {
624
601
  const next = pendingQueue.shift();
625
- process.stdout.write(`${C_WARN}⏩ 自动执行队列 [${pendingQueue.length + 1}/${pendingQueue.length + 1}]${RESET}\n`);
602
+ appendLine(`${C_WARN}⏩ 自动执行队列 [${pendingQueue.length + 1}/${pendingQueue.length + 1}]${RESET}`);
626
603
  await processInput(next, comm);
627
604
  return;
628
605
  }
606
+ inkSetThinking(false);
629
607
  }
630
608
  catch (e) {
609
+ inkSetThinking(false);
631
610
  if (!e.message?.includes('ERR_USE_AFTER_CLOSE') && !e.message?.includes('write after end')) {
632
- process.stdout.write(`${MAGENTA}❌ ${e.message}${RESET}\n`);
611
+ appendLine(`${MAGENTA}❌ ${e.message}${RESET}`);
633
612
  }
634
613
  }
635
614
  }
@@ -873,7 +852,7 @@ async function runToolCommand(tool, args, outputJson, comm, model, prompt) {
873
852
  catch { }
874
853
  break;
875
854
  }
876
- process.stdout.write(`⏳ 任务已派给 ${targetPk.slice(0, 12)}..., 等回复 (最多 90s)...\n`);
855
+ appendLine(`⏳ 任务已派给 ${targetPk.slice(0, 12)}..., 等回复 (最多 90s)...`);
877
856
  const reply = await replyPromise;
878
857
  const lines = [
879
858
  `✅ 协作完成 (${reply.durationMs ? Math.round(reply.durationMs / 1000) + 's' : '?'})`,
@@ -1439,8 +1418,8 @@ async function runToolCommand(tool, args, outputJson, comm, model, prompt) {
1439
1418
  const { P2PDirect } = await import('./network/p2p-direct.js');
1440
1419
  const id = await resolveIdentity();
1441
1420
  const p2p = new P2PDirect({ name: 'cli-listen', role: id.role });
1442
- process.stdout.write(`[chat-p2p-listen] role=${id.role} pk=${id.publicKey.slice(0, 12)} listening on bolloon-agent-harness\n`);
1443
- process.stdout.write(`[chat-p2p-listen] press Ctrl-C to stop\n`);
1421
+ appendLine(`[chat-p2p-listen] role=${id.role} pk=${id.publicKey.slice(0, 12)} listening on bolloon-agent-harness`);
1422
+ appendLine(`[chat-p2p-listen] press Ctrl-C to stop`);
1444
1423
  const onData = (ev) => {
1445
1424
  try {
1446
1425
  const text = Buffer.isBuffer(ev.data) ? ev.data.toString('utf8') : String(ev.data);
@@ -1449,15 +1428,15 @@ async function runToolCommand(tool, args, outputJson, comm, model, prompt) {
1449
1428
  if (env && env.v === 3 && env.op === 'agent.chat.direct') {
1450
1429
  const { text: body, fromRole } = env.payload || {};
1451
1430
  const ts = (env.payload?.ts || new Date().toISOString()).replace('T', ' ').replace(/\.\d+Z$/, '');
1452
- process.stdout.write(`\n[${ts} ${fromRole || ev.fromPublicKey?.slice(0, 12)} → me] ${body}\n> `);
1431
+ appendLine(`\n[${ts} ${fromRole || ev.fromPublicKey?.slice(0, 12)} → me] ${body}\n> `);
1453
1432
  return;
1454
1433
  }
1455
1434
  }
1456
1435
  catch { /* 非 v3 envelope, 当 raw 显示 */ }
1457
- process.stdout.write(`\n[raw ${ev.fromPublicKey?.slice(0, 12)}] ${text.slice(0, 200)}\n> `);
1436
+ appendLine(`\n[raw ${ev.fromPublicKey?.slice(0, 12)}] ${text.slice(0, 200)}\n> `);
1458
1437
  }
1459
1438
  catch (e) {
1460
- process.stdout.write(`[chat-p2p-listen] decode error: ${e?.message ?? e}\n`);
1439
+ appendLine(`[chat-p2p-listen] decode error: ${e?.message ?? e}`);
1461
1440
  }
1462
1441
  };
1463
1442
  p2p.on('data', onData);
@@ -1465,12 +1444,12 @@ async function runToolCommand(tool, args, outputJson, comm, model, prompt) {
1465
1444
  const keepAlive = setInterval(() => {
1466
1445
  const now = Date.now();
1467
1446
  if (now - lastPing > 5 * 60_000) {
1468
- process.stdout.write(`[chat-p2p-listen] alive, role=${id.role}\n`);
1447
+ appendLine(`[chat-p2p-listen] alive, role=${id.role}`);
1469
1448
  lastPing = now;
1470
1449
  }
1471
1450
  }, 30_000);
1472
1451
  const stop = async () => {
1473
- process.stdout.write(`\n[chat-p2p-listen] stopping...\n`);
1452
+ appendLine(`\n[chat-p2p-listen] stopping...`);
1474
1453
  try {
1475
1454
  p2p.off('data', onData);
1476
1455
  }
@@ -1487,7 +1466,7 @@ async function runToolCommand(tool, args, outputJson, comm, model, prompt) {
1487
1466
  process.on('SIGHUP', stop);
1488
1467
  await p2p.start();
1489
1468
  await p2p.joinTopic(Buffer.from('bolloon-agent-harness'));
1490
- process.stdout.write(`[chat-p2p-listen] joined topic ✓\n> `);
1469
+ appendLine(`[chat-p2p-listen] joined topic ✓\n> `);
1491
1470
  await new Promise(() => { });
1492
1471
  break;
1493
1472
  }
@@ -2167,7 +2146,7 @@ async function main() {
2167
2146
  console.log = originalLog;
2168
2147
  console.info = originalInfo;
2169
2148
  process.stdout.write = originalStdoutWrite;
2170
- startCLI(comm);
2149
+ await startCLI(comm);
2171
2150
  }
2172
2151
  }
2173
2152
  catch (e) {
@@ -31,20 +31,64 @@ async function writeFile(data) {
31
31
  await ensureDir();
32
32
  await fs.writeFile(KNOWN_PEERS_FILE, JSON.stringify(data, null, 2), 'utf-8');
33
33
  }
34
- /** 添加或更新一个 known peer (key 用 name) */
34
+ /** 添加或更新一个 known peer (key 用 name, 但按 publicKey 去重) */
35
35
  export async function addOrUpdatePeer(name, publicKey, notes) {
36
36
  const safeName = (name && name.length > 0) ? name : `peer-${publicKey.substring(0, 8)}`;
37
37
  const data = await readFile();
38
- const existing = data.peers[safeName];
39
- data.peers[safeName] = {
38
+ // 2026-08-02 fix: 按 publicKey 去重 — 之前用 name 作 key, 同一 publicKey 被
39
+ // 自动发现 (discovered-xxx) / 手动添加 (备注名) / manifest 重命名 (ownerName)
40
+ // 等多条路径写入时, 会生成多条重复条目 (apple/mechrevo/node 指向同一节点)。
41
+ // 现在: 若 publicKey 已存在 (无论 name 是什么), 复用那条 entry 只更新名字/备注。
42
+ let existingEntry;
43
+ let existingName = null;
44
+ for (const [n, p] of Object.entries(data.peers)) {
45
+ if (p.publicKey === publicKey) {
46
+ existingEntry = p;
47
+ existingName = n;
48
+ break;
49
+ }
50
+ }
51
+ const targetName = existingName || safeName;
52
+ if (existingEntry) {
53
+ // 同 publicKey 已存在 → 更新 (保留原 addedAt, 新名字非 discovered- 前缀时替换)
54
+ // 2026-08-02 二次修: 用户手动命名过的条目 (非 discovered- 前缀, 如 mechrevo)
55
+ // 不应被自动来源的名字 (senderName/node/ownerName) 覆盖 — 只有自动名
56
+ // (discovered-xxx / peer-xxx) 才允许被替换成更有意义的名称。
57
+ const userNamed = existingName && !existingName.startsWith('discovered-') && !existingName.startsWith('peer-');
58
+ const keepAutoName = existingName?.startsWith('discovered-') && !name;
59
+ let finalName;
60
+ if (userNamed) {
61
+ finalName = existingName; // 保留用户命名
62
+ }
63
+ else if (keepAutoName) {
64
+ finalName = existingName;
65
+ }
66
+ else {
67
+ finalName = safeName;
68
+ }
69
+ if (finalName !== existingName) {
70
+ delete data.peers[existingName];
71
+ }
72
+ data.peers[finalName] = {
73
+ publicKey,
74
+ name: finalName,
75
+ addedAt: existingEntry.addedAt || new Date().toISOString(),
76
+ lastConnectedAt: existingEntry.lastConnectedAt,
77
+ notes: notes || existingEntry.notes
78
+ };
79
+ await writeFile(data);
80
+ console.log(`[known-peers] 更新 (publicKey 去重): ${finalName} = ${publicKey.substring(0, 12)}...`);
81
+ return;
82
+ }
83
+ data.peers[targetName] = {
40
84
  publicKey,
41
- name: safeName,
42
- addedAt: existing?.addedAt || new Date().toISOString(),
43
- lastConnectedAt: existing?.lastConnectedAt,
44
- notes: notes || existing?.notes
85
+ name: targetName,
86
+ addedAt: new Date().toISOString(),
87
+ lastConnectedAt: undefined,
88
+ notes: notes || undefined
45
89
  };
46
90
  await writeFile(data);
47
- console.log(`[known-peers] 添加/更新: ${safeName} = ${publicKey.substring(0, 12)}...`);
91
+ console.log(`[known-peers] 添加/更新: ${targetName} = ${publicKey.substring(0, 12)}...`);
48
92
  }
49
93
  /** 删除 known peer */
50
94
  export async function removePeer(name) {
@@ -42,6 +42,10 @@ const TOOL_WHITELIST = new Set([
42
42
  'read_directory', 'add_friend_by_id', 'delegate_to_engine',
43
43
  'set_persona', 'get_operation_logs', 'park_goal',
44
44
  'list_channels', 'list_local_channels',
45
+ // 2026-08-02: skill 沉淀工具 (skill-writer.ts)
46
+ 'create_skill', 'update_skill', 'list_skill_candidates', 'promote_skill',
47
+ // 2026-08-02: plan/todo/review 工具 (plan-store.ts)
48
+ 'create_plan', 'update_plan', 'review_plan', 'list_plans',
45
49
  ]);
46
50
  export const gateWhitelist = { gate: 'whitelist', allowed: true };
47
51
  /**