[BUG] Claude Code spawns duplicate channel plugin instances mid-session, causing 409 Conflict and tool loss

Resolved 💬 16 comments Opened Mar 20, 2026 by greghughespdx Closed Jun 14, 2026

TL;DR

Claude Code spawns a second Telegram channel plugin process ~3 minutes into a healthy session, with no crash or error preceding it. This triggers a cascade:

  1. Harness bug (this repo): Duplicate spawn with no apparent trigger. Tools not re-registered after the lifecycle churn. No debug logging.
  2. Plugin-side damage (claude-plugins-official): 409 Conflict, zombie process, silent notification failures. PRs #812, #813, #814 mitigate these but don't address the harness trigger.

What needs investigation here: Why does the harness spawn a duplicate plugin process when the existing one is healthy? And why aren't tools re-registered after the process lifecycle churn?

---

Instrumented Diagnostic Log

We added lifecycle logging to the plugin to capture the exact failure sequence. The plugin was healthy for ~3 minutes (10 successful tool calls, 8 successful notifications, zero errors) before the harness spawned a second instance:

[19:51:24.204] PID=62665 starting up
[19:51:24.206] PID=62665 MCP connected
[19:51:47.405] PID=62665 delivering notification from myusername in chat XXXXXXXXXX
[19:51:47.405] PID=62665 notification delivered OK for chat XXXXXXXXXX
[19:51:47.636] PID=62665 tool call: reply
[19:51:59.543] PID=62665 tool call: reply
[19:52:32.195] PID=62665 delivering notification from myusername in chat XXXXXXXXXX
[19:52:32.196] PID=62665 notification delivered OK for chat XXXXXXXXXX
[19:52:36.444] PID=62665 tool call: reply
[19:52:42.173] PID=62665 delivering notification from myusername in chat XXXXXXXXXX
[19:52:42.173] PID=62665 notification delivered OK for chat XXXXXXXXXX
[19:52:53.014] PID=62665 delivering notification from myusername in chat XXXXXXXXXX
[19:52:53.014] PID=62665 notification delivered OK for chat XXXXXXXXXX
[19:52:53.293] PID=62665 tool call: reply
[19:52:57.878] PID=62665 tool call: reply
[19:53:03.031] PID=62665 delivering notification from myusername in chat XXXXXXXXXX
[19:53:03.031] PID=62665 notification delivered OK for chat XXXXXXXXXX
[19:53:08.931] PID=62665 tool call: reply
[19:53:19.631] PID=62665 tool call: reply
[19:53:37.686] PID=62665 delivering notification from myusername in chat XXXXXXXXXX
[19:53:37.686] PID=62665 notification delivered OK for chat XXXXXXXXXX
[19:53:42.061] PID=62665 tool call: reply
[19:53:47.657] PID=62665 delivering notification from myusername in chat XXXXXXXXXX
[19:53:47.657] PID=62665 notification delivered OK for chat XXXXXXXXXX
[19:54:00.217] PID=62665 delivering notification from myusername in chat XXXXXXXXXX
[19:54:00.218] PID=62665 notification delivered OK for chat XXXXXXXXXX
[19:54:09.444] PID=62665 tool call: reply
[19:54:21.395] PID=69445 starting up                    <-- SECOND INSTANCE SPAWNED
[19:54:21.397] PID=69445 MCP connected                  <-- new instance connects MCP
[19:54:21.959] PID=62665 UNHANDLED REJECTION: GrammyError: Call to 'getUpdates'
               failed! (409: Conflict: terminated by other getUpdates request;
               make sure that only one bot instance is running)
[19:54:24.436] PID=69445 delivering notification from myusername in chat XXXXXXXXXX
[19:54:24.436] PID=69445 notification delivered OK for chat XXXXXXXXXX
[19:54:34.906] PID=62665 tool call: reply                <-- old instance still gets tool calls
[19:54:36.947] PID=69445 stdin END event                 <-- harness kills new instance
[19:54:36.948] PID=69445 stdin CLOSE event
[19:54:37.084] PID=69445 beforeExit code=0
[19:54:37.084] PID=69445 exit code=0                     <-- new instance exits cleanly

