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