[BUG] Bash tool returns empty output for SSH commands that work in regular terminal

Resolved 💬 18 comments Opened Feb 18, 2026 by baslogic Closed Apr 1, 2026

Preflight Checklist

  • [x] Searched existing issues
  • [x] Single bug report
  • [x] Using latest version

What's Wrong?

The Bash tool silently swallows all stdout from SSH remote commands in certain cases. The exact same commands produce full output when run in a regular terminal.

A compound SSH test command returns complete output in Terminal.app but returns (No content) / empty through the Bash tool:

echo "=== TEST 1: qm list ===" && ssh root@<remote-host> 'qm list' && echo "=== TEST 2: pct list ===" && ssh root@<remote-host> 'pct list' && echo "=== TEST 3: echo PATH ===" && ssh root@<remote-host> 'echo $PATH' && echo "=== ALL TESTS DONE ==="
  • Terminal.app result: All test headers, SSH command output, and markers displayed correctly
  • Bash tool result: Completely empty — no output at all

Even simple commands like ssh root@<remote-host> 'qm list' return empty. The remote commands do execute — verified by redirecting to a file on the remote host and reading it in a separate SSH session.

Notably, some SSH commands work fine through the Bash tool (e.g., ssh root@<remote-host> 'hostname && uptime && free -h'), while others consistently return empty (e.g., ssh root@<remote-host> 'qm list', ssh root@<remote-host> 'echo $PATH'). The affected commands produce clean ASCII output with no special characters (verified via hex dump).

What Should Happen?

The Bash tool should return the same stdout/stderr as running the command directly in a terminal.

Steps to Reproduce

  1. Have an SSH-accessible remote host running Proxmox VE
  2. Verify ssh root@<remote-host> 'qm list' works in a regular terminal
  3. Run the same command through Claude Code's Bash tool
  4. Observe empty output

Minimal reproducer (any command that produces column-padded output over SSH may trigger this):

ssh root@<remote-host> 'qm list'

Additional Findings (2026-02-18)

Further investigation revealed several new data points:

Not limited to SSH — gh CLI also affected

The gh issue view command exhibits the same empty output behavior, suggesting this is a broader subprocess stdout capture problem, not SSH-specific.

Local file redirect workaround works

In addition to the remote-side file redirect, redirecting locally also recovers the output:

ssh root@<remote-host> 'qm list' > /tmp/output.txt 2>&1
# Then read /tmp/output.txt — contains the full expected output

Exit code is 0 and the file contains correct output, confirming the command executes successfully.

TCP connectivity is fine

nc -z -w 3 <remote-host> 22 returns success and captures output correctly through the Bash tool. Only the actual SSH session output is lost.

First invocation in a session can succeed

On the very first Bash tool invocation in a fresh session, SSH output was captured correctly. Every subsequent invocation returned empty. This suggests some state change within the sandbox/tool after the first execution.

VPN split tunnel is not a factor

Initially suspected VPN routing, but the issue persists with VPN disconnected. The routing table correctly sends 192.168.8.0/24 traffic through the local interface regardless of VPN state.

Workarounds

  1. Local file redirect: ssh root@<host> 'command' > /tmp/out.txt 2>&1 then read the file
  2. Remote file redirect: ssh root@<host> 'command > /tmp/out.txt' && ssh root@<host> 'cat /tmp/out.txt'
  3. Run directly in terminal and paste results back to Claude Code

Is this a regression?

I don't know

Claude Code Version

2.1.45

Platform

Anthropic API

Operating System

macOS 26.2 (arm64)

Terminal/Shell

Terminal.app / zsh inside tmux

Claude Model

Opus 4.6

Related Issues

  • #19663 — Bash tool returns (No content) for all commands on macOS
  • #22627 — CLI freezes when reading file content over SSH pipes
  • #16431 — Commands with pipes or cd && ... patterns run in background with 0-byte output
  • #21143 — Bash command failing with no output

View original on GitHub ↗

18 Comments

baslogic · 4 months ago

Meta note: even the gh issue create and gh issue list commands used to file this bug had their output swallowed by the Bash tool. Had to use the same file-redirect workaround (gh issue list --json ... > /tmp/out.json && cat /tmp/out.json) just to confirm the issue was created.

The bug is eating its own bug report. 🐛

github-actions[bot] · 4 months ago

