upfynai-code 2.6.6 → 2.7.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 CHANGED
@@ -13,7 +13,7 @@ const program = new Command();
13
13
  program
14
14
  .name('upfynai-code')
15
15
  .description('Launch Upfyn AI coding environment from your terminal')
16
- .version('2.6.6')
16
+ .version('2.6.7')
17
17
  .option('--local', 'Start a local server instead of opening the hosted app')
18
18
  .action(async (options) => {
19
19
  if (options.local) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "upfynai-code",
3
- "version": "2.6.6",
3
+ "version": "2.7.0",
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,139 @@ 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
+
27
+ console.log(chalk.cyan(` [relay] Starting shell session in ${projectPath}`));
28
+
29
+ // Build the shell command
30
+ let shellCmd, shellArgs;
31
+ const provider = data.provider || 'claude';
32
+ const isPlainShell = data.isPlainShell || (!!data.initialCommand && !data.hasSession) || provider === 'plain-shell';
33
+
34
+ if (isPlainShell && data.initialCommand) {
35
+ // Run a specific command
36
+ if (isWin) {
37
+ shellCmd = 'powershell.exe';
38
+ shellArgs = ['-Command', `Set-Location -Path "${projectPath}"; ${data.initialCommand}`];
39
+ } else {
40
+ shellCmd = 'bash';
41
+ shellArgs = ['-c', `cd "${projectPath}" && ${data.initialCommand}`];
42
+ }
43
+ } else if (isPlainShell) {
44
+ // Interactive shell
45
+ if (isWin) {
46
+ shellCmd = 'powershell.exe';
47
+ shellArgs = ['-NoExit', '-Command', `Set-Location -Path "${projectPath}"`];
48
+ } else {
49
+ shellCmd = process.env.SHELL || 'bash';
50
+ shellArgs = ['--login'];
51
+ }
52
+ } else if (provider === 'cursor') {
53
+ if (isWin) {
54
+ shellCmd = 'powershell.exe';
55
+ const cursorCmd = data.hasSession && data.sessionId
56
+ ? `cursor-agent --resume="${data.sessionId}"`
57
+ : 'cursor-agent';
58
+ shellArgs = ['-Command', `Set-Location -Path "${projectPath}"; ${cursorCmd}`];
59
+ } else {
60
+ shellCmd = 'bash';
61
+ const cursorCmd = data.hasSession && data.sessionId
62
+ ? `cursor-agent --resume="${data.sessionId}"`
63
+ : 'cursor-agent';
64
+ shellArgs = ['-c', `cd "${projectPath}" && ${cursorCmd}`];
65
+ }
66
+ } else {
67
+ // Claude (default)
68
+ const command = data.initialCommand || 'claude';
69
+ if (isWin) {
70
+ shellCmd = 'powershell.exe';
71
+ const claudeCmd = data.hasSession && data.sessionId
72
+ ? `claude --resume ${data.sessionId}`
73
+ : command;
74
+ shellArgs = ['-Command', `Set-Location -Path "${projectPath}"; ${claudeCmd}`];
75
+ } else {
76
+ shellCmd = 'bash';
77
+ const claudeCmd = data.hasSession && data.sessionId
78
+ ? `claude --resume ${data.sessionId} || claude`
79
+ : command;
80
+ shellArgs = ['-c', `cd "${projectPath}" && ${claudeCmd}`];
81
+ }
82
+ }
83
+
84
+ const proc = spawn(shellCmd, shellArgs, {
85
+ cwd: isPlainShell && !data.initialCommand ? projectPath : undefined,
86
+ env: {
87
+ ...process.env,
88
+ TERM: 'xterm-256color',
89
+ COLORTERM: 'truecolor',
90
+ FORCE_COLOR: '3',
91
+ },
92
+ stdio: ['pipe', 'pipe', 'pipe'],
93
+ });
94
+
95
+ activeShellSessions.set(shellSessionId, { proc, projectPath });
96
+
97
+ // Stream stdout → browser via relay
98
+ proc.stdout.on('data', (chunk) => {
99
+ if (ws.readyState === WebSocket.OPEN) {
100
+ ws.send(JSON.stringify({
101
+ type: 'relay-shell-output',
102
+ shellSessionId,
103
+ data: chunk.toString(),
104
+ }));
105
+ }
106
+ });
107
+
108
+ // Stream stderr → browser via relay
109
+ proc.stderr.on('data', (chunk) => {
110
+ if (ws.readyState === WebSocket.OPEN) {
111
+ ws.send(JSON.stringify({
112
+ type: 'relay-shell-output',
113
+ shellSessionId,
114
+ data: chunk.toString(),
115
+ }));
116
+ }
117
+ });
118
+
119
+ proc.on('close', (code) => {
120
+ activeShellSessions.delete(shellSessionId);
121
+ if (ws.readyState === WebSocket.OPEN) {
122
+ ws.send(JSON.stringify({
123
+ type: 'relay-shell-exited',
124
+ shellSessionId,
125
+ exitCode: code,
126
+ }));
127
+ }
128
+ console.log(chalk.dim(` [relay] Shell session ended (code ${code})`));
129
+ });
130
+
131
+ proc.on('error', (err) => {
132
+ activeShellSessions.delete(shellSessionId);
133
+ if (ws.readyState === WebSocket.OPEN) {
134
+ ws.send(JSON.stringify({
135
+ type: 'relay-shell-output',
136
+ shellSessionId,
137
+ data: `\r\n\x1b[31mError: ${err.message}\x1b[0m\r\n`,
138
+ }));
139
+ }
140
+ });
141
+ }
9
142
 
10
143
  /**
11
144
  * Execute a shell command and return stdout
@@ -71,12 +204,25 @@ async function handleRelayCommand(data, ws) {
71
204
  const { requestId, action } = data;
72
205
 
73
206
  try {
207
+ // Permission gate (opencode pattern: dangerous actions require browser approval)
208
+ if (needsPermission(action, data)) {
209
+ const approved = await requestPermission(ws, requestId, action, data);
210
+ if (!approved) {
211
+ ws.send(JSON.stringify({
212
+ type: 'relay-response', requestId,
213
+ error: 'Permission denied by user',
214
+ }));
215
+ return;
216
+ }
217
+ }
218
+
74
219
  switch (action) {
75
220
  case 'claude-query': {
76
221
  const { command, options } = data;
77
222
  console.log(chalk.cyan(' [relay] Processing Claude query...'));
78
223
 
79
- const args = ['--print'];
224
+ // Stream-JSON mode for real-time token streaming (opencode pattern)
225
+ const args = ['-p', '--output-format', 'stream-json', '--include-partial-messages'];
80
226
  if (options?.projectPath) args.push('--cwd', options.projectPath);
81
227
  if (options?.sessionId) args.push('--continue', options.sessionId);
82
228
 
@@ -86,29 +232,75 @@ async function handleRelayCommand(data, ws) {
86
232
  env: process.env,
87
233
  });
88
234
 
235
+ // Track for abort support (opencode pattern: activeRequests)
236
+ activeProcesses.set(requestId, { proc, action: 'claude-query' });
237
+
238
+ let stdoutBuffer = '';
239
+ let capturedSessionId = null;
240
+
241
+ // Parse NDJSON line-by-line (same pattern as cursor-cli.js)
89
242
  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
- }));
243
+ stdoutBuffer += chunk.toString();
244
+ const lines = stdoutBuffer.split('\n');
245
+ stdoutBuffer = lines.pop(); // keep incomplete last line in buffer
246
+
247
+ for (const line of lines) {
248
+ if (!line.trim()) continue;
249
+ try {
250
+ const evt = JSON.parse(line);
251
+
252
+ if (evt.type === 'system' && evt.subtype === 'init') {
253
+ // Capture session ID for resume support
254
+ if (evt.session_id) capturedSessionId = evt.session_id;
255
+ ws.send(JSON.stringify({
256
+ type: 'relay-stream', requestId,
257
+ data: { type: 'claude-system', sessionId: evt.session_id, model: evt.model, cwd: evt.cwd },
258
+ }));
259
+ } else if (evt.type === 'assistant' && evt.message?.content?.length) {
260
+ // Real-time text delta
261
+ const text = evt.message.content[0].text || '';
262
+ ws.send(JSON.stringify({
263
+ type: 'relay-stream', requestId,
264
+ data: { type: 'claude-response', content: text },
265
+ }));
266
+ } else if (evt.type === 'result') {
267
+ // Session complete — include captured session ID
268
+ ws.send(JSON.stringify({
269
+ type: 'relay-stream', requestId,
270
+ data: { type: 'claude-result', sessionId: capturedSessionId, subtype: evt.subtype },
271
+ }));
272
+ }
273
+ } catch {
274
+ // Non-JSON line — send as raw text
275
+ if (line.trim()) {
276
+ ws.send(JSON.stringify({
277
+ type: 'relay-stream', requestId,
278
+ data: { type: 'claude-response', content: line },
279
+ }));
280
+ }
281
+ }
282
+ }
95
283
  });
96
284
 
97
285
  proc.stderr.on('data', (chunk) => {
98
286
  ws.send(JSON.stringify({
99
- type: 'relay-stream',
100
- requestId,
287
+ type: 'relay-stream', requestId,
101
288
  data: { type: 'claude-error', content: chunk.toString() },
102
289
  }));
103
290
  });
104
291
 
105
292
  proc.on('close', (code) => {
293
+ activeProcesses.delete(requestId);
106
294
  ws.send(JSON.stringify({
107
- type: 'relay-complete',
108
- requestId,
295
+ type: 'relay-complete', requestId,
109
296
  exitCode: code,
297
+ sessionId: capturedSessionId,
110
298
  }));
111
299
  });
300
+
301
+ proc.on('error', () => {
302
+ activeProcesses.delete(requestId);
303
+ });
112
304
  break;
113
305
  }
114
306
 
@@ -123,8 +315,13 @@ async function handleRelayCommand(data, ws) {
123
315
  ];
124
316
  if (dangerous.some(d => cmdLower.includes(d.toLowerCase()))) throw new Error('Command blocked for safety');
125
317
  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 } }));
318
+ // Persistent shell singleton (opencode pattern: commands share one shell process)
319
+ const shell = getPersistentShell(cwd || process.cwd());
320
+ const result = await shell.exec(cmd, { timeoutMs: 60000 });
321
+ ws.send(JSON.stringify({
322
+ type: 'relay-response', requestId,
323
+ data: { stdout: result.stdout, stderr: result.stderr, exitCode: result.exitCode, cwd: result.cwd },
324
+ }));
128
325
  break;
129
326
  }
130
327
 
@@ -166,6 +363,70 @@ async function handleRelayCommand(data, ws) {
166
363
  break;
167
364
  }
168
365
 
366
+ case 'browse-dirs': {
367
+ // Browse directories on user's local machine
368
+ const { dirPath: browsePath } = data;
369
+ const os = await import('os');
370
+ let targetDir = browsePath || os.default.homedir();
371
+ if (targetDir === '~') targetDir = os.default.homedir();
372
+ else if (targetDir.startsWith('~/') || targetDir.startsWith('~\\')) targetDir = path.join(os.default.homedir(), targetDir.slice(2));
373
+ targetDir = path.resolve(targetDir);
374
+
375
+ let dirs = [];
376
+ // On Windows, detect available drives to show alongside directory listing
377
+ let drives = [];
378
+ if (process.platform === 'win32') {
379
+ try {
380
+ const { execSync } = await import('child_process');
381
+ const wmicOut = execSync('wmic logicaldisk get name', { encoding: 'utf8', timeout: 5000 });
382
+ drives = wmicOut.split('\n')
383
+ .map(l => l.trim())
384
+ .filter(l => /^[A-Z]:$/.test(l))
385
+ .map(d => ({ name: d + '\\', path: d + '\\', type: 'drive' }));
386
+ } catch { /* ignore — wmic not available */ }
387
+ }
388
+
389
+ const entries = await fsPromises.readdir(targetDir, { withFileTypes: true });
390
+ dirs = entries
391
+ .filter(e => e.isDirectory() && !e.name.startsWith('.'))
392
+ .map(e => ({ name: e.name, path: path.join(targetDir, e.name), type: 'directory' }))
393
+ .sort((a, b) => a.name.localeCompare(b.name));
394
+ ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { path: targetDir, suggestions: dirs, drives, homedir: os.default.homedir() } }));
395
+ break;
396
+ }
397
+
398
+ case 'validate-path': {
399
+ // Check if a path exists on user's machine
400
+ const { targetPath } = data;
401
+ const os2 = await import('os');
402
+ let checkPath = targetPath || '';
403
+ if (checkPath === '~') checkPath = os2.default.homedir();
404
+ else if (checkPath.startsWith('~/') || checkPath.startsWith('~\\')) checkPath = path.join(os2.default.homedir(), checkPath.slice(2));
405
+ checkPath = path.resolve(checkPath);
406
+
407
+ try {
408
+ const stats = await fsPromises.stat(checkPath);
409
+ ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { exists: true, isDirectory: stats.isDirectory(), resolvedPath: checkPath } }));
410
+ } catch {
411
+ ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { exists: false, resolvedPath: checkPath } }));
412
+ }
413
+ break;
414
+ }
415
+
416
+ case 'create-folder': {
417
+ // Create a directory on user's machine
418
+ const { folderPath } = data;
419
+ const os3 = await import('os');
420
+ let mkPath = folderPath || '';
421
+ if (mkPath === '~') mkPath = os3.default.homedir();
422
+ else if (mkPath.startsWith('~/') || mkPath.startsWith('~\\')) mkPath = path.join(os3.default.homedir(), mkPath.slice(2));
423
+ mkPath = path.resolve(mkPath);
424
+
425
+ await fsPromises.mkdir(mkPath, { recursive: true });
426
+ ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { success: true, path: mkPath } }));
427
+ break;
428
+ }
429
+
169
430
  case 'git-operation': {
170
431
  const { gitCommand, cwd: gitCwd } = data;
171
432
  console.log(chalk.dim(' [relay] Running git operation...'));
@@ -175,6 +436,68 @@ async function handleRelayCommand(data, ws) {
175
436
  break;
176
437
  }
177
438
 
439
+ // Sub-agent: read-only research agent (opencode pattern: AgentTask)
440
+ // Spawns a separate claude process with --allowedTools limited to read-only
441
+ case 'claude-task-query': {
442
+ const { command: taskCmd, options: taskOpts } = data;
443
+ console.log(chalk.cyan(' [relay] Spawning sub-agent for research...'));
444
+
445
+ const taskArgs = ['-p', '--output-format', 'stream-json', '--include-partial-messages'];
446
+ // Sub-agents are read-only: use --allowedTools to restrict
447
+ taskArgs.push('--allowedTools', 'View,Glob,Grep,LS,Read');
448
+ if (taskOpts?.projectPath) taskArgs.push('--cwd', taskOpts.projectPath);
449
+ // Sub-agents always start fresh (no --continue)
450
+
451
+ const taskProc = spawn('claude', [...taskArgs, taskCmd || ''], {
452
+ shell: true,
453
+ cwd: taskOpts?.projectPath || os.homedir(),
454
+ env: process.env,
455
+ });
456
+
457
+ activeProcesses.set(requestId, { proc: taskProc, action: 'claude-task-query' });
458
+ let taskBuffer = '';
459
+
460
+ taskProc.stdout.on('data', (chunk) => {
461
+ taskBuffer += chunk.toString();
462
+ const lines = taskBuffer.split('\n');
463
+ taskBuffer = lines.pop();
464
+ for (const line of lines) {
465
+ if (!line.trim()) continue;
466
+ try {
467
+ const evt = JSON.parse(line);
468
+ if (evt.type === 'assistant' && evt.message?.content?.length) {
469
+ ws.send(JSON.stringify({
470
+ type: 'relay-stream', requestId,
471
+ data: { type: 'claude-response', content: evt.message.content[0].text || '' },
472
+ }));
473
+ }
474
+ } catch {
475
+ if (line.trim()) {
476
+ ws.send(JSON.stringify({
477
+ type: 'relay-stream', requestId,
478
+ data: { type: 'claude-response', content: line },
479
+ }));
480
+ }
481
+ }
482
+ }
483
+ });
484
+
485
+ taskProc.stderr.on('data', (chunk) => {
486
+ ws.send(JSON.stringify({
487
+ type: 'relay-stream', requestId,
488
+ data: { type: 'claude-error', content: chunk.toString() },
489
+ }));
490
+ });
491
+
492
+ taskProc.on('close', (code) => {
493
+ activeProcesses.delete(requestId);
494
+ ws.send(JSON.stringify({ type: 'relay-complete', requestId, exitCode: code }));
495
+ });
496
+
497
+ taskProc.on('error', () => { activeProcesses.delete(requestId); });
498
+ break;
499
+ }
500
+
178
501
  default:
179
502
  ws.send(JSON.stringify({
180
503
  type: 'relay-response',
@@ -250,6 +573,19 @@ export async function connect(options = {}) {
250
573
  console.log(chalk.green(' Connected! Your local machine is now bridged to the server.'));
251
574
  console.log(chalk.dim(' Claude Code is the AI brain. Press Ctrl+C to disconnect.\n'));
252
575
 
576
+ // Send initial working directory so it becomes the default project
577
+ const cwd = process.cwd();
578
+ const dirName = path.basename(cwd);
579
+ ws.send(JSON.stringify({
580
+ type: 'relay-init',
581
+ cwd,
582
+ dirName,
583
+ homedir: os.homedir(),
584
+ platform: process.platform,
585
+ hostname: os.hostname(),
586
+ }));
587
+ console.log(chalk.dim(` Default project: ${cwd}\n`));
588
+
253
589
  const heartbeat = setInterval(() => {
254
590
  if (ws.readyState === WebSocket.OPEN) {
255
591
  ws.send(JSON.stringify({ type: 'ping' }));
@@ -271,6 +607,51 @@ export async function connect(options = {}) {
271
607
  handleRelayCommand(data, ws);
272
608
  return;
273
609
  }
610
+ // Abort handler (opencode pattern: cancel via context propagation)
611
+ if (data.type === 'relay-abort') {
612
+ const entry = activeProcesses.get(data.requestId);
613
+ if (entry?.proc) {
614
+ entry.proc.kill('SIGTERM');
615
+ activeProcesses.delete(data.requestId);
616
+ ws.send(JSON.stringify({
617
+ type: 'relay-complete', requestId: data.requestId,
618
+ exitCode: -1, aborted: true,
619
+ }));
620
+ console.log(chalk.yellow(' [relay] Process aborted by user'));
621
+ }
622
+ return;
623
+ }
624
+ // Permission response from browser (opencode pattern: grant/deny flow)
625
+ if (data.type === 'relay-permission-response') {
626
+ handlePermissionResponse(data);
627
+ return;
628
+ }
629
+ // ── Relay Shell: interactive terminal on local machine ──────────
630
+ if (data.type === 'relay-command' && data.action === 'shell-session-start') {
631
+ handleShellSessionStart(data, ws);
632
+ return;
633
+ }
634
+ if (data.type === 'relay-shell-input') {
635
+ const session = activeShellSessions.get(data.shellSessionId);
636
+ if (session?.proc?.stdin?.writable) {
637
+ session.proc.stdin.write(data.data);
638
+ }
639
+ return;
640
+ }
641
+ if (data.type === 'relay-shell-resize') {
642
+ // PTY resize not available with basic spawn — ignored for non-pty
643
+ return;
644
+ }
645
+ if (data.type === 'relay-shell-kill') {
646
+ const session = activeShellSessions.get(data.shellSessionId);
647
+ if (session?.proc) {
648
+ session.proc.kill('SIGTERM');
649
+ activeShellSessions.delete(data.shellSessionId);
650
+ console.log(chalk.dim(' [relay] Shell session killed'));
651
+ }
652
+ return;
653
+ }
654
+
274
655
  if (data.type === 'pong') return;
275
656
  if (data.type === 'error') {
276
657
  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
+ }