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/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
|
+
}
|