@epiphytic/claudecodeui 1.0.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 (142) hide show
  1. package/LICENSE +675 -0
  2. package/README.md +414 -0
  3. package/dist/api-docs.html +879 -0
  4. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  5. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  6. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  7. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  8. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  9. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  10. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  11. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  12. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  13. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  14. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  15. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  16. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  17. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  18. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  19. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  20. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  21. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  22. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  23. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  24. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  25. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  26. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  27. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  28. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  29. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  30. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  31. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  32. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  33. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  34. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  35. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  36. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  37. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  38. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  39. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  40. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  41. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  42. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  43. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  44. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  45. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  46. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  47. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  48. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  49. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  50. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  51. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  52. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  53. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  54. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  55. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  56. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  57. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  58. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  59. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  60. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  61. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  62. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  63. package/dist/assets/index-DfR9xEkp.css +32 -0
  64. package/dist/assets/index-DvlVn6Eb.js +1231 -0
  65. package/dist/assets/vendor-codemirror-CJLzwpLB.js +39 -0
  66. package/dist/assets/vendor-react-DcyRfQm3.js +59 -0
  67. package/dist/assets/vendor-xterm-DfaPXD3y.js +66 -0
  68. package/dist/clear-cache.html +85 -0
  69. package/dist/convert-icons.md +53 -0
  70. package/dist/favicon.png +0 -0
  71. package/dist/favicon.svg +9 -0
  72. package/dist/generate-icons.js +49 -0
  73. package/dist/icons/claude-ai-icon.svg +1 -0
  74. package/dist/icons/codex-white.svg +3 -0
  75. package/dist/icons/codex.svg +3 -0
  76. package/dist/icons/cursor-white.svg +12 -0
  77. package/dist/icons/cursor.svg +1 -0
  78. package/dist/icons/generate-icons.md +19 -0
  79. package/dist/icons/icon-128x128.png +0 -0
  80. package/dist/icons/icon-128x128.svg +12 -0
  81. package/dist/icons/icon-144x144.png +0 -0
  82. package/dist/icons/icon-144x144.svg +12 -0
  83. package/dist/icons/icon-152x152.png +0 -0
  84. package/dist/icons/icon-152x152.svg +12 -0
  85. package/dist/icons/icon-192x192.png +0 -0
  86. package/dist/icons/icon-192x192.svg +12 -0
  87. package/dist/icons/icon-384x384.png +0 -0
  88. package/dist/icons/icon-384x384.svg +12 -0
  89. package/dist/icons/icon-512x512.png +0 -0
  90. package/dist/icons/icon-512x512.svg +12 -0
  91. package/dist/icons/icon-72x72.png +0 -0
  92. package/dist/icons/icon-72x72.svg +12 -0
  93. package/dist/icons/icon-96x96.png +0 -0
  94. package/dist/icons/icon-96x96.svg +12 -0
  95. package/dist/icons/icon-template.svg +12 -0
  96. package/dist/index.html +52 -0
  97. package/dist/logo-128.png +0 -0
  98. package/dist/logo-256.png +0 -0
  99. package/dist/logo-32.png +0 -0
  100. package/dist/logo-512.png +0 -0
  101. package/dist/logo-64.png +0 -0
  102. package/dist/logo.svg +17 -0
  103. package/dist/manifest.json +61 -0
  104. package/dist/screenshots/cli-selection.png +0 -0
  105. package/dist/screenshots/desktop-main.png +0 -0
  106. package/dist/screenshots/mobile-chat.png +0 -0
  107. package/dist/screenshots/tools-modal.png +0 -0
  108. package/dist/sw.js +107 -0
  109. package/package.json +120 -0
  110. package/server/claude-sdk.js +721 -0
  111. package/server/cli.js +469 -0
  112. package/server/cursor-cli.js +267 -0
  113. package/server/database/db.js +554 -0
  114. package/server/database/init.sql +54 -0
  115. package/server/index.js +2120 -0
  116. package/server/middleware/auth.js +161 -0
  117. package/server/openai-codex.js +389 -0
  118. package/server/orchestrator/client.js +989 -0
  119. package/server/orchestrator/github-auth.js +308 -0
  120. package/server/orchestrator/index.js +216 -0
  121. package/server/orchestrator/protocol.js +299 -0
  122. package/server/orchestrator/proxy.js +364 -0
  123. package/server/orchestrator/status-tracker.js +226 -0
  124. package/server/projects.js +1604 -0
  125. package/server/routes/agent.js +1230 -0
  126. package/server/routes/auth.js +135 -0
  127. package/server/routes/cli-auth.js +341 -0
  128. package/server/routes/codex.js +345 -0
  129. package/server/routes/commands.js +521 -0
  130. package/server/routes/cursor.js +795 -0
  131. package/server/routes/git.js +1128 -0
  132. package/server/routes/mcp-utils.js +48 -0
  133. package/server/routes/mcp.js +650 -0
  134. package/server/routes/projects.js +378 -0
  135. package/server/routes/settings.js +178 -0
  136. package/server/routes/taskmaster.js +1963 -0
  137. package/server/routes/user.js +106 -0
  138. package/server/utils/commandParser.js +303 -0
  139. package/server/utils/gitConfig.js +24 -0
  140. package/server/utils/mcp-detector.js +198 -0
  141. package/server/utils/taskmaster-websocket.js +129 -0
  142. package/shared/modelConstants.js +65 -0
