Skip to content

cli: session export truncates JSON when stdout is a pipe #57266

Open

Description

@paulgries

Summary

opencode session export <session-id> produces complete, valid JSON when stdout is a regular file, but a truncated prefix when stdout is a pipe — while still exiting 0. Every transcript larger than the OS pipe buffer is silently cut off, so opencode session export X | jq, or capturing the export into memory, gets corrupted JSON with no diagnostic.

Environment

  • opencode version: 2.0.32
  • OS: Darwin 25.6.0 arm64 (Darwin Kernel Version 25.6.0: Fri Jul 31 19:19:08 PDT 2026; root:xnu-12377.161.14~5/RELEASE_ARM64_T6050)
  • Terminal: TERM=xterm-256color, TERM_PROGRAM=vscode, COLORTERM=truecolor (CLI invoked non-interactively from a shell)
  • Shell: /bin/zsh
  • Install/channel: latest, release — Homebrew /opt/homebrew/bin/opencode → @opencode/cli 2.0.32
  • Active plugins: none configured (no plugins key in ~/.config/opencode/opencode.json, no project .opencode/ directory)

Reproduction

Session ids below are my own and omitted; any session whose export exceeds the pipe buffer behaves the same.

  1. Pick a real session whose export is large (this one is 1,195,916 bytes).
  2. Redirect stdout to a file: opencode session export <id> > full.json → 1,195,916 bytes, json.loads succeeds, exit 0.
  3. Pipe stdout through cat: opencode session export <id> | cat > piped.json → 327,680 bytes, invalid JSON, exit 0.
  4. Pipe stdout to wc: opencode session export <id> | wc -c → 262,144 bytes.
  5. Capture stdout with Python subprocess.stdout=PIPE → 262,144 bytes, exit 0, json.loads fails with Unterminated string starting at: line 2121 column 25 (char 258002).
  6. Control, a small session (~806 bytes): pipe and file-redirect results are byte-for-byte identical.
import subprocess, json
sid = "<a-session-with-a-1.2MB-export>"
r = subprocess.run(["opencode", "session", "export", sid],
                   stdout=subprocess.PIPE, stderr=subprocess.PIPE)
print(len(r.stdout), r.returncode)          # 262144 0
json.loads(r.stdout)                        # ValueError: Unterminated string starting at ...

Expected Behavior

Byte-identical output regardless of whether stdout is a file, a pipe, or captured in memory. If the write genuinely cannot be completed, a non-zero exit and a message on stderr — never a partial payload reported as success.

Actual Behavior

Truncated output with exit status 0. The truncated stream is a strict prefix of the full payload (full.startswith(truncated) is true), so bytes are simply missing from the end, and the amount lost varies between runs of the same command (262,144 vs 327,680 bytes of the same 1,195,916-byte export). That smells like the process exiting before pending stdout writes are flushed, rather than a deliberate size limit.

Additional Context

  • Impact. I archive every session nightly for durable history. The first version of my sweeper captured stdout with PIPE; 27 of 51 sessions came back as invalid JSON, cut mid-string around character 261k. 10 of my 51 archived sessions exceed 1 MB, so this is the common case, not an edge case. Anyone piping into jq or an editor buffer gets a half a transcript and no warning.
  • Silent is the worst part. Exit 0 plus a valid-looking prefix means callers cannot detect the failure after the fact; my archive would have been quietly incomplete had I not validated each payload.
  • Workaround. Redirect stdout to a file instead of piping it — that path is always correct. Validate the payload before using it.
  • Frequency. Consistent: reproduced on every attempt (4/4) across three different sessions; only the byte count that survives varies.
  • Separate issue. A different session in the same store fails to export entirely with Unexpected server error. Check server logs for details. (exit 1, ~18 MB stored). I will file that separately since it looks like a distinct defect.

