@epiphytic/claudecodeui 1.1.0 → 1.2.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.
package/server/index.js CHANGED
@@ -589,7 +589,7 @@ app.get(
589
589
  },
590
590
  );
591
591
 
592
- // Get messages for a specific session
592
+ // Get messages for a specific session with ETag caching support
593
593
  app.get(
594
594
  "/api/projects/:projectName/sessions/:sessionId/messages",
595
595
  authenticateToken,
@@ -609,6 +609,27 @@ app.get(
609
609
  parsedOffset,
610
610
  );
611
611
 
612
+ // Generate ETag based on message count and last timestamp
613
+ const messages = Array.isArray(result) ? result : result.messages || [];
614
+ const total = Array.isArray(result) ? messages.length : result.total || 0;
615
+ const lastTimestamp =
616
+ messages.length > 0
617
+ ? messages[messages.length - 1]?.timestamp || ""
618
+ : "";
619
+ const currentETag = `"${sessionId}-${total}-${Buffer.from(lastTimestamp).toString("base64").slice(0, 16)}"`;
620
+
621
+ // Check If-None-Match header for conditional request
622
+ const clientETag = req.headers["if-none-match"];
623
+ if (clientETag && clientETag === currentETag) {
624
+ return res.status(304).end();
625
+ }
626
+
627
+ // Set caching headers
628
+ res.set({
629
+ "Cache-Control": "private, max-age=5",
630
+ ETag: currentETag,
631
+ });
632
+
612
633
  // Handle both old and new response formats
613
634
  if (Array.isArray(result)) {
614
635
  // Backward compatibility: no pagination parameters were provided
@@ -1126,6 +1147,32 @@ async function handleChatMessage(ws, writer, messageData) {
1126
1147
  const sessionIdForTracking =
1127
1148
  data.options?.sessionId || data.sessionId || `session-${Date.now()}`;
1128
1149
 
1150
+ // Handle proactive external session check (before user submits a prompt)
1151
+ if (data.type === "check-external-session") {
1152
+ const projectPath = data.projectPath;
1153
+ if (projectPath) {
1154
+ const externalCheck = detectExternalClaude(projectPath);
1155
+ writer.send({
1156
+ type: "external-session-check-result",
1157
+ projectPath,
1158
+ hasExternalSession: externalCheck.hasExternalSession,
1159
+ details: externalCheck.hasExternalSession
1160
+ ? {
1161
+ processIds: externalCheck.processes.map((p) => p.pid),
1162
+ commands: externalCheck.processes.map((p) => p.command),
1163
+ tmuxSessions: externalCheck.tmuxSessions.map(
1164
+ (s) => s.sessionName,
1165
+ ),
1166
+ lockFile: externalCheck.lockFile.exists
1167
+ ? externalCheck.lockFile.lockFile
1168
+ : null,
1169
+ }
1170
+ : null,
1171
+ });
1172
+ }
1173
+ return;
1174
+ }
1175
+
1129
1176
  if (data.type === "claude-command") {
1130
1177
  console.log("[DEBUG] User message:", data.command || "[Continue/Resume]");
1131
1178
  console.log("📁 Project:", data.options?.projectPath || "Unknown");
@@ -2389,6 +2436,9 @@ app.get(
2389
2436
  try {
2390
2437
  const { projectName, sessionId } = req.params;
2391
2438
  const { provider = "claude" } = req.query;
2439
+ console.log(
2440
+ `[TOKEN-USAGE] Request for project: ${projectName}, session: ${sessionId}, provider: ${provider}`,
2441
+ );
2392
2442
  const homeDir = os.homedir();
2393
2443
 
2394
2444
  // Allow only safe characters in sessionId
@@ -2507,8 +2557,8 @@ app.get(
2507
2557
 
2508
2558
  // Construct the JSONL file path
2509
2559
  // Claude stores session files in ~/.claude/projects/[encoded-project-path]/[session-id].jsonl
2510
- // The encoding replaces /, spaces, ~, and _ with -
2511
- const encodedPath = projectPath.replace(/[\\/:\s~_]/g, "-");
2560
+ // The encoding replaces /, spaces, ~, _, and . with -
2561
+ const encodedPath = projectPath.replace(/[\\/:\s~_.]/g, "-");
2512
2562
  const projectDir = path.join(homeDir, ".claude", "projects", encodedPath);
2513
2563
 
2514
2564
  const jsonlPath = path.join(projectDir, `${safeSessionId}.jsonl`);
@@ -2528,9 +2578,22 @@ app.get(
2528
2578
  fileContent = await fsPromises.readFile(jsonlPath, "utf8");
2529
2579
  } catch (error) {
2530
2580
  if (error.code === "ENOENT") {
2531
- return res
2532
- .status(404)
2533
- .json({ error: "Session file not found", path: jsonlPath });
2581
+ // Session file doesn't exist yet (new session with no messages)
2582
+ // Return zero token usage instead of 404
2583
+ const parsedContextWindow = parseInt(process.env.CONTEXT_WINDOW, 10);
2584
+ const contextWindow = Number.isFinite(parsedContextWindow)
2585
+ ? parsedContextWindow
2586
+ : 160000;
2587
+ return res.json({
2588
+ used: 0,
2589
+ total: contextWindow,
2590
+ breakdown: {
2591
+ input: 0,
2592
+ cacheCreation: 0,
2593
+ cacheRead: 0,
2594
+ },
2595
+ newSession: true,
2596
+ });
2534
2597
  }
2535
2598
  throw error; // Re-throw other errors to be caught by outer try-catch
2536
2599
  }
@@ -8,12 +8,13 @@
8
8
  import { EventEmitter } from "events";
9
9
  import WebSocket from "ws";
10
10
  import os from "os";
11
- import { userDb } from "../database/db.js";
11
+ import { userDb, orchestratorTokensDb } from "../database/db.js";
12
12
  import { generateToken } from "../middleware/auth.js";
13
13
  import {
14
14
  createRegisterMessage,
15
15
  createStatusUpdateMessage,
16
16
  createPingMessage,
17
+ createPendingRegisterMessage,
17
18
  createResponseChunkMessage,
18
19
  createResponseCompleteMessage,
19
20
  createErrorMessage,
@@ -49,12 +50,16 @@ export class OrchestratorClient extends EventEmitter {
49
50
  * Creates a new OrchestratorClient
50
51
  * @param {Object} config - Configuration options
51
52
  * @param {string} config.url - Orchestrator WebSocket URL
52
- * @param {string} config.token - Authentication token
53
+ * @param {string} [config.token] - Authentication token (optional for pending mode)
53
54
  * @param {string} [config.clientId] - Custom client ID (defaults to hostname-pid)
54
55
  * @param {number} [config.reconnectInterval] - Base reconnect interval in ms
55
56
  * @param {number} [config.heartbeatInterval] - Heartbeat interval in ms
56
57
  * @param {Object} [config.metadata] - Additional metadata to send on register
57
58
  * @param {string} [config.callbackUrl] - HTTP callback URL for proxying (e.g., http://localhost:3010)
59
+ * @param {Object} [config.claimPatterns] - Claim patterns for pending mode authorization
60
+ * @param {string} [config.claimPatterns.user] - GitHub username claim
61
+ * @param {string} [config.claimPatterns.org] - GitHub organization claim
62
+ * @param {string} [config.claimPatterns.team] - GitHub team claim (format: org/team-slug)
58
63
  */
59
64
  constructor(config) {
60
65
  super();
@@ -62,13 +67,10 @@ export class OrchestratorClient extends EventEmitter {
62
67
  if (!config.url) {
63
68
  throw new Error("Orchestrator URL is required");
64
69
  }
65
- if (!config.token) {
66
- throw new Error("Orchestrator token is required");
67
- }
68
70
 
69
71
  this.config = {
70
72
  url: config.url,
71
- token: config.token,
73
+ token: config.token || null,
72
74
  clientId: config.clientId || `${os.hostname()}-${process.pid}`,
73
75
  reconnectInterval: config.reconnectInterval || DEFAULTS.reconnectInterval,
74
76
  heartbeatInterval: config.heartbeatInterval || DEFAULTS.heartbeatInterval,
@@ -76,6 +78,7 @@ export class OrchestratorClient extends EventEmitter {
76
78
  config.maxReconnectAttempts || DEFAULTS.maxReconnectAttempts,
77
79
  metadata: config.metadata || {},
78
80
  callbackUrl: config.callbackUrl || null,
81
+ claimPatterns: config.claimPatterns || {},
79
82
  };
80
83
 
81
84
  this.ws = null;
@@ -88,27 +91,97 @@ export class OrchestratorClient extends EventEmitter {
88
91
  this.isConnected = false;
89
92
  this.isRegistered = false;
90
93
  this.shouldReconnect = true;
94
+
95
+ // Pending mode state
96
+ this.pendingMode = false;
97
+ this.pendingId = null;
98
+ this.orchestratorHost = null;
99
+ }
100
+
101
+ /**
102
+ * Resolves the orchestrator token using precedence rules
103
+ * @returns {Promise<string|null>} The token or null if none available
104
+ */
105
+ async resolveToken() {
106
+ // 1. Check config first (from .env ORCHESTRATOR_TOKEN)
107
+ if (this.config.token && this.config.token.trim() !== "") {
108
+ return this.config.token;
109
+ }
110
+
111
+ // 2. Check database for host-specific token
112
+ try {
113
+ const url = new URL(
114
+ this.config.url
115
+ .replace("wss://", "https://")
116
+ .replace("ws://", "http://"),
117
+ );
118
+ this.orchestratorHost = url.host;
119
+
120
+ const stored = orchestratorTokensDb.getToken(this.orchestratorHost);
121
+ if (stored?.token) {
122
+ console.log(
123
+ `[ORCHESTRATOR] Using stored token for host: ${this.orchestratorHost}`,
124
+ );
125
+ return stored.token;
126
+ }
127
+ } catch (error) {
128
+ console.error("[ORCHESTRATOR] Error resolving token:", error.message);
129
+ }
130
+
131
+ // 3. No token available
132
+ return null;
91
133
  }
92
134
 
93
135
  /**
94
136
  * Connects to the orchestrator server
137
+ * Determines whether to use authenticated or pending mode
95
138
  * @returns {Promise<void>} Resolves when connected and registered
96
139
  */
97
140
  async connect() {
98
- return new Promise((resolve, reject) => {
99
- if (this.ws && this.ws.readyState === WebSocket.OPEN) {
100
- resolve();
101
- return;
102
- }
141
+ if (this.ws && this.ws.readyState === WebSocket.OPEN) {
142
+ return;
143
+ }
144
+
145
+ // Parse host for token storage
146
+ try {
147
+ const url = new URL(
148
+ this.config.url
149
+ .replace("wss://", "https://")
150
+ .replace("ws://", "http://"),
151
+ );
152
+ this.orchestratorHost = url.host;
153
+ } catch (error) {
154
+ console.error("[ORCHESTRATOR] Invalid orchestrator URL:", error.message);
155
+ throw error;
156
+ }
157
+
158
+ // Resolve token
159
+ const token = await this.resolveToken();
160
+
161
+ if (token) {
162
+ // Authenticated mode - existing flow
163
+ this.pendingMode = false;
164
+ return this.connectWithToken(token);
165
+ } else {
166
+ // Pending mode - new flow
167
+ this.pendingMode = true;
168
+ return this.connectPending();
169
+ }
170
+ }
103
171
 
172
+ /**
173
+ * Connects in authenticated mode with a token
174
+ * @param {string} token - The authentication token
175
+ * @returns {Promise<void>} Resolves when connected and registered
176
+ */
177
+ async connectWithToken(token) {
178
+ return new Promise((resolve, reject) => {
104
179
  this.shouldReconnect = true;
105
180
 
106
181
  try {
107
182
  // 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
183
  const connectionUrl = new URL(this.config.url);
111
- connectionUrl.searchParams.set("token", this.config.token);
184
+ connectionUrl.searchParams.set("token", token);
112
185
  connectionUrl.searchParams.set("client_id", this.config.clientId);
113
186
  const urlString = connectionUrl.toString();
114
187
 
@@ -194,6 +267,136 @@ export class OrchestratorClient extends EventEmitter {
194
267
  });
195
268
  }
196
269
 
270
+ /**
271
+ * Connects in pending mode (no token)
272
+ * @returns {Promise<void>} Resolves when connected (but not fully registered)
273
+ */
274
+ async connectPending() {
275
+ return new Promise((resolve, reject) => {
276
+ this.shouldReconnect = true;
277
+
278
+ // Build claim patterns from config
279
+ const { claimPatterns } = this.config;
280
+ const hasClaimPattern =
281
+ (claimPatterns.user && claimPatterns.user.trim()) ||
282
+ (claimPatterns.org && claimPatterns.org.trim()) ||
283
+ (claimPatterns.team && claimPatterns.team.trim());
284
+
285
+ if (!hasClaimPattern) {
286
+ const error = new Error(
287
+ "Pending mode requires at least one claim pattern (user, org, or team)",
288
+ );
289
+ console.error("[ORCHESTRATOR]", error.message);
290
+ reject(error);
291
+ return;
292
+ }
293
+
294
+ try {
295
+ // Build pending connection URL
296
+ const pendingUrl = new URL(
297
+ this.config.url.replace("/ws/connect", "/ws/pending"),
298
+ );
299
+ if (claimPatterns.user) {
300
+ pendingUrl.searchParams.set("user", claimPatterns.user);
301
+ }
302
+ if (claimPatterns.org) {
303
+ pendingUrl.searchParams.set("org", claimPatterns.org);
304
+ }
305
+ if (claimPatterns.team) {
306
+ pendingUrl.searchParams.set("team", claimPatterns.team);
307
+ }
308
+
309
+ console.log(
310
+ `[ORCHESTRATOR] Connecting in pending mode to ${pendingUrl.origin}${pendingUrl.pathname}`,
311
+ );
312
+ this.ws = new WebSocket(pendingUrl.toString());
313
+
314
+ const connectTimeout = setTimeout(() => {
315
+ if (!this.isConnected) {
316
+ this.ws.terminate();
317
+ reject(new Error("Connection timeout"));
318
+ }
319
+ }, 30000);
320
+
321
+ this.ws.on("open", () => {
322
+ clearTimeout(connectTimeout);
323
+ console.log("[ORCHESTRATOR] Pending mode connection established");
324
+ this.isConnected = true;
325
+ this.reconnectAttempts = 0;
326
+ this.currentReconnectInterval = this.config.reconnectInterval;
327
+
328
+ // Send pending registration message
329
+ this.sendPendingRegister();
330
+ this.startHeartbeat();
331
+ resolve();
332
+ });
333
+
334
+ this.ws.on("message", (data) => {
335
+ this.handleMessage(data.toString());
336
+ });
337
+
338
+ this.ws.on("close", (code, reason) => {
339
+ clearTimeout(connectTimeout);
340
+ const wasConnected = this.isConnected;
341
+ this.isConnected = false;
342
+ this.isRegistered = false;
343
+ this.stopHeartbeat();
344
+
345
+ console.log(
346
+ `[ORCHESTRATOR] Pending connection closed: ${code} ${reason || ""}`,
347
+ );
348
+ this.emit("disconnected", { code, reason: reason?.toString() });
349
+
350
+ if (this.shouldReconnect) {
351
+ this.scheduleReconnect();
352
+ }
353
+
354
+ if (!wasConnected) {
355
+ reject(new Error(`Connection failed: ${code}`));
356
+ }
357
+ });
358
+
359
+ this.ws.on("error", (error) => {
360
+ console.error("[ORCHESTRATOR] Pending mode error:", error.message);
361
+ this.emit("error", error);
362
+ });
363
+ } catch (error) {
364
+ console.error(
365
+ "[ORCHESTRATOR] Pending connection error:",
366
+ error.message,
367
+ );
368
+ reject(error);
369
+ }
370
+ });
371
+ }
372
+
373
+ /**
374
+ * Sends pending registration message for pending mode
375
+ */
376
+ sendPendingRegister() {
377
+ this.pendingId = this.generatePendingId();
378
+
379
+ const message = createPendingRegisterMessage(
380
+ this.pendingId,
381
+ os.hostname(),
382
+ process.cwd(),
383
+ os.platform(),
384
+ );
385
+
386
+ this.sendMessage(message);
387
+ console.log(
388
+ "[ORCHESTRATOR] Sent pending registration, waiting for authorization...",
389
+ );
390
+ }
391
+
392
+ /**
393
+ * Generates a unique pending ID
394
+ * @returns {string} Unique pending ID
395
+ */
396
+ generatePendingId() {
397
+ return `pending_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
398
+ }
399
+
197
400
  /**
198
401
  * Disconnects from the orchestrator server
199
402
  */
@@ -375,6 +578,23 @@ export class OrchestratorClient extends EventEmitter {
375
578
  this.handleHttpProxyRequest(message);
376
579
  break;
377
580
 
581
+ // Pending mode messages
582
+ case InboundMessageTypes.PENDING_REGISTERED:
583
+ this.handlePendingRegistered(message);
584
+ break;
585
+
586
+ case InboundMessageTypes.TOKEN_GRANTED:
587
+ this.handleTokenGranted(message);
588
+ break;
589
+
590
+ case InboundMessageTypes.AUTHORIZATION_DENIED:
591
+ this.handleAuthorizationDenied(message);
592
+ break;
593
+
594
+ case InboundMessageTypes.AUTHORIZATION_TIMEOUT:
595
+ this.handleAuthorizationTimeout(message);
596
+ break;
597
+
378
598
  default:
379
599
  console.log("[ORCHESTRATOR] Unknown message type:", message.type);
380
600
  }
@@ -452,6 +672,94 @@ export class OrchestratorClient extends EventEmitter {
452
672
  this.emit("user_request", message);
453
673
  }
454
674
 
675
+ /**
676
+ * Handles pending registration acknowledgment
677
+ * @param {Object} message - Pending registered message
678
+ */
679
+ handlePendingRegistered(message) {
680
+ if (message.success) {
681
+ console.log(
682
+ "[ORCHESTRATOR] Pending registration accepted:",
683
+ message.message || "Waiting for authorization",
684
+ );
685
+ this.emit("pending_registered");
686
+ } else {
687
+ console.error(
688
+ "[ORCHESTRATOR] Pending registration failed:",
689
+ message.message || "Unknown error",
690
+ );
691
+ this.emit(
692
+ "error",
693
+ new Error(message.message || "Pending registration failed"),
694
+ );
695
+ }
696
+ }
697
+
698
+ /**
699
+ * Handles token granted - authorization successful
700
+ * @param {Object} message - Token granted message
701
+ */
702
+ async handleTokenGranted(message) {
703
+ const { token, client_id } = message;
704
+
705
+ console.log("[ORCHESTRATOR] Authorization granted! Received token.");
706
+
707
+ // Store token in database for future connections
708
+ try {
709
+ orchestratorTokensDb.saveToken(this.orchestratorHost, token, client_id);
710
+ console.log(
711
+ `[ORCHESTRATOR] Token stored for host: ${this.orchestratorHost}`,
712
+ );
713
+ } catch (error) {
714
+ console.error("[ORCHESTRATOR] Failed to store token:", error.message);
715
+ }
716
+
717
+ // Update config with new token
718
+ this.config.token = token;
719
+ this.config.clientId = client_id;
720
+ this.pendingMode = false;
721
+
722
+ // Emit event for any listeners
723
+ this.emit("token_granted", { token, client_id });
724
+
725
+ // Disconnect and reconnect with the new token
726
+ console.log("[ORCHESTRATOR] Reconnecting with new token...");
727
+ this.disconnect();
728
+
729
+ // Small delay before reconnecting
730
+ setTimeout(async () => {
731
+ try {
732
+ await this.connectWithToken(token);
733
+ console.log("[ORCHESTRATOR] Successfully reconnected with new token");
734
+ } catch (error) {
735
+ console.error(
736
+ "[ORCHESTRATOR] Failed to reconnect with new token:",
737
+ error.message,
738
+ );
739
+ }
740
+ }, 1000);
741
+ }
742
+
743
+ /**
744
+ * Handles authorization denied
745
+ * @param {Object} message - Authorization denied message
746
+ */
747
+ handleAuthorizationDenied(message) {
748
+ console.error("[ORCHESTRATOR] Authorization denied:", message.reason);
749
+ this.emit("authorization_denied", { reason: message.reason });
750
+ }
751
+
752
+ /**
753
+ * Handles authorization timeout (e.g., 10 minutes expired)
754
+ * @param {Object} message - Authorization timeout message
755
+ */
756
+ handleAuthorizationTimeout(message) {
757
+ console.warn("[ORCHESTRATOR] Authorization timed out:", message.message);
758
+ this.emit("authorization_timeout", { message: message.message });
759
+ // The WebSocket will be closed by the server
760
+ // The reconnection logic will attempt to reconnect in pending mode again
761
+ }
762
+
455
763
  /**
456
764
  * Handles HTTP proxy request from orchestrator
457
765
  * Makes a local HTTP request and sends the response back
@@ -460,7 +768,9 @@ export class OrchestratorClient extends EventEmitter {
460
768
  async handleHttpProxyRequest(message) {
461
769
  const { request_id, method, path, headers, body, query, proxy_base } =
462
770
  message;
463
- console.log(`[ORCHESTRATOR] HTTP proxy request: ${method} ${path}`);
771
+ console.log(
772
+ `[ORCHESTRATOR] HTTP proxy request: ${method} ${path} (proxy_base: ${proxy_base || "none"})`,
773
+ );
464
774
 
465
775
  try {
466
776
  // Extract orchestrator user info from headers for auto-authentication
@@ -548,6 +858,30 @@ export class OrchestratorClient extends EventEmitter {
548
858
  // Make the local HTTP request
549
859
  const response = await fetch(url, fetchOptions);
550
860
 
861
+ // Log non-200 responses for debugging
862
+ if (!response.ok) {
863
+ console.log(
864
+ `[ORCHESTRATOR] HTTP proxy non-OK response: ${response.status} for ${path}`,
865
+ );
866
+ }
867
+
868
+ // Handle 304 Not Modified - return immediately with no body
869
+ if (response.status === 304) {
870
+ const responseHeaders = [];
871
+ response.headers.forEach((value, key) => {
872
+ responseHeaders.push([key, value]);
873
+ });
874
+ const proxyResponse = createHttpProxyResponseMessage(
875
+ request_id,
876
+ 304,
877
+ responseHeaders,
878
+ "",
879
+ );
880
+ this.sendMessage(proxyResponse);
881
+ console.log(`[ORCHESTRATOR] HTTP proxy response: 304 Not Modified`);
882
+ return;
883
+ }
884
+
551
885
  // Collect response headers
552
886
  const responseHeaders = [];
553
887
  let contentType = "";
@@ -559,23 +893,34 @@ export class OrchestratorClient extends EventEmitter {
559
893
  });
560
894
 
561
895
  // Determine if content is binary based on content-type
562
- // Text types: text/*, application/json, application/javascript, application/xml, etc. with utf-8
896
+ // Text types: text/*, application/json, application/javascript, application/xml, image/svg+xml, etc.
563
897
  const isTextContent =
564
898
  contentType.startsWith("text/") ||
565
899
  contentType.includes("application/json") ||
566
900
  contentType.includes("application/javascript") ||
567
901
  contentType.includes("application/xml") ||
902
+ contentType.includes("image/svg+xml") ||
568
903
  contentType.includes("utf-8");
569
904
 
570
905
  // Get response body - use arrayBuffer for binary, text for text content
571
906
  let responseBody;
572
907
  if (isTextContent) {
573
908
  responseBody = await response.text();
909
+ if (path.includes("/icons/")) {
910
+ console.log(
911
+ `[ORCHESTRATOR] Icon response (text): ${path} - ${responseBody.length} bytes, content-type: ${contentType}`,
912
+ );
913
+ }
574
914
  } else {
575
915
  // Binary content - read as arrayBuffer and base64 encode
576
916
  const arrayBuffer = await response.arrayBuffer();
577
917
  responseBody = Buffer.from(arrayBuffer).toString("base64");
578
918
  responseHeaders.push(["x-orch-encoding", "base64"]);
919
+ if (path.includes("/icons/")) {
920
+ console.log(
921
+ `[ORCHESTRATOR] Icon response (binary/base64): ${path} - original ${arrayBuffer.byteLength} bytes, base64 ${responseBody.length} chars, content-type: ${contentType}`,
922
+ );
923
+ }
579
924
  }
580
925
 
581
926
  // Rewrite URLs if proxy_base is provided and content type is HTML or JavaScript