lazy-gravity 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (34) hide show
  1. package/README.md +18 -6
  2. package/dist/bin/cli.js +18 -18
  3. package/dist/bin/commands/doctor.js +2 -1
  4. package/dist/bin/commands/start.js +25 -2
  5. package/dist/bot/index.js +346 -152
  6. package/dist/commands/joinCommandHandler.js +302 -0
  7. package/dist/commands/joinDetachCommandHandler.js +285 -0
  8. package/dist/commands/registerSlashCommands.js +35 -0
  9. package/dist/database/chatSessionRepository.js +10 -0
  10. package/dist/database/userPreferenceRepository.js +72 -0
  11. package/dist/events/interactionCreateHandler.js +58 -36
  12. package/dist/events/messageCreateHandler.js +158 -53
  13. package/dist/services/antigravityLauncher.js +4 -3
  14. package/dist/services/approvalDetector.js +6 -0
  15. package/dist/services/cdpBridgeManager.js +184 -84
  16. package/dist/services/cdpConnectionPool.js +79 -51
  17. package/dist/services/cdpService.js +149 -51
  18. package/dist/services/chatSessionService.js +229 -8
  19. package/dist/services/errorPopupDetector.js +6 -0
  20. package/dist/services/planningDetector.js +6 -0
  21. package/dist/services/responseMonitor.js +125 -24
  22. package/dist/services/updateCheckService.js +147 -0
  23. package/dist/services/userMessageDetector.js +221 -0
  24. package/dist/ui/modeUi.js +11 -1
  25. package/dist/ui/outputUi.js +30 -0
  26. package/dist/ui/sessionPickerUi.js +48 -0
  27. package/dist/utils/antigravityPaths.js +94 -0
  28. package/dist/utils/configLoader.js +10 -0
  29. package/dist/utils/discordButtonUtils.js +33 -0
  30. package/dist/utils/logBuffer.js +47 -0
  31. package/dist/utils/logger.js +80 -20
  32. package/dist/utils/pathUtils.js +57 -0
  33. package/dist/utils/plainTextFormatter.js +70 -0
  34. package/package.json +4 -4
