@siteboon/claude-code-ui 1.13.6 → 1.14.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/dist/index.html CHANGED
@@ -25,11 +25,11 @@
25
25
 
26
26
  <!-- Prevent zoom on iOS -->
27
27
  <meta name="format-detection" content="telephone=no" />
28
- <script type="module" crossorigin src="/assets/index-lf1GuHwT.js"></script>
29
- <link rel="modulepreload" crossorigin href="/assets/vendor-react-DVSKlM5e.js">
30
- <link rel="modulepreload" crossorigin href="/assets/vendor-codemirror-CnTQH7Pk.js">
28
+ <script type="module" crossorigin src="/assets/index-Dqg05I_l.js"></script>
29
+ <link rel="modulepreload" crossorigin href="/assets/vendor-react-DcyRfQm3.js">
30
+ <link rel="modulepreload" crossorigin href="/assets/vendor-codemirror-CJLzwpLB.js">
31
31
  <link rel="modulepreload" crossorigin href="/assets/vendor-xterm-DfaPXD3y.js">
32
- <link rel="stylesheet" crossorigin href="/assets/index-Cc6pl7ji.css">
32
+ <link rel="stylesheet" crossorigin href="/assets/index-BQGOOBNa.css">
33
33
  </head>
34
34
  <body>
35
35
  <div id="root"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@siteboon/claude-code-ui",
3
- "version": "1.13.6",
3
+ "version": "1.14.1",
4
4
  "description": "A web-based UI for Claude Code CLI",
5
5
  "type": "module",
6
6
  "main": "server/index.js",
@@ -71,6 +71,8 @@
71
71
  "express": "^4.18.2",
72
72
  "fuse.js": "^7.0.0",
73
73
  "gray-matter": "^4.0.3",
74
+ "i18next": "^25.7.4",
75
+ "i18next-browser-languagedetector": "^8.2.0",
74
76
  "jsonwebtoken": "^9.0.2",
75
77
  "katex": "^0.16.25",
76
78
  "lucide-react": "^0.515.0",
@@ -81,8 +83,10 @@
81
83
  "react": "^18.2.0",
82
84
  "react-dom": "^18.2.0",
83
85
  "react-dropzone": "^14.2.3",
86
+ "react-i18next": "^16.5.3",
84
87
  "react-markdown": "^10.1.0",
85
88
  "react-router-dom": "^6.8.1",
89
+ "react-syntax-highlighter": "^15.6.1",
86
90
  "rehype-katex": "^7.0.1",
87
91
  "remark-gfm": "^4.0.0",
88
92
  "remark-math": "^6.0.0",
@@ -13,6 +13,9 @@
13
13
  */
14
14
 
15
15
  import { query } from '@anthropic-ai/claude-agent-sdk';
16
+ // Used to mint unique approval request IDs when randomUUID is not available.
17
+ // This keeps parallel tool approvals from colliding; it does not add any crypto/security guarantees.
18
+ import crypto from 'crypto';
16
19
  import { promises as fs } from 'fs';
17
20
  import path from 'path';
18
21
  import os from 'os';
@@ -20,6 +23,124 @@ import { CLAUDE_MODELS } from '../shared/modelConstants.js';
20
23
 
21
24
  // Session tracking: Map of session IDs to active query instances
22
25
  const activeSessions = new Map();