Found 3 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/19663
  2. https://github.com/anthropics/claude-code/issues/22627
  3. https://github.com/anthropics/claude-code/issues/16431

This issue will be automatically closed as a duplicate in 3 days.

  • If your issue is a duplicate, please close it and 👍 the existing issue instead
  • To prevent auto-closure, add a comment or 👎 this comment

🤖 Generated with Claude Code

baslogic · 4 months ago

Related Issues — Common Root Cause

After investigating, this appears to be part of a systemic problem with the Bash tool's stdout capture mechanism. Three related issues exhibit overlapping symptoms:

  • #19663 — Bash tool returns (No content) for all commands on macOS. Root cause traced to shell snapshots containing set -o onecmd, breaking the multi-step wrapper script. Affects all platforms on v2.1.x.
  • #22627 — CLI freezes when reading file content over SSH pipes. Metadata commands (stat, ls) work; content commands (tail, cat) freeze. Points to broken streamed SSH output handling.
  • #16431 — Commands with pipes (|) or cd && ... patterns silently get routed to background execution with 0-byte output files. Labeled has repro, platform:macos.

How this issue adds to the picture

This issue provides additional diagnostic evidence:

  1. Hex dump of remote command output confirms the content is clean ASCII (no ANSI escapes, no control characters) — ruling out special character interference
  2. Side-by-side comparison: the exact same compound command produces full output in Terminal.app and zero output through the Bash tool
  3. Selective failure: in the same session, ssh host 'hostname && uptime' works while ssh host 'qm list' returns empty — suggesting the issue is sensitive to the remote command's output buffering behavior (Perl-based tools like Proxmox qm/pct vs. standard coreutils)
  4. Even gh CLI output is swallowed — this isn't SSH-specific; the gh issue create and gh issue list commands used to file this very bug also returned empty

Common thread

All four issues point to broken subprocess stdout capture in the Bash tool, triggered by some combination of:

  • Piped or chained commands
  • SSH remote execution
  • Tools with non-standard output buffering (Perl, Ruby, etc.)
  • Output containing wide column padding / whitespace
KatayR · 4 months ago

Bash commands keep returning "Error: Exit code 1" after the standalone Claude installation.

`
● Bash(pwd 2>&1)
⎿ Error: Exit code 1

● Bash(/bin/pwd 2>&1; echo "EXIT: $?")
⎿ Error: Exit code 1

● Bash(echo "hello world")
⎿ Error: Exit code 1

● Bash(whoami)
⎿ Error: Exit code 1
● Bash(echo "hello") ⎿ Error: Exit code 1 `

Only this works:
● Bash(true)
⎿ (No output)

I'm on windows, Claude Code version is 2.1.45.

Bash works fine outside of Claude Code.

malle-pietje · 4 months ago

Exact same issue popped up here today after working fine for weeks, months...

  • Claude Code v2.1.45
  • running in Powershell
  • connecting to sshd on Ubuntu 24.04

Nothing special there.

ugattupalli-coatue · 4 months ago

Having the same issue.

I uninstalled and reinstalled Claude. Tried the native installation and also through Homebrew.

Facing this on v2.1.49 and been an issue since v2.1.30. I'm on MacOS 15.7.3 on MBP M1Pro

baslogic · 4 months ago

Update: Root Cause Narrowed — Internal Pipe Reader Timeout (~270ms)

Universal Local Reproducer (no SSH, no dependencies)

# This FAILS (output swallowed):
/bin/sleep 0.3 && echo "hello"

# This WORKS:
/bin/sleep 0.2 && echo "hello"

This proves the bug is not SSH-related. Any external binary that delays stdout by ~270ms+ triggers it.

Findings

1. Precise timeout threshold: ~270-280ms

Tested locally with /bin/sleep (external binary):

| Delay | Output captured? |
|-------|-----------------|
| 200ms | ✅ Yes |
| 250ms | ✅ Yes |
| 270ms | ✅ Yes |
| 280ms | ❌ No |
| 290ms | ❌ No |
| 300ms | ❌ No |

The earlier SSH threshold appeared to be ~380ms because SSH adds ~100-120ms of connection overhead before the remote command starts.

2. Only affects external binaries, not shell builtins

# WORKS — sleep is a zsh builtin, runs in-process:
sleep 0.3 && echo "hello"

