Skip to Content
Руководство для пользователейВозможностиДвойной вывод

Двойной вывод

Двойной вывод (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 дочернему процессу по трём веским причинам:

  1. Поверхность API. node-pty.spawn(file, args, options) принимает cwd, env, cols, rows, encoding и т.д. — но нет массива stdio. В API просто нет места, чтобы сказать «также присоедини этот fd как fd 3 в дочернем процессе». bun-pty предоставляет тот же интерфейс.
  2. Семантика forkpty(3). Под капотом обёртки PTY вызывают forkpty(3) (или эквивалентный танец posix_openpt + login_tty). Этот системный вызов выделяет пару мастер/слейв псевдотерминала и перенаправляет fd дочернего процесса 0/1/2 на слейв-сторону, так что дочерний процесс считает, что он подключён к реальному терминалу. Любые fd выше 2 в родительском процессе закрываются login_tty, который вызывает close(fd) для fd >= 3 перед exec. Дополнительные fd активно уничтожаются, а не наследуются.
  3. Побочный эффект управления терминалом. Даже если бы вы каким-то образом протащили дополнительный 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.jsonl

POC 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 заполняет этот пробел.

Last updated on