@siteboon/claude-code-ui 1.25.0 → 1.25.2

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.
@@ -1,84 +1,124 @@
1
1
  import { spawn } from 'child_process';
2
2
  import crossSpawn from 'cross-spawn';
3
- import { promises as fs } from 'fs';
4
- import path from 'path';
5
- import os from 'os';
6
3
 
7
4
  // Use cross-spawn on Windows for better command execution
8
5
  const spawnFunction = process.platform === 'win32' ? crossSpawn : spawn;
9
6
 
10
7
  let activeCursorProcesses = new Map(); // Track active processes by session ID
11
8
 
9
+ const WORKSPACE_TRUST_PATTERNS = [
10
+ /workspace trust required/i,
11
+ /do you trust the contents of this directory/i,
12
+ /working with untrusted contents/i,
13
+ /pass --trust,\s*--yolo,\s*or -f/i
14
+ ];
15
+
16
+ function isWorkspaceTrustPrompt(text = '') {
17
+ if (!text || typeof text !== 'string') {
18
+ return false;
19
+ }
20
+
21
+ return WORKSPACE_TRUST_PATTERNS.some((pattern) => pattern.test(text));
22
+ }
23
+
12
24
  async function spawnCursor(command, options = {}, ws) {
13
25
  return new Promise(async (resolve, reject) => {
14
- const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model, images } = options;
26
+ const { sessionId, projectPath, cwd, resume, toolsSettings, skipPermissions, model } = options;
15
27
  let capturedSessionId = sessionId; // Track session ID throughout the process
16
28
  let sessionCreatedSent = false; // Track if we've already sent session-created event
17
- let messageBuffer = ''; // Buffer for accumulating assistant messages
18
-
29
+ let hasRetriedWithTrust = false;
30
+ let settled = false;
31
+
19
32
  // Use tools settings passed from frontend, or defaults
20
33
  const settings = toolsSettings || {
21
34
  allowedShellCommands: [],
22
35
  skipPermissions: false
23
36
  };
24
-
37
+
25
38
  // Build Cursor CLI command
26
- const args = [];
27
-
39
+ const baseArgs = [];
40
+
28
41
  // Build flags allowing both resume and prompt together (reply in existing session)
29
42
  // Treat presence of sessionId as intention to resume, regardless of resume flag
30
43
  if (sessionId) {
31
- args.push('--resume=' + sessionId);
44
+ baseArgs.push('--resume=' + sessionId);
32
45
  }
33
46
 
34
47
  if (command && command.trim()) {
35
48
  // Provide a prompt (works for both new and resumed sessions)
36
- args.push('-p', command);
49
+ baseArgs.push('-p', command);
37
50
 
38
51
  // Add model flag if specified (only meaningful for new sessions; harmless on resume)
39
52
  if (!sessionId && model) {
40
- args.push('--model', model);
53
+ baseArgs.push('--model', model);
41
54
  }
42
55
 
43
56
  // Request streaming JSON when we are providing a prompt
44
- args.push('--output-format', 'stream-json');
57
+ baseArgs.push('--output-format', 'stream-json');
45
58
  }
46
-
59
+
47
60
  // Add skip permissions flag if enabled
48
61
  if (skipPermissions || settings.skipPermissions) {
49
- args.push('-f');
50
- console.log('⚠️ Using -f flag (skip permissions)');
62
+ baseArgs.push('-f');
63
+ console.log('Using -f flag (skip permissions)');
51
64
  }
52
-
65
+
53
66
  // Use cwd (actual project directory) instead of projectPath
54
67
  const workingDir = cwd || projectPath || process.cwd();
55
-
56
- console.log('Spawning Cursor CLI:', 'cursor-agent', args.join(' '));
57
- console.log('Working directory:', workingDir);
58
- console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume);
59
-
60
- const cursorProcess = spawnFunction('cursor-agent', args, {
61
- cwd: workingDir,
62
- stdio: ['pipe', 'pipe', 'pipe'],
63
- env: { ...process.env } // Inherit all environment variables
64
- });
65
-
68
+
66
69
  // Store process reference for potential abort
67
70
  const processKey = capturedSessionId || Date.now().toString();
68
- activeCursorProcesses.set(processKey, cursorProcess);
69
-
70
- // Handle stdout (streaming JSON responses)
71
- cursorProcess.stdout.on('data', (data) => {
72
- const rawOutput = data.toString();
73
- console.log('📤 Cursor CLI stdout:', rawOutput);
74
-
75
- const lines = rawOutput.split('\n').filter(line => line.trim());
76
-
77
- for (const line of lines) {
71
+
72
+ const settleOnce = (callback) => {
73
+ if (settled) {
74
+ return;
75
+ }
76
+ settled = true;
77
+ callback();
78
+ };
79
+
80
+ const runCursorProcess = (args, runReason = 'initial') => {
81
+ const isTrustRetry = runReason === 'trust-retry';
82
+ let runSawWorkspaceTrustPrompt = false;
83
+ let stdoutLineBuffer = '';
84
+
85
+ if (isTrustRetry) {
86
+ console.log('Retrying Cursor CLI with --trust after workspace trust prompt');
87
+ }
88
+
89
+ console.log('Spawning Cursor CLI:', 'cursor-agent', args.join(' '));
90
+ console.log('Working directory:', workingDir);
91
+ console.log('Session info - Input sessionId:', sessionId, 'Resume:', resume);
92
+
93
+ const cursorProcess = spawnFunction('cursor-agent', args, {
94
+ cwd: workingDir,
95
+ stdio: ['pipe', 'pipe', 'pipe'],
96
+ env: { ...process.env } // Inherit all environment variables
97
+ });
98
+
99
+ activeCursorProcesses.set(processKey, cursorProcess);
100
+
101
+ const shouldSuppressForTrustRetry = (text) => {
102
+ if (hasRetriedWithTrust || args.includes('--trust')) {
103
+ return false;
104
+ }
105
+ if (!isWorkspaceTrustPrompt(text)) {
106
+ return false;
107
+ }
108
+
109
+ runSawWorkspaceTrustPrompt = true;
110
+ return true;
111
+ };
112
+
113
+ const processCursorOutputLine = (line) => {
114
+ if (!line || !line.trim()) {
115
+ return;
116
+ }
117
+
78
118
  try {
79
119
  const response = JSON.parse(line);
80
- console.log('📄 Parsed JSON response:', response);
81
-
120
+ console.log('Parsed JSON response:', response);
121
+
82
122
  // Handle different message types
83
123
  switch (response.type) {
84
124
  case 'system':
@@ -86,14 +126,14 @@ async function spawnCursor(command, options = {}, ws) {
86
126
  // Capture session ID
87
127
  if (response.session_id && !capturedSessionId) {
88
128
  capturedSessionId = response.session_id;
89
- console.log('📝 Captured session ID:', capturedSessionId);
90
-
129
+ console.log('Captured session ID:', capturedSessionId);
130
+
91
131
  // Update process key with captured session ID
92
132
  if (processKey !== capturedSessionId) {
93
133
  activeCursorProcesses.delete(processKey);
94
134
  activeCursorProcesses.set(capturedSessionId, cursorProcess);
95
135
  }
96
-
136
+
97
137
  // Set session ID on writer (for API endpoint compatibility)
98
138
  if (ws.setSessionId && typeof ws.setSessionId === 'function') {
99
139
  ws.setSessionId(capturedSessionId);
@@ -110,7 +150,7 @@ async function spawnCursor(command, options = {}, ws) {
110
150
  });
111
151
  }
112
152
  }
113
-
153
+
114
154
  // Send system info to frontend
115
155
  ws.send({
116
156
  type: 'cursor-system',
@@ -119,7 +159,7 @@ async function spawnCursor(command, options = {}, ws) {
119
159
  });
120
160
  }
121
161
  break;
122
-
162
+
123
163
  case 'user':
124
164
  // Forward user message
125
165
  ws.send({
@@ -128,13 +168,12 @@ async function spawnCursor(command, options = {}, ws) {
128
168
  sessionId: capturedSessionId || sessionId || null
129
169
  });
130
170
  break;
131
-
171
+
132
172
  case 'assistant':
133
173
  // Accumulate assistant message chunks
134
174
  if (response.message && response.message.content && response.message.content.length > 0) {
135
175
  const textContent = response.message.content[0].text;
136
- messageBuffer += textContent;
137
-
176
+
138
177
  // Send as Claude-compatible format for frontend
139
178
  ws.send({
140
179
  type: 'claude-response',
@@ -149,23 +188,14 @@ async function spawnCursor(command, options = {}, ws) {
149
188
  });
150
189
  }
151
190
  break;
152
-
191
+
153
192
  case 'result':
154
193
  // Session complete
155
194
  console.log('Cursor session result:', response);
156
-
157
- // Send final message if we have buffered content
158
- if (messageBuffer) {
159
- ws.send({
160
- type: 'claude-response',
161
- data: {
162
- type: 'content_block_stop'
163
- },
164
- sessionId: capturedSessionId || sessionId || null
165
- });
166
- }
167
-
168
- // Send completion event
195
+
196
+ // Do not emit an extra content_block_stop here.
197
+ // The UI already finalizes the streaming message in cursor-result handling,
198
+ // and emitting both can produce duplicate assistant messages.
169
199
  ws.send({
170
200
  type: 'cursor-result',
171
201
  sessionId: capturedSessionId || sessionId,
@@ -173,7 +203,7 @@ async function spawnCursor(command, options = {}, ws) {
173
203
  success: response.subtype === 'success'
174
204
  });
175
205
  break;
176
-
206
+
177
207
  default:
178
208
  // Forward any other message types
179
209
  ws.send({
@@ -183,7 +213,12 @@ async function spawnCursor(command, options = {}, ws) {
183
213
  });
184
214
  }
185
215
  } catch (parseError) {
186
- console.log('📄 Non-JSON response:', line);
216
+ console.log('Non-JSON response:', line);
217
+
218
+ if (shouldSuppressForTrustRetry(line)) {
219
+ return;
220
+ }
221
+
187
222
  // If not JSON, send as raw text
188
223
  ws.send({
189
224
  type: 'cursor-output',
@@ -191,67 +226,106 @@ async function spawnCursor(command, options = {}, ws) {
191
226
  sessionId: capturedSessionId || sessionId || null
192
227
  });
193
228
  }
194
- }
195
- });
196
-
197
- // Handle stderr
198
- cursorProcess.stderr.on('data', (data) => {
199
- console.error('Cursor CLI stderr:', data.toString());
200
- ws.send({
201
- type: 'cursor-error',
202
- error: data.toString(),
203
- sessionId: capturedSessionId || sessionId || null
229
+ };
230
+
231
+ // Handle stdout (streaming JSON responses)
232
+ cursorProcess.stdout.on('data', (data) => {
233
+ const rawOutput = data.toString();
234
+ console.log('Cursor CLI stdout:', rawOutput);
235
+
236
+ // Stream chunks can split JSON objects across packets; keep trailing partial line.
237
+ stdoutLineBuffer += rawOutput;
238
+ const completeLines = stdoutLineBuffer.split(/\r?\n/);
239
+ stdoutLineBuffer = completeLines.pop() || '';
240
+
241
+ completeLines.forEach((line) => {
242
+ processCursorOutputLine(line.trim());
243
+ });
244
+ });
245
+
246
+ // Handle stderr
247
+ cursorProcess.stderr.on('data', (data) => {
248
+ const stderrText = data.toString();
249
+ console.error('Cursor CLI stderr:', stderrText);
250
+
251
+ if (shouldSuppressForTrustRetry(stderrText)) {
252
+ return;
253
+ }
254
+
255
+ ws.send({
256
+ type: 'cursor-error',
257
+ error: stderrText,
258
+ sessionId: capturedSessionId || sessionId || null
259
+ });
204
260
  });
205
- });
206
-
207
- // Handle process completion
208
- cursorProcess.on('close', async (code) => {
209
- console.log(`Cursor CLI process exited with code ${code}`);
210
-
211
- // Clean up process reference
212
- const finalSessionId = capturedSessionId || sessionId || processKey;
213
- activeCursorProcesses.delete(finalSessionId);
214
-
215
- ws.send({
216
- type: 'claude-complete',
217
- sessionId: finalSessionId,
218
- exitCode: code,
219
- isNewSession: !sessionId && !!command // Flag to indicate this was a new session
261
+
262
+ // Handle process completion
263
+ cursorProcess.on('close', async (code) => {
264
+ console.log(`Cursor CLI process exited with code ${code}`);
265
+
266
+ const finalSessionId = capturedSessionId || sessionId || processKey;
267
+ activeCursorProcesses.delete(finalSessionId);
268
+
269
+ // Flush any final unterminated stdout line before completion handling.
270
+ if (stdoutLineBuffer.trim()) {
271
+ processCursorOutputLine(stdoutLineBuffer.trim());
272
+ stdoutLineBuffer = '';
273
+ }
274
+
275
+ if (
276
+ runSawWorkspaceTrustPrompt &&
277
+ code !== 0 &&
278
+ !hasRetriedWithTrust &&
279
+ !args.includes('--trust')
280
+ ) {
281
+ hasRetriedWithTrust = true;
282
+ runCursorProcess([...args, '--trust'], 'trust-retry');
283
+ return;
284
+ }
285
+
286
+ ws.send({
287
+ type: 'claude-complete',
288
+ sessionId: finalSessionId,
289
+ exitCode: code,
290
+ isNewSession: !sessionId && !!command // Flag to indicate this was a new session
291
+ });
292
+
293
+ if (code === 0) {
294
+ settleOnce(() => resolve());
295
+ } else {
296
+ settleOnce(() => reject(new Error(`Cursor CLI exited with code ${code}`)));
297
+ }
220
298
  });
221
-
222
- if (code === 0) {
223
- resolve();
224
- } else {
225
- reject(new Error(`Cursor CLI exited with code ${code}`));
226
- }
227
- });
228
-
229
- // Handle process errors
230
- cursorProcess.on('error', (error) => {
231
- console.error('Cursor CLI process error:', error);
232
-
233
- // Clean up process reference on error
234
- const finalSessionId = capturedSessionId || sessionId || processKey;
235
- activeCursorProcesses.delete(finalSessionId);
236
-
237
- ws.send({
238
- type: 'cursor-error',
239
- error: error.message,
240
- sessionId: capturedSessionId || sessionId || null
299
+
300
+ // Handle process errors
301
+ cursorProcess.on('error', (error) => {
302
+ console.error('Cursor CLI process error:', error);
303
+
304
+ // Clean up process reference on error
305
+ const finalSessionId = capturedSessionId || sessionId || processKey;
306
+ activeCursorProcesses.delete(finalSessionId);
307
+
308
+ ws.send({
309
+ type: 'cursor-error',
310
+ error: error.message,
311
+ sessionId: capturedSessionId || sessionId || null
312
+ });
313
+
314
+ settleOnce(() => reject(error));
241
315
  });
242
316
 
243
- reject(error);
244
- });
245
-
246
- // Close stdin since Cursor doesn't need interactive input
247
- cursorProcess.stdin.end();
317
+ // Close stdin since Cursor doesn't need interactive input
318
+ cursorProcess.stdin.end();
319
+ };
320
+
321
+ runCursorProcess(baseArgs, 'initial');
248
322
  });
249
323
  }
250
324
 
251
325
  function abortCursorSession(sessionId) {
252
326
  const process = activeCursorProcesses.get(sessionId);
253
327
  if (process) {
254
- console.log(`🛑 Aborting Cursor session: ${sessionId}`);
328
+ console.log(`Aborting Cursor session: ${sessionId}`);
255
329
  process.kill('SIGTERM');
256
330
  activeCursorProcesses.delete(sessionId);
257
331
  return true;
package/server/index.js CHANGED
@@ -1730,8 +1730,14 @@ function handleShellConnection(ws) {
1730
1730
  shellCommand = 'cursor-agent';
1731
1731
  }
1732
1732
  } else if (provider === 'codex') {
1733
+ // Use codex command; attempt to resume and fall back to a new session when the resume fails.
1733
1734
  if (hasSession && sessionId) {
1734
- shellCommand = `codex resume "${sessionId}" || codex`;
1735
+ if (os.platform() === 'win32') {
1736
+ // PowerShell syntax for fallback
1737
+ shellCommand = `codex resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { codex }`;
1738
+ } else {
1739
+ shellCommand = `codex resume "${sessionId}" || codex`;
1740
+ }
1735
1741
  } else {
1736
1742
  shellCommand = 'codex';
1737
1743
  }
@@ -1765,7 +1771,11 @@ function handleShellConnection(ws) {
1765
1771
  // Claude (default provider)
1766
1772
  const command = initialCommand || 'claude';
1767
1773
  if (hasSession && sessionId) {
1768
- shellCommand = `claude --resume "${sessionId}" || claude`;
1774
+ if (os.platform() === 'win32') {
1775
+ shellCommand = `claude --resume "${sessionId}"; if ($LASTEXITCODE -ne 0) { claude }`;
1776
+ } else {
1777
+ shellCommand = `claude --resume "${sessionId}" || claude`;
1778
+ }
1769
1779
  } else {
1770
1780
  shellCommand = command;
1771
1781
  }
@@ -3,8 +3,8 @@ import { promises as fs } from 'fs';
3
3
  import path from 'path';
4
4
  import { fileURLToPath } from 'url';
5
5
  import os from 'os';
6
- import matter from 'gray-matter';
7
6
  import { CLAUDE_MODELS, CURSOR_MODELS, CODEX_MODELS } from '../../shared/modelConstants.js';
7
+ import { parseFrontmatter } from '../utils/frontmatter.js';
8
8
 
9
9
  const __filename = fileURLToPath(import.meta.url);
10
10
  const __dirname = path.dirname(__filename);
@@ -38,7 +38,7 @@ async function scanCommandsDirectory(dir, baseDir, namespace) {
38
38
  // Parse markdown file for metadata
39
39
  try {
40
40
  const content = await fs.readFile(fullPath, 'utf8');
41
- const { data: frontmatter, content: commandContent } = matter(content);
41
+ const { data: frontmatter, content: commandContent } = parseFrontmatter(content);
42
42
 
43
43
  // Calculate relative path from baseDir for command name
44
44
  const relativePath = path.relative(baseDir, fullPath);
@@ -475,7 +475,7 @@ router.post('/load', async (req, res) => {
475
475
 
476
476
  // Read and parse the command file
477
477
  const content = await fs.readFile(commandPath, 'utf8');
478
- const { data: metadata, content: commandContent } = matter(content);
478
+ const { data: metadata, content: commandContent } = parseFrontmatter(content);
479
479
 
480
480
  res.json({
481
481
  path: commandPath,
@@ -560,7 +560,7 @@ router.post('/execute', async (req, res) => {
560
560
  }
561
561
  }
562
562
  const content = await fs.readFile(commandPath, 'utf8');
563
- const { data: metadata, content: commandContent } = matter(content);
563
+ const { data: metadata, content: commandContent } = parseFrontmatter(content);
564
564
  // Basic argument replacement (will be enhanced in command parser utility)
565
565
  let processedContent = commandContent;
566
566