@lincoln504/pi-research 1.0.12 → 1.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -203,6 +203,30 @@ Cross-session, cross-process state (active sessions, browser status, metrics) li
203
203
  `StateManagerService` (`src/infrastructure/state/`), which serializes concurrent writes
204
204
  with file-based locking (`FileLockService`).
205
205
 
206
+ ### Concurrent runs (the run cap)
207
+
208
+ Every pi-research process on a machine — CLI, agent skill, pi extension, SDK — shares
209
+ one leader-elected browser pool and one embedding model. Letting an unbounded number of
210
+ research runs onto that shared pool does not slow runs down gracefully; it saturates the
211
+ priority queue and degrades *all* of them at once.
212
+
213
+ `ResearchRunSemaphore` (`src/infrastructure/research-run-semaphore.ts`) therefore gates
214
+ every `runResearch()` entry on one of N slots, realized as N well-known lock files in the
215
+ state directory and coordinated by the same `FileLockService`. Because slot ownership is
216
+ recorded as PID + process start time, a slot held by a crashed run is reclaimed
217
+ immediately by the next acquire, while a *live* holder is never stolen — a legitimate run
218
+ holds its slot for minutes, and stealing it would admit the (N+1)th run the cap exists to
219
+ prevent.
220
+
221
+ Runs beyond the cap **queue** rather than fail: the acquire polls until a slot frees,
222
+ announcing itself once through the observer (`onRunQueued`, surfaced by the CLI as
223
+ `• queued: …`) so a waiting run is never mistaken for a hung one. Only if nothing frees
224
+ within the whole queue window does it raise `ResearchRunCapacityError` — a temporary
225
+ condition the CLI reports as exit code `75`, distinct from a crash. The cap fails *open*
226
+ on any internal or IO error, so a fault in the semaphore itself can never prevent research
227
+ from running. Both the cap and the queue window are configurable
228
+ (`PI_RESEARCH_MAX_CONCURRENT_RUNS`, `PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS`).
229
+
206
230
  ### TUI
207
231
 
208
232
  The live progress panel uses `@earendil-works/pi-tui`, which handles terminal state
