@bolloon/bolloon-agent 0.5.3 → 0.5.5

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 (144) hide show
  1. package/dist/agents/agent-identity-store.js +41 -5
  2. package/dist/agents/background-notices.js +124 -0
  3. package/dist/agents/channel-identity.js +54 -0
  4. package/dist/agents/code-write-gate.js +47 -0
  5. package/dist/agents/deny-pipeline.js +11 -2
  6. package/dist/agents/egress-sanitize.js +69 -0
  7. package/dist/agents/error-classifier.js +67 -5
  8. package/dist/agents/experience-review.js +226 -0
  9. package/dist/agents/gateway-group.js +153 -18
  10. package/dist/agents/iteration-budget.js +64 -0
  11. package/dist/agents/lesson-to-skill.js +211 -0
  12. package/dist/agents/managed-services.js +64 -0
  13. package/dist/agents/parse-tool-call.js +38 -32
  14. package/dist/agents/pi-sdk-session-manager.js +72 -3
  15. package/dist/agents/pi-sdk-tools.js +268 -47
  16. package/dist/agents/pi-sdk.js +470 -22
  17. package/dist/agents/plan-store.js +36 -0
  18. package/dist/agents/process-runner.js +64 -2
  19. package/dist/agents/session-store.js +144 -3
  20. package/dist/agents/shell-guard.js +109 -13
  21. package/dist/agents/skill-health.js +90 -0
  22. package/dist/agents/skill-ledger.js +129 -0
  23. package/dist/agents/tool-loop-guard.js +303 -0
  24. package/dist/agents/tool-path-args.js +53 -0
  25. package/dist/agents/tool-result-gate.js +50 -0
  26. package/dist/agents/tool-subset.js +109 -0
  27. package/dist/agents/tool-telemetry.js +97 -0
  28. package/dist/agents/wallet-tools.js +63 -0
  29. package/dist/bootstrap/context-collector.js +37 -7
  30. package/dist/bootstrap/lifecycle-hooks.js +12 -3
  31. package/dist/bootstrap/persona-init.js +174 -0
  32. package/dist/bootstrap/persona-loader.js +5 -1
  33. package/dist/cli/ink-app.js +231 -32
  34. package/dist/cli/input-history.js +119 -0
  35. package/dist/cli/input-paste.js +218 -0
  36. package/dist/cli/loading-tui.js +69 -7
  37. package/dist/cli/log-gate.js +46 -1
  38. package/dist/cli/mention-data.js +30 -0
  39. package/dist/cli/reasoning-view.js +103 -0
  40. package/dist/cli/session-summary.js +129 -0
  41. package/dist/cli/session-tree.js +141 -0
  42. package/dist/cli/startup-notice.js +25 -0
  43. package/dist/cli/status-segments.js +155 -0
  44. package/dist/cli/stores.js +16 -0
  45. package/dist/cli/trace-line.js +54 -0
  46. package/dist/cli/wallet-store.js +159 -0
  47. package/dist/cli/wallet-table.js +46 -0
  48. package/dist/cli-entry.js +22 -0
  49. package/dist/context-compaction/auto-compact.js +37 -2
  50. package/dist/electron-build/agents/agent-identity-store.js +43 -5
  51. package/dist/electron-build/agents/agent-identity-store.js.map +1 -1
  52. package/dist/electron-build/agents/background-notices.js +138 -0
  53. package/dist/electron-build/agents/background-notices.js.map +1 -0
  54. package/dist/electron-build/agents/channel-identity.js +64 -0
  55. package/dist/electron-build/agents/channel-identity.js.map +1 -0
  56. package/dist/electron-build/agents/code-write-gate.js +55 -0
  57. package/dist/electron-build/agents/code-write-gate.js.map +1 -0
  58. package/dist/electron-build/agents/deny-pipeline.js +11 -2
  59. package/dist/electron-build/agents/deny-pipeline.js.map +1 -1
  60. package/dist/electron-build/agents/egress-sanitize.js +76 -0
  61. package/dist/electron-build/agents/egress-sanitize.js.map +1 -0
  62. package/dist/electron-build/agents/error-classifier.js +68 -5
  63. package/dist/electron-build/agents/error-classifier.js.map +1 -1
  64. package/dist/electron-build/agents/experience-review.js +243 -0
  65. package/dist/electron-build/agents/experience-review.js.map +1 -0
  66. package/dist/electron-build/agents/iteration-budget.js +73 -0
  67. package/dist/electron-build/agents/iteration-budget.js.map +1 -0
  68. package/dist/electron-build/agents/lesson-to-skill.js +228 -0
  69. package/dist/electron-build/agents/lesson-to-skill.js.map +1 -0
  70. package/dist/electron-build/agents/managed-services.js +69 -0
  71. package/dist/electron-build/agents/managed-services.js.map +1 -0
  72. package/dist/electron-build/agents/parse-tool-call.js +38 -32
  73. package/dist/electron-build/agents/parse-tool-call.js.map +1 -1
  74. package/dist/electron-build/agents/pi-sdk-session-manager.js +75 -3
  75. package/dist/electron-build/agents/pi-sdk-session-manager.js.map +1 -1
  76. package/dist/electron-build/agents/pi-sdk-tools.js +268 -46
  77. package/dist/electron-build/agents/pi-sdk-tools.js.map +1 -1
  78. package/dist/electron-build/agents/pi-sdk-types.js.map +1 -1
  79. package/dist/electron-build/agents/pi-sdk.js +471 -22
  80. package/dist/electron-build/agents/pi-sdk.js.map +1 -1
  81. package/dist/electron-build/agents/plan-store.js +37 -0
  82. package/dist/electron-build/agents/plan-store.js.map +1 -1
  83. package/dist/electron-build/agents/process-runner.js +71 -2
  84. package/dist/electron-build/agents/process-runner.js.map +1 -1
  85. package/dist/electron-build/agents/session-store.js +144 -3
  86. package/dist/electron-build/agents/session-store.js.map +1 -1
  87. package/dist/electron-build/agents/shell-guard.js +110 -14
  88. package/dist/electron-build/agents/shell-guard.js.map +1 -1
  89. package/dist/electron-build/agents/skill-health.js +103 -0
  90. package/dist/electron-build/agents/skill-health.js.map +1 -0
  91. package/dist/electron-build/agents/skill-ledger.js +146 -0
  92. package/dist/electron-build/agents/skill-ledger.js.map +1 -0
  93. package/dist/electron-build/agents/tool-loop-guard.js +319 -0
  94. package/dist/electron-build/agents/tool-loop-guard.js.map +1 -0
  95. package/dist/electron-build/agents/tool-path-args.js +63 -0
  96. package/dist/electron-build/agents/tool-path-args.js.map +1 -0
  97. package/dist/electron-build/agents/tool-result-gate.js +59 -0
  98. package/dist/electron-build/agents/tool-result-gate.js.map +1 -0
  99. package/dist/electron-build/agents/tool-subset.js +116 -0
  100. package/dist/electron-build/agents/tool-subset.js.map +1 -0
  101. package/dist/electron-build/agents/tool-telemetry.js +108 -0
  102. package/dist/electron-build/agents/tool-telemetry.js.map +1 -0
  103. package/dist/electron-build/agents/wallet-tools.js +102 -0
  104. package/dist/electron-build/agents/wallet-tools.js.map +1 -0
  105. package/dist/electron-build/bootstrap/context-collector.js +38 -7
  106. package/dist/electron-build/bootstrap/context-collector.js.map +1 -1
  107. package/dist/electron-build/bootstrap/lifecycle-hooks.js +12 -3
  108. package/dist/electron-build/bootstrap/lifecycle-hooks.js.map +1 -1
  109. package/dist/electron-build/bootstrap/persona-init.js +216 -0
  110. package/dist/electron-build/bootstrap/persona-init.js.map +1 -0
  111. package/dist/electron-build/bootstrap/persona-loader.js +5 -1
  112. package/dist/electron-build/bootstrap/persona-loader.js.map +1 -1
  113. package/dist/electron-build/cli/wallet-store.js +200 -0
  114. package/dist/electron-build/cli/wallet-store.js.map +1 -0
  115. package/dist/electron-build/context-compaction/auto-compact.js +38 -2
  116. package/dist/electron-build/context-compaction/auto-compact.js.map +1 -1
  117. package/dist/electron-build/external-engines/delegate.js +22 -0
  118. package/dist/electron-build/external-engines/delegate.js.map +1 -1
  119. package/dist/electron-build/llm/pi-ai.js +25 -0
  120. package/dist/electron-build/llm/pi-ai.js.map +1 -1
  121. package/dist/electron-build/orbitdb/cid-database.js +55 -0
  122. package/dist/electron-build/orbitdb/cid-database.js.map +1 -1
  123. package/dist/electron-build/orbitdb/ipfs-node.js +36 -11
  124. package/dist/electron-build/orbitdb/ipfs-node.js.map +1 -1
  125. package/dist/electron-build/web/server-storage.js +26 -5
  126. package/dist/electron-build/web/server-storage.js.map +1 -1
  127. package/dist/external-engines/delegate.js +21 -0
  128. package/dist/index.js +1324 -173
  129. package/dist/llm/pi-ai.js +23 -0
  130. package/dist/orbitdb/cid-database.js +55 -0
  131. package/dist/orbitdb/event-index.js +370 -0
  132. package/dist/orbitdb/event-query.js +131 -0
  133. package/dist/orbitdb/event-shell.js +510 -0
  134. package/dist/orbitdb/group-access.js +381 -0
  135. package/dist/orbitdb/group-shards.js +140 -0
  136. package/dist/orbitdb/ipfs-node.js +36 -11
  137. package/dist/web/bolloon-web.json +3 -3
  138. package/dist/web/client.js +3 -2
  139. package/dist/web/mobile-core.js +7762 -6289
  140. package/dist/web/routes-mobile-tasks.js +2 -1
  141. package/dist/web/server-storage.js +26 -5
  142. package/dist/web/server.js +2 -0
  143. package/dist/web/sw.js +1 -1
  144. package/package.json +2 -1
