@bolloon/bolloon-agent 0.5.2 → 0.5.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (80) hide show
  1. package/README.md +4 -4
  2. package/dist/agents/chain/transfer-classify.js +99 -0
  3. package/dist/agents/chain/transfer-index.js +354 -0
  4. package/dist/agents/identity/address-binding.js +613 -0
  5. package/dist/agents/network-pulse.js +706 -27
  6. package/dist/agents/pi-sdk-tools.js +67 -15
  7. package/dist/agents/pi-sdk.js +7 -1
  8. package/dist/agents/session-store.js +144 -3
  9. package/dist/agents/wallet-tools.js +63 -0
  10. package/dist/agents/x402/direct-payment.js +689 -0
  11. package/dist/agents/x402/order-identity.js +408 -0
  12. package/dist/agents/x402/paid-info-store.js +17 -0
  13. package/dist/agents/x402/seller-signing.js +614 -0
  14. package/dist/agents/x402/seller-summary.js +398 -0
  15. package/dist/cli/identity-command.js +327 -2
  16. package/dist/cli/ink-app.js +79 -27
  17. package/dist/cli/input-history.js +119 -0
  18. package/dist/cli/loading-tui.js +66 -6
  19. package/dist/cli/log-gate.js +39 -1
  20. package/dist/cli/protocol-envelope.js +4 -0
  21. package/dist/cli/session-summary.js +129 -0
  22. package/dist/cli/session-tree.js +141 -0
  23. package/dist/cli/status-segments.js +70 -0
  24. package/dist/cli/stores.js +16 -0
  25. package/dist/cli/update-commands.js +41 -25
  26. package/dist/cli/wallet-store.js +159 -0
  27. package/dist/cli/wallet-table.js +46 -0
  28. package/dist/cli/x402-buyer-command.js +463 -0
  29. package/dist/cli/x402-seller-command.js +289 -0
  30. package/dist/cli-entry.js +43 -5
  31. package/dist/electron-build/agents/chain/transfer-classify.js +106 -0
  32. package/dist/electron-build/agents/chain/transfer-classify.js.map +1 -0
  33. package/dist/electron-build/agents/chain/transfer-index.js +400 -0
  34. package/dist/electron-build/agents/chain/transfer-index.js.map +1 -0
  35. package/dist/electron-build/agents/identity/address-binding.js +672 -0
  36. package/dist/electron-build/agents/identity/address-binding.js.map +1 -0
  37. package/dist/electron-build/agents/network-pulse.js +714 -28
  38. package/dist/electron-build/agents/network-pulse.js.map +1 -1
  39. package/dist/electron-build/agents/pi-sdk-tools.js +68 -15
  40. package/dist/electron-build/agents/pi-sdk-tools.js.map +1 -1
  41. package/dist/electron-build/agents/pi-sdk.js +7 -1
  42. package/dist/electron-build/agents/pi-sdk.js.map +1 -1
  43. package/dist/electron-build/agents/session-store.js +144 -3
  44. package/dist/electron-build/agents/session-store.js.map +1 -1
  45. package/dist/electron-build/agents/wallet-tools.js +102 -0
  46. package/dist/electron-build/agents/wallet-tools.js.map +1 -0
  47. package/dist/electron-build/agents/x402/direct-payment.js +744 -0
  48. package/dist/electron-build/agents/x402/direct-payment.js.map +1 -0
  49. package/dist/electron-build/agents/x402/order-identity.js +458 -0
  50. package/dist/electron-build/agents/x402/order-identity.js.map +1 -0
  51. package/dist/electron-build/agents/x402/paid-info-protocol.js.map +1 -1
  52. package/dist/electron-build/agents/x402/paid-info-store.js +18 -0
  53. package/dist/electron-build/agents/x402/paid-info-store.js.map +1 -1
  54. package/dist/electron-build/agents/x402/seller-signing.js +682 -0
  55. package/dist/electron-build/agents/x402/seller-signing.js.map +1 -0
  56. package/dist/electron-build/cli/wallet-store.js +200 -0
  57. package/dist/electron-build/cli/wallet-store.js.map +1 -0
  58. package/dist/electron-build/llm/pi-ai.js +25 -0
  59. package/dist/electron-build/llm/pi-ai.js.map +1 -1
  60. package/dist/electron-build/utils/auto-update.js +2 -2
  61. package/dist/electron-build/utils/auto-update.js.map +1 -1
  62. package/dist/electron-build/utils/runtime-bootstrap.js +1 -1
  63. package/dist/electron-build/utils/runtime-bootstrap.js.map +1 -1
  64. package/dist/electron-build/utils/update-manager.js +3 -3
  65. package/dist/electron-build/utils/update-manager.js.map +1 -1
  66. package/dist/electron-build/utils/version-identity.js +1 -1
  67. package/dist/electron-build/utils/version-identity.js.map +1 -1
  68. package/dist/electron-build/web/routes-x402-info.js +28 -1
  69. package/dist/electron-build/web/routes-x402-info.js.map +1 -1
  70. package/dist/index.js +829 -104
  71. package/dist/llm/pi-ai.js +23 -0
  72. package/dist/utils/auto-update.js +2 -2
  73. package/dist/utils/runtime-bootstrap.js +1 -1
  74. package/dist/utils/update-health.js +2 -2
  75. package/dist/utils/update-manager.js +3 -3
  76. package/dist/utils/version-identity.js +1 -1
  77. package/dist/web/bolloon-web.json +3 -3
  78. package/dist/web/routes-x402-info.js +28 -1
  79. package/dist/web/sw.js +1 -1
  80. package/package.json +2 -1
package/dist/index.js CHANGED
@@ -1,9 +1,14 @@
1
1
  import { createHyperswarmCommunicator, createTopic, KeyManager, AgentAuthManager, createVerificationManager, } from '@diap/sdk';
2
2
  import { irohTransport } from './network/iroh-transport.js';
3
+ import { getLastAiTiming, getLastAiUsage } from './llm/pi-ai.js';
4
+ import { setHistoryScope } from './cli/input-history.js';
5
+ import { statusSegments, fitSegments, rightAlignPad } from './cli/status-segments.js';
6
+ import { loadWalletTool } from './agents/wallet-tools.js';
3
7
  import { HybridMessenger } from './network/hybrid-messenger.js';
4
8
  import * as ed25519 from '@noble/ed25519';
5
9
  import { sha512 } from '@noble/hashes/sha2.js';
6
10
  import * as fs from 'fs/promises';
11
+ import * as fsSync from 'fs'; // 2026-09-30: 启动面板读技能快照(同步; 渲染路径上不能 await)
7
12
  import { existsSync, mkdirSync } from 'fs';
8
13
  import * as path from 'path';
9
14
  import { spawn, execSync } from 'child_process';
@@ -11,12 +16,14 @@ import * as os from 'os';
11
16
  import { documentReader } from './documents/reader.js';
12
17
  import { initMinimax } from './constraints/index.js';
13
18
  import { createAgentSession } from './agents/pi-sdk.js';
19
+ import { SessionStore } from './agents/session-store.js'; // 2026-09-30: 退出存档 —— 只为把落盘路径如实打出来
20
+ import { kuboAlreadyRunning } from './agents/pi-sdk-tools.js';
14
21
  import { createSubAgentManager } from './agents/subagent-manager.js';
15
22
  import { getGlobalSharedContext } from './social/global-shared-context.js';
16
23
  import { createBollharnessIntegration } from './bollharness-integration/index.js';
17
24
  import * as readline from 'readline';
18
- import { printBanner, renderDialog, renderUserMessage, renderAgentMessage, renderMessageBox, renderToolCallListItem, termWidth, ROBOT_HEAD, BOLLOON_BANNER, dispWidth } from './cli/loading-tui.js';
19
- import { startInk, stopInk, suspendInk, resumeInk, inkAppendLine as appendLine, inkReplaceMatchingLine, inkSetStatus, inkSetThinking, inkSetTransient } from './cli/ink-app.js';
25
+ import { printBanner, termHeight, truncate, renderDialog, renderUserMessage, renderAgentMessage, renderMessageBox, renderToolCallListItem, termWidth, ROBOT_HEAD, BOLLOON_BANNER, BOLLOON_BANNER_SMALL, dispWidth } from './cli/loading-tui.js';
26
+ import { startInk, stopInk, suspendInk, resumeInk, inkAppendLine as appendLine, inkSetStatus, inkSetThinking, inkSetTransient, bootPanelMaxLines } from './cli/ink-app.js';
20
27
  // 2026-09-26: 启动期日志闸门 (默认静默加载日志 + 写文件 + verbose 全量回流 + 信号行不吞)
21
28
  import { installStartupLogGate, isStartupVerbose, startupLogPath, VERBOSE_ENV, carriesHumanSignal, logStartupLine } from './cli/log-gate.js';
22
29
  // 2026-09-27: 回复流卫生 — 内部运行日志 (循环推进/运行登记/收尾计数…) 不进对话回复流, 改落日志文件
@@ -142,6 +149,13 @@ ed25519.hashes.sha512 = sha512;
142
149
  let startupPanelFirst = false;
143
150
  /** 面板第一帧已经画出来了 (startCLI 取走面板提示之后): 晚到的降级提示直接追加进面板 */
144
151
  let startupPanelReady = false;
