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
@@ -61,24 +61,37 @@ class QuestionDetector extends events_1.EventEmitter {
61
61
  const callParams = {
62
62
  expression: `
63
63
  (() => {
64
+ const getInteractiveItems = (elContainer) => {
65
+ return Array.from(elContainer.querySelectorAll('li, label, a, [role="radio"], [role="option"], [class*="cursor-pointer"]'))
66
+ .filter(el => {
67
+ if (el.tagName === 'BUTTON' || el.closest('button')) return false;
68
+ const role = el.getAttribute('role');
69
+ if (['radio', 'option', 'checkbox', 'button', 'menuitem'].includes(role)) return true;
70
+ if (el.tagName === 'A' || el.tagName === 'LABEL') return true;
71
+ const style = window.getComputedStyle(el);
72
+ return style.cursor === 'pointer';
73
+ });
74
+ };
64
75
  const containers = Array.from(document.querySelectorAll('div, form, dialog')).reverse();
65
76
  let targetList = null;
66
77
  let submitBtn = null;
67
78
 
68
79
  for (const container of containers) {
80
+ const hasTextInput = container.querySelector('textarea, input:not([type="hidden"]):not([type="radio"]):not([type="checkbox"]), [contenteditable], [role="textbox"], .monaco-editor, .editor-container, .inputarea, vscode-text-field, vscode-text-area');
81
+ const hasExplicitQuestionItems = container.querySelector('[role="radio"], input[type="radio"], input[type="checkbox"], [role="option"], [role="checkbox"]');
82
+ if (hasTextInput && !hasExplicitQuestionItems) continue;
69
83
  const buttons = Array.from(container.querySelectorAll('button'));
70
84
  let possibleSubmitBtn = null;
71
85
  for (const btn of buttons) {
72
86
  const text = btn.textContent?.toLowerCase() || '';
73
- if (text.includes('submit')) {
87
+ if (text.includes('submit') || text.includes('continue')) {
74
88
  possibleSubmitBtn = btn;
75
89
  break;
76
90
  }
77
91
  }
78
92
 
79
93
  if (possibleSubmitBtn) {
80
- const items = Array.from(container.querySelectorAll('li, label, a, [role="radio"], [role="option"], [class*="cursor-pointer"]'))
81
- .filter(el => el.tagName !== 'BUTTON' && !el.closest('button'));
94
+ const items = getInteractiveItems(container);
82
95
 
83
96
  if (items.length > 1) {
84
97
  targetList = container;
@@ -90,30 +103,38 @@ class QuestionDetector extends events_1.EventEmitter {
90
103
 
91
104
  if (!targetList || !submitBtn) return { found: false };
92
105
 
93
- const items = Array.from(targetList.querySelectorAll('li, label, a, [role="radio"], [role="option"], [class*="cursor-pointer"]'))
94
- .filter(el => el.tagName !== 'BUTTON' && !el.closest('button'));
106
+ const items = getInteractiveItems(targetList);
95
107
  if (items.length <= ${index}) return { found: false };
96
108
 
97
109
  const targetOption = items[${index}];
98
110
 
99
- const optionRect = targetOption.getBoundingClientRect();
100
- const btnRect = submitBtn.getBoundingClientRect();
101
-
102
- return {
103
- found: true,
104
- option: {
105
- x: Math.round(optionRect.left + optionRect.width / 2),
106
- y: Math.round(optionRect.top + optionRect.height / 2)
107
- },
108
- button: {
109
- x: Math.round(btnRect.left + btnRect.width / 2),
110
- y: Math.round(btnRect.top + btnRect.height / 2)
111
+ const clickElement = (el) => {
112
+ const rect = el.getBoundingClientRect();
113
+ const clickX = rect.left + rect.width / 2;
114
+ const clickY = rect.top + rect.height / 2;
115
+ const events = ['pointerdown', 'mousedown', 'mouseup', 'click'];
116
+ for (const type of events) {
117
+ el.dispatchEvent(new MouseEvent(type, {
118
+ bubbles: true,
119
+ cancelable: true,
120
+ view: window,
121
+ clientX: clickX,
122
+ clientY: clickY,
123
+ }));
111
124
  }
112
125
  };
126
+
127
+ return new Promise(resolve => {
128
+ clickElement(targetOption);
129
+ setTimeout(() => {
130
+ clickElement(submitBtn);
131
+ resolve({ found: true });
132
+ }, 50);
133
+ });
113
134
  })()
114
135
  `,
115
136
  returnByValue: true,
116
- awaitPromise: false,
137
+ awaitPromise: true,
117
138
  };
118
139
  if (contextId !== null) {
119
140
  callParams.contextId = contextId;
@@ -124,37 +145,6 @@ class QuestionDetector extends events_1.EventEmitter {
124
145
  this.logger.warn(`[QuestionDetector:${this.projectName}] Could not find question modal elements during submission.`);
125
146
  return false;
126
147
  }
127
- // Click the option
128
- await this.cdp.call('Input.dispatchMouseEvent', {
129
- type: 'mousePressed',
130
- x: result.option.x,
131
- y: result.option.y,
132
- button: 'left',
133
- clickCount: 1,
134
- });
135
- await this.cdp.call('Input.dispatchMouseEvent', {
136
- type: 'mouseReleased',
137
- x: result.option.x,
138
- y: result.option.y,
139
- button: 'left',
140
- clickCount: 1,
141
- });
142
- await new Promise(resolve => setTimeout(resolve, 50));
143
- // Click the submit button
144
- await this.cdp.call('Input.dispatchMouseEvent', {
145
- type: 'mousePressed',
146
- x: result.button.x,
147
- y: result.button.y,
148
- button: 'left',
149
- clickCount: 1,
150
- });
151
- await this.cdp.call('Input.dispatchMouseEvent', {
152
- type: 'mouseReleased',
153
- x: result.button.x,
154
- y: result.button.y,
155
- button: 'left',
156
- clickCount: 1,
157
- });
158
148
  this.lastQuestionDetected = false;
159
149
  return true;
160
150
  }
@@ -170,12 +160,26 @@ class QuestionDetector extends events_1.EventEmitter {
170
160
  const callParams = {
171
161
  expression: `
172
162
  (() => {
163
+ const getInteractiveItems = (elContainer) => {
164
+ return Array.from(elContainer.querySelectorAll('li, label, a, [role="radio"], [role="option"], [class*="cursor-pointer"]'))
165
+ .filter(el => {
166
+ if (el.tagName === 'BUTTON' || el.closest('button')) return false;
167
+ const role = el.getAttribute('role');
168
+ if (['radio', 'option', 'checkbox', 'button', 'menuitem'].includes(role)) return true;
169
+ if (el.tagName === 'A' || el.tagName === 'LABEL') return true;
170
+ const style = window.getComputedStyle(el);
171
+ return style.cursor === 'pointer';
172
+ });
173
+ };
174
+
173
175
  const containers = Array.from(document.querySelectorAll('div, form, dialog')).reverse();
174
176
  let skipBtn = null;
175
177
 
176
178
  for (const container of containers) {
177
- const items = Array.from(container.querySelectorAll('li, [role="radio"], [role="option"], .cursor-pointer'))
178
- .filter(el => el.tagName !== 'BUTTON' && !el.closest('button'));
179
+ const hasTextInput = container.querySelector('textarea, input:not([type="hidden"]):not([type="radio"]):not([type="checkbox"]), [contenteditable="true"], [contenteditable=""], [role="textbox"]');
180
+ const hasExplicitQuestionItems = container.querySelector('[role="radio"], input[type="radio"], input[type="checkbox"], [role="option"], [role="checkbox"]');
181
+ if (hasTextInput && !hasExplicitQuestionItems) continue;
182
+ const items = getInteractiveItems(container);
179
183
  const hasList = items.length > 1;
180
184
 
181
185
  const buttons = Array.from(container.querySelectorAll('button'));
@@ -191,14 +195,24 @@ class QuestionDetector extends events_1.EventEmitter {
191
195
 
192
196
  if (!skipBtn) return { found: false };
193
197
 
194
- const btnRect = skipBtn.getBoundingClientRect();
195
- return {
196
- found: true,
197
- button: {
198
- x: Math.round(btnRect.left + btnRect.width / 2),
199
- y: Math.round(btnRect.top + btnRect.height / 2)
198
+ const clickElement = (el) => {
199
+ const rect = el.getBoundingClientRect();
200
+ const clickX = rect.left + rect.width / 2;
201
+ const clickY = rect.top + rect.height / 2;
202
+ const events = ['pointerdown', 'mousedown', 'mouseup', 'click'];
203
+ for (const type of events) {
204
+ el.dispatchEvent(new MouseEvent(type, {
205
+ bubbles: true,
206
+ cancelable: true,
207
+ view: window,
208
+ clientX: clickX,
209
+ clientY: clickY,
210
+ }));
200
211
  }
201
212
  };
213
+
214
+ clickElement(skipBtn);
215
+ return { found: true };
202
216
  })()
203
217
  `,
204
218
  returnByValue: true,
@@ -212,20 +226,6 @@ class QuestionDetector extends events_1.EventEmitter {
212
226
  this.logger.warn(`[QuestionDetector:${this.projectName}] Could not find skip button.`);
213
227
  return false;
214
228
  }
215
- await this.cdp.call('Input.dispatchMouseEvent', {
216
- type: 'mousePressed',
217
- x: result.button.x,
218
- y: result.button.y,
219
- button: 'left',
220
- clickCount: 1,
221
- });
222
- await this.cdp.call('Input.dispatchMouseEvent', {
223
- type: 'mouseReleased',
224
- x: result.button.x,
225
- y: result.button.y,
226
- button: 'left',
227
- clickCount: 1,
228
- });
229
229
  this.lastQuestionDetected = false;
230
230
  return true;
231
231
  }
@@ -241,24 +241,73 @@ class QuestionDetector extends events_1.EventEmitter {
241
241
  const callParams = {
242
242
  expression: `
243
243
  (() => {
244
+ const STOP_PATTERNS = [
245
+ /^stop$/,
246
+ /^stop generating$/,
247
+ /^stop response$/,
248
+ /^停止$/,
249
+ /^生成を停止$/,
250
+ /^応答を停止$/,
251
+ ];
252
+ const normalize = (value) => (value || '').toLowerCase().replace(/\\s+/g, ' ').trim();
253
+ const isStopLabel = (value) => {
254
+ const normalized = normalize(value);
255
+ if (!normalized) return false;
256
+ return STOP_PATTERNS.some((re) => re.test(normalized));
257
+ };
258
+ const isGenerating = Array.from(document.querySelectorAll('button, [role="button"]')).some(btn => {
259
+ const labels = [
260
+ btn.textContent || '',
261
+ btn.getAttribute('aria-label') || '',
262
+ btn.getAttribute('title') || '',
263
+ ];
264
+ return labels.some(isStopLabel);
265
+ }) || (() => {
266
+ const panel = document.querySelector('.antigravity-agent-side-panel');
267
+ if (panel) {
268
+ const panelText = (panel.textContent || '').trim();
269
+ return /Working\.\s*$/i.test(panelText);
270
+ }
271
+ return false;
272
+ })();
273
+ if (isGenerating) {
274
+ return { detected: false, reason: "IDE is generating" };
275
+ }
276
+
277
+ const getInteractiveItems = (elContainer) => {
278
+ return Array.from(elContainer.querySelectorAll('li, label, a, [role="radio"], [role="option"], [class*="cursor-pointer"]'))
279
+ .filter(el => {
280
+ if (el.tagName === 'BUTTON' || el.closest('button')) return false;
281
+ const text = (el.innerText || el.textContent || '').trim();
282
+ if (!text) return false;
283
+ const role = el.getAttribute('role');
284
+ if (['radio', 'option', 'checkbox', 'button', 'menuitem'].includes(role)) return true;
285
+ if (el.tagName === 'A' || el.tagName === 'LABEL') return true;
286
+ const style = window.getComputedStyle(el);
287
+ return style.cursor === 'pointer';
288
+ });
289
+ };
290
+
244
291
  const containers = Array.from(document.querySelectorAll('div, form, dialog')).reverse();
245
292
  let targetList = null;
246
293
  let submitBtn = null;
247
294
 
248
295
  for (const container of containers) {
296
+ const hasTextInput = container.querySelector('textarea, input:not([type="hidden"]):not([type="radio"]):not([type="checkbox"]), [contenteditable], [role="textbox"], .monaco-editor, .editor-container, .inputarea, vscode-text-field, vscode-text-area');
297
+ const hasExplicitQuestionItems = container.querySelector('[role="radio"], input[type="radio"], input[type="checkbox"], [role="option"], [role="checkbox"]');
298
+ if (hasTextInput && !hasExplicitQuestionItems) continue;
249
299
  const buttons = Array.from(container.querySelectorAll('button'));
250
300
  let possibleSubmitBtn = null;
251
301
  for (const btn of buttons) {
252
302
  const text = btn.textContent?.toLowerCase() || '';
253
- if (text.includes('submit')) {
303
+ if (text.includes('submit') || text.includes('continue')) {
254
304
  possibleSubmitBtn = btn;
255
305
  break;
256
306
  }
257
307
  }
258
308
 
259
309
  if (possibleSubmitBtn) {
260
- const items = Array.from(container.querySelectorAll('li, label, a, [role="radio"], [role="option"], [class*="cursor-pointer"]'))
261
- .filter(el => el.tagName !== 'BUTTON' && !el.closest('button'));
310
+ const items = getInteractiveItems(container);
262
311
 
263
312
  if (items.length > 1) {
264
313
  targetList = container;
@@ -280,8 +329,7 @@ class QuestionDetector extends events_1.EventEmitter {
280
329
  }
281
330
  const title = titleEl ? (titleEl.innerText || titleEl.textContent || '').trim() : 'Question';
282
331
 
283
- const items = Array.from(targetList.querySelectorAll('li, label, a, [role="radio"], [role="option"], [class*="cursor-pointer"]'))
284
- .filter(el => el.tagName !== 'BUTTON' && !el.closest('button'));
332
+ const items = getInteractiveItems(targetList);
285
333
  const options = items.map(n => {
286
334
  const rect = n.getBoundingClientRect();
287
335
  const finalLabel = n.innerText || n.textContent || 'Option';
@@ -321,6 +369,8 @@ class QuestionDetector extends events_1.EventEmitter {
321
369
  this.logger.debug(`[QuestionDetector] Error evaluating on ctx ${contextId}: ${e.message}`);
322
370
  return null;
323
371
  });
372
+ if (!this._isStarted)
373
+ return;
324
374
  if (response?.exceptionDetails) {
325
375
  this.logger.debug(`[QuestionDetector] Exception on ctx ${contextId}: ${response.exceptionDetails.exception?.description || response.exceptionDetails.text}`);
326
376
  }
@@ -336,6 +386,8 @@ class QuestionDetector extends events_1.EventEmitter {
336
386
  lastReason = result.reason;
337
387
  }
338
388
  }
389
+ if (!this._isStarted)
390
+ return;
339
391
  const result = detectedResult;
340
392
  if (result && result.detected) {
341
393
  this.emptyPollCount = 0;
@@ -146,6 +146,13 @@ exports.RESPONSE_SELECTORS = {
146
146
  }
147
147
  }
148
148
 
149
+ if (panel) {
150
+ const panelText = (panel.textContent || '').trim();
151
+ if (/Working\.\s*$/i.test(panelText)) {
152
+ return { isGenerating: true };
153
+ }
154
+ }
155
+
149
156
  return { isGenerating: false };
150
157
  })()`,
151
158
  /** Click stop button via tooltip-id + text fallback */
@@ -629,6 +636,7 @@ class ResponseMonitor {
629
636
  onCdpReconnectFailed = null;
630
637
  // Activity-based timeout (#49)
631
638
  lastActivityTime = 0;
639
+ startTime = 0;
632
640
  constructor(options) {
633
641
  this.cdpService = options.cdpService;
634
642
  this.pollIntervalMs = options.pollIntervalMs ?? 2000;
@@ -731,6 +739,7 @@ class ResponseMonitor {
731
739
  }
732
740
  // Activity-based timeout: track last activity time instead of fixed timer (#49)
733
741
  this.lastActivityTime = Date.now();
742
+ this.startTime = Date.now();
734
743
  // Register CDP connection event listeners (#48)
735
744
  this.registerCdpConnectionListeners();
736
745
  const mode = passive ? 'Passive monitoring' : 'Monitoring';
@@ -1109,7 +1118,14 @@ class ResponseMonitor {
1109
1118
  // Guard: never timeout while the stop button is visible — it means
1110
1119
  // Antigravity is still actively generating (extended thinking, long
1111
1120
  // shell commands, large file operations, etc.).
1112
- if (this.maxDurationMs > 0 && !isGenerating && Date.now() - this.lastActivityTime >= this.maxDurationMs) {
1121
+ // Hard timeout limit (20 minutes absolute hard timeout)
1122
+ const hardTimeoutLimit = 1200000;
1123
+ const totalElapsed = Date.now() - this.startTime;
1124
+ const isHardTimeout = totalElapsed >= hardTimeoutLimit;
1125
+ if (isHardTimeout || (this.maxDurationMs > 0 && !isGenerating && Date.now() - this.lastActivityTime >= this.maxDurationMs)) {
1126
+ if (isHardTimeout) {
1127
+ logger_1.logger.warn(`[ResponseMonitor] Hard timeout of 20 minutes reached.`);
1128
+ }
1113
1129
  const lastText = this.lastText ?? '';
1114
1130
  this.setPhase('timeout', lastText);
1115
1131
  await this.stop();
@@ -35,6 +35,7 @@ var __importStar = (this && this.__importStar) || (function () {
35
35
  Object.defineProperty(exports, "__esModule", { value: true });
36
36
  exports.ScheduleService = void 0;
37
37
  const cron = __importStar(require("node-cron"));
38
+ const logger_1 = require("../utils/logger");
38
39
  /**
39
40
  * Service class for managing scheduled jobs.
40
41
  *
@@ -46,9 +47,16 @@ class ScheduleService {
46
47
  repo;
47
48
  /** Map managing active cron tasks (schedule ID -> ScheduledTask) */
48
49
  activeTasks = new Map();
50
+ jobCallback;
49
51
  constructor(repo) {
50
52
  this.repo = repo;
51
53
  }
54
+ /**
55
+ * Get the stored job callback.
56
+ */
57
+ getJobCallback() {
58
+ return this.jobCallback;
59
+ }
52
60
  /**
53
61
  * Called on bot startup. Loads all enabled schedules from DB and registers/resumes them with node-cron.
54
62
  *
@@ -56,6 +64,7 @@ class ScheduleService {
56
64
  * @returns Number of restored schedules
57
65
  */
58
66
  restoreAll(jobCallback) {
67
+ this.jobCallback = jobCallback;
59
68
  const enabledSchedules = this.repo.findEnabled();
60
69
  for (const schedule of enabledSchedules) {
61
70
  this.registerCronTask(schedule, jobCallback);
@@ -73,20 +82,36 @@ class ScheduleService {
73
82
  * @returns Created schedule record
74
83
  * @throws On invalid cron expression
75
84
  */
76
- addSchedule(cronExpression, prompt, workspacePath, jobCallback) {
85
+ addSchedule(cronExpression, prompt, workspacePath, channelIdOrCallback, jobCallback) {
86
+ let finalChannelId = '';
87
+ let finalJobCallback;
88
+ if (typeof channelIdOrCallback === 'function') {
89
+ finalJobCallback = channelIdOrCallback;
90
+ }
91
+ else {
92
+ finalChannelId = channelIdOrCallback;
93
+ finalJobCallback = jobCallback;
94
+ }
95
+ if (!finalJobCallback) {
96
+ throw new Error('Job callback is not initialized.');
97
+ }
77
98
  // Validate cron expression
78
99
  if (!cron.validate(cronExpression)) {
79
100
  throw new Error(`Invalid cron expression: ${cronExpression}`);
80
101
  }
81
102
  // Save to DB
82
- const record = this.repo.create({
103
+ const recordInput = {
83
104
  cronExpression,
84
105
  prompt,
85
106
  workspacePath,
86
107
  enabled: true,
87
- });
108
+ };
109
+ if (finalChannelId) {
110
+ recordInput.channelId = finalChannelId;
111
+ }
112
+ const record = this.repo.create(recordInput);
88
113
  // Register with node-cron
89
- this.registerCronTask(record, jobCallback);
114
+ this.registerCronTask(record, finalJobCallback);
90
115
  return record;
91
116
  }
92
117
  /**
@@ -115,6 +140,76 @@ class ScheduleService {
115
140
  }
116
141
  this.activeTasks.clear();
117
142
  }
143
+ /**
144
+ * Remove all scheduled tasks.
145
+ * Stops all active cron tasks in memory, empties the database table,
146
+ * and resets the autoincrement ID back to 0.
147
+ */
148
+ resetSchedules() {
149
+ this.repo.reset();
150
+ this.stopAll();
151
+ }
152
+ /**
153
+ * Export all schedules as a JSON string
154
+ */
155
+ backupSchedules() {
156
+ const list = this.listSchedules();
157
+ // Export only portable fields (exclude autoincrement ID and timestamps)
158
+ const portable = list.map(s => ({
159
+ cronExpression: s.cronExpression,
160
+ prompt: s.prompt,
161
+ workspacePath: s.workspacePath,
162
+ channelId: s.channelId,
163
+ enabled: s.enabled
164
+ }));
165
+ return JSON.stringify(portable, null, 2);
166
+ }
167
+ restoreSchedules(jsonContent, jobCallback) {
168
+ if (!jobCallback) {
169
+ throw new Error('Job callback is not initialized.');
170
+ }
171
+ let parsed;
172
+ try {
173
+ parsed = JSON.parse(jsonContent);
174
+ }
175
+ catch (err) {
176
+ throw new Error('Invalid backup format: root must be an array of schedule objects.');
177
+ }
178
+ if (!Array.isArray(parsed)) {
179
+ throw new Error('Invalid backup format: root must be an array of schedule objects.');
180
+ }
181
+ // Validate items
182
+ const validated = [];
183
+ for (const item of parsed) {
184
+ if (!item || typeof item !== 'object') {
185
+ throw new Error('Invalid backup format: each schedule must contain cronExpression, prompt, and workspacePath.');
186
+ }
187
+ if (typeof item.cronExpression !== 'string' || typeof item.prompt !== 'string' || typeof item.workspacePath !== 'string') {
188
+ throw new Error('Invalid backup format: each schedule must contain cronExpression, prompt, and workspacePath.');
189
+ }
190
+ if (!cron.validate(item.cronExpression)) {
191
+ throw new Error(`Invalid cron expression in backup: "${item.cronExpression}"`);
192
+ }
193
+ validated.push({
194
+ cronExpression: item.cronExpression,
195
+ prompt: item.prompt,
196
+ workspacePath: item.workspacePath,
197
+ channelId: typeof item.channelId === 'string' ? item.channelId : undefined,
198
+ enabled: typeof item.enabled === 'boolean' ? item.enabled : true
199
+ });
200
+ }
201
+ // Write to DB first (atomic transaction)
202
+ const restoredRecords = this.repo.bulkRestore(validated);
203
+ // Stop memory crons now that DB write succeeded
204
+ this.stopAll();
205
+ // Resume crons in memory
206
+ for (const record of restoredRecords) {
207
+ if (record.enabled) {
208
+ this.registerCronTask(record, jobCallback);
209
+ }
210
+ }
211
+ return restoredRecords.length;
212
+ }
118
213
  /**
119
214
  * Get a list of all schedules
120
215
  */
@@ -125,7 +220,9 @@ class ScheduleService {
125
220
  * Internal method to register a task with node-cron
126
221
  */
127
222
  registerCronTask(schedule, jobCallback) {
223
+ logger_1.logger.info(`[Schedule] Registering cron task ID ${schedule.id} with expression "${schedule.cronExpression}"`);
128
224
  const task = cron.schedule(schedule.cronExpression, () => {
225
+ logger_1.logger.info(`[Schedule] Cron trigger fired for task ID ${schedule.id}`);
129
226
  jobCallback(schedule);
130
227
  });
131
228
  this.activeTasks.set(schedule.id, task);
@@ -113,6 +113,10 @@ function mergeConfig(persisted) {
113
113
  if (platforms.includes('telegram') && !telegramToken) {
114
114
  throw new Error('TELEGRAM_BOT_TOKEN is required when platforms include "telegram"');
115
115
  }
116
+ const heartbeatEnabled = resolveBoolean(process.env.HEARTBEAT_ENABLED, persisted.heartbeatEnabled, false);
117
+ const heartbeatIntervalMs = resolvePositiveInt(process.env.HEARTBEAT_INTERVAL_MS, persisted.heartbeatIntervalMs, 3600000);
118
+ const heartbeatChannelId = process.env.HEARTBEAT_CHANNEL_ID ?? persisted.heartbeatChannelId ?? undefined;
119
+ const heartbeatLastMessageId = persisted.heartbeatLastMessageId ?? undefined;
116
120
  return {
117
121
  discordToken,
118
122
  clientId,
@@ -128,6 +132,10 @@ function mergeConfig(persisted) {
128
132
  telegramAllowedUserIds,
129
133
  platforms,
130
134
  cdpHost,
135
+ heartbeatEnabled,
136
+ heartbeatIntervalMs,
137
+ heartbeatChannelId,
138
+ heartbeatLastMessageId,
131
139
  };
132
140
  }
133
141
  function resolveAllowedUserIds(persisted) {
@@ -3,7 +3,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.fileOpenCache = void 0;
4
4
  /**
5
5
  * In-memory cache to map button customIds to file URLs.
6
- * Bounded to a maximum size to prevent memory leaks, and entries are removed once consumed.
6
+ * Bounded to a maximum size to prevent memory leaks, using a FIFO eviction policy when the cache size limit is reached.
7
7
  */
8
8
  class BoundedCache {
9
9
  max_size;
@@ -23,11 +23,7 @@ class BoundedCache {
23
23
  this.map.set(key, value);
24
24
  }
25
25
  get(key) {
26
- const value = this.map.get(key);
27
- if (value !== undefined) {
28
- this.map.delete(key); // Remove upon consumption
29
- }
30
- return value;
26
+ return this.map.get(key);
31
27
  }
32
28
  }
33
29
  exports.fileOpenCache = new BoundedCache(1000);
@@ -1,6 +1,9 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.parseQuestionCustomId = parseQuestionCustomId;
4
+ exports.executeBrowserClick = executeBrowserClick;
5
+ const logger_1 = require("./logger");
6
+ const approvalDetector_1 = require("../services/approvalDetector");
4
7
  function parseQuestionCustomId(customId, expectedPrefix) {
5
8
  if (customId !== expectedPrefix && !customId.startsWith(expectedPrefix + ':'))
6
9
  return null;
@@ -23,3 +26,22 @@ function parseQuestionCustomId(customId, expectedPrefix) {
23
26
  }
24
27
  return result;
25
28
  }
29
+ /**
30
+ * Executes a click on an element matching the target text in the IDE.
31
+ * Returns true if the click was successfully dispatched, false otherwise.
32
+ */
33
+ async function executeBrowserClick(cdp, buttonText) {
34
+ try {
35
+ const script = (0, approvalDetector_1.buildClickScript)(buttonText);
36
+ const evalResult = await cdp.call('Runtime.evaluate', {
37
+ expression: script,
38
+ returnByValue: true,
39
+ awaitPromise: true,
40
+ });
41
+ return !!evalResult?.result?.value?.ok;
42
+ }
43
+ catch (error) {
44
+ logger_1.logger.error(`[executeBrowserClick] Error clicking button "${buttonText}":`, error);
45
+ return false;
46
+ }
47
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lazy-gravity",
3
- "version": "0.10.0",
3
+ "version": "0.12.0",
4
4
  "description": "Control Antigravity from anywhere — a local, secure bot (Discord + Telegram) that lets you remotely operate Antigravity on your home PC from your smartphone.",
5
5
  "main": "dist/index.js",
6
6
  "bin": {
@@ -50,6 +50,7 @@
50
50
  "@inquirer/select": "^5.1.0",
51
51
  "better-sqlite3": "^12.6.2",
52
52
  "commander": "^15.0.0",
53
+ "cron-parser": "^5.6.1",
53
54
  "discord.js": "^14.25.1",
54
55
  "dotenv": "^17.3.1",
55
56
  "grammy": "^1.41.1",