Instrumentation added to server.ts: process.stdin end/close/error handlers, process.stdout error handler, process.on('uncaughtException'/'unhandledRejection'), transport.onerror on StdioServerTransport, and logging on every tool call and notification delivery with success/failure.

The Cascade

  1. Claude Code spawns a duplicate plugin process (~3 min into session, no error preceding it)
  2. Both instances call getUpdates on the same bot token -> Telegram returns 409 to the original
  3. Grammy's bot.start() rejects with an unhandled rejection (no .catch() on the fire-and-forget call)
  4. Claude Code kills the new instance's stdin pipe shortly after spawning it
  5. The original instance is left alive but with broken polling and a poisoned Grammy state
  6. MCP tools disappear from the session

What Existing Plugin PRs Cover

| Problem | PR | Status |
|---|---|---|
| Zombie process after MCP disconnect | #813 | Open, fixes stdin close detection |
| 409 Conflict crash | #814 | Open, adds retry with backoff |
| Unhandled rejection from bot.start() | #812 | Open, adds error handlers |
| Silent notification failures | #812 | Open, adds .catch() logging |
| Harness spawns duplicate process | None | Not addressed |
| Tools not re-registered after churn | None | Not addressed |

If PRs #812-814 all merge, the plugin would handle the duplicate gracefully (retry on 409, log errors, exit on stdin close). But the harness would still be spawning unnecessary duplicates and losing tool registrations.

Impact Beyond Telegram

The duplicate spawn is a harness behavior, not Telegram-specific. Any channel plugin with an exclusive external resource (Discord websocket, Slack RTM, etc.) would potentially hit the same 409-equivalent conflict. Plugins with idempotent polling would survive the duplication but would still lose tools from the registry inconsistency.

Note: Discord uses client.login(TOKEN) and a gateway websocket (discord.js). Discord's gateway handles duplicate connections differently than Telegram: Discord assigns a session ID per connection, and if a second connection opens with the same token, the gateway can either resume the old session or invalidate it gracefully. It doesn't return a hard 409 error like Telegram does. So, Discord would still get the duplicate-spawn from the harness, but it appears the consequences would be less severe. Discord's gateway is designed for reconnection. Telegram's getUpdates long-polling is explicitly exclusive (only one poller per token, hard 409 if violated).

Steps to Reproduce

  1. Start Claude Code with a channel plugin:

``
claude --channels plugin:telegram@claude-plugins-official
``

  1. Use the reply tool several times over 2-5 minutes
  2. Observe via ps aux | grep telegram that a second bun process appears
  3. Tool calls begin failing: Error: No such tool available: mcp__plugin_telegram_telegram__reply
  4. Original bun process is still running with the original PID

Reproduced 3 times in ~1 hour across separate session restarts.

Process Table Evidence

PID   STARTED   COMMAND
62657 12:51PM   bun run --cwd .../telegram/0.0.1 --shell=bun --silent start
69443 12:54PM   bun run --cwd .../telegram/0.0.1 --shell=bun --silent start

Comparison: Custom Channel Server Unaffected

A custom channel server loaded via --dangerously-load-development-channels server:agent-bus in the same session was NOT duplicated and remained stable throughout. The key difference: the custom server exits cleanly when stdin closes (no long-running background loop), so even if the harness attempted a restart, there would be no competing process.

Environment

  • macOS Darwin 25.3.0 (Apple Silicon)
  • Claude Code (latest via homebrew)
  • Plugin: plugin:telegram@claude-plugins-official (v0.0.1)

Suggested Fix

Harness side (this repo):

  1. Do not spawn a duplicate plugin instance if one is already running and healthy
  2. If a restart is needed, kill the old instance first (SIGTERM, wait, SIGKILL)
  3. After spawning a new instance, re-register its tools in the active session
  4. Log plugin lifecycle events (spawn, kill, restart, tool registration) to ~/.claude/debug/

Related Issues

View original on GitHub ↗

16 Comments

github-actions[bot] · 3 months ago

