You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
[BUG] Telegram channel plugin: starting a second session kills the running poller, leaving the channel permanently dead #81571
I have searched existing issues and this hasn't been reported yet
This is a single bug report (please file separate reports for different bugs)
I am using the latest version of Claude Code
What's Wrong?
telegram@claude-plugins-official (v0.0.6) enforces a single Telegram long-poller via ~/.claude/channels/telegram/bot.pid. The startup takeover guard SIGTERMs any live holder, so a
newly started Claude Code session steals the channel from a session already using it. If that new
session is short-lived, it takes the poller down with it on exit and no poller remains — inbound
messages queue at Telegram forever with nothing draining them.
The failure is completely silent: no error surfaces in either session, and restarting does not help
while the thief is alive. The user-visible symptom is "my bot stopped responding".
Because the thief deletes bot.pid during its own shutdown, the diagnosable end state is no poller
process and no bot.pid at all, with getWebhookInfo showing pending_update_count climbing and
no consumer.
Root cause
server.ts v0.0.6, lines 56–69:
// Telegram allows exactly one getUpdates consumer per token. If a previous// session crashed (SIGKILL, terminal closed) its server.ts grandchild can// survive as an orphan and hold the slot forever, so every new session sees// 409 Conflict. Kill any stale holder before we start polling.mkdirSync(STATE_DIR,{recursive: true,mode: 0o700})try{conststale=parseInt(readFileSync(PID_FILE,'utf8'),10)if(stale>1&&stale!==process.pid){process.kill(stale,0)process.stderr.write(`telegram channel: replacing stale poller pid=${stale}\n`)process.kill(stale,'SIGTERM')}}catch{}writeFileSync(PID_FILE,String(process.pid))
process.kill(pid, 0) throws ESRCH only when the process is dead. The branches are therefore
inverted relative to the intent stated in the comment directly above them:
holder dead → probe throws → catch → left alone (nothing to do, fine)
holder alive and healthy → probe succeeds → SIGTERM ← the bug
The comment says the guard exists to reap an orphaned server.ts left by a crashed session. But pid
liveness alone cannot distinguish an orphan from a healthy incumbent serving another session — both
are simply "a live pid" — so the guard evicts every incumbent.
The file already contains an orphan watchdog (setInterval, ~line 671) that self-terminates a
reparented or stdin-dead server within ~5 s. That already covers the crashed-session scenario the
startup kill claims to handle, which makes the unconditional kill largely redundant.
What Should Happen?
A second Claude Code session starting up should not terminate a healthy poller that another
session is actively using. It should detect that the channel is already served and defer — leaving
the incumbent running and exiting quietly — while still being able to reclaim the slot from a poller
that is genuinely dead or wedged.
Concretely: after starting session B alongside a working session A, session A's poller should still
be alive and still delivering Telegram messages, and closing B should leave A's channel untouched.
Error Messages/Logs
# Session B's stderr as it steals the channel from healthy session A (pid 14329):
telegram channel: replacing stale poller pid=14329
telegram channel: shutting down
# Resulting state — B has exited, A was killed, nothing is polling:
$ pgrep -f "bun .../telegram/0.0.6/server.ts"| wc -l
0
$ cat ~/.claude/channels/telegram/bot.pid
cat: bot.pid: No such file or directory # deleted during B's shutdown
$ curl -s "https://api.telegram.org/bot$TOKEN/getWebhookInfo"
{"ok":true,"result":{"url":"","has_custom_certificate":false,"pending_update_count":1}}
# ^ messages queued at Telegram with no consumer; nothing in either session reports an error
Steps to Reproduce
Deterministic, and needs no real bot token — the pid logic runs before any network call, so a dummy
token is enough (the resulting 401 Unauthorized polling errors are expected and irrelevant). The
server honours TELEGRAM_STATE_DIR, so this does not touch a real channel.
A=14329
telegram channel: replacing stale poller pid=14329
telegram channel: shutting down
A KILLED <-- bug
0
cat: bot.pid: No such file or directory
Real-world equivalent (no script): configure the Telegram channel, confirm the bot replies, then
start a second Claude Code session with the plugin enabled and close it again. The bot goes silent
permanently. Observed independently on three separate machines; on one, the stealing session logged telegram channel: replacing stale poller pid=12233, then took SIGINT and exited ~2 s later,
leaving no poller behind.
Claude Model
None
Is this a regression?
I don't know
Last Working Version
No response
Claude Code Version
2.1.220
Platform
Anthropic API
Operating System
macOS
Terminal/Shell
Terminal.app (macOS)
Additional Information
Suggested fix
Require the holder to look wedged, not merely alive. The incumbent heartbeats; a starting server
defers unless the heartbeat is stale.
constHEARTBEAT_MS=5_000constHEARTBEAT_STALE_MS=20_000mkdirSync(STATE_DIR,{recursive: true,mode: 0o700})try{constholder=parseInt(readFileSync(PID_FILE,'utf8'),10)if(holder>1&&holder!==process.pid){letalive=truetry{process.kill(holder,0)}catch{alive=false}if(alive){constageMs=Date.now()-statSync(PID_FILE).mtimeMsif(ageMs<HEARTBEAT_STALE_MS){process.stderr.write(`telegram channel: another session already holds the channel `+`(pid=${holder}, heartbeat ${Math.round(ageMs/1000)}s ago) — deferring to it.\n`)process.exit(0)}process.stderr.write(`telegram channel: taking over wedged poller pid=${holder}\n`)process.kill(holder,'SIGTERM')}}}catch{}writeFileSync(PID_FILE,String(process.pid))// heartbeat — rewriting the pid file refreshes its mtime, which is what lets a// later server tell "healthy incumbent" from "wedged orphan"setInterval(()=>{try{if(!shuttingDown)writeFileSync(PID_FILE,String(process.pid))}catch{}},HEARTBEAT_MS).unref()
Notes:
statSync is already imported; using writeFileSync for the heartbeat avoids adding utimesSync.
shuttingDown is declared later with let; the interval callback only runs after module
evaluation, and the guard stops the heartbeat resurrecting the pid file shutdown() deletes.
unref() so the timer never by itself keeps the process alive.
Orphan recovery is preserved: an orphan stops heartbeating and is reclaimable after 20 s, and the
existing ppid/stdin watchdog still reaps it within ~5 s.
Verification
Patched locally and tested in an isolated TELEGRAM_STATE_DIR. bun build server.ts --target=bun
compiles clean (243 modules).
Scenario
Expected
Result
Second server starts, incumbent heartbeat 4 s old
defer, incumbent survives
pass — deferring to it (pid=14088, heartbeat 4s ago), exit 0, incumbent alive, pid file unchanged
Incumbent SIGSTOPped, pid-file mtime backdated past the 20 s threshold
take over
pass — taking over wedged poller pid=14088 (no heartbeat for 129s), pid file reclaimed to new pid
Affects anyone running more than one Claude Code session concurrently, which is routine for
multi-agent and worker-session setups. Because the plugin lives in a version-pinned cache directory,
a local patch is wiped by the next plugin upgrade, so there is no durable user-side workaround.
Preflight Checklist
What's Wrong?
telegram@claude-plugins-official(v0.0.6) enforces a single Telegram long-poller via~/.claude/channels/telegram/bot.pid. The startup takeover guard SIGTERMs any live holder, so anewly started Claude Code session steals the channel from a session already using it. If that new
session is short-lived, it takes the poller down with it on exit and no poller remains — inbound
messages queue at Telegram forever with nothing draining them.
The failure is completely silent: no error surfaces in either session, and restarting does not help
while the thief is alive. The user-visible symptom is "my bot stopped responding".
Because the thief deletes
bot.pidduring its own shutdown, the diagnosable end state is no pollerprocess and no
bot.pidat all, withgetWebhookInfoshowingpending_update_countclimbing andno consumer.
Root cause
server.tsv0.0.6, lines 56–69:process.kill(pid, 0)throwsESRCHonly when the process is dead. The branches are thereforeinverted relative to the intent stated in the comment directly above them:
catch→ left alone (nothing to do, fine)SIGTERM← the bugThe comment says the guard exists to reap an orphaned
server.tsleft by a crashed session. But pidliveness alone cannot distinguish an orphan from a healthy incumbent serving another session — both
are simply "a live pid" — so the guard evicts every incumbent.
The file already contains an orphan watchdog (
setInterval, ~line 671) that self-terminates areparented or stdin-dead server within ~5 s. That already covers the crashed-session scenario the
startup kill claims to handle, which makes the unconditional kill largely redundant.
What Should Happen?
A second Claude Code session starting up should not terminate a healthy poller that another
session is actively using. It should detect that the channel is already served and defer — leaving
the incumbent running and exiting quietly — while still being able to reclaim the slot from a poller
that is genuinely dead or wedged.
Concretely: after starting session B alongside a working session A, session A's poller should still
be alive and still delivering Telegram messages, and closing B should leave A's channel untouched.
Error Messages/Logs
Steps to Reproduce
Deterministic, and needs no real bot token — the pid logic runs before any network call, so a dummy
token is enough (the resulting
401 Unauthorizedpolling errors are expected and irrelevant). Theserver honours
TELEGRAM_STATE_DIR, so this does not touch a real channel.Actual output on v0.0.6:
Real-world equivalent (no script): configure the Telegram channel, confirm the bot replies, then
start a second Claude Code session with the plugin enabled and close it again. The bot goes silent
permanently. Observed independently on three separate machines; on one, the stealing session logged
telegram channel: replacing stale poller pid=12233, then took SIGINT and exited ~2 s later,leaving no poller behind.
Claude Model
None
Is this a regression?
I don't know
Last Working Version
No response
Claude Code Version
2.1.220
Platform
Anthropic API
Operating System
macOS
Terminal/Shell
Terminal.app (macOS)
Additional Information
Suggested fix
Require the holder to look wedged, not merely alive. The incumbent heartbeats; a starting server
defers unless the heartbeat is stale.
Notes:
statSyncis already imported; usingwriteFileSyncfor the heartbeat avoids addingutimesSync.shuttingDownis declared later withlet; the interval callback only runs after moduleevaluation, and the guard stops the heartbeat resurrecting the pid file
shutdown()deletes.unref()so the timer never by itself keeps the process alive.existing ppid/stdin watchdog still reaps it within ~5 s.
Verification
Patched locally and tested in an isolated
TELEGRAM_STATE_DIR.bun build server.ts --target=buncompiles clean (243 modules).
deferring to it (pid=14088, heartbeat 4s ago), exit 0, incumbent alive, pid file unchangedSIGSTOPped, pid-file mtime backdated past the 20 s thresholdtaking over wedged poller pid=14088 (no heartbeat for 129s), pid file reclaimed to new pidNot duplicates of
GitHub flagged potential duplicates; checked each:
bun server.tsper session — SIGTERM, bot.pid eviction, and orphan watchdog are all defeated by a wedged event loop #81107 (open) — leaks one 100%-CPUbun server.tsper session. Cites the same block(
server.ts:60-68) but the opposite direction: SIGTERM is a no-op against an incumbent whoseevent loop is wedged. Distinct, and this fix does not resolve it — a wedged process ignores
SIGTERM either way. Same nine lines though; probably best fixed together.
--continue. Harness-level sessionteardown; no
bot.pid, no sibling plugin process, different signal.processes lose their pid record and spin on 409. Complains the takeover misses processes, not
that it kills healthy ones.
Impact
Affects anyone running more than one Claude Code session concurrently, which is routine for
multi-agent and worker-session setups. Because the plugin lives in a version-pinned cache directory,
a local patch is wiped by the next plugin upgrade, so there is no durable user-side workaround.