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/Dockerfile +23 -0
- package/README.md +138 -0
- package/SECURITY.md +31 -0
- package/bin/cli.js +743 -0
- package/config/config.example.json +70 -0
- package/docker-compose.yml +31 -0
- package/docs/legacy/DEVELOPMENT.md +174 -0
- package/docs/legacy/HANDOVER.md +149 -0
- package/ecosystem.config.cjs +26 -0
- package/hooks/hook.mjs +299 -0
- package/hooks/settings.json +15 -0
- package/package.json +45 -0
- package/sandbox/Dockerfile +61 -0
- package/scripts/install.sh +114 -0
- package/src/bot.js +1518 -0
- package/src/core/config.js +195 -0
- package/src/core/perf-monitor.js +360 -0
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);
|