代码智能体读 CLI 时,常把 stdout 当成一篇不会回头的小说:进度条的每一帧都进上下文,对齐空格被当成缺字段,pager 和 y/n 则让工具调用一直不返回。本文用 Node.js 内置 API,把原始输出收成可消费结构——还原回车覆盖后的最后一帧、剥离 VT 控制符、识别简单表格,并在疑似交互等待时门禁退出。要降低的是「看了输出却读错」,而不是再给模型加一句「请仔细看终端」。

智能体把终端当小说读,错在哪

终端输出不是 Markdown,也不是一份写完就定稿的日志。它是给人类眼睛看的、带时序的屏幕更新:同一行会被 \r 反复覆盖,颜色和清行靠 ANSI/VT 序列,表格靠空格把列「看齐」,确认提示则故意停住等 stdin。

智能体一侧通常只有「把 stdout 收集成字符串」这一条路径。于是会出现三类典型误读:

  1. 把进度条中间态当成最终结果,上下文里堆满 45%、46%。
  2. 把列为了对齐而插入的空格当成空值,或把一个单元格拆成多个字段。
  3. 碰到 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 上无声卡住」从默认行为里拿掉。与其提示模型仔细看终端,不如别让它读到那篇小说。