# FAILS — /bin/sleep is an external binary, forks a subprocess:
/bin/sleep 0.3 && echo "hello"

This suggests the pipe reader closes after the forked child process exits but before the parent shell flushes its remaining stdout (the echo).

3. Python reproducer (also works)

python3 -c "import time; time.sleep(0.3); print('hello')"

Same threshold. Any subprocess that takes >270ms before writing to stdout gets silently dropped.

4. Not caused by hooks or config

Disabled the PreToolUse hook entirely — bug persists. The issue is internal to the Bash tool's subprocess output capture.

5. Intermittent "first run works" explained

The first Bash tool invocation in a fresh session sometimes captures output correctly. Subsequent invocations fail. This is consistent with a pipe reader or event loop listener that initializes correctly but doesn't properly reset its state between invocations.

6. Not macOS version specific

Confirmed by @ugattupalli-coatue on macOS 15.7.3 (Sequoia) and by us on macOS 26.2 (Tahoe). Also reported on Windows. This is a cross-platform Claude Code bug.

Likely Mechanism

The Bash tool's Node.js subprocess handler appears to have a race condition in its stdout pipe reader. When an external binary is forked (fork+exec), the pipe reader seems to interpret the child process exit as "done reading" and closes the pipe — but the parent shell still has pending output to flush. Commands that complete within ~270ms finish before this premature close; anything slower loses its output.

Environment

  • Claude Code: 2.1.52
  • macOS: 26.2 (Darwin 25.2.0, arm64)
  • Shell: zsh
  • Model: Opus 4.6
baslogic · 4 months ago

Update 2: Debug Log Timing Analysis — Shell Exit Before Stdout Drain

Ran Claude Code with --debug-file /tmp/claude-debug.log and executed /bin/sleep 0.3 && echo "hello" twice. First run captured output, second run swallowed it.

Debug Log: First Run (OUTPUT CAPTURED ✅)

17:02:35.406  Creating shell snapshot (first invocation, fresh session)
17:02:35.416  Spawning shell without login (-l flag skipped)
17:02:36.376  Shell snapshot created successfully (178369 bytes)
17:02:36.704  PostToolUse hook fires — tool considers command "done"

Total duration: ~1,288ms

The snapshot creation adds ~960ms of overhead. This gives /bin/sleep 0.3 && echo "hello" plenty of time — the sleep and echo finish well before the tool closes the pipe.

Debug Log: Second Run (OUTPUT SWALLOWED ❌)

17:02:49.197  Spawning shell without login (-l flag skipped)
17:02:49.529  PostToolUse hook fires — tool considers command "done"

Total duration: ~332ms

No snapshot creation on the second run — the shell is reused. The tool declares "done" at 332ms, which is almost exactly when /bin/sleep 0.3 finishes. The echo "hello" hasn't flushed to the pipe yet when the tool closes it.

Root Cause

The Bash tool's "command complete" signal is based on the shell process exiting, not on stdout being fully drained.

  • First invocation: snapshot creation overhead (~960ms) provides a buffer that masks the race condition — stdout has plenty of time to flush before the tool reads it.
  • Subsequent invocations: no snapshot overhead, so the tool closes the pipe within milliseconds of the external binary exiting, before the parent shell's remaining stdout (echo) flushes.

No Errors Logged

The debug log contains zero errors or warnings related to stdout/pipe handling. The tool silently believes the command produced no output. There is no EPIPE, no broken pipe, no drain warning — the race condition is completely invisible to the current logging.

The Fix

The tool needs to wait for the stdout pipe EOF (i.e., fully drain the readable stream) before declaring the command complete, rather than relying on process exit timing alone. This is a classic Node.js subprocess gotcha — child.on('exit') fires before child.stdout.on('end') when there's buffered data.

The standard pattern is:

// Wrong: closes pipe too early
child.on('exit', () => resolve(output));

// Right: wait for streams to drain
child.on('close', () => resolve(output));
// or
Promise.all([
  new Promise(r => child.stdout.on('end', r)),
  new Promise(r => child.on('exit', r))
]).then(() => resolve(output));

Environment

  • Claude Code: 2.1.52 with --debug-file /tmp/claude-debug.log
  • macOS: 26.2 (Darwin 25.2.0, arm64)
  • Shell: zsh
baslogic · 4 months ago

Update: Regression identified — introduced in v2.1.45

