upfynai-code 2.4.0 → 2.4.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "upfynai-code",
3
- "version": "2.4.0",
3
+ "version": "2.4.1",
4
4
  "description": "Upfyn-Code — Visual AI coding interface with Upfyn-Canvas whiteboard for AI coding assistants by Thinqmesh Technologies",
5
5
  "type": "module",
6
6
  "main": "server/index.js",
package/server/cli.js CHANGED
@@ -325,12 +325,17 @@ async function launchInteractive() {
325
325
  childEnv.ANTHROPIC_API_KEY = config.anthropicApiKey;
326
326
  }
327
327
 
328
- // 8. Start background relay if config exists
328
+ // 8. Start the local web UI server in the background
329
+ const port = process.env.PORT || '3001';
330
+ process.env.VITE_IS_PLATFORM = 'true'; // local mode
331
+ startBackgroundServer(port);
332
+
333
+ // 9. Start background relay if config exists (for cloud mode)
329
334
  if (config.relayKey && config.server) {
330
335
  startBackgroundRelay(config);
331
336
  }
332
337
 
333
- // 9. Spawn Claude Code interactively
338
+ // 10. Spawn Claude Code interactively
334
339
  const child = spawn(claudeBin, [], {
335
340
  stdio: 'inherit',
336
341
  cwd: process.cwd(),
@@ -350,6 +355,29 @@ async function launchInteractive() {
350
355
  });
351
356
  }
352
357
 
358
+ // --- Background server for local mode ---
359
+ function startBackgroundServer(port) {
360
+ // Start the server in the background so the web UI is available
361
+ import('./index.js').then(() => {
362
+ // Server started successfully in background
363
+ }).catch(() => {
364
+ // Server failed to start — user still has Claude Code CLI
365
+ });
366
+
367
+ // Open browser after a short delay
368
+ setTimeout(() => {
369
+ const url = `http://localhost:${port}`;
370
+ try {
371
+ const openCmd = process.platform === 'win32' ? 'start'
372
+ : process.platform === 'darwin' ? 'open'
373
+ : 'xdg-open';
374
+ execSync(`${openCmd} ${url}`, { stdio: 'ignore' });
375
+ } catch {
376
+ // Browser open failed — not critical
377
+ }
378
+ }, 2000);
379
+ }
380
+
353
381
  // --- Background relay connection ---
354
382
  function startBackgroundRelay(config) {
355
383
  // Import and start relay in background (non-blocking)
@@ -366,17 +394,48 @@ function startBackgroundRelay(config) {
366
394
  });
367
395
  }
368
396
 
369
- // Start the server
397
+ // Start the server (self-hosted local mode)
370
398
  async function startServer() {
371
399
  // Check for updates silently on startup
372
400
  checkForUpdates(true);
373
401
 
374
- // Show server banner
375
402
  const port = process.env.PORT || '3001';
403
+
404
+ // Auto-detect local mode — set IS_PLATFORM flag
405
+ if (!process.env.RAILWAY_ENVIRONMENT && !process.env.VERCEL && !process.env.FORCE_HOSTED_MODE) {
406
+ process.env.VITE_IS_PLATFORM = 'true';
407
+ }
408
+
409
+ // Show server banner
376
410
  showServerBanner(port, packageJson.version);
377
411
 
412
+ // Detect local agents
413
+ const claudeBin = findClaudeBinary();
414
+ if (claudeBin) {
415
+ console.log(` ${c.green('OK')} Claude Code detected: ${c.bright(claudeBin)}`);
416
+ } else {
417
+ console.log(` ${c.yellow('!')} Claude Code not found. Install: ${c.bright('npm i -g @anthropic-ai/claude-code')}`);
418
+ }
419
+ console.log('');
420
+
378
421
  // Import and run the server
379
422
  await import('./index.js');
423
+
424
+ // Auto-open browser after server starts (local mode only)
425
+ if (!process.env.RAILWAY_ENVIRONMENT && !process.env.VERCEL) {
426
+ const url = `http://localhost:${port}`;
427
+ setTimeout(() => {
428
+ try {
429
+ const openCmd = process.platform === 'win32' ? 'start'
430
+ : process.platform === 'darwin' ? 'open'
431
+ : 'xdg-open';
432
+ execSync(`${openCmd} ${url}`, { stdio: 'ignore' });
433
+ console.log(` ${c.green('OK')} Opened ${c.cyan(url)} in browser\n`);
434
+ } catch {
435
+ console.log(` ${c.dim('Open in browser:')} ${c.cyan(url)}\n`);
436
+ }
437
+ }, 1500);
438
+ }
380
439
  }
