upfynai-code 2.7.4 → 2.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/connect.js CHANGED
@@ -1,14 +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';
11
12
 
13
+ // Resolve agents: dist/agents/ (npm package) or ../../shared/agents/ (monorepo)
14
+ const __connectDir = dirname(fileURLToPath(import.meta.url));
15
+ const _agentsPath = existsSync(join(__connectDir, '../dist/agents/index.js'))
16
+ ? '../dist/agents/index.js'
17
+ : '../../shared/agents/index.js';
18
+ const { executeAction, isStreamingAction } = await import(_agentsPath);
19
+
12
20
  // Active process tracking (opencode pattern: activeRequests sync.Map)
13
21
  const activeProcesses = new Map(); // requestId → { proc, action }
14
22
 
@@ -23,42 +31,26 @@ function handleShellSessionStart(data, ws) {
23
31
  const shellSessionId = data.requestId;
24
32
  const projectPath = data.projectPath || process.cwd();
25
33
  const isWin = process.platform === 'win32';
26
- const isMac = process.platform === 'darwin';
27
- const shellType = data.shellType; // 'cmd', 'powershell', 'bash', 'zsh', etc.
34
+ const shellType = data.shellType;
28
35
 
29
36
  console.log(chalk.cyan(` [relay] Starting shell session in ${projectPath}`));
30
37
 
31
- // Build the shell command
32
38
  let shellCmd, shellArgs;
33
39
  const provider = data.provider || 'claude';
34
40
  const isPlainShell = data.isPlainShell || (!!data.initialCommand && !data.hasSession) || provider === 'plain-shell';
35
41
 
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
42
  function getInteractiveShell(projectDir) {
41
43
  if (isWin) {
42
- if (shellType === 'cmd') {
43
- return { cmd: 'cmd.exe', args: ['/K', `cd /d "${projectDir}"`] };
44
- }
45
- // Default to PowerShell on Windows
44
+ if (shellType === 'cmd') return { cmd: 'cmd.exe', args: ['/K', `cd /d "${projectDir}"`] };
46
45
  return { cmd: 'powershell.exe', args: ['-NoExit', '-Command', `Set-Location -LiteralPath '${projectDir.replace(/'/g, "''")}'`] };
47
46
  }
48
- // Unix: use shellType if specified, else $SHELL or bash
49
47
  const sh = shellType || process.env.SHELL || 'bash';
50
48
  return { cmd: sh, args: ['--login'] };
51
49
  }
52
50
 
53
- /**
54
- * Helper: wrap a command to execute in the right shell for the platform + shellType.
55
- * Returns { cmd, args }.
56
- */
57
51
  function wrapCommand(command, projectDir) {
58
52
  if (isWin) {
59
- if (shellType === 'cmd') {
60
- return { cmd: 'cmd.exe', args: ['/C', `cd /d "${projectDir}" && ${command}`] };
61
- }
53
+ if (shellType === 'cmd') return { cmd: 'cmd.exe', args: ['/C', `cd /d "${projectDir}" && ${command}`] };
62
54
  return { cmd: 'powershell.exe', args: ['-Command', `Set-Location -LiteralPath '${projectDir.replace(/'/g, "''")}'; ${command}`] };
63
55
  }
64
56
  const sh = shellType || 'bash';
@@ -66,11 +58,9 @@ function handleShellSessionStart(data, ws) {
66
58
  }
67
59
 
68
60
  if (isPlainShell && data.initialCommand) {
69
- // Run a specific command
70
61
  const s = wrapCommand(data.initialCommand, projectPath);
71
62
  shellCmd = s.cmd; shellArgs = s.args;
72
63
  } else if (isPlainShell) {
73
- // Interactive shell
74
64
  const s = getInteractiveShell(projectPath);
75
65
  shellCmd = s.cmd; shellArgs = s.args;
76
66
  } else if (provider === 'cursor') {
@@ -80,7 +70,6 @@ function handleShellSessionStart(data, ws) {
80
70
  const s = wrapCommand(cursorCmd, projectPath);
81
71
  shellCmd = s.cmd; shellArgs = s.args;
82
72
  } else {
83
- // Claude (default)
84
73
  const command = data.initialCommand || 'claude';
85
74
  let claudeCmd;
86
75
  if (isWin) {
@@ -96,56 +85,32 @@ function handleShellSessionStart(data, ws) {
96
85
  shellCmd = s.cmd; shellArgs = s.args;
97
86
  }
98
87
 
99
- // Send ack so browser knows spawn is starting
100
- ws.send(JSON.stringify({
101
- type: 'relay-shell-output',
102
- shellSessionId,
103
- data: '',
104
- }));
88
+ ws.send(JSON.stringify({ type: 'relay-shell-output', shellSessionId, data: '' }));
105
89
 
106
90
  const proc = spawn(shellCmd, shellArgs, {
107
91
  cwd: isPlainShell && !data.initialCommand ? projectPath : undefined,
108
- env: {
109
- ...process.env,
110
- TERM: 'xterm-256color',
111
- COLORTERM: 'truecolor',
112
- FORCE_COLOR: '3',
113
- },
92
+ env: { ...process.env, TERM: 'xterm-256color', COLORTERM: 'truecolor', FORCE_COLOR: '3' },
114
93
  stdio: ['pipe', 'pipe', 'pipe'],
115
94
  });
116
95
 
117
96
  activeShellSessions.set(shellSessionId, { proc, projectPath });
118
97
 
119
- // Stream stdout → browser via relay
120
98
  proc.stdout.on('data', (chunk) => {
121
99
  if (ws.readyState === WebSocket.OPEN) {
122
- ws.send(JSON.stringify({
123
- type: 'relay-shell-output',
124
- shellSessionId,
125
- data: chunk.toString(),
126
- }));
100
+ ws.send(JSON.stringify({ type: 'relay-shell-output', shellSessionId, data: chunk.toString() }));
127
101
  }
128
102
  });
129
103
 
130
- // Stream stderr → browser via relay
131
104
  proc.stderr.on('data', (chunk) => {
132
105
  if (ws.readyState === WebSocket.OPEN) {
133
- ws.send(JSON.stringify({
134
- type: 'relay-shell-output',
135
- shellSessionId,
136
- data: chunk.toString(),
137
- }));
106
+ ws.send(JSON.stringify({ type: 'relay-shell-output', shellSessionId, data: chunk.toString() }));
138
107
  }
139
108
  });
140
109
 
141
110
  proc.on('close', (code) => {
142
111
  activeShellSessions.delete(shellSessionId);
143
112
  if (ws.readyState === WebSocket.OPEN) {
144
- ws.send(JSON.stringify({
145
- type: 'relay-shell-exited',
146
- shellSessionId,
147
- exitCode: code,
148
- }));
113
+ ws.send(JSON.stringify({ type: 'relay-shell-exited', shellSessionId, exitCode: code }));
149
114
  }
150
115
  console.log(chalk.dim(` [relay] Shell session ended (code ${code})`));
151
116
  });
@@ -153,88 +118,33 @@ function handleShellSessionStart(data, ws) {
153
118
  proc.on('error', (err) => {
154
119
  activeShellSessions.delete(shellSessionId);
155
120
  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
- }));
121
+ ws.send(JSON.stringify({ type: 'relay-shell-output', shellSessionId, data: `\r\n\x1b[31mError: ${err.message}\x1b[0m\r\n` }));
161
122
  }
162
123
  });
163
124
  }
164
125
 
165
126
  /**
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
127
+ * Build execution context for shared agents.
128
+ * Provides CLI-specific capabilities (persistent shell, process tracking, streaming).
202
129
  */
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);
130
+ function buildAgentContext(requestId, ws) {
131
+ return {
132
+ requestId,
133
+ streamMode: 'structured',
134
+ getPersistentShell,
135
+ trackProcess: (id, entry) => activeProcesses.set(id, entry),
136
+ untrackProcess: (id) => activeProcesses.delete(id),
137
+ stream: (data) => {
138
+ if (ws.readyState === WebSocket.OPEN) {
139
+ ws.send(JSON.stringify({ type: 'relay-stream', requestId, data }));
227
140
  }
228
- items.push(item);
229
- }
230
- return items;
231
- } catch {
232
- return [];
233
- }
141
+ },
142
+ };
234
143
  }
