@siteboon/claude-code-ui 1.16.4 → 1.18.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.
@@ -56,4 +56,4 @@ Error generating stack: `+u.message+`
56
56
  * LICENSE.md file in the root directory of this source tree.
57
57
  *
58
58
  * @license MIT
59
- */const up="6";try{window.__reactRouterVersion=up}catch{}const ip="startTransition",Ha=od[ip];function dp(o){let{basename:f,children:a,future:y,window:g}=o,C=j.useRef();C.current==null&&(C.current=pd({window:g,v5Compat:!0}));let _=C.current,[T,P]=j.useState({action:_.action,location:_.location}),{v7_startTransition:U}=y||{},$=j.useCallback(N=>{U&&Ha?Ha(()=>P(N)):P(N)},[P,U]);return j.useLayoutEffect(()=>_.listen($),[_,$]),j.useEffect(()=>np(y),[y]),j.createElement(lp,{basename:f,children:a,location:T.location,navigationType:T.action,navigator:_,future:y})}var Qa;(function(o){o.UseScrollRestoration="useScrollRestoration",o.UseSubmit="useSubmit",o.UseSubmitFetcher="useSubmitFetcher",o.UseFetcher="useFetcher",o.useViewTransitionState="useViewTransitionState"})(Qa||(Qa={}));var Ka;(function(o){o.UseFetcher="useFetcher",o.UseFetchers="useFetchers",o.UseScrollRestoration="useScrollRestoration"})(Ka||(Ka={}));export{dp as B,id as R,j as a,fd as b,sp as c,op as d,fp as e,rp as f,Ya as g,cp as h,Xa as r,ap as u};
59
+ */const up="6";try{window.__reactRouterVersion=up}catch{}const ip="startTransition",Ha=od[ip];function dp(o){let{basename:f,children:a,future:y,window:g}=o,C=j.useRef();C.current==null&&(C.current=pd({window:g,v5Compat:!0}));let _=C.current,[T,P]=j.useState({action:_.action,location:_.location}),{v7_startTransition:U}=y||{},$=j.useCallback(N=>{U&&Ha?Ha(()=>P(N)):P(N)},[P,U]);return j.useLayoutEffect(()=>_.listen($),[_,$]),j.useEffect(()=>np(y),[y]),j.createElement(lp,{basename:f,children:a,location:T.location,navigationType:T.action,navigator:_,future:y})}var Qa;(function(o){o.UseScrollRestoration="useScrollRestoration",o.UseSubmit="useSubmit",o.UseSubmitFetcher="useSubmitFetcher",o.UseFetcher="useFetcher",o.useViewTransitionState="useViewTransitionState"})(Qa||(Qa={}));var Ka;(function(o){o.UseFetcher="useFetcher",o.UseFetchers="useFetchers",o.UseScrollRestoration="useScrollRestoration"})(Ka||(Ka={}));export{dp as B,id as R,j as a,fd as b,sp as c,op as d,cp as e,fp as f,Ya as g,rp as h,Xa as r,ap as u};
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-Cep8Annb.js"></script>
29
- <link rel="modulepreload" crossorigin href="/assets/vendor-react-DcyRfQm3.js">
30
- <link rel="modulepreload" crossorigin href="/assets/vendor-codemirror-CJLzwpLB.js">
28
+ <script type="module" crossorigin src="/assets/index-CF54Qj8d.js"></script>
29
+ <link rel="modulepreload" crossorigin href="/assets/vendor-react-DIN4KjD2.js">
30
+ <link rel="modulepreload" crossorigin href="/assets/vendor-codemirror-l-lAmaJ1.js">
31
31
  <link rel="modulepreload" crossorigin href="/assets/vendor-xterm-DfaPXD3y.js">
32
- <link rel="stylesheet" crossorigin href="/assets/index-DQad8ylc.css">
32
+ <link rel="stylesheet" crossorigin href="/assets/index-BiDFBFLP.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.16.4",
3
+ "version": "1.18.0",
4
4
  "description": "A web-based UI for Claude Code CLI",
5
5
  "type": "module",
6
6
  "main": "server/index.js",
@@ -42,7 +42,7 @@
42
42
  "author": "Claude Code UI Contributors",
43
43
  "license": "GPL-3.0",