@@ -0,0 +1,302 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.JoinCommandHandler = void 0;
4
+ const i18n_1 = require("../utils/i18n");
5
+ const discord_js_1 = require("discord.js");
6
+ const cdpBridgeManager_1 = require("../services/cdpBridgeManager");
7
+ const responseMonitor_1 = require("../services/responseMonitor");
8
+ const sessionPickerUi_1 = require("../ui/sessionPickerUi");
9
+ const logger_1 = require("../utils/logger");
10
+ /** Maximum embed description length (Discord limit is 4096) */
11
+ const MAX_EMBED_DESC = 4000;
12
+ /**
13
+ * Handler for /join and /mirror commands
14
+ *
15
+ * /join — List Antigravity sessions and connect to one via a select menu.
16
+ * /mirror — Toggle PC-to-Discord message mirroring ON/OFF.
17
+ */
18
+ class JoinCommandHandler {
19
+ chatSessionService;
20
+ chatSessionRepo;
21
+ bindingRepo;
22
+ channelManager;
23
+ pool;
24
+ workspaceService;
25
+ client;
26
+ /** Active ResponseMonitors per workspace (for AI response mirroring) */
27
+ activeResponseMonitors = new Map();
28
+ constructor(chatSessionService, chatSessionRepo, bindingRepo, channelManager, pool, workspaceService, client) {
29
+ this.chatSessionService = chatSessionService;
30
+ this.chatSessionRepo = chatSessionRepo;
31
+ this.bindingRepo = bindingRepo;
32
+ this.channelManager = channelManager;
33
+ this.pool = pool;
34
+ this.workspaceService = workspaceService;
35
+ this.client = client;
36
+ }
37
+ /**
38
+ * Resolve a project name (from DB) to its full absolute path.
39
+ * The DB stores only the project name; CDP needs the full path for launching.
40
+ */
41
+ resolveProjectPath(projectName) {
42
+ return this.workspaceService.getWorkspacePath(projectName);
43
+ }
44
+ /**
45
+ * /join — Show session picker for the workspace bound to this channel.
46
+ */
47
+ async handleJoin(interaction, bridge) {
48
+ const binding = this.bindingRepo.findByChannelId(interaction.channelId);
49
+ const session = this.chatSessionRepo.findByChannelId(interaction.channelId);
50
+ const projectName = binding?.workspacePath ?? session?.workspacePath;
51
+ if (!projectName) {
52
+ await interaction.editReply({
53
+ content: (0, i18n_1.t)('⚠️ No project is bound to this channel. Use `/project` first.'),
54
+ });
55
+ return;
56
+ }
57
+ const projectPath = this.resolveProjectPath(projectName);
58
+ let cdp;
59
+ try {
60
+ cdp = await this.pool.getOrConnect(projectPath);
61
+ }
62
+ catch (e) {
63
+ await interaction.editReply({
64
+ content: (0, i18n_1.t)(`⚠️ Failed to connect to project: ${e.message}`),
65
+ });
66
+ return;
67
+ }
68
+ const sessions = await this.chatSessionService.listAllSessions(cdp);
69
+ const { embeds, components } = (0, sessionPickerUi_1.buildSessionPickerUI)(sessions);
70
+ await interaction.editReply({ embeds, components });
71
+ }
72
+ /**
73
+ * Handle session selection from the /join picker.
74
+ *
75
+ * Flow:
76
+ * 1. Check if a channel already exists for this session (by displayName)
77
+ * 2. If yes → reply with a link to that channel
78
+ * 3. If no → create a new channel, bind it, activate session, start mirroring
79
+ */
80
+ async handleJoinSelect(interaction, bridge) {
81
+ const selectedTitle = interaction.values[0];
82
+ const guild = interaction.guild;
83
+ if (!guild) {
84
+ await interaction.editReply({ content: (0, i18n_1.t)('⚠️ This command can only be used in a server.') });
85
+ return;
86
+ }
87
+ const binding = this.bindingRepo.findByChannelId(interaction.channelId);
88
+ const session = this.chatSessionRepo.findByChannelId(interaction.channelId);
89
+ const projectName = binding?.workspacePath ?? session?.workspacePath;
90
+ if (!projectName) {
91
+ await interaction.editReply({ content: (0, i18n_1.t)('⚠️ No project is bound to this channel.') });
92
+ return;
93
+ }
94
+ const projectPath = this.resolveProjectPath(projectName);
95
+ // Step 1: Check if a channel already exists for this session
96
+ const existingSession = this.chatSessionRepo.findByDisplayName(projectName, selectedTitle);
97
+ if (existingSession) {
98
+ const embed = new discord_js_1.EmbedBuilder()
99
+ .setTitle((0, i18n_1.t)('🔗 Session Already Connected'))
100
+ .setDescription((0, i18n_1.t)(`This session already has a channel:\n→ <#${existingSession.channelId}>`))
101
+ .setColor(0x3498DB)
102
+ .setTimestamp();
103
+ await interaction.editReply({ embeds: [embed], components: [] });
104
+ return;
105
+ }
106
+ // Step 2: Connect to CDP
107
+ let cdp;
108
+ try {
109
+ cdp = await this.pool.getOrConnect(projectPath);
110
+ }
111
+ catch (e) {
112
+ await interaction.editReply({ content: (0, i18n_1.t)(`⚠️ Failed to connect to project: ${e.message}`) });
113
+ return;
114
+ }
115
+ // Step 3: Activate the session in Antigravity
116
+ const activateResult = await this.chatSessionService.activateSessionByTitle(cdp, selectedTitle);
117
+ if (!activateResult.ok) {
118
+ await interaction.editReply({ content: (0, i18n_1.t)(`⚠️ Failed to join session: ${activateResult.error}`) });
119
+ return;
120
+ }
121
+ // Step 4: Create a new Discord channel for this session
122
+ const categoryResult = await this.channelManager.ensureCategory(guild, projectName);
123
+ const categoryId = categoryResult.categoryId;
124
+ const sessionNumber = this.chatSessionRepo.getNextSessionNumber(categoryId);
125
+ const channelName = this.channelManager.sanitizeChannelName(`${sessionNumber}-${selectedTitle}`);
126
+ const channelResult = await this.channelManager.createSessionChannel(guild, categoryId, channelName);
127
+ const newChannelId = channelResult.channelId;
128
+ // Step 5: Register binding and session
129
+ this.bindingRepo.upsert({
130
+ channelId: newChannelId,
131
+ workspacePath: projectName,
132
+ guildId: guild.id,
133
+ });
134
+ this.chatSessionRepo.create({
135
+ channelId: newChannelId,
136
+ categoryId,
137
+ workspacePath: projectName,
138
+ sessionNumber,
139
+ guildId: guild.id,
140
+ });
141
+ this.chatSessionRepo.updateDisplayName(newChannelId, selectedTitle);
142
+ // Step 6: Start mirroring (routes dynamically to all bound session channels)
143
+ this.startMirroring(bridge, cdp, projectName);
144
+ const embed = new discord_js_1.EmbedBuilder()
145
+ .setTitle((0, i18n_1.t)('🔗 Joined Session'))
146
+ .setDescription((0, i18n_1.t)(`Connected to: **${selectedTitle}**\n→ <#${newChannelId}>\n\n` +
147
+ `📡 Mirroring is **ON** — PC messages will appear in the new channel.\n` +
148
+ `Use \`/mirror\` to toggle.`))
149
+ .setColor(0x2ECC71)
150
+ .setTimestamp();
151
+ await interaction.editReply({ embeds: [embed], components: [] });
152
+ }
153
+ /**
154
+ * /mirror — Toggle mirroring ON/OFF for the current channel's workspace.
155
+ */
156
+ async handleMirror(interaction, bridge) {
157
+ const binding = this.bindingRepo.findByChannelId(interaction.channelId);
158
+ const session = this.chatSessionRepo.findByChannelId(interaction.channelId);
159
+ const projectName = binding?.workspacePath ?? session?.workspacePath;
160
+ if (!projectName) {
161
+ await interaction.editReply({
162
+ content: (0, i18n_1.t)('⚠️ No project is bound to this channel. Use `/project` first.'),
163
+ });
164
+ return;
165
+ }
166
+ const projectPath = this.resolveProjectPath(projectName);
167
+ const detector = this.pool.getUserMessageDetector(projectName);
168
+ if (detector?.isActive()) {
169
+ // Turn OFF — stop user message detector and any active response monitor
170
+ detector.stop();
171
+ const responseMonitor = this.activeResponseMonitors.get(projectName);
172
+ if (responseMonitor?.isActive()) {
173
+ await responseMonitor.stop();
174
+ this.activeResponseMonitors.delete(projectName);
175
+ }
176
+ const embed = new discord_js_1.EmbedBuilder()
177
+ .setTitle((0, i18n_1.t)('📡 Mirroring OFF'))
178
+ .setDescription((0, i18n_1.t)('PC-to-Discord message mirroring has been stopped.'))
179
+ .setColor(0x95A5A6)
180
+ .setTimestamp();
181
+ await interaction.editReply({ embeds: [embed] });
182
+ }
183
+ else {
184
+ // Turn ON
185
+ let cdp;
186
+ try {
187
+ cdp = await this.pool.getOrConnect(projectPath);
188
+ }
189
+ catch (e) {
190
+ await interaction.editReply({
191
+ content: (0, i18n_1.t)(`⚠️ Failed to connect to project: ${e.message}`),
192
+ });
193
+ return;
194
+ }
195
+ this.startMirroring(bridge, cdp, projectName);
196
+ const embed = new discord_js_1.EmbedBuilder()
197
+ .setTitle((0, i18n_1.t)('📡 Mirroring ON'))
198
+ .setDescription((0, i18n_1.t)('PC-to-Discord message mirroring is now active.\n' +
199
+ 'Messages typed in Antigravity will appear in the corresponding session channel.'))
200
+ .setColor(0x2ECC71)
201
+ .setTimestamp();
202
+ await interaction.editReply({ embeds: [embed] });
203
+ }
204
+ }
205
+ /**
206
+ * Start user message mirroring for a project.
207
+ *
208
+ * When a PC message is detected, the callback resolves the correct Discord
209
+ * channel via chatSessionRepo.findByDisplayName. Only explicitly joined
210
+ * sessions (with a displayName binding) receive mirrored messages.
211
+ */
212
+ startMirroring(bridge, cdp, projectName) {
213
+ // Force re-prime: stop existing detector so that ensureUserMessageDetector
214
+ // creates a fresh one. This prevents the detector from treating the
215
+ // new session's last message as a "new" user message after /join.
216
+ const existing = this.pool.getUserMessageDetector(projectName);
217
+ if (existing?.isActive()) {
218
+ existing.stop();
219
+ }
220
+ (0, cdpBridgeManager_1.ensureUserMessageDetector)(bridge, cdp, projectName, (info) => {
221
+ this.routeMirroredMessage(cdp, projectName, info)
222
+ .catch((err) => {
223
+ logger_1.logger.error('[Mirror] Error routing mirrored message:', err);
224
+ });
225
+ });
226
+ }
227
+ /**
228
+ * Route a mirrored PC message to the correct Discord channel and
229
+ * start a passive ResponseMonitor to capture the AI response.
230
+ *
231
+ * Routing: chatSessionRepo.findByDisplayName only — no fallbacks.
232
+ * Sessions without an explicit channel binding are silently skipped.
233
+ */
234
+ async routeMirroredMessage(cdp, projectName, info) {
235
+ const chatTitle = await (0, cdpBridgeManager_1.getCurrentChatTitle)(cdp);
236
+ if (!chatTitle) {
237
+ logger_1.logger.debug('[Mirror] No chat title detected, skipping');
238
+ return;
239
+ }
240
+ const session = this.chatSessionRepo.findByDisplayName(projectName, chatTitle);
241
+ if (!session) {
242
+ logger_1.logger.debug(`[Mirror] No bound channel for session "${chatTitle}", skipping`);
243
+ return;
244
+ }
245
+ const channel = this.client.channels.cache.get(session.channelId);
246
+ if (!channel || !('send' in channel))
247
+ return;
248
+ const sendable = channel;
249
+ // Mirror the user message
250
+ const userEmbed = new discord_js_1.EmbedBuilder()
251
+ .setDescription(`🖥️ ${info.text}`)
252
+ .setColor(0x95A5A6)
253
+ .setFooter({ text: `Typed in Antigravity · ${chatTitle}` })
254
+ .setTimestamp();
255
+ await sendable.send({ embeds: [userEmbed] }).catch((err) => {
256
+ logger_1.logger.error('[Mirror] Failed to send user message:', err);
257
+ });
258
+ // Start passive ResponseMonitor to capture the AI response
259
+ this.startResponseMirror(cdp, projectName, sendable, chatTitle);
260
+ }
261
+ /**
262
+ * Start a passive ResponseMonitor that sends the AI response to Discord
263
+ * when generation completes.
264
+ */
265
+ startResponseMirror(cdp, projectName, channel, chatTitle) {
266
+ // Stop previous monitor if still running
267
+ const prev = this.activeResponseMonitors.get(projectName);
268
+ if (prev?.isActive()) {
269
+ prev.stop().catch(() => { });
270
+ }
271
+ const monitor = new responseMonitor_1.ResponseMonitor({
272
+ cdpService: cdp,
273
+ pollIntervalMs: 2000,
274
+ maxDurationMs: 300000,
275
+ onComplete: (finalText) => {
276
+ this.activeResponseMonitors.delete(projectName);
277
+ if (!finalText || finalText.trim().length === 0)
278
+ return;
279
+ const text = finalText.length > MAX_EMBED_DESC
280
+ ? finalText.slice(0, MAX_EMBED_DESC) + '\n…(truncated)'
281
+ : finalText;
282
+ const embed = new discord_js_1.EmbedBuilder()
283
+ .setDescription(text)
284
+ .setColor(0x5865F2)
285
+ .setFooter({ text: `Antigravity response · ${chatTitle}` })
286
+ .setTimestamp();
287
+ channel.send({ embeds: [embed] }).catch((err) => {
288
+ logger_1.logger.error('[Mirror] Failed to send AI response:', err);
289
+ });
290
+ },
291
+ onTimeout: () => {
292
+ this.activeResponseMonitors.delete(projectName);
293
+ },
294
+ });
295
+ this.activeResponseMonitors.set(projectName, monitor);
296
+ monitor.startPassive().catch((err) => {
297
+ logger_1.logger.error('[Mirror] Failed to start response monitor:', err);
298
+ this.activeResponseMonitors.delete(projectName);
299
+ });
300
+ }
301
+ }
302
+ exports.JoinCommandHandler = JoinCommandHandler;
@@ -0,0 +1,285 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.JoinDetachCommandHandler = void 0;
4
+ const i18n_1 = require("../utils/i18n");
5
+ const discord_js_1 = require("discord.js");
6
+ const cdpBridgeManager_1 = require("../services/cdpBridgeManager");
7
+ const responseMonitor_1 = require("../services/responseMonitor");
8
+ const sessionPickerUi_1 = require("../ui/sessionPickerUi");
9
+ const logger_1 = require("../utils/logger");
10
+ /** Maximum embed description length (Discord limit is 4096) */
11
+ const MAX_EMBED_DESC = 4000;
12
+ /**
13
+ * Handler for /join and /mirror commands.
14
+ *
15
+ * /join — List Antigravity sessions and connect to one via a select menu.
16
+ * /mirror — Toggle PC-to-Discord message mirroring ON/OFF.
17
+ */
18
+ class JoinDetachCommandHandler {
19
+ chatSessionService;
20
+ chatSessionRepo;
21
+ bindingRepo;
22
+ channelManager;
23
+ pool;
24
+ client;
25
+ /** Active ResponseMonitors per workspace (for AI response mirroring) */
26
+ activeResponseMonitors = new Map();
27
+ constructor(chatSessionService, chatSessionRepo, bindingRepo, channelManager, pool, client) {
28
+ this.chatSessionService = chatSessionService;
29
+ this.chatSessionRepo = chatSessionRepo;
30
+ this.bindingRepo = bindingRepo;
31
+ this.channelManager = channelManager;
32
+ this.pool = pool;
33
+ this.client = client;
34
+ }
35
+ /**
36
+ * /join — Show session picker for the workspace bound to this channel.
37
+ */
38
+ async handleJoin(interaction, bridge) {
39
+ const binding = this.bindingRepo.findByChannelId(interaction.channelId);
40
+ const session = this.chatSessionRepo.findByChannelId(interaction.channelId);
41
+ const workspaceName = binding?.workspacePath ?? session?.workspacePath;
42
+ if (!workspaceName) {
43
+ await interaction.editReply({
44
+ content: (0, i18n_1.t)('⚠️ No project is bound to this channel. Use `/project` first.'),
45
+ });
46
+ return;
47
+ }
48
+ let cdp;
49
+ try {
50
+ cdp = await this.pool.getOrConnect(workspaceName);
51
+ }
52
+ catch (e) {
53
+ await interaction.editReply({
54
+ content: (0, i18n_1.t)(`⚠️ Failed to connect to project: ${e.message}`),
55
+ });
56
+ return;
57
+ }
58
+ const sessions = await this.chatSessionService.listAllSessions(cdp);
59
+ const { embeds, components } = (0, sessionPickerUi_1.buildSessionPickerUI)(sessions);
60
+ await interaction.editReply({ embeds, components });
61
+ }
62
+ /**
63
+ * Handle session selection from the /join picker.
64
+ *
65
+ * Flow:
66
+ * 1. Check if a channel already exists for this session (by displayName)
67
+ * 2. If yes → reply with a link to that channel
68
+ * 3. If no → create a new channel, bind it, activate session, start mirroring
69
+ */
70
+ async handleJoinSelect(interaction, bridge) {
71
+ const selectedTitle = interaction.values[0];
72
+ const guild = interaction.guild;
73
+ if (!guild) {
74
+ await interaction.editReply({ content: (0, i18n_1.t)('⚠️ This command can only be used in a server.') });
75
+ return;
76
+ }
77
+ const binding = this.bindingRepo.findByChannelId(interaction.channelId);
78
+ const session = this.chatSessionRepo.findByChannelId(interaction.channelId);
79
+ const workspaceName = binding?.workspacePath ?? session?.workspacePath;
80
+ if (!workspaceName) {
81
+ await interaction.editReply({ content: (0, i18n_1.t)('⚠️ No project is bound to this channel.') });
82
+ return;
83
+ }
84
+ // Step 1: Check if a channel already exists for this session
85
+ const existingSession = this.chatSessionRepo.findByDisplayName(workspaceName, selectedTitle);
86
+ if (existingSession) {
87
+ const embed = new discord_js_1.EmbedBuilder()
88
+ .setTitle((0, i18n_1.t)('🔗 Session Already Connected'))
89
+ .setDescription((0, i18n_1.t)(`This session already has a channel:\n→ <#${existingSession.channelId}>`))
90
+ .setColor(0x3498DB)
91
+ .setTimestamp();
92
+ await interaction.editReply({ embeds: [embed], components: [] });
93
+ return;
94
+ }
95
+ // Step 2: Connect to CDP
96
+ let cdp;
97
+ try {
98
+ cdp = await this.pool.getOrConnect(workspaceName);
99
+ }
100
+ catch (e) {
101
+ await interaction.editReply({ content: (0, i18n_1.t)(`⚠️ Failed to connect to project: ${e.message}`) });
102
+ return;
103
+ }
104
+ // Step 3: Activate the session in Antigravity
105
+ const activateResult = await this.chatSessionService.activateSessionByTitle(cdp, selectedTitle);
106
+ if (!activateResult.ok) {
107
+ await interaction.editReply({ content: (0, i18n_1.t)(`⚠️ Failed to join session: ${activateResult.error}`) });
108
+ return;
109
+ }
110
+ // Step 4: Create a new Discord channel for this session
111
+ const categoryResult = await this.channelManager.ensureCategory(guild, workspaceName);
112
+ const categoryId = categoryResult.categoryId;
113
+ const sessionNumber = this.chatSessionRepo.getNextSessionNumber(categoryId);
114
+ const channelName = this.channelManager.sanitizeChannelName(`${sessionNumber}-${selectedTitle}`);
115
+ const channelResult = await this.channelManager.createSessionChannel(guild, categoryId, channelName);
116
+ const newChannelId = channelResult.channelId;
117
+ // Step 5: Register binding and session
118
+ this.bindingRepo.upsert({
119
+ channelId: newChannelId,
120
+ workspacePath: workspaceName,
121
+ guildId: guild.id,
122
+ });
123
+ this.chatSessionRepo.create({
124
+ channelId: newChannelId,
125
+ categoryId,
126
+ workspacePath: workspaceName,
127
+ sessionNumber,
128
+ guildId: guild.id,
129
+ });
130
+ this.chatSessionRepo.updateDisplayName(newChannelId, selectedTitle);
131
+ // Step 6: Start mirroring (routes dynamically to all bound session channels)
132
+ this.startMirroring(bridge, cdp, workspaceName);
133
+ const embed = new discord_js_1.EmbedBuilder()
134
+ .setTitle((0, i18n_1.t)('🔗 Joined Session'))
135
+ .setDescription((0, i18n_1.t)(`Connected to: **${selectedTitle}**\n→ <#${newChannelId}>\n\n` +
136
+ `📡 Mirroring is **ON** — PC messages will appear in the new channel.\n` +
137
+ `Use \`/mirror\` to toggle.`))
138
+ .setColor(0x2ECC71)
139
+ .setTimestamp();
140
+ await interaction.editReply({ embeds: [embed], components: [] });
141
+ }
142
+ /**
143
+ * /mirror — Toggle mirroring ON/OFF for the current channel's workspace.
144
+ */
145
+ async handleMirror(interaction, bridge) {
146
+ const binding = this.bindingRepo.findByChannelId(interaction.channelId);
147
+ const session = this.chatSessionRepo.findByChannelId(interaction.channelId);
148
+ const workspaceName = binding?.workspacePath ?? session?.workspacePath;
149
+ const dirName = workspaceName ? this.pool.extractDirName(workspaceName) : null;
150
+ if (!dirName || !workspaceName) {
151
+ await interaction.editReply({
152
+ content: (0, i18n_1.t)('⚠️ No project is bound to this channel. Use `/project` first.'),
153
+ });
154
+ return;
155
+ }
156
+ const detector = this.pool.getUserMessageDetector(dirName);
157
+ if (detector?.isActive()) {
158
+ // Turn OFF — stop user message detector and any active response monitor
159
+ detector.stop();
160
+ const responseMonitor = this.activeResponseMonitors.get(dirName);
161
+ if (responseMonitor?.isActive()) {
162
+ await responseMonitor.stop();
163
+ this.activeResponseMonitors.delete(dirName);
164
+ }
165
+ const embed = new discord_js_1.EmbedBuilder()
166
+ .setTitle((0, i18n_1.t)('📡 Mirroring OFF'))
167
+ .setDescription((0, i18n_1.t)('PC-to-Discord message mirroring has been stopped.'))
168
+ .setColor(0x95A5A6)
169
+ .setTimestamp();
170
+ await interaction.editReply({ embeds: [embed] });
171
+ }
172
+ else {
173
+ // Turn ON
174
+ let cdp;
175
+ try {
176
+ cdp = await this.pool.getOrConnect(workspaceName);
177
+ }
178
+ catch (e) {
179
+ await interaction.editReply({
180
+ content: (0, i18n_1.t)(`⚠️ Failed to connect to project: ${e.message}`),
181
+ });
182
+ return;
183
+ }
184
+ this.startMirroring(bridge, cdp, workspaceName);
185
+ const embed = new discord_js_1.EmbedBuilder()
186
+ .setTitle((0, i18n_1.t)('📡 Mirroring ON'))
187
+ .setDescription((0, i18n_1.t)('PC-to-Discord message mirroring is now active.\n' +
188
+ 'Messages typed in Antigravity will appear in the corresponding session channel.'))
189
+ .setColor(0x2ECC71)
190
+ .setTimestamp();
191
+ await interaction.editReply({ embeds: [embed] });
192
+ }
193
+ }
194
+ /**
195
+ * Start user message mirroring for a workspace.
196
+ *
197
+ * When a PC message is detected, the callback resolves the correct Discord
198
+ * channel via chatSessionRepo.findByDisplayName. Only explicitly joined
199
+ * sessions (with a displayName binding) receive mirrored messages.
200
+ */
201
+ startMirroring(bridge, cdp, workspaceName) {
202
+ const dirName = this.pool.extractDirName(workspaceName);
203
+ (0, cdpBridgeManager_1.ensureUserMessageDetector)(bridge, cdp, dirName, (info) => {
204
+ this.routeMirroredMessage(cdp, dirName, workspaceName, info)
205
+ .catch((err) => {
206
+ logger_1.logger.error('[Mirror] Error routing mirrored message:', err);
207
+ });
208
+ });
209
+ }
210
+ /**
211
+ * Route a mirrored PC message to the correct Discord channel and
212
+ * start a passive ResponseMonitor to capture the AI response.
213
+ *
214
+ * Routing: chatSessionRepo.findByDisplayName only — no fallbacks.
215
+ * Sessions without an explicit channel binding are silently skipped.
216
+ */
217
+ async routeMirroredMessage(cdp, dirName, workspaceName, info) {
218
+ const chatTitle = await (0, cdpBridgeManager_1.getCurrentChatTitle)(cdp);
219
+ if (!chatTitle) {
220
+ logger_1.logger.debug('[Mirror] No chat title detected, skipping');
221
+ return;
222
+ }
223
+ const session = this.chatSessionRepo.findByDisplayName(workspaceName, chatTitle);
224
+ if (!session) {
225
+ logger_1.logger.debug(`[Mirror] No bound channel for session "${chatTitle}", skipping`);
226
+ return;
227
+ }
228
+ const channel = this.client.channels.cache.get(session.channelId);
229
+ if (!channel || !('send' in channel))
230
+ return;
231
+ const sendable = channel;
232
+ // Mirror the user message
233
+ const userEmbed = new discord_js_1.EmbedBuilder()
234
+ .setDescription(`🖥️ ${info.text}`)
235
+ .setColor(0x95A5A6)
236
+ .setFooter({ text: `Typed in Antigravity · ${chatTitle}` })
237
+ .setTimestamp();
238
+ await sendable.send({ embeds: [userEmbed] }).catch((err) => {
239
+ logger_1.logger.error('[Mirror] Failed to send user message:', err);
240
+ });
241
+ // Start passive ResponseMonitor to capture the AI response
242
+ this.startResponseMirror(cdp, dirName, sendable, chatTitle);
243
+ }
244
+ /**
245
+ * Start a passive ResponseMonitor that sends the AI response to Discord
246
+ * when generation completes.
247
+ */
248
+ startResponseMirror(cdp, dirName, channel, chatTitle) {
249
+ // Stop previous monitor if still running
250
+ const prev = this.activeResponseMonitors.get(dirName);
251
+ if (prev?.isActive()) {
252
+ prev.stop().catch(() => { });
253
+ }
254
+ const monitor = new responseMonitor_1.ResponseMonitor({
255
+ cdpService: cdp,
256
+ pollIntervalMs: 2000,
257
+ maxDurationMs: 300000,
258
+ onComplete: (finalText) => {
259
+ this.activeResponseMonitors.delete(dirName);
260
+ if (!finalText || finalText.trim().length === 0)
261
+ return;
262
+ const text = finalText.length > MAX_EMBED_DESC
263
+ ? finalText.slice(0, MAX_EMBED_DESC) + '\n…(truncated)'
264
+ : finalText;
265
+ const embed = new discord_js_1.EmbedBuilder()
266
+ .setDescription(text)
267
+ .setColor(0x5865F2)
268
+ .setFooter({ text: `Antigravity response · ${chatTitle}` })
269
+ .setTimestamp();
270
+ channel.send({ embeds: [embed] }).catch((err) => {
271
+ logger_1.logger.error('[Mirror] Failed to send AI response:', err);
272
+ });
273
+ },
274
+ onTimeout: () => {
275
+ this.activeResponseMonitors.delete(dirName);
276
+ },
277
+ });
278
+ this.activeResponseMonitors.set(dirName, monitor);
279
+ monitor.startPassive().catch((err) => {
280
+ logger_1.logger.error('[Mirror] Failed to start response monitor:', err);
281
+ this.activeResponseMonitors.delete(dirName);
282
+ });
283
+ }
284
+ }
285
+ exports.JoinDetachCommandHandler = JoinDetachCommandHandler;
@@ -102,6 +102,37 @@ const cleanupCommand = new discord_js_1.SlashCommandBuilder()
102
102
  const helpCommand = new discord_js_1.SlashCommandBuilder()
