Двойной вывод
Двойной вывод (Dual Output) — это режим сайдкара для интерактивного TUI: пока Qwen Code нормально отображает вывод в stdout, он параллельно отправляет структурированный поток JSON-событий в отдельный канал, чтобы внешняя программа — расширение IDE, веб-интерфейс, CI-пайплайн, скрипт автоматизации — могла наблюдать за сессией и управлять ею.
Также предусмотрен обратный канал: внешняя программа может записывать JSONL-команды в файл, который TUI отслеживает, что позволяет отправлять промпты и отвечать на запросы разрешений инструментов так, как если бы за клавиатурой сидел человек.
Двойной вывод полностью опционален. Если указанные ниже флаги отсутствуют, TUI ведёт себя как обычно — без лишнего ввода-вывода и без изменений в поведении.
Варианты использования
Двойной вывод — это низкоуровневый примитив. Вот конкретные интеграции, которые он открывает:
Терминал + чат: синхронизация в реальном времени в двух режимах
Флагманский сценарий. Веб- или десктопный ChatUI размещает TUI внутри PTY и отображает параллельную панель беседы, управляемую структурированным потоком событий:
- Пользователь может вводить текст в любой из интерфейсов — в TUI (для опытных пользователей терминала) или в веб-интерфейсе (более богатый UX, ссылки для отправки, мобильная версия). Оба представления синхронизируются, потому что каждое сообщение проходит через одни и те же JSON-события.
- Запросы на одобрение инструментов отображаются в обоих местах; кто первый одобрил — тот и выиграл.
- История сессии захватывается дословно из
--json-file, поэтому на серверной стороне есть каноническая машиночитаемая стенограмма без необходимости парсить ANSI.
Расширения IDE (VS Code / JetBrains / Cursor / Neovim)
Встраивание Qwen Code в IDE. TUI запускается во встроенной панели терминала редактора для тех, кому это нужно, а расширение потребляет события --json-fd / --json-file для управления:
- Наложения inline-диффа, когда агент изменяет файлы.
- Боковая панель webview с отформатированным Markdown, вызовами инструментов с подсветкой синтаксиса и кликабельными ссылками.
- Индикаторы в строке состояния (обдумывание / ответ / ожидание подтверждения).
- Программная запись
confirmation_response, когда пользователь нажимает родную кнопку подтверждения в IDE.
Браузерные чат-интерфейсы
Node/Bun-сервер запускает TUI в PTY для рендеринга, но предоставляет WebSocket-канал браузеру. События из --json-file пересылаются клиенту; сообщения пользователя, введённые в браузере, внедряются через --input-file. С обеих сторон не требуется парсить ANSI.
Наблюдатели CI / автоматизации
CI-задание запускает Qwen Code с промптом задачи. Человек видит TUI в логе задания; система CI отслеживает --json-file, чтобы:
- Отметить задание как неудачное, если событие
resultсообщает об ошибке. - Отправлять метрики
token usage/duration_ms/tool_useв систему сбора. - Архивировать полную стенограмму как артефакт сборки.
Многоагентная оркестрация
Агент-супервизор запускает несколько рабочих TUI, каждый со своей парой файлов событий/ввода. Он отслеживает прогресс, отправляет уточняющие промпты и обеспечивает соблюдение глобальных бюджетов/политик безопасности, одобряя или отклоняя вызовы инструментов во всех рабочих процессах.
Запись, аудит и воспроизведение сессий
Запись каждой TUI-сессии в обычный файл с помощью --json-file. В дальнейшем:
- Аудит соответствия может восстановить, что именно было выполнено.
- Автоматизированные регрессионные тесты могут сравнивать прогоны на разных версиях моделей.
- Инструмент воспроизведения может заново отправлять события через тот же протокол для передачи в дашборды визуализации.
Панели мониторинга наблюдаемости
Передача --json-file в Loki / OTEL / любой другой конвейер, принимающий JSONL. Извлекайте usage.input_tokens, tool_use.name, result.duration_api_ms как метрики первого класса в Grafana. Не нужно писать регулярные выражения для парсинга логов.
Тестирование и контроль качества
Интеграционные тесты запускают Qwen Code без головы, управляют им с помощью сценариев --input-file и проверяют события --json-file. В отличие от парсинга ANSI из stdout, утверждения стабильны при изменениях интерфейса.
Флаги
| Флаг | Тип | Назначение |
|---|---|---|
--json-fd <n> | число, n >= 3 | Запись структурированных JSON-событий в файловый дескриптор n. Вызывающий процесс должен предоставить этот fd через конфигурацию stdio при порождении процесса или через перенаправление в шелле. |
--json-file <путь> | путь | Запись структурированных JSON-событий в файл. Путь может быть обычным файлом, FIFO (именованным каналом) или /dev/fd/N. |
--input-file <путь> | путь | Отслеживание этого файла на предмет JSONL-команд, записываемых внешней программой. |
--json-fd и --json-file взаимно исключают друг друга. Файловые дескрипторы 0, 1 и 2 отклоняются, чтобы не нарушать собственный вывод TUI.
Зачем нужны два флага для вывода? (--json-fd и --json-file)
На первый взгляд --json-fd может показаться достаточным — вызывающий процесс порождает Qwen Code с дополнительным файловым дескриптором, TUI записывает в него события, и готово. На практике передача fd ломается в самом важном сценарии встраивания: запуск TUI внутри псевдотерминала (PTY). Именно поэтому эта функция также предлагает альтернативу, основанную на пути.
Когда --json-fd работает
Обычный child_process.spawn с массивом stdio:
const child = spawn('qwen', ['--json-fd', '3'], {
stdio: ['inherit', 'inherit', 'inherit', eventsFd],
});Node-потомок поддерживает произвольные записи в stdio; fd 3 наследуется дочерним процессом, который может писать в него напрямую. Zero-copy, zero-buffer, zero-filesystem — самый быстрый путь.
Почему --json-fd не работает под PTY
Обёртки PTY, такие как node-pty и bun-pty — это то, как серьёзные встраиватели (расширения IDE, веб-терминалы, мультиплексоры типа tmux) размещают интерактивный TUI. Они не могут передать дополнительные fd дочернему процессу по трём веским причинам:
- Поверхность API.
node-pty.spawn(file, args, options)принимаетcwd,env,cols,rows,encodingи т.д. — но нет массиваstdio. В API просто нет места, чтобы сказать «также присоедини этот fd как fd 3 в дочернем процессе».bun-ptyпредоставляет тот же интерфейс. - Семантика
forkpty(3). Под капотом обёртки PTY вызываютforkpty(3)(или эквивалентный танецposix_openpt+login_tty). Этот системный вызов выделяет пару мастер/слейв псевдотерминала и перенаправляет fd дочернего процесса 0/1/2 на слейв-сторону, так что дочерний процесс считает, что он подключён к реальному терминалу. Любые fd выше 2 в родительском процессе закрываютсяlogin_tty, который вызываетclose(fd)дляfd >= 3передexec. Дополнительные fd активно уничтожаются, а не наследуются. - Побочный эффект управления терминалом. Даже если бы вы каким-то образом протащили дополнительный fd, он не был бы терминалом, поэтому рендерер TUI дочернего процесса (который пишет escape-последовательности в предположении TTY на fd 1) всё равно нуждался бы в слейве для вывода. В итоге у вас было бы два независимых транспорта.
Короче говоря: как только встраивателю требуется настоящий TTY для рендеринга TUI — а это нужно каждому расширению IDE, каждому веб-терминалу, каждому десктопному чату — наследование fd становится невозможным.
--json-file заполняет пробел
Путь к файлу передаётся как обычный аргумент CLI, поэтому он работает в любой модели порождения:
import { spawn } from 'node-pty';
const pty = spawn(
'qwen',
[
'--json-file',
'/tmp/qwen-events.jsonl',
'--input-file',
'/tmp/qwen-input.jsonl',
],
{ cols: 120, rows: 40 },
);Дочерний процесс сам открывает файл и записывает туда события; встраиватель отслеживает тот же путь с помощью fs.watch + инкрементальное чтение. Три момента, на которые стоит обратить внимание:
- Обычный файл, FIFO (именованный канал) или
/dev/fd/N— все работают. FIFO — вариант с наименьшей задержкой, когда обе стороны на одном хосте. - Мост открывает FIFO с
O_NONBLOCKи откатывается к блокирующему режиму приENXIO(читатель ещё не подключён), так что запуск PTY никогда не зависает в ожидании потребителя. - Для изоляции нескольких сессий используйте пути для каждой сессии в
$XDG_RUNTIME_DIRили в каталоге, созданном с помощьюmkdtempс режимом0700.
Какой флаг использовать?
| Тип встраивания | Используйте |
|---|---|
child_process.spawn с обычным stdio | --json-fd |
node-pty / bun-pty / любой PTY-хост | --json-file |
| Перенаправление в шелле / ручное тестирование конвейера | любой |
| Сбор логов CI (обычный файл, чтение после завершения) | --json-file |
| Минимальная задержка на одном хосте | --json-file + FIFO |
Общее правило: если вам нужно корректное отображение TUI, вам нужен PTY, а значит, нужен --json-file. --json-fd предназначен для более простых встраивателей, которым не важна достоверность TUI — обычно это программные обёртки, которые всё равно игнорируют stdout.
Быстрый старт
Запустите Qwen Code с обоими каналами, используя обычные файлы:
touch /tmp/qwen-events.jsonl /tmp/qwen-input.jsonl
qwen \
--json-file /tmp/qwen-events.jsonl \
--input-file /tmp/qwen-input.jsonlВо втором терминале отслеживайте поток событий:
tail -f /tmp/qwen-events.jsonlВ третьем терминале отправьте промпт в работающий TUI:
echo '{"type":"submit","text":"Объясни этот репозиторий"}' >> /tmp/qwen-input.jsonlПромпт появится в TUI точно так же, как если бы его ввёл пользователь, а потоковый ответ будет продублирован в /tmp/qwen-events.jsonl.
Использование FIFO (именованных каналов) для вывода событий
FIFO обеспечивают меньшую задержку, чем обычные файлы (нет дискового ввода-вывода), и хорошо работают, когда обе стороны находятся на одном хосте. Мост открывает FIFO с O_RDWR | O_NONBLOCK, поэтому он не блокируется, даже если читатель ещё не подключён — события буферизируются в буфере ядра канала, пока не появится читатель.
Примечание: Для
--input-fileтребуется обычный файл (не FIFO), поскольку наблюдатель полагается наstat.sizeдля обнаружения новых данных, а для FIFO это значение всегда равно 0.
mkfifo /tmp/qwen-events.jsonl
touch /tmp/qwen-input.jsonl
qwen \
--json-file /tmp/qwen-events.jsonl \
--input-file /tmp/qwen-input.jsonl
# TUI запускается сразу — не нужно сначала запускать читателя.
# Во втором терминале подключитесь, когда будете готовы:
cat /tmp/qwen-events.jsonlЕсли читатель так и не подключился, мост автоматически отключается, когда внутренний буфер превышает 1 МБ. TUI при этом продолжает нормально работать.
Схема выходных событий
События отправляются в формате JSON Lines (один объект на строку). Схема такая же, как в неинтерактивном режиме --output-format=stream-json, с всегда включённым includePartialMessages.
Первое событие в канале — всегда system / session_start, которое отправляется при создании моста. Используйте его для связывания канала с идентификатором сессии до того, как прибудут другие события.
// Жизненный цикл сессии
{
"type": "system",
"subtype": "session_start",
"uuid": "...",
"session_id": "...",
"data": { "session_id": "...", "cwd": "/путь/к/рабочей/директории" }
}
// Потоковые события для текущего ответа ассистента
{ "type": "stream_event", "event": { "type": "message_start", "message": { ... } }, ... }
{ "type": "stream_event", "event": { "type": "content_block_start", "index": 0, "content_block": { "type": "text" } }, ... }
{ "type": "stream_event", "event": { "type": "content_block_delta", "index": 0, "delta": { "type": "text_delta", "text": "Привет" } }, ... }
{ "type": "stream_event", "event": { "type": "content_block_stop", "index": 0 }, ... }
{ "type": "stream_event", "event": { "type": "message_stop" }, ... }
// Завершённые сообщения
{ "type": "user", "message": { "role": "user", "content": [...] }, ... }
{ "type": "assistant", "message": { "role": "assistant", "content": [...], "usage": { ... } }, ... }
{ "type": "user", "message": { "role": "user", "content": [{ "type": "tool_result", ... }] } }
// Плоскость управления разрешениями (только когда инструмент требует одобрения)
{
"type": "control_request",
"request_id": "...",
"request": {
"subtype": "can_use_tool",
"tool_name": "run_shell_command",
"tool_use_id": "...",
"input": { "command": "rm -rf /tmp/x" },
"permission_suggestions": null,
"blocked_path": null
}
}
{
"type": "control_response",
"response": {
"subtype": "success",
"request_id": "...",
"response": { "allowed": true }
}
}control_response отправляется независимо от того, было ли решение принято в TUI (собственный интерфейс подтверждения) или внешним confirmation_response (см. ниже). В любом случае все наблюдатели видят окончательный результат.
Схема входных команд
В --input-file принимаются две формы команд:
// Отправка сообщения пользователя в очередь промптов
{ "type": "submit", "text": "Что делает эта функция?" }
// Ответ на ожидающий control_request
{ "type": "confirmation_response", "request_id": "...", "allowed": true }Поведение:
- Команды
submitставятся в очередь. Если TUI занят ответом, они автоматически повторяются при следующем возврате TUI в состояние ожидания. - Команды
confirmation_responseотправляются немедленно и никогда не ставятся в очередь, потому что вызов инструмента блокирует выполнение, и ответ должен дойти до обработчикаonConfirmбез ожидания более раннихsubmit. - Какая сторона первой одобрит инструмент, та и выигрывает; запоздалый ответ другой стороны безвредно игнорируется.
- Строки, которые не удаётся распарсить как JSON, логируются и пропускаются — они не останавливают наблюдатель.
Замечания по задержкам
Входной файл наблюдается с помощью fs.watchFile с интервалом опроса 500 мс, поэтому наихудшая задержка в оба конца для удалённого submit составляет около половины секунды. Это сделано намеренно: опрос переносим между платформами и файловыми системами (включая macOS / сетевые монтирования) и соответствует типичному темпу работы человека, на который рассчитана эта функция. Выходной канал не имеет опроса — события записываются синхронно по мере их генерации TUI.
Режимы отказа
- Плохой fd. Если fd, переданный в
--json-fd, не открыт или является одним из 0/1/2, TUI выводит предупреждение вstderrи продолжает работу без двойного вывода. - Плохой путь. Если файл, переданный в
--json-file, не может быть открыт, TUI выводит предупреждение и продолжает работу без двойного вывода. - Отключение потребителя. Если читатель на другой стороне канала отключается (
EPIPE), мост молча отключается, а TUI продолжает работу. Повторных попыток нет. - Переполнение буфера FIFO. При записи в FIFO без подключенного читателя события буферизируются в канале ядра (~64 КБ в Linux) и в Node.js WriteStream. Как только канал заполняется или внутренний буфер превышает 1 МБ, мост отключается и закрывает fd. В этом случае
session_endне отправляется — потребители должны рассматривать закрытый поток безsession_endкак аварийное завершение. TUI продолжает нормально работать. - Исключение в адаптере. Любое исключение, возникшее при отправке события, перехватывается, логируется и отключает мост. TUI никогда не падает из-за сбоя двойного вывода.
Пример порождения процесса
Типичный родительский процесс-встраиватель запускает Qwen Code с обоими каналами:
import { spawn } from 'node:child_process';
import { openSync } from 'node:fs';
const eventsFd = openSync('/tmp/qwen-events.jsonl', 'w');
const child = spawn(
'qwen',
['--json-fd', '3', '--input-file', '/tmp/qwen-input.jsonl'],
{ stdio: ['inherit', 'inherit', 'inherit', eventsFd] },
);TUI по-прежнему владеет терминалом пользователя на stdio 0/1/2, а встраиватель читает структурированные события из файла, связанного с fd 3, и отправляет команды, добавляя строки JSONL в /tmp/qwen-input.jsonl.
Конфигурация через настройки
Для долгоживущих встраивателей часто неудобно передавать флаги CLI при каждом запуске. Те же каналы можно настроить в settings.json с ключом верхнего уровня dualOutput:
// ~/.qwen/settings.json (уровень пользователя)
// или <workspace>/.qwen/settings.json (уровень рабочей области)
{
"dualOutput": {
"jsonFile": "/tmp/qwen-events.jsonl",
"inputFile": "/tmp/qwen-input.jsonl",
},
}Правила приоритета:
- Флаг CLI имеет приоритет над настройками. Передача
--json-file /fooв командной строке переопределяетdualOutput.jsonFileв настройках. - У
--json-fdнет эквивалента в настройках — передача fd является вопросом времени порождения процесса и не может быть статически объявлена. - Если нет ни флага, ни настройки, двойной вывод остаётся отключенным (идентично текущему поведению по умолчанию).
Флаг requiresRestart: true означает, что изменения вступают в силу только при следующем запуске Qwen Code, поскольку мост создаётся один раз во время запуска.
Рабочие демонстрации
Каждый скрипт ниже готов к копированию и запуску. Начните с POC 1, чтобы убедиться, что сборка поддерживает двойной вывод; POC 4 является наиболее близким аналогом реальной интеграции с расширением IDE.
POC 1 — наблюдение за потоком событий
Просмотр всех структурированных событий, которые генерирует TUI при обычном использовании:
# Терминал A
mkfifo /tmp/qwen-events.jsonl
cat /tmp/qwen-events.jsonl | jq -c 'select(.type != "stream_event") | {type, subtype}'
# Терминал B
qwen --json-file /tmp/qwen-events.jsonl
# ...затем обычный чат; терминал A показывает session_start,
# жизненный цикл user/assistant/result/control_request в реальном времени.Ожидаемая первая строка в терминале A:
{ "type": "system", "subtype": "session_start" }POC 2 — внешняя отправка промптов
Управление TUI из второго терминала, не касаясь клавиатуры первого:
# Терминал A
touch /tmp/qwen-in.jsonl
qwen --input-file /tmp/qwen-in.jsonl
# Терминал B — TUI отвечает так, как если бы вы ввели текст
echo '{"type":"submit","text":"список файлов в текущей директории"}' \
>> /tmp/qwen-in.jsonlPOC 3 — удалённое одобрение разрешений инструментов
Одобрение или отклонение вызовов инструментов из отдельного процесса:
# Терминал A — наблюдаем control_requests
mkfifo /tmp/qwen-out.jsonl
touch /tmp/qwen-in.jsonl
(cat /tmp/qwen-out.jsonl \
| jq -c 'select(.type == "control_request")') &
# Терминал B
qwen --json-file /tmp/qwen-out.jsonl --input-file /tmp/qwen-in.jsonl
# Попросите Qwen сделать что-то, требующее одобрения, например
# "выполни `ls -la /tmp`". В терминале A появится control_request.
# Скопируйте request_id, затем в третьем терминале:
echo '{"type":"confirmation_response","request_id":"<вставьте-id>","allowed":true}' \
>> /tmp/qwen-in.jsonl
# Запрос на подтверждение в TUI исчезнет, и инструмент выполнится.Если вы ответите с неизвестным request_id, мост отправит control_response с subtype: "error" в канал вывода, чтобы ваш потребитель мог залогировать это или повторить попытку:
{
"type": "control_response",
"response": {
"subtype": "error",
"request_id": "...",
"error": "неизвестный request_id (уже разрешён, отменён или никогда не выдавался)"
}
}POC 4 — встраивание в Node (как в IDE)
Наиболее реалистичный пример: родительский процесс порождает Qwen Code, отслеживает события и отправляет промпты по своему расписанию.
// demo-embedder.ts
import { spawn } from 'node:child_process';
import { appendFileSync, createReadStream, writeFileSync } from 'node:fs';
import { createInterface } from 'node:readline';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
const events = join(tmpdir(), `qwen-events-${process.pid}.jsonl`);
const input = join(tmpdir(), `qwen-input-${process.pid}.jsonl`);
writeFileSync(events, '');
writeFileSync(input, '');
const child = spawn('qwen', ['--json-file', events, '--input-file', input], {
stdio: 'inherit',
});
// Отслеживание выходного канала. В реальном приложении вы бы
// использовали правильное отслеживание по смещению; здесь для
// краткости мы повторно передаём поток с начала.
const rl = createInterface({
input: createReadStream(events, { encoding: 'utf8' }),
});
rl.on('line', (line) => {
if (!line.trim()) return;
const ev = JSON.parse(line);
if (ev.type === 'system' && ev.subtype === 'session_start') {
console.log('[встраиватель] рукопожатие:', {
protocol_version: ev.data.protocol_version,
version: ev.data.version,
supported_events: ev.data.supported_events,
});
// Проверка возможностей перед использованием функциональности
if (ev.data.supported_events.includes('control_request')) {
console.log('[встраиватель] доступна плоскость управления разрешениями');
}
}
if (ev.type === 'assistant') {
console.log(
'[встраиватель] ответ ассистента завершён, токенов =',
ev.message.usage?.output_tokens,
);
}
if (ev.type === 'system' && ev.subtype === 'session_end') {
console.log('[встраиватель] сессия завершена корректно');
}
});
// Через 2 секунды отправляем промпт, как если бы его ввёл пользователь
setTimeout(() => {
appendFileSync(
input,
JSON.stringify({ type: 'submit', text: 'привет от встраивателя' }) + '\n',
);
}, 2000);
child.on('exit', () => process.exit(0));Запуск:
npx tsx demo-embedder.ts
# Qwen Code TUI открывается в текущем терминале; embedder логирует
# события handshake + turn-end + session_end в stdout родительского процесса.POC 5 — определение возможности через capability handshake
Старые версии Qwen Code не будут отправлять protocol_version. Считайте это поле
опциональным и определяйте поддержку через фичу-детект:
rl.on('line', (line) => {
const ev = JSON.parse(line);
if (ev.type === 'system' && ev.subtype === 'session_start') {
const v = ev.data?.protocol_version ?? 0;
if (v < 1) {
console.error(
'qwen-code dual output присутствует, но протокол < 1; ' +
'переход в режим наилучшего усилия',
);
} else {
console.log('протокол qwen-code dual output v' + v);
}
}
});POC 6 — session_end как чистый сигнал завершения
rl.on('line', (line) => {
const ev = JSON.parse(line);
if (ev.type === 'system' && ev.subtype === 'session_end') {
console.log('[embedder] чистое завершение, сессия', ev.data.session_id);
// Сброс метрик, закрытие WebSockets и т.д.
}
});Если TUI аварийно завершается до session_end, выходной поток закрывается
(EPIPE при следующей записи); embedder’ы должны обрабатывать оба пути.
POC 7 — тестирование отказов (доказательство, что флаги никогда не ломают TUI)
qwen --json-fd 1
# stderr: "Warning: dual output disabled — ..."
# TUI по-прежнему запускается нормально.
qwen --json-fd 9999
# stderr: "Warning: dual output disabled — fd 9999 not open"
# TUI по-прежнему запускается нормально.
qwen --json-fd 3 --json-file /tmp/x.jsonl
# yargs отклоняет: "--json-fd and --json-file are mutually exclusive."
# Процесс завершается до запуска TUI.
qwen --json-file /nonexistent/dir/x.jsonl
# предупреждение в stderr; TUI по-прежнему запускается.Отношение к Claude Code
Claude Code предоставляет аналогичный формат потоковых JSON-событий через
--print --output-format stream-json, но только в неинтерактивном режиме
— у него нет эквивалента одновременного запуска TUI и структурированного
бокового канала. Dual Output заполняет этот пробел.