lazy-gravity 0.10.0 → 0.12.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 (33) hide show
  1. package/README.md +7 -0
  2. package/dist/bin/commands/doctor.js +45 -7
  3. package/dist/bot/index.js +526 -24
  4. package/dist/commands/chatCommandHandler.js +11 -41
  5. package/dist/commands/registerSlashCommands.js +62 -0
  6. package/dist/database/scheduleRepository.js +50 -6
  7. package/dist/events/interactionCreateHandler.js +163 -28
  8. package/dist/events/messageCreateHandler.js +24 -28
  9. package/dist/handlers/fileChangeButtonAction.js +12 -24
  10. package/dist/handlers/genericActionButtonAction.js +16 -18
  11. package/dist/handlers/planningButtonAction.js +105 -17
  12. package/dist/handlers/planningModalSubmitAction.js +38 -0
  13. package/dist/handlers/questionSelectAction.js +8 -7
  14. package/dist/handlers/questionSkipAction.js +7 -6
  15. package/dist/platform/discord/wrappers.js +76 -0
  16. package/dist/services/approvalDetector.js +43 -14
  17. package/dist/services/artifactService.js +61 -21
  18. package/dist/services/assistantDomExtractor.js +23 -3
  19. package/dist/services/cdpBridgeManager.js +35 -2
  20. package/dist/services/cdpConnectionPool.js +7 -2
  21. package/dist/services/cdpService.js +113 -8
  22. package/dist/services/chatSessionService.js +15 -8
  23. package/dist/services/heartbeatService.js +261 -0
  24. package/dist/services/notificationSender.js +12 -2
  25. package/dist/services/planningDetector.js +1 -1
  26. package/dist/services/promptDispatcher.js +21 -1
  27. package/dist/services/questionDetector.js +128 -76
  28. package/dist/services/responseMonitor.js +17 -1
  29. package/dist/services/scheduleService.js +101 -4
  30. package/dist/utils/configLoader.js +8 -0
  31. package/dist/utils/fileOpenCache.js +2 -6
  32. package/dist/utils/questionActionUtils.js +22 -0
  33. package/package.json +2 -1
@@ -1,40 +1,8 @@
1
1
  "use strict";
2
- var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
- if (k2 === undefined) k2 = k;
4
- var desc = Object.getOwnPropertyDescriptor(m, k);
5
- if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
- desc = { enumerable: true, get: function() { return m[k]; } };
7
- }
8
- Object.defineProperty(o, k2, desc);
9
- }) : (function(o, m, k, k2) {
10
- if (k2 === undefined) k2 = k;
11
- o[k2] = m[k];
12
- }));
13
- var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
- Object.defineProperty(o, "default", { enumerable: true, value: v });
15
- }) : function(o, v) {
16
- o["default"] = v;
17
- });
18
- var __importStar = (this && this.__importStar) || (function () {
19
- var ownKeys = function(o) {
20
- ownKeys = Object.getOwnPropertyNames || function (o) {
21
- var ar = [];
22
- for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
- return ar;
24
- };
25
- return ownKeys(o);
26
- };
27
- return function (mod) {
28
- if (mod && mod.__esModule) return mod;
29
- var result = {};
30
- if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
- __setModuleDefault(result, mod);
32
- return result;
33
- };
34
- })();
35
2
  Object.defineProperty(exports, "__esModule", { value: true });
36
3
  exports.ChatCommandHandler = void 0;
37
4
  const i18n_1 = require("../utils/i18n");
5
+ const logger_1 = require("../utils/logger");
38
6
  const discord_js_1 = require("discord.js");
