enabledPlugins conflates dispatch gate and autoloader, causing plugin SIGTERM fratricide between concurrent sessions

Resolved 💬 3 comments Opened Apr 17, 2026 by btc-er Closed Apr 20, 2026

Summary

enabledPlugins in ~/.claude/settings.json conflates two independent functions:

  1. Dispatch gate — whether --channels routes messages to a plugin
  2. Autoloader — whether every new claude process 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

  1. Set ~/.claude/settings.json to include:

``json
{ "enabledPlugins": { "telegram@claude-plugins-official": true } }
``

  1. Start a claude session with --channels plugin:telegram@claude-plugins-official. Verify the plugin starts and the Telegram bot is polling (check ~/.claude/channels/telegram/bot.pid).
  2. In a separate terminal, start any claude process — even a simple claude --print "hello" with no --channels flag.
  3. Observe: the second claude process reads enabledPlugins, auto-loads the Telegram plugin, the new plugin reads bot.pid, finds the first session's plugin PID, sends SIGTERM to it, and takes over the poll slot.
  4. The first session's plugin is now dead. The first session loses all inbound channel functionality.
  5. When the second claude process 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 --channels dispatch is authorized, separately from whether the plugin is auto-loaded on every session.
  • OR a flag like --no-plugins should exist to prevent child processes from auto-loading plugins (note: --bare exists 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:

  1. Reads ~/.claude/channels/telegram/bot.pid
  2. If a PID is found and the process is alive, sends SIGTERM to it
  3. Writes its own PID to bot.pid
  4. 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

  1. Split enabledPlugins into two settings — one for "authorize --channels dispatch" and one for "auto-load on every session." This is the minimal change that would resolve the catch-22.
  2. Add a --no-plugins flag — a lighter alternative to --bare that suppresses plugin auto-loading without disabling hooks, CLAUDE.md, or auto-memory.
  3. 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.
  4. Fix the orphan watchdog for Bun on macOS — use a polling mechanism that reads /proc or calls kill(ppid, 0) instead of relying on process.ppid, which is a cached startup value in Bun.

Environment

  • Claude Code: v2.1.110
  • Plugin: telegram@claude-plugins-official v0.0.6
  • Runtime: Bun 1.3.11
  • OS: macOS (Darwin)
  • Use case: Multiple concurrent claude sessions 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

View original on GitHub ↗

This issue has 3 comments on GitHub. Read the full discussion on GitHub ↗