Title
process.exit() right after process.stdout.write() in print mode (-p --output-format json) can truncate the final result line when stdout is piped
Body
Summary
When running command-code in non-interactive print mode with JSON output (-p --output-format json) and stdout redirected to a pipe (not a TTY) — the normal case for any orchestrator/CI that spawns the CLI as a child process and reads its stdout — the final {"type":"result",...} NDJSON line is sometimes silently dropped even though the run completed successfully and every prior {"type":"event",...} line streamed correctly.
The child process exits with code 0 and no stderr output. From the caller's side, the NDJSON stream just ends without ever emitting the one line that signals "this run is done, here is the final text/usage/session id."
Environment
command-code version: 1.27.1 (also current latest on npm — confirmed no newer release fixes this)
- Node.js: v22+ (as required by the CLI itself)
- OS: Linux
- Invocation (representative):
command-code -p --output-format json --yolo --skip-onboarding --no-auto-update -m <model> [--resume <session-id>], spawned as a child process with stdout captured via a pipe (not inherited from a TTY).
What we found in dist/cli.mjs
The print-mode entrypoint builds the result payload via runPrintMode(...), then does:
t?.write(),
o.stdoutText && process.stdout.write(o.stdoutText),
o.stderrText && process.stderr.write(o.stderrText),
process.exit(o.exitCode)
process.stdout.write() is called and its return value (backpressure signal) is ignored, and process.exit() is invoked immediately afterward with no wait for a flush/'drain' event or write callback.
On Linux, when stdout is a pipe (as opposed to a TTY or a regular file), writes are asynchronous at the libuv level. process.exit() does not wait for pending async writes to complete — it tears the process down immediately. If the OS pipe buffer is under backpressure (e.g. right after a burst of event lines, or when the final result payload — which embeds the full finalText — is large), this last write() can be partially or fully lost before it reaches the reader on the other end of the pipe.
This matches observed behavior exactly:
- Every streamed
event line arrives fine (each write appears to get enough of a scheduling gap to flush).
- Only the single final
result line — written once, immediately followed by exit() — is intermittently missing.
- It reproduces on successful runs with larger output/tool-call counts, consistent with the final payload being big enough to not fit in one synchronous pipe write.
- No stderr, no non-zero exit code — the process believes it exited cleanly.
Suggested fix
Don't call process.exit() unconditionally right after process.stdout.write(). Either:
- Await the write completing (pass a callback to
.write(), or wait for 'drain' if it returns false) before exiting, or
- Prefer setting
process.exitCode = o.exitCode and letting the process exit naturally once the event loop drains, instead of forcing process.exit().
Impact
Any tool that drives command-code non-interactively and parses its NDJSON stdout (CI pipelines, orchestrators, IDE integrations) cannot reliably distinguish "the run actually finished successfully but the terminal frame got dropped" from "the run crashed" — because from the outside, both look identical: stdout just stops, exit code 0, no result frame. Consumers are forced to either trust an unconfirmed completion (risking silently reporting failed work as succeeded) or treat every truncated stream as a failure (which then re-runs/retries a run that actually already finished).
Title
process.exit()right afterprocess.stdout.write()in print mode (-p --output-format json) can truncate the finalresultline when stdout is pipedBody
Summary
When running
command-codein non-interactive print mode with JSON output (-p --output-format json) and stdout redirected to a pipe (not a TTY) — the normal case for any orchestrator/CI that spawns the CLI as a child process and reads its stdout — the final{"type":"result",...}NDJSON line is sometimes silently dropped even though the run completed successfully and every prior{"type":"event",...}line streamed correctly.The child process exits with code
0and no stderr output. From the caller's side, the NDJSON stream just ends without ever emitting the one line that signals "this run is done, here is the final text/usage/session id."Environment
command-codeversion:1.27.1(also currentlateston npm — confirmed no newer release fixes this)command-code -p --output-format json --yolo --skip-onboarding --no-auto-update -m <model> [--resume <session-id>], spawned as a child process with stdout captured via a pipe (not inherited from a TTY).What we found in
dist/cli.mjsThe print-mode entrypoint builds the result payload via
runPrintMode(...), then does:process.stdout.write()is called and its return value (backpressure signal) is ignored, andprocess.exit()is invoked immediately afterward with no wait for a flush/'drain'event or write callback.On Linux, when
stdoutis a pipe (as opposed to a TTY or a regular file), writes are asynchronous at the libuv level.process.exit()does not wait for pending async writes to complete — it tears the process down immediately. If the OS pipe buffer is under backpressure (e.g. right after a burst ofeventlines, or when the finalresultpayload — which embeds the fullfinalText— is large), this lastwrite()can be partially or fully lost before it reaches the reader on the other end of the pipe.This matches observed behavior exactly:
eventline arrives fine (each write appears to get enough of a scheduling gap to flush).resultline — written once, immediately followed byexit()— is intermittently missing.Suggested fix
Don't call
process.exit()unconditionally right afterprocess.stdout.write(). Either:.write(), or wait for'drain'if it returnsfalse) before exiting, orprocess.exitCode = o.exitCodeand letting the process exit naturally once the event loop drains, instead of forcingprocess.exit().Impact
Any tool that drives
command-codenon-interactively and parses its NDJSON stdout (CI pipelines, orchestrators, IDE integrations) cannot reliably distinguish "the run actually finished successfully but the terminal frame got dropped" from "the run crashed" — because from the outside, both look identical: stdout just stops, exit code 0, noresultframe. Consumers are forced to either trust an unconfirmed completion (risking silently reporting failed work as succeeded) or treat every truncated stream as a failure (which then re-runs/retries a run that actually already finished).