The startup takeover guard SIGTERMed any live bot.pid holder, so starting a
second Claude Code session stole the Telegram polling slot from the session
already using it — and when that second session exited, no poller remained
at all and the bot went permanently silent (anthropics/claude-code#81571).
Replace the kill-on-startup pattern with cooperative slot ownership:
- slot free (no pid file, holder dead, or pid recycled to an unrelated
process — identity-checked via /proc cmdline or ps args) → claim and poll
- live healthy holder → standby: outbound tools stay fully usable, and a
watcher claims the slot the moment the holder exits, so the channel hands
off to the last session standing instead of dying
- nothing is ever killed; genuinely orphaned pollers are already reaped by
the stdin orphan watchdog, and the pid file is removed by its owner on
shutdown
Adds a regression test (bun test external_plugins/telegram/test) that spawns
two real servers against an isolated state dir: the second must not kill the
first, the slot must hand off when the holder exits, and the pid file must
be cleaned up by the last server out. Fails on 0.0.7, passes on 0.0.8.
Fixesanthropics/claude-code#81571
No-Verification-Needed: fix is in the plugins repo; verified by its own regression test plus a two-session CLI e2e
* fix(telegram): honor TELEGRAM_STATE_DIR/CLAUDE_CONFIG_DIR in skills and server
The server already reads TELEGRAM_STATE_DIR for multi-bot setups, but the
/telegram:access and /telegram:configure skills hardcoded
~/.claude/channels/telegram/ in 11 places. So with a custom state dir the
skill writes access.json to the default location while the server reads
from the override — pairing and allowlist edits silently don't take effect.
Skills now resolve the state dir via shell expansion (TELEGRAM_STATE_DIR →
CLAUDE_CONFIG_DIR/channels/telegram → ~/.claude/channels/telegram) before
any read/write. Server gets the same CLAUDE_CONFIG_DIR fallback. Also adds
Bash(echo)/Bash(chmod) to configure skill's allowed-tools (chmod was already
documented but not allowlisted).
* fix(telegram): verify stale PID is a server.ts process before SIGTERM
PID files race with OS PID recycling. The lockfile from #1349 stored only a
PID; after enough churn that PID can be reassigned to anything — including
the new launch's own bun-run wrapper. SIGTERMing the wrapper closes our
stdin and triggers immediate self-shutdown ('replacing stale poller' then
'shutting down' within seconds — matches #1459 item 3).
Now check 'ps -p <pid> -o args=' contains 'server.ts' before killing.
execFileSync (no shell); whole block already try/catch so Windows/ps-missing
falls through to just overwriting the lockfile.
* fix(telegram): drop ppid watchdog check; redirect bun install stdout to stderr
Two v0.0.5/0.0.6 regressions causing the plugin to fail at startup:
1. The orphan watchdog's process.ppid !== bootPpid check false-fires when the
bun-run/shell wrapper exits or execs during normal startup and we get
reparented to init — plugin self-terminates ~5s after launch. Stdin-close
alone is the correct signal: the kernel closes the MCP pipe on any CLI
death regardless of intermediate wrappers, so the ppid check was both
unnecessary and harmful. (#1467; also the actual cause of #1459 item 3
and likely #1425.)
2. 'bun install --no-summary' in the start script writes to stdout, which is
the MCP JSON-RPC transport. The harness sees non-JSON bytes during the
handshake and drops the connection ('Failed to connect'). Redirect install
output to stderr. (#1470; also explains #1425 on Windows.)
---------
Co-authored-by: Claude <noreply@anthropic.com>
Co-authored-by: Bryan Thompson <238056179+bryan-anthropic@users.noreply.github.com>
Claude Code's /plugin install now registers the official marketplace
automatically and activates the plugin in the same session, so the
/reload-plugins step these READMEs document is no longer needed.
discord and telegram keep a conditional note because their MCP servers
can't always be activated mid-session.
The bot command handlers bypassed access control — they responded to
any DM user regardless of dmPolicy, leaking bot presence and
contradicting ACCESS.md's "Drop silently. No reply." contract for
allowlist mode.
Add dmCommandGate() that applies the same disabled/allowlist checks
as gate() without the pairing side effects, and route all three
handlers through it. Also prune expired pending codes before /status
iterates them.
Fixes#854
Co-authored-by: Claude <noreply@anthropic.com>
A single ETIMEDOUT/ECONNRESET/DNS failure during long-polling rejected
bot.start(); the catch block returned and polling stopped permanently.
The MCP server process stayed alive (stdin keeps it running), so outbound
reply/react tools kept working — but the bot was deaf to inbound messages
until a full restart. Users see 'typing...' then nothing, indistinguishable
from the harness-side gate bug.
Now all errors retry with the same capped backoff (max 15s). attempt resets
to 0 in onStart so backoff doesn't accumulate across a long-running session.
Co-authored-by: Claude <noreply@anthropic.com>
* fix(telegram): prevent zombie pollers from blocking new sessions
The MCP server runs as a grandchild of the CLI (via `bun run start` →
shell → `bun server.ts`). When the CLI is killed uncleanly (SIGKILL,
crash, terminal close), the grandchild survives as an orphan and keeps
long-polling getUpdates indefinitely. Telegram allows only one consumer
per token, so every subsequent session sees 409 Conflict and the
existing retry loop spins forever.
Three layered mitigations:
- PID lockfile (STATE_DIR/bot.pid): on startup, SIGTERM any stale holder
before claiming the slot, so a fresh session always wins.
- Orphan watchdog: every 5s check for parent reparenting (POSIX ppid
change) or a dead stdin pipe, and self-terminate. Covers cases where
the existing stdin end/close events never fire through the wrapper.
- 409 retry cap: give up after 8 attempts (~28s) instead of looping
forever, and bail immediately if shutdown has begun.
Also adds a SIGHUP handler and removes the pidfile on clean shutdown
(only if still owned by this process).
* chore(telegram): bump version to 0.0.5
---------
Co-authored-by: Claude <noreply@anthropic.com>
* feat(telegram,discord): compact permission messages with expandable details
Replace verbose permission request messages with a compact format showing
only the tool name. Adds a "See more" button that expands inline to show
tool_name, description, and pretty-printed input_preview JSON. Yes/No
buttons replace Allow/Deny. Bump plugin versions to 0.0.4.
* revert: restore Allow/Deny button labels
Replace "Reply 'yes abcde' to allow" text instruction with native
inline buttons (Telegram InlineKeyboard, Discord ButtonBuilder).
One tap to approve/deny instead of typing a 5-char ID.
- Telegram: callback_query handler with allowFrom gate, edits message
to show outcome and remove buttons after decision
- Discord: interactionCreate handler with allowFrom gate, updates
interaction with outcome and clears components
- Text-reply path (PERMISSION_REPLY_RE) kept as fallback
- Bump both plugins to v0.0.3
🏠 Remote-Dev: homespace
Complete the plugin side of anthropics/claude-cli-internal#23061 (permission
prompts over channels).
Capability: both servers now declare
experimental["claude/channel/permission"]
which tells CC they can relay permission requests. This capability asserts the
server authenticates the replier — gate()/access.allowFrom filters
non-allowlisted senders before handleInbound runs.
Outbound (CC → user): setNotificationHandler for
notifications/claude/channel/permission_request
formats the tool name, description, and input preview into a human-readable
message and sends it to every allowlisted DM. Groups are excluded — the
security thread resolution was "single-user mode for official plugins."
Inbound (user → CC): PERMISSION_REPLY_RE intercept in handleInbound catches
"yes xxxxx" / "no xxxxx" replies, emits the structured
notifications/claude/channel/permission
event with {request_id, behavior}, reacts with checkmark/cross, and returns
without relaying the text to Claude as a chat message.
The regex is inlined from channelPermissions.ts (no cross-repo dep). IDs are
lowercased at the plugin boundary per the case-insensitive spec.
Version bumped 0.0.1 → 0.0.2 so the plugin reconciler picks up the change.
🏠 Remote-Dev: homespace
Partially reverts #758. The reload step is not redundant: the configure
skill runs before the restart step, so it is not loaded yet when the user
types /telegram:configure. CLI prints 'Run /reload-plugins to activate.'
after install (pluginInstallationHelpers.ts:529). Mintlify reports
confirm users hit 'Unknown skill: discord:configure' at step 3.
- safeName() strips <>[]\r\n; from file_name/title before they hit the
<channel> notification — delimiter chars would let an uploader break
out of the tag or forge meta entries
- download_attachment strips ext/uniqueId to alphanumeric before join()
— defense-in-depth against path traversal (file_unique_id is
Telegram-controlled so this is belt-and-braces)
- /status in a group would leak the sender's pending pairing code to
other group members, who could then pair as that user
- Commands in non-allowlisted groups confirm bot presence and enable spam
- /start now acknowledges dmPolicy === 'disabled' instead of lying
- setMyCommands scoped to private chats so the / menu only shows in DMs
Message edits don't trigger push notifications on the user's device.
Update system instructions and edit_message tool description to steer
the assistant toward edit-for-progress + new-reply-on-completion.
Fixes#786
Hardcoded ~/.claude/channels/<name>/ meant only one bot per machine.
Respect TELEGRAM_STATE_DIR / DISCORD_STATE_DIR so users can run
multiple bots with separate tokens and allowlists.
Also fixed README path ('in your project' -> '~/...') to match the code.
Fixes#792
During /mcp reload or when a zombie from a previous session still holds
the polling slot, the new process gets 409 Conflict on its first
getUpdates and dies immediately. Retry with backoff until the slot
frees — typically within a second or two.
Also handles the two-sessions case: the second Claude Code instance
keeps retrying (with a clear message about what's happening) and takes
over when the first one exits.
Fixes#804#794, partial #788 (issue 4)
When the MCP stdio transport closes, the bot kept polling Telegram as
a zombie process — holding the token and causing 409 Conflict for the
next session.
- Listen for stdin end/close and SIGTERM/SIGINT -> bot.stop() + exit
- Force-exit after 2s if bot.stop() stalls on the long-poll timeout
- unref the approval-check interval so it doesn't keep us alive
Fixes#793, partial #788 (issue 3)
The bot would silently stop delivering messages after the first error:
grammy's default handler calls bot.stop() on any middleware throw, and
void bot.start() / void mcp.notification() swallow rejections with no log.
- bot.catch(): log and keep polling on handler errors
- bot.start().catch(): log when polling dies (bad token, 409, network)
- mcp.notification().catch(): log when inbound delivery to Claude fails
- process-level unhandledRejection/uncaughtException as a safety net
Fixes#756#759#761#777#809, partial #788
The bot token is a credential. Tighten perms on load so hand-written
or pre-existing .env files get locked down, and update the configure
skill to chmod after writing. No-op on Windows.
- Drop /reload-plugins (redundant, you restart with --channels next)
- Fix token save path: .claude/channels/ not ~/.claude/channels/
- Clarify bot only responds once channel is running (pairing step)
Both MCP servers run on Bun, but this wasn't documented. Add a
Prerequisites section with the install command so users don't hit
a missing-runtime error on first setup.
Remove the three chat bridge plugins from external_plugins/ and their
corresponding entries in marketplace.json.
Co-authored-by: Claude <noreply@anthropic.com>
Telegram messaging bridge for Claude Code. Runs a local MCP server that
connects to the Telegram Bot API via a user-created bot token.
Built-in access control: inbound messages are gated by an allowlist
(default: pairing mode), outbound sends are scoped to the same allowlist.
The /telegram:access skill manages pairing, allowlists, and policy.
Ships full source — server.ts runs locally via bun, started by the
.mcp.json command. First external_plugins entry to bundle source rather
than point at a hosted MCP endpoint.