@lincoln504/pi-research 1.0.12 → 1.1.2

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
@@ -3,7 +3,7 @@
3
3
  </p>
4
4
 
5
5
  <p align="center">
6
- <strong>Limitless research and knowledge store — for pi, agent skill, or SDK.</strong>
6
+ <strong>Local web research and knowledge store</strong>
7
7
  </p>
8
8
 
9
9
  <p align="center">
@@ -24,9 +24,20 @@
24
24
 
25
25
  <a href="https://github.com/Lincoln504/pi-research/actions/workflows/ci.yml"><img alt="Build status" src="https://img.shields.io/github/actions/workflow/status/Lincoln504/pi-research/ci.yml?style=flat-square&branch=main" /></a> <a href="https://www.npmjs.com/package/@lincoln504/pi-research"><img alt="npm version" src="https://img.shields.io/npm/v/@lincoln504/pi-research.svg?style=flat-square" /></a>
26
26
 
27
- Research is broken into subtasks automatically, with each sub-researcher given a high volume of sources to investigate. Then, evaluator decides whether the answer is complete or an additional round of research is needed. The final result is a synthesized cited report.
27
+ **Free, unlimited web search &amp; scraping — no monthly quota, no subscription.**
28
+ You pay only for the LLM tokens you use, and it builds your own private, searchable
29
+ knowledge store on your machine.
28
30
 
29
- Ask in natural language — the tool understands the depth needed:
31
+ ### How it works
32
+
33
+ A research run is a loop of agent teams: a coordinator plans the work and runs an
34
+ opening search, parallel researcher agents scrape and read sources through a fixed,
35
+ sandboxed toolkit (no shell access, and no ability to write or modify files), and an evaluator decides whether
36
+ to run another round or synthesize — returning one cited Markdown report while its
37
+ sources are saved to the knowledge store in the background.
38
+
39
+ Three depth levels — normal, deep, ultra — set the team size and number of rounds;
40
+ ask in natural language and the tool picks the right one:
30
41
 
31
42
  ![Prompt-driven multi-round research in the pi TUI](docs/media/02-prompt-research.gif)
32
43
 
@@ -39,12 +50,13 @@ Ask in natural language — the tool understands the depth needed:
39
50
  - Using the pi-research agent skill as OpenClaw's web access.
40
51
  - Building agent systems that identify and examine web sources.
41
52
 
42
- ### Advantages
53
+ ### Why pi-research
43
54
 
