Agent-team teammate reports 'Spawned successfully' but never registers in mailbox (v2.1.123, macOS, tmux)

Status Fixed / completed
Reported on v2.1.123
Maintainer reply None cached
Activity 3 comments · opened Apr 29, 2026 · closed Apr 29, 2026

Summary

Agent-team teammates spawned via the Agent tool report Spawned successfully but the child Claude Code subprocess never registers with the parent's mailbox. current-team.json stays at members: [] and the orchestrator never receives a reply.

Environment

  • macOS Darwin 24.4.0 (arm64)
  • Claude Code v2.1.123 (native installer, binary at ~/.local/share/claude/versions/2.1.123)
  • tmux 3.6a (Homebrew)
  • zsh
  • CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 enabled
  • Reproduced on v2.1.118 and v2.1.123. Issue is independent of binary version.

Reproduction

  1. Orchestrator session (interactive claude) calls:

``
Agent({
subagent_type: "general-purpose",
model: "sonnet",
mode: "acceptEdits",
team_name: "<team>",
name: "test-spawn",
prompt: "reply HELLO and end your turn"
})
``

  1. Tool returns Spawned successfully with agent_id: test-spawn@<team>.
  2. Tmux pane shows the spawn shell command, optionally a compdef: command not found warning from zsh init, then Resume this session with: claude --resume <uuid>, then a fresh shell prompt.
  3. ~/.claude/teams/<team>/config.json contains the team but members is empty.
  4. Orchestrator's current-team.json shows members: []. No teammate reply ever arrives. Orchestrator hangs or times out.

Expected behavior

Child process registers itself in the parent team's config.json members array, opens IPC mailbox, processes the prompt, and replies via SendMessage.

Actual behavior

Child exits cleanly (prints "Resume this session" — the standard exit message) before completing the IPC handshake. Orchestrator sees Spawned successfully but never gets a reply.

Diagnostic results

Direct binary tests work:

echo "hi" | /Users/<user>/.local/share/claude/versions/2.1.123 --print --model sonnet --permission-mode bypassPermissions
# returns "Hey."

env CLAUDECODE=1 CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 \
  /Users/<user>/.local/share/claude/versions/2.1.123 \
  --print "reply HELLO" \
  --agent-id t@td --agent-name t --team-name td --agent-color blue \
  --parent-session-id <fresh-uuid> --agent-type general-purpose \
  --permission-mode auto --model sonnet
# stdout: HELLO, exit 0

Manual tmux pane reproduction (no orchestrator) — child binary stays alive and shows banner:

tmux new-session -d -s test
tmux send-keys -t test "env CLAUDECODE=1 CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1 \
  /Users/<user>/.local/share/claude/versions/2.1.123 \
  --agent-id t@td --agent-name t --team-name td --agent-color blue \
  --parent-session-id <fresh-uuid> --agent-type general-purpose \
  --permission-mode auto --model sonnet" Enter
sleep 5
tmux capture-pane -t test -p
# Pane shows Claude Code banner, "@t" agent prompt, no errors, stays alive.

So the binary itself spawns correctly under tmux. The failure is internal to Claude Code's orchestrator-side spawn logic when invoked via the Agent tool with team_name.

Suspected cause

Orchestrator declares Spawned successfully based on tmux pane creation (or the binary returning a session id) rather than on the child completing the mailbox handshake. The child appears to start, then exits before subscribing to the mailbox — possibly because:

  • The --parent-session-id it receives doesn't match a parent that has begun listening on the corresponding mailbox path.
  • A race between team config write and child subscribe.
  • The child's interactive-mode init aborts when its stdin is the tmux pane (different from manual reproduction stdin path).

Side-quest finding (already worked around)

Independent of this bug, ~/.zshrc lines that source process substitutions (source <(ng completion script)) without an [[ -o interactive ]] guard emit compdef: command not found warnings during shell init in tmux panes. After guarding zshrc, the warning is gone and a manual tmux spawn succeeds — but the orchestrator's Agent call still fails to receive a reply, which is why I believe the spawn-vs-handshake issue is a separate bug.

Workaround

Use plain subagents (Agent without team_name) — these spawn reliably. Forfeits team mailbox / per-teammate fresh-context-window benefits.

Repo for reference

The orchestrator system that hits this consistently: <https://github.com/borabekarr/claude-code-system> (private/local but happy to provide minimum repro on request).

