diff --git a/.env.example b/.env.example index 8a3ea78..9e77052 100644 --- a/.env.example +++ b/.env.example @@ -7,4 +7,8 @@ ALLOWED_USER_ID=your_discord_user_id # Base folder path where Claude Code will operate # Channel names will be appended to this path -BASE_FOLDER=/Users/your-user-name/repos \ No newline at end of file +BASE_FOLDER=/Users/your-user-name/repos + +# MCP Server Port (optional, defaults to 3001) +# Change this if port 3001 is already in use +MCP_SERVER_PORT=3001 \ No newline at end of file diff --git a/.gitignore b/.gitignore index d7d3341..6be4fad 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,7 @@ coverage logs _.log report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json +log.txt # dotenv environment variable files .env diff --git a/README.md b/README.md index ad1f9e4..3fc187f 100644 --- a/README.md +++ b/README.md @@ -165,6 +165,29 @@ Bot: 🔧 LS (path: .) - Shows real-time tool usage and responses - Only responds to the configured `ALLOWED_USER_ID` +## Troubleshooting + +### Claude Code Hooks + +If you have Claude Code hooks configured (e.g., audio notifications in `~/.claude/settings.json`), they may interfere with the Discord bot. The bot automatically sets `CLAUDE_DISABLE_HOOKS=1` when spawning Claude processes. + +To make your hooks respect this, add a check at the beginning of your hook scripts: + +```bash +#!/bin/bash +# Example: ~/.claude/claude-sound-notification.sh + +# Skip hook if running from Discord bot +if [ "$CLAUDE_DISABLE_HOOKS" = "1" ]; then + exit 0 +fi + +# Your existing hook code (e.g., play sound) +mplayer -really-quiet /path/to/sound.mp3 +``` + +This ensures hooks run normally in your terminal but are skipped when using the Discord bot. + For detailed setup instructions, troubleshooting, and development information, see [CONTRIBUTING.md](CONTRIBUTING.md). ## License diff --git a/mcp-bridge.cjs b/mcp-bridge.cjs index c307551..26ffe2e 100755 --- a/mcp-bridge.cjs +++ b/mcp-bridge.cjs @@ -7,7 +7,8 @@ const { Transform } = require('stream'); // Debug: Log environment variables at startup console.error(`MCP Bridge startup: DISCORD_CHANNEL_ID=${process.env.DISCORD_CHANNEL_ID}, DISCORD_CHANNEL_NAME=${process.env.DISCORD_CHANNEL_NAME}, DISCORD_USER_ID=${process.env.DISCORD_USER_ID}`); -const MCP_SERVER_URL = 'http://localhost:3001/mcp'; +const MCP_SERVER_PORT = process.env.MCP_SERVER_PORT || '3001'; +const MCP_SERVER_URL = `http://localhost:${MCP_SERVER_PORT}/mcp`; // Transform stream to handle MCP messages const mcpTransform = new Transform({ @@ -48,7 +49,7 @@ const mcpTransform = new Transform({ const options = { hostname: 'localhost', - port: 3001, + port: parseInt(MCP_SERVER_PORT), path: '/mcp', method: 'POST', headers diff --git a/src/claude/manager.ts b/src/claude/manager.ts index c9c677e..fb558d1 100644 --- a/src/claude/manager.ts +++ b/src/claude/manager.ts @@ -6,6 +6,16 @@ import type { SDKMessage } from "../types/index.js"; import { buildClaudeCommand, type DiscordContext } from "../utils/shell.js"; import { DatabaseManager } from "../db/database.js"; +/** + * Truncate text to fit Discord embed description limit (4096 characters) + */ +function truncateForEmbed(text: string, maxLength: number = 4096): string { + if (text.length <= maxLength) { + return text; + } + return text.substring(0, maxLength - 50) + '\n...\n[Message truncated]'; +} + export class ClaudeManager { private db: DatabaseManager; private channelMessages = new Map(); @@ -103,6 +113,7 @@ export class ClaudeManager { env: { ...process.env, SHELL: "/bin/bash", + CLAUDE_DISABLE_HOOKS: "1", // Disable audio/hooks for Discord bot usage }, }); @@ -174,8 +185,6 @@ export class ClaudeManager { } else if (parsed.type === "result") { this.handleResultMessage(channelId, parsed).then(() => { clearTimeout(timeout); - claude.kill("SIGTERM"); - this.channelProcesses.delete(channelId); }).catch(console.error); } else if (parsed.type === "system") { console.log("System message:", parsed.subtype); @@ -195,10 +204,13 @@ export class ClaudeManager { claude.on("close", (code) => { console.log(`Claude process exited with code ${code}`); clearTimeout(timeout); + // Ensure cleanup on process close this.channelProcesses.delete(channelId); - if (code !== 0 && code !== null) { + // Only show error for actual failure codes (not 0, not null, not 143) + // 143 = SIGTERM which can be normal shutdown + if (code !== 0 && code !== null && code !== 143) { // Process failed - send error embed to Discord const channel = this.channelMessages.get(channelId)?.channel; if (channel) { @@ -206,7 +218,7 @@ export class ClaudeManager { .setTitle("❌ Claude Code Failed") .setDescription(`Process exited with code: ${code}`) .setColor(0xFF0000); // Red for error - + channel.send({ embeds: [errorEmbed] }).catch(console.error); } } @@ -226,9 +238,9 @@ export class ClaudeManager { if (channel) { const warningEmbed = new EmbedBuilder() .setTitle("⚠️ Warning") - .setDescription(stderrOutput.trim()) + .setDescription(truncateForEmbed(stderrOutput.trim())) .setColor(0xFFA500); // Orange for warnings - + channel.send({ embeds: [warningEmbed] }).catch(console.error); } } @@ -246,7 +258,7 @@ export class ClaudeManager { if (channel) { const processErrorEmbed = new EmbedBuilder() .setTitle("❌ Process Error") - .setDescription(error.message) + .setDescription(truncateForEmbed(error.message)) .setColor(0xFF0000); // Red for errors channel.send({ embeds: [processErrorEmbed] }).catch(console.error); diff --git a/src/utils/shell.ts b/src/utils/shell.ts index 5eee2d0..caf7399 100644 --- a/src/utils/shell.ts +++ b/src/utils/shell.ts @@ -32,7 +32,9 @@ export function buildClaudeCommand( "--output-format", "stream-json", "--model", - "sonnet", + "claude-sonnet-4-5-20250929", // Latest Sonnet 4.5 + "--permission-mode", + "acceptEdits", // Automatically accept edits without prompting "-p", escapedPrompt, "--verbose", @@ -41,7 +43,7 @@ export function buildClaudeCommand( // Add session-specific MCP configuration commandParts.push("--mcp-config", sessionMcpConfigPath); commandParts.push("--permission-prompt-tool", "mcp__discord-permissions__approve_tool"); - + // Add allowed tools - we'll let the MCP server handle permissions commandParts.push("--allowedTools", "mcp__discord-permissions"); @@ -70,8 +72,9 @@ function createSessionMcpConfig(discordContext?: DiscordContext): string { command: "node", args: [bridgeScriptPath], env: { + MCP_SERVER_PORT: process.env.MCP_SERVER_PORT || "3001", DISCORD_CHANNEL_ID: discordContext?.channelId || "unknown", - DISCORD_CHANNEL_NAME: discordContext?.channelName || "unknown", + DISCORD_CHANNEL_NAME: discordContext?.channelName || "unknown", DISCORD_USER_ID: discordContext?.userId || "unknown", DISCORD_MESSAGE_ID: discordContext?.messageId || "" } diff --git a/test/claude/manager.test.ts b/test/claude/manager.test.ts index 96769f2..4c4031c 100644 --- a/test/claude/manager.test.ts +++ b/test/claude/manager.test.ts @@ -15,6 +15,13 @@ vi.mock('../../src/db/database.js', () => ({ DatabaseManager: vi.fn() })); +vi.mock('../../src/utils/shell.js', () => ({ + buildClaudeCommand: vi.fn((workingDir: string, prompt: string) => + `cd ${workingDir} && claude -p "${prompt}"` + ), + escapeShellString: vi.fn((str: string) => `'${str}'`) +})); + describe('ClaudeManager', () => { let manager: ClaudeManager; let mockDb: any; @@ -91,15 +98,15 @@ describe('ClaudeManager', () => { }); describe('setDiscordMessage', () => { - it('should set discord message and initialize responses', () => { + it('should set discord message and initialize tool calls', () => { const mockMessage = { edit: vi.fn() }; manager.setDiscordMessage('channel-1', mockMessage); - + const channelMessages = (manager as any).channelMessages; - const channelResponses = (manager as any).channelResponses; - + const channelToolCalls = (manager as any).channelToolCalls; + expect(channelMessages.get('channel-1')).toBe(mockMessage); - expect(channelResponses.get('channel-1')).toEqual({ embeds: [], textContent: "" }); + expect(channelToolCalls.get('channel-1')).toBeInstanceOf(Map); }); }); @@ -198,4 +205,124 @@ describe('ClaudeManager', () => { expect(mockDb.close).toHaveBeenCalled(); }); }); + + describe('process exit code handling', () => { + it('should not show error for exit code 0 (success)', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + + const mockChannel = { send: vi.fn() }; + manager.setDiscordMessage('channel-1', { channel: mockChannel }); + + const mockProcess = { + pid: 12345, + stdin: { end: vi.fn() }, + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: vi.fn(), + kill: vi.fn() + }; + + const { spawn } = await import('child_process'); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + manager.reserveChannel('channel-1', undefined, {}); + await manager.runClaudeCode('channel-1', 'test-channel', 'test prompt'); + + // Simulate process close with exit code 0 + const closeHandler = mockProcess.on.mock.calls.find(call => call[0] === 'close')?.[1]; + if (closeHandler) closeHandler(0); + + // Should not send error message + expect(mockChannel.send).not.toHaveBeenCalledWith( + expect.objectContaining({ + embeds: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + title: '❌ Claude Code Failed' + }) + }) + ]) + }) + ); + }); + + it('should not show error for exit code 143 (SIGTERM)', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + + const mockChannel = { send: vi.fn() }; + manager.setDiscordMessage('channel-1', { channel: mockChannel }); + + const mockProcess = { + pid: 12345, + stdin: { end: vi.fn() }, + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: vi.fn(), + kill: vi.fn() + }; + + const { spawn } = await import('child_process'); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + manager.reserveChannel('channel-1', undefined, {}); + await manager.runClaudeCode('channel-1', 'test-channel', 'test prompt'); + + // Simulate process close with exit code 143 (SIGTERM) + const closeHandler = mockProcess.on.mock.calls.find(call => call[0] === 'close')?.[1]; + if (closeHandler) closeHandler(143); + + // Should not send error message for SIGTERM + expect(mockChannel.send).not.toHaveBeenCalledWith( + expect.objectContaining({ + embeds: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + title: '❌ Claude Code Failed' + }) + }) + ]) + }) + ); + }); + + it('should show error for actual failure codes', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + + const mockChannel = { send: vi.fn().mockResolvedValue({}) }; + manager.setDiscordMessage('channel-1', { channel: mockChannel }); + + const mockProcess = { + pid: 12345, + stdin: { end: vi.fn() }, + stdout: { on: vi.fn() }, + stderr: { on: vi.fn() }, + on: vi.fn(), + kill: vi.fn() + }; + + const { spawn } = await import('child_process'); + vi.mocked(spawn).mockReturnValue(mockProcess as any); + + manager.reserveChannel('channel-1', undefined, {}); + await manager.runClaudeCode('channel-1', 'test-channel', 'test prompt'); + + // Simulate process close with exit code 1 (error) + const closeHandler = mockProcess.on.mock.calls.find(call => call[0] === 'close')?.[1]; + if (closeHandler) closeHandler(1); + + // Should send error message for actual failures + expect(mockChannel.send).toHaveBeenCalledWith( + expect.objectContaining({ + embeds: expect.arrayContaining([ + expect.objectContaining({ + data: expect.objectContaining({ + title: '❌ Claude Code Failed', + description: 'Process exited with code: 1' + }) + }) + ]) + }) + ); + }); + }); }); \ No newline at end of file