381
440
 
382
441
  // Parse CLI arguments
@@ -1,5 +1,31 @@
1
1
  /**
2
- * Environment Flag: Is Platform
3
- * Indicates if the app is running in Platform mode (hosted) or OSS mode (self-hosted)
2
+ * Environment Flag: Is Platform (Self-Hosted / Local Mode)
3
+ *
4
+ * When true, the app runs in single-user local mode:
5
+ * - Skips JWT authentication (uses first DB user)
6
+ * - Claude Code SDK runs directly on the machine
7
+ * - No relay connection needed
8
+ *
9
+ * Auto-detected when:
10
+ * - VITE_IS_PLATFORM=true is set, OR
11
+ * - Running locally (not on Railway/Vercel/cloud)
4
12
  */
5
- export const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true';
13
+ const isCloudEnv = !!(
14
+ process.env.RAILWAY_ENVIRONMENT ||
15
+ process.env.VERCEL ||
16
+ process.env.RENDER ||
17
+ process.env.FLY_APP_NAME ||
18
+ process.env.HEROKU_APP_NAME
19
+ );
20
+
21
+ export const IS_PLATFORM = process.env.VITE_IS_PLATFORM === 'true' || (!isCloudEnv && !process.env.FORCE_HOSTED_MODE);
22
+
23
+ /**
24
+ * True when running on a cloud provider (Railway, Vercel, etc.)
25
+ */
26
+ export const IS_CLOUD = isCloudEnv;
27
+
28
+ /**
29
+ * True when running locally (self-hosted mode)
30
+ */
31
+ export const IS_LOCAL = IS_PLATFORM && !isCloudEnv;
Binary file
package/server/index.js CHANGED
@@ -70,9 +70,10 @@ import cliAuthRoutes from './routes/cli-auth.js';
70
70
  import userRoutes from './routes/user.js';
71
71
  import codexRoutes from './routes/codex.js';
72
72
  import paymentRoutes from './routes/payments.js';
73
- import { initializeDatabase, relayTokensDb, subscriptionDb, credentialsDb } from './database/db.js';
73
+ import { initializeDatabase, relayTokensDb, subscriptionDb, credentialsDb, userDb } from './database/db.js';
74
74
  import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
75
- import { IS_PLATFORM } from './constants/config.js';
75
+ import { IS_PLATFORM, IS_LOCAL } from './constants/config.js';
76
+ import { execSync } from 'child_process';
76
77
 
77
78
  // File system watchers for provider project/session folders
78
79
  const PROVIDER_WATCH_PATHS = [
@@ -539,12 +540,70 @@ app.get('/api/relay/status', authenticateToken, (req, res) => {
539
540
  });
540
541
  });
541
542
 