We bisected this issue and confirmed it's a regression starting at v2.1.45.

Test methodology

Repeated runs of /bin/sleep 0.3 && echo "hello" across versions:

| Version | Result |
|---------|--------|
| 2.1.44 | Consistent output — 3/3 passes at 0.3s, also passes at 0.5s |
| 2.1.45 – 2.1.56 | Intermittent silent stdout loss at ≥0.3s delay |

Reproduction on 2.1.56

During a single session, the same command alternated between returning hello and returning empty output (Tool ran without output or errors), confirming the race condition is timing-dependent.

Likely culprits in 2.1.45

The 2.1.45 changelog includes changes to startup performance, memory usage, and Task tool process handling. Subsequent versions made further BashTool changes:

  • 2.1.47: "Fixed bash tool output silently discarded on Windows with MSYS2/Cygwin" (Windows-only fix, same class of bug)
  • 2.1.51: "BashTool now skips login shell (-l flag) by default when a shell snapshot is available"

The Windows stdout fix in 2.1.47 suggests the team is aware of pipe handling issues in the BashTool but the macOS/Linux variant remains unfixed.

Environment

  • macOS Darwin 25.3.0 (arm64)
  • Shell: zsh
  • Platform: Anthropic API
baslogic · 4 months ago

Requesting maintainer review — regression bisected, root cause identified, local reproducer provided

This issue has a universal local reproducer (no SSH, no dependencies):

# FAILS (output swallowed):
/bin/sleep 0.3 && echo "hello"

# WORKS:
/bin/sleep 0.2 && echo "hello"

Root cause: The BashTool's pipe reader closes on process exit rather than waiting for stdout to fully drain. The threshold is ~270ms — any external binary that delays stdout beyond that gets silently dropped. This is the classic Node.js child.on('exit') vs child.on('close') race condition.

Regression bisected to v2.1.45 — v2.1.44 works consistently. The same class of bug was already fixed for Windows in v2.1.47 ("bash tool output silently discarded on Windows with MSYS2/Cygwin"), but the macOS/Linux variant remains.

This also unifies at least 4 open issues with zero maintainer responses: #19663, #22627, #16431, #21143.

The fix is a one-liner — wait for stream drain before resolving:

// Current (broken): resolves before stdout flushes
child.on('exit', () => resolve(output));

// Fixed: waits for streams to close
child.on('close', () => resolve(output));

@whyuan-cc @ddworken @claude — would any of you be able to take a look? This affects every platform on v2.1.45+ and has multiple community reports.

baslogic · 4 months ago

Update: Root Cause Identified — claude mcp list in Custom Statusline

After extensive investigation, we identified the actual root cause on our end: a custom statusline command that called claude mcp list on every refresh cycle.

The Setup

Our settings.json included a custom statusline:

"statusLine": {
  "type": "command",
  "command": "bash ~/.claude/statusline-command.sh"
}

The script displayed model, cost, context usage, git branch, MCP server status, and AWS SSO token TTL:

#!/bin/bash
input=$(cat)

# Parse all fields
model=$(echo "$input" | jq -r '.model.id // ""' | sed 's/^claude-//')
cwd=$(echo "$input" | jq -r '.cwd // ""')
project=$(echo "$input" | jq -r '.workspace.project_dir // ""')
used=$(echo "$input" | jq -r '.context_window.used_percentage // 0' | cut -d. -f1)
cost=$(echo "$input" | jq -r '.cost.total_cost_usd // 0')
# ... more jq calls for duration, lines added/removed, version ...

branch=$(git -C "$cwd" rev-parse --abbrev-ref HEAD 2>/dev/null)

# THIS IS THE PROBLEM:
mcp_raw=$(claude mcp list 2>/dev/null || true)
if [ -n "$mcp_raw" ]; then
  mcp_total=$(echo "$mcp_raw" | grep -c "✓\|✗\|❌" || echo 0)
  mcp_connected=$(echo "$mcp_raw" | grep -c "✓" || echo 0)
  mcp_names=$(echo "$mcp_raw" | grep "✓\|✗\|❌" | sed 's/:.*//' | tr '\n' ',' | sed 's/,$//')
fi

# ... format and printf output ...

