@lincoln504/pi-research 1.0.6 → 1.0.9

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.
@@ -85,6 +85,7 @@ The coordinator will plan as many researchers as needed (up to the max). You do
85
85
 
86
86
  **Multiple Topics:**
87
87
  - If you need to research multiple unrelated topics of **truly distinct scope**, you may emit multiple `research` calls **simultaneously in a single turn**. Each call gets its own isolated research run with its own coordinator and researchers.
88
+ - **Maximum 4 simultaneous calls** — Do NOT make more than 4 `research` calls in a single turn, even if you have more unrelated topics. Exceeding 4 simultaneous research runs can overload the system and degrade performance. If you have more than 4 truly distinct topics, they can likely be consolidated into fewer calls (group related subtopics, use broader queries, or batch across multiple turns).
88
89
  - Example: `research("bananas")` and `research("quantum computing")` in the same turn — these have zero overlap.
89
90
  - **Group related topics**: If topics share ANY scope or could inform each other, combine them into a single `research` call. The internal coordinator will decompose them into parallel researcher agents. Do NOT split related sub-topics into separate calls.
90
91
  - Example: `research("Python async performance and Rust async performance comparison")` — NOT separate calls for Python and Rust, since the comparison IS the research goal.
@@ -2219,6 +2219,8 @@ var ConfigSchema = Type.Object({
2219
2219
  RESEARCHER_MAX_RETRIES: Type.Number({ minimum: 0, maximum: 5, default: 2 }),
2220
2220
  /** Base delay between retries in milliseconds (default: 2000, range: 100-10000) */
2221
2221
  RESEARCHER_MAX_RETRY_DELAY_MS: Type.Number({ minimum: 100, maximum: 1e4, default: 2e3 }),
2222
+ /** Unique researcher failures that abort the entire run (default: 2, range: 1-10) */
2223
+ MAX_FAILED_RESEARCHERS: Type.Number({ minimum: 1, maximum: 10, default: 2 }),
2222
2224
  /** Target depth for recursive research (default: 1, range: 1-3) */
2223
2225
  DEFAULT_RESEARCH_DEPTH: Type.Number({ minimum: 1, maximum: 3, default: 1 }),
2224
2226
  /** Number of batches to allow for a single scrape tool call (default: 2, 0=unlimited) */
@@ -2276,9 +2278,13 @@ var ConfigSchema = Type.Object({
2276
2278
  * never killed before it has used its full nav budget plus time waiting in the queue.
2277
2279
  * (default: 10000) */
2278
2280
  BROWSER_TASK_TIMEOUT_MS: Type.Number({ minimum: 2e3, maximum: 12e4, default: 1e4 }),
2279
- /** Timeout for coordinator/evaluator/repair/knowledge LLM calls in ms (default: 300000 = 5 min, range: 60s-600s).
2280
- * Not exposed in TUI — controlled via PI_RESEARCH_LLM_TIMEOUT_MS env var. */
2281
- LLM_TIMEOUT_MS: Type.Number({ minimum: 6e4, maximum: 6e5, default: 3e5 }),
2281
+ /** Timeout for coordinator/evaluator/repair/knowledge LLM calls in ms (default: 300000 = 5 min, range: 60s-30min).
2282
+ * Not exposed in TUI — controlled via PI_RESEARCH_LLM_TIMEOUT_MS env var.
2283
+ * Ceiling raised to 30min (matching RESEARCHER_TIMEOUT_MS): a slow model on a
2284
+ * large-context synthesis/plan call can legitimately run >10min, and the old
2285
+ * 600000 ceiling silently clamped an explicit PI_RESEARCH_LLM_TIMEOUT_MS
2286
+ * (e.g. 900000), causing those calls to time out mid-generation. */
2287
+ LLM_TIMEOUT_MS: Type.Number({ minimum: 6e4, maximum: 18e5, default: 3e5 }),
2282
2288
  /** Chain-of-thought "thinking" level for the engine's own LLM calls (coordinator,
2283
2289
  * evaluator, synthesis, JSON-repair, knowledge extraction) AND the researcher
2284
2290
  * sub-agents. Default 'off': these calls emit structured JSON / cited reports, not
@@ -84,10 +84,23 @@ Two conventions apply:
84
84
  report. A mid-round evaluation that cannot be parsed continues the existing agenda
85
85
  rather than finalizing early, so a parse failure never truncates a run.
86
86
 
87
- ### Research tools
87
+ ### Tool inventory
88
88
 
89
- Each researcher has a fixed tool set and a shared budget of 12 gathering calls per phase
90
- (`MAX_GATHERING_CALLS`).
89
+ This is the canonical list of every tool the system exposes, on both surfaces.
90
+
91
+ **Host-facing tools** — registered with the pi session (`src/index.ts`) for the calling
92
+ agent to invoke:
93
+
94
+ | Tool | Purpose |
95
+ |------|---------|
96
+ | `research` | Run a full multi-source research session and return the cited Markdown report |
97
+ | `research_knowledge_search` | Instant local search of the knowledge store — checked before live research; always registered, reports why when the store is disabled |
98
+ | `health` | Verify system status (browser pool, knowledge store, GPU lock); optional liveness probe |
99
+
100
+ **Researcher-agent tools** — the fixed set each researcher sub-agent works with
101
+ (`src/tools/index.ts`). `search`, `security_search`, `stackexchange`, and
102
+ `youtube_transcript` share a budget of 12 gathering calls per phase
103
+ (`MAX_GATHERING_CALLS`); `scrape` and local `grep` have their own budgets:
91
104
 
92
105
  | Tool | Quick | Deep | Backend |
93
106
  |------|-------|------|---------|
@@ -102,7 +115,9 @@ Each researcher has a fixed tool set and a shared budget of 12 gathering calls p
102
115
  In deep research `search` is excluded — the coordinator runs the search burst and hands
103
116
  out URLs directly. In quick research `grep` is excluded — the single session is not
104
117
  expected to traverse a local codebase. Researchers cannot write files, run shell
105
- commands, or reach the network outside these tools.
118
+ commands, or reach the network outside these tools. Individual researcher tools can be
119
+ disabled per run with `PI_RESEARCH_DISABLED_TOOLS`
120
+ (see [CONFIGURATION.md](CONFIGURATION.md)).
106
121
 
107
122
  ### Browser infrastructure
108
123
 
@@ -0,0 +1,129 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project will be documented in this file.
4
+
5
+ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
+ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
+
8
+ ## [1.0.9] - 2026-07-28
9
+
10
+ ### Fixed
11
+ - **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.
12
+ - **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.
13
+ - **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.
14
+ - **Explicit-API-key pre-flight hang** — seeding a runtime API key triggered a model-catalog refresh whose default options open live network connections with no timeout; in network-restricted environments the CLI pre-flight hung indefinitely. The refresh is now pinned offline (`allowNetwork: false`).
15
+ - **No filesystem side effects on read-only commands** — the migrated runtime's default file-backed credential store created `~/.pi/agent/auth.json` at construction, so every CLI invocation (including `status`) silently created a pi config dir on machines without pi and hard-failed where `$HOME` is read-only. An in-memory credential store is used when no `auth.json` exists; env-var provider keys are unaffected.
16
+ - **Search-timeout cascade under concurrent load** — the per-query hard cap in browser search started at enqueue with only the nav budget, so a query stuck in a saturated worker queue burned its whole budget waiting and aborted with zero work ("Query timed out … likely blocked or slow startup"). The cap now includes the queue-wait margin, matching the scheduler's own ceiling.
17
+ - **`PI_RESEARCH_LLM_TIMEOUT_MS` ceiling raised 10 → 30 min** — an explicit higher value was silently clamped to 600000, timing out large-context synthesis calls mid-generation.
18
+ - **CHANGELOG dates corrected** — release dates in this file wrongly said 2024; corrected to the actual git release dates (2026).
19
+
20
+ ### Security
21
+ - **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.
22
+ - **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.
23
+ - **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).
24
+
25
+ ### Changed
26
+ - **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).
27
+ - **Docs**: `PI_RESEARCH_LLM_TIMEOUT_MS` range updated in CONFIGURATION.md.
28
+
29
+ ## [1.0.8] - 2026-07-17
30
+
31
+ ### Added
32
+ - **CHANGELOG** — Added comprehensive CHANGELOG.md documenting all versions from 0.1.13 through 1.0.7
33
+ - **System prompt constraint** — Added "Maximum 4 simultaneous calls" constraint to research-tool-usage prompt to prevent system overload
34
+
35
+ ### Changed
36
+ - **Topic consolidation guidance** — Emphasized that topics can likely be consolidated to fit the 4-call maximum, with strategies for grouping related subtopics
37
+ - **Package structure** — CHANGELOG.md now included in published package (docs/CHANGELOG.md)
38
+
39
+ ## [1.0.7] - 2026-07-14
40
+
41
+ ### Fixed
42
+ - **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.
43
+ - **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.
44
+ - **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.
45
+ - **Unsurfaced quality signals** - Added per-run error tracking and per-researcher grounding signals to the log output for better debugging of report quality variance.
46
+ - **SIGINT/SIGTERM cancellation wiring** - Improved cancellation handling for graceful shutdown on interrupt signals.
47
+ - **Multi-researcher citation-provenance folding** - Fixed citation provenance tracking when multiple researchers contribute to a report.
48
+ - **Untitled YouTube video citation line** - Fixed citation formatting for YouTube videos without titles.
49
+
50
+ ### Security
51
+ - Several hardening improvements from the adversarial-review audit:
52
+ - Migration abort safety gates
53
+ - Hollow-run detection gates
54
+ - Streaming caps for large responses
55
+ - Socket drain on Content-Length pre-screen throws
56
+ - Observer isolation improvements
57
+
58
+ ## [1.0.6] - 2026-07-04
59
+
60
+ ### Fixed
61
+ - **Observer isolation** - Fixed `makeSafeObserver` proxying the observer directly, which could cause state leakage.
62
+
63
+ ### Documentation
64
+ - Corrected depth-0 availability documentation
65
+ - Fixed migration.ts attribution in architecture docs
66
+
67
+ ## [1.0.5] - 2026-07-04
68
+
69
+ ### Fixed
70
+ - **Content-Length pre-screen throws** - Added proper body draining to prevent resource leaks
71
+ - **TTL refresh for transcribed links** - Fixed TTL handling for YouTube transcripts
72
+
73
+ ### Fixed
74
+ - **YouTube transcript provenance** - Fixed transcript provenance tracking in final report citations
75
+
76
+ ## [1.0.4] - 2026-07-04
77
+
78
+ ### Fixed
79
+ - **Credential detection** - Detect pi keys by pi's own semantics, not file presence; registry no longer gated on auth.json
80
+ - **Configuration clarity** - Updated docs to reflect that pi has configuration files, not a named 'auth' feature
81
+
82
+ ## [1.0.3] - 2026-07-04
83
+
84
+ ### Fixed
85
+ - **Model resolution** - Require a configured model on the standalone CLI/skill; align SDK session model with RESEARCH_MODEL
86
+ - **Configuration requirement** - Added proper error messages when model is not configured
87
+
88
+ ### Documentation
89
+ - Updated installation and configuration documentation
90
+
91
+ ## [1.0.2] - 2026-07-04
92
+
93
+ ### Fixed
94
+ - **Timeout robustness** - Improved timeout handling for long-running operations
95
+ - De-emphasized the markdown-conversion fallback in favor of native HTML-to-markdown conversion
96
+
97
+ ## [1.0.1] - 2026-07-04
98
+
99
+ ### Fixed
100
+ - Initial stable release after rebuild against current pi APIs
101
+ - Stealth-browser stack integration
102
+
103
+ ## [0.1.13] - 2026-04-15
104
+
105
+ ### Added
106
+ - Initial research functionality
107
+ - Knowledge store integration
108
+ - Multi-agent orchestration
109
+
110
+ ---
111
+
112
+ ## Deferred Findings (from v1.0.7 audit)
113
+
114
+ The following issues were identified during the v1.0.7 audit but intentionally deferred to future releases:
115
+
116
+ 1. **KnowledgeStore.close() timeout behavior** - The close() method proceeds even when the pending-write wait times out. A correct fix requires threading cancellation tokens through write paths or documenting the 10s timeout as intended behavior.
117
+
118
+ 2. **Cross-session teardown via shared singletons** - Session shutdown handlers run process-wide cleanup for reload/new/fork events. The correct behavior depends on pi's session model semantics and needs further investigation.
119
+
120
+ 3. **Lower-severity hardening items** - Various low-confidence or low-impact findings noted for future investigation:
121
+ - SIGINT-adjacent cancellation race
122
+ - Knowledge-store migration vs. concurrent access
123
+ - Global activity gate reused for per-session decisions
124
+ - Zombie queued browser tasks after timeout
125
+ - Non-atomic `clearBrowserServer`
126
+ - Inconsistent `knowledge --json` payload shapes
127
+ - Backward-clock-step tolerance across multiple rate limiters
128
+
129
+ See `DEFERRED-FINDINGS-2026-07-14.md` for full details.
@@ -73,6 +73,7 @@ Research
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. |
75
75
  | `PI_RESEARCH_RETRY_DELAY_MS` | `2000` | 100–10000 | Base delay between retries. |
76
+ | `PI_RESEARCH_MAX_FAILED_RESEARCHERS` | `2` | 1–10 | Unique researcher failures that abort the whole run. Raise to let slower, still-in-flight researchers finish before giving up. |
76
77
  | `PI_RESEARCH_WORKER_THREADS` | `4` | 1–10 | Browser worker processes. Higher = more throughput, more CPU/RAM. |
77
78
  | `PI_RESEARCH_WORKER_CONCURRENCY` | `2` | 1–10 | Tasks per worker process. |
78
79
  | `PI_RESEARCH_MODEL` | _(pi: session model; CLI/skill: required)_ | — | The model research runs on. **Required for the standalone CLI / agent skill** — they use only this configured model (never the model selected inside the pi extension) and refuse to start without one (the CLI's per-run `--model` flag also satisfies this). On the SDK it selects the session model when no `model` option is given. In the pi extension it overrides researcher sub-agents and knowledge synthesis, while the coordinator and evaluator keep using the session model. Accepts `provider/id` or a bare model id. |
@@ -96,7 +97,7 @@ Timeouts
96
97
 
97
98
  | Variable | Default | Range | Description |
98
99
  |----------|---------|-------|-------------|
99
- | `PI_RESEARCH_LLM_TIMEOUT_MS` | `300000` | 60000–600000 | Coordinator / evaluator / repair / knowledge LLM call timeout. |
100
+ | `PI_RESEARCH_LLM_TIMEOUT_MS` | `300000` | 60000–1800000 | Coordinator / evaluator / repair / knowledge LLM call timeout. |
100
101
  | `PI_RESEARCH_SCRAPE_TIMEOUT_MS` | `15000` | 5000–120000 | Per-page scrape (page-load) timeout. |
101
102
  | `PI_RESEARCH_SEARCH_TIMEOUT_MS` | `45000` | 5000–120000 | Browser search page timeout. |
102
103
  | `PI_RESEARCH_BROWSER_TASK_TIMEOUT_MS` | `10000` | 2000–120000 | Queue-wait / overhead margin added to each browser op's own timeout (a search task ceiling is `SEARCH_TIMEOUT_MS` + this; a scrape is `SCRAPE_TIMEOUT_MS` + this). |
package/docs/SDK.md CHANGED
@@ -76,7 +76,10 @@ await shutdownResearchSDK();
76
76
  `runResearchDetailed`, `searchKnowledge`, `scrapeUrl`, `getResearchHealth`,
77
77
  `getLastRunStats`, and `getSessionMetrics`, plus `exportKnowledge` (write the
78
78
  knowledge store to a web-consumable JSON file) and the post-run telemetry accessors
79
- `getLastRunMetrics`, `getLastRunSummary`, and `getLastErrorReport`. Both
79
+ `getLastRunMetrics`, `getLastRunSummary`, `getLastErrorReport`, and
80
+ `getLastResearcherOutcome` (planned/launched/succeeded/failed researcher counts plus
81
+ per-researcher failure reasons for the most recent run — lets a caller tell a thin
82
+ report caused by a sparse topic apart from one where most researchers failed). Both
80
83
  `@lincoln504/pi-research` and `@lincoln504/pi-research/sdk` export these symbols.
81
84
 
82
85
  > Concurrency: a single initialized SDK instance runs one research call at a time.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincoln504/pi-research",
3
- "version": "1.0.6",
3
+ "version": "1.0.9",
4
4
  "description": "A set of web research tools (search, scrape, and others) and research agent system that uses them safely and efficiently.",
5
5
  "type": "module",
6
6
  "private": false,
@@ -37,7 +37,8 @@
37
37
  "scripts/prepare.cjs",
38
38
  "LICENSE",
39
39
  "docs/THIRD-PARTY-NOTICES.md",
40
- "README.md"
40
+ "README.md",
41
+ "docs/CHANGELOG.md"
41
42
  ],
42
43
  "repository": {
43
44
  "type": "git",
@@ -109,9 +110,9 @@
109
110
  "image": "https://raw.githubusercontent.com/Lincoln504/pi-research/main/docs/media/hero.png"
110
111
  },
111
112
  "dependencies": {
112
- "@earendil-works/pi-ai": ">=0.80.2 <1",
113
- "@earendil-works/pi-coding-agent": ">=0.80.2 <1",
114
- "@earendil-works/pi-tui": ">=0.80.2 <1",
113
+ "@earendil-works/pi-ai": ">=0.80.8 <1",
114
+ "@earendil-works/pi-coding-agent": ">=0.80.8 <1",
115
+ "@earendil-works/pi-tui": ">=0.80.8 <1",
115
116
  "@huggingface/transformers": "4.2.0",
116
117
  "@kreuzberg/html-to-markdown-node": "^3.5.5",
117
118
  "@lancedb/lancedb": "0.29.0",
@@ -122,11 +123,11 @@
122
123
  "impit": "0.13.0",
123
124
  "jsdom": "^29.1.1",
124
125
  "node-html-markdown": "^2.0.0",
125
- "pdf-oxide-wasm": "^0.3.70",
126
+ "pdf-oxide-wasm": "^0.3.75",
126
127
  "playwright-core": "1.60.0",
127
128
  "poolifier": "^5.3.2",
128
129
  "typebox": "1.1.38",
129
- "undici": "^8.6.0",
130
+ "undici": "^8.9.0",
130
131
  "youtubei.js": "^17.2.0"
131
132
  },
132
133
  "optionalDependencies": {
@@ -139,20 +140,21 @@
139
140
  "onnxruntime-web": "1.24.3",
140
141
  "node-domexception": "npm:@nolyfill/domexception@^1.0.28",
141
142
  "ua-parser-js": "^1.0.41",
142
- "protobufjs": "7.6.4",
143
- "vite": "^8.0.16"
143
+ "protobufjs": "7.6.5",
144
+ "vite": "^8.0.16",
145
+ "adm-zip": "^0.6.0"
144
146
  },
145
147
  "devDependencies": {
146
148
  "@eslint/js": "^10.0.1",
147
149
  "@types/jsdom": "^28.0.3",
148
- "@typescript-eslint/eslint-plugin": "^8.60.0",
149
- "@typescript-eslint/parser": "^8.60.0",
150
- "@vitest/coverage-v8": "^4.1.7",
151
- "dependency-cruiser": "^17.4.2",
150
+ "@typescript-eslint/eslint-plugin": "^8.65.0",
151
+ "@typescript-eslint/parser": "^8.65.0",
152
+ "@vitest/coverage-v8": "^4.1.10",
153
+ "dependency-cruiser": "^17.4.3",
152
154
  "depngn": "^0.6.0",
153
- "eslint": "^10.4.0",
154
- "npm-check-updates": "^22.2.1",
155
+ "eslint": "^10.8.0",
156
+ "npm-check-updates": "^22.2.9",
155
157
  "typescript": ">=6.0.3 <6.1.0",
156
- "vitest": "^4.1.7"
158
+ "vitest": "^4.1.10"
157
159
  }
158
160
  }
@@ -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.0.6", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
5
+ metadata: { "author": "Lincoln Deen", "version": "1.0.9", "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
@@ -31,9 +31,12 @@ import {
31
31
  runDeepResearch,
32
32
  searchKnowledge,
33
33
  shutdownResearchSDK,
34
+ getLastErrorReport,
35
+ getLastResearcherOutcome,
34
36
  type KnowledgeSearchResult,
35
37
  } from './sdk.ts';
36
38
  import type { HeadlessObserverOptions } from './orchestration/headless-observer.ts';
39
+ import { RESEARCH_STOPPED_ERROR_CODE } from './orchestration/session-state.ts';
37
40
  import { validateAndSanitizeQuery } from './utils/input-validation.ts';
38
41
  import { validateInitialLink, MAX_INITIAL_LINKS } from './utils/url-utils.ts';
39
42
  import { exportResearchReport, appendExportMessage } from './utils/research-export.ts';
@@ -237,9 +240,9 @@ function fileExists(p: string): boolean {
237
240
  * OPENAI_API_KEY. File presence alone is neither necessary (keys can live in
238
241
  * models.json / env with no auth.json) nor sufficient (auth.json can be `{}`).
239
242
  */
240
- function piKeysAvailable(piAuthPresent: boolean, provider?: string): boolean {
243
+ async function piKeysAvailable(piAuthPresent: boolean, provider?: string): Promise<boolean> {
241
244
  try {
242
- const available = safeGetAvailable(buildModelRegistry(undefined, undefined));
245
+ const available = safeGetAvailable(await buildModelRegistry(undefined, undefined));
243
246
  if (!provider) return available.length > 0;
244
247
  // Provider known (from the configured provider/model-id): keys must exist
245
248
  // for THAT provider — a key for some other provider would pass pre-flight
@@ -279,7 +282,7 @@ export interface CredentialDetection {
279
282
  * blocks a valid `--model` run). An explicit PI_RESEARCH_PROVIDER still wins:
280
283
  * it names the provider the configured key belongs to.
281
284
  */
282
- function detectCredentials(explicitModel?: string): CredentialDetection {
285
+ async function detectCredentials(explicitModel?: string): Promise<CredentialDetection> {
283
286
  const paths = resolvedConfigPaths();
284
287
  const piAuthPresent = fileExists(paths.piAuth);
285
288
  const piModelsPresent = fileExists(paths.piModels);
@@ -296,7 +299,7 @@ function detectCredentials(explicitModel?: string): CredentialDetection {
296
299
  providerEnv ?? (model && model.includes('/') ? model.slice(0, model.indexOf('/')) : undefined);
297
300
  // Provider-aware when the configured model names one: a key for a DIFFERENT
298
301
  // provider must not greenlight this run.
299
- const piKeysUsable = piKeysAvailable(piAuthPresent, provider);
302
+ const piKeysUsable = await piKeysAvailable(piAuthPresent, provider);
300
303
 
301
304
  // --- Explicit API-key path -------------------------------------------------
302
305
  if (apiKey) {
@@ -437,7 +440,7 @@ interface ResearchArgs {
437
440
  async function cmdResearch(args: ResearchArgs): Promise<number> {
438
441
  // Pre-flight with the per-run --model flag folded in: it satisfies the
439
442
  // model-required check and steers the provider-aware key detection.
440
- const det = detectCredentials(args.model);
443
+ const det = await detectCredentials(args.model);
441
444
  if (det.problem) {
442
445
  if (args.json) toStdout(pretty({ ok: false, error: det.problem, exitCode: EXIT.CONFIG }));
443
446
  else toStderr(`\nError: ${det.problem}\n\n${configBlock(det)}\n`);
@@ -468,6 +471,11 @@ async function cmdResearch(args: ResearchArgs): Promise<number> {
468
471
  const observer: HeadlessObserverOptions = makeProgressObserver();
469
472
 
470
473
  let exit: number = EXIT.OK;
474
+ // Let Ctrl-C/SIGTERM (main()'s onSignal) cancel THIS run's orchestrator instead of
475
+ // only tearing down shared SDK services out from under it — see
476
+ // activeResearchAbortController's declaration for why.
477
+ const abortController = new AbortController();
478
+ activeResearchAbortController = abortController;
471
479
  try {
472
480
  await initResearchSDK({
473
481
  model: runModel,
@@ -485,7 +493,7 @@ async function cmdResearch(args: ResearchArgs): Promise<number> {
485
493
  observer,
486
494
  ...(args.excludeTools ? { excludeTools: args.excludeTools } : {}),
487
495
  ...(args.initialLinks ? { initialLinks: args.initialLinks } : {}),
488
- });
496
+ }, abortController.signal);
489
497
 
490
498
  // Optionally persist the report to a file (opt-in via
491
499
  // PI_RESEARCH_REPORT_EXPORT_ENABLED). The SDK is a pure library and does not
@@ -503,13 +511,29 @@ async function cmdResearch(args: ResearchArgs): Promise<number> {
503
511
  }
504
512
 
505
513
  if (args.json) {
506
- toStdout(pretty({ ok: true, depth, report, ...(reportPath ? { reportPath } : {}) }));
514
+ // Surface the internal quality/health signals that were previously log-only
515
+ // (ISSUES-ANALYSIS-2026-07-09.md, Symptom 4): a caller reading only stdout/JSON
516
+ // had no way to distinguish "thin report, little material out there" from "thin
517
+ // report because most researchers died mid-run" without opening the log file.
518
+ const outcome = getLastResearcherOutcome();
519
+ const errorReport = getLastErrorReport();
520
+ toStdout(pretty({
521
+ ok: true,
522
+ depth,
523
+ report,
524
+ ...(reportPath ? { reportPath } : {}),
525
+ ...(outcome ? { researchers: { planned: outcome.planned, launched: outcome.launched, succeeded: outcome.succeeded, failed: outcome.failed } } : {}),
526
+ ...(errorReport && errorReport.totalErrors > 0 ? {
527
+ trackedErrors: { total: errorReport.totalErrors, uniquePatterns: errorReport.uniquePatterns },
528
+ } : {}),
529
+ }));
507
530
  } else {
508
531
  toStdout(report.endsWith('\n') ? report : report + '\n');
509
532
  }
510
533
  } catch (err) {
511
- exit = reportError(err, 'research', args.json);
534
+ exit = await reportError(err, 'research', args.json);
512
535
  } finally {
536
+ if (activeResearchAbortController === abortController) activeResearchAbortController = null;
513
537
  await safeShutdown();
514
538
  }
515
539
  return exit;
@@ -521,7 +545,7 @@ async function cmdResearch(args: ResearchArgs): Promise<number> {
521
545
  * research.
522
546
  */
523
547
  async function cmdKnowledge(queries: string[], json?: boolean): Promise<number> {
524
- const det = detectCredentials();
548
+ const det = await detectCredentials();
525
549
  if (det.problem) {
526
550
  if (json) { toStdout(pretty({ ok: false, error: det.problem, exitCode: EXIT.CONFIG })); return EXIT.CONFIG; }
527
551
  toStderr(`\nError: ${det.problem}\n\n${configBlock(det)}\n`);
@@ -571,7 +595,7 @@ async function cmdKnowledge(queries: string[], json?: boolean): Promise<number>
571
595
  * run, without initializing the SDK. Machine-readable with --json.
572
596
  */
573
597
  async function cmdStatus(json?: boolean): Promise<number> {
574
- const det = detectCredentials();
598
+ const det = await detectCredentials();
575
599
  const paths = resolvedConfigPaths();
576
600
  const cfg = getConfig(process.cwd(), 'cli');
577
601
  const summary = {
@@ -791,12 +815,23 @@ function describeUninstall(r: { status: string; path: string; message?: string }
791
815
  // ---------------------------------------------------------------------------
792
816
 
793
817
  /** Distinguish setup errors from runtime errors and print a clean, located message. */
794
- function reportError(err: unknown, what: string, json?: boolean): number {
818
+ export async function reportError(err: unknown, what: string, json?: boolean): Promise<number> {
795
819
  const msg = err instanceof Error ? err.message : String(err);
796
820
  const lower = msg.toLowerCase();
797
821
 
822
+ // A tagged research-stop error (session-state.ts's createResearchStopError) is an
823
+ // orchestration/infrastructure failure (worker-pool contention, ungrounded
824
+ // researchers, etc.), never a credentials problem — classify it as SOFTWARE
825
+ // unconditionally and skip the substring heuristic below entirely. Without this,
826
+ // the boilerplate advice line every research-stop message carries ("Verify the
827
+ // model is available and the API key / context settings are valid.") contains the
828
+ // literal substring "api key," so the heuristic alone would misclassify EVERY
829
+ // research-stop — regardless of real cause — as a config error (exit 78).
830
+ const isTaggedResearchStop = (err as { code?: unknown } | undefined)?.code === RESEARCH_STOPPED_ERROR_CODE;
831
+
798
832
  // Auth / model / config-shaped errors → CONFIG exit + config block.
799
833
  const isConfigError =
834
+ !isTaggedResearchStop && (
800
835
  lower.includes('no llm model available') ||
801
836
  lower.includes('not found in pi') ||
802
837
  lower.includes('no api key') ||
@@ -804,7 +839,7 @@ function reportError(err: unknown, what: string, json?: boolean): number {
804
839
  lower.includes('invalid model string') ||
805
840
  lower.includes('authentication') ||
806
841
  lower.includes('unauthorized') ||
807
- lower.includes('api key');
842
+ lower.includes('api key'));
808
843
 
809
844
  // Rate limits win over config-shaped matches: a provider 429 often mentions
810
845
  // "api key" in its body (e.g. "rate limit exceeded for this api key"), and
@@ -830,7 +865,7 @@ function reportError(err: unknown, what: string, json?: boolean): number {
830
865
  return exitCode;
831
866
  }
832
867
  if (isConfigError) {
833
- toStderr(`\nError: pi-research ${what} failed: ${msg}\n\n${configBlock(detectCredentials())}\n`);
868
+ toStderr(`\nError: pi-research ${what} failed: ${msg}\n\n${configBlock(await detectCredentials())}\n`);
834
869
  return exitCode;
835
870
  }
836
871
  toStderr(`\nError: pi-research ${what} failed: ${msg}\n`);
@@ -1175,6 +1210,15 @@ const flushAndExit = (code: number): void => {
1175
1210
  });
1176
1211
  };
1177
1212
 
1213
+ // Signal to the in-flight `research` run's AbortController, if any is active.
1214
+ // Without this, Ctrl-C only tore down shared SDK services (browser pool, LLM
1215
+ // clients, LanceDB) via safeShutdown() while runDeepResearch() was still using
1216
+ // them — the orchestrator never saw a cancellation and had no chance to return
1217
+ // its already-wired partial/fallback synthesis for a clean stop. Set at the
1218
+ // start of cmdResearch, cleared in its finally so a stale reference never
1219
+ // outlives that one run.
1220
+ let activeResearchAbortController: AbortController | null = null;
1221
+
1178
1222
  async function main(argv: string[]): Promise<number> {
1179
1223
  // Register signal handlers so SIGINT/SIGTERM always trigger SDK shutdown
1180
1224
  // before the process exits. safeShutdown() is idempotent — safe to call
@@ -1184,6 +1228,11 @@ async function main(argv: string[]): Promise<number> {
1184
1228
  if (_signalCleanupDone) return;
1185
1229
  _signalCleanupDone = true;
1186
1230
  toStderr(`\n[pi-research] ${sig} — cleaning up…\n`);
1231
+ // Abort the in-flight research run FIRST (synchronously ahead of the shutdown
1232
+ // teardown below) so the orchestrator's existing signal-aware cancellation path
1233
+ // gets a chance to return a partial/fallback synthesis instead of racing a live
1234
+ // browser-pool/LLM teardown out from under it.
1235
+ activeResearchAbortController?.abort();
1187
1236
  // Fire-and-forget: we can't await here, but safeShutdown swallows errors
1188
1237
  // and the finally blocks in each command handler also call it. Drain via
1189
1238
  // flushAndExit (not a bare process.exit) so buffered stdout isn't truncated.
package/src/config.ts CHANGED
@@ -28,6 +28,8 @@ export const ConfigSchema = Type.Object({
28
28
  RESEARCHER_MAX_RETRIES: Type.Number({ minimum: 0, maximum: 5, default: 2 }),
29
29
  /** Base delay between retries in milliseconds (default: 2000, range: 100-10000) */
30
30
  RESEARCHER_MAX_RETRY_DELAY_MS: Type.Number({ minimum: 100, maximum: 10000, default: 2000 }),
31
+ /** Unique researcher failures that abort the entire run (default: 2, range: 1-10) */
32
+ MAX_FAILED_RESEARCHERS: Type.Number({ minimum: 1, maximum: 10, default: 2 }),
31
33
  /** Target depth for recursive research (default: 1, range: 1-3) */
32
34
  DEFAULT_RESEARCH_DEPTH: Type.Number({ minimum: 1, maximum: 3, default: 1 }),
33
35
  /** Number of batches to allow for a single scrape tool call (default: 2, 0=unlimited) */
@@ -85,9 +87,13 @@ export const ConfigSchema = Type.Object({
85
87
  * never killed before it has used its full nav budget plus time waiting in the queue.
86
88
  * (default: 10000) */
87
89
  BROWSER_TASK_TIMEOUT_MS: Type.Number({ minimum: 2000, maximum: 120000, default: 10000 }),
88
- /** Timeout for coordinator/evaluator/repair/knowledge LLM calls in ms (default: 300000 = 5 min, range: 60s-600s).
89
- * Not exposed in TUI — controlled via PI_RESEARCH_LLM_TIMEOUT_MS env var. */
90
- LLM_TIMEOUT_MS: Type.Number({ minimum: 60000, maximum: 600000, default: 300000 }),
90
+ /** Timeout for coordinator/evaluator/repair/knowledge LLM calls in ms (default: 300000 = 5 min, range: 60s-30min).
91
+ * Not exposed in TUI — controlled via PI_RESEARCH_LLM_TIMEOUT_MS env var.
92
+ * Ceiling raised to 30min (matching RESEARCHER_TIMEOUT_MS): a slow model on a
93
+ * large-context synthesis/plan call can legitimately run >10min, and the old
94
+ * 600000 ceiling silently clamped an explicit PI_RESEARCH_LLM_TIMEOUT_MS
95
+ * (e.g. 900000), causing those calls to time out mid-generation. */
96
+ LLM_TIMEOUT_MS: Type.Number({ minimum: 60000, maximum: 1800000, default: 300000 }),
91
97
  /** Chain-of-thought "thinking" level for the engine's own LLM calls (coordinator,
92
98
  * evaluator, synthesis, JSON-repair, knowledge extraction) AND the researcher
93
99
  * sub-agents. Default 'off': these calls emit structured JSON / cited reports, not
@@ -690,6 +696,7 @@ export function saveConfig(config: Config, scope: 'local' | 'user' = 'local', cw
690
696
  PI_RESEARCH_MAX_RESEARCHERS: String(config.MAX_CONCURRENT_RESEARCHERS),
691
697
  PI_RESEARCH_MAX_RETRIES: String(config.RESEARCHER_MAX_RETRIES),
692
698
  PI_RESEARCH_RETRY_DELAY_MS: String(config.RESEARCHER_MAX_RETRY_DELAY_MS),
699
+ PI_RESEARCH_MAX_FAILED_RESEARCHERS: String(config.MAX_FAILED_RESEARCHERS ?? DEFAULTS.MAX_FAILED_RESEARCHERS),
693
700
  PI_RESEARCH_HEALTH_CHECK_TIMEOUT_MS: String(config.HEALTH_CHECK_TIMEOUT_MS ?? DEFAULTS.HEALTH_CHECK_TIMEOUT_MS),
694
701
  PI_RESEARCH_SEARCH_TIMEOUT_MS: String(config.SEARCH_TIMEOUT_MS),
695
702
  PI_RESEARCH_TUI_REFRESH_DEBOUNCE_MS: String(config.TUI_REFRESH_DEBOUNCE_MS),
@@ -912,6 +919,7 @@ export function createConfig(env: Record<string, string | undefined>, processEnv
912
919
  MAX_CONCURRENT_RESEARCHERS: parseEnvNumber(e, 'PI_RESEARCH_MAX_RESEARCHERS', DEFAULTS.MAX_CONCURRENT_RESEARCHERS, 1, 5, true),
913
920
  RESEARCHER_MAX_RETRIES: parseEnvNumber(e, 'PI_RESEARCH_MAX_RETRIES', DEFAULTS.RESEARCHER_MAX_RETRIES, 0, 5, true),
914
921
  RESEARCHER_MAX_RETRY_DELAY_MS: parseEnvNumber(e, 'PI_RESEARCH_RETRY_DELAY_MS', DEFAULTS.RESEARCHER_MAX_RETRY_DELAY_MS, 100, 10000),
922
+ MAX_FAILED_RESEARCHERS: parseEnvNumber(e, 'PI_RESEARCH_MAX_FAILED_RESEARCHERS', DEFAULTS.MAX_FAILED_RESEARCHERS, 1, 10, true),
915
923
  DEFAULT_RESEARCH_DEPTH: parseEnvNumber(e, 'PI_RESEARCH_DEFAULT_RESEARCH_DEPTH', DEFAULTS.DEFAULT_RESEARCH_DEPTH, 1, 3, true),
916
924
  MAX_SCRAPE_BATCHES: parseEnvNumber(e, 'PI_RESEARCH_MAX_SCRAPE_BATCHES', DEFAULTS.MAX_SCRAPE_BATCHES, 0, 99, true),
917
925
  MAX_GATHERING_CALLS: parseEnvNumber(e, 'PI_RESEARCH_MAX_GATHERING_CALLS', DEFAULTS.MAX_GATHERING_CALLS, 1, 100, true),
@@ -941,7 +949,7 @@ export function createConfig(env: Record<string, string | undefined>, processEnv
941
949
  SEARCH_TIMEOUT_MS: parseEnvNumber(e, 'PI_RESEARCH_SEARCH_TIMEOUT_MS', DEFAULTS.SEARCH_TIMEOUT_MS, 5000, 120000),
942
950
  TUI_REFRESH_DEBOUNCE_MS: parseEnvNumber(e, 'PI_RESEARCH_TUI_REFRESH_DEBOUNCE_MS', DEFAULTS.TUI_REFRESH_DEBOUNCE_MS, 0, 1000),
943
951
  BROWSER_TASK_TIMEOUT_MS: parseEnvNumber(e, 'PI_RESEARCH_BROWSER_TASK_TIMEOUT_MS', DEFAULTS.BROWSER_TASK_TIMEOUT_MS, 2000, 120000),
944
- LLM_TIMEOUT_MS: parseEnvNumber(e, 'PI_RESEARCH_LLM_TIMEOUT_MS', DEFAULTS.LLM_TIMEOUT_MS, 60000, 600000),
952
+ LLM_TIMEOUT_MS: parseEnvNumber(e, 'PI_RESEARCH_LLM_TIMEOUT_MS', DEFAULTS.LLM_TIMEOUT_MS, 60000, 1800000),
945
953
  LLM_THINKING_LEVEL: parseEnvEnum(e, 'PI_RESEARCH_LLM_THINKING_LEVEL', ['off', 'minimal', 'low', 'medium', 'high'] as const, DEFAULTS.LLM_THINKING_LEVEL),
946
954
  PLANNING_MAX_TOKENS: parseEnvNumber(e, 'PI_RESEARCH_PLANNING_MAX_TOKENS', DEFAULTS.PLANNING_MAX_TOKENS, 1024, 131072),
947
955
  SYNTHESIS_MAX_TOKENS: parseEnvNumber(e, 'PI_RESEARCH_SYNTHESIS_MAX_TOKENS', DEFAULTS.SYNTHESIS_MAX_TOKENS, 1024, 131072),