Commit cb60b87a90e57a6c5014116294ba358397c59db0
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); |