upfynai-code 2.6.7 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "upfynai-code",
3
- "version": "2.6.7",
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
 
@@ -175,12 +372,26 @@ async function handleRelayCommand(data, ws) {
175
372
  else if (targetDir.startsWith('~/') || targetDir.startsWith('~\\')) targetDir = path.join(os.default.homedir(), targetDir.slice(2));
176
373
  targetDir = path.resolve(targetDir);
177
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
+
178
389
  const entries = await fsPromises.readdir(targetDir, { withFileTypes: true });
179
- const dirs = entries
390
+ dirs = entries
180
391
  .filter(e => e.isDirectory() && !e.name.startsWith('.'))
181
392
  .map(e => ({ name: e.name, path: path.join(targetDir, e.name), type: 'directory' }))
182
393
  .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() } }));
394
+ ws.send(JSON.stringify({ type: 'relay-response', requestId, data: { path: targetDir, suggestions: dirs, drives, homedir: os.default.homedir() } }));
184
395
  break;
185
396
  }
186
397
 
@@ -202,6 +413,20 @@ async function handleRelayCommand(data, ws) {
202
413
  break;
203
414
  }
204
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
+
205
430
  case 'git-operation': {
206
431
  const { gitCommand, cwd: gitCwd } = data;
207
432
  console.log(chalk.dim(' [relay] Running git operation...'));
@@ -211,6 +436,68 @@ async function handleRelayCommand(data, ws) {
211
436
  break;
212
437
  }
213
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
+
214
501
  default:
215
502
  ws.send(JSON.stringify({
216
503
  type: 'relay-response',
@@ -286,6 +573,19 @@ export async function connect(options = {}) {
286
573
  console.log(chalk.green(' Connected! Your local machine is now bridged to the server.'));
287
574
  console.log(chalk.dim(' Claude Code is the AI brain. Press Ctrl+C to disconnect.\n'));
288
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
+
289
589
  const heartbeat = setInterval(() => {
290
590
  if (ws.readyState === WebSocket.OPEN) {
291
591
  ws.send(JSON.stringify({ type: 'ping' }));
@@ -307,6 +607,51 @@ export async function connect(options = {}) {
307
607
  handleRelayCommand(data, ws);
308
608
  return;
309
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
+
310
655
  if (data.type === 'pong') return;
311
656
  if (data.type === 'error') {
312
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
+ }