Telegram plugin: multiple sessions compete for getUpdates, messages route to random session

Resolved 💬 4 comments Opened Mar 27, 2026 by dfiru Closed May 7, 2026

Bug Summary

When the Telegram plugin (telegram@claude-plugins-official) is enabled in ~/.claude/settings.json under enabledPlugins, every Claude Code session on the machine spawns its own bun process that calls the Telegram Bot API's getUpdates long-polling endpoint. Telegram only delivers updates to one consumer at a time. The result: incoming messages get routed to whichever bun process happens to win the poll slot — often a background session, subagent, or puppet that has no context to respond. The user's primary interactive session silently stops receiving messages.

Environment

  • macOS (Darwin 25.3.0)
  • Claude Code CLI (latest)
  • Plugin: telegram@claude-plugins-official (0.0.4)
  • Bun runtime

Root Cause

The enabledPlugins map in ~/.claude/settings.json is global — it applies to every claude process, not just the one started with --channels. When a user has:

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

...every Claude Code session (interactive sessions, --remote-control puppets, subagents, radiants, anything spawned by the daemon) loads the plugin and starts a bun process that calls bot.start()getUpdates.

Why the existing 409 handler makes it worse

The plugin already handles Telegram's 409 Conflict response (line 956-994 in server.ts), which fires when another consumer is active. But the handler retries with backoff indefinitely:

void (async () => {
  for (let attempt = 1; ; attempt++) {
    try {
      await bot.start({ ... })
    } catch (err) {
      if (err instanceof GrammyError && err.error_code === 409) {
        const delay = Math.min(1000 * attempt, 15000)
        await new Promise(r => setTimeout(r, delay))
        continue  // <-- retries forever
      }
    }
  }
})()

This means every competing process sits in a retry loop, waiting to steal the slot. When the winning process's long-poll times out or the session ends, a random loser takes over. Messages bounce between sessions unpredictably.

Steps to Reproduce

  1. Enable the telegram plugin globally:

``json
// ~/.claude/settings.json
{ "enabledPlugins": { "telegram@claude-plugins-official": true } }
``

  1. Start an interactive Claude Code session with --channels plugin:telegram@claude-plugins-official
  1. Start any additional Claude Code session (e.g., a subagent, a puppet via --remote-control, or just a second terminal)
  1. Send a message to the bot on Telegram
  1. Expected: Message arrives in the interactive session (the one started with --channels)
  2. Actual: Message arrives in a random session. Often the one you're not looking at. The interactive session appears to stop receiving Telegram messages entirely.

Observable symptoms

# Multiple bun processes competing:
$ ps aux | grep "telegram.*bun"
daniel  75094  bun run --cwd .../telegram/0.0.4 --shell=bun --silent start   # puppet A
daniel  94539  bun run --cwd .../telegram --shell=bun --silent start          # puppet B  
daniel  18376  bun run --cwd .../telegram --shell=bun --silent start          # interactive session

# stderr from competing instances:
telegram channel: 409 Conflict — another instance is polling (zombie session, or a second Claude Code running?), retrying in 1s
telegram channel: 409 Conflict, retrying in 2s
telegram channel: 409 Conflict, retrying in 3s

Impact

