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