@lincoln504/pi-research 1.1.0 → 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 (82) hide show
  1. package/README.md +4 -4
  2. package/dist/cli.mjs +1404 -600
  3. package/dist/thread-worker.mjs +40 -11
  4. package/docs/AGENT-SKILL.md +11 -4
  5. package/docs/ARCHITECTURE.md +19 -9
  6. package/docs/CHANGELOG.md +107 -1
  7. package/docs/CONFIGURATION.md +10 -8
  8. package/docs/KNOWLEDGE-STORE.md +5 -4
  9. package/docs/PI-EXTENSION.md +1 -1
  10. package/docs/SDK.md +63 -5
  11. package/docs/THIRD-PARTY-NOTICES.md +1 -1
  12. package/package.json +5 -4
  13. package/scripts/build.cjs +23 -5
  14. package/scripts/cleanup.cjs +50 -10
  15. package/scripts/prepare.cjs +15 -5
  16. package/scripts/setup.cjs +24 -4
  17. package/skills/pi-research/README.md +9 -2
  18. package/skills/pi-research/SKILL.md +8 -5
  19. package/skills/pi-research/references/configuration.md +7 -3
  20. package/skills/pi-research/scripts/run.mjs +24 -2
  21. package/src/cli.ts +161 -21
  22. package/src/config.ts +56 -15
  23. package/src/constants.ts +31 -0
  24. package/src/core/interfaces/knowledge-interfaces.ts +6 -1
  25. package/src/core/interfaces/state-manager-interfaces.ts +1 -1
  26. package/src/core/llm/model-registry-factory.ts +40 -3
  27. package/src/core/llm/research-model-resolver.ts +2 -2
  28. package/src/core/pi-version.ts +136 -0
  29. package/src/core/service-registry.ts +117 -8
  30. package/src/index.ts +11 -18
  31. package/src/infrastructure/browser/browser-client.ts +16 -4
  32. package/src/infrastructure/browser/browser-error-utils.ts +12 -1
  33. package/src/infrastructure/browser/browser-server.ts +17 -5
  34. package/src/infrastructure/browser/browser-task-scheduler.ts +101 -23
  35. package/src/infrastructure/browser/cleanup-utils.ts +45 -7
  36. package/src/infrastructure/browser/client-agent.ts +4 -3
  37. package/src/infrastructure/browser/priority-task-queue.ts +10 -3
  38. package/src/infrastructure/browser/scheduler-factory.ts +26 -1
  39. package/src/infrastructure/browser/task-execution-service.ts +92 -28
  40. package/src/infrastructure/browser/thread-worker-lifecycle.ts +50 -13
  41. package/src/infrastructure/browser/thread-worker.mjs +40 -11
  42. package/src/infrastructure/browser/worker-pool-manager.ts +11 -3
  43. package/src/infrastructure/embedding/embedding-client.ts +22 -8
  44. package/src/infrastructure/embedding/embedding-factory.ts +28 -11
  45. package/src/infrastructure/embedding/embedding-server.ts +65 -4
  46. package/src/infrastructure/file-lock-service.ts +223 -26
  47. package/src/infrastructure/process-lifecycle-service.ts +108 -31
  48. package/src/infrastructure/state/state-backup-manager.ts +5 -7
  49. package/src/infrastructure/state/state-manager-service.ts +1 -1
  50. package/src/infrastructure/state/state-manager.ts +19 -20
  51. package/src/infrastructure/types/state-types.ts +9 -0
  52. package/src/knowledge/embedder-init.ts +1 -1
  53. package/src/knowledge/embedder.ts +52 -3
  54. package/src/knowledge/index.ts +1 -1
  55. package/src/knowledge/store.ts +35 -9
  56. package/src/knowledge/webgpu-viability.ts +9 -4
  57. package/src/knowledge/writer-queue.ts +88 -46
  58. package/src/orchestration/deep-research-orchestrator.ts +13 -2
  59. package/src/orchestration/quick-research-orchestrator.ts +2 -2
  60. package/src/orchestration/research-orchestration-service.ts +2 -4
  61. package/src/orchestration/research-synthesis-service.ts +34 -92
  62. package/src/security/cisa-kev.ts +10 -3
  63. package/src/security/github-advisories.ts +18 -7
  64. package/src/security/nvd-client.ts +17 -6
  65. package/src/security/osv-client.ts +14 -4
  66. package/src/skill-install/skill-installer.ts +135 -9
  67. package/src/stackexchange/rest-client.ts +6 -2
  68. package/src/tools/research-knowledge-search.ts +23 -2
  69. package/src/tools/search.ts +6 -1
  70. package/src/utils/atomic-replace.ts +101 -0
  71. package/src/utils/circuit-breaker.ts +20 -0
  72. package/src/utils/citation-utils.ts +106 -8
  73. package/src/utils/http-body.ts +176 -0
  74. package/src/utils/llm-usage.ts +37 -0
  75. package/src/utils/metrics-summary.ts +25 -2
  76. package/src/utils/research-export.ts +9 -2
  77. package/src/web-research/browser-search.ts +50 -4
  78. package/src/web-research/retry-utils.ts +13 -1
  79. package/src/web-research/search.ts +20 -5
  80. package/src/web-research/types.ts +17 -4
  81. package/src/web-research/web-scraper.ts +7 -50
  82. package/src/youtube/transcript-client.ts +5 -1