44
- - Unlimited search and scrape, for free — you only pay for LLM tokens.
45
- - Context-efficient — it returns a synthesized, cited report to the chat instead of dumping raw web content into the conversation.
46
- - Safe by design — web access runs inside a specialized, limited research agent with no filesystem or shell access.
47
- - Search a little or a lot — three depth levels in the pi tool (four via the SDK and standalone CLI, which add the quick depth-0 path). Levels 1 and 2 are recommended for everyday workflow use; level 3 is for larger-scale investigations.
55
+ - **Free, unlimited search &amp; scrape — no quota, no monthly fee.** Most AI-search providers cap free searches per month and then charge; results run on *their* index on *their* servers. pi-research's search and scraping layer (DuckDuckGo via a stealth browser) is free and unlimited — you pay only for the LLM tokens you use, never per search or per month.
56
+ - **You own your index.** Every finding is written into a local [LanceDB](https://lancedb.com) knowledge store on *your* machine — a persistent, searchable index you control and keep, which also seeds future runs so repeat questions get faster, cheaper answers. No borrowed, server-side index you can't keep or query. Findings can also be kept **globally**, scoped to a **single project**, or **not kept at all** — set per directory, changed whenever you like. The optionality is the point: the store is yours to build, partition, or switch off.
57
+ - **Cited reports, not raw dumps.** It returns one synthesized, sourced Markdown report to the chat instead of dumping raw web content into the conversation — context-efficient and easy to verify.
58
+ - **Sandboxed by design.** Web access runs inside a specialized, limited research agent that cannot run shell commands or write, edit, or delete anything. It can read and search files in the working directory, so scope that directory as you would for any coding agent.
59
+ - **Search a little or a lot** — three depth levels in the pi tool (four via the SDK and standalone CLI, which add the quick depth-0 path). Levels 1 and 2 are recommended for everyday workflow use; level 3 is for larger-scale investigations.
48
60
 
49
61
  ### Requirements / limitations
50
62
 
package/dist/cli.mjs CHANGED
@@ -1709,9 +1709,23 @@ var init_metrics = __esm({
1709
1709
  });
1710
1710
 
1711
1711
  // src/utils/llm-usage.ts
1712
+ function warnIfUnpriced(model, tokens, cost) {
1713
+ if (tokens <= 0 || cost > 0) return;
1714
+ const m = model;
1715
+ const key = `${m.provider ?? "unknown"}/${m.id ?? "unknown"}`;
1716
+ if (unpricedModelsWarned.has(key)) return;
1717
+ const table = m.cost;
1718
+ const allZero = !table || Object.values(table).every((v) => typeof v !== "number" || v === 0);
1719
+ if (!allZero) return;
1720
+ unpricedModelsWarned.add(key);
1721
+ logger.warn(
1722
+ `[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.`
1723
+ );
1724
+ }
1712
1725
  function recordLlmUsage(model, rawUsage, opts) {
1713
1726
  if (!rawUsage) return { tokens: 0, cost: 0 };
1714
1727
  const { tokens, cost } = extractUsage(model, rawUsage);
1728
+ warnIfUnpriced(model, tokens, cost);
1715
1729
  if (tokens > 0 || cost > 0) {
1716
1730
  const labels = { component: opts.component };
1717
1731
  if (opts.complexity !== void 0) labels["complexity"] = String(opts.complexity);
@@ -1721,11 +1735,14 @@ function recordLlmUsage(model, rawUsage, opts) {
1721
1735
  }
1722
1736
  return { tokens, cost };
1723
1737
  }
1738
+ var unpricedModelsWarned;
1724
1739
  var init_llm_usage = __esm({
1725
1740
  "src/utils/llm-usage.ts"() {
1726
1741
  "use strict";
1727
1742
  init_llm();
1743
+ init_logger();
1728
1744
  init_metrics();
1745
+ unpricedModelsWarned = /* @__PURE__ */ new Set();
1729
1746
  }
1730
1747
  });
1731
1748
 
@@ -3292,6 +3309,13 @@ function constructMinimalModel(provider, modelId, _apiKey) {
3292
3309
  // Provider-specific base URLs are handled by pi-ai internal registry
3293
3310
  reasoning: false,
3294
3311
  input: ["text"],
3312
+ // No catalog is available on this path, so there is no price data and no real
3313
+ // context/output ceiling to read. The zeros below are "unknown", NOT "free":
3314
+ // cost reporting for this model will read $0.00 (see warnIfUnpriced in
3315
+ // utils/llm-usage.ts), and the window figures are conservative guesses, not the
3316
+ // model's true limits — so docs/CONFIGURATION.md's "clamped to the model's real
3317
+ // ceiling" guarantee does not hold here. Anyone relying on accurate cost or the
3318
+ // full context window should give pi a credential so it can fetch the real catalog.
3295
3319
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
3296
3320
  contextWindow: 128e3,
3297
3321
  maxTokens: 32768,
@@ -3309,7 +3333,7 @@ function resolveModel(registry, modelSpec, provider, apiKey) {
3309
3333
  if (apiKey && provider) {
3310
3334
  return constructMinimalModel(prov, modelId, apiKey);
3311
3335
  }
3312
- throw new Error(`Model "${modelSpec}" not found in pi's configured model registry. Check ${path6.join(getAgentDir(), "models.json")}.`);
3336
+ throw new Error(buildModelNotFoundMessage(modelSpec));
3313
3337
  }
3314
3338
  const allModels = safeGetAll(registry);
3315
3339
  const sameId = allModels.filter((m) => m.id === modelSpec);
@@ -3340,9 +3364,23 @@ function resolveModel(registry, modelSpec, provider, apiKey) {
3340
3364
  }
3341
3365
  return constructMinimalModel(provider, modelSpec, apiKey);
3342
3366
  }
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
- );
3367
+ throw new Error(buildNoModelAvailableMessage());
3368
+ }
3369
+ function credentialGuidance() {
3370
+ const agentDir = getAgentDir();
3371
+ 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.
3372
+ \u2022 Custom or self-hosted provider (Ollama, vLLM, a proxy): declare it in ${path6.join(agentDir, "models.json")}.
3373
+ Note: a models.json entry REPLACES pi's catalog entry for the same model id, discarding its pricing and
3374
+ context/output limits. To adjust a single field of a built-in model, use "modelOverrides" instead \u2014 it merges.`;
3375
+ }
3376
+ function buildModelNotFoundMessage(modelSpec) {
3377
+ return `Model "${modelSpec}" not found in pi's model registry.
3378
+ ${credentialGuidance()}`;
3379
+ }
3380
+ function buildNoModelAvailableMessage() {
3381
+ return `No LLM model available.
3382
+ ${credentialGuidance()}
3383
+ \u2022 Or pass an explicit apiKey.`;
3346
3384
  }
3347
3385
  function readModelsJsonProviderOrder() {
3348
3386
  const modelsJsonPath = path6.join(getAgentDir(), "models.json");
@@ -3428,6 +3466,7 @@ function withTimeout2(promise, timeoutMs, label, signal) {
3428
3466
  logger.debug(`[withTimeout] Starting ${timeoutMs}ms timeout guard (external signal: ${signal?.aborted ?? "no signal"})`);
3429
3467
  return new Promise((resolve8, reject) => {
3430
3468
  if (combinedSignal.aborted) {
3469
+ void promise.catch(() => void 0);
3431
3470
  logger.debug(`[withTimeout] ${label} already aborted at start (external cancellation)`);
3432
3471
  return reject(new Error(`${label} cancelled or timed out`));
3433
3472
  }
@@ -3533,7 +3572,7 @@ async function retryWithBackoff(fn, options = {}) {
3533
3572
  metrics.observe("retry_backoff_delay_ms", delay2, { attempt: String(attempt), label: opts.label });
3534
3573
  logger.warn(`[Retry] ${opts.label} failed (attempt ${attempt + 1}/${opts.maxRetries + 1}), retrying in ${Math.round(delay2)}ms: ${lastError.message}`);
3535
3574
  try {
3536
- await abortableDelay(delay2, opts.signal);
3575
+ await abortableDelay(delay2, opts.signal, true);
3537
3576
  } catch {
3538
3577
  metrics.increment("retry_aborted_total", 1, { label: opts.label });
3539
3578
  throw lastError;
@@ -4052,6 +4091,11 @@ var init_circuit_breaker = __esm({
4052
4091
  metrics.observe("circuit_breaker_call_duration_ms", duration, { breaker: this.options.name, status: "error" });
4053
4092
  if (this.options.isTransientError(error)) {
4054
4093
  this.onFailure(error);
4094
+ } else if (admittedAsProbe && this.state === "HALF_OPEN") {
4095
+ logger.warn(
4096
+ `[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)}`
4097
+ );
4098
+ this.transitionTo("OPEN");
4055
4099
  }
4056
4100
  throw error;
4057
4101
  } finally {
@@ -8039,7 +8083,7 @@ function resolveResearchModel(options) {
8039
8083
  if (anyModel.length > 0) {
8040
8084
  return pickPreferredAvailable(anyModel, readModelsJsonProviderOrder());
8041
8085
  }
8042
- throw new Error("No LLM model available for research. Please configure your model registry (~/.pi/agent/models.json).");
8086
+ throw new Error(buildNoModelAvailableMessage());
8043
8087
  }
8044
8088
  var init_research_model_resolver = __esm({
8045
8089
  "src/core/llm/research-model-resolver.ts"() {
@@ -8341,7 +8385,7 @@ async function runBackgroundExtraction(model, auth, conversationHistory, queries
8341
8385
  const delay2 = KNOWLEDGE_SYNTHESIS_RETRY_BASE_MS * attempt;
8342
8386
  metrics.increment("research_knowledge_search_synthesis_retries_total", 1);
8343
8387
  logger.warn(`[research-knowledge-search] Synthesis attempt ${attempt}/${KNOWLEDGE_SYNTHESIS_MAX_ATTEMPTS} failed transiently (${msg2}); retrying in ${delay2}ms`);
8344
- await abortableDelay(delay2, signal);
8388
+ await abortableDelay(delay2, signal, true);
8345
8389
  }
8346
8390
  }
8347
8391
  const extracted = extractJson(responseText, "object");
@@ -8429,7 +8473,7 @@ ${desc}`;
8429
8473
  const delay2 = KNOWLEDGE_SYNTHESIS_RETRY_BASE_MS * attempt;
8430
8474
  metrics.increment("research_knowledge_search_triage_retries_total", 1);
8431
8475
  logger.warn(`[research-knowledge-search] Triage attempt ${attempt}/${KNOWLEDGE_SYNTHESIS_MAX_ATTEMPTS} failed transiently (${msg2}); retrying in ${delay2}ms`);
8432
- await abortableDelay(delay2, signal);
8476
+ await abortableDelay(delay2, signal, true);
8433
8477
  }
8434
8478
  }
8435
8479
  const extracted = extractJson(responseText, "object");
@@ -9557,11 +9601,11 @@ function normalizeCitations(reports) {
9557
9601
  });
9558
9602
  const headerIdx = lastCitedLinksHeaderIndex(report);
9559
9603
  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
- });
9604
+ content = rewriteCitationMarkers(
9605
+ content,
9606
+ localToGlobal,
9607
+ (n) => !localToGlobal.has(n)
9608
+ );
9565
9609
  normalizedReports.set(id, content.trim());
9566
9610
  }
9567
9611
  return { normalizedReports, globalCitations };
@@ -9576,6 +9620,42 @@ function formatCitedLinks(citations) {
9576
9620
  return `CITED LINKS
9577
9621
  ${links.join("\n")}`;
9578
9622
  }
9623
+ var MAX_PLAUSIBLE_CITATION_MARKER = 200;
9624
+ function rewriteCitationMarkers(text, remap, isDangling) {
9625
+ const inFence = fencedCodeMask(text);
9626
+ return text.replace(/([ \t]*)\[(\d+)\]/g, (match, pad, digits, offset) => {
9627
+ const n = parseInt(digits, 10);
9628
+ const mapped = remap.get(n);
9629
+ if (mapped !== void 0) return `${pad}[${mapped}]`;
9630
+ if (n < 1 || n > MAX_PLAUSIBLE_CITATION_MARKER || !isDangling(n)) return match;
9631
+ if (inFence(offset) || inInlineCodeSpan(text, offset)) return match;
9632
+ const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
9633
+ return text.slice(lineStart, offset).trim() === "" ? pad : "";
9634
+ });
9635
+ }
9636
+ function fencedCodeMask(text) {
9637
+ const ranges = [];
9638
+ let open4 = -1;
9639
+ let pos = 0;
9640
+ for (const line of text.split("\n")) {
9641
+ if (/^\s{0,3}(```|~~~)/.test(line)) {
9642
+ if (open4 < 0) open4 = pos;
9643
+ else {
9644
+ ranges.push([open4, pos + line.length]);
9645
+ open4 = -1;
9646
+ }
9647
+ }
9648
+ pos += line.length + 1;
9649
+ }
9650
+ if (open4 >= 0) ranges.push([open4, text.length]);
9651
+ return (offset) => ranges.some(([start, end]) => offset >= start && offset < end);
9652
+ }
9653
+ function inInlineCodeSpan(text, offset) {
9654
+ const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
9655
+ let ticks = 0;
9656
+ for (let i = lineStart; i < offset; i++) if (text[i] === "`") ticks++;
9657
+ return ticks % 2 === 1;
9658
+ }
9579
9659
 
9580
9660
  // src/core/planning-service.ts
9581
9661
  init_agentic_repair();
@@ -10813,7 +10893,12 @@ function isServerDrainingError(error) {
10813
10893
  function isPoolShutdownError(error) {
10814
10894
  if (!error || typeof error !== "object") return false;
10815
10895
  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"));
10896
+ 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
10897
+ // pool. Its own text says "please retry", but until it was listed here no
10898
+ // classifier matched it, so the first attempt rethrew instead of retrying —
10899
+ // and auto-recovery fires only after 3 consecutive worker failures, i.e.
10900
+ // exactly when a burst of tasks is in flight and would all die together.
10901
+ err.message.includes("Worker pool is being reset"));
10817
10902
  }
10818
10903
  var sessionCircuitBreakers = /* @__PURE__ */ new Map();
10819
10904
  var MAX_SESSION_BREAKERS = 256;
@@ -14228,14 +14313,14 @@ init_file_lock_service();
14228
14313
  init_service_registry();
14229
14314
  init_service_names();
14230
14315
  import * as pathmod2 from "node:path";
14231
- var DEFAULT_MAX_CONCURRENT_RUNS = 2;
14232
- var DEFAULT_ACQUIRE_TIMEOUT_MS = 0;
14316
+ var DEFAULT_MAX_CONCURRENT_RUNS = 3;
14317
+ var DEFAULT_ACQUIRE_TIMEOUT_MS = 6e5;
14233
14318
  var POLL_DELAY_MS = 250;
14234
14319
  var ResearchRunCapacityError = class extends Error {
14235
14320
  slots;
14236
14321
  constructor(slots, maxWaitMs) {
14237
14322
  super(
14238
- `Maximum concurrent research runs (${slots}) reached${maxWaitMs > 0 ? ` after waiting ${maxWaitMs}ms` : ""}; wait for an in-progress run to finish and retry. (Override with PI_RESEARCH_MAX_CONCURRENT_RUNS.)`
14323
+ `Maximum concurrent research runs (${slots}) reached${maxWaitMs > 0 ? ` and still busy after queueing for ${Math.round(maxWaitMs / 1e3)}s` : ""}; another run is still in progress. This is temporary \u2014 retry shortly. (Raise the cap with PI_RESEARCH_MAX_CONCURRENT_RUNS, or the queue wait with PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS.)`
14239
14324
  );
14240
14325
  this.name = "ResearchRunCapacityError";
14241
14326
  this.slots = slots;
@@ -14335,16 +14420,20 @@ var ResearchRunSemaphore = class {
14335
14420
  * @param signal aborts the wait. Without it a run started under a long
14336
14421
  * `PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS` would ignore Ctrl-C until the deadline
14337
14422
  * passed, because the wait happens before any orchestrator exists to cancel.
14423
+ * @param onQueued invoked at most once, when the first full sweep finds every
14424
+ * slot busy and the call is about to wait. Lets a front-end tell the user the
14425
+ * run is queued rather than hung. Never allowed to break the acquire.
14338
14426
  * @throws {ResearchRunCapacityError} if no slot frees within `maxWaitMs`.
14339
14427
  * @throws {Error} named `AbortError` if `signal` aborts while waiting.
14340
14428
  */
14341
- async acquire(maxWaitMs = this.defaultMaxWaitMs, signal) {
14429
+ async acquire(maxWaitMs = this.defaultMaxWaitMs, signal, onQueued) {
14342
14430
  if (!this._initialized) await this.initialize();
14343
14431
  if (signal?.aborted) {
14344
14432
  metrics.increment("research_run_acquire_total", 1, { status: "aborted" });
14345
14433
  throwIfAborted(signal);
14346
14434
  }
14347
14435
  const deadline = Date.now() + Math.max(0, maxWaitMs);
14436
+ let announcedQueued = false;
14348
14437
  for (; ; ) {
14349
14438
  const start = this.nextStartIndex;
14350
14439
  this.nextStartIndex = (this.nextStartIndex + 1) % this.maxSlots;
@@ -14361,6 +14450,15 @@ var ResearchRunSemaphore = class {
14361
14450
  metrics.increment("research_run_acquire_total", 1, { status: "exhausted" });
14362
14451
  throw new ResearchRunCapacityError(this.maxSlots, maxWaitMs);
14363
14452
  }
14453
+ if (!announcedQueued) {
14454
+ announcedQueued = true;
14455
+ logger.info(`[ResearchRunSemaphore] All ${this.maxSlots} run slot(s) busy \u2014 queueing for up to ${maxWaitMs}ms.`);
14456
+ try {
14457
+ onQueued?.(this.maxSlots, maxWaitMs);
14458
+ } catch (err) {
14459
+ logger.debug(`[ResearchRunSemaphore] onQueued callback threw: ${err instanceof Error ? err.message : String(err)}`);
14460
+ }
14461
+ }
14364
14462
  try {
14365
14463
  await sleep(POLL_DELAY_MS, signal);
14366
14464
  } catch (err) {
@@ -14886,6 +14984,17 @@ init_service_registry();
14886
14984
  init_service_interfaces();
14887
14985
  var lastRestartTime = 0;
14888
14986
  var RESTART_COOLDOWN_MS = 1e4;
14987
+ var LEADER_HANDOVER_RETRIES = 3;
14988
+ var HANDOVER_BACKOFF_MS = 1200;
14989
+ async function recoverFromLeaderHandover(container) {
14990
+ logger.warn("[BrowserManager] Leader handover detected (pool draining) \u2014 dropping stale scheduler handle and re-resolving...");
14991
+ await forceSchedulerRestart2(false, container).catch(
14992
+ (err) => logger.debug("[BrowserManager] Scheduler re-resolve during handover failed (non-fatal):", err)
14993
+ );
14994
+ await waitForBrowserPoolIdle(15e3).catch(
14995
+ (err) => logger.debug("Wait for browser idle timed out or failed:", err)
14996
+ );
14997
+ }
14889
14998
  async function getScheduler2(config, container = getServiceContainer()) {
14890
14999
  try {
14891
15000
  const factory = await getService(ServiceNames.SCHEDULER_FACTORY, void 0, container);
@@ -14902,7 +15011,7 @@ async function forceSchedulerRestart2(forceClearRemoteState = false, container =
14902
15011
  return await forceSchedulerRestart(forceClearRemoteState, container);
14903
15012
  }
14904
15013
  }
14905
- async function runBrowserTask(taskOrUrl, type = "scrape", config, signal, retries = 1, container = getServiceContainer()) {
15014
+ async function runBrowserTask(taskOrUrl, type = "scrape", config, signal, retries = 1, container = getServiceContainer(), handoverRetries = LEADER_HANDOVER_RETRIES) {
14906
15015
  if (signal?.aborted) throw new Error("Aborted");
14907
15016
  const sessionId = typeof taskOrUrl === "object" ? taskOrUrl.sessionId : void 0;
14908
15017
  const breaker2 = sessionId ? getBrowserCircuitBreaker(sessionId) : browserCircuitBreaker;
@@ -14923,6 +15032,19 @@ async function runBrowserTask(taskOrUrl, type = "scrape", config, signal, retrie
14923
15032
  });
14924
15033
  } catch (error) {
14925
15034
  if (signal?.aborted || error instanceof Error && error.message === "Aborted") throw new Error("Aborted", { cause: error });
15035
+ const isHandover = isPoolShutdownError(error) && !isTaskTimeoutError(error) && !isCloudflareBlockError(error);
15036
+ if (isHandover && handoverRetries > 0) {
15037
+ errorTracker.trackError(error, {
15038
+ component: "browser-manager",
15039
+ operation: type,
15040
+ taskType: type,
15041
+ errorType: "leader_handover"
15042
+ });
15043
+ logger.warn(`[BrowserManager] Leader handover during ${type} task (handover attempts left: ${handoverRetries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
15044
+ await recoverFromLeaderHandover(container);
15045
+ await new Promise((resolve8) => setTimeout(resolve8, HANDOVER_BACKOFF_MS + Math.floor(Math.random() * 400)));
15046
+ return runBrowserTask(taskOrUrl, type, config, signal, retries, container, handoverRetries - 1);
15047
+ }
14926
15048
  if (retries > 0 && isTransientSocketError(error) && !isTaskTimeoutError(error) && !isCloudflareBlockError(error)) {
14927
15049
  errorTracker.trackError(error, {
14928
15050
  component: "browser-manager",
@@ -14948,7 +15070,7 @@ async function runBrowserTask(taskOrUrl, type = "scrape", config, signal, retrie
14948
15070
  }
14949
15071
  const jitter = 100 + Math.floor(Math.random() * 400);
14950
15072
  await new Promise((resolve8) => setTimeout(resolve8, jitter));
14951
- return runBrowserTask(taskOrUrl, type, config, signal, retries - 1, container);
15073
+ return runBrowserTask(taskOrUrl, type, config, signal, retries - 1, container, handoverRetries);
14952
15074
  }
14953
15075
  throw error;
14954
15076
  }
@@ -14968,7 +15090,7 @@ async function runBrowserHealthCheck(config, retries = 1, signal, container = ge
14968
15090
  });
14969
15091
  logger.warn(`[BrowserManager] Transient socket error during healthcheck (retries left: ${retries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
14970
15092
  if (isPoolShutdownError(error)) {
14971
- await waitForBrowserPoolIdle(15e3).catch((err) => logger.debug("Wait for browser idle timed out or failed:", err));
15093
+ await recoverFromLeaderHandover(container);
14972
15094
  } else {
14973
15095
  const now = Date.now();
14974
15096
  if (now - lastRestartTime > RESTART_COOLDOWN_MS) {
@@ -14987,7 +15109,7 @@ async function runBrowserHealthCheck(config, retries = 1, signal, container = ge
14987
15109
  throw error;
14988
15110
  }
14989
15111
  }
14990
- async function runWorkerSearch(query, config, signal, retries = 1, sessionId, container = getServiceContainer()) {
15112
+ async function runWorkerSearch(query, config, signal, retries = 1, sessionId, container = getServiceContainer(), handoverRetries = LEADER_HANDOVER_RETRIES) {
14991
15113
  if (signal?.aborted) throw new Error("Aborted");
14992
15114
  const breaker2 = sessionId ? getBrowserCircuitBreaker(sessionId) : browserCircuitBreaker;
14993
15115
  try {
@@ -14998,6 +15120,19 @@ async function runWorkerSearch(query, config, signal, retries = 1, sessionId, co
14998
15120
  });
14999
15121
  } catch (error) {
15000
15122
  if (signal?.aborted || error instanceof Error && error.message === "Aborted") throw new Error("Aborted", { cause: error });
15123
+ const isHandover = isPoolShutdownError(error) && !isTaskTimeoutError(error) && !isCloudflareBlockError(error);
15124
+ if (isHandover && handoverRetries > 0) {
15125
+ errorTracker.trackError(error, {
15126
+ component: "browser-manager",
15127
+ operation: "search",
15128
+ query,
15129
+ errorType: "leader_handover"
15130
+ });
15131
+ logger.warn(`[BrowserManager] Leader handover during search (handover attempts left: ${handoverRetries}): ${(error instanceof Error ? error.message : String(error)).substring(0, 100)}...`);
15132
+ await recoverFromLeaderHandover(container);
15133
+ await new Promise((resolve8) => setTimeout(resolve8, HANDOVER_BACKOFF_MS + Math.floor(Math.random() * 400)));
15134
+ return runWorkerSearch(query, config, signal, retries, sessionId, container, handoverRetries - 1);
15135
+ }
15001
15136
  if (retries > 0 && isTransientSocketError(error) && !isTaskTimeoutError(error) && !isCloudflareBlockError(error)) {
15002
15137
  errorTracker.trackError(error, {
15003
15138
  component: "browser-manager",
@@ -15021,7 +15156,7 @@ async function runWorkerSearch(query, config, signal, retries = 1, sessionId, co
15021
15156
  }
15022
15157
  const jitter = 100 + Math.floor(Math.random() * 400);
15023
15158
  await new Promise((resolve8) => setTimeout(resolve8, jitter));
15024
- return runWorkerSearch(query, config, signal, retries - 1, sessionId, container);
15159
+ return runWorkerSearch(query, config, signal, retries - 1, sessionId, container, handoverRetries);
15025
15160
  }
15026
15161
  throw error;
15027
15162
  }
@@ -20813,6 +20948,9 @@ var HeadlessObserver = class {
20813
20948
  onStart(query, complexity) {
20814
20949
  this.emit("start", { query, complexity });
20815
20950
  }
20951
+ onRunQueued(slots, maxWaitMs) {
20952
+ this.emit("run_queued", { slots, maxWaitMs });
20953
+ }
20816
20954
  onPlanningStart(attempt) {
20817
20955
  this.emit("planning_start", { attempt });
20818
20956
  }
@@ -21611,7 +21749,9 @@ var ResearchOrchestrationService = class {
21611
21749
  try {
21612
21750
  const container = tryGetServiceContainerFromCtx(ctx);
21613
21751
  const semaphore = await getService(ServiceNames.RESEARCH_RUN_SEMAPHORE, ctx, container);
21614
- runSlot = await semaphore.acquire(void 0, signal);
21752
+ runSlot = await semaphore.acquire(void 0, signal, (slots, maxWaitMs) => {
21753
+ notifyRunQueued(observer, slots, maxWaitMs);
21754
+ });
21615
21755
  logger.info(`[ResearchOrchestrationService] Acquired research run slot ${runSlot.slotIndex} (cap ${semaphore.getMaxSlots()}).`);
21616
21756
  } catch (err) {
21617
21757
  if (err instanceof ResearchRunCapacityError) throw err;
@@ -21965,6 +22105,15 @@ var ResearchOrchestrationService = class {
21965
22105
  }
21966
22106
  }
21967
22107
  };
22108
+ function notifyRunQueued(observer, slots, maxWaitMs) {
22109
+ try {
22110
+ const obs = observer;
22111
+ if (typeof obs?.onRunQueued === "function") obs.onRunQueued(slots, maxWaitMs);
22112
+ else if (typeof obs?.onProgress === "function") obs.onProgress("run_queued", { slots, maxWaitMs });
22113
+ } catch (err) {
22114
+ logger.debug(`[ResearchOrchestrationService] run_queued notification threw: ${err instanceof Error ? err.message : String(err)}`);
22115
+ }
22116
+ }
21968
22117
 
21969
22118
  // src/orchestration/research-session-service.ts
21970
22119
  init_logger();
@@ -22114,42 +22263,6 @@ init_service_registry();
22114
22263
  init_service_names();
22115
22264
  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._";
22116
22265
  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._";
22117
- var MAX_PLAUSIBLE_CITATION_MARKER = 200;
22118
- function rewriteCitationMarkers(text, remap, isDangling) {
22119
- const inFence = fencedCodeMask(text);
22120
- return text.replace(/([ \t]*)\[(\d+)\]/g, (match, pad, digits, offset) => {
22121
- const n = parseInt(digits, 10);
22122
- const mapped = remap.get(n);
22123
- if (mapped !== void 0) return `${pad}[${mapped}]`;
22124
- if (n < 1 || n > MAX_PLAUSIBLE_CITATION_MARKER || !isDangling(n)) return match;
22125
- if (inFence(offset) || inInlineCodeSpan(text, offset)) return match;
22126
- const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
22127
- return text.slice(lineStart, offset).trim() === "" ? pad : "";
22128
- });
22129
- }
22130
- function fencedCodeMask(text) {
22131
- const ranges = [];
22132
- let open4 = -1;
22133
- let pos = 0;
22134
- for (const line of text.split("\n")) {
22135
- if (/^\s{0,3}(```|~~~)/.test(line)) {
22136
- if (open4 < 0) open4 = pos;
22137
- else {
22138
- ranges.push([open4, pos + line.length]);
22139
- open4 = -1;
22140
- }
22141
- }
22142
- pos += line.length + 1;
22143
- }
22144
- if (open4 >= 0) ranges.push([open4, text.length]);
22145
- return (offset) => ranges.some(([start, end]) => offset >= start && offset < end);
22146
- }
22147
- function inInlineCodeSpan(text, offset) {
22148
- const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
22149
- let ticks = 0;
22150
- for (let i = lineStart; i < offset; i++) if (text[i] === "`") ticks++;
22151
- return ticks % 2 === 1;
22152
- }
22153
22266
  function redactUnverifiedProseUrls(text, trusted) {
22154
22267
  if (trusted.size === 0) return text;
22155
22268
  let removed = 0;
@@ -23191,8 +23304,8 @@ function appendExportMessage(result, filepath, totalCost) {
23191
23304
  ---
23192
23305
 
23193
23306
  Research report saved to: ${filepath}`;
23194
- if (totalCost !== void 0 && totalCost > 0) {
23195
- const costStr = totalCost < 0.01 ? "<$0.01" : `$${totalCost.toFixed(4)}`.replace(/0+$/, "").replace(/\.$/, "");
23307
+ if (totalCost !== void 0) {
23308
+ const costStr = totalCost === 0 ? "$0.00" : totalCost < 0.01 ? "<$0.01" : `$${totalCost.toFixed(4)}`.replace(/0+$/, "").replace(/\.$/, "");
23196
23309
  footer += `
23197
23310
  Total cost: ${costStr}`;
23198
23311
  }
@@ -23221,7 +23334,15 @@ var EXIT = {
23221
23334
  /** Missing setup: package, model, or API key not configured. */
23222
23335
  CONFIG: 78,
23223
23336
  /** Something went wrong at runtime (network, provider, internal). */
23224
- SOFTWARE: 70
23337
+ SOFTWARE: 70,
23338
+ /**
23339
+ * Temporary failure — the request was well-formed and the install is healthy,
23340
+ * but the machine is at capacity right now (all research run slots held by
23341
+ * live runs). Distinct from SOFTWARE so a calling agent can tell "busy, try
23342
+ * again shortly" apart from "this run crashed" and back off instead of
23343
+ * re-running a broken command.
23344
+ */
23345
+ TEMPFAIL: 75
23225
23346
  };
23226
23347
  function toStderr(s) {
23227
23348
  process.stderr.write(s);
@@ -23241,6 +23362,12 @@ function makeProgressObserver() {
23241
23362
  toStderr(` \u2022 plan: ${data?.plan?.researchers?.length ?? "?"} researcher(s)
23242
23363
  `);
23243
23364
  break;
23365
+ case "run_queued":
23366
+ toStderr(
23367
+ ` \u2022 queued: ${data?.slots ?? "?"} concurrent run(s) already active \u2014 waiting up to ${Math.round((data?.maxWaitMs ?? 0) / 1e3)}s for a slot\u2026
23368
+ `
23369
+ );
23370
+ break;
23244
23371
  case "round_start":
23245
23372
  toStderr(` \u2022 round ${data?.round}
23246
23373
  `);
@@ -23261,10 +23388,16 @@ function makeProgressObserver() {
23261
23388
  toStderr(` \u2022 evaluator: ${data?.action}
23262
23389
  `);
23263
23390
  break;
23264
- case "tokens_consumed":
23265
- if (data?.cost) toStderr(` \u2022 tokens: ${data?.tokens ?? 0} ($${Number(data.cost).toFixed(4)})
23391
+ case "tokens_consumed": {
23392
+ const consumed = Number(data?.tokens ?? 0);
23393
+ const costVal = typeof data?.cost === "number" ? data.cost : void 0;
23394
+ if (consumed > 0 || costVal !== void 0) {
23395
+ const costPart = costVal === void 0 ? "" : ` ($${costVal.toFixed(4)})`;
23396
+ toStderr(` \u2022 tokens: ${consumed}${costPart}
23266
23397
  `);
23398
+ }
23267
23399
  break;
23400
+ }
23268
23401
  case "complete":
23269
23402
  toStderr(` \u2022 complete (${data?.result?.length ?? 0} chars)
23270
23403
  `);
@@ -23785,17 +23918,25 @@ async function reportError(err, what, json) {
23785
23918
  const msg2 = err instanceof Error ? err.message : String(err);
23786
23919
  const lower = msg2.toLowerCase();
23787
23920
  const isTaggedResearchStop = err?.code === RESEARCH_STOPPED_ERROR_CODE;
23788
- const isConfigError = !isTaggedResearchStop && (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"));
23921
+ const isCapacity = err instanceof Error && err.name === "ResearchRunCapacityError";
23922
+ 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"));
23789
23923
  const isRateLimit = lower.includes("429") || lower.includes("rate limit") || lower.includes("too many requests");
23790
- const exitCode = isConfigError && !isRateLimit ? EXIT.CONFIG : EXIT.SOFTWARE;
23924
+ const exitCode = isCapacity ? EXIT.TEMPFAIL : isConfigError && !isRateLimit ? EXIT.CONFIG : EXIT.SOFTWARE;
23791
23925
  if (json) {
23792
23926
  const payload = { ok: false, error: msg2, exitCode };
23927
+ if (isCapacity || isRateLimit) payload["retryable"] = true;
23793
23928
  if (process.env["PI_RESEARCH_DEBUG"] === "true" && err instanceof Error && err.stack) {
23794
23929
  payload["stack"] = err.stack;
23795
23930
  }
23796
23931
  toStdout(pretty(payload));
23797
23932
  return exitCode;
23798
23933
  }
23934
+ if (isCapacity) {
23935
+ toStderr(`
23936
+ Error: pi-research ${what} did not start: ${msg2}
23937
+ `);
23938
+ return exitCode;
23939
+ }
23799
23940
  if (isRateLimit) {
23800
23941
  toStderr(`
23801
23942
  Error: pi-research ${what} halted: provider rate limit reached. Wait a moment and retry.
@@ -23898,7 +24039,9 @@ CONFIGURE
23898
24039
  EXIT CODES
23899
24040
  0 success
23900
24041
  64 bad arguments
24042
+ 75 at capacity \u2014 every concurrent run slot is busy; nothing is broken, retry later
23901
24043
  78 not configured \u2014 message prints the exact locations to fix
24044
+ (on \`knowledge\`, also returned when the store is deliberately disabled)
23902
24045
  70 runtime error (network, provider, internal)
23903
24046
  `;
23904
24047
  }
@@ -1284,6 +1284,11 @@ var init_circuit_breaker = __esm({
1284
1284
  metrics.observe("circuit_breaker_call_duration_ms", duration, { breaker: this.options.name, status: "error" });
1285
1285
  if (this.options.isTransientError(error)) {
1286
1286
  this.onFailure(error);
1287
+ } else if (admittedAsProbe && this.state === "HALF_OPEN") {
1288
+ logger.warn(
1289
+ `[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)}`
1290
+ );
1291
+ this.transitionTo("OPEN");
1287
1292
  }
1288
1293
  throw error;
1289
1294
  } finally {
@@ -1379,7 +1384,12 @@ function isServerDrainingError(error) {
1379
1384
  function isPoolShutdownError(error) {
1380
1385
  if (!error || typeof error !== "object") return false;
1381
1386
  const err = error;
1382
- 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"));
1387
+ 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
1388
+ // pool. Its own text says "please retry", but until it was listed here no
1389
+ // classifier matched it, so the first attempt rethrew instead of retrying —
1390
+ // and auto-recovery fires only after 3 consecutive worker failures, i.e.
1391
+ // exactly when a burst of tasks is in flight and would all die together.
1392
+ err.message.includes("Worker pool is being reset"));
1383
1393
  }
1384
1394
  var DEFAULT_BREAKER_CONFIG, browserCircuitBreaker;
1385
1395
  var init_browser_error_utils = __esm({