@lincoln504/pi-research 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.
Files changed (82) hide show
  1. package/README.md +4 -4
  2. package/dist/cli.mjs +1404 -600
  3. package/dist/thread-worker.mjs +40 -11
  4. package/docs/AGENT-SKILL.md +11 -4
  5. package/docs/ARCHITECTURE.md +19 -9
  6. package/docs/CHANGELOG.md +107 -1
  7. package/docs/CONFIGURATION.md +10 -8
  8. package/docs/KNOWLEDGE-STORE.md +5 -4
  9. package/docs/PI-EXTENSION.md +1 -1
  10. package/docs/SDK.md +63 -5
  11. package/docs/THIRD-PARTY-NOTICES.md +1 -1
  12. package/package.json +5 -4
  13. package/scripts/build.cjs +23 -5
  14. package/scripts/cleanup.cjs +50 -10
  15. package/scripts/prepare.cjs +15 -5
  16. package/scripts/setup.cjs +24 -4
  17. package/skills/pi-research/README.md +9 -2
  18. package/skills/pi-research/SKILL.md +8 -5
  19. package/skills/pi-research/references/configuration.md +7 -3
  20. package/skills/pi-research/scripts/run.mjs +24 -2
  21. package/src/cli.ts +161 -21
  22. package/src/config.ts +56 -15
  23. package/src/constants.ts +31 -0
  24. package/src/core/interfaces/knowledge-interfaces.ts +6 -1
  25. package/src/core/interfaces/state-manager-interfaces.ts +1 -1
  26. package/src/core/llm/model-registry-factory.ts +40 -3
  27. package/src/core/llm/research-model-resolver.ts +2 -2
  28. package/src/core/pi-version.ts +136 -0
  29. package/src/core/service-registry.ts +117 -8
  30. package/src/index.ts +11 -18
  31. package/src/infrastructure/browser/browser-client.ts +16 -4
  32. package/src/infrastructure/browser/browser-error-utils.ts +12 -1
  33. package/src/infrastructure/browser/browser-server.ts +17 -5
  34. package/src/infrastructure/browser/browser-task-scheduler.ts +101 -23
  35. package/src/infrastructure/browser/cleanup-utils.ts +45 -7
  36. package/src/infrastructure/browser/client-agent.ts +4 -3
  37. package/src/infrastructure/browser/priority-task-queue.ts +10 -3
  38. package/src/infrastructure/browser/scheduler-factory.ts +26 -1
  39. package/src/infrastructure/browser/task-execution-service.ts +92 -28
  40. package/src/infrastructure/browser/thread-worker-lifecycle.ts +50 -13
  41. package/src/infrastructure/browser/thread-worker.mjs +40 -11
  42. package/src/infrastructure/browser/worker-pool-manager.ts +11 -3
  43. package/src/infrastructure/embedding/embedding-client.ts +22 -8
  44. package/src/infrastructure/embedding/embedding-factory.ts +28 -11
  45. package/src/infrastructure/embedding/embedding-server.ts +65 -4
  46. package/src/infrastructure/file-lock-service.ts +223 -26
  47. package/src/infrastructure/process-lifecycle-service.ts +108 -31
  48. package/src/infrastructure/state/state-backup-manager.ts +5 -7
  49. package/src/infrastructure/state/state-manager-service.ts +1 -1
  50. package/src/infrastructure/state/state-manager.ts +19 -20
  51. package/src/infrastructure/types/state-types.ts +9 -0
  52. package/src/knowledge/embedder-init.ts +1 -1
  53. package/src/knowledge/embedder.ts +52 -3
  54. package/src/knowledge/index.ts +1 -1
  55. package/src/knowledge/store.ts +35 -9
  56. package/src/knowledge/webgpu-viability.ts +9 -4
  57. package/src/knowledge/writer-queue.ts +88 -46
  58. package/src/orchestration/deep-research-orchestrator.ts +13 -2
  59. package/src/orchestration/quick-research-orchestrator.ts +2 -2
  60. package/src/orchestration/research-orchestration-service.ts +2 -4
  61. package/src/orchestration/research-synthesis-service.ts +34 -92
  62. package/src/security/cisa-kev.ts +10 -3
  63. package/src/security/github-advisories.ts +18 -7
  64. package/src/security/nvd-client.ts +17 -6
  65. package/src/security/osv-client.ts +14 -4
  66. package/src/skill-install/skill-installer.ts +135 -9
  67. package/src/stackexchange/rest-client.ts +6 -2
  68. package/src/tools/research-knowledge-search.ts +23 -2
  69. package/src/tools/search.ts +6 -1
  70. package/src/utils/atomic-replace.ts +101 -0
  71. package/src/utils/circuit-breaker.ts +20 -0
  72. package/src/utils/citation-utils.ts +106 -8
  73. package/src/utils/http-body.ts +176 -0
  74. package/src/utils/llm-usage.ts +37 -0
  75. package/src/utils/metrics-summary.ts +25 -2
  76. package/src/utils/research-export.ts +9 -2
  77. package/src/web-research/browser-search.ts +50 -4
  78. package/src/web-research/retry-utils.ts +13 -1
  79. package/src/web-research/search.ts +20 -5
  80. package/src/web-research/types.ts +17 -4
  81. package/src/web-research/web-scraper.ts +7 -50
  82. package/src/youtube/transcript-client.ts +5 -1
