@quantiya/codevibe-gemini-plugin 1.0.6 → 1.0.8

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