View original on GitHub ↗

3 Comments

0xbrainkid · 5 months ago

This is lifecycle-registration failure between spawn acknowledgment and actual availability.

If a teammate reports “Spawned successfully” but never registers in the mailbox, then the system is asserting success at an intermediate step while the control surface the user depends on never becomes real.

Why it matters:

  • users read “spawned successfully” as operational readiness, not as a partial backend milestone
  • mailbox registration is the point where the spawned teammate becomes actionable
  • if success is declared before availability exists, operators lose trust in the meaning of lifecycle states

I would want clarity on:

  1. whether spawn and mailbox registration are separate async stages with missing reconciliation
  2. whether the teammate exists but is indexed under the wrong identity/session, or never completed startup at all
  3. whether the UI can distinguish spawn requested, runtime up, and mailbox registered instead of collapsing them into one premature success state

Lifecycle status has to describe the state users can actually act on. If “spawned successfully” precedes mailbox visibility indefinitely, the control plane is overstating reality.

borabekarr · 5 months ago

Update for everyone hitting the "teammate spawns then dies in 1–10 seconds" bug

Thanks to the reviewer who reframed this as a lifecycle-registration failure between spawn acknowledgment and actual availability. That framing was exactly right and turned out to point at a deeper problem than the original symptom. Posting the full investigation + fix here so anyone hitting the same thing can copy-paste their way out.

---

TL;DR

