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/agent/agent.js ADDED
@@ -0,0 +1,1218 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * vibe-agent - Persistent daemon for remote Claude Code session management
5
+ *
6
+ * Runs on a host (EC2, local Mac, etc.) and accepts commands from iOS to:
7
+ * - Start new Claude Code sessions
8
+ * - Resume existing sessions
9
+ * - Stop running sessions
10
+ *
11
+ * Usage:
12
+ * vibe-agent --bridge wss://ws.neng.ai --token <firebase-token>
13
+ * vibe-agent --login --bridge wss://ws.neng.ai
14
+ */
15
+
16
+ const { spawn, execSync } = require('child_process');
17
+ const WebSocket = require('ws');
18
+ const { WebSocketServer } = require('ws');
19
+ const { v4: uuidv4 } = require('uuid');
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const os = require('os');
23
+
24
+ // ====================
25
+ // Configuration
26
+ // ====================
27
+
28
+ const CONFIG_DIR = path.join(os.homedir(), '.vibe-agent');
29
+ const CONFIG_FILE = path.join(CONFIG_DIR, 'config.json');
30
+ const AUTH_FILE = path.join(CONFIG_DIR, 'auth.json');
31
+
32
+ const RECONNECT_DELAY_MS = 5000;
33
+ const HEARTBEAT_INTERVAL_MS = 30000;
34
+ const LOCAL_SERVER_PORT = 9999;
35
+ const PORT_FILE = path.join(os.homedir(), '.vibe-agent', 'port');
36
+
37
+ // Colors for terminal output
38
+ const colors = {
39
+ reset: '\x1b[0m',
40
+ green: '\x1b[32m',
41
+ yellow: '\x1b[33m',
42
+ red: '\x1b[31m',
43
+ cyan: '\x1b[36m',
44
+ dim: '\x1b[2m',
45
+ bold: '\x1b[1m'
46
+ };
47
+
48
+ function log(msg, color = colors.reset) {
49
+ const timestamp = new Date().toLocaleTimeString();
50
+ console.log(`${colors.dim}[${timestamp}]${colors.reset} ${color}${msg}${colors.reset}`);
51
+ }
52
+
53
+ // ====================
54
+ // Configuration Management
55
+ // ====================
56
+
57
+ function loadConfig() {
58
+ try {
59
+ if (fs.existsSync(CONFIG_FILE)) {
60
+ return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
61
+ }
62
+ } catch (err) {
63
+ // Ignore
64
+ }
65
+ return {};
66
+ }
67
+
68
+ function saveConfig(config) {
69
+ try {
70
+ if (!fs.existsSync(CONFIG_DIR)) {
71
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
72
+ }
73
+ fs.writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2), 'utf8');
74
+ // Restrict permissions on Unix (Windows uses ACLs instead)
75
+ if (process.platform !== 'win32') {
76
+ fs.chmodSync(CONFIG_FILE, 0o600);
77
+ }
78
+ } catch (err) {
79
+ log(`Failed to save config: ${err.message}`, colors.red);
80
+ }
81
+ }
82
+
83
+ function loadAuth() {
84
+ try {
85
+ if (fs.existsSync(AUTH_FILE)) {
86
+ return JSON.parse(fs.readFileSync(AUTH_FILE, 'utf8'));
87
+ }
88
+ } catch (err) {
89
+ // Ignore
90
+ }
91
+ return null;
92
+ }
93
+
94
+ function saveAuth(idToken, refreshToken = null) {
95
+ try {
96
+ if (!fs.existsSync(CONFIG_DIR)) {
97
+ fs.mkdirSync(CONFIG_DIR, { recursive: true });
98
+ }
99
+ const data = { idToken, refreshToken, updatedAt: new Date().toISOString() };
100
+ fs.writeFileSync(AUTH_FILE, JSON.stringify(data, null, 2), 'utf8');
101
+ // Restrict permissions on Unix (Windows uses ACLs instead)
102
+ if (process.platform !== 'win32') {
103
+ fs.chmodSync(AUTH_FILE, 0o600);
104
+ }
105
+ return true;
106
+ } catch (err) {
107
+ log(`Failed to save auth: ${err.message}`, colors.red);
108
+ return false;
109
+ }
110
+ }
111
+
112
+ // ====================
113
+ // Token Refresh
114
+ // ====================
115
+
116
+ const FIREBASE_CONFIG = {
117
+ apiKey: "AIzaSyAJKYavMidKYxRpfhP2IHUiy8dafc3ISqc"
118
+ };
119
+
120
+ async function refreshIdToken() {
121
+ const auth = loadAuth();
122
+ if (!auth?.refreshToken) {
123
+ return null;
124
+ }
125
+
126
+ try {
127
+ log('Refreshing authentication token...', colors.dim);
128
+ const response = await fetch(
129
+ `https://securetoken.googleapis.com/v1/token?key=${FIREBASE_CONFIG.apiKey}`,
130
+ {
131
+ method: 'POST',
132
+ headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
133
+ body: `grant_type=refresh_token&refresh_token=${auth.refreshToken}`
134
+ }
135
+ );
136
+
137
+ if (!response.ok) {
138
+ const error = await response.json();
139
+ log(`Token refresh failed: ${error.error?.message || response.status}`, colors.yellow);
140
+ return null;
141
+ }
142
+
143
+ const data = await response.json();
144
+ saveAuth(data.id_token, data.refresh_token);
145
+ log('Token refreshed successfully', colors.green);
146
+ return data.id_token;
147
+ } catch (err) {
148
+ log(`Token refresh error: ${err.message}`, colors.red);
149
+ return null;
150
+ }
151
+ }
152
+
153
+ // ====================
154
+ // Agent State
155
+ // ====================
156
+
157
+ let bridgeUrl = null;
158
+ let authToken = null;
159
+ let ws = null;
160
+ let isAuthenticated = false;
161
+ let agentId = null;
162
+ let hostName = os.hostname();
163
+ let reconnectTimer = null;
164
+ let heartbeatTimer = null;
165
+
166
+ // Track running sessions: sessionId -> { process, path, name, localWs }
167
+ const runningSessions = new Map();
168
+
169
+ // Track sessions being intentionally stopped (to distinguish from unexpected disconnects)
170
+ const stoppingSessions = new Set();
171
+
172
+ // Local server for vibe-cli connections
173
+ let localServer = null;
174
+ // Track local CLI connections: ws -> { sessionId, authenticated }
175
+ const localClients = new Map();
176
+
177
+ // ====================
178
+ // WebSocket Connection
179
+ // ====================
180
+
181
+ function connect() {
182
+ if (!bridgeUrl) {
183
+ log('No bridge URL configured', colors.red);
184
+ return;
185
+ }
186
+
187
+ log(`Connecting to ${bridgeUrl}...`, colors.cyan);
188
+
189
+ try {
190
+ ws = new WebSocket(bridgeUrl);
191
+ } catch (err) {
192
+ log(`Failed to create WebSocket: ${err.message}`, colors.red);
193
+ scheduleReconnect();
194
+ return;
195
+ }
196
+
197
+ ws.on('open', () => {
198
+ log('Connected to bridge', colors.green);
199
+ clearTimeout(reconnectTimer);
200
+
201
+ // Authenticate
202
+ if (authToken) {
203
+ send({ type: 'authenticate', token: authToken });
204
+ } else {
205
+ log('No auth token - run: vibe-agent --login', colors.yellow);
206
+ }
207
+ });
208
+
209
+ ws.on('message', (data) => {
210
+ try {
211
+ const msg = JSON.parse(data.toString());
212
+ handleMessage(msg);
213
+ } catch (err) {
214
+ log(`Failed to parse message: ${err.message}`, colors.red);
215
+ }
216
+ });
217
+
218
+ ws.on('close', () => {
219
+ log('Disconnected from bridge', colors.yellow);
220
+ isAuthenticated = false;
221
+ stopHeartbeat();
222
+
223
+ // Notify all local clients that bridge is disconnected
224
+ for (const [clientWs, clientInfo] of localClients) {
225
+ try {
226
+ clientWs.send(JSON.stringify({
227
+ type: 'bridge_disconnected',
228
+ message: 'Bridge connection lost, reconnecting...'
229
+ }));
230
+ } catch (err) {
231
+ // Client may already be closed
232
+ }
233
+ }
234
+
235
+ scheduleReconnect();
236
+ });
237
+
238
+ ws.on('error', (err) => {
239
+ log(`WebSocket error: ${err.message}`, colors.red);
240
+ });
241
+ }
242
+
243
+ function send(msg) {
244
+ if (ws && ws.readyState === WebSocket.OPEN) {
245
+ ws.send(JSON.stringify(msg));
246
+ return true;
247
+ }
248
+ return false;
249
+ }
250
+
251
+ function scheduleReconnect() {
252
+ if (reconnectTimer) return;
253
+ log(`Reconnecting in ${RECONNECT_DELAY_MS / 1000}s...`, colors.dim);
254
+ reconnectTimer = setTimeout(() => {
255
+ reconnectTimer = null;
256
+ connect();
257
+ }, RECONNECT_DELAY_MS);
258
+ }
259
+
260
+ function startHeartbeat() {
261
+ stopHeartbeat();
262
+ heartbeatTimer = setInterval(() => {
263
+ send({ type: 'agent_heartbeat' });
264
+ }, HEARTBEAT_INTERVAL_MS);
265
+ }
266
+
267
+ function stopHeartbeat() {
268
+ if (heartbeatTimer) {
269
+ clearInterval(heartbeatTimer);
270
+ heartbeatTimer = null;
271
+ }
272
+ }
273
+
274
+ // ====================
275
+ // Local Server (for vibe-cli connections)
276
+ // ====================
277
+
278
+ function startLocalServer() {
279
+ try {
280
+ localServer = new WebSocketServer({ port: LOCAL_SERVER_PORT });
281
+
282
+ localServer.on('listening', () => {
283
+ log(`Local server listening on port ${LOCAL_SERVER_PORT}`, colors.green);
284
+ // Write port file for auto-discovery
285
+ try {
286
+ fs.writeFileSync(PORT_FILE, LOCAL_SERVER_PORT.toString(), 'utf8');
287
+ } catch (err) {
288
+ // Ignore
289
+ }
290
+ });
291
+
292
+ localServer.on('connection', (clientWs) => {
293
+ log('Local vibe-cli connected', colors.cyan);
294
+
295
+ localClients.set(clientWs, {
296
+ sessionId: null,
297
+ authenticated: false
298
+ });
299
+
300
+ clientWs.on('message', (data) => {
301
+ try {
302
+ const msg = JSON.parse(data.toString());
303
+ handleLocalMessage(clientWs, msg);
304
+ } catch (err) {
305
+ log(`Failed to parse local message: ${err.message}`, colors.red);
306
+ }
307
+ });
308
+
309
+ clientWs.on('close', () => {
310
+ const clientInfo = localClients.get(clientWs);
311
+ if (clientInfo?.sessionId) {
312
+ const sessionId = clientInfo.sessionId;
313
+ const wasIntentionalStop = stoppingSessions.has(sessionId);
314
+ stoppingSessions.delete(sessionId); // Clean up
315
+
316
+ log(`Local session ${sessionId.slice(0, 8)} ${wasIntentionalStop ? 'stopped' : 'disconnected'}`, colors.dim);
317
+ runningSessions.delete(sessionId);
318
+ // Notify bridge
319
+ send({
320
+ type: 'agent_session_ended',
321
+ sessionId: sessionId,
322
+ exitCode: 0,
323
+ reason: wasIntentionalStop ? 'stopped_by_user' : 'disconnected'
324
+ });
325
+ }
326
+ localClients.delete(clientWs);
327
+ });
328
+
329
+ clientWs.on('error', (err) => {
330
+ log(`Local client error: ${err.message}`, colors.red);
331
+ });
332
+ });
333
+
334
+ localServer.on('error', (err) => {
335
+ if (err.code === 'EADDRINUSE') {
336
+ log(`Port ${LOCAL_SERVER_PORT} in use - another agent running?`, colors.red);
337
+ } else {
338
+ log(`Local server error: ${err.message}`, colors.red);
339
+ }
340
+ });
341
+
342
+ } catch (err) {
343
+ log(`Failed to start local server: ${err.message}`, colors.red);
344
+ }
345
+ }
346
+
347
+ function stopLocalServer() {
348
+ if (localServer) {
349
+ localServer.close();
350
+ localServer = null;
351
+ }
352
+ // Remove port file
353
+ try {
354
+ if (fs.existsSync(PORT_FILE)) {
355
+ fs.unlinkSync(PORT_FILE);
356
+ }
357
+ } catch (err) {
358
+ // Ignore
359
+ }
360
+ }
361
+
362
+ function handleLocalMessage(clientWs, msg) {
363
+ const clientInfo = localClients.get(clientWs);
364
+
365
+ switch (msg.type) {
366
+ // Authentication: local clients inherit agent's auth
367
+ case 'authenticate':
368
+ // Local clients don't need to re-authenticate - they inherit agent's session
369
+ clientInfo.authenticated = true;
370
+ clientWs.send(JSON.stringify({
371
+ type: 'authenticated',
372
+ userId: 'local',
373
+ email: 'via-agent'
374
+ }));
375
+ break;
376
+
377
+ // Session registration: track locally and relay to bridge
378
+ case 'register_session':
379
+ const sessionId = msg.sessionId;
380
+ clientInfo.sessionId = sessionId;
381
+
382
+ // Track this session as managed by agent
383
+ runningSessions.set(sessionId, {
384
+ localWs: clientWs,
385
+ path: msg.path || process.cwd(),
386
+ name: msg.name || path.basename(msg.path || process.cwd()),
387
+ startedAt: new Date().toISOString(),
388
+ managed: true // Indicates connected via local server, not spawned
389
+ });
390
+
391
+ log(`Local session registered: ${sessionId.slice(0, 8)} (${msg.name || msg.path})`, colors.green);
392
+
393
+ // Check if agent is authenticated (has agentId)
394
+ if (!agentId) {
395
+ log('Warning: Agent not authenticated yet, session will not be managed', colors.yellow);
396
+ }
397
+
398
+ // Relay to bridge with agentId so bridge knows this session is managed
399
+ const registrationMsg = {
400
+ ...msg,
401
+ agentId: agentId || null, // null if not yet authenticated
402
+ agentHostName: agentId ? hostName : null
403
+ };
404
+ if (send(registrationMsg)) {
405
+ // Bridge will respond with session_registered
406
+ } else {
407
+ clientWs.send(JSON.stringify({
408
+ type: 'error',
409
+ message: 'Not connected to bridge'
410
+ }));
411
+ }
412
+ break;
413
+
414
+ // All other messages: relay to bridge
415
+ default:
416
+ // Add sessionId if not present
417
+ if (!msg.sessionId && clientInfo.sessionId) {
418
+ msg.sessionId = clientInfo.sessionId;
419
+ }
420
+
421
+ if (send(msg)) {
422
+ // Message relayed successfully
423
+ } else {
424
+ clientWs.send(JSON.stringify({
425
+ type: 'error',
426
+ message: 'Not connected to bridge'
427
+ }));
428
+ }
429
+ break;
430
+ }
431
+ }
432
+
433
+ // Relay bridge messages to appropriate local client
434
+ function relayToLocalClient(msg) {
435
+ const sessionId = msg.sessionId;
436
+ if (!sessionId) return false;
437
+
438
+ const session = runningSessions.get(sessionId);
439
+ if (!session?.localWs) return false;
440
+
441
+ try {
442
+ session.localWs.send(JSON.stringify(msg));
443
+ return true;
444
+ } catch (err) {
445
+ log(`Failed to relay to local client: ${err.message}`, colors.red);
446
+ return false;
447
+ }
448
+ }
449
+
450
+ // ====================
451
+ // Message Handling
452
+ // ====================
453
+
454
+ function handleMessage(msg) {
455
+ switch (msg.type) {
456
+ case 'authenticated':
457
+ isAuthenticated = true;
458
+ log(`Authenticated as ${msg.email || msg.userId}`, colors.green);
459
+
460
+ // Register as an agent
461
+ if (!agentId) {
462
+ agentId = uuidv4();
463
+ // Persist agentId so it survives restarts
464
+ const config = loadConfig();
465
+ config.agentId = agentId;
466
+ saveConfig(config);
467
+ log(`Generated new agent ID: ${agentId.slice(0, 8)}...`, colors.dim);
468
+ }
469
+ send({
470
+ type: 'agent_register',
471
+ agentId,
472
+ hostName,
473
+ platform: process.platform,
474
+ activeSessions: Array.from(runningSessions.keys())
475
+ });
476
+ startHeartbeat();
477
+
478
+ // Re-register all sessions with bridge after reconnect
479
+ for (const [sessionId, session] of runningSessions) {
480
+ log(`Re-registering session: ${sessionId.slice(0, 8)} (${session.managed ? 'managed' : 'spawned'})`, colors.dim);
481
+ send({
482
+ type: 'register_session',
483
+ sessionId,
484
+ path: session.path,
485
+ name: session.name,
486
+ agentId: agentId,
487
+ agentHostName: hostName
488
+ });
489
+ }
490
+
491
+ // Notify local clients that bridge is reconnected
492
+ for (const [clientWs, clientInfo] of localClients) {
493
+ try {
494
+ clientWs.send(JSON.stringify({
495
+ type: 'bridge_reconnected',
496
+ message: 'Bridge connection restored'
497
+ }));
498
+ } catch (err) {
499
+ // Client may already be closed
500
+ }
501
+ }
502
+ break;
503
+
504
+ case 'auth_error':
505
+ isAuthenticated = false;
506
+ log(`Authentication failed: ${msg.message}`, colors.yellow);
507
+
508
+ // Try to refresh token
509
+ (async () => {
510
+ const newToken = await refreshIdToken();
511
+ if (newToken) {
512
+ authToken = newToken;
513
+ send({ type: 'authenticate', token: newToken });
514
+ } else {
515
+ log('Please re-login: vibe-agent --login', colors.red);
516
+ }
517
+ })();
518
+ break;
519
+
520
+ case 'agent_registered':
521
+ log(`Agent registered: ${msg.agentId}`, colors.green);
522
+ log(`Host: ${hostName}`, colors.dim);
523
+ log('Waiting for commands...', colors.cyan);
524
+ break;
525
+
526
+ case 'start_session':
527
+ handleStartSession(msg);
528
+ break;
529
+
530
+ case 'resume_session':
531
+ handleResumeSession(msg);
532
+ break;
533
+
534
+ case 'stop_session':
535
+ handleStopSession(msg);
536
+ break;
537
+
538
+ case 'list_agent_sessions':
539
+ send({
540
+ type: 'agent_sessions',
541
+ sessions: Array.from(runningSessions.entries()).map(([id, s]) => ({
542
+ sessionId: id,
543
+ path: s.path,
544
+ name: s.name,
545
+ status: 'active'
546
+ }))
547
+ });
548
+ break;
549
+
550
+ case 'error':
551
+ log(`Bridge error: ${msg.message}`, colors.red);
552
+ // Relay errors to local client if applicable
553
+ relayToLocalClient(msg);
554
+ break;
555
+
556
+ // Messages to relay to local vibe-cli clients
557
+ case 'session_registered':
558
+ case 'joined_session':
559
+ case 'message_history':
560
+ case 'claude_message':
561
+ case 'permission_request':
562
+ case 'session_status':
563
+ case 'session_ended':
564
+ case 'session_renamed':
565
+ case 'user_message':
566
+ case 'permission_approved':
567
+ case 'permission_denied':
568
+ // These are responses/events for local sessions - relay them
569
+ relayToLocalClient(msg);
570
+ break;
571
+
572
+ default:
573
+ // Try to relay unknown messages to local client
574
+ if (msg.sessionId) {
575
+ relayToLocalClient(msg);
576
+ }
577
+ break;
578
+ }
579
+ }
580
+
581
+ // ====================
582
+ // Session Management
583
+ // ====================
584
+
585
+ function findVibeCli() {
586
+ // Look for vibe.js in common locations
587
+ const locations = [
588
+ path.join(__dirname, '..', 'vibe-cli', 'vibe.js'),
589
+ path.join(os.homedir(), 'vibe-cli', 'vibe.js'),
590
+ ];
591
+
592
+ // Add platform-specific locations
593
+ if (process.platform === 'win32') {
594
+ locations.push(path.join(os.homedir(), 'AppData', 'Local', 'vibe-cli', 'vibe.js'));
595
+ } else {
596
+ locations.push('/usr/local/bin/vibe');
597
+ }
598
+
599
+ for (const loc of locations) {
600
+ if (fs.existsSync(loc)) {
601
+ return loc;
602
+ }
603
+ }
604
+
605
+ // Try to find via which/where
606
+ try {
607
+ const cmd = process.platform === 'win32' ? 'where vibe' : 'which vibe';
608
+ const result = execSync(cmd, { encoding: 'utf8' }).trim();
609
+ // 'where' on Windows can return multiple lines, take the first
610
+ return result.split('\n')[0].trim();
611
+ } catch {
612
+ return null;
613
+ }
614
+ }
615
+
616
+ function handleStartSession(msg) {
617
+ const { sessionId, path: projectPath, name, prompt, requestId } = msg;
618
+
619
+ log(`Starting session: ${name || projectPath || 'new'}`, colors.cyan);
620
+
621
+ const vibeCli = findVibeCli();
622
+ if (!vibeCli) {
623
+ log('vibe-cli not found!', colors.red);
624
+ send({
625
+ type: 'agent_session_error',
626
+ requestId,
627
+ sessionId,
628
+ error: 'vibe-cli not found on this host'
629
+ });
630
+ return;
631
+ }
632
+
633
+ // Build args - use --agent to connect via local server
634
+ const args = ['--agent', `ws://localhost:${LOCAL_SERVER_PORT}`];
635
+
636
+ if (name) {
637
+ args.push('--name', name);
638
+ }
639
+
640
+ if (prompt) {
641
+ args.push(prompt);
642
+ }
643
+
644
+ // Spawn vibe-cli - expand ~ to home directory
645
+ let cwd = projectPath || os.homedir();
646
+ if (cwd.startsWith('~')) {
647
+ cwd = cwd.replace(/^~/, os.homedir());
648
+ }
649
+
650
+ // Validate path exists
651
+ if (!fs.existsSync(cwd)) {
652
+ log(`Path does not exist: ${cwd}`, colors.red);
653
+ send({
654
+ type: 'agent_session_error',
655
+ requestId,
656
+ sessionId,
657
+ error: `Path does not exist: ${cwd}`
658
+ });
659
+ return;
660
+ }
661
+
662
+ log(`Spawning: node ${vibeCli} ${args.join(' ')}`, colors.dim);
663
+ log(`Working directory: ${cwd}`, colors.dim);
664
+
665
+ try {
666
+ const proc = spawn('node', [vibeCli, ...args], {
667
+ cwd,
668
+ stdio: ['pipe', 'pipe', 'pipe'],
669
+ detached: false
670
+ });
671
+
672
+ const newSessionId = sessionId || uuidv4();
673
+
674
+ runningSessions.set(newSessionId, {
675
+ process: proc,
676
+ path: cwd,
677
+ name: name || path.basename(cwd),
678
+ startedAt: new Date().toISOString()
679
+ });
680
+
681
+ proc.stdout.on('data', (data) => {
682
+ // Log output for debugging
683
+ const output = data.toString().trim();
684
+ if (output) {
685
+ log(`[${newSessionId.slice(0, 8)}] ${output}`, colors.dim);
686
+ }
687
+ });
688
+
689
+ proc.stderr.on('data', (data) => {
690
+ const output = data.toString().trim();
691
+ if (output) {
692
+ log(`[${newSessionId.slice(0, 8)}] ${output}`, colors.yellow);
693
+ }
694
+ });
695
+
696
+ proc.on('exit', (code) => {
697
+ log(`Session ${newSessionId.slice(0, 8)} exited with code ${code}`, colors.dim);
698
+ runningSessions.delete(newSessionId);
699
+
700
+ send({
701
+ type: 'agent_session_ended',
702
+ sessionId: newSessionId,
703
+ exitCode: code
704
+ });
705
+ });
706
+
707
+ proc.on('error', (err) => {
708
+ log(`Session error: ${err.message}`, colors.red);
709
+ runningSessions.delete(newSessionId);
710
+
711
+ send({
712
+ type: 'agent_session_error',
713
+ requestId,
714
+ sessionId: newSessionId,
715
+ error: err.message
716
+ });
717
+ });
718
+
719
+ // Notify bridge that session started
720
+ send({
721
+ type: 'agent_session_started',
722
+ requestId,
723
+ sessionId: newSessionId,
724
+ path: cwd,
725
+ name: name || path.basename(cwd)
726
+ });
727
+
728
+ log(`Session started: ${newSessionId.slice(0, 8)}`, colors.green);
729
+
730
+ } catch (err) {
731
+ log(`Failed to start session: ${err.message}`, colors.red);
732
+ send({
733
+ type: 'agent_session_error',
734
+ requestId,
735
+ sessionId,
736
+ error: err.message
737
+ });
738
+ }
739
+ }
740
+
741
+ function handleResumeSession(msg) {
742
+ const { sessionId, path: projectPath, name, requestId } = msg;
743
+
744
+ log(`Resuming session: ${sessionId.slice(0, 8)}`, colors.cyan);
745
+
746
+ // Check if already running
747
+ if (runningSessions.has(sessionId)) {
748
+ log('Session is already running', colors.yellow);
749
+ send({
750
+ type: 'agent_session_error',
751
+ requestId,
752
+ sessionId,
753
+ error: 'Session is already running'
754
+ });
755
+ return;
756
+ }
757
+
758
+ const vibeCli = findVibeCli();
759
+ if (!vibeCli) {
760
+ log('vibe-cli not found!', colors.red);
761
+ send({
762
+ type: 'agent_session_error',
763
+ requestId,
764
+ sessionId,
765
+ error: 'vibe-cli not found on this host'
766
+ });
767
+ return;
768
+ }
769
+
770
+ // Build args with --resume - use --agent to connect via local server
771
+ const args = ['--agent', `ws://localhost:${LOCAL_SERVER_PORT}`, '--resume', sessionId];
772
+
773
+ if (name) {
774
+ args.push('--name', name);
775
+ }
776
+
777
+ // Spawn vibe-cli with resume - expand ~ to home directory
778
+ let cwd = projectPath || os.homedir();
779
+ if (cwd.startsWith('~')) {
780
+ cwd = cwd.replace(/^~/, os.homedir());
781
+ }
782
+
783
+ // Validate path exists
784
+ if (!fs.existsSync(cwd)) {
785
+ log(`Path does not exist: ${cwd}`, colors.red);
786
+ send({
787
+ type: 'agent_session_error',
788
+ requestId,
789
+ sessionId,
790
+ error: `Path does not exist: ${cwd}`
791
+ });
792
+ return;
793
+ }
794
+
795
+ log(`Spawning: node ${vibeCli} ${args.join(' ')}`, colors.dim);
796
+
797
+ try {
798
+ const proc = spawn('node', [vibeCli, ...args], {
799
+ cwd,
800
+ stdio: ['pipe', 'pipe', 'pipe'],
801
+ detached: false
802
+ });
803
+
804
+ runningSessions.set(sessionId, {
805
+ process: proc,
806
+ path: cwd,
807
+ name: name || path.basename(cwd),
808
+ startedAt: new Date().toISOString()
809
+ });
810
+
811
+ proc.stdout.on('data', (data) => {
812
+ const output = data.toString().trim();
813
+ if (output) {
814
+ log(`[${sessionId.slice(0, 8)}] ${output}`, colors.dim);
815
+ }
816
+ });
817
+
818
+ proc.stderr.on('data', (data) => {
819
+ const output = data.toString().trim();
820
+ if (output) {
821
+ log(`[${sessionId.slice(0, 8)}] ${output}`, colors.yellow);
822
+ }
823
+ });
824
+
825
+ proc.on('exit', (code) => {
826
+ log(`Session ${sessionId.slice(0, 8)} exited with code ${code}`, colors.dim);
827
+ runningSessions.delete(sessionId);
828
+
829
+ send({
830
+ type: 'agent_session_ended',
831
+ sessionId,
832
+ exitCode: code
833
+ });
834
+ });
835
+
836
+ proc.on('error', (err) => {
837
+ log(`Session error: ${err.message}`, colors.red);
838
+ runningSessions.delete(sessionId);
839
+
840
+ send({
841
+ type: 'agent_session_error',
842
+ requestId,
843
+ sessionId,
844
+ error: err.message
845
+ });
846
+ });
847
+
848
+ // Notify bridge
849
+ send({
850
+ type: 'agent_session_resumed',
851
+ requestId,
852
+ sessionId,
853
+ path: cwd,
854
+ name: name || path.basename(cwd)
855
+ });
856
+
857
+ log(`Session resumed: ${sessionId.slice(0, 8)}`, colors.green);
858
+
859
+ } catch (err) {
860
+ log(`Failed to resume session: ${err.message}`, colors.red);
861
+ send({
862
+ type: 'agent_session_error',
863
+ requestId,
864
+ sessionId,
865
+ error: err.message
866
+ });
867
+ }
868
+ }
869
+
870
+ function handleStopSession(msg) {
871
+ const { sessionId, requestId } = msg;
872
+
873
+ const session = runningSessions.get(sessionId);
874
+ if (!session) {
875
+ send({
876
+ type: 'agent_session_error',
877
+ requestId,
878
+ sessionId,
879
+ error: 'Session not found'
880
+ });
881
+ return;
882
+ }
883
+
884
+ log(`Stopping session: ${sessionId.slice(0, 8)}`, colors.yellow);
885
+
886
+ try {
887
+ if (session.process) {
888
+ // Spawned session - kill the process
889
+ // On Windows, kill() without signal terminates the process
890
+ // On Unix, SIGTERM is the graceful termination signal
891
+ if (process.platform === 'win32') {
892
+ session.process.kill();
893
+ } else {
894
+ session.process.kill('SIGTERM');
895
+
896
+ // Force kill after timeout (Unix only, Windows kill() is already forceful)
897
+ setTimeout(() => {
898
+ if (runningSessions.has(sessionId) && session.process) {
899
+ session.process.kill('SIGKILL');
900
+ }
901
+ }, 5000);
902
+ }
903
+ } else if (session.localWs) {
904
+ // Managed session (connected via --agent) - send stop command first
905
+ // This tells vibe-cli to NOT reconnect after disconnect
906
+ stoppingSessions.add(sessionId); // Track intentional stop
907
+ try {
908
+ session.localWs.send(JSON.stringify({
909
+ type: 'session_stop',
910
+ sessionId,
911
+ reason: 'stopped_by_user'
912
+ }));
913
+ } catch (err) {
914
+ // Ignore send errors
915
+ }
916
+ // Give vibe-cli time to process the stop message before closing
917
+ setTimeout(() => {
918
+ try {
919
+ session.localWs.close(1000, 'Stopped by agent');
920
+ } catch (err) {
921
+ // Already closed
922
+ }
923
+ }, 100);
924
+ runningSessions.delete(sessionId);
925
+ }
926
+
927
+ send({
928
+ type: 'agent_session_stopping',
929
+ requestId,
930
+ sessionId
931
+ });
932
+ } catch (err) {
933
+ log(`Failed to stop session: ${err.message}`, colors.red);
934
+ send({
935
+ type: 'agent_session_error',
936
+ requestId,
937
+ sessionId,
938
+ error: err.message
939
+ });
940
+ }
941
+ }
942
+
943
+ // ====================
944
+ // Headless Login
945
+ // ====================
946
+
947
+ async function startHeadlessLogin(bridgeHttpUrl) {
948
+ console.log(`
949
+ ${colors.cyan}${colors.bold}vibe-agent Headless Login${colors.reset}
950
+ ${'='.repeat(40)}
951
+ `);
952
+
953
+ try {
954
+ log('Requesting device code...', colors.dim);
955
+ const codeRes = await fetch(`${bridgeHttpUrl}/device/code`, {
956
+ method: 'POST',
957
+ headers: { 'Content-Type': 'application/json' }
958
+ });
959
+
960
+ if (!codeRes.ok) {
961
+ log(`Failed to get device code: ${codeRes.status}`, colors.red);
962
+ process.exit(1);
963
+ }
964
+
965
+ const { deviceId, code, expiresIn } = await codeRes.json();
966
+
967
+ console.log(` Visit: ${bridgeHttpUrl}/device`);
968
+ console.log(` Code: ${colors.bold}${code}${colors.reset}`);
969
+ console.log('');
970
+ console.log(` Code expires in ${Math.floor(expiresIn / 60)} minutes.`);
971
+ console.log(' Waiting for authentication...');
972
+ console.log('');
973
+
974
+ // Poll for token
975
+ const pollInterval = 3000;
976
+ const maxAttempts = Math.ceil((expiresIn * 1000) / pollInterval);
977
+
978
+ for (let i = 0; i < maxAttempts; i++) {
979
+ await new Promise(r => setTimeout(r, pollInterval));
980
+
981
+ try {
982
+ const pollRes = await fetch(`${bridgeHttpUrl}/device/poll/${deviceId}`);
983
+ const pollData = await pollRes.json();
984
+
985
+ if (pollData.status === 'complete') {
986
+ saveAuth(pollData.token, pollData.refreshToken || null);
987
+ console.log('');
988
+ log(`Logged in as ${pollData.email}`, colors.green);
989
+ log(`Auth saved to ${AUTH_FILE}`, colors.dim);
990
+ if (pollData.refreshToken) {
991
+ log('Token auto-refresh enabled', colors.dim);
992
+ }
993
+ process.exit(0);
994
+ } else if (pollRes.status === 404 || pollData.error === 'Device not found or expired') {
995
+ console.log('\n\nCode expired. Please try again.');
996
+ process.exit(1);
997
+ }
998
+
999
+ process.stdout.write('.');
1000
+ } catch (err) {
1001
+ process.stdout.write('!');
1002
+ }
1003
+ }
1004
+
1005
+ console.log('\n\nLogin timed out.');
1006
+ process.exit(1);
1007
+
1008
+ } catch (err) {
1009
+ log(`Login failed: ${err.message}`, colors.red);
1010
+ process.exit(1);
1011
+ }
1012
+ }
1013
+
1014
+ // ====================
1015
+ // CLI Argument Parsing
1016
+ // ====================
1017
+
1018
+ function printHelp() {
1019
+ console.log(`
1020
+ ${colors.cyan}${colors.bold}vibe-agent${colors.reset} - Persistent daemon for remote Claude Code sessions
1021
+
1022
+ ${colors.bold}Usage:${colors.reset}
1023
+ vibe-agent --bridge <url> Start agent connected to bridge
1024
+ vibe-agent --login --bridge <url> Login via device code flow
1025
+ vibe-agent --status Show agent status
1026
+
1027
+ ${colors.bold}Options:${colors.reset}
1028
+ --bridge <url> Bridge server URL (wss://ws.neng.ai)
1029
+ --login Start device code login flow
1030
+ --token <token> Use specific Firebase token
1031
+ --name <name> Set host display name
1032
+ --status Show current status and exit
1033
+ --help, -h Show this help
1034
+
1035
+ ${colors.bold}Examples:${colors.reset}
1036
+ # Login (first time)
1037
+ vibe-agent --login --bridge wss://ws.neng.ai
1038
+
1039
+ # Start agent daemon
1040
+ vibe-agent --bridge wss://ws.neng.ai
1041
+
1042
+ # Start with custom host name
1043
+ vibe-agent --bridge wss://ws.neng.ai --name "AWS Dev Server"
1044
+ `);
1045
+ }
1046
+
1047
+ function parseArgs() {
1048
+ const args = process.argv.slice(2);
1049
+ const options = {
1050
+ bridge: null,
1051
+ token: null,
1052
+ login: false,
1053
+ name: null,
1054
+ status: false,
1055
+ help: false
1056
+ };
1057
+
1058
+ for (let i = 0; i < args.length; i++) {
1059
+ const arg = args[i];
1060
+ switch (arg) {
1061
+ case '--bridge':
1062
+ options.bridge = args[++i];
1063
+ break;
1064
+ case '--token':
1065
+ options.token = args[++i];
1066
+ break;
1067
+ case '--login':
1068
+ options.login = true;
1069
+ break;
1070
+ case '--name':
1071
+ options.name = args[++i];
1072
+ break;
1073
+ case '--status':
1074
+ options.status = true;
1075
+ break;
1076
+ case '--help':
1077
+ case '-h':
1078
+ options.help = true;
1079
+ break;
1080
+ }
1081
+ }
1082
+
1083
+ return options;
1084
+ }
1085
+
1086
+ // ====================
1087
+ // Main
1088
+ // ====================
1089
+
1090
+ async function main() {
1091
+ const options = parseArgs();
1092
+
1093
+ if (options.help) {
1094
+ printHelp();
1095
+ process.exit(0);
1096
+ }
1097
+
1098
+ // Load saved config
1099
+ const config = loadConfig();
1100
+
1101
+ bridgeUrl = options.bridge || config.bridgeUrl;
1102
+ hostName = options.name || config.hostName || os.hostname();
1103
+ agentId = config.agentId || null; // Load persisted agentId
1104
+
1105
+ if (options.token) {
1106
+ authToken = options.token;
1107
+ saveAuth(authToken);
1108
+ } else {
1109
+ const auth = loadAuth();
1110
+ authToken = auth?.idToken;
1111
+ }
1112
+
1113
+ // Save config for next time
1114
+ if (bridgeUrl) {
1115
+ config.bridgeUrl = bridgeUrl;
1116
+ }
1117
+ if (options.name) {
1118
+ config.hostName = hostName;
1119
+ }
1120
+ saveConfig(config);
1121
+
1122
+ // Status check
1123
+ if (options.status) {
1124
+ console.log(`Bridge URL: ${bridgeUrl || 'Not configured'}`);
1125
+ console.log(`Host Name: ${hostName}`);
1126
+ console.log(`Auth Token: ${authToken ? 'Configured' : 'Not configured'}`);
1127
+ console.log(`Agent ID: ${agentId || 'Will be assigned on first connect'}`);
1128
+ process.exit(0);
1129
+ }
1130
+
1131
+ // Login flow
1132
+ if (options.login) {
1133
+ if (!bridgeUrl) {
1134
+ log('--bridge URL required for login', colors.red);
1135
+ process.exit(1);
1136
+ }
1137
+ const httpUrl = bridgeUrl.replace('wss://', 'https://').replace('ws://', 'http://');
1138
+ await startHeadlessLogin(httpUrl);
1139
+ return;
1140
+ }
1141
+
1142
+ // Validate requirements
1143
+ if (!bridgeUrl) {
1144
+ log('No bridge URL. Run: vibe-agent --bridge wss://ws.neng.ai', colors.red);
1145
+ process.exit(1);
1146
+ }
1147
+
1148
+ if (!authToken) {
1149
+ log('No auth token. Run: vibe-agent --login --bridge <url>', colors.yellow);
1150
+ }
1151
+
1152
+ // Banner
1153
+ console.log(`
1154
+ ${colors.cyan}${colors.bold}vibe-agent${colors.reset}
1155
+ ${'='.repeat(40)}
1156
+ Host: ${hostName}
1157
+ Bridge: ${bridgeUrl}
1158
+ Local: ws://localhost:${LOCAL_SERVER_PORT}
1159
+ Auth: ${authToken ? 'Configured' : 'Not configured'}
1160
+ ${'='.repeat(40)}
1161
+ `);
1162
+
1163
+ // Start local server for vibe-cli connections
1164
+ startLocalServer();
1165
+
1166
+ // Connect to bridge
1167
+ connect();
1168
+
1169
+ // Handle shutdown (SIGINT works on both Unix and Windows for Ctrl+C)
1170
+ const shutdown = () => {
1171
+ log('Shutting down...', colors.yellow);
1172
+
1173
+ // Stop all sessions (both spawned and managed)
1174
+ for (const [sessionId, session] of runningSessions) {
1175
+ log(`Stopping session ${sessionId.slice(0, 8)}`, colors.dim);
1176
+ if (session.process) {
1177
+ try {
1178
+ // On Windows, kill() without signal terminates the process
1179
+ // On Unix, SIGTERM is the graceful termination signal
1180
+ if (process.platform === 'win32') {
1181
+ session.process.kill();
1182
+ } else {
1183
+ session.process.kill('SIGTERM');
1184
+ }
1185
+ } catch (err) {
1186
+ // Ignore
1187
+ }
1188
+ } else if (session.localWs) {
1189
+ try {
1190
+ session.localWs.close(1001, 'Agent shutting down');
1191
+ } catch (err) {
1192
+ // Ignore
1193
+ }
1194
+ }
1195
+ }
1196
+
1197
+ // Stop local server (closes remaining client connections)
1198
+ stopLocalServer();
1199
+
1200
+ if (ws) {
1201
+ ws.close();
1202
+ }
1203
+
1204
+ setTimeout(() => process.exit(0), 1000);
1205
+ };
1206
+
1207
+ process.on('SIGINT', shutdown);
1208
+
1209
+ // SIGTERM only exists on Unix
1210
+ if (process.platform !== 'win32') {
1211
+ process.on('SIGTERM', shutdown);
1212
+ }
1213
+ }
1214
+
1215
+ main().catch(err => {
1216
+ log(`Fatal error: ${err.message}`, colors.red);
1217
+ process.exit(1);
1218
+ });