@quantiya/codevibe-gemini-plugin 1.0.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 (66) hide show
  1. package/.env.example +28 -0
  2. package/README.md +117 -0
  3. package/bin/codevibe-gemini +238 -0
  4. package/dist/appsync-client.d.ts +66 -0
  5. package/dist/appsync-client.d.ts.map +1 -0
  6. package/dist/appsync-client.js +819 -0
  7. package/dist/appsync-client.js.map +1 -0
  8. package/dist/auth-cli.d.ts +18 -0
  9. package/dist/auth-cli.d.ts.map +1 -0
  10. package/dist/auth-cli.js +472 -0
  11. package/dist/auth-cli.js.map +1 -0
  12. package/dist/command-executor.d.ts +20 -0
  13. package/dist/command-executor.d.ts.map +1 -0
  14. package/dist/command-executor.js +127 -0
  15. package/dist/command-executor.js.map +1 -0
  16. package/dist/config.d.ts +25 -0
  17. package/dist/config.d.ts.map +1 -0
  18. package/dist/config.js +106 -0
  19. package/dist/config.js.map +1 -0
  20. package/dist/crypto-service.d.ts +115 -0
  21. package/dist/crypto-service.d.ts.map +1 -0
  22. package/dist/crypto-service.js +278 -0
  23. package/dist/crypto-service.js.map +1 -0
  24. package/dist/http-api.d.ts +63 -0
  25. package/dist/http-api.d.ts.map +1 -0
  26. package/dist/http-api.js +582 -0
  27. package/dist/http-api.js.map +1 -0
  28. package/dist/key-manager.d.ts +87 -0
  29. package/dist/key-manager.d.ts.map +1 -0
  30. package/dist/key-manager.js +287 -0
  31. package/dist/key-manager.js.map +1 -0
  32. package/dist/logger.d.ts +2 -0
  33. package/dist/logger.d.ts.map +1 -0
  34. package/dist/logger.js +18 -0
  35. package/dist/logger.js.map +1 -0
  36. package/dist/prompt-responder.d.ts +22 -0
  37. package/dist/prompt-responder.d.ts.map +1 -0
  38. package/dist/prompt-responder.js +132 -0
  39. package/dist/prompt-responder.js.map +1 -0
  40. package/dist/server.d.ts +2 -0
  41. package/dist/server.d.ts.map +1 -0
  42. package/dist/server.js +1422 -0
  43. package/dist/server.js.map +1 -0
  44. package/dist/token-storage.d.ts +39 -0
  45. package/dist/token-storage.d.ts.map +1 -0
  46. package/dist/token-storage.js +169 -0
  47. package/dist/token-storage.js.map +1 -0
  48. package/dist/transcript-watcher.d.ts +111 -0
  49. package/dist/transcript-watcher.d.ts.map +1 -0
  50. package/dist/transcript-watcher.js +324 -0
  51. package/dist/transcript-watcher.js.map +1 -0
  52. package/dist/types.d.ts +119 -0
  53. package/dist/types.d.ts.map +1 -0
  54. package/dist/types.js +16 -0
  55. package/dist/types.js.map +1 -0
  56. package/gemini-extension.json +84 -0
  57. package/hooks/after-agent.sh +122 -0
  58. package/hooks/after-tool.sh +71 -0
  59. package/hooks/before-agent.sh +46 -0
  60. package/hooks/before-tool.sh +17 -0
  61. package/hooks/common.sh +220 -0
  62. package/hooks/hooks.json +81 -0
  63. package/hooks/notification.sh +32 -0
  64. package/hooks/session-end.sh +70 -0
  65. package/hooks/session-start.sh +72 -0
  66. package/package.json +73 -0
