@herbertgao/pi-extensions 2026.9.18 → 2026.9.19

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 (48) hide show
  1. package/README.md +19 -20
  2. package/THIRD_PARTY_NOTICES.md +1 -1
  3. package/node_modules/@herbertgao/pi-cc-extensions/extensions/feature/shell/working-message.ts +21 -6
  4. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/index.ts +15 -4
  5. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/diff/index.ts +43 -7
  6. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/grouping.ts +7 -2
  7. package/node_modules/@herbertgao/pi-cc-extensions/extensions/renderer/tool/result.ts +1 -1
  8. package/node_modules/@herbertgao/pi-cc-extensions/package.json +2 -2
  9. package/node_modules/pi-antigravity/README.md +29 -6
  10. package/node_modules/pi-antigravity/package.json +12 -7
  11. package/node_modules/pi-antigravity/src/auth/accounts.ts +327 -0
  12. package/node_modules/pi-antigravity/src/auth/index.ts +9 -0
  13. package/node_modules/pi-antigravity/src/client/client.ts +1 -1
  14. package/node_modules/pi-antigravity/src/image/image.ts +4 -4
  15. package/node_modules/pi-antigravity/src/index.ts +203 -13
  16. package/node_modules/pi-antigravity/src/search/index.ts +1 -0
  17. package/node_modules/pi-antigravity/src/search/search.ts +325 -0
  18. package/node_modules/pi-antigravity/src/stream/stream.ts +191 -14
  19. package/node_modules/pi-antigravity/src/types/enums.ts +45 -36
  20. package/node_modules/pi-antigravity/src/types/types.ts +5 -12
  21. package/node_modules/pi-antigravity/src/utils/http.ts +7 -2
  22. package/node_modules/pi-antigravity/src/utils/util.ts +31 -3
  23. package/node_modules/pi-typesafe/README.md +2 -0
  24. package/node_modules/pi-typesafe/package.json +2 -1
  25. package/node_modules/pi-web-access/CHANGELOG.md +72 -0
  26. package/node_modules/pi-web-access/README.md +34 -13
  27. package/node_modules/pi-web-access/chrome-cookies.ts +79 -25
  28. package/node_modules/pi-web-access/curator-page.ts +5 -1
  29. package/node_modules/pi-web-access/curator-server.ts +2 -0
  30. package/node_modules/pi-web-access/dist/index.js +1337 -1009
  31. package/node_modules/pi-web-access/exa.ts +20 -52
  32. package/node_modules/pi-web-access/extract.ts +41 -4
  33. package/node_modules/pi-web-access/gemini-search.ts +12 -4
  34. package/node_modules/pi-web-access/index.ts +29 -37
  35. package/node_modules/pi-web-access/openai-search.ts +35 -7
  36. package/node_modules/pi-web-access/package.json +6 -5
  37. package/node_modules/pi-web-access/page-query.ts +2 -2
  38. package/node_modules/pi-web-access/query-rewrite.ts +2 -3
  39. package/node_modules/pi-web-access/ssrf-protection.ts +27 -10
  40. package/node_modules/pi-web-access/storage.ts +48 -77
  41. package/node_modules/pi-web-access/summary-model-scope.ts +6 -77
  42. package/node_modules/pi-web-access/summary-review.ts +3 -4
  43. package/node_modules/pi-web-access/tavily.ts +67 -20
  44. package/node_modules/pi-web-access/tool-activation.ts +53 -33
  45. package/node_modules/pi-web-access/utils.ts +1 -1
  46. package/node_modules/pi-web-access/you.ts +164 -0
  47. package/node_modules/pi-web-access/zai.ts +161 -0
  48. package/package.json +6 -6
@@ -5,7 +5,7 @@ import { activityMonitor } from "./activity.ts";
5
5
  import { normalizeDomain } from "./domain-filter-normalization.ts";
6
6
  import type { SearchOptions, SearchResponse, SearchResult } from "./perplexity.ts";
7
7
  import { hasCredentialSource, redactCredential, resolveCredential } from "./credential-source.ts";
8
- import { getWebSearchConfigPath } from "./utils.ts";
8
+ import { fetchWithCredentialRedirects, getWebSearchConfigPath } from "./utils.ts";
9
9
 
10
10
  const OPENAI_RESPONSES_URL = "https://api.openai.com/v1/responses";
11
11
  const CODEX_RESPONSES_URL = "https://chatgpt.com/backend-api/codex/responses";
@@ -13,13 +13,16 @@ const CONFIG_PATH = getWebSearchConfigPath();
13
13
  const SEARCH_TIMEOUT_MS = 60_000;
14
14
 
15
15
  // The selected model runs the server-side web_search call and writes the cited summary.
16
- // Prefer the newest mid-tier ("terra") model, then the newest bare mainline id; price
16
+ // Prefer the newest mid-tier ("terra") model, then the newest bare mainline id, then any
17
+ // other versioned GPT id (gateway providers such as opencode-go list only suffixed GPT
18
+ // ids next to non-OpenAI models; unversioned ids such as gpt-oss-* are skipped); price
17
19
  // tiers ("pro"/"ultra" id segments) are excluded, and the numeric-aware sort keeps