@@ -1284,6 +1284,11 @@ var init_circuit_breaker = __esm({
1284
1284
  metrics.observe("circuit_breaker_call_duration_ms", duration, { breaker: this.options.name, status: "error" });
1285
1285
  if (this.options.isTransientError(error)) {
1286
1286
  this.onFailure(error);
1287
+ } else if (admittedAsProbe && this.state === "HALF_OPEN") {
1288
+ logger.warn(
1289
+ `[CircuitBreaker] '${this.options.name}' HALF_OPEN probe failed with a non-counted error; treating as inconclusive and re-opening: ${error instanceof Error ? error.message : String(error)}`
1290
+ );
1291
+ this.transitionTo("OPEN");
1287
1292
  }
1288
1293
  throw error;
1289
1294
  } finally {
@@ -1379,7 +1384,16 @@ function isServerDrainingError(error) {
1379
1384
  function isPoolShutdownError(error) {
1380
1385
  if (!error || typeof error !== "object") return false;
1381
1386
  const err = error;
1382
- return typeof err.message === "string" && (err.message.includes("Worker pool is shutting down") || err.message.includes("Cannot execute a task on destroying pool") || err.message.includes("destroying pool"));
1387
+ return typeof err.message === "string" && (err.message.includes("Worker pool is shutting down") || err.message.includes("Cannot execute a task on destroying pool") || err.message.includes("destroying pool") || // worker-pool-manager throws this when auto-recovery is still swapping the
1388
+ // pool. Its own text says "please retry", but until it was listed here no
1389
+ // classifier matched it, so the first attempt rethrew instead of retrying —
1390
+ // and auto-recovery fires only after 3 consecutive worker failures, i.e.
1391
+ // exactly when a burst of tasks is in flight and would all die together.
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"));
1383
1397
  }
1384
1398
  var DEFAULT_BREAKER_CONFIG, browserCircuitBreaker;
1385
1399
  var init_browser_error_utils = __esm({
@@ -1743,12 +1757,34 @@ function setupOrphanProtection() {
1743
1757
  if (!cluster.isWorker) {
1744
1758
  return;
1745
1759
  }
1746
- if (!process2.ppid) {
1760
+ const originalPpid = process2.ppid;
1761
+ if (!originalPpid) {
1747
1762
  return;
1748
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
+ };
1749
1780
  const checkOrphan = async () => {
1750
1781
  try {
1751
- 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);
1752
1788
  orphanCheckTimer = setTimeout(checkOrphan, 1e4);
1753
1789
  if (orphanCheckTimer) {
1754
1790
  orphanCheckTimer.unref();
@@ -1760,14 +1796,7 @@ function setupOrphanProtection() {
1760
1796
  return;
1761
1797
  }
1762
1798
  logToDebugFile("WARN", `[Worker-${workerId}] Parent process died or unreachable (orphaned), shutting down...`);
1763
- if (cleanupBrowser) {
1764
- await cleanupBrowser().catch((err) => {
1765
- logToDebugFile("WARN", `[Worker-${workerId}] Browser cleanup failed during orphan exit:`, err);
1766
- });
1767
- }
1768
- cleanupOrphanProtection();
1769
- process2.env["PI_PROCESS_EXITING"] = "1";
1770
- process2.exit(1);
1799
+ await exitAsOrphan();
1771
1800
  }
1772
1801
  };
1773
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,113 @@ 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
+
89
+ ## [1.1.2] - 2026-08-04
90
+
91
+ > Supersedes `v1.1.1`, which was tagged but never published: its release run was
92
+ > correctly blocked by a red CI, and the tag is retained only as a marker of that
93
+ > attempt. Everything below is the full set of changes since 1.1.0.
94
+
95
+ ### Fixed
96
+ - **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.)
97
+ - **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.
98
+ - **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.
99
+ - **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.
100
+ - **`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.
101
+ - **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.
102
+ - **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.
103
+
104
+ ### Changed
105
+ - **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.
106
+ - **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.
107
+ - **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.
108
+
109
+ ### Documentation
110
+ - **`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.
111
+ - **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.
112
+ - **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.
113
+ - **`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.
114
+
8
115
  ## [1.1.0] - 2026-08-04
9
116
 
10
117
  ### Changed
@@ -166,4 +273,3 @@ The following issues were identified during the v1.0.7 audit but intentionally d
166
273
  - Inconsistent `knowledge --json` payload shapes
167
274
  - Backward-clock-step tolerance across multiple rate limiters
168
275
 
169
- 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
@@ -218,7 +220,7 @@ set the knowledge-store mode per directory directly:
218
220
 
219
221
  ```sh
220
222
  pi-research knowledge-config # show the mode here and where it comes from
221
- pi-research knowledge-config set none|project|global
223
+ pi-research knowledge-config set <none|project|global> # pick ONE value
222
224
  ```
223
225
 
224
226
  Under the agent skill you don't have to run this yourself — you ask the agent (e.g. "disable the
@@ -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
 
@@ -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.
@@ -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
@@ -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 {
@@ -95,6 +98,61 @@ report caused by a sparse topic apart from one where most researchers failed). B
95
98
  > observer with `onRunQueued(slots, maxWaitMs)` to tell a waiting user the run is
96
99
  > queued rather than hung.
97
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
+
98
156
  The SDK does not write report files. Report export is a front-end concern — the pi
99
157
  extension and the CLI / agent skill do it when `PI_RESEARCH_REPORT_EXPORT_ENABLED=true`.
100
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.0",
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
  },