Skip to content

cli: "api get" truncates stdout when it is a pipe #52925

Open

Description

@vibe-maribit

Description

opencode api get returns complete output when stdout is a file, but truncated output when stdout is a pipe. The result is invalid JSON, so the usual opencode api get ... | jq pattern fails on larger responses.

Reproducible on every attempt with a ~580 KB response:

opencode api get "/api/session/<id>/message?limit=100" > file.json  wc -c  #1 593218 bytes, valid JSON, 2/2 runs
opencode api get "/api/session/<id>/message?limit=100" | wc -c  # varies per run: 598985 / 409600 / 262144 bytes

The truncation points land on exact multiples of 4096 (262144 = 256 KiB, 327680 = 320 KiB, 409600 = 400 KiB), which looks like the process exiting before stdout has been drained rather than a transport error. Exit code is 0 in all cases.

Smaller responses often come through intact, which is why it looks intermittent: `/openapi.json" (256 KB) succeeded via redirect on every run but failed when piped.

Impact: scripts that pipe opencode api output into jq, `python" or any other consumer break silently on large payloads, with exit code 0. Workaround: redirect to a file first.

Expected: stdout should be fully flushed before exit, regardless of whether it is a TTY, a pipe, or a file.

Plugins

none

OpenCode version

2.0.22 (channel: latest)

Steps to reproduce

  1. Pick a session with a large transcript (a few hundred KB of JSON), e.g. /api/session/<id>/message?limit=100.
  2. opencode api get "/api/session/<id>/message?limit=100" > /tmp/a.json then validate it with python3 - c "import json;json.load(open('/tmp/a.json'))" -> no error, exit 0, complete file.
  3. opencode api get "/api/session/<id>/message?limit=100" | python3 - c "import json,sys;json.load(sys.stdin)" -> json.decoder.JSONDecodeError: Unterminated string.
  4. opencode api get "/api/session/<id>/message?limit=100" | wc -c -> a different, smaller number on each run (262144, 327680, 409600), always less than the redirected file size.
  5. echo $? -> 0 in all cases.

Screenshot and/or share link

No response

Operating System

Linux Mint 22.3 (Linux 6.8.0-111-generic)

Terminal

unknown terminal app (TERM=xterm-256color, COLORTERM=truecolor)

