@demigodmode/pi-web-agent 1.10.0 → 1.12.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 (77) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/README.md +19 -4
  3. package/dist/backends/config.d.ts +40 -0
  4. package/dist/backends/config.js +140 -1
  5. package/dist/backends/factory.d.ts +17 -1
  6. package/dist/backends/factory.js +170 -85
  7. package/dist/backends/failure.d.ts +11 -0
  8. package/dist/backends/failure.js +34 -0
  9. package/dist/backends/fallback-policy.d.ts +33 -0
  10. package/dist/backends/fallback-policy.js +239 -0
  11. package/dist/backends/provider-failure.d.ts +21 -0
  12. package/dist/backends/provider-failure.js +111 -0
  13. package/dist/backends/provider-health.d.ts +29 -0
  14. package/dist/backends/provider-health.js +49 -0
  15. package/dist/commands/web-agent-config.d.ts +17 -1
  16. package/dist/commands/web-agent-config.js +131 -7
  17. package/dist/extension.d.ts +1 -0
  18. package/dist/extension.js +49 -3
  19. package/dist/fetch/destination-policy.d.ts +32 -0
  20. package/dist/fetch/destination-policy.js +24 -0
  21. package/dist/fetch/firecrawl-fetch.js +64 -45
  22. package/dist/fetch/guard-proxy-fetch.d.ts +17 -0
  23. package/dist/fetch/guard-proxy-fetch.js +82 -0
  24. package/dist/fetch/guard-proxy.d.ts +58 -0
  25. package/dist/fetch/guard-proxy.js +420 -0
  26. package/dist/fetch/guarded-fetch.d.ts +7 -0
  27. package/dist/fetch/guarded-fetch.js +75 -0
  28. package/dist/fetch/headless-fetch.d.ts +17 -2
  29. package/dist/fetch/headless-fetch.js +181 -9
  30. package/dist/fetch/http-fetch.js +16 -1
  31. package/dist/fetch/network-guard.d.ts +82 -0
  32. package/dist/fetch/network-guard.js +275 -0
  33. package/dist/fetch/proxy-fetch.d.ts +22 -0
  34. package/dist/fetch/proxy-fetch.js +46 -0
  35. package/dist/jiti-compat-run.d.ts +1 -0
  36. package/dist/jiti-compat-run.js +9 -0
  37. package/dist/jiti-compat.d.ts +32 -0
  38. package/dist/jiti-compat.js +215 -0
  39. package/dist/orchestration/answer-synthesizer.js +2 -0
  40. package/dist/orchestration/evidence-quality.d.ts +3 -2
  41. package/dist/orchestration/evidence-quality.js +2 -1
  42. package/dist/orchestration/index.d.ts +23 -0
  43. package/dist/orchestration/index.js +9 -2
  44. package/dist/orchestration/research-orchestrator.d.ts +21 -1
  45. package/dist/orchestration/research-orchestrator.js +40 -7
  46. package/dist/orchestration/research-types.d.ts +13 -1
  47. package/dist/orchestration/research-worker.js +38 -3
  48. package/dist/orchestration/stop-decider.js +3 -1
  49. package/dist/presentation/config-store.js +10 -0
  50. package/dist/presentation/explore-presentation.js +3 -1
  51. package/dist/presentation/fetch-presentation.js +16 -9
  52. package/dist/presentation/search-presentation.d.ts +2 -1
  53. package/dist/presentation/search-presentation.js +13 -1
  54. package/dist/readers/youtube-reader.d.ts +3 -1
  55. package/dist/readers/youtube-reader.js +11 -3
  56. package/dist/search/brave.d.ts +1 -2
  57. package/dist/search/brave.js +23 -80
  58. package/dist/search/duckduckgo.d.ts +7 -3
  59. package/dist/search/duckduckgo.js +17 -18
  60. package/dist/search/exa.d.ts +1 -2
  61. package/dist/search/exa.js +15 -76
  62. package/dist/search/fanout.d.ts +12 -0
  63. package/dist/search/fanout.js +86 -47
  64. package/dist/search/json-provider.d.ts +32 -0
  65. package/dist/search/json-provider.js +76 -0
  66. package/dist/search/searxng.d.ts +1 -2
  67. package/dist/search/searxng.js +15 -57
  68. package/dist/search/tavily.d.ts +1 -2
  69. package/dist/search/tavily.js +17 -74
  70. package/dist/search/youcom.d.ts +1 -2
  71. package/dist/search/youcom.js +15 -76
  72. package/dist/tools/web-explore.d.ts +9 -0
  73. package/dist/tools/web-explore.js +16 -2
  74. package/dist/tools/web-search.js +41 -103
  75. package/dist/types.d.ts +40 -0
  76. package/package.json +4 -3
  77. package/scripts/patch-jiti-compat.mjs +52 -8