The culprit is claude mcp list — it spawns an entire Claude Code CLI process (full Node.js application startup) on every statusline refresh. Compare that to the other commands in the script (jq, git rev-parse, sed, grep) which are lightweight binaries that return in microseconds. Running a full Node.js app on a periodic loop saturates the event loop and causes the Bash tool's stdout pipe reader to lose the race.

How We Confirmed It

| Configuration | /bin/sleep 2.0 && echo "hello" |
|---|---|
| Statusline with claude mcp list | Fails at ~270-320ms (threshold varies with system load) |
| Statusline without claude mcp list | Passes at 2000ms+ |
| Statusline completely disabled | Passes at 2000ms+ |

Other factors we ruled out:

  • Heavy .zshrc (NVM 895ms, direnv 262ms, SDKMAN 40ms) — shifted threshold by ~10-20ms at most
  • CPU load (Dart language server at 139% CPU) — no meaningful impact after killing it
  • FortiClient endpoint protection (13 processes) — not the cause
  • macOS version (26.3 Tahoe) — not version-specific

Correction to Previous Analysis

Our earlier comments identified this as a Bash tool pipe handling bug (using child.on('exit') instead of child.on('close')). That analysis was wrong — or at minimum, incomplete. The ~270ms threshold we measured was not an inherent timeout in the Bash tool's pipe reader. It was the result of claude mcp list saturating the event loop on our specific machine.

We cannot confirm whether the underlying race condition exists independently of this statusline issue. On our system, with claude mcp list removed from the statusline, we see zero stdout loss even at multi-second delays.

Recommendation

If you're experiencing this issue, check whether you have a custom statusline or hooks that spawn heavyweight processes (especially claude mcp list or other CLI subprocesses). Disabling or simplifying those may resolve it.

A lighter-weight way to query MCP server status from within a statusline would be useful — claude mcp list is too expensive to call on a periodic refresh loop.

Environment

  • Claude Code: 2.1.59
  • macOS: 26.3 (Darwin 25.3.0, arm64)
  • Shell: zsh 5.9
AllenThomasDev · 4 months ago

For slow commands > 0.3seconds, can someone confirm if they can write the command to the file?
eg. Bash(slow command) gives no output for me, but Bash(slow command) > foo.txt saves the output to the file

baslogic · 4 months ago

@AllenThomasDev Yes, the file redirect workaround works. Both of these approaches capture the output correctly:

# Local redirect
ssh root@host 'slow-command' > /tmp/output.txt 2>&1
cat /tmp/output.txt

# Works for non-SSH commands too
/bin/sleep 0.3 && echo "hello" > /tmp/output.txt
cat /tmp/output.txt

That said, check our latest update above — the root cause on our end turned out to be a custom statusline running claude mcp list on every refresh cycle, which saturated the event loop. After removing that, we no longer see stdout loss even at multi-second delays. If you have any custom statuslines or hooks spawning heavyweight processes, disabling those may fix it entirely.

possibilities · 4 months ago

+1 — also hitting this on macOS (arm64) with Claude Code 2.1.63. In my case, the Bash tool loses all stdout when the command launches a nested Claude Code subprocess via the claude-agent-sdk query() function. Output is written correctly (verified via file redirect and os.write(1) returning success), but the Bash tool captures nothing. File redirect workaround confirms.

dimfeld · 4 months ago

Hadn't made the connection before but now that you mention it, that is exactly the same problem I've been having. I've been seeing the same thing as @possibilities -- when the Bash tool spawns a subprocess that internally runs Claude Code, the parent Claude sees nothing on the Bash output.

My subprocess is running claude with streaming JSON input/output instead of using agent SDK but basically the same thing. I also worked around for now with solutions such as file redirect.

possibilities · 4 months ago
Hadn't made the connection before but now that you mention it, that is exactly the same problem I've been having. I've been seeing the same thing as @possibilities -- when the Bash tool spawns a subprocess that internally runs Claude Code, the parent Claude sees nothing on the Bash output. My subprocess is running claude with streaming JSON input/output instead of using agent SDK but basically the same thing. I also worked around for now with solutions such as file redirect.

Maybe it wasn't a useful exercise but I tried moving the invocation out to a separate script that gets shelled out to and still nothing. That was just me flailing but thought I'd mention it.

github-actions[bot] · 3 months ago

Closing for now — inactive for too long. Please open a new issue if this is still relevant.

github-actions[bot] · 3 months ago

This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.