Found 3 possible duplicate issues:

  1. https://github.com/anthropics/claude-code/issues/36786
  2. https://github.com/anthropics/claude-code/issues/36652
  3. https://github.com/anthropics/claude-code/issues/36477

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

greghughespdx · 3 months ago

Not a duplicate of the suggested issues. Here's why:

  • #36786: Describes process reparenting to systemd on Linux, triggered by long Bash commands or mcp list. Our issue is macOS, and the trigger is the harness spontaneously spawning a duplicate instance with no preceding event or error.
  • #36652: Plugin crashes on first inbound notification (server exits on stdout write). Different failure mode entirely.
  • #36477: Messages stop processing after first response (stuck at prompt). Different symptom.

What makes this issue distinct is the instrumented diagnostic log that captures the exact sequence: the harness spawns a second plugin process while the first is healthy (10 successful tool calls, 8 successful notifications, zero errors), causing a 409 Conflict cascade. None of the suggested duplicates identify this root cause or provide comparable evidence.

The three issues are related in the sense that they're all symptoms of fragile plugin lifecycle management, but they describe different triggers and failure modes.

Alienfader · 3 months ago

Same stdin-close behavior here, but from a different trigger: /mcp reconnect (not duplicate spawning).

We're running a non-channel MCP server (Continuity — 46 tools, stdio transport, configured via .mcp.json). The server works perfectly on fresh session start, but every /mcp mid-session reconnect kills it immediately:

--- MCP server started PID=21482 PPID=21476 at 2026-03-21T16:59:46.429Z ---
[MCP] stdin closed — starting 5s grace period
[MCP] Grace period expired — shutting down

--- MCP server started PID=21624 PPID=21620 at 2026-03-21T16:59:59.710Z ---
[MCP] stdin closed — starting 5s grace period
[MCP] Grace period expired — shutting down

Reproduced 15+ times across multiple sessions. Fresh session starts stay connected indefinitely:

--- MCP server started PID=15335 PPID=15310 at 2026-03-21T16:48:20.217Z ---
(no stdin close — server runs for the entire session)

Your instrumented log at PID=69445 shows the same thing we're seeing — harness spawns the process, then closes stdin ~15 seconds later. In our case it's even faster — stdin closes within seconds of spawn on every /mcp reconnect.

We instrumented our server to confirm it's not a startup timeout:

| Component | Time |
| --- | --- |
| Bundle evaluation (1.5MB) | 25ms |
| Server constructor | 5ms |
| Transport connect | <1ms |
| Total startup | ~30ms |
| tools/list response | 19KB, 46 tools |

The server is ready in ~30ms. The harness is closing stdin before the handshake completes.

This suggests the stdin-close behavior isn't specific to channel plugins or duplicate spawning — it's a general harness issue affecting any MCP server during lifecycle events (/mcp reconnect, context recycling, duplicate spawn).

Environment: macOS Darwin 25.3.0, Node.js v22.13.0, Claude Code latest

greghughespdx · 3 months ago

Good to have the additional instrumented data. It appears pretty clear that this needs someone who can take a look under the hood. Lots of changes, happening quickly. I am happy to see the features (and fixes) rolling out iteratively and quickly - this is all part of shipping fast and making an impact!

Our real-world context: I built and published a small, lightweight tool that connects Claude Code (and other tools) to iMessage without the complexity and instability the heavy, complex, sometimes-unstable tools that are out there. Our "bridge" component was originally intended to run as a plugin, but it was failing due to this bug. It works perfectly as a custom server so I deployed it that way for now. The blocker for making it a proper plugin is the harness bug. As a plugin, it's subject to the duplicate-spawn issue.

Same stdin-close behavior here, but from a different trigger: /mcp reconnect (not duplicate spawning). We're running a non-channel MCP server (Continuity — 46 tools, stdio transport, configured via .mcp.json). The server works perfectly on fresh session start, but every /mcp mid-session reconnect kills it immediately:

...