39
7
  /**
40
8
  * Handler for chat session related commands
@@ -111,9 +79,7 @@ class ChatCommandHandler {
111
79
  if (!newChatResult.ok) {
112
80
  // Log but don't fail the command, as the channel still needs to be created
113
81
  // and the user might just have to click it manually if the IDE state is strange.
114
- Promise.resolve().then(() => __importStar(require('../utils/logger'))).then(({ logger }) => {
115
- logger.warn(`[/new] Could not start new chat in IDE automatically: ${newChatResult.error}`);
116
- });
82
+ logger_1.logger.warn(`[/new] Could not start new chat in IDE automatically: ${newChatResult.error}`);
117
83
  }
118
84
  const customNameRaw = interaction.options.getString('name');
119
85
  let safeCustomName = null;
@@ -156,9 +122,7 @@ class ChatCommandHandler {
156
122
  if (workspaceCdp) {
157
123
  const renameResult = await this.chatSessionService.renameCurrentChatInUI(workspaceCdp, safeCustomName);
158
124
  if (!renameResult.ok) {
159
- Promise.resolve().then(() => __importStar(require('../utils/logger'))).then(({ logger }) => {
160
- logger.warn(`[/new] Could not rename chat in IDE automatically: ${renameResult.error}`);
161
- });
125
+ logger_1.logger.warn(`[/new] Could not rename chat in IDE automatically: ${renameResult.error}`);
162
126
  }
163
127
  }
164
128
  }
@@ -185,8 +149,14 @@ class ChatCommandHandler {
185
149
  const embed = new discord_js_1.EmbedBuilder()
186
150
  .setTitle((0, i18n_1.t)('💬 Chat Session Info'))
187
151
  .setColor(info.hasActiveChat ? 0x00CC88 : 0x888888)
188
- .addFields({ name: (0, i18n_1.t)('Title'), value: info.title, inline: true }, { name: (0, i18n_1.t)('Status'), value: info.hasActiveChat ? (0, i18n_1.t)('🟢 Active') : (0, i18n_1.t)('⚪ Inactive'), inline: true })
189
- .setDescription((0, i18n_1.t)('※ Non-session channel.\nUse `/project` to create a project first.'))
152
+ .addFields({ name: (0, i18n_1.t)('Title'), value: info.title, inline: true }, { name: (0, i18n_1.t)('Status'), value: info.hasActiveChat ? (0, i18n_1.t)('🟢 Active') : (0, i18n_1.t)('⚪ Inactive'), inline: true });
153
+ const channel = await interaction.client.channels.fetch(interaction.channelId).catch(() => null);
154
+ const categoryId = channel?.parentId;
155
+ const isProjectChannel = categoryId ? !!this.bindingRepo.findByChannelId(categoryId) : false;
156
+ const desc = isProjectChannel
157
+ ? (0, i18n_1.t)('※ No active session is bound to this channel.\n\n💡 **Tip**: If you recently deleted a session in the IDE, this channel was automatically unbound. Send a prompt or use `/new` to start a fresh chat.')
158
+ : (0, i18n_1.t)('※ Non-session channel.\nUse `/project` to create a project first.');
159
+ embed.setDescription(desc)
190
160
  .setTimestamp();
191
161
  await interaction.editReply({ embeds: [embed] });
192
162
  return;
@@ -168,6 +168,66 @@ const openCommand = new discord_js_1.SlashCommandBuilder()
168
168
  .setName('filepath')
169
169
  .setDescription((0, i18n_1.t)('Absolute or relative path to the file'))
170
170
  .setRequired(true));
171
+ /** /heartbeat command definition */
172
+ const heartbeatCommand = new discord_js_1.SlashCommandBuilder()
173
+ .setName('heartbeat')
174
+ .setDescription((0, i18n_1.t)('Configure periodic bot heartbeat notifications'))
175
+ .addSubcommand((sub) => sub
176
+ .setName('on')
177
+ .setDescription((0, i18n_1.t)('Enable periodic heartbeats'))
178
+ .addStringOption((option) => option
179
+ .setName('interval')
180
+ .setDescription((0, i18n_1.t)('Interval (e.g., 1d, 1h, 30m - unit required)'))
181
+ .setRequired(false))
182
+ .addChannelOption((option) => option
183
+ .setName('channel')
184
+ .setDescription((0, i18n_1.t)('Target channel for heartbeat (defaults to current)'))
185
+ .addChannelTypes(discord_js_1.ChannelType.GuildText, discord_js_1.ChannelType.GuildAnnouncement)
186
+ .setRequired(false)))
187
+ .addSubcommand((sub) => sub
188
+ .setName('off')
189
+ .setDescription((0, i18n_1.t)('Disable periodic heartbeats')))
190
+ .addSubcommand((sub) => sub
191
+ .setName('status')
192
+ .setDescription((0, i18n_1.t)('Display current heartbeat config and status')));
193
+ /** /schedule command definition */
194
+ const scheduleCommand = new discord_js_1.SlashCommandBuilder()
195
+ .setName('schedule')
196
+ .setDescription((0, i18n_1.t)('Manage scheduled tasks'))
197
+ .addSubcommand((sub) => sub
198
+ .setName('list')
199
+ .setDescription((0, i18n_1.t)('Show all scheduled tasks with next-run times')))
200
+ .addSubcommand((sub) => sub
201
+ .setName('add')
202
+ .setDescription((0, i18n_1.t)('Register a recurring task'))
203
+ .addStringOption((option) => option
204
+ .setName('cron')
205
+ .setDescription((0, i18n_1.t)('Cron expression (e.g. "0 * * * *")'))
206
+ .setRequired(true))
207
+ .addStringOption((option) => option
208
+ .setName('prompt')
209
+ .setDescription((0, i18n_1.t)('Prompt content to execute'))
210
+ .setRequired(true)))
211
+ .addSubcommand((sub) => sub
212
+ .setName('remove')
213
+ .setDescription((0, i18n_1.t)('Delete a scheduled task'))
214
+ .addIntegerOption((option) => option
215
+ .setName('id')
216
+ .setDescription((0, i18n_1.t)('ID of the task to delete'))
217
+ .setRequired(true)))
218
+ .addSubcommand((sub) => sub
219
+ .setName('clear')
220
+ .setDescription((0, i18n_1.t)('Remove all scheduled tasks and reset task IDs')))
221
+ .addSubcommand((sub) => sub
222
+ .setName('backup')
223
+ .setDescription((0, i18n_1.t)('Export all scheduled tasks as a JSON file attachment')))
224
+ .addSubcommand((sub) => sub
225
+ .setName('restore')
226
+ .setDescription((0, i18n_1.t)('Restore scheduled tasks from a JSON file attachment'))
227
+ .addAttachmentOption((option) => option
228
+ .setName('file')
229
+ .setDescription((0, i18n_1.t)('The schedules_backup.json file to import'))
230
+ .setRequired(true)));
171
231
  /** Array of commands to register */