package/dist/cli.mjs CHANGED
@@ -1017,11 +1017,12 @@ function disposeAllServices(container = globalServiceContainer) {
1017
1017
  function resetServiceContainer(container = globalServiceContainer) {
1018
1018
  return container.reset();
1019
1019
  }
1020
- var initializationContext, ServiceContainer, globalServiceContainer;
1020
+ var DISPOSE_INFLIGHT_INIT_TIMEOUT_MS, initializationContext, ServiceContainer, globalServiceContainer;
1021
1021
  var init_service_registry = __esm({
1022
1022
  "src/core/service-registry.ts"() {
1023
1023
  "use strict";
1024
1024
  init_logger();
1025
+ DISPOSE_INFLIGHT_INIT_TIMEOUT_MS = 5e3;
1025
1026
  initializationContext = new AsyncLocalStorage4();
1026
1027
  ServiceContainer = class {
1027
1028
  services = /* @__PURE__ */ new Map();
@@ -1113,11 +1114,26 @@ var init_service_registry = __esm({
1113
1114
  throw new Error(`Service '${name}' is not registered`);
1114
1115
  }
1115
1116
  if (registration.instance) {
1117
+ if (registration.initializationPromise && caller === void 0) {
1118
+ await registration.initializationPromise.catch(() => {
1119
+ });
1120
+ if (!registration.instance) {
1121
+ return this.get(name, ctx);
1122
+ }
1123
+ }
1116
1124
  if (ctx && registration.instance.initialize) {
1117
- return initializationContext.run(name, async () => {
1125
+ const reinit = initializationContext.run(name, async () => {
1118
1126
  await registration.instance.initialize(ctx);
1119
1127
  return registration.instance;
1120
1128
  });
1129
+ registration.initializationPromise = reinit;
1130
+ const clearIfCurrent = () => {
1131
+ if (registration.initializationPromise === reinit) {
1132
+ registration.initializationPromise = null;
1133
+ }
1134
+ };
1135
+ reinit.then(clearIfCurrent, clearIfCurrent);
1136
+ return reinit;
1121
1137
  }
1122
1138
  return registration.instance;
1123
1139
  }
@@ -1137,7 +1153,21 @@ var init_service_registry = __esm({
1137
1153
  }
1138
1154
  }
1139
1155
  /**
1140
- * Get a service instance synchronously (returns null if not initialized)
1156
+ * Get a service instance synchronously (returns null if not registered or not
1157
+ * yet constructed).
1158
+ *
1159
+ * Deliberately returns instances in NON-settled lifecycles too. This is a
1160
+ * lifecycle *inspector* as much as an accessor: the health check resolves the
1161
+ * knowledge store through it precisely so it can report `initializing` /
1162
+ * `disabled` / `disposed (not running)` WITHOUT forcing initialization (forcing
1163
+ * it was a past cause of 45 s CI stalls). Filtering those states out here would
1164
+ * make every one of them indistinguishable from "absent".
1165
+ *
1166
+ * The trade-off: because `_initializeService` publishes the instance before
1167
+ * awaiting its `initialize()` (to break dependency cycles), a caller that
1168
+ * immediately *uses* the result can catch a half-built service. Callers that
1169
+ * need a ready service must use the async `get()`, which waits — see the note
1170
+ * there. Callers that only inspect `lifecycle` are safe by construction.
1141
1171
  */
1142
1172
  tryGet(name) {
1143
1173
  if (this.isDisposing) {
@@ -1171,6 +1201,10 @@ var init_service_registry = __esm({
1171
1201
  if (!registration) {
1172
1202
  throw new Error(`Service '${name}' is not registered`);
1173
1203
  }
1204
+ if (registration.initializationPromise) {
1205
+ await registration.initializationPromise.catch(() => {
1206
+ });
1207
+ }
1174
1208
  if (registration.instance && registration.instance.dispose) {
1175
1209
  await registration.instance.dispose().catch((err) => {
1176
1210
  logger.warn(`[ServiceContainer] Error disposing service '${name}':`, err);
@@ -1190,6 +1224,10 @@ var init_service_registry = __esm({
1190
1224
  if (!registration) {
1191
1225
  throw new Error(`Service '${name}' is not registered`);
1192
1226
  }
1227
+ if (registration.initializationPromise) {
1228
+ await registration.initializationPromise.catch(() => {
1229
+ });
1230
+ }
1193
1231
  if (registration.instance && registration.instance.dispose) {
1194
1232
  await registration.instance.dispose().catch((err) => {
1195
1233
  logger.warn(`[ServiceContainer] Error disposing old service '${name}':`, err);
@@ -1219,6 +1257,24 @@ var init_service_registry = __esm({
1219
1257
  logger.log("[ServiceContainer] Disposing all services (DAG-ordered teardown)...");
1220
1258
  }
1221
1259
  try {
1260
+ const inFlight2 = Array.from(this.services.values()).map((r) => r.initializationPromise).filter((p) => p != null);
1261
+ if (inFlight2.length > 0) {
1262
+ if (this.defaultOptions.enableLogging) {
1263
+ logger.debug(`[ServiceContainer] Waiting for ${inFlight2.length} in-flight initialization(s) before teardown`);
1264
+ }
1265
+ let settleTimer;
1266
+ await Promise.race([
1267
+ Promise.allSettled(inFlight2),
1268
+ new Promise((resolve8) => {
1269
+ settleTimer = setTimeout(() => {
1270
+ logger.warn("[ServiceContainer] Timed out waiting for in-flight service initialization; proceeding with teardown");
1271
+ resolve8();
1272
+ }, DISPOSE_INFLIGHT_INIT_TIMEOUT_MS);
1273
+ settleTimer.unref?.();
1274
+ })
1275
+ ]);
1276
+ if (settleTimer) clearTimeout(settleTimer);
1277
+ }
1222
1278
  const activeServices = Array.from(this.services.keys()).filter(
1223
1279
  (name) => this.services.get(name)?.instance !== null
1224
1280
  );
@@ -1294,12 +1350,12 @@ var init_service_registry = __esm({
1294
1350
  if (!registration) {
1295
1351
  return null;
1296
1352
  }
1297
- if (registration.instance) {
1298
- return "initialized" /* INITIALIZED */;
1299
- }
1300
1353
  if (registration.initializationPromise) {
1301
1354
  return "initializing" /* INITIALIZING */;
1302
1355
  }
1356
+ if (registration.instance) {
1357
+ return "initialized" /* INITIALIZED */;
1358
+ }
1303
1359
  return "uninitialized" /* UNINITIALIZED */;
1304
1360
  }
1305
1361
  /**
@@ -1709,9 +1765,23 @@ var init_metrics = __esm({
1709
1765
  });
1710
1766
 
1711
1767
  // src/utils/llm-usage.ts
1768
+ function warnIfUnpriced(model, tokens, cost) {
1769
+ if (tokens <= 0 || cost > 0) return;
1770
+ const m = model;
1771
+ const key = `${m.provider ?? "unknown"}/${m.id ?? "unknown"}`;
1772
+ if (unpricedModelsWarned.has(key)) return;
1773
+ const table = m.cost;
1774
+ const allZero = !table || Object.values(table).every((v) => typeof v !== "number" || v === 0);
1775
+ if (!allZero) return;
1776
+ unpricedModelsWarned.add(key);
1777
+ logger.warn(
1778
+ `[LlmUsage] Model "${key}" consumed ${tokens} tokens but reports $0.00 \u2014 its price table is all zeros. Expected for flat-rate plans and local servers. Otherwise the price data is missing: a hand-written "models" entry in ~/.pi/agent/models.json replaces pi's catalog entry (and its pricing) for the same model id \u2014 remove it and let pi's catalog supply the model, or use "modelOverrides", which merges.`
1779
+ );
1780
+ }
1712
1781
  function recordLlmUsage(model, rawUsage, opts) {
1713
1782
  if (!rawUsage) return { tokens: 0, cost: 0 };
1714
1783
  const { tokens, cost } = extractUsage(model, rawUsage);
1784
+ warnIfUnpriced(model, tokens, cost);
1715
1785
  if (tokens > 0 || cost > 0) {
1716
1786
  const labels = { component: opts.component };
1717
1787
  if (opts.complexity !== void 0) labels["complexity"] = String(opts.complexity);
@@ -1721,11 +1791,14 @@ function recordLlmUsage(model, rawUsage, opts) {
1721
1791
  }
1722
1792
  return { tokens, cost };
1723
1793
  }
1794
+ var unpricedModelsWarned;
1724
1795
  var init_llm_usage = __esm({
1725
1796
  "src/utils/llm-usage.ts"() {
1726
1797
  "use strict";
1727
1798
  init_llm();
1799
+ init_logger();
1728
1800
  init_metrics();
1801
+ unpricedModelsWarned = /* @__PURE__ */ new Set();
1729
1802
  }
1730
1803
  });
1731
1804
 
@@ -1739,11 +1812,11 @@ function stripTrailingLlmPunctuation(input) {
1739
1812
  continue;
1740
1813
  }
1741
1814
  if (last === ")" || last === "]" || last === "}") {
1742
- const open4 = last === ")" ? "(" : last === "]" ? "[" : "{";
1815
+ const open5 = last === ")" ? "(" : last === "]" ? "[" : "{";
1743
1816
  let opens = 0;
1744
1817
  let closes = 0;
1745
1818
  for (const ch of s) {
1746
- if (ch === open4) opens++;
1819
+ if (ch === open5) opens++;
1747
1820
  else if (ch === last) closes++;
1748
1821
  }
1749
1822
  if (closes > opens) {
@@ -2026,8 +2099,69 @@ var init_host_config = __esm({
2026
2099
  }
2027
2100
  });
2028
2101
 
2102
+ // src/utils/atomic-replace.ts
2103
+ import * as fs4 from "node:fs/promises";
2104
+ import * as fsSync from "node:fs";
2105
+ function isRetryable(err) {
2106
+ const code = err?.code;
2107
+ return code !== void 0 && RETRYABLE_CODES.has(code);
2108
+ }
2109
+ async function replaceFile(tmpPath, destPath) {
2110
+ let lastError;
2111
+ for (let attempt = 0; attempt < MAX_RENAME_ATTEMPTS; attempt++) {
2112
+ try {
2113
+ await fs4.rename(tmpPath, destPath);
2114
+ return "renamed";
2115
+ } catch (err) {
2116
+ lastError = err;
2117
+ if (process.platform !== "win32" || !isRetryable(err)) throw err;
2118
+ if (attempt < MAX_RENAME_ATTEMPTS - 1) {
2119
+ await new Promise((r) => setTimeout(r, RENAME_RETRY_DELAY_MS * (attempt + 1)));
2120
+ }
2121
+ }
2122
+ }
2123
+ try {
2124
+ await fs4.copyFile(tmpPath, destPath);
2125
+ return "copied";
2126
+ } catch {
2127
+ throw lastError;
2128
+ }
2129
+ }
2130
+ function replaceFileSync(tmpPath, destPath) {
2131
+ let lastError;
2132
+ for (let attempt = 0; attempt < MAX_RENAME_ATTEMPTS; attempt++) {
2133
+ try {
2134
+ fsSync.renameSync(tmpPath, destPath);
2135
+ return "renamed";
2136
+ } catch (err) {
2137
+ lastError = err;
2138
+ if (process.platform !== "win32" || !isRetryable(err)) throw err;
2139
+ if (attempt < MAX_RENAME_ATTEMPTS - 1) {
2140
+ const until = Date.now() + RENAME_RETRY_DELAY_MS * (attempt + 1);
2141
+ while (Date.now() < until) {
2142
+ }
2143
+ }
2144
+ }
2145
+ }
2146
+ try {
2147
+ fsSync.copyFileSync(tmpPath, destPath);
2148
+ return "copied";
2149
+ } catch {
2150
+ throw lastError;
2151
+ }
2152
+ }
2153
+ var RETRYABLE_CODES, MAX_RENAME_ATTEMPTS, RENAME_RETRY_DELAY_MS;
2154
+ var init_atomic_replace = __esm({
2155
+ "src/utils/atomic-replace.ts"() {
2156
+ "use strict";
2157
+ RETRYABLE_CODES = /* @__PURE__ */ new Set(["EPERM", "EBUSY", "EACCES"]);
2158
+ MAX_RENAME_ATTEMPTS = 5;
2159
+ RENAME_RETRY_DELAY_MS = 25;
2160
+ }
2161
+ });
2162
+
2029
2163
  // src/config.ts
2030
- import * as fs4 from "node:fs";
2164
+ import * as fs5 from "node:fs";
2031
2165
  import * as path5 from "node:path";
2032
2166
  import * as os2 from "node:os";
2033
2167
  import { Type } from "typebox";
@@ -2042,8 +2176,8 @@ function getProjectSettingsRegistryPath() {
2042
2176
  function loadProjectSettingsRegistry() {
2043
2177
  const registryPath = getProjectSettingsRegistryPath();
2044
2178
  try {
2045
- if (fs4.existsSync(registryPath)) {
2046
- const content = fs4.readFileSync(registryPath, "utf-8");
2179
+ if (fs5.existsSync(registryPath)) {
2180
+ const content = fs5.readFileSync(registryPath, "utf-8");
2047
2181
  return JSON.parse(content);
2048
2182
  }
2049
2183
  } catch (err) {
@@ -2064,8 +2198,8 @@ function sleepSync(ms) {
2064
2198
  function updateProjectSettingsRegistry(mutate) {
2065
2199
  const registryPath = getProjectSettingsRegistryPath();
2066
2200
  const dir = path5.dirname(registryPath);
2067
- if (!fs4.existsSync(dir)) {
2068
- fs4.mkdirSync(dir, { recursive: true, mode: 448 });
2201
+ if (!fs5.existsSync(dir)) {
2202
+ fs5.mkdirSync(dir, { recursive: true, mode: 448 });
2069
2203
  }
2070
2204
  const lockPath = `${registryPath}.lock`;
2071
2205
  let lockFd = null;
@@ -2073,14 +2207,14 @@ function updateProjectSettingsRegistry(mutate) {
2073
2207
  try {
2074
2208
  for (let i = 0; i < maxRetries; i++) {
2075
2209
  try {
2076
- lockFd = fs4.openSync(lockPath, "wx", 384);
2210
+ lockFd = fs5.openSync(lockPath, "wx", 384);
2077
2211
  break;
2078
2212
  } catch (err) {
2079
2213
  if (err.code === "EEXIST") {
2080
2214
  try {
2081
- const stats = fs4.statSync(lockPath);
2215
+ const stats = fs5.statSync(lockPath);
2082
2216
  if (Date.now() - stats.mtimeMs > 3e4) {
2083
- fs4.unlinkSync(lockPath);
2217
+ fs5.unlinkSync(lockPath);
2084
2218
  continue;
2085
2219
  }
2086
2220
  } catch {
@@ -2095,9 +2229,9 @@ function updateProjectSettingsRegistry(mutate) {
2095
2229
  throw new Error(`Failed to acquire lock for project settings registry after ${maxRetries} retries. Aborting to prevent data corruption.`);
2096
2230
  }
2097
2231
  let registry = {};
2098
- if (fs4.existsSync(registryPath)) {
2232
+ if (fs5.existsSync(registryPath)) {
2099
2233
  try {
2100
- registry = JSON.parse(fs4.readFileSync(registryPath, "utf-8"));
2234
+ registry = JSON.parse(fs5.readFileSync(registryPath, "utf-8"));
2101
2235
  } catch (readErr) {
2102
2236
  if (readErr?.code === "ENOENT") {
2103
2237
  registry = {};
@@ -2112,15 +2246,15 @@ function updateProjectSettingsRegistry(mutate) {
2112
2246
  mutate(registry);
2113
2247
  const tmpPath = `${registryPath}.tmp.${process.pid}.${Date.now()}`;
2114
2248
  try {
2115
- fs4.writeFileSync(tmpPath, JSON.stringify(registry, null, 2), { encoding: "utf-8", mode: 384 });
2116
- fs4.renameSync(tmpPath, registryPath);
2249
+ fs5.writeFileSync(tmpPath, JSON.stringify(registry, null, 2), { encoding: "utf-8", mode: 384 });
2250
+ fs5.renameSync(tmpPath, registryPath);
2117
2251
  try {
2118
- fs4.chmodSync(registryPath, 384);
2252
+ fs5.chmodSync(registryPath, 384);
2119
2253
  } catch {
2120
2254
  }
2121
2255
  } catch (writeErr) {
2122
2256
  try {
2123
- fs4.unlinkSync(tmpPath);
2257
+ fs5.unlinkSync(tmpPath);
2124
2258
  } catch {
2125
2259
  }
2126
2260
  throw writeErr;
@@ -2131,11 +2265,11 @@ function updateProjectSettingsRegistry(mutate) {
2131
2265
  } finally {
2132
2266
  if (lockFd !== null) {
2133
2267
  try {
2134
- fs4.closeSync(lockFd);
2268
+ fs5.closeSync(lockFd);
2135
2269
  } catch {
2136
2270
  }
2137
2271
  try {
2138
- fs4.unlinkSync(lockPath);
2272
+ fs5.unlinkSync(lockPath);
2139
2273
  } catch {
2140
2274
  }
2141
2275
  }
@@ -2165,7 +2299,7 @@ function describeKnowledgeStoreMode(cwd = process.cwd(), iface) {
2165
2299
  const KEY = "PI_RESEARCH_KNOWLEDGE_STORE_MODE";
2166
2300
  const fileHasKey = (p) => {
2167
2301
  try {
2168
- return fs4.existsSync(p) && parseDotEnv(fs4.readFileSync(p, "utf-8"))[KEY] !== void 0;
2302
+ return fs5.existsSync(p) && parseDotEnv(fs5.readFileSync(p, "utf-8"))[KEY] !== void 0;
2169
2303
  } catch {
2170
2304
  return false;
2171
2305
  }
@@ -2177,7 +2311,7 @@ function describeKnowledgeStoreMode(cwd = process.cwd(), iface) {
2177
2311
  let registryHasKey = false;
2178
2312
  try {
2179
2313
  const registry = loadProjectSettingsRegistry();
2180
- registryHasKey = registry[normalizeWorkspacePath(cwd)]?.[KEY] !== void 0;
2314
+ registryHasKey = findRegistryEntry(registry, normalizeWorkspacePath(cwd))?.[KEY] !== void 0;
2181
2315
  } catch {
2182
2316
  }
2183
2317
  if (registryHasKey) origin = "this directory (project settings)";
@@ -2203,14 +2337,27 @@ function parseDotEnv(content) {
2203
2337
  }
2204
2338
  return out;
2205
2339
  }
2340
+ function findRegistryKey(registry, normalizedCwd) {
2341
+ if (registry[normalizedCwd] !== void 0) return normalizedCwd;
2342
+ if (process.platform !== "win32" && process.platform !== "darwin") return void 0;
2343
+ const lowered = normalizedCwd.toLowerCase();
2344
+ for (const key of Object.keys(registry)) {
2345
+ if (key.toLowerCase() === lowered) return key;
2346
+ }
2347
+ return void 0;
2348
+ }
2349
+ function findRegistryEntry(registry, normalizedCwd) {
2350
+ const key = findRegistryKey(registry, normalizedCwd);
2351
+ return key === void 0 ? void 0 : registry[key];
2352
+ }
2206
2353
  function loadEnvFiles(cwd, iface) {
2207
2354
  const merged = {};
2208
2355
  const globalPath = getGlobalEnvFilePath();
2209
2356
  const registry = loadProjectSettingsRegistry();
2210
2357
  const normalizedCwd = normalizeWorkspacePath(cwd);
2211
- const homeRegistry = registry[normalizeWorkspacePath(os2.homedir())];
2358
+ const homeRegistry = findRegistryEntry(registry, normalizeWorkspacePath(os2.homedir()));
2212
2359
  try {
2213
- if (!fs4.existsSync(globalPath) && homeRegistry && Object.keys(homeRegistry).length > 0) {
2360
+ if (!fs5.existsSync(globalPath) && homeRegistry && Object.keys(homeRegistry).length > 0) {
2214
2361
  logger.info("[config] config.env missing. Initializing from user settings in central registry...");
2215
2362
  const migrated = {};
2216
2363
  for (const key of USER_MIGRATION_KEYS) {
@@ -2225,8 +2372,8 @@ function loadEnvFiles(cwd, iface) {
2225
2372
  logger.warn("[config] Failed one-time config.env migration:", err);
2226
2373
  }
2227
2374
  try {
2228
- if (fs4.existsSync(globalPath)) {
2229
- Object.assign(merged, parseDotEnv(fs4.readFileSync(globalPath, "utf-8")));
2375
+ if (fs5.existsSync(globalPath)) {
2376
+ Object.assign(merged, parseDotEnv(fs5.readFileSync(globalPath, "utf-8")));
2230
2377
  }
2231
2378
  } catch (err) {
2232
2379
  logger.warn("[config] Failed to read global env file:", err);
@@ -2234,8 +2381,8 @@ function loadEnvFiles(cwd, iface) {
2234
2381
  if (iface) {
2235
2382
  const ifacePath = getInterfaceEnvFilePath(iface);
2236
2383
  try {
2237
- if (fs4.existsSync(ifacePath)) {
2238
- Object.assign(merged, parseDotEnv(fs4.readFileSync(ifacePath, "utf-8")));
2384
+ if (fs5.existsSync(ifacePath)) {
2385
+ Object.assign(merged, parseDotEnv(fs5.readFileSync(ifacePath, "utf-8")));
2239
2386
  }
2240
2387
  } catch (err) {
2241
2388
  logger.warn(`[config] Failed to read interface config ${ifacePath}:`, err);
@@ -2243,30 +2390,33 @@ function loadEnvFiles(cwd, iface) {
2243
2390
  }
2244
2391
  const legacyPath = getLocalEnvFilePath(cwd);
2245
2392
  let legacyEnv = {};
2246
- if (fs4.existsSync(legacyPath)) {
2393
+ if (fs5.existsSync(legacyPath)) {
2247
2394
  try {
2248
2395
  logger.warn(`[config] WARNING: .pi-research.env files are deprecated. Settings will be auto-migrated to the centralized registry. Remove ${legacyPath} after migration.`);
2249
- legacyEnv = parseDotEnv(fs4.readFileSync(legacyPath, "utf-8"));
2396
+ legacyEnv = parseDotEnv(fs5.readFileSync(legacyPath, "utf-8"));
2250
2397
  Object.assign(merged, legacyEnv);
2251
2398
  const legacyLocal = Object.fromEntries(
2252
2399
  Object.entries(legacyEnv).filter(([k]) => LOCAL_SCOPE_KEYS.has(k))
2253
2400
  );
2254
2401
  const existingLocal = Object.fromEntries(
2255
- Object.entries(registry[normalizedCwd] ?? {}).filter(([k]) => LOCAL_SCOPE_KEYS.has(k))
2402
+ Object.entries(findRegistryEntry(registry, normalizedCwd) ?? {}).filter(([k]) => LOCAL_SCOPE_KEYS.has(k))
2256
2403
  );
2257
2404
  if (Object.keys(legacyLocal).length > 0 && JSON.stringify(existingLocal) !== JSON.stringify(legacyLocal)) {
2258
2405
  logger.info(`[config] Migrating legacy .pi-research.env settings from ${cwd} to central registry...`);
2259
- registry[normalizedCwd] = { ...registry[normalizedCwd], ...legacyLocal };
2406
+ const mergeKey = findRegistryKey(registry, normalizedCwd) ?? normalizedCwd;
2407
+ registry[mergeKey] = { ...registry[mergeKey], ...legacyLocal };
2260
2408
  updateProjectSettingsRegistry((disk) => {
2261
- disk[normalizedCwd] = { ...disk[normalizedCwd], ...legacyLocal };
2409
+ const diskKey = findRegistryKey(disk, normalizedCwd) ?? normalizedCwd;
2410
+ disk[diskKey] = { ...disk[diskKey], ...legacyLocal };
2262
2411
  });
2263
2412
  }
2264
2413
  } catch (err) {
2265
2414
  logger.warn(`[config] Failed to load legacy settings for ${cwd}:`, err);
2266
2415
  }
2267
2416
  }
2268
- if (registry[normalizedCwd]) {
2269
- for (const [key, val] of Object.entries(registry[normalizedCwd])) {
2417
+ const cwdEntry = findRegistryEntry(registry, normalizedCwd);
2418
+ if (cwdEntry) {
2419
+ for (const [key, val] of Object.entries(cwdEntry)) {
2270
2420
  if (LOCAL_SCOPE_KEYS.has(key)) {
2271
2421
  if (legacyEnv[key] !== void 0 && legacyEnv[key] !== val) {
2272
2422
  logger.warn(`[config] Config divergence for ${key} in ${cwd}: registry value differs from legacy .pi-research.env. Registry wins.`);
@@ -2276,7 +2426,7 @@ function loadEnvFiles(cwd, iface) {
2276
2426
  logger.warn(`[config] Ignoring stale user-scoped ${key} in the project registry (config.env wins). Re-save your user settings to drop it.`);
2277
2427
  }
2278
2428
  }
2279
- } else if (Object.keys(merged).length === 0 && !fs4.existsSync(legacyPath) && !fs4.existsSync(globalPath)) {
2429
+ } else if (Object.keys(merged).length === 0 && !fs5.existsSync(legacyPath) && !fs5.existsSync(globalPath)) {
2280
2430
  logger.warn(`[config] No configuration found for workspace: ${cwd}. Using code defaults. Run /research-config to configure.`);
2281
2431
  }
2282
2432
  warnLegacyEnvVarsOnce(merged);
@@ -2340,7 +2490,8 @@ function saveConfig(config, scope = "local", cwd = process.cwd(), changedKeys) {
2340
2490
  if (scope === "local") {
2341
2491
  const normalizedCwd = normalizeWorkspacePath(cwd);
2342
2492
  updateProjectSettingsRegistry((registry) => {
2343
- registry[normalizedCwd] = { ...registry[normalizedCwd] ?? {}, ...newValues };
2493
+ const key = findRegistryKey(registry, normalizedCwd) ?? normalizedCwd;
2494
+ registry[key] = { ...registry[key] ?? {}, ...newValues };
2344
2495
  });
2345
2496
  logger.debug(`[config] Saved project settings for ${normalizedCwd} to central registry.`);
2346
2497
  return;
@@ -2348,22 +2499,22 @@ function saveConfig(config, scope = "local", cwd = process.cwd(), changedKeys) {
2348
2499
  const p = getGlobalEnvFilePath();
2349
2500
  const lockPath = `${p}.lock`;
2350
2501
  const dir = path5.dirname(p);
2351
- if (!fs4.existsSync(dir)) {
2352
- fs4.mkdirSync(dir, { recursive: true, mode: 448 });
2502
+ if (!fs5.existsSync(dir)) {
2503
+ fs5.mkdirSync(dir, { recursive: true, mode: 448 });
2353
2504
  }
2354
2505
  let lockFd = null;
2355
2506
  const lockRetryDelay = 50;
2356
2507
  const lockMaxRetries = 100;
2357
2508
  for (let i = 0; i < lockMaxRetries; i++) {
2358
2509
  try {
2359
- lockFd = fs4.openSync(lockPath, "wx", 384);
2510
+ lockFd = fs5.openSync(lockPath, "wx", 384);
2360
2511
  break;
2361
2512
  } catch (err) {
2362
2513
  if (err.code === "EEXIST") {
2363
2514
  try {
2364
- const stats = fs4.statSync(lockPath);
2515
+ const stats = fs5.statSync(lockPath);
2365
2516
  if (Date.now() - stats.mtimeMs > 3e4) {
2366
- fs4.unlinkSync(lockPath);
2517
+ fs5.unlinkSync(lockPath);
2367
2518
  continue;
2368
2519
  }
2369
2520
  } catch {
@@ -2380,8 +2531,8 @@ function saveConfig(config, scope = "local", cwd = process.cwd(), changedKeys) {
2380
2531
  }
2381
2532
  try {
2382
2533
  let lines = [];
2383
- if (fs4.existsSync(p)) {
2384
- lines = fs4.readFileSync(p, "utf-8").split("\n");
2534
+ if (fs5.existsSync(p)) {
2535
+ lines = fs5.readFileSync(p, "utf-8").split("\n");
2385
2536
  } else {
2386
2537
  lines = [
2387
2538
  `# pi-research global configuration`,
@@ -2420,35 +2571,32 @@ function saveConfig(config, scope = "local", cwd = process.cwd(), changedKeys) {
2420
2571
  }
2421
2572
  }
2422
2573
  const tmpPath = `${p}.tmp.${process.pid}.${Date.now()}`;
2423
- fs4.writeFileSync(tmpPath, outLines.join("\n"), { encoding: "utf-8", mode: 384 });
2574
+ fs5.writeFileSync(tmpPath, outLines.join("\n"), { encoding: "utf-8", mode: 384 });
2424
2575
  try {
2425
- fs4.renameSync(tmpPath, p);
2426
- } catch (renameErr) {
2427
- if (process.platform === "win32") {
2428
- fs4.copyFileSync(tmpPath, p);
2576
+ if (replaceFileSync(tmpPath, p) === "copied") {
2429
2577
  try {
2430
- fs4.unlinkSync(tmpPath);
2578
+ fs5.unlinkSync(tmpPath);
2431
2579
  } catch {
2432
2580
  }
2433
- } else {
2434
- try {
2435
- fs4.unlinkSync(tmpPath);
2436
- } catch {
2437
- }
2438
- throw renameErr;
2439
2581
  }
2582
+ } catch (renameErr) {
2583
+ try {
2584
+ fs5.unlinkSync(tmpPath);
2585
+ } catch {
2586
+ }
2587
+ throw renameErr;
2440
2588
  }
2441
2589
  try {
2442
- fs4.chmodSync(p, 384);
2590
+ fs5.chmodSync(p, 384);
2443
2591
  } catch {
2444
2592
  }
2445
2593
  } catch (err) {
2446
2594
  logger.error(`[config] Failed to write config to ${p}:`, err);
2447
2595
  throw err;
2448
2596
  } finally {
2449
- fs4.closeSync(lockFd);
2597
+ fs5.closeSync(lockFd);
2450
2598
  try {
2451
- fs4.unlinkSync(lockPath);
2599
+ fs5.unlinkSync(lockPath);
2452
2600
  } catch {
2453
2601
  }
2454
2602
  }
@@ -2575,6 +2723,7 @@ var init_config = __esm({
2575
2723
  init_logger();
2576
2724
  init_host_config();
2577
2725
  init_text_utils();
2726
+ init_atomic_replace();
2578
2727
  ConfigSchema = Type.Object({
2579
2728
  /** Per-researcher timeout in milliseconds (default: 300000, range: 3-30 min) */
2580
2729
  RESEARCHER_TIMEOUT_MS: Type.Number({ minimum: 18e4, maximum: 18e5, default: 3e5 }),
@@ -2794,7 +2943,11 @@ function getMaxScrapeBatches(config) {
2794
2943
  return 2;
2795
2944
  }
2796
2945
  }
2797
- var REQUEST_DELAY_MS_NVD, REQUEST_DELAY_MS_OTHER, MAX_GATHERING_CALLS, MAX_GREP_CALLS, MAX_SCRAPE_URLS, MAX_TEAM_SIZE_LEVEL_1, MAX_TEAM_SIZE_LEVEL_2, MAX_TEAM_SIZE_LEVEL_3, MAX_ROUNDS_LEVEL_1, MAX_ROUNDS_LEVEL_2, MAX_ROUNDS_LEVEL_3, MAX_EXTRA_ROUNDS_WITH_STEERING, OSV_TIMEOUT_MS, WEBGPU_PROBE_TIMEOUT_MS, MAX_QUERY_LENGTH, MAX_FILENAME_QUERY_LENGTH, MIN_QUERY_LENGTH, MAX_EXPORT_RETRIES, DEFAULT_MAX_RETRIES, DEFAULT_INITIAL_DELAY_MS, DEFAULT_MAX_DELAY_MS, DEFAULT_MODEL_CONTEXT_WINDOW, BATCH_2_DEFAULT_CONCURRENCY, MAX_SCRAPE_CONTENT_CHARS_PER_DOC, RESEARCHER_LAUNCH_DELAY_MS, MAX_QUERIES_PER_RESEARCHER_LEVEL_1, MAX_QUERIES_PER_RESEARCHER_LEVEL_2, MAX_QUERIES_PER_RESEARCHER_LEVEL_3;
2946
+ function resolveExcludedTools(callerExclusions, configDisabled = []) {
2947
+ const base = callerExclusions && callerExclusions.length > 0 ? callerExclusions : DEFAULT_EXCLUDED_TOOLS;
2948
+ return [.../* @__PURE__ */ new Set([...base, ...configDisabled])];
2949
+ }
2950
+ var REQUEST_DELAY_MS_NVD, REQUEST_DELAY_MS_OTHER, MAX_GATHERING_CALLS, MAX_GREP_CALLS, MAX_SCRAPE_URLS, MAX_TEAM_SIZE_LEVEL_1, MAX_TEAM_SIZE_LEVEL_2, MAX_TEAM_SIZE_LEVEL_3, MAX_ROUNDS_LEVEL_1, MAX_ROUNDS_LEVEL_2, MAX_ROUNDS_LEVEL_3, MAX_EXTRA_ROUNDS_WITH_STEERING, OSV_TIMEOUT_MS, WEBGPU_PROBE_TIMEOUT_MS, MAX_QUERY_LENGTH, MAX_FILENAME_QUERY_LENGTH, MIN_QUERY_LENGTH, MAX_EXPORT_RETRIES, DEFAULT_MAX_RETRIES, DEFAULT_INITIAL_DELAY_MS, DEFAULT_MAX_DELAY_MS, DEFAULT_MODEL_CONTEXT_WINDOW, BATCH_2_DEFAULT_CONCURRENCY, MAX_SCRAPE_CONTENT_CHARS_PER_DOC, RESEARCHER_LAUNCH_DELAY_MS, MAX_QUERIES_PER_RESEARCHER_LEVEL_1, MAX_QUERIES_PER_RESEARCHER_LEVEL_2, MAX_QUERIES_PER_RESEARCHER_LEVEL_3, DEFAULT_EXCLUDED_TOOLS;
2798
2951
  var init_constants = __esm({
2799
2952
  "src/constants.ts"() {
2800
2953
  "use strict";
@@ -2827,6 +2980,7 @@ var init_constants = __esm({
2827
2980
  MAX_QUERIES_PER_RESEARCHER_LEVEL_1 = 10;
2828
2981
  MAX_QUERIES_PER_RESEARCHER_LEVEL_2 = 15;
2829
2982
  MAX_QUERIES_PER_RESEARCHER_LEVEL_3 = 20;
2983
+ DEFAULT_EXCLUDED_TOOLS = ["grep"];
2830
2984
  }
2831
2985
  });
2832
2986
 
@@ -2853,8 +3007,8 @@ function findMatchingBracket(text, start) {
2853
3007
  let depth = 0;
2854
3008
  let inString = false;
2855
3009
  let escaped = false;
2856
- const open4 = text[start];
2857
- const close = open4 === "{" ? "}" : "]";
3010
+ const open5 = text[start];
3011
+ const close = open5 === "{" ? "}" : "]";
2858
3012
  for (let i = start; i < text.length; i++) {
2859
3013
  const ch = text[i];
2860
3014
  if (escaped) {
@@ -2870,7 +3024,7 @@ function findMatchingBracket(text, start) {
2870
3024
  continue;
2871
3025
  }
2872
3026
  if (inString) continue;
2873
- if (ch === open4) depth++;
3027
+ if (ch === open5) depth++;
2874
3028
  else if (ch === close) {
2875
3029
  depth--;
2876
3030
  if (depth === 0) return { index: i, inString: false };
@@ -3210,7 +3364,7 @@ var init_agentic_repair = __esm({
3210
3364
  import { ModelRegistry, ModelRuntime, getAgentDir } from "@earendil-works/pi-coding-agent";
3211
3365
  import { InMemoryCredentialStore } from "@earendil-works/pi-ai";
3212
3366
  import * as path6 from "node:path";
3213
- import * as fs5 from "node:fs";
3367
+ import * as fs6 from "node:fs";
3214
3368
  function safeGetAll(registry) {
3215
3369
  if (registry && typeof registry.getAll === "function") {
3216
3370
  try {
@@ -3254,8 +3408,8 @@ async function buildModelRegistry(apiKey, provider) {
3254
3408
  const agentDir = getAgentDir();
3255
3409
  const modelsJsonPath = path6.join(agentDir, "models.json");
3256
3410
  const authPath = path6.join(agentDir, "auth.json");
3257
- const hasModels = fs5.existsSync(modelsJsonPath);
3258
- const hasAuth = fs5.existsSync(authPath);
3411
+ const hasModels = fs6.existsSync(modelsJsonPath);
3412
+ const hasAuth = fs6.existsSync(authPath);
3259
3413
  const runtime = await ModelRuntime.create({
3260
3414
  authPath: hasAuth ? authPath : void 0,
3261
3415
  credentials: hasAuth ? void 0 : new InMemoryCredentialStore(),
@@ -3292,6 +3446,13 @@ function constructMinimalModel(provider, modelId, _apiKey) {
3292
3446
  // Provider-specific base URLs are handled by pi-ai internal registry
3293
3447
  reasoning: false,
3294
3448
  input: ["text"],
3449
+ // No catalog is available on this path, so there is no price data and no real
3450
+ // context/output ceiling to read. The zeros below are "unknown", NOT "free":
3451
+ // cost reporting for this model will read $0.00 (see warnIfUnpriced in
3452
+ // utils/llm-usage.ts), and the window figures are conservative guesses, not the
3453
+ // model's true limits — so docs/CONFIGURATION.md's "clamped to the model's real
3454
+ // ceiling" guarantee does not hold here. Anyone relying on accurate cost or the
3455
+ // full context window should give pi a credential so it can fetch the real catalog.
3295
3456
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
3296
3457
  contextWindow: 128e3,
3297
3458
  maxTokens: 32768,
@@ -3309,7 +3470,7 @@ function resolveModel(registry, modelSpec, provider, apiKey) {
3309
3470
  if (apiKey && provider) {
3310
3471
  return constructMinimalModel(prov, modelId, apiKey);
3311
3472
  }
3312
- throw new Error(`Model "${modelSpec}" not found in pi's configured model registry. Check ${path6.join(getAgentDir(), "models.json")}.`);
3473
+ throw new Error(buildModelNotFoundMessage(modelSpec));
3313
3474
  }
3314
3475
  const allModels = safeGetAll(registry);
3315
3476
  const sameId = allModels.filter((m) => m.id === modelSpec);
@@ -3340,15 +3501,29 @@ function resolveModel(registry, modelSpec, provider, apiKey) {
3340
3501
  }
3341
3502
  return constructMinimalModel(provider, modelSpec, apiKey);
3342
3503
  }
3343
- throw new Error(
3344
- `No LLM model available. Please configure your model registry (${path6.join(getAgentDir(), "models.json")}) or provide an explicit apiKey.`
3345
- );
3504
+ throw new Error(buildNoModelAvailableMessage());
3505
+ }
3506
+ function credentialGuidance() {
3507
+ const agentDir = getAgentDir();
3508
+ return ` \u2022 Provider pi supports built-in (openrouter, anthropic, openai, \u2026): add a credential \u2014 run \`pi\` and use /login, set the provider's API-key environment variable, or add it to ${path6.join(agentDir, "auth.json")}. pi then fetches that provider's model catalog automatically.
3509
+ \u2022 Custom or self-hosted provider (Ollama, vLLM, a proxy): declare it in ${path6.join(agentDir, "models.json")}.
3510
+ Note: a models.json entry REPLACES pi's catalog entry for the same model id, discarding its pricing and
3511
+ context/output limits. To adjust a single field of a built-in model, use "modelOverrides" instead \u2014 it merges.`;
3512
+ }
3513
+ function buildModelNotFoundMessage(modelSpec) {
3514
+ return `Model "${modelSpec}" not found in pi's model registry.
3515
+ ${credentialGuidance()}`;
3516
+ }
3517
+ function buildNoModelAvailableMessage() {
3518
+ return `No LLM model available.
3519
+ ${credentialGuidance()}
3520
+ \u2022 Or pass an explicit apiKey.`;
3346
3521
  }
3347
3522
  function readModelsJsonProviderOrder() {
3348
3523
  const modelsJsonPath = path6.join(getAgentDir(), "models.json");
3349
- if (!fs5.existsSync(modelsJsonPath)) return [];
3524
+ if (!fs6.existsSync(modelsJsonPath)) return [];
3350
3525
  try {
3351
- const raw = JSON.parse(fs5.readFileSync(modelsJsonPath, "utf-8"));
3526
+ const raw = JSON.parse(fs6.readFileSync(modelsJsonPath, "utf-8"));
3352
3527
  return Object.keys(raw?.providers ?? {});
3353
3528
  } catch (err) {
3354
3529
  logger.warn(`[ModelRegistry] Could not read provider order from models.json: ${err}`);
@@ -3428,6 +3603,7 @@ function withTimeout2(promise, timeoutMs, label, signal) {
3428
3603
  logger.debug(`[withTimeout] Starting ${timeoutMs}ms timeout guard (external signal: ${signal?.aborted ?? "no signal"})`);
3429
3604
  return new Promise((resolve8, reject) => {
3430
3605
  if (combinedSignal.aborted) {
3606
+ void promise.catch(() => void 0);
3431
3607
  logger.debug(`[withTimeout] ${label} already aborted at start (external cancellation)`);
3432
3608
  return reject(new Error(`${label} cancelled or timed out`));
3433
3609
  }
@@ -3533,7 +3709,7 @@ async function retryWithBackoff(fn, options = {}) {
3533
3709
  metrics.observe("retry_backoff_delay_ms", delay2, { attempt: String(attempt), label: opts.label });
3534
3710
  logger.warn(`[Retry] ${opts.label} failed (attempt ${attempt + 1}/${opts.maxRetries + 1}), retrying in ${Math.round(delay2)}ms: ${lastError.message}`);
3535
3711
  try {
3536
- await abortableDelay(delay2, opts.signal);
3712
+ await abortableDelay(delay2, opts.signal, true);
3537
3713
  } catch {
3538
3714
  metrics.increment("retry_aborted_total", 1, { label: opts.label });
3539
3715
  throw lastError;
@@ -3553,11 +3729,11 @@ var init_retry_utils = __esm({
3553
3729
  });
3554
3730
 
3555
3731
  // src/infrastructure/file-lock-service.ts
3556
- import * as fs6 from "node:fs/promises";
3732
+ import * as fs7 from "node:fs/promises";
3557
3733
  import * as crypto from "node:crypto";
3558
3734
  import * as pathmod from "node:path";
3559
3735
  import { AsyncLocalStorage as AsyncLocalStorage6 } from "node:async_hooks";
3560
- var lockContext, FileLockService;
3736
+ var lockContext, CLEANUP_DRAIN_TIMEOUT_MS, TRASH_SWEEP_AGE_MS, FileLockService;
3561
3737
  var init_file_lock_service = __esm({
3562
3738
  "src/infrastructure/file-lock-service.ts"() {
3563
3739
  "use strict";
@@ -3566,6 +3742,8 @@ var init_file_lock_service = __esm({
3566
3742
  init_service_registry();
3567
3743
  init_service_names();
3568
3744
  lockContext = new AsyncLocalStorage6();
3745
+ CLEANUP_DRAIN_TIMEOUT_MS = 5e3;
3746
+ TRASH_SWEEP_AGE_MS = 10 * 6e4;
3569
3747
  FileLockService = class {
3570
3748
  name = ServiceNames.FILE_LOCK_SERVICE;
3571
3749
  lifecycle = "uninitialized" /* UNINITIALIZED */;
@@ -3588,6 +3766,9 @@ var init_file_lock_service = __esm({
3588
3766
  // PID reuse. Null when unavailable (no processLifecycle, or lookup failed) —
3589
3767
  // then lock content omits startTime and readers treat us as a legacy owner.
3590
3768
  ownStartTime = null;
3769
+ // Set by cleanup(). A retired instance no longer manages the lock file, so
3770
+ // acquiring through it would hand out exclusivity it cannot honour.
3771
+ _retired = false;
3591
3772
  constructor(options) {
3592
3773
  this.lockFilePath = options.lockFilePath;
3593
3774
  this.lockTimeout = options.lockTimeout ?? 2e4;
@@ -3635,7 +3816,7 @@ var init_file_lock_service = __esm({
3635
3816
  this.lifecycle = "initializing" /* INITIALIZING */;
3636
3817
  try {
3637
3818
  const lockDir = pathmod.dirname(this.lockFilePath);
3638
- await fs6.mkdir(lockDir, { recursive: true, mode: 448 });
3819
+ await fs7.mkdir(lockDir, { recursive: true, mode: 448 });
3639
3820
  } catch (err) {
3640
3821
  logger.warn(`[FileLockService] Failed to create lock directory: ${err instanceof Error ? err.message : String(err)}`);
3641
3822
  }
@@ -3655,15 +3836,104 @@ var init_file_lock_service = __esm({
3655
3836
  await this.cleanup();
3656
3837
  this.lifecycle = "disposed" /* DISPOSED */;
3657
3838
  }
3839
+ /**
3840
+ * Put a lock file back after a trash-and-verify decided not to reclaim it.
3841
+ *
3842
+ * The old form linked it back inside a swallowing catch and then unlinked the
3843
+ * trash copy unconditionally — so any link failure other than "a peer already
3844
+ * re-created it" DESTROYED the lock this branch exists to preserve. Distinguish
3845
+ * the two: if the path is occupied again the peer's lock wins and ours is dropped;
3846
+ * otherwise move ours back rather than deleting it.
3847
+ */
3848
+ async _restoreLockFile(trashPath) {
3849
+ try {
3850
+ await fs7.link(trashPath, this.lockFilePath);
3851
+ await fs7.unlink(trashPath).catch(() => {
3852
+ });
3853
+ return;
3854
+ } catch {
3855
+ }
3856
+ const peerHoldsIt = await fs7.access(this.lockFilePath).then(() => true).catch(() => false);
3857
+ if (peerHoldsIt) {
3858
+ await fs7.unlink(trashPath).catch(() => {
3859
+ });
3860
+ return;
3861
+ }
3862
+ try {
3863
+ const content = await fs7.readFile(trashPath, "utf-8");
3864
+ const handle = await fs7.open(this.lockFilePath, "wx", 384);
3865
+ try {
3866
+ await handle.write(content);
3867
+ await handle.sync();
3868
+ } finally {
3869
+ await handle.close().catch(() => {
3870
+ });
3871
+ }
3872
+ await fs7.unlink(trashPath).catch(() => {
3873
+ });
3874
+ } catch {
3875
+ const occupied = await fs7.access(this.lockFilePath).then(() => true).catch(() => false);
3876
+ if (occupied) await fs7.unlink(trashPath).catch(() => {
3877
+ });
3878
+ }
3879
+ }
3880
+ /**
3881
+ * Hand our turn back to the next caller in the FIFO queue.
3882
+ *
3883
+ * Every path that stops holding the lock must run this, including the ones that
3884
+ * find the handle already gone. `acquireLock` parks on `await previous` with no
3885
+ * timeout of its own — the lockTimeout only starts counting once the turn has
3886
+ * been granted — so a turn that is never resolved is not a slow acquire, it is a
3887
+ * permanent one.
3888
+ */
3889
+ _releaseTurn() {
3890
+ const resolve8 = this.resolveTurn;
3891
+ this.resolveTurn = null;
3892
+ if (resolve8) resolve8();
3893
+ }
3894
+ /**
3895
+ * Wait, bounded, for an in-flight critical section to finish.
3896
+ *
3897
+ * Yanking a held lock is not a clean teardown: unlinking the file while the
3898
+ * holder is still mid read-modify-write drops cross-process exclusion, and a peer
3899
+ * that acquires in that window loses the holder's update — precisely the hazard
3900
+ * the lock exists to prevent. Since every caller of {@link cleanup} is a shutdown
3901
+ * path, waiting briefly for a normal release is both safe and correct.
3902
+ *
3903
+ * Bounded because shutdown must make progress regardless: if the holder is wedged
3904
+ * (or is itself the caller, which would otherwise deadlock), we give up and force
3905
+ * the teardown, which is the pre-existing behaviour.
3906
+ */
3907
+ async _drainHeldLock(timeoutMs) {
3908
+ const deadline = Date.now() + timeoutMs;
3909
+ while (this.lockHandle !== null && Date.now() < deadline) {
3910
+ await this.sleep(Math.min(25, this.lockRetryDelay));
3911
+ }
3912
+ return this.lockHandle === null;
3913
+ }
3658
3914
  /**
3659
3915
  * Clean up any stale lock files on initialization.
3660
3916
  * This handles cases where locks weren't released due to crashes.
3661
3917
  */
3662
3918
  async cleanupStaleLocksOnStartup() {
3663
3919
  try {
3664
- const rawContent = await fs6.readFile(this.lockFilePath, "utf-8").catch(() => null);
3920
+ const dir = pathmod.dirname(this.lockFilePath);
3921
+ const prefix = `${pathmod.basename(this.lockFilePath)}.trash.`;
3922
+ for (const name of await fs7.readdir(dir)) {
3923
+ if (!name.startsWith(prefix)) continue;
3924
+ const p = pathmod.join(dir, name);
3925
+ const st = await fs7.stat(p).catch(() => null);
3926
+ if (st && Date.now() - st.mtimeMs > TRASH_SWEEP_AGE_MS) {
3927
+ await fs7.unlink(p).catch(() => {
3928
+ });
3929
+ }
3930
+ }
3931
+ } catch {
3932
+ }
3933
+ try {
3934
+ const rawContent = await fs7.readFile(this.lockFilePath, "utf-8").catch(() => null);
3665
3935
  if (rawContent === null) return;
3666
- const stats = await fs6.stat(this.lockFilePath);
3936
+ const stats = await fs7.stat(this.lockFilePath);
3667
3937
  const lockAge = Date.now() - stats.mtimeMs;
3668
3938
  const parsed = this._parseLockContent(rawContent);
3669
3939
  const memo = /* @__PURE__ */ new Map();
@@ -3674,21 +3944,21 @@ var init_file_lock_service = __esm({
3674
3944
  );
3675
3945
  const expectedUuid = parsed?.uuid ?? "";
3676
3946
  const trashPath = `${this.lockFilePath}.trash.${crypto.randomBytes(8).toString("hex")}`;
3947
+ let renamed = false;
3677
3948
  try {
3678
- await fs6.rename(this.lockFilePath, trashPath);
3679
- const trashContent = await fs6.readFile(trashPath, "utf-8");
3949
+ await fs7.rename(this.lockFilePath, trashPath);
3950
+ renamed = true;
3951
+ const trashContent = await fs7.readFile(trashPath, "utf-8");
3680
3952
  const trashParsed = this._parseLockContent(trashContent);
3681
3953
  if ((trashParsed?.uuid ?? "") !== expectedUuid) {
3682
- try {
3683
- await fs6.link(trashPath, this.lockFilePath);
3684
- } catch {
3685
- }
3686
- await fs6.unlink(trashPath);
3954
+ await this._restoreLockFile(trashPath);
3687
3955
  return;
3688
3956
  }
3689
- await fs6.unlink(trashPath);
3957
+ await fs7.unlink(trashPath).catch(() => {
3958
+ });
3690
3959
  logger.log("[FileLockService] Stale lock removed");
3691
3960
  } catch {
3961
+ if (renamed) await this._restoreLockFile(trashPath);
3692
3962
  }
3693
3963
  }
3694
3964
  } catch (error) {
@@ -3706,6 +3976,9 @@ var init_file_lock_service = __esm({
3706
3976
  * @throws Error if unable to acquire lock within timeout
3707
3977
  */
3708
3978
  async acquireLock() {
3979
+ if (this._retired) {
3980
+ throw new Error(`Cannot acquire lock at ${this.lockFilePath}: service has been disposed`);
3981
+ }
3709
3982
  const heldLocks = lockContext.getStore();
3710
3983
  const lockKey = `${this.lockFilePath}:${this.lockUuid}`;
3711
3984
  if (heldLocks?.has(lockKey)) {
@@ -3729,17 +4002,27 @@ var init_file_lock_service = __esm({
3729
4002
  if (myResolve) myResolve();
3730
4003
  return;
3731
4004
  }
4005
+ if (this._retired) {
4006
+ myResolve();
4007
+ throw new Error(`Cannot acquire lock at ${this.lockFilePath}: service was disposed while queued`);
4008
+ }
3732
4009
  this.resolveTurn = myResolve;
3733
4010
  const startTime = Date.now();
3734
4011
  let contentionCount = 0;
3735
4012
  const contendedOwnerAliveMemo = /* @__PURE__ */ new Map();
3736
4013
  try {
3737
4014
  for (let _attempt = 0; _attempt < this.lockRetries; _attempt++) {
4015
+ if (this._retired) {
4016
+ throw new Error(`Cannot acquire lock at ${this.lockFilePath}: service was disposed during acquisition`);
4017
+ }
3738
4018
  let handle = null;
3739
4019
  try {
3740
- handle = await fs6.open(this.lockFilePath, "wx", 384);
4020
+ handle = await fs7.open(this.lockFilePath, "wx", 384);
3741
4021
  await handle.write(this._makeLockContent());
3742
4022
  await handle.sync();
4023
+ if (this._retired) {
4024
+ throw new Error(`Cannot acquire lock at ${this.lockFilePath}: service was disposed during acquisition`);
4025
+ }
3743
4026
  this.lockHandle = handle;
3744
4027
  this.lockCount = 1;
3745
4028
  if (heldLocks) {
@@ -3761,7 +4044,7 @@ var init_file_lock_service = __esm({
3761
4044
  } catch {
3762
4045
  }
3763
4046
  try {
3764
- await fs6.unlink(this.lockFilePath);
4047
+ await fs7.unlink(this.lockFilePath);
3765
4048
  } catch {
3766
4049
  }
3767
4050
  }
@@ -3770,45 +4053,44 @@ var init_file_lock_service = __esm({
3770
4053
  if (errnoError.code === "EEXIST") {
3771
4054
  contentionCount++;
3772
4055
  try {
3773
- const rawContent = await fs6.readFile(this.lockFilePath, "utf-8");
4056
+ const rawContent = await fs7.readFile(this.lockFilePath, "utf-8");
3774
4057
  const parsed = this._parseLockContent(rawContent);
3775
4058
  const lockUuid = parsed?.uuid ?? "";
3776
- const stats = await fs6.stat(this.lockFilePath);
4059
+ const stats = await fs7.stat(this.lockFilePath);
3777
4060
  const lockAge = Date.now() - stats.mtimeMs;
3778
4061
  if (lockUuid === this.lockUuid) {
3779
4062
  try {
3780
- await fs6.unlink(this.lockFilePath);
4063
+ await fs7.unlink(this.lockFilePath);
3781
4064
  } catch {
3782
4065
  }
3783
4066
  continue;
3784
4067
  }
3785
4068
  if (await this._shouldReclaim(parsed?.pid ?? null, parsed?.startTime ?? null, lockAge, contendedOwnerAliveMemo)) {
3786
4069
  const trashPath = `${this.lockFilePath}.trash.${crypto.randomBytes(8).toString("hex")}`;
4070
+ let renamed = false;
3787
4071
  try {
3788
- await fs6.rename(this.lockFilePath, trashPath);
3789
- const trashContent = await fs6.readFile(trashPath, "utf-8");
4072
+ await fs7.rename(this.lockFilePath, trashPath);
4073
+ renamed = true;
4074
+ const trashContent = await fs7.readFile(trashPath, "utf-8");
3790
4075
  const trashParsed = this._parseLockContent(trashContent);
3791
4076
  if ((trashParsed?.uuid ?? "") !== lockUuid) {
3792
- try {
3793
- await fs6.link(trashPath, this.lockFilePath);
3794
- } catch {
3795
- }
3796
- await fs6.unlink(trashPath);
4077
+ await this._restoreLockFile(trashPath);
3797
4078
  continue;
3798
4079
  }
3799
- await fs6.unlink(trashPath);
4080
+ await fs7.unlink(trashPath).catch(() => {
4081
+ });
3800
4082
  } catch {
4083
+ if (renamed) await this._restoreLockFile(trashPath);
3801
4084
  }
3802
4085
  continue;
3803
4086
  }
3804
- } catch (_statError) {
3805
- const trashPath = `${this.lockFilePath}.trash.${crypto.randomBytes(8).toString("hex")}`;
3806
- try {
3807
- await fs6.rename(this.lockFilePath, trashPath);
3808
- await fs6.unlink(trashPath);
3809
- continue;
3810
- } catch {
3811
- }
4087
+ } catch (inspectError) {
4088
+ const code = inspectError?.code;
4089
+ if (code === "ENOENT") continue;
4090
+ logger.warn(
4091
+ `[FileLockService] Could not inspect contended lock at ${this.lockFilePath} (${code ?? "unknown"}); backing off rather than reclaiming it.`
4092
+ );
4093
+ metrics.increment("state_lock_inspect_failed_total", 1, { code: code ?? "unknown" });
3812
4094
  }
3813
4095
  if (Date.now() - startTime >= this.lockTimeout) {
3814
4096
  const duration = Date.now() - startTime;
@@ -3829,10 +4111,7 @@ var init_file_lock_service = __esm({
3829
4111
  metrics.increment("state_lock_acquire_total", 1, { status: "failed" });
3830
4112
  throw new Error(`Failed to acquire lock after ${this.lockRetries} retries`);
3831
4113
  } catch (err) {
3832
- if (this.resolveTurn) {
3833
- this.resolveTurn();
3834
- this.resolveTurn = null;
3835
- }
4114
+ this._releaseTurn();
3836
4115
  throw err;
3837
4116
  }
3838
4117
  }
@@ -3841,6 +4120,7 @@ var init_file_lock_service = __esm({
3841
4120
  */
3842
4121
  async releaseLock() {
3843
4122
  if (this.lockHandle === null) {
4123
+ this._releaseTurn();
3844
4124
  return;
3845
4125
  }
3846
4126
  this.lockCount--;
@@ -3848,10 +4128,10 @@ var init_file_lock_service = __esm({
3848
4128
  return;
3849
4129
  }
3850
4130
  try {
3851
- const content = await fs6.readFile(this.lockFilePath, "utf8").catch(() => "");
4131
+ const content = await fs7.readFile(this.lockFilePath, "utf8").catch(() => "");
3852
4132
  const parsed = this._parseLockContent(content);
3853
4133
  if (parsed?.uuid === this.lockUuid) {
3854
- await fs6.unlink(this.lockFilePath).catch(() => {
4134
+ await fs7.unlink(this.lockFilePath).catch(() => {
3855
4135
  });
3856
4136
  }
3857
4137
  await this.lockHandle.close().catch(() => {
@@ -3864,19 +4144,20 @@ var init_file_lock_service = __esm({
3864
4144
  }
3865
4145
  metrics.setGauge("state_lock_held", 0);
3866
4146
  } finally {
3867
- const resolve8 = this.resolveTurn;
3868
- this.resolveTurn = null;
3869
- if (resolve8) {
3870
- resolve8();
3871
- }
4147
+ this._releaseTurn();
3872
4148
  }
3873
4149
  }
3874
4150
  /**
3875
4151
  * Execute a function within the scope of a filesystem lock.
3876
4152
  * This is the preferred way to use the lock service.
3877
4153
  * Safe re-entrancy is supported within the same async flow.
4154
+ *
4155
+ * The acquire timeout is the one given to the constructor (`lockTimeout`). This
4156
+ * used to take a second `_timeout` argument that was accepted and then ignored
4157
+ * entirely — a caller passing 5000 silently got the constructed 20000 — so it is
4158
+ * gone rather than left to mislead. No caller was passing it.
3878
4159
  */
3879
- async withLock(callback, _timeout = 3e4) {
4160
+ async withLock(callback) {
3880
4161
  const heldLocks = lockContext.getStore();
3881
4162
  const lockKey = `${this.lockFilePath}:${this.lockUuid}`;
3882
4163
  if (heldLocks?.has(lockKey)) {
@@ -3892,15 +4173,29 @@ var init_file_lock_service = __esm({
3892
4173
  });
3893
4174
  }
3894
4175
  /**
3895
- * Force clean up any lock files owned by this instance.
4176
+ * Clean up any lock file owned by this instance, and retire the instance.
4177
+ *
4178
+ * Called only from shutdown paths (`dispose`, `StateManager.cleanup`). It first
4179
+ * gives an in-flight critical section a bounded chance to release normally, then
4180
+ * forces the teardown if it has not. After this the instance is retired:
4181
+ * `acquireLock` fails fast rather than blocking on a queue nothing will ever
4182
+ * drain, which is what makes forcing the teardown safe to do at all.
3896
4183
  */
3897
4184
  async cleanup() {
4185
+ const drained = await this._drainHeldLock(CLEANUP_DRAIN_TIMEOUT_MS);
4186
+ if (!drained) {
4187
+ logger.warn(
4188
+ `[FileLockService] Lock at ${this.lockFilePath} still held after ${CLEANUP_DRAIN_TIMEOUT_MS}ms; forcing teardown. A concurrent writer in another process may observe the lock as free.`
4189
+ );
4190
+ metrics.increment("state_lock_forced_teardown_total", 1);
4191
+ }
4192
+ this._retired = true;
3898
4193
  if (this.lockHandle) {
3899
4194
  try {
3900
- const content = await fs6.readFile(this.lockFilePath, "utf8").catch(() => "");
4195
+ const content = await fs7.readFile(this.lockFilePath, "utf8").catch(() => "");
3901
4196
  const parsed = this._parseLockContent(content);
3902
4197
  if (parsed?.uuid === this.lockUuid) {
3903
- await fs6.unlink(this.lockFilePath).catch(() => {
4198
+ await fs7.unlink(this.lockFilePath).catch(() => {
3904
4199
  });
3905
4200
  }
3906
4201
  } catch {
@@ -3912,6 +4207,7 @@ var init_file_lock_service = __esm({
3912
4207
  this.lockHandle = null;
3913
4208
  }
3914
4209
  this.lockCount = 0;
4210
+ this._releaseTurn();
3915
4211
  }
3916
4212
  /**
3917
4213
  * Check if the current instance holds the lock
@@ -3996,6 +4292,93 @@ var init_file_lock_service = __esm({
3996
4292
  }
3997
4293
  });
3998
4294
 
4295
+ // src/utils/http-body.ts
4296
+ async function readBodyCapped(response, maxBytes) {
4297
+ const body = response.body;
4298
+ if (!body || typeof body.getReader !== "function") {
4299
+ return new Uint8Array(await response.arrayBuffer());
4300
+ }
4301
+ const reader = body.getReader();
4302
+ const chunks = [];
4303
+ let total = 0;
4304
+ try {
4305
+ for (; ; ) {
4306
+ const { done, value } = await reader.read();
4307
+ if (done) break;
4308
+ if (value) {
4309
+ total += value.byteLength;
4310
+ if (total > maxBytes) throw new BodyTooLargeError(total, maxBytes);
4311
+ chunks.push(value);
4312
+ }
4313
+ }
4314
+ } finally {
4315
+ try {
4316
+ await reader.cancel();
4317
+ } catch {
4318
+ }
4319
+ }
4320
+ const out = new Uint8Array(total);
4321
+ let offset = 0;
4322
+ for (const c of chunks) {
4323
+ out.set(c, offset);
4324
+ offset += c.byteLength;
4325
+ }
4326
+ return out;
4327
+ }
4328
+ function hasStreamBody(response) {
4329
+ const body = response.body;
4330
+ return !!body && typeof body.getReader === "function";
4331
+ }
4332
+ async function readTextCapped(response, maxBytes = MAX_JSON_BODY_BYTES) {
4333
+ if (hasStreamBody(response)) {
4334
+ return new TextDecoder("utf-8").decode(await readBodyCapped(response, maxBytes));
4335
+ }
4336
+ if (typeof response.arrayBuffer === "function") {
4337
+ return new TextDecoder("utf-8").decode(new Uint8Array(await response.arrayBuffer()));
4338
+ }
4339
+ return response.text();
4340
+ }
4341
+ async function readJsonCapped(response, maxBytes = MAX_JSON_BODY_BYTES) {
4342
+ if (hasStreamBody(response) || typeof response.arrayBuffer === "function" || typeof response.text === "function") {
4343
+ return JSON.parse(await readTextCapped(response, maxBytes));
4344
+ }
4345
+ return response.json();
4346
+ }
4347
+ var BodyTooLargeError, MAX_JSON_BODY_BYTES, MAX_BULK_JSON_BODY_BYTES, Utf8Body;
4348
+ var init_http_body = __esm({
4349
+ "src/utils/http-body.ts"() {
4350
+ "use strict";
4351
+ BodyTooLargeError = class extends Error {
4352
+ constructor(bytesRead, maxBytes) {
4353
+ super(`Response body exceeded ${maxBytes} byte cap`);
4354
+ this.bytesRead = bytesRead;
4355
+ this.maxBytes = maxBytes;
4356
+ this.name = "BodyTooLargeError";
4357
+ }
4358
+ bytesRead;
4359
+ maxBytes;
4360
+ };
4361
+ MAX_JSON_BODY_BYTES = 32 * 1024 * 1024;
4362
+ MAX_BULK_JSON_BODY_BYTES = 96 * 1024 * 1024;
4363
+ Utf8Body = class {
4364
+ chunks = [];
4365
+ bytes = 0;
4366
+ /** Total bytes accumulated so far. */
4367
+ get byteLength() {
4368
+ return this.bytes;
4369
+ }
4370
+ push(chunk) {
4371
+ this.chunks.push(chunk);
4372
+ this.bytes += chunk.byteLength;
4373
+ }
4374
+ /** Concatenate and decode. Safe to call once the stream has ended. */
4375
+ toString() {
4376
+ return Buffer.concat(this.chunks).toString("utf8");
4377
+ }
4378
+ };
4379
+ }
4380
+ });
4381
+
3999
4382
  // src/utils/circuit-breaker.ts
4000
4383
  var CircuitBreaker;
4001
4384
  var init_circuit_breaker = __esm({
@@ -4052,6 +4435,11 @@ var init_circuit_breaker = __esm({
4052
4435
  metrics.observe("circuit_breaker_call_duration_ms", duration, { breaker: this.options.name, status: "error" });
4053
4436
  if (this.options.isTransientError(error)) {
4054
4437
  this.onFailure(error);
4438
+ } else if (admittedAsProbe && this.state === "HALF_OPEN") {
4439
+ logger.warn(
4440
+ `[CircuitBreaker] '${this.options.name}' HALF_OPEN probe failed with a non-counted error; treating as inconclusive and re-opening: ${error instanceof Error ? error.message : String(error)}`
4441
+ );
4442
+ this.transitionTo("OPEN");
4055
4443
  }
4056
4444
  throw error;
4057
4445
  } finally {
@@ -4555,7 +4943,7 @@ var init_embedder_utils = __esm({
4555
4943
  });
4556
4944
 
4557
4945
  // src/knowledge/store.ts
4558
- import * as fs12 from "node:fs";
4946
+ import * as fs13 from "node:fs";
4559
4947
  import * as fsPromises from "node:fs/promises";
4560
4948
  import * as path13 from "node:path";
4561
4949
  function isLanceCommitConflict(err) {
@@ -4629,7 +5017,7 @@ var init_store = __esm({
4629
5017
  }
4630
5018
  async loadManifest() {
4631
5019
  try {
4632
- if (fs12.existsSync(this.manifestPath)) {
5020
+ if (fs13.existsSync(this.manifestPath)) {
4633
5021
  const content = await fsPromises.readFile(this.manifestPath, "utf-8");
4634
5022
  const manifest = JSON.parse(content);
4635
5023
  if (manifest.activeTableName) {
@@ -4651,7 +5039,7 @@ var init_store = __esm({
4651
5039
  return true;
4652
5040
  } catch (err) {
4653
5041
  logger.warn("[store] Failed to save manifest:", err);
4654
- if (fs12.existsSync(tempPath)) {
5042
+ if (fs13.existsSync(tempPath)) {
4655
5043
  try {
4656
5044
  await fsPromises.unlink(tempPath);
4657
5045
  } catch {
@@ -4679,8 +5067,8 @@ var init_store = __esm({
4679
5067
  async openInternal() {
4680
5068
  if (this.db) return;
4681
5069
  try {
4682
- if (!fs12.existsSync(this.options.dbDir)) {
4683
- fs12.mkdirSync(this.options.dbDir, { recursive: true });
5070
+ if (!fs13.existsSync(this.options.dbDir)) {
5071
+ fs13.mkdirSync(this.options.dbDir, { recursive: true });
4684
5072
  }
4685
5073
  this.db = await (await getLancedb()).connect(this.options.dbDir);
4686
5074
  const tableNames = await this.db.tableNames();
@@ -4783,7 +5171,7 @@ var init_store = __esm({
4783
5171
  const oldDir = path13.join(this.options.dbDir, `${this.tableName}.lance`);
4784
5172
  const backupDir = path13.join(this.options.dbDir, `${backupTableName}.lance`);
4785
5173
  try {
4786
- if (fs12.existsSync(oldDir)) {
5174
+ if (fs13.existsSync(oldDir)) {
4787
5175
  await fsPromises.rename(oldDir, backupDir);
4788
5176
  logger.info(`[store] Successfully backed up database directory to ${backupDir}`);
4789
5177
  }
@@ -5067,10 +5455,22 @@ var init_store = __esm({
5067
5455
  }
5068
5456
  }
5069
5457
  }
5070
- async deleteByUrlAndType(url, ingestionType) {
5071
- return this.trackOperation("deleteByUrlAndType", void 0, () => this.deleteByUrlAndTypeInner(url, ingestionType));
5458
+ /**
5459
+ * @param olderThan When set, only rows with `timestamp < olderThan` are removed.
5460
+ * This is what lets a refresh write the replacement chunks FIRST and prune the
5461
+ * superseded ones after, instead of deleting first and losing the entry
5462
+ * outright if the insert then fails. `timestamp` is used as the discriminator
5463
+ * because it is a real (indexed) column — `contentHash` lives inside the
5464
+ * JSON-encoded `metadata` blob and cannot be filtered on.
5465
+ */
5466
+ async deleteByUrlAndType(url, ingestionType, olderThan) {
5467
+ if (this.isClosing) {
5468
+ logger.warn(`[store] Prune of ${ingestionType} chunks for ${url} skipped \u2014 store is closing; superseded duplicates remain until the next refresh.`);
5469
+ metrics.increment("knowledge_store_delete_total", 1, { operation: "by_url_and_type", status: "skipped_closing" });
5470
+ }
5471
+ return this.trackOperation("deleteByUrlAndType", void 0, () => this.deleteByUrlAndTypeInner(url, ingestionType, olderThan));
5072
5472
  }
5073
- async deleteByUrlAndTypeInner(url, ingestionType) {
5473
+ async deleteByUrlAndTypeInner(url, ingestionType, olderThan) {
5074
5474
  let retryCount = 0;
5075
5475
  const MAX_RETRIES = 5;
5076
5476
  const BASE_DELAY = 100;
@@ -5081,11 +5481,12 @@ var init_store = __esm({
5081
5481
  try {
5082
5482
  const escapedUrl = url.replace(/'/g, "''");
5083
5483
  const escapedType = ingestionType.replace(/'/g, "''");
5084
- await table.delete(`url = '${escapedUrl}' AND ingestion_type = '${escapedType}' AND (${scopeFilter})`);
5484
+ const ageClause = Number.isFinite(olderThan) ? ` AND timestamp < ${Math.floor(olderThan)}` : "";
5485
+ await table.delete(`url = '${escapedUrl}' AND ingestion_type = '${escapedType}' AND (${scopeFilter})${ageClause}`);
5085
5486
  const duration = Date.now() - startTime;
5086
5487
  metrics.observe("knowledge_store_delete_duration_ms", duration, { operation: "by_url_and_type" });
5087
5488
  metrics.increment("knowledge_store_delete_total", 1, { operation: "by_url_and_type", status: "success" });
5088
- logger.log(`[store] Deleted ${ingestionType} chunks for ${url} (scoped)`);
5489
+ logger.log(`[store] Deleted ${ingestionType} chunks for ${url} (scoped)${ageClause ? " older than the replacement" : ""}`);
5089
5490
  return;
5090
5491
  } catch (err) {
5091
5492
  if (isLanceCommitConflict(err)) {
@@ -5138,8 +5539,8 @@ var init_store = __esm({
5138
5539
  });
5139
5540
  try {
5140
5541
  const dir = path13.dirname(outputPath);
5141
- await fs12.promises.mkdir(dir, { recursive: true });
5142
- await fs12.promises.writeFile(outputPath, JSON.stringify(exportData), "utf-8");
5542
+ await fs13.promises.mkdir(dir, { recursive: true });
5543
+ await fs13.promises.writeFile(outputPath, JSON.stringify(exportData), "utf-8");
5143
5544
  logger.info(`[store] Successfully exported ${exportData.length} entries to ${outputPath}`);
5144
5545
  } catch (err) {
5145
5546
  logger.error(`[store] Failed to export knowledge store for web:`, err);
@@ -5156,7 +5557,7 @@ var init_store = __esm({
5156
5557
  const scopeFilter = this.getScopeFilter();
5157
5558
  const startTime = Date.now();
5158
5559
  const escapedUrl = url.replace(/'/g, "''");
5159
- const results = await table.query().where(`url = '${escapedUrl}' AND ingestion_type = 'synthesis-description' AND (${scopeFilter})`).limit(1).toArray();
5560
+ const results = await table.query().where(`url = '${escapedUrl}' AND ingestion_type = 'synthesis-description' AND (${scopeFilter})`).limit(16).toArray();
5160
5561
  const duration = Date.now() - startTime;
5161
5562
  metrics.observe("knowledge_store_query_duration_ms", duration, { operation: "rebuild_document" });
5162
5563
  metrics.increment("knowledge_store_query_total", 1, { operation: "rebuild_document" });
@@ -5164,6 +5565,7 @@ var init_store = __esm({
5164
5565
  metrics.increment("knowledge_store_cache_hits_total", 1, { status: "miss" });
5165
5566
  return null;
5166
5567
  }
5568
+ results.sort((a, b) => Number(b.timestamp) - Number(a.timestamp));
5167
5569
  const r = results[0];
5168
5570
  const rawAgeMs = Date.now() - Number(r.timestamp);
5169
5571
  const hasAge = Number.isFinite(rawAgeMs);
@@ -5319,14 +5721,14 @@ var init_store = __esm({
5319
5721
  for (const name of migrationDirs) {
5320
5722
  const fullPath = path13.join(this.options.dbDir, name);
5321
5723
  logger.info(`[store] Removing orphaned migration temp directory: ${name}`);
5322
- await fsPromises.rm(fullPath, { recursive: true, force: true });
5724
+ await fsPromises.rm(fullPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
5323
5725
  }
5324
5726
  if (backupDirs.length > 1) {
5325
5727
  backupDirs.sort();
5326
5728
  for (const name of backupDirs.slice(0, -1)) {
5327
5729
  const fullPath = path13.join(this.options.dbDir, name);
5328
5730
  logger.info(`[store] Removing superseded backup directory: ${name}`);
5329
- await fsPromises.rm(fullPath, { recursive: true, force: true });
5731
+ await fsPromises.rm(fullPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
5330
5732
  }
5331
5733
  }
5332
5734
  } catch (err) {
@@ -5508,44 +5910,47 @@ var init_writer_queue = __esm({
5508
5910
  async process() {
5509
5911
  if (this.processing) return;
5510
5912
  this.processing = true;
5511
- while (this.queue.length > 0) {
5512
- const item = this.queue.shift();
5513
- try {
5514
- const urlKey = normalizeUrl(item.url);
5515
- const prev = this.inflightByUrl.get(urlKey) ?? Promise.resolve();
5516
- const inflight = prev.then(() => this._ingestInner(item));
5517
- this.inflightByUrl.set(urlKey, inflight);
5913
+ try {
5914
+ while (this.queue.length > 0) {
5915
+ const item = this.queue.shift();
5518
5916
  try {
5519
- await inflight;
5520
- } finally {
5521
- if (this.inflightByUrl.get(urlKey) === inflight) {
5522
- this.inflightByUrl.delete(urlKey);
5523
- }
5524
- }
5525
- } catch (err) {
5526
- if (isEmbedderUnreachable(err)) {
5527
- logger.warn(`[writer-queue] Embedder unreachable for ${item.url}, retrying once after 2s...`);
5528
- await new Promise((r) => setTimeout(r, 2e3));
5917
+ const urlKey = normalizeUrl(item.url);
5918
+ const prev = this.inflightByUrl.get(urlKey) ?? Promise.resolve();
5919
+ const inflight = prev.then(() => this._ingestInner(item));
5920
+ this.inflightByUrl.set(urlKey, inflight);
5529
5921
  try {
5530
- await this.ingest(item);
5531
- } catch (retryErr) {
5532
- logger.error(`[writer-queue] Retry failed for ${item.url}, dropping:`, retryErr);
5533
- metrics.increment("knowledge_ingest_dropped_total", 1, { reason: "embedder_unreachable" });
5922
+ await inflight;
5923
+ } finally {
5924
+ if (this.inflightByUrl.get(urlKey) === inflight) {
5925
+ this.inflightByUrl.delete(urlKey);
5926
+ }
5927
+ }
5928
+ } catch (err) {
5929
+ if (isEmbedderUnreachable(err)) {
5930
+ logger.warn(`[writer-queue] Embedder unreachable for ${item.url}, retrying once after 2s...`);
5931
+ await new Promise((r) => setTimeout(r, 2e3));
5932
+ try {
5933
+ await this.ingest(item);
5934
+ } catch (retryErr) {
5935
+ logger.error(`[writer-queue] Retry failed for ${item.url}, dropping:`, retryErr);
5936
+ metrics.increment("knowledge_ingest_dropped_total", 1, { reason: "embedder_unreachable" });
5937
+ }
5938
+ } else if (isNoSpace(err)) {
5939
+ logger.error(`[writer-queue] ENOSPC: disk full \u2014 dropping ${item.url}. Free up disk space to resume knowledge ingestion.`);
5940
+ metrics.increment("knowledge_ingest_dropped_total", 1, { reason: "enospc" });
5941
+ } else {
5942
+ logger.error(`[writer-queue] Failed to ingest ${item.url}:`, err);
5943
+ metrics.increment("knowledge_ingest_dropped_total", 1, { reason: "ingest_error" });
5534
5944
  }
5535
- } else if (isNoSpace(err)) {
5536
- logger.error(`[writer-queue] ENOSPC: disk full \u2014 dropping ${item.url}. Free up disk space to resume knowledge ingestion.`);
5537
- metrics.increment("knowledge_ingest_dropped_total", 1, { reason: "enospc" });
5538
- } else {
5539
- logger.error(`[writer-queue] Failed to ingest ${item.url}:`, err);
5540
- metrics.increment("knowledge_ingest_dropped_total", 1, { reason: "ingest_error" });
5541
5945
  }
5542
5946
  }
5543
- }
5544
- this.processing = false;
5545
- const resolvers = [...this.drainResolvers];
5546
- this.drainResolvers = [];
5547
- for (const resolve8 of resolvers) {
5548
- resolve8();
5947
+ } finally {
5948
+ this.processing = false;
5949
+ const resolvers = [...this.drainResolvers];
5950
+ this.drainResolvers = [];
5951
+ for (const resolve8 of resolvers) {
5952
+ resolve8();
5953
+ }
5549
5954
  }
5550
5955
  }
5551
5956
  async ingest(item) {
@@ -5586,15 +5991,23 @@ var init_writer_queue = __esm({
5586
5991
  };
5587
5992
  const existing = await this.options.store.findByUrl(normalizedUrl);
5588
5993
  const sameType = existing.filter((c) => c.metadata["ingestionType"] === incomingType);
5589
- if (sameType.length > 0 && sameType[0].metadata["contentHash"] === hash) {
5590
- logger.log(`[writer-queue] Skipping ${normalizedUrl} (${incomingType}) \u2014 content unchanged.`);
5591
- return;
5592
- }
5593
5994
  if (sameType.length > 0) {
5594
- await this.options.store.deleteByUrlAndType(normalizedUrl, incomingType);
5995
+ const newestTs = Math.max(...sameType.map((d) => Number(d.timestamp) || 0));
5996
+ const newest = sameType.find((d) => (Number(d.timestamp) || 0) === newestTs);
5997
+ if (newest.metadata["contentHash"] === hash) {
5998
+ if (sameType.some((d) => (Number(d.timestamp) || 0) < newestTs)) {
5999
+ await this.options.store.deleteByUrlAndType(normalizedUrl, incomingType, newestTs).catch((err) => {
6000
+ logger.warn(`[writer-queue] Sweeping superseded duplicates for ${normalizedUrl} (${incomingType}) failed:`, err);
6001
+ metrics.increment("knowledge_ingest_stale_prune_failed_total", 1);
6002
+ });
6003
+ }
6004
+ logger.log(`[writer-queue] Skipping ${normalizedUrl} (${incomingType}) \u2014 content unchanged.`);
6005
+ return;
6006
+ }
5595
6007
  }
5596
6008
  const rawChunks = this.options.chunker ? this.options.chunker.chunk(item.markdown) : [{ text: item.markdown, actual_overlap: 0 }];
5597
6009
  if (rawChunks.length === 0) return;
6010
+ const insertedAt = Date.now();
5598
6011
  const docs = rawChunks.map((chunk, i) => ({
5599
6012
  url: normalizedUrl,
5600
6013
  text: chunk.text,
@@ -5609,9 +6022,15 @@ var init_writer_queue = __esm({
5609
6022
  chunkIndex: i,
5610
6023
  totalChunks: rawChunks.length
5611
6024
  },
5612
- timestamp: Date.now()
6025
+ timestamp: insertedAt
5613
6026
  }));
5614
6027
  await this.options.store.addDocuments(docs);
6028
+ if (sameType.length > 0) {
6029
+ await this.options.store.deleteByUrlAndType(normalizedUrl, incomingType, insertedAt).catch((err) => {
6030
+ logger.warn(`[writer-queue] Replacement for ${normalizedUrl} (${incomingType}) is stored, but pruning the superseded chunks failed; duplicates remain until the next refresh:`, err);
6031
+ metrics.increment("knowledge_ingest_stale_prune_failed_total", 1);
6032
+ });
6033
+ }
5615
6034
  }
5616
6035
  async drain(timeoutMs) {
5617
6036
  return new Promise((resolve8) => {
@@ -5879,7 +6298,7 @@ var init_model_config = __esm({
5879
6298
  });
5880
6299
 
5881
6300
  // src/knowledge/index.ts
5882
- import * as fs13 from "node:fs";
6301
+ import * as fs14 from "node:fs";
5883
6302
  function getMigrationStrategy(config) {
5884
6303
  const strategy = config.MIGRATION_STRATEGY;
5885
6304
  if (!strategy) return void 0;
@@ -5941,10 +6360,10 @@ async function createKnowledgeStoreComponents(embedderFactory, reconnectFactory,
5941
6360
  }
5942
6361
  async function forceDeleteKnowledgeStore(config, workspace) {
5943
6362
  const dbDir = getDbDir(config, workspace);
5944
- if (fs13.existsSync(dbDir)) {
6363
+ if (fs14.existsSync(dbDir)) {
5945
6364
  try {
5946
- fs13.rmSync(dbDir, { recursive: true, force: true });
5947
- fs13.mkdirSync(dbDir, { recursive: true });
6365
+ fs14.rmSync(dbDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
6366
+ fs14.mkdirSync(dbDir, { recursive: true });
5948
6367
  logger.info(`[knowledge] Knowledge store at ${dbDir} forcefully deleted and recreated.`);
5949
6368
  } catch (err) {
5950
6369
  logger.error(`[knowledge] Failed to forcefully delete directory ${dbDir}:`, err);
@@ -6079,11 +6498,11 @@ async function isModelCached(model) {
6079
6498
  const env = await getHFEnv();
6080
6499
  const cacheDir = env.cacheDir;
6081
6500
  if (!cacheDir) return false;
6082
- const { access: access2, readdir: readdir5, readFile: readFile7, stat: stat5, rm: rm3 } = await import("node:fs/promises");
6501
+ const { access: access3, readdir: readdir6, readFile: readFile7, stat: stat5, rm: rm3 } = await import("node:fs/promises");
6083
6502
  const path19 = await import("node:path");
6084
6503
  const modelDir = path19.default.join(cacheDir, model);
6085
6504
  const onnxDir = path19.default.join(modelDir, "onnx");
6086
- await access2(path19.default.join(onnxDir, "model.onnx"));
6505
+ await access3(path19.default.join(onnxDir, "model.onnx"));
6087
6506
  try {
6088
6507
  const cfgRaw = await readFile7(path19.default.join(modelDir, "config.json"), "utf-8");
6089
6508
  JSON.parse(cfgRaw);
@@ -6100,7 +6519,7 @@ async function isModelCached(model) {
6100
6519
  }
6101
6520
  }
6102
6521
  if (!hasTokenizer) return false;
6103
- const entries = await readdir5(onnxDir).catch(() => []);
6522
+ const entries = await readdir6(onnxDir).catch(() => []);
6104
6523
  const hasExternalWeights = entries.includes("model.onnx_data");
6105
6524
  if (hasExternalWeights) {
6106
6525
  const dataStat = await stat5(path19.default.join(onnxDir, "model.onnx_data")).catch(() => null);
@@ -6141,7 +6560,7 @@ async function purgeModelCache(model) {
6141
6560
  logger.warn(`[embedder] Refusing to purge unsafe model-cache path for model id ${JSON.stringify(model)}`);
6142
6561
  return;
6143
6562
  }
6144
- await rm3(modelDir, { recursive: true, force: true });
6563
+ await rm3(modelDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
6145
6564
  logger.warn(`[embedder] Purged corrupt model cache at ${modelDir}`);
6146
6565
  } catch (err) {
6147
6566
  logger.warn("[embedder] Failed to purge model cache:", err);
@@ -6341,19 +6760,21 @@ function runProbe(model, cacheDir, timeoutMs) {
6341
6760
  finish(false);
6342
6761
  }, timeoutMs);
6343
6762
  if (typeof timer.unref === "function") timer.unref();
6344
- let stdout = "";
6345
- let stderr = "";
6763
+ const stdoutChunks = [];
6764
+ const stderrChunks = [];
6346
6765
  child.stdout?.on("data", (d) => {
6347
- stdout += String(d);
6766
+ stdoutChunks.push(Buffer.from(d));
6348
6767
  });
6349
6768
  child.stderr?.on("data", (d) => {
6350
- stderr += String(d);
6769
+ stderrChunks.push(Buffer.from(d));
6351
6770
  });
6352
6771
  child.on("error", (err) => {
6353
6772
  logger.warn("[embedder] WebGPU probe process error \u2014 defaulting to CPU:", err);
6354
6773
  finish(false);
6355
6774
  });
6356
6775
  child.on("close", (code, signal) => {
6776
+ const stdout = Buffer.concat(stdoutChunks).toString("utf8");
6777
+ const stderr = Buffer.concat(stderrChunks).toString("utf8");
6357
6778
  if (stdout.includes("PROBE_OK")) {
6358
6779
  finish(true);
6359
6780
  return;
@@ -6446,6 +6867,14 @@ var init_embedder = __esm({
6446
6867
  idleTimer = null;
6447
6868
  IDLE_TIMEOUT_MS = 60 * 1e3;
6448
6869
  activeEmbeddings = 0;
6870
+ /**
6871
+ * Why the in-flight dispose was started. An IDLE dispose is a memory-reclaim
6872
+ * pause that a later embed is expected to undo, so initialize() waits it out and
6873
+ * re-initializes. A TERMINAL dispose (shutdown, config change, explicit
6874
+ * disposal) must never be undone — re-initializing there would resurrect the
6875
+ * ONNX pipeline during teardown. Only meaningful while state === 'disposing'.
6876
+ */
6877
+ disposeReason = "terminal";
6449
6878
  constructor(options) {
6450
6879
  this.model = options.model;
6451
6880
  this.poolingMode = options.pooling ?? "mean";
@@ -6479,7 +6908,7 @@ var init_embedder = __esm({
6479
6908
  }
6480
6909
  if (this.state === "ready") {
6481
6910
  logger.info(`[embedder] Idle timeout reached (${this.IDLE_TIMEOUT_MS}ms), releasing GPU memory...`);
6482
- this.dispose().catch((err) => logger.warn("[embedder] Failed to dispose on idle:", err));
6911
+ this.dispose("idle").catch((err) => logger.warn("[embedder] Failed to dispose on idle:", err));
6483
6912
  }
6484
6913
  }, this.IDLE_TIMEOUT_MS);
6485
6914
  if (this.idleTimer) safeUnref(this.idleTimer);
@@ -6493,7 +6922,20 @@ var init_embedder = __esm({
6493
6922
  async initialize() {
6494
6923
  if (this.state === "ready") return;
6495
6924
  if (this.state === "disposing") {
6496
- throw new Error("Cannot initialize while disposing");
6925
+ if (this.disposeReason === "idle") {
6926
+ logger.debug("[embedder] initialize() raced an idle dispose \u2014 waiting for it to finish, then re-initializing.");
6927
+ await this.disposePromise?.catch(() => {
6928
+ });
6929
+ if (this.state === "ready") return;
6930
+ if (this.disposeReason === "terminal") {
6931
+ throw new Error("Cannot initialize while disposing");
6932
+ }
6933
+ if (this.state === "disposing") {
6934
+ throw new Error("Cannot initialize while disposing");
6935
+ }
6936
+ } else {
6937
+ throw new Error("Cannot initialize while disposing");
6938
+ }
6497
6939
  }
6498
6940
  if (this.initializingPromise) {
6499
6941
  return this.initializingPromise;
@@ -6815,12 +7257,21 @@ var init_embedder = __esm({
6815
7257
  this.recoveryPromise = null;
6816
7258
  }
6817
7259
  }
6818
- async dispose() {
7260
+ /**
7261
+ * @param reason `'idle'` for the memory-reclaim pause driven by the idle timer —
7262
+ * a later embed is allowed to revive the embedder. Anything else is terminal
7263
+ * (shutdown, config change, explicit disposal) and must never be revived, so
7264
+ * the default is deliberately the safe one: callers that do not opt in get
7265
+ * terminal semantics.
7266
+ */
7267
+ async dispose(reason = "terminal") {
6819
7268
  if (this.state === "idle") return;
6820
7269
  this.stopIdleTimer();
6821
7270
  if (this.state === "disposing" && this.disposePromise) {
7271
+ if (reason === "terminal") this.disposeReason = "terminal";
6822
7272
  return this.disposePromise;
6823
7273
  }
7274
+ this.disposeReason = reason;
6824
7275
  this.state = "disposing";
6825
7276
  this.disposePromise = (async () => {
6826
7277
  const maxWaitMs = 5e3;
@@ -6865,8 +7316,21 @@ var init_embedder = __esm({
6865
7316
 
6866
7317
  // src/infrastructure/embedding/embedding-server.ts
6867
7318
  import * as http4 from "node:http";
7319
+ import * as crypto7 from "node:crypto";
6868
7320
  import * as path15 from "node:path";
6869
- var SerialQueue, EmbeddingServer;
7321
+ function getEmbeddingServerAuthSecret() {
7322
+ if (!_embeddingAuthSecret) {
7323
+ _embeddingAuthSecret = crypto7.randomBytes(32).toString("hex");
7324
+ }
7325
+ return _embeddingAuthSecret;
7326
+ }
7327
+ function isAuthorized(presented) {
7328
+ const header = Array.isArray(presented) ? presented[0] : presented;
7329
+ const expected = Buffer.from(getEmbeddingServerAuthSecret(), "utf8");
7330
+ const actual = Buffer.from(header ?? "", "utf8");
7331
+ return actual.length === expected.length && crypto7.timingSafeEqual(actual, expected);
7332
+ }
7333
+ var _embeddingAuthSecret, SerialQueue, EmbeddingServer;
6870
7334
  var init_embedding_server = __esm({
6871
7335
  "src/infrastructure/embedding/embedding-server.ts"() {
6872
7336
  "use strict";
@@ -6874,6 +7338,8 @@ var init_embedding_server = __esm({
6874
7338
  init_stdio_capture();
6875
7339
  init_log_utils();
6876
7340
  init_disk_space_checker();
7341
+ init_http_body();
7342
+ _embeddingAuthSecret = null;
6877
7343
  SerialQueue = class {
6878
7344
  running = false;
6879
7345
  tasks = [];
@@ -7176,12 +7642,18 @@ var init_embedding_server = __esm({
7176
7642
  }
7177
7643
  // ---- HTTP request handler ----
7178
7644
  async handleRequest(req, res) {
7645
+ if (!isAuthorized(req.headers["x-embedding-auth"])) {
7646
+ res.writeHead(403, { "Content-Type": "application/json" });
7647
+ res.end(JSON.stringify({ error: "Unauthorized" }));
7648
+ req.resume();
7649
+ return;
7650
+ }
7179
7651
  const MAX_BODY_SIZE = 50 * 1024 * 1024;
7180
- let body = "";
7652
+ const collected = new Utf8Body();
7181
7653
  let oversized = false;
7182
7654
  req.on("data", (chunk) => {
7183
- body += chunk;
7184
- if (body.length > MAX_BODY_SIZE) {
7655
+ collected.push(chunk);
7656
+ if (collected.byteLength > MAX_BODY_SIZE) {
7185
7657
  oversized = true;
7186
7658
  req.destroy(new Error("Payload too large"));
7187
7659
  }
@@ -7205,6 +7677,7 @@ var init_embedding_server = __esm({
7205
7677
  }
7206
7678
  finish();
7207
7679
  });
7680
+ req.on("close", finish);
7208
7681
  req.on("end", async () => {
7209
7682
  try {
7210
7683
  if (req.method === "GET" && req.url === "/health") {
@@ -7224,7 +7697,7 @@ var init_embedding_server = __esm({
7224
7697
  finish();
7225
7698
  return;
7226
7699
  }
7227
- const data = JSON.parse(body);
7700
+ const data = JSON.parse(collected.toString());
7228
7701
  switch (req.url) {
7229
7702
  case "/embed": {
7230
7703
  this.assertServing();
@@ -7274,17 +7747,24 @@ var init_embedding_client = __esm({
7274
7747
  "src/infrastructure/embedding/embedding-client.ts"() {
7275
7748
  "use strict";
7276
7749
  init_logger();
7750
+ init_http_body();
7277
7751
  EmbeddingClient = class {
7278
- constructor(port, _device = "unknown") {
7752
+ constructor(port, _device = "unknown", authSecret) {
7279
7753
  this.port = port;
7280
7754
  this._device = _device;
7755
+ this.authSecret = authSecret;
7281
7756
  logger.log(`[EmbeddingClient] Connecting to embedding server at http://127.0.0.1:${port}`);
7282
7757
  }
7283
7758
  port;
7284
7759
  _device;
7760
+ authSecret;
7285
7761
  // Public so store.ts can set the dimension from existing table schema
7286
7762
  // before the embedder has been fully warmed up (see Embedder.setDimension).
7287
7763
  dimension = null;
7764
+ /** Auth header for the leader, or nothing when connecting to a pre-auth leader. */
7765
+ authHeaders() {
7766
+ return this.authSecret ? { "x-embedding-auth": this.authSecret } : {};
7767
+ }
7288
7768
  // ---- IEmbedder ----
7289
7769
  getDevice() {
7290
7770
  return this._device;
@@ -7338,26 +7818,27 @@ var init_embedding_client = __esm({
7338
7818
  hostname: "127.0.0.1",
7339
7819
  port: this.port,
7340
7820
  path: path19,
7341
- method: "GET"
7821
+ method: "GET",
7822
+ headers: this.authHeaders()
7342
7823
  },
7343
7824
  (res) => {
7344
- let body = "";
7825
+ const collected = new Utf8Body();
7345
7826
  res.on("data", (chunk) => {
7346
- body += chunk;
7827
+ collected.push(chunk);
7347
7828
  });
7348
7829
  res.on("end", () => {
7349
7830
  if (resolved) return;
7350
7831
  resolved = true;
7351
7832
  clearTimeout(timer);
7352
7833
  try {
7353
- const parsed = JSON.parse(body);
7834
+ const parsed = JSON.parse(collected.toString());
7354
7835
  if (res.statusCode !== 200) {
7355
7836
  reject(new Error(`[EmbeddingClient] GET ${path19} returned HTTP ${res.statusCode}`));
7356
7837
  } else {
7357
7838
  resolve8(parsed);
7358
7839
  }
7359
7840
  } catch (_e) {
7360
- reject(new Error(`[EmbeddingClient] Failed to parse GET ${path19} response: ${body}`));
7841
+ reject(new Error(`[EmbeddingClient] Failed to parse GET ${path19} response: ${collected.toString()}`));
7361
7842
  }
7362
7843
  });
7363
7844
  res.on("error", (err) => {
@@ -7399,20 +7880,21 @@ var init_embedding_client = __esm({
7399
7880
  method: "POST",
7400
7881
  headers: {
7401
7882
  "Content-Type": "application/json",
7402
- "Content-Length": Buffer.byteLength(body)
7883
+ "Content-Length": Buffer.byteLength(body),
7884
+ ...this.authHeaders()
7403
7885
  }
7404
7886
  },
7405
7887
  (res) => {
7406
- let responseBody = "";
7888
+ const collected = new Utf8Body();
7407
7889
  res.on("data", (chunk) => {
7408
- responseBody += chunk;
7890
+ collected.push(chunk);
7409
7891
  });
7410
7892
  res.on("end", () => {
7411
7893
  if (resolved) return;
7412
7894
  resolved = true;
7413
7895
  clearTimeout(timer);
7414
7896
  try {
7415
- const parsed = JSON.parse(responseBody);
7897
+ const parsed = JSON.parse(collected.toString());
7416
7898
  if (res.statusCode !== 200) {
7417
7899
  const msg2 = parsed?.["error"];
7418
7900
  reject(new Error(typeof msg2 === "string" ? msg2 : `HTTP ${res.statusCode}`));
@@ -7420,7 +7902,7 @@ var init_embedding_client = __esm({
7420
7902
  resolve8(parsed);
7421
7903
  }
7422
7904
  } catch (_e) {
7423
- reject(new Error(`[EmbeddingClient] Failed to parse POST ${path19} response: ${responseBody}`));
7905
+ reject(new Error(`[EmbeddingClient] Failed to parse POST ${path19} response: ${collected.toString()}`));
7424
7906
  }
7425
7907
  });
7426
7908
  res.on("error", (err) => {
@@ -7446,7 +7928,7 @@ var init_embedding_client = __esm({
7446
7928
  });
7447
7929
 
7448
7930
  // src/infrastructure/embedding/embedding-factory.ts
7449
- import * as crypto7 from "node:crypto";
7931
+ import * as crypto8 from "node:crypto";
7450
7932
  import * as net2 from "node:net";
7451
7933
  async function isPortListening2(port, timeoutMs = 2e3) {
7452
7934
  return new Promise((resolve8) => {
@@ -7491,13 +7973,14 @@ async function getEmbedder(config, _attempt = 0) {
7491
7973
  return _embeddingInstance;
7492
7974
  }
7493
7975
  logger.info(`[EmbeddingFactory] Configuration change detected (${_cachedModel} on ${_cachedDevice} -> ${cfg.EMBEDDING_MODEL} on ${cfg.EMBEDDING_DEVICE}). Disposing stale instance.`);
7494
- await _embeddingInstance.dispose?.();
7976
+ const stale = _embeddingInstance;
7495
7977
  _embeddingInstance = null;
7978
+ await stale.dispose?.();
7496
7979
  }
7497
- if (_embeddingInitPromise) return _embeddingInitPromise;
7980
+ if (_embeddingInitPromise && _attempt === 0) return _embeddingInitPromise;
7498
7981
  let p;
7499
7982
  const init = async () => {
7500
- const serverId = crypto7.randomUUID();
7983
+ const serverId = crypto8.randomUUID();
7501
7984
  const stateManager = await getService(ServiceNames.STATE_MANAGER);
7502
7985
  const processLifecycle = await getService(ServiceNames.PROCESS_LIFECYCLE);
7503
7986
  const myStartTime = await processLifecycle.getCurrentProcessStartTime();
@@ -7519,6 +8002,9 @@ async function getEmbedder(config, _attempt = 0) {
7519
8002
  pid: process.pid,
7520
8003
  serverId,
7521
8004
  model: cfg.EMBEDDING_MODEL,
8005
+ // Published with the claim, not with the port, so a follower that adopts
8006
+ // this leader always has the secret available whenever the port is.
8007
+ authSecret: getEmbeddingServerAuthSecret(),
7522
8008
  ...myStartTime !== null ? { startTime: myStartTime } : {}
7523
8009
  };
7524
8010
  iAmCandidate = true;
@@ -7559,7 +8045,7 @@ async function getEmbedder(config, _attempt = 0) {
7559
8045
  const portOk = await isPortListening2(info.port);
7560
8046
  if (portOk) {
7561
8047
  logger.info(`[EmbeddingFactory] Connecting to embedding server on port ${info.port}`);
7562
- const client = new EmbeddingClient(info.port);
8048
+ const client = new EmbeddingClient(info.port, cfg.EMBEDDING_DEVICE, info.authSecret);
7563
8049
  await client.fetchHealth();
7564
8050
  _embeddingInstance = client;
7565
8051
  _cachedModel = cfg.EMBEDDING_MODEL;
@@ -7575,7 +8061,6 @@ async function getEmbedder(config, _attempt = 0) {
7575
8061
  }
7576
8062
  }
7577
8063
  }
7578
- _embeddingInitPromise = null;
7579
8064
  if (_attempt + 1 >= MAX_LEADER_WAIT_RETRIES) {
7580
8065
  throw new Error(
7581
8066
  `[EmbeddingFactory] Could not obtain an embedding server after ${MAX_LEADER_WAIT_RETRIES} attempts (no process became leader; model init may be failing repeatedly).`
@@ -7610,11 +8095,10 @@ async function getEmbedder(config, _attempt = 0) {
7610
8095
  };
7611
8096
  p = init();
7612
8097
  _embeddingInitPromise = p;
7613
- p.then(() => {
7614
- _embeddingInitPromise = null;
7615
- }).catch(() => {
7616
- _embeddingInitPromise = null;
7617
- });
8098
+ const clearIfCurrent = () => {
8099
+ if (_embeddingInitPromise === p) _embeddingInitPromise = null;
8100
+ };
8101
+ p.then(clearIfCurrent).catch(clearIfCurrent);
7618
8102
  return p;
7619
8103
  }
7620
8104
  async function clearEmbeddingInstance() {
@@ -8039,7 +8523,7 @@ function resolveResearchModel(options) {
8039
8523
  if (anyModel.length > 0) {
8040
8524
  return pickPreferredAvailable(anyModel, readModelsJsonProviderOrder());
8041
8525
  }
8042
- throw new Error("No LLM model available for research. Please configure your model registry (~/.pi/agent/models.json).");
8526
+ throw new Error(buildNoModelAvailableMessage());
8043
8527
  }
8044
8528
  var init_research_model_resolver = __esm({
8045
8529
  "src/core/llm/research-model-resolver.ts"() {
@@ -8341,7 +8825,7 @@ async function runBackgroundExtraction(model, auth, conversationHistory, queries
8341
8825
  const delay2 = KNOWLEDGE_SYNTHESIS_RETRY_BASE_MS * attempt;
8342
8826
  metrics.increment("research_knowledge_search_synthesis_retries_total", 1);
8343
8827
  logger.warn(`[research-knowledge-search] Synthesis attempt ${attempt}/${KNOWLEDGE_SYNTHESIS_MAX_ATTEMPTS} failed transiently (${msg2}); retrying in ${delay2}ms`);
8344
- await abortableDelay(delay2, signal);
8828
+ await abortableDelay(delay2, signal, true);
8345
8829
  }
8346
8830
  }
8347
8831
  const extracted = extractJson(responseText, "object");
@@ -8429,7 +8913,7 @@ ${desc}`;
8429
8913
  const delay2 = KNOWLEDGE_SYNTHESIS_RETRY_BASE_MS * attempt;
8430
8914
  metrics.increment("research_knowledge_search_triage_retries_total", 1);
8431
8915
  logger.warn(`[research-knowledge-search] Triage attempt ${attempt}/${KNOWLEDGE_SYNTHESIS_MAX_ATTEMPTS} failed transiently (${msg2}); retrying in ${delay2}ms`);
8432
- await abortableDelay(delay2, signal);
8916
+ await abortableDelay(delay2, signal, true);
8433
8917
  }
8434
8918
  }
8435
8919
  const extracted = extractJson(responseText, "object");
@@ -8642,6 +9126,13 @@ function createResearchKnowledgeSearchTool(iface) {
8642
9126
  return buildSteeringResult(result, urls);
8643
9127
  } catch (error) {
8644
9128
  const durationMs = Date.now() - startTime;
9129
+ const msg2 = error instanceof Error ? error.message : String(error);
9130
+ if (signal?.aborted || error instanceof Error && error.name === "AbortError" || /^(aborted|research (aborted|cancelled))$/i.test(msg2)) {
9131
+ metrics.observe("research_knowledge_search_duration_ms", durationMs, { status: "cancelled" });
9132
+ metrics.increment("research_knowledge_search_total", 1, { status: "cancelled" });
9133
+ logger.debug("[research-knowledge-search] Cancelled mid-search \u2014 propagating rather than reporting an empty store.");
9134
+ throw error;
9135
+ }
8645
9136
  metrics.observe("research_knowledge_search_duration_ms", durationMs, { status: "error" });
8646
9137
  metrics.increment("research_knowledge_search_total", 1, { status: "error" });
8647
9138
  logger.error("[research-knowledge-search] Tool execution failed:", error);
@@ -8712,10 +9203,19 @@ __export(skill_installer_exports, {
8712
9203
  skillUninstallCandidates: () => skillUninstallCandidates,
8713
9204
  uninstallSkill: () => uninstallSkill
8714
9205
  });
8715
- import * as fs15 from "node:fs";
9206
+ import * as crypto10 from "node:crypto";
9207
+ import * as fs16 from "node:fs";
8716
9208
  import * as os6 from "node:os";
8717
9209
  import * as path17 from "node:path";
8718
9210
  import { fileURLToPath as fileURLToPath4 } from "node:url";
9211
+ function readSkillVersion(sourceDir) {
9212
+ try {
9213
+ const md = fs16.readFileSync(path17.join(sourceDir, "SKILL.md"), "utf-8");
9214
+ return /"version"\s*:\s*"([^"]+)"/.exec(md)?.[1] ?? null;
9215
+ } catch {
9216
+ return null;
9217
+ }
9218
+ }
8719
9219
  function homeDir(opts) {
8720
9220
  return opts?.home ?? os6.homedir();
8721
9221
  }
@@ -8742,7 +9242,7 @@ function resolveSkillSourceDir(explicit) {
8742
9242
  }
8743
9243
  for (const c of candidates) {
8744
9244
  try {
8745
- if (fs15.existsSync(path17.join(c, "SKILL.md"))) return path17.resolve(c);
9245
+ if (fs16.existsSync(path17.join(c, "SKILL.md"))) return path17.resolve(c);
8746
9246
  } catch {
8747
9247
  }
8748
9248
  }
@@ -8754,8 +9254,8 @@ function getManifestPath(opts) {
8754
9254
  function readManifest(opts) {
8755
9255
  const p = getManifestPath(opts);
8756
9256
  try {
8757
- if (fs15.existsSync(p)) {
8758
- const parsed = JSON.parse(fs15.readFileSync(p, "utf-8"));
9257
+ if (fs16.existsSync(p)) {
9258
+ const parsed = JSON.parse(fs16.readFileSync(p, "utf-8"));
8759
9259
  if (parsed && Array.isArray(parsed.entries)) {
8760
9260
  return { version: 1, package: PACKAGE_NAME, entries: parsed.entries };
8761
9261
  }
@@ -8766,10 +9266,10 @@ function readManifest(opts) {
8766
9266
  }
8767
9267
  function writeManifest(manifest, opts) {
8768
9268
  const p = getManifestPath(opts);
8769
- fs15.mkdirSync(path17.dirname(p), { recursive: true });
9269
+ fs16.mkdirSync(path17.dirname(p), { recursive: true });
8770
9270
  const tmp = `${p}.tmp`;
8771
- fs15.writeFileSync(tmp, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
8772
- fs15.renameSync(tmp, p);
9271
+ fs16.writeFileSync(tmp, JSON.stringify(manifest, null, 2) + "\n", "utf-8");
9272
+ fs16.renameSync(tmp, p);
8773
9273
  }
8774
9274
  function upsertEntry(manifest, entry) {
8775
9275
  const idx = manifest.entries.findIndex((e) => e.path === entry.path);
@@ -8778,9 +9278,9 @@ function upsertEntry(manifest, entry) {
8778
9278
  }
8779
9279
  function isOwnedSymlink(targetPath, expectedSource) {
8780
9280
  try {
8781
- const st = fs15.lstatSync(targetPath);
9281
+ const st = fs16.lstatSync(targetPath);
8782
9282
  if (!st.isSymbolicLink()) return false;
8783
- const dest = path17.resolve(path17.dirname(targetPath), fs15.readlinkSync(targetPath));
9283
+ const dest = path17.resolve(path17.dirname(targetPath), fs16.readlinkSync(targetPath));
8784
9284
  if (expectedSource && path17.resolve(expectedSource) === dest) return true;
8785
9285
  return /[/\\]pi-research[/\\]skills[/\\]pi-research$/.test(dest);
8786
9286
  } catch {
@@ -8789,7 +9289,7 @@ function isOwnedSymlink(targetPath, expectedSource) {
8789
9289
  }
8790
9290
  function isOwnedCopy(targetPath) {
8791
9291
  try {
8792
- const md = fs15.readFileSync(path17.join(targetPath, "SKILL.md"), "utf-8");
9292
+ const md = fs16.readFileSync(path17.join(targetPath, "SKILL.md"), "utf-8");
8793
9293
  return md.includes(PACKAGE_NAME);
8794
9294
  } catch {
8795
9295
  return false;
@@ -8802,12 +9302,12 @@ function detectHarnesses(opts) {
8802
9302
  const absSkillPath = path17.join(absSkillsDir, SKILL_NAME);
8803
9303
  let present = false;
8804
9304
  try {
8805
- present = fs15.existsSync(path17.join(home, h.baseDir));
9305
+ present = fs16.existsSync(path17.join(home, h.baseDir));
8806
9306
  } catch {
8807
9307
  }
8808
9308
  let installed = "none";
8809
9309
  try {
8810
- if (fs15.existsSync(absSkillPath) || isSymlinkPresent(absSkillPath)) {
9310
+ if (fs16.existsSync(absSkillPath) || isSymlinkPresent(absSkillPath)) {
8811
9311
  if (isOwnedSymlink(absSkillPath)) installed = "owned-symlink";
8812
9312
  else if (isOwnedCopy(absSkillPath)) installed = "owned-copy";
8813
9313
  else installed = "foreign";
@@ -8819,7 +9319,7 @@ function detectHarnesses(opts) {
8819
9319
  }
8820
9320
  function isSymlinkPresent(p) {
8821
9321
  try {
8822
- return fs15.lstatSync(p).isSymbolicLink();
9322
+ return fs16.lstatSync(p).isSymbolicLink();
8823
9323
  } catch {
8824
9324
  return false;
8825
9325
  }
@@ -8836,6 +9336,7 @@ function installSkill(toolIds, opts = {}) {
8836
9336
  const manifest = readManifest(opts);
8837
9337
  const results = [];
8838
9338
  const now = (/* @__PURE__ */ new Date()).toISOString();
9339
+ const skillVersion = readSkillVersion(source);
8839
9340
  for (const id of toolIds) {
8840
9341
  const def = HARNESSES.find((h) => h.id === id);
8841
9342
  if (!def) {
@@ -8847,13 +9348,17 @@ function installSkill(toolIds, opts = {}) {
8847
9348
  const type = opts.copy ? "copy" : "symlink";
8848
9349
  if (isOwnedSymlink(absSkillPath, source) || isOwnedCopy(absSkillPath)) {
8849
9350
  try {
8850
- const lst = fs15.lstatSync(absSkillPath);
9351
+ const lst = fs16.lstatSync(absSkillPath);
8851
9352
  if (lst.isSymbolicLink()) {
8852
- const dest = path17.resolve(path17.dirname(absSkillPath), fs15.readlinkSync(absSkillPath));
9353
+ const dest = path17.resolve(path17.dirname(absSkillPath), fs16.readlinkSync(absSkillPath));
8853
9354
  if (dest !== path17.resolve(source)) {
8854
- fs15.unlinkSync(absSkillPath);
8855
- fs15.symlinkSync(source, absSkillPath, process.platform === "win32" ? "junction" : "dir");
8856
- upsertEntry(manifest, { tool: id, path: absSkillPath, type: "symlink", source, createdAt: now });
9355
+ if (opts.dryRun) {
9356
+ results.push({ tool: id, path: absSkillPath, type: "symlink", status: "planned", message: "would re-point stale symlink to current source" });
9357
+ continue;
9358
+ }
9359
+ fs16.unlinkSync(absSkillPath);
9360
+ fs16.symlinkSync(source, absSkillPath, process.platform === "win32" ? "junction" : "dir");
9361
+ upsertEntry(manifest, { tool: id, path: absSkillPath, type: "symlink", source, createdAt: now, ...skillVersion ? { version: skillVersion } : {} });
8857
9362
  results.push({ tool: id, path: absSkillPath, type: "symlink", status: "installed", message: "re-pointed stale symlink to current source" });
8858
9363
  continue;
8859
9364
  }
@@ -8861,10 +9366,10 @@ function installSkill(toolIds, opts = {}) {
8861
9366
  } catch {
8862
9367
  }
8863
9368
  results.push({ tool: id, path: absSkillPath, type, status: "already-installed" });
8864
- upsertEntry(manifest, { tool: id, path: absSkillPath, type: isSymlinkPresent(absSkillPath) ? "symlink" : "copy", source, createdAt: now });
9369
+ upsertEntry(manifest, { tool: id, path: absSkillPath, type: isSymlinkPresent(absSkillPath) ? "symlink" : "copy", source, createdAt: now, ...skillVersion ? { version: skillVersion } : {} });
8865
9370
  continue;
8866
9371
  }
8867
- if (fs15.existsSync(absSkillPath) || isSymlinkPresent(absSkillPath)) {
9372
+ if (fs16.existsSync(absSkillPath) || isSymlinkPresent(absSkillPath)) {
8868
9373
  results.push({ tool: id, path: absSkillPath, type, status: "skipped-foreign", message: 'a non-pi-research "research" skill already exists here; left untouched' });
8869
9374
  continue;
8870
9375
  }
@@ -8873,20 +9378,20 @@ function installSkill(toolIds, opts = {}) {
8873
9378
  continue;
8874
9379
  }
8875
9380
  try {
8876
- fs15.mkdirSync(absSkillsDir, { recursive: true });
9381
+ fs16.mkdirSync(absSkillsDir, { recursive: true });
8877
9382
  if (type === "symlink") {
8878
9383
  try {
8879
- fs15.symlinkSync(source, absSkillPath, process.platform === "win32" ? "junction" : "dir");
9384
+ fs16.symlinkSync(source, absSkillPath, process.platform === "win32" ? "junction" : "dir");
8880
9385
  } catch (err) {
8881
9386
  copyDir(source, absSkillPath);
8882
- upsertEntry(manifest, { tool: id, path: absSkillPath, type: "copy", source, createdAt: now });
9387
+ upsertEntry(manifest, { tool: id, path: absSkillPath, type: "copy", source, createdAt: now, ...skillVersion ? { version: skillVersion } : {} });
8883
9388
  results.push({ tool: id, path: absSkillPath, type: "copy", status: "installed", message: `symlink unavailable (${err?.code ?? err?.message}); copied instead` });
8884
9389
  continue;
8885
9390
  }
8886
9391
  } else {
8887
9392
  copyDir(source, absSkillPath);
8888
9393
  }
8889
- upsertEntry(manifest, { tool: id, path: absSkillPath, type, source, createdAt: now });
9394
+ upsertEntry(manifest, { tool: id, path: absSkillPath, type, source, createdAt: now, ...skillVersion ? { version: skillVersion } : {} });
8890
9395
  results.push({ tool: id, path: absSkillPath, type, status: "installed" });
8891
9396
  } catch (err) {
8892
9397
  results.push({ tool: id, path: absSkillPath, type, status: "error", message: err?.message ?? String(err) });
@@ -8913,7 +9418,7 @@ function uninstallSkill(toolIds, opts = {}) {
8913
9418
  continue;
8914
9419
  }
8915
9420
  const owned = isOwnedSymlink(skillPath) || isOwnedCopy(skillPath);
8916
- const present = fs15.existsSync(skillPath) || isSymlinkPresent(skillPath);
9421
+ const present = fs16.existsSync(skillPath) || isSymlinkPresent(skillPath);
8917
9422
  if (!present) {
8918
9423
  results.push({ tool, path: skillPath, status: "not-present" });
8919
9424
  continue;
@@ -8931,8 +9436,8 @@ function uninstallSkill(toolIds, opts = {}) {
8931
9436
  continue;
8932
9437
  }
8933
9438
  try {
8934
- if (isSymlinkPresent(skillPath)) fs15.unlinkSync(skillPath);
8935
- else fs15.rmSync(skillPath, { recursive: true, force: true });
9439
+ if (isSymlinkPresent(skillPath)) fs16.unlinkSync(skillPath);
9440
+ else fs16.rmSync(skillPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
8936
9441
  results.push({ tool, path: skillPath, status: "removed" });
8937
9442
  } catch (err) {
8938
9443
  results.push({ tool, path: skillPath, status: "error", message: err?.message ?? String(err) });
@@ -8945,7 +9450,7 @@ function uninstallSkill(toolIds, opts = {}) {
8945
9450
  writeManifest({ version: 1, package: PACKAGE_NAME, entries: keep }, opts);
8946
9451
  } else {
8947
9452
  try {
8948
- fs15.rmSync(getManifestPath(opts));
9453
+ fs16.rmSync(getManifestPath(opts));
8949
9454
  } catch {
8950
9455
  }
8951
9456
  }
@@ -8953,7 +9458,7 @@ function uninstallSkill(toolIds, opts = {}) {
8953
9458
  return results;
8954
9459
  }
8955
9460
  function reconcileSkillInstalls(opts = {}) {
8956
- const result = { pruned: [], repointed: [] };
9461
+ const result = { pruned: [], repointed: [], refreshed: [] };
8957
9462
  const manifest = readManifest(opts);
8958
9463
  if (manifest.entries.length === 0) return result;
8959
9464
  let source;
@@ -8962,10 +9467,68 @@ function reconcileSkillInstalls(opts = {}) {
8962
9467
  } catch {
8963
9468
  source = null;
8964
9469
  }
9470
+ const currentVersion = source !== null ? readSkillVersion(source) : null;
8965
9471
  const keep = [];
8966
9472
  for (const e of manifest.entries) {
8967
9473
  if (e.type !== "symlink") {
8968
- keep.push(e);
9474
+ const stale2 = currentVersion !== null && e.version !== currentVersion;
9475
+ if (stale2 && source !== null && fs16.existsSync(e.path) && isOwnedCopy(e.path)) {
9476
+ const dir = path17.dirname(e.path);
9477
+ const base = path17.basename(e.path);
9478
+ try {
9479
+ for (const name of fs16.readdirSync(dir)) {
9480
+ if (name.startsWith(`.${base}.new-`) || name.startsWith(`.${base}.old-`)) {
9481
+ fs16.rmSync(path17.join(dir, name), { recursive: true, force: true });
9482
+ }
9483
+ }
9484
+ } catch {
9485
+ }
9486
+ const suffix = crypto10.randomBytes(4).toString("hex");
9487
+ const fresh = path17.join(dir, `.${base}.new-${suffix}`);
9488
+ const retired = path17.join(dir, `.${base}.old-${suffix}`);
9489
+ try {
9490
+ copyDir(source, fresh);
9491
+ } catch {
9492
+ try {
9493
+ fs16.rmSync(fresh, { recursive: true, force: true });
9494
+ } catch {
9495
+ }
9496
+ keep.push(e);
9497
+ continue;
9498
+ }
9499
+ try {
9500
+ fs16.renameSync(e.path, retired);
9501
+ } catch {
9502
+ try {
9503
+ fs16.rmSync(fresh, { recursive: true, force: true });
9504
+ } catch {
9505
+ }
9506
+ keep.push(e);
9507
+ continue;
9508
+ }
9509
+ try {
9510
+ fs16.renameSync(fresh, e.path);
9511
+ } catch {
9512
+ try {
9513
+ fs16.renameSync(retired, e.path);
9514
+ } catch {
9515
+ }
9516
+ try {
9517
+ fs16.rmSync(fresh, { recursive: true, force: true });
9518
+ } catch {
9519
+ }
9520
+ keep.push(e);
9521
+ continue;
9522
+ }
9523
+ try {
9524
+ fs16.rmSync(retired, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
9525
+ } catch {
9526
+ }
9527
+ keep.push({ ...e, source, version: currentVersion });
9528
+ result.refreshed.push(e.path);
9529
+ } else {
9530
+ keep.push(e);
9531
+ }
8969
9532
  continue;
8970
9533
  }
8971
9534
  if (!isSymlinkPresent(e.path)) {
@@ -8976,18 +9539,18 @@ function reconcileSkillInstalls(opts = {}) {
8976
9539
  keep.push(e);
8977
9540
  continue;
8978
9541
  }
8979
- const targetLive = fs15.existsSync(e.path);
9542
+ const targetLive = fs16.existsSync(e.path);
8980
9543
  let dest = null;
8981
9544
  try {
8982
- dest = path17.resolve(path17.dirname(e.path), fs15.readlinkSync(e.path));
9545
+ dest = path17.resolve(path17.dirname(e.path), fs16.readlinkSync(e.path));
8983
9546
  } catch {
8984
9547
  }
8985
9548
  const stale = source !== null && dest !== null && dest !== path17.resolve(source);
8986
9549
  if (!targetLive || stale) {
8987
9550
  if (source !== null) {
8988
9551
  try {
8989
- fs15.unlinkSync(e.path);
8990
- fs15.symlinkSync(source, e.path, process.platform === "win32" ? "junction" : "dir");
9552
+ fs16.unlinkSync(e.path);
9553
+ fs16.symlinkSync(source, e.path, process.platform === "win32" ? "junction" : "dir");
8991
9554
  keep.push({ ...e, type: "symlink", source });
8992
9555
  result.repointed.push(e.path);
8993
9556
  } catch {
@@ -8995,7 +9558,7 @@ function reconcileSkillInstalls(opts = {}) {
8995
9558
  }
8996
9559
  } else if (!targetLive) {
8997
9560
  try {
8998
- fs15.unlinkSync(e.path);
9561
+ fs16.unlinkSync(e.path);
8999
9562
  result.pruned.push(e.path);
9000
9563
  } catch {
9001
9564
  keep.push(e);
@@ -9010,14 +9573,22 @@ function reconcileSkillInstalls(opts = {}) {
9010
9573
  if (keep.length > 0) writeManifest({ version: 1, package: PACKAGE_NAME, entries: keep }, opts);
9011
9574
  else {
9012
9575
  try {
9013
- fs15.rmSync(getManifestPath(opts));
9576
+ fs16.rmSync(getManifestPath(opts));
9014
9577
  } catch {
9015
9578
  }
9016
9579
  }
9017
9580
  return result;
9018
9581
  }
9019
9582
  function copyDir(src, dest) {
9020
- fs15.cpSync(src, dest, { recursive: true, dereference: true });
9583
+ const st = fs16.statSync(src);
9584
+ if (st.isDirectory()) {
9585
+ fs16.mkdirSync(dest, { recursive: true });
9586
+ for (const name of fs16.readdirSync(src)) {
9587
+ copyDir(path17.join(src, name), path17.join(dest, name));
9588
+ }
9589
+ } else {
9590
+ fs16.copyFileSync(src, dest);
9591
+ }
9021
9592
  }
9022
9593
  var HARNESSES, SKILL_NAME, PACKAGE_NAME, SKILL_AGENT_TARGETS, isTarget;
9023
9594
  var init_skill_installer = __esm({
@@ -9039,7 +9610,8 @@ var init_skill_installer = __esm({
9039
9610
  });
9040
9611
 
9041
9612
  // src/cli.ts
9042
- import * as fs16 from "node:fs";
9613
+ import * as fs17 from "node:fs";
9614
+ import * as os7 from "node:os";
9043
9615
  import * as path18 from "node:path";
9044
9616
  import { fileURLToPath as fileURLToPath5 } from "node:url";
9045
9617
 
@@ -9554,27 +10126,63 @@ function normalizeCitations(reports) {
9554
10126
  const localKey = cit.number ?? index + 1;
9555
10127
  localToGlobal.set(localKey, globalId);
9556
10128
  }
9557
- });
9558
- const headerIdx = lastCitedLinksHeaderIndex(report);
9559
- let content = headerIdx >= 0 ? report.slice(0, headerIdx) : report;
9560
- content = content.replace(/\[(\d+)\]/g, (match, p1) => {
9561
- const localId = parseInt(p1, 10);
9562
- const globalId = localToGlobal.get(localId);
9563
- return globalId !== void 0 ? `[${globalId}]` : match;
9564
- });
9565
- normalizedReports.set(id, content.trim());
10129
+ });
10130
+ const headerIdx = lastCitedLinksHeaderIndex(report);
10131
+ let content = headerIdx >= 0 ? report.slice(0, headerIdx) : report;
10132
+ content = rewriteCitationMarkers(
10133
+ content,
10134
+ localToGlobal,
10135
+ (n) => !localToGlobal.has(n)
10136
+ );
10137
+ normalizedReports.set(id, content.trim());
10138
+ }
10139
+ return { normalizedReports, globalCitations };
10140
+ }
10141
+ function formatCitedLinks(citations) {
10142
+ if (citations.length === 0) return "";
10143
+ const links = citations.map((cit) => {
10144
+ const sourcePart = cit.source ? ` [Source: ${cit.source}]` : "";
10145
+ const descPart = cit.description ? ` \u2014 ${cit.description}` : "";
10146
+ return `[${cit.id}] ${cit.url}${sourcePart}${descPart}`;
10147
+ });
10148
+ return `CITED LINKS
10149
+ ${links.join("\n")}`;
10150
+ }
10151
+ var MAX_PLAUSIBLE_CITATION_MARKER = 200;
10152
+ function rewriteCitationMarkers(text, remap, isDangling) {
10153
+ const inFence = fencedCodeMask(text);
10154
+ return text.replace(/([ \t]*)\[(\d+)\]/g, (match, pad, digits, offset) => {
10155
+ const n = parseInt(digits, 10);
10156
+ const mapped = remap.get(n);
10157
+ if (mapped !== void 0) return `${pad}[${mapped}]`;
10158
+ if (n < 1 || n > MAX_PLAUSIBLE_CITATION_MARKER || !isDangling(n)) return match;
10159
+ if (inFence(offset) || inInlineCodeSpan(text, offset)) return match;
10160
+ const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
10161
+ return text.slice(lineStart, offset).trim() === "" ? pad : "";
10162
+ });
10163
+ }
10164
+ function fencedCodeMask(text) {
10165
+ const ranges = [];
10166
+ let open5 = -1;
10167
+ let pos = 0;
10168
+ for (const line of text.split("\n")) {
10169
+ if (/^\s{0,3}(```|~~~)/.test(line)) {
10170
+ if (open5 < 0) open5 = pos;
10171
+ else {
10172
+ ranges.push([open5, pos + line.length]);
10173
+ open5 = -1;
10174
+ }
10175
+ }
10176
+ pos += line.length + 1;
9566
10177
  }
9567
- return { normalizedReports, globalCitations };
10178
+ if (open5 >= 0) ranges.push([open5, text.length]);
10179
+ return (offset) => ranges.some(([start, end]) => offset >= start && offset < end);
9568
10180
  }
9569
- function formatCitedLinks(citations) {
9570
- if (citations.length === 0) return "";
9571
- const links = citations.map((cit) => {
9572
- const sourcePart = cit.source ? ` [Source: ${cit.source}]` : "";
9573
- const descPart = cit.description ? ` \u2014 ${cit.description}` : "";
9574
- return `[${cit.id}] ${cit.url}${sourcePart}${descPart}`;
9575
- });
9576
- return `CITED LINKS
9577
- ${links.join("\n")}`;
10181
+ function inInlineCodeSpan(text, offset) {
10182
+ const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
10183
+ let ticks = 0;
10184
+ for (let i = lineStart; i < offset; i++) if (text[i] === "`") ticks++;
10185
+ return ticks % 2 === 1;
9578
10186
  }
9579
10187
 
9580
10188
  // src/core/planning-service.ts
@@ -10463,11 +11071,11 @@ import * as path7 from "node:path";
10463
11071
  init_config();
10464
11072
  init_logger();
10465
11073
  import * as crypto2 from "node:crypto";
10466
- import { join as join6 } from "node:path";
11074
+ import { join as join7 } from "node:path";
10467
11075
  import { existsSync as existsSync5, mkdirSync as mkdirSync3 } from "node:fs";
10468
11076
  import { platform, homedir as homedir2 } from "node:os";
10469
11077
  function getWindowsCamoufoxDir() {
10470
- return join6(homedir2(), "AppData", "Local", "camoufox", "camoufox", "Cache");
11078
+ return join7(homedir2(), "AppData", "Local", "camoufox", "camoufox", "Cache");
10471
11079
  }
10472
11080
  function getBrowserCacheDir() {
10473
11081
  if (process.env["PLAYWRIGHT_BROWSERS_PATH"]) {
@@ -10477,10 +11085,10 @@ function getBrowserCacheDir() {
10477
11085
  if (osPlatform === "win32") {
10478
11086
  return getWindowsCamoufoxDir();
10479
11087
  } else if (osPlatform === "darwin") {
10480
- return join6(homedir2(), "Library", "Caches", "camoufox");
11088
+ return join7(homedir2(), "Library", "Caches", "camoufox");
10481
11089
  } else {
10482
- const cacheHome = process.env["XDG_CACHE_HOME"] || join6(homedir2(), ".cache");
10483
- return join6(cacheHome, "camoufox");
11090
+ const cacheHome = process.env["XDG_CACHE_HOME"] || join7(homedir2(), ".cache");
11091
+ return join7(cacheHome, "camoufox");
10484
11092
  }
10485
11093
  }
10486
11094
  function getBrowserProfileDir(config) {
@@ -10489,8 +11097,8 @@ function getBrowserProfileDir(config) {
10489
11097
  if (configured && configured.trim().length > 0) {
10490
11098
  dir = configured;
10491
11099
  } else {
10492
- const cacheHome = process.env["XDG_CACHE_HOME"] || join6(homedir2(), ".cache");
10493
- dir = join6(cacheHome, "pi-research", "profiles");
11100
+ const cacheHome = process.env["XDG_CACHE_HOME"] || join7(homedir2(), ".cache");
11101
+ dir = join7(cacheHome, "pi-research", "profiles");
10494
11102
  }
10495
11103
  if (!existsSync5(dir)) {
10496
11104
  try {
@@ -10543,10 +11151,10 @@ function getCamoufoxBinaryPath() {
10543
11151
  if (osPlatform === "win32") {
10544
11152
  return getWindowsCamoufoxDir();
10545
11153
  } else if (osPlatform === "darwin") {
10546
- return join6(homedir2(), "Library", "Caches", "camoufox");
11154
+ return join7(homedir2(), "Library", "Caches", "camoufox");
10547
11155
  } else {
10548
- const cacheHome = process.env["XDG_CACHE_HOME"] || join6(homedir2(), ".cache");
10549
- return join6(cacheHome, "camoufox");
11156
+ const cacheHome = process.env["XDG_CACHE_HOME"] || join7(homedir2(), ".cache");
11157
+ return join7(cacheHome, "camoufox");
10550
11158
  }
10551
11159
  }
10552
11160
  function generateSchedulerVersion(config) {
@@ -10584,6 +11192,7 @@ function isFullMockMode() {
10584
11192
  // src/infrastructure/browser/browser-client.ts
10585
11193
  init_logger();
10586
11194
  init_error_tracker();
11195
+ init_http_body();
10587
11196
  import * as http2 from "node:http";
10588
11197
 
10589
11198
  // src/infrastructure/browser/client-agent.ts
@@ -10593,7 +11202,7 @@ var clientAgent = new http.Agent({
10593
11202
  maxSockets: 100,
10594
11203
  // Allow up to 100 concurrent requests to the leader
10595
11204
  maxFreeSockets: 10,
10596
- // Set timeout to 180s (3x the client timeout) to handle slow browser responses
11205
+ // 180s (3x the client timeout) to handle slow browser responses
10597
11206
  // and prevent "socket hang up" errors during peak load
10598
11207
  timeout: 18e4
10599
11208
  });
@@ -10610,6 +11219,12 @@ var BrowserClient = class {
10610
11219
  }
10611
11220
  port;
10612
11221
  authSecret;
11222
+ /** The leader port this client was built for — lets the herd-guard cooldown
11223
+ * verify the cached handle still targets the REGISTERED leader, not a
11224
+ * predecessor that died inside the cooldown window. */
11225
+ get targetPort() {
11226
+ return this.port;
11227
+ }
10613
11228
  async request(path19, data, signal) {
10614
11229
  const start = Date.now();
10615
11230
  const operation = path19.includes("/search") ? "search" : path19.includes("/scrape") ? "browser-task" : path19.includes("/healthcheck") ? "healthcheck" : "network";
@@ -10660,8 +11275,8 @@ var BrowserClient = class {
10660
11275
  "X-Browser-Auth": this.authSecret
10661
11276
  }
10662
11277
  }, (res) => {
10663
- let body = "";
10664
- res.on("data", (chunk) => body += chunk);
11278
+ const collected = new Utf8Body();
11279
+ res.on("data", (chunk) => collected.push(chunk));
10665
11280
  res.on("end", () => {
10666
11281
  if (resolved) return;
10667
11282
  resolved = true;
@@ -10669,7 +11284,7 @@ var BrowserClient = class {
10669
11284
  abortCleanup?.();
10670
11285
  const duration = Date.now() - start;
10671
11286
  try {
10672
- const parsed = JSON.parse(body);
11287
+ const parsed = JSON.parse(collected.toString());
10673
11288
  if (res.statusCode === 503 && parsed?.error === "draining") {
10674
11289
  const error = new Error(
10675
11290
  `Browser pool leader is draining (${parsed.reason || "leadership-lost"}) \u2014 re-elect and retry`
@@ -10694,7 +11309,8 @@ var BrowserClient = class {
10694
11309
  resolve8(parsed);
10695
11310
  }
10696
11311
  } catch (_e) {
10697
- const preview = body.length > 200 ? body.slice(0, 200) + "..." : body;
11312
+ const raw = collected.toString();
11313
+ const preview = raw.length > 200 ? raw.slice(0, 200) + "..." : raw;
10698
11314
  const error = new Error(`Failed to parse response (status ${res.statusCode}): ${preview}`);
10699
11315
  errorTracker.trackError(error, {
10700
11316
  component: "browser-manager",
@@ -10776,6 +11392,7 @@ init_service_interfaces();
10776
11392
 
10777
11393
  // src/infrastructure/browser/browser-server.ts
10778
11394
  init_logger();
11395
+ init_http_body();
10779
11396
  import * as http3 from "node:http";
10780
11397
  import * as crypto3 from "node:crypto";
10781
11398
 
@@ -10813,7 +11430,16 @@ function isServerDrainingError(error) {
10813
11430
  function isPoolShutdownError(error) {
10814
11431
  if (!error || typeof error !== "object") return false;
10815
11432
  const err = error;
10816
- return typeof err.message === "string" && (err.message.includes("Worker pool is shutting down") || err.message.includes("Cannot execute a task on destroying pool") || err.message.includes("destroying pool"));
11433
+ return typeof err.message === "string" && (err.message.includes("Worker pool is shutting down") || err.message.includes("Cannot execute a task on destroying pool") || err.message.includes("destroying pool") || // worker-pool-manager throws this when auto-recovery is still swapping the
11434
+ // pool. Its own text says "please retry", but until it was listed here no
11435
+ // classifier matched it, so the first attempt rethrew instead of retrying —
11436
+ // and auto-recovery fires only after 3 consecutive worker failures, i.e.
11437
+ // exactly when a burst of tasks is in flight and would all die together.
11438
+ err.message.includes("Worker pool is being reset") || // poolifier's wording once destroy() has COMPLETED (vs "destroying pool"
11439
+ // while it is in progress). A queued closure holding a pool captured before
11440
+ // an auto-recovery destroy dispatches into exactly this window; it is the
11441
+ // same transient swap the "being reset" string covers, not a hard fault.
11442
+ err.message.includes("Cannot execute a task on not started pool"));
10817
11443
  }
10818
11444
  var sessionCircuitBreakers = /* @__PURE__ */ new Map();
10819
11445
  var MAX_SESSION_BREAKERS = 256;
@@ -10921,11 +11547,13 @@ var BrowserServer = class {
10921
11547
  const expected = Buffer.from(getOrCreateAuthSecret(), "utf8");
10922
11548
  const actual = Buffer.from(authHeader ?? "", "utf8");
10923
11549
  if (actual.length !== expected.length || !crypto3.timingSafeEqual(actual, expected)) {
11550
+ req.resume();
10924
11551
  res.writeHead(403, { "Content-Type": "application/json" });
10925
11552
  res.end(JSON.stringify({ error: "Unauthorized" }));
10926
11553
  return;
10927
11554
  }
10928
11555
  if (this.draining) {
11556
+ req.resume();
10929
11557
  res.writeHead(503, {
10930
11558
  "Content-Type": "application/json",
10931
11559
  "Connection": "close"
@@ -10934,16 +11562,17 @@ var BrowserServer = class {
10934
11562
  return;
10935
11563
  }
10936
11564
  if (req.method !== "POST") {
11565
+ req.resume();
10937
11566
  res.writeHead(405);
10938
11567
  res.end("Method Not Allowed");
10939
11568
  return;
10940
11569
  }
10941
- let body = "";
11570
+ const collected = new Utf8Body();
10942
11571
  let oversized = false;
10943
11572
  const MAX_BODY_SIZE = 10 * 1024 * 1024;
10944
11573
  req.on("data", (chunk) => {
10945
- body += chunk;
10946
- if (body.length > MAX_BODY_SIZE) {
11574
+ collected.push(chunk);
11575
+ if (collected.byteLength > MAX_BODY_SIZE) {
10947
11576
  oversized = true;
10948
11577
  req.destroy(new Error("Payload too large"));
10949
11578
  }
@@ -10960,7 +11589,7 @@ var BrowserServer = class {
10960
11589
  });
10961
11590
  req.on("end", async () => {
10962
11591
  try {
10963
- const data = JSON.parse(body);
11592
+ const data = JSON.parse(collected.toString());
10964
11593
  let result;
10965
11594
  switch (req.url) {
10966
11595
  case "/search":
@@ -11339,10 +11968,10 @@ var PriorityTaskQueue = class {
11339
11968
  this.activeCount++;
11340
11969
  logger.debug(`[PriorityQueue] Starting task: ${task.type}. Active: ${this.activeCount}/${this.maxTotalConcurrency}`);
11341
11970
  let onAbort;
11342
- const fnPromise = task.fn();
11343
- fnPromise.catch(() => {
11344
- });
11345
11971
  try {
11972
+ const fnPromise = task.fn();
11973
+ fnPromise.catch(() => {
11974
+ });
11346
11975
  let result;
11347
11976
  if (task.signal) {
11348
11977
  const abortPromise = new Promise((_, rej) => {
@@ -11408,6 +12037,25 @@ var PriorityTaskQueue = class {
11408
12037
  };
11409
12038
 
11410
12039
  // src/infrastructure/browser/browser-task-scheduler.ts
12040
+ var WORKER_DEATH_BACKSTOP_GRACE_MS = 5e3;
12041
+ async function executePoolTaskWithDeathBackstop(execute, taskTimeoutMs, label) {
12042
+ const backstopMs = taskTimeoutMs + WORKER_DEATH_BACKSTOP_GRACE_MS;
12043
+ let backstopId;
12044
+ try {
12045
+ return await Promise.race([
12046
+ execute(),
12047
+ new Promise((_, reject) => {
12048
+ backstopId = setTimeout(
12049
+ () => reject(new Error(`${label} task unanswered after ${backstopMs}ms \u2014 worker likely died mid-task`)),
12050
+ backstopMs
12051
+ );
12052
+ if (backstopId.unref) backstopId.unref();
12053
+ })
12054
+ ]);
12055
+ } finally {
12056
+ if (backstopId) clearTimeout(backstopId);
12057
+ }
12058
+ }
11411
12059
  var BrowserTaskScheduler = class {
11412
12060
  // 5 minutes (was 30min — wasted RAM)
11413
12061
  constructor(schedulerId, stateManager, container = getServiceContainer()) {
@@ -11535,15 +12183,18 @@ var BrowserTaskScheduler = class {
11535
12183
  });
11536
12184
  logger.debug(`[BrowserTaskScheduler] Executing search: "${query}" (Timeout: ${timeoutMs}ms)`);
11537
12185
  let result;
12186
+ const settled = new AbortController();
12187
+ const queueSignal = signal ? AbortSignal.any([signal, settled.signal]) : settled.signal;
11538
12188
  try {
11539
12189
  const queue = this.getPriorityQueue(config);
11540
12190
  result = await Promise.race([
11541
12191
  queue.enqueue("search", async () => {
11542
- return await Promise.race([
11543
- pool.execute({ type: "search", query, queuedAt: startTime, taskTimeoutMs: timeoutMs }),
11544
- timeoutPromise
11545
- ]);
11546
- }, signal),
12192
+ return await executePoolTaskWithDeathBackstop(
12193
+ () => pool.execute({ type: "search", query, queuedAt: startTime, taskTimeoutMs: timeoutMs }),
12194
+ timeoutMs,
12195
+ "Search"
12196
+ );
12197
+ }, queueSignal),
11547
12198
  timeoutPromise
11548
12199
  ]);
11549
12200
  logger.debug(`[BrowserTaskScheduler] Search completed: "${query}" in ${Date.now() - startTime}ms`);
@@ -11563,6 +12214,7 @@ var BrowserTaskScheduler = class {
11563
12214
  throw error;
11564
12215
  } finally {
11565
12216
  if (timeoutId) clearTimeout(timeoutId);
12217
+ settled.abort();
11566
12218
  }
11567
12219
  const duration = Date.now() - startTime;
11568
12220
  metrics.observe("browser_search_duration_ms", duration, { status: "success" });
@@ -11596,15 +12248,18 @@ var BrowserTaskScheduler = class {
11596
12248
  if (timeoutId.unref) timeoutId.unref();
11597
12249
  });
11598
12250
  let result;
12251
+ const settled = new AbortController();
12252
+ const queueSignal = signal ? AbortSignal.any([signal, settled.signal]) : settled.signal;
11599
12253
  try {
11600
12254
  const queue = this.getPriorityQueue(config);
11601
12255
  result = await Promise.race([
11602
12256
  queue.enqueue("scrape", async () => {
11603
- return await Promise.race([
11604
- pool.execute({ type: "scrape", url, queuedAt: startTime, taskTimeoutMs: timeoutMs }),
11605
- timeoutPromise
11606
- ]);
11607
- }, signal),
12257
+ return await executePoolTaskWithDeathBackstop(
12258
+ () => pool.execute({ type: "scrape", url, queuedAt: startTime, taskTimeoutMs: timeoutMs }),
12259
+ timeoutMs,
12260
+ "Scrape"
12261
+ );
12262
+ }, queueSignal),
11608
12263
  timeoutPromise
11609
12264
  ]);
11610
12265
  } catch (error) {
@@ -11621,6 +12276,7 @@ var BrowserTaskScheduler = class {
11621
12276
  throw error;
11622
12277
  } finally {
11623
12278
  if (timeoutId) clearTimeout(timeoutId);
12279
+ settled.abort();
11624
12280
  }
11625
12281
  const duration = Date.now() - startTime;
11626
12282
  metrics.observe("browser_scrape_duration_ms", duration, { status: "success" });
@@ -11650,17 +12306,18 @@ var BrowserTaskScheduler = class {
11650
12306
  if (timeoutId.unref) timeoutId.unref();
11651
12307
  });
11652
12308
  let result;
12309
+ const settled = new AbortController();
12310
+ const queueSignal = signal ? AbortSignal.any([signal, settled.signal]) : settled.signal;
11653
12311
  try {
11654
12312
  const queue = this.getPriorityQueue(config);
11655
12313
  result = await Promise.race([
11656
12314
  queue.enqueue("healthcheck", async () => {
11657
- const execPromise = pool.execute({ type: "healthcheck", queuedAt: startTime, taskTimeoutMs: timeoutMs });
11658
- execPromise.catch((err) => logger.debug(`[BrowserTaskScheduler] Background healthcheck task rejection: ${err.message}`));
11659
- return await Promise.race([
11660
- execPromise,
11661
- timeoutPromise
11662
- ]);
11663
- }, signal),
12315
+ return await executePoolTaskWithDeathBackstop(() => {
12316
+ const execPromise = pool.execute({ type: "healthcheck", queuedAt: startTime, taskTimeoutMs: timeoutMs });
12317
+ execPromise.catch((err) => logger.debug(`[BrowserTaskScheduler] Background healthcheck task rejection: ${err.message}`));
12318
+ return execPromise;
12319
+ }, timeoutMs, "Healthcheck");
12320
+ }, queueSignal),
11664
12321
  timeoutPromise
11665
12322
  ]);
11666
12323
  } catch (error) {
@@ -11677,6 +12334,7 @@ var BrowserTaskScheduler = class {
11677
12334
  throw error;
11678
12335
  } finally {
11679
12336
  if (timeoutId) clearTimeout(timeoutId);
12337
+ settled.abort();
11680
12338
  }
11681
12339
  const duration = Date.now() - startTime;
11682
12340
  metrics.observe("browser_healthcheck_duration_ms", duration, { status: "success" });
@@ -11717,12 +12375,16 @@ var BrowserTaskScheduler = class {
11717
12375
  this.leadershipTimer = null;
11718
12376
  }
11719
12377
  if (!this.container.isDisposing) {
11720
- const schedulerService = await getService(ServiceNames.SCHEDULER, void 0, this.container);
11721
- const currentScheduler = schedulerService.getSchedulerInstance();
11722
- if (currentScheduler && "schedulerId" in currentScheduler && currentScheduler.schedulerId === this.schedulerId) {
11723
- schedulerService.setSchedulerInstance(null);
11724
- schedulerService.setSchedulerVersion(null);
11725
- schedulerService.setSchedulerInitializationPromise(null);
12378
+ try {
12379
+ const schedulerService = await getService(ServiceNames.SCHEDULER, void 0, this.container);
12380
+ const currentScheduler = schedulerService.getSchedulerInstance();
12381
+ if (currentScheduler && "schedulerId" in currentScheduler && currentScheduler.schedulerId === this.schedulerId) {
12382
+ schedulerService.setSchedulerInstance(null);
12383
+ schedulerService.setSchedulerVersion(null);
12384
+ schedulerService.setSchedulerInitializationPromise(null);
12385
+ }
12386
+ } catch (err) {
12387
+ logger.debug("[Scheduler] Could not clear scheduler service reference during shutdown:", err);
11726
12388
  }
11727
12389
  }
11728
12390
  let serverInfo = null;
@@ -11968,6 +12630,15 @@ async function getScheduler(config, container = getServiceContainer()) {
11968
12630
  let wonElection = false;
11969
12631
  let winnerPort = port;
11970
12632
  let winnerAuthSecret;
12633
+ const myStartTime = await (async () => {
12634
+ try {
12635
+ const processLifecycle = await getService(ServiceNames.PROCESS_LIFECYCLE, void 0, container);
12636
+ return await processLifecycle.getCurrentProcessStartTime();
12637
+ } catch (err) {
12638
+ logger.debug("[Scheduler] Could not read own process start time (PID-reuse guard degrades to signal-0):", err);
12639
+ return null;
12640
+ }
12641
+ })();
11971
12642
  try {
11972
12643
  await stateManager.updateState(async (state) => {
11973
12644
  if (state.browserServer) {
@@ -11980,7 +12651,13 @@ async function getScheduler(config, container = getServiceContainer()) {
11980
12651
  }
11981
12652
  }
11982
12653
  const secret = getBrowserServerAuthSecret();
11983
- state.browserServer = { port, pid: process.pid, schedulerId, authSecret: secret };
12654
+ state.browserServer = {
12655
+ port,
12656
+ pid: process.pid,
12657
+ schedulerId,
12658
+ authSecret: secret,
12659
+ ...myStartTime !== null ? { startTime: myStartTime } : {}
12660
+ };
11984
12661
  state.schedulerVersion = schedulerVersion;
11985
12662
  wonElection = true;
11986
12663
  return state;
@@ -12078,8 +12755,9 @@ init_service_interfaces();
12078
12755
  // src/infrastructure/state/state-manager.ts
12079
12756
  init_config();
12080
12757
  init_logger();
12758
+ init_atomic_replace();
12081
12759
  init_metrics();
12082
- import * as fs7 from "node:fs/promises";
12760
+ import * as fs8 from "node:fs/promises";
12083
12761
  import * as path8 from "node:path";
12084
12762
  import * as crypto5 from "node:crypto";
12085
12763
  import process2 from "node:process";
@@ -12130,7 +12808,16 @@ var SingletonStateSchema = Type3.Object({
12130
12808
  // cross-embed and corrupt the vector space) — it falls back to a local
12131
12809
  // in-process embedder instead. Optional for entries written by older
12132
12810
  // processes, which are tolerated as matching.
12133
- model: Type3.Optional(Type3.String())
12811
+ model: Type3.Optional(Type3.String()),
12812
+ // Shared secret a follower must present on every embed request. Mirrors
12813
+ // browserServer.authSecret: the server listens on loopback, but loopback is
12814
+ // not an authorization boundary — any local process could otherwise enqueue
12815
+ // embedding work, and because the serial queue steps the leader down when
12816
+ // poisoned, sustained abuse denies the knowledge store rather than merely
12817
+ // burning GPU. Optional so an entry written by an older leader still parses;
12818
+ // a client that finds none simply sends no header, and such a leader does not
12819
+ // check one either.
12820
+ authSecret: Type3.Optional(Type3.String())
12134
12821
  }))
12135
12822
  });
12136
12823
 
@@ -12347,7 +13034,7 @@ var StateManager = class {
12347
13034
  ];
12348
13035
  for (const dir of dirs) {
12349
13036
  try {
12350
- await fs7.mkdir(dir, { recursive: true, mode: 448 });
13037
+ await fs8.mkdir(dir, { recursive: true, mode: 448 });
12351
13038
  } catch (err) {
12352
13039
  logger.error(`[StateManager] Failed to create directory ${dir}:`, err);
12353
13040
  throw err;
@@ -12377,14 +13064,14 @@ var StateManager = class {
12377
13064
  const stateDir = path8.dirname(this.stateFilePath);
12378
13065
  const cutoff = Date.now() - STALE_TMP_MS;
12379
13066
  try {
12380
- const entries = await fs7.readdir(stateDir);
13067
+ const entries = await fs8.readdir(stateDir);
12381
13068
  for (const entry of entries) {
12382
13069
  if (!entry.startsWith("research-state-") || !entry.endsWith(".tmp")) continue;
12383
13070
  const filePath = path8.join(stateDir, entry);
12384
13071
  try {
12385
- const stats = await fs7.stat(filePath);
13072
+ const stats = await fs8.stat(filePath);
12386
13073
  if (!stats.isFile() || stats.mtimeMs >= cutoff) continue;
12387
- await fs7.unlink(filePath);
13074
+ await fs8.unlink(filePath);
12388
13075
  logger.debug(`[StateManager] Removed orphaned state temp file: ${entry}`);
12389
13076
  } catch {
12390
13077
  }
@@ -12415,7 +13102,7 @@ var StateManager = class {
12415
13102
  return this.getDefaultState();
12416
13103
  }
12417
13104
  try {
12418
- const content = await fs7.readFile(this.stateFilePath, "utf-8");
13105
+ const content = await fs8.readFile(this.stateFilePath, "utf-8");
12419
13106
  const state = JSON.parse(content);
12420
13107
  return this.validator.migrateAndValidate(state);
12421
13108
  } catch (error) {
@@ -12444,7 +13131,7 @@ var StateManager = class {
12444
13131
  );
12445
13132
  try {
12446
13133
  await this.recoverFromCorruptionDirect();
12447
- const recovered = await fs7.readFile(this.stateFilePath, "utf-8");
13134
+ const recovered = await fs8.readFile(this.stateFilePath, "utf-8");
12448
13135
  return this.validator.migrateAndValidate(JSON.parse(recovered));
12449
13136
  } catch (recoveryError) {
12450
13137
  logger.error(
@@ -12505,26 +13192,24 @@ var StateManager = class {
12505
13192
  const tempFileName = `research-state-${crypto5.randomBytes(16).toString("hex")}.tmp`;
12506
13193
  tempFilePath = path8.join(path8.dirname(this.stateFilePath), tempFileName);
12507
13194
  const content = JSON.stringify(state, null, 2);
12508
- const fh = await fs7.open(tempFilePath, "w", 384);
13195
+ const fh = await fs8.open(tempFilePath, "w", 384);
12509
13196
  try {
12510
13197
  await fh.writeFile(content, { encoding: "utf-8" });
12511
13198
  await fh.sync();
12512
13199
  } finally {
12513
13200
  await fh.close();
12514
13201
  }
12515
- try {
12516
- await fs7.rename(tempFilePath, this.stateFilePath);
12517
- } catch (renameErr) {
12518
- if (process2.platform === "win32") {
12519
- await fs7.copyFile(tempFilePath, this.stateFilePath);
12520
- const tmp = tempFilePath;
12521
- tempFilePath = null;
12522
- await fs7.unlink(tmp).catch((err) => {
12523
- logger.warn("[StateManager] Failed to remove temp file after copy fallback (write succeeded):", err);
12524
- });
12525
- } else {
12526
- throw renameErr;
12527
- }
13202
+ const outcome = await replaceFile(tempFilePath, this.stateFilePath);
13203
+ if (outcome === "copied") {
13204
+ logger.warn(
13205
+ "[StateManager] State written via non-atomic copy: the rename was blocked by another process holding the state file. A concurrent reader may observe a partial file."
13206
+ );
13207
+ metrics.increment("state_write_non_atomic_total", 1);
13208
+ const tmp = tempFilePath;
13209
+ tempFilePath = null;
13210
+ await fs8.unlink(tmp).catch((err) => {
13211
+ logger.warn("[StateManager] Failed to remove temp file after copy fallback (write succeeded):", err);
13212
+ });
12528
13213
  }
12529
13214
  tempFilePath = null;
12530
13215
  await this.backupManager.cleanupOldBackups();
@@ -12532,7 +13217,7 @@ var StateManager = class {
12532
13217
  throw new Error(`Failed to write state: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
12533
13218
  } finally {
12534
13219
  if (tempFilePath) {
12535
- await fs7.unlink(tempFilePath).catch((err) => {
13220
+ await fs8.unlink(tempFilePath).catch((err) => {
12536
13221
  logger.warn("[StateManager] Failed to clean up temp file:", err);
12537
13222
  });
12538
13223
  }
@@ -12962,8 +13647,23 @@ var MetricsService = class {
12962
13647
  // src/infrastructure/process-lifecycle-service.ts
12963
13648
  init_service_registry();
12964
13649
  init_service_names();
12965
- import * as fs8 from "node:fs/promises";
13650
+ import * as fs9 from "node:fs/promises";
12966
13651
  import { execFile } from "node:child_process";
13652
+ function parseEtime(value) {
13653
+ const m = /^(?:(\d+)-)?(?:(\d+):)?(\d+):(\d+)$/.exec(value.trim());
13654
+ if (!m) return null;
13655
+ const days = m[1] ? parseInt(m[1], 10) : 0;
13656
+ const hours = m[2] ? parseInt(m[2], 10) : 0;
13657
+ const minutes = parseInt(m[3], 10);
13658
+ const seconds = parseInt(m[4], 10);
13659
+ if ([days, hours, minutes, seconds].some((n) => isNaN(n))) return null;
13660
+ return days * 86400 + hours * 3600 + minutes * 60 + seconds;
13661
+ }
13662
+ function isDefinitelyGone(err) {
13663
+ const code = err?.code;
13664
+ if (code === "EPERM" || code === "EACCES") return false;
13665
+ return true;
13666
+ }
12967
13667
  var ProcessLifecycleService = class {
12968
13668
  name = ServiceNames.PROCESS_LIFECYCLE;
12969
13669
  lifecycle = "uninitialized" /* UNINITIALIZED */;
@@ -12984,7 +13684,7 @@ var ProcessLifecycleService = class {
12984
13684
  async getProcessStartTime(pid) {
12985
13685
  if (process.platform === "linux") {
12986
13686
  try {
12987
- const stat5 = await fs8.readFile(`/proc/${pid}/stat`, "utf8");
13687
+ const stat5 = await fs9.readFile(`/proc/${pid}/stat`, "utf8");
12988
13688
  const lastParenIndex = stat5.lastIndexOf(")");
12989
13689
  if (lastParenIndex === -1) return null;
12990
13690
  const partsAfterName = stat5.substring(lastParenIndex + 2).trim().split(/\s+/);
@@ -13012,23 +13712,35 @@ var ProcessLifecycleService = class {
13012
13712
  return null;
13013
13713
  }
13014
13714
  }
13015
- try {
13016
- const output = await new Promise((resolve8, reject) => {
13017
- const child = execFile(
13018
- "ps",
13019
- ["-o", "etimes=", "-p", String(pid)],
13020
- { encoding: "utf8", timeout: 3e3 },
13021
- (err, stdout) => err ? reject(err) : resolve8(stdout)
13022
- );
13023
- child.unref?.();
13024
- });
13025
- if (!output || !output.trim()) return null;
13026
- const elapsedSec = parseInt(output.trim(), 10);
13027
- if (isNaN(elapsedSec)) return null;
13028
- return Math.floor(Date.now() / 1e3) - elapsedSec;
13029
- } catch {
13030
- return null;
13715
+ const elapsedSec = await this.readElapsedSeconds(pid);
13716
+ if (elapsedSec === null) return null;
13717
+ return Math.floor(Date.now() / 1e3) - elapsedSec;
13718
+ }
13719
+ /** Elapsed seconds for `pid` via ps, or null. Tries `etimes`, then POSIX `etime`. */
13720
+ async readElapsedSeconds(pid) {
13721
+ const run = async (fmt) => {
13722
+ try {
13723
+ return await new Promise((resolve8, reject) => {
13724
+ const child = execFile(
13725
+ "ps",
13726
+ ["-o", fmt, "-p", String(pid)],
13727
+ { encoding: "utf8", timeout: 3e3 },
13728
+ (err, stdout) => err ? reject(err) : resolve8(stdout)
13729
+ );
13730
+ child.unref?.();
13731
+ });
13732
+ } catch {
13733
+ return null;
13734
+ }
13735
+ };
13736
+ const etimes = await run("etimes=");
13737
+ if (etimes && etimes.trim()) {
13738
+ const n = parseInt(etimes.trim(), 10);
13739
+ if (!isNaN(n)) return n;
13031
13740
  }
13741
+ const etime = await run("etime=");
13742
+ if (etime && etime.trim()) return parseEtime(etime.trim());
13743
+ return null;
13032
13744
  }
13033
13745
  /**
13034
13746
  * Get the start time for the current process
@@ -13049,17 +13761,25 @@ var ProcessLifecycleService = class {
13049
13761
  async isProcessAlive(pid, expectedStartTime) {
13050
13762
  try {
13051
13763
  process.kill(pid, 0);
13764
+ } catch (err) {
13765
+ if (isDefinitelyGone(err)) return false;
13766
+ }
13767
+ try {
13052
13768
  if (expectedStartTime !== void 0 && expectedStartTime !== null) {
13053
13769
  const actualStartTime = await this.getProcessStartTime(pid);
13054
13770
  if (actualStartTime === null) {
13055
- process.kill(pid, 0);
13056
- return true;
13771
+ try {
13772
+ process.kill(pid, 0);
13773
+ return true;
13774
+ } catch (reconfirmErr) {
13775
+ return !isDefinitelyGone(reconfirmErr);
13776
+ }
13057
13777
  }
13058
13778
  return Math.abs(actualStartTime - expectedStartTime) <= 1;
13059
13779
  }
13060
13780
  return true;
13061
13781
  } catch {
13062
- return false;
13782
+ return true;
13063
13783
  }
13064
13784
  }
13065
13785
  /**
@@ -13069,8 +13789,8 @@ var ProcessLifecycleService = class {
13069
13789
  try {
13070
13790
  process.kill(pid, 0);
13071
13791
  return true;
13072
- } catch {
13073
- return false;
13792
+ } catch (err) {
13793
+ return !isDefinitelyGone(err);
13074
13794
  }
13075
13795
  }
13076
13796
  /**
@@ -13567,7 +14287,7 @@ var StateValidator = class {
13567
14287
  init_logger();
13568
14288
  init_metrics();
13569
14289
  init_config();
13570
- import { dirname as dirname7, join as join11 } from "node:path";
14290
+ import { dirname as dirname7, join as join12 } from "node:path";
13571
14291
  import { existsSync as existsSync7 } from "node:fs";
13572
14292
  import { fileURLToPath as fileURLToPath2 } from "node:url";
13573
14293
  import { FixedClusterPool, WorkerChoiceStrategies } from "poolifier";
@@ -13578,7 +14298,7 @@ import { spawn } from "node:child_process";
13578
14298
  import { createRequire as createRequire3 } from "node:module";
13579
14299
  import { createHash as createHash2 } from "node:crypto";
13580
14300
  import { existsSync as existsSync6, readdirSync as readdirSync2, statSync as statSync3, fstatSync, openSync as openSync3, closeSync as closeSync3, rmSync } from "node:fs";
13581
- import { join as join9, dirname as dirname6 } from "node:path";
14301
+ import { join as join10, dirname as dirname6 } from "node:path";
13582
14302
  import { tmpdir as tmpdir2 } from "node:os";
13583
14303
  var FETCH_TIMEOUT_MS = 15 * 60 * 1e3;
13584
14304
  var STALE_LOCK_MS = 20 * 60 * 1e3;
@@ -13591,7 +14311,7 @@ function isBrowserBinaryPresent() {
13591
14311
  if (!existsSync6(cacheDir)) return false;
13592
14312
  return readdirSync2(cacheDir).some((entry) => {
13593
14313
  try {
13594
- return statSync3(join9(cacheDir, entry)).isDirectory();
14314
+ return statSync3(join10(cacheDir, entry)).isDirectory();
13595
14315
  } catch {
13596
14316
  return false;
13597
14317
  }
@@ -13604,7 +14324,7 @@ function resolveCamoufoxBin() {
13604
14324
  try {
13605
14325
  const require2 = createRequire3(import.meta.url);
13606
14326
  const pkgJson = require2.resolve("camoufox-js/package.json");
13607
- const bin = join9(dirname6(pkgJson), "..", ".bin", process.platform === "win32" ? "camoufox-js.cmd" : "camoufox-js");
14327
+ const bin = join10(dirname6(pkgJson), "..", ".bin", process.platform === "win32" ? "camoufox-js.cmd" : "camoufox-js");
13608
14328
  if (existsSync6(bin)) return bin;
13609
14329
  } catch {
13610
14330
  }
@@ -13648,7 +14368,7 @@ async function provision() {
13648
14368
  return;
13649
14369
  }
13650
14370
  const cacheKey = createHash2("sha256").update(getCamoufoxBinaryPath()).digest("hex").slice(0, 16);
13651
- const lockPath = join9(tmpdir2(), `pi-research-camoufox-fetch-${cacheKey}.lock`);
14371
+ const lockPath = join10(tmpdir2(), `pi-research-camoufox-fetch-${cacheKey}.lock`);
13652
14372
  let haveLock = false;
13653
14373
  try {
13654
14374
  closeSync3(openSync3(lockPath, "wx", 384));
@@ -13703,12 +14423,26 @@ function ensureBrowserInstalled() {
13703
14423
 
13704
14424
  // src/infrastructure/browser/cleanup-utils.ts
13705
14425
  init_logger();
13706
- import * as fs9 from "node:fs/promises";
14426
+ import * as fs10 from "node:fs/promises";
13707
14427
  import * as path9 from "node:path";
13708
14428
  import * as os4 from "node:os";
13709
14429
  var DEFAULT_STALE_MS = 30 * 24 * 60 * 60 * 1e3;
13710
14430
  var LOCK_FILE_NAMES = ["lock", ".parentlock", "parent.lock"];
13711
14431
  var LOCK_FRESHNESS_MS = 2 * 60 * 1e3;
14432
+ async function isWindowsParentLockHeld(lockPath) {
14433
+ let handle;
14434
+ try {
14435
+ handle = await fs10.open(lockPath, "r");
14436
+ } catch (e) {
14437
+ const code = e?.code;
14438
+ if (code === "ENOENT") return false;
14439
+ if (code === "EBUSY" || code === "EPERM" || code === "EACCES") return true;
14440
+ return null;
14441
+ }
14442
+ await handle.close().catch(() => {
14443
+ });
14444
+ return false;
14445
+ }
13712
14446
  function parsePidFromLockTarget(target) {
13713
14447
  const m = /\+(\d+)\s*$/.exec(target);
13714
14448
  if (!m) return null;
@@ -13728,17 +14462,22 @@ async function isProfileLive(profileDir) {
13728
14462
  const lockPath = path9.join(profileDir, name);
13729
14463
  let lst;
13730
14464
  try {
13731
- lst = await fs9.lstat(lockPath);
14465
+ lst = await fs10.lstat(lockPath);
13732
14466
  } catch {
13733
14467
  continue;
13734
14468
  }
13735
14469
  if (lst.isSymbolicLink()) {
13736
14470
  try {
13737
- const pid = parsePidFromLockTarget(await fs9.readlink(lockPath));
14471
+ const pid = parsePidFromLockTarget(await fs10.readlink(lockPath));
13738
14472
  if (pid !== null) return isPidAlive(pid);
13739
14473
  } catch {
13740
14474
  }
13741
14475
  }
14476
+ if (process.platform === "win32" && name === "parent.lock") {
14477
+ const held = await isWindowsParentLockHeld(lockPath);
14478
+ if (held !== null) return held;
14479
+ return true;
14480
+ }
13742
14481
  return Date.now() - lst.mtimeMs < LOCK_FRESHNESS_MS;
13743
14482
  }
13744
14483
  return false;
@@ -13750,19 +14489,19 @@ async function cleanupStaleProfiles(baseDir, staleMs = DEFAULT_STALE_MS) {
13750
14489
  let removed = 0;
13751
14490
  let errors = 0;
13752
14491
  try {
13753
- const entries = await fs9.readdir(scanDir);
14492
+ const entries = await fs10.readdir(scanDir);
13754
14493
  const now = Date.now();
13755
14494
  for (const entry of entries) {
13756
14495
  if (usePrefix && !entry.startsWith(prefix)) continue;
13757
14496
  const fullPath = path9.join(scanDir, entry);
13758
14497
  try {
13759
- const stats = await fs9.stat(fullPath);
14498
+ const stats = await fs10.stat(fullPath);
13760
14499
  if (!stats.isDirectory()) continue;
13761
14500
  const isProfileLike = entry.startsWith(prefix) || entry.startsWith("playwright-artifacts-");
13762
14501
  if (isProfileLike) {
13763
- const inner = await fs9.readdir(fullPath);
14502
+ const inner = await fs10.readdir(fullPath);
13764
14503
  if (inner.length === 0) {
13765
- await fs9.rmdir(fullPath);
14504
+ await fs10.rmdir(fullPath);
13766
14505
  removed++;
13767
14506
  continue;
13768
14507
  }
@@ -13770,12 +14509,12 @@ async function cleanupStaleProfiles(baseDir, staleMs = DEFAULT_STALE_MS) {
13770
14509
  logger.debug(`[Cleanup] Skipping profile ${entry} \u2014 owned by a live browser`);
13771
14510
  continue;
13772
14511
  }
13773
- await fs9.rm(fullPath, { recursive: true, force: true });
14512
+ await fs10.rm(fullPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
13774
14513
  removed++;
13775
14514
  continue;
13776
14515
  }
13777
14516
  if (now - stats.mtimeMs > staleMs && !await isProfileLive(fullPath)) {
13778
- await fs9.rm(fullPath, { recursive: true, force: true });
14517
+ await fs10.rm(fullPath, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
13779
14518
  removed++;
13780
14519
  }
13781
14520
  } catch (_e) {
@@ -13795,7 +14534,7 @@ async function cleanupStaleProfiles(baseDir, staleMs = DEFAULT_STALE_MS) {
13795
14534
  init_log_utils();
13796
14535
  import process3 from "node:process";
13797
14536
  import cluster from "node:cluster";
13798
- import * as fs10 from "node:fs/promises";
14537
+ import * as fs11 from "node:fs/promises";
13799
14538
  var workerId = "";
13800
14539
  var BENIGN_IPC_CODES = /* @__PURE__ */ new Set(["EPIPE", "ERR_IPC_CHANNEL_CLOSED", "ECONNRESET"]);
13801
14540
  function isBenignClusterIpcError(err) {
@@ -13856,7 +14595,7 @@ function logToDebugFile(level, ...args) {
13856
14595
  return String(arg);
13857
14596
  }).join(" "))
13858
14597
  };
13859
- fs10.appendFile(logFile, `${JSON.stringify(entry)}
14598
+ fs11.appendFile(logFile, `${JSON.stringify(entry)}
13860
14599
  `).catch(() => {
13861
14600
  });
13862
14601
  } catch {
@@ -13931,6 +14670,7 @@ var WorkerPoolManager = class {
13931
14670
  try {
13932
14671
  if (this.pool && this.currentWorkerCount !== maxWorkers) {
13933
14672
  logger.log(`[WorkerPoolManager] Worker count changed from ${this.currentWorkerCount} to ${maxWorkers}, recreating pool...`);
14673
+ this.poolEpoch++;
13934
14674
  await this.pool.destroy();
13935
14675
  this.pool = null;
13936
14676
  }
@@ -13945,7 +14685,7 @@ var WorkerPoolManager = class {
13945
14685
  void cleanupStaleProfiles(void 0, STALE_PROFILE_MS).catch((e) => logger.debug("[WorkerPoolManager] Profile sweep (legacy tmp) failed:", e));
13946
14686
  void cleanupOrphanedCamoufoxProcesses().catch((e) => logger.debug("[WorkerPoolManager] Startup orphan sweep failed:", e));
13947
14687
  const workerConcurrency = (config || getConfig()).WORKER_CONCURRENCY;
13948
- const workerPath = join11(__dirname, "./thread-worker.mjs");
14688
+ const workerPath = join12(__dirname, "./thread-worker.mjs");
13949
14689
  if (!existsSync7(workerPath)) {
13950
14690
  throw new Error(
13951
14691
  `Browser worker bundle is missing at ${workerPath}. It is produced by the build step; reinstall the extension (e.g. \`pi update --extensions\`) or run \`npm install\` in the extension directory so the prepare/build script regenerates it.`
@@ -13975,7 +14715,7 @@ var WorkerPoolManager = class {
13975
14715
  }
13976
14716
  },
13977
14717
  exitHandler: (code) => {
13978
- if (code !== 0 && code !== null && this.poolEpoch === myEpoch) {
14718
+ if (code !== 0 && this.poolEpoch === myEpoch) {
13979
14719
  logger.error(`[WorkerPoolManager] Worker exited with code ${code}`);
13980
14720
  this.consecutiveErrors++;
13981
14721
  if (this.consecutiveErrors >= 3) {
@@ -13998,6 +14738,7 @@ var WorkerPoolManager = class {
13998
14738
  logger.warn("[WorkerPoolManager] Pool initialized but a shutdown intervened. Destroying...");
13999
14739
  const built = this.pool;
14000
14740
  if (this.pool === built) this.pool = null;
14741
+ this.poolEpoch++;
14001
14742
  await built.destroy().catch((err) => logger.debug("Swallowed pool destroy error:", err));
14002
14743
  throw new Error("Worker pool is shutting down");
14003
14744
  }
@@ -14058,6 +14799,7 @@ var WorkerPoolManager = class {
14058
14799
  const t = setTimeout(async () => {
14059
14800
  try {
14060
14801
  if (this.isShuttingDown || this.generation !== myGen) return;
14802
+ this.poolEpoch++;
14061
14803
  if (deadPool) await deadPool.destroy();
14062
14804
  logger.info("[WorkerPoolManager] Auto-recovery: old pool destroyed.");
14063
14805
  } catch (err) {
@@ -14098,6 +14840,7 @@ var WorkerPoolManager = class {
14098
14840
  }
14099
14841
  if (this.pool) {
14100
14842
  try {
14843
+ this.poolEpoch++;
14101
14844
  const destroyPromise = this.pool.destroy();
14102
14845
  destroyPromise.catch((err) => logger.debug(`[WorkerPoolManager] Background pool destroy rejection: ${err.message}`));
14103
14846
  await raceWithDeadline(destroyPromise, 5e3);
@@ -14275,7 +15018,7 @@ var ResearchRunSemaphore = class {
14275
15018
  if (this._initialized) return;
14276
15019
  this.lifecycle = "initializing" /* INITIALIZING */;
14277
15020
  try {
14278
- await import("node:fs/promises").then((fs17) => fs17.mkdir(this.slotDir, { recursive: true, mode: 448 }));
15021
+ await import("node:fs/promises").then((fs18) => fs18.mkdir(this.slotDir, { recursive: true, mode: 448 }));
14279
15022
  } catch (err) {
14280
15023
  logger.warn(`[ResearchRunSemaphore] Could not create slot dir ${this.slotDir}: ${err instanceof Error ? err.message : String(err)}`);
14281
15024
  }
@@ -14410,9 +15153,10 @@ function sleep(ms, signal) {
14410
15153
 
14411
15154
  // src/infrastructure/state/state-backup-manager.ts
14412
15155
  init_logger();
15156
+ init_atomic_replace();
14413
15157
  init_service_registry();
14414
15158
  init_service_names();
14415
- import * as fs11 from "node:fs/promises";
15159
+ import * as fs12 from "node:fs/promises";
14416
15160
  import * as crypto6 from "node:crypto";
14417
15161
  import * as path11 from "node:path";
14418
15162
  var QUARANTINE_KEEP = 5;
@@ -14436,7 +15180,7 @@ var StateBackupManager = class {
14436
15180
  return;
14437
15181
  }
14438
15182
  this.lifecycle = "initializing" /* INITIALIZING */;
14439
- await fs11.mkdir(this.backupDirPath, { recursive: true, mode: 448 });
15183
+ await fs12.mkdir(this.backupDirPath, { recursive: true, mode: 448 });
14440
15184
  this._initialized = true;
14441
15185
  this.lifecycle = "initialized" /* INITIALIZED */;
14442
15186
  }
@@ -14450,15 +15194,15 @@ var StateBackupManager = class {
14450
15194
  async createBackup() {
14451
15195
  try {
14452
15196
  try {
14453
- await fs11.access(this.stateFilePath);
15197
+ await fs12.access(this.stateFilePath);
14454
15198
  } catch {
14455
15199
  return;
14456
15200
  }
14457
- await fs11.mkdir(this.backupDirPath, { recursive: true, mode: 448 });
15201
+ await fs12.mkdir(this.backupDirPath, { recursive: true, mode: 448 });
14458
15202
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
14459
15203
  const backupFileName = `research-state-${timestamp}-${crypto6.randomBytes(4).toString("hex")}.json`;
14460
15204
  const backupFilePath = path11.join(this.backupDirPath, backupFileName);
14461
- await fs11.copyFile(this.stateFilePath, backupFilePath);
15205
+ await fs12.copyFile(this.stateFilePath, backupFilePath);
14462
15206
  } catch (error) {
14463
15207
  throw new Error(`Failed to create backup: ${error instanceof Error ? error.message : String(error)}`, { cause: error });
14464
15208
  }
@@ -14468,14 +15212,14 @@ var StateBackupManager = class {
14468
15212
  */
14469
15213
  async cleanupOldBackups() {
14470
15214
  try {
14471
- const entries = await fs11.readdir(this.backupDirPath);
15215
+ const entries = await fs12.readdir(this.backupDirPath);
14472
15216
  if (entries.length <= this.maxBackups) {
14473
15217
  return;
14474
15218
  }
14475
15219
  const backupFiles = [];
14476
15220
  for (const entry of entries) {
14477
15221
  const filePath = path11.join(this.backupDirPath, entry);
14478
- const stats = await fs11.stat(filePath);
15222
+ const stats = await fs12.stat(filePath);
14479
15223
  if (stats.isFile() && entry.startsWith("research-state-") && entry.endsWith(".json")) {
14480
15224
  backupFiles.push({ name: entry, mtime: stats.mtime });
14481
15225
  }
@@ -14484,7 +15228,7 @@ var StateBackupManager = class {
14484
15228
  const backupsToRemove = backupFiles.slice(this.maxBackups);
14485
15229
  for (const backupFile of backupsToRemove) {
14486
15230
  const filePath = path11.join(this.backupDirPath, backupFile.name);
14487
- await fs11.unlink(filePath);
15231
+ await fs12.unlink(filePath);
14488
15232
  }
14489
15233
  } catch (error) {
14490
15234
  logger.error(`Failed to cleanup old backups: ${error instanceof Error ? error.message : String(error)}`);
@@ -14497,10 +15241,10 @@ var StateBackupManager = class {
14497
15241
  try {
14498
15242
  let backups = [];
14499
15243
  try {
14500
- const entries = await fs11.readdir(this.backupDirPath);
15244
+ const entries = await fs12.readdir(this.backupDirPath);
14501
15245
  for (const entry of entries) {
14502
15246
  const filePath = path11.join(this.backupDirPath, entry);
14503
- const stats = await fs11.stat(filePath);
15247
+ const stats = await fs12.stat(filePath);
14504
15248
  if (stats.isFile() && entry.startsWith("research-state-") && entry.endsWith(".json")) {
14505
15249
  backups.push({ name: entry, mtime: stats.mtime });
14506
15250
  }
@@ -14512,7 +15256,7 @@ var StateBackupManager = class {
14512
15256
  const backupPath = path11.join(this.backupDirPath, backup.name);
14513
15257
  let restoreContent;
14514
15258
  try {
14515
- const content = await fs11.readFile(backupPath, "utf-8");
15259
+ const content = await fs12.readFile(backupPath, "utf-8");
14516
15260
  const validated = this.validator.migrateAndValidate(JSON.parse(content));
14517
15261
  restoreContent = JSON.stringify(validated, null, 2);
14518
15262
  } catch (err) {
@@ -14540,27 +15284,27 @@ var StateBackupManager = class {
14540
15284
  */
14541
15285
  async quarantineFutureState(fileVersion) {
14542
15286
  try {
14543
- await fs11.access(this.stateFilePath);
15287
+ await fs12.access(this.stateFilePath);
14544
15288
  } catch {
14545
15289
  return;
14546
15290
  }
14547
- await fs11.mkdir(this.backupDirPath, { recursive: true, mode: 448 });
15291
+ await fs12.mkdir(this.backupDirPath, { recursive: true, mode: 448 });
14548
15292
  const timestamp = (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
14549
15293
  const name = `research-state-future-v${fileVersion}-${timestamp}.quarantine`;
14550
15294
  const dest = path11.join(this.backupDirPath, name);
14551
- await fs11.copyFile(this.stateFilePath, dest);
15295
+ await fs12.copyFile(this.stateFilePath, dest);
14552
15296
  logger.warn(`[StateManager] Quarantined newer-build state (v${fileVersion}) copy at ${dest}`);
14553
15297
  await this.pruneQuarantineFiles(QUARANTINE_KEEP);
14554
15298
  }
14555
15299
  /** Keep only the newest `keep` *.quarantine files; delete the rest. Best-effort. */
14556
15300
  async pruneQuarantineFiles(keep) {
14557
15301
  try {
14558
- const entries = await fs11.readdir(this.backupDirPath);
15302
+ const entries = await fs12.readdir(this.backupDirPath);
14559
15303
  const quarantines = [];
14560
15304
  for (const entry of entries) {
14561
15305
  if (!entry.endsWith(".quarantine")) continue;
14562
15306
  try {
14563
- const stats = await fs11.stat(path11.join(this.backupDirPath, entry));
15307
+ const stats = await fs12.stat(path11.join(this.backupDirPath, entry));
14564
15308
  if (stats.isFile()) quarantines.push({ name: entry, mtime: stats.mtimeMs });
14565
15309
  } catch {
14566
15310
  }
@@ -14569,7 +15313,7 @@ var StateBackupManager = class {
14569
15313
  quarantines.sort((a, b) => b.mtime - a.mtime);
14570
15314
  for (const stale of quarantines.slice(keep)) {
14571
15315
  try {
14572
- await fs11.unlink(path11.join(this.backupDirPath, stale.name));
15316
+ await fs12.unlink(path11.join(this.backupDirPath, stale.name));
14573
15317
  } catch {
14574
15318
  }
14575
15319
  }
@@ -14582,10 +15326,10 @@ var StateBackupManager = class {
14582
15326
  */
14583
15327
  async atomicWriteStateFile(content) {
14584
15328
  const stateDir = path11.dirname(this.stateFilePath);
14585
- await fs11.mkdir(stateDir, { recursive: true, mode: 448 });
15329
+ await fs12.mkdir(stateDir, { recursive: true, mode: 448 });
14586
15330
  const tempFile = `research-state-${crypto6.randomBytes(16).toString("hex")}.tmp`;
14587
15331
  const tempPath = path11.join(stateDir, tempFile);
14588
- const fh = await fs11.open(tempPath, "w", 384);
15332
+ const fh = await fs12.open(tempPath, "w", 384);
14589
15333
  try {
14590
15334
  await fh.writeFile(content, { encoding: "utf-8" });
14591
15335
  await fh.sync();
@@ -14593,17 +15337,14 @@ var StateBackupManager = class {
14593
15337
  await fh.close();
14594
15338
  }
14595
15339
  try {
14596
- await fs11.rename(tempPath, this.stateFilePath);
14597
- } catch (renameErr) {
14598
- if (process.platform === "win32") {
14599
- await fs11.copyFile(tempPath, this.stateFilePath);
14600
- await fs11.unlink(tempPath).catch(() => {
14601
- });
14602
- } else {
14603
- await fs11.unlink(tempPath).catch(() => {
15340
+ if (await replaceFile(tempPath, this.stateFilePath) === "copied") {
15341
+ await fs12.unlink(tempPath).catch(() => {
14604
15342
  });
14605
- throw renameErr;
14606
15343
  }
15344
+ } catch (renameErr) {
15345
+ await fs12.unlink(tempPath).catch(() => {
15346
+ });
15347
+ throw renameErr;
14607
15348
  }
14608
15349
  }
14609
15350
  /**
@@ -14910,6 +15651,41 @@ async function recoverFromLeaderHandover(container) {
14910
15651
  (err) => logger.debug("Wait for browser idle timed out or failed:", err)
14911
15652
  );
14912
15653
  }
15654
+ async function isRegisteredLeaderAliveUncached(container) {
15655
+ try {
15656
+ const stateManager = await getService(ServiceNames.STATE_MANAGER, void 0, container);
15657
+ const info = await stateManager.getBrowserServer();
15658
+ if (!info) return false;
15659
+ const schedulerService = await getService(ServiceNames.SCHEDULER, void 0, container);
15660
+ const cached = schedulerService.getSchedulerInstance();
15661
+ if (cached instanceof BrowserClient && cached.targetPort !== info.port) return false;
15662
+ return await stateManager.isPidAlive(info.pid, info.schedulerId);
15663
+ } catch {
15664
+ return false;
15665
+ }
15666
+ }
15667
+ var LEADER_LIVENESS_COALESCE_MS = 1e3;
15668
+ var leaderLivenessProbe = null;
15669
+ async function isRegisteredLeaderAlive(container) {
15670
+ const now = Date.now();
15671
+ if (leaderLivenessProbe && now - leaderLivenessProbe.startedAt < LEADER_LIVENESS_COALESCE_MS) {
15672
+ return leaderLivenessProbe.result;
15673
+ }
15674
+ const result = isRegisteredLeaderAliveUncached(container);
15675
+ leaderLivenessProbe = { startedAt: now, result };
15676
+ return result;
15677
+ }
15678
+ async function restartSchedulerWithHerdGuard(container) {
15679
+ const now = Date.now();
15680
+ if (now - lastRestartTime > RESTART_COOLDOWN_MS || !await isRegisteredLeaderAlive(container)) {
15681
+ lastRestartTime = now;
15682
+ logger.warn("[BrowserManager] Forcing scheduler restart and retrying...");
15683
+ await forceSchedulerRestart2(false, container);
15684
+ } else {
15685
+ logger.warn("[BrowserManager] Scheduler restart triggered recently and the registered leader is still alive \u2014 waiting for pool idle instead...");
15686
+ }
15687
+ await waitForBrowserPoolIdle(15e3).catch((err) => logger.debug("Wait for browser idle timed out or failed:", err));
15688
+ }
14913
15689
  async function getScheduler2(config, container = getServiceContainer()) {
14914
15690
  try {
14915
15691
  const factory = await getService(ServiceNames.SCHEDULER_FACTORY, void 0, container);
@@ -14972,16 +15748,7 @@ async function runBrowserTask(taskOrUrl, type = "scrape", config, signal, retrie
14972
15748
  logger.warn(`[BrowserManager] Pool is draining \u2014 waiting for pool idle before retry...`);
14973
15749
  await waitForBrowserPoolIdle(15e3).catch((err) => logger.debug("Wait for browser idle timed out or failed:", err));
14974
15750
  } else {
14975
- const now = Date.now();
14976
- if (now - lastRestartTime > RESTART_COOLDOWN_MS) {
14977
- lastRestartTime = now;
14978
- logger.warn(`[BrowserManager] Forcing scheduler restart and retrying...`);
14979
- await forceSchedulerRestart2(false, container);
14980
- await waitForBrowserPoolIdle(15e3).catch((err) => logger.debug("Wait for browser idle timed out or failed:", err));
14981
- } else {
14982
- logger.warn(`[BrowserManager] Scheduler restart recently triggered, waiting for pool idle...`);
14983
- await waitForBrowserPoolIdle(15e3).catch((err) => logger.debug("Wait for browser idle timed out or failed:", err));
14984
- }
15751
+ await restartSchedulerWithHerdGuard(container);
14985
15752
  }
14986
15753
  const jitter = 100 + Math.floor(Math.random() * 400);
14987
15754
  await new Promise((resolve8) => setTimeout(resolve8, jitter));
@@ -15007,15 +15774,7 @@ async function runBrowserHealthCheck(config, retries = 1, signal, container = ge
15007
15774
  if (isPoolShutdownError(error)) {
15008
15775
  await recoverFromLeaderHandover(container);
15009
15776
  } else {
15010
- const now = Date.now();
15011
- if (now - lastRestartTime > RESTART_COOLDOWN_MS) {
15012
- lastRestartTime = now;
15013
- logger.warn(`[BrowserManager] Forcing scheduler restart and retrying...`);
15014
- await forceSchedulerRestart2(false, container);
15015
- await waitForBrowserPoolIdle(15e3).catch((err) => logger.debug("Wait for browser idle timed out or failed:", err));
15016
- } else {
15017
- await waitForBrowserPoolIdle(15e3).catch((err) => logger.debug("Wait for browser idle timed out or failed:", err));
15018
- }
15777
+ await restartSchedulerWithHerdGuard(container);
15019
15778
  }
15020
15779
  const jitter = 100 + Math.floor(Math.random() * 400);
15021
15780
  await new Promise((resolve8) => setTimeout(resolve8, jitter));
@@ -15059,15 +15818,7 @@ async function runWorkerSearch(query, config, signal, retries = 1, sessionId, co
15059
15818
  if (isPoolShutdownError(error)) {
15060
15819
  await waitForBrowserPoolIdle(15e3).catch((err) => logger.debug("Wait for browser idle timed out or failed:", err));
15061
15820
  } else {
15062
- const now = Date.now();
15063
- if (now - lastRestartTime > RESTART_COOLDOWN_MS) {
15064
- lastRestartTime = now;
15065
- logger.warn(`[BrowserManager] Forcing scheduler restart and retrying...`);
15066
- await forceSchedulerRestart2(false, container);
15067
- await waitForBrowserPoolIdle(15e3).catch((err) => logger.debug("Wait for browser idle timed out or failed:", err));
15068
- } else {
15069
- await waitForBrowserPoolIdle(15e3).catch((err) => logger.debug("Wait for browser idle timed out or failed:", err));
15070
- }
15821
+ await restartSchedulerWithHerdGuard(container);
15071
15822
  }
15072
15823
  const jitter = 100 + Math.floor(Math.random() * 400);
15073
15824
  await new Promise((resolve8) => setTimeout(resolve8, jitter));
@@ -15083,7 +15834,7 @@ init_safe_unref();
15083
15834
  init_url_utils();
15084
15835
  init_metrics();
15085
15836
  init_service_registry();
15086
- async function performSearch(queries, config, signal, onProgress, container = getServiceContainer()) {
15837
+ async function performSearch(queries, config, signal, onProgress, container = getServiceContainer(), failures) {
15087
15838
  const startTime = Date.now();
15088
15839
  const resultMap = /* @__PURE__ */ new Map();
15089
15840
  const seenUrls = /* @__PURE__ */ new Set();
@@ -15106,7 +15857,7 @@ async function performSearch(queries, config, signal, onProgress, container = ge
15106
15857
  logger.log(`[Search] Orchestrating ${queries.length} queries across ${maxWorkers} worker processes...`);
15107
15858
  if (onProgress) onProgress(0);
15108
15859
  const QUERY_TIMEOUT_MS = (config?.SEARCH_TIMEOUT_MS ?? 45e3) + (config?.BROWSER_TASK_TIMEOUT_MS ?? 1e4);
15109
- const filteredQueries = queries.filter((q) => q.trim());
15860
+ const filteredQueries = [...new Set(queries.filter((q) => q.trim()))];
15110
15861
  let timeoutCount = 0;
15111
15862
  let errorCount = 0;
15112
15863
  let sampleWorkerError = "";
@@ -15151,11 +15902,24 @@ async function performSearch(queries, config, signal, onProgress, container = ge
15151
15902
  metrics.increment("browser_search_queries_total", 1, { status });
15152
15903
  if (isTimeout) {
15153
15904
  logger.warn(`[Search] Query timed out after ${QUERY_TIMEOUT_MS}ms \u2014 likely starved waiting for a free browser worker under concurrent load, or a slow/blocked search provider: "${query}"`);
15905
+ failures?.set(query, {
15906
+ type: "timeout",
15907
+ message: `Search timed out after ${QUERY_TIMEOUT_MS}ms before returning anything. This says nothing about the query \u2014 the browser pool was saturated or the search provider was slow/blocked. Retrying the same query later is reasonable.`
15908
+ });
15154
15909
  } else {
15155
15910
  const msg2 = error instanceof Error ? error.message : String(error);
15156
15911
  if (msg2 !== "Aborted") {
15157
15912
  if (!sampleWorkerError) sampleWorkerError = msg2;
15158
15913
  logger.error(`[Search] Worker failed for "${query}": ${msg2}`);
15914
+ failures?.set(query, {
15915
+ type: "service_unavailable",
15916
+ message: `The search backend failed for this query (${msg2}). This is an infrastructure failure, not a signal about the query itself \u2014 do not rewrite the query in response to it.`
15917
+ });
15918
+ } else if (!signal?.aborted) {
15919
+ failures?.set(query, {
15920
+ type: "service_unavailable",
15921
+ message: "The search task was cancelled on the worker side (pool drain or leader handover) before completing. This says nothing about the query \u2014 do not rewrite it; retrying the same query later is reasonable."
15922
+ });
15159
15923
  }
15160
15924
  }
15161
15925
  resultMap.set(query, []);
@@ -15165,6 +15929,9 @@ async function performSearch(queries, config, signal, onProgress, container = ge
15165
15929
  }
15166
15930
  });
15167
15931
  await Promise.all(searchTasks);
15932
+ if (signal?.aborted) {
15933
+ throw new Error("Aborted");
15934
+ }
15168
15935
  const totalResults = Array.from(resultMap.values()).reduce((sum, r) => sum + r.length, 0);
15169
15936
  if (totalResults === 0 && filteredQueries.length > 0) {
15170
15937
  metrics.increment("browser_search_total_failures_total", 1);
@@ -15195,7 +15962,8 @@ async function search(queries, config, signal, onProgress, container = getServic
15195
15962
  metrics.observe("search_query_count_per_request", queries.length);
15196
15963
  const searchStart = Date.now();
15197
15964
  try {
15198
- const resultMap = await performSearch(queries, config, signal, onProgress, container);
15965
+ const failures = /* @__PURE__ */ new Map();
15966
+ const resultMap = await performSearch(queries, config, signal, onProgress, container, failures);
15199
15967
  const searchDuration = Date.now() - searchStart;
15200
15968
  metrics.observe("search_latency_ms", searchDuration);
15201
15969
  let totalResults = 0;
@@ -15209,7 +15977,7 @@ async function search(queries, config, signal, onProgress, container = getServic
15209
15977
  successfulQueries++;
15210
15978
  metrics.observe("search_results_per_query", qResults.length);
15211
15979
  } else {
15212
- result.error = {
15980
+ result.error = failures.get(q) ?? {
15213
15981
  type: "empty_results",
15214
15982
  message: "Browser-based search returned no relevant results for this query (the query may be too narrow, or the site returned nothing usable)."
15215
15983
  };
@@ -15224,10 +15992,13 @@ async function search(queries, config, signal, onProgress, container = getServic
15224
15992
  return results;
15225
15993
  } catch (error) {
15226
15994
  const searchDuration = Date.now() - searchStart;
15995
+ const message = error instanceof Error ? error.message : String(error);
15996
+ if (signal?.aborted || message === "Aborted") {
15997
+ throw error;
15998
+ }
15227
15999
  metrics.observe("search_latency_ms", searchDuration, { status: "error" });
15228
16000
  metrics.increment("search_queries_total", queries.length, { status: "error" });
15229
16001
  metrics.increment("search_errors_total", 1, { error_type: "search_failed" });
15230
- const message = error instanceof Error ? error.message : String(error);
15231
16002
  if (message.includes("Search completely failed")) {
15232
16003
  metrics.increment("search_errors_total", 1, { error_type: "total_search_failure" });
15233
16004
  throw error;
@@ -15602,7 +16373,9 @@ function createSearchTool(options) {
15602
16373
  markdown += `## Query ${i + 1}: ${r.query}
15603
16374
  `;
15604
16375
  if (r.results.length === 0) {
15605
- markdown += `*No results found.*
16376
+ markdown += r.error ? `*${r.error.message}*
16377
+
16378
+ ` : `*No results found.*
15606
16379
 
15607
16380
  `;
15608
16381
  } else {
@@ -15746,7 +16519,7 @@ init_logger();
15746
16519
  import * as dns from "node:dns/promises";
15747
16520
  import * as dnsCb from "node:dns";
15748
16521
  import * as net3 from "node:net";
15749
- import crypto8 from "node:crypto";
16522
+ import crypto9 from "node:crypto";
15750
16523
  import { NodeHtmlMarkdown } from "node-html-markdown";
15751
16524
  function isBenignScrapeFailure(error) {
15752
16525
  const msg2 = error instanceof Error ? error.message : String(error);
@@ -15757,7 +16530,7 @@ function isBenignScrapeFailure(error) {
15757
16530
  }
15758
16531
  function getRandomUserAgent() {
15759
16532
  try {
15760
- const index = crypto8.randomInt(0, USER_AGENTS.length);
16533
+ const index = crypto9.randomInt(0, USER_AGENTS.length);
15761
16534
  const userAgent = USER_AGENTS[index];
15762
16535
  return userAgent ?? USER_AGENTS[0];
15763
16536
  } catch {
@@ -15999,6 +16772,7 @@ function validateContent(html, markdown, url) {
15999
16772
  // src/web-research/web-scraper.ts
16000
16773
  init_retry_utils();
16001
16774
  init_safe_unref();
16775
+ init_http_body();
16002
16776
  init_service_registry();
16003
16777
  var FETCH_RETRY_DELAY_MS = 250;
16004
16778
  var playwrightAvailable = false;
@@ -16095,48 +16869,6 @@ function decodeHtmlBody(bytes, contentTypeHeader) {
16095
16869
  return new TextDecoder("utf-8").decode(bytes);
16096
16870
  }
16097
16871
  }
16098
- var BodyTooLargeError = class extends Error {
16099
- constructor(bytesRead, maxBytes) {
16100
- super(`Response body exceeded ${maxBytes} byte cap`);
16101
- this.bytesRead = bytesRead;
16102
- this.maxBytes = maxBytes;
16103
- this.name = "BodyTooLargeError";
16104
- }
16105
- bytesRead;
16106
- maxBytes;
16107
- };
16108
- async function readBodyCapped(response, maxBytes) {
16109
- const body = response.body;
16110
- if (!body || typeof body.getReader !== "function") {
16111
- return new Uint8Array(await response.arrayBuffer());
16112
- }
16113
- const reader = body.getReader();
16114
- const chunks = [];
16115
- let total = 0;
16116
- try {
16117
- for (; ; ) {
16118
- const { done, value } = await reader.read();
16119
- if (done) break;
16120
- if (value) {
16121
- total += value.byteLength;
16122
- if (total > maxBytes) throw new BodyTooLargeError(total, maxBytes);
16123
- chunks.push(value);
16124
- }
16125
- }
16126
- } finally {
16127
- try {
16128
- await reader.cancel();
16129
- } catch {
16130
- }
16131
- }
16132
- const out = new Uint8Array(total);
16133
- let offset = 0;
16134
- for (const c of chunks) {
16135
- out.set(c, offset);
16136
- offset += c.byteLength;
16137
- }
16138
- return out;
16139
- }
16140
16872
  function trackFetchLayerFailure(url, error) {
16141
16873
  const isSsrf = error instanceof Error && error.message.includes("not allowed");
16142
16874
  errorTracker.trackError(error instanceof Error ? error : String(error), {
@@ -16710,6 +17442,7 @@ init_retry_utils();
16710
17442
  init_circuit_breaker();
16711
17443
  init_safe_unref();
16712
17444
  init_metrics();
17445
+ init_http_body();
16713
17446
 
16714
17447
  // src/security/nvd-types.ts
16715
17448
  function isWeaknessDescription(value) {
@@ -16998,18 +17731,20 @@ async function fetchPaginated(term, options, maxResults, severityVersion, admitE
16998
17731
  const response = await fetchWithRetry(url, signal);
16999
17732
  let data;
17000
17733
  try {
17001
- data = await response.json();
17002
- } catch {
17003
- metrics.increment("nvd_search_errors_total", 1, { error_type: "malformed_json" });
17734
+ data = await readJsonCapped(response, MAX_BULK_JSON_BODY_BYTES);
17735
+ } catch (err) {
17736
+ metrics.increment("nvd_search_errors_total", 1, {
17737
+ error_type: err instanceof BodyTooLargeError ? "body_too_large" : "malformed_json"
17738
+ });
17004
17739
  break;
17005
17740
  }
17006
17741
  const rawEntries = extractNVDEntries(data);
17007
17742
  const admitted = rawEntries.filter(admitEntry).map((entry) => parseNVDEntry(entry, options));
17008
17743
  if (rawEntries.length === 0) {
17009
- metrics.increment("nvd_cache_misses_total", 1, { term });
17744
+ metrics.increment("nvd_cache_misses_total", 1);
17010
17745
  break;
17011
17746
  }
17012
- metrics.increment("nvd_cache_hits_total", 1, { term });
17747
+ metrics.increment("nvd_cache_hits_total", 1);
17013
17748
  allVulnerabilities.push(...admitted);
17014
17749
  const responseData = data;
17015
17750
  if (responseData?.["totalResults"] !== void 0 && typeof responseData["totalResults"] === "number" && startIndex + pageSize >= responseData["totalResults"]) {
@@ -17103,6 +17838,7 @@ async function searchNVD(terms, options) {
17103
17838
  init_retry_utils();
17104
17839
  init_circuit_breaker();
17105
17840
  init_metrics();
17841
+ init_http_body();
17106
17842
  var CISA_KEV_URL = "https://www.cisa.gov/sites/default/files/feeds/known_exploited_vulnerabilities.json";
17107
17843
  var cisaCircuitBreaker = new CircuitBreaker({
17108
17844
  failureThreshold: 5,
@@ -17186,10 +17922,14 @@ async function searchCisaKev(terms = [], options) {
17186
17922
  }, options?.signal);
17187
17923
  let data;
17188
17924
  try {
17189
- data = await response.json();
17190
- } catch {
17925
+ data = await readJsonCapped(response, MAX_BULK_JSON_BODY_BYTES);
17926
+ } catch (err) {
17927
+ if (err instanceof BodyTooLargeError) {
17928
+ metrics.increment("cisa_kev_errors_total", 1, { error_type: "body_too_large" });
17929
+ throw err;
17930
+ }
17191
17931
  metrics.increment("cisa_kev_errors_total", 1, { error_type: "malformed_json" });
17192
- throw new Error("CISA KEV feed returned a malformed JSON response.");
17932
+ throw new Error("CISA KEV feed returned a malformed JSON response.", { cause: err });
17193
17933
  }
17194
17934
  const cisaData = extractCisaKevItems(data);
17195
17935
  metrics.increment(cisaData.length > 0 ? "cisa_kev_cache_hits_total" : "cisa_kev_cache_misses_total", 1);
@@ -17312,6 +18052,7 @@ function normalizeEcosystem(value, target) {
17312
18052
  init_retry_utils();
17313
18053
  init_circuit_breaker();
17314
18054
  init_metrics();
18055
+ init_http_body();
17315
18056
  init_logger();
17316
18057
 
17317
18058
  // src/security/github-advisory-types.ts
@@ -17533,10 +18274,14 @@ async function searchGitHubAdvisories(terms, options) {
17533
18274
  }));
17534
18275
  let data;
17535
18276
  try {
17536
- data = await response.json();
17537
- } catch {
18277
+ data = await readJsonCapped(response);
18278
+ } catch (err) {
18279
+ if (err instanceof BodyTooLargeError) {
18280
+ metrics.increment("github_search_errors_total", 1, { error_type: "body_too_large" });
18281
+ throw err;
18282
+ }
17538
18283
  metrics.increment("github_search_errors_total", 1, { error_type: "malformed_json" });
17539
- throw new Error("GitHub API returned a malformed JSON response (repo advisories).");
18284
+ throw new Error("GitHub API returned a malformed JSON response (repo advisories).", { cause: err });
17540
18285
  }
17541
18286
  let repoAdvisories = [];
17542
18287
  if (isGitHubAdvisoryArray(data)) {
@@ -17602,10 +18347,14 @@ async function searchGitHubAdvisories(terms, options) {
17602
18347
  }));
17603
18348
  let data;
17604
18349
  try {
17605
- data = await response.json();
17606
- } catch {
18350
+ data = await readJsonCapped(response);
18351
+ } catch (err) {
18352
+ if (err instanceof BodyTooLargeError) {
18353
+ metrics.increment("github_search_errors_total", 1, { error_type: "body_too_large", endpoint: endpointType });
18354
+ throw err;
18355
+ }
17607
18356
  metrics.increment("github_search_errors_total", 1, { error_type: "malformed_json", endpoint: endpointType });
17608
- throw new Error(`GitHub API returned a malformed JSON response for term "${term}".`);
18357
+ throw new Error(`GitHub API returned a malformed JSON response for term "${term}".`, { cause: err });
17609
18358
  }
17610
18359
  let items = [];
17611
18360
  if (isGitHubAdvisoryArray(data)) {
@@ -17615,7 +18364,7 @@ async function searchGitHubAdvisories(terms, options) {
17615
18364
  } else if (isGitHubAdvisoryListResponse(data) && isArray(data.items)) {
17616
18365
  items = data.items.filter(isGitHubAdvisoryRaw);
17617
18366
  }
17618
- metrics.increment(items.length > 0 ? "github_cache_hits_total" : "github_cache_misses_total", 1, { term, endpoint: endpointType });
18367
+ metrics.increment(items.length > 0 ? "github_cache_hits_total" : "github_cache_misses_total", 1, { endpoint: endpointType });
17619
18368
  return items.map(mapGitHubAdvisory);
17620
18369
  };
17621
18370
  const settled = await Promise.allSettled(terms.map(fetchTerm));
@@ -17686,6 +18435,7 @@ init_logger();
17686
18435
  init_constants();
17687
18436
  init_circuit_breaker();
17688
18437
  init_metrics();
18438
+ init_http_body();
17689
18439
 
17690
18440
  // src/security/cvss.ts
17691
18441
  var AV = { N: 0.85, A: 0.62, L: 0.55, P: 0.2 };
@@ -17957,10 +18707,14 @@ async function searchOSV(terms, options) {
17957
18707
  }
17958
18708
  let data;
17959
18709
  try {
17960
- data = await response.json();
17961
- } catch {
18710
+ data = await readJsonCapped(response);
18711
+ } catch (err) {
18712
+ if (err instanceof BodyTooLargeError) {
18713
+ metrics.increment("osv_search_errors_total", 1, { error_type: "body_too_large" });
18714
+ throw err;
18715
+ }
17962
18716
  metrics.increment("osv_search_errors_total", 1, { error_type: "malformed_json" });
17963
- throw new Error(`OSV returned a malformed JSON response for "${term}".`);
18717
+ throw new Error(`OSV returned a malformed JSON response for "${term}".`, { cause: err });
17964
18718
  }
17965
18719
  let items;
17966
18720
  if (isOsvVulnerability(data)) {
@@ -17972,7 +18726,7 @@ async function searchOSV(terms, options) {
17972
18726
  return { vulns: [], skipped: false };
17973
18727
  }
17974
18728
  const endpoint = isIdLookup ? "vulns_by_id" : "query";
17975
- metrics.increment(items.length > 0 ? "osv_cache_hits_total" : "osv_cache_misses_total", 1, { term, endpoint });
18729
+ metrics.increment(items.length > 0 ? "osv_cache_hits_total" : "osv_cache_misses_total", 1, { endpoint });
17976
18730
  const vulns = [];
17977
18731
  for (const item of items) {
17978
18732
  if (item.withdrawn) continue;
@@ -18565,6 +19319,7 @@ import { Value as Value8 } from "typebox/value";
18565
19319
  init_logger();
18566
19320
  init_circuit_breaker();
18567
19321
  init_metrics();
19322
+ init_http_body();
18568
19323
  var API_BASE = "https://api.stackexchange.com/2.3";
18569
19324
  var StackExchangeClient = class {
18570
19325
  _apiKey;
@@ -18629,8 +19384,9 @@ var StackExchangeClient = class {
18629
19384
  });
18630
19385
  let data;
18631
19386
  try {
18632
- data = await response.json();
19387
+ data = await readJsonCapped(response);
18633
19388
  } catch (parseErr) {
19389
+ if (parseErr instanceof BodyTooLargeError) throw parseErr;
18634
19390
  if (!response.ok) throw new Error(`HTTP ${response.status} from Stack Exchange API`, { cause: parseErr });
18635
19391
  throw parseErr;
18636
19392
  }
@@ -19404,6 +20160,7 @@ init_retry_utils();
19404
20160
  init_circuit_breaker();
19405
20161
  init_logger();
19406
20162
  init_metrics();
20163
+ init_http_body();
19407
20164
 
19408
20165
  // src/youtube/potoken.ts
19409
20166
  init_logger();
@@ -19636,7 +20393,7 @@ async function fetchOne(session, videoId, contentToken, lang, timeoutMs, signal,
19636
20393
  });
19637
20394
  throw new Error(`HTTP ${response.status} from timedtext endpoint`);
19638
20395
  }
19639
- const body = await response.text();
20396
+ const body = await readTextCapped(response);
19640
20397
  const text = parseJson3(body);
19641
20398
  if (!text) {
19642
20399
  throw new Error("Empty transcript body (bot-protection rejected the token).");
@@ -21025,7 +21782,7 @@ ${prompt}`);
21025
21782
  modelRegistry: ctx.modelRegistry,
21026
21783
  systemPrompt: prompt,
21027
21784
  extensionCtx: ctx,
21028
- excludeTools: this.options.excludeTools || ["grep"],
21785
+ excludeTools: resolveExcludedTools(this.options.excludeTools),
21029
21786
  config: this.config,
21030
21787
  getGlobalState: () => ({
21031
21788
  version: 1,
@@ -21354,6 +22111,9 @@ var DeepResearchOrchestrator = class {
21354
22111
  excludeTools: this.options.excludeTools,
21355
22112
  steeringMessages: steeringTexts
21356
22113
  });
22114
+ } else if (this.currentRound >= maxRounds) {
22115
+ logger.log(`[DeepOrchestrator] Round cap (${maxRounds}) reached \u2014 skipping the evaluator and going straight to final synthesis ${this.elapsed()}`);
22116
+ break;
21357
22117
  } else {
21358
22118
  const synthesisService2 = await getService(ServiceNames.RESEARCH_SYNTHESIS_SERVICE, ctx, container);
21359
22119
  observer?.onEvaluationStart?.(this.currentRound);
@@ -21479,7 +22239,7 @@ var DeepResearchOrchestrator = class {
21479
22239
  options: {
21480
22240
  ...this.options,
21481
22241
  config: this.config,
21482
- excludeTools: this.options.excludeTools || ["grep"],
22242
+ excludeTools: resolveExcludedTools(this.options.excludeTools),
21483
22243
  observer: this.observer
21484
22244
  },
21485
22245
  currentRound: this.currentRound,
@@ -21656,7 +22416,7 @@ var ResearchOrchestrationService = class {
21656
22416
  const { ctx, query, depth = 0, observer, onUpdate, sessionId, researchId, config, excludeTools } = options;
21657
22417
  const researchConfig = config || getConfig(ctx.cwd);
21658
22418
  const configDisabledTools = researchConfig.DISABLED_TOOLS ? researchConfig.DISABLED_TOOLS.split(",").map((s) => s.trim()).filter(Boolean) : [];
21659
- const mergedExcludeTools = configDisabledTools.length > 0 ? [.../* @__PURE__ */ new Set([...excludeTools ?? [], ...configDisabledTools])] : excludeTools;
22419
+ const mergedExcludeTools = resolveExcludedTools(excludeTools, configDisabledTools);
21660
22420
  const selectedModel = await this.resolveResearchModel(options);
21661
22421
  logger.info(`[ResearchOrchestrationService] Using model: ${selectedModel.provider}/${selectedModel.id}`);
21662
22422
  const researchStart = Date.now();
@@ -22178,42 +22938,6 @@ init_service_registry();
22178
22938
  init_service_names();
22179
22939
  var NO_SOURCES_NOTE = "CITED LINKS\n_No web sources were successfully retrieved for this report \u2014 searches or page fetches may have been blocked (for example by site bot-protection) or returned no usable results. Treat the findings above as unverified._";
22180
22940
  var UNVERIFIED_LINKS_NOTE = "_None of the links above could be verified: no page fetch succeeded this session and no citation could be matched to retrieved content. They may be inaccurate or invented \u2014 check each one before relying on it._";
22181
- var MAX_PLAUSIBLE_CITATION_MARKER = 200;
22182
- function rewriteCitationMarkers(text, remap, isDangling) {
22183
- const inFence = fencedCodeMask(text);
22184
- return text.replace(/([ \t]*)\[(\d+)\]/g, (match, pad, digits, offset) => {
22185
- const n = parseInt(digits, 10);
22186
- const mapped = remap.get(n);
22187
- if (mapped !== void 0) return `${pad}[${mapped}]`;
22188
- if (n < 1 || n > MAX_PLAUSIBLE_CITATION_MARKER || !isDangling(n)) return match;
22189
- if (inFence(offset) || inInlineCodeSpan(text, offset)) return match;
22190
- const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
22191
- return text.slice(lineStart, offset).trim() === "" ? pad : "";
22192
- });
22193
- }
22194
- function fencedCodeMask(text) {
22195
- const ranges = [];
22196
- let open4 = -1;
22197
- let pos = 0;
22198
- for (const line of text.split("\n")) {
22199
- if (/^\s{0,3}(```|~~~)/.test(line)) {
22200
- if (open4 < 0) open4 = pos;
22201
- else {
22202
- ranges.push([open4, pos + line.length]);
22203
- open4 = -1;
22204
- }
22205
- }
22206
- pos += line.length + 1;
22207
- }
22208
- if (open4 >= 0) ranges.push([open4, text.length]);
22209
- return (offset) => ranges.some(([start, end]) => offset >= start && offset < end);
22210
- }
22211
- function inInlineCodeSpan(text, offset) {
22212
- const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
22213
- let ticks = 0;
22214
- for (let i = lineStart; i < offset; i++) if (text[i] === "`") ticks++;
22215
- return ticks % 2 === 1;
22216
- }
22217
22941
  function redactUnverifiedProseUrls(text, trusted) {
22218
22942
  if (trusted.size === 0) return text;
22219
22943
  let removed = 0;
@@ -22257,6 +22981,23 @@ var ResearchSynthesisService = class _ResearchSynthesisService {
22257
22981
  this.sessions.set(sessionId, reports);
22258
22982
  return reports;
22259
22983
  }
22984
+ /**
22985
+ * Read a session's reports WITHOUT creating an entry for an unknown id.
22986
+ *
22987
+ * The read-only accessors below must not go through `getSessionReports`: that
22988
+ * is create-on-read, so a query for an id we have never seen inserts an empty
22989
+ * map — and at MAX_SESSIONS that insert evicts the current head. The LRU touch
22990
+ * protects sessions that are read or written, but an in-flight run whose
22991
+ * researchers are all still executing (reports stored early, nothing touched
22992
+ * since) is exactly the stalest key, so a stray read for an unknown session
22993
+ * could evict a live run's reports and silently shrink its final synthesis.
22994
+ *
22995
+ * Returns a shared empty map for unknown ids — callers here only read.
22996
+ */
22997
+ peekSessionReports(sessionId) {
22998
+ return this.sessions.get(sessionId) ?? _ResearchSynthesisService.EMPTY_REPORTS;
22999
+ }
23000
+ static EMPTY_REPORTS = /* @__PURE__ */ new Map();
22260
23001
  /**
22261
23002
  * Store a researcher report
22262
23003
  * @param sessionId - Session identifier
@@ -22270,13 +23011,13 @@ var ResearchSynthesisService = class _ResearchSynthesisService {
22270
23011
  * Get a report by ID
22271
23012
  */
22272
23013
  getReport(sessionId, id) {
22273
- return this.getSessionReports(sessionId).get(id);
23014
+ return this.peekSessionReports(sessionId).get(id);
22274
23015
  }
22275
23016
  /**
22276
23017
  * Get all reports
22277
23018
  */
22278
23019
  getAllReports(sessionId) {
22279
- return new Map(this.getSessionReports(sessionId));
23020
+ return new Map(this.peekSessionReports(sessionId));
22280
23021
  }
22281
23022
  /**
22282
23023
  * Get reports for a specific round
@@ -22284,7 +23025,7 @@ var ResearchSynthesisService = class _ResearchSynthesisService {
22284
23025
  getReportsForRound(sessionId, round) {
22285
23026
  const roundReports = /* @__PURE__ */ new Map();
22286
23027
  const prefix = `${round}.`;
22287
- for (const [key, report] of this.getSessionReports(sessionId).entries()) {
23028
+ for (const [key, report] of this.peekSessionReports(sessionId).entries()) {
22288
23029
  if (key.startsWith(prefix)) {
22289
23030
  roundReports.set(key, report);
22290
23031
  }
@@ -22295,13 +23036,13 @@ var ResearchSynthesisService = class _ResearchSynthesisService {
22295
23036
  * Get total number of reports
22296
23037
  */
22297
23038
  getReportCount(sessionId) {
22298
- return this.getSessionReports(sessionId).size;
23039
+ return this.peekSessionReports(sessionId).size;
22299
23040
  }
22300
23041
  /**
22301
23042
  * Check if there are any reports
22302
23043
  */
22303
23044
  hasReports(sessionId) {
22304
- return this.getSessionReports(sessionId).size > 0;
23045
+ return this.peekSessionReports(sessionId).size > 0;
22305
23046
  }
22306
23047
  /**
22307
23048
  * Clear reports for a session, or all reports if no sessionId provided
@@ -22320,7 +23061,7 @@ var ResearchSynthesisService = class _ResearchSynthesisService {
22320
23061
  * @returns Fallback synthesis string
22321
23062
  */
22322
23063
  buildFallbackSynthesis(sessionId, currentRound = 0) {
22323
- const reports = this.getSessionReports(sessionId);
23064
+ const reports = this.peekSessionReports(sessionId);
22324
23065
  const reportCount = reports.size;
22325
23066
  const roundInfo = currentRound > 0 ? ` (up to Round ${currentRound})` : "";
22326
23067
  let synthesis = `Research Findings${roundInfo}
@@ -22356,7 +23097,7 @@ Research performed using ${modelId}`;
22356
23097
  * @returns Synthesis with guaranteed and verified CITED LINKS section
22357
23098
  */
22358
23099
  ensureCitedLinks(sessionId, synthesis) {
22359
- const reports = this.getSessionReports(sessionId);
23100
+ const reports = this.peekSessionReports(sessionId);
22360
23101
  let globalCitations = reports.size > 0 ? normalizeCitations(reports).globalCitations : [];
22361
23102
  if (reports.size > 0) {
22362
23103
  const scraped = getScrapedLinks(sessionId);
@@ -22455,7 +23196,7 @@ ${guidanceSection}`;
22455
23196
  extractAllCitations(sessionId) {
22456
23197
  const seen = /* @__PURE__ */ new Set();
22457
23198
  const allCitations = [];
22458
- const reports = this.getSessionReports(sessionId);
23199
+ const reports = this.peekSessionReports(sessionId);
22459
23200
  for (const report of reports.values()) {
22460
23201
  const citations = parseCitations(report);
22461
23202
  for (const cit of citations) {
@@ -23117,10 +23858,10 @@ init_url_utils();
23117
23858
  // src/utils/research-export.ts
23118
23859
  init_logger();
23119
23860
  init_constants();
23120
- import { promises as fs14 } from "node:fs";
23861
+ import { promises as fs15 } from "node:fs";
23121
23862
  import { homedir as homedir4, tmpdir as tmpdir4, platform as platform3 } from "node:os";
23122
- import { join as join18, resolve as resolve3, normalize, sep as sep2 } from "node:path";
23123
- import { randomBytes as randomBytes5 } from "node:crypto";
23863
+ import { join as join19, resolve as resolve3, normalize, sep as sep2 } from "node:path";
23864
+ import { randomBytes as randomBytes6 } from "node:crypto";
23124
23865
  var PREFERRED_SUBDIRS = [
23125
23866
  "research",
23126
23867
  "docs",
@@ -23183,16 +23924,16 @@ function isSafeToWriteDirectly(dir) {
23183
23924
  async function findPreferredSubdir(cwd) {
23184
23925
  let entries;
23185
23926
  try {
23186
- entries = await fs14.readdir(cwd);
23927
+ entries = await fs15.readdir(cwd);
23187
23928
  } catch {
23188
23929
  return null;
23189
23930
  }
23190
23931
  for (const target of PREFERRED_SUBDIRS) {
23191
23932
  const match = entries.find((e) => e.toLowerCase() === target);
23192
23933
  if (match === void 0) continue;
23193
- const candidate = join18(cwd, match);
23934
+ const candidate = join19(cwd, match);
23194
23935
  try {
23195
- const stat5 = await fs14.stat(candidate);
23936
+ const stat5 = await fs15.stat(candidate);
23196
23937
  if (stat5.isDirectory()) return candidate;
23197
23938
  } catch {
23198
23939
  }
@@ -23215,7 +23956,7 @@ function sanitizeQuery2(query) {
23215
23956
  return query.toLowerCase().trim().slice(0, MAX_FILENAME_QUERY_LENGTH).replace(/[^a-z0-9\s-]/g, "").replace(/\s+/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
23216
23957
  }
23217
23958
  function generateHash() {
23218
- return randomBytes5(3).toString("hex");
23959
+ return randomBytes6(3).toString("hex");
23219
23960
  }
23220
23961
  async function exportResearchReport(query, result, _mode, cwd, explicitDir) {
23221
23962
  const sanitizedQuery = sanitizeQuery2(query);
@@ -23224,7 +23965,7 @@ async function exportResearchReport(query, result, _mode, cwd, explicitDir) {
23224
23965
  if (explicitDir && explicitDir.trim()) {
23225
23966
  targetDir = resolve3(explicitDir.trim());
23226
23967
  try {
23227
- await fs14.mkdir(targetDir, { recursive: true });
23968
+ await fs15.mkdir(targetDir, { recursive: true });
23228
23969
  } catch (error) {
23229
23970
  logger.error(`[export] Could not create export dir "${targetDir}":`, error);
23230
23971
  return null;
@@ -23235,9 +23976,9 @@ async function exportResearchReport(query, result, _mode, cwd, explicitDir) {
23235
23976
  for (let attempt = 0; attempt < MAX_EXPORT_RETRIES; attempt++) {
23236
23977
  const hash = generateHash();
23237
23978
  const filename = `${baseFilename}-${hash}.md`;
23238
- const filepath = join18(targetDir, filename);
23979
+ const filepath = join19(targetDir, filename);
23239
23980
  try {
23240
- await fs14.writeFile(filepath, result, { flag: "wx" });
23981
+ await fs15.writeFile(filepath, result, { flag: "wx" });
23241
23982
  logger.log(`[export] Research report saved to: ${filepath}`);
23242
23983
  return filepath;
23243
23984
  } catch (error) {
@@ -23255,8 +23996,8 @@ function appendExportMessage(result, filepath, totalCost) {
23255
23996
  ---
23256
23997
 
23257
23998
  Research report saved to: ${filepath}`;
23258
- if (totalCost !== void 0 && totalCost > 0) {
23259
- const costStr = totalCost < 0.01 ? "<$0.01" : `$${totalCost.toFixed(4)}`.replace(/0+$/, "").replace(/\.$/, "");
23999
+ if (totalCost !== void 0) {
24000
+ const costStr = totalCost === 0 ? "$0.00" : totalCost < 0.01 ? "<$0.01" : `$${totalCost.toFixed(4)}`.replace(/0+$/, "").replace(/\.$/, "");
23260
24001
  footer += `
23261
24002
  Total cost: ${costStr}`;
23262
24003
  }
@@ -23270,7 +24011,7 @@ import { getAgentDir as getAgentDir2 } from "@earendil-works/pi-coding-agent";
23270
24011
  function readPkgVersion() {
23271
24012
  try {
23272
24013
  const pkgPath = fileURLToPath5(new URL("../package.json", import.meta.url));
23273
- const pkg = JSON.parse(fs16.readFileSync(pkgPath, "utf-8"));
24014
+ const pkg = JSON.parse(fs17.readFileSync(pkgPath, "utf-8"));
23274
24015
  return typeof pkg.version === "string" && pkg.version ? pkg.version : "0.0.0";
23275
24016
  } catch {
23276
24017
  return "0.0.0";
@@ -23293,8 +24034,28 @@ var EXIT = {
23293
24034
  * again shortly" apart from "this run crashed" and back off instead of
23294
24035
  * re-running a broken command.
23295
24036
  */
23296
- TEMPFAIL: 75
24037
+ TEMPFAIL: 75,
24038
+ /**
24039
+ * The run was CANCELLED via a programmatic abort (the caller's AbortSignal),
24040
+ * with no OS signal involved. 130 — 128+SIGINT — is the conventional
24041
+ * "interrupted" code and the natural default when there is no signal number to
24042
+ * derive one from.
24043
+ *
24044
+ * A cancellation caused by an actual SIGNAL does NOT use this constant: see
24045
+ * exitCodeForSignal below, which reports the true 128+N.
24046
+ *
24047
+ * Distinct from SOFTWARE for the same reason TEMPFAIL is: a deliberate stop is
24048
+ * not a fault. Reported as SOFTWARE (70), a cancel matched the skill contract's
24049
+ * "runtime error — suggest one retry for transient failures", so an agent
24050
+ * relaying the result would offer to re-run the very research the user had just
24051
+ * interrupted, and a --json consumer could not tell a cancel from a crash.
24052
+ */
24053
+ CANCELLED: 130
23297
24054
  };
24055
+ function exitCodeForSignal(sig) {
24056
+ const signum = os7.constants.signals[sig];
24057
+ return typeof signum === "number" ? 128 + signum : EXIT.CANCELLED;
24058
+ }
23298
24059
  function toStderr(s) {
23299
24060
  process.stderr.write(s);
23300
24061
  }
@@ -23339,10 +24100,16 @@ function makeProgressObserver() {
23339
24100
  toStderr(` \u2022 evaluator: ${data?.action}
23340
24101
  `);
23341
24102
  break;
23342
- case "tokens_consumed":
23343
- if (data?.cost) toStderr(` \u2022 tokens: ${data?.tokens ?? 0} ($${Number(data.cost).toFixed(4)})
24103
+ case "tokens_consumed": {
24104
+ const consumed = Number(data?.tokens ?? 0);
24105
+ const costVal = typeof data?.cost === "number" ? data.cost : void 0;
24106
+ if (consumed > 0 || costVal !== void 0) {
24107
+ const costPart = costVal === void 0 ? "" : ` ($${costVal.toFixed(4)})`;
24108
+ toStderr(` \u2022 tokens: ${consumed}${costPart}
23344
24109
  `);
24110
+ }
23345
24111
  break;
24112
+ }
23346
24113
  case "complete":
23347
24114
  toStderr(` \u2022 complete (${data?.result?.length ?? 0} chars)
23348
24115
  `);
@@ -23362,16 +24129,22 @@ function bridgeConfigEnv(explicitConfigPath) {
23362
24129
  const cliOverlayPath = getInterfaceEnvFilePath("cli");
23363
24130
  let loaded = false;
23364
24131
  const merged = {};
24132
+ const fromExplicitBase = /* @__PURE__ */ new Set();
23365
24133
  for (const filePath of [basePath, cliOverlayPath]) {
23366
24134
  try {
23367
- if (!fs16.existsSync(filePath)) continue;
23368
- Object.assign(merged, parseDotEnv(fs16.readFileSync(filePath, "utf-8")));
24135
+ if (!fs17.existsSync(filePath)) continue;
24136
+ const parsed = parseDotEnv(fs17.readFileSync(filePath, "utf-8"));
24137
+ Object.assign(merged, parsed);
24138
+ for (const k of Object.keys(parsed)) {
24139
+ if (explicitConfigPath !== void 0 && filePath === basePath) fromExplicitBase.add(k);
24140
+ else fromExplicitBase.delete(k);
24141
+ }
23369
24142
  loaded = true;
23370
24143
  } catch {
23371
24144
  }
23372
24145
  }
23373
24146
  for (const [k, v] of Object.entries(merged)) {
23374
- if (isProjectScopedKey(k)) continue;
24147
+ if (isProjectScopedKey(k) && !fromExplicitBase.has(k)) continue;
23375
24148
  if (process.env[k] === void 0 && (k.startsWith("PI_RESEARCH_") || k === "STACKEXCHANGE_API_KEY" || k === "GITHUB_TOKEN" || k === "NVD_API_KEY")) {
23376
24149
  process.env[k] = v;
23377
24150
  }
@@ -23393,7 +24166,7 @@ function resolvedConfigPaths() {
23393
24166
  }
23394
24167
  function fileExists(p) {
23395
24168
  try {
23396
- return fs16.existsSync(p);
24169
+ return fs17.existsSync(p);
23397
24170
  } catch {
23398
24171
  return false;
23399
24172
  }
@@ -23864,18 +24637,26 @@ async function reportError(err, what, json) {
23864
24637
  const lower = msg2.toLowerCase();
23865
24638
  const isTaggedResearchStop = err?.code === RESEARCH_STOPPED_ERROR_CODE;
23866
24639
  const isCapacity = err instanceof Error && err.name === "ResearchRunCapacityError";
23867
- const isConfigError = !isTaggedResearchStop && !isCapacity && (lower.includes("no llm model available") || lower.includes("not found in pi") || lower.includes("no api key") || lower.includes("provider must be specified") || lower.includes("invalid model string") || lower.includes("authentication") || lower.includes("unauthorized") || lower.includes("api key"));
23868
- const isRateLimit = lower.includes("429") || lower.includes("rate limit") || lower.includes("too many requests");
23869
- const exitCode = isCapacity ? EXIT.TEMPFAIL : isConfigError && !isRateLimit ? EXIT.CONFIG : EXIT.SOFTWARE;
24640
+ const isCancellation = cancellationRequested || err instanceof Error && /^(aborted|research (aborted|cancelled))$/i.test(msg2);
24641
+ const isConfigError = !isTaggedResearchStop && !isCapacity && !isCancellation && (lower.includes("no llm model available") || lower.includes("not found in pi") || lower.includes("no api key") || lower.includes("provider must be specified") || lower.includes("invalid model string") || lower.includes("authentication") || lower.includes("unauthorized") || lower.includes("api key"));
24642
+ const isRateLimit = !isCancellation && (lower.includes("429") || lower.includes("rate limit") || lower.includes("too many requests"));
24643
+ const exitCode = isCancellation ? cancellationSignal ? exitCodeForSignal(cancellationSignal) : EXIT.CANCELLED : isCapacity ? EXIT.TEMPFAIL : isConfigError && !isRateLimit ? EXIT.CONFIG : EXIT.SOFTWARE;
23870
24644
  if (json) {
23871
24645
  const payload = { ok: false, error: msg2, exitCode };
23872
- if (isCapacity || isRateLimit) payload["retryable"] = true;
24646
+ if ((isCapacity || isRateLimit) && !isCancellation) payload["retryable"] = true;
24647
+ if (isCancellation) payload["cancelled"] = true;
23873
24648
  if (process.env["PI_RESEARCH_DEBUG"] === "true" && err instanceof Error && err.stack) {
23874
24649
  payload["stack"] = err.stack;
23875
24650
  }
23876
24651
  toStdout(pretty(payload));
23877
24652
  return exitCode;
23878
24653
  }
24654
+ if (isCancellation) {
24655
+ toStderr(`
24656
+ pi-research ${what} cancelled.
24657
+ `);
24658
+ return exitCode;
24659
+ }
23879
24660
  if (isCapacity) {
23880
24661
  toStderr(`
23881
24662
  Error: pi-research ${what} did not start: ${msg2}
@@ -23982,10 +24763,14 @@ CONFIGURE
23982
24763
  Run \`${BINARY_NAME} status\` to see exactly what is detected.
23983
24764
 
23984
24765
  EXIT CODES
23985
- 0 success
23986
- 64 bad arguments
23987
- 78 not configured \u2014 message prints the exact locations to fix
23988
- 70 runtime error (network, provider, internal)
24766
+ 0 success
24767
+ 64 bad arguments
24768
+ 75 at capacity \u2014 every concurrent run slot is busy; nothing is broken, retry later
24769
+ 78 not configured \u2014 message prints the exact locations to fix
24770
+ (on \`knowledge\`, also returned when the store is deliberately disabled)
24771
+ 130 cancelled \u2014 Ctrl-C; nothing failed, do not auto-retry
24772
+ (other stop signals report POSIX 128+N: 143 SIGTERM, 129 SIGHUP, 131 SIGQUIT)
24773
+ 70 runtime error (network, provider, internal)
23989
24774
  `;
23990
24775
  }
23991
24776
  function parseArgs(argv) {
@@ -24180,19 +24965,35 @@ var flushAndExit = (code) => {
24180
24965
  });
24181
24966
  };
24182
24967
  var activeResearchAbortController = null;
24968
+ var cancellationRequested = false;
24969
+ var cancellationSignal = null;
24970
+ function _resetCancellationLatchForTests() {
24971
+ cancellationRequested = false;
24972
+ cancellationSignal = null;
24973
+ }
24974
+ function _markCancellationForTests(sig) {
24975
+ cancellationRequested = true;
24976
+ cancellationSignal = sig ?? null;
24977
+ }
24183
24978
  async function main(argv) {
24184
24979
  let _signalCleanupDone = false;
24185
24980
  const onSignal = (sig) => {
24186
24981
  if (_signalCleanupDone) return;
24187
24982
  _signalCleanupDone = true;
24983
+ cancellationRequested = true;
24984
+ cancellationSignal = sig;
24188
24985
  toStderr(`
24189
24986
  [pi-research] ${sig} \u2014 cleaning up\u2026
24190
24987
  `);
24191
24988
  activeResearchAbortController?.abort();
24192
- void safeShutdown().finally(() => flushAndExit(EXIT.SOFTWARE));
24989
+ void safeShutdown().finally(() => flushAndExit(exitCodeForSignal(sig)));
24193
24990
  };
24194
24991
  process.once("SIGINT", () => onSignal("SIGINT"));
24195
24992
  process.once("SIGTERM", () => onSignal("SIGTERM"));
24993
+ process.once("SIGHUP", () => onSignal("SIGHUP"));
24994
+ if (process.platform === "win32") {
24995
+ process.once("SIGBREAK", () => onSignal("SIGBREAK"));
24996
+ }
24196
24997
  let parsed;
24197
24998
  try {
24198
24999
  parsed = parseArgs(argv);
@@ -24239,8 +25040,8 @@ ${buildHelp()}`);
24239
25040
  var _isMain = (() => {
24240
25041
  try {
24241
25042
  if (!process.argv[1]) return false;
24242
- const modulePath = fs16.realpathSync(path18.resolve(fileURLToPath5(import.meta.url)));
24243
- const invokedPath = fs16.realpathSync(path18.resolve(process.argv[1]));
25043
+ const modulePath = fs17.realpathSync(path18.resolve(fileURLToPath5(import.meta.url)));
25044
+ const invokedPath = fs17.realpathSync(path18.resolve(process.argv[1]));
24244
25045
  return modulePath === invokedPath;
24245
25046
  } catch {
24246
25047
  return false;
@@ -24257,7 +25058,10 @@ Error: fatal: ${err instanceof Error ? err.message : String(err)}
24257
25058
  export {
24258
25059
  EXIT,
24259
25060
  UsageError,
25061
+ _markCancellationForTests,
25062
+ _resetCancellationLatchForTests,
24260
25063
  bridgeConfigEnv,
25064
+ exitCodeForSignal,
24261
25065
  parseArgs,
24262
25066
  reportError
24263
25067
  };