agent-window 1.3.9 → 1.4.1

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.
@@ -0,0 +1,148 @@
1
+ /**
2
+ * BMAD Participant Manager
3
+ *
4
+ * Manages active experts per Discord channel (meeting room metaphor)
5
+ */
6
+
7
+ import { getDefaultExpert, getExpert } from './experts.js';
8
+
9
+ /**
10
+ * Participant Manager class
11
+ * Tracks active experts per channel with Master fallback
12
+ */
13
+ export class ParticipantManager {
14
+ constructor() {
15
+ // Map<channelId, Map<expertName, expert>>
16
+ this.channels = new Map();
17
+ }
18
+
19
+ /**
20
+ * Get participants for a channel
21
+ * @param {string} channelId - Discord channel ID
22
+ * @returns {Array} Array of expert objects in the channel
23
+ */
24
+ getParticipants(channelId) {
25
+ const channel = this.channels.get(channelId);
26
+ if (!channel) return [];
27
+ return Array.from(channel.values());
28
+ }
29
+
30
+ /**
31
+ * Check if channel has any participants
32
+ * @param {string} channelId - Discord channel ID
33
+ * @returns {boolean} True if channel has participants
34
+ */
35
+ hasParticipants(channelId) {
36
+ const channel = this.channels.get(channelId);
37
+ return channel && channel.size > 0;
38
+ }
39
+
40
+ /**
41
+ * Add participant to channel
42
+ * @param {string} channelId - Discord channel ID
43
+ * @param {Object} expert - Expert object to add
44
+ * @returns {Object} { added: boolean }
45
+ */
46
+ addParticipant(channelId, expert) {
47
+ if (!expert || !expert.name) return { added: false };
48
+
49
+ if (!this.channels.has(channelId)) {
50
+ this.channels.set(channelId, new Map());
51
+ }
52
+
53
+ const channel = this.channels.get(channelId);
54
+
55
+ if (channel.has(expert.name)) {
56
+ return { added: false }; // Already in room
57
+ }
58
+
59
+ channel.set(expert.name, expert);
60
+ console.log(`[BMAD] Added ${expert.displayName || expert.name} to channel ${channelId}`);
61
+ return { added: true };
62
+ }
63
+
64
+ /**
65
+ * Remove participant from channel
66
+ * Enforces Master fallback rule: room cannot be empty
67
+ * @param {string} channelId - Discord channel ID
68
+ * @param {string} expertName - Expert name to remove
69
+ * @returns {Object} { removed: boolean, fallback: boolean, message: string }
70
+ */
71
+ removeParticipant(channelId, expertName) {
72
+ const channel = this.channels.get(channelId);
73
+ if (!channel) {
74
+ return { removed: false, fallback: false, message: 'Channel has no participants' };
75
+ }
76
+
77
+ if (!channel.has(expertName)) {
78
+ return { removed: false, fallback: false, message: 'Expert not in room' };
79
+ }
80
+
81
+ // Check if this is the last participant
82
+ if (channel.size === 1) {
83
+ const master = getDefaultExpert();
84
+
85
+ // If trying to remove Master and they're the only one
86
+ if (channel.has(master?.name)) {
87
+ return {
88
+ removed: false,
89
+ fallback: false,
90
+ message: 'Cannot dismiss Master - room cannot be empty'
91
+ };
92
+ }
93
+
94
+ // Remove the expert but add Master
95
+ channel.delete(expertName);
96
+ if (master) {
97
+ channel.set(master.name, master);
98
+ console.log(`[BMAD] Removed last expert, Master stays in channel ${channelId}`);
99
+ return {
100
+ removed: true,
101
+ fallback: true,
102
+ message: `Expert left. Master stays to keep the room open.`
103
+ };
104
+ }
105
+ }
106
+
107
+ // Normal removal
108
+ const expert = channel.get(expertName);
109
+ channel.delete(expertName);
110
+ console.log(`[BMAD] Removed ${expert?.displayName || expertName} from channel ${channelId}`);
111
+ return { removed: true, fallback: false, message: 'Expert left the room' };
112
+ }
113
+
114
+ /**
115
+ * Ensure Master is in the channel (add if not present)
116
+ * @param {string} channelId - Discord channel ID
117
+ * @returns {Object} Master expert object
118
+ */
119
+ ensureMaster(channelId) {
120
+ const master = getDefaultExpert();
121
+ if (!master) {
122
+ console.warn('[BMAD] Default expert (Master) not found in manifest');
123
+ return null;
124
+ }
125
+
126
+ this.addParticipant(channelId, master);
127
+ return master;
128
+ }
129
+
130
+ /**
131
+ * Clear all participants from a channel
132
+ * @param {string} channelId - Discord channel ID
133
+ */
134
+ clearChannel(channelId) {
135
+ this.channels.delete(channelId);
136
+ }
137
+
138
+ /**
139
+ * Get channel count
140
+ * @returns {number} Number of active channels
141
+ */
142
+ getActiveChannelCount() {
143
+ return this.channels.size;
144
+ }
145
+ }
146
+
147
+ // Singleton instance
148
+ export const participantManager = new ParticipantManager();
package/src/bot.js CHANGED
@@ -21,15 +21,26 @@
21
21
 