172
232
  exports.slashCommands = [
173
233
  helpCommand,
@@ -191,6 +251,8 @@ exports.slashCommands = [
191
251
  logsCommand,
192
252
  artifactsCommand,
193
253
  openCommand,
254
+ heartbeatCommand,
255
+ scheduleCommand,
194
256
  ];
195
257
  /**
196
258
  * Register slash commands with Discord
@@ -11,9 +11,6 @@ class ScheduleRepository {
11
11
  this.db = db;
12
12
  this.initialize();
13
13
  }
14
- /**
15
- * Initialize table (create if not exists)
16
- */
17
14
  initialize() {
18
15
  this.db.exec(`
19
16
  CREATE TABLE IF NOT EXISTS schedules (
@@ -21,25 +18,33 @@ class ScheduleRepository {
21
18
  cron_expression TEXT NOT NULL,
22
19
  prompt TEXT NOT NULL,
23
20
  workspace_path TEXT NOT NULL,
21
+ channel_id TEXT,
24
22
  enabled INTEGER NOT NULL DEFAULT 1,
25
23
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
26
24
  )
27
25
  `);
26
+ // Perform schema migration if channel_id doesn't exist (fallback for old databases)
27
+ const tableInfo = this.db.pragma("table_info(schedules)");
28
+ const hasChannelId = tableInfo.some((col) => col.name === 'channel_id');
29
+ if (!hasChannelId) {
30
+ this.db.exec('ALTER TABLE schedules ADD COLUMN channel_id TEXT');
31
+ }
28
32
  }
29
33
  /**
30
34
  * Create a new schedule
31
35
  */
32
36
  create(input) {
33
37
  const stmt = this.db.prepare(`
34
- INSERT INTO schedules (cron_expression, prompt, workspace_path, enabled)
35
- VALUES (?, ?, ?, ?)
38
+ INSERT INTO schedules (cron_expression, prompt, workspace_path, channel_id, enabled)
39
+ VALUES (?, ?, ?, ?, ?)
36
40
  `);
37
- const result = stmt.run(input.cronExpression, input.prompt, input.workspacePath, input.enabled ? 1 : 0);
41
+ const result = stmt.run(input.cronExpression, input.prompt, input.workspacePath, input.channelId || '', input.enabled ? 1 : 0);
38
42
  return {
39
43
  id: result.lastInsertRowid,
40
44
  cronExpression: input.cronExpression,
41
45
  prompt: input.prompt,
42
46
  workspacePath: input.workspacePath,
47
+ channelId: input.channelId,
43
48
  enabled: input.enabled,
44
49
  };
45
50
  }
@@ -73,6 +78,40 @@ class ScheduleRepository {
73
78
  const result = this.db.prepare('DELETE FROM schedules WHERE id = ?').run(id);
74
79
  return result.changes > 0;
75
80
  }
81
+ /**
82
+ * Clear all schedules and reset the autoincrement ID back to 0
83
+ */
84
+ reset() {
85
+ this.db.transaction(() => {
86
+ this.db.exec('DELETE FROM schedules');
87
+ this.db.exec("DELETE FROM sqlite_sequence WHERE name = 'schedules'");
88
+ })();
89
+ }
90
+ /**
91
+ * Bulk restore schedules. Clears the table and inserts all provided records.
92
+ */
93
+ bulkRestore(records) {
94
+ const result = [];
95
+ this.db.transaction(() => {
96
+ this.reset();
97
+ const stmt = this.db.prepare(`
98
+ INSERT INTO schedules (cron_expression, prompt, workspace_path, channel_id, enabled)
99
+ VALUES (?, ?, ?, ?, ?)
100
+ `);
101
+ for (const record of records) {
102
+ const insertResult = stmt.run(record.cronExpression, record.prompt, record.workspacePath, record.channelId || '', record.enabled ? 1 : 0);
103
+ result.push({
104
+ id: insertResult.lastInsertRowid,
105
+ cronExpression: record.cronExpression,
106
+ prompt: record.prompt,
107
+ workspacePath: record.workspacePath,
108
+ channelId: record.channelId,
109
+ enabled: record.enabled,
110
+ });
111
+ }
112
+ })();
113
+ return result;
114
+ }
76
115
  /**
77
116
  * Partially update a schedule
78
117
  */
@@ -91,6 +130,10 @@ class ScheduleRepository {
91
130
  sets.push('workspace_path = ?');
92
131
  values.push(input.workspacePath);
93
132
  }
133
+ if (input.channelId !== undefined) {
134
+ sets.push('channel_id = ?');
135
+ values.push(input.channelId);
136
+ }
94
137
  if (input.enabled !== undefined) {
95
138
  sets.push('enabled = ?');
96
139
  values.push(input.enabled ? 1 : 0);
@@ -111,6 +154,7 @@ class ScheduleRepository {
111
154
  cronExpression: row.cron_expression,
112
155
  prompt: row.prompt,
113
156
  workspacePath: row.workspace_path,
157
+ channelId: row.channel_id || undefined,
114
158
  enabled: row.enabled === 1,
115
159
  createdAt: row.created_at,
116
160
  };
@@ -21,6 +21,8 @@ const notificationSender_1 = require("../services/notificationSender");
21
21
  const questionSelectAction_1 = require("../handlers/questionSelectAction");
22
22
  const questionSkipAction_1 = require("../handlers/questionSkipAction");
23
23
  const wrappers_1 = require("../platform/discord/wrappers");
24
+ const questionActionUtils_1 = require("../utils/questionActionUtils");
25
+ const projectResolver_1 = require("../utils/projectResolver");
24
26
  const cleanupCommandHandler_1 = require("../commands/cleanupCommandHandler");
25
27
  const projectListUi_1 = require("../ui/projectListUi");
26
28
  const modeService_1 = require("../services/modeService");
@@ -110,6 +112,10 @@ function createInteractionCreateHandler(deps) {
110
112
  return { ok: true };
111
113
  };
112
114
  return async (interaction) => {
115
+ const isHeartbeatCommand = 'commandName' in interaction && interaction.commandName === 'heartbeat';
116
+ if (deps.heartbeatService && deps.config.allowedUserIds.includes(interaction.user.id) && !isHeartbeatCommand) {
117
+ deps.heartbeatService.recordActivity();
118
+ }
113
119
  if (interaction.isAutocomplete()) {
114
120
  if (!deps.config.allowedUserIds.includes(interaction.user.id)) {
115
121
  await interaction.respond([]).catch(logger_1.logger.error);
@@ -226,6 +232,19 @@ function createInteractionCreateHandler(deps) {
226
232
  embeds: [updatedEmbed],
227
233
  components: (0, discordButtonUtils_1.disableAllButtons)(interaction.message.components),
228
234
  });
235
+ if (success && (approvalAction.action === 'approve' || approvalAction.action === 'always_allow') && deps.promptDispatcher && interaction.channelId && interaction.message) {
236
+ const cdp = projectName
237
+ ? deps.bridge.pool.getConnected(projectName, resolveSelectedAccount(interaction.channelId, interaction.user.id, getParentChannelId(interaction)))
238
+ : null;
239
+ if (cdp) {
240
+ deps.promptDispatcher.resume({
241
+ message: interaction.message,
242
+ prompt: '',
243
+ cdp,
244
+ options: buildResumeOptions()
245
+ }).catch(e => logger_1.logger.error('[Approval] Failed to resume monitoring:', e));
246
+ }
247
+ }
229
248
  }
230
249
  else {
231
250
  await interaction.reply({ content: 'Approval button not found.', flags: discord_js_1.MessageFlags.Ephemeral });
@@ -873,8 +892,39 @@ function createInteractionCreateHandler(deps) {
873
892
  }
874
893
  if (interaction.customId.startsWith('file_open:')) {
875
894
  await interaction.deferUpdate().catch(() => { });
876
- const hashId = interaction.customId.replace('file_open:', '');
877
- const fileUrl = fileOpenCache_1.fileOpenCache.get(hashId);
895
+ let fileUrl = undefined;
896
+ const suffix = interaction.customId.substring('file_open:'.length);
897
+ if (suffix.startsWith('rel:')) {
898
+ const relativePath = suffix.substring('rel:'.length);
899
+ const workspacePath = interaction.channelId ? (deps.wsHandler.getWorkspaceForChannel(interaction.channelId) ||
900
+ deps.chatSessionRepo?.findByChannelId(interaction.channelId)?.workspacePath) : undefined;
901
+ if (workspacePath) {
902
+ fileUrl = `file:///${path_1.default.join(workspacePath, relativePath).replace(/\\/g, '/')}`;
903
+ }
904
+ }
905
+ else if (suffix.startsWith('art:')) {
906
+ const parts = suffix.substring('art:'.length).split(':');
907
+ if (parts.length >= 2) {
908
+ const convId = parts[0];
909
+ const filename = parts.slice(1).join(':');
910
+ if (deps.artifactService) {
911
+ const artifacts = deps.artifactService.listArtifacts(convId);
912
+ const matched = artifacts.find(art => art.filename.toLowerCase() === filename.toLowerCase());
913
+ if (matched) {
914
+ fileUrl = `file:///${matched.absolutePath.replace(/\\/g, '/')}`;
915
+ }
916
+ }
917
+ if (!fileUrl) {
918
+ const brainDir = path_1.default.join(require('os').homedir(), '.gemini', 'antigravity', 'brain');
919
+ fileUrl = `file:///${path_1.default.join(brainDir, convId, filename).replace(/\\/g, '/')}`;
920
+ }
921
+ }
922
+ }
923
+ else {
924
+ // Fallback to cache (for legacy file_open:<hashId> or explicit cache usage)
925
+ const hashId = suffix.startsWith('cache:') ? suffix.substring('cache:'.length) : suffix;
926
+ fileUrl = fileOpenCache_1.fileOpenCache.get(hashId);
927
+ }
878
928
  if (!fileUrl) {
879
929
  await interaction.followUp({
880
930
  content: (0, i18n_1.t)('File URL not found in cache or expired.'),
@@ -893,37 +943,58 @@ function createInteractionCreateHandler(deps) {
893
943
  }
894
944
  resolvedPath = path_1.default.resolve(rawPath);
895
945
  }
896
- let workspacePath = undefined;
897
- if (interaction.channelId) {
898
- workspacePath = deps.wsHandler.getWorkspaceForChannel(interaction.channelId) ||
899
- deps.chatSessionRepo?.findByChannelId(interaction.channelId)?.workspacePath;
900
- }
901
- if (!workspacePath) {
902
- await interaction.followUp({
903
- content: `❌ Error: Cannot open files without an active workspace.`,
904
- flags: discord_js_1.MessageFlags.Ephemeral
905
- }).catch(logger_1.logger.error);
906
- return;
907
- }
908
- const relative = path_1.default.relative(workspacePath, resolvedPath);
909
- if (relative.startsWith('..') || path_1.default.isAbsolute(relative)) {
910
- await interaction.followUp({
911
- content: `❌ Error: Cannot open files outside the workspace root.`,
912
- flags: discord_js_1.MessageFlags.Ephemeral
913
- }).catch(logger_1.logger.error);
914
- return;
946
+ const isArtifact = resolvedPath.includes('.gemini') && resolvedPath.includes('antigravity') && resolvedPath.includes('brain');
947
+ if (!isArtifact) {
948
+ let workspacePath = undefined;
949
+ if (interaction.channelId) {
950
+ workspacePath = deps.wsHandler.getWorkspaceForChannel(interaction.channelId) ||
951
+ deps.chatSessionRepo?.findByChannelId(interaction.channelId)?.workspacePath;
952
+ }
953
+ if (!workspacePath) {
954
+ await interaction.followUp({
955
+ content: `❌ Error: Cannot open files without an active workspace.`,
956
+ flags: discord_js_1.MessageFlags.Ephemeral
957
+ }).catch(logger_1.logger.error);
958
+ return;
959
+ }
960
+ const resolvedWorkspacePath = path_1.default.resolve(workspacePath);
961
+ const relative = path_1.default.relative(resolvedWorkspacePath, resolvedPath);
962
+ if (relative.startsWith('..') || path_1.default.isAbsolute(relative)) {
963
+ await interaction.followUp({
964
+ content: `❌ Error: Cannot open files outside the workspace root.`,
965
+ flags: discord_js_1.MessageFlags.Ephemeral
966
+ }).catch(logger_1.logger.error);
967
+ return;
968
+ }
915
969
  }
916
970
  try {
971
+ const comp = interaction.component;
972
+ const buttonLabel = comp?.type === 2 ? comp.label : null;
973
+ const isReviewBtn = suffix.startsWith('art:') ||
974
+ buttonLabel?.toLowerCase().includes('review') ||
975
+ resolvedPath.endsWith('walkthrough.md') ||
976
+ resolvedPath.endsWith('task.md') ||
977
+ resolvedPath.endsWith('implementation_plan.md');
917
978
  const fileContent = fs_1.default.readFileSync(resolvedPath, 'utf8');
918
979
  const MAX_DESC_LEN = 4096;
919
- const truncated = fileContent.length > MAX_DESC_LEN
980
+ let truncated = fileContent.length > MAX_DESC_LEN
920
981
  ? fileContent.substring(0, MAX_DESC_LEN - 15) + '\n\n(truncated)'
921
982
  : fileContent;
983
+ if (isReviewBtn) {
984
+ truncated += '\n\n💡 *To request changes to this artifact, simply reply in this channel.*';
985
+ }
922
986
  const embed = new discord_js_1.EmbedBuilder()
923
- .setTitle(`Opened: ${path_1.default.basename(resolvedPath)}`)
987
+ .setTitle(isReviewBtn ? `Reviewing: ${path_1.default.basename(resolvedPath)}` : `Opened: ${path_1.default.basename(resolvedPath)}`)
924
988
  .setDescription(truncated)
925
- .setColor(0x3498DB)
989
+ .setColor(isReviewBtn ? 0x2ECC71 : 0x3498DB)
926
990
  .setTimestamp();
991
+ if (isReviewBtn) {
992
+ await interaction.followUp({
993
+ embeds: [embed],
994
+ components: []
995
+ }).catch(logger_1.logger.error);
996
+ return;
997
+ }
927
998
  (0, child_process_1.execFile)((0, pathUtils_1.getAntigravityCliPath)(), [resolvedPath], async (error) => {
928
999
  if (error) {
929
1000
  logger_1.logger.error(`Failed to open file via CLI: ${error.message}`);
@@ -933,9 +1004,14 @@ function createInteractionCreateHandler(deps) {
933
1004
  }).catch(() => { });
934
1005
  }
935
1006
  else {
1007
+ const closeRow = new discord_js_1.ActionRowBuilder().addComponents(new discord_js_1.ButtonBuilder()
1008
+ .setCustomId('file_close_active')
1009
+ .setLabel('Close Active Tab in IDE')
1010
+ .setStyle(discord_js_1.ButtonStyle.Secondary));
936
1011
  await interaction.followUp({
937
1012
  content: `✅ Opened file in IDE: **${path_1.default.basename(resolvedPath)}**`,
938
1013
  embeds: [embed],
1014
+ components: [closeRow],
939
1015
  flags: discord_js_1.MessageFlags.Ephemeral
940
1016
  }).catch(() => { });
941
1017
  }
@@ -949,9 +1025,38 @@ function createInteractionCreateHandler(deps) {
949
1025
  }
950
1026
  return;
951
1027
  }
1028
+ if (interaction.customId === 'file_close_active') {
1029
+ await interaction.deferUpdate().catch(() => { });
1030
+ try {
1031
+ const cdp = deps.getCurrentCdp(deps.bridge);
1032
+ if (!cdp) {
1033
+ await interaction.followUp({
1034
+ content: `❌ No active CDP connection found.`,
1035
+ flags: discord_js_1.MessageFlags.Ephemeral
1036
+ }).catch(() => { });
1037
+ return;
1038
+ }
1039
+ if (!cdp.isConnected()) {
1040
+ await cdp.connect();
1041
+ }
1042
+ await cdp.closeActiveEditor();
1043
+ await interaction.followUp({
1044
+ content: `✅ Sent close command to IDE.`,
1045
+ flags: discord_js_1.MessageFlags.Ephemeral
1046
+ }).catch(() => { });
1047
+ }
1048
+ catch (e) {
1049
+ logger_1.logger.error(`Failed to close active editor: ${e.message}`);
1050
+ await interaction.followUp({
1051
+ content: `❌ Error closing editor: ${e.message}`,
1052
+ flags: discord_js_1.MessageFlags.Ephemeral
1053
+ }).catch(() => { });
1054
+ }
1055
+ return;
1056
+ }
952
1057
  }
953
1058
  catch (error) {
954
- logger_1.logger.error('Error during button interaction handling:', error);
1059
+ logger_1.logger.error(`[InteractionCreate] Error handling interaction: ${error instanceof Error ? error.message : String(error)}`);
955
1060
  try {
956
1061
  if (!interaction.replied && !interaction.deferred) {
957
1062
  await interaction.reply({ content: 'An error occurred while processing the button action.', flags: discord_js_1.MessageFlags.Ephemeral });
@@ -1243,7 +1348,22 @@ function createInteractionCreateHandler(deps) {
1243
1348
  try {
1244
1349
  const action = (0, questionSelectAction_1.createQuestionSelectAction)({ bridge: deps.bridge, wsHandler: deps.wsHandler });
1245
1350
  const wrapped = (0, wrappers_1.wrapDiscordSelect)(interaction);
1246
- await action.execute(wrapped, interaction.values);
1351
+ const success = await action.execute(wrapped, interaction.values);
1352
+ if (success && deps.promptDispatcher && interaction.channelId && interaction.message) {
1353
+ const parsed = (0, questionActionUtils_1.parseQuestionCustomId)(interaction.customId, notificationSender_1.QUESTION_SELECT_ACTION_PREFIX);
1354
+ if (parsed) {
1355
+ const projectName = (0, projectResolver_1.resolveProjectName)({ bridge: deps.bridge, wsHandler: deps.wsHandler }, interaction.channelId, parsed.projectName);
1356
+ const cdp = projectName ? deps.bridge.pool.getConnected(projectName, resolveSelectedAccount(interaction.channelId, interaction.user.id, getParentChannelId(interaction))) : null;
1357
+ if (cdp) {
1358
+ deps.promptDispatcher.resume({
1359
+ message: interaction.message,
1360
+ prompt: '',
1361
+ cdp,
1362
+ options: buildResumeOptions()
1363
+ }).catch(e => logger_1.logger.error('[QuestionSelectAction] Failed to resume monitoring:', e));
1364
+ }
1365
+ }
1366
+ }
1247
1367
  }
1248
1368
  catch (err) {
1249
1369
  logger_1.logger.error('[QuestionSelectAction] Error executing action:', err);
@@ -1260,7 +1380,22 @@ function createInteractionCreateHandler(deps) {
1260
1380
  try {
1261
1381
  const action = (0, questionSkipAction_1.createQuestionSkipAction)({ bridge: deps.bridge, wsHandler: deps.wsHandler });
1262
1382
  const wrapped = (0, wrappers_1.wrapDiscordButton)(interaction);
1263
- await action.execute(wrapped, {});
1383
+ const success = await action.execute(wrapped, {});
1384
+ if (success && deps.promptDispatcher && interaction.channelId && interaction.message) {
1385
+ const parsed = (0, questionActionUtils_1.parseQuestionCustomId)(interaction.customId, notificationSender_1.QUESTION_SKIP_ACTION_PREFIX);
1386
+ if (parsed) {
1387
+ const projectName = (0, projectResolver_1.resolveProjectName)({ bridge: deps.bridge, wsHandler: deps.wsHandler }, interaction.channelId, parsed.projectName);
1388
+ const cdp = projectName ? deps.bridge.pool.getConnected(projectName, resolveSelectedAccount(interaction.channelId, interaction.user.id, getParentChannelId(interaction))) : null;
1389
+ if (cdp) {
1390
+ deps.promptDispatcher.resume({
1391
+ message: interaction.message,
1392
+ prompt: '',
1393
+ cdp,
1394
+ options: buildResumeOptions()
1395
+ }).catch(e => logger_1.logger.error('[QuestionSkipAction] Failed to resume monitoring:', e));
1396
+ }
1397
+ }
1398
+ }
1264
1399
  }
1265
1400
  catch (err) {
1266
1401
  logger_1.logger.error('[QuestionSkipAction] Error executing action:', err);
@@ -1317,7 +1452,7 @@ function createInteractionCreateHandler(deps) {
1317
1452
  throw deferError;
1318
1453
  }
1319
1454
  try {
1320
- await deps.handleSlashInteraction(commandInteraction, deps.slashCommandHandler, deps.bridge, deps.wsHandler, deps.chatHandler, deps.cleanupHandler, deps.modeService, deps.modelService, deps.bridge.autoAccept, deps.client, deps.accountPrefRepo, deps.channelPrefRepo, deps.antigravityAccounts, deps.chatSessionRepo, deps.scheduleService);
1455
+ await deps.handleSlashInteraction(commandInteraction, deps.slashCommandHandler, deps.bridge, deps.wsHandler, deps.chatHandler, deps.cleanupHandler, deps.modeService, deps.modelService, deps.bridge.autoAccept, deps.client, deps.accountPrefRepo, deps.channelPrefRepo, deps.antigravityAccounts, deps.chatSessionRepo, deps.scheduleService, deps.heartbeatService);
1321
1456
  }
1322
1457
  catch (error) {
1323
1458
  logger_1.logger.error(`[SlashCommand] command=${commandInteraction.commandName} channel=${commandInteraction.channelId} ` +
@@ -10,7 +10,9 @@ const modeService_1 = require("../services/modeService");
10
10
  const imageHandler_1 = require("../utils/imageHandler");
11
11
  const accountUtils_1 = require("../utils/accountUtils");
12
12
  const logger_1 = require("../utils/logger");
13
+ const workspaceQueue_1 = require("../bot/workspaceQueue");
13
14
  function createMessageCreateHandler(deps) {
15
+ const workspaceQueue = deps.workspaceQueue ?? new workspaceQueue_1.WorkspaceQueue();
14
16
  const getCurrentCdp = deps.getCurrentCdp ?? cdpBridgeManager_1.getCurrentCdp;
15
17
  const ensureApprovalDetector = deps.ensureApprovalDetector ?? cdpBridgeManager_1.ensureApprovalDetector;
16
18
  const ensureErrorPopupDetector = deps.ensureErrorPopupDetector ?? cdpBridgeManager_1.ensureErrorPopupDetector;
@@ -31,29 +33,15 @@ function createMessageCreateHandler(deps) {
31
33
  return match ? match.cdpPort : null;
32
34
  };
33
35
  const getSessionAccountName = (channelId) => deps.chatSessionRepo.findByChannelId(channelId)?.activeAccountName ?? null;
34
- // Per-workspace prompt queue: serializes send→response cycles
35
- const workspaceQueues = new Map();
36
- const workspaceQueueDepths = new Map();
37
- function enqueueForWorkspace(workspacePath, task) {
38
- // .catch: ensure a prior rejection never stalls the chain
39
- const current = (workspaceQueues.get(workspacePath) ?? Promise.resolve()).catch(() => { });
40
- const next = current.then(async () => {
41
- try {
42
- await task();
43
- }
44
- catch (err) {
45
- logger_1.logger.error('[WorkspaceQueue] task error:', err?.message || err);
46
- }
47
- });
48
- workspaceQueues.set(workspacePath, next);
49
- return next;
50
- }
51
36
  return async (message) => {
52
37
  if (message.author.bot)
53
38
  return;
54
39
  if (!deps.config.allowedUserIds.includes(message.author.id)) {
55
40
  return;
56
41
  }
42
+ if (deps.heartbeatService) {
43
+ deps.heartbeatService.recordActivity();
44
+ }
57
45
  const parsed = (0, messageParser_1.parseMessageContent)(message.content);
58
46
  if (parsed.isCommand && parsed.commandName) {
59
47
  if (parsed.commandName === 'autoaccept') {
@@ -160,7 +148,7 @@ function createMessageCreateHandler(deps) {
160
148
  await message.reply(`✅ Switched session account to **${requested}**.`).catch(() => { });
161
149
  return;
162
150
  }
163
- const slashOnlyCommands = ['help', 'stop', 'model', 'mode', 'project', 'chat', 'new', 'cleanup', 'join', 'mirror', 'output'];
151
+ const slashOnlyCommands = ['help', 'stop', 'model', 'mode', 'project', 'chat', 'new', 'cleanup', 'join', 'mirror', 'output', 'heartbeat'];
164
152
  if (slashOnlyCommands.includes(parsed.commandName)) {
165
153
  await message.reply({
166
154
  content: `💡 Please use \`/${parsed.commandName}\` as a slash command.\nType \`/${parsed.commandName}\` in the Discord input field to see suggestions.`,
@@ -225,7 +213,10 @@ function createMessageCreateHandler(deps) {
225
213
  const chunk = textAttachments.slice(i, i + CONCURRENCY_LIMIT);
226
214
  const results = await Promise.all(chunk.map(async (textAtt) => {
227
215
  try {
228
- const res = await fetch(textAtt.url);
216
+ const controller = new AbortController();
217
+ const timeoutId = setTimeout(() => controller.abort(), 10000);
218
+ const res = await fetch(textAtt.url, { signal: controller.signal });
219
+ clearTimeout(timeoutId);
229
220
  if (res.ok) {
230
221
  const content = await res.text();
231
222
  return `\n\n[Attached File: ${textAtt.name}]\n\`\`\`\n${content}\n\`\`\``;
@@ -252,9 +243,8 @@ function createMessageCreateHandler(deps) {
252
243
  if (workspacePath) {
253
244
  const projectLabel = deps.bridge.pool.extractProjectName(workspacePath);
254
245
  // Track queue depth for hourglass reactions
255
- const currentDepth = workspaceQueueDepths.get(workspacePath) ?? 0;
256
- workspaceQueueDepths.set(workspacePath, currentDepth + 1);
257
- const newDepth = currentDepth + 1;
246
+ const currentDepth = workspaceQueue.getDepth(workspacePath);
247
+ const newDepth = workspaceQueue.incrementDepth(workspacePath);
258
248
  if (currentDepth > 0) {
259
249
  logger_1.logger.info(`[Queue:${projectLabel}] Enqueued (depth: ${newDepth}, channel: ${message.channelId})`);
260
250
  await message.react('⏳').catch(() => { });
@@ -263,7 +253,7 @@ function createMessageCreateHandler(deps) {
263
253
  logger_1.logger.info(`[Queue:${projectLabel}] Processing immediately (depth: ${newDepth}, channel: ${message.channelId})`);
264
254
  }
265
255
  const queueStartTime = Date.now();
266
- await enqueueForWorkspace(workspacePath, async () => {
256
+ await workspaceQueue.enqueue(workspacePath, async () => {
267
257
  const waitMs = Date.now() - queueStartTime;
268
258
  if (waitMs > 100) {
269
259
  logger_1.logger.info(`[Queue:${projectLabel}] Task started after ${Math.round(waitMs / 1000)}s wait (channel: ${message.channelId})`);
@@ -349,9 +339,16 @@ function createMessageCreateHandler(deps) {
349
339
  }
350
340
  }
351
341
  if (!activationResult.ok) {
352
- const reason = activationResult.error ? ` (${activationResult.error})` : '';
353
- await message.reply(`⚠️ Could not route this message to the bound session (${session.displayName}). ` +
354
- `Please open /chat and verify the session${reason}.`).catch(() => { });
342
+ const isDeleted = activationResult.error?.includes('Conversation not found in Past Conversations');
343
+ let replyText = `⚠️ Could not route this message to the bound session (${session.displayName}).\n*Reason: ${activationResult.error}*`;
344
+ if (isDeleted) {
345
+ deps.chatSessionRepo.deleteByChannelId(message.channelId);
346
+ replyText += `\n\n💡 **Tip**: This session appears to have been deleted in the IDE. I have unbound this channel so your next message will start a fresh chat. You can also type \`/new\` anytime.`;
347
+ }
348
+ else {
349
+ replyText += `\n\n💡 If this session is broken, type \`/new\` to force a new chat.`;
350
+ }
351
+ await message.reply(replyText).catch(() => { });
355
352
  return;
356
353
  }
357
354
  }
@@ -427,8 +424,7 @@ function createMessageCreateHandler(deps) {
427
424
  await message.reply(`Failed to connect to workspace: ${e.message}`);
428
425
  }
429
426
  finally {
430
- const remainingDepth = (workspaceQueueDepths.get(workspacePath) ?? 1) - 1;
431
- workspaceQueueDepths.set(workspacePath, remainingDepth);
427
+ const remainingDepth = workspaceQueue.decrementDepth(workspacePath);
432
428
  if (remainingDepth > 0) {
433
429
  logger_1.logger.info(`[Queue:${projectLabel}] Task done, ${remainingDepth} remaining`);
434
430
  }