103
103
  .setName('help')
104
104
  .setDescription((0, i18n_1.t)('Display list of available commands'));
105
+ /** /join command definition */
106
+ const joinCommand = new discord_js_1.SlashCommandBuilder()
107
+ .setName('join')
108
+ .setDescription((0, i18n_1.t)('Join an existing Antigravity session (shows up to 20 recent sessions)'));
109
+ /** /mirror command definition */
110
+ const mirrorCommand = new discord_js_1.SlashCommandBuilder()
111
+ .setName('mirror')
112
+ .setDescription((0, i18n_1.t)('Toggle PC-to-Discord message mirroring for the current session'));
113
+ /** /output command definition */
114
+ const outputCommand = new discord_js_1.SlashCommandBuilder()
115
+ .setName('output')
116
+ .setDescription((0, i18n_1.t)('Toggle output format between Embed and Plain Text'))
117
+ .addStringOption((option) => option
118
+ .setName('format')
119
+ .setDescription((0, i18n_1.t)('embed / plain (optional direct switch)'))
120
+ .setRequired(false));
121
+ /** /logs command definition */
122
+ const logsCommand = new discord_js_1.SlashCommandBuilder()
123
+ .setName('logs')
124
+ .setDescription((0, i18n_1.t)('View recent bot logs'))
125
+ .addIntegerOption((option) => option
126
+ .setName('lines')
127
+ .setDescription((0, i18n_1.t)('Number of recent log lines (default: 50)'))
128
+ .setRequired(false)
129
+ .setMinValue(1)
130
+ .setMaxValue(100))
131
+ .addStringOption((option) => option
132
+ .setName('level')
133
+ .setDescription((0, i18n_1.t)('Filter by log level'))
134
+ .setRequired(false)
135
+ .addChoices({ name: 'debug', value: 'debug' }, { name: 'info', value: 'info' }, { name: 'warn', value: 'warn' }, { name: 'error', value: 'error' }));
105
136
  /** /ping command definition */
