enabledPlugins conflates dispatch gate and autoloader, causing plugin SIGTERM fratricide between concurrent sessions
Summary
enabledPlugins in ~/.claude/settings.json conflates two independent functions:
- Dispatch gate — whether
--channelsroutes messages to a plugin - Autoloader — whether every new
claudeprocess spawns the plugin at startup
This creates an unresolvable catch-22: there is no configuration state that enables --channels dispatch WITHOUT causing every child claude process to auto-spawn the plugin. When a child process spawns a second plugin instance, the plugin's kill-stale-pid startup guard SIGTERMs the parent's plugin instance ("fratricide"), leaving the parent session with a dead channel.
This affects any workflow that runs multiple concurrent claude sessions on the same machine (e.g., multiple terminal tabs, automation scripts spawning subprocesses, etc.).
Reproduction Steps
- Set
~/.claude/settings.jsonto include:
``json``
{ "enabledPlugins": { "telegram@claude-plugins-official": true } }
- Start a
claudesession with--channels plugin:telegram@claude-plugins-official. Verify the plugin starts and the Telegram bot is polling (check~/.claude/channels/telegram/bot.pid). - In a separate terminal, start any
claudeprocess — even a simpleclaude --print "hello"with no--channelsflag. - Observe: the second
claudeprocess readsenabledPlugins, auto-loads the Telegram plugin, the new plugin readsbot.pid, finds the first session's plugin PID, sends SIGTERM to it, and takes over the poll slot. - The first session's plugin is now dead. The first session loses all inbound channel functionality.
- When the second
claudeprocess exits, its plugin also dies (stdin EOF), leaving zero active pollers.
Expected Behavior
Starting a second claude process (without --channels) should NOT interfere with an existing session's plugin. Ideally:
enabledPlugins(or a new setting) should control whether--channelsdispatch is authorized, separately from whether the plugin is auto-loaded on every session.- OR a flag like
--no-pluginsshould exist to prevent child processes from auto-loading plugins (note:--bareexists and prevents plugin loading, but it also disables hooks,CLAUDE.md, and auto-memory, making it unsuitable for real sessions).
Actual Behavior
enabledPlugins Truth Table (empirically verified on v2.1.110)
| enabledPlugins value | --channels dispatch | Child session (no --channels) |
|---|---|---|
| {} | BLOCKED — plugin not started | No plugin spawned |
| {"telegram@...": true} | WORKS | SPAWNS PLUGIN → SIGTERM fratricide |
| key absent | BLOCKED | No plugin spawned |
| {"telegram@...": false} | BLOCKED | No plugin spawned |
No state satisfies "dispatch works AND children don't spawn plugins."
Kill-stale-pid mechanism (plugin source server.ts, lines ~58-68)
On startup, the plugin:
- Reads
~/.claude/channels/telegram/bot.pid - If a PID is found and the process is alive, sends
SIGTERMto it - Writes its own PID to
bot.pid - Begins polling Telegram
This is a "last writer wins" singleton guard. It works correctly when a single session restarts its own plugin, but becomes destructive when concurrent sessions each spawn their own plugin instance. The SIGTERM is unconditional — it does not check whether the existing plugin belongs to a different session.
Secondary Bug: Orphan Watchdog Broken on macOS/Bun
The plugin includes an orphan watchdog (lines ~657-664 of server.ts) that checks process.ppid !== bootPpid to detect when the parent claude process has died. However, process.ppid in Bun (tested on 1.3.11, macOS) is a startup snapshot that never updates after the real parent dies.
Evidence: after killing the parent process, process.ppid continued to report the dead parent's PID at t=1s, 3s, and 8s, while the kernel-level ppid had already changed to 1 (init/launchd). The watchdog conditions (ppid !== bootPpid, stdin.destroyed, stdin.readableEnded) did not fire within 10 seconds of parent death.
This means orphaned plugin processes linger indefinitely until the next session's fratricide guard kills them, or until the machine is restarted.
This is independent from the enabledPlugins conflation bug — either bug alone would cause reliability issues; together they create a situation where the plugin is essentially unusable with concurrent sessions.
Suggested Fixes
- Split
enabledPluginsinto two settings — one for "authorize--channelsdispatch" and one for "auto-load on every session." This is the minimal change that would resolve the catch-22. - Add a
--no-pluginsflag — a lighter alternative to--barethat suppresses plugin auto-loading without disabling hooks,CLAUDE.md, or auto-memory. - Make the singleton guard cooperative — instead of unconditional SIGTERM, use a UNIX socket or lock file with session affinity, so concurrent sessions' plugins can coexist or gracefully defer to the existing instance.
- Fix the orphan watchdog for Bun on macOS — use a polling mechanism that reads
/procor callskill(ppid, 0)instead of relying onprocess.ppid, which is a cached startup value in Bun.
Environment
- Claude Code: v2.1.110
- Plugin:
telegram@claude-plugins-officialv0.0.6 - Runtime: Bun 1.3.11
- OS: macOS (Darwin)
- Use case: Multiple concurrent
claudesessions on the same machine (legitimate workflow — e.g., multiple projects, automation, agent orchestration)
Related Issues
- #47153 — Telegram plugin reliability
- #37072, #37301, #37933, #36502, #36411 — Various
--channels/ plugin issues
This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