Activity

  1. opencode-agent commented on Oct 20, 2026

    @opencode-agent
    Contributor

    Thanks for the detailed report! I reproduced this on 2.0.32 (on Linux, so it isn't specific to macOS), and it still happens on the latest dev (16c338372).

    What I did: I imported a session of about 1.2 MB, then exported it three ways.

    • Redirected to a file: 1,206,978 bytes, valid JSON.
    • Piped through cat: 589,824 or 720,896 bytes depending on the run, invalid JSON.
    • Piped to a reader that waits a second before reading: 65,536 bytes every time.

    Every run exited 0, and the truncated sizes are multiples of 4096. The script below reproduces it.

    The likely cause: the export handler writes its JSON to stdout without waiting for the write to finish, and the CLI then exits, which drops whatever is still queued for the pipe. #52925 is the same problem in opencode api get; its open fix (#52955) doesn't cover session export.

    Until this is fixed, redirecting to a file (as you're doing) gives the full export.

    Reproduction script (repro.sh)
    #!/usr/bin/env bash
    # Repro for #57266: `opencode session export` truncates JSON when stdout is a pipe.
    # Usage: ./repro.sh                 (uses `opencode` from PATH)
    #        OC="bun --conditions=browser --preload=.../preload.js .../packages/cli/src/index.ts" ./repro.sh
    set -u
    OC=${OC:-opencode}
    # Server flag: a private server by default, or set SERVER=http://... (+ OPENCODE_SERVER_PASSWORD) to use a running one.
    SRV=${SERVER:+--server $SERVER}; SRV=${SRV:---standalone}
    W=$(mktemp -d /tmp/opencode/repro57266.XXXX)
    export XDG_DATA_HOME=$W/data XDG_CONFIG_HOME=$W/cfg XDG_STATE_HOME=$W/state XDG_CACHE_HOME=$W/cache
    mkdir -p "$W/proj" && cd "$W/proj"
    $OC --version
    
    # Build a ~1.2 MB session transfer file (40 user messages with 30 KB text each) and import it.
    python3 - "$W/big.json" <<'EOF'
    import json, sys, time
    now = int(time.time() * 1000)
    msgs = [{"id": f"msg_{i:026d}", "type": "user", "time": {"created": now + i}, "text": f"msg {i} " + "x" * 30000} for i in range(40)]
    data = {"info": {"id": "ses_repro57266aaaaaaaaaaaaaaa", "projectID": "global", "cost": 0,
                     "tokens": {"input": 0, "output": 0, "reasoning": 0, "cache": {"read": 0, "write": 0}},
                     "time": {"created": now, "updated": now}, "location": {"directory": sys.argv[1].rsplit('/',1)[0] + "/proj"}},
            "messages": msgs}
    json.dump(data, open(sys.argv[1], "w"))
    EOF
    $OC session import $SRV "$W/big.json" || exit 1
    SID=ses_repro57266aaaaaaaaaaaaaaa
    
    $OC session export $SRV $SID > "$W/file.json"; rc=$?; echo "file:  $(wc -c < "$W/file.json") bytes, exit $rc"
    $OC session export $SRV $SID | cat > "$W/pipe.json"; echo "pipe:  $(wc -c < "$W/pipe.json") bytes, exit ${PIPESTATUS[0]}"
    $OC session export $SRV $SID | (sleep 1; cat) > "$W/slow.json"; echo "slow pipe: $(wc -c < "$W/slow.json") bytes, exit ${PIPESTATUS[0]}"
    for f in file pipe slow; do
      python3 -c "import json,sys; json.load(open(sys.argv[1])); print('$f: valid JSON')" "$W/$f.json" 2>/dev/null || echo "$f: INVALID JSON"
    done

    Run it: bash repro.sh

  2. Alek2077 commented on Nov 3, 2026

    @Alek2077

    Still reproducible on OpenCode 2.2.5 (Linux x86_64, WSL2).

    • opencode session export <id> > full.json → 2,972,714 bytes, valid JSON, exit 0.
    • opencode session export <id> | wc -c → 262,144 bytes, exit 0; jq fails with Unterminated string.
    • A 547,143-byte export also truncates to 262,144 bytes when piped.

    Redirecting to a file is the workaround. This matches the flush-before-exit diagnosis; #57347 looks like the right fix.

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

Metadata

Metadata

Assignees

No one assigned

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions