@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
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Host (pi) version compatibility.
3
+ *
4
+ * Extracted from the extension entry point so the policy is one testable unit
5
+ * rather than inline arithmetic, because the range this package declares cannot
6
+ * itself enforce compatibility:
7
+ *
8
+ * - As a pi EXTENSION, the host supplies `@earendil-works/*`. Our package.json
9
+ * range never constrains it, so an in-process check is the only enforcement.
10
+ * - As a standalone CLI/SDK, the range is `>=0.80.8 <1` and published tarballs
11
+ * carry no lockfile, so every fresh install resolves the newest 0.x at that
12
+ * instant. pi is pre-1.0: a minor bump may break anything under semver, and
13
+ * already has (0.83.0 extended the ResourceLoader contract).
14
+ *
15
+ * Hence two thresholds, not one. Below the FLOOR we throw — those APIs are gone
16
+ * and nothing will work. Above the TESTED ceiling we warn once and continue: a
17
+ * newer pi usually works, refusing it would strand users on every pi release, but
18
+ * silently pretending it is verified is how "works on my machine" ships.
19
+ */
20
+
21
+ export interface SemverParts {
22
+ major: number;
23
+ minor: number;
24
+ patch: number;
25
+ /**
26
+ * True when the version carried a pre-release tag (`-rc.1`). Semver orders a
27
+ * pre-release BELOW its release, so `0.80.8-rc.1` predates the `0.80.8` floor
28
+ * and may lack the APIs the floor exists for. Only the floor comparison uses
29
+ * this; the tested-ceiling check compares lines and ignores it.
30
+ */
31
+ prerelease?: boolean;
32
+ }
33
+
34
+ /**
35
+ * Parse a semver-ish version string. Tolerates a `v` prefix and pre-release /
36
+ * build metadata suffixes (`0.84.0-rc.1`, `0.84.0+abc`), which pi has used.
37
+ * Returns null when the numeric core cannot be read, so callers degrade to an
38
+ * explicit "cannot determine" rather than to a silently wrong comparison.
39
+ */
40
+ export function parsePiVersion(version: string): SemverParts | null {
41
+ const m = /^v?(\d+)\.(\d+)\.(\d+)(-)?/.exec(String(version ?? '').trim());
42
+ if (!m) return null;
43
+ const major = Number(m[1]);
44
+ const minor = Number(m[2]);
45
+ const patch = Number(m[3]);
46
+ if ([major, minor, patch].some((n) => !Number.isFinite(n))) return null;
47
+ // The flag is emitted only when set, so a plain release parses to the exact
48
+ // {major, minor, patch} shape existing comparisons and tests expect.
49
+ return m[4] === '-' ? { major, minor, patch, prerelease: true } : { major, minor, patch };
50
+ }
51
+
52
+ /** Ordering comparison: negative if a < b, 0 if equal, positive if a > b. */
53
+ export function compareVersions(a: SemverParts, b: SemverParts): number {
54
+ return (a.major - b.major) || (a.minor - b.minor) || (a.patch - b.patch);
55
+ }
56
+
57
+ /**
58
+ * Oldest host this package works against.
59
+ *
60
+ * 0.80.8 introduced `ModelRuntime` and removed `AuthStorage` /
61
+ * `ModelRegistry.create()`. `buildModelRegistry` calls `ModelRuntime.create()`
62
+ * unconditionally, and `createAgentSession()` is invoked without the removed
63
+ * `modelRegistry` option, relying on the 0.80.8+ host to build its own.
64
+ */
65
+ export const PI_MIN_VERSION: SemverParts = { major: 0, minor: 80, patch: 8 };
66
+
67
+ /**
68
+ * Newest host line this release was actually exercised against (CI + a real run).
69
+ * Bump this — deliberately — when a new pi is verified, not automatically.
70
+ * Compared on MAJOR.MINOR only: a patch bump within a tested line is not a new
71
+ * surface, and warning on it would be noise.
72
+ */
73
+ export const PI_TESTED_MAX_VERSION: SemverParts = { major: 0, minor: 83, patch: 0 };
74
+
75
+ export type PiCompatibilityLevel = 'ok' | 'unparseable' | 'too-old' | 'untested';
76
+
77
+ export interface PiCompatibility {
78
+ level: PiCompatibilityLevel;
79
+ /** True when the host must be rejected outright. */
80
+ fatal: boolean;
81
+ message: string | null;
82
+ }
83
+
84
+ /**
85
+ * Classify a host version against the supported window.
86
+ *
87
+ * Pure: no logging, no throwing. The caller decides what to do with each level,
88
+ * which is what makes the policy testable without stubbing a logger.
89
+ */
90
+ export function checkPiCompatibility(
91
+ version: string,
92
+ opts: { min?: SemverParts; testedMax?: SemverParts } = {},
93
+ ): PiCompatibility {
94
+ const min = opts.min ?? PI_MIN_VERSION;
95
+ const testedMax = opts.testedMax ?? PI_TESTED_MAX_VERSION;
96
+
97
+ const parsed = parsePiVersion(version);
98
+ if (!parsed) {
99
+ return {
100
+ level: 'unparseable',
101
+ fatal: true,
102
+ message:
103
+ `[pi-research] Cannot parse pi-coding-agent version "${version}". ` +
104
+ `Please ensure pi-coding-agent is installed correctly.`,
105
+ };
106
+ }
107
+
108
+ // A pre-release OF the floor version orders below the floor (0.80.8-rc.1 <
109
+ // 0.80.8) and may predate the very APIs the floor guards.
110
+ if (compareVersions(parsed, min) < 0 || (compareVersions(parsed, min) === 0 && parsed.prerelease === true)) {
111
+ return {
112
+ level: 'too-old',
113
+ fatal: true,
114
+ message:
115
+ `[pi-research] pi-coding-agent v${version} is too old. ` +
116
+ `Requires v${min.major}.${min.minor}.${min.patch}+. Please update pi-coding-agent.`,
117
+ };
118
+ }
119
+
120
+ // Compare on major.minor only — a patch release inside a tested line is fine.
121
+ const line: SemverParts = { major: parsed.major, minor: parsed.minor, patch: 0 };
122
+ const testedLine: SemverParts = { major: testedMax.major, minor: testedMax.minor, patch: 0 };
123
+ if (compareVersions(line, testedLine) > 0) {
124
+ return {
125
+ level: 'untested',
126
+ fatal: false,
127
+ message:
128
+ `[pi-research] pi-coding-agent v${version} is newer than the last version this ` +
129
+ `release of pi-research was tested against (v${testedMax.major}.${testedMax.minor}.x). ` +
130
+ `Continuing — it will most likely work — but if you hit anything odd, that mismatch ` +
131
+ `is the first thing to suspect. Updating pi-research usually resolves it.`,
132
+ };
133
+ }
134
+
135
+ return { level: 'ok', fatal: false, message: null };
136
+ }
@@ -17,6 +17,13 @@ import { logger } from '../logger.ts';
17
17
  import { AsyncLocalStorage } from 'node:async_hooks';
