@quantiya/codevibe-claude-plugin 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/.claude-plugin/plugin.json +22 -0
  2. package/.env.example +28 -0
  3. package/LICENSE +21 -0
  4. package/README.md +301 -0
  5. package/bin/claude-companion-setup +65 -0
  6. package/bin/codevibe-claude +134 -0
  7. package/dist/appsync-client.d.ts +67 -0
  8. package/dist/appsync-client.d.ts.map +1 -0
  9. package/dist/appsync-client.js +858 -0
  10. package/dist/appsync-client.js.map +1 -0
  11. package/dist/auth-cli.d.ts +18 -0
  12. package/dist/auth-cli.d.ts.map +1 -0
  13. package/dist/auth-cli.js +472 -0
  14. package/dist/auth-cli.js.map +1 -0
  15. package/dist/command-executor.d.ts +20 -0
  16. package/dist/command-executor.d.ts.map +1 -0
  17. package/dist/command-executor.js +127 -0
  18. package/dist/command-executor.js.map +1 -0
  19. package/dist/config.d.ts +25 -0
  20. package/dist/config.d.ts.map +1 -0
  21. package/dist/config.js +106 -0
  22. package/dist/config.js.map +1 -0
  23. package/dist/crypto-service.d.ts +115 -0
  24. package/dist/crypto-service.d.ts.map +1 -0
  25. package/dist/crypto-service.js +278 -0
  26. package/dist/crypto-service.js.map +1 -0
  27. package/dist/http-api.d.ts +35 -0
  28. package/dist/http-api.d.ts.map +1 -0
  29. package/dist/http-api.js +334 -0
  30. package/dist/http-api.js.map +1 -0
  31. package/dist/key-manager.d.ts +87 -0
  32. package/dist/key-manager.d.ts.map +1 -0
  33. package/dist/key-manager.js +287 -0
  34. package/dist/key-manager.js.map +1 -0
  35. package/dist/logger.d.ts +2 -0
  36. package/dist/logger.d.ts.map +1 -0
  37. package/dist/logger.js +18 -0
  38. package/dist/logger.js.map +1 -0
  39. package/dist/prompt-responder.d.ts +22 -0
  40. package/dist/prompt-responder.d.ts.map +1 -0
  41. package/dist/prompt-responder.js +132 -0
  42. package/dist/prompt-responder.js.map +1 -0
  43. package/dist/server.d.ts +2 -0
  44. package/dist/server.d.ts.map +1 -0
  45. package/dist/server.js +1154 -0
  46. package/dist/server.js.map +1 -0
  47. package/dist/token-storage.d.ts +39 -0
  48. package/dist/token-storage.d.ts.map +1 -0
  49. package/dist/token-storage.js +169 -0
  50. package/dist/token-storage.js.map +1 -0
  51. package/dist/types.d.ts +110 -0
  52. package/dist/types.d.ts.map +1 -0
  53. package/dist/types.js +17 -0
  54. package/dist/types.js.map +1 -0
  55. package/hooks/common.sh +121 -0
  56. package/hooks/hooks.json +81 -0
  57. package/hooks/notification.sh +32 -0
  58. package/hooks/permission-request.sh +191 -0
  59. package/hooks/post-tool-use.sh +42 -0
  60. package/hooks/session-end.sh +57 -0
  61. package/hooks/session-start.sh +127 -0
  62. package/hooks/stop.sh +255 -0
  63. package/hooks/user-prompt.sh +32 -0
  64. package/package.json +70 -0