@@ -0,0 +1,21 @@
1
+ import type { FailureInfo, SearchProviderName } from '../types.js';
2
+ export type ClassifiedProvider = SearchProviderName | 'firecrawl';
3
+ export type ResponseParts = {
4
+ status: number;
5
+ headers: Headers;
6
+ /** Parsed JSON body, or undefined when the body was empty or not JSON. */
7
+ json?: unknown;
8
+ };
9
+ export declare class BodyReadError extends Error {
10
+ readonly cause: unknown;
11
+ constructor(cause: unknown);
12
+ }
13
+ /**
14
+ * Reads a response body once, keeping status and headers alongside the parsed JSON.
15
+ * A body that can't be read (connection dropped mid-stream) throws BodyReadError: that's
16
+ * a transport failure, not a malformed response.
17
+ */
18
+ export declare function readResponseParts(response: Response): Promise<ResponseParts & {
19
+ text: string;
20
+ }>;
21
+ export declare function classifyHttpFailure(provider: ClassifiedProvider, parts: ResponseParts, now?: number): FailureInfo;
@@ -0,0 +1,111 @@
1
+ import { parseRetryAfter } from './failure.js';
2
+ export class BodyReadError extends Error {
3
+ cause;
4
+ constructor(cause) {
5
+ super(cause instanceof Error ? cause.message : String(cause));
6
+ this.cause = cause;
7
+ this.name = 'BodyReadError';
8
+ }
9
+ }
10
+ /**
11
+ * Reads a response body once, keeping status and headers alongside the parsed JSON.
12
+ * A body that can't be read (connection dropped mid-stream) throws BodyReadError: that's
13
+ * a transport failure, not a malformed response.
14
+ */
15
+ export async function readResponseParts(response) {
16
+ let text;
17
+ try {
18
+ text = await response.text();
19
+ }
20
+ catch (error) {
21
+ throw new BodyReadError(error);
22
+ }
23
+ let json;
24
+ try {
25
+ json = text ? JSON.parse(text) : undefined;
26
+ }
27
+ catch {
28
+ json = undefined;
29
+ }
30
+ return { status: response.status, headers: response.headers, json, text };
31
+ }
32
+ // Source: https://exa.ai/docs/reference/error-codes
33
+ const EXA_TAGS = {
34
+ RATE_LIMIT_EXCEEDED: 'rate_limited',
35
+ NO_MORE_CREDITS: 'quota_exhausted',
36
+ API_KEY_BUDGET_EXCEEDED: 'quota_exhausted',
37
+ TEAM_BUDGET_EXCEEDED: 'quota_exhausted',
38
+ INVALID_API_KEY: 'auth_failed',
39
+ FEATURE_DISABLED: 'auth_failed',
40
+ PROHIBITED_CONTENT: 'bad_request',
41
+ CONTENT_FILTER_ERROR: 'bad_request',
42
+ INVALID_REQUEST_BODY: 'bad_request',
43
+ INVALID_REQUEST: 'bad_request',
44
+ INVALID_NUM_RESULTS: 'bad_request'
45
+ };
46
+ /**
47
+ * Conservative defaults for undocumented responses. A 401 disables the provider
48
+ * (low risk: resets on config change); an undocumented 403 is treated as a bot
49
+ * wall; an undocumented 402 is not assumed to mean quota.
50
+ */
51
+ function defaultKind(status) {
52
+ if (status === 429)
53
+ return 'rate_limited';
54
+ if (status === 401)
55
+ return 'auth_failed';
56
+ if (status === 403)
57
+ return 'blocked';
58
+ if (status === 400 || status === 422)
59
+ return 'bad_request';
60
+ if (status === 408 || status >= 500)
61
+ return 'transient';
62
+ return 'bad_response';
63
+ }
64
+ function stringField(json, field) {
65
+ if (!json || typeof json !== 'object')
66
+ return undefined;
67
+ const value = json[field];
68
+ return typeof value === 'string' ? value : undefined;
69
+ }
70
+ export function classifyHttpFailure(provider, parts, now = Date.now()) {
71
+ const { status } = parts;
72
+ let kind;
73
+ let providerCode;
74
+ if (provider === 'exa') {
75
+ const tag = stringField(parts.json, 'tag');
76
+ if (tag && EXA_TAGS[tag]) {
77
+ kind = EXA_TAGS[tag];
78
+ providerCode = tag;
79
+ }
80
+ else if (status === 402) {
81
+ kind = 'quota_exhausted'; // documented 402 meaning for Exa
82
+ }
83
+ }
84
+ else if (provider === 'firecrawl') {
85
+ // Source: https://docs.firecrawl.dev/api-reference/errors
86
+ if (status === 402)
87
+ kind = 'quota_exhausted';
88
+ }
89
+ else if (provider === 'youcom') {
90
+ // Source: https://you.com/docs/api-reference/search/v1-search (429 UNVERIFIED -> default)
91
+ // Documented for /v1/search; the client currently calls /v1/agents/search (#60).
92
+ if (status === 402)
93
+ kind = 'quota_exhausted';
94
+ else if (status === 403)
95
+ kind = 'auth_failed';
96
+ }
97
+ else if (provider === 'searxng') {
98
+ // Source: https://docs.searxng.org/dev/search_api.html (403 = format=json disabled in settings)
99
+ if (status === 403)
100
+ kind = 'auth_failed';
101
+ }
102
+ // brave, tavily, duckduckgo: defaults only (UNVERIFIED beyond 429; see research gate).
103
+ const resolved = kind ?? defaultKind(status);
104
+ const info = { kind: resolved, httpStatus: status, ...(providerCode ? { providerCode } : {}) };
105
+ if (resolved === 'rate_limited') {
106
+ const retryAfter = parseRetryAfter(parts.headers.get('retry-after'), now);
107
+ if (retryAfter !== undefined)
108
+ info.providerRetryAfterMs = retryAfter;
109
+ }
110
+ return info;
111
+ }
@@ -0,0 +1,29 @@
1
+ import type { FailureInfo } from '../types.js';
2
+ export declare const DEFAULT_COOLDOWN_MS = 60000;
3
+ export declare const MIN_COOLDOWN_MS = 1000;
4
+ export declare const MAX_COOLDOWN_MS: number;
5
+ export type ProviderHealthState = {
6
+ state: 'available';
7
+ } | {
8
+ state: 'cooling_down';
9
+ until: number;
10
+ failure: FailureInfo;
11
+ detail?: string;
12
+ } | {
13
+ state: 'disabled';
14
+ failure: FailureInfo;
15
+ detail?: string;
16
+ };
17
+ export type ProviderHealth = {
18
+ get(key: string): ProviderHealthState;
19
+ record(key: string, failure: FailureInfo, detail?: string): ProviderHealthState;
20
+ };
21
+ /** The applied cooldown. The provider's own value stays in failure.providerRetryAfterMs. */
22
+ export declare function cooldownFor(failure: FailureInfo): number;
23
+ /**
24
+ * Per backend set. The set is rebuilt on any effective config change, so fixed
25
+ * credentials or new endpoints take effect without restarting Pi.
26
+ */
27
+ export declare function createProviderHealth({ now }?: {
28
+ now?: () => number;
29
+ }): ProviderHealth;
@@ -0,0 +1,49 @@
1
+ export const DEFAULT_COOLDOWN_MS = 60_000;
2
+ export const MIN_COOLDOWN_MS = 1000;
3
+ export const MAX_COOLDOWN_MS = 15 * 60_000;
4
+ /** The applied cooldown. The provider's own value stays in failure.providerRetryAfterMs. */
5
+ export function cooldownFor(failure) {
6
+ const raw = failure.providerRetryAfterMs;
7
+ if (raw === undefined || Number.isNaN(raw))
8
+ return DEFAULT_COOLDOWN_MS;
9
+ return Math.min(MAX_COOLDOWN_MS, Math.max(MIN_COOLDOWN_MS, raw));
10
+ }
11
+ /**
12
+ * Per backend set. The set is rebuilt on any effective config change, so fixed
13
+ * credentials or new endpoints take effect without restarting Pi.
14
+ */
15
+ export function createProviderHealth({ now = Date.now } = {}) {
16
+ const states = new Map();
17
+ function get(key) {
18
+ const current = states.get(key);
19
+ if (!current)
20
+ return { state: 'available' };
21
+ if (current.state === 'cooling_down' && current.until <= now()) {
22
+ states.delete(key);
23
+ return { state: 'available' };
24
+ }
25
+ return current;
26
+ }
27
+ function record(key, failure, detail) {
28
+ const current = get(key);
29
+ if (current.state === 'disabled')
30
+ return current;
31
+ switch (failure.kind) {
32
+ case 'rate_limited': {
33
+ const next = { state: 'cooling_down', until: now() + cooldownFor(failure), failure };
34
+ states.set(key, next);
35
+ return next;
36
+ }
37
+ case 'quota_exhausted':
38
+ case 'auth_failed':
39
+ case 'not_configured': {
40
+ const next = { state: 'disabled', failure, ...(detail ? { detail } : {}) };
41
+ states.set(key, next);
42
+ return next;
43
+ }
44
+ default:
45
+ return current;
46
+ }
47
+ }
48
+ return { get, record };
49
+ }
@@ -16,6 +16,9 @@ type CommandDeps = {
16
16
  arch: string;
17
17
  };