18
18
 
19
19
 
20
+ /**
21
+ * How long teardown waits for an initialization that is still in flight. Long
22
+ * enough to cover a normal service coming up, short enough that a service blocked
23
+ * on something slow (a model download) cannot hold shutdown open indefinitely.
24
+ */
25
+ const DISPOSE_INFLIGHT_INIT_TIMEOUT_MS = 5_000;
26
+
20
27
  /**
21
28
  * Service lifecycle stages
22
29
  */
@@ -209,15 +216,52 @@ export class ServiceContainer {
209
216
  throw new Error(`Service '${name}' is not registered`);
210
217
  }
211
218
 
212
- // Return existing instance if already initialized
219
+ // Return existing instance if already initialized.
213
220
  if (registration.instance) {
221
+ // `_initializeService` publishes `registration.instance` BEFORE awaiting the
222
+ // service's own initialize(), deliberately: a service whose initialize()
223
+ // resolves a peer that resolves it back would otherwise deadlock, and the
224
+ // partial instance is what breaks that cycle. But the same early publish
225
+ // hands a HALF-BUILT service to unrelated concurrent callers — e.g.
226
+ // StateManagerService only assigns its inner manager after nine awaited
227
+ // getService() calls, so every method on it throws "not initialized" until
228
+ // then, and the embedding factory resolves it and immediately calls
229
+ // updateState(). Worse, the ctx branch below would re-run initialize() on an
230
+ // instance that is still running it, constructing a second StateManager and
231
+ // discarding the first.
232
+ //
233
+ // So: if it is still initializing, wait for that to finish. The cycle case is
234
+ // exactly the case where `caller` is set (we are inside some service's
235
+ // initialize()), and there we must NOT wait — that is the deadlock the early
236
+ // publish exists to avoid. Outside an initialization there is no cycle to
237
+ // break and waiting is always correct.
238
+ if (registration.initializationPromise && caller === undefined) {
239
+ await registration.initializationPromise.catch(() => { /* surfaced below */ });
240
+ if (!registration.instance) {
241
+ // Initialization failed and cleaned up after itself — fall through and
242
+ // retry from scratch rather than returning a torn-down instance.
243
+ return this.get<T>(name, ctx);
244
+ }
245
+ }
214
246
  // Re-initialize if ctx is provided and service supports it
215
247
  if (ctx && registration.instance.initialize) {
216
248
  // Wrap in initializationContext so nested get() calls are tracked
217
- return initializationContext.run(name, async () => {
249
+ const reinit = initializationContext.run(name, async () => {
218
250
  await registration.instance!.initialize!(ctx);
219
251
  return registration.instance as T;
220
252
  });
253
+ // Recorded like a first init: a re-init is just as in-flight. Without
254
+ // this, disposeAll's settle-wait cannot see it (it would dispose the
255
+ // instance under a running initialize()), and concurrent callers
256
+ // received the instance mid-re-initialization.
257
+ registration.initializationPromise = reinit as Promise<IService>;
258
+ const clearIfCurrent = () => {
259
+ if (registration.initializationPromise === (reinit as Promise<IService>)) {
260
+ registration.initializationPromise = null;
261
+ }
262
+ };
263
+ reinit.then(clearIfCurrent, clearIfCurrent);
264
+ return reinit;
221
265
  }
222
266
  return registration.instance as T;
223
267
  }
@@ -243,7 +287,21 @@ export class ServiceContainer {
243
287
  }
244
288
 
245
289
  /**
246
- * Get a service instance synchronously (returns null if not initialized)
290
+ * Get a service instance synchronously (returns null if not registered or not
291
+ * yet constructed).
292
+ *
293
+ * Deliberately returns instances in NON-settled lifecycles too. This is a
294
+ * lifecycle *inspector* as much as an accessor: the health check resolves the
295
+ * knowledge store through it precisely so it can report `initializing` /
296
+ * `disabled` / `disposed (not running)` WITHOUT forcing initialization (forcing
297
+ * it was a past cause of 45 s CI stalls). Filtering those states out here would
298
+ * make every one of them indistinguishable from "absent".
299
+ *
300
+ * The trade-off: because `_initializeService` publishes the instance before
301
+ * awaiting its `initialize()` (to break dependency cycles), a caller that
302
+ * immediately *uses* the result can catch a half-built service. Callers that
303
+ * need a ready service must use the async `get()`, which waits — see the note
304
+ * there. Callers that only inspect `lifecycle` are safe by construction.
247
305
  */
248
306
  tryGet<T extends IService>(name: string): T | null {
249
307
  if (this.isDisposing) {
@@ -286,6 +344,15 @@ export class ServiceContainer {
286
344
  throw new Error(`Service '${name}' is not registered`);
287
345
  }
288
346
 
347
+ // Settle an in-flight initialization first. Disposing the early-published
348
+ // instance while its initialize() is still running hands the awaiting get()
349
+ // caller a service that was disposed under it — observed as store-closed
350
+ // errors when /research-config cleared the knowledge store during a
351
+ // concurrent first search.
352
+ if (registration.initializationPromise) {
353
+ await registration.initializationPromise.catch(() => { /* failed init cleaned up after itself */ });
354
+ }
355
+
289
356
  if (registration.instance && registration.instance.dispose) {
290
357
  await registration.instance.dispose().catch((err: unknown) => {
291
358
  logger.warn(`[ServiceContainer] Error disposing service '${name}':`, err);
@@ -309,6 +376,11 @@ export class ServiceContainer {
309
376
  throw new Error(`Service '${name}' is not registered`);
310
377
  }
311
378
 
379
+ // Settle an in-flight initialization before swapping — see clear().
380
+ if (registration.initializationPromise) {
381
+ await registration.initializationPromise.catch(() => { /* failed init cleaned up after itself */ });
382
+ }
383
+
312
384
  // Dispose old instance if present
313
385
  if (registration.instance && registration.instance.dispose) {
314
386
  await registration.instance.dispose().catch((err: unknown) => {
@@ -347,7 +419,41 @@ export class ServiceContainer {
347
419
  }
348
420
 
349
421
  try {
350
- const activeServices = Array.from(this.services.keys()).filter(name =>
422
+ // Settle any initialization still in flight BEFORE snapshotting what to tear
423
+ // down. `activeServices` is "instances that are non-null right now", so a
424
+ // get() whose factory has not resolved yet is invisible to it — and nothing
425
+ // else awaits it. Such a service installs itself into the registry AFTER
426
+ // teardown reports completion and is never disposed: for BrowserTaskScheduler
427
+ // that is a listening HTTP server plus a cluster pool and Camoufox children
428
+ // outliving shutdown; for KnowledgeStoreService an ONNX session and LanceDB
429
+ // handles. (SchedulerService.dispose hand-rolls this same guard for itself,
430
+ // which does not help when the leaked service IS SchedulerService.)
431
+ //
432
+ // Bounded: a service blocked on something slow (a model download) must not
433
+ // hold teardown open indefinitely. On timeout we proceed — the snapshot below
434
+ // still catches anything that landed in the meantime.
435
+ const inFlight = Array.from(this.services.values())
436
+ .map((r) => r.initializationPromise)
437
+ .filter((p): p is Promise<IService> => p != null);
438
+ if (inFlight.length > 0) {
439
+ if (this.defaultOptions.enableLogging) {
440
+ logger.debug(`[ServiceContainer] Waiting for ${inFlight.length} in-flight initialization(s) before teardown`);
441
+ }
442
+ let settleTimer: ReturnType<typeof setTimeout> | undefined;
443
+ await Promise.race([
444
+ Promise.allSettled(inFlight),
445
+ new Promise<void>((resolve) => {
446
+ settleTimer = setTimeout(() => {
447
+ logger.warn('[ServiceContainer] Timed out waiting for in-flight service initialization; proceeding with teardown');
448
+ resolve();
449
+ }, DISPOSE_INFLIGHT_INIT_TIMEOUT_MS);
450
+ settleTimer.unref?.();
451
+ }),
452
+ ]);
453
+ if (settleTimer) clearTimeout(settleTimer);
454
+ }
455
+
456
+ const activeServices = Array.from(this.services.keys()).filter(name =>
351
457
  this.services.get(name)?.instance !== null
352
458
  );
353
459
 
@@ -443,14 +549,17 @@ export class ServiceContainer {
443
549
  return null;
444
550
  }
445
551
 
446
- if (registration.instance) {
447
- return ServiceLifecycle.INITIALIZED;
448
- }
449
-
552
+ // Promise first: the instance is published BEFORE its initialize() completes
553
+ // (the early publish that lets dependency cycles resolve), so instance-first
554
+ // ordering reported a mid-initialization service as INITIALIZED.
450
555
  if (registration.initializationPromise) {
451
556
  return ServiceLifecycle.INITIALIZING;
452
557
  }
453
558
 
559
+ if (registration.instance) {
560
+ return ServiceLifecycle.INITIALIZED;
561
+ }
562
+
454
563
  return ServiceLifecycle.UNINITIALIZED;
455
564
  }
456
565
 
package/src/index.ts CHANGED
@@ -11,6 +11,7 @@ import type { ResearchResultDetails } from './types/index.ts';
11
11
  import { createResearchTool, createHealthTool } from './tool.ts';
12
12
  import { createResearchKnowledgeSearchTool } from './tools/research-knowledge-search.ts';
13
13
  import { logger } from './logger.ts';
14
+ import { checkPiCompatibility } from './core/pi-version.ts';
14
15
  import { randomUUID } from 'node:crypto';
15
16
  import { shutdownManager } from './utils/shutdown-manager.ts';
16
17
  import { healthRegistry } from './healthcheck/index.ts';
@@ -98,25 +99,17 @@ export default async function (pi: ExtensionAPI) {
98
99
  // 0.80.8+ host auto-building its ModelRuntime. This in-host check is the real
99
100
  // enforcement point: when running as an extension the host's version is NOT
100
101
  // constrained by npm's resolution of our own package.json range.
101
- const versionParts = PI_VERSION.split('.').map(Number);
102
- const major = versionParts[0] ?? 0;
103
- const minor = versionParts[1] ?? 0;
104
- const patch = versionParts[2] ?? 0;
105
- if (isNaN(major) || isNaN(minor) || isNaN(patch)) {
106
- throw new Error(
107
- `[pi-research] Cannot parse pi-coding-agent version "${PI_VERSION}". ` +
108
- `Please ensure pi-coding-agent is installed correctly.`,
109
- );
102
+ // Two thresholds, not one — see src/core/pi-version.ts. Below the floor the APIs
103
+ // we call do not exist, so we refuse. Above the last TESTED line we warn and
104
+ // continue: pi is pre-1.0, a minor bump can break anything under semver (0.83.0
105
+ // already extended the ResourceLoader contract), and our own dependency range
106
+ // cannot constrain the host at all when running as an extension.
107
+ const compat = checkPiCompatibility(PI_VERSION);
108
+ if (compat.fatal) {
109
+ throw new Error(compat.message ?? `[pi-research] Unsupported pi-coding-agent version "${PI_VERSION}".`);
110
110
  }
111
- const minMajor = 0, minMinor = 80, minPatch = 8;
112
- const tooOld = major < minMajor
113
- || (major === minMajor && minor < minMinor)
114
- || (major === minMajor && minor === minMinor && patch < minPatch);
115
- if (tooOld) {
116
- throw new Error(
117
- `[pi-research] pi-coding-agent v${PI_VERSION} is too old. ` +
118
- `Requires v${minMajor}.${minMinor}.${minPatch}+. Please update pi-coding-agent.`,
119
- );
111
+ if (compat.message) {
112
+ logger.warn(compat.message);
120
113
  }
121
114
 
122
115
  // Re-register the beforeExit safety net (deactivate() strips event listeners during reload).
@@ -11,6 +11,7 @@ import type { SearchResult } from '../../web-research/types.ts';
11
11
  import type { IScheduler } from '../../core/interfaces/scheduler-interfaces.ts';
12
12
  import { logger } from '../../logger.ts';
13
13
  import { errorTracker } from '../../utils/error-tracker.ts';
14
+ import { Utf8Body } from '../../utils/http-body.ts';
14
15
  import { getClientAgent } from './client-agent.ts';
15
16
  import type { NodeError } from '../../types/index.ts';
16
17
 
@@ -21,6 +22,13 @@ import type { NodeError } from '../../types/index.ts';
21
22
  export class BrowserClient implements IScheduler {
22
23
  private readonly authSecret: string;
23
24
 
25
+ /** The leader port this client was built for — lets the herd-guard cooldown
26
+ * verify the cached handle still targets the REGISTERED leader, not a
27
+ * predecessor that died inside the cooldown window. */
28
+ get targetPort(): number {
29
+ return this.port;
30
+ }
31
+
24
32
  constructor(private readonly port: number, authSecret?: string) {
25
33
  this.authSecret = authSecret ?? process.env['PI_BROWSER_AUTH_SECRET'] ?? '';
26
34
  logger.log(`[BrowserClient] Connecting to global scheduler at http://127.0.0.1:${port}`);
@@ -93,8 +101,11 @@ export class BrowserClient implements IScheduler {
93
101
  // when headers arrive, but the body is still streaming — clearing
94
102
  // now would let a stalled body hang forever and make an external
95
103
  // abort a no-op. Keep both armed until 'end' or 'error'.
96
- let body = '';
97
- res.on('data', chunk => body += chunk);
104
+ // This response carries scraped page content, so per-chunk decoding
105
+ // would corrupt every non-ASCII page that exceeds one chunk. Collect
106
+ // bytes and decode once. See Utf8Body.
107
+ const collected = new Utf8Body();
108
+ res.on('data', (chunk: Buffer) => collected.push(chunk));
98
109
  res.on('end', () => {
99
110
  if (resolved) return;
100
111
  resolved = true;
@@ -102,7 +113,7 @@ export class BrowserClient implements IScheduler {
102
113
  abortCleanup?.();
103
114
  const duration = Date.now() - start;
104
115
  try {
105
- const parsed = JSON.parse(body);
116
+ const parsed = JSON.parse(collected.toString());
106
117
  if (res.statusCode === 503 && parsed?.error === 'draining') {
107
118
  // Planned leadership handover, not a fault. Distinct message so
108
119
  // isServerDrainingError can classify it: retry paths treat it as
@@ -135,7 +146,8 @@ export class BrowserClient implements IScheduler {
135
146
  }
136
147
  } catch (_e) {
137
148
  // FIX (#23): Truncate response body in error to prevent data leakage
138
- const preview = body.length > 200 ? body.slice(0, 200) + '...' : body;
149
+ const raw = collected.toString();
150
+ const preview = raw.length > 200 ? raw.slice(0, 200) + '...' : raw;
139
151
  const error = new Error(`Failed to parse response (status ${res.statusCode}): ${preview}`);
140
152
  errorTracker.trackError(error, {
141
153
  component: 'browser-manager',
@@ -109,7 +109,18 @@ export function isPoolShutdownError(error: unknown): boolean {
109
109
  return typeof err.message === 'string' && (
110
110
  err.message.includes('Worker pool is shutting down') ||
111
111
  err.message.includes('Cannot execute a task on destroying pool') ||
112
- err.message.includes('destroying pool')
112
+ err.message.includes('destroying pool') ||
113
+ // worker-pool-manager throws this when auto-recovery is still swapping the
114
+ // pool. Its own text says "please retry", but until it was listed here no
115
+ // classifier matched it, so the first attempt rethrew instead of retrying —
116
+ // and auto-recovery fires only after 3 consecutive worker failures, i.e.
117
+ // exactly when a burst of tasks is in flight and would all die together.
118
+ err.message.includes('Worker pool is being reset') ||
119
+ // poolifier's wording once destroy() has COMPLETED (vs "destroying pool"
120
+ // while it is in progress). A queued closure holding a pool captured before
121
+ // an auto-recovery destroy dispatches into exactly this window; it is the
122
+ // same transient swap the "being reset" string covers, not a hard fault.
123
+ err.message.includes('Cannot execute a task on not started pool')
113
124
  );
114
125
  }
115
126
 
@@ -1,6 +1,7 @@
1
1
  import * as http from 'node:http';
2
2
  import * as crypto from 'node:crypto';
3
3
  import { logger } from '../../logger.ts';
4
+ import { Utf8Body } from '../../utils/http-body.ts';
4
5
  import type { SearchResult } from '../../web-research/types.ts';
5
6
  import { isCloudflareBlockError, isPoolShutdownError } from './browser-error-utils.ts';
6
7
 
@@ -92,6 +93,12 @@ export class BrowserServer {
92
93
  const expected = Buffer.from(getOrCreateAuthSecret(), 'utf8');
93
94
  const actual = Buffer.from(authHeader ?? '', 'utf8');
94
95
  if (actual.length !== expected.length || !crypto.timingSafeEqual(actual, expected)) {
96
+ // Drain the request before answering (mirrors the embedding
97
+ // server's 403 path): without it a still-arriving body leaves
98
+ // the request half-read and Node destroys the socket after the
99
+ // response flush, so the client can see ECONNRESET instead of
100
+ // the status.
101
+ req.resume();
95
102
  res.writeHead(403, { 'Content-Type': 'application/json' });
96
103
  res.end(JSON.stringify({ error: 'Unauthorized' }));
97
104
  return;
@@ -103,6 +110,7 @@ export class BrowserServer {
103
110
  // stops the client reusing this socket for a retry that would
104
111
  // only 503 again.
105
112
  if (this.draining) {
113
+ req.resume(); // drain before answering — see the 403 path
106
114
  res.writeHead(503, {
107
115
  'Content-Type': 'application/json',
108
116
  'Connection': 'close',
@@ -112,18 +120,22 @@ export class BrowserServer {
112
120
  }
113
121
 
114
122
  if (req.method !== 'POST') {
123
+ req.resume(); // drain before answering — see the 403 path
115
124
  res.writeHead(405);
116
125
  res.end('Method Not Allowed');
117
126
  return;
118
127
  }
119
128
 
120
- let body = '';
129
+ // Decoded once at 'end' rather than per chunk: `body += chunk`
130
+ // mangles any multi-byte character that straddles a chunk boundary.
131
+ // See Utf8Body.
132
+ const collected = new Utf8Body();
121
133
  let oversized = false;
122
134
  const MAX_BODY_SIZE = 10 * 1024 * 1024; // 10MB limit
123
135
 
124
- req.on('data', chunk => {
125
- body += chunk;
126
- if (body.length > MAX_BODY_SIZE) {
136
+ req.on('data', (chunk: Buffer) => {
137
+ collected.push(chunk);
138
+ if (collected.byteLength > MAX_BODY_SIZE) {
127
139
  oversized = true;
128
140
  req.destroy(new Error('Payload too large'));
129
141
  }
@@ -144,7 +156,7 @@ export class BrowserServer {
144
156
 
145
157
  req.on('end', async () => {
146
158
  try {
147
- const data = JSON.parse(body);
159
+ const data = JSON.parse(collected.toString());
148
160
  let result: any;
149
161
 
150
162
  switch (req.url) {