@pixelbyte-software/pixcode 1.30.1 → 1.31.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.
Files changed (205) hide show
  1. package/LICENSE +718 -718
  2. package/README.de.md +248 -248
  3. package/README.ja.md +240 -240
  4. package/README.ko.md +240 -240
  5. package/README.md +295 -285
  6. package/README.ru.md +248 -248
  7. package/README.tr.md +250 -250
  8. package/README.zh-CN.md +240 -240
  9. package/dist/api-docs.html +879 -879
  10. package/dist/assets/index-BRRJ47XQ.css +32 -0
  11. package/dist/assets/index-EQohwyiC.js +837 -0
  12. package/dist/clear-cache.html +85 -85
  13. package/dist/convert-icons.md +52 -52
  14. package/dist/favicon.png +0 -0
  15. package/dist/favicon.svg +7 -8
  16. package/dist/generate-icons.js +48 -48
  17. package/dist/icons/codex-white.svg +3 -3
  18. package/dist/icons/codex.svg +3 -3
  19. package/dist/icons/cursor-white.svg +11 -11
  20. package/dist/icons/icon-128x128.png +0 -0
  21. package/dist/icons/icon-128x128.svg +9 -12
  22. package/dist/icons/icon-144x144.png +0 -0
  23. package/dist/icons/icon-144x144.svg +9 -12
  24. package/dist/icons/icon-152x152.png +0 -0
  25. package/dist/icons/icon-152x152.svg +9 -12
  26. package/dist/icons/icon-192x192.png +0 -0
  27. package/dist/icons/icon-192x192.svg +9 -12
  28. package/dist/icons/icon-384x384.png +0 -0
  29. package/dist/icons/icon-384x384.svg +9 -12
  30. package/dist/icons/icon-512x512.png +0 -0
  31. package/dist/icons/icon-512x512.svg +9 -12
  32. package/dist/icons/icon-72x72.png +0 -0
  33. package/dist/icons/icon-72x72.svg +9 -12
  34. package/dist/icons/icon-96x96.png +0 -0
  35. package/dist/icons/icon-96x96.svg +9 -12
  36. package/dist/icons/icon-template.svg +9 -12
  37. package/dist/icons/qwen-ai-icon.png +0 -0
  38. package/dist/index.html +59 -49
  39. package/dist/logo.png +0 -0
  40. package/dist/logo.svg +11 -16
  41. package/dist/manifest.json +60 -60
  42. package/dist/sw.js +124 -124
  43. package/dist-server/server/cli.js +100 -97
  44. package/dist-server/server/cli.js.map +1 -1
  45. package/dist-server/server/daemon/manager.js +33 -33
  46. package/dist-server/server/daemon-manager.js +62 -62
  47. package/dist-server/server/database/db.js +114 -22
  48. package/dist-server/server/database/db.js.map +1 -1
  49. package/dist-server/server/database/schema.js +122 -89
  50. package/dist-server/server/database/schema.js.map +1 -1
  51. package/dist-server/server/gemini-cli.js +6 -1
  52. package/dist-server/server/gemini-cli.js.map +1 -1
  53. package/dist-server/server/index.js +234 -65
  54. package/dist-server/server/index.js.map +1 -1
  55. package/dist-server/server/modules/providers/list/claude/claude-auth.provider.js +29 -2
  56. package/dist-server/server/modules/providers/list/claude/claude-auth.provider.js.map +1 -1
  57. package/dist-server/server/modules/providers/list/codex/codex-auth.provider.js +22 -2
  58. package/dist-server/server/modules/providers/list/codex/codex-auth.provider.js.map +1 -1
  59. package/dist-server/server/modules/providers/list/cursor/cursor-auth.provider.js +2 -2
  60. package/dist-server/server/modules/providers/list/cursor/cursor-auth.provider.js.map +1 -1
  61. package/dist-server/server/modules/providers/list/gemini/gemini-auth.provider.js +14 -2
  62. package/dist-server/server/modules/providers/list/gemini/gemini-auth.provider.js.map +1 -1
  63. package/dist-server/server/modules/providers/list/qwen/qwen-auth.provider.js +132 -0
  64. package/dist-server/server/modules/providers/list/qwen/qwen-auth.provider.js.map +1 -0
  65. package/dist-server/server/modules/providers/list/qwen/qwen-mcp.provider.js +87 -0
  66. package/dist-server/server/modules/providers/list/qwen/qwen-mcp.provider.js.map +1 -0
  67. package/dist-server/server/modules/providers/list/qwen/qwen-sessions.provider.js +201 -0
  68. package/dist-server/server/modules/providers/list/qwen/qwen-sessions.provider.js.map +1 -0
  69. package/dist-server/server/modules/providers/list/qwen/qwen.provider.js +19 -0
  70. package/dist-server/server/modules/providers/list/qwen/qwen.provider.js.map +1 -0
  71. package/dist-server/server/modules/providers/provider.registry.js +2 -0
  72. package/dist-server/server/modules/providers/provider.registry.js.map +1 -1
  73. package/dist-server/server/modules/providers/provider.routes.js +310 -1
  74. package/dist-server/server/modules/providers/provider.routes.js.map +1 -1
  75. package/dist-server/server/projects.js +197 -6
  76. package/dist-server/server/projects.js.map +1 -1
  77. package/dist-server/server/qwen-code-cli.js +350 -0
  78. package/dist-server/server/qwen-code-cli.js.map +1 -0
  79. package/dist-server/server/qwen-response-handler.js +70 -0
  80. package/dist-server/server/qwen-response-handler.js.map +1 -0
  81. package/dist-server/server/routes/commands.js +25 -25
  82. package/dist-server/server/routes/git.js +17 -17
  83. package/dist-server/server/routes/network.js +116 -0
  84. package/dist-server/server/routes/network.js.map +1 -0
  85. package/dist-server/server/routes/projects.js +43 -0
  86. package/dist-server/server/routes/projects.js.map +1 -1
  87. package/dist-server/server/routes/qwen.js +23 -0
  88. package/dist-server/server/routes/qwen.js.map +1 -0
  89. package/dist-server/server/routes/taskmaster.js +419 -419
  90. package/dist-server/server/routes/telegram.js +119 -0
  91. package/dist-server/server/routes/telegram.js.map +1 -0
  92. package/dist-server/server/services/external-access.js +228 -0
  93. package/dist-server/server/services/external-access.js.map +1 -0
  94. package/dist-server/server/services/install-jobs.js +394 -0
  95. package/dist-server/server/services/install-jobs.js.map +1 -0
  96. package/dist-server/server/services/notification-orchestrator.js +19 -5
  97. package/dist-server/server/services/notification-orchestrator.js.map +1 -1
  98. package/dist-server/server/services/provider-credentials.js +154 -0
  99. package/dist-server/server/services/provider-credentials.js.map +1 -0
  100. package/dist-server/server/services/provider-models.js +218 -0
  101. package/dist-server/server/services/provider-models.js.map +1 -0
  102. package/dist-server/server/services/telegram/bot.js +259 -0
  103. package/dist-server/server/services/telegram/bot.js.map +1 -0
  104. package/dist-server/server/services/telegram/translations.js +160 -0
  105. package/dist-server/server/services/telegram/translations.js.map +1 -0
  106. package/dist-server/server/utils/port-access.js +196 -0
  107. package/dist-server/server/utils/port-access.js.map +1 -0
  108. package/dist-server/shared/modelConstants.js +18 -0
  109. package/dist-server/shared/modelConstants.js.map +1 -1
  110. package/package.json +177 -168
  111. package/scripts/fix-node-pty.js +67 -67
  112. package/server/claude-sdk.js +834 -834
  113. package/server/cli.js +940 -937
  114. package/server/constants/config.js +4 -4
  115. package/server/cursor-cli.js +342 -342
  116. package/server/daemon/manager.js +564 -564
  117. package/server/daemon-manager.js +920 -920
  118. package/server/database/db.js +696 -593
  119. package/server/database/schema.js +138 -102
  120. package/server/gemini-cli.js +475 -469
  121. package/server/gemini-response-handler.js +79 -79
  122. package/server/index.js +2730 -2557
  123. package/server/load-env.js +34 -34
  124. package/server/middleware/auth.js +132 -132
  125. package/server/modules/providers/list/claude/claude-auth.provider.ts +145 -123
  126. package/server/modules/providers/list/claude/claude-mcp.provider.ts +135 -135
  127. package/server/modules/providers/list/claude/claude-sessions.provider.ts +306 -306
  128. package/server/modules/providers/list/claude/claude.provider.ts +15 -15
  129. package/server/modules/providers/list/codex/codex-auth.provider.ts +115 -100
  130. package/server/modules/providers/list/codex/codex-mcp.provider.ts +135 -135
  131. package/server/modules/providers/list/codex/codex-sessions.provider.ts +319 -319
  132. package/server/modules/providers/list/codex/codex.provider.ts +15 -15
  133. package/server/modules/providers/list/cursor/cursor-auth.provider.ts +143 -143
  134. package/server/modules/providers/list/cursor/cursor-mcp.provider.ts +108 -108
  135. package/server/modules/providers/list/cursor/cursor-sessions.provider.ts +421 -421
  136. package/server/modules/providers/list/cursor/cursor.provider.ts +15 -15
  137. package/server/modules/providers/list/gemini/gemini-auth.provider.ts +163 -151
  138. package/server/modules/providers/list/gemini/gemini-mcp.provider.ts +110 -110
  139. package/server/modules/providers/list/gemini/gemini-sessions.provider.ts +227 -227
  140. package/server/modules/providers/list/gemini/gemini.provider.ts +15 -15
  141. package/server/modules/providers/list/qwen/qwen-auth.provider.ts +145 -0
  142. package/server/modules/providers/list/qwen/qwen-mcp.provider.ts +114 -0
  143. package/server/modules/providers/list/qwen/qwen-sessions.provider.ts +218 -0
  144. package/server/modules/providers/list/qwen/qwen.provider.ts +21 -0
  145. package/server/modules/providers/provider.registry.ts +38 -36
  146. package/server/modules/providers/provider.routes.ts +583 -217
  147. package/server/modules/providers/services/mcp.service.ts +94 -94
  148. package/server/modules/providers/services/provider-auth.service.ts +26 -26
  149. package/server/modules/providers/services/sessions.service.ts +45 -45
  150. package/server/modules/providers/shared/base/abstract.provider.ts +20 -20
  151. package/server/modules/providers/shared/mcp/mcp.provider.ts +151 -151
  152. package/server/modules/providers/tests/mcp.test.ts +293 -293
  153. package/server/openai-codex.js +426 -426
  154. package/server/projects.js +2993 -2792
  155. package/server/qwen-code-cli.js +392 -0
  156. package/server/qwen-response-handler.js +73 -0
  157. package/server/routes/agent.js +1245 -1245
  158. package/server/routes/auth.js +134 -134
  159. package/server/routes/codex.js +19 -19
  160. package/server/routes/commands.js +554 -554
  161. package/server/routes/cursor.js +52 -52
  162. package/server/routes/gemini.js +24 -24
  163. package/server/routes/git.js +1488 -1488
  164. package/server/routes/mcp-utils.js +31 -31
  165. package/server/routes/messages.js +61 -61
  166. package/server/routes/network.js +128 -0
  167. package/server/routes/plugins.js +307 -307
  168. package/server/routes/projects.js +675 -627
  169. package/server/routes/qwen.js +27 -0
  170. package/server/routes/settings.js +286 -286
  171. package/server/routes/taskmaster.js +1471 -1471
  172. package/server/routes/telegram.js +125 -0
  173. package/server/routes/user.js +123 -123
  174. package/server/services/external-access.js +240 -0
  175. package/server/services/install-jobs.js +410 -0
  176. package/server/services/notification-orchestrator.js +242 -227
  177. package/server/services/provider-credentials.js +151 -0
  178. package/server/services/provider-models.js +225 -0
  179. package/server/services/telegram/bot.js +280 -0
  180. package/server/services/telegram/translations.js +170 -0
  181. package/server/services/vapid-keys.js +35 -35
  182. package/server/sessionManager.js +225 -225
  183. package/server/shared/interfaces.ts +54 -54
  184. package/server/shared/types.ts +172 -172
  185. package/server/shared/utils.ts +193 -193
  186. package/server/tsconfig.json +36 -36
  187. package/server/utils/colors.js +21 -21
  188. package/server/utils/commandParser.js +303 -303
  189. package/server/utils/frontmatter.js +18 -18
  190. package/server/utils/gitConfig.js +34 -34
  191. package/server/utils/mcp-detector.js +147 -147
  192. package/server/utils/plugin-loader.js +457 -457
  193. package/server/utils/plugin-process-manager.js +184 -184
  194. package/server/utils/port-access.js +209 -0
  195. package/server/utils/runtime-paths.js +37 -37
  196. package/server/utils/taskmaster-websocket.js +128 -128
  197. package/server/utils/url-detection.js +71 -71
  198. package/server/vite-daemon.js +78 -78
  199. package/shared/modelConstants.js +117 -97
  200. package/shared/networkHosts.js +22 -22
  201. package/dist/assets/index-C2c9QNwK.css +0 -32
  202. package/dist/assets/index-DyXDZED-.js +0 -1277
  203. package/dist-server/server/routes/cli-auth.js +0 -25
  204. package/dist-server/server/routes/cli-auth.js.map +0 -1
  205. package/server/routes/cli-auth.js +0 -27