package/dist/server.js ADDED
@@ -0,0 +1,1422 @@
1
+ "use strict";
2
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
3
+ if (k2 === undefined) k2 = k;
4
+ var desc = Object.getOwnPropertyDescriptor(m, k);
5
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
6
+ desc = { enumerable: true, get: function() { return m[k]; } };
7
+ }
8
+ Object.defineProperty(o, k2, desc);
9
+ }) : (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ o[k2] = m[k];
12
+ }));
13
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
14
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
15
+ }) : function(o, v) {
16
+ o["default"] = v;
17
+ });
18
+ var __importStar = (this && this.__importStar) || (function () {
19
+ var ownKeys = function(o) {
20
+ ownKeys = Object.getOwnPropertyNames || function (o) {
21
+ var ar = [];
22
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
23
+ return ar;
24
+ };
25
+ return ownKeys(o);
26
+ };
27
+ return function (mod) {
28
+ if (mod && mod.__esModule) return mod;
29
+ var result = {};
30
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
31
+ __setModuleDefault(result, mod);
32
+ return result;
33
+ };
34
+ })();
35
+ Object.defineProperty(exports, "__esModule", { value: true });
36
+ const fs = __importStar(require("fs"));
37
+ const path = __importStar(require("path"));
38
+ const os = __importStar(require("os"));
39
+ const crypto = __importStar(require("crypto"));
40
+ const logger_1 = require("./logger");
41
+ // Import shared modules from codevibe-core
42
+ const codevibe_core_1 = require("@quantiya/codevibe-core");
43
+ // Import plugin-specific modules
44
+ const http_api_1 = require("./http-api");
45
+ const command_executor_1 = require("./command-executor");
46
+ const prompt_responder_1 = require("./prompt-responder");
47
+ // Import plugin-specific types
48
+ const types_1 = require("./types");
49
+ class McpServer {
50
+ constructor(sessionId, workingDirectory) {
51
+ this.activeSessions = new Map();
52
+ this.assignedPort = 0;
53
+ // Track prompts sent from mobile to avoid duplicate USER_PROMPT events
54
+ // When mobile sends a prompt, it gets typed into terminal and appears in BeforeAgent hook
55
+ // We track with timestamp and expire after 10 seconds
56
+ this.pendingMobilePrompts = new Map();
57
+ // Map Gemini's session ID to our generated backend session ID
58
+ this.geminiToBackendSessionId = new Map();
59
+ // Track pending interactive prompts from BeforeTool hooks waiting for mobile response
60
+ this.pendingInteractivePrompts = new Map();
61
+ this.sessionKey = null; // E2E encryption session key
62
+ // The backend session ID from the most recent SessionStart or session switch.
63
+ // When Gemini CLI resumes a session via /resume, hooks fire with the ORIGINAL
64
+ // session ID. This tracks the current active session for switch detection.
65
+ this.currentBackendSessionId = null;
66
+ this.httpApi = new http_api_1.HttpApi();
67
+ // AppSyncClient is created in start() after config is loaded with correct environment
68
+ this.commandExecutor = new command_executor_1.CommandExecutor();
69
+ this.promptResponder = new prompt_responder_1.PromptResponder();
70
+ this.initialSessionId = sessionId;
71
+ this.workingDirectory = workingDirectory || process.cwd();
72
+ }
73
+ /**
74
+ * Get the port the server is listening on
75
+ */
76
+ getPort() {
77
+ return this.assignedPort;
78
+ }
79
+ /**
80
+ * Generate a deterministic backend session ID for a Gemini session.
81
+ * Format: gemini-<hash of geminiSessionId>
82
+ *
83
+ * Deterministic IDs enable session resume: the same Gemini session always maps
84
+ * to the same backend session, allowing getSession() to find previously created sessions.
85
+ */
86
+ generateBackendSessionId(geminiSessionId) {
87
+ const hash = crypto.createHash('sha256')
88
+ .update(geminiSessionId)
89
+ .digest('hex').substring(0, 16);
90
+ return `gemini-${hash}`;
91
+ }
92
+ /**
93
+ * Get the backend session ID for a Gemini session ID
94
+ * Creates a new mapping if one doesn't exist
95
+ */
96
+ getBackendSessionId(geminiSessionId) {
97
+ let backendSessionId = this.geminiToBackendSessionId.get(geminiSessionId);
98
+ if (!backendSessionId) {
99
+ backendSessionId = this.generateBackendSessionId(geminiSessionId);
100
+ this.geminiToBackendSessionId.set(geminiSessionId, backendSessionId);
101
+ logger_1.logger.info('Generated backend session ID', {
102
+ geminiSessionId,
103
+ backendSessionId,
104
+ });
105
+ }
106
+ return backendSessionId;
107
+ }
108
+ /**
109
+ * Track a mobile prompt to filter out the duplicate USER_PROMPT from desktop hook
110
+ */
111
+ trackMobilePrompt(sessionId, prompt) {
112
+ if (!this.pendingMobilePrompts.has(sessionId)) {
113
+ this.pendingMobilePrompts.set(sessionId, []);
114
+ }
115
+ this.pendingMobilePrompts.get(sessionId).push({
116
+ prompt: prompt.trim(),
117
+ timestamp: Date.now(),
118
+ });
119
+ logger_1.logger.debug('Tracking mobile prompt for deduplication', { sessionId, promptLength: prompt.length });
120
+ }
121
+ /**
122
+ * Check if a prompt was recently sent from mobile (within expiry window)
123
+ * Returns true and removes the entry if found
124
+ */
125
+ isRecentMobilePrompt(sessionId, prompt) {
126
+ const prompts = this.pendingMobilePrompts.get(sessionId);
127
+ if (!prompts)
128
+ return false;
129
+ const now = Date.now();
130
+ const trimmedPrompt = prompt.trim();
131
+ // Clean up expired entries and check for match
132
+ const validPrompts = [];
133
+ let found = false;
134
+ for (const entry of prompts) {
135
+ if (now - entry.timestamp > McpServer.MOBILE_PROMPT_EXPIRY_MS) {
136
+ // Expired, skip
137
+ continue;
138
+ }
139
+ if (!found && entry.prompt === trimmedPrompt) {
140
+ // Found match, mark as found but don't add to validPrompts (consume it)
141
+ found = true;
142
+ logger_1.logger.debug('Found matching mobile prompt, filtering duplicate', { sessionId });
143
+ continue;
144
+ }
145
+ validPrompts.push(entry);
146
+ }
147
+ // Update the list with remaining valid prompts
148
+ if (validPrompts.length > 0) {
149
+ this.pendingMobilePrompts.set(sessionId, validPrompts);
150
+ }
151
+ else {
152
+ this.pendingMobilePrompts.delete(sessionId);
153
+ }
154
+ return found;
155
+ }
156
+ /**
157
+ * Write port file for a session so hooks can discover the server port
158
+ */
159
+ writePortFile(sessionId) {
160
+ const portFilePath = path.join(os.tmpdir(), `codevibe-gemini-${sessionId}.port`);
161
+ try {
162
+ fs.writeFileSync(portFilePath, this.assignedPort.toString());
163
+ logger_1.logger.info(`Port file written: ${portFilePath} -> ${this.assignedPort}`);
164
+ }
165
+ catch (error) {
166
+ logger_1.logger.error(`Failed to write port file: ${portFilePath}`, error);
167
+ }
168
+ }
169
+ /**
170
+ * Remove port file for a session
171
+ */
172
+ removePortFile(sessionId) {
173
+ const portFilePath = path.join(os.tmpdir(), `codevibe-gemini-${sessionId}.port`);
174
+ try {
175
+ if (fs.existsSync(portFilePath)) {
176
+ fs.unlinkSync(portFilePath);
177
+ logger_1.logger.info(`Port file removed: ${portFilePath}`);
178
+ }
179
+ }
180
+ catch (error) {
181
+ logger_1.logger.warn(`Failed to remove port file: ${portFilePath}`, error);
182
+ }
183
+ }
184
+ async start() {
185
+ try {
186
+ logger_1.logger.info('Starting Gemini Companion MCP Server...', {
187
+ environment: (0, codevibe_core_1.getEnvironment)(),
188
+ });
189
+ // Create AppSyncClient (auto-configures from ENVIRONMENT env var)
190
+ this.appSyncClient = new codevibe_core_1.AppSyncClient();
191
+ // Authenticate using stored OAuth tokens (from 'codevibe-gemini login')
192
+ const storedTokensAuth = await this.appSyncClient.authenticateWithStoredTokens();
193
+ if (storedTokensAuth) {
194
+ logger_1.logger.info('Authenticated with stored OAuth tokens', {
195
+ userId: this.appSyncClient.getCurrentUserId(),
196
+ email: this.appSyncClient.getCurrentUserEmail(),
197
+ });
198
+ }
199
+ else {
200
+ logger_1.logger.error('Authentication failed. Run "codevibe-gemini login" first.');
201
+ console.error('Not authenticated. Run "codevibe-gemini login" to sign in.');
202
+ process.exit(1);
203
+ }
204
+ // Register event handlers
205
+ this.httpApi.onEvent(this.handleEventFromHook.bind(this));
206
+ // Register interactive prompt handlers for BeforeTool hooks
207
+ this.httpApi.onInteractivePrompt(this.handleInteractivePromptRequest.bind(this));
208
+ this.httpApi.onGetPromptResponse(this.getInteractivePromptResponse.bind(this));
209
+ // Start HTTP API with dynamic port allocation
210
+ // Pass session ID if provided at startup
211
+ this.assignedPort = await this.httpApi.start(this.initialSessionId);
212
+ logger_1.logger.info('MCP Server started successfully', {
213
+ port: this.assignedPort,
214
+ host: (0, codevibe_core_1.getConfig)().server.host,
215
+ dynamicPort: (0, codevibe_core_1.getConfig)().server.dynamicPort,
216
+ sessionId: this.initialSessionId,
217
+ authenticated: this.appSyncClient.isAuthenticated(),
218
+ userId: this.appSyncClient.getCurrentUserId(),
219
+ });
220
+ // Write default port file so hooks can find us before any session is created
221
+ const defaultPortFile = path.join(os.tmpdir(), 'codevibe-gemini-default.port');
222
+ fs.writeFileSync(defaultPortFile, this.assignedPort.toString());
223
+ logger_1.logger.info(`Default port file written: ${defaultPortFile} -> ${this.assignedPort}`);
224
+ }
225
+ catch (error) {
226
+ logger_1.logger.error('Failed to start MCP Server:', error);
227
+ throw error;
228
+ }
229
+ }
230
+ /**
231
+ * Create an event with optional E2E encryption
232
+ * Encrypts content and metadata if session key is available
233
+ */
234
+ async createEncryptedEvent(params) {
235
+ let eventContent = params.content;
236
+ let eventMetadata = params.metadata;
237
+ let isEncrypted = false;
238
+ // Encrypt event content if we have a session key
239
+ if (this.sessionKey) {
240
+ eventContent = codevibe_core_1.cryptoService.encryptContent(params.content, this.sessionKey);
241
+ if (eventMetadata) {
242
+ const encryptedMeta = codevibe_core_1.cryptoService.encryptMetadata(eventMetadata, this.sessionKey);
243
+ eventMetadata = { encrypted: encryptedMeta };
244
+ }
245
+ isEncrypted = true;
246
+ }
247
+ await this.appSyncClient.createEvent({
248
+ sessionId: params.sessionId,
249
+ type: params.type,
250
+ source: params.source,
251
+ content: eventContent,
252
+ metadata: eventMetadata,
253
+ promptId: params.promptId,
254
+ timestamp: params.timestamp,
255
+ isEncrypted,
256
+ });
257
+ }
258
+ async stop() {
259
+ logger_1.logger.info('Stopping MCP Server...');
260
+ // Mark all active sessions as INACTIVE before shutting down
261
+ const sessionIds = Array.from(this.activeSessions.keys());
262
+ logger_1.logger.info(`Marking ${sessionIds.length} active session(s) as INACTIVE...`);
263
+ for (const sessionId of sessionIds) {
264
+ try {
265
+ await this.appSyncClient.updateSession({
266
+ sessionId,
267
+ status: codevibe_core_1.SessionStatus.INACTIVE,
268
+ });
269
+ logger_1.logger.info('Session marked as INACTIVE during shutdown', { sessionId });
270
+ // Remove port file
271
+ this.removePortFile(sessionId);
272
+ }
273
+ catch (error) {
274
+ logger_1.logger.warn('Failed to mark session as INACTIVE during shutdown', { sessionId, error });
275
+ }
276
+ }
277
+ // Cleanup subscriptions
278
+ this.appSyncClient.cleanupSubscriptions();
279
+ // Clear active sessions
280
+ this.activeSessions.clear();
281
+ this.currentBackendSessionId = null;
282
+ // Remove default port file
283
+ const defaultPortFile = path.join(os.tmpdir(), 'codevibe-gemini-default.port');
284
+ try {
285
+ fs.unlinkSync(defaultPortFile);
286
+ }
287
+ catch { }
288
+ // Stop HTTP API
289
+ await this.httpApi.stop();
290
+ logger_1.logger.info('MCP Server stopped');
291
+ }
292
+ /**
293
+ * Handle events received from hook scripts via HTTP API
294
+ */
295
+ async handleEventFromHook(payload) {
296
+ const { session_id, hook_event_name, type } = payload;
297
+ let { content } = payload;
298
+ logger_1.logger.info('Processing hook event', {
299
+ sessionId: session_id,
300
+ hookEvent: hook_event_name,
301
+ type,
302
+ });
303
+ try {
304
+ // Handle session lifecycle events
305
+ if (hook_event_name === 'SessionStart') {
306
+ await this.handleSessionStart(payload);
307
+ }
308
+ else if (hook_event_name === 'SessionEnd') {
309
+ await this.handleSessionEnd(payload);
310
+ }
311
+ // Map Gemini session ID to backend session ID
312
+ // Session lifecycle events (SessionStart/SessionEnd) are handled separately and create the mapping
313
+ let backendSessionId = this.geminiToBackendSessionId.get(session_id);
314
+ if (!backendSessionId) {
315
+ // Try the session_id directly in case it's already a backend session ID
316
+ if (this.activeSessions.has(session_id)) {
317
+ backendSessionId = session_id;
318
+ }
319
+ else {
320
+ // Unknown session ID — likely from /resume within an active session.
321
+ // When Gemini CLI resumes via /resume, there's NO new SessionStart hook;
322
+ // hooks just start using the ORIGINAL session ID.
323
+ const generatedId = this.generateBackendSessionId(session_id);
324
+ if (this.activeSessions.has(generatedId)) {
325
+ // Already active (e.g. multiple hooks arrived), just cache the mapping
326
+ backendSessionId = generatedId;
327
+ this.geminiToBackendSessionId.set(session_id, generatedId);
328
+ logger_1.logger.info('Mapped unknown session ID to existing active session', {
329
+ geminiSessionId: session_id,
330
+ backendSessionId: generatedId,
331
+ });
332
+ }
333
+ else {
334
+ // Resumed session — switch to it
335
+ logger_1.logger.info('Detected resumed session from hook, switching backend session', {
336
+ geminiSessionId: session_id,
337
+ newBackendSessionId: generatedId,
338
+ previousBackendSessionId: this.currentBackendSessionId,
339
+ });
340
+ await this.switchToResumedSession(session_id, generatedId);
341
+ backendSessionId = generatedId;
342
+ }
343
+ }
344
+ }
345
+ // Skip USER_PROMPT events that originated from mobile
346
+ // When mobile sends a prompt via tmux, it triggers BeforeAgent hook
347
+ // This creates a duplicate since mobile already created the event
348
+ if (type === codevibe_core_1.EventType.USER_PROMPT &&
349
+ payload.source === codevibe_core_1.EventSource.DESKTOP &&
350
+ (hook_event_name === 'UserPromptSubmit' || hook_event_name === 'BeforeAgent') &&
351
+ content &&
352
+ this.isRecentMobilePrompt(backendSessionId, content)) {
353
+ logger_1.logger.info('Skipping duplicate USER_PROMPT from mobile-originated prompt', {
354
+ sessionId: backendSessionId,
355
+ contentLength: content.length,
356
+ });
357
+ return; // Don't send to AppSync
358
+ }
359
+ // For INTERACTIVE_PROMPT from Notification hook:
360
+ // The Notification hook script blocks Gemini CLI — the prompt is NOT rendered
361
+ // until the hook returns. So we must return immediately and defer tmux capture.
362
+ // After the hook returns (and Gemini renders the prompt), we capture tmux
363
+ // to parse the actual options dynamically.
364
+ if (type === codevibe_core_1.EventType.INTERACTIVE_PROMPT && hook_event_name === 'Notification') {
365
+ // Track interactive prompt state immediately (before async work)
366
+ const sessionState = this.activeSessions.get(backendSessionId);
367
+ if (sessionState) {
368
+ sessionState.waitingForPromptResponse = true;
369
+ sessionState.pendingPromptId = payload.prompt_id;
370
+ }
371
+ // Fire async — don't await, so handleEventFromHook returns immediately,
372
+ // the HTTP response goes back to the hook script, and Gemini CLI renders the prompt.
373
+ this.sendInteractivePromptAsync(backendSessionId, payload, content).catch(e => {
374
+ logger_1.logger.error('Failed to send interactive prompt with dynamic options', { error: e });
375
+ });
376
+ return;
377
+ }
378
+ // Send event to AppSync (with encryption if available)
379
+ // Default source to DESKTOP since all hook events originate from desktop
380
+ await this.createEncryptedEvent({
381
+ sessionId: backendSessionId,
382
+ type,
383
+ source: payload.source || codevibe_core_1.EventSource.DESKTOP,
384
+ content,
385
+ metadata: payload.metadata,
386
+ promptId: payload.prompt_id,
387
+ });
388
+ // Track interactive prompts (for non-Notification paths like BeforeTool)
389
+ if (type === codevibe_core_1.EventType.INTERACTIVE_PROMPT) {
390
+ const sessionState = this.activeSessions.get(backendSessionId);
391
+ if (sessionState) {
392
+ sessionState.waitingForPromptResponse = true;
393
+ sessionState.pendingPromptId = payload.prompt_id;
394
+ if (payload.metadata?.submitMap) {
395
+ sessionState.pendingSubmitMap = payload.metadata.submitMap;
396
+ }
397
+ logger_1.logger.info('Interactive prompt detected - waiting for response', {
398
+ sessionId: backendSessionId,
399
+ promptId: payload.prompt_id,
400
+ });
401
+ }
402
+ }
403
+ // Clear waiting state when user sends new prompt from desktop
404
+ // (means they answered the prompt locally or moved on)
405
+ if (type === codevibe_core_1.EventType.USER_PROMPT && payload.source === codevibe_core_1.EventSource.DESKTOP) {
406
+ const sessionState = this.activeSessions.get(backendSessionId);
407
+ if (sessionState?.waitingForPromptResponse) {
408
+ sessionState.waitingForPromptResponse = false;
409
+ sessionState.pendingPromptId = undefined;
410
+ logger_1.logger.info('Clearing prompt wait state - new desktop prompt received', {
411
+ sessionId: backendSessionId,
412
+ });
413
+ }
414
+ }
415
+ logger_1.logger.info('Event sent to AppSync successfully', { sessionId: backendSessionId, type, hookEvent: hook_event_name });
416
+ }
417
+ catch (error) {
418
+ logger_1.logger.error('Failed to process hook event:', error);
419
+ throw error;
420
+ }
421
+ }
422
+ /**
423
+ * Handle SessionStart hook event
424
+ *
425
+ * Supports both new sessions and resumed sessions via centralized
426
+ * resumeOrCreateSession() from codevibe-core.
427
+ */
428
+ async handleSessionStart(payload) {
429
+ const geminiSessionId = payload.session_id;
430
+ const backendSessionId = this.getBackendSessionId(geminiSessionId);
431
+ const cwd = payload.metadata?.cwd || process.cwd();
432
+ logger_1.logger.info('Session started', {
433
+ backendSessionId,
434
+ geminiSessionId,
435
+ cwd,
436
+ source: payload.metadata?.source,
437
+ });
438
+ // Mark any previous sessions as INACTIVE
439
+ const previousSessionIds = Array.from(this.activeSessions.keys()).filter(id => id !== backendSessionId);
440
+ if (previousSessionIds.length > 0) {
441
+ logger_1.logger.info(`Marking ${previousSessionIds.length} previous session(s) as INACTIVE`);
442
+ for (const prevId of previousSessionIds) {
443
+ try {
444
+ await this.appSyncClient.updateSession({
445
+ sessionId: prevId,
446
+ status: codevibe_core_1.SessionStatus.INACTIVE,
447
+ });
448
+ logger_1.logger.info('Previous session marked INACTIVE', { prevId, newSessionId: backendSessionId });
449
+ }
450
+ catch (error) {
451
+ logger_1.logger.warn('Failed to mark previous session as INACTIVE', { prevId, error });
452
+ }
453
+ this.removePortFile(prevId);
454
+ this.activeSessions.delete(prevId);
455
+ }
456
+ }
457
+ // Write port files for both backend and Gemini session IDs
458
+ this.writePortFile(backendSessionId);
459
+ this.writePortFile(geminiSessionId);
460
+ const userId = this.appSyncClient.getCurrentUserId();
461
+ // Create session state
462
+ const sessionState = {
463
+ sessionId: backendSessionId,
464
+ userId,
465
+ projectPath: cwd,
466
+ cwd,
467
+ createdAt: new Date(),
468
+ subscriptionActive: false,
469
+ waitingForPromptResponse: false,
470
+ metadata: payload.metadata || {},
471
+ };
472
+ this.activeSessions.set(backendSessionId, sessionState);
473
+ // Resume or create session via centralized utility
474
+ try {
475
+ const result = await (0, codevibe_core_1.resumeOrCreateSession)({
476
+ sessionId: backendSessionId,
477
+ userId,
478
+ agentType: codevibe_core_1.AgentType.GEMINI,
479
+ projectPath: cwd,
480
+ metadata: payload.metadata || {},
481
+ }, this.appSyncClient, logger_1.logger);
482
+ this.sessionKey = result.sessionKey;
483
+ }
484
+ catch (error) {
485
+ logger_1.logger.error('Failed to create/resume session:', error);
486
+ if (this.isSessionLimitExceeded(error)) {
487
+ this.displaySubscriptionLimitError(error, 'session');
488
+ this.activeSessions.delete(backendSessionId);
489
+ this.removePortFile(backendSessionId);
490
+ return;
491
+ }
492
+ logger_1.logger.warn('Session creation failed but continuing...', { error: error.message });
493
+ }
494
+ this.currentBackendSessionId = backendSessionId;
495
+ this.subscribeToMobileEvents(backendSessionId);
496
+ this.appSyncClient.startHeartbeat(backendSessionId);
497
+ }
498
+ /**
499
+ * Handle session switch when hooks reveal a resumed session (via /resume).
500
+ * Looks up the existing backend session, reactivates it, and marks previous session INACTIVE.
501
+ */
502
+ async switchToResumedSession(geminiSessionId, backendSessionId) {
503
+ // Cache the mapping
504
+ this.geminiToBackendSessionId.set(geminiSessionId, backendSessionId);
505
+ const userId = this.appSyncClient.getCurrentUserId();
506
+ // Resume or create session via centralized utility
507
+ try {
508
+ const result = await (0, codevibe_core_1.resumeOrCreateSession)({
509
+ sessionId: backendSessionId,
510
+ userId,
511
+ agentType: codevibe_core_1.AgentType.GEMINI,
512
+ projectPath: this.workingDirectory,
513
+ metadata: {},
514
+ }, this.appSyncClient, logger_1.logger);
515
+ this.sessionKey = result.sessionKey;
516
+ logger_1.logger.info('Resumed session via switchToResumedSession', {
517
+ backendSessionId,
518
+ resumed: result.resumed,
519
+ hasSessionKey: !!result.sessionKey,
520
+ });
521
+ }
522
+ catch (error) {
523
+ logger_1.logger.error('Failed to resume/create session during switch:', error);
524
+ if (this.isSessionLimitExceeded(error)) {
525
+ this.displaySubscriptionLimitError(error, 'session');
526
+ return;
527
+ }
528
+ }
529
+ // Create session state
530
+ const sessionState = {
531
+ sessionId: backendSessionId,
532
+ userId,
533
+ projectPath: this.workingDirectory,
534
+ cwd: this.workingDirectory,
535
+ createdAt: new Date(),
536
+ subscriptionActive: false,
537
+ waitingForPromptResponse: false,
538
+ metadata: {},
539
+ };
540
+ this.activeSessions.set(backendSessionId, sessionState);
541
+ this.writePortFile(backendSessionId);
542
+ this.writePortFile(geminiSessionId);
543
+ // Mark previous session as INACTIVE
544
+ if (this.currentBackendSessionId && this.currentBackendSessionId !== backendSessionId) {
545
+ try {
546
+ await this.appSyncClient.updateSession({
547
+ sessionId: this.currentBackendSessionId,
548
+ status: codevibe_core_1.SessionStatus.INACTIVE,
549
+ });
550
+ logger_1.logger.info('Previous session marked INACTIVE during resume switch', {
551
+ previousSessionId: this.currentBackendSessionId,
552
+ newSessionId: backendSessionId,
553
+ });
554
+ }
555
+ catch (error) {
556
+ logger_1.logger.warn('Failed to mark previous session INACTIVE', { error });
557
+ }
558
+ this.appSyncClient.stopHeartbeat(this.currentBackendSessionId);
559
+ this.removePortFile(this.currentBackendSessionId);
560
+ this.activeSessions.delete(this.currentBackendSessionId);
561
+ }
562
+ this.currentBackendSessionId = backendSessionId;
563
+ this.subscribeToMobileEvents(backendSessionId);
564
+ this.appSyncClient.startHeartbeat(backendSessionId);
565
+ }
566
+ /**
567
+ * Handle SessionEnd hook event
568
+ */
569
+ async handleSessionEnd(payload) {
570
+ const geminiSessionId = payload.session_id;
571
+ // Look up the backend session ID; try generated ID as fallback for resumed sessions
572
+ let sessionId = this.geminiToBackendSessionId.get(geminiSessionId);
573
+ if (!sessionId) {
574
+ const generated = this.generateBackendSessionId(geminiSessionId);
575
+ if (this.activeSessions.has(generated)) {
576
+ sessionId = generated;
577
+ }
578
+ else {
579
+ sessionId = geminiSessionId; // last resort fallback
580
+ }
581
+ }
582
+ logger_1.logger.info('Session ended', {
583
+ sessionId,
584
+ geminiSessionId,
585
+ reason: payload.metadata?.reason,
586
+ });
587
+ // Stop heartbeat
588
+ this.appSyncClient.stopHeartbeat(sessionId);
589
+ // Remove port files for both session IDs
590
+ this.removePortFile(sessionId);
591
+ this.removePortFile(geminiSessionId);
592
+ // Clear any waiting prompt state before ending session
593
+ const sessionState = this.activeSessions.get(sessionId);
594
+ if (sessionState?.waitingForPromptResponse) {
595
+ logger_1.logger.info('Clearing prompt wait state - session ending', { sessionId });
596
+ sessionState.waitingForPromptResponse = false;
597
+ sessionState.pendingPromptId = undefined;
598
+ }
599
+ // Update session status in AppSync
600
+ if (sessionState) {
601
+ try {
602
+ await this.appSyncClient.updateSession({
603
+ sessionId,
604
+ status: codevibe_core_1.SessionStatus.INACTIVE,
605
+ });
606
+ logger_1.logger.info('Session marked as INACTIVE in AppSync', { sessionId });
607
+ }
608
+ catch (error) {
609
+ logger_1.logger.warn('Failed to update session in AppSync:', error);
610
+ }
611
+ }
612
+ else {
613
+ logger_1.logger.warn('Cannot update session - session state not found', { sessionId });
614
+ }
615
+ // Remove from active sessions
616
+ this.activeSessions.delete(sessionId);
617
+ logger_1.logger.debug('Session cleanup completed', { sessionId });
618
+ }
619
+ /**
620
+ * Handle interactive prompt request from BeforeTool hook
621
+ * Creates an INTERACTIVE_PROMPT event and waits for mobile response
622
+ */
623
+ async handleInteractivePromptRequest(request) {
624
+ const promptId = `prompt-${Date.now()}-${Math.random().toString(36).substring(2, 9)}`;
625
+ logger_1.logger.info('Handling interactive prompt request', {
626
+ promptId,
627
+ sessionId: request.sessionId,
628
+ toolName: request.toolName,
629
+ });
630
+ // Get the backend session ID (the request may use Gemini session ID)
631
+ const backendSessionId = this.geminiToBackendSessionId.get(request.sessionId) || request.sessionId;
632
+ // Map tool name to iOS-expected display name
633
+ const mappedToolName = this.mapGeminiToolName(request.toolName);
634
+ // Build the prompt content
635
+ const toolDescription = this.buildToolDescription(mappedToolName, request.toolInput);
636
+ const promptContent = `Gemini wants to use ${mappedToolName}:\n${toolDescription}`;
637
+ // Build metadata with tool info and options
638
+ // Match Claude plugin format exactly for iOS compatibility
639
+ const metadata = {
640
+ tool_name: mappedToolName,
641
+ tool_input: request.toolInput,
642
+ options: types_1.DEFAULT_TOOL_APPROVAL_OPTIONS,
643
+ instructions: 'Select an option',
644
+ };
645
+ // Send INTERACTIVE_PROMPT event to iOS
646
+ try {
647
+ await this.createEncryptedEvent({
648
+ sessionId: backendSessionId,
649
+ type: codevibe_core_1.EventType.INTERACTIVE_PROMPT,
650
+ source: codevibe_core_1.EventSource.DESKTOP,
651
+ content: promptContent,
652
+ metadata,
653
+ promptId,
654
+ });
655
+ logger_1.logger.info('Interactive prompt event sent to iOS', {
656
+ promptId,
657
+ backendSessionId,
658
+ toolName: request.toolName,
659
+ });
660
+ // Track the pending prompt
661
+ const pendingPrompt = {
662
+ promptId,
663
+ sessionId: backendSessionId,
664
+ toolName: request.toolName,
665
+ toolInput: request.toolInput,
666
+ createdAt: new Date(),
667
+ resolve: () => { }, // Will be set by the Promise
668
+ reject: () => { }, // Will be set by the Promise
669
+ };
670
+ this.pendingInteractivePrompts.set(promptId, pendingPrompt);
671
+ // Update session state to track waiting for response
672
+ const sessionState = this.activeSessions.get(backendSessionId);
673
+ if (sessionState) {
674
+ sessionState.waitingForPromptResponse = true;
675
+ sessionState.pendingPromptId = promptId;
676
+ }
677
+ // Set timeout to auto-reject after 5 minutes
678
+ pendingPrompt.timeoutId = setTimeout(() => {
679
+ const prompt = this.pendingInteractivePrompts.get(promptId);
680
+ if (prompt) {
681
+ logger_1.logger.warn('Interactive prompt timed out', { promptId });
682
+ this.pendingInteractivePrompts.delete(promptId);
683
+ // Clear session state
684
+ const state = this.activeSessions.get(backendSessionId);
685
+ if (state?.pendingPromptId === promptId) {
686
+ state.waitingForPromptResponse = false;
687
+ state.pendingPromptId = undefined;
688
+ }
689
+ }
690
+ }, McpServer.INTERACTIVE_PROMPT_TIMEOUT_MS);
691
+ return {
692
+ promptId,
693
+ status: 'pending',
694
+ };
695
+ }
696
+ catch (error) {
697
+ logger_1.logger.error('Failed to send interactive prompt event:', error);
698
+ throw error;
699
+ }
700
+ }
701
+ /**
702
+ * Send INTERACTIVE_PROMPT event asynchronously after capturing tmux options.
703
+ * Called from handleEventFromHook for Notification/ToolPermission events.
704
+ * The 500ms delay allows the hook script to return so Gemini CLI renders the prompt.
705
+ */
706
+ async sendInteractivePromptAsync(backendSessionId, payload, content) {
707
+ // Wait for hook script to return and Gemini CLI to render the prompt
708
+ await new Promise(resolve => setTimeout(resolve, 500));
709
+ // Try to capture dynamic options from tmux
710
+ const tmuxSession = process.env.CODEVIBE_GEMINI_TMUX_SESSION;
711
+ if (tmuxSession) {
712
+ try {
713
+ const { exec } = await Promise.resolve().then(() => __importStar(require('child_process')));
714
+ const execAsync = (cmd) => new Promise((resolve, reject) => {
715
+ exec(cmd, { timeout: 5000 }, (err, stdout, stderr) => {
716
+ if (err)
717
+ reject(err);
718
+ else
719
+ resolve({ stdout, stderr });
720
+ });
721
+ });
722
+ const { stdout } = await execAsync(`tmux capture-pane -p -e -S -30 -t '${tmuxSession}'`);
723
+ const parsed = (0, codevibe_core_1.parseInteractivePrompt)(stdout);
724
+ if (parsed && parsed.options.length > 0) {
725
+ payload.metadata = payload.metadata || {};
726
+ payload.metadata.options = parsed.options;
727
+ payload.metadata.submitMap = parsed.submitMap;
728
+ logger_1.logger.info('Parsed dynamic options from tmux', {
729
+ optionCount: parsed.options.length,
730
+ kind: parsed.kind,
731
+ options: parsed.options,
732
+ });
733
+ }
734
+ else {
735
+ logger_1.logger.debug('No dynamic options parsed from tmux, using defaults from hook data');
736
+ }
737
+ }
738
+ catch (e) {
739
+ logger_1.logger.warn('Failed to capture tmux pane for options', { error: e });
740
+ }
741
+ }
742
+ // Send event to AppSync
743
+ await this.createEncryptedEvent({
744
+ sessionId: backendSessionId,
745
+ type: codevibe_core_1.EventType.INTERACTIVE_PROMPT,
746
+ source: payload.source || codevibe_core_1.EventSource.DESKTOP,
747
+ content,
748
+ metadata: payload.metadata,
749
+ promptId: payload.prompt_id,
750
+ });
751
+ // Update session state with submitMap
752
+ const sessionState = this.activeSessions.get(backendSessionId);
753
+ if (sessionState && payload.metadata?.submitMap) {
754
+ sessionState.pendingSubmitMap = payload.metadata.submitMap;
755
+ }
756
+ logger_1.logger.info('Interactive prompt sent to AppSync with dynamic options', {
757
+ sessionId: backendSessionId,
758
+ promptId: payload.prompt_id,
759
+ });
760
+ }
761
+ /**
762
+ * Map Gemini CLI tool type names to iOS-expected display names
763
+ */
764
+ mapGeminiToolName(name) {
765
+ switch (name.toLowerCase()) {
766
+ case 'exec':
767
+ case 'shell':
768
+ return 'Bash';
769
+ case 'edit':
770
+ case 'edit_file':
771
+ return 'Edit';
772
+ case 'write':
773
+ case 'write_file':
774
+ return 'Write';
775
+ case 'read':
776
+ case 'read_file':
777
+ return 'Read';
778
+ default:
779
+ return name;
780
+ }
781
+ }
782
+ /**
783
+ * Build a human-readable description of the tool operation
784
+ */
785
+ buildToolDescription(toolName, toolInput) {
786
+ switch (toolName.toLowerCase()) {
787
+ case 'edit':
788
+ case 'write':
789
+ if (toolInput.file_path) {
790
+ return `File: ${toolInput.file_path}`;
791
+ }
792
+ return JSON.stringify(toolInput, null, 2);
793
+ case 'shell':
794
+ case 'bash':
795
+ if (toolInput.command) {
796
+ return `Command: ${toolInput.command}`;
797
+ }
798
+ return JSON.stringify(toolInput, null, 2);
799
+ case 'read':
800
+ if (toolInput.file_path) {
801
+ return `Reading: ${toolInput.file_path}`;
802
+ }
803
+ return JSON.stringify(toolInput, null, 2);
804
+ default:
805
+ return JSON.stringify(toolInput, null, 2);
806
+ }
807
+ }
808
+ /**
809
+ * Get the response for a pending interactive prompt
810
+ * Called by hook script polling for mobile response
811
+ */
812
+ getInteractivePromptResponse(promptId) {
813
+ const pendingPrompt = this.pendingInteractivePrompts.get(promptId);
814
+ if (!pendingPrompt) {
815
+ logger_1.logger.debug('No pending prompt found', { promptId });
816
+ return null;
817
+ }
818
+ // Check if we have a decision stored
819
+ const sessionState = this.activeSessions.get(pendingPrompt.sessionId);
820
+ // If no response yet, return pending status
821
+ if (sessionState?.waitingForPromptResponse) {
822
+ return {
823
+ promptId,
824
+ decision: 'pending',
825
+ };
826
+ }
827
+ // If waitingForPromptResponse is false, the prompt was answered
828
+ // Check the stored decision based on session state
829
+ const prompt = this.pendingInteractivePrompts.get(promptId);
830
+ if (prompt) {
831
+ // The decision was handled in subscribeToMobileEvents
832
+ // Clean up and return the stored decision
833
+ const decision = prompt.decision || 'ask';
834
+ const reason = prompt.reason;
835
+ // Clear the pending prompt
836
+ if (prompt.timeoutId) {
837
+ clearTimeout(prompt.timeoutId);
838
+ }
839
+ this.pendingInteractivePrompts.delete(promptId);
840
+ logger_1.logger.info('Returning interactive prompt decision', { promptId, decision, reason });
841
+ return {
842
+ promptId,
843
+ decision,
844
+ reason,
845
+ };
846
+ }
847
+ return null;
848
+ }
849
+ /**
850
+ * Resolve a pending interactive prompt with a decision from mobile
851
+ * Called when mobile user responds to the prompt
852
+ */
853
+ resolveInteractivePrompt(promptId, decision, reason) {
854
+ const pendingPrompt = this.pendingInteractivePrompts.get(promptId);
855
+ if (!pendingPrompt) {
856
+ logger_1.logger.warn('Cannot resolve - prompt not found', { promptId });
857
+ return;
858
+ }
859
+ logger_1.logger.info('Resolving interactive prompt', {
860
+ promptId,
861
+ decision,
862
+ reason,
863
+ });
864
+ // Store the decision on the pending prompt for getInteractivePromptResponse to read
865
+ pendingPrompt.decision = decision;
866
+ pendingPrompt.reason = reason;
867
+ // Clear the waiting state
868
+ const sessionState = this.activeSessions.get(pendingPrompt.sessionId);
869
+ if (sessionState) {
870
+ sessionState.waitingForPromptResponse = false;
871
+ sessionState.pendingPromptId = undefined;
872
+ }
873
+ }
874
+ /**
875
+ * Subscribe to mobile events for a session
876
+ */
877
+ subscribeToMobileEvents(sessionId) {
878
+ logger_1.logger.info('Subscribing to mobile events', { sessionId });
879
+ const sessionState = this.activeSessions.get(sessionId);
880
+ if (!sessionState) {
881
+ logger_1.logger.error('Session not found', { sessionId });
882
+ return;
883
+ }
884
+ this.appSyncClient.subscribeToEvents(sessionId, async (event) => {
885
+ logger_1.logger.info('Received mobile event', {
886
+ eventId: event.eventId,
887
+ type: event.type,
888
+ sessionId: event.sessionId,
889
+ isEncrypted: event.isEncrypted,
890
+ });
891
+ // Decrypt event content if encrypted
892
+ let decryptedContent = event.content || '';
893
+ if (event.isEncrypted && this.sessionKey) {
894
+ try {
895
+ decryptedContent = codevibe_core_1.cryptoService.decryptContent(event.content, this.sessionKey);
896
+ logger_1.logger.debug('Event decrypted successfully', { eventId: event.eventId });
897
+ }
898
+ catch (error) {
899
+ logger_1.logger.error('Failed to decrypt event:', { eventId: event.eventId, error });
900
+ // Fall back to original content (might not work, but better than nothing)
901
+ decryptedContent = event.content;
902
+ }
903
+ }
904
+ // Create a modified event with decrypted content for processing
905
+ const processedEvent = { ...event, content: decryptedContent };
906
+ // Mark event as DELIVERED (MCP server received it) - double gray checkmark
907
+ try {
908
+ await this.appSyncClient.updateEventStatus({
909
+ eventId: event.eventId,
910
+ sessionId: event.sessionId,
911
+ timestamp: event.timestamp,
912
+ deliveryStatus: codevibe_core_1.DeliveryStatus.DELIVERED,
913
+ });
914
+ logger_1.logger.info('Event marked as DELIVERED', { eventId: event.eventId });
915
+ }
916
+ catch (error) {
917
+ logger_1.logger.warn('Failed to mark event as DELIVERED', { eventId: event.eventId, error });
918
+ }
919
+ // Handle different event types from mobile
920
+ if (event.type === codevibe_core_1.EventType.USER_PROMPT) {
921
+ const sessionState = this.activeSessions.get(sessionId);
922
+ // Check if session is waiting for prompt response
923
+ if (sessionState?.waitingForPromptResponse) {
924
+ // Parse the input to determine action
925
+ const content = decryptedContent.trim();
926
+ const parsed = this.parseInteractivePromptInput(content);
927
+ logger_1.logger.info('Parsed interactive prompt input', {
928
+ sessionId,
929
+ content,
930
+ parsed,
931
+ });
932
+ if (parsed.action === 'select_option') {
933
+ // User selected option - translate via submitMap if available
934
+ const submitMap = sessionState.pendingSubmitMap;
935
+ const terminalInput = submitMap?.[parsed.option] || parsed.option;
936
+ logger_1.logger.info('User selected option', {
937
+ option: parsed.option,
938
+ terminalInput,
939
+ hasSubmitMap: !!submitMap,
940
+ });
941
+ // Resolve the pending interactive prompt for BeforeTool hook polling
942
+ if (sessionState.pendingPromptId) {
943
+ const decision = parsed.option === '1' || parsed.option === '2' ? 'allow' : 'deny';
944
+ const reason = parsed.option === '2' ? 'allowed_for_session' : undefined;
945
+ this.resolveInteractivePrompt(sessionState.pendingPromptId, decision, reason);
946
+ }
947
+ const success = await this.promptResponder.answerInteractivePrompt(sessionId, terminalInput);
948
+ if (success) {
949
+ await this.markEventExecuted(event);
950
+ sessionState.waitingForPromptResponse = false;
951
+ sessionState.pendingPromptId = undefined;
952
+ delete sessionState.pendingSubmitMap;
953
+ await this.createEncryptedEvent({
954
+ sessionId,
955
+ type: codevibe_core_1.EventType.NOTIFICATION,
956
+ source: codevibe_core_1.EventSource.DESKTOP,
957
+ content: `Selected option ${parsed.option}`,
958
+ metadata: { promptAnswered: true },
959
+ });
960
+ }
961
+ else {
962
+ await this.sendPromptError(sessionId, 'Failed to select option');
963
+ }
964
+ }
965
+ else if (parsed.action === 'reject_and_prompt') {
966
+ // User typed "3" or "3 <text>" - reject prompt and send new prompt
967
+ logger_1.logger.info('User rejecting prompt and sending new prompt', {
968
+ newPrompt: parsed.newPrompt,
969
+ });
970
+ // Resolve the pending interactive prompt for BeforeTool hook polling
971
+ if (sessionState.pendingPromptId) {
972
+ this.resolveInteractivePrompt(sessionState.pendingPromptId, 'deny');
973
+ }
974
+ // First, reject the interactive prompt by sending "3" (the reject option)
975
+ const rejectSuccess = await this.promptResponder.answerInteractivePrompt(sessionId, '3');
976
+ // Clear waiting state
977
+ sessionState.waitingForPromptResponse = false;
978
+ sessionState.pendingPromptId = undefined;
979
+ delete sessionState.pendingSubmitMap;
980
+ if (rejectSuccess) {
981
+ await this.createEncryptedEvent({
982
+ sessionId,
983
+ type: codevibe_core_1.EventType.NOTIFICATION,
984
+ source: codevibe_core_1.EventSource.DESKTOP,
985
+ content: 'Interactive prompt rejected',
986
+ metadata: { promptRejected: true },
987
+ });
988
+ // If there's a new prompt to send, execute it after a short delay
989
+ if (parsed.newPrompt) {
990
+ // Wait for Gemini to process the rejection
991
+ await new Promise(resolve => setTimeout(resolve, 1000));
992
+ // Send the new prompt
993
+ const modifiedEvent = { ...event, content: parsed.newPrompt };
994
+ await this.executeMobilePrompt(sessionId, modifiedEvent);
995
+ }
996
+ await this.markEventExecuted(event);
997
+ }
998
+ else {
999
+ await this.sendPromptError(sessionId, 'Failed to reject prompt');
1000
+ }
1001
+ }
1002
+ else {
1003
+ // 'send_as_response' - send content as-is to interactive prompt (free-form for option 3)
1004
+ logger_1.logger.info('Sending as free-form response to interactive prompt', {
1005
+ response: content,
1006
+ });
1007
+ const success = await this.promptResponder.answerInteractivePrompt(sessionId, content);
1008
+ if (success) {
1009
+ await this.markEventExecuted(event);
1010
+ sessionState.waitingForPromptResponse = false;
1011
+ sessionState.pendingPromptId = undefined;
1012
+ await this.createEncryptedEvent({
1013
+ sessionId,
1014
+ type: codevibe_core_1.EventType.NOTIFICATION,
1015
+ source: codevibe_core_1.EventSource.DESKTOP,
1016
+ content: `Response "${content}" sent to interactive prompt`,
1017
+ metadata: { promptAnswered: true },
1018
+ });
1019
+ }
1020
+ else {
1021
+ await this.sendPromptError(sessionId, 'Failed to send response');
1022
+ }
1023
+ }
1024
+ }
1025
+ else {
1026
+ // No interactive prompt waiting - execute as regular prompt
1027
+ await this.executeMobilePrompt(sessionId, processedEvent);
1028
+ }
1029
+ }
1030
+ }, (error) => {
1031
+ logger_1.logger.error('Subscription error', { sessionId, error });
1032
+ // TODO: Implement reconnection logic
1033
+ });
1034
+ sessionState.subscriptionActive = true;
1035
+ logger_1.logger.info('Subscription active', { sessionId });
1036
+ }
1037
+ /**
1038
+ * Parse mobile input when an interactive prompt is waiting
1039
+ * Determines what action to take based on the input pattern
1040
+ *
1041
+ * Input patterns:
1042
+ * - "1" or "2" → select that option
1043
+ * - "3" → reject prompt, wait for next message as new prompt
1044
+ * - "3 <text>" or "3<newline><text>" → reject prompt, send <text> as new prompt
1045
+ * - anything else → send as free-form response to interactive prompt
1046
+ */
1047
+ parseInteractivePromptInput(content) {
1048
+ const trimmed = content.trim();
1049
+ // Check for option 1 or 2 (exact match)
1050
+ if (trimmed === '1' || trimmed === '2') {
1051
+ return { action: 'select_option', option: trimmed };
1052
+ }
1053
+ // Check for option 3 patterns
1054
+ if (trimmed === '3') {
1055
+ // Just "3" - reject prompt, but no new prompt yet
1056
+ // The next message will be treated as a new prompt since waitingForPromptResponse will be false
1057
+ return { action: 'reject_and_prompt', newPrompt: undefined };
1058
+ }
1059
+ // Check for "3 <text>" or "3\n<text>" pattern
1060
+ const option3Match = trimmed.match(/^3[\s\n]+(.+)$/s);
1061
+ if (option3Match) {
1062
+ return { action: 'reject_and_prompt', newPrompt: option3Match[1].trim() };
1063
+ }
1064
+ // Anything else - send as free-form response
1065
+ return { action: 'send_as_response' };
1066
+ }
1067
+ /**
1068
+ * Helper to mark an event as EXECUTED
1069
+ */
1070
+ async markEventExecuted(event) {
1071
+ try {
1072
+ await this.appSyncClient.updateEventStatus({
1073
+ eventId: event.eventId,
1074
+ sessionId: event.sessionId,
1075
+ timestamp: event.timestamp,
1076
+ deliveryStatus: codevibe_core_1.DeliveryStatus.EXECUTED,
1077
+ });
1078
+ logger_1.logger.info('Event marked as EXECUTED', { eventId: event.eventId });
1079
+ }
1080
+ catch (error) {
1081
+ logger_1.logger.warn('Failed to mark event as EXECUTED', { eventId: event.eventId, error });
1082
+ }
1083
+ }
1084
+ /**
1085
+ * Helper to send error notification to mobile
1086
+ */
1087
+ async sendPromptError(sessionId, message) {
1088
+ await this.createEncryptedEvent({
1089
+ sessionId,
1090
+ type: codevibe_core_1.EventType.NOTIFICATION,
1091
+ source: codevibe_core_1.EventSource.DESKTOP,
1092
+ content: message,
1093
+ metadata: { error: true },
1094
+ });
1095
+ }
1096
+ /**
1097
+ * Check if error is a subscription limit exceeded error
1098
+ */
1099
+ isSessionLimitExceeded(error) {
1100
+ const errorMessage = this.getErrorMessage(error);
1101
+ return errorMessage.includes('SESSION_LIMIT_EXCEEDED');
1102
+ }
1103
+ /**
1104
+ * Check if error is a usage limit exceeded error (message or image)
1105
+ */
1106
+ isUsageLimitExceeded(error) {
1107
+ const errorMessage = this.getErrorMessage(error);
1108
+ return errorMessage.includes('MESSAGE_LIMIT_EXCEEDED') || errorMessage.includes('IMAGE_LIMIT_EXCEEDED');
1109
+ }
1110
+ /**
1111
+ * Extract error message from various error types
1112
+ */
1113
+ getErrorMessage(error) {
1114
+ if (error instanceof Error) {
1115
+ return error.message;
1116
+ }
1117
+ if (typeof error === 'object' && error !== null) {
1118
+ const errorObj = error;
1119
+ // Check for GraphQL error format
1120
+ if (errorObj.errors && Array.isArray(errorObj.errors)) {
1121
+ return errorObj.errors.map((e) => e.message || '').join(' ');
1122
+ }
1123
+ // Check for message property
1124
+ if (typeof errorObj.message === 'string') {
1125
+ return errorObj.message;
1126
+ }
1127
+ }
1128
+ return String(error);
1129
+ }
1130
+ /**
1131
+ * Display subscription limit error to user
1132
+ */
1133
+ displaySubscriptionLimitError(error, limitType) {
1134
+ const errorMessage = this.getErrorMessage(error);
1135
+ // Extract tier and limit info from error message if available
1136
+ let tierInfo = '';
1137
+ const tierMatch = errorMessage.match(/for your (\w+) plan/i);
1138
+ if (tierMatch) {
1139
+ tierInfo = ` (${tierMatch[1]} tier)`;
1140
+ }
1141
+ let limitInfo = '';
1142
+ const limitMatch = errorMessage.match(/of (\d+)/);
1143
+ if (limitMatch) {
1144
+ limitInfo = ` [Limit: ${limitMatch[1]}]`;
1145
+ }
1146
+ // Display user-friendly console message with formatting
1147
+ console.log('\n' + '='.repeat(60));
1148
+ console.log('⚠️ SUBSCRIPTION LIMIT REACHED');
1149
+ console.log('='.repeat(60));
1150
+ switch (limitType) {
1151
+ case 'session':
1152
+ console.log(`You have reached the maximum number of active sessions${tierInfo}.`);
1153
+ console.log(`${limitInfo}`);
1154
+ console.log('\nTo continue, please:');
1155
+ console.log(' • Close an existing Gemini CLI session, or');
1156
+ console.log(' • Upgrade your subscription in the CodeVibe iOS app');
1157
+ break;
1158
+ case 'message':
1159
+ console.log(`You have reached your monthly message limit${tierInfo}.`);
1160
+ console.log(`${limitInfo}`);
1161
+ console.log('\nTo continue, please:');
1162
+ console.log(' • Wait until your usage resets next month, or');
1163
+ console.log(' • Upgrade your subscription in the CodeVibe iOS app');
1164
+ break;
1165
+ case 'image':
1166
+ console.log(`You have reached your monthly image attachment limit${tierInfo}.`);
1167
+ console.log(`${limitInfo}`);
1168
+ console.log('\nTo continue, please:');
1169
+ console.log(' • Wait until your usage resets next month, or');
1170
+ console.log(' • Upgrade your subscription in the CodeVibe iOS app');
1171
+ break;
1172
+ }
1173
+ console.log('\nNote: You can still use Gemini CLI normally from your desktop.');
1174
+ console.log('This limit only affects syncing with the mobile app.');
1175
+ console.log('='.repeat(60) + '\n');
1176
+ logger_1.logger.error('Subscription limit exceeded', { limitType, errorMessage });
1177
+ }
1178
+ /**
1179
+ * Download an attachment from S3 and save it to the working directory
1180
+ * Returns the relative file path (for Gemini CLI's @./path syntax)
1181
+ *
1182
+ * Gemini CLI requires @./path format for local file references.
1183
+ * Files are saved to .codevibe-attachments/ in the working directory.
1184
+ * @param attachment The attachment to download
1185
+ * @param sessionId The session ID for organizing files
1186
+ * @param eventIsEncrypted Whether the parent event is encrypted (fallback for attachment.isEncrypted)
1187
+ */
1188
+ async downloadAttachment(attachment, sessionId, eventIsEncrypted) {
1189
+ try {
1190
+ // Use attachment.isEncrypted if available, otherwise fall back to event.isEncrypted
1191
+ // This handles AppSync subscription limitation where nested object fields may be null
1192
+ const shouldDecrypt = attachment.isEncrypted ?? eventIsEncrypted ?? false;
1193
+ logger_1.logger.info('Downloading attachment', {
1194
+ id: attachment.id,
1195
+ type: attachment.type,
1196
+ filename: attachment.filename,
1197
+ s3Key: attachment.s3Key,
1198
+ attachmentIsEncrypted: attachment.isEncrypted,
1199
+ eventIsEncrypted,
1200
+ shouldDecrypt,
1201
+ });
1202
+ // Get pre-signed download URL
1203
+ const { downloadUrl } = await this.appSyncClient.getAttachmentDownloadUrl(attachment.s3Key);
1204
+ // Download the file
1205
+ const response = await fetch(downloadUrl);
1206
+ if (!response.ok) {
1207
+ throw new Error(`Failed to download attachment: ${response.status} ${response.statusText}`);
1208
+ }
1209
+ let buffer = Buffer.from(await response.arrayBuffer());
1210
+ // Decrypt if encrypted
1211
+ if (shouldDecrypt && this.sessionKey) {
1212
+ try {
1213
+ logger_1.logger.info('Decrypting attachment', { id: attachment.id });
1214
+ buffer = codevibe_core_1.cryptoService.decryptData(buffer, this.sessionKey);
1215
+ logger_1.logger.info('Attachment decrypted successfully', {
1216
+ id: attachment.id,
1217
+ decryptedSize: buffer.length,
1218
+ });
1219
+ }
1220
+ catch (decryptError) {
1221
+ logger_1.logger.error('Failed to decrypt attachment:', { id: attachment.id, error: decryptError });
1222
+ throw new Error('Failed to decrypt attachment');
1223
+ }
1224
+ }
1225
+ else if (shouldDecrypt && !this.sessionKey) {
1226
+ logger_1.logger.warn('Cannot decrypt attachment - no session key available', { id: attachment.id });
1227
+ // Continue with encrypted data - Gemini CLI won't be able to view it properly
1228
+ }
1229
+ // Create attachments directory in the working directory
1230
+ // Gemini CLI requires relative paths starting with ./
1231
+ const attachmentsDir = path.join(this.workingDirectory, '.codevibe-attachments');
1232
+ if (!fs.existsSync(attachmentsDir)) {
1233
+ fs.mkdirSync(attachmentsDir, { recursive: true });
1234
+ }
1235
+ // Determine file extension from MIME type or filename
1236
+ let extension = '';
1237
+ if (attachment.filename) {
1238
+ const ext = path.extname(attachment.filename);
1239
+ if (ext)
1240
+ extension = ext;
1241
+ }
1242
+ if (!extension) {
1243
+ // Fallback to MIME type
1244
+ const mimeToExt = {
1245
+ 'image/jpeg': '.jpg',
1246
+ 'image/png': '.png',
1247
+ 'image/gif': '.gif',
1248
+ 'image/webp': '.webp',
1249
+ 'image/heic': '.heic',
1250
+ 'application/pdf': '.pdf',
1251
+ };
1252
+ extension = mimeToExt[attachment.type] || '.bin';
1253
+ }
1254
+ // Save to attachments directory with unique filename
1255
+ const filename = `attachment-${attachment.id}${extension}`;
1256
+ const absolutePath = path.join(attachmentsDir, filename);
1257
+ fs.writeFileSync(absolutePath, buffer);
1258
+ // Return relative path for Gemini CLI's @./path syntax
1259
+ const relativePath = `./.codevibe-attachments/${filename}`;
1260
+ logger_1.logger.info('Attachment saved to working directory', {
1261
+ id: attachment.id,
1262
+ absolutePath,
1263
+ relativePath,
1264
+ size: buffer.length,
1265
+ });
1266
+ return relativePath;
1267
+ }
1268
+ catch (error) {
1269
+ logger_1.logger.error('Failed to download attachment:', { id: attachment.id, error });
1270
+ return null;
1271
+ }
1272
+ }
1273
+ /**
1274
+ * Execute a prompt from mobile in the desktop Gemini CLI session
1275
+ * Uses tmux send-keys to send the prompt to the Gemini CLI session
1276
+ * If the event has attachments, downloads them and includes file paths in prompt
1277
+ */
1278
+ async executeMobilePrompt(sessionId, event) {
1279
+ let prompt = event.content || '';
1280
+ const attachments = event.attachments || [];
1281
+ logger_1.logger.info('Executing mobile prompt via tmux', {
1282
+ sessionId,
1283
+ promptLength: prompt.length,
1284
+ attachmentCount: attachments.length,
1285
+ });
1286
+ // Download attachments and build file paths to include in prompt
1287
+ const attachmentPaths = [];
1288
+ if (attachments.length > 0) {
1289
+ logger_1.logger.info('Downloading attachments for prompt', { count: attachments.length });
1290
+ for (const attachment of attachments) {
1291
+ const filePath = await this.downloadAttachment(attachment, sessionId, event.isEncrypted // Pass event encryption status as fallback
1292
+ );
1293
+ if (filePath) {
1294
+ attachmentPaths.push(filePath);
1295
+ }
1296
+ }
1297
+ // If we have downloaded files, prepend them to the prompt
1298
+ // Gemini CLI uses @path/to/file format for file references
1299
+ if (attachmentPaths.length > 0) {
1300
+ const fileReferences = attachmentPaths
1301
+ .map(p => `@${p}`)
1302
+ .join(' ');
1303
+ // Format: @file references first, then the user's message
1304
+ if (prompt) {
1305
+ prompt = `${fileReferences} ${prompt}`;
1306
+ }
1307
+ else {
1308
+ // No text content, just file references with instruction
1309
+ prompt = `${fileReferences} Please analyze the attached file(s).`;
1310
+ }
1311
+ logger_1.logger.info('Prompt updated with attachment paths', {
1312
+ attachmentCount: attachmentPaths.length,
1313
+ newPromptLength: prompt.length,
1314
+ });
1315
+ }
1316
+ }
1317
+ // Track this prompt to filter out the duplicate USER_PROMPT from the hook
1318
+ this.trackMobilePrompt(sessionId, prompt);
1319
+ try {
1320
+ // Use tmux send-keys to send the prompt (same as answering interactive prompts)
1321
+ const success = await this.promptResponder.answerInteractivePrompt(sessionId, prompt);
1322
+ if (success) {
1323
+ // Mark event as EXECUTED (fed into Claude Code) - double blue checkmark
1324
+ try {
1325
+ await this.appSyncClient.updateEventStatus({
1326
+ eventId: event.eventId,
1327
+ sessionId: event.sessionId,
1328
+ timestamp: event.timestamp,
1329
+ deliveryStatus: codevibe_core_1.DeliveryStatus.EXECUTED,
1330
+ });
1331
+ logger_1.logger.info('Event marked as EXECUTED', { eventId: event.eventId });
1332
+ }
1333
+ catch (error) {
1334
+ logger_1.logger.warn('Failed to mark event as EXECUTED', { eventId: event.eventId, error });
1335
+ }
1336
+ logger_1.logger.info('Mobile prompt sent successfully', { sessionId });
1337
+ // Note: Suppressed confirmation notification to reduce noise in iOS app
1338
+ // The double checkmarks (DELIVERED → EXECUTED) already indicate success
1339
+ }
1340
+ else {
1341
+ logger_1.logger.error('Failed to send mobile prompt', { sessionId });
1342
+ // Send error notification to mobile
1343
+ await this.createEncryptedEvent({
1344
+ sessionId,
1345
+ type: codevibe_core_1.EventType.NOTIFICATION,
1346
+ source: codevibe_core_1.EventSource.DESKTOP,
1347
+ content: `Failed to send prompt to Gemini CLI`,
1348
+ metadata: {
1349
+ error: true,
1350
+ },
1351
+ });
1352
+ }
1353
+ }
1354
+ catch (error) {
1355
+ logger_1.logger.error('Failed to execute mobile prompt:', error);
1356
+ }
1357
+ }
1358
+ }
1359
+ McpServer.MOBILE_PROMPT_EXPIRY_MS = 10000; // 10 seconds
1360
+ McpServer.INTERACTIVE_PROMPT_TIMEOUT_MS = 300000; // 5 minutes
1361
+ // Main entry point
1362
+ async function main() {
1363
+ // Get session ID from command line argument or environment variable
1364
+ const sessionId = process.argv[2] || process.env.GEMINI_SESSION_ID;
1365
+ // Get working directory from environment variable or use current directory
1366
+ const workingDirectory = process.env.GEMINI_WORKING_DIRECTORY || process.cwd();
1367
+ if (sessionId) {
1368
+ logger_1.logger.info(`Starting MCP server for session: ${sessionId}`);
1369
+ }
1370
+ else {
1371
+ logger_1.logger.info('Starting MCP server without initial session ID (will be discovered from transcript)');
1372
+ }
1373
+ logger_1.logger.info(`Working directory: ${workingDirectory}`);
1374
+ const server = new McpServer(sessionId, workingDirectory);
1375
+ try {
1376
+ await server.start();
1377
+ // Output the assigned port for the session-start hook to capture
1378
+ const port = server.getPort();
1379
+ console.log(`PORT=${port}`);
1380
+ // Handle graceful shutdown
1381
+ let isShuttingDown = false;
1382
+ const shutdown = async (signal) => {
1383
+ if (isShuttingDown) {
1384
+ logger_1.logger.info('Shutdown already in progress, ignoring additional signal');
1385
+ return;
1386
+ }
1387
+ isShuttingDown = true;
1388
+ logger_1.logger.info(`Received ${signal} signal, stopping server...`);
1389
+ try {
1390
+ await server.stop();
1391
+ logger_1.logger.info('Graceful shutdown completed');
1392
+ process.exit(0);
1393
+ }
1394
+ catch (error) {
1395
+ logger_1.logger.error('Error during shutdown:', error);
1396
+ process.exit(1);
1397
+ }
1398
+ };
1399
+ process.on('SIGINT', () => shutdown('SIGINT'));
1400
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
1401
+ process.on('SIGHUP', () => shutdown('SIGHUP'));
1402
+ // Handle uncaught exceptions - try to cleanup before exit
1403
+ process.on('uncaughtException', async (error) => {
1404
+ logger_1.logger.error('Uncaught exception:', error);
1405
+ await shutdown('uncaughtException');
1406
+ });
1407
+ process.on('unhandledRejection', async (reason) => {
1408
+ logger_1.logger.error('Unhandled rejection:', reason);
1409
+ await shutdown('unhandledRejection');
1410
+ });
1411
+ }
1412
+ catch (error) {
1413
+ logger_1.logger.error('Failed to start MCP Server:', error);
1414
+ process.exit(1);
1415
+ }
1416
+ }
1417
+ // Start the server
1418
+ main().catch((error) => {
1419
+ logger_1.logger.error('Unhandled error in main:', error);
1420
+ process.exit(1);
1421
+ });
1422
+ //# sourceMappingURL=server.js.map