@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 +13 -1424
- package/package.json +5 -4
- package/dist/appsync-client.js +0 -819
- package/dist/auth-cli.js +0 -472
- package/dist/command-executor.js +0 -127
- package/dist/config.js +0 -106
- package/dist/crypto-service.js +0 -278
- package/dist/http-api.js +0 -582
- package/dist/key-manager.js +0 -287
- package/dist/logger.js +0 -18
- package/dist/prompt-responder.js +0 -132
- package/dist/token-storage.js +0 -169
- package/dist/transcript-watcher.js +0 -324
- package/dist/types.js +0 -16
package/dist/server.js
CHANGED
|
@@ -1,1424 +1,13 @@
|
|
|
1
|
-
"use strict";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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});
|