Two independent bugs were stacked on top of each other:

  1. Hook env-var bug — local PreToolUse hooks (context-mode-router.py, read-discipline.py in our repo) were reading CLAUDE_SESSION_ID and CLAUDE_AGENT_ID from os.environ. Claude Code does not expose these as environment variables to PreToolUse hooks (upstream tracking: #40140, #16424). Both reads returned empty strings, all sessions keyed to "anon", offense state accumulated across every run forever. The first 300+-line Read from a fresh teammate hard-denied because the file already had 8 strikes from prior sessions. The teammate had no recovery path and the harness ended the session within 3 seconds.
  1. Lifecycle reconciliation gap — the SDK's Agent({team_name, name, ...}) returns "Spawned successfully" on spawn-request acknowledgment only. There is no signal back to the orchestrator when a teammate dies between ack and the orchestrator's first follow-up message. members[].isActive stays true in ~/.claude/teams/<team>/config.json indefinitely, and any single-teammate enforcement hook then zombie-blocks every future spawn until manual cleanup.

Bug 1 caused the suicides we were observing. Bug 2 explains why the suicides went undetected for minutes (in our case: 4 minutes between the teammate's death and our orchestrator's first follow-up).

---

Empirical evidence (one incident, one timeline)

Team exec-do-you-think-we-misty-waffle-20260429-134713, member step-1-executor:

| UTC timestamp | Event | Source |
|---|---|---|
| 13:49:04.321Z | Briefing delivered, marked read=true | inboxes/step-1-executor.json |
| 13:49:06.682Z | Teammate session jsonl first event | ~/.claude/projects/.../cb19bbd3-...jsonl |
| 13:49:09.762Z | Last event in teammate jsonl — session ended | same file, line 22 of 22 |
| 13:53:16.114Z | Orchestrator's first SendMessage(shutdown_request) to a long-dead PID | inbox file |

Three-second teammate lifetime. Four minutes of orchestrator blindness. The teammate's last tool call was a single Read on a 418-line file that the local context-mode-router.py denied with Second offense for pattern '...' — except it was the teammate's first Read in its first turn. The hook had been counting against the global "anon" session for weeks.

---

Reviewer's three questions, answered empirically

Q1: Are spawn and mailbox registration separate async stages with missing reconciliation?

Yes. Five distinct signals exist on different timelines:

| State | Source-of-truth signal |
|---|---|
| spawn_requested | Agent({...}) SDK call returned ok; member listed in config.json#members[] |
| runtime_up | ps -axwwo pid,command row contains claude/versions/ and --agent-id <member>@<team> |
| mailbox_ready | PID alive AND ~/.claude/teams/<team>/inboxes/<member>.json exists and is writable |
| producing | Member jsonl growing at ~/.claude/projects/.../{session-id}.jsonl |
| dead | Inbox file or config entry exists, but no PID matches |

The SDK collapses them into one positive Spawned successfully ack.

Q2: Does the teammate exist but is indexed under the wrong identity/session, or never completed startup at all?

Neither. Identity and indexing are correct. In team mode each teammate gets:

  • a canonical agentId of <member>@<team>,
  • its own session_id distinct from team-lead's,
  • its own jsonl at the project root (team-mode spawns are siblings, not nested under any subagents/ folder unlike old-style Agent-tool subagents),
  • an inbox file that receives the briefing successfully.

The teammate fully starts up. Then it dies seconds later from a hook deny / harness termination / unhandled tool-deny / OOM / anything, and the death never propagates back. Control plane stays at members[].isActive: true.

Q3: Can the UI distinguish spawn-requested / runtime-up / mailbox-registered instead of collapsing them?

Not at the SDK layer today. We synthesized the distinction locally from filesystem and process state. Drop-in scripts below.

---

Fixes (drop-in for similar setups)

1. Patch your PreToolUse hooks to read identity from stdin JSON, not env

The PreToolUse hook input on stdin includes session_id, transcript_path, cwd, tool_name, tool_input. It does NOT include agent_id (upstream feature request: #40140).

Replace any os.environ.get("CLAUDE_SESSION_ID") / os.environ.get("CLAUDE_AGENT_ID") in your hooks with stdin parsing:

import json, sys
input_data = json.loads(sys.stdin.read())
session_id = (input_data.get("session_id") or "").strip() or "anon"
transcript_path = (input_data.get("transcript_path") or "").strip()

# Detect team-mode subagent vs orchestrator: each teammate gets its own session_id.
# Detect old-style nested Agent subagent: transcript_path contains "/subagents/agent-".
agent_id = ""
if transcript_path and "/subagents/agent-" in transcript_path:
    try:
        agent_id = transcript_path.rsplit("agent-", 1)[1].rsplit(".jsonl", 1)[0]
    except IndexError:
        pass

Then key any per-session state files on session_id (and agent_id if you have it), and reset state when session_id changes. Wipe your existing offense / state files once after deploying — they will be poisoned with cross-session counts.

2. Add a lifecycle probe + reconciliation poller

Two short scripts. Both read-only, idempotent, sub-second.

teammate-lifecycle.sh — single-shot probe, prints one line of JSON:

#!/usr/bin/env bash
set -u
AGENT_ID="${1:-}"
[[ "$AGENT_ID" == *"@"* ]] || { echo "usage: $0 <member>@<team>" >&2; exit 64; }

MEMBER="${AGENT_ID%@*}"; TEAM="${AGENT_ID#*@}"
TEAM_DIR="${HOME}/.claude/teams/${TEAM}"
CONFIG="${TEAM_DIR}/config.json"; INBOX="${TEAM_DIR}/inboxes/${MEMBER}.json"

in_config=false; [ -f "$CONFIG" ] && grep -q "\"agentId\": *\"${AGENT_ID}\"" "$CONFIG" 2>/dev/null && in_config=true
inbox_exists=false; [ -f "$INBOX" ] && inbox_exists=true
inbox_writable=false; [ -w "$INBOX" 2>/dev/null ] && inbox_writable=true

PID=$(ps -axwwo pid,command 2>/dev/null | awk -v n="--agent-id $AGENT_ID" '/claude\/versions\// && index($0,n){print $1; exit}')
[ -z "$PID" ] && PID_J=null && alive=false || { PID_J="$PID"; alive=true; }

if   $alive && $inbox_exists; then STATE=mailbox_ready
elif $alive;                   then STATE=runtime_up
elif $inbox_exists || $in_config; then
  if $in_config && ! $inbox_exists; then STATE=spawn_requested; else STATE=dead; fi
else STATE=missing
fi

printf '{"state":"%s","pid":%s,"inbox_exists":%s,"inbox_writable":%s,"ts":"%s"}\n' \
  "$STATE" "$PID_J" "$inbox_exists" "$inbox_writable" "$(date -u +%FT%TZ)"

post-spawn-reconcile.sh — polls every 1s up to a timeout. Exit 0 = mailbox_ready. Exit 1 = dead or missing. Exit 2 = timeout.

#!/usr/bin/env bash
set -u
AGENT_ID="${1:-}"; TIMEOUT="${2:-15}"
[[ "$AGENT_ID" == *"@"* ]] || { echo "usage: $0 <member>@<team> [timeout]" >&2; exit 64; }
[[ "$TIMEOUT" =~ ^[0-9]+$ ]] || { echo "timeout must be int" >&2; exit 64; }
PROBE="$(dirname "$0")/teammate-lifecycle.sh"; [ -x "$PROBE" ] || exit 64

elapsed=0; final=""; state=""
while [ "$elapsed" -le "$TIMEOUT" ]; do
  final=$(bash "$PROBE" "$AGENT_ID" 2>/dev/null)
  state=$(printf '%s' "$final" | sed -n 's/.*"state":"\([^"]*\)".*/\1/p')
  case "$state" in
    mailbox_ready) echo "$final"; echo "reconcile: $AGENT_ID -> mailbox_ready in ${elapsed}s" >&2; exit 0 ;;
    dead|missing)  echo "$final"; echo "reconcile: $AGENT_ID -> $state in ${elapsed}s"      >&2; exit 1 ;;
  esac
  [ "$elapsed" -ge "$TIMEOUT" ] && break
  sleep 1; elapsed=$((elapsed+1))
