lazy-gravity 0.11.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.
package/README.md CHANGED
@@ -90,12 +90,19 @@ Just type in any bound channel:
90
90
  - `πŸ“ /template list` β€” Display registered templates with execute buttons
91
91
  - `πŸ“ /template add <name> <prompt>` β€” Register a new prompt template
92
92
  - `πŸ“ /template delete <name>` β€” Delete a template
93
+ - `πŸ“… /schedule list` β€” Show all scheduled tasks with next localized run times
94
+ - `πŸ“… /schedule add <cron> <prompt>` β€” Register a recurring task for the current channel's bound project
95
+ - `πŸ“… /schedule remove <id>` β€” Delete a scheduled task by ID
96
+ - `πŸ“… /schedule clear` β€” Remove all scheduled tasks and reset the task ID counter
97
+ - `πŸ“… /schedule backup` β€” Export all scheduled tasks as a JSON file attachment
98
+ - `πŸ“… /schedule restore <file>` β€” Restore scheduled tasks from a JSON file attachment
93
99
  - `πŸ”— /join` β€” Join an existing Antigravity session (shows up to 20 recent sessions)
94
100
  - `πŸ”— /mirror` β€” Toggle PCβ†’Discord message mirroring for the current session
95
101
  - `πŸ›‘ /stop` β€” Force-stop a running Antigravity task
96
102
  - `πŸ›‘ /shutdown` β€” Shut down the IDE while keeping the active CDP project connections and session bindings
97
103
  - `πŸ“Έ /screenshot` β€” Capture and send Antigravity's current screen
98
104
  - `πŸ”§ /status` β€” Show bot connection status, current mode, and active project
105
+ - `πŸ’“ /heartbeat [on|off|status]` β€” Configure periodic bot heartbeat notifications
99
106
  - `βœ… /autoaccept [on|off|status]` β€” Toggle auto-approval of file edit dialogs
100
107
  - `πŸ“ /output [embed|plain]` β€” Toggle output format between Embed and Plain Text (plain text is easier to copy on mobile)
101
108
  - `πŸ“‹ /logs [lines] [level]` β€” View recent bot logs (ephemeral)
package/dist/bot/index.js CHANGED
@@ -62,6 +62,10 @@ const workspaceBindingRepository_1 = require("../database/workspaceBindingReposi
62
62
  const channelPreferenceRepository_1 = require("../database/channelPreferenceRepository");
63
63
  const chatSessionRepository_1 = require("../database/chatSessionRepository");
64
64
  const artifactThreadRepository_1 = require("../database/artifactThreadRepository");
65
+ const scheduleRepository_1 = require("../database/scheduleRepository");
66
+ const scheduleService_1 = require("../services/scheduleService");
67
+ const cron_parser_1 = __importDefault(require("cron-parser"));
68
+ const workspaceQueue_1 = require("./workspaceQueue");
65
69
  const workspaceService_1 = require("../services/workspaceService");
66
70
  const workspaceCommandHandler_1 = require("../commands/workspaceCommandHandler");
67
71
  const chatCommandHandler_1 = require("../commands/chatCommandHandler");
@@ -108,6 +112,7 @@ const buttonHandler_1 = require("../handlers/buttonHandler");
108
112
  const selectHandler_1 = require("../handlers/selectHandler");
109
113
  const approvalButtonAction_1 = require("../handlers/approvalButtonAction");
110
114
  const planningButtonAction_1 = require("../handlers/planningButtonAction");
115
+ const heartbeatService_1 = require("../services/heartbeatService");
111
116
  const errorPopupButtonAction_1 = require("../handlers/errorPopupButtonAction");
112
117
  const runCommandButtonAction_1 = require("../handlers/runCommandButtonAction");
113
118
  const modelButtonAction_1 = require("../handlers/modelButtonAction");
@@ -1100,9 +1105,13 @@ const startBot = async (cliLogLevel) => {
1100
1105
  const workspaceBindingRepo = new workspaceBindingRepository_1.WorkspaceBindingRepository(db);
1101
1106
  const chatSessionRepo = new chatSessionRepository_1.ChatSessionRepository(db);
1102
1107
  const artifactThreadRepo = new artifactThreadRepository_1.ArtifactThreadRepository(db);
1108
+ const scheduleRepo = new scheduleRepository_1.ScheduleRepository(db);
1109
+ const scheduleService = new scheduleService_1.ScheduleService(scheduleRepo);
1110
+ const workspaceQueue = new workspaceQueue_1.WorkspaceQueue();
1103
1111
  const artifactService = new artifactService_1.ArtifactService();
1104
1112
  const workspaceService = new workspaceService_1.WorkspaceService(config.workspaceBaseDir);
1105
1113
  const channelManager = new channelManager_1.ChannelManager();
1114
+ const heartbeatService = new heartbeatService_1.HeartbeatService();
1106
1115
  // Auto-launch Antigravity with CDP port if not already running
1107
1116
  await (0, antigravityLauncher_1.ensureAntigravityRunning)();
1108
1117
  // Initialize CDP bridge (lazy connection: pool creation only)
@@ -1217,6 +1226,8 @@ const startBot = async (cliLogLevel) => {
1217
1226
  }));