18
18
  checkTypebox?: () => Promise<boolean>;
19
+ checkJitiCompat?: () => {
20
+ pending: string[];
21
+ };
19
22
  checkBackends?: (config: BackendConfig) => Promise<string[]>;
20
23
  getChangelog?: () => Promise<string | undefined>;
21
24
  };
@@ -33,7 +36,20 @@ export declare function validateBackendUrl(value: string): {
33
36
  ok: false;
34
37
  message: string;
35
38
  };
36
- export declare function createBackendUrlEditor(theme: any, label: string, placeholderUrl: string, onOpenChange?: (open: boolean) => void): (currentValue: string, done: (selectedValue?: string) => void) => Component;
39
+ export declare function validateAllowRanges(value: string): {
40
+ ok: true;
41
+ value: string;
42
+ } | {
43
+ ok: false;
44
+ message: string;
45
+ };
46
+ export declare function createBackendUrlEditor(theme: any, label: string, placeholderUrl: string, onOpenChange?: (open: boolean) => void, validate?: (value: string) => {
47
+ ok: true;
48
+ value: string;
49
+ } | {
50
+ ok: false;
51
+ message: string;
52
+ }): (currentValue: string, done: (selectedValue?: string) => void) => Component;
37
53
  export declare function getInheritedConfigForScope(loaded: Awaited<LoadedPresentationConfig>, scope: PresentationScope): PresentationConfig;