152
+ /**
153
+ * 2026-09-30: 启动面板 / 就绪行**各只许追加一次**.
154
+ * 防任何重入路径 (会话切换 / 重复 boot / 恢复) 把面板再叠一条 —— leo 报的「顶框重复 14 次」那一类
155
+ * 都落在这个类里; 叠了就再也说不清屏上哪一条是真的.
156
+ */
157
+ let bootPanelPrinted = false;
158
+ let bootReadinessPrinted = false;
145
159
  /**
146
160
  * 启动期(面板之前)一行进度 / 降级输出 —— 交互默认口径下的**唯一出路**。
147
161
  *
@@ -331,6 +345,122 @@ async function bootstrapIroh(keypair, name) {
331
345
  // Agent 懒加载
332
346
  // ---------------------------------------------------------------------------
333
347
  let agent = null;
348
+ /**
349
+ * 退出存档 (2026-09-30, leo: 「按 ctrl+c / esc 退出的时候, 自动存档 session 进程, 最后显示再见👋」)
350
+ *
351
+ * 三条纪律:
352
+ * 1. **幂等** —— 两条退出路径 (Ctrl+C/双击 Esc 与输入 exit) 都调它, 只存一次 (sessionArchivedOnExit).
353
+ * 2. **如实** —— 存成功给 key 与落盘路径; 没建过会话就直说"无需存档"; 失败给原因。**不许静默**。
354
+ * 3. **不阻塞退出** —— 存档失败也照常走完退出流程 (存不下不是不退出的理由)。
355
+ */
356
+ let sessionArchivedOnExit = false;
357
+ /**
358
+ * 退出期上屏 (2026-09-30): `stopInk()` 之后 Ink 已卸载, 此时 `appendLine()` 只进 store、**打不到终端**
359
+ * —— 既有代码就是这样, 所以「👋 再见!」用户**从来没真看见过** (这次真机日志坐实)。
360
+ * 退出期的最后几句话必须用 `process.stdout.write` 直写。
361
+ */
362
+ function writeExitLine(line) {
363
+ try {
364
+ process.stdout.write(line + '\n');
365
+ }
366
+ catch { /* 写不出去也不能挡住退出 */ }
367
+ }
368
+ /** 本次 CLI 会话的 key (bootSessionId 算好后写进来; 退出存档两条路径都读它) */
369
+ let cliSessionKey = null;
370
+ async function archiveSessionOnExit(key) {
371
+ if (sessionArchivedOnExit)
372
+ return '';
373
+ sessionArchivedOnExit = true;
374
+ const t0 = Date.now();
375
+ try {
376
+ if (!agent || !key || typeof agent.saveCurrentSession !== 'function') {
377
+ return `${C_DIM}(本次没建会话, 无需存档)${RESET}`;
378
+ }
379
+ await agent.saveCurrentSession(key);
380
+ // 2026-09-30 (leo: 「结束进程的时候没有加载 title 和总结,以至于打开 session 后没有内容」):
381
+ // 退出存档时**顺手把 AI 标题/摘要算好写回** —— 否则 /sessions 里那条只有会话 ID (数据在, 只是没标题)。
382
+ // 纪律: 12s 内不回来就放行 (存档已完成, 标题是附加品); 失败**如实标注**, 不挡退出, 不编标题。
383
+ let titleNote = '';
384
+ try {
385
+ const { summarizeSession } = await import('./cli/session-summary.js');
386
+ const d = await summarizeSession(key, { store: new SessionStore() });
387
+ if (d.source === 'ai' && d.title)
388
+ titleNote = ` · ${C_TEXT}${d.title}${RESET}${C_DIM}`;
389
+ else if (d.source === 'raw' && d.title)
390
+ titleNote = ` · ${C_DIM}(AI 不可用, 标题用了首条消息)`;
391
+ else
392
+ titleNote = ` · ${C_DIM}(没有可总结的用户内容)`;
393
+ }
394
+ catch (e) {
395
+ titleNote = ` · ${C_WARN}(标题没生成: ${String(e?.message || e).slice(0, 40)})${RESET}${C_DIM}`;
396
+ }
397
+ // 2026-09-30: 退出也落一次 commit —— 会话树 (git) 靠 fork/退出两个时点建起来, 不做消息级提交
398
+ let treeNote = '';
399
+ try {
400
+ const { commitTree } = await import('./cli/session-tree.js');
401
+ const tr = await commitTree(new SessionStore(), `session: ${key} · exit save`);
402
+ if (!tr.ok)
403
+ treeNote = ` · ${C_WARN}树未记上: ${String(tr.error).slice(0, 60)}${RESET}${C_DIM}`;
404
+ }
405
+ catch { /* 树失败不挡退出 */ }
406
+ let where = '';
407
+ try {
408
+ where = new SessionStore().pathFor(key);
409
+ }
410
+ catch { /* 路径拿不到不影响存档本身 */ }
411
+ // 2026-09-30 (leo: 「没有结束时间,没有总花费时间和日期记录」): 退出的那一行要能回答
412
+ // 「这次会话从几点到几点、一共多久」—— 起点取会话真起点 (cliStartTime, 与状态栏 ⏱ 同一个钟)。
413
+ const p2 = (n) => String(n).padStart(2, '0');
414
+ const stamp = (d) => `${d.getFullYear()}-${p2(d.getMonth() + 1)}-${p2(d.getDate())} ${p2(d.getHours())}:${p2(d.getMinutes())}:${p2(d.getSeconds())}`;
415
+ const ended = new Date();
416
+ const timeNote = cliStartTime
417
+ ? ` · ${stamp(new Date(cliStartTime))} → ${stamp(ended)} · 用时 ${fmtDuration(Date.now() - cliStartTime)}`
418
+ : ` · ${stamp(ended)}`;
419
+ return `${C_DIM}📦 session 已存档: ${key}${timeNote}${RESET}\n ${C_DIM}${where ? where : ''} · ${Date.now() - t0}ms${titleNote}${treeNote}${RESET}`;
420
+ }
421
+ catch (e) {
422
+ const msg = String(e?.message || e);
423
+ // 2026-09-30: "没内容可存档" 与 "存档失败" 是两件事 —— 前者不该报成 ⚠ 吓人。
424
+ // (历史事故: 写路径误用读语义 ⇒ 新会话一律抛 not found ⇒ 用户看到红字却什么也没坏)
425
+ if (/session not found/i.test(msg)) {
426
+ return `${C_DIM}(本次没有可存档的会话内容 — 会话文件还没落盘)${RESET}`;
427
+ }
428
+ return `${C_WARN}⚠ session 存档失败: ${msg.slice(0, 90)}${RESET}`;
429
+ }
430
+ }
431
+ /**
432
+ * 读一条会话的 metadata.title 并刷新工具栏右侧 (没有就清空)。
433
+ * 2026-09-30 (leo: 「切换 session 后也要有 title 加载」): 启动 / 切会话 / 回合结束三处共用同一套口径 ——
434
+ * 否则切完会话右侧还挂上一条的标题 (或空着), 看着像没生效。
435
+ */
436
+ async function refreshSessionTitle(key) {
437
+ // 2026-09-30 (leo: 「切换后没有立刻刷新」): **先立刻清空并重绘** —— 否则等待异步读盘那几十毫秒里,
438
+ // 工具栏右侧还挂着上一条会话的标题, 看着就是"没刷新"。读完再填。
439
+ cliSessionPreview = '';
440
+ cliSessionTitleKey = key || null;
441
+ try {
442
+ inkSetStatus(getStatus());
443
+ }
444
+ catch { /* Ink 未起 */ }
445
+ if (!key)
446
+ return;
447
+ try {
448
+ const fs = await import('fs/promises');
449
+ const store = new SessionStore();
450
+ const raw = JSON.parse(await fs.readFile(store.pathFor(key), 'utf-8'));
451
+ const md = (Array.isArray(raw) ? {} : raw?.metadata) || {};
452
+ const t = String(md.title || '').trim();
453
+ // 只认"这条读的确实是当前会话" —— 读盘期间可能又切了一次
454
+ cliSessionPreview = (t && t !== key && cliSessionTitleKey === key) ? t.slice(0, 24) : '';
455
+ }
456
+ catch {
457
+ cliSessionPreview = ''; // 读不到/没标题 ⇒ 空着 (不拿旧标题冒充)
458
+ }
459
+ try {
460
+ inkSetStatus(getStatus());
461
+ }
462
+ catch { /* Ink 未起 */ }
463
+ }
334
464
  /** 2026-08-09: agent 当前绑定的 channel id (null = 默认 harness 身份) — 切换时据此重建 */
335
465
  let agentBoundChannelId = null;
336
466
  let harness = null;
@@ -483,8 +613,23 @@ let _startupLogGate = null;
483
613
  const pendingQueue = [];
484
614
  let cliStartTime = 0;
485
615
  let cliModelName = '…';
486
- let cliAgentName = '…';
616
+ let cliAgentName = 'bolloon'; // 2026-09-30: 别用 '…' —— 身份没解析出来时状态栏显示个省略号, 看着像"没有 bolloon 标识"
487
617
  let cliActiveChannelId = null;
618
+ /**
619
+ * 状态栏"活数据" (leo 2026-09-30: 参考别家 CLI 的 ◎ / ◷ / ↑ t/s / ⚙ / ⏲ / ✓ 那一行)
620
+ * · cliTurnStartedAt / cliLastTurnMs —— 本轮用时(◷) 与 上一步用时(✓)
621
+ * · cliTurnReplyBytes —— 本轮回复字节数, 折 ≈t/s (按 ~3.5B/token 估, 所以标"≈")
622
+ * · 在跑工具数不另存 —— 直接用已有的 tuiToolCalls (它就是"还没结束的工具"清单)
623
+ * 测不到的段 (比如"限流窗口还剩 20m") 一律不显示 —— 不编数字。
624
+ */
625
+ let cliTurnStartedAt = 0;
626
+ let cliLastTurnMs = 0;
627
+ let cliTurnReplyBytes = 0;
628
+ let cliTurnToolCount = 0; // 在跑工具数 (回合内由工具事件更新)
629
+ /** 当前会话的 Title/Preview (工具栏右侧显示用; 回合结束后后台生成一次, 生成过就不再跑) */
630
+ let cliSessionPreview = '';
631
+ let cliSessionTitleKey = null; // 这条标题属于哪个会话 —— 不是当前会话一律不显示 (防串台)
632
+ let cliSessionSummarizing = false;
488
633
  // 2026-08-12: 当前 active channel 的 agentId (如 agent-alice). memory 落盘按 agentId 存,
489
634
  // /memory /resume /did 读路径必须用 agentId 而非 display name (cliAgentName), 否则路径不一致读不到.
490
635
  let cliAgentId = null;
@@ -548,16 +693,71 @@ function buildContextBar(usage) {
548
693
  suffix = ` ${C_OK}✓ 已压缩${RESET}`;
549
694
  return `${usageTxt} ${C_DIM}│${RESET} ${bar} ${barColor}${pctTxt}${RESET}${suffix}`;
550
695
  }
696
+ /**
697
+ * 状态栏里的模型名: **只留型号**。
698
+ * 2026-09-30 leo: 「deepseek · deepseek-flash v0.5.3,这里有重复冗余,保留模型型号就行,provider 可以不要」
699
+ * (`cliModelName` 仍是 'deepseek · deepseek-flash' —— 启动面板那边保持原样, 只有状态栏缩。)
700
+ */
701
+ function statusModelName() {
702
+ const parts = String(cliModelName || '').split(' · ');
703
+ return parts.length > 1 ? parts.slice(1).join(' · ') : String(cliModelName || '');
704
+ }
551
705
  /** 状态栏: 模型 │ 当前智能体 (含 channel) │ ⏱ 时间 │ 320k/1M │ [██████░░░░] 32% (bolloon 色系) */
