upfynai-code 2.7.1 → 2.7.2

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/src/connect.js CHANGED
@@ -1,758 +1,768 @@
1
- import WebSocket from 'ws';
2
- import os from 'os';
3
- import { spawn } from 'child_process';
4
- import { promises as fsPromises } from 'fs';
5
- import path from 'path';
6
- import chalk from 'chalk';
7
- import { readConfig, writeConfig, displayUrl } from './config.js';
8
- import { getToken, validateToken } from './auth.js';
9
- import { getPersistentShell } from './persistent-shell.js';
10
- import { needsPermission, requestPermission, handlePermissionResponse } from './permissions.js';
11
-
12
- // Active process tracking (opencode pattern: activeRequests sync.Map)
13
- const activeProcesses = new Map(); // requestId → { proc, action }
14
-
15
- // Active shell sessions for relay terminal (shellSessionId → { proc })
16
- const activeShellSessions = new Map();
17
-
18
- /**
19
- * Start an interactive shell session on the local machine, relayed to the browser.
20
- * Spawns a PTY-like process and streams I/O via WebSocket.
21
- */
22
- function handleShellSessionStart(data, ws) {
23
- const shellSessionId = data.requestId;
24
- const projectPath = data.projectPath || process.cwd();
25
- const isWin = process.platform === 'win32';
26
- const isMac = process.platform === 'darwin';
27
- const shellType = data.shellType; // 'cmd', 'powershell', 'bash', 'zsh', etc.
28
-
29
- console.log(chalk.cyan(` [relay] Starting shell session in ${projectPath}`));
30
-
31
- // Build the shell command
32
- let shellCmd, shellArgs;
33
- const provider = data.provider || 'claude';
34
- const isPlainShell = data.isPlainShell || (!!data.initialCommand && !data.hasSession) || provider === 'plain-shell';
35
-
36
- /**
37
- * Helper: get the right shell command + args for interactive shell based on platform + shellType.
38
- * Returns { cmd, args } that cd into projectPath.
39
- */
40
- function getInteractiveShell(projectDir) {
41
- if (isWin) {
42
- if (shellType === 'cmd') {
43
- return { cmd: 'cmd.exe', args: ['/K', `cd /d "${projectDir}"`] };
44
- }
45
- // Default to PowerShell on Windows
46
- return { cmd: 'powershell.exe', args: ['-NoExit', '-Command', `Set-Location -LiteralPath '${projectDir.replace(/'/g, "''")}'`] };
47
- }
48
- // Unix: use shellType if specified, else $SHELL or bash
49
- const sh = shellType || process.env.SHELL || 'bash';
50
- return { cmd: sh, args: ['--login'] };
51
- }
52
-
53
- /**
54
- * Helper: wrap a command to execute in the right shell for the platform + shellType.
55
- * Returns { cmd, args }.
56
- */
57
- function wrapCommand(command, projectDir) {
58
- if (isWin) {
59
- if (shellType === 'cmd') {
60
- return { cmd: 'cmd.exe', args: ['/C', `cd /d "${projectDir}" && ${command}`] };
61
- }
62
- return { cmd: 'powershell.exe', args: ['-Command', `Set-Location -LiteralPath '${projectDir.replace(/'/g, "''")}'; ${command}`] };
63
- }
64
- const sh = shellType || 'bash';
65
- return { cmd: sh, args: ['-c', `cd "${projectDir}" && ${command}`] };
66
- }
67
-
68
- if (isPlainShell && data.initialCommand) {
69
- // Run a specific command
70
- const s = wrapCommand(data.initialCommand, projectPath);
71
- shellCmd = s.cmd; shellArgs = s.args;
72
- } else if (isPlainShell) {
73
- // Interactive shell
74
- const s = getInteractiveShell(projectPath);
75
- shellCmd = s.cmd; shellArgs = s.args;
76
- } else if (provider === 'cursor') {
77
- const cursorCmd = data.hasSession && data.sessionId
78
- ? `cursor-agent --resume="${data.sessionId}"`
79
- : 'cursor-agent';
80
- const s = wrapCommand(cursorCmd, projectPath);
81
- shellCmd = s.cmd; shellArgs = s.args;
82
- } else {
83
- // Claude (default)
84
- const command = data.initialCommand || 'claude';
85
- let claudeCmd;
86
- if (isWin) {
87
- claudeCmd = data.hasSession && data.sessionId
88
- ? `claude --resume ${data.sessionId}`
89
- : command;
90
- } else {
91
- claudeCmd = data.hasSession && data.sessionId
92
- ? `claude --resume ${data.sessionId} || claude`
93
- : command;
94
- }
95
- const s = wrapCommand(claudeCmd, projectPath);
96
- shellCmd = s.cmd; shellArgs = s.args;
97
- }
98
-
99
- // Send ack so browser knows spawn is starting
100
- ws.send(JSON.stringify({
101
- type: 'relay-shell-output',
102
- shellSessionId,
103
- data: '',
104
- }));
105
-
106
- const proc = spawn(shellCmd, shellArgs, {
107
- cwd: isPlainShell && !data.initialCommand ? projectPath : undefined,
108
- env: {
109
- ...process.env,
110
- TERM: 'xterm-256color',
111
- COLORTERM: 'truecolor',
112
- FORCE_COLOR: '3',
113
- },
114
- stdio: ['pipe', 'pipe', 'pipe'],
115
- });
116
-
117
- activeShellSessions.set(shellSessionId, { proc, projectPath });
118
-
119
- // Stream stdout → browser via relay
120
- proc.stdout.on('data', (chunk) => {
121
- if (ws.readyState === WebSocket.OPEN) {
122
- ws.send(JSON.stringify({
123
- type: 'relay-shell-output',
124
- shellSessionId,
125
- data: chunk.toString(),
126
- }));
127
- }
128
- });
129
-
130
- // Stream stderr → browser via relay
131
- proc.stderr.on('data', (chunk) => {
132
- if (ws.readyState === WebSocket.OPEN) {
133
- ws.send(JSON.stringify({
134
- type: 'relay-shell-output',
135
- shellSessionId,
136
- data: chunk.toString(),
137
- }));
138
- }
139
- });
140
-
141
- proc.on('close', (code) => {
142
- activeShellSessions.delete(shellSessionId);
143
- if (ws.readyState === WebSocket.OPEN) {
144
- ws.send(JSON.stringify({
145
- type: 'relay-shell-exited',
146
- shellSessionId,
147
- exitCode: code,
148
- }));
149
- }
150
- console.log(chalk.dim(` [relay] Shell session ended (code ${code})`));
151
- });
152
-
153
- proc.on('error', (err) => {
154
- activeShellSessions.delete(shellSessionId);
155
- if (ws.readyState === WebSocket.OPEN) {
156
- ws.send(JSON.stringify({
157
- type: 'relay-shell-output',
158
- shellSessionId,
159
- data: `\r\n\x1b[31mError: ${err.message}\x1b[0m\r\n`,
160
- }));
161
- }
162
- });
163
- }
164
-
165
- /**
166
- * Execute a shell command and return stdout
167
- */
168
- function execCommand(cmd, args, options = {}) {
169
- return new Promise((resolve, reject) => {
170
- const proc = spawn(cmd, args, {
171
- shell: true,
172
- cwd: options.cwd || os.homedir(),
173
- env: { ...process.env, ...options.env },
174
- stdio: ['pipe', 'pipe', 'pipe'],
175
- });
176
-
177
- let stdout = '';
178
- let stderr = '';
179
- proc.stdout.on('data', (d) => { stdout += d; });
180
- proc.stderr.on('data', (d) => { stderr += d; });
181
-
182
- const timeout = setTimeout(() => {
183
- proc.kill();
184
- reject(new Error('Command timed out'));
185
- }, options.timeout || 30000);
186
-
187
- proc.on('close', (code) => {
188
- clearTimeout(timeout);
189
- if (code === 0) resolve(stdout);
190
- else reject(new Error(stderr || `Exit code ${code}`));
191
- });
192
-
193
- proc.on('error', (err) => {
194
- clearTimeout(timeout);
195
- reject(err);
196
- });
197
- });
198
- }
199
-
200
- /**
201
- * Build a file tree for a directory
202
- */
203
- async function buildFileTree(dirPath, maxDepth, currentDepth = 0) {
204
- if (currentDepth >= maxDepth) return [];
205
- try {
206
- const entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
207
- const items = [];
208
- for (const entry of entries.slice(0, 200)) {
209
- // Skip heavy/hidden directories
210
- if (entry.name === 'node_modules' || entry.name === '.git' ||
211
- entry.name === 'dist' || entry.name === 'build' ||
212
- entry.name === '.svn' || entry.name === '.hg') continue;
213
- if (entry.name.startsWith('.') && currentDepth === 0) continue; // hide dotfiles at root only
214
-
215
- const itemPath = path.join(dirPath, entry.name);
216
- const item = { name: entry.name, path: itemPath, type: entry.isDirectory() ? 'directory' : 'file' };
217
-
218
- // Get file stats for metadata (size, modified)
219
- try {
220
- const stats = await fsPromises.stat(itemPath);
221
- item.size = stats.size;
222
- item.modified = stats.mtime.toISOString();
223
- } catch { /* ignore stat errors */ }
224
-
225
- if (entry.isDirectory() && currentDepth < maxDepth - 1) {
226
- item.children = await buildFileTree(itemPath, maxDepth, currentDepth + 1);
227
- }
228
- items.push(item);
229
- }
230
- return items;
231
- } catch {
232
- return [];
233
- }
234
- }
235
-
236
- /**
237
- * Handle incoming relay commands from the server
238
- */
239
- async function handleRelayCommand(data, ws) {
240
- const { requestId, action } = data;
241
-
242
- try {
243
- // Permission gate (opencode pattern: dangerous actions require browser approval)
244
- if (needsPermission(action, data)) {
245
- const approved = await requestPermission(ws, requestId, action, data);
246
- if (!approved) {
247
- ws.send(JSON.stringify({
248
- type: 'relay-response', requestId,
249
- error: 'Permission denied by user',
250
- }));
251
- return;
252
- }
253
- }
254
-
255
- switch (action) {
256
- case 'claude-query': {
257
- const { command, options } = data;
258
- console.log(chalk.cyan(' [relay] Processing Claude query...'));
259
-
260
- // Stream-JSON mode for real-time token streaming (opencode pattern)
261
- const args = ['-p', '--output-format', 'stream-json', '--include-partial-messages'];
262
- if (options?.projectPath) args.push('--cwd', options.projectPath);
263
- if (options?.sessionId) args.push('--continue', options.sessionId);
264
-
265
- const proc = spawn('claude', [...args, command || ''], {
266
- shell: true,
267
- cwd: options?.projectPath || os.homedir(),
268
- env: process.env,
269
- });
270
-
271
- // Track for abort support (opencode pattern: activeRequests)
272
- activeProcesses.set(requestId, { proc, action: 'claude-query' });
273
-
274
- let stdoutBuffer = '';
275
- let capturedSessionId = null;
276
-
277
- // Parse NDJSON line-by-line (same pattern as cursor-cli.js)
278
- proc.stdout.on('data', (chunk) => {
279
- stdoutBuffer += chunk.toString();
280
- const lines = stdoutBuffer.split('\n');
281
- stdoutBuffer = lines.pop(); // keep incomplete last line in buffer
282
-
283
- for (const line of lines) {
284
- if (!line.trim()) continue;
285
- try {
286
- const evt = JSON.parse(line);
287
-
288
- if (evt.type === 'system' && evt.subtype === 'init') {
289
- // Capture session ID for resume support
290
- if (evt.session_id) capturedSessionId = evt.session_id;
291
- ws.send(JSON.stringify({
292
- type: 'relay-stream', requestId,
293
- data: { type: 'claude-system', sessionId: evt.session_id, model: evt.model, cwd: evt.cwd },
294
- }));
295
- } else if (evt.type === 'assistant' && evt.message?.content?.length) {
296
- // Real-time text delta
297
- const text = evt.message.content[0].text || '';
298
- ws.send(JSON.stringify({
299
- type: 'relay-stream', requestId,
300
- data: { type: 'claude-response', content: text },
301
- }));
302
- } else if (evt.type === 'result') {
303
- // Session complete — include captured session ID
304
- ws.send(JSON.stringify({
305
- type: 'relay-stream', requestId,
306
- data: { type: 'claude-result', sessionId: capturedSessionId, subtype: evt.subtype },
307
- }));
308
- }
309
- } catch {
310
- // Non-JSON line — send as raw text
311
- if (line.trim()) {
312
- ws.send(JSON.stringify({
313
- type: 'relay-stream', requestId,
314
- data: { type: 'claude-response', content: line },
315
- }));
316
- }
317
- }
318
- }
319
- });
320
-
321
- proc.stderr.on('data', (chunk) => {
322
- ws.send(JSON.stringify({
323
- type: 'relay-stream', requestId,
324
- data: { type: 'claude-error', content: chunk.toString() },
325
- }));
326
- });
327
-
328
- proc.on('close', (code) => {
329
- activeProcesses.delete(requestId);
330
- ws.send(JSON.stringify({
331
- type: 'relay-complete', requestId,
332
- exitCode: code,
333
- sessionId: capturedSessionId,
334
- }));
335
- });
336
-
337
- proc.on('error', () => {
338
- activeProcesses.delete(requestId);
339
- });
340
- break;
341
- }
342
-
343
- case 'shell-command': {
344
- const { command: cmd, cwd } = data;
345
- if (!cmd || typeof cmd !== 'string') throw new Error('Invalid command');
346
- const cmdLower = cmd.toLowerCase();
347
- const dangerous = [
348
- 'rm -rf /', 'mkfs', 'dd if=', ':(){', 'fork bomb', '> /dev/sd',
349
- 'format c:', 'format d:', 'format e:', 'del /s /q c:\\',
350
- 'rd /s /q c:\\', 'reg delete', 'bcdedit',
351
- ];
352
- if (dangerous.some(d => cmdLower.includes(d.toLowerCase()))) throw new Error('Command blocked for safety');
353
- console.log(chalk.dim(' [relay] Executing shell command...'));
354
- // Persistent shell singleton (opencode pattern: commands share one shell process)
355
- const shell = getPersistentShell(cwd || process.cwd());
356
- const result = await shell.exec(cmd, { timeoutMs: 60000 });
357
- ws.send(JSON.stringify({
358
- type: 'relay-response', requestId,
359
- data: { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode, cwd: result.cwd },
360
- }));
361
- break;
362
- }
363
-
364
- case 'file-read': {
365
- let { filePath, encoding } = data;
366
- if (!filePath || typeof filePath !== 'string') throw new Error('Invalid file path');
367
- // Resolve ~ to home directory (routes send paths like ~/.cursor/config.json)
368
- if (filePath === '~') filePath = os.homedir();
369
- else if (filePath.startsWith('~/') || filePath.startsWith('~\\')) filePath = path.join(os.homedir(), filePath.slice(2));
370
- const normalizedPath = path.resolve(filePath);
371
- const normLower = normalizedPath.toLowerCase().replace(/\\/g, '/');
372
- const blockedRead = ['/etc/shadow', '/etc/passwd', '.ssh/id_rsa', '.ssh/id_ed25519', '/.env'];
373
- if (blockedRead.some(b => normLower.includes(b))) throw new Error('Access denied');
374
- const content = await fsPromises.readFile(normalizedPath, encoding === 'base64' ? null : 'utf8');
375
- const result = encoding === 'base64' ? content.toString('base64') : content;
376
- ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { content: result } }));
377
- break;
378
- }
379
-
380
- case 'file-write': {
381
- let fp2 = data.filePath;
382
- const fileContent = data.content;
383
- if (!fp2 || typeof fp2 !== 'string') throw new Error('Invalid file path');
384
- // Resolve ~ to home directory
385
- if (fp2 === '~') fp2 = os.homedir();
386
- else if (fp2.startsWith('~/') || fp2.startsWith('~\\')) fp2 = path.join(os.homedir(), fp2.slice(2));
387
- const normalizedFp = path.resolve(fp2);
388
- const fpLower = normalizedFp.toLowerCase().replace(/\\/g, '/');
389
- const blockedWrite = [
390
- '/etc/', '/usr/bin/', '/usr/sbin/',
391
- '/windows/system32', '/windows/syswow64', '/program files',
392
- '.ssh/', '/.env',
393
- ];
394
- if (blockedWrite.some(d => fpLower.includes(d))) throw new Error('Access denied');
395
- const parentDir = path.dirname(normalizedFp);
396
- await fsPromises.mkdir(parentDir, { recursive: true }).catch(() => {});
397
- await fsPromises.writeFile(normalizedFp, fileContent, 'utf8');
398
- ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { success: true } }));
399
- break;
400
- }
401
-
402
- case 'file-tree': {
403
- const { dirPath, depth, maxDepth } = data;
404
- const treeDepth = depth || maxDepth || 3;
405
- const resolvedDir = dirPath ? path.resolve(dirPath) : process.cwd();
406
- const tree = await buildFileTree(resolvedDir, treeDepth);
407
- ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { files: tree } }));
408
- break;
409
- }
410
-
411
- case 'browse-dirs': {
412
- // Browse directories on user's local machine
413
- const { dirPath: browsePath } = data;
414
- const os = await import('os');
415
- let targetDir = browsePath || os.default.homedir();
416
- if (targetDir === '~') targetDir = os.default.homedir();
417
- else if (targetDir.startsWith('~/') || targetDir.startsWith('~\\')) targetDir = path.join(os.default.homedir(), targetDir.slice(2));
418
- targetDir = path.resolve(targetDir);
419
-
420
- let dirs = [];
421
- // On Windows, detect available drives to show alongside directory listing
422
- let drives = [];
423
- if (process.platform === 'win32') {
424
- try {
425
- const { execSync } = await import('child_process');
426
- const wmicOut = execSync('wmic logicaldisk get name', { encoding: 'utf8', timeout: 5000 });
427
- drives = wmicOut.split('\n')
428
- .map(l => l.trim())
429
- .filter(l => /^[A-Z]:$/.test(l))
430
- .map(d => ({ name: d + '\\', path: d + '\\', type: 'drive' }));
431
- } catch { /* ignore wmic not available */ }
432
- }
433
-
434
- const entries = await fsPromises.readdir(targetDir, { withFileTypes: true });
435
- dirs = entries
436
- .filter(e => e.isDirectory() && !e.name.startsWith('.'))
437
- .map(e => ({ name: e.name, path: path.join(targetDir, e.name), type: 'directory' }))
438
- .sort((a, b) => a.name.localeCompare(b.name));
439
- ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { path: targetDir, suggestions: dirs, drives, homedir: os.default.homedir() } }));
440
- break;
441
- }
442
-
443
- case 'validate-path': {
444
- // Check if a path exists on user's machine
445
- const { targetPath } = data;
446
- const os2 = await import('os');
447
- let checkPath = targetPath || '';
448
- if (checkPath === '~') checkPath = os2.default.homedir();
449
- else if (checkPath.startsWith('~/') || checkPath.startsWith('~\\')) checkPath = path.join(os2.default.homedir(), checkPath.slice(2));
450
- checkPath = path.resolve(checkPath);
451
-
452
- try {
453
- const stats = await fsPromises.stat(checkPath);
454
- ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { exists: true, isDirectory: stats.isDirectory(), resolvedPath: checkPath } }));
455
- } catch {
456
- ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { exists: false, resolvedPath: checkPath } }));
457
- }
458
- break;
459
- }
460
-
461
- case 'create-folder': {
462
- // Create a directory on user's machine
463
- const { folderPath } = data;
464
- const os3 = await import('os');
465
- let mkPath = folderPath || '';
466
- if (mkPath === '~') mkPath = os3.default.homedir();
467
- else if (mkPath.startsWith('~/') || mkPath.startsWith('~\\')) mkPath = path.join(os3.default.homedir(), mkPath.slice(2));
468
- mkPath = path.resolve(mkPath);
469
-
470
- await fsPromises.mkdir(mkPath, { recursive: true });
471
- ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { success: true, path: mkPath } }));
472
- break;
473
- }
474
-
475
- case 'git-operation': {
476
- const { gitCommand, cwd: gitCwd } = data;
477
- console.log(chalk.dim(' [relay] Running git operation...'));
478
- const resolvedGitCwd = gitCwd ? path.resolve(gitCwd) : process.cwd();
479
- const result = await execCommand('git', [gitCommand], { cwd: resolvedGitCwd });
480
- ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { stdout: result } }));
481
- break;
482
- }
483
-
484
- // Sub-agent: read-only research agent (opencode pattern: AgentTask)
485
- // Spawns a separate claude process with --allowedTools limited to read-only
486
- case 'claude-task-query': {
487
- const { command: taskCmd, options: taskOpts } = data;
488
- console.log(chalk.cyan(' [relay] Spawning sub-agent for research...'));
489
-
490
- const taskArgs = ['-p', '--output-format', 'stream-json', '--include-partial-messages'];
491
- // Sub-agents are read-only: use --allowedTools to restrict
492
- taskArgs.push('--allowedTools', 'View,Glob,Grep,LS,Read');
493
- if (taskOpts?.projectPath) taskArgs.push('--cwd', taskOpts.projectPath);
494
- // Sub-agents always start fresh (no --continue)
495
-
496
- const taskProc = spawn('claude', [...taskArgs, taskCmd || ''], {
497
- shell: true,
498
- cwd: taskOpts?.projectPath || os.homedir(),
499
- env: process.env,
500
- });
501
-
502
- activeProcesses.set(requestId, { proc: taskProc, action: 'claude-task-query' });
503
- let taskBuffer = '';
504
-
505
- taskProc.stdout.on('data', (chunk) => {
506
- taskBuffer += chunk.toString();
507
- const lines = taskBuffer.split('\n');
508
- taskBuffer = lines.pop();
509
- for (const line of lines) {
510
- if (!line.trim()) continue;
511
- try {
512
- const evt = JSON.parse(line);
513
- if (evt.type === 'assistant' && evt.message?.content?.length) {
514
- ws.send(JSON.stringify({
515
- type: 'relay-stream', requestId,
516
- data: { type: 'claude-response', content: evt.message.content[0].text || '' },
517
- }));
518
- }
519
- } catch {
520
- if (line.trim()) {
521
- ws.send(JSON.stringify({
522
- type: 'relay-stream', requestId,
523
- data: { type: 'claude-response', content: line },
524
- }));
525
- }
526
- }
527
- }
528
- });
529
-
530
- taskProc.stderr.on('data', (chunk) => {
531
- ws.send(JSON.stringify({
532
- type: 'relay-stream', requestId,
533
- data: { type: 'claude-error', content: chunk.toString() },
534
- }));
535
- });
536
-
537
- taskProc.on('close', (code) => {
538
- activeProcesses.delete(requestId);
539
- ws.send(JSON.stringify({ type: 'relay-complete', requestId, exitCode: code }));
540
- });
541
-
542
- taskProc.on('error', () => { activeProcesses.delete(requestId); });
543
- break;
544
- }
545
-
546
- case 'detect-agents': {
547
- // Detect installed AI CLI agents on user's machine
548
- const agents = {};
549
- const checkCmd = process.platform === 'win32' ? 'where' : 'which';
550
- const agentBins = { claude: 'claude', cursor: 'cursor-agent', codex: 'codex' };
551
- for (const [name, bin] of Object.entries(agentBins)) {
552
- try {
553
- const { execSync } = await import('child_process');
554
- execSync(`${checkCmd} ${bin}`, { encoding: 'utf8', timeout: 5000, stdio: 'pipe' });
555
- agents[name] = true;
556
- } catch {
557
- agents[name] = false;
558
- }
559
- }
560
- ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { agents } }));
561
- break;
562
- }
563
-
564
- default:
565
- ws.send(JSON.stringify({
566
- type: 'relay-response',
567
- requestId,
568
- error: `Unknown action: ${action}`,
569
- }));
570
- }
571
- } catch (err) {
572
- ws.send(JSON.stringify({
573
- type: 'relay-response',
574
- requestId,
575
- error: err.message,
576
- }));
577
- }
578
- }
579
-
580
- /**
581
- * Connect to the remote server via WebSocket relay.
582
- * Bridges local Claude Code, shell, filesystem, and git to the web UI.
583
- */
584
- export async function connect(options = {}) {
585
- const config = readConfig();
586
- const serverUrl = options.server || config.serverUrl;
587
- let relayKey = options.key;
588
-
589
- // If no key provided, fetch one using the auth token
590
- if (!relayKey) {
591
- const token = getToken();
592
- if (!token) {
593
- console.log(chalk.yellow('\n No account found. Run `uc login` first.\n'));
594
- process.exit(1);
595
- }
596
-
597
- console.log(chalk.dim('\n Validating session...'));
598
- const user = await validateToken();
599
- if (!user) {
600
- console.log(chalk.yellow(' Session expired. Run `uc login` to re-authenticate.\n'));
601
- process.exit(1);
602
- }
603
-
604
- // Fetch a relay token from the API
605
- try {
606
- const res = await fetch(`${serverUrl}/api/auth/connect-token`, {
607
- headers: { Authorization: `Bearer ${token}` },
608
- });
609
- if (!res.ok) throw new Error('Failed to get connect token');
610
- const data = await res.json();
611
- relayKey = data.token;
612
- } catch (err) {
613
- console.log(chalk.red('\n Could not get connection token. Check your network and try again.\n'));
614
- process.exit(1);
615
- }
616
- }
617
-
618
- // Save for future use
619
- writeConfig({ relayKey });
620
-
621
- const wsUrl = serverUrl.replace(/^http/, 'ws') + '/relay?token=' + encodeURIComponent(relayKey);
622
-
623
- console.log(chalk.bold('\n Upfyn-Code Relay Client\n'));
624
- console.log(` Server: ${chalk.cyan(displayUrl(serverUrl))}`);
625
- console.log(` Machine: ${chalk.dim(os.hostname())}`);
626
- console.log(` User: ${chalk.dim(os.userInfo().username)}\n`);
627
-
628
- let reconnectAttempts = 0;
629
- const MAX_RECONNECT = 10;
630
-
631
- function doConnect() {
632
- const ws = new WebSocket(wsUrl);
633
-
634
- ws.on('open', () => {
635
- reconnectAttempts = 0;
636
- console.log(chalk.green(' Connected! Your local machine is now bridged to the server.'));
637
- console.log(chalk.dim(' Claude Code is the AI brain. Press Ctrl+C to disconnect.\n'));
638
-
639
- // Send initial working directory so it becomes the default project
640
- const cwd = process.cwd();
641
- const dirName = path.basename(cwd);
642
- ws.send(JSON.stringify({
643
- type: 'relay-init',
644
- cwd,
645
- dirName,
646
- homedir: os.homedir(),
647
- platform: process.platform,
648
- hostname: os.hostname(),
649
- }));
650
- console.log(chalk.dim(` Default project: ${cwd}\n`));
651
-
652
- const heartbeat = setInterval(() => {
653
- if (ws.readyState === WebSocket.OPEN) {
654
- ws.send(JSON.stringify({ type: 'ping' }));
655
- }
656
- }, 30000);
657
-
658
- ws.on('close', () => clearInterval(heartbeat));
659
- });
660
-
661
- ws.on('message', (rawMessage) => {
662
- try {
663
- const data = JSON.parse(rawMessage);
664
-
665
- if (data.type === 'relay-connected') {
666
- console.log(chalk.green(` ${data.message}`));
667
- return;
668
- }
669
- if (data.type === 'relay-command') {
670
- handleRelayCommand(data, ws);
671
- return;
672
- }
673
- // Abort handler (opencode pattern: cancel via context propagation)
674
- if (data.type === 'relay-abort') {
675
- const entry = activeProcesses.get(data.requestId);
676
- if (entry?.proc) {
677
- entry.proc.kill('SIGTERM');
678
- activeProcesses.delete(data.requestId);
679
- ws.send(JSON.stringify({
680
- type: 'relay-complete', requestId: data.requestId,
681
- exitCode: -1, aborted: true,
682
- }));
683
- console.log(chalk.yellow(' [relay] Process aborted by user'));
684
- }
685
- return;
686
- }
687
- // Permission response from browser (opencode pattern: grant/deny flow)
688
- if (data.type === 'relay-permission-response') {
689
- handlePermissionResponse(data);
690
- return;
691
- }
692
- // ── Relay Shell: interactive terminal on local machine ──────────
693
- if (data.type === 'relay-command' && data.action === 'shell-session-start') {
694
- handleShellSessionStart(data, ws);
695
- return;
696
- }
697
- if (data.type === 'relay-shell-input') {
698
- const session = activeShellSessions.get(data.shellSessionId);
699
- if (session?.proc?.stdin?.writable) {
700
- session.proc.stdin.write(data.data);
701
- }
702
- return;
703
- }
704
- if (data.type === 'relay-shell-resize') {
705
- // PTY resize not available with basic spawn — ignored for non-pty
706
- return;
707
- }
708
- if (data.type === 'relay-shell-kill') {
709
- const session = activeShellSessions.get(data.shellSessionId);
710
- if (session?.proc) {
711
- session.proc.kill('SIGTERM');
712
- activeShellSessions.delete(data.shellSessionId);
713
- console.log(chalk.dim(' [relay] Shell session killed'));
714
- }
715
- return;
716
- }
717
-
718
- if (data.type === 'pong') return;
719
- if (data.type === 'error') {
720
- console.error(chalk.red(` Server error: ${data.error}`));
721
- return;
722
- }
723
- } catch (e) {
724
- // message parse error
725
- }
726
- });
727
-
728
- ws.on('close', (code) => {
729
- if (code === 1000) {
730
- console.log(chalk.dim(' Disconnected.'));
731
- process.exit(0);
732
- }
733
-
734
- reconnectAttempts++;
735
- if (reconnectAttempts <= MAX_RECONNECT) {
736
- const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
737
- console.log(chalk.dim(` Connection lost. Reconnecting in ${delay / 1000}s... (${reconnectAttempts}/${MAX_RECONNECT})`));
738
- setTimeout(doConnect, delay);
739
- } else {
740
- console.error(chalk.red(' Max reconnection attempts reached. Exiting.'));
741
- process.exit(1);
742
- }
743
- });
744
-
745
- ws.on('error', (err) => {
746
- if (err.code === 'ECONNREFUSED') {
747
- console.error(chalk.red(` Cannot reach ${displayUrl(serverUrl)}. Is the server running?`));
748
- }
749
- });
750
- }
751
-
752
- doConnect();
753
-
754
- process.on('SIGINT', () => {
755
- console.log(chalk.dim('\n Disconnecting...'));
756
- process.exit(0);
757
- });
758
- }
1
+ import WebSocket from 'ws';
2
+ import os from 'os';
3
+ import { spawn } from 'child_process';
4
+ import { promises as fsPromises } from 'fs';
5
+ import path from 'path';
6
+ import chalk from 'chalk';
7
+ import { readConfig, writeConfig, displayUrl } from './config.js';
8
+ import { getToken, validateToken } from './auth.js';
9
+ import { getPersistentShell } from './persistent-shell.js';
10
+ import { needsPermission, requestPermission, handlePermissionResponse } from './permissions.js';
11
+
12
+ // Active process tracking (opencode pattern: activeRequests sync.Map)
13
+ const activeProcesses = new Map(); // requestId → { proc, action }
14
+
15
+ // Active shell sessions for relay terminal (shellSessionId → { proc })
16
+ const activeShellSessions = new Map();
17
+
18
+ /**
19
+ * Start an interactive shell session on the local machine, relayed to the browser.
20
+ * Spawns a PTY-like process and streams I/O via WebSocket.
21
+ */
22
+ function handleShellSessionStart(data, ws) {
23
+ const shellSessionId = data.requestId;
24
+ const projectPath = data.projectPath || process.cwd();
25
+ const isWin = process.platform === 'win32';
26
+ const isMac = process.platform === 'darwin';
27
+ const shellType = data.shellType; // 'cmd', 'powershell', 'bash', 'zsh', etc.
28
+
29
+ console.log(chalk.cyan(` [relay] Starting shell session in ${projectPath}`));
30
+
31
+ // Build the shell command
32
+ let shellCmd, shellArgs;
33
+ const provider = data.provider || 'claude';
34
+ const isPlainShell = data.isPlainShell || (!!data.initialCommand && !data.hasSession) || provider === 'plain-shell';
35
+
36
+ /**
37
+ * Helper: get the right shell command + args for interactive shell based on platform + shellType.
38
+ * Returns { cmd, args } that cd into projectPath.
39
+ */
40
+ function getInteractiveShell(projectDir) {
41
+ if (isWin) {
42
+ if (shellType === 'cmd') {
43
+ return { cmd: 'cmd.exe', args: ['/K', `cd /d "${projectDir}"`] };
44
+ }
45
+ // Default to PowerShell on Windows
46
+ return { cmd: 'powershell.exe', args: ['-NoExit', '-Command', `Set-Location -LiteralPath '${projectDir.replace(/'/g, "''")}'`] };
47
+ }
48
+ // Unix: use shellType if specified, else $SHELL or bash
49
+ const sh = shellType || process.env.SHELL || 'bash';
50
+ return { cmd: sh, args: ['--login'] };
51
+ }
52
+
53
+ /**
54
+ * Helper: wrap a command to execute in the right shell for the platform + shellType.
55
+ * Returns { cmd, args }.
56
+ */
57
+ function wrapCommand(command, projectDir) {
58
+ if (isWin) {
59
+ if (shellType === 'cmd') {
60
+ return { cmd: 'cmd.exe', args: ['/C', `cd /d "${projectDir}" && ${command}`] };
61
+ }
62
+ return { cmd: 'powershell.exe', args: ['-Command', `Set-Location -LiteralPath '${projectDir.replace(/'/g, "''")}'; ${command}`] };
63
+ }
64
+ const sh = shellType || 'bash';
65
+ return { cmd: sh, args: ['-c', `cd "${projectDir}" && ${command}`] };
66
+ }
67
+
68
+ if (isPlainShell && data.initialCommand) {
69
+ // Run a specific command
70
+ const s = wrapCommand(data.initialCommand, projectPath);
71
+ shellCmd = s.cmd; shellArgs = s.args;
72
+ } else if (isPlainShell) {
73
+ // Interactive shell
74
+ const s = getInteractiveShell(projectPath);
75
+ shellCmd = s.cmd; shellArgs = s.args;
76
+ } else if (provider === 'cursor') {
77
+ const cursorCmd = data.hasSession && data.sessionId
78
+ ? `cursor-agent --resume="${data.sessionId}"`
79
+ : 'cursor-agent';
80
+ const s = wrapCommand(cursorCmd, projectPath);
81
+ shellCmd = s.cmd; shellArgs = s.args;
82
+ } else {
83
+ // Claude (default)
84
+ const command = data.initialCommand || 'claude';
85
+ let claudeCmd;
86
+ if (isWin) {
87
+ claudeCmd = data.hasSession && data.sessionId
88
+ ? `claude --resume ${data.sessionId}`
89
+ : command;
90
+ } else {
91
+ claudeCmd = data.hasSession && data.sessionId
92
+ ? `claude --resume ${data.sessionId} || claude`
93
+ : command;
94
+ }
95
+ const s = wrapCommand(claudeCmd, projectPath);
96
+ shellCmd = s.cmd; shellArgs = s.args;
97
+ }
98
+
99
+ // Send ack so browser knows spawn is starting
100
+ ws.send(JSON.stringify({
101
+ type: 'relay-shell-output',
102
+ shellSessionId,
103
+ data: '',
104
+ }));
105
+
106
+ const proc = spawn(shellCmd, shellArgs, {
107
+ cwd: isPlainShell && !data.initialCommand ? projectPath : undefined,
108
+ env: {
109
+ ...process.env,
110
+ TERM: 'xterm-256color',
111
+ COLORTERM: 'truecolor',
112
+ FORCE_COLOR: '3',
113
+ },
114
+ stdio: ['pipe', 'pipe', 'pipe'],
115
+ });
116
+
117
+ activeShellSessions.set(shellSessionId, { proc, projectPath });
118
+
119
+ // Stream stdout → browser via relay
120
+ proc.stdout.on('data', (chunk) => {
121
+ if (ws.readyState === WebSocket.OPEN) {
122
+ ws.send(JSON.stringify({
123
+ type: 'relay-shell-output',
124
+ shellSessionId,
125
+ data: chunk.toString(),
126
+ }));
127
+ }
128
+ });
129
+
130
+ // Stream stderr → browser via relay
131
+ proc.stderr.on('data', (chunk) => {
132
+ if (ws.readyState === WebSocket.OPEN) {
133
+ ws.send(JSON.stringify({
134
+ type: 'relay-shell-output',
135
+ shellSessionId,
136
+ data: chunk.toString(),
137
+ }));
138
+ }
139
+ });
140
+
141
+ proc.on('close', (code) => {
142
+ activeShellSessions.delete(shellSessionId);
143
+ if (ws.readyState === WebSocket.OPEN) {
144
+ ws.send(JSON.stringify({
145
+ type: 'relay-shell-exited',
146
+ shellSessionId,
147
+ exitCode: code,
148
+ }));
149
+ }
150
+ console.log(chalk.dim(` [relay] Shell session ended (code ${code})`));
151
+ });
152
+
153
+ proc.on('error', (err) => {
154
+ activeShellSessions.delete(shellSessionId);
155
+ if (ws.readyState === WebSocket.OPEN) {
156
+ ws.send(JSON.stringify({
157
+ type: 'relay-shell-output',
158
+ shellSessionId,
159
+ data: `\r\n\x1b[31mError: ${err.message}\x1b[0m\r\n`,
160
+ }));
161
+ }
162
+ });
163
+ }
164
+
165
+ /**
166
+ * Execute a shell command and return stdout
167
+ */
168
+ function execCommand(cmd, args, options = {}) {
169
+ return new Promise((resolve, reject) => {
170
+ const proc = spawn(cmd, args, {
171
+ shell: true,
172
+ cwd: options.cwd || os.homedir(),
173
+ env: { ...process.env, ...options.env },
174
+ stdio: ['pipe', 'pipe', 'pipe'],
175
+ });
176
+
177
+ let stdout = '';
178
+ let stderr = '';
179
+ proc.stdout.on('data', (d) => { stdout += d; });
180
+ proc.stderr.on('data', (d) => { stderr += d; });
181
+
182
+ const timeout = setTimeout(() => {
183
+ proc.kill();
184
+ reject(new Error('Command timed out'));
185
+ }, options.timeout || 30000);
186
+
187
+ proc.on('close', (code) => {
188
+ clearTimeout(timeout);
189
+ if (code === 0) resolve(stdout);
190
+ else reject(new Error(stderr || `Exit code ${code}`));
191
+ });
192
+
193
+ proc.on('error', (err) => {
194
+ clearTimeout(timeout);
195
+ reject(err);
196
+ });
197
+ });
198
+ }
199
+
200
+ /**
201
+ * Build a file tree for a directory
202
+ */
203
+ async function buildFileTree(dirPath, maxDepth, currentDepth = 0) {
204
+ if (currentDepth >= maxDepth) return [];
205
+ try {
206
+ const entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
207
+ const items = [];
208
+ for (const entry of entries.slice(0, 200)) {
209
+ // Skip heavy/hidden directories
210
+ if (entry.name === 'node_modules' || entry.name === '.git' ||
211
+ entry.name === 'dist' || entry.name === 'build' ||
212
+ entry.name === '.svn' || entry.name === '.hg') continue;
213
+ if (entry.name.startsWith('.') && currentDepth === 0) continue; // hide dotfiles at root only
214
+
215
+ const itemPath = path.join(dirPath, entry.name);
216
+ const item = { name: entry.name, path: itemPath, type: entry.isDirectory() ? 'directory' : 'file' };
217
+
218
+ // Get file stats for metadata (size, modified)
219
+ try {
220
+ const stats = await fsPromises.stat(itemPath);
221
+ item.size = stats.size;
222
+ item.modified = stats.mtime.toISOString();
223
+ } catch { /* ignore stat errors */ }
224
+
225
+ if (entry.isDirectory() && currentDepth < maxDepth - 1) {
226
+ item.children = await buildFileTree(itemPath, maxDepth, currentDepth + 1);
227
+ }
228
+ items.push(item);
229
+ }
230
+ return items;
231
+ } catch {
232
+ return [];
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Handle incoming relay commands from the server
238
+ */
239
+ async function handleRelayCommand(data, ws) {
240
+ const { requestId, action } = data;
241
+
242
+ try {
243
+ // Permission gate (opencode pattern: dangerous actions require browser approval)
244
+ if (needsPermission(action, data)) {
245
+ const approved = await requestPermission(ws, requestId, action, data);
246
+ if (!approved) {
247
+ ws.send(JSON.stringify({
248
+ type: 'relay-response', requestId,
249
+ error: 'Permission denied by user',
250
+ }));
251
+ return;
252
+ }
253
+ }
254
+
255
+ switch (action) {
256
+ case 'claude-query': {
257
+ const { command, options } = data;
258
+ console.log(chalk.cyan(' [relay] Processing Claude query...'));
259
+
260
+ // Stream-JSON mode for real-time token streaming (opencode pattern)
261
+ const args = ['-p', '--output-format', 'stream-json', '--include-partial-messages'];
262
+ if (options?.projectPath) args.push('--cwd', options.projectPath);
263
+ if (options?.sessionId) args.push('--continue', options.sessionId);
264
+
265
+ const proc = spawn('claude', [...args, command || ''], {
266
+ shell: true,
267
+ cwd: options?.projectPath || os.homedir(),
268
+ env: process.env,
269
+ });
270
+
271
+ // Track for abort support (opencode pattern: activeRequests)
272
+ activeProcesses.set(requestId, { proc, action: 'claude-query' });
273
+
274
+ let stdoutBuffer = '';
275
+ let capturedSessionId = null;
276
+ // Track previous text length per content block to compute deltas
277
+ // (--include-partial-messages sends FULL accumulated text each update, not just the delta)
278
+ let lastTextLength = 0;
279
+
280
+ // Parse NDJSON line-by-line (same pattern as cursor-cli.js)
281
+ proc.stdout.on('data', (chunk) => {
282
+ stdoutBuffer += chunk.toString();
283
+ const lines = stdoutBuffer.split('\n');
284
+ stdoutBuffer = lines.pop(); // keep incomplete last line in buffer
285
+
286
+ for (const line of lines) {
287
+ if (!line.trim()) continue;
288
+ try {
289
+ const evt = JSON.parse(line);
290
+
291
+ if (evt.type === 'system' && evt.subtype === 'init') {
292
+ // Capture session ID for resume support
293
+ if (evt.session_id) capturedSessionId = evt.session_id;
294
+ // Reset delta tracking for new session
295
+ lastTextLength = 0;
296
+ ws.send(JSON.stringify({
297
+ type: 'relay-stream', requestId,
298
+ data: { type: 'claude-system', sessionId: evt.session_id, model: evt.model, cwd: evt.cwd },
299
+ }));
300
+ } else if (evt.type === 'assistant' && evt.message?.content?.length) {
301
+ // --include-partial-messages sends FULL text each time; compute the new delta only
302
+ const fullText = evt.message.content[0].text || '';
303
+ const delta = fullText.slice(lastTextLength);
304
+ lastTextLength = fullText.length;
305
+ if (delta) {
306
+ ws.send(JSON.stringify({
307
+ type: 'relay-stream', requestId,
308
+ data: { type: 'claude-response', content: delta },
309
+ }));
310
+ }
311
+ } else if (evt.type === 'result') {
312
+ // Session complete — reset delta tracking, capture session ID
313
+ lastTextLength = 0;
314
+ ws.send(JSON.stringify({
315
+ type: 'relay-stream', requestId,
316
+ data: { type: 'claude-result', sessionId: capturedSessionId, subtype: evt.subtype },
317
+ }));
318
+ }
319
+ } catch {
320
+ // Non-JSON line — send as raw text only if it looks meaningful
321
+ if (line.trim() && !line.startsWith('%') && !line.includes('claude query')) {
322
+ ws.send(JSON.stringify({
323
+ type: 'relay-stream', requestId,
324
+ data: { type: 'claude-response', content: line },
325
+ }));
326
+ }
327
+ }
328
+ }
329
+ });
330
+
331
+ proc.stderr.on('data', (chunk) => {
332
+ ws.send(JSON.stringify({
333
+ type: 'relay-stream', requestId,
334
+ data: { type: 'claude-error', content: chunk.toString() },
335
+ }));
336
+ });
337
+
338
+ proc.on('close', (code) => {
339
+ activeProcesses.delete(requestId);
340
+ ws.send(JSON.stringify({
341
+ type: 'relay-complete', requestId,
342
+ exitCode: code,
343
+ sessionId: capturedSessionId,
344
+ }));
345
+ });
346
+
347
+ proc.on('error', () => {
348
+ activeProcesses.delete(requestId);
349
+ });
350
+ break;
351
+ }
352
+
353
+ case 'shell-command': {
354
+ const { command: cmd, cwd } = data;
355
+ if (!cmd || typeof cmd !== 'string') throw new Error('Invalid command');
356
+ const cmdLower = cmd.toLowerCase();
357
+ const dangerous = [
358
+ 'rm -rf /', 'mkfs', 'dd if=', ':(){', 'fork bomb', '> /dev/sd',
359
+ 'format c:', 'format d:', 'format e:', 'del /s /q c:\\',
360
+ 'rd /s /q c:\\', 'reg delete', 'bcdedit',
361
+ ];
362
+ if (dangerous.some(d => cmdLower.includes(d.toLowerCase()))) throw new Error('Command blocked for safety');
363
+ console.log(chalk.dim(' [relay] Executing shell command...'));
364
+ // Persistent shell singleton (opencode pattern: commands share one shell process)
365
+ const shell = getPersistentShell(cwd || process.cwd());
366
+ const result = await shell.exec(cmd, { timeoutMs: 60000 });
367
+ ws.send(JSON.stringify({
368
+ type: 'relay-response', requestId,
369
+ data: { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode, cwd: result.cwd },
370
+ }));
371
+ break;
372
+ }
373
+
374
+ case 'file-read': {
375
+ let { filePath, encoding } = data;
376
+ if (!filePath || typeof filePath !== 'string') throw new Error('Invalid file path');
377
+ // Resolve ~ to home directory (routes send paths like ~/.cursor/config.json)
378
+ if (filePath === '~') filePath = os.homedir();
379
+ else if (filePath.startsWith('~/') || filePath.startsWith('~\\')) filePath = path.join(os.homedir(), filePath.slice(2));
380
+ const normalizedPath = path.resolve(filePath);
381
+ const normLower = normalizedPath.toLowerCase().replace(/\\/g, '/');
382
+ const blockedRead = ['/etc/shadow', '/etc/passwd', '.ssh/id_rsa', '.ssh/id_ed25519', '/.env'];
383
+ if (blockedRead.some(b => normLower.includes(b))) throw new Error('Access denied');
384
+ const content = await fsPromises.readFile(normalizedPath, encoding === 'base64' ? null : 'utf8');
385
+ const result = encoding === 'base64' ? content.toString('base64') : content;
386
+ ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { content: result } }));
387
+ break;
388
+ }
389
+
390
+ case 'file-write': {
391
+ let fp2 = data.filePath;
392
+ const fileContent = data.content;
393
+ if (!fp2 || typeof fp2 !== 'string') throw new Error('Invalid file path');
394
+ // Resolve ~ to home directory
395
+ if (fp2 === '~') fp2 = os.homedir();
396
+ else if (fp2.startsWith('~/') || fp2.startsWith('~\\')) fp2 = path.join(os.homedir(), fp2.slice(2));
397
+ const normalizedFp = path.resolve(fp2);
398
+ const fpLower = normalizedFp.toLowerCase().replace(/\\/g, '/');
399
+ const blockedWrite = [
400
+ '/etc/', '/usr/bin/', '/usr/sbin/',
401
+ '/windows/system32', '/windows/syswow64', '/program files',
402
+ '.ssh/', '/.env',
403
+ ];
404
+ if (blockedWrite.some(d => fpLower.includes(d))) throw new Error('Access denied');
405
+ const parentDir = path.dirname(normalizedFp);
406
+ await fsPromises.mkdir(parentDir, { recursive: true }).catch(() => {});
407
+ await fsPromises.writeFile(normalizedFp, fileContent, 'utf8');
408
+ ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { success: true } }));
409
+ break;
410
+ }
411
+
412
+ case 'file-tree': {
413
+ const { dirPath, depth, maxDepth } = data;
414
+ const treeDepth = depth || maxDepth || 3;
415
+ const resolvedDir = dirPath ? path.resolve(dirPath) : process.cwd();
416
+ const tree = await buildFileTree(resolvedDir, treeDepth);
417
+ ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { files: tree } }));
418
+ break;
419
+ }
420
+
421
+ case 'browse-dirs': {
422
+ // Browse directories on user's local machine
423
+ const { dirPath: browsePath } = data;
424
+ const os = await import('os');
425
+ let targetDir = browsePath || os.default.homedir();
426
+ if (targetDir === '~') targetDir = os.default.homedir();
427
+ else if (targetDir.startsWith('~/') || targetDir.startsWith('~\\')) targetDir = path.join(os.default.homedir(), targetDir.slice(2));
428
+ targetDir = path.resolve(targetDir);
429
+
430
+ let dirs = [];
431
+ // On Windows, detect available drives to show alongside directory listing
432
+ let drives = [];
433
+ if (process.platform === 'win32') {
434
+ try {
435
+ const { execSync } = await import('child_process');
436
+ const wmicOut = execSync('wmic logicaldisk get name', { encoding: 'utf8', timeout: 5000 });
437
+ drives = wmicOut.split('\n')
438
+ .map(l => l.trim())
439
+ .filter(l => /^[A-Z]:$/.test(l))
440
+ .map(d => ({ name: d + '\\', path: d + '\\', type: 'drive' }));
441
+ } catch { /* ignore — wmic not available */ }
442
+ }
443
+
444
+ const entries = await fsPromises.readdir(targetDir, { withFileTypes: true });
445
+ dirs = entries
446
+ .filter(e => e.isDirectory() && !e.name.startsWith('.'))
447
+ .map(e => ({ name: e.name, path: path.join(targetDir, e.name), type: 'directory' }))
448
+ .sort((a, b) => a.name.localeCompare(b.name));
449
+ ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { path: targetDir, suggestions: dirs, drives, homedir: os.default.homedir() } }));
450
+ break;
451
+ }
452
+
453
+ case 'validate-path': {
454
+ // Check if a path exists on user's machine
455
+ const { targetPath } = data;
456
+ const os2 = await import('os');
457
+ let checkPath = targetPath || '';
458
+ if (checkPath === '~') checkPath = os2.default.homedir();
459
+ else if (checkPath.startsWith('~/') || checkPath.startsWith('~\\')) checkPath = path.join(os2.default.homedir(), checkPath.slice(2));
460
+ checkPath = path.resolve(checkPath);
461
+
462
+ try {
463
+ const stats = await fsPromises.stat(checkPath);
464
+ ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { exists: true, isDirectory: stats.isDirectory(), resolvedPath: checkPath } }));
465
+ } catch {
466
+ ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { exists: false, resolvedPath: checkPath } }));
467
+ }
468
+ break;
469
+ }
470
+
471
+ case 'create-folder': {
472
+ // Create a directory on user's machine
473
+ const { folderPath } = data;
474
+ const os3 = await import('os');
475
+ let mkPath = folderPath || '';
476
+ if (mkPath === '~') mkPath = os3.default.homedir();
477
+ else if (mkPath.startsWith('~/') || mkPath.startsWith('~\\')) mkPath = path.join(os3.default.homedir(), mkPath.slice(2));
478
+ mkPath = path.resolve(mkPath);
479
+
480
+ await fsPromises.mkdir(mkPath, { recursive: true });
481
+ ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { success: true, path: mkPath } }));
482
+ break;
483
+ }
484
+
485
+ case 'git-operation': {
486
+ const { gitCommand, cwd: gitCwd } = data;
487
+ console.log(chalk.dim(' [relay] Running git operation...'));
488
+ const resolvedGitCwd = gitCwd ? path.resolve(gitCwd) : process.cwd();
489
+ const result = await execCommand('git', [gitCommand], { cwd: resolvedGitCwd });
490
+ ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { stdout: result } }));
491
+ break;
492
+ }
493
+
494
+ // Sub-agent: read-only research agent (opencode pattern: AgentTask)
495
+ // Spawns a separate claude process with --allowedTools limited to read-only
496
+ case 'claude-task-query': {
497
+ const { command: taskCmd, options: taskOpts } = data;
498
+ console.log(chalk.cyan(' [relay] Spawning sub-agent for research...'));
499
+
500
+ const taskArgs = ['-p', '--output-format', 'stream-json', '--include-partial-messages'];
501
+ // Sub-agents are read-only: use --allowedTools to restrict
502
+ taskArgs.push('--allowedTools', 'View,Glob,Grep,LS,Read');
503
+ if (taskOpts?.projectPath) taskArgs.push('--cwd', taskOpts.projectPath);
504
+ // Sub-agents always start fresh (no --continue)
505
+
506
+ const taskProc = spawn('claude', [...taskArgs, taskCmd || ''], {
507
+ shell: true,
508
+ cwd: taskOpts?.projectPath || os.homedir(),
509
+ env: process.env,
510
+ });
511
+
512
+ activeProcesses.set(requestId, { proc: taskProc, action: 'claude-task-query' });
513
+ let taskBuffer = '';
514
+
515
+ taskProc.stdout.on('data', (chunk) => {
516
+ taskBuffer += chunk.toString();
517
+ const lines = taskBuffer.split('\n');
518
+ taskBuffer = lines.pop();
519
+ for (const line of lines) {
520
+ if (!line.trim()) continue;
521
+ try {
522
+ const evt = JSON.parse(line);
523
+ if (evt.type === 'assistant' && evt.message?.content?.length) {
524
+ ws.send(JSON.stringify({
525
+ type: 'relay-stream', requestId,
526
+ data: { type: 'claude-response', content: evt.message.content[0].text || '' },
527
+ }));
528
+ }
529
+ } catch {
530
+ if (line.trim()) {
531
+ ws.send(JSON.stringify({
532
+ type: 'relay-stream', requestId,
533
+ data: { type: 'claude-response', content: line },
534
+ }));
535
+ }
536
+ }
537
+ }
538
+ });
539
+
540
+ taskProc.stderr.on('data', (chunk) => {
541
+ ws.send(JSON.stringify({
542
+ type: 'relay-stream', requestId,
543
+ data: { type: 'claude-error', content: chunk.toString() },
544
+ }));
545
+ });
546
+
547
+ taskProc.on('close', (code) => {
548
+ activeProcesses.delete(requestId);
549
+ ws.send(JSON.stringify({ type: 'relay-complete', requestId, exitCode: code }));
550
+ });
551
+
552
+ taskProc.on('error', () => { activeProcesses.delete(requestId); });
553
+ break;
554
+ }
555
+
556
+ case 'detect-agents': {
557
+ // Detect installed AI CLI agents on user's machine
558
+ const agents = {};
559
+ const checkCmd = process.platform === 'win32' ? 'where' : 'which';
560
+ const agentBins = { claude: 'claude', cursor: 'cursor-agent', codex: 'codex' };
561
+ for (const [name, bin] of Object.entries(agentBins)) {
562
+ try {
563
+ const { execSync } = await import('child_process');
564
+ execSync(`${checkCmd} ${bin}`, { encoding: 'utf8', timeout: 5000, stdio: 'pipe' });
565
+ agents[name] = true;
566
+ } catch {
567
+ agents[name] = false;
568
+ }
569
+ }
570
+ ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { agents } }));
571
+ break;
572
+ }
573
+
574
+ default:
575
+ ws.send(JSON.stringify({
576
+ type: 'relay-response',
577
+ requestId,
578
+ error: `Unknown action: ${action}`,
579
+ }));
580
+ }
581
+ } catch (err) {
582
+ ws.send(JSON.stringify({
583
+ type: 'relay-response',
584
+ requestId,
585
+ error: err.message,
586
+ }));
587
+ }
588
+ }
589
+
590
+ /**
591
+ * Connect to the remote server via WebSocket relay.
592
+ * Bridges local Claude Code, shell, filesystem, and git to the web UI.
593
+ */
594
+ export async function connect(options = {}) {
595
+ const config = readConfig();
596
+ const serverUrl = options.server || config.serverUrl;
597
+ let relayKey = options.key;
598
+
599
+ // If no key provided, fetch one using the auth token
600
+ if (!relayKey) {
601
+ const token = getToken();
602
+ if (!token) {
603
+ console.log(chalk.yellow('\n No account found. Run `uc login` first.\n'));
604
+ process.exit(1);
605
+ }
606
+
607
+ console.log(chalk.dim('\n Validating session...'));
608
+ const user = await validateToken();
609
+ if (!user) {
610
+ console.log(chalk.yellow(' Session expired. Run `uc login` to re-authenticate.\n'));
611
+ process.exit(1);
612
+ }
613
+
614
+ // Fetch a relay token from the API
615
+ try {
616
+ const res = await fetch(`${serverUrl}/api/auth/connect-token`, {
617
+ headers: { Authorization: `Bearer ${token}` },
618
+ });
619
+ if (!res.ok) throw new Error('Failed to get connect token');
620
+ const data = await res.json();
621
+ relayKey = data.token;
622
+ } catch (err) {
623
+ console.log(chalk.red('\n Could not get connection token. Check your network and try again.\n'));
624
+ process.exit(1);
625
+ }
626
+ }
627
+
628
+ // Save for future use
629
+ writeConfig({ relayKey });
630
+
631
+ const wsUrl = serverUrl.replace(/^http/, 'ws') + '/relay?token=' + encodeURIComponent(relayKey);
632
+
633
+ console.log(chalk.bold('\n Upfyn-Code Relay Client\n'));
634
+ console.log(` Server: ${chalk.cyan(displayUrl(serverUrl))}`);
635
+ console.log(` Machine: ${chalk.dim(os.hostname())}`);
636
+ console.log(` User: ${chalk.dim(os.userInfo().username)}\n`);
637
+
638
+ let reconnectAttempts = 0;
639
+ const MAX_RECONNECT = 10;
640
+
641
+ function doConnect() {
642
+ const ws = new WebSocket(wsUrl);
643
+
644
+ ws.on('open', () => {
645
+ reconnectAttempts = 0;
646
+ console.log(chalk.green(' Connected! Your local machine is now bridged to the server.'));
647
+ console.log(chalk.dim(' Claude Code is the AI brain. Press Ctrl+C to disconnect.\n'));
648
+
649
+ // Send initial working directory so it becomes the default project
650
+ const cwd = process.cwd();
651
+ const dirName = path.basename(cwd);
652
+ ws.send(JSON.stringify({
653
+ type: 'relay-init',
654
+ cwd,
655
+ dirName,
656
+ homedir: os.homedir(),
657
+ platform: process.platform,
658
+ hostname: os.hostname(),
659
+ }));
660
+ console.log(chalk.dim(` Default project: ${cwd}\n`));
661
+
662
+ const heartbeat = setInterval(() => {
663
+ if (ws.readyState === WebSocket.OPEN) {
664
+ ws.send(JSON.stringify({ type: 'ping' }));
665
+ }
666
+ }, 30000);
667
+
668
+ ws.on('close', () => clearInterval(heartbeat));
669
+ });
670
+
671
+ ws.on('message', (rawMessage) => {
672
+ try {
673
+ const data = JSON.parse(rawMessage);
674
+
675
+ if (data.type === 'relay-connected') {
676
+ console.log(chalk.green(` ${data.message}`));
677
+ return;
678
+ }
679
+ if (data.type === 'relay-command') {
680
+ handleRelayCommand(data, ws);
681
+ return;
682
+ }
683
+ // Abort handler (opencode pattern: cancel via context propagation)
684
+ if (data.type === 'relay-abort') {
685
+ const entry = activeProcesses.get(data.requestId);
686
+ if (entry?.proc) {
687
+ entry.proc.kill('SIGTERM');
688
+ activeProcesses.delete(data.requestId);
689
+ ws.send(JSON.stringify({
690
+ type: 'relay-complete', requestId: data.requestId,
691
+ exitCode: -1, aborted: true,
692
+ }));
693
+ console.log(chalk.yellow(' [relay] Process aborted by user'));
694
+ }
695
+ return;
696
+ }
697
+ // Permission response from browser (opencode pattern: grant/deny flow)
698
+ if (data.type === 'relay-permission-response') {
699
+ handlePermissionResponse(data);
700
+ return;
701
+ }
702
+ // ── Relay Shell: interactive terminal on local machine ──────────
703
+ if (data.type === 'relay-command' && data.action === 'shell-session-start') {
704
+ handleShellSessionStart(data, ws);
705
+ return;
706
+ }
707
+ if (data.type === 'relay-shell-input') {
708
+ const session = activeShellSessions.get(data.shellSessionId);
709
+ if (session?.proc?.stdin?.writable) {
710
+ session.proc.stdin.write(data.data);
711
+ }
712
+ return;
713
+ }
714
+ if (data.type === 'relay-shell-resize') {
715
+ // PTY resize not available with basic spawn — ignored for non-pty
716
+ return;
717
+ }
718
+ if (data.type === 'relay-shell-kill') {
719
+ const session = activeShellSessions.get(data.shellSessionId);
720
+ if (session?.proc) {
721
+ session.proc.kill('SIGTERM');
722
+ activeShellSessions.delete(data.shellSessionId);
723
+ console.log(chalk.dim(' [relay] Shell session killed'));
724
+ }
725
+ return;
726
+ }
727
+
728
+ if (data.type === 'pong') return;
729
+ if (data.type === 'error') {
730
+ console.error(chalk.red(` Server error: ${data.error}`));
731
+ return;
732
+ }
733
+ } catch (e) {
734
+ // message parse error
735
+ }
736
+ });
737
+
738
+ ws.on('close', (code) => {
739
+ if (code === 1000) {
740
+ console.log(chalk.dim(' Disconnected.'));
741
+ process.exit(0);
742
+ }
743
+
744
+ reconnectAttempts++;
745
+ if (reconnectAttempts <= MAX_RECONNECT) {
746
+ const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);
747
+ console.log(chalk.dim(` Connection lost. Reconnecting in ${delay / 1000}s... (${reconnectAttempts}/${MAX_RECONNECT})`));
748
+ setTimeout(doConnect, delay);
749
+ } else {
750
+ console.error(chalk.red(' Max reconnection attempts reached. Exiting.'));
751
+ process.exit(1);
752
+ }
753
+ });
754
+
755
+ ws.on('error', (err) => {
756
+ if (err.code === 'ECONNREFUSED') {
757
+ console.error(chalk.red(` Cannot reach ${displayUrl(serverUrl)}. Is the server running?`));
758
+ }
759
+ });
760
+ }
761
+
762
+ doConnect();
763
+
764
+ process.on('SIGINT', () => {
765
+ console.log(chalk.dim('\n Disconnecting...'));
766
+ process.exit(0);
767
+ });
768
+ }