@lincoln504/pi-research 1.4.3 → 1.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/README.md +1 -1
  2. package/dist/cli.mjs +396 -125
  3. package/dist/prompts/research-knowledge-relevance-triage.md +2 -0
  4. package/dist/prompts/research-knowledge-search-extractor.md +2 -0
  5. package/dist/prompts/researcher.md +2 -14
  6. package/dist/thread-worker.mjs +17 -4
  7. package/docs/AGENT-SKILL.md +4 -3
  8. package/docs/CHANGELOG.md +131 -2
  9. package/docs/CONFIGURATION.md +2 -2
  10. package/docs/SDK.md +1 -1
  11. package/package.json +3 -2
  12. package/skills/pi-research/SKILL.md +1 -1
  13. package/src/cli.ts +44 -3
  14. package/src/config.ts +31 -9
  15. package/src/constants.ts +23 -4
  16. package/src/core/interfaces/state-manager-interfaces.ts +8 -0
  17. package/src/core/llm/research-model-resolver.ts +38 -0
  18. package/src/core/planning-service.ts +39 -4
  19. package/src/index.ts +3 -1
  20. package/src/infrastructure/browser/thread-worker-browser.ts +10 -0
  21. package/src/infrastructure/browser/thread-worker-messaging.ts +20 -3
  22. package/src/infrastructure/browser/thread-worker.mjs +17 -4
  23. package/src/infrastructure/embedding/embedding-factory.ts +34 -0
  24. package/src/infrastructure/embedding/embedding-server.ts +34 -9
  25. package/src/infrastructure/state/state-manager.ts +15 -0
  26. package/src/knowledge/embedder.ts +36 -7
  27. package/src/knowledge/store-operations.ts +47 -38
  28. package/src/knowledge/store.ts +86 -8
  29. package/src/knowledge/writer-queue.ts +4 -2
  30. package/src/observers/research-observer-impl.ts +14 -6
  31. package/src/orchestration/deep-research-orchestrator.ts +15 -3
  32. package/src/orchestration/quick-research-orchestrator.ts +37 -11
  33. package/src/orchestration/research-orchestration-service.ts +21 -2
  34. package/src/orchestration/research-synthesis-service.ts +36 -0
  35. package/src/orchestration/researcher-executor.ts +46 -17
  36. package/src/orchestration/researcher.ts +2 -2
  37. package/src/orchestration/session-state.ts +36 -4
  38. package/src/prompts/research-knowledge-relevance-triage.md +2 -0
  39. package/src/prompts/research-knowledge-search-extractor.md +2 -0
  40. package/src/prompts/researcher.md +2 -14
  41. package/src/sdk.ts +65 -52
  42. package/src/stackexchange/index.ts +8 -0
  43. package/src/tools/health-tool-definition.ts +11 -1
  44. package/src/tools/index.ts +2 -1
  45. package/src/tools/research-knowledge-search.ts +58 -10
  46. package/src/tools/research-tool-definition.ts +27 -16
  47. package/src/tools/scrape.ts +8 -1
  48. package/src/tools/stackexchange.ts +14 -7
  49. package/src/utils/llm-usage.ts +4 -1
  50. package/src/utils/log-utils.ts +5 -2
  51. package/src/utils/metrics-summary.ts +21 -1
  52. package/src/utils/tool-usage-tracker.ts +6 -3
  53. package/src/web-research/scraper-utils.ts +6 -2
  54. package/src/web-research/web-scraper.ts +9 -0
  55. package/src/youtube/index.ts +11 -1
  56. package/src/youtube/potoken.ts +20 -2
@@ -18,6 +18,8 @@ Your ONLY job is to decide which candidates are genuinely relevant to the user's
18
18
  Field definitions:
19
19
  - `relevant_indices` (array of integers, required): the 0-based indices of the relevant candidates, in any order. Empty array `[]` if none are relevant.
20
20
 
21
+ <!-- USER_TURN: everything below is delivered as the USER message of the stateless call, not the system prompt. The rules above stay byte-identical across calls so the provider can serve the system prefix from its prompt cache; the sections below vary per call. -->
22
+
21
23
  ## USER'S SEARCH QUERY
22
24
 
23
25
  Judge each candidate's relevance to this search query. This is the PRIMARY signal for what the user wants; the conversation below is supplementary and may be absent for a direct (non-conversational) search:
@@ -24,6 +24,8 @@ Field definitions:
24
24
  - `synthesis` (string, optional): The synthesized answer if found. Use inline citation markers [1], [2], etc. that correspond to the citations array. Present when answer_status is "yes" or "maybe". Omit when answer_status is "no".
25
25
  - `citations` (array of strings, required): The source URLs from the reference documents that were used to construct the answer. Empty array if answer_status is "no".
26
26
 
27
+ <!-- USER_TURN: everything below is delivered as the USER message of the stateless call, not the system prompt. The rules above stay byte-identical across calls so the provider can serve the system prefix from its prompt cache; the sections below vary per call. -->
28
+
27
29
  ## USER'S SEARCH QUERY
28
30
 
29
31
  Answer this search query from the reference documents. This is the PRIMARY signal for what the user wants; the conversation below is supplementary and may be absent for a direct (non-conversational) search:
@@ -2,7 +2,7 @@
2
2
 
3
3
  <!-- RESEARCHER_AGENT_MARKER -->
4
4
 
5
- You are an autonomous research agent. Your goal is to investigate your assigned topic with depth and rigor.
5
+ You are an autonomous research agent. Your goal is to investigate your assigned topic with depth and rigor. Your specific goal, any historical knowledge-store results, your initial evidence/search results, and any sibling-coordination notes are provided in the user message that follows this system prompt — not here, so this system prompt stays byte-identical across every researcher and round (prompt caching).
6
6
 
7
7
  ## CORE DIRECTIVES (Strict Enforcement)
8
8
 
@@ -23,20 +23,10 @@ Do not treat any source as inherently objective. Justify its inclusion through t
23
23
 
24
24
  ---
25
25
 
26
- ## Your Goal
27
- {{goal}}
28
-
29
- {{store_section}}
30
- (Note: when knowledge-store results appear above, they were retrieved automatically for your goal, aggregated from the **Project Knowledge Store** and **User Knowledge Store**. Do NOT try to search the store manually.)
31
-
32
- {{evidence_section}}
33
-
34
- ---
35
-
36
26
  ## Workflow
37
27
 
38
28
  ### Step 1: Build Your Source List
39
- Combine historical URLs and search results into a unified pool. Use previous session summaries as a guide for what to expect.
29
+ Combine historical URLs and search results into a unified pool. Use previous session summaries as a guide for what to expect. Any knowledge-store results in your user message were retrieved automatically for your goal from the **Project Knowledge Store** and/or **User Knowledge Store** — cite them with that store name as their `Source:`.
40
30
 
41
31
  ### Step 2: Scrape Round 1
42
32
  Identify the 4 most promising URLs and scrape them in one batch (the scrape tool accepts up to 6 per call; 4 is your target per round). Prioritize primary sources, authoritative references, and dense documentation; all else being equal, lean toward more recent / current sources and topics.
@@ -51,8 +41,6 @@ Do NOT scrape URLs directly from the pool — only scrape URLs from your own sou
51
41
  ### Step 4: Synthesize
52
42
  Write your report immediately after scraping is complete, or as soon as a tool reports its limit is reached (e.g. "GATHERING LIMIT REACHED", "SEARCH LIMIT REACHED", "SCRAPE PROTOCOL COMPLETE"). Make no further tool calls after beginning synthesis.
53
43
 
54
- {{coordination_section}}
55
-
56
44
  ---
57
45
 
58
46
  ## Guidelines
@@ -1571,6 +1571,8 @@ function isBenignScrapeFailure(error) {
1571
1571
  msg.startsWith("Fetch returned stub:") || // nav-only / near-empty page
1572
1572
  msg.startsWith("Could not extract content from PDF") || // malformed/encrypted/scanned-only PDF
1573
1573
  msg.startsWith("PDF too large") || // exceeds the deliberate 100MB policy cap
1574
+ msg.startsWith("HTML response too large") || // fetch-layer 25MB policy cap — same class as the PDF cap
1575
+ msg.startsWith("Browser HTML too large") || // browser-layer 25MB policy cap
1574
1576
  /^HTTP \d{3}\b/.test(msg) || // remote 4xx/5xx status
1575
1577
  isCloudflareBlockError(error) || isTaskTimeoutError(error) || isPoolShutdownError(error);
1576
1578
  }
