@episoda/cli 0.2.174 → 0.2.176

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.
@@ -2187,6 +2187,7 @@ var require_websocket_client = __commonJS({
2187
2187
  constructor() {
2188
2188
  this.eventHandlers = /* @__PURE__ */ new Map();
2189
2189
  this.reconnectAttempts = 0;
2190
+ this.autoReconnectEnabled = true;
2190
2191
  this.url = "";
2191
2192
  this.token = "";
2192
2193
  this.isConnected = false;
@@ -2235,7 +2236,7 @@ var require_websocket_client = __commonJS({
2235
2236
  clearTimeout(this.reconnectTimeout);
2236
2237
  this.reconnectTimeout = void 0;
2237
2238
  }
2238
- return new Promise((resolve6, reject) => {
2239
+ return new Promise((resolve7, reject) => {
2239
2240
  const connectionTimeout = setTimeout(() => {
2240
2241
  if (this.ws) {
2241
2242
  this.ws.terminate();
@@ -2266,7 +2267,7 @@ var require_websocket_client = __commonJS({
2266
2267
  daemonPid: this.daemonPid
2267
2268
  });
2268
2269
  this.startHeartbeat();
2269
- resolve6();
2270
+ resolve7();
2270
2271
  });
2271
2272
  this.ws.on("pong", () => {
2272
2273
  if (this.heartbeatTimeoutTimer) {
@@ -2288,7 +2289,7 @@ var require_websocket_client = __commonJS({
2288
2289
  this.ws.on("close", (code, reason) => {
2289
2290
  console.log(`[EpisodaClient] WebSocket closed: ${code} ${reason.toString()}`);
2290
2291
  this.isConnected = false;
2291
- const willReconnect = !this.isDisconnecting;
2292
+ const willReconnect = !this.isDisconnecting && this.autoReconnectEnabled;
2292
2293
  this.emit({
2293
2294
  type: "disconnected",
2294
2295
  code,
@@ -2312,6 +2313,19 @@ var require_websocket_client = __commonJS({
2312
2313
  }
2313
2314
  });
2314
2315
  }
2316
+ /**
2317
+ * Enable/disable automatic reconnect scheduling.
2318
+ *
2319
+ * This lets daemon startup/handshake own retry policy without competing
2320
+ * with client-side reconnect loops.
2321
+ */
2322
+ setAutoReconnect(enabled) {
2323
+ this.autoReconnectEnabled = enabled;
2324
+ if (!enabled && this.reconnectTimeout) {
2325
+ clearTimeout(this.reconnectTimeout);
2326
+ this.reconnectTimeout = void 0;
2327
+ }
2328
+ }
2315
2329
  /**
2316
2330
  * Disconnect from the server
2317
2331
  * @param intentional - If true, prevents automatic reconnection (user-initiated disconnect)
@@ -2397,13 +2411,13 @@ var require_websocket_client = __commonJS({
2397
2411
  console.warn("[EpisodaClient] Cannot send - WebSocket not connected");
2398
2412
  return false;
2399
2413
  }
2400
- return new Promise((resolve6) => {
2414
+ return new Promise((resolve7) => {
2401
2415
  this.ws.send(JSON.stringify(message), (error) => {
2402
2416
  if (error) {
2403
2417
  console.error("[EpisodaClient] Failed to send message:", error);
2404
- resolve6(false);
2418
+ resolve7(false);
2405
2419
  } else {
2406
- resolve6(true);
2420
+ resolve7(true);
2407
2421
  }
2408
2422
  });
2409
2423
  });
@@ -2526,7 +2540,12 @@ var require_websocket_client = __commonJS({
2526
2540
  if (this.rateLimitBackoffUntil && Date.now() < this.rateLimitBackoffUntil) {
2527
2541
  const waitTime = this.rateLimitBackoffUntil - Date.now();
2528
2542
  console.log(`[EpisodaClient] Rate limited, waiting ${Math.round(waitTime / 1e3)}s before retry`);
2529
- this.reconnectAttempts++;
2543
+ this.emit({
2544
+ type: "reconnect_scheduled",
2545
+ attempt: this.reconnectAttempts + 1,
2546
+ delayMs: waitTime,
2547
+ strategy: "rate_limited"
2548
+ });
2530
2549
  this.reconnectTimeout = setTimeout(() => {
2531
2550
  this.rateLimitBackoffUntil = void 0;
2532
2551
  this.scheduleReconnect();
@@ -2534,6 +2553,7 @@ var require_websocket_client = __commonJS({
2534
2553
  return;
2535
2554
  }
2536
2555
  let delay;
2556
+ let strategy = "connection_lost";
2537
2557
  let shouldRetry = true;
2538
2558
  const isCloudMode = this.environment === "cloud";
2539
2559
  const MAX_CLOUD_AUTH_FAILURES = 3;
@@ -2545,20 +2565,28 @@ var require_websocket_client = __commonJS({
2545
2565
  } else {
2546
2566
  delay = Math.min(1e3 * Math.pow(2, this.reconnectAttempts), MAX_CLOUD_RECONNECT_DELAY);
2547
2567
  delay = applyJitter(delay);
2568
+ strategy = "cloud";
2548
2569
  const delayStr = delay >= 6e4 ? `${Math.round(delay / 6e4)}m` : `${Math.round(delay / 1e3)}s`;
2549
2570
  console.log(`[EpisodaClient] Cloud mode: reconnecting in ${delayStr}... (attempt ${this.reconnectAttempts + 1}, never giving up)`);
2550
2571
  }
2551
2572
  } else if (this.isGracefulShutdown && this.reconnectAttempts < 7) {
2552
2573
  delay = Math.min(500 * Math.pow(2, this.reconnectAttempts), 5e3);
2553
2574
  delay = applyJitter(delay);
2575
+ strategy = "graceful_shutdown";
2554
2576
  console.log(`[EpisodaClient] Server restarting, reconnecting in ${delay}ms (attempt ${this.reconnectAttempts + 1}/7)`);
2555
2577
  } else {
2556
2578
  delay = Math.min(1e3 * Math.pow(2, this.reconnectAttempts), MAX_LOCAL_RECONNECT_DELAY);
2557
2579
  delay = applyJitter(delay);
2580
+ strategy = "connection_lost";
2558
2581
  const delayStr = delay >= 6e4 ? `${Math.round(delay / 6e4)}m` : `${Math.round(delay / 1e3)}s`;
2559
2582
  console.log(`[EpisodaClient] Connection lost, retrying in ${delayStr}... (attempt ${this.reconnectAttempts + 1}, retrying until connected)`);
2560
2583
  }
2561
2584
  if (!shouldRetry) {
2585
+ this.emit({
2586
+ type: "reconnect_exhausted",
2587
+ attempts: this.reconnectAttempts,
2588
+ reason: `retry_exhausted_after_${this.consecutiveAuthFailures}_auth_failures`
2589
+ });
2562
2590
  this.emit({
2563
2591
  type: "disconnected",
2564
2592
  code: 1006,
@@ -2567,9 +2595,20 @@ var require_websocket_client = __commonJS({
2567
2595
  });
2568
2596
  return;
2569
2597
  }
2570
- this.reconnectAttempts++;
2598
+ const reconnectAttempt = this.reconnectAttempts + 1;
2599
+ this.reconnectAttempts = reconnectAttempt;
2600
+ this.emit({
2601
+ type: "reconnect_scheduled",
2602
+ attempt: reconnectAttempt,
2603
+ delayMs: delay,
2604
+ strategy
2605
+ });
2571
2606
  this.reconnectTimeout = setTimeout(() => {
2572
2607
  console.log("[EpisodaClient] Attempting reconnection...");
2608
+ this.emit({
2609
+ type: "reconnect_attempt",
2610
+ attempt: reconnectAttempt
2611
+ });
2573
2612
  this.connect(this.url, this.token, this.machineId, {
2574
2613
  hostname: this.hostname,
2575
2614
  osPlatform: this.osPlatform,
@@ -2582,6 +2621,11 @@ var require_websocket_client = __commonJS({
2582
2621
  containerId: this.containerId
2583
2622
  }).then(() => {
2584
2623
  console.log("[EpisodaClient] Reconnection successful");
2624
+ this.emit({
2625
+ type: "reconnect_result",
2626
+ attempt: reconnectAttempt,
2627
+ success: true
2628
+ });
2585
2629
  this.reconnectAttempts = 0;
2586
2630
  this.isGracefulShutdown = false;
2587
2631
  this.firstDisconnectTime = void 0;
@@ -2589,6 +2633,12 @@ var require_websocket_client = __commonJS({
2589
2633
  this.consecutiveAuthFailures = 0;
2590
2634
  }).catch((error) => {
2591
2635
  console.error("[EpisodaClient] Reconnection failed:", error.message);
2636
+ this.emit({
2637
+ type: "reconnect_result",
2638
+ attempt: reconnectAttempt,
2639
+ success: false,
2640
+ error: error instanceof Error ? error.message : String(error)
2641
+ });
2592
2642
  });
2593
2643
  }, delay);
2594
2644
  }
@@ -2996,7 +3046,7 @@ var require_package = __commonJS({
2996
3046
  "package.json"(exports2, module2) {
2997
3047
  module2.exports = {
2998
3048
  name: "@episoda/cli",
2999
- version: "0.2.174",
3049
+ version: "0.2.176",
3000
3050
  description: "CLI tool for Episoda local development workflow orchestration",
3001
3051
  main: "dist/index.js",
3002
3052
  types: "dist/index.d.ts",
@@ -3255,6 +3305,16 @@ function touchProject(projectPath) {
3255
3305
  writeProjects(data);
3256
3306
  }
3257
3307
  }
3308
+ function pruneMissingProjectPaths() {
3309
+ const data = readProjects();
3310
+ const initialLength = data.projects.length;
3311
+ data.projects = data.projects.filter((project) => fs2.existsSync(project.path));
3312
+ const removedCount = initialLength - data.projects.length;
3313
+ if (removedCount > 0) {
3314
+ writeProjects(data);
3315
+ }
3316
+ return removedCount;
3317
+ }
3258
3318
 
3259
3319
  // src/daemon/daemon-manager.ts
3260
3320
  var path3 = __toESM(require("path"));
@@ -3301,10 +3361,10 @@ var IPCServer = class {
3301
3361
  this.server = net.createServer((socket) => {
3302
3362
  this.handleConnection(socket);
3303
3363
  });
3304
- return new Promise((resolve6, reject) => {
3364
+ return new Promise((resolve7, reject) => {
3305
3365
  this.server.listen(socketPath, () => {
3306
3366
  fs3.chmodSync(socketPath, 384);
3307
- resolve6();
3367
+ resolve7();
3308
3368
  });
3309
3369
  this.server.on("error", reject);
3310
3370
  });
@@ -3315,12 +3375,12 @@ var IPCServer = class {
3315
3375
  async stop() {
3316
3376
  if (!this.server) return;
3317
3377
  const socketPath = getSocketPath();
3318
- return new Promise((resolve6) => {
3378
+ return new Promise((resolve7) => {
3319
3379
  this.server.close(() => {
3320
3380
  if (fs3.existsSync(socketPath)) {
3321
3381
  fs3.unlinkSync(socketPath);
3322
3382
  }
3323
- resolve6();
3383
+ resolve7();
3324
3384
  });
3325
3385
  });
3326
3386
  }
@@ -3389,8 +3449,24 @@ var import_child_process2 = require("child_process");
3389
3449
  var import_util = require("util");
3390
3450
  var import_core5 = __toESM(require_dist());
3391
3451
  var execAsync = (0, import_util.promisify)(import_child_process2.exec);
3452
+ async function isGitRepository(projectPath) {
3453
+ try {
3454
+ await execAsync("git rev-parse --git-dir", { cwd: projectPath, timeout: 5e3 });
3455
+ return true;
3456
+ } catch {
3457
+ return false;
3458
+ }
3459
+ }
3392
3460
  async function cleanupStaleCommits(projectPath) {
3393
3461
  try {
3462
+ if (!await isGitRepository(projectPath)) {
3463
+ return {
3464
+ success: true,
3465
+ deleted_count: 0,
3466
+ kept_count: 0,
3467
+ message: "Skipping cleanup: project path is not a git repository"
3468
+ };
3469
+ }
3394
3470
  const machineId = await getMachineId();
3395
3471
  const config = await (0, import_core5.loadConfig)();
3396
3472
  if (!config?.access_token) {
@@ -3404,7 +3480,10 @@ async function cleanupStaleCommits(projectPath) {
3404
3480
  try {
3405
3481
  await execAsync("git fetch origin", { cwd: projectPath, timeout: 3e4 });
3406
3482
  } catch (fetchError) {
3407
- console.warn("[EP950] Could not fetch origin:", fetchError);
3483
+ const fetchMessage = fetchError instanceof Error ? fetchError.message : String(fetchError);
3484
+ if (!fetchMessage.includes("not a git repository")) {
3485
+ console.warn("[EP950] Could not fetch origin:", fetchError);
3486
+ }
3408
3487
  }
3409
3488
  let currentBranch = "";
3410
3489
  try {
@@ -3546,7 +3625,7 @@ function getDownloadUrl() {
3546
3625
  return platformUrls[arch4] || null;
3547
3626
  }
3548
3627
  async function downloadFile(url, destPath) {
3549
- return new Promise((resolve6, reject) => {
3628
+ return new Promise((resolve7, reject) => {
3550
3629
  const followRedirect = (currentUrl, redirectCount = 0) => {
3551
3630
  if (redirectCount > 5) {
3552
3631
  reject(new Error("Too many redirects"));
@@ -3576,7 +3655,7 @@ async function downloadFile(url, destPath) {
3576
3655
  response.pipe(file);
3577
3656
  file.on("finish", () => {
3578
3657
  file.close();
3579
- resolve6();
3658
+ resolve7();
3580
3659
  });
3581
3660
  file.on("error", (err) => {
3582
3661
  fs4.unlinkSync(destPath);
@@ -3990,10 +4069,10 @@ var TunnelManager = class extends import_events.EventEmitter {
3990
4069
  const isTracked = Array.from(this.tunnelStates.values()).some((s) => s.info.pid === pid);
3991
4070
  console.log(`[Tunnel] EP904: Found cloudflared PID ${pid} on port ${port} (tracked: ${isTracked})`);
3992
4071
  this.killByPid(pid, "SIGTERM");
3993
- await new Promise((resolve6) => setTimeout(resolve6, 500));
4072
+ await new Promise((resolve7) => setTimeout(resolve7, 500));
3994
4073
  if (this.isProcessRunning(pid)) {
3995
4074
  this.killByPid(pid, "SIGKILL");
3996
- await new Promise((resolve6) => setTimeout(resolve6, 200));
4075
+ await new Promise((resolve7) => setTimeout(resolve7, 200));
3997
4076
  }
3998
4077
  killed.push(pid);
3999
4078
  }
@@ -4027,7 +4106,7 @@ var TunnelManager = class extends import_events.EventEmitter {
4027
4106
  if (!this.tunnelStates.has(moduleUid)) {
4028
4107
  console.log(`[Tunnel] EP877: Found orphaned process PID ${pid} for ${moduleUid}, killing...`);
4029
4108
  this.killByPid(pid, "SIGTERM");
4030
- await new Promise((resolve6) => setTimeout(resolve6, 1e3));
4109
+ await new Promise((resolve7) => setTimeout(resolve7, 1e3));
4031
4110
  if (this.isProcessRunning(pid)) {
4032
4111
  this.killByPid(pid, "SIGKILL");
4033
4112
  }
@@ -4042,7 +4121,7 @@ var TunnelManager = class extends import_events.EventEmitter {
4042
4121
  if (!trackedPids.includes(pid) && !cleaned.includes(pid)) {
4043
4122
  console.log(`[Tunnel] EP877: Found untracked cloudflared process PID ${pid}, killing...`);
4044
4123
  this.killByPid(pid, "SIGTERM");
4045
- await new Promise((resolve6) => setTimeout(resolve6, 500));
4124
+ await new Promise((resolve7) => setTimeout(resolve7, 500));
4046
4125
  if (this.isProcessRunning(pid)) {
4047
4126
  this.killByPid(pid, "SIGKILL");
4048
4127
  }
@@ -4132,7 +4211,7 @@ var TunnelManager = class extends import_events.EventEmitter {
4132
4211
  return { success: false, error: `Failed to get cloudflared: ${errorMessage}` };
4133
4212
  }
4134
4213
  }
4135
- return new Promise((resolve6) => {
4214
+ return new Promise((resolve7) => {
4136
4215
  const tunnelInfo = {
4137
4216
  moduleUid,
4138
4217
  url: previewUrl || "",
@@ -4198,7 +4277,7 @@ var TunnelManager = class extends import_events.EventEmitter {
4198
4277
  moduleUid,
4199
4278
  url: tunnelInfo.url
4200
4279
  });
4201
- resolve6({ success: true, url: tunnelInfo.url });
4280
+ resolve7({ success: true, url: tunnelInfo.url });
4202
4281
  }
4203
4282
  };
4204
4283
  process2.stderr?.on("data", (data) => {
@@ -4225,7 +4304,7 @@ var TunnelManager = class extends import_events.EventEmitter {
4225
4304
  onStatusChange?.("error", errorMsg);
4226
4305
  this.emitEvent({ type: "error", moduleUid, error: errorMsg });
4227
4306
  }
4228
- resolve6({ success: false, error: errorMsg });
4307
+ resolve7({ success: false, error: errorMsg });
4229
4308
  } else if (wasConnected) {
4230
4309
  if (currentState && !currentState.intentionallyStopped) {
4231
4310
  console.log(`[Tunnel] EP948: Named tunnel ${moduleUid} crashed unexpectedly, attempting reconnect...`);
@@ -4256,7 +4335,7 @@ var TunnelManager = class extends import_events.EventEmitter {
4256
4335
  this.emitEvent({ type: "error", moduleUid, error: error.message });
4257
4336
  }
4258
4337
  if (!connected) {
4259
- resolve6({ success: false, error: error.message });
4338
+ resolve7({ success: false, error: error.message });
4260
4339
  }
4261
4340
  });
4262
4341
  setTimeout(() => {
@@ -4279,7 +4358,7 @@ var TunnelManager = class extends import_events.EventEmitter {
4279
4358
  onStatusChange?.("error", errorMsg);
4280
4359
  this.emitEvent({ type: "error", moduleUid, error: errorMsg });
4281
4360
  }
4282
- resolve6({ success: false, error: errorMsg });
4361
+ resolve7({ success: false, error: errorMsg });
4283
4362
  }
4284
4363
  }, TUNNEL_TIMEOUTS.NAMED_TUNNEL_CONNECT);
4285
4364
  });
@@ -4340,7 +4419,7 @@ var TunnelManager = class extends import_events.EventEmitter {
4340
4419
  if (orphanPid && this.isProcessRunning(orphanPid)) {
4341
4420
  console.log(`[Tunnel] EP877: Killing orphaned process ${orphanPid} for ${moduleUid} before starting new tunnel`);
4342
4421
  this.killByPid(orphanPid, "SIGTERM");
4343
- await new Promise((resolve6) => setTimeout(resolve6, 500));
4422
+ await new Promise((resolve7) => setTimeout(resolve7, 500));
4344
4423
  if (this.isProcessRunning(orphanPid)) {
4345
4424
  this.killByPid(orphanPid, "SIGKILL");
4346
4425
  }
@@ -4349,7 +4428,7 @@ var TunnelManager = class extends import_events.EventEmitter {
4349
4428
  const killedOnPort = await this.killCloudflaredOnPort(port);
4350
4429
  if (killedOnPort.length > 0) {
4351
4430
  console.log(`[Tunnel] EP904: Pre-start port cleanup killed ${killedOnPort.length} process(es) on port ${port}`);
4352
- await new Promise((resolve6) => setTimeout(resolve6, 1e3));
4431
+ await new Promise((resolve7) => setTimeout(resolve7, 1e3));
4353
4432
  }
4354
4433
  const cleanup = await this.cleanupOrphanedProcesses();
4355
4434
  if (cleanup.cleaned > 0) {
@@ -4389,7 +4468,7 @@ var TunnelManager = class extends import_events.EventEmitter {
4389
4468
  if (orphanPid && this.isProcessRunning(orphanPid)) {
4390
4469
  console.log(`[Tunnel] EP877: Stopping orphaned process ${orphanPid} for ${moduleUid} via PID file`);
4391
4470
  this.killByPid(orphanPid, "SIGTERM");
4392
- await new Promise((resolve6) => setTimeout(resolve6, 1e3));
4471
+ await new Promise((resolve7) => setTimeout(resolve7, 1e3));
4393
4472
  if (this.isProcessRunning(orphanPid)) {
4394
4473
  this.killByPid(orphanPid, "SIGKILL");
4395
4474
  }
@@ -4405,16 +4484,16 @@ var TunnelManager = class extends import_events.EventEmitter {
4405
4484
  const tunnel = state.info;
4406
4485
  if (tunnel.process && !tunnel.process.killed) {
4407
4486
  tunnel.process.kill("SIGTERM");
4408
- await new Promise((resolve6) => {
4487
+ await new Promise((resolve7) => {
4409
4488
  const timeout = setTimeout(() => {
4410
4489
  if (tunnel.process && !tunnel.process.killed) {
4411
4490
  tunnel.process.kill("SIGKILL");
4412
4491
  }
4413
- resolve6();
4492
+ resolve7();
4414
4493
  }, 3e3);
4415
4494
  tunnel.process.once("exit", () => {
4416
4495
  clearTimeout(timeout);
4417
- resolve6();
4496
+ resolve7();
4418
4497
  });
4419
4498
  });
4420
4499
  }
@@ -4701,15 +4780,15 @@ async function writeToStdinWithBackpressure(process2, data, drainTimeoutMs, labe
4701
4780
  if (!stdin || stdin.destroyed) {
4702
4781
  throw new Error(`[${label}] stdin not available. session=${sessionId}`);
4703
4782
  }
4704
- await new Promise((resolve6, reject) => {
4783
+ await new Promise((resolve7, reject) => {
4705
4784
  const ok = stdin.write(data);
4706
4785
  if (ok) {
4707
- resolve6();
4786
+ resolve7();
4708
4787
  return;
4709
4788
  }
4710
4789
  const onDrain = () => {
4711
4790
  cleanup();
4712
- resolve6();
4791
+ resolve7();
4713
4792
  };
4714
4793
  const onError = (err) => {
4715
4794
  cleanup();
@@ -4730,18 +4809,18 @@ async function writeToStdinWithBackpressure(process2, data, drainTimeoutMs, labe
4730
4809
  });
4731
4810
  }
4732
4811
  function waitForProcessExit(process2, alive, timeoutMs) {
4733
- return new Promise((resolve6) => {
4812
+ return new Promise((resolve7) => {
4734
4813
  if (!process2 || !alive) {
4735
- resolve6(true);
4814
+ resolve7(true);
4736
4815
  return;
4737
4816
  }
4738
4817
  const onExit = () => {
4739
4818
  clearTimeout(timer);
4740
- resolve6(true);
4819
+ resolve7(true);
4741
4820
  };
4742
4821
  const timer = setTimeout(() => {
4743
4822
  process2.removeListener("exit", onExit);
4744
- resolve6(false);
4823
+ resolve7(false);
4745
4824
  }, timeoutMs);
4746
4825
  process2.once("exit", onExit);
4747
4826
  });
@@ -5737,10 +5816,10 @@ var CodexPersistentRuntime = class {
5737
5816
  }
5738
5817
  waitForThreadId(timeoutMs) {
5739
5818
  if (this._agentSessionId) return Promise.resolve(this._agentSessionId);
5740
- return new Promise((resolve6, reject) => {
5819
+ return new Promise((resolve7, reject) => {
5741
5820
  const onId = (id) => {
5742
5821
  clearTimeout(timer);
5743
- resolve6(id);
5822
+ resolve7(id);
5744
5823
  };
5745
5824
  const timer = setTimeout(() => {
5746
5825
  this.threadIdWaiters = this.threadIdWaiters.filter((w) => w !== onId);
@@ -5771,12 +5850,12 @@ var CodexPersistentRuntime = class {
5771
5850
  sendRequest(method, params, timeoutMs = INIT_TIMEOUT_MS) {
5772
5851
  const id = this.nextId++;
5773
5852
  const msg = { jsonrpc: "2.0", id, method, params };
5774
- return new Promise(async (resolve6, reject) => {
5853
+ return new Promise(async (resolve7, reject) => {
5775
5854
  const timeout = setTimeout(() => {
5776
5855
  this.pending.delete(id);
5777
5856
  reject(new Error(`JSON-RPC timeout: ${method}`));
5778
5857
  }, timeoutMs);
5779
- this.pending.set(id, { resolve: resolve6, reject, timeout });
5858
+ this.pending.set(id, { resolve: resolve7, reject, timeout });
5780
5859
  try {
5781
5860
  await this.writeToStdin(JSON.stringify(msg) + "\n");
5782
5861
  } catch (err) {
@@ -5926,11 +6005,11 @@ var UnifiedAgentRuntime = class {
5926
6005
  if (!this.currentTurn && this.impl.turnState === "idle") {
5927
6006
  return this.dispatchTurn(message, callbacks);
5928
6007
  }
5929
- return new Promise((resolve6, reject) => {
6008
+ return new Promise((resolve7, reject) => {
5930
6009
  this.queuedTurns.push({
5931
6010
  message,
5932
6011
  callbacks,
5933
- resolve: resolve6,
6012
+ resolve: resolve7,
5934
6013
  reject
5935
6014
  });
5936
6015
  });
@@ -7443,13 +7522,13 @@ async function getChildProcessRssMb(pid) {
7443
7522
  if (!pid || pid <= 0) return null;
7444
7523
  try {
7445
7524
  if (typeof import_child_process9.execFile !== "function") return null;
7446
- const stdout = await new Promise((resolve6, reject) => {
7525
+ const stdout = await new Promise((resolve7, reject) => {
7447
7526
  (0, import_child_process9.execFile)("ps", ["-o", "rss=", "-p", String(pid)], { timeout: 1e3 }, (err, out) => {
7448
7527
  if (err) {
7449
7528
  reject(err);
7450
7529
  return;
7451
7530
  }
7452
- resolve6(String(out));
7531
+ resolve7(String(out));
7453
7532
  });
7454
7533
  });
7455
7534
  const kb = Number(String(stdout).trim());
@@ -7552,8 +7631,8 @@ var AgentControlPlane = class {
7552
7631
  async withConfigLock(fn) {
7553
7632
  const previousLock = this.configWriteLock;
7554
7633
  let releaseLock;
7555
- this.configWriteLock = new Promise((resolve6) => {
7556
- releaseLock = resolve6;
7634
+ this.configWriteLock = new Promise((resolve7) => {
7635
+ releaseLock = resolve7;
7557
7636
  });
7558
7637
  try {
7559
7638
  await previousLock;
@@ -7630,26 +7709,12 @@ var AgentControlPlane = class {
7630
7709
  const existingSession = this.sessions.get(sessionId);
7631
7710
  if (existingSession) {
7632
7711
  if (existingSession.provider === provider && existingSession.moduleId === moduleId) {
7633
- console.log(`[AgentManager] EP1232: Session ${sessionId} already exists, treating start as message (idempotent)`);
7712
+ console.log(`[AgentManager] EP1417: Session ${sessionId} already exists, acknowledging duplicate start`);
7634
7713
  if (requestedWorkspaceId && !normalizeWorkspaceId(existingSession.workspaceId)) {
7635
7714
  existingSession.workspaceId = requestedWorkspaceId;
7636
7715
  console.log(`[AgentManager] EP1357: Updated session ${sessionId} workspaceId from start options`);
7637
7716
  }
7638
- return this.sendMessage({
7639
- sessionId,
7640
- message,
7641
- isFirstMessage: false,
7642
- // Not first since session exists
7643
- canWrite,
7644
- readOnlyReason,
7645
- onChunk,
7646
- onToolUse,
7647
- // EP1236: Pass tool use callback
7648
- onToolResult,
7649
- // EP1311: Pass tool result callback
7650
- onComplete,
7651
- onError
7652
- });
7717
+ return { success: true };
7653
7718
  }
7654
7719
  console.log(`[AgentManager] EP1232: Session ${sessionId} exists with incompatible config - provider: ${existingSession.provider} vs ${provider}, module: ${existingSession.moduleId} vs ${moduleId}`);
7655
7720
  return { success: false, error: "Session already exists with incompatible configuration" };
@@ -8796,7 +8861,7 @@ var WorktreeManager = class _WorktreeManager {
8796
8861
  const lockContent = fs14.readFileSync(lockPath, "utf-8").trim();
8797
8862
  const lockPid = parseInt(lockContent, 10);
8798
8863
  if (!isNaN(lockPid) && this.isProcessRunning(lockPid)) {
8799
- await new Promise((resolve6) => setTimeout(resolve6, retryInterval));
8864
+ await new Promise((resolve7) => setTimeout(resolve7, retryInterval));
8800
8865
  continue;
8801
8866
  }
8802
8867
  } catch {
@@ -8810,7 +8875,7 @@ var WorktreeManager = class _WorktreeManager {
8810
8875
  } catch {
8811
8876
  continue;
8812
8877
  }
8813
- await new Promise((resolve6) => setTimeout(resolve6, retryInterval));
8878
+ await new Promise((resolve7) => setTimeout(resolve7, retryInterval));
8814
8879
  continue;
8815
8880
  }
8816
8881
  throw err;
@@ -9600,6 +9665,7 @@ function getInstallCommand(cwd) {
9600
9665
  var fs34 = __toESM(require("fs"));
9601
9666
  var http2 = __toESM(require("http"));
9602
9667
  var os15 = __toESM(require("os"));
9668
+ var import_child_process19 = require("child_process");
9603
9669
 
9604
9670
  // src/daemon/ipc-router.ts
9605
9671
  var os14 = __toESM(require("os"));
@@ -10333,7 +10399,7 @@ async function handleExec(command, projectPath) {
10333
10399
  env = {}
10334
10400
  } = command;
10335
10401
  const effectiveTimeout = Math.min(Math.max(timeout, 1e3), MAX_TIMEOUT);
10336
- return new Promise((resolve6) => {
10402
+ return new Promise((resolve7) => {
10337
10403
  let stdout = "";
10338
10404
  let stderr = "";
10339
10405
  let timedOut = false;
@@ -10341,7 +10407,7 @@ async function handleExec(command, projectPath) {
10341
10407
  const done = (result) => {
10342
10408
  if (resolved) return;
10343
10409
  resolved = true;
10344
- resolve6(result);
10410
+ resolve7(result);
10345
10411
  };
10346
10412
  try {
10347
10413
  const proc = (0, import_child_process11.spawn)(cmd, {
@@ -10445,18 +10511,18 @@ var import_core12 = __toESM(require_dist());
10445
10511
  // src/utils/port-check.ts
10446
10512
  var net2 = __toESM(require("net"));
10447
10513
  async function isPortInUse(port) {
10448
- return new Promise((resolve6) => {
10514
+ return new Promise((resolve7) => {
10449
10515
  const server = net2.createServer();
10450
10516
  server.once("error", (err) => {
10451
10517
  if (err.code === "EADDRINUSE") {
10452
- resolve6(true);
10518
+ resolve7(true);
10453
10519
  } else {
10454
- resolve6(false);
10520
+ resolve7(false);
10455
10521
  }
10456
10522
  });
10457
10523
  server.once("listening", () => {
10458
10524
  server.close();
10459
- resolve6(false);
10525
+ resolve7(false);
10460
10526
  });
10461
10527
  server.listen(port);
10462
10528
  });
@@ -10859,7 +10925,7 @@ var DevServerRegistry = class {
10859
10925
  return killed;
10860
10926
  }
10861
10927
  wait(ms) {
10862
- return new Promise((resolve6) => setTimeout(resolve6, ms));
10928
+ return new Promise((resolve7) => setTimeout(resolve7, ms));
10863
10929
  }
10864
10930
  killWsServerOnPort(wsPort, worktreePath) {
10865
10931
  const killed = [];
@@ -11433,7 +11499,7 @@ var DevServerRunner = class extends import_events2.EventEmitter {
11433
11499
  return Math.min(delay, DEV_SERVER_CONSTANTS.MAX_RESTART_DELAY_MS);
11434
11500
  }
11435
11501
  async checkHealth(port) {
11436
- return new Promise((resolve6) => {
11502
+ return new Promise((resolve7) => {
11437
11503
  const req = http.request(
11438
11504
  {
11439
11505
  hostname: "localhost",
@@ -11442,18 +11508,18 @@ var DevServerRunner = class extends import_events2.EventEmitter {
11442
11508
  method: "HEAD",
11443
11509
  timeout: DEV_SERVER_CONSTANTS.HEALTH_CHECK_TIMEOUT_MS
11444
11510
  },
11445
- () => resolve6(true)
11511
+ () => resolve7(true)
11446
11512
  );
11447
- req.on("error", () => resolve6(false));
11513
+ req.on("error", () => resolve7(false));
11448
11514
  req.on("timeout", () => {
11449
11515
  req.destroy();
11450
- resolve6(false);
11516
+ resolve7(false);
11451
11517
  });
11452
11518
  req.end();
11453
11519
  });
11454
11520
  }
11455
11521
  async checkWsHealth(wsPort) {
11456
- return new Promise((resolve6) => {
11522
+ return new Promise((resolve7) => {
11457
11523
  const req = http.request(
11458
11524
  {
11459
11525
  hostname: "127.0.0.1",
@@ -11463,14 +11529,14 @@ var DevServerRunner = class extends import_events2.EventEmitter {
11463
11529
  timeout: DEV_SERVER_CONSTANTS.HEALTH_CHECK_TIMEOUT_MS
11464
11530
  },
11465
11531
  (res) => {
11466
- resolve6(res.statusCode === 200);
11532
+ resolve7(res.statusCode === 200);
11467
11533
  res.resume();
11468
11534
  }
11469
11535
  );
11470
- req.on("error", () => resolve6(false));
11536
+ req.on("error", () => resolve7(false));
11471
11537
  req.on("timeout", () => {
11472
11538
  req.destroy();
11473
- resolve6(false);
11539
+ resolve7(false);
11474
11540
  });
11475
11541
  req.end();
11476
11542
  });
@@ -11498,7 +11564,7 @@ var DevServerRunner = class extends import_events2.EventEmitter {
11498
11564
  return false;
11499
11565
  }
11500
11566
  wait(ms) {
11501
- return new Promise((resolve6) => setTimeout(resolve6, ms));
11567
+ return new Promise((resolve7) => setTimeout(resolve7, ms));
11502
11568
  }
11503
11569
  getLogsDir() {
11504
11570
  const logsDir = path25.join((0, import_core12.getConfigDir)(), "logs");
@@ -11697,7 +11763,7 @@ var PreviewManager = class extends import_events3.EventEmitter {
11697
11763
  for (let attempt = 1; attempt <= MAX_TUNNEL_RETRIES; attempt++) {
11698
11764
  if (attempt > 1) {
11699
11765
  console.log(`[PreviewManager] Retrying tunnel for ${moduleUid} (attempt ${attempt}/${MAX_TUNNEL_RETRIES})...`);
11700
- await new Promise((resolve6) => setTimeout(resolve6, 2e3));
11766
+ await new Promise((resolve7) => setTimeout(resolve7, 2e3));
11701
11767
  }
11702
11768
  tunnelResult = await this.tunnel.startTunnel({
11703
11769
  moduleUid,
@@ -11829,7 +11895,7 @@ var PreviewManager = class extends import_events3.EventEmitter {
11829
11895
  }
11830
11896
  console.log(`[PreviewManager] Restarting preview for ${moduleUid}`);
11831
11897
  await this.stopPreview(moduleUid);
11832
- await new Promise((resolve6) => setTimeout(resolve6, 1e3));
11898
+ await new Promise((resolve7) => setTimeout(resolve7, 1e3));
11833
11899
  return this.startPreview({
11834
11900
  moduleUid,
11835
11901
  worktreePath: state.worktreePath,
@@ -12770,7 +12836,7 @@ async function killProcessOnPort(port) {
12770
12836
  } catch {
12771
12837
  }
12772
12838
  }
12773
- await new Promise((resolve6) => setTimeout(resolve6, 1e3));
12839
+ await new Promise((resolve7) => setTimeout(resolve7, 1e3));
12774
12840
  for (const pid of pids) {
12775
12841
  try {
12776
12842
  (0, import_child_process15.execSync)(`kill -0 ${pid} 2>/dev/null`, { encoding: "utf8" });
@@ -12779,7 +12845,7 @@ async function killProcessOnPort(port) {
12779
12845
  } catch {
12780
12846
  }
12781
12847
  }
12782
- await new Promise((resolve6) => setTimeout(resolve6, 500));
12848
+ await new Promise((resolve7) => setTimeout(resolve7, 500));
12783
12849
  const stillInUse = await isPortInUse(port);
12784
12850
  if (stillInUse) {
12785
12851
  console.error(`[DevServer] EP929: Port ${port} still in use after kill attempts`);
@@ -12799,7 +12865,7 @@ async function waitForPort(port, timeoutMs = 3e4) {
12799
12865
  if (await isPortInUse(port)) {
12800
12866
  return true;
12801
12867
  }
12802
- await new Promise((resolve6) => setTimeout(resolve6, checkInterval));
12868
+ await new Promise((resolve7) => setTimeout(resolve7, checkInterval));
12803
12869
  }
12804
12870
  return false;
12805
12871
  }
@@ -12871,7 +12937,7 @@ async function handleProcessExit(moduleUid, code, signal) {
12871
12937
  const delay = calculateRestartDelay(serverInfo.restartCount);
12872
12938
  console.log(`[DevServer] EP932: Restarting ${moduleUid} in ${delay}ms (attempt ${serverInfo.restartCount + 1}/${MAX_RESTART_ATTEMPTS})`);
12873
12939
  writeToLog(serverInfo.logFile || "", `Scheduling restart in ${delay}ms (attempt ${serverInfo.restartCount + 1})`, false);
12874
- await new Promise((resolve6) => setTimeout(resolve6, delay));
12940
+ await new Promise((resolve7) => setTimeout(resolve7, delay));
12875
12941
  if (!activeServers.has(moduleUid)) {
12876
12942
  console.log(`[DevServer] EP932: Server ${moduleUid} was removed during restart delay, aborting restart`);
12877
12943
  return;
@@ -12996,7 +13062,7 @@ async function stopDevServer(moduleUid) {
12996
13062
  writeToLog(serverInfo.logFile, `Stopping server (manual stop)`, false);
12997
13063
  }
12998
13064
  serverInfo.process.kill("SIGTERM");
12999
- await new Promise((resolve6) => setTimeout(resolve6, 2e3));
13065
+ await new Promise((resolve7) => setTimeout(resolve7, 2e3));
13000
13066
  if (!serverInfo.process.killed) {
13001
13067
  serverInfo.process.kill("SIGKILL");
13002
13068
  }
@@ -13014,7 +13080,7 @@ async function restartDevServer(moduleUid) {
13014
13080
  writeToLog(logFile, `Manual restart requested`, false);
13015
13081
  }
13016
13082
  await stopDevServer(moduleUid);
13017
- await new Promise((resolve6) => setTimeout(resolve6, 1e3));
13083
+ await new Promise((resolve7) => setTimeout(resolve7, 1e3));
13018
13084
  if (await isPortInUse(port)) {
13019
13085
  await killProcessOnPort(port);
13020
13086
  }
@@ -13076,7 +13142,6 @@ var IPCRouter = class {
13076
13142
  });
13077
13143
  this.ipcServer.on("add-project", async (params) => {
13078
13144
  const { projectId, projectPath } = params;
13079
- addProject(projectId, projectPath);
13080
13145
  const MAX_RETRIES = 3;
13081
13146
  const INITIAL_DELAY = 1e3;
13082
13147
  let lastError = "";
@@ -13089,6 +13154,7 @@ var IPCRouter = class {
13089
13154
  lastError = "Connection established but not healthy";
13090
13155
  return { success: false, connected: false, error: lastError };
13091
13156
  }
13157
+ addProject(projectId, projectPath);
13092
13158
  return { success: true, connected: true };
13093
13159
  } catch (error) {
13094
13160
  lastError = error instanceof Error ? error.message : String(error);
@@ -13096,7 +13162,7 @@ var IPCRouter = class {
13096
13162
  if (attempt < MAX_RETRIES) {
13097
13163
  const delay = INITIAL_DELAY * Math.pow(2, attempt - 1);
13098
13164
  console.log(`[Daemon] Retrying in ${delay / 1e3}s...`);
13099
- await new Promise((resolve6) => setTimeout(resolve6, delay));
13165
+ await new Promise((resolve7) => setTimeout(resolve7, delay));
13100
13166
  await this.host.disconnectProject(projectPath);
13101
13167
  }
13102
13168
  }
@@ -13536,6 +13602,8 @@ var UpdateManager = class _UpdateManager {
13536
13602
  const child = (0, import_child_process17.spawn)("node", [this.daemonEntryFile], {
13537
13603
  detached: true,
13538
13604
  stdio: ["ignore", logFd, logFd],
13605
+ cwd: configDir,
13606
+ // Keep daemon process context stable across restarts.
13539
13607
  env: { ...process.env, EPISODA_DAEMON_MODE: "1" }
13540
13608
  });
13541
13609
  if (!child.pid) {
@@ -13609,6 +13677,8 @@ var HealthOrchestrator = class _HealthOrchestrator {
13609
13677
  this.tunnelPollInterval = null;
13610
13678
  // EP833: Track consecutive health check failures per tunnel
13611
13679
  this.tunnelHealthFailures = /* @__PURE__ */ new Map();
13680
+ // Restart after 2 consecutive failures
13681
+ this.tunnelRestartCooldownUntil = /* @__PURE__ */ new Map();
13612
13682
  // 3 second timeout for health checks
13613
13683
  // EP929: Health check polling interval (restored from EP843 removal)
13614
13684
  // Health checks are orthogonal to push-based state sync - they detect dead tunnels
@@ -13623,7 +13693,11 @@ var HealthOrchestrator = class _HealthOrchestrator {
13623
13693
  this.HEALTH_CHECK_FAILURE_THRESHOLD = 2;
13624
13694
  }
13625
13695
  static {
13626
- // Restart after 2 consecutive failures
13696
+ // moduleUid -> unix ms
13697
+ this.HEALTH_RESTART_COOLDOWN_MS = 3 * 60 * 1e3;
13698
+ }
13699
+ static {
13700
+ // 3 minutes
13627
13701
  this.HEALTH_CHECK_TIMEOUT_MS = 3e3;
13628
13702
  }
13629
13703
  static {
@@ -14043,20 +14117,55 @@ var HealthOrchestrator = class _HealthOrchestrator {
14043
14117
  const isHealthy = await this.checkTunnelHealth(tunnel);
14044
14118
  if (isHealthy) {
14045
14119
  this.tunnelHealthFailures.delete(tunnel.moduleUid);
14120
+ this.tunnelRestartCooldownUntil.delete(tunnel.moduleUid);
14046
14121
  } else {
14047
14122
  const failures = (this.tunnelHealthFailures.get(tunnel.moduleUid) || 0) + 1;
14048
14123
  this.tunnelHealthFailures.set(tunnel.moduleUid, failures);
14049
14124
  console.log(`[Daemon] EP833: Health check failed for ${tunnel.moduleUid} (${failures}/${_HealthOrchestrator.HEALTH_CHECK_FAILURE_THRESHOLD})`);
14050
- if (failures >= _HealthOrchestrator.HEALTH_CHECK_FAILURE_THRESHOLD) {
14051
- console.log(`[Daemon] EP833: Tunnel unhealthy for ${tunnel.moduleUid}, restarting...`);
14052
- await this.host.withTunnelLock(tunnel.moduleUid, async () => {
14053
- await this.restartTunnel(tunnel.moduleUid, tunnel.port);
14054
- });
14055
- this.tunnelHealthFailures.delete(tunnel.moduleUid);
14125
+ if (failures < _HealthOrchestrator.HEALTH_CHECK_FAILURE_THRESHOLD) {
14126
+ continue;
14056
14127
  }
14128
+ const now = Date.now();
14129
+ const cooldownUntil = this.tunnelRestartCooldownUntil.get(tunnel.moduleUid) || 0;
14130
+ if (cooldownUntil > now) {
14131
+ continue;
14132
+ }
14133
+ const healthProjectId = config.project_id || config.projectId;
14134
+ if (healthProjectId) {
14135
+ const activeModuleUids = await this.fetchActiveModuleUids(healthProjectId);
14136
+ if (activeModuleUids && !activeModuleUids.includes(tunnel.moduleUid)) {
14137
+ console.log(`[Daemon] EP1417: Skipping health auto-restart for ${tunnel.moduleUid}; module is no longer active`);
14138
+ await this.retireInactiveTunnel(tunnel.moduleUid);
14139
+ this.tunnelHealthFailures.delete(tunnel.moduleUid);
14140
+ this.tunnelRestartCooldownUntil.delete(tunnel.moduleUid);
14141
+ continue;
14142
+ }
14143
+ }
14144
+ console.log(`[Daemon] EP833: Tunnel unhealthy for ${tunnel.moduleUid}, restarting...`);
14145
+ await this.host.withTunnelLock(tunnel.moduleUid, async () => {
14146
+ await this.restartTunnel(tunnel.moduleUid, tunnel.port);
14147
+ });
14148
+ this.tunnelHealthFailures.delete(tunnel.moduleUid);
14149
+ this.tunnelRestartCooldownUntil.set(
14150
+ tunnel.moduleUid,
14151
+ now + _HealthOrchestrator.HEALTH_RESTART_COOLDOWN_MS
14152
+ );
14057
14153
  }
14058
14154
  }
14059
14155
  }
14156
+ async retireInactiveTunnel(moduleUid) {
14157
+ try {
14158
+ const tunnelManager = getTunnelManager();
14159
+ await tunnelManager.stopTunnel(moduleUid);
14160
+ } catch (error) {
14161
+ console.warn(`[Daemon] EP1417: Failed to stop inactive tunnel for ${moduleUid}:`, error);
14162
+ }
14163
+ try {
14164
+ await stopDevServer(moduleUid);
14165
+ } catch (error) {
14166
+ console.warn(`[Daemon] EP1417: Failed to stop inactive dev server for ${moduleUid}:`, error);
14167
+ }
14168
+ }
14060
14169
  /**
14061
14170
  * EP833: Check if a tunnel is healthy
14062
14171
  * EP1042: Now also verifies dev server ownership (correct worktree)
@@ -14207,45 +14316,54 @@ var DaemonCore = class {
14207
14316
  // src/daemon/connection-manager.ts
14208
14317
  var ConnectionManager = class {
14209
14318
  constructor() {
14319
+ // Single source-of-truth for project connectivity.
14210
14320
  this.connections = /* @__PURE__ */ new Map();
14211
- // projectPath -> connection
14212
- this.liveConnections = /* @__PURE__ */ new Set();
14213
- // projectPath
14214
- this.pendingConnections = /* @__PURE__ */ new Set();
14215
14321
  }
14216
- // projectPath
14322
+ // projectPath -> connection
14217
14323
  hasConnection(projectPath) {
14218
14324
  return this.connections.has(projectPath);
14219
14325
  }
14220
14326
  hasLiveConnection(projectPath) {
14221
- return this.liveConnections.has(projectPath);
14327
+ return this.getState(projectPath) === "connected";
14222
14328
  }
14223
14329
  hasPendingConnection(projectPath) {
14224
- return this.pendingConnections.has(projectPath);
14330
+ const state = this.getState(projectPath);
14331
+ return state === "connecting" || state === "authenticating";
14225
14332
  }
14226
14333
  isConnectionHealthy(projectPath) {
14227
- return this.connections.has(projectPath) && this.liveConnections.has(projectPath);
14334
+ return this.getState(projectPath) === "connected" && this.isWebSocketOpen(projectPath);
14228
14335
  }
14229
14336
  getConnection(projectPath) {
14230
14337
  return this.connections.get(projectPath);
14231
14338
  }
14232
14339
  setConnection(projectPath, connection) {
14233
- this.connections.set(projectPath, connection);
14340
+ const stateful = {
14341
+ ...connection,
14342
+ state: connection.state || "connecting",
14343
+ lastTransitionAt: connection.lastTransitionAt || Date.now()
14344
+ };
14345
+ this.connections.set(projectPath, stateful);
14234
14346
  }
14235
14347
  deleteConnection(projectPath) {
14236
14348
  this.connections.delete(projectPath);
14237
14349
  }
14238
14350
  addLiveConnection(projectPath) {
14239
- this.liveConnections.add(projectPath);
14351
+ this.setState(projectPath, "connected");
14240
14352
  }
14241
14353
  removeLiveConnection(projectPath) {
14242
- this.liveConnections.delete(projectPath);
14354
+ const state = this.getState(projectPath);
14355
+ if (state === "connected") {
14356
+ this.setState(projectPath, "disconnected");
14357
+ }
14243
14358
  }
14244
14359
  addPendingConnection(projectPath) {
14245
- this.pendingConnections.add(projectPath);
14360
+ this.setState(projectPath, "connecting");
14246
14361
  }
14247
14362
  removePendingConnection(projectPath) {
14248
- this.pendingConnections.delete(projectPath);
14363
+ const state = this.getState(projectPath);
14364
+ if (state === "connecting" || state === "authenticating") {
14365
+ this.setState(projectPath, "disconnected");
14366
+ }
14249
14367
  }
14250
14368
  isWebSocketOpen(projectPath) {
14251
14369
  const connection = this.connections.get(projectPath);
@@ -14256,10 +14374,18 @@ var ConnectionManager = class {
14256
14374
  return this.connections.size;
14257
14375
  }
14258
14376
  liveConnectionCount() {
14259
- return this.liveConnections.size;
14377
+ let count = 0;
14378
+ for (const connection of this.connections.values()) {
14379
+ if (connection.state === "connected") count++;
14380
+ }
14381
+ return count;
14260
14382
  }
14261
14383
  pendingConnectionCount() {
14262
- return this.pendingConnections.size;
14384
+ let count = 0;
14385
+ for (const connection of this.connections.values()) {
14386
+ if (connection.state === "connecting" || connection.state === "authenticating") count++;
14387
+ }
14388
+ return count;
14263
14389
  }
14264
14390
  entries() {
14265
14391
  return this.connections.entries();
@@ -14268,12 +14394,24 @@ var ConnectionManager = class {
14268
14394
  this.connections.clear();
14269
14395
  }
14270
14396
  clearLiveConnections() {
14271
- this.liveConnections.clear();
14397
+ for (const [projectPath, connection] of this.connections.entries()) {
14398
+ if (connection.state === "connected") {
14399
+ this.setState(projectPath, "disconnected");
14400
+ }
14401
+ }
14272
14402
  }
14273
14403
  clearPendingConnections() {
14274
- this.pendingConnections.clear();
14404
+ for (const [projectPath, connection] of this.connections.entries()) {
14405
+ if (connection.state === "connecting" || connection.state === "authenticating") {
14406
+ this.setState(projectPath, "disconnected");
14407
+ }
14408
+ }
14275
14409
  }
14276
14410
  async restoreConnections(connectProject) {
14411
+ const pruned = pruneMissingProjectPaths();
14412
+ if (pruned > 0) {
14413
+ console.log(`[Daemon] Pruned ${pruned} stale project tracking entr${pruned === 1 ? "y" : "ies"} before restore`);
14414
+ }
14277
14415
  const projects = getAllProjects();
14278
14416
  for (const project of projects) {
14279
14417
  try {
@@ -14283,19 +14421,31 @@ var ConnectionManager = class {
14283
14421
  }
14284
14422
  }
14285
14423
  }
14424
+ getState(projectPath) {
14425
+ return this.connections.get(projectPath)?.state || null;
14426
+ }
14427
+ setState(projectPath, state, error) {
14428
+ const connection = this.connections.get(projectPath);
14429
+ if (!connection) return;
14430
+ connection.state = state;
14431
+ connection.lastTransitionAt = Date.now();
14432
+ connection.lastError = error;
14433
+ }
14286
14434
  /**
14287
- * Wait for auth_success or auth_error after a successful WebSocket connect().
14435
+ * Wait for auth_success or fail-fast auth/transport errors after connect().
14288
14436
  *
14289
14437
  * This keeps auth timeout lifecycle in the connection subsystem and ensures
14290
14438
  * handlers are removed deterministically on every exit path.
14291
14439
  */
14292
14440
  async waitForAuthentication(client, timeoutMs = 3e4) {
14293
- await new Promise((resolve6, reject) => {
14441
+ await new Promise((resolve7, reject) => {
14294
14442
  let settled = false;
14295
14443
  const cleanup = () => {
14296
14444
  clearTimeout(timeout);
14297
14445
  client.off("auth_success", authHandler);
14298
- client.off("auth_error", errorHandler);
14446
+ client.off("auth_error", authErrorHandler);
14447
+ client.off("error", serverErrorHandler);
14448
+ client.off("disconnected", disconnectedHandler);
14299
14449
  };
14300
14450
  const timeout = setTimeout(() => {
14301
14451
  if (settled) return;
@@ -14307,17 +14457,41 @@ var ConnectionManager = class {
14307
14457
  if (settled) return;
14308
14458
  settled = true;
14309
14459
  cleanup();
14310
- resolve6();
14460
+ resolve7();
14311
14461
  };
14312
- const errorHandler = (message) => {
14462
+ const authErrorHandler = (message) => {
14313
14463
  if (settled) return;
14314
14464
  settled = true;
14315
14465
  cleanup();
14316
14466
  const errorMsg = message;
14317
14467
  reject(new Error(errorMsg.message || "Authentication failed"));
14318
14468
  };
14469
+ const serverErrorHandler = (message) => {
14470
+ if (settled) return;
14471
+ settled = true;
14472
+ cleanup();
14473
+ const errorMsg = message;
14474
+ const code = errorMsg.code || "SERVER_ERROR";
14475
+ if (code === "TOO_SOON" && typeof errorMsg.retryAfter === "number") {
14476
+ reject(new Error(`Authentication deferred by server (${code}, retryAfter=${errorMsg.retryAfter}s)`));
14477
+ return;
14478
+ }
14479
+ reject(new Error(errorMsg.message || `Authentication failed (${code})`));
14480
+ };
14481
+ const disconnectedHandler = (message) => {
14482
+ if (settled) return;
14483
+ settled = true;
14484
+ cleanup();
14485
+ const event = message;
14486
+ const code = typeof event.code === "number" ? event.code : -1;
14487
+ const reason = event.reason || "connection closed";
14488
+ const suffix = event.willReconnect ? ", reconnect scheduled" : "";
14489
+ reject(new Error(`Connection closed before authentication (code=${code}, reason=${reason}${suffix})`));
14490
+ };
14319
14491
  client.on("auth_success", authHandler);
14320
- client.on("auth_error", errorHandler);
14492
+ client.on("auth_error", authErrorHandler);
14493
+ client.on("error", serverErrorHandler);
14494
+ client.on("disconnected", disconnectedHandler);
14321
14495
  });
14322
14496
  }
14323
14497
  };
@@ -14884,6 +15058,8 @@ var Daemon = class _Daemon {
14884
15058
  // EP1003: Prevents race conditions between server-orchestrated tunnel commands
14885
15059
  this.tunnelOperationLocks = /* @__PURE__ */ new Map();
14886
15060
  // moduleUid -> operation promise
15061
+ // EP1426: Single in-flight connect attempt per project path.
15062
+ this.connectProjectInFlight = /* @__PURE__ */ new Map();
14887
15063
  // EP1210-7: Health HTTP endpoint for external monitoring
14888
15064
  this.healthServer = null;
14889
15065
  // EP1267: Cloud disconnect watchdog
@@ -14921,6 +15097,14 @@ var Daemon = class _Daemon {
14921
15097
  this.agentEventSeq.set(sessionId, next);
14922
15098
  return next;
14923
15099
  }
15100
+ logReliabilityMetric(metric, fields) {
15101
+ console.log(`[Daemon][Metric] ${JSON.stringify({
15102
+ metric,
15103
+ machineId: this.machineId || "unknown",
15104
+ at: (/* @__PURE__ */ new Date()).toISOString(),
15105
+ ...fields
15106
+ })}`);
15107
+ }
14924
15108
  /**
14925
15109
  * Start the daemon
14926
15110
  */
@@ -15117,8 +15301,8 @@ var Daemon = class _Daemon {
15117
15301
  }
15118
15302
  }
15119
15303
  let releaseLock;
15120
- const lockPromise = new Promise((resolve6) => {
15121
- releaseLock = resolve6;
15304
+ const lockPromise = new Promise((resolve7) => {
15305
+ releaseLock = resolve7;
15122
15306
  });
15123
15307
  this.tunnelOperationLocks.set(moduleUid, lockPromise);
15124
15308
  try {
@@ -15135,6 +15319,25 @@ var Daemon = class _Daemon {
15135
15319
  * EP1366: Made public for IPCRouterHost interface
15136
15320
  */
15137
15321
  async connectProject(projectId, projectPath) {
15322
+ const existing = this.connectProjectInFlight.get(projectPath);
15323
+ if (existing) {
15324
+ console.log(`[Daemon] Connection attempt already in progress for ${projectPath}, joining existing attempt`);
15325
+ return existing;
15326
+ }
15327
+ const attempt = this.connectProjectInternal(projectId, projectPath);
15328
+ this.connectProjectInFlight.set(projectPath, attempt);
15329
+ try {
15330
+ await attempt;
15331
+ } finally {
15332
+ if (this.connectProjectInFlight.get(projectPath) === attempt) {
15333
+ this.connectProjectInFlight.delete(projectPath);
15334
+ }
15335
+ }
15336
+ }
15337
+ async connectProjectInternal(projectId, projectPath) {
15338
+ const initialState = this.connectionManager.getState(projectPath);
15339
+ const isReconnectAttempt = initialState !== null;
15340
+ const connectAttemptStartedAt = Date.now();
15138
15341
  if (this.connectionManager.hasConnection(projectPath)) {
15139
15342
  if (this.connectionManager.hasLiveConnection(projectPath)) {
15140
15343
  console.log(`[Daemon] Already connected to ${projectPath}`);
@@ -15145,7 +15348,7 @@ var Daemon = class _Daemon {
15145
15348
  const maxWait = 35e3;
15146
15349
  const startTime = Date.now();
15147
15350
  while (this.connectionManager.hasPendingConnection(projectPath) && Date.now() - startTime < maxWait) {
15148
- await new Promise((resolve6) => setTimeout(resolve6, 500));
15351
+ await new Promise((resolve7) => setTimeout(resolve7, 500));
15149
15352
  }
15150
15353
  if (this.connectionManager.hasLiveConnection(projectPath)) {
15151
15354
  console.log(`[Daemon] Pending connection succeeded for ${projectPath}`);
@@ -15156,6 +15359,12 @@ var Daemon = class _Daemon {
15156
15359
  console.warn(`[Daemon] Stale connection detected for ${projectPath}, forcing reconnection`);
15157
15360
  await this.disconnectProject(projectPath);
15158
15361
  }
15362
+ this.logReliabilityMetric("ws_connect_attempt", {
15363
+ projectId,
15364
+ projectPath,
15365
+ reconnect: isReconnectAttempt,
15366
+ previousState: initialState
15367
+ });
15159
15368
  const config = await (0, import_core22.loadConfig)();
15160
15369
  if (!config || !config.access_token) {
15161
15370
  throw new Error("No access token found. Please run: episoda auth");
@@ -15170,12 +15379,15 @@ var Daemon = class _Daemon {
15170
15379
  }
15171
15380
  console.log(`[Daemon] Connecting to ${wsEndpoint.wsUrl} for project ${projectId} (source: ${wsEndpoint.source})...`);
15172
15381
  const client = new import_core22.EpisodaClient();
15382
+ client.setAutoReconnect(false);
15173
15383
  const gitExecutor = new import_core22.GitExecutor();
15174
15384
  const connection = {
15175
15385
  projectId,
15176
15386
  projectPath,
15177
15387
  client,
15178
- gitExecutor
15388
+ gitExecutor,
15389
+ state: "connecting",
15390
+ lastTransitionAt: Date.now()
15179
15391
  };
15180
15392
  this.connectionManager.setConnection(projectPath, connection);
15181
15393
  this.connectionManager.addPendingConnection(projectPath);
@@ -15513,6 +15725,7 @@ var Daemon = class _Daemon {
15513
15725
  }
15514
15726
  });
15515
15727
  client.on("auth_success", async (message) => {
15728
+ client.setAutoReconnect(true);
15516
15729
  console.log(`[Daemon] Authenticated for project ${projectId}`);
15517
15730
  touchProject(projectPath);
15518
15731
  this.connectionManager.addLiveConnection(projectPath);
@@ -15626,9 +15839,59 @@ var Daemon = class _Daemon {
15626
15839
  console.error("[Daemon] EP1237: Error handling agent_reconciliation_commands:", error instanceof Error ? error.message : error);
15627
15840
  }
15628
15841
  });
15842
+ client.on("reconnect_scheduled", (event) => {
15843
+ const reconnectEvent = event;
15844
+ this.logReliabilityMetric("ws_transport_reconnect_scheduled", {
15845
+ projectId,
15846
+ projectPath,
15847
+ attempt: reconnectEvent.attempt,
15848
+ delayMs: reconnectEvent.delayMs,
15849
+ strategy: reconnectEvent.strategy
15850
+ });
15851
+ });
15852
+ client.on("reconnect_attempt", (event) => {
15853
+ const reconnectEvent = event;
15854
+ this.logReliabilityMetric("ws_transport_reconnect_attempt", {
15855
+ projectId,
15856
+ projectPath,
15857
+ attempt: reconnectEvent.attempt
15858
+ });
15859
+ });
15860
+ client.on("reconnect_result", (event) => {
15861
+ const reconnectEvent = event;
15862
+ this.logReliabilityMetric("ws_transport_reconnect_result", {
15863
+ projectId,
15864
+ projectPath,
15865
+ attempt: reconnectEvent.attempt,
15866
+ success: reconnectEvent.success,
15867
+ error: reconnectEvent.error
15868
+ });
15869
+ });
15870
+ client.on("reconnect_exhausted", (event) => {
15871
+ const reconnectEvent = event;
15872
+ this.logReliabilityMetric("ws_transport_reconnect_exhausted", {
15873
+ projectId,
15874
+ projectPath,
15875
+ attempts: reconnectEvent.attempts,
15876
+ reason: reconnectEvent.reason
15877
+ });
15878
+ this.logReliabilityMetric("manual_reconnect_needed", {
15879
+ source: "daemon",
15880
+ projectId,
15881
+ projectPath,
15882
+ reason: reconnectEvent.reason
15883
+ });
15884
+ });
15629
15885
  client.on("disconnected", (event) => {
15630
15886
  const disconnectEvent = event;
15631
15887
  console.log(`[Daemon] Connection closed for ${projectId}: code=${disconnectEvent.code}, willReconnect=${disconnectEvent.willReconnect}`);
15888
+ this.logReliabilityMetric("ws_transport_disconnect", {
15889
+ projectId,
15890
+ projectPath,
15891
+ code: disconnectEvent.code,
15892
+ reason: disconnectEvent.reason,
15893
+ willReconnect: disconnectEvent.willReconnect
15894
+ });
15632
15895
  this.connectionManager.removeLiveConnection(projectPath);
15633
15896
  if (this.connectionManager.liveConnectionCount() === 0) {
15634
15897
  this.lastDisconnectAt = this.lastDisconnectAt || Date.now();
@@ -15666,10 +15929,36 @@ var Daemon = class _Daemon {
15666
15929
  containerId
15667
15930
  });
15668
15931
  console.log(`[Daemon] Successfully connected to project ${projectId}`);
15932
+ this.connectionManager.setState(projectPath, "authenticating");
15669
15933
  await this.connectionManager.waitForAuthentication(client, 3e4);
15670
15934
  console.log(`[Daemon] Authentication complete for project ${projectId}`);
15935
+ this.logReliabilityMetric("ws_connect_result", {
15936
+ projectId,
15937
+ projectPath,
15938
+ reconnect: isReconnectAttempt,
15939
+ success: true,
15940
+ durationMs: Date.now() - connectAttemptStartedAt
15941
+ });
15671
15942
  } catch (error) {
15672
15943
  console.error(`[Daemon] Failed to connect to ${projectId}:`, error);
15944
+ this.logReliabilityMetric("ws_connect_result", {
15945
+ projectId,
15946
+ projectPath,
15947
+ reconnect: isReconnectAttempt,
15948
+ success: false,
15949
+ durationMs: Date.now() - connectAttemptStartedAt,
15950
+ error: error instanceof Error ? error.message : String(error)
15951
+ });
15952
+ try {
15953
+ await client.disconnect(true);
15954
+ } catch {
15955
+ }
15956
+ client.clearAllHandlers();
15957
+ this.connectionManager.setState(
15958
+ projectPath,
15959
+ "disconnected",
15960
+ error instanceof Error ? error.message : String(error)
15961
+ );
15673
15962
  this.connectionManager.deleteConnection(projectPath);
15674
15963
  this.connectionManager.removePendingConnection(projectPath);
15675
15964
  throw error;
@@ -16101,14 +16390,13 @@ var Daemon = class _Daemon {
16101
16390
  console.log(`[Daemon] EP1002: ${installCmd.description} (detected from ${installCmd.detectedFrom})`);
16102
16391
  console.log(`[Daemon] EP1002: Running: ${installCmd.command.join(" ")}`);
16103
16392
  try {
16104
- const { execSync: execSync11 } = await import("child_process");
16105
- execSync11(installCmd.command.join(" "), {
16106
- cwd: worktreePath,
16107
- stdio: "inherit",
16108
- timeout: 10 * 60 * 1e3,
16109
- // 10 minute timeout
16110
- env: { ...process.env, CI: "true" }
16111
- });
16393
+ await this.runForegroundCommand(
16394
+ installCmd.command[0],
16395
+ installCmd.command.slice(1),
16396
+ worktreePath,
16397
+ { ...process.env, CI: "true" },
16398
+ 10 * 60 * 1e3
16399
+ );
16112
16400
  installSucceeded = true;
16113
16401
  console.log(`[Daemon] EP1002: Dependencies installed successfully`);
16114
16402
  } catch (installError) {
@@ -16124,13 +16412,12 @@ var Daemon = class _Daemon {
16124
16412
  const bootstrapCmd = buildCmd;
16125
16413
  console.log(`[Daemon] EP1386: Bootstrapping packages after dependency install (${bootstrapCmd})`);
16126
16414
  try {
16127
- const { execSync: execSync11 } = await import("child_process");
16128
- execSync11(bootstrapCmd, {
16129
- cwd: worktreePath,
16130
- stdio: "inherit",
16131
- timeout: 10 * 60 * 1e3,
16132
- env: { ...process.env, CI: "true" }
16133
- });
16415
+ await this.runShellForegroundCommand(
16416
+ bootstrapCmd,
16417
+ worktreePath,
16418
+ { ...process.env, CI: "true" },
16419
+ 10 * 60 * 1e3
16420
+ );
16134
16421
  console.log("[Daemon] EP1386: Package bootstrap completed");
16135
16422
  } catch (buildError) {
16136
16423
  const errorMsg = buildError instanceof Error ? buildError.message : String(buildError);
@@ -16150,6 +16437,74 @@ var Daemon = class _Daemon {
16150
16437
  await this.updateModuleWorktreeStatus(moduleUid, "ready", worktreePath);
16151
16438
  console.log(`[Daemon] EP1002: Worktree setup complete for ${moduleUid}`);
16152
16439
  }
16440
+ async runForegroundCommand(command, args, cwd, env, timeoutMs) {
16441
+ await new Promise((resolve7, reject) => {
16442
+ const commandLabel = `${command} ${args.join(" ")}`.trim();
16443
+ const child = (0, import_child_process19.spawn)(command, args, {
16444
+ cwd,
16445
+ env,
16446
+ stdio: "inherit",
16447
+ shell: false
16448
+ });
16449
+ let settled = false;
16450
+ let timedOut = false;
16451
+ const timeoutSeconds = Math.round(timeoutMs / 1e3);
16452
+ const termGraceMs = 5e3;
16453
+ let termTimer = null;
16454
+ let killTimer = null;
16455
+ const finish = (error) => {
16456
+ if (settled) return;
16457
+ settled = true;
16458
+ if (termTimer) clearTimeout(termTimer);
16459
+ if (killTimer) clearTimeout(killTimer);
16460
+ if (error) {
16461
+ reject(error);
16462
+ return;
16463
+ }
16464
+ resolve7();
16465
+ };
16466
+ termTimer = setTimeout(() => {
16467
+ timedOut = true;
16468
+ try {
16469
+ child.kill("SIGTERM");
16470
+ } catch {
16471
+ }
16472
+ killTimer = setTimeout(() => {
16473
+ if (settled) return;
16474
+ try {
16475
+ child.kill("SIGKILL");
16476
+ } catch {
16477
+ }
16478
+ finish(
16479
+ new Error(`Command timed out after ${timeoutSeconds}s (terminated with SIGKILL fallback): ${commandLabel}`)
16480
+ );
16481
+ }, termGraceMs);
16482
+ }, timeoutMs);
16483
+ child.on("error", (error) => {
16484
+ finish(error);
16485
+ });
16486
+ child.on("exit", (code, signal) => {
16487
+ if (timedOut) {
16488
+ finish(
16489
+ new Error(`Command timed out after ${timeoutSeconds}s (exit code=${code ?? "null"}, signal=${signal ?? "none"}): ${commandLabel}`)
16490
+ );
16491
+ return;
16492
+ }
16493
+ if (code === 0) {
16494
+ finish();
16495
+ return;
16496
+ }
16497
+ finish(new Error(`Command failed (code=${code ?? "null"}, signal=${signal ?? "none"}): ${commandLabel}`));
16498
+ });
16499
+ });
16500
+ }
16501
+ async runShellForegroundCommand(command, cwd, env, timeoutMs) {
16502
+ if (process.platform === "win32") {
16503
+ await this.runForegroundCommand("cmd", ["/d", "/s", "/c", command], cwd, env, timeoutMs);
16504
+ return;
16505
+ }
16506
+ await this.runForegroundCommand("sh", ["-lc", command], cwd, env, timeoutMs);
16507
+ }
16153
16508
  // EP1003: startTunnelForModule removed - server now orchestrates via tunnel_start commands
16154
16509
  // EP1003: autoStartTunnelsForProject removed - server now orchestrates via reconciliation
16155
16510
  // Recovery flow: daemon sends reconciliation_report → server processes and sends commands