dis-gravity 1.1.0 → 1.2.5

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.
@@ -1,702 +1,1199 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * Dis-Gravity Standalone CLI Daemon
5
- * Run Dis-Gravity as an editor-agnostic background service for JetBrains IDEs,
6
- * Neovim, Emacs, Zed, or headless remote servers.
7
- */
8
-
9
- const fs = require('fs');
10
- const path = require('path');
11
- const os = require('os');
12
- const cp = require('child_process');
13
-
14
- function parseArgs() {
15
- const args = process.argv.slice(2);
16
- const config = {
17
- token: process.env.DISCORD_BOT_TOKEN || '',
18
- channelId: process.env.DISCORD_CHANNEL_ID || '',
19
- guildId: process.env.DISCORD_GUILD_ID || '',
20
- allowedUsers: (process.env.DISCORD_ALLOWED_USERS || '').split(',').map(s => s.trim()).filter(Boolean),
21
- provider: process.env.DISCORD_AGENT_PROVIDER || 'cli',
22
- debugPort: parseInt(process.env.DEBUG_PORT || '9000', 10),
23
- workspace: process.cwd(),
24
- tableStyle: process.env.TABLE_STYLE || 'image',
25
- autoAttachTableImages: true
26
- };
27
-
28
- for (let i = 0; i < args.length; i++) {
29
- const arg = args[i];
30
- if (arg === '--help' || arg === '-h') {
31
- printHelp();
32
- process.exit(0);
33
- }
34
- if (arg === '--token' || arg === '-t') config.token = args[++i];
35
- else if (arg.startsWith('--token=')) config.token = arg.split('=')[1];
36
- else if (arg === '--channel' || arg === '-c') config.channelId = args[++i];
37
- else if (arg.startsWith('--channel=')) config.channelId = arg.split('=')[1];
38
- else if (arg === '--guild' || arg === '-g') config.guildId = args[++i];
39
- else if (arg.startsWith('--guild=')) config.guildId = arg.split('=')[1];
40
- else if (arg === '--provider' || arg === '-p') config.provider = args[++i];
41
- else if (arg.startsWith('--provider=')) config.provider = arg.split('=')[1];
42
- else if (arg === '--port') config.debugPort = parseInt(args[++i], 10);
43
- else if (arg.startsWith('--port=')) config.debugPort = parseInt(arg.split('=')[1], 10);
44
- else if (arg === '--workspace' || arg === '-w') config.workspace = path.resolve(args[++i]);
45
- else if (arg.startsWith('--workspace=')) config.workspace = path.resolve(arg.split('=')[1]);
46
- else if (arg === '--allowed-users' || arg === '-u') {
47
- config.allowedUsers = args[++i].split(',').map(s => s.trim()).filter(Boolean);
48
- } else if (arg.startsWith('--allowed-users=')) {
49
- config.allowedUsers = arg.split('=')[1].split(',').map(s => s.trim()).filter(Boolean);
50
- } else if (arg === '--table-style') config.tableStyle = args[++i];
51
- }
52
-
53
- return config;
54
- }
55
-
56
- function printHelp() {
57
- console.log(`
58
- ╔════════════════════════════════════════════════════════════════════════╗
59
- ║ Dis-Gravity Standalone CLI Daemon ║
60
- ║ The Ultimate Discord Bridge for JetBrains, Neovim & Headless ║
61
- ╚════════════════════════════════════════════════════════════════════════╝
62
-
63
- Usage:
64
- npx dis-gravity [options]
65
- node bin/dis-gravity.js [options]
66
-
67
- Options:
68
- -t, --token <string> Discord Bot Token (env: DISCORD_BOT_TOKEN)
69
- -c, --channel <string> Discord Channel ID to bind (env: DISCORD_CHANNEL_ID)
70
- -g, --guild <string> Discord Guild ID (optional, for instant slash commands)
71
- -p, --provider <string> AI Provider: 'cli', 'antigravity', 'cursor', 'windsurf' (default: cli)
72
- -w, --workspace <path> Workspace folder path (default: current directory)
73
- -u, --allowed-users <ids> Comma-separated Discord User IDs allowed to execute commands
74
- --port <number> CDP port for Antigravity/Cursor/Windsurf (default: 9000)
75
- --table-style <style> Table formatting: image, cards, rounded, boxed, ascii (default: image)
76
- -h, --help Show this help menu
77
-
78
- Examples:
79
- npx dis-gravity --token="BOT_TOKEN" --channel="123456789" --provider=cli
80
- npx dis-gravity --provider=antigravity --port=9000
81
- `);
82
- }
83
-
84
- // Minimal logger
85
- const log = {
86
- appendLine: (msg) => {
87
- const ts = new Date().toISOString().substring(11, 19);
88
- console.log(`[${ts}] ${msg}`);
89
- }
90
- };
91
-
92
- function executeShellCommand(cmdStr, cwd, timeoutMs = 30000) {
93
- return new Promise((resolve) => {
94
- const startTime = Date.now();
95
- cp.exec(cmdStr, { cwd, timeout: timeoutMs, maxBuffer: 1024 * 1024 * 10 }, (err, stdout, stderr) => {
96
- const duration = ((Date.now() - startTime) / 1000).toFixed(1);
97
- resolve({
98
- code: err ? (err.code || 1) : 0,
99
- stdout: stdout || "",
100
- stderr: stderr || "",
101
- duration,
102
- error: err ? err.message : null
103
- });
104
- });
105
- });
106
- }
107
-
108
- // Find headless browser for table card rendering
109
- function findHeadlessBrowser() {
110
- if (process.platform === 'win32') {
111
- const candidates = [
112
- 'C:\\Program Files (x86)\\Microsoft\\Edge\\Application\\msedge.exe',
113
- 'C:\\Program Files\\Microsoft\\Edge\\Application\\msedge.exe',
114
- 'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
115
- 'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe'
116
- ];
117
- for (const c of candidates) {
118
- if (fs.existsSync(c)) return c;
119
- }
120
- } else {
121
- const candidates = ['google-chrome', 'chromium', 'chromium-browser', 'microsoft-edge'];
122
- for (const c of candidates) {
123
- try {
124
- cp.execSync(`which ${c}`, { stdio: 'ignore' });
125
- return c;
126
- } catch {}
127
- }
128
- }
129
- return null;
130
- }
131
-
132
- function buildTableHtml(headers, rows, alignments) {
133
- const thHtml = headers.map((h, i) => {
134
- const align = alignments[i] || 'left';
135
- return `<th style="padding: 10px 14px; text-align: ${align}; border-bottom: 2px solid #5865F2; color: #fff; font-size: 14px; font-weight: 600;">${escapeHtml(h)}</th>`;
136
- }).join('');
137
-
138
- const trHtml = rows.map((row, rIdx) => {
139
- const bg = rIdx % 2 === 0 ? '#2b2d31' : '#313338';
140
- const tdHtml = row.map((cell, cIdx) => {
141
- const align = alignments[cIdx] || 'left';
142
- return `<td style="padding: 9px 14px; text-align: ${align}; border-bottom: 1px solid #3f4147; color: #dbdee1; font-size: 13px;">${escapeHtml(cell)}</td>`;
143
- }).join('');
144
- return `<tr style="background: ${bg};">${tdHtml}</tr>`;
145
- }).join('');
146
-
147
- return `<!DOCTYPE html><html><head><meta charset="utf-8"><style>
148
- body { margin: 0; padding: 16px; background: #1e1f22; font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; display: inline-block; }
149
- .table-card { border-radius: 8px; overflow: hidden; box-shadow: 0 4px 12px rgba(0,0,0,0.4); border: 1px solid #383a40; background: #2b2d31; display: inline-block; }
150
- table { border-collapse: collapse; width: 100%; min-width: 320px; }
151
- .header-banner { background: linear-gradient(90deg, #5865F2, #4752C4); color: white; padding: 8px 14px; font-size: 12px; font-weight: 700; text-transform: uppercase; letter-spacing: 0.5px; }
152
- </style></head><body>
153
- <div class="table-card">
154
- <div class="header-banner">📊 Dis-Gravity Data Table</div>
155
- <table>
156
- <thead><tr style="background: #232428;">${thHtml}</tr></thead>
157
- <tbody>${trHtml}</tbody>
158
- </table>
159
- </div>
160
- </body></html>`;
161
- }
162
-
163
- function escapeHtml(str) {
164
- if (!str) return '';
165
- return String(str).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
166
- }
167
-
168
- async function renderTableToPng(headers, rows, alignments) {
169
- const browserPath = findHeadlessBrowser();
170
- if (!browserPath) return null;
171
-
172
- const tempHtml = path.join(os.tmpdir(), `table_${Date.now()}_${Math.random().toString(36).substring(2)}.html`);
173
- const tempPng = path.join(os.tmpdir(), `table_${Date.now()}_${Math.random().toString(36).substring(2)}.png`);
174
-
175
- try {
176
- const html = buildTableHtml(headers, rows, alignments);
177
- fs.writeFileSync(tempHtml, html, 'utf8');
178
-
179
- const colCount = headers.length || 3;
180
- const rowCount = rows.length || 3;
181
- const width = Math.min(1200, Math.max(450, colCount * 180));
182
- const height = Math.min(1000, Math.max(200, (rowCount + 2) * 45 + 80));
183
-
184
- const args = [
185
- '--headless',
186
- '--disable-gpu',
187
- '--hide-scrollbars',
188
- `--screenshot=${tempPng}`,
189
- `--window-size=${width},${height}`,
190
- `file:///${tempHtml.replace(/\\/g, '/')}`
191
- ];
192
- cp.execFileSync(browserPath, args, { timeout: 6000, stdio: 'ignore' });
193
-
194
- if (fs.existsSync(tempPng)) {
195
- const buf = fs.readFileSync(tempPng);
196
- try { fs.unlinkSync(tempHtml); } catch {}
197
- try { fs.unlinkSync(tempPng); } catch {}
198
- return buf;
199
- }
200
- } catch {
201
- } finally {
202
- try { if (fs.existsSync(tempHtml)) fs.unlinkSync(tempHtml); } catch {}
203
- try { if (fs.existsSync(tempPng)) fs.unlinkSync(tempPng); } catch {}
204
- }
205
- return null;
206
- }
207
-
208
- // Main Daemon Application
209
- async function startDaemon() {
210
- const config = parseArgs();
211
-
212
- let Client, GatewayIntentBits, Partials, ActionRowBuilder, ButtonBuilder, ButtonStyle;
213
- try {
214
- const djs = require('discord.js');
215
- Client = djs.Client;
216
- GatewayIntentBits = djs.GatewayIntentBits;
217
- Partials = djs.Partials;
218
- ActionRowBuilder = djs.ActionRowBuilder;
219
- ButtonBuilder = djs.ButtonBuilder;
220
- ButtonStyle = djs.ButtonStyle;
221
- } catch (err) {
222
- console.error('❌ Error: discord.js is required to run the standalone daemon.');
223
- console.error(' Please install it with: npm install discord.js');
224
- process.exit(1);
225
- }
226
-
227
- console.log(`
228
- ╔════════════════════════════════════════════════════════════════════════╗
229
- ║ Dis-Gravity Standalone CLI Daemon ║
230
- ╚════════════════════════════════════════════════════════════════════════╝
231
- `);
232
-
233
- if (!config.token) {
234
- console.error('❌ Error: Discord Bot Token is required (--token or DISCORD_BOT_TOKEN).');
235
- process.exit(1);
236
- }
237
-
238
- if (!config.channelId) {
239
- console.error('❌ Error: Discord Channel ID is required (--channel or DISCORD_CHANNEL_ID).');
240
- process.exit(1);
241
- }
242
-
243
- log.appendLine(`Initializing Daemon with workspace: ${config.workspace}`);
244
- log.appendLine(`Provider: ${config.provider.toUpperCase()} | Channel ID: ${config.channelId}`);
245
- if (config.allowedUsers.length > 0) {
246
- log.appendLine(`Security Whitelist: ${config.allowedUsers.length} user(s) allowed (${config.allowedUsers.join(', ')})`);
247
- } else {
248
- log.appendLine(`⚠️ SECURITY WARNING: No --allowed-users configured! Anyone with access to channel #${config.channelId} can execute commands and access files in this workspace.`);
249
- log.appendLine(` To restrict access to your user only, launch with: --allowed-users="YOUR_DISCORD_USER_ID"`);
250
- }
251
-
252
- const client = new Client({
253
- intents: [
254
- GatewayIntentBits.Guilds,
255
- GatewayIntentBits.GuildMessages,
256
- GatewayIntentBits.MessageContent,
257
- GatewayIntentBits.DirectMessages
258
- ],
259
- partials: [Partials.Channel, Partials.Message]
260
- });
261
-
262
- function isUserAllowed(userId) {
263
- if (!config.allowedUsers || config.allowedUsers.length === 0) return true;
264
- return config.allowedUsers.includes(userId);
265
- }
266
-
267
- // Active CLI agent supervisor
268
- let activeProcess = null;
269
- let activeStepStatus = null;
270
- let pendingApproval = null;
271
- let approvalResolver = null;
272
-
273
- async function cancelActiveAgent() {
274
- if (activeProcess) {
275
- try { activeProcess.kill('SIGINT'); } catch {}
276
- activeProcess = null;
277
- }
278
- activeStepStatus = null;
279
- if (approvalResolver) {
280
- approvalResolver('cancel');
281
- approvalResolver = null;
282
- pendingApproval = null;
283
- }
284
- }
285
-
286
- async function executeCliAgent(promptText, onStepUpdate) {
287
- const isWindows = process.platform === 'win32';
288
- const checkCmd = isWindows ? 'where.exe' : 'which';
289
-
290
- let engine = 'shell';
291
- const check = (bin) => {
292
- try { cp.execSync(`${checkCmd} ${bin}`, { stdio: 'ignore' }); return true; } catch { return false; }
293
- };
294
-
295
- if (check('agy')) engine = 'agy';
296
- else if (check('claude')) engine = 'claude';
297
- else if (check('aider')) engine = 'aider';
298
-
299
- let binary = '';
300
- let args = [];
301
-
302
- if (engine === 'agy') {
303
- binary = 'agy';
304
- args = [promptText];
305
- } else if (engine === 'claude') {
306
- binary = 'claude';
307
- args = ['-p', promptText];
308
- } else if (engine === 'aider') {
309
- binary = 'aider';
310
- args = ['--message', promptText, '--yes-always'];
311
- } else {
312
- binary = isWindows ? 'powershell.exe' : '/bin/bash';
313
- args = isWindows ? ['-NoProfile', '-Command', promptText] : ['-c', promptText];
314
- }
315
-
316
- log.appendLine(`[Agent] Spawning ${binary} (${engine.toUpperCase()}) in ${config.workspace}...`);
317
- activeStepStatus = `Running ${engine.toUpperCase()} agent...`;
318
- onStepUpdate?.(activeStepStatus);
319
-
320
- return new Promise((resolve) => {
321
- let stdout = '';
322
- let stderr = '';
323
- const startTime = Date.now();
324
-
325
- try {
326
- const proc = cp.spawn(binary, args, {
327
- cwd: config.workspace,
328
- env: { ...process.env, CI: '1', TERM: 'dumb' }
329
- });
330
- activeProcess = proc;
331
-
332
- const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
333
-
334
- proc.stdout.on('data', (chunk) => {
335
- const text = stripAnsi(chunk.toString());
336
- stdout += text;
337
- const lastLine = text.trim().split('\n').pop() || '';
338
- if (lastLine) {
339
- activeStepStatus = lastLine.substring(0, 80);
340
- onStepUpdate?.(activeStepStatus);
341
- }
342
-
343
- if (/(?:\(|\[)\s*[yY]\s*[\/|]\s*[nN]\s*(?:\)|\])|(?:proceed|allow|continue|confirm|choice|run this)\b.*\?|\?\s+\[[yY]\/[nN]\]/i.test(lastLine)) {
344
- pendingApproval = {
345
- question: lastLine,
346
- options: [
347
- { id: 'yes', text: '🟢 Proceed / Allow (y)' },
348
- { id: 'no', text: '🔴 Deny / Cancel (n)' }
349
- ]
350
- };
351
- approvalResolver = (ans) => {
352
- const isYes = /^(yes|proceed|allow|y)/i.test(ans || '');
353
- try { proc.stdin.write(isYes ? 'y\n' : 'n\n'); } catch {}
354
- };
355
- }
356
- });
357
-
358
- proc.stderr.on('data', (chunk) => {
359
- stderr += stripAnsi(chunk.toString());
360
- });
361
-
362
- proc.on('close', (code) => {
363
- activeProcess = null;
364
- activeStepStatus = null;
365
- pendingApproval = null;
366
- approvalResolver = null;
367
- const dur = ((Date.now() - startTime) / 1000).toFixed(1);
368
- const badge = code === 0 ? `✅ **Process completed in ${dur}s (exit 0)**` : `⚠️ **Process exited with code ${code} (${dur}s)**`;
369
- const output = (stdout.trim() || stderr.trim()) || '(No output recorded)';
370
- resolve(`${badge}\n\n\`\`\`text\n${output.substring(0, 1800)}\n\`\`\``);
371
- });
372
-
373
- proc.on('error', (err) => {
374
- activeProcess = null;
375
- activeStepStatus = null;
376
- resolve(`❌ **Failed to spawn CLI process (${binary}):** ${err.message}`);
377
- });
378
- } catch (err) {
379
- activeProcess = null;
380
- activeStepStatus = null;
381
- resolve(`❌ **Execution error:** ${err.message}`);
382
- }
383
- });
384
- }
385
-
386
- async function getDaemonStatusPayload(customHeader = null) {
387
- const cpus = os.cpus();
388
- const cpuModel = cpus && cpus[0] ? cpus[0].model.trim() : 'CPU';
389
- const freeGB = (os.freemem() / (1024 ** 3)).toFixed(1);
390
- const totalGB = (os.totalmem() / (1024 ** 3)).toFixed(1);
391
- const st = await executeShellCommand('git rev-parse --abbrev-ref HEAD', config.workspace);
392
- const branch = st.stdout.trim() || 'unknown';
393
-
394
- let msg = "";
395
- if (customHeader) {
396
- msg += `${customHeader}\n\n`;
397
- }
398
- msg += `📊 **Dis-Gravity Standalone Daemon Status**\n\n`;
399
- msg += `• **Project:** 📁 \`${path.basename(config.workspace)}\`\n`;
400
- msg += `• **Workspace:** \`${config.workspace}\`\n`;
401
- msg += `• **Git Branch:** 🌿 \`${branch}\`\n`;
402
- msg += `• **Provider:** 🤖 🟢 **Standalone Daemon (${config.provider.toUpperCase()})**\n`;
403
- msg += `• **Daemon State:** ${activeProcess ? '⏳ Busy (Process active)' : '🟢 Idle (Waiting for commands)'}\n`;
404
- msg += `• **Host System:** 💻 ${os.platform()} (${os.release()}, ${os.arch()})\n`;
405
- msg += `• **Memory:** 📈 ${freeGB} GB free / ${totalGB} GB total\n`;
406
- if (!config.allowedUsers || config.allowedUsers.length === 0) {
407
- msg += `\n> ⚠️ *Security notice: No \`allowedUsers\` whitelist configured. Add your Discord User ID to restrict access.*`;
408
- }
409
-
410
- const actionRow = new ActionRowBuilder().addComponents(
411
- new ButtonBuilder()
412
- .setCustomId("daemon:status")
413
- .setLabel("🔄 Refresh")
414
- .setStyle(ButtonStyle.Primary),
415
- new ButtonBuilder()
416
- .setCustomId("daemon:diff")
417
- .setLabel("🌿 Git Diff")
418
- .setStyle(ButtonStyle.Secondary),
419
- new ButtonBuilder()
420
- .setCustomId("daemon:info")
421
- .setLabel("🖥️ System Info")
422
- .setStyle(ButtonStyle.Secondary)
423
- );
424
-
425
- return { content: msg.substring(0, 1900), components: [actionRow] };
426
- }
427
-
428
- async function sendDaemonStatusNotification(channel, header = "🚀 **Dis-Gravity Daemon Connected & Ready**") {
429
- try {
430
- const payload = await getDaemonStatusPayload(header);
431
- await channel.send(payload);
432
- log.appendLine(`Sent automatic status notification to #${channel.name || channel.id}`);
433
- } catch (err) {
434
- log.appendLine(`Failed to send daemon status notification: ${err.message}`);
435
- }
436
- }
437
-
438
- client.once('ready', async () => {
439
- log.appendLine(`🚀 Dis-Gravity Daemon logged in as ${client.user.tag}`);
440
- const channel = await client.channels.fetch(config.channelId).catch(() => null);
441
- if (!channel) {
442
- log.appendLine(`⚠️ Warning: Could not find channel with ID ${config.channelId}`);
443
- } else {
444
- log.appendLine(`Connected to Discord Channel: #${channel.name || config.channelId}`);
445
- await sendDaemonStatusNotification(channel, "🚀 **Dis-Gravity Daemon Connected & Ready**");
446
- }
447
-
448
- // Register slash commands
449
- try {
450
- const slashCommands = [
451
- { name: "sh", description: "Execute shell command in workspace", options: [{ name: "command", description: "Command to execute", type: 3, required: true }] },
452
- { name: "diff", description: "View git diff summary and preview" },
453
- { name: "info", description: "View host hardware and workspace health dashboard" },
454
- { name: "tree", description: "View workspace directory tree" },
455
- { name: "stop", description: "Cancel running agent or command" },
456
- { name: "status", description: "View daemon status and provider connection" },
457
- { name: "help", description: "Display Dis-Gravity commands" }
458
- ];
459
-
460
- if (config.guildId) {
461
- const guild = await client.guilds.fetch(config.guildId).catch(() => null);
462
- if (guild) {
463
- await guild.commands.set(slashCommands);
464
- log.appendLine(`Registered ${slashCommands.length} guild slash commands in ${guild.name}`);
465
- }
466
- } else {
467
- await client.application.commands.set(slashCommands);
468
- log.appendLine(`Registered ${slashCommands.length} global slash commands`);
469
- }
470
- } catch (err) {
471
- log.appendLine(`Notice: Could not register slash commands (${err.message}). Prefix commands (!sh, !diff, etc.) are fully active.`);
472
- }
473
- });
474
-
475
- // Handle Slash Commands and Buttons
476
- client.on('interactionCreate', async (interaction) => {
477
- try {
478
- if (!isUserAllowed(interaction.user.id)) {
479
- if (!interaction.replied && !interaction.deferred) {
480
- await interaction.reply({ content: `⛔ **Unauthorized:** Your Discord User ID (\`${interaction.user.id}\`) is not on the whitelist.`, ephemeral: true });
481
- }
482
- return;
483
- }
484
- if (interaction.isButton()) {
485
- const id = interaction.customId;
486
- if (id === 'daemon:status') {
487
- await interaction.deferUpdate().catch(() => {});
488
- const statusPayload = await getDaemonStatusPayload();
489
- await interaction.editReply(statusPayload).catch(() => {});
490
- return;
491
- }
492
- if (id === 'daemon:diff') {
493
- await interaction.deferReply({ ephemeral: true }).catch(() => {});
494
- const st = await executeShellCommand('git status --short', config.workspace);
495
- const df = await executeShellCommand('git diff --stat', config.workspace);
496
- let reply = `🌿 **Git Status & Diff Preview**\n\n\`\`\`diff\n${st.stdout || 'Clean working tree'}\n\`\`\`\n`;
497
- if (df.stdout) reply += `**Diff Stat:**\n\`\`\`text\n${df.stdout}\n\`\`\``;
498
- await interaction.editReply(reply).catch(() => {});
499
- return;
500
- }
501
- if (id === 'daemon:info') {
502
- await interaction.deferReply({ ephemeral: true }).catch(() => {});
503
- const cpus = os.cpus();
504
- const cpuModel = cpus && cpus[0] ? cpus[0].model.trim() : 'CPU';
505
- const freeGB = (os.freemem() / (1024 ** 3)).toFixed(1);
506
- const totalGB = (os.totalmem() / (1024 ** 3)).toFixed(1);
507
- const st = await executeShellCommand('git rev-parse --abbrev-ref HEAD', config.workspace);
508
- let msg = `🖥️ **Host & System Info**\n\n`;
509
- msg += `• **Project:** \`${path.basename(config.workspace)}\`\n`;
510
- msg += `• **Workspace:** \`${config.workspace}\`\n`;
511
- msg += `• **Branch:** \`${st.stdout.trim() || 'unknown'}\`\n`;
512
- msg += `• **System:** ${os.platform()} (${os.release()}, ${os.arch()})\n`;
513
- msg += `• **CPU:** ${cpuModel} (${cpus ? cpus.length : 1} cores)\n`;
514
- msg += `• **Memory:** ${freeGB} GB free / ${totalGB} GB total\n`;
515
- await interaction.editReply(msg).catch(() => {});
516
- return;
517
- }
518
- if (id.startsWith('approve_') && approvalResolver) {
519
- const choice = id.replace('approve_', '');
520
- approvalResolver(choice);
521
- approvalResolver = null;
522
- pendingApproval = null;
523
- await interaction.reply({ content: `✅ **Action approved:** \`${choice}\``, ephemeral: true }).catch(() => {});
524
- return;
525
- }
526
- if (id === 'agent_cancel') {
527
- await cancelActiveAgent();
528
- await interaction.reply({ content: "🛑 **Execution halted.**", ephemeral: true }).catch(() => {});
529
- return;
530
- }
531
- }
532
-
533
- if (interaction.isChatInputCommand()) {
534
- const cmd = interaction.commandName;
535
- if (cmd === 'sh') {
536
- const commandStr = interaction.options.getString('command');
537
- await interaction.deferReply();
538
- const res = await executeShellCommand(commandStr, config.workspace);
539
- const badge = res.code === 0 ? `✅ **Exit 0 (${res.duration}s)**` : `❌ **Exit ${res.code} (${res.duration}s)**`;
540
- const out = res.stdout || res.stderr || '(No output)';
541
- await interaction.editReply(`${badge}\n\`\`\`text\n${out.substring(0, 1800)}\n\`\`\``);
542
- return;
543
- }
544
-
545
- if (cmd === 'diff') {
546
- await interaction.deferReply();
547
- const st = await executeShellCommand('git status --short', config.workspace);
548
- const df = await executeShellCommand('git diff --stat', config.workspace);
549
- let reply = `🌿 **Git Status & Diff Preview**\n\n`;
550
- reply += `\`\`\`diff\n${st.stdout || 'Clean working tree'}\n\`\`\`\n`;
551
- if (df.stdout) reply += `**Diff Stat:**\n\`\`\`text\n${df.stdout}\n\`\`\``;
552
- await interaction.editReply(reply);
553
- return;
554
- }
555
-
556
- if (cmd === 'info') {
557
- await interaction.deferReply();
558
- const cpus = os.cpus();
559
- const cpuModel = cpus && cpus[0] ? cpus[0].model.trim() : 'CPU';
560
- const freeGB = (os.freemem() / (1024 ** 3)).toFixed(1);
561
- const totalGB = (os.totalmem() / (1024 ** 3)).toFixed(1);
562
- const st = await executeShellCommand('git rev-parse --abbrev-ref HEAD', config.workspace);
563
- let msg = `🖥️ **Dis-Gravity Host & Workspace Health Dashboard**\n\n`;
564
- msg += `• **Project:** \`${path.basename(config.workspace)}\`\n`;
565
- msg += `• **Path:** \`${config.workspace}\`\n`;
566
- msg += `• **Git Branch:** \`${st.stdout.trim() || 'unknown'}\`\n`;
567
- msg += `• **Provider:** 🟢 **Standalone Daemon (${config.provider.toUpperCase()})**\n`;
568
- msg += `• **System:** ${os.platform()} (${os.release()}, ${os.arch()})\n`;
569
- msg += `• **CPU:** ${cpuModel} (${cpus ? cpus.length : 1} cores)\n`;
570
- msg += `• **Memory:** ${freeGB} GB free / ${totalGB} GB total\n`;
571
- msg += `• **Node Runtime:** ${process.version} (PID: ${process.pid})\n`;
572
- await interaction.editReply(msg);
573
- return;
574
- }
575
-
576
- if (cmd === 'status') {
577
- await interaction.deferReply().catch(() => {});
578
- const statusPayload = await getDaemonStatusPayload();
579
- await interaction.editReply(statusPayload).catch(() => {});
580
- return;
581
- }
582
-
583
- if (cmd === 'stop') {
584
- await cancelActiveAgent();
585
- await interaction.reply('🛑 **Agent generation / command cancelled.**');
586
- return;
587
- }
588
-
589
- if (cmd === 'help') {
590
- let h = `🤖 **Dis-Gravity Standalone Daemon Commands**\n\n`;
591
- h += `• \`/sh <cmd>\` or \`!sh <cmd>\` — Run terminal command\n`;
592
- h += `• \`/diff\` or \`!diff\` — View git status and diff preview\n`;
593
- h += `• \`/info\` or \`!info\` — System hardware and workspace dashboard\n`;
594
- h += `• \`/stop\` or \`!stop\` — Cancel running process\n`;
595
- await interaction.reply(h);
596
- return;
597
- }
598
- }
599
- } catch (err) {
600
- log.appendLine(`Interaction error: ${err.message}`);
601
- }
602
- });
603
-
604
- // Handle Chat Messages
605
- client.on('messageCreate', async (message) => {
606
- if (message.author.bot) return;
607
- if (message.channelId !== config.channelId && !message.channel.isDMBased()) return;
608
-
609
- if (!isUserAllowed(message.author.id)) {
610
- await message.reply(`⛔ **Unauthorized:** Your Discord User ID (\`${message.author.id}\`) is not in the Dis-Gravity whitelist.`).catch(() => {});
611
- return;
612
- }
613
-
614
- const text = message.content.trim();
615
-
616
- // Shell command prefix
617
- if (text.startsWith('!sh ') || text.startsWith('$ ')) {
618
- const cmdStr = text.replace(/^(!sh |\$ )/, '');
619
- const statusMsg = await message.reply(`⚡ Running: \`${cmdStr}\`...`);
620
- const res = await executeShellCommand(cmdStr, config.workspace);
621
- const badge = res.code === 0 ? `✅ **Exit 0 (${res.duration}s)**` : `❌ **Exit ${res.code} (${res.duration}s)**`;
622
- const out = res.stdout || res.stderr || '(No output)';
623
- await statusMsg.edit(`${badge}\n\`\`\`text\n${out.substring(0, 1800)}\n\`\`\``);
624
- return;
625
- }
626
-
627
- // Status
628
- if (text === '!status' || text === '/status') {
629
- const statusPayload = await getDaemonStatusPayload();
630
- await message.reply(statusPayload).catch(() => {});
631
- return;
632
- }
633
-
634
- // Git Diff
635
- if (text === '!diff' || text === '/diff') {
636
- const st = await executeShellCommand('git status --short', config.workspace);
637
- const df = await executeShellCommand('git diff --stat', config.workspace);
638
- let reply = `🌿 **Git Status & Diff Preview**\n\n\`\`\`diff\n${st.stdout || 'Clean working tree'}\n\`\`\`\n`;
639
- if (df.stdout) reply += `**Diff Stat:**\n\`\`\`text\n${df.stdout}\n\`\`\``;
640
- await message.reply(reply);
641
- return;
642
- }
643
-
644
- // Info
645
- if (text === '!info' || text === '/info') {
646
- const cpus = os.cpus();
647
- const cpuModel = cpus && cpus[0] ? cpus[0].model.trim() : 'CPU';
648
- const freeGB = (os.freemem() / (1024 ** 3)).toFixed(1);
649
- const totalGB = (os.totalmem() / (1024 ** 3)).toFixed(1);
650
- const st = await executeShellCommand('git rev-parse --abbrev-ref HEAD', config.workspace);
651
- let msg = `🖥️ **Dis-Gravity Host & Workspace Health Dashboard**\n\n`;
652
- msg += `• **Project:** \`${path.basename(config.workspace)}\`\n`;
653
- msg += `• **Path:** \`${config.workspace}\`\n`;
654
- msg += `• **Git Branch:** \`${st.stdout.trim() || 'unknown'}\`\n`;
655
- msg += `• **Provider:** 🟢 **Standalone Daemon (${config.provider.toUpperCase()})**\n`;
656
- msg += `• **System:** ${os.platform()} (${os.release()}, ${os.arch()})\n`;
657
- msg += `• **CPU:** ${cpuModel} (${cpus ? cpus.length : 1} cores)\n`;
658
- msg += `• **Memory:** ${freeGB} GB free / ${totalGB} GB total\n`;
659
- await message.reply(msg);
660
- return;
661
- }
662
-
663
- // Stop
664
- if (text === '!stop' || text === '/stop') {
665
- await cancelActiveAgent();
666
- await message.reply('🛑 **Agent generation / command cancelled.**');
667
- return;
668
- }
669
-
670
- // Forward general prompt to AI Agent
671
- const statusMsg = await message.reply('⏳ **Analyzing prompt... (0s)**');
672
- let elapsed = 0;
673
- const timer = setInterval(async () => {
674
- elapsed += 2;
675
- const statusText = activeStepStatus ? `⏳ **Working (${elapsed}s):** \`${activeStepStatus}\`` : `⏳ **Analyzing... (${elapsed}s)**`;
676
- await statusMsg.edit(statusText).catch(() => {});
677
- }, 2000);
678
-
679
- try {
680
- const res = await executeCliAgent(text, (step) => {
681
- statusMsg.edit(`⏳ **Working (${elapsed}s):** \`${step}\``).catch(() => {});
682
- });
683
-
684
- clearInterval(timer);
685
- await statusMsg.edit(res);
686
- } catch (err) {
687
- clearInterval(timer);
688
- await statusMsg.edit(`❌ **Error:** ${err.message}`);
689
- }
690
- });
691
-
692
- await client.login(config.token);
693
- }
694
-
695
- if (require.main === module) {
696
- startDaemon().catch((err) => {
697
- console.error('Fatal Daemon Error:', err);
698
- process.exit(1);
699
- });
700
- }
701
-
702
- module.exports = { startDaemon };
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Dis-Gravity Standalone CLI Daemon
5
+ * Run Dis-Gravity as an editor-agnostic background service for JetBrains IDEs,
6
+ * Neovim, Emacs, Zed, or headless remote servers.
7
+ */
8
+
9
+ const fs = require('fs');
10
+ const path = require('path');
11
+ const os = require('os');
12
+ const cp = require('child_process');
13
+
14
+ const {
15
+ looksLikeBotToken,
16
+ buildInviteUrl,
17
+ inspectApplication,
18
+ ensureMessageContentIntent,
19
+ listUserGuilds,
20
+ waitForGuildJoin,
21
+ findOrCreateChannel,
22
+ sendWelcomeMessage,
23
+ loadConfig,
24
+ saveConfig,
25
+ clearConfig,
26
+ openBrowser,
27
+ CONFIG_FILE
28
+ } = require('../lib/onboarding');
29
+ const {
30
+ executeShellCommand,
31
+ findHeadlessBrowser,
32
+ buildTableHtml,
33
+ escapeHtml,
34
+ renderTableToPng,
35
+ isUserAllowed: coreIsUserAllowed,
36
+ parseMarkdownTable,
37
+ readWorkspaceFile,
38
+ listWorkspaceTree,
39
+ getGitStatusAndDiff,
40
+ getSystemHealth,
41
+ parseScheduleSpec,
42
+ parseScheduleCommandInput,
43
+ TaskScheduler,
44
+ ThreadWorkspaceManager,
45
+ isAudioAttachment,
46
+ transcribeAudioFile,
47
+ buildDiffHtml,
48
+ renderDiffToPng,
49
+ detectDestructiveKeywords,
50
+ captureWorkspaceCheckpoint,
51
+ restoreWorkspaceCheckpoint
52
+ } = require('../lib/core');
53
+
54
+ function parseArgs() {
55
+ const args = process.argv.slice(2);
56
+ const saved = loadConfig();
57
+
58
+ const config = {
59
+ token: process.env.DISCORD_BOT_TOKEN || saved.token || '',
60
+ channelId: process.env.DISCORD_CHANNEL_ID || saved.channelId || '',
61
+ guildId: process.env.DISCORD_GUILD_ID || saved.guildId || '',
62
+ allowedUsers: process.env.DISCORD_ALLOWED_USERS
63
+ ? process.env.DISCORD_ALLOWED_USERS.split(',').map(s => s.trim()).filter(Boolean)
64
+ : (Array.isArray(saved.allowedUsers) ? saved.allowedUsers : []),
65
+ provider: process.env.DISCORD_AGENT_PROVIDER || saved.provider || 'cli',
66
+ debugPort: parseInt(process.env.DEBUG_PORT || String(saved.debugPort || '9000'), 10),
67
+ workspace: process.cwd(),
68
+ tableStyle: process.env.TABLE_STYLE || saved.tableStyle || 'image',
69
+ autoAttachTableImages: true,
70
+ runSetup: false,
71
+ showStatus: false
72
+ };
73
+
74
+ for (let i = 0; i < args.length; i++) {
75
+ const arg = args[i];
76
+ if (arg === '--help' || arg === '-h') {
77
+ printHelp();
78
+ process.exit(0);
79
+ }
80
+ if (arg === 'setup' || arg === '--setup') {
81
+ config.runSetup = true;
82
+ } else if (arg === 'status' || arg === '--status') {
83
+ config.showStatus = true;
84
+ } else if (arg === '--reset') {
85
+ clearConfig();
86
+ console.log(`✅ Dis-Gravity configuration cleared (~/.dis-gravity/config.json deleted).`);
87
+ process.exit(0);
88
+ } else if (arg === '--token' || arg === '-t') config.token = args[++i] || '';
89
+ else if (arg.startsWith('--token=')) config.token = arg.split('=')[1] || '';
90
+ else if (arg === '--channel' || arg === '-c') config.channelId = args[++i] || '';
91
+ else if (arg.startsWith('--channel=')) config.channelId = arg.split('=')[1] || '';
92
+ else if (arg === '--guild' || arg === '-g') config.guildId = args[++i] || '';
93
+ else if (arg.startsWith('--guild=')) config.guildId = arg.split('=')[1] || '';
94
+ else if (arg === '--provider' || arg === '-p') config.provider = args[++i] || 'cli';
95
+ else if (arg.startsWith('--provider=')) config.provider = arg.split('=')[1] || 'cli';
96
+ else if (arg === '--port') config.debugPort = parseInt(args[++i] || '9000', 10);
97
+ else if (arg.startsWith('--port=')) config.debugPort = parseInt(arg.split('=')[1] || '9000', 10);
98
+ else if (arg === '--workspace' || arg === '-w') {
99
+ if (args[i + 1]) config.workspace = path.resolve(args[++i]);
100
+ } else if (arg.startsWith('--workspace=')) {
101
+ const wsVal = arg.split('=')[1];
102
+ if (wsVal) config.workspace = path.resolve(wsVal);
103
+ } else if (arg === '--allowed-users' || arg === '-u') {
104
+ const uVal = args[++i] || '';
105
+ config.allowedUsers = uVal.split(',').map(s => s.trim()).filter(Boolean);
106
+ } else if (arg.startsWith('--allowed-users=')) {
107
+ const uVal = arg.split('=')[1] || '';
108
+ config.allowedUsers = uVal.split(',').map(s => s.trim()).filter(Boolean);
109
+ } else if (arg === '--table-style') config.tableStyle = args[++i] || 'image';
110
+ }
111
+
112
+ return config;
113
+ }
114
+
115
+ function printHelp() {
116
+ console.log(`
117
+ ╔════════════════════════════════════════════════════════════════════════╗
118
+ ║ Dis-Gravity Standalone CLI Daemon ║
119
+ ║ The Ultimate Discord Bridge for JetBrains, Neovim & Headless ║
120
+ ╚════════════════════════════════════════════════════════════════════════╝
121
+
122
+ Usage:
123
+ npx dis-gravity [command] [options]
124
+ node bin/dis-gravity.js [command] [options]
125
+
126
+ Commands:
127
+ setup, --setup Run interactive guided onboarding wizard (<90 seconds)
128
+ status, --status Show current configuration and saved credentials
129
+ --reset Delete saved credentials (~/.dis-gravity/config.json)
130
+
131
+ Options:
132
+ -t, --token <string> Discord Bot Token (env: DISCORD_BOT_TOKEN)
133
+ -c, --channel <string> Discord Channel ID to bind (env: DISCORD_CHANNEL_ID)
134
+ -g, --guild <string> Discord Guild ID (optional, for instant slash commands)
135
+ -p, --provider <string> AI Provider: 'cli', 'antigravity', 'cursor', 'windsurf' (default: cli)
136
+ -w, --workspace <path> Workspace folder path (default: current directory)
137
+ -u, --allowed-users <ids> Comma-separated Discord User IDs allowed to execute commands
138
+ --port <number> CDP port for Antigravity/Cursor/Windsurf (default: 9000)
139
+ --table-style <style> Table formatting: image, cards, rounded, boxed, ascii (default: image)
140
+ -h, --help Show this help menu
141
+
142
+ Examples:
143
+ npx dis-gravity setup
144
+ npx dis-gravity
145
+ npx dis-gravity --provider=antigravity --port=9000
146
+ `);
147
+ }
148
+
149
+ // Minimal logger
150
+ const log = {
151
+ appendLine: (msg) => {
152
+ const ts = new Date().toISOString().substring(11, 19);
153
+ console.log(`[${ts}] ${msg}`);
154
+ }
155
+ };
156
+
157
+
158
+
159
+ function promptUser(query, isPassword = false) {
160
+ const readline = require('readline');
161
+ const rl = readline.createInterface({
162
+ input: process.stdin,
163
+ output: process.stdout
164
+ });
165
+
166
+ return new Promise((resolve) => {
167
+ if (isPassword && process.stdin.isTTY && typeof process.stdin.setRawMode === 'function') {
168
+ process.stdout.write(query);
169
+ let input = '';
170
+ const onData = (char) => {
171
+ char = char.toString();
172
+ if (char === '\n' || char === '\r' || char === '\u0004') {
173
+ process.stdin.removeListener('data', onData);
174
+ try { process.stdin.setRawMode(false); } catch { }
175
+ process.stdout.write('\n');
176
+ rl.close();
177
+ resolve(input.trim());
178
+ } else if (char === '\u0003') {
179
+ process.exit(1);
180
+ } else if (char === '\b' || char === '\x7f') {
181
+ if (input.length > 0) {
182
+ input = input.slice(0, -1);
183
+ process.stdout.write('\b \b');
184
+ }
185
+ } else {
186
+ input += char;
187
+ process.stdout.write('*');
188
+ }
189
+ };
190
+ process.stdin.setRawMode(true);
191
+ process.stdin.resume();
192
+ process.stdin.on('data', onData);
193
+ } else {
194
+ rl.question(query, (ans) => {
195
+ rl.close();
196
+ resolve(ans.trim());
197
+ });
198
+ }
199
+ });
200
+ }
201
+
202
+ async function runCliSetupWizard(config) {
203
+ console.log(`
204
+ ╔════════════════════════════════════════════════════════════════════════╗
205
+ ║ Dis-Gravity Guided Onboarding Wizard ║
206
+ ║ Zero-Friction Discord Bridge Setup in under 90s ║
207
+ ╚════════════════════════════════════════════════════════════════════════╝
208
+ `);
209
+
210
+ console.log(`Dis-Gravity bridges your terminal and AI coding agents to Discord.\n`);
211
+ console.log(`Step 1 of 4: Discord Bot Token`);
212
+ console.log(`────────────────────────────────────────────────────────────────────────`);
213
+ console.log(`If you haven't created a bot yet:`);
214
+ console.log(` 1. Open Discord Developer Portal: https://discord.com/developers/applications`);
215
+ console.log(` 2. Click 'New Application' -> Name it 'Dis-Gravity' (or any name)`);
216
+ console.log(` 3. Click 'Bot' in sidebar -> Click 'Reset Token' -> Copy Token\n`);
217
+
218
+ let token = config.token;
219
+ while (!token || !looksLikeBotToken(token)) {
220
+ if (token && !looksLikeBotToken(token)) {
221
+ console.log(`⚠️ That doesn't look like a valid Discord bot token format.`);
222
+ }
223
+ const openPortal = await promptUser(`Would you like to open the Developer Portal in your browser? [Y/n]: `);
224
+ if (!openPortal || /^y/i.test(openPortal)) {
225
+ openBrowser('https://discord.com/developers/applications');
226
+ console.log(`🌐 Opened https://discord.com/developers/applications in browser.\n`);
227
+ }
228
+ token = await promptUser(`Paste your Bot Token: `, true);
229
+ }
230
+
231
+ console.log(`\n🔍 Verifying token and inspecting application...`);
232
+ let appInfo;
233
+ try {
234
+ appInfo = await inspectApplication(token);
235
+ console.log(`✅ Verified Bot: @${appInfo.botTag} (App: "${appInfo.appName}")`);
236
+ console.log(` Owner: ${appInfo.ownerUsername} (ID: ${appInfo.ownerId})`);
237
+ } catch (err) {
238
+ console.error(`❌ Failed to verify bot token with Discord API: ${err.message}`);
239
+ process.exit(1);
240
+ }
241
+
242
+ console.log(`\nStep 2 of 4: Message Content Intent`);
243
+ console.log(`────────────────────────────────────────────────────────────────────────`);
244
+ console.log(`Checking Message Content Intent (required to read commands)...`);
245
+ const intentRes = await ensureMessageContentIntent(token);
246
+ if (intentRes.alreadyEnabled) {
247
+ console.log(`✅ Message Content Intent is already enabled.`);
248
+ } else if (intentRes.autoEnabled) {
249
+ console.log(`✅ Automatically enabled Message Content Intent via Discord API.`);
250
+ } else {
251
+ console.log(`⚠️ Could not auto-enable intent via API.`);
252
+ console.log(` Please open: ${intentRes.portalUrl}`);
253
+ console.log(` Scroll to "Privileged Gateway Intents" -> Toggle "Message Content Intent" -> Save Changes.`);
254
+ await promptUser(`Press Enter once Message Content Intent is toggled ON... `);
255
+ }
256
+
257
+ console.log(`\nStep 3 of 4: Server Authorization`);
258
+ console.log(`────────────────────────────────────────────────────────────────────────`);
259
+ let guilds = await listUserGuilds(token).catch(() => []);
260
+ let selectedGuild = null;
261
+
262
+ if (guilds.length > 0) {
263
+ console.log(`The bot is already in the following server(s):`);
264
+ guilds.forEach((g, idx) => console.log(` [${idx + 1}] ${g.name} (ID: ${g.id})`));
265
+ console.log(` [N] Invite bot to a different server`);
266
+ const choice = await promptUser(`Select server [1-${guilds.length}] or N: `);
267
+ const num = parseInt(choice, 10);
268
+ if (num >= 1 && num <= guilds.length) {
269
+ selectedGuild = guilds[num - 1];
270
+ }
271
+ }
272
+
273
+ if (!selectedGuild) {
274
+ const inviteUrl = buildInviteUrl(appInfo.appId);
275
+ console.log(`Opening OAuth2 server invite in your browser:`);
276
+ console.log(`🔗 ${inviteUrl}\n`);
277
+ openBrowser(inviteUrl);
278
+ console.log(`⏳ Waiting for bot to join your server (click 'Authorize' on Discord)...`);
279
+ const initialGuildIds = guilds.map(g => g.id);
280
+ try {
281
+ selectedGuild = await waitForGuildJoin(token, {
282
+ initialGuildIds,
283
+ timeoutMs: 120000,
284
+ onPoll: ({ elapsedMs }) => {
285
+ process.stdout.write(`\rWaiting for authorization... (${Math.round(elapsedMs / 1000)}s) `);
286
+ }
287
+ });
288
+ process.stdout.write('\n');
289
+ console.log(`✅ Bot joined server: "${selectedGuild.name}"!`);
290
+ } catch (err) {
291
+ console.error(`\n❌ ${err.message}`);
292
+ process.exit(1);
293
+ }
294
+ } else {
295
+ console.log(`Using server: "${selectedGuild.name}".`);
296
+ }
297
+
298
+ console.log(`\nStep 4 of 4: Private Channel & Security Setup`);
299
+ console.log(`────────────────────────────────────────────────────────────────────────`);
300
+ let channelResult;
301
+ try {
302
+ channelResult = await findOrCreateChannel(token, selectedGuild.id, {
303
+ botId: appInfo.botId,
304
+ ownerId: appInfo.ownerId,
305
+ channelName: 'dis-gravity'
306
+ });
307
+ if (channelResult.created) {
308
+ console.log(`✅ Created private channel #${channelResult.channel.name} (ID: ${channelResult.channel.id})`);
309
+ console.log(` (Strictly locked to @everyone; accessible only by you and your bot)`);
310
+ } else {
311
+ console.log(`✅ Reusing existing #${channelResult.channel.name} channel (ID: ${channelResult.channel.id})`);
312
+ }
313
+ } catch (err) {
314
+ if (err.code === 'MISSING_MANAGE_CHANNELS' && err.fallbackChannel) {
315
+ console.log(`⚠️ ${err.message}`);
316
+ channelResult = { channel: err.fallbackChannel };
317
+ } else {
318
+ console.error(`❌ Failed channel setup: ${err.message}`);
319
+ process.exit(1);
320
+ }
321
+ }
322
+
323
+ // Security whitelist
324
+ const allowedUsers = appInfo.ownerId ? [appInfo.ownerId] : [];
325
+ console.log(`🔒 Auto-whitelisted your Discord User ID (${appInfo.ownerId}) for command authorization.`);
326
+
327
+ // Save config
328
+ saveConfig({
329
+ token,
330
+ channelId: channelResult.channel.id,
331
+ guildId: selectedGuild.id,
332
+ allowedUsers,
333
+ provider: config.provider || 'cli',
334
+ tableStyle: config.tableStyle || 'image'
335
+ });
336
+ console.log(`💾 Configuration saved securely to ${CONFIG_FILE} (0600 mode).`);
337
+
338
+ // Send welcome ping
339
+ console.log(`📬 Sending welcome notification to #${channelResult.channel.name}...`);
340
+ await sendWelcomeMessage(token, channelResult.channel.id, {
341
+ ownerId: appInfo.ownerId,
342
+ provider: config.provider || 'cli'
343
+ }).catch(() => { });
344
+
345
+ console.log(`\n🎉 Setup complete! Launching Dis-Gravity daemon...\n`);
346
+
347
+ config.token = token;
348
+ config.channelId = channelResult.channel.id;
349
+ config.guildId = selectedGuild.id;
350
+ config.allowedUsers = allowedUsers;
351
+ }
352
+
353
+ // Main Daemon Application
354
+ async function startDaemon() {
355
+ const config = parseArgs();
356
+
357
+ if (config.showStatus) {
358
+ console.log(`\n📊 Dis-Gravity CLI Status & Configuration`);
359
+ console.log(`• Config File: ${fs.existsSync(CONFIG_FILE) ? CONFIG_FILE : 'None (not configured)'}`);
360
+ console.log(`• Bot Token: ${config.token ? (config.token.substring(0, 10) + '...' + config.token.slice(-4)) : 'Not set'}`);
361
+ console.log(`• Channel ID: ${config.channelId || 'Not set'}`);
362
+ console.log(`• Guild ID: ${config.guildId || 'Not set'}`);
363
+ console.log(`• Allowed Users: ${config.allowedUsers.length > 0 ? config.allowedUsers.join(', ') : 'None'}`);
364
+ console.log(`• Provider: ${config.provider}`);
365
+ process.exit(0);
366
+ }
367
+
368
+ if (config.runSetup || !config.token || !config.channelId) {
369
+ if (process.stdin.isTTY) {
370
+ await runCliSetupWizard(config);
371
+ } else {
372
+ console.error('❌ Error: Discord Bot Token and Channel ID are required.');
373
+ console.error(' Run `npx dis-gravity setup` interactively, or pass --token and --channel flags.');
374
+ console.error(' Alternatively, configure ~/.dis-gravity/config.json.');
375
+ process.exit(1);
376
+ }
377
+ }
378
+
379
+ let Client, GatewayIntentBits, Partials, ActionRowBuilder, ButtonBuilder, ButtonStyle;
380
+ try {
381
+ const djs = require('discord.js');
382
+ Client = djs.Client;
383
+ GatewayIntentBits = djs.GatewayIntentBits;
384
+ Partials = djs.Partials;
385
+ ActionRowBuilder = djs.ActionRowBuilder;
386
+ ButtonBuilder = djs.ButtonBuilder;
387
+ ButtonStyle = djs.ButtonStyle;
388
+ } catch (err) {
389
+ console.error('❌ Error: discord.js is required to run the standalone daemon.');
390
+ console.error(' Please install it with: npm install discord.js');
391
+ process.exit(1);
392
+ }
393
+
394
+ console.log(`
395
+ ╔════════════════════════════════════════════════════════════════════════╗
396
+ ║ Dis-Gravity Standalone CLI Daemon ║
397
+ ╚════════════════════════════════════════════════════════════════════════╝
398
+ `);
399
+
400
+ log.appendLine(`Initializing Daemon with workspace: ${config.workspace}`);
401
+ log.appendLine(`Provider: ${config.provider.toUpperCase()} | Channel ID: ${config.channelId}`);
402
+ if (config.allowedUsers.length > 0) {
403
+ log.appendLine(`Security Whitelist: ${config.allowedUsers.length} user(s) allowed (${config.allowedUsers.join(', ')})`);
404
+ } else {
405
+ log.appendLine(`⚠️ SECURITY NOTICE: No --allowed-users configured! Default-deny security is active.`);
406
+ log.appendLine(` All remote commands and interactions are BLOCKED until you specify authorized Discord User ID(s).`);
407
+ log.appendLine(` Configure with: --allowed-users="YOUR_DISCORD_USER_ID" or set "allowedUsers" in dis-gravity.config.json.`);
408
+ }
409
+
410
+ const client = new Client({
411
+ intents: [
412
+ GatewayIntentBits.Guilds,
413
+ GatewayIntentBits.GuildMessages,
414
+ GatewayIntentBits.MessageContent,
415
+ GatewayIntentBits.DirectMessages
416
+ ],
417
+ partials: [Partials.Channel, Partials.Message]
418
+ });
419
+
420
+ const isUserAllowed = (userId) => coreIsUserAllowed(userId, config.allowedUsers);
421
+
422
+ // Thread-per-Session Workspace Manager & Task Scheduler
423
+ const threadStoragePath = path.join(os.homedir(), '.dis-gravity', 'thread-workspaces.json');
424
+ const threadWorkspaces = new ThreadWorkspaceManager({ storageFile: threadStoragePath });
425
+ const scheduler = new TaskScheduler({ maxJobs: 25 });
426
+
427
+ const resolveThreadWorkspace = (channelObj) => {
428
+ if (channelObj && typeof channelObj.isThread === 'function' && channelObj.isThread()) {
429
+ return threadWorkspaces.getThreadWorkspace(channelObj.id, config.workspace);
430
+ }
431
+ return config.workspace;
432
+ };
433
+
434
+ // Active CLI agent supervisor
435
+ let activeProcess = null;
436
+ let activeStepStatus = null;
437
+ let pendingApproval = null;
438
+ let approvalResolver = null;
439
+
440
+ async function cancelActiveAgent() {
441
+ if (activeProcess) {
442
+ try { activeProcess.kill('SIGINT'); } catch { }
443
+ activeProcess = null;
444
+ }
445
+ activeStepStatus = null;
446
+ if (approvalResolver) {
447
+ approvalResolver('cancel');
448
+ approvalResolver = null;
449
+ pendingApproval = null;
450
+ }
451
+ }
452
+
453
+ async function executeCliAgent(promptText, onStepUpdate, targetWorkspace = config.workspace) {
454
+ const cwd = targetWorkspace || config.workspace;
455
+ const isWindows = process.platform === 'win32';
456
+ const checkCmd = isWindows ? 'where.exe' : 'which';
457
+
458
+ let engine = 'shell';
459
+ const check = (bin) => {
460
+ try { cp.execFileSync(checkCmd, [bin], { stdio: 'ignore' }); return true; } catch { return false; }
461
+ };
462
+
463
+ if (check('agy')) engine = 'agy';
464
+ else if (check('claude')) engine = 'claude';
465
+ else if (check('aider')) engine = 'aider';
466
+
467
+ let binary = '';
468
+ let args = [];
469
+
470
+ if (engine === 'agy') {
471
+ binary = 'agy';
472
+ args = [promptText];
473
+ } else if (engine === 'claude') {
474
+ binary = 'claude';
475
+ args = ['-p', promptText];
476
+ } else if (engine === 'aider') {
477
+ binary = 'aider';
478
+ args = ['--message', promptText, '--yes-always'];
479
+ } else {
480
+ binary = isWindows ? 'powershell.exe' : '/bin/bash';
481
+ args = isWindows ? ['-NoProfile', '-Command', promptText] : ['-c', promptText];
482
+ }
483
+
484
+ log.appendLine(`[Agent] Spawning ${binary} (${engine.toUpperCase()}) in ${cwd}...`);
485
+ activeStepStatus = `Running ${engine.toUpperCase()} agent...`;
486
+ onStepUpdate?.(activeStepStatus);
487
+
488
+ return new Promise((resolve) => {
489
+ let stdout = '';
490
+ let stderr = '';
491
+ const startTime = Date.now();
492
+
493
+ try {
494
+ const proc = cp.spawn(binary, args, {
495
+ cwd,
496
+ env: { ...process.env, CI: '1', TERM: 'dumb' }
497
+ });
498
+ activeProcess = proc;
499
+
500
+ const stripAnsi = (s) => s.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, '');
501
+
502
+ const MAX_OUTPUT_BUF = 64 * 1024;
503
+ proc.stdout.on('data', (chunk) => {
504
+ const text = stripAnsi(chunk.toString());
505
+ if (stdout.length < MAX_OUTPUT_BUF) {
506
+ stdout += text.slice(0, MAX_OUTPUT_BUF - stdout.length);
507
+ }
508
+ const lastLine = text.trim().split('\n').pop() || '';
509
+ if (lastLine) {
510
+ activeStepStatus = lastLine.substring(0, 80);
511
+ onStepUpdate?.(activeStepStatus);
512
+ }
513
+
514
+ if (/(?:\(|\[)\s*[yY]\s*[\/|]\s*[nN]\s*(?:\)|\])|(?:proceed|allow|continue|confirm|choice|run this)\b.*\?|\?\s+\[[yY]\/[nN]\]/i.test(lastLine)) {
515
+ pendingApproval = {
516
+ question: lastLine,
517
+ options: [
518
+ { id: 'yes', text: '🟢 Proceed / Allow (y)' },
519
+ { id: 'no', text: '🔴 Deny / Cancel (n)' }
520
+ ]
521
+ };
522
+ approvalResolver = (ans) => {
523
+ const isYes = /^(yes|proceed|allow|y)/i.test(ans || '');
524
+ try { proc.stdin.write(isYes ? 'y\n' : 'n\n'); } catch { }
525
+ };
526
+ }
527
+ });
528
+
529
+ proc.stderr.on('data', (chunk) => {
530
+ const text = stripAnsi(chunk.toString());
531
+ if (stderr.length < MAX_OUTPUT_BUF) {
532
+ stderr += text.slice(0, MAX_OUTPUT_BUF - stderr.length);
533
+ }
534
+ });
535
+
536
+ proc.on('close', (code) => {
537
+ activeProcess = null;
538
+ activeStepStatus = null;
539
+ pendingApproval = null;
540
+ approvalResolver = null;
541
+ const dur = ((Date.now() - startTime) / 1000).toFixed(1);
542
+ const badge = code === 0 ? `✅ **Process completed in ${dur}s (exit 0)**` : `⚠️ **Process exited with code ${code} (${dur}s)**`;
543
+ const output = (stdout.trim() || stderr.trim()) || '(No output recorded)';
544
+ resolve(`${badge}\n\n\`\`\`text\n${output.substring(0, 1800)}\n\`\`\``);
545
+ });
546
+
547
+ proc.on('error', (err) => {
548
+ activeProcess = null;
549
+ activeStepStatus = null;
550
+ resolve(`❌ **Failed to spawn CLI process (${binary}):** ${err.message}`);
551
+ });
552
+ } catch (err) {
553
+ activeProcess = null;
554
+ activeStepStatus = null;
555
+ resolve(`❌ **Execution error:** ${err.message}`);
556
+ }
557
+ });
558
+ }
559
+
560
+ function buildScheduleListPayload() {
561
+ const jobs = scheduler.listJobs();
562
+ if (jobs.length === 0) {
563
+ return {
564
+ content: `⏰ **Scheduled Prompts (0 active)**\n\nNo scheduled tasks are currently queued.\n💡 Schedule one with: \`/schedule time:30m prompt:run npm test\` or \`!schedule every 1h check git status\``,
565
+ components: []
566
+ };
567
+ }
568
+
569
+ let msg = `⏰ **Active Scheduled Prompts (${jobs.length}):**\n\n`;
570
+ for (const j of jobs) {
571
+ const nextStr = j.nextRunAt ? new Date(j.nextRunAt).toLocaleTimeString() : 'N/A';
572
+ const wsBadge = j.workspace ? ` • 📁 \`${path.basename(j.workspace)}\`` : '';
573
+ msg += `• **#${j.id}** [\`${j.spec}\` — ${j.description}] → \`${j.prompt.substring(0, 80)}\` (Next: ${nextStr}${wsBadge})\n`;
574
+ }
575
+
576
+ const row = new ActionRowBuilder();
577
+ for (const j of jobs.slice(0, 4)) {
578
+ row.addComponents(
579
+ new ButtonBuilder()
580
+ .setCustomId(`schedule:cancel:${j.id}`)
581
+ .setLabel(`🗑️ Cancel #${j.id}`)
582
+ .setStyle(ButtonStyle.Secondary)
583
+ );
584
+ }
585
+ row.addComponents(
586
+ new ButtonBuilder()
587
+ .setCustomId('schedule:cancel_all')
588
+ .setLabel('🧹 Cancel All')
589
+ .setStyle(ButtonStyle.Danger)
590
+ );
591
+
592
+ return { content: msg.substring(0, 1900), components: [row] };
593
+ }
594
+
595
+ async function triggerScheduledJob(job) {
596
+ try {
597
+ const targetChannelId = job.threadId || config.channelId;
598
+ const targetChannel = await client.channels.fetch(targetChannelId).catch(() => null);
599
+ if (!targetChannel) return;
600
+ const targetWs = job.workspace || config.workspace;
601
+ const statusMsg = await targetChannel.send(`⏰ **Running Scheduled Task #${job.id}** (\`${job.spec}\` | 📁 \`${path.basename(targetWs)}\`):\n> ${job.prompt}`);
602
+ const res = await executeCliAgent(job.prompt, null, targetWs);
603
+ await statusMsg.edit(`⏰ **Scheduled Task #${job.id} Result** (\`${job.spec}\`):\n> ${job.prompt}\n\n${res}`).catch(() => { });
604
+ } catch (err) {
605
+ log.appendLine(`[Schedule] Error running job #${job.id}: ${err.message}`);
606
+ }
607
+ }
608
+
609
+ async function getDaemonStatusPayload(customHeader = null, targetWorkspace = config.workspace) {
610
+ const health = getSystemHealth(targetWorkspace);
611
+ const gitInfo = await getGitStatusAndDiff(targetWorkspace);
612
+ const activeSchedules = scheduler.listJobs().length;
613
+ const activeBindings = threadWorkspaces.listBindings().length;
614
+
615
+ let msg = "";
616
+ if (customHeader) {
617
+ msg += `${customHeader}\n\n`;
618
+ }
619
+ msg += `📊 **Dis-Gravity Standalone Daemon Status**\n\n`;
620
+ msg += `• **Project:** 📁 \`${path.basename(targetWorkspace)}\`\n`;
621
+ msg += `• **Workspace:** \`${targetWorkspace}\`\n`;
622
+ msg += `• **Git Branch:** 🌿 \`${gitInfo.branch}\`\n`;
623
+ msg += `• **Provider:** 🤖 🟢 **Standalone Daemon (${config.provider.toUpperCase()})**\n`;
624
+ msg += `• **Daemon State:** ${activeProcess ? '⏳ Busy (Process active)' : '🟢 Idle (Waiting for commands)'}\n`;
625
+ msg += `• **Schedules / Threads:** ⏰ ${activeSchedules} scheduled | 🧵 ${activeBindings} bound thread(s)\n`;
626
+ msg += `• **Host System:** 💻 ${health.platform} (${health.release}, ${health.arch})\n`;
627
+ msg += `• **Memory:** 📈 ${health.memory}\n`;
628
+ if (!config.allowedUsers || config.allowedUsers.length === 0) {
629
+ msg += `\n> 🛡️ *Security active: No \`allowedUsers\` configured. Remote execution is BLOCKED until your Discord User ID is added.*`;
630
+ }
631
+
632
+ const actionRow = new ActionRowBuilder().addComponents(
633
+ new ButtonBuilder()
634
+ .setCustomId("daemon:status")
635
+ .setLabel("🔄 Refresh")
636
+ .setStyle(ButtonStyle.Primary),
637
+ new ButtonBuilder()
638
+ .setCustomId("daemon:diff")
639
+ .setLabel("🌿 Git Diff")
640
+ .setStyle(ButtonStyle.Secondary),
641
+ new ButtonBuilder()
642
+ .setCustomId("daemon:info")
643
+ .setLabel("🖥️ System Info")
644
+ .setStyle(ButtonStyle.Secondary)
645
+ );
646
+
647
+ return { content: msg.substring(0, 1900), components: [actionRow] };
648
+ }
649
+
650
+ async function sendDaemonStatusNotification(channel, header = "🚀 **Dis-Gravity Daemon Connected & Ready**") {
651
+ try {
652
+ const payload = await getDaemonStatusPayload(header);
653
+ await channel.send(payload);
654
+ log.appendLine(`Sent automatic status notification to #${channel.name || channel.id}`);
655
+ } catch (err) {
656
+ log.appendLine(`Failed to send daemon status notification: ${err.message}`);
657
+ }
658
+ }
659
+
660
+ client.once('ready', async () => {
661
+ log.appendLine(`🚀 Dis-Gravity Daemon logged in as ${client.user.tag}`);
662
+ const channel = await client.channels.fetch(config.channelId).catch(() => null);
663
+ if (!channel) {
664
+ log.appendLine(`⚠️ Warning: Could not find channel with ID ${config.channelId}`);
665
+ } else {
666
+ log.appendLine(`Connected to Discord Channel: #${channel.name || config.channelId}`);
667
+ await sendDaemonStatusNotification(channel, "🚀 **Dis-Gravity Daemon Connected & Ready**");
668
+ }
669
+
670
+ // Register slash commands
671
+ try {
672
+ const slashCommands = [
673
+ { name: "sh", description: "Execute shell command in workspace", options: [{ name: "command", description: "Command to execute", type: 3, required: true }] },
674
+ { name: "diff", description: "View git diff summary and preview" },
675
+ { name: "info", description: "View host hardware and workspace health dashboard" },
676
+ { name: "tree", description: "View workspace directory tree" },
677
+ {
678
+ name: "schedule",
679
+ description: "Schedule a delayed or recurring cron prompt, or list/cancel active schedules",
680
+ options: [
681
+ { name: "time", description: "Delay or cron (e.g. '30m', 'every 1h', '0 9 * * *', 'list', or 'cancel <id>')", type: 3, required: false },
682
+ { name: "prompt", description: "Prompt or command to execute when triggered", type: 3, required: false }
683
+ ]
684
+ },
685
+ {
686
+ name: "bind",
687
+ description: "Bind the current Discord thread to a specific local workspace directory",
688
+ options: [
689
+ { name: "workspace", description: "Path to workspace directory (or 'unbind' to clear)", type: 3, required: true }
690
+ ]
691
+ },
692
+ { name: "threads", description: "List all Discord threads bound to isolated workspaces and sessions" },
693
+ { name: "stop", description: "Cancel running agent or command" },
694
+ { name: "status", description: "View daemon status and provider connection" },
695
+ { name: "help", description: "Display Dis-Gravity commands" }
696
+ ];
697
+
698
+ if (config.guildId) {
699
+ const guild = await client.guilds.fetch(config.guildId).catch(() => null);
700
+ if (guild) {
701
+ await guild.commands.set(slashCommands);
702
+ log.appendLine(`Registered ${slashCommands.length} guild slash commands in ${guild.name}`);
703
+ }
704
+ } else {
705
+ await client.application.commands.set(slashCommands);
706
+ log.appendLine(`Registered ${slashCommands.length} global slash commands`);
707
+ }
708
+ } catch (err) {
709
+ log.appendLine(`Notice: Could not register slash commands (${err.message}). Prefix commands (!sh, !diff, etc.) are fully active.`);
710
+ }
711
+ });
712
+
713
+ // Handle Slash Commands and Buttons
714
+ client.on('interactionCreate', async (interaction) => {
715
+ try {
716
+ if (!isUserAllowed(interaction.user.id)) {
717
+ if (!interaction.replied && !interaction.deferred) {
718
+ const hasWhitelist = config.allowedUsers && config.allowedUsers.length > 0;
719
+ const denial = hasWhitelist
720
+ ? `⛔ **Unauthorized:** Your Discord User ID (\`${interaction.user.id}\`) is not on the Dis-Gravity whitelist.`
721
+ : `⛔ **Unauthorized:** No \`allowedUsers\` whitelist is configured in Dis-Gravity daemon. For security, all remote interactions are blocked by default. Add your Discord User ID (\`${interaction.user.id}\`) via \`--allowed-users\` or in \`dis-gravity.config.json\`.`;
722
+ await interaction.reply({ content: denial, ephemeral: true });
723
+ }
724
+ return;
725
+ }
726
+
727
+ const activeWs = resolveThreadWorkspace(interaction.channel);
728
+
729
+ if (interaction.isButton()) {
730
+ const id = interaction.customId;
731
+ if (id === 'daemon:status') {
732
+ await interaction.deferUpdate().catch(() => { });
733
+ const statusPayload = await getDaemonStatusPayload(null, activeWs);
734
+ await interaction.editReply(statusPayload).catch(() => { });
735
+ return;
736
+ }
737
+ if (id === 'daemon:diff') {
738
+ await interaction.deferReply({ ephemeral: true }).catch(() => { });
739
+ const st = await executeShellCommand('git status --short', activeWs);
740
+ const df = await executeShellCommand('git diff --stat', activeWs);
741
+ let reply = `🌿 **Git Status & Diff Preview** (\`${path.basename(activeWs)}\`)\n\n\`\`\`diff\n${st.stdout || 'Clean working tree'}\n\`\`\`\n`;
742
+ if (df.stdout) reply += `**Diff Stat:**\n\`\`\`text\n${df.stdout}\n\`\`\``;
743
+ await interaction.editReply(reply).catch(() => { });
744
+ return;
745
+ }
746
+ if (id === 'daemon:info') {
747
+ await interaction.deferReply({ ephemeral: true }).catch(() => { });
748
+ const cpus = os.cpus();
749
+ const cpuModel = cpus && cpus[0] ? cpus[0].model.trim() : 'CPU';
750
+ const freeGB = (os.freemem() / (1024 ** 3)).toFixed(1);
751
+ const totalGB = (os.totalmem() / (1024 ** 3)).toFixed(1);
752
+ const st = await executeShellCommand('git rev-parse --abbrev-ref HEAD', activeWs);
753
+ let msg = `🖥️ **Host & System Info**\n\n`;
754
+ msg += `• **Project:** \`${path.basename(activeWs)}\`\n`;
755
+ msg += `• **Workspace:** \`${activeWs}\`\n`;
756
+ msg += `• **Branch:** \`${st.stdout.trim() || 'unknown'}\`\n`;
757
+ msg += `• **System:** ${os.platform()} (${os.release()}, ${os.arch()})\n`;
758
+ msg += `• **CPU:** ${cpuModel} (${cpus ? cpus.length : 1} cores)\n`;
759
+ msg += `• **Memory:** ${freeGB} GB free / ${totalGB} GB total\n`;
760
+ await interaction.editReply(msg).catch(() => { });
761
+ return;
762
+ }
763
+ if (id === 'schedule:cancel_all') {
764
+ const count = scheduler.cancelAll();
765
+ await interaction.update({ content: `🧹 **Cancelled all ${count} scheduled task(s).**`, components: [] }).catch(() => { });
766
+ return;
767
+ }
768
+ if (id.startsWith('schedule:cancel:')) {
769
+ const jobId = id.replace('schedule:cancel:', '');
770
+ scheduler.cancelJob(jobId);
771
+ const updated = buildScheduleListPayload();
772
+ await interaction.update(updated).catch(() => { });
773
+ return;
774
+ }
775
+ if (id.startsWith('approve_') && approvalResolver) {
776
+ const choice = id.replace('approve_', '');
777
+ approvalResolver(choice);
778
+ approvalResolver = null;
779
+ pendingApproval = null;
780
+ await interaction.reply({ content: `✅ **Action approved:** \`${choice}\``, ephemeral: true }).catch(() => { });
781
+ return;
782
+ }
783
+ if (id === 'agent_cancel') {
784
+ await cancelActiveAgent();
785
+ await interaction.reply({ content: "🛑 **Execution halted.**", ephemeral: true }).catch(() => { });
786
+ return;
787
+ }
788
+ }
789
+
790
+ if (interaction.isChatInputCommand()) {
791
+ const cmd = interaction.commandName;
792
+ if (cmd === 'sh') {
793
+ const commandStr = interaction.options.getString('command');
794
+ await interaction.deferReply();
795
+ const res = await executeShellCommand(commandStr, activeWs);
796
+ const badge = res.code === 0 ? `✅ **Exit 0 (${res.duration}s)** | 📁 \`${path.basename(activeWs)}\`` : `❌ **Exit ${res.code} (${res.duration}s)** | 📁 \`${path.basename(activeWs)}\``;
797
+ const out = res.stdout || res.stderr || '(No output)';
798
+ await interaction.editReply(`${badge}\n\`\`\`text\n${out.substring(0, 1800)}\n\`\`\``);
799
+ return;
800
+ }
801
+
802
+ if (cmd === 'diff') {
803
+ await interaction.deferReply();
804
+ const st = await executeShellCommand('git status --short', activeWs);
805
+ const df = await executeShellCommand('git diff --stat', activeWs);
806
+ let reply = `🌿 **Git Status & Diff Preview** (\`${path.basename(activeWs)}\`)\n\n`;
807
+ reply += `\`\`\`diff\n${st.stdout || 'Clean working tree'}\n\`\`\`\n`;
808
+ if (df.stdout) reply += `**Diff Stat:**\n\`\`\`text\n${df.stdout}\n\`\`\``;
809
+ await interaction.editReply(reply);
810
+ return;
811
+ }
812
+
813
+ if (cmd === 'tree') {
814
+ await interaction.deferReply();
815
+ const tree = listWorkspaceTree(activeWs, '.', 2, 80);
816
+ await interaction.editReply(`📁 **Workspace Tree (\`${path.basename(activeWs)}\`):**\n\`\`\`text\n${tree.substring(0, 1800)}\n\`\`\``);
817
+ return;
818
+ }
819
+
820
+ if (cmd === 'schedule') {
821
+ const timeOpt = (interaction.options.getString('time') || '').trim();
822
+ const promptOpt = (interaction.options.getString('prompt') || '').trim();
823
+ if (!timeOpt || /^list$/i.test(timeOpt)) {
824
+ await interaction.reply(buildScheduleListPayload());
825
+ return;
826
+ }
827
+ if (/^cancel/i.test(timeOpt)) {
828
+ const target = timeOpt.replace(/^cancel\s*/i, '').trim() || promptOpt;
829
+ if (target === 'all') {
830
+ const count = scheduler.cancelAll();
831
+ await interaction.reply(`🧹 **Cancelled all ${count} scheduled task(s).**`);
832
+ } else {
833
+ const ok = scheduler.cancelJob(target);
834
+ await interaction.reply(ok ? `🗑️ **Cancelled scheduled task #${target}.**` : `⚠️ **Schedule #${target} not found.**`);
835
+ }
836
+ return;
837
+ }
838
+ if (!promptOpt) {
839
+ const parsedCmd = parseScheduleCommandInput(timeOpt);
840
+ if (parsedCmd.action === 'add') {
841
+ const job = scheduler.addJob({
842
+ spec: parsedCmd.parsedSpec,
843
+ prompt: parsedCmd.prompt,
844
+ threadId: interaction.channel?.isThread?.() ? interaction.channelId : null,
845
+ workspace: activeWs,
846
+ author: interaction.user.username,
847
+ onTrigger: triggerScheduledJob
848
+ });
849
+ await interaction.reply(`⏰ **Scheduled Task #${job.id}** (\`${job.spec}\` — ${job.description})\n• **Prompt:** \`${job.prompt}\`\n• **Workspace:** \`${activeWs}\`\n• **Next Run:** ${new Date(job.nextRunAt).toLocaleString()}`);
850
+ return;
851
+ }
852
+ await interaction.reply({ content: `⚠️ Please specify both \`time\` and \`prompt\`, e.g. \`/schedule time:30m prompt:run npm test\`.`, ephemeral: true });
853
+ return;
854
+ }
855
+ try {
856
+ const job = scheduler.addJob({
857
+ spec: timeOpt,
858
+ prompt: promptOpt,
859
+ threadId: interaction.channel?.isThread?.() ? interaction.channelId : null,
860
+ workspace: activeWs,
861
+ author: interaction.user.username,
862
+ onTrigger: triggerScheduledJob
863
+ });
864
+ await interaction.reply(`⏰ **Scheduled Task #${job.id}** (\`${job.spec}\` — ${job.description})\n• **Prompt:** \`${job.prompt}\`\n• **Workspace:** \`${activeWs}\`\n• **Next Run:** ${new Date(job.nextRunAt).toLocaleString()}`);
865
+ } catch (err) {
866
+ await interaction.reply({ content: `❌ **Schedule Error:** ${err.message}`, ephemeral: true });
867
+ }
868
+ return;
869
+ }
870
+
871
+ if (cmd === 'bind') {
872
+ const wsArg = (interaction.options.getString('workspace') || '').trim();
873
+ if (!interaction.channel?.isThread?.()) {
874
+ await interaction.reply({ content: `⚠️ \`/bind\` must be run inside a Discord thread, or use \`!thread <name> <workspace_path>\` in the main channel to create a bound workspace thread.`, ephemeral: true });
875
+ return;
876
+ }
877
+ if (/^(unbind|clear|none)$/i.test(wsArg)) {
878
+ threadWorkspaces.unbindThread(interaction.channelId);
879
+ await interaction.reply(`🔓 **Unbound thread from custom workspace.** Reverting to default workspace (\`${config.workspace}\`).`);
880
+ return;
881
+ }
882
+ try {
883
+ const binding = threadWorkspaces.bindThread(interaction.channelId, wsArg, {
884
+ boundBy: interaction.user.username,
885
+ baseWorkspace: path.dirname(config.workspace)
886
+ });
887
+ await interaction.reply(`🧵 **Thread Bound to Workspace:** 📁 **\`${binding.label}\`**\n> Path: \`${binding.workspace}\`\n\nAll commands (\`/sh\`, \`/diff\`, \`/tree\`, prompts) in this thread now execute isolated in this workspace.`);
888
+ } catch (err) {
889
+ await interaction.reply({ content: `❌ **Failed to bind workspace:** ${err.message}`, ephemeral: true });
890
+ }
891
+ return;
892
+ }
893
+
894
+ if (cmd === 'threads') {
895
+ const list = threadWorkspaces.listBindings();
896
+ if (list.length === 0) {
897
+ await interaction.reply(`🧵 **Bound Workspace Threads (0 active)**\n\nNo threads are currently bound to custom workspaces.\n💡 Use \`/bind <workspace_path>\` inside any thread or \`!thread <name> <workspace_path>\` to create one.`);
898
+ return;
899
+ }
900
+ let msg = `🧵 **Active Workspace Thread Bindings (${list.length}):**\n\n`;
901
+ for (const b of list) {
902
+ msg += `• <#${b.threadId}> → 📁 **\`${b.label}\`** (\`${b.workspace}\`)${b.conversationName ? ` • 💬 *${b.conversationName}*` : ''}\n`;
903
+ }
904
+ await interaction.reply(msg.substring(0, 1900));
905
+ return;
906
+ }
907
+
908
+ if (cmd === 'info') {
909
+ await interaction.deferReply();
910
+ const cpus = os.cpus();
911
+ const cpuModel = cpus && cpus[0] ? cpus[0].model.trim() : 'CPU';
912
+ const freeGB = (os.freemem() / (1024 ** 3)).toFixed(1);
913
+ const totalGB = (os.totalmem() / (1024 ** 3)).toFixed(1);
914
+ const st = await executeShellCommand('git rev-parse --abbrev-ref HEAD', activeWs);
915
+ let msg = `🖥️ **Dis-Gravity Host & Workspace Health Dashboard**\n\n`;
916
+ msg += `• **Project:** \`${path.basename(activeWs)}\`\n`;
917
+ msg += `• **Path:** \`${activeWs}\`\n`;
918
+ msg += `• **Git Branch:** \`${st.stdout.trim() || 'unknown'}\`\n`;
919
+ msg += `• **Provider:** 🟢 **Standalone Daemon (${config.provider.toUpperCase()})**\n`;
920
+ msg += `• **System:** ${os.platform()} (${os.release()}, ${os.arch()})\n`;
921
+ msg += `• **CPU:** ${cpuModel} (${cpus ? cpus.length : 1} cores)\n`;
922
+ msg += `• **Memory:** ${freeGB} GB free / ${totalGB} GB total\n`;
923
+ msg += `• **Node Runtime:** ${process.version} (PID: ${process.pid})\n`;
924
+ await interaction.editReply(msg);
925
+ return;
926
+ }
927
+
928
+ if (cmd === 'status') {
929
+ await interaction.deferReply().catch(() => { });
930
+ const statusPayload = await getDaemonStatusPayload(null, activeWs);
931
+ await interaction.editReply(statusPayload).catch(() => { });
932
+ return;
933
+ }
934
+
935
+ if (cmd === 'stop') {
936
+ await cancelActiveAgent();
937
+ await interaction.reply('🛑 **Agent generation / command cancelled.**');
938
+ return;
939
+ }
940
+
941
+ if (cmd === 'help') {
942
+ let h = `🤖 **Dis-Gravity Standalone Daemon Commands**\n\n`;
943
+ h += `• \`/sh <cmd>\` or \`!sh <cmd>\` — Run terminal command\n`;
944
+ h += `• \`/diff\` or \`!diff\` — View git status and diff preview\n`;
945
+ h += `• \`/schedule <time> <prompt>\` or \`!schedule\` — Schedule delayed or cron prompts\n`;
946
+ h += `• \`/bind <workspace>\` or \`!bind <path>\` — Bind current thread to a workspace folder\n`;
947
+ h += `• \`/threads\` or \`!threads\` — List bound workspace threads\n`;
948
+ h += `• 🎙️ **Voice Notes** — Send a Discord voice message to auto-transcribe & run\n`;
949
+ h += `• \`/info\` or \`!info\` — System hardware and workspace dashboard\n`;
950
+ h += `• \`/stop\` or \`!stop\` — Cancel running process\n`;
951
+ await interaction.reply(h);
952
+ return;
953
+ }
954
+ }
955
+ } catch (err) {
956
+ log.appendLine(`Interaction error: ${err.message}`);
957
+ }
958
+ });
959
+
960
+ // Handle Chat Messages
961
+ client.on('messageCreate', async (message) => {
962
+ if (message.author.bot) return;
963
+ const isMainChannel = message.channelId === config.channelId;
964
+ const isBoundThread = message.channel?.isThread?.() && message.channel.parentId === config.channelId;
965
+ if (!isMainChannel && !isBoundThread && !message.channel.isDMBased()) return;
966
+
967
+ if (!isUserAllowed(message.author.id)) {
968
+ const hasWhitelist = config.allowedUsers && config.allowedUsers.length > 0;
969
+ const denial = hasWhitelist
970
+ ? `⛔ **Unauthorized:** Your Discord User ID (\`${message.author.id}\`) is not in the Dis-Gravity whitelist.`
971
+ : `⛔ **Unauthorized:** No \`allowedUsers\` whitelist is configured in Dis-Gravity daemon. For security, all remote commands and prompts are blocked by default. Add your Discord User ID (\`${message.author.id}\`) via \`--allowed-users\` or in \`dis-gravity.config.json\`.`;
972
+ await message.reply(denial).catch(() => { });
973
+ return;
974
+ }
975
+
976
+ const activeWs = resolveThreadWorkspace(message.channel);
977
+ let text = message.content.trim();
978
+
979
+ // Voice Message / Audio Attachment Transcription
980
+ if (message.attachments && message.attachments.size > 0) {
981
+ const attachmentsList = Array.from(message.attachments.values());
982
+ const audioAtt = attachmentsList.find(a => isAudioAttachment(a, message.flags?.bitfield || 0));
983
+ if (audioAtt && audioAtt.url) {
984
+ try {
985
+ const parsedUrl = new URL(audioAtt.url);
986
+ if (parsedUrl.protocol === 'https:' && ['cdn.discordapp.com', 'media.discordapp.net'].includes(parsedUrl.hostname.toLowerCase())) {
987
+ const tmpDir = path.join(activeWs, '.disgravity', 'attachments');
988
+ if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
989
+ const ext = path.extname(audioAtt.name || 'voice.ogg') || '.ogg';
990
+ const localAudioPath = path.join(tmpDir, `voice_${Date.now()}${ext}`);
991
+ const res = await fetch(audioAtt.url);
992
+ if (res.ok) {
993
+ const buf = Buffer.from(await res.arrayBuffer());
994
+ fs.writeFileSync(localAudioPath, buf);
995
+ const stt = await transcribeAudioFile(localAudioPath);
996
+ if (stt.ok && stt.text) {
997
+ await message.reply(`🎙️ **Voice Prompt Transcribed (${stt.provider}):**\n> *"${stt.text}"*`).catch(() => { });
998
+ text = text ? `${text}\n${stt.text}` : stt.text;
999
+ } else if (!text) {
1000
+ await message.reply(`🎙️ **Voice message saved** (\`${path.basename(localAudioPath)}\`), but automatic transcription is not configured (${stt.error}). Forwarding audio file path to agent.`).catch(() => { });
1001
+ text = `Please process the attached audio recording at ${localAudioPath}`;
1002
+ }
1003
+ }
1004
+ }
1005
+ } catch (err) {
1006
+ log.appendLine(`[Voice] Error processing audio attachment: ${err.message}`);
1007
+ }
1008
+ }
1009
+ }
1010
+
1011
+ if (!text) return;
1012
+
1013
+ // Schedule command (!schedule or /schedule)
1014
+ if (text === '!schedule' || text.startsWith('!schedule ') || text === '/schedule' || text.startsWith('/schedule ')) {
1015
+ const rawArg = text.replace(/^[/!]schedule\s*/i, '').trim();
1016
+ const parsedCmd = parseScheduleCommandInput(rawArg);
1017
+ if (parsedCmd.action === 'list') {
1018
+ await message.reply(buildScheduleListPayload()).catch(() => { });
1019
+ return;
1020
+ }
1021
+ if (parsedCmd.action === 'cancel') {
1022
+ if (parsedCmd.target === 'all') {
1023
+ const count = scheduler.cancelAll();
1024
+ await message.reply(`🧹 **Cancelled all ${count} scheduled task(s).**`);
1025
+ } else {
1026
+ const ok = scheduler.cancelJob(parsedCmd.target);
1027
+ await message.reply(ok ? `🗑️ **Cancelled scheduled task #${parsedCmd.target}.**` : `⚠️ **Schedule #${parsedCmd.target} not found.**`);
1028
+ }
1029
+ return;
1030
+ }
1031
+ if (parsedCmd.action === 'add') {
1032
+ try {
1033
+ const job = scheduler.addJob({
1034
+ spec: parsedCmd.parsedSpec,
1035
+ prompt: parsedCmd.prompt,
1036
+ threadId: isBoundThread ? message.channelId : null,
1037
+ workspace: activeWs,
1038
+ author: message.author.username,
1039
+ onTrigger: triggerScheduledJob
1040
+ });
1041
+ await message.reply(`⏰ **Scheduled Task #${job.id}** (\`${job.spec}\` — ${job.description})\n• **Prompt:** \`${job.prompt}\`\n• **Workspace:** \`${activeWs}\`\n• **Next Run:** ${new Date(job.nextRunAt).toLocaleString()}`);
1042
+ } catch (err) {
1043
+ await message.reply(`❌ **Schedule Error:** ${err.message}`);
1044
+ }
1045
+ return;
1046
+ }
1047
+ await message.reply(`⚠️ ${parsedCmd.error}`);
1048
+ return;
1049
+ }
1050
+
1051
+ // Bind thread to workspace (!bind <path> or /bind <path>)
1052
+ if (text.startsWith('!bind ') || text.startsWith('/bind ')) {
1053
+ const wsArg = text.replace(/^[/!]bind\s+/i, '').trim();
1054
+ if (!isBoundThread) {
1055
+ await message.reply(`⚠️ Use \`!bind <workspace_path>\` inside a Discord thread, or use \`!thread <name> <workspace_path>\` here to create a new workspace thread.`);
1056
+ return;
1057
+ }
1058
+ if (/^(unbind|clear|none)$/i.test(wsArg)) {
1059
+ threadWorkspaces.unbindThread(message.channelId);
1060
+ await message.reply(`🔓 **Unbound thread from custom workspace.** Reverting to \`${config.workspace}\`.`);
1061
+ return;
1062
+ }
1063
+ try {
1064
+ const binding = threadWorkspaces.bindThread(message.channelId, wsArg, {
1065
+ boundBy: message.author.username,
1066
+ baseWorkspace: path.dirname(config.workspace)
1067
+ });
1068
+ await message.reply(`🧵 **Thread Bound to Workspace:** 📁 **\`${binding.label}\`**\n> Path: \`${binding.workspace}\``);
1069
+ } catch (err) {
1070
+ await message.reply(`❌ **Failed to bind workspace:** ${err.message}`);
1071
+ }
1072
+ return;
1073
+ }
1074
+
1075
+ // Create bound workspace thread (!thread <name> [workspace_path])
1076
+ if (text.startsWith('!thread ') || text.startsWith('/thread ')) {
1077
+ const rawArgs = text.replace(/^[/!]thread\s+/i, '').trim();
1078
+ const parts = rawArgs.split(/\s+/);
1079
+ const threadName = parts[0] || 'session';
1080
+ const wsArg = parts.slice(1).join(' ') || activeWs;
1081
+ try {
1082
+ const thread = await message.startThread({
1083
+ name: `📁 ${threadName}`.substring(0, 100),
1084
+ autoArchiveDuration: 1440
1085
+ });
1086
+ const binding = threadWorkspaces.bindThread(thread.id, wsArg, {
1087
+ label: threadName,
1088
+ boundBy: message.author.username,
1089
+ baseWorkspace: path.dirname(config.workspace)
1090
+ });
1091
+ await thread.send(`🧵 **Dedicated Workspace Thread Ready**\n• **Session:** \`${threadName}\`\n• **Bound Workspace:** \`${binding.workspace}\`\n\nAll commands and AI prompts in this thread will run isolated in \`${binding.workspace}\`.`);
1092
+ } catch (err) {
1093
+ await message.reply(`❌ **Could not create workspace thread:** ${err.message}`);
1094
+ }
1095
+ return;
1096
+ }
1097
+
1098
+ // List bound threads (!threads or /threads)
1099
+ if (text === '!threads' || text === '/threads') {
1100
+ const list = threadWorkspaces.listBindings();
1101
+ if (list.length === 0) {
1102
+ await message.reply(`🧵 **Bound Workspace Threads (0 active)**\nUse \`!bind <workspace_path>\` inside a thread or \`!thread <name> <workspace_path>\` to create one.`);
1103
+ return;
1104
+ }
1105
+ let msg = `🧵 **Active Workspace Thread Bindings (${list.length}):**\n\n`;
1106
+ for (const b of list) {
1107
+ msg += `• <#${b.threadId}> → 📁 **\`${b.label}\`** (\`${b.workspace}\`)\n`;
1108
+ }
1109
+ await message.reply(msg.substring(0, 1900));
1110
+ return;
1111
+ }
1112
+
1113
+ // Shell command prefix
1114
+ if (text.startsWith('!sh ') || text.startsWith('$ ')) {
1115
+ const cmdStr = text.replace(/^(!sh |\$ )/, '');
1116
+ const statusMsg = await message.reply(`⚡ Running: \`${cmdStr}\` in \`${path.basename(activeWs)}\`...`);
1117
+ const res = await executeShellCommand(cmdStr, activeWs);
1118
+ const badge = res.code === 0 ? `✅ **Exit 0 (${res.duration}s)** | 📁 \`${path.basename(activeWs)}\`` : `❌ **Exit ${res.code} (${res.duration}s)** | 📁 \`${path.basename(activeWs)}\``;
1119
+ const out = res.stdout || res.stderr || '(No output)';
1120
+ await statusMsg.edit(`${badge}\n\`\`\`text\n${out.substring(0, 1800)}\n\`\`\``);
1121
+ return;
1122
+ }
1123
+
1124
+ // Status
1125
+ if (text === '!status' || text === '/status') {
1126
+ const statusPayload = await getDaemonStatusPayload(null, activeWs);
1127
+ await message.reply(statusPayload).catch(() => { });
1128
+ return;
1129
+ }
1130
+
1131
+ // Git Diff
1132
+ if (text === '!diff' || text === '/diff') {
1133
+ const st = await executeShellCommand('git status --short', activeWs);
1134
+ const df = await executeShellCommand('git diff --stat', activeWs);
1135
+ let reply = `🌿 **Git Status & Diff Preview** (\`${path.basename(activeWs)}\`)\n\n\`\`\`diff\n${st.stdout || 'Clean working tree'}\n\`\`\`\n`;
1136
+ if (df.stdout) reply += `**Diff Stat:**\n\`\`\`text\n${df.stdout}\n\`\`\``;
1137
+ await message.reply(reply);
1138
+ return;
1139
+ }
1140
+
1141
+ // Info
1142
+ if (text === '!info' || text === '/info') {
1143
+ const cpus = os.cpus();
1144
+ const cpuModel = cpus && cpus[0] ? cpus[0].model.trim() : 'CPU';
1145
+ const freeGB = (os.freemem() / (1024 ** 3)).toFixed(1);
1146
+ const totalGB = (os.totalmem() / (1024 ** 3)).toFixed(1);
1147
+ const st = await executeShellCommand('git rev-parse --abbrev-ref HEAD', activeWs);
1148
+ let msg = `🖥️ **Dis-Gravity Host & Workspace Health Dashboard**\n\n`;
1149
+ msg += `• **Project:** \`${path.basename(activeWs)}\`\n`;
1150
+ msg += `• **Path:** \`${activeWs}\`\n`;
1151
+ msg += `• **Git Branch:** \`${st.stdout.trim() || 'unknown'}\`\n`;
1152
+ msg += `• **Provider:** 🟢 **Standalone Daemon (${config.provider.toUpperCase()})**\n`;
1153
+ msg += `• **System:** ${os.platform()} (${os.release()}, ${os.arch()})\n`;
1154
+ msg += `• **CPU:** ${cpuModel} (${cpus ? cpus.length : 1} cores)\n`;
1155
+ msg += `• **Memory:** ${freeGB} GB free / ${totalGB} GB total\n`;
1156
+ await message.reply(msg);
1157
+ return;
1158
+ }
1159
+
1160
+ // Stop
1161
+ if (text === '!stop' || text === '/stop') {
1162
+ await cancelActiveAgent();
1163
+ await message.reply('🛑 **Agent generation / command cancelled.**');
1164
+ return;
1165
+ }
1166
+
1167
+ // Forward general prompt to AI Agent
1168
+ const statusMsg = await message.reply('⏳ **Analyzing prompt... (0s)**');
1169
+ let elapsed = 0;
1170
+ const timer = setInterval(async () => {
1171
+ elapsed += 2;
1172
+ const statusText = activeStepStatus ? `⏳ **Working (${elapsed}s):** \`${activeStepStatus}\`` : `⏳ **Analyzing... (${elapsed}s)**`;
1173
+ await statusMsg.edit(statusText).catch(() => { });
1174
+ }, 2000);
1175
+
1176
+ try {
1177
+ const res = await executeCliAgent(text, (step) => {
1178
+ statusMsg.edit(`⏳ **Working (${elapsed}s):** \`${step}\``).catch(() => { });
1179
+ }, activeWs);
1180
+
1181
+ clearInterval(timer);
1182
+ await statusMsg.edit(res);
1183
+ } catch (err) {
1184
+ clearInterval(timer);
1185
+ await statusMsg.edit(`❌ **Error:** ${err.message}`);
1186
+ }
1187
+ });
1188
+
1189
+ await client.login(config.token);
1190
+ }
1191
+
1192
+ if (require.main === module) {
1193
+ startDaemon().catch((err) => {
1194
+ console.error('Fatal Daemon Error:', err);
1195
+ process.exit(1);
1196
+ });
1197
+ }
1198
+
1199
+ module.exports = { startDaemon };