Commit cb60b87a90e57a6c5014116294ba358397c59db0

Authored by yibo
1 parent 7c099008

feat(tools): scripts/tail-agent.js —— 跟看 agent 的 LLM 实时输出

控制台「运行日志」看不到 LLM 逐字输出,不是坏了,是引擎按设计不存:

1. **引擎根本不消费流式 chunk**:defaultAgentExecutor 的 onAcpEvent 只处理
   session/result(累 usage)与 mock/files,文本 chunk 一个字都不接。
2. **事件里的自由文本截断到 600 字**(EVENT_TEXT_MAX),graph-engine.js 那段注释
   写了三条理由:完整输出无上限而 getRunState 每次轮询 SELECT * 全量回传;
   完整产物已有地址(output_ref)不该有第二个真相源;fail_reason 是选边的匹配键
   不能掺自由文本。
所以 stream.log / timeline.ndjson 里只有结构化事件 + 短摘要。

但 Claude CLI 自己把完整会话写在
  ~/.claude/projects/<cwd 路径的 / 换成 ->/<sessionId>.jsonl
而 agent 的 cwd 就是 data/companies/<公司>/agents/<角色>,目录名可推。
这个脚本把那份 jsonl 渲染成可读的实时流:

  node scripts/tail-agent.js <公司>                  列出哪些角色有 transcript
  node scripts/tail-agent.js <公司> <角色>            跟看(增量轮询,Ctrl+C 退出)
  node scripts/tail-agent.js <公司> <角色> --last 40  只看最后 40 条

只渲染 assistant 的 text 当「LLM 输出」:user 侧的 text 是喂进去的东西(提示词全文、
每轮注入的 <env_status>),跟看时纯噪音 —— 第一版没滤,实测满屏 env_status。
user 侧只留 tool_result(agent 到底读到了什么,这个有用)。

在运行中的实例上验过:mycompany/sdd-planning 正在跑时列表、--last、跟看三种都对,
角色名写错时明确报「目录不存在 + 先跑列表看有哪些」,不静默空跑。

