@qwen-code/qwen-code 0.15.0-preview.0 → 0.15.0-preview.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,593 @@
1
+ # Dual Output
2
+
3
+ Dual Output is a sidecar mode for the interactive TUI: while Qwen Code keeps
4
+ rendering normally on `stdout`, it concurrently emits a structured JSON event
5
+ stream to a separate channel so an external program — an IDE extension, a web
6
+ frontend, a CI pipeline, an automation script — can observe and steer the
7
+ session.
8
+
9
+ It also provides a reverse channel: an external program can write JSONL
10
+ commands into a file that the TUI watches, allowing it to submit prompts and
11
+ respond to tool-permission requests as if a human were at the keyboard.
12
+
13
+ Dual Output is fully optional. When the flags below are absent the TUI behaves
14
+ exactly as before with no extra I/O and no behavioral changes.
15
+
16
+ ## Use cases
17
+
18
+ Dual Output is a low-level plumbing primitive. These are concrete integrations
19
+ it unlocks:
20
+
21
+ ### Terminal + Chat dual-mode real-time sync
22
+
23
+ The flagship use case. A web or desktop ChatUI hosts the TUI inside a PTY
24
+ and renders a parallel conversation view driven by the structured event
25
+ stream:
26
+
27
+ - User can type in either surface — the TUI (for terminal-native power-users)
28
+ or the web UI (for richer UX, shareable links, mobile). Both views stay
29
+ in sync because every message flows through the same JSON events.
30
+ - Tool-approval prompts appear in both places; whoever approves first wins.
31
+ - Session history is captured verbatim from `--json-file`, so the server
32
+ side has a canonical machine-readable transcript without parsing ANSI.
33
+
34
+ ### IDE extensions (VS Code / JetBrains / Cursor / Neovim)
35
+
36
+ Embed Qwen Code inside the IDE. The TUI runs in the editor's integrated
37
+ terminal panel for users who want it, while the extension consumes
38
+ `--json-fd` / `--json-file` events to drive:
39
+
40
+ - Inline diff overlays when the agent touches files.
41
+ - A webview side panel with formatted markdown, syntax-highlighted tool
42
+ calls, and clickable citations.
43
+ - Status bar indicators (thinking / responding / awaiting approval).
44
+ - Programmatic `confirmation_response` writes when the user clicks a
45
+ native IDE approval button.
46
+
47
+ ### Browser-based Chat frontends
48
+
49
+ A Node/Bun server spawns the TUI in a PTY for its rendering semantics but
50
+ exposes a WebSocket channel to the browser. Events on `--json-file` are
51
+ forwarded to the client; user messages typed in the browser are injected
52
+ via `--input-file`. No ANSI parsing on either side.
53
+
54
+ ### CI / automation observers
55
+
56
+ A CI job runs Qwen Code with a task prompt. The human sees the TUI in the
57
+ job log; the CI system tails `--json-file` to:
58
+
59
+ - Fail the job if a `result` event reports an error.
60
+ - Push `token usage` / `duration_ms` / `tool_use` counts to metrics.
61
+ - Archive the full transcript as a build artifact.
62
+
63
+ ### Multi-agent orchestration
64
+
65
+ A supervisor agent spawns multiple TUI workers, each with its own pair of
66
+ event/input files. It watches progress, injects follow-up prompts, and
67
+ enforces global budget / safety policies by approving or denying tool
68
+ calls across all workers.
69
+
70
+ ### Session recording, audit, and replay
71
+
72
+ Tee every TUI session to a regular file with `--json-file`. Later:
73
+
74
+ - Compliance audits can reconstruct exactly what was executed.
75
+ - Automated regression tests can compare runs across model versions.
76
+ - A replay tool can re-emit events through the same protocol to feed
77
+ visualization dashboards.
78
+
79
+ ### Observability dashboards
80
+
81
+ Stream `--json-file` into Loki / OTEL / any pipeline that accepts JSONL.
82
+ Extract `usage.input_tokens`, `tool_use.name`, `result.duration_api_ms`
83
+ as first-class metrics in Grafana. No need for log-parsing regex.
84
+
85
+ ### Testing and QA
86
+
87
+ Integration tests spawn Qwen Code headlessly, drive it with `--input-file`
88
+ scripts, and assert on `--json-file` events. Unlike parsing stdout ANSI,
89
+ assertions are stable across UI refactors.
90
+
91
+ ## Flags
92
+
93
+ | Flag | Type | Purpose |
94
+ | --------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------ |
95
+ | `--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. |
96
+ | `--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`. |
97
+ | `--input-file <path>` | path | Watch this file for JSONL commands written by an external program. |
98
+
99
+ `--json-fd` and `--json-file` are mutually exclusive. fds 0, 1, and 2 are
100
+ rejected to prevent corrupting the TUI's own output.
101
+
102
+ ## Why two output flags? (`--json-fd` vs `--json-file`)
103
+
104
+ At first glance `--json-fd` looks sufficient — the caller spawns Qwen Code
105
+ with an extra file descriptor, the TUI writes events to it, done. In
106
+ practice, fd passing breaks down under the most important embedding
107
+ scenario: running the TUI inside a pseudo-terminal (PTY). That is why
108
+ this feature also exposes a path-based alternative.
109
+
110
+ ### When `--json-fd` works
111
+
112
+ Pure `child_process.spawn` with a `stdio` array:
113
+
114
+ ```ts
115
+ const child = spawn('qwen', ['--json-fd', '3'], {
116
+ stdio: ['inherit', 'inherit', 'inherit', eventsFd],
117
+ });
118
+ ```
119
+
120
+ Node's spawn supports arbitrary `stdio` entries; fd 3 is inherited by the
121
+ child, which can write to it directly. Zero-copy, zero-buffer, zero
122
+ filesystem — the fastest path.
123
+
124
+ ### Why `--json-fd` does **not** work under PTY
125
+
126
+ PTY wrappers like [`node-pty`](https://github.com/microsoft/node-pty) and
127
+ [`bun-pty`](https://github.com/oven-sh/bun) are how any serious embedder
128
+ (IDE extensions, web terminals, tmux-like multiplexers) hosts an
129
+ interactive TUI. They cannot forward extra fds to the child, for three
130
+ reinforcing reasons:
131
+
132
+ 1. **API surface.** `node-pty.spawn(file, args, options)` accepts `cwd`,
133
+ `env`, `cols`, `rows`, `encoding`, etc. — but **no `stdio` array**. There
134
+ is simply no place in the API to say "also attach this fd as fd 3 in
135
+ the child". `bun-pty` exposes the same shape.
136
+ 2. **`forkpty(3)` semantics.** Under the hood, PTY wrappers call
137
+ `forkpty(3)` (or the equivalent `posix_openpt` + `login_tty` dance).
138
+ That syscall allocates a master/slave pseudo-terminal pair and
139
+ redirects the child's fds 0/1/2 to the slave side so the child thinks
140
+ it is attached to a real terminal. Any fds above 2 in the parent are
141
+ closed by `login_tty`, which calls `close(fd)` for `fd >= 3` before
142
+ `exec`. Extra fds are actively wiped, not inherited.
143
+ 3. **Controlling-terminal side effect.** Even if you hacked an extra fd
144
+ through, it would not be a terminal, so the child's TUI renderer
145
+ (which writes escape sequences assuming a TTY on fd 1) would still
146
+ need the slave for its output. You would end up with two independent
147
+ transports anyway.
148
+
149
+ In short: the moment an embedder needs a real TTY for TUI rendering —
150
+ which is every IDE extension, every web terminal, every desktop chat
151
+ app — fd inheritance is off the table.
152
+
153
+ ### `--json-file` fills the gap
154
+
155
+ A file path is passed as an ordinary CLI argument, so it survives every
156
+ spawn model:
157
+
158
+ ```ts
159
+ import { spawn } from 'node-pty';
160
+
161
+ const pty = spawn(
162
+ 'qwen',
163
+ [
164
+ '--json-file',
165
+ '/tmp/qwen-events.jsonl',
166
+ '--input-file',
167
+ '/tmp/qwen-input.jsonl',
168
+ ],
169
+ { cols: 120, rows: 40 },
170
+ );
171
+ ```
172
+
173
+ The child opens the file itself and writes events there; the embedder
174
+ tails the same path with `fs.watch` + incremental reads. Three things to
175
+ note:
176
+
177
+ - **Regular file**, FIFO (named pipe), or `/dev/fd/N` all work. FIFO is
178
+ the lowest-latency option when both sides are on the same host.
179
+ - The bridge opens FIFOs with `O_NONBLOCK` and falls back to blocking
180
+ mode on `ENXIO` (no reader yet), so PTY startup is never deadlocked
181
+ waiting for a consumer.
182
+ - For multi-session isolation, use per-session paths under
183
+ `$XDG_RUNTIME_DIR` or a `mkdtemp`'d directory with mode `0700`.
184
+
185
+ ### Which flag should I use?
186
+
187
+ | Embedding style | Use |
188
+ | ------------------------------------------------- | -------------------- |
189
+ | `child_process.spawn` with plain stdio | `--json-fd` |
190
+ | `node-pty` / `bun-pty` / any PTY host | `--json-file` |
191
+ | Shell redirection / manual pipeline testing | either |
192
+ | CI log collection (regular file, read after exit) | `--json-file` |
193
+ | Lowest possible latency on same host | `--json-file` + FIFO |
194
+
195
+ The general rule: **if you need the TUI to render correctly, you need a
196
+ PTY, which means you need `--json-file`.** `--json-fd` is for simpler
197
+ embedders that do not care about TUI fidelity — typically programmatic
198
+ wrappers that throw away stdout anyway.
199
+
200
+ ## Quick start
201
+
202
+ Run Qwen Code with all three channels enabled:
203
+
204
+ ```bash
205
+ mkfifo /tmp/qwen-events.jsonl /tmp/qwen-input.jsonl
206
+ qwen \
207
+ --json-file /tmp/qwen-events.jsonl \
208
+ --input-file /tmp/qwen-input.jsonl
209
+ ```
210
+
211
+ In a second terminal, tail the event stream:
212
+
213
+ ```bash
214
+ cat /tmp/qwen-events.jsonl
215
+ ```
216
+
217
+ In a third terminal, push a prompt into the running TUI:
218
+
219
+ ```bash
220
+ echo '{"type":"submit","text":"Explain this repo"}' >> /tmp/qwen-input.jsonl
221
+ ```
222
+
223
+ The prompt appears in the TUI exactly as if the user typed it, and the
224
+ streaming response is mirrored on `/tmp/qwen-events.jsonl`.
225
+
226
+ ## Output event schema
227
+
228
+ Events are emitted as JSON Lines (one object per line). The schema is the same
229
+ one used by the non-interactive `--output-format=stream-json` mode, with
230
+ `includePartialMessages` always enabled.
231
+
232
+ The first event on the channel is always `system` / `session_start`, emitted
233
+ when the bridge is constructed. Use it to correlate the channel with a
234
+ session id before any other event arrives.
235
+
236
+ ```jsonc
237
+ // Session lifecycle
238
+ {
239
+ "type": "system",
240
+ "subtype": "session_start",
241
+ "uuid": "...",
242
+ "session_id": "...",
243
+ "data": { "session_id": "...", "cwd": "/path/to/cwd" }
244
+ }
245
+
246
+ // Streaming events for an in-progress assistant turn
247
+ { "type": "stream_event", "event": { "type": "message_start", "message": { ... } }, ... }
248
+ { "type": "stream_event", "event": { "type": "content_block_start", "index": 0, "content_block": { "type": "text" } }, ... }
249
+ { "type": "stream_event", "event": { "type": "content_block_delta", "index": 0, "delta": { "type": "text_delta", "text": "Hello" } }, ... }
250
+ { "type": "stream_event", "event": { "type": "content_block_stop", "index": 0 }, ... }
251
+ { "type": "stream_event", "event": { "type": "message_stop" }, ... }
252
+
253
+ // Completed messages
254
+ { "type": "user", "message": { "role": "user", "content": [...] }, ... }
255
+ { "type": "assistant", "message": { "role": "assistant", "content": [...], "usage": { ... } }, ... }
256
+ { "type": "user", "message": { "role": "user", "content": [{ "type": "tool_result", ... }] } }
257
+
258
+ // Permission control plane (only when a tool needs approval)
259
+ {
260
+ "type": "control_request",
261
+ "request_id": "...",
262
+ "request": {
263
+ "subtype": "can_use_tool",
264
+ "tool_name": "run_shell_command",
265
+ "tool_use_id": "...",
266
+ "input": { "command": "rm -rf /tmp/x" },
267
+ "permission_suggestions": null,
268
+ "blocked_path": null
269
+ }
270
+ }
271
+ {
272
+ "type": "control_response",
273
+ "response": {
274
+ "subtype": "success",
275
+ "request_id": "...",
276
+ "response": { "allowed": true }
277
+ }
278
+ }
279
+ ```
280
+
281
+ `control_response` is emitted whether the decision was made in the TUI
282
+ (native approval UI) or by an external `confirmation_response` (see below).
283
+ Either way, all observers see the final outcome.
284
+
285
+ ## Input command schema
286
+
287
+ Two command shapes are accepted on `--input-file`:
288
+
289
+ ```jsonc
290
+ // Submit a user message into the prompt queue
291
+ { "type": "submit", "text": "What does this function do?" }
292
+
293
+ // Reply to a pending control_request
294
+ { "type": "confirmation_response", "request_id": "...", "allowed": true }
295
+ ```
296
+
297
+ Behavior:
298
+
299
+ - `submit` commands are queued. If the TUI is busy responding, they are
300
+ retried automatically the next time the TUI returns to the idle state.
301
+ - `confirmation_response` commands are dispatched immediately and never
302
+ queued, because a tool call is blocking and the response must reach the
303
+ underlying `onConfirm` handler without waiting for any earlier `submit`.
304
+ - Whichever side approves a tool first wins; the other side's late response
305
+ is harmlessly dropped.
306
+ - Lines that fail to parse as JSON are logged and skipped — they do not
307
+ stop the watcher.
308
+
309
+ ## Latency notes
310
+
311
+ The input file is observed with `fs.watchFile` at a 500 ms polling interval,
312
+ so worst-case round-trip latency for a remote `submit` is about half a
313
+ second. This is intentional: polling is portable across platforms and
314
+ filesystems (including macOS / network mounts), and matches the typical
315
+ human-in-the-loop pacing the feature targets. The output channel has no
316
+ polling — events are written synchronously as the TUI emits them.
317
+
318
+ ## Failure modes
319
+
320
+ - **Bad fd.** If the fd passed to `--json-fd` is not open or is one of
321
+ 0/1/2, the TUI prints a warning to `stderr` and continues without dual
322
+ output enabled.
323
+ - **Bad path.** If the file passed to `--json-file` cannot be opened, the
324
+ TUI prints a warning and continues without dual output.
325
+ - **Consumer disconnect.** If the reader on the other side of the channel
326
+ goes away (`EPIPE`), the bridge silently disables itself and the TUI
327
+ keeps running. No retry.
328
+ - **Adapter exception.** Any exception thrown while emitting an event is
329
+ caught, logged, and disables the bridge. The TUI is never crashed by a
330
+ dual-output failure.
331
+
332
+ ## Spawn example
333
+
334
+ A typical embedding parent process spawns Qwen Code with both channels:
335
+
336
+ ```ts
337
+ import { spawn } from 'node:child_process';
338
+ import { openSync } from 'node:fs';
339
+
340
+ const eventsFd = openSync('/tmp/qwen-events.jsonl', 'w');
341
+ const child = spawn(
342
+ 'qwen',
343
+ ['--json-fd', '3', '--input-file', '/tmp/qwen-input.jsonl'],
344
+ { stdio: ['inherit', 'inherit', 'inherit', eventsFd] },
345
+ );
346
+ ```
347
+
348
+ The TUI still owns the user's terminal on stdio 0/1/2, while the embedder
349
+ reads structured events on the file backing fd 3 and pushes commands by
350
+ appending JSONL lines to `/tmp/qwen-input.jsonl`.
351
+
352
+ ## Settings-based configuration
353
+
354
+ For long-lived embedders it is often inconvenient to thread CLI flags
355
+ through every launch. The same channels can be configured in
356
+ `settings.json` under the top-level `dualOutput` key:
357
+
358
+ ```jsonc
359
+ // ~/.qwen/settings.json (user-level)
360
+ // or <workspace>/.qwen/settings.json (workspace-level)
361
+ {
362
+ "dualOutput": {
363
+ "jsonFile": "/tmp/qwen-events.jsonl",
364
+ "inputFile": "/tmp/qwen-input.jsonl",
365
+ },
366
+ }
367
+ ```
368
+
369
+ Precedence rules:
370
+
371
+ - CLI flag **wins** over settings. Passing `--json-file /foo` on the
372
+ command line overrides `dualOutput.jsonFile` in settings.
373
+ - `--json-fd` has no settings equivalent — fd passing is a spawn-time
374
+ concern that cannot be statically declared.
375
+ - If neither flag nor setting is present, dual output stays disabled
376
+ (identical to today's default).
377
+
378
+ The `requiresRestart: true` flag means changes only take effect on the
379
+ next Qwen Code launch, since the bridge is constructed once during
380
+ startup.
381
+
382
+ ## Runnable demos
383
+
384
+ Every script below is copy-paste ready. Start with POC&nbsp;1 to verify
385
+ the build has dual output; POC&nbsp;4 is the closest analogue to a real
386
+ IDE-extension integration.
387
+
388
+ ### POC 1 — observe the event stream
389
+
390
+ Watch every structured event the TUI emits while a human uses it
391
+ normally:
392
+
393
+ ```bash
394
+ # Terminal A
395
+ mkfifo /tmp/qwen-events.jsonl
396
+ cat /tmp/qwen-events.jsonl | jq -c 'select(.type != "stream_event") | {type, subtype}'
397
+
398
+ # Terminal B
399
+ qwen --json-file /tmp/qwen-events.jsonl
400
+ # ...then chat normally; terminal A shows session_start,
401
+ # user/assistant/result/control_request lifecycle in real time.
402
+ ```
403
+
404
+ Expected first line in terminal A:
405
+
406
+ ```json
407
+ { "type": "system", "subtype": "session_start" }
408
+ ```
409
+
410
+ ### POC 2 — inject prompts from outside
411
+
412
+ Drive the TUI from a second terminal without touching the keyboard of
413
+ the first:
414
+
415
+ ```bash
416
+ # Terminal A
417
+ touch /tmp/qwen-in.jsonl
418
+ qwen --input-file /tmp/qwen-in.jsonl
419
+
420
+ # Terminal B — the TUI responds as if you typed it
421
+ echo '{"type":"submit","text":"list files in the current directory"}' \
422
+ >> /tmp/qwen-in.jsonl
423
+ ```
424
+
425
+ ### POC 3 — remote tool-permission bridge
426
+
427
+ Approve or deny tool calls from a separate process:
428
+
429
+ ```bash
430
+ # Terminal A — observe control_requests
431
+ mkfifo /tmp/qwen-out.jsonl
432
+ touch /tmp/qwen-in.jsonl
433
+ (cat /tmp/qwen-out.jsonl \
434
+ | jq -c 'select(.type == "control_request")') &
435
+
436
+ # Terminal B
437
+ qwen --json-file /tmp/qwen-out.jsonl --input-file /tmp/qwen-in.jsonl
438
+ # Ask Qwen to do something that needs approval, e.g.
439
+ # "run `ls -la /tmp`". A control_request will appear in terminal A.
440
+ # Copy the request_id, then in a third terminal:
441
+ echo '{"type":"confirmation_response","request_id":"<paste-id>","allowed":true}' \
442
+ >> /tmp/qwen-in.jsonl
443
+ # The TUI confirmation prompt dismisses and the tool executes.
444
+ ```
445
+
446
+ If you reply with an unknown `request_id`, the bridge emits a
447
+ `control_response` with `subtype: "error"` on the output channel so your
448
+ consumer can log it or retry:
449
+
450
+ ```json
451
+ {
452
+ "type": "control_response",
453
+ "response": {
454
+ "subtype": "error",
455
+ "request_id": "...",
456
+ "error": "unknown request_id (already resolved, cancelled, or never issued)"
457
+ }
458
+ }
459
+ ```
460
+
461
+ ### POC 4 — Node embedder (IDE-like)
462
+
463
+ The most realistic shape: a parent process spawns Qwen Code, tails
464
+ events, and injects prompts on its own schedule.
465
+
466
+ ```ts
467
+ // demo-embedder.ts
468
+ import { spawn } from 'node:child_process';
469
+ import { appendFileSync, createReadStream, writeFileSync } from 'node:fs';
470
+ import { createInterface } from 'node:readline';
471
+ import { tmpdir } from 'node:os';
472
+ import { join } from 'node:path';
473
+
474
+ const events = join(tmpdir(), `qwen-events-${process.pid}.jsonl`);
475
+ const input = join(tmpdir(), `qwen-input-${process.pid}.jsonl`);
476
+ writeFileSync(events, '');
477
+ writeFileSync(input, '');
478
+
479
+ const child = spawn('qwen', ['--json-file', events, '--input-file', input], {
480
+ stdio: 'inherit',
481
+ });
482
+
483
+ // Tail the output channel. In production you'd use a proper
484
+ // byte-offset tail; this one re-streams from 0 for brevity.
485
+ const rl = createInterface({
486
+ input: createReadStream(events, { encoding: 'utf8' }),
487
+ });
488
+ rl.on('line', (line) => {
489
+ if (!line.trim()) return;
490
+ const ev = JSON.parse(line);
491
+ if (ev.type === 'system' && ev.subtype === 'session_start') {
492
+ console.log('[embedder] handshake:', {
493
+ protocol_version: ev.data.protocol_version,
494
+ version: ev.data.version,
495
+ supported_events: ev.data.supported_events,
496
+ });
497
+ // Feature-detect before using a capability
498
+ if (ev.data.supported_events.includes('control_request')) {
499
+ console.log('[embedder] permission control-plane available');
500
+ }
501
+ }
502
+ if (ev.type === 'assistant') {
503
+ console.log(
504
+ '[embedder] assistant turn ended, tokens =',
505
+ ev.message.usage?.output_tokens,
506
+ );
507
+ }
508
+ if (ev.type === 'system' && ev.subtype === 'session_end') {
509
+ console.log('[embedder] session ended cleanly');
510
+ }
511
+ });
512
+
513
+ // After 2s, inject a prompt as if the user typed it
514
+ setTimeout(() => {
515
+ appendFileSync(
516
+ input,
517
+ JSON.stringify({ type: 'submit', text: 'hello from embedder' }) + '\n',
518
+ );
519
+ }, 2000);
520
+
521
+ child.on('exit', () => process.exit(0));
522
+ ```
523
+
524
+ Run with:
525
+
526
+ ```bash
527
+ npx tsx demo-embedder.ts
528
+ # Qwen Code TUI opens in the current terminal; the embedder logs
529
+ # handshake + turn-end + session_end events to the parent's stdout.
530
+ ```
531
+
532
+ ### POC 5 — capability handshake feature detection
533
+
534
+ Older Qwen Code versions won't emit `protocol_version`. Treat the field
535
+ as optional and feature-detect:
536
+
537
+ ```ts
538
+ rl.on('line', (line) => {
539
+ const ev = JSON.parse(line);
540
+ if (ev.type === 'system' && ev.subtype === 'session_start') {
541
+ const v = ev.data?.protocol_version ?? 0;
542
+ if (v < 1) {
543
+ console.error(
544
+ 'qwen-code dual output is present but protocol < 1; ' +
545
+ 'falling back to best-effort behavior',
546
+ );
547
+ } else {
548
+ console.log('qwen-code dual output protocol v' + v);
549
+ }
550
+ }
551
+ });
552
+ ```
553
+
554
+ ### POC 6 — session_end as a clean termination signal
555
+
556
+ ```ts
557
+ rl.on('line', (line) => {
558
+ const ev = JSON.parse(line);
559
+ if (ev.type === 'system' && ev.subtype === 'session_end') {
560
+ console.log('[embedder] clean shutdown, session', ev.data.session_id);
561
+ // Flush metrics, close WebSockets, etc.
562
+ }
563
+ });
564
+ ```
565
+
566
+ If the TUI crashes before `session_end`, the output stream closes
567
+ (`EPIPE` on next write); embedders should handle both paths.
568
+
569
+ ### POC 7 — failure drills (prove the flags never break the TUI)
570
+
571
+ ```bash
572
+ qwen --json-fd 1
573
+ # stderr: "Warning: dual output disabled — ..."
574
+ # TUI still launches normally.
575
+
576
+ qwen --json-fd 9999
577
+ # stderr: "Warning: dual output disabled — fd 9999 not open"
578
+ # TUI still launches normally.
579
+
580
+ qwen --json-fd 3 --json-file /tmp/x.jsonl
581
+ # yargs rejects: "--json-fd and --json-file are mutually exclusive."
582
+ # Process exits before TUI starts.
583
+
584
+ qwen --json-file /nonexistent/dir/x.jsonl
585
+ # stderr warning; TUI still launches.
586
+ ```
587
+
588
+ ## Relation to Claude Code
589
+
590
+ Claude Code exposes a similar stream-json event format under
591
+ `--print --output-format stream-json`, but only in non-interactive mode
592
+ — it has no equivalent of running the TUI and a structured sidecar
593
+ channel at the same time. Dual Output fills that gap.
@@ -310,6 +310,67 @@ echo "Recent usage trends:"
310
310
  tail -5 usage.log
311
311
  ```