package/docs/CHANGELOG.md CHANGED
@@ -5,6 +5,46 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [1.1.2] - 2026-08-04
9
+
10
+ > Supersedes `v1.1.1`, which was tagged but never published: its release run was
11
+ > correctly blocked by a red CI, and the tag is retained only as a marker of that
12
+ > attempt. Everything below is the full set of changes since 1.1.0.
13
+
14
+ ### Fixed
15
+ - **The multi-process run-cap test counted cumulative slot acquisitions as concurrent ones.** The "thundering herd" test asserts the *concurrent* cap but tallied *cumulative* `acquired` events, and let its winners hold for a finite 4 s. On a slow runner the six contenders start staggered, so a late one can attempt after an early winner's hold has already elapsed, legitimately win the freed slot, and push the cumulative tally past the cap while at most `MAX` ever held at once — a false failure that blocked this release on Windows CI. Confirmed by direct reproduction: with the stagger forced, four acquisitions were recorded against a maximum of two concurrent holders, the late pair's acquire timestamps falling strictly after the early pair's releases on reused slots. Winners now hold until killed, so there is no free slot to win and the count measures genuine simultaneity — a strictly stronger assertion. Test-only; the semaphore itself was never at fault and is unchanged. (The sibling "distinct slot assignments" test was fixed the same way in 1.1.0; that change cited this test's own stated principle but left this test on the racy pattern.)
16
+ - **Citation markers could be silently retargeted at the wrong source.** When merging researcher reports, `normalizeCitations` renumbers each report's local `[N]` markers to global ids — but a marker it could not map was left *verbatim* instead of being removed. Global ids are assigned densely from 1, so a surviving `[1]` no longer meant "this report's first source": it resolved against the renumbered global list and pointed at an unrelated document. Markers go unmapped whenever `parseCitations` legitimately drops an entry, e.g. a URL whose hostname has no dot (`localhost`, a bare internal host, a malformed line). This was not cosmetic — the normalized body is fed straight into the evaluator/synthesis prompt, which is explicitly told the numbering is already global, so the model faithfully reproduced the wrong attribution, and `ensureCitedLinks` then regenerated a `CITED LINKS` list perfectly consistent with it. A correct structure-preserving rewriter already existed on the synthesis path (added in 1.0.11); it is now shared, so dangling markers are removed cleanly while fenced blocks and inline-code spans — a quoted `items[12]` — stay untouched. The prior test could never have caught this: its assertion was structurally guaranteed by the CITED-LINKS slice regardless of marker handling.
17
+ - **A retry could silently abandon the operation instead of retrying it.** `retryWithBackoff` awaited its inter-attempt sleep on an `unref`'d timer. The sleep *is* the operation — the retry has already been decided and the next attempt must run — and `abortableDelay`'s own docstring says exactly that (`web-scraper.ts` passes `keepAlive` with a comment explaining why); the backoff simply omitted it. The failure compounds itself: the transient network errors that trigger a retry are precisely the ones that leave no referenced socket handle, so the backoff is frequently the *only* pending handle, and Node drains the event loop and exits mid-retry. The next attempt never runs and nothing is reported — in the CLI neither branch of the top-level handler fires, so the user gets a bare exit with no report and no error. Affected every `retryWithBackoff` caller (LLM planning, all four security clients, YouTube transcripts) plus both knowledge-search retry loops.
18
+ - **The circuit breaker could pin permanently in `HALF_OPEN`, collapsing parallel search bursts.** `onFailure` is the only path back to `OPEN` and is skipped for errors the breaker does not count, while `transitionTo('HALF_OPEN')` never re-arms `nextAttemptTime` — so a trial probe failing with a non-counted error left the breaker stuck with no time-based escape, fast-failing every caller beyond `halfOpenMaxCalls` (default 1) indefinitely. This was not a corner case for the browser pool: `DEFAULT_BREAKER_CONFIG.isTransientError` deliberately excludes pool-shutdown, draining, Cloudflare and task-timeout errors — exactly what a recovering or blocked pool returns — so the probe most likely to run was also the one most likely to pin it. A non-counted probe failure is now treated as *inconclusive* and re-opens with a fresh reset window.
19
+ - **`npm install <dir>`, `file:`, `npm link` and `pi install <dir>` aborted during install.** The published package ships the built `skills/pi-research/scripts/run.mjs` and deliberately excludes its `run.ts` source, yet also ships `prepare.cjs` + `build.cjs` — and `prepare` runs `build.cjs all`, whose `skill` target hard-requires `run.ts` as its esbuild entry point. Running the shipped build against the shipped tree failed with `Could not resolve run.ts` and exit 1. Registry `.tgz` and `npx` installs were unaffected only because `prepare` does not run there. The target now no-ops when `run.ts` is absent and `run.mjs` is already built, and still errors when neither exists.
20
+ - **A pool-reset error told callers to retry, but nothing recognised it.** `worker-pool-manager` throws `Worker pool is being reset, please retry` while auto-recovery swaps the pool, and no error classifier matched that string — so the first attempt rethrew instead of retrying. Auto-recovery fires only after 3 consecutive worker failures, i.e. exactly when a burst of tasks is in flight and would all fail together: the same user-visible shape as the leader-handover loss fixed in 1.1.0, entering through a different door. Now classified as a transient pool-unavailable condition.
21
+ - **Cancelling a run at the wrong moment could kill the process.** `withTimeout`'s early-abort path returned before attaching to the caller-supplied promise, orphaning that promise's eventual rejection. Node 22+ defaults to `--unhandled-rejections=throw`, and the main process registers no `unhandledRejection` handler, so an Esc timed between two awaits (reachable via the YouTube transcript client, which threads the same signal into both) crashed the host instead of cancelling cleanly. The in-flight rejection is now adopted before bailing out.
22
+
23
+ ### Changed
24
+ - **Model-resolution errors now lead with the credential path instead of pointing at `models.json`.** For a provider pi ships built in (openrouter, anthropic, openai, …), the fix for an unresolved model is a credential — after which pi fetches that provider's catalog, with real pricing and context limits, on its own. Both error messages instead named `~/.pi/agent/models.json` as the remedy, which is wrong for exactly that case and demonstrably led users to hand-write `models[]` entries. Because such an entry **replaces** pi's catalog entry wholesale rather than merging into it (`modelOverrides` is the merging mechanism), doing so silently zeroed out the model's pricing and capped its context and output windows. The messages now name `/login`, `auth.json` and the provider env var first, scope `models.json` to genuinely custom or self-hosted providers, and warn about the replace-vs-merge distinction.
25
+ - **A cost of exactly `$0.00` is now shown rather than omitted.** Cost is computed locally as tokens × the model's price table, never read off the wire, so a model whose table is all zeros bills tokens and reports zero indefinitely. Every display site gated on `cost > 0`, which made a misconfigured price table indistinguishable from "nothing reported yet" — and on the CLI progress line a falsy cost suppressed the **token count** as well, so the run appeared to consume nothing. Zero is also the honest figure for flat-rate plans and local models. `recordLlmUsage` additionally warns once per model when tokens are billed against an all-zero table, naming the usual cause.
26
+ - **Session Metrics now actually reports cost.** The view has always advertised "API cost estimates", but `SessionStats` carried no cost field to show; it now aggregates and displays one.
27
+
28
+ ### Documentation
29
+ - **`README.md` claimed the research agent has "no filesystem or shell access" and is "safe to point at anything".** The shell half is accurate; the filesystem half was not — `read` and `grep` are registered unconditionally and the researcher exclusion list (`bash`, `write`, `edit`, `repl`, `git`, `terminal`) never excluded them. `ARCHITECTURE.md` documented this correctly, so the README was the outlier — and it is the sentence a user relies on when deciding whether to run this against a sensitive directory. Corrected to what the code actually enforces: no shell, no writes, but reads and searches within the working directory.
30
+ - **The `knowledge-config` example was an unquoted shell pipe.** `... knowledge-config set none|project|global` sat inside a block `SKILL.md` explicitly instructs the agent to run on the user's behalf; pasted literally, bash executes `set none` and pipes into two "command not found" lines — so a user asking to *enable* the store would have it silently *disabled*. Now a `<none|project|global>` placeholder, fixed in all four copies.
31
+ - **Exit-code documentation gaps.** `75` (at capacity) — 1.1.0's headline exit code — was missing from the CLI's own `--help`. And `78` from `knowledge` covers two very different conditions (store deliberately disabled vs. missing credentials) while `SKILL.md` documented only the first, telling agents to stay silent and continue on what is actually a hard configuration failure. Both surfaces now distinguish them.
32
+ - **`docs/SDK.md` recommended a loader recipe that cannot work.** It suggested `node --experimental-transform-types your-script.ts` for consuming the TypeScript entrypoint; Node refuses to strip *or* transform TypeScript under `node_modules`, failing with `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`. A loader (`tsx`/`ts-node`) or the pi host is mandatory, and the limitation is now stated.
33
+
34
+ ## [1.1.0] - 2026-08-04
35
+
36
+ ### Changed
37
+ - **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.
38
+ - **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.
39
+
40
+ ### Added
41
+ - **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.
42
+
43
+ ### Fixed
44
+ - **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.
45
+ - **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.
46
+ - **`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`.
47
+
8
48
  ## [1.0.12] - 2026-08-03