552
706
  function getStatus() {
553
707
  const usage = getCliCtxUsage();
554
- const agentPart = cliActiveChannelId ? `${cliAgentName} ${C_DIM}(ch:${cliActiveChannelId.slice(0, 10)})${RESET}` : cliAgentName;
555
- return `${C_ACCENT}${cliModelName}${RESET}${C_DIM} │${RESET} ${agentPart} ${C_DIM}│${RESET} ⏱ ${C_TEXT}${fmtDuration(Date.now() - cliStartTime)}${RESET}${C_DIM} │${RESET} ${buildContextBar(usage)}`;
708
+ // 名字兜底: 空/'…' 一律显示 bolloon —— 标识必须一直在 (leo: 「bolloon 标识没出现」)
709
+ const __name = (!cliAgentName || cliAgentName === '…') ? 'bolloon' : cliAgentName;
710
+ // 2026-09-30: 左半边按宽压扁 —— 窄终端里 `(ch:xxx)` 与 token 计数让位给右边的活数据段
711
+ const __w = termWidth();
712
+ const agentPart = (cliActiveChannelId && __w >= 120) ? `${__name} ${C_DIM}(ch:${cliActiveChannelId.slice(0, 10)})${RESET}` : __name;
713
+ // 左半边先建好 —— 后面对右侧活数据段做宽度取舍要用它的纯文本长度
714
+ const __left = `${C_ACCENT}${statusModelName()}${RESET} ${C_DIM}v${_BOLLOON_VERSION}${RESET}${C_DIM} │${RESET} ${agentPart} ${C_DIM}│${RESET} ⏱ ${C_TEXT}${fmtDuration(Date.now() - cliStartTime)}${RESET}${C_DIM} │${RESET} ${buildContextBar(usage)}`;
715
+ const __leftPlainLen = __left.replace(/\x1b\[[0-9;]*m/g, '').length;
716
+ // 2026-09-30 (leo): 四段抽到 src/cli/status-segments.ts (纯函数 ⇒ "什么时候显示什么"可确定性验证)
717
+ const __segs = statusSegments({
718
+ running: cliTurnStartedAt > 0,
719
+ turnElapsedMs: cliTurnStartedAt > 0 ? Date.now() - cliTurnStartedAt : 0,
720
+ lastTurnMs: cliLastTurnMs,
721
+ lastTurnReplyBytes: cliTurnReplyBytes,
722
+ toolCount: cliTurnToolCount,
723
+ aiTiming: getLastAiTiming(),
724
+ aiUsage: getLastAiUsage(),
725
+ turnStartedAt: cliTurnStartedAt,
726
+ }).map(s => {
727
+ const m = /^(\S+)\s(.*)$/.exec(s);
728
+ const icon = m ? m[1] : '';
729
+ const val = m ? m[2] : s;
730
+ const color = icon.startsWith('✓') ? C_OK : C_TEXT;
731
+ return `${C_DIM}${icon}${RESET} ${color}${val}${RESET}`;
732
+ });
733
+ // 按剩余宽度取舍 (真机 112 列时右段被截 ⇒ 看着像没出现)
734
+ const __segsFit = fitSegments(__segs.map(x => x.replace(/\x1b\[[0-9;]*m/g, '')), Math.max(20, termWidth() - 2 - __leftPlainLen - 6));
735
+ const __live = __segsFit.length
736
+ ? ` ${C_DIM}│${RESET} ` + __segsFit.map(x => {
737
+ const m = /^(\S+)\s(.*)$/.exec(x);
738
+ const icon = m ? m[1] : '';
739
+ const val = m ? m[2] : x;
740
+ return `${C_DIM}${icon}${RESET} ${icon.startsWith('✓') ? C_OK : C_TEXT}${val}${RESET}`;
741
+ }).join(` ${C_DIM}│${RESET} `)
742
+ : '';
743
+ // 2026-09-30 (leo: 「这个 title 需要顶格右侧」): 会话 Title **右对齐**贴右边缘 ——
744
+ // 先算左半边+活数据段的显示宽, 再补空格把 `▸ 标题` 推到 width-1 处; 放不下就不显示 (不挤坏这一行)。
745
+ const __notePlain = (cliSessionPreview && cliSessionTitleKey === cliSessionKey) ? `▸ ${cliSessionPreview}` : '';
746
+ let __noteStr = '';
747
+ if (__notePlain) {
748
+ // 宽度必须用 dispWidth (East Asian Width 表): 行内的 `│ ░ ◷ ▸ ◎ ✓ ↑ ⚙` 都是**单宽**,
749
+ // 用 "charCode > 255 ⇒ 2" 的启发式会每行多算十几列 ⇒ 右对齐留一大截 (leo: 「没有完全右对齐」)。
750
+ const used = dispWidth(`${__left}${__live}`);
751
+ const pad = rightAlignPad(used, dispWidth(__notePlain), termWidth());
752
+ if (pad !== null)
753
+ __noteStr = ' '.repeat(pad) + `${C_DIM}${__notePlain}${RESET}`;
754
+ }
755
+ return `${__left}${__live}${__noteStr}`;
556
756
  }
557
757
  function statusBarLine() {
558
758
  const dur = cliStartTime ? fmtDuration(Date.now() - cliStartTime) : '0s';
559
759
  const usage = getCliCtxUsage();
560
- return `${C_ACCENT}${cliModelName}${RESET}${C_DIM} │${RESET} ${cliAgentName} ${C_DIM}│${RESET} ⏱ ${C_ACCENT}${dur}${RESET} ${C_DIM}│${RESET} ${buildContextBar(usage)}`;
760
+ return `${C_ACCENT}${statusModelName()}${RESET}${C_DIM} │${RESET} ${cliAgentName} ${C_DIM}│${RESET} ⏱ ${C_ACCENT}${dur}${RESET} ${C_DIM}│${RESET} ${buildContextBar(usage)}`;
561
761
  }
562
762
  /**
563
763
  * 启动会话面板 — 按类别展示启动加载的 skills (真实类别 = 目录名去掉 frontmatter.name 后缀),
@@ -565,31 +765,91 @@ function statusBarLine() {
565
765
  */
566
766
  /** 启动面板框: BOLLOON 字标 logo 顶部居中 → 下方两栏 = face 艺术字(左) + 加载内容(右) */
567
767
  function buildBootBox(face, banner, rest) {
568
- const faceW = Math.max(1, ...face.map((l) => dispWidth(l)));
569
- const bannerMax = Math.max(1, ...banner.map((l) => dispWidth(l)));
570
- const restMax = Math.max(1, ...rest.map((l) => dispWidth(l)));
768
+ /**
769
+ * 2026-09-30 修复 (leo 报「TUI 渲染出错 + 回复被截断」):
770
+ * 盒子外宽 = min(终端宽-2, 内容宽+4, 调用方上限) ⇒ **内容宽一旦超过 终端宽-6, 每行都被终端折行**,
771
+ * 艺术字因此裂成两列错位 (实测 110 列终端下 BOLLOON 艺术字宽 108 > 内宽 104).
772
+ * 另外原来 maxLines: 0 = 不限高 ⇒ 面板长过屏幕, 把输入行顶出视野 (30 行终端下实测输入行消失).
773
+ * 所以这里先把内容**裁进真实可用宽高**, 再画盒子:
774
+ * 宽度: 艺术字太宽 ⇒ 整块丢掉(宁可不显示) · 两栏装不下 ⇒ 先丢 face 列 · 右侧行超宽 ⇒ 逐行截断
775
+ * 高度: 面板 ≤ 终端行数 - 10 (给输入行/状态栏留位置), 砍掉的尾部如实标注行数与自查入口
776
+ */
571
777
  const gap = 4;
572
- const twoColW = faceW + gap + restMax;
573
- const contentW = Math.max(bannerMax + 8, twoColW);
574
- const center = (rows) => rows.map((l) => ' '.repeat(Math.max(0, Math.floor((contentW - dispWidth(l)) / 2))) + l);
575
- const n = rest.length;
576
- const fStart = Math.max(0, Math.floor((n - face.length) / 2)); // face 列对内容垂直居中 = 等高
577
- const twoCol = rest.map((r, i) => {
578
- const f = (i >= fStart && i < fStart + face.length) ? face[i - fStart] : null;
579
- const fpart = f ? `${f}${' '.repeat(Math.max(0, faceW - dispWidth(f)) + gap)}` : ' '.repeat(faceW + gap);
778
+ const maxInner = Math.max(24, termWidth() - 6); // 外框 = │ + 空 + 内容 + 空 + │
779
+ const rows = termHeight();
780
+ const bannerW = banner.reduce((m, l) => Math.max(m, dispWidth(l)), 0);
781
+ // 装不下(极窄终端)仍退回一行文字 —— 保证任何宽度都有字标, 不再是空白
782
+ const useBanner = bannerW > 0 && bannerW + 4 <= maxInner ? banner : [`${C_ACCENT}${BOLD}BOLLOON${RESET}`];
783
+ let useFace = face;
784
+ const faceW0 = face.reduce((m, l) => Math.max(m, dispWidth(l)), 0);
785
+ const restMax0 = rest.reduce((m, l) => Math.max(m, dispWidth(l)), 0);
786
+ /**
787
+ * 2026-09-30 (leo: 「我说的 logo 是面板里面的小吉祥物, 它看不到了」):
788
+ * 原来的判据是「吉祥物 + 最宽内容行 > 内宽 ⇒ **把吉祥物整块丢掉**」——
789
+ * 而技能类目行天生很长 (openclaw-imports: 3d-web-experience, …), 于是**每次都触发、吉祥物永远不显示**。
790
+ * 优先级反了: 吉祥物是面板的标识, 内容行可以裁。现在只在
791
+ * 「连最小内容宽度都放不下」(真的窄) 时才丢吉祥物; 否则**裁窄右侧内容**让它俩共存。
792
+ */
793
+ const MIN_REST_W = 30;
794
+ if (useFace.length > 0 && faceW0 + gap + MIN_REST_W > maxInner)
795
+ useFace = [];
796
+ const faceW = useFace.reduce((m, l) => Math.max(m, dispWidth(l)), 0);
797
+ const room = Math.max(16, maxInner - (useFace.length > 0 ? faceW + gap : 0));
798
+ const restFit = rest.map((l) => (dispWidth(l) > room ? truncate(l, room) : l));
799
+ const bannerRows = useBanner.length > 0 ? useBanner.length + 1 : 0; // +1 = banner 与两栏之间的空行
800
+ // 2026-09-30 (leo: 「还是启动后会出现顶部的刷新」): 面板高过**消息窗口**时, Ink 清不掉上一帧
801
+ // ⇒ 退化成追加模式, 顶框和艺术字被一条条重复写到屏上. 所以高度按窗口真高算, 不拍脑袋 (原来 rows-10).
802
+ const bootWinH = bootPanelMaxLines(rows, termWidth());
803
+ // 2026-09-30 (leo: 「面板右侧的 skills 内容不见了」): **内容优先** —— 面板内容按终端高度留出
804
+ // 输入栏/状态栏的余量来给 (不按"面板区"的高度砍); 装不下的部分由**面板区滚动**看全.
805
+ // 上一版按区域高算 ⇒ 小终端下面板只剩 3 行内容, skills 直接没了 (回归, 已改回).
806
+ // 2026-09-30 (leo: 「内容出现了, logo 消失了」): 面板进 <Static>(无限高度) 后, 一旦
807
+ // "面板 + 底部块" 超过可视行数, **被顶出视野的正是顶部的 logo** (内容在下面反而留着)。
808
+ // 原来 rows-10 正好卡满(0 余量) ⇒ 最后那个换行就把顶行(logo)滚掉。
809
+ // 底部块实测 6 行 + 就绪行 1 行 ⇒ 这里留 3 行余量, 启动这一刻 logo 一定看得见。
810
+ const bodyCap = Math.max(4, rows - 13 - bannerRows); // 下限 4: 宁可少列类目, 也要保住整只 logo
811
+ void bootWinH;
812
+ let restLines = restFit;
813
+ if (restLines.length > bodyCap) {
814
+ const dropped = restLines.length - (bodyCap - 1);
815
+ restLines = [...restLines.slice(0, bodyCap - 1), `${C_DIM}… 还有 ${dropped} 行 (PgUp 滚面板 / bolloon status 看全量)${RESET}`];
816
+ }
817
+ const restMax = restLines.reduce((m, l) => Math.max(m, dispWidth(l)), 0);
818
+ const twoColW = useFace.length > 0 ? faceW + gap + restMax : restMax;
819
+ const contentW = Math.max(useBanner.length > 0 ? bannerW + 8 : 0, twoColW);
820
+ const center = (rs) => rs.map((l) => ' '.repeat(Math.max(0, Math.floor((contentW - dispWidth(l)) / 2))) + l);
821
+ const n = restLines.length;
822
+ const fStart = Math.max(0, Math.floor((n - useFace.length) / 2)); // face 列对内容垂直居中 = 等高
823
+ const twoCol = restLines.map((r, i) => {
824
+ const f = (useFace.length > 0 && i >= fStart && i < fStart + useFace.length) ? useFace[i - fStart] : null;
825
+ const fpart = f
826
+ ? `${f}${' '.repeat(Math.max(0, faceW - dispWidth(f)) + gap)}`
827
+ : (useFace.length > 0 ? ' '.repeat(faceW + gap) : '');
580
828
  return fpart + r;
581
829
  });
582
- const body = [...center(banner), '', ...twoCol];
583
- return renderMessageBox({ title: '🚀 Bolloon · 启动面板', body: body.join('\n'), color: C_ACCENT, maxLines: 0 });
830
+ const body = [...(useBanner.length > 0 ? [...center(useBanner), ''] : []), ...twoCol];
831
+ const outer = Math.min(termWidth() - 2, Math.max(contentW + 4, 40));
832
+ return renderMessageBox({ title: '🚀 Bolloon · 启动面板', body: body.join('\n'), color: C_ACCENT, maxLines: 0, width: outer });
833
+ }
834
+ /** 启动面板的技能快照 (成功扫描后写; 下次超时/读失败时兜底 —— 跟启动日志同目录) */
835
+ function skillsCachePath() {
836
+ return path.join(path.dirname(startupLogPath()), 'panel-skills.json');
584
837
  }
585
838
  async function bootPanel(boot) {
586
839
  const sub = []; // tools / MCP (Promise.all 里填充, 最后统一排到类别下方)
587
840
  const catNames = new Map();
841
+ let skillsScanTimedOut = false; // 技能扫描到点没收完 ⇒ 面板里如实标出来, 不假装"就是这些"
842
+ const skillsScanErrors = []; // 2026-09-30: 读失败**必须计数写出来** —— 静默吞掉 = 屏上"0 skills"(假 0, 比不显示更坏)
843
+ let skillsFromCache = false; // 本次没扫到 ⇒ 用上次成功扫描的快照, 并如实标注来源
844
+ let skillsCacheAt = '';
588
845
  await Promise.all([
589
846
  (async () => {
590
847
  try {
591
848
  // 真实类别 = 目录名前缀去掉技能名后缀 (SKILL.md 无 category 字段, 但 frontmatter.name 是真名:
592
849
  // software-development-bolloon-development / name=bolloon-development → software-development)
850
+ // 2026-09-30: **先把模块导进来, 再开始计时** —— 原写法把 4s 预算从 import 之前就开始算,
851
+ // 而冷启动那一刻 import 技能模块(带 yaml 等)本身要好几秒 ⇒ 预算被 import 吃掉, 面板报
852
+ // "扫描超时 / 0 个技能"(实测: 扫描真身只 702ms/1300 个, 却显示 0).
593
853
  const { loadSkillsDir, defaultSkillPaths } = await import('./agents/skill-loader.js');
594
854
  const pushCat = (cat, name) => {
595
855
  const arr = catNames.get(cat) || [];
@@ -597,32 +857,63 @@ async function bootPanel(boot) {
597
857
  arr.push(name);
598
858
  catNames.set(cat, arr);
599
859
  };
600
- for (const root of defaultSkillPaths()) {
601
- const metas = await loadSkillsDir(root);
602
- for (const m of metas) {
603
- if (m.status === 'archived')
604
- continue;
605
- const dir = m.sourcePath ? path.basename(path.dirname(m.sourcePath)) : '';
606
- const nm = m.name || '';
607
- let cat = dir;
608
- if (dir && nm && dir.endsWith(nm)) {
609
- const pre = dir.slice(0, dir.length - nm.length).replace(/-+$/, '');
610
- if (pre)
611
- cat = pre;
860
+ // 2026-09-30: 多个技能根**并行**扫 (原来顺序 await ⇒ 实测 4399ms, 并行 297ms, 1300 个技能);
861
+ // 并给一个上限: 超时就如实标出来, 不让"出面板"被一次磁盘抖动无限期拖住.
862
+ const roots = defaultSkillPaths();
863
+ const all = await Promise.race([
864
+ Promise.all(roots.map((r) => loadSkillsDir(r).catch((e) => {
865
+ // 2026-09-30: 原来这里 `.catch(() => [])` **静默**吞错 ⇒ 屏上"0 skills · 0 类",
866
+ // 谁也看不出是"真 0"还是"读不动" (leo 报「面板内容变少了」)。现在计数 + 写出来。
867
+ skillsScanErrors.push(`${path.basename(r)}: ${String(e?.message || e).slice(0, 70)}`);
868
+ return [];
869
+ }))),
870
+ new Promise((res) => setTimeout(() => res(null), 6000)), // 冷 FS 上留足余量
871
+ ]);
872
+ if (all === null) {
873
+ skillsScanTimedOut = true;
874
+ }
875
+ else {
876
+ for (const metas of all) {
877
+ for (const m of metas) {
878
+ if (m.status === 'archived')
879
+ continue;
880
+ const dir = m.sourcePath ? path.basename(path.dirname(m.sourcePath)) : '';
881
+ const nm = m.name || '';
882
+ let cat = dir;
883
+ if (dir && nm && dir.endsWith(nm)) {
884
+ const pre = dir.slice(0, dir.length - nm.length).replace(/-+$/, '');
885
+ if (pre)
886
+ cat = pre;
887
+ }
888
+ pushCat(cat || 'other', nm || dir);
612
889
  }
613
- pushCat(cat || 'other', nm || dir);
614
890
  }
615
891
  }
892
+ // 扫到东西就落一份快照 —— 下次超时/读失败时用它兜底(并标注来源), 不再"内容突然变少"
893
+ if (catNames.size > 0) {
894
+ try {
895
+ const totalNow = [...catNames.values()].reduce((s2, a) => s2 + a.length, 0);
896
+ await fs.mkdir(path.dirname(skillsCachePath()), { recursive: true });
897
+ await fs.writeFile(skillsCachePath(), JSON.stringify({
898
+ at: new Date().toLocaleString('zh-CN', { hour12: false }), total: totalNow, cats: [...catNames.entries()],
899
+ }), 'utf8');
900
+ }
901
+ catch { /* 快照写不进去不影响面板 */ }
902
+ }
616
903
  }
617
904
  catch { /* 省略 */ }
618
905
  })(),
619
906
  (async () => {
907
+ /**
908
+ * 2026-09-30 (leo: 「面板的加载没有和输入框一起出现, 有时间差, 且面板内部内容有缺失」):
909
+ * 这里原来是 `getAgent()` —— 建**整个 agent 会话**(模型/工具/身份). 插桩实测它把事件循环
910
+ * **同步阻塞**几十秒 (调用后 20s 连 end 都没打出来, 2.5s/4s 上限的定时器都点不着) ⇒ 面板被拖到
911
+ * 十几秒后才出, 技能那一段也没跑完(=内容缺失). 面板只为一行 `🔧 N tools` 付这个代价不值得
912
+ * ⇒ **不再等它**: 已建好的 agent 才取工具数; 全量清单看 `bolloon status`.
913
+ */
620
914
  try {
621
- const a = await Promise.race([
622
- getAgent().catch(() => null),
623
- new Promise((res) => setTimeout(() => res(null), 2500)),
624
- ]);
625
- const tools = a && typeof a.getToolList === 'function' ? a.getToolList() : null;
915
+ const a = agent && typeof agent.getToolList === 'function' ? agent : null;
916
+ const tools = a ? a.getToolList() : null;
626
917
  if (tools && tools.length > 0)
627
918
  sub.push(`🔧 ${tools.length} tools`);
628
919
  }
@@ -640,7 +931,20 @@ async function bootPanel(boot) {
640
931
  ]);
641
932
  // 栈式布局: face 艺术字居中 → BOLLOON 字标 logo 在其下 → 内容左对齐
642
933
  const art = ROBOT_HEAD;
643
- const banner = BOLLOON_BANNER.split('\n');
934
+ // 2026-09-30 (leo: 「我看不到我的 logo」): 按宽度**挑档**, 不再"窄就丢".
935
+ // 原来只有一档 87 列艺术字, 终端宽 < ~97 列时整块被丢、只剩一行文字.
936
+ // 挑"装得下的最大档": 大(87 列) → 小(37 列) → 都装不下才在框里退回一行文字.
937
+ const candInner = Math.max(24, termWidth() - 6);
938
+ // 2026-09-30 (leo: 「内容出现了, logo 消失了」): 挑档**宽和高都要装得下** ——
939
+ // 面板进 <Static>(无限高度) 后, 一旦"面板 + 底部块"超过可视行数, 被顶出视野的正是顶部 logo。
940
+ // 所以: 整块艺术字(87 列)装不下高就换窄版(37 列), 都不行才退回一行文字 —— 任何行数都有 logo。
941
+ const candRows = termHeight();
942
+ const bannerFits = (b) => {
943
+ const w = Math.max(...b.map(dispWidth));
944
+ const panelRows = b.length + 4 /* 最少内容行 */ + 2 /* 上下边框 */;
945
+ return w + 4 <= candInner && panelRows + 7 /* 底部块+就绪行 */ <= candRows - 2;
946
+ };
947
+ const banner = [BOLLOON_BANNER, BOLLOON_BANNER_SMALL].map(b => b.split('\n')).find(bannerFits) || [];
644
948
  // 头: 目录 / 模型 / Session (预先加载信息)
645
949
  const rest = [];
646
950
  if (boot.dir)
@@ -650,6 +954,20 @@ async function bootPanel(boot) {
650
954
  if (boot.session)
651
955
  rest.push(`Session: ${boot.session}`);
652
956
  rest.push('');
957
+ // 2026-09-30 (leo 的屏面: 提示写了"快照"但数字还是 0): 回落必须在 normalized/sorted **之前** ——
958
+ // 先把快照填进 catNames, 再统计, 否则出现"有提示没数字"的假面板。
959
+ if (catNames.size === 0) {
960
+ try {
961
+ const c = JSON.parse(fsSync.readFileSync(skillsCachePath(), 'utf8'));
962
+ for (const [k, arr] of (c.cats || []))
963
+ catNames.set(k, arr);
964
+ if (catNames.size > 0) {
965
+ skillsFromCache = true;
966
+ skillsCacheAt = String(c.at || '');
967
+ }
968
+ }
969
+ catch { /* 没快照就照实显示 0 */ }
970
+ }
653
971
  // 类别行 (全部展开, 不截断类别; 每类列前 8 名 + '+N more') — 单一实例归 other
654
972
  const normalized = new Map();
655
973
  for (const [cat, arr] of catNames) {
@@ -664,12 +982,20 @@ async function bootPanel(boot) {
664
982
  }
665
983
  const sorted = [...normalized.entries()].sort((a, b) => b[1].length - a[1].length);
666
984
  const total = sorted.reduce((s, [, arr]) => s + arr.length, 0);
985
+ // 2026-09-30 (leo 报「回复渲染时被截断」): 面板**压矮** —— 技能类目只列前 4 类(其余归到计数行),
986
+ // 否则 20+ 行面板把消息区占满, 后面的回复没地方站 (= 挤成窄列/看起来被截断).
987
+ // 2026-09-30 (真机日志: 计数行被面板裁掉): **计数行放类目之前** —— 面板装不下时裁的是尾部,
988
+ // 原来的顺序 (类目 → 计数) 会让"⚡ N skills · M 类"这个最要紧的数先被丢掉。
989
+ rest.push(`⚡ ${total} skills · ${sorted.length} 类${skillsFromCache ? ` (上次成功扫描的快照 · ${skillsCacheAt})` : ''}`, '');
667
990
  for (const [cat, arr] of sorted) {
668
991
  const shown = arr.slice(0, 8);
669
992
  const more = arr.length > shown.length ? `, +${arr.length - shown.length} more` : '';
670
993
  rest.push(`${cat}: ${shown.join(', ')}${more}`);
671
994
  }
672
- rest.push(`⚡ ${total} skills · ${sorted.length} 类`, '');
995
+ if (skillsScanTimedOut)
996
+ rest.push(`${C_DIM}(本次技能扫描超时, 上面是${skillsFromCache ? '快照' : '已扫到的部分'} —— bolloon status 看全量)${RESET}`, '');
997
+ if (skillsScanErrors.length)
998
+ rest.push(`${C_WARN}(技能根读取失败 ${skillsScanErrors.length} 个: ${skillsScanErrors[0]})${RESET}`, '');
673
999
  if (sub.length)
674
1000
  rest.push(...sub, '');
675
1001
  // 自动整理 (经验/技能整理心跳) 模式并入面板
@@ -744,6 +1070,10 @@ async function startCLI(commReady) {
744
1070
  if (process.env.BOLLOON_SKIP_KUBO !== '1') {
745
1071
  void (async () => {
746
1072
  try {
1073
+ // 2026-09-30: 已经在跑就别让 SDK 再走装配 —— 那步会跑 `ipfs init` 并在启动面板前打一行
1074
+ // [warn](含"失败"⇒ 日志门按信号放行), 就是 leo 报的「开启过程的日志没去掉」.
1075
+ if (await kuboAlreadyRunning())
1076
+ return;
747
1077
  const sdk = await import('@diap/sdk');
748
1078
  const checkKuboSetup = sdk.checkKuboSetup;
749
1079
  if (typeof checkKuboSetup === 'function') {
@@ -762,6 +1092,7 @@ async function startCLI(commReady) {
762
1092
  if (active) {
763
1093
  cliAgentName = active.name;
764
1094
  cliActiveChannelId = active.channelId ?? null;
1095
+ setHistoryScope(active.channelId ?? null);
765
1096
  // 2026-08-12: 同步 agentId (memory 路径一致) — 从 rawChannels 按 channelId 取 agentId
766
1097
  const raw = active.channelId ? store.rawChannels.find((c) => c.id === active.channelId) : undefined;
767
1098
  cliAgentId = raw?.agentId || null;
@@ -777,7 +1108,7 @@ async function startCLI(commReady) {
777
1108
  _ctxManagerRef = await import('./bootstrap/context-manager.js');
778
1109
  }
779
1110
  catch { /* 降级: getCliCtxUsage 返回 0/1M */ }
780
- const initialStatus = `${C_ACCENT}${cliModelName}${RESET}${C_DIM} │${RESET} ${cliAgentName} ${C_DIM}│${RESET} ⏱ 0s${C_DIM} │${RESET} ${buildContextBar(getCliCtxUsage())}`;
1111
+ const initialStatus = `${C_ACCENT}${statusModelName()}${RESET} ${C_DIM}v${_BOLLOON_VERSION}${RESET}${C_DIM} │${RESET} ${cliAgentName} ${C_DIM}│${RESET} ⏱ 0s${C_DIM} │${RESET} ${buildContextBar(getCliCtxUsage())}`;
781
1112
  // 2026-09-08 (leo 规格): 图标下元信息层数据 — 目录(home→~) / 模型 / Session id (Hermes 风格: YYYYMMDD_HHMMSS_xxxx)
782
1113
  const bootDirShort = process.cwd().replace(os.homedir(), '~');
783
1114
  const bootSessionId = (() => {
@@ -785,6 +1116,39 @@ async function startCLI(commReady) {
785
1116
  const p = (n, l = 2) => String(n).padStart(l, '0');
786
1117
  return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}_${p(d.getHours())}${p(d.getMinutes())}${p(d.getSeconds())}_${Math.random().toString(16).slice(2, 8)}`;
787
1118
  })();
1119
+ const notes = takeStartupPanelNotes();
1120
+ const verbosePreamble = startupPreambleVisible();
1121
+ const bootBox = bootPanelPrinted ? null : await bootPanel({
1122
+ dir: bootDirShort,
1123
+ model: (cliModelName && cliModelName !== '…') ? cliModelName : undefined,
1124
+ session: bootSessionId,
1125
+ });
1126
+ cliSessionKey = bootSessionId; // 2026-09-30: 退出存档要用 (两条退出路径都读它)
1127
+ void refreshSessionTitle(cliSessionKey); // 启动就把当前会话的标题带上
1128
+ if (bootBox) {
1129
+ // 2026-09-30 (leo: 「面板和回复要往上面无限推」): 面板就是这条流的**第一条** —— 渲染侧走 <Static>, 高度无限
1130
+ appendLine(bootBox);
1131
+ bootPanelPrinted = true;
1132
+ }
1133
+ // 2026-09-27 (leo 口径: `bolloon --cli` 起来直接就是面板, 启动前不刷前言):
1134
+ // 就绪度报告 / 初始化续跑前言**默认不上屏** —— 但它们不是消失, 而是变成**面板里的行**:
1135
+ // · 就绪度折成一行 `就绪: basic ✓ · agent ✓ · durable ✗ · network ✓ · 详情: …`
1136
+ // · 失败 / 需人介入 (缺 key、连通性实测失败、初始化中断) 折成 `/!\` 开头的提示行 —— **不许吞**
1137
+ // 注: 字面量里必须写 `\\ ` (反斜杠要转义), 否则 JS 会把 `\ ` 吃成空格 —— 屏上就变成 `/! ` 了
1138
+ // (验收门 `verify-cli-panel` 的 A4 按 `/!\` 判, 真踩过一次)。
1139
+ // 全量版仍在 `startup.log`; `--verbose` / BOLLOON_VERBOSE=1 下逐字回到启动前的位置 (走 stderr)。
1140
+ if (notes.readiness && !verbosePreamble && !bootReadinessPrinted) {
1141
+ appendLine(`${C_DIM}${notes.readiness}${RESET}`);
1142
+ bootReadinessPrinted = true;
1143
+ }
1144
+ for (const a of notes.alerts)
1145
+ appendLine(`${C_WARN}/!\\ ${a}${RESET}`);
1146
+ // 已经到面板了: 缓冲里剩下的纯前言**不上屏**(已在 startup.log), 丢掉引用免得下次启动重放
1147
+ clearBufferedNotices();
1148
+ // 面板第一帧之后: 晚到的降级提示 (后台 iroh / bootstrap / DID 发布…) 直接追加进面板,
1149
+ // 不再等下一次 `takeStartupPanelNotes()` —— 否则它们会静静地烂在告警数组里 (= 吞)
1150
+ startupPanelReady = true;
1151
+ // 2026-09-30: 面板在起帧前已进 store ⇒ 首帧里与输入框一起出现
788
1152
  startInk((text) => { processInput(text, comm); }, initialStatus, getStatus);
789
1153
  // 2026-08-10: 自动整理心跳 (CLI 侧, 与社交心跳并列) — 启动后立即"固定看一下 skills view"
790
1154
  // (扫描遗留 skills), 之后按周期 (默认 30min, env BOLLOON_ORGANIZE_HEARTBEAT_MS) 完整进化经验.
@@ -811,7 +1175,15 @@ async function startCLI(commReady) {
811
1175
  boxLines.push(`🧠 知识整理: ${kSections.map(s => s.error ? `${s.label}✗` : `${s.label}✓`).join(' ')}`);
812
1176
  }
813
1177
  if (boxLines.length > 0) {
814
- appendLine(renderMessageBox({ title: '自动整理完成', body: boxLines.join('\n'), color: C_ACCENT, maxLines: 10 }));
1178
+ /**
1179
+ * 2026-09-30 (leo: 「还是没有把开启过程的日志去掉」): 自动整理是**内部运行日志** ——
1180
+ * ① 开机第一轮是纯扫描(不取 LLM、什么都没改) ⇒ 一个字符都不该上屏;
1181
+ * ② 周期轮就算真做了事, 也不许**往对话回复流塞一个 10 行盒子** ——
1182
+ * 那会挤掉/截断正在渲染的回复(实测回复被压成窄列).
1183
+ * 处置: 只落盘(与其它内部运行日志同一条路径/同一个 [运行] 标记), 屏上只留 transient 颜文字.
1184
+ * 去哪儿查: grep '[运行]' ~/.bolloon/logs/startup.log
1185
+ */
1186
+ appendInternalRunLog(`自动整理完成: ${boxLines.join(' | ')}`, 'organize-heartbeat');
815
1187
  }
816
1188
  },
817
1189
  onError: () => inkSetTransient(null),
@@ -887,35 +1259,11 @@ async function startCLI(commReady) {
887
1259
  }
888
1260
  catch { /* cron 调度启动失败不阻塞 CLI */ }
889
1261
  // 启动会话面板 (大框): 栈式 = face 艺术字居中 + BOLLOON 字标 logo 在其下 + 预设信息(skills/工具/模型/目录/Session/分支/时间)
890
- // 先立即渲染「艺术字 + logo + 正在加载...」, bootPanel 就绪后 inkReplaceMatchingLine 按标记原位替换为完整内容
891
- // (用匹配替换而非 replaceLast — P2P/连接消息可能先于 bootPanel 追加, replaceLast 会覆盖错一条)
892
- const bootBox = buildBootBox(ROBOT_HEAD, BOLLOON_BANNER.split('\n'), [
893
- `${bootDirShort} · ${(cliModelName && cliModelName !== '…') ? cliModelName : ''} · Session: ${bootSessionId}`,
894
- '',
895
- '⟳ 正在加载技能 / 工具...',
896
- ]);
897
- appendLine(bootBox);
898
- // 2026-09-27 (leo 口径: `bolloon --cli` 起来直接就是面板, 启动前不刷前言):
899
- // 就绪度报告 / 初始化续跑前言**默认不上屏** —— 但它们不是消失, 而是变成**面板里的行**:
900
- // · 就绪度折成一行 `就绪: basic ✓ · agent ✓ · durable ✗ · network ✓ · 详情: …`
901
- // · 失败 / 需人介入 (缺 key、连通性实测失败、初始化中断) 折成 `/!\` 开头的提示行 —— **不许吞**
902
- // 注: 字面量里必须写 `\\ ` (反斜杠要转义), 否则 JS 会把 `\ ` 吃成空格 —— 屏上就变成 `/! ` 了
903
- // (验收门 `verify-cli-panel` 的 A4 按 `/!\` 判, 真踩过一次)。
904
- // 全量版仍在 `startup.log`; `--verbose` / BOLLOON_VERBOSE=1 下逐字回到启动前的位置 (走 stderr)。
905
- const notes = takeStartupPanelNotes();
906
- const verbosePreamble = startupPreambleVisible();
907
- if (notes.readiness && !verbosePreamble)
908
- appendLine(`${C_DIM}${notes.readiness}${RESET}`);
909
- for (const a of notes.alerts)
910
- appendLine(`${C_WARN}/!\\ ${a}${RESET}`);
911
- // 已经到面板了: 缓冲里剩下的纯前言**不上屏**(已在 startup.log), 丢掉引用免得下次启动重放
912
- clearBufferedNotices();
913
- // 面板第一帧之后: 晚到的降级提示 (后台 iroh / bootstrap / DID 发布…) 直接追加进面板,
914
- // 不再等下一次 `takeStartupPanelNotes()` —— 否则它们会静静地烂在告警数组里 (= 吞)
915
- startupPanelReady = true;
916
- void bootPanel({ dir: bootDirShort, model: (cliModelName && cliModelName !== '…') ? cliModelName : undefined, session: bootSessionId })
917
- .then((box) => { if (box)
918
- inkReplaceMatchingLine(bootBox, box); }).catch(() => { });
1262
+ //
1263
+ // 2026-09-30 (leo: 「加载过程不渲染, 直接展示结果」): **不再先打「⟳ 正在加载技能 / 工具...」占位框再替换** ——
1264
+ // 先把内容算完 (skills/tools 各有 2.5s 预算), 然后**一次性**把成品框打出来.
1265
+ // 代价: 起来到出框之间屏幕是空的 (不给任何假的"加载中"画面); 好处: 屏上只会出现一份面板, 没有替换中间态.
1266
+ // (面板已在上面一次性打出 —— 不再有占位框需要替换)
919
1267
  // Wait on a promise that resolves on Ctrl+C / 双击 Esc
920
1268
  // (ink-app 的 requestExit 调 __inkRequestExit → resolve, 清理后 process.exit)
921
1269
  let cliExitResolve = () => { };
@@ -924,7 +1272,14 @@ async function startCLI(commReady) {
924
1272
  await exitPromise;
925
1273
  delete globalThis.__inkRequestExit;
926
1274
  stopInk();
927
- appendLine(`\n${CYAN}👋 再见!${RESET}`);
1275
+ // 2026-09-30 (leo): 退出前**自动存档 session**, 存档结果先上屏, 最后才是「再见👋」
1276
+ try {
1277
+ const note = await archiveSessionOnExit(cliSessionKey);
1278
+ if (note)
1279
+ writeExitLine(note);
1280
+ }
1281
+ catch { /* 存档绝不阻塞退出 */ }
1282
+ writeExitLine(`\n${CYAN}👋 再见!${RESET}`);
928
1283
  try {
929
1284
  cliOrganizeHeartbeat?.stop();
930
1285
  }
@@ -1072,6 +1427,7 @@ async function processInputInner(input, comm) {
1072
1427
  await store.setActive(r.channel.id);
1073
1428
  cliAgentName = r.identity.name;
1074
1429
  cliActiveChannelId = r.channel.id;
1430
+ setHistoryScope(r.channel.id);
1075
1431
  cliAgentId = r.channel.agentId || null; // 2026-08-12: memory 路径一致
1076
1432
  // 2026-08-09: 切 channel 必须重建 agent session — 否则身份/记忆停留在旧 channel (bug 修复)
1077
1433
  invalidateAgent();
@@ -1174,6 +1530,7 @@ async function processInputInner(input, comm) {
1174
1530
  await store.setActive(id);
1175
1531
  cliAgentName = name.trim();
1176
1532
  cliActiveChannelId = id;
1533
+ setHistoryScope(id);
1177
1534
  cliAgentId = agentId; // 2026-08-12: memory 路径一致
1178
1535
  // 2026-08-09: 新建 agent 后立即重建 session — 否则新 agent 身份不加载 (bug 修复)
1179
1536
  invalidateAgent();
@@ -1257,6 +1614,11 @@ async function processInputInner(input, comm) {
1257
1614
  }
1258
1615
  // ==================== 2026-08-06: 系统命令组 (/model /now /ipfs /memory ...) ====================
1259
1616
  const cmd = trimmed.toLowerCase();
1617
+ // 2026-09-30 (leo: 「/session 1 是切换到该 session,目前这个命令还没成功」「bolloon 把这个命令当成了 prompt」):
1618
+ // `cmd` 是**整行**小写 ⇒ '/session 1' ≠ '/session' ⇒ 带参数的命令掉进 prompt 路径 (被当用户消息发下去)。
1619
+ // 不能全局改成"首词" —— 同文件里 `/email clear` 这类多词命令就是靠整行匹配的。
1620
+ // 所以加一个**首词**给"带参数的命令"用 (cmd 保持原样给多词命令用)。
1621
+ const cmdHead = cmd.split(/\s+/)[0];
1260
1622
  // /net — Agent 网络快捷命令 (join/status/ctx, 2026-09-08)
1261
1623
  if (cmd === '/net' || cmd.startsWith('/net ')) {
1262
1624
  const arg = trimmed.slice(4).trim();
@@ -2173,17 +2535,190 @@ async function processInputInner(input, comm) {
2173
2535
  }
2174
2536
  return;
2175
2537
  }
2176
- // /session — 当前会话信息
2177
- if (cmd === '/session') {
2538
+ // /session — 会话信息 + **最近会话列表**(用 /session <序号> 继续)
2539
+ // 2026-09-30 (leo: 「/session 没有反应, 没有展示最新的记录, 无法选择继续」):
2540
+ // 原来只打三行信息, 且整段被 `catch {}` **静默吞错** ⇒ getAgent 一抛错就"什么都没发生"。
2541
+ // 现在: 错误如实说出来; 并列出最近会话(最新在上), 可直接选一个接着聊。
2542
+ // /fork <#|id> — 从某条会话分叉出新会话 (标准 branch 语义: 共享 fork 点之前历史, 之后各自独立)
2543
+ // 树用 git 管: ~/.bolloon/sessions/ 是仓库, 每次 fork / 退出存档 = 一次 commit (git log --graph 即会话树)
2544
+ if (cmdHead === '/fork') {
2545
+ const forkArg = trimmed.split(/\s+/)[1] || '';
2546
+ if (!forkArg || forkArg === '--tree') {
2547
+ try {
2548
+ const { treeLog } = await import('./cli/session-tree.js');
2549
+ const store = new SessionStore();
2550
+ if (forkArg === '--tree') {
2551
+ const t = await treeLog(store, 12);
2552
+ appendLine(t.ok ? `${C_ACCENT}会话树 (最近 12 次提交):${RESET}\n${t.text.split('\n').map(l => ` ${C_DIM}${l}${RESET}`).join('\n')}` : `${C_WARN}⚠ 读会话树失败: ${t.error}${RESET}`);
2553
+ }
2554
+ else {
2555
+ appendLine(`${C_DIM}用法: ${RESET}${C_ACCENT}/fork <#|id>${RESET}${C_DIM} — 从某条会话分叉 (语义 = 共享 fork 点之前历史, 之后各自独立)${RESET}`);
2556
+ appendLine(`${C_DIM} ${RESET}${C_ACCENT}/fork --tree${RESET}${C_DIM} — 看会话树 (git log) · 序号见 /sessions${RESET}`);
2557
+ }
2558
+ }
2559
+ catch (e) {
2560
+ appendLine(`${C_WARN}⚠ ${String(e?.message || e).slice(0, 120)}${RESET}`);
2561
+ }
2562
+ return;
2563
+ }
2564
+ try {
2565
+ const store = new SessionStore();
2566
+ const list = await store.listRecent(12);
2567
+ const idx = Number(forkArg);
2568
+ const src = Number.isFinite(idx) && idx >= 1 ? list[idx - 1] : list.find(r => r.key === forkArg);
2569
+ if (!src) {
2570
+ appendLine(`${C_WARN}⚠ 没有第 ${forkArg} 个会话 —— 用 /sessions 列表里的 # 或完整 ID${RESET}`);
2571
+ return;
2572
+ }
2573
+ const { forkSession } = await import('./cli/session-tree.js');
2574
+ const r = await forkSession(store, src.key);
2575
+ if (!r.ok || !r.newKey) {
2576
+ appendLine(`${C_ERROR}/fork 失败: ${r.error || '未知原因'}${RESET}`);
2577
+ return;
2578
+ }
2579
+ cliSessionKey = r.newKey; // 之后就在这条分支上继续 (独立)
2580
+ let resumed = 0;
2581
+ try {
2582
+ const a = await getAgent();
2583
+ if (typeof a.resumeSession === 'function')
2584
+ resumed = await a.resumeSession(r.newKey, 30);
2585
+ }
2586
+ catch { /* 载入失败不影响 fork 本身 */ }
2587
+ // channel 的 currentSessionId 指到新分支 (与 /sessions <#> 同一套落盘口径)
2588
+ try {
2589
+ const { readFile, writeFile } = await import('fs/promises');
2590
+ const { join } = await import('path');
2591
+ const cp = join(process.env.HOME || '/tmp', '.bolloon', 'sessions', 'channels.json');
2592
+ const parsed = JSON.parse(await readFile(cp, 'utf-8'));
2593
+ const channels = Array.isArray(parsed) ? parsed : parsed?.channels || [];
2594
+ for (const c of channels) {
2595
+ if (cliActiveChannelId && c.id === cliActiveChannelId)
2596
+ c.currentSessionId = r.newKey;
2597
+ else if (!cliActiveChannelId && c.id === channels[0]?.id)
2598
+ c.currentSessionId = r.newKey;
2599
+ }
2600
+ await writeFile(cp, JSON.stringify(Array.isArray(parsed) ? channels : { ...parsed, channels }, null, 2), 'utf-8');
2601
+ }
2602
+ catch { /* 无 channels.json 也能继续 */ }
2603
+ appendLine(`${C_OK}✓ 已分叉${RESET} ${C_DIM}${r.newKey}${RESET} ← ${C_DIM}${src.key}${RESET} ${C_DIM}(继承 ${r.inherited} 条历史 · 已载入 ${resumed} 条 · 之后两条各自独立)${RESET}`);
2604
+ appendLine(r.commit ? ` ${C_DIM}git: ${r.commit}${RESET}` : ` ${C_WARN}⚠ 树未记上: ${r.error}${RESET}`);
2605
+ }
2606
+ catch (e) {
2607
+ appendLine(`${C_ERROR}/fork 失败: ${String(e?.message || e).slice(0, 200)}${RESET}`);
2608
+ }
2609
+ return;
2610
+ }
2611
+ if (cmdHead === '/session' || cmdHead === '/sessions') { // 带参数 (如 /session 1) 也认
2612
+ const sessArg = trimmed.split(/\s+/)[1] || '';
2613
+ const sessStore = new SessionStore();
2178
2614
  try {
2179
2615
  const a = await getAgent();
2180
2616
  const h = a.messageHistory ?? [];
2181
- appendLine(`${C_ACCENT}会话:${RESET}`);
2182
- appendLine(` ${C_DIM}channel:${RESET} ${a.currentChannelId || '—'}`);
2183
- appendLine(` ${C_DIM}agent:${RESET} ${a.currentAgentId || '—'}`);
2184
- appendLine(` ${C_DIM}消息:${RESET} ${h.length} 条 (${h.length > 15 ? `${h.length - 15} 条已压缩` : '窗口内'})`);
2617
+ appendLine(`${C_ACCENT}当前会话:${RESET} ${C_DIM}${cliSessionKey || '—'}${RESET}`);
2618
+ appendLine(` ${C_DIM}channel:${RESET} ${a.currentChannelId || cliActiveChannelId || '—'} ${C_DIM}agent:${RESET} ${a.currentAgentId || cliAgentId || '—'}`);
2619
+ appendLine(` ${C_DIM}消息:${RESET} ${h.length} 条 ${h.length > 15 ? `${C_DIM}(${h.length - 15} 条已压缩)${RESET}` : ''}`);
2620
+ }
2621
+ catch (e) {
2622
+ appendLine(`${C_WARN}⚠ 读当前会话失败: ${String(e?.message || e).slice(0, 120)}${RESET}`);
2623
+ }
2624
+ let recent = [];
2625
+ try {
2626
+ recent = await sessStore.listRecent(10);
2627
+ }
2628
+ catch (e) {
2629
+ appendLine(`${C_WARN}⚠ 读会话目录失败: ${String(e?.message || e).slice(0, 120)}${RESET}`);
2630
+ }
2631
+ if (!recent.length) {
2632
+ appendLine(`${C_DIM}没有可继续的历史会话 (${sessStore.dir})${RESET}`);
2633
+ }
2634
+ else {
2635
+ // 2026-09-30 (leo: 「Title, Preview 要有内容, 使用 AI 来总结进去」):
2636
+ // 缺标题的会话 (title == key) 现场用 AI 总结一次, 结果写回文件 metadata (只算一次)。
2637
+ // 每次最多 3 条 (每条 12s 超时), 免得 /sessions 变成一次长跑; 失败的**如实标注**不编。
2638
+ const need = recent.filter(r => r.title === r.key).slice(0, 3);
2639
+ if (need.length) {
2640
+ appendLine(`${C_DIM}正在用 AI 总结 ${need.length} 个会话的标题/摘要…${RESET}`);
2641
+ try {
2642
+ const { summarizeSession } = await import('./cli/session-summary.js');
2643
+ for (const r of need) {
2644
+ const d = await summarizeSession(r.key, { store: sessStore });
2645
+ r.title = d.title;
2646
+ if (d.preview)
2647
+ r.preview = d.preview;
2648
+ r.summarySource = d.source;
2649
+ }
2650
+ }
2651
+ catch (e) {
2652
+ appendLine(`${C_WARN}⚠ AI 总结不可用 (标题保持会话 ID): ${String(e?.message || e).slice(0, 100)}${RESET}`);
2653
+ }
2654
+ }
2655
+ // 表格 (leo 2026-09-30 指定格式): # / Title / Preview / Last Active / ID
2656
+ // 列宽按终端宽动态分配 (先给 Preview 让位), 中文按**显示宽**对齐 (dispWidth)。
2657
+ const relEn = (ms) => {
2658
+ const age = Math.max(0, Date.now() - ms);
2659
+ if (age < 60_000)
2660
+ return 'just now';
2661
+ if (age < 3_600_000)
2662
+ return `${Math.floor(age / 60_000)}m ago`;
2663
+ if (age < 86_400_000)
2664
+ return `${Math.floor(age / 3_600_000)}h ago`;
2665
+ return `${Math.floor(age / 86_400_000)}d ago`;
2666
+ };
2667
+ const PAD = (v, w) => v + ' '.repeat(Math.max(0, w - dispWidth(v)));
2668
+ const avail = Math.max(60, termWidth() - 2);
2669
+ const wN = 3, wTitle = 32, wAgo = 13, wId = 24;
2670
+ const wPrev = Math.max(12, Math.min(40, avail - (wN + wTitle + wAgo + wId + 4)));
2671
+ const hdr = [PAD('#', wN), PAD('Title', wTitle), PAD('Preview', wPrev), PAD('Last Active', wAgo), PAD('ID', wId)];
2672
+ const rule = [wN, wTitle, wPrev, wAgo, wId].map(w => '─'.repeat(w));
2673
+ appendLine(`${C_ACCENT}Recent sessions:${RESET}`);
2674
+ appendLine('');
2675
+ appendLine(` ${C_DIM}${hdr.join(' ')}${RESET}`);
2676
+ appendLine(` ${C_DIM}${rule.join(' ')}${RESET}`);
2677
+ recent.forEach((r, i) => {
2678
+ const cur = r.key === cliSessionKey ? ` ${C_OK}←当前${RESET}` : '';
2679
+ appendLine(` ${C_ACCENT}${PAD(String(i + 1), wN)}${RESET} ${PAD(truncate(r.title, wTitle), wTitle)} ${C_DIM}${PAD(truncate(r.preview || '—', wPrev), wPrev)}${RESET} ${PAD(relEn(r.mtimeMs), wAgo)} ${C_DIM}${PAD(truncate(r.key, wId), wId)}${RESET}${cur}`);
2680
+ });
2681
+ appendLine('');
2682
+ appendLine(` ${C_DIM}继续某个会话: ${C_ACCENT}/sessions <#>${C_DIM} (例如 /sessions 1)${RESET}`);
2683
+ }
2684
+ if (sessArg) {
2685
+ const idx = Number(sessArg);
2686
+ const pick = Number.isFinite(idx) && idx >= 1 ? recent[idx - 1] : recent.find(r => r.key === sessArg);
2687
+ if (!pick) {
2688
+ appendLine(`${C_WARN}⚠ 没有第 ${sessArg} 个会话 —— 用上面列表的 # 或完整 ID${RESET}`);
2689
+ return;
2690
+ }
2691
+ try {
2692
+ const a = await getAgent();
2693
+ const n = typeof a.resumeSession === 'function' ? await a.resumeSession(pick.key, 30) : 0;
2694
+ cliSessionKey = pick.key; // 之后退出存档也写回这个会话
2695
+ let saved = false;
2696
+ try {
2697
+ const { readFile, writeFile } = await import('fs/promises');
2698
+ const { join } = await import('path');
2699
+ const cp = join(process.env.HOME || '/tmp', '.bolloon', 'sessions', 'channels.json');
2700
+ const parsed = JSON.parse(await readFile(cp, 'utf-8'));
2701
+ const channels = Array.isArray(parsed) ? parsed : parsed?.channels || [];
2702
+ for (const c of channels) {
2703
+ if (cliActiveChannelId && c.id === cliActiveChannelId) {
2704
+ c.currentSessionId = pick.key;
2705
+ saved = true;
2706
+ }
2707
+ else if (!cliActiveChannelId && c.id === channels[0]?.id) {
2708
+ c.currentSessionId = pick.key;
2709
+ saved = true;
2710
+ }
2711
+ }
2712
+ await writeFile(cp, JSON.stringify(Array.isArray(parsed) ? channels : { ...parsed, channels }, null, 2), 'utf-8');
2713
+ }
2714
+ catch { /* 无 channels.json 也能继续 */ }
2715
+ await refreshSessionTitle(pick.key); // 切完立刻把这条会话的标题带上 (leo 2026-09-30)
2716
+ appendLine(`${C_OK}✓ 已切到会话 ${pick.key}${RESET} ${C_DIM}(载入 ${n} 条历史${saved ? ' · 已写入 channel' : ''}; 屏上不重放旧消息, 直接接着聊)${RESET}`);
2717
+ }
2718
+ catch (e) {
2719
+ appendLine(`${C_ERROR}/session ${sessArg} 失败: ${String(e?.message || e).slice(0, 200)}${RESET}`);
2720
+ }
2185
2721
  }
2186
- catch { /* 静默 */ }
2187
2722
  return;
2188
2723
  }
2189
2724
  // /memory — 记忆摘要 (memory-compressor 落盘文件)
@@ -2307,7 +2842,7 @@ async function processInputInner(input, comm) {
2307
2842
  return;
2308
2843
  }
2309
2844
  // /todo — 循环过程工具: 查看/勾选步骤 (2026-08-08)
2310
- if (cmd === '/todo') {
2845
+ if (cmdHead === '/todo') {
2311
2846
  try {
2312
2847
  const { listActivePlans } = await import('./agents/plan-store.js');
2313
2848
  const plans = await listActivePlans();
@@ -2670,24 +3205,142 @@ async function processInputInner(input, comm) {
2670
3205
  return;
2671
3206
  }
2672
3207
  // /wallet — 钱包状态
2673
- if (cmd === '/wallet') {
3208
+ // /wallet — 钱包台账 (2026-09-30 做实: 以前只读一个**从来没人写过**的 wallets.json ⇒ 永远「钱包 (0)」)
3209
+ // 纪律: 私钥/助记词只落 0600 台账 (~/.bolloon/wallets.json), **命令输出永不回显**;
3210
+ // 不读别的 agent 的钱包目录 (chain-config.ts 硬规则), 要收编只能 /wallet import <path> 显式指定。
3211
+ if (cmdHead === '/wallet') {
2674
3212
  try {
2675
- const { readFile } = await import('fs/promises');
2676
- const { join } = await import('path');
2677
- let wallets = [];
2678
- try {
2679
- wallets = JSON.parse(await readFile(join(process.env.HOME || '/tmp', '.bolloon', 'wallets.json'), 'utf-8'));
3213
+ const { loadWallets, addWallet, walletsDir, shortAddr } = await import('./cli/wallet-store.js');
3214
+ const arg = trimmed.split(/\s+/).slice(1);
3215
+ const sub = (arg[0] || '').toLowerCase();
3216
+ const list = await loadWallets();
3217
+ const pick = (spec) => {
3218
+ const n = Number(spec);
3219
+ if (spec && Number.isFinite(n) && n >= 1)
3220
+ return list[n - 1];
3221
+ if (spec?.startsWith('0x'))
3222
+ return list.find(w => w.address.toLowerCase() === spec.toLowerCase());
3223
+ return list.find(w => w.name === spec);
3224
+ };
3225
+ if (!sub) {
3226
+ // 表格 (leo 2026-09-30: 「钱包格式也统一一个表格」) —— 与 /sessions 同一套 idiom:
3227
+ // 列宽按终端宽动态分配, 中文按**显示宽**对齐 (PAD/dispWidth), 宽不够就让 Name 先窄。
3228
+ // 地址优先给满 42 字符 (能直接复制); 列宽不足时退回缩写 (0x1234…abcd), 不截半截地址。
3229
+ appendLine(`${C_ACCENT}钱包 (${list.length}):${RESET}`);
3230
+ if (!list.length) {
3231
+ appendLine('');
3232
+ appendLine(` ${C_DIM}无 — 用 ${RESET}${C_ACCENT}/wallet new [名字]${RESET}${C_DIM} 生成一个 EVM 钱包${RESET}`);
3233
+ }
3234
+ else {
3235
+ // 表格渲染抽到 src/cli/wallet-table.ts (纯函数 ⇒ 可确定性验证对齐, 不用靠 PTY 抓包)
3236
+ const { renderWalletTable } = await import('./cli/wallet-table.js');
3237
+ const rows = renderWalletTable(list, { width: termWidth(), shortAddr });
3238
+ appendLine('');
3239
+ rows.forEach((r, k) => {
3240
+ appendLine(k < 2 ? ` ${C_DIM}${r}${RESET}` : ` ${C_TEXT}${r}${RESET}`);
3241
+ });
3242
+ }
3243
+ appendLine('');
3244
+ appendLine(` ${C_DIM}台账目录: ${walletsDir()}/ (一钱包一文件 · 0600 · 私钥不回显)${RESET}`);
3245
+ appendLine(` ${C_DIM}用法: ${RESET}${C_ACCENT}/wallet new [名字]${RESET}${C_DIM} · ${RESET}${C_ACCENT}/wallet import <文件路径> [名字]${RESET}${C_DIM} · ${RESET}${C_ACCENT}/wallet show <#|地址>${RESET}${C_DIM} · ${RESET}${C_ACCENT}/wallet bal <#|地址>${RESET}`);
3246
+ return;
2680
3247
  }
2681
- catch { /* 无 */ }
2682
- appendLine(`${C_ACCENT}钱包 (${Array.isArray(wallets) ? wallets.length : 0}):${RESET}`);
2683
- if (!Array.isArray(wallets) || wallets.length === 0) {
2684
- appendLine(` ${C_DIM}无 — 可用 wallet_create 工具创建 EVM 钱包${RESET}`);
3248
+ if (sub === 'new') {
3249
+ const name = arg[1] || `wallet-${new Date().toISOString().slice(0, 10)}`;
3250
+ const mod = await loadWalletTool('createWallet');
3251
+ if (!mod?.createWallet) {
3252
+ appendLine(`${C_ERROR}/wallet new 失败: 找不到 createWallet 实现${RESET}`);
3253
+ return;
3254
+ }
3255
+ const r = await mod.createWallet();
3256
+ const { rec, replaced } = await addWallet({
3257
+ name, address: r.address, network: '—', source: 'created',
3258
+ createdAt: r.createdAt || new Date().toISOString(), privateKey: r.privateKey, mnemonic: r.mnemonic,
3259
+ });
3260
+ appendLine(`${C_OK}✓ 钱包${replaced ? '已在台账 (补齐信息)' : '已创建'}${RESET} ${C_ACCENT}${rec.name}${RESET}`);
3261
+ appendLine(` ${C_DIM}地址:${RESET} ${rec.address}`);
3262
+ appendLine(` ${C_DIM}私钥/助记词: 已写入 ${walletsDir()}/${rec.name}.json (0600) — 不回显, 需要时自己打开看${RESET}`);
3263
+ return;
3264
+ }
3265
+ if (sub === 'import') {
3266
+ const src = arg[1];
3267
+ if (!src) {
3268
+ appendLine(`${C_WARN}用法: /wallet import <文件路径> [名字] — 路径必须你显式给出 (不扫别的 agent 目录)${RESET}`);
3269
+ return;
3270
+ }
3271
+ let raw;
3272
+ try {
3273
+ raw = JSON.parse(await (await import('fs/promises')).readFile(src.replace(/^~/, process.env.HOME || ''), 'utf-8'));
3274
+ }
3275
+ catch (err) {
3276
+ appendLine(`${C_ERROR}读不了 ${src}: ${String(err?.message || err).slice(0, 100)}${RESET}`);
3277
+ return;
3278
+ }
3279
+ const pk = raw?.privateKey || raw?.private_key;
3280
+ const mn = raw?.mnemonic || raw?.phrase;
3281
+ if (!pk && !mn) {
3282
+ appendLine(`${C_ERROR}文件里没有 privateKey / mnemonic — 不收${RESET}`);
3283
+ return;
3284
+ }
3285
+ let addr = raw?.address;
3286
+ if (!addr && pk) {
3287
+ const mod = await loadWalletTool('importWallet');
3288
+ try {
3289
+ const r = await mod.importWallet({ privateKey: pk, mnemonic: mn });
3290
+ addr = r.address;
3291
+ }
3292
+ catch { /* 下面照实报 */ }
3293
+ }
3294
+ if (!addr) {
3295
+ appendLine(`${C_ERROR}拿不到地址 — 文件里没有 address 且无法从私钥推导${RESET}`);
3296
+ return;
3297
+ }
3298
+ const { rec, replaced } = await addWallet({
3299
+ name: arg[2] || String(src).split('/').pop().replace(/\.json$/, ''), address: addr,
3300
+ network: raw?.network || '—', source: 'imported', createdAt: new Date().toISOString(), privateKey: pk, mnemonic: mn,
3301
+ });
3302
+ appendLine(`${C_OK}✓ 已收编${RESET} ${C_ACCENT}${rec.name}${RESET}${replaced ? ` ${C_DIM}(同地址已在台账, 已合并)${RESET}` : ''}`);
3303
+ appendLine(` ${C_DIM}地址:${RESET} ${rec.address} ${C_DIM}(私钥只进 0600 台账, 不回显)${RESET}`);
3304
+ return;
2685
3305
  }
2686
- for (const w of (Array.isArray(wallets) ? wallets : []).slice(0, 5)) {
2687
- appendLine(` ${C_DIM}·${RESET} ${w.name || w.address?.slice(0, 12) || '?'} ${C_DIM}${String(w.address || '').slice(0, 16)}...${RESET}`);
3306
+ if (sub === 'show') {
3307
+ const w = pick(arg[1] || '1');
3308
+ if (!w) {
3309
+ appendLine(`${C_WARN}没有这个钱包 — /wallet 看列表${RESET}`);
3310
+ return;
3311
+ }
3312
+ appendLine(`${C_ACCENT}${w.name}${RESET}`);
3313
+ appendLine(` ${C_DIM}地址:${RESET} ${w.address}`);
3314
+ appendLine(` ${C_DIM}网络:${RESET} ${w.network || '—'} ${C_DIM}· 来源:${RESET} ${w.source === 'imported' ? '导入' : '生成'} ${C_DIM}· 建立:${RESET} ${String(w.createdAt).slice(0, 19).replace('T', ' ')}`);
3315
+ appendLine(` ${C_DIM}密钥:${RESET} ${w.privateKey || w.mnemonic ? '已存 (只在本机 0600 文件里)' : '未存 (只有地址)'}`);
3316
+ return;
2688
3317
  }
3318
+ if (sub === 'bal' || sub === 'balance') {
3319
+ const w = pick(arg[1] || '1');
3320
+ if (!w) {
3321
+ appendLine(`${C_WARN}没有这个钱包 — /wallet 看列表${RESET}`);
3322
+ return;
3323
+ }
3324
+ const mod = await loadWalletTool('getBalance');
3325
+ if (!mod?.getBalance) {
3326
+ appendLine(`${C_ERROR}找不到 getBalance 实现${RESET}`);
3327
+ return;
3328
+ }
3329
+ appendLine(`${C_DIM}查询中… ${shortAddr(w.address)}${RESET}`);
3330
+ try {
3331
+ const r = await mod.getBalance({ address: w.address, rpcUrl: arg[2] });
3332
+ appendLine(`${C_OK}💰${RESET} ${w.name} ${C_DIM}${r.address || w.address}${RESET} ${C_ACCENT}${r.balanceEth} ${r.symbol || 'ETH'}${RESET} ${C_DIM}(${r.balance} wei)${RESET}`);
3333
+ }
3334
+ catch (err) {
3335
+ appendLine(`${C_ERROR}查余额失败: ${String(err?.message || err).slice(0, 140)}${RESET}`);
3336
+ }
3337
+ return;
3338
+ }
3339
+ appendLine(`${C_WARN}未知子命令 ${sub} — 用法: /wallet [new|import|show|bal]${RESET}`);
3340
+ }
3341
+ catch (e) {
3342
+ appendLine(`${C_ERROR}/wallet 失败: ${String(e?.message || e).slice(0, 200)}${RESET}`);
2689
3343
  }
2690
- catch { /* 静默 */ }
2691
3344
  return;
2692
3345
  }
2693
3346
  // /email — 邮件配置管理; /email <host:port:user:from> 设置 / /email clear 清除 (2026-08-08)
@@ -2980,6 +3633,13 @@ async function processInputInner(input, comm) {
2980
3633
  return;
2981
3634
  }
2982
3635
  if (trimmed === '退出' || trimmed === 'exit' || trimmed === 'quit') {
3636
+ // 同一条退出纪律: 先存档, 再告别 (幂等 —— 与 Ctrl+C 路径共用)
3637
+ try {
3638
+ const note = await archiveSessionOnExit(cliSessionKey);
3639
+ if (note)
3640
+ appendLine(note);
3641
+ }
3642
+ catch { /* 非致命 */ }
2983
3643
  appendLine(`\n${CYAN}👋 再见!${RESET}`);
2984
3644
  isRunning = false;
2985
3645
  return;
@@ -3047,6 +3707,9 @@ async function processInputInner(input, comm) {
3047
3707
  const a = await getAgent();
3048
3708
  const boxW = Math.min(termWidth() - 2, 76);
3049
3709
  // 工具调用显示由 tui-shell 的 onStream handler 处理
3710
+ cliTurnStartedAt = Date.now();
3711
+ cliTurnReplyBytes = 0;
3712
+ cliTurnToolCount = 0;
3050
3713
  const response = await a.prompt(trimmed, {
3051
3714
  onStream: (e) => {
3052
3715
  // 2026-08-07: 中间思考/状态显示 — 之前只显示工具步骤, LLM 的思考过程
@@ -3095,6 +3758,7 @@ async function processInputInner(input, comm) {
3095
3758
  tuiToolCounter++;
3096
3759
  const toolName = e.tool || '?';
3097
3760
  tuiToolCalls.push({ tool: toolName, args: e.args, _t: Date.now() });
3761
+ cliTurnToolCount = tuiToolCalls.filter(c => c.tool !== 'system' && c.tool !== 'loop' && c.tool !== '?').length; // ⚙ 启动即算
3098
3762
  // 2026-08-12 (TaskA): 工具命中要干净 — step_start 不 appendLine 到消息流 (避免重复),
3099
3763
  // 改用 transient 行显示"正在执行"(消息流只在 done 时出现一次完成行).
3100
3764
  if (toolName !== 'system' && toolName !== 'loop' && toolName !== '?') {
@@ -3127,6 +3791,7 @@ async function processInputInner(input, comm) {
3127
3791
  }
3128
3792
  // 更新 transient: 还有进行中的工具 → 显示下一个; 否则清空 (交回 thinking 动画)
3129
3793
  const remaining = tuiToolCalls.filter(c => c.tool !== 'system' && c.tool !== 'loop' && c.tool !== '?');
3794
+ cliTurnToolCount = remaining.length; // ⚙ 状态栏用 (模块级)
3130
3795
  if (remaining.length > 0) {
3131
3796
  const label = remaining.length > 1 ? `执行 ${remaining.length} 个工具: ${remaining.map(c => c.tool).join(', ')}` : `🔧 ${remaining[0].tool}`;
3132
3797
  inkSetTransient(`${C_DIM}${label} 运行中...${RESET}`);
@@ -3139,6 +3804,59 @@ async function processInputInner(input, comm) {
3139
3804
  });
3140
3805
  // 智能体回复框
3141
3806
  appendLine(renderAgentMessage(response));
3807
+ try {
3808
+ cliLastTurnMs = cliTurnStartedAt ? Date.now() - cliTurnStartedAt : 0;
3809
+ cliTurnReplyBytes = String(response ?? '').length;
3810
+ cliTurnStartedAt = 0;
3811
+ cliTurnToolCount = 0;
3812
+ }
3813
+ catch { /* 结算失败不影响回复 */ }
3814
+ // 2026-09-30 (leo: 「session 的 Preview 在对话框工具栏右侧做显示」):
3815
+ // 回合结束后**后台**给这条会话生成一次标题/摘要 (600ms 级, 失败不打扰), 生成后工具栏右侧就有内容;
3816
+ // 顺带把 metadata 写盘 ⇒ 这段会话在 /sessions 里也立刻有 Title/Preview。
3817
+ if (cliSessionKey && !cliSessionPreview && !cliSessionSummarizing) {
3818
+ cliSessionSummarizing = true;
3819
+ const keyForNote = cliSessionKey;
3820
+ setImmediate(async () => {
3821
+ try {
3822
+ const { summarizeSession } = await import('./cli/session-summary.js');
3823
+ const store = new SessionStore();
3824
+ // 先存档! 2026-09-30 诊断日志挖出的根因: 回合结束时**会话还没落盘**
3825
+ // (CLI 只在退出时存档) ⇒ 总结器看到的文件里没有用户那句话 ⇒ source=none ⇒ 永远没标题
3826
+ // (屏幕上因此看不到 ▸; 退出后才出现, 因为那次存档把消息写进去了)。
3827
+ try {
3828
+ await agent?.saveCurrentSession?.(keyForNote);
3829
+ }
3830
+ catch { /* 存不下也继续试总结 */ }
3831
+ const d = await summarizeSession(keyForNote, { store });
3832
+ // 2026-09-30 (leo: 「Title 放在右侧吧,不要摘要了」): 右侧显示的是**标题** (≤20 字, 比摘要短得多);
3833
+ // 优先用调用的返回值, 取不到再回读文件 (标题可能由退出存档/别的进程写进去)。
3834
+ let note = String(d?.title && d.title !== keyForNote ? d.title : '').trim();
3835
+ if (!note) {
3836
+ try {
3837
+ const fs = await import('fs/promises');
3838
+ const raw = JSON.parse(await fs.readFile(store.pathFor(keyForNote), 'utf-8'));
3839
+ const md = (Array.isArray(raw) ? {} : raw?.metadata) || {};
3840
+ note = String(md.title || '').trim();
3841
+ }
3842
+ catch { /* 回读失败就用返回值 */ }
3843
+ }
3844
+ if (note && note !== keyForNote) {
3845
+ cliSessionPreview = note.slice(0, 24);
3846
+ inkSetStatus(getStatus());
3847
+ console.error(`[session-note] 已更新: ${note.slice(0, 24)}`);
3848
+ }
3849
+ else
3850
+ console.error(`[session-note] 没拿到标题 (source=${d?.source}, key=${keyForNote})`);
3851
+ }
3852
+ catch (e) {
3853
+ console.error(`[session-note] 失败: ${String(e?.message || e).slice(0, 120)}`);
3854
+ }
3855
+ finally {
3856
+ cliSessionSummarizing = false;
3857
+ }
3858
+ });
3859
+ }
3142
3860
  // 停止思考动画
3143
3861
  inkSetThinking(false);
3144
3862
  // 2026-08-04: run-end 经验整理 — 连续成功工具 ≥2 自动写 skill 候选
@@ -3213,7 +3931,7 @@ async function processInputInner(input, comm) {
3213
3931
  maxTokens: usage.maxTokens,
3214
3932
  stage: usage.stage,
3215
3933
  };
3216
- const statusText = `${C_ACCENT}${cliModelName}${RESET}${C_DIM} │${RESET} ${cliAgentName} ${C_DIM}│${RESET} ⏱ ${fmtDuration(Date.now() - cliStartTime)}${C_DIM} │${RESET} ${buildContextBar(usageView)}`;
3934
+ const statusText = `${C_ACCENT}${statusModelName()}${RESET}${C_DIM} │${RESET} ${cliAgentName} ${C_DIM}│${RESET} ⏱ ${fmtDuration(Date.now() - cliStartTime)}${C_DIM} │${RESET} ${buildContextBar(usageView)}`;
3217
3935
  inkSetStatus(statusText);
3218
3936
  }
3219
3937
  catch { /* 降级容忍 */ }
@@ -3872,7 +4590,7 @@ goalFlag) {
3872
4590
  : 'ℹ 使用缓存结论';
3873
4591
  response = `${head}\n\n当前版本: ${r.currentVersion}\n最新版本: ${r.latestVersion || '未知'}\n安装方式: ${r.installMethod}\n结论: ${r.status}${r.reason ? `\n说明: ${r.reason}` : ''}`;
3874
4592
  if (r.status === 'update_available')
3875
- response += `\n\n运行 bolloon update plan 看计划, bolloon update now 执行更新`;
4593
+ response += `\n\n运行 bolloon update 直接更新 (先打印计划与风险检查; 只看计划用 --dry-run)`;
3876
4594
  if (r.status === 'offline' || r.status === 'registry_unavailable' || r.status === 'local_version_unknown')
3877
4595
  error = response;
3878
4596
  break;
@@ -4748,10 +5466,10 @@ async function main() {
4748
5466
  }
4749
5467
  // 启动时后台检查更新 (不阻塞主流程)。
4750
5468
  // 2026-09-19 行为变更: **默认只通知, 不自动安装** —— 发现新版本会打印一行提示 +
4751
- // "bolloon update plan / now"。要恢复自动安装需显式 config.json `autoInstall: true`
5469
+ // "bolloon update"。要恢复自动安装需显式 config.json `autoInstall: true`
4752
5470
  // (+ `autoRestart: true` 才自动重启)。
4753
5471
  // 关闭本次检查: --no-update / BOLLOON_SKIP_UPDATE=1; 临时开自动装: BOLLOON_AUTO_UPDATE=1
4754
- // 手动检查: bolloon update / bolloon update plan / bolloon doctor
5472
+ // 手动检查: bolloon update / bolloon update --dry-run / bolloon doctor
4755
5473
  if (!args.updateCheck && !args.updateNow) {
4756
5474
  void (async () => {
4757
5475
  try {
@@ -4960,12 +5678,19 @@ async function main() {
4960
5678
  void (async () => {
4961
5679
  let kuboReady = false;
4962
5680
  try {
4963
- const sdk = await import('@diap/sdk');
4964
- const checkKuboSetup = sdk.checkKuboSetup;
4965
- if (typeof checkKuboSetup === 'function') {
4966
- const setup = await checkKuboSetup(true, true);
4967
- kuboReady = !!(setup?.ready && setup?.daemonRunning);
4968
- bootNotice('info', kuboReady ? 'IPFS 本地 Kubo 就绪 → IPNS 发布/解析可用' : 'Kubo 不可用, IPFS 降级本地模式');
5681
+ // 2026-09-30: 已经在跑 → 直接算就绪, 不触发 SDK 那次注定失败的 `ipfs init`(它会在面板前打一行 warn)
5682
+ if (await kuboAlreadyRunning()) {
5683
+ kuboReady = true;
5684
+ bootNotice('info', 'IPFS 本地 Kubo 已在运行 → IPNS 发布/解析可用');
5685
+ }
5686
+ else {
5687
+ const sdk = await import('@diap/sdk');
5688
+ const checkKuboSetup = sdk.checkKuboSetup;
5689
+ if (typeof checkKuboSetup === 'function') {
5690
+ const setup = await checkKuboSetup(true, true);
5691
+ kuboReady = !!(setup?.ready && setup?.daemonRunning);
5692
+ bootNotice('info', kuboReady ? 'IPFS 本地 Kubo 就绪 → IPNS 发布/解析可用' : 'Kubo 不可用, IPFS 降级本地模式');
5693
+ }
4969
5694
  }
4970
5695
  }
4971
5696
  catch (e) {
@@ -5043,7 +5768,7 @@ async function main() {
5043
5768
  bootNotice('info', `Bootstrap 完成 (${bs.durationMs}ms, ${bs.errors.length} 个非致命错误)`);
5044
5769
  }
5045
5770
  catch (err) {
5046
- bootNotice('warn', `Bootstrap 失败 (非致命, 主流程继续): ${err.message}`);
5771
+ bootNotice('warn', `上下文扫描未完成 (可选步骤, 已跳过, 主流程继续): ${err.message} —— 详情见 startup.log`);
5047
5772
  }
5048
5773
  })();
5049
5774
  }
@@ -5059,7 +5784,7 @@ async function main() {
5059
5784
  bootNotice('info', `Bootstrap 完成 (${bs.durationMs}ms, ${bs.errors.length} 个非致命错误)`);
5060
5785
  }
5061
5786
  catch (err) {
5062
- bootNotice('warn', `Bootstrap 失败 (非致命, 主流程继续): ${err.message}`);
5787
+ bootNotice('warn', `上下文扫描未完成 (可选步骤, 已跳过, 主流程继续): ${err.message} —— 详情见 startup.log`);
5063
5788
  }
5064
5789
  }
5065
5790
  if (mode === 'web') {