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 +1218 -0
- package/login.html +331 -0
- package/package.json +49 -0
- package/pty-wrapper-node.js +157 -0
- package/pty-wrapper.py +225 -0
- package/vibe.js +1621 -0
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
|
+
});
|