@@ -2190,7 +2192,7 @@ async function extractSearchResults(page) {
2190
2192
  try {
2191
2193
  const u = new URL(url);
2192
2194
  const uddg = u.searchParams.get("uddg");
2193
- if (uddg) url = decodeURIComponent(uddg || "");
2195
+ if (uddg) url = uddg;
2194
2196
  } catch {
2195
2197
  }
2196
2198
  let urlHostname = "";
@@ -2332,7 +2334,7 @@ async function executeScrapeTask(_context, url, signal) {
2332
2334
  if (resp.request().resourceType() === "document" && resp.frame() === page.mainFrame()) {
2333
2335
  finalMainFrameAddr = resp.serverAddr().catch(() => null);
2334
2336
  const check2 = Promise.resolve(resp.headerValue("content-type")).then(async (ct) => {
2335
- const looksLikePdf = ct?.includes("application/pdf") || url.toLowerCase().endsWith(".pdf");
2337
+ const looksLikePdf = ct?.includes("application/pdf") || String(resp.url()).toLowerCase().endsWith(".pdf");
2336
2338
  if (!looksLikePdf || oversizedPdfError || poisonedError) return;
2337
2339
  const declared = parseInt(await resp.headerValue("content-length").catch(() => null) || "", 10);
2338
2340
  if (Number.isFinite(declared) && declared > MAX_PDF_SIZE) {
@@ -2374,7 +2376,8 @@ async function executeScrapeTask(_context, url, signal) {
2374
2376
  }
2375
2377
  const contentType = await response?.headerValue("content-type") || "";
2376
2378
  const status = response?.status?.() ?? 0;
2377
- if (contentType.includes("application/pdf") || url.toLowerCase().endsWith(".pdf")) {
2379
+ const finalUrl = String(response?.url?.() ?? url);
2380
+ if (contentType.includes("application/pdf") || finalUrl.toLowerCase().endsWith(".pdf")) {
2378
2381
  if (!response) throw new Error(`[Worker] No response received for PDF URL: ${url}`);
2379
2382
  if (status >= 400) throw new Error(`HTTP ${status}`);
2380
2383
  const declaredLength = parseInt(await response.headerValue("content-length").catch(() => null) || "", 10);
@@ -2805,7 +2808,17 @@ async function initBrowser() {
2805
2808
  // silent either, so the mute is applied unconditionally.
2806
2809
  firefox_user_prefs: {
2807
2810
  "media.volume_scale": "0.0",
2808
- "media.autoplay.default": 5
2811
+ "media.autoplay.default": 5,
2812
+ // SSRF: WebSocket connections bypass BOTH layers of the browser
2813
+ // scrape defense — page.route('**') intercepts HTTP(S) only, and
2814
+ // the serverAddr() rebinding backstop hangs off page.on('response'),
2815
+ // which a WS handshake never fires. A hostile scraped page could
2816
+ // therefore `new WebSocket('ws://10.x.x.x/...')` to probe or reach
2817
+ // internal hosts with no validation, and write any reply into the
2818
+ // DOM that page.content() returns. The scraper extracts static
2819
+ // content (domcontentloaded), so nothing it needs arrives over WS —
2820
+ // disable the channel outright at the engine level.
2821
+ "network.websocket.max-connections": 0
2809
2822
  }
2810
2823
  });
2811
2824
  launchPromise.catch((err) => logToDebugFile3("DEBUG", `[ThreadWorker] Background browser launch rejection: ${err.message}`));
@@ -77,9 +77,10 @@ One-click (recommended). From the `pi` extension, run `/research-config` →
77
77
  Install in External Agents. The installer:
78
78
 
79
79
  1. Detects which target agents are present under `$HOME` — currently Claude
80
- (`~/.claude/skills`), OpenAI Codex CLI (`~/.codex/skills`), and OpenClaw
81
- (`~/.openclaw/skills`, OpenClaw's managed skill root, which accepts symlinked
82
- skill folders).
80
+ (`~/.claude/skills`), OpenClaw (`~/.openclaw/skills`, OpenClaw's managed
81
+ skill root, which accepts symlinked skill folders), and OpenAI Codex CLI
82
+ (`~/.codex/skills` — unlike the other two, this path is not confirmed by
83
+ Codex's official docs; Codex skills support is still emerging).
83
84
  2. Symlinks `skills/pi-research/` into each present agent, never overwriting an
84
85
  unrelated skill already in that slot.
85
86
  3. Records what it created in a manifest, so Remove from External Agents
package/docs/CHANGELOG.md CHANGED
@@ -5,7 +5,101 @@ 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
- ## [Unreleased]
8
+ ## [1.5.1] - 2026-08-22
9
+
10
+ Post-1.5.0 hardening pass driven by a full static-analysis battery (npm audit,
11
+ dependency-cruiser, madge, actionlint/shellcheck, license audit, CodeQL state,
12
+ ts-prune triage) plus verification of the published 1.5.0 artifact from the
13
+ registry. Followed by an eight-way whole-project audit (core/orchestration,
14
+ web-research/browser, knowledge/embeddings, security/logging, prompts/caching
15
+ invariance, CLI/SDK/scripts, CI/release/supply-chain, test quality) that
16
+ adversarially re-verified every 1.4.x/1.5.0 changelog claim against the code
17
+ and found one of them false — see the browser PDF dispatch fix below.
18
+
19
+ ### Security
20
+
21
+ - **WebSocket connections are now disabled in the scrape browser** (`network.websocket.max-connections: 0`). WS bypassed BOTH layers of the browser SSRF defense: `page.route('**')` intercepts HTTP(S) only, and the `serverAddr()` rebinding backstop hangs off `page.on('response')`, which a WS handshake never fires — so a hostile scraped page could open `ws://10.x.x.x/...` to probe or reach internal hosts unvalidated and write any reply into the DOM that `page.content()` returns. The scraper extracts static content; nothing it needs arrives over WS (`src/infrastructure/browser/thread-worker-browser.ts`).
22
+ - **Stack Exchange results now carry the same untrusted-data boundary banner as scrape/YouTube output.** Question/answer bodies, titles, and user names are authored by arbitrary internet users and were the one gathering-tool output injected into researcher context with no in-band "data, not instructions" reminder (`src/stackexchange/index.ts`).
23
+ - The one token-bearing `run:` block in CI that still interpolated `${{ github.ref_name }}` inline (the dep-graph push step, which holds `contents: write`) now uses env indirection like release.yml's publish job — branch names may contain shell metacharacters. Collaborator-gated in practice (push events only), so hygiene rather than an exposed hole (`.github/workflows/ci.yml`).
24
+
25
+ ### Changed
26
+
27
+ - **`sharp` overridden to `^0.35.0` in this repository's own tree** (GHSA-f88m-g3jw-g9cj, libvips CVEs). The prior allowlist entry argued an override was riskier than the unreachable advisory; that assessment is now superseded by direct evidence — the override installs cleanly, lancedb's native binding survives, and the full unit suite (2799 tests, including every embedding/knowledge-store suite) passes against the new tree. `npm audit --omit=dev` reports zero vulnerabilities here and the audit-gate allowlist is empty. Scope caveat: npm `overrides` apply only at the root project, so CONSUMERS installing this package still resolve sharp within `@huggingface/transformers`' own `^0.34.5` range and will see the advisory in their own audits until transformers bumps it upstream — for them the standing analysis holds unchanged (pi-research uses transformers exclusively for text embeddings; the affected sharp image pipeline is never invoked) (`package.json`, `config/tooling/audit-exceptions.json`).
28
+ - **Every GitHub Action in both workflows is now pinned by full commit SHA** (tag noted beside each pin), closing the long-standing TODO: the release workflow holds `id-token: write`, so a repointed mutable tag on a compromised action would otherwise run inside the trusted-publishing job (`.github/workflows/ci.yml`, `.github/workflows/release.yml`).
29
+ - Fixed the three remaining shellcheck notes actionlint reported in the workflows (unquoted `$GITHUB_ENV`/exit-status expansions, unanchored `ls *.tgz` glob); both workflows now pass actionlint (including its shellcheck integration) clean. (Raw `shellcheck -s bash` still reports SC2164 for unguarded `cd` in two steps — benign under Actions' `bash -e`, where a failed `cd` aborts the step.)
30
+
31
+ ### Fixed
32
+
33
+ - **The 1.5.0 claim "Both browser-layer checks now use the response's own post-redirect URL" was false: only the early-abort check was fixed.** The actual PDF-vs-HTML *dispatch* still classified against the pre-redirect task URL, leaving both failure directions open on the browser fallback path — a download endpoint redirecting to a real `.pdf` served as octet-stream fell into the HTML branch (Firefox's PDF-viewer chrome serialized as content, cacheable and citable), and a `.pdf` URL redirecting to an HTML page was fed to the PDF extractor, discarding good HTML. The dispatch now uses `response.url()` with a task-URL fallback, and a new regression suite pins the dispatch itself, which no existing test did (`src/infrastructure/browser/thread-worker-messaging.ts`, `test/unit/infrastructure/thread-worker-pdf-redirect-dispatch.test.ts`).
34
+ - **DuckDuckGo result URLs containing percent-escapes were corrupted by double-decoding.** `searchParams.get('uddg')` already decodes the redirect target once; the extra `decodeURIComponent` structurally broke targets whose canonical form contains escapes (`%26`/`%3F` in query values, `%2F` path slashes) so the later scrape fetched the wrong resource, and a lone `%` threw `URIError`, silently dropping the result via the hostname filter (`src/infrastructure/browser/thread-worker-messaging.ts`).
35
+ - **A steered quick run stored the steering-guidance block inside the final source's knowledge-store description** — the same trailing-text absorption mechanism the 1.5.0 grounding-notice placement was designed around, unhandled for steering: `appendSteeringGuidance` runs after `ensureCitedLinks`, and `parseCitations` folds everything after the last citation into that entry's description. Citations are now parsed from a pre-steering snapshot (`src/orchestration/quick-research-orchestrator.ts`).
36
+ - **An SDK init failure before the service phase permanently poisoned `initResearchSDK()`.** `validateConfig`, the missing-provider check, and `buildModelRegistry` all threw before the `try` whose `finally` clears `_initPromise`, so every subsequent init returned the same stale rejection (and partial globals were never torn down) until a shutdown ran — a corrected retry after a bad `options.config` was stuck on the original error. The whole body now shares one try/catch/finally (`src/sdk.ts`).
37
+ - **A rare router `wait` decision degraded the run's fallback machinery two ways:** the wait plan (schema-legal with `researchers: []`) was stored into `currentPlans`, clobbering the last real delegate agenda that the empty-after-cap and transient-failure fallbacks continue from; and every wait retry re-emitted `evaluation_start` and re-ran the infrastructure health probe for the same round, violating the once-per-round event contract `round_start` already enforces. Wait plans are no longer stored; evaluation announcement and health check are gated on first entry to the round (`src/core/planning-service.ts`, `src/orchestration/deep-research-orchestrator.ts`).
38
+ - **Researchers abandoned by a fast-stop no longer fire failure events after the run's terminal callback.** A researcher wedged past the bounded abort/settle waits settles after the run has ended; its late `recordResearcherFailure` re-created the just-freed session state via create-on-read (a process-lifetime leak on the SDK's per-run session ids) and `onResearcherFailure` broke the exactly-one-terminal-event contract. A `runTerminated` latch set at both fast-stop throw sites now suppresses the post-terminal recording (`src/orchestration/research-orchestration-service.ts`).
39
+ - **Re-embed migration hardened against transient embedder failures.** `migrationReEmbed`'s per-batch `embedMany` was the one embed call not wrapped in `withEmbedderReconnect` — yet a long migration is exactly when a leader step-down (ECONNREFUSED fast-fail) is most likely, and any throw escalates through the fallback ladder to the `backup` strategy, which replaces the fully-intact table with an empty one. The migration also wrote vectors with none of `addDocumentsToStore`'s count/width/finite integrity guards; both paths now share one extracted guard (`src/knowledge/store.ts`, `src/knowledge/store-operations.ts`).
40
+ - **A wedged embedding pipeline is no longer disposed at process exit.** The poison path's step-down deliberately skips `pipeline.dispose()` (it would join the permanently hung native thread), but the instance stayed registered for process-exit cleanup, whose task called `dispose()` unconditionally — re-creating at teardown the exact hang the step-down avoided, plus two futile 5s drain waits. The wedge is now latched on the Embedder and `dispose()` abandons the pipeline (`src/knowledge/embedder.ts`, `src/infrastructure/embedding/embedding-server.ts`).
41
+ - **Mixed-version state file (`stateTooNew`) silently voided cross-process exclusion.** When a newer build's state file exists, the older process suppresses all state writes to protect it — but then its GPU-lock acquires always saw "no owner" and its embedding leader-election claim was written into the void (invisible to every other process), so each read-only process self-elected and could initialize its own GPU/WebGPU context concurrently: exactly the native-segfault class both mechanisms exist to prevent. The embedding factory now detects read-only mode (`stateManager.isReadOnly()`, checked both before the candidacy claim and after it, since the flag is set lazily by the first read that encounters the newer file) and degrades to a coordination-free mode — no election, local in-process embedder forced onto CPU (a CPU device never touches the GPU lock; concurrent CPU inference across processes is safe). Slower embedding for the mixed-version window only; normal service resumes once every session runs the same build (`src/infrastructure/state/state-manager.ts`, `src/core/interfaces/state-manager-interfaces.ts`, `src/infrastructure/embedding/embedding-factory.ts`, `test/unit/infrastructure/embedding-factory-readonly-state.test.ts`).
42
+ - **`rebuildDocument` could serve superseded content when the old generation alone filled its 16-row window.** After a failed prune, a long description's chunks could occupy all 16 fragment-scan slots with old-generation rows, so newest-wins sorted stale rows only. The query now re-fetches with a 1024 bound when the cap is hit (`src/knowledge/store.ts`).
43
+ - **PoToken mint mutex: the wedge flag is now re-checked at dequeue time, and only a mint that actually started may clear it.** A caller already queued when the 5-minute hard release fired started its own mint while the abandoned one could still be mid-bridge — the double-bridge `globalThis` corruption the flag exists to prevent, guarded only for callers arriving after the release. The dequeue re-check alone was then defeated by its own victim: the rejected caller's settle handler unconditionally cleared the wedge microtasks after the hard release set it, reopening minting for the next caller while the abandoned bridge was still installed; the clear is now gated on the call having entered the mint (`src/youtube/potoken.ts`).
44
+ - **An unknown `stackexchange` command no longer burns a gathering-budget slot.** The free-text `command` passed schema validation, spent one of `MAX_GATHERING_CALLS`, then died in the dispatch switch with an error message blaming rate limiting; command names are now validated before the charge, matching the validate-before-charge contract the 1.4.2 entry established for the other tools (`src/tools/stackexchange.ts`).
45
+ - **The `health` tool was the one tool casting `params` with no schema gate** — a host delivering null made the destructure throw a raw TypeError out of `execute()` instead of a structured `invalid_parameters` result (`src/tools/health-tool-definition.ts`).
46
+ - **Knowledge-search prompt fills no longer double-substitute.** Sequential `String.replace` calls re-scan the accumulated string, so a literal `{{reference_documents}}`/`{{candidates}}` token typed inside a query would hijack the later replacement (dumping the documents into the query section and leaving the real placeholder verbatim); both fills now run one left-to-right `/g` pass over the template only (`src/tools/research-knowledge-search.ts`).
47
+ - **The grounding notice now says "cited", not "verified".** Since the provenance gate was deliberately removed, the count includes citations whose pages were never successfully retrieved — "could be verified" overstated what the pipeline checked. Log lines and the CLI stderr WARNING reworded to match (`src/orchestration/research-synthesis-service.ts`, `src/cli.ts`).
48
+ - A partial-synthesis reduce pass returning an action-less `{"content": "..."}` envelope reached the merge pass as raw JSON (braces reproduced in the final report) because `parseJsonPlan` rejects that shape; the unwrap now tries it directly before treating the text as prose (`src/core/planning-service.ts`).
49
+ - The initial-links retry backoff was the one sleep in `researcher-executor.ts` not abortable — a cancel landing during it waited out the full delay (`src/orchestration/researcher-executor.ts`).
50
+ - A user-cancelled run was recorded as `status: 'error'` in `research_manager_latency_ms`/`research_manager_requests_total` while the orchestrator layer below correctly labeled the same run `cancelled`; the catch now checks the signal (`src/orchestration/research-orchestration-service.ts`).
51
+ - `popped` steering messages accumulated unbounded in a long-lived pi session that always had an active run (the pruning ran only in `endResearchSession`'s last-run branch); they are now capped alongside the live-message cap (`src/orchestration/session-state.ts`).
52
+ - Oversized-page policy-cap failures (`HTML response too large`, `Browser HTML too large`) now classify as benign scrape failures like the PDF cap they mirror, instead of inflating the diagnostic ERROR count (`src/web-research/scraper-utils.ts`).
53
+ - The one-time registry→config.env migration list omitted `PI_RESEARCH_MAX_FAILED_RESEARCHERS` and the four `PI_RESEARCH_YOUTUBE_*` keys, silently dropping them for legacy-registry users (`src/config.ts`).
54
+ - `docs/CONFIGURATION.md` now carries the `DEBUG` timing caveat the 1.5.0 `isVerboseFromEnv` comment fix established: a `DEBUG=true` line in `config.env` is only synced into the environment on a config save, not on load (`docs/CONFIGURATION.md`).
55
+ - Test hygiene: every CLI subprocess suite now pins `PI_RESEARCH_LOG_PATH` under its sandbox HOME — scrubbing `PI_RESEARCH_*` had also scrubbed the log redirect, so every `npm test` appended dozens of spurious WARN lines to the user's real shared log (the same forensic-noise class the 1.5.0 in-process fix closed, left open for subprocesses). Also corrected that file's misleading "machine-wide semaphore" contention note (the slot dir lives under the pinned sandbox HOME) and replaced research-workflow's can't-fail `>= 0` assertion with a real one (`test/unit/cli.test.ts`, `test/integration/research-workflow.test.ts`).
56
+ - **`npm ci` broke on CI after the lockfile was regenerated for the sharp override.** `@kreuzberg/html-to-markdown-node@3.7.2` lists `linux-x64-musl`/`linux-arm64-musl` in its optionalDependencies, but those packages were never published to the registry (404) — the only lock entry that can exist for a phantom optional is a bare `{"optional": true}` stub, and npm's sync check requires it NESTED under the parent (hoisted stubs fail; verified empirically both ways). Every lock regeneration silently drops the stubs — second occurrence of this drift class (first: 2026-06-24). Restored the nested stubs, and added a unit test that reads `package-lock.json` and fails with a self-explanatory message the moment a future regeneration drops them again, so the constraint is enforced in-repo rather than remembered (`package-lock.json`, `test/unit/package.test.ts`).
57
+
58
+ ## [1.5.0] - 2026-08-20
59
+
60
+ Whole-project audit (release integrity, CI/test health, SSRF consistency,
61
+ docs-vs-code accuracy, and an adversarial re-check of every fix claimed in
62
+ the 1.4.1 changelog entry) that found several defects in code the 1.4.1
63
+ audit itself had just "fixed" — proof that a changelog claim needs
64
+ independent re-verification, not just a passing regression test for the
65
+ first failure mode it covers. Plus a prompt-caching workstream: a full verification of how
66
+ prompt caching actually behaves across every provider path pi supports,
67
+ restructuring of every LLM prompt in the codebase for cache-prefix
68
+ invariance, and a caching-aware scrape budget.
69
+
70
+ ### Added
71
+
72
+ - **Prompt-caching-aware scrape budget.** When prompt caching is known to be active for the run's resolved research model — the Anthropic API (caching is native and always on), or an `openai-completions` provider route explicitly configured with Anthropic-style `cacheControlFormat` — the effective per-researcher scrape-batch maximum is the configured `PI_RESEARCH_MAX_SCRAPE_BATCHES` plus one: the cached system-prompt/tool prefix makes the extra batch's context re-send cheap. The detection is deliberately conservative (`isPromptCachingActiveForModel`): providers whose caching is implicit/server-side (OpenAI Responses, Google, Mistral, Bedrock) report false, because a false positive would raise the budget without the discount that pays for it. One arithmetic source of truth (`getMaxScrapeBatches`) feeds tracker enforcement, the researcher prompt text, the scrape tool's own protocol text, and the TUI progress units, so they can never disagree; the increment is capped so a configured 99 (the documented max) never crosses into the >99 "unlimited" display band (`src/core/llm/research-model-resolver.ts`, `src/constants.ts`, `src/utils/tool-usage-tracker.ts`, `src/tools/scrape.ts`, `src/observers/research-observer-impl.ts`).
73
+ - **Prompt-cache metrics surfaced to users.** The per-run summary's token line now reads e.g. `**150,000** tokens (**90,000** from cache)` whenever the provider reported prompt-cache reads, and `ResearchStats` (SDK `getLastRunStats()`) carries `cacheReadTokens`/`cacheWriteTokens`. Previously the `llm_cache_read/write_tokens_total` counters existed but reached no user-facing surface, so there was no way to see whether caching was working (`src/utils/metrics-summary.ts`).
74
+ - **Weak-grounding notice on completed reports.** A run that completes but verifies fewer than 3 sources now inserts an explicit `Grounding notice` ahead of its CITED LINKS list telling the reader (human or calling agent) that unattributed claims are unverified. Driven by a real production run: a depth-1 report whose researchers retrieved almost nothing shipped as a fully sourced-looking document with one citation and fabricated most of its specifics, and the calling agent had to detect the degradation itself. The existing zero-source notes already covered the empty case; this covers the sourced-looking-but-barely-grounded one. The notice sits BEFORE the CITED LINKS header — anything after the last citation entry gets absorbed into that entry's description when quick mode re-parses the report for knowledge-store entries. The CLI additionally prints a stderr `WARNING:` line on completion when a report carries the weak-grounding or no-sources notice, so callers that read stderr (the agent skill does) can distinguish a degraded run from a healthy one without parsing the report (`src/orchestration/research-synthesis-service.ts`, `src/cli.ts`).
75
+
76
+ ### Changed
77
+
78
+ - **Every researcher's system prompt is now byte-identical across researchers, rounds, and retry attempts.** The per-researcher goal, knowledge-store results, initial evidence, sibling-coordination notes, and steering messages moved from the system prompt into the researcher's initial user message. A byte-identical system prompt + tool set is the cacheable prefix providers key on, so on caching providers every researcher after the first now gets prompt-cache reads on that prefix instead of a fresh cache write per researcher (previously each researcher's unique system prompt guaranteed zero cross-researcher reuse — with Anthropic's 25% cache-write premium, strictly worse than no caching). Quick mode got the same restructure, which also removed a long-standing redundancy where the query was sent twice (once templated into the system prompt, once as the user message). Regression tests pin the invariance directly (`src/prompts/researcher.md`, `src/orchestration/researcher-executor.ts`, `src/orchestration/quick-research-orchestrator.ts`).
79
+ - **The knowledge-search extractor and relevance-triage prompts split static rules from per-call data.** Both stateless background calls put the queries, conversation history, and reference documents/candidates inside the system prompt, so on caching providers every call was a distinct cache entry — pure cache-write premium with zero reads. The templates now carry a `USER_TURN` marker: rules above it stay the (cacheable, byte-identical) system prompt; the per-call sections below are delivered as the user message. A missing marker falls back to the legacy all-in-system shape rather than corrupting the prompt (`src/prompts/research-knowledge-search-extractor.md`, `src/prompts/research-knowledge-relevance-triage.md`, `src/tools/research-knowledge-search.ts`).
80
+ - **Removed a vestigial instruction from the researcher prompt.** "Do NOT try to search the store manually" told researchers not to use a tool they have never been given (`research_knowledge_search` is a top-level tool, not part of the researcher toolset); the surviving wording just explains where knowledge-store results come from and how to cite them.
81
+ - `docs/SDK.md` now opens by describing the package as a high-level research SDK.
82
+
83
+ ### Fixed
84
+
85
+ - **A markdown-fenced JSON synthesis envelope could ship verbatim as the final report.** `salvageReportText` suppresses broken JSON envelopes when parse and repair both fail, but its check was `startsWith('{')` — a ```` ```json ````-fenced envelope starts with backticks and sailed through (observed in production 2026-08-20: a fenced `{"action":"synthesize",…}` blob, internal protocol fields included, published as a user-facing report). The sniff now strips one leading/trailing code fence before the shape check; prose that merely contains a fenced block is still preserved (`src/core/planning-service.ts`).
86
+ - **The release workflow's credentialed `publish` job interpolated an untrusted job output into its shell.** `npm publish "${{ needs.test-install.outputs.tar_file }}"` splices text originating in the one job that runs untrusted dependency lifecycle scripts directly into the script of the only job holding `id-token: write` — a script-injection channel that re-opened the exact credential/untrusted-code separation the 1.4.2 publish restructure exists to enforce (GitHub's hardening guide lists `needs.*.outputs.*` as an injection source). The publish job now derives the tarball filename deterministically from the release tag (env-indirected), verifies the downloaded artifact contains exactly that file, and the `test-install` job no longer exports the output at all (`.github/workflows/release.yml`).
87
+ - **The browser scrape layer classified PDF-vs-HTML against the pre-redirect URL** — the same bug class 1.4.2 fixed in the fetch layer via `currentUrl`, left unfixed one layer down. A download endpoint without `.pdf` that redirects to the real `.pdf` file (with missing/mislabeled content-type) fell into the HTML branch, serializing Firefox's PDF-viewer chrome instead of the document, and the oversized-PDF early abort never armed for it. Both browser-layer checks now use the response's own post-redirect URL (`src/infrastructure/browser/thread-worker-messaging.ts`).
88
+ - **Unit tests polluted the real shared log file.** The test setup's `PI_RESEARCH_LOG_PATH` redirect ran in the setup module's body — but its own static imports (metrics → logger) are hoisted and evaluate first, so the logger singleton had already bound the user's real log path before the redirect executed. Every error-path unit test then appended real-looking WARN/ERROR entries (6 spurious ERRORs and hundreds of WARNs in one observed day) that materially slowed forensic diagnosis of real runs. The env assignments now live in an import-free module imported first (`test/setup/unit-env.ts`).
89
+ - **The missing-config advisory was the single most common line in the log.** `getConfig` warned "No configuration found for workspace" on every call from an unconfigured directory — 362 entries in one observed day. Now warned once per workspace per process (`src/config.ts`).
90
+ - Singular knowledge-search progress line rendered a bare trailing slash ("1 query/") — same rate-vs-count confusion the search-burst line was already fixed for (`src/cli.ts`).
91
+ - `isVerboseFromEnv`'s doc comment claimed config loads sync `config.DEBUG` into the env; only saves do. The comment now says exactly when a `DEBUG=true` line in config.env takes effect (`src/utils/log-utils.ts`).
92
+ - **Four 1.4.2 fixes shipped without the regression tests their release notes implied.** Backfilled and verified: `security_search` must not charge the shared gathering budget on unknown-database/invalid-severity rejects; `redactSecrets` must still mark the scan-cut tail as truncated when redaction shrinks the prefix under the cap; `uninstallSkill([])` must remove nothing (an explicit empty list is not `--all`); and redirect-aware PDF/HTML classification on the fetch path (both directions, verified failing against the pre-fix code).
93
+ - **Knowledge-store migration could still lose data if BOTH the canonical rename and its own revert failed.** The 1.4.1 fix for the manifest/disk desync bug added a revert-the-rename path for when the post-rename manifest save fails, but the revert's own failure fell through into a catch block written for a different case (the first rename never happening at all) — which pointed the manifest at a directory that no longer existed on disk. Now retries the manifest save against the name that actually matches disk, and logs loudly if that also fails, instead of silently stranding the data; a third-order failure (the recovery reopen itself failing) is also handled — and the manifest repair is deliberately not gated behind the reopen, since it needs only file writes, so a reopen-only fault still gets the manifest fixed for the next restart. Three regression tests cover the compound failures directly (`src/knowledge/store.ts`).
94
+ - **`SIGQUIT` was documented as gracefully handled and wasn't.** `docs/SDK.md` and `SKILL.md` list SIGQUIT alongside SIGINT/SIGTERM/SIGHUP as a signal the CLI catches and shuts down cleanly from — but no listener was ever registered for it, so a real SIGQUIT hit Node's default disposition (immediate kill, no abort, no drain) instead. Added the missing handler to both the CLI and SDK; a real-subprocess test now sends an actual SIGQUIT to the built binary and distinguishes "our handler ran" from "the OS killed it" (`src/cli.ts`, `src/sdk.ts`).
95
+ - **Embedding-server shutdown could dispose the pipeline while a second queued embed was starting.** `SerialQueue.waitForIdle()` only checked the currently-executing task; there's a real gap, verified with a controlled reproduction, between one task clearing that state and the next queued task claiming it, and a caller checking exactly in that gap saw "idle" while a second native call was about to run against the about-to-be-disposed pipeline — the same class of native-GPU-concurrency bug this queue exists to prevent, one layer deeper than the 1.4.1 fix covered. Now also tracks whether the queue is still mid-drain (`src/infrastructure/embedding/embedding-server.ts`).
96
+ - **Two sibling headless research runs finishing at nearly the same moment could both skip clearing shared steering messages.** `endResearchSession`'s "last run in the session" gate only checked TUI-only state (`order`, `panels`), so on every headless run it was trivially true — one headless run ending reset steering while a sibling headless run was still consuming it. The gate now also requires the abort registry (populated by TUI and headless runs alike) to be empty, and because this run's own entry is removed in the same synchronous function, the whichever-sibling-finishes-last decision is atomic; the separate, racy guard the research tool used to run before its cleanup was removed in favor of this one (`src/orchestration/session-state.ts`, `src/tools/research-tool-definition.ts`).
97
+ - **A single YouTube transcript could dominate a researcher's context window.** The `youtube_transcript` tool concatenated raw transcripts into its tool response with no per-video cap — a long video (or several in one batch call) could inject hundreds of thousands of characters. Each transcript is now truncated with the same per-document cap and marker the scrape tool uses (`src/youtube/index.ts`).
98
+ - **A legacy `.pi-research.env` file in the current directory could set user-scoped keys.** The tool runs in arbitrary (including untrusted) repositories; a checked-in legacy env file could override user-level configuration such as API endpoints for anyone who ran research in that directory. CWD-local legacy files are now restricted to the same local-scope key allowlist that already governed project-scoped registry storage; user-scoped keys in them are ignored (`src/config.ts`).
99
+ - **The scrape tool's own protocol text could disagree with the enforced limit.** `scrape.ts` computed its batch count from config directly instead of asking the tracker that actually enforces the limit; with the new caching-aware budget those could differ by one. The tool now reads the tracker's limit, falling back to config only when no tracker is wired (`src/tools/scrape.ts`).
100
+ - **Release workflow's credentialed `publish` job ran an unscoped `npm install`.** `npm install -g npm@11` (needed for the OIDC trusted-publishing exchange) executed in the one job holding `id-token: write`, without `--ignore-scripts` — npm itself ships no install-time lifecycle scripts today, but the job's whole design is to never run installable code there on trust alone. Added `--ignore-scripts`; verified the resulting npm binary still publishes correctly (`.github/workflows/release.yml`).
101
+ - **README overstated the knowledge store as always-on.** "Every finding is written to a local LanceDB knowledge store" implied unconditional, automatic behavior; it's actually conditional on the store being enabled. Reworded to "can be saved."
102
+ - **`AGENT-SKILL.md` presented the Codex skill-install path as equally confirmed as Claude/OpenClaw's.** The installer itself tags the Codex path `confidence: 'unverified'` ("path not confirmed by official docs"); the doc now says so.
9
103
 
10
104
  ## [1.4.3] - 2026-08-19
11
105
 
@@ -468,6 +562,7 @@ were added alongside the fixes.
468
562
  ### Reviewed, not changed
469
563
 
470
564
  A full CodeQL alert triage found 12 of 13 open alerts to be false positives or out-of-scope for a code fix, each verified individually rather than dismissed on assumption:
565
+
471
566
  - Two `js/missing-await` alerts in `embedder.ts` are reference-identity checks (`this.recoveryPromise === recovery`) misread as a forgotten `await` — awaiting either side would break the logic.
472
567
  - A `js/file-system-race` alert in `file-lock-service.ts` is on code that already reopens with the `'wx'` exclusive-create flag specifically to make the check-then-open atomic.
473
568
  - A `js/insecure-temporary-file` alert in `browser/cleanup-utils.ts` is a read-only liveness probe on a Firefox lock file, not a file creation.
@@ -617,6 +712,7 @@ cancellation-reporting path fixed in 1.2.0.
617
712
  ### Fixed
618
713
 
619
714
  **Found in this release's own fresh-eyes pass (new, on top of the three prior rounds):**
715
+
620
716
  - **The chunker's own safety invariant was false — `overlap < targetSize` does not, in fact, prevent infinite loops.** Reproduced directly: `new Chunker({targetSize: 5, overlap: 4}).chunk(...)` on text containing a boundary-straddling emoji crashed the worker process with an out-of-memory error rather than merely running slowly. The UTF-16 surrogate-pair backoff (added earlier in this range) can land on the exact fixed point the forward-progress guard already enforces for `end`, so neither `start` nor `end` advances between iterations and the same chunk is pushed forever. A matching, milder gap let a lone high surrogate ship uncorrected into stored/embedded text whenever backing off would have violated that same guard. Both closed: the surrogate fix now grows past the low surrogate instead of silently doing nothing when shrinking would re-collide, and a new forward-progress invariant on `start` — mirroring the existing one on `end` — guarantees real per-iteration progress independent of any heuristic interaction, not just the ones already traced by hand. A brute-force sweep across small targetSize/overlap/emoji-position combinations backs the fix, not just the one reproducing triple.
621
717
  - **Uninstall's "is this directory exclusively ours" check was a bare name match, not an ownership check.** `isPiResearchNamespaced()` treated any path segment literally equal to `pi-research` as sufficient proof a `PI_RESEARCH_STATE_DIR` override was safe to delete wholesale — but naming a directory after the tool pointed at it is a completely ordinary thing to do (e.g. organizing several tools' state under `~/agents-state/<tool-name>/`), and nothing stops a user from keeping unrelated content in that same folder. The one regression test guarding this path only ever planted pi-research-owned entries, never foreign content, so it could never have caught this. Now only the exact default `<dirName>/research` layout — something the runtime actually constructs itself and can therefore prove is exclusively its own — authorizes wholesale removal; every other override, however named, only ever loses the specific entries the runtime is known to create there.
622
718
  - **A researcher could still run a full billed session after its run was told to stop.** The three fast-stop checkpoints added earlier in this range (top-of-loop, initial-links loop, post-backoff sleep) left one window uncovered: between `createResearcherSession()` resolving and the session being registered for `abortAllSessions()`'s visibility, a few lines later behind an awaited call. A stop tripping in exactly that gap was invisible to the abort scan, and the caller's bounded ~10s wait for self-stopping researchers elapsed before the session's own LLM call and scrape burst finished — the run returned and released its slot while the researcher kept going in the background. A fourth checkpoint immediately after registration closes it.
@@ -633,6 +729,7 @@ cancellation-reporting path fixed in 1.2.0.
633
729
  **From the backlog-clearing round** (re-auditing every item deferred since v1.0.7 plus both rounds above): the cancellation-reporting gap described above; `knowledge --json` success payloads not carrying `ok: true` despite claiming parity with the error shape, and `knowledge-config` having no `--json` error handling at all; the SSRF IPv6 gate missing `fec0::/10` and the NAT64 well-known prefix `64:ff9b::/96` (a real bypass on hosts whose egress traverses a NAT64 gateway, reachable via `64:ff9b::169.254.169.254`); steering messages already consumed by a finished run leaking into the next unrelated run's system-prompt injection; `clearBrowserServer` gaining the same compare-and-delete semantics its embedding-leader sibling already had; a second, independent chunker surrogate-pair gap (raw UTF-16 boundaries with no pair awareness); and steering-extended round budgets being invisible to the round-phase prompt logic (`PlanningService` recomputed `maxRounds` from the base complexity table only). `HealthCheckService` — zero runtime consumers, fully superseded by the live `src/healthcheck/registry.ts` system — was deleted.
634
730
 
635
731
  ### Changed
732
+
636
733
  - **A cancelled-but-partially-successful research run now reports its true status.** See above — this is the same exit-code contract 1.2.0 established, extended to a success-path case that fix didn't reach.
637
734
 
638
735
  ## [1.2.0] - 2026-08-05
@@ -649,6 +746,7 @@ changes alongside the fixes: cancelled runs now exit `128 + signal` instead of
649
746
  and the host-version self-check gained the tested-ceiling warning.
650
747
 
651
748
  ### Fixed
749
+
652
750
  - **A worker dying mid-task permanently shrank the browser queue — enough deaths wedged the leader.** Measured on poolifier 5.3.2: when a cluster worker is OOM-killed or SIGKILLed, the in-flight `pool.execute()` promise is *never* settled — not on the death, not when a replacement respawns, not even on `pool.destroy()`. The worker enforces its task timeout on itself, but only while alive — so the dispatched closure pended forever and its PriorityTaskQueue concurrency slot was never released. Each such death shrank the queue by one; the caller's own timeout error is excluded from both retry and the circuit breaker, incoming requests kept resetting the idle timer, and leadership was retained — a machine-wide singleton serving nothing but timeouts. Compounding it, an unexpected `SIGKILL` exits with the same `null` code as an intentional `pool.destroy()`, so these deaths never counted toward auto-recovery either. Every `execute()` now carries its own dead-worker backstop timer created *inside* the dispatched closure (sharing no lifetime with the caller's outer race — the shared-timer variant is exactly how a queued task once lost its guard), intentional destroys bump the pool epoch first so a same-epoch `null` exit now counts as the abnormal death it is, and a task whose caller has already been answered releases its queue claim instead of keeping the slot plus a fresh worker budget. Poolifier's post-destroy wording ("not started pool") is also classified as the transient pool-swap it is, and the orphan-exit path bounds its browser cleanup so a wedged browser cannot keep the orphan alive forever.
653
751
  - **The integration suite deleted the real `~/.cache/pi-research` on any machine with `XDG_CACHE_HOME` exported.** The preuninstall parity test spawns the real `scripts/cleanup.cjs` with a throwaway HOME — but cleanup.cjs resolves the cache from `XDG_CACHE_HOME` *before* falling back to the homedir, and the test passed the parent environment through. On a configured Linux desktop (where `XDG_CACHE_HOME` is commonly an absolute path) every `npm run test:integration` silently removed the real cache tree — browser profiles, the WebGPU probe verdict, downloaded embedding models. Verified by timestamps on this machine: the cache's birth time sat in the middle of a test run. The spawn now pins every path the script honours inside the throwaway HOME. In the same class: `cleanup.cjs` now applies to `PI_RESEARCH_STATE_DIR` / `PI_RESEARCH_CONFIG_DIR_NAME` the identical strictness it already applied to a relative `XDG_CACHE_HOME` (a relative state dir would have rm'd a tree relative to wherever the script was invoked), and `prepare.cjs` no longer builds via a shell string that leaves `$(…)` live inside double quotes — the same injection shape c1e363e7 removed from setup/cleanup.
654
752
  - **A failed copy-refresh destroyed the installed skill permanently.** The stale-copy refresh removed the install first and copied second, so a copy failure (ENOSPC, unreadable source, a locked file on Windows) left *nothing* — and the retry guard required the path to exist, so the vanished install was never retried. Worse, the copy helper was `fs.cpSync`, whose native implementation aborts the entire host process on an unreadable directory (observed: `terminate called after throwing an instance of 'std::filesystem::__cxx11::filesystem_error'`) — and this runs at every startup. Refresh is now build-then-swap with rollback (the old tree is not touched until the replacement is fully built; on any failure the old install is byte-for-byte intact and retried next startup; staging names are dot-prefixed so a crash mid-swap cannot leave a directory a skill scanner would list as a duplicate skill), and the copy walks the tree in JS so failures throw catchably instead of killing the process. `installSkill`'s stale-symlink re-point now honours `dryRun` like every other mutation.
@@ -681,6 +779,7 @@ and the host-version self-check gained the tested-ceiling warning.
681
779
  - **Search failures were reported to the researcher as "the query may be too narrow".** Every zero-result query got the same `empty_results` message regardless of cause, so a timeout or a dead worker read as feedback about the query — prompting the agent to rewrite a perfectly good query when the infrastructure was what broke. (Observed when a pinned circuit breaker fast-failed 19 of a 20-query burst.) Timeouts and worker errors now carry their own type and message, stating explicitly that the failure says nothing about the query.
682
780
 
683
781
  ### Changed
782
+
684
783
  - **Cancelled runs exit in the POSIX `128 + signal` range instead of `70`.** *This changes the CLI's exit-code contract.* A deliberate stop is not a runtime fault, but it shared `SOFTWARE` (70) with genuine crashes — and 70 is documented in `SKILL.md` as "runtime error — suggest one retry for transient failures", so an agent relaying the result would offer to re-run the very research the user had just interrupted. Cancellation now reports `130` for Ctrl-C/SIGINT, `143` for SIGTERM, `129` for SIGHUP, `131` for SIGQUIT, and `130` for a programmatic abort where no signal was involved; it says "cancelled" rather than "failed" and sets `cancelled: true` (never `retryable`) in `--json`. Classification keys off the signal handler having fired, not off pattern-matching whichever subsystem noticed the abort first.
685
784
 
686
785
  The code is derived **per signal** rather than fixed, because the CLI installs handlers for these signals and so is never actually killed by them — it catches them and exits deliberately. A fixed code would make the status a wrapper observes depend on whether our handler beat a force-kill (handler wins → the fixed code, handler loses → the shell's `128 + N`); deriving `128 + N` makes the handler transparent, so the same user action reports the same number either way. Every value lands in 129–143, safely inside the 0–255 a POSIX wait status preserves — nothing can be truncated into a false success. Windows implements no `128 + N` convention at all, so there these are simply our documented contract values. `SKILL.md` now tells agents to treat **any** exit ≥ 128 as a cancellation.
@@ -701,12 +800,14 @@ and the host-version self-check gained the tested-ceiling warning.
701
800
  - **Read-only accessors on the synthesis service could evict a live run's reports.** `getReport`, `getAllReports`, `getReportsForRound`, `getReportCount`, `hasReports`, `buildFallbackSynthesis`, `ensureCitedLinks` and `extractAllCitations` all went through a create-on-read helper, so a query for an unknown session id inserted an empty map — and at the 200-session cap that insert evicts the head. The LRU touch protects sessions that are read or written, but an in-flight run whose researchers are all still executing is precisely the stalest key, so a stray read could drop a live run's reports and silently shrink its final synthesis. Reads now use a non-mutating view.
702
801
 
703
802
  ### Changed
803
+
704
804
  - **Host (pi) version compatibility is now a floor *and* a tested ceiling.** The runtime self-check enforced only a minimum, so a host newer than anything this release was built against passed silently and failed deeper — and the declared range cannot help: as a pi extension the host supplies `@earendil-works/*` (our range never constrains it), and standalone, published tarballs carry no lockfile so every fresh install resolves the newest 0.x at that instant. pi is pre-1.0, where a minor bump may break anything under semver — and 0.83.0 already did extend the `ResourceLoader` contract. Below the floor is still fatal; above the last verified line it now warns once and continues, because refusing would strand users on every pi release while silence hides the likeliest cause of a strange failure. The policy moved into a tested module (`src/core/pi-version.ts`) with a test asserting the floor still matches the range declared in `package.json`, and another asserting the installed pi is within the tested window so the constant cannot go quietly stale.
705
805
  - **CI tests more than one Node version.** `engines` declares `>=22.19.0` but every job pinned `.nvmrc` (22.19.0), so the only version ever exercised was the floor of the supported range. A current-LTS leg now runs alongside it (with the Node version in the cache key, so the two legs cannot share a `node_modules` tree and decide each other's result).
706
806
  - **Nothing executed the CLI from an installed package layout.** The install checks verified files exist; the only place `dist/cli.mjs` actually ran was from the repo root, where the full dev tree is present. Because the bundle is built with esbuild `packages: 'external'`, every bare import resolves at runtime from the *consumer's* tree — so a dependency moved to `devDependencies`, a dropped `files` entry, or an exports subpath present only in the dev tree would pass lint, type-check, unit tests and manifest verification, and ship broken. CI and the release job now execute the installed CLI and the skill launcher. (Checked by hand: all 25 external specifiers currently resolve, so the hole was latent, not live.) The job is a three-OS matrix — the npm-generated Windows `.cmd`/`.ps1` bin shim, the thing `npx pi-research` actually invokes, had never once been generated or run in CI — and it runs the shim directly, on Windows the `.cmd` specifically.
707
807
  - **Pinned the dependency-graph generator.** `deps:generate` ran `npx --yes madge`, resolving the newest release from the registry at CI time — unpinned third-party code executing in a job that holds `contents: write` and pushes back to the repo, and a hard network dependency that made CI non-reproducible. Now pinned to `madge@8.0.0`.
708
808
 
709
809
  ### Documentation
810
+
710
811
  - **The README overstated the researcher's boundary.** It said the research agent "can read and search files in the working directory". The `cwd` passed to `read`/`grep` is a resolution base, not a jail — an absolute path resolves to itself — so the real boundary is *no mutation*, not *this directory only*. Reworded to "read-only by design … it can read and search files", and `ARCHITECTURE.md` now states plainly that `read`/`grep` are registered unconditionally and are not covered by the researcher exclusion list.
711
812
  - **The SDK's cancellation contract was entirely undocumented, and is not what you would guess.** `runDeepResearch`, `runQuickResearch`, `verifyUrl` and `scrapeUrl` all accept an `AbortSignal` as a trailing positional argument — absent from `SDK.md`. More importantly, an abort does **not** simply reject: if any researcher report was already collected, the run *resolves* with a partial synthesis and fires `onComplete`, and only a run with nothing gathered rejects. A caller treating "resolved" as "ran to completion" would silently ship a truncated report as a full one. Now documented as a table, together with the matching CLI behaviour.
712
813
  - **`--help` did not list exit code `130`.** The same omission `1.1.1` fixed for `75`.
@@ -714,6 +815,7 @@ and the host-version self-check gained the tested-ceiling warning.
714
815
  - **`npm uninstall` cleans up nothing, and the docs said otherwise.** npm removed the `preuninstall` lifecycle for the package being uninstalled in npm 7; verified against npm 11 here, where `postinstall` fires on install and `preuninstall` never fires on uninstall. The bundled `scripts/cleanup.cjs` — which removes the agent-skill links, the state tree and the cache tree including downloaded embedding models — therefore never ran, while `AGENT-SKILL.md` claimed stale links were "garbage-collected … on `npm uninstall`". Documented accurately, with `pi-research skill uninstall` named as the supported path, and the script itself exposed as `npm run cleanup`.
715
816
 
716
817
  ### Tests
818
+
717
819
  - **The future-state-version test asserted only the harmless half of the quarantine contract.** It checked that a `version: 2` file written by a newer build degrades to an in-memory default, but not that the newer file is left untouched — the part that actually protects the other build's live sessions, browser lease and `authSecret`. Its comment also described the mechanism as "treat it as corruption and recover", which is the opposite of what the code does. It now asserts the on-disk file is byte-intact after a write attempt and that a forensic copy was quarantined. (The full contract was covered elsewhere in the same file; this test simply read as permitting the destructive behaviour.)
718
820
 
719
821
  ## [1.1.2] - 2026-08-04
@@ -723,6 +825,7 @@ and the host-version self-check gained the tested-ceiling warning.
723
825
  > attempt. Everything below is the full set of changes since 1.1.0.
724
826
 
725
827
  ### Fixed
828
+
726
829
  - **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.)
727
830
  - **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.
728
831
  - **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.
@@ -732,11 +835,13 @@ and the host-version self-check gained the tested-ceiling warning.
732
835
  - **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.
733
836
 
734
837
  ### Changed
838
+
735
839
  - **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.
736
840
  - **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.
737
841
  - **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.
738
842
 
739
843
  ### Documentation
844
+
740
845
  - **`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.
741
846
  - **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.
742
847
  - **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.
@@ -745,13 +850,16 @@ and the host-version self-check gained the tested-ceiling warning.
745
850
  ## [1.1.0] - 2026-08-04
746
851
 
747
852
  ### Changed
853
+
748
854
  - **Concurrent runs over the cap now QUEUE instead of failing — the run-cap default is a queue, not a fail-fast.** v1.0.11 added the machine-wide run cap that stopped parallel runs saturating the shared browser/embedding pool, but it shipped with `PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS` defaulting to `0`. That made contention *fatal*: the cap was enforced by rejecting the run outright, in about two seconds, rather than by serializing it. Verified end-to-end before the change — four simultaneous skill runs against the default cap produced two clean reports and two hard failures, with the two rejected queries simply lost. That is the wrong trade for a semaphore guarding a shared resource: an extra run is a legitimate request to do work, not an error. The default acquire wait is now **600000 ms (10 minutes)**, so over-cap runs wait for a slot and then execute normally; a depth-1 run measured at ~2–3 minutes, so this absorbs a backlog of roughly three further runs per slot. `PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS=0` restores the old strict fail-fast. The cap itself is raised from **2 to 3** concurrent runs. The agent skill's recommended command timeouts are raised to match (depth 1 `1500000` ms, depth 2 `1800000`, depth 3 `2400000`), since a queued run legitimately takes longer than an unqueued one and the old budgets no longer bounded the worst case.
749
855
  - **A queued run now says so** (`• queued: N concurrent run(s) already active — waiting up to Ns for a slot…`) instead of sitting silent for the whole acquire wait. Silence for up to ten minutes is indistinguishable from a hang, which would have made the queueing change above *look* like a regression. The notice is emitted exactly once per run, via a new optional `ResearchObserver.onRunQueued(slots, maxWaitMs)`; because the queue happens before any orchestrator exists, the notification is dispatched to whichever observer shape the caller supplied — a `ResearchObserver` instance (SDK / pi extension) or the `HeadlessObserverOptions` bag carrying only `onProgress` (CLI / agent skill). A throwing front-end callback can never fail the run it is reporting on.
750
856
 
751
857
  ### Added
858
+
752
859
  - **Distinct exit code `75` (`EX_TEMPFAIL`) for run-cap exhaustion.** Capacity exhaustion previously exited `70` — the same code as a genuine crash — so a calling agent could not tell "this machine is busy with other healthy runs, try again shortly" from "this run is broken", and the agent skill's own exit-code table told it to treat `70` as a transient error worth one immediate retry (which, under a 0 ms acquire, failed again instantly). It now exits `75`, documented in `SKILL.md` with explicit guidance not to reconfigure anything and not to retry in a tight loop, and `--json` output carries `retryable: true`. The classification is matched on the error name so it survives bundling into `dist/cli.mjs`, and is checked ahead of the credentials heuristic — the capacity message names `PI_RESEARCH_MAX_CONCURRENT_RUNS`, and the substring heuristic would otherwise have been at risk of reading a busy machine as a misconfigured one. Capacity exhaustion is also reported without a stack trace, since a routine "busy" state printed as a stack reads as a crash.
753
860
 
754
861
  ### Fixed
862
+
755
863
  - **A browser-pool leader exiting could destroy a concurrent run outright — the retry reconnected to the dead leader instead of re-electing.** The browser pool is hosted by one elected leader process shared by every pi-research run on the machine, so when that leader finishes its own run and tears down its workers, every other run with work in flight takes `Worker pool is shutting down` on *all* outstanding tasks at once. The recovery path handled this incorrectly: on a pool-shutdown error it only waited for the pool to go idle and **never dropped the cached scheduler handle**, so the single retry called `getScheduler()`, got the same cached client still pointing at the dead leader's port (visible in logs as a retry "Connecting to existing scheduler" with an *identical* version id), and failed the same way. The generic transient-socket branch did the right thing and forced a re-resolve; the pool-shutdown branch — the case where the leader is provably going away — did not. Reproduced in a real six-way concurrent run: a run acquired its slot in the same second the leader shut down, all 20 queries of its search burst failed, and the run aborted with `Search completely failed: all 20 queries encountered worker errors` and the tell-tale `60 tracked error(s) … "Worker pool is shutting down" ×60` — roughly two seconds *before* a fresh leader finished being elected and served everyone else normally. A handover now drops the stale handle and forces re-resolution (joining the new leader, or winning the election itself), and gets its own retry budget of 3 attempts with a ~1.2 s backoff, sized to a real election rather than to the 100–500 ms jitter meant for a socket blip. The budget is tracked separately from the generic retry so a handover cannot be starved by, or consume, the single transient-socket attempt; genuinely unrecoverable pool loss still gives up rather than retrying forever. This failure mode became far more likely with over-cap queueing, since a queued run is admitted at exactly the moment another run finishes — which is exactly when the leader may be tearing down.
756
864
  - **The run-cap's two environment variables were entirely undocumented.** `PI_RESEARCH_MAX_CONCURRENT_RUNS` and `PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS` shipped in v1.0.11 and appeared in neither `docs/CONFIGURATION.md` nor `.env.example` — the latter described in the docs as "the canonical, exhaustive list" — even though the capacity error message explicitly instructed the user to override the first of them. Both are now documented in both places, and the run cap has an `ARCHITECTURE.md` section covering slot ownership, crash reclamation, queueing and fail-open behaviour.
757
865
  - **`docs/SDK.md` recommended a workaround that the run cap had since made unreliable** — it told callers to "use a separate process per concurrent run" with no mention that separate processes are themselves bounded machine-wide, so following the documented advice past the cap hit a hard failure. It now documents the cap, the queue window, `ResearchRunCapacityError`, exit code `75`, and `onRunQueued`.
@@ -759,16 +867,19 @@ and the host-version self-check gained the tested-ceiling warning.
759
867
  ## [1.0.12] - 2026-08-03
760
868
 
761
869
  ### Fixed
870
+
762
871
  - **Run-cap / file-lock / leader-election could misreport a live process as dead (Windows/macOS) — the root cause of the flaky `run-semaphore-multiprocess` integration failures on Windows CI.** `ProcessLifecycleService.isProcessAlive(pid, expectedStartTime)` returned `false` ("dead") whenever `process.kill(pid, 0)` confirmed the process was alive but the follow-up **start-time lookup failed**. On Linux that null reliably means the process exited in the microsecond gap between the `signal(0)` and the `/proc/{pid}/stat` read, so `false` was correct — but on Windows/macOS the start time comes from a subprocess (`powershell Get-Process` / `ps`) that can fail or time out under load while the process is still very much alive. Reporting a confirmed-alive PID as dead is catastrophic for *every* caller: a live lock / leader / run-cap owner is reclaimed from under it, so two writers collide (lost update) or the run-cap admits extra concurrent runs. The v1.0.11 Windows CI failure manifested both ways depending on timing — `expected 3 to be 4` (two holders collided on one slot) and `expected +0 to be 3` (a 5th process stole a live holder's slot and was admitted past the cap). The fix re-confirms liveness instead of guessing: when the start-time lookup returns null it issues a second `signal(0)`; if the process is still alive it reports `true`, and only if that probe throws (the process genuinely died in the interim) does the outer handler return `false`. A real death is therefore still detected within one poll tick (~100 ms); the only behavioural change is that a confirmed-alive process is never again reclaimed on a transient start-time lookup failure. PID-reuse protection is unchanged when the start time is resolvable (the `±1s` slack comparison still applies). This is a strict safety improvement across all consumers — file locks, the run-cap semaphore, GPU locks, the embedding leader, browser scheduler and state sessions — none of which ever wanted a live owner treated as dead.
763
872
  - **Stack Exchange integration tests no longer fail when the upstream API returns empty results** — the Stack Exchange `search` endpoints intermittently answer `200 OK` (quota consumed, no error) with a zero-item `items:[]` for queries that normally return thousands of results (verified 2026-08-03: `q=javascript` on Stack Overflow returned `[]` from a residential IP). This is an upstream API degradation / contract drift, not a pi-research code fault and not a datacenter-IP block, yet it hard-failed the serial integration suite on all three CI OSes and thereby blocked every release whose code was otherwise identical to a previously-green build. The integration-test environment-skip helper (`isNetworkUnavailable`) now also recognizes a successful-but-empty Stack Exchange response (anchored on the tool-specific `**API Quota:**` footer plus the zero-item body) and skips the affected tests visibly via `ctx.skip()` — the same treatment already given to transport errors and `429`/`5xx` throttling. The anchor is emitted only by the Stack Exchange tool, so it cannot mask a regression in any other tool, and a populated result still exercises the assertions in full.
764
873
 
765
874
  ## [1.0.11] - 2026-08-02
766
875
 
767
876
  ### Added
877
+
768
878
  - **Cross-process research run-cap** — a machine-wide counting semaphore (`ResearchRunSemaphore`) now gates every `runResearch()` entry on one of N file-lock slots. This is the root-cause fix for the parallel-run instability (60+ error storms and, on constrained hosts, process aborts) that occurred when several research runs saturated the single shared leader-elected browser/embedding pool at once. Holders are reclaimed immediately when their PID exits (PID + startTime guards against PID reuse); *live* holders are never stolen, because a run legitimately holds a slot for minutes. The cap **fails open** on any internal/IO error (a semaphore bug can never break research) and **fails fast** with a distinct `ResearchRunCapacityError` only on genuine capacity exhaustion. (Phase 1 of the multi-run hardening plan.)
769
879
  - **Browser leader `drain` / 503 handover** — when the elected leader loses leadership (election loss, idle timeout, or shutdown), it now enters a draining state that answers new requests with an explicit `503 { error: "draining" }` *before* closing the listener, instead of letting connected clients stall on a connect timeout or bare reset. The client classifies this as a transient "re-elect and retry" signal (logged at DEBUG, recorded under its own `draining` errorType for forensics) and it is deliberately excluded from the circuit breaker so a routine leadership handover never reads as an incident.
770
880
 
771
881
  ### Fixed
882
+
772
883
  - **Citation rewriting regression — `[removed]` tokens, list/code corruption, `[0]` deletion** — the prior placeholder-citation defense rewrote synthesis bodies with a global space collapse that flattened nested lists and 4-space code blocks, deleted bracketed integers *inside* quoted code (`items[12]`), treated `[0]` as a citation, and emitted broken `[removed]` tokens for dropped citations. All replaced by a structure-preserving rewrite: fenced blocks and inline-code spans are skipped entirely, line-leading indentation is preserved, dangling markers are removed cleanly (never as a visible token), and `[N]` outside a plausible citation range (`1..200`) and `[0]` are left untouched.
773
884
  - **Provenance gate mass-dropping legitimate citations** — the gate that previously DROPPED citations tagged Scrape/Transcript when their URL was absent from the successful-scrape pool conflated a *blocked* scrape (real URL, content unretrieved) with fabrication: under the common high-scrape-failure rate (bot blocks on GitHub / Microsoft / etc.) it mass-deleted real citations. The gate is removed; every parsed citation is now kept (deduped by URL). Fabrication defense rests on URL dedup plus a new prose-URL sweep instead of punishing failed scrapes.
774
885
  - **Fabricated inline URLs in synthesis prose** — inline `https?://` tokens in the report body whose URL was not retrieved this session are now redacted to `[link removed: not found in retrieved sources]` (verified inline references are kept). A matching caveat block is appended when a model-written `CITED LINKS` section survives a run that retrieved nothing, so no report ships fully-sourced-looking with no warning on a zero-retrieval run.
@@ -780,11 +891,13 @@ and the host-version self-check gained the tested-ceiling warning.
780
891
  ## [1.0.10] - 2026-07-30
781
892
 
782
893
  ### Fixed
894
+
783
895
  - **pi 0.83.0 compatibility — `ResourceLoader` contract** — pi 0.83.0 extended the `ResourceLoader` contract with `getSystemPromptSource()` and `getAppendSystemPromptSources()` (consumed by interactive mode's startup context listing for file-backed `SYSTEM.md`/`APPEND_SYSTEM.md`). The in-memory sub-session loader now implements both, returning "no backing file" — accurate for prompts passed as text. The extra methods are harmless on pre-0.83 hosts, so the supported range stays `>=0.80.8 <1`. Audited the 0.82.1→0.83.0 dist diffs: the only other contract change (`ExtensionContext.scopedModels`) is provided by pi's own session code, and the mock runtime path has been immune since v1.0.9 via `createExtensionRuntime()`.
784
896
 
785
897
  ## [1.0.9] - 2026-07-28
786
898
 
787
899
  ### Fixed
900
+
788
901
  - **pi ≥ 0.81.0 researcher crash** — every researcher session died with `pendingNativeProviderRegistrations is not iterable` under pi 0.81.0+ ("no source material was collected"). The hand-maintained `ExtensionRuntime` mock in the resource loader was frozen at a pre-0.81 contract; it is replaced with pi's own `createExtensionRuntime()`, which always satisfies the running host's contract and cannot drift again.
789
902
  - **pi 0.80.8+ ModelRuntime migration** — `buildModelRegistry` migrated off the removed `AuthStorage`/`ModelRegistry.create()` APIs to `ModelRuntime.create()` + the synchronous `ModelRegistry` facade. The `@earendil-works/*` dependency floor and the in-host version self-check are both raised to 0.80.8 to match.
790
903
  - **Explicit-API-key auth now reaches researcher LLM calls** — researcher sessions are created with the `modelRuntime` backing our own registry (pi 0.80.8 removed the old `modelRegistry` session option). Without this, standalone `PI_RESEARCH_API_KEY` users with no `~/.pi/agent/auth.json` passed pre-flight and then failed auth on every researcher call.
@@ -795,27 +908,32 @@ and the host-version self-check gained the tested-ceiling warning.
795
908
  - **CHANGELOG dates corrected** — release dates in this file wrongly said 2024; corrected to the actual git release dates (2026).
796
909
 
797
910
  ### Security
911
+
798
912
  - **protobufjs override 7.6.4 → 7.6.5** — clears GHSA-j3f2-48v5-ccww (DoS) and its whole moderate advisory chain through `@google/genai`/pi-ai.
799
913
  - **adm-zip forced to 0.6.0 via override** — clears GHSA-xcpc-8h2w-3j85 (crafted-ZIP 4 GB allocation) in the camoufox-js subtree; extraction APIs camoufox uses verified compatible.
800
914
  - **Audit gate hardened with a reviewed exception list** — CI and the release publish gate now run `scripts/audit-gate.cjs`: any moderate+ advisory in the shipped tree fails the build unless explicitly allowlisted in `config/tooling/audit-exceptions.json` with an upstream-blocked justification, a clears-when condition, and a review date. Current exceptions: `brace-expansion` (frozen inside pi-coding-agent's npm-shrinkwrap; fix released upstream) and `sharp` (unused image pipeline of the embeddings-only transformers dependency).
801
915
 
802
916
  ### Changed
917
+
803
918
  - **Dependencies**: pi ecosystem lockfile refreshed to 0.82.1 (matching the current pi host); `pdf-oxide-wasm` 0.3.77; `undici` ^8.9; devDependencies refreshed (eslint 10.8, typescript-eslint 8.65, vitest 4.1.10, dependency-cruiser 17.4.3). Deliberate pins unchanged (typebox 1.1.38, playwright-core 1.60.0, camoufox-js 0.10.x, lancedb 0.29, apache-arrow 21.1.0, transformers 4.2, impit 0.13).
804
919
  - **Docs**: `PI_RESEARCH_LLM_TIMEOUT_MS` range updated in CONFIGURATION.md.
805
920
 
806
921
  ## [1.0.8] - 2026-07-17
807
922
 
808
923
  ### Added
924
+
809
925
  - **CHANGELOG** — Added comprehensive CHANGELOG.md documenting all versions from 0.1.13 through 1.0.7
810
926
  - **System prompt constraint** — Added "Maximum 4 simultaneous calls" constraint to research-tool-usage prompt to prevent system overload
811
927
 
812
928
  ### Changed
929
+
813
930
  - **Topic consolidation guidance** — Emphasized that topics can likely be consolidated to fit the 4-call maximum, with strategies for grouping related subtopics
814
931
  - **Package structure** — CHANGELOG.md now included in published package (docs/CHANGELOG.md)
815
932
 
816
933
  ## [1.0.7] - 2026-07-14
817
934
 
818
935
  ### Fixed
936
+
819
937
  - **Browser-pool leader-election race** - Fixed a critical bug where a single client's transient socket error could force-clear shared leader state, causing a live, healthy leader to self-destruct via the leadership-miss watchdog. The fix reserves the "unreachable, force clear" path for cases where liveness probes actually confirm the registered leader PID or port is gone.
820
938
  - **Researcher retry-exemption** - Fixed the "no initial search results or historical links" failure path that bypassed the retry loop entirely. This now routes through the same `RESEARCHER_MAX_RETRIES`/`RESEARCHER_MAX_RETRY_DELAY_MS` retry loop as other failures.
821
939
  - **Exit-code misclassification** - Fixed false-positive classification of "Research stopped" errors as config errors (exit 78) via substring matching on the boilerplate "API key" advice text. Error classification now properly distinguishes infrastructure failures from credential problems.
@@ -825,6 +943,7 @@ and the host-version self-check gained the tested-ceiling warning.
825
943
  - **Untitled YouTube video citation line** - Fixed citation formatting for YouTube videos without titles.
826
944
 
827
945
  ### Security
946
+
828
947
  - Several hardening improvements from the adversarial-review audit:
829
948
  - Migration abort safety gates
830
949
  - Hollow-run detection gates
@@ -835,51 +954,61 @@ and the host-version self-check gained the tested-ceiling warning.
835
954
  ## [1.0.6] - 2026-07-04
836
955
 
837
956
  ### Fixed
957
+
838
958
  - **Observer isolation** - Fixed `makeSafeObserver` proxying the observer directly, which could cause state leakage.
839
959
 
840
960
  ### Documentation
961
+
841
962
  - Corrected depth-0 availability documentation
842
963
  - Fixed migration.ts attribution in architecture docs
843
964
 
844
965
  ## [1.0.5] - 2026-07-04
845
966
 
846
967
  ### Fixed
968
+
847
969
  - **Content-Length pre-screen throws** - Added proper body draining to prevent resource leaks
848
970
  - **TTL refresh for transcribed links** - Fixed TTL handling for YouTube transcripts
849
971
 
850
972
  ### Fixed
973
+
851
974
  - **YouTube transcript provenance** - Fixed transcript provenance tracking in final report citations
852
975
 
853
976
  ## [1.0.4] - 2026-07-04
854
977
 
855
978
  ### Fixed
979
+
856
980
  - **Credential detection** - Detect pi keys by pi's own semantics, not file presence; registry no longer gated on auth.json
857
981
  - **Configuration clarity** - Updated docs to reflect that pi has configuration files, not a named 'auth' feature
858
982
 
859
983
  ## [1.0.3] - 2026-07-04
860
984
 
861
985
  ### Fixed
986
+
862
987
  - **Model resolution** - Require a configured model on the standalone CLI/skill; align SDK session model with RESEARCH_MODEL
863
988
  - **Configuration requirement** - Added proper error messages when model is not configured
864
989
 
865
990
  ### Documentation
991
+
866
992
  - Updated installation and configuration documentation
867
993
 
868
994
  ## [1.0.2] - 2026-07-04
869
995
 
870
996
  ### Fixed
997
+
871
998
  - **Timeout robustness** - Improved timeout handling for long-running operations
872
999
  - De-emphasized the markdown-conversion fallback in favor of native HTML-to-markdown conversion
873
1000
 
874
1001
  ## [1.0.1] - 2026-07-04
875
1002
 
876
1003
  ### Fixed
1004
+
877
1005
  - Initial stable release after rebuild against current pi APIs
878
1006
  - Stealth-browser stack integration
879
1007
 
880
1008
  ## [0.1.13] - 2026-04-15
881
1009
 
882
1010
  ### Added
1011
+
883
1012
  - Initial research functionality
884
1013
  - Knowledge store integration
885
1014
  - Multi-agent orchestration
@@ -937,6 +1066,7 @@ this pass with a regression test. None remain open.
937
1066
 
938
1067
  Two further items surfaced and closed in the same pass, found via later
939
1068
  audit rounds' own deferred lists rather than the v1.0.7 one:
1069
+
940
1070
  - **SSRF: `isPrivateIpv6` missed `fec0::/10`** (deprecated site-local) and
941
1071
  **`64:ff9b::/96`** (the NAT64 well-known prefix — actively deployed today,
942
1072
  and a real bypass vector on a host whose egress traverses a NAT64
@@ -955,4 +1085,3 @@ audit rounds' own deferred lists rather than the v1.0.7 one:
955
1085
 
956
1086
  One dead-code cleanup: `HealthCheckService` (a superseded, zero-consumer
957
1087
  predecessor of the live `src/healthcheck/registry.ts` system) was removed.
958
-
@@ -68,7 +68,7 @@ Research
68
68
  | `PI_RESEARCH_TIMEOUT_MS` (TUI) | `300000` | 180000–1800000 | Per-researcher timeout (3–30 min). |
69
69
  | `PI_RESEARCH_MAX_RESEARCHERS` (TUI) | `3` | 1–5 | Parallel researchers. |
70
70
  | `PI_RESEARCH_DEFAULT_RESEARCH_DEPTH` (TUI) `[project]` | `1` | 1–3 | Depth for `/research` and the CLI when `--depth` is omitted (1=normal, 2=deep, 3=ultra). |
71
- | `PI_RESEARCH_MAX_SCRAPE_BATCHES` (TUI) | `2` | 0–99 | Scrape batches per researcher (0 = unlimited). |
71
+ | `PI_RESEARCH_MAX_SCRAPE_BATCHES` (TUI) | `2` | 0–99 | Scrape batches per researcher (0 = unlimited). When prompt caching is known to be active for the resolved research model (Anthropic API models, or a provider route explicitly configured for Anthropic-style cache control), the effective limit is this value plus one — the cached prompt prefix makes the extra batch cheap. |
72
72
  | `PI_RESEARCH_MAX_GATHERING_CALLS` | `12` | 1–100 | Shared web-gathering calls per researcher (`search` + `security_search` + `stackexchange` + `youtube_transcript`). |
73
73
  | `PI_RESEARCH_MAX_CONCURRENT_SCRAPES` | `3` | 1–20 | Concurrent URLs fetched per scrape batch. |
74
74
  | `PI_RESEARCH_MAX_RETRIES` | `2` | 0–5 | Retries per researcher request. |
@@ -144,7 +144,7 @@ Diagnostics & platform
144
144
 
145
145
  | Variable | Default | Description |
146
146
  |----------|---------|-------------|
147
- | `PI_RESEARCH_DEBUG` (TUI) | `false` | Verbose INFO+DEBUG logging to the log file. |
147
+ | `PI_RESEARCH_DEBUG` (TUI) | `false` | Verbose INFO+DEBUG logging to the log file. Note: a `DEBUG=true` line saved in `config.env` reliably takes effect only for processes where a config *save* has run (saves sync it into the environment; plain loads do not) — to guarantee verbose logging from process start, export `PI_RESEARCH_DEBUG=true` in the environment. |
148
148
  | `PI_RESEARCH_CONSOLE_LOG` | `false` | Mirror logs to stdout/stderr (useful in CI / headless). |
149
149
  | `PI_RESEARCH_LOG_PATH` | _(OS temp)_ | Override the verbose log file path. Browser workers inherit it automatically. |
150
150
  | `PI_RESEARCH_LOG_FILE` | _(unset)_ | Send browser worker-thread logs to a separate file. If unset, workers log to `PI_RESEARCH_LOG_PATH`. |