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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "upfynai-code",
3
- "version": "2.6.7",
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, 100)) {
54
- if (entry.name.startsWith('.') || entry.name === 'node_modules') continue;
55
- const item = { name: entry.name, type: entry.isDirectory() ? 'directory' : 'file' };
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(path.join(dirPath, entry.name), maxDepth, currentDepth + 1);
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
- const args = ['--print'];
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
- ws.send(JSON.stringify({
91
- type: 'relay-stream',
92
- requestId,
93
- data: { type: 'claude-response', content: chunk.toString() },
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
- const result = await execCommand(cmd, [], { cwd: cwd || process.cwd(), timeout: 60000 });
127
- ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { stdout: result } }));
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
- const { filePath } = data;
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
- ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { content } }));
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
- const { filePath: fp, content: fileContent } = data;
145
- if (!fp || typeof fp !== 'string') throw new Error('Invalid file path');
146
- const normalizedFp = path.resolve(fp);
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 = 3 } = data;
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, depth);
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
- const dirs = entries
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
+ }