Your instrumented log at PID=69445 shows the same thing we're seeing — harness spawns the process, then closes stdin ~15 seconds later. In our case it's even faster — stdin closes within seconds of spawn on every /mcp reconnect. We instrumented our server to confirm it's not a startup timeout: Component Time Bundle evaluation (1.5MB) 25ms Server constructor 5ms Transport connect <1ms Total startup ~30ms tools/list response 19KB, 46 tools The server is ready in ~30ms. The harness is closing stdin before the handshake completes. This suggests the stdin-close behavior isn't specific to channel plugins or duplicate spawning — it's a general harness issue affecting any MCP server during lifecycle events (/mcp reconnect, context recycling, duplicate spawn). Environment: macOS Darwin 25.3.0, Node.js v22.13.0, Claude Code latest
fdjkgh580 · 3 months ago

Same issue with Discord plugin. Reply tool intermittently fails with "channel is not allowlisted" even though sender is in
allowFrom. Messages come in fine but replies randomly fail. Retrying usually works. Plugin: claude-channel-discord v0.0.4,
macOS, DM allowlist mode.

Rich627 · 3 months ago

Seeing this with my WhatsApp channel plugin too (Rich627/whatsapp-claude-plugin). The harness spawns duplicate plugin processes mid-session with no crash or trigger — causes duplicate WhatsApp connections and message handling races.

My current workaround for the 24/7 headless setup: pkill -f "claude.*whatsapp" before each restart, and running inside a single tmux session managed by a LaunchAgent with KeepAlive: false (KeepAlive true made it worse — it would spawn new instances on top of existing ones).

diegomarvid · 3 months ago

Workaround: Self-healing via tmux send-keys + cron loop

We run a persistent Claude Code session as a Telegram daemon inside tmux. We hit the exact same duplicate-spawn issue — two bun server.ts processes running, messages splitting between them.

We built an auto-heal mechanism that runs entirely from within the Claude session using /loop (cron) + tmux send-keys:

How it works

Every 2 minutes, a cron job inside the Claude session runs:

pgrep -f "bun.*server.ts" | wc -l | tr -d ' '
  • If count is 1 → healthy, do nothing.
  • If count is 0 or >1 → auto-heal:
  1. pkill -f "bun.*server.ts" — kill all bun processes
  2. Use tmux send-keys to inject /mcp keystrokes into the Claude Code TUI:
sleep 3 && tmux send-keys -t claude-telegram "/mcp" Enter \
&& sleep 3 && tmux send-keys -t claude-telegram Up \
&& sleep 1 && tmux send-keys -t claude-telegram Enter \
&& sleep 2 && tmux send-keys -t claude-telegram Enter \
&& sleep 2 && tmux send-keys -t claude-telegram Escape

This navigates: /mcp → select the plugin (Up = last item) → Enter → Reconnect → Esc to close.

Key details

  • Each tmux send-keys must be a separate call with delays — chaining them in one call doesn't work reliably.
  • The keys queue in the terminal buffer and execute after Claude's current turn finishes.
  • The background command runs via run_in_background so it doesn't block the session.
  • When the MCP status is failed (after pkill), the submenu shows 1. Reconnect, 2. Disable — so Enter selects Reconnect directly.
  • When the MCP status is connected (duplicate spawn), it shows 1. View tools, 2. Reconnect, 3. Disable — so you'd need Down + Enter instead.
  • Since we always pkill first, the status is always "failed" and the simple sequence works.

Results

Running for several hours in production. Successfully auto-healed 3 times without human intervention. The duplicate-spawn issue recurs roughly every 30-60 minutes.

What would be better

A programmatic way to trigger MCP reconnect — e.g., claude mcp reconnect <server-name> as a CLI command, or an API that Claude can call from within a session. The tmux hack works but is fragile (depends on menu position, timing, etc.).

reinaldoon · 3 months ago

Same issue with custom WhatsApp channel plugin (not Telegram)

Confirming this affects custom-built channel plugins too, not just official ones. We built a WhatsApp channel plugin (based on the iMessage plugin architecture) and see the exact same duplicate spawn behavior.

