agent-window 1.0.0

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/src/bot.js ADDED
@@ -0,0 +1,1518 @@
1
+ /**
2
+ * AgentBridge - Bridge AI coding agents to chat platforms
3
+ *
4
+ * Features:
5
+ * - Docker sandbox isolation for secure CLI execution
6
+ * - Permission system with chat-based approval buttons
7
+ * - Session management with channel binding
8
+ * - Multi-agent support (Claude Code, Codex, OpenCode)
9
+ *
10
+ * Commands:
11
+ * @bot <message> - Send message (continues last conversation)
12
+ * @bot -n <message> - Start new conversation
13
+ * @bot -r <id> [msg] - Resume specific session
14
+ * @bot /pick - Choose session (button UI)
15
+ * @bot /sessions - List recent sessions
16
+ * @bot /status - Check bot status
17
+ * @bot /help - Show help
18
+ */
19
+
20
+ import { Client, GatewayIntentBits, Partials, Events, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js';
21
+ import { spawn, execSync } from 'child_process';
22
+ import { readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync, mkdirSync } from 'fs';
23
+ import { join } from 'path';
24
+
25
+ // Import centralized configuration
26
+ import config from './core/config.js';
27
+
28
+ // Import performance monitoring
29
+ import { createMonitor, formatMonitorSummary } from './core/perf-monitor.js';
30
+
31
+ // Extract commonly used config values for convenience
32
+ const BOT_TOKEN = config.discord.token;
33
+ const PROJECT_DIR = config.workspace.projectDir;
34
+ const OAUTH_TOKEN = config.backend.oauthToken;
35
+ const ALLOWED_CHANNELS = config.discord.allowedChannels;
36
+ const CHANNEL_SESSIONS_FILE = config.paths.sessions;
37
+ const PENDING_DIR = config.paths.pending;
38
+ const HOOK_DIR = config.paths.hooks;
39
+ const CONTAINER_NAME = config.workspace.containerName;
40
+ const DOCKER_IMAGE = config.workspace.dockerImage;
41
+
42
+ // Docker container internal paths
43
+ const CONTAINER_WORKSPACE = config.docker.containerPaths.workspace;
44
+ const CONTAINER_CONFIG_DIR = config.docker.containerPaths.configDir;
45
+ const CONTAINER_HOOK_DIR = config.docker.containerPaths.hookDir;
46
+ const CONTAINER_PENDING_DIR = config.docker.containerPaths.pendingDir;
47
+ const CONTAINER_SETTINGS_FILE = config.docker.containerPaths.settingsFile;
48
+ const PORT_MAPPINGS = config.workspace.portMappings;
49
+ const CLI_MAX_TURNS = config.cli.maxTurns;
50
+ const CLI_COMMAND = config.cli.command;
51
+ const CLI_TASK_TIMEOUT = config.cli.taskTimeout;
52
+ const PERMISSION_POLL_INTERVAL = config.permissions.pollInterval;
53
+ const DOCKER_CHECK_TIMEOUT = config.docker.checkTimeout;
54
+ const STATUS_UPDATE_THROTTLE = config.ui.statusUpdateThrottle;
55
+ const THEME = config.ui.theme;
56
+
57
+ // Platform configuration
58
+ const PLATFORM = config.platform.type;
59
+ const MESSAGE_MAX_LENGTH = config.platform.messageMaxLength;
60
+ const RETRY_DELAY = config.platform.retryDelay;
61
+ const PREVIEW_LENGTHS = config.platform.previewLengths;
62
+
63
+ // Track active tasks and their channels
64
+ const activeTasks = new Map();
65
+ const messageQueues = new Map();
66
+ const pendingPermissions = new Map(); // requestId -> { channelId, message }
67
+
68
+ let containerReady = false;
69
+
70
+ // Ensure pending directory exists
71
+ try {
72
+ mkdirSync(PENDING_DIR, { recursive: true });
73
+ } catch (e) {}
74
+
75
+ // Check if persistent container exists and is running
76
+ function isContainerRunning() {
77
+ try {
78
+ const result = execSync(
79
+ `docker inspect -f '{{.State.Running}}' ${CONTAINER_NAME} 2>/dev/null`,
80
+ { encoding: 'utf-8' }
81
+ ).trim();
82
+ return result === 'true';
83
+ } catch (e) {
84
+ return false;
85
+ }
86
+ }
87
+
88
+ // Start or ensure persistent container is running
89
+ function ensureContainer() {
90
+ // Log the paths being used
91
+ console.log('[Docker] PENDING_DIR (host):', PENDING_DIR);
92
+ console.log('[Docker] HOOK_DIR (host):', HOOK_DIR);
93
+
94
+ if (isContainerRunning()) {
95
+ // Verify the container has correct mounts by checking if we can see our pending dir
96
+ console.log('[Docker] Container already running, verifying mounts...');
97
+ try {
98
+ // Create a test file and check if container can see it
99
+ const testFile = join(PENDING_DIR, '.mount-test');
100
+ writeFileSync(testFile, 'test');
101
+ const result = execSync(
102
+ `docker exec ${CONTAINER_NAME} cat ${CONTAINER_PENDING_DIR}/.mount-test 2>/dev/null`,
103
+ { encoding: 'utf-8', timeout: DOCKER_CHECK_TIMEOUT }
104
+ ).trim();
105
+ unlinkSync(testFile);
106
+ if (result === 'test') {
107
+ console.log('[Docker] Mount verification: OK');
108
+ return true;
109
+ }
110
+ } catch (e) {
111
+ console.log('[Docker] Mount verification failed, restarting container...');
112
+ stopContainer();
113
+ // Fall through to create new container
114
+ }
115
+ }
116
+
117
+ console.log('[Docker] Starting persistent container...');
118
+ try {
119
+ // Remove old container if exists
120
+ try {
121
+ execSync(`docker rm -f ${CONTAINER_NAME} 2>/dev/null`);
122
+ } catch (e) {}
123
+
124
+ // Start new persistent container
125
+ const portArgs = PORT_MAPPINGS.map(p => `-p ${p}`).join(' ');
126
+ const envArgs = Object.entries(config.docker.env)
127
+ .map(([k, v]) => `-e '${k}=${v}'`)
128
+ .join(' ');
129
+
130
+ const dockerCmd = [
131
+ 'docker', 'run', '-d',
132
+ '--name', CONTAINER_NAME,
133
+ portArgs,
134
+ '--user', `${process.getuid()}:${process.getgid()}`,
135
+ '-v', `${PROJECT_DIR}:${CONTAINER_WORKSPACE}:rw`,
136
+ '-v', `${config.backend.configDir}:${CONTAINER_CONFIG_DIR}:rw`,
137
+ '-v', `${HOOK_DIR}:${CONTAINER_HOOK_DIR}:ro`,
138
+ '-v', `${PENDING_DIR}:${CONTAINER_PENDING_DIR}:rw`,
139
+ `-e 'CLAUDE_CODE_OAUTH_TOKEN=${OAUTH_TOKEN || ''}'`,
140
+ envArgs,
141
+ '--entrypoint', 'tail',
142
+ DOCKER_IMAGE,
143
+ '-f', '/dev/null'
144
+ ].join(' ');
145
+
146
+ execSync(dockerCmd);
147
+ console.log('[Docker] Container started successfully');
148
+ return true;
149
+ } catch (e) {
150
+ console.error('[Docker] Failed to start container:', e.message);
151
+ return false;
152
+ }
153
+ }
154
+
155
+ // Stop persistent container (call on bot shutdown)
156
+ function stopContainer() {
157
+ try {
158
+ execSync(`docker stop ${CONTAINER_NAME} 2>/dev/null`);
159
+ execSync(`docker rm ${CONTAINER_NAME} 2>/dev/null`);
160
+ console.log('[Docker] Container stopped');
161
+ } catch (e) {}
162
+ }
163
+
164
+ // Cleanup on exit
165
+ process.on('SIGINT', () => {
166
+ console.log('Shutting down...');
167
+ stopContainer();
168
+ process.exit(0);
169
+ });
170
+
171
+ process.on('SIGTERM', () => {
172
+ stopContainer();
173
+ process.exit(0);
174
+ });
175
+
176
+ // Load channel-session mappings
177
+ function loadChannelSessions() {
178
+ try {
179
+ if (existsSync(CHANNEL_SESSIONS_FILE)) {
180
+ return JSON.parse(readFileSync(CHANNEL_SESSIONS_FILE, 'utf-8'));
181
+ }
182
+ } catch (e) {
183
+ console.error('[Sessions] Failed to load:', e.message);
184
+ }
185
+ return {};
186
+ }
187
+
188
+ // Save channel-session mappings
189
+ function saveChannelSessions(sessions) {
190
+ try {
191
+ writeFileSync(CHANNEL_SESSIONS_FILE, JSON.stringify(sessions, null, 2));
192
+ } catch (e) {
193
+ console.error('[Sessions] Failed to save:', e.message);
194
+ }
195
+ }
196
+
197
+ // Get session ID for a channel
198
+ function getChannelSession(channelId) {
199
+ const sessions = loadChannelSessions();
200
+ return sessions[channelId] || null;
201
+ }
202
+
203
+ // Set session ID for a channel
204
+ function setChannelSession(channelId, sessionId) {
205
+ const sessions = loadChannelSessions();
206
+ sessions[channelId] = sessionId;
207
+ saveChannelSessions(sessions);
208
+ console.log(`[Sessions] Channel ${channelId} bound to session ${sessionId.substring(0, 8)}...`);
209
+ }
210
+
211
+ // Format elapsed time (excluding blocked time) for display
212
+ function formatElapsedTime(task) {
213
+ if (!task || !task.startTime) return '';
214
+ const now = Date.now();
215
+ const totalElapsed = now - task.startTime;
216
+ const blocked = task.blockedTime + (task.lastBlockStart ? (now - task.lastBlockStart) : 0);
217
+ const activeTime = Math.max(0, totalElapsed - blocked);
218
+
219
+ const seconds = Math.floor(activeTime / 1000);
220
+ if (seconds < 60) {
221
+ return `${seconds}s`;
222
+ }
223
+ const minutes = Math.floor(seconds / 60);
224
+ const remainingSeconds = seconds % 60;
225
+ return `${minutes}m${remainingSeconds}s`;
226
+ }
227
+
228
+ // Initialize client
229
+ const client = new Client({
230
+ intents: [
231
+ GatewayIntentBits.Guilds,
232
+ GatewayIntentBits.GuildMessages,
233
+ GatewayIntentBits.MessageContent,
234
+ GatewayIntentBits.DirectMessages,
235
+ ],
236
+ partials: [Partials.Channel, Partials.Message],
237
+ });
238
+
239
+ // Parse a single JSON line from stream
240
+ function parseJsonLine(line) {
241
+ try {
242
+ return JSON.parse(line.trim());
243
+ } catch (e) {
244
+ return null;
245
+ }
246
+ }
247
+
248
+ // Extract text content from assistant message
249
+ function extractAssistantText(msg) {
250
+ if (!msg.message?.content) return '';
251
+ return msg.message.content
252
+ .filter(c => c.type === 'text')
253
+ .map(c => c.text)
254
+ .join('\n');
255
+ }
256
+
257
+ // Format permission request for display
258
+ function formatPermissionRequest(request) {
259
+ const { tool_name, input } = request;
260
+ const cmdLen = PREVIEW_LENGTHS.command;
261
+ const fileLen = PREVIEW_LENGTHS.fileContent;
262
+ const editLen = PREVIEW_LENGTHS.editPreview;
263
+ const jsonLen = PREVIEW_LENGTHS.jsonInput;
264
+
265
+ let description = '';
266
+ switch (tool_name) {
267
+ case 'Bash':
268
+ const cmd = input.command || '';
269
+ description = `**Command:**\n\`\`\`bash\n${cmd.substring(0, cmdLen)}${cmd.length > cmdLen ? '...' : ''}\n\`\`\``;
270
+ break;
271
+ case 'Write':
272
+ const wPath = input.file_path || '';
273
+ const wContent = input.content || '';
274
+ description = `**File:** \`${wPath}\`\n**Content preview:**\n\`\`\`\n${wContent.substring(0, fileLen)}${wContent.length > fileLen ? '...' : ''}\n\`\`\``;
275
+ break;
276
+ case 'Edit':
277
+ const ePath = input.file_path || '';
278
+ const oldStr = input.old_string || '';
279
+ const newStr = input.new_string || '';
280
+ description = `**File:** \`${ePath}\`\n**Replace:**\n\`\`\`\n${oldStr.substring(0, editLen)}${oldStr.length > editLen ? '...' : ''}\n\`\`\`\n**With:**\n\`\`\`\n${newStr.substring(0, editLen)}${newStr.length > editLen ? '...' : ''}\n\`\`\``;
281
+ break;
282
+ case 'NotebookEdit':
283
+ description = `**Notebook:** \`${input.notebook_path || ''}\`\n**Cell:** ${input.cell_number || 0}`;
284
+ break;
285
+ default:
286
+ description = `**Input:**\n\`\`\`json\n${JSON.stringify(input, null, 2).substring(0, jsonLen)}\n\`\`\``;
287
+ }
288
+
289
+ return description;
290
+ }
291
+
292
+ // Track completed button clicks to prevent duplicate processing
293
+ const completedClicks = new Set();
294
+ // Track requests being processed (to prevent duplicate Discord messages)
295
+ const processingRequests = new Set();
296
+
297
+ /**
298
+ * Clean up all pending permissions for a channel
299
+ * Called when task completes, errors out, or is cancelled
300
+ * @param {string} channelId - Discord channel ID
301
+ * @param {string} reason - Reason for cleanup (for logging)
302
+ */
303
+ async function cleanupPermissions(channelId, reason = 'task ended') {
304
+ console.log(`[Permission] Cleaning up permissions for channel ${channelId} (${reason})`);
305
+
306
+ // Clean up pending permissions
307
+ for (const [requestId, perm] of pendingPermissions) {
308
+ if (perm.channelId === channelId) {
309
+ try {
310
+ // Update the permission card to show cancelled/timeout
311
+ await perm.message.edit({
312
+ embeds: [{
313
+ title: '⚠️ Cancelled',
314
+ description: `Task ${reason}. Permission request automatically cancelled.`,
315
+ color: THEME.warning,
316
+ }],
317
+ components: [],
318
+ });
319
+ } catch (e) {
320
+ // Message might have been deleted, ignore
321
+ }
322
+
323
+ // Clean up request files
324
+ try {
325
+ unlinkSync(join(PENDING_DIR, `${requestId}.request.json`));
326
+ } catch (e) {}
327
+ try {
328
+ unlinkSync(join(PENDING_DIR, `${requestId}.response.json`));
329
+ } catch (e) {}
330
+
331
+ pendingPermissions.delete(requestId);
332
+ processingRequests.delete(requestId);
333
+ completedClicks.add(requestId);
334
+ }
335
+ }
336
+ }
337
+
338
+ // Watch for permission requests from MCP server
339
+ function startPermissionWatcher() {
340
+ console.log('[Permission] Starting watcher for:', PENDING_DIR);
341
+
342
+ // Poll directory for new request files
343
+ setInterval(async () => {
344
+ try {
345
+ const files = readdirSync(PENDING_DIR);
346
+ for (const file of files) {
347
+ if (!file.endsWith('.request.json')) continue;
348
+
349
+ const requestId = file.replace('.request.json', '');
350
+ // Skip if already processing or already have Discord message
351
+ if (processingRequests.has(requestId) || pendingPermissions.has(requestId)) continue;
352
+
353
+ // Mark as processing IMMEDIATELY (before any async work)
354
+ processingRequests.add(requestId);
355
+
356
+ const requestFile = join(PENDING_DIR, file);
357
+ try {
358
+ const request = JSON.parse(readFileSync(requestFile, 'utf-8'));
359
+ console.log(`[Permission] New request: ${request.tool_name} (${requestId.substring(0, 8)}...)`);
360
+
361
+ // Find the channel that has an active task
362
+ let targetChannelId = null;
363
+ for (const [channelId, task] of activeTasks) {
364
+ targetChannelId = channelId;
365
+ break;
366
+ }
367
+
368
+ if (!targetChannelId) {
369
+ console.error('[Permission] No active task channel found');
370
+ processingRequests.delete(requestId);
371
+ continue;
372
+ }
373
+
374
+ const channel = await client.channels.fetch(targetChannelId);
375
+ if (!channel) {
376
+ console.error('[Permission] Channel not found:', targetChannelId);
377
+ processingRequests.delete(requestId);
378
+ continue;
379
+ }
380
+
381
+ // Create buttons
382
+ const row = new ActionRowBuilder()
383
+ .addComponents(
384
+ new ButtonBuilder()
385
+ .setCustomId(`perm_allow_${requestId}`)
386
+ .setLabel('Allow')
387
+ .setEmoji('✅')
388
+ .setStyle(ButtonStyle.Success),
389
+ new ButtonBuilder()
390
+ .setCustomId(`perm_deny_${requestId}`)
391
+ .setLabel('Deny')
392
+ .setEmoji('❌')
393
+ .setStyle(ButtonStyle.Danger),
394
+ );
395
+
396
+ // Send permission request message with retry
397
+ const description = formatPermissionRequest(request);
398
+ let msg = null;
399
+ let retries = 3;
400
+ while (retries > 0 && !msg) {
401
+ try {
402
+ msg = await channel.send({
403
+ embeds: [{
404
+ title: `🔒 Permission: ${request.tool_name}`,
405
+ description: description,
406
+ color: THEME.warning,
407
+ footer: { text: `ID: ${requestId.substring(0, 8)}` },
408
+ }],
409
+ components: [row],
410
+ });
411
+ } catch (sendErr) {
412
+ retries--;
413
+ console.error(`[Permission] Send failed (${retries} retries left):`, sendErr.message);
414
+ if (retries > 0) await new Promise(r => setTimeout(r, RETRY_DELAY));
415
+ }
416
+ }
417
+
418
+ if (!msg) {
419
+ console.error('[Permission] Failed to send after retries, skipping');
420
+ processingRequests.delete(requestId);
421
+ continue;
422
+ }
423
+
424
+ // Mark as pending AFTER successfully sending message
425
+ pendingPermissions.set(requestId, {
426
+ channelId: targetChannelId,
427
+ message: msg,
428
+ request,
429
+ });
430
+
431
+ // Update task status - move status to bottom by re-sending
432
+ const task = activeTasks.get(targetChannelId);
433
+ if (task && !task.aborted) {
434
+ // Start tracking blocked time
435
+ task.lastBlockStart = Date.now();
436
+
437
+ // Delete old status message and send new one below permission card
438
+ if (task.statusMsg && task.channel) {
439
+ try {
440
+ await task.statusMsg.delete();
441
+ } catch (e) {}
442
+
443
+ // Send new status message (will appear below permission card)
444
+ const elapsed = formatElapsedTime(task);
445
+ const statusText = `🔒 Waiting for permission: ${request.tool_name}`;
446
+ const statusWithTime = elapsed ? `${statusText}\n⏱️ ${elapsed}` : statusText;
447
+ task.statusMsg = await task.channel.send({
448
+ content: statusWithTime,
449
+ components: [task.stopButton],
450
+ });
451
+ }
452
+ }
453
+
454
+ } catch (e) {
455
+ console.error('[Permission] Error processing request:', e.message);
456
+ processingRequests.delete(requestId);
457
+ }
458
+ }
459
+ } catch (e) {
460
+ // Directory might not exist yet
461
+ }
462
+ }, PERMISSION_POLL_INTERVAL);
463
+ }
464
+
465
+ // Track processed session button clicks to prevent duplicates
466
+ const processedSessionClicks = new Set();
467
+ // Track processed stop button clicks to prevent duplicates
468
+ const processedStopClicks = new Set();
469
+
470
+ // Handle button interactions
471
+ client.on(Events.InteractionCreate, async (interaction) => {
472
+ if (!interaction.isButton()) return;
473
+
474
+ const customId = interaction.customId;
475
+
476
+ // Handle session selection buttons
477
+ if (customId.startsWith('session_')) {
478
+ // CRITICAL: Acknowledge interaction FIRST within 3-second Discord limit
479
+ try { await interaction.deferUpdate(); } catch (e) {}
480
+
481
+ // Generate unique click ID (messageId + customId)
482
+ const clickId = `${interaction.message.id}_${customId}`;
483
+
484
+ // Check if already processed
485
+ if (processedSessionClicks.has(clickId)) {
486
+ console.log('[Session] Duplicate click ignored');
487
+ return;
488
+ }
489
+
490
+ // Mark as processed IMMEDIATELY
491
+ processedSessionClicks.add(clickId);
492
+
493
+ const channelId = interaction.channelId;
494
+ const originalMessage = interaction.message;
495
+
496
+ if (customId === 'session_new') {
497
+ console.log('[Session] User selected: new session');
498
+
499
+ // Clear saved session for this channel
500
+ const sessions = loadChannelSessions();
501
+ delete sessions[channelId];
502
+ saveChannelSessions(sessions);
503
+
504
+ // Update card using direct message.edit()
505
+ try {
506
+ await originalMessage.edit({
507
+ embeds: [{
508
+ title: '✨ New Session Ready',
509
+ description: 'Next message will start a fresh conversation.',
510
+ color: THEME.success,
511
+ }],
512
+ components: [],
513
+ });
514
+ } catch (e) {
515
+ console.log('[Session] Card update failed:', e.message);
516
+ }
517
+
518
+ } else if (customId.startsWith('session_resume_')) {
519
+ const sessionId = customId.replace('session_resume_', '');
520
+ console.log('[Session] User selected: resume', sessionId.substring(0, 8));
521
+
522
+ setChannelSession(channelId, sessionId);
523
+
524
+ try {
525
+ await originalMessage.edit({
526
+ embeds: [{
527
+ title: '▶️ Session Selected',
528
+ description: `Next message will continue session \`${sessionId.substring(0, 8)}...\``,
529
+ color: THEME.success,
530
+ }],
531
+ components: [],
532
+ });
533
+ } catch (e) {
534
+ console.log('[Session] Card update failed:', e.message);
535
+ }
536
+ }
537
+
538
+ // Clean up old processed clicks (keep last 100)
539
+ if (processedSessionClicks.size > 100) {
540
+ const arr = Array.from(processedSessionClicks);
541
+ arr.slice(0, arr.length - 100).forEach(id => processedSessionClicks.delete(id));
542
+ }
543
+
544
+ return;
545
+ }
546
+
547
+ // Handle stop button
548
+ if (customId.startsWith('stop_')) {
549
+ // CRITICAL: Acknowledge interaction FIRST within 3-second Discord limit
550
+ try { await interaction.deferUpdate(); } catch (e) {}
551
+
552
+ // Generate unique click ID (messageId + customId)
553
+ const clickId = `${interaction.message.id}_${customId}`;
554
+
555
+ // Check if already processed
556
+ if (processedStopClicks.has(clickId)) {
557
+ console.log('[Stop] Duplicate click ignored');
558
+ return;
559
+ }
560
+
561
+ // Mark as processed IMMEDIATELY
562
+ processedStopClicks.add(clickId);
563
+
564
+ const targetChannelId = customId.replace('stop_', '');
565
+ console.log('[Stop] User clicked stop for channel:', targetChannelId);
566
+
567
+ const task = activeTasks.get(targetChannelId);
568
+ const originalMessage = interaction.message;
569
+
570
+ if (task) {
571
+ task.aborted = true;
572
+ if (task.child && !task.child.killed) {
573
+ task.child.kill('SIGTERM');
574
+ console.log('[Stop] Task aborted');
575
+ }
576
+
577
+ // Update card using direct message.edit()
578
+ try {
579
+ await originalMessage.edit({
580
+ content: '⏹️ Task stopped by user',
581
+ components: [],
582
+ });
583
+ } catch (e) {
584
+ console.log('[Stop] Card update failed:', e.message);
585
+ }
586
+ }
587
+
588
+ // Clean up old processed clicks (keep last 100)
589
+ if (processedStopClicks.size > 100) {
590
+ const arr = Array.from(processedStopClicks);
591
+ arr.slice(0, arr.length - 100).forEach(id => processedStopClicks.delete(id));
592
+ }
593
+
594
+ return;
595
+ }
596
+
597
+ // Handle permission buttons
598
+ if (!customId.startsWith('perm_')) return;
599
+
600
+ const parts = customId.split('_');
601
+ const action = parts[1]; // 'allow' or 'deny'
602
+ const requestId = parts.slice(2).join('_');
603
+
604
+ console.log(`[Permission] Button clicked: ${action} for ${requestId.substring(0, 8)}`);
605
+
606
+ // Check if already clicked (duplicate click protection)
607
+ if (completedClicks.has(requestId)) {
608
+ console.log('[Permission] Duplicate click ignored:', requestId.substring(0, 8));
609
+ try { await interaction.deferUpdate(); } catch (e) {}
610
+ return;
611
+ }
612
+
613
+ // Mark as clicked IMMEDIATELY to prevent race conditions
614
+ completedClicks.add(requestId);
615
+
616
+ const pending = pendingPermissions.get(requestId);
617
+ if (!pending) {
618
+ console.log('[Permission] Request not found in pending:', requestId.substring(0, 8));
619
+ try { await interaction.deferUpdate(); } catch (e) {}
620
+ return;
621
+ }
622
+
623
+ // STEP 1: Immediately remove buttons (best UX - instant feedback)
624
+ try {
625
+ await interaction.update({ components: [] });
626
+ console.log('[Permission] Buttons removed immediately');
627
+ } catch (e) {
628
+ // Fallback to deferUpdate if update fails
629
+ try { await interaction.deferUpdate(); } catch (e2) {}
630
+ try { await pending.message.edit({ components: [] }); } catch (e3) {}
631
+ }
632
+
633
+ // STEP 2: Write response file
634
+ const response = action === 'allow'
635
+ ? { behavior: 'allow' }
636
+ : { behavior: 'deny', message: 'User denied permission' };
637
+
638
+ const responseFile = join(PENDING_DIR, `${requestId}.response.json`);
639
+
640
+ // Also delete request file to prevent re-detection by watcher
641
+ const requestFile = join(PENDING_DIR, `${requestId}.request.json`);
642
+
643
+ try {
644
+ writeFileSync(responseFile, JSON.stringify(response));
645
+ console.log(`[Permission] Response file written: ${action}`);
646
+ // Delete request file immediately to prevent re-detection
647
+ try { unlinkSync(requestFile); } catch (e) {}
648
+ } catch (writeErr) {
649
+ console.error('[Permission] Failed to write response file:', writeErr.message);
650
+ // Remove from clicked so user can retry
651
+ completedClicks.delete(requestId);
652
+ // Send new permission card
653
+ try {
654
+ const row = new ActionRowBuilder()
655
+ .addComponents(
656
+ new ButtonBuilder()
657
+ .setCustomId(`perm_allow_${requestId}`)
658
+ .setLabel('Allow')
659
+ .setEmoji('✅')
660
+ .setStyle(ButtonStyle.Success),
661
+ new ButtonBuilder()
662
+ .setCustomId(`perm_deny_${requestId}`)
663
+ .setLabel('Deny')
664
+ .setEmoji('❌')
665
+ .setStyle(ButtonStyle.Danger),
666
+ );
667
+ await pending.message.channel.send({
668
+ content: '⚠️ Permission write failed, please try again:',
669
+ embeds: [pending.message.embeds[0]],
670
+ components: [row],
671
+ });
672
+ } catch (e) {}
673
+ return;
674
+ }
675
+
676
+ // STEP 3: Update card to show final status
677
+ const statusEmoji = action === 'allow' ? '✅' : '❌';
678
+ const statusText = action === 'allow' ? 'Allowed' : 'Denied';
679
+ const color = action === 'allow' ? THEME.success : THEME.error;
680
+
681
+ try {
682
+ await pending.message.edit({
683
+ embeds: [{
684
+ title: `${statusEmoji} ${statusText}: ${pending.request.tool_name}`,
685
+ description: formatPermissionRequest(pending.request),
686
+ color: color,
687
+ footer: { text: `ID: ${requestId.substring(0, 8)}` },
688
+ }],
689
+ });
690
+ console.log('[Permission] Card updated with status');
691
+ } catch (e) {
692
+ console.log('[Permission] Card status update failed (OK, permission granted):', e.message);
693
+ }
694
+
695
+ // Remove from pending and processing
696
+ pendingPermissions.delete(requestId);
697
+ processingRequests.delete(requestId);
698
+ console.log(`[Permission] Completed: ${action} ${requestId.substring(0, 8)}`);
699
+
700
+ // Update task status to show permission was handled
701
+ // Update task timing and status
702
+ const task = activeTasks.get(pending.channelId);
703
+ if (task && !task.aborted) {
704
+ // Stop tracking blocked time
705
+ if (task.lastBlockStart) {
706
+ task.blockedTime += Date.now() - task.lastBlockStart;
707
+ task.lastBlockStart = null;
708
+ }
709
+ if (action === 'allow' && task.updateStatus) {
710
+ await task.updateStatus(`✅ Permission granted: ${pending.request.tool_name}\n⏳ Continuing...`, true);
711
+ }
712
+ }
713
+
714
+ // Clean up old completed clicks (keep last 200)
715
+ if (completedClicks.size > 200) {
716
+ const arr = Array.from(completedClicks);
717
+ arr.slice(0, arr.length - 200).forEach(id => completedClicks.delete(id));
718
+ }
719
+ });
720
+
721
+ // Command handlers
722
+ const commands = {
723
+ async help(message) {
724
+ const helpText = `**AgentBridge** - AI Coding Agent Bridge
725
+
726
+ **Usage:**
727
+ \`@bot <message>\` - Send message (continues last session)
728
+ \`@bot -n <message>\` - Start new session
729
+ \`@bot -r <session_id> [message]\` - Resume specific session
730
+
731
+ **Security:**
732
+ 🐳 Docker sandbox isolation
733
+ 🔒 Permission approval for dangerous operations
734
+ ⚡ Persistent container for fast response
735
+
736
+ **Auto-approved:** Read, Glob, Grep, WebFetch, WebSearch, Task
737
+ **Needs approval:** Write, Edit, Bash, NotebookEdit
738
+
739
+ **Commands:**
740
+ \`@bot /pick\` - Choose session (new or resume)
741
+ \`@bot /sessions\` - List all recent sessions
742
+ \`@bot /status\` - Check bot status
743
+ \`@bot /help\` - Show this help`;
744
+
745
+ await message.channel.send({
746
+ embeds: [{
747
+ title: 'Help',
748
+ description: helpText,
749
+ color: THEME.info,
750
+ }]
751
+ });
752
+ },
753
+
754
+ async pick(message) {
755
+ await commands.showSessionPicker(message);
756
+ },
757
+
758
+ async status(message) {
759
+ const uptime = process.uptime();
760
+ const hours = Math.floor(uptime / 3600);
761
+ const minutes = Math.floor((uptime % 3600) / 60);
762
+ const containerRunning = isContainerRunning();
763
+
764
+ const statusText = `**Status**: Online
765
+ **Mode**: Docker Sandbox + Hooks Permission
766
+ **Uptime**: ${hours}h ${minutes}m
767
+ **Container**: ${CONTAINER_NAME} (${containerRunning ? '🟢 running' : '🔴 stopped'})
768
+ **Project**: ${PROJECT_DIR}
769
+ **Workspace**: ${CONTAINER_WORKSPACE}
770
+ **OAuth**: ${OAUTH_TOKEN ? 'configured' : 'not set'}`;
771
+
772
+ await message.channel.send({
773
+ embeds: [{
774
+ title: 'Bot Status',
775
+ description: statusText,
776
+ color: containerRunning ? THEME.success : THEME.error,
777
+ }]
778
+ });
779
+ },
780
+
781
+ async sessions(message) {
782
+ try {
783
+ // Sessions are stored on host, use host path
784
+ const projectPath = PROJECT_DIR.replace(/\//g, '-');
785
+ const sessionsFile = join(config.backend.configDir, 'projects', projectPath, 'sessions-index.json');
786
+
787
+ if (!existsSync(sessionsFile)) {
788
+ await message.channel.send('No sessions found');
789
+ return;
790
+ }
791
+
792
+ const data = JSON.parse(readFileSync(sessionsFile, 'utf-8'));
793
+ const sessions = data.entries || [];
794
+
795
+ const recentSessions = sessions
796
+ .sort((a, b) => new Date(b.modified) - new Date(a.modified))
797
+ .slice(0, 10);
798
+
799
+ if (recentSessions.length === 0) {
800
+ await message.channel.send('No sessions found');
801
+ return;
802
+ }
803
+
804
+ const list = recentSessions.map((s, i) => {
805
+ const date = new Date(s.modified);
806
+ const dateStr = `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`;
807
+ const preview = (s.firstPrompt || '').substring(0, 30).replace(/\n/g, ' ');
808
+ const shortId = s.sessionId.substring(0, 8);
809
+ return `**${i + 1}.** ${dateStr}\n\`-r ${shortId}\`\n${preview}${preview.length >= 30 ? '...' : ''}`;
810
+ }).join('\n\n');
811
+
812
+ await message.channel.send({
813
+ embeds: [{
814
+ title: `Recent Sessions (${recentSessions.length}/${sessions.length})`,
815
+ description: list + '\n\n**Usage:** `@bot -r <session_id> <message>`',
816
+ color: THEME.info,
817
+ }]
818
+ });
819
+ } catch (err) {
820
+ console.error('Sessions error:', err);
821
+ await message.channel.send(`Error: ${err.message}`);
822
+ }
823
+ },
824
+
825
+ // Show session selection card with buttons
826
+ async showSessionPicker(message) {
827
+ try {
828
+ const projectPath = PROJECT_DIR.replace(/\//g, '-');
829
+ const sessionsFile = join(config.backend.configDir, 'projects', projectPath, 'sessions-index.json');
830
+
831
+ let recentSessions = [];
832
+ if (existsSync(sessionsFile)) {
833
+ const data = JSON.parse(readFileSync(sessionsFile, 'utf-8'));
834
+ recentSessions = (data.entries || [])
835
+ .sort((a, b) => new Date(b.modified) - new Date(a.modified))
836
+ .slice(0, 3); // Top 3 recent sessions
837
+ }
838
+
839
+ // Create buttons
840
+ const buttons = [
841
+ new ButtonBuilder()
842
+ .setCustomId('session_new')
843
+ .setLabel('New Session')
844
+ .setEmoji('✨')
845
+ .setStyle(ButtonStyle.Primary),
846
+ ];
847
+
848
+ // Add recent session buttons
849
+ recentSessions.forEach((s, i) => {
850
+ const preview = (s.firstPrompt || '').substring(0, 20).replace(/\n/g, ' ');
851
+ buttons.push(
852
+ new ButtonBuilder()
853
+ .setCustomId(`session_resume_${s.sessionId}`)
854
+ .setLabel(`${preview}${preview.length >= 20 ? '...' : ''}`)
855
+ .setStyle(ButtonStyle.Secondary)
856
+ );
857
+ });
858
+
859
+ const row = new ActionRowBuilder().addComponents(buttons);
860
+
861
+ const description = recentSessions.length > 0
862
+ ? `Choose to start a new session or continue a recent one:\n\n${recentSessions.map((s, i) => {
863
+ const date = new Date(s.modified);
864
+ const dateStr = `${date.getMonth() + 1}/${date.getDate()} ${date.getHours()}:${String(date.getMinutes()).padStart(2, '0')}`;
865
+ const preview = (s.firstPrompt || '').substring(0, 40).replace(/\n/g, ' ');
866
+ return `**${i + 1}.** ${dateStr}\n${preview}${preview.length >= 40 ? '...' : ''}`;
867
+ }).join('\n\n')}`
868
+ : 'No recent sessions found. Start a new conversation!';
869
+
870
+ await message.channel.send({
871
+ embeds: [{
872
+ title: '🗂️ Choose Session',
873
+ description: description,
874
+ color: THEME.info,
875
+ footer: { text: 'Select a button or send a message to start' }
876
+ }],
877
+ components: [row],
878
+ });
879
+ } catch (err) {
880
+ console.error('Session picker error:', err);
881
+ await message.channel.send('Starting new session...');
882
+ }
883
+ },
884
+
885
+ async claude(message, args) {
886
+ const userMessage = args.join(' ').trim();
887
+ if (!userMessage) {
888
+ await commands.help(message);
889
+ return;
890
+ }
891
+
892
+ const channelId = message.channel.id;
893
+
894
+ // Check if there's an active task
895
+ if (activeTasks.has(channelId)) {
896
+ const task = activeTasks.get(channelId);
897
+
898
+ // Check if there are pending permissions for this channel
899
+ const hasPendingPermissions = Array.from(pendingPermissions.values())
900
+ .some(p => p.channelId === channelId);
901
+
902
+ if (hasPendingPermissions) {
903
+ // User sent new message while waiting for permission - cancel current task
904
+ console.log('[Claude] New message received while permission pending - cancelling task');
905
+
906
+ // Abort the current task
907
+ task.aborted = true;
908
+ if (task.child && !task.child.killed) {
909
+ task.child.kill('SIGTERM');
910
+ }
911
+
912
+ // Clean up pending permissions for this channel
913
+ for (const [requestId, perm] of pendingPermissions) {
914
+ if (perm.channelId === channelId) {
915
+ // Update the permission card to show cancelled
916
+ try {
917
+ await perm.message.edit({
918
+ embeds: [{
919
+ title: '⚠️ Cancelled',
920
+ description: 'User sent new message - permission request cancelled',
921
+ color: THEME.warning,
922
+ }],
923
+ components: [],
924
+ });
925
+ } catch (e) {}
926
+
927
+ // Clean up request files
928
+ try {
929
+ unlinkSync(join(PENDING_DIR, `${requestId}.request.json`));
930
+ } catch (e) {}
931
+ try {
932
+ unlinkSync(join(PENDING_DIR, `${requestId}.response.json`));
933
+ } catch (e) {}
934
+
935
+ pendingPermissions.delete(requestId);
936
+ processingRequests.delete(requestId); // Also clean up processingRequests!
937
+ completedClicks.add(requestId);
938
+ }
939
+ }
940
+
941
+ // Update status message
942
+ try {
943
+ await task.statusMsg.edit({
944
+ content: '⚠️ Previous task cancelled - starting new request...',
945
+ components: [],
946
+ });
947
+ } catch (e) {}
948
+
949
+ // Clean up
950
+ activeTasks.delete(channelId);
951
+ messageQueues.delete(channelId);
952
+
953
+ // Start new task
954
+ await commands._processClaudeMessage(message, userMessage, channelId);
955
+ return;
956
+ }
957
+
958
+ // No pending permissions - just queue the message
959
+ if (!messageQueues.has(channelId)) {
960
+ messageQueues.set(channelId, []);
961
+ }
962
+ messageQueues.get(channelId).push({ content: userMessage, timestamp: Date.now() });
963
+ const queueSize = messageQueues.get(channelId).length;
964
+ await message.channel.send(`📥 Queued (#${queueSize}) - will process after current task`);
965
+ return;
966
+ }
967
+
968
+ await commands._processClaudeMessage(message, userMessage, channelId);
969
+ },
970
+
971
+ async _processClaudeMessage(message, userMessage, channelId) {
972
+ // Create stop button
973
+ const stopButton = new ActionRowBuilder()
974
+ .addComponents(
975
+ new ButtonBuilder()
976
+ .setCustomId(`stop_${channelId}`)
977
+ .setLabel('Stop')
978
+ .setEmoji('⏹️')
979
+ .setStyle(ButtonStyle.Danger)
980
+ );
981
+
982
+ const statusMsg = await message.channel.send({
983
+ content: '🐳 Connecting to container...',
984
+ components: [stopButton],
985
+ });
986
+
987
+ // Track timing for this task
988
+ const taskStartTime = Date.now();
989
+ // Create performance monitor for this task
990
+ const perfMonitor = createMonitor(channelId, { enabled: true, verbose: false });
991
+
992
+ activeTasks.set(channelId, {
993
+ statusMsg,
994
+ channel: message.channel, // Store channel for re-sending status
995
+ aborted: false,
996
+ stopButton,
997
+ startTime: taskStartTime,
998
+ blockedTime: 0, // Time spent waiting for permissions
999
+ lastBlockStart: null, // When current block started
1000
+ perfMonitor, // Performance monitoring
1001
+ });
1002
+
1003
+ try {
1004
+ // Parse flags
1005
+ let actualMessage = userMessage;
1006
+
1007
+ // Base CLI args with PreToolUse hook for permission control
1008
+ // Hook handles: auto-approve safe tools, request Discord approval for risky tools
1009
+ const cliArgs = [
1010
+ '--print',
1011
+ '--output-format', 'stream-json',
1012
+ '--verbose',
1013
+ '--max-turns', String(CLI_MAX_TURNS),
1014
+ '--settings', CONTAINER_SETTINGS_FILE,
1015
+ ];
1016
+
1017
+ // Track if we're forcing a new session or resuming a specific one
1018
+ if (userMessage.startsWith('-n ') || userMessage.startsWith('--new ')) {
1019
+ // Force new session
1020
+ actualMessage = userMessage.replace(/^--?n(ew)?\s+/, '');
1021
+ cliArgs.push(actualMessage);
1022
+ } else if (userMessage.match(/^-r\s+\S+/) || userMessage.match(/^--resume\s+\S+/)) {
1023
+ // Manual resume specific session
1024
+ const match = userMessage.match(/^--?r(esume)?\s+(\S+)(?:\s+(.*))?$/);
1025
+ if (match) {
1026
+ let sessionId = match[2];
1027
+
1028
+ // Resolve short session ID
1029
+ if (sessionId.length < 36) {
1030
+ try {
1031
+ const projectPath = PROJECT_DIR.replace(/\//g, '-');
1032
+ const sessionsFile = join(config.backend.configDir, 'projects', projectPath, 'sessions-index.json');
1033
+ if (existsSync(sessionsFile)) {
1034
+ const data = JSON.parse(readFileSync(sessionsFile, 'utf-8'));
1035
+ const found = (data.entries || []).find(s => s.sessionId.startsWith(sessionId));
1036
+ if (found) {
1037
+ console.log('[Claude] Resolved', sessionId, '->', found.sessionId);
1038
+ sessionId = found.sessionId;
1039
+ }
1040
+ }
1041
+ } catch (e) {
1042
+ console.log('[Claude] Session lookup failed:', e.message);
1043
+ }
1044
+ }
1045
+
1046
+ cliArgs.push('--resume', sessionId);
1047
+ actualMessage = match[3] || 'continue';
1048
+ cliArgs.push(actualMessage);
1049
+ }
1050
+ } else {
1051
+ // Default: auto-resume channel's saved session or start new
1052
+ const savedSessionId = getChannelSession(channelId);
1053
+ if (savedSessionId) {
1054
+ console.log('[Claude] Auto-resuming channel session:', savedSessionId.substring(0, 8));
1055
+ cliArgs.push('--resume', savedSessionId);
1056
+ }
1057
+ cliArgs.push(actualMessage);
1058
+ }
1059
+
1060
+ console.log('[Claude] Starting Docker + Hooks mode');
1061
+
1062
+ // Ensure persistent container is running
1063
+ if (!ensureContainer()) {
1064
+ throw new Error('Failed to start Docker container');
1065
+ }
1066
+
1067
+ const result = await new Promise((resolve, reject) => {
1068
+ let jsonBuffer = '';
1069
+ let assistantTexts = [];
1070
+ let finalResult = '';
1071
+ let capturedSessionId = null;
1072
+ let lastStatusUpdate = 0;
1073
+ const task = activeTasks.get(channelId);
1074
+
1075
+ // Use docker exec to run CLI in the persistent container
1076
+ const dockerArgs = [
1077
+ 'exec', '-i',
1078
+ CONTAINER_NAME,
1079
+ CLI_COMMAND,
1080
+ ...cliArgs
1081
+ ];
1082
+
1083
+ console.log('[Docker] Executing in container:', CONTAINER_NAME);
1084
+
1085
+ const child = spawn('docker', dockerArgs, {
1086
+ stdio: ['pipe', 'pipe', 'pipe'],
1087
+ });
1088
+
1089
+ child.stdin.end();
1090
+
1091
+ // Update status periodically (with stop button)
1092
+ // forceUpdate=true bypasses throttle for important state changes
1093
+ // Uses task.statusMsg to support message re-creation after permission cards
1094
+ const updateStatus = async (status, forceUpdate = false) => {
1095
+ const now = Date.now();
1096
+ if ((forceUpdate || now - lastStatusUpdate > STATUS_UPDATE_THROTTLE) && task && !task.aborted) {
1097
+ lastStatusUpdate = now;
1098
+ // Add elapsed time to status
1099
+ const elapsed = formatElapsedTime(task);
1100
+ const statusWithTime = elapsed ? `${status}\n⏱️ ${elapsed}` : status;
1101
+ // Use task.statusMsg which may be updated when permission cards are sent
1102
+ const currentStatusMsg = task.statusMsg;
1103
+ if (currentStatusMsg) {
1104
+ try {
1105
+ await currentStatusMsg.edit({
1106
+ content: statusWithTime,
1107
+ components: [task.stopButton],
1108
+ });
1109
+ } catch (e) {
1110
+ // Ignore edit errors (message may have been deleted)
1111
+ }
1112
+ }
1113
+ }
1114
+ };
1115
+
1116
+ // Store updateStatus in task so permission handler can use it
1117
+ task.updateStatus = updateStatus;
1118
+
1119
+ child.stdout.on('data', async (data) => {
1120
+ jsonBuffer += data.toString();
1121
+
1122
+ // Process complete JSON lines
1123
+ const lines = jsonBuffer.split('\n');
1124
+ jsonBuffer = lines.pop(); // Keep incomplete line in buffer
1125
+
1126
+ for (const line of lines) {
1127
+ if (!line.trim()) continue;
1128
+
1129
+ const json = parseJsonLine(line);
1130
+ if (!json) continue;
1131
+
1132
+ console.log('[JSON]', json.type, json.subtype || '');
1133
+
1134
+ // Capture session_id from any message that has it
1135
+ if (json.session_id && !capturedSessionId) {
1136
+ capturedSessionId = json.session_id;
1137
+ console.log('[Claude] Captured session_id:', capturedSessionId.substring(0, 8));
1138
+ }
1139
+
1140
+ switch (json.type) {
1141
+ case 'system':
1142
+ if (json.subtype === 'init') {
1143
+ await updateStatus('🐳 Initializing...');
1144
+ if (json.session_id) {
1145
+ capturedSessionId = json.session_id;
1146
+ }
1147
+ }
1148
+ break;
1149
+
1150
+ case 'assistant':
1151
+ const text = extractAssistantText(json);
1152
+ if (text) {
1153
+ assistantTexts.push(text);
1154
+ const previewLen = PREVIEW_LENGTHS.response;
1155
+ const preview = text.substring(0, previewLen);
1156
+ await updateStatus(`💭 Responding...\n\`\`\`\n${preview}${text.length > previewLen ? '...' : ''}\n\`\`\``);
1157
+ }
1158
+ break;
1159
+
1160
+ case 'result':
1161
+ finalResult = json.result || '';
1162
+ break;
1163
+
1164
+ case 'tool_use':
1165
+ const toolName = json.tool_name || json.name || 'unknown';
1166
+ // Record tool call in performance monitor
1167
+ const task = activeTasks.get(channelId);
1168
+ if (task && task.perfMonitor) {
1169
+ task.perfMonitor.recordToolCall(toolName, json.input || {});
1170
+ }
1171
+ if (toolName === 'Task') {
1172
+ const agentType = json.input?.subagent_type || 'agent';
1173
+ const desc = json.input?.description || '';
1174
+ await updateStatus(`🤖 Subagent [${agentType}]: ${desc || 'running'}...`);
1175
+ // Track subagent depth
1176
+ if (task && task.perfMonitor) {
1177
+ task.perfMonitor.recordSubagentCall(agentType);
1178
+ }
1179
+ } else if (toolName === 'Bash') {
1180
+ const cmd = json.input?.command?.substring(0, 50) || '';
1181
+ await updateStatus(`⚡ Bash: ${cmd}${cmd.length >= 50 ? '...' : ''}`);
1182
+ } else if (toolName === 'Read' || toolName === 'Edit' || toolName === 'Write') {
1183
+ const file = json.input?.file_path?.split('/').pop() || '';
1184
+ await updateStatus(`📄 ${toolName}: ${file}`);
1185
+ } else {
1186
+ await updateStatus(`🔧 ${toolName}...`);
1187
+ }
1188
+ break;
1189
+
1190
+ case 'tool_result':
1191
+ break;
1192
+ }
1193
+ }
1194
+ });
1195
+
1196
+ child.stderr.on('data', (data) => {
1197
+ const text = data.toString();
1198
+ // Filter out MCP server logs for cleaner output
1199
+ if (!text.includes('[MCP]')) {
1200
+ console.error('[Docker stderr]', text);
1201
+ } else {
1202
+ console.log('[MCP]', text.trim());
1203
+ }
1204
+ });
1205
+
1206
+ child.on('error', (err) => {
1207
+ console.error('[Docker] spawn error:', err);
1208
+ reject(err);
1209
+ });
1210
+
1211
+ child.on('close', (code, signal) => {
1212
+ console.log('[Docker] Process closed - code:', code, 'signal:', signal);
1213
+
1214
+ // Process any remaining buffer
1215
+ if (jsonBuffer.trim()) {
1216
+ const json = parseJsonLine(jsonBuffer);
1217
+ if (json?.type === 'result') {
1218
+ finalResult = json.result || '';
1219
+ if (json.session_id) {
1220
+ capturedSessionId = json.session_id;
1221
+ }
1222
+ }
1223
+ }
1224
+
1225
+ if (code === 0) {
1226
+ // Save session binding if we captured a session_id
1227
+ if (capturedSessionId) {
1228
+ setChannelSession(channelId, capturedSessionId);
1229
+ }
1230
+ // Prefer final result, fall back to assistant texts
1231
+ const output = finalResult || assistantTexts.join('\n');
1232
+ resolve(output);
1233
+ } else if (signal) {
1234
+ reject(new Error(`Process killed: ${signal}`));
1235
+ } else {
1236
+ reject(new Error(`Process exited with code ${code}`));
1237
+ }
1238
+ });
1239
+
1240
+ // Store child process for potential abort
1241
+ if (task) {
1242
+ task.child = child;
1243
+ }
1244
+
1245
+ // Task timeout (configurable, default 1 hour)
1246
+ setTimeout(() => {
1247
+ if (!child.killed) {
1248
+ child.kill('SIGTERM');
1249
+ reject(new Error(`Timeout (${CLI_TASK_TIMEOUT / 60000} min)`));
1250
+ }
1251
+ }, CLI_TASK_TIMEOUT);
1252
+ });
1253
+
1254
+ // Delete status message
1255
+ await statusMsg.delete().catch(() => {});
1256
+
1257
+ // Parse response for file sends (convert container paths to host paths)
1258
+ const response = result.trim() || '(no output)';
1259
+ const fileMatches = response.matchAll(/\[SEND_FILE:(.+?)\]/g);
1260
+ const filesToSend = [];
1261
+
1262
+ for (const match of fileMatches) {
1263
+ let filePath = match[1].trim();
1264
+ // Convert container path to host path
1265
+ if (filePath.startsWith(CONTAINER_WORKSPACE + '/')) {
1266
+ filePath = filePath.replace(CONTAINER_WORKSPACE + '/', PROJECT_DIR + '/');
1267
+ }
1268
+ if (existsSync(filePath)) {
1269
+ filesToSend.push(filePath);
1270
+ }
1271
+ }
1272
+
1273
+ // Remove SEND_FILE tags from text
1274
+ const textResponse = response.replace(/\[SEND_FILE:.+?\]/g, '').trim();
1275
+
1276
+ // Send text response (split if needed, with retry)
1277
+ if (textResponse) {
1278
+ const chunkRegex = new RegExp(`[\\s\\S]{1,${MESSAGE_MAX_LENGTH}}`, 'g');
1279
+ const chunks = textResponse.match(chunkRegex) || [textResponse];
1280
+ for (const chunk of chunks) {
1281
+ let retries = 3;
1282
+ while (retries > 0) {
1283
+ try {
1284
+ await message.channel.send(chunk);
1285
+ break;
1286
+ } catch (sendErr) {
1287
+ retries--;
1288
+ console.error(`[Send] Failed (${retries} retries left):`, sendErr.message);
1289
+ if (retries > 0) await new Promise(r => setTimeout(r, RETRY_DELAY));
1290
+ }
1291
+ }
1292
+ }
1293
+ }
1294
+
1295
+ // Send files
1296
+ if (filesToSend.length > 0) {
1297
+ await message.channel.send({
1298
+ content: `${filesToSend.length} file(s)`,
1299
+ files: filesToSend.map(f => ({
1300
+ attachment: f,
1301
+ name: f.split('/').pop()
1302
+ }))
1303
+ });
1304
+ }
1305
+
1306
+ // Show "cooked for" summary at task end
1307
+ const finalTask = activeTasks.get(channelId);
1308
+ const cookedTime = formatElapsedTime(finalTask);
1309
+ if (cookedTime) {
1310
+ const perfSummary = finalTask?.perfMonitor ? formatMonitorSummary(finalTask.perfMonitor) : '';
1311
+ await message.channel.send(`⏱️ Cooked for ${cookedTime}${perfSummary ? ' | ' + perfSummary : ''}`);
1312
+ }
1313
+
1314
+ activeTasks.delete(channelId);
1315
+ // Clean up any pending permissions for this channel
1316
+ await cleanupPermissions(channelId, 'completed');
1317
+ await commands._processQueue(message, channelId);
1318
+
1319
+ } catch (err) {
1320
+ console.error('Claude CLI error:', err);
1321
+ // Try to send error message (statusMsg might be deleted)
1322
+ try {
1323
+ await statusMsg.edit(`Error: ${err.message}`);
1324
+ } catch (editErr) {
1325
+ // statusMsg was deleted, send new message
1326
+ await message.channel.send(`❌ Error: ${err.message}`);
1327
+ }
1328
+ activeTasks.delete(channelId);
1329
+ // Clean up any pending permissions for this channel
1330
+ await cleanupPermissions(channelId, 'encountered an error');
1331
+ await commands._processQueue(message, channelId);
1332
+ }
1333
+ },
1334
+
1335
+ async _processQueue(message, channelId) {
1336
+ const queue = messageQueues.get(channelId);
1337
+ if (!queue || queue.length === 0) return;
1338
+
1339
+ const queuedMessages = queue.splice(0, queue.length);
1340
+
1341
+ if (queuedMessages.length === 1) {
1342
+ await message.channel.send('Processing queued message...');
1343
+ await commands._processClaudeMessage(message, queuedMessages[0].content, channelId);
1344
+ } else {
1345
+ const combined = queuedMessages.map((m, i) => `[${i + 1}] ${m.content}`).join('\n\n');
1346
+ const prompt = `User sent ${queuedMessages.length} messages while you were busy:\n\n${combined}`;
1347
+ await message.channel.send(`Processing ${queuedMessages.length} queued messages...`);
1348
+ await commands._processClaudeMessage(message, prompt, channelId);
1349
+ }
1350
+ },
1351
+
1352
+ // Abort current task for a channel
1353
+ async abort(message) {
1354
+ const channelId = message.channel.id;
1355
+ const task = activeTasks.get(channelId);
1356
+
1357
+ if (!task) {
1358
+ await message.channel.send('No active task to abort');
1359
+ return;
1360
+ }
1361
+
1362
+ task.aborted = true;
1363
+ if (task.child && !task.child.killed) {
1364
+ task.child.kill('SIGTERM');
1365
+ await message.channel.send('Task aborted');
1366
+ }
1367
+ },
1368
+ };
1369
+
1370
+ // Process messages
1371
+ client.on(Events.MessageCreate, async (message) => {
1372
+ // DEBUG: Log ALL messages
1373
+ console.log(`[MSG] Channel: ${message.channel.id}, Author: ${message.author.tag}, Bot: ${message.author.bot}, Content: ${message.content.substring(0, 80)}`);
1374
+ console.log(`[MSG] Mentions: ${JSON.stringify(Array.from(message.mentions.users.keys()))}`);
1375
+
1376
+ if (message.author.bot) {
1377
+ console.log('[MSG] Ignored: author is bot');
1378
+ return;
1379
+ }
1380
+
1381
+ const content = message.content.trim();
1382
+ const isMentioned = message.mentions.has(client.user);
1383
+ const isDM = message.channel.type === 1;
1384
+
1385
+ console.log(`[MSG] isMentioned: ${isMentioned}, isDM: ${isDM}, clientUserId: ${client.user.id}`);
1386
+
1387
+ if (!isMentioned && !isDM) {
1388
+ console.log('[MSG] Ignored: not mentioned and not DM');
1389
+ return;
1390
+ }
1391
+
1392
+ if (ALLOWED_CHANNELS && !isDM) {
1393
+ if (!ALLOWED_CHANNELS.includes(message.channel.id)) {
1394
+ console.log('[MSG] Ignored: channel not allowed');
1395
+ return;
1396
+ }
1397
+ }
1398
+
1399
+ const cleanContent = content.replace(/<@!?\d+>/g, '').trim();
1400
+
1401
+ // Commands
1402
+ if (cleanContent.startsWith('/')) {
1403
+ const [cmd, ...args] = cleanContent.slice(1).split(/\s+/);
1404
+ const handler = commands[cmd.toLowerCase()];
1405
+
1406
+ if (handler) {
1407
+ try {
1408
+ await handler(message, args);
1409
+ } catch (err) {
1410
+ console.error(`Command error (${cmd}):`, err);
1411
+ await message.channel.send(`Error: ${err.message}`);
1412
+ }
1413
+ } else {
1414
+ await message.channel.send(`Unknown command: \`/${cmd}\`. Use \`/help\` for available commands.`);
1415
+ }
1416
+ return;
1417
+ }
1418
+
1419
+ // Default to claude
1420
+ if (cleanContent) {
1421
+ await commands.claude(message, [cleanContent]);
1422
+ } else {
1423
+ await commands.help(message);
1424
+ }
1425
+ });
1426
+
1427
+ // Ready event
1428
+ client.on(Events.ClientReady, () => {
1429
+ console.log(`AgentBridge logged in as ${client.user.tag}`);
1430
+ console.log(`Bot User ID: ${client.user.id}`);
1431
+ console.log(`Project: ${PROJECT_DIR}`);
1432
+ console.log(`Mode: Docker Sandbox + Hooks Permission`);
1433
+ console.log(`Container: ${CONTAINER_NAME}`);
1434
+ console.log(`OAuth: ${OAUTH_TOKEN ? 'configured' : 'not set'}`);
1435
+
1436
+ // Start persistent container
1437
+ ensureContainer();
1438
+ console.log(`Allowed Channels Config: ${ALLOWED_CHANNELS ? ALLOWED_CHANNELS.join(', ') : 'All'}`);
1439
+ console.log(`Guilds: ${client.guilds.cache.size}`);
1440
+ client.guilds.cache.forEach(guild => {
1441
+ console.log(` Guild: ${guild.name} (${guild.id})`);
1442
+ console.log(` Channels bot can see:`);
1443
+ guild.channels.cache.forEach(channel => {
1444
+ if (channel.type === 0) { // Text channel
1445
+ console.log(` - #${channel.name} (${channel.id})`);
1446
+ }
1447
+ });
1448
+ });
1449
+
1450
+ // Start permission watcher
1451
+ startPermissionWatcher();
1452
+
1453
+ console.log(`Ready! @${client.user.username} to interact`);
1454
+ });
1455
+
1456
+ // Error handling
1457
+ client.on(Events.Error, (error) => {
1458
+ console.error('Discord client error:', error);
1459
+ });
1460
+
1461
+ // Debug: log all raw events
1462
+ client.on('raw', (event) => {
1463
+ if (event.t === 'MESSAGE_CREATE') {
1464
+ console.log('[RAW] MESSAGE_CREATE received:', event.d?.content?.substring(0, 50));
1465
+ }
1466
+ });
1467
+
1468
+ // Debug: connection status
1469
+ client.on('debug', (info) => {
1470
+ if (info.includes('Heartbeat') || info.includes('Session')) {
1471
+ console.log('[DEBUG]', info);
1472
+ }
1473
+ });
1474
+
1475
+ client.on('warn', (info) => {
1476
+ console.warn('[WARN]', info);
1477
+ });
1478
+
1479
+ client.on('disconnect', () => {
1480
+ console.error('[DISCONNECT] Bot disconnected from Discord');
1481
+ });
1482
+
1483
+ client.on('reconnecting', () => {
1484
+ console.log('[RECONNECTING] Bot reconnecting...');
1485
+ });
1486
+
1487
+ process.on('unhandledRejection', (error) => {
1488
+ console.error('Unhandled rejection:', error);
1489
+ });
1490
+
1491
+ // Error handling - prevent crashes on network issues
1492
+ client.on('error', (e) => {
1493
+ console.error('[Client] Error:', e.message);
1494
+ });
1495
+
1496
+ client.on('shardError', (e) => {
1497
+ console.error('[Shard] Error:', e.message);
1498
+ });
1499
+
1500
+ client.on('shardDisconnect', (e, shardId) => {
1501
+ console.log(`[Shard ${shardId}] Disconnected, will auto-reconnect...`);
1502
+ });
1503
+
1504
+ client.on('shardReconnecting', (shardId) => {
1505
+ console.log(`[Shard ${shardId}] Reconnecting...`);
1506
+ });
1507
+
1508
+ client.on('shardResume', (shardId) => {
1509
+ console.log(`[Shard ${shardId}] Resumed`);
1510
+ });
1511
+
1512
+ process.on('unhandledRejection', (e) => {
1513
+ console.error('[Process] Unhandled rejection:', e);
1514
+ });
1515
+
1516
+ // Start
1517
+ console.log('Starting AgentBridge...');
1518
+ client.login(BOT_TOKEN);