@lincoln504/pi-research 1.5.0 → 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.
- package/dist/cli.mjs +210 -86
- package/dist/thread-worker.mjs +16 -3
- package/docs/CHANGELOG.md +87 -2
- package/docs/CONFIGURATION.md +1 -1
- package/package.json +3 -2
- package/skills/pi-research/SKILL.md +1 -1
- package/src/cli.ts +1 -1
- package/src/config.ts +5 -0
- package/src/core/interfaces/state-manager-interfaces.ts +8 -0
- package/src/core/planning-service.ts +27 -3
- package/src/infrastructure/browser/thread-worker-browser.ts +10 -0
- package/src/infrastructure/browser/thread-worker-messaging.ts +15 -2
- package/src/infrastructure/browser/thread-worker.mjs +16 -3
- package/src/infrastructure/embedding/embedding-factory.ts +34 -0
- package/src/infrastructure/embedding/embedding-server.ts +5 -0
- package/src/infrastructure/state/state-manager.ts +15 -0
- package/src/knowledge/embedder.ts +36 -7
- package/src/knowledge/store-operations.ts +47 -38
- package/src/knowledge/store.ts +26 -4
- package/src/knowledge/writer-queue.ts +4 -2
- package/src/orchestration/deep-research-orchestrator.ts +15 -3
- package/src/orchestration/quick-research-orchestrator.ts +8 -1
- package/src/orchestration/research-orchestration-service.ts +21 -2
- package/src/orchestration/research-synthesis-service.ts +8 -4
- package/src/orchestration/researcher-executor.ts +15 -1
- package/src/orchestration/session-state.ts +17 -0
- package/src/sdk.ts +59 -52
- package/src/stackexchange/index.ts +8 -0
- package/src/tools/health-tool-definition.ts +11 -1
- package/src/tools/research-knowledge-search.ts +23 -8
- package/src/tools/stackexchange.ts +14 -7
- package/src/utils/llm-usage.ts +4 -1
- package/src/web-research/scraper-utils.ts +6 -2
- package/src/youtube/potoken.ts +20 -2
package/docs/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,56 @@ All notable changes to this project will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [1.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
|
+
|
|
8
58
|
## [1.5.0] - 2026-08-20
|
|
9
59
|
|
|
10
60
|
Whole-project audit (release integrity, CI/test health, SSRF consistency,
|
|
@@ -21,7 +71,7 @@ invariance, and a caching-aware scrape budget.
|
|
|
21
71
|
|
|
22
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`).
|
|
23
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`).
|
|
24
|
-
- **Weak-grounding notice on completed reports.** A run that completes but verifies fewer than 3 sources now
|
|
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`).
|
|
25
75
|
|
|
26
76
|
### Changed
|
|
27
77
|
|
|
@@ -512,6 +562,7 @@ were added alongside the fixes.
|
|
|
512
562
|
### Reviewed, not changed
|
|
513
563
|
|
|
514
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
|
+
|
|
515
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.
|
|
516
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.
|
|
517
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.
|
|
@@ -661,6 +712,7 @@ cancellation-reporting path fixed in 1.2.0.
|
|
|
661
712
|
### Fixed
|
|
662
713
|
|
|
663
714
|
**Found in this release's own fresh-eyes pass (new, on top of the three prior rounds):**
|
|
715
|
+
|
|
664
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.
|
|
665
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.
|
|
666
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.
|
|
@@ -677,6 +729,7 @@ cancellation-reporting path fixed in 1.2.0.
|
|
|
677
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.
|
|
678
730
|
|
|
679
731
|
### Changed
|
|
732
|
+
|
|
680
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.
|
|
681
734
|
|
|
682
735
|
## [1.2.0] - 2026-08-05
|
|
@@ -693,6 +746,7 @@ changes alongside the fixes: cancelled runs now exit `128 + signal` instead of
|
|
|
693
746
|
and the host-version self-check gained the tested-ceiling warning.
|
|
694
747
|
|
|
695
748
|
### Fixed
|
|
749
|
+
|
|
696
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.
|
|
697
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.
|
|
698
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.
|
|
@@ -725,6 +779,7 @@ and the host-version self-check gained the tested-ceiling warning.
|
|
|
725
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.
|
|
726
780
|
|
|
727
781
|
### Changed
|
|
782
|
+
|
|
728
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.
|
|
729
784
|
|
|
730
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.
|
|
@@ -745,12 +800,14 @@ and the host-version self-check gained the tested-ceiling warning.
|
|
|
745
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.
|
|
746
801
|
|
|
747
802
|
### Changed
|
|
803
|
+
|
|
748
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.
|
|
749
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).
|
|
750
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.
|
|
751
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`.
|
|
752
808
|
|
|
753
809
|
### Documentation
|
|
810
|
+
|
|
754
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.
|
|
755
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.
|
|
756
813
|
- **`--help` did not list exit code `130`.** The same omission `1.1.1` fixed for `75`.
|
|
@@ -758,6 +815,7 @@ and the host-version self-check gained the tested-ceiling warning.
|
|
|
758
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`.
|
|
759
816
|
|
|
760
817
|
### Tests
|
|
818
|
+
|
|
761
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.)
|
|
762
820
|
|
|
763
821
|
## [1.1.2] - 2026-08-04
|
|
@@ -767,6 +825,7 @@ and the host-version self-check gained the tested-ceiling warning.
|
|
|
767
825
|
> attempt. Everything below is the full set of changes since 1.1.0.
|
|
768
826
|
|
|
769
827
|
### Fixed
|
|
828
|
+
|
|
770
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.)
|
|
771
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.
|
|
772
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.
|
|
@@ -776,11 +835,13 @@ and the host-version self-check gained the tested-ceiling warning.
|
|
|
776
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.
|
|
777
836
|
|
|
778
837
|
### Changed
|
|
838
|
+
|
|
779
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.
|
|
780
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.
|
|
781
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.
|
|
782
842
|
|
|
783
843
|
### Documentation
|
|
844
|
+
|
|
784
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.
|
|
785
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.
|
|
786
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.
|
|
@@ -789,13 +850,16 @@ and the host-version self-check gained the tested-ceiling warning.
|
|
|
789
850
|
## [1.1.0] - 2026-08-04
|
|
790
851
|
|
|
791
852
|
### Changed
|
|
853
|
+
|
|
792
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.
|
|
793
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.
|
|
794
856
|
|
|
795
857
|
### Added
|
|
858
|
+
|
|
796
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.
|
|
797
860
|
|
|
798
861
|
### Fixed
|
|
862
|
+
|
|
799
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.
|
|
800
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.
|
|
801
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`.
|
|
@@ -803,16 +867,19 @@ and the host-version self-check gained the tested-ceiling warning.
|
|
|
803
867
|
## [1.0.12] - 2026-08-03
|
|
804
868
|
|
|
805
869
|
### Fixed
|
|
870
|
+
|
|
806
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.
|
|
807
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.
|
|
808
873
|
|
|
809
874
|
## [1.0.11] - 2026-08-02
|
|
810
875
|
|
|
811
876
|
### Added
|
|
877
|
+
|
|
812
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.)
|
|
813
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.
|
|
814
880
|
|
|
815
881
|
### Fixed
|
|
882
|
+
|
|
816
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.
|
|
817
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.
|
|
818
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.
|
|
@@ -824,11 +891,13 @@ and the host-version self-check gained the tested-ceiling warning.
|
|
|
824
891
|
## [1.0.10] - 2026-07-30
|
|
825
892
|
|
|
826
893
|
### Fixed
|
|
894
|
+
|
|
827
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()`.
|
|
828
896
|
|
|
829
897
|
## [1.0.9] - 2026-07-28
|
|
830
898
|
|
|
831
899
|
### Fixed
|
|
900
|
+
|
|
832
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.
|
|
833
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.
|
|
834
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.
|
|
@@ -839,27 +908,32 @@ and the host-version self-check gained the tested-ceiling warning.
|
|
|
839
908
|
- **CHANGELOG dates corrected** — release dates in this file wrongly said 2024; corrected to the actual git release dates (2026).
|
|
840
909
|
|
|
841
910
|
### Security
|
|
911
|
+
|
|
842
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.
|
|
843
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.
|
|
844
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).
|
|
845
915
|
|
|
846
916
|
### Changed
|
|
917
|
+
|
|
847
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).
|
|
848
919
|
- **Docs**: `PI_RESEARCH_LLM_TIMEOUT_MS` range updated in CONFIGURATION.md.
|
|
849
920
|
|
|
850
921
|
## [1.0.8] - 2026-07-17
|
|
851
922
|
|
|
852
923
|
### Added
|
|
924
|
+
|
|
853
925
|
- **CHANGELOG** — Added comprehensive CHANGELOG.md documenting all versions from 0.1.13 through 1.0.7
|
|
854
926
|
- **System prompt constraint** — Added "Maximum 4 simultaneous calls" constraint to research-tool-usage prompt to prevent system overload
|
|
855
927
|
|
|
856
928
|
### Changed
|
|
929
|
+
|
|
857
930
|
- **Topic consolidation guidance** — Emphasized that topics can likely be consolidated to fit the 4-call maximum, with strategies for grouping related subtopics
|
|
858
931
|
- **Package structure** — CHANGELOG.md now included in published package (docs/CHANGELOG.md)
|
|
859
932
|
|
|
860
933
|
## [1.0.7] - 2026-07-14
|
|
861
934
|
|
|
862
935
|
### Fixed
|
|
936
|
+
|
|
863
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.
|
|
864
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.
|
|
865
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.
|
|
@@ -869,6 +943,7 @@ and the host-version self-check gained the tested-ceiling warning.
|
|
|
869
943
|
- **Untitled YouTube video citation line** - Fixed citation formatting for YouTube videos without titles.
|
|
870
944
|
|
|
871
945
|
### Security
|
|
946
|
+
|
|
872
947
|
- Several hardening improvements from the adversarial-review audit:
|
|
873
948
|
- Migration abort safety gates
|
|
874
949
|
- Hollow-run detection gates
|
|
@@ -879,51 +954,61 @@ and the host-version self-check gained the tested-ceiling warning.
|
|
|
879
954
|
## [1.0.6] - 2026-07-04
|
|
880
955
|
|
|
881
956
|
### Fixed
|
|
957
|
+
|
|
882
958
|
- **Observer isolation** - Fixed `makeSafeObserver` proxying the observer directly, which could cause state leakage.
|
|
883
959
|
|
|
884
960
|
### Documentation
|
|
961
|
+
|
|
885
962
|
- Corrected depth-0 availability documentation
|
|
886
963
|
- Fixed migration.ts attribution in architecture docs
|
|
887
964
|
|
|
888
965
|
## [1.0.5] - 2026-07-04
|
|
889
966
|
|
|
890
967
|
### Fixed
|
|
968
|
+
|
|
891
969
|
- **Content-Length pre-screen throws** - Added proper body draining to prevent resource leaks
|
|
892
970
|
- **TTL refresh for transcribed links** - Fixed TTL handling for YouTube transcripts
|
|
893
971
|
|
|
894
972
|
### Fixed
|
|
973
|
+
|
|
895
974
|
- **YouTube transcript provenance** - Fixed transcript provenance tracking in final report citations
|
|
896
975
|
|
|
897
976
|
## [1.0.4] - 2026-07-04
|
|
898
977
|
|
|
899
978
|
### Fixed
|
|
979
|
+
|
|
900
980
|
- **Credential detection** - Detect pi keys by pi's own semantics, not file presence; registry no longer gated on auth.json
|
|
901
981
|
- **Configuration clarity** - Updated docs to reflect that pi has configuration files, not a named 'auth' feature
|
|
902
982
|
|
|
903
983
|
## [1.0.3] - 2026-07-04
|
|
904
984
|
|
|
905
985
|
### Fixed
|
|
986
|
+
|
|
906
987
|
- **Model resolution** - Require a configured model on the standalone CLI/skill; align SDK session model with RESEARCH_MODEL
|
|
907
988
|
- **Configuration requirement** - Added proper error messages when model is not configured
|
|
908
989
|
|
|
909
990
|
### Documentation
|
|
991
|
+
|
|
910
992
|
- Updated installation and configuration documentation
|
|
911
993
|
|
|
912
994
|
## [1.0.2] - 2026-07-04
|
|
913
995
|
|
|
914
996
|
### Fixed
|
|
997
|
+
|
|
915
998
|
- **Timeout robustness** - Improved timeout handling for long-running operations
|
|
916
999
|
- De-emphasized the markdown-conversion fallback in favor of native HTML-to-markdown conversion
|
|
917
1000
|
|
|
918
1001
|
## [1.0.1] - 2026-07-04
|
|
919
1002
|
|
|
920
1003
|
### Fixed
|
|
1004
|
+
|
|
921
1005
|
- Initial stable release after rebuild against current pi APIs
|
|
922
1006
|
- Stealth-browser stack integration
|
|
923
1007
|
|
|
924
1008
|
## [0.1.13] - 2026-04-15
|
|
925
1009
|
|
|
926
1010
|
### Added
|
|
1011
|
+
|
|
927
1012
|
- Initial research functionality
|
|
928
1013
|
- Knowledge store integration
|
|
929
1014
|
- Multi-agent orchestration
|
|
@@ -981,6 +1066,7 @@ this pass with a regression test. None remain open.
|
|
|
981
1066
|
|
|
982
1067
|
Two further items surfaced and closed in the same pass, found via later
|
|
983
1068
|
audit rounds' own deferred lists rather than the v1.0.7 one:
|
|
1069
|
+
|
|
984
1070
|
- **SSRF: `isPrivateIpv6` missed `fec0::/10`** (deprecated site-local) and
|
|
985
1071
|
**`64:ff9b::/96`** (the NAT64 well-known prefix — actively deployed today,
|
|
986
1072
|
and a real bypass vector on a host whose egress traverses a NAT64
|
|
@@ -999,4 +1085,3 @@ audit rounds' own deferred lists rather than the v1.0.7 one:
|
|
|
999
1085
|
|
|
1000
1086
|
One dead-code cleanup: `HealthCheckService` (a superseded, zero-consumer
|
|
1001
1087
|
predecessor of the live `src/healthcheck/registry.ts` system) was removed.
|
|
1002
|
-
|
package/docs/CONFIGURATION.md
CHANGED
|
@@ -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`. |
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@lincoln504/pi-research",
|
|
3
|
-
"version": "1.5.
|
|
3
|
+
"version": "1.5.1",
|
|
4
4
|
"description": "Web research for your agents with smart and safe tooling + knowledge store",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"private": false,
|
|
@@ -158,7 +158,8 @@
|
|
|
158
158
|
"adm-zip": "^0.6.0",
|
|
159
159
|
"jsdom": {
|
|
160
160
|
"undici": "^7.29.0"
|
|
161
|
-
}
|
|
161
|
+
},
|
|
162
|
+
"sharp": "^0.35.0"
|
|
162
163
|
},
|
|
163
164
|
"devDependencies": {
|
|
164
165
|
"@eslint/js": "^10.0.1",
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
name: pi-research
|
|
3
3
|
description: "Multi-agent web research for any coding agent. Use for anything requiring current information from the internet — news, trends, product/service/company facts, statistics, people, places, events, APIs, library behavior, or 'what is X / how does X work' questions. Also use to search a local knowledge store of previously researched findings before doing live research. Powered by the pi-research engine (search, stealth scraping, security databases, Stack Exchange). Not for reading local files, running project commands, or analyzing this repository's own source."
|
|
4
4
|
license: MIT
|
|
5
|
-
metadata: { "author": "Lincoln Deen", "version": "1.5.
|
|
5
|
+
metadata: { "author": "Lincoln Deen", "version": "1.5.1", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
|
|
6
6
|
allowed-tools: Bash(node:*)
|
|
7
7
|
---
|
|
8
8
|
|
package/src/cli.ts
CHANGED
|
@@ -210,7 +210,7 @@ export function makeProgressObserver(): HeadlessObserverOptions {
|
|
|
210
210
|
reportText.includes('None of the links above could be verified')) {
|
|
211
211
|
toStderr(' • WARNING: no web sources could be retrieved — the report is unverified\n');
|
|
212
212
|
} else if (reportText.includes('Grounding notice: only')) {
|
|
213
|
-
toStderr(' • WARNING: weakly grounded report (fewer than 3
|
|
213
|
+
toStderr(' • WARNING: weakly grounded report (fewer than 3 cited sources) — treat unattributed claims as unverified\n');
|
|
214
214
|
}
|
|
215
215
|
break;
|
|
216
216
|
}
|
package/src/config.ts
CHANGED
|
@@ -245,6 +245,11 @@ const USER_MIGRATION_KEYS = [
|
|
|
245
245
|
'PI_RESEARCH_PLANNING_MAX_TOKENS',
|
|
246
246
|
'PI_RESEARCH_SYNTHESIS_MAX_TOKENS',
|
|
247
247
|
'PI_RESEARCH_MIGRATION_STRATEGY',
|
|
248
|
+
'PI_RESEARCH_MAX_FAILED_RESEARCHERS',
|
|
249
|
+
'PI_RESEARCH_YOUTUBE_TRANSCRIPT_MAX_VIDEOS',
|
|
250
|
+
'PI_RESEARCH_YOUTUBE_TRANSCRIPT_TIMEOUT_MS',
|
|
251
|
+
'PI_RESEARCH_YOUTUBE_TRANSCRIPT_LANG',
|
|
252
|
+
'PI_RESEARCH_YOUTUBE_QUERY_EVERY_N',
|
|
248
253
|
'PI_RESEARCH_CONSOLE_LOG',
|
|
249
254
|
'PI_RESEARCH_MODEL',
|
|
250
255
|
'PI_RESEARCH_KNOWLEDGE_DIR',
|
|
@@ -8,6 +8,14 @@ import type { IService } from '../service-registry.ts';
|
|
|
8
8
|
* State manager service interface
|
|
9
9
|
*/
|
|
10
10
|
export interface IStateManager extends IService {
|
|
11
|
+
/**
|
|
12
|
+
* True once a newer-build state file forced this process into read-only mode
|
|
13
|
+
* (writes suppressed) — state-file-based coordination (GPU lock, embedding
|
|
14
|
+
* leader election) is then void and consumers must degrade to a
|
|
15
|
+
* coordination-free mode. Optional so structural test doubles need not
|
|
16
|
+
* implement it; callers use `isReadOnly?.()` (absent ⇒ not read-only).
|
|
17
|
+
*/
|
|
18
|
+
isReadOnly?(): boolean;
|
|
11
19
|
readState(): Promise<any>;
|
|
12
20
|
writeState(state: any): Promise<void>;
|
|
13
21
|
updateState(updater: (state: any) => any | Promise<any>): Promise<void>;
|
|
@@ -648,7 +648,10 @@ export class PlanningService implements IPlanningService {
|
|
|
648
648
|
|
|
649
649
|
// Both lead prompts are deliberately ROUND-INVARIANT: they interpolate only values
|
|
650
650
|
// that are fixed for the whole run (complexity, team size, query budget, the
|
|
651
|
-
// disabled-tool list).
|
|
651
|
+
// disabled-tool list). One deliberate exception: the total-rounds figure inside
|
|
652
|
+
// complexity_guidance moves if steering extends maxRounds mid-run — the evaluator
|
|
653
|
+
// must see the true budget, so that steer costs one cache-prefix invalidation.
|
|
654
|
+
// Everything that changes between rounds — root query, round
|
|
652
655
|
// number, agenda, executed queries, steering, round-phase guidance — is appended to
|
|
653
656
|
// the END of the user message instead (see runContext below).
|
|
654
657
|
//
|
|
@@ -882,7 +885,15 @@ export class PlanningService implements IPlanningService {
|
|
|
882
885
|
return capped;
|
|
883
886
|
}
|
|
884
887
|
|
|
885
|
-
|
|
888
|
+
// A 'wait' plan is NOT stored: it carries no agenda (schema-legal with
|
|
889
|
+
// researchers: []), and storing it clobbered the last real delegate plan
|
|
890
|
+
// that the empty-after-cap fallback above and the retry's previousPlan /
|
|
891
|
+
// initialAgendaSection all read — so a rare 'wait' followed by one
|
|
892
|
+
// unparseable/transient retry degraded to an early empty synthesize
|
|
893
|
+
// instead of continuing the prior agenda.
|
|
894
|
+
if (finalPlan.action !== 'wait') {
|
|
895
|
+
this.currentPlans.set(sessionId, finalPlan);
|
|
896
|
+
}
|
|
886
897
|
return finalPlan;
|
|
887
898
|
} catch (err) {
|
|
888
899
|
// A genuine cancellation must propagate so the orchestrator can abort cleanly.
|
|
@@ -1211,7 +1222,20 @@ function unwrapPartialSynthesis(text: string, service: { parseJsonPlan(t: string
|
|
|
1211
1222
|
return content;
|
|
1212
1223
|
}
|
|
1213
1224
|
} catch {
|
|
1214
|
-
//
|
|
1225
|
+
// parseJsonPlan rejects a `{"content": "..."}` envelope with no "action"
|
|
1226
|
+
// (its non-synthesize guard demands a researchers array) — but that is
|
|
1227
|
+
// still an envelope, not prose, and returning it raw put the braces in the
|
|
1228
|
+
// final report: the exact failure this unwrap exists to prevent. Try the
|
|
1229
|
+
// action-less shape directly before giving up.
|
|
1230
|
+
try {
|
|
1231
|
+
const bare = JSON.parse(trimmed) as { content?: unknown };
|
|
1232
|
+
if (bare && typeof bare === 'object' && typeof bare.content === 'string' && bare.content.trim()) {
|
|
1233
|
+
logger.debug('[PlanningService] Partial synthesis returned an action-less JSON envelope; unwrapped its content');
|
|
1234
|
+
return bare.content.trim();
|
|
1235
|
+
}
|
|
1236
|
+
} catch {
|
|
1237
|
+
// Genuinely not JSON — prose that happens to mention "content". Use as written.
|
|
1238
|
+
}
|
|
1215
1239
|
}
|
|
1216
1240
|
return trimmed;
|
|
1217
1241
|
}
|
|
@@ -243,6 +243,16 @@ export async function initBrowser(): Promise<void> {
|
|
|
243
243
|
firefox_user_prefs: {
|
|
244
244
|
'media.volume_scale': '0.0',
|
|
245
245
|
'media.autoplay.default': 5,
|
|
246
|
+
// SSRF: WebSocket connections bypass BOTH layers of the browser
|
|
247
|
+
// scrape defense — page.route('**') intercepts HTTP(S) only, and
|
|
248
|
+
// the serverAddr() rebinding backstop hangs off page.on('response'),
|
|
249
|
+
// which a WS handshake never fires. A hostile scraped page could
|
|
250
|
+
// therefore `new WebSocket('ws://10.x.x.x/...')` to probe or reach
|
|
251
|
+
// internal hosts with no validation, and write any reply into the
|
|
252
|
+
// DOM that page.content() returns. The scraper extracts static
|
|
253
|
+
// content (domcontentloaded), so nothing it needs arrives over WS —
|
|
254
|
+
// disable the channel outright at the engine level.
|
|
255
|
+
'network.websocket.max-connections': 0,
|
|
246
256
|
},
|
|
247
257
|
});
|
|
248
258
|
// Guard with a hard timeout. We catch the launchPromise rejection so it
|
|
@@ -241,8 +241,14 @@ async function extractSearchResults(page: any): Promise<any[]> {
|
|
|
241
241
|
let url = link.href;
|
|
242
242
|
try {
|
|
243
243
|
const u = new URL(url);
|
|
244
|
+
// searchParams.get() already percent-decodes the redirect target once.
|
|
245
|
+
// A second decodeURIComponent here double-decoded targets whose
|
|
246
|
+
// canonical form itself contains escapes (%26/%3F in query values,
|
|
247
|
+
// %2F path slashes, %27 in titles) — structurally corrupting them so
|
|
248
|
+
// the later scrape fetched the wrong resource — and threw URIError on
|
|
249
|
+
// a lone `%`, silently dropping the result via the hostname filter.
|
|
244
250
|
const uddg = u.searchParams.get('uddg');
|
|
245
|
-
if (uddg) url =
|
|
251
|
+
if (uddg) url = uddg;
|
|
246
252
|
} catch {
|
|
247
253
|
// ignore
|
|
248
254
|
}
|
|
@@ -616,7 +622,14 @@ export async function executeScrapeTask(
|
|
|
616
622
|
// extracting the document — a silent content loss for exactly the URLs this
|
|
617
623
|
// feature advertises as supported, and specifically for the malformed/oddly-
|
|
618
624
|
// served PDFs most likely to reach this browser fallback in the first place.
|
|
619
|
-
|
|
625
|
+
// response.url(), NOT the original task `url`: goto() follows redirects, so
|
|
626
|
+
// classification must use the URL that actually answered — a download
|
|
627
|
+
// endpoint without `.pdf` redirecting to the real `.pdf` file must take the
|
|
628
|
+
// PDF branch, and a `.pdf` task URL redirecting to an HTML page must NOT
|
|
629
|
+
// (same two-sided redirect-aware fix the fetch layer got via currentUrl,
|
|
630
|
+
// and the early-abort check above already applies via resp.url()).
|
|
631
|
+
const finalUrl = String(response?.url?.() ?? url);
|
|
632
|
+
if (contentType.includes('application/pdf') || finalUrl.toLowerCase().endsWith('.pdf')) {
|
|
620
633
|
if (!response) throw new Error(`[Worker] No response received for PDF URL: ${url}`);
|
|
621
634
|
// An errored "PDF" is an error page: fail BEFORE buffering its body.
|
|
622
635
|
if (status >= 400) throw new Error(`HTTP ${status}`);
|