Telegram plugin: multiple instances compete for getUpdates, stealing messages

Resolved 💬 2 comments Opened Apr 1, 2026 by lianghualin Closed May 7, 2026

Bug

When multiple Claude Code terminals are open, each spawns its own Telegram plugin (bun child process). All instances poll getUpdates on the same bot token, causing:

  • Messages consumed by a non-channel instance (which silently drops them)
  • The dedicated --channels instance never sees those messages
  • Appears as if messages are lost until a keystroke triggers a retry

Reproduction

  1. Start Claude Code with --channels plugin:telegram@claude-plugins-official in one terminal
  2. Open 1-2 more Claude Code terminals (regular sessions)
  3. Send a message via Telegram
  4. Message may be received by a non-channel instance's plugin instead of the channel listener

Root cause

Every Claude Code instance loads all configured plugins as MCP child processes. The Telegram plugin unconditionally calls bot.start() (long-polling getUpdates). Telegram's Bot API delivers each update to one consumer — whichever polls first wins. The existing 409 retry loop makes this worse: losers keep retrying and may steal the slot when the winner reconnects.

Process tree showing 3 competing consumers:

claude (42723, --channels telegram)  ← should be the only poller
├── bun → telegram plugin (polling)
claude (68266, --resume)
├── bun → telegram plugin (polling)  ← steals messages
claude (10942)
├── bun → telegram plugin (polling)  ← steals messages

Proposed fix: poll lock file

Add a file lock (~/.claude/channels/telegram/poll.lock) so only one process polls at a time. Others run in tools-only mode (reply/react/edit still work, just no getUpdates).

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

function isProcessAlive(pid: number): boolean {
  try {
    process.kill(pid, 0)
    return true
  } catch {
    return false
  }
}

let holdsPollLock = false

function tryAcquirePollLock(): boolean {
  mkdirSync(STATE_DIR, { recursive: true, mode: 0o700 })
  try {
    const existing = readFileSync(POLL_LOCK, 'utf8').trim()
    const pid = parseInt(existing, 10)
    if (!isNaN(pid) && isProcessAlive(pid)) {
      process.stderr.write(
        `telegram channel: poll lock held by PID ${pid} — skipping polling, tools-only mode\n`,
      )
      return false
    }
    process.stderr.write(`telegram channel: removing stale poll lock (PID ${pid})\n`)
  } catch (err) {
    if ((err as NodeJS.ErrnoException).code !== 'ENOENT') {
      process.stderr.write(`telegram channel: poll lock read error: ${err}\n`)
    }
  }
  writeFileSync(POLL_LOCK, String(process.pid) + '\n', { mode: 0o600 })
  holdsPollLock = true
  return true
}

function releasePollLock(): void {
  if (!holdsPollLock) return
  try {
    const content = readFileSync(POLL_LOCK, 'utf8').trim()
    if (parseInt(content, 10) === process.pid) {
      rmSync(POLL_LOCK, { force: true })
    }
  } catch {}
  holdsPollLock = false
}

Then guard bot.start():

if (tryAcquirePollLock()) {
  // existing bot.start() retry loop
} else {
  // tools-only: just fetch botUsername
  void bot.api.getMe().then(me => { botUsername = me.username ?? '' }, () => {})
}

Release on shutdown + exit:

function shutdown(): void {
  // ...existing logic...
  releasePollLock()
  // ...
}
process.on('exit', releasePollLock)

Environment

  • macOS (Darwin 25.4.0), tmux, multiple Claude Code terminals
  • Telegram plugin v0.0.4 from claude-plugins-official

View original on GitHub ↗

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