543
+ /**
544
+ * Detect installed AI CLI agents on the local machine (server-side).
545
+ * Used in self-hosted/local mode where no relay is needed.
546
+ */
547
+ let cachedLocalAgents = null;
548
+ let localAgentsCacheTime = 0;
549
+ function detectLocalAgents() {
550
+ // Cache for 60 seconds
551
+ if (cachedLocalAgents && Date.now() - localAgentsCacheTime < 60000) {
552
+ return cachedLocalAgents;
553
+ }
554
+ const isWindows = process.platform === 'win32';
555
+ const whichCmd = isWindows ? 'where' : 'which';
556
+ const agents = [
557
+ { name: 'claude', binary: 'claude', label: 'Claude Code' },
558
+ { name: 'codex', binary: 'codex', label: 'OpenAI Codex' },
559
+ { name: 'cursor', binary: 'cursor-agent', label: 'Cursor Agent' },
560
+ ];
561
+ const detected = {};
562
+ for (const agent of agents) {
563
+ try {
564
+ const result = execSync(`${whichCmd} ${agent.binary}`, { stdio: 'pipe', timeout: 5000 }).toString().trim();
565
+ detected[agent.name] = { installed: true, path: result.split('\n')[0].trim(), label: agent.label };
566
+ } catch {
567
+ detected[agent.name] = { installed: false, label: agent.label };
568
+ }
569
+ }
570
+ cachedLocalAgents = detected;
571
+ localAgentsCacheTime = Date.now();
572
+ return detected;
573
+ }
574
+
542
575
  // Connection status — alias at path the frontend expects
543
576
  app.get('/api/auth/connection-status', authenticateToken, (req, res) => {
544
577
  const relay = relayConnections.get(Number(req.user.id));
578
+ const connected = !!(relay && relay.ws.readyState === 1);
579
+
580
+ // In local mode, always "connected" — SDK runs directly on this machine
581
+ if (IS_LOCAL) {
582
+ const agents = detectLocalAgents();
583
+ return res.json({
584
+ connected: true,
585
+ local: true,
586
+ connectedAt: Date.now(),
587
+ agents,
588
+ machine: {
589
+ hostname: os.hostname(),
590
+ platform: process.platform,
591
+ cwd: process.cwd(),
592
+ }
593
+ });
594
+ }
595
+
545
596
  res.json({
546
- connected: !!(relay && relay.ws.readyState === 1),
547
- connectedAt: relay?.connectedAt || null
597
+ connected,
598
+ local: false,
599
+ connectedAt: relay?.connectedAt || null,
600
+ agents: connected ? (relay.agents || null) : null,
601
+ machine: connected ? {
602
+ hostname: relay.machine,
603
+ platform: relay.platform,
604
+ cwd: relay.cwd,
605
+ version: relay.version,
606
+ } : null
548
607
  });
549
608
  });
550
609
 
@@ -1128,35 +1187,51 @@ function handleChatConnection(ws, request) {
1128
1187
  }
1129
1188
  if (sid) lockedSessionsForThisWs.add(sid);
1130
1189
 