@@ -1,4 +1,4 @@
1
- import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
1
+ import { Fragment as _Fragment, jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
2
  /**
3
3
  * ink-app.tsx — Ink (React for CLI) 渲染入口
4
4
  *
@@ -8,10 +8,14 @@ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
8
8
  * ↑/↓ 导航, Tab/Enter 选中, Esc 关闭, 弹出窗打开时 TextInput 让出焦点 (focus=false)
9
9
  */
10
10
  import React, { useState, useEffect, useCallback, useRef, useMemo } from 'react';
11
+ import { ruleFor } from './status-segments.js';
12
+ import { loadInputHistory, appendInputHistory, MEMORY_CAP } from './input-history.js';
11
13
  import * as fs from 'fs';
12
- import { render, Box, Text, useInput, useApp, useStdout } from 'ink';
14
+ import { Static, render, Box, Text, useInput, useApp, useStdout } from 'ink';
15
+ import { stripUiNoise, collapsePaste, stripBracketedPaste, looksLikePasteChunk, logPasteChunk, shouldSuppressMention, singleLine, logPopupEvent, PASTE_MENTION_SHIELD_MS } from './input-paste.js';
13
16
  import TextInput from 'ink-text-input';
14
17
  import { dispWidth, LOADING_FRAMES as KAOMOJI } from './loading-tui.js';
18
+ import { reasoningMode } from './reasoning-view.js';
15
19
  import { THEME, fg } from './theme.js';
16
20
  import { COMPOSER_PLACEHOLDER, POPUP_TITLE_TAB, POPUP_TITLE_AGENT, POPUP_TITLE_FILE, POPUP_TITLE_COMMAND } from './content.js';
17
21
  import { DOUBLE_ESC_MS, STATUS_TICK_MS, THINK_FRAME_MS } from './timing.js';
@@ -53,6 +57,19 @@ const MIN_HISTORY_LINES = 1;
53
57
  /** 弹层最多占几行 (给历史区留出至少 1 行消息, 免得弹窗把历史区吃光) */
54
58
  const POPUP_MAX_ROWS = 8;
55
59
  /** 一屏布局账本 (纯函数 —— 验收门直接喂尺寸复核) */
60
+ /**
61
+ * 启动面板最多能占几行 = 消息窗口真高 (与渲染同一套预算; 面板按它裁, 免得高过窗口).
62
+ * 2026-09-30: 面板高过窗口时 Ink 清不掉上一帧 ⇒ 退化成"追加", 顶框被反复重画.
63
+ */
64
+ export function bootPanelMaxLines(rows, cols) {
65
+ const b = layoutBudget({ rows: Math.max(8, Math.floor(rows || 24) - 2), cols: Math.floor(cols || 80) });
66
+ return Math.max(4, b.msgH);
67
+ }
68
+ /** 面板区高度 (窗口的 60%; 会话区永远留 >=3 行) —— 给启动面板建盒子时对齐用 */
69
+ export function bootPanelRegionLines(rows, cols) {
70
+ const b = layoutBudget({ rows: Math.max(8, Math.floor(rows || 24) - 2), cols: Math.floor(cols || 80) });
71
+ return Math.max(4, Math.min(Math.floor(b.msgH * 0.6), b.msgH - 3));
72
+ }
56
73
  export function layoutBudget(opts) {
57
74
  const rows = Math.max(RESERVED_LINES + MIN_HISTORY_LINES, Math.floor(opts.rows || 24));
58
75
  const cols = Math.max(20, Math.floor(opts.cols || 80));
@@ -91,6 +108,14 @@ const MentionPopup = ({ title, items, sel, width, loading, maxRows }) => {
91
108
  };
92
109
  const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH }) => {
93
110
  const [input, setInput] = useState('');
111
+ /** 本次粘贴攒到的**原文**(只进文件, 不进输入框 ⇒ 输入框那份是引用) */
112
+ const pasteRawRef = useRef('');
113
+ /** 同一次粘贴复用同一个文件序号(避免每块都新开一个文件) */
114
+ const pasteSeqRef = useRef(null);
115
+ /** 粘贴后短暂封住补全弹窗(免得粘贴内容里的 '@'/'/'/'#' 弹窗抢焦点) */
116
+ const pasteShieldUntilRef = useRef(0);
117
+ /** 本次粘贴的**文件路径** —— 输入框那份不带路径(@/'/'/'#' 会触发弹窗 ✗), 提交时用它补上 ✓ */
118
+ const pastePathRef = useRef(null);
94
119
  // 2026-08-07: inputRef 同步镜像 input — useInput 回调拿最新值 (闭包里的 input 是陈旧的)
95
120
  const inputRef = useRef('');
96
121
  useEffect(() => {
@@ -116,9 +141,20 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
116
141
  const lastEscRef = useRef(0);
117
142
  const C_WARN_ANSI = fg(THEME.warn); // #f59e0b
118
143
  // ── @ / # 弹出窗状态 ──────────────────────────────────────────────────────
119
- const mention = useMemo(() => getMention(input), [input]);
144
+ // 2026-10-01: 粘贴引用本身 / 超长输入 / "刚粘贴过" 一律不当 mention 来源 (纯判断 ⇒ 弹窗**根本不会开** ⇒ 也就没有抢焦点/抖动 ✓)
145
+ const [pastedTick, setPastedTick] = useState(0);
146
+ const mention = useMemo(() => (shouldSuppressMention(input, pasteShieldUntilRef.current) ? null : getMention(input)), [input, pastedTick]);
120
147
  const mentionKey = mention ? `${mention.kind}:${mention.start}` : null;
121
148
  const [items, setItems] = useState([]);
149
+ // 2026-10-01: 弹窗观测 —— 实测证明 **mention 从未命中**(全 mention.none ✓); 且用户确认那个弹窗是
150
+ // **macOS 终端自己的"多行粘贴确认"** ✓(不是 bolloon 画的 ✗) ⇒ 所以只保留 mention 这一条观测即可 ✓
151
+ // (曾试过把 items/tabState/picker 也记上, 已撤掉: 目标已被证伪, 留着只是噪音 ✗ —— 减法 ✓)。
152
+ useEffect(() => {
153
+ if (mention)
154
+ void logPopupEvent({ ev: 'mention.hit', kind: mention.kind, trigger: mention.trigger, len: input.length, head: input });
155
+ else if (input.length > 0)
156
+ void logPopupEvent({ ev: 'mention.none', len: input.length, head: input });
157
+ }, [mentionKey, input.length]);
122
158
  const [sel, setSel] = useState(0);
123
159
  const [dismissed, setDismissed] = useState(null);
124
160
  const [loadingFiles, setLoadingFiles] = useState(false);
@@ -157,6 +193,14 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
157
193
  }, []);
158
194
  // ── 输入历史 (↑/↓ 切换) ───────────────────────────────────────────────────
159
195
  const historyRef = useRef([]);
196
+ // 2026-09-30 (leo: 「输入历史有落盘文件夹吗,要实现一下」): 启动时把落盘历史读回内存 —— 重启后 ↑ 还能翻出来。
197
+ // 异步读, 读回来直接替换 ref (空文件/读失败 = 保持空, 不打扰输入)。
198
+ useEffect(() => {
199
+ let alive = true;
200
+ loadInputHistory().then(h => { if (alive && h.length)
201
+ historyRef.current = h; }).catch(() => { });
202
+ return () => { alive = false; };
203
+ }, []);
160
204
  const historyIdxRef = useRef(-1); // -1 = 正在编辑新草稿
161
205
  const draftRef = useRef('');
162
206
  // 加载当前 mention 的候选 (agent/command 缓存一次; file 每次打开重扫)
@@ -405,18 +449,57 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
405
449
  lastSubmitRef.current = { t: now, v: trimmed };
406
450
  if (!trimmed)
407
451
  return;
408
- // 入历史 (去重最近一条, 上限 100)
452
+ // 入历史 (去重最近一条) + **落盘** (~/.bolloon/history/input-<渠道>.jsonl, 0600)
453
+ // 秘密形态的输入 (私钥/助记词) 由 appendInputHistory 拦下不写盘 —— 输入历史是纯文本, 写进去就是事故。
409
454
  const hist = historyRef.current;
410
455
  if (hist[hist.length - 1] !== trimmed)
411
456
  hist.push(trimmed);
412
- if (hist.length > 100)
457
+ if (hist.length > MEMORY_CAP)
413
458
  hist.shift();
459
+ void appendInputHistory(trimmed);
414
460
  historyIdxRef.current = -1;
415
461
  draftRef.current = '';
416
462
  setInput('');
463
+ // 2026-10-01: 长输入/粘贴**折叠** —— 整段落文件, 对话流只留一行引用(指向文件 ⇒ 需要细节读得回来)
464
+ // 阈值: ≥8 行 或 ≥1200 字符; 短输入原样发送; 写盘失败退化成原样(绝不打断发送)。
465
+ let outgoing = stripUiNoise(trimmed); // 防 UI 残影(颜文字/思考中/ANSI)进上下文
466
+ try {
467
+ const collapsed = collapsePaste(trimmed);
468
+ if (collapsed.collapsed)
469
+ outgoing = collapsed.sendText;
470
+ }
471
+ catch { /* 折叠失败 = 原样发送 */ }
417
472
  // 用户消息由 processInput 统一通过 appendLine(renderUserMessage) 显示
418
- onPrompt(trimmed);
473
+ onPrompt(outgoing);
419
474
  }, [onPrompt]);