22
22
  import { Client, GatewayIntentBits, Partials, Events, ActionRowBuilder, ButtonBuilder, ButtonStyle } from 'discord.js';
23
23
  import { spawn, execSync } from 'child_process';
24
- import { readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync, mkdirSync } from 'fs';
24
+ import { readFileSync, writeFileSync, existsSync, readdirSync, unlinkSync, mkdirSync, chmodSync } from 'fs';
25
25
  import { join } from 'path';
26
-
27
- // Import centralized configuration
26
+ import { homedir } from 'os';
27
+ import { https } from 'https';
28
+ import { http } from 'http';
29
+ import { createWriteStream } from 'fs';
30
+
31
+ // Import performance monitoring utilities (local)
32
+ import {
33
+ createMonitor,
34
+ formatMonitorSummary
35
+ } from './core/perf-monitor.js';
36
+
37
+ import {
38
+ syncMCPConfigLogged
39
+ } from './core/mcp-sync.js';
40
+
41
+ // Import centralized configuration (local, project-specific)
28
42
  import config from './core/config.js';
29
43
 
30
- // Import performance monitoring
31
- import { createMonitor, formatMonitorSummary } from './core/perf-monitor.js';
32
-
33
44
  // Extract commonly used config values for convenience
34
45
  const BOT_TOKEN = config.discord.token;
35
46
  const PROJECT_DIR = config.workspace.projectDir;
@@ -38,6 +49,7 @@ const ALLOWED_CHANNELS = config.discord.allowedChannels;
38
49
  const CHANNEL_SESSIONS_FILE = config.paths.sessions;
39
50
  const PENDING_DIR = config.paths.pending;
40
51
  const HOOK_DIR = config.paths.hooks;
52
+ const USE_DOCKER = config.workspace.useDocker; // Whether to use Docker or run locally
41
53
  const CONTAINER_NAME = config.workspace.containerName;
42
54
  const DOCKER_IMAGE = config.workspace.dockerImage;
43
55
 
@@ -47,6 +59,8 @@ const CONTAINER_CONFIG_DIR = config.docker.containerPaths.configDir;
47
59
  const CONTAINER_HOOK_DIR = config.docker.containerPaths.hookDir;
48
60
  const CONTAINER_PENDING_DIR = config.docker.containerPaths.pendingDir;
49
61
  const CONTAINER_SETTINGS_FILE = config.docker.containerPaths.settingsFile;
62
+ const CONTAINER_UPLOADS_DIR = config.docker.containerPaths.uploadsDir;
63
+ const UPLOADS_DIR = config.paths.uploads;
50
64
  const PORT_MAPPINGS = config.workspace.portMappings;
51
65
  const CLI_MAX_TURNS = config.cli.maxTurns;
52
66
  const CLI_COMMAND = config.cli.command;
