qwen-code/docs/users/features/dual-output.md
ChiGao 91476134ae
Some checks are pending
Qwen Code CI / Classify PR (push) Waiting to run
Qwen Code CI / Lint (push) Blocked by required conditions
Qwen Code CI / Test (macos-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (ubuntu-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Test (windows-latest, Node 22.x) (push) Blocked by required conditions
Qwen Code CI / Post Coverage Comment (push) Blocked by required conditions
Qwen Code CI / CodeQL (push) Blocked by required conditions
E2E Tests / E2E Test (Linux) - sandbox:docker (push) Waiting to run
E2E Tests / E2E Test (Linux) - sandbox:none (push) Waiting to run
E2E Tests / E2E Test - macOS (push) Waiting to run
fix(dual-output): prevent FIFO blocking on startup when no reader connected (#4894)
* fix(dual-output): prevent FIFO blocking on startup when no reader connected

DualOutputBridge's ENXIO fallback used a blocking createWriteStream on
FIFOs, causing the TUI to hang indefinitely when launched with
`--json-file <fifo>` before a reader connects (issue #4727).

Fix: use O_RDWR | O_NONBLOCK for the FIFO fallback path. This POSIX
trick satisfies the kernel's "at least one reader" requirement without
blocking. A buffer high-water-mark (1 MB) self-disables the bridge if
no consumer ever drains the pipe.

Also updates Quick start docs to recommend regular files as the default,
with FIFOs documented as an advanced option that now works without
ordering constraints.

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

* fix(dual-output): address review feedback — guardActive, stream.destroy, tests

- Rename isBufferOverflowing() to guardActive() (command-query separation)
- Apply buffer guard to all write methods, not just processEvent
- Call stream.destroy() on overflow so FIFO consumers get EOF
- Handle destroyed stream in shutdown() to prevent hanging
- Add test: bridge disables on buffer overflow + stream is destroyed
- Fix doc: --input-file requires regular file (not FIFO), stat.size=0

Generated with AI

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>

---------

Co-authored-by: 秦奇 <gary.gq@alibaba-inc.com>
Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
2026-06-15 06:04:06 +08:00

22 KiB

Dual Output

Dual Output is a sidecar mode for the interactive TUI: while Qwen Code keeps rendering normally on stdout, it concurrently emits a structured JSON event stream to a separate channel so an external program — an IDE extension, a web frontend, a CI pipeline, an automation script — can observe and steer the session.

It also provides a reverse channel: an external program can write JSONL commands into a file that the TUI watches, allowing it to submit prompts and respond to tool-permission requests as if a human were at the keyboard.

Dual Output is fully optional. When the flags below are absent the TUI behaves exactly as before with no extra I/O and no behavioral changes.

Use cases

Dual Output is a low-level plumbing primitive. These are concrete integrations it unlocks:

Terminal + Chat dual-mode real-time sync

The flagship use case. A web or desktop ChatUI hosts the TUI inside a PTY and renders a parallel conversation view driven by the structured event stream:

  • User can type in either surface — the TUI (for terminal-native power-users) or the web UI (for richer UX, shareable links, mobile). Both views stay in sync because every message flows through the same JSON events.
  • Tool-approval prompts appear in both places; whoever approves first wins.
  • Session history is captured verbatim from --json-file, so the server side has a canonical machine-readable transcript without parsing ANSI.

IDE extensions (VS Code / JetBrains / Cursor / Neovim)

Embed Qwen Code inside the IDE. The TUI runs in the editor's integrated terminal panel for users who want it, while the extension consumes --json-fd / --json-file events to drive:

  • Inline diff overlays when the agent touches files.
  • A webview side panel with formatted markdown, syntax-highlighted tool calls, and clickable citations.
  • Status bar indicators (thinking / responding / awaiting approval).
  • Programmatic confirmation_response writes when the user clicks a native IDE approval button.

Browser-based Chat frontends

A Node/Bun server spawns the TUI in a PTY for its rendering semantics but exposes a WebSocket channel to the browser. Events on --json-file are forwarded to the client; user messages typed in the browser are injected via --input-file. No ANSI parsing on either side.

CI / automation observers

A CI job runs Qwen Code with a task prompt. The human sees the TUI in the job log; the CI system tails --json-file to:

  • Fail the job if a result event reports an error.
  • Push token usage / duration_ms / tool_use counts to metrics.
  • Archive the full transcript as a build artifact.

Multi-agent orchestration

A supervisor agent spawns multiple TUI workers, each with its own pair of event/input files. It watches progress, injects follow-up prompts, and enforces global budget / safety policies by approving or denying tool calls across all workers.

Session recording, audit, and replay

Tee every TUI session to a regular file with --json-file. Later:

  • Compliance audits can reconstruct exactly what was executed.
  • Automated regression tests can compare runs across model versions.
  • A replay tool can re-emit events through the same protocol to feed visualization dashboards.

Observability dashboards

Stream --json-file into Loki / OTEL / any pipeline that accepts JSONL. Extract usage.input_tokens, tool_use.name, result.duration_api_ms as first-class metrics in Grafana. No need for log-parsing regex.

Testing and QA

Integration tests spawn Qwen Code headlessly, drive it with --input-file scripts, and assert on --json-file events. Unlike parsing stdout ANSI, assertions are stable across UI refactors.

Flags

Flag Type Purpose
--json-fd <n> number, n >= 3 Write structured JSON events to file descriptor n. The caller must provide this fd via spawn stdio configuration or shell redirection.
--json-file <path> path Write structured JSON events to a file. The path can be a regular file, a FIFO (named pipe), or /dev/fd/N.
--input-file <path> path Watch this file for JSONL commands written by an external program.

--json-fd and --json-file are mutually exclusive. fds 0, 1, and 2 are rejected to prevent corrupting the TUI's own output.

Why two output flags? (--json-fd vs --json-file)

At first glance --json-fd looks sufficient — the caller spawns Qwen Code with an extra file descriptor, the TUI writes events to it, done. In practice, fd passing breaks down under the most important embedding scenario: running the TUI inside a pseudo-terminal (PTY). That is why this feature also exposes a path-based alternative.

When --json-fd works

Pure child_process.spawn with a stdio array:

const child = spawn('qwen', ['--json-fd', '3'], {
  stdio: ['inherit', 'inherit', 'inherit', eventsFd],
});

Node's spawn supports arbitrary stdio entries; fd 3 is inherited by the child, which can write to it directly. Zero-copy, zero-buffer, zero filesystem — the fastest path.

Why --json-fd does not work under PTY

PTY wrappers like node-pty and bun-pty are how any serious embedder (IDE extensions, web terminals, tmux-like multiplexers) hosts an interactive TUI. They cannot forward extra fds to the child, for three reinforcing reasons:

  1. API surface. node-pty.spawn(file, args, options) accepts cwd, env, cols, rows, encoding, etc. — but no stdio array. There is simply no place in the API to say "also attach this fd as fd 3 in the child". bun-pty exposes the same shape.
  2. forkpty(3) semantics. Under the hood, PTY wrappers call forkpty(3) (or the equivalent posix_openpt + login_tty dance). That syscall allocates a master/slave pseudo-terminal pair and redirects the child's fds 0/1/2 to the slave side so the child thinks it is attached to a real terminal. Any fds above 2 in the parent are closed by login_tty, which calls close(fd) for fd >= 3 before exec. Extra fds are actively wiped, not inherited.
  3. Controlling-terminal side effect. Even if you hacked an extra fd through, it would not be a terminal, so the child's TUI renderer (which writes escape sequences assuming a TTY on fd 1) would still need the slave for its output. You would end up with two independent transports anyway.

In short: the moment an embedder needs a real TTY for TUI rendering — which is every IDE extension, every web terminal, every desktop chat app — fd inheritance is off the table.

--json-file fills the gap

A file path is passed as an ordinary CLI argument, so it survives every spawn model:

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 },
);

The child opens the file itself and writes events there; the embedder tails the same path with fs.watch + incremental reads. Three things to note:

  • Regular file, FIFO (named pipe), or /dev/fd/N all work. FIFO is the lowest-latency option when both sides are on the same host.
  • The bridge opens FIFOs with O_NONBLOCK and falls back to blocking mode on ENXIO (no reader yet), so PTY startup is never deadlocked waiting for a consumer.
  • For multi-session isolation, use per-session paths under $XDG_RUNTIME_DIR or a mkdtemp'd directory with mode 0700.

Which flag should I use?

Embedding style Use
child_process.spawn with plain stdio --json-fd
node-pty / bun-pty / any PTY host --json-file
Shell redirection / manual pipeline testing either
CI log collection (regular file, read after exit) --json-file
Lowest possible latency on same host --json-file + FIFO

The general rule: if you need the TUI to render correctly, you need a PTY, which means you need --json-file. --json-fd is for simpler embedders that do not care about TUI fidelity — typically programmatic wrappers that throw away stdout anyway.

Quick start

Run Qwen Code with both channels enabled using regular files:

touch /tmp/qwen-events.jsonl /tmp/qwen-input.jsonl
qwen \
  --json-file /tmp/qwen-events.jsonl \
  --input-file /tmp/qwen-input.jsonl

In a second terminal, tail the event stream:

tail -f /tmp/qwen-events.jsonl

In a third terminal, push a prompt into the running TUI:

echo '{"type":"submit","text":"Explain this repo"}' >> /tmp/qwen-input.jsonl

The prompt appears in the TUI exactly as if the user typed it, and the streaming response is mirrored on /tmp/qwen-events.jsonl.

Using FIFOs (named pipes) for event output

FIFOs deliver lower latency than regular files (no disk I/O) and work well when both sides are on the same host. The bridge opens FIFOs with O_RDWR | O_NONBLOCK, so it does not block even if no reader is connected yet — events are buffered in the kernel pipe buffer until a reader attaches.

Note: --input-file requires a regular file (not a FIFO) because the watcher relies on stat.size to detect new data, which is always 0 for FIFOs.

mkfifo /tmp/qwen-events.jsonl
touch /tmp/qwen-input.jsonl
qwen \
  --json-file /tmp/qwen-events.jsonl \
  --input-file /tmp/qwen-input.jsonl
# TUI starts immediately — no need to start a reader first.

# In a second terminal, connect whenever ready:
cat /tmp/qwen-events.jsonl

If no reader ever connects, the bridge auto-disables once the internal buffer exceeds 1 MB. The TUI continues running normally.

Output event schema

Events are emitted as JSON Lines (one object per line). The schema is the same one used by the non-interactive --output-format=stream-json mode, with includePartialMessages always enabled.

The first event on the channel is always system / session_start, emitted when the bridge is constructed. Use it to correlate the channel with a session id before any other event arrives.

// Session lifecycle
{
  "type": "system",
  "subtype": "session_start",
  "uuid": "...",
  "session_id": "...",
  "data": { "session_id": "...", "cwd": "/path/to/cwd" }
}

// Streaming events for an in-progress assistant turn
{ "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" }, ... }

// Completed messages
{ "type": "user", "message": { "role": "user", "content": [...] }, ... }
{ "type": "assistant", "message": { "role": "assistant", "content": [...], "usage": { ... } }, ... }
{ "type": "user", "message": { "role": "user", "content": [{ "type": "tool_result", ... }] } }

// Permission control plane (only when a tool needs approval)
{
  "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 is emitted whether the decision was made in the TUI (native approval UI) or by an external confirmation_response (see below). Either way, all observers see the final outcome.

Input command schema

Two command shapes are accepted on --input-file:

// Submit a user message into the prompt queue
{ "type": "submit", "text": "What does this function do?" }

// Reply to a pending control_request
{ "type": "confirmation_response", "request_id": "...", "allowed": true }

Behavior:

  • submit commands are queued. If the TUI is busy responding, they are retried automatically the next time the TUI returns to the idle state.
  • confirmation_response commands are dispatched immediately and never queued, because a tool call is blocking and the response must reach the underlying onConfirm handler without waiting for any earlier submit.
  • Whichever side approves a tool first wins; the other side's late response is harmlessly dropped.
  • Lines that fail to parse as JSON are logged and skipped — they do not stop the watcher.

Latency notes

The input file is observed with fs.watchFile at a 500 ms polling interval, so worst-case round-trip latency for a remote submit is about half a second. This is intentional: polling is portable across platforms and filesystems (including macOS / network mounts), and matches the typical human-in-the-loop pacing the feature targets. The output channel has no polling — events are written synchronously as the TUI emits them.

Failure modes

  • Bad fd. If the fd passed to --json-fd is not open or is one of 0/1/2, the TUI prints a warning to stderr and continues without dual output enabled.
  • Bad path. If the file passed to --json-file cannot be opened, the TUI prints a warning and continues without dual output.
  • Consumer disconnect. If the reader on the other side of the channel goes away (EPIPE), the bridge silently disables itself and the TUI keeps running. No retry.
  • FIFO buffer overflow. When writing to a FIFO with no reader attached, events buffer in the kernel pipe (~64 KB on Linux) and the Node.js WriteStream. Once the pipe is full or the internal buffer exceeds 1 MB, the bridge disables itself and closes the fd. No session_end is emitted in this case — consumers should treat a closed stream without session_end as an abnormal termination. The TUI continues running normally.
  • Adapter exception. Any exception thrown while emitting an event is caught, logged, and disables the bridge. The TUI is never crashed by a dual-output failure.

Spawn example

A typical embedding parent process spawns Qwen Code with both channels:

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] },
);