26
+ // In-memory registry of pending tool approvals keyed by requestId.
27
+ // This does not persist approvals or share across processes; it exists so the
28
+ // SDK can pause tool execution while the UI decides what to do.
29
+ const pendingToolApprovals = new Map();
30
+
31
+ // Default approval timeout kept under the SDK's 60s control timeout.
32
+ // This does not change SDK limits; it only defines how long we wait for the UI,
33
+ // introduced to avoid hanging the run when no decision arrives.
34
+ const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
35
+
36
+ // Generate a stable request ID for UI approval flows.
37
+ // This does not encode tool details or get shown to users; it exists so the UI
38
+ // can respond to the correct pending request without collisions.
39
+ function createRequestId() {
40
+ // if clause is used because randomUUID is not available in older Node.js versions
41
+ if (typeof crypto.randomUUID === 'function') {
42
+ return crypto.randomUUID();
43
+ }
44
+ return crypto.randomBytes(16).toString('hex');
45
+ }
46
+
47
+ // Wait for a UI approval decision, honoring SDK cancellation.
48
+ // This does not auto-approve or auto-deny; it only resolves with UI input,
49
+ // and it cleans up the pending map to avoid leaks, introduced to prevent
50
+ // replying after the SDK cancels the control request.
51
+ function waitForToolApproval(requestId, options = {}) {
52
+ const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel } = options;
53
+
54
+ return new Promise(resolve => {
55
+ let settled = false;
56
+
57
+ const finalize = (decision) => {
58
+ if (settled) return;
59
+ settled = true;
60
+ cleanup();
61
+ resolve(decision);
62
+ };
63
+
64
+ const cleanup = () => {
65
+ pendingToolApprovals.delete(requestId);
66
+ clearTimeout(timeout);
67
+ if (signal && abortHandler) {
68
+ signal.removeEventListener('abort', abortHandler);
69
+ }
70
+ };
71
+
72
+ // Timeout is local to this process; it does not override SDK timing.
73
+ // It exists to prevent the UI prompt from lingering indefinitely.
74
+ const timeout = setTimeout(() => {
75
+ onCancel?.('timeout');
76
+ finalize(null);
77
+ }, timeoutMs);
78
+
79
+ const abortHandler = () => {
80
+ // If the SDK cancels the control request, stop waiting to avoid
81
+ // replying after the process is no longer ready for writes.
82
+ onCancel?.('cancelled');
83
+ finalize({ cancelled: true });
84
+ };
85
+
86
+ if (signal) {
87
+ if (signal.aborted) {
88
+ onCancel?.('cancelled');
89
+ finalize({ cancelled: true });
90
+ return;
91
+ }
92
+ signal.addEventListener('abort', abortHandler, { once: true });
93
+ }
94
+
95
+ pendingToolApprovals.set(requestId, (decision) => {
96
+ finalize(decision);
97
+ });
98
+ });
99
+ }
100
+
101
+ // Resolve a pending approval. This does not validate the decision payload;
102
+ // validation and tool matching remain in canUseTool, which keeps this as a
103
+ // lightweight WebSocket -> SDK relay.
104
+ function resolveToolApproval(requestId, decision) {
105
+ const resolver = pendingToolApprovals.get(requestId);
106
+ if (resolver) {
107
+ resolver(decision);
108
+ }
109
+ }
110
+
111
+ // Match stored permission entries against a tool + input combo.
112
+ // This only supports exact tool names and the Bash(command:*) shorthand
113
+ // used by the UI; it intentionally does not implement full glob semantics,
114
+ // introduced to stay consistent with the UI's "Allow rule" format.
115
+ function matchesToolPermission(entry, toolName, input) {
116
+ if (!entry || !toolName) {
117
+ return false;
118
+ }
119
+
120
+ if (entry === toolName) {
121
+ return true;
122
+ }
123
+
124
+ const bashMatch = entry.match(/^Bash\((.+):\*\)$/);
125
+ if (toolName === 'Bash' && bashMatch) {
126
+ const allowedPrefix = bashMatch[1];
127
+ let command = '';
128
+
129
+ if (typeof input === 'string') {
130
+ command = input.trim();
131
+ } else if (input && typeof input === 'object' && typeof input.command === 'string') {
132
+ command = input.command.trim();
133
+ }
134
+
135
+ if (!command) {
136
+ return false;
137
+ }
138
+
139
+ return command.startsWith(allowedPrefix);
140
+ }
141
+
142
+ return false;
143
+ }
23
144
 