44
44
  "dependencies": {
45
- "@anthropic-ai/claude-agent-sdk": "^0.1.29",
45
+ "@anthropic-ai/claude-agent-sdk": "^0.1.71",
46
46
  "@codemirror/lang-css": "^6.3.1",
47
47
  "@codemirror/lang-html": "^6.4.9",
48
48
  "@codemirror/lang-javascript": "^6.2.4",
@@ -89,6 +89,7 @@
89
89
  "react-router-dom": "^6.8.1",
90
90
  "react-syntax-highlighter": "^15.6.1",
91
91
  "rehype-katex": "^7.0.1",
92
+ "rehype-raw": "^7.0.0",
92
93
  "remark-gfm": "^4.0.0",
93
94
  "remark-math": "^6.0.0",
94
95
  "sqlite": "^5.1.1",
@@ -13,41 +13,26 @@
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
16
  import crypto from 'crypto';
19
17
  import { promises as fs } from 'fs';
20
18
  import path from 'path';
21
19
  import os from 'os';
22
20
  import { CLAUDE_MODELS } from '../shared/modelConstants.js';
23
21
 
24
- // Session tracking: Map of session IDs to active query instances
25
22
  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
23
  const pendingToolApprovals = new Map();
30
24
 
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
25
  const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
35
26
 
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.
27
+ const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion']);
28
+
39
29
  function createRequestId() {
40
- // if clause is used because randomUUID is not available in older Node.js versions
41
30
  if (typeof crypto.randomUUID === 'function') {
42
31
  return crypto.randomUUID();
43
32
  }
44
33
  return crypto.randomBytes(16).toString('hex');
45
34
  }
46
35
 
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
36
  function waitForToolApproval(requestId, options = {}) {
52
37
  const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel } = options;
53
38
 
@@ -61,24 +46,25 @@ function waitForToolApproval(requestId, options = {}) {
61
46
  resolve(decision);
62
47
  };
63
48
 
49
+ let timeout;
50
+
64
51
  const cleanup = () => {
65
52
  pendingToolApprovals.delete(requestId);
66
- clearTimeout(timeout);
53
+ if (timeout) clearTimeout(timeout);
67
54
  if (signal && abortHandler) {
68
55
  signal.removeEventListener('abort', abortHandler);
69
56
  }
70
57
  };
71
58
 
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);
59
+ // timeoutMs 0 = wait indefinitely (interactive tools)
60
+ if (timeoutMs > 0) {
61
+ timeout = setTimeout(() => {
62
+ onCancel?.('timeout');
63
+ finalize(null);
64
+ }, timeoutMs);
65
+ }
78
66
 
79
67
  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
68
  onCancel?.('cancelled');
83
69
  finalize({ cancelled: true });
84
70
  };
@@ -98,9 +84,6 @@ function waitForToolApproval(requestId, options = {}) {
98
84
  });
99
85
  }
