@bolloon/bolloon-agent 0.5.0 → 0.5.1

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 (106) hide show
  1. package/dist/agents/execution-supervisor.js +87 -5
  2. package/dist/agents/model-policy.js +651 -0
  3. package/dist/agents/pi-sdk-tools.js +84 -33
  4. package/dist/agents/pi-sdk.js +83 -23
  5. package/dist/agents/run-store.js +53 -0
  6. package/dist/agents/task/task-runner.js +8 -1
  7. package/dist/agents/workflow-pivot-loop.js +19 -12
  8. package/dist/cli/clipboard.js +65 -0
  9. package/dist/cli/ink-app.js +145 -17
  10. package/dist/cli/log-gate.js +386 -0
  11. package/dist/cli/mention-data.js +2 -0
  12. package/dist/cli/model-selector.js +538 -0
  13. package/dist/cli/reply-hygiene.js +54 -0
  14. package/dist/cli/setup-wizard.js +686 -69
  15. package/dist/cli/startup-notice.js +160 -0
  16. package/dist/cli/stores.js +22 -1
  17. package/dist/cli/theme.js +49 -0
  18. package/dist/cli/tui-select.js +970 -0
  19. package/dist/cli-entry.js +125 -18
  20. package/dist/electron-build/agents/execution-supervisor.js +86 -4
  21. package/dist/electron-build/agents/execution-supervisor.js.map +1 -1
  22. package/dist/electron-build/agents/goal-store.js.map +1 -1
  23. package/dist/electron-build/agents/model-policy.js +706 -0
  24. package/dist/electron-build/agents/model-policy.js.map +1 -0
  25. package/dist/electron-build/agents/pi-sdk-tools.js +84 -33
  26. package/dist/electron-build/agents/pi-sdk-tools.js.map +1 -1
  27. package/dist/electron-build/agents/pi-sdk-types.js.map +1 -1
  28. package/dist/electron-build/agents/pi-sdk.js +83 -23
  29. package/dist/electron-build/agents/pi-sdk.js.map +1 -1
  30. package/dist/electron-build/agents/run-store.js +55 -1
  31. package/dist/electron-build/agents/run-store.js.map +1 -1
  32. package/dist/electron-build/agents/task/task-runner.js +8 -1
  33. package/dist/electron-build/agents/task/task-runner.js.map +1 -1
  34. package/dist/electron-build/agents/workflow-pivot-loop.js +19 -12
  35. package/dist/electron-build/agents/workflow-pivot-loop.js.map +1 -1
  36. package/dist/electron-build/llm/config-store.js +146 -6
  37. package/dist/electron-build/llm/config-store.js.map +1 -1
  38. package/dist/electron-build/llm/connection-probe.js +764 -0
  39. package/dist/electron-build/llm/connection-probe.js.map +1 -0
  40. package/dist/electron-build/llm/custom-provider-store.js +272 -0
  41. package/dist/electron-build/llm/custom-provider-store.js.map +1 -0
  42. package/dist/electron-build/llm/data/provider-catalog-baked.js +23 -0
  43. package/dist/electron-build/llm/data/provider-catalog-baked.js.map +1 -0
  44. package/dist/electron-build/llm/model-catalog.js +778 -0
  45. package/dist/electron-build/llm/model-catalog.js.map +1 -0
  46. package/dist/electron-build/llm/model-discovery.js +1202 -0
  47. package/dist/electron-build/llm/model-discovery.js.map +1 -0
  48. package/dist/electron-build/llm/model-selection.js +1315 -0
  49. package/dist/electron-build/llm/model-selection.js.map +1 -0
  50. package/dist/electron-build/llm/pi-ai.js +70 -11
  51. package/dist/electron-build/llm/pi-ai.js.map +1 -1
  52. package/dist/electron-build/llm/provider-catalog.js +969 -0
  53. package/dist/electron-build/llm/provider-catalog.js.map +1 -0
  54. package/dist/electron-build/llm/provider-registry.js +950 -0
  55. package/dist/electron-build/llm/provider-registry.js.map +1 -0
  56. package/dist/electron-build/llm/tool-name.js +266 -0
  57. package/dist/electron-build/llm/tool-name.js.map +1 -0
  58. package/dist/electron-build/utils/dual-source-facts.js +236 -0
  59. package/dist/electron-build/utils/dual-source-facts.js.map +1 -0
  60. package/dist/electron-build/utils/dual-source.js +32 -156
  61. package/dist/electron-build/utils/dual-source.js.map +1 -1
  62. package/dist/electron-build/utils/update-manager.js +22 -72
  63. package/dist/electron-build/utils/update-manager.js.map +1 -1
  64. package/dist/electron-build/utils/update-state.js +2 -19
  65. package/dist/electron-build/utils/update-state.js.map +1 -1
  66. package/dist/electron-build/utils/version-identity.js +193 -0
  67. package/dist/electron-build/utils/version-identity.js.map +1 -0
  68. package/dist/electron-build/utils/version-info.js +47 -101
  69. package/dist/electron-build/utils/version-info.js.map +1 -1
  70. package/dist/index.js +282 -96
  71. package/dist/ios/bolloon-web.json +10 -0
  72. package/dist/ios/mobile-contacts.js +44 -11
  73. package/dist/ios/mobile-core.js +1723 -84
  74. package/dist/ios/mobile-tasks.js +339 -0
  75. package/dist/ios/mobile-update.js +1442 -0
  76. package/dist/ios/mobile.js +308 -35
  77. package/dist/ios/routes-llm-config.js +344 -52
  78. package/dist/ios/routes-mobile-tasks.js +353 -0
  79. package/dist/ios/server.js +163 -4
  80. package/dist/ios/sw.js +1 -1
  81. package/dist/llm/config-store.js +146 -6
  82. package/dist/llm/connection-probe.js +744 -0
  83. package/dist/llm/custom-provider-store.js +224 -0
  84. package/dist/llm/data/provider-catalog-baked.js +19 -0
  85. package/dist/llm/model-catalog.js +717 -0
  86. package/dist/llm/model-discovery.js +1137 -0
  87. package/dist/llm/model-selection.js +1241 -0
  88. package/dist/llm/pi-ai.js +70 -11
  89. package/dist/llm/provider-catalog.js +898 -0
  90. package/dist/llm/provider-registry.js +917 -0
  91. package/dist/llm/tool-name.js +220 -0
  92. package/dist/setup/onboard.js +34 -8
  93. package/dist/utils/dual-source-facts.js +223 -0
  94. package/dist/utils/dual-source.js +12 -141
  95. package/dist/utils/update-manager.js +16 -59
  96. package/dist/utils/update-state.js +6 -18
  97. package/dist/utils/version-identity.js +176 -0
  98. package/dist/utils/version-info.js +9 -78
  99. package/dist/web/bolloon-web.json +10 -0
  100. package/dist/web/mobile-core.js +1721 -84
  101. package/dist/web/mobile-update.js +1442 -0
  102. package/dist/web/mobile.js +308 -35
  103. package/dist/web/routes-llm-config.js +344 -52
  104. package/dist/web/sw.js +1 -1
  105. package/package.json +1 -1
  106. package/scripts/build-web.ts +36 -0