@@ -105,23 +119,231 @@ function updateHealthStatus(component, status) {
105
119
 
106
120
  // Get overall health status
107
121
  function getOverallHealth() {
108
- const checks = [
109
- healthStatus.pm2 ? '✓' : '✗',
110
- healthStatus.discord ? '✓' : '✗',
111
- healthStatus.docker ? '✓' : '✗'
112
- ].join(' ');
113
-
114
- if (healthStatus.pm2 && healthStatus.discord && healthStatus.docker) {
115
- return 'healthy';
116
- } else if (healthStatus.pm2 && healthStatus.discord) {
117
- return 'degraded'; // Running but Docker failed
118
- } else if (healthStatus.pm2) {
119
- return 'unhealthy'; // PM2 running but Discord disconnected
122
+ // Build health check string
123
+ const checks = [healthStatus.pm2 ? '✓' : '✗', healthStatus.discord ? '✓' : '✗'];
124
+
125
+ // Only check Docker if enabled
126
+ if (USE_DOCKER) {
127
+ checks.push(healthStatus.docker ? '✓' : '✗');
128
+ }
129
+
130
+ const healthString = checks.join(' ');
131
+
132
+ // Determine overall status
133
+ if (USE_DOCKER) {
134
+ // Docker mode: all components must be healthy
135
+ if (healthStatus.pm2 && healthStatus.discord && healthStatus.docker) {
136
+ return 'healthy';
137
+ } else if (healthStatus.pm2 && healthStatus.discord) {
138
+ return 'degraded'; // Running but Docker failed
139
+ } else if (healthStatus.pm2) {
140
+ return 'unhealthy'; // PM2 running but Discord disconnected
141
+ } else {
142
+ return 'failed';
143
+ }
120
144
  } else {
121
- return 'failed';
145
+ // Non-Docker mode: only PM2 and Discord required
146
+ if (healthStatus.pm2 && healthStatus.discord) {
147
+ return 'healthy';
148
+ } else if (healthStatus.pm2) {
149
+ return 'unhealthy'; // PM2 running but Discord disconnected
150
+ } else {
151
+ return 'failed';
152
+ }
122
153
  }
123
154
  }
124
155
 
156
+ // ============================================================================
157
+ // File Attachment Handling
158
+ // ============================================================================
159
+
160
+ // Supported file types for processing
161
+ const SUPPORTED_FILE_TYPES = {
162
+ images: ['.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg'],
163
+ videos: ['.mp4', '.mov', '.webm', '.avi', '.mkv'],
164
+ documents: ['.pdf', '.doc', '.docx', '.txt', '.md', '.json', '.yaml', '.yml', '.xml', '.csv'],
165
+ code: ['.js', '.ts', '.py', '.java', '.c', '.cpp', '.h', '.go', '.rs', '.rb', '.php', '.sh', '.bat'],
166
+ archives: ['.zip', '.tar', '.gz', '.7z', '.rar'],
167
+ };
168
+
169
+ // Download file from URL to local path
170
+ function downloadFile(url, destPath) {
171
+ return new Promise((resolve, reject) => {
172
+ const protocol = url.startsWith('https') ? https : http;
173
+ const file = createWriteStream(destPath);
174
+
175
+ protocol.get(url, {
176
+ headers: {
177
+ 'User-Agent': 'DiscordBot (https://discord.js.org)'
178
+ }
179
+ }, (response) => {
180
+ if (response.statusCode === 302 || response.statusCode === 301) {
181
+ // Follow redirect
182
+ downloadFile(response.headers.location, destPath)
183
+ .then(resolve)
184
+ .catch(reject);
185
+ return;
186
+ }
187
+
188
+ if (response.statusCode !== 200) {
189
+ reject(new Error(`Failed to download: ${response.statusCode}`));
190
+ return;
191
+ }
192
+
193
+ response.pipe(file);
194
+
195
+ file.on('finish', () => {
196
+ file.close();
197
+ resolve(destPath);
198
+ });
199
+
200
+ file.on('error', (err) => {
201
+ unlinkSync(destPath);
202
+ reject(err);
203
+ });
204
+ }).on('error', (err) => {
205
+ unlinkSync(destPath);
206
+ reject(err);
207
+ });
208
+ });
209
+ }
210
+
211
+ // Process Discord message attachments
212
+ async function processAttachments(message, channelId) {
213
+ if (!message.attachments || message.attachments.size === 0) {
214
+ return [];
215
+ }
216
+
217
+ const uploadedFiles = [];
218
+
219
+ // Ensure uploads directory exists
220
+ const date = new Date();
221
+ const dateStr = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`;
222
+ const instanceUploadDir = join(UPLOADS_DIR, CONTAINER_NAME, dateStr, channelId);
223
+
224
+ try {
225
+ mkdirSync(instanceUploadDir, { recursive: true });
226
+ } catch (e) {
227
+ console.error('[Upload] Failed to create upload directory:', e);
228
+ return [];
229
+ }
230
+
231
+ console.log(`[Upload] Processing ${message.attachments.size} attachment(s)`);
232
+
233
+ for (const [attachmentId, attachment] of message.attachments) {
234
+ try {
235
+ const url = attachment.url;
236
+ const filename = attachment.name;
237
+ const filesize = attachment.size;
238
+
239
+ console.log(`[Upload] Processing: ${filename} (${Math.round(filesize / 1024)}KB)`);
240
+
241
+ // Check file size (max 50MB)
242
+ if (filesize > 50 * 1024 * 1024) {
243
+ console.warn(`[Upload] Skipping ${filename}: file too large (${Math.round(filesize / 1024 / 1024)}MB)`);
244
+ await message.channel.send(`⚠️ File \`${filename}\` is too large (max 50MB)`);
245
+ continue;
246
+ }
247
+
248
+ // Determine file type
249
+ const ext = filename.toLowerCase().substring(filename.lastIndexOf('.'));
250
+ const fileType = Object.entries(SUPPORTED_FILE_TYPES).find(([_, exts]) => exts.includes(ext));
251
+
252
+ if (!fileType) {
253
+ console.warn(`[Upload] Skipping ${filename}: unsupported file type`);
254
+ await message.channel.send(`⚠️ Unsupported file type: \`${filename}\``);
255
+ continue;
256
+ }
257
+
258
+ const [type, _] = fileType;
259
+ const destPath = join(instanceUploadDir, filename);
260
+
261
+ // Download file
262
+ console.log(`[Upload] Downloading ${url} to ${destPath}`);
263
+ await downloadFile(url, destPath);
264
+
265
+ // Make file readable
266
+ try {
267
+ chmodSync(destPath, 0o644);
268
+ } catch (e) {
269
+ // Ignore
270
+ }
271
+
272
+ // Calculate container path
273
+ const relativePath = destPath.replace(UPLOADS_DIR, '').replace(/^\//, '');
274
+ const containerPath = join(CONTAINER_UPLOADS_DIR, CONTAINER_NAME, dateStr, channelId, filename);
275
+
276
+ uploadedFiles.push({
277
+ hostPath: destPath,
278
+ containerPath: containerPath,
279
+ filename: filename,
280
+ type: type,
281
+ size: filesize,
282
+ url: url,
283
+ description: attachment.description || null,
284
+ });
285
+
286
+ console.log(`[Upload] ✓ Saved: ${filename} (${type})`);
287
+ } catch (err) {
288
+ console.error(`[Upload] Failed to process attachment:`, err);
289
+ await message.channel.send(`❌ Failed to download \`${attachment.name}\`: ${err.message}`);
290
+ }
291
+ }
292
+
293
+ return uploadedFiles;
294
+ }
295
+
296
+ // Format file information for Claude prompt
297
+ function formatFilesForPrompt(uploadedFiles) {
298
+ if (uploadedFiles.length === 0) {
299
+ return '';
300
+ }
301
+
302
+ const lines = [
303
+ '',
304
+ '--- ATTACHED FILES ---',
305
+ ];
306
+
307
+ uploadedFiles.forEach((file, idx) => {
308
+ const sizeKB = Math.round(file.size / 1024);
309
+ const sizeMB = (file.size / 1024 / 1024).toFixed(2);
310
+
311
+ let sizeStr = sizeKB > 1024 ? `${sizeMB}MB` : `${sizeKB}KB`;
312
+
313
+ lines.push(`${idx + 1}. **${file.filename}** (${file.type}, ${sizeStr})`);
314
+ lines.push(` - Container path: \`${file.containerPath}\``);
315
+
316
+ if (file.description) {
317
+ lines.push(` - Description: ${file.description}`);
318
+ }
319
+
320
+ // Add specific instructions based on file type
321
+ if (file.type === 'images') {
322
+ lines.push(` - ⚠️ This is an image file. You can view it using the Read tool or analyze it with vision capabilities.`);
323
+ } else if (file.type === 'videos') {
324
+ lines.push(` - ⚠️ This is a video file. You may need to extract frames or use video analysis tools.`);
325
+ } else if (file.type === 'documents') {
326
+ lines.push(` - ⚠️ This is a document. You can read it with the Read tool for analysis.`);
327
+ } else if (file.type === 'code') {
328
+ lines.push(` - ⚠️ This is a code file. You can review and analyze it using Read and Grep tools.`);
329
+ }
330
+
331
+ lines.push('');
332
+ });
333
+
334
+ lines.push('--- END OF ATTACHED FILES ---');
335
+ lines.push('');
336
+
337
+ return lines.join('\n');
338
+ }
339
+
340
+ // Ensure uploads directory exists
341
+ try {
342
+ mkdirSync(UPLOADS_DIR, { recursive: true });
343
+ } catch (e) {
344
+ console.error('[Upload] Failed to create uploads directory:', e);
345
+ }
346
+
125
347
  // Ensure pending directory exists