package/dist/server.js ADDED
@@ -0,0 +1,1154 @@
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 logger_1 = require("./logger");
40
+ // Import shared modules from codevibe-core
41
+ const codevibe_core_1 = require("@quantiya/codevibe-core");
42
+ // Import plugin-specific modules
43
+ const http_api_1 = require("./http-api");
44
+ const command_executor_1 = require("./command-executor");
45
+ const prompt_responder_1 = require("./prompt-responder");
46
+ class McpServer {
47
+ constructor(sessionId) {
48
+ this.activeSessions = new Map();
49
+ this.assignedPort = 0;
50
+ this.sessionKey = null; // E2E encryption session key
51
+ // Map Claude Code session IDs to backend session IDs (claude-{uuid} format)
52
+ this.claudeToBackendSessionId = new Map();
53
+ // Track prompts sent from mobile to avoid duplicate USER_PROMPT events
54
+ // When mobile sends a prompt, it gets typed into terminal which triggers UserPromptSubmit hook
55
+ // We track with timestamp and expire after 3 seconds to avoid false positives
56
+ this.pendingMobilePrompts = new Map();
57
+ this.httpApi = new http_api_1.HttpApi();
58
+ // AppSyncClient is created in start() after config is loaded with correct environment
59
+ this.commandExecutor = new command_executor_1.CommandExecutor();
60
+ this.promptResponder = new prompt_responder_1.PromptResponder();
61
+ this.initialSessionId = sessionId;
62
+ }
63
+ /**
64
+ * Get the port the server is listening on
65
+ */
66
+ getPort() {
67
+ return this.assignedPort;
68
+ }
69
+ /**
70
+ * Generate backend session ID from Claude Code session ID.
71
+ * Format: claude-{claudeSessionId}
72
+ */
73
+ generateBackendSessionId(claudeSessionId) {
74
+ return `claude-${claudeSessionId}`;
75
+ }
76
+ /**
77
+ * Track a mobile prompt to filter out the duplicate USER_PROMPT from desktop hook
78
+ */
79
+ trackMobilePrompt(sessionId, prompt) {
80
+ if (!this.pendingMobilePrompts.has(sessionId)) {
81
+ this.pendingMobilePrompts.set(sessionId, []);
82
+ }
83
+ this.pendingMobilePrompts.get(sessionId).push({
84
+ prompt: prompt.trim(),
85
+ timestamp: Date.now(),
86
+ });
87
+ logger_1.logger.debug('Tracking mobile prompt for deduplication', { sessionId, promptLength: prompt.length });
88
+ }
89
+ /**
90
+ * Check if a prompt was recently sent from mobile (within expiry window)
91
+ * Returns true and removes the entry if found
92
+ */
93
+ isRecentMobilePrompt(sessionId, prompt) {
94
+ const prompts = this.pendingMobilePrompts.get(sessionId);
95
+ if (!prompts)
96
+ return false;
97
+ const now = Date.now();
98
+ const trimmedPrompt = prompt.trim();
99
+ // Clean up expired entries and check for match
100
+ const validPrompts = [];
101
+ let found = false;
102
+ for (const entry of prompts) {
103
+ if (now - entry.timestamp > McpServer.MOBILE_PROMPT_EXPIRY_MS) {
104
+ // Expired, skip
105
+ continue;
106
+ }
107
+ if (!found && entry.prompt === trimmedPrompt) {
108
+ // Found match, mark as found but don't add to validPrompts (consume it)
109
+ found = true;
110
+ logger_1.logger.debug('Found matching mobile prompt, filtering duplicate', { sessionId });
111
+ continue;
112
+ }
113
+ validPrompts.push(entry);
114
+ }
115
+ // Update the list with remaining valid prompts
116
+ if (validPrompts.length > 0) {
117
+ this.pendingMobilePrompts.set(sessionId, validPrompts);
118
+ }
119
+ else {
120
+ this.pendingMobilePrompts.delete(sessionId);
121
+ }
122
+ return found;
123
+ }
124
+ /**
125
+ * Write port file for a session so hooks can discover the server port
126
+ */
127
+ writePortFile(sessionId) {
128
+ const portFilePath = path.join(os.tmpdir(), `codevibe-claude-${sessionId}.port`);
129
+ try {
130
+ fs.writeFileSync(portFilePath, this.assignedPort.toString());
131
+ logger_1.logger.info(`Port file written: ${portFilePath} -> ${this.assignedPort}`);
132
+ }
133
+ catch (error) {
134
+ logger_1.logger.error(`Failed to write port file: ${portFilePath}`, error);
135
+ }
136
+ }
137
+ /**
138
+ * Remove port file for a session
139
+ */
140
+ removePortFile(sessionId) {
141
+ const portFilePath = path.join(os.tmpdir(), `codevibe-claude-${sessionId}.port`);
142
+ try {
143
+ if (fs.existsSync(portFilePath)) {
144
+ fs.unlinkSync(portFilePath);
145
+ logger_1.logger.info(`Port file removed: ${portFilePath}`);
146
+ }
147
+ }
148
+ catch (error) {
149
+ logger_1.logger.warn(`Failed to remove port file: ${portFilePath}`, error);
150
+ }
151
+ }
152
+ async start() {
153
+ try {
154
+ logger_1.logger.info('Starting CodeVibe MCP Server...', {
155
+ environment: (0, codevibe_core_1.getEnvironment)(),
156
+ });
157
+ // Create AppSyncClient (auto-configures from ENVIRONMENT env var)
158
+ this.appSyncClient = new codevibe_core_1.AppSyncClient();
159
+ // Authenticate using stored OAuth tokens (from 'codevibe-claude login')
160
+ const storedTokensAuth = await this.appSyncClient.authenticateWithStoredTokens();
161
+ if (storedTokensAuth) {
162
+ logger_1.logger.info('Authenticated with stored OAuth tokens', {
163
+ userId: this.appSyncClient.getCurrentUserId(),
164
+ email: this.appSyncClient.getCurrentUserEmail(),
165
+ });
166
+ // Register device encryption key for E2E encryption
167
+ await this.registerDeviceEncryptionKey();
168
+ }
169
+ else {
170
+ logger_1.logger.error('Authentication failed. Run "codevibe-claude login" first.');
171
+ console.error('Not authenticated. Run "codevibe-claude login" to sign in.');
172
+ process.exit(1);
173
+ }
174
+ // Register event handlers
175
+ this.httpApi.onEvent(this.handleEventFromHook.bind(this));
176
+ // Start HTTP API with dynamic port allocation
177
+ // Pass session ID if provided at startup
178
+ this.assignedPort = await this.httpApi.start(this.initialSessionId);
179
+ logger_1.logger.info('MCP Server started successfully', {
180
+ port: this.assignedPort,
181
+ host: (0, codevibe_core_1.getConfig)().server.host,
182
+ dynamicPort: (0, codevibe_core_1.getConfig)().server.dynamicPort,
183
+ sessionId: this.initialSessionId,
184
+ authenticated: this.appSyncClient.isAuthenticated(),
185
+ userId: this.appSyncClient.getCurrentUserId(),
186
+ });
187
+ }
188
+ catch (error) {
189
+ logger_1.logger.error('Failed to start MCP Server:', error);
190
+ throw error;
191
+ }
192
+ }
193
+ async stop() {
194
+ logger_1.logger.info('Stopping MCP Server...');
195
+ // Mark all active sessions as INACTIVE before shutting down
196
+ const sessionIds = Array.from(this.activeSessions.keys());
197
+ logger_1.logger.info(`Marking ${sessionIds.length} active session(s) as INACTIVE...`);
198
+ for (const sessionId of sessionIds) {
199
+ try {
200
+ await this.appSyncClient.updateSession({
201
+ sessionId,
202
+ status: codevibe_core_1.SessionStatus.INACTIVE,
203
+ });
204
+ logger_1.logger.info('Session marked as INACTIVE during shutdown', { sessionId });
205
+ // Remove port file using raw Claude session ID
206
+ const state = this.activeSessions.get(sessionId);
207
+ if (state) {
208
+ this.removePortFile(state.claudeSessionId);
209
+ }
210
+ }
211
+ catch (error) {
212
+ logger_1.logger.warn('Failed to mark session as INACTIVE during shutdown', { sessionId, error });
213
+ }
214
+ }
215
+ // Cleanup subscriptions
216
+ this.appSyncClient.cleanupSubscriptions();
217
+ // Clear active sessions
218
+ this.activeSessions.clear();
219
+ // Stop HTTP API
220
+ await this.httpApi.stop();
221
+ logger_1.logger.info('MCP Server stopped');
222
+ }
223
+ /**
224
+ * Handle events received from hook scripts via HTTP API
225
+ */
226
+ async handleEventFromHook(payload) {
227
+ const { session_id, hook_event_name, type, content } = payload;
228
+ logger_1.logger.info('Processing hook event', {
229
+ sessionId: session_id,
230
+ hookEvent: hook_event_name,
231
+ type,
232
+ });
233
+ try {
234
+ // Handle session lifecycle events
235
+ if (hook_event_name === 'SessionStart') {
236
+ await this.handleSessionStart(payload);
237
+ }
238
+ else if (hook_event_name === 'SessionEnd') {
239
+ await this.handleSessionEnd(payload);
240
+ }
241
+ // Resolve backend session ID (claude-{uuid} format)
242
+ const backendSessionId = this.claudeToBackendSessionId.get(session_id)
243
+ || this.generateBackendSessionId(session_id);
244
+ // Skip USER_PROMPT events that originated from mobile
245
+ // When mobile sends a prompt via tmux, it triggers UserPromptSubmit hook
246
+ // This creates a duplicate since mobile already created the event
247
+ if (type === codevibe_core_1.EventType.USER_PROMPT &&
248
+ payload.source === codevibe_core_1.EventSource.DESKTOP &&
249
+ hook_event_name === 'UserPromptSubmit' &&
250
+ content &&
251
+ this.isRecentMobilePrompt(backendSessionId, content)) {
252
+ logger_1.logger.info('Skipping duplicate USER_PROMPT from mobile-originated prompt', {
253
+ sessionId: backendSessionId,
254
+ contentLength: content.length,
255
+ });
256
+ return; // Don't send to AppSync
257
+ }
258
+ // Intercept INTERACTIVE_PROMPT — handle async with tmux snapshot parsing
259
+ if (type === codevibe_core_1.EventType.INTERACTIVE_PROMPT) {
260
+ const sessionState = this.activeSessions.get(backendSessionId);
261
+ if (sessionState) {
262
+ sessionState.waitingForPromptResponse = true;
263
+ sessionState.pendingPromptId = payload.prompt_id;
264
+ logger_1.logger.info('Interactive prompt detected - will parse options from tmux', {
265
+ sessionId: backendSessionId,
266
+ promptId: payload.prompt_id,
267
+ });
268
+ }
269
+ // Fire async — capture tmux after prompt renders, then send to AppSync
270
+ this.sendInteractivePromptAsync(backendSessionId, payload, content).catch(e => {
271
+ logger_1.logger.error('Failed to send interactive prompt with dynamic options', { error: e });
272
+ });
273
+ return; // Don't create AppSync event here — async handler will do it
274
+ }
275
+ // Encrypt event content if we have a session key
276
+ let eventContent = content;
277
+ let eventMetadata = payload.metadata;
278
+ let isEncrypted = false;
279
+ // DEBUG: Log session key state for hook events
280
+ logger_1.logger.info('Hook event encryption state', {
281
+ type,
282
+ sessionId: backendSessionId,
283
+ hasSessionKey: !!this.sessionKey,
284
+ sessionKeyLength: this.sessionKey?.length || 0,
285
+ });
286
+ if (this.sessionKey) {
287
+ eventContent = codevibe_core_1.cryptoService.encryptContent(content, this.sessionKey);
288
+ if (eventMetadata) {
289
+ const encryptedMeta = codevibe_core_1.cryptoService.encryptMetadata(eventMetadata, this.sessionKey);
290
+ eventMetadata = { encrypted: encryptedMeta };
291
+ }
292
+ isEncrypted = true;
293
+ logger_1.logger.info('Event encrypted for hook', { type, sessionId: backendSessionId, isEncrypted: true });
294
+ }
295
+ else {
296
+ logger_1.logger.warn('No session key - event will NOT be encrypted', { type, sessionId: backendSessionId });
297
+ }
298
+ // Send event to AppSync
299
+ const event = await this.appSyncClient.createEvent({
300
+ sessionId: backendSessionId,
301
+ type,
302
+ source: payload.source,
303
+ content: eventContent,
304
+ metadata: eventMetadata,
305
+ promptId: payload.prompt_id,
306
+ isEncrypted: isEncrypted ? true : undefined,
307
+ });
308
+ // Clear waiting state when user sends new prompt from desktop
309
+ // (means they answered the prompt locally or moved on)
310
+ if (type === codevibe_core_1.EventType.USER_PROMPT && payload.source === codevibe_core_1.EventSource.DESKTOP) {
311
+ const sessionState = this.activeSessions.get(backendSessionId);
312
+ if (sessionState?.waitingForPromptResponse) {
313
+ sessionState.waitingForPromptResponse = false;
314
+ sessionState.pendingPromptId = undefined;
315
+ sessionState.pendingSubmitMap = undefined;
316
+ logger_1.logger.info('Clearing prompt wait state - new desktop prompt received', {
317
+ sessionId: backendSessionId,
318
+ });
319
+ }
320
+ }
321
+ logger_1.logger.debug('Event sent to AppSync successfully');
322
+ }
323
+ catch (error) {
324
+ logger_1.logger.error('Failed to process hook event:', error);
325
+ throw error;
326
+ }
327
+ }
328
+ /**
329
+ * Handle SessionStart hook event
330
+ *
331
+ * Handles both new sessions and /resume:
332
+ * - If session exists in backend → reactivate it (same session ID)
333
+ * - If session doesn't exist → create new one with that session ID
334
+ */
335
+ async handleSessionStart(payload) {
336
+ const claudeSessionId = payload.session_id;
337
+ const sessionId = this.generateBackendSessionId(claudeSessionId);
338
+ const cwd = payload.metadata?.cwd || process.cwd();
339
+ // Cache the Claude → backend session ID mapping
340
+ this.claudeToBackendSessionId.set(claudeSessionId, sessionId);
341
+ logger_1.logger.info('Session started', { claudeSessionId, sessionId, cwd });
342
+ // If there are other sessions in memory, mark them as INACTIVE
343
+ // This happens when user does /resume - the first session should be marked INACTIVE
344
+ const previousSessionIds = Array.from(this.activeSessions.keys()).filter(id => id !== sessionId);
345
+ if (previousSessionIds.length > 0) {
346
+ logger_1.logger.info(`Marking ${previousSessionIds.length} previous session(s) as INACTIVE`);
347
+ for (const prevId of previousSessionIds) {
348
+ try {
349
+ await this.appSyncClient.updateSession({
350
+ sessionId: prevId,
351
+ status: codevibe_core_1.SessionStatus.INACTIVE,
352
+ });
353
+ logger_1.logger.info('Previous session marked INACTIVE', { prevId, newSessionId: sessionId });
354
+ }
355
+ catch (error) {
356
+ logger_1.logger.warn('Failed to mark previous session as INACTIVE', { prevId, error });
357
+ }
358
+ // Use raw Claude session ID for port file (hooks use raw IDs)
359
+ const prevState = this.activeSessions.get(prevId);
360
+ if (prevState) {
361
+ this.removePortFile(prevState.claudeSessionId);
362
+ }
363
+ this.activeSessions.delete(prevId);
364
+ }
365
+ }
366
+ // Write port file using raw Claude session ID (hooks use raw IDs to discover port)
367
+ this.writePortFile(claudeSessionId);
368
+ // Create session state with authenticated user ID
369
+ const userId = this.appSyncClient.getCurrentUserId();
370
+ const sessionState = {
371
+ sessionId,
372
+ claudeSessionId,
373
+ userId,
374
+ projectPath: cwd,
375
+ cwd,
376
+ createdAt: new Date(),
377
+ subscriptionActive: false,
378
+ waitingForPromptResponse: false,
379
+ metadata: payload.metadata || {},
380
+ };
381
+ this.activeSessions.set(sessionId, sessionState);
382
+ // Use centralized resume/create utility from codevibe-core
383
+ try {
384
+ const result = await (0, codevibe_core_1.resumeOrCreateSession)({
385
+ sessionId,
386
+ userId: sessionState.userId,
387
+ agentType: codevibe_core_1.AgentType.CLAUDE,
388
+ projectPath: cwd,
389
+ metadata: payload.metadata || {},
390
+ }, this.appSyncClient, logger_1.logger);
391
+ this.sessionKey = result.sessionKey;
392
+ // Claude-specific: warn if resumed encrypted session but no key found
393
+ // (device key was regenerated after session was created)
394
+ if (result.resumed && !result.sessionKey) {
395
+ const pluginDeviceId = await codevibe_core_1.keychainManager.getDeviceId();
396
+ logger_1.logger.error('Device key not found in session encryptedKeys', { sessionId, pluginDeviceId });
397
+ console.error('\n⚠️ E2E ENCRYPTION WARNING: Cannot decrypt this session!');
398
+ console.error(` Your device ID (${pluginDeviceId.substring(0, 8)}...) is not in session's encryption keys.`);
399
+ console.error(' This happens if your device key was regenerated after the session was created.');
400
+ console.error(' SOLUTION: Start a new Claude Code session instead of resuming this one.\n');
401
+ }
402
+ }
403
+ catch (error) {
404
+ // createSession errors propagate from resumeOrCreateSession
405
+ if (this.isSessionLimitExceeded(error)) {
406
+ this.displaySubscriptionLimitError(error, 'session');
407
+ this.activeSessions.delete(sessionId);
408
+ this.removePortFile(claudeSessionId);
409
+ return;
410
+ }
411
+ logger_1.logger.error('Failed to create/resume session:', error);
412
+ }
413
+ // Subscribe to mobile events for this session
414
+ this.subscribeToMobileEvents(sessionId);
415
+ // Start heartbeat so iOS can detect if desktop is still connected
416
+ this.appSyncClient.startHeartbeat(sessionId);
417
+ }
418
+ /**
419
+ * Handle SessionEnd hook event
420
+ */
421
+ async handleSessionEnd(payload) {
422
+ const claudeSessionId = payload.session_id;
423
+ const sessionId = this.claudeToBackendSessionId.get(claudeSessionId)
424
+ || this.generateBackendSessionId(claudeSessionId);
425
+ logger_1.logger.info('Session ended', {
426
+ claudeSessionId,
427
+ sessionId,
428
+ reason: payload.metadata?.reason,
429
+ });
430
+ // Remove port file using raw Claude session ID (hooks use raw IDs)
431
+ this.removePortFile(claudeSessionId);
432
+ // Clear any waiting prompt state before ending session
433
+ const sessionState = this.activeSessions.get(sessionId);
434
+ if (sessionState?.waitingForPromptResponse) {
435
+ logger_1.logger.info('Clearing prompt wait state - session ending', { sessionId });
436
+ sessionState.waitingForPromptResponse = false;
437
+ sessionState.pendingPromptId = undefined;
438
+ }
439
+ // Stop heartbeat
440
+ this.appSyncClient.stopHeartbeat(sessionId);
441
+ // Update session status in AppSync
442
+ if (sessionState) {
443
+ try {
444
+ await this.appSyncClient.updateSession({
445
+ sessionId,
446
+ status: codevibe_core_1.SessionStatus.INACTIVE,
447
+ });
448
+ logger_1.logger.info('Session marked as INACTIVE in AppSync', { sessionId });
449
+ }
450
+ catch (error) {
451
+ logger_1.logger.warn('Failed to update session in AppSync:', error);
452
+ }
453
+ }
454
+ else {
455
+ logger_1.logger.warn('Cannot update session - session state not found', { sessionId });
456
+ }
457
+ // Remove from active sessions and ID mapping
458
+ this.activeSessions.delete(sessionId);
459
+ this.claudeToBackendSessionId.delete(claudeSessionId);
460
+ logger_1.logger.debug('Session cleanup completed', { sessionId });
461
+ }
462
+ /**
463
+ * Register device encryption key with backend for E2E encryption
464
+ * This ensures the current device's public key is known to the backend
465
+ * so session keys can be encrypted for this device
466
+ */
467
+ async registerDeviceEncryptionKey() {
468
+ try {
469
+ // Get or generate device key pair (async keychain access)
470
+ const deviceId = await codevibe_core_1.keychainManager.getDeviceId();
471
+ const publicKey = await codevibe_core_1.keychainManager.getDevicePublicKey();
472
+ const platform = codevibe_core_1.keychainManager.getDevicePlatform();
473
+ const deviceName = codevibe_core_1.keychainManager.getDeviceName();
474
+ logger_1.logger.info('Registering device encryption key', { deviceId, platform, deviceName });
475
+ // Register with backend (will update if deviceId already exists)
476
+ await this.appSyncClient.registerDeviceKey(deviceId, publicKey, platform, deviceName);
477
+ codevibe_core_1.keychainManager.setIsRegistered(true);
478
+ logger_1.logger.info('Device encryption key registered successfully', { deviceId });
479
+ }
480
+ catch (error) {
481
+ // Don't fail startup if registration fails - encryption is optional
482
+ logger_1.logger.warn('Failed to register device encryption key (E2E encryption may not work):', error);
483
+ }
484
+ }
485
+ /**
486
+ * Subscribe to mobile events for a session
487
+ */
488
+ subscribeToMobileEvents(sessionId) {
489
+ logger_1.logger.info('Subscribing to mobile events', { sessionId });
490
+ const sessionState = this.activeSessions.get(sessionId);
491
+ if (!sessionState) {
492
+ logger_1.logger.error('Session not found', { sessionId });
493
+ return;
494
+ }
495
+ this.appSyncClient.subscribeToEvents(sessionId, async (event) => {
496
+ logger_1.logger.info('Received mobile event', {
497
+ eventId: event.eventId,
498
+ type: event.type,
499
+ sessionId: event.sessionId,
500
+ isEncrypted: event.isEncrypted,
501
+ });
502
+ // Decrypt event content if encrypted
503
+ let decryptedContent = event.content || '';
504
+ if (event.isEncrypted && this.sessionKey) {
505
+ try {
506
+ decryptedContent = codevibe_core_1.cryptoService.decryptContent(event.content, this.sessionKey);
507
+ logger_1.logger.debug('Event decrypted successfully', { eventId: event.eventId });
508
+ }
509
+ catch (error) {
510
+ logger_1.logger.error('Failed to decrypt event:', { eventId: event.eventId, error });
511
+ // Fall back to original content (might not work, but better than nothing)
512
+ decryptedContent = event.content;
513
+ }
514
+ }
515
+ // Create a modified event with decrypted content for processing
516
+ const processedEvent = { ...event, content: decryptedContent };
517
+ // Mark event as DELIVERED (MCP server received it) - double gray checkmark
518
+ try {
519
+ await this.appSyncClient.updateEventStatus({
520
+ eventId: event.eventId,
521
+ sessionId: event.sessionId,
522
+ timestamp: event.timestamp,
523
+ deliveryStatus: codevibe_core_1.DeliveryStatus.DELIVERED,
524
+ });
525
+ logger_1.logger.info('Event marked as DELIVERED', { eventId: event.eventId });
526
+ }
527
+ catch (error) {
528
+ logger_1.logger.warn('Failed to mark event as DELIVERED', { eventId: event.eventId, error });
529
+ }
530
+ // Handle different event types from mobile
531
+ if (event.type === codevibe_core_1.EventType.USER_PROMPT) {
532
+ const sessionState = this.activeSessions.get(sessionId);
533
+ // Check if session is waiting for prompt response
534
+ if (sessionState?.waitingForPromptResponse) {
535
+ // Parse the input to determine action
536
+ const content = decryptedContent.trim();
537
+ const optionCount = sessionState.pendingSubmitMap
538
+ ? Object.keys(sessionState.pendingSubmitMap).length
539
+ : 3;
540
+ const parsed = this.parseInteractivePromptInput(content, optionCount);
541
+ logger_1.logger.info('Parsed interactive prompt input', {
542
+ sessionId,
543
+ content,
544
+ parsed,
545
+ hasSubmitMap: !!sessionState.pendingSubmitMap,
546
+ });
547
+ if (parsed.action === 'select_option') {
548
+ // Translate via submitMap before sending to terminal
549
+ const terminalInput = sessionState.pendingSubmitMap?.[parsed.option] || parsed.option;
550
+ logger_1.logger.info('User selected option', { option: parsed.option, terminalInput });
551
+ const success = await this.promptResponder.answerInteractivePrompt(sessionId, terminalInput);
552
+ if (success) {
553
+ await this.markEventExecuted(event);
554
+ sessionState.waitingForPromptResponse = false;
555
+ sessionState.pendingPromptId = undefined;
556
+ sessionState.pendingSubmitMap = undefined;
557
+ await this.appSyncClient.createEvent({
558
+ sessionId,
559
+ type: codevibe_core_1.EventType.NOTIFICATION,
560
+ source: codevibe_core_1.EventSource.DESKTOP,
561
+ content: `Selected option ${parsed.option}`,
562
+ metadata: { promptAnswered: true },
563
+ });
564
+ }
565
+ else {
566
+ await this.sendPromptError(sessionId, 'Failed to select option');
567
+ }
568
+ }
569
+ else if (parsed.action === 'option_with_followup') {
570
+ // Translate option via submitMap, send it, then send follow-up text
571
+ const terminalInput = sessionState.pendingSubmitMap?.[parsed.option] || parsed.option;
572
+ logger_1.logger.info('User selected option with follow-up', {
573
+ option: parsed.option,
574
+ terminalInput,
575
+ followUpText: parsed.followUpText,
576
+ });
577
+ const optionSuccess = await this.promptResponder.answerInteractivePrompt(sessionId, terminalInput);
578
+ // Clear waiting state
579
+ sessionState.waitingForPromptResponse = false;
580
+ sessionState.pendingPromptId = undefined;
581
+ sessionState.pendingSubmitMap = undefined;
582
+ if (optionSuccess) {
583
+ await this.appSyncClient.createEvent({
584
+ sessionId,
585
+ type: codevibe_core_1.EventType.NOTIFICATION,
586
+ source: codevibe_core_1.EventSource.DESKTOP,
587
+ content: `Selected option ${parsed.option}`,
588
+ metadata: { promptAnswered: true },
589
+ });
590
+ // If there's follow-up text, send it after a short delay
591
+ if (parsed.followUpText) {
592
+ // Wait for Claude to process the option selection
593
+ await new Promise(resolve => setTimeout(resolve, 1000));
594
+ // Send the follow-up text as a new prompt
595
+ const modifiedEvent = { ...event, content: parsed.followUpText };
596
+ await this.executeMobilePrompt(sessionId, modifiedEvent);
597
+ }
598
+ await this.markEventExecuted(event);
599
+ }
600
+ else {
601
+ await this.sendPromptError(sessionId, 'Failed to select option');
602
+ }
603
+ }
604
+ else {
605
+ // 'send_as_response' - send content as-is to interactive prompt
606
+ logger_1.logger.info('Sending as free-form response to interactive prompt', {
607
+ response: content,
608
+ });
609
+ const success = await this.promptResponder.answerInteractivePrompt(sessionId, content);
610
+ if (success) {
611
+ await this.markEventExecuted(event);
612
+ sessionState.waitingForPromptResponse = false;
613
+ sessionState.pendingPromptId = undefined;
614
+ sessionState.pendingSubmitMap = undefined;
615
+ await this.appSyncClient.createEvent({
616
+ sessionId,
617
+ type: codevibe_core_1.EventType.NOTIFICATION,
618
+ source: codevibe_core_1.EventSource.DESKTOP,
619
+ content: `Response sent to interactive prompt`,
620
+ metadata: { promptAnswered: true },
621
+ });
622
+ }
623
+ else {
624
+ await this.sendPromptError(sessionId, 'Failed to send response');
625
+ }
626
+ }
627
+ }
628
+ else {
629
+ // No interactive prompt waiting - execute as regular prompt
630
+ await this.executeMobilePrompt(sessionId, processedEvent);
631
+ }
632
+ }
633
+ }, (error) => {
634
+ logger_1.logger.error('Subscription error', { sessionId, error });
635
+ // TODO: Implement reconnection logic
636
+ });
637
+ sessionState.subscriptionActive = true;
638
+ logger_1.logger.info('Subscription active', { sessionId });
639
+ }
640
+ /**
641
+ * Send INTERACTIVE_PROMPT to AppSync after capturing dynamic options from tmux.
642
+ * Waits 500ms for Claude Code to render the prompt, then captures the terminal.
643
+ */
644
+ async sendInteractivePromptAsync(backendSessionId, payload, content) {
645
+ // Wait for hook to return and Claude Code to render the prompt
646
+ await new Promise(resolve => setTimeout(resolve, 500));
647
+ const tmuxSession = process.env.CODEVIBE_TMUX_SESSION;
648
+ const metadata = { ...(payload.metadata || {}) };
649
+ if (tmuxSession) {
650
+ try {
651
+ const { exec } = await Promise.resolve().then(() => __importStar(require('child_process')));
652
+ const execAsync = (cmd) => new Promise((resolve, reject) => {
653
+ exec(cmd, { timeout: 5000 }, (err, stdout) => {
654
+ if (err)
655
+ reject(err);
656
+ else
657
+ resolve({ stdout: stdout || '' });
658
+ });
659
+ });
660
+ const { stdout } = await execAsync(`tmux capture-pane -p -e -S -30 -t '${tmuxSession}'`);
661
+ // Log captured snapshot for debugging
662
+ const snapshotLines = stdout.split('\n');
663
+ logger_1.logger.info('tmux capture result', {
664
+ tmuxSession,
665
+ totalLines: snapshotLines.length,
666
+ lastLines: snapshotLines.slice(-15).map(l => l.replace(/\x1B[^m]*m/g, '').trim()).filter(Boolean),
667
+ });
668
+ const parsed = (0, codevibe_core_1.parseInteractivePrompt)(stdout);
669
+ if (parsed && parsed.options.length > 0) {
670
+ metadata.options = parsed.options;
671
+ metadata.submitMap = parsed.submitMap;
672
+ metadata.instructions = this.buildPromptInstructions(parsed);
673
+ logger_1.logger.info('Parsed dynamic options from tmux', {
674
+ optionCount: parsed.options.length,
675
+ kind: parsed.kind,
676
+ options: parsed.options,
677
+ });
678
+ }
679
+ else {
680
+ logger_1.logger.info('No dynamic options parsed from tmux, using fallback', {
681
+ parsedResult: parsed,
682
+ });
683
+ this.addFallbackOptions(metadata);
684
+ }
685
+ }
686
+ catch (e) {
687
+ logger_1.logger.warn('Failed to capture tmux pane for options', { error: e });
688
+ this.addFallbackOptions(metadata);
689
+ }
690
+ }
691
+ else {
692
+ logger_1.logger.warn('No tmux session — using fallback options');
693
+ this.addFallbackOptions(metadata);
694
+ }
695
+ // Store submitMap in session state for response translation
696
+ const sessionState = this.activeSessions.get(backendSessionId);
697
+ if (sessionState && metadata.submitMap) {
698
+ sessionState.pendingSubmitMap = metadata.submitMap;
699
+ }
700
+ // Encrypt and send to AppSync
701
+ let eventContent = content;
702
+ let eventMetadata = metadata;
703
+ let isEncrypted = false;
704
+ if (this.sessionKey) {
705
+ eventContent = codevibe_core_1.cryptoService.encryptContent(content, this.sessionKey);
706
+ const encryptedMeta = codevibe_core_1.cryptoService.encryptMetadata(eventMetadata, this.sessionKey);
707
+ eventMetadata = { encrypted: encryptedMeta };
708
+ isEncrypted = true;
709
+ }
710
+ await this.appSyncClient.createEvent({
711
+ sessionId: backendSessionId,
712
+ type: codevibe_core_1.EventType.INTERACTIVE_PROMPT,
713
+ source: payload.source,
714
+ content: eventContent,
715
+ metadata: eventMetadata,
716
+ promptId: payload.prompt_id,
717
+ isEncrypted: isEncrypted ? true : undefined,
718
+ });
719
+ logger_1.logger.info('Interactive prompt sent to AppSync with dynamic options', {
720
+ sessionId: backendSessionId,
721
+ });
722
+ }
723
+ addFallbackOptions(metadata) {
724
+ metadata.options = [
725
+ { number: '1', text: 'Yes' },
726
+ { number: '2', text: 'Yes, and don\'t ask again' },
727
+ { number: '3', text: 'Reject and tell Claude what to do differently' },
728
+ ];
729
+ metadata.submitMap = { '1': '1', '2': '2', '3': '3' };
730
+ metadata.instructions = 'Reply with 1, 2, or 3. Append a message to provide alternative instructions.';
731
+ }
732
+ buildPromptInstructions(parsed) {
733
+ const nums = parsed.options.map(o => o.number).join(', ');
734
+ return `Reply with ${nums}. Append a message to provide alternative instructions.`;
735
+ }
736
+ /**
737
+ * Parse mobile input when an interactive prompt is waiting.
738
+ * Dynamically supports any number of options (not hardcoded to 3).
739
+ *
740
+ * Input patterns:
741
+ * - "N" (exact number) → select that option
742
+ * - "N <text>" → select option N, then send <text> as follow-up prompt
743
+ * - anything else → send as free-form response
744
+ */
745
+ parseInteractivePromptInput(content, optionCount = 3) {
746
+ const trimmed = content.trim();
747
+ // Check for exact option number (e.g., "1", "2", "3")
748
+ const exactMatch = trimmed.match(/^(\d+)$/);
749
+ if (exactMatch) {
750
+ const num = parseInt(exactMatch[1]);
751
+ if (num >= 1 && num <= optionCount) {
752
+ return { action: 'select_option', option: exactMatch[1] };
753
+ }
754
+ }
755
+ // Check for "N <text>" or "N\n<text>" pattern (option + follow-up)
756
+ const withTextMatch = trimmed.match(/^(\d+)[\s\n]+(.+)$/s);
757
+ if (withTextMatch) {
758
+ const num = parseInt(withTextMatch[1]);
759
+ if (num >= 1 && num <= optionCount) {
760
+ return { action: 'option_with_followup', option: withTextMatch[1], followUpText: withTextMatch[2].trim() };
761
+ }
762
+ }
763
+ // Anything else — send as free-form response
764
+ return { action: 'send_as_response' };
765
+ }
766
+ /**
767
+ * Helper to mark an event as EXECUTED
768
+ */
769
+ async markEventExecuted(event) {
770
+ try {
771
+ await this.appSyncClient.updateEventStatus({
772
+ eventId: event.eventId,
773
+ sessionId: event.sessionId,
774
+ timestamp: event.timestamp,
775
+ deliveryStatus: codevibe_core_1.DeliveryStatus.EXECUTED,
776
+ });
777
+ logger_1.logger.info('Event marked as EXECUTED', { eventId: event.eventId });
778
+ }
779
+ catch (error) {
780
+ logger_1.logger.warn('Failed to mark event as EXECUTED', { eventId: event.eventId, error });
781
+ }
782
+ }
783
+ /**
784
+ * Helper to send error notification to mobile
785
+ */
786
+ async sendPromptError(sessionId, message) {
787
+ await this.appSyncClient.createEvent({
788
+ sessionId,
789
+ type: codevibe_core_1.EventType.NOTIFICATION,
790
+ source: codevibe_core_1.EventSource.DESKTOP,
791
+ content: message,
792
+ metadata: { error: true },
793
+ });
794
+ }
795
+ /**
796
+ * Check if error is a subscription limit exceeded error
797
+ */
798
+ isSessionLimitExceeded(error) {
799
+ const errorMessage = this.getErrorMessage(error);
800
+ return errorMessage.includes('SESSION_LIMIT_EXCEEDED');
801
+ }
802
+ /**
803
+ * Check if error is a usage limit exceeded error (message or image)
804
+ */
805
+ isUsageLimitExceeded(error) {
806
+ const errorMessage = this.getErrorMessage(error);
807
+ return errorMessage.includes('MESSAGE_LIMIT_EXCEEDED') || errorMessage.includes('IMAGE_LIMIT_EXCEEDED');
808
+ }
809
+ /**
810
+ * Extract error message from various error types
811
+ */
812
+ getErrorMessage(error) {
813
+ if (error instanceof Error) {
814
+ return error.message;
815
+ }
816
+ if (typeof error === 'object' && error !== null) {
817
+ const errorObj = error;
818
+ // Check for GraphQL error format
819
+ if (errorObj.errors && Array.isArray(errorObj.errors)) {
820
+ return errorObj.errors.map((e) => e.message || '').join(' ');
821
+ }
822
+ // Check for message property
823
+ if (typeof errorObj.message === 'string') {
824
+ return errorObj.message;
825
+ }
826
+ }
827
+ return String(error);
828
+ }
829
+ /**
830
+ * Display subscription limit error to user
831
+ */
832
+ displaySubscriptionLimitError(error, limitType) {
833
+ const errorMessage = this.getErrorMessage(error);
834
+ // Extract tier and limit info from error message if available
835
+ let tierInfo = '';
836
+ const tierMatch = errorMessage.match(/for your (\w+) plan/i);
837
+ if (tierMatch) {
838
+ tierInfo = ` (${tierMatch[1]} tier)`;
839
+ }
840
+ let limitInfo = '';
841
+ const limitMatch = errorMessage.match(/of (\d+)/);
842
+ if (limitMatch) {
843
+ limitInfo = ` [Limit: ${limitMatch[1]}]`;
844
+ }
845
+ // Display user-friendly console message with formatting
846
+ console.log('\n' + '='.repeat(60));
847
+ console.log('⚠️ SUBSCRIPTION LIMIT REACHED');
848
+ console.log('='.repeat(60));
849
+ switch (limitType) {
850
+ case 'session':
851
+ console.log(`You have reached the maximum number of active sessions${tierInfo}.`);
852
+ console.log(`${limitInfo}`);
853
+ console.log('\nTo continue, please:');
854
+ console.log(' • Close an existing Claude Code session, or');
855
+ console.log(' • Upgrade your subscription in the CodeVibe iOS app');
856
+ break;
857
+ case 'message':
858
+ console.log(`You have reached your monthly message limit${tierInfo}.`);
859
+ console.log(`${limitInfo}`);
860
+ console.log('\nTo continue, please:');
861
+ console.log(' • Wait until your usage resets next month, or');
862
+ console.log(' • Upgrade your subscription in the CodeVibe iOS app');
863
+ break;
864
+ case 'image':
865
+ console.log(`You have reached your monthly image attachment limit${tierInfo}.`);
866
+ console.log(`${limitInfo}`);
867
+ console.log('\nTo continue, please:');
868
+ console.log(' • Wait until your usage resets next month, or');
869
+ console.log(' • Upgrade your subscription in the CodeVibe iOS app');
870
+ break;
871
+ }
872
+ console.log('\nNote: You can still use Claude Code normally from your desktop.');
873
+ console.log('This limit only affects syncing with the mobile app.');
874
+ console.log('='.repeat(60) + '\n');
875
+ logger_1.logger.error('Subscription limit exceeded', { limitType, errorMessage });
876
+ }
877
+ /**
878
+ * Download an attachment from S3 and save it to a temp file
879
+ * If the attachment is encrypted, decrypts it using the session key
880
+ * Returns the local file path
881
+ * @param attachment The attachment to download
882
+ * @param sessionId The session ID for organizing temp files
883
+ * @param eventIsEncrypted Whether the parent event is encrypted (fallback for attachment.isEncrypted)
884
+ */
885
+ async downloadAttachment(attachment, sessionId, eventIsEncrypted) {
886
+ try {
887
+ // Use attachment.isEncrypted if available, otherwise fall back to event.isEncrypted
888
+ // This handles AppSync subscription limitation where nested object fields may be null
889
+ const shouldDecrypt = attachment.isEncrypted ?? eventIsEncrypted ?? false;
890
+ logger_1.logger.info('Downloading attachment - START', {
891
+ id: attachment.id,
892
+ type: attachment.type,
893
+ filename: attachment.filename,
894
+ s3Key: attachment.s3Key,
895
+ attachmentIsEncrypted: attachment.isEncrypted,
896
+ eventIsEncrypted,
897
+ shouldDecrypt,
898
+ hasSessionKey: !!this.sessionKey,
899
+ });
900
+ // Get pre-signed download URL
901
+ const { downloadUrl } = await this.appSyncClient.getAttachmentDownloadUrl(attachment.s3Key);
902
+ // Download the file
903
+ const response = await fetch(downloadUrl);
904
+ if (!response.ok) {
905
+ throw new Error(`Failed to download attachment: ${response.status} ${response.statusText}`);
906
+ }
907
+ let buffer = Buffer.from(await response.arrayBuffer());
908
+ logger_1.logger.info('Attachment downloaded', {
909
+ id: attachment.id,
910
+ downloadedSize: buffer.length,
911
+ first20Bytes: buffer.slice(0, 20).toString('hex'),
912
+ });
913
+ // Decrypt if encrypted
914
+ logger_1.logger.info('Checking decryption conditions', {
915
+ id: attachment.id,
916
+ shouldDecrypt,
917
+ hasSessionKey: !!this.sessionKey,
918
+ willDecrypt: !!(shouldDecrypt && this.sessionKey),
919
+ });
920
+ if (shouldDecrypt && this.sessionKey) {
921
+ try {
922
+ logger_1.logger.info('Decrypting attachment', { id: attachment.id, encryptedSize: buffer.length });
923
+ buffer = codevibe_core_1.cryptoService.decryptData(buffer, this.sessionKey);
924
+ logger_1.logger.info('Attachment decrypted successfully', {
925
+ id: attachment.id,
926
+ decryptedSize: buffer.length,
927
+ first20Bytes: buffer.slice(0, 20).toString('hex'),
928
+ });
929
+ }
930
+ catch (decryptError) {
931
+ logger_1.logger.error('Failed to decrypt attachment:', { id: attachment.id, error: decryptError });
932
+ throw new Error('Failed to decrypt attachment');
933
+ }
934
+ }
935
+ else if (shouldDecrypt && !this.sessionKey) {
936
+ logger_1.logger.warn('Cannot decrypt attachment - no session key available', { id: attachment.id });
937
+ // Continue with encrypted data - Claude Code won't be able to view it properly
938
+ }
939
+ else {
940
+ logger_1.logger.info('Skipping decryption - attachment not encrypted or no session key', {
941
+ id: attachment.id,
942
+ shouldDecrypt,
943
+ hasSessionKey: !!this.sessionKey,
944
+ });
945
+ }
946
+ // Create temp directory for this session if it doesn't exist
947
+ const tempDir = path.join(os.tmpdir(), 'codevibe-claude', sessionId);
948
+ if (!fs.existsSync(tempDir)) {
949
+ fs.mkdirSync(tempDir, { recursive: true });
950
+ }
951
+ // Determine file extension from MIME type or filename
952
+ let extension = '';
953
+ // If filename is encrypted, decrypt it
954
+ let actualFilename = attachment.filename;
955
+ if (shouldDecrypt && attachment.filename && this.sessionKey) {
956
+ try {
957
+ actualFilename = codevibe_core_1.cryptoService.decryptContent(attachment.filename, this.sessionKey);
958
+ }
959
+ catch {
960
+ // Filename decryption failed, use as-is (might be plaintext)
961
+ actualFilename = attachment.filename;
962
+ }
963
+ }
964
+ if (actualFilename) {
965
+ const ext = path.extname(actualFilename);
966
+ if (ext)
967
+ extension = ext;
968
+ }
969
+ if (!extension) {
970
+ // Fallback to MIME type
971
+ const mimeToExt = {
972
+ 'image/jpeg': '.jpg',
973
+ 'image/png': '.png',
974
+ 'image/gif': '.gif',
975
+ 'image/webp': '.webp',
976
+ 'image/heic': '.heic',
977
+ 'application/pdf': '.pdf',
978
+ };
979
+ extension = mimeToExt[attachment.type] || '.bin';
980
+ }
981
+ // Save to temp file
982
+ const filename = `attachment-${attachment.id}${extension}`;
983
+ const filePath = path.join(tempDir, filename);
984
+ fs.writeFileSync(filePath, buffer);
985
+ logger_1.logger.info('Attachment saved to temp file', {
986
+ id: attachment.id,
987
+ filePath,
988
+ size: buffer.length,
989
+ wasDecrypted: shouldDecrypt && !!this.sessionKey,
990
+ });
991
+ return filePath;
992
+ }
993
+ catch (error) {
994
+ logger_1.logger.error('Failed to download attachment:', { id: attachment.id, error });
995
+ return null;
996
+ }
997
+ }
998
+ /**
999
+ * Execute a prompt from mobile in the desktop Claude Code session
1000
+ * Uses tmux send-keys to send the prompt to the Claude Code session
1001
+ * If the event has attachments, downloads them and includes file paths in prompt
1002
+ */
1003
+ async executeMobilePrompt(sessionId, event) {
1004
+ let prompt = event.content || '';
1005
+ const attachments = event.attachments || [];
1006
+ logger_1.logger.info('Executing mobile prompt via tmux', {
1007
+ sessionId,
1008
+ promptLength: prompt.length,
1009
+ attachmentCount: attachments.length,
1010
+ });
1011
+ // Download attachments and build file paths to include in prompt
1012
+ const attachmentPaths = [];
1013
+ if (attachments.length > 0) {
1014
+ logger_1.logger.info('Downloading attachments for prompt', { count: attachments.length });
1015
+ for (const attachment of attachments) {
1016
+ const filePath = await this.downloadAttachment(attachment, sessionId, event.isEncrypted // Pass event encryption status as fallback
1017
+ );
1018
+ if (filePath) {
1019
+ attachmentPaths.push(filePath);
1020
+ }
1021
+ }
1022
+ // If we have downloaded files, prepend them to the prompt
1023
+ if (attachmentPaths.length > 0) {
1024
+ const fileReferences = attachmentPaths
1025
+ .map(p => `[Attached file: ${p}]`)
1026
+ .join('\n');
1027
+ // Format: file references first, then the user's message
1028
+ if (prompt) {
1029
+ prompt = `${fileReferences}\n\n${prompt}`;
1030
+ }
1031
+ else {
1032
+ // No text content, just file references with instruction
1033
+ prompt = `${fileReferences}\n\nPlease analyze the attached file(s).`;
1034
+ }
1035
+ logger_1.logger.info('Prompt updated with attachment paths', {
1036
+ attachmentCount: attachmentPaths.length,
1037
+ newPromptLength: prompt.length,
1038
+ });
1039
+ }
1040
+ }
1041
+ // Track this prompt to filter out the duplicate USER_PROMPT from the hook
1042
+ this.trackMobilePrompt(sessionId, prompt);
1043
+ try {
1044
+ // Use tmux send-keys to send the prompt (same as answering interactive prompts)
1045
+ const success = await this.promptResponder.answerInteractivePrompt(sessionId, prompt);
1046
+ if (success) {
1047
+ // Mark event as EXECUTED (fed into Claude Code) - double blue checkmark
1048
+ try {
1049
+ await this.appSyncClient.updateEventStatus({
1050
+ eventId: event.eventId,
1051
+ sessionId: event.sessionId,
1052
+ timestamp: event.timestamp,
1053
+ deliveryStatus: codevibe_core_1.DeliveryStatus.EXECUTED,
1054
+ });
1055
+ logger_1.logger.info('Event marked as EXECUTED', { eventId: event.eventId });
1056
+ }
1057
+ catch (error) {
1058
+ logger_1.logger.warn('Failed to mark event as EXECUTED', { eventId: event.eventId, error });
1059
+ }
1060
+ logger_1.logger.info('Mobile prompt sent successfully', { sessionId });
1061
+ // Send confirmation to mobile
1062
+ const confirmationMsg = attachmentPaths.length > 0
1063
+ ? `Prompt with ${attachmentPaths.length} attachment(s) sent to Claude Code`
1064
+ : `Prompt "${prompt.substring(0, 50)}${prompt.length > 50 ? '...' : ''}" sent to Claude Code`;
1065
+ await this.appSyncClient.createEvent({
1066
+ sessionId,
1067
+ type: codevibe_core_1.EventType.NOTIFICATION,
1068
+ source: codevibe_core_1.EventSource.DESKTOP,
1069
+ content: confirmationMsg,
1070
+ metadata: {
1071
+ mobilePrompt: true,
1072
+ attachmentCount: attachmentPaths.length,
1073
+ },
1074
+ });
1075
+ }
1076
+ else {
1077
+ logger_1.logger.error('Failed to send mobile prompt', { sessionId });
1078
+ // Send error notification to mobile
1079
+ await this.appSyncClient.createEvent({
1080
+ sessionId,
1081
+ type: codevibe_core_1.EventType.NOTIFICATION,
1082
+ source: codevibe_core_1.EventSource.DESKTOP,
1083
+ content: `Failed to send prompt to Claude Code`,
1084
+ metadata: {
1085
+ error: true,
1086
+ },
1087
+ });
1088
+ }
1089
+ }
1090
+ catch (error) {
1091
+ logger_1.logger.error('Failed to execute mobile prompt:', error);
1092
+ }
1093
+ }
1094
+ }
1095
+ McpServer.MOBILE_PROMPT_EXPIRY_MS = 3000; // 3 seconds
1096
+ // Main entry point
1097
+ async function main() {
1098
+ // Get session ID from command line argument or environment variable
1099
+ const sessionId = process.argv[2] || process.env.CLAUDE_SESSION_ID;
1100
+ if (sessionId) {
1101
+ logger_1.logger.info(`Starting MCP server for session: ${sessionId}`);
1102
+ }
1103
+ else {
1104
+ logger_1.logger.info('Starting MCP server without initial session ID (will be set on SessionStart)');
1105
+ }
1106
+ const server = new McpServer(sessionId);
1107
+ try {
1108
+ await server.start();
1109
+ // Output the assigned port for the session-start hook to capture
1110
+ const port = server.getPort();
1111
+ console.log(`PORT=${port}`);
1112
+ // Handle graceful shutdown
1113
+ let isShuttingDown = false;
1114
+ const shutdown = async (signal) => {
1115
+ if (isShuttingDown) {
1116
+ logger_1.logger.info('Shutdown already in progress, ignoring additional signal');
1117
+ return;
1118
+ }
1119
+ isShuttingDown = true;
1120
+ logger_1.logger.info(`Received ${signal} signal, stopping server...`);
1121
+ try {
1122
+ await server.stop();
1123
+ logger_1.logger.info('Graceful shutdown completed');
1124
+ process.exit(0);
1125
+ }
1126
+ catch (error) {
1127
+ logger_1.logger.error('Error during shutdown:', error);
1128
+ process.exit(1);
1129
+ }
1130
+ };
1131
+ process.on('SIGINT', () => shutdown('SIGINT'));
1132
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
1133
+ process.on('SIGHUP', () => shutdown('SIGHUP'));
1134
+ // Handle uncaught exceptions - try to cleanup before exit
1135
+ process.on('uncaughtException', async (error) => {
1136
+ logger_1.logger.error('Uncaught exception:', error);
1137
+ await shutdown('uncaughtException');
1138
+ });
1139
+ process.on('unhandledRejection', async (reason) => {
1140
+ logger_1.logger.error('Unhandled rejection:', reason);
1141
+ await shutdown('unhandledRejection');
1142
+ });
1143
+ }
1144
+ catch (error) {
1145
+ logger_1.logger.error('Failed to start MCP Server:', error);
1146
+ process.exit(1);
1147
+ }
1148
+ }
1149
+ // Start the server
1150
+ main().catch((error) => {
1151
+ logger_1.logger.error('Unhandled error in main:', error);
1152
+ process.exit(1);
1153
+ });
1154
+ //# sourceMappingURL=server.js.map