38
54
  export declare function getScopeDisplayConfig(loaded: Awaited<LoadedPresentationConfig>, scope: PresentationScope): PresentationConfig;
39
55
  export declare function getInheritedBackendsForScope(loaded: Awaited<LoadedPresentationConfig>, scope: PresentationScope): BackendConfig;
@@ -1,10 +1,13 @@
1
- import { DEFAULT_BACKEND_CONFIG, mergeBackendConfigLayers, validateBackendConfig, usableSearchProviders } from '../backends/config.js';
1
+ import { DEFAULT_BACKEND_CONFIG, isValidProxyUrl, mergeBackendConfigLayers, stripProxyCredentials, validateBackendConfig, usableSearchProviders } from '../backends/config.js';
2
2
  import { checkBackendHealth } from '../backends/doctor.js';
3
+ import { parseCidr } from '../fetch/network-guard.js';
3
4
  import { DynamicBorder, getSettingsListTheme } from '@earendil-works/pi-coding-agent';
4
5
  import { Container, Input, SelectList, SettingsList, Text } from '@earendil-works/pi-tui';
5
6
  import { DEFAULT_PRESENTATION_CONFIG, mergePresentationConfigLayers, resolvePresentationMode } from '../presentation/config.js';
6
7
  import { loadPresentationConfigLayers, resetPresentationConfigScope, saveBackendConfigScope, savePresentationConfigScope } from '../presentation/config-store.js';
7
8
  import { resolveBrowserExecutable } from '../fetch/browser-resolution.js';