235
144
 
236
145
  /**
237
- * Handle incoming relay commands from the server
146
+ * Handle incoming relay commands from the server.
147
+ * Delegates to shared agent modules for action execution.
238
148
  */
239
149
  async function handleRelayCommand(data, ws) {
240
150
  const { requestId, action } = data;
@@ -244,369 +154,29 @@ async function handleRelayCommand(data, ws) {
244
154
  if (needsPermission(action, data)) {
245
155
  const approved = await requestPermission(ws, requestId, action, data);
246
156
  if (!approved) {
247
- ws.send(JSON.stringify({
248
- type: 'relay-response', requestId,
249
- error: 'Permission denied by user',
250
- }));
157
+ ws.send(JSON.stringify({ type: 'relay-response', requestId, error: 'Permission denied by user' }));
251
158
  return;
252
159
  }
253
160
  }
254
161
 
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
- }
351
-
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
- }
410
-
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
- }
162
+ const ctx = buildAgentContext(requestId, ws);
571
163
 
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
- }
164
+ // For the exec action, params come from data.options in the old format
165
+ const params = action === 'exec'
166
+ ? { command: data.options?.command || data.command, timeout: data.options?.timeout || data.timeout, cwd: data.options?.cwd || data.cwd }
167
+ : data;
596
168
 
