@lincoln504/pi-research 1.1.2 → 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/README.md +3 -3
  2. package/dist/cli.mjs +1287 -540
  3. package/dist/thread-worker.mjs +30 -11
  4. package/docs/AGENT-SKILL.md +11 -4
  5. package/docs/ARCHITECTURE.md +19 -9
  6. package/docs/CHANGELOG.md +81 -1
  7. package/docs/CONFIGURATION.md +9 -7
  8. package/docs/KNOWLEDGE-STORE.md +4 -3
  9. package/docs/PI-EXTENSION.md +1 -1
  10. package/docs/SDK.md +55 -0
  11. package/docs/THIRD-PARTY-NOTICES.md +1 -1
  12. package/package.json +5 -4
  13. package/scripts/cleanup.cjs +50 -10
  14. package/scripts/prepare.cjs +15 -5
  15. package/scripts/setup.cjs +24 -4
  16. package/skills/pi-research/README.md +9 -2
  17. package/skills/pi-research/SKILL.md +6 -3
  18. package/skills/pi-research/references/configuration.md +6 -2
  19. package/skills/pi-research/scripts/run.mjs +24 -2
  20. package/src/cli.ts +149 -21
  21. package/src/config.ts +56 -15
  22. package/src/constants.ts +31 -0
  23. package/src/core/interfaces/knowledge-interfaces.ts +6 -1
  24. package/src/core/interfaces/state-manager-interfaces.ts +1 -1
  25. package/src/core/pi-version.ts +136 -0
  26. package/src/core/service-registry.ts +117 -8
  27. package/src/index.ts +11 -18
  28. package/src/infrastructure/browser/browser-client.ts +16 -4
  29. package/src/infrastructure/browser/browser-error-utils.ts +6 -1
  30. package/src/infrastructure/browser/browser-server.ts +17 -5
  31. package/src/infrastructure/browser/browser-task-scheduler.ts +101 -23
  32. package/src/infrastructure/browser/cleanup-utils.ts +45 -7
  33. package/src/infrastructure/browser/client-agent.ts +4 -3
  34. package/src/infrastructure/browser/priority-task-queue.ts +10 -3
  35. package/src/infrastructure/browser/scheduler-factory.ts +26 -1
  36. package/src/infrastructure/browser/task-execution-service.ts +92 -28
  37. package/src/infrastructure/browser/thread-worker-lifecycle.ts +50 -13
  38. package/src/infrastructure/browser/thread-worker.mjs +30 -11
  39. package/src/infrastructure/browser/worker-pool-manager.ts +11 -3
  40. package/src/infrastructure/embedding/embedding-client.ts +22 -8
  41. package/src/infrastructure/embedding/embedding-factory.ts +28 -11
  42. package/src/infrastructure/embedding/embedding-server.ts +65 -4
  43. package/src/infrastructure/file-lock-service.ts +223 -26
  44. package/src/infrastructure/process-lifecycle-service.ts +108 -31
  45. package/src/infrastructure/state/state-backup-manager.ts +5 -7
  46. package/src/infrastructure/state/state-manager-service.ts +1 -1
  47. package/src/infrastructure/state/state-manager.ts +19 -20
  48. package/src/infrastructure/types/state-types.ts +9 -0
  49. package/src/knowledge/embedder-init.ts +1 -1
  50. package/src/knowledge/embedder.ts +52 -3
  51. package/src/knowledge/index.ts +1 -1
  52. package/src/knowledge/store.ts +35 -9
  53. package/src/knowledge/webgpu-viability.ts +9 -4
  54. package/src/knowledge/writer-queue.ts +88 -46
  55. package/src/orchestration/deep-research-orchestrator.ts +13 -2
  56. package/src/orchestration/quick-research-orchestrator.ts +2 -2
  57. package/src/orchestration/research-orchestration-service.ts +2 -4
  58. package/src/orchestration/research-synthesis-service.ts +33 -11
  59. package/src/security/cisa-kev.ts +10 -3
  60. package/src/security/github-advisories.ts +18 -7
  61. package/src/security/nvd-client.ts +17 -6
  62. package/src/security/osv-client.ts +14 -4
  63. package/src/skill-install/skill-installer.ts +135 -9
  64. package/src/stackexchange/rest-client.ts +6 -2
  65. package/src/tools/research-knowledge-search.ts +15 -0
  66. package/src/tools/search.ts +6 -1
  67. package/src/utils/atomic-replace.ts +101 -0
  68. package/src/utils/citation-utils.ts +3 -1
  69. package/src/utils/http-body.ts +176 -0
  70. package/src/web-research/browser-search.ts +50 -4
  71. package/src/web-research/search.ts +20 -5
  72. package/src/web-research/types.ts +17 -4
  73. package/src/web-research/web-scraper.ts +7 -50
  74. package/src/youtube/transcript-client.ts +5 -1
@@ -1389,7 +1389,11 @@ function isPoolShutdownError(error) {
1389
1389
  // classifier matched it, so the first attempt rethrew instead of retrying —
1390
1390
  // and auto-recovery fires only after 3 consecutive worker failures, i.e.
1391
1391
  // exactly when a burst of tasks is in flight and would all die together.
1392
- err.message.includes("Worker pool is being reset"));
1392
+ err.message.includes("Worker pool is being reset") || // poolifier's wording once destroy() has COMPLETED (vs "destroying pool"
1393
+ // while it is in progress). A queued closure holding a pool captured before
1394
+ // an auto-recovery destroy dispatches into exactly this window; it is the
1395
+ // same transient swap the "being reset" string covers, not a hard fault.
1396
+ err.message.includes("Cannot execute a task on not started pool"));
1393
1397
  }
1394
1398
  var DEFAULT_BREAKER_CONFIG, browserCircuitBreaker;
