minivibe 0.1.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/vibe.js ADDED
@@ -0,0 +1,1621 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * vibe-cli - Claude Code wrapper with mobile remote control
5
+ *
6
+ * Usage:
7
+ * vibe "your prompt here" Start new session with prompt
8
+ * vibe --bridge ws://server:8080 Connect to bridge server (for internet access)
9
+ * vibe --resume <id> Resume existing session
10
+ * vibe Start interactive session
11
+ */
12
+
13
+ const { spawn, execSync, exec } = require('child_process');
14
+ const WebSocket = require('ws');
15
+ const { v4: uuidv4 } = require('uuid');
16
+ const fs = require('fs');
17
+ const path = require('path');
18
+ const os = require('os');
19
+ const http = require('http');
20
+
21
+ // Find claude executable
22
+ function findClaudePath() {
23
+ try {
24
+ // Use 'where' on Windows, 'which' on Unix
25
+ const cmd = os.platform() === 'win32' ? 'where claude' : 'which claude';
26
+ const result = execSync(cmd, { encoding: 'utf8' }).trim();
27
+ // 'where' on Windows can return multiple lines, take the first
28
+ return result.split('\n')[0].trim();
29
+ } catch {
30
+ // Fallback paths
31
+ if (os.platform() === 'win32') {
32
+ // Common Windows install locations
33
+ const userPath = path.join(os.homedir(), 'AppData', 'Local', 'Programs', 'claude', 'claude.exe');
34
+ if (fs.existsSync(userPath)) return userPath;
35
+ return 'claude'; // Hope it's in PATH
36
+ }
37
+ return '/opt/homebrew/bin/claude';
38
+ }
39
+ }
40
+
41
+ // Token storage
42
+ const TOKEN_FILE = path.join(os.homedir(), '.vibe', 'token');
43
+ const AUTH_FILE = path.join(os.homedir(), '.vibe', 'auth.json');
44
+ const LOGIN_HTML = path.join(__dirname, 'login.html');
45
+
46
+ // Firebase web config (public - safe to embed in client code)
47
+ // Update these values from: Firebase Console → Project Settings → Web App
48
+ const FIREBASE_CONFIG = {
49
+ apiKey: "AIzaSyAJKYavMidKYxRpfhP2IHUiy8dafc3ISqc",
50
+ authDomain: "minivibe-adaf4.firebaseapp.com",
51
+ projectId: "minivibe-adaf4",
52
+ storageBucket: "minivibe-adaf4.firebasestorage.app",
53
+ messagingSenderId: "11868121436",
54
+ appId: "1:11868121436:web:fa1a4941e6b222bc59b999",
55
+ measurementId: "G-29YEJLRVDS"
56
+ };
57
+
58
+ // Discover local vibe-agent
59
+ const AGENT_PORT_FILE = path.join(os.homedir(), '.vibe-agent', 'port');
60
+ const DEFAULT_AGENT_URL = 'ws://localhost:9999';
61
+
62
+ function discoverLocalAgent() {
63
+ // Try to read port file from vibe-agent
64
+ try {
65
+ if (fs.existsSync(AGENT_PORT_FILE)) {
66
+ const port = fs.readFileSync(AGENT_PORT_FILE, 'utf8').trim();
67
+ return `ws://localhost:${port}`;
68
+ }
69
+ } catch (err) {
70
+ // Ignore
71
+ }
72
+ // Fall back to default
73
+ return DEFAULT_AGENT_URL;
74
+ }
75
+
76
+ // Get stored auth data (token + refresh token)
77
+ function getStoredAuth() {
78
+ try {
79
+ // Try new JSON format first
80
+ if (fs.existsSync(AUTH_FILE)) {
81
+ const data = JSON.parse(fs.readFileSync(AUTH_FILE, 'utf8'));
82
+ return data;
83
+ }
84
+ // Fall back to old token-only format
85
+ if (fs.existsSync(TOKEN_FILE)) {
86
+ return { idToken: fs.readFileSync(TOKEN_FILE, 'utf8').trim() };
87
+ }
88
+ } catch (err) {
89
+ // Ignore
90
+ }
91
+ return null;
92
+ }
93
+
94
+ function getStoredToken() {
95
+ const auth = getStoredAuth();
96
+ return auth?.idToken || null;
97
+ }
98
+
99
+ // Store auth data (token + refresh token)
100
+ function storeAuth(idToken, refreshToken = null) {
101
+ try {
102
+ const dir = path.dirname(AUTH_FILE);
103
+ if (!fs.existsSync(dir)) {
104
+ fs.mkdirSync(dir, { recursive: true });
105
+ }
106
+ const data = { idToken, refreshToken, updatedAt: new Date().toISOString() };
107
+ fs.writeFileSync(AUTH_FILE, JSON.stringify(data, null, 2), 'utf8');
108
+ fs.chmodSync(AUTH_FILE, 0o600); // Only user can read
109
+ // Also write to old token file for backwards compatibility
110
+ fs.writeFileSync(TOKEN_FILE, idToken, 'utf8');
111
+ fs.chmodSync(TOKEN_FILE, 0o600);
112
+ return true;
113
+ } catch (err) {
114
+ console.error(`Failed to store auth: ${err.message}`);
115
+ return false;
116
+ }
117
+ }
118
+
119
+ // Legacy function for backwards compatibility
120
+ function storeToken(token) {
121
+ return storeAuth(token, null);
122
+ }
123
+
124
+ // Refresh the ID token using Firebase REST API
125
+ async function refreshIdToken() {
126
+ const auth = getStoredAuth();
127
+ if (!auth?.refreshToken) {
128
+ logStatus('No refresh token available');
129
+ return null;
130
+ }
131
+
132
+ try {
133
+ logStatus('Refreshing authentication token...');
134
+ const response = await fetch(
135
+ `https://securetoken.googleapis.com/v1/token?key=${FIREBASE_CONFIG.apiKey}`,
136
+ {
137
+ method: 'POST',
138
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
139
+ body: `grant_type=refresh_token&refresh_token=${auth.refreshToken}`
140
+ }
141
+ );
142
+
143
+ if (!response.ok) {
144
+ const error = await response.json();
145
+ logStatus(`Token refresh failed: ${error.error?.message || response.status}`);
146
+ return null;
147
+ }
148
+
149
+ const data = await response.json();
150
+ // Store new tokens
151
+ storeAuth(data.id_token, data.refresh_token);
152
+ log('✅ Token refreshed successfully', colors.green);
153
+ return data.id_token;
154
+ } catch (err) {
155
+ logStatus(`Token refresh error: ${err.message}`);
156
+ return null;
157
+ }
158
+ }
159
+
160
+ // Browser-based login
161
+ function startLoginFlow() {
162
+ // Check if Firebase config is set up
163
+ if (FIREBASE_CONFIG.apiKey === "YOUR_API_KEY") {
164
+ console.log(`
165
+ Firebase not configured.
166
+
167
+ Edit vibe.js and update FIREBASE_CONFIG with your Firebase web app values:
168
+
169
+ const FIREBASE_CONFIG = {
170
+ apiKey: "your-api-key",
171
+ authDomain: "your-project.firebaseapp.com",
172
+ projectId: "your-project-id"
173
+ };
174
+
175
+ Get these values from:
176
+ 1. Firebase Console → Project Settings → General
177
+ 2. Scroll to "Your apps" → Web app → Config
178
+ `);
179
+ process.exit(1);
180
+ }
181
+
182
+ const firebaseConfig = FIREBASE_CONFIG;
183
+
184
+ if (!fs.existsSync(LOGIN_HTML)) {
185
+ console.error(`Login page not found: ${LOGIN_HTML}`);
186
+ console.error('Make sure login.html is in the vibe-cli directory.');
187
+ process.exit(1);
188
+ }
189
+
190
+ const loginHtml = fs.readFileSync(LOGIN_HTML, 'utf8');
191
+
192
+ // Inject Firebase config into HTML
193
+ const htmlWithConfig = loginHtml.replace(
194
+ 'window.FIREBASE_CONFIG',
195
+ `window.FIREBASE_CONFIG = ${JSON.stringify(firebaseConfig)}`
196
+ );
197
+
198
+ const PORT = 9876;
199
+ let server;
200
+
201
+ server = http.createServer((req, res) => {
202
+ if (req.method === 'GET' && req.url === '/') {
203
+ res.writeHead(200, { 'Content-Type': 'text/html' });
204
+ res.end(htmlWithConfig);
205
+ } else if (req.method === 'POST' && req.url === '/callback') {
206
+ let body = '';
207
+ req.on('data', chunk => body += chunk);
208
+ req.on('end', () => {
209
+ try {
210
+ const { token, refreshToken, email } = JSON.parse(body);
211
+ if (token) {
212
+ storeAuth(token, refreshToken || null);
213
+ console.log(`\n✅ Logged in as ${email}`);
214
+ console.log(` Auth saved to ${AUTH_FILE}`);
215
+ if (refreshToken) {
216
+ console.log(` Token auto-refresh enabled`);
217
+ }
218
+ res.writeHead(200, { 'Content-Type': 'application/json' });
219
+ res.end(JSON.stringify({ success: true }));
220
+
221
+ // Close server after short delay
222
+ setTimeout(() => {
223
+ server.close();
224
+ process.exit(0);
225
+ }, 500);
226
+ } else {
227
+ res.writeHead(400, { 'Content-Type': 'application/json' });
228
+ res.end(JSON.stringify({ error: 'No token provided' }));
229
+ }
230
+ } catch (err) {
231
+ res.writeHead(400, { 'Content-Type': 'application/json' });
232
+ res.end(JSON.stringify({ error: err.message }));
233
+ }
234
+ });
235
+ } else {
236
+ res.writeHead(404);
237
+ res.end('Not found');
238
+ }
239
+ });
240
+
241
+ server.listen(PORT, () => {
242
+ const url = `http://localhost:${PORT}`;
243
+ console.log(`\nOpening browser for login...`);
244
+ console.log(`If browser doesn't open, visit: ${url}`);
245
+ console.log(`\nPress Ctrl+C to cancel.\n`);
246
+
247
+ // Open browser based on OS
248
+ // Windows 'start' needs empty title ("") before URL
249
+ if (process.platform === 'win32') {
250
+ exec(`start "" "${url}"`);
251
+ } else {
252
+ const openCmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
253
+ exec(`${openCmd} ${url}`);
254
+ }
255
+
256
+ // Timeout after 5 minutes
257
+ setTimeout(() => {
258
+ console.log('\nLogin timed out. Please try again.');
259
+ server.close();
260
+ process.exit(1);
261
+ }, 5 * 60 * 1000);
262
+ });
263
+
264
+ server.on('error', (err) => {
265
+ if (err.code === 'EADDRINUSE') {
266
+ console.error(`Port ${PORT} is in use. Close other applications and try again.`);
267
+ } else {
268
+ console.error('Server error:', err.message);
269
+ }
270
+ process.exit(1);
271
+ });
272
+ }
273
+
274
+ // Default bridge URL for headless login
275
+ const DEFAULT_BRIDGE_URL = 'wss://ws.neng.ai';
276
+
277
+ // Headless device code login flow
278
+ async function startHeadlessLogin(bridgeHttpUrl) {
279
+ console.log(`
280
+ 🔐 Headless Login
281
+ ══════════════════════════════════════
282
+ `);
283
+
284
+ try {
285
+ // Request a device code from the bridge server
286
+ console.log('Requesting device code...');
287
+ const codeRes = await fetch(`${bridgeHttpUrl}/device/code`, {
288
+ method: 'POST',
289
+ headers: { 'Content-Type': 'application/json' }
290
+ });
291
+
292
+ if (!codeRes.ok) {
293
+ console.error(`Failed to get device code: ${codeRes.status}`);
294
+ process.exit(1);
295
+ }
296
+
297
+ const { deviceId, code, expiresIn } = await codeRes.json();
298
+
299
+ console.log(` Visit: ${bridgeHttpUrl}/device`);
300
+ console.log(` Code: ${code}`);
301
+ console.log('');
302
+ console.log(` Code expires in ${Math.floor(expiresIn / 60)} minutes.`);
303
+ console.log(' Waiting for authentication...');
304
+ console.log('');
305
+ console.log(' Press Ctrl+C to cancel.');
306
+ console.log('');
307
+
308
+ // Poll for token
309
+ const pollInterval = 3000; // 3 seconds
310
+ const maxAttempts = Math.ceil((expiresIn * 1000) / pollInterval);
311
+ let attempts = 0;
312
+ let consecutiveErrors = 0;
313
+ const maxConsecutiveErrors = 5;
314
+
315
+ while (attempts < maxAttempts) {
316
+ await new Promise(resolve => setTimeout(resolve, pollInterval));
317
+ attempts++;
318
+
319
+ try {
320
+ const pollRes = await fetch(`${bridgeHttpUrl}/device/poll/${deviceId}`);
321
+
322
+ // Handle non-JSON responses gracefully
323
+ const contentType = pollRes.headers.get('content-type') || '';
324
+ if (!contentType.includes('application/json')) {
325
+ consecutiveErrors++;
326
+ if (consecutiveErrors >= maxConsecutiveErrors) {
327
+ console.error(`\nServer error: Invalid response format`);
328
+ process.exit(1);
329
+ }
330
+ process.stdout.write('!');
331
+ continue;
332
+ }
333
+
334
+ const pollData = await pollRes.json();
335
+ consecutiveErrors = 0; // Reset on successful response
336
+
337
+ if (pollData.status === 'complete') {
338
+ console.log(''); // New line after dots
339
+ storeAuth(pollData.token, pollData.refreshToken || null);
340
+ console.log(`✅ Logged in as ${pollData.email}`);
341
+ console.log(` Auth saved to ${AUTH_FILE}`);
342
+ if (pollData.refreshToken) {
343
+ console.log(` Token auto-refresh enabled`);
344
+ }
345
+ process.exit(0);
346
+ } else if (pollRes.status === 404 || pollData.error === 'Device not found or expired') {
347
+ console.log('\n\nCode expired. Please try again.');
348
+ process.exit(1);
349
+ } else if (pollData.error) {
350
+ console.error(`\nError: ${pollData.error}`);
351
+ process.exit(1);
352
+ }
353
+ // Still pending, continue polling
354
+ process.stdout.write('.');
355
+ } catch (err) {
356
+ consecutiveErrors++;
357
+ if (consecutiveErrors >= maxConsecutiveErrors) {
358
+ console.error(`\nNetwork error: ${err.message}`);
359
+ console.error('Please check your internet connection and try again.');
360
+ process.exit(1);
361
+ }
362
+ // Temporary network error - show warning but continue
363
+ process.stdout.write('!');
364
+ }
365
+ }
366
+
367
+ console.log('\n\nLogin timed out. Please try again.');
368
+ process.exit(1);
369
+ } catch (err) {
370
+ console.error(`Failed to start headless login: ${err.message}`);
371
+ process.exit(1);
372
+ }
373
+ }
374
+
375
+ // Parse arguments
376
+ const args = process.argv.slice(2);
377
+ let initialPrompt = null;
378
+ let resumeSessionId = null;
379
+ let bridgeUrl = null;
380
+ let agentUrl = null; // Connect via local vibe-agent
381
+ let authToken = null;
382
+ let headlessMode = false;
383
+ let sessionName = null;
384
+ let useNodePty = os.platform() === 'win32'; // Auto-detect Windows, can be overridden
385
+
386
+ for (let i = 0; i < args.length; i++) {
387
+ if (args[i] === '--help' || args[i] === '-h') {
388
+ console.log(`
389
+ vibe-cli - Claude Code wrapper with mobile remote control
390
+
391
+ Usage:
392
+ vibe "your prompt here" Start new session with prompt
393
+ vibe --bridge ws://server:8080 Connect to bridge server
394
+ vibe --agent Connect via local vibe-agent (managed mode)
395
+ vibe --resume <id> Resume existing session
396
+ vibe --login Sign in with Google (browser)
397
+ vibe --login --headless Sign in on headless server (EC2, etc.)
398
+ vibe Start interactive session
399
+
400
+ Options:
401
+ --bridge <url> Connect to bridge server (enables internet access)
402
+ --agent [url] Connect via local vibe-agent (default: ws://localhost:9999)
403
+ --name <name> Name this session (shown in mobile app)
404
+ --resume <id> Resume a previous session by ID
405
+ --login Sign in with Google in browser
406
+ --headless Use device code flow for headless environments
407
+ --token <token> Set Firebase auth token manually
408
+ --logout Remove stored auth token
409
+ --node-pty Use Node.js PTY wrapper (required for Windows, optional for Unix)
410
+ --help, -h Show this help message
411
+
412
+ In-Session Commands:
413
+ /name <name> Rename the current session
414
+
415
+ Authentication:
416
+ Use --login to sign in via browser, or get token from MiniVibe iOS app.
417
+ Use --login --headless on servers without a browser (EC2, etc.)
418
+
419
+ Examples:
420
+ vibe --login Sign in (browser)
421
+ vibe --login --headless Sign in (headless)
422
+ vibe --bridge wss://ws.neng.ai Connect to bridge
423
+ vibe --agent Connect via local agent
424
+ vibe --bridge wss://ws.neng.ai "Fix bug" With initial prompt
425
+ `);
426
+ process.exit(0);
427
+ } else if (args[i] === '--headless') {
428
+ headlessMode = true;
429
+ } else if (args[i] === '--login') {
430
+ // Defer login handling until we know if --headless is also present
431
+ // Will be handled after the loop
432
+ } else if (args[i] === '--bridge' && args[i + 1]) {
433
+ bridgeUrl = args[i + 1];
434
+ i++;
435
+ } else if (args[i] === '--agent') {
436
+ // Check if next arg is a URL or another flag
437
+ if (args[i + 1] && !args[i + 1].startsWith('--')) {
438
+ agentUrl = args[i + 1];
439
+ i++;
440
+ } else {
441
+ // Auto-discover or use default
442
+ agentUrl = discoverLocalAgent();
443
+ }
444
+ } else if (args[i] === '--name' && args[i + 1]) {
445
+ sessionName = args[i + 1];
446
+ i++;
447
+ } else if (args[i] === '--resume' && args[i + 1]) {
448
+ resumeSessionId = args[i + 1];
449
+ i++;
450
+ } else if (args[i] === '--token' && args[i + 1]) {
451
+ authToken = args[i + 1];
452
+ storeToken(authToken);
453
+ console.log('Token stored successfully');
454
+ i++;
455
+ } else if (args[i] === '--logout') {
456
+ try {
457
+ if (fs.existsSync(TOKEN_FILE)) {
458
+ fs.unlinkSync(TOKEN_FILE);
459
+ console.log('Logged out successfully');
460
+ } else {
461
+ console.log('Not logged in');
462
+ }
463
+ } catch (err) {
464
+ console.error('Logout failed:', err.message);
465
+ }
466
+ process.exit(0);
467
+ } else if (args[i] === '--node-pty') {
468
+ useNodePty = true;
469
+ } else if (!args[i].startsWith('--')) {
470
+ initialPrompt = args[i];
471
+ }
472
+ }
473
+
474
+ // Handle login after all args are parsed (so we know if --headless was set)
475
+ // Login mode is exclusive - don't start Claude
476
+ let loginMode = false;
477
+ if (args.includes('--login')) {
478
+ loginMode = true;
479
+ if (headlessMode) {
480
+ // Convert WebSocket URL to HTTP(S) URL for API calls
481
+ let httpUrl = bridgeUrl || DEFAULT_BRIDGE_URL;
482
+ httpUrl = httpUrl.replace('wss://', 'https://').replace('ws://', 'http://');
483
+ startHeadlessLogin(httpUrl);
484
+ // startHeadlessLogin is async and exits on its own
485
+ } else {
486
+ startLoginFlow();
487
+ // startLoginFlow starts a server and exits on callback
488
+ }
489
+ }
490
+
491
+ // Load stored token if not provided
492
+ if (!authToken) {
493
+ authToken = getStoredToken();
494
+ }
495
+
496
+ // Session state
497
+ const sessionId = resumeSessionId || uuidv4();
498
+ let claudeProcess = null;
499
+ let isRunning = false;
500
+ let isShuttingDown = false;
501
+ let bridgeSocket = null;
502
+ let reconnectTimer = null;
503
+ let sessionFileWatcher = null;
504
+ let lastFileSize = 0;
505
+ let heartbeatTimer = null;
506
+ let isAuthenticated = false;
507
+ let pendingPermission = null; // Track pending permission request { id, command, timestamp }
508
+ let lastApprovalTime = 0; // Debounce rapid approvals
509
+ const completedToolIds = new Set(); // Track tool_use IDs that have tool_result (already executed)
510
+ const MAX_COMPLETED_TOOLS = 500; // Limit Set size to prevent memory issues in long sessions
511
+ let lastCapturedPrompt = null; // Last permission prompt captured from CLI output
512
+ const mobileMessageHashes = new Set(); // Track messages from mobile to avoid duplicate echo
513
+ const MAX_MOBILE_MESSAGES = 100; // Limit Set size
514
+
515
+ // Colors for terminal output
516
+ const colors = {
517
+ reset: '\x1b[0m',
518
+ bright: '\x1b[1m',
519
+ dim: '\x1b[2m',
520
+ green: '\x1b[32m',
521
+ yellow: '\x1b[33m',
522
+ blue: '\x1b[34m',
523
+ magenta: '\x1b[35m',
524
+ cyan: '\x1b[36m',
525
+ };
526
+
527
+ function log(msg, color = '') {
528
+ console.log(`${color}${msg}${colors.reset}`);
529
+ }
530
+
531
+ function logStatus(msg) {
532
+ log(`[vibe] ${msg}`, colors.dim);
533
+ }
534
+
535
+ // Get Claude session file path
536
+ // Claude uses path with '/' replaced by '-' (not base64)
537
+ function getSessionFilePath() {
538
+ const projectPathHash = process.cwd().replace(/\//g, '-');
539
+ const claudeDir = path.join(os.homedir(), '.claude', 'projects', projectPathHash);
540
+ return path.join(claudeDir, `${sessionId}.jsonl`);
541
+ }
542
+
543
+ // Get local IP
544
+ function getLocalIP() {
545
+ const interfaces = os.networkInterfaces();
546
+ for (const name of Object.keys(interfaces)) {
547
+ for (const iface of interfaces[name]) {
548
+ if (iface.family === 'IPv4' && !iface.internal) {
549
+ return iface.address;
550
+ }
551
+ }
552
+ }
553
+ return 'localhost';
554
+ }
555
+
556
+ // Safe write to Claude's stdin
557
+ function safeStdinWrite(data) {
558
+ if (!claudeProcess || !isRunning || isShuttingDown) {
559
+ return false;
560
+ }
561
+ try {
562
+ if (claudeProcess.stdin && claudeProcess.stdin.writable) {
563
+ claudeProcess.stdin.write(data);
564
+ return true;
565
+ }
566
+ return false;
567
+ } catch (err) {
568
+ logStatus(`Failed to write to Claude: ${err.message}`);
569
+ return false;
570
+ }
571
+ }
572
+
573
+ // Send message to Claude (from bridge)
574
+ function sendToClaude(content, source = 'bridge') {
575
+ if (!claudeProcess || !isRunning || isShuttingDown) {
576
+ log('Claude is not running', colors.yellow);
577
+ return false;
578
+ }
579
+
580
+ logStatus(`Sending message: "${content}"`);
581
+
582
+ // Send text first
583
+ const textBuffer = Buffer.from(content, 'utf8');
584
+ if (!safeStdinWrite(textBuffer)) {
585
+ return false;
586
+ }
587
+
588
+ // Send Enter key separately after a short delay
589
+ // This mimics how a real keyboard sends input
590
+ setTimeout(() => {
591
+ if (!claudeProcess || !isRunning || isShuttingDown) return;
592
+
593
+ const enterBuffer = Buffer.from('\r', 'utf8');
594
+ logStatus(`Sending Enter (\\r = 0x0d)`);
595
+ safeStdinWrite(enterBuffer);
596
+ }, 100);
597
+
598
+ if (source === 'mobile') {
599
+ log(`📱 [mobile]: ${content}`, colors.cyan);
600
+ }
601
+
602
+ return true;
603
+ }
604
+
605
+ // ====================
606
+ // Bridge Connection
607
+ // ====================
608
+
609
+ function connectToBridge() {
610
+ // Use agentUrl if set, otherwise bridgeUrl
611
+ const targetUrl = agentUrl || bridgeUrl;
612
+ if (!targetUrl) return;
613
+
614
+ const isAgentMode = !!agentUrl;
615
+
616
+ // Check for auth token when bridge is enabled (not needed for agent mode)
617
+ if (!isAgentMode && !authToken) {
618
+ log('⚠️ No authentication token found.', colors.yellow);
619
+ log(' Use --token <token> to set your Firebase token.', colors.dim);
620
+ log(' Get your token from the MiniVibe mobile app.', colors.dim);
621
+ log('', '');
622
+ log(' Continuing without authentication (bridge may reject connection)', colors.dim);
623
+ }
624
+
625
+ if (isAgentMode) {
626
+ logStatus(`Connecting to local agent: ${targetUrl}`);
627
+ } else {
628
+ logStatus(`Connecting to bridge: ${targetUrl}`);
629
+ }
630
+ isAuthenticated = false;
631
+
632
+ bridgeSocket = new WebSocket(targetUrl);
633
+
634
+ bridgeSocket.on('open', () => {
635
+ logStatus('Connected to bridge server');
636
+
637
+ // Authenticate first
638
+ if (authToken) {
639
+ logStatus('Sending authentication...');
640
+ bridgeSocket.send(JSON.stringify({
641
+ type: 'authenticate',
642
+ token: authToken
643
+ }));
644
+ } else {
645
+ // No token - send a placeholder (will work if bridge is in dev mode)
646
+ bridgeSocket.send(JSON.stringify({
647
+ type: 'authenticate',
648
+ token: 'dev-mode'
649
+ }));
650
+ }
651
+
652
+ // Start heartbeat to keep connection alive
653
+ if (heartbeatTimer) clearInterval(heartbeatTimer);
654
+ heartbeatTimer = setInterval(() => {
655
+ if (bridgeSocket && bridgeSocket.readyState === WebSocket.OPEN) {
656
+ bridgeSocket.ping();
657
+ }
658
+ }, 30000); // Ping every 30 seconds
659
+ });
660
+
661
+ bridgeSocket.on('message', (raw) => {
662
+ try {
663
+ const msg = JSON.parse(raw.toString());
664
+ handleBridgeMessage(msg);
665
+ } catch (err) {
666
+ logStatus(`Invalid bridge message: ${err.message}`);
667
+ }
668
+ });
669
+
670
+ bridgeSocket.on('close', () => {
671
+ logStatus('Disconnected from bridge');
672
+ bridgeSocket = null;
673
+
674
+ // Stop heartbeat
675
+ if (heartbeatTimer) {
676
+ clearInterval(heartbeatTimer);
677
+ heartbeatTimer = null;
678
+ }
679
+
680
+ // Reconnect if not shutting down
681
+ if (!isShuttingDown) {
682
+ logStatus('Reconnecting in 3 seconds...');
683
+ reconnectTimer = setTimeout(connectToBridge, 3000);
684
+ }
685
+ });
686
+
687
+ bridgeSocket.on('error', (err) => {
688
+ logStatus(`Bridge connection error: ${err.message}`);
689
+ });
690
+ }
691
+
692
+ function handleBridgeMessage(msg) {
693
+ switch (msg.type) {
694
+ case 'authenticated':
695
+ isAuthenticated = true;
696
+ logStatus(`Authenticated as ${msg.userId} (${msg.email || 'no email'})`);
697
+ log('✅ Authenticated with bridge server', colors.green);
698
+
699
+ // Now register our session
700
+ bridgeSocket.send(JSON.stringify({
701
+ type: 'register_session',
702
+ sessionId,
703
+ path: process.cwd(),
704
+ name: sessionName || path.basename(process.cwd()) // Use --name or fallback to directory name
705
+ }));
706
+ break;
707
+
708
+ case 'auth_error':
709
+ isAuthenticated = false;
710
+ log(`⚠️ Authentication failed: ${msg.message}`, colors.yellow);
711
+
712
+ // Try to refresh the token
713
+ (async () => {
714
+ const newToken = await refreshIdToken();
715
+ if (newToken) {
716
+ authToken = newToken;
717
+ // Re-authenticate with new token
718
+ if (bridgeSocket && bridgeSocket.readyState === WebSocket.OPEN) {
719
+ bridgeSocket.send(JSON.stringify({
720
+ type: 'authenticate',
721
+ token: newToken
722
+ }));
723
+ }
724
+ } else {
725
+ log('❌ Token refresh failed. Please re-login:', colors.yellow);
726
+ log(' vibe --login', colors.dim);
727
+ }
728
+ })();
729
+ break;
730
+
731
+ case 'session_registered':
732
+ logStatus(`Session registered with bridge: ${msg.sessionId.slice(0, 8)}...`);
733
+ break;
734
+
735
+ case 'send_message':
736
+ // Mobile sent a message - forward to Claude
737
+ // Track it so we don't echo it back (bridge already echoed to iOS)
738
+ if (msg.content) {
739
+ // Store hash of message content to detect it in session file later
740
+ const hash = msg.content.trim().toLowerCase();
741
+ if (mobileMessageHashes.size >= MAX_MOBILE_MESSAGES) {
742
+ // Remove oldest (first) entry
743
+ const first = mobileMessageHashes.values().next().value;
744
+ mobileMessageHashes.delete(first);
745
+ }
746
+ mobileMessageHashes.add(hash);
747
+ sendToClaude(msg.content, msg.source || 'mobile');
748
+ }
749
+ break;
750
+
751
+ case 'approve_permission':
752
+ case 'approve_permission_always':
753
+ // Claude Code shows numbered selection: 1=Yes, 2=Yes don't ask again, 3=Custom
754
+ // approve_permission sends '1', approve_permission_always sends '2'
755
+ // Can also use msg.option to specify: 1, 2, or 3
756
+ {
757
+ const now = Date.now();
758
+ // Debounce: ignore if less than 500ms since last approval
759
+ if (now - lastApprovalTime < 500) {
760
+ log('📱 Permission approval debounced (too fast)', colors.dim);
761
+ break;
762
+ }
763
+ // Check if there's a pending permission
764
+ if (!pendingPermission) {
765
+ log('📱 No pending permission to approve', colors.yellow);
766
+ break;
767
+ }
768
+ // Determine which option to send
769
+ let option = '1';
770
+ let optionLabel = 'Yes';
771
+ let customText = null;
772
+
773
+ if (msg.type === 'approve_permission_always' || msg.option === 2) {
774
+ option = '2';
775
+ optionLabel = "Yes, don't ask again";
776
+ } else if (msg.option === 3) {
777
+ // Option 3: custom text
778
+ if (msg.customText && typeof msg.customText === 'string' && msg.customText.trim()) {
779
+ option = '3';
780
+ optionLabel = 'Custom';
781
+ // Sanitize: remove newlines, limit length
782
+ customText = msg.customText.replace(/[\r\n]+/g, ' ').trim().slice(0, 500);
783
+ } else {
784
+ log('📱 Option 3 requires customText, falling back to option 1', colors.yellow);
785
+ }
786
+ } else if (msg.option && msg.option !== 1) {
787
+ log(`📱 Invalid option ${msg.option}, using option 1`, colors.yellow);
788
+ }
789
+
790
+ // Send the option number first, then Enter separately (like sendToClaude)
791
+ // Claude's ink UI needs them sent separately to register the selection
792
+ const optionBuffer = Buffer.from(option, 'utf8');
793
+ if (safeStdinWrite(optionBuffer)) {
794
+ log(`📱 Permission approved (${option}: ${optionLabel}): ${pendingPermission.command}`, colors.green);
795
+ lastApprovalTime = now;
796
+ const savedCustomText = customText; // Save for closure
797
+ pendingPermission = null;
798
+
799
+ // Send Enter after a short delay
800
+ setTimeout(() => {
801
+ // Guard: check if process is still running
802
+ if (!claudeProcess || !isRunning || isShuttingDown) return;
803
+
804
+ const enterBuffer = Buffer.from('\r', 'utf8');
805
+ safeStdinWrite(enterBuffer);
806
+ logStatus('Sent Enter for permission confirmation');
807
+
808
+ // For option 3, send custom text after another delay
809
+ if (option === '3' && savedCustomText) {
810
+ setTimeout(() => {
811
+ // Guard: check if process is still running
812
+ if (!claudeProcess || !isRunning || isShuttingDown) return;
813
+
814
+ // Send custom text
815
+ const textBuffer = Buffer.from(savedCustomText, 'utf8');
816
+ safeStdinWrite(textBuffer);
817
+ // Then Enter
818
+ setTimeout(() => {
819
+ // Guard: check if process is still running
820
+ if (!claudeProcess || !isRunning || isShuttingDown) return;
821
+
822
+ const enterBuffer2 = Buffer.from('\r', 'utf8');
823
+ if (safeStdinWrite(enterBuffer2)) {
824
+ log(`📱 Sent custom text: "${savedCustomText.slice(0, 50)}${savedCustomText.length > 50 ? '...' : ''}"`, colors.dim);
825
+ }
826
+ }, 100);
827
+ }, 150);
828
+ }
829
+ }, 100);
830
+
831
+ sendToBridge({ type: 'session_status', status: 'active' });
832
+ }
833
+ }
834
+ break;
835
+
836
+ case 'deny_permission':
837
+ // Send Ctrl+C to cancel the permission prompt (more reliable than Escape)
838
+ {
839
+ if (!pendingPermission) {
840
+ log('📱 No pending permission to deny', colors.yellow);
841
+ break;
842
+ }
843
+ // Try Escape first, then Ctrl+C as fallback
844
+ if (safeStdinWrite('\x1b') || safeStdinWrite('\x03')) {
845
+ log(`📱 Permission denied: ${pendingPermission.command}`, colors.yellow);
846
+ pendingPermission = null;
847
+ sendToBridge({ type: 'session_status', status: 'active' });
848
+ }
849
+ }
850
+ break;
851
+
852
+ case 'session_renamed':
853
+ // Update local session name (could be from our rename or from iOS rename)
854
+ if (msg.name) {
855
+ sessionName = msg.name;
856
+ log(`📝 Session name updated: ${msg.name}`, colors.cyan);
857
+ }
858
+ break;
859
+
860
+ case 'error':
861
+ logStatus(`Bridge error: ${msg.message}`);
862
+ break;
863
+
864
+ // Agent-mode messages (when connected via --agent)
865
+ case 'bridge_disconnected':
866
+ log('⚠️ Bridge connection lost, agent reconnecting...', colors.yellow);
867
+ break;
868
+
869
+ case 'bridge_reconnected':
870
+ log('✅ Bridge connection restored', colors.green);
871
+ break;
872
+
873
+ case 'session_stop':
874
+ // Agent is stopping this session - don't reconnect
875
+ log('🛑 Session stopped by agent', colors.yellow);
876
+ isShuttingDown = true;
877
+ // Let Claude process finish gracefully
878
+ if (claudeProcess && !claudeProcess.killed) {
879
+ try { claudeProcess.kill('SIGTERM'); } catch {}
880
+ }
881
+ break;
882
+
883
+ default:
884
+ logStatus(`Unknown bridge message: ${msg.type}`);
885
+ }
886
+ }
887
+
888
+ function sendToBridge(data) {
889
+ if (!isAuthenticated) {
890
+ logStatus(`Cannot send to bridge: not authenticated`);
891
+ return false;
892
+ }
893
+ if (bridgeSocket && bridgeSocket.readyState === WebSocket.OPEN) {
894
+ try {
895
+ const json = JSON.stringify(data);
896
+ bridgeSocket.send(json);
897
+ logStatus(`SENT to bridge: type=${data.type}, size=${json.length} bytes`);
898
+ return true;
899
+ } catch (err) {
900
+ logStatus(`Failed to send to bridge: ${err.message}`);
901
+ }
902
+ } else {
903
+ logStatus(`Cannot send to bridge: socket ${bridgeSocket ? 'not open' : 'null'}`);
904
+ }
905
+ return false;
906
+ }
907
+
908
+ // ====================
909
+ // Session File Watcher (for Claude output)
910
+ // ====================
911
+
912
+ function startSessionFileWatcher() {
913
+ const sessionFile = getSessionFilePath();
914
+ logStatus(`Watching session file: ${sessionFile}`);
915
+
916
+ // Also check what files actually exist in the projects directory
917
+ const projectsDir = path.join(os.homedir(), '.claude', 'projects');
918
+ if (fs.existsSync(projectsDir)) {
919
+ const dirs = fs.readdirSync(projectsDir);
920
+ logStatus(`Found ${dirs.length} project directories in ${projectsDir}`);
921
+ // Look for our session in any directory
922
+ for (const dir of dirs.slice(0, 5)) { // Check first 5
923
+ const fullDir = path.join(projectsDir, dir);
924
+ if (fs.statSync(fullDir).isDirectory()) {
925
+ const files = fs.readdirSync(fullDir);
926
+ const sessionFiles = files.filter(f => f.includes(sessionId.slice(0, 8)));
927
+ if (sessionFiles.length > 0) {
928
+ logStatus(`Found session files in ${dir}: ${sessionFiles.join(', ')}`);
929
+ }
930
+ }
931
+ }
932
+ }
933
+
934
+ let fileCheckCount = 0;
935
+ const pollInterval = setInterval(() => {
936
+ if (!isRunning || isShuttingDown) {
937
+ clearInterval(pollInterval);
938
+ return;
939
+ }
940
+
941
+ try {
942
+ if (!fs.existsSync(sessionFile)) {
943
+ // Log occasionally that file doesn't exist yet
944
+ if (fileCheckCount++ % 20 === 0) {
945
+ logStatus(`Session file not found yet: ${sessionFile}`);
946
+ }
947
+ return;
948
+ }
949
+
950
+ const stats = fs.statSync(sessionFile);
951
+ if (stats.size > lastFileSize) {
952
+ logStatus(`Session file changed: ${lastFileSize} -> ${stats.size} bytes`);
953
+ const fd = fs.openSync(sessionFile, 'r');
954
+ const buffer = Buffer.alloc(stats.size - lastFileSize);
955
+ fs.readSync(fd, buffer, 0, buffer.length, lastFileSize);
956
+ fs.closeSync(fd);
957
+ lastFileSize = stats.size;
958
+
959
+ const newContent = buffer.toString('utf8');
960
+ const lines = newContent.split('\n').filter(l => l.trim());
961
+ logStatus(`Processing ${lines.length} new lines from session file`);
962
+
963
+ // First pass: collect all tool_result IDs (auto-approved tools)
964
+ // This ensures we know which tools are already completed before processing tool_use
965
+ for (const line of lines) {
966
+ try {
967
+ const msg = JSON.parse(line);
968
+ if (msg.message?.content && Array.isArray(msg.message.content)) {
969
+ for (const block of msg.message.content) {
970
+ if (block.type === 'tool_result' && block.tool_use_id) {
971
+ // Limit Set size to prevent memory issues
972
+ if (completedToolIds.size >= MAX_COMPLETED_TOOLS) {
973
+ const firstId = completedToolIds.values().next().value;
974
+ completedToolIds.delete(firstId);
975
+ }
976
+ completedToolIds.add(block.tool_use_id);
977
+ logStatus(`Pre-scan: found completed tool ${block.tool_use_id}`);
978
+ }
979
+ }
980
+ }
981
+ } catch (e) {
982
+ // Not valid JSON, skip
983
+ }
984
+ }
985
+
986
+ // Second pass: process all messages
987
+ for (const line of lines) {
988
+ try {
989
+ const msg = JSON.parse(line);
990
+ processSessionMessage(msg);
991
+ } catch (e) {
992
+ // Not valid JSON, skip
993
+ }
994
+ }
995
+ }
996
+ } catch (err) {
997
+ logStatus(`Session file error: ${err.message}`);
998
+ }
999
+ }, 500);
1000
+
1001
+ return () => clearInterval(pollInterval);
1002
+ }
1003
+
1004
+ function processSessionMessage(msg) {
1005
+ logStatus(`Processing message: type=${msg.type || 'unknown'}, has_message=${!!msg.message}`);
1006
+
1007
+ // Debug: log content block types
1008
+ if (msg.message?.content && Array.isArray(msg.message.content)) {
1009
+ const blockTypes = msg.message.content.map(b => b.type).join(', ');
1010
+ logStatus(`Content blocks: [${blockTypes}]`);
1011
+ }
1012
+
1013
+ if (!msg.message || !msg.message.role) {
1014
+ logStatus(`Skipping: no message.role (type=${msg.type})`);
1015
+ return;
1016
+ }
1017
+
1018
+ const role = msg.message.role;
1019
+ const content = msg.message.content;
1020
+
1021
+ logStatus(`Message role=${role}, content_type=${typeof content}, is_array=${Array.isArray(content)}`);
1022
+
1023
+ // Build comprehensive message with all content types
1024
+ let parts = [];
1025
+ let toolUses = [];
1026
+ let thinkingContent = '';
1027
+
1028
+ if (typeof content === 'string') {
1029
+ parts.push(content);
1030
+ } else if (Array.isArray(content)) {
1031
+ for (const block of content) {
1032
+ if (block.type === 'text') {
1033
+ parts.push(block.text);
1034
+ } else if (block.type === 'thinking') {
1035
+ // Claude's reasoning/planning
1036
+ thinkingContent = block.thinking || '';
1037
+ logStatus(`Found thinking block: ${thinkingContent.slice(0, 100)}...`);
1038
+ } else if (block.type === 'tool_use') {
1039
+ // Tool use request (permission prompt)
1040
+ const toolInfo = {
1041
+ id: block.id,
1042
+ name: block.name,
1043
+ input: block.input
1044
+ };
1045
+ toolUses.push(toolInfo);
1046
+ logStatus(`Found tool_use: ${block.name} (id: ${block.id})`);
1047
+
1048
+ // Format tool use for display
1049
+ let toolDescription = `**${block.name}**`;
1050
+ if (block.input) {
1051
+ if (block.name === 'Bash' && block.input.command) {
1052
+ toolDescription += `\n\`\`\`\n${block.input.command}\n\`\`\``;
1053
+ if (block.input.description) {
1054
+ toolDescription += `\n${block.input.description}`;
1055
+ }
1056
+ } else if (block.name === 'Read' && block.input.file_path) {
1057
+ toolDescription += `: ${block.input.file_path}`;
1058
+ } else if (block.name === 'Write' && block.input.file_path) {
1059
+ toolDescription += `: ${block.input.file_path}`;
1060
+ } else if (block.name === 'Edit' && block.input.file_path) {
1061
+ toolDescription += `: ${block.input.file_path}`;
1062
+ } else {
1063
+ // Generic tool input display
1064
+ const inputStr = JSON.stringify(block.input, null, 2);
1065
+ if (inputStr.length < 500) {
1066
+ toolDescription += `\n\`\`\`json\n${inputStr}\n\`\`\``;
1067
+ }
1068
+ }
1069
+ }
1070
+ parts.push(toolDescription);
1071
+ } else if (block.type === 'tool_result') {
1072
+ // Tool execution result - permission was processed (auto-approved or user approved)
1073
+ logStatus(`Found tool_result for tool_use_id: ${block.tool_use_id}`);
1074
+ // Mark this tool as completed so we don't send permission_request for it
1075
+ if (completedToolIds.size >= MAX_COMPLETED_TOOLS) {
1076
+ const firstId = completedToolIds.values().next().value;
1077
+ completedToolIds.delete(firstId);
1078
+ }
1079
+ completedToolIds.add(block.tool_use_id);
1080
+ // Clear pending permission since tool was executed
1081
+ if (pendingPermission && pendingPermission.id === block.tool_use_id) {
1082
+ pendingPermission = null;
1083
+ }
1084
+ if (block.content) {
1085
+ const resultText = typeof block.content === 'string'
1086
+ ? block.content
1087
+ : JSON.stringify(block.content);
1088
+ if (resultText.length < 1000) {
1089
+ parts.push(`*Result:*\n${resultText}`);
1090
+ } else {
1091
+ parts.push(`*Result:* (${resultText.length} chars)`);
1092
+ }
1093
+ }
1094
+ }
1095
+ }
1096
+ }
1097
+
1098
+ // Send thinking content if present
1099
+ if (thinkingContent) {
1100
+ log(`💭 Sending thinking to iOS (${thinkingContent.length} chars)`, colors.dim);
1101
+ sendToBridge({
1102
+ type: 'claude_message',
1103
+ message: {
1104
+ id: uuidv4(),
1105
+ sender: 'claude',
1106
+ content: `💭 *Thinking:*\n${thinkingContent}`,
1107
+ timestamp: new Date().toISOString(),
1108
+ messageType: 'thinking'
1109
+ }
1110
+ });
1111
+ }
1112
+
1113
+ // Send tool use as permission request (only for tools not yet executed)
1114
+ const pendingTools = toolUses.filter(t => !completedToolIds.has(t.id));
1115
+ if (pendingTools.length > 0) {
1116
+ log(`🔧 Sending ${pendingTools.length} tool_use to iOS (${toolUses.length - pendingTools.length} already completed)`, colors.dim);
1117
+ }
1118
+
1119
+ // Helper function to send permission request with captured or fallback options
1120
+ function sendPermissionRequest(tool, displayText, capturedPrompt) {
1121
+ let options;
1122
+ let question = 'Permission required';
1123
+
1124
+ if (capturedPrompt && capturedPrompt.options) {
1125
+ // Use actual options captured from CLI terminal output
1126
+ log(`📋 Using captured CLI prompt with ${capturedPrompt.options.length} options`, colors.dim);
1127
+ options = capturedPrompt.options.map(opt => ({
1128
+ id: opt.id,
1129
+ label: opt.label,
1130
+ action: opt.id === 1 ? 'approve' : opt.id === 2 ? 'approve_always' : 'custom',
1131
+ requiresInput: opt.requiresInput || false
1132
+ }));
1133
+ question = capturedPrompt.question || question;
1134
+ } else {
1135
+ // Fallback: build context-aware options
1136
+ log(`⚠️ No captured prompt, using fallback options`, colors.dim);
1137
+ let dontAskAgainLabel = "Yes, and don't ask again";
1138
+ if (tool.name === 'Bash' && tool.input?.command) {
1139
+ const baseCmd = tool.input.command.trim().split(/\s+/)[0];
1140
+ dontAskAgainLabel = `Yes, and don't ask again for ${baseCmd} commands`;
1141
+ } else if (tool.name === 'Read') {
1142
+ dontAskAgainLabel = "Yes, and don't ask again for Read";
1143
+ } else if (tool.name === 'Write') {
1144
+ dontAskAgainLabel = "Yes, and don't ask again for Write";
1145
+ } else if (tool.name === 'Edit') {
1146
+ dontAskAgainLabel = "Yes, and don't ask again for Edit";
1147
+ }
1148
+ options = [
1149
+ { id: 1, label: 'Yes', action: 'approve' },
1150
+ { id: 2, label: dontAskAgainLabel, action: 'approve_always' },
1151
+ { id: 3, label: 'Tell Claude what to do differently', action: 'custom', requiresInput: true }
1152
+ ];
1153
+ }
1154
+
1155
+ sendToBridge({
1156
+ type: 'permission_request',
1157
+ sessionId: sessionId,
1158
+ requestId: tool.id,
1159
+ command: tool.name,
1160
+ question: question,
1161
+ displayText: displayText,
1162
+ fullText: JSON.stringify(tool.input, null, 2),
1163
+ options: options,
1164
+ cancelLabel: 'Cancel'
1165
+ });
1166
+ }
1167
+
1168
+ for (const tool of pendingTools) {
1169
+ // Track the pending permission (only last one if multiple)
1170
+ pendingPermission = {
1171
+ id: tool.id,
1172
+ command: tool.name,
1173
+ timestamp: Date.now()
1174
+ };
1175
+
1176
+ // Build display-friendly description
1177
+ let displayText = `**${tool.name}**`;
1178
+ if (tool.input) {
1179
+ if (tool.name === 'Bash' && tool.input.command) {
1180
+ displayText = `Run command:\n\`\`\`\n${tool.input.command}\n\`\`\``;
1181
+ } else if (tool.name === 'Read' && tool.input.file_path) {
1182
+ displayText = `Read file: ${tool.input.file_path}`;
1183
+ } else if (tool.name === 'Write' && tool.input.file_path) {
1184
+ displayText = `Write file: ${tool.input.file_path}`;
1185
+ } else if (tool.name === 'Edit' && tool.input.file_path) {
1186
+ displayText = `Edit file: ${tool.input.file_path}`;
1187
+ } else {
1188
+ displayText = `${tool.name}: ${JSON.stringify(tool.input, null, 2)}`;
1189
+ }
1190
+ }
1191
+
1192
+ // Check if we already have a captured prompt
1193
+ if (lastCapturedPrompt && lastCapturedPrompt.options) {
1194
+ sendPermissionRequest(tool, displayText, lastCapturedPrompt);
1195
+ lastCapturedPrompt = null;
1196
+ } else {
1197
+ // Wait briefly for prompt to be captured from terminal output
1198
+ // The prompt appears on terminal slightly after tool_use is written to session file
1199
+ const toolCopy = { ...tool };
1200
+ const displayTextCopy = displayText;
1201
+ setTimeout(() => {
1202
+ sendPermissionRequest(toolCopy, displayTextCopy, lastCapturedPrompt);
1203
+ lastCapturedPrompt = null;
1204
+ }, 300); // 300ms delay to allow prompt capture
1205
+ }
1206
+ }
1207
+
1208
+ // Send text content
1209
+ const textContent = parts.join('\n\n');
1210
+ if (!textContent.trim()) {
1211
+ logStatus(`Skipping: no displayable content extracted`);
1212
+ return;
1213
+ }
1214
+
1215
+ // Only skip user messages that came from mobile (send_message)
1216
+ // Bridge already echoed those, so sending again would cause duplicates
1217
+ // But initial prompts and local terminal input should still be sent
1218
+ if (role === 'user') {
1219
+ const hash = textContent.trim().toLowerCase();
1220
+ if (mobileMessageHashes.has(hash)) {
1221
+ // This message came from mobile - bridge already echoed it
1222
+ mobileMessageHashes.delete(hash); // Clean up (each message only needs to be skipped once)
1223
+ logStatus(`Skipping mobile message (bridge echoed): "${textContent.slice(0, 30)}..."`);
1224
+ return;
1225
+ }
1226
+ // This is initial prompt or local terminal input - send it
1227
+ logStatus(`Sending user message (initial/local): "${textContent.slice(0, 50)}..."`);
1228
+ } else {
1229
+ logStatus(`Sending ${role} message to bridge: "${textContent.slice(0, 50)}${textContent.length > 50 ? '...' : ''}"`);
1230
+ }
1231
+
1232
+ // Send to bridge
1233
+ sendToBridge({
1234
+ type: 'claude_message',
1235
+ message: {
1236
+ id: msg.uuid || uuidv4(),
1237
+ sender: role === 'user' ? 'user' : 'claude',
1238
+ content: textContent,
1239
+ timestamp: new Date().toISOString()
1240
+ }
1241
+ });
1242
+
1243
+ // Send token usage if available (assistant messages include usage data)
1244
+ if (msg.message?.usage) {
1245
+ const usage = msg.message.usage;
1246
+ sendToBridge({
1247
+ type: 'token_usage',
1248
+ sessionId: sessionId,
1249
+ model: msg.message.model || null,
1250
+ usage: {
1251
+ input_tokens: usage.input_tokens || 0,
1252
+ output_tokens: usage.output_tokens || 0,
1253
+ cache_creation_tokens: usage.cache_creation_input_tokens || 0,
1254
+ cache_read_tokens: usage.cache_read_input_tokens || 0
1255
+ }
1256
+ });
1257
+ logStatus(`Token usage: in=${usage.input_tokens || 0}, out=${usage.output_tokens || 0}, cache_create=${usage.cache_creation_input_tokens || 0}, cache_read=${usage.cache_read_input_tokens || 0}`);
1258
+ }
1259
+ }
1260
+
1261
+ // ====================
1262
+ // Claude Process
1263
+ // ====================
1264
+
1265
+ function startClaude() {
1266
+ const claudePath = findClaudePath();
1267
+
1268
+ // Claude CLI: --session-id and --resume are mutually exclusive
1269
+ // - For new sessions: use --session-id <id>
1270
+ // - For resume: use --resume <id>
1271
+ let claudeArgs;
1272
+ if (resumeSessionId) {
1273
+ claudeArgs = ['--resume', sessionId];
1274
+ logStatus(`Resuming Claude session: ${sessionId.slice(0, 8)}...`);
1275
+ } else {
1276
+ claudeArgs = ['--session-id', sessionId];
1277
+ logStatus(`Starting new Claude session: ${sessionId.slice(0, 8)}...`);
1278
+ }
1279
+
1280
+ // Choose PTY wrapper based on platform/flag
1281
+ // - Windows: always use node-pty (Python PTY doesn't work)
1282
+ // - Unix: use Python by default, node-pty with --node-pty flag
1283
+ if (useNodePty) {
1284
+ const nodeWrapperPath = path.join(__dirname, 'pty-wrapper-node.js');
1285
+ logStatus('Using Node.js PTY wrapper');
1286
+
1287
+ // Check if wrapper file exists
1288
+ if (!fs.existsSync(nodeWrapperPath)) {
1289
+ log('Error: pty-wrapper-node.js not found.', colors.red);
1290
+ log('Re-install vibe-cli or check installation.', colors.yellow);
1291
+ process.exit(1);
1292
+ }
1293
+
1294
+ // Check if node-pty is available
1295
+ try {
1296
+ require.resolve('node-pty');
1297
+ } catch (err) {
1298
+ log('Error: node-pty is not installed.', colors.red);
1299
+ log('Install it with: npm install node-pty', colors.yellow);
1300
+ if (os.platform() === 'win32') {
1301
+ log('', colors.reset);
1302
+ log('On Windows, you may also need:', colors.yellow);
1303
+ log(' - Python 3.x', colors.dim);
1304
+ log(' - Visual Studio Build Tools', colors.dim);
1305
+ log(' npm install --global windows-build-tools', colors.dim);
1306
+ }
1307
+ process.exit(1);
1308
+ }
1309
+
1310
+ claudeProcess = spawn('node', [nodeWrapperPath, claudePath, ...claudeArgs], {
1311
+ cwd: process.cwd(),
1312
+ env: { ...process.env, TERM: 'xterm-256color' },
1313
+ stdio: ['pipe', 'inherit', 'inherit', 'pipe'] // FD 3 for prompt detection
1314
+ });
1315
+ } else {
1316
+ // Use Python PTY wrapper (Unix only)
1317
+ if (os.platform() === 'win32') {
1318
+ log('Error: Python PTY wrapper does not work on Windows.', colors.red);
1319
+ log('Please install node-pty: npm install node-pty', colors.yellow);
1320
+ process.exit(1);
1321
+ }
1322
+
1323
+ const pythonWrapperPath = path.join(__dirname, 'pty-wrapper.py');
1324
+
1325
+ // Check if wrapper file exists
1326
+ if (!fs.existsSync(pythonWrapperPath)) {
1327
+ log('Error: pty-wrapper.py not found.', colors.red);
1328
+ log('Re-install vibe-cli or check installation.', colors.yellow);
1329
+ process.exit(1);
1330
+ }
1331
+
1332
+ claudeProcess = spawn('python3', [pythonWrapperPath, claudePath, ...claudeArgs], {
1333
+ cwd: process.cwd(),
1334
+ env: { ...process.env, TERM: 'xterm-256color' },
1335
+ stdio: ['pipe', 'inherit', 'inherit', 'pipe'] // FD 3 for prompt detection
1336
+ });
1337
+ }
1338
+
1339
+ isRunning = true;
1340
+
1341
+ // Start watching session file
1342
+ sessionFileWatcher = startSessionFileWatcher();
1343
+
1344
+ // Read permission prompts from FD 3 (captured by PTY wrapper)
1345
+ let promptBuffer = '';
1346
+ if (claudeProcess.stdio[3]) {
1347
+ claudeProcess.stdio[3].on('data', (data) => {
1348
+ promptBuffer += data.toString();
1349
+ // Process complete JSON lines
1350
+ const lines = promptBuffer.split('\n');
1351
+ promptBuffer = lines.pop() || ''; // Keep incomplete line in buffer
1352
+
1353
+ for (const line of lines) {
1354
+ if (!line.trim()) continue;
1355
+ try {
1356
+ const prompt = JSON.parse(line);
1357
+ if (prompt.type === 'permission_prompt') {
1358
+ log(`🔔 Captured permission prompt from CLI`, colors.dim);
1359
+ // Store for use when we see the corresponding tool_use
1360
+ lastCapturedPrompt = prompt;
1361
+ }
1362
+ } catch (e) {
1363
+ // Not valid JSON, skip
1364
+ }
1365
+ }
1366
+ });
1367
+ }
1368
+
1369
+ claudeProcess.on('exit', (code, signal) => {
1370
+ isRunning = false;
1371
+ logStatus(`Claude exited (code: ${code}, signal: ${signal})`);
1372
+
1373
+ if (sessionFileWatcher) {
1374
+ sessionFileWatcher();
1375
+ sessionFileWatcher = null;
1376
+ }
1377
+
1378
+ sendToBridge({
1379
+ type: 'session_ended',
1380
+ exitCode: code,
1381
+ signal
1382
+ });
1383
+
1384
+ process.exit(code || 0);
1385
+ });
1386
+
1387
+ claudeProcess.on('error', (err) => {
1388
+ log(`Failed to start Claude: ${err.message}`, colors.yellow);
1389
+ process.exit(1);
1390
+ });
1391
+ }
1392
+
1393
+ // ====================
1394
+ // Terminal Input
1395
+ // ====================
1396
+
1397
+ let commandBuffer = '';
1398
+ let inCommandMode = false;
1399
+
1400
+ function handleVibeCommand(command) {
1401
+ const trimmed = command.trim();
1402
+
1403
+ if (trimmed.startsWith('/name ')) {
1404
+ const newName = trimmed.slice(6).trim();
1405
+ if (!newName) {
1406
+ log('Usage: /name <session name>', colors.yellow);
1407
+ return true;
1408
+ }
1409
+
1410
+ // Update local session name
1411
+ sessionName = newName;
1412
+
1413
+ // Send rename request to bridge
1414
+ if (bridgeSocket && bridgeSocket.readyState === WebSocket.OPEN && isAuthenticated) {
1415
+ bridgeSocket.send(JSON.stringify({
1416
+ type: 'rename_session',
1417
+ sessionId: sessionId,
1418
+ name: newName
1419
+ }));
1420
+ log(`📝 Session renamed to: ${newName}`, colors.green);
1421
+ } else {
1422
+ log(`📝 Session name set to: ${newName} (not connected to bridge)`, colors.yellow);
1423
+ }
1424
+ return true;
1425
+ }
1426
+
1427
+ if (trimmed === '/name') {
1428
+ // Show current session name
1429
+ const currentName = sessionName || path.basename(process.cwd());
1430
+ log(`📝 Current session name: ${currentName}`, colors.cyan);
1431
+ log(' To rename: /name <new name>', colors.dim);
1432
+ return true;
1433
+ }
1434
+
1435
+ // Not a recognized vibe command - forward to Claude
1436
+ // This allows users to type paths like /usr/bin/bash
1437
+ return false;
1438
+ }
1439
+
1440
+ function setupTerminalInput() {
1441
+ if (process.stdin.isTTY) {
1442
+ process.stdin.setRawMode(true);
1443
+ }
1444
+ process.stdin.resume();
1445
+ process.stdin.on('data', (data) => {
1446
+ // Debug: log what bytes the keyboard sends (only when not in command mode)
1447
+ if (data.length <= 4 && !inCommandMode) {
1448
+ logStatus(`Keyboard input: ${data.length} bytes, hex: ${data.toString('hex')}`);
1449
+ }
1450
+
1451
+ const str = data.toString();
1452
+
1453
+ // Check for command mode
1454
+ for (const char of str) {
1455
+ const code = char.charCodeAt(0);
1456
+
1457
+ // Enter key (CR or LF)
1458
+ if (code === 0x0d || code === 0x0a) {
1459
+ if (inCommandMode) {
1460
+ // Try to handle as vibe command
1461
+ if (handleVibeCommand(commandBuffer)) {
1462
+ // Command was handled, reset and don't forward
1463
+ commandBuffer = '';
1464
+ inCommandMode = false;
1465
+ // Echo a newline to terminal
1466
+ process.stdout.write('\n');
1467
+ continue;
1468
+ }
1469
+ // Not a valid command, forward the buffered content + Enter
1470
+ if (claudeProcess && isRunning && claudeProcess.stdin && claudeProcess.stdin.writable) {
1471
+ claudeProcess.stdin.write(commandBuffer + '\r');
1472
+ }
1473
+ commandBuffer = '';
1474
+ inCommandMode = false;
1475
+ continue;
1476
+ }
1477
+ }
1478
+
1479
+ // Ctrl+C or Escape in command mode - cancel command
1480
+ if ((code === 0x03 || code === 0x1b) && inCommandMode) {
1481
+ // Clear the echoed text
1482
+ for (let i = 0; i < commandBuffer.length; i++) {
1483
+ process.stdout.write('\b \b');
1484
+ }
1485
+ commandBuffer = '';
1486
+ inCommandMode = false;
1487
+ if (code === 0x03) {
1488
+ // Forward Ctrl+C to Claude if not in command mode
1489
+ // (but we just exited, so do nothing)
1490
+ }
1491
+ continue;
1492
+ }
1493
+
1494
+ // Backspace handling in command mode
1495
+ if ((code === 0x7f || code === 0x08) && inCommandMode) {
1496
+ if (commandBuffer.length > 0) {
1497
+ commandBuffer = commandBuffer.slice(0, -1);
1498
+ // Echo backspace to terminal
1499
+ process.stdout.write('\b \b');
1500
+ }
1501
+ if (commandBuffer.length === 0) {
1502
+ inCommandMode = false;
1503
+ }
1504
+ continue;
1505
+ }
1506
+
1507
+ // Start of line and '/' character - enter command mode
1508
+ if (char === '/' && commandBuffer.length === 0 && !inCommandMode) {
1509
+ inCommandMode = true;
1510
+ commandBuffer = '/';
1511
+ // Echo to terminal
1512
+ process.stdout.write('/');
1513
+ continue;
1514
+ }
1515
+
1516
+ // In command mode - buffer the character
1517
+ if (inCommandMode) {
1518
+ commandBuffer += char;
1519
+ // Echo to terminal
1520
+ process.stdout.write(char);
1521
+ continue;
1522
+ }
1523
+
1524
+ // Normal mode - forward to Claude
1525
+ if (claudeProcess && isRunning && claudeProcess.stdin && claudeProcess.stdin.writable) {
1526
+ claudeProcess.stdin.write(char);
1527
+ }
1528
+ }
1529
+ });
1530
+ }
1531
+
1532
+ // ====================
1533
+ // Shutdown
1534
+ // ====================
1535
+
1536
+ function setupShutdown() {
1537
+ const shutdown = () => {
1538
+ if (isShuttingDown) return;
1539
+ isShuttingDown = true;
1540
+
1541
+ log('\n');
1542
+ logStatus('Shutting down...');
1543
+
1544
+ if (reconnectTimer) {
1545
+ clearTimeout(reconnectTimer);
1546
+ }
1547
+
1548
+ if (heartbeatTimer) {
1549
+ clearInterval(heartbeatTimer);
1550
+ }
1551
+
1552
+ if (bridgeSocket) {
1553
+ try { bridgeSocket.close(); } catch {}
1554
+ }
1555
+
1556
+ if (sessionFileWatcher) {
1557
+ sessionFileWatcher();
1558
+ }
1559
+
1560
+ if (claudeProcess && !claudeProcess.killed) {
1561
+ try { claudeProcess.kill('SIGTERM'); } catch {}
1562
+ }
1563
+
1564
+ setTimeout(() => process.exit(0), 2000);
1565
+ };
1566
+
1567
+ process.on('SIGINT', shutdown);
1568
+ process.on('SIGTERM', shutdown);
1569
+ process.on('uncaughtException', (err) => {
1570
+ logStatus(`Uncaught exception: ${err.message}`);
1571
+ shutdown();
1572
+ });
1573
+ }
1574
+
1575
+ // ====================
1576
+ // Main
1577
+ // ====================
1578
+
1579
+ function main() {
1580
+ console.log('');
1581
+ log('🎵 vibe-cli', colors.bright + colors.magenta);
1582
+ log('══════════════════════════════════════', colors.dim);
1583
+ log(` Session: ${sessionId.slice(0, 8)}...`, colors.dim);
1584
+
1585
+ if (agentUrl) {
1586
+ log(` Agent: ${agentUrl}`, colors.dim);
1587
+ log(` Mode: Managed (via local agent)`, colors.dim);
1588
+ } else if (bridgeUrl) {
1589
+ log(` Bridge: ${bridgeUrl}`, colors.dim);
1590
+ log(` Mode: Cloud (via bridge server)`, colors.dim);
1591
+ log(` Auth: ${authToken ? 'Token stored' : 'No token (dev mode)'}`, colors.dim);
1592
+ } else {
1593
+ log(` Mode: Local (no bridge - terminal only)`, colors.dim);
1594
+ }
1595
+
1596
+ log(` Terminal: ${process.cwd()}`, colors.dim);
1597
+ log('══════════════════════════════════════', colors.dim);
1598
+ console.log('');
1599
+
1600
+ setupShutdown();
1601
+
1602
+ if (agentUrl || bridgeUrl) {
1603
+ connectToBridge();
1604
+ }
1605
+
1606
+ startClaude();
1607
+ setupTerminalInput();
1608
+
1609
+ // Send initial prompt after a short delay (let Claude start up)
1610
+ if (initialPrompt) {
1611
+ setTimeout(() => {
1612
+ logStatus(`Sending initial prompt: ${initialPrompt}`);
1613
+ sendToClaude(initialPrompt, 'initial');
1614
+ }, 2000);
1615
+ }
1616
+ }
1617
+
1618
+ // Only start Claude if not in login mode
1619
+ if (!loginMode) {
1620
+ main();
1621
+ }