9
+ import { createProxyFetch } from '../fetch/proxy-fetch.js';
10
+ import { checkJitiCompat } from '../jiti-compat.js';
8
11
  import { getLatestChangelogEntry } from '../changelog-notice.js';
9
12
  const PRESENTATION_TOOL_NAMES = ['web_explore'];
10
13
  function parseScopeToken(token) {
@@ -29,7 +32,11 @@ function cloneBackendConfig(config) {
29
32
  ...config.fetch,
30
33
  options: config.fetch.options ? { ...config.fetch.options } : undefined
31
34
  },
32
- headless: { ...config.headless }
35
+ headless: { ...config.headless },
36
+ proxy: config.proxy ? { ...config.proxy } : undefined,
37
+ network: config.network
38
+ ? { ...config.network, ...(config.network.allowRanges ? { allowRanges: [...config.network.allowRanges] } : {}) }
39
+ : undefined
33
40
  };
34
41
  }
35
42
  function sameJson(left, right) {
@@ -47,6 +54,37 @@ export function validateBackendUrl(value) {
47
54
  return { ok: false, message: 'Invalid URL. Include http:// or https://.' };
48
55
  }
49
56
  }
57
+ function splitRanges(value) {
58
+ return value
59
+ .split(',')
60
+ .map((range) => range.trim())
61
+ .filter(Boolean);
62
+ }
63
+ export function validateAllowRanges(value) {
64
+ for (const range of splitRanges(value)) {
65
+ const cidr = parseCidr(range);
66
+ if (!cidr)
67
+ return { ok: false, message: `Not a valid CIDR range: ${range}` };
68
+ if (cidr.prefix === 0)
69
+ return { ok: false, message: `${range} allows every address. List specific ranges instead.` };
70
+ }
71
+ return { ok: true, value: splitRanges(value).join(', ') };
72
+ }
73
+ /**
74
+ * The extension re-applies this patch on load, so `pending` here means the
75
+ * patch could not be written (read-only install, permissions, or a dependency
76
+ * whose shape changed). Point at the manual script in that case: it is the
77
+ * same recovery step people have been finding by digging through issue #34.
78
+ */
79
+ function formatJitiCompatLine(status) {
80
+ if (status.pending.length === 0)
81
+ return 'jsdom compat patch: ok';
82
+ return [
83
+ `jsdom compat patch: needed (${status.pending.join(', ')})`,
84
+ 'Run: node ~/.pi/agent/npm/node_modules/@demigodmode/pi-web-agent/scripts/patch-jiti-compat.mjs',
85
+ 'then restart Pi.'
86
+ ].join('\n');
87
+ }
50
88
  async function defaultCheckTypebox() {
51
89
  try {
52
90
  await import('typebox');
@@ -87,8 +125,11 @@ function formatBackendSummary(config = DEFAULT_BACKEND_CONFIG) {
87
125
  return [
88
126
  searchSuffix ? `${searchBase} ${searchSuffix}` : searchBase,
89
127
  fetchSuffix ? `${fetchBase} ${fetchSuffix}` : fetchBase,
90
- `headless: ${config.headless.provider}`
91
- ].join('\n');
128
+ `headless: ${config.headless.provider}`,
129
+ config.proxy ? `proxy: ${stripProxyCredentials(config.proxy.url)}` : undefined
130
+ ]
131
+ .filter(Boolean)
132
+ .join('\n');
92
133
  }
93
134
  function formatConfigSummary(config) {
94
135
  const lines = [`defaultMode: ${config.defaultMode}`];
@@ -119,7 +160,7 @@ function buildPresentationSettingsItems(scope, config) {
119
160
  }))
120
161
  ];
121
162
  }