Environment: Claude Code 2.1.104, macOS, Bun runtime, custom plugin:whatsapp using StdioServerTransport

Instrumented Proof

We added PID logging to every webhook processing step. The evidence is clear:

With orphan processes (harness-spawned duplicates):

[PID 69404] DUPLICATE skip: wamid...from 55XXXXXXXXXX (another instance processed first)

After manually killing orphans — same session, same code:

[PID 69404] NEW msg stored: wamid...from 55XXXXXXXXXX

The current process attempts to process the webhook, but a harness-spawned duplicate (orphan connected to a dead/previous MCP session) wins the race and stores the message. The notification goes to the dead session → message is silently lost.

How it manifests for us

Both instances poll the same Cloudflare R2 webhook storage. The first to process a webhook marks it in a shared SQLite DB. The loser sees "already processed" and skips. Since the orphan's MCP stdio goes to a dead session, notifications are lost intermittently (~20-30% of messages).

Timeline from our logs

14:08:17 [PID 71186] starting...  ← session start
14:08:42 [PID 71186] polling started
14:08:59 [PID 71370] starting...  ← harness spawns duplicate (no crash, no error)
14:09:13 [PID 71370] polling started
  → Two instances now competing for webhooks

No crash, no error, no disconnect preceded PID 71370's spawn. Exactly matches the ~3 minute pattern described in the original report.

Workaround: PID lockfile

We implemented a lockfile (plugin.pid) that kills the previous instance on startup:

const LOCK_FILE = join(HOME, '.claude/channels/whatsapp/plugin.pid')

function acquireLock() {
  if (existsSync(LOCK_FILE)) {
    const oldPid = parseInt(readFileSync(LOCK_FILE, 'utf-8').trim())
    try {
      process.kill(oldPid, 0) // check alive
      process.kill(oldPid, 'SIGTERM') // kill it
    } catch {} // stale PID, ignore
  }
  writeFileSync(LOCK_FILE, String(process.pid))
}

acquireLock() // called at startup, before polling begins

This works but is a plugin-side workaround. The harness should:

  1. Not spawn duplicates when the existing process is healthy
  2. Send SIGTERM to the old process before spawning a new one
  3. Re-register tools after lifecycle churn
noahzweben · 3 months ago

Hi - we've put in some fixes to channels that address zombie bun server.ts pollers / 409 Conflict. Please update your channel version for telegram to 0.0.6.

---
_Generated by Claude Code_

greghughespdx · 3 months ago
Hi - we've put in some fixes to channels that address zombie bun server.ts pollers / 409 Conflict. Please update your channel version for telegram to 0.0.6. _Generated by Claude Code_

Very nice - thanks Noah!

trojette · 3 months ago

Observed in production — compact as trigger

Reproducing consistently with --channels plugin:telegram@claude-plugins-official v0.0.6, Claude Code v2.1.109, bun 1.3.11, Linux (screen session).

Trigger: auto-compact (; Conversation compacted in scrollback). Heavy dev sessions hit context limit → compact → duplicate bun spawned → 409 chaos → both buns exit.

Observable state: pgrep -P $CLAUDE_PID bun returns empty. Claude process and full context still alive. /reload-plugins says "Reloaded: 1 plugin" but no new subprocess is spawned — Claude seemingly treats the plugin as already loaded from the failed restart attempt.

Workaround: a second /reload-plugins (after the 409 window has cleared) reliably brings bun back. We have automated this via:

screen -S mysession -p 0 -X stuff "/reload-plugins"$'\\r'

triggered from a monitoring cron when bun is found absent.

Suggestion: the 8-attempt retry loop in server.ts could SIGTERM the stale poller via PID_FILE before retrying, rather than just waiting. Or Claude Code could delay before respawning the plugin subprocess after a compact.

xe-su · 2 months ago

Hitting this in production on Linux with the official Telegram plugin. The 2-hour cadence is wall-clock-driven and metronome-like — at approximately 2h elapsed, claude spawns a parallel MCP child while the original is healthy and active, producing the duplicate-instance + 409 Conflict + mutual-termination cascade described in the issue body.