This is a complete loss of Telegram functionality for any user running more than one Claude Code session. The user has no indication which session received their message, and the receiving session typically has no context to respond meaningfully (it might be a background build agent or a subagent that doesn't even surface channel messages).

The only workaround today is manually killing competing bun processes after every session spawn, which breaks again on the next session start. Setting enabledPlugins to false disables the plugin entirely (including for sessions that explicitly request it via --channels), so that's not a solution either.

Proposed Fix

Add a PID lock file (~/.claude/channels/telegram/poll.lock) so only one bun process calls getUpdates. All other instances skip bot.start() but still serve MCP tools (reply/react/edit are direct Bot API calls, not dependent on long-polling).

Implementation (complete, tested)

1. Add imports (line 22):

-import { readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, statSync, renameSync, realpathSync, chmodSync } from 'fs'
+import { readFileSync, writeFileSync, mkdirSync, readdirSync, rmSync, statSync, renameSync, realpathSync, chmodSync, existsSync, unlinkSync } from 'fs'

2. Add lock functions (after INBOX_DIR constant):

const POLL_LOCK = join(STATE_DIR, 'poll.lock')

// Single-instance polling lock. Only one bun process should call getUpdates
// at a time. Others still serve MCP tools (reply/react/edit) but skip polling.
// Without this, every Claude Code session that loads the plugin competes for
// the getUpdates slot, causing message delivery to bounce between sessions.
function acquirePollLock(): boolean {
  try {
    mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
    if (existsSync(POLL_LOCK)) {
      const pid = parseInt(readFileSync(POLL_LOCK, 'utf8').trim(), 10)
      if (pid && !isNaN(pid)) {
        try {
          process.kill(pid, 0) // signal 0 = alive check
          return false         // another live process holds the lock
        } catch {
          // Stale lock from dead process — take it
        }
      }
    }
    writeFileSync(POLL_LOCK, String(process.pid) + '\n', { mode: 0o600 })
    return true
  } catch {
    return false
  }
}

function releasePollLock(): void {
  try {
    const content = readFileSync(POLL_LOCK, 'utf8').trim()
    if (parseInt(content, 10) === process.pid) unlinkSync(POLL_LOCK)
  } catch {}
}

3. Release lock on shutdown (in shutdown() function):

 function shutdown(): void {
   if (shuttingDown) return
   shuttingDown = true
   process.stderr.write('telegram channel: shutting down\n')
+  releasePollLock()
   setTimeout(() => process.exit(0), 2000)
   void Promise.resolve(bot.stop()).finally(() => process.exit(0))
 }

4. Guard bot.start() (replace the IIFE at line ~956):

const holdsPollLock = acquirePollLock()
if (!holdsPollLock) {
  process.stderr.write(
    'telegram channel: another instance is polling — this instance serves tools only (no getUpdates)\n',
  )
} else {
  // existing bot.start() retry loop, unchanged
  void (async () => {
    for (let attempt = 1; ; attempt++) {
      // ... existing 409 retry logic ...
    }
  })()
}

Why this works

  • First process wins: Whichever bun process starts first grabs the lock and polls. All subsequent instances see the lock, verify the PID is alive, and skip polling.
  • Stale lock recovery: If the lock-holding process dies (kill -9, crash), the next instance detects the dead PID via process.kill(pid, 0) and takes over. No manual cleanup needed.
  • Tools still work everywhere: reply, react, and edit_message are direct Bot API calls (bot.api.sendMessage() etc.), not dependent on the polling loop. Non-polling instances can still send outbound messages.
  • 409 retry preserved: The lock holder still has the 409 retry logic as a fallback for zombies from previous sessions that haven't released the lock yet.
  • Zero config: No new env vars, no settings changes, no CLI flags. Works automatically.
  • Backwards compatible: Single-session users see no behavior change. The lock is acquired, polling starts, lock is released on exit. Identical to today.

Edge cases handled

| Scenario | Behavior |
|---|---|
| Single session | Lock acquired, polls normally, released on exit |
| Multiple sessions | First polls, others serve tools only |
| Lock holder crashes (SIGKILL) | Next instance detects dead PID, takes lock |
| Lock file manually deleted | Next instance creates it, no error |
| Race condition (two start simultaneously) | One wins the write, other sees lock. 409 retry catches the edge case if both write. |
| Lock holder shuts down cleanly | releasePollLock() removes file, next instance starts clean |

Alternative Approaches Considered

| Approach | Why not |
|---|---|
| Set enabledPlugins: false | Disables plugin entirely, even for --channels sessions |
| Per-session plugin config | Doesn't exist in Claude Code's settings model |
| Env var (TELEGRAM_NO_POLL=1) in puppet boot scripts | Requires modifying every consumer. Breaks for new session types. |
| Webhook mode instead of long-polling | Requires a publicly reachable URL, HTTPS cert, port forwarding. Overkill for a local CLI tool. |
| flock() / advisory file lock | Bun doesn't expose flock(). PID check is sufficient and portable. |

Full diff

The complete fix is available as a PR (auto-closed by your external contribution policy): https://github.com/anthropics/claude-plugins-official/pull/1070

The diff is 82 insertions, 39 deletions (the deletion count is from re-indenting the existing bot.start() block inside the new if/else). Net new logic is ~30 lines.

View original on GitHub ↗

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