475
+ /**
476
+ * 输入历史: 上一/下一条。
477
+ * 2026-09-30 (leo: 「历史的上下切换遇到 /session 等指令就无法切换了」) —— 抽出来是因为
478
+ * 历史里一旦出现 `/xxx`, 恢复出来的输入会**打开命令补全弹窗**, 而弹窗把 ↑/↓ 当"移动候选"吃掉
479
+ * ⇒ 历史再也切不动。现在: **空输入** 或 **已在历史态** 时 ↑/↓ 一律归历史 (见下面优先块)。
480
+ */
481
+ const histUp = useCallback(() => {
482
+ const hist = historyRef.current;
483
+ if (hist.length === 0)
484
+ return;
485
+ if (historyIdxRef.current === -1)
486
+ draftRef.current = inputRef.current;
487
+ if (historyIdxRef.current < hist.length - 1) {
488
+ historyIdxRef.current += 1;
489
+ setInput(hist[hist.length - 1 - historyIdxRef.current]);
490
+ setTiKey(k => k + 1);
491
+ }
492
+ }, []);
493
+ const histDown = useCallback(() => {
494
+ if (historyIdxRef.current === -1)
495
+ return;
496
+ historyIdxRef.current -= 1;
497
+ if (historyIdxRef.current === -1)
498
+ setInput(draftRef.current);
499
+ else
500
+ setInput(historyRef.current[historyRef.current.length - 1 - historyIdxRef.current]);
501
+ setTiKey(k => k + 1);
502
+ }, []);
420
503
  useInput((_input, key) => {
421
504
  // 退出请求: 通知 startCLI resolve → 走清理 → process.exit (带兜底)
422
505
  const requestExit = () => {
@@ -429,6 +512,46 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
429
512
  requestExit();
430
513
  return;
431
514
  }
515
+ // ── ① 粘贴块**最先**处理 (2026-10-01) ──
516
+ // 必须在弹窗/picker/历史之前: 否则粘贴内容里的 TAB 会先被补全弹窗吃掉 ⇒ 弹窗打开 + 抢焦点 ✗(实测)。
517
+ // 实测形态: 一整块 762 字符纯文本(无括号标记/无换行) ⇒ 同步落文件 + 输入框整段设成"零触发字符"的引用 ✓。
518
+ // 2026-10-01 (第五版, 最终简化): **粘贴块 ⇒ 同步折叠成一个引用**。
519
+ // 为什么砍掉前面那套(定时器 + burst 状态 + 兜底冲洗): 真机上 80ms 定时器会**抖**(输入框变窄一下 ✗)
520
+ // 而且没冲上就**什么都看不到** ✗。现在: 不用定时器 ✗ 不用状态开关 ✗ ——
521
+ // 攒到的原文先落文件, 输入框**整段设成那一个引用**(不是追加) ⇒ 永远只有一行 ✓ 永远看得见 ✓
522
+ // 引用零触发字符 ⇒ 补全弹窗根本不会开 ✓ 也就没有"变窄"的位移 ✓。
523
+ {
524
+ const piece = stripBracketedPaste(_input);
525
+ if (piece && looksLikePasteChunk(_input)) {
526
+ pasteRawRef.current += piece;
527
+ let shown = singleLine(piece);
528
+ try {
529
+ const c = collapsePaste(pasteRawRef.current, { counter: pasteSeqRef.current ?? undefined });
530
+ if (c.collapsed) {
531
+ if (!pasteSeqRef.current)
532
+ pasteSeqRef.current = Number((/\d+/.exec(c.inputText) || ['0'])[0]) || null;
533
+ pastePathRef.current = c.path ?? null;
534
+ shown = c.inputText;
535
+ }
536
+ }
537
+ catch { /* 折叠失败 ⇒ 原样显示(至少看得见) */ }
538
+ pasteShieldUntilRef.current = Date.now() + PASTE_MENTION_SHIELD_MS;
539
+ setPastedTick(t => t + 1);
540
+ setInput(() => shown);
541
+ void logPasteChunk({ len: _input.length, marker: _input !== piece, nl: /[\n\r]/.test(_input), esc: _input.includes('\u001b'), tab: _input.includes('\t') });
542
+ return;
543
+ }
544
+ }
545
+ // ── 输入历史优先 (比补全弹窗更优先) ──
546
+ // 规则: 空输入, 或已经在历史态 (historyIdx !== -1) ⇒ ↑/↓ 归历史, 弹窗/选择器都让路。
547
+ // 只有"用户自己敲了内容且不在历史态"时, 补全弹窗才拥有 ↑/↓。
548
+ if ((key.upArrow || key.downArrow) && (historyIdxRef.current !== -1 || !inputRef.current.trim())) {
549
+ if (key.upArrow)
550
+ histUp();
551
+ else
552
+ histDown();
553
+ return;
554
+ }
432
555
  // ── 程序化选择器: 全键接管 (↑↓ 选择, Enter 确认, Esc 关闭) ──
433
556
  if (picker) {
434
557
  const itemsArg = picker.items;
@@ -480,9 +603,16 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
480
603
  }
481
604
  if ((key.return || /[\n\r]/.test(_input)) && filtered.length === 0) {
482
605
  // 弹窗无匹配项: Enter = 提交当前输入 (否则 /channel 无参 + Enter 永远提交不了 — 2026-08-06)
483
- const v = input.trim();
484
- if (v)
485
- onSubmit(v);
606
+ // 输入框里是**不含路径**的引用(@/'/'/'#' 会触发弹窗 ✗) ⇒ 提交时把路径与"要看细节读它"补上 ✓
607
+ const refText = input.trim();
608
+ const full = (pastePathRef.current && /^\[粘贴[^\]]*\]$/.test(refText))
609
+ ? `${refText}\n(整段已存到 ${pastePathRef.current} —— 需要看细节就 read_file 读它, 不用我重述)`
610
+ : refText;
611
+ pasteRawRef.current = '';
612
+ pasteSeqRef.current = null;
613
+ pastePathRef.current = null;
614
+ if (full)
615
+ onSubmit(full);
486
616
  else
487
617
  setDismissed(mentionKey);
488
618
  return;
@@ -526,6 +656,10 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
526
656
  return;
527
657
  }
528
658
  if (_input && !key.ctrl && !key.meta && !key.return) {
659
+ if (pasteShieldUntilRef.current) {
660
+ pasteShieldUntilRef.current = 0;
661
+ setPastedTick(t => t + 1);
662
+ }
529
663
  setInput(cur => cur + _input);
530
664
  return;
531
665
  }
@@ -533,7 +667,7 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
533
667
  }