The TUI still owns the user's terminal on stdio 0/1/2, while the embedder reads structured events on the file backing fd 3 and pushes commands by appending JSONL lines to /tmp/qwen-input.jsonl.

Settings-based configuration

For long-lived embedders it is often inconvenient to thread CLI flags through every launch. The same channels can be configured in settings.json under the top-level dualOutput key:

// ~/.qwen/settings.json  (user-level)
// or <workspace>/.qwen/settings.json  (workspace-level)
{
  "dualOutput": {
    "jsonFile": "/tmp/qwen-events.jsonl",
    "inputFile": "/tmp/qwen-input.jsonl",
  },
}

Precedence rules:

  • CLI flag wins over settings. Passing --json-file /foo on the command line overrides dualOutput.jsonFile in settings.
  • --json-fd has no settings equivalent — fd passing is a spawn-time concern that cannot be statically declared.
  • If neither flag nor setting is present, dual output stays disabled (identical to today's default).

The requiresRestart: true flag means changes only take effect on the next Qwen Code launch, since the bridge is constructed once during startup.

Runnable demos

Every script below is copy-paste ready. Start with POC 1 to verify the build has dual output; POC 4 is the closest analogue to a real IDE-extension integration.

POC 1 — observe the event stream

Watch every structured event the TUI emits while a human uses it normally:

# Terminal A
mkfifo /tmp/qwen-events.jsonl
cat /tmp/qwen-events.jsonl | jq -c 'select(.type != "stream_event") | {type, subtype}'

# Terminal B
qwen --json-file /tmp/qwen-events.jsonl
# ...then chat normally; terminal A shows session_start,
# user/assistant/result/control_request lifecycle in real time.

Expected first line in terminal A:

{ "type": "system", "subtype": "session_start" }

POC 2 — inject prompts from outside

Drive the TUI from a second terminal without touching the keyboard of the first:

# Terminal A
touch /tmp/qwen-in.jsonl
qwen --input-file /tmp/qwen-in.jsonl

# Terminal B — the TUI responds as if you typed it
echo '{"type":"submit","text":"list files in the current directory"}' \
  >> /tmp/qwen-in.jsonl

POC 3 — remote tool-permission bridge

Approve or deny tool calls from a separate process:

# Terminal A — observe control_requests
mkfifo /tmp/qwen-out.jsonl
touch /tmp/qwen-in.jsonl
(cat /tmp/qwen-out.jsonl \
  | jq -c 'select(.type == "control_request")') &

# Terminal B
qwen --json-file /tmp/qwen-out.jsonl --input-file /tmp/qwen-in.jsonl
# Ask Qwen to do something that needs approval, e.g.
# "run `ls -la /tmp`". A control_request will appear in terminal A.
# Copy the request_id, then in a third terminal:
echo '{"type":"confirmation_response","request_id":"<paste-id>","allowed":true}' \
  >> /tmp/qwen-in.jsonl
# The TUI confirmation prompt dismisses and the tool executes.

If you reply with an unknown request_id, the bridge emits a control_response with subtype: "error" on the output channel so your consumer can log it or retry:

{
  "type": "control_response",
  "response": {
    "subtype": "error",
    "request_id": "...",
    "error": "unknown request_id (already resolved, cancelled, or never issued)"
  }
}

POC 4 — Node embedder (IDE-like)

The most realistic shape: a parent process spawns Qwen Code, tails events, and injects prompts on its own schedule.

// 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',
});