9
49
 
10
50
  ### Fixed
@@ -76,6 +76,8 @@ Research
76
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. |
77
77
  | `PI_RESEARCH_WORKER_THREADS` | `4` | 1–10 | Browser worker processes. Higher = more throughput, more CPU/RAM. |
78
78
  | `PI_RESEARCH_WORKER_CONCURRENCY` | `2` | 1–10 | Tasks per worker process. |
79
+ | `PI_RESEARCH_MAX_CONCURRENT_RUNS` | `3` | ≥1 | Machine-wide cap on research runs executing at the same time, across **every** process (CLI, agent skill, pi extension, SDK). Runs beyond the cap queue rather than fail. All concurrent runs share one leader-elected browser/embedding pool, so oversubscribing it degrades every run at once. |
80
+ | `PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS` | `600000` | ≥0 | How long a run queues for a free slot before failing with "maximum concurrent research runs reached" (CLI exit `75`). `0` = fail immediately instead of queueing. |
79
81
  | `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. |
80
82
  | `PI_RESEARCH_DISABLED_TOOLS` | _(none)_ | — | Comma-separated research tools to disable for a run (`search`, `scrape`, `security_search`, `stackexchange`, `youtube_transcript`, `grep`). Removed from every researcher's toolset and named in the coordinator/evaluator prompt. |
81
83
  | `PI_RESEARCH_REPORT_EXPORT_ENABLED` (TUI) | `false` | — | Front-ends write a Markdown report to disk and surface its path. |
@@ -216,7 +218,7 @@ set the knowledge-store mode per directory directly:
216
218
 
217
219
  ```sh
218
220
  pi-research knowledge-config # show the mode here and where it comes from
219
- pi-research knowledge-config set none|project|global
221
+ pi-research knowledge-config set <none|project|global> # pick ONE value
220
222
  ```
221
223
 
222
224
  Under the agent skill you don't have to run this yourself — you ask the agent (e.g. "disable the
@@ -44,7 +44,7 @@ a project-scoped setting you can change per directory:
44
44
  | `none` | The store is disabled — nothing is read or written, the `research_knowledge_search` tool is not advertised to the agent, and `/knowledge-store` is unavailable. Re-enabling needs no restart (see [PI-EXTENSION.md](PI-EXTENSION.md) for the registration mechanics). |
45
45
 
46
46
  Change the mode for the current directory with the `/research-config` TUI (Knowledge Mode)
47
- in the pi extension, or with `pi-research knowledge-config set none|project|global` on the
47
+ in the pi extension, or with `pi-research knowledge-config set <none|project|global>` (pick one value) on the
48
48
  standalone CLI. The setting persists to the per-directory project registry — see
49
49
  [CONFIGURATION.md](CONFIGURATION.md) for the full precedence chain. The change applies on
50
50
  the next run — no restart.
package/docs/SDK.md CHANGED
@@ -33,13 +33,16 @@ global file entirely and run purely from defaults + `process.env` + `options.con
33
33
  > `enum` and constructor parameter properties, which Node's strip-only mode
34
34
  > (`--experimental-strip-types`, the default since Node 23.6) rejects with
35
35
  > `ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`. Use one of:
