@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.
- package/.claude-plugin/plugin.json +22 -0
- package/.env.example +28 -0
- package/LICENSE +21 -0
- package/README.md +301 -0
- package/bin/claude-companion-setup +65 -0
- package/bin/codevibe-claude +134 -0
- package/dist/appsync-client.d.ts +67 -0
- package/dist/appsync-client.d.ts.map +1 -0
- package/dist/appsync-client.js +858 -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 +35 -0
- package/dist/http-api.d.ts.map +1 -0
- package/dist/http-api.js +334 -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 +1154 -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/types.d.ts +110 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +17 -0
- package/dist/types.js.map +1 -0
- package/hooks/common.sh +121 -0
- package/hooks/hooks.json +81 -0
- package/hooks/notification.sh +32 -0
- package/hooks/permission-request.sh +191 -0
- package/hooks/post-tool-use.sh +42 -0
- package/hooks/session-end.sh +57 -0
- package/hooks/session-start.sh +127 -0
- package/hooks/stop.sh +255 -0
- package/hooks/user-prompt.sh +32 -0
- 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
|