package/dist/index.js CHANGED
@@ -16,7 +16,14 @@ 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
18
  import { printBanner, renderDialog, renderUserMessage, renderAgentMessage, renderMessageBox, renderToolCallListItem, termWidth, ROBOT_HEAD, BOLLOON_BANNER, dispWidth } from './cli/loading-tui.js';
19
- import { startInk, stopInk, inkAppendLine as appendLine, inkReplaceMatchingLine, inkSetStatus, inkSetThinking, inkSetTransient } from './cli/ink-app.js';
19
+ import { startInk, stopInk, suspendInk, resumeInk, inkAppendLine as appendLine, inkReplaceMatchingLine, inkSetStatus, inkSetThinking, inkSetTransient } from './cli/ink-app.js';
20
+ // 2026-09-26: 启动期日志闸门 (默认静默加载日志 + 写文件 + verbose 全量回流 + 信号行不吞)
21
+ import { installStartupLogGate, isStartupVerbose, startupLogPath, VERBOSE_ENV, carriesHumanSignal, logStartupLine } from './cli/log-gate.js';
22
+ // 2026-09-27: 回复流卫生 — 内部运行日志 (循环推进/运行登记/收尾计数…) 不进对话回复流, 改落日志文件
23
+ import { isInternalRunLog, appendInternalRunLog } from './cli/reply-hygiene.js';
24
+ // 2026-09-27: 启动**前言 / 就绪度报告**闸门 (默认不上屏 → 面板一行 + 显式查询命令; 失败折成面板提示)
25
+ import { noticeLine as noticePreamble, flushStartupNotices, setStartupReadiness, alertsFromSetup, readinessLine, preambleHintLine, startupPreambleVisible, pushStartupAlert, takeStartupPanelNotes, clearBufferedNotices, } from './cli/startup-notice.js';
26
+ import { clipboardWrite } from './cli/clipboard.js';
20
27
  // 启动自动检查更新:后台、节流、检测到新版本自动安装(可被 --no-update / BOLLOON_SKIP_UPDATE 关闭)
21
28
  import { createRequire } from 'module';
22
29
  const _require = createRequire(import.meta.url);
@@ -124,6 +131,53 @@ const s = {
124
131
  // @ts-ignore - noble/ed25519 v3 requires sha512 to be set
125
132
  ed25519.hashes.sha512 = sha512;
126
133
  // ---------------------------------------------------------------------------
134
+ // 启动期(面板之前)输出 —— 「面板优先」与「失败不许吞」两条口径的收口 (2026-09-27)
135
+ // ---------------------------------------------------------------------------
136
+ /**
137
+ * true = 这一程走「面板优先」口径 (交互 CLI 且没开 verbose/前言), 由 `main()` 的交互分支置位。
138
+ * 为什么需要它: 下面那些 `s.info` / `s.warn` (**面板之前**打的那几条进度/降级) 不能只看
139
+ * "有没有 ⚠" 决定上不上屏 —— 默认口径要求**面板第一帧之前一行都没有**, 而 leo 的铁律
140
+ * 又要求错误/降级**必须看得见**。两条同时成立 => 不放 stderr, 折进面板。
141
+ */
142
+ let startupPanelFirst = false;
143
+ /** 面板第一帧已经画出来了 (startCLI 取走面板提示之后): 晚到的降级提示直接追加进面板 */
144
+ let startupPanelReady = false;
145
+ /**
146
+ * 启动期(面板之前)一行进度 / 降级输出 —— 交互默认口径下的**唯一出路**。
147
+ *
148
+ * leo 口径 (2026-09-27, 两条必须同时成立):
149
+ * ① 「打完 `bolloon --cli` 直接就是面板」⇒ 面板第一帧之前**一行都不许上屏** (进度/就绪度/初始化日志都算);
150
+ * ② 「错误 / 降级 / 需人介入不许吞」⇒ 它必须**在屏上真能看到**。
151
+ * 于是这一层把面板之前那些 `s.info` / `s.warn` 分成两种去处:
152
+ * · 带人信号的 (失败/降级/超时/不可用/⚠ …, 判据复用 `log-gate.carriesHumanSignal` —— 只有一处) →
153
+ * **折进面板告警通道**: 面板还没画就登记 (`pushStartupAlert`, 与 `/!\ ⚠ 未就绪 …` 同一出路),
154
+ * 面板已经画了就地 `appendLine` 追加一行 (晚到的失败也不许消失在内存里);
155
+ * · 不带信号的纯进度 → 不上屏, 只落 `~/.bolloon/logs/startup.log` (诊断不丢)。
156
+ *
157
+ * `--verbose` / `BOLLOON_STARTUP_PREAMBLE=1` / 非交互 / web: **逐字照旧** (`s.warn` / `s.info`
158
+ * 原样打屏, 非交互那几条就是正常输出 —— 那里没有面板接着显示, 折进面板等于吞)。
159
+ *
160
+ * @param fallback 老路径的渲染方式 (只有 publishDID 那条 `s.step(…, 'warn')` 与 `s.warn` 不同)
161
+ */
162
+ function bootNotice(kind, line, fallback) {
163
+ if (!startupPanelFirst || startupPreambleVisible()) {
164
+ if (fallback)
165
+ fallback();
166
+ else if (kind === 'warn')
167
+ s.warn(line);
168
+ else
169
+ s.info(line);
170
+ return;
171
+ }
172
+ logStartupLine(line); // 诊断不丢: 无论上不上屏
173
+ if (!carriesHumanSignal(line))
174
+ return; // 纯进度: 默认口径下不上屏
175
+ if (startupPanelReady)
176
+ appendLine(`${C_WARN}/!\\ ${line}${RESET}`);
177
+ else
178
+ pushStartupAlert(line);
179
+ }
180
+ // ---------------------------------------------------------------------------
127
181
  // Harness loop ─ poll-free event-driven (Hyperswarm DHT 自动推送)
128
182
  // ---------------------------------------------------------------------------
129
183
  /** 原始 Hyperswarm stream 缓存; HyperswarmCommunicator.sendToConnection 只打日志不写流 */
@@ -186,8 +240,14 @@ function publishDID(name, kp) {
186
240
  }
187
241
  catch (e) {
188
242
  // 一次失败直接放弃 — 本地模式运行就够了, 不重试
189
- appendLine(` ${YELLOW}⚠ IPFS 发布失败 (${e?.message?.slice(0, 80) || 'unknown'}), 本地模式运行${RESET}`);
190
- s.step(2, 5, '发布 DID → IPFS', 'warn');
243
+ const reason = e?.message?.slice(0, 80) || 'unknown';
244
+ // 失败详情照旧进面板 (appendLine → Ink): "为什么降级"这一条本来就是给面板看的
245
+ appendLine(` ${YELLOW}⚠ IPFS 发布失败 (${reason}), 本地模式运行${RESET}`);
246
+ // 2026-09-27 口径收口: 这一行原来是打在**面板之前**的进度行 (默认口径下不许上屏),
247
+ // 可它是"降级" ⇒ 按铁律不许吞。两条同时成立的做法 = 折进面板告警通道
248
+ // (`verify-cli-panel` 的 A1「面板前 0 行」+ A7「面板里能定位到这条降级原文」成对钉住)。
249
+ // verbose / 非交互: 逐字回到老样子 (`s.step(…, 'warn')`)。
250
+ bootNotice('warn', `⚠ 降级: [2/5] 发布 DID → IPFS 失败 (${reason}) · 本地模式继续`, () => s.step(2, 5, '发布 DID → IPFS', 'warn'));
191
251
  resolve({});
192
252
  }
193
253
  };
