代码智能体读 CLI 时,常把 stdout 当成一篇不会回头的小说:进度条的每一帧都进上下文,对齐空格被当成缺字段,pager 和 y/n 则让工具调用一直不返回。本文用 Node.js 内置 API,把原始输出收成可消费结构——还原回车覆盖后的最后一帧、剥离 VT 控制符、识别简单表格,并在疑似交互等待时门禁退出。要降低的是「看了输出却读错」,而不是再给模型加一句「请仔细看终端」。
智能体把终端当小说读,错在哪
终端输出不是 Markdown,也不是一份写完就定稿的日志。它是给人类眼睛看的、带时序的屏幕更新:同一行会被 \r 反复覆盖,颜色和清行靠 ANSI/VT 序列,表格靠空格把列「看齐」,确认提示则故意停住等 stdin。
智能体一侧通常只有「把 stdout 收集成字符串」这一条路径。于是会出现三类典型误读:
- 把进度条中间态当成最终结果,上下文里堆满
45%、46%。 - 把列为了对齐而插入的空格当成空值,或把一个单元格拆成多个字段。
- 碰到
less、Git pager,或Do you want to continue? [Y/n],子进程不退出,工具调用卡住。
这不是模型不够聪明,是输入层把「屏幕」拍成了「小说」。下面只用 Node.js 内置模块:node:util 的 stripVTControlCharacters、node:child_process、node:test。不引入第三方包,示例均可直接 node 运行。需要先说清边界:这里做的是行级回放和启发式解析,不是完整终端模拟器,也覆盖不了 vim、htop 这类全屏 TUI。
先还原最后一帧,再剥 ANSI
只删颜色不够。util.stripVTControlCharacters 能去掉 CSI 等 VT 控制序列,但不会解释 \r。进度条的常见形态是同一行不断回车覆盖。若把整段原始文本直接丢给模型,历史百分比还在。
比较稳妥的顺序是:把 \r\n 规范成 \n → 每一行只保留最后一个 \r 之后的内容 → 再剥 VT。这样模型看到的是「最后一帧」,而不是一部进度条连续剧。
const { stripVTControlCharacters } = require('node:util');
const assert = require('node:assert/strict');
function finalizeTerminalText(raw) {
const normalized = String(raw).replace(/\r\n/g, '\n');
const lines = normalized.split('\n').map((line) => {
const parts = line.split('\r');
return parts[parts.length - 1];
});
return stripVTControlCharacters(lines.join('\n'));
}
const raw =
'Downloading 10%\r' +
'Downloading 45%\r' +
'\x1b[32mDownloading 100%\x1b[0m\n' +
'\x1b[36mstatus\x1b[0m ok\n';
const plain = finalizeTerminalText(raw);
assert.equal(plain, 'Downloading 100%\nstatus ok\n');
console.log(plain);
这是故意简化的行级回放:它处理不了光标上移、局部清屏、保存/恢复光标。对 npm install、curl、docker pull 这类「同一行刷新」通常够用。不确定的 CSI 宁可不解释,以免解析器自己编造一屏并不存在的内容。
有人会先剥控制符再处理 \r,顺序也对进度条成立,因为 \r 不是 VT 序列,剥完后仍在。但若将来要解释 2K(清行)这类 CSI,就必须在剥颜色之前做。目前没有完整模拟器时,不要假装已经「还原了终端」。
把表格从空格对齐变回列
CLI 表格常见两种:用 | 或 │ 做列界;以及用两个以上空格对齐(不少 Go 写的 CLI、容器相关命令都是这类)。智能体按空格分词,会把「对齐填充」和「字段」混在一起。
管道表可以把空单元格留下来;空格对齐表做不到这一点——连续空格既可能是列间距,也可能是空字段,信息在文本里已经丢了。解析器该做的是:列数对得上就输出对象数组,对不上就返回 null,把纯文本留给上层,而不是硬塞一份错的 JSON。
const assert = require('node:assert/strict');
function isSeparator(line) {
return /^[-+|:\s─━├┼┤┬┴└┘┌┐]+$/.test(line.trim());
}
function splitRow(line) {
const trimmed = line.trim();
const delim = trimmed.includes('│') ? '│' : trimmed.includes('|') ? '|' : null;
if (delim) {
let cells = trimmed.split(delim).map((cell) => cell.trim());
if (cells[0] === '') cells = cells.slice(1);
if (cells.at(-1) === '') cells = cells.slice(0, -1);
return cells;
}
return trimmed.split(/\s{2,}/).map((cell) => cell.trim()).filter(Boolean);
}
function parseSimpleTable(text) {
const lines = String(text)
.split('\n')
.map((line) => line.trimEnd())
.filter((line) => line.trim().length > 0 && !isSeparator(line));
if (lines.length < 2) return null;
const rows = lines.map(splitRow);
const width = rows[0].length;
if (width < 2) return null;
if (!rows.every((row) => row.length === width)) return null;
const headers = rows[0];
return rows.slice(1).map((row) => {
const record = {};
headers.forEach((header, i) => {
record[header] = row[i];
});
return record;
});
}
const pipeTable = [
'| NAME | AGE | CITY |',
'| ---- | --- | ---- |',
'| Ada | 36 | |',
'| Lin | 28 | SZX |',
].join('\n');
assert.deepEqual(parseSimpleTable(pipeTable), [
{ NAME: 'Ada', AGE: '36', CITY: '' },
{ NAME: 'Lin', AGE: '28', CITY: 'SZX' },
]);
const aligned = [
'ID TITLE STATE',
'1 boot ready',
'2 deploy failed',
].join('\n');
assert.deepEqual(parseSimpleTable(aligned), [
{ ID: '1', TITLE: 'boot', STATE: 'ready' },
{ ID: '2', TITLE: 'deploy', STATE: 'failed' },
]);
console.log('table ok');
全角字符按显示宽度对齐、单元格内换行、把空格当数据的列,都会让这个解析失手。门禁很简单:列宽不一致就当没有表格。错误的结构比没有结构更伤,因为模型会把它当成已抽取事实。
检测交互等待,别让工具调用挂死
交互不是靠猜进程名。比较可观察的信号是:子进程还活着、stdout/stderr 已经安静一段时间、最后一行像在提问或像 pager。密码提示、[Y/n]、(END)、--More-- 都属于这一类。
不要把「最后一行没有换行」单独当成等待。进度条刷新时,当前行常常也没有 \n,那只是动画还在走。没有提示特征就不要杀进程。
const { spawn } = require('node:child_process');
const { stripVTControlCharacters } = require('node:util');
const assert = require('node:assert/strict');
function finalizeTerminalText(raw) {
const normalized = String(raw).replace(/\r\n/g, '\n');
const lines = normalized.split('\n').map((line) => {
const parts = line.split('\r');
return parts[parts.length - 1];
});
return stripVTControlCharacters(lines.join('\n'));
}
const PROMPT_RE =
/(?:\byes\s*\/\s*no\b|\by\/n\b|\[[yY]\/[nN]\]|password:|passphrase:|press (?:any key|enter|return)\b|--More--|\(END\)|^:\s*$|[??]\s*$)/i;
function detectInteraction({ plain, childAlive, idleMs, idleThresholdMs = 1500 }) {
if (!childAlive || idleMs < idleThresholdMs) {
return { waiting: false, reason: null, lastLine: null };
}
const lastLine = plain.trimEnd().split('\n').pop() ?? '';
if (PROMPT_RE.test(lastLine)) {
return { waiting: true, reason: 'prompt', lastLine };
}
return { waiting: false, reason: null, lastLine };
}
function runReadOnlyCli(command, args, options = {}) {
const timeoutMs = options.timeoutMs ?? 5000;
const idleThresholdMs = options.idleThresholdMs ?? 400;
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
stdio: ['ignore', 'pipe', 'pipe'],
env: {
...process.env,
PAGER: 'cat',
GIT_PAGER: 'cat',
TERM: 'dumb',
},
});
let rawOut = '';
let rawErr = '';
let lastDataAt = Date.now();
let settled = false;
let interaction = { waiting: false, reason: null, lastLine: null };
const finish = (payload) => {
if (settled) return;
settled = true;
clearTimeout(maxTimer);
clearInterval(idleTimer);
resolve(payload);
};
child.stdout.setEncoding('utf8');
child.stderr.setEncoding('utf8');
child.stdout.on('data', (chunk) => {
rawOut += chunk;
lastDataAt = Date.now();
});
child.stderr.on('data', (chunk) => {
rawErr += chunk;
lastDataAt = Date.now();
});
child.on('error', (err) => {
if (!settled) {
settled = true;
reject(err);
}
});
child.on('close', (code, signal) => {
finish({
exitCode: code,
signal,
timedOut: signal === 'SIGTERM',
interaction,
stdoutPlain: finalizeTerminalText(rawOut),
stderrPlain: stripVTControlCharacters(rawErr),
});
});
const maxTimer = setTimeout(() => child.kill('SIGTERM'), timeoutMs);
const idleTimer = setInterval(() => {
interaction = detectInteraction({
plain: finalizeTerminalText(rawOut + '\n' + rawErr),
childAlive: true,
idleMs: Date.now() - lastDataAt,
idleThresholdMs,
});
if (interaction.waiting) child.kill('SIGTERM');
}, 100);
});
}
async function main() {
const result = await runReadOnlyCli(process.execPath, [
'-e',
'process.stdout.write("Continue? [Y/n] "); setTimeout(() => {}, 30_000);',
]);
assert.equal(result.interaction.waiting, true);
assert.equal(result.interaction.reason, 'prompt');
assert.match(result.stdoutPlain, /\[Y\/n\]/);
console.log(JSON.stringify(result.interaction, null, 2));
}
main();
stdin 对智能体工具应默认关掉(stdio 第一项用 'ignore'),避免它猜着回 y。PAGER=cat、GIT_PAGER=cat、TERM=dumb 能让一部分 CLI 不要把输出送进 less;这不是万能开关,只是减少无声卡住的常见来源。一旦判定 waiting,就 SIGTERM,并让工具层返回明确错误,而不是把半截提示当作成败结论。
启发式会误伤:有的程序把 [Y/n] 写进帮助文本然后正常退出;有的进度条文案里带问号。所以 waiting: true 时应同时带上 lastLine,方便单测和人工 spot check,而不是在日志里只写一句「疑似交互」。
接到只读工具层,解析要能单测
智能体不该看到原始字节流。spawn、回放、剥 ANSI、表格、交互检测应收成一个只读结果:退出码、纯文本、表格(可空)、交互状态。模型消费这个对象;解析本身用 node:test 锁住,避免下次改正则时把进度条重新当成小说。
const { test } = require('node:test');
const assert = require('node:assert/strict');
const { stripVTControlCharacters } = require('node:util');
function finalizeTerminalText(raw) {
const normalized = String(raw).replace(/\r\n/g, '\n');
const lines = normalized.split('\n').map((line) => {
const parts = line.split('\r');
return parts[parts.length - 1];
});
return stripVTControlCharacters(lines.join('\n'));
}
function isSeparator(line) {
return /^[-+|:\s─━├┼┤┬┴└┘┌┐]+$/.test(line.trim());
}
function splitRow(line) {
const trimmed = line.trim();
const delim = trimmed.includes('│') ? '│' : trimmed.includes('|') ? '|' : null;
if (delim) {
let cells = trimmed.split(delim).map((cell) => cell.trim());
if (cells[0] === '') cells = cells.slice(1);
if (cells.at(-1) === '') cells = cells.slice(0, -1);
return cells;
}
return trimmed.split(/\s{2,}/).map((cell) => cell.trim()).filter(Boolean);
}
function parseSimpleTable(text) {
const lines = String(text)
.split('\n')
.map((line) => line.trimEnd())
.filter((line) => line.trim().length > 0 && !isSeparator(line));
if (lines.length < 2) return null;
const rows = lines.map(splitRow);
const width = rows[0].length;
if (width < 2 || !rows.every((row) => row.length === width)) return null;
const headers = rows[0];
return rows.slice(1).map((row) => {
const record = {};
headers.forEach((header, i) => {
record[header] = row[i];
});
return record;
});
}
function toToolResult({ exitCode, signal, interaction, stdoutRaw, stderrRaw }) {
const stdoutPlain = finalizeTerminalText(stdoutRaw);
return {
ok: exitCode === 0 && !interaction.waiting,
exitCode,
signal,
interaction,
stdoutPlain,
stderrPlain: stripVTControlCharacters(stderrRaw),
tables: [parseSimpleTable(stdoutPlain)].filter(Boolean),
};
}
test('进度条只保留最后一帧,并抽出表格', () => {
const stdoutRaw =
'pulling 10%\rpulling 100%\n' +
'NAME TAG\n' +
'node 20\n';
const result = toToolResult({
exitCode: 0,
signal: null,
interaction: { waiting: false, reason: null, lastLine: null },
stdoutRaw,
stderrRaw: '',
});
assert.equal(result.ok, true);
assert.equal(result.stdoutPlain, 'pulling 100%\nNAME TAG\nnode 20\n');
assert.deepEqual(result.tables, [[{ NAME: 'node', TAG: '20' }]]);
});
test('确认提示不得标成成功', () => {
const result = toToolResult({
exitCode: null,
signal: 'SIGTERM',
interaction: {
waiting: true,
reason: 'prompt',
lastLine: 'Overwrite? [Y/n]',
},
stdoutRaw: 'Overwrite? [Y/n] ',
stderrRaw: '',
});
assert.equal(result.ok, false);
assert.equal(result.interaction.waiting, true);
});
把这份结果接到工具层时,建议同时做两道硬门禁:interaction.waiting === true 则对模型返回失败原因,不要附带「看起来像同意」的半截句子;表格列数不稳定则 tables 留空。工具是只读的,意思是默认不把 stdin 交给模型去填。真要自动确认,应走命令自己的非交互旗标(许多 CLI 有 --yes / --non-interactive),而不是在 PTY 里扮演用户。
总结
智能体读错终端,多半不是因为字认不准,而是因为把屏幕更新当成了连续文本。先按 \r 还原最后一帧,再用 stripVTControlCharacters 去掉 VT 序列,表格能对齐再结构化、对不齐就放弃,提示词匹配且输出空闲则杀进程并报错——这几步都可以单测,也都可以放在模型前面。
它解决不了全屏 TUI,也恢复不了空格对齐表里已经丢失的空单元格。但可以把「进度条当结果、空格当缺字段、pager 上无声卡住」从默认行为里拿掉。与其提示模型仔细看终端,不如别让它读到那篇小说。
评论