106
137
  const pingCommand = new discord_js_1.SlashCommandBuilder()
107
138
  .setName('ping')
@@ -120,7 +151,11 @@ exports.slashCommands = [
120
151
  newCommand,
121
152
  chatCommand,
122
153
  cleanupCommand,
154
+ joinCommand,
155
+ mirrorCommand,
156
+ outputCommand,
123
157
  pingCommand,
158
+ logsCommand,
124
159
  ];
125
160
  /**
126
161
  * Register slash commands with Discord
@@ -67,6 +67,16 @@ class ChatSessionRepository {
67
67
  const result = this.db.prepare('UPDATE chat_sessions SET display_name = ?, is_renamed = 1 WHERE channel_id = ?').run(displayName, channelId);
68
68
  return result.changes > 0;
69
69
  }
70
+ /**
71
+ * Find a session by display name within a workspace.
72
+ * Returns the first match (most recent).
73
+ */
74
+ findByDisplayName(workspacePath, displayName) {
75
+ const row = this.db.prepare('SELECT * FROM chat_sessions WHERE workspace_path = ? AND display_name = ? ORDER BY id DESC LIMIT 1').get(workspacePath, displayName);
76
+ if (!row)
77
+ return undefined;
78
+ return this.mapRow(row);
79
+ }
70
80
  deleteByChannelId(channelId) {
71
81
  const result = this.db.prepare('DELETE FROM chat_sessions WHERE channel_id = ?').run(channelId);
72
82
  return result.changes > 0;