Dual Output
Dual Output는 대화형 TUI의 사이드카 모드입니다: Qwen Code가 stdout에서 정상적으로 렌더링하는 동안, 구조화된 JSON 이벤트 스트림을 별도의 채널로 동시에 내보내어 외부 프로그램 — IDE 확장, 웹 프론트엔드, CI 파이프라인, 자동화 스크립트 — 이 세션을 관찰하고 제어할 수 있게 합니다.
또한 역방향 채널도 제공합니다: 외부 프로그램이 TUI가 감시하는 파일에 JSONL 명령을 작성하여, 마치 사람이 키보드에 있는 것처럼 프롬프트를 제출하고 도구 권한 요청에 응답할 수 있습니다.
Dual Output는 완전히 선택적입니다. 아래 플래그가 없으면 TUI는 추가 I/O나 동작 변경 없이 이전과 동일하게 동작합니다.
사용 사례
Dual Output는 저수준 플럼빙 프리미티브입니다. 이것이 가능하게 하는 구체적인 통합은 다음과 같습니다:
터미널 + Chat 이중 모드 실시간 동기화
주요 사용 사례입니다. 웹 또는 데스크톱 ChatUI가 PTY 내부에 TUI를 호스팅하고 구조화된 이벤트 스트림으로 구동되는 병렬 대화 뷰를 렌더링합니다:
- 사용자는 어느 표면에서든 입력할 수 있습니다 — TUI(터미널 네이티브 파워 유저용) 또는 웹 UI(더 풍부한 UX, 공유 가능한 링크, 모바일용). 두 뷰는 모든 메시지가 같은 JSON 이벤트를 통과하므로 동기화를 유지합니다.
- 도구 승인 프롬프트가 두 곳 모두에 나타납니다; 먼저 승인하는 쪽이 승리합니다.
- 세션 기록은
--json-file에서 그대로 캡처되므로, 서버 측은 ANSI를 파싱하지 않고도 표준 기계 판독 가능한 트랜스크립트를 갖습니다.
IDE 확장 (VS Code / JetBrains / Cursor / Neovim)
IDE 내부에 Qwen Code를 임베드합니다. TUI는 원하는 사용자를 위해 편집기의 통합 터미널 패널에서 실행되고, 확장은 --json-fd / --json-file 이벤트를 소비하여 다음을 구동합니다:
- 에이전트가 파일을 건드릴 때 인라인 diff 오버레이.
- 포맷된 Markdown, 구문 강조된 도구 호출 및 클릭 가능한 인용을 가진 웹뷰 사이드 패널.
- 상태 표시줄 표시기 (생각 중 / 응답 중 / 승인 대기).
- 사용자가 네이티브 IDE 승인 버튼을 클릭할 때 프로그램적
confirmation_response쓰기.
브라우저 기반 Chat 프론트엔드
Node/Bun 서버가 렌더링 의미를 위해 PTY에서 TUI를 생성하지만 브라우저에 WebSocket 채널을 노출합니다. --json-file의 이벤트가 클라이언트로 전달되고; 브라우저에서 입력된 사용자 메시지는 --input-file을 통해 주입됩니다. 양쪽 모두 ANSI 파싱이 필요 없습니다.
CI / 자동화 옵저버
CI 작업이 작업 프롬프트와 함께 Qwen Code를 실행합니다. 사람은 작업 로그에서 TUI를 보고; CI 시스템은 --json-file을 테일하여:
result이벤트가 오류를 보고하면 작업을 실패시킵니다.token usage/duration_ms/tool_use카운트를 메트릭으로 푸시합니다.- 전체 트랜스크립트를 빌드 아티팩트로 보관합니다.
다중 에이전트 오케스트레이션
슈퍼바이저 에이전트가 각각 고유한 이벤트/입력 파일 쌍을 가진 여러 TUI 작업자를 생성합니다. 진행 상황을 감시하고, 후속 프롬프트를 주입하며, 모든 작업자에 걸쳐 도구 호출을 승인 또는 거부하여 전역 예산 / 안전 정책을 시행합니다.
세션 기록, 감사 및 재생
--json-file로 모든 TUI 세션을 일반 파일에 티합니다. 나중에:
- 규정 감사에서 정확히 무엇이 실행되었는지 재구성할 수 있습니다.
- 자동 회귀 테스트가 모델 버전 간 실행을 비교할 수 있습니다.
- 재생 도구가 같은 프로토콜을 통해 이벤트를 재방출하여 시각화 대시보드에 공급할 수 있습니다.
관찰 가능성 대시보드
--json-file을 JSONL을 수용하는 Loki / OTEL / 모든 파이프라인으로 스트리밍합니다. usage.input_tokens, tool_use.name, result.duration_api_ms를 Grafana의 일급 메트릭으로 추출합니다. 로그 파싱 정규식이 필요 없습니다.
테스트 및 QA
통합 테스트는 Qwen Code를 헤드리스로 생성하고, --input-file 스크립트로 구동하며, --json-file 이벤트에서 어서션합니다. stdout ANSI를 파싱하는 것과 달리, 어서션은 UI 리팩토링에 걸쳐 안정적입니다.
플래그
| 플래그 | 타입 | 목적 |
|---|---|---|
--json-fd <n> | number, n >= 3 | 파일 디스크립터 n에 구조화된 JSON 이벤트를 씁니다. 호출자는 spawn stdio 구성이나 셸 리디렉션을 통해 이 fd를 제공해야 합니다. |
--json-file <path> | path | 파일에 구조화된 JSON 이벤트를 씁니다. 경로는 일반 파일, FIFO(이름 있는 파이프) 또는 /dev/fd/N이 될 수 있습니다. |
--input-file <path> | path | 외부 프로그램이 작성한 JSONL 명령을 위해 이 파일을 감시합니다. |
--json-fd와 --json-file은 상호 배타적입니다. fd 0, 1, 2는 TUI 자체 출력을 손상시키지 않기 위해 거부됩니다.
왜 두 개의 출력 플래그가 있는가? (--json-fd vs --json-file)
언뜻 보면 --json-fd로 충분해 보입니다 — 호출자가 추가 파일 디스크립터와 함께 Qwen Code를 spawn하면, TUI가 이벤트를 거기에 쓰고 끝입니다. 실제로, fd 전달은 가장 중요한 임베딩 시나리오에서 무너집니다: 의사 터미널(PTY) 내부에서 TUI를 실행하는 것입니다. 이것이 이 기능이 경로 기반 대안도 노출하는 이유입니다.
--json-fd가 작동할 때
stdio 배열이 있는 순수 child_process.spawn:
const child = spawn('qwen', ['--json-fd', '3'], {
stdio: ['inherit', 'inherit', 'inherit', eventsFd],
});Node의 spawn은 임의의 stdio 항목을 지원합니다; fd 3은 자식에 의해 상속되며, 자식은 이에 직접 쓸 수 있습니다. 제로 카피, 제로 버퍼, 제로 파일 시스템 — 가장 빠른 경로입니다.
--json-fd가 PTY 아래에서 작동하지 않는 이유
node-pty 및 bun-pty와 같은 PTY 래퍼는 심각한 임베더(IDE 확장, 웹 터미널, tmux 유사 멀티플렉서)가 대화형 TUI를 호스팅하는 방법입니다. 이들은 세 가지 상호 강화되는 이유로 자식에 추가 fd를 전달할 수 없습니다:
- API 표면.
node-pty.spawn(file, args, options)은cwd,env,cols,rows,encoding등을 받습니다 — 하지만stdio배열은 없습니다. “이 fd를 자식의 fd 3으로 추가 첨부”라고 말할 API 장소가 단순히 없습니다.bun-pty도 같은 형태를 노출합니다. forkpty(3)시맨틱. 내부적으로 PTY 래퍼는forkpty(3)을 호출합니다(또는 동등한posix_openpt+login_tty댄스). 해당 시스템 콜은 마스터/슬레이브 의사 터미널 쌍을 할당하고 자식의 fd 0/1/2를 슬레이브 측으로 리디렉션하여 자식이 실제 터미널에 연결된 것처럼 생각합니다. 부모의 2 초과 fd는login_tty에 의해 닫히며,exec전에fd >= 3에 대해close(fd)를 호출합니다. 추가 fd는 적극적으로 삭제되며 상속되지 않습니다.- 제어 터미널 부작용. 추가 fd를 해킹해서 통과시킨다 해도, 터미널이 아니므로 자식의 TUI 렌더러(fd 1에서 TTY를 가정하고 이스케이프 시퀀스를 작성)는 여전히 출력을 위해 슬레이브가 필요합니다. 어쨌든 두 개의 독립적인 전송 수단을 갖게 됩니다.
요약하면: 임베더가 TUI 렌더링을 위해 실제 TTY가 필요한 순간 — 모든 IDE 확장, 모든 웹 터미널, 모든 데스크톱 채팅 앱 — fd 상속은 불가능합니다.
--json-file이 갭을 채움
파일 경로는 일반 CLI 인수로 전달되므로 모든 spawn 모델을 생존합니다:
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아래의 세션별 경로 또는 모드0700의mkdtemp’d 디렉토리를 사용하세요.
어떤 플래그를 사용해야 하나요?
| 임베딩 스타일 | 사용 |
|---|---|
일반 stdio를 가진 child_process.spawn | --json-fd |
node-pty / bun-pty / 모든 PTY 호스트 | --json-file |
| 셸 리디렉션 / 수동 파이프라인 테스트 | 둘 다 |
| CI 로그 수집 (일반 파일, 종료 후 읽기) | --json-file |
| 같은 호스트에서 가능한 낮은 지연 | --json-file + FIFO |
일반 규칙: TUI가 올바르게 렌더링되는 것이 필요하면, PTY가 필요하고, 이는 --json-file이 필요하다는 뜻입니다. --json-fd는 TUI 정확도에 신경 쓰지 않는 더 간단한 임베더용입니다 — 일반적으로 stdout을 어쨌든 버리는 프로그램matic 래퍼.
빠른 시작
일반 파일을 사용하여 두 채널을 모두 활성화하여 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":"Explain this repo"}' >> /tmp/qwen-input.jsonl프롬프트가 사용자가 입력한 것과 정확히 동일하게 TUI에 나타나고, 스트리밍 응답이 /tmp/qwen-events.jsonl에 미러링됩니다.
이벤트 출력에 FIFO(이름 있는 파이프) 사용
FIFO는 일반 파일보다 낮은 지연을 전달합니다(디스크 I/O 없음)이며 양쪽이 같은 호스트에 있을 때 잘 작동합니다. 브리지는 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리더가 절대 연결되지 않으면, 브리지는 내부 버퍼가 1MB를 초과하면 자동으로 비활성화됩니다. TUI는 정상적으로 계속 실행됩니다.
출력 이벤트 스키마
이벤트는 JSON Lines로 내보내집니다(한 줄에 하나의 객체). 스키마는 비대화형 --output-format=stream-json 모드에서 사용되는 것과 동일하며, includePartialMessages가 항상 활성화되어 있습니다.
채널의 첫 이벤트는 항상 system / session_start이며, 브리지가 구성될 때 내보내집니다. 다른 이벤트가 도착하기 전에 채널을 세션 id와 상관시키는 데 사용하세요.
// 세션 라이프사이클
{
"type": "system",
"subtype": "session_start",
"uuid": "...",
"session_id": "...",
"data": { "session_id": "...", "cwd": "/path/to/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": "Hello" } }, ... }
{ "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(네이티브 승인 UI)에서 결정되었든 외부 confirmation_response(아래 참조)에서 결정되었든 내보내집니다. 어느 쪽이든, 모든 옵저버가 최종 결과를 볼 수 있습니다.
입력 명령 스키마
--input-file에서 두 가지 명령 형태가 허용됩니다:
// 프롬프트 큐에 사용자 메시지 제출
{ "type": "submit", "text": "What does this function do?" }
// 보류 중인 control_request에 회신
{ "type": "confirmation_response", "request_id": "...", "allowed": true }동작:
submit명령은 대기열에 들어갑니다. TUI가 응답으로 바쁘면, TUI가 다음에 유휴 상태로 돌아올 때 자동으로 재시도됩니다.confirmation_response명령은 즉시 디스패치되며 절대 대기열에 들어가지 않습니다. 도구 호출이 블로킹 중이며 응답이 이전submit을 기다리지 않고 기본onConfirm핸들러에 도달해야 하기 때문입니다.- 어느 쪽이 먼저 도구 승인을 하면 승리합니다; 다른 쪽의 늦은 응답은 해 없이 삭제됩니다.
- JSON으로 파싱에 실패한 라인은 기록되고 건너뜁니다 — 감시자를 중지하지 않습니다.
지연 참고
입력 파일은 500ms 폴링 간격으로 fs.watchFile로 관찰되므로, 원격 submit의 최악의 경우 왕복 지연은 약 0.5초입니다. 이것은 의도적입니다: 폴링은 플랫폼과 파일 시스템(macOS / 네트워크 마운트 포함)에 걸쳐 이식 가능하며, 이 기능이 대상으로 하는 일반적인 인간-인-더-루프 페이싱과 일치합니다. 출력 채널에는 폴링이 없습니다 — 이벤트는 TUI가 내보낼 때 동기적으로 기록됩니다.
실패 모드
- 잘못된 fd.
--json-fd에 전달된 fd가 열려 있지 않거나 0/1/2 중 하나이면, TUI는stderr에 경고를 출력하고 dual output 없이 계속합니다. - 잘못된 경로.
--json-file에 전달된 파일을 열 수 없으면, TUI는 경고를 출력하고 dual output 없이 계속합니다. - 소비자 연결 끊김. 채널 반대편의 리더가 사라지면(
EPIPE), 브리지는 조용히 자체 비활성화하고 TUI는 계속 실행됩니다. 재시도 없음. - FIFO 버퍼 오버플로우. 리더가 연결되지 않은 FIFO에 쓸 때, 이벤트는 커널 파이프(Linux에서 ~64KB)와 Node.js WriteStream에 버퍼링됩니다. 파이프가 가득 차거나 내부 버퍼가 1MB를 초과하면, 브리지는 자체 비활성화하고 fd를 닫습니다. 이 경우
session_end가 내보내지지 않습니다 — 소비자는session_end없이 닫힌 스트림을 비정상 종료로 처리해야 합니다. TUI는 정상적으로 계속 실행됩니다. - 어댑터 예외. 이벤트를 내보내는 동안 발생한 모든 예외는 잡히고, 기록되며, 브리지를 비활성화합니다. TUI는 dual-output 실패로 크래시되지 않습니다.
Spawn 예시
일반적인 임베딩 부모 프로세스는 두 채널과 함께 Qwen Code를 spawn합니다:
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을 지원하는 파일에서 구조화된 이벤트를 읽고 /tmp/qwen-input.jsonl에 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 전달은 정적으로 선언할 수 없는 spawn 시점 문제입니다.- 플래그도 설정도 없으면 dual output은 비활성화된 상태로 유지됩니다(오늘날의 기본값과 동일).
requiresRestart: true 플래그는 변경 사항이 다음 Qwen Code 시작 시에만 적용됨을 의미합니다. 브리지는 시작 시 한 번만 구성되기 때문입니다.
실행 가능한 데모
아래 각 스크립트는 복사하여 바로 사용할 수 있습니다. POC 1로 시작하여 빌드에 dual output이 있는지 확인하세요; 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":"list files in the current directory"}' \
>> /tmp/qwen-in.jsonlPOC 3 — 원격 도구 권한 브리지
별도 프로세스에서 도구 호출을 승인 또는 거부하세요:
# 터미널 A — control_request 관찰
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에게 요청하세요. 예:
# "run `ls -la /tmp`". control_request가 터미널 A에 나타납니다.
# request_id를 복사한 다음 세 번째 터미널에서:
echo '{"type":"confirmation_response","request_id":"<paste-id>","allowed":true}' \
>> /tmp/qwen-input.jsonl
# TUI 확인 프롬프트가 해제되고 도구가 실행됩니다.알 수 없는 request_id로 회신하면, 브리지는 출력 채널에 subtype: "error"가 있는 control_response를 내보내어 소비자가 기록하거나 재시도할 수 있습니다:
{
"type": "control_response",
"response": {
"subtype": "error",
"request_id": "...",
"error": "unknown request_id (already resolved, cancelled, or never issued)"
}
}POC 4 — Node 임베더 (IDE 유사)
가장 현실적인 형태: 부모 프로세스가 Qwen Code를 spawn하고, 이벤트를 테일하고, 자체 일정에 따라 프롬프트를 주입합니다.
// 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',
});
// 출력 채널을 테일합니다. 프로덕션에서는 적절한
// 바이트 오프셋 테일을 사용하겠지만, 간결함을 위해 여기서 0부터 재스트리밍합니다.
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('[embedder] handshake:', {
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('[embedder] permission control-plane available');
}
}
if (ev.type === 'assistant') {
console.log(
'[embedder] assistant turn ended, tokens =',
ev.message.usage?.output_tokens,
);
}
if (ev.type === 'system' && ev.subtype === 'session_end') {
console.log('[embedder] session ended cleanly');
}
});
// 2초 후, 사용자가 입력한 것처럼 프롬프트 주입
setTimeout(() => {
appendFileSync(
input,
JSON.stringify({ type: 'submit', text: 'hello from embedder' }) + '\n',
);
}, 2000);
child.on('exit', () => process.exit(0));실행 방법:
npx tsx demo-embedder.ts
# Qwen Code TUI가 현재 터미널에서 열립니다; 임베더는
# 핸드셰이크 + 턴 종료 + 세션 종료 이벤트를 부모의 stdout에 기록합니다.POC 5 — 기능 핸드셰이크 기능 감지
이전 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 is present but protocol < 1; ' +
'falling back to best-effort behavior',
);
} else {
console.log('qwen-code dual output protocol 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] clean shutdown, session', ev.data.session_id);
// 메트릭 플러시, WebSocket 닫기 등.
}
});TUI가 session_end 전에 크래시하면, 출력 스트림이 닫힙니다(다음 쓰기에 EPIPE); 임베더는 두 경로 모두 처리해야 합니다.
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는 --print --output-format stream-json 아래에 유사한 stream-json 이벤트 형식을 노출하지만, 비대화형 모드에서만 — TUI를 실행하면서 동시에 구조화된 사이드카 채널을 갖는 것과 동등한 것이 없습니다. Dual Output이 그 갭을 채웁니다.