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.
Replace the npx stdio launch of @upstash/context7-mcp with Context7's
hosted remote MCP server (https://mcp.context7.com/mcp), matching the
configuration Upstash ships in upstash/context7 (plugins/claude/context7).
Adds a README and refreshes the plugin descriptions.
Co-Authored-By: Fahreddin Özcan <88107904+fahreddinozcan@users.noreply.github.com>
Driver: dir-plugin-add-official
* Migrate Asana plugin to V2 MCP server
The V1 beta server (https://mcp.asana.com/sse) is deprecated and shuts down
on 2026-08-05. Asana's V2 server (https://mcp.asana.com/v2/mcp) requires each
user to register their own Asana OAuth app and does not support Dynamic Client
Registration. A plugin manifest cannot carry per-user OAuth credentials, so the
bundled MCP server entry is removed in favor of a documented one-time
`claude mcp add` setup (per Asana's official Claude Code instructions).
- Remove dead V1 sse .mcp.json
- Add README with V2 OAuth setup + troubleshooting
- Add /asana-setup slash command to guide the one-time setup
- Update plugin + marketplace descriptions to reference V2
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
* plugin-dev: stop using deprecated Asana V1 SSE endpoint in examples
The Asana V1 SSE server (https://mcp.asana.com/sse) is deprecated and shuts
down on 2026-08-05, and Asana V2 is streamable HTTP with manual OAuth — not
SSE with automatic (DCR) OAuth. Replace the Asana SSE examples in the
mcp-integration skill with neutral placeholders so the tutorial no longer
teaches a dead endpoint or implies Asana is a zero-config SSE/OAuth server.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: Greg Dardis <86272347+greg-dardis@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
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>
DMChannel.recipientId can be null when client.channels.fetch() returns
a DM channel with a cold cache. The inbound gate correctly uses
msg.author.id, but fetchAllowedChannel relied on recipientId, so
replies to allowlisted DMs intermittently failed with "channel not
allowlisted" after session restart.
Maintain a channelId→userId map populated during inbound handling and
fall back to it when recipientId is null.
Fixesanthropics/claude-code#40576Fixesanthropics/claude-code#41647🏠 Remote-Dev: homespace
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>
Mirrors the existing IMESSAGE_STATE_DIR override. Lets a mock sqlite
chat.db stand in for ~/Library/Messages/chat.db so chat_messages can be
tested without macOS + Full Disk Access + real iMessage history.
SMS sender IDs are spoofable; iMessage is Apple-ID-authenticated and
end-to-end encrypted. The plugin previously treated both identically,
so a forged SMS from the owner's own number would match SELF, bypass
the access gate, and inherit owner-level trust — including permission
approval.
handleInbound now drops anything with service != 'iMessage' unless
IMESSAGE_ALLOW_SMS=true. Default is the safe path; users who want SMS
can opt in after reading the warning in README.
The self-chat echo filter matches outbound text against what chat.db
stores on round-trip. Three divergence sources caused false negatives
and duplicate bubbles:
- Signature suffix: "\nSent by Claude" is appended on send, but the
\n may not round-trip identically through attributedBody
- Emoji variation selectors (U+FE00-FE0F) and ZWJ (U+200D): chat.db
can add or drop these on emoji characters
- Smart quotes: macOS auto-substitutes straight quotes on the way in
Strip/normalize all three in echoKey() before the existing whitespace
collapse.
Fixes#1024
Tapback reactions and read receipts synced from linked devices arrive
as chat.db rows with whitespace-only text. The existing empty-check
used falsy comparison which doesn't catch ' ' or invisible chars,
causing unsolicited replies to reaction taps.
Fixes#1041
Permission prompts were being broadcast to all allowlisted contacts plus
every DM resolvable from the SELF address set. Two compounding bugs:
1. SELF was polluted by chat.last_addressed_handle, which on machines
with SMS history returns short codes, business handles, and other
contacts' numbers — not just the owner's addresses. One reporter's
query returned 50 addresses (2 actually theirs) resolving to 148 DM
chats, all of which received permission prompts.
2. Even with a clean SELF, the handler sent to allowFrom + SELF, so
every allowlisted contact received the prompt and could reply to
approve tool execution on the owner's machine.
Fix:
- Build SELF from message.account WHERE is_from_me=1 only
- Send permission prompts to self-chat only, not allowFrom
- Accept permission replies from self-chat only
Fixes#1048Fixes#1010
Reformat chat_messages output from flat per-message lines to grouped
conversation threads. Each thread gets a header labelling it DM or Group
with its participant list, date-separator lines when the calendar day
rolls over, and [HH:MM] local-time stamps instead of full ISO.
chat_guid is now optional — omit to dump every allowlisted chat at once
for a quick multi-thread overview. Default limit raised 20→100 per chat,
capped at 500.
New queries: qChatParticipants (handle list per chat) and qChatInfo
(display_name + style to distinguish DM/group). renderMsg replaced by
conversationHeader + renderConversation.
The lockfile had 94 artifactory.infra.ant.dev URLs baked in from
generation behind a private registry. External users hit 401s on
'bun install' and the server never starts. Regenerated against
registry.npmjs.org to match the .npmrc.
Write/Edit previews are unbearably long over iMessage. Bash is the
dangerous one where seeing the command matters; everything else gets
tool_name + description only.
* 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
Brings the imessage channel to parity with recent telegram/discord
hardening:
- Permission-relay capability: declare claude/channel/permission,
handle inbound permission_request notifications by fanning out to
allowlisted DM chats + self-chat, intercept "yes/no <id>" replies
after the gate check and emit structured permission events instead
of relaying as chat. Groups excluded per single-user-mode policy.
- Global unhandledRejection/uncaughtException handlers so the server
logs instead of dying silently.
- IMESSAGE_STATE_DIR env override for the state directory.
- .unref() on both setInterval timers so they don't block shutdown.
- stdin EOF / SIGTERM / SIGINT shutdown handler that closes chat.db
and exits cleanly instead of leaving a zombie poll loop.
Adds zod as a direct dep (already transitively present via the MCP SDK)
for the notification handler schema.
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