1131
- console.log('[DEBUG] User message:', data.command || '[Continue/Resume]');
1132
- console.log('📁 Project:', data.options?.projectPath || 'Unknown');
1133
-
1134
- // BYOK: look up user's Anthropic API key, inject if available
1135
- const userAnthropicKey = wsUser?.userId
1136
- ? await getUserProviderKey(wsUser.userId, 'anthropic_key')
1137
- : null;
1138
-
1139
- await withUserApiKey('ANTHROPIC_API_KEY', userAnthropicKey, () =>
1140
- queryClaudeSDK(data.command, data.options, writer)
1141
- );
1190
+ // Check if user has active relay → route to local machine
1191
+ if (hasActiveRelay(wsUser?.userId)) {
1192
+ await routeViaRelay(wsUser.userId, 'claude-query', data, writer, {
1193
+ response: 'claude-response',
1194
+ complete: 'claude-complete',
1195
+ error: 'claude-error'
1196
+ });
1197
+ } else {
1198
+ // Fall back to server-side SDK
1199
+ const userAnthropicKey = wsUser?.userId
1200
+ ? await getUserProviderKey(wsUser.userId, 'anthropic_key')
1201
+ : null;
1202
+
1203
+ await withUserApiKey('ANTHROPIC_API_KEY', userAnthropicKey, () =>
1204
+ queryClaudeSDK(data.command, data.options, writer)
1205
+ );
1206
+ }
1142
1207
  } else if (data.type === 'cursor-command') {
1143
- console.log('[DEBUG] Cursor message:', data.command || '[Continue/Resume]');
1144
- console.log('📁 Project:', data.options?.cwd || 'Unknown');
1145
- console.log('🤖 Model:', data.options?.model || 'default');
1146
- await spawnCursor(data.command, data.options, writer);
1208
+ // Check if user has active relay → route to local machine
1209
+ if (hasActiveRelay(wsUser?.userId)) {
1210
+ await routeViaRelay(wsUser.userId, 'cursor-query', data, writer, {
1211
+ response: 'cursor-response',
1212
+ complete: 'cursor-complete',
1213
+ error: 'cursor-error'
1214
+ });
1215
+ } else {
1216
+ await spawnCursor(data.command, data.options, writer);
1217
+ }
1147
1218
  } else if (data.type === 'codex-command') {
1148
- console.log('[DEBUG] Codex message:', data.command || '[Continue/Resume]');
1149
- console.log('📁 Project:', data.options?.projectPath || data.options?.cwd || 'Unknown');
1150
- console.log('🤖 Model:', data.options?.model || 'default');
1151
-
1152
- // BYOK: look up user's OpenAI API key, inject if available
1153
- const userOpenaiKey = wsUser?.userId
1154
- ? await getUserProviderKey(wsUser.userId, 'openai_key')
1155
- : null;
1219
+ // Check if user has active relay → route to local machine
1220
+ if (hasActiveRelay(wsUser?.userId)) {
1221
+ await routeViaRelay(wsUser.userId, 'codex-query', data, writer, {
1222
+ response: 'codex-response',
1223
+ complete: 'codex-complete',
1224
+ error: 'codex-error'
1225
+ });
1226
+ } else {
1227
+ const userOpenaiKey = wsUser?.userId
1228
+ ? await getUserProviderKey(wsUser.userId, 'openai_key')
1229
+ : null;
1156
1230
 
1157
- await withUserApiKey('OPENAI_API_KEY', userOpenaiKey, () =>
1158
- queryCodex(data.command, data.options, writer)
1159
- );
1231
+ await withUserApiKey('OPENAI_API_KEY', userOpenaiKey, () =>
1232
+ queryCodex(data.command, data.options, writer)
1233
+ );
1234
+ }
1160
1235
  } else if (data.type === 'openrouter-command') {
1161
1236
  console.log('[DEBUG] OpenRouter message:', data.command?.slice(0, 60) || '[empty]');
1162
1237
  console.log('🤖 Model:', data.options?.model || OPENROUTER_MODELS.DEFAULT);
@@ -1288,12 +1363,20 @@ async function handleRelayConnection(ws, token, request) {
1288
1363
  const userId = Number(tokenData.user_id);
1289
1364
  const username = tokenData.username;
1290
1365
 
1291
- // Extract optional Anthropic API key from relay handshake headers
1366
+ // Extract optional headers from relay handshake
1292
1367
  const anthropicApiKey = request?.headers?.['x-anthropic-api-key'] || null;
1368
+ const relayVersion = request?.headers?.['x-upfyn-version'] || null;
1369
+ const relayMachine = request?.headers?.['x-upfyn-machine'] || null;
1370
+ const relayPlatform = request?.headers?.['x-upfyn-platform'] || null;
1371
+ const relayCwd = request?.headers?.['x-upfyn-cwd'] || null;
1293
1372
 
1294
1373
  // Store relay connection with API key in memory only (use Number() for consistent Map key type)
1295
1374
  // API key is held per-user in the relay connection, NOT in process.env
1296
- relayConnections.set(userId, { ws, user: tokenData, connectedAt: Date.now(), anthropicApiKey });
1375
+ relayConnections.set(userId, {
1376
+ ws, user: tokenData, connectedAt: Date.now(), anthropicApiKey,
1377
+ version: relayVersion, machine: relayMachine, platform: relayPlatform, cwd: relayCwd,
1378
+ agents: null // populated when client sends agent-capabilities
1379
+ });
1297
1380
 
1298
1381
  ws.send(JSON.stringify({
1299
1382
  type: 'relay-connected',
@@ -1344,6 +1427,29 @@ async function handleRelayConnection(ws, token, request) {
1344
1427
  return;
1345
1428
  }
1346
1429
 
1430
+ // Agent capabilities report from relay client
1431
+ if (data.type === 'agent-capabilities') {
1432
+ const relay = relayConnections.get(userId);
1433
+ if (relay) {
1434
+ relay.agents = data.agents || {};
1435
+ relay.machine = data.machine || relay.machine;
1436
+ }
1437
+ // Broadcast agent info to browser clients
1438
+ for (const client of connectedClients) {
1439
+ try {
1440
+ if (client.readyState === 1) {
1441
+ client.send(JSON.stringify({
1442
+ type: 'relay-agents',
1443
+ userId,
1444
+ agents: data.agents || {},
1445
+ machine: data.machine || {}
1446
+ }));
1447
+ }
1448
+ } catch (e) { /* ignore */ }
1449
+ }
1450
+ return;
1451
+ }
1452
+
1347
1453
  // Heartbeat
1348
1454
  if (data.type === 'ping') {
1349
1455
  ws.send(JSON.stringify({ type: 'pong' }));
@@ -1394,7 +1500,7 @@ function sendRelayCommand(userId, action, payload, onStream = null, timeoutMs =
1394
1500
  return new Promise((resolve, reject) => {
1395
1501
  const relay = relayConnections.get(userId);
1396
1502
  if (!relay || relay.ws.readyState !== 1) {
1397
- reject(new Error('No relay connection. Run "upfynai-code connect" on your local machine.'));
1503
+ reject(new Error('No relay connection. Run "uc connect" on your local machine.'));
1398
1504
  return;
1399
1505
  }
1400
1506
 
@@ -1415,6 +1521,95 @@ function sendRelayCommand(userId, action, payload, onStream = null, timeoutMs =
1415
1521
  });
1416
1522
  }
1417
1523
 
1524
+ /**
1525
+ * Check if a user has an active relay connection
1526
+ */
1527
+ function hasActiveRelay(userId) {
1528
+ if (!userId) return false;
1529
+ const relay = relayConnections.get(Number(userId));
1530
+ return relay && relay.ws.readyState === 1;
1531
+ }
1532
+
1533
+ /**
1534
+ * Route a chat command through the user's relay connection to their local machine.
1535
+ * Translates relay-stream/relay-complete events into the format the frontend expects.
1536
+ *
1537
+ * @param {number} userId - User ID
1538
+ * @param {string} action - Relay action (claude-query, codex-query, cursor-query)
1539
+ * @param {object} data - Original command data from the browser
1540
+ * @param {object} writer - WebSocket writer to send events to browser
1541
+ * @param {object} eventMap - Maps relay stream data types to chat event types
1542
+ */
1543
+ async function routeViaRelay(userId, action, data, writer, eventMap = {}) {
1544
+ const sessionId = data.options?.sessionId || `relay-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
1545
+
1546
+ // Send session-created so the frontend can track this query
1547
+ writer.send({ type: 'session-created', sessionId });
1548
+
1549
+ // Determine event types from the provider
1550
+ const responseType = eventMap.response || 'claude-response';
1551
+ const completeType = eventMap.complete || 'claude-complete';
1552
+ const errorType = eventMap.error || 'claude-error';
1553
+
1554
+ let fullContent = '';
1555
+
1556
+ try {
1557
+ const result = await sendRelayCommand(
1558
+ Number(userId),
1559
+ action,
1560
+ {
1561
+ command: data.command,
1562
+ options: data.options || {}
1563
+ },
1564
+ // onStream callback — translates relay events to chat events
1565
+ (streamData) => {
1566
+ if (streamData.type === 'claude-response' || streamData.type === 'codex-response' || streamData.type === 'cursor-response') {
1567
+ fullContent += streamData.content || '';
1568
+ writer.send({
1569
+ type: responseType,
1570
+ data: {
1571
+ type: 'assistant',
1572
+ message: {
1573
+ type: 'text',
1574
+ text: streamData.content || ''
1575
+ }
1576
+ },
1577
+ sessionId
1578
+ });
1579
+ } else if (streamData.type === 'claude-error' || streamData.type === 'codex-error' || streamData.type === 'cursor-error') {
1580
+ writer.send({
1581
+ type: responseType,
1582
+ data: {
1583
+ type: 'assistant',
1584
+ message: {
1585
+ type: 'text',
1586
+ text: streamData.content || ''
1587
+ }
1588
+ },
1589
+ sessionId
1590
+ });
1591
+ }
1592
+ },
1593
+ 600000 // 10 minute timeout for AI queries
1594
+ );
1595
+
1596
+ // Send completion event
1597
+ writer.send({
1598
+ type: completeType,
1599
+ sessionId,
1600
+ exitCode: result?.exitCode ?? 0,
1601
+ isNewSession: !data.options?.sessionId,
1602
+ viaRelay: true
1603
+ });
1604
+ } catch (error) {
1605
+ writer.send({
1606
+ type: errorType,
1607
+ error: error.message,
1608
+ sessionId
1609
+ });
1610
+ }
1611
+ }
1612
+
1418
1613
  // Handle shell WebSocket connections
1419
1614
  function handleShellConnection(ws) {
1420
1615
  if (!pty) {
@@ -2274,6 +2469,18 @@ async function startServer() {
2274
2469
  // Initialize authentication database
2275
2470
  await initializeDatabase();
2276
2471
 
2472
+ // In local mode, ensure a default user exists (no signup needed)
2473
+ if (IS_LOCAL) {
2474
+ const hasUsers = await userDb.hasUsers();
2475
+ if (!hasUsers) {
2476
+ const localUsername = os.userInfo().username || 'local';
2477
+ const dummyHash = crypto.randomBytes(32).toString('hex');
2478
+ await userDb.createUser(localUsername, dummyHash);
2479
+ console.log(`${c.ok('[LOCAL]')} Created local user: ${c.bright(localUsername)}`);
2480
+ }
2481
+ console.log(`${c.info('[MODE]')} Running in ${c.bright('LOCAL')} mode (no login required)`);
2482
+ }
2483
+
2277
2484
  // Check if running in production mode (dist folder exists OR NODE_ENV/RAILWAY set)
2278
2485
  const distIndexPath = path.join(__dirname, '../dist/index.html');
2279
2486
  const isProduction = fs.existsSync(distIndexPath) || process.env.NODE_ENV === 'production' || !!process.env.RAILWAY_ENVIRONMENT;
@@ -31,7 +31,8 @@ import crypto from 'crypto';
31
31
  import jwt from 'jsonwebtoken';
32
32
  import { userDb, apiKeysDb, relayTokensDb } from './database/db.js';
33
33
 
34
- const JWT_SECRET = process.env.JWT_SECRET?.trim() || (() => { throw new Error('JWT_SECRET required'); })();
34
+ import { IS_PLATFORM } from './constants/config.js';
35
+ const JWT_SECRET = process.env.JWT_SECRET?.trim() || (IS_PLATFORM ? crypto.randomBytes(32).toString('hex') : (() => { throw new Error('JWT_SECRET required'); })());
35
36
 
36
37
  // In-memory canvas state (Excalidraw elements, synced via WebSocket with browser clients)
37
38
  let canvasElements = [];
@@ -1,11 +1,17 @@
1
1
  import jwt from 'jsonwebtoken';
2
+ import crypto from 'crypto';
2
3
  import { userDb, relayTokensDb } from '../database/db.js';
3
4
  import { IS_PLATFORM } from '../constants/config.js';
4
5
 
5
- const JWT_SECRET = process.env.JWT_SECRET?.trim();
6
+ let JWT_SECRET = process.env.JWT_SECRET?.trim();
6
7
  if (!JWT_SECRET) {
7
- console.error('[SECURITY] JWT_SECRET environment variable is required. Server cannot start without it.');
8
- process.exit(1);
8
+ if (IS_PLATFORM) {
9
+ // In local/self-hosted mode, generate a random secret (auth is bypassed anyway)
10
+ JWT_SECRET = crypto.randomBytes(32).toString('hex');
11
+ } else {
12
+ console.error('[SECURITY] JWT_SECRET environment variable is required. Server cannot start without it.');
13
+ process.exit(1);
14
+ }
9
15
  }
10
16
 
11
17
  // Optional static API key middleware
@@ -14,7 +14,7 @@ import WebSocket from 'ws';
14
14
  import os from 'os';
15
15
  import fs from 'fs';
16
16
  import path from 'path';
17
- import { spawn } from 'child_process';
17
+ import { spawn, execSync } from 'child_process';
18
18
  import { promises as fsPromises } from 'fs';
19
19
  import crypto from 'crypto';
20
20
  import {
@@ -137,6 +137,100 @@ async function handleRelayCommand(data, ws) {
137
137
  break;
138
138
  }
139
139
 
140
+ case 'codex-query': {
141
+ const { command, options } = data;
142
+ logRelayEvent('>', `Codex query: ${command?.slice(0, 60)}...`, 'cyan');
143
+
144
+ const codexArgs = ['--quiet'];
145
+ if (options?.projectPath || options?.cwd) {
146
+ codexArgs.push('--cwd', options.projectPath || options.cwd);
147
+ }
148
+ if (options?.model) codexArgs.push('--model', options.model);
149
+
150
+ const codexProc = spawn('codex', [...codexArgs, command || ''], {
151
+ shell: true,
152
+ cwd: options?.projectPath || options?.cwd || os.homedir(),
153
+ env: process.env,
154
+ });
155
+
156
+ codexProc.stdout.on('data', (chunk) => {
157
+ ws.send(JSON.stringify({
158
+ type: 'relay-stream',
159
+ requestId,
160
+ data: { type: 'codex-response', content: chunk.toString() }
161
+ }));
162
+ });
163
+
164
+ codexProc.stderr.on('data', (chunk) => {
165
+ ws.send(JSON.stringify({
166
+ type: 'relay-stream',
167
+ requestId,
168
+ data: { type: 'codex-error', content: chunk.toString() }
169
+ }));
170
+ });
171
+
172
+ codexProc.on('close', (code) => {
173
+ ws.send(JSON.stringify({
174
+ type: 'relay-complete',
175
+ requestId,
176
+ exitCode: code
177
+ }));
178
+ });
179
+ break;
180
+ }
181
+
182
+ case 'cursor-query': {
183
+ const { command, options } = data;
184
+ logRelayEvent('>', `Cursor query: ${command?.slice(0, 60)}...`, 'cyan');
185
+
186
+ const cursorArgs = [];
187
+ if (options?.projectPath || options?.cwd) {
188
+ cursorArgs.push('--cwd', options.projectPath || options.cwd);
189
+ }
190
+ if (options?.model) cursorArgs.push('--model', options.model);
191
+
192
+ const cursorProc = spawn('cursor-agent', [...cursorArgs, command || ''], {
193
+ shell: true,
194
+ cwd: options?.projectPath || options?.cwd || os.homedir(),
195
+ env: process.env,
196
+ });
197
+
198
+ cursorProc.stdout.on('data', (chunk) => {
199
+ ws.send(JSON.stringify({
200
+ type: 'relay-stream',
201
+ requestId,
202
+ data: { type: 'cursor-response', content: chunk.toString() }
203
+ }));
204
+ });
205
+
206
+ cursorProc.stderr.on('data', (chunk) => {
207
+ ws.send(JSON.stringify({
208
+ type: 'relay-stream',
209
+ requestId,
210
+ data: { type: 'cursor-error', content: chunk.toString() }
211
+ }));
212
+ });
213
+
214
+ cursorProc.on('close', (code) => {
215
+ ws.send(JSON.stringify({
216
+ type: 'relay-complete',
217
+ requestId,
218
+ exitCode: code
219
+ }));
220
+ });
221
+ break;
222
+ }
223
+
224
+ case 'detect-agents': {
225
+ const agents = detectInstalledAgents();
226
+ ws.send(JSON.stringify({
227
+ type: 'relay-response',
228
+ requestId,
229
+ data: { agents }
230
+ }));
231
+ break;
232
+ }
233
+
140
234
  case 'shell-command': {
141
235
  const { command: cmd, cwd } = data;
142
236
  // Block dangerous shell patterns
@@ -228,6 +322,39 @@ async function buildFileTree(dirPath, maxDepth, currentDepth = 0) {
228
322
  }
229
323
  }
230
324
 
325
+ /**
326
+ * Detect which AI CLI agents are installed on this machine
327
+ * Returns an object with agent names and their availability
328
+ */
329
+ function detectInstalledAgents() {
330
+ const isWindows = process.platform === 'win32';
331
+ const whichCmd = isWindows ? 'where' : 'which';
332
+
333
+ const agents = [
334
+ { name: 'claude', binary: 'claude', label: 'Claude Code' },
335
+ { name: 'codex', binary: 'codex', label: 'OpenAI Codex' },
336
+ { name: 'cursor', binary: 'cursor-agent', label: 'Cursor Agent' },
337
+ ];
338
+
339
+ const detected = {};
340
+ for (const agent of agents) {
341
+ try {
342
+ const result = execSync(`${whichCmd} ${agent.binary}`, { stdio: 'pipe', timeout: 5000 }).toString().trim();
343
+ detected[agent.name] = {
344
+ installed: true,
345
+ path: result.split('\n')[0].trim(),
346
+ label: agent.label,
347
+ };
348
+ } catch {
349
+ detected[agent.name] = {
350
+ installed: false,
351
+ label: agent.label,
352
+ };
353
+ }
354
+ }
355
+ return detected;
356
+ }
357
+
231
358
  /**
232
359
  * Create WebSocket connection with optional API key in handshake
233
360
  */
@@ -239,6 +366,8 @@ function createRelayConnection(wsUrl, config = {}) {
239
366
  }
240
367
  headers['x-upfyn-version'] = VERSION;
241
368
  headers['x-upfyn-machine'] = os.hostname();
369
+ headers['x-upfyn-platform'] = process.platform;
370
+ headers['x-upfyn-cwd'] = process.cwd();
242
371
 
243
372
  return new WebSocket(wsUrl, { headers });
244
373
  }
@@ -302,6 +431,34 @@ export async function connectToServer(options = {}) {
302
431
  const nameMatch = data.message?.match(/Connected as (.+?)\./);
303
432
  const username = nameMatch ? nameMatch[1] : 'Unknown';
304
433
  showConnectionBanner(username, serverUrl);
434
+
435
+ // Detect and report installed agents
436
+ const agents = detectInstalledAgents();
437
+ const installed = Object.entries(agents)
438
+ .filter(([, info]) => info.installed)
439
+ .map(([name, info]) => info.label);
440
+ const missing = Object.entries(agents)
441
+ .filter(([, info]) => !info.installed)
442
+ .map(([name, info]) => info.label);
443
+
444
+ if (installed.length > 0) {
445
+ logRelayEvent('+', `Agents found: ${installed.join(', ')}`, 'green');
446
+ }
447
+ if (missing.length > 0) {
448
+ logRelayEvent('~', `Not found: ${missing.join(', ')} (install to enable)`, 'yellow');
449
+ }
450
+
451
+ // Send agent capabilities to server
452
+ ws.send(JSON.stringify({
453
+ type: 'agent-capabilities',
454
+ agents,
455
+ machine: {
456
+ hostname: os.hostname(),
457
+ platform: process.platform,
458
+ cwd: process.cwd(),
459
+ }
460
+ }));
461
+
305
462
  logRelayEvent('*', 'Relay active -- waiting for commands...', 'green');
306
463
  return;
307
464
  }
@@ -293,7 +293,7 @@ Custom commands can be created in:
293
293
  // Read version from package.json
294
294
  const packageJsonPath = path.join(path.dirname(__dirname), '..', 'package.json');
295
295
  let version = 'unknown';
296
- let packageName = 'upfyn-code';
296
+ let packageName = 'upfynai-code';
297
297
 
298
298
  try {
299
299
  const packageJson = JSON.parse(await fs.readFile(packageJsonPath, 'utf8'));