upfynai-code 2.6.7 → 2.7.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/package.json +1 -1
- package/src/connect.js +435 -27
- package/src/permissions.js +140 -0
- package/src/persistent-shell.js +261 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "upfynai-code",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.7.1",
|
|
4
4
|
"description": "Unified AI coding interface — access AI chat, terminal, file explorer, git, and visual canvas from any browser. Connect your local machine and code from anywhere.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
package/src/connect.js
CHANGED
|
@@ -6,6 +6,161 @@ import path from 'path';
|
|
|
6
6
|
import chalk from 'chalk';
|
|
7
7
|
import { readConfig, writeConfig, displayUrl } from './config.js';
|
|
8
8
|
import { getToken, validateToken } from './auth.js';
|
|
9
|
+
import { getPersistentShell } from './persistent-shell.js';
|
|
10
|
+
import { needsPermission, requestPermission, handlePermissionResponse } from './permissions.js';
|
|
11
|
+
|
|
12
|
+
// Active process tracking (opencode pattern: activeRequests sync.Map)
|
|
13
|
+
const activeProcesses = new Map(); // requestId → { proc, action }
|
|
14
|
+
|
|
15
|
+
// Active shell sessions for relay terminal (shellSessionId → { proc })
|
|
16
|
+
const activeShellSessions = new Map();
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Start an interactive shell session on the local machine, relayed to the browser.
|
|
20
|
+
* Spawns a PTY-like process and streams I/O via WebSocket.
|
|
21
|
+
*/
|
|
22
|
+
function handleShellSessionStart(data, ws) {
|
|
23
|
+
const shellSessionId = data.requestId;
|
|
24
|
+
const projectPath = data.projectPath || process.cwd();
|
|
25
|
+
const isWin = process.platform === 'win32';
|
|
26
|
+
const isMac = process.platform === 'darwin';
|
|
27
|
+
const shellType = data.shellType; // 'cmd', 'powershell', 'bash', 'zsh', etc.
|
|
28
|
+
|
|
29
|
+
console.log(chalk.cyan(` [relay] Starting shell session in ${projectPath}`));
|
|
30
|
+
|
|
31
|
+
// Build the shell command
|
|
32
|
+
let shellCmd, shellArgs;
|
|
33
|
+
const provider = data.provider || 'claude';
|
|
34
|
+
const isPlainShell = data.isPlainShell || (!!data.initialCommand && !data.hasSession) || provider === 'plain-shell';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Helper: get the right shell command + args for interactive shell based on platform + shellType.
|
|
38
|
+
* Returns { cmd, args } that cd into projectPath.
|
|
39
|
+
*/
|
|
40
|
+
function getInteractiveShell(projectDir) {
|
|
41
|
+
if (isWin) {
|
|
42
|
+
if (shellType === 'cmd') {
|
|
43
|
+
return { cmd: 'cmd.exe', args: ['/K', `cd /d "${projectDir}"`] };
|
|
44
|
+
}
|
|
45
|
+
// Default to PowerShell on Windows
|
|
46
|
+
return { cmd: 'powershell.exe', args: ['-NoExit', '-Command', `Set-Location -LiteralPath '${projectDir.replace(/'/g, "''")}'`] };
|
|
47
|
+
}
|
|
48
|
+
// Unix: use shellType if specified, else $SHELL or bash
|
|
49
|
+
const sh = shellType || process.env.SHELL || 'bash';
|
|
50
|
+
return { cmd: sh, args: ['--login'] };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Helper: wrap a command to execute in the right shell for the platform + shellType.
|
|
55
|
+
* Returns { cmd, args }.
|
|
56
|
+
*/
|
|
57
|
+
function wrapCommand(command, projectDir) {
|
|
58
|
+
if (isWin) {
|
|
59
|
+
if (shellType === 'cmd') {
|
|
60
|
+
return { cmd: 'cmd.exe', args: ['/C', `cd /d "${projectDir}" && ${command}`] };
|
|
61
|
+
}
|
|
62
|
+
return { cmd: 'powershell.exe', args: ['-Command', `Set-Location -LiteralPath '${projectDir.replace(/'/g, "''")}'; ${command}`] };
|
|
63
|
+
}
|
|
64
|
+
const sh = shellType || 'bash';
|
|
65
|
+
return { cmd: sh, args: ['-c', `cd "${projectDir}" && ${command}`] };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (isPlainShell && data.initialCommand) {
|
|
69
|
+
// Run a specific command
|
|
70
|
+
const s = wrapCommand(data.initialCommand, projectPath);
|
|
71
|
+
shellCmd = s.cmd; shellArgs = s.args;
|
|
72
|
+
} else if (isPlainShell) {
|
|
73
|
+
// Interactive shell
|
|
74
|
+
const s = getInteractiveShell(projectPath);
|
|
75
|
+
shellCmd = s.cmd; shellArgs = s.args;
|
|
76
|
+
} else if (provider === 'cursor') {
|
|
77
|
+
const cursorCmd = data.hasSession && data.sessionId
|
|
78
|
+
? `cursor-agent --resume="${data.sessionId}"`
|
|
79
|
+
: 'cursor-agent';
|
|
80
|
+
const s = wrapCommand(cursorCmd, projectPath);
|
|
81
|
+
shellCmd = s.cmd; shellArgs = s.args;
|
|
82
|
+
} else {
|
|
83
|
+
// Claude (default)
|
|
84
|
+
const command = data.initialCommand || 'claude';
|
|
85
|
+
let claudeCmd;
|
|
86
|
+
if (isWin) {
|
|
87
|
+
claudeCmd = data.hasSession && data.sessionId
|
|
88
|
+
? `claude --resume ${data.sessionId}`
|
|
89
|
+
: command;
|
|
90
|
+
} else {
|
|
91
|
+
claudeCmd = data.hasSession && data.sessionId
|
|
92
|
+
? `claude --resume ${data.sessionId} || claude`
|
|
93
|
+
: command;
|
|
94
|
+
}
|
|
95
|
+
const s = wrapCommand(claudeCmd, projectPath);
|
|
96
|
+
shellCmd = s.cmd; shellArgs = s.args;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// Send ack so browser knows spawn is starting
|
|
100
|
+
ws.send(JSON.stringify({
|
|
101
|
+
type: 'relay-shell-output',
|
|
102
|
+
shellSessionId,
|
|
103
|
+
data: '',
|
|
104
|
+
}));
|
|
105
|
+
|
|
106
|
+
const proc = spawn(shellCmd, shellArgs, {
|
|
107
|
+
cwd: isPlainShell && !data.initialCommand ? projectPath : undefined,
|
|
108
|
+
env: {
|
|
109
|
+
...process.env,
|
|
110
|
+
TERM: 'xterm-256color',
|
|
111
|
+
COLORTERM: 'truecolor',
|
|
112
|
+
FORCE_COLOR: '3',
|
|
113
|
+
},
|
|
114
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
activeShellSessions.set(shellSessionId, { proc, projectPath });
|
|
118
|
+
|
|
119
|
+
// Stream stdout → browser via relay
|
|
120
|
+
proc.stdout.on('data', (chunk) => {
|
|
121
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
122
|
+
ws.send(JSON.stringify({
|
|
123
|
+
type: 'relay-shell-output',
|
|
124
|
+
shellSessionId,
|
|
125
|
+
data: chunk.toString(),
|
|
126
|
+
}));
|
|
127
|
+
}
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
// Stream stderr → browser via relay
|
|
131
|
+
proc.stderr.on('data', (chunk) => {
|
|
132
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
133
|
+
ws.send(JSON.stringify({
|
|
134
|
+
type: 'relay-shell-output',
|
|
135
|
+
shellSessionId,
|
|
136
|
+
data: chunk.toString(),
|
|
137
|
+
}));
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
proc.on('close', (code) => {
|
|
142
|
+
activeShellSessions.delete(shellSessionId);
|
|
143
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
144
|
+
ws.send(JSON.stringify({
|
|
145
|
+
type: 'relay-shell-exited',
|
|
146
|
+
shellSessionId,
|
|
147
|
+
exitCode: code,
|
|
148
|
+
}));
|
|
149
|
+
}
|
|
150
|
+
console.log(chalk.dim(` [relay] Shell session ended (code ${code})`));
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
proc.on('error', (err) => {
|
|
154
|
+
activeShellSessions.delete(shellSessionId);
|
|
155
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
156
|
+
ws.send(JSON.stringify({
|
|
157
|
+
type: 'relay-shell-output',
|
|
158
|
+
shellSessionId,
|
|
159
|
+
data: `\r\n\x1b[31mError: ${err.message}\x1b[0m\r\n`,
|
|
160
|
+
}));
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
}
|
|
9
164
|
|
|
10
165
|
/**
|
|
11
166
|
* Execute a shell command and return stdout
|
|
@@ -50,11 +205,25 @@ async function buildFileTree(dirPath, maxDepth, currentDepth = 0) {
|
|
|
50
205
|
try {
|
|
51
206
|
const entries = await fsPromises.readdir(dirPath, { withFileTypes: true });
|
|
52
207
|
const items = [];
|
|
53
|
-
for (const entry of entries.slice(0,
|
|
54
|
-
|
|
55
|
-
|
|
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
|
+
|
|
56
225
|
if (entry.isDirectory() && currentDepth < maxDepth - 1) {
|
|
57
|
-
item.children = await buildFileTree(
|
|
226
|
+
item.children = await buildFileTree(itemPath, maxDepth, currentDepth + 1);
|
|
58
227
|
}
|
|
59
228
|
items.push(item);
|
|
60
229
|
}
|
|
@@ -71,12 +240,25 @@ async function handleRelayCommand(data, ws) {
|
|
|
71
240
|
const { requestId, action } = data;
|
|
72
241
|
|
|
73
242
|
try {
|
|
243
|
+
// Permission gate (opencode pattern: dangerous actions require browser approval)
|
|
244
|
+
if (needsPermission(action, data)) {
|
|
245
|
+
const approved = await requestPermission(ws, requestId, action, data);
|
|
246
|
+
if (!approved) {
|
|
247
|
+
ws.send(JSON.stringify({
|
|
248
|
+
type: 'relay-response', requestId,
|
|
249
|
+
error: 'Permission denied by user',
|
|
250
|
+
}));
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
74
255
|
switch (action) {
|
|
75
256
|
case 'claude-query': {
|
|
76
257
|
const { command, options } = data;
|
|
77
258
|
console.log(chalk.cyan(' [relay] Processing Claude query...'));
|
|
78
259
|
|
|
79
|
-
|
|
260
|
+
// Stream-JSON mode for real-time token streaming (opencode pattern)
|
|
261
|
+
const args = ['-p', '--output-format', 'stream-json', '--include-partial-messages'];
|
|
80
262
|
if (options?.projectPath) args.push('--cwd', options.projectPath);
|
|
81
263
|
if (options?.sessionId) args.push('--continue', options.sessionId);
|
|
82
264
|
|
|
@@ -86,29 +268,75 @@ async function handleRelayCommand(data, ws) {
|
|
|
86
268
|
env: process.env,
|
|
87
269
|
});
|
|
88
270
|
|
|
271
|
+
// Track for abort support (opencode pattern: activeRequests)
|
|
272
|
+
activeProcesses.set(requestId, { proc, action: 'claude-query' });
|
|
273
|
+
|
|
274
|
+
let stdoutBuffer = '';
|
|
275
|
+
let capturedSessionId = null;
|
|
276
|
+
|
|
277
|
+
// Parse NDJSON line-by-line (same pattern as cursor-cli.js)
|
|
89
278
|
proc.stdout.on('data', (chunk) => {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
279
|
+
stdoutBuffer += chunk.toString();
|
|
280
|
+
const lines = stdoutBuffer.split('\n');
|
|
281
|
+
stdoutBuffer = lines.pop(); // keep incomplete last line in buffer
|
|
282
|
+
|
|
283
|
+
for (const line of lines) {
|
|
284
|
+
if (!line.trim()) continue;
|
|
285
|
+
try {
|
|
286
|
+
const evt = JSON.parse(line);
|
|
287
|
+
|
|
288
|
+
if (evt.type === 'system' && evt.subtype === 'init') {
|
|
289
|
+
// Capture session ID for resume support
|
|
290
|
+
if (evt.session_id) capturedSessionId = evt.session_id;
|
|
291
|
+
ws.send(JSON.stringify({
|
|
292
|
+
type: 'relay-stream', requestId,
|
|
293
|
+
data: { type: 'claude-system', sessionId: evt.session_id, model: evt.model, cwd: evt.cwd },
|
|
294
|
+
}));
|
|
295
|
+
} else if (evt.type === 'assistant' && evt.message?.content?.length) {
|
|
296
|
+
// Real-time text delta
|
|
297
|
+
const text = evt.message.content[0].text || '';
|
|
298
|
+
ws.send(JSON.stringify({
|
|
299
|
+
type: 'relay-stream', requestId,
|
|
300
|
+
data: { type: 'claude-response', content: text },
|
|
301
|
+
}));
|
|
302
|
+
} else if (evt.type === 'result') {
|
|
303
|
+
// Session complete — include captured session ID
|
|
304
|
+
ws.send(JSON.stringify({
|
|
305
|
+
type: 'relay-stream', requestId,
|
|
306
|
+
data: { type: 'claude-result', sessionId: capturedSessionId, subtype: evt.subtype },
|
|
307
|
+
}));
|
|
308
|
+
}
|
|
309
|
+
} catch {
|
|
310
|
+
// Non-JSON line — send as raw text
|
|
311
|
+
if (line.trim()) {
|
|
312
|
+
ws.send(JSON.stringify({
|
|
313
|
+
type: 'relay-stream', requestId,
|
|
314
|
+
data: { type: 'claude-response', content: line },
|
|
315
|
+
}));
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
95
319
|
});
|
|
96
320
|
|
|
97
321
|
proc.stderr.on('data', (chunk) => {
|
|
98
322
|
ws.send(JSON.stringify({
|
|
99
|
-
type: 'relay-stream',
|
|
100
|
-
requestId,
|
|
323
|
+
type: 'relay-stream', requestId,
|
|
101
324
|
data: { type: 'claude-error', content: chunk.toString() },
|
|
102
325
|
}));
|
|
103
326
|
});
|
|
104
327
|
|
|
105
328
|
proc.on('close', (code) => {
|
|
329
|
+
activeProcesses.delete(requestId);
|
|
106
330
|
ws.send(JSON.stringify({
|
|
107
|
-
type: 'relay-complete',
|
|
108
|
-
requestId,
|
|
331
|
+
type: 'relay-complete', requestId,
|
|
109
332
|
exitCode: code,
|
|
333
|
+
sessionId: capturedSessionId,
|
|
110
334
|
}));
|
|
111
335
|
});
|
|
336
|
+
|
|
337
|
+
proc.on('error', () => {
|
|
338
|
+
activeProcesses.delete(requestId);
|
|
339
|
+
});
|
|
112
340
|
break;
|
|
113
341
|
}
|
|
114
342
|
|
|
@@ -123,27 +351,40 @@ async function handleRelayCommand(data, ws) {
|
|
|
123
351
|
];
|
|
124
352
|
if (dangerous.some(d => cmdLower.includes(d.toLowerCase()))) throw new Error('Command blocked for safety');
|
|
125
353
|
console.log(chalk.dim(' [relay] Executing shell command...'));
|
|
126
|
-
|
|
127
|
-
|
|
354
|
+
// Persistent shell singleton (opencode pattern: commands share one shell process)
|
|
355
|
+
const shell = getPersistentShell(cwd || process.cwd());
|
|
356
|
+
const result = await shell.exec(cmd, { timeoutMs: 60000 });
|
|
357
|
+
ws.send(JSON.stringify({
|
|
358
|
+
type: 'relay-response', requestId,
|
|
359
|
+
data: { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode, cwd: result.cwd },
|
|
360
|
+
}));
|
|
128
361
|
break;
|
|
129
362
|
}
|
|
130
363
|
|
|
131
364
|
case 'file-read': {
|
|
132
|
-
|
|
365
|
+
let { filePath, encoding } = data;
|
|
133
366
|
if (!filePath || typeof filePath !== 'string') throw new Error('Invalid file path');
|
|
367
|
+
// Resolve ~ to home directory (routes send paths like ~/.cursor/config.json)
|
|
368
|
+
if (filePath === '~') filePath = os.homedir();
|
|
369
|
+
else if (filePath.startsWith('~/') || filePath.startsWith('~\\')) filePath = path.join(os.homedir(), filePath.slice(2));
|
|
134
370
|
const normalizedPath = path.resolve(filePath);
|
|
135
371
|
const normLower = normalizedPath.toLowerCase().replace(/\\/g, '/');
|
|
136
372
|
const blockedRead = ['/etc/shadow', '/etc/passwd', '.ssh/id_rsa', '.ssh/id_ed25519', '/.env'];
|
|
137
373
|
if (blockedRead.some(b => normLower.includes(b))) throw new Error('Access denied');
|
|
138
|
-
const content = await fsPromises.readFile(normalizedPath, 'utf8');
|
|
139
|
-
|
|
374
|
+
const content = await fsPromises.readFile(normalizedPath, encoding === 'base64' ? null : 'utf8');
|
|
375
|
+
const result = encoding === 'base64' ? content.toString('base64') : content;
|
|
376
|
+
ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { content: result } }));
|
|
140
377
|
break;
|
|
141
378
|
}
|
|
142
379
|
|
|
143
380
|
case 'file-write': {
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
381
|
+
let fp2 = data.filePath;
|
|
382
|
+
const fileContent = data.content;
|
|
383
|
+
if (!fp2 || typeof fp2 !== 'string') throw new Error('Invalid file path');
|
|
384
|
+
// Resolve ~ to home directory
|
|
385
|
+
if (fp2 === '~') fp2 = os.homedir();
|
|
386
|
+
else if (fp2.startsWith('~/') || fp2.startsWith('~\\')) fp2 = path.join(os.homedir(), fp2.slice(2));
|
|
387
|
+
const normalizedFp = path.resolve(fp2);
|
|
147
388
|
const fpLower = normalizedFp.toLowerCase().replace(/\\/g, '/');
|
|
148
389
|
const blockedWrite = [
|
|
149
390
|
'/etc/', '/usr/bin/', '/usr/sbin/',
|
|
@@ -159,10 +400,11 @@ async function handleRelayCommand(data, ws) {
|
|
|
159
400
|
}
|
|
160
401
|
|
|
161
402
|
case 'file-tree': {
|
|
162
|
-
const { dirPath, depth
|
|
403
|
+
const { dirPath, depth, maxDepth } = data;
|
|
404
|
+
const treeDepth = depth || maxDepth || 3;
|
|
163
405
|
const resolvedDir = dirPath ? path.resolve(dirPath) : process.cwd();
|
|
164
|
-
const tree = await buildFileTree(resolvedDir,
|
|
165
|
-
ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { tree } }));
|
|
406
|
+
const tree = await buildFileTree(resolvedDir, treeDepth);
|
|
407
|
+
ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { files: tree } }));
|
|
166
408
|
break;
|
|
167
409
|
}
|
|
168
410
|
|
|
@@ -175,12 +417,26 @@ async function handleRelayCommand(data, ws) {
|
|
|
175
417
|
else if (targetDir.startsWith('~/') || targetDir.startsWith('~\\')) targetDir = path.join(os.default.homedir(), targetDir.slice(2));
|
|
176
418
|
targetDir = path.resolve(targetDir);
|
|
177
419
|
|
|
420
|
+
let dirs = [];
|
|
421
|
+
// On Windows, detect available drives to show alongside directory listing
|
|
422
|
+
let drives = [];
|
|
423
|
+
if (process.platform === 'win32') {
|
|
424
|
+
try {
|
|
425
|
+
const { execSync } = await import('child_process');
|
|
426
|
+
const wmicOut = execSync('wmic logicaldisk get name', { encoding: 'utf8', timeout: 5000 });
|
|
427
|
+
drives = wmicOut.split('\n')
|
|
428
|
+
.map(l => l.trim())
|
|
429
|
+
.filter(l => /^[A-Z]:$/.test(l))
|
|
430
|
+
.map(d => ({ name: d + '\\', path: d + '\\', type: 'drive' }));
|
|
431
|
+
} catch { /* ignore — wmic not available */ }
|
|
432
|
+
}
|
|
433
|
+
|
|
178
434
|
const entries = await fsPromises.readdir(targetDir, { withFileTypes: true });
|
|
179
|
-
|
|
435
|
+
dirs = entries
|
|
180
436
|
.filter(e => e.isDirectory() && !e.name.startsWith('.'))
|
|
181
437
|
.map(e => ({ name: e.name, path: path.join(targetDir, e.name), type: 'directory' }))
|
|
182
438
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
183
|
-
ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { path: targetDir, suggestions: dirs, homedir: os.default.homedir() } }));
|
|
439
|
+
ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { path: targetDir, suggestions: dirs, drives, homedir: os.default.homedir() } }));
|
|
184
440
|
break;
|
|
185
441
|
}
|
|
186
442
|
|
|
@@ -202,6 +458,20 @@ async function handleRelayCommand(data, ws) {
|
|
|
202
458
|
break;
|
|
203
459
|
}
|
|
204
460
|
|
|
461
|
+
case 'create-folder': {
|
|
462
|
+
// Create a directory on user's machine
|
|
463
|
+
const { folderPath } = data;
|
|
464
|
+
const os3 = await import('os');
|
|
465
|
+
let mkPath = folderPath || '';
|
|
466
|
+
if (mkPath === '~') mkPath = os3.default.homedir();
|
|
467
|
+
else if (mkPath.startsWith('~/') || mkPath.startsWith('~\\')) mkPath = path.join(os3.default.homedir(), mkPath.slice(2));
|
|
468
|
+
mkPath = path.resolve(mkPath);
|
|
469
|
+
|
|
470
|
+
await fsPromises.mkdir(mkPath, { recursive: true });
|
|
471
|
+
ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { success: true, path: mkPath } }));
|
|
472
|
+
break;
|
|
473
|
+
}
|
|
474
|
+
|
|
205
475
|
case 'git-operation': {
|
|
206
476
|
const { gitCommand, cwd: gitCwd } = data;
|
|
207
477
|
console.log(chalk.dim(' [relay] Running git operation...'));
|
|
@@ -211,6 +481,86 @@ async function handleRelayCommand(data, ws) {
|
|
|
211
481
|
break;
|
|
212
482
|
}
|
|
213
483
|
|
|
484
|
+
// Sub-agent: read-only research agent (opencode pattern: AgentTask)
|
|
485
|
+
// Spawns a separate claude process with --allowedTools limited to read-only
|
|
486
|
+
case 'claude-task-query': {
|
|
487
|
+
const { command: taskCmd, options: taskOpts } = data;
|
|
488
|
+
console.log(chalk.cyan(' [relay] Spawning sub-agent for research...'));
|
|
489
|
+
|
|
490
|
+
const taskArgs = ['-p', '--output-format', 'stream-json', '--include-partial-messages'];
|
|
491
|
+
// Sub-agents are read-only: use --allowedTools to restrict
|
|
492
|
+
taskArgs.push('--allowedTools', 'View,Glob,Grep,LS,Read');
|
|
493
|
+
if (taskOpts?.projectPath) taskArgs.push('--cwd', taskOpts.projectPath);
|
|
494
|
+
// Sub-agents always start fresh (no --continue)
|
|
495
|
+
|
|
496
|
+
const taskProc = spawn('claude', [...taskArgs, taskCmd || ''], {
|
|
497
|
+
shell: true,
|
|
498
|
+
cwd: taskOpts?.projectPath || os.homedir(),
|
|
499
|
+
env: process.env,
|
|
500
|
+
});
|
|
501
|
+
|
|
502
|
+
activeProcesses.set(requestId, { proc: taskProc, action: 'claude-task-query' });
|
|
503
|
+
let taskBuffer = '';
|
|
504
|
+
|
|
505
|
+
taskProc.stdout.on('data', (chunk) => {
|
|
506
|
+
taskBuffer += chunk.toString();
|
|
507
|
+
const lines = taskBuffer.split('\n');
|
|
508
|
+
taskBuffer = lines.pop();
|
|
509
|
+
for (const line of lines) {
|
|
510
|
+
if (!line.trim()) continue;
|
|
511
|
+
try {
|
|
512
|
+
const evt = JSON.parse(line);
|
|
513
|
+
if (evt.type === 'assistant' && evt.message?.content?.length) {
|
|
514
|
+
ws.send(JSON.stringify({
|
|
515
|
+
type: 'relay-stream', requestId,
|
|
516
|
+
data: { type: 'claude-response', content: evt.message.content[0].text || '' },
|
|
517
|
+
}));
|
|
518
|
+
}
|
|
519
|
+
} catch {
|
|
520
|
+
if (line.trim()) {
|
|
521
|
+
ws.send(JSON.stringify({
|
|
522
|
+
type: 'relay-stream', requestId,
|
|
523
|
+
data: { type: 'claude-response', content: line },
|
|
524
|
+
}));
|
|
525
|
+
}
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
taskProc.stderr.on('data', (chunk) => {
|
|
531
|
+
ws.send(JSON.stringify({
|
|
532
|
+
type: 'relay-stream', requestId,
|
|
533
|
+
data: { type: 'claude-error', content: chunk.toString() },
|
|
534
|
+
}));
|
|
535
|
+
});
|
|
536
|
+
|
|
537
|
+
taskProc.on('close', (code) => {
|
|
538
|
+
activeProcesses.delete(requestId);
|
|
539
|
+
ws.send(JSON.stringify({ type: 'relay-complete', requestId, exitCode: code }));
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
taskProc.on('error', () => { activeProcesses.delete(requestId); });
|
|
543
|
+
break;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
case 'detect-agents': {
|
|
547
|
+
// Detect installed AI CLI agents on user's machine
|
|
548
|
+
const agents = {};
|
|
549
|
+
const checkCmd = process.platform === 'win32' ? 'where' : 'which';
|
|
550
|
+
const agentBins = { claude: 'claude', cursor: 'cursor-agent', codex: 'codex' };
|
|
551
|
+
for (const [name, bin] of Object.entries(agentBins)) {
|
|
552
|
+
try {
|
|
553
|
+
const { execSync } = await import('child_process');
|
|
554
|
+
execSync(`${checkCmd} ${bin}`, { encoding: 'utf8', timeout: 5000, stdio: 'pipe' });
|
|
555
|
+
agents[name] = true;
|
|
556
|
+
} catch {
|
|
557
|
+
agents[name] = false;
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { agents } }));
|
|
561
|
+
break;
|
|
562
|
+
}
|
|
563
|
+
|
|
214
564
|
default:
|
|
215
565
|
ws.send(JSON.stringify({
|
|
216
566
|
type: 'relay-response',
|
|
@@ -286,6 +636,19 @@ export async function connect(options = {}) {
|
|
|
286
636
|
console.log(chalk.green(' Connected! Your local machine is now bridged to the server.'));
|
|
287
637
|
console.log(chalk.dim(' Claude Code is the AI brain. Press Ctrl+C to disconnect.\n'));
|
|
288
638
|
|
|
639
|
+
// Send initial working directory so it becomes the default project
|
|
640
|
+
const cwd = process.cwd();
|
|
641
|
+
const dirName = path.basename(cwd);
|
|
642
|
+
ws.send(JSON.stringify({
|
|
643
|
+
type: 'relay-init',
|
|
644
|
+
cwd,
|
|
645
|
+
dirName,
|
|
646
|
+
homedir: os.homedir(),
|
|
647
|
+
platform: process.platform,
|
|
648
|
+
hostname: os.hostname(),
|
|
649
|
+
}));
|
|
650
|
+
console.log(chalk.dim(` Default project: ${cwd}\n`));
|
|
651
|
+
|
|
289
652
|
const heartbeat = setInterval(() => {
|
|
290
653
|
if (ws.readyState === WebSocket.OPEN) {
|
|
291
654
|
ws.send(JSON.stringify({ type: 'ping' }));
|
|
@@ -307,6 +670,51 @@ export async function connect(options = {}) {
|
|
|
307
670
|
handleRelayCommand(data, ws);
|
|
308
671
|
return;
|
|
309
672
|
}
|
|
673
|
+
// Abort handler (opencode pattern: cancel via context propagation)
|
|
674
|
+
if (data.type === 'relay-abort') {
|
|
675
|
+
const entry = activeProcesses.get(data.requestId);
|
|
676
|
+
if (entry?.proc) {
|
|
677
|
+
entry.proc.kill('SIGTERM');
|
|
678
|
+
activeProcesses.delete(data.requestId);
|
|
679
|
+
ws.send(JSON.stringify({
|
|
680
|
+
type: 'relay-complete', requestId: data.requestId,
|
|
681
|
+
exitCode: -1, aborted: true,
|
|
682
|
+
}));
|
|
683
|
+
console.log(chalk.yellow(' [relay] Process aborted by user'));
|
|
684
|
+
}
|
|
685
|
+
return;
|
|
686
|
+
}
|
|
687
|
+
// Permission response from browser (opencode pattern: grant/deny flow)
|
|
688
|
+
if (data.type === 'relay-permission-response') {
|
|
689
|
+
handlePermissionResponse(data);
|
|
690
|
+
return;
|
|
691
|
+
}
|
|
692
|
+
// ── Relay Shell: interactive terminal on local machine ──────────
|
|
693
|
+
if (data.type === 'relay-command' && data.action === 'shell-session-start') {
|
|
694
|
+
handleShellSessionStart(data, ws);
|
|
695
|
+
return;
|
|
696
|
+
}
|
|
697
|
+
if (data.type === 'relay-shell-input') {
|
|
698
|
+
const session = activeShellSessions.get(data.shellSessionId);
|
|
699
|
+
if (session?.proc?.stdin?.writable) {
|
|
700
|
+
session.proc.stdin.write(data.data);
|
|
701
|
+
}
|
|
702
|
+
return;
|
|
703
|
+
}
|
|
704
|
+
if (data.type === 'relay-shell-resize') {
|
|
705
|
+
// PTY resize not available with basic spawn — ignored for non-pty
|
|
706
|
+
return;
|
|
707
|
+
}
|
|
708
|
+
if (data.type === 'relay-shell-kill') {
|
|
709
|
+
const session = activeShellSessions.get(data.shellSessionId);
|
|
710
|
+
if (session?.proc) {
|
|
711
|
+
session.proc.kill('SIGTERM');
|
|
712
|
+
activeShellSessions.delete(data.shellSessionId);
|
|
713
|
+
console.log(chalk.dim(' [relay] Shell session killed'));
|
|
714
|
+
}
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
|
|
310
718
|
if (data.type === 'pong') return;
|
|
311
719
|
if (data.type === 'error') {
|
|
312
720
|
console.error(chalk.red(` Server error: ${data.error}`));
|
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Permission-Gated Tool Execution
|
|
3
|
+
* Ported from opencode-ai/opencode internal/permission/permission.go
|
|
4
|
+
*
|
|
5
|
+
* Splits relay actions into safe (auto-approve) and dangerous (require browser approval).
|
|
6
|
+
* Permission requests are sent to the server → browser, and we wait for the response.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
// Safe actions — auto-approved, read-only (opencode pattern: tools without permission flag)
|
|
10
|
+
const SAFE_ACTIONS = new Set([
|
|
11
|
+
'file-read',
|
|
12
|
+
'file-tree',
|
|
13
|
+
'browse-dirs',
|
|
14
|
+
'validate-path',
|
|
15
|
+
'detect-agents',
|
|
16
|
+
]);
|
|
17
|
+
|
|
18
|
+
// Dangerous actions — require user approval (opencode pattern: tools with permission flag)
|
|
19
|
+
const DANGEROUS_ACTIONS = new Set([
|
|
20
|
+
'shell-command',
|
|
21
|
+
'file-write',
|
|
22
|
+
'create-folder',
|
|
23
|
+
'git-operation',
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
// Safe shell command prefixes — auto-approved even within shell-command
|
|
27
|
+
// (opencode pattern: safe bash commands bypass permission)
|
|
28
|
+
const SAFE_SHELL_PREFIXES = [
|
|
29
|
+
'ls', 'dir', 'pwd', 'echo', 'cat', 'head', 'tail', 'wc',
|
|
30
|
+
'git status', 'git log', 'git diff', 'git branch', 'git remote',
|
|
31
|
+
'node --version', 'npm --version', 'python --version',
|
|
32
|
+
'which', 'where', 'type', 'whoami', 'hostname',
|
|
33
|
+
'date', 'uptime', 'df', 'free',
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// Pending permission requests: requestId → { resolve }
|
|
37
|
+
const pendingPermissions = new Map();
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Check if an action needs user permission.
|
|
41
|
+
* @param {string} action - Relay action type
|
|
42
|
+
* @param {object} payload - Action payload
|
|
43
|
+
* @returns {boolean}
|
|
44
|
+
*/
|
|
45
|
+
export function needsPermission(action, payload) {
|
|
46
|
+
if (SAFE_ACTIONS.has(action)) return false;
|
|
47
|
+
if (!DANGEROUS_ACTIONS.has(action)) return false; // unknown actions handled elsewhere
|
|
48
|
+
|
|
49
|
+
// Shell commands: check if it's a safe read-only command
|
|
50
|
+
if (action === 'shell-command' && payload?.command) {
|
|
51
|
+
const cmd = payload.command.trim().toLowerCase();
|
|
52
|
+
if (SAFE_SHELL_PREFIXES.some(prefix => cmd.startsWith(prefix))) {
|
|
53
|
+
return false;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
return true;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Request permission from the browser via the relay WebSocket.
|
|
62
|
+
* Blocks until the user approves or denies.
|
|
63
|
+
*
|
|
64
|
+
* @param {WebSocket} ws - Relay WebSocket connection
|
|
65
|
+
* @param {string} requestId - Relay request ID
|
|
66
|
+
* @param {string} action - Action being requested
|
|
67
|
+
* @param {object} payload - Action payload
|
|
68
|
+
* @param {number} timeoutMs - Timeout for waiting (default 60s)
|
|
69
|
+
* @returns {Promise<boolean>} - true if approved, false if denied
|
|
70
|
+
*/
|
|
71
|
+
export function requestPermission(ws, requestId, action, payload, timeoutMs = 60000) {
|
|
72
|
+
return new Promise((resolve) => {
|
|
73
|
+
const permId = `perm-${requestId}`;
|
|
74
|
+
|
|
75
|
+
const timeout = setTimeout(() => {
|
|
76
|
+
pendingPermissions.delete(permId);
|
|
77
|
+
resolve(false); // Timeout = deny
|
|
78
|
+
}, timeoutMs);
|
|
79
|
+
|
|
80
|
+
pendingPermissions.set(permId, {
|
|
81
|
+
resolve: (approved) => {
|
|
82
|
+
clearTimeout(timeout);
|
|
83
|
+
pendingPermissions.delete(permId);
|
|
84
|
+
resolve(approved);
|
|
85
|
+
},
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// Send permission request to server → browser
|
|
89
|
+
ws.send(JSON.stringify({
|
|
90
|
+
type: 'relay-permission-request',
|
|
91
|
+
requestId,
|
|
92
|
+
permissionId: permId,
|
|
93
|
+
action,
|
|
94
|
+
description: describeAction(action, payload),
|
|
95
|
+
payload: sanitizePayload(action, payload),
|
|
96
|
+
}));
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Handle a permission response from the server.
|
|
102
|
+
* @param {object} data - { permissionId, approved }
|
|
103
|
+
*/
|
|
104
|
+
export function handlePermissionResponse(data) {
|
|
105
|
+
const pending = pendingPermissions.get(data.permissionId);
|
|
106
|
+
if (pending) {
|
|
107
|
+
pending.resolve(Boolean(data.approved));
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* Generate a human-readable description of the action.
|
|
113
|
+
*/
|
|
114
|
+
function describeAction(action, payload) {
|
|
115
|
+
switch (action) {
|
|
116
|
+
case 'shell-command':
|
|
117
|
+
return `Execute command: ${payload?.command || '(unknown)'}`;
|
|
118
|
+
case 'file-write':
|
|
119
|
+
return `Write to file: ${payload?.filePath || '(unknown)'}`;
|
|
120
|
+
case 'create-folder':
|
|
121
|
+
return `Create folder: ${payload?.folderPath || '(unknown)'}`;
|
|
122
|
+
case 'git-operation':
|
|
123
|
+
return `Run git: ${payload?.gitCommand || '(unknown)'}`;
|
|
124
|
+
default:
|
|
125
|
+
return `${action}: ${JSON.stringify(payload || {}).slice(0, 200)}`;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Sanitize payload for display — remove large content fields.
|
|
131
|
+
*/
|
|
132
|
+
function sanitizePayload(action, payload) {
|
|
133
|
+
if (!payload) return {};
|
|
134
|
+
const safe = { ...payload };
|
|
135
|
+
// Don't send file content to browser for permission display
|
|
136
|
+
if (safe.content && safe.content.length > 500) {
|
|
137
|
+
safe.content = safe.content.slice(0, 500) + `... (${safe.content.length} chars total)`;
|
|
138
|
+
}
|
|
139
|
+
return safe;
|
|
140
|
+
}
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent Shell Singleton
|
|
3
|
+
* Ported from opencode-ai/opencode internal/llm/tools/shell/shell.go
|
|
4
|
+
*
|
|
5
|
+
* Maintains a single long-running shell process across commands.
|
|
6
|
+
* - Commands are queued and executed sequentially
|
|
7
|
+
* - Environment and cwd persist between commands
|
|
8
|
+
* - Child processes can be killed on abort
|
|
9
|
+
* - Shell respawns automatically if it dies
|
|
10
|
+
*/
|
|
11
|
+
import { spawn, execSync } from 'child_process';
|
|
12
|
+
import os from 'os';
|
|
13
|
+
import path from 'path';
|
|
14
|
+
import { promises as fs } from 'fs';
|
|
15
|
+
|
|
16
|
+
let shellInstance = null;
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Get or create the persistent shell singleton.
|
|
20
|
+
* @param {string} workingDir - Initial working directory
|
|
21
|
+
* @returns {PersistentShell}
|
|
22
|
+
*/
|
|
23
|
+
export function getPersistentShell(workingDir) {
|
|
24
|
+
if (!shellInstance || !shellInstance.isAlive) {
|
|
25
|
+
shellInstance = new PersistentShell(workingDir || os.homedir());
|
|
26
|
+
}
|
|
27
|
+
return shellInstance;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
class PersistentShell {
|
|
31
|
+
constructor(cwd) {
|
|
32
|
+
this.cwd = cwd;
|
|
33
|
+
this.isAlive = false;
|
|
34
|
+
this.commandQueue = [];
|
|
35
|
+
this.processing = false;
|
|
36
|
+
this.proc = null;
|
|
37
|
+
this.stdin = null;
|
|
38
|
+
this._start();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
_start() {
|
|
42
|
+
const isWin = process.platform === 'win32';
|
|
43
|
+
const shellPath = isWin
|
|
44
|
+
? process.env.COMSPEC || 'cmd.exe'
|
|
45
|
+
: process.env.SHELL || '/bin/bash';
|
|
46
|
+
const shellArgs = isWin ? ['/Q'] : ['-l'];
|
|
47
|
+
|
|
48
|
+
this.proc = spawn(shellPath, shellArgs, {
|
|
49
|
+
cwd: this.cwd,
|
|
50
|
+
env: { ...process.env, GIT_EDITOR: 'true' },
|
|
51
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
52
|
+
shell: false,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
this.stdin = this.proc.stdin;
|
|
56
|
+
this.isAlive = true;
|
|
57
|
+
|
|
58
|
+
this.proc.on('close', () => {
|
|
59
|
+
this.isAlive = false;
|
|
60
|
+
// Reject any queued commands
|
|
61
|
+
for (const queued of this.commandQueue) {
|
|
62
|
+
queued.reject(new Error('Shell process died'));
|
|
63
|
+
}
|
|
64
|
+
this.commandQueue = [];
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
this.proc.on('error', () => {
|
|
68
|
+
this.isAlive = false;
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Execute a command in the persistent shell.
|
|
74
|
+
* Queued and executed sequentially (opencode pattern: commandQueue channel).
|
|
75
|
+
*
|
|
76
|
+
* @param {string} command - Shell command to execute
|
|
77
|
+
* @param {object} opts - { timeoutMs, abortSignal }
|
|
78
|
+
* @returns {Promise<{ stdout, stderr, exitCode, interrupted, cwd }>}
|
|
79
|
+
*/
|
|
80
|
+
exec(command, opts = {}) {
|
|
81
|
+
return new Promise((resolve, reject) => {
|
|
82
|
+
this.commandQueue.push({ command, opts, resolve, reject });
|
|
83
|
+
this._processNext();
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async _processNext() {
|
|
88
|
+
if (this.processing || this.commandQueue.length === 0) return;
|
|
89
|
+
this.processing = true;
|
|
90
|
+
|
|
91
|
+
const { command, opts, resolve, reject } = this.commandQueue.shift();
|
|
92
|
+
|
|
93
|
+
try {
|
|
94
|
+
const result = await this._execCommand(command, opts);
|
|
95
|
+
resolve(result);
|
|
96
|
+
} catch (err) {
|
|
97
|
+
reject(err);
|
|
98
|
+
} finally {
|
|
99
|
+
this.processing = false;
|
|
100
|
+
// Process next in queue
|
|
101
|
+
if (this.commandQueue.length > 0) {
|
|
102
|
+
this._processNext();
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
async _execCommand(command, { timeoutMs = 60000, abortSignal } = {}) {
|
|
108
|
+
if (!this.isAlive) {
|
|
109
|
+
throw new Error('Shell is not alive');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const isWin = process.platform === 'win32';
|
|
113
|
+
const tmpDir = os.tmpdir();
|
|
114
|
+
const ts = Date.now() + '-' + Math.random().toString(36).slice(2, 8);
|
|
115
|
+
const stdoutFile = path.join(tmpDir, `uc-stdout-${ts}`);
|
|
116
|
+
const stderrFile = path.join(tmpDir, `uc-stderr-${ts}`);
|
|
117
|
+
const statusFile = path.join(tmpDir, `uc-status-${ts}`);
|
|
118
|
+
const cwdFile = path.join(tmpDir, `uc-cwd-${ts}`);
|
|
119
|
+
|
|
120
|
+
try {
|
|
121
|
+
// Build the shell command that redirects output to temp files
|
|
122
|
+
// Exact same pattern as opencode's shell.go execCommand
|
|
123
|
+
let fullCommand;
|
|
124
|
+
if (isWin) {
|
|
125
|
+
// Windows cmd.exe variant
|
|
126
|
+
fullCommand = [
|
|
127
|
+
`${command} > "${stdoutFile}" 2> "${stderrFile}"`,
|
|
128
|
+
`echo %ERRORLEVEL% > "${statusFile}"`,
|
|
129
|
+
`cd > "${cwdFile}"`,
|
|
130
|
+
].join(' & ');
|
|
131
|
+
} else {
|
|
132
|
+
// Unix bash variant (identical to opencode)
|
|
133
|
+
const escaped = command.replace(/'/g, "'\\''");
|
|
134
|
+
fullCommand = [
|
|
135
|
+
`eval '${escaped}' < /dev/null > '${stdoutFile}' 2> '${stderrFile}'`,
|
|
136
|
+
`EXEC_EXIT_CODE=$?`,
|
|
137
|
+
`pwd > '${cwdFile}'`,
|
|
138
|
+
`echo $EXEC_EXIT_CODE > '${statusFile}'`,
|
|
139
|
+
].join('\n');
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
this.stdin.write(fullCommand + '\n');
|
|
143
|
+
|
|
144
|
+
// Poll for status file (same polling pattern as opencode)
|
|
145
|
+
let interrupted = false;
|
|
146
|
+
const startTime = Date.now();
|
|
147
|
+
|
|
148
|
+
await new Promise((done, fail) => {
|
|
149
|
+
const poll = setInterval(async () => {
|
|
150
|
+
// Check abort
|
|
151
|
+
if (abortSignal?.aborted) {
|
|
152
|
+
this.killChildren();
|
|
153
|
+
interrupted = true;
|
|
154
|
+
clearInterval(poll);
|
|
155
|
+
done();
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// Check timeout
|
|
160
|
+
if (timeoutMs > 0 && Date.now() - startTime > timeoutMs) {
|
|
161
|
+
this.killChildren();
|
|
162
|
+
interrupted = true;
|
|
163
|
+
clearInterval(poll);
|
|
164
|
+
done();
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
// Check if status file exists and has content
|
|
169
|
+
try {
|
|
170
|
+
const stat = await fs.stat(statusFile);
|
|
171
|
+
if (stat.size > 0) {
|
|
172
|
+
clearInterval(poll);
|
|
173
|
+
done();
|
|
174
|
+
}
|
|
175
|
+
} catch {
|
|
176
|
+
// File doesn't exist yet
|
|
177
|
+
}
|
|
178
|
+
}, 50);
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
// Read results from temp files
|
|
182
|
+
const stdout = await this._readFileOrEmpty(stdoutFile);
|
|
183
|
+
const stderr = await this._readFileOrEmpty(stderrFile);
|
|
184
|
+
const exitCodeStr = await this._readFileOrEmpty(statusFile);
|
|
185
|
+
const newCwd = await this._readFileOrEmpty(cwdFile);
|
|
186
|
+
|
|
187
|
+
let exitCode = 0;
|
|
188
|
+
if (exitCodeStr.trim()) {
|
|
189
|
+
exitCode = parseInt(exitCodeStr.trim(), 10) || 0;
|
|
190
|
+
} else if (interrupted) {
|
|
191
|
+
exitCode = 143;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
if (newCwd.trim()) {
|
|
195
|
+
this.cwd = newCwd.trim();
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
return {
|
|
199
|
+
stdout: stdout,
|
|
200
|
+
stderr: interrupted ? stderr + '\nCommand execution timed out or was interrupted' : stderr,
|
|
201
|
+
exitCode,
|
|
202
|
+
interrupted,
|
|
203
|
+
cwd: this.cwd,
|
|
204
|
+
};
|
|
205
|
+
} finally {
|
|
206
|
+
// Cleanup temp files
|
|
207
|
+
await Promise.all([
|
|
208
|
+
fs.unlink(stdoutFile).catch(() => {}),
|
|
209
|
+
fs.unlink(stderrFile).catch(() => {}),
|
|
210
|
+
fs.unlink(statusFile).catch(() => {}),
|
|
211
|
+
fs.unlink(cwdFile).catch(() => {}),
|
|
212
|
+
]);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Kill child processes of the shell (opencode pattern: killChildren).
|
|
218
|
+
* On Unix: pgrep -P <pid> → SIGTERM each child.
|
|
219
|
+
* On Windows: taskkill /PID /T.
|
|
220
|
+
*/
|
|
221
|
+
killChildren() {
|
|
222
|
+
if (!this.proc?.pid) return;
|
|
223
|
+
try {
|
|
224
|
+
if (process.platform === 'win32') {
|
|
225
|
+
execSync(`taskkill /PID ${this.proc.pid} /T /F`, { stdio: 'ignore', timeout: 5000 });
|
|
226
|
+
// Respawn since taskkill kills the parent too on Windows
|
|
227
|
+
this._start();
|
|
228
|
+
} else {
|
|
229
|
+
const output = execSync(`pgrep -P ${this.proc.pid}`, { encoding: 'utf8', timeout: 5000 });
|
|
230
|
+
for (const line of output.split('\n')) {
|
|
231
|
+
const pid = parseInt(line.trim(), 10);
|
|
232
|
+
if (pid > 0) {
|
|
233
|
+
try { process.kill(pid, 'SIGTERM'); } catch { /* already dead */ }
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
} catch {
|
|
238
|
+
// pgrep/taskkill failed — no children to kill
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Close the persistent shell.
|
|
244
|
+
*/
|
|
245
|
+
close() {
|
|
246
|
+
if (!this.isAlive) return;
|
|
247
|
+
try {
|
|
248
|
+
this.stdin.write('exit\n');
|
|
249
|
+
this.proc.kill('SIGTERM');
|
|
250
|
+
} catch { /* ignore */ }
|
|
251
|
+
this.isAlive = false;
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
async _readFileOrEmpty(filePath) {
|
|
255
|
+
try {
|
|
256
|
+
return await fs.readFile(filePath, 'utf8');
|
|
257
|
+
} catch {
|
|
258
|
+
return '';
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|