@ian2018cs/agenthub 0.1.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 (136) hide show
  1. package/LICENSE +675 -0
  2. package/README.md +330 -0
  3. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  4. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  5. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  6. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  7. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  8. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  9. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  10. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  11. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  12. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  13. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  14. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  15. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  16. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  17. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  18. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  19. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  20. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  21. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  22. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  23. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  24. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  25. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  26. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  27. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  28. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  29. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  30. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  31. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  32. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  33. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  34. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  35. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  36. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  37. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  38. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  39. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  40. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  41. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  42. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  43. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  44. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  45. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  46. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  47. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  48. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  49. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  50. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  51. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  52. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  53. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  54. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  55. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  56. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  57. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  58. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  59. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  60. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  61. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  62. package/dist/assets/index-B4ru3EJb.css +32 -0
  63. package/dist/assets/index-DDFuyrpY.js +154 -0
  64. package/dist/assets/vendor-codemirror-C_VWDoZS.js +39 -0
  65. package/dist/assets/vendor-icons-CJV4dnDL.js +326 -0
  66. package/dist/assets/vendor-katex-DK8hFnhL.js +261 -0
  67. package/dist/assets/vendor-markdown-VwNYkg_0.js +35 -0
  68. package/dist/assets/vendor-react-BeVl62c0.js +59 -0
  69. package/dist/assets/vendor-syntax-CdGaPJRS.js +16 -0
  70. package/dist/assets/vendor-utils-00TdZexr.js +1 -0
  71. package/dist/assets/vendor-xterm-CvdiG4-n.js +66 -0
  72. package/dist/clear-cache.html +85 -0
  73. package/dist/convert-icons.md +53 -0
  74. package/dist/favicon.png +0 -0
  75. package/dist/favicon.svg +9 -0
  76. package/dist/generate-icons.js +49 -0
  77. package/dist/icons/claude-ai-icon.svg +1 -0
  78. package/dist/icons/codex-white.svg +3 -0
  79. package/dist/icons/codex.svg +3 -0
  80. package/dist/icons/cursor-white.svg +12 -0
  81. package/dist/icons/cursor.svg +1 -0
  82. package/dist/icons/generate-icons.md +19 -0
  83. package/dist/icons/icon-128x128.png +0 -0
  84. package/dist/icons/icon-128x128.svg +12 -0
  85. package/dist/icons/icon-144x144.png +0 -0
  86. package/dist/icons/icon-144x144.svg +12 -0
  87. package/dist/icons/icon-152x152.png +0 -0
  88. package/dist/icons/icon-152x152.svg +12 -0
  89. package/dist/icons/icon-192x192.png +0 -0
  90. package/dist/icons/icon-192x192.svg +12 -0
  91. package/dist/icons/icon-384x384.png +0 -0
  92. package/dist/icons/icon-384x384.svg +12 -0
  93. package/dist/icons/icon-512x512.png +0 -0
  94. package/dist/icons/icon-512x512.svg +12 -0
  95. package/dist/icons/icon-72x72.png +0 -0
  96. package/dist/icons/icon-72x72.svg +12 -0
  97. package/dist/icons/icon-96x96.png +0 -0
  98. package/dist/icons/icon-96x96.svg +12 -0
  99. package/dist/icons/icon-template.svg +12 -0
  100. package/dist/index.html +57 -0
  101. package/dist/logo-128.png +0 -0
  102. package/dist/logo-256.png +0 -0
  103. package/dist/logo-32.png +0 -0
  104. package/dist/logo-512.png +0 -0
  105. package/dist/logo-64.png +0 -0
  106. package/dist/logo.svg +17 -0
  107. package/dist/manifest.json +61 -0
  108. package/dist/screenshots/cli-selection.png +0 -0
  109. package/dist/screenshots/desktop-main.png +0 -0
  110. package/dist/screenshots/mobile-chat.png +0 -0
  111. package/dist/screenshots/tools-modal.png +0 -0
  112. package/dist/sw.js +49 -0
  113. package/package.json +113 -0
  114. package/server/claude-sdk.js +791 -0
  115. package/server/cli.js +330 -0
  116. package/server/database/auth.db +0 -0
  117. package/server/database/db.js +523 -0
  118. package/server/database/init.sql +23 -0
  119. package/server/index.js +1678 -0
  120. package/server/load-env.js +27 -0
  121. package/server/middleware/auth.js +118 -0
  122. package/server/projects.js +899 -0
  123. package/server/routes/admin.js +89 -0
  124. package/server/routes/auth.js +144 -0
  125. package/server/routes/commands.js +570 -0
  126. package/server/routes/mcp-utils.js +37 -0
  127. package/server/routes/mcp.js +593 -0
  128. package/server/routes/projects.js +216 -0
  129. package/server/routes/skills.js +891 -0
  130. package/server/routes/usage.js +206 -0
  131. package/server/services/pricing.js +196 -0
  132. package/server/services/usage-scanner.js +283 -0
  133. package/server/services/user-directories.js +123 -0
  134. package/server/utils/commandParser.js +303 -0
  135. package/server/utils/mcp-detector.js +73 -0
  136. package/shared/modelConstants.js +23 -0