36
- > - the pi host, which loads it natively;
37
- > - **`node --experimental-transform-types your-script.ts`** — must be passed
38
- > explicitly; full transform is not the default on any current Node release;
36
+ > - the pi host, which loads it natively (via `jiti`);
39
37
  > - a loader such as `tsx` or `ts-node`.
40
38
  >
41
- > Plain `node script.js` doing `require('@lincoln504/pi-research/sdk')`, or
42
- > `--experimental-strip-types`, will not work. (`engines.node` is `>=22.19.0`.)
39
+ > **Bare Node cannot load it at all, with any flag.** Node refuses to strip or
40
+ > transform TypeScript that lives under `node_modules` — as an installed
41
+ > dependency's source does — failing with
42
+ > `ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`. That applies to
43
+ > `--experimental-transform-types` exactly as it does to
44
+ > `--experimental-strip-types`, so neither flag helps here; a loader (or the pi
45
+ > host) is mandatory. (`engines.node` is `>=22.19.0`.)
43
46
 
44
47
  ```typescript
45
48
  import {
@@ -85,6 +88,15 @@ report caused by a sparse topic apart from one where most researchers failed). B
85
88
  > Concurrency: a single initialized SDK instance runs one research call at a time.
86
89
  > Overlapping `runDeepResearch`/`runQuickResearch` calls on the same instance throw
87
90
  > — run them sequentially, or use a separate process per concurrent run.
91
+ >
92
+ > Separate processes are additionally bounded by a **machine-wide run cap**
93
+ > (`PI_RESEARCH_MAX_CONCURRENT_RUNS`, default 3) covering every pi-research process
94
+ > on the host, because they all share one leader-elected browser/embedding pool. A
95
+ > run over the cap queues for up to `PI_RESEARCH_RUN_ACQUIRE_TIMEOUT_MS` (default
96
+ > 10 min) and only then rejects with `ResearchRunCapacityError` — a temporary
97
+ > "try again shortly" condition, surfaced by the CLI as exit code `75`. Supply an
98
+ > observer with `onRunQueued(slots, maxWaitMs)` to tell a waiting user the run is
99
+ > queued rather than hung.
88
100
 
89
101
  The SDK does not write report files. Report export is a front-end concern — the pi
90
102
  extension and the CLI / agent skill do it when `PI_RESEARCH_REPORT_EXPORT_ENABLED=true`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincoln504/pi-research",
3
- "version": "1.0.12",
3
+ "version": "1.1.2",
4
4
  "description": "A set of web research tools (search, scrape, and others) and research agent system that uses them safely and efficiently.",
5
5
  "type": "module",
6
6
  "private": false,
package/scripts/build.cjs CHANGED
@@ -106,13 +106,31 @@ const TARGETS = {
106
106
  copyWebGpuProbe();
107
107
  copyPrompts();
108
108
  },
109
- skill: () =>
110
- esbuild.build({
109
+ skill: () => {
110
+ const entry = p('skills', 'pi-research', 'scripts', 'run.ts');
111
+ const outfile = p('skills', 'pi-research', 'scripts', 'run.mjs');
112
+ // The published package ships the BUILT run.mjs and deliberately excludes its
113
+ // run.ts source (package.json "files": "!skills/pi-research/scripts/run.ts"),
114
+ // yet it also ships prepare.cjs + build.cjs — and `prepare` runs `build.cjs all`.
115
+ // On any install that executes prepare against the shipped tree (npm install
116
+ // <dir>, file:, npm link, pi install <dir>) this target had no entry point and
117
+ // aborted the whole install with "Could not resolve run.ts". Registry .tgz and
118
+ // npx installs were unaffected only because prepare does not run there.
119
+ // Nothing to do when the artifact is already present and its source is not.
120
+ if (!fs.existsSync(entry)) {
121
+ if (fs.existsSync(outfile)) {
122
+ console.log('[build] skill: run.ts absent and run.mjs already built (published tree) — skipping');
123
+ return Promise.resolve();
124
+ }
125
+ throw new Error(`[build] skill: neither ${entry} nor a prebuilt ${outfile} exists`);
126
+ }
127
+ return esbuild.build({
111
128
  ...COMMON,
112
- entryPoints: [p('skills', 'pi-research', 'scripts', 'run.ts')],
113
- outfile: p('skills', 'pi-research', 'scripts', 'run.mjs'),
129
+ entryPoints: [entry],
130
+ outfile,
114
131
  banner: { js: SHEBANG },
115
- }),
132
+ });
133
+ },
116
134
  };
117
135
 
118
136
  // The cli target's copyThreadWorker step depends on the worker output existing,
@@ -2,7 +2,7 @@
2
2
  name: pi-research
3
3
  description: "Multi-agent web research for any coding agent. Use for anything requiring current information from the internet — news, trends, product/service/company facts, statistics, people, places, events, APIs, library behavior, or 'what is X / how does X work' questions. Also use to search a local knowledge store of previously researched findings before doing live research. Powered by the pi-research engine (search, stealth scraping, security databases, Stack Exchange). Not for reading local files, running project commands, or analyzing this repository's own source."
4
4
  license: MIT
5
- metadata: { "author": "Lincoln Deen", "version": "1.0.12", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
5
+ metadata: { "author": "Lincoln Deen", "version": "1.1.2", "package": "@lincoln504/pi-research", "engine": "pi-research-sdk", "openclaw": { "requires": { "bins": ["node"] } } }
6
6
  allowed-tools: Bash(node:*)
7
7
  ---
8
8
 
@@ -22,8 +22,16 @@ node "<SKILL_DIR>/scripts/run.mjs" status [--json]
22
22
  - **stderr** = progress + errors — relay errors, suppress routine progress.
23
23
  - **Always set a generous command timeout** so a stalled run can't block forever, but
24
24
  leave plenty of headroom — a real run should finish well inside it:
25
- `knowledge` 600000ms · `research --depth 1` 900000ms · `--depth 2` 1200000ms · `--depth 3` 1800000ms.
25
+ `knowledge` 600000ms · `research --depth 1` 1500000ms · `--depth 2` 1800000ms · `--depth 3` 2400000ms.
26
+ These already include headroom for the run to sit **queued** behind other runs on a
27
+ busy machine (see below); don't shorten them on the assumption a slot is free.
26
28
  - **Background**: append `&` and keep the PID to work while a run completes; otherwise it runs foreground (blocks). Timeouts apply either way.
29
+ - **Several at once is fine.** Up to 3 runs execute concurrently machine-wide (shared
30
+ with any other tool or agent on this machine); further runs **queue** for a slot and
31
+ report `• queued: …` on stderr — that is normal, not a stall. Only if nothing frees
32
+ up within the queue window does a run exit `75` (see *Exit codes*). Prefer one
33
+ `research` call with a well-scoped query over splitting one topic into many parallel
34
+ calls — depth 1 already parallelises internally.
27
35
 
28
36
  ## Workflow
29
37
 
@@ -55,8 +63,9 @@ node "<SKILL_DIR>/scripts/run.mjs" status [--json]
55
63
  | Exit | Meaning | Action |
56
64
  |------|---------|--------|
57
65
  | `0` | Success | Read stdout; cite sources; relay any "saved to: <path>" line. |
58
- | `78` on `knowledge` | Store disabled/unconfigured | Silent; proceed to `research`. |
66
+ | `78` on `knowledge` | Two cases — **read stderr to tell them apart.** | Says the store is **disabled**: silent; proceed to `research`. Mentions **credentials/model/API key**: this is the same hard config failure as `78` on `research` — **stop and relay full stderr**; don't silently continue, `research` will fail the same way. |
59
67
  | `78` on `research` | Engine missing or no model/key | **Stop. Relay full stderr** (it shows where to configure). Don't retry until fixed. |
68
+ | `75` | Machine at capacity — other research runs are already using every slot | **Nothing is broken.** Wait for an in-flight run to finish, then retry this query once. Don't reconfigure anything and don't retry in a tight loop. |
60
69
  | `70` | Runtime error | Relay stderr; suggest one retry for transient failures (network, rate limit). |
61
70
  | `64` | Bad arguments | Fix args; don't surface unless the query itself is malformed. |
62
71
 
@@ -72,7 +81,7 @@ the new setting. Check the current one first if useful:
72
81
 
73
82
  ```