312
312
 
313
+ ## Persistent Retry Mode
314
+
315
+ When Qwen Code runs in CI/CD pipelines or as a background daemon, a brief API outage (rate limiting or overload) should not kill a multi-hour task. **Persistent retry mode** makes Qwen Code retry transient API errors indefinitely until the service recovers.
316
+
317
+ ### How it works
318
+
319
+ - **Transient errors only**: HTTP 429 (Rate Limit) and 529 (Overloaded) are retried indefinitely. Other errors (400, 500, etc.) still fail normally.
320
+ - **Exponential backoff with cap**: Retry delays grow exponentially but are capped at **5 minutes** per retry.
321
+ - **Heartbeat keepalive**: During long waits, a status line is printed to stderr every **30 seconds** to prevent CI runners from killing the process due to inactivity.
322
+ - **Graceful degradation**: Non-transient errors and interactive mode are completely unaffected.
323
+
324
+ ### Activation
325
+
326
+ Set the `QWEN_CODE_UNATTENDED_RETRY` environment variable to `true` or `1` (strict match, case-sensitive):
327
+
328
+ ```bash
329
+ export QWEN_CODE_UNATTENDED_RETRY=1
330
+ ```
331
+
332
+ > [!important]
333
+ > Persistent retry requires an **explicit opt-in**. `CI=true` alone does **not** activate it — silently turning a fast-fail CI job into an infinite-wait job would be dangerous. Always set `QWEN_CODE_UNATTENDED_RETRY` explicitly in your pipeline configuration.
334
+
335
+ ### Examples
336
+
337
+ #### GitHub Actions
338
+
339
+ ```yaml
340
+ - name: Automated code review
341
+ env:
342
+ QWEN_CODE_UNATTENDED_RETRY: '1'
343
+ run: |
344
+ qwen -p "Review all files in src/ for security issues" \
345
+ --output-format json \
346
+ --yolo > review.json
347
+ ```
348
+
349
+ #### Overnight batch processing
350
+
351
+ ```bash
352
+ export QWEN_CODE_UNATTENDED_RETRY=1
353
+ qwen -p "Migrate all callback-style functions to async/await in src/" --yolo
354
+ ```
355
+
356
+ #### Background daemon
357
+
358
+ ```bash
359
+ QWEN_CODE_UNATTENDED_RETRY=1 nohup qwen -p "Audit all dependencies for known CVEs" \
360
+ --output-format json > audit.json 2> audit.log &
361
+ ```
362
+
363
+ ### Monitoring
364
+
365
+ During persistent retry, heartbeat messages are printed to **stderr**:
366
+
367
+ ```
368
+ [qwen-code] Waiting for API capacity... attempt 3, retry in 45s
369
+ [qwen-code] Waiting for API capacity... attempt 3, retry in 15s
370
+ ```
371
+
372
+ These messages keep CI runners alive and let you monitor progress. They do not appear in stdout, so JSON output piped to other tools remains clean.
373
+
313
374
  ## Resources
314
375
 
315
376
  - [CLI Configuration](../configuration/settings#command-line-arguments) - Complete configuration guide