episoda 0.2.13 → 0.2.15

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.
@@ -1718,6 +1718,18 @@ var require_websocket_client = __commonJS({
1718
1718
  this.isIntentionalDisconnect = false;
1719
1719
  this.lastConnectAttemptTime = Date.now();
1720
1720
  this.lastErrorCode = void 0;
1721
+ if (this.ws) {
1722
+ try {
1723
+ this.ws.removeAllListeners();
1724
+ this.ws.terminate();
1725
+ } catch {
1726
+ }
1727
+ this.ws = void 0;
1728
+ }
1729
+ if (this.reconnectTimeout) {
1730
+ clearTimeout(this.reconnectTimeout);
1731
+ this.reconnectTimeout = void 0;
1732
+ }
1721
1733
  return new Promise((resolve2, reject) => {
1722
1734
  const connectionTimeout = setTimeout(() => {
1723
1735
  if (this.ws) {
@@ -1827,6 +1839,32 @@ var require_websocket_client = __commonJS({
1827
1839
  }
1828
1840
  this.eventHandlers.get(event).push(handler);
1829
1841
  }
1842
+ /**
1843
+ * EP812: Register a one-time event handler (removes itself after first call)
1844
+ * @param event - Event type
1845
+ * @param handler - Handler function
1846
+ */
1847
+ once(event, handler) {
1848
+ const onceHandler = (message) => {
1849
+ this.off(event, onceHandler);
1850
+ handler(message);
1851
+ };
1852
+ this.on(event, onceHandler);
1853
+ }
1854
+ /**
1855
+ * EP812: Remove an event handler
1856
+ * @param event - Event type
1857
+ * @param handler - Handler function to remove
1858
+ */
1859
+ off(event, handler) {
1860
+ const handlers = this.eventHandlers.get(event);
1861
+ if (handlers) {
1862
+ const index = handlers.indexOf(handler);
1863
+ if (index !== -1) {
1864
+ handlers.splice(index, 1);
1865
+ }
1866
+ }
1867
+ }
1830
1868
  /**
1831
1869
  * Send a message to the server
1832
1870
  * @param message - Client message to send
@@ -1920,6 +1958,10 @@ var require_websocket_client = __commonJS({
1920
1958
  console.log("[EpisodaClient] Intentional disconnect - not reconnecting");
1921
1959
  return;
1922
1960
  }
1961
+ if (this.reconnectTimeout) {
1962
+ console.log("[EpisodaClient] Reconnection already scheduled, skipping duplicate");
1963
+ return;
1964
+ }
1923
1965
  if (this.heartbeatTimer) {
1924
1966
  clearInterval(this.heartbeatTimer);
1925
1967
  this.heartbeatTimer = void 0;
@@ -2238,7 +2280,7 @@ var require_package = __commonJS({
2238
2280
  "package.json"(exports2, module2) {
2239
2281
  module2.exports = {
2240
2282
  name: "episoda",
2241
- version: "0.2.12",
2283
+ version: "0.2.14",
2242
2284
  description: "CLI tool for Episoda local development workflow orchestration",
2243
2285
  main: "dist/index.js",
2244
2286
  types: "dist/index.d.ts",
@@ -3130,6 +3172,10 @@ var Daemon = class {
3130
3172
  // Updated by 'auth_success' (add) and 'disconnected' (remove) events
3131
3173
  this.liveConnections = /* @__PURE__ */ new Set();
3132
3174
  // projectPath
3175
+ // EP813: Track connections that are still authenticating (in progress)
3176
+ // Prevents race condition between restoreConnections() and add-project IPC
3177
+ this.pendingConnections = /* @__PURE__ */ new Set();
3178
+ // projectPath
3133
3179
  this.shuttingDown = false;
3134
3180
  this.ipcServer = new IPCServer();
3135
3181
  }
@@ -3198,18 +3244,31 @@ var Daemon = class {
3198
3244
  this.ipcServer.on("add-project", async (params) => {
3199
3245
  const { projectId, projectPath } = params;
3200
3246
  addProject(projectId, projectPath);
3201
- try {
3202
- await this.connectProject(projectId, projectPath);
3203
- const isHealthy = this.isConnectionHealthy(projectPath);
3204
- if (!isHealthy) {
3205
- console.warn(`[Daemon] Connection completed but not healthy for ${projectPath}`);
3206
- return { success: false, connected: false, error: "Connection established but not healthy" };
3247
+ const MAX_RETRIES = 3;
3248
+ const INITIAL_DELAY = 1e3;
3249
+ let lastError = "";
3250
+ for (let attempt = 1; attempt <= MAX_RETRIES; attempt++) {
3251
+ try {
3252
+ await this.connectProject(projectId, projectPath);
3253
+ const isHealthy = this.isConnectionHealthy(projectPath);
3254
+ if (!isHealthy) {
3255
+ console.warn(`[Daemon] Connection completed but not healthy for ${projectPath}`);
3256
+ lastError = "Connection established but not healthy";
3257
+ return { success: false, connected: false, error: lastError };
3258
+ }
3259
+ return { success: true, connected: true };
3260
+ } catch (error) {
3261
+ lastError = error instanceof Error ? error.message : String(error);
3262
+ console.error(`[Daemon] Connection attempt ${attempt}/${MAX_RETRIES} failed:`, lastError);
3263
+ if (attempt < MAX_RETRIES) {
3264
+ const delay = INITIAL_DELAY * Math.pow(2, attempt - 1);
3265
+ console.log(`[Daemon] Retrying in ${delay / 1e3}s...`);
3266
+ await new Promise((resolve2) => setTimeout(resolve2, delay));
3267
+ await this.disconnectProject(projectPath);
3268
+ }
3207
3269
  }
3208
- return { success: true, connected: true };
3209
- } catch (error) {
3210
- const errorMessage = error instanceof Error ? error.message : String(error);
3211
- return { success: false, connected: false, error: errorMessage };
3212
3270
  }
3271
+ return { success: false, connected: false, error: `Failed after ${MAX_RETRIES} attempts: ${lastError}` };
3213
3272
  });
3214
3273
  this.ipcServer.on("remove-project", async (params) => {
3215
3274
  const { projectPath } = params;
@@ -3284,6 +3343,19 @@ var Daemon = class {
3284
3343
  console.log(`[Daemon] Already connected to ${projectPath}`);
3285
3344
  return;
3286
3345
  }
3346
+ if (this.pendingConnections.has(projectPath)) {
3347
+ console.log(`[Daemon] Connection in progress for ${projectPath}, waiting...`);
3348
+ const maxWait = 35e3;
3349
+ const startTime = Date.now();
3350
+ while (this.pendingConnections.has(projectPath) && Date.now() - startTime < maxWait) {
3351
+ await new Promise((resolve2) => setTimeout(resolve2, 500));
3352
+ }
3353
+ if (this.liveConnections.has(projectPath)) {
3354
+ console.log(`[Daemon] Pending connection succeeded for ${projectPath}`);
3355
+ return;
3356
+ }
3357
+ console.warn(`[Daemon] Pending connection timed out for ${projectPath}`);
3358
+ }
3287
3359
  console.warn(`[Daemon] Stale connection detected for ${projectPath}, forcing reconnection`);
3288
3360
  await this.disconnectProject(projectPath);
3289
3361
  }
@@ -3310,6 +3382,7 @@ var Daemon = class {
3310
3382
  gitExecutor
3311
3383
  };
3312
3384
  this.connections.set(projectPath, connection);
3385
+ this.pendingConnections.add(projectPath);
3313
3386
  client.on("command", async (message) => {
3314
3387
  if (message.type === "command" && message.command) {
3315
3388
  console.log(`[Daemon] Received command for ${projectId}:`, message.command);
@@ -3405,6 +3478,7 @@ var Daemon = class {
3405
3478
  console.log(`[Daemon] Authenticated for project ${projectId}`);
3406
3479
  touchProject(projectPath);
3407
3480
  this.liveConnections.add(projectPath);
3481
+ this.pendingConnections.delete(projectPath);
3408
3482
  const authMessage = message;
3409
3483
  if (authMessage.userId && authMessage.workspaceId) {
3410
3484
  await this.configureGitUser(projectPath, authMessage.userId, authMessage.workspaceId, this.machineId, projectId, authMessage.deviceId);
@@ -3447,6 +3521,23 @@ var Daemon = class {
3447
3521
  } catch (pidError) {
3448
3522
  console.warn(`[Daemon] Could not read daemon PID:`, pidError instanceof Error ? pidError.message : pidError);
3449
3523
  }
3524
+ const authSuccessPromise = new Promise((resolve2, reject) => {
3525
+ const AUTH_TIMEOUT = 3e4;
3526
+ const timeout = setTimeout(() => {
3527
+ reject(new Error("Authentication timeout after 30s - server may be under heavy load. Try again in a few seconds."));
3528
+ }, AUTH_TIMEOUT);
3529
+ const authHandler = () => {
3530
+ clearTimeout(timeout);
3531
+ resolve2();
3532
+ };
3533
+ client.once("auth_success", authHandler);
3534
+ const errorHandler = (message) => {
3535
+ clearTimeout(timeout);
3536
+ const errorMsg = message;
3537
+ reject(new Error(errorMsg.message || "Authentication failed"));
3538
+ };
3539
+ client.once("auth_error", errorHandler);
3540
+ });
3450
3541
  await client.connect(wsUrl, config.access_token, this.machineId, {
3451
3542
  hostname: os.hostname(),
3452
3543
  osPlatform: os.platform(),
@@ -3454,9 +3545,12 @@ var Daemon = class {
3454
3545
  daemonPid
3455
3546
  });
3456
3547
  console.log(`[Daemon] Successfully connected to project ${projectId}`);
3548
+ await authSuccessPromise;
3549
+ console.log(`[Daemon] Authentication complete for project ${projectId}`);
3457
3550
  } catch (error) {
3458
3551
  console.error(`[Daemon] Failed to connect to ${projectId}:`, error);
3459
3552
  this.connections.delete(projectPath);
3553
+ this.pendingConnections.delete(projectPath);
3460
3554
  throw error;
3461
3555
  }
3462
3556
  }
@@ -3474,6 +3568,7 @@ var Daemon = class {
3474
3568
  await connection.client.disconnect();
3475
3569
  this.connections.delete(projectPath);
3476
3570
  this.liveConnections.delete(projectPath);
3571
+ this.pendingConnections.delete(projectPath);
3477
3572
  console.log(`[Daemon] Disconnected from ${projectPath}`);
3478
3573
  }
3479
3574
  /**