@bolloon/bolloon-agent 0.2.3 → 0.2.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.
- package/bin/bolloon-cli.cjs +15 -4
- package/dist/agents/pi-sdk.js +11 -5
- package/dist/agents/react-loop.js +34 -0
- package/dist/cli-entry.js +6 -2
- package/dist/web/input-validator.js +103 -0
- package/dist/web/server.js +72 -1
- package/package.json +1 -1
package/bin/bolloon-cli.cjs
CHANGED
|
@@ -29,11 +29,22 @@ function getMainEntry() {
|
|
|
29
29
|
const binPath = require.main.filename; // /tmp/node_modules/.bin/bolloon
|
|
30
30
|
const binDir = path.dirname(binPath); // /tmp/node_modules/.bin
|
|
31
31
|
const packageDir = path.dirname(binDir); // /tmp/node_modules/@bolloon/bolloon-agent
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
32
|
+
// 优先用 package.json 'main' 字段 (v0.2.3+ 指向 dist/cli-entry.js).
|
|
33
|
+
// 真实 npm 安装用户拿到的是编译后的 dist, 不该再依赖 tsx.
|
|
34
|
+
const pkgJsonPath = path.join(packageDir, "package.json");
|
|
35
|
+
if (fs.existsSync(pkgJsonPath)) {
|
|
36
|
+
try {
|
|
37
|
+
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, "utf-8"));
|
|
38
|
+
if (pkg.main) {
|
|
39
|
+
const mainPath = path.join(packageDir, pkg.main);
|
|
40
|
+
if (fs.existsSync(mainPath)) return mainPath;
|
|
41
|
+
}
|
|
42
|
+
} catch {}
|
|
35
43
|
}
|
|
36
|
-
// fallback
|
|
44
|
+
// fallback: 兼容老版本 (dist/index.js 是之前默认)
|
|
45
|
+
const distIndex = path.join(packageDir, "dist", "index.js");
|
|
46
|
+
if (fs.existsSync(distIndex)) return distIndex;
|
|
47
|
+
// 最后才 fallback 到 src (开发环境)
|
|
37
48
|
return path.join(packageDir, "src", "index.ts");
|
|
38
49
|
}
|
|
39
50
|
|
package/dist/agents/pi-sdk.js
CHANGED
|
@@ -34,6 +34,7 @@ import { ReactHarness } from '../security/react-harness.js';
|
|
|
34
34
|
import { parseToolCall as parseToolCallImpl, isFinalResponse as isFinalResponseImpl, extractFinalAnswer as extractFinalAnswerImpl } from './parse-tool-call.js';
|
|
35
35
|
import { sessionStore as defaultSessionStore } from './session-store.js';
|
|
36
36
|
import { ToolRegistry } from './tool-registry.js';
|
|
37
|
+
import { decideMaxIterations, decideContextOverflow, shouldCompactBeforeIteration } from './react-loop.js';
|
|
37
38
|
const SHARED_SESSION_PATH = path.join(process.env.HOME || '/tmp', '.bolloon', 'sessions');
|
|
38
39
|
const PERSONA_PATH = path.join(process.env.HOME || '/tmp', '.bolloon', 'persona.json');
|
|
39
40
|
export class PiSessionManager {
|
|
@@ -2187,10 +2188,12 @@ ${this.getToolDefinitions()}
|
|
|
2187
2188
|
while (iteration < this.MAX_REACT_ITERATIONS) {
|
|
2188
2189
|
iteration++;
|
|
2189
2190
|
// 停止条件 1: max turns (fail-safe 10000, 正常任务永远跑不到)
|
|
2190
|
-
|
|
2191
|
+
// 2026-07-01 (v0.2.4 子任务 1): 委托给 react-loop.decideMaxIterations 纯函数
|
|
2192
|
+
const maxIterDecision = decideMaxIterations(iteration, this.MAX_REACT_ITERATIONS);
|
|
2193
|
+
if (maxIterDecision.shouldExit) {
|
|
2191
2194
|
console.warn(`[PiAgent] 达到最大循环数 ${this.MAX_REACT_ITERATIONS}, 强制终止 (fail-safe)`);
|
|
2192
2195
|
onStream?.({ type: 'error', content: `⏹️ 达到最大循环数 (${this.MAX_REACT_ITERATIONS}, fail-safe)`, tool: 'loop' });
|
|
2193
|
-
finalResponse = finalResponse ||
|
|
2196
|
+
finalResponse = finalResponse || maxIterDecision.finalAnswer;
|
|
2194
2197
|
break;
|
|
2195
2198
|
}
|
|
2196
2199
|
// 停止条件 2: signal.aborted (显式 abort / 用户中断)
|
|
@@ -2217,9 +2220,10 @@ ${this.getToolDefinitions()}
|
|
|
2217
2220
|
}
|
|
2218
2221
|
// 2026-06-16 新增: loop 内自动压缩 — token 超 80% 阈值时跑一次
|
|
2219
2222
|
// compact 失败走 C 路径: 不强行 break, 让现有 60K 阈值兜底 (后面有检查)
|
|
2223
|
+
// 2026-07-01 (v0.2.4 子任务 1): 触发判定走 shouldCompactBeforeIteration 纯函数
|
|
2220
2224
|
const compactThreshold = this.MAX_OUTPUT_TOKEN_ESCALATION_THRESHOLD * this.LOOP_COMPACT_RATIO;
|
|
2221
2225
|
const estimatedTokensBefore = this.estimateHistoryTokens();
|
|
2222
|
-
if (estimatedTokensBefore
|
|
2226
|
+
if (shouldCompactBeforeIteration(estimatedTokensBefore, compactThreshold)) {
|
|
2223
2227
|
const tokensBeforeCompact = estimatedTokensBefore;
|
|
2224
2228
|
console.log(`[PiAgent] loop 入口 token ${tokensBeforeCompact} > ${compactThreshold}, 触发自动压缩`);
|
|
2225
2229
|
onStream?.({ type: 'status', content: `🗜️ loop 自动压缩 (token ${tokensBeforeCompact} > ${compactThreshold})`, tool: 'compactor' });
|
|
@@ -2232,11 +2236,13 @@ ${this.getToolDefinitions()}
|
|
|
2232
2236
|
}
|
|
2233
2237
|
}
|
|
2234
2238
|
// 停止条件 3: context overflow (compact 后还超, 强制终止)
|
|
2239
|
+
// 2026-07-01 (v0.2.4 子任务 1): 委托给 react-loop.decideContextOverflow 纯函数
|
|
2235
2240
|
const estimatedTokens = this.estimateHistoryTokens();
|
|
2236
|
-
|
|
2241
|
+
const overflowDecision = decideContextOverflow(estimatedTokens, this.MAX_OUTPUT_TOKEN_ESCALATION_THRESHOLD);
|
|
2242
|
+
if (overflowDecision.shouldExit) {
|
|
2237
2243
|
console.warn(`[PiAgent] context overflow (${estimatedTokens} tokens > ${this.MAX_OUTPUT_TOKEN_ESCALATION_THRESHOLD})`);
|
|
2238
2244
|
onStream?.({ type: 'error', content: `⏹️ 上下文溢出 (${estimatedTokens} tokens, 阈值 ${this.MAX_OUTPUT_TOKEN_ESCALATION_THRESHOLD})`, tool: 'loop' });
|
|
2239
|
-
finalResponse = finalResponse ||
|
|
2245
|
+
finalResponse = finalResponse || overflowDecision.finalAnswer;
|
|
2240
2246
|
break;
|
|
2241
2247
|
}
|
|
2242
2248
|
// 调试日志:显示每次循环开始
|
|
@@ -78,6 +78,40 @@ export function extractFinalText(reply, marker = '<final gen>') {
|
|
|
78
78
|
export function shouldForceExit(totalErrors, maxTotalErrors) {
|
|
79
79
|
return totalErrors >= maxTotalErrors;
|
|
80
80
|
}
|
|
81
|
+
/**
|
|
82
|
+
* 2026-07-01 (v0.2.4 子任务 1): 迭代上限判定 — 纯函数.
|
|
83
|
+
* 之前 runReActLoop 在循环顶部硬编码 `if (iteration >= MAX_REACT_ITERATIONS)`,
|
|
84
|
+
* 抽出后 claude code 可独立单测 + 替代.
|
|
85
|
+
*/
|
|
86
|
+
export function decideMaxIterations(iteration, maxIterations) {
|
|
87
|
+
return {
|
|
88
|
+
shouldExit: iteration >= maxIterations,
|
|
89
|
+
finalAnswer: iteration >= maxIterations
|
|
90
|
+
? '(本轮 ReAct 循环达到最大步数, 强制结束)'
|
|
91
|
+
: '',
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* 2026-07-01 (v0.2.4 子任务 1): context overflow 判定 — 纯函数.
|
|
96
|
+
* 之前 runReActLoop 紧接 decideMaxIterations 之后硬编码 token 阈值检查.
|
|
97
|
+
* 抽出后行为一致 + 可测.
|
|
98
|
+
*/
|
|
99
|
+
export function decideContextOverflow(estimatedTokens, threshold) {
|
|
100
|
+
return {
|
|
101
|
+
shouldExit: estimatedTokens > threshold,
|
|
102
|
+
finalAnswer: estimatedTokens > threshold
|
|
103
|
+
? `(本轮 ReAct 循环因上下文溢出终止)`
|
|
104
|
+
: '',
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* 2026-07-01 (v0.2.4 子任务 1): compact 触发判定 — 纯函数.
|
|
109
|
+
* 之前 runReActLoop 入口处: estimatedTokens > compactThreshold → 跑 maybeAutoCompact.
|
|
110
|
+
* 抽出纯函数后, claude code 能 dry-run "token X 时该 compact 吗".
|
|
111
|
+
*/
|
|
112
|
+
export function shouldCompactBeforeIteration(estimatedTokens, compactThreshold) {
|
|
113
|
+
return estimatedTokens > compactThreshold;
|
|
114
|
+
}
|
|
81
115
|
/**
|
|
82
116
|
* 同一工具连续失败 N 次, 提示 LLM 不要再用同一个工具 (force final).
|
|
83
117
|
*/
|
package/dist/cli-entry.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
import { spawn } from 'child_process';
|
|
14
14
|
import * as path from 'path';
|
|
15
15
|
import * as fs from 'fs';
|
|
16
|
+
import { fileURLToPath } from 'url';
|
|
16
17
|
const isWindows = process.platform === 'win32';
|
|
17
18
|
// ANSI 颜色
|
|
18
19
|
const RESET = '\x1b[0m';
|
|
@@ -66,8 +67,11 @@ ${BOLD}环境变量:${RESET}
|
|
|
66
67
|
`);
|
|
67
68
|
}
|
|
68
69
|
function getDistDir() {
|
|
69
|
-
//
|
|
70
|
-
|
|
70
|
+
// 2026-07-01: ESM 模块无 __dirname, 用 import.meta.url + path.dirname 拿当前文件所在目录.
|
|
71
|
+
// 对于打包后的应用, 这是 dist 目录; 对于 tsx 跑 src/index.ts 时, 是 src/ 目录
|
|
72
|
+
// (后续 getMainScript 会优先用 dist/cli-entry.js, 不依赖这里的返回值)
|
|
73
|
+
const __filename_esm = fileURLToPath(import.meta.url);
|
|
74
|
+
return path.dirname(__filename_esm);
|
|
71
75
|
}
|
|
72
76
|
function getMainScript() {
|
|
73
77
|
const distDir = getDistDir();
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* input-validator — web 接口输入验证 (v0.2.5)
|
|
3
|
+
*
|
|
4
|
+
* 2026-07-01: 抽到独立模块, claude code / 前端都能 import 验证.
|
|
5
|
+
* - 后端用: POST /api/validate-input 调 validate()
|
|
6
|
+
* - 前端用: 实时 UI 反馈 (发送前预校验, 不阻塞)
|
|
7
|
+
*
|
|
8
|
+
* 验证规则 (bolloon web 当前业务):
|
|
9
|
+
* - text 非空 (trim 后)
|
|
10
|
+
* - text 长度 <= MAX_TEXT_LENGTH
|
|
11
|
+
* - text 不包含 NUL / 不可打印控制字符
|
|
12
|
+
* - channelId 非空
|
|
13
|
+
* - channelName (新建频道) 长度 <= MAX_NAME_LENGTH
|
|
14
|
+
* - 不含 SQL/XSS payload 触发 (sanitize-only: 不是阻止, 是 warn)
|
|
15
|
+
*
|
|
16
|
+
* 设计: 纯函数 + 返回结构化 ValidationResult.
|
|
17
|
+
* { ok: true, reason?, severity? }
|
|
18
|
+
* 严重等级: 'info' | 'warn' | 'block'
|
|
19
|
+
*/
|
|
20
|
+
export const MAX_TEXT_LENGTH = 16_000; // 大约 4K tokens, 单条 user 消息够用
|
|
21
|
+
export const MAX_NAME_LENGTH = 200; // channel name 上限
|
|
22
|
+
export const MAX_AGENT_ID_LENGTH = 200;
|
|
23
|
+
/** 校验单条 /message 请求 (text + channelId) */
|
|
24
|
+
export function validateMessageInput(input) {
|
|
25
|
+
// 1. channelId
|
|
26
|
+
if (typeof input.channelId !== 'string' || input.channelId.trim() === '') {
|
|
27
|
+
return { ok: false, severity: 'block', reason: 'channelId 必填' };
|
|
28
|
+
}
|
|
29
|
+
if (input.channelId.length > 256) {
|
|
30
|
+
return { ok: false, severity: 'block', reason: `channelId 长度超过 256 字符 (${input.channelId.length})` };
|
|
31
|
+
}
|
|
32
|
+
// 2. text
|
|
33
|
+
if (typeof input.text !== 'string') {
|
|
34
|
+
return { ok: false, severity: 'block', reason: 'text 必填且必须是字符串' };
|
|
35
|
+
}
|
|
36
|
+
const trimmed = input.text.trim();
|
|
37
|
+
if (trimmed === '') {
|
|
38
|
+
return { ok: false, severity: 'block', reason: 'text 不能为空 (trim 后)' };
|
|
39
|
+
}
|
|
40
|
+
if (trimmed.length > MAX_TEXT_LENGTH) {
|
|
41
|
+
return {
|
|
42
|
+
ok: false,
|
|
43
|
+
severity: 'block',
|
|
44
|
+
reason: `text 长度 ${trimmed.length} 超过最大 ${MAX_TEXT_LENGTH} 字符`,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
// 3. 控制字符检测 — 拒绝 NUL + 不可打印 char (< 0x20 除了 \n \r \t)
|
|
48
|
+
// eslint-disable-next-line no-control-regex
|
|
49
|
+
const controlCharRe = /[\x00-\x08\x0B-\x0C\x0E-\x1F]/g;
|
|
50
|
+
const controlMatches = trimmed.match(controlCharRe);
|
|
51
|
+
if (controlMatches && controlMatches.length > 0) {
|
|
52
|
+
return {
|
|
53
|
+
ok: false,
|
|
54
|
+
severity: 'block',
|
|
55
|
+
reason: `text 含 ${controlMatches.length} 个不可打印控制字符`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
// 4. 通用安全 check — 大段 HTML/JS 注入 (sanitize 是另一层, 这里只 warn)
|
|
59
|
+
const htmlRe = /<\/?[a-z][\s\S]*>/i;
|
|
60
|
+
if (htmlRe.test(trimmed) && trimmed.length > 500) {
|
|
61
|
+
return {
|
|
62
|
+
ok: true,
|
|
63
|
+
severity: 'warn',
|
|
64
|
+
reason: 'text 包含 HTML 标签 (>500 字符), 确认发送前预览',
|
|
65
|
+
cleaned: trimmed,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return { ok: true, severity: 'info', cleaned: trimmed };
|
|
69
|
+
}
|
|
70
|
+
/** 校验 channel 创建输入 */
|
|
71
|
+
export function validateChannelInput(input) {
|
|
72
|
+
if (typeof input.name !== 'string' || input.name.trim() === '') {
|
|
73
|
+
return { ok: false, severity: 'block', reason: 'name 必填' };
|
|
74
|
+
}
|
|
75
|
+
const trimmedName = input.name.trim();
|
|
76
|
+
if (trimmedName.length > MAX_NAME_LENGTH) {
|
|
77
|
+
return {
|
|
78
|
+
ok: false,
|
|
79
|
+
severity: 'block',
|
|
80
|
+
reason: `name 长度 ${trimmedName.length} 超过最大 ${MAX_NAME_LENGTH}`,
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
if (typeof input.agentId === 'string' && input.agentId.length > MAX_AGENT_ID_LENGTH) {
|
|
84
|
+
return {
|
|
85
|
+
ok: false,
|
|
86
|
+
severity: 'block',
|
|
87
|
+
reason: `agentId 长度 ${input.agentId.length} 超过最大 ${MAX_AGENT_ID_LENGTH}`,
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
return { ok: true, severity: 'info', cleaned: trimmedName };
|
|
91
|
+
}
|
|
92
|
+
const BOOT_TIME = Date.now();
|
|
93
|
+
export function healthCheck(version) {
|
|
94
|
+
return {
|
|
95
|
+
ok: true,
|
|
96
|
+
version,
|
|
97
|
+
uptime_sec: Math.floor((Date.now() - BOOT_TIME) / 1000),
|
|
98
|
+
validators_loaded: [
|
|
99
|
+
'validateMessageInput',
|
|
100
|
+
'validateChannelInput',
|
|
101
|
+
],
|
|
102
|
+
};
|
|
103
|
+
}
|
package/dist/web/server.js
CHANGED
|
@@ -6,6 +6,32 @@ import * as fs from 'fs/promises';
|
|
|
6
6
|
import * as fsSync from 'fs';
|
|
7
7
|
import * as path from 'path';
|
|
8
8
|
import * as os from 'os';
|
|
9
|
+
import { validateMessageInput, validateChannelInput, healthCheck, } from './input-validator.js';
|
|
10
|
+
// 读自身 package.json 拿 version (health endpoint 用)
|
|
11
|
+
// 路径: src/web/server.ts → ../../package.json (编译后 dist/web/server.js)
|
|
12
|
+
let cachedVersion = null;
|
|
13
|
+
function getPackageVersion() {
|
|
14
|
+
if (cachedVersion)
|
|
15
|
+
return cachedVersion;
|
|
16
|
+
try {
|
|
17
|
+
const here = fileURLToPath(import.meta.url);
|
|
18
|
+
const hereDir = path.dirname(here);
|
|
19
|
+
// 试 ../package.json (相对 dist/web/) 或 ../../package.json (相对 src/web/)
|
|
20
|
+
let raw = null;
|
|
21
|
+
for (const rel of ['../package.json', '../../package.json']) {
|
|
22
|
+
try {
|
|
23
|
+
raw = fsSync.readFileSync(path.join(hereDir, rel), 'utf-8');
|
|
24
|
+
break;
|
|
25
|
+
}
|
|
26
|
+
catch { }
|
|
27
|
+
}
|
|
28
|
+
cachedVersion = raw ? (JSON.parse(raw).version ?? '0.0.0') : 'unknown';
|
|
29
|
+
}
|
|
30
|
+
catch {
|
|
31
|
+
cachedVersion = 'unknown';
|
|
32
|
+
}
|
|
33
|
+
return cachedVersion;
|
|
34
|
+
}
|
|
9
35
|
// 2026-06-17: 终端静默 — server.ts 高频 spam (LLM 流式每个 token 一次 broadcast,
|
|
10
36
|
// 每次 P2P 收发 [v3]/[v3-meta]/[v3-cross]/[v3-friend] 各一次) 全部走 console.log proxy,
|
|
11
37
|
// 默认 (BOLLOON_VERBOSE != '1') 完全不打;VERBOSE=1 时恢复.
|
|
@@ -1410,6 +1436,36 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1410
1436
|
}
|
|
1411
1437
|
app.get('/', serveStaticHtml('index.html', 'index.html not found; please run `npm run build:web`', 'index'));
|
|
1412
1438
|
app.get('/api-config', serveStaticHtml('api-config.html', 'api-config.html not found; please run `npm run build:web`', 'api-config'));
|
|
1439
|
+
// 2026-07-01 (v0.2.5): 输入验证 + 健康检查 API
|
|
1440
|
+
// - POST /api/validate-input: 前端发送前预校验, 避免后端 reject
|
|
1441
|
+
// - GET /api/health: liveness probe + validators 清单
|
|
1442
|
+
// 这些端点不依赖 LLM / P2P, 永远可以响应.
|
|
1443
|
+
app.post('/api/validate-input', async (req, res) => {
|
|
1444
|
+
try {
|
|
1445
|
+
const body = req.body ?? {};
|
|
1446
|
+
const kind = body.kind || 'message';
|
|
1447
|
+
let result;
|
|
1448
|
+
if (kind === 'channel') {
|
|
1449
|
+
result = validateChannelInput({ name: body.name, agentId: body.agentId });
|
|
1450
|
+
}
|
|
1451
|
+
else {
|
|
1452
|
+
result = validateMessageInput({ text: body.text, channelId: body.channelId });
|
|
1453
|
+
}
|
|
1454
|
+
res.json({
|
|
1455
|
+
ok: result.ok,
|
|
1456
|
+
severity: result.severity ?? (result.ok ? 'info' : 'block'),
|
|
1457
|
+
reason: result.reason,
|
|
1458
|
+
cleaned: result.cleaned,
|
|
1459
|
+
kind,
|
|
1460
|
+
});
|
|
1461
|
+
}
|
|
1462
|
+
catch (e) {
|
|
1463
|
+
res.status(400).json({ ok: false, severity: 'block', reason: e?.message ?? 'parse error' });
|
|
1464
|
+
}
|
|
1465
|
+
});
|
|
1466
|
+
app.get('/api/health', (_req, res) => {
|
|
1467
|
+
res.json(healthCheck(getPackageVersion()));
|
|
1468
|
+
});
|
|
1413
1469
|
// 全局兜底: 任何 next(err) 走到这里, 给出结构化 4xx/5xx 而不是默认 HTML
|
|
1414
1470
|
app.use((err, req, res, _next) => {
|
|
1415
1471
|
console.error('[server] unhandled error on', req.method, req.path, '-', err?.message || err);
|
|
@@ -1480,8 +1536,11 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1480
1536
|
runState.running = true;
|
|
1481
1537
|
runState.abortController = new AbortController();
|
|
1482
1538
|
broadcastQueueUpdate(channelId);
|
|
1539
|
+
// 2026-07-01 (v0.2.5): hoist sessionKey 到 try 外, 让 finally 块的 saveCurrentSession 能用
|
|
1540
|
+
const sessionKey = `${channelId}:${currentSessionId}`;
|
|
1541
|
+
let agent = null;
|
|
1483
1542
|
try {
|
|
1484
|
-
|
|
1543
|
+
agent = await getAgentForChannel(channelId, realChannelDid, realChannelName, realChannelDidDoc);
|
|
1485
1544
|
let fullResponse = '';
|
|
1486
1545
|
// P0.5: 注入门回传的 usedIds, 落 session message metadata, UI 可查
|
|
1487
1546
|
let usedJudgmentIds = [];
|
|
@@ -1816,6 +1875,18 @@ export async function createWebServer(port = 3000, options = {}) {
|
|
|
1816
1875
|
runState.running = false;
|
|
1817
1876
|
runState.abortController = null;
|
|
1818
1877
|
broadcastQueueUpdate(channelId);
|
|
1878
|
+
// 2026-07-01 (v0.2.5): 持久化当前 messageHistory — 让 web 用户跨刷新保留对话.
|
|
1879
|
+
// saveCurrentSession 失败静默, 不阻塞 channel 状态清理.
|
|
1880
|
+
// saveCurrentSession 内部走 SessionStore (默认 ~/.bolloon/sessions/cache/<sessionKey>.json).
|
|
1881
|
+
// agent 可能在 try 抛错后仍为 null (getAgentForChannel 失败), null-guard 跳过 save.
|
|
1882
|
+
if (agent) {
|
|
1883
|
+
try {
|
|
1884
|
+
await agent.saveCurrentSession(sessionKey);
|
|
1885
|
+
}
|
|
1886
|
+
catch (saveErr) {
|
|
1887
|
+
console.warn(`[web] saveCurrentSession failed (non-fatal): ${saveErr?.message?.slice(0, 100)}`);
|
|
1888
|
+
}
|
|
1889
|
+
}
|
|
1819
1890
|
}
|
|
1820
1891
|
});
|
|
1821
1892
|
// ---------- 频道元数据后台修复队列 ----------
|