24
145
  /**
25
146
  * Maps CLI options to SDK-compatible options format
@@ -52,29 +173,28 @@ function mapCliOptionsToSDK(options = {}) {
52
173
  if (settings.skipPermissions && permissionMode !== 'plan') {
53
174
  // When skipping permissions, use bypassPermissions mode
54
175
  sdkOptions.permissionMode = 'bypassPermissions';
55
- } else {
56
- // Map allowed tools
57
- let allowedTools = [...(settings.allowedTools || [])];
58
-
59
- // Add plan mode default tools
60
- if (permissionMode === 'plan') {
61
- const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch'];
62
- for (const tool of planModeTools) {
63
- if (!allowedTools.includes(tool)) {
64
- allowedTools.push(tool);
65
- }
176
+ }
177
+
178
+ // Map allowed tools (always set to avoid implicit "allow all" defaults).
179
+ // This does not grant permissions by itself; it just configures the SDK,
180
+ // introduced because leaving it undefined made the SDK treat it as "all tools allowed."
181
+ let allowedTools = [...(settings.allowedTools || [])];
182
+
183
+ // Add plan mode default tools
184
+ if (permissionMode === 'plan') {
185
+ const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch'];
186
+ for (const tool of planModeTools) {
187
+ if (!allowedTools.includes(tool)) {
188
+ allowedTools.push(tool);
66
189
  }
67
190
  }
191
+ }
68
192
 
69
- if (allowedTools.length > 0) {
70
- sdkOptions.allowedTools = allowedTools;
71
- }
193
+ sdkOptions.allowedTools = allowedTools;
72
194
 
73
- // Map disallowed tools
74
- if (settings.disallowedTools && settings.disallowedTools.length > 0) {
75
- sdkOptions.disallowedTools = settings.disallowedTools;
76
- }
77
- }
195
+ // Map disallowed tools (always set so the SDK doesn't treat "undefined" as permissive).
196
+ // This does not override allowlists; it only feeds the canUseTool gate.
197
+ sdkOptions.disallowedTools = settings.disallowedTools || [];
78
198
 
79
199
  // Map model (default to sonnet)
80
200
  // Valid models: sonnet, opus, haiku, opusplan, sonnet[1m]
@@ -370,6 +490,76 @@ async function queryClaudeSDK(command, options = {}, ws) {
370
490
  tempImagePaths = imageResult.tempImagePaths;
371
491
  tempDir = imageResult.tempDir;
372
492
 
493
+ // Gate tool usage with explicit UI approval when not auto-approved.
494
+ // This does not render UI or persist permissions; it only bridges to the UI
495
+ // via WebSocket and waits for the response, introduced so tool calls pause
496
+ // instead of auto-running when the allowlist is empty.
497
+ sdkOptions.canUseTool = async (toolName, input, context) => {
498
+ if (sdkOptions.permissionMode === 'bypassPermissions') {
499
+ return { behavior: 'allow', updatedInput: input };
500
+ }
501
+
502
+ const isDisallowed = (sdkOptions.disallowedTools || []).some(entry =>
503
+ matchesToolPermission(entry, toolName, input)
504
+ );
505
+ if (isDisallowed) {
506
+ return { behavior: 'deny', message: 'Tool disallowed by settings' };
507
+ }
508
+
509
+ const isAllowed = (sdkOptions.allowedTools || []).some(entry =>
510
+ matchesToolPermission(entry, toolName, input)
511
+ );
512
+ if (isAllowed) {
513
+ return { behavior: 'allow', updatedInput: input };
514
+ }
515
+
516
+ const requestId = createRequestId();
517
+ ws.send({
518
+ type: 'claude-permission-request',
519
+ requestId,
520
+ toolName,
521
+ input,
522
+ sessionId: capturedSessionId || sessionId || null
523
+ });
524
+
525
+ // Wait for the UI; if the SDK cancels, notify the UI so it can dismiss the banner.
526
+ // This does not retry or resurface the prompt; it just reflects the cancellation.
527
+ const decision = await waitForToolApproval(requestId, {
528
+ signal: context?.signal,
529
+ onCancel: (reason) => {
530
+ ws.send({
531
+ type: 'claude-permission-cancelled',
532
+ requestId,
533
+ reason,
534
+ sessionId: capturedSessionId || sessionId || null
535
+ });
536
+ }
537
+ });
538
+ if (!decision) {
539
+ return { behavior: 'deny', message: 'Permission request timed out' };
540
+ }
541
+
542
+ if (decision.cancelled) {
543
+ return { behavior: 'deny', message: 'Permission request cancelled' };
544
+ }
545
+
546
+ if (decision.allow) {
547
+ // rememberEntry only updates this run's in-memory allowlist to prevent
548
+ // repeated prompts in the same session; persistence is handled by the UI.
549
+ if (decision.rememberEntry && typeof decision.rememberEntry === 'string') {
550
+ if (!sdkOptions.allowedTools.includes(decision.rememberEntry)) {
551
+ sdkOptions.allowedTools.push(decision.rememberEntry);
552
+ }
553
+ if (Array.isArray(sdkOptions.disallowedTools)) {
554
+ sdkOptions.disallowedTools = sdkOptions.disallowedTools.filter(entry => entry !== decision.rememberEntry);
555
+ }
556
+ }
557
+ return { behavior: 'allow', updatedInput: decision.updatedInput ?? input };
558
+ }
559
+
560
+ return { behavior: 'deny', message: decision.message ?? 'User denied tool use' };
561
+ };
562
+
373
563
  // Create SDK query instance
374
564
  const queryInstance = query({
375
565
  prompt: finalCommand,
@@ -413,7 +603,8 @@ async function queryClaudeSDK(command, options = {}, ws) {
413
603
  const transformedMessage = transformMessage(message);
414
604
  ws.send({
415
605
  type: 'claude-response',
416
- data: transformedMessage
606
+ data: transformedMessage,
607
+ sessionId: capturedSessionId || sessionId || null
417
608
  });
418
609
 
419
610
  // Extract and send token budget updates from result messages
@@ -423,7 +614,8 @@ async function queryClaudeSDK(command, options = {}, ws) {
423
614
  console.log('Token budget from modelUsage:', tokenBudget);
424
615
  ws.send({
425
616
  type: 'token-budget',
426
- data: tokenBudget
617
+ data: tokenBudget,
618
+ sessionId: capturedSessionId || sessionId || null
427
619
  });
428
620
  }
429
621
  }
@@ -461,7 +653,8 @@ async function queryClaudeSDK(command, options = {}, ws) {
461
653
  // Send error to WebSocket
462
654
  ws.send({
463
655
  type: 'claude-error',
464
- error: error.message
656
+ error: error.message,
657
+ sessionId: capturedSessionId || sessionId || null
465
658
  });
466
659
 
467
660
  throw error;
@@ -526,5 +719,6 @@ export {
526
719
  queryClaudeSDK,
527
720
  abortClaudeSDKSession,
528
721
  isClaudeSDKSessionActive,
529
- getActiveClaudeSDKSessions
722
+ getActiveClaudeSDKSessions,
723
+ resolveToolApproval
530
724
  };
@@ -114,7 +114,8 @@ async function spawnCursor(command, options = {}, ws) {
114
114
  // Send system info to frontend
115
115
  ws.send({
116
116
  type: 'cursor-system',
117
- data: response
117
+ data: response,
118
+ sessionId: capturedSessionId || sessionId || null
118
119
  });
119
120
  }
120
121
  break;
@@ -123,7 +124,8 @@ async function spawnCursor(command, options = {}, ws) {
123
124
  // Forward user message
124
125
  ws.send({
125
126
  type: 'cursor-user',
126
- data: response
127
+ data: response,
128
+ sessionId: capturedSessionId || sessionId || null
127
129
  });
128
130
  break;
129
131
 
@@ -142,7 +144,8 @@ async function spawnCursor(command, options = {}, ws) {
142
144
  type: 'text_delta',
143
145
  text: textContent
144
146
  }
145
- }
147
+ },
148
+ sessionId: capturedSessionId || sessionId || null
146
149
  });
147
150
  }
148
151
  break;
@@ -157,7 +160,8 @@ async function spawnCursor(command, options = {}, ws) {
157
160
  type: 'claude-response',
158
161
  data: {
159
162
  type: 'content_block_stop'
160
- }
163
+ },
164
+ sessionId: capturedSessionId || sessionId || null
161
165
  });
162
166
  }
163
167
 
@@ -174,7 +178,8 @@ async function spawnCursor(command, options = {}, ws) {
174
178
  // Forward any other message types
175
179
  ws.send({
176
180
  type: 'cursor-response',
177
- data: response
181
+ data: response,
182
+ sessionId: capturedSessionId || sessionId || null
178
183
  });
179
184
  }
180
185
  } catch (parseError) {
@@ -182,7 +187,8 @@ async function spawnCursor(command, options = {}, ws) {
182
187
  // If not JSON, send as raw text
183
188
  ws.send({
184
189
  type: 'cursor-output',
185
- data: line
190
+ data: line,
191
+ sessionId: capturedSessionId || sessionId || null
186
192
  });
187
193
  }
188
194
  }
@@ -193,7 +199,8 @@ async function spawnCursor(command, options = {}, ws) {
193
199
  console.error('Cursor CLI stderr:', data.toString());
194
200
  ws.send({
195
201
  type: 'cursor-error',
196
- error: data.toString()
202
+ error: data.toString(),
203
+ sessionId: capturedSessionId || sessionId || null
197
204
  });
198
205
  });
199
206
 
@@ -229,7 +236,8 @@ async function spawnCursor(command, options = {}, ws) {
229
236
 
230
237
  ws.send({
231
238
  type: 'cursor-error',
232
- error: error.message
239
+ error: error.message,
240
+ sessionId: capturedSessionId || sessionId || null
233
241
  });
234
242
 
235
243
  reject(error);
@@ -264,4 +272,4 @@ export {
264
272
  abortCursorSession,
265
273
  isCursorSessionActive,
266
274
  getActiveCursorSessions
267
- };
275
+ };
package/server/index.js CHANGED
@@ -58,7 +58,7 @@ import fetch from 'node-fetch';
58
58
  import mime from 'mime-types';
59
59
 
60
60
  import { getProjects, getSessions, getSessionMessages, renameProject, deleteSession, deleteProject, addProjectManually, extractProjectDirectory, clearProjectDirectoryCache } from './projects.js';
61
- import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions } from './claude-sdk.js';
61
+ import { queryClaudeSDK, abortClaudeSDKSession, isClaudeSDKSessionActive, getActiveClaudeSDKSessions, resolveToolApproval } from './claude-sdk.js';
62
62
  import { spawnCursor, abortCursorSession, isCursorSessionActive, getActiveCursorSessions } from './cursor-cli.js';
63
63
  import { queryCodex, abortCodexSession, isCodexSessionActive, getActiveCodexSessions } from './openai-codex.js';
64
64
  import gitRoutes from './routes/git.js';
@@ -70,7 +70,7 @@ import mcpUtilsRoutes from './routes/mcp-utils.js';
70
70
  import commandsRoutes from './routes/commands.js';
71
71
  import settingsRoutes from './routes/settings.js';
72
72
  import agentRoutes from './routes/agent.js';
73
- import projectsRoutes from './routes/projects.js';
73
+ import projectsRoutes, { FORBIDDEN_PATHS } from './routes/projects.js';
74
74
  import cliAuthRoutes from './routes/cli-auth.js';
75
75
  import userRoutes from './routes/user.js';
76
76
  import codexRoutes from './routes/codex.js';
@@ -80,6 +80,20 @@ import { validateApiKey, authenticateToken, authenticateWebSocket } from './midd
80
80
  // File system watcher for projects folder
81
81
  let projectsWatcher = null;
82
82
  const connectedClients = new Set();
83
+ let isGetProjectsRunning = false; // Flag to prevent reentrant calls
84
+
85
+ // Broadcast progress to all connected WebSocket clients
86
+ function broadcastProgress(progress) {
87
+ const message = JSON.stringify({
88
+ type: 'loading_progress',
89
+ ...progress
90
+ });
91
+ connectedClients.forEach(client => {
92
+ if (client.readyState === WebSocket.OPEN) {
93
+ client.send(message);
94
+ }
95
+ });
96
+ }
83
97
 
84
98
  // Setup file system watcher for Claude projects folder using chokidar
85
99
  async function setupProjectsWatcher() {
@@ -117,13 +131,19 @@ async function setupProjectsWatcher() {
117
131
  const debouncedUpdate = async (eventType, filePath) => {
118
132
  clearTimeout(debounceTimer);
119
133
  debounceTimer = setTimeout(async () => {
134
+ // Prevent reentrant calls
135
+ if (isGetProjectsRunning) {
136
+ return;
137
+ }
138
+
120
139
  try {
140
+ isGetProjectsRunning = true;
121
141
 
122
142
  // Clear project directory cache when files change
123
143
  clearProjectDirectoryCache();
124
144
 
125
145
  // Get updated projects list
126
- const updatedProjects = await getProjects();
146
+ const updatedProjects = await getProjects(broadcastProgress);
127
147
 
128
148
  // Notify all connected clients about the project changes
129
149
  const updateMessage = JSON.stringify({
@@ -142,6 +162,8 @@ async function setupProjectsWatcher() {
142
162
 
143
163
  } catch (error) {
144
164
  console.error('[ERROR] Error handling project changes:', error);
165
+ } finally {
166
+ isGetProjectsRunning = false;
145
167
  }
146
168
  }, 300); // 300ms debounce (slightly faster than before)
147
169
  };
@@ -366,7 +388,7 @@ app.post('/api/system/update', authenticateToken, async (req, res) => {
366
388
 
367
389
  app.get('/api/projects', authenticateToken, async (req, res) => {
368
390
  try {
369
- const projects = await getProjects();
391
+ const projects = await getProjects(broadcastProgress);
370
392
  res.json(projects);
371
393
  } catch (error) {
372
394
  res.status(500).json({ error: error.message });
@@ -433,11 +455,12 @@ app.delete('/api/projects/:projectName/sessions/:sessionId', authenticateToken,
433
455
  }
434
456
  });
435
457
 
436
- // Delete project endpoint (only if empty)
458
+ // Delete project endpoint (force=true to delete with sessions)
437
459
  app.delete('/api/projects/:projectName', authenticateToken, async (req, res) => {
438
460
  try {
439
461
  const { projectName } = req.params;
440
- await deleteProject(projectName);
462
+ const force = req.query.force === 'true';
463
+ await deleteProject(projectName, force);
441
464
  res.json({ success: true });
442
465
  } catch (error) {
443
466
  res.status(500).json({ error: error.message });
@@ -496,7 +519,13 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
496
519
  name: item.name,
497
520
  type: 'directory'
498
521
  }))
499
- .slice(0, 20); // Limit results
522
+ .sort((a, b) => {
523
+ const aHidden = a.name.startsWith('.');
524
+ const bHidden = b.name.startsWith('.');
525
+ if (aHidden && !bHidden) return 1;
526
+ if (!aHidden && bHidden) return -1;
527
+ return a.name.localeCompare(b.name);
528
+ });
500
529
 
501
530
  // Add common directories if browsing home directory
502
531
  const suggestions = [];
@@ -521,6 +550,55 @@ app.get('/api/browse-filesystem', authenticateToken, async (req, res) => {
521
550
  }
522
551
  });
523
552
 
553
+ app.post('/api/create-folder', authenticateToken, async (req, res) => {
554
+ try {
555
+ const { path: folderPath } = req.body;
556
+ if (!folderPath) {
557
+ return res.status(400).json({ error: 'Path is required' });
558
+ }
559
+ const homeDir = os.homedir();
560
+ const targetPath = path.resolve(folderPath.replace('~', homeDir));
561
+ const normalizedPath = path.normalize(targetPath);
562
+ const comparePath = normalizedPath.toLowerCase();
563
+ const forbiddenLower = FORBIDDEN_PATHS.map(p => p.toLowerCase());
564
+ if (forbiddenLower.includes(comparePath) || comparePath === '/') {
565
+ return res.status(403).json({ error: 'Cannot create folders in system directories' });
566
+ }
567
+ for (const forbidden of forbiddenLower) {
568
+ if (comparePath.startsWith(forbidden + path.sep)) {
569
+ if (forbidden === '/var' && (comparePath.startsWith('/var/tmp') || comparePath.startsWith('/var/folders'))) {
570
+ continue;
571
+ }
572
+ return res.status(403).json({ error: `Cannot create folders in system directory: ${forbidden}` });
573
+ }
574
+ }
575
+ const parentDir = path.dirname(targetPath);
576
+ try {
577
+ await fs.promises.access(parentDir);
578
+ } catch (err) {
579
+ return res.status(404).json({ error: 'Parent directory does not exist' });
580
+ }
581
+ try {
582
+ await fs.promises.access(targetPath);
583
+ return res.status(409).json({ error: 'Folder already exists' });
584
+ } catch (err) {
585
+ // Folder doesn't exist, which is what we want
586
+ }
587
+ try {
588
+ await fs.promises.mkdir(targetPath, { recursive: false });
589
+ res.json({ success: true, path: targetPath });
590
+ } catch (mkdirError) {
591
+ if (mkdirError.code === 'EEXIST') {
592
+ return res.status(409).json({ error: 'Folder already exists' });
593
+ }
594
+ throw mkdirError;
595
+ }
596
+ } catch (error) {
597
+ console.error('Error creating folder:', error);
598
+ res.status(500).json({ error: 'Failed to create folder' });
599
+ }
600
+ });
601
+
524
602
  // Read file content endpoint
525
603
  app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) => {
526
604
  try {
@@ -804,6 +882,18 @@ function handleChatConnection(ws) {
804
882
  provider,
805
883
  success
806
884
  });
885
+ } else if (data.type === 'claude-permission-response') {
886
+ // Relay UI approval decisions back into the SDK control flow.
887
+ // This does not persist permissions; it only resolves the in-flight request,
888
+ // introduced so the SDK can resume once the user clicks Allow/Deny.
889
+ if (data.requestId) {
890
+ resolveToolApproval(data.requestId, {
891
+ allow: Boolean(data.allow),
892
+ updatedInput: data.updatedInput,
893
+ message: data.message,
894
+ rememberEntry: data.rememberEntry
895
+ });
896
+ }
807
897
  } else if (data.type === 'cursor-abort') {
808
898
  console.log('[DEBUG] Abort Cursor session:', data.sessionId);
809
899
  const success = abortCursorSession(data.sessionId);
@@ -1625,10 +1715,13 @@ async function getFileTree(dirPath, maxDepth = 3, currentDepth = 0, showHidden =
1625
1715
  // Debug: log all entries including hidden files
1626
1716
 
1627
1717
 
1628
- // Skip only heavy build directories
1718
+ // Skip heavy build directories and VCS directories
1629
1719
  if (entry.name === 'node_modules' ||
1630
1720
  entry.name === 'dist' ||
1631
- entry.name === 'build') continue;
1721
+ entry.name === 'build' ||
1722
+ entry.name === '.git' ||
1723
+ entry.name === '.svn' ||
1724
+ entry.name === '.hg') continue;
1632
1725
 
1633
1726
  const itemPath = path.join(dirPath, entry.name);
1634
1727
  const item = {
@@ -37,7 +37,12 @@ const authenticateToken = async (req, res, next) => {
37
37
 
38
38
  // Normal OSS JWT validation
39
39
  const authHeader = req.headers['authorization'];
40
- const token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
40
+ let token = authHeader && authHeader.split(' ')[1]; // Bearer TOKEN
41
+
42
+ // Also check query param for SSE endpoints (EventSource can't set headers)
43
+ if (!token && req.query.token) {
44
+ token = req.query.token;
45
+ }
41
46
 
42
47
  if (!token) {
43
48
  return res.status(401).json({ error: 'Access denied. No token provided.' });
@@ -272,7 +272,8 @@ export async function queryCodex(command, options = {}, ws) {
272
272
  data: {
273
273
  used: totalTokens,
274
274
  total: 200000 // Default context window for Codex models
275
- }
275
+ },
276
+ sessionId: currentSessionId
276
277
  });
277
278
  }
278
279
  }
@@ -280,7 +281,8 @@ export async function queryCodex(command, options = {}, ws) {
280
281
  // Send completion event
281
282
  sendMessage(ws, {
282
283
  type: 'codex-complete',
283
- sessionId: currentSessionId
284
+ sessionId: currentSessionId,
285
+ actualSessionId: thread.id
284
286
  });
285
287
 
286
288
  } catch (error) {