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).
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
Same patterns as #812/#813 for the discord channel:
- process-level unhandledRejection/uncaughtException handlers
- client.on('error') to log discord.js errors
- mcp.notification().catch() so inbound delivery failures surface
- stdin close / SIGTERM -> client.destroy() + exit (zombie fix)
- .unref() the approval-check interval
- client.login().catch() to log+exit on bad token instead of crashing
Discord is inherently more resilient than telegram (discord.js
auto-reconnects, no 409 equivalent), but these gaps were still there.
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