100
86
 
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
87
  function resolveToolApproval(requestId, decision) {
105
88
  const resolver = pendingToolApprovals.get(requestId);
106
89
  if (resolver) {
@@ -175,9 +158,6 @@ function mapCliOptionsToSDK(options = {}) {
175
158
  sdkOptions.permissionMode = 'bypassPermissions';
176
159
  }
177
160
 
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
161
  let allowedTools = [...(settings.allowedTools || [])];
182
162
 
183
163
  // Add plan mode default tools
@@ -192,8 +172,11 @@ function mapCliOptionsToSDK(options = {}) {
192
172
 
193
173
  sdkOptions.allowedTools = allowedTools;
194
174
 
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.
175
+ // Use the tools preset to make all default built-in tools available (including AskUserQuestion).
176
+ // This was introduced in SDK 0.1.57. Omitting this preserves existing behavior (all tools available),
177
+ // but being explicit ensures forward compatibility and clarity.
178
+ sdkOptions.tools = { type: 'preset', preset: 'claude_code' };
179
+
197
180
  sdkOptions.disallowedTools = settings.disallowedTools || [];
198
181
 
199
182
  // Map model (default to sonnet)
@@ -267,9 +250,7 @@ function getAllSessions() {
267
250
  * @returns {Object} Transformed message ready for WebSocket
268
251
  */
269
252
  function transformMessage(sdkMessage) {
270
- // SDK messages are already in a format compatible with the frontend
271
- // The CLI sends them wrapped in {type: 'claude-response', data: message}
272
- // We'll do the same here to maintain compatibility
253
+ // Pass-through; SDK messages match frontend format.
273
254
  return sdkMessage;
274
255
  }
275
256
 
@@ -490,27 +471,27 @@ async function queryClaudeSDK(command, options = {}, ws) {
490
471
  tempImagePaths = imageResult.tempImagePaths;
491
472
  tempDir = imageResult.tempDir;
492
473
 
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
474
  sdkOptions.canUseTool = async (toolName, input, context) => {
498
- if (sdkOptions.permissionMode === 'bypassPermissions') {
499
- return { behavior: 'allow', updatedInput: input };
500
- }
475
+ const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName);
501
476
 
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
- }
477
+ if (!requiresInteraction) {
478
+ if (sdkOptions.permissionMode === 'bypassPermissions') {
479
+ return { behavior: 'allow', updatedInput: input };
480
+ }
508
481
 
509
- const isAllowed = (sdkOptions.allowedTools || []).some(entry =>
510
- matchesToolPermission(entry, toolName, input)
511
- );
512
- if (isAllowed) {
513
- return { behavior: 'allow', updatedInput: input };
482
+ const isDisallowed = (sdkOptions.disallowedTools || []).some(entry =>
483
+ matchesToolPermission(entry, toolName, input)
484
+ );
485
+ if (isDisallowed) {
486
+ return { behavior: 'deny', message: 'Tool disallowed by settings' };
487
+ }
488
+
489
+ const isAllowed = (sdkOptions.allowedTools || []).some(entry =>
490
+ matchesToolPermission(entry, toolName, input)
491
+ );
492
+ if (isAllowed) {
493
+ return { behavior: 'allow', updatedInput: input };
494
+ }
514
495
  }
515
496
 
516
497
  const requestId = createRequestId();
@@ -522,9 +503,8 @@ async function queryClaudeSDK(command, options = {}, ws) {
522
503
  sessionId: capturedSessionId || sessionId || null
523
504
  });
524
505
 
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
506
  const decision = await waitForToolApproval(requestId, {
507
+ timeoutMs: requiresInteraction ? 0 : undefined,
528
508
  signal: context?.signal,
529
509
  onCancel: (reason) => {
530
510
  ws.send({
@@ -544,8 +524,6 @@ async function queryClaudeSDK(command, options = {}, ws) {
544
524
  }
545
525
 
546
526
  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
527
  if (decision.rememberEntry && typeof decision.rememberEntry === 'string') {
550
528
  if (!sdkOptions.allowedTools.includes(decision.rememberEntry)) {
551
529
  sdkOptions.allowedTools.push(decision.rememberEntry);
@@ -560,12 +538,22 @@ async function queryClaudeSDK(command, options = {}, ws) {
560
538
  return { behavior: 'deny', message: decision.message ?? 'User denied tool use' };
561
539
  };
562
540
 
563
- // Create SDK query instance
541
+ // Set stream-close timeout for interactive tools (Query constructor reads it synchronously). Claude Agent SDK has a default of 5s and this overrides it
542
+ const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
543
+ process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000';
544
+
564
545
  const queryInstance = query({
565
546
  prompt: finalCommand,
566
547
  options: sdkOptions
567
548
  });
568
549
 
550
+ // Restore immediately — Query constructor already captured the value
551
+ if (prevStreamTimeout !== undefined) {
552
+ process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = prevStreamTimeout;
553
+ } else {
554
+ delete process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
555
+ }
556
+
569
557
  // Track the query instance for abort capability
570
558
  if (capturedSessionId) {
571
559
  addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir);
Binary file
package/server/index.js CHANGED
@@ -63,8 +63,24 @@ import { initializeDatabase } from './database/db.js';
63
63
  import { validateApiKey, authenticateToken, authenticateWebSocket } from './middleware/auth.js';
64
64
  import { IS_PLATFORM } from './constants/config.js';
65
65
 
66
- // File system watcher for projects folder
67
- let projectsWatcher = null;
66
+ // File system watchers for provider project/session folders
67
+ const PROVIDER_WATCH_PATHS = [
68
+ { provider: 'claude', rootPath: path.join(os.homedir(), '.claude', 'projects') },
69
+ { provider: 'cursor', rootPath: path.join(os.homedir(), '.cursor', 'chats') },
70
+ { provider: 'codex', rootPath: path.join(os.homedir(), '.codex', 'sessions') }
71
+ ];
72
+ const WATCHER_IGNORED_PATTERNS = [
73
+ '**/node_modules/**',
74
+ '**/.git/**',
75
+ '**/dist/**',
76
+ '**/build/**',
77
+ '**/*.tmp',
78
+ '**/*.swp',
79
+ '**/.DS_Store'
80
+ ];
81
+ const WATCHER_DEBOUNCE_MS = 300;
82
+ let projectsWatchers = [];
83
+ let projectsWatcherDebounceTimer = null;
68
84
  const connectedClients = new Set();
69
85
  let isGetProjectsRunning = false; // Flag to prevent reentrant calls
70
86
 
@@ -81,94 +97,110 @@ function broadcastProgress(progress) {
81
97
  });
82
98
  }
83
99
 
84
- // Setup file system watcher for Claude projects folder using chokidar
100
+ // Setup file system watchers for Claude, Cursor, and Codex project/session folders
85
101
  async function setupProjectsWatcher() {
86
102
  const chokidar = (await import('chokidar')).default;
87
- const claudeProjectsPath = path.join(os.homedir(), '.claude', 'projects');
88
103
 
89
- if (projectsWatcher) {
90
- projectsWatcher.close();
104
+ if (projectsWatcherDebounceTimer) {
105
+ clearTimeout(projectsWatcherDebounceTimer);
106
+ projectsWatcherDebounceTimer = null;
91
107
  }
92
108
 
93
- try {
94
- // Initialize chokidar watcher with optimized settings
95
- projectsWatcher = chokidar.watch(claudeProjectsPath, {
96
- ignored: [
97
- '**/node_modules/**',
98
- '**/.git/**',
99
- '**/dist/**',
100
- '**/build/**',
101
- '**/*.tmp',
102
- '**/*.swp',
103
- '**/.DS_Store'
104
- ],
105
- persistent: true,
106
- ignoreInitial: true, // Don't fire events for existing files on startup
107
- followSymlinks: false,
108
- depth: 10, // Reasonable depth limit
109
- awaitWriteFinish: {
110
- stabilityThreshold: 100, // Wait 100ms for file to stabilize
111
- pollInterval: 50
109
+ await Promise.all(
110
+ projectsWatchers.map(async (watcher) => {
111
+ try {
112
+ await watcher.close();
113
+ } catch (error) {
114
+ console.error('[WARN] Failed to close watcher:', error);
112
115
  }
113
- });
114
-
115
- // Debounce function to prevent excessive notifications
116
- let debounceTimer;
117
- const debouncedUpdate = async (eventType, filePath) => {
118
- clearTimeout(debounceTimer);
119
- debounceTimer = setTimeout(async () => {
120
- // Prevent reentrant calls
121
- if (isGetProjectsRunning) {
122
- return;
123
- }
116
+ })
117
+ );
118
+ projectsWatchers = [];
124
119
 
125
- try {
126
- isGetProjectsRunning = true;
120
+ const debouncedUpdate = (eventType, filePath, provider, rootPath) => {
121
+ if (projectsWatcherDebounceTimer) {
122
+ clearTimeout(projectsWatcherDebounceTimer);
123
+ }
127
124
 
128
- // Clear project directory cache when files change
129
- clearProjectDirectoryCache();
125
+ projectsWatcherDebounceTimer = setTimeout(async () => {
126
+ // Prevent reentrant calls
127
+ if (isGetProjectsRunning) {
128
+ return;
129
+ }
130
130
 
131
- // Get updated projects list
132
- const updatedProjects = await getProjects(broadcastProgress);
131
+ try {
132
+ isGetProjectsRunning = true;
133
+
134
+ // Clear project directory cache when files change
135
+ clearProjectDirectoryCache();
136
+
137
+ // Get updated projects list
138
+ const updatedProjects = await getProjects(broadcastProgress);
139
+
140
+ // Notify all connected clients about the project changes
141
+ const updateMessage = JSON.stringify({
142
+ type: 'projects_updated',
143
+ projects: updatedProjects,
144
+ timestamp: new Date().toISOString(),
145
+ changeType: eventType,
146
+ changedFile: path.relative(rootPath, filePath),
147
+ watchProvider: provider
148
+ });
133
149
 
134
- // Notify all connected clients about the project changes
135
- const updateMessage = JSON.stringify({
136
- type: 'projects_updated',
137
- projects: updatedProjects,
138
- timestamp: new Date().toISOString(),
139
- changeType: eventType,
140
- changedFile: path.relative(claudeProjectsPath, filePath)
141
- });
150
+ connectedClients.forEach(client => {
151
+ if (client.readyState === WebSocket.OPEN) {
152
+ client.send(updateMessage);
153
+ }
154
+ });
142
155
 
143
- connectedClients.forEach(client => {
144
- if (client.readyState === WebSocket.OPEN) {
145
- client.send(updateMessage);
146
- }
147
- });
156
+ } catch (error) {
157
+ console.error('[ERROR] Error handling project changes:', error);
158
+ } finally {
159
+ isGetProjectsRunning = false;
160
+ }
161
+ }, WATCHER_DEBOUNCE_MS);
162
+ };
148
163
 
149
- } catch (error) {
150
- console.error('[ERROR] Error handling project changes:', error);
151
- } finally {
152
- isGetProjectsRunning = false;
164
+ for (const { provider, rootPath } of PROVIDER_WATCH_PATHS) {
165
+ try {
166
+ // chokidar v4 emits ENOENT via the "error" event for missing roots and will not auto-recover.
167
+ // Ensure provider folders exist before creating the watcher so watching stays active.
168
+ await fsPromises.mkdir(rootPath, { recursive: true });
169
+
170
+ // Initialize chokidar watcher with optimized settings
171
+ const watcher = chokidar.watch(rootPath, {
172
+ ignored: WATCHER_IGNORED_PATTERNS,
173
+ persistent: true,
174
+ ignoreInitial: true, // Don't fire events for existing files on startup
175
+ followSymlinks: false,
176
+ depth: 10, // Reasonable depth limit
177
+ awaitWriteFinish: {
178
+ stabilityThreshold: 100, // Wait 100ms for file to stabilize
179
+ pollInterval: 50
153
180
  }
154
- }, 300); // 300ms debounce (slightly faster than before)
155
- };
156
-
157
- // Set up event listeners
158
- projectsWatcher
159
- .on('add', (filePath) => debouncedUpdate('add', filePath))
160
- .on('change', (filePath) => debouncedUpdate('change', filePath))
161
- .on('unlink', (filePath) => debouncedUpdate('unlink', filePath))
162
- .on('addDir', (dirPath) => debouncedUpdate('addDir', dirPath))
163
- .on('unlinkDir', (dirPath) => debouncedUpdate('unlinkDir', dirPath))
164
- .on('error', (error) => {
165
- console.error('[ERROR] Chokidar watcher error:', error);
166
- })
167
- .on('ready', () => {
168
181
  });
169
182
 
170
- } catch (error) {
171
- console.error('[ERROR] Failed to setup projects watcher:', error);
183
+ // Set up event listeners
184
+ watcher
185
+ .on('add', (filePath) => debouncedUpdate('add', filePath, provider, rootPath))
186
+ .on('change', (filePath) => debouncedUpdate('change', filePath, provider, rootPath))
187
+ .on('unlink', (filePath) => debouncedUpdate('unlink', filePath, provider, rootPath))
188
+ .on('addDir', (dirPath) => debouncedUpdate('addDir', dirPath, provider, rootPath))
189
+ .on('unlinkDir', (dirPath) => debouncedUpdate('unlinkDir', dirPath, provider, rootPath))
190
+ .on('error', (error) => {
191
+ console.error(`[ERROR] ${provider} watcher error:`, error);
192
+ })
193
+ .on('ready', () => {
194
+ });
195
+
196
+ projectsWatchers.push(watcher);
197
+ } catch (error) {
198
+ console.error(`[ERROR] Failed to setup ${provider} watcher for ${rootPath}:`, error);
199
+ }
200
+ }
201
+
202
+ if (projectsWatchers.length === 0) {
203
+ console.error('[ERROR] Failed to setup any provider watchers');
172
204
  }
173
205
  }
174
206
 
@@ -671,7 +703,6 @@ app.get('/api/projects/:projectName/file', authenticateToken, async (req, res) =
671
703
  const { projectName } = req.params;
672
704
  const { filePath } = req.query;
673
705
 
674
- console.log('[DEBUG] File read request:', projectName, filePath);
675
706
 
676
707
  // Security: ensure the requested path is inside the project root
677
708
  if (!filePath) {
@@ -712,7 +743,6 @@ app.get('/api/projects/:projectName/files/content', authenticateToken, async (re
712
743
  const { projectName } = req.params;
713
744
  const { path: filePath } = req.query;
714
745
 
715
- console.log('[DEBUG] Binary file serve request:', projectName, filePath);
716
746
 
717
747
  // Security: ensure the requested path is inside the project root
718
748
  if (!filePath) {
@@ -766,7 +796,6 @@ app.put('/api/projects/:projectName/file', authenticateToken, async (req, res) =
766
796
  const { projectName } = req.params;
767
797
  const { filePath, content } = req.body;
768
798
 
769
- console.log('[DEBUG] File save request:', projectName, filePath);
770
799
 
771
800
  // Security: ensure the requested path is inside the project root
772
801
  if (!filePath) {
@@ -203,6 +203,7 @@ export async function queryCodex(command, options = {}, ws) {
203
203
  let codex;
204
204
  let thread;
205
205
  let currentSessionId = sessionId;
206
+ const abortController = new AbortController();
206
207
 
207
208
  try {
208
209
  // Initialize Codex SDK
@@ -232,6 +233,7 @@ export async function queryCodex(command, options = {}, ws) {
232
233
  thread,
233
234
  codex,
234
235
  status: 'running',
236
+ abortController,
235
237
  startedAt: new Date().toISOString()
236
238
  });
237
239
 
@@ -243,7 +245,9 @@ export async function queryCodex(command, options = {}, ws) {
243
245
  });
244
246
 
245
247
  // Execute with streaming
246
- const streamedTurn = await thread.runStreamed(command);
248
+ const streamedTurn = await thread.runStreamed(command, {
249
+ signal: abortController.signal
250
+ });
247
251
 
248
252
  for await (const event of streamedTurn.events) {
249
253
  // Check if session was aborted
@@ -286,20 +290,27 @@ export async function queryCodex(command, options = {}, ws) {
286
290
  });
287
291
 
288
292
  } catch (error) {
289
- console.error('[Codex] Error:', error);
290
-
291
- sendMessage(ws, {
292
- type: 'codex-error',
293
- error: error.message,
294
- sessionId: currentSessionId
295
- });
293
+ const session = currentSessionId ? activeCodexSessions.get(currentSessionId) : null;
294
+ const wasAborted =
295
+ session?.status === 'aborted' ||
296
+ error?.name === 'AbortError' ||
297
+ String(error?.message || '').toLowerCase().includes('aborted');
298
+
299
+ if (!wasAborted) {
300
+ console.error('[Codex] Error:', error);
301
+ sendMessage(ws, {
302
+ type: 'codex-error',
303
+ error: error.message,
304
+ sessionId: currentSessionId
305
+ });
306
+ }
296
307
 
297
308
  } finally {
298
309
  // Update session status
299
310
  if (currentSessionId) {
300
311
  const session = activeCodexSessions.get(currentSessionId);
301
312
  if (session) {
302
- session.status = 'completed';
313
+ session.status = session.status === 'aborted' ? 'aborted' : 'completed';
303
314
  }
304
315
  }
305
316
  }
@@ -318,9 +329,11 @@ export function abortCodexSession(sessionId) {
318
329
  }
319
330
 
320
331
  session.status = 'aborted';
321
-
322
- // The SDK doesn't have a direct abort method, but marking status
323
- // will cause the streaming loop to exit
332
+ try {
333
+ session.abortController?.abort();
334
+ } catch (error) {
335
+ console.warn(`[Codex] Failed to abort session ${sessionId}:`, error);
336
+ }
324
337
 
325
338
  return true;
326
339
  }