@@ -418,6 +478,8 @@ function rpcErr(code, msg) {
418
478
  // 2026-07-28: 改用 readline.createInterface + replReadline 循环
419
479
  let isRunning = false;
420
480
  let queueMode = false;
481
+ // 2026-09-26: 启动期日志闸门句柄 (main() 里装; 诊断/验收用 `gate.stats` 读被静默的行数)
482
+ let _startupLogGate = null;
421
483
  const pendingQueue = [];
422
484
  let cliStartTime = 0;
423
485
  let cliModelName = '…';
@@ -633,33 +695,11 @@ async function startCLI(commReady) {
633
695
  // 内部用法全空安全 (P2P 功能自动降级, 就绪后立即可用)
634
696
  let comm = null;
635
697
  commReady.then((c) => { comm = c; }).catch(() => { });
636
- // CLI 模式下静音所有 console.log/warn/info/debug
637
- // (Ink 用自己的 render 引擎, console 输出会污染终端)
638
- console.log = () => { };
639
- console.warn = () => { };
640
- console.info = () => { };
641
- console.debug = () => { };
642
- // 过滤 process.stdout/stderr.write — 丢弃启动期 SDK/后台日志
643
- // (ISO 时间戳前缀如 `2026-09-08T...Z [info]:` 或被 [info]/[warn]/[error] 标记的行)
644
- const _origStdout = process.stdout.write.bind(process.stdout);
645
- const _origStderr = process.stderr.write.bind(process.stderr);
646
- const isLogLine = (line) => {
647
- const t = line.trimStart();
648
- return t.startsWith('[') || /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(t) || /\[\s*(info|warn|error|debug|log)\s*\]/.test(t)
649
- // Kubo/ipfs 启动噪声: "Use 'ipfs init --help'..." / "ipfs daemon is running..." 提示行无时间戳, 一并丢弃
650
- || /ipfs init --help|ipfs daemon is running|please stop it to run this command/i.test(t);
651
- };
652
- const wrap = (orig) => (chunk, ...rest) => {
653
- const s = typeof chunk === 'string' ? chunk : String(chunk);
654
- if (!isLogLine(s))
655
- return orig(chunk, ...rest);
656
- const keep = s.split('\n').filter((l) => !isLogLine(l)).join('\n');
657
- if (keep)
658
- orig(keep, ...rest);
659
- return true;
660
- };
661
- process.stdout.write = wrap(_origStdout);
662
- process.stderr.write = wrap(_origStderr);
698
+ // 2026-09-26: 启动期日志静默**上移到 `src/cli/log-gate.ts`** (闸门在 main() 里装, 覆盖 CLI 交互 +
699
+ // dashboard/web + plain 三个启动面)。这里不再自己 wrap stdout/stderr / 清空 console ——
700
+ // 旧写法只丢「行首 `[`」, 既漏掉依赖库的 ISO 时间戳日志, 又会把 `[supervisor] 初始化未就绪`
701
+ // 这种**需要人介入**的行一起吞掉 (与「错误不许被吞」冲突)。闸门按行分类, 信号行改道 stderr。
702
+ void _startupLogGate;
663
703
  let peerCount = 0;
664
704
  void commReady.then((c) => { try {
665
705
  if (c)
@@ -855,6 +895,24 @@ async function startCLI(commReady) {
855
895
  '⟳ 正在加载技能 / 工具...',
856
896
  ]);
857
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;
858
916
  void bootPanel({ dir: bootDirShort, model: (cliModelName && cliModelName !== '…') ? cliModelName : undefined, session: bootSessionId })
859
917
  .then((box) => { if (box)
860
918
  inkReplaceMatchingLine(bootBox, box); }).catch(() => { });
@@ -1345,6 +1403,10 @@ async function processInputInner(input, comm) {
1345
1403
  }
1346
1404
  const r = await agent.resumeRun(runId);
1347
1405
  appendLine(r.ok ? `${C_ACCENT}✅ 恢复执行完成${RESET}` : `${C_ERROR}恢复失败: ${r.reason}${RESET}`);
1406
+ if (r.modelDrift?.drifted)
1407
+ appendLine(`${C_ERROR}⚠ 模型配置已漂离快照: ${r.modelDrift.message}${RESET}`);
1408
+ if (r.modelApplied)
1409
+ appendLine(`${C_DIM} 本次恢复用的模型 (按 Run 快照装配): ${r.modelApplied.provider}/${r.modelApplied.model}${RESET}`);
1348
1410
  }
1349
1411
  catch (e) {
1350
1412
  appendLine(`${C_ERROR}/resume 失败: ${String(e?.message || e).slice(0, 200)}${RESET}`);
@@ -1408,6 +1470,8 @@ async function processInputInner(input, comm) {
1408
1470
  }
1409
1471
  const r = await agent.resumeRun(runId);
1410
1472
  appendLine(r.ok ? `${C_ACCENT}✅ 已批准并继续执行${RESET}` : `${C_ERROR}批准后恢复失败: ${r.reason}${RESET}`);
1473
+ if (r.modelApplied)
1474
+ appendLine(`${C_DIM} 本次恢复用的模型 (按 Run 快照装配): ${r.modelApplied.provider}/${r.modelApplied.model}${RESET}`);
1411
1475
  }
1412
1476
  catch (e) {
1413
1477
  appendLine(`${C_ERROR}/approve 失败: ${String(e?.message || e).slice(0, 200)}${RESET}`);
@@ -1696,46 +1760,99 @@ async function processInputInner(input, comm) {
1696
1760
  }
1697
1761
  return;
1698
1762
  }
1699
- // /model — 无参: 交互选择器 (ink 渲染, 复用 MentionPopup); 有参: 直接切换/测连通/看状态
1763
+ // /model — 无参: 分步选择器 (供应商 → 凭证 → 模型 → 参数 → 作用域 → 测试 → 确认);
1764
+ // 有参: 直接切换/测连通/看状态 (与 bolloon model 同一套语义)
1700
1765
  if (cmd === '/model' || cmd.startsWith('/model ')) {
1701
1766
  const modelArg = trimmed.slice('/model'.length).trim();
1702
- if (modelArg) {
1703
- try {
1704
- const { runModelCommand } = await import('./cli/setup-wizard.js');
1705
- // 会话内不提供隐藏输入 (避免 API key 留在会话回显/记录里) → 需要 key 时给出系统终端指引
1706
- const out = await runModelCommand(modelArg);
1707
- for (const line of String(out).split('\n'))
1708
- appendLine(`${C_DIM}${line}${RESET}`);
1709
- }
1710
- catch (e) {
1711
- appendLine(`${C_ERROR}/model 失败: ${String(e?.message || e).slice(0, 150)}${RESET}`);
1712
- }
1713
- return;
1767
+ if (!modelArg) {
1768
+ appendLine(`${C_DIM}模型选择 (分步): 供应商 → 凭证 → 模型 → 生成参数 → 作用域 → 测试连接 → 确认切换${RESET}`);
1714
1769
  }
1715
1770
  try {
1716
- const { llmConfigStore, PROVIDER_INFO } = await import('./llm/config-store.js');
1717
- await llmConfigStore.initialize();
1718
- const config = await llmConfigStore.getConfig();
1719
- const items = Object.entries(config.providers).map(([name, p]) => ({
1720
- kind: 'command',
1721
- label: name,
1722
- hint: `${String(PROVIDER_INFO[name]?.name || '').padEnd(14)} ${p.apiKey ? '🔑' : p.requiresApiKey ? '⚠ 无key' : ''} ${p.model || ''}`,
1723
- insert: name,
1724
- }));
1725
- globalThis.__inkOpenPicker?.(items, '选择模型供应商 (↑↓ 选择 · Enter 确认 · Esc 取消)', async (it) => {
1771
+ const { runModelCommand, modelTtyIO } = await import('./cli/setup-wizard.js');
1772
+ /**
1773
+ * 把渲染层的选择器借给分步选择器用: 每次要一个选择时开窗, 拿到结果 (或用户 Esc) 就往下走。
1774
+ * Esc 通过取消回调报回 null —— 没有这条通路时"按 Esc"会让调用方一直等。
1775
+ *
1776
+ * ⚠️ 这条 (`inkChoose`, Ink 画的小弹窗) 现在只是**非终端**的兜底 (管道/脚本)。
1777
+ * 真终端走下面那条: 与 `bolloon model` **同一个** `tui-select` 全屏选择器
1778
+ * (固定高度视窗 + 跟随 + 折叠 + 搜索 + 光标高亮 + "还有 M 家")。
1779
+ */
1780
+ const inkChoose = (items, title) => new Promise((resolve) => {
1781
+ const g = globalThis;
1782
+ if (typeof g.__inkOpenPicker !== 'function') {
1783
+ resolve(null);
1784
+ return;
1785
+ }
1786
+ g.__inkOpenPicker(items.map((c) => ({ kind: 'command', label: c.label, hint: c.hint, insert: c.value })), title, (it) => resolve(it?.insert ?? it?.label ?? null), () => resolve(null));
1787
+ });
1788
+ // 真终端: 让出终端给**同一个**全屏选择器组件 (两个渲染器不能同时抢一个 tty), 用完挂回来。
1789
+ const sharedPicker = await import('./cli/tui-select.js').then((m) => m.tuiCapable()).catch(() => false);
1790
+ let out;
1791
+ if (sharedPicker) {
1792
+ suspendInk();
1726
1793
  try {
1727
- await llmConfigStore.setActiveProvider(it.label);
1728
- const active = await llmConfigStore.getActiveProvider();
1729
- appendLine(`${C_OK}✓ 已切换到 ${it.label} (${String(PROVIDER_INFO[it.label]?.name || '')})${RESET}`);
1730
- appendLine(`${C_DIM} 当前模型: ${config.providers[it.label]?.model || '默认'}${RESET}`);
1794
+ const tio = modelTtyIO();
1795
+ out = await runModelCommand(modelArg, {
1796
+ ...tio,
1797
+ // 会话内**不提供**隐藏输入: API key 不许进会话回显/记录 (需要 key 时选择器给系统终端指引)
1798
+ askHidden: undefined,
1799
+ // `tio.live = true`: 每一步当场打在终端上 (选择器自己就是整屏渲染, 不需要再回放整段)
1800
+ });
1731
1801
  }
1732
- catch (e) {
1733
- appendLine(`${C_ERROR}✗ 切换失败: ${String(e.message || e).slice(0, 150)}${RESET}`);
1802
+ finally {
1803
+ resumeInk();
1734
1804
  }
1735
- });
1805
+ }
1806
+ else {
1807
+ out = await runModelCommand(modelArg, { choose: inkChoose });
1808
+ }
1809
+ for (const line of String(out).split('\n'))
1810
+ if (line.trim())
1811
+ appendLine(`${C_DIM}${line}${RESET}`);
1812
+ }
1813
+ catch (e) {
1814
+ appendLine(`${C_ERROR}/model 失败: ${String(e?.message || e).slice(0, 150)}${RESET}`);
1815
+ }
1816
+ return;
1817
+ }
1818
+ // /copy — 把**最近一条回复** (或整段会话) 真写进系统剪贴板 (2026-09-27)
1819
+ // 为什么要有: 面板是 Ink 画的整屏自绘帧, 终端里用鼠标框选会被"重画一帧"打断;
1820
+ // 与其让用户撞运气, 不如给一条**不靠鼠标**的显式通路 (`/copy` / `/copy all` / `/copy 3`)。
1821
+ if (cmd === '/copy' || cmd.startsWith('/copy ')) {
1822
+ const arg = trimmed.slice('/copy'.length).trim().toLowerCase();
1823
+ try {
1824
+ const store = await import('./cli/stores.js');
1825
+ const msgs = store.transcriptStore.get().filter((m) => String(m ?? '').trim());
1826
+ let text = '';
1827
+ let what = '';
1828
+ if (arg === 'all') {
1829
+ text = store.allMsgsText();
1830
+ what = `整段会话 (${msgs.length} 条)`;
1831
+ }
1832
+ else if (/^\d+$/.test(arg)) {
1833
+ const n = Math.max(1, Math.min(Number(arg), msgs.length));
1834
+ text = msgs.slice(msgs.length - n).join('\n');
1835
+ what = `最近 ${n} 条`;
1836
+ }
1837
+ else {
1838
+ text = store.lastMsg();
1839
+ what = '最近一条';
1840
+ }
1841
+ if (!String(text).trim()) {
1842
+ appendLine(`${C_DIM}面板里还没有可复制的内容${RESET}`);
1843
+ return;
1844
+ }
1845
+ const r = clipboardWrite(text);
1846
+ if (r.ok) {
1847
+ appendLine(`${C_ACCENT}✅ 已复制${what} → 系统剪贴板 (${r.tool} · ${r.chars} 字符)${RESET}`);
1848
+ }
1849
+ else {
1850
+ appendLine(`${C_ERROR}✗ 复制失败 (${what}): ${r.error}${RESET}`);
1851
+ appendLine(`${C_DIM} 兜底: 终端里用鼠标框选不会被自动重画打断 (上滚会自动暂停跟随)${RESET}`);
1852
+ }
1736
1853
  }
1737
1854
  catch (e) {
1738
- appendLine(`${C_ERROR}/model 失败: ${String(e.message || e).slice(0, 150)}${RESET}`);
1855
+ appendLine(`${C_ERROR}/copy 失败: ${String(e?.message || e).slice(0, 150)}${RESET}`);
1739
1856
  }
1740
1857
  return;
1741
1858
  }
@@ -2946,8 +3063,21 @@ async function processInputInner(input, comm) {
2946
3063
  }
2947
3064
  else if (e.type === 'status' && e.content) {
2948
3065
  const content = String(e.content);
3066
+ // 2026-09-27 (回复流卫生): **内部运行日志不进对话回复流** ——
3067
+ // CLI 交互面没有独立的状态区, 这条 onStream 就是"回复流", 于是
3068
+ // `🔄 开始 ReAct 循环...` / `🧷 运行已登记 (...)` / `✅ 处理完成,共 N 次循环` /
3069
+ // `🎯 目标仍在进行 (未判完成): …` 这类**给开发者看的运行过程**会和用户的结论
3070
+ // 混成一列消息 (leo 报的正是这个)。判据不按子串猜, 只认发送点的显式声明
3071
+ // (`StreamEvent.internal`, 见 pi-sdk-types) —— 旧的子串过滤继续保留在下面兜底。
3072
+ // 搬走不是删掉: 同一行原文照落 logs/startup.log (目的地走已有的 startupLogPath()),
3073
+ // `--verbose`/`BOLLOON_VERBOSE=1` 时原样上屏, 诊断能力一点不丢。
3074
+ if (isInternalRunLog(e)) {
3075
+ appendInternalRunLog(content, e.tool);
3076
+ if (isStartupVerbose())
3077
+ appendLine(`${C_DIM}${content}${RESET}`);
3078
+ }
2949
3079
  // Reflection / 反思 / 💡 → 圆角思考框 (和回复一样走 renderMessageBox, 白字+亮边框)
2950
- if (content.includes('Reflection') || content.includes('反思') || content.includes('💡')) {
3080
+ else if (content.includes('Reflection') || content.includes('反思') || content.includes('💡')) {
2951
3081
  const body = content.replace(/^💡\s*/, '').slice(0, 1500);
2952
3082
  if (body.trim())
2953
3083
  appendLine(renderMessageBox({ title: '💡 反思', body, color: C_WARN }));
@@ -4593,6 +4723,29 @@ async function main() {
4593
4723
  printHelp();
4594
4724
  process.exit(0);
4595
4725
  }
4726
+ // 2026-09-26: 启动期日志闸门 —— 在**任何 bootstrap 之前**装上, 覆盖三个启动面:
4727
+ // `cli-interactive` (CLI 交互启动, stdout 归 Ink, 信号行改道 stderr) / `web` (dashboard) / 其它 `plain`。
4728
+ // 默认: 加载日志不上控制台 + 全部落日志文件; `--verbose` / `BOLLOON_VERBOSE=1`: 一行不改地全量回流;
4729
+ // 带「失败/未就绪/超时/⚠」等信号的行任何模式下都可见 (leo: 错误不许被吞)。
4730
+ // 不装闸门的三类: `--json` (stdout 是机器读的数据本体)、一次性工具调用 (`--prompt`/`--tool`/`--read` …)、
4731
+ // 命令式诊断 (`--setup-*` / `--supervise*`, 它们的输出就是交付物)。
4732
+ const isOneShotTool = !!(args.tool || args.prompt);
4733
+ const isCommandLike = !!(args.supervise || args.setupStatus || args.setupResume || args.setupRepair
4734
+ || args.setupReconfigure || args.setupTest);
4735
+ if (!args.json && !isOneShotTool && !isCommandLike) {
4736
+ _startupLogGate = installStartupLogGate({
4737
+ mode: args.web ? 'web' : 'cli-interactive',
4738
+ args: process.argv.slice(2),
4739
+ });
4740
+ // 闸门自己的失败必须可见 (否则「日志写不进去」会成为新的静默缺陷)
4741
+ if (_startupLogGate.stats.fileError) {
4742
+ process.stderr.write(`${_startupLogGate.stats.fileError}\n`);
4743
+ }
4744
+ // 诊断模式提示: 告诉用户「现在全量输出」+ 静默时的日志落哪 (默认模式下这一行不出现)
4745
+ if (isStartupVerbose(process.argv.slice(2))) {
4746
+ process.stderr.write(`[log-gate] 诊断模式 (--verbose / ${VERBOSE_ENV}=1): 启动日志全量输出, 同时落盘 ${startupLogPath()}\n`);
4747
+ }
4748
+ }
4596
4749
  // 启动时后台检查更新 (不阻塞主流程)。
4597
4750
  // 2026-09-19 行为变更: **默认只通知, 不自动安装** —— 发现新版本会打印一行提示 +
4598
4751
  // "bolloon update plan / now"。要恢复自动安装需显式 config.json `autoInstall: true`
@@ -4684,54 +4837,75 @@ async function main() {
4684
4837
  };
4685
4838
  const isCLIInteractive = mode === 'cli' && !isNonInteractive;
4686
4839
  if (isCLIInteractive) {
4687
- console.log = () => { };
4688
- console.info = () => { };
4689
- // 2026-08-07: 只吞 SDK 结构化日志 (2026-...T 前缀直接写 stdout 的), 其余 (Ink ANSI 渲染) 走原始 write
4690
- // 不能整体 no-op — Ink 渲染依赖 write callback, 且 pty/管道缓冲满时 no-op 会掩盖真实写状态
4691
- process.stdout.write = ((chunk, ...rest) => {
4692
- const s = String(chunk);
4693
- if (/^\d{4}-\d{2}-\d{2}T/.test(s))
4694
- return true; // SDK 结构化日志 → 吞
4695
- return originalStdoutWrite(chunk, ...rest);
4696
- });
4840
+ // 2026-09-26: console.log/info 与 stdout 的静默已由 `src/cli/log-gate.ts` 统一负责
4841
+ // (mode='cli-interactive': 加载日志丢弃并写文件, 信号行改道 stderr, Ink 的 ANSI 原样放行)。
4842
+ // 这里只留「交互模式不打扰用户」的那一件与日志无关的事: 静音 auto-update 通知。
4697
4843
  // 2026-08-07: 交互模式静音 auto-update 后台通知 (stderr), 避免 "🔍 检查更新" 污染 TUI
4698
4844
  void import('./utils/auto-update.js').then(({ setNotifyQuiet }) => setNotifyQuiet(true)).catch(() => { });
4699
4845
  }
4700
4846
  // 2026-09-13: 首次运行引导 — 没有可用模型供应商 / 还没有用户身份时, 先走初始化向导
4701
4847
  // (放在 CLI 启动前: 用户先回答"你是谁 / 用哪个模型", 再进 TUI 面板)
4702
4848
  if (isCLIInteractive) {
4849
+ // 面板优先口径 (2026-09-27): 这一程后面打的所有启动期进度/降级行都走 `bootNotice()`
4850
+ // —— 面板第一帧之前不上屏, 但"失败/降级/需人介入"折进面板告警 (两条口径同时成立)。
4851
+ // `--verbose` / `BOLLOON_STARTUP_PREAMBLE=1` 时这个开关是 false ⇒ 逐字照旧打屏。
4852
+ startupPanelFirst = !startupPreambleVisible();
4703
4853
  // 2026-09-16 (M4 启动硬门禁): 先把初始化事实读出来 —— 未就绪就**先修**, 修不好就非零退出。
4704
4854
  // 旧行为是"向导失败也只 warn, 照常进对话", 结果是半成品配置能进运行态 (看起来启动成功、实际不可执行)。
4855
+ //
4856
+ // 2026-09-27 (leo 口径): **打完 `bolloon --cli` 就该直接看到面板** ——
4857
+ // 下面这一整段 (门禁报告 / 向导续跑前言 / `✅ 初始化完成`) 属于"启动前的日志", 默认**一个字节都不上屏**:
4858
+ // · `describeSetup(ev)` 改走 `noticePreamble()` → 默认进缓冲 (照落启动日志), verbose/`BOLLOON_STARTUP_PREAMBLE=1` 时逐字回来;
4859
+ // · 就绪度折成**面板一行** (`就绪: basic ✓ · … · 详情: bolloon setup status`, 在 startCLI 里 appendLine);
4860
+ // · 失败 / 需人介入折成**面板告警行** (`alertsFromSetup`) —— 不许被吞, 但也不许刷屏。
4861
+ // 兜底: 只要这一程**走不到面板** (非零退出) 或**要人当场回答** (向导提问前), 缓冲先 flush 到 stderr。
4705
4862
  const w = (m) => process.stderr.write(m.endsWith('\n') ? m : m + '\n');
4706
4863
  let gateEv = null;
4707
4864
  try {
4708
4865
  const { refreshSetupState, describeSetup } = await import('./setup/setup-store.js');
4709
4866
  gateEv = await refreshSetupState({ light: true });
4867
+ // 面板一行就绪度 + 告警 (先登记, 面板渲染时取走)
4868
+ setStartupReadiness(readinessLine(gateEv.state?.readiness || {}));
4869
+ for (const a of alertsFromSetup(gateEv)) {
4870
+ pushStartupAlert(a);
4871
+ noticePreamble(a);
4872
+ }
4710
4873
  if (gateEv.gate !== 'ready') {
4711
- w(describeSetup(gateEv));
4874
+ noticePreamble(describeSetup(gateEv));
4712
4875
  if (process.env.BOLLOON_SKIP_SETUP === '1') {
4713
- w('⚠ BOLLOON_SKIP_SETUP=1 → 诊断模式: 可以看状态/修配置, 但 agent 执行被门禁拦住 (不能绕过)');
4876
+ noticePreamble('⚠ BOLLOON_SKIP_SETUP=1 → 诊断模式: 可以看状态/修配置, 但 agent 执行被门禁拦住 (不能绕过)');
4714
4877
  }
4715
4878
  else {
4716
4879
  const { runSetupWizard } = await import('./cli/setup-wizard.js');
4717
- await runSetupWizard({ interactive: true });
4880
+ // quietStartup: 向导的续跑前言默认不上屏 (它自己会在"要人回答/有失败"时先 flush)
4881
+ await runSetupWizard({ interactive: true, quietStartup: true });
4718
4882
  const after = await refreshSetupState({});
4883
+ setStartupReadiness(readinessLine(after.state?.readiness || {}));
4884
+ for (const a of alertsFromSetup(after)) {
4885
+ pushStartupAlert(a);
4886
+ noticePreamble(a);
4887
+ }
4719
4888
  if (after.gate !== 'ready') {
4720
- w(describeSetup(after));
4889
+ // 走不到面板 → 前言必须先落屏 (否则就是被吞)
4890
+ noticePreamble(describeSetup(after));
4891
+ flushStartupNotices();
4721
4892
  w('⛔ 初始化未完成 → 退出 (不会以"看起来能跑"的状态进入对话)');
4722
4893
  process.exit(1);
4723
4894
  }
4724
- w('✅ 初始化完成, 进入正常模式');
4895
+ noticePreamble('✅ 初始化完成, 进入正常模式');
4725
4896
  }
4726
4897
  }
4727
4898
  }
4728
4899
  catch (e) {
4729
- // 评估/向导自身异常: fail-closed —— 不假装就绪
4730
- w(`⛔ 初始化流程失败 (fail-closed): ${String(e?.message || e).slice(0, 200)}`);
4731
- w(` 当前状态评估: ${gateEv ? gateEv.gate : '未知 (评估都没跑通)'}`);
4732
- w(' 排查: `bolloon setup --status` (或删掉 ~/.bolloon/setup-state.json 后重跑 setup)');
4900
+ // 评估/向导自身异常: fail-closed —— 不假装就绪, 且缓冲**必须**落屏 (这就要退出了, 没有面板接着)
4901
+ noticePreamble(`⛔ 初始化流程失败 (fail-closed): ${String(e?.message || e).slice(0, 200)}`, { visible: true });
4902
+ noticePreamble(` 当前状态评估: ${gateEv ? gateEv.gate : '未知 (评估都没跑通)'}`, { visible: true });
4903
+ noticePreamble(` 排查: \`bolloon setup status\` (或删掉 ~/.bolloon/setup-state.json 后重跑 setup)`, { visible: true });
4904
+ flushStartupNotices();
4733
4905
  process.exit(1);
4734
4906
  }
4907
+ if (startupPreambleVisible())
4908
+ w(preambleHintLine());
4735
4909
  }
4736
4910
  if (isNonInteractive) {
4737
4911
  console.error = () => { };
@@ -4758,8 +4932,20 @@ async function main() {
4758
4932
  hasKimi ? 'Kimi' :
4759
4933
  hasGlm ? 'GLM' :
4760
4934
  hasQwen ? 'Qwen' : null;
4935
+ // 2026-09-26: 启动装配统一从「有效模型配置」来 (Session > Global > 供应商默认 > 环境变量)。
4936
+ // 旧写法按"环境变量里有哪些 key"挑供应商, 与 Web 启动(读配置文件)给出不同结果 ——
4937
+ // 同一个人配了 deepseek 配置文件、环境里又留着 OPENAI_API_KEY, 换个入口就换模型。
4761
4938
  if (llmProvider) {
4762
- initMinimax({ provider: llmProvider.toLowerCase() });
4939
+ try {
4940
+ const { applyEffectiveToRuntime } = await import('./llm/model-selection.js');
4941
+ const eff = await applyEffectiveToRuntime();
4942
+ bootNotice('info', `模型: ${eff.provider}/${eff.model} (来源: ${eff.source})`);
4943
+ }
4944
+ catch (e) {
4945
+ // 装配失败不静默: 退回按环境变量初始化 (至少让用户看到原因)
4946
+ bootNotice('warn', `按有效配置装配模型失败 (${String(e?.message || e).slice(0, 120)}), 退回环境变量猜测`);
4947
+ initMinimax({ provider: llmProvider.toLowerCase() });
4948
+ }
4763
4949
  }
4764
4950
  else {
4765
4951
  if (isNonInteractive) {
@@ -4779,11 +4965,11 @@ async function main() {
4779
4965
  if (typeof checkKuboSetup === 'function') {
4780
4966
  const setup = await checkKuboSetup(true, true);
4781
4967
  kuboReady = !!(setup?.ready && setup?.daemonRunning);
4782
- s.info(kuboReady ? 'IPFS 本地 Kubo 就绪 → IPNS 发布/解析可用' : 'Kubo 不可用, IPFS 降级本地模式');
4968
+ bootNotice('info', kuboReady ? 'IPFS 本地 Kubo 就绪 → IPNS 发布/解析可用' : 'Kubo 不可用, IPFS 降级本地模式');
4783
4969
  }
4784
4970
  }
4785
4971
  catch (e) {
4786
- s.warn(`Kubo 自动安装失败 (非致命): ${String(e?.message || e).slice(0, 120)}`);
4972
+ bootNotice('warn', `Kubo 自动安装失败 (非致命): ${String(e?.message || e).slice(0, 120)}`);
4787
4973
  }
4788
4974
  publishDID(name, keypair).then(({ cid, ipnsName }) => {
4789
4975
  if (cid)
@@ -4805,7 +4991,7 @@ async function main() {
4805
4991
  agentIdentity.p2pChannel = 'bolloon-agent-harness';
4806
4992
  }
4807
4993
  }).catch(err => {
4808
- s.warn(`P2P Web 模式启动失败: ${err.message}`);
4994
+ bootNotice('warn', `P2P Web 模式启动失败: ${err.message}`);
4809
4995
  });
4810
4996
  }
4811
4997
  else if (isCLIInteractive) {
@@ -4813,7 +4999,7 @@ async function main() {
4813
4999
  // startCLI 收 Promise, 内部空安全 (P2P 功能就绪前自动降级, 一般 1-3s 内可用)
4814
5000
  commReady = withTimeout(bootstrapP2P(verifier), 20_000, 'P2P 网络初始化')
4815
5001
  .catch((err) => {
4816
- s.warn(`P2P 初始化超时/失败, 降级无 P2P 模式: ${err.message}`);
5002
+ bootNotice('warn', `P2P 初始化超时/失败, 降级无 P2P 模式: ${err.message}`);
4817
5003
  return null;
4818
5004
  });
4819
5005
  void commReady.then((c) => {
@@ -4830,7 +5016,7 @@ async function main() {
4830
5016
  // 2026-08-07: 弱网下 hyperswarm DHT start/joinTopic 可能无限挂起 → 20s 超时门, 超时降级无 P2P 模式
4831
5017
  comm = await withTimeout(bootstrapP2P(verifier), 20_000, 'P2P 网络初始化')
4832
5018
  .catch((err) => {
4833
- s.warn(`P2P 初始化超时/失败, 降级无 P2P 模式: ${err.message}`);
5019
+ bootNotice('warn', `P2P 初始化超时/失败, 降级无 P2P 模式: ${err.message}`);
4834
5020
  return null;
4835
5021
  });
4836
5022
  if (comm) {
@@ -4843,37 +5029,37 @@ async function main() {
4843
5029
  }
4844
5030
  }
4845
5031
  catch (err) {
4846
- s.warn(`P2P 初始化失败: ${err.message}`);
4847
- s.warn('将使用无 P2P 模式运行');
5032
+ bootNotice('warn', `P2P 初始化失败: ${err.message}`);
5033
+ bootNotice('warn', '将使用无 P2P 模式运行');
4848
5034
  }
4849
5035
  if (isCLIInteractive) {
4850
5036
  // 2026-09-08 加速启动: iroh + Bolloon bootstrap 也全部后台, 不阻塞 UI 首帧
4851
5037
  void withTimeout(bootstrapIroh(keypair, name), 15_000, 'iroh P2P 初始化')
4852
- .catch((err) => s.warn(`iroh 初始化超时, 继续使用 Hyperswarm P2P: ${err.message}`));
5038
+ .catch((err) => bootNotice('warn', `iroh 初始化超时, 继续使用 Hyperswarm P2P: ${err.message}`));
4853
5039
  void (async () => {
4854
5040
  try {
4855
5041
  const { bootstrapBolloon } = await import('./pi-ecosystem-judgment/human-value-pipeline.js');
4856
5042
  const bs = await withTimeout(bootstrapBolloon({ cwd: process.cwd() }), 20_000, 'Bolloon 上下文扫描');
4857
- s.info(`Bootstrap 完成 (${bs.durationMs}ms, ${bs.errors.length} 个非致命错误)`);
5043
+ bootNotice('info', `Bootstrap 完成 (${bs.durationMs}ms, ${bs.errors.length} 个非致命错误)`);
4858
5044
  }
4859
5045
  catch (err) {
4860
- s.warn(`Bootstrap 失败 (非致命, 主流程继续): ${err.message}`);
5046
+ bootNotice('warn', `Bootstrap 失败 (非致命, 主流程继续): ${err.message}`);
4861
5047
  }
4862
5048
  })();
4863
5049
  }
4864
5050
  else {
4865
5051
  await withTimeout(bootstrapIroh(keypair, name), 15_000, 'iroh P2P 初始化')
4866
- .catch((err) => s.warn(`iroh 初始化超时, 继续使用 Hyperswarm P2P: ${err.message}`));
5052
+ .catch((err) => bootNotice('warn', `iroh 初始化超时, 继续使用 Hyperswarm P2P: ${err.message}`));
4867
5053
  // Bolloon Bootstrap: 启动扫描 + Context 收集 + 挂定时任务
4868
5054
  // 失败静默 (主流程不被阻塞)
4869
5055
  try {
4870
5056
  const { bootstrapBolloon } = await import('./pi-ecosystem-judgment/human-value-pipeline.js');
4871
- s.info('正在 bootstrap bolloon 上下文...');
5057
+ bootNotice('info', '正在 bootstrap bolloon 上下文...');
4872
5058
  const bs = await withTimeout(bootstrapBolloon({ cwd: process.cwd() }), 20_000, 'Bolloon 上下文扫描');
4873
- s.info(`Bootstrap 完成 (${bs.durationMs}ms, ${bs.errors.length} 个非致命错误)`);
5059
+ bootNotice('info', `Bootstrap 完成 (${bs.durationMs}ms, ${bs.errors.length} 个非致命错误)`);
4874
5060
  }
4875
5061
  catch (err) {
4876
- s.warn(`Bootstrap 失败 (非致命, 主流程继续): ${err.message}`);
5062
+ bootNotice('warn', `Bootstrap 失败 (非致命, 主流程继续): ${err.message}`);
4877
5063
  }
4878
5064
  }
4879
5065
  if (mode === 'web') {
@@ -0,0 +1,10 @@
1
+ {
2
+ "schema": "bolloon-mobile-web/1",
3
+ "version": "0.5.1",
4
+ "channel": "stable",
5
+ "build": "stable",
6
+ "commit": "40006e67bf87e892569dd0b02cf2456fddcbbfaf",
7
+ "builtAt": "2026-09-27T05:37:37.788Z",
8
+ "layer": "web",
9
+ "note": "web 资源层构建戳 (手机端 OTA 的身份第 ③ 级回退; 装机后以 OTA state.json 为准)"
10
+ }