534
668
  // ── 正常模式 ──
535
669
  // #7 输入层: 滚动键走数据化 keymap (Ctrl+U/D / PgUp/PgDn / Home-End / Alt+↑↓ / Ctrl+Home-End)
536
- if (totalLines > availH) {
670
+ if (false && totalLines > availH) { // 无限高度: 不再有 app 内虚拟滚动 (PgUp 归终端)
537
671
  const pg = Math.max(6, availH - 2);
538
672
  const maxT = Math.max(0, totalLines - availH);
539
673
  const act = resolveNormalKey(key, { scrollable: true, input: _input });
@@ -563,27 +697,11 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
563
697
  }
564
698
  // ↑/↓ 切换输入历史 (TextInput 本身忽略 up/down, 无冲突)
565
699
  if (key.upArrow) {
566
- const hist = historyRef.current;
567
- if (hist.length === 0)
568
- return;
569
- if (historyIdxRef.current === -1)
570
- draftRef.current = input;
571
- if (historyIdxRef.current < hist.length - 1) {
572
- historyIdxRef.current += 1;
573
- setInput(hist[hist.length - 1 - historyIdxRef.current]);
574
- setTiKey(k => k + 1);
575
- }
700
+ histUp();
576
701
  return;
577
702
  }
578
703
  if (key.downArrow) {
579
- if (historyIdxRef.current === -1)
580
- return;
581
- historyIdxRef.current -= 1;
582
- if (historyIdxRef.current === -1)
583
- setInput(draftRef.current);
584
- else
585
- setInput(historyRef.current[historyRef.current.length - 1 - historyIdxRef.current]);
586
- setTiKey(k => k + 1);
704
+ histDown();
587
705
  return;
588
706
  }
589
707
  // 双击 Esc 退出当前进程: 第一击提示, 500ms 内第二击退出
@@ -672,7 +790,11 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
672
790
  const sticky = stickRef.current;
673
791
  const popupOpenNow = !!(popupOpen || picker);
674
792
  const budget = layoutBudget({
675
- rows: termSize.h, cols: W,
793
+ // 2026-09-30 (leo 报「顶部重绘」+ 真机日志坐实): 帧高 = 终端行数 - 2.
794
+ // 整帧 == 终端高时, 每次 flush (状态栏每秒一 tick) 都把最上一行滚进 scrollback
795
+ // ⇒ 面板被一遍遍重复留在屏上 (实测一次运行重画 9 次). 留 2 行余量 (终端可视行数
796
+ // 常比报告值矮 1~2 行) 即根治.
797
+ rows: Math.max(8, termSize.h - 2), cols: W,
676
798
  popupRows: popupOpenNow ? POPUP_MAX_ROWS : 0,
677
799
  });
678
800
  const msgH = budget.msgH; // 消息窗口高度 (弹层占的那几行已经扣掉)
@@ -694,8 +816,10 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
694
816
  // 严格钳制: 端界不越过 msgH, 保证 transcript 内容行数 ≤ 分区高度 (互不覆盖)
695
817
  while (end + 1 < msgs.length && cumulative[end + 1] <= top + msgH)
696
818
  end++;
697
- const visible = useMemo(() => msgs.slice(start, end + 1), [msgs, start, end]);
698
- const scrolledOut = maxTop > 0 && !sticky;
819
+ void start;
820
+ void end;
821
+ const visible = msgs; // 2026-09-30 (leo: 「我要无限高度」): 全部内容交给 <Static>, 不再切片
822
+ const scrolledOut = false; // 虚拟滚动退场 —— 滚动交给终端 scrollback
699
823
  // 测试/诊断探针: 把**真实用到的**布局与滚动状态写一份 JSONL (验收门按它做精确断言;
700
824
  // 不设 BOLLOON_TUI_PROBE 时零开销 —— 只在渲染后追加一行)。
701
825
  // 注: 走静态 import —— dist 是 ESM, 这里 `require()` 不存在 (踩过一次: 探针静默不写 + 树停更)。
@@ -715,7 +839,7 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
715
839
  }