18
20
  // e.g. gpt-5.10 ahead of gpt-5.9.
19
21
  const EXCLUDED_MODEL_SEGMENTS = new Set(["pro", "ultra"]);
20
22
  const MODEL_PREFERENCE = [
21
23
  (id: string) => id.includes("terra"),
22
24
  (id: string) => /^gpt-\d+(\.\d+)?$/.test(id),
25
+ (id: string) => /^gpt-\d/.test(id),
23
26
  ];
24
27
  const DEFAULT_SEARCH_PROVIDERS: readonly string[] = ["openai-codex", "openai"];
25
28
 
@@ -182,6 +185,9 @@ function resolveConfiguredResponsesUrl(value: unknown): string {
182
185
  if (url.protocol !== "https:" && url.protocol !== "http:") {
183
186
  throw new Error(`openaiResponsesUrl in ${CONFIG_PATH} must use http or https`);
184
187
  }
188
+ if (url.hostname === "opencode.ai" && url.protocol !== "https:") {
189
+ throw new Error(`openaiResponsesUrl in ${CONFIG_PATH} must use HTTPS for opencode.ai`);
190
+ }
185
191
  return url.toString();
186
192
  }
187
193
 
@@ -204,6 +210,9 @@ function resolveProviderResponsesUrl(baseUrl: unknown, useCodexEndpoint: boolean
204
210
  } catch {
205
211
  throw new CustomOpenAIBaseUrlError("OpenAI search configuration: openaiUseProviderBaseUrl requires an absolute http(s) provider baseUrl");
206
212
  }
213
+ if (url.hostname === "opencode.ai" && url.protocol !== "https:") {
214
+ throw new CustomOpenAIBaseUrlError("OpenAI search configuration: opencode.ai provider baseUrl must use HTTPS");
215
+ }
207
216
  const path = url.pathname.replace(/\/+$/u, "");
