diff --git a/external_plugins/discord/server.ts b/external_plugins/discord/server.ts index 078c29a..81faeba 100644 --- a/external_plugins/discord/server.ts +++ b/external_plugins/discord/server.ts @@ -58,6 +58,15 @@ if (!TOKEN) { } const INBOX_DIR = join(STATE_DIR, 'inbox') +// Last-resort safety net — without these the process dies silently on any +// unhandled promise rejection. With them it logs and keeps serving tools. +process.on('unhandledRejection', err => { + process.stderr.write(`discord channel: unhandled rejection: ${err}\n`) +}) +process.on('uncaughtException', err => { + process.stderr.write(`discord channel: uncaught exception: ${err}\n`) +}) + const client = new Client({ intents: [ GatewayIntentBits.DirectMessages, @@ -342,7 +351,7 @@ function checkApprovals(): void { } } -if (!STATIC) setInterval(checkApprovals, 5000) +if (!STATIC) setInterval(checkApprovals, 5000).unref() // Discord caps messages at 2000 chars (hard limit — larger sends reject). // Split long replies, preferring paragraph boundaries when chunkMode is @@ -637,6 +646,25 @@ mcp.setRequestHandler(CallToolRequestSchema, async req => { await mcp.connect(new StdioServerTransport()) +// When Claude Code closes the MCP connection, stdin gets EOF. Without this +// the gateway stays connected as a zombie holding resources. +let shuttingDown = false +function shutdown(): void { + if (shuttingDown) return + shuttingDown = true + process.stderr.write('discord channel: shutting down\n') + setTimeout(() => process.exit(0), 2000) + void Promise.resolve(client.destroy()).finally(() => process.exit(0)) +} +process.stdin.on('end', shutdown) +process.stdin.on('close', shutdown) +process.on('SIGTERM', shutdown) +process.on('SIGINT', shutdown) + +client.on('error', err => { + process.stderr.write(`discord channel: client error: ${err}\n`) +}) + client.on('messageCreate', msg => { if (msg.author.bot) return handleInbound(msg).catch(e => process.stderr.write(`discord: handleInbound failed: ${e}\n`)) @@ -685,7 +713,7 @@ async function handleInbound(msg: Message): Promise { // forgeable by any allowlisted sender typing that string. const content = msg.content || (atts.length > 0 ? '(attachment)' : '') - void mcp.notification({ + mcp.notification({ method: 'notifications/claude/channel', params: { content, @@ -698,6 +726,8 @@ async function handleInbound(msg: Message): Promise { ...(atts.length > 0 ? { attachment_count: String(atts.length), attachments: atts.join('; ') } : {}), }, }, + }).catch(err => { + process.stderr.write(`discord channel: failed to deliver inbound to Claude: ${err}\n`) }) } @@ -705,4 +735,7 @@ client.once('ready', c => { process.stderr.write(`discord channel: gateway connected as ${c.user.tag}\n`) }) -await client.login(TOKEN) +client.login(TOKEN).catch(err => { + process.stderr.write(`discord channel: login failed: ${err}\n`) + process.exit(1) +})