122
- export function createBackendUrlEditor(theme, label, placeholderUrl, onOpenChange) {
163
+ export function createBackendUrlEditor(theme, label, placeholderUrl, onOpenChange, validate = validateBackendUrl) {
123
164
  return (currentValue, done) => {
124
165
  onOpenChange?.(true);
125
166
  const initialValue = currentValue && currentValue !== 'not set' ? currentValue : placeholderUrl;
@@ -143,7 +184,7 @@ export function createBackendUrlEditor(theme, label, placeholderUrl, onOpenChang
143
184
  finish('');
144
185
  return;
145
186
  }
146
- const validated = validateBackendUrl(value);
187
+ const validated = validate(value);
147
188
  if (!validated.ok) {
148
189
  showError(validated.message);
149
190
  return;
@@ -249,6 +290,26 @@ function buildBackendSettingsItems(scope, backends, theme, onUrlEditorOpenChange
249
290
  label: 'Firecrawl API key',
250
291
  currentValue: 'env var',
251
292
  values: ['env var']
293
+ },
294
+ {
295
+ id: 'backend:proxy:url',
296
+ label: 'Proxy URL',
297
+ currentValue: backends.proxy ? stripProxyCredentials(backends.proxy.url) : 'not set',
298
+ submenu: createBackendUrlEditor(theme, 'HTTP proxy URL', 'http://127.0.0.1:7890', onUrlEditorOpenChange)
299
+ },
300
+ {
301
+ id: 'backend:network:allowRanges',
302
+ label: 'Network allow list',
303
+ currentValue: backends.network?.allowRanges?.length ? backends.network.allowRanges.join(', ') : 'not set',
304
+ submenu: createBackendUrlEditor(theme, 'Private ranges to allow, comma separated CIDRs, e.g. 198.18.0.0/15',
305
+ // No prefilled example: pressing enter on an empty list must not quietly add an exception.
306
+ '', onUrlEditorOpenChange, validateAllowRanges)
307
+ },
308
+ {
309
+ id: 'backend:network:trustProxyDns',
310
+ label: 'Trust the upstream proxy to enforce private-address restrictions',
311
+ currentValue: backends.network?.trustProxyDns ? 'on' : 'off',
312
+ values: ['off', 'on']
252
313
  }
253
314
  ];
254
315
  }
@@ -421,6 +482,33 @@ export function applySettingsValue(state, id, newValue) {
421
482
  delete currentBackends.fetch.baseUrl;
422
483
  }
423
484
  }
485
+ if (id === 'backend:proxy:url') {
486
+ if (newValue.trim()) {
487
+ currentBackends.proxy = { ...currentBackends.proxy, url: newValue.trim() };
488
+ }
489
+ else {
490
+ delete currentBackends.proxy;
491
+ }
492
+ }
493
+ if (id === 'backend:network:allowRanges') {
494
+ const ranges = splitRanges(newValue);
495
+ const next = { ...currentBackends.network };
496
+ if (ranges.length > 0) {
497
+ next.allowRanges = ranges;
498
+ }
499
+ else {
500
+ delete next.allowRanges;
501
+ }
502
+ if (Object.keys(next).length > 0) {
503
+ currentBackends.network = next;
504
+ }
505
+ else {
506
+ delete currentBackends.network;
507
+ }
508
+ }
509
+ if (id === 'backend:network:trustProxyDns') {
510
+ currentBackends.network = { ...currentBackends.network, trustProxyDns: newValue === 'on' };
511
+ }
424
512
  nextDrafts[nextScope] = currentDraft;
425
513
  nextBackendDrafts[nextScope] = currentBackends;
426
514
  return {
@@ -485,6 +573,34 @@ export function collapseBackendConfigToOverride(config, inheritedConfig) {
485
573
  if (!sameJson(config.headless, inheritedConfig.headless)) {
486
574
  override.headless = { ...config.headless };
487
575
  }
576
+ if (!sameJson(config.proxy, inheritedConfig.proxy)) {
577
+ if (config.proxy) {
578
+ const { password: _password, ...proxy } = config.proxy;
579
+ override.proxy = { ...proxy };
580
+ }
581
+ else if (inheritedConfig.proxy) {
582
+ // The proxy was cleared at this scope; record an explicit disable so it
583
+ // overrides a proxy set in a lower (e.g. global) layer.
584
+ override.proxy = { url: '' };
585
+ }
586
+ }
587
+ if (!sameJson(config.network, inheritedConfig.network)) {
588
+ if (config.network) {
589
+ override.network = {
590
+ ...config.network,
591
+ ...(config.network.allowRanges ? { allowRanges: [...config.network.allowRanges] } : {})
592
+ };
593
+ // Cleared at this scope: an explicit empty list overrides the parent's.
594
+ if (!config.network.allowRanges && inheritedConfig.network?.allowRanges)
595
+ override.network.allowRanges = [];
596
+ }
597
+ else if (inheritedConfig.network) {
598
+ override.network = {
599
+ ...(inheritedConfig.network.allowRanges ? { allowRanges: [] } : {}),
600
+ ...(inheritedConfig.network.trustProxyDns ? { trustProxyDns: false } : {})
601
+ };
602
+ }
603
+ }
488
604
  return override;
489
605
  }
490
606
  export function handleSettingsShortcut(data) {
@@ -669,7 +785,12 @@ export function registerWebAgentConfigCommands(pi, deps = {}) {
669
785
  arch: process.arch
670
786
  };
671
787
  const checkTypebox = deps.checkTypebox ?? defaultCheckTypebox;
672
- const checkBackends = deps.checkBackends ?? ((config) => checkBackendHealth(config));
788
+ const checkCompat = deps.checkJitiCompat ?? checkJitiCompat;
789
+ const checkBackends = deps.checkBackends ?? ((config) => checkBackendHealth(config, {
790
+ // An invalid proxy url is reported by validateBackendConfig below; never
791
+ // build a proxy agent from it (no connectivity check is attempted).
792
+ fetchImpl: config.proxy && isValidProxyUrl(config.proxy.url) ? createProxyFetch(config.proxy) : fetch
793
+ }));
673
794
  const getChangelog = deps.getChangelog ?? (() => getLatestChangelogEntry());
674
795
  pi.registerCommand('web-agent', {
675
796
  description: 'Open settings or manage pi-web-agent presentation config',
@@ -700,7 +821,10 @@ export function registerWebAgentConfigCommands(pi, deps = {}) {
700
821
  'pi-web-agent: loaded',
701
822
  `runtime: node ${runtime.nodeVersion} ${runtime.platform} ${runtime.arch}`,
702
823
  `typebox: ${typeboxOk ? 'ok' : 'missing'}`,
824
+ formatJitiCompatLine(checkCompat()),
703
825
  formatBackendSummary(backendConfig),
826
+ `network allow list: ${backendConfig.network?.allowRanges?.length ? backendConfig.network.allowRanges.join(', ') : 'none'}`,
827
+ `trust upstream proxy for private addresses: ${backendConfig.network?.trustProxyDns ? 'on' : 'off'}`,
704
828
  backendIssues.length > 0 ? `backend config: warning\n${backendIssues.join('\n')}` : 'backend config: ok',
705
829
  ...backendHealth
706
830
  ];
@@ -1,2 +1,3 @@
1
+ import './jiti-compat-run.js';
1
2
  import type { ExtensionAPI } from '@earendil-works/pi-coding-agent';
2
3
  export default function extension(pi: ExtensionAPI): void;
package/dist/extension.js CHANGED
@@ -1,3 +1,5 @@
1
+ // Keep first: re-applies the jsdom/jiti compat patch before jsdom loads (#34).
2
+ import './jiti-compat-run.js';
1
3
  import { DEFAULT_BACKEND_CONFIG } from './backends/config.js';
2
4
  import { Type } from 'typebox';
3
5
  import { registerWebAgentConfigCommands } from './commands/web-agent-config.js';
@@ -57,20 +59,64 @@ export default function extension(pi) {
57
59
  registerWebAgentConfigCommands(pi);
58
60
  const injectedWebExplore = pi.__webExploreTool;
59
61
  let cachedBackendKey;
62
+ let cachedWorkflow;
60
63
  let cachedWebExplore;
64
+ // Runs in flight per workflow, so a replaced workflow is never closed mid-run.
65
+ const activeRuns = new Map();
66
+ const retiring = new Set();
67
+ const closeWorkflow = (workflow) => {
68
+ retiring.delete(workflow);
69
+ void Promise.resolve(workflow.close?.()).catch(() => undefined);
70
+ };
71
+ const retire = (workflow) => {
72
+ if ((activeRuns.get(workflow) ?? 0) === 0) {
73
+ closeWorkflow(workflow);
74
+ }
75
+ else {
76
+ retiring.add(workflow);
77
+ }
78
+ };
79
+ const leased = (workflow) => ({
80
+ run: async (input) => {
81
+ activeRuns.set(workflow, (activeRuns.get(workflow) ?? 0) + 1);
82
+ try {
83
+ return await workflow.run(input);
84
+ }
85
+ finally {
86
+ const remaining = (activeRuns.get(workflow) ?? 1) - 1;
87
+ if (remaining > 0) {
88
+ activeRuns.set(workflow, remaining);
89
+ }
90
+ else {
91
+ activeRuns.delete(workflow);
92
+ if (retiring.has(workflow))
93
+ closeWorkflow(workflow);
94
+ }
95
+ }
96
+ }
97
+ });
61
98
  async function getConfiguredWebExplore() {
62
99
  if (injectedWebExplore)
63
100
  return injectedWebExplore;
64
101
  const backendConfig = await getEffectiveBackendConfig(pi);
65
102
  const backendKey = JSON.stringify(backendConfig);
66
103
  if (!cachedWebExplore || cachedBackendKey !== backendKey) {
104
+ if (cachedWorkflow)
105
+ retire(cachedWorkflow);
67
106
  cachedBackendKey = backendKey;
68
- cachedWebExplore = createWebExploreTool({
69
- explore: createResearchWorkflow({ backendConfig })
70
- });
107
+ cachedWorkflow = createResearchWorkflow({ backendConfig });
108
+ cachedWebExplore = createWebExploreTool({ explore: leased(cachedWorkflow) });
71
109
  }
72
110
  return cachedWebExplore;
73
111
  }
112
+ pi.on('session_shutdown', async () => {
113
+ const workflow = cachedWorkflow;
114
+ cachedWorkflow = undefined;
115
+ cachedWebExplore = undefined;
116
+ cachedBackendKey = undefined;
117
+ if (workflow)
118
+ retire(workflow);
119
+ });
74
120
  pi.on('session_start', async (_event, ctx) => {
75
121
  try {
76
122
  const notice = await getUpdateChangelogNotice();
@@ -0,0 +1,32 @@
1
+ import { BlockedAddressError, UnverifiedDestinationError, type NetworkGuard } from './network-guard.js';
2
+ export type Destination = {
3
+ action: 'connect';
4
+ host: string;
5
+ address: string;
6
+ } | {
7
+ action: 'delegate';
8
+ host: string;
9
+ } | {
10
+ action: 'refuse';
11
+ error: BlockedAddressError | UnverifiedDestinationError;
12
+ };
13
+ export type DestinationMode = {
14
+ /** An upstream proxy is configured. */
15
+ upstream: boolean;
16
+ /**
17
+ * The user explicitly trusts the upstream proxy to enforce private-address
18
+ * restrictions. Only meaningful with an upstream.
19
+ */
20
+ trustProxyDns?: boolean;
21
+ };
22
+ /**
23
+ * Turns one resolution into where a connection may go (spec revision 2).
24
+ *
25
+ * - blocked answers are refused in every mode
26
+ * - by default the connection goes to an address we checked, never a hostname
27
+ * something else will resolve
28
+ * - with a trusted upstream the hostname is delegated, which also covers names
29
+ * only that proxy can resolve
30
+ * - anything else unresolvable is refused
31
+ */
32
+ export declare function decideDestination(host: string, guard: NetworkGuard, mode: DestinationMode): Promise<Destination>;
@@ -0,0 +1,24 @@
1
+ import { BlockedAddressError, UnverifiedDestinationError } from './network-guard.js';
2
+ /**
3
+ * Turns one resolution into where a connection may go (spec revision 2).
4
+ *
5
+ * - blocked answers are refused in every mode
6
+ * - by default the connection goes to an address we checked, never a hostname
7
+ * something else will resolve
8
+ * - with a trusted upstream the hostname is delegated, which also covers names
9
+ * only that proxy can resolve
10
+ * - anything else unresolvable is refused
11
+ */
12
+ export async function decideDestination(host, guard, mode) {
13
+ const resolution = await guard.resolveHost(host);
14
+ if (resolution.status === 'blocked') {
15
+ return { action: 'refuse', error: new BlockedAddressError(resolution.host, resolution.address) };
16
+ }
17
+ if (mode.upstream && mode.trustProxyDns) {
18
+ return { action: 'delegate', host: resolution.host };
19
+ }
20
+ if (resolution.status === 'unresolved') {
21
+ return { action: 'refuse', error: new UnverifiedDestinationError(resolution.host) };
22
+ }
23
+ return { action: 'connect', host: resolution.host, address: resolution.addresses[0] };
24
+ }