@lincoln504/pi-research 0.1.13 → 1.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (248) hide show
  1. package/LICENSE +1 -1
  2. package/README.md +55 -138
  3. package/dist/cli.mjs +23375 -0
  4. package/dist/prompts/research-knowledge-relevance-triage.md +35 -0
  5. package/dist/prompts/research-knowledge-search-extractor.md +41 -0
  6. package/dist/prompts/research-tool-usage.md +92 -0
  7. package/dist/prompts/researcher.md +89 -0
  8. package/dist/prompts/system-coordinator.md +75 -0
  9. package/dist/prompts/system-lead-evaluator.md +118 -0
  10. package/dist/thread-worker.mjs +2611 -0
  11. package/dist/webgpu-probe.mjs +80 -0
  12. package/docs/AGENT-SKILL.md +130 -0
  13. package/docs/ARCHITECTURE.md +306 -80
  14. package/docs/CONFIGURATION.md +249 -0
  15. package/docs/KNOWLEDGE-STORE.md +211 -0
  16. package/docs/PI-EXTENSION.md +91 -0
  17. package/docs/SDK.md +122 -0
  18. package/docs/THIRD-PARTY-NOTICES.md +66 -0
  19. package/package.json +102 -36
  20. package/scripts/build.cjs +140 -0
  21. package/scripts/cleanup.cjs +149 -0
  22. package/scripts/prepare.cjs +28 -0
  23. package/scripts/setup.cjs +134 -0
  24. package/skills/pi-research/README.md +90 -0
  25. package/skills/pi-research/SKILL.md +102 -0
  26. package/skills/pi-research/references/configuration.md +77 -0
  27. package/skills/pi-research/scripts/run.mjs +237 -0
  28. package/src/cleanup/research-cleanup.ts +140 -0
  29. package/src/cli.ts +1195 -0
  30. package/src/config.ts +1032 -155
  31. package/src/constants.ts +115 -84
  32. package/src/core/health-check-service.ts +265 -0
  33. package/src/core/interfaces/health-check-interfaces.ts +50 -0
  34. package/src/core/interfaces/knowledge-interfaces.ts +190 -0
  35. package/src/core/interfaces/observer-interfaces.ts +67 -0
  36. package/src/core/interfaces/orchestration-interfaces.ts +86 -0
  37. package/src/core/interfaces/planning-interfaces.ts +100 -0
  38. package/src/core/interfaces/process-interfaces.ts +37 -0
  39. package/src/core/interfaces/research-plan-types.ts +52 -0
  40. package/src/core/interfaces/scheduler-interfaces.ts +91 -0
  41. package/src/core/interfaces/service-names.ts +39 -0
  42. package/src/core/interfaces/state-manager-interfaces.ts +29 -0
  43. package/src/core/interfaces/steering-interfaces.ts +31 -0
  44. package/src/core/llm/agentic-repair.ts +169 -0
  45. package/src/core/llm/inject-date.ts +52 -0
  46. package/src/core/llm/llm-timeout.ts +64 -0
  47. package/src/core/llm/llm-utils.ts +86 -0
  48. package/src/core/llm/model-registry-factory.ts +287 -0
  49. package/src/core/llm/pi-ai-completion.ts +29 -0
  50. package/src/core/llm/prompts.ts +50 -0
  51. package/src/core/llm/research-model-resolver.ts +91 -0
  52. package/src/core/planning-constants.ts +13 -0
  53. package/src/core/planning-service.ts +649 -0
  54. package/src/core/planning-utils.ts +299 -0
  55. package/src/core/scheduler-factory.ts +48 -0
  56. package/src/core/scheduler-service.ts +123 -0
  57. package/src/core/service-initialization.ts +194 -0
  58. package/src/core/service-interfaces.ts +48 -0
  59. package/src/core/service-registry.ts +659 -0
  60. package/src/healthcheck/index.ts +271 -265
  61. package/src/healthcheck/registry.ts +130 -0
  62. package/src/index.ts +639 -50
  63. package/src/infrastructure/browser/browser-cleanup.ts +309 -0
  64. package/src/infrastructure/browser/browser-client.ts +199 -0
  65. package/src/infrastructure/browser/browser-error-utils.ts +160 -0
  66. package/src/infrastructure/browser/browser-lifecycle.ts +120 -0
  67. package/src/infrastructure/browser/browser-server.ts +174 -0
  68. package/src/infrastructure/browser/browser-task-scheduler.ts +461 -0
  69. package/src/infrastructure/browser/cleanup-utils.ts +155 -0
  70. package/src/infrastructure/browser/client-agent.ts +31 -0
  71. package/src/infrastructure/browser/config.ts +288 -0
  72. package/src/infrastructure/browser/ensure-browser.ts +205 -0
  73. package/src/infrastructure/browser/index.ts +37 -0
  74. package/src/infrastructure/browser/priority-task-queue.ts +226 -0
  75. package/src/infrastructure/browser/scheduler-factory.ts +354 -0
  76. package/src/infrastructure/browser/task-execution-service.ts +209 -0
  77. package/src/infrastructure/browser/thread-worker-browser.ts +282 -0
  78. package/src/infrastructure/browser/thread-worker-lifecycle.ts +323 -0
  79. package/src/infrastructure/browser/thread-worker-messaging.ts +662 -0
  80. package/src/infrastructure/browser/thread-worker-types.ts +25 -0
  81. package/src/infrastructure/browser/thread-worker.mjs +2611 -0
  82. package/src/infrastructure/browser/thread-worker.ts +168 -0
  83. package/src/infrastructure/browser/worker-pool-manager.ts +449 -0
  84. package/src/infrastructure/embedding/embedding-client.ts +205 -0
  85. package/src/infrastructure/embedding/embedding-factory.ts +324 -0
  86. package/src/infrastructure/embedding/embedding-server.ts +535 -0
  87. package/src/infrastructure/file-lock-service.ts +592 -0
  88. package/src/infrastructure/gpu-resource-service.ts +191 -0
  89. package/src/infrastructure/knowledge-store-service.ts +475 -0
  90. package/src/infrastructure/metrics-service.ts +129 -0
  91. package/src/infrastructure/process-lifecycle-service.ts +253 -0
  92. package/src/infrastructure/scheduler-factory-service.ts +58 -0
  93. package/src/infrastructure/service-initialization.ts +292 -0
  94. package/src/infrastructure/state/state-backup-manager.ts +290 -0
  95. package/src/infrastructure/state/state-browser-api.ts +62 -0
  96. package/src/infrastructure/state/state-browser-manager.ts +70 -0
  97. package/src/infrastructure/state/state-manager-service.ts +264 -0
  98. package/src/infrastructure/state/state-manager.ts +547 -0
  99. package/src/infrastructure/state/state-metrics.ts +92 -0
  100. package/src/infrastructure/state/state-migration.ts +103 -0
  101. package/src/infrastructure/state/state-path-configuration.ts +119 -0
  102. package/src/infrastructure/state/state-session-api.ts +94 -0
  103. package/src/infrastructure/state/state-session-manager.ts +127 -0
  104. package/src/infrastructure/state/state-validator.ts +93 -0
  105. package/src/infrastructure/types/state-types.ts +85 -0
  106. package/src/knowledge/chunker.ts +157 -0
  107. package/src/knowledge/embedder-init.ts +413 -0
  108. package/src/knowledge/embedder-utils.ts +296 -0
  109. package/src/knowledge/embedder.ts +592 -0
  110. package/src/knowledge/index.ts +126 -0
  111. package/src/knowledge/lancedb-loader.ts +35 -0
  112. package/src/knowledge/migration.ts +17 -0
  113. package/src/knowledge/model-config.ts +132 -0
  114. package/src/knowledge/onnx-env.ts +48 -0
  115. package/src/knowledge/store-operations.ts +291 -0
  116. package/src/knowledge/store-schema.ts +79 -0
  117. package/src/knowledge/store.ts +1251 -0
  118. package/src/knowledge/transformers-loader.ts +31 -0
  119. package/src/knowledge/webgpu-probe.mjs +80 -0
  120. package/src/knowledge/webgpu-viability.ts +255 -0
  121. package/src/knowledge/writer-queue.ts +252 -0
  122. package/src/logger.ts +316 -122
  123. package/src/observers/research-observer-impl.ts +459 -0
  124. package/src/orchestration/deep-research-orchestrator.ts +524 -773
  125. package/src/orchestration/deep-research-types.ts +47 -35
  126. package/src/orchestration/headless-observer.ts +114 -0
  127. package/src/orchestration/orchestration-types.ts +33 -0
  128. package/src/orchestration/quick-research-orchestrator.ts +463 -0
  129. package/src/orchestration/research-orchestration-service.ts +549 -0
  130. package/src/orchestration/research-session-service.ts +178 -0
  131. package/src/orchestration/research-synthesis-service.ts +348 -0
  132. package/src/orchestration/researcher-executor.ts +394 -0
  133. package/src/orchestration/researcher.ts +115 -42
  134. package/src/orchestration/service-initialization.ts +109 -0
  135. package/src/orchestration/session-context.ts +7 -3
  136. package/src/orchestration/session-state.ts +672 -0
  137. package/src/prompts/research-knowledge-relevance-triage.md +35 -0
  138. package/src/prompts/research-knowledge-search-extractor.md +41 -0
  139. package/src/prompts/research-tool-usage.md +63 -16
  140. package/src/prompts/researcher.md +59 -136
  141. package/src/prompts/system-coordinator.md +64 -13
  142. package/src/prompts/system-lead-evaluator.md +82 -189
  143. package/src/research-config.ts +872 -0
  144. package/src/sdk.ts +847 -0
  145. package/src/security/cisa-kev.ts +72 -9
  146. package/src/security/cvss.ts +92 -0
  147. package/src/security/ecosystem.ts +45 -0
  148. package/src/security/github-advisories.ts +168 -428
  149. package/src/security/github-advisory-types.ts +282 -0
  150. package/src/security/index.ts +63 -177
  151. package/src/security/nvd-client.ts +550 -0
  152. package/src/security/nvd-types.ts +179 -0
  153. package/src/security/osv-client.ts +246 -0
  154. package/src/security/osv-types.ts +285 -0
  155. package/src/security/types.ts +67 -53
  156. package/src/skill-install/skill-installer.ts +519 -0
  157. package/src/stackexchange/index.ts +81 -27
  158. package/src/stackexchange/output/compact.ts +7 -6
  159. package/src/stackexchange/output/table.ts +11 -7
  160. package/src/stackexchange/queries.ts +0 -10
  161. package/src/stackexchange/rest-client.ts +141 -66
  162. package/src/stackexchange/types.ts +0 -17
  163. package/src/tool.ts +15 -423
  164. package/src/tools/grep.ts +32 -310
  165. package/src/tools/health-tool-definition.ts +101 -0
  166. package/src/tools/index.ts +38 -9
  167. package/src/tools/research-knowledge-search.ts +974 -0
  168. package/src/tools/research-knowledge-types.ts +77 -0
  169. package/src/tools/research-tool-definition.ts +494 -0
  170. package/src/tools/scrape.ts +286 -388
  171. package/src/tools/search.ts +98 -153
  172. package/src/tools/security.ts +107 -80
  173. package/src/tools/stackexchange.ts +58 -35
  174. package/src/tools/youtube-transcript.ts +163 -0
  175. package/src/tui/knowledge-search-panel.ts +86 -0
  176. package/src/tui/pulse.ts +86 -0
  177. package/src/tui/research-health.ts +124 -0
  178. package/src/tui/research-panel-color-utils.ts +340 -0
  179. package/src/tui/research-panel-format.ts +54 -0
  180. package/src/tui/research-panel-state.ts +404 -0
  181. package/src/tui/research-panel-wave.ts +280 -0
  182. package/src/tui/research-panel.ts +339 -435
  183. package/src/tui/research-tui-manager.ts +233 -0
  184. package/src/tui/tui-controller.ts +137 -0
  185. package/src/types/extension-context.ts +18 -49
  186. package/src/types/index.ts +61 -0
  187. package/src/types/llm.ts +68 -29
  188. package/src/types/research-panel-types.ts +61 -0
  189. package/src/utils/circuit-breaker.ts +146 -0
  190. package/src/utils/citation-utils.ts +97 -0
  191. package/src/utils/disk-space-checker.ts +55 -0
  192. package/src/utils/error-tracker.ts +265 -0
  193. package/src/utils/host-config.ts +45 -0
  194. package/src/utils/input-validation.ts +8 -10
  195. package/src/utils/json-utils.ts +150 -16
  196. package/src/utils/llm-usage.ts +54 -0
  197. package/src/utils/log-rotation.ts +125 -0
  198. package/src/utils/log-utils.ts +159 -0
  199. package/src/utils/make-resource-loader.ts +3 -1
  200. package/src/utils/metrics-summary.ts +459 -0
  201. package/src/utils/metrics.ts +302 -0
  202. package/src/utils/research-export.ts +28 -12
  203. package/src/utils/safe-unref.ts +31 -0
  204. package/src/utils/shared-links.ts +195 -90
  205. package/src/utils/shutdown-manager.ts +65 -0
  206. package/src/utils/stdio-capture.ts +407 -0
  207. package/src/utils/text-utils.ts +258 -11
  208. package/src/utils/tool-usage-tracker.ts +83 -16
  209. package/src/utils/url-utils.ts +255 -0
  210. package/src/utils/user-agent.ts +28 -0
  211. package/src/web-research/browser-search.ts +179 -0
  212. package/src/web-research/retry-utils.ts +173 -77
  213. package/src/web-research/scraper-types.ts +72 -0
  214. package/src/web-research/scraper-utils.ts +434 -0
  215. package/src/web-research/search.ts +78 -218
  216. package/src/web-research/types.ts +9 -24
  217. package/src/web-research/utils.ts +2 -143
  218. package/src/web-research/web-scraper.ts +477 -0
  219. package/src/youtube/index.ts +154 -0
  220. package/src/youtube/potoken.ts +259 -0
  221. package/src/youtube/transcript-client.ts +390 -0
  222. package/src/youtube/video-id.ts +118 -0
  223. package/config/searxng/default-settings.yml +0 -189
  224. package/config/searxng/limiter.toml +0 -34
  225. package/config/tooling/eslint.config.js +0 -73
  226. package/config/tooling/vitest.config.integration.ts +0 -17
  227. package/config/tooling/vitest.config.ts +0 -40
  228. package/config/tooling/vitest.config.unit.ts +0 -12
  229. package/docs/assets/README-banner.jpg +0 -0
  230. package/scripts/setup.js +0 -212
  231. package/src/infrastructure/searxng-lifecycle.ts +0 -644
  232. package/src/infrastructure/searxng-manager.ts +0 -1183
  233. package/src/infrastructure/state-manager.ts +0 -918
  234. package/src/orchestration/deep-research-reducer.ts +0 -129
  235. package/src/orchestration/id-utils.ts +0 -63
  236. package/src/security/nvd.ts +0 -532
  237. package/src/security/osv.ts +0 -487
  238. package/src/stackexchange/cache.ts +0 -111
  239. package/src/stackexchange/output/index.ts +0 -7
  240. package/src/stackexchange/output/json.ts +0 -21
  241. package/src/tui/searxng-status.ts +0 -80
  242. package/src/utils/inject-date.ts +0 -29
  243. package/src/utils/research-error.ts +0 -205
  244. package/src/utils/searxng-config.ts +0 -94
  245. package/src/utils/session-state.ts +0 -295
  246. package/src/web-research/dependencies.ts +0 -20
  247. package/src/web-research/scrapers.ts +0 -935
  248. package/tsconfig.json +0 -40
