cord-bot 1.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/.env.example ADDED
@@ -0,0 +1,15 @@
1
+ # Required
2
+ DISCORD_BOT_TOKEN=your-bot-token-here
3
+
4
+ # Optional - Redis connection
5
+ REDIS_HOST=localhost
6
+ REDIS_PORT=6379
7
+
8
+ # Optional - Claude configuration
9
+ CLAUDE_WORKING_DIR=/path/to/your/project
10
+
11
+ # Optional - Database
12
+ DB_PATH=./data/threads.db
13
+
14
+ # Optional - Timezone for datetime injection
15
+ TZ=America/New_York
@@ -0,0 +1,11 @@
1
+ {
2
+ "files.exclude": {
3
+ "**/.git": true,
4
+ "**/.svn": true,
5
+ "**/.hg": true,
6
+ "**/CVS": true,
7
+ "**/.DS_Store": true,
8
+ "**/Thumbs.db": true
9
+ },
10
+ "hide-files.files": []
11
+ }
package/README.md ADDED
@@ -0,0 +1,113 @@
1
+ # Cord
2
+
3
+ A simple bridge that connects Discord to Claude Code CLI.
4
+
5
+ > **cord** /kôrd/ — a connection between two things.
6
+
7
+ When someone @mentions your bot, it:
8
+ 1. Creates a thread for the conversation
9
+ 2. Queues the message for Claude processing
10
+ 3. Posts Claude's response back to the thread
11
+ 4. Remembers context for follow-up messages
12
+
13
+ ## Architecture
14
+
15
+ ```
16
+ Discord Bot → BullMQ Queue → Claude Spawner
17
+ (Node.js) (Redis) (Bun)
18
+ ```
19
+
20
+ - **Bot** (`src/bot.ts`): Catches @mentions, creates threads, sends to queue
21
+ - **Queue** (`src/queue.ts`): BullMQ job queue for reliable processing
22
+ - **Worker** (`src/worker.ts`): Pulls jobs, spawns Claude, posts responses
23
+ - **Spawner** (`src/spawner.ts`): The Claude CLI integration (the core)
24
+ - **DB** (`src/db.ts`): SQLite for thread→session mapping
25
+
26
+ ## Prerequisites
27
+
28
+ - [Bun](https://bun.sh) runtime
29
+ - [Redis](https://redis.io) server
30
+ - [Claude Code CLI](https://claude.ai/code) installed and authenticated
31
+ - Discord bot token (see setup below)
32
+
33
+ ## Discord Bot Setup
34
+
35
+ 1. Go to [Discord Developer Portal](https://discord.com/developers/applications)
36
+ 2. Click **New Application**, give it a name
37
+ 3. Go to **Bot** tab → Click **Add Bot**
38
+ 4. Under **Privileged Gateway Intents**, enable:
39
+ - **Message Content Intent** (required to read message text)
40
+ 5. Click **Reset Token** → Copy the token (this is your `DISCORD_BOT_TOKEN`)
41
+ 6. Go to **OAuth2** → **URL Generator**:
42
+ - Scopes: `bot`
43
+ - Bot Permissions: `Send Messages`, `Create Public Threads`, `Send Messages in Threads`, `Read Message History`
44
+ 7. Copy the generated URL → Open in browser → Invite bot to your server
45
+
46
+ **Note:** This runs 100% locally. The bot connects *outbound* to Discord's gateway - no need to expose ports or use ngrok.
47
+
48
+ ## Quick Start
49
+
50
+ ```bash
51
+ # Install dependencies
52
+ bun install
53
+
54
+ # Set environment variables
55
+ export DISCORD_BOT_TOKEN="your-bot-token"
56
+
57
+ # Start Redis (if not already running)
58
+ redis-server &
59
+
60
+ # Start the bot and worker
61
+ bun run src/bot.ts &
62
+ bun run src/worker.ts
63
+ ```
64
+
65
+ ## Environment Variables
66
+
67
+ | Variable | Required | Default | Description |
68
+ |----------|----------|---------|-------------|
69
+ | `DISCORD_BOT_TOKEN` | Yes | - | Your Discord bot token |
70
+ | `REDIS_HOST` | No | `localhost` | Redis server host |
71
+ | `REDIS_PORT` | No | `6379` | Redis server port |
72
+ | `CLAUDE_WORKING_DIR` | No | `cwd` | Working directory for Claude |
73
+ | `DB_PATH` | No | `./data/threads.db` | SQLite database path |
74
+
75
+ ## How It Works
76
+
77
+ ### New Mentions
78
+
79
+ 1. User @mentions the bot with a question
80
+ 2. Bot creates a thread from the message
81
+ 3. Bot generates a UUID session ID
82
+ 4. Bot stores thread_id → session_id in SQLite
83
+ 5. Bot queues a job with the prompt and session ID
84
+ 6. Worker picks up the job
85
+ 7. Worker spawns Claude with `--session-id UUID`
86
+ 8. Worker posts Claude's response to the thread
87
+
88
+ ### Follow-up Messages
89
+
90
+ 1. User sends another message in the thread
91
+ 2. Bot looks up the session ID from SQLite
92
+ 3. Bot queues a job with `resume: true`
93
+ 4. Worker spawns Claude with `--resume UUID`
94
+ 5. Claude has full context from previous messages
95
+
96
+ ## Key CLI Flags
97
+
98
+ The magic is in `src/spawner.ts`:
99
+
100
+ ```typescript
101
+ // For new sessions:
102
+ claude --print --session-id UUID -p "prompt"
103
+
104
+ // For follow-ups:
105
+ claude --print --resume UUID -p "prompt"
106
+
107
+ // Inject context that survives compaction:
108
+ claude --append-system-prompt "Current time: ..."
109
+ ```
110
+
111
+ ## License
112
+
113
+ MIT
package/bin/cord.ts ADDED
@@ -0,0 +1,224 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Cord CLI - Manage your Discord-Claude bridge
4
+ *
5
+ * Commands:
6
+ * cord start - Start bot and worker
7
+ * cord stop - Stop all processes
8
+ * cord status - Show running status
9
+ * cord logs - Show combined logs
10
+ * cord setup - Interactive setup wizard
11
+ */
12
+
13
+ import { spawn, spawnSync } from 'bun';
14
+ import { existsSync, readFileSync, writeFileSync } from 'fs';
15
+ import { join } from 'path';
16
+ import * as readline from 'readline';
17
+
18
+ const PID_FILE = join(process.cwd(), '.cord.pid');
19
+
20
+ const command = process.argv[2];
21
+
22
+ async function prompt(question: string): Promise<string> {
23
+ const rl = readline.createInterface({
24
+ input: process.stdin,
25
+ output: process.stdout,
26
+ });
27
+ return new Promise((resolve) => {
28
+ rl.question(question, (answer) => {
29
+ rl.close();
30
+ resolve(answer.trim());
31
+ });
32
+ });
33
+ }
34
+
35
+ async function setup() {
36
+ console.log('\n🔌 Cord Setup\n');
37
+
38
+ // Check for .env
39
+ const envPath = join(process.cwd(), '.env');
40
+ const envExamplePath = join(process.cwd(), '.env.example');
41
+
42
+ if (existsSync(envPath)) {
43
+ console.log('✓ .env file exists');
44
+ } else if (existsSync(envExamplePath)) {
45
+ console.log('Creating .env from .env.example...\n');
46
+
47
+ const token = await prompt('Discord Bot Token: ');
48
+ if (!token) {
49
+ console.log('Token required. Run setup again when ready.');
50
+ process.exit(1);
51
+ }
52
+
53
+ const tz = await prompt('Timezone (default: America/New_York): ') || 'America/New_York';
54
+
55
+ let envContent = readFileSync(envExamplePath, 'utf-8');
56
+ envContent = envContent.replace('your-bot-token-here', token);
57
+ envContent = envContent.replace('TZ=America/New_York', `TZ=${tz}`);
58
+
59
+ writeFileSync(envPath, envContent);
60
+ console.log('\n✓ .env file created');
61
+ }
62
+
63
+ // Check Redis
64
+ const redis = spawnSync(['redis-cli', 'ping'], { stdout: 'pipe', stderr: 'pipe' });
65
+ if (redis.exitCode === 0) {
66
+ console.log('✓ Redis is running');
67
+ } else {
68
+ console.log('⚠ Redis not running. Start it with: redis-server');
69
+ }
70
+
71
+ // Check Claude CLI
72
+ const claude = spawnSync(['claude', '--version'], { stdout: 'pipe', stderr: 'pipe' });
73
+ if (claude.exitCode === 0) {
74
+ console.log('✓ Claude CLI installed');
75
+ } else {
76
+ console.log('⚠ Claude CLI not found. Install from: https://claude.ai/code');
77
+ }
78
+
79
+ console.log('\n✨ Setup complete! Run: cord start\n');
80
+ }
81
+
82
+ async function start() {
83
+ if (existsSync(PID_FILE)) {
84
+ console.log('Cord is already running. Run: cord stop');
85
+ process.exit(1);
86
+ }
87
+
88
+ console.log('Starting Cord...\n');
89
+
90
+ // Start bot
91
+ const bot = spawn(['bun', 'run', 'src/bot.ts'], {
92
+ stdout: 'inherit',
93
+ stderr: 'inherit',
94
+ cwd: process.cwd(),
95
+ });
96
+
97
+ // Start worker
98
+ const worker = spawn(['bun', 'run', 'src/worker.ts'], {
99
+ stdout: 'inherit',
100
+ stderr: 'inherit',
101
+ cwd: process.cwd(),
102
+ });
103
+
104
+ // Save PIDs
105
+ writeFileSync(PID_FILE, JSON.stringify({
106
+ bot: bot.pid,
107
+ worker: worker.pid,
108
+ startedAt: new Date().toISOString(),
109
+ }));
110
+
111
+ console.log(`Bot PID: ${bot.pid}`);
112
+ console.log(`Worker PID: ${worker.pid}`);
113
+ console.log('\nCord is running. Press Ctrl+C to stop.\n');
114
+
115
+ // Handle exit
116
+ process.on('SIGINT', () => {
117
+ console.log('\nStopping Cord...');
118
+ bot.kill();
119
+ worker.kill();
120
+ if (existsSync(PID_FILE)) {
121
+ const fs = require('fs');
122
+ fs.unlinkSync(PID_FILE);
123
+ }
124
+ process.exit(0);
125
+ });
126
+
127
+ // Wait for processes
128
+ await Promise.all([bot.exited, worker.exited]);
129
+ }
130
+
131
+ function stop() {
132
+ if (!existsSync(PID_FILE)) {
133
+ console.log('Cord is not running.');
134
+ return;
135
+ }
136
+
137
+ const pids = JSON.parse(readFileSync(PID_FILE, 'utf-8'));
138
+
139
+ try {
140
+ process.kill(pids.bot);
141
+ console.log(`Stopped bot (PID ${pids.bot})`);
142
+ } catch {}
143
+
144
+ try {
145
+ process.kill(pids.worker);
146
+ console.log(`Stopped worker (PID ${pids.worker})`);
147
+ } catch {}
148
+
149
+ const fs = require('fs');
150
+ fs.unlinkSync(PID_FILE);
151
+ console.log('Cord stopped.');
152
+ }
153
+
154
+ function status() {
155
+ if (!existsSync(PID_FILE)) {
156
+ console.log('Cord is not running.');
157
+ return;
158
+ }
159
+
160
+ const pids = JSON.parse(readFileSync(PID_FILE, 'utf-8'));
161
+
162
+ const botAlive = isProcessRunning(pids.bot);
163
+ const workerAlive = isProcessRunning(pids.worker);
164
+
165
+ console.log(`Bot: ${botAlive ? '✓ running' : '✗ stopped'} (PID ${pids.bot})`);
166
+ console.log(`Worker: ${workerAlive ? '✓ running' : '✗ stopped'} (PID ${pids.worker})`);
167
+ console.log(`Started: ${pids.startedAt}`);
168
+ }
169
+
170
+ function isProcessRunning(pid: number): boolean {
171
+ try {
172
+ process.kill(pid, 0);
173
+ return true;
174
+ } catch {
175
+ return false;
176
+ }
177
+ }
178
+
179
+ function showHelp() {
180
+ console.log(`
181
+ Cord - Discord to Claude Code bridge
182
+
183
+ Usage: cord <command>
184
+
185
+ Commands:
186
+ start Start bot and worker
187
+ stop Stop all processes
188
+ status Show running status
189
+ setup Interactive setup wizard
190
+ help Show this help
191
+
192
+ Examples:
193
+ cord setup # First-time configuration
194
+ cord start # Start the bot
195
+ cord status # Check if running
196
+ cord stop # Stop everything
197
+ `);
198
+ }
199
+
200
+ // Main
201
+ switch (command) {
202
+ case 'start':
203
+ start();
204
+ break;
205
+ case 'stop':
206
+ stop();
207
+ break;
208
+ case 'status':
209
+ status();
210
+ break;
211
+ case 'setup':
212
+ setup();
213
+ break;
214
+ case 'help':
215
+ case '--help':
216
+ case '-h':
217
+ case undefined:
218
+ showHelp();
219
+ break;
220
+ default:
221
+ console.log(`Unknown command: ${command}`);
222
+ showHelp();
223
+ process.exit(1);
224
+ }
package/bun.lock ADDED
@@ -0,0 +1,131 @@
1
+ {
2
+ "lockfileVersion": 1,
3
+ "configVersion": 1,
4
+ "workspaces": {
5
+ "": {
6
+ "name": "discord-claude-bridge",
7
+ "dependencies": {
8
+ "bullmq": "^5.67.1",
9
+ "discord.js": "^14.25.1",
10
+ "ioredis": "^5.9.2",
11
+ },
12
+ "devDependencies": {
13
+ "@types/bun": "latest",
14
+ },
15
+ "peerDependencies": {
16
+ "typescript": "^5",
17
+ },
18
+ },
19
+ },
20
+ "packages": {
21
+ "@discordjs/builders": ["@discordjs/builders@1.13.1", "", { "dependencies": { "@discordjs/formatters": "^0.6.2", "@discordjs/util": "^1.2.0", "@sapphire/shapeshift": "^4.0.0", "discord-api-types": "^0.38.33", "fast-deep-equal": "^3.1.3", "ts-mixer": "^6.0.4", "tslib": "^2.6.3" } }, "sha512-cOU0UDHc3lp/5nKByDxkmRiNZBpdp0kx55aarbiAfakfKJHlxv/yFW1zmIqCAmwH5CRlrH9iMFKJMpvW4DPB+w=="],
22
+
23
+ "@discordjs/collection": ["@discordjs/collection@1.5.3", "", {}, "sha512-SVb428OMd3WO1paV3rm6tSjM4wC+Kecaa1EUGX7vc6/fddvw/6lg90z4QtCqm21zvVe92vMMDt9+DkIvjXImQQ=="],
24
+
25
+ "@discordjs/formatters": ["@discordjs/formatters@0.6.2", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-y4UPwWhH6vChKRkGdMB4odasUbHOUwy7KL+OVwF86PvT6QVOwElx+TiI1/6kcmcEe+g5YRXJFiXSXUdabqZOvQ=="],
26
+
27
+ "@discordjs/rest": ["@discordjs/rest@2.6.0", "", { "dependencies": { "@discordjs/collection": "^2.1.1", "@discordjs/util": "^1.1.1", "@sapphire/async-queue": "^1.5.3", "@sapphire/snowflake": "^3.5.3", "@vladfrangu/async_event_emitter": "^2.4.6", "discord-api-types": "^0.38.16", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-RDYrhmpB7mTvmCKcpj+pc5k7POKszS4E2O9TYc+U+Y4iaCP+r910QdO43qmpOja8LRr1RJ0b3U+CqVsnPqzf4w=="],
28
+
29
+ "@discordjs/util": ["@discordjs/util@1.2.0", "", { "dependencies": { "discord-api-types": "^0.38.33" } }, "sha512-3LKP7F2+atl9vJFhaBjn4nOaSWahZ/yWjOvA4e5pnXkt2qyXRCHLxoBQy81GFtLGCq7K9lPm9R517M1U+/90Qg=="],
30
+
31
+ "@discordjs/ws": ["@discordjs/ws@1.2.3", "", { "dependencies": { "@discordjs/collection": "^2.1.0", "@discordjs/rest": "^2.5.1", "@discordjs/util": "^1.1.0", "@sapphire/async-queue": "^1.5.2", "@types/ws": "^8.5.10", "@vladfrangu/async_event_emitter": "^2.2.4", "discord-api-types": "^0.38.1", "tslib": "^2.6.2", "ws": "^8.17.0" } }, "sha512-wPlQDxEmlDg5IxhJPuxXr3Vy9AjYq5xCvFWGJyD7w7Np8ZGu+Mc+97LCoEc/+AYCo2IDpKioiH0/c/mj5ZR9Uw=="],
32
+
33
+ "@ioredis/commands": ["@ioredis/commands@1.5.0", "", {}, "sha512-eUgLqrMf8nJkZxT24JvVRrQya1vZkQh8BBeYNwGDqa5I0VUi8ACx7uFvAaLxintokpTenkK6DASvo/bvNbBGow=="],
34
+
35
+ "@msgpackr-extract/msgpackr-extract-darwin-arm64": ["@msgpackr-extract/msgpackr-extract-darwin-arm64@3.0.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw=="],
36
+
37
+ "@msgpackr-extract/msgpackr-extract-darwin-x64": ["@msgpackr-extract/msgpackr-extract-darwin-x64@3.0.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw=="],
38
+
39
+ "@msgpackr-extract/msgpackr-extract-linux-arm": ["@msgpackr-extract/msgpackr-extract-linux-arm@3.0.3", "", { "os": "linux", "cpu": "arm" }, "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw=="],
40
+
41
+ "@msgpackr-extract/msgpackr-extract-linux-arm64": ["@msgpackr-extract/msgpackr-extract-linux-arm64@3.0.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg=="],
42
+
43
+ "@msgpackr-extract/msgpackr-extract-linux-x64": ["@msgpackr-extract/msgpackr-extract-linux-x64@3.0.3", "", { "os": "linux", "cpu": "x64" }, "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg=="],
44
+
45
+ "@msgpackr-extract/msgpackr-extract-win32-x64": ["@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3", "", { "os": "win32", "cpu": "x64" }, "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ=="],
46
+
47
+ "@sapphire/async-queue": ["@sapphire/async-queue@1.5.5", "", {}, "sha512-cvGzxbba6sav2zZkH8GPf2oGk9yYoD5qrNWdu9fRehifgnFZJMV+nuy2nON2roRO4yQQ+v7MK/Pktl/HgfsUXg=="],
48
+
49
+ "@sapphire/shapeshift": ["@sapphire/shapeshift@4.0.0", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "lodash": "^4.17.21" } }, "sha512-d9dUmWVA7MMiKobL3VpLF8P2aeanRTu6ypG2OIaEv/ZHH/SUQ2iHOVyi5wAPjQ+HmnMuL0whK9ez8I/raWbtIg=="],
50
+
51
+ "@sapphire/snowflake": ["@sapphire/snowflake@3.5.3", "", {}, "sha512-jjmJywLAFoWeBi1W7994zZyiNWPIiqRRNAmSERxyg93xRGzNYvGjlZ0gR6x0F4gPRi2+0O6S71kOZYyr3cxaIQ=="],
52
+
53
+ "@types/bun": ["@types/bun@1.3.6", "", { "dependencies": { "bun-types": "1.3.6" } }, "sha512-uWCv6FO/8LcpREhenN1d1b6fcspAB+cefwD7uti8C8VffIv0Um08TKMn98FynpTiU38+y2dUO55T11NgDt8VAA=="],
54
+
55
+ "@types/node": ["@types/node@25.0.10", "", { "dependencies": { "undici-types": "~7.16.0" } }, "sha512-zWW5KPngR/yvakJgGOmZ5vTBemDoSqF3AcV/LrO5u5wTWyEAVVh+IT39G4gtyAkh3CtTZs8aX/yRM82OfzHJRg=="],
56
+
57
+ "@types/ws": ["@types/ws@8.18.1", "", { "dependencies": { "@types/node": "*" } }, "sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg=="],
58
+
59
+ "@vladfrangu/async_event_emitter": ["@vladfrangu/async_event_emitter@2.4.7", "", {}, "sha512-Xfe6rpCTxSxfbswi/W/Pz7zp1WWSNn4A0eW4mLkQUewCrXXtMj31lCg+iQyTkh/CkusZSq9eDflu7tjEDXUY6g=="],
60
+
61
+ "bullmq": ["bullmq@5.67.1", "", { "dependencies": { "cron-parser": "4.9.0", "ioredis": "5.9.2", "msgpackr": "1.11.5", "node-abort-controller": "3.1.1", "semver": "7.7.3", "tslib": "2.8.1", "uuid": "11.1.0" } }, "sha512-ELJEAzwzesgFxk29emvnAakqrwdBEhEyfZREPQ8pbG4ALVz/mk/AhfuChzxkFpJ7SfL2qclPHbiUGBZzaqcLvg=="],
62
+
63
+ "bun-types": ["bun-types@1.3.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-OlFwHcnNV99r//9v5IIOgQ9Uk37gZqrNMCcqEaExdkVq3Avwqok1bJFmvGMCkCE0FqzdY8VMOZpfpR3lwI+CsQ=="],
64
+
65
+ "cluster-key-slot": ["cluster-key-slot@1.1.2", "", {}, "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA=="],
66
+
67
+ "cron-parser": ["cron-parser@4.9.0", "", { "dependencies": { "luxon": "^3.2.1" } }, "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q=="],
68
+
69
+ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
70
+
71
+ "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
72
+
73
+ "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],
74
+
75
+ "discord-api-types": ["discord-api-types@0.38.37", "", {}, "sha512-Cv47jzY1jkGkh5sv0bfHYqGgKOWO1peOrGMkDFM4UmaGMOTgOW8QSexhvixa9sVOiz8MnVOBryWYyw/CEVhj7w=="],
76
+
77
+ "discord.js": ["discord.js@14.25.1", "", { "dependencies": { "@discordjs/builders": "^1.13.0", "@discordjs/collection": "1.5.3", "@discordjs/formatters": "^0.6.2", "@discordjs/rest": "^2.6.0", "@discordjs/util": "^1.2.0", "@discordjs/ws": "^1.2.3", "@sapphire/snowflake": "3.5.3", "discord-api-types": "^0.38.33", "fast-deep-equal": "3.1.3", "lodash.snakecase": "4.1.1", "magic-bytes.js": "^1.10.0", "tslib": "^2.6.3", "undici": "6.21.3" } }, "sha512-2l0gsPOLPs5t6GFZfQZKnL1OJNYFcuC/ETWsW4VtKVD/tg4ICa9x+jb9bkPffkMdRpRpuUaO/fKkHCBeiCKh8g=="],
78
+
79
+ "fast-deep-equal": ["fast-deep-equal@3.1.3", "", {}, "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q=="],
80
+
81
+ "ioredis": ["ioredis@5.9.2", "", { "dependencies": { "@ioredis/commands": "1.5.0", "cluster-key-slot": "^1.1.0", "debug": "^4.3.4", "denque": "^2.1.0", "lodash.defaults": "^4.2.0", "lodash.isarguments": "^3.1.0", "redis-errors": "^1.2.0", "redis-parser": "^3.0.0", "standard-as-callback": "^2.1.0" } }, "sha512-tAAg/72/VxOUW7RQSX1pIxJVucYKcjFjfvj60L57jrZpYCHC3XN0WCQ3sNYL4Gmvv+7GPvTAjc+KSdeNuE8oWQ=="],
82
+
83
+ "lodash": ["lodash@4.17.23", "", {}, "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w=="],
84
+
85
+ "lodash.defaults": ["lodash.defaults@4.2.0", "", {}, "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="],
86
+
87
+ "lodash.isarguments": ["lodash.isarguments@3.1.0", "", {}, "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="],
88
+
89
+ "lodash.snakecase": ["lodash.snakecase@4.1.1", "", {}, "sha512-QZ1d4xoBHYUeuouhEq3lk3Uq7ldgyFXGBhg04+oRLnIz8o9T65Eh+8YdroUwn846zchkA9yDsDl5CVVaV2nqYw=="],
90
+
91
+ "luxon": ["luxon@3.7.2", "", {}, "sha512-vtEhXh/gNjI9Yg1u4jX/0YVPMvxzHuGgCm6tC5kZyb08yjGWGnqAjGJvcXbqQR2P3MyMEFnRbpcdFS6PBcLqew=="],
92
+
93
+ "magic-bytes.js": ["magic-bytes.js@1.13.0", "", {}, "sha512-afO2mnxW7GDTXMm5/AoN1WuOcdoKhtgXjIvHmobqTD1grNplhGdv3PFOyjCVmrnOZBIT/gD/koDKpYG+0mvHcg=="],
94
+
95
+ "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
96
+
97
+ "msgpackr": ["msgpackr@1.11.5", "", { "optionalDependencies": { "msgpackr-extract": "^3.0.2" } }, "sha512-UjkUHN0yqp9RWKy0Lplhh+wlpdt9oQBYgULZOiFhV3VclSF1JnSQWZ5r9gORQlNYaUKQoR8itv7g7z1xDDuACA=="],
98
+
99
+ "msgpackr-extract": ["msgpackr-extract@3.0.3", "", { "dependencies": { "node-gyp-build-optional-packages": "5.2.2" }, "optionalDependencies": { "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3", "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3", "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3" }, "bin": { "download-msgpackr-prebuilds": "bin/download-prebuilds.js" } }, "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA=="],
100
+
101
+ "node-abort-controller": ["node-abort-controller@3.1.1", "", {}, "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ=="],
102
+
103
+ "node-gyp-build-optional-packages": ["node-gyp-build-optional-packages@5.2.2", "", { "dependencies": { "detect-libc": "^2.0.1" }, "bin": { "node-gyp-build-optional-packages": "bin.js", "node-gyp-build-optional-packages-optional": "optional.js", "node-gyp-build-optional-packages-test": "build-test.js" } }, "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw=="],
104
+
105
+ "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
106
+
107
+ "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
108
+
109
+ "semver": ["semver@7.7.3", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-SdsKMrI9TdgjdweUSR9MweHA4EJ8YxHn8DFaDisvhVlUOe4BF1tLD7GAj0lIqWVl+dPb/rExr0Btby5loQm20Q=="],
110
+
111
+ "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
112
+
113
+ "ts-mixer": ["ts-mixer@6.0.4", "", {}, "sha512-ufKpbmrugz5Aou4wcr5Wc1UUFWOLhq+Fm6qa6P0w0K5Qw2yhaUoiWszhCVuNQyNwrlGiscHOmqYoAox1PtvgjA=="],
114
+
115
+ "tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
116
+
117
+ "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
118
+
119
+ "undici": ["undici@6.21.3", "", {}, "sha512-gBLkYIlEnSp8pFbT64yFgGE6UIB9tAkhukC23PmMDCe5Nd+cRqKxSjw5y54MK2AZMgZfJWMaNE4nYUHgi1XEOw=="],
120
+
121
+ "undici-types": ["undici-types@7.16.0", "", {}, "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw=="],
122
+
123
+ "uuid": ["uuid@11.1.0", "", { "bin": { "uuid": "dist/esm/bin/uuid" } }, "sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A=="],
124
+
125
+ "ws": ["ws@8.19.0", "", { "peerDependencies": { "bufferutil": "^4.0.1", "utf-8-validate": ">=5.0.2" }, "optionalPeers": ["bufferutil", "utf-8-validate"] }, "sha512-blAT2mjOEIi0ZzruJfIhb3nps74PRWTCz1IjglWEEpQl5XS/UNama6u2/rjFkDDouqr4L67ry+1aGIALViWjDg=="],
126
+
127
+ "@discordjs/rest/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
128
+
129
+ "@discordjs/ws/@discordjs/collection": ["@discordjs/collection@2.1.1", "", {}, "sha512-LiSusze9Tc7qF03sLCujF5iZp7K+vRNEDBZ86FT9aQAv3vxMLihUvKvpsCWiQ2DJq1tVckopKm1rxomgNUc9hg=="],
130
+ }
131
+ }
package/index.ts ADDED
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Cord - Discord to Claude Code bridge
3
+ *
4
+ * A simple bridge that connects Discord to Claude Code CLI.
5
+ *
6
+ * Start the bot: bun run src/bot.ts
7
+ * Start the worker: bun run src/worker.ts
8
+ *
9
+ * Or run both: bun run start
10
+ */
11
+
12
+ console.log('Cord - Discord to Claude Code bridge');
13
+ console.log('');
14
+ console.log('To start the system:');
15
+ console.log(' 1. Start Redis: redis-server');
16
+ console.log(' 2. Start the bot: bun run src/bot.ts');
17
+ console.log(' 3. Start the worker: bun run src/worker.ts');
18
+ console.log('');
19
+ console.log('Environment variables:');
20
+ console.log(' DISCORD_BOT_TOKEN - Your Discord bot token (required)');
21
+ console.log(' REDIS_HOST - Redis host (default: localhost)');
22
+ console.log(' REDIS_PORT - Redis port (default: 6379)');
23
+ console.log(' CLAUDE_WORKING_DIR - Working directory for Claude (default: cwd)');
24
+ console.log(' DB_PATH - SQLite database path (default: ./data/threads.db)');
package/package.json ADDED
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "cord-bot",
3
+ "version": "1.0.2",
4
+ "module": "index.ts",
5
+ "type": "module",
6
+ "bin": {
7
+ "cord": "bin/cord.ts"
8
+ },
9
+ "scripts": {
10
+ "start": "bun run bin/cord.ts start",
11
+ "bot": "bun run src/bot.ts",
12
+ "worker": "bun run src/worker.ts",
13
+ "setup": "bun run bin/cord.ts setup"
14
+ },
15
+ "devDependencies": {
16
+ "@types/bun": "latest"
17
+ },
18
+ "peerDependencies": {
19
+ "typescript": "^5"
20
+ },
21
+ "dependencies": {
22
+ "bullmq": "^5.67.1",
23
+ "discord.js": "^14.25.1",
24
+ "ioredis": "^5.9.2"
25
+ }
26
+ }
package/src/bot.ts ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Discord Bot - Catches @mentions, creates threads, forwards to queue
3
+ *
4
+ * This is the entry point for the Discord → Claude bridge.
5
+ * When someone @mentions the bot, it:
6
+ * 1. Creates a thread for the conversation
7
+ * 2. Queues the message for Claude processing
8
+ * 3. Posts responses back to the thread
9
+ */
10
+
11
+ import {
12
+ Client,
13
+ GatewayIntentBits,
14
+ Events,
15
+ Message,
16
+ TextChannel,
17
+ ThreadAutoArchiveDuration
18
+ } from 'discord.js';
19
+ import { claudeQueue } from './queue.js';
20
+ import { db } from './db.js';
21
+
22
+ // Force unbuffered logging
23
+ const log = (msg: string) => process.stdout.write(`[bot] ${msg}\n`);
24
+
25
+ const client = new Client({
26
+ intents: [
27
+ GatewayIntentBits.Guilds,
28
+ GatewayIntentBits.GuildMessages,
29
+ GatewayIntentBits.MessageContent,
30
+ ],
31
+ });
32
+
33
+ client.once(Events.ClientReady, (c) => {
34
+ log(`Logged in as ${c.user.tag}`);
35
+ });
36
+
37
+ client.on(Events.MessageCreate, async (message: Message) => {
38
+ // Ignore bots
39
+ if (message.author.bot) return;
40
+
41
+ const isMentioned = client.user && message.mentions.has(client.user);
42
+ const isInThread = message.channel.isThread();
43
+
44
+ // =========================================================================
45
+ // THREAD MESSAGES: Continue existing conversations
46
+ // =========================================================================
47
+ if (isInThread) {
48
+ const thread = message.channel;
49
+
50
+ // Look up session ID for this thread
51
+ const mapping = db.query('SELECT session_id FROM threads WHERE thread_id = ?')
52
+ .get(thread.id) as { session_id: string } | null;
53
+
54
+ if (!mapping) {
55
+ // Not a thread we created, ignore
56
+ return;
57
+ }
58
+
59
+ log(`Thread message from ${message.author.tag}`);
60
+
61
+ // Show typing indicator
62
+ await thread.sendTyping();
63
+
64
+ // Extract message content (strip @mentions)
65
+ const content = message.content.replace(/<@!?\d+>/g, '').trim();
66
+
67
+ // Queue for Claude processing with session resume
68
+ await claudeQueue.add('process', {
69
+ prompt: content,
70
+ threadId: thread.id,
71
+ sessionId: mapping.session_id,
72
+ resume: true,
73
+ userId: message.author.id,
74
+ username: message.author.tag,
75
+ });
76
+
77
+ return;
78
+ }
79
+
80
+ // =========================================================================
81
+ // NEW MENTIONS: Start new conversations
82
+ // =========================================================================
83
+ if (!isMentioned) return;
84
+
85
+ log(`New mention from ${message.author.tag}`);
86
+
87
+ // Create a thread for the conversation
88
+ let thread;
89
+ try {
90
+ // Generate thread name from message content
91
+ const rawText = message.content.replace(/<@!?\d+>/g, '').trim();
92
+ const threadName = rawText.length > 50
93
+ ? rawText.slice(0, 47) + '...'
94
+ : rawText || 'New conversation';
95
+
96
+ thread = await message.startThread({
97
+ name: threadName,
98
+ autoArchiveDuration: ThreadAutoArchiveDuration.OneDay,
99
+ });
100
+ } catch (error) {
101
+ log(`Failed to create thread: ${error}`);
102
+ await message.reply('Failed to start thread. Try again?');
103
+ return;
104
+ }
105
+
106
+ // Generate a new session ID for this conversation
107
+ const sessionId = crypto.randomUUID();
108
+
109
+ // Store the thread → session mapping
110
+ db.run(
111
+ 'INSERT INTO threads (thread_id, session_id) VALUES (?, ?)',
112
+ [thread.id, sessionId]
113
+ );
114
+
115
+ log(`Created thread ${thread.id} with session ${sessionId}`);
116
+
117
+ // Show typing indicator
118
+ await thread.sendTyping();
119
+
120
+ // Extract message content
121
+ const content = message.content.replace(/<@!?\d+>/g, '').trim();
122
+
123
+ // Queue for Claude processing
124
+ await claudeQueue.add('process', {
125
+ prompt: content,
126
+ threadId: thread.id,
127
+ sessionId,
128
+ resume: false,
129
+ userId: message.author.id,
130
+ username: message.author.tag,
131
+ });
132
+ });
133
+
134
+ // Start the bot
135
+ const token = process.env.DISCORD_BOT_TOKEN;
136
+ if (!token) {
137
+ console.error('DISCORD_BOT_TOKEN required');
138
+ process.exit(1);
139
+ }
140
+
141
+ client.login(token);
142
+
143
+ // Export for external use
144
+ export { client };
package/src/db.ts ADDED
@@ -0,0 +1,41 @@
1
+ /**
2
+ * Database - SQLite for thread → session mappings
3
+ *
4
+ * Simple key-value store:
5
+ * - thread_id (Discord thread ID)
6
+ * - session_id (Claude session UUID)
7
+ *
8
+ * When a follow-up message comes in a thread, we look up
9
+ * the session ID to use --resume.
10
+ */
11
+
12
+ import { Database } from 'bun:sqlite';
13
+
14
+ const DB_PATH = process.env.DB_PATH || './data/threads.db';
15
+
16
+ // Ensure data directory exists
17
+ import { mkdirSync } from 'fs';
18
+ import { dirname } from 'path';
19
+ try {
20
+ mkdirSync(dirname(DB_PATH), { recursive: true });
21
+ } catch {}
22
+
23
+ // Open database
24
+ export const db = new Database(DB_PATH);
25
+
26
+ // Create tables if they don't exist
27
+ db.run(`
28
+ CREATE TABLE IF NOT EXISTS threads (
29
+ thread_id TEXT PRIMARY KEY,
30
+ session_id TEXT NOT NULL,
31
+ created_at DATETIME DEFAULT CURRENT_TIMESTAMP
32
+ )
33
+ `);
34
+
35
+ // Create index for faster lookups
36
+ db.run(`
37
+ CREATE INDEX IF NOT EXISTS idx_threads_session
38
+ ON threads(session_id)
39
+ `);
40
+
41
+ console.log(`[db] SQLite database ready at ${DB_PATH}`);
package/src/discord.ts ADDED
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Discord utilities - Helper functions for posting to Discord
3
+ *
4
+ * Separated from bot.ts so the worker can send messages
5
+ * without importing the full client.
6
+ */
7
+
8
+ const DISCORD_BOT_TOKEN = process.env.DISCORD_BOT_TOKEN;
9
+
10
+ if (!DISCORD_BOT_TOKEN) {
11
+ console.warn('[discord] DISCORD_BOT_TOKEN not set - sendToThread will fail');
12
+ }
13
+
14
+ /**
15
+ * Send a message to a Discord thread
16
+ *
17
+ * Uses the REST API directly so we don't need the gateway client
18
+ */
19
+ export async function sendToThread(threadId: string, content: string): Promise<void> {
20
+ // Discord message limit is 2000 chars
21
+ const MAX_LENGTH = 2000;
22
+
23
+ // Split long messages
24
+ const chunks: string[] = [];
25
+ let remaining = content;
26
+
27
+ while (remaining.length > 0) {
28
+ if (remaining.length <= MAX_LENGTH) {
29
+ chunks.push(remaining);
30
+ break;
31
+ }
32
+
33
+ // Find a good split point (newline or space)
34
+ let splitAt = remaining.lastIndexOf('\n', MAX_LENGTH);
35
+ if (splitAt === -1 || splitAt < MAX_LENGTH / 2) {
36
+ splitAt = remaining.lastIndexOf(' ', MAX_LENGTH);
37
+ }
38
+ if (splitAt === -1 || splitAt < MAX_LENGTH / 2) {
39
+ splitAt = MAX_LENGTH;
40
+ }
41
+
42
+ chunks.push(remaining.slice(0, splitAt));
43
+ remaining = remaining.slice(splitAt).trim();
44
+ }
45
+
46
+ // Send each chunk
47
+ for (const chunk of chunks) {
48
+ const response = await fetch(
49
+ `https://discord.com/api/v10/channels/${threadId}/messages`,
50
+ {
51
+ method: 'POST',
52
+ headers: {
53
+ 'Authorization': `Bot ${DISCORD_BOT_TOKEN}`,
54
+ 'Content-Type': 'application/json',
55
+ },
56
+ body: JSON.stringify({ content: chunk }),
57
+ }
58
+ );
59
+
60
+ if (!response.ok) {
61
+ const error = await response.text();
62
+ throw new Error(`Discord API error: ${response.status} ${error}`);
63
+ }
64
+ }
65
+ }
66
+
67
+ /**
68
+ * Send typing indicator to a thread
69
+ */
70
+ export async function sendTyping(channelId: string): Promise<void> {
71
+ await fetch(
72
+ `https://discord.com/api/v10/channels/${channelId}/typing`,
73
+ {
74
+ method: 'POST',
75
+ headers: {
76
+ 'Authorization': `Bot ${DISCORD_BOT_TOKEN}`,
77
+ },
78
+ }
79
+ );
80
+ }
package/src/queue.ts ADDED
@@ -0,0 +1,42 @@
1
+ /**
2
+ * Queue - BullMQ job queue for Claude processing
3
+ *
4
+ * Why a queue?
5
+ * 1. Claude can take minutes to respond - Discord would timeout
6
+ * 2. Rate limiting - don't spawn 100 Claude processes at once
7
+ * 3. Persistence - if the bot crashes, jobs aren't lost
8
+ */
9
+
10
+ import { Queue } from 'bullmq';
11
+ import IORedis from 'ioredis';
12
+
13
+ // Redis connection for BullMQ
14
+ const connection = new IORedis({
15
+ host: process.env.REDIS_HOST || 'localhost',
16
+ port: parseInt(process.env.REDIS_PORT || '6379'),
17
+ maxRetriesPerRequest: null, // Required for BullMQ
18
+ });
19
+
20
+ // The queue that holds Claude processing jobs
21
+ export const claudeQueue = new Queue('claude', {
22
+ connection,
23
+ defaultJobOptions: {
24
+ attempts: 3,
25
+ backoff: {
26
+ type: 'exponential',
27
+ delay: 1000,
28
+ },
29
+ removeOnComplete: 100, // Keep last 100 completed jobs
30
+ removeOnFail: 50, // Keep last 50 failed jobs
31
+ },
32
+ });
33
+
34
+ // Job data structure
35
+ export interface ClaudeJob {
36
+ prompt: string;
37
+ threadId: string;
38
+ sessionId: string;
39
+ resume: boolean;
40
+ userId: string;
41
+ username: string;
42
+ }
package/src/spawner.ts ADDED
@@ -0,0 +1,107 @@
1
+ /**
2
+ * Spawner - The Claude CLI integration
3
+ *
4
+ * THIS IS THE CORE OF THE SYSTEM.
5
+ *
6
+ * Key flags:
7
+ * - `--print`: Non-interactive mode, returns output
8
+ * - `--session-id UUID`: Set session ID for new sessions
9
+ * - `--resume UUID`: Resume an existing session (for follow-ups)
10
+ * - `--append-system-prompt`: Inject context that survives compaction
11
+ * - `-p "prompt"`: The actual prompt to send
12
+ */
13
+
14
+ const log = (msg: string) => process.stdout.write(`[spawner] ${msg}\n`);
15
+
16
+ // Timezone for datetime injection (set via TZ env var)
17
+ const TIMEZONE = process.env.TZ || 'UTC';
18
+
19
+ interface SpawnOptions {
20
+ prompt: string;
21
+ sessionId: string;
22
+ resume: boolean;
23
+ systemPrompt?: string;
24
+ }
25
+
26
+ /**
27
+ * Get current datetime in user's timezone
28
+ * Claude Code doesn't know the time - we inject it
29
+ */
30
+ function getDatetimeContext(): string {
31
+ const now = new Date();
32
+ return now.toLocaleString('en-US', {
33
+ weekday: 'long',
34
+ year: 'numeric',
35
+ month: 'long',
36
+ day: 'numeric',
37
+ hour: 'numeric',
38
+ minute: '2-digit',
39
+ hour12: true,
40
+ timeZone: TIMEZONE,
41
+ });
42
+ }
43
+
44
+ /**
45
+ * Spawn Claude CLI and return the response
46
+ */
47
+ export async function spawnClaude(options: SpawnOptions): Promise<string> {
48
+ const { prompt, sessionId, resume, systemPrompt } = options;
49
+
50
+ log(`Spawning Claude - Session: ${sessionId}, Resume: ${resume}`);
51
+
52
+ // Build CLI arguments
53
+ const args = ['claude'];
54
+
55
+ // Non-interactive mode
56
+ args.push('--print');
57
+
58
+ // Session handling
59
+ if (resume) {
60
+ // Resume existing session for follow-up messages
61
+ args.push('--resume', sessionId);
62
+ } else {
63
+ // New session - set the ID upfront
64
+ args.push('--session-id', sessionId);
65
+ }
66
+
67
+ // Inject datetime context (survives session compaction)
68
+ const datetimeContext = `Current date/time: ${getDatetimeContext()}`;
69
+ const fullSystemPrompt = systemPrompt
70
+ ? `${datetimeContext}\n\n${systemPrompt}`
71
+ : datetimeContext;
72
+
73
+ args.push('--append-system-prompt', fullSystemPrompt);
74
+
75
+ // The actual prompt
76
+ args.push('-p', prompt);
77
+
78
+ log(`Command: ${args.join(' ').slice(0, 100)}...`);
79
+
80
+ // Spawn the process
81
+ const proc = Bun.spawn(args, {
82
+ cwd: process.env.CLAUDE_WORKING_DIR || process.cwd(),
83
+ env: {
84
+ ...process.env,
85
+ TZ: TIMEZONE,
86
+ },
87
+ stdout: 'pipe',
88
+ stderr: 'pipe',
89
+ });
90
+
91
+ // Collect output
92
+ const stdout = await new Response(proc.stdout).text();
93
+ const stderr = await new Response(proc.stderr).text();
94
+
95
+ // Wait for process to exit
96
+ const exitCode = await proc.exited;
97
+
98
+ if (exitCode !== 0) {
99
+ log(`Claude exited with code ${exitCode}`);
100
+ log(`stderr: ${stderr}`);
101
+ throw new Error(`Claude failed: ${stderr || 'Unknown error'}`);
102
+ }
103
+
104
+ log(`Claude responded (${stdout.length} chars)`);
105
+
106
+ return stdout.trim();
107
+ }
package/src/worker.ts ADDED
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Worker - Processes Claude jobs from the queue
3
+ *
4
+ * This is where the magic happens:
5
+ * 1. Pulls jobs from the queue
6
+ * 2. Spawns Claude CLI with the right flags
7
+ * 3. Posts the response back to Discord
8
+ */
9
+
10
+ import { Worker, Job } from 'bullmq';
11
+ import IORedis from 'ioredis';
12
+ import { spawnClaude } from './spawner.js';
13
+ import { sendToThread } from './discord.js';
14
+ import type { ClaudeJob } from './queue.js';
15
+
16
+ const log = (msg: string) => process.stdout.write(`[worker] ${msg}\n`);
17
+
18
+ const connection = new IORedis({
19
+ host: process.env.REDIS_HOST || 'localhost',
20
+ port: parseInt(process.env.REDIS_PORT || '6379'),
21
+ maxRetriesPerRequest: null,
22
+ });
23
+
24
+ const worker = new Worker<ClaudeJob>(
25
+ 'claude',
26
+ async (job: Job<ClaudeJob>) => {
27
+ const { prompt, threadId, sessionId, resume, username } = job.data;
28
+
29
+ log(`Processing job ${job.id} for ${username}`);
30
+ log(`Session: ${sessionId}, Resume: ${resume}`);
31
+
32
+ try {
33
+ // Spawn Claude and get response
34
+ const response = await spawnClaude({
35
+ prompt,
36
+ sessionId,
37
+ resume,
38
+ });
39
+
40
+ // Send response to Discord thread
41
+ await sendToThread(threadId, response);
42
+
43
+ log(`Job ${job.id} completed`);
44
+ return { success: true, responseLength: response.length };
45
+
46
+ } catch (error) {
47
+ log(`Job ${job.id} failed: ${error}`);
48
+
49
+ // Send error message to thread
50
+ await sendToThread(
51
+ threadId,
52
+ `Something went wrong. Try again?\n\`\`\`${error}\`\`\``
53
+ );
54
+
55
+ throw error; // Re-throw for BullMQ retry logic
56
+ }
57
+ },
58
+ {
59
+ connection,
60
+ concurrency: 2, // Process up to 2 jobs at once
61
+ }
62
+ );
63
+
64
+ worker.on('completed', (job) => {
65
+ log(`Job ${job?.id} completed`);
66
+ });
67
+
68
+ worker.on('failed', (job, err) => {
69
+ log(`Job ${job?.id} failed: ${err.message}`);
70
+ });
71
+
72
+ log('Worker started, waiting for jobs...');
73
+
74
+ export { worker };
package/tsconfig.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "compilerOptions": {
3
+ // Environment setup & latest features
4
+ "lib": ["ESNext"],
5
+ "target": "ESNext",
6
+ "module": "Preserve",
7
+ "moduleDetection": "force",
8
+ "jsx": "react-jsx",
9
+ "allowJs": true,
10
+
11
+ // Bundler mode
12
+ "moduleResolution": "bundler",
13
+ "allowImportingTsExtensions": true,
14
+ "verbatimModuleSyntax": true,
15
+ "noEmit": true,
16
+
17
+ // Best practices
18
+ "strict": true,
19
+ "skipLibCheck": true,
20
+ "noFallthroughCasesInSwitch": true,
21
+ "noUncheckedIndexedAccess": true,
22
+ "noImplicitOverride": true,
23
+
24
+ // Some stricter flags (disabled by default)
25
+ "noUnusedLocals": false,
26
+ "noUnusedParameters": false,
27
+ "noPropertyAccessFromIndexSignature": false
28
+ }
29
+ }