@@ -1,834 +1,834 @@
1
- /**
2
- * Claude SDK Integration
3
- *
4
- * This module provides SDK-based integration with Claude using the @anthropic-ai/claude-agent-sdk.
5
- * It mirrors the interface of claude-cli.js but uses the SDK internally for better performance
6
- * and maintainability.
7
- *
8
- * Key features:
9
- * - Direct SDK integration without child processes
10
- * - Session management with abort capability
11
- * - Options mapping between CLI and SDK formats
12
- * - WebSocket message streaming
13
- */
14
-
15
- import { query } from '@anthropic-ai/claude-agent-sdk';
16
- import crypto from 'crypto';
17
- import { promises as fs } from 'fs';
18
- import path from 'path';
19
- import os from 'os';
20
- import { CLAUDE_MODELS } from '../shared/modelConstants.js';
21
- import {
22
- createNotificationEvent,
23
- notifyRunFailed,
24
- notifyRunStopped,
25
- notifyUserIfEnabled
26
- } from './services/notification-orchestrator.js';
27
- import { sessionsService } from './modules/providers/services/sessions.service.js';
28
- import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
29
- import { createNormalizedMessage } from './shared/utils.js';
30
-
31
- const activeSessions = new Map();
32
- const pendingToolApprovals = new Map();
33
-
34
- const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
35
-
36
- const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion', 'ExitPlanMode']);
37
-
38
- function createRequestId() {
39
- if (typeof crypto.randomUUID === 'function') {
40
- return crypto.randomUUID();
41
- }
42
- return crypto.randomBytes(16).toString('hex');
43
- }
44
-
45
- function waitForToolApproval(requestId, options = {}) {
46
- const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel, metadata } = options;
47
-
48
- return new Promise(resolve => {
49
- let settled = false;
50
-
51
- const finalize = (decision) => {
52
- if (settled) return;
53
- settled = true;
54
- cleanup();
55
- resolve(decision);
56
- };
57
-
58
- let timeout;
59
-
60
- const cleanup = () => {
61
- pendingToolApprovals.delete(requestId);
62
- if (timeout) clearTimeout(timeout);
63
- if (signal && abortHandler) {
64
- signal.removeEventListener('abort', abortHandler);
65
- }
66
- };
67
-
68
- // timeoutMs 0 = wait indefinitely (interactive tools)
69
- if (timeoutMs > 0) {
70
- timeout = setTimeout(() => {
71
- onCancel?.('timeout');
72
- finalize(null);
73
- }, timeoutMs);
74
- }
75
-
76
- const abortHandler = () => {
77
- onCancel?.('cancelled');
78
- finalize({ cancelled: true });
79
- };
80
-
81
- if (signal) {
82
- if (signal.aborted) {
83
- onCancel?.('cancelled');
84
- finalize({ cancelled: true });
85
- return;
86
- }
87
- signal.addEventListener('abort', abortHandler, { once: true });
88
- }
89
-
90
- const resolver = (decision) => {
91
- finalize(decision);
92
- };
93
- // Attach metadata for getPendingApprovalsForSession lookup
94
- if (metadata) {
95
- Object.assign(resolver, metadata);
96
- }
97
- pendingToolApprovals.set(requestId, resolver);
98
- });
99
- }
100
-
101
- function resolveToolApproval(requestId, decision) {
102
- const resolver = pendingToolApprovals.get(requestId);
103
- if (resolver) {
104
- resolver(decision);
105
- }
106
- }
107
-
108
- // Match stored permission entries against a tool + input combo.
109
- // This only supports exact tool names and the Bash(command:*) shorthand
110
- // used by the UI; it intentionally does not implement full glob semantics,
111
- // introduced to stay consistent with the UI's "Allow rule" format.
112
- function matchesToolPermission(entry, toolName, input) {
113
- if (!entry || !toolName) {
114
- return false;
115
- }
116
-
117
- if (entry === toolName) {
118
- return true;
119
- }
120
-
121
- const bashMatch = entry.match(/^Bash\((.+):\*\)$/);
122
- if (toolName === 'Bash' && bashMatch) {
123
- const allowedPrefix = bashMatch[1];
124
- let command = '';
125
-
126
- if (typeof input === 'string') {
127
- command = input.trim();
128
- } else if (input && typeof input === 'object' && typeof input.command === 'string') {
129
- command = input.command.trim();
130
- }
131
-
132
- if (!command) {
133
- return false;
134
- }
135
-
136
- return command.startsWith(allowedPrefix);
137
- }
138
-
139
- return false;
140
- }
141
-
142
- /**
143
- * Maps CLI options to SDK-compatible options format
144
- * @param {Object} options - CLI options
145
- * @returns {Object} SDK-compatible options
146
- */
147
- function mapCliOptionsToSDK(options = {}) {
148
- const { sessionId, cwd, toolsSettings, permissionMode } = options;
149
-
150
- const sdkOptions = {};
151
-
152
- // Forward all host env vars (e.g. ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN) to the subprocess.
153
- // Since claude-agent-sdk 0.2.113+, options.env REPLACES process.env in the subprocess
154
- // instead of overlaying it. Without spreading process.env here, users who rely on
155
- // ANTHROPIC_BASE_URL, HTTP(S)_PROXY, etc. would silently lose those settings.
156
- sdkOptions.env = { ...process.env };
157
-
158
- // Use CLAUDE_CLI_PATH if explicitly set, otherwise fall back to the "claude" binary on PATH.
159
- // The SDK (>=0.2.113) looks for a bundled native binary as an optional dependency by default;
160
- // this fallback keeps users who installed via the official installer working even when
161
- // `npm prune --production` removed those optional deps.
162
- sdkOptions.pathToClaudeCodeExecutable = process.env.CLAUDE_CLI_PATH || 'claude';
163
-
164
- // Map working directory
165
- if (cwd) {
166
- sdkOptions.cwd = cwd;
167
- }
168
-
169
- // Map permission mode
170
- if (permissionMode && permissionMode !== 'default') {
171
- sdkOptions.permissionMode = permissionMode;
172
- }
173
-
174
- // Map tool settings
175
- const settings = toolsSettings || {
176
- allowedTools: [],
177
- disallowedTools: [],
178
- skipPermissions: false
179
- };
180
-
181
- // Handle tool permissions
182
- if (settings.skipPermissions && permissionMode !== 'plan') {
183
- // When skipping permissions, use bypassPermissions mode
184
- sdkOptions.permissionMode = 'bypassPermissions';
185
- }
186
-
187
- let allowedTools = [...(settings.allowedTools || [])];
188
-
189
- // Add plan mode default tools
190
- if (permissionMode === 'plan') {
191
- const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch'];
192
- for (const tool of planModeTools) {
193
- if (!allowedTools.includes(tool)) {
194
- allowedTools.push(tool);
195
- }
196
- }
197
- }
198
-
199
- sdkOptions.allowedTools = allowedTools;
200
-
201
- // Use the tools preset to make all default built-in tools available (including AskUserQuestion).
202
- // This was introduced in SDK 0.1.57. Omitting this preserves existing behavior (all tools available),
203
- // but being explicit ensures forward compatibility and clarity.
204
- sdkOptions.tools = { type: 'preset', preset: 'claude_code' };
205
-
206
- sdkOptions.disallowedTools = settings.disallowedTools || [];
207
-
208
- // Map model (default to sonnet)
209
- // Valid models: sonnet, opus, haiku, opusplan, sonnet[1m]
210
- sdkOptions.model = options.model || CLAUDE_MODELS.DEFAULT;
211
- // Model logged at query start below
212
-
213
- // Map system prompt configuration
214
- sdkOptions.systemPrompt = {
215
- type: 'preset',
216
- preset: 'claude_code' // Required to use CLAUDE.md
217
- };
218
-
219
- // Map setting sources for CLAUDE.md loading
220
- // This loads CLAUDE.md from project, user (~/.config/claude/CLAUDE.md), and local directories
221
- sdkOptions.settingSources = ['project', 'user', 'local'];
222
-
223
- // Map resume session
224
- if (sessionId) {
225
- sdkOptions.resume = sessionId;
226
- }
227
-
228
- return sdkOptions;
229
- }
230
-
231
- /**
232
- * Adds a session to the active sessions map
233
- * @param {string} sessionId - Session identifier
234
- * @param {Object} queryInstance - SDK query instance
235
- * @param {Array<string>} tempImagePaths - Temp image file paths for cleanup
236
- * @param {string} tempDir - Temp directory for cleanup
237
- */
238
- function addSession(sessionId, queryInstance, tempImagePaths = [], tempDir = null, writer = null) {
239
- activeSessions.set(sessionId, {
240
- instance: queryInstance,
241
- startTime: Date.now(),
242
- status: 'active',
243
- tempImagePaths,
244
- tempDir,
245
- writer
246
- });
247
- }
248
-
249
- /**
250
- * Removes a session from the active sessions map
251
- * @param {string} sessionId - Session identifier
252
- */
253
- function removeSession(sessionId) {
254
- activeSessions.delete(sessionId);
255
- }
256
-
257
- /**
258
- * Gets a session from the active sessions map
259
- * @param {string} sessionId - Session identifier
260
- * @returns {Object|undefined} Session data or undefined
261
- */
262
- function getSession(sessionId) {
263
- return activeSessions.get(sessionId);
264
- }
265
-
266
- /**
267
- * Gets all active session IDs
268
- * @returns {Array<string>} Array of active session IDs
269
- */
270
- function getAllSessions() {
271
- return Array.from(activeSessions.keys());
272
- }
273
-
274
- /**
275
- * Transforms SDK messages to WebSocket format expected by frontend
276
- * @param {Object} sdkMessage - SDK message object
277
- * @returns {Object} Transformed message ready for WebSocket
278
- */
279
- function transformMessage(sdkMessage) {
280
- // Extract parent_tool_use_id for subagent tool grouping
281
- if (sdkMessage.parent_tool_use_id) {
282
- return {
283
- ...sdkMessage,
284
- parentToolUseId: sdkMessage.parent_tool_use_id
285
- };
286
- }
287
- return sdkMessage;
288
- }
289
-
290
- /**
291
- * Extracts token usage from SDK result messages
292
- * @param {Object} resultMessage - SDK result message
293
- * @returns {Object|null} Token budget object or null
294
- */
295
- function extractTokenBudget(resultMessage) {
296
- if (resultMessage.type !== 'result' || !resultMessage.modelUsage) {
297
- return null;
298
- }
299
-
300
- // Get the first model's usage data
301
- const modelKey = Object.keys(resultMessage.modelUsage)[0];
302
- const modelData = resultMessage.modelUsage[modelKey];
303
-
304
- if (!modelData) {
305
- return null;
306
- }
307
-
308
- // Use cumulative tokens if available (tracks total for the session)
309
- // Otherwise fall back to per-request tokens
310
- const inputTokens = modelData.cumulativeInputTokens || modelData.inputTokens || 0;
311
- const outputTokens = modelData.cumulativeOutputTokens || modelData.outputTokens || 0;
312
- const cacheReadTokens = modelData.cumulativeCacheReadInputTokens || modelData.cacheReadInputTokens || 0;
313
- const cacheCreationTokens = modelData.cumulativeCacheCreationInputTokens || modelData.cacheCreationInputTokens || 0;
314
-
315
- // Total used = input + output + cache tokens
316
- const totalUsed = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens;
317
-
318
- // Use configured context window budget from environment (default 160000)
319
- // This is the user's budget limit, not the model's context window
320
- const contextWindow = parseInt(process.env.CONTEXT_WINDOW) || 160000;
321
-
322
- // Token calc logged via token-budget WS event
323
-
324
- return {
325
- used: totalUsed,
326
- total: contextWindow
327
- };
328
- }
329
-
330
- /**
331
- * Handles image processing for SDK queries
332
- * Saves base64 images to temporary files and returns modified prompt with file paths
333
- * @param {string} command - Original user prompt
334
- * @param {Array} images - Array of image objects with base64 data
335
- * @param {string} cwd - Working directory for temp file creation
336
- * @returns {Promise<Object>} {modifiedCommand, tempImagePaths, tempDir}
337
- */
338
- async function handleImages(command, images, cwd) {
339
- const tempImagePaths = [];
340
- let tempDir = null;
341
-
342
- if (!images || images.length === 0) {
343
- return { modifiedCommand: command, tempImagePaths, tempDir };
344
- }
345
-
346
- try {
347
- // Create temp directory in the project directory
348
- const workingDir = cwd || process.cwd();
349
- tempDir = path.join(workingDir, '.tmp', 'images', Date.now().toString());
350
- await fs.mkdir(tempDir, { recursive: true });
351
-
352
- // Save each image to a temp file
353
- for (const [index, image] of images.entries()) {
354
- // Extract base64 data and mime type
355
- const matches = image.data.match(/^data:([^;]+);base64,(.+)$/);
356
- if (!matches) {
357
- console.error('Invalid image data format');
358
- continue;
359
- }
360
-
361
- const [, mimeType, base64Data] = matches;
362
- const extension = mimeType.split('/')[1] || 'png';
363
- const filename = `image_${index}.${extension}`;
364
- const filepath = path.join(tempDir, filename);
365
-
366
- // Write base64 data to file
367
- await fs.writeFile(filepath, Buffer.from(base64Data, 'base64'));
368
- tempImagePaths.push(filepath);
369
- }
370
-
371
- // Include the full image paths in the prompt
372
- let modifiedCommand = command;
373
- if (tempImagePaths.length > 0 && command && command.trim()) {
374
- const imageNote = `\n\n[Images provided at the following paths:]\n${tempImagePaths.map((p, i) => `${i + 1}. ${p}`).join('\n')}`;
375
- modifiedCommand = command + imageNote;
376
- }
377
-
378
- // Images processed
379
- return { modifiedCommand, tempImagePaths, tempDir };
380
- } catch (error) {
381
- console.error('Error processing images for SDK:', error);
382
- return { modifiedCommand: command, tempImagePaths, tempDir };
383
- }
384
- }
385
-
386
- /**
387
- * Cleans up temporary image files
388
- * @param {Array<string>} tempImagePaths - Array of temp file paths to delete
389
- * @param {string} tempDir - Temp directory to remove
390
- */
391
- async function cleanupTempFiles(tempImagePaths, tempDir) {
392
- if (!tempImagePaths || tempImagePaths.length === 0) {
393
- return;
394
- }
395
-
396
- try {
397
- // Delete individual temp files
398
- for (const imagePath of tempImagePaths) {
399
- await fs.unlink(imagePath).catch(err =>
400
- console.error(`Failed to delete temp image ${imagePath}:`, err)
401
- );
402
- }
403
-
404
- // Delete temp directory
405
- if (tempDir) {
406
- await fs.rm(tempDir, { recursive: true, force: true }).catch(err =>
407
- console.error(`Failed to delete temp directory ${tempDir}:`, err)
408
- );
409
- }
410
-
411
- // Temp files cleaned
412
- } catch (error) {
413
- console.error('Error during temp file cleanup:', error);
414
- }
415
- }
416
-
417
- /**
418
- * Loads MCP server configurations from ~/.claude.json
419
- * @param {string} cwd - Current working directory for project-specific configs
420
- * @returns {Object|null} MCP servers object or null if none found
421
- */
422
- async function loadMcpConfig(cwd) {
423
- try {
424
- const claudeConfigPath = path.join(os.homedir(), '.claude.json');
425
-
426
- // Check if config file exists
427
- try {
428
- await fs.access(claudeConfigPath);
429
- } catch (error) {
430
- // File doesn't exist, return null
431
- // No config file
432
- return null;
433
- }
434
-
435
- // Read and parse config file
436
- let claudeConfig;
437
- try {
438
- const configContent = await fs.readFile(claudeConfigPath, 'utf8');
439
- claudeConfig = JSON.parse(configContent);
440
- } catch (error) {
441
- console.error('Failed to parse ~/.claude.json:', error.message);
442
- return null;
443
- }
444
-
445
- // Extract MCP servers (merge global and project-specific)
446
- let mcpServers = {};
447
-
448
- // Add global MCP servers
449
- if (claudeConfig.mcpServers && typeof claudeConfig.mcpServers === 'object') {
450
- mcpServers = { ...claudeConfig.mcpServers };
451
- // Global MCP servers loaded
452
- }
453
-
454
- // Add/override with project-specific MCP servers
455
- if (claudeConfig.claudeProjects && cwd) {
456
- const projectConfig = claudeConfig.claudeProjects[cwd];
457
- if (projectConfig && projectConfig.mcpServers && typeof projectConfig.mcpServers === 'object') {
458
- mcpServers = { ...mcpServers, ...projectConfig.mcpServers };
459
- // Project MCP servers merged
460
- }
461
- }
462
-
463
- // Return null if no servers found
464
- if (Object.keys(mcpServers).length === 0) {
465
- return null;
466
- }
467
- return mcpServers;
468
- } catch (error) {
469
- console.error('Error loading MCP config:', error.message);
470
- return null;
471
- }
472
- }
473
-
474
- /**
475
- * Executes a Claude query using the SDK
476
- * @param {string} command - User prompt/command
477
- * @param {Object} options - Query options
478
- * @param {Object} ws - WebSocket connection
479
- * @returns {Promise<void>}
480
- */
481
- async function queryClaudeSDK(command, options = {}, ws) {
482
- const { sessionId, sessionSummary } = options;
483
- let capturedSessionId = sessionId;
484
- let sessionCreatedSent = false;
485
- let tempImagePaths = [];
486
- let tempDir = null;
487
-
488
- const emitNotification = (event) => {
489
- notifyUserIfEnabled({
490
- userId: ws?.userId || null,
491
- writer: ws,
492
- event
493
- });
494
- };
495
-
496
- try {
497
- // Map CLI options to SDK format
498
- const sdkOptions = mapCliOptionsToSDK(options);
499
-
500
- // Load MCP configuration
501
- const mcpServers = await loadMcpConfig(options.cwd);
502
- if (mcpServers) {
503
- sdkOptions.mcpServers = mcpServers;
504
- }
505
-
506
- // Handle images - save to temp files and modify prompt
507
- const imageResult = await handleImages(command, options.images, options.cwd);
508
- const finalCommand = imageResult.modifiedCommand;
509
- tempImagePaths = imageResult.tempImagePaths;
510
- tempDir = imageResult.tempDir;
511
-
512
- sdkOptions.hooks = {
513
- Notification: [{
514
- matcher: '',
515
- hooks: [async (input) => {
516
- const message = typeof input?.message === 'string' ? input.message : 'Claude requires your attention.';
517
- emitNotification(createNotificationEvent({
518
- provider: 'claude',
519
- sessionId: capturedSessionId || sessionId || null,
520
- kind: 'action_required',
521
- code: 'agent.notification',
522
- meta: { message, sessionName: sessionSummary },
523
- severity: 'warning',
524
- requiresUserAction: true,
525
- dedupeKey: `claude:hook:notification:${capturedSessionId || sessionId || 'none'}:${message}`
526
- }));
527
- return {};
528
- }]
529
- }]
530
- };
531
-
532
- sdkOptions.canUseTool = async (toolName, input, context) => {
533
- const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName);
534
-
535
- if (!requiresInteraction) {
536
- if (sdkOptions.permissionMode === 'bypassPermissions') {
537
- return { behavior: 'allow', updatedInput: input };
538
- }
539
-
540
- const isDisallowed = (sdkOptions.disallowedTools || []).some(entry =>
541
- matchesToolPermission(entry, toolName, input)
542
- );
543
- if (isDisallowed) {
544
- return { behavior: 'deny', message: 'Tool disallowed by settings' };
545
- }
546
-
547
- const isAllowed = (sdkOptions.allowedTools || []).some(entry =>
548
- matchesToolPermission(entry, toolName, input)
549
- );
550
- if (isAllowed) {
551
- return { behavior: 'allow', updatedInput: input };
552
- }
553
- }
554
-
555
- const requestId = createRequestId();
556
- ws.send(createNormalizedMessage({ kind: 'permission_request', requestId, toolName, input, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
557
- emitNotification(createNotificationEvent({
558
- provider: 'claude',
559
- sessionId: capturedSessionId || sessionId || null,
560
- kind: 'action_required',
561
- code: 'permission.required',
562
- meta: { toolName, sessionName: sessionSummary },
563
- severity: 'warning',
564
- requiresUserAction: true,
565
- dedupeKey: `claude:permission:${capturedSessionId || sessionId || 'none'}:${requestId}`
566
- }));
567
-
568
- const decision = await waitForToolApproval(requestId, {
569
- timeoutMs: requiresInteraction ? 0 : undefined,
570
- signal: context?.signal,
571
- metadata: {
572
- _sessionId: capturedSessionId || sessionId || null,
573
- _toolName: toolName,
574
- _input: input,
575
- _receivedAt: new Date(),
576
- },
577
- onCancel: (reason) => {
578
- ws.send(createNormalizedMessage({ kind: 'permission_cancelled', requestId, reason, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
579
- }
580
- });
581
- if (!decision) {
582
- return { behavior: 'deny', message: 'Permission request timed out' };
583
- }
584
-
585
- if (decision.cancelled) {
586
- return { behavior: 'deny', message: 'Permission request cancelled' };
587
- }
588
-
589
- if (decision.allow) {
590
- if (decision.rememberEntry && typeof decision.rememberEntry === 'string') {
591
- if (!sdkOptions.allowedTools.includes(decision.rememberEntry)) {
592
- sdkOptions.allowedTools.push(decision.rememberEntry);
593
- }
594
- if (Array.isArray(sdkOptions.disallowedTools)) {
595
- sdkOptions.disallowedTools = sdkOptions.disallowedTools.filter(entry => entry !== decision.rememberEntry);
596
- }
597
- }
598
- return { behavior: 'allow', updatedInput: decision.updatedInput ?? input };
599
- }
600
-
601
- return { behavior: 'deny', message: decision.message ?? 'User denied tool use' };
602
- };
603
-
604
- // Set stream-close timeout for interactive tools (Query constructor reads it synchronously). Claude Agent SDK has a default of 5s and this overrides it
605
- const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
606
- process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000';
607
-
608
- let queryInstance;
609
- try {
610
- queryInstance = query({
611
- prompt: finalCommand,
612
- options: sdkOptions
613
- });
614
- } catch (hookError) {
615
- // Older/newer SDK versions may not accept hook shapes yet.
616
- // Keep notification behavior operational via runtime events even if hook registration fails.
617
- console.warn('Failed to initialize Claude query with hooks, retrying without hooks:', hookError?.message || hookError);
618
- delete sdkOptions.hooks;
619
- queryInstance = query({
620
- prompt: finalCommand,
621
- options: sdkOptions
622
- });
623
- }
624
-
625
- // Restore immediately — Query constructor already captured the value
626
- if (prevStreamTimeout !== undefined) {
627
- process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = prevStreamTimeout;
628
- } else {
629
- delete process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
630
- }
631
-
632
- // Track the query instance for abort capability
633
- if (capturedSessionId) {
634
- addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, ws);
635
- }
636
-
637
- // Process streaming messages
638
- console.log('Starting async generator loop for session:', capturedSessionId || 'NEW');
639
- for await (const message of queryInstance) {
640
- // Capture session ID from first message
641
- if (message.session_id && !capturedSessionId) {
642
-
643
- capturedSessionId = message.session_id;
644
- addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, ws);
645
-
646
- // Set session ID on writer
647
- if (ws.setSessionId && typeof ws.setSessionId === 'function') {
648
- ws.setSessionId(capturedSessionId);
649
- }
650
-
651
- // Send session-created event only once for new sessions
652
- if (!sessionId && !sessionCreatedSent) {
653
- sessionCreatedSent = true;
654
- ws.send(createNormalizedMessage({ kind: 'session_created', newSessionId: capturedSessionId, sessionId: capturedSessionId, provider: 'claude' }));
655
- }
656
- } else {
657
- // session_id already captured
658
- }
659
-
660
- // Transform and normalize message via adapter
661
- const transformedMessage = transformMessage(message);
662
- const sid = capturedSessionId || sessionId || null;
663
-
664
- // Use adapter to normalize SDK events into NormalizedMessage[]
665
- const normalized = sessionsService.normalizeMessage('claude', transformedMessage, sid);
666
- for (const msg of normalized) {
667
- // Preserve parentToolUseId from SDK wrapper for subagent tool grouping
668
- if (transformedMessage.parentToolUseId && !msg.parentToolUseId) {
669
- msg.parentToolUseId = transformedMessage.parentToolUseId;
670
- }
671
- ws.send(msg);
672
- }
673
-
674
- // Extract and send token budget updates from result messages
675
- if (message.type === 'result') {
676
- const models = Object.keys(message.modelUsage || {});
677
- if (models.length > 0) {
678
- // Model info available in result message
679
- }
680
- const tokenBudgetData = extractTokenBudget(message);
681
- if (tokenBudgetData) {
682
- ws.send(createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: tokenBudgetData, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
683
- }
684
- }
685
- }
686
-
687
- // Clean up session on completion
688
- if (capturedSessionId) {
689
- removeSession(capturedSessionId);
690
- }
691
-
692
- // Clean up temporary image files
693
- await cleanupTempFiles(tempImagePaths, tempDir);
694
-
695
- // Send completion event
696
- ws.send(createNormalizedMessage({ kind: 'complete', exitCode: 0, isNewSession: !sessionId && !!command, sessionId: capturedSessionId, provider: 'claude' }));
697
- notifyRunStopped({
698
- userId: ws?.userId || null,
699
- provider: 'claude',
700
- sessionId: capturedSessionId || sessionId || null,
701
- sessionName: sessionSummary,
702
- stopReason: 'completed'
703
- });
704
- // Complete
705
-
706
- } catch (error) {
707
- console.error('SDK query error:', error);
708
-
709
- // Clean up session on error
710
- if (capturedSessionId) {
711
- removeSession(capturedSessionId);
712
- }
713
-
714
- // Clean up temporary image files on error
715
- await cleanupTempFiles(tempImagePaths, tempDir);
716
-
717
- // Check if Claude CLI is installed for a clearer error message
718
- const installed = await providerAuthService.isProviderInstalled('claude');
719
- const errorContent = !installed
720
- ? 'Claude Code is not installed. Please install it first: https://docs.anthropic.com/en/docs/claude-code'
721
- : error.message;
722
-
723
- // Send error to WebSocket
724
- ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
725
- notifyRunFailed({
726
- userId: ws?.userId || null,
727
- provider: 'claude',
728
- sessionId: capturedSessionId || sessionId || null,
729
- sessionName: sessionSummary,
730
- error
731
- });
732
- }
733
- }
734
-
735
- /**
736
- * Aborts an active SDK session
737
- * @param {string} sessionId - Session identifier
738
- * @returns {boolean} True if session was aborted, false if not found
739
- */
740
- async function abortClaudeSDKSession(sessionId) {
741
- const session = getSession(sessionId);
742
-
743
- if (!session) {
744
- console.log(`Session ${sessionId} not found`);
745
- return false;
746
- }
747
-
748
- try {
749
- console.log(`Aborting SDK session: ${sessionId}`);
750
-
751
- // Call interrupt() on the query instance
752
- await session.instance.interrupt();
753
-
754
- // Update session status
755
- session.status = 'aborted';
756
-
757
- // Clean up temporary image files
758
- await cleanupTempFiles(session.tempImagePaths, session.tempDir);
759
-
760
- // Clean up session
761
- removeSession(sessionId);
762
-
763
- return true;
764
- } catch (error) {
765
- console.error(`Error aborting session ${sessionId}:`, error);
766
- return false;
767
- }
768
- }
769
-
770
- /**
771
- * Checks if an SDK session is currently active
772
- * @param {string} sessionId - Session identifier
773
- * @returns {boolean} True if session is active
774
- */
775
- function isClaudeSDKSessionActive(sessionId) {
776
- const session = getSession(sessionId);
777
- return session && session.status === 'active';
778
- }
779
-
780
- /**
781
- * Gets all active SDK session IDs
782
- * @returns {Array<string>} Array of active session IDs
783
- */
784
- function getActiveClaudeSDKSessions() {
785
- return getAllSessions();
786
- }
787
-
788
- /**
789
- * Get pending tool approvals for a specific session.
790
- * @param {string} sessionId - The session ID
791
- * @returns {Array} Array of pending permission request objects
792
- */
793
- function getPendingApprovalsForSession(sessionId) {
794
- const pending = [];
795
- for (const [requestId, resolver] of pendingToolApprovals.entries()) {
796
- if (resolver._sessionId === sessionId) {
797
- pending.push({
798
- requestId,
799
- toolName: resolver._toolName || 'UnknownTool',
800
- input: resolver._input,
801
- context: resolver._context,
802
- sessionId,
803
- receivedAt: resolver._receivedAt || new Date(),
804
- });
805
- }
806
- }
807
- return pending;
808
- }
809
-
810
- /**
811
- * Reconnect a session's WebSocketWriter to a new raw WebSocket.
812
- * Called when client reconnects (e.g. page refresh) while SDK is still running.
813
- * @param {string} sessionId - The session ID
814
- * @param {Object} newRawWs - The new raw WebSocket connection
815
- * @returns {boolean} True if writer was successfully reconnected
816
- */
817
- function reconnectSessionWriter(sessionId, newRawWs) {
818
- const session = getSession(sessionId);
819
- if (!session?.writer?.updateWebSocket) return false;
820
- session.writer.updateWebSocket(newRawWs);
821
- console.log(`[RECONNECT] Writer swapped for session ${sessionId}`);
822
- return true;
823
- }
824
-
825
- // Export public API
826
- export {
827
- queryClaudeSDK,
828
- abortClaudeSDKSession,
829
- isClaudeSDKSessionActive,
830
- getActiveClaudeSDKSessions,
831
- resolveToolApproval,
832
- getPendingApprovalsForSession,
833
- reconnectSessionWriter
834
- };
1
+ /**
2
+ * Claude SDK Integration
3
+ *
4
+ * This module provides SDK-based integration with Claude using the @anthropic-ai/claude-agent-sdk.
5
+ * It mirrors the interface of claude-cli.js but uses the SDK internally for better performance
6
+ * and maintainability.
7
+ *
8
+ * Key features:
9
+ * - Direct SDK integration without child processes
10
+ * - Session management with abort capability
11
+ * - Options mapping between CLI and SDK formats
12
+ * - WebSocket message streaming
13
+ */
14
+
15
+ import { query } from '@anthropic-ai/claude-agent-sdk';
16
+ import crypto from 'crypto';
17
+ import { promises as fs } from 'fs';
18
+ import path from 'path';
19
+ import os from 'os';
20
+ import { CLAUDE_MODELS } from '../shared/modelConstants.js';
21
+ import {
22
+ createNotificationEvent,
23
+ notifyRunFailed,
24
+ notifyRunStopped,
25
+ notifyUserIfEnabled
26
+ } from './services/notification-orchestrator.js';
27
+ import { sessionsService } from './modules/providers/services/sessions.service.js';
28
+ import { providerAuthService } from './modules/providers/services/provider-auth.service.js';
29
+ import { createNormalizedMessage } from './shared/utils.js';
30
+
31
+ const activeSessions = new Map();
32
+ const pendingToolApprovals = new Map();
33
+
34
+ const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
35
+
36
+ const TOOLS_REQUIRING_INTERACTION = new Set(['AskUserQuestion', 'ExitPlanMode']);
37
+
38
+ function createRequestId() {
39
+ if (typeof crypto.randomUUID === 'function') {
40
+ return crypto.randomUUID();
41
+ }
42
+ return crypto.randomBytes(16).toString('hex');
43
+ }
44
+
45
+ function waitForToolApproval(requestId, options = {}) {
46
+ const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel, metadata } = options;
47
+
48
+ return new Promise(resolve => {
49
+ let settled = false;
50
+
51
+ const finalize = (decision) => {
52
+ if (settled) return;
53
+ settled = true;
54
+ cleanup();
55
+ resolve(decision);
56
+ };
57
+
58
+ let timeout;
59
+
60
+ const cleanup = () => {
61
+ pendingToolApprovals.delete(requestId);
62
+ if (timeout) clearTimeout(timeout);
63
+ if (signal && abortHandler) {
64
+ signal.removeEventListener('abort', abortHandler);
65
+ }
66
+ };
67
+
68
+ // timeoutMs 0 = wait indefinitely (interactive tools)
69
+ if (timeoutMs > 0) {
70
+ timeout = setTimeout(() => {
71
+ onCancel?.('timeout');
72
+ finalize(null);
73
+ }, timeoutMs);
74
+ }
75
+
76
+ const abortHandler = () => {
77
+ onCancel?.('cancelled');
78
+ finalize({ cancelled: true });
79
+ };
80
+
81
+ if (signal) {
82
+ if (signal.aborted) {
83
+ onCancel?.('cancelled');
84
+ finalize({ cancelled: true });
85
+ return;
86
+ }
87
+ signal.addEventListener('abort', abortHandler, { once: true });
88
+ }
89
+
90
+ const resolver = (decision) => {
91
+ finalize(decision);
92
+ };
93
+ // Attach metadata for getPendingApprovalsForSession lookup
94
+ if (metadata) {
95
+ Object.assign(resolver, metadata);
96
+ }
97
+ pendingToolApprovals.set(requestId, resolver);
98
+ });
99
+ }
100
+
101
+ function resolveToolApproval(requestId, decision) {
102
+ const resolver = pendingToolApprovals.get(requestId);
103
+ if (resolver) {
104
+ resolver(decision);
105
+ }
106
+ }
107
+
108
+ // Match stored permission entries against a tool + input combo.
109
+ // This only supports exact tool names and the Bash(command:*) shorthand
110
+ // used by the UI; it intentionally does not implement full glob semantics,
111
+ // introduced to stay consistent with the UI's "Allow rule" format.
112
+ function matchesToolPermission(entry, toolName, input) {
113
+ if (!entry || !toolName) {
114
+ return false;
115
+ }
116
+
117
+ if (entry === toolName) {
118
+ return true;
119
+ }
120
+
121
+ const bashMatch = entry.match(/^Bash\((.+):\*\)$/);
122
+ if (toolName === 'Bash' && bashMatch) {
123
+ const allowedPrefix = bashMatch[1];
124
+ let command = '';
125
+
126
+ if (typeof input === 'string') {
127
+ command = input.trim();
128
+ } else if (input && typeof input === 'object' && typeof input.command === 'string') {
129
+ command = input.command.trim();
130
+ }
131
+
132
+ if (!command) {
133
+ return false;
134
+ }
135
+
136
+ return command.startsWith(allowedPrefix);
137
+ }
138
+
139
+ return false;
140
+ }
141
+
142
+ /**
143
+ * Maps CLI options to SDK-compatible options format
144
+ * @param {Object} options - CLI options
145
+ * @returns {Object} SDK-compatible options
146
+ */
147
+ function mapCliOptionsToSDK(options = {}) {
148
+ const { sessionId, cwd, toolsSettings, permissionMode } = options;
149
+
150
+ const sdkOptions = {};
151
+
152
+ // Forward all host env vars (e.g. ANTHROPIC_BASE_URL, ANTHROPIC_AUTH_TOKEN) to the subprocess.
153
+ // Since claude-agent-sdk 0.2.113+, options.env REPLACES process.env in the subprocess
154
+ // instead of overlaying it. Without spreading process.env here, users who rely on
155
+ // ANTHROPIC_BASE_URL, HTTP(S)_PROXY, etc. would silently lose those settings.
156
+ sdkOptions.env = { ...process.env };
157
+
158
+ // Use CLAUDE_CLI_PATH if explicitly set, otherwise fall back to the "claude" binary on PATH.
159
+ // The SDK (>=0.2.113) looks for a bundled native binary as an optional dependency by default;
160
+ // this fallback keeps users who installed via the official installer working even when
161
+ // `npm prune --production` removed those optional deps.
162
+ sdkOptions.pathToClaudeCodeExecutable = process.env.CLAUDE_CLI_PATH || 'claude';
163
+
164
+ // Map working directory
165
+ if (cwd) {
166
+ sdkOptions.cwd = cwd;
167
+ }
168
+
169
+ // Map permission mode
170
+ if (permissionMode && permissionMode !== 'default') {
171
+ sdkOptions.permissionMode = permissionMode;
172
+ }
173
+
174
+ // Map tool settings
175
+ const settings = toolsSettings || {
176
+ allowedTools: [],
177
+ disallowedTools: [],
178
+ skipPermissions: false
179
+ };
180
+
181
+ // Handle tool permissions
182
+ if (settings.skipPermissions && permissionMode !== 'plan') {
183
+ // When skipping permissions, use bypassPermissions mode
184
+ sdkOptions.permissionMode = 'bypassPermissions';
185
+ }
186
+
187
+ let allowedTools = [...(settings.allowedTools || [])];
188
+
189
+ // Add plan mode default tools
190
+ if (permissionMode === 'plan') {
191
+ const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch'];
192
+ for (const tool of planModeTools) {
193
+ if (!allowedTools.includes(tool)) {
194
+ allowedTools.push(tool);
195
+ }
196
+ }
197
+ }
198
+
199
+ sdkOptions.allowedTools = allowedTools;
200
+
201
+ // Use the tools preset to make all default built-in tools available (including AskUserQuestion).
202
+ // This was introduced in SDK 0.1.57. Omitting this preserves existing behavior (all tools available),
203
+ // but being explicit ensures forward compatibility and clarity.
204
+ sdkOptions.tools = { type: 'preset', preset: 'claude_code' };
205
+
206
+ sdkOptions.disallowedTools = settings.disallowedTools || [];
207
+
208
+ // Map model (default to sonnet)
209
+ // Valid models: sonnet, opus, haiku, opusplan, sonnet[1m]
210
+ sdkOptions.model = options.model || CLAUDE_MODELS.DEFAULT;
211
+ // Model logged at query start below
212
+
213
+ // Map system prompt configuration
214
+ sdkOptions.systemPrompt = {
215
+ type: 'preset',
216
+ preset: 'claude_code' // Required to use CLAUDE.md
217
+ };
218
+
219
+ // Map setting sources for CLAUDE.md loading
220
+ // This loads CLAUDE.md from project, user (~/.config/claude/CLAUDE.md), and local directories
221
+ sdkOptions.settingSources = ['project', 'user', 'local'];
222
+
223
+ // Map resume session
224
+ if (sessionId) {
225
+ sdkOptions.resume = sessionId;
226
+ }
227
+
228
+ return sdkOptions;
229
+ }
230
+
231
+ /**
232
+ * Adds a session to the active sessions map
233
+ * @param {string} sessionId - Session identifier
234
+ * @param {Object} queryInstance - SDK query instance
235
+ * @param {Array<string>} tempImagePaths - Temp image file paths for cleanup
236
+ * @param {string} tempDir - Temp directory for cleanup
237
+ */
238
+ function addSession(sessionId, queryInstance, tempImagePaths = [], tempDir = null, writer = null) {
239
+ activeSessions.set(sessionId, {
240
+ instance: queryInstance,
241
+ startTime: Date.now(),
242
+ status: 'active',
243
+ tempImagePaths,
244
+ tempDir,
245
+ writer
246
+ });
247
+ }
248
+
249
+ /**
250
+ * Removes a session from the active sessions map
251
+ * @param {string} sessionId - Session identifier
252
+ */
253
+ function removeSession(sessionId) {
254
+ activeSessions.delete(sessionId);
255
+ }
256
+
257
+ /**
258
+ * Gets a session from the active sessions map
259
+ * @param {string} sessionId - Session identifier
260
+ * @returns {Object|undefined} Session data or undefined
261
+ */
262
+ function getSession(sessionId) {
263
+ return activeSessions.get(sessionId);
264
+ }
265
+
266
+ /**
267
+ * Gets all active session IDs
268
+ * @returns {Array<string>} Array of active session IDs
269
+ */
270
+ function getAllSessions() {
271
+ return Array.from(activeSessions.keys());
272
+ }
273
+
274
+ /**
275
+ * Transforms SDK messages to WebSocket format expected by frontend
276
+ * @param {Object} sdkMessage - SDK message object
277
+ * @returns {Object} Transformed message ready for WebSocket
278
+ */
279
+ function transformMessage(sdkMessage) {
280
+ // Extract parent_tool_use_id for subagent tool grouping
281
+ if (sdkMessage.parent_tool_use_id) {
282
+ return {
283
+ ...sdkMessage,
284
+ parentToolUseId: sdkMessage.parent_tool_use_id
285
+ };
286
+ }
287
+ return sdkMessage;
288
+ }
289
+
290
+ /**
291
+ * Extracts token usage from SDK result messages
292
+ * @param {Object} resultMessage - SDK result message
293
+ * @returns {Object|null} Token budget object or null
294
+ */
295
+ function extractTokenBudget(resultMessage) {
296
+ if (resultMessage.type !== 'result' || !resultMessage.modelUsage) {
297
+ return null;
298
+ }
299
+
300
+ // Get the first model's usage data
301
+ const modelKey = Object.keys(resultMessage.modelUsage)[0];
302
+ const modelData = resultMessage.modelUsage[modelKey];
303
+
304
+ if (!modelData) {
305
+ return null;
306
+ }
307
+
308
+ // Use cumulative tokens if available (tracks total for the session)
309
+ // Otherwise fall back to per-request tokens
310
+ const inputTokens = modelData.cumulativeInputTokens || modelData.inputTokens || 0;
311
+ const outputTokens = modelData.cumulativeOutputTokens || modelData.outputTokens || 0;
312
+ const cacheReadTokens = modelData.cumulativeCacheReadInputTokens || modelData.cacheReadInputTokens || 0;
313
+ const cacheCreationTokens = modelData.cumulativeCacheCreationInputTokens || modelData.cacheCreationInputTokens || 0;
314
+
315
+ // Total used = input + output + cache tokens
316
+ const totalUsed = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens;
317
+
318
+ // Use configured context window budget from environment (default 160000)
319
+ // This is the user's budget limit, not the model's context window
320
+ const contextWindow = parseInt(process.env.CONTEXT_WINDOW) || 160000;
321
+
322
+ // Token calc logged via token-budget WS event
323
+
324
+ return {
325
+ used: totalUsed,
326
+ total: contextWindow
327
+ };
328
+ }
329
+
330
+ /**
331
+ * Handles image processing for SDK queries
332
+ * Saves base64 images to temporary files and returns modified prompt with file paths
333
+ * @param {string} command - Original user prompt
334
+ * @param {Array} images - Array of image objects with base64 data
335
+ * @param {string} cwd - Working directory for temp file creation
336
+ * @returns {Promise<Object>} {modifiedCommand, tempImagePaths, tempDir}
337
+ */
338
+ async function handleImages(command, images, cwd) {
339
+ const tempImagePaths = [];
340
+ let tempDir = null;
341
+
342
+ if (!images || images.length === 0) {
343
+ return { modifiedCommand: command, tempImagePaths, tempDir };
344
+ }
345
+
346
+ try {
347
+ // Create temp directory in the project directory
348
+ const workingDir = cwd || process.cwd();
349
+ tempDir = path.join(workingDir, '.tmp', 'images', Date.now().toString());
350
+ await fs.mkdir(tempDir, { recursive: true });
351
+
352
+ // Save each image to a temp file
353
+ for (const [index, image] of images.entries()) {
354
+ // Extract base64 data and mime type
355
+ const matches = image.data.match(/^data:([^;]+);base64,(.+)$/);
356
+ if (!matches) {
357
+ console.error('Invalid image data format');
358
+ continue;
359
+ }
360
+
361
+ const [, mimeType, base64Data] = matches;
362
+ const extension = mimeType.split('/')[1] || 'png';
363
+ const filename = `image_${index}.${extension}`;
364
+ const filepath = path.join(tempDir, filename);
365
+
366
+ // Write base64 data to file
367
+ await fs.writeFile(filepath, Buffer.from(base64Data, 'base64'));
368
+ tempImagePaths.push(filepath);
369
+ }
370
+
371
+ // Include the full image paths in the prompt
372
+ let modifiedCommand = command;
373
+ if (tempImagePaths.length > 0 && command && command.trim()) {
374
+ const imageNote = `\n\n[Images provided at the following paths:]\n${tempImagePaths.map((p, i) => `${i + 1}. ${p}`).join('\n')}`;
375
+ modifiedCommand = command + imageNote;
376
+ }
377
+
378
+ // Images processed
379
+ return { modifiedCommand, tempImagePaths, tempDir };
380
+ } catch (error) {
381
+ console.error('Error processing images for SDK:', error);
382
+ return { modifiedCommand: command, tempImagePaths, tempDir };
383
+ }
384
+ }
385
+
386
+ /**
387
+ * Cleans up temporary image files
388
+ * @param {Array<string>} tempImagePaths - Array of temp file paths to delete
389
+ * @param {string} tempDir - Temp directory to remove
390
+ */
391
+ async function cleanupTempFiles(tempImagePaths, tempDir) {
392
+ if (!tempImagePaths || tempImagePaths.length === 0) {
393
+ return;
394
+ }
395
+
396
+ try {
397
+ // Delete individual temp files
398
+ for (const imagePath of tempImagePaths) {
399
+ await fs.unlink(imagePath).catch(err =>
400
+ console.error(`Failed to delete temp image ${imagePath}:`, err)
401
+ );
402
+ }
403
+
404
+ // Delete temp directory
405
+ if (tempDir) {
406
+ await fs.rm(tempDir, { recursive: true, force: true }).catch(err =>
407
+ console.error(`Failed to delete temp directory ${tempDir}:`, err)
408
+ );
409
+ }
410
+
411
+ // Temp files cleaned
412
+ } catch (error) {
413
+ console.error('Error during temp file cleanup:', error);
414
+ }
415
+ }
416
+
417
+ /**
418
+ * Loads MCP server configurations from ~/.claude.json
419
+ * @param {string} cwd - Current working directory for project-specific configs
420
+ * @returns {Object|null} MCP servers object or null if none found
421
+ */
422
+ async function loadMcpConfig(cwd) {
423
+ try {
424
+ const claudeConfigPath = path.join(os.homedir(), '.claude.json');
425
+
426
+ // Check if config file exists
427
+ try {
428
+ await fs.access(claudeConfigPath);
429
+ } catch (error) {
430
+ // File doesn't exist, return null
431
+ // No config file
432
+ return null;
433
+ }
434
+
435
+ // Read and parse config file
436
+ let claudeConfig;
437
+ try {
438
+ const configContent = await fs.readFile(claudeConfigPath, 'utf8');
439
+ claudeConfig = JSON.parse(configContent);
440
+ } catch (error) {
441
+ console.error('Failed to parse ~/.claude.json:', error.message);
442
+ return null;
443
+ }
444
+
445
+ // Extract MCP servers (merge global and project-specific)
446
+ let mcpServers = {};
447
+
448
+ // Add global MCP servers
449
+ if (claudeConfig.mcpServers && typeof claudeConfig.mcpServers === 'object') {
450
+ mcpServers = { ...claudeConfig.mcpServers };
451
+ // Global MCP servers loaded
452
+ }
453
+
454
+ // Add/override with project-specific MCP servers
455
+ if (claudeConfig.claudeProjects && cwd) {
456
+ const projectConfig = claudeConfig.claudeProjects[cwd];
457
+ if (projectConfig && projectConfig.mcpServers && typeof projectConfig.mcpServers === 'object') {
458
+ mcpServers = { ...mcpServers, ...projectConfig.mcpServers };
459
+ // Project MCP servers merged
460
+ }
461
+ }
462
+
463
+ // Return null if no servers found
464
+ if (Object.keys(mcpServers).length === 0) {
465
+ return null;
466
+ }
467
+ return mcpServers;
468
+ } catch (error) {
469
+ console.error('Error loading MCP config:', error.message);
470
+ return null;
471
+ }
472
+ }
473
+
474
+ /**
475
+ * Executes a Claude query using the SDK
476
+ * @param {string} command - User prompt/command
477
+ * @param {Object} options - Query options
478
+ * @param {Object} ws - WebSocket connection
479
+ * @returns {Promise<void>}
480
+ */
481
+ async function queryClaudeSDK(command, options = {}, ws) {
482
+ const { sessionId, sessionSummary } = options;
483
+ let capturedSessionId = sessionId;
484
+ let sessionCreatedSent = false;
485
+ let tempImagePaths = [];
486
+ let tempDir = null;
487
+
488
+ const emitNotification = (event) => {
489
+ notifyUserIfEnabled({
490
+ userId: ws?.userId || null,
491
+ writer: ws,
492
+ event
493
+ });
494
+ };
495
+
496
+ try {
497
+ // Map CLI options to SDK format
498
+ const sdkOptions = mapCliOptionsToSDK(options);
499
+
500
+ // Load MCP configuration
501
+ const mcpServers = await loadMcpConfig(options.cwd);
502
+ if (mcpServers) {
503
+ sdkOptions.mcpServers = mcpServers;
504
+ }
505
+
506
+ // Handle images - save to temp files and modify prompt
507
+ const imageResult = await handleImages(command, options.images, options.cwd);
508
+ const finalCommand = imageResult.modifiedCommand;
509
+ tempImagePaths = imageResult.tempImagePaths;
510
+ tempDir = imageResult.tempDir;
511
+
512
+ sdkOptions.hooks = {
513
+ Notification: [{
514
+ matcher: '',
515
+ hooks: [async (input) => {
516
+ const message = typeof input?.message === 'string' ? input.message : 'Claude requires your attention.';
517
+ emitNotification(createNotificationEvent({
518
+ provider: 'claude',
519
+ sessionId: capturedSessionId || sessionId || null,
520
+ kind: 'action_required',
521
+ code: 'agent.notification',
522
+ meta: { message, sessionName: sessionSummary },
523
+ severity: 'warning',
524
+ requiresUserAction: true,
525
+ dedupeKey: `claude:hook:notification:${capturedSessionId || sessionId || 'none'}:${message}`
526
+ }));
527
+ return {};
528
+ }]
529
+ }]
530
+ };
531
+
532
+ sdkOptions.canUseTool = async (toolName, input, context) => {
533
+ const requiresInteraction = TOOLS_REQUIRING_INTERACTION.has(toolName);
534
+
535
+ if (!requiresInteraction) {
536
+ if (sdkOptions.permissionMode === 'bypassPermissions') {
537
+ return { behavior: 'allow', updatedInput: input };
538
+ }
539
+
540
+ const isDisallowed = (sdkOptions.disallowedTools || []).some(entry =>
541
+ matchesToolPermission(entry, toolName, input)
542
+ );
543
+ if (isDisallowed) {
544
+ return { behavior: 'deny', message: 'Tool disallowed by settings' };
545
+ }
546
+
547
+ const isAllowed = (sdkOptions.allowedTools || []).some(entry =>
548
+ matchesToolPermission(entry, toolName, input)
549
+ );
550
+ if (isAllowed) {
551
+ return { behavior: 'allow', updatedInput: input };
552
+ }
553
+ }
554
+
555
+ const requestId = createRequestId();
556
+ ws.send(createNormalizedMessage({ kind: 'permission_request', requestId, toolName, input, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
557
+ emitNotification(createNotificationEvent({
558
+ provider: 'claude',
559
+ sessionId: capturedSessionId || sessionId || null,
560
+ kind: 'action_required',
561
+ code: 'permission.required',
562
+ meta: { toolName, sessionName: sessionSummary },
563
+ severity: 'warning',
564
+ requiresUserAction: true,
565
+ dedupeKey: `claude:permission:${capturedSessionId || sessionId || 'none'}:${requestId}`
566
+ }));
567
+
568
+ const decision = await waitForToolApproval(requestId, {
569
+ timeoutMs: requiresInteraction ? 0 : undefined,
570
+ signal: context?.signal,
571
+ metadata: {
572
+ _sessionId: capturedSessionId || sessionId || null,
573
+ _toolName: toolName,
574
+ _input: input,
575
+ _receivedAt: new Date(),
576
+ },
577
+ onCancel: (reason) => {
578
+ ws.send(createNormalizedMessage({ kind: 'permission_cancelled', requestId, reason, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
579
+ }
580
+ });
581
+ if (!decision) {
582
+ return { behavior: 'deny', message: 'Permission request timed out' };
583
+ }
584
+
585
+ if (decision.cancelled) {
586
+ return { behavior: 'deny', message: 'Permission request cancelled' };
587
+ }
588
+
589
+ if (decision.allow) {
590
+ if (decision.rememberEntry && typeof decision.rememberEntry === 'string') {
591
+ if (!sdkOptions.allowedTools.includes(decision.rememberEntry)) {
592
+ sdkOptions.allowedTools.push(decision.rememberEntry);
593
+ }
594
+ if (Array.isArray(sdkOptions.disallowedTools)) {
595
+ sdkOptions.disallowedTools = sdkOptions.disallowedTools.filter(entry => entry !== decision.rememberEntry);
596
+ }
597
+ }
598
+ return { behavior: 'allow', updatedInput: decision.updatedInput ?? input };
599
+ }
600
+
601
+ return { behavior: 'deny', message: decision.message ?? 'User denied tool use' };
602
+ };
603
+
604
+ // Set stream-close timeout for interactive tools (Query constructor reads it synchronously). Claude Agent SDK has a default of 5s and this overrides it
605
+ const prevStreamTimeout = process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
606
+ process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = '300000';
607
+
608
+ let queryInstance;
609
+ try {
610
+ queryInstance = query({
611
+ prompt: finalCommand,
612
+ options: sdkOptions
613
+ });
614
+ } catch (hookError) {
615
+ // Older/newer SDK versions may not accept hook shapes yet.
616
+ // Keep notification behavior operational via runtime events even if hook registration fails.
617
+ console.warn('Failed to initialize Claude query with hooks, retrying without hooks:', hookError?.message || hookError);
618
+ delete sdkOptions.hooks;
619
+ queryInstance = query({
620
+ prompt: finalCommand,
621
+ options: sdkOptions
622
+ });
623
+ }
624
+
625
+ // Restore immediately — Query constructor already captured the value
626
+ if (prevStreamTimeout !== undefined) {
627
+ process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT = prevStreamTimeout;
628
+ } else {
629
+ delete process.env.CLAUDE_CODE_STREAM_CLOSE_TIMEOUT;
630
+ }
631
+
632
+ // Track the query instance for abort capability
633
+ if (capturedSessionId) {
634
+ addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, ws);
635
+ }
636
+
637
+ // Process streaming messages
638
+ console.log('Starting async generator loop for session:', capturedSessionId || 'NEW');
639
+ for await (const message of queryInstance) {
640
+ // Capture session ID from first message
641
+ if (message.session_id && !capturedSessionId) {
642
+
643
+ capturedSessionId = message.session_id;
644
+ addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir, ws);
645
+
646
+ // Set session ID on writer
647
+ if (ws.setSessionId && typeof ws.setSessionId === 'function') {
648
+ ws.setSessionId(capturedSessionId);
649
+ }
650
+
651
+ // Send session-created event only once for new sessions
652
+ if (!sessionId && !sessionCreatedSent) {
653
+ sessionCreatedSent = true;
654
+ ws.send(createNormalizedMessage({ kind: 'session_created', newSessionId: capturedSessionId, sessionId: capturedSessionId, provider: 'claude' }));
655
+ }
656
+ } else {
657
+ // session_id already captured
658
+ }
659
+
660
+ // Transform and normalize message via adapter
661
+ const transformedMessage = transformMessage(message);
662
+ const sid = capturedSessionId || sessionId || null;
663
+
664
+ // Use adapter to normalize SDK events into NormalizedMessage[]
665
+ const normalized = sessionsService.normalizeMessage('claude', transformedMessage, sid);
666
+ for (const msg of normalized) {
667
+ // Preserve parentToolUseId from SDK wrapper for subagent tool grouping
668
+ if (transformedMessage.parentToolUseId && !msg.parentToolUseId) {
669
+ msg.parentToolUseId = transformedMessage.parentToolUseId;
670
+ }
671
+ ws.send(msg);
672
+ }
673
+
674
+ // Extract and send token budget updates from result messages
675
+ if (message.type === 'result') {
676
+ const models = Object.keys(message.modelUsage || {});
677
+ if (models.length > 0) {
678
+ // Model info available in result message
679
+ }
680
+ const tokenBudgetData = extractTokenBudget(message);
681
+ if (tokenBudgetData) {
682
+ ws.send(createNormalizedMessage({ kind: 'status', text: 'token_budget', tokenBudget: tokenBudgetData, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
683
+ }
684
+ }
685
+ }
686
+
687
+ // Clean up session on completion
688
+ if (capturedSessionId) {
689
+ removeSession(capturedSessionId);
690
+ }
691
+
692
+ // Clean up temporary image files
693
+ await cleanupTempFiles(tempImagePaths, tempDir);
694
+
695
+ // Send completion event
696
+ ws.send(createNormalizedMessage({ kind: 'complete', exitCode: 0, isNewSession: !sessionId && !!command, sessionId: capturedSessionId, provider: 'claude' }));
697
+ notifyRunStopped({
698
+ userId: ws?.userId || null,
699
+ provider: 'claude',
700
+ sessionId: capturedSessionId || sessionId || null,
701
+ sessionName: sessionSummary,
702
+ stopReason: 'completed'
703
+ });
704
+ // Complete
705
+
706
+ } catch (error) {
707
+ console.error('SDK query error:', error);
708
+
709
+ // Clean up session on error
710
+ if (capturedSessionId) {
711
+ removeSession(capturedSessionId);
712
+ }
713
+
714
+ // Clean up temporary image files on error
715
+ await cleanupTempFiles(tempImagePaths, tempDir);
716
+
717
+ // Check if Claude CLI is installed for a clearer error message
718
+ const installed = await providerAuthService.isProviderInstalled('claude');
719
+ const errorContent = !installed
720
+ ? 'Claude Code is not installed. Please install it first: https://docs.anthropic.com/en/docs/claude-code'
721
+ : error.message;
722
+
723
+ // Send error to WebSocket
724
+ ws.send(createNormalizedMessage({ kind: 'error', content: errorContent, sessionId: capturedSessionId || sessionId || null, provider: 'claude' }));
725
+ notifyRunFailed({
726
+ userId: ws?.userId || null,
727
+ provider: 'claude',
728
+ sessionId: capturedSessionId || sessionId || null,
729
+ sessionName: sessionSummary,
730
+ error
731
+ });
732
+ }
733
+ }
734
+
735
+ /**
736
+ * Aborts an active SDK session
737
+ * @param {string} sessionId - Session identifier
738
+ * @returns {boolean} True if session was aborted, false if not found
739
+ */
740
+ async function abortClaudeSDKSession(sessionId) {
741
+ const session = getSession(sessionId);
742
+
743
+ if (!session) {
744
+ console.log(`Session ${sessionId} not found`);
745
+ return false;
746
+ }
747
+
748
+ try {
749
+ console.log(`Aborting SDK session: ${sessionId}`);
750
+
751
+ // Call interrupt() on the query instance
752
+ await session.instance.interrupt();
753
+
754
+ // Update session status
755
+ session.status = 'aborted';
756
+
757
+ // Clean up temporary image files
758
+ await cleanupTempFiles(session.tempImagePaths, session.tempDir);
759
+
760
+ // Clean up session
761
+ removeSession(sessionId);
762
+
763
+ return true;
764
+ } catch (error) {
765
+ console.error(`Error aborting session ${sessionId}:`, error);
766
+ return false;
767
+ }
768
+ }
769
+
770
+ /**
771
+ * Checks if an SDK session is currently active
772
+ * @param {string} sessionId - Session identifier
773
+ * @returns {boolean} True if session is active
774
+ */
775
+ function isClaudeSDKSessionActive(sessionId) {
776
+ const session = getSession(sessionId);
777
+ return session && session.status === 'active';
778
+ }
779
+
780
+ /**
781
+ * Gets all active SDK session IDs
782
+ * @returns {Array<string>} Array of active session IDs
783
+ */
784
+ function getActiveClaudeSDKSessions() {
785
+ return getAllSessions();
786
+ }
787
+
788
+ /**
789
+ * Get pending tool approvals for a specific session.
790
+ * @param {string} sessionId - The session ID
791
+ * @returns {Array} Array of pending permission request objects
792
+ */
793
+ function getPendingApprovalsForSession(sessionId) {
794
+ const pending = [];
795
+ for (const [requestId, resolver] of pendingToolApprovals.entries()) {
796
+ if (resolver._sessionId === sessionId) {
797
+ pending.push({
798
+ requestId,
799
+ toolName: resolver._toolName || 'UnknownTool',
800
+ input: resolver._input,
801
+ context: resolver._context,
802
+ sessionId,
803
+ receivedAt: resolver._receivedAt || new Date(),
804
+ });
805
+ }
806
+ }
807
+ return pending;
808
+ }
809
+
810
+ /**
811
+ * Reconnect a session's WebSocketWriter to a new raw WebSocket.
812
+ * Called when client reconnects (e.g. page refresh) while SDK is still running.
813
+ * @param {string} sessionId - The session ID
814
+ * @param {Object} newRawWs - The new raw WebSocket connection
815
+ * @returns {boolean} True if writer was successfully reconnected
816
+ */
817
+ function reconnectSessionWriter(sessionId, newRawWs) {
818
+ const session = getSession(sessionId);
819
+ if (!session?.writer?.updateWebSocket) return false;
820
+ session.writer.updateWebSocket(newRawWs);
821
+ console.log(`[RECONNECT] Writer swapped for session ${sessionId}`);
822
+ return true;
823
+ }
824
+
825
+ // Export public API
826
+ export {
827
+ queryClaudeSDK,
828
+ abortClaudeSDKSession,
829
+ isClaudeSDKSessionActive,
830
+ getActiveClaudeSDKSessions,
831
+ resolveToolApproval,
832
+ getPendingApprovalsForSession,
833
+ reconnectSessionWriter
834
+ };