1395
1399
  var init_browser_error_utils = __esm({
@@ -1753,12 +1757,34 @@ function setupOrphanProtection() {
1753
1757
  if (!cluster.isWorker) {
1754
1758
  return;
1755
1759
  }
1756
- if (!process2.ppid) {
1760
+ const originalPpid = process2.ppid;
1761
+ if (!originalPpid) {
1757
1762
  return;
1758
1763
  }
1764
+ const exitAsOrphan = async () => {
1765
+ if (cleanupBrowser) {
1766
+ await Promise.race([
1767
+ cleanupBrowser().catch((err) => {
1768
+ logToDebugFile("WARN", `[Worker-${workerId}] Browser cleanup failed during orphan exit:`, err);
1769
+ }),
1770
+ new Promise((resolve) => {
1771
+ const t = setTimeout(resolve, 1e4);
1772
+ if (t.unref) t.unref();
1773
+ })
1774
+ ]);
1775
+ }
1776
+ cleanupOrphanProtection();
1777
+ process2.env["PI_PROCESS_EXITING"] = "1";
1778
+ process2.exit(1);
1779
+ };
1759
1780
  const checkOrphan = async () => {
1760
1781
  try {
1761
- process2.kill(process2.ppid, 0);
1782
+ if (process2.ppid !== originalPpid) {
1783
+ logToDebugFile("WARN", `[Worker-${workerId}] Re-parented (${originalPpid} -> ${process2.ppid}): parent died, shutting down...`);
1784
+ await exitAsOrphan();
1785
+ return;
1786
+ }
1787
+ process2.kill(originalPpid, 0);
1762
1788
  orphanCheckTimer = setTimeout(checkOrphan, 1e4);
1763
1789
  if (orphanCheckTimer) {
1764
1790
  orphanCheckTimer.unref();
@@ -1770,14 +1796,7 @@ function setupOrphanProtection() {
1770
1796
  return;
1771
1797
  }
1772
1798
  logToDebugFile("WARN", `[Worker-${workerId}] Parent process died or unreachable (orphaned), shutting down...`);
1773
- if (cleanupBrowser) {
1774
- await cleanupBrowser().catch((err) => {
1775
- logToDebugFile("WARN", `[Worker-${workerId}] Browser cleanup failed during orphan exit:`, err);
1776
- });
1777
- }
1778
- cleanupOrphanProtection();
1779
- process2.env["PI_PROCESS_EXITING"] = "1";
1780
- process2.exit(1);
1799
+ await exitAsOrphan();
1781
1800
  }
1782
1801
  };
1783
1802
  orphanCheckTimer = setTimeout(checkOrphan, 1e4);
@@ -41,7 +41,7 @@ On Windows, run `pi-research` from `cmd` or use `pi-research.cmd`: PowerShell's
41
41
  execution policy (`Restricted`) blocks npm's `.ps1` shims ("running scripts is
42
42
  disabled"); or run `Set-ExecutionPolicy -Scope CurrentUser RemoteSigned` once.
43
43
 
44
- ![Installing the research skill into external agents](media/05-agent-skill.gif)
44
+ ![Installing the research skill into external agents](https://raw.githubusercontent.com/Lincoln504/pi-research/main/docs/media/05-agent-skill.gif)
45
45
 
46
46
  ### How it works
47
47
 
@@ -83,8 +83,15 @@ Install in External Agents. The installer:
83
83
  2. Symlinks `skills/pi-research/` into each present agent, never overwriting an
84
84
  unrelated skill already in that slot.
85
85
  3. Records what it created in a manifest, so Remove from External Agents
86
- removes only its own links. Stale links are also garbage-collected on startup and
87
- on `npm uninstall`.
86
+ removes only its own links. Stale links are also garbage-collected on startup.
87
+
88
+ > **Uninstalling removes nothing on its own.** The package ships a `preuninstall`
89
+ > script, but **npm 7 and newer do not run `preuninstall`** — verified against
90
+ > npm 11: `postinstall` fires, `preuninstall` does not. So `npm uninstall
91
+ > @lincoln504/pi-research` leaves the skill links, the state directory
92
+ > (`~/.pi/research/state`) and the cache directory (`~/.cache/pi-research`,
93
+ > including downloaded embedding models) in place. Run `pi-research skill uninstall`
94
+ > **before** removing the package to take the links with you.
88
95
 
89
96
  Standalone (no pi extension). `pi-research skill install` and `pi-research skill uninstall`
90
97
  do exactly the same from the CLI — same agent detection, same manifest, same
@@ -135,7 +142,7 @@ npm install -g @lincoln504/pi-research
135
142
  node "<skill_dir>/scripts/run.mjs" status # verify the engine is detected
136
143
  ```
137
144
 
138
- ![One-command health and readiness check](media/06-health-check.gif)
145
+ ![One-command health and readiness check](https://raw.githubusercontent.com/Lincoln504/pi-research/main/docs/media/06-health-check.gif)
139
146
 
140
147
  Once installed, ask the agent to research something — its skill system activates
141
148
  pi-research automatically. The in-package readme (`skills/pi-research/README.md`) and
@@ -109,15 +109,21 @@ agent to invoke:
109
109
  | `youtube_transcript` | ✓ | ✓ | YouTube captions via youtubei.js + BotGuard PoToken (≤3 videos, one call per researcher) |
110
110
  | `security_search` | ✓ | ✓ | NVD, CISA KEV, GitHub Advisories, OSV |
111
111
  | `stackexchange` | ✓ | ✓ | Stack Exchange network |
112
- | `grep` | — | ✓ | Local ripgrep (from pi-coding-agent) |
112
+ | `grep` | — | — | Local ripgrep (from pi-coding-agent) — off by default, see below |
113
113
  | `read` | ✓ | ✓ | Local file reads (from pi-coding-agent) |
114
114
 
115
115
  In deep research `search` is excluded — the coordinator runs the search burst and hands
116
- out URLs directly. In quick research `grep` is excluded — the single session is not
117
- expected to traverse a local codebase. Researchers cannot write files, run shell
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)).
116
+ out URLs directly.
117
+
118
+ `grep` is excluded by default at **every** depth and on every front-end (CLI, SDK, agent
119
+ skill, pi extension): this is web research, and a capable model otherwise spends turns
120
+ searching the local filesystem. Passing an explicit `excludeTools` list — `--exclude-tools`
121
+ on the CLI, the `excludeTools` tool parameter in the extension — REPLACES that default, so
122
+ naming other tools re-enables `grep`. `PI_RESEARCH_DISABLED_TOOLS` is separate and strictly
123
+ additive: it can only ever remove capabilities (see [CONFIGURATION.md](CONFIGURATION.md)).
124
+
125
+ Researchers cannot write files, run shell commands, or reach the network outside these
126
+ tools.
121
127
 
122
128
  ### Browser infrastructure
123
129
 
@@ -281,8 +287,12 @@ src/
281
287
 
282
288
  ### Key design decisions
283
289
 
284
- Sandboxed researchers — researcher agents are limited to the tool set above. They cannot
285
- write files, spawn processes, or make arbitrary network calls.
290
+ Read-only researchers — researcher agents are limited to the tool set above. They cannot
291
+ write files, spawn processes, or make arbitrary network calls. They *can* read and grep
292
+ files: `read` and `grep` are registered unconditionally and the researcher exclusion list
293
+ (`bash`, `write`, `edit`, `repl`, `git`, `terminal`) does not cover them. The `cwd` passed
294
+ to those tools is a resolution base, not a jail — an absolute path resolves to itself — so
295
+ the boundary is "no mutation", not "only this directory".
286
296
 
287
297
  Worker pool over direct browser — browser processes are isolated in workers so a crash
288
298
  in one cannot affect the orchestrator or other sessions.
@@ -308,7 +318,7 @@ other). This sits above `@lancedb/lancedb` 0.29's declared Arrow peer ceiling
308
318
  whenever `@lancedb/lancedb` is upgraded.
309
319
 
310
320
  Pinned validation library — `typebox` is pinned to the exact version the pi host packages
311
- depend on (`@earendil-works/pi-ai` / `@earendil-works/pi-coding-agent` pin `1.1.38`). Every
321
+ depend on (`@earendil-works/pi-ai` / `@earendil-works/pi-coding-agent` 0.83.0 pin `1.3.7`). Every
312
322
  tool's parameter schema is built with TypeBox here and handed across the boundary to pi's
313
323
  tool system, so the two must agree on `Value.Check`/`Convert` semantics. A floating `^1.1.38`
314
324
  range let a fresh consumer install resolve pi-research to a newer TypeBox than pi's, shipping
package/docs/CHANGELOG.md CHANGED
@@ -5,6 +5,87 @@ 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.2.0] - 2026-08-05
9
+
10
+ Clears the medium/low findings deferred out of the 1.1.2 audit, then a second
11
+ full-tree audit of everything since. Every item below was confirmed against the
12
+ code — and, where the claim was about the operating system, a package manager, or
13
+ a library's failure behaviour, against a live experiment on this machine rather
14
+ than from memory.
15
+
16
+ A minor (not patch) version because the range carries deliberate contract
17
+ changes alongside the fixes: cancelled runs now exit `128 + signal` instead of
18
+ `70` (and the agent-skill launcher forwards cancelling signals to the engine),
19
+ and the host-version self-check gained the tested-ceiling warning.
20
+
21
+ ### Fixed
22
+ - **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.
23
+ - **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.
24
+ - **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.
25
+ - **A signal aimed at the skill launcher orphaned a live engine run.** The launcher installed no signal handlers and never forwarded anything to its child — so a harness that enforces its command timeout by SIGTERM-ing the *launcher* pid (not the process group) killed the messenger and left the engine researching for minutes, holding one of the 3 machine-wide run slots plus the browser pool and embedding server. The cancelling signals are now forwarded to the child and the child's exit drives the launcher's own `128 + N`, verified end-to-end against a stub engine. A duplicate delivery (a terminal Ctrl-C already signals the whole group) is harmless — the engine swallows its second signal.
26
+ - **A caller mid-lock-acquisition slipped past teardown and ran its critical section on a disposed service.** `cleanup()`'s drain watches only the *held* handle and the retirement check runs before the retry loop is entered — so a caller contending for a peer's lock (holding nothing, turn already granted) was invisible to teardown, survived `cleanup()`, won the lock when the peer released, and ran its read-modify-write against torn-down services. The retry loop now re-checks retirement each attempt and again between winning the file and committing the acquisition. Also in the lock's reclaim dance: a transient read failure *after* the rename-to-trash used to silently destroy a lock that may have just changed hands to a live peer (the verify never ran) — it is now restored, on the same "a restored lock is always better than a destroyed one" rule f6c7604e established; the no-hardlink restore fallback re-creates the path exclusively instead of `rename` (which would silently replace a lock a peer created in the probe window); and trash copies leaked by a crash mid-dance are swept at startup (nothing collected them before).
27
+ - **A document arriving during the embedder's idle teardown could still be resurrected by a shutdown.** The 1.1.2-era fix let an `initialize()` that raced an *idle* dispose wait it out and revive — but a waiter already parked on that dispose re-checked only the *state* afterwards, not the *reason*. A terminal dispose landing as a downgrade while it waited left state at `'idle'`, so the parked waiter fell through and ran a full model load (30s cached, up to 300s uncached) during process teardown — the exact resurrection the downgrade exists to prevent. The waiter now re-reads the downgraded reason and refuses. (A later, deliberate `embed()` after a completed dispose still revives — that re-use is pinned behaviour.)
28
+ - **The per-query failure attribution never reached the researcher it was written for.** The infrastructure-vs-query distinction added for search failures was produced and then dropped: the search tool — the only surface the agent actually reads — rendered `*No results found.*` for every zero-result query regardless of cause, so a timeout or dead worker still read as query feedback and the agent still rewrote perfectly good queries. The tool now renders the attributed message. Two residual paths in the same class: a worker-side task cancellation (pool drain, leader handover) with no caller abort fell through to the "query may be too narrow" default and is now attributed as the infrastructure interruption it is; and a user's Esc/Ctrl-C was counted into the search *error* metrics before the abort rethrow, painting a failing backend out of a habit of cancelling runs. Duplicate query strings in one burst are also deduplicated now — both the result map and the failures map are keyed by the string, so two racing instances of the same query could clobber delivered results with a later timeout.
29
+ - **`--json` could report a cancellation with the wrong number — or as retryable.** The cancellation latch recorded *that* a signal fired but not *which*, so an abort unwinding through the error reporter returned a flat 130 racing the signal path's own `128 + N` — SIGTERM nondeterministically exited 130 or 143, and the `exitCode` field could contradict the actual status. The latch now carries the signal name, so both racers agree. A capacity error surfacing after Ctrl-C could also emit `retryable: true` alongside `cancelled: true` — a consumer honouring the former would re-run the research the user just stopped; `cancelled` now wins. And a bare `AbortError` *name* with no latch and none of the exact cancellation signatures is no longer classified as a cancellation: internal timeout aborts throw that name too, and an escaped one would have reported "cancelled — do not re-run" for a run nobody stopped.
30
+ - **Project-scoped keys in an explicit `--config` file were silently dropped.** The env bridge skips per-directory keys so the *ambient* config.env cannot out-rank a per-cwd override — but `getConfig` never reads the `--config` path at all, so a depth or knowledge-store mode set in `--config ci.env` simply did not apply, with no warning, while the bridge's comment claimed otherwise. Keys from an explicitly named config file are now bridged: a file the user names on this exact invocation outranking the per-directory registry is the intent.
31
+ - **A failed prune's duplicates could outlive every subsequent refresh — and be the copy served.** The add-then-prune failure mode (accepted in 1.1.2's ingest fix) leaves two generations of a URL; three follow-on holes made those duplicates sticky. The scrape-cache lookup took `limit(1)` with no ordering, and LanceDB's fragment-scan order typically returns the *oldest* row — so the superseded generation was served, and its older timestamp fed the serve-age gate. The dedup check compared the incoming hash against whichever row the store returned first, so with stable content the refresh matched the old generation, skipped, and never pruned — the one code path that ever looks at that URL again preserved the duplicates forever. And a prune skipped because the store was closing (a post-run background ingest racing Ctrl-C — the *common* window) resolved as success, so the failed-prune metric never saw its most frequent cause. Newest generation now wins everywhere, the unchanged-content skip sweeps older generations, and the closing-skip is surfaced. The strict `timestamp < olderThan` boundary is now pinned against the real engine (a row stamped *at* the boundary is the replacement itself and must survive).
32
+ - **Service-container lifecycle gaps from the 0cba8c58 race class.** `clearService`/`replace` disposed the early-published instance while its `initialize()` was still running, handing the awaiting caller a service disposed under it (observed shape: store-closed errors when `/research-config` changed the knowledge mode during a concurrent first search) — both now settle the in-flight initialization first. A ctx-bearing re-initialization was invisible to `disposeAll`'s settle-wait (and to concurrent callers) because it was never recorded as in-flight; it is now. `getServiceLifecycle` reported a mid-initialization service as `INITIALIZED` (instance is published before its initialize completes — that early publish is what lets dependency cycles resolve); the in-flight promise is now checked first. And the scheduler's own `shutdown()` aborted permanently — drain, state-clear, server-stop and pool teardown all skipped, with `isShuttingDown` already latched — if container disposal began between its guard and its `getService` call.
33
+ - **The restart cooldown trusted the wrong fact.** d4f300d7 made the herd-guard cooldown yield when the registered leader is dead — but the cooldown protects the *cached handle*, and "the registered leader is alive" is not "our handle points at it". If our leader died and another process elected a successor inside the window, the probe found the (new) leader alive, the cooldown held, and every retry kept failing against the corpse's port. The check now also requires the cached client to target the registered leader's port.
34
+ - **A pre-release of the floor version passed the host check.** `0.80.8-rc.1` orders *below* the `0.80.8` floor under semver and may predate the very APIs the floor guards (ModelRuntime landed in 0.80.8), but the parser discarded the tag and passed it as ok. The floor comparison now accounts for it; pre-releases above the floor are unaffected.
35
+ - **Small IPC asymmetries from the 4a4e65c6 class.** The browser server's 403/503/405 refusal paths now drain the request before answering (the embedding server's mirrored path already did — an undrained body can surface to the client as ECONNRESET instead of the status), and the WebGPU probe no longer decodes its child's pipe chunks individually — the last surviving `body += chunk` variant in the tree (log-mojibake only: the sentinel is ASCII).
36
+ - **Multi-byte UTF-8 was silently corrupted at chunk boundaries on every local IPC path.** Six HTTP paths (browser client/server, embedding client×2/server) accumulated bodies with `body += chunk`, which stringifies each Buffer on its own — a character split across a chunk boundary decoded as two fragments and both halves became U+FFFD. Measured: 15 mangled characters in a 560KB CJK payload. Nothing threw (`JSON.parse` still succeeded), so the damage reached the report and the knowledge store unannounced — and only ever for non-ASCII content, which is why it survived. The browser client carries scraped page text and the embedding server the text being embedded, so this corrupted both the output and the vectors. Bodies are now buffered and decoded once (`Utf8Body`), which also makes the payload caps count real bytes instead of UTF-16 units of an already-broken string.
37
+ - **Every shutdown path yanked a held cross-process lock and could wedge the lock queue forever.** `FileLockService.cleanup()` ran with no check for an in-flight critical section: it unlinked the lock file mid read-modify-write (a peer could acquire and lose the update), and because `releaseLock()` returns early when the handle is already null — *before* the `finally` that hands the FIFO turn on — every caller parked on the queue blocked forever. That wait has no timeout of its own (the lock timeout only starts once the turn is granted), so it was a permanent wedge, not a slow path. Teardown now drains the held lock first (bounded), retires the instance so late callers fail fast instead of hanging, and always hands the turn back.
38
+ - **The lock-reclaim restore path destroyed the very lock it meant to put back.** Both trash-and-verify branches restored a not-ours lock by linking it back inside a swallowing catch and then unlinking the trash copy unconditionally — correct only when the link failed because a peer had already re-created the lock. Any other failure (EPERM, a filesystem without hardlinks, a Windows link limit) silently destroyed a live peer's lock and let this process win it. The two cases are now distinguished: an occupied path means the peer's lock is authoritative and ours is dropped; otherwise ours is moved back. `withLock`'s second parameter — accepted and then ignored entirely — is gone rather than left to mislead.
39
+ - **Windows deleted live browser profiles out from under running research.** The profile-reclaim freshness heuristic read `parent.lock`'s mtime, which is stamped once at browser startup and never refreshed — so every browser alive longer than the 2-minute window looked dead, the normal case for a research run. The "the OS won't delete a held-open file" assumption was no safety net either: a recursive delete removes prefs and cookies well before it reaches the one locked file. Replaced with a positive probe of whether the lock is still held open.
40
+ - **`PI_RESEARCH_DISABLED_TOOLS` was not strictly subtract-only.** Disabling any unrelated tool silently *enabled* `grep` (the merged list replaced the default exclusions instead of stacking on them), and the same "no preference" input meant opposite things on the CLI and in the extension because an empty array tests truthy. Disabling now only ever removes capabilities, identically everywhere.
41
+ - **A vanished embedding client leaked its server request forever.** The embedding server's request promise resolved only from `'end'` or `'error'`, but a client that goes away emits `'aborted'`/`'close'` and neither of those — the handler then dangled indefinitely holding the request, the response and the buffered body. Not exotic: the client destroys the socket on its own timeout. `'close'` now settles it.
42
+ - **Three security clients labelled metrics with the raw user search term.** The registry keys counters by label set and is cleared only explicitly, so every distinct query added a permanent entry for the life of a pi host session — unbounded growth driven straight by user input. The term labels are gone.
43
+ - **`cleanup.cjs` honoured a RELATIVE `XDG_CACHE_HOME` that the runtime deliberately rejects.** Since that script exists to delete things, it would have `rmSync`'d a cwd-relative `pi-research` tree — whatever happened to sit there — while leaving the real cache in place. It now mirrors the runtime's resolution, which its comment already claimed it did.
44
+ - **A queued browser task could be dispatched with its timeout already disarmed.** All three scheduler entry points built ONE timeout promise and raced it twice — once against the enqueue, again inside the enqueued closure against the pool call. The two races have different lifetimes: when the outer one settled (a caller abort, a queue-capacity rejection), the method's `finally` cleared the shared timer, and a task still *queued* at that moment was dispatched afterwards racing a promise that could never settle — a guard that silently stopped guarding while the task held a queue concurrency slot. The closure's bound is now its own timer with its own lifetime (see the dead-worker entry above, which this replacement also had to cover).
45
+ - **Orphaned browser workers never noticed their parent had died — on Linux the guard was completely inert.** The worker polls `process.kill(process.ppid, 0)` to detect an orphaned state, and treats `EPERM` as "parent alive but owned by another user". But POSIX re-parents an orphan the instant its parent dies — to PID 1, or to the nearest subreaper — and `process.ppid` is re-read from the OS on every access, so the probe targeted PID 1, which always exists and raises `EPERM` for an unprivileged process. The guard therefore reported "alive" forever and orphaned workers kept their camoufox browsers running. Measured directly: a child's `ppid` flips from the real parent to `1` on the first tick after the parent exits, and `process.kill(1, 0)` throws `EPERM`. The parent PID is now captured once at setup, re-parenting is checked explicitly (the authoritative POSIX signal), and the liveness probe targets the original PID rather than whatever inherited the process. Windows does not re-parent, so the probe remains the mechanism there.
46
+ - **The browser-leader PID-reuse guard was dead code.** `StateManager.isPidAlive` passes `state.browserServer.startTime` as `expectedStartTime` so a recycled PID cannot be mistaken for a live leader, and `ProcessLifecycleService` implements the comparison — but the leader election in `scheduler-factory.ts` wrote `state.browserServer` as an inline literal carrying only the PID, bypassing the `setBrowserServer` API that records the start time. `expectedStartTime` was therefore always `undefined` and the check silently degraded to a bare signal-0 probe, which a reused PID passes: every follower would adopt an unrelated process as the browser leader and connect to its port. The election now records its own start time (matching the embedding leader, which already did).
47
+ - **A recently-triggered restart could leave the scheduler handle pointed at a dead leader.** A module-global 10 s cooldown suppresses cascading restarts, but it applied unconditionally — including when the cached client pointed at a process that no longer exists. A *hard* leader kill (SIGKILL, OOM) surfaces as `ECONNREFUSED`, which is a transient socket error but **not** a pool-shutdown error, so it takes the cooldown-guarded path rather than the leader-handover path added in 1.1.0. Inside the cooldown window the handle was kept, the retry re-resolved to the same dead port, and the task failed identically — the same stale-handle failure 1.1.0 fixed for graceful shutdown, reachable through the ungraceful door. The cooldown now yields whenever the registered leader cannot be confirmed alive; the herd guard is unchanged for the live-leader case it exists for.
48
+ - **A failed knowledge-store refresh destroyed the entry it was refreshing.** Re-ingesting a changed URL deleted the existing chunks and *then* inserted the replacements. Anything that failed in between — an unreachable embedder, `ENOSPC`, a store closing mid-write — left the URL with no entry at all, having thrown away a perfectly good older copy to make room for one that never arrived. The replacement is now written first and the superseded rows pruned afterwards, bounded by the new rows' timestamp so the prune cannot take what was just inserted. A failed prune leaves duplicates (cleaned up by the next refresh) instead of discarding a write that already succeeded.
49
+ - **A document arriving during the embedder's idle teardown was dropped, not retried.** The idle timer releases the embedding pipeline purely to reclaim memory, and the next embed is meant to bring it straight back — but `dispose()` flips the state synchronously and then waits (up to ~5 s for in-flight embeds plus the pipeline's own teardown), and for that entire window `initialize()` threw `Cannot initialize while disposing`. No classifier matches that string, so the knowledge writer queue took its generic branch and dropped the document with no retry. An *idle* dispose is now awaited and the embedder revived; a *terminal* dispose (shutdown, config change) still refuses, so teardown cannot resurrect the pipeline it is releasing.
50
+ - **Cancelling a run was reported as an infrastructure outage.** On cancellation every query returns empty, which is indistinguishable from a dead browser pool by result count alone — so the run failed with "Browser workers may be unavailable, DuckDuckGo is unreachable, or the system is under extreme load". The user had pressed Ctrl-C. Cancellation is now detected before that heuristic and propagated as a cancellation, including out of `search()`, which previously flattened it into per-query `unknown` errors and let the run carry on to synthesize from nothing.
51
+ - **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.
52
+
53
+ ### Changed
54
+ - **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.
55
+
56
+ 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.
57
+ - **The evaluator is no longer called on the final round.** At the round cap the loop breaks to the forced final synthesis regardless of what the evaluator decides, and that path re-runs it with `mustSynthesize` — so on every capped run whose evaluator would have said `delegate` (the common case, since it is being cut off mid-plan) the in-loop call was a second full-context coordinator request whose result was discarded. Exactly one coordinator call now happens either way, with the same outcome.
58
+
59
+ - **The agent-skill launcher reported a cancelled run as a runtime error.** `run.mjs` maps a signal-killed engine to exit `70` — the code `SKILL.md` documents as "runtime error, suggest one retry" — so a harness that SIGTERMs a run on timeout prompted the agent to re-run research that had deliberately been stopped, one layer above the engine's own (now fixed) classification. `SIGINT`/`SIGTERM`/`SIGHUP`/`SIGQUIT` now report "cancelled" and exit with the same `128 + N` the shell and the engine use; `SIGKILL` and the rest still exit `70`, since an unexplained hard kill is more likely an OOM than a user's decision. Verified end-to-end against a stub engine: SIGTERM → `143`, SIGINT → `130`, SIGHUP → `129`, SIGQUIT → `131`, SIGKILL → `70`.
60
+ - **A leader-liveness probe could fan out across a failing query burst.** The cooldown check added above runs on the failure path, where a 20-query search losing its leader reaches it up to 19 times within milliseconds. The probe is a `/proc` read on Linux, but on Windows the PID-reuse guard resolves the process start time by spawning `powershell -NoProfile` — so an uncoalesced burst meant ~19 concurrent PowerShell processes on the slowest supported platform. Concurrent probes now share one result for a 1 s window, far shorter than the restart cooldown itself: it collapses a burst rather than caching a verdict.
61
+
62
+ - **The service container handed half-initialized services to concurrent callers.** `_initializeService` publishes the instance *before* awaiting the service's own `initialize()` — deliberately, since that is what lets a dependency cycle resolve instead of deadlocking — but any unrelated caller arriving in that window received a service whose setup had not run. `StateManagerService` only assigns its inner manager after nine awaited resolutions, so every method on it throws until then, and the embedding factory resolves it and immediately calls `updateState()`. A caller passing `ctx` was worse: it re-ran `initialize()` on an instance still executing it, constructing a second `StateManager` and discarding the first. `get()` now waits for an in-flight initialization — except when called from *within* another service's initialization, which is exactly the cycle the early publish exists to break. `tryGet()` deliberately still exposes non-settled instances: it doubles as the lifecycle inspector the health check uses to report `initializing`/`disposed` without forcing initialization.
63
+ - **Teardown could report completion while a service was still starting up.** `disposeAll()` snapshotted "instances that are non-null right now", so a `get()` whose factory had not yet resolved was invisible to it and nothing awaited it — the service then installed itself into the registry *after* shutdown finished and was never disposed. For `BrowserTaskScheduler` that is a listening HTTP server plus a cluster pool and Camoufox children outliving shutdown; for `KnowledgeStoreService`, an ONNX session and LanceDB handles. In-flight initializations are now settled first, under a bounded wait so a service blocked on something slow cannot hold shutdown open.
64
+ - **The PID-reuse guard was inert on macOS.** The non-Linux start-time lookup used `ps -o etimes=`, a procps-ng/FreeBSD extension that Apple's `ps` does not implement (POSIX mandates only `etime`). It fails quietly — procps-ng writes to stderr and still exits 0 with empty stdout, BSD `ps` exits non-zero — so the lookup returned `null` for every PID and the file lock, run-slot semaphore, GPU lease and both leader elections all silently degraded to a bare signal-0 check, while their comments claimed PID-reuse safety. Now falls back to parsing POSIX `etime`.
65
+ - **A process that could not be signalled was reported as dead.** `isProcessAlive` caught every `process.kill(pid, 0)` failure as "gone", but `EPERM` (POSIX) and `EACCES` (what libuv surfaces on Windows for `ERROR_ACCESS_DENIED`) mean the process *exists* and simply is not ours to signal — another user on a shared host, a CI side-car, a different Windows session. Treating those as dead lets a caller reclaim a live lock, run slot or leader from its owner. Existence and identity are now separate steps, so a permission error no longer short-circuits the start-time comparison either: a PID-reuse impostor at a non-signalable PID is still detected. This also made the existing impostor test load-bearing — it previously passed because `kill(1, 0)` raises `EPERM` and `EPERM` was read as dead, so the start-time comparison never ran and the test would have passed even with start-time lookup completely broken.
66
+ - **Install/uninstall ran a shell string built from a filesystem path.** `setup.cjs` and `cleanup.cjs` invoked camoufox via `execSync` with the resolved binary path interpolated into a command string. `execSync` goes through `/bin/sh -c`, where `$(…)`, backticks and quotes remain live *inside* double quotes — so a package installed under a directory whose name contains shell metacharacters would execute them at install time. Both now use `spawnSync` with an argv array, matching the runtime path in `ensure-browser.ts`.
67
+
68
+ - **The embedding server accepted work from any local process.** The browser server has required a shared secret on every request since 1.0.x; the embedding server, which exposes the same kind of loopback HTTP endpoint, checked nothing. Binding `127.0.0.1` is not an authorization boundary — any local process could enqueue embedding work, and because the serial queue steps the leader *down* when it poisons, sustained abuse denies the knowledge store for every process in the cluster rather than merely burning GPU time. It now requires the same class of secret, published to followers through the state file that already carries the browser one, checked in constant time before the body is read (so an unauthenticated caller cannot make the server buffer 50 MB) and before routing (so it cannot probe leadership state either). A leader that predates the secret publishes none and checks none, so a mixed-version cluster keeps working.
69
+ - **Copy-installed skills were never upgraded.** A symlink re-reads the package on every use, so it is always current; a copy is a point-in-time snapshot, and reconcile skipped non-symlinks entirely while the manifest recorded no version — so drift was undetectable by construction. Copies are not an edge case: Windows without symlink privilege, cross-device installs, `--copy`, and the documented OpenClaw route all produce them, and those users kept the `SKILL.md` their engine shipped with indefinitely, *including its exit-code contract*, while the engine underneath them changed. Entries are now version-stamped and an owned copy is refreshed when the package moves on. Ownership is re-checked first, so a foreign directory at the tracked path is still never touched, and entries written before stamping are refreshed once.
70
+ - **A cancelled knowledge search reported an empty store.** The catch-all returned the "nothing found" miss string for *every* error including an abort, so the calling agent read a definitive "the store has nothing on this" and proceeded to full live research — exactly the work the user had just interrupted. Cancellation now propagates. (Same misclassification as the search layer's outage message on Ctrl-C.)
71
+ - **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.
72
+
73
+ ### Changed
74
+ - **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.
75
+ - **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).
76
+ - **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.
77
+ - **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`.
78
+
79
+ ### Documentation
80
+ - **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.
81
+ - **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.
82
+ - **`--help` did not list exit code `130`.** The same omission `1.1.1` fixed for `75`.
83
+ - **`.env.example` implied `PI_RESEARCH_PURGE_BROWSERS` takes effect on `npm uninstall`.** It is read by `scripts/cleanup.cjs`, which npm ≥7 never invokes — and that script reads the process environment, not this file. The entry now shows the command that actually works.
84
+ - **`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`.
85
+
86
+ ### Tests
87
+ - **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.)
88
+
8
89
  ## [1.1.2] - 2026-08-04
9
90
 
10
91
  > Supersedes `v1.1.1`, which was tagged but never published: its release run was
@@ -192,4 +273,3 @@ The following issues were identified during the v1.0.7 audit but intentionally d
192
273
  - Inconsistent `knowledge --json` payload shapes
193
274
  - Backward-clock-step tolerance across multiple rate limiters
194
275
 
195
- See `DEFERRED-FINDINGS-2026-07-14.md` for full details.
@@ -6,7 +6,7 @@ model. This document covers the
6
6
  settings exposed in the `/research-config` TUI first, then the complete
7
7
  environment-variable reference, and finally how the configuration layers resolve.
8
8
 
9
- ![The /research-config settings TUI](media/04-config.gif)
9
+ ![The /research-config settings TUI](https://raw.githubusercontent.com/Lincoln504/pi-research/main/docs/media/04-config.gif)
10
10
 
11
11
  ### Settings in the TUI
12
12
 
@@ -79,7 +79,7 @@ Research
79
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
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. |
81
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. |
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. |
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. Strictly additive — it can only remove capabilities, never grant one, and it stacks on top of the default exclusions rather than replacing them. |
83
83
  | `PI_RESEARCH_REPORT_EXPORT_ENABLED` (TUI) | `false` | — | Front-ends write a Markdown report to disk and surface its path. |
84
84
  | `PI_RESEARCH_REPORT_EXPORT_DIR` | _(smart cwd)_ | — | Pin exported reports to a fixed directory, bypassing the cwd-relative resolution. Useful for the agent skill, which runs from the host agent's arbitrary directory. |
85
85
  | `PI_RESEARCH_MAX_SCRAPE_TOKEN_FRACTION_FOR_SCRAPING` | `0.15` | 0.05–1.0 | Max fraction of the context window used for initial scrape context. |
@@ -151,11 +151,13 @@ Diagnostics & platform
151
151
  | `PI_RESEARCH_TMP_DIR` | `~/.cache/pi-research/profiles` | Transient per-worker browser-profile directory. Disk-backed by default (kept off a RAM-backed `/tmp` so per-worker profiles don't add memory pressure). Point under the system temp dir to opt into tmpfs/RAM. |
152
152
  | `PI_RESEARCH_STATE_DIR` | `~/.pi/research/state` | Override the state directory (active sessions, browser status, project registry). |
153
153
  | `PI_RESEARCH_TUI_REFRESH_DEBOUNCE_MS` | `100` | TUI refresh debounce (0–1000 ms). |
154
- | `PI_RESEARCH_SKIP_HEALTHCHECK` | _(unset)_ | Set `1`/`true` to skip the pre-flight browser/embedding health check and rely on per-task timeouts. |
154
+ | `PI_RESEARCH_SKIP_HEALTHCHECK` | _(unset)_ | Set `1`/`true` to skip the pre-flight browser/embedding health check and rely on per-task timeouts. **Depth 0 (quick) only** — depth 1-3 runs have no such pre-flight check, so this has no effect on them. |
155
155
  | `PI_RESEARCH_USE_XVFB` | _(unset)_ | Linux only. Bare-TTY runs are true-headless and need no X server; set `true` to opt into a virtual framebuffer (`sudo apt install xvfb`). |
156
156
  | `PI_RESEARCH_SKILL_DIR` | _(auto)_ | Override the bundled research-skill source directory used by the skill installer. |
157
- | `PI_RESEARCH_PURGE_BROWSERS` | _(unset)_ | Set `1` during `npm uninstall` to also delete the shared camoufox browser cache (kept by default because other installs may use it). |
157
+ | `PI_RESEARCH_PURGE_BROWSERS` | _(unset)_ | Read by the bundled `scripts/cleanup.cjs`: set `1` to also delete the shared camoufox browser cache (kept by default because other installs may use it). Note npm ≥7 does not run `preuninstall`, so that script does not fire on `npm uninstall` — see [AGENT-SKILL.md](AGENT-SKILL.md). |
158
158
  | `PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD` | _(unset)_ | Set `1` during `npm install` to skip the camoufox browser download (fetched lazily on first use instead; standard Playwright convention). |
159
+ | `PLAYWRIGHT_BROWSERS_PATH` | _(user cache)_ | Standard Playwright variable, honoured here too: overrides where the camoufox binary is stored and looked up, and is passed through to browser workers. |
160
+ | `XDG_CACHE_HOME` | `~/.cache` | Standard XDG variable. When set, every `~/.cache/pi-research/...` path below is rooted at `$XDG_CACHE_HOME/pi-research/...` instead. |
159
161
  | `PLAYWRIGHT_INSTALL_DEPS` | _(unset)_ | Linux only. Set `true` during `npm install` to also install system libraries via `npx playwright install-deps` (same as `npm run install:system-deps`). |
160
162
  | `PI_RESEARCH_CONFIG_DIR_NAME` | `.pi` | Override the host config-directory name under your home dir (advanced; e.g. set to share another harness's config root). |
161
163
 
@@ -165,7 +167,7 @@ Testing only — never enable in production
165
167
  |----------|-------------|
166
168
  | `PI_RESEARCH_MOCK_SEARCH` | Return fabricated search results instead of real web data. |
167
169
  | `PI_RESEARCH_MOCK_SCRAPE` | Return fabricated scrape results instead of real page content. |
168
- | `PI_RESEARCH_FORCE_READY` | Bypass readiness checks and run even when critical services failed to initialize. |
170
+ | `PI_RESEARCH_FORCE_READY` | Bypass readiness checks and run even when critical services failed to initialize. **pi extension only** — the CLI, agent skill and SDK do not consult it. |
169
171
  | `PI_RESEARCH_ALLOW_LOOPBACK_SCRAPE` | Permit scraping loopback/private/internal addresses, bypassing SSRF protection. |
170
172
 
171
173
  ### How configuration is layered
@@ -245,8 +247,8 @@ All pi-research state lives under its own namespace, `~/.pi/research/`:
245
247
  | `~/.pi/research/state/project-settings.json` | Project registry (per-directory settings). |
246
248
  | `~/.pi/research/state/` | Active sessions, browser status, locks. |
247
249
  | `~/.pi/research/knowledge_db/` | The knowledge store (LanceDB), unless `PI_RESEARCH_KNOWLEDGE_DIR` is set. |
248
- | `~/.cache/pi-research/profiles/` | Transient browser profiles, unless `PI_RESEARCH_TMP_DIR` is set. |
249
- | `~/.cache/pi-research/webgpu-viability.json` | Cached WebGPU-viability verdict (see the knowledge store doc). |
250
+ | `~/.cache/pi-research/profiles/` | Transient browser profiles, unless `PI_RESEARCH_TMP_DIR` is set. Rooted at `$XDG_CACHE_HOME` when that is set. |
251
+ | `~/.cache/pi-research/webgpu-viability.json` | Cached WebGPU-viability verdict (see the knowledge store doc). Rooted at `$XDG_CACHE_HOME` when that is set. |
250
252
 
251
253
  Paths can be relocated with `PI_RESEARCH_STATE_DIR`, `PI_RESEARCH_KNOWLEDGE_DIR`,
252
254
  and `PI_RESEARCH_TMP_DIR`.
@@ -12,7 +12,7 @@ in two distinct ways, and it is an optional cache — research works without it:
12
12
  store; instead the orchestrator hands each researcher the previously useful URLs for
13
13
  its goal as starting points to re-scrape live.
14
14
 
15
- ![Knowledge-store hit — a cached answer returned without a live run](media/03-knowledge-store.gif)
15
+ ![Knowledge-store hit — a cached answer returned without a live run](https://raw.githubusercontent.com/Lincoln504/pi-research/main/docs/media/03-knowledge-store.gif)
16
16
 
17
17
  Together these make repeat work faster and cheaper.
18
18
 
@@ -177,8 +177,9 @@ built with, the store is migrated according to `PI_RESEARCH_MIGRATION_STRATEGY`:
177
177
  If `re-embed` fails, pi-research falls back to `backup`. A failed `backup` (or
178
178
  `drop`) aborts the migration instead: the store stays on the old model and the
179
179
  next open retries — data is never dropped unless `drop` was chosen explicitly.
180
- Changing the model from the `/research-config` menu always clears the current
181
- store and starts fresh.
180
+ Changing the model from the `/research-config` menu prompts for confirmation
181
+ before clearing the current store and starting fresh; declining reverts the model
182
+ change and leaves the store intact.
182
183
 
183
184
  ### Managing the store
184
185
 
@@ -22,7 +22,7 @@ Three slash commands are also registered:
22
22
  | `/research-config` | Opens the interactive TUI settings dashboard. |
23
23
  | `/knowledge-store <query>` | Searches the local knowledge store for a query and returns a synthesised answer from previously researched findings. Unavailable when Knowledge Mode is `none`. The store auto-manages its own compaction, so there is no maintenance subcommand. |
24
24
 
25
- ![Running a live investigation with the /research slash command](media/01-slash-research.gif)
25
+ ![Running a live investigation with the /research slash command](https://raw.githubusercontent.com/Lincoln504/pi-research/main/docs/media/01-slash-research.gif)
26
26
 
27
27
  ### Tools
28
28
 
package/docs/SDK.md CHANGED
@@ -98,6 +98,61 @@ report caused by a sparse topic apart from one where most researchers failed). B
98
98
  > observer with `onRunQueued(slots, maxWaitMs)` to tell a waiting user the run is
99
99
  > queued rather than hung.
100
100
 
101
+ ### Cancellation
102
+
103
+ `runDeepResearch`, `runQuickResearch`, `verifyUrl` and `scrapeUrl` all take an
104
+ optional `AbortSignal` as their **last positional argument** (not a field of the
105
+ options object):
106
+
107
+ ```js
108
+ const controller = new AbortController();
109
+ setTimeout(() => controller.abort(), 60_000);
110
+
111
+ const markdown = await runDeepResearch('…', { depth: 2 }, controller.signal);
112
+ ```
113
+
114
+ The orchestrator checks the signal at every round boundary and threads it into
115
+ search, scraping and the LLM calls, so an abort stops work rather than merely
116
+ detaching from it.
117
+
118
+ **An abort does not always reject.** The outcome depends on whether anything was
119
+ collected before the signal arrived:
120
+
121
+ | State when aborted | Result | Observer |
122
+ |---|---|---|
123
+ | At least one researcher report collected | **Resolves** with a partial synthesis built from what was gathered | `onComplete` |
124
+ | Nothing collected yet | **Rejects** (`Research aborted` / `Research cancelled`) | `onError` |
125
+
126
+ So a caller must not treat "resolved" as "ran to completion" when it aborted the
127
+ run itself — check your own signal, not just the promise. Exactly one of
128
+ `onComplete` / `onError` fires either way.
129
+
130
+ The CLI reports cancellation through the exit code, always: a signalled run exits
131
+ in the cancellation range — **`128 + signal`** (`130` Ctrl-C/SIGINT, `143` SIGTERM,
132
+ `129` SIGHUP, `131` SIGQUIT) — and a programmatic abort where no signal was involved
133
+ exits `130`. A cancelled run never exits `0`, because `0` means the research
134
+ succeeded and an agent relaying it would report a completed run to the user.
135
+
136
+ Whether a *partial* report reaches stdout first depends on how far the run got
137
+ before the abort landed: the handler aborts the in-flight run before tearing down,
138
+ so an orchestrator that can still synthesise what it gathered may print that
139
+ material ahead of the exit. Treat it as a best-effort bonus, not a guarantee — the
140
+ exit code is the part you can rely on.
141
+
142
+ Treat any code ≥ 128 as a cancellation; `pi-research --help` lists the full set, and
143
+ the agent-facing contract is the exit-code table in
144
+ [`SKILL.md`](../skills/pi-research/SKILL.md).
145
+
146
+ These are deliberately not the `70` runtime-error code and never carry
147
+ `retryable: true` — a cancel is a completed intention, not a fault to retry. The
148
+ codes are derived per-signal rather than fixed because the CLI *handles* those
149
+ signals rather than dying from them: a fixed code would make the observed exit
150
+ status depend on whether the handler beat a force-kill, whereas `128 + N` matches
151
+ what the shell reports either way.
152
+
153
+ You still must call `shutdownResearchSDK()` afterwards: aborting a run releases
154
+ that run, not the browser pool, LanceDB handles or worker processes.
155
+
101
156
  The SDK does not write report files. Report export is a front-end concern — the pi
102
157
  extension and the CLI / agent skill do it when `PI_RESEARCH_REPORT_EXPORT_ENABLED=true`.
103
158
 
@@ -19,7 +19,7 @@ copyleft). It is resolved at install time and **not bundled** into this package'
19
19
  artifacts (the bundled entry points are built `--packages=external`), so no MPL-covered source is
20
20
  combined into or redistributed by this MIT package. It is used in-process only to
21
21
  configure and launch the Camoufox (Firefox-fork) browser **binary as a separate
22
- operating-system process**, which this package then drives over the Playwright/CDP
22
+ operating-system process**, which this package then drives over the Playwright/Juggler
23
23
  protocol. The Camoufox binary is downloaded and run separately and is subject to its
24
24
  own license.
25
25
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincoln504/pi-research",
3
- "version": "1.1.2",
3
+ "version": "1.2.0",
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,
@@ -93,13 +93,14 @@
93
93
  "build": "node scripts/build.cjs all",
94
94
  "prepublishOnly": "node scripts/build.cjs all && node scripts/verify-package.cjs manifest",
95
95
  "prepare": "node scripts/prepare.cjs",
96
- "version": "node -e \"const fs=require('fs');const p=require('./package.json');const sp='./skills/pi-research/SKILL.md';let s=fs.readFileSync(sp,'utf8');s=s.replace(/\\\"version\\\": \\\"[^\\\"]*\\\"/,'\\\"version\\\": \\\"'+p.version+'\\\"');fs.writeFileSync(sp,s);console.log('✔ synced SKILL.md to v'+p.version)\" && git add skills/pi-research/SKILL.md",
96
+ "version": "node scripts/sync-skill-version.cjs && git add skills/pi-research/SKILL.md",
97
97
  "postinstall": "node scripts/setup.cjs",
98
98
  "preuninstall": "node scripts/cleanup.cjs",
99
+ "cleanup": "node scripts/cleanup.cjs",
99
100
  "setup": "node scripts/setup.cjs",
100
101
  "install:system-deps": "node scripts/setup.cjs --system-deps",
101
102
  "deps:check": "depcruise --config config/tooling/dependency-cruiser.cjs --output-type err-long src",
102
- "deps:generate": "npx --yes madge --image docs/deps.svg src/index.ts",
103
+ "deps:generate": "npx --yes madge@8.0.0 --image docs/deps.svg src/index.ts",
103
104
  "analyze:dependencies": "ncu",
104
105
  "analyze:compatibility": "depngn"
105
106
  },
@@ -126,7 +127,7 @@
126
127
  "pdf-oxide-wasm": "^0.3.75",
127
128
  "playwright-core": "1.60.0",
128
129
  "poolifier": "^5.3.2",
129
- "typebox": "1.1.38",
130
+ "typebox": "1.3.7",
130
131
  "undici": "^8.9.0",
131
132
  "youtubei.js": "^17.2.0"
132
133
  },
@@ -1,6 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- const { execSync } = require('child_process');
3
+ /**
4
+ * Uninstall-time cleanup: skill links, cache tree, state tree.
5
+ *
6
+ * IMPORTANT — this does NOT run on `npm uninstall`. npm removed the
7
+ * `preuninstall`/`postuninstall` lifecycle for the package being removed in
8
+ * npm 7; verified against npm 11, where `postinstall` fires on install but
9
+ * `preuninstall` never fires on uninstall. The `preuninstall` entry in
10
+ * package.json is retained for package managers that still honour it, but it
11
+ * must not be treated as the cleanup path.
12
+ *
13
+ * The supported paths are `pi-research skill uninstall` (removes the skill
14
+ * links via the same owned-only manifest logic) and running this script
15
+ * directly: node <package-root>/scripts/cleanup.cjs
16
+ */
17
+
18
+ const { spawnSync } = require('child_process');
4
19
  const { rmSync, existsSync, lstatSync, readlinkSync, readFileSync, writeFileSync, unlinkSync } = require('fs');
5
20
  const os = require('os');
6
21
  const path = require('path');
@@ -52,7 +67,7 @@ function removeInstalledSkills() {
52
67
  if (!owned) { kept.push(e); continue; }
53
68
  try {
54
69
  if (lstatSync(p).isSymbolicLink()) unlinkSync(p);
55
- else rmSync(p, { recursive: true, force: true });
70
+ else rmSync(p, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
56
71
  console.log(`pi-research: removed skill install at ${p}`);
57
72
  } catch (err) {
58
73
  kept.push(e);
@@ -93,8 +108,17 @@ if (process.env.PI_RESEARCH_PURGE_BROWSERS === '1') {
93
108
  if (process.env.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD !== '1') {
94
109
  try {
95
110
  const bin = resolveCamoufoxBin();
96
- const cmd = bin ? `"${bin}" remove` : 'npx camoufox-js remove';
97
- execSync(cmd, { stdio: 'inherit' });
111
+ // Argv array, not a shell string — see the matching note in setup.cjs: `bin`
112
+ // is derived from the install directory, and a shell string would let a
113
+ // directory name containing `$(…)` execute at uninstall time.
114
+ const isWin = process.platform === 'win32';
115
+ const res = bin
116
+ ? (isWin
117
+ ? spawnSync(`"${bin}"`, ['remove'], { stdio: 'inherit', shell: true })
118
+ : spawnSync(bin, ['remove'], { stdio: 'inherit' }))
119
+ : spawnSync('npx', ['camoufox-js', 'remove'], { stdio: 'inherit', shell: isWin });
120
+ if (res.error) throw res.error;
121
+ if (res.status !== 0) throw new Error(`camoufox remove exited with code ${res.status}`);
98
122
  console.log('pi-research: camoufox browser binaries removed.');
99
123
  } catch (error) {
100
124
  console.warn(`pi-research: could not remove camoufox binaries: ${error instanceof Error ? error.message : String(error)}`);
@@ -109,7 +133,7 @@ if (process.env.PI_RESEARCH_PURGE_BROWSERS === '1') {
109
133
  const legacyCacheDir = path.join(projectRoot, '.browser');
110
134
  if (existsSync(legacyCacheDir)) {
111
135
  try {
112
- rmSync(legacyCacheDir, { recursive: true, force: true });
136
+ rmSync(legacyCacheDir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
113
137
  } catch (error) {
114
138
  console.warn(`pi-research: could not remove ${legacyCacheDir}: ${error instanceof Error ? error.message : String(error)}`);
115
139
  }
@@ -126,7 +150,7 @@ if (existsSync(legacyCacheDir)) {
126
150
  function removeOwnedScratchDir(label, dir) {
127
151
  if (!dir || !existsSync(dir)) return;
128
152
  try {
129
- rmSync(dir, { recursive: true, force: true });
153
+ rmSync(dir, { recursive: true, force: true, maxRetries: 5, retryDelay: 50 });
130
154
  console.log(`pi-research: removed ${label} ${dir}`);
131
155
  } catch (error) {
132
156
  console.warn(`pi-research: could not remove ${label} ${dir}: ${error instanceof Error ? error.message : String(error)}`);
@@ -136,14 +160,30 @@ function removeOwnedScratchDir(label, dir) {
136
160
  // Cache tree: browser profiles, webgpu-viability probe, downloaded embedding models.
137
161
  // Same resolution as config.ts (XDG_CACHE_HOME || ~/.cache, then pi-research) on
138
162
  // every platform. Shared camoufox binaries live in ~/.cache/camoufox (handled above).
139
- const cacheHome = process.env.XDG_CACHE_HOME || path.join(os.homedir(), '.cache');
163
+ // A RELATIVE XDG_CACHE_HOME is ignored, exactly as embedder-utils.ts does at runtime.
164
+ // Honouring one here would rmSync a cwd-relative `pi-research` tree — deleting whatever
165
+ // happens to sit there — while leaving the real cache untouched. This script's whole job
166
+ // is deletion, so it must not resolve paths more loosely than the code that created them.
167
+ const xdgCache = process.env.XDG_CACHE_HOME;
168
+ const cacheHome = xdgCache && path.isAbsolute(xdgCache) ? xdgCache : path.join(os.homedir(), '.cache');
140
169
  removeOwnedScratchDir('cache dir', path.join(cacheHome, 'pi-research'));
141
170
 
142
171
  // State tree: transient run state, locks, backups, project-settings. Now lives in
143
172
  // pi-research's own namespace (~/.pi/research/state), honoring the same overrides
144
- // as getGlobalConfigDir()/PI_RESEARCH_STATE_DIR.
145
- const stateDir = process.env.PI_RESEARCH_STATE_DIR
146
- || path.join(os.homedir(), process.env.PI_RESEARCH_CONFIG_DIR_NAME || '.pi', 'research', 'state');
173
+ // as getGlobalConfigDir()/PI_RESEARCH_STATE_DIR — with the same strictness the
174
+ // XDG guard above applies. A RELATIVE state dir would rmSync a tree relative to
175
+ // wherever the user happens to invoke this script (generally NOT where the
176
+ // runtime created state), and a config-dir name carrying a path separator could
177
+ // traverse out of HOME entirely. Deletion must never resolve paths more loosely
178
+ // than the code that created them.
179
+ const stateOverride = process.env.PI_RESEARCH_STATE_DIR;
180
+ const rawDirName = process.env.PI_RESEARCH_CONFIG_DIR_NAME;
181
+ const dirName = rawDirName && !rawDirName.includes('/') && !rawDirName.includes('\\') && rawDirName !== '.' && rawDirName !== '..'
182
+ ? rawDirName
183
+ : '.pi';
184
+ const stateDir = stateOverride && path.isAbsolute(stateOverride)
185
+ ? stateOverride
186
+ : path.join(os.homedir(), dirName, 'research', 'state');
147
187
  removeOwnedScratchDir('state dir', stateDir);
148
188
 
149
189
  process.exit(0);