@lincoln504/pi-research 1.7.9 → 1.7.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -49,7 +49,7 @@ npm install -g @lincoln504/pi-research
49
49
  pi-research skill install
50
50
  ```
51
51
 
52
- No extra setup is needed. The package ships ready-made bindings for every platform and declares no build step, so there is nothing to approve or compile. The stealth browser (1GB+ installed) is fetched by the `postinstall` script when npm runs install scripts, which it does by default up to npm 11.18 — that is the bundled npm on the Node 22.22.2 floor this package requires, so expect the fetch during install. Newer npm (11.19+) skips install scripts by default, and the browser is then fetched lazily on the first scrape instead. Either way the first scrape takes a few minutes until it is in place. Set `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` to defer the fetch deliberately. Only Windows with npm older than 11.19 can fail when the install tries to compile from source. Upgrading npm fixes it.
52
+ No extra setup is needed. The package ships ready-made bindings for every platform and declares no build step, so there is nothing to approve or compile. The stealth browser (1GB+ installed) is fetched by the `postinstall` script whenever npm runs dependency install scripts — which is npm 10 through 11.19 (11.16–11.19 print a warning) — and that includes the npm bundled with the Node 22.22.2 floor this package requires (10.9.7) and the npm bundled with Node 24/25 (11.19.0), so expect the fetch during install. npm 12, the current npm, blocks dependency install scripts by default, and the browser is then fetched lazily on the first scrape instead. Either way the first scrape takes a few minutes until it is in place. Set `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1` to defer the fetch deliberately.
53
53
 
54
54
  The `@earendil-works/pi-*` host packages are **peer dependencies**. pi supplies its own copies when it loads this as an extension, and npm ≥7 installs them automatically for a standalone (global or project) install. An install that skips peers does not get them: `npm install --legacy-peer-deps`, yarn classic, and `pi install` itself (it passes that flag on purpose) are all in that group. The standalone engine and the agent-skill launcher then stop with a message naming the missing package; add it with:
55
55
 
@@ -61,6 +61,10 @@ Standalone use needs that package on disk because the CLI and SDK import it dire
61
61
 
62
62
  In pi, the extension works out of the box on the session's model and pi's configuration. Standalone use ([agent skill](docs/AGENT-SKILL.md) or [SDK](docs/SDK.md)) needs a model configured. See [Configuration](docs/CONFIGURATION.md).
63
63
 
64
+ ### Update
65
+
66
+ `pi update --extensions` moves every installed extension to its newest published version; `pi update npm:@lincoln504/pi-research` targets only this one. A versioned source (`npm:@lincoln504/pi-research@1.7.9`) is pinned and never updates — remove and reinstall it to move versions.
67
+
64
68
  ### Uninstall
65
69
 
66
70
  `pi remove npm:@lincoln504/pi-research` removes the extension. `npm uninstall -g @lincoln504/pi-research` removes the standalone engine. npm 7+ no longer runs `preuninstall`, so nothing else is removed on its own: skill links into other agents (Claude Code, Codex, …), the state directory (`~/.pi/research/state`), and the cache (`~/.cache/pi-research`, including any downloaded embedding models) stay in place. Remove the skill links first with `pi-research skill uninstall` (or `/research-config` → Remove from External Agents), then remove the rest with `pi-research cleanup`: The shared stealth-browser cache (`~/.cache/camoufox`) is preserved unless `PI_RESEARCH_PURGE_BROWSERS=1`. See [AGENT-SKILL.md](docs/AGENT-SKILL.md#installation-flow) for the full picture.
@@ -2,7 +2,7 @@
2
2
  name: pi-research
3
3
  description: "Multi-agent web research for any coding agent. Use for anything requiring current information from the internet — news, trends, product/service/company facts, statistics, people, places, events, APIs, library behavior, or 'what is X / how does X work' questions. Also use to search a local knowledge store of previously researched findings before doing live research. Powered by the pi-research engine (search, stealth scraping, security databases, Stack Exchange). Not for reading local files, running project commands, or analyzing this repository's own source."
4
4
  license: MIT
5
- metadata: { "author": "Lincoln Deen", "version": "1.7.9", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
5
+ metadata: { "author": "Lincoln Deen", "version": "1.7.11", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
6
6
  allowed-tools: Bash(node:*)
7
7
  ---
8
8
 
package/dist/cli.mjs CHANGED
@@ -16617,6 +16617,7 @@ init_metrics();
16617
16617
  init_config();
16618
16618
  import { dirname as dirname10, join as join14 } from "node:path";
16619
16619
  import { existsSync as existsSync9 } from "node:fs";
16620
+ import { execFileSync } from "node:child_process";
16620
16621
  import { fileURLToPath as fileURLToPath3 } from "node:url";
16621
16622
  import { FixedClusterPool, WorkerChoiceStrategies } from "poolifier";
16622
16623
 
@@ -16625,7 +16626,7 @@ init_logger();
16625
16626
  import { spawn as spawn2 } from "node:child_process";
16626
16627
  import { createRequire as createRequire6 } from "node:module";
16627
16628
  import { createHash as createHash2 } from "node:crypto";
16628
- import { existsSync as existsSync8, fstatSync, openSync as openSync4, closeSync as closeSync4, rmSync as rmSync2, readFileSync as readFileSync5 } from "node:fs";
16629
+ import { existsSync as existsSync8, fstatSync, openSync as openSync4, closeSync as closeSync4, rmSync as rmSync2, readFileSync as readFileSync5, createWriteStream } from "node:fs";
16629
16630
  import { join as join12, dirname as dirname9 } from "node:path";
16630
16631
  import { tmpdir as tmpdir2, platform as platform3, homedir as homedir5 } from "node:os";
16631
16632
  var FETCH_TIMEOUT_MS = 45 * 60 * 1e3;
@@ -16671,18 +16672,36 @@ function runFetch() {
16671
16672
  delete env["CAMOUFOX_INSTALL_DIR"];
16672
16673
  delete env["PLAYWRIGHT_BROWSERS_PATH"];
16673
16674
  logger.info(`[ensure-browser] Camoufox not found; fetching the browser (this runs once, ~1.3GB): ${process.execPath} ${cli} fetch`);
16674
- const child = spawn2(process.execPath, [cli, "fetch"], { stdio: "inherit", env });
16675
+ const outLogPath = join12(tmpdir2(), `pi-research-camoufox-fetch-${process.pid}-${Date.now()}.log`);
16676
+ const outStream = createWriteStream(outLogPath, { mode: 384 });
16677
+ const child = spawn2(process.execPath, [cli, "fetch"], { stdio: ["ignore", "pipe", "inherit"], env });
16678
+ if (child.stdout) {
16679
+ child.stdout.pipe(outStream);
16680
+ }
16675
16681
  const timer = setTimeout(() => {
16676
16682
  child.kill("SIGKILL");
16677
16683
  reject(new Error(`camoufox fetch timed out after ${FETCH_TIMEOUT_MS}ms`));
16678
16684
  }, FETCH_TIMEOUT_MS);
16679
- child.on("error", (err) => {
16685
+ const finish = (err, ok) => {
16680
16686
  clearTimeout(timer);
16681
- reject(err);
16687
+ void outStream.close();
16688
+ if (err) reject(err);
16689
+ else resolve9(ok);
16690
+ };
16691
+ child.on("error", (err) => {
16692
+ const message = err instanceof Error ? err.message : String(err);
16693
+ finish(new Error(`${message} (fetch output logged at ${outLogPath})`), false);
16682
16694
  });
16683
16695
  child.on("exit", (code) => {
16684
- clearTimeout(timer);
16685
- resolve9(code === 0);
16696
+ if (code === 0) {
16697
+ try {
16698
+ rmSync2(outLogPath, { force: true });
16699
+ } catch {
16700
+ }
16701
+ finish(null, true);
16702
+ } else {
16703
+ finish(new Error(`camoufox fetch exited with code ${code} (fetch output logged at ${outLogPath})`), false);
16704
+ }
16686
16705
  });
16687
16706
  });
16688
16707
  }
@@ -16904,6 +16923,30 @@ function setupMasterIpcErrorHandler() {
16904
16923
  const w = cluster.workers?.[id];
16905
16924
  if (w) guardClusterWorker(w);
16906
16925
  }
16926
+ guardWorkerEmitReemit();
16927
+ }
16928
+ var workerEmitGuardPatched = false;
16929
+ var workerEmitOriginal = null;
16930
+ function guardWorkerEmitReemit() {
16931
+ if (workerEmitGuardPatched || cluster.isWorker) return;
16932
+ const Worker2 = cluster.Worker;
16933
+ if (!Worker2 || !Worker2.prototype) return;
16934
+ workerEmitGuardPatched = true;
16935
+ const origEmit = Worker2.prototype.emit;
16936
+ if (typeof origEmit !== "function") return;
16937
+ workerEmitOriginal = origEmit;
16938
+ Worker2.prototype.emit = function(event, ...args) {
16939
+ if (event === "error" && typeof this.listenerCount === "function" && this.listenerCount("error") === 0) {
16940
+ this.on("error", (err) => {
16941
+ if (isBenignClusterIpcError(err)) {
16942
+ logToDebugFile("DEBUG", `[Master] Last-resort guard swallowed listener-less IPC error: ${err?.code || err?.message}`);
16943
+ return;
16944
+ }
16945
+ logToDebugFile("WARN", `[Master] Last-resort guard handled listener-less worker error: ${err?.stack || err?.message || err}`);
16946
+ });
16947
+ }
16948
+ return origEmit.apply(this, [event, ...args]);
16949
+ };
16907
16950
  }
16908
16951
  function guardClusterWorker(worker) {
16909
16952
  if (!worker || worker.__piIpcGuarded) return;
@@ -17002,6 +17045,34 @@ var WorkerPoolManager = class {
17002
17045
  // Handle for the schedulePoolReset() destroy timer, so shutdown() can cancel a pending
17003
17046
  // reset instead of letting it fire after teardown and clobber a freshly-rebuilt pool.
17004
17047
  _resetTimer = null;
17048
+ // One in-flight destroy promise per pool instance. Both the auto-recovery timer
17049
+ // and shutdown() can try to destroy the same pool (observed 2026-10-08 during a
17050
+ // leader-handover storm: "Cannot destroy an already destroying pool"). A second
17051
+ // destroy() racing the first wipes worker listeners (terminate →
17052
+ // removeAllListeners) while the first is still sending kill messages — the fatal
17053
+ // IPC re-emit window. Sharing one promise per pool makes that race impossible.
17054
+ _destroyInFlight = /* @__PURE__ */ new Map();
17055
+ /**
17056
+ * Serialize pool destruction: the first caller wins; later callers await the
17057
+ * same promise instead of racing a second destroy(). Rejections that merely
17058
+ * report a concurrent/finished destroy are treated as success — the pool is
17059
+ * gone either way, and re-throwing them would turn a benign race into a
17060
+ * logged (and potentially escalated) failure.
17061
+ */
17062
+ destroyPoolOnce(deadPool) {
17063
+ let p = this._destroyInFlight.get(deadPool);
17064
+ if (!p) {
17065
+ p = deadPool.destroy().catch((e) => {
17066
+ if (e instanceof Error && /already (destroying|destroyed|starting)/i.test(e.message)) {
17067
+ return;
17068
+ }
17069
+ throw new Error(`Worker pool destroy failed: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
17070
+ });
17071
+ void p.then(() => this._destroyInFlight.delete(deadPool));
17072
+ this._destroyInFlight.set(deadPool, p);
17073
+ }
17074
+ return p;
17075
+ }
17005
17076
  /**
17006
17077
  * Ensure the pool is initialized with the current config.
17007
17078
  * Recreates the pool if the worker count has changed.
@@ -17036,7 +17107,7 @@ var WorkerPoolManager = class {
17036
17107
  if (this.pool && (this.currentWorkerCount !== maxWorkers || this.currentWorkerConcurrency !== workerConcurrency)) {
17037
17108
  logger.log(`[WorkerPoolManager] Pool shape changed (workers ${this.currentWorkerCount} -> ${maxWorkers}, concurrency ${this.currentWorkerConcurrency} -> ${workerConcurrency}), recreating pool...`);
17038
17109
  this.poolEpoch++;
17039
- await this.pool.destroy();
17110
+ await this.destroyPoolOnce(this.pool);
17040
17111
  this.pool = null;
17041
17112
  }
17042
17113
  this.currentWorkerCount = maxWorkers;
@@ -17056,6 +17127,14 @@ var WorkerPoolManager = class {
17056
17127
  `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.`
17057
17128
  );
17058
17129
  }
17130
+ try {
17131
+ execFileSync(process.execPath, ["--check", workerPath], { stdio: "pipe" });
17132
+ } catch (e) {
17133
+ throw new Error(
17134
+ `Browser worker bundle at ${workerPath} does not parse (${e instanceof Error ? e.message : String(e)}). Reinstall the extension (e.g. \`pi update --extensions\`) so the build step regenerates it.`,
17135
+ { cause: e }
17136
+ );
17137
+ }
17059
17138
  const myEpoch = ++this.poolEpoch;
17060
17139
  this.pool = new FixedClusterPool(maxWorkers, workerPath, {
17061
17140
  env: browserEnv,
@@ -17217,7 +17296,7 @@ var WorkerPoolManager = class {
17217
17296
  try {
17218
17297
  if (this.isShuttingDown || this.generation !== myGen) return;
17219
17298
  this.poolEpoch++;
17220
- if (deadPool) await deadPool.destroy();
17299
+ if (deadPool) await this.destroyPoolOnce(deadPool);
17221
17300
  logger.info("[WorkerPoolManager] Auto-recovery: old pool destroyed.");
17222
17301
  } catch (err) {
17223
17302
  logger.warn("[WorkerPoolManager] Auto-recovery: error destroying old pool:", err);
@@ -17258,7 +17337,7 @@ var WorkerPoolManager = class {
17258
17337
  if (this.pool) {
17259
17338
  try {
17260
17339
  this.poolEpoch++;
17261
- const destroyPromise = this.pool.destroy();
17340
+ const destroyPromise = this.destroyPoolOnce(this.pool);
17262
17341
  destroyPromise.catch((err) => logger.debug(`[WorkerPoolManager] Background pool destroy rejection: ${err.message}`));
17263
17342
  await raceWithDeadline(destroyPromise, 5e3);
17264
17343
  } catch (e) {
@@ -18492,7 +18571,7 @@ init_logger();
18492
18571
 
18493
18572
  // src/healthcheck/index.ts
18494
18573
  init_config();
18495
- import { execFileSync } from "node:child_process";
18574
+ import { execFileSync as execFileSync2 } from "node:child_process";
18496
18575
  init_service_registry();
18497
18576
  init_service_interfaces();
18498
18577
 
@@ -18584,7 +18663,7 @@ async function checkBrowserCapability() {
18584
18663
  if (isBrowserAvailable() || mockMode) {
18585
18664
  if (!mockMode && resolveHeadlessMode() === "virtual") {
18586
18665
  try {
18587
- execFileSync("which", ["Xvfb"], { stdio: "ignore" });
18666
+ execFileSync2("which", ["Xvfb"], { stdio: "ignore" });
18588
18667
  } catch {
18589
18668
  return {
18590
18669
  healthy: false,
@@ -1958,12 +1958,22 @@ function setupIpcErrorHandler() {
1958
1958
  return;
1959
1959
  }
1960
1960
  cluster.worker.on("error", (err) => {
1961
- if (err && err.code === "ERR_IPC_CHANNEL_CLOSED") {
1961
+ if (isBenignClusterIpcError(err)) {
1962
1962
  return;
1963
1963
  }
1964
1964
  throw err;
1965
1965
  });
1966
1966
  }
1967
+ var BENIGN_IPC_CODES = /* @__PURE__ */ new Set(["EPIPE", "ERR_IPC_CHANNEL_CLOSED", "ECONNRESET"]);
1968
+ function isBenignClusterIpcError(err) {
1969
+ if (!err) return false;
1970
+ if (typeof err.code === "string" && BENIGN_IPC_CODES.has(err.code)) return true;
1971
+ const msg = String(err.message || err);
1972
+ return /\b(EPIPE|ECONNRESET)\b/.test(msg) || /channel closed/i.test(msg);
1973
+ }
1974
+ function markWorkerBootstrap(id) {
1975
+ logToDebugFile("WARN", `[Worker-${id}] Bootstrap complete (pid ${process2.pid})`);
1976
+ }
1967
1977
  function setupUncaughtExceptionHandler() {
1968
1978
  process2.on("uncaughtException", (err) => {
1969
1979
  if (err && err.message && err.message.includes("reading 'url'") && err.stack && err.stack.includes("coreBundle.js")) {
@@ -3232,6 +3242,7 @@ setBrowserCleanup(cleanupBrowser2);
3232
3242
  if (process3.env["GITHUB_ACTIONS"] !== "true") {
3233
3243
  setupOrphanProtection();
3234
3244
  }
3245
+ markWorkerBootstrap(workerId4);
3235
3246
  var FULL_MOCK_MODE = process3.env["PI_RESEARCH_MOCK_SEARCH"] === "true" && process3.env["PI_RESEARCH_MOCK_SCRAPE"] === "true";
3236
3247
  async function runTask(data) {
3237
3248
  if (!data) {
@@ -21,10 +21,12 @@ npm install -g @lincoln504/pi-research # the engine (puts `pi-research` on PAT
21
21
  pi-research skill install # link the skill into every detected agent
22
22
  ```
23
23
 
24
- On npm ≥11.19 (and npm 12) dependency install scripts are skipped by default — and
24
+ npm 12, the current npm, blocks dependency install scripts by default — and
25
25
  none are needed: the only native piece on the launch path is `impit`, which ships
26
26
  prebuilt binaries, and the stealth browser self-provisions on its first use (the first
27
- scrape takes a few minutes). No approval is required (see the [README](../README.md#install)).
27
+ scrape takes a few minutes). On npm 10 through 11.19 the `postinstall` fetch runs
28
+ during install instead; that is the npm bundled with Node 24/25 (11.19.0) and with
29
+ the Node 22.22.2 floor (10.9.7). No approval is required (see the [README](../README.md#install)).
28
30
 
29
31
  The global install also brings the `@earendil-works/pi-*` host packages in as peer
30
32
  dependencies (npm ≥7 resolves them). If your install skips peers
@@ -387,7 +387,7 @@ warns on every startup when a host-provided package (`typebox`, `@sinclair/typeb
387
387
  loader and create a second live module instance (`resource-loader.js`,
388
388
  `collectExtensionPackageWarnings`; pi `docs/packages.md`). Under pi the host's own copy is used,
389
389
  so the two agree on `Value.Check`/`Convert` by construction. `@earendil-works/pi-ai` and
390
- `@earendil-works/pi-coding-agent` 1.0.0 through 1.0.3 all pin `1.3.27` (re-checked 2026-10-05 in
390
+ `@earendil-works/pi-coding-agent` 1.0.0 through 1.1.0 all pin `1.3.27` (re-checked 2026-10-08 in
391
391
  the installed packages' `package.json`), which is why the devDependency, and so every test run and
392
392
  the type-check, stays on exactly that version: bump it in lockstep with the pi host, not
393
393
  independently. A standalone CLI/SDK install resolves the peer automatically on npm >= 7; the
package/docs/CHANGELOG.md CHANGED
@@ -5,6 +5,38 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.7.11] - 2026-10-10
9
+
10
+ ### Fixed
11
+
12
+ - **The browser worker pool crash class observed on 2026-10-08 is closed at four layers.** On a pi host, an `ERR_IPC_CHANNEL_CLOSED` raised by a write to a worker's closed IPC channel reached a `cluster.Worker` with no `error` listener and threw inside `EventEmitter` → `uncaughtException` → the pi process exited mid-research. The full chain was reconstructed from the host's crash log: a storm of worker exits with code 1 (16+ in under a second, **zero worker-side log lines**) fed the 3-consecutive-error threshold, the auto-recovery 1 s timer fired into a leader-handover storm that tore down and rebuilt the pool three times in ten seconds with an explicit `Cannot destroy an already destroying pool` double-destroy, and the fatal `destroy()` → `sendKillMessageToWorker` → `Worker.send` hit the closed channel at 22:00:30.12. Fixes, in `src/infrastructure/browser/thread-worker-lifecycle.ts` and `worker-pool-manager.ts`:
13
+ - **The worker-side IPC handler now swallows the same benign set as the master side** (`EPIPE`, `ECONNRESET`, `ERR_IPC_CHANNEL_CLOSED`, `channel closed`, via the shared `isBenignClusterIpcError`). The old narrow check (`ERR_IPC_CHANNEL_CLOSED` only) re-threw `EPIPE`/`ECONNRESET` during teardown churn as an uncaught worker-side exception — a code-1 exit that fed the consecutive-error counter driving the storm. Non-benign errors still re-throw (the worker's uncaught handler logs and exits, by design).
14
+ - **Pool destruction is serialized.** The auto-recovery timer, `shutdown()`, and the shape-change re-init all destroy through one in-flight promise per pool (`destroyPoolOnce`), so a second `destroy()` can no longer race the first — the double-destroy that wiped worker listeners (`terminate` → `removeAllListeners`) while the first destroy was still sending kill messages, which opened the fatal re-emit window. Rejections that merely report a concurrent/finished destroy are swallowed as success.
15
+ - **A `cluster.Worker` prototype last-resort guard.** `setupMasterIpcErrorHandler` also patches `Worker.prototype.emit` so no Worker can ever emit `error` without a listener, whatever wipe path, timing, or pool generation is involved. The per-worker re-attach patch already survives poolifier's `removeAllListeners`; this makes the crash class unreachable rather than merely guarded. The listener it attaches can only be the first on a listener-less Worker (the exact state where `EventEmitter` throws), so it never shadows poolifier's own error accounting.
16
+ - **A corrupted worker bundle now fails loudly at pool init.** A bundle that exists but is truncated or overwritten fails with a bare `SyntaxError` in every forked worker — code-1 exits with no worker-side log, the same silent-churn signature the missing-bundle guard could not catch. `ensurePool` now runs `node --check` on `thread-worker.mjs` (~40 ms) and throws an actionable reinstall error on a parse failure. (Observed 2026-10-09 on a consumer git checkout that had also lost `src/cleanup/research-cleanup.ts`, breaking the research tool with `Cannot find module` — a partial-install corruption class, now loud for the bundle and diagnosable for the rest.)
17
+ - **Each worker writes a startup marker** (`[Worker-<id>] Bootstrap complete (pid …)`, WARN, always recorded). A non-zero exit with no marker means the worker died during top-level module evaluation, before its handlers were installed — the exact signature of the 2026-10-08 silent churn storm, now visible in post-mortems.
18
+ - Tests: new `test/unit/infrastructure/browser/thread-worker-lifecycle.test.ts` (widened swallow, re-attach patch, prototype last-resort guard plus the no-patch control that throws, bootstrap marker) and destroy-serialization cases in `worker-pool-manager.test.ts` (auto-recovery timer vs concurrent `shutdown()` sharing one `destroy()` call; "already destroying" rejection swallowed; parse-guard failure). Full unit suite 3318, integration 103+57, lint, type-check (TypeScript 6 and 7), depcruise and `npm audit` (0) pass.
19
+
20
+ ## [1.7.10] - 2026-10-08
21
+
22
+ ### Changed
23
+
24
+ - **The pi host dev dependencies move 1.0.4 → 1.1.0** (`@earendil-works/pi-ai`, `pi-coding-agent`, `pi-tui`; the peer range stays `>=0.99.0 <2`). The 1.0.4 → 1.1.0 diff is additive for this package (declaration diff of the installed packages, 2026-10-08): `pi-coding-agent` adds `durationMs` on the tool render context and `tool_execution_end`, `aborted` on `agent_settled` events, and the `+name`/`-name` tool modifiers; `pi-ai` adds the Decisions-API classifier surface and an optional `AuthProvider.agentName`; `pi-tui` adds the OSC 7501 program-status helpers and `Text.setPaddingX`. `docs/extensions.md` is unchanged and nothing was removed or re-signed, so the 0.99.0 floor is unchanged too. `PI_TESTED_MAX_VERSION` moves to 1.1.0 (`src/core/pi-version.ts`), so a pi 1.1.x host runs without the "newer than the tested ceiling" notice; `npm audit` stays at 0 and type-check (TypeScript 6 and 7), lint and the full unit suite (3305) pass against 1.1.0.
25
+ - **The npm install-script boundary is corrected across every user-facing surface and CI comment.** npm 10 through 11.19 run dependency install scripts (11.16–11.19 print a warning); npm 12, the current npm, blocks them by default — the npm bundled with Node 24/25 is 11.19.0, which still runs them, so a stock Node install fetches the browser during install. The old "11.19+ skips install scripts" wording in `README.md`, `docs/AGENT-SKILL.md`, `docs/SDK.md`, the ES/JA/ZH docs and the `ci.yml` comments was wrong — verified against the npm 11.19.0 source (arborist skips a script only when it is explicitly denied; `strict-allow-scripts` is opt-in) and consistent with this repo's own earlier measurement that 11.16–11.19 "warn but still run". The stale "compile from source on Windows" / `better-sqlite3` 13 references that survived the 1.7.9 launcher swap are gone with it.
26
+ - **Update guidance is documented** (README and `docs/PI-EXTENSION.md`): `pi update --extensions` moves every installed extension to its newest published version, `pi update npm:@lincoln504/pi-research` targets only this one, and a versioned source (`npm:@lincoln504/pi-research@1.7.9`) is pinned and never updates — remove and reinstall it to move versions.
27
+ - **`setup.cjs` announces the ~1.3 GB fetch before it starts**, so the postinstall fetch that runs inside `pi install` / `pi update --extensions` on script-running npm (10–11.19) no longer looks like a hang.
28
+ - In-range dev refresh: `vite` 8.3.2 → 8.3.4.
29
+
30
+ ### Fixed
31
+
32
+ - **The in-host lazy browser fetch no longer writes raw to the terminal.** `ensureBrowserInstalled()` spawned its `camoufox fetch` child with `stdio: 'inherit'`; a child with an inherited fd 1 bypasses pi's JS-level stdout redirection (pi's `takeOverStdout` rewrites `process.stdout.write` for the TUI) and interleaves the download's progress with the host's output stream — in print/JSON/RPC modes stdout is the protocol itself, so raw child output could corrupt it. The child's stdout is now captured in a temp file (its path is logged on failure, the file is removed on success) and stderr stays inherited, so real errors remain visible. Pinned by a test asserting `stdio: ['ignore', 'pipe', 'inherit']` on the fetch child.
33
+
34
+ ### Also shipped since 1.7.9
35
+
36
+ - **`source-map-js` 1.2.2** in the production tree clears GHSA-68fv-2mgg-jv7q from the audit gate (`npm audit`: 0).
37
+ - **The unit suite stops leaking throwaway tmpdirs.**
38
+ - **`docs/AUDIT-GATE.md` records** that a shipped host `npm-shrinkwrap.json` does not propagate a consumer-side `sharp` override (consumer advisory note).
39
+
8
40
  ## [1.7.9] - 2026-10-05
9
41
 
10
42
  ### Changed
@@ -83,6 +83,10 @@ coding agents detected on this machine so they can run web research through the
83
83
  CLI, and remove it again — with exact, manifest-tracked cleanup. See
84
84
  [AGENT-SKILL.md](AGENT-SKILL.md) for the full installation flow.
85
85
 
86
+ ### Update
87
+
88
+ `pi update --extensions` moves every installed extension to its newest published version; `pi update npm:@lincoln504/pi-research` targets only this one. A versioned source (`npm:@lincoln504/pi-research@1.7.9`) is pinned and never updates — remove and reinstall it to move versions.
89
+
86
90
  ### Lifecycle
87
91
 
88
92
  - `activate` — registers commands, tools, the TUI controller, and initializes services.
package/docs/SDK.md CHANGED
@@ -21,9 +21,11 @@ classic — the SDK cannot import them, so add the one it loads directly:
21
21
  npm install @earendil-works/pi-coding-agent
22
22
  ```
23
23
 
24
- On npm ≥11.19 (and npm 12), dependency install scripts are skipped by default. Nothing
24
+ npm 12, the current npm, blocks dependency install scripts by default. Nothing
25
25
  here needs them: the only native piece on the launch path is `impit`, which ships
26
- prebuilt binaries, and the stealth browser self-provisions on its first use. (The
26
+ prebuilt binaries, and the stealth browser self-provisions on its first use. On npm 10
27
+ through 11.19 the `postinstall` fetch runs during install instead; that is the npm
28
+ bundled with Node 24/25 (11.19.0) and with the Node 22.22.2 floor (10.9.7). (The
27
29
  `npm approve-scripts better-sqlite3` + `npm rebuild` pair documented by earlier
28
30
  releases repaired the old `camoufox-js` launcher, whose `better-sqlite3` binding came
29
31
  from an install script — the 2026-10-05 move to `@camoufox/camoufox` removed that
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincoln504/pi-research",
3
- "version": "1.7.9",
3
+ "version": "1.7.11",
4
4
  "description": "Web research for your agents with smart and safe tooling + knowledge store",
5
5
  "type": "module",
6
6
  "private": false,
package/scripts/setup.cjs CHANGED
@@ -135,6 +135,7 @@ if (process.env.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD === '1') {
135
135
  return true;
136
136
  };
137
137
 
138
+ console.log('pi-research: downloading the ~1.3 GB Camoufox browser — this can take several minutes on a slow link…');
138
139
  const pkgRoot = layout.findPackageRoot(projectRoot);
139
140
  const result = layout.provisionBrowser(cachePath, runFetch, (m) => console.log(m), pkgRoot ? layout.loadPin(pkgRoot) : null);
140
141
  if (result.status === 'failed') {
@@ -122,8 +122,16 @@ export const PI_MIN_VERSION: SemverParts = { major: 0, minor: 99, patch: 0 };
122
122
  * modes), the `builtin:<name>` naming for built-in extensions and tools, and
123
123
  * `--no-extensions` now also disabling the built-in extensions. None of them
124
124
  * change what this package does.
125
+ *
126
+ * 1.1.0 (2026-10-07) is additive for this package. Diffed against 1.0.4 on 2026-10-08:
127
+ * `@earendil-works/pi-coding-agent` adds `durationMs` on the tool render context and
128
+ * `tool_execution_end`, `aborted` on `agent_settled` events, and the `+name`/`-name`
129
+ * tool modifiers; `@earendil-works/pi-ai` adds the Decisions-API classifier surface
130
+ * and an optional `AuthProvider.agentName`; `@earendil-works/pi-tui` adds the OSC 7501
131
+ * program-status helpers and `Text.setPaddingX`. `docs/extensions.md` is unchanged,
132
+ * and nothing was removed or re-signed, so the 0.99.0 floor is unchanged too.
125
133
  */
126
- export const PI_TESTED_MAX_VERSION: SemverParts = { major: 1, minor: 0, patch: 0 };
134
+ export const PI_TESTED_MAX_VERSION: SemverParts = { major: 1, minor: 1, patch: 0 };
127
135
 
128
136
  export type PiCompatibilityLevel = 'ok' | 'unparseable' | 'too-old' | 'untested';
129
137
 
@@ -21,7 +21,7 @@
21
21
  import { spawn } from 'node:child_process';
22
22
  import { createRequire } from 'node:module';
23
23
  import { createHash } from 'node:crypto';
24
- import { existsSync, fstatSync, openSync, closeSync, rmSync, readFileSync } from 'node:fs';
24
+ import { existsSync, fstatSync, openSync, closeSync, rmSync, readFileSync, createWriteStream } from 'node:fs';
25
25
  import { join, dirname } from 'node:path';
26
26
  import { tmpdir, platform, homedir } from 'node:os';
27
27
  import { logger } from '../../logger.ts';
@@ -89,6 +89,14 @@ const delay = (ms: number): Promise<void> => new Promise((r) => setTimeout(r, ms
89
89
  * spawn error / timeout. Runs the CLI's JS entry with process.execPath and an argv array:
90
90
  * no shell, so neither a spaced Windows path nor a metacharacter in an install directory
91
91
  * can be reinterpreted.
92
+ *
93
+ * The child's stdout is captured in a temp file, not inherited: a child with an
94
+ * inherited fd 1 writes raw to the terminal, which inside a pi session bypasses the
95
+ * host's JS-level stdout redirection (pi redirects `process.stdout.write` for its TUI)
96
+ * and interleaves the download's progress with the host's output stream — in
97
+ * print/JSON/RPC modes stdout is the protocol itself, so raw child output corrupts it.
98
+ * The launcher's progress is noise either way; its errors go to stderr, which stays
99
+ * inherited and is therefore still visible.
92
100
  */
93
101
  function runFetch(): Promise<boolean> {
94
102
  return new Promise<boolean>((resolve, reject) => {
@@ -106,19 +114,35 @@ function runFetch(): Promise<boolean> {
106
114
  delete env['PLAYWRIGHT_BROWSERS_PATH'];
107
115
  logger.info(`[ensure-browser] Camoufox not found; fetching the browser (this runs once, ~1.3GB): ${process.execPath} ${cli} fetch`);
108
116
 
109
- const child = spawn(process.execPath, [cli, 'fetch'], { stdio: 'inherit', env });
117
+ const outLogPath = join(tmpdir(), `pi-research-camoufox-fetch-${process.pid}-${Date.now()}.log`);
118
+ const outStream = createWriteStream(outLogPath, { mode: 0o600 });
119
+ const child = spawn(process.execPath, [cli, 'fetch'], { stdio: ['ignore', 'pipe', 'inherit'], env });
120
+ if (child.stdout) {
121
+ child.stdout.pipe(outStream);
122
+ }
110
123
  const timer = setTimeout(() => {
111
124
  child.kill('SIGKILL');
112
125
  reject(new Error(`camoufox fetch timed out after ${FETCH_TIMEOUT_MS}ms`));
113
126
  }, FETCH_TIMEOUT_MS);
114
127
 
115
- child.on('error', (err) => {
128
+ const finish = (err: Error | null, ok: boolean): void => {
116
129
  clearTimeout(timer);
117
- reject(err);
130
+ void outStream.close();
131
+ if (err) reject(err);
132
+ else resolve(ok);
133
+ };
134
+
135
+ child.on('error', (err) => {
136
+ const message = err instanceof Error ? err.message : String(err);
137
+ finish(new Error(`${message} (fetch output logged at ${outLogPath})`), false);
118
138
  });
119
139
  child.on('exit', (code) => {
120
- clearTimeout(timer);
121
- resolve(code === 0);
140
+ if (code === 0) {
141
+ try { rmSync(outLogPath, { force: true }); } catch { /* best-effort */ }
142
+ finish(null, true);
143
+ } else {
144
+ finish(new Error(`camoufox fetch exited with code ${code} (fetch output logged at ${outLogPath})`), false);
145
+ }
122
146
  });
123
147
  });
124
148
  }
@@ -20,14 +20,23 @@ export function setWorkerId(id: string): void {
20
20
  }
21
21
 
22
22
  /**
23
- * Handle ERR_IPC_CHANNEL_CLOSED when poolifier tries to send messages during shutdown
23
+ * Handle benign closed-channel IPC errors when poolifier tears down a worker's
24
+ * channel. Symmetric with the master-side classification (isBenignClusterIpcError):
25
+ * the write failed because the pipe is already gone, which is not a health signal.
26
+ *
27
+ * The old narrow check (ERR_IPC_CHANNEL_CLOSED only) re-threw EPIPE/ECONNRESET as an
28
+ * uncaught worker-side exception → exit code 1. A worker that dies that way during
29
+ * teardown churn feeds the master's consecutive-error counter and re-spawns — the
30
+ * self-sustaining worker storm behind the 2026-10-08 crash chain. Swallowing the
31
+ * same benign set here stops that feedback; genuinely non-benign errors still
32
+ * re-throw (the worker's uncaught handler logs and exits, as designed).
24
33
  */
25
34
  export function setupIpcErrorHandler(): void {
26
35
  if (!cluster.isWorker || !cluster.worker) {
27
36
  return;
28
37
  }
29
38
  cluster.worker.on('error', (err: any) => {
30
- if (err && err.code === 'ERR_IPC_CHANNEL_CLOSED') {
39
+ if (isBenignClusterIpcError(err)) {
31
40
  return;
32
41
  }
33
42
  throw err;
@@ -92,6 +101,10 @@ export function setupMasterIpcErrorHandler(): void {
92
101
  const w = (cluster.workers as any)?.[id];
93
102
  if (w) guardClusterWorker(w);
94
103
  }
104
+
105
+ // Prototype-level backstop: no cluster.Worker can ever emit 'error' without
106
+ // a listener, whatever path wiped its per-instance listeners.
107
+ guardWorkerEmitReemit();
95
108
  }
96
109
 
97
110
  /** Reset for tests — re-arms the one-time install guard. */
@@ -99,6 +112,55 @@ export function __resetMasterIpcGuardForTests(): void {
99
112
  masterIpcGuardInstalled = false;
100
113
  }
101
114
 
115
+ let workerEmitGuardPatched = false;
116
+ let workerEmitOriginal: unknown = null;
117
+
118
+ /**
119
+ * Last-resort defense against the crash class observed on 2026-10-08: an 'error'
120
+ * event emitted on a cluster.Worker with NO listeners throws inside EventEmitter
121
+ * (uncaughtException → the pi host exits). The per-worker guard above is re-armed
122
+ * after poolifier's removeAllListeners() by the re-attach patch in
123
+ * guardClusterWorker, but this patches the Worker PROTOTYPE so that no Worker can
124
+ * ever emit 'error' without a listener — whatever wipe path, re-emit timing, or
125
+ * pool generation is involved.
126
+ *
127
+ * The listener attached here can only be the first listener on a listener-less
128
+ * Worker (that is the exact state where EventEmitter would throw), so it can never
129
+ * shadow poolifier's own error accounting, which is always attached at fork time.
130
+ * Idempotent; master process only. Installed once by setupMasterIpcErrorHandler().
131
+ */
132
+ export function guardWorkerEmitReemit(): void {
133
+ if (workerEmitGuardPatched || cluster.isWorker) return;
134
+ const Worker = (cluster as unknown as { Worker?: { prototype: { emit?: unknown } } }).Worker;
135
+ if (!Worker || !Worker.prototype) return;
136
+ workerEmitGuardPatched = true;
137
+ const origEmit = Worker.prototype.emit;
138
+ if (typeof origEmit !== 'function') return;
139
+ workerEmitOriginal = origEmit;
140
+ Worker.prototype.emit = function (this: any, event: string | symbol, ...args: unknown[]) {
141
+ if (event === 'error' && typeof this.listenerCount === 'function' && this.listenerCount('error') === 0) {
142
+ this.on('error', (err: any) => {
143
+ if (isBenignClusterIpcError(err)) {
144
+ logToDebugFile('DEBUG', `[Master] Last-resort guard swallowed listener-less IPC error: ${err?.code || err?.message}`);
145
+ return;
146
+ }
147
+ logToDebugFile('WARN', `[Master] Last-resort guard handled listener-less worker error: ${err?.stack || err?.message || err}`);
148
+ });
149
+ }
150
+ return origEmit.apply(this, [event, ...args]);
151
+ };
152
+ }
153
+
154
+ /** Reset for tests — restores the original prototype emit and re-arms the flag. */
155
+ export function __resetWorkerEmitGuardForTests(): void {
156
+ if (workerEmitGuardPatched && workerEmitOriginal) {
157
+ const Worker = (cluster as unknown as { Worker?: { prototype: { emit?: unknown } } }).Worker;
158
+ if (Worker && Worker.prototype) Worker.prototype.emit = workerEmitOriginal;
159
+ workerEmitOriginal = null;
160
+ }
161
+ workerEmitGuardPatched = false;
162
+ }
163
+
102
164
  function guardClusterWorker(worker: any): void {
103
165
  if (!worker || worker.__piIpcGuarded) return;
104
166
  worker.__piIpcGuarded = true;
@@ -137,6 +199,18 @@ function guardClusterWorker(worker: any): void {
137
199
  }
138
200
  }
139
201
 
202
+ /**
203
+ * Worker bootstrap marker. WARN (always recorded; INFO/DEBUG are gated behind
204
+ * PI_RESEARCH_DEBUG). If a worker later exits non-zero WITHOUT this line in the
205
+ * log, it died during top-level module evaluation — before its error handlers
206
+ * were installed — and left no diagnostic behind. The 2026-10-08 churn storm
207
+ * had exactly that signature (a burst of code-1 exits with zero worker-side log
208
+ * lines); this marker makes that class visible in post-mortems.
209
+ */
210
+ export function markWorkerBootstrap(id: string): void {
211
+ logToDebugFile('WARN', `[Worker-${id}] Bootstrap complete (pid ${process.pid})`);
212
+ }
213
+
140
214
  /**
141
215
  * Handle Uncaught Exceptions in Worker
142
216
  */
@@ -1958,12 +1958,22 @@ function setupIpcErrorHandler() {
1958
1958
  return;
1959
1959
  }
1960
1960
  cluster.worker.on("error", (err) => {
1961
- if (err && err.code === "ERR_IPC_CHANNEL_CLOSED") {
1961
+ if (isBenignClusterIpcError(err)) {
1962
1962
  return;
1963
1963
  }
1964
1964
  throw err;
1965
1965
  });
1966
1966
  }
1967
+ var BENIGN_IPC_CODES = /* @__PURE__ */ new Set(["EPIPE", "ERR_IPC_CHANNEL_CLOSED", "ECONNRESET"]);
1968
+ function isBenignClusterIpcError(err) {
1969
+ if (!err) return false;
1970
+ if (typeof err.code === "string" && BENIGN_IPC_CODES.has(err.code)) return true;
1971
+ const msg = String(err.message || err);
1972
+ return /\b(EPIPE|ECONNRESET)\b/.test(msg) || /channel closed/i.test(msg);
1973
+ }
1974
+ function markWorkerBootstrap(id) {
1975
+ logToDebugFile("WARN", `[Worker-${id}] Bootstrap complete (pid ${process2.pid})`);
1976
+ }
1967
1977
  function setupUncaughtExceptionHandler() {
1968
1978
  process2.on("uncaughtException", (err) => {
1969
1979
  if (err && err.message && err.message.includes("reading 'url'") && err.stack && err.stack.includes("coreBundle.js")) {
@@ -3232,6 +3242,7 @@ setBrowserCleanup(cleanupBrowser2);
3232
3242
  if (process3.env["GITHUB_ACTIONS"] !== "true") {
3233
3243
  setupOrphanProtection();
3234
3244
  }
3245
+ markWorkerBootstrap(workerId4);
3235
3246
  var FULL_MOCK_MODE = process3.env["PI_RESEARCH_MOCK_SEARCH"] === "true" && process3.env["PI_RESEARCH_MOCK_SCRAPE"] === "true";
3236
3247
  async function runTask(data) {
3237
3248
  if (!data) {
@@ -19,6 +19,7 @@ import {
19
19
  setupOrphanProtection,
20
20
  createKillHandler,
21
21
  setBrowserCleanup,
22
+ markWorkerBootstrap,
22
23
  } from './thread-worker-lifecycle.ts';
23
24
  import {
24
25
  setWorkerId,
@@ -60,6 +61,11 @@ if (process.env['GITHUB_ACTIONS'] !== 'true') {
60
61
  setupOrphanProtection();
61
62
  }
62
63
 
64
+ // Startup marker (always recorded). A non-zero exit with no marker in the log
65
+ // means the worker died during top-level module evaluation, before the handlers
66
+ // above were installed — the silent churn signature of the 2026-10-08 crash chain.
67
+ markWorkerBootstrap(workerId);
68
+
63
69
  // When both search and scrape are mocked, skip Firefox entirely in task handlers
64
70
  // and in the eager warm-up below. Firefox startup in cluster workers takes 60-90s
65
71
  // on constrained CI runners; mocked tasks don't need a real browser at all.
@@ -7,6 +7,7 @@
7
7
 
8
8
  import { dirname, join } from 'node:path';
9
9
  import { existsSync } from 'node:fs';
10
+ import { execFileSync } from 'node:child_process';
10
11
  import { fileURLToPath } from 'node:url';
11
12
  import { FixedClusterPool, WorkerChoiceStrategies } from 'poolifier';
12
13
  import { logger } from '../../logger.ts';
@@ -110,6 +111,35 @@ export class WorkerPoolManager implements IService {
110
111
  // Handle for the schedulePoolReset() destroy timer, so shutdown() can cancel a pending
111
112
  // reset instead of letting it fire after teardown and clobber a freshly-rebuilt pool.
112
113
  private _resetTimer: ReturnType<typeof setTimeout> | null = null;
114
+ // One in-flight destroy promise per pool instance. Both the auto-recovery timer
115
+ // and shutdown() can try to destroy the same pool (observed 2026-10-08 during a
116
+ // leader-handover storm: "Cannot destroy an already destroying pool"). A second
117
+ // destroy() racing the first wipes worker listeners (terminate →
118
+ // removeAllListeners) while the first is still sending kill messages — the fatal
119
+ // IPC re-emit window. Sharing one promise per pool makes that race impossible.
120
+ private _destroyInFlight: Map<object, Promise<void>> = new Map();
121
+
122
+ /**
123
+ * Serialize pool destruction: the first caller wins; later callers await the
124
+ * same promise instead of racing a second destroy(). Rejections that merely
125
+ * report a concurrent/finished destroy are treated as success — the pool is
126
+ * gone either way, and re-throwing them would turn a benign race into a
127
+ * logged (and potentially escalated) failure.
128
+ */
129
+ private destroyPoolOnce(deadPool: object): Promise<void> {
130
+ let p = this._destroyInFlight.get(deadPool);
131
+ if (!p) {
132
+ p = (deadPool as { destroy: () => Promise<void> }).destroy().catch((e: unknown) => {
133
+ if (e instanceof Error && /already (destroying|destroyed|starting)/i.test(e.message)) {
134
+ return;
135
+ }
136
+ throw new Error(`Worker pool destroy failed: ${e instanceof Error ? e.message : String(e)}`, { cause: e });
137
+ });
138
+ void p.then(() => this._destroyInFlight.delete(deadPool));
139
+ this._destroyInFlight.set(deadPool, p);
140
+ }
141
+ return p;
142
+ }
113
143
 
114
144
  constructor(
115
145
  private readonly onPoolError?: (error: Error, consecutiveErrors: number) => void
@@ -169,7 +199,7 @@ export class WorkerPoolManager implements IService {
169
199
  if (this.pool && (this.currentWorkerCount !== maxWorkers || this.currentWorkerConcurrency !== workerConcurrency)) {
170
200
  logger.log(`[WorkerPoolManager] Pool shape changed (workers ${this.currentWorkerCount} -> ${maxWorkers}, concurrency ${this.currentWorkerConcurrency} -> ${workerConcurrency}), recreating pool...`);
171
201
  this.poolEpoch++; // intentional destroy: silence the dying pool's exit events
172
- await this.pool.destroy();
202
+ await this.destroyPoolOnce(this.pool);
173
203
  this.pool = null;
174
204
  }
175
205
 
@@ -237,6 +267,23 @@ export class WorkerPoolManager implements IService {
237
267
  );
238
268
  }
239
269
 
270
+ // A bundle that EXISTS but is corrupted (truncated or partially overwritten
271
+ // file — observed 2026-10-08: a git checkout missing files) fails with a bare
272
+ // SyntaxError in every forked worker — code-1 exits with no worker-side log,
273
+ // the same silent churn signature as a missing bundle. Parse (not execute)
274
+ // the source here so a corrupted bundle fails loudly at pool init instead of
275
+ // masking as worker churn. `node --check` parses .mjs as ESM (~40 ms).
276
+ try {
277
+ execFileSync(process.execPath, ['--check', workerPath], { stdio: 'pipe' });
278
+ } catch (e) {
279
+ throw new Error(
280
+ `Browser worker bundle at ${workerPath} does not parse ` +
281
+ `(${e instanceof Error ? e.message : String(e)}). ` +
282
+ `Reinstall the extension (e.g. \`pi update --extensions\`) so the build step regenerates it.`,
283
+ { cause: e },
284
+ );
285
+ }
286
+
240
287
  // Identity token for THIS pool instance. The handlers below compare it against
241
288
  // the live `this.poolEpoch` so a superseded pool's trailing events are ignored.
242
289
  const myEpoch = ++this.poolEpoch;
@@ -472,7 +519,7 @@ export class WorkerPoolManager implements IService {
472
519
  // and nulling shared state would clobber a pool rebuilt after the shutdown.
473
520
  if (this.isShuttingDown || this.generation !== myGen) return;
474
521
  this.poolEpoch++; // intentional destroy: silence the dying pool's exit events
475
- if (deadPool) await deadPool.destroy();
522
+ if (deadPool) await this.destroyPoolOnce(deadPool);
476
523
  logger.info('[WorkerPoolManager] Auto-recovery: old pool destroyed.');
477
524
  } catch (err) {
478
525
  logger.warn('[WorkerPoolManager] Auto-recovery: error destroying old pool:', err);
@@ -535,7 +582,10 @@ export class WorkerPoolManager implements IService {
535
582
  // in their killHandler (context.close / browser.close via Playwright), so
536
583
  // allow enough time for those to complete before the IPC channel closes.
537
584
  this.poolEpoch++; // intentional destroy: silence the dying pool's exit events
538
- const destroyPromise = this.pool.destroy();
585
+ // Shared promise: if the auto-recovery timer is already destroying this pool
586
+ // (it fired before shutdown cleared the handle), await that destroy instead of
587
+ // racing a second one — the double-destroy race of the 2026-10-08 crash chain.
588
+ const destroyPromise = this.destroyPoolOnce(this.pool);
539
589
  destroyPromise.catch((err: Error) => logger.debug(`[WorkerPoolManager] Background pool destroy rejection: ${err.message}`));
540
590
  await raceWithDeadline(destroyPromise, 5000);
541
591
  } catch (e) {