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/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 +4 -2
- package/scripts/prepublish.js +41 -0
- package/src/connect.js +61 -515
- package/src/launch.js +39 -15
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
|
|
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
|
-
|
|
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
|
-
*
|
|
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
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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);
|