74
83
  node "<SKILL_DIR>/scripts/run.mjs" knowledge-config # current mode + where it's set
75
- node "<SKILL_DIR>/scripts/run.mjs" knowledge-config set none|project|global
84
+ node "<SKILL_DIR>/scripts/run.mjs" knowledge-config set <none|project|global> # pick ONE value
76
85
  ```
77
86
 
78
87
  The change applies to the current directory on the next run. Never change it unprompted.
@@ -52,7 +52,7 @@ shared store — the default), `project` (scoped to the current directory), or `
52
52
 
53
53
  ```sh
54
54
  node "<SKILL_DIR>/scripts/run.mjs" knowledge-config # current mode + its source
55
- node "<SKILL_DIR>/scripts/run.mjs" knowledge-config set none|project|global
55
+ node "<SKILL_DIR>/scripts/run.mjs" knowledge-config set <none|project|global> # pick ONE value
56
56
  ```
57
57
 
58
58
  `set` persists to the per-directory project registry (`~/.pi/research/state/
package/src/cli.ts CHANGED
@@ -76,6 +76,14 @@ export const EXIT = {
76
76
  CONFIG: 78,
77
77
  /** Something went wrong at runtime (network, provider, internal). */
78
78
  SOFTWARE: 70,
79
+ /**
80
+ * Temporary failure — the request was well-formed and the install is healthy,
81
+ * but the machine is at capacity right now (all research run slots held by
82
+ * live runs). Distinct from SOFTWARE so a calling agent can tell "busy, try
83
+ * again shortly" apart from "this run crashed" and back off instead of
84
+ * re-running a broken command.
85
+ */
86
+ TEMPFAIL: 75,
79
87
  } as const;
80
88
 
81
89
  // ---------------------------------------------------------------------------
@@ -108,6 +116,12 @@ function makeProgressObserver(): HeadlessObserverOptions {
108
116
  case 'planning_success':
109
117
  toStderr(` • plan: ${data?.plan?.researchers?.length ?? '?'} researcher(s)\n`);
110
118
  break;
119
+ case 'run_queued':
120
+ toStderr(
121
+ ` • queued: ${data?.slots ?? '?'} concurrent run(s) already active — waiting up to ` +
122
+ `${Math.round((data?.maxWaitMs ?? 0) / 1000)}s for a slot…\n`,
123
+ );
124
+ break;
111
125
  case 'round_start':
112
126
  toStderr(` • round ${data?.round}\n`);
113
127
  break;
@@ -123,9 +137,19 @@ function makeProgressObserver(): HeadlessObserverOptions {
123
137
  case 'evaluation_decision':
124
138
  toStderr(` • evaluator: ${data?.action}\n`);
125
139
  break;
126
- case 'tokens_consumed':
127
- if (data?.cost) toStderr(` • tokens: ${data?.tokens ?? 0} ($${Number(data.cost).toFixed(4)})\n`);
140
+ case 'tokens_consumed': {
141
+ // A truthiness check on cost used to gate this whole line, so a model with
142
+ // an all-zero price table suppressed the TOKEN count as well — the run
143
+ // looked like it consumed nothing. Tokens stand on their own; cost is
144
+ // appended whenever it is a number, including 0.
145
+ const consumed = Number(data?.tokens ?? 0);
146
+ const costVal = typeof data?.cost === 'number' ? data.cost : undefined;
147
+ if (consumed > 0 || costVal !== undefined) {
148
+ const costPart = costVal === undefined ? '' : ` ($${costVal.toFixed(4)})`;
149
+ toStderr(` • tokens: ${consumed}${costPart}\n`);
150
+ }
128
151
  break;
152
+ }
129
153
  case 'complete':
130
154
  toStderr(` • complete (${data?.result?.length ?? 0} chars)\n`);
131
155
  break;
@@ -829,9 +853,19 @@ export async function reportError(err: unknown, what: string, json?: boolean): P
829
853
  // research-stop — regardless of real cause — as a config error (exit 78).
830
854
  const isTaggedResearchStop = (err as { code?: unknown } | undefined)?.code === RESEARCH_STOPPED_ERROR_CODE;
831
855
 
856
+ // Run-cap exhaustion is an operational state, not a fault: every slot is held by
857
+ // another *healthy* live run. Matched on the error name rather than `instanceof`
858
+ // so it survives bundling (dist/cli.mjs) and any duplicate module instance.
859
+ // Checked ahead of the substring heuristics below rather than relying on the
860
+ // current wording of the message: capacity text is config-shaped (it names two
861
+ // PI_RESEARCH_* env vars), so an exact classification must not depend on that
862
+ // message never drifting into one of the credential keywords. Misreading a busy
863
+ // machine as a setup problem would send the user to reconfigure a working install.
864
+ const isCapacity = err instanceof Error && err.name === 'ResearchRunCapacityError';
865
+
832
866
  // Auth / model / config-shaped errors → CONFIG exit + config block.
833
867
  const isConfigError =
834
- !isTaggedResearchStop && (
868
+ !isTaggedResearchStop && !isCapacity && (
835
869
  lower.includes('no llm model available') ||
836
870
  lower.includes('not found in pi') ||
837
871
  lower.includes('no api key') ||
@@ -846,12 +880,19 @@ export async function reportError(err: unknown, what: string, json?: boolean): P
846
880
  // classifying that as a setup problem sends the user to reconfigure a
847
881
  // perfectly working key instead of waiting out the limit.
848
882
  const isRateLimit = lower.includes('429') || lower.includes('rate limit') || lower.includes('too many requests');
849
- const exitCode = isConfigError && !isRateLimit ? EXIT.CONFIG : EXIT.SOFTWARE;
883
+ const exitCode = isCapacity
884
+ ? EXIT.TEMPFAIL
885
+ : isConfigError && !isRateLimit
886
+ ? EXIT.CONFIG
887
+ : EXIT.SOFTWARE;
850
888
 
851
889
  // --json: emit a structured error on stdout so a machine consumer never has to
852
890
  // special-case plain-text stderr. Mirrors the { ok: true, ... } success shape.
853
891
  if (json) {
854
892
  const payload: Record<string, unknown> = { ok: false, error: msg, exitCode };
893
+ // Machine-readable "this will succeed later" flag, so a consumer can back off
894
+ // and retry without string-matching the human message.
895
+ if (isCapacity || isRateLimit) payload['retryable'] = true;
855
896
  if (process.env['PI_RESEARCH_DEBUG'] === 'true' && err instanceof Error && err.stack) {
856
897
  payload['stack'] = err.stack;
857
898
  }
@@ -859,6 +900,13 @@ export async function reportError(err: unknown, what: string, json?: boolean): P
859
900
  return exitCode;
860
901
  }
861
902
 
903
+ // Capacity exhaustion is a healthy-machine "busy" state — report it as such and
904
+ // never with a stack trace, which would read as a crash for a routine condition.
905
+ if (isCapacity) {
906
+ toStderr(`\nError: pi-research ${what} did not start: ${msg}\n`);
907
+ return exitCode;
908
+ }
909
+
862
910
  // Rate limits are operational, not fatal setup problems — checked FIRST.
863
911
  if (isRateLimit) {
864
912
  toStderr(`\nError: pi-research ${what} halted: provider rate limit reached. Wait a moment and retry.\n ${msg}\n`);
@@ -978,7 +1026,9 @@ CONFIGURE
978
1026
  EXIT CODES
979
1027
  0 success
980
1028
  64 bad arguments
1029
+ 75 at capacity — every concurrent run slot is busy; nothing is broken, retry later
981
1030
  78 not configured — message prints the exact locations to fix
1031
+ (on \`knowledge\`, also returned when the store is deliberately disabled)
982
1032
  70 runtime error (network, provider, internal)
983
1033
  `;
984
1034
  }