597
- default:
598
- ws.send(JSON.stringify({
599
- type: 'relay-response',
600
- requestId,
601
- error: `Unknown action: ${action}`,
602
- }));
169
+ if (isStreamingAction(action)) {
170
+ // Streaming actions: agent calls ctx.stream() for chunks, returns on completion
171
+ const result = await executeAction(action, params, ctx);
172
+ ws.send(JSON.stringify({ type: 'relay-complete', requestId, exitCode: result.exitCode, sessionId: result.sessionId }));
173
+ } else {
174
+ // Sync actions: agent returns data directly
175
+ const result = await executeAction(action, params, ctx);
176
+ ws.send(JSON.stringify({ type: 'relay-response', requestId, data: result }));
603
177
  }
604
178
  } catch (err) {
605
- ws.send(JSON.stringify({
606
- type: 'relay-response',
607
- requestId,
608
- error: err.message,
609
- }));
179
+ ws.send(JSON.stringify({ type: 'relay-response', requestId, error: err.message }));
610
180
  }
611
181
  }
612
182
 
@@ -619,7 +189,6 @@ export async function connect(options = {}) {
619
189
  const serverUrl = options.server || config.serverUrl;
620
190
  let relayKey = options.key;
621
191
 
622
- // If no key provided, fetch one using the auth token
623
192
  if (!relayKey) {
624
193
  const token = getToken();
625
194
  if (!token) {
@@ -634,7 +203,6 @@ export async function connect(options = {}) {
634
203
  process.exit(1);
635
204
  }
636
205
 
637
- // Fetch a relay token from the API
638
206
  try {
639
207
  const res = await fetch(`${serverUrl}/api/auth/connect-token`, {
640
208
  headers: { Authorization: `Bearer ${token}` },
@@ -648,7 +216,6 @@ export async function connect(options = {}) {
648
216
  }
649
217
  }
650
218
 
651
- // Save for future use
652
219
  writeConfig({ relayKey });
653
220
 
654
221
  const wsUrl = serverUrl.replace(/^http/, 'ws') + '/relay?token=' + encodeURIComponent(relayKey);
@@ -669,25 +236,17 @@ export async function connect(options = {}) {
669
236
  console.log(chalk.green(' Connected! Your local machine is now bridged to the server.'));
670
237
  console.log(chalk.dim(' Claude Code is the AI brain. Press Ctrl+C to disconnect.\n'));
671
238
 
672
- // Send initial working directory so it becomes the default project
673
239
  const cwd = process.cwd();
674
240
  const dirName = path.basename(cwd);
675
241
  ws.send(JSON.stringify({
676
- type: 'relay-init',
677
- cwd,
678
- dirName,
679
- homedir: os.homedir(),
680
- platform: process.platform,
681
- hostname: os.hostname(),
242
+ type: 'relay-init', cwd, dirName,
243
+ homedir: os.homedir(), platform: process.platform, hostname: os.hostname(),
682
244
  }));
683
245
  console.log(chalk.dim(` Default project: ${cwd}\n`));
684
246
 
685
247
  const heartbeat = setInterval(() => {
686
- if (ws.readyState === WebSocket.OPEN) {
687
- ws.send(JSON.stringify({ type: 'ping' }));
688
- }
248
+ if (ws.readyState === WebSocket.OPEN) ws.send(JSON.stringify({ type: 'ping' }));
689
249
  }, 30000);
690
-
691
250
  ws.on('close', () => clearInterval(heartbeat));
692
251
  });
693
252
 
@@ -700,44 +259,33 @@ export async function connect(options = {}) {
700
259
  return;
701
260
  }
702
261
  if (data.type === 'relay-command') {
703
- handleRelayCommand(data, ws);
262
+ if (data.action === 'shell-session-start') {
263
+ handleShellSessionStart(data, ws);
264
+ } else {
265
+ handleRelayCommand(data, ws);
266
+ }
704
267
  return;
705
268
  }
706
- // Abort handler (opencode pattern: cancel via context propagation)
707
269
  if (data.type === 'relay-abort') {
708
270
  const entry = activeProcesses.get(data.requestId);
709
271
  if (entry?.proc) {
710
272
  entry.proc.kill('SIGTERM');
711
273
  activeProcesses.delete(data.requestId);
712
- ws.send(JSON.stringify({
713
- type: 'relay-complete', requestId: data.requestId,
714
- exitCode: -1, aborted: true,
715
- }));
274
+ ws.send(JSON.stringify({ type: 'relay-complete', requestId: data.requestId, exitCode: -1, aborted: true }));
716
275
  console.log(chalk.yellow(' [relay] Process aborted by user'));
717
276
  }
718
277
  return;
719
278
  }
720
- // Permission response from browser (opencode pattern: grant/deny flow)
721
279
  if (data.type === 'relay-permission-response') {
722
280
  handlePermissionResponse(data);
723
281
  return;
724
282
  }
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
283
  if (data.type === 'relay-shell-input') {
731
284
  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
285
+ if (session?.proc?.stdin?.writable) session.proc.stdin.write(data.data);
739
286
  return;
740
287
  }
288
+ if (data.type === 'relay-shell-resize') return;
741
289
  if (data.type === 'relay-shell-kill') {
742
290
  const session = activeShellSessions.get(data.shellSessionId);
743
291
  if (session?.proc) {
@@ -747,7 +295,6 @@ export async function connect(options = {}) {
747
295
  }
748
296
  return;
749
297
  }
750
-
751
298
  if (data.type === 'pong') return;
752
299
  if (data.type === 'error') {
753
300
  console.error(chalk.red(` Server error: ${data.error}`));
@@ -763,7 +310,6 @@ export async function connect(options = {}) {
763
310
  console.log(chalk.dim(' Disconnected.'));
764
311
  process.exit(0);
765
312
  }
766
-
767
313
  reconnectAttempts++;
768
314
  if (reconnectAttempts <= MAX_RECONNECT) {
769
315
  const delay = Math.min(1000 * Math.pow(2, reconnectAttempts), 30000);