Skip to content

claude --bg never returns to a caller that pipes its stdout — the job finishes and the caller hangs #336

Description

@Wirasm

claude --bg never returns to a caller that captures its stdout through a pipe — the job finishes, the process exits, and the caller is still waiting.

Measured in spike #330, from an agent tool call. Claude Code 2.1.226.

What was measured

Same command, two ways of taking its output:

how stdout was taken result
pipe (… | cat, or captured by a harness) never returned at 110 s — and by then the process had already exited and the job was already done
file (… > out.txt) exit 0 in 0.87 s

The cause: claude --bg hands the job to a detached supervisor daemon, and the daemon inherits the caller's file descriptors. A pipe stays open as long as any process holds the write end, so the caller waits on a pipe that a long-lived daemon is keeping alive — not on the job, which finished.

Why this is filed rather than shrugged at

It is #184's exact shape, and #184 cost this repo a shipped feature. That bug was a printf emitting a correct escape sequence whose stdout the harness captured: it worked from a shell the operator typed into, was silent from an agent's tool call, and reported success either way. push.sh exists in its current form because of it, and its header carries the measurement of what an agent's position actually is — tty is ??, session 0, /dev/tty unopenable, [ -t 1 ] false.

An agent's tool call captures stdout by construction. So this is not an edge case for helm — it is the default case for every agent that would ever run claude --bg, and the failure is a hang rather than an error, which is the worst available shape: indistinguishable from a slow spawn, and it burns the caller's turn.

This repo has now hit the same class three times — #124, #184, and today's canvas gate accepting exit 0 as delivery. This is the fourth, in a primitive nobody has built on yet, which is the cheapest moment to know about it.

What to do

Nothing in helm calls claude --bg today, so this is a rule to establish before anything does, not a regression to fix:

  • Redirect stdout to a file, never a pipe, in any helm code path or documented snippet that spawns a background agent. The job's real output is read afterwards through claude agents --json, ~/.claude/jobs/<id>/state.json or claude logs <id> — all of which the spike confirmed work TTY-free.
  • If a skill or AGENTS.md ever documents claude --bg, the redirect is part of the documented invocation, with the reason attached — a documented snippet is an implementation, and this one hangs.
  • Consider whether whatever eventually spawns background agents should refuse to run with a piped stdout at all, the way push.sh refuses when it cannot find a pty helm owns. A loud refusal beats a hang, and that is the lesson push.sh exits 0 from outside a helm pane, having written its OSC into some other terminal #282 just paid for.

Acceptance

  • The rule is written down where someone about to spawn a background agent will see it.
  • Any helm code that spawns one uses a file, and something proves it — a hang has no error to assert on, so the test is that the call returns, with a timeout that fails rather than waits.
  • Measured from an agent tool call, which is the only seat where this happens.

Related: #330 (the spike), #184 and #124 (the same class), #282 (loud refusal over silent success).

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions