@lincoln504/pi-research 1.1.0 → 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
@@ -32,7 +32,7 @@ knowledge store on your machine.
32
32
 
33
33
  A research run is a loop of agent teams: a coordinator plans the work and runs an
34
34
  opening search, parallel researcher agents scrape and read sources through a fixed,
35
- sandboxed toolkit (no filesystem or shell access), and an evaluator decides whether
35
+ sandboxed toolkit (no shell access, and no ability to write or modify files), and an evaluator decides whether
36
36
  to run another round or synthesize — returning one cited Markdown report while its
37
37
  sources are saved to the knowledge store in the background.
38
38
 
@@ -55,7 +55,7 @@ ask in natural language and the tool picks the right one:
55
55
  - **Free, unlimited search & 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
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
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 with no filesystem or shell access — safe to point at anything.
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
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.
60
60
 
61
61
  ### Requirements / limitations
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;
@@ -22178,42 +22263,6 @@ init_service_registry();
22178
22263
  init_service_names();
22179
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._";
22180
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._";
22181
- var MAX_PLAUSIBLE_CITATION_MARKER = 200;
22182
- function rewriteCitationMarkers(text, remap, isDangling) {
22183
- const inFence = fencedCodeMask(text);
22184
- return text.replace(/([ \t]*)\[(\d+)\]/g, (match, pad, digits, offset) => {
22185
- const n = parseInt(digits, 10);
22186
- const mapped = remap.get(n);
22187
- if (mapped !== void 0) return `${pad}[${mapped}]`;
22188
- if (n < 1 || n > MAX_PLAUSIBLE_CITATION_MARKER || !isDangling(n)) return match;
22189
- if (inFence(offset) || inInlineCodeSpan(text, offset)) return match;
22190
- const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
22191
- return text.slice(lineStart, offset).trim() === "" ? pad : "";
22192
- });
22193
- }
22194
- function fencedCodeMask(text) {
22195
- const ranges = [];
22196
- let open4 = -1;
22197
- let pos = 0;
22198
- for (const line of text.split("\n")) {
22199
- if (/^\s{0,3}(```|~~~)/.test(line)) {
22200
- if (open4 < 0) open4 = pos;
22201
- else {
22202
- ranges.push([open4, pos + line.length]);
22203
- open4 = -1;
22204
- }
22205
- }
22206
- pos += line.length + 1;
22207
- }
22208
- if (open4 >= 0) ranges.push([open4, text.length]);
22209
- return (offset) => ranges.some(([start, end]) => offset >= start && offset < end);
22210
- }
22211
- function inInlineCodeSpan(text, offset) {
22212
- const lineStart = text.lastIndexOf("\n", offset - 1) + 1;
22213
- let ticks = 0;
22214
- for (let i = lineStart; i < offset; i++) if (text[i] === "`") ticks++;
22215
- return ticks % 2 === 1;
22216
- }
22217
22266
  function redactUnverifiedProseUrls(text, trusted) {
22218
22267
  if (trusted.size === 0) return text;
22219
22268
  let removed = 0;
@@ -23255,8 +23304,8 @@ function appendExportMessage(result, filepath, totalCost) {
23255
23304
  ---
23256
23305
 
23257
23306
  Research report saved to: ${filepath}`;
23258
- if (totalCost !== void 0 && totalCost > 0) {
23259
- const costStr = totalCost < 0.01 ? "<$0.01" : `$${totalCost.toFixed(4)}`.replace(/0+$/, "").replace(/\.$/, "");
23307
+ if (totalCost !== void 0) {
23308
+ const costStr = totalCost === 0 ? "$0.00" : totalCost < 0.01 ? "<$0.01" : `$${totalCost.toFixed(4)}`.replace(/0+$/, "").replace(/\.$/, "");
23260
23309
  footer += `
23261
23310
  Total cost: ${costStr}`;
23262
23311
  }
@@ -23339,10 +23388,16 @@ function makeProgressObserver() {
23339
23388
  toStderr(` \u2022 evaluator: ${data?.action}
23340
23389
  `);
23341
23390
  break;
23342
- case "tokens_consumed":
23343
- 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}
23344
23397
  `);
23398
+ }
23345
23399
  break;
23400
+ }
23346
23401
  case "complete":
23347
23402
  toStderr(` \u2022 complete (${data?.result?.length ?? 0} chars)
23348
23403
  `);
@@ -23984,7 +24039,9 @@ CONFIGURE
23984
24039
  EXIT CODES
23985
24040
  0 success
23986
24041
  64 bad arguments
24042
+ 75 at capacity \u2014 every concurrent run slot is busy; nothing is broken, retry later
23987
24043
  78 not configured \u2014 message prints the exact locations to fix
24044
+ (on \`knowledge\`, also returned when the store is deliberately disabled)
23988
24045
  70 runtime error (network, provider, internal)
23989
24046
  `;
23990
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({
package/docs/CHANGELOG.md CHANGED
@@ -5,6 +5,32 @@ 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.1.2] - 2026-08-04
9
+
10
+ > Supersedes `v1.1.1`, which was tagged but never published: its release run was
11
+ > correctly blocked by a red CI, and the tag is retained only as a marker of that
12
+ > attempt. Everything below is the full set of changes since 1.1.0.
13
+
14
+ ### Fixed
15
+ - **The multi-process run-cap test counted cumulative slot acquisitions as concurrent ones.** The "thundering herd" test asserts the *concurrent* cap but tallied *cumulative* `acquired` events, and let its winners hold for a finite 4 s. On a slow runner the six contenders start staggered, so a late one can attempt after an early winner's hold has already elapsed, legitimately win the freed slot, and push the cumulative tally past the cap while at most `MAX` ever held at once — a false failure that blocked this release on Windows CI. Confirmed by direct reproduction: with the stagger forced, four acquisitions were recorded against a maximum of two concurrent holders, the late pair's acquire timestamps falling strictly after the early pair's releases on reused slots. Winners now hold until killed, so there is no free slot to win and the count measures genuine simultaneity — a strictly stronger assertion. Test-only; the semaphore itself was never at fault and is unchanged. (The sibling "distinct slot assignments" test was fixed the same way in 1.1.0; that change cited this test's own stated principle but left this test on the racy pattern.)
16
+ - **Citation markers could be silently retargeted at the wrong source.** When merging researcher reports, `normalizeCitations` renumbers each report's local `[N]` markers to global ids — but a marker it could not map was left *verbatim* instead of being removed. Global ids are assigned densely from 1, so a surviving `[1]` no longer meant "this report's first source": it resolved against the renumbered global list and pointed at an unrelated document. Markers go unmapped whenever `parseCitations` legitimately drops an entry, e.g. a URL whose hostname has no dot (`localhost`, a bare internal host, a malformed line). This was not cosmetic — the normalized body is fed straight into the evaluator/synthesis prompt, which is explicitly told the numbering is already global, so the model faithfully reproduced the wrong attribution, and `ensureCitedLinks` then regenerated a `CITED LINKS` list perfectly consistent with it. A correct structure-preserving rewriter already existed on the synthesis path (added in 1.0.11); it is now shared, so dangling markers are removed cleanly while fenced blocks and inline-code spans — a quoted `items[12]` — stay untouched. The prior test could never have caught this: its assertion was structurally guaranteed by the CITED-LINKS slice regardless of marker handling.
17
+ - **A retry could silently abandon the operation instead of retrying it.** `retryWithBackoff` awaited its inter-attempt sleep on an `unref`'d timer. The sleep *is* the operation — the retry has already been decided and the next attempt must run — and `abortableDelay`'s own docstring says exactly that (`web-scraper.ts` passes `keepAlive` with a comment explaining why); the backoff simply omitted it. The failure compounds itself: the transient network errors that trigger a retry are precisely the ones that leave no referenced socket handle, so the backoff is frequently the *only* pending handle, and Node drains the event loop and exits mid-retry. The next attempt never runs and nothing is reported — in the CLI neither branch of the top-level handler fires, so the user gets a bare exit with no report and no error. Affected every `retryWithBackoff` caller (LLM planning, all four security clients, YouTube transcripts) plus both knowledge-search retry loops.
18
+ - **The circuit breaker could pin permanently in `HALF_OPEN`, collapsing parallel search bursts.** `onFailure` is the only path back to `OPEN` and is skipped for errors the breaker does not count, while `transitionTo('HALF_OPEN')` never re-arms `nextAttemptTime` — so a trial probe failing with a non-counted error left the breaker stuck with no time-based escape, fast-failing every caller beyond `halfOpenMaxCalls` (default 1) indefinitely. This was not a corner case for the browser pool: `DEFAULT_BREAKER_CONFIG.isTransientError` deliberately excludes pool-shutdown, draining, Cloudflare and task-timeout errors — exactly what a recovering or blocked pool returns — so the probe most likely to run was also the one most likely to pin it. A non-counted probe failure is now treated as *inconclusive* and re-opens with a fresh reset window.
19
+ - **`npm install <dir>`, `file:`, `npm link` and `pi install <dir>` aborted during install.** The published package ships the built `skills/pi-research/scripts/run.mjs` and deliberately excludes its `run.ts` source, yet also ships `prepare.cjs` + `build.cjs` — and `prepare` runs `build.cjs all`, whose `skill` target hard-requires `run.ts` as its esbuild entry point. Running the shipped build against the shipped tree failed with `Could not resolve run.ts` and exit 1. Registry `.tgz` and `npx` installs were unaffected only because `prepare` does not run there. The target now no-ops when `run.ts` is absent and `run.mjs` is already built, and still errors when neither exists.
20
+ - **A pool-reset error told callers to retry, but nothing recognised it.** `worker-pool-manager` throws `Worker pool is being reset, please retry` while auto-recovery swaps the pool, and no error classifier matched that string — so the first attempt rethrew instead of retrying. Auto-recovery fires only after 3 consecutive worker failures, i.e. exactly when a burst of tasks is in flight and would all fail together: the same user-visible shape as the leader-handover loss fixed in 1.1.0, entering through a different door. Now classified as a transient pool-unavailable condition.
21
+ - **Cancelling a run at the wrong moment could kill the process.** `withTimeout`'s early-abort path returned before attaching to the caller-supplied promise, orphaning that promise's eventual rejection. Node 22+ defaults to `--unhandled-rejections=throw`, and the main process registers no `unhandledRejection` handler, so an Esc timed between two awaits (reachable via the YouTube transcript client, which threads the same signal into both) crashed the host instead of cancelling cleanly. The in-flight rejection is now adopted before bailing out.
22
+
23
+ ### Changed
24
+ - **Model-resolution errors now lead with the credential path instead of pointing at `models.json`.** For a provider pi ships built in (openrouter, anthropic, openai, …), the fix for an unresolved model is a credential — after which pi fetches that provider's catalog, with real pricing and context limits, on its own. Both error messages instead named `~/.pi/agent/models.json` as the remedy, which is wrong for exactly that case and demonstrably led users to hand-write `models[]` entries. Because such an entry **replaces** pi's catalog entry wholesale rather than merging into it (`modelOverrides` is the merging mechanism), doing so silently zeroed out the model's pricing and capped its context and output windows. The messages now name `/login`, `auth.json` and the provider env var first, scope `models.json` to genuinely custom or self-hosted providers, and warn about the replace-vs-merge distinction.
25
+ - **A cost of exactly `$0.00` is now shown rather than omitted.** Cost is computed locally as tokens × the model's price table, never read off the wire, so a model whose table is all zeros bills tokens and reports zero indefinitely. Every display site gated on `cost > 0`, which made a misconfigured price table indistinguishable from "nothing reported yet" — and on the CLI progress line a falsy cost suppressed the **token count** as well, so the run appeared to consume nothing. Zero is also the honest figure for flat-rate plans and local models. `recordLlmUsage` additionally warns once per model when tokens are billed against an all-zero table, naming the usual cause.
26
+ - **Session Metrics now actually reports cost.** The view has always advertised "API cost estimates", but `SessionStats` carried no cost field to show; it now aggregates and displays one.
27
+
28
+ ### Documentation
29
+ - **`README.md` claimed the research agent has "no filesystem or shell access" and is "safe to point at anything".** The shell half is accurate; the filesystem half was not — `read` and `grep` are registered unconditionally and the researcher exclusion list (`bash`, `write`, `edit`, `repl`, `git`, `terminal`) never excluded them. `ARCHITECTURE.md` documented this correctly, so the README was the outlier — and it is the sentence a user relies on when deciding whether to run this against a sensitive directory. Corrected to what the code actually enforces: no shell, no writes, but reads and searches within the working directory.
30
+ - **The `knowledge-config` example was an unquoted shell pipe.** `... knowledge-config set none|project|global` sat inside a block `SKILL.md` explicitly instructs the agent to run on the user's behalf; pasted literally, bash executes `set none` and pipes into two "command not found" lines — so a user asking to *enable* the store would have it silently *disabled*. Now a `<none|project|global>` placeholder, fixed in all four copies.
31
+ - **Exit-code documentation gaps.** `75` (at capacity) — 1.1.0's headline exit code — was missing from the CLI's own `--help`. And `78` from `knowledge` covers two very different conditions (store deliberately disabled vs. missing credentials) while `SKILL.md` documented only the first, telling agents to stay silent and continue on what is actually a hard configuration failure. Both surfaces now distinguish them.
32
+ - **`docs/SDK.md` recommended a loader recipe that cannot work.** It suggested `node --experimental-transform-types your-script.ts` for consuming the TypeScript entrypoint; Node refuses to strip *or* transform TypeScript under `node_modules`, failing with `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`. A loader (`tsx`/`ts-node`) or the pi host is mandatory, and the limitation is now stated.
33
+
8
34
  ## [1.1.0] - 2026-08-04
9
35
 
10
36
  ### Changed
@@ -218,7 +218,7 @@ set the knowledge-store mode per directory directly:
218
218
 
219
219
  ```sh
220
220
  pi-research knowledge-config # show the mode here and where it comes from
221
- pi-research knowledge-config set none|project|global
221
+ pi-research knowledge-config set <none|project|global> # pick ONE value
222
222
  ```
223
223
 
224
224
  Under the agent skill you don't have to run this yourself — you ask the agent (e.g. "disable the
@@ -44,7 +44,7 @@ a project-scoped setting you can change per directory:
44
44
  | `none` | The store is disabled — nothing is read or written, the `research_knowledge_search` tool is not advertised to the agent, and `/knowledge-store` is unavailable. Re-enabling needs no restart (see [PI-EXTENSION.md](PI-EXTENSION.md) for the registration mechanics). |
45
45
 
46
46
  Change the mode for the current directory with the `/research-config` TUI (Knowledge Mode)
47
- in the pi extension, or with `pi-research knowledge-config set none|project|global` on the
47
+ in the pi extension, or with `pi-research knowledge-config set <none|project|global>` (pick one value) on the
48
48
  standalone CLI. The setting persists to the per-directory project registry — see
49
49
  [CONFIGURATION.md](CONFIGURATION.md) for the full precedence chain. The change applies on
50
50
  the next run — no restart.
package/docs/SDK.md CHANGED
@@ -33,13 +33,16 @@ global file entirely and run purely from defaults + `process.env` + `options.con
33
33
  > `enum` and constructor parameter properties, which Node's strip-only mode
34
34
  > (`--experimental-strip-types`, the default since Node 23.6) rejects with
35
35
  > `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`. Use one of:
36
- > - the pi host, which loads it natively;
37
- > - **`node --experimental-transform-types your-script.ts`** — must be passed
38
- > explicitly; full transform is not the default on any current Node release;
36
+ > - the pi host, which loads it natively (via `jiti`);
39
37
  > - a loader such as `tsx` or `ts-node`.
40
38
  >
41
- > Plain `node script.js` doing `require('@lincoln504/pi-research/sdk')`, or
42
- > `--experimental-strip-types`, will not work. (`engines.node` is `>=22.19.0`.)
39
+ > **Bare Node cannot load it at all, with any flag.** Node refuses to strip or
40
+ > transform TypeScript that lives under `node_modules` — as an installed
41
+ > dependency's source does — failing with
42
+ > `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`. That applies to
43
+ > `--experimental-transform-types` exactly as it does to
44
+ > `--experimental-strip-types`, so neither flag helps here; a loader (or the pi
45
+ > host) is mandatory. (`engines.node` is `>=22.19.0`.)
43
46
 
44
47
  ```typescript
45
48
  import {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincoln504/pi-research",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "A set of web research tools (search, scrape, and others) and research agent system that uses them safely and efficiently.",
5
5
  "type": "module",
6
6
  "private": false,
package/scripts/build.cjs CHANGED
@@ -106,13 +106,31 @@ const TARGETS = {
106
106
  copyWebGpuProbe();
107
107
  copyPrompts();
108
108
  },
109
- skill: () =>
110
- esbuild.build({
109
+ skill: () => {
110
+ const entry = p('skills', 'pi-research', 'scripts', 'run.ts');
111
+ const outfile = p('skills', 'pi-research', 'scripts', 'run.mjs');
112
+ // The published package ships the BUILT run.mjs and deliberately excludes its
113
+ // run.ts source (package.json "files": "!skills/pi-research/scripts/run.ts"),
114
+ // yet it also ships prepare.cjs + build.cjs — and `prepare` runs `build.cjs all`.
115
+ // On any install that executes prepare against the shipped tree (npm install
116
+ // <dir>, file:, npm link, pi install <dir>) this target had no entry point and
117
+ // aborted the whole install with "Could not resolve run.ts". Registry .tgz and
118
+ // npx installs were unaffected only because prepare does not run there.
119
+ // Nothing to do when the artifact is already present and its source is not.
120
+ if (!fs.existsSync(entry)) {
121
+ if (fs.existsSync(outfile)) {
122
+ console.log('[build] skill: run.ts absent and run.mjs already built (published tree) — skipping');
123
+ return Promise.resolve();
124
+ }
125
+ throw new Error(`[build] skill: neither ${entry} nor a prebuilt ${outfile} exists`);
126
+ }
127
+ return esbuild.build({
111
128
  ...COMMON,
112
- entryPoints: [p('skills', 'pi-research', 'scripts', 'run.ts')],
113
- outfile: p('skills', 'pi-research', 'scripts', 'run.mjs'),
129
+ entryPoints: [entry],
130
+ outfile,
114
131
  banner: { js: SHEBANG },
115
- }),
132
+ });
133
+ },
116
134
  };
117
135
 
118
136
  // The cli target's copyThreadWorker step depends on the worker output existing,
@@ -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.1.0", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
5
+ metadata: { "author": "Lincoln Deen", "version": "1.1.2", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
6
6
  allowed-tools: Bash(node:*)
7
7
  ---
8
8
 
@@ -63,7 +63,7 @@ node "<SKILL_DIR>/scripts/run.mjs" status [--json]
63
63
  | Exit | Meaning | Action |
64
64
  |------|---------|--------|
65
65
  | `0` | Success | Read stdout; cite sources; relay any "saved to: <path>" line. |
66
- | `78` on `knowledge` | Store disabled/unconfigured | Silent; proceed to `research`. |
66
+ | `78` on `knowledge` | Two cases — **read stderr to tell them apart.** | Says the store is **disabled**: silent; proceed to `research`. Mentions **credentials/model/API key**: this is the same hard config failure as `78` on `research` — **stop and relay full stderr**; don't silently continue, `research` will fail the same way. |
67
67
  | `78` on `research` | Engine missing or no model/key | **Stop. Relay full stderr** (it shows where to configure). Don't retry until fixed. |
68
68
  | `75` | Machine at capacity — other research runs are already using every slot | **Nothing is broken.** Wait for an in-flight run to finish, then retry this query once. Don't reconfigure anything and don't retry in a tight loop. |
69
69
  | `70` | Runtime error | Relay stderr; suggest one retry for transient failures (network, rate limit). |
@@ -81,7 +81,7 @@ the new setting. Check the current one first if useful:
81
81
 
82
82
  ```
83
83
  node "<SKILL_DIR>/scripts/run.mjs" knowledge-config # current mode + where it's set
84
- node "<SKILL_DIR>/scripts/run.mjs" knowledge-config set none|project|global
84
+ node "<SKILL_DIR>/scripts/run.mjs" knowledge-config set <none|project|global> # pick ONE value
85
85
  ```
86
86
 
87
87
  The change applies to the current directory on the next run. Never change it unprompted.
@@ -52,7 +52,7 @@ shared store — the default), `project` (scoped to the current directory), or `
52
52
 
53
53
  ```sh
54
54
  node "<SKILL_DIR>/scripts/run.mjs" knowledge-config # current mode + its source
55
- node "<SKILL_DIR>/scripts/run.mjs" knowledge-config set none|project|global
55
+ node "<SKILL_DIR>/scripts/run.mjs" knowledge-config set <none|project|global> # pick ONE value
56
56
  ```
57
57
 
58
58
  `set` persists to the per-directory project registry (`~/.pi/research/state/
package/src/cli.ts CHANGED
@@ -137,9 +137,19 @@ function makeProgressObserver(): HeadlessObserverOptions {
137
137
  case 'evaluation_decision':
138
138
  toStderr(` • evaluator: ${data?.action}\n`);
139
139
  break;
140
- case 'tokens_consumed':
141
- if (data?.cost) toStderr(` • tokens: ${data?.tokens ?? 0} ($${Number(data.cost).toFixed(4)})\n`);
140
+ case 'tokens_consumed': {
141
+ // A truthiness check on cost used to gate this whole line, so a model with
142
+ // an all-zero price table suppressed the TOKEN count as well — the run
143
+ // looked like it consumed nothing. Tokens stand on their own; cost is
144
+ // appended whenever it is a number, including 0.
145
+ const consumed = Number(data?.tokens ?? 0);
146
+ const costVal = typeof data?.cost === 'number' ? data.cost : undefined;
147
+ if (consumed > 0 || costVal !== undefined) {
148
+ const costPart = costVal === undefined ? '' : ` ($${costVal.toFixed(4)})`;
149
+ toStderr(` • tokens: ${consumed}${costPart}\n`);
150
+ }
142
151
  break;
152
+ }
143
153
  case 'complete':
144
154
  toStderr(` • complete (${data?.result?.length ?? 0} chars)\n`);
145
155
  break;
@@ -1016,7 +1026,9 @@ CONFIGURE
1016
1026
  EXIT CODES
1017
1027
  0 success
1018
1028
  64 bad arguments
1029
+ 75 at capacity — every concurrent run slot is busy; nothing is broken, retry later
1019
1030
  78 not configured — message prints the exact locations to fix
1031
+ (on \`knowledge\`, also returned when the store is deliberately disabled)
1020
1032
  70 runtime error (network, provider, internal)
1021
1033
  `;
1022
1034
  }
@@ -219,6 +219,13 @@ export function constructMinimalModel(provider: string, modelId: string, _apiKey
219
219
  baseUrl: '', // Provider-specific base URLs are handled by pi-ai internal registry
220
220
  reasoning: false,
221
221
  input: ['text'],
222
+ // No catalog is available on this path, so there is no price data and no real
223
+ // context/output ceiling to read. The zeros below are "unknown", NOT "free":
224
+ // cost reporting for this model will read $0.00 (see warnIfUnpriced in
225
+ // utils/llm-usage.ts), and the window figures are conservative guesses, not the
226
+ // model's true limits — so docs/CONFIGURATION.md's "clamped to the model's real
227
+ // ceiling" guarantee does not hold here. Anyone relying on accurate cost or the
228
+ // full context window should give pi a credential so it can fetch the real catalog.
222
229
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
223
230
  contextWindow: 128_000,
224
231
  maxTokens: 32_768,
@@ -253,7 +260,7 @@ export function resolveModel(registry: ModelRegistry, modelSpec?: string, provid
253
260
  return constructMinimalModel(prov, modelId, apiKey);
254
261
  }
255
262
 
256
- throw new Error(`Model "${modelSpec}" not found in pi's configured model registry. Check ${path.join(getAgentDir(), 'models.json')}.`);
263
+ throw new Error(buildModelNotFoundMessage(modelSpec));
257
264
  }
258
265
 
259
266
  // Validate format: must contain a slash OR be found as a bare model ID.
@@ -306,11 +313,41 @@ export function resolveModel(registry: ModelRegistry, modelSpec?: string, provid
306
313
  return constructMinimalModel(provider, modelSpec, apiKey);
307
314
  }
308
315
 
309
- throw new Error(
310
- `No LLM model available. Please configure your model registry (${path.join(getAgentDir(), 'models.json')}) or provide an explicit apiKey.`,
316
+ throw new Error(buildNoModelAvailableMessage());
317
+ }
318
+
319
+ /**
320
+ * Credential-first guidance for model-resolution failures.
321
+ *
322
+ * These messages used to say only "Check ~/.pi/agent/models.json", which is the
323
+ * WRONG remedy for a provider pi ships built in: the fix there is a credential,
324
+ * after which pi fetches the provider's catalog (with real pricing and context
325
+ * limits) on its own. Following the old advice, users hand-wrote `models[]`
326
+ * entries — and because such an entry REPLACES pi's catalog entry wholesale
327
+ * rather than merging into it, that silently zeroed out pricing and capped
328
+ * context/output windows. Lead with the credential path; scope models.json to
329
+ * genuinely custom providers; name `modelOverrides` for single-field tweaks.
330
+ */
331
+ function credentialGuidance(): string {
332
+ const agentDir = getAgentDir();
333
+ return (
334
+ ` • Provider pi supports built-in (openrouter, anthropic, openai, …): add a credential — run \`pi\` and use /login, ` +
335
+ `set the provider's API-key environment variable, or add it to ${path.join(agentDir, 'auth.json')}. ` +
336
+ `pi then fetches that provider's model catalog automatically.\n` +
337
+ ` • Custom or self-hosted provider (Ollama, vLLM, a proxy): declare it in ${path.join(agentDir, 'models.json')}.\n` +
338
+ ` Note: a models.json entry REPLACES pi's catalog entry for the same model id, discarding its pricing and\n` +
339
+ ` context/output limits. To adjust a single field of a built-in model, use "modelOverrides" instead — it merges.`
311
340
  );
312
341
  }
313
342
 
343
+ export function buildModelNotFoundMessage(modelSpec: string): string {
344
+ return `Model "${modelSpec}" not found in pi's model registry.\n${credentialGuidance()}`;
345
+ }
346
+
347
+ export function buildNoModelAvailableMessage(): string {
348
+ return `No LLM model available.\n${credentialGuidance()}\n • Or pass an explicit apiKey.`;
349
+ }
350
+
314
351
  /**
315
352
  * Read the user's provider order from ~/.pi/agent/models.json.
316
353
  * Returns provider keys in document (insertion) order, or [] when the file is
@@ -9,7 +9,7 @@ import { type Model } from '@earendil-works/pi-ai';
9
9
  import { type ModelRegistry } from '@earendil-works/pi-coding-agent';
10
10
  import { getConfig } from '../../config.ts';
11
11
  import { logger } from '../../logger.ts';
12
- import { pickPreferredAvailable, readModelsJsonProviderOrder, safeGetAll, safeGetAvailable } from './model-registry-factory.ts';
12
+ import { buildNoModelAvailableMessage, pickPreferredAvailable, readModelsJsonProviderOrder, safeGetAll, safeGetAvailable } from './model-registry-factory.ts';
13
13
 
14
14
  /**
15
15
  * Resolve the research model with standardized priority:
@@ -87,5 +87,5 @@ export function resolveResearchModel(options: {
87
87
  return pickPreferredAvailable(anyModel, readModelsJsonProviderOrder()) as Model<any>;
88
88
  }
89
89
 
90
- throw new Error('No LLM model available for research. Please configure your model registry (~/.pi/agent/models.json).');
90
+ throw new Error(buildNoModelAvailableMessage());
91
91
  }
@@ -109,7 +109,13 @@ 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')
113
119
  );
114
120
  }
115
121
 
@@ -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({
@@ -10,7 +10,7 @@
10
10
  */
11
11
 
12
12
  import { parseCitations } from '../utils/text-utils.ts';
13
- import { normalizeCitations, formatCitedLinks, type GlobalCitation } from '../utils/citation-utils.ts';
13
+ import { normalizeCitations, formatCitedLinks, rewriteCitationMarkers, type GlobalCitation } from '../utils/citation-utils.ts';
14
14
  import { lastCitedLinksHeaderIndex } from '../utils/text-utils.ts';
15
15
  import { getScrapedLinks, isTranscribedLink, normalizeUrl } from '../utils/shared-links.ts';
16
16
  import { stripTrailingLlmPunctuation } from '../utils/url-utils.ts';
@@ -33,86 +33,6 @@ const NO_SOURCES_NOTE =
33
33
  const UNVERIFIED_LINKS_NOTE =
34
34
  '_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 — check each one before relying on it._';
35
35
 
36
- /**
37
- * Largest `[N]` treated as a citation marker. Inline markers index a source list
38
- * that is realistically well under this; the bound exists so an ordinary bracketed
39
- * number in prose — a year (`[2023]`), an issue id, an array index — is left alone
40
- * instead of being silently deleted as a "dangling citation".
41
- */
42
- const MAX_PLAUSIBLE_CITATION_MARKER = 200;
43
-
44
- /**
45
- * Rewrite inline `[N]` citation markers in a prose body: renumber the ones we can
46
- * map, delete the ones known to dangle, leave everything else untouched.
47
- *
48
- * Deletion is whitespace-aware rather than followed by a global space collapse.
49
- * A blanket `text.replace(/ +/g, ' ')` also eats *leading* indentation, which
50
- * silently flattens nested list levels into one and turns 4-space indented code
51
- * blocks into paragraphs — corrupting the structure of every report that contains
52
- * them. So the pad preceding a deleted marker is consumed only when the marker is
53
- * mid-line (i.e. real prose spacing); line-leading indentation is preserved.
54
- *
55
- * Code is skipped entirely. A report may quote code containing bracketed integers
56
- * (`items[12]`), and on the no-CITED-LINKS path *every* out-of-range marker is treated
57
- * as dangling — so without this guard a quoted array index would be silently deleted
58
- * from the user's code sample. Fenced blocks are tracked across lines; single-backtick
59
- * spans are detected by an odd backtick count earlier on the same line.
60
- *
61
- * @param remap local marker number → verified global id
62
- * @param isDangling whether a marker not present in `remap` should be deleted
63
- */
64
- function rewriteCitationMarkers(
65
- text: string,
66
- remap: Map<number, number>,
67
- isDangling: (n: number) => boolean,
68
- ): string {
69
- const inFence = fencedCodeMask(text);
70
- return text.replace(/([ \t]*)\[(\d+)\]/g, (match, pad: string, digits: string, offset: number) => {
71
- const n = parseInt(digits, 10);
72
- const mapped = remap.get(n);
73
- if (mapped !== undefined) return `${pad}[${mapped}]`;
74
- // Citation numbering is 1-based; `[0]` is always an index or a literal, never a marker.
75
- if (n < 1 || n > MAX_PLAUSIBLE_CITATION_MARKER || !isDangling(n)) return match;
76
- if (inFence(offset) || inInlineCodeSpan(text, offset)) return match;
77
- // Preserve indentation when nothing but whitespace precedes on this line;
78
- // otherwise drop the pad too so no double space is left behind.
79
- const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
80
- return text.slice(lineStart, offset).trim() === '' ? pad : '';
81
- });
82
- }
83
-
84
- /**
85
- * Build an `offset → is inside a ``` fenced block` predicate for `text`.
86
- *
87
- * Computed once per rewrite (a single line scan) rather than per marker, so marker
88
- * rewriting stays linear in the body length.
89
- */
90
- function fencedCodeMask(text: string): (offset: number) => boolean {
91
- const ranges: Array<[number, number]> = [];
92
- let open = -1;
93
- let pos = 0;
94
- for (const line of text.split('\n')) {
95
- if (/^\s{0,3}(```|~~~)/.test(line)) {
96
- if (open < 0) open = pos;
97
- else {
98
- ranges.push([open, pos + line.length]);
99
- open = -1;
100
- }
101
- }
102
- pos += line.length + 1; // +1 for the '\n' consumed by split
103
- }
104
- // An unterminated fence runs to the end of the body — treat the remainder as code.
105
- if (open >= 0) ranges.push([open, text.length]);
106
- return (offset: number) => ranges.some(([start, end]) => offset >= start && offset < end);
107
- }
108
-
109
- /** Whether `offset` sits inside a single-backtick code span on its own line. */
110
- function inInlineCodeSpan(text: string, offset: number): boolean {
111
- const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
112
- let ticks = 0;
113
- for (let i = lineStart; i < offset; i++) if (text[i] === '`') ticks++;
114
- return ticks % 2 === 1;
115
- }
116
36
 
117
37
  /**
118
38
  * Redact inline `https?://` tokens in synthesis prose whose URL was NOT retrieved
@@ -481,7 +481,10 @@ export async function runBackgroundExtraction(
481
481
  const delay = KNOWLEDGE_SYNTHESIS_RETRY_BASE_MS * attempt;
482
482
  metrics.increment('research_knowledge_search_synthesis_retries_total', 1);
483
483
  logger.warn(`[research-knowledge-search] Synthesis attempt ${attempt}/${KNOWLEDGE_SYNTHESIS_MAX_ATTEMPTS} failed transiently (${msg}); retrying in ${delay}ms`);
484
- await abortableDelay(delay, signal);
484
+ // keepAlive=true: the retry is already committed and the next attempt must
485
+ // run, so this sleep is foreground work. An unref'd timer here can be the
486
+ // only pending handle and let the process exit mid-retry (see abortableDelay).
487
+ await abortableDelay(delay, signal, true);
485
488
  }
486
489
  }
487
490
 
@@ -625,7 +628,10 @@ export async function triageRelevantUrls(
625
628
  const delay = KNOWLEDGE_SYNTHESIS_RETRY_BASE_MS * attempt;
626
629
  metrics.increment('research_knowledge_search_triage_retries_total', 1);
627
630
  logger.warn(`[research-knowledge-search] Triage attempt ${attempt}/${KNOWLEDGE_SYNTHESIS_MAX_ATTEMPTS} failed transiently (${msg}); retrying in ${delay}ms`);
628
- await abortableDelay(delay, signal);
631
+ // keepAlive=true: the retry is already committed and the next attempt must
632
+ // run, so this sleep is foreground work. An unref'd timer here can be the
633
+ // only pending handle and let the process exit mid-retry (see abortableDelay).
634
+ await abortableDelay(delay, signal, true);
629
635
  }
630
636
  }
631
637
 
@@ -78,6 +78,26 @@ export class CircuitBreaker {
78
78
  metrics.observe('circuit_breaker_call_duration_ms', duration, { breaker: this.options.name, status: 'error' });
79
79
  if (this.options.isTransientError(error)) {
80
80
  this.onFailure(error);
81
+ } else if (admittedAsProbe && this.state === 'HALF_OPEN') {
82
+ // The trial probe failed for a reason this breaker does not count. That is
83
+ // INCONCLUSIVE, not healthy — so re-open and wait out another reset window
84
+ // rather than staying HALF_OPEN.
85
+ //
86
+ // Without this the breaker pins permanently: onFailure is the only path to
87
+ // OPEN, and it is skipped for non-counted errors, while transitionTo(HALF_OPEN)
88
+ // never re-arms nextAttemptTime. The admission gate above then fast-fails every
89
+ // caller beyond halfOpenMaxCalls (default 1) forever. This is not a corner case
90
+ // for the browser pool: DEFAULT_BREAKER_CONFIG.isTransientError deliberately
91
+ // excludes pool-shutdown, draining, Cloudflare and task-timeout errors — exactly
92
+ // what a recovering or blocked pool returns — so the probe most likely to run is
93
+ // also the one most likely to pin the breaker. Observed effect: 19 of a 20-query
94
+ // parallel burst fast-failing instantly, surfacing to the researcher as
95
+ // "your query may be too narrow".
96
+ logger.warn(
97
+ `[CircuitBreaker] '${this.options.name}' HALF_OPEN probe failed with a non-counted error; ` +
98
+ `treating as inconclusive and re-opening: ${error instanceof Error ? error.message : String(error)}`
99
+ );
100
+ this.transitionTo('OPEN');
81
101
  }
82
102
  throw error;
83
103
  } finally {
@@ -67,13 +67,28 @@ export function normalizeCitations(reports: Map<string, string>): {
67
67
  const headerIdx = lastCitedLinksHeaderIndex(report);
68
68
  let content = headerIdx >= 0 ? report.slice(0, headerIdx) : report;
69
69
 
70
- // Replace [N] with [GlobalID]
71
- // Also handle [N][M] combinations
72
- content = content.replace(/\[(\d+)\]/g, (match, p1) => {
73
- const localId = parseInt(p1, 10);
74
- const globalId = localToGlobal.get(localId);
75
- return globalId !== undefined ? `[${globalId}]` : match;
76
- });
70
+ // Renumber local [N] markers to their global ids, and DELETE the ones that could
71
+ // not be mapped.
72
+ //
73
+ // Leaving an unmapped marker in place (the previous `return match`) was a silent
74
+ // misattribution bug, not a harmless no-op: global ids are assigned densely from 1,
75
+ // so a surviving `[1]` no longer means "this report's first source" — it resolves
76
+ // against the renumbered global list and points at a completely different document.
77
+ // Markers go unmapped whenever parseCitations dropped the entry (e.g. a URL whose
78
+ // hostname has no dot: `localhost`, a bare internal host, a malformed line). The
79
+ // result is fed straight into the evaluator/synthesis prompt, which is explicitly
80
+ // told the numbering is already normalized — so the model faithfully reproduces the
81
+ // wrong attribution, and ensureCitedLinks then regenerates a CITED LINKS list that
82
+ // looks perfectly consistent with it.
83
+ //
84
+ // rewriteCitationMarkers is the same routine the synthesis path already uses: it
85
+ // skips fenced blocks and inline code spans (so a quoted `items[12]` survives),
86
+ // bounds what counts as a marker, and preserves line-leading indentation.
87
+ content = rewriteCitationMarkers(
88
+ content,
89
+ localToGlobal,
90
+ (n) => !localToGlobal.has(n),
91
+ );
77
92
 
78
93
  normalizedReports.set(id, content.trim());
79
94
  }
@@ -95,3 +110,84 @@ export function formatCitedLinks(citations: GlobalCitation[]): string {
95
110
 
96
111
  return `CITED LINKS\n${links.join('\n')}`;
97
112
  }
113
+
114
+ /**
115
+ * Largest `[N]` treated as a citation marker. Inline markers index a source list
116
+ * that is realistically well under this; the bound exists so an ordinary bracketed
117
+ * number in prose — a year (`[2023]`), an issue id, an array index — is left alone
118
+ * instead of being silently deleted as a "dangling citation".
119
+ */
120
+ export const MAX_PLAUSIBLE_CITATION_MARKER = 200;
121
+
122
+ /**
123
+ * Rewrite inline `[N]` citation markers in a prose body: renumber the ones we can
124
+ * map, delete the ones known to dangle, leave everything else untouched.
125
+ *
126
+ * Deletion is whitespace-aware rather than followed by a global space collapse.
127
+ * A blanket `text.replace(/ +/g, ' ')` also eats *leading* indentation, which
128
+ * silently flattens nested list levels into one and turns 4-space indented code
129
+ * blocks into paragraphs — corrupting the structure of every report that contains
130
+ * them. So the pad preceding a deleted marker is consumed only when the marker is
131
+ * mid-line (i.e. real prose spacing); line-leading indentation is preserved.
132
+ *
133
+ * Code is skipped entirely. A report may quote code containing bracketed integers
134
+ * (`items[12]`), and on the no-CITED-LINKS path *every* out-of-range marker is treated
135
+ * as dangling — so without this guard a quoted array index would be silently deleted
136
+ * from the user's code sample. Fenced blocks are tracked across lines; single-backtick
137
+ * spans are detected by an odd backtick count earlier on the same line.
138
+ *
139
+ * @param remap local marker number → verified global id
140
+ * @param isDangling whether a marker not present in `remap` should be deleted
141
+ */
142
+ export function rewriteCitationMarkers(
143
+ text: string,
144
+ remap: Map<number, number>,
145
+ isDangling: (n: number) => boolean,
146
+ ): string {
147
+ const inFence = fencedCodeMask(text);
148
+ return text.replace(/([ \t]*)\[(\d+)\]/g, (match, pad: string, digits: string, offset: number) => {
149
+ const n = parseInt(digits, 10);
150
+ const mapped = remap.get(n);
151
+ if (mapped !== undefined) return `${pad}[${mapped}]`;
152
+ // Citation numbering is 1-based; `[0]` is always an index or a literal, never a marker.
153
+ if (n < 1 || n > MAX_PLAUSIBLE_CITATION_MARKER || !isDangling(n)) return match;
154
+ if (inFence(offset) || inInlineCodeSpan(text, offset)) return match;
155
+ // Preserve indentation when nothing but whitespace precedes on this line;
156
+ // otherwise drop the pad too so no double space is left behind.
157
+ const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
158
+ return text.slice(lineStart, offset).trim() === '' ? pad : '';
159
+ });
160
+ }
161
+
162
+ /**
163
+ * Build an `offset → is inside a ``` fenced block` predicate for `text`.
164
+ *
165
+ * Computed once per rewrite (a single line scan) rather than per marker, so marker
166
+ * rewriting stays linear in the body length.
167
+ */
168
+ function fencedCodeMask(text: string): (offset: number) => boolean {
169
+ const ranges: Array<[number, number]> = [];
170
+ let open = -1;
171
+ let pos = 0;
172
+ for (const line of text.split('\n')) {
173
+ if (/^\s{0,3}(```|~~~)/.test(line)) {
174
+ if (open < 0) open = pos;
175
+ else {
176
+ ranges.push([open, pos + line.length]);
177
+ open = -1;
178
+ }
179
+ }
180
+ pos += line.length + 1; // +1 for the '\n' consumed by split
181
+ }
182
+ // An unterminated fence runs to the end of the body — treat the remainder as code.
183
+ if (open >= 0) ranges.push([open, text.length]);
184
+ return (offset: number) => ranges.some(([start, end]) => offset >= start && offset < end);
185
+ }
186
+
187
+ /** Whether `offset` sits inside a single-backtick code span on its own line. */
188
+ function inInlineCodeSpan(text: string, offset: number): boolean {
189
+ const lineStart = text.lastIndexOf('\n', offset - 1) + 1;
190
+ let ticks = 0;
191
+ for (let i = lineStart; i < offset; i++) if (text[i] === '`') ticks++;
192
+ return ticks % 2 === 1;
193
+ }
@@ -13,8 +13,44 @@
13
13
 
14
14
  import type { Model } from '@earendil-works/pi-ai';
15
15
  import { extractUsage } from '../types/llm.ts';
16
+ import { logger } from '../logger.ts';
16
17
  import { metrics } from './metrics.ts';
17
18
 
19
+ /** Models already warned about, so the notice fires at most once per model per process. */
20
+ const unpricedModelsWarned = new Set<string>();
21
+
22
+ /**
23
+ * Warn once when a model bills tokens but carries an all-zero price table.
24
+ *
25
+ * Cost is never read off the wire — pi computes it as tokens × the model's local
26
+ * price table — so an unpriced table yields $0.00 silently and indefinitely. That
27
+ * is legitimate for flat-rate plans and local servers, but it is also exactly what
28
+ * a misconfiguration looks like: a hand-written `models[]` entry in models.json
29
+ * REPLACES pi's catalog entry for the same id, discarding its pricing. This
30
+ * happened in practice and went unnoticed for weeks because every display site
31
+ * suppressed a zero cost. The message deliberately does not assert a fault — it
32
+ * states the condition and names the usual cause.
33
+ */
34
+ function warnIfUnpriced(model: Model<any>, tokens: number, cost: number): void {
35
+ if (tokens <= 0 || cost > 0) return;
36
+ const m = model as unknown as { provider?: string; id?: string; cost?: Record<string, number> };
37
+ const key = `${m.provider ?? 'unknown'}/${m.id ?? 'unknown'}`;
38
+ if (unpricedModelsWarned.has(key)) return;
39
+
40
+ const table = m.cost;
41
+ const allZero =
42
+ !table || Object.values(table).every((v) => typeof v !== 'number' || v === 0);
43
+ if (!allZero) return;
44
+
45
+ unpricedModelsWarned.add(key);
46
+ logger.warn(
47
+ `[LlmUsage] Model "${key}" consumed ${tokens} tokens but reports $0.00 — its price table is all zeros. ` +
48
+ `Expected for flat-rate plans and local servers. Otherwise the price data is missing: a hand-written ` +
49
+ `"models" entry in ~/.pi/agent/models.json replaces pi's catalog entry (and its pricing) for the same ` +
50
+ `model id — remove it and let pi's catalog supply the model, or use "modelOverrides", which merges.`
51
+ );
52
+ }
53
+
18
54
  /** Structural subset of the run observer — just the token sink. Kept structural (rather
19
55
  * than importing ResearchObserver from core/) so this foundation-layer helper does not
20
56
  * depend on an upper layer; the full ResearchObserver satisfies it. */
@@ -43,6 +79,7 @@ export function recordLlmUsage(
43
79
  ): { tokens: number; cost: number } {
44
80
  if (!rawUsage) return { tokens: 0, cost: 0 };
45
81
  const { tokens, cost } = extractUsage(model, rawUsage);
82
+ warnIfUnpriced(model, tokens, cost);
46
83
  if (tokens > 0 || cost > 0) {
47
84
  const labels: Record<string, string> = { component: opts.component };
48
85
  if (opts.complexity !== undefined) labels['complexity'] = String(opts.complexity);
@@ -283,8 +283,15 @@ export function buildResearchSummary(stats: ResearchStats): string {
283
283
  if (stats.tokens > 0) {
284
284
  resourceParts.push(`**${formatTokens(stats.tokens)}** tokens`);
285
285
  }
286
- if (stats.cost > 0) {
287
- const costStr = stats.cost < 0.01 ? '<$0.01' : `$${stats.cost.toFixed(2)}`;
286
+ // Render the cost whenever tokens were actually billed-through, INCLUDING when it
287
+ // is exactly zero. Gating on `cost > 0` hid a real defect for weeks: a model whose
288
+ // price table is all zeros (an unpriced or catalog-overwritten entry) produced no
289
+ // cost line at all, so a misconfiguration was indistinguishable from a run that
290
+ // simply had not reported yet. "$0.00" beside a large token count is conspicuous;
291
+ // silence is not. Zero is also the honest figure for flat-rate and local providers.
292
+ if (stats.tokens > 0 || stats.cost > 0) {
293
+ const costStr =
294
+ stats.cost === 0 ? '$0.00' : stats.cost < 0.01 ? '<$0.01' : `$${stats.cost.toFixed(2)}`;
288
295
  resourceParts.push(`**${costStr}**`);
289
296
  }
290
297
  if (stats.durationMs > 0) {
@@ -317,6 +324,9 @@ export interface SessionStats {
317
324
  totalUrlsDiscovered: number;
318
325
  totalSearchQueries: number;
319
326
  totalTokens: number;
327
+ /** Summed LLM cost across the session's runs. The Session Metrics UI advertises
328
+ * "API cost estimates"; without this field it had no cost to show. */
329
+ totalCost: number;
320
330
  totalToolUsage: {
321
331
  searches: number;
322
332
  scrapes: number;
@@ -339,6 +349,7 @@ export function aggregateSessionStats(
339
349
  let totalUrlsDiscovered = 0;
340
350
  let totalSearchQueries = 0;
341
351
  let totalTokens = 0;
352
+ let totalCost = 0;
342
353
  const totalToolUsage = {
343
354
  searches: 0,
344
355
  scrapes: 0,
@@ -359,6 +370,7 @@ export function aggregateSessionStats(
359
370
  totalUrlsDiscovered += stats.urlsDiscovered;
360
371
  totalSearchQueries += stats.searchQueries;
361
372
  totalTokens += stats.tokens;
373
+ totalCost += stats.cost;
362
374
  totalToolUsage.searches += stats.toolUsage.searches;
363
375
  totalToolUsage.scrapes += stats.toolUsage.scrapes;
364
376
  totalToolUsage.securitySearches += stats.toolUsage.securitySearches;
@@ -377,6 +389,7 @@ export function aggregateSessionStats(
377
389
  totalUrlsDiscovered,
378
390
  totalSearchQueries,
379
391
  totalTokens,
392
+ totalCost,
380
393
  totalToolUsage,
381
394
  };
382
395
  }
@@ -403,6 +416,16 @@ export function buildSessionOverview(stats: SessionStats): string {
403
416
  }
404
417
  if (stats.totalTokens > 0) {
405
418
  overviewParts.push(`**${formatTokens(stats.totalTokens)}** tokens`);
419
+ // Shown alongside tokens even at exactly $0.00 — the Session Metrics view
420
+ // advertises cost estimates, and a silent omission is what let an unpriced
421
+ // model table go unnoticed. Zero is correct for flat-rate and local providers.
422
+ const costStr =
423
+ stats.totalCost === 0
424
+ ? '$0.00'
425
+ : stats.totalCost < 0.01
426
+ ? '<$0.01'
427
+ : `$${stats.totalCost.toFixed(2)}`;
428
+ overviewParts.push(`**${costStr}**`);
406
429
  }
407
430
  if (stats.totalDurationMs > 0) {
408
431
  overviewParts.push(`**${formatDuration(stats.totalDurationMs)}** total research time`);
@@ -207,8 +207,15 @@ export async function exportResearchReport(
207
207
  */
208
208
  export function appendExportMessage(result: string, filepath: string, totalCost?: number): string {
209
209
  let footer = `\n\n---\n\nResearch report saved to: ${filepath}`;
210
- if (totalCost !== undefined && totalCost > 0) {
211
- const costStr = totalCost < 0.01 ? '<$0.01' : `$${totalCost.toFixed(4)}`.replace(/0+$/, '').replace(/\.$/, '');
210
+ // Report a defined cost even when it is exactly zero — suppressing it made an
211
+ // unpriced/misconfigured model table look identical to "no cost reported yet".
212
+ if (totalCost !== undefined) {
213
+ const costStr =
214
+ totalCost === 0
215
+ ? '$0.00'
216
+ : totalCost < 0.01
217
+ ? '<$0.01'
218
+ : `$${totalCost.toFixed(4)}`.replace(/0+$/, '').replace(/\.$/, '');
212
219
  footer += `\nTotal cost: ${costStr}`;
213
220
  }
214
221
  return result + footer;
@@ -141,6 +141,11 @@ export function withTimeout<T>(
141
141
  // aborted before we start means an external (user) cancellation, not a timeout —
142
142
  // a clean stop, so log at debug, not error.
143
143
  if (combinedSignal.aborted) {
144
+ // `promise` is a parameter, so the caller already started it. Returning here
145
+ // skips the promise.then(...) attachment below, leaving its eventual rejection
146
+ // with no handler — and Node 22 defaults to --unhandled-rejections=throw, so
147
+ // that kills the process. Adopt the rejection before bailing out.
148
+ void promise.catch(() => undefined);
144
149
  logger.debug(`[withTimeout] ${label} already aborted at start (external cancellation)`);
145
150
  return reject(new Error(`${label} cancelled or timed out`));
146
151
  }
@@ -319,7 +324,14 @@ export async function retryWithBackoff<T>(
319
324
  // the full backoff. If the wait is aborted, surface the original transient
320
325
  // error (not the synthetic AbortError) so callers see the real cause.
321
326
  try {
322
- await abortableDelay(delay, opts.signal);
327
+ // keepAlive=true: this sleep IS the operation. The retry has already been
328
+ // decided and the next attempt must run, so the timer must hold the event
329
+ // loop open. Without it, a transient network failure — which by its nature
330
+ // leaves no referenced socket handle — can make the backoff the sole pending
331
+ // handle, and Node drains the loop and exits mid-retry. The attempt never
332
+ // runs, and no error surfaces: in the CLI neither branch of the top-level
333
+ // handler fires, so the user gets a bare exit with no report.
334
+ await abortableDelay(delay, opts.signal, true);
323
335
  } catch {
324
336
  metrics.increment('retry_aborted_total', 1, { label: opts.label });
325
337
  throw lastError;