lazy-gravity 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +18 -6
  2. package/dist/bin/cli.js +18 -18
  3. package/dist/bin/commands/doctor.js +2 -1
  4. package/dist/bin/commands/start.js +25 -2
  5. package/dist/bot/index.js +346 -152
  6. package/dist/commands/joinCommandHandler.js +302 -0
  7. package/dist/commands/joinDetachCommandHandler.js +285 -0
  8. package/dist/commands/registerSlashCommands.js +35 -0
  9. package/dist/database/chatSessionRepository.js +10 -0
  10. package/dist/database/userPreferenceRepository.js +72 -0
  11. package/dist/events/interactionCreateHandler.js +58 -36
  12. package/dist/events/messageCreateHandler.js +158 -53
  13. package/dist/services/antigravityLauncher.js +4 -3
  14. package/dist/services/approvalDetector.js +6 -0
  15. package/dist/services/cdpBridgeManager.js +184 -84
  16. package/dist/services/cdpConnectionPool.js +79 -51
  17. package/dist/services/cdpService.js +149 -51
  18. package/dist/services/chatSessionService.js +229 -8
  19. package/dist/services/errorPopupDetector.js +6 -0
  20. package/dist/services/planningDetector.js +6 -0
  21. package/dist/services/responseMonitor.js +125 -24
  22. package/dist/services/updateCheckService.js +147 -0
  23. package/dist/services/userMessageDetector.js +221 -0
  24. package/dist/ui/modeUi.js +11 -1
  25. package/dist/ui/outputUi.js +30 -0
  26. package/dist/ui/sessionPickerUi.js +48 -0
  27. package/dist/utils/antigravityPaths.js +94 -0
  28. package/dist/utils/configLoader.js +10 -0
  29. package/dist/utils/discordButtonUtils.js +33 -0
  30. package/dist/utils/logBuffer.js +47 -0
  31. package/dist/utils/logger.js +80 -20
  32. package/dist/utils/pathUtils.js +57 -0
  33. package/dist/utils/plainTextFormatter.js +70 -0
  34. package/package.json +4 -4
package/dist/bot/index.js CHANGED
@@ -40,6 +40,7 @@ exports.startBot = exports.getResponseDeliveryModeForTest = void 0;
40
40
  exports.createSerialTaskQueueForTest = createSerialTaskQueueForTest;
41
41
  const i18n_1 = require("../utils/i18n");
42
42
  const logger_1 = require("../utils/logger");
43
+ const logBuffer_1 = require("../utils/logBuffer");
43
44
  const discord_js_1 = require("discord.js");
44
45
  const better_sqlite3_1 = __importDefault(require("better-sqlite3"));
45
46
  const config_1 = require("../utils/config");
@@ -56,9 +57,11 @@ const chatCommandHandler_1 = require("../commands/chatCommandHandler");
56
57
  const cleanupCommandHandler_1 = require("../commands/cleanupCommandHandler");
57
58
  const channelManager_1 = require("../services/channelManager");
58
59
  const titleGeneratorService_1 = require("../services/titleGeneratorService");
60
+ const joinCommandHandler_1 = require("../commands/joinCommandHandler");
59
61
  const chatSessionService_1 = require("../services/chatSessionService");
60
62
  const responseMonitor_1 = require("../services/responseMonitor");
61
63
  const antigravityLauncher_1 = require("../services/antigravityLauncher");
64
+ const pathUtils_1 = require("../utils/pathUtils");
62
65
  const promptDispatcher_1 = require("../services/promptDispatcher");
63
66
  const cdpBridgeManager_1 = require("../services/cdpBridgeManager");
64
67
  const streamMessageFormatter_1 = require("../utils/streamMessageFormatter");
@@ -69,7 +72,10 @@ const modeUi_1 = require("../ui/modeUi");
69
72
  const modelsUi_1 = require("../ui/modelsUi");
70
73
  const templateUi_1 = require("../ui/templateUi");
71
74
  const autoAcceptUi_1 = require("../ui/autoAcceptUi");
75
+ const outputUi_1 = require("../ui/outputUi");
72
76
  const screenshotUi_1 = require("../ui/screenshotUi");
77
+ const userPreferenceRepository_1 = require("../database/userPreferenceRepository");
78
+ const plainTextFormatter_1 = require("../utils/plainTextFormatter");
73
79
  const interactionCreateHandler_1 = require("../events/interactionCreateHandler");
74
80
  const messageCreateHandler_1 = require("../events/messageCreateHandler");
75
81
  // =============================================================================