@@ -18,6 +18,15 @@ export interface HeadlessObserverOptions {
18
18
  export interface ResearchObserver {
19
19
  onStart?(query: string, complexity: number): void;
20
20
 
21
+ /**
22
+ * Fired once when the run cannot start immediately because every concurrent-run
23
+ * slot is held, and it is about to queue. Without this a queued run is silent
24
+ * for as long as the acquire wait allows, which is indistinguishable from a hang.
25
+ * @param slots the machine-wide concurrent-run cap
26
+ * @param maxWaitMs how long the run will queue before giving up
27
+ */
28
+ onRunQueued?(slots: number, maxWaitMs: number): void;
29
+
21
30
  // Coordinator/Planning phase
22
31
  onPlanningStart?(attempt: number): void;
23
32
  onPlanningProgress?(status: string): void;
@@ -219,6 +219,13 @@ export function constructMinimalModel(provider: string, modelId: string, _apiKey
219
219
  baseUrl: '', // Provider-specific base URLs are handled by pi-ai internal registry
220
220
  reasoning: false,
221
221
  input: ['text'],
222
+ // No catalog is available on this path, so there is no price data and no real
223
+ // context/output ceiling to read. The zeros below are "unknown", NOT "free":
224
+ // cost reporting for this model will read $0.00 (see warnIfUnpriced in
225
+ // utils/llm-usage.ts), and the window figures are conservative guesses, not the
226
+ // model's true limits — so docs/CONFIGURATION.md's "clamped to the model's real
227
+ // ceiling" guarantee does not hold here. Anyone relying on accurate cost or the
228
+ // full context window should give pi a credential so it can fetch the real catalog.
222
229
  cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0 },
223
230
  contextWindow: 128_000,
224
231
  maxTokens: 32_768,
@@ -253,7 +260,7 @@ export function resolveModel(registry: ModelRegistry, modelSpec?: string, provid
253
260
  return constructMinimalModel(prov, modelId, apiKey);
254
261
  }
255
262
 
256
- throw new Error(`Model "${modelSpec}" not found in pi's configured model registry. Check ${path.join(getAgentDir(), 'models.json')}.`);
263
+ throw new Error(buildModelNotFoundMessage(modelSpec));
257
264
  }
258
265
 
259
266
  // Validate format: must contain a slash OR be found as a bare model ID.
@@ -306,11 +313,41 @@ export function resolveModel(registry: ModelRegistry, modelSpec?: string, provid
306
313
  return constructMinimalModel(provider, modelSpec, apiKey);
307
314
  }
308
315
 
309
- throw new Error(
310
- `No LLM model available. Please configure your model registry (${path.join(getAgentDir(), 'models.json')}) or provide an explicit apiKey.`,
316
+ throw new Error(buildNoModelAvailableMessage());
317
+ }
318
+
319
+ /**
320
+ * Credential-first guidance for model-resolution failures.
321
+ *
322
+ * These messages used to say only "Check ~/.pi/agent/models.json", which is the
323
+ * WRONG remedy for a provider pi ships built in: the fix there is a credential,
324
+ * after which pi fetches the provider's catalog (with real pricing and context
325
+ * limits) on its own. Following the old advice, users hand-wrote `models[]`
326
+ * entries — and because such an entry REPLACES pi's catalog entry wholesale
327
+ * rather than merging into it, that silently zeroed out pricing and capped
328
+ * context/output windows. Lead with the credential path; scope models.json to
329
+ * genuinely custom providers; name `modelOverrides` for single-field tweaks.
330
+ */
331
+ function credentialGuidance(): string {
332
+ const agentDir = getAgentDir();
333
+ return (
334
+ ` • Provider pi supports built-in (openrouter, anthropic, openai, …): add a credential — run \`pi\` and use /login, ` +
335
+ `set the provider's API-key environment variable, or add it to ${path.join(agentDir, 'auth.json')}. ` +
336
+ `pi then fetches that provider's model catalog automatically.\n` +
337
+ ` • Custom or self-hosted provider (Ollama, vLLM, a proxy): declare it in ${path.join(agentDir, 'models.json')}.\n` +
338
+ ` Note: a models.json entry REPLACES pi's catalog entry for the same model id, discarding its pricing and\n` +
339
+ ` context/output limits. To adjust a single field of a built-in model, use "modelOverrides" instead — it merges.`
311
340
  );
312
341
  }
313
342
 
343
+ export function buildModelNotFoundMessage(modelSpec: string): string {
344
+ return `Model "${modelSpec}" not found in pi's model registry.\n${credentialGuidance()}`;
345
+ }
346
+
347
+ export function buildNoModelAvailableMessage(): string {
348
+ return `No LLM model available.\n${credentialGuidance()}\n • Or pass an explicit apiKey.`;
349
+ }
350
+
314
351
  /**
315
352
  * Read the user's provider order from ~/.pi/agent/models.json.
316
353
  * Returns provider keys in document (insertion) order, or [] when the file is
@@ -9,7 +9,7 @@ import { type Model } from '@earendil-works/pi-ai';
9
9
  import { type ModelRegistry } from '@earendil-works/pi-coding-agent';
10
10
  import { getConfig } from '../../config.ts';
11
11
  import { logger } from '../../logger.ts';
12
- import { pickPreferredAvailable, readModelsJsonProviderOrder, safeGetAll, safeGetAvailable } from './model-registry-factory.ts';
12
+ import { buildNoModelAvailableMessage, pickPreferredAvailable, readModelsJsonProviderOrder, safeGetAll, safeGetAvailable } from './model-registry-factory.ts';
13
13
 
14
14
  /**
15
15
  * Resolve the research model with standardized priority:
@@ -87,5 +87,5 @@ export function resolveResearchModel(options: {
87
87
  return pickPreferredAvailable(anyModel, readModelsJsonProviderOrder()) as Model<any>;
88
88
  }
89
89
 
90
- throw new Error('No LLM model available for research. Please configure your model registry (~/.pi/agent/models.json).');
90
+ throw new Error(buildNoModelAvailableMessage());
91
91
  }
@@ -90,9 +90,18 @@ export function isServerDrainingError(error: unknown): boolean {
90
90
 
91
91
  /**
92
92
  * Check if an error is specifically a pool-shutdown / pool-drain error.
93
- * For these errors the scheduler itself is fine — only the WorkerPool is temporarily
94
- * draining. The correct recovery is to wait for the drain to finish and retry,
95
- * NOT to call forceSchedulerRestart (which would start another drain cycle).
93
+ *
94
+ * These are never counted against the circuit breaker: the pool recovers on its own.
95
+ *
96
+ * NOTE: this was once documented as "wait for the drain to finish and retry, NOT
97
+ * forceSchedulerRestart". That is right only when the draining pool is *local*. In the
98
+ * common case the pool belongs to another process — the elected leader — which is
99
+ * exiting, so waiting alone leaves the cached client pointed at a dead port and the
100
+ * retry fails identically (this cost whole search bursts; see the v1.1.0 changelog).
101
+ * Recovery must also drop the cached scheduler handle so the next call re-resolves the
102
+ * new leader; `recoverFromLeaderHandover` in task-execution-service.ts does exactly
103
+ * that, using `forceSchedulerRestart(false)` whose liveness probe and in-progress guard
104
+ * prevent the restart churn the old note was warning about.
96
105
  */
97
106
  export function isPoolShutdownError(error: unknown): boolean {
98
107
  if (!error || typeof error !== 'object') return false;
@@ -100,7 +109,13 @@ export function isPoolShutdownError(error: unknown): boolean {
100
109
  return typeof err.message === 'string' && (
101
110
  err.message.includes('Worker pool is shutting down') ||
102
111
  err.message.includes('Cannot execute a task on destroying pool') ||
103
- err.message.includes('destroying pool')
112
+ err.message.includes('destroying pool') ||
113
+ // worker-pool-manager throws this when auto-recovery is still swapping the
114
+ // pool. Its own text says "please retry", but until it was listed here no
115
+ // classifier matched it, so the first attempt rethrew instead of retrying —
116
+ // and auto-recovery fires only after 3 consecutive worker failures, i.e.
117
+ // exactly when a burst of tasks is in flight and would all die together.
118
+ err.message.includes('Worker pool is being reset')
104
119
  );
105
120
  }
106
121