@lincoln504/pi-research 0.1.12 → 0.1.13

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.
package/README.md CHANGED
@@ -4,6 +4,13 @@
4
4
 
5
5
  Multi-agent web research extension for [pi](https://github.com/badlogic/pi-mono). Runs parallel AI researchers against a self-hosted SearXNG search container, scrapes pages, queries security databases, and tracks everything in a real-time terminal UI.
6
6
 
7
+ There are other ways to connect a `pi` session to the internet. Here is why this one is designed the way it is:
8
+
9
+ - **SearXNG-based** — free, no API keys required by default. A single Docker container is shared across all concurrent research calls on the same machine; no duplicate instances, no per-session overhead. Point `SEARXNG_URL` at an existing instance to skip container management entirely.
10
+ - **Unified subagent system** — web access, scraping, and synthesis are handled by isolated researcher agents. Research context stays out of the main chat session while still enabling thorough multi-source exploration.
11
+ - **Safety** — researcher agents have no access to file writes, file edits, or shell commands. All web tools are scoped to the subagent system and executed in a structured, tool-limited sequence to keep agents focused and prevent runaway use.
12
+ - **Minimal integration** — a system prompt hook provides light guidance to the host LLM on when and how to invoke the tool. No manual prompt engineering required. The extension does not conflict with other subagent or tool systems, and the unified toolset means no need to wire up or tune multiple separate tools.
13
+
7
14
  ---
8
15
 
9
16
  ## Contents
@@ -47,6 +54,7 @@ Multi-agent web research extension for [pi](https://github.com/badlogic/pi-mono)
47
54
  - The `pi` CLI installed and configured
48
55
  - Docker daemon running (required for SearXNG)
49
56
  - Internet access
57
+ - LLM in pi with minimum 100k context window (optimized for 100k+)
50
58
 
51
59
  ---
52
60
 
@@ -56,10 +64,21 @@ Multi-agent web research extension for [pi](https://github.com/badlogic/pi-mono)
56
64
  pi install npm:@lincoln504/pi-research
57
65
  ```
58
66
 
67
+ The installation will automatically download and install:
68
+ - Node.js dependencies
69
+ - Playwright browser binaries (Chromium)
70
+
71
+ This process may take a few minutes on first install. If browser installation fails during postinstall, chromium will be installed automatically when you first use the research tool.
72
+
73
+ **Manual browser installation (if needed)**
74
+
75
+ ```bash
76
+ npm run install:browsers
77
+ ```
78
+
59
79
  **From a checkout**
60
80
 
61
81
  ```bash
62
- npm install
63
82
  pi install .
64
83
  ```
65
84
 
@@ -69,11 +88,8 @@ pi install .
69
88
 
70
89
  **macOS** — Install Docker Desktop and start it from Applications.
71
90
 
72
- **Linux** — Install Docker and add your user to the docker group:
91
+ **Linux** — Install Docker for your distribution ([docs.docker.com/engine/install](https://docs.docker.com/engine/install)), then:
73
92
  ```bash
74
- # Install Docker
75
- sudo apt-get update && sudo apt-get install -y docker.io
76
-
77
93
  # Start Docker
78
94
  sudo systemctl start docker
79
95
  sudo systemctl enable docker
@@ -110,6 +126,7 @@ Do a deep dive on CVE-2024-3094 at depth 2
110
126
 
111
127
  | Variable | Default | Description |
112
128
  |----------|---------|-------------|
129
+ | `SEARXNG_URL` | — | External SearXNG base URL (e.g. `http://localhost:8080`). When set, Docker container management is skipped entirely and this URL is used directly |
113
130
  | `BRAVE_SEARCH_API_KEY` | — | Enables `braveapi` engine (official REST API) |
114
131
  | `PI_RESEARCH_VERBOSE` | — | Set to `1` to write diagnostic JSONL logs to a temp file (see below) |
115
132
  | `PI_RESEARCH_SKIP_HEALTHCHECK` | — | Skip startup health check (set to `1` if engines are blocked) |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@lincoln504/pi-research",
3
- "version": "0.1.12",
3
+ "version": "0.1.13",
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,
@@ -50,10 +50,10 @@
50
50
  "test:integration": "vitest run --config config/tooling/vitest.config.integration.ts",
51
51
  "test:watch": "vitest --config config/tooling/vitest.config.unit.ts",
52
52
  "test:coverage": "vitest run --config config/tooling/vitest.config.ts --coverage",
53
- "postinstall": "node scripts/setup.js",
53
+ "postinstall": "node scripts/setup.js || exit 0",
54
54
  "install:browsers": "npx playwright install chromium",
55
- "install:deps": "npx playwright install-deps",
56
- "setup": "npm install && npm run install:browsers"
55
+ "install:deps": "npx playwright install-deps chromium",
56
+ "setup": "npm run install:browsers"
57
57
  },
58
58
  "pi": {
59
59
  "extensions": [
package/scripts/setup.js CHANGED
@@ -4,13 +4,19 @@
4
4
  * pi-research Postinstall Setup Script
5
5
  *
6
6
  * This script runs automatically after `npm install` and ensures:
7
- * 1. Playwright browser binaries are installed (Chromium, Firefox, WebKit)
7
+ * 1. Playwright browser binaries are installed (Chromium)
8
8
  * 2. System dependencies are available (if run with --system-deps flag)
9
9
  *
10
+ * Key Design Principles:
11
+ * - NEVER exits with code 1 - this would block npm install from completing
12
+ * - Uses npx playwright install, which works if playwright is in node_modules
13
+ * - Provides clear feedback about installation status
14
+ * - Gracefully handles missing dependencies (warns but doesn't fail)
15
+ *
10
16
  * Usage:
11
- * npm run setup # Full setup: install deps + browsers
12
- * npm run install:browsers # Install only browser binaries
13
- * npm run install:deps # Install system dependencies only
17
+ * npm run setup # Full setup: install deps + browsers
18
+ * npm run install:browsers # Install only browser binaries
19
+ * npm run install:deps # Install system dependencies only
14
20
  *
15
21
  * Environment Variables:
16
22
  * PLAYWRIGHT_BROWSERS_PATH - Custom path for browser cache
@@ -19,7 +25,7 @@
19
25
 
20
26
  import { execSync } from 'child_process';
21
27
  import { existsSync, readdirSync, statSync } from 'fs';
22
- import { join, dirname } from 'path';
28
+ import { dirname, join } from 'path';
23
29
  import { fileURLToPath } from 'url';
24
30
  import { homedir, platform } from 'os';
25
31
 
@@ -50,17 +56,6 @@ if (major < requiredVersions.min) {
50
56
 
51
57
  console.log(` Required: >= ${requiredVersions.min}.0.0`);
52
58
 
53
- // Check if playwright is installed
54
- const playwrightPath = join(process.cwd(), 'node_modules', 'playwright');
55
-
56
- if (!existsSync(playwrightPath)) {
57
- console.error('\n❌ ERROR: playwright is not installed.');
58
- console.error(' Run `npm install` first, then `npm run setup`.');
59
- process.exit(1);
60
- }
61
-
62
- console.log('✅ Playwright package installed');
63
-
64
59
  // Detect OS for browser installation
65
60
  const isLinux = platform() === 'linux';
66
61
  const isDarwin = platform() === 'darwin';
@@ -68,88 +63,130 @@ const isWindows = platform() === 'win32';
68
63
 
69
64
  console.log(`\n📦 Detected platform: ${platform()}`);
70
65
 
71
- // Install browsers
72
- console.log('\n🌐 Installing Playwright browsers...');
66
+ // Track installation success
67
+ let playwrightInstalled = false;
68
+ let browsersInstalled = false;
69
+ let systemDepsInstalled = false;
73
70
 
74
- try {
75
- // Check if user wants to install system dependencies
76
- const installDeps = process.argv.includes('--system-deps') ||
77
- process.env.PLAYWRIGHT_INSTALL_DEPS === 'true';
78
-
79
- if (installDeps && isLinux) {
80
- console.log('⚙️ Installing Linux system dependencies for Playwright...');
81
- try {
82
- execSync('npx playwright install-deps', {
83
- stdio: 'inherit',
84
- env: { ...process.env, PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '0' }
85
- });
86
- console.log('✅ System dependencies installed');
87
- } catch (error) {
88
- console.warn('⚠️ Could not install system dependencies automatically.');
89
- console.warn(' Try running: sudo apt-get install $(npx playwright install-deps --print-deps)');
90
- }
91
- }
71
+ // Check if we should skip browser installation
72
+ const skipBrowserDownload = process.env.PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD === '1';
92
73
 
93
- // Install browser binaries
94
- // Only Chromium is needed for scraping (as per scrapers.ts implementation)
95
- console.log(' Installing Chromium browser...');
96
- execSync('npx playwright install chromium', {
97
- stdio: 'inherit',
98
- env: {
99
- ...process.env,
100
- PLAYWRIGHT_BROWSERS_PATH: process.env.PLAYWRIGHT_BROWSERS_PATH || ''
101
- }
102
- });
103
-
104
- console.log('✅ Browsers installed successfully');
105
-
106
- // Verify installation
107
- console.log('\n📋 Verifying browser installation...');
108
- const browsers = ['chromium']; // Only Chromium is required for scraping
109
- const cacheDir = process.env['PLAYWRIGHT_BROWSERS_PATH'] ||
110
- (process.platform === 'win32'
111
- ? join(process.env['LOCALAPPDATA'] || join(homedir(), 'AppData', 'Local'), 'ms-playwright')
112
- : join(homedir(), '.cache', 'ms-playwright'));
113
-
114
- browsers.forEach(browser => {
115
- let browserDirs = [];
116
- try {
117
- browserDirs = readdirSync(cacheDir).filter(f =>
118
- f.startsWith(browser) && statSync(join(cacheDir, f)).isDirectory()
119
- );
120
- } catch {
121
- // cache dir may not exist yet or may be at a different path
74
+ if (skipBrowserDownload) {
75
+ console.log('\n⏭️ Skipping browser installation (PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1)');
76
+ } else {
77
+ // Install browsers using npx playwright
78
+ console.log('\n🌐 Installing Playwright browsers...');
79
+
80
+ try {
81
+ // Check if user wants to install system dependencies
82
+ const installDeps = process.argv.includes('--system-deps') ||
83
+ process.env.PLAYWRIGHT_INSTALL_DEPS === 'true';
84
+
85
+ if (installDeps && isLinux) {
86
+ console.log('⚙️ Installing Linux system dependencies for Playwright...');
87
+ try {
88
+ execSync('npx playwright install-deps', {
89
+ stdio: 'inherit',
90
+ env: { ...process.env, PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD: '0' }
91
+ });
92
+ console.log('✅ System dependencies installed');
93
+ systemDepsInstalled = true;
94
+ } catch (error) {
95
+ console.warn('⚠️ Could not install system dependencies automatically.');
96
+ console.warn(' Try running: sudo apt-get install $(npx playwright install-deps --print-deps)');
97
+ // Don't fail - continue with browser installation
98
+ }
122
99
  }
123
100
 
124
- if (browserDirs.length > 0) {
125
- console.log(` ✅ ${browser}: ${browserDirs.sort().join(', ')}`);
101
+ // Install browser binaries
102
+ // Only Chromium is needed for scraping (as per scrapers.ts implementation)
103
+ console.log(' Installing Chromium browser...');
104
+ execSync('npx playwright install chromium', {
105
+ stdio: 'inherit',
106
+ env: {
107
+ ...process.env,
108
+ PLAYWRIGHT_BROWSERS_PATH: process.env.PLAYWRIGHT_BROWSERS_PATH || ''
109
+ }
110
+ });
111
+
112
+ console.log('✅ Browsers installed successfully');
113
+ browsersInstalled = true;
114
+
115
+ } catch (error) {
116
+ // Parse error message to provide better guidance
117
+ const errorMsg = error instanceof Error ? error.message : String(error);
118
+
119
+ if (errorMsg.includes('npx: command not found')) {
120
+ console.warn('\n⚠️ npx not available - skipping browser installation');
121
+ console.warn(' Chromium can be installed later with: npm run install:browsers');
122
+ } else if (errorMsg.includes('playwright') || errorMsg.includes('ENOENT')) {
123
+ console.warn('\n⚠️ Playwright package not yet available - skipping browser installation');
124
+ console.warn(' This is normal during initial npm install.');
125
+ console.warn(' Chromium will be installed automatically when you run the research tool.');
126
+ console.warn(' Or manually with: npm run install:browsers');
126
127
  } else {
127
- console.warn(` ⚠️ ${browser}: Not found in cache`);
128
+ console.warn('\n⚠️ Browser installation failed:');
129
+ console.warn(` ${errorMsg}`);
130
+ console.warn('\n💡 You can install Chromium later with:');
131
+ console.warn(' npm run install:browsers');
128
132
  }
129
- });
130
133
 
131
- // Show browser versions
132
- console.log('\n🔍 Browser versions:');
134
+ // NEVER exit with code 1 - let npm install complete
135
+ console.log('\n📦 pi-research installation will continue...');
136
+ console.log('='.repeat(60));
137
+ console.log('✅ pi-research installed (browsers can be installed later)');
138
+ console.log('='.repeat(60) + '\n');
139
+ process.exit(0);
140
+ }
141
+ }
142
+
143
+ // Verify installation
144
+ console.log('\n📋 Verifying browser installation...');
145
+
146
+ const browsers = ['chromium']; // Only Chromium is required for scraping
147
+ const cacheDir = process.env['PLAYWRIGHT_BROWSERS_PATH'] ||
148
+ (process.platform === 'win32'
149
+ ? join(process.env['LOCALAPPDATA'] || join(homedir(), 'AppData', 'Local'), 'ms-playwright')
150
+ : join(homedir(), '.cache', 'ms-playwright'));
151
+
152
+ browsers.forEach(browser => {
153
+ let browserDirs = [];
133
154
  try {
134
- const playwrightVersion = execSync('npx playwright --version', { encoding: 'utf8' }).trim();
135
- console.log(` Playwright: ${playwrightVersion}`);
136
- } catch (error) {
137
- console.warn(' Could not determine Playwright version');
155
+ browserDirs = readdirSync(cacheDir).filter(f =>
156
+ f.startsWith(browser) && statSync(join(cacheDir, f)).isDirectory()
157
+ );
158
+ } catch {
159
+ // cache dir may not exist yet or may be at a different path
160
+ }
161
+
162
+ if (browserDirs.length > 0) {
163
+ console.log(` ✅ ${browser}: ${browserDirs.sort().join(', ')}`);
164
+ } else {
165
+ console.warn(` ⚠️ ${browser}: Not found in cache`);
138
166
  }
167
+ });
139
168
 
169
+ // Show browser versions
170
+ console.log('\n🔍 Browser versions:');
171
+ try {
172
+ const playwrightVersion = execSync('npx playwright --version', { encoding: 'utf8' }).trim();
173
+ console.log(` Playwright: ${playwrightVersion}`);
174
+ playwrightInstalled = true;
140
175
  } catch (error) {
141
- console.error('\n❌ ERROR: Failed to install browsers');
142
- console.error(' Error:', error.message);
143
- console.error('\n💡 Troubleshooting:');
144
- console.error(' 1. Ensure you have internet connectivity');
145
- console.error(' 2. Try running: npm run install:browsers');
146
- console.error(' 3. Check Node.js version: node --version');
147
- console.error(' 4. For Linux: sudo apt-get install -y $(npx playwright install-deps --print-deps)');
148
- process.exit(1);
176
+ console.warn(' Could not determine Playwright version');
149
177
  }
150
178
 
179
+ // Final status message
180
+ const setupComplete = browsersInstalled && playwrightInstalled;
181
+
151
182
  console.log('\n' + '='.repeat(60));
152
- console.log('🎉 Setup complete! Chromium browser installed. pi-research is ready to use.');
183
+ if (setupComplete) {
184
+ console.log('🎉 Setup complete! Chromium browser installed. pi-research is ready to use.');
185
+ } else {
186
+ console.log('📦 pi-research installed successfully!');
187
+ console.log(' Chromium browser installation may be in progress or was skipped.');
188
+ console.log(' The browser will be installed automatically when you first use the research tool.');
189
+ }
153
190
  console.log('='.repeat(60));
154
191
 
155
192
  // Additional info
@@ -158,6 +195,7 @@ console.log(' • npm test - Run unit tests');
158
195
  console.log(' • npm run type-check - Type check TypeScript');
159
196
  console.log(' • npm run lint - Run ESLint');
160
197
  console.log(' • npm run setup - Full setup with system deps');
198
+ console.log(' • npm run install:browsers - Install Chromium browser only');
161
199
 
162
200
  // Platform-specific tips
163
201
  if (isLinux) {
package/src/config.ts CHANGED
@@ -27,6 +27,8 @@ export interface Config {
27
27
  CONSOLE_RESTORE_DELAY_MS: number;
28
28
  /** BCP 47 language tag sent to SearXNG to filter search results (default: 'en-US') */
29
29
  SEARCH_LANGUAGE: string;
30
+ /** External SearXNG base URL — bypasses Docker container management entirely when set */
31
+ SEARXNG_URL?: string;
30
32
  }
31
33
 
32
34
  /**
@@ -41,6 +43,7 @@ const DEFAULTS: Config = {
41
43
  TUI_REFRESH_DEBOUNCE_MS: 10,
42
44
  CONSOLE_RESTORE_DELAY_MS: 15000,
43
45
  SEARCH_LANGUAGE: 'en-US',
46
+ SEARXNG_URL: undefined,
44
47
  };
45
48
 
46
49
  /**
@@ -110,6 +113,7 @@ export function createConfig(env: Record<string, string | undefined> = process.e
110
113
  DEFAULTS.CONSOLE_RESTORE_DELAY_MS
111
114
  ),
112
115
  SEARCH_LANGUAGE: parseEnvString(env, 'PI_RESEARCH_SEARCH_LANGUAGE') ?? DEFAULTS.SEARCH_LANGUAGE,
116
+ SEARXNG_URL: parseEnvString(env, 'SEARXNG_URL'),
113
117
  };
114
118
  }
115
119
 
@@ -200,7 +204,15 @@ export function getBraveSearchApiKey(): string | undefined {
200
204
  return getConfig().BRAVE_SEARCH_API_KEY;
201
205
  }
202
206
 
207
+ /**
208
+ * @deprecated Use getConfig().SEARXNG_URL instead
209
+ */
210
+ export function getExternalSearxngUrl(): string | undefined {
211
+ return getConfig().SEARXNG_URL;
212
+ }
213
+
203
214
  // Legacy variable exports (for backward compatibility, but reflect module load time values)
204
215
  export const RESEARCHER_TIMEOUT_MS = getResearcherTimeoutMs();
205
216
  export const PROXY_URL = getProxyUrl();
206
217
  export const BRAVE_SEARCH_API_KEY = getBraveSearchApiKey();
218
+ export const SEARXNG_URL = getExternalSearxngUrl();
package/src/index.ts CHANGED
@@ -47,7 +47,7 @@ export default function (pi: ExtensionAPI) {
47
47
  pi.on('session_start', async (_event, ctx) => {
48
48
  // Skip Docker check during install to avoid errors on fresh systems
49
49
  // Docker will be checked when research tool is first used
50
- if (!process.env['PI_INSTALL_MODE']) {
50
+ if (!process.env['PI_INSTALL_MODE'] && !process.env['SEARXNG_URL']) {
51
51
  const dockerCheck = await checkDockerAvailability();
52
52
  if (!dockerCheck.running) {
53
53
  ctx.ui.notify(`pi-research: ${dockerCheck.error}`, 'warning');
@@ -24,7 +24,7 @@ import * as path from 'node:path';
24
24
  import * as fs from 'node:fs';
25
25
  import * as os from 'node:os';
26
26
  import { fileURLToPath } from 'node:url';
27
- import { PROXY_URL, BRAVE_SEARCH_API_KEY } from '../config.ts';
27
+ import { PROXY_URL, BRAVE_SEARCH_API_KEY, SEARXNG_URL } from '../config.ts';
28
28
  import { shutdownManager } from '../utils/shutdown-manager.ts';
29
29
 
30
30
  /**
@@ -98,6 +98,8 @@ export interface SearxngLifecycleConfig {
98
98
  manager?: DockerSearxngManager | null;
99
99
  proxyUrl?: string;
100
100
  braveApiKey?: string;
101
+ /** External SearXNG base URL. When set, Docker container management is skipped entirely. */
102
+ searxngUrl?: string;
101
103
  }
102
104
 
103
105
  type ResolvedConfig = {
@@ -106,6 +108,7 @@ type ResolvedConfig = {
106
108
  manager: DockerSearxngManager | null;
107
109
  proxyUrl: string | undefined;
108
110
  braveApiKey: string | undefined;
111
+ searxngUrl: string | undefined;
109
112
  };
110
113
 
111
114
  /**
@@ -132,6 +135,7 @@ const DEFAULT_CONFIG: ResolvedConfig = {
132
135
  manager: null,
133
136
  proxyUrl: PROXY_URL,
134
137
  braveApiKey: BRAVE_SEARCH_API_KEY,
138
+ searxngUrl: SEARXNG_URL,
135
139
  };
136
140
 
137
141
  /**
@@ -152,6 +156,10 @@ export class SearxngLifecycleManager implements ISearxngLifecycleManager {
152
156
  private statusCallbacks: StatusCallback[] = [];
153
157
  private readonly config: ResolvedConfig;
154
158
  private proxySettingsPath: string | null = null;
159
+ /** Coalesces concurrent init() calls onto a single in-flight promise. */
160
+ private initializationPromise: Promise<void> | null = null;
161
+ /** Set when SEARXNG_URL is configured; signals that Docker management is bypassed. */
162
+ private externalSearxngUrl: string | null = null;
155
163
 
156
164
  constructor(config: Partial<SearxngLifecycleConfig> = {}) {
157
165
  this.config = { ...DEFAULT_CONFIG, ...config } as ResolvedConfig;
@@ -162,9 +170,15 @@ export class SearxngLifecycleManager implements ISearxngLifecycleManager {
162
170
  }
163
171
 
164
172
  /**
165
- * Get manager instance (for passing to web-research module)
173
+ * Get manager instance (for passing to web-research module).
174
+ * In external URL mode, returns a minimal shim that satisfies the getSearxngUrl()
175
+ * interface used by web-research tools and the health check.
166
176
  */
167
177
  getManager(): DockerSearxngManager | null {
178
+ if (this.externalSearxngUrl) {
179
+ const url = this.externalSearxngUrl;
180
+ return { getSearxngUrl: () => url } as unknown as DockerSearxngManager;
181
+ }
168
182
  return this.manager;
169
183
  }
170
184
 
@@ -279,6 +293,46 @@ export class SearxngLifecycleManager implements ISearxngLifecycleManager {
279
293
  return;
280
294
  }
281
295
 
296
+ // Coalesce concurrent init() calls: only the first proceeds; latecomers
297
+ // await the in-flight promise and return once it resolves.
298
+ // This assignment and check are both synchronous (no await between them),
299
+ // so JavaScript's single-threaded event loop guarantees only one caller
300
+ // can set initializationPromise before any other caller checks it.
301
+ if (this.initializationPromise) {
302
+ logger.log('[pi-research] SearXNG init already in progress, awaiting');
303
+ await this.initializationPromise;
304
+ return;
305
+ }
306
+
307
+ let resolveInit!: () => void;
308
+ let rejectInit!: (err: unknown) => void;
309
+ this.initializationPromise = new Promise<void>((resolve, reject) => {
310
+ resolveInit = resolve;
311
+ rejectInit = reject;
312
+ });
313
+
314
+ try {
315
+ await this._performInit(ctx);
316
+ resolveInit();
317
+ } catch (err) {
318
+ rejectInit(err);
319
+ throw err;
320
+ } finally {
321
+ this.initializationPromise = null;
322
+ }
323
+ }
324
+
325
+ private async _performInit(ctx: ExtensionContext): Promise<void> {
326
+ // External SearXNG URL: skip all Docker management and use the URL directly.
327
+ if (this.config.searxngUrl) {
328
+ this.externalSearxngUrl = this.config.searxngUrl;
329
+ this.currentStatus = { state: 'active', url: this.config.searxngUrl, isFunctional: false };
330
+ this.initialized = true;
331
+ logger.log('[pi-research] External SEARXNG_URL configured — Docker management skipped:', this.config.searxngUrl);
332
+ this.notifyStatusChange();
333
+ return;
334
+ }
335
+
282
336
  // Skip real Docker verification when a test/dummy manager is injected.
283
337
  if (!this.config.manager) {
284
338
  const dockerCheck = await verifyDockerInstalled();
@@ -396,6 +450,10 @@ export class SearxngLifecycleManager implements ISearxngLifecycleManager {
396
450
  }
397
451
 
398
452
  async ensureRunning(): Promise<string> {
453
+ if (this.externalSearxngUrl) {
454
+ return this.externalSearxngUrl;
455
+ }
456
+
399
457
  if (!this.manager) {
400
458
  throw new Error('SearXNG not initialized. Call initLifecycle() first.');
401
459
  }
@@ -411,6 +469,15 @@ export class SearxngLifecycleManager implements ISearxngLifecycleManager {
411
469
  }
412
470
 
413
471
  async shutdown(): Promise<void> {
472
+ if (this.externalSearxngUrl) {
473
+ // External URL mode — no container to tear down
474
+ this.externalSearxngUrl = null;
475
+ this.currentStatus = { state: 'inactive', url: '', isFunctional: false };
476
+ this.initialized = false;
477
+ this.notifyStatusChange();
478
+ return;
479
+ }
480
+
414
481
  if (!this.manager || !this.initialized) {
415
482
  logger.log('[pi-research] SearXNG not active, skipping shutdown');
416
483
  return;
@@ -454,7 +521,7 @@ export class SearxngLifecycleManager implements ISearxngLifecycleManager {
454
521
  }
455
522
 
456
523
  isInitialized(): boolean {
457
- return this.initialized && this.manager !== null;
524
+ return this.initialized && (this.manager !== null || this.externalSearxngUrl !== null);
458
525
  }
459
526
  }
460
527
 
@@ -79,5 +79,15 @@ export function ensureAssistantResponse(session: AgentSession, label: string): s
79
79
  throw new Error(`${label}: Provider error - ${msg}`);
80
80
  }
81
81
 
82
- return extractText(last);
82
+ const text = extractText(last);
83
+ if (!text.trim()) {
84
+ // No text content blocks in the final assistant message. This typically means
85
+ // all tool calls failed (e.g. SearXNG unreachable) and the model produced only
86
+ // thinking blocks, or the session ended without a visible response.
87
+ throw new Error(
88
+ `${label}: Researcher produced no text output. ` +
89
+ `This usually means SearXNG was unavailable during the run — check Docker status and retry.`
90
+ );
91
+ }
92
+ return text;
83
93
  }