1218
1227
  client.once(discord_js_1.Events.ClientReady, async (readyClient) => {
1219
1228
  logger_1.logger.info(`Ready! Logged in as ${readyClient.user.tag} | extractionMode=${config.extractionMode}`);
1229
+ heartbeatService.init(readyClient, bridge);
1230
+ heartbeatService.start();
1220
1231
  try {
1221
1232
  await (0, registerSlashCommands_1.registerSlashCommands)(discordToken, discordClientId, config.guildId);
1222
1233
  }
@@ -1257,6 +1268,74 @@ const startBot = async (cliLogLevel) => {
1257
1268
  catch (error) {
1258
1269
  logger_1.logger.warn('Failed to send startup dashboard embed:', error);
1259
1270
  }
1271
+ // Restore scheduled tasks
1272
+ const scheduleJobCallback = async (schedule) => {
1273
+ logger_1.logger.info(`[Schedule] Trigger callback running for task ${schedule.id}. Workspace: ${schedule.workspacePath}, promptLength: ${schedule.prompt.length}`);
1274
+ try {
1275
+ let channelId = schedule.channelId;
1276
+ const isWindows = process.platform === 'win32';
1277
+ const bindings = workspaceBindingRepo.findAll().filter(b => {
1278
+ const absPath = workspaceService.getWorkspacePath(b.workspacePath);
1279
+ return isWindows
1280
+ ? absPath.toLowerCase() === schedule.workspacePath.toLowerCase()
1281
+ : absPath === schedule.workspacePath;
1282
+ });
1283
+ if (!channelId || !bindings.some(b => b.channelId === channelId)) {
1284
+ channelId = bindings[0]?.channelId;
1285
+ }
1286
+ if (!channelId) {
1287
+ logger_1.logger.warn(`[Schedule] No channel bound to workspace ${schedule.workspacePath}. Skipping task ${schedule.id}.`);
1288
+ return;
1289
+ }
1290
+ const channel = await readyClient.channels.fetch(channelId).catch(() => null);
1291
+ if (channel && channel.isTextBased() && 'send' in channel) {
1292
+ logger_1.logger.info(`[Schedule] Sending trigger notification to channel ${channelId} for task ${schedule.id}`);
1293
+ const message = await channel.send({
1294
+ content: '⏰ **Scheduled Task Triggered**',
1295
+ allowedMentions: { parse: [] }
1296
+ });
1297
+ const projectLabel = bridge.pool.extractProjectName(schedule.workspacePath);
1298
+ workspaceQueue.incrementDepth(schedule.workspacePath);
1299
+ await workspaceQueue.enqueue(schedule.workspacePath, async () => {
1300
+ try {
1301
+ logger_1.logger.info(`[Schedule] Dispatching prompt to promptDispatcher for task ${schedule.id}`);
1302
+ const preferredAccount = bridge.pool.getPreferredAccountForWorkspace(schedule.workspacePath)
1303
+ || (config.antigravityAccounts?.[0]?.name ?? 'default');
1304
+ const cdp = await bridge.pool.getOrConnect(schedule.workspacePath, { name: preferredAccount });
1305
+ await promptDispatcher.send({
1306
+ message: message,
1307
+ prompt: schedule.prompt,
1308
+ cdp,
1309
+ inboundImages: [],
1310
+ options: {
1311
+ chatSessionService,
1312
+ chatSessionRepo,
1313
+ channelManager,
1314
+ titleGenerator,
1315
+ userPrefRepo,
1316
+ artifactService,
1317
+ extractionMode: config.extractionMode,
1318
+ },
1319
+ });
1320
+ }
1321
+ finally {
1322
+ const remainingDepth = workspaceQueue.decrementDepth(schedule.workspacePath);
1323
+ if (remainingDepth > 0) {
1324
+ logger_1.logger.info(`[Queue:${projectLabel}] Task done, ${remainingDepth} remaining`);
1325
+ }
1326
+ }
1327
+ });
1328
+ }
1329
+ else {
1330
+ logger_1.logger.warn(`[Schedule] Channel ${channelId} not found or not text-based for task ${schedule.id}.`);
1331
+ }
1332
+ }
1333
+ catch (error) {
1334
+ logger_1.logger.error(`[Schedule] Failed to execute task ${schedule.id}:`, error);
1335
+ }
1336
+ };
1337
+ const restoredCount = scheduleService.restoreAll(scheduleJobCallback);
1338
+ logger_1.logger.info(`[Schedule] Restored ${restoredCount} scheduled tasks.`);
1260
1339
  });
1261
1340
  // [Discord Interactions API] Slash command interaction handler
1262
1341
  client.on(discord_js_1.Events.InteractionCreate, (0, interactionCreateHandler_1.createInteractionCreateHandler)({
@@ -1290,7 +1369,9 @@ const startBot = async (cliLogLevel) => {
1290
1369
  channelManager,
1291
1370
  titleGenerator,
1292
1371
  antigravityAccounts: config.antigravityAccounts,
1293
- handleSlashInteraction: async (interaction, handler, bridgeArg, wsHandlerArg, chatHandlerArg, cleanupHandlerArg, modeServiceArg, modelServiceArg, autoAcceptServiceArg, clientArg, accountPrefRepoArg, channelPrefRepoArg, antigravityAccountsArg, chatSessionRepoArg, scheduleServiceArg) => handleSlashInteraction(interaction, handler, bridgeArg, wsHandlerArg, chatHandlerArg, cleanupHandlerArg, chatSessionService, modeServiceArg, modelServiceArg, autoAcceptServiceArg, clientArg, promptDispatcher, templateRepo, joinHandler, userPrefRepo, accountPrefRepoArg, channelPrefRepoArg, antigravityAccountsArg, chatSessionRepoArg, artifactService, scheduleServiceArg),
1372
+ heartbeatService,
1373
+ scheduleService,
1374
+ handleSlashInteraction: async (interaction, handler, bridgeArg, wsHandlerArg, chatHandlerArg, cleanupHandlerArg, modeServiceArg, modelServiceArg, autoAcceptServiceArg, clientArg, accountPrefRepoArg, channelPrefRepoArg, antigravityAccountsArg, chatSessionRepoArg, scheduleServiceArg) => handleSlashInteraction(interaction, handler, bridgeArg, wsHandlerArg, chatHandlerArg, cleanupHandlerArg, chatSessionService, modeServiceArg, modelServiceArg, autoAcceptServiceArg, clientArg, promptDispatcher, templateRepo, joinHandler, userPrefRepo, accountPrefRepoArg, channelPrefRepoArg, antigravityAccountsArg, chatSessionRepoArg, artifactService, scheduleServiceArg, heartbeatService),
1294
1375
  handleTemplateUse: async (interaction, templateId) => {
1295
1376
  const template = templateRepo.findById(templateId);
1296
1377
  if (!template) {
@@ -1412,6 +1493,8 @@ const startBot = async (cliLogLevel) => {
1412
1493
  accountPrefRepo,
1413
1494
  channelPrefRepo,
1414
1495
  antigravityAccounts: config.antigravityAccounts,
1496
+ heartbeatService,
1497
+ workspaceQueue,
1415
1498
  }));
1416
1499
  await client.login(discordToken);
1417
1500
  } // end: else (credentials present)
@@ -1682,10 +1765,22 @@ async function autoRenameChannel(message, chatSessionRepo, titleGenerator, chann
1682
1765
  logger_1.logger.error('[AutoRename] Rename failed:', err);
1683
1766
  }
1684
1767
  }
1768
+ /**
1769
+ * Utility to parse a cron expression and format the next run time.
1770
+ */
1771
+ function formatNextRunTime(cronExpression) {
1772
+ try {
1773
+ const interval = cron_parser_1.default.parse(cronExpression);
1774
+ return interval.next().toDate().toLocaleString();
1775
+ }
1776
+ catch (err) {
1777
+ return 'Invalid Cron';
1778
+ }
1779
+ }
1685
1780
  /**
1686
1781
  * Handle Discord Interactions API slash commands
1687
1782
  */
1688
- async function handleSlashInteraction(interaction, handler, bridge, wsHandler, chatHandler, cleanupHandler, chatSessionService, modeService, modelService, autoAcceptService, _client, promptDispatcher, templateRepo, joinHandler, userPrefRepo, accountPrefRepo, channelPrefRepo, antigravityAccounts = [{ name: 'default', cdpPort: 9222 }], chatSessionRepo, artifactService, scheduleService) {
1783
+ async function handleSlashInteraction(interaction, handler, bridge, wsHandler, chatHandler, cleanupHandler, chatSessionService, modeService, modelService, autoAcceptService, _client, promptDispatcher, templateRepo, joinHandler, userPrefRepo, accountPrefRepo, channelPrefRepo, antigravityAccounts = [{ name: 'default', cdpPort: 9222 }], chatSessionRepo, artifactService, scheduleService, heartbeatService) {
1689
1784
  const commandName = interaction.commandName;
1690
1785
  const getAccountPort = (accountName) => {
1691
1786
  const match = antigravityAccounts.find((account) => account.name === accountName);
@@ -2248,6 +2343,244 @@ async function handleSlashInteraction(interaction, handler, bridge, wsHandler, c
2248
2343
  });
2249
2344
  break;
2250
2345
  }
2346
+ case 'heartbeat': {
2347
+ const subcommand = interaction.options.getSubcommand();
2348
+ if (!heartbeatService) {
2349
+ await interaction.editReply({ content: 'Heartbeat service not available.' });
2350
+ break;
2351
+ }
2352
+ const envOverrides = [];
2353
+ if (process.env.HEARTBEAT_ENABLED !== undefined)
2354
+ envOverrides.push('HEARTBEAT_ENABLED');
2355
+ if (process.env.HEARTBEAT_INTERVAL_MS !== undefined)
2356
+ envOverrides.push('HEARTBEAT_INTERVAL_MS');
2357
+ if (process.env.HEARTBEAT_CHANNEL_ID !== undefined)
2358
+ envOverrides.push('HEARTBEAT_CHANNEL_ID');
2359
+ let warningPrefix = '';
2360
+ if (envOverrides.length > 0) {
2361
+ warningPrefix = `⚠️ **Warning**: Environment override(s) active: ${envOverrides.join(', ')}. Changes saved to config.json may not take effect until overrides are removed.\n\n`;
2362
+ }
2363
+ if (subcommand === 'on') {
2364
+ const intervalStr = interaction.options.getString('interval') || '1h';
2365
+ const targetChannel = interaction.options.getChannel('channel') || interaction.channel;
2366
+ if (!targetChannel || typeof targetChannel.isTextBased !== 'function' || !targetChannel.isTextBased()) {
2367
+ await interaction.editReply({ content: '⚠️ Please select a valid text channel.' });
2368
+ break;
2369
+ }
2370
+ // Check permissions
2371
+ const botUser = interaction.client.user;
2372
+ const permissions = targetChannel.permissionsFor?.(botUser);
2373
+ if (!permissions || !permissions.has('SendMessages') || !permissions.has('EmbedLinks')) {
2374
+ await interaction.editReply({ content: '⚠️ Bot does not have permission to send messages and embed links in that channel.' });
2375
+ break;
2376
+ }
2377
+ const intervalMs = (0, heartbeatService_1.parseInterval)(intervalStr);
2378
+ if (intervalMs === null || intervalMs <= 0) {
2379
+ await interaction.editReply({ content: '⚠️ Invalid interval format. Use a value with a unit, e.g. "1d", "1h", "30m" (bare numbers are not allowed).' });
2380
+ break;
2381
+ }
2382
+ if (intervalMs < 10000) {
2383
+ await interaction.editReply({ content: '⚠️ Interval must be at least 10 seconds.' });
2384
+ break;
2385
+ }
2386
+ if (intervalMs > 2147483647) {
2387
+ await interaction.editReply({ content: '⚠️ Interval cannot be greater than 24.8 days (2147483647 ms).' });
2388
+ break;
2389
+ }
2390
+ await heartbeatService.updateConfig(true, intervalMs, targetChannel.id);
2391
+ await interaction.editReply({
2392
+ content: `${warningPrefix}πŸ’“ Heartbeat enabled! Sending updates every **${intervalStr}** to channel <#${targetChannel.id}>.`
2393
+ });
2394
+ }
2395
+ else if (subcommand === 'off') {
2396
+ await heartbeatService.disable();
2397
+ await interaction.editReply({ content: `${warningPrefix}πŸ’“ Heartbeat disabled.` });
2398
+ }
2399
+ else if (subcommand === 'status') {
2400
+ const config = (0, config_1.loadConfig)();
2401
+ const uptimeMs = Date.now() - heartbeatService.botStartTime;
2402
+ const uptimeStr = (0, heartbeatService_1.formatDuration)(uptimeMs);
2403
+ const lastActivityStr = (0, heartbeatService_1.formatRelativeTime)(heartbeatService.lastActivityTimestamp);
2404
+ const activeWorkspaces = bridge.pool.getActiveWorkspaceNames();
2405
+ const activeCount = activeWorkspaces.length;
2406
+ const activeList = activeCount > 0 ? activeWorkspaces.join(', ') : 'None';
2407
+ const intervalVal = config.heartbeatIntervalMs != null ? (0, heartbeatService_1.formatDuration)(config.heartbeatIntervalMs) : 'N/A';
2408
+ const statusEmbed = new discord_js_1.EmbedBuilder()
2409
+ .setTitle('πŸ’“ Heartbeat Status')
2410
+ .setColor(config.heartbeatEnabled ? 0x00CC88 : 0x888888)
2411
+ .addFields({ name: 'Enabled', value: config.heartbeatEnabled ? '🟒 Yes' : 'βšͺ No', inline: true }, { name: 'Interval', value: config.heartbeatEnabled ? intervalVal : 'N/A', inline: true }, { name: 'Target Channel', value: config.heartbeatChannelId ? `<#${config.heartbeatChannelId}>` : 'N/A', inline: true }, { name: 'Active Sessions', value: `${activeCount} (${activeList})`, inline: true }, { name: 'Uptime', value: uptimeStr, inline: true }, { name: 'Last Activity', value: lastActivityStr, inline: true })
2412
+ .setTimestamp();
2413
+ await interaction.editReply({ embeds: [statusEmbed] });
2414
+ }
2415
+ break;
2416
+ }
2417
+ case 'schedule': {
2418
+ if (!scheduleService) {
2419
+ await interaction.editReply({ content: 'Schedule service not available.' });
2420
+ break;
2421
+ }
2422
+ const subcommand = interaction.options.getSubcommand();
2423
+ if (subcommand === 'list') {
2424
+ const schedules = scheduleService.listSchedules();
2425
+ if (schedules.length === 0) {
2426
+ await interaction.editReply({ content: 'No scheduled tasks found.' });
2427
+ break;
2428
+ }
2429
+ let formatted = '';
2430
+ let truncatedCount = 0;
2431
+ for (const s of schedules) {
2432
+ const nextRunStr = formatNextRunTime(s.cronExpression);
2433
+ const line = `**ID:** ${s.id} | **Cron:** \`${s.cronExpression}\` | **Next:** ${nextRunStr} | **Prompt:** ${s.prompt}\n`;
2434
+ if (formatted.length + line.length > 3900) {
2435
+ truncatedCount = schedules.length - schedules.indexOf(s);
2436
+ break;
2437
+ }
2438
+ formatted += line;
2439
+ }
2440
+ if (truncatedCount > 0) {
2441
+ formatted += `\n*...and ${truncatedCount} more task(s) (truncated due to Discord size limit).*`;
2442
+ }
2443
+ const embed = new discord_js_1.EmbedBuilder()
2444
+ .setTitle('πŸ•’ Scheduled Tasks')
2445
+ .setDescription(formatted)
2446
+ .setColor(0x00CC88);
2447
+ await interaction.editReply({ embeds: [embed] });
2448
+ break;
2449
+ }
2450
+ if (subcommand === 'add') {
2451
+ const cronExpr = interaction.options.getString('cron', true);
2452
+ const promptText = interaction.options.getString('prompt', true);
2453
+ const workspacePath = wsHandler.getWorkspaceForChannel(interaction.channelId);
2454
+ if (!workspacePath) {
2455
+ await interaction.editReply({ content: '⚠️ This channel is not bound to a workspace. Please bind it first.' });
2456
+ break;
2457
+ }
2458
+ try {
2459
+ const jobCb = scheduleService.getJobCallback();
2460
+ if (!jobCb) {
2461
+ await interaction.editReply({ content: '⚠️ Schedule service is still initializing. Please try again in a few seconds.' });
2462
+ break;
2463
+ }
2464
+ const record = scheduleService.addSchedule(cronExpr, promptText, workspacePath, interaction.channelId, jobCb);
2465
+ const nextRun = formatNextRunTime(cronExpr);
2466
+ const nextRunStr = nextRun !== 'Invalid Cron' ? ` (Next run: ${nextRun})` : '';
2467
+ await interaction.editReply({ content: `βœ… Scheduled task added! (ID: ${record.id})${nextRunStr}` });
2468
+ }
2469
+ catch (error) {
2470
+ await interaction.editReply({ content: `❌ Failed to add schedule: ${error.message}` });
2471
+ }
2472
+ break;
2473
+ }
2474
+ if (subcommand === 'remove') {
2475
+ const id = interaction.options.getInteger('id', true);
2476
+ const success = scheduleService.removeSchedule(id);
2477
+ if (success) {
2478
+ await interaction.editReply({ content: `βœ… Removed scheduled task ID: ${id}` });
2479
+ }
2480
+ else {
2481
+ await interaction.editReply({ content: `⚠️ Scheduled task ID ${id} not found.` });
2482
+ }
2483
+ break;
2484
+ }
2485
+ if (subcommand === 'clear') {
2486
+ const initialSchedules = scheduleService.listSchedules();
2487
+ const count = initialSchedules.length;
2488
+ if (count === 0) {
2489
+ await interaction.editReply({ content: 'πŸ“… No scheduled tasks found to clear.' });
2490
+ break;
2491
+ }
2492
+ const initialIdsJson = JSON.stringify(initialSchedules.map(s => s.id).sort((a, b) => a - b));
2493
+ const confirmBtnId = `schedule_clear_confirm_${interaction.id}`;
2494
+ const cancelBtnId = `schedule_clear_cancel_${interaction.id}`;
2495
+ const row = new discord_js_1.ActionRowBuilder().addComponents(new discord_js_1.ButtonBuilder()
2496
+ .setCustomId(confirmBtnId)
2497
+ .setLabel(`${(0, i18n_1.t)('Confirm Clear')} (${count})`)
2498
+ .setStyle(discord_js_1.ButtonStyle.Danger), new discord_js_1.ButtonBuilder()
2499
+ .setCustomId(cancelBtnId)
2500
+ .setLabel((0, i18n_1.t)('Cancel'))
2501
+ .setStyle(discord_js_1.ButtonStyle.Secondary));
2502
+ const message = await interaction.editReply({
2503
+ content: `⚠️ **Warning**: This will delete all **${count}** scheduled task(s) and reset the ID counter to 0. Are you sure you want to proceed?`,
2504
+ components: [row]
2505
+ });
2506
+ const collector = message.createMessageComponentCollector({
2507
+ filter: (i) => i.user.id === interaction.user.id && (i.customId === confirmBtnId || i.customId === cancelBtnId),
2508
+ time: 30000,
2509
+ max: 1
2510
+ });
2511
+ collector.on('collect', async (i) => {
2512
+ if (i.customId === confirmBtnId) {
2513
+ const currentSchedules = scheduleService.listSchedules();
2514
+ const currentIdsJson = JSON.stringify(currentSchedules.map(s => s.id).sort((a, b) => a - b));
2515
+ if (initialIdsJson !== currentIdsJson) {
2516
+ await i.update({
2517
+ content: '⚠️ **Action aborted**: The scheduled tasks list changed while waiting for confirmation. No schedules were cleared.',
2518
+ components: []
2519
+ });
2520
+ return;
2521
+ }
2522
+ scheduleService.resetSchedules();
2523
+ await i.update({
2524
+ content: `βœ… Successfully removed all **${count}** scheduled task(s) and reset the task ID counter to 0.`,
2525
+ components: []
2526
+ });
2527
+ }
2528
+ else {
2529
+ await i.update({
2530
+ content: '❌ Action cancelled. Scheduled tasks were not cleared.',
2531
+ components: []
2532
+ });
2533
+ }
2534
+ });
2535
+ collector.on('end', async (collected) => {
2536
+ if (collected.size === 0) {
2537
+ await interaction.editReply({
2538
+ content: '⚠️ Action timed out. Scheduled tasks were not cleared.',
2539
+ components: []
2540
+ }).catch(() => { });
2541
+ }
2542
+ });
2543
+ break;
2544
+ }
2545
+ if (subcommand === 'backup') {
2546
+ const json = scheduleService.backupSchedules();
2547
+ const buffer = Buffer.from(json, 'utf-8');
2548
+ await interaction.editReply({
2549
+ content: 'πŸ“‹ **LazyGravity Schedules Backup**',
2550
+ files: [{
2551
+ attachment: buffer,
2552
+ name: 'schedules_backup.json'
2553
+ }]
2554
+ });
2555
+ break;
2556
+ }
2557
+ if (subcommand === 'restore') {
2558
+ const attachment = interaction.options.getAttachment('file', true);
2559
+ if (!attachment.name.endsWith('.json')) {
2560
+ await interaction.editReply({ content: '❌ Attachment must be a `.json` file.' });
2561
+ break;
2562
+ }
2563
+ try {
2564
+ // Download file content using global fetch (available in Node 18+)
2565
+ const response = await fetch(attachment.url);
2566
+ if (!response.ok)
2567
+ throw new Error(`HTTP error! status: ${response.status}`);
2568
+ const jsonText = await response.text();
2569
+ const jobCb = scheduleService.getJobCallback();
2570
+ if (!jobCb) {
2571
+ await interaction.editReply({ content: '⚠️ Schedule service is still initializing. Please try again in a few seconds.' });
2572
+ break;
2573
+ }
2574
+ const restoredCount = scheduleService.restoreSchedules(jsonText, jobCb);
2575
+ await interaction.editReply({ content: `βœ… Successfully restored ${restoredCount} scheduled tasks from backup!` });
2576
+ }
2577
+ catch (error) {
2578
+ await interaction.editReply({ content: `❌ Failed to restore schedules: ${error.message}` });
2579
+ }
2580
+ break;
2581
+ }
2582
+ break;
2583
+ }
2251
2584
  default:
2252
2585
  await interaction.editReply({
2253
2586
  content: `Unknown command: /${commandName}`,
@@ -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
  };
@@ -112,6 +112,10 @@ function createInteractionCreateHandler(deps) {
112
112
  return { ok: true };
113
113
  };
114
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
+ }
115
119
  if (interaction.isAutocomplete()) {
116
120
  if (!deps.config.allowedUserIds.includes(interaction.user.id)) {
117
121
  await interaction.respond([]).catch(logger_1.logger.error);
@@ -1448,7 +1452,7 @@ function createInteractionCreateHandler(deps) {
1448
1452
  throw deferError;
1449
1453
  }
1450
1454
  try {
1451
- 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);
1452
1456
  }
1453
1457
  catch (error) {
1454
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.`,
@@ -255,9 +243,8 @@ function createMessageCreateHandler(deps) {
255
243
  if (workspacePath) {
256
244
  const projectLabel = deps.bridge.pool.extractProjectName(workspacePath);
257
245
  // Track queue depth for hourglass reactions
258
- const currentDepth = workspaceQueueDepths.get(workspacePath) ?? 0;
259
- workspaceQueueDepths.set(workspacePath, currentDepth + 1);
260
- const newDepth = currentDepth + 1;
246
+ const currentDepth = workspaceQueue.getDepth(workspacePath);
247
+ const newDepth = workspaceQueue.incrementDepth(workspacePath);
261
248
  if (currentDepth > 0) {
262
249
  logger_1.logger.info(`[Queue:${projectLabel}] Enqueued (depth: ${newDepth}, channel: ${message.channelId})`);
263
250
  await message.react('⏳').catch(() => { });
@@ -266,7 +253,7 @@ function createMessageCreateHandler(deps) {
266
253
  logger_1.logger.info(`[Queue:${projectLabel}] Processing immediately (depth: ${newDepth}, channel: ${message.channelId})`);
267
254
  }
268
255
  const queueStartTime = Date.now();
269
- await enqueueForWorkspace(workspacePath, async () => {
256
+ await workspaceQueue.enqueue(workspacePath, async () => {
270
257
  const waitMs = Date.now() - queueStartTime;
271
258
  if (waitMs > 100) {
272
259
  logger_1.logger.info(`[Queue:${projectLabel}] Task started after ${Math.round(waitMs / 1000)}s wait (channel: ${message.channelId})`);
@@ -437,8 +424,7 @@ function createMessageCreateHandler(deps) {
437
424
  await message.reply(`Failed to connect to workspace: ${e.message}`);
438
425
  }
439
426
  finally {
440
- const remainingDepth = (workspaceQueueDepths.get(workspacePath) ?? 1) - 1;
441
- workspaceQueueDepths.set(workspacePath, remainingDepth);
427
+ const remainingDepth = workspaceQueue.decrementDepth(workspacePath);
442
428
  if (remainingDepth > 0) {
443
429
  logger_1.logger.info(`[Queue:${projectLabel}] Task done, ${remainingDepth} remaining`);
444
430
  }
@@ -0,0 +1,261 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.HeartbeatService = void 0;
4
+ exports.parseInterval = parseInterval;
5
+ exports.formatDuration = formatDuration;
6
+ exports.formatRelativeTime = formatRelativeTime;
7
+ const discord_js_1 = require("discord.js");
8
+ const logger_1 = require("../utils/logger");
9
+ const configLoader_1 = require("../utils/configLoader");
10
+ class HeartbeatService {
11
+ client = null;
12
+ bridge = null;
13
+ intervalId = null;
14
+ generationToken = 0;
15
+ isSending = false;
16
+ botStartTime = Date.now();
17
+ lastActivityTimestamp = Date.now();
18
+ constructor() { }
19
+ init(client, bridge) {
20
+ this.client = client;
21
+ this.bridge = bridge;
22
+ this.botStartTime = Date.now();
23
+ this.lastActivityTimestamp = Date.now();
24
+ }
25
+ recordActivity() {
26
+ this.lastActivityTimestamp = Date.now();
27
+ }
28
+ start() {
29
+ this.stop();
30
+ const config = configLoader_1.ConfigLoader.load();
31
+ if (!config.heartbeatEnabled) {
32
+ logger_1.logger.info('[HeartbeatService] Periodic heartbeat is disabled.');
33
+ return;
34
+ }
35
+ const channelId = config.heartbeatChannelId;
36
+ if (!channelId) {
37
+ logger_1.logger.warn('[HeartbeatService] Enabled but heartbeatChannelId is not configured.');
38
+ return;
39
+ }
40
+ let interval = config.heartbeatIntervalMs ?? 3600000;
41
+ if (interval < 10000) {
42
+ logger_1.logger.warn(`[HeartbeatService] Interval ${interval}ms is too low. Clamping to 10000ms.`);
43
+ interval = 10000;
44
+ }
45
+ else if (interval > 2147483647) {
46
+ logger_1.logger.warn(`[HeartbeatService] Interval ${interval}ms is too high (max is 2147483647ms). Clamping to 2147483647ms.`);
47
+ interval = 2147483647;
48
+ }
49
+ logger_1.logger.info(`[HeartbeatService] Starting periodic heartbeat every ${interval}ms to channel ${channelId}`);
50
+ // Run immediately on start
51
+ this.sendHeartbeat().catch(err => {
52
+ logger_1.logger.error('[HeartbeatService] Failed to send initial heartbeat:', err);
53
+ });
54
+ this.intervalId = setInterval(() => {
55
+ this.sendHeartbeat().catch(err => {
56
+ logger_1.logger.error('[HeartbeatService] Failed to send periodic heartbeat:', err);
57
+ });
58
+ }, interval);
59
+ }
60
+ stop() {
61
+ this.generationToken++;
62
+ if (this.intervalId) {
63
+ clearInterval(this.intervalId);
64
+ this.intervalId = null;
65
+ }
66
+ }
67
+ async updateConfig(enabled, intervalMs, channelId) {
68
+ this.generationToken++;
69
+ const config = configLoader_1.ConfigLoader.load();
70
+ // If channel changed, clear the last message ID
71
+ if (config.heartbeatChannelId !== channelId) {
72
+ if (config.heartbeatChannelId && config.heartbeatLastMessageId) {
73
+ try {
74
+ const oldChannel = await this.client?.channels.fetch(config.heartbeatChannelId);
75
+ if (oldChannel && oldChannel.isTextBased()) {
76
+ const oldMsg = await oldChannel.messages.fetch(config.heartbeatLastMessageId);
77
+ if (oldMsg) {
78
+ await oldMsg.delete().catch(() => { });
79
+ }
80
+ }
81
+ }
82
+ catch (err) {
83
+ logger_1.logger.debug('[HeartbeatService] Failed to delete old heartbeat message from previous channel:', err);
84
+ }
85
+ }
86
+ configLoader_1.ConfigLoader.save({ heartbeatLastMessageId: undefined });
87
+ }
88
+ // Save to config.json
89
+ configLoader_1.ConfigLoader.save({
90
+ heartbeatEnabled: enabled,
91
+ heartbeatIntervalMs: intervalMs,
92
+ heartbeatChannelId: channelId,
93
+ });
94
+ logger_1.logger.info(`[HeartbeatService] Config updated: enabled=${enabled}, interval=${intervalMs}ms, channel=${channelId}`);
95
+ // Restart loop
96
+ this.start();
97
+ }
98
+ async disable() {
99
+ this.generationToken++;
100
+ const config = configLoader_1.ConfigLoader.load();
101
+ if (config.heartbeatChannelId && config.heartbeatLastMessageId) {
102
+ try {
103
+ const oldChannel = await this.client?.channels.fetch(config.heartbeatChannelId);
104
+ if (oldChannel && oldChannel.isTextBased()) {
105
+ const oldMsg = await oldChannel.messages.fetch(config.heartbeatLastMessageId);
106
+ if (oldMsg) {
107
+ await oldMsg.delete().catch(() => { });
108
+ }
109
+ }
110
+ }
111
+ catch (err) {
112
+ logger_1.logger.debug('[HeartbeatService] Failed to delete heartbeat message upon disabling:', err);
113
+ }
114
+ }
115
+ configLoader_1.ConfigLoader.save({
116
+ heartbeatEnabled: false,
117
+ heartbeatLastMessageId: undefined,
118
+ });
119
+ this.stop();
120
+ logger_1.logger.info('[HeartbeatService] Heartbeat disabled.');
121
+ }
122
+ async sendHeartbeat() {
123
+ if (!this.client || !this.bridge) {
124
+ logger_1.logger.warn('[HeartbeatService] Cannot send heartbeat: client or bridge not initialized.');
125
+ return;
126
+ }
127
+ if (this.isSending) {
128
+ logger_1.logger.debug('[HeartbeatService] Heartbeat send already in flight. Skipping overlapping execution.');
129
+ return;
130
+ }
131
+ const gen = this.generationToken;
132
+ this.isSending = true;
133
+ const config = configLoader_1.ConfigLoader.load();
134
+ const channelId = config.heartbeatChannelId;
135
+ if (!channelId) {
136
+ this.isSending = false;
137
+ return;
138
+ }
139
+ try {
140
+ const channel = await this.client.channels.fetch(channelId);
141
+ if (gen !== this.generationToken) {
142
+ logger_1.logger.debug(`[HeartbeatService] Aborting heartbeat send: stale generation (expected ${gen}, current ${this.generationToken})`);
143
+ return;
144
+ }
145
+ if (!channel || !channel.isTextBased()) {
146
+ logger_1.logger.warn(`[HeartbeatService] Channel ${channelId} not found or is not a text channel.`);
147
+ return;
148
+ }
149
+ const embed = this.buildHeartbeatEmbed();
150
+ const lastMessageId = config.heartbeatLastMessageId;
151
+ if (lastMessageId) {
152
+ try {
153
+ const message = await channel.messages.fetch(lastMessageId);
154
+ if (gen !== this.generationToken) {
155
+ logger_1.logger.debug('[HeartbeatService] Aborting message edit: stale generation');
156
+ return;
157
+ }
158
+ if (message && message.author.id === this.client.user?.id) {
159
+ await message.edit({ embeds: [embed] });
160
+ logger_1.logger.debug(`[HeartbeatService] Updated heartbeat message in-place: ${lastMessageId}`);
161
+ return;
162
+ }
163
+ }
164
+ catch (fetchErr) {
165
+ logger_1.logger.debug(`[HeartbeatService] Failed to fetch or edit message ${lastMessageId}, sending new one.`);
166
+ }
167
+ }
168
+ if (gen !== this.generationToken) {
169
+ logger_1.logger.debug('[HeartbeatService] Aborting message send: stale generation');
170
+ return;
171
+ }
172
+ // Send new message
173
+ const newMsg = await channel.send({ embeds: [embed] });
174
+ if (gen !== this.generationToken) {
175
+ logger_1.logger.debug(`[HeartbeatService] Stale generation after send. Deleting new message ${newMsg.id} to avoid leakage.`);
176
+ await newMsg.delete().catch(() => { });
177
+ return;
178
+ }
179
+ configLoader_1.ConfigLoader.save({ heartbeatLastMessageId: newMsg.id });
180
+ logger_1.logger.info(`[HeartbeatService] Sent new heartbeat message: ${newMsg.id}`);
181
+ }
182
+ catch (error) {
183
+ logger_1.logger.error('[HeartbeatService] Error in sendHeartbeat:', error);
184
+ }
185
+ finally {
186
+ this.isSending = false;
187
+ }
188
+ }
189
+ buildHeartbeatEmbed() {
190
+ const uptimeMs = Date.now() - this.botStartTime;
191
+ const uptimeStr = formatDuration(uptimeMs);
192
+ const activeWorkspaces = this.bridge?.pool.getActiveWorkspaceNames() || [];
193
+ const activeCount = activeWorkspaces.length;
194
+ const activeList = activeCount > 0 ? activeWorkspaces.join(', ') : 'None';
195
+ const lastActivityStr = formatRelativeTime(this.lastActivityTimestamp);
196
+ return new discord_js_1.EmbedBuilder()
197
+ .setTitle('πŸ’“ LazyGravity Heartbeat')
198
+ .setColor(0x00CC88)
199
+ .addFields({ name: 'Status', value: '🟒 Running', inline: true }, { name: 'Uptime', value: uptimeStr, inline: true }, { name: 'Active Sessions', value: `${activeCount} (${activeList})`, inline: true }, { name: 'Last Activity', value: lastActivityStr, inline: true })
200
+ .setFooter({ text: `Last updated: ${new Date().toLocaleString()}` })
201
+ .setTimestamp();
202
+ }
203
+ }
204
+ exports.HeartbeatService = HeartbeatService;
205
+ /**
206
+ * Parse a duration string like "1h", "6h", "30m", or "2d" to milliseconds.
207
+ * Requiring a unit prevents ambiguity with bare numbers.
208
+ */
209
+ function parseInterval(str) {
210
+ const match = str.trim().toLowerCase().match(/^(\d+)(ms|s|m|h|d)$/);
211
+ if (!match)
212
+ return null;
213
+ const value = parseInt(match[1], 10);
214
+ const unit = match[2];
215
+ switch (unit) {
216
+ case 'ms': return value;
217
+ case 's': return value * 1000;
218
+ case 'm': return value * 60 * 1000;
219
+ case 'h': return value * 60 * 60 * 1000;
220
+ case 'd': return value * 24 * 60 * 60 * 1000;
221
+ default: return null;
222
+ }
223
+ }
224
+ /**
225
+ * Format milliseconds to a human-readable duration (e.g. "2h 15m")
226
+ */
227
+ function formatDuration(ms) {
228
+ const seconds = Math.floor((ms / 1000) % 60);
229
+ const minutes = Math.floor((ms / (1000 * 60)) % 60);
230
+ const hours = Math.floor((ms / (1000 * 60 * 60)) % 24);
231
+ const days = Math.floor(ms / (1000 * 60 * 60 * 24));
232
+ const parts = [];
233
+ if (days > 0)
234
+ parts.push(`${days}d`);
235
+ if (hours > 0)
236
+ parts.push(`${hours}h`);
237
+ if (minutes > 0)
238
+ parts.push(`${minutes}m`);
239
+ if (seconds > 0 || parts.length === 0)
240
+ parts.push(`${seconds}s`);
241
+ return parts.join(' ');
242
+ }
243
+ /**
244
+ * Format a past timestamp to a relative string (e.g. "5m ago")
245
+ */
246
+ function formatRelativeTime(timestamp) {
247
+ const diff = Date.now() - timestamp;
248
+ if (diff < 5000)
249
+ return 'just now';
250
+ const seconds = Math.floor(diff / 1000);
251
+ if (seconds < 60)
252
+ return `${seconds}s ago`;
253
+ const minutes = Math.floor(seconds / 60);
254
+ if (minutes < 60)
255
+ return `${minutes}m ago`;
256
+ const hours = Math.floor(minutes / 60);
257
+ if (hours < 24)
258
+ return `${hours}h ${minutes % 60}m ago`;
259
+ const days = Math.floor(hours / 24);
260
+ return `${days}d ago`;
261
+ }
@@ -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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lazy-gravity",
3
- "version": "0.11.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",