ponytail: projects 目录名按 CLI 现行的「/ → -」规则推导,规则变了会找不到目录
(会明说,不会假装没输出)。

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Showing 1 changed file with 139 additions and 0 deletions
scripts/tail-agent.js 0 → 100644
  1 +#!/usr/bin/env node
  2 +/**
  3 + * 跟看某个 company agent 的 LLM 实时输出。
  4 + *
  5 + * 为什么需要它:控制台的「运行日志」读的是 `stream.log` / `timeline.ndjson`,
  6 + * 那里只有结构化事件(node.status / agent.call / node.output),而且事件里的自由文本
  7 + * 被刻意截断到 600 字(graph-engine.js 的 EVENT_TEXT_MAX,注释里写了三条理由)。
  8 + * 引擎更是**根本不消费**流式 chunk —— onAcpEvent 只取 session/result 的 usage。
  9 + * 所以 LLM 的逐字输出不在引擎的任何轨道上。
  10 + *
  11 + * 但 Claude CLI 自己把完整会话写在
  12 + * ~/.claude/projects/<把 cwd 路径里的 / 换成 - >/<sessionId>.jsonl
  13 + * agent 的 cwd 是 `data/companies/<公司>/agents/<角色>`,所以目录名可推。
  14 + *
  15 + * 用法:
  16 + * node scripts/tail-agent.js <公司> <角色> # 跟看最新会话(-f)
  17 + * node scripts/tail-agent.js <公司> <角色> --last 40 # 只看最后 40 条,不跟
  18 + * node scripts/tail-agent.js <公司> # 列出这家公司有哪些角色有 transcript
  19 + *
  20 + * ponytail: 目录名按「绝对路径 / → -」推导,这是 Claude CLI 现行的命名规则;
  21 + * 规则变了这个脚本会找不到目录(会明说找不到,不会静默空跑)。
  22 + */
  23 +const fs = require('fs');
  24 +const os = require('os');
  25 +const path = require('path');
  26 +
  27 +const REPO = path.resolve(__dirname, '..');
  28 +const PROJECTS = path.join(os.homedir(), '.claude', 'projects');
  29 +
  30 +const [company, role, ...rest] = process.argv.slice(2);
  31 +const lastN = (() => {
  32 + const i = rest.indexOf('--last');
  33 + return i >= 0 ? Number(rest[i + 1] || 30) : null;
  34 +})();
  35 +
  36 +if (!company) {
  37 + console.error('用法: node scripts/tail-agent.js <公司> [<角色>] [--last N]');
  38 + process.exit(1);
  39 +}
  40 +
  41 +/** cwd 绝对路径 → Claude CLI 的 projects 目录名 */
  42 +function projectDirFor(cid, roleName) {
  43 + const cwd = path.join(REPO, 'data', 'companies', cid, 'agents', roleName);
  44 + return path.join(PROJECTS, cwd.replace(/\//g, '-'));
  45 +}
  46 +
  47 +if (!role) {
  48 + const prefix = `-${REPO.replace(/^\//, '').replace(/\//g, '-')}-data-companies-${company}-agents-`;
  49 + const dirs = fs.existsSync(PROJECTS)
  50 + ? fs.readdirSync(PROJECTS).filter((d) => d.startsWith(prefix))
  51 + : [];
  52 + if (!dirs.length) {
  53 + console.error(`没找到 ${company} 的任何 agent transcript(目录前缀 ${prefix})`);
  54 + process.exit(2);
  55 + }
  56 + console.log(`${company} 有 transcript 的角色:`);
  57 + for (const d of dirs) {
  58 + const roleName = d.slice(prefix.length);
  59 + const files = fs.readdirSync(path.join(PROJECTS, d)).filter((f) => f.endsWith('.jsonl'));
  60 + const newest = files
  61 + .map((f) => ({ f, m: fs.statSync(path.join(PROJECTS, d, f)).mtime }))
  62 + .sort((a, b) => b.m - a.m)[0];
  63 + console.log(` ${roleName.padEnd(22)} ${files.length} 个会话` + (newest ? `,最近 ${newest.m.toLocaleString()}` : ''));
  64 + }
  65 + process.exit(0);
  66 +}
  67 +
  68 +const dir = projectDirFor(company, role);
  69 +if (!fs.existsSync(dir)) {
  70 + console.error(`没有这个 agent 的 transcript 目录:\n ${dir}\n(角色名写对了吗?先跑 node scripts/tail-agent.js ${company} 看有哪些)`);
  71 + process.exit(2);
  72 +}
  73 +const sessions = fs.readdirSync(dir).filter((f) => f.endsWith('.jsonl'))
  74 + .map((f) => ({ p: path.join(dir, f), m: fs.statSync(path.join(dir, f)).mtime }))
  75 + .sort((a, b) => b.m - a.m);
  76 +if (!sessions.length) {
  77 + console.error(`目录在但没有会话文件:${dir}`);
  78 + process.exit(2);
  79 +}
  80 +const file = sessions[0].p;
  81 +
  82 +// ── 渲染 ───────────────────────────────────────────────────────────
  83 +const C = { dim: '\x1b[2m', reset: '\x1b[0m', cyan: '\x1b[36m', green: '\x1b[32m', yellow: '\x1b[33m', mag: '\x1b[35m' };
  84 +const clip = (s, n) => (s.length <= n ? s : `${s.slice(0, n)}…`);
  85 +
  86 +function render(line) {
  87 + let d;
  88 + try { d = JSON.parse(line); } catch { return; }
  89 + const msg = d.message;
  90 + if (!msg || !Array.isArray(msg.content)) return;
  91 +
  92 + // 只把 assistant 的 text 当「LLM 输出」。user 侧的 text 是喂进去的东西 ——
  93 + // 提示词全文、以及每轮注入的 <env_status>(时间戳/目录状态),跟看时纯噪音。
  94 + // user 侧只留 tool_result(那是工具的真实返回,看 agent 读到了什么很有用)。
  95 + const isAssistant = msg.role === 'assistant';
  96 +
  97 + for (const c of msg.content) {
  98 + if (c.type === 'text' && isAssistant && c.text && c.text.trim()) {
  99 + console.log(`${C.green}▌${C.reset} ${c.text.trim()}`);
  100 + } else if (c.type === 'thinking' && c.thinking && c.thinking.trim()) {
  101 + console.log(`${C.dim}◌ ${clip(c.thinking.trim().replace(/\n+/g, ' '), 300)}${C.reset}`);
  102 + } else if (c.type === 'tool_use') {
  103 + const i = c.input || {};
  104 + const arg = i.file_path || i.command || i.pattern || i.path || i.url || '';
  105 + console.log(`${C.cyan}→ ${c.name}${C.reset} ${C.dim}${clip(String(arg), 160)}${C.reset}`);
  106 + } else if (c.type === 'tool_result') {
  107 + const t = typeof c.content === 'string'
  108 + ? c.content
  109 + : (c.content || []).map((x) => (x && x.text) || '').join(' ');
  110 + if (t.trim()) console.log(`${C.dim}← ${clip(t.trim().replace(/\n+/g, ' '), 200)}${C.reset}`);
  111 + }
  112 + }
  113 +}
  114 +
  115 +console.log(`${C.mag}会话${C.reset} ${file}`);
  116 +console.log(`${C.dim}${company} / ${role}${lastN ? ` 最后 ${lastN} 条` : ' 跟看中(Ctrl+C 退出)'}${C.reset}\n`);
  117 +
  118 +const all = fs.readFileSync(file, 'utf-8').split('\n').filter(Boolean);
  119 +for (const l of (lastN ? all.slice(-lastN) : all)) render(l);
  120 +
  121 +if (lastN) process.exit(0);
  122 +
  123 +// 跟看:只从当前长度往后读增量
  124 +let offset = fs.statSync(file).size;
  125 +let carry = '';
  126 +setInterval(() => {
  127 + let size;
  128 + try { size = fs.statSync(file).size; } catch { return; }
  129 + if (size <= offset) return;
  130 + const fd = fs.openSync(file, 'r');
  131 + const buf = Buffer.alloc(size - offset);
  132 + fs.readSync(fd, buf, 0, buf.length, offset);
  133 + fs.closeSync(fd);
  134 + offset = size;
  135 + const chunk = carry + buf.toString('utf-8');
  136 + const lines = chunk.split('\n');
  137 + carry = lines.pop() || ''; // 最后一段可能是半行,留到下一轮
  138 + for (const l of lines) if (l.trim()) render(l);
  139 +}, 700);
... ...