[BUG] Claude Code spawns duplicate channel plugin instances mid-session, causing 409 Conflict and tool loss
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:
- Harness bug (this repo): Duplicate spawn with no apparent trigger. Tools not re-registered after the lifecycle churn. No debug logging.
- 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
- Claude Code spawns a duplicate plugin process (~3 min into session, no error preceding it)
- Both instances call
getUpdateson the same bot token -> Telegram returns 409 to the original - Grammy's
bot.start()rejects with an unhandled rejection (no.catch()on the fire-and-forget call) - Claude Code kills the new instance's stdin pipe shortly after spawning it
- The original instance is left alive but with broken polling and a poisoned Grammy state
- 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
- Start Claude Code with a channel plugin:
````
claude --channels plugin:telegram@claude-plugins-official
- Use the reply tool several times over 2-5 minutes
- Observe via
ps aux | grep telegramthat a second bun process appears - Tool calls begin failing:
Error: No such tool available: mcp__plugin_telegram_telegram__reply - 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):
- Do not spawn a duplicate plugin instance if one is already running and healthy
- If a restart is needed, kill the old instance first (SIGTERM, wait, SIGKILL)
- After spawning a new instance, re-register its tools in the active session
- Log plugin lifecycle events (spawn, kill, restart, tool registration) to
~/.claude/debug/
Related Issues
- #36308 - MCP servers should auto-reconnect (general case, doesn't cover duplicate spawning)
- anthropics/claude-plugins-official#788 - Four resilience issues (comprehensive analysis)
- anthropics/claude-plugins-official#794 - Two sessions with same bot cause 409 (same mechanism, but we show it happens within a single session)
16 Comments
Found 3 possible duplicate issues:
This issue will be automatically closed as a duplicate in 3 days.
🤖 Generated with Claude Code
Not a duplicate of the suggested issues. Here's why:
mcp list. Our issue is macOS, and the trigger is the harness spontaneously spawning a duplicate instance with no preceding event or error.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.
Same stdin-close behavior here, but from a different trigger:
/mcpreconnect (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/mcpmid-session reconnect kills it immediately:Reproduced 15+ times across multiple sessions. Fresh session starts stay connected indefinitely:
Your instrumented log at
PID=69445shows 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/mcpreconnect.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 (
/mcpreconnect, context recycling, duplicate spawn).Environment: macOS Darwin 25.3.0, Node.js v22.13.0, Claude Code latest
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 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.
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 withKeepAlive: false(KeepAlive true made it worse — it would spawn new instances on top of existing ones).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.tsprocesses 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:
pkill -f "bun.*server.ts"— kill all bun processestmux send-keysto inject/mcpkeystrokes into the Claude Code TUI:This navigates:
/mcp→ select the plugin (Up = last item) → Enter → Reconnect → Esc to close.Key details
tmux send-keysmust be a separate call with delays — chaining them in one call doesn't work reliably.run_in_backgroundso it doesn't block the session.1. Reconnect, 2. Disable— so Enter selects Reconnect directly.1. View tools, 2. Reconnect, 3. Disable— so you'd need Down + Enter instead.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.).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:whatsappusingStdioServerTransportInstrumented Proof
We added PID logging to every webhook processing step. The evidence is clear:
With orphan processes (harness-spawned duplicates):
After manually killing orphans — same session, same code:
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
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:This works but is a plugin-side workaround. The harness should:
Hi - we've put in some fixes to channels that address zombie
bun server.tspollers / 409 Conflict. Please update your channel version for telegram to 0.0.6.---
_Generated by Claude Code_
Very nice - thanks Noah!
Observed in production — compact as trigger
Reproducing consistently with
--channels plugin:telegram@claude-plugins-officialv0.0.6, Claude Code v2.1.109, bun 1.3.11, Linux (screen session).Trigger: auto-compact (
; Conversation compactedin scrollback). Heavy dev sessions hit context limit → compact → duplicate bun spawned → 409 chaos → both buns exit.Observable state:
pgrep -P $CLAUDE_PID bunreturns empty. Claude process and full context still alive./reload-pluginssays "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:triggered from a monitoring cron when bun is found absent.
Suggestion: the 8-attempt retry loop in
server.tscouldSIGTERMthe stale poller viaPID_FILEbefore retrying, rather than just waiting. Or Claude Code could delay before respawning the plugin subprocess after a compact.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:
A couple of secondary effects worth surfacing while you're looking at this:
claudeparent process. End-to-end cost is more than just the plugin — full session restarts every ~2 hours.Related issues that look like facets of the same root cause: #1478, #36933, #45146.
Happy to share sanitized logs if helpful for prioritization.
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 loopWhen compact spawned a second
claude --channelsprocess, both pointed to the sameTELEGRAM_STATE_DIR(~/.claude/channels/telegram). Each spawned its ownbun server.ts. On startup,server.tswrites its PID tobot.pidand kills any PID it finds there (stale-kill guard). With two concurrent instances:bot.pidbot.pid, kills Bun A, overwrites with its own PIDConfirmed via:
Fix
Kill the duplicate
claude --channelsprocess (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 onebun server.tsperTELEGRAM_STATE_DIR.Stability
Since that fix (Linux, screen session, claude-plugins-official telegram v0.0.6, bun 1.3.11):
/usr/local/bin/bun server.ts, PID stable, no reload)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.
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.
Closing for now — inactive for too long. Please open a new issue if this is still relevant.
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