// Tail the output channel. In production you'd use a proper
// byte-offset tail; this one re-streams from 0 for brevity.
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,
    });
    // Feature-detect before using a capability
    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');
  }
});

// After 2s, inject a prompt as if the user typed it
setTimeout(() => {
  appendFileSync(
    input,
    JSON.stringify({ type: 'submit', text: 'hello from embedder' }) + '\n',
  );
}, 2000);

child.on('exit', () => process.exit(0));

Run with:

npx tsx demo-embedder.ts
# Qwen Code TUI opens in the current terminal; the embedder logs
# handshake + turn-end + session_end events to the parent's stdout.

POC 5 — capability handshake feature detection

Older Qwen Code versions won't emit protocol_version. Treat the field as optional and feature-detect:

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 as a clean termination signal

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);
    // Flush metrics, close WebSockets, etc.
  }
});

If the TUI crashes before session_end, the output stream closes (EPIPE on next write); embedders should handle both paths.

POC 7 — failure drills (prove the flags never break the TUI)

qwen --json-fd 1
# stderr: "Warning: dual output disabled — ..."
# TUI still launches normally.

qwen --json-fd 9999
# stderr: "Warning: dual output disabled — fd 9999 not open"
# TUI still launches normally.

qwen --json-fd 3 --json-file /tmp/x.jsonl
# yargs rejects: "--json-fd and --json-file are mutually exclusive."
# Process exits before TUI starts.

qwen --json-file /nonexistent/dir/x.jsonl
# stderr warning; TUI still launches.

Relation to Claude Code

Claude Code exposes a similar stream-json event format under --print --output-format stream-json, but only in non-interactive mode — it has no equivalent of running the TUI and a structured sidecar channel at the same time. Dual Output fills that gap.