@bolloon/bolloon-agent 0.4.4 → 0.4.6

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.
@@ -34,6 +34,43 @@ export const SIDE_EFFECT_TOOLS = new Set([
34
34
  'write_file', 'edit_file', 'shell_exec', 'git_commit', 'git_push', 'git_branch',
35
35
  'create_task', 'update_task',
36
36
  ]);
37
+ export async function runTerminalCommand(raw, opts = {}) {
38
+ const { checkTerminalCommand } = await import('./shell-guard.js');
39
+ const timeoutMs = opts.timeoutMs ?? 30000;
40
+ const list = (opts.commands && opts.commands.length > 0) ? opts.commands : [raw];
41
+ for (const c of list) {
42
+ const guard = checkTerminalCommand(String(c || '').trim());
43
+ if (!guard.allowed) {
44
+ return { success: false, error: `[terminal-guard] ${guard.reason}`, deniedByGuard: true };
45
+ }
46
+ }
47
+ const { exec } = await import('child_process');
48
+ const runOne = (cmdStr) => new Promise((resolve) => {
49
+ exec(cmdStr, {
50
+ cwd: opts.cwd ?? process.cwd(),
51
+ timeout: timeoutMs,
52
+ maxBuffer: 8 * 1024 * 1024,
53
+ env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
54
+ }, (err, stdout, stderr) => {
55
+ const output = ((stdout || '') + (stderr ? `\n[stderr]\n${stderr}` : '')).trim().slice(0, 8000);
56
+ if (err) {
57
+ resolve({ success: false, cmd: cmdStr.slice(0, 120), error: `exit ${err.code ?? '?'}: ${String(err.message || '').slice(0, 200)}`, output: output || undefined });
58
+ }
59
+ else {
60
+ resolve({ success: true, cmd: cmdStr.slice(0, 120), output: output || '(无输出)' });
61
+ }
62
+ });
63
+ });
64
+ if (list.length === 1)
65
+ return runOne(list[0]);
66
+ const results = await Promise.all(list.map((c) => runOne(c)));
67
+ const failed = results.filter((r) => !r.success);
68
+ const allOutput = results.map((r) => `${r.cmd}\n${r.output || r.error || ''}`).join('\n\n---\n\n');
69
+ if (failed.length > 0) {
70
+ return { success: false, error: `${failed.length}/${list.length} 条命令失败: ${failed[0].error}`, output: allOutput, partial: results };
71
+ }
72
+ return { success: true, output: allOutput, count: list.length, parallel: true };
73
+ }
37
74
  export function registerBuiltinTools(ctx) {
38
75
  ctx.tools.set('read_document', {
39
76
  name: 'read_document',
@@ -621,17 +658,22 @@ export function registerBuiltinTools(ctx) {
621
658
  for (const tool of p2pDocumentTools) {
622
659
  ctx.tools.set(tool.name, tool);
623
660
  }
624
- // shell_exec
661
+ // 2026-08-12 (Task2): shell_exec / terminal 统一走模块级 runTerminalCommand (宽松护栏 + 多命令并行).
662
+ // shell_exec — 2026-08-12 (Task2): 与 terminal 统一走宽松护栏 (denylist-only).
663
+ // 兼容旧格式 (command + args 数组), 内部转成完整命令字符串交给 runTerminal,
664
+ // 不再用窄白名单 → 模型不会因 "command 不在白名单" 报错.
625
665
  ctx.tools.set('shell_exec', {
626
666
  name: 'shell_exec',
627
- description: ' cwd 跑 shell 命令. 仅支持白名单内命令: git, npm, npx, tsx, tsc, vitest, cat, head, tail, ls, wc, echo, pwd, date, mkdir, touch. 禁止管道/重定向/rm -rf/sudo. 命中护栏黑名单会被拒.',
628
- parameters: { command: '可执行文件 (必填, 必须在白名单)', args: '参数数组, 逗号分隔', timeoutMs: '超时毫秒, 默认 30000' },
667
+ description: '执行 shell 命令 (兼容模式, 参数数组或命令字符串均可). 护栏只挡高危破坏操作 (sudo/格式化/rm -rf 根目录/写 ~/.bolloon 数据). 推荐直接用 terminal 传完整命令字符串.',
668
+ parameters: { command: '可执行文件 或 完整命令 (必填)', args: '参数数组, 逗号分隔 (可选)', timeoutMs: '超时毫秒, 默认 30000' },
629
669
  execute: async (args) => {
630
670
  const cmd = String(args.command || '').trim();
631
671
  if (!cmd)
632
672
  return { success: false, error: 'command 必填' };
633
- let argList = [];
673
+ // 兼容参数数组 拼成命令字符串
674
+ let full = cmd;
634
675
  const rawArgs = args.args;
676
+ let argList = [];
635
677
  if (Array.isArray(rawArgs)) {
636
678
  argList = rawArgs.map((s) => String(s).trim()).filter(Boolean);
637
679
  }
@@ -640,61 +682,36 @@ export function registerBuiltinTools(ctx) {
640
682
  if (trimmed.startsWith('[') && trimmed.endsWith(']')) {
641
683
  try {
642
684
  const parsed = JSON.parse(trimmed);
643
- if (Array.isArray(parsed)) {
685
+ if (Array.isArray(parsed))
644
686
  argList = parsed.map((s) => String(s).trim()).filter(Boolean);
645
- }
646
687
  }
647
- catch { /* fall through to comma split */ }
688
+ catch { /* JSON 数组 */ }
648
689
  }
649
- if (argList.length === 0) {
690
+ if (argList.length === 0)
650
691
  argList = trimmed.split(',').map(s => s.trim()).filter(Boolean);
651
- }
652
- }
653
- const timeoutMs = Number(args.timeoutMs) || 30000;
654
- const result = await shellExec(cmd, argList, { timeoutMs });
655
- if (result.deniedByGuard) {
656
- return { success: false, error: result.error };
657
692
  }
658
- if (!result.success) {
659
- return { success: false, error: result.error, output: result.output };
693
+ // command 只是可执行文件名且带 args → 拼成 "cmd arg1 arg2"; 否则原样
694
+ if (argList.length > 0 && !/\s/.test(cmd) && !cmd.includes('&&') && !cmd.includes(';') && !cmd.includes('|')) {
695
+ full = `${cmd} ${argList.map(a => (/[\s"&|<>^()%!`]/.test(a) ? `"${a.replace(/"/g, '""')}"` : a)).join(' ')}`;
660
696
  }
661
- return { success: true, output: result.output };
697
+ const timeoutMs = Number(args.timeoutMs) || 30000;
698
+ return await runTerminalCommand(full, { timeoutMs, cwd: ctx.cwd });
662
699
  }
663
700
  });
664
701
  // 2026-08-10: terminal — 灵活终端写命令 (用户要求: bolloon 自己写命令进 terminal, 少围栏).
665
- // shell_exec 的区别: 接受**完整 shell 命令字符串** (管道/重定向/写文件都行),
666
- // 护栏只挡高危破坏模式 (checkTerminalCommand: 提权/格式化/删根/.bolloon 数据), 其余放行.
702
+ // 2026-08-12 (Task2): 支持 commands 数组并行执行; shell_exec 统一走 runTerminalCommand.
703
+ // shell_exec 的区别: 直接接受完整 shell 命令字符串, 更适合模型自主写命令.
667
704
  ctx.tools.set('terminal', {
668
705
  name: 'terminal',
669
- description: '执行完整 shell 命令 (支持管道/重定向/写文件/跑脚本). 护栏只挡高危破坏操作 (sudo/格式化/rm -rf 根目录/写 ~/.bolloon 数据), 其余灵活放行. 适合: 写 HTML 文件、跑 python/node 脚本、查系统状态、装依赖.',
670
- parameters: { command: '完整 shell 命令 (必填, 如: echo "<html>" > /tmp/site/index.html && ls /tmp/site)', timeoutMs: '超时毫秒, 默认 30000' },
706
+ description: '执行完整 shell 命令 (支持管道/重定向/写文件/跑脚本). 护栏只挡高危破坏操作 (sudo/格式化/rm -rf 根目录/写 ~/.bolloon 数据), 其余灵活放行. 适合: 写 HTML 文件、跑 python/node 脚本、查系统状态、装依赖. 多条命令用 commands 数组并行执行.',
707
+ parameters: { command: '完整 shell 命令 (必填, 如: echo "<html>" > /tmp/site/index.html && ls /tmp/site)', commands: '可选: 多条命令数组 (并行执行), 每条独立字符串', timeoutMs: '超时毫秒, 默认 30000' },
671
708
  execute: async (args) => {
672
709
  const raw = String(args.command || '').trim();
673
- if (!raw)
674
- return { success: false, error: 'command 必填' };
675
- const { checkTerminalCommand } = await import('./shell-guard.js');
676
- const guard = checkTerminalCommand(raw);
677
- if (!guard.allowed) {
678
- return { success: false, error: `[terminal-guard] ${guard.reason}`, deniedByGuard: true };
679
- }
680
- const { exec } = await import('child_process');
710
+ const commands = Array.isArray(args.commands) ? args.commands.map((c) => String(c || '').trim()).filter(Boolean) : [];
711
+ if (!raw && commands.length === 0)
712
+ return { success: false, error: 'command 或 commands 必填' };
681
713
  const timeoutMs = Number(args.timeoutMs) || 30000;
682
- return new Promise((resolve) => {
683
- exec(raw, {
684
- cwd: process.cwd(),
685
- timeout: timeoutMs,
686
- maxBuffer: 8 * 1024 * 1024,
687
- env: { ...process.env, GIT_TERMINAL_PROMPT: '0' },
688
- }, (err, stdout, stderr) => {
689
- const output = ((stdout || '') + (stderr ? `\n[stderr]\n${stderr}` : '')).trim().slice(0, 8000);
690
- if (err) {
691
- resolve({ success: false, error: `exit ${err.code ?? '?'}: ${String(err.message || '').slice(0, 200)}`, output: output || undefined });
692
- }
693
- else {
694
- resolve({ success: true, output: output || '(无输出)' });
695
- }
696
- });
697
- });
714
+ return await runTerminalCommand(raw, { timeoutMs, cwd: ctx.cwd, commands: commands.length > 0 ? commands : undefined });
698
715
  }
699
716
  });
700
717
  // self_improve
@@ -201,6 +201,15 @@ export class PiAgentSession {
201
201
  currentSignal = null;
202
202
  /** Bootstrap SessionStart 拼的 system prompt 片段 (用完即清) */
203
203
  bootstrapAddition = '';
204
+ /** 2026-08-12 (Task3 认知卸载): 工具选择与认知卸载指南 — 注入 system prompt, 降低"模型不触发 write/edit/read"率. */
205
+ static TOOL_SELECTION_GUIDE = `【工具选择与认知卸载指南 (严格遵循)】
206
+ - 写/改文件: 必须用 write_file / edit_file (不要用 terminal 拼字符串写文件).
207
+ - 读文件: 用 read_file / read_directory / list_files.
208
+ - 跑命令/查状态/装依赖/跑脚本: 用 terminal.
209
+ - 执行 git 操作: 用 git_* 专用工具 (git_diff/git_commit/git_push/git_branch/git_log).
210
+ - 查看/调用技能: 用 list_skills / use_skill.
211
+ - 认知卸载: 当任务对你过大或反复失败时, 不要死磕 — 用 delegate_to_engine 把编码/复杂任务委派给外部引擎 (codex/claude-code/opencode), 或把目标拆小分步完成.
212
+ - 每步只选一个最合适的工具, 先读后写, 写完验证 (跑 tsc/vitest 或读回文件).`;
204
213
  /** 当前 prompt 开始时间 (供 Stop hook 计算 durationMs) */
205
214
  promptStartTime = 0;
206
215
  /** 当前 channel id (由 getAgentForChannel / prompt 4 参注入, 供 hook / log 使用) */
@@ -1055,6 +1064,8 @@ ${this.currentIntentHint}
1055
1064
 
1056
1065
  ${this.getToolDefinitions()}
1057
1066
 
1067
+ ${PiAgentSession.TOOL_SELECTION_GUIDE}
1068
+
1058
1069
  工作模式:
1059
1070
  1. 理解用户自然语言请求
1060
1071
  2. 分析需要哪些工具来完成
@@ -1295,6 +1306,8 @@ ${loopProgressSection}
1295
1306
 
1296
1307
  ${toolDefs}
1297
1308
 
1309
+ ${PiAgentSession.TOOL_SELECTION_GUIDE}
1310
+
1298
1311
  工作模式:
1299
1312
  1. 理解用户自然语言请求
1300
1313
  2. 分析需要哪些工具来完成
@@ -266,9 +266,21 @@ export async function writeRunEndSkillCandidates(steps, source, minOk = 2) {
266
266
  return { wrote: false, reason: `成功工具不足 (${okSteps.length} < ${minOk})` };
267
267
  }
268
268
  const toolNames = okSteps.map((s) => s.name).slice(0, 5).join(', ');
269
- const body = `## 背景\n本轮对话连续成功调用了 ${okSteps.length} 个工具: ${toolNames}.\n\n` +
270
- `## 流程\n${okSteps.map((s) => `1. 调用 ${s.name}${s.output ? ': ' + String(s.output).slice(0, 120) : ''}`).join('\n')}\n\n` +
271
- `## 注意事项\n- 工具名以 list_skills / get_operation_logs 的实际注册名为准\n- 沉淀为正式 skill 前请人工确认流程可复用\n`;
269
+ // 2026-08-12 (Task5): 让 run-end 候选 body 更可复用 结构化呈现调用链 + 每步作用 + 适用场景.
270
+ const stepLines = okSteps.map((s, i) => {
271
+ const out = s.output ? String(s.output).replace(/\s+/g, ' ').slice(0, 140) : '';
272
+ return `${i + 1}. **${s.name}**${out ? ` — ${out}` : ''}`;
273
+ }).join('\n');
274
+ const body = `## 适用场景\n用户请求需要连续执行工具序列: ${toolNames}.\n\n` +
275
+ `## 调用链\n${stepLines}\n\n` +
276
+ `## 流程要点\n${okSteps.map((s, i) => {
277
+ // 2026-08-12 (Task5 优化): 用真实输出提炼每步作用, 避免千篇一律的"调用 X 获取下一步输入".
278
+ const out = s.output ? String(s.output).replace(/\s+/g, ' ').trim() : '';
279
+ const clue = out && out.length > 30 ? out.slice(0, 60) + '…' : (out || '完成');
280
+ return `- 第 ${i + 1} 步 ${s.name}: ${clue}`;
281
+ }).join('\n')}\n\n` +
282
+ `## 如何验证\n- 涉及改代码: 跑 tsc + vitest 确认无错; 涉及写文件: read_file 读回校验.\n` +
283
+ `- 沉淀为正式 skill 前请人工确认流程可复用, 再 list_skill_candidates / promote_skill 转正\n`;
272
284
  // 2026-08-08: 稳定签名 + 固定文件名 → 同一套工具反复跑时合并更新到同一个候选 (runs++)
273
285
  const signature = toolSignature(okSteps);
274
286
  const candName = `auto-${signature}`;
@@ -193,6 +193,16 @@ export class WorkflowPivotLoop {
193
193
  * 传原生 tools + tool_choice auto → LLM 返回结构化 tool_calls.
194
194
  */
195
195
  buildOpenAITools() {
196
+ // 2026-08-12 (Task3 认知卸载): 核心编码工具加强 description, 提升模型触发率.
197
+ const USAGE_HINT = {
198
+ write_file: '写/创建文件用此工具 (改代码首选). ',
199
+ edit_file: '修改文件指定内容用此工具 (改代码首选). ',
200
+ read_file: '读取文件内容用此工具. ',
201
+ read_directory: '列目录用此工具. ',
202
+ list_files: '列出文件用此工具. ',
203
+ terminal: '跑 shell 命令/脚本/装依赖/查状态用此工具 (不要用它写文件). ',
204
+ delegate_to_engine: '任务过大或复杂时委派给外部编码引擎 (认知卸载). ',
205
+ };
196
206
  const out = [];
197
207
  for (const [name, tool] of this.tools) {
198
208
  const params = tool.parameters || {};
@@ -207,7 +217,7 @@ export class WorkflowPivotLoop {
207
217
  type: 'function',
208
218
  function: {
209
219
  name,
210
- description: tool.description || name,
220
+ description: (USAGE_HINT[name] || '') + (tool.description || name),
211
221
  parameters: { type: 'object', properties, required },
212
222
  },
213
223
  });
@@ -54,7 +54,7 @@ export function getSessionCursorPath(agentId, channelId, sessionId, home) {
54
54
  /**
55
55
  * Session 缓存文件路径 (跟 server.ts:1806 sessionKey 规则一致)
56
56
  */
57
- function getSessionCacheFile(channelId, sessionId, home) {
57
+ export function getSessionCacheFile(channelId, sessionId, home) {
58
58
  const root = path.join(home || os.homedir(), '.bolloon', 'sessions', 'cache');
59
59
  const safeChannel = sanitizeKey(channelId);
60
60
  const safeSession = sanitizeKey(sessionId).replace(/:/g, '__');
@@ -293,11 +293,23 @@ const InkApp = ({ onPrompt, initialStatus, getStatusUpdate, terminalW, terminalH
293
293
  globalThis.__inkSetTransient = (v) => {
294
294
  setTransient(v === undefined ? null : v);
295
295
  };
296
+ // 2026-08-12 (Task4): 原地替换最后一条消息 (命令加载态 → 完成态用).
297
+ // 不命中则追加 (兼容旧逻辑).
298
+ globalThis.__inkReplaceLast = (line) => {
299
+ setMsgs(prev => {
300
+ if (prev.length === 0)
301
+ return [...prev, line];
302
+ const next = prev.slice();
303
+ next[next.length - 1] = line;
304
+ return next;
305
+ });
306
+ };
296
307
  return () => {
297
308
  delete globalThis.__inkAppend;
298
309
  delete globalThis.__inkSetStatus;
299
310
  delete globalThis.__inkSetThinking;
300
311
  delete globalThis.__inkSetTransient;
312
+ delete globalThis.__inkReplaceLast;
301
313
  };
302
314
  }, []);
303
315
  const onSubmit = useCallback((value) => {
@@ -574,6 +586,12 @@ export function inkAppendLine(line) {
574
586
  if (fn)
575
587
  fn(line);
576
588
  }
589
+ /** 2026-08-12 (Task4): 原地替换最后一条消息 (命令加载态 → 完成态). 无消息时追加. */
590
+ export function inkReplaceLastLine(line) {
591
+ const fn = globalThis.__inkReplaceLast;
592
+ if (fn)
593
+ fn(line);
594
+ }
577
595
  export function inkSetStatus(s) {
578
596
  const fn = globalThis.__inkSetStatus;
579
597
  if (fn)
package/dist/index.js CHANGED
@@ -16,7 +16,7 @@ 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 } from './cli/loading-tui.js';
19
- import { startInk, stopInk, inkAppendLine as appendLine, inkSetStatus, inkSetThinking, inkSetTransient } from './cli/ink-app.js';
19
+ import { startInk, stopInk, inkAppendLine as appendLine, inkReplaceLastLine as replaceLastLine, inkSetStatus, inkSetThinking, inkSetTransient } from './cli/ink-app.js';
20
20
  // 启动自动检查更新:后台、节流、检测到新版本自动安装(可被 --no-update / BOLLOON_SKIP_UPDATE 关闭)
21
21
  import { createRequire } from 'module';
22
22
  const _require = createRequire(import.meta.url);
@@ -423,6 +423,9 @@ let cliStartTime = 0;
423
423
  let cliModelName = '…';
424
424
  let cliAgentName = '…';
425
425
  let cliActiveChannelId = null;
426
+ // 2026-08-12: 当前 active channel 的 agentId (如 agent-alice). memory 落盘按 agentId 存,
427
+ // /memory /resume /did 读路径必须用 agentId 而非 display name (cliAgentName), 否则路径不一致读不到.
428
+ let cliAgentId = null;
426
429
  // 2026-08-10: CLI 自动整理心跳 (与社交心跳并列, 独立于 server) — 退出时 stop
427
430
  let cliOrganizeHeartbeat = null;
428
431
  function fmtDuration(ms) {
@@ -435,6 +438,13 @@ function fmtDuration(ms) {
435
438
  const h = Math.floor(m / 60);
436
439
  return `${h}h ${m % 60}m`;
437
440
  }
441
+ /** 2026-08-12: 返回当前 agentId (memory 路径用). 优先 cliAgentId, fallback cliAgentName. */
442
+ function getCliAgentId() {
443
+ if (cliAgentId)
444
+ return cliAgentId;
445
+ // 无显式 agentId 时退到 cliAgentName (harness 默认 'agent', 其余用名字)
446
+ return cliAgentName === 'bolloon' ? 'agent' : (cliAgentName || 'agent');
447
+ }
438
448
  /** 2026-08-06: 从 ContextManager 读上下文用量 (CLI 状态栏数据源, 失败退化 0/1M) */
439
449
  // 2026-08-07: 不能用 require 加载 ESM 模块 (ERR_REQUIRE_ESM) → startCLI 里 await import 一次缓存引用,
440
450
  // 同步函数 getCliCtxUsage/getStatus 复用 — 之前裸 require 与 _require 都抛错被 catch → 状态栏恒 0/1M
@@ -563,6 +573,9 @@ async function startCLI(comm) {
563
573
  if (active) {
564
574
  cliAgentName = active.name;
565
575
  cliActiveChannelId = active.channelId ?? null;
576
+ // 2026-08-12: 同步 agentId (memory 路径一致) — 从 rawChannels 按 channelId 取 agentId
577
+ const raw = active.channelId ? store.rawChannels.find((c) => c.id === active.channelId) : undefined;
578
+ cliAgentId = raw?.agentId || null;
566
579
  }
567
580
  }
568
581
  catch {
@@ -692,23 +705,36 @@ async function processInput(input, comm) {
692
705
  const runEndOkSteps = [];
693
706
  // each iteration
694
707
  let lastToolEvent = null;
695
- // !command — 直接执行终端命令
708
+ // !command — 直接执行终端命令. 2026-08-12 (Task4): 支持多命令 (&& / ;) 逐段顺序执行 + 加载显示.
696
709
  if (trimmed.startsWith('!')) {
697
710
  const cmd = trimmed.slice(1).trim();
698
711
  if (!cmd) {
699
- appendLine(`${C_DIM}!<命令> 执行终端命令, 如 !ls -la${RESET}`);
712
+ appendLine(`${C_DIM}!<命令> 执行终端命令 (支持 && 串联多命令), 如 !ls -la${RESET}`);
700
713
  return;
701
714
  }
702
- appendLine(`${C_DIM}── $ ${cmd}${RESET}`);
715
+ const { execSync } = await import('child_process');
716
+ // 拆成多段: && 逻辑与 (前失败则停) / ; 无条件顺序. 保留每段顺序执行, 显示加载态.
717
+ const segments = cmd.split(/\s*;\s*/).filter(Boolean);
703
718
  try {
704
- const { execSync } = await import('child_process');
705
- const out = execSync(cmd, { timeout: 30000, encoding: 'utf-8', cwd: process.cwd() });
706
- appendLine(`${C_DIM}${out || '(无输出)'}${RESET}`);
719
+ for (const seg of segments) {
720
+ const segCmds = seg.split(/\s*&&\s*/).filter(Boolean);
721
+ for (const c of segCmds) {
722
+ appendLine(`${C_DIM}── $ ${c}${RESET}`);
723
+ try {
724
+ const out = execSync(c, { timeout: 30000, encoding: 'utf-8', cwd: process.cwd() });
725
+ appendLine(`${C_DIM}${out || '(无输出)'}${RESET}`);
726
+ }
727
+ catch (e) {
728
+ appendLine(`${C_ERROR}${e.stderr || e.message}${RESET}`);
729
+ // && 逻辑与: 某段失败则中止后续 && 段
730
+ break;
731
+ }
732
+ }
733
+ }
707
734
  }
708
- catch (e) {
709
- appendLine(`${C_ERROR}${e.stderr || e.message}${RESET}`);
735
+ finally {
736
+ appendLine(`${C_DIM}──${RESET}`);
710
737
  }
711
- appendLine(`${C_DIM}──${RESET}`);
712
738
  return;
713
739
  }
714
740
  // /channel — 切换当前智能体 (agent channel), 参数 name/id/number 自动解析
@@ -743,6 +769,7 @@ async function processInput(input, comm) {
743
769
  await store.setActive(r.channel.id);
744
770
  cliAgentName = r.identity.name;
745
771
  cliActiveChannelId = r.channel.id;
772
+ cliAgentId = r.channel.agentId || null; // 2026-08-12: memory 路径一致
746
773
  // 2026-08-09: 切 channel 必须重建 agent session — 否则身份/记忆停留在旧 channel (bug 修复)
747
774
  invalidateAgent();
748
775
  // 立即重建 (提前建好, 避免下次输入才卡顿; 失败不阻塞切换)
@@ -809,6 +836,7 @@ async function processInput(input, comm) {
809
836
  await store.setActive(id);
810
837
  cliAgentName = name.trim();
811
838
  cliActiveChannelId = id;
839
+ cliAgentId = agentId; // 2026-08-12: memory 路径一致
812
840
  // 2026-08-09: 新建 agent 后立即重建 session — 否则新 agent 身份不加载 (bug 修复)
813
841
  invalidateAgent();
814
842
  try {
@@ -1107,7 +1135,7 @@ async function processInput(input, comm) {
1107
1135
  const { getMemoryDir } = await import('./bootstrap/memory-compressor.js');
1108
1136
  const { readdir, readFile } = await import('fs/promises');
1109
1137
  const { join } = await import('path');
1110
- const dir = getMemoryDir(cliAgentName === 'bolloon' ? 'agent' : cliAgentName);
1138
+ const dir = getMemoryDir(getCliAgentId());
1111
1139
  const files = (await readdir(join(dir, 'sessions')).catch(() => [])).filter((f) => f.endsWith('.summary.md'));
1112
1140
  appendLine(`${C_ACCENT}记忆摘要 (${files.length} 个 session):${RESET}`);
1113
1141
  for (const f of files.slice(-5)) {
@@ -1126,10 +1154,10 @@ async function processInput(input, comm) {
1126
1154
  // /resume — 恢复: 最近记忆摘要 + 进行中计划
1127
1155
  if (cmd === '/resume' || cmd.startsWith('/resume ')) {
1128
1156
  try {
1129
- const { getMemoryDir, getSessionSummaryPath } = await import('./bootstrap/memory-compressor.js');
1157
+ const { getMemoryDir } = await import('./bootstrap/memory-compressor.js');
1130
1158
  const { readFile, readdir } = await import('fs/promises');
1131
1159
  const { join } = await import('path');
1132
- const dir = getMemoryDir(cliAgentName === 'bolloon' ? 'agent' : cliAgentName);
1160
+ const dir = getMemoryDir(getCliAgentId());
1133
1161
  const files = (await readdir(join(dir, 'sessions')).catch(() => [])).filter((f) => f.endsWith('.summary.md'));
1134
1162
  appendLine(`${C_ACCENT}↻ 恢复上下文:${RESET}`);
1135
1163
  if (files.length > 0) {
@@ -1286,6 +1314,50 @@ async function processInput(input, comm) {
1286
1314
  catch { /* 静默 */ }
1287
1315
  return;
1288
1316
  }
1317
+ // /skills [名] — 查看正式技能 (2026-08-12 Task5): 无参列全部, 带名看详情. 运行时开始前的技能 view.
1318
+ if (cmd === '/skills' || cmd.startsWith('/skills ')) {
1319
+ try {
1320
+ const { defaultSkillPaths, loadSkillsDir } = await import('./agents/skill-loader.js');
1321
+ const dirs = defaultSkillPaths();
1322
+ const metas = [];
1323
+ for (const d of dirs) {
1324
+ const m = await loadSkillsDir(d);
1325
+ for (const s of m)
1326
+ if (s.status === 'active' && !metas.some(x => x.name === s.name))
1327
+ metas.push(s);
1328
+ }
1329
+ const q = cmd.startsWith('/skills ') ? cmd.slice('/skills '.length).trim().toLowerCase() : '';
1330
+ if (!q) {
1331
+ appendLine(`${C_ACCENT}技能 (${metas.length}):${RESET}`);
1332
+ if (metas.length === 0)
1333
+ appendLine(` ${C_DIM}暂无正式技能 — run-end 经验可沉淀为 skill${RESET}`);
1334
+ for (const s of metas.slice(0, 20)) {
1335
+ const desc = (s.description || '').slice(0, 60);
1336
+ appendLine(` ${C_DIM}·${RESET} ${C_ACCENT}${s.name}${RESET}${desc ? ` ${C_DIM}${desc}${RESET}` : ''}`);
1337
+ }
1338
+ appendLine(`${C_DIM}用法: /skills <名> 查看详情${RESET}`);
1339
+ }
1340
+ else {
1341
+ const hit = metas.find(s => s.name.toLowerCase() === q || s.name.toLowerCase().includes(q));
1342
+ if (!hit) {
1343
+ appendLine(`${C_WARN}未找到技能: '${q}'${RESET}`);
1344
+ return;
1345
+ }
1346
+ appendLine(`${C_ACCENT}═ ${hit.name} ═${RESET}`);
1347
+ if (hit.description)
1348
+ appendLine(` ${C_DIM}描述:${RESET} ${hit.description}`);
1349
+ if (hit.triggers && hit.triggers.length > 0)
1350
+ appendLine(` ${C_DIM}触发:${RESET} ${hit.triggers.join(', ')}`);
1351
+ const body = (hit.body || '').trim().slice(0, 1200);
1352
+ if (body)
1353
+ appendLine(` ${C_DIM}---${RESET}\n${body}`);
1354
+ }
1355
+ }
1356
+ catch (e) {
1357
+ appendLine(`${C_ERROR}/skills 失败: ${String(e?.message || e).slice(0, 120)}${RESET}`);
1358
+ }
1359
+ return;
1360
+ }
1289
1361
  // /mcp — MCP 插件/工具列表
1290
1362
  if (cmd === '/mcp') {
1291
1363
  try {
@@ -1324,7 +1396,7 @@ async function processInput(input, comm) {
1324
1396
  if (cmd === '/did') {
1325
1397
  try {
1326
1398
  const { loadOrCreateAgentIdentity } = await import('./agents/agent-identity.js');
1327
- const identity = loadOrCreateAgentIdentity(cliAgentName === 'bolloon' ? 'default-agent' : cliAgentName);
1399
+ const identity = loadOrCreateAgentIdentity(getCliAgentId());
1328
1400
  appendLine(`${C_ACCENT}DID 身份:${RESET}`);
1329
1401
  appendLine(` ${C_DIM}did:${RESET} ${identity.did}`);
1330
1402
  appendLine(` ${C_DIM}publicKey:${RESET} ${identity.publicKey?.slice(0, 32) || '—'}...`);
@@ -1649,6 +1721,7 @@ async function processInput(input, comm) {
1649
1721
  appendLine(` ${C_ACCENT}/todo${RESET} 查看/勾选循环步骤 ${C_DIM}/todo <planId> <序号>${RESET}`);
1650
1722
  appendLine(` ${C_ACCENT}/tools${RESET} 可用工具列表 (名/参数/简介)`);
1651
1723
  appendLine(` ${C_ACCENT}/skill${RESET} 技能候选 ${C_DIM}skill-writer 沉淀候选${RESET}`);
1724
+ appendLine(` ${C_ACCENT}/skills${RESET} 查看正式技能 ${C_DIM}/skills <名> 看详情 (运行时开始前 view)${RESET}`);
1652
1725
  appendLine(` ${C_ACCENT}/mcp${RESET} MCP 服务器列表`);
1653
1726
  appendLine(` ${C_ACCENT}/agent${RESET} 当前智能体身份`);
1654
1727
  appendLine(` ${C_ACCENT}/did${RESET} DID 身份`);
@@ -1753,15 +1826,8 @@ async function processInput(input, comm) {
1753
1826
  // 真正思考内容在 status 的 Reflection/💡 事件 → 下方框渲染
1754
1827
  }
1755
1828
  else if (e.phase && !e.type) {
1756
- const ph = String(e.phase);
1757
- const detail = e.detail ? ` (${String(e.detail).slice(0, 60)})` : '';
1758
- const phLabel = {
1759
- intent_classified: '意图识别',
1760
- tool_selected: '工具选择',
1761
- reflection: '反思',
1762
- planning: '规划',
1763
- };
1764
- appendLine(`${C_WARN}◈ ${phLabel[ph] || ph}${detail}${RESET}`);
1829
+ // 2026-08-12 (Task4): phase (意图识别/工具选择/规划) 是模型内部规划过程,
1830
+ // 用户偏好"中间过程不显示" 静默丢弃, 不污染终端. (仅 Reflection 框保留, status 分支)
1765
1831
  }
1766
1832
  else if (e.type === 'status' && e.content) {
1767
1833
  const content = String(e.content);
@@ -1771,13 +1837,24 @@ async function processInput(input, comm) {
1771
1837
  if (body.trim())
1772
1838
  appendLine(renderMessageBox({ title: '💡 反思', body, color: C_WARN }));
1773
1839
  }
1774
- else if (!content.includes('🔄 循环') && !content.includes('📋 参数')) {
1840
+ else if (!content.includes('🔄 循环') && !content.includes('📋 参数')
1841
+ && !content.includes('🔍 任务复杂度') && !content.includes('⚙️ 动态配置')
1842
+ && !content.includes('⏹️ pivot loop')
1843
+ // 2026-08-12 (Task4): 循环过渡噪音 — "工具执行完成继续循环"/"继续总结" 是内部推进过程,
1844
+ // 不是给用户看的内容, 一律静默丢弃 (用户抱怨"每次显示触发下一轮循环"的真凶).
1845
+ && !content.includes('继续循环') && !content.includes('继续总结')) {
1775
1846
  appendLine(`${C_DIM}${content}${RESET}`);
1776
1847
  }
1777
1848
  }
1778
1849
  else if (e.type === 'step_start') {
1779
1850
  tuiToolCounter++;
1780
- tuiToolCalls.push({ tool: e.tool || '?', args: e.args, _t: Date.now() });
1851
+ const toolName = e.tool || '?';
1852
+ tuiToolCalls.push({ tool: toolName, args: e.args, _t: Date.now() });
1853
+ // 2026-08-12 (Task4): 命令/工具调用显示加载态 — 追加一行 "运行中", done 时原地替换.
1854
+ // system/loop 这类内部步骤不显示, 只显示真实工具/命令调用.
1855
+ if (toolName !== 'system' && toolName !== 'loop' && toolName !== '?') {
1856
+ appendLine(` ${C_DIM}🔧 ${toolName} 运行中...${RESET}`);
1857
+ }
1781
1858
  }
1782
1859
  else if (e.type === 'step_done' || e.type === 'step_error') {
1783
1860
  const p = tuiToolCalls.shift();
@@ -1795,7 +1872,14 @@ async function processInput(input, comm) {
1795
1872
  runEndOkSteps.push({ status: 'ok', name: t, output: e.output });
1796
1873
  }
1797
1874
  }
1798
- appendLine(renderToolCallListItem(doneItem, tuiToolCalls.length + 1, tuiToolCounter));
1875
+ // 2026-08-12 (Task4): done/error 原地替换 step_start 的加载行 (若 tool 非内部).
1876
+ const doneTool = e.tool ?? p?.tool;
1877
+ if (doneTool !== 'system' && doneTool !== 'loop' && doneTool !== '?') {
1878
+ replaceLastLine(renderToolCallListItem(doneItem, tuiToolCalls.length + 1, tuiToolCounter));
1879
+ }
1880
+ else {
1881
+ appendLine(renderToolCallListItem(doneItem, tuiToolCalls.length + 1, tuiToolCounter));
1882
+ }
1799
1883
  }
1800
1884
  }
1801
1885
  });