Telegram plugin: multiple instances compete for getUpdates, stealing messages
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
--channelsinstance never sees those messages - Appears as if messages are lost until a keystroke triggers a retry
Reproduction
- Start Claude Code with
--channels plugin:telegram@claude-plugins-officialin one terminal - Open 1-2 more Claude Code terminals (regular sessions)
- Send a message via Telegram
- 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
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