Pattern reproduces every ~2 hours predictably, which strongly suggests the trigger is a fixed timer in claude's MCP lifecycle, not idle / memory / event-driven. Want to surface a few asks that would help any channel-plugin operator live with this until it's properly fixed:

  1. Document the 2h refresh in the MCP docs. Acknowledging it exists at all would let plugin authors design around it instead of reverse-engineering from production failures.
  2. Expose a config knob (env var or settings.json key) to tune or disable the interval. For long-running channel plugins holding exclusive external resources (Telegram bot tokens, Discord WS, Slack RTM), an off-switch removes the issue at the source.
  3. Graceful handoff: SIGTERM the existing child, wait for clean exit, then spawn the replacement. This alone eliminates the 409 Conflict window for any channel plugin with exclusive external resources.
  4. Re-register tools after respawn (already in this issue's suggested fixes — prevents the toolless-session failure mode even when refresh succeeds).

A couple of secondary effects worth surfacing while you're looking at this:

  • Host supervisors that watch "is the MCP child alive?" interpret the duplicate-spawn → 409 → mutual-kill cascade as a full crash and tear down the entire claude parent process. End-to-end cost is more than just the plugin — full session restarts every ~2 hours.
  • The refresh occasionally collides with OAuth token refresh, producing 401 authentication errors in the recycle window.

Related issues that look like facets of the same root cause: #1478, #36933, #45146.

Happy to share sanitized logs if helpful for prioritization.

trojette · 2 months ago

Follow-up to my comment #11 — root cause confirmed, 11 days stable

After the compact-triggered duplicate spawn I described earlier, I was able to isolate the exact failure mechanism and a reliable fix.

---

Root cause: shared TELEGRAM_STATE_DIR + bot.pid stale-kill loop

When compact spawned a second claude --channels process, both pointed to the same TELEGRAM_STATE_DIR (~/.claude/channels/telegram). Each spawned its own bun server.ts. On startup, server.ts writes its PID to bot.pid and kills any PID it finds there (stale-kill guard). With two concurrent instances:

  1. Bun A starts → writes its PID to bot.pid
  2. Bun B starts → reads bot.pid, kills Bun A, overwrites with its own PID
  3. Bun A's death triggers claude to respawn → new bun kills Bun B
  4. Loop → both buns exit → bot dead

Confirmed via:

pgrep -a -u adn claude | grep channels
# showed two PIDs (e.g. 5102 + 5103) both with --channels plugin:telegram@claude-plugins-official
# and the same --state-dir

Fix

Kill the duplicate claude --channels process (the one spawned by compact, not the original session), then do a single clean restart of the original screen window. The stale-kill loop stops immediately since there is only one bun server.ts per TELEGRAM_STATE_DIR.

Stability

Since that fix (Linux, screen session, claude-plugins-official telegram v0.0.6, bun 1.3.11):

  • 11 days continuous uptime for the Bach Mamelouk bot (/usr/local/bin/bun server.ts, PID stable, no reload)
  • No 409 Conflict, no mutual-termination, no plugin loss after subsequent compacts

The session has gone through multiple compacts since then with no recurrence — the key invariant is one claude process per TELEGRAM_STATE_DIR.

---

The harness-side fix would be: before spawning a new plugin subprocess, check whether one is already running for the same state dir and skip the spawn (or adopt the existing process). This would prevent the mutual-kill loop regardless of whether the stale-kill guard exists in the plugin.

LozzKappa · 2 months ago

Same issue here. I documented all root causes + a working workaround architecture:
https://github.com/LozzKappa/clam-bot/blob/main/telegram-plugin-multi-instance-conflict.md
Includes interim SIGTERM patch, per-path mcpServers config, and the 4 upstream fixes needed.

github-actions[bot] · 1 month ago

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

greghughespdx · 4 days ago

Follow-up: the root cause here was never fixed at the harness level - the plugin v0.0.6 patch only changed the symptom (single-token takeover instead of 409). Filed a new issue with the corrected mechanism, fresh 3/3 reproduction evidence, and the workaround stack this forces: #76789