716
840
  catch { /* 探针写不进去绝不影响界面 */ }
717
841
  });
718
- return (_jsxs(Box, { flexDirection: "column", height: budget.rows, width: budget.cols, overflow: "hidden", children: [_jsxs(Box, { flexDirection: "column", height: budget.history, children: [_jsxs(Box, { height: msgH, overflow: "hidden", flexDirection: hasRails ? 'row' : 'column', children: [_jsx(Box, { flexGrow: 1, flexDirection: "column", justifyContent: "flex-start", overflow: "hidden", children: _jsx(Messages, { msgs: visible }) }), hasRails && (_jsx(Box, { width: 36, marginLeft: 1, flexDirection: "column", justifyContent: "flex-start", children: railNames.map((n) => _jsxs(Text, { color: THEME.muted, children: [n, "\\n", widgets[n]] }, n)) }))] }), popupOpenNow && tabState && (_jsx(MentionPopup, { title: POPUP_TITLE_TAB, items: filtered, sel: safeSel, width: W, maxRows: budget.popupRows })), popupOpenNow && !tabState && mention && (_jsx(MentionPopup, { title: popupTitle, items: filtered, sel: safeSel, width: W, loading: loadingFiles, maxRows: budget.popupRows })), popupOpenNow && !tabState && !mention && picker && (_jsx(MentionPopup, { title: picker.title, items: picker.items, sel: Math.min(picker.sel, picker.items.length - 1), width: W, maxRows: budget.popupRows }))] }), _jsx(Box, { height: budget.activity, width: budget.cols, overflow: "hidden", children: scrolledOut ? (_jsxs(Text, { color: THEME.warn, children: ["\u5DF2\u6682\u505C\u8DDF\u968F \u00B7 PgDn/End \u56DE\u5230\u5E95\u90E8 (\u2191", top, " \u884C \u00B7 Ctrl+U/D \u7FFB\u9875 \u00B7 Home \u5230\u9876)"] })) : transient ? (_jsx(Text, { children: transient })) : thinking ? (_jsxs(Text, { color: "yellow", children: [KAOMOJI[thinkingIdx.current], " \u601D\u8003\u4E2D..."] })) : (_jsx(Text, { children: " " })) }), _jsx(Box, { children: _jsx(Text, { bold: true, color: THEME.accent, children: '─'.repeat(W) }) }), _jsx(Box, { height: 1, width: budget.cols, overflow: "hidden", children: _jsx(Text, { children: status }) }), _jsx(Box, { children: _jsx(Text, { bold: true, color: THEME.accent, children: '─'.repeat(W) }) }), _jsxs(Box, { width: budget.cols, height: 1, overflow: "hidden", children: [_jsx(Text, { bold: true, color: THEME.accent, children: "\u276F " }), _jsx(Box, { width: Math.max(10, budget.cols - 2), height: 1, overflow: "hidden", flexShrink: 0, children: _jsx(TextInput, { value: input, onChange: setInput, onSubmit: onSubmit, focus: !popupOpenNow, placeholder: COMPOSER_PLACEHOLDER }, tiKey) })] }), _jsx(Box, { children: _jsx(Text, { bold: true, color: THEME.accent, children: '─'.repeat(W) }) })] }));
842
+ return (_jsxs(_Fragment, { children: [_jsx(Static, { items: msgs, children: (m, i) => _jsx(Text, { children: m }, `m${i}`) }), _jsxs(Box, { flexDirection: "column", width: budget.cols, children: [hasRails && (_jsx(Box, { flexDirection: "column", children: railNames.map((n) => _jsx(Text, { color: THEME.muted, children: `${n}: ${String(widgets[n] ?? '').replace(/\n/g, ' ')}` }, n)) })), popupOpenNow && tabState && (_jsx(MentionPopup, { title: POPUP_TITLE_TAB, items: filtered, sel: safeSel, width: W, maxRows: budget.popupRows })), popupOpenNow && !tabState && mention && (_jsx(MentionPopup, { title: popupTitle, items: filtered, sel: safeSel, width: W, loading: loadingFiles, maxRows: budget.popupRows })), popupOpenNow && !tabState && !mention && picker && (_jsx(MentionPopup, { title: picker.title, items: picker.items, sel: Math.min(picker.sel, picker.items.length - 1), width: W, maxRows: budget.popupRows })), _jsx(Box, { height: 1, width: budget.cols, overflow: "hidden", children: transient ? (_jsx(Text, { children: transient })) : thinking ? (_jsx(Text, { color: "yellow", children: reasoningMode() === 'trace' ? '思考中...' : `${KAOMOJI[thinkingIdx.current]} 思考中...` })) : (_jsx(Text, { color: THEME.muted, children: '· 回车发送 · ↑↓ 历史 · PgUp 回看 · Esc 双击退出' })) }), _jsx(Box, { width: budget.cols, height: 1, overflow: "hidden", children: _jsx(Text, { bold: true, color: THEME.accent, children: ruleFor(Math.max(10, budget.cols - 1)) }) }), _jsx(Box, { height: 1, width: budget.cols, overflow: "hidden", children: _jsx(Text, { children: status }) }), _jsx(Box, { width: budget.cols, height: 1, overflow: "hidden", children: _jsx(Text, { bold: true, color: THEME.accent, children: ruleFor(Math.max(10, budget.cols - 1)) }) }), _jsxs(Box, { width: budget.cols, height: 1, overflow: "hidden", children: [_jsx(Text, { bold: true, color: THEME.accent, children: "\u276F " }), _jsx(Box, { width: Math.max(10, budget.cols - 2), height: 1, overflow: "hidden", flexShrink: 0, children: _jsx(TextInput, { value: singleLine(input), onChange: setInput, onSubmit: onSubmit, focus: !popupOpenNow, placeholder: COMPOSER_PLACEHOLDER }, tiKey) })] }), _jsx(Box, { width: budget.cols, height: 1, overflow: "hidden", children: _jsx(Text, { bold: true, color: THEME.accent, children: ruleFor(Math.max(10, budget.cols - 1)) }) })] })] }));
719
843
  };
