episoda 0.2.104 → 0.2.105

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.
@@ -2117,6 +2117,7 @@ var require_websocket_client = __commonJS({
2117
2117
  this.lastCommandTime = Date.now();
2118
2118
  this.isIntentionalDisconnect = false;
2119
2119
  this.lastConnectAttemptTime = 0;
2120
+ this.consecutiveAuthFailures = 0;
2120
2121
  }
2121
2122
  /**
2122
2123
  * Connect to episoda.dev WebSocket gateway
@@ -2384,6 +2385,10 @@ var require_websocket_client = __commonJS({
2384
2385
  this.rateLimitBackoffUntil = Date.now() + retryAfterMs;
2385
2386
  console.log(`[EpisodaClient] ${errorMessage.code}: will retry after ${retryAfterMs / 1e3}s`);
2386
2387
  }
2388
+ if (errorMessage.code === "AUTH_FAILED" || errorMessage.code === "UNAUTHORIZED" || errorMessage.code === "INVALID_TOKEN") {
2389
+ this.consecutiveAuthFailures++;
2390
+ console.warn(`[EpisodaClient] Auth failure (${this.consecutiveAuthFailures}): ${errorMessage.code}`);
2391
+ }
2387
2392
  }
2388
2393
  const handlers = this.eventHandlers.get(message.type) || [];
2389
2394
  handlers.forEach((handler) => {
@@ -2442,7 +2447,19 @@ var require_websocket_client = __commonJS({
2442
2447
  }
2443
2448
  let delay;
2444
2449
  let shouldRetry = true;
2445
- if (this.isGracefulShutdown) {
2450
+ const isCloudMode = this.environment === "cloud";
2451
+ const MAX_CLOUD_AUTH_FAILURES = 3;
2452
+ const MAX_CLOUD_RECONNECT_DELAY = 3e5;
2453
+ if (isCloudMode) {
2454
+ if (this.consecutiveAuthFailures >= MAX_CLOUD_AUTH_FAILURES) {
2455
+ console.error(`[EpisodaClient] Cloud mode: ${MAX_CLOUD_AUTH_FAILURES} consecutive auth failures - token may be invalid. Giving up.`);
2456
+ shouldRetry = false;
2457
+ } else {
2458
+ delay = Math.min(1e3 * Math.pow(2, this.reconnectAttempts), MAX_CLOUD_RECONNECT_DELAY);
2459
+ const delayStr = delay >= 6e4 ? `${Math.round(delay / 6e4)}m` : `${Math.round(delay / 1e3)}s`;
2460
+ console.log(`[EpisodaClient] Cloud mode: reconnecting in ${delayStr}... (attempt ${this.reconnectAttempts + 1}, never giving up)`);
2461
+ }
2462
+ } else if (this.isGracefulShutdown) {
2446
2463
  if (this.reconnectAttempts >= 7) {
2447
2464
  console.error('[EpisodaClient] Server restart reconnection failed after 7 attempts. Run "episoda dev" to reconnect.');
2448
2465
  shouldRetry = false;
@@ -2485,6 +2502,7 @@ var require_websocket_client = __commonJS({
2485
2502
  this.isGracefulShutdown = false;
2486
2503
  this.firstDisconnectTime = void 0;
2487
2504
  this.rateLimitBackoffUntil = void 0;
2505
+ this.consecutiveAuthFailures = 0;
2488
2506
  }).catch((error) => {
2489
2507
  console.error("[EpisodaClient] Reconnection failed:", error.message);
2490
2508
  });
@@ -2786,7 +2804,7 @@ var require_package = __commonJS({
2786
2804
  "package.json"(exports2, module2) {
2787
2805
  module2.exports = {
2788
2806
  name: "episoda",
2789
- version: "0.2.104",
2807
+ version: "0.2.105",
2790
2808
  description: "CLI tool for Episoda local development workflow orchestration",
2791
2809
  main: "dist/index.js",
2792
2810
  types: "dist/index.d.ts",
@@ -9466,6 +9484,7 @@ function getInstallCommand(cwd) {
9466
9484
 
9467
9485
  // src/daemon/daemon-process.ts
9468
9486
  var fs21 = __toESM(require("fs"));
9487
+ var http2 = __toESM(require("http"));
9469
9488
  var os8 = __toESM(require("os"));
9470
9489
  var path22 = __toESM(require("path"));
9471
9490
  var packageJson = require_package();
@@ -9600,6 +9619,8 @@ var Daemon = class _Daemon {
9600
9619
  // 60 seconds
9601
9620
  // EP1190: Worktree cleanup runs every N health checks (5 * 60s = 5 minutes)
9602
9621
  this.healthCheckCounter = 0;
9622
+ // EP1210-7: Health HTTP endpoint for external monitoring
9623
+ this.healthServer = null;
9603
9624
  this.ipcServer = new IPCServer();
9604
9625
  }
9605
9626
  static {
@@ -9619,6 +9640,9 @@ var Daemon = class _Daemon {
9619
9640
  static {
9620
9641
  this.WORKTREE_CLEANUP_EVERY_N_CHECKS = 5;
9621
9642
  }
9643
+ static {
9644
+ this.HEALTH_PORT = 9999;
9645
+ }
9622
9646
  /**
9623
9647
  * Start the daemon
9624
9648
  */
@@ -9640,6 +9664,7 @@ var Daemon = class _Daemon {
9640
9664
  await this.auditWorktreesOnStartup();
9641
9665
  this.startHealthCheckPolling();
9642
9666
  this.setupShutdownHandlers();
9667
+ this.startHealthEndpoint();
9643
9668
  console.log("[Daemon] Daemon started successfully");
9644
9669
  const modeConfig = getDaemonModeConfig();
9645
9670
  console.log("[Daemon] EP1115: Mode config:", {
@@ -9669,6 +9694,55 @@ var Daemon = class _Daemon {
9669
9694
  }
9670
9695
  }
9671
9696
  // EP738: Removed startHttpServer - device info now flows through WebSocket broadcast + database
9697
+ /**
9698
+ * EP1210-7: Start health HTTP endpoint for external monitoring
9699
+ *
9700
+ * Provides a simple HTTP endpoint that external systems can use to check daemon status.
9701
+ * Returns 200 when daemon has at least one live connection, 503 when disconnected.
9702
+ *
9703
+ * Only binds to localhost (127.0.0.1) for security.
9704
+ */
9705
+ startHealthEndpoint() {
9706
+ try {
9707
+ this.healthServer = http2.createServer((req, res) => {
9708
+ if (req.url === "/health" || req.url === "/") {
9709
+ const isConnected = this.liveConnections.size > 0;
9710
+ const projects = Array.from(this.connections.entries()).map(([path23, conn]) => ({
9711
+ path: path23,
9712
+ connected: this.liveConnections.has(path23)
9713
+ }));
9714
+ const status = {
9715
+ status: isConnected ? "healthy" : "degraded",
9716
+ connected: isConnected,
9717
+ machineId: this.machineId,
9718
+ uptime: process.uptime(),
9719
+ liveConnections: this.liveConnections.size,
9720
+ totalConnections: this.connections.size,
9721
+ projects
9722
+ };
9723
+ res.writeHead(isConnected ? 200 : 503, { "Content-Type": "application/json" });
9724
+ res.end(JSON.stringify(status));
9725
+ } else {
9726
+ res.writeHead(404);
9727
+ res.end("Not found");
9728
+ }
9729
+ });
9730
+ this.healthServer.listen(_Daemon.HEALTH_PORT, "127.0.0.1", () => {
9731
+ console.log(`[Daemon] EP1210-7: Health endpoint listening on http://127.0.0.1:${_Daemon.HEALTH_PORT}/health`);
9732
+ });
9733
+ this.healthServer.on("error", (err) => {
9734
+ if (err.code === "EADDRINUSE") {
9735
+ console.warn(`[Daemon] EP1210-7: Health port ${_Daemon.HEALTH_PORT} already in use, skipping health endpoint`);
9736
+ } else {
9737
+ console.warn("[Daemon] EP1210-7: Health endpoint failed to start:", err.message);
9738
+ }
9739
+ this.healthServer = null;
9740
+ });
9741
+ } catch (err) {
9742
+ console.warn("[Daemon] EP1210-7: Failed to create health server:", err.message);
9743
+ this.healthServer = null;
9744
+ }
9745
+ }
9672
9746
  /**
9673
9747
  * Register IPC command handlers
9674
9748
  */
@@ -11750,6 +11824,10 @@ var Daemon = class _Daemon {
11750
11824
  if (this.shuttingDown) return;
11751
11825
  this.shuttingDown = true;
11752
11826
  console.log("[Daemon] Shutting down...");
11827
+ if (this.healthServer) {
11828
+ this.healthServer.close();
11829
+ this.healthServer = null;
11830
+ }
11753
11831
  this.stopTunnelPolling();
11754
11832
  this.stopHealthCheckPolling();
11755
11833
  for (const [projectPath, connection] of this.connections) {