Activity

  1. github-actions commented on Oct 3, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of existing issues. Please check:

    • #29330: Large CLI JSON output is truncated or invalid when piped, while file redirection works.
    • #14948: opencode export produces truncated JSON when piped for large sessions.
    • #26399: opencode debug skill returns incomplete JSON when output is buffered/piped.
  2. r266-tech commented on Oct 3, 2026

    @r266-tech
    Contributor

    Reproduced with the current v2 CLI entrypoint on macOS / Bun 1.4.2, using api get /synthetic --server http://127.0.0.1:PORT against a local HTTP fixture (no session data). The same response is 600,095 bytes including the newline:

    • File redirection: 600,095 bytes, exact match, valid JSON.
    • Fast pipe reader: 393,216 bytes, invalid JSON.
    • Reader consuming 4 KiB every 5 ms: 131,072 bytes, invalid JSON.

    All three CLI processes exited 0. The tested files match current v2: handlers/api.ts calls process.stdout.write(...) without awaiting completion, and src/index.ts:136 explicitly calls process.exit(...) on success. This gives a session-independent reproduction for testing the stdout-drain fix. The older #46912 targets dev export/session-list/db handlers, so it does not cover this V2 handler.

  3. Shalin-Shah-2002 commented on Oct 3, 2026

    @Shalin-Shah-2002

    I reproduced this on the V2 CLI and opened a fix: #52955. api get didn't wait for its stdout write to finish before the entrypoint called process.exit(), so Bun cut off large piped output.

  4. conectaxp commented on Oct 23, 2026

    @conectaxp

    Occurrence report — still reproducible on the current stable release (v2.1.4)

    Adding measured evidence from another affected consumer, since #52925 (and its fix PR #52955) are still open and the defect is present in the latest stable build.

    Environment

    • OpenCode: v2.1.4 (channel: latest, published 2026-10-23) — the newest stable release
    • OS: Linux (Arch-based), x86_64
    • Install: npm CLI (@opencode/cli) on PATH
    • Plugins: none

    Observed behavior

    opencode api get returns a truncated body when stdout is a pipe, and exits 0. The truncation always lands on a 4096 multiple and always ends mid-JSON, so the consumer sees invalid JSON with no error signal:

    opencode api get "/api/model?location%5Bdirectory%5D=<dir>" | wc -c
    # 262144 | 327680 | 389996   (varies per run; 389996 = the complete body)
    
    opencode api get "/api/model?location%5Bdirectory%5D=<dir>" > out.json
    wc -c < out.json
    # 389996   (complete, every run)
    

    The complete body for this request is ~390 KB, i.e. above the 256 KiB pipe-buffer size, which matches the mechanism described in #57816.

    Additional data points that may help triage

    1. It is not a version regression. Every release I could test truncates, so the fix cannot be a matter of pinning or downgrading a runtime version. Eight runs per version, same request, stdout piped to wc -c:

      version complete truncated
      2.0.32 5/8 3/8
      2.1.1 4/8 4/8
      2.1.2 4/8 4/8
      2.1.3 3/8 5/8
      2.1.4 7/8 1/8

      The rate is not stable across versions, which makes a single-sample version matrix actively misleading — a first pass here made 2.1.1 look clean and 2.1.2 look broken, and that was just luck. Worth calling out for anyone bisecting.

    2. The failure rate depends on how the consumer reads the pipe. With the same v2.1.4 binary and the same request, opencode api get ... | wc -c returned the complete body in 7 of 8 runs, while a consumer that reads the child's stdout through an io.ReadAll loop over an exec-created pipe (what gentle-ai does) was truncated in 6 of 6 runs. So "retry until it works" is not a dependable mitigation for a programmatic consumer either.

    3. Small payloads are never truncated. Two endpoints well under the buffer size were intact 10/10 runs each through a pipe (/config, ~7 KB; /api/provider, ~1.2 KB). Only bodies above ~256 KiB are affected, which is why the symptom looks intermittent in mixed workloads.

    4. The cut size is not fixed. 262144 and 327680 were both observed, so this is a flush race rather than a constant in the source.

    5. Downstream impact. gentle-ai consumes this exact endpoint to discover the model catalog for its agent-assignment screen, reading the child's stdout through a pipe. With a truncated body its catalog parse fails and the TUI shows:

      Could not parse models from OpenCode.
      OpenCode returned an unexpected model catalog format. Verify your OpenCode version.
      

      The message points at the OpenCode version, but the version is not the variable — the transport is. Redirecting that one call to a temp file makes the discovery succeed 6/6 runs (0/6 before), which is consistent with opencode api truncates large output when stdout is a pipe (un-awaited write before process.exit) #57816's proposed cause and fix(cli): flush api output before exit #52955's fix.

    Impact on the current stable release

    Because #52955 is not merged, opencode api get still corrupts piped output in v2.1.4, the release published today. Scripts that pipe the output into jq, python, or any consumer keep breaking silently with exit 0. Redirecting to a file first is the only reliable workaround.


    AI assistance disclosure: the measurements, reproduction and analysis in this comment were produced with AI assistance (MiniMax-M3 via OpenCode). The reporter independently reviewed every command and result reproduced above; all numbers come from runs performed on the machine described here. No OpenCode source code was modified.

  5. CBEPX commented on Oct 24, 2026

    @CBEPX
    Contributor

    Reproduced on installed OpenCode v2.1.3 on 24 October 2026, Darwin 27.0.0 arm64. This adds evidence to #52925 and #52955.

    A Python HTTP server bound only to 127.0.0.1 returned synthetic JSON. No user sessions, provider inventory, or inference were involved. HOME, XDG directories, and OpenCode configuration were isolated.

    The response was {"data":" + 1,500,000 ASCII x characters + "}\n, exactly 1,500,012 bytes. Each run used opencode api --server http://127.0.0.1:<port> GET /fixture.

    stdout destination Captured bytes JSON CLI exit stderr
    Regular file 1,500,012 Valid, exact response 0 Empty
    Python subprocess.PIPE, run 1 294,912 Unterminated string 0 Empty
    Python subprocess.PIPE, run 2 327,680 Unterminated string 0 Empty
    Slow pipe, 4,096-byte reads with 5 ms delay 65,536 Unterminated string 0 Empty
    Reader closes after 4,096 bytes 4,096 Intentionally incomplete 0 Empty

    The file SHA256 matched the response: 5640acc268f4a597d833576aa519b33d843b95fe5aca8e9f65100ae0706741f0.
    The installed executable SHA256 was 1638ccfd0936dc10b15df34268b541a219822f4735d564359420d426c03383f1.

    The server received only /api/info and /fixture. All observed child processes stopped, and the loopback endpoint closed. Process polling does not exclude unobserved short-lived descendants.

    These measurements confirm the PIPE/file contrast on v2.1.3. They do not establish a fixed truncation limit. I did not test PR head 64446144b78a67ee18ed9d72cb8c6c7994e4cb20, so these results do not certify its fix or EPIPE handling.

    Dependency-free reproduction

    The script covers the regular-file and PIPE cases. The slow-reader and reader-close cases used a separate harness.

    Run this Python 3 script with opencode installed. It creates a temporary isolated profile and an owned loopback endpoint.

    import http.server
    import json
    import os
    from pathlib import Path
    import shutil
    import subprocess
    import tempfile
    import threading
    
    binary = shutil.which("opencode")
    assert binary
    body = ('{"data":"' + 'x' * 1_500_000 + '"}\n').encode()
    
    with tempfile.TemporaryDirectory(prefix="opencode-pipe-") as tmp:
        env = {"PATH": os.environ["PATH"], "HOME": tmp}
        for key in ("XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME", "XDG_STATE_HOME", "TMPDIR"):
            directory = Path(tmp, key)
            directory.mkdir()
            env[key] = str(directory)
        env["OPENCODE_CONFIG_DIR"] = env["XDG_CONFIG_HOME"]
        version = subprocess.check_output([binary, "--version"], env=env, cwd=tmp).decode().strip().split()[-1].removeprefix("v")
    
        class Handler(http.server.BaseHTTPRequestHandler):
            def do_GET(self):
                if self.path == "/api/info":
                    data = json.dumps({"version": version, "pid": os.getpid(), "urls": [], "paths": {"tmp": tmp}}).encode()
                elif self.path == "/fixture":
                    data = body
                else:
                    self.send_error(404)
                    return
                self.send_response(200)
                self.send_header("Content-Type", "application/json")
                self.send_header("Content-Length", str(len(data)))
                self.end_headers()
                self.wfile.write(data)
    
            def log_message(self, *args):
                pass
    
        server = http.server.HTTPServer(("127.0.0.1", 0), Handler)
        thread = threading.Thread(target=server.serve_forever)
        thread.start()
        command = [binary, "api", "--server", f"http://127.0.0.1:{server.server_port}", "GET", "/fixture"]
        try:
            for mode in ("file", "pipe"):
                with tempfile.TemporaryFile() as output:
                    run = subprocess.run(command, env=env, cwd=tmp, stdin=subprocess.DEVNULL,
                                         stdout=output if mode == "file" else subprocess.PIPE,
                                         stderr=subprocess.PIPE, timeout=30)
                    output.seek(0)
                    data = output.read() if mode == "file" else run.stdout
                    print(mode, "bytes=", len(data), "exact=", data == body,
                          "exit=", run.returncode, "stderr=", run.stderr)
        finally:
            server.shutdown()
            server.server_close()
            thread.join()
  6. DBenYaakov commented on Oct 26, 2026

    @DBenYaakov

    Adding a production-impact data point and one measurement that refines the characterisation. Linux x86_64 (6.14 kernel), opencode v2.0.26, bash 5.

    Impact: we run opencode api get /api/session/<id>/message?limit=50 from a systemd timer every 10 minutes to decide whether to wake a parked session. Once that session's message payload grew past ~1.3 MB, command-substitution capture (OUT="$(opencode api get …)") returned truncated JSON with exit 0 on every run. The script correctly rejected the invalid JSON and fail-closed to "do nothing" — so the wake stopped silently for three days (488 consecutive failed ticks) with no error anywhere, because the CLI reported success.

    Measurement: it is not a fixed 256 KiB cap; the cut point is the consumer's drain timing.

    $ opencode api get "$URL" > out.json; wc -c out.json      # 1317886, valid JSON — every run
    $ for i in 1 2 3; do printf %s "$(opencode api get "$URL")" | wc -c; done
    217088
    339968
    249856
    $ opencode api get "$URL" | (sleep 2; wc -c)              # 65536 — exactly one pipe buffer
    $ opencode api get "$URL" | wc -c                         # 1317886 — fast consumer keeps up
    

    Payloads under 64 KiB are unaffected, which is why this surfaces late and intermittently. Consistent with the un-awaited write before process.exit already identified; #52955 looks like the right fix. The silent exit-0 is the dangerous part — for unattended callers, a non-zero exit on an incomplete write would be a meaningful improvement even before the flush lands.

  7. opencodejr commented on Oct 27, 2026

    @opencodejr

    Thanks for the detailed report. I reproduced this with a local server that returns a ~600 KB JSON body, calling opencode api get <path> --server <url>:

    • Redirected to a file, the output is always complete (604902 bytes).
    • Piped to wc -c, the size changes from run to run (604902, 262144, 589824).
    • Piped to a reader that waits a second before reading, it is cut off at 65536 bytes every time. Piped into Python's json.load, it fails with Unterminated string while opencode still exits with 0.

    This still happens on the latest dev (45b53fe). The likely cause is that the api command writes the response to stdout and the CLI then calls process.exit before a piped stdout has been fully written. The script below reproduces it. Until it's fixed, redirecting to a file first (as you described) is a reliable workaround.

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Reproduces anomalyco/opencode#52925: `opencode api get` truncates stdout when it is a pipe.
    # Usage: ./repro.sh            (uses `opencode` from PATH)
    #        OC_DEV=/path/to/opencode/checkout ./repro.sh   (runs the CLI from source)
    set -u
    cd "$(dirname "$0")"
    if [ -n "${OC_DEV:-}" ]; then
      oc() { bun --conditions=browser --preload="$OC_DEV/packages/tui/node_modules/@opentui/solid/scripts/preload.js" "$OC_DEV/packages/cli/src/index.ts" "$@"; }
    else
      oc() { opencode "$@"; }
    fi
    # Mock server: returns a ~600 KB JSON body for every path (stands in for a large session transcript).
    cat > /tmp/big-server.ts <<'TS'
    const big = JSON.stringify({ items: Array.from({ length: 6000 }, (_, i) => ({ i, text: "x".repeat(80) })) })
    Bun.serve({ port: 4999, fetch: () => new Response(big, { headers: { "content-type": "application/json" } }) })
    TS
    bun /tmp/big-server.ts & SERVER=$!; trap 'kill $SERVER' EXIT; sleep 1
    URL=http://127.0.0.1:4999
    echo "redirect to file:"; oc api get /api/x --server $URL 2>/dev/null > /tmp/api-out.json; wc -c < /tmp/api-out.json
    echo "pipe, fast reader:"; for i in 1 2 3; do oc api get /api/x --server $URL 2>/dev/null | wc -c; done
    echo "pipe, slow reader (sleep 1 before reading):"; for i in 1 2 3; do oc api get /api/x --server $URL 2>/dev/null | (sleep 1; wc -c); done
    echo "pipe into python json parser, exit status of opencode:"
    oc api get /api/x --server $URL 2>/dev/null | (sleep 1; python3 -c 'import json,sys; json.load(sys.stdin); print("valid JSON")') ; echo "opencode exit=${PIPESTATUS[0]}"

    Run it: bash repro.sh

  8. jeffweiss commented on Oct 28, 2026

    @jeffweiss

    Still reproduces on 2.2.0 (channel latest, npm @opencode/cli, Darwin 25.6.0 arm64). #52955 is still open.

    This reproduction needs no session data and fails every time. It uses /openapi.json (318,081 bytes) and a reader that waits one second before draining the pipe:

    opencode api get /openapi.json > spec.json; wc -c < spec.json   # 318081 (3/3 runs)
    opencode api get /openapi.json | wc -c                          # 318081 (5/5 runs)
    opencode api get /openapi.json | (sleep 1; wc -c)               # 65536 (3/3 runs)
    opencode api get /openapi.json | jq . > /dev/null               # jq: parse error at column 163840

    Exit status is 0 every time. At this size a fast reader usually drains the pipe before the CLI exits, which is why earlier reports look intermittent. The sleeping reader removes that race.

  9. dbech commented on Nov 1, 2026

    @dbech

    The same root cause also hits opencode session export on 2.x (2.2.3 and 2.2.5, Linux x64). The export is written with one process.stdout.write(...) followed by process.exit(), so when stdout is a pipe the output stops at a multiple of 64 KiB and the exit code is still 0. Redirected to a file it's always complete. This looks like #29330 again, closed in the V1 triage.

    Over a pipe (--server against a stub serving one session), the export was truncated in: ~105 KB 0/10 runs, 210 KB 1/10, 250–630 KB 7–8/10, ≥1 MB 10/10. Redirected to a file, 30/30 runs were complete (up to 3.1 MB). Anything that reads the export through a pipe (| jq, Python's subprocess.run(capture_output=True)) silently gets invalid JSON.

    Plain Bun 1.4.4 behaves the same, so waiting for stdout to drain before exiting should fix it:

    // bun exit-after-write.js | (sleep 1; wc -c)  -> 65536
    // bun exit-after-write.js > out.txt           -> 1048577 bytes
    process.stdout.write("y".repeat(1048576) + "\n");
    process.exit(0);
    Repro against opencode (stub server + script)

    fake_server.py serves one session with an export of about 1.2 MB:

    import json, sys
    from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
    
    N = int(sys.argv[1]) if len(sys.argv) > 1 else 1200
    EXPORT = json.dumps({'data': {'info': {'id': 'ses_test', 'title': 'test'}, 'messages': [
        {'id': f'msg_{i:05d}', 'type': 'assistant', 'content': [{'type': 'text', 'text': 'y' * 900}]}
        for i in range(N)]}}).encode()
    
    class Handler(BaseHTTPRequestHandler):
        def log_message(self, *args): pass
        def do_GET(self):   # /api/info, then /api/experimental/session/ses_test/export?sanitize=false
            body = EXPORT if '/export' in self.path else b'{}'
            self.send_response(200)
            self.send_header('Content-Type', 'application/json')
            self.send_header('Content-Length', str(len(body)))
            self.end_headers()
            self.wfile.write(body)
    
    server = ThreadingHTTPServer(('127.0.0.1', 0), Handler)
    print(server.server_address[1], flush=True)
    server.serve_forever()

    repro.sh:

    #!/usr/bin/env bash
    set -u
    dir=$(mktemp -d); trap 'kill $server 2>/dev/null; rm -rf "$dir"' EXIT
    python3 "$(dirname "$0")/fake_server.py" 1200 > "$dir/port" & server=$!
    until [ -s "$dir/port" ]; do sleep 0.1; done
    url="http://127.0.0.1:$(cat "$dir/port")"
    export HOME="$dir/home" XDG_CONFIG_HOME="$dir/home/config" XDG_DATA_HOME="$dir/home/data"
    opencode session export --server "$url" ses_test < /dev/null > "$dir/export.json" 2>/dev/null
    echo "to a file: $(wc -c < "$dir/export.json") bytes"
    for i in 1 2 3 4 5; do
      opencode session export --server "$url" ses_test < /dev/null 2>/dev/null | wc -c | tr -d '\n'
      echo " bytes to a pipe, opencode exit ${PIPESTATUS[0]}"
    done
    opencode session export --server "$url" ses_test < /dev/null 2>/dev/null | (sleep 1; wc -c) | tr -d '\n'
    echo " bytes to a pipe read 1 s late, opencode exit ${PIPESTATUS[0]}"

    Output on 2.2.5:

    to a file: 1270882 bytes
    131072 bytes to a pipe, opencode exit 0
    131072 bytes to a pipe, opencode exit 0
    458752 bytes to a pipe, opencode exit 0
    262144 bytes to a pipe, opencode exit 0
    1270882 bytes to a pipe, opencode exit 0
    65536 bytes to a pipe read 1 s late, opencode exit 0
    

    Written by Claude Opus 5.5 (Anthropic).

  10. naeemakhtar110 commented on Nov 2, 2026

    @naeemakhtar110

    Same symptom on a different command: opencode models can also exit 0 after writing only part of its output when stdout is a pipe.

    Environment: version 1.18.34, Linux, one shared opencode.db of about 2.4 GB, about 20 concurrent opencode processes.

    Measurement: opencode models was started 192 times in parallel from Node child_process with piped stdout.

    • About 5% of runs returned only 3-4 KB of a roughly 13 KB list, cut off mid-line, with exit code 0 and empty stderr.
    • Some other runs failed with Unexpected error: database is locked.
    • The same command with stdout redirected to a file never truncated in 600 runs.

    This matches the diagnosis here (the process exits before stdout is drained). Expected: flush stdout before exit, or exit non-zero. For callers that parse the list, a silent partial list with exit 0 is hard to distinguish from a valid short list.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions