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/bin/cli.js +1 -1
- package/dist/agents/claude.js +197 -0
- package/dist/agents/codex.js +48 -0
- package/dist/agents/cursor.js +48 -0
- package/dist/agents/detect.js +51 -0
- package/dist/agents/exec.js +31 -0
- package/dist/agents/files.js +105 -0
- package/dist/agents/git.js +18 -0
- package/dist/agents/index.js +87 -0
- package/dist/agents/shell.js +38 -0
- package/dist/agents/utils.js +136 -0
- package/package.json +5 -2
- package/scripts/postinstall.js +9 -0
- package/scripts/prepublish.js +41 -0
- package/src/animation.js +228 -0
- package/src/connect.js +65 -515
- package/src/launch.js +39 -15
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
|
|
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
|
-
|
|
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
|
-
*
|
|
128
|
+
* Build execution context for shared agents.
|
|
129
|
+
* Provides CLI-specific capabilities (persistent shell, process tracking, streaming).
|
|
167
130
|
*/
|
|
168
|
-
function
|
|
169
|
-
return
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
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
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|