@@ -0,0 +1,309 @@
1
+ /**
2
+ * Browser Cleanup Utilities
3
+ *
4
+ * Functions for cleaning up orphaned browser processes and related resources.
5
+ */
6
+
7
+ import { exec } from 'node:child_process';
8
+ import { promisify } from 'node:util';
9
+ import * as os from 'node:os';
10
+ import { logger } from '../../logger.ts';
11
+
12
+ const execAsync = promisify(exec);
13
+
14
+ /**
15
+ * Find and kill orphaned Camoufox browser processes.
16
+ *
17
+ * Orphaned processes are those that:
18
+ * 1. Are running Camoufox/Firefox browsers
19
+ * 2. Have a parent PID that no longer exists
20
+ * 3. Are not part of an active browser pool
21
+ *
22
+ * This should be called after pool destruction to clean up any stragglers.
23
+ */
24
+ export async function cleanupOrphanedCamoufoxProcesses(): Promise<void> {
25
+ const platform = os.platform();
26
+
27
+ try {
28
+ if (platform === 'darwin' || platform === 'linux') {
29
+ await cleanupOrphanedProcessesUnix();
30
+ } else if (platform === 'win32') {
31
+ await cleanupOrphanedProcessesWindows();
32
+ } else {
33
+ logger.warn(`[BrowserCleanup] Platform ${platform} not supported for orphan cleanup`);
34
+ }
35
+ } catch (error) {
36
+ const msg = error instanceof Error ? error.message : String(error);
37
+ logger.warn(`[BrowserCleanup] Failed to cleanup orphaned processes: ${msg}`);
38
+ }
39
+ }
40
+
41
+ /**
42
+ * Marker that identifies a browser process as one WE launched (Camoufox via
43
+ * Playwright), never the user's own system Firefox. We match on the launched
44
+ * EXECUTABLE — `camoufox-bin` (Linux), `camoufox.exe` (Windows) or
45
+ * `…/MacOS/camoufox` (macOS), per camoufox-js's LAUNCH_FILE — plus our
46
+ * `…/pi-research/profiles/…` profile path (the fallback when PI_RESEARCH_TMP_DIR
47
+ * relocates profiles). Anchoring on the executable name (rather than a bare
48
+ * `camoufox` substring) keeps an incidental `tail camoufox.log`, `grep camoufox`,
49
+ * or an editor buffer from ever matching — the system Firefox matches none of
50
+ * these. (A user's OWN orphaned standalone Camoufox is the one indistinguishable
51
+ * case; it is gated further by the strict orphan conditions below.)
52
+ */
53
+ export const PI_BROWSER_MARKER = /camoufox-bin\b|camoufox\.exe\b|[/\\]MacOS[/\\]camoufox\b|[/\\]pi-research[/\\]profiles[/\\]/i;
54
+
55
+ /**
56
+ * Cleanup orphaned processes on Unix-like systems (macOS, Linux)
57
+ */
58
+ async function cleanupOrphanedProcessesUnix(): Promise<void> {
59
+ try {
60
+ // Scan by FULL command line (args), not comm, so we can positively identify our
61
+ // own Camoufox processes and never touch the user's system Firefox.
62
+ const { stdout } = await execAsync(
63
+ 'ps -eo pid,ppid,args 2>/dev/null',
64
+ );
65
+
66
+ const lines = stdout.trim().split('\n');
67
+ const cleanupTasks: Promise<void>[] = [];
68
+
69
+ for (const line of lines) {
70
+ // Capture PID, PPID, and the remaining args from each line.
71
+ const match = line.trim().match(/^\s*(\d+)\s+(\d+)\s+(.*)$/);
72
+ if (!match || !match[1] || !match[2]) continue;
73
+
74
+ const args = match[3] || '';
75
+ // Only ever consider OUR Camoufox processes — never the user's Firefox.
76
+ if (!PI_BROWSER_MARKER.test(args)) continue;
77
+
78
+ const pid = parseInt(match[1], 10);
79
+ const ppid = parseInt(match[2], 10);
80
+ const comm = 'camoufox';
81
+ // Never target ourselves.
82
+ if (pid === process.pid) continue;
83
+
84
+ // Determine if orphan:
85
+ // 1. PPID is 1 (adopted by init)
86
+ // 2. Parent PID is dead (process.kill(ppid, 0) throws)
87
+ let isOrphan = false;
88
+ if (ppid === 1) {
89
+ isOrphan = true;
90
+ } else {
91
+ try {
92
+ process.kill(ppid, 0);
93
+ isOrphan = false;
94
+ } catch (e) {
95
+ // ESRCH = parent is gone (genuine orphan). EPERM = parent is ALIVE but
96
+ // owned by another user — NOT an orphan. Conflating them would let a
97
+ // multi-user host / CI side-car kill a live, foreign-owned browser.
98
+ isOrphan = (e as NodeJS.ErrnoException)?.code !== 'EPERM';
99
+ }
100
+ }
101
+
102
+ if (isOrphan) {
103
+ cleanupTasks.push((async () => {
104
+ logger.log(`[BrowserCleanup] Found orphaned ${comm} process: PID ${pid}, parent PID ${ppid}`);
105
+
106
+ try {
107
+ // Kill the orphaned process gracefully
108
+ process.kill(pid, 'SIGTERM');
109
+
110
+ // Give it a moment to exit gracefully (wait in parallel)
111
+ await new Promise(resolve => setTimeout(resolve, 500));
112
+
113
+ // Check if it's still running and force kill if needed
114
+ try {
115
+ process.kill(pid, 0);
116
+ // Still running, force kill
117
+ process.kill(pid, 'SIGKILL');
118
+ logger.warn(`[BrowserCleanup] Force killed orphaned process: PID ${pid}`);
119
+ } catch {
120
+ // Process exited
121
+ logger.log(`[BrowserCleanup] Terminated orphaned process: PID ${pid}`);
122
+ }
123
+ } catch (killError) {
124
+ const msg = killError instanceof Error ? killError.message : String(killError);
125
+ logger.warn(`[BrowserCleanup] Failed to kill orphaned process PID ${pid}: ${msg}`);
126
+ }
127
+ })());
128
+ }
129
+ }
130
+
131
+ if (cleanupTasks.length > 0) {
132
+ await Promise.all(cleanupTasks);
133
+ logger.log(`[BrowserCleanup] Cleaned up ${cleanupTasks.length} orphaned browser process(es)`);
134
+ }
135
+ } catch (error) {
136
+ const msg = error instanceof Error ? error.message : String(error);
137
+ logger.warn(`[BrowserCleanup] Failed to find orphaned processes: ${msg}`);
138
+ }
139
+ }
140
+
141
+ interface WinProc { pid: number; ppid: number; name: string; commandLine: string; }
142
+
143
+ /**
144
+ * Snapshot all Windows processes with their parent PID and FULL command line.
145
+ *
146
+ * Uses PowerShell/CIM rather than `wmic` (removed by default in Windows 11 24H2
147
+ * and Server 2025). A single call returns the whole process table, which lets us
148
+ * (a) positively identify OUR Camoufox via {@link PI_BROWSER_MARKER} on the
149
+ * command line — never the user's own firefox.exe — and (b) detect dead parents
150
+ * from the same snapshot without a per-PID query.
151
+ */
152
+ async function queryWindowsProcesses(): Promise<WinProc[]> {
153
+ const cmd =
154
+ 'powershell -NoProfile -NonInteractive -Command ' +
155
+ '"Get-CimInstance Win32_Process | Select-Object ProcessId,ParentProcessId,Name,CommandLine | ConvertTo-Json -Compress"';
156
+ const { stdout } = await execAsync(cmd, { maxBuffer: 1024 * 1024 * 16 }).catch(() => ({ stdout: '' }));
157
+ if (!stdout.trim()) return [];
158
+
159
+ let parsed: unknown;
160
+ try {
161
+ parsed = JSON.parse(stdout);
162
+ } catch {
163
+ return [];
164
+ }
165
+ // ConvertTo-Json emits a bare object for a single result, an array otherwise.
166
+ const arr = Array.isArray(parsed) ? parsed : [parsed];
167
+ const procs: WinProc[] = [];
168
+ for (const p of arr) {
169
+ if (!p || typeof p !== 'object') continue;
170
+ const rec = p as Record<string, unknown>;
171
+ const pid = Number(rec['ProcessId']);
172
+ if (!Number.isFinite(pid)) continue;
173
+ const ppid = Number(rec['ParentProcessId']);
174
+ const name = rec['Name'];
175
+ const commandLine = rec['CommandLine'];
176
+ procs.push({
177
+ pid,
178
+ // 0 is an "unknown parent" sentinel (unparseable/missing ParentProcessId).
179
+ // It must NEVER be treated as orphaned — see cleanupOrphanedProcessesWindows.
180
+ ppid: Number.isFinite(ppid) && ppid > 0 ? ppid : 0,
181
+ name: typeof name === 'string' ? name : '',
182
+ commandLine: typeof commandLine === 'string' ? commandLine : '',
183
+ });
184
+ }
185
+ return procs;
186
+ }
187
+
188
+ /**
189
+ * Cleanup orphaned processes on Windows
190
+ */
191
+ async function cleanupOrphanedProcessesWindows(): Promise<void> {
192
+ try {
193
+ const procs = await queryWindowsProcesses();
194
+ if (procs.length === 0) return;
195
+
196
+ // Live-PID set from the same snapshot. A process whose ParentProcessId is
197
+ // absent from this set has a dead parent — the Windows analogue of the Unix
198
+ // path's `process.kill(ppid, 0)` liveness probe. (Windows does NOT reparent
199
+ // to PID 1, and never resets a dead parent's PID to 0, so absence-from-table
200
+ // is the correct signal.)
201
+ const alivePids = new Set(procs.map((p) => p.pid));
202
+ const cleanupTasks: Promise<void>[] = [];
203
+
204
+ for (const proc of procs) {
205
+ // ONLY ever consider our own Camoufox — match the full command line so the
206
+ // user's personal firefox.exe is never a candidate. This is the safety
207
+ // guarantee the old image-name-only match lacked.
208
+ if (!PI_BROWSER_MARKER.test(proc.commandLine)) continue;
209
+ if (proc.pid === process.pid) continue;
210
+ // Unknown parent (sentinel 0): we cannot prove it is orphaned, so we must
211
+ // NOT kill it — a live pool browser must never be force-killed on a guess.
212
+ if (proc.ppid <= 0) continue;
213
+ // Orphaned iff the parent is gone from the live process table.
214
+ if (alivePids.has(proc.ppid)) continue;
215
+
216
+ cleanupTasks.push((async () => {
217
+ logger.log(`[BrowserCleanup] Found orphaned Camoufox process: PID ${proc.pid}, dead parent PID ${proc.ppid}`);
218
+ try {
219
+ await execAsync(`taskkill /PID ${proc.pid} /F /T`);
220
+ logger.log(`[BrowserCleanup] Terminated orphaned process: PID ${proc.pid}`);
221
+ } catch (killError) {
222
+ const msg = killError instanceof Error ? killError.message : String(killError);
223
+ logger.warn(`[BrowserCleanup] Failed to kill orphaned process PID ${proc.pid}: ${msg}`);
224
+ }
225
+ })());
226
+ }
227
+
228
+ if (cleanupTasks.length > 0) {
229
+ await Promise.all(cleanupTasks);
230
+ logger.log(`[BrowserCleanup] Cleaned up ${cleanupTasks.length} orphaned browser process(es)`);
231
+ }
232
+ } catch (error) {
233
+ const msg = error instanceof Error ? error.message : String(error);
234
+ logger.warn(`[BrowserCleanup] Failed to find orphaned processes on Windows: ${msg}`);
235
+ }
236
+ }
237
+
238
+
239
+ // ============================================================================
240
+ // Browser Manager Cleanup Functions
241
+ // ============================================================================
242
+
243
+ /**
244
+ * Snapshot browser PIDs that are children of the given worker PIDs
245
+ */
246
+ export async function getBrowserPidsForWorkers(workerPids: number[]): Promise<number[]> {
247
+ const platform = os.platform();
248
+ const pids: number[] = [];
249
+ if (!workerPids || workerPids.length === 0) return pids;
250
+
251
+ try {
252
+ if (platform === 'darwin' || platform === 'linux') {
253
+ const { stdout } = await execAsync('ps -eo pid,ppid,comm | grep -E "(firefox|camoufox)" | grep -v grep').catch(() => ({ stdout: '' }));
254
+ const lines = stdout.trim().split('\n');
255
+ for (const line of lines) {
256
+ const match = line.trim().match(/^\s*(\d+)\s+(\d+)\s+/);
257
+ if (match && match[1] && match[2]) {
258
+ const pid = parseInt(match[1], 10);
259
+ const ppid = parseInt(match[2], 10);
260
+ if (workerPids.includes(ppid)) pids.push(pid);
261
+ }
262
+ }
263
+ } else if (platform === 'win32') {
264
+ // wmic-free: one CIM snapshot, matched on the full command line so only
265
+ // OUR Camoufox children of the given workers are returned.
266
+ const procs = await queryWindowsProcesses();
267
+ for (const proc of procs) {
268
+ if (!PI_BROWSER_MARKER.test(proc.commandLine)) continue;
269
+ if (workerPids.includes(proc.ppid)) pids.push(proc.pid);
270
+ }
271
+ }
272
+ } catch (err) {
273
+ const msg = err instanceof Error ? err.message : String(err);
274
+ logger.warn(`[BrowserCleanup] Failed to get browser PIDs for workers: ${msg}`);
275
+ }
276
+ return pids;
277
+ }
278
+
279
+ /**
280
+ * Kill specific browser processes in parallel
281
+ */
282
+ export async function killBrowserProcesses(pids: number[]): Promise<void> {
283
+ if (!pids || pids.length === 0) return;
284
+ const platform = os.platform();
285
+
286
+ // Parallelize killing processes for speed
287
+ await Promise.all(pids.map(async (pid) => {
288
+ try {
289
+ if (platform === 'win32') {
290
+ await execAsync(`taskkill /PID ${pid} /F /T`);
291
+ logger.debug(`[BrowserCleanup] Terminated worker browser process: PID ${pid}`);
292
+ } else {
293
+ process.kill(pid, 'SIGTERM');
294
+ // Reduce wait time for graceful exit from 500ms to 200ms
295
+ await new Promise(resolve => setTimeout(resolve, 200));
296
+ try {
297
+ process.kill(pid, 0);
298
+ process.kill(pid, 'SIGKILL');
299
+ logger.debug(`[BrowserCleanup] Force killed worker browser process: PID ${pid}`);
300
+ } catch {
301
+ logger.debug(`[BrowserCleanup] Terminated worker browser process: PID ${pid}`);
302
+ }
303
+ }
304
+ } catch (err) {
305
+ const msg = err instanceof Error ? err.message : String(err);
306
+ logger.debug(`[BrowserCleanup] Failed to kill browser process PID ${pid}: ${msg}`);
307
+ }
308
+ }));
309
+ }
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Browser Client for Remote Scheduler Communication
3
+ *
4
+ * HTTP client that communicates with a remote scheduler via HTTP.
5
+ * Extracted from browser-manager.ts for better separation of concerns.
6
+ */
7
+
8
+ import * as http from 'node:http';
9
+ import type { Config } from '../../config.ts';
10
+ import type { SearchResult } from '../../web-research/types.ts';
11
+ import type { IScheduler } from '../../core/interfaces/scheduler-interfaces.ts';
12
+ import { logger } from '../../logger.ts';
13
+ import { errorTracker } from '../../utils/error-tracker.ts';
14
+ import { getClientAgent } from './client-agent.ts';
15
+ import type { NodeError } from '../../types/index.ts';
16
+
17
+ /**
18
+ * HTTP client that communicates with a remote scheduler.
19
+ * Used when this process is not the leader.
20
+ */
21
+ export class BrowserClient implements IScheduler {
22
+ private readonly authSecret: string;
23
+
24
+ constructor(private readonly port: number, authSecret?: string) {
25
+ this.authSecret = authSecret ?? process.env['PI_BROWSER_AUTH_SECRET'] ?? '';
26
+ logger.log(`[BrowserClient] Connecting to global scheduler at http://127.0.0.1:${port}`);
27
+ }
28
+
29
+ private async request<T>(path: string, data: any, signal?: AbortSignal): Promise<T> {
30
+ const start = Date.now();
31
+ // Extract operation from path for error tracking
32
+ const operation = path.includes('/search') ? 'search' :
33
+ path.includes('/scrape') ? 'browser-task' :
34
+ path.includes('/healthcheck') ? 'healthcheck' : 'network';
35
+
36
+ return new Promise((resolve, reject) => {
37
+ const agent = getClientAgent();
38
+ // 60s timeout for client requests (1/3 of original 180s)
39
+ const timeoutMs = 60000;
40
+ let resolved = false;
41
+ const controller = new AbortController();
42
+
43
+ // Merge outer signal with our local controller
44
+ let abortCleanup: (() => void) | undefined;
45
+ if (signal) {
46
+ if (signal.aborted) {
47
+ return reject(new Error('Aborted'));
48
+ }
49
+ const onAbort = () => {
50
+ if (resolved) return;
51
+ resolved = true;
52
+ controller.abort();
53
+ reject(new Error('Aborted'));
54
+ };
55
+ signal.addEventListener('abort', onAbort, { once: true });
56
+ abortCleanup = () => signal.removeEventListener('abort', onAbort);
57
+ }
58
+
59
+ const timer = setTimeout(() => {
60
+ if (!resolved) {
61
+ resolved = true;
62
+ // Remove the outer-signal abort listener on timeout too. Without this,
63
+ // a long-lived run signal shared across many client requests accumulates
64
+ // one never-removed listener per timed-out request for the run's duration.
65
+ abortCleanup?.();
66
+ controller.abort();
67
+ const error = new Error(`[BrowserClient] Request to ${path} timed out after ${timeoutMs}ms (Shared queue may be deep)`);
68
+ errorTracker.trackError(error, {
69
+ component: 'browser-manager',
70
+ operation,
71
+ errorType: 'timeout',
72
+ });
73
+ reject(error);
74
+ }
75
+ }, timeoutMs);
76
+ // FIX (#34): Don't keep the event loop alive for timeout timers
77
+ if (timer.unref) timer.unref();
78
+
79
+ const req = http.request({
80
+ hostname: '127.0.0.1',
81
+ port: this.port,
82
+ path,
83
+ method: 'POST',
84
+ agent,
85
+ signal: controller.signal,
86
+ headers: {
87
+ 'Content-Type': 'application/json',
88
+ // FIX (#21): Send auth token to authenticate with browser server
89
+ 'X-Browser-Auth': this.authSecret,
90
+ }
91
+ }, (res) => {
92
+ // Do NOT clear the timeout/abort here. The response callback fires
93
+ // when headers arrive, but the body is still streaming — clearing
94
+ // now would let a stalled body hang forever and make an external
95
+ // abort a no-op. Keep both armed until 'end' or 'error'.
96
+ let body = '';
97
+ res.on('data', chunk => body += chunk);
98
+ res.on('end', () => {
99
+ if (resolved) return;
100
+ resolved = true;
101
+ clearTimeout(timer);
102
+ abortCleanup?.();
103
+ const duration = Date.now() - start;
104
+ try {
105
+ const parsed = JSON.parse(body);
106
+ if (res.statusCode !== 200) {
107
+ const error = new Error(parsed.error || `HTTP ${res.statusCode}`);
108
+ errorTracker.trackError(error, {
109
+ component: 'browser-manager',
110
+ operation,
111
+ errorType: 'http_error',
112
+ });
113
+ reject(error);
114
+ } else {
115
+ logger.debug(`[BrowserClient] Request ${path} completed in ${duration}ms`);
116
+ resolve(parsed);
117
+ }
118
+ } catch (_e) {
119
+ // FIX (#23): Truncate response body in error to prevent data leakage
120
+ const preview = body.length > 200 ? body.slice(0, 200) + '...' : body;
121
+ const error = new Error(`Failed to parse response (status ${res.statusCode}): ${preview}`);
122
+ errorTracker.trackError(error, {
123
+ component: 'browser-manager',
124
+ operation,
125
+ errorType: 'parse_error',
126
+ });
127
+ reject(error);
128
+ }
129
+ });
130
+ res.on('error', (err) => {
131
+ if (resolved) return;
132
+ resolved = true;
133
+ clearTimeout(timer);
134
+ abortCleanup?.();
135
+ const error = new Error(`[BrowserClient] Response stream error on ${path}: ${err.message}`);
136
+ errorTracker.trackError(error, {
137
+ component: 'browser-manager',
138
+ operation,
139
+ errorType: 'response_stream_error',
140
+ });
141
+ reject(error);
142
+ });
143
+ });
144
+
145
+ req.on('error', (err) => {
146
+ if (resolved) return;
147
+ resolved = true;
148
+ clearTimeout(timer);
149
+ abortCleanup?.();
150
+
151
+ // If it's just a timeout abort error, the timer already rejected it.
152
+ if (err.name === 'AbortError') return;
153
+ // Enhance error message with socket-specific details
154
+ const nodeErr = err as NodeError;
155
+ let errorMsg: string;
156
+ let errorType: string;
157
+ if (nodeErr.code === 'ECONNRESET' || nodeErr.code === 'EPIPE') {
158
+ errorMsg = `Browser pool socket ${path} closed (pool likely busy or restarting) - ${err.message}`;
159
+ errorType = 'connection_reset';
160
+ } else if (nodeErr.code === 'ECONNREFUSED') {
161
+ errorMsg = `Browser pool ${path} unreachable (server restarting or torn down) - ${err.message}`;
162
+ errorType = 'connection_refused';
163
+ } else if (nodeErr.code === 'ETIMEDOUT') {
164
+ errorMsg = `Browser pool ${path} timed out (slow browser response) - ${err.message}`;
165
+ errorType = 'timeout';
166
+ } else {
167
+ errorMsg = `Browser pool ${path} error: ${err.message}`;
168
+ errorType = 'unknown';
169
+ }
170
+ const error = new Error(errorMsg);
171
+ logger.error(`[BrowserClient] Request to http://127.0.0.1:${this.port}${path} failed:`, errorMsg);
172
+ errorTracker.trackError(error, {
173
+ component: 'browser-manager',
174
+ operation,
175
+ errorType,
176
+ });
177
+ reject(error);
178
+ });
179
+ req.write(JSON.stringify(data));
180
+ req.end();
181
+ });
182
+ }
183
+
184
+ async runSearch(query: string, _config?: Config, signal?: AbortSignal): Promise<SearchResult[]> {
185
+ return this.request('/search', { query }, signal);
186
+ }
187
+
188
+ async runScrape(url: string, _config?: Config, signal?: AbortSignal): Promise<any> {
189
+ return this.request('/scrape', { url }, signal);
190
+ }
191
+
192
+ async runHealthCheck(_config?: Config, signal?: AbortSignal): Promise<{ success: boolean }> {
193
+ return this.request('/healthcheck', {}, signal);
194
+ }
195
+
196
+ async shutdown() {
197
+ // Clients don't shutdown the server
198
+ }
199
+ }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * Browser Error Utilities
3
+ *
4
+ * Error detection and circuit breaker utilities for browser operations.
5
+ * Extracted from browser-manager.ts for better separation of concerns.
6
+ */
7
+
8
+ import { CircuitBreaker } from '../../utils/circuit-breaker.ts';
9
+ import type { NodeError } from '../../types/index.ts';
10
+
11
+ /**
12
+ * Check if an error is a transient socket error that can be retried.
13
+ */
14
+ export function isTransientSocketError(error: unknown): boolean {
15
+ if (!error || typeof error !== 'object') return false;
16
+ const err = error as NodeError;
17
+ return typeof err.message === 'string' && (
18
+ err.message.includes('ECONNREFUSED') ||
19
+ err.message.includes('ECONNRESET') ||
20
+ err.message.includes('socket hang up') ||
21
+ err.message.includes('EPIPE') ||
22
+ err.message.includes('ETIMEDOUT') ||
23
+ err.message.includes('timed out') ||
24
+ err.message.includes('pool busy') ||
25
+ err.message.includes('unreachable') ||
26
+ // WorkerPoolManager throws this during the window between forceSchedulerRestart
27
+ // clearing the scheduler reference and the old pool's 1500ms drain completing.
28
+ // It is a transient state — retry after a short delay succeeds once drain finishes.
29
+ err.message.includes('Worker pool is shutting down') ||
30
+ // Poolifier throws this when pool.execute() is called while the pool is being
31
+ // destroyed (e.g., during scheduler restart). Treat as transient — the pool will
32
+ // be ready for new tasks once destruction completes and a new pool is initialized.
33
+ err.message.includes('Cannot execute a task on destroying pool') ||
34
+ err.message.includes('destroying pool')
35
+ );
36
+ }
37
+
38
+ /**
39
+ * Check if an error is a Cloudflare block/challenge error.
40
+ * These are content-layer rejections, not infrastructure failures — they must NOT
41
+ * count toward the circuit breaker failure threshold. Counting them caused the
42
+ * breaker to open during normal searches and block all subsequent requests.
43
+ */
44
+ export function isCloudflareBlockError(error: unknown): boolean {
45
+ if (!error || typeof error !== 'object') return false;
46
+ const err = error as NodeError;
47
+ return typeof err.message === 'string' && (
48
+ err.message.includes('Fetch blocked: Cloudflare') ||
49
+ err.message.includes('Cloudflare challenge')
50
+ );
51
+ }
52
+
53
+ /**
54
+ * Check if an error is a task-level timeout (search/scrape/healthcheck timed out).
55
+ * These indicate the remote site is slow or unresponsive — a normal occurrence during
56
+ * a burst of 20 parallel queries. They must NOT trip the circuit breaker; only true
57
+ * socket-layer infrastructure failures (ECONNREFUSED, ECONNRESET, etc.) should do so.
58
+ */
59
+ export function isTaskTimeoutError(error: unknown): boolean {
60
+ if (!error || typeof error !== 'object') return false;
61
+ const err = error as NodeError;
62
+ return typeof err.message === 'string' && (
63
+ err.message.includes('timed out after') ||
64
+ err.message.includes('Search task timed out') ||
65
+ err.message.includes('Scrape task timed out') ||
66
+ err.message.includes('Health check timed out') ||
67
+ err.message.includes('[BrowserClient] Request to')
68
+ );
69
+ }
70
+
71
+ /**
72
+ * Check if an error is specifically a pool-shutdown / pool-drain error.
73
+ * For these errors the scheduler itself is fine — only the WorkerPool is temporarily
74
+ * draining. The correct recovery is to wait for the drain to finish and retry,
75
+ * NOT to call forceSchedulerRestart (which would start another drain cycle).
76
+ */
77
+ export function isPoolShutdownError(error: unknown): boolean {
78
+ if (!error || typeof error !== 'object') return false;
79
+ const err = error as NodeError;
80
+ return typeof err.message === 'string' && (
81
+ err.message.includes('Worker pool is shutting down') ||
82
+ err.message.includes('Cannot execute a task on destroying pool') ||
83
+ err.message.includes('destroying pool')
84
+ );
85
+ }
86
+
87
+ // Session-scoped circuit breaker storage to support concurrent research runs
88
+ const sessionCircuitBreakers = new Map<string, CircuitBreaker>();
89
+ // Hard cap so a cleanup path that ever skips clearSessionCircuitBreaker (e.g. an earlier
90
+ // throw in the cleanup closure) can't grow the map unboundedly over a long-lived TUI
91
+ // process with many sessions. Far above any realistic concurrent-session count. Mirrors
92
+ // the MAX_PATTERNS bound in error-tracker.ts.
93
+ const MAX_SESSION_BREAKERS = 256;
94
+
95
+ /**
96
+ * Default circuit breaker configuration
97
+ */
98
+ const DEFAULT_BREAKER_CONFIG: any = {
99
+ // Raised from 3→8: parallel search bursts of 20 queries frequently produce
100
+ // simultaneous timeouts on slow/CF-protected sites. A threshold of 3 caused
101
+ // the breaker to open mid-burst, blocking all subsequent requests for the round.
102
+ failureThreshold: 8,
103
+ // Raised from 15s→45s: enough time for one full search burst + retry window
104
+ // to complete before the breaker attempts to half-open.
105
+ resetTimeoutMs: 45000,
106
+ name: 'BrowserPool',
107
+ isTransientError: (error: unknown) => {
108
+ // Pool-drain transients: pool recovers on its own — don't count.
109
+ if (isPoolShutdownError(error)) return false;
110
+ // Cloudflare blocks are content-layer rejections, not infra failures — don't count.
111
+ if (isCloudflareBlockError(error)) return false;
112
+ // Task-level timeouts (slow/unresponsive sites) are expected during parallel
113
+ // search bursts — don't count toward the circuit breaker threshold.
114
+ if (isTaskTimeoutError(error)) return false;
115
+ // Only genuine socket-layer failures (ECONNREFUSED, ECONNRESET, etc.) trip the circuit.
116
+ return isTransientSocketError(error);
117
+ }
118
+ };
119
+
120
+ /**
121
+ * Get or create a session-scoped circuit breaker
122
+ */
123
+ export function getBrowserCircuitBreaker(sessionId: string): CircuitBreaker {
124
+ let breaker = sessionCircuitBreakers.get(sessionId);
125
+ if (!breaker) {
126
+ // Evict the oldest (insertion-order) entry when at the cap before inserting.
127
+ if (sessionCircuitBreakers.size >= MAX_SESSION_BREAKERS) {
128
+ const oldest = sessionCircuitBreakers.keys().next().value;
129
+ if (oldest !== undefined) sessionCircuitBreakers.delete(oldest);
130
+ }
131
+ breaker = new CircuitBreaker({ ...DEFAULT_BREAKER_CONFIG, name: `BrowserPool-${sessionId}` });
132
+ sessionCircuitBreakers.set(sessionId, breaker);
133
+ }
134
+ return breaker;
135
+ }
136
+
137
+ /**
138
+ * Clear a session's circuit breaker
139
+ */
140
+ export function clearSessionCircuitBreaker(sessionId: string): void {
141
+ sessionCircuitBreakers.delete(sessionId);
142
+ }
143
+
144
+ /**
145
+ * Global fallback circuit breaker — used when no sessionId is available.
146
+ */
147
+ export const browserCircuitBreaker = new CircuitBreaker(DEFAULT_BREAKER_CONFIG);
148
+
149
+ /**
150
+ * Reset all browser circuit breakers
151
+ * Call this in test teardown/setup to prevent cross-test circuit state bleed.
152
+ */
153
+ export function resetBrowserCircuitBreaker(sessionId?: string): void {
154
+ if (sessionId) {
155
+ clearSessionCircuitBreaker(sessionId);
156
+ } else {
157
+ sessionCircuitBreakers.clear();
158
+ browserCircuitBreaker.reset();
159
+ }
160
+ }