@@ -0,0 +1,989 @@
1
+ /**
2
+ * OrchestratorClient
3
+ *
4
+ * WebSocket client that connects claudecodeui to a central orchestrator server.
5
+ * Handles connection management, authentication, heartbeats, and message routing.
6
+ */
7
+
8
+ import { EventEmitter } from "events";
9
+ import WebSocket from "ws";
10
+ import os from "os";
11
+ import { userDb } from "../database/db.js";
12
+ import { generateToken } from "../middleware/auth.js";
13
+ import {
14
+ createRegisterMessage,
15
+ createStatusUpdateMessage,
16
+ createPingMessage,
17
+ createResponseChunkMessage,
18
+ createResponseCompleteMessage,
19
+ createErrorMessage,
20
+ createHttpProxyResponseMessage,
21
+ serialize,
22
+ parse,
23
+ validateInboundMessage,
24
+ InboundMessageTypes,
25
+ StatusValues,
26
+ CommandTypes,
27
+ } from "./protocol.js";
28
+
29
+ /**
30
+ * Default configuration values
31
+ */
32
+ const DEFAULTS = {
33
+ reconnectInterval: 5000,
34
+ heartbeatInterval: 30000,
35
+ heartbeatTimeout: 10000,
36
+ maxReconnectAttempts: 10,
37
+ reconnectBackoffMultiplier: 1.5,
38
+ maxReconnectInterval: 60000,
39
+ };
40
+
41
+ /**
42
+ * OrchestratorClient class
43
+ *
44
+ * Manages the WebSocket connection to the orchestrator server.
45
+ * Emits events: 'connected', 'disconnected', 'error', 'command', 'user_request'
46
+ */
47
+ export class OrchestratorClient extends EventEmitter {
48
+ /**
49
+ * Creates a new OrchestratorClient
50
+ * @param {Object} config - Configuration options
51
+ * @param {string} config.url - Orchestrator WebSocket URL
52
+ * @param {string} config.token - Authentication token
53
+ * @param {string} [config.clientId] - Custom client ID (defaults to hostname-pid)
54
+ * @param {number} [config.reconnectInterval] - Base reconnect interval in ms
55
+ * @param {number} [config.heartbeatInterval] - Heartbeat interval in ms
56
+ * @param {Object} [config.metadata] - Additional metadata to send on register
57
+ * @param {string} [config.callbackUrl] - HTTP callback URL for proxying (e.g., http://localhost:3010)
58
+ */
59
+ constructor(config) {
60
+ super();
61
+
62
+ if (!config.url) {
63
+ throw new Error("Orchestrator URL is required");
64
+ }
65
+ if (!config.token) {
66
+ throw new Error("Orchestrator token is required");
67
+ }
68
+
69
+ this.config = {
70
+ url: config.url,
71
+ token: config.token,
72
+ clientId: config.clientId || `${os.hostname()}-${process.pid}`,
73
+ reconnectInterval: config.reconnectInterval || DEFAULTS.reconnectInterval,
74
+ heartbeatInterval: config.heartbeatInterval || DEFAULTS.heartbeatInterval,
75
+ maxReconnectAttempts:
76
+ config.maxReconnectAttempts || DEFAULTS.maxReconnectAttempts,
77
+ metadata: config.metadata || {},
78
+ callbackUrl: config.callbackUrl || null,
79
+ };
80
+
81
+ this.ws = null;
82
+ this.status = StatusValues.IDLE;
83
+ this.reconnectAttempts = 0;
84
+ this.currentReconnectInterval = this.config.reconnectInterval;
85
+ this.reconnectTimer = null;
86
+ this.heartbeatTimer = null;
87
+ this.heartbeatTimeoutTimer = null;
88
+ this.isConnected = false;
89
+ this.isRegistered = false;
90
+ this.shouldReconnect = true;
91
+ }
92
+
93
+ /**
94
+ * Connects to the orchestrator server
95
+ * @returns {Promise<void>} Resolves when connected and registered
96
+ */
97
+ async connect() {
98
+ return new Promise((resolve, reject) => {
99
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
100
+ resolve();
101
+ return;
102
+ }
103
+
104
+ this.shouldReconnect = true;
105
+
106
+ try {
107
+ // Build connection URL with token and client_id
108
+ // This allows ORCHESTRATOR_URL to just be the base URL (e.g., wss://host/ws/connect)
109
+ // and the token from ORCHESTRATOR_TOKEN is automatically appended
110
+ const connectionUrl = new URL(this.config.url);
111
+ connectionUrl.searchParams.set("token", this.config.token);
112
+ connectionUrl.searchParams.set("client_id", this.config.clientId);
113
+ const urlString = connectionUrl.toString();
114
+
115
+ // Log connection without exposing token
116
+ console.log(
117
+ `[ORCHESTRATOR] Connecting to ${this.config.url} as ${this.config.clientId}`,
118
+ );
119
+ this.ws = new WebSocket(urlString);
120
+
121
+ const connectTimeout = setTimeout(() => {
122
+ if (!this.isConnected) {
123
+ this.ws.terminate();
124
+ reject(new Error("Connection timeout"));
125
+ }
126
+ }, 30000);
127
+
128
+ this.ws.on("open", () => {
129
+ clearTimeout(connectTimeout);
130
+ console.log("[ORCHESTRATOR] WebSocket connection established");
131
+ this.isConnected = true;
132
+ this.reconnectAttempts = 0;
133
+ this.currentReconnectInterval = this.config.reconnectInterval;
134
+
135
+ // Send registration message
136
+ this.sendRegister();
137
+ });
138
+
139
+ this.ws.on("message", (data) => {
140
+ this.handleMessage(data.toString());
141
+ });
142
+
143
+ this.ws.on("close", (code, reason) => {
144
+ clearTimeout(connectTimeout);
145
+ const wasConnected = this.isConnected;
146
+ this.isConnected = false;
147
+ this.isRegistered = false;
148
+ this.stopHeartbeat();
149
+
150
+ console.log(
151
+ `[ORCHESTRATOR] Connection closed: ${code} ${reason || ""}`,
152
+ );
153
+ this.emit("disconnected", { code, reason: reason?.toString() });
154
+
155
+ if (this.shouldReconnect) {
156
+ this.scheduleReconnect();
157
+ }
158
+
159
+ if (!wasConnected) {
160
+ reject(new Error(`Connection failed: ${code}`));
161
+ }
162
+ });
163
+
164
+ this.ws.on("error", (error) => {
165
+ console.error("[ORCHESTRATOR] WebSocket error:", error.message);
166
+ this.emit("error", error);
167
+ });
168
+
169
+ // Wait for registration before resolving
170
+ const onRegistered = () => {
171
+ clearTimeout(connectTimeout);
172
+ this.removeListener("error", onError);
173
+ resolve();
174
+ };
175
+
176
+ const onError = (error) => {
177
+ clearTimeout(connectTimeout);
178
+ this.removeListener("registered", onRegistered);
179
+ reject(error);
180
+ };
181
+
182
+ this.once("registered", onRegistered);
183
+ this.once("error", onError);
184
+
185
+ // Clean up listeners if socket closes before registration
186
+ this.ws.once("close", () => {
187
+ this.removeListener("registered", onRegistered);
188
+ this.removeListener("error", onError);
189
+ });
190
+ } catch (error) {
191
+ console.error("[ORCHESTRATOR] Connection error:", error.message);
192
+ reject(error);
193
+ }
194
+ });
195
+ }
196
+
197
+ /**
198
+ * Disconnects from the orchestrator server
199
+ */
200
+ disconnect() {
201
+ console.log("[ORCHESTRATOR] Disconnecting...");
202
+ this.shouldReconnect = false;
203
+ this.stopHeartbeat();
204
+ this.clearReconnectTimer();
205
+
206
+ if (this.ws) {
207
+ this.ws.close(1000, "Client disconnect");
208
+ this.ws = null;
209
+ }
210
+
211
+ this.isConnected = false;
212
+ this.isRegistered = false;
213
+ }
214
+
215
+ /**
216
+ * Sends registration message to orchestrator
217
+ */
218
+ sendRegister() {
219
+ const metadata = {
220
+ hostname: os.hostname(),
221
+ platform: os.platform(),
222
+ project: process.cwd(),
223
+ status: this.status,
224
+ version: process.env.npm_package_version || "1.0.0",
225
+ // Include callback URL for HTTP proxy support
226
+ // This allows the orchestrator to proxy HTTP requests to this instance
227
+ callback_url: this.config.callbackUrl || null,
228
+ ...this.config.metadata,
229
+ };
230
+
231
+ const message = createRegisterMessage(
232
+ this.config.clientId,
233
+ this.config.token,
234
+ metadata,
235
+ );
236
+ this.sendMessage(message);
237
+ }
238
+
239
+ /**
240
+ * Sends a status update to the orchestrator
241
+ * @param {string} status - New status (idle, active, busy)
242
+ */
243
+ sendStatusUpdate(status) {
244
+ if (!Object.values(StatusValues).includes(status)) {
245
+ console.warn(`[ORCHESTRATOR] Invalid status: ${status}`);
246
+ return;
247
+ }
248
+
249
+ this.status = status;
250
+
251
+ if (!this.isRegistered) {
252
+ console.log(
253
+ "[ORCHESTRATOR] Not registered, queuing status update:",
254
+ status,
255
+ );
256
+ return;
257
+ }
258
+
259
+ const message = createStatusUpdateMessage(this.config.clientId, status);
260
+ this.sendMessage(message);
261
+ }
262
+
263
+ /**
264
+ * Sends a ping message for heartbeat
265
+ */
266
+ sendPing() {
267
+ const message = createPingMessage(this.config.clientId);
268
+ this.sendMessage(message);
269
+
270
+ // Clear any existing heartbeat timeout before setting a new one
271
+ // This prevents stale timers from firing on healthy connections
272
+ if (this.heartbeatTimeoutTimer) {
273
+ clearTimeout(this.heartbeatTimeoutTimer);
274
+ this.heartbeatTimeoutTimer = null;
275
+ }
276
+
277
+ // Set timeout for pong response
278
+ this.heartbeatTimeoutTimer = setTimeout(() => {
279
+ console.warn("[ORCHESTRATOR] Heartbeat timeout, reconnecting...");
280
+ this.ws?.terminate();
281
+ }, DEFAULTS.heartbeatTimeout);
282
+ }
283
+
284
+ /**
285
+ * Sends a response chunk for a proxied request
286
+ * @param {string} requestId - Request ID
287
+ * @param {Object} data - Chunk data
288
+ */
289
+ sendResponseChunk(requestId, data) {
290
+ const message = createResponseChunkMessage(requestId, data);
291
+ this.sendMessage(message);
292
+ }
293
+
294
+ /**
295
+ * Sends a response complete message for a proxied request
296
+ * @param {string} requestId - Request ID
297
+ * @param {Object} [data] - Final data
298
+ */
299
+ sendResponseComplete(requestId, data = null) {
300
+ const message = createResponseCompleteMessage(requestId, data);
301
+ this.sendMessage(message);
302
+ }
303
+
304
+ /**
305
+ * Sends an error message
306
+ * @param {string} requestId - Request ID (optional)
307
+ * @param {string} errorMessage - Error message
308
+ */
309
+ sendError(requestId, errorMessage) {
310
+ const message = createErrorMessage(requestId, errorMessage);
311
+ this.sendMessage(message);
312
+ }
313
+
314
+ /**
315
+ * Sends a message to the orchestrator
316
+ * @param {Object} message - Message object
317
+ */
318
+ sendMessage(message) {
319
+ if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
320
+ console.warn("[ORCHESTRATOR] Cannot send message, not connected");
321
+ return;
322
+ }
323
+
324
+ try {
325
+ this.ws.send(serialize(message));
326
+ } catch (error) {
327
+ console.error("[ORCHESTRATOR] Failed to send message:", error.message);
328
+ }
329
+ }
330
+
331
+ /**
332
+ * Handles incoming messages from the orchestrator
333
+ * @param {string} data - Raw message data
334
+ */
335
+ handleMessage(data) {
336
+ const message = parse(data);
337
+ if (!message) {
338
+ return;
339
+ }
340
+
341
+ if (!validateInboundMessage(message)) {
342
+ console.warn("[ORCHESTRATOR] Invalid message received:", message.type);
343
+ return;
344
+ }
345
+
346
+ switch (message.type) {
347
+ case InboundMessageTypes.REGISTERED:
348
+ this.handleRegistered(message);
349
+ break;
350
+
351
+ case InboundMessageTypes.PONG:
352
+ this.handlePong();
353
+ break;
354
+
355
+ case InboundMessageTypes.COMMAND:
356
+ this.handleCommand(message);
357
+ break;
358
+
359
+ case InboundMessageTypes.ERROR:
360
+ console.error("[ORCHESTRATOR] Error from server:", message.message);
361
+ this.emit("error", new Error(message.message));
362
+ break;
363
+
364
+ case InboundMessageTypes.USER_REQUEST:
365
+ this.handleUserRequest(message);
366
+ break;
367
+
368
+ case InboundMessageTypes.USER_REQUEST_FOLLOW_UP:
369
+ // Emit follow-up messages for proxy handler to process
370
+ this.emit(InboundMessageTypes.USER_REQUEST_FOLLOW_UP, message);
371
+ break;
372
+
373
+ case InboundMessageTypes.HTTP_PROXY_REQUEST:
374
+ this.handleHttpProxyRequest(message);
375
+ break;
376
+
377
+ default:
378
+ console.log("[ORCHESTRATOR] Unknown message type:", message.type);
379
+ }
380
+ }
381
+
382
+ /**
383
+ * Handles registration response
384
+ * @param {Object} message - Registration response message
385
+ */
386
+ handleRegistered(message) {
387
+ if (message.success) {
388
+ console.log("[ORCHESTRATOR] Successfully registered with orchestrator");
389
+ this.isRegistered = true;
390
+ this.startHeartbeat();
391
+ this.emit("registered");
392
+ this.emit("connected");
393
+
394
+ // Send current status if not idle
395
+ if (this.status !== StatusValues.IDLE) {
396
+ this.sendStatusUpdate(this.status);
397
+ }
398
+ } else {
399
+ console.error(
400
+ "[ORCHESTRATOR] Registration failed:",
401
+ message.message || "Unknown error",
402
+ );
403
+ this.emit("error", new Error(message.message || "Registration failed"));
404
+ this.disconnect();
405
+ }
406
+ }
407
+
408
+ /**
409
+ * Handles pong response
410
+ */
411
+ handlePong() {
412
+ if (this.heartbeatTimeoutTimer) {
413
+ clearTimeout(this.heartbeatTimeoutTimer);
414
+ this.heartbeatTimeoutTimer = null;
415
+ }
416
+ }
417
+
418
+ /**
419
+ * Handles command from orchestrator
420
+ * @param {Object} message - Command message
421
+ */
422
+ handleCommand(message) {
423
+ console.log("[ORCHESTRATOR] Received command:", message.command);
424
+
425
+ switch (message.command) {
426
+ case CommandTypes.DISCONNECT:
427
+ console.log("[ORCHESTRATOR] Server requested disconnect");
428
+ this.shouldReconnect = false;
429
+ this.disconnect();
430
+ break;
431
+
432
+ case CommandTypes.REFRESH_STATUS:
433
+ this.sendStatusUpdate(this.status);
434
+ break;
435
+
436
+ default:
437
+ this.emit("command", message);
438
+ }
439
+ }
440
+
441
+ /**
442
+ * Handles user request from orchestrator (proxy mode)
443
+ * @param {Object} message - User request message
444
+ */
445
+ handleUserRequest(message) {
446
+ console.log(
447
+ "[ORCHESTRATOR] Received user request:",
448
+ message.request_id,
449
+ message.action,
450
+ );
451
+ this.emit("user_request", message);
452
+ }
453
+
454
+ /**
455
+ * Handles HTTP proxy request from orchestrator
456
+ * Makes a local HTTP request and sends the response back
457
+ * @param {Object} message - HTTP proxy request message
458
+ */
459
+ async handleHttpProxyRequest(message) {
460
+ const { request_id, method, path, headers, body, query, proxy_base } =
461
+ message;
462
+ console.log(`[ORCHESTRATOR] HTTP proxy request: ${method} ${path}`);
463
+
464
+ try {
465
+ // Extract orchestrator user info from headers for auto-authentication
466
+ let orchestratorUserId = null;
467
+ let orchestratorUsername = null;
468
+
469
+ if (headers && Array.isArray(headers)) {
470
+ for (const [key, value] of headers) {
471
+ if (key.toLowerCase() === "x-orchestrator-user-id") {
472
+ orchestratorUserId = value;
473
+ } else if (key.toLowerCase() === "x-orchestrator-username") {
474
+ orchestratorUsername = value;
475
+ }
476
+ }
477
+ }
478
+
479
+ // If we have orchestrator user info, generate a token for auto-authentication
480
+ let orchestratorToken = null;
481
+ if (orchestratorUserId && orchestratorUsername) {
482
+ try {
483
+ orchestratorToken = await this.getOrCreateOrchestratorToken(
484
+ orchestratorUserId,
485
+ orchestratorUsername,
486
+ );
487
+ } catch (err) {
488
+ console.error(
489
+ "[ORCHESTRATOR] Failed to create orchestrator token:",
490
+ err,
491
+ );
492
+ }
493
+ }
494
+
495
+ // Build the local URL - use the configured local server port
496
+ const port = process.env.PORT || 3010;
497
+ let url = `http://localhost:${port}${path}`;
498
+ if (query) {
499
+ url += `?${query}`;
500
+ }
501
+
502
+ // Build fetch options
503
+ const fetchOptions = {
504
+ method: method || "GET",
505
+ headers: {},
506
+ };
507
+
508
+ // Add headers (skip orchestrator-specific headers and authorization)
509
+ // We'll set our own Authorization header if we have an orchestrator token
510
+ if (headers && Array.isArray(headers)) {
511
+ for (const [key, value] of headers) {
512
+ const keyLower = key.toLowerCase();
513
+ // Skip host header, orchestrator headers, and authorization (we'll set it ourselves)
514
+ if (
515
+ keyLower !== "host" &&
516
+ keyLower !== "authorization" &&
517
+ !keyLower.startsWith("x-orchestrator-")
518
+ ) {
519
+ fetchOptions.headers[key] = value;
520
+ }
521
+ }
522
+ }
523
+
524
+ // If we have an orchestrator token, add it as Authorization header
525
+ // This auto-authenticates the request to claudecodeui
526
+ // We always skip the original Authorization header to avoid case-sensitivity issues
527
+ if (orchestratorToken) {
528
+ // Validate token format before setting (should have 3 parts separated by dots)
529
+ const tokenParts = orchestratorToken.split(".");
530
+ if (tokenParts.length !== 3) {
531
+ console.error(
532
+ `[ORCHESTRATOR] Invalid token format - expected 3 parts, got ${tokenParts.length}`,
533
+ );
534
+ } else {
535
+ fetchOptions.headers["authorization"] = `Bearer ${orchestratorToken}`;
536
+ console.log(
537
+ `[ORCHESTRATOR] Setting auth header for user: ${orchestratorUsername || "[unknown]"} (token: [REDACTED])`,
538
+ );
539
+ }
540
+ }
541
+
542
+ // Add body for non-GET/HEAD requests
543
+ if (body && method !== "GET" && method !== "HEAD") {
544
+ fetchOptions.body = body;
545
+ }
546
+
547
+ // Make the local HTTP request
548
+ const response = await fetch(url, fetchOptions);
549
+
550
+ // Collect response headers
551
+ const responseHeaders = [];
552
+ let contentType = "";
553
+ response.headers.forEach((value, key) => {
554
+ responseHeaders.push([key, value]);
555
+ if (key.toLowerCase() === "content-type") {
556
+ contentType = value;
557
+ }
558
+ });
559
+
560
+ // Determine if content is binary based on content-type
561
+ // Text types: text/*, application/json, application/javascript, application/xml, etc. with utf-8
562
+ const isTextContent =
563
+ contentType.startsWith("text/") ||
564
+ contentType.includes("application/json") ||
565
+ contentType.includes("application/javascript") ||
566
+ contentType.includes("application/xml") ||
567
+ contentType.includes("utf-8");
568
+
569
+ // Get response body - use arrayBuffer for binary, text for text content
570
+ let responseBody;
571
+ if (isTextContent) {
572
+ responseBody = await response.text();
573
+ } else {
574
+ // Binary content - read as arrayBuffer and base64 encode
575
+ const arrayBuffer = await response.arrayBuffer();
576
+ responseBody = Buffer.from(arrayBuffer).toString("base64");
577
+ responseHeaders.push(["x-orch-encoding", "base64"]);
578
+ }
579
+
580
+ // Rewrite URLs if proxy_base is provided and content type is HTML or JavaScript
581
+ let didRewrite = false;
582
+ if (proxy_base) {
583
+ if (contentType.includes("text/html")) {
584
+ console.log(
585
+ `[ORCHESTRATOR] Rewriting HTML URLs with proxy_base: ${proxy_base}`,
586
+ );
587
+ responseBody = this.rewriteHtmlUrls(
588
+ responseBody,
589
+ proxy_base,
590
+ orchestratorToken,
591
+ orchestratorUsername,
592
+ );
593
+ didRewrite = true;
594
+ } else if (contentType.includes("javascript")) {
595
+ console.log(
596
+ `[ORCHESTRATOR] Rewriting JS URLs with proxy_base: ${proxy_base} (content-type: ${contentType})`,
597
+ );
598
+ responseBody = this.rewriteJsUrls(responseBody, proxy_base);
599
+ didRewrite = true;
600
+ }
601
+ }
602
+
603
+ // If we rewrote content, adjust headers for proper caching behavior
604
+ // - Remove Content-Length since body size changed
605
+ // - Modify Cache-Control to use must-revalidate instead of immutable
606
+ // This allows Cloudflare to cache but revalidate periodically
607
+ let finalHeaders = responseHeaders;
608
+ if (didRewrite) {
609
+ finalHeaders = responseHeaders
610
+ .filter(([key]) => key.toLowerCase() !== "content-length")
611
+ .map(([key, value]) => {
612
+ // Modify Cache-Control for rewritten assets
613
+ // Replace immutable with must-revalidate and cap max-age to 1 hour
614
+ if (
615
+ key.toLowerCase() === "cache-control" &&
616
+ value.includes("immutable")
617
+ ) {
618
+ // Change "public, max-age=31536000, immutable" to
619
+ // "public, max-age=3600, must-revalidate" (1 hour)
620
+ const newValue = value
621
+ .replace(/immutable/gi, "must-revalidate")
622
+ .replace(/max-age=\d+/i, "max-age=3600");
623
+ console.log(
624
+ `[ORCHESTRATOR] Modified Cache-Control: ${value} -> ${newValue}`,
625
+ );
626
+ return [key, newValue];
627
+ }
628
+ return [key, value];
629
+ });
630
+ }
631
+
632
+ // Send the response back to orchestrator
633
+ const proxyResponse = createHttpProxyResponseMessage(
634
+ request_id,
635
+ response.status,
636
+ finalHeaders,
637
+ responseBody,
638
+ );
639
+ this.sendMessage(proxyResponse);
640
+
641
+ console.log(
642
+ `[ORCHESTRATOR] HTTP proxy response: ${response.status} (${responseBody.length} bytes)`,
643
+ );
644
+ } catch (error) {
645
+ // Log full error details internally but don't expose to client
646
+ console.error(
647
+ "[ORCHESTRATOR] HTTP proxy request failed:",
648
+ error.message,
649
+ error.stack,
650
+ );
651
+
652
+ // Send generic error response without internal details
653
+ const errorResponse = createHttpProxyResponseMessage(
654
+ request_id,
655
+ 502,
656
+ [["Content-Type", "application/json"]],
657
+ JSON.stringify({ error: "Proxy request failed" }),
658
+ );
659
+ this.sendMessage(errorResponse);
660
+ }
661
+ }
662
+
663
+ /**
664
+ * Escapes a string for safe inclusion in inline JavaScript
665
+ * Prevents XSS by escaping quotes, backslashes, and script-breaking characters
666
+ * @param {string} str - String to escape
667
+ * @returns {string} Escaped string safe for JS interpolation
668
+ */
669
+ escapeForJs(str) {
670
+ if (!str) return "";
671
+ return str
672
+ .replace(/\\/g, "\\\\")
673
+ .replace(/"/g, '\\"')
674
+ .replace(/'/g, "\\'")
675
+ .replace(/</g, "\\x3c")
676
+ .replace(/>/g, "\\x3e")
677
+ .replace(/\n/g, "\\n")
678
+ .replace(/\r/g, "\\r");
679
+ }
680
+
681
+ /**
682
+ * Rewrites absolute URLs in HTML content to go through the proxy
683
+ * @param {string} html - HTML content
684
+ * @param {string} proxyBase - Base proxy path (e.g., "/clients/{id}/proxy")
685
+ * @param {string|null} orchestratorToken - Optional JWT token for auto-authentication
686
+ * @param {string|null} orchestratorUsername - Optional username for token matching
687
+ * @returns {string} HTML with rewritten URLs
688
+ */
689
+ rewriteHtmlUrls(
690
+ html,
691
+ proxyBase,
692
+ orchestratorToken = null,
693
+ orchestratorUsername = null,
694
+ ) {
695
+ // Add a cache-busting version to force fresh fetches from Cloudflare edge
696
+ // Derived from package version to auto-increment on releases
697
+ const cacheVersion = process.env.npm_package_version || "1.0.0";
698
+
699
+ let result = html
700
+ .replace(/src="\/(?!\/)/g, `src="${proxyBase}/`)
701
+ .replace(/href="\/(?!\/)/g, `href="${proxyBase}/`)
702
+ .replace(/action="\/(?!\/)/g, `action="${proxyBase}/`)
703
+ .replace(/src='\/(?!\/)/g, `src='${proxyBase}/`)
704
+ .replace(/href='\/(?!\/)/g, `href='${proxyBase}/`)
705
+ .replace(/action='\/(?!\/)/g, `action='${proxyBase}/`)
706
+ // Handle service worker registration: pass proxyBase as query param
707
+ // e.g., register('/sw.js') -> register('/clients/.../proxy/sw.js?proxyBase=/clients/.../proxy')
708
+ .replace(
709
+ /\.register\('\/sw\.js'\)/g,
710
+ `.register('${proxyBase}/sw.js?proxyBase=${encodeURIComponent(proxyBase)}')`,
711
+ )
712
+ .replace(
713
+ /\.register\("\/sw\.js"\)/g,
714
+ `.register("${proxyBase}/sw.js?proxyBase=${encodeURIComponent(proxyBase)}")`,
715
+ );
716
+
717
+ // Add cache-busting parameter to JS and CSS asset URLs
718
+ // Match patterns like src="/clients/.../proxy/assets/file.js" or href="/clients/.../proxy/assets/file.css"
719
+ result = result.replace(
720
+ /(<script[^>]+src="[^"]+\.js)(")/g,
721
+ `$1?_=${cacheVersion}$2`,
722
+ );
723
+ result = result.replace(
724
+ /(<link[^>]+href="[^"]+\.css)(")/g,
725
+ `$1?_=${cacheVersion}$2`,
726
+ );
727
+
728
+ // Inject scripts to:
729
+ // 1. Auto-authenticate via orchestrator token
730
+ // 2. Patch fetch() to redirect API calls through the proxy
731
+ //
732
+ // Note: React app uses 'auth-token' as the localStorage key
733
+ // We need to check if the existing token belongs to the same orchestrator user.
734
+ // If not, we update the token. We also store the orchestrator username separately
735
+ // to detect user changes without decoding the JWT on every request.
736
+ const proxyPatchScript = `<script>
737
+ // Patch fetch and WebSocket to redirect through the proxy
738
+ (function() {
739
+ const proxyBase = "${proxyBase}";
740
+
741
+ // Make proxyBase available globally for React app components
742
+ window.__ORCHESTRATOR_PROXY_BASE__ = proxyBase;
743
+
744
+ const originalFetch = window.fetch;
745
+
746
+ window.fetch = function(url, options) {
747
+ // Convert URL to string if it's a Request object
748
+ let urlStr = url instanceof Request ? url.url : String(url);
749
+
750
+ // If it's an absolute path (starts with /) but not a full URL (no protocol)
751
+ // and not already going through the proxy, redirect it
752
+ // EXCEPT: Orchestrator API paths should go directly to the orchestrator, not through the proxy
753
+ const isOrchestratorApi = urlStr.startsWith('/api/clients/') || urlStr.startsWith('/api/tokens');
754
+ if (urlStr.startsWith('/') && !urlStr.startsWith('//') && !urlStr.startsWith(proxyBase) && !isOrchestratorApi) {
755
+ const newUrl = proxyBase + urlStr;
756
+ console.log('[ORCHESTRATOR] Redirecting fetch:', urlStr, '->', newUrl);
757
+
758
+ if (url instanceof Request) {
759
+ // Create a new Request with the modified URL
760
+ url = new Request(newUrl, url);
761
+ } else {
762
+ url = newUrl;
763
+ }
764
+ }
765
+
766
+ return originalFetch.call(this, url, options);
767
+ };
768
+
769
+ console.log('[ORCHESTRATOR] Fetch patched for proxy base:', proxyBase);
770
+ })();
771
+ </script>`;
772
+
773
+ // Escape user-controlled values to prevent XSS
774
+ const safeUsername = this.escapeForJs(orchestratorUsername || "");
775
+ const safeToken = this.escapeForJs(orchestratorToken || "");
776
+
777
+ const authScript = orchestratorToken
778
+ ? `<script>
779
+ // Auto-authenticate via orchestrator token
780
+ (function() {
781
+ const existingToken = localStorage.getItem('auth-token');
782
+ const storedOrchestratorUser = localStorage.getItem('orchestrator-user');
783
+ const orchestratorUsername = "${safeUsername}";
784
+
785
+ // Check if we need to update the token:
786
+ // 1. No existing token
787
+ // 2. Orchestrator user changed (different GitHub user accessing via proxy)
788
+ // 3. Token exists but no stored orchestrator user (legacy direct login token)
789
+ const needsUpdate = !existingToken ||
790
+ (orchestratorUsername && storedOrchestratorUser !== orchestratorUsername) ||
791
+ (existingToken && !storedOrchestratorUser && orchestratorUsername);
792
+
793
+ if (needsUpdate) {
794
+ const token = "${safeToken}";
795
+ localStorage.setItem('auth-token', token);
796
+ if (orchestratorUsername) {
797
+ localStorage.setItem('orchestrator-user', orchestratorUsername);
798
+ }
799
+ console.log('[ORCHESTRATOR] Auto-authenticated via orchestrator proxy for user:', orchestratorUsername || 'unknown');
800
+ // Reload the page so the app initializes with the token
801
+ window.location.reload();
802
+ return;
803
+ }
804
+ console.log('[ORCHESTRATOR] Already have valid auth token for user:', storedOrchestratorUser);
805
+ })();
806
+ </script>`
807
+ : "";
808
+
809
+ // Inject both scripts right after the opening <head> tag
810
+ // Proxy patch must come first so fetch is patched before any other scripts run
811
+ result = result.replace(
812
+ /<head>/i,
813
+ `<head>${proxyPatchScript}${authScript}`,
814
+ );
815
+
816
+ return result;
817
+ }
818
+
819
+ /**
820
+ * Rewrites absolute URLs in JavaScript content to go through the proxy
821
+ * @param {string} js - JavaScript content
822
+ * @param {string} proxyBase - Base proxy path (e.g., "/clients/{id}/proxy")
823
+ * @returns {string} JavaScript with rewritten URLs
824
+ */
825
+ rewriteJsUrls(js, proxyBase) {
826
+ // Only rewrite specific path prefixes that are likely URLs, not regex patterns
827
+ // This is more selective than matching all "/" to avoid breaking regex literals
828
+ const urlPrefixes = [
829
+ "api",
830
+ "assets",
831
+ "auth",
832
+ "ws",
833
+ "favicon",
834
+ "static",
835
+ "socket.io",
836
+ "sw.js",
837
+ "manifest.json",
838
+ "icons",
839
+ ];
840
+
841
+ // Orchestrator API paths that should NOT be rewritten (they go directly to the orchestrator)
842
+ const orchestratorApiPaths = ["/api/clients/", "/api/tokens"];
843
+
844
+ let result = js;
845
+ for (const prefix of urlPrefixes) {
846
+ // Escape regex metacharacters in the prefix to match literal characters
847
+ const escapedPrefix = prefix.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
848
+ // Match "/${prefix}, '/${prefix}, `/${prefix} patterns
849
+ result = result
850
+ .replace(
851
+ new RegExp(`"\\/${escapedPrefix}(?=[\\/"])`, "g"),
852
+ `"${proxyBase}/${prefix}`,
853
+ )
854
+ .replace(
855
+ new RegExp(`'\\/${escapedPrefix}(?=[\\/'])`, "g"),
856
+ `'${proxyBase}/${prefix}`,
857
+ )
858
+ .replace(
859
+ new RegExp(`\`\\/${escapedPrefix}(?=[\\/\`])`, "g"),
860
+ `\`${proxyBase}/${prefix}`,
861
+ );
862
+ }
863
+
864
+ // Undo rewriting for orchestrator API paths - these should go directly to orchestrator
865
+ for (const path of orchestratorApiPaths) {
866
+ const escapedPath = path.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
867
+ const rewrittenPath = `${proxyBase}${path}`;
868
+ const escapedRewrittenPath = rewrittenPath.replace(
869
+ /[.*+?^${}()|[\]\\]/g,
870
+ "\\$&",
871
+ );
872
+ // Restore the original path by replacing the rewritten version
873
+ result = result
874
+ .replace(new RegExp(`"${escapedRewrittenPath}`, "g"), `"${path}`)
875
+ .replace(new RegExp(`'${escapedRewrittenPath}`, "g"), `'${path}`)
876
+ .replace(new RegExp(`\`${escapedRewrittenPath}`, "g"), `\`${path}`);
877
+ }
878
+
879
+ return result;
880
+ }
881
+
882
+ /**
883
+ * Starts the heartbeat interval
884
+ */
885
+ startHeartbeat() {
886
+ this.stopHeartbeat();
887
+ this.heartbeatTimer = setInterval(() => {
888
+ this.sendPing();
889
+ }, this.config.heartbeatInterval);
890
+ }
891
+
892
+ /**
893
+ * Stops the heartbeat interval
894
+ */
895
+ stopHeartbeat() {
896
+ if (this.heartbeatTimer) {
897
+ clearInterval(this.heartbeatTimer);
898
+ this.heartbeatTimer = null;
899
+ }
900
+ if (this.heartbeatTimeoutTimer) {
901
+ clearTimeout(this.heartbeatTimeoutTimer);
902
+ this.heartbeatTimeoutTimer = null;
903
+ }
904
+ }
905
+
906
+ /**
907
+ * Schedules a reconnection attempt with exponential backoff
908
+ */
909
+ scheduleReconnect() {
910
+ if (!this.shouldReconnect) {
911
+ return;
912
+ }
913
+
914
+ if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
915
+ console.error("[ORCHESTRATOR] Max reconnect attempts reached, giving up");
916
+ this.emit("error", new Error("Max reconnect attempts reached"));
917
+ return;
918
+ }
919
+
920
+ this.reconnectAttempts++;
921
+ console.log(
922
+ `[ORCHESTRATOR] Scheduling reconnect attempt ${this.reconnectAttempts}/${this.config.maxReconnectAttempts} in ${this.currentReconnectInterval}ms`,
923
+ );
924
+
925
+ this.reconnectTimer = setTimeout(async () => {
926
+ try {
927
+ await this.connect();
928
+ } catch (error) {
929
+ console.error("[ORCHESTRATOR] Reconnect failed:", error.message);
930
+ }
931
+ }, this.currentReconnectInterval);
932
+
933
+ // Exponential backoff
934
+ this.currentReconnectInterval = Math.min(
935
+ this.currentReconnectInterval * DEFAULTS.reconnectBackoffMultiplier,
936
+ DEFAULTS.maxReconnectInterval,
937
+ );
938
+ }
939
+
940
+ /**
941
+ * Clears the reconnect timer
942
+ */
943
+ clearReconnectTimer() {
944
+ if (this.reconnectTimer) {
945
+ clearTimeout(this.reconnectTimer);
946
+ this.reconnectTimer = null;
947
+ }
948
+ }
949
+
950
+ /**
951
+ * Gets the current connection state
952
+ * @returns {Object} Connection state
953
+ */
954
+ getState() {
955
+ return {
956
+ isConnected: this.isConnected,
957
+ isRegistered: this.isRegistered,
958
+ status: this.status,
959
+ clientId: this.config.clientId,
960
+ reconnectAttempts: this.reconnectAttempts,
961
+ };
962
+ }
963
+
964
+ /**
965
+ * Gets or creates a JWT token for an orchestrator-authenticated user
966
+ * This enables seamless authentication when accessing claudecodeui through the orchestrator proxy
967
+ * @param {string} githubId - GitHub user ID from orchestrator
968
+ * @param {string} githubUsername - GitHub username from orchestrator
969
+ * @returns {string} JWT token for the user
970
+ */
971
+ async getOrCreateOrchestratorToken(githubId, githubUsername) {
972
+ // Get or create the user in the local database
973
+ const user = await userDb.getOrCreateOrchestratorUser(
974
+ githubId,
975
+ githubUsername,
976
+ );
977
+
978
+ // Generate a JWT token for this user
979
+ const token = generateToken(user);
980
+
981
+ console.log(
982
+ `[ORCHESTRATOR] Generated token for orchestrator user: ${githubUsername} (GitHub ID: ${githubId})`,
983
+ );
984
+
985
+ return token;
986
+ }
987
+ }
988
+
989
+ export default OrchestratorClient;