126
348
  try {
127
349
  mkdirSync(PENDING_DIR, { recursive: true });
@@ -142,6 +364,13 @@ function isContainerRunning() {
142
364
 
143
365
  // Start or ensure persistent container is running
144
366
  function ensureContainer() {
367
+ // Skip Docker operations if not using Docker
368
+ if (!USE_DOCKER) {
369
+ console.log('[Docker] Docker is disabled (useDocker: false). Running in local mode.');
370
+ updateHealthStatus('docker', true); // Mark as "healthy" since we don't need Docker
371
+ return true;
372
+ }
373
+
145
374
  // Log the paths being used
146
375
  console.log('[Docker] PENDING_DIR (host):', PENDING_DIR);
147
376
  console.log('[Docker] HOOK_DIR (host):', HOOK_DIR);
@@ -193,6 +422,7 @@ function ensureContainer() {
193
422
  '-v', `${config.backend.configDir}:${CONTAINER_CONFIG_DIR}:rw`,
194
423
  '-v', `${HOOK_DIR}:${CONTAINER_HOOK_DIR}:ro`,
195
424
  '-v', `${PENDING_DIR}:${CONTAINER_PENDING_DIR}:rw`,
425
+ '-v', `${UPLOADS_DIR}:${CONTAINER_UPLOADS_DIR}:rw`,
196
426
  `-e 'CLAUDE_CODE_OAUTH_TOKEN=${OAUTH_TOKEN || ''}'`,
197
427
  envArgs,
198
428
  '--entrypoint', 'tail',
@@ -202,6 +432,10 @@ function ensureContainer() {
202
432
 
203
433
  execSync(dockerCmd);
204
434
  console.log('[Docker] Container started successfully');
435
+
436
+ // Sync MCP configuration from host to container
437
+ syncMCPConfig();
438
+
205
439
  updateHealthStatus('docker', true);
206
440
  return true;
207
441
  } catch (e) {
@@ -228,6 +462,11 @@ function ensureContainer() {
228
462
 
229
463
  // Stop persistent container (call on bot shutdown)
230
464
  function stopContainer() {
465
+ if (!USE_DOCKER) {
466
+ console.log('[Docker] Docker is disabled. No container to stop.');
467
+ return;
468
+ }
469
+
231
470
  try {
232
471
  execSync(`docker stop ${CONTAINER_NAME} 2>/dev/null`);
233
472
  execSync(`docker rm ${CONTAINER_NAME} 2>/dev/null`);
@@ -235,6 +474,11 @@ function stopContainer() {
235
474
  } catch (e) {}
236
475
  }
237
476
 
477
+ // Sync MCP configuration (wrapper for npm package function)
478
+ function syncMCPConfig() {
479
+ syncMCPConfigLogged(CONTAINER_NAME, PENDING_DIR, CONTAINER_PENDING_DIR, DOCKER_CHECK_TIMEOUT);
480
+ }
481
+
238
482
  // Cleanup on exit
239
483
  process.on('SIGINT', () => {
240
484
  console.log('Shutting down...');
@@ -1011,13 +1255,19 @@ const commands = {
1011
1255
 
1012
1256
  async claude(message, args) {
1013
1257
  const userMessage = args.join(' ').trim();
1014
- if (!userMessage) {
1258
+ const channelId = message.channel.id;
1259
+
1260
+ // Process attachments first
1261
+ const uploadedFiles = await processAttachments(message, channelId);
1262
+
1263
+ // If no text message but have attachments, provide a default message
1264
+ const actualMessage = userMessage || (uploadedFiles.length > 0 ? 'Please analyze the attached file(s)' : '');
1265
+
1266
+ if (!actualMessage) {
1015
1267
  await commands.help(message);
1016
1268
  return;
1017
1269
  }
1018
1270
 
1019
- const channelId = message.channel.id;
1020
-
1021
1271
  // Check if there's an active task
1022
1272
  if (activeTasks.has(channelId)) {
1023
1273
  const task = activeTasks.get(channelId);
@@ -1078,7 +1328,7 @@ const commands = {
1078
1328
  messageQueues.delete(channelId);
1079
1329
 
1080
1330
  // Start new task
1081
- await commands._processClaudeMessage(message, userMessage, channelId);
1331
+ await commands._processClaudeMessage(message, actualMessage, channelId, uploadedFiles);
1082
1332
  return;
1083
1333
  }
1084
1334
 
@@ -1086,16 +1336,16 @@ const commands = {
1086
1336
  if (!messageQueues.has(channelId)) {
1087
1337
  messageQueues.set(channelId, []);
1088
1338
  }
1089
- messageQueues.get(channelId).push({ content: userMessage, timestamp: Date.now() });
1339
+ messageQueues.get(channelId).push({ content: actualMessage, timestamp: Date.now(), files: uploadedFiles });
1090
1340
  const queueSize = messageQueues.get(channelId).length;
1091
1341
  await message.channel.send(`📥 Queued (#${queueSize}) - will process after current task`);
1092
1342
  return;
1093
1343
  }
1094
1344
 
1095
- await commands._processClaudeMessage(message, userMessage, channelId);
1345
+ await commands._processClaudeMessage(message, actualMessage, channelId, uploadedFiles);
1096
1346
  },
1097
1347
 
1098
- async _processClaudeMessage(message, userMessage, channelId) {
1348
+ async _processClaudeMessage(message, userMessage, channelId, uploadedFiles = []) {
1099
1349
  // Create stop button
1100
1350
  const stopButton = new ActionRowBuilder()
1101
1351
  .addComponents(
@@ -1131,6 +1381,13 @@ const commands = {
1131
1381
  // Parse flags
1132
1382
  let actualMessage = userMessage;
1133
1383
 
1384
+ // Add file information to the message if there are attachments
1385
+ if (uploadedFiles.length > 0) {
1386
+ const fileInfo = formatFilesForPrompt(uploadedFiles);
1387
+ actualMessage = fileInfo + userMessage;
1388
+ console.log(`[Claude] Added ${uploadedFiles.length} file(s) to prompt`);
1389
+ }
1390
+
1134
1391
  // Base CLI args with PreToolUse hook for permission control
1135
1392
  // Hook handles: auto-approve safe tools, request Discord approval for risky tools
1136
1393
  const cliArgs = [
@@ -1139,6 +1396,7 @@ const commands = {
1139
1396
  '--verbose',
1140
1397
  '--max-turns', String(CLI_MAX_TURNS),
1141
1398
  '--settings', CONTAINER_SETTINGS_FILE,
1399
+ '--model', config.cli.model, // Add model selection
1142
1400
  ];
1143
1401
 
1144
1402
  // Add model selection (defaults to opus)
@@ -1217,21 +1475,42 @@ const commands = {
1217
1475
  let lastStatusUpdate = 0;
1218
1476
  const task = activeTasks.get(channelId);
1219
1477
 
1220
- // Use docker exec to run CLI in the persistent container
1221
- const dockerArgs = [
1222
- 'exec', '-i',
1223
- CONTAINER_NAME,
1224
- CLI_COMMAND,
1225
- ...cliArgs
1226
- ];
1478
+ // Execute CLI command (either in Docker or locally based on configuration)
1479
+ let child;
1227
1480
 
1228
- console.log('[Docker] Executing in container:', CONTAINER_NAME);
1481
+ if (USE_DOCKER) {
1482
+ // Use docker exec to run CLI in the persistent container
1483
+ const dockerArgs = [
1484
+ 'exec', '-i',
1485
+ CONTAINER_NAME,
1486
+ CLI_COMMAND,
1487
+ ...cliArgs
1488
+ ];
1229
1489
 
1230
- const child = spawn('docker', dockerArgs, {
1231
- stdio: ['pipe', 'pipe', 'pipe'],
1232
- });
1490
+ console.log('[Docker] Executing in container:', CONTAINER_NAME);
1233
1491
 
1234
- child.stdin.end();
1492
+ child = spawn('docker', dockerArgs, {
1493
+ stdio: ['pipe', 'pipe', 'pipe'],
1494
+ });
1495
+
1496
+ child.stdin.end();
1497
+ } else {
1498
+ // Run CLI directly on local host
1499
+ console.log('[Local] Executing locally:', CLI_COMMAND, ...cliArgs);
1500
+
1501
+ child = spawn(CLI_COMMAND, cliArgs, {
1502
+ stdio: ['pipe', 'pipe', 'pipe'],
1503
+ cwd: PROJECT_DIR,
1504
+ env: {
1505
+ ...process.env,
1506
+ CLAUDE_CODE_OAUTH_TOKEN: OAUTH_TOKEN || '',
1507
+ HOME: process.env.HOME,
1508
+ PATH: process.env.PATH
1509
+ }
1510
+ });
1511
+
1512
+ child.stdin.end();
1513
+ }
1235
1514
 
1236
1515
  // Update status periodically (with stop button)
1237
1516
  // forceUpdate=true bypasses throttle for important state changes
@@ -1616,9 +1895,14 @@ client.on(Events.ClientReady, async () => {
1616
1895
  // Send startup notification to Discord channels
1617
1896
  try {
1618
1897
  const uptime = Math.floor((Date.now() - healthStatus.startTime) / 1000);
1619
- const dockerStatus = healthStatus.docker ? '✓ Connected' : '✗ Not Available';
1620
1898
  const overallStatus = overallHealth.toUpperCase();
1621
1899
 
1900
+ // Build execution mode info
1901
+ const execMode = USE_DOCKER ? 'Docker Container' : 'Local Host';
1902
+ const dockerStatus = USE_DOCKER
1903
+ ? (healthStatus.docker ? '✓ Connected' : '✗ Not Available')
1904
+ : '⊘ Disabled (Local Mode)';
1905
+
1622
1906
  // Build startup message
1623
1907
  const startupMessage = {
1624
1908
  embeds: [{
@@ -1630,6 +1914,11 @@ client.on(Events.ClientReady, async () => {
1630
1914
  value: overallStatus,
1631
1915
  inline: true
1632
1916
  },
1917
+ {
1918
+ name: 'Execution Mode',
1919
+ value: execMode,
1920
+ inline: true
1921
+ },
1633
1922
  {
1634
1923
  name: 'Docker',
1635
1924
  value: dockerStatus,
@@ -1642,7 +1931,9 @@ client.on(Events.ClientReady, async () => {
1642
1931
  },
1643
1932
  {
1644
1933
  name: 'Components',
1645
- value: `PM2: ✓\nDiscord: ✓\nDocker: ${healthStatus.docker ? '✓' : '✗'}`,
1934
+ value: USE_DOCKER
1935
+ ? `PM2: ✓\nDiscord: ✓\nDocker: ${healthStatus.docker ? '✓' : '✗'}`
1936
+ : `PM2: ✓\nDiscord: ✓\nDocker: ⊘ (Local Mode)`,
1646
1937
  inline: false
1647
1938
  }
1648
1939
  ],