@@ -127,6 +133,17 @@ function createSerialTaskQueueForTest(queueName, traceId) {
127
133
  * - Visualize the flow of planning/analysis/execution confirmation/implementation as logs
128
134
  */
129
135
  async function sendPromptToAntigravity(bridge, message, prompt, cdp, modeService, modelService, inboundImages = [], options) {
136
+ // Completion signal — called exactly once when the entire prompt lifecycle ends
137
+ let completionSignaled = false;
138
+ const signalCompletion = (exitPath) => {
139
+ if (completionSignaled)
140
+ return;
141
+ completionSignaled = true;
142
+ logger_1.logger.debug(`[sendPrompt:${message.channelId}] signalCompletion via ${exitPath}`);
143
+ options?.onFullCompletion?.();
144
+ };
145
+ // Resolve output format once at the start (no mid-response switches)
146
+ const outputFormat = options?.userPrefRepo?.getOutputFormat(message.author.id) ?? 'embed';
130
147
  // Add reaction to acknowledge command receipt
131
148
  await message.react('👀').catch(() => { });
132
149
  const channel = (message.channel && 'send' in message.channel) ? message.channel : null;
@@ -137,6 +154,13 @@ async function sendPromptToAntigravity(bridge, message, prompt, cdp, modeService
137
154
  const sendEmbed = (title, description, color, fields, footerText) => enqueueGeneral(async () => {
138
155
  if (!channel)
139
156
  return;
157
+ if (outputFormat === 'plain') {
158
+ const chunks = (0, plainTextFormatter_1.formatAsPlainText)({ title, description, fields, footerText });
159
+ for (const chunk of chunks) {
160
+ await channel.send({ content: chunk }).catch(() => { });
161
+ }
162
+ return;
163
+ }
140
164
  const embed = new discord_js_1.EmbedBuilder()
141
165
  .setTitle(title)
142
166
  .setDescription(description)
@@ -256,9 +280,10 @@ async function sendPromptToAntigravity(bridge, message, prompt, cdp, modeService
256
280
  }
257
281
  };
258
282
  if (!cdp.isConnected()) {
259
- await sendEmbed(`${PHASE_ICONS.error} Connection Error`, 'Not connected to Antigravity.\nStart with `open -a Antigravity --args --remote-debugging-port=9223`, then send a message to auto-connect.', PHASE_COLORS.error);
283
+ await sendEmbed(`${PHASE_ICONS.error} Connection Error`, `Not connected to Antigravity.\nStart with \`${(0, pathUtils_1.getAntigravityCdpHint)(9223)}\`, then send a message to auto-connect.`, PHASE_COLORS.error);
260
284
  await clearWatchingReaction();
261
285
  await message.react('❌').catch(() => { });
286
+ signalCompletion('cdp-disconnected');
262
287
  return;
263
288
  }
264
289
  const localMode = modeService.getCurrentMode();
@@ -311,6 +336,30 @@ async function sendPromptToAntigravity(bridge, message, prompt, cdp, modeService
311
336
  return;
312
337
  if (!channel)
313
338
  return;
339
+ if (outputFormat === 'plain') {
340
+ const formatted = (0, discordFormatter_1.formatForDiscord)((rawText || '').trim());
341
+ const plainChunks = (0, plainTextFormatter_1.splitPlainText)(`**${title}**\n${formatted}\n_${footerText}_`);
342
+ const renderKey = `${title}|plain|${footerText}|${plainChunks.join('\n<<<PAGE_BREAK>>>\n')}`;
343
+ if (renderKey === lastLiveResponseKey && liveResponseMessages.length > 0)
344
+ return;
345
+ lastLiveResponseKey = renderKey;
346
+ for (let i = 0; i < plainChunks.length; i++) {
347
+ if (!liveResponseMessages[i]) {
348
+ liveResponseMessages[i] = await channel.send({ content: plainChunks[i] }).catch(() => null);
349
+ continue;
350
+ }
351
+ await liveResponseMessages[i].edit({ content: plainChunks[i] }).catch(async () => {
352
+ liveResponseMessages[i] = await channel.send({ content: plainChunks[i] }).catch(() => null);
353
+ });
354
+ }
355
+ while (liveResponseMessages.length > plainChunks.length) {
356
+ const extra = liveResponseMessages.pop();
357
+ if (!extra)
358
+ continue;
359
+ await extra.delete().catch(() => { });
360
+ }
361
+ return;
362
+ }
314
363
  const descriptions = buildLiveResponseDescriptions(rawText);
315
364
  const renderKey = `${title}|${color}|${footerText}|${descriptions.join('\n<<<PAGE_BREAK>>>\n')}`;
316
365
  if (renderKey === lastLiveResponseKey && liveResponseMessages.length > 0) {
@@ -347,6 +396,31 @@ async function sendPromptToAntigravity(bridge, message, prompt, cdp, modeService
347
396
  return;
348
397
  if (!channel)
349
398
  return;
399
+ if (outputFormat === 'plain') {
400
+ const formatted = (0, discordFormatter_1.formatForDiscord)((rawText || '').trim());
401
+ const plainContent = `**${title}**\n${formatted}\n_${footerText}_`;
402
+ const plainChunks = (0, plainTextFormatter_1.splitPlainText)(plainContent);
403
+ const renderKey = `${title}|plain|${footerText}|${plainChunks.join('\n<<<PAGE_BREAK>>>\n')}`;
404
+ if (renderKey === lastLiveActivityKey && liveActivityMessages.length > 0)
405
+ return;
406
+ lastLiveActivityKey = renderKey;
407
+ for (let i = 0; i < plainChunks.length; i++) {
408
+ if (!liveActivityMessages[i]) {
409
+ liveActivityMessages[i] = await channel.send({ content: plainChunks[i] }).catch(() => null);
410
+ continue;
411
+ }
412
+ await liveActivityMessages[i].edit({ content: plainChunks[i] }).catch(async () => {
413
+ liveActivityMessages[i] = await channel.send({ content: plainChunks[i] }).catch(() => null);
414
+ });
415
+ }
416
+ while (liveActivityMessages.length > plainChunks.length) {
417
+ const extra = liveActivityMessages.pop();
418
+ if (!extra)
419
+ continue;
420
+ await extra.delete().catch(() => { });
421
+ }
422
+ return;
423
+ }
350
424
  const descriptions = buildLiveActivityDescriptions(rawText);
351
425
  const renderKey = `${title}|${color}|${footerText}|${descriptions.join('\n<<<PAGE_BREAK>>>\n')}`;
352
426
  if (renderKey === lastLiveActivityKey && liveActivityMessages.length > 0) {
@@ -392,6 +466,7 @@ async function sendPromptToAntigravity(bridge, message, prompt, cdp, modeService
392
466
  await sendEmbed(`${PHASE_ICONS.error} Message Injection Failed`, `Failed to send message: ${injectResult.error}`, PHASE_COLORS.error);
393
467
  await clearWatchingReaction();
394
468
  await message.react('❌').catch(() => { });
469
+ signalCompletion('inject-failed');
395
470
  return;
396
471
  }
397
472
  const startTime = Date.now();
@@ -430,122 +505,127 @@ async function sendPromptToAntigravity(bridge, message, prompt, cdp, modeService
430
505
  },
431
506
  onComplete: async (finalText) => {
432
507
  isFinalized = true;
433
- // If the user explicitly pressed /stop, skip output display entirely
434
- const wasStoppedByUser = userStopRequestedChannels.delete(message.channelId);
435
- if (wasStoppedByUser) {
436
- logger_1.logger.info(`[sendPromptToAntigravity:${monitorTraceId}] Stopped by user — skipping output`);
437
- await clearWatchingReaction();
438
- await message.react('⏹️').catch(() => { });
439
- return;
440
- }
441
508
  try {
442
- const elapsed = Math.round((Date.now() - startTime) / 1000);
443
- const isQuotaError = monitor.getPhase() === 'quotaReached' || monitor.getQuotaDetected();
444
- // Quota early exit — skip text extraction, output logging, and embed entirely
445
- if (isQuotaError) {
509
+ // If the user explicitly pressed /stop, skip output display entirely
510
+ const wasStoppedByUser = userStopRequestedChannels.delete(message.channelId);
511
+ if (wasStoppedByUser) {
512
+ logger_1.logger.info(`[sendPromptToAntigravity:${monitorTraceId}] Stopped by user — skipping output`);
513
+ await clearWatchingReaction();
514
+ await message.react('⏹️').catch(() => { });
515
+ return;
516
+ }
517
+ try {
518
+ const elapsed = Math.round((Date.now() - startTime) / 1000);
519
+ const isQuotaError = monitor.getPhase() === 'quotaReached' || monitor.getQuotaDetected();
520
+ // Quota early exit — skip text extraction, output logging, and embed entirely
521
+ if (isQuotaError) {
522
+ const finalLogText = lastActivityLogText || processLogBuffer.snapshot();
523
+ if (finalLogText && finalLogText.trim().length > 0) {
524
+ logger_1.logger.divider('Process Log');
525
+ console.info(finalLogText);
526
+ }
527
+ logger_1.logger.divider();
528
+ liveActivityUpdateVersion += 1;
529
+ await upsertLiveActivityEmbeds(`${PHASE_ICONS.thinking} Process Log`, finalLogText || ACTIVITY_PLACEHOLDER, PHASE_COLORS.thinking, (0, i18n_1.t)(`⏱️ Time: ${elapsed}s | Process log`), {
530
+ source: 'complete',
531
+ expectedVersion: liveActivityUpdateVersion,
532
+ });
533
+ liveResponseUpdateVersion += 1;
534
+ await upsertLiveResponseEmbeds('⚠️ Model Quota Reached', 'Model quota limit reached. Please wait or switch to a different model.', 0xFF6B6B, (0, i18n_1.t)(`⏱️ Time: ${elapsed}s | Quota Reached`), {
535
+ source: 'complete',
536
+ expectedVersion: liveResponseUpdateVersion,
537
+ });
538
+ try {
539
+ const modelsPayload = await (0, modelsUi_1.buildModelsUI)(cdp, () => bridge.quota.fetchQuota());
540
+ if (modelsPayload && channel) {
541
+ await channel.send({ ...modelsPayload });
542
+ }
543
+ }
544
+ catch (e) {
545
+ logger_1.logger.error('[Quota] Failed to send model selection UI:', e);
546
+ }
547
+ await clearWatchingReaction();
548
+ await message.react('⚠️').catch(() => { });
549
+ return;
550
+ }
551
+ // Normal path — extract final text
552
+ const responseText = (finalText && finalText.trim().length > 0)
553
+ ? finalText
554
+ : lastProgressText;
555
+ const emergencyText = (!responseText || responseText.trim().length === 0)
556
+ ? await tryEmergencyExtractText()
557
+ : '';
558
+ const finalResponseText = responseText && responseText.trim().length > 0
559
+ ? responseText
560
+ : emergencyText;
561
+ const separated = (0, discordFormatter_1.splitOutputAndLogs)(finalResponseText);
562
+ const finalOutputText = separated.output || finalResponseText;
563
+ // Process logs are now collected by onProcessLog callback directly;
564
+ // sanitizeActivityLines is NOT applied because it would strip the very
565
+ // content we want to display (activity messages, tool names, etc.)
446
566
  const finalLogText = lastActivityLogText || processLogBuffer.snapshot();
447
567
  if (finalLogText && finalLogText.trim().length > 0) {
448
568
  logger_1.logger.divider('Process Log');
449
569
  console.info(finalLogText);
450
570
  }
571
+ if (finalOutputText && finalOutputText.trim().length > 0) {
572
+ logger_1.logger.divider(`Output (${finalOutputText.length} chars)`);
573
+ console.info(finalOutputText);
574
+ }
451
575
  logger_1.logger.divider();
452
576
  liveActivityUpdateVersion += 1;
577
+ const activityVersion = liveActivityUpdateVersion;
453
578
  await upsertLiveActivityEmbeds(`${PHASE_ICONS.thinking} Process Log`, finalLogText || ACTIVITY_PLACEHOLDER, PHASE_COLORS.thinking, (0, i18n_1.t)(`⏱️ Time: ${elapsed}s | Process log`), {
454
579
  source: 'complete',
455
- expectedVersion: liveActivityUpdateVersion,
580
+ expectedVersion: activityVersion,
456
581
  });
457
582
  liveResponseUpdateVersion += 1;
458
- await upsertLiveResponseEmbeds('⚠️ Model Quota Reached', 'Model quota limit reached. Please wait or switch to a different model.', 0xFF6B6B, (0, i18n_1.t)(`⏱️ Time: ${elapsed}s | Quota Reached`), {
459
- source: 'complete',
460
- expectedVersion: liveResponseUpdateVersion,
461
- });
462
- try {
463
- const modelsPayload = await (0, modelsUi_1.buildModelsUI)(cdp, () => bridge.quota.fetchQuota());
464
- if (modelsPayload && channel) {
465
- await channel.send({ ...modelsPayload });
466
- }
583
+ const responseVersion = liveResponseUpdateVersion;
584
+ if (finalOutputText && finalOutputText.trim().length > 0) {
585
+ await upsertLiveResponseEmbeds(`${PHASE_ICONS.complete} Final Output`, finalOutputText, PHASE_COLORS.complete, (0, i18n_1.t)(`⏱️ Time: ${elapsed}s | Complete`), {
586
+ source: 'complete',
587
+ expectedVersion: responseVersion,
588
+ });
467
589
  }
468
- catch (e) {
469
- logger_1.logger.error('[Quota] Failed to send model selection UI:', e);
590
+ else {
591
+ await upsertLiveResponseEmbeds(`${PHASE_ICONS.complete} Complete`, (0, i18n_1.t)('Failed to extract response. Use `/screenshot` to verify.'), PHASE_COLORS.complete, (0, i18n_1.t)(`⏱️ Time: ${elapsed}s | Complete`), {
592
+ source: 'complete',
593
+ expectedVersion: responseVersion,
594
+ });
470
595
  }
471
- await clearWatchingReaction();
472
- await message.react('⚠️').catch(() => { });
473
- return;
474
- }
475
- // Normal path — extract final text
476
- const responseText = (finalText && finalText.trim().length > 0)
477
- ? finalText
478
- : lastProgressText;
479
- const emergencyText = (!responseText || responseText.trim().length === 0)
480
- ? await tryEmergencyExtractText()
481
- : '';
482
- const finalResponseText = responseText && responseText.trim().length > 0
483
- ? responseText
484
- : emergencyText;
485
- const separated = (0, discordFormatter_1.splitOutputAndLogs)(finalResponseText);
486
- const finalOutputText = separated.output || finalResponseText;
487
- // Process logs are now collected by onProcessLog callback directly;
488
- // sanitizeActivityLines is NOT applied because it would strip the very
489
- // content we want to display (activity messages, tool names, etc.)
490
- const finalLogText = lastActivityLogText || processLogBuffer.snapshot();
491
- if (finalLogText && finalLogText.trim().length > 0) {
492
- logger_1.logger.divider('Process Log');
493
- console.info(finalLogText);
494
- }
495
- if (finalOutputText && finalOutputText.trim().length > 0) {
496
- logger_1.logger.divider(`Output (${finalOutputText.length} chars)`);
497
- console.info(finalOutputText);
498
- }
499
- logger_1.logger.divider();
500
- liveActivityUpdateVersion += 1;
501
- const activityVersion = liveActivityUpdateVersion;
502
- await upsertLiveActivityEmbeds(`${PHASE_ICONS.thinking} Process Log`, finalLogText || ACTIVITY_PLACEHOLDER, PHASE_COLORS.thinking, (0, i18n_1.t)(`⏱️ Time: ${elapsed}s | Process log`), {
503
- source: 'complete',
504
- expectedVersion: activityVersion,
505
- });
506
- liveResponseUpdateVersion += 1;
507
- const responseVersion = liveResponseUpdateVersion;
508
- if (finalOutputText && finalOutputText.trim().length > 0) {
509
- await upsertLiveResponseEmbeds(`${PHASE_ICONS.complete} Final Output`, finalOutputText, PHASE_COLORS.complete, (0, i18n_1.t)(`⏱️ Time: ${elapsed}s | Complete`), {
510
- source: 'complete',
511
- expectedVersion: responseVersion,
512
- });
513
- }
514
- else {
515
- await upsertLiveResponseEmbeds(`${PHASE_ICONS.complete} Complete`, (0, i18n_1.t)('Failed to extract response. Use `/screenshot` to verify.'), PHASE_COLORS.complete, (0, i18n_1.t)(`⏱️ Time: ${elapsed}s | Complete`), {
516
- source: 'complete',
517
- expectedVersion: responseVersion,
518
- });
519
- }
520
- if (options && message.guild) {
521
- try {
522
- const sessionInfo = await options.chatSessionService.getCurrentSessionInfo(cdp);
523
- if (sessionInfo && sessionInfo.hasActiveChat && sessionInfo.title && sessionInfo.title !== (0, i18n_1.t)('(Untitled)')) {
524
- const session = options.chatSessionRepo.findByChannelId(message.channelId);
525
- const workspaceDirName = session
526
- ? bridge.pool.extractDirName(session.workspacePath)
527
- : cdp.getCurrentWorkspaceName();
528
- if (workspaceDirName) {
529
- (0, cdpBridgeManager_1.registerApprovalSessionChannel)(bridge, workspaceDirName, sessionInfo.title, message.channel);
530
- }
531
- const newName = options.titleGenerator.sanitizeForChannelName(sessionInfo.title);
532
- if (session && session.displayName !== sessionInfo.title) {
533
- const formattedName = `${session.sessionNumber}-${newName}`;
534
- await options.channelManager.renameChannel(message.guild, message.channelId, formattedName);
535
- options.chatSessionRepo.updateDisplayName(message.channelId, sessionInfo.title);
596
+ if (options && message.guild) {
597
+ try {
598
+ const sessionInfo = await options.chatSessionService.getCurrentSessionInfo(cdp);
599
+ if (sessionInfo && sessionInfo.hasActiveChat && sessionInfo.title && sessionInfo.title !== (0, i18n_1.t)('(Untitled)')) {
600
+ const session = options.chatSessionRepo.findByChannelId(message.channelId);
601
+ const projectName = session
602
+ ? bridge.pool.extractProjectName(session.workspacePath)
603
+ : cdp.getCurrentWorkspaceName();
604
+ if (projectName) {
605
+ (0, cdpBridgeManager_1.registerApprovalSessionChannel)(bridge, projectName, sessionInfo.title, message.channel);
606
+ }
607
+ const newName = options.titleGenerator.sanitizeForChannelName(sessionInfo.title);
608
+ if (session && session.displayName !== sessionInfo.title) {
609
+ const formattedName = `${session.sessionNumber}-${newName}`;
610
+ await options.channelManager.renameChannel(message.guild, message.channelId, formattedName);
611
+ options.chatSessionRepo.updateDisplayName(message.channelId, sessionInfo.title);
612
+ }
536
613
  }
537
614
  }
615
+ catch (e) {
616
+ logger_1.logger.error('[Rename] Failed to get title from Antigravity and rename:', e);
617
+ }
538
618
  }
539
- catch (e) {
540
- logger_1.logger.error('[Rename] Failed to get title from Antigravity and rename:', e);
541
- }
619
+ await sendGeneratedImages(finalOutputText || '');
620
+ await clearWatchingReaction();
621
+ await message.react(finalOutputText && finalOutputText.trim().length > 0 ? '✅' : '⚠️').catch(() => { });
622
+ }
623
+ catch (error) {
624
+ logger_1.logger.error(`[sendPromptToAntigravity:${monitorTraceId}] onComplete failed:`, error);
542
625
  }
543
- await sendGeneratedImages(finalOutputText || '');
544
- await clearWatchingReaction();
545
- await message.react(finalOutputText && finalOutputText.trim().length > 0 ? '✅' : '⚠️').catch(() => { });
546
626
  }
547
- catch (error) {
548
- logger_1.logger.error(`[sendPromptToAntigravity:${monitorTraceId}] onComplete failed:`, error);
627
+ finally {
628
+ signalCompletion('onComplete');
549
629
  }
550
630
  },
551
631
  onTimeout: async (lastText) => {
@@ -578,6 +658,9 @@ async function sendPromptToAntigravity(bridge, message, prompt, cdp, modeService
578
658
  catch (error) {
579
659
  logger_1.logger.error(`[sendPromptToAntigravity:${monitorTraceId}] onTimeout failed:`, error);
580
660
  }
661
+ finally {
662
+ signalCompletion('onTimeout');
663
+ }
581
664
  },
582
665
  });
583
666
  await monitor.start();
@@ -602,18 +685,21 @@ async function sendPromptToAntigravity(bridge, message, prompt, cdp, modeService
602
685
  await sendEmbed(`${PHASE_ICONS.error} Error`, (0, i18n_1.t)(`Error occurred during processing: ${e.message}`), PHASE_COLORS.error);
603
686
  await clearWatchingReaction();
604
687
  await message.react('❌').catch(() => { });
688
+ signalCompletion('top-level-catch');
605
689
  }
606
690
  }
607
691
  // =============================================================================
608
692
  // Bot main entry point
609
693
  // =============================================================================
610
- const startBot = async () => {
694
+ const startBot = async (cliLogLevel) => {
611
695
  const config = (0, config_1.loadConfig)();
696
+ logger_1.logger.setLogLevel(cliLogLevel ?? config.logLevel);
612
697
  const dbPath = process.env.NODE_ENV === 'test' ? ':memory:' : 'antigravity.db';
613
698
  const db = new better_sqlite3_1.default(dbPath);
614
699
  const modeService = new modeService_1.ModeService();
615
700
  const modelService = new modelService_1.ModelService();
616
701
  const templateRepo = new templateRepository_1.TemplateRepository(db);
702
+ const userPrefRepo = new userPreferenceRepository_1.UserPreferenceRepository(db);
617
703
  const workspaceBindingRepo = new workspaceBindingRepository_1.WorkspaceBindingRepository(db);
618
704
  const chatSessionRepo = new chatSessionRepository_1.ChatSessionRepository(db);
619
705
  const workspaceService = new workspaceService_1.WorkspaceService(config.workspaceBaseDir);
@@ -631,7 +717,7 @@ const startBot = async () => {
631
717
  modelService,
632
718
  sendPromptImpl: sendPromptToAntigravity,
633
719
  });
634
- // Initialize command handlers
720
+ // Initialize command handlers (joinHandler is created after client, see below)
635
721
  const wsHandler = new workspaceCommandHandler_1.WorkspaceCommandHandler(workspaceBindingRepo, chatSessionRepo, workspaceService, channelManager);
636
722
  const chatHandler = new chatCommandHandler_1.ChatCommandHandler(chatSessionService, chatSessionRepo, workspaceBindingRepo, channelManager, workspaceService, bridge.pool);
637
723
  const cleanupHandler = new cleanupCommandHandler_1.CleanupCommandHandler(chatSessionRepo, workspaceBindingRepo);
@@ -643,6 +729,7 @@ const startBot = async () => {
643
729
  discord_js_1.GatewayIntentBits.MessageContent,
644
730
  ]
645
731
  });
732
+ const joinHandler = new joinCommandHandler_1.JoinCommandHandler(chatSessionService, chatSessionRepo, workspaceBindingRepo, channelManager, bridge.pool, workspaceService, client);
646
733
  client.once(discord_js_1.Events.ClientReady, async (readyClient) => {
647
734
  logger_1.logger.info(`Ready! Logged in as ${readyClient.user.tag} | extractionMode=${config.extractionMode}`);
648
735
  try {
@@ -700,7 +787,9 @@ const startBot = async () => {
700
787
  parseApprovalCustomId: cdpBridgeManager_1.parseApprovalCustomId,
701
788
  parseErrorPopupCustomId: cdpBridgeManager_1.parseErrorPopupCustomId,
702
789
  parsePlanningCustomId: cdpBridgeManager_1.parsePlanningCustomId,
703
- handleSlashInteraction: async (interaction, handler, bridgeArg, wsHandlerArg, chatHandlerArg, cleanupHandlerArg, modeServiceArg, modelServiceArg, autoAcceptServiceArg, clientArg) => handleSlashInteraction(interaction, handler, bridgeArg, wsHandlerArg, chatHandlerArg, cleanupHandlerArg, modeServiceArg, modelServiceArg, autoAcceptServiceArg, clientArg, promptDispatcher, templateRepo),
790
+ joinHandler,
791
+ userPrefRepo,
792
+ handleSlashInteraction: async (interaction, handler, bridgeArg, wsHandlerArg, chatHandlerArg, cleanupHandlerArg, modeServiceArg, modelServiceArg, autoAcceptServiceArg, clientArg) => handleSlashInteraction(interaction, handler, bridgeArg, wsHandlerArg, chatHandlerArg, cleanupHandlerArg, modeServiceArg, modelServiceArg, autoAcceptServiceArg, clientArg, promptDispatcher, templateRepo, joinHandler, userPrefRepo),
704
793
  handleTemplateUse: async (interaction, templateId) => {
705
794
  const template = templateRepo.findById(templateId);
706
795
  if (!template) {
@@ -717,17 +806,17 @@ const startBot = async () => {
717
806
  if (workspacePath) {
718
807
  try {
719
808
  cdp = await bridge.pool.getOrConnect(workspacePath);
720
- const dirName = bridge.pool.extractDirName(workspacePath);
721
- bridge.lastActiveWorkspace = dirName;
809
+ const projectName = bridge.pool.extractProjectName(workspacePath);
810
+ bridge.lastActiveWorkspace = projectName;
722
811
  bridge.lastActiveChannel = interaction.channel;
723
- (0, cdpBridgeManager_1.registerApprovalWorkspaceChannel)(bridge, dirName, interaction.channel);
812
+ (0, cdpBridgeManager_1.registerApprovalWorkspaceChannel)(bridge, projectName, interaction.channel);
724
813
  const session = chatSessionRepo.findByChannelId(channelId);
725
814
  if (session?.displayName) {
726
- (0, cdpBridgeManager_1.registerApprovalSessionChannel)(bridge, dirName, session.displayName, interaction.channel);
815
+ (0, cdpBridgeManager_1.registerApprovalSessionChannel)(bridge, projectName, session.displayName, interaction.channel);
727
816
  }
728
- (0, cdpBridgeManager_1.ensureApprovalDetector)(bridge, cdp, dirName, client);
729
- (0, cdpBridgeManager_1.ensureErrorPopupDetector)(bridge, cdp, dirName, client);
730
- (0, cdpBridgeManager_1.ensurePlanningDetector)(bridge, cdp, dirName, client);
817
+ (0, cdpBridgeManager_1.ensureApprovalDetector)(bridge, cdp, projectName, client);
818
+ (0, cdpBridgeManager_1.ensureErrorPopupDetector)(bridge, cdp, projectName, client);
819
+ (0, cdpBridgeManager_1.ensurePlanningDetector)(bridge, cdp, projectName, client);
731
820
  }
732
821
  catch (e) {
733
822
  await interaction.followUp({
@@ -761,6 +850,7 @@ const startBot = async () => {
761
850
  chatSessionRepo,
762
851
  channelManager,
763
852
  titleGenerator,
853
+ userPrefRepo,
764
854
  },
765
855
  });
766
856
  }
@@ -788,6 +878,7 @@ const startBot = async () => {
788
878
  }),
789
879
  autoRenameChannel,
790
880
  handleScreenshot: screenshotUi_1.handleScreenshot,
881
+ userPrefRepo,
791
882
  }));
792
883
  await client.login(config.discordToken);
793
884
  };
@@ -815,55 +906,82 @@ async function autoRenameChannel(message, chatSessionRepo, titleGenerator, chann
815
906
  /**
816
907
  * Handle Discord Interactions API slash commands
817
908
  */
818
- async function handleSlashInteraction(interaction, handler, bridge, wsHandler, chatHandler, cleanupHandler, modeService, modelService, autoAcceptService, _client, promptDispatcher, templateRepo) {
909
+ async function handleSlashInteraction(interaction, handler, bridge, wsHandler, chatHandler, cleanupHandler, modeService, modelService, autoAcceptService, _client, promptDispatcher, templateRepo, joinHandler, userPrefRepo) {
819
910
  const commandName = interaction.commandName;
820
911
  switch (commandName) {
821
912
  case 'help': {
913
+ const helpFields = [
914
+ {
915
+ name: '💬 Chat', value: [
916
+ '`/new` — Start a new chat session',
917
+ '`/chat` — Show current session info + list',
918
+ ].join('\n')
919
+ },
920
+ {
921
+ name: '🔗 Session', value: [
922
+ '`/join` — Join an existing Antigravity session',
923
+ '`/mirror` — Toggle PC→Discord mirroring ON/OFF',
924
+ ].join('\n')
925
+ },
926
+ {
927
+ name: '⏹️ Control', value: [
928
+ '`/stop` — Interrupt active LLM generation',
929
+ '`/screenshot` — Capture Antigravity screen',
930
+ ].join('\n')
931
+ },
932
+ {
933
+ name: '⚙️ Settings', value: [
934
+ '`/mode` — Display and change execution mode',
935
+ '`/model [name]` — Display and change LLM model',
936
+ '`/output [format]` — Toggle Embed / Plain Text output',
937
+ ].join('\n')
938
+ },
939
+ {
940
+ name: '📁 Projects', value: [
941
+ '`/project` — Display project list',
942
+ '`/project create <name>` — Create a new project',
943
+ ].join('\n')
944
+ },
945
+ {
946
+ name: '📝 Templates', value: [
947
+ '`/template list` — Show templates with execute buttons (click to run)',
948
+ '`/template add <name> <prompt>` — Register a template',
949
+ '`/template delete <name>` — Delete a template',
950
+ ].join('\n')
951
+ },
952
+ {
953
+ name: '🔧 System', value: [
954
+ '`/status` — Display overall bot status',
955
+ '`/autoaccept` — Toggle auto-approve mode for approval dialogs via buttons',
956
+ '`/logs [lines] [level]` — View recent bot logs',
957
+ '`/cleanup [days]` — Clean up unused channels/categories',
958
+ '`/help` — Show this help',
959
+ ].join('\n')
960
+ },
961
+ ];
962
+ const helpOutputFormat = userPrefRepo?.getOutputFormat(interaction.user.id) ?? 'embed';
963
+ if (helpOutputFormat === 'plain') {
964
+ const chunks = (0, plainTextFormatter_1.formatAsPlainText)({
965
+ title: '📖 LazyGravity Commands',
966
+ description: 'Commands for controlling Antigravity from Discord.',
967
+ fields: helpFields,
968
+ footerText: 'Text messages are sent directly to Antigravity',
969
+ });
970
+ await interaction.editReply({ content: chunks[0] });
971
+ break;
972
+ }
822
973
  const embed = new discord_js_1.EmbedBuilder()
823
974
  .setTitle('📖 LazyGravity Commands')
824
975
  .setColor(0x5865F2)
825
976
  .setDescription('Commands for controlling Antigravity from Discord.')
826
- .addFields({
827
- name: '💬 Chat', value: [
828
- '`/new` — Start a new chat session',
829
- '`/chat` — Show current session info + list',
830
- ].join('\n')
831
- }, {
832
- name: '⏹️ Control', value: [
833
- '`/stop` — Interrupt active LLM generation',
834
- '`/screenshot` — Capture Antigravity screen',
835
- ].join('\n')
836
- }, {
837
- name: '⚙️ Settings', value: [
838
- '`/mode` — Display and change execution mode',
839
- '`/model [name]` — Display and change LLM model',
840
- ].join('\n')
841
- }, {
842
- name: '📁 Projects', value: [
843
- '`/project` — Display project list',
844
- '`/project create <name>` — Create a new project',
845
- ].join('\n')
846
- }, {
847
- name: '📝 Templates', value: [
848
- '`/template list` — Show templates with execute buttons (click to run)',
849
- '`/template add <name> <prompt>` — Register a template',
850
- '`/template delete <name>` — Delete a template',
851
- ].join('\n')
852
- }, {
853
- name: '🔧 System', value: [
854
- '`/status` — Display overall bot status',
855
- '`/autoaccept` — Toggle auto-approve mode for approval dialogs via buttons',
856
- '`/cleanup [days]` — Clean up unused channels/categories',
857
- '`/help` — Show this help',
858
- ].join('\n')
859
- })
977
+ .addFields(...helpFields)
860
978
  .setFooter({ text: 'Text messages are sent directly to Antigravity' })
861
979
  .setTimestamp();
862
980
  await interaction.editReply({ embeds: [embed] });
863
981
  break;
864
982
  }
865
983
  case 'mode': {
866
- await (0, modeUi_1.sendModeUI)(interaction, modeService);
984
+ await (0, modeUi_1.sendModeUI)(interaction, modeService, { getCurrentCdp: () => (0, cdpBridgeManager_1.getCurrentCdp)(bridge) });
867
985
  break;
868
986
  }
869
987
  case 'model': {
@@ -924,23 +1042,46 @@ async function handleSlashInteraction(interaction, handler, bridge, wsHandler, c
924
1042
  return cdp ? 'CDP Connected' : 'Disconnected';
925
1043
  })();
926
1044
  const currentMode = modeService.getCurrentMode();
927
- const embed = new discord_js_1.EmbedBuilder()
928
- .setTitle('🔧 Bot Status')
929
- .setColor(activeNames.length > 0 ? 0x00CC88 : 0x888888)
930
- .addFields({ name: 'CDP Connection', value: activeNames.length > 0 ? `🟢 ${activeNames.length} project(s) connected` : 'Disconnected', inline: true }, { name: 'Mode', value: modeService_1.MODE_DISPLAY_NAMES[currentMode] || currentMode, inline: true }, { name: 'Auto Approve', value: autoAcceptService.isEnabled() ? '🟢 ON' : '⚪ OFF', inline: true })
931
- .setTimestamp();
1045
+ const mirroringWorkspaces = activeNames.filter((name) => bridge.pool.getUserMessageDetector(name)?.isActive());
1046
+ const mirrorStatus = mirroringWorkspaces.length > 0
1047
+ ? `📡 ON (${mirroringWorkspaces.join(', ')})`
1048
+ : '⚪ OFF';
1049
+ const statusFields = [
1050
+ { name: 'CDP Connection', value: activeNames.length > 0 ? `🟢 ${activeNames.length} project(s) connected` : '⚪ Disconnected', inline: true },
1051
+ { name: 'Mode', value: modeService_1.MODE_DISPLAY_NAMES[currentMode] || currentMode, inline: true },
1052
+ { name: 'Auto Approve', value: autoAcceptService.isEnabled() ? '🟢 ON' : '⚪ OFF', inline: true },
1053
+ { name: 'Mirroring', value: mirrorStatus, inline: true },
1054
+ ];
1055
+ let statusDescription = '';
932
1056
  if (activeNames.length > 0) {
933
1057
  const lines = activeNames.map((name) => {
934
1058
  const cdp = bridge.pool.getConnected(name);
935
1059
  const contexts = cdp ? cdp.getContexts().length : 0;
936
1060
  const detectorActive = bridge.pool.getApprovalDetector(name)?.isActive() ? ' [Detecting]' : '';
937
- return `• **${name}** Contexts: ${contexts}${detectorActive}`;
1061
+ const mirrorActive = bridge.pool.getUserMessageDetector(name)?.isActive() ? ' [Mirror]' : '';
1062
+ return `• **${name}** — Contexts: ${contexts}${detectorActive}${mirrorActive}`;
938
1063
  });
939
- embed.setDescription(`**Connected Projects:**\n${lines.join('\n')}`);
1064
+ statusDescription = `**Connected Projects:**\n${lines.join('\n')}`;
940
1065
  }
941
1066
  else {
942
- embed.setDescription('Send a message to auto-connect to a project.');
1067
+ statusDescription = 'Send a message to auto-connect to a project.';
1068
+ }
1069
+ const statusOutputFormat = userPrefRepo?.getOutputFormat(interaction.user.id) ?? 'embed';
1070
+ if (statusOutputFormat === 'plain') {
1071
+ const chunks = (0, plainTextFormatter_1.formatAsPlainText)({
1072
+ title: '🔧 Bot Status',
1073
+ description: statusDescription,
1074
+ fields: statusFields,
1075
+ });
1076
+ await interaction.editReply({ content: chunks[0] });
1077
+ break;
943
1078
  }
1079
+ const embed = new discord_js_1.EmbedBuilder()
1080
+ .setTitle('🔧 Bot Status')
1081
+ .setColor(activeNames.length > 0 ? 0x00CC88 : 0x888888)
1082
+ .addFields(...statusFields)
1083
+ .setDescription(statusDescription)
1084
+ .setTimestamp();
944
1085
  await interaction.editReply({ embeds: [embed] });
945
1086
  break;
946
1087
  }
@@ -954,6 +1095,23 @@ async function handleSlashInteraction(interaction, handler, bridge, wsHandler, c
954
1095
  await interaction.editReply({ content: result.message });
955
1096
  break;
956
1097
  }
1098
+ case 'output': {
1099
+ if (!userPrefRepo) {
1100
+ await interaction.editReply({ content: 'Output preference service not available.' });
1101
+ break;
1102
+ }
1103
+ const requestedFormat = interaction.options.getString('format');
1104
+ if (!requestedFormat) {
1105
+ const currentFormat = userPrefRepo.getOutputFormat(interaction.user.id);
1106
+ await (0, outputUi_1.sendOutputUI)(interaction, currentFormat);
1107
+ break;
1108
+ }
1109
+ const format = requestedFormat === 'plain' ? 'plain' : 'embed';
1110
+ userPrefRepo.setOutputFormat(interaction.user.id, format);
1111
+ const label = format === 'plain' ? 'Plain Text' : 'Embed';
1112
+ await interaction.editReply({ content: `Output format changed to **${label}**.` });
1113
+ break;
1114
+ }
957
1115
  case 'screenshot': {
958
1116
  await (0, screenshotUi_1.handleScreenshot)(interaction, (0, cdpBridgeManager_1.getCurrentCdp)(bridge));
959
1117
  break;
@@ -1022,6 +1180,24 @@ async function handleSlashInteraction(interaction, handler, bridge, wsHandler, c
1022
1180
  await chatHandler.handleChat(interaction);
1023
1181
  break;
1024
1182
  }
1183
+ case 'join': {
1184
+ if (joinHandler) {
1185
+ await joinHandler.handleJoin(interaction, bridge);
1186
+ }
1187
+ else {
1188
+ await interaction.editReply({ content: (0, i18n_1.t)('⚠️ Join handler not available.') });
1189
+ }
1190
+ break;
1191
+ }
1192
+ case 'mirror': {
1193
+ if (joinHandler) {
1194
+ await joinHandler.handleMirror(interaction, bridge);
1195
+ }
1196
+ else {
1197
+ await interaction.editReply({ content: (0, i18n_1.t)('⚠️ Mirror handler not available.') });
1198
+ }
1199
+ break;
1200
+ }
1025
1201
  case 'cleanup': {
1026
1202
  await cleanupHandler.handleCleanup(interaction);
1027
1203
  break;
@@ -1031,6 +1207,24 @@ async function handleSlashInteraction(interaction, handler, bridge, wsHandler, c
1031
1207
  await interaction.editReply({ content: `🏓 Pong! API Latency is **${apiLatency}ms**.` });
1032
1208
  break;
1033
1209
  }
1210
+ case 'logs': {
1211
+ const lines = interaction.options.getInteger('lines') ?? 50;
1212
+ const level = interaction.options.getString('level');
1213
+ const entries = logBuffer_1.logBuffer.getRecent(lines, level ?? undefined);
1214
+ if (entries.length === 0) {
1215
+ await interaction.editReply({ content: 'No log entries found.' });
1216
+ break;
1217
+ }
1218
+ const formatted = entries
1219
+ .map((e) => `${e.timestamp.slice(11, 19)} ${e.message}`)
1220
+ .join('\n');
1221
+ const MAX_CONTENT = 1900;
1222
+ const codeBlock = formatted.length <= MAX_CONTENT
1223
+ ? `\`\`\`\n${formatted}\n\`\`\``
1224
+ : `\`\`\`\n${formatted.slice(0, MAX_CONTENT)}\n\`\`\`\n(truncated — showing ${MAX_CONTENT} chars of ${formatted.length})`;
1225
+ await interaction.editReply({ content: codeBlock });
1226
+ break;
1227
+ }
1034
1228
  default:
1035
1229
  await interaction.editReply({
1036
1230
  content: `Unknown command: /${commandName}`,