720
844
  export { InkApp };
721
845
  // ─── 启动 ────────────────────────────────────────────────────────────────────
@@ -726,6 +850,8 @@ export function startInk(onPrompt, initialStatus, getStatusUpdate) {
726
850
  const tw = process.stdout.columns || 80;
727
851
  const th = process.stdout.rows || 24;
728
852
  _lastInkArgs = { onPrompt, initialStatus, getStatusUpdate };
853
+ // 2026-10-01: 接管 console —— 别的模块(p2p/iroh 等)的直写也一律进 Ink 消息流, 不再推 footer ✓
854
+ installConsoleIntercept();
729
855
  _inkInstance = render(_jsx(InkApp, { onPrompt: onPrompt, initialStatus: initialStatus, getStatusUpdate: getStatusUpdate, terminalW: tw, terminalH: th }), {
730
856
  stdout: process.stdout,
731
857
  stdin: process.stdin,
@@ -773,6 +899,7 @@ export function stopInk() {
773
899
  _inkInstance = null;
774
900
  }
775
901
  _lastInkArgs = null;
902
+ uninstallConsoleIntercept(); // 2026-10-01: 退出后把 console 还给系统(之后照常能打印 ✓)
776
903
  }
777
904
  export function inkAppendLine(line) {
778
905
  appendMsg(line);
@@ -797,3 +924,75 @@ export function inkSetThinking(v) {
797
924
  export function inkSetTransient(v) {
798
925
  setUiTransient(v);
799
926
  }
927
+ /**
928
+ * **进程级 console 拦截** (2026-10-01, 用户: 「有新的需要拦截」)。
929
+ * 为什么: 上一轮只把 `src/index.ts` 的直写收口了 ✗ —— 但启动那几行(`iroh: …` · `主题: …` ·
930
+ * `✓ [4/5] 启动 iroh P2P`)来自**别的模块** ✓(`src/network/p2p.ts` 24 处 · `iroh-integration.ts` 7 处 ✓),
931
+ * 它们绕过统一出口 ⇒ 照样把 footer 推下去(重复的提示行 ✓)。
932
+ * 做法: 在 **Ink 启动那一刻**接管 `console.log/info/warn/error` ✓ —— Ink 在跑 ⇒ 一律走它的消息流 ✓;
933
+ * **绝不动 `process.stdout.write`** ✗(Ink 自己要用 ✓)。卸载时恢复原函数 ✓(退出后照样能打印 ✓)。
934
+ * 递归保护 + 长度上限 ✓(任何异常都退回原函数 ✓, 不许因为拦截而丢日志 ✓)。
935
+ */
936
+ let _consoleOriginals = null;
937
+ let _interceptBusy = false;
938
+ /** 把一串 console 参数压成一行(剥 ANSI · 对象用紧凑 JSON · 有上限 ✓) */
939
+ export function formatConsoleArgs(args) {
940
+ const one = (v) => {
941
+ if (typeof v === 'string')
942
+ return v;
943
+ if (v instanceof Error)
944
+ return v.message;
945
+ try {
946
+ return JSON.stringify(v);
947
+ }
948
+ catch {
949
+ return String(v);
950
+ }
951
+ };
952
+ return args.map(one).join(' ')
953
+ .replace(/\x1b\[[0-9;]*[A-Za-z]/g, '')
954
+ .replace(/\s+/g, ' ')
955
+ .trim()
956
+ .slice(0, 1000);
957
+ }
958
+ export function installConsoleIntercept() {
959
+ if (_consoleOriginals)
960
+ return; // 已装过 ⇒ 幂等 ✓
961
+ _consoleOriginals = { log: console.log, info: console.info, warn: console.warn, error: console.error };
962
+ const route = (level) => (...args) => {
963
+ if (_interceptBusy) {
964
+ _consoleOriginals.log(...args);
965
+ return;
966
+ }
967
+ const line = formatConsoleArgs(args);
968
+ if (!line)
969
+ return;
970
+ _interceptBusy = true;
971
+ try {
972
+ // 归类前缀让来源一眼可辨 ✓(不猜内容, 只标级别 ✓)
973
+ appendMsg(level === 'log' || level === 'info' ? line : `${level === 'warn' ? '⚠ ' : '✗ '}${line}`);
974
+ }
975
+ catch {
976
+ try {
977
+ _consoleOriginals.log(...args);
978
+ }
979
+ catch { /* 真打不出去就算了 */ }
980
+ }
981
+ finally {
982
+ _interceptBusy = false;
983
+ }
984
+ };
985
+ console.log = route('log');
986
+ console.info = route('info');
987
+ console.warn = route('warn');
988
+ console.error = route('error');
989
+ }
990
+ export function uninstallConsoleIntercept() {
991
+ if (!_consoleOriginals)
992
+ return;
993
+ console.log = _consoleOriginals.log;
994
+ console.info = _consoleOriginals.info;
995
+ console.warn = _consoleOriginals.warn;
996
+ console.error = _consoleOriginals.error;
997
+ _consoleOriginals = null;
998
+ }
@@ -0,0 +1,119 @@
1
+ /**
2
+ * input-history.ts — 输入历史落盘 (leo 2026-09-30: 「输入历史有落盘文件夹吗,要实现一下」)
3
+ *
4
+ * 之前: 历史只在内存 (`ink-app.tsx` 的 historyRef, 上限 100, **重启即失**)。
5
+ * 现在: 落 `~/.bolloon/history/`, **一渠道一文件** `input-<channelId>.jsonl` (0600) ——
6
+ * 渠道隔离 (不同 agent/channel 的输入不该互相串), 重启后 ↑ 还能翻出来。
7
+ *
8
+ * 四条纪律:
9
+ * 1. **秘密不入历史** —— 64 位 hex 私钥 / `privateKey` / 助记词形态 (12+ 个小写词) 一律不写盘。
10
+ * 输入历史是纯文本、会被翻出来、也可能被贴出去; 钱包助记词进去就是事故。
11
+ * 2. **不打断输入** —— 落盘失败只记日志 (console.error), 绝不让打字报错。
12
+ * 3. **去重** —— 连续重复的同一行不重复写 (↑ 翻起来不烦)。
13
+ * 4. **有上限** —— 内存 200 / 文件 500 (超了重写成最后 500, 原子写)。
14
+ */
15
+ import * as fs from 'fs/promises';
16
+ import * as path from 'path';
17
+ /** 内存里最多留多少条 (CLI 进程内) */
18
+ export const MEMORY_CAP = 200;
19
+ /** 文件里最多留多少条 (超了裁剪) */
20
+ export const FILE_CAP = 500;
21
+ let scope = 'default';
22
+ /** 由 CLI 在切渠道时调用 (index.ts), 决定写哪个文件 */
23
+ export function setHistoryScope(channel) {
24
+ scope = sanitizeChannel(channel) || 'default';
25
+ }
26
+ export function historyScope() { return scope; }
27
+ function sanitizeChannel(channel) {
28
+ return String(channel || '').replace(/[^A-Za-z0-9._-]+/g, '_').slice(0, 64);
29
+ }
30
+ export function historyDir() {
31
+ return path.join(process.env.HOME || '/tmp', '.bolloon', 'history');
32
+ }
33
+ export function historyFile(channel) {
34
+ const ch = sanitizeChannel(channel) || scope || 'default';
35
+ return path.join(historyDir(), `input-${ch}.jsonl`);
36
+ }
37
+ /**
38
+ * 看着像秘密的输入 —— **不写历史**。
39
+ * 判据刻意保守 (宁可漏存一条普通输入, 也不能把私钥/助记词写进历史文件):
40
+ * · 64 位 hex (可带 0x) / 40 位 hex 地址不拦 (地址是公开的)
41
+ * · 出现 privateKey / private_key / mnemonic / 助记词 字样
42
+ * · 12 个以上连续小写单词 (助记词形态)
43
+ */
44
+ export function looksSecret(line) {
45
+ const s = String(line || '');
46
+ if (/0x[0-9a-fA-F]{64}\b/.test(s))
47
+ return true;
48
+ if (/\b(privateKey|private_key|mnemonic|助记词|seedPhrase|seed_phrase)\b/i.test(s))
49
+ return true;
50
+ // 助记词形态: 整行 (去掉常见前缀/标点) 是 12/15/18/21/24 个小写词
51
+ const words = s.replace(/^[^A-Za-z]*/, '').trim().split(/\s+/);
52
+ if (words.length >= 12 && words.length <= 30 && words.every(w => /^[a-z]{3,8}$/.test(w)))
53
+ return true;
54
+ return false;
55
+ }
56
+ /** 读历史 (新→旧? 不, 这里按**旧→新**返回: 与内存数组同序, ↑ 取最后一条) */
57
+ export async function loadInputHistory(channel) {
58
+ try {
59
+ const text = await fs.readFile(historyFile(channel), 'utf-8');
60
+ const out = [];
61
+ for (const raw of text.split('\n')) {
62
+ const line = raw.trim();
63
+ if (!line)
64
+ continue;
65
+ try {
66
+ const v = JSON.parse(line);
67
+ if (typeof v === 'string' && v.trim())
68
+ out.push(v);
69
+ }
70
+ catch { /* 坏行跳过 (历史坏了不该让 CLI 起不来) */ }
71
+ }
72
+ return out.slice(-MEMORY_CAP);
73
+ }
74
+ catch (e) {
75
+ if (e?.code === 'ENOENT')
76
+ return [];
77
+ console.error(`[input-history] 读不了 ${historyFile(channel)}: ${String(e?.message || e).slice(0, 120)}`);
78
+ return [];
79
+ }
80
+ }
81
+ /**
82
+ * 追加一条 (去重: 与最后一条相同则不写)。
83
+ * 返回 true = 真写了; false = 跳过了 (秘密/空/重复) —— 调用方可以据此决定是否提示。
84
+ */
85
+ export async function appendInputHistory(line, channel) {
86
+ const v = String(line || '').trim();
87
+ if (!v)
88
+ return false;
89
+ if (looksSecret(v)) {
90
+ console.error(`[input-history] 这行看着像密钥/助记词, **不写历史** (长度 ${v.length})`);
91
+ return false;
92
+ }
93
+ const file = historyFile(channel);
94
+ try {
95
+ await fs.mkdir(historyDir(), { recursive: true });
96
+ // 去重: 看最后一条
97
+ const existing = await loadInputHistory(channel);
98
+ if (existing.length && existing[existing.length - 1] === v)
99
+ return false;
100
+ await fs.appendFile(file, JSON.stringify(v) + '\n', { encoding: 'utf-8', mode: 0o600 });
101
+ try {
102
+ await fs.chmod(file, 0o600);
103
+ }
104
+ catch { /* 已足够 */ }
105
+ if (existing.length + 1 > FILE_CAP + 300) {
106
+ // 裁剪: 原子重写成最后 FILE_CAP 条
107
+ const kept = [...existing, v].slice(-FILE_CAP);
108
+ const tmp = `${file}.tmp`;
109
+ await fs.writeFile(tmp, kept.map(x => JSON.stringify(x) + '\n').join(''), { encoding: 'utf-8', mode: 0o600 });
110
+ await fs.rename(tmp, file);
111
+ }
112
+ return true;
113
+ }
114
+ catch (e) {
115
+ // 不打断输入: 只记日志
116
+ console.error(`[input-history] 写不了 ${file}: ${String(e?.message || e).slice(0, 120)}`);
117
+ return false;
118
+ }
119
+ }