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>
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
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.