@@ -0,0 +1,791 @@
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
+ // Used to mint unique approval request IDs when randomUUID is not available.
17
+ // This keeps parallel tool approvals from colliding; it does not add any crypto/security guarantees.
18
+ import crypto from 'crypto';
19
+ import { promises as fs } from 'fs';
20
+ import path from 'path';
21
+ import { CLAUDE_MODELS } from '../shared/modelConstants.js';
22
+ import { getUserPaths } from './services/user-directories.js';
23
+ import { usageDb } from './database/db.js';
24
+ import { calculateCost, normalizeModelName } from './services/pricing.js';
25
+
26
+ // Session tracking: Map of session IDs to active query instances
27
+ const activeSessions = new Map();
28
+ // In-memory registry of pending tool approvals keyed by requestId.
29
+ // This does not persist approvals or share across processes; it exists so the
30
+ // SDK can pause tool execution while the UI decides what to do.
31
+ const pendingToolApprovals = new Map();
32
+
33
+ // Default approval timeout kept under the SDK's 60s control timeout.
34
+ // This does not change SDK limits; it only defines how long we wait for the UI,
35
+ // introduced to avoid hanging the run when no decision arrives.
36
+ const TOOL_APPROVAL_TIMEOUT_MS = parseInt(process.env.CLAUDE_TOOL_APPROVAL_TIMEOUT_MS, 10) || 55000;
37
+
38
+ // Generate a stable request ID for UI approval flows.
39
+ // This does not encode tool details or get shown to users; it exists so the UI
40
+ // can respond to the correct pending request without collisions.
41
+ function createRequestId() {
42
+ // if clause is used because randomUUID is not available in older Node.js versions
43
+ if (typeof crypto.randomUUID === 'function') {
44
+ return crypto.randomUUID();
45
+ }
46
+ return crypto.randomBytes(16).toString('hex');
47
+ }
48
+
49
+ // Wait for a UI approval decision, honoring SDK cancellation.
50
+ // This does not auto-approve or auto-deny; it only resolves with UI input,
51
+ // and it cleans up the pending map to avoid leaks, introduced to prevent
52
+ // replying after the SDK cancels the control request.
53
+ function waitForToolApproval(requestId, options = {}) {
54
+ const { timeoutMs = TOOL_APPROVAL_TIMEOUT_MS, signal, onCancel } = options;
55
+
56
+ return new Promise(resolve => {
57
+ let settled = false;
58
+
59
+ const finalize = (decision) => {
60
+ if (settled) return;
61
+ settled = true;
62
+ cleanup();
63
+ resolve(decision);
64
+ };
65
+
66
+ const cleanup = () => {
67
+ pendingToolApprovals.delete(requestId);
68
+ clearTimeout(timeout);
69
+ if (signal && abortHandler) {
70
+ signal.removeEventListener('abort', abortHandler);
71
+ }
72
+ };
73
+
74
+ // Timeout is local to this process; it does not override SDK timing.
75
+ // It exists to prevent the UI prompt from lingering indefinitely.
76
+ const timeout = setTimeout(() => {
77
+ onCancel?.('timeout');
78
+ finalize(null);
79
+ }, timeoutMs);
80
+
81
+ const abortHandler = () => {
82
+ // If the SDK cancels the control request, stop waiting to avoid
83
+ // replying after the process is no longer ready for writes.
84
+ onCancel?.('cancelled');
85
+ finalize({ cancelled: true });
86
+ };
87
+
88
+ if (signal) {
89
+ if (signal.aborted) {
90
+ onCancel?.('cancelled');
91
+ finalize({ cancelled: true });
92
+ return;
93
+ }
94
+ signal.addEventListener('abort', abortHandler, { once: true });
95
+ }
96
+
97
+ pendingToolApprovals.set(requestId, (decision) => {
98
+ finalize(decision);
99
+ });
100
+ });
101
+ }
102
+
103
+ // Resolve a pending approval. This does not validate the decision payload;
104
+ // validation and tool matching remain in canUseTool, which keeps this as a
105
+ // lightweight WebSocket -> SDK relay.
106
+ function resolveToolApproval(requestId, decision) {
107
+ const resolver = pendingToolApprovals.get(requestId);
108
+ if (resolver) {
109
+ resolver(decision);
110
+ }
111
+ }
112
+
113
+ // Match stored permission entries against a tool + input combo.
114
+ // This only supports exact tool names and the Bash(command:*) shorthand
115
+ // used by the UI; it intentionally does not implement full glob semantics,
116
+ // introduced to stay consistent with the UI's "Allow rule" format.
117
+ function matchesToolPermission(entry, toolName, input) {
118
+ if (!entry || !toolName) {
119
+ return false;
120
+ }
121
+
122
+ if (entry === toolName) {
123
+ return true;
124
+ }
125
+
126
+ const bashMatch = entry.match(/^Bash\((.+):\*\)$/);
127
+ if (toolName === 'Bash' && bashMatch) {
128
+ const allowedPrefix = bashMatch[1];
129
+ let command = '';
130
+
131
+ if (typeof input === 'string') {
132
+ command = input.trim();
133
+ } else if (input && typeof input === 'object' && typeof input.command === 'string') {
134
+ command = input.command.trim();
135
+ }
136
+
137
+ if (!command) {
138
+ return false;
139
+ }
140
+
141
+ return command.startsWith(allowedPrefix);
142
+ }
143
+
144
+ return false;
145
+ }
146
+
147
+ /**
148
+ * Maps CLI options to SDK-compatible options format
149
+ * @param {Object} options - CLI options
150
+ * @returns {Object} SDK-compatible options
151
+ */
152
+ function mapCliOptionsToSDK(options = {}) {
153
+ const { sessionId, cwd, toolsSettings, permissionMode, images } = options;
154
+
155
+ const sdkOptions = {};
156
+
157
+ // Map working directory
158
+ if (cwd) {
159
+ sdkOptions.cwd = cwd;
160
+ }
161
+
162
+ // Map permission mode
163
+ if (permissionMode && permissionMode !== 'default') {
164
+ sdkOptions.permissionMode = permissionMode;
165
+ }
166
+
167
+ // Map tool settings
168
+ const settings = toolsSettings || {
169
+ allowedTools: [],
170
+ disallowedTools: [],
171
+ skipPermissions: false
172
+ };
173
+
174
+ // Handle tool permissions
175
+ if (settings.skipPermissions && permissionMode !== 'plan') {
176
+ // When skipping permissions, use bypassPermissions mode
177
+ sdkOptions.permissionMode = 'bypassPermissions';
178
+ }
179
+
180
+ // Map allowed tools (always set to avoid implicit "allow all" defaults).
181
+ // This does not grant permissions by itself; it just configures the SDK,
182
+ // introduced because leaving it undefined made the SDK treat it as "all tools allowed."
183
+ let allowedTools = [...(settings.allowedTools || [])];
184
+
185
+ // Add plan mode default tools
186
+ if (permissionMode === 'plan') {
187
+ const planModeTools = ['Read', 'Task', 'exit_plan_mode', 'TodoRead', 'TodoWrite', 'WebFetch', 'WebSearch'];
188
+ for (const tool of planModeTools) {
189
+ if (!allowedTools.includes(tool)) {
190
+ allowedTools.push(tool);
191
+ }
192
+ }
193
+ }
194
+
195
+ sdkOptions.allowedTools = allowedTools;
196
+
197
+ // Map disallowed tools (always set so the SDK doesn't treat "undefined" as permissive).
198
+ // This does not override allowlists; it only feeds the canUseTool gate.
199
+ sdkOptions.disallowedTools = settings.disallowedTools || [];
200
+
201
+ // Map model (default to sonnet)
202
+ // Valid models: sonnet, opus, haiku
203
+ sdkOptions.model = options.model || CLAUDE_MODELS.DEFAULT;
204
+ console.log(`Using model: ${sdkOptions.model}`);
205
+
206
+ // Map system prompt configuration
207
+ sdkOptions.systemPrompt = {
208
+ type: 'preset',
209
+ preset: 'claude_code' // Required to use CLAUDE.md
210
+ };
211
+
212
+ // Map setting sources for CLAUDE.md loading
213
+ // This loads CLAUDE.md from project, user (~/.config/claude/CLAUDE.md), and local directories
214
+ sdkOptions.settingSources = ['project', 'user', 'local'];
215
+
216
+ // Map resume session
217
+ if (sessionId) {
218
+ sdkOptions.resume = sessionId;
219
+ }
220
+
221
+ return sdkOptions;
222
+ }
223
+
224
+ /**
225
+ * Adds a session to the active sessions map
226
+ * @param {string} sessionId - Session identifier
227
+ * @param {Object} queryInstance - SDK query instance
228
+ * @param {Array<string>} tempImagePaths - Temp image file paths for cleanup
229
+ * @param {string} tempDir - Temp directory for cleanup
230
+ */
231
+ function addSession(sessionId, queryInstance, tempImagePaths = [], tempDir = null) {
232
+ activeSessions.set(sessionId, {
233
+ instance: queryInstance,
234
+ startTime: Date.now(),
235
+ status: 'active',
236
+ tempImagePaths,
237
+ tempDir
238
+ });
239
+ }
240
+
241
+ /**
242
+ * Removes a session from the active sessions map
243
+ * @param {string} sessionId - Session identifier
244
+ */
245
+ function removeSession(sessionId) {
246
+ activeSessions.delete(sessionId);
247
+ }
248
+
249
+ /**
250
+ * Gets a session from the active sessions map
251
+ * @param {string} sessionId - Session identifier
252
+ * @returns {Object|undefined} Session data or undefined
253
+ */
254
+ function getSession(sessionId) {
255
+ return activeSessions.get(sessionId);
256
+ }
257
+
258
+ /**
259
+ * Gets all active session IDs
260
+ * @returns {Array<string>} Array of active session IDs
261
+ */
262
+ function getAllSessions() {
263
+ return Array.from(activeSessions.keys());
264
+ }
265
+
266
+ /**
267
+ * Transforms SDK messages to WebSocket format expected by frontend
268
+ * @param {Object} sdkMessage - SDK message object
269
+ * @returns {Object} Transformed message ready for WebSocket
270
+ */
271
+ function transformMessage(sdkMessage) {
272
+ // SDK messages are already in a format compatible with the frontend
273
+ // The CLI sends them wrapped in {type: 'claude-response', data: message}
274
+ // We'll do the same here to maintain compatibility
275
+ return sdkMessage;
276
+ }
277
+
278
+ /**
279
+ * Extracts token usage from SDK result messages
280
+ * @param {Object} resultMessage - SDK result message
281
+ * @returns {Object|null} Token budget object or null
282
+ */
283
+ function extractTokenBudget(resultMessage) {
284
+ if (resultMessage.type !== 'result' || !resultMessage.modelUsage) {
285
+ return null;
286
+ }
287
+
288
+ // Get the first model's usage data
289
+ const modelKey = Object.keys(resultMessage.modelUsage)[0];
290
+ const modelData = resultMessage.modelUsage[modelKey];
291
+
292
+ if (!modelData) {
293
+ return null;
294
+ }
295
+
296
+ // Use cumulative tokens if available (tracks total for the session)
297
+ // Otherwise fall back to per-request tokens
298
+ const inputTokens = modelData.cumulativeInputTokens || modelData.inputTokens || 0;
299
+ const outputTokens = modelData.cumulativeOutputTokens || modelData.outputTokens || 0;
300
+ const cacheReadTokens = modelData.cumulativeCacheReadInputTokens || modelData.cacheReadInputTokens || 0;
301
+ const cacheCreationTokens = modelData.cumulativeCacheCreationInputTokens || modelData.cacheCreationInputTokens || 0;
302
+
303
+ // Total used = input + output + cache tokens
304
+ const totalUsed = inputTokens + outputTokens + cacheReadTokens + cacheCreationTokens;
305
+
306
+ // Use configured context window budget from environment (default 160000)
307
+ // This is the user's budget limit, not the model's context window
308
+ const contextWindow = parseInt(process.env.CONTEXT_WINDOW) || 160000;
309
+
310
+ console.log(`Token calculation: input=${inputTokens}, output=${outputTokens}, cache=${cacheReadTokens + cacheCreationTokens}, total=${totalUsed}/${contextWindow}`);
311
+
312
+ return {
313
+ used: totalUsed,
314
+ total: contextWindow
315
+ };
316
+ }
317
+
318
+ /**
319
+ * Handles image processing for SDK queries
320
+ * Saves base64 images to temporary files and returns modified prompt with file paths
321
+ * @param {string} command - Original user prompt
322
+ * @param {Array} images - Array of image objects with base64 data
323
+ * @param {string} cwd - Working directory for temp file creation
324
+ * @returns {Promise<Object>} {modifiedCommand, tempImagePaths, tempDir}
325
+ */
326
+ async function handleImages(command, images, cwd) {
327
+ const tempImagePaths = [];
328
+ let tempDir = null;
329
+
330
+ if (!images || images.length === 0) {
331
+ return { modifiedCommand: command, tempImagePaths, tempDir };
332
+ }
333
+
334
+ try {
335
+ // Create temp directory in the project directory
336
+ const workingDir = cwd || process.cwd();
337
+ tempDir = path.join(workingDir, '.tmp', 'images', Date.now().toString());
338
+ await fs.mkdir(tempDir, { recursive: true });
339
+
340
+ // Save each image to a temp file
341
+ for (const [index, image] of images.entries()) {
342
+ // Extract base64 data and mime type
343
+ const matches = image.data.match(/^data:([^;]+);base64,(.+)$/);
344
+ if (!matches) {
345
+ console.error('Invalid image data format');
346
+ continue;
347
+ }
348
+
349
+ const [, mimeType, base64Data] = matches;
350
+ const extension = mimeType.split('/')[1] || 'png';
351
+ const filename = `image_${index}.${extension}`;
352
+ const filepath = path.join(tempDir, filename);
353
+
354
+ // Write base64 data to file
355
+ await fs.writeFile(filepath, Buffer.from(base64Data, 'base64'));
356
+ tempImagePaths.push(filepath);
357
+ }
358
+
359
+ // Include the full image paths in the prompt
360
+ let modifiedCommand = command;
361
+ if (tempImagePaths.length > 0 && command && command.trim()) {
362
+ const imageNote = `\n\n[Images provided at the following paths:]\n${tempImagePaths.map((p, i) => `${i + 1}. ${p}`).join('\n')}`;
363
+ modifiedCommand = command + imageNote;
364
+ }
365
+
366
+ console.log(`Processed ${tempImagePaths.length} images to temp directory: ${tempDir}`);
367
+ return { modifiedCommand, tempImagePaths, tempDir };
368
+ } catch (error) {
369
+ console.error('Error processing images for SDK:', error);
370
+ return { modifiedCommand: command, tempImagePaths, tempDir };
371
+ }
372
+ }
373
+
374
+ /**
375
+ * Cleans up temporary image files
376
+ * @param {Array<string>} tempImagePaths - Array of temp file paths to delete
377
+ * @param {string} tempDir - Temp directory to remove
378
+ */
379
+ async function cleanupTempFiles(tempImagePaths, tempDir) {
380
+ if (!tempImagePaths || tempImagePaths.length === 0) {
381
+ return;
382
+ }
383
+
384
+ try {
385
+ // Delete individual temp files
386
+ for (const imagePath of tempImagePaths) {
387
+ await fs.unlink(imagePath).catch(err =>
388
+ console.error(`Failed to delete temp image ${imagePath}:`, err)
389
+ );
390
+ }
391
+
392
+ // Delete temp directory
393
+ if (tempDir) {
394
+ await fs.rm(tempDir, { recursive: true, force: true }).catch(err =>
395
+ console.error(`Failed to delete temp directory ${tempDir}:`, err)
396
+ );
397
+ }
398
+
399
+ console.log(`Cleaned up ${tempImagePaths.length} temp image files`);
400
+ } catch (error) {
401
+ console.error('Error during temp file cleanup:', error);
402
+ }
403
+ }
404
+
405
+ /**
406
+ * Loads MCP server configurations from user-specific .claude.json
407
+ * @param {string} cwd - Current working directory for project-specific configs
408
+ * @param {string} userUuid - User UUID for user-specific config (required)
409
+ * @returns {Object|null} MCP servers object or null if none found
410
+ */
411
+ async function loadMcpConfig(cwd, userUuid) {
412
+ if (!userUuid) {
413
+ console.log('No userUuid provided for loadMcpConfig, proceeding without MCP servers');
414
+ return null;
415
+ }
416
+ try {
417
+ const configDir = getUserPaths(userUuid).configDir;
418
+ const claudeConfigPath = path.join(configDir, '.claude.json');
419
+
420
+ // Check if config file exists
421
+ try {
422
+ await fs.access(claudeConfigPath);
423
+ } catch (error) {
424
+ // File doesn't exist, return null
425
+ console.log('No ~/.claude.json found, proceeding without MCP servers');
426
+ return null;
427
+ }
428
+
429
+ // Read and parse config file
430
+ let claudeConfig;
431
+ try {
432
+ const configContent = await fs.readFile(claudeConfigPath, 'utf8');
433
+ claudeConfig = JSON.parse(configContent);
434
+ } catch (error) {
435
+ console.error('Failed to parse ~/.claude.json:', error.message);
436
+ return null;
437
+ }
438
+
439
+ // Extract MCP servers (merge global and project-specific)
440
+ let mcpServers = {};
441
+
442
+ // Add global MCP servers
443
+ if (claudeConfig.mcpServers && typeof claudeConfig.mcpServers === 'object') {
444
+ mcpServers = { ...claudeConfig.mcpServers };
445
+ console.log(`Loaded ${Object.keys(mcpServers).length} global MCP servers`);
446
+ }
447
+
448
+ // Add/override with project-specific MCP servers
449
+ if (claudeConfig.claudeProjects && cwd) {
450
+ const projectConfig = claudeConfig.claudeProjects[cwd];
451
+ if (projectConfig && projectConfig.mcpServers && typeof projectConfig.mcpServers === 'object') {
452
+ mcpServers = { ...mcpServers, ...projectConfig.mcpServers };
453
+ console.log(`Loaded ${Object.keys(projectConfig.mcpServers).length} project-specific MCP servers`);
454
+ }
455
+ }
456
+
457
+ // Return null if no servers found
458
+ if (Object.keys(mcpServers).length === 0) {
459
+ console.log('No MCP servers configured');
460
+ return null;
461
+ }
462
+
463
+ console.log(`Total MCP servers loaded: ${Object.keys(mcpServers).length}`);
464
+ return mcpServers;
465
+ } catch (error) {
466
+ console.error('Error loading MCP config:', error.message);
467
+ return null;
468
+ }
469
+ }
470
+
471
+ /**
472
+ * Executes a Claude query using the SDK
473
+ * @param {string} command - User prompt/command
474
+ * @param {Object} options - Query options
475
+ * @param {Object} ws - WebSocket connection
476
+ * @returns {Promise<void>}
477
+ */
478
+ async function queryClaudeSDK(command, options = {}, ws) {
479
+ const { sessionId, userUuid } = options;
480
+ let capturedSessionId = sessionId;
481
+ let sessionCreatedSent = false;
482
+ let tempImagePaths = [];
483
+ let tempDir = null;
484
+
485
+ // Set CLAUDE_CONFIG_DIR for user isolation
486
+ if (userUuid) {
487
+ const userPaths = getUserPaths(userUuid);
488
+ process.env.CLAUDE_CONFIG_DIR = userPaths.claudeDir;
489
+ console.log(`Set CLAUDE_CONFIG_DIR to user data directory (contains .claude folder): ${userPaths.claudeDir}`);
490
+ }
491
+
492
+ try {
493
+ // Map CLI options to SDK format
494
+ const sdkOptions = mapCliOptionsToSDK(options);
495
+
496
+ // Load MCP configuration
497
+ const mcpServers = await loadMcpConfig(options.cwd, userUuid);
498
+ if (mcpServers) {
499
+ sdkOptions.mcpServers = mcpServers;
500
+ }
501
+
502
+ // Handle images - save to temp files and modify prompt
503
+ const imageResult = await handleImages(command, options.images, options.cwd);
504
+ const finalCommand = imageResult.modifiedCommand;
505
+ tempImagePaths = imageResult.tempImagePaths;
506
+ tempDir = imageResult.tempDir;
507
+
508
+ // Gate tool usage with explicit UI approval when not auto-approved.
509
+ // This does not render UI or persist permissions; it only bridges to the UI
510
+ // via WebSocket and waits for the response, introduced so tool calls pause
511
+ // instead of auto-running when the allowlist is empty.
512
+ sdkOptions.canUseTool = async (toolName, input, context) => {
513
+ if (sdkOptions.permissionMode === 'bypassPermissions') {
514
+ return { behavior: 'allow', updatedInput: input };
515
+ }
516
+
517
+ const isDisallowed = (sdkOptions.disallowedTools || []).some(entry =>
518
+ matchesToolPermission(entry, toolName, input)
519
+ );
520
+ if (isDisallowed) {
521
+ return { behavior: 'deny', message: 'Tool disallowed by settings' };
522
+ }
523
+
524
+ const isAllowed = (sdkOptions.allowedTools || []).some(entry =>
525
+ matchesToolPermission(entry, toolName, input)
526
+ );
527
+ if (isAllowed) {
528
+ return { behavior: 'allow', updatedInput: input };
529
+ }
530
+
531
+ const requestId = createRequestId();
532
+ ws.send({
533
+ type: 'claude-permission-request',
534
+ requestId,
535
+ toolName,
536
+ input,
537
+ sessionId: capturedSessionId || sessionId || null
538
+ });
539
+
540
+ // Wait for the UI; if the SDK cancels, notify the UI so it can dismiss the banner.
541
+ // This does not retry or resurface the prompt; it just reflects the cancellation.
542
+ const decision = await waitForToolApproval(requestId, {
543
+ signal: context?.signal,
544
+ onCancel: (reason) => {
545
+ ws.send({
546
+ type: 'claude-permission-cancelled',
547
+ requestId,
548
+ reason,
549
+ sessionId: capturedSessionId || sessionId || null
550
+ });
551
+ }
552
+ });
553
+ if (!decision) {
554
+ return { behavior: 'deny', message: 'Permission request timed out' };
555
+ }
556
+
557
+ if (decision.cancelled) {
558
+ return { behavior: 'deny', message: 'Permission request cancelled' };
559
+ }
560
+
561
+ if (decision.allow) {
562
+ // rememberEntry only updates this run's in-memory allowlist to prevent
563
+ // repeated prompts in the same session; persistence is handled by the UI.
564
+ if (decision.rememberEntry && typeof decision.rememberEntry === 'string') {
565
+ if (!sdkOptions.allowedTools.includes(decision.rememberEntry)) {
566
+ sdkOptions.allowedTools.push(decision.rememberEntry);
567
+ }
568
+ if (Array.isArray(sdkOptions.disallowedTools)) {
569
+ sdkOptions.disallowedTools = sdkOptions.disallowedTools.filter(entry => entry !== decision.rememberEntry);
570
+ }
571
+ }
572
+ return { behavior: 'allow', updatedInput: decision.updatedInput ?? input };
573
+ }
574
+
575
+ return { behavior: 'deny', message: decision.message ?? 'User denied tool use' };
576
+ };
577
+
578
+ // Create SDK query instance
579
+ const queryInstance = query({
580
+ prompt: finalCommand,
581
+ options: sdkOptions
582
+ });
583
+
584
+ // Track the query instance for abort capability
585
+ if (capturedSessionId) {
586
+ addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir);
587
+ }
588
+
589
+ // Process streaming messages
590
+ console.log('Starting async generator loop for session:', capturedSessionId || 'NEW');
591
+ for await (const message of queryInstance) {
592
+ // Capture session ID from first message
593
+ if (message.session_id && !capturedSessionId) {
594
+
595
+ capturedSessionId = message.session_id;
596
+ addSession(capturedSessionId, queryInstance, tempImagePaths, tempDir);
597
+
598
+ // Set session ID on writer
599
+ if (ws.setSessionId && typeof ws.setSessionId === 'function') {
600
+ ws.setSessionId(capturedSessionId);
601
+ }
602
+
603
+ // Send session-created event only once for new sessions
604
+ if (!sessionId && !sessionCreatedSent) {
605
+ sessionCreatedSent = true;
606
+ ws.send({
607
+ type: 'session-created',
608
+ sessionId: capturedSessionId
609
+ });
610
+ } else {
611
+ console.log('Not sending session-created. sessionId:', sessionId, 'sessionCreatedSent:', sessionCreatedSent);
612
+ }
613
+ } else {
614
+ console.log('No session_id in message or already captured. message.session_id:', message.session_id, 'capturedSessionId:', capturedSessionId);
615
+ }
616
+
617
+ // Transform and send message to WebSocket
618
+ const transformedMessage = transformMessage(message);
619
+ ws.send({
620
+ type: 'claude-response',
621
+ data: transformedMessage
622
+ });
623
+
624
+ // Extract and send token budget updates from result messages
625
+ if (message.type === 'result') {
626
+ const tokenBudget = extractTokenBudget(message);
627
+ if (tokenBudget) {
628
+ console.log('Token budget from modelUsage:', tokenBudget);
629
+ ws.send({
630
+ type: 'token-budget',
631
+ data: tokenBudget
632
+ });
633
+ }
634
+
635
+ // Record usage to database for admin statistics
636
+ if (userUuid && message.modelUsage) {
637
+ try {
638
+ const modelKey = Object.keys(message.modelUsage)[0];
639
+ const modelData = message.modelUsage[modelKey];
640
+
641
+ if (modelData) {
642
+ const inputTokens = modelData.inputTokens || 0;
643
+ const outputTokens = modelData.outputTokens || 0;
644
+ const cacheReadTokens = modelData.cacheReadInputTokens || 0;
645
+ const cacheCreationTokens = modelData.cacheCreationInputTokens || 0;
646
+
647
+ const normalizedModel = normalizeModelName(modelKey);
648
+ const cost = calculateCost({
649
+ model: normalizedModel,
650
+ inputTokens,
651
+ outputTokens,
652
+ cacheReadTokens,
653
+ cacheCreationTokens
654
+ });
655
+
656
+ // Insert usage record
657
+ usageDb.insertRecord({
658
+ user_uuid: userUuid,
659
+ session_id: capturedSessionId,
660
+ model: normalizedModel,
661
+ input_tokens: inputTokens,
662
+ output_tokens: outputTokens,
663
+ cache_read_tokens: cacheReadTokens,
664
+ cache_creation_tokens: cacheCreationTokens,
665
+ cost_usd: cost,
666
+ source: 'sdk'
667
+ });
668
+
669
+ // Update daily summary
670
+ const today = new Date().toISOString().split('T')[0];
671
+ usageDb.upsertDailySummary({
672
+ user_uuid: userUuid,
673
+ date: today,
674
+ model: normalizedModel,
675
+ total_input_tokens: inputTokens,
676
+ total_output_tokens: outputTokens,
677
+ total_cost_usd: cost,
678
+ session_count: 0, // Session count updated separately
679
+ request_count: 1
680
+ });
681
+
682
+ console.log(`Recorded usage for user ${userUuid}: ${normalizedModel}, cost: $${cost.toFixed(6)}`);
683
+ }
684
+ } catch (usageError) {
685
+ console.error('Error recording usage:', usageError);
686
+ // Don't throw - usage recording failure shouldn't break the query
687
+ }
688
+ }
689
+ }
690
+ }
691
+
692
+ // Clean up session on completion
693
+ if (capturedSessionId) {
694
+ removeSession(capturedSessionId);
695
+ }
696
+
697
+ // Clean up temporary image files
698
+ await cleanupTempFiles(tempImagePaths, tempDir);
699
+
700
+ // Send completion event
701
+ console.log('Streaming complete, sending claude-complete event');
702
+ ws.send({
703
+ type: 'claude-complete',
704
+ sessionId: capturedSessionId,
705
+ exitCode: 0,
706
+ isNewSession: !sessionId && !!command
707
+ });
708
+ console.log('claude-complete event sent');
709
+
710
+ } catch (error) {
711
+ console.error('SDK query error:', error);
712
+
713
+ // Clean up session on error
714
+ if (capturedSessionId) {
715
+ removeSession(capturedSessionId);
716
+ }
717
+
718
+ // Clean up temporary image files on error
719
+ await cleanupTempFiles(tempImagePaths, tempDir);
720
+
721
+ // Send error to WebSocket
722
+ ws.send({
723
+ type: 'claude-error',
724
+ error: error.message
725
+ });
726
+
727
+ throw error;
728
+ }
729
+ }
730
+
731
+ /**
732
+ * Aborts an active SDK session
733
+ * @param {string} sessionId - Session identifier
734
+ * @returns {boolean} True if session was aborted, false if not found
735
+ */
736
+ async function abortClaudeSDKSession(sessionId) {
737
+ const session = getSession(sessionId);
738
+
739
+ if (!session) {
740
+ console.log(`Session ${sessionId} not found`);
741
+ return false;
742
+ }
743
+
744
+ try {
745
+ console.log(`Aborting SDK session: ${sessionId}`);
746
+
747
+ // Call interrupt() on the query instance
748
+ await session.instance.interrupt();
749
+
750
+ // Update session status
751
+ session.status = 'aborted';
752
+
753
+ // Clean up temporary image files
754
+ await cleanupTempFiles(session.tempImagePaths, session.tempDir);
755
+
756
+ // Clean up session
757
+ removeSession(sessionId);
758
+
759
+ return true;
760
+ } catch (error) {
761
+ console.error(`Error aborting session ${sessionId}:`, error);
762
+ return false;
763
+ }
764
+ }
765
+
766
+ /**
767
+ * Checks if an SDK session is currently active
768
+ * @param {string} sessionId - Session identifier
769
+ * @returns {boolean} True if session is active
770
+ */
771
+ function isClaudeSDKSessionActive(sessionId) {
772
+ const session = getSession(sessionId);
773
+ return session && session.status === 'active';
774
+ }
775
+
776
+ /**
777
+ * Gets all active SDK session IDs
778
+ * @returns {Array<string>} Array of active session IDs
779
+ */
780
+ function getActiveClaudeSDKSessions() {
781
+ return getAllSessions();
782
+ }
783
+
784
+ // Export public API
785
+ export {
786
+ queryClaudeSDK,
787
+ abortClaudeSDKSession,
788
+ isClaudeSDKSessionActive,
789
+ getActiveClaudeSDKSessions,
790
+ resolveToolApproval
791
+ };