208
217
  if (path.endsWith("/responses")) {
209
218
  url.pathname = path;
@@ -239,6 +248,19 @@ function toRequestHeaders(headers: ProviderHeaders): Record<string, string> {
239
248
  return requestHeaders;
240
249
  }
241
250
 
251
+ function applyOpenCodeDestinationHeaders(headers: HeadersInit, requestUrl: string, ctx?: Pick<ExtensionContext, "sessionManager">): Headers {
252
+ const requestHeaders = new Headers(headers);
253
+ requestHeaders.delete("x-opencode-session");
254
+ requestHeaders.delete("x-opencode-client");
255
+ const url = new URL(requestUrl);
256
+ const sessionId = ctx?.sessionManager?.getSessionId?.();
257
+ if (url.protocol === "https:" && url.hostname === "opencode.ai" && sessionId) {
258
+ requestHeaders.set("x-opencode-session", sessionId);
259
+ requestHeaders.set("x-opencode-client", "pi");
260
+ }
261
+ return requestHeaders;
262
+ }
263
+
242
264
  async function resolvePiAuth(ctx: ExtensionContext, responsesUrl: string, providers: readonly string[], modelOverride?: string, hasExplicitResponsesUrl = false, useProviderBaseUrl = false): Promise<OpenAIAuth | undefined> {
243
265
  let models: ReturnType<typeof ctx.modelRegistry.getAll>;
244
266
  let invalidProviderUrlError: CustomOpenAIBaseUrlError | undefined;
@@ -643,6 +665,7 @@ async function runOpenAISearch(
643
665
  query: string,
644
666
  options: SearchOptions,
645
667
  auth: OpenAIAuth,
668
+ ctx?: Pick<ExtensionContext, "sessionManager">,
646
669
  ): Promise<SearchResponse> {
647
670
  const useAlphaSearch = isAlphaSearchEnabled();
648
671
  if (useAlphaSearch) options.signal?.throwIfAborted();
@@ -672,16 +695,21 @@ async function runOpenAISearch(
672
695
  if (accountId) headers["chatgpt-account-id"] = accountId;
673
696
  headers.originator = "pi";
674
697
  }
698
+ const requestHeaders = applyOpenCodeDestinationHeaders(
699
+ useAlphaSearch ? buildAlphaSearchHeaders(headers, auth.apiKey) : headers,
700
+ requestUrl,
701
+ ctx,
702
+ );
675
703
 
676
704
  try {
677
- const response = await fetch(requestUrl, {
705
+ const response = await fetchWithCredentialRedirects(requestUrl, {
678
706
  method: "POST",
679
- headers: useAlphaSearch ? buildAlphaSearchHeaders(headers, auth.apiKey) : headers,
707
+ headers: requestHeaders,
680
708
  body: JSON.stringify(body),
681
709
  signal: options.signal
682
710
  ? AbortSignal.any([AbortSignal.timeout(SEARCH_TIMEOUT_MS), options.signal])
683
711
  : AbortSignal.timeout(SEARCH_TIMEOUT_MS),
684
- });
712
+ }, ["Authorization", "x-opencode-session", "x-opencode-client", "chatgpt-account-id", ...Object.keys(auth.headers)]);
685
713
 
686
714
  if (!response.ok) {
687
715
  activityMonitor.logError(activityId, `HTTP ${response.status}`);
@@ -738,7 +766,7 @@ export async function searchWithOpenAI(
738
766
  " 3. Set OPENAI_API_KEY environment variable",
739
767
  );
740
768
  }
741
- return runOpenAISearch(query, options, auth);
769
+ return runOpenAISearch(query, options, auth, ctx);
742
770
  }
743
771
 
744
772
  export async function searchWithCurrentModelOpenAI(
@@ -748,5 +776,5 @@ export async function searchWithCurrentModelOpenAI(
748
776
  ): Promise<SearchResponse> {
749
777
  if (!ctx) throw new Error("OpenAI current-model search requires an extension context");
750
778
  const auth = await resolveCurrentModelAuth(ctx, options.signal);
751
- return runOpenAISearch(query, options, auth);
779
+ return runOpenAISearch(query, options, auth, ctx);
752
780
  }
@@ -1,10 +1,10 @@
1
1
  {
2
2
  "name": "pi-web-access",
3
- "version": "0.31.0",
4
- "description": "Web search, URL fetching, GitHub repo cloning, PDF extraction, YouTube video understanding, and local video analysis for Pi coding agent. Supports OpenAI, Brave, Parallel, TinyFish, Search1API, Searchinfinity, Querit, Tavily, Firecrawl, Crawl4AI, Jina, SERPdive, Kagi, Ollama, AnySearch, Bright Data SERP, SerpBase, SerpApi, Serply, SearXNG, Exa, Perplexity, Gemini, Kimi, and Mistral.",
3
+ "version": "0.34.0",
4
+ "description": "Web search, URL fetching, GitHub repo cloning, PDF extraction, YouTube video understanding, and local video analysis for Pi coding agent. Supports OpenAI, Brave, Parallel, TinyFish, Search1API, Searchinfinity, Querit, Tavily, Firecrawl, Crawl4AI, Jina, SERPdive, Kagi, Ollama, AnySearch, Bright Data SERP, SerpBase, SerpApi, Serply, You.com, SearXNG, Exa, Perplexity, Gemini, Kimi, and Mistral.",
5
5
  "type": "module",
6
6
  "scripts": {
7
- "test": "node --test",
7
+ "test": "node --import ./test/isolate-env.mjs --test",
8
8
  "typecheck": "tsc",
9
9
  "audit:runtime": "npm audit --omit=dev --omit=peer",
10
10
  "build": "node scripts/build.js",
@@ -50,6 +50,7 @@
50
50
  "@earendil-works/pi-tui": "0.86.1",
51
51
  "@types/turndown": "^5.0.6",
52
52
  "esbuild": "^0.28.1",
53
+ "typebox": "1.3.27",
53
54
  "typescript": "^7.0.2"
54
55
  },
55
56
  "dependencies": {
@@ -60,8 +61,8 @@
60
61
  "p-limit": "^6.1.0",
61
62
  "promise.try": "^2.0.1",
62
63
  "turndown": "^7.2.0",
63
- "unpdf": "^1.6.2",
64
- "undici": "^8.9.0"
64
+ "undici": "^8.11.2",
65
+ "unpdf": "^1.6.2"
65
66
  },
66
67
  "peerDependencies": {
67
68
  "@earendil-works/pi-ai": "*",
@@ -1,7 +1,7 @@
1
1
  import type { complete, Api, Message, Model } from "@earendil-works/pi-ai/compat";
2
2
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
3
3
  import { existsSync, readFileSync } from "node:fs";
4
- import { findModelWithProviderRouting, loadEnabledModelPatterns, modelMatchesEnabledPatterns } from "./summary-model-scope.ts";
4
+ import { findModelWithProviderRouting, isModelInScope } from "./summary-model-scope.ts";
5
5
  import { getWebSearchConfigPath } from "./utils.ts";
6
6
  import { awaitWithAbort } from "./abortable.ts";
7
7
  import { openCodeSessionHeaders } from "./opencode-session-headers.ts";
@@ -84,7 +84,7 @@ function resolveModel(ctx: ExtensionContext, override?: string, configured?: Ans
84
84
  throw new Error("No current model available for page answering");
85
85
  }
86
86
  if (!model.input.includes("text")) throw new Error(`Answer model does not support text input: ${model.provider}/${model.id}`);
87
- if (!modelMatchesEnabledPatterns(model, loadEnabledModelPatterns(ctx))) {
87
+ if (!isModelInScope(model, ctx.scopedModels)) {
88
88
  throw new Error(`Answer model is not enabled: ${model.provider}/${model.id}`);
89
89
  }
90
90
  return model;
@@ -1,16 +1,15 @@
1
1
  import type { complete, Api, Model, ProviderHeaders } from "@earendil-works/pi-ai/compat";
2
2
  import type { SummaryGenerationContext } from "./summary-review.ts";
3
3
  import { awaitWithAbort } from "./abortable.ts";
4
- import { findModelWithProviderRouting, loadEnabledModelPatterns, modelMatchesEnabledPatterns } from "./summary-model-scope.ts";
4
+ import { findModelWithProviderRouting, isModelInScope } from "./summary-model-scope.ts";
5
5
 
6
6
  async function resolveFirstAvailableModel(
7
7
  ctx: SummaryGenerationContext,
8
8
  candidates: Array<{ provider: string; id: string }>,
9
9
  ): Promise<{ model: Model<Api>; apiKey: string; headers?: ProviderHeaders }> {
10
- const enabledModelPatterns = loadEnabledModelPatterns(ctx);
11
10
  for (const { provider, id } of candidates) {
12
11
  const model = findModelWithProviderRouting(ctx.modelRegistry, provider, id);
13
- if (!model || !modelMatchesEnabledPatterns(model, enabledModelPatterns)) continue;
12
+ if (!model || !isModelInScope(model, ctx.scopedModels)) continue;
14
13
  const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
15
14
  if (auth.ok && auth.apiKey) return { model, apiKey: auth.apiKey, headers: auth.headers };
16
15
  }
@@ -1,11 +1,13 @@
1
1
  import { lookup as dnsLookup } from "node:dns/promises";
2
2
  import { existsSync, readFileSync, statSync } from "node:fs";
3
3
  import net from "node:net";
4
- import { getActiveProxy, getWebSearchConfigPath, hasScopedProxyDecision, isProxyBypassedUrl } from "./utils.ts";
4
+ import { getActiveProxy, getWebSearchConfigPath, hasScopedProxyDecision, isProxyBypassedUrl, loadConfiguredProxy, normalizeProxyUrl } from "./utils.ts";
5
5
 
6
6
  const DEFAULT_MAX_REDIRECTS = 5;
7
7
  const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
8
8
  const LOOPBACK_ALLOW_RANGES = ["127.0.0.0/8", "::1", "::ffff:127.0.0.0/104"];
9
+ // Proxy schemes where the proxy, not curl, resolves the target hostname.
10
+ const REMOTE_DNS_PROXY_PROTOCOLS = new Set(["http:", "https:", "socks4a:", "socks5h:"]);
9
11
 
10
12
  export type LookupAddress = { address: string; family: number };
11
13
  export type Lookup = (hostname: string) => Promise<LookupAddress[]>;
@@ -147,12 +149,15 @@ interface ValidationOptions {
147
149
  */
148
150
  allowRanges?: string[];
149
151
  /**
150
- * When true, trust an explicitly-configured HTTP(S) proxy for hostname
151
- * resolution instead of performing local DNS lookups inside the sandbox.
152
- * Literal IPs and localhost remain blocked, and NO_PROXY hosts still use
153
- * the local SSRF preflight. This does not configure proxy transport.
152
+ * When true, let the proxy carrying the request resolve hostnames instead of
153
+ * performing local DNS lookups: an HTTP(S) proxy from the environment, or the
154
+ * `proxy` configured in web-search.json. A different per-call proxy never
155
+ * qualifies. Literal IPs and localhost remain blocked, and NO_PROXY hosts still
156
+ * use the local SSRF preflight. This does not configure proxy transport.
154
157
  */
155
158
  trustEnvProxy?: boolean;
159
+ /** Proxy pinned on the request via `__proxy`; defaults to the scoped proxy, like the transport. */
160
+ proxy?: string;
156
161
  /** Allow loopback URLs for explicit provider base endpoints, not fetched targets. */
157
162
  allowLoopback?: boolean;
158
163
  }
@@ -207,14 +212,21 @@ export async function validateRemoteUrl(rawUrl: string | URL, options: Validatio
207
212
  return url;
208
213
  }
209
214
 
210
- if (shouldTrustEnvProxy(url, options.trustEnvProxy === true)) return url;
215
+ const trustProxy = options.trustEnvProxy === true;
216
+ const proxy = options.proxy !== undefined
217
+ ? normalizeProxyUrl(options.proxy, "proxy")
218
+ : hasScopedProxyDecision() ? getActiveProxy() : undefined;
219
+ if (proxy === undefined ? shouldTrustEnvProxy(url, trustProxy) : trustProxy && isConfiguredRemoteDnsProxy(url, proxy)) return url;
211
220
 
212
221
  let addresses: LookupAddress[];
213
222
  try {
214
223
  addresses = await (options.lookup ?? defaultLookup)(hostname);
215
224
  } catch (err) {
216
225
  const message = err instanceof Error ? err.message : String(err);
217
- throw new Error(`Failed to resolve ${hostname}: ${message}`);
226
+ const hint = !trustProxy && proxy && isConfiguredRemoteDnsProxy(url, proxy)
227
+ ? ". If your configured proxy resolves hostnames, set ssrf.trustEnvProxy to true in web-search.json."
228
+ : "";
229
+ throw new Error(`Failed to resolve ${hostname}: ${message}${hint}`);
218
230
  }
219
231
 
220
232
  if (addresses.length === 0) throw new Error(`Failed to resolve ${hostname}: no addresses returned`);
@@ -333,15 +345,20 @@ function hostnameMatchesNoProxy(hostname: string, port: string, entry: string):
333
345
 
334
346
  function shouldTrustEnvProxy(url: URL, enabled: boolean): boolean {
335
347
  if (!enabled || !getProxyForProtocol(url.protocol)) return false;
336
- if (hasScopedProxyDecision()) return false;
337
- const activeProxy = getActiveProxy();
338
- if (activeProxy && !isProxyBypassedUrl(url)) return false;
339
348
  const hostname = normalizeHostname(url.hostname);
340
349
  const port = url.port || (url.protocol === "https:" ? "443" : "80");
341
350
  const noProxy = process.env.NO_PROXY || process.env.no_proxy || "";
342
351
  return !noProxy.split(",").some(entry => hostnameMatchesNoProxy(hostname, port, entry));
343
352
  }
344
353
 
354
+ /** Only the operator's configured proxy qualifies: a model-supplied per-call proxy must not skip DNS validation. */
355
+ function isConfiguredRemoteDnsProxy(url: URL, proxy: string | null): boolean {
356
+ return proxy !== null &&
357
+ REMOTE_DNS_PROXY_PROTOCOLS.has(new URL(proxy).protocol) &&
358
+ !isProxyBypassedUrl(url) &&
359
+ proxy === loadConfiguredProxy();
360
+ }
361
+
345
362
  function assertPublicAddress(address: string, hostname: string, allowRanges: ParsedCidr[] = []): void {
346
363
  const normalized = normalizeHostname(address);
347
364
  const ipVersion = net.isIP(normalized);
@@ -74,7 +74,9 @@ export function generateId(): string {
74
74
  }
75
75
 
76
76
  export function getFetchCacheDir(): string {
77
- return join(getWebSearchConfigDir(), FETCH_CACHE_DIR);
77
+ // Always append our own folder: pruning deletes stale *.json files, so the override must
78
+ // never point pruning at a directory that holds user files.
79
+ return join(process.env.PI_WEB_ACCESS_CACHE_ROOT || getWebSearchConfigDir(), FETCH_CACHE_DIR);
78
80
  }
79
81
 
80
82
  function fetchCachePath(key: string): string | null {
@@ -151,17 +153,9 @@ function cacheLimits(limits?: Partial<FetchCacheLimits>): FetchCacheLimits {
151
153
  return resolved;
152
154
  }
153
155
 
154
- function enforceDirectoryMode(fd: number): void {
156
+ function enforceMode(fd: number, mode: number): void {
155
157
  try {
156
- fchmodSync(fd, 0o700);
157
- } catch (err) {
158
- if (process.platform !== "win32") throw err;
159
- }
160
- }
161
-
162
- function enforceFileMode(fd: number): void {
163
- try {
164
- fchmodSync(fd, 0o600);
158
+ fchmodSync(fd, mode);
165
159
  } catch (err) {
166
160
  if (process.platform !== "win32") throw err;
167
161
  }
@@ -182,31 +176,23 @@ function safeFetchCacheDir(create: boolean): string | null {
182
176
  if (before.isSymbolicLink() || !before.isDirectory()) {
183
177
  throw new Error("Fetched content cache path is not a safe directory");
184
178
  }
185
- if (process.platform === "win32") {
186
- const after = lstatSync(dir);
187
- if (after.isSymbolicLink() || !after.isDirectory() || after.dev !== before.dev || after.ino !== before.ino) {
188
- throw new Error("Fetched content cache directory changed while securing it");
179
+ if (process.platform !== "win32") {
180
+ const fd = openSync(dir, constants.O_RDONLY | O_DIRECTORY | O_NOFOLLOW);
181
+ try {
182
+ const opened = fstatSync(fd);
183
+ if (!opened.isDirectory() || opened.dev !== before.dev || opened.ino !== before.ino) {
184
+ throw new Error("Fetched content cache directory changed while opening");
185
+ }
186
+ enforceMode(fd, 0o700);
187
+ } finally {
188
+ closeSync(fd);
189
189
  }
190
- return dir;
191
190
  }
192
- let fd: number | null = null;
193
- try {
194
- fd = openSync(dir, constants.O_RDONLY | O_DIRECTORY | O_NOFOLLOW);
195
- const opened = fstatSync(fd);
196
- if (!opened.isDirectory() || opened.dev !== before.dev || opened.ino !== before.ino) {
197
- throw new Error("Fetched content cache directory changed while opening");
198
- }
199
- enforceDirectoryMode(fd);
200
- closeSync(fd);
201
- fd = null;
202
- const after = lstatSync(dir);
203
- if (after.isSymbolicLink() || !after.isDirectory() || after.dev !== before.dev || after.ino !== before.ino) {
204
- throw new Error("Fetched content cache directory changed while securing it");
205
- }
206
- return dir;
207
- } finally {
208
- if (fd !== null) try { closeSync(fd); } catch {}
191
+ const after = lstatSync(dir);
192
+ if (after.isSymbolicLink() || !after.isDirectory() || after.dev !== before.dev || after.ino !== before.ino) {
193
+ throw new Error("Fetched content cache directory changed while securing it");
209
194
  }
195
+ return dir;
210
196
  }
211
197
 
212
198
  function openRegularFile(path: string): { fd: number; info: Stats } {
@@ -225,28 +211,22 @@ function openRegularFile(path: string): { fd: number; info: Stats } {
225
211
  }
226
212
  }
227
213
 
228
- type CacheUnlinkResult = "removed" | "missing" | "changed" | "error";
229
-
230
- function unlinkCacheFile(dir: string, file: CacheFile): CacheUnlinkResult {
214
+ // True once the entry is gone; false when the directory or entry changed underneath us or could not be removed.
215
+ function unlinkCacheFile(dir: string, file: CacheFile): boolean {
231
216
  try {
232
217
  const root = lstatSync(dir);
233
- if (root.isSymbolicLink() || !root.isDirectory()) return "changed";
234
- const path = join(dir, file.name);
235
- let current: Stats;
236
- try {
237
- current = lstatSync(path);
238
- } catch (err) {
239
- return (err as NodeJS.ErrnoException).code === "ENOENT" ? "missing" : "error";
240
- }
241
- if (current.isSymbolicLink() || !current.isFile() || current.dev !== file.dev || current.ino !== file.ino) return "changed";
242
- try {
243
- unlinkSync(path);
244
- return "removed";
245
- } catch (err) {
246
- return (err as NodeJS.ErrnoException).code === "ENOENT" ? "missing" : "error";
247
- }
218
+ if (root.isSymbolicLink() || !root.isDirectory()) return false;
248
219
  } catch {
249
- return "error";
220
+ return false;
221
+ }
222
+ try {
223
+ const path = join(dir, file.name);
224
+ const current = lstatSync(path);
225
+ if (current.isSymbolicLink() || !current.isFile() || current.dev !== file.dev || current.ino !== file.ino) return false;
226
+ unlinkSync(path);
227
+ return true;
228
+ } catch (err) {
229
+ return (err as NodeJS.ErrnoException).code === "ENOENT";
250
230
  }
251
231
  }
252
232
 
@@ -269,24 +249,21 @@ function pruneFetchCache(now: number, limits: FetchCacheLimits, preferredKey?: s
269
249
  const files: CacheFile[] = [];
270
250
  for (const entry of entries) {
271
251
  if (!CACHE_KEY_PATTERN.test(entry) && !CACHE_TMP_PATTERN.test(entry)) continue;
272
- const path = join(dir, entry);
273
- let opened: ReturnType<typeof openRegularFile>;
274
- try {
275
- opened = openRegularFile(path);
276
- } catch {
277
- continue;
278
- }
252
+ let info: Stats;
279
253
  try {
280
- enforceFileMode(opened.fd);
254
+ const opened = openRegularFile(join(dir, entry));
255
+ info = opened.info;
256
+ try {
257
+ enforceMode(opened.fd, 0o600);
258
+ } finally {
259
+ closeSync(opened.fd);
260
+ }
281
261
  } catch {
282
- closeSync(opened.fd);
283
262
  continue;
284
263
  }
285
- closeSync(opened.fd);
286
- const file = { name: entry, size: opened.info.size, mtimeMs: opened.info.mtimeMs, dev: opened.info.dev, ino: opened.info.ino };
264
+ const file = { name: entry, size: info.size, mtimeMs: info.mtimeMs, dev: info.dev, ino: info.ino };
287
265
  if (now - file.mtimeMs >= CACHE_TTL_MS) {
288
- const removed = unlinkCacheFile(dir, file);
289
- if (removed !== "removed" && removed !== "missing") return false;
266
+ if (!unlinkCacheFile(dir, file)) return false;
290
267
  continue;
291
268
  }
292
269
  if (CACHE_KEY_PATTERN.test(entry)) files.push(file);
@@ -307,8 +284,7 @@ function pruneFetchCache(now: number, limits: FetchCacheLimits, preferredKey?: s
307
284
  if (index < 0) break;
308
285
  const file = files[index];
309
286
  attempted.add(file.name);
310
- const removed = unlinkCacheFile(dir, file);
311
- if (removed === "removed" || removed === "missing") files.splice(index, 1);
287
+ if (unlinkCacheFile(dir, file)) files.splice(index, 1);
312
288
  usage = projectedUsage();
313
289
  }
314
290
  return usage.entries <= limits.maxEntries && usage.bytes <= limits.maxBytes;
@@ -335,7 +311,7 @@ function writeFetchCache(data: StoredSearchData & { urls: ExtractedContent[] }):
335
311
  fd = openSync(tmpPath, constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY | O_NOFOLLOW, 0o600);
336
312
  const tmpInfo = fstatSync(fd);
337
313
  tmpFile = { name: tmpName, size: tmpInfo.size, mtimeMs: tmpInfo.mtimeMs, dev: tmpInfo.dev, ino: tmpInfo.ino };
338
- enforceFileMode(fd);
314
+ enforceMode(fd, 0o600);
339
315
  writeFileSync(fd, serialized, "utf8");
340
316
  fsyncSync(fd);
341
317
  closeSync(fd);
@@ -358,11 +334,6 @@ function writeFetchCache(data: StoredSearchData & { urls: ExtractedContent[] }):
358
334
  return { version: FETCH_CACHE_VERSION, key, storedAt: Date.now() };
359
335
  }
360
336
 
361
- function cacheWriteError(err: unknown): string {
362
- const message = err instanceof Error ? err.message : String(err);
363
- return `Failed to write fetched content cache: ${message}`;
364
- }
365
-
366
337
  function createFetchSessionData(data: StoredSearchData & { urls: ExtractedContent[] }, ref: FetchCacheRef | null, cacheError?: string): StoredSearchData {
367
338
  return {
368
339
  id: data.id,
@@ -394,9 +365,9 @@ function unavailableFetchData(data: StoredSearchData, reason: string): StoredSea
394
365
  };
395
366
  }
396
367
 
397
- function readCachedFetchData(data: StoredSearchData, now = Date.now()): StoredSearchData {
368
+ function readCachedFetchData(data: StoredSearchData): StoredSearchData {
398
369
  if (data.type !== "fetch") return data;
399
- if (now - data.timestamp >= CACHE_TTL_MS) {
370
+ if (Date.now() - data.timestamp >= CACHE_TTL_MS) {
400
371
  return unavailableFetchData(data, "Cached fetched content is missing or expired");
401
372
  }
402
373
  if (isInlineFetchData(data)) return data;
@@ -410,7 +381,7 @@ function readCachedFetchData(data: StoredSearchData, now = Date.now()): StoredSe
410
381
  if (!safeFetchCacheDir(false)) return unavailableFetchData(data, "Cached fetched content is missing or expired");
411
382
  const opened = openRegularFile(path);
412
383
  fd = opened.fd;
413
- enforceFileMode(fd);
384
+ enforceMode(fd, 0o600);
414
385
  const parsed: unknown = JSON.parse(readFileSync(fd, "utf8"));
415
386
  if (!isValidStoredData(parsed) || parsed.type !== "fetch" || parsed.id !== data.id || !isInlineFetchData(parsed)) {
416
387
  return unavailableFetchData(data, "Cached fetched content is invalid");
@@ -452,7 +423,7 @@ export function storeFetchedContentResult(id: string, data: StoredSearchData & {
452
423
  try {
453
424
  ref = writeFetchCache(data);
454
425
  } catch (err) {
455
- cacheError = cacheWriteError(err);
426
+ cacheError = `Failed to write fetched content cache: ${err instanceof Error ? err.message : String(err)}`;
456
427
  }
457
428
  storedResults.set(id, ref ? { ...data, fetchCache: ref, urlMetadata: metadataForUrls(data.urls) } : { ...data, fetchCacheError: cacheError });
458
429
  return createFetchSessionData(data, ref, cacheError);
@@ -1,16 +1,7 @@
1
- import { existsSync, readFileSync } from "node:fs";
2
- import { homedir } from "node:os";
3
- import { join } from "node:path";
4
-
5
1
  const THINKING_LEVELS = new Set(["off", "minimal", "low", "medium", "high", "xhigh", "max"]);
6
2
 
7
3
  export type SummaryThinkingLevel = "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max";
8
4
 
9
- interface SummaryModelScopeContext {
10
- cwd: string;
11
- isProjectTrusted(): boolean;
12
- }
13
-
14
5
  export interface ModelLike {
15
6
  provider: string;
16
7
  id: string;
@@ -45,39 +36,12 @@ export function findModelWithProviderRouting<T extends ModelLike>(
45
36
  return routed ?? registry.find(provider, id);
46
37
  }
47
38
 
48
- function getAgentDir(): string {
49
- return process.env.PI_CODING_AGENT_DIR || join(homedir(), ".pi", "agent");
50
- }
51
-
52
- function readSettings(path: string): Record<string, unknown> {
53
- if (!existsSync(path)) return {};
54
- const raw = readFileSync(path, "utf8");
55
- try {
56
- return JSON.parse(raw) as Record<string, unknown>;
57
- } catch (err) {
58
- const message = err instanceof Error ? err.message : String(err);
59
- throw new Error(`Failed to parse ${path}: ${message}`);
60
- }
61
- }
62
-
63
- export function loadEnabledModelPatterns(ctx: SummaryModelScopeContext): string[] | null {
64
- const globalSettings = readSettings(join(getAgentDir(), "settings.json"));
65
- const projectSettings = ctx.isProjectTrusted()
66
- ? readSettings(join(ctx.cwd, ".pi", "settings.json"))
67
- : {};
68
- const value = Object.hasOwn(projectSettings, "enabledModels")
69
- ? projectSettings.enabledModels
70
- : globalSettings.enabledModels;
71
- if (value === undefined) return null;
72
- if (!Array.isArray(value)) throw new Error("enabledModels must be an array");
73
- return value
74
- .filter((item): item is string => typeof item === "string")
75
- .map(item => item.trim())
76
- .filter(Boolean);
77
- }
78
-
79
- export function summaryModelValue(model: ModelLike): string {
80
- return `${model.provider}/${model.id}`;
39
+ /**
40
+ * Pi resolves `--models` and `enabledModels` against the available catalogue and exposes the
41
+ * result as `ctx.scopedModels`; an empty scope means every available model is allowed.
42
+ */
43
+ export function isModelInScope(model: ModelLike, scopedModels: ReadonlyArray<{ model: ModelLike }>): boolean {
44
+ return scopedModels.length === 0 || scopedModels.some(scoped => scoped.model.provider === model.provider && scoped.model.id === model.id);
81
45
  }
82
46
 
83
47
  export function splitThinkingSuffix(value: string): { value: string; thinkingLevel?: SummaryThinkingLevel } {
@@ -88,38 +52,3 @@ export function splitThinkingSuffix(value: string): { value: string; thinkingLev
88
52
  ? { value: value.slice(0, index), thinkingLevel: suffix as SummaryThinkingLevel }
89
53
  : { value };
90
54
  }
91
-
92
- function stripThinkingSuffix(pattern: string): string {
93
- return splitThinkingSuffix(pattern).value;
94
- }
95
-
96
- function globToRegExp(pattern: string): RegExp {
97
- let source = "^";
98
- for (const char of pattern) {
99
- if (char === "*") {
100
- source += ".*";
101
- } else if (char === "?") {
102
- source += ".";
103
- } else {
104
- source += char.replace(/[|\\{}()[\]^$+?.]/g, "\\$&");
105
- }
106
- }
107
- return new RegExp(`${source}$`, "i");
108
- }
109
-
110
- export function modelMatchesEnabledPatterns(model: ModelLike, patterns: string[] | null): boolean {
111
- if (patterns === null) return true;
112
- const value = summaryModelValue(model).toLowerCase();
113
- const id = model.id.toLowerCase();
114
- for (const rawPattern of patterns) {
115
- const pattern = stripThinkingSuffix(rawPattern.trim()).toLowerCase();
116
- if (!pattern) continue;
117
- if (pattern.includes("*") || pattern.includes("?")) {
118
- const regex = globToRegExp(pattern);
119
- if (regex.test(value) || regex.test(id)) return true;
120
- continue;
121
- }
122
- if (pattern === value || pattern === id) return true;
123
- }
124
- return false;
125
- }
@@ -2,7 +2,7 @@ import type { ModelThinkingLevel, ThinkingLevel } from "@earendil-works/pi-ai";
2
2
  import type { complete, Api, Message, Model } from "@earendil-works/pi-ai/compat";
3
3
  import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
4
4
  import { openCodeSessionHeaders } from "./opencode-session-headers.ts";
5
- import { findModelWithProviderRouting, loadEnabledModelPatterns, modelMatchesEnabledPatterns, splitThinkingSuffix, type SummaryThinkingLevel } from "./summary-model-scope.ts";
5
+ import { findModelWithProviderRouting, isModelInScope, splitThinkingSuffix, type SummaryThinkingLevel } from "./summary-model-scope.ts";
6
6
  import type { QueryResultData } from "./storage.ts";
7
7
 
8
8
  type ProviderHeaders = Record<string, string | null>;
@@ -30,7 +30,7 @@ export interface SummaryMeta {
30
30
  edited?: boolean;
31
31
  }
32
32
 
33
- export type SummaryGenerationContext = Pick<ExtensionContext, "model" | "modelRegistry" | "sessionManager" | "cwd" | "isProjectTrusted">;
33
+ export type SummaryGenerationContext = Pick<ExtensionContext, "model" | "modelRegistry" | "sessionManager" | "scopedModels">;
34
34
 
35
35
  function estimateTokens(text: string): number {
36
36
  const trimmed = text.trim();
@@ -215,7 +215,6 @@ async function resolveSummaryModelCandidates(
215
215
  ctx: SummaryGenerationContext,
216
216
  modelOverride?: string,
217
217
  ): Promise<{ candidates: Array<{ model: Model<Api>; apiKey?: string; headers?: ProviderHeaders; thinkingLevel?: SummaryThinkingLevel }>; errors: string[] }> {
218
- const enabledModelPatterns = loadEnabledModelPatterns(ctx);
219
218
  const specs: Array<{ provider: string; id: string; thinkingLevel?: SummaryThinkingLevel }> = [];
220
219
  const normalizedOverride = typeof modelOverride === "string" ? modelOverride.trim() : "";
221
220
  if (normalizedOverride.length > 0) specs.push(parseModelSelector(normalizedOverride));
@@ -234,7 +233,7 @@ async function resolveSummaryModelCandidates(
234
233
  errors.push(`Summary model not found: ${value}`);
235
234
  continue;
236
235
  }
237
- if (!modelMatchesEnabledPatterns(model, enabledModelPatterns)) {
236
+ if (!isModelInScope(model, ctx.scopedModels)) {
238
237
  errors.push(`Summary model is not enabled: ${value}`);
239
238
  continue;
240
239
  }