done
echo "$final"; echo "reconcile: $AGENT_ID -> ${state:-unknown} in ${elapsed}s" >&2; exit 2

Call the poller in your orchestrator immediately after every team-mode Agent({...}) returns. Treat exit 1 as spawn_self_died (retry), exit 2 as spawn_no_mailbox (retry), three consecutive lifecycle failures as terminal debt.

3. Patch any single-teammate enforcement hook to skip dead members

If you have a PreToolUse hook on Agent that blocks new spawns while an existing member is "active" (we do — single-teammate.py), it will zombie-block forever once a teammate self-dies. Add a PID liveness check using the same --agent-id awk needle, and treat config members whose PID is gone as effectively shutdown with a stderr warning. One ps invocation, reused across all members. Fail-open on every exception.

ps_out = subprocess.run(["ps","-axwwo","pid,command"], capture_output=True, text=True, timeout=2).stdout
truly_active = []
for m in looks_active:
    needle = f"--agent-id {m['agentId']}"
    alive = any("claude/versions/" in line and needle in line for line in ps_out.splitlines())
    if alive:
        truly_active.append(m)
    else:
        sys.stderr.write(f"single-teammate: auto-skipping dead member {m['name']} — PID gone, treating as shutdown.\n")

---

Verified end-to-end

After shipping all three patches, ran a real-world smoke test:

| Phase | Check | Result |
|---|---|---|
| Spawn | Agent({team_name, mode: "acceptEdits", subagent_type: "general-purpose"}) | ack received |
| Reconcile | post-spawn-reconcile.sh <member>@<team> 15 | exit 0 in 0s, state=mailbox_ready, PID matched |
| Real work | Teammate completed a Write to disk | file appeared at T+1s, exact 487 bytes |
| Single-teammate hook (alive) | New spawn attempted while teammate's PID alive | exit 2, blocks correctly |
| Graceful shutdown | SendMessage(shutdown_request) + teardown-teammate.sh | PID=N TERM ok within 3s grace, no SIGKILL needed |
| Single-teammate hook (dead) | New spawn attempted while teammate's PID gone but config still says state: "active" | exit 0 with stderr single-teammate: auto-skipping dead member ... PID gone |
| Lifecycle probe (post-death) | teammate-lifecycle.sh | state=dead, PID null, inbox file retained for evidence |
| Reconcile (post-death) | poller | exit 1 in 0s — would correctly trigger spawn_self_died retry |

Hours of teammate suicide pain → reproducible, observable, recoverable lifecycle.

---

To the reviewer

The framing — "Lifecycle status has to describe the state users can actually act on. If 'spawned successfully' precedes mailbox visibility indefinitely, the control plane is overstating reality" — was the load-bearing insight. The env-var hook bug was the proximate cause of our specific incident, but it was your point that pushed us to add structural safety so the next failure mode (whatever it is) is detected within seconds instead of minutes. Appreciate it.

For upstream improvement, the two open issues most directly relevant are #40140 (PreToolUse needs agent_id) and #16424 (subagent session_id shared with parent). A standardized lifecycle event stream — spawn_requested / runtime_up / mailbox_ready / producing / dead as first-class signals — would let every downstream toolchain stop reinventing the synthesis above.

github-actions[bot] · 5 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.