@herbertgao/pi-extensions 2026.8.14 → 2026.8.15

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 (231) hide show
  1. package/README.md +16 -13
  2. package/THIRD_PARTY_NOTICES.md +0 -1
  3. package/node_modules/@czottmann/pi-automode/CHANGELOG.md +8 -1
  4. package/node_modules/@czottmann/pi-automode/README.md +4 -0
  5. package/node_modules/@czottmann/pi-automode/extensions/auto-mode/classifier.ts +71 -9
  6. package/node_modules/@czottmann/pi-automode/package.json +1 -1
  7. package/node_modules/@herbertgao/pi-bark/LICENSE +21 -0
  8. package/node_modules/@herbertgao/pi-bark/README.md +44 -0
  9. package/node_modules/@herbertgao/pi-bark/assets/pi-icon.png +0 -0
  10. package/node_modules/@herbertgao/pi-bark/package.json +53 -0
  11. package/node_modules/@herbertgao/pi-bark/src/index.ts +178 -0
  12. package/node_modules/@juicesharp/rpiv-ask-user-question/package.json +2 -2
  13. package/node_modules/@narumitw/pi-btw/README.md +125 -88
  14. package/node_modules/@narumitw/pi-btw/dist/index.ts +140 -45
  15. package/node_modules/@narumitw/pi-btw/dist/index.ts.map +2 -2
  16. package/node_modules/@narumitw/pi-btw/package.json +5 -8
  17. package/node_modules/@narumitw/pi-btw/src/btw.ts +31 -26
  18. package/node_modules/@narumitw/pi-btw/src/fullscreen-ui.ts +106 -17
  19. package/node_modules/@narumitw/pi-btw/src/menu.ts +34 -2
  20. package/node_modules/@narumitw/pi-btw/src/settings.ts +16 -0
  21. package/node_modules/@tifan/pi-handoff/README.md +36 -13
  22. package/node_modules/@tifan/pi-handoff/package.json +2 -2
  23. package/node_modules/@tifan/pi-handoff/src/index.ts +160 -4
  24. package/node_modules/@tifan/pi-recap/README.md +1 -1
  25. package/node_modules/@tifan/pi-recap/package.json +2 -2
  26. package/node_modules/@tifan/pi-recap/src/index.ts +15 -0
  27. package/node_modules/@tifan/pi-rename/README.md +42 -21
  28. package/node_modules/@tifan/pi-rename/package.json +1 -1
  29. package/node_modules/@tifan/pi-rename/src/index.ts +62 -11
  30. package/node_modules/@tifan/pi-rename/src/language.ts +18 -0
  31. package/node_modules/@tifan/pi-rename/src/models.ts +69 -13
  32. package/node_modules/@tifan/pi-rename/src/naming.ts +36 -7
  33. package/node_modules/@tifan/pi-rename/src/sanitize.ts +22 -14
  34. package/node_modules/pi-lens/CHANGELOG.md +320 -0
  35. package/node_modules/pi-lens/config/dependency-cruiser-eager-allowlist.json +79 -0
  36. package/node_modules/pi-lens/dist/clients/actionable-warnings-logger.js +16 -1
  37. package/node_modules/pi-lens/dist/clients/ast-grep-client.js +13 -6
  38. package/node_modules/pi-lens/dist/clients/availability-probe-flight.js +12 -0
  39. package/node_modules/pi-lens/dist/clients/biome-client.js +7 -0
  40. package/node_modules/pi-lens/dist/clients/blocker-freshness.js +20 -0
  41. package/node_modules/pi-lens/dist/clients/bounded-telemetry.js +56 -19
  42. package/node_modules/pi-lens/dist/clients/build-identity.js +95 -0
  43. package/node_modules/pi-lens/dist/clients/bus-publish.js +3 -0
  44. package/node_modules/pi-lens/dist/clients/cache/rule-cache.js +2 -1
  45. package/node_modules/pi-lens/dist/clients/cache-observability.js +2 -1
  46. package/node_modules/pi-lens/dist/clients/cascade-logger.js +15 -1
  47. package/node_modules/pi-lens/dist/clients/dead-code-client.js +14 -11
  48. package/node_modules/pi-lens/dist/clients/degradation-ledger.js +56 -4
  49. package/node_modules/pi-lens/dist/clients/demoted-finding-render.js +14 -0
  50. package/node_modules/pi-lens/dist/clients/dependency-checker.js +20 -3
  51. package/node_modules/pi-lens/dist/clients/deps/minimatch.js +1 -1
  52. package/node_modules/pi-lens/dist/clients/dispatch/collect-later-tier.js +39 -0
  53. package/node_modules/pi-lens/dist/clients/dispatch/dispatcher.js +150 -9
  54. package/node_modules/pi-lens/dist/clients/dispatch/fact-store.js +231 -9
  55. package/node_modules/pi-lens/dist/clients/dispatch/integration.js +205 -100
  56. package/node_modules/pi-lens/dist/clients/dispatch/pending-runner-findings.js +87 -0
  57. package/node_modules/pi-lens/dist/clients/dispatch/plan.js +21 -5
  58. package/node_modules/pi-lens/dist/clients/dispatch/runners/ast-grep-napi.js +174 -55
  59. package/node_modules/pi-lens/dist/clients/dispatch/runners/eslint.js +5 -1
  60. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-lint.js +16 -3
  61. package/node_modules/pi-lens/dist/clients/dispatch/runners/helm-render.js +54 -9
  62. package/node_modules/pi-lens/dist/clients/dispatch/runners/index.js +1 -3
  63. package/node_modules/pi-lens/dist/clients/dispatch/runners/lsp.js +37 -1
  64. package/node_modules/pi-lens/dist/clients/dispatch/runners/markdownlint.js +4 -1
  65. package/node_modules/pi-lens/dist/clients/dispatch/runners/oxlint.js +11 -4
  66. package/node_modules/pi-lens/dist/clients/dispatch/runners/psscriptanalyzer.js +4 -0
  67. package/node_modules/pi-lens/dist/clients/dispatch/runners/rust-clippy.js +8 -3
  68. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/runner-helpers.js +76 -25
  69. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/spawn-outcome.js +4 -0
  70. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/tool-failure.js +19 -7
  71. package/node_modules/pi-lens/dist/clients/dispatch/runners/utils/toolchain-availability.js +7 -13
  72. package/node_modules/pi-lens/dist/clients/dispatch/runners/yaml-rule-parser.js +51 -17
  73. package/node_modules/pi-lens/dist/clients/event-loop-monitor.js +66 -1
  74. package/node_modules/pi-lens/dist/clients/file-utils.js +157 -7
  75. package/node_modules/pi-lens/dist/clients/finding-delivery-gate.js +5 -0
  76. package/node_modules/pi-lens/dist/clients/formatters.js +28 -3
  77. package/node_modules/pi-lens/dist/clients/git-guard.js +197 -47
  78. package/node_modules/pi-lens/dist/clients/git-tracked-ignore.js +32 -1
  79. package/node_modules/pi-lens/dist/clients/govulncheck-client.js +5 -1
  80. package/node_modules/pi-lens/dist/clients/gzip-stage-write.js +7 -0
  81. package/node_modules/pi-lens/dist/clients/installer/index.js +131 -34
  82. package/node_modules/pi-lens/dist/clients/installer/managed-tool-refresh.js +3 -1
  83. package/node_modules/pi-lens/dist/clients/instance-reaper.js +4 -13
  84. package/node_modules/pi-lens/dist/clients/instance-registry-lock.js +145 -0
  85. package/node_modules/pi-lens/dist/clients/instance-registry.js +432 -122
  86. package/node_modules/pi-lens/dist/clients/jscpd-client.js +6 -1
  87. package/node_modules/pi-lens/dist/clients/language-profile.js +4 -2
  88. package/node_modules/pi-lens/dist/clients/latency-logger.js +19 -1
  89. package/node_modules/pi-lens/dist/clients/lens-flag-registry.js +8 -0
  90. package/node_modules/pi-lens/dist/clients/lens-map.js +3 -1
  91. package/node_modules/pi-lens/dist/clients/lsp/cascade-tier.js +4 -0
  92. package/node_modules/pi-lens/dist/clients/lsp/client.js +516 -31
  93. package/node_modules/pi-lens/dist/clients/lsp/config.js +25 -2
  94. package/node_modules/pi-lens/dist/clients/lsp/diagnostic-binding.js +9 -4
  95. package/node_modules/pi-lens/dist/clients/lsp/index.js +509 -182
  96. package/node_modules/pi-lens/dist/clients/lsp/jvm-runtime.js +5 -0
  97. package/node_modules/pi-lens/dist/clients/lsp/launch.js +36 -8
  98. package/node_modules/pi-lens/dist/clients/lsp/pending-aux-coverage.js +116 -4
  99. package/node_modules/pi-lens/dist/clients/lsp/server.js +142 -13
  100. package/node_modules/pi-lens/dist/clients/lsp/session-roots.js +99 -0
  101. package/node_modules/pi-lens/dist/clients/lsp/workspace-diagnostics-cache.js +16 -6
  102. package/node_modules/pi-lens/dist/clients/lsp/workspace-sweep-hold.js +32 -23
  103. package/node_modules/pi-lens/dist/clients/lsp-mutation.js +16 -2
  104. package/node_modules/pi-lens/dist/clients/mcp/analyze.js +12 -6
  105. package/node_modules/pi-lens/dist/clients/mcp/session.js +14 -1
  106. package/node_modules/pi-lens/dist/clients/memory-sampler.js +23 -1
  107. package/node_modules/pi-lens/dist/clients/message-end-attribution.js +30 -0
  108. package/node_modules/pi-lens/dist/clients/metrics-history.js +21 -4
  109. package/node_modules/pi-lens/dist/clients/opaque-mutation-scan.js +232 -18
  110. package/node_modules/pi-lens/dist/clients/package-manager.js +6 -18
  111. package/node_modules/pi-lens/dist/clients/path-keyed-map.js +14 -1
  112. package/node_modules/pi-lens/dist/clients/path-utils.js +32 -0
  113. package/node_modules/pi-lens/dist/clients/pipeline.js +0 -8
  114. package/node_modules/pi-lens/dist/clients/process-singletons.js +210 -0
  115. package/node_modules/pi-lens/dist/clients/project-diagnostics/runner-adapters/madge.js +2 -1
  116. package/node_modules/pi-lens/dist/clients/project-diagnostics/scanner.js +10 -4
  117. package/node_modules/pi-lens/dist/clients/project-snapshot.js +33 -11
  118. package/node_modules/pi-lens/dist/clients/read-guard-logger.js +24 -2
  119. package/node_modules/pi-lens/dist/clients/read-guard.js +74 -7
  120. package/node_modules/pi-lens/dist/clients/recent-touches.js +17 -5
  121. package/node_modules/pi-lens/dist/clients/review-graph/builder.js +639 -62
  122. package/node_modules/pi-lens/dist/clients/review-graph/tsconfig-paths.js +5 -1
  123. package/node_modules/pi-lens/dist/clients/review-graph-logger.js +21 -1
  124. package/node_modules/pi-lens/dist/clients/runtime-coordinator.js +68 -0
  125. package/node_modules/pi-lens/dist/clients/runtime-session.js +30 -12
  126. package/node_modules/pi-lens/dist/clients/runtime-tool-call.js +15 -0
  127. package/node_modules/pi-lens/dist/clients/runtime-tool-result.js +106 -7
  128. package/node_modules/pi-lens/dist/clients/runtime-turn.js +242 -27
  129. package/node_modules/pi-lens/dist/clients/safe-spawn.js +158 -18
  130. package/node_modules/pi-lens/dist/clients/security-scan-client.js +48 -17
  131. package/node_modules/pi-lens/dist/clients/session-lifecycle.js +238 -42
  132. package/node_modules/pi-lens/dist/clients/sg-runner.js +27 -15
  133. package/node_modules/pi-lens/dist/clients/shared-checkout-guard.js +338 -0
  134. package/node_modules/pi-lens/dist/clients/spawn-output-cap.js +37 -0
  135. package/node_modules/pi-lens/dist/clients/spawn-timeout-cooldown.js +15 -1
  136. package/node_modules/pi-lens/dist/clients/startup-scan.js +6 -7
  137. package/node_modules/pi-lens/dist/clients/startup-timing.js +69 -4
  138. package/node_modules/pi-lens/dist/clients/string-utils.js +20 -0
  139. package/node_modules/pi-lens/dist/clients/test-runner-client.js +419 -88
  140. package/node_modules/pi-lens/dist/clients/tool-policy.js +1 -1
  141. package/node_modules/pi-lens/dist/clients/tree-sitter-client.js +11 -5
  142. package/node_modules/pi-lens/dist/clients/tree-sitter-logger.js +17 -1
  143. package/node_modules/pi-lens/dist/clients/warm-attach.js +8 -10
  144. package/node_modules/pi-lens/dist/clients/word-index-logger.js +13 -1
  145. package/node_modules/pi-lens/dist/clients/word-index-store.js +448 -0
  146. package/node_modules/pi-lens/dist/clients/word-index.js +582 -104
  147. package/node_modules/pi-lens/dist/clients/workspace-topology.js +17 -0
  148. package/node_modules/pi-lens/dist/clients/zizmor-config.js +26 -6
  149. package/node_modules/pi-lens/dist/index.js +11243 -6651
  150. package/node_modules/pi-lens/dist/mcp/build-staleness.js +8 -7
  151. package/node_modules/pi-lens/dist/mcp/cli.js +3 -1
  152. package/node_modules/pi-lens/dist/mcp/server.js +6 -1
  153. package/node_modules/pi-lens/dist/tools/lens-diagnostics.js +4 -0
  154. package/node_modules/pi-lens/dist/tools/lsp-diagnostics.js +14 -5
  155. package/node_modules/pi-lens/dist/tools/lsp-navigation.js +3 -1
  156. package/node_modules/pi-lens/docs/agent-guide.md +4 -2
  157. package/node_modules/pi-lens/docs/ast-grep_rules_catalog.md +41 -10
  158. package/node_modules/pi-lens/docs/astplayground.md +26 -17
  159. package/node_modules/pi-lens/package.json +5 -1
  160. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/c-no-malloc-free-test.yml +20 -0
  161. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/cpp-no-malloc-free-test.yml +25 -0
  162. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/cpp-no-printf-test.yml +19 -0
  163. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-no-fmt-println-test.yml +19 -0
  164. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-no-panic-in-lib-test.yml +17 -0
  165. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-no-underscore-func-name-test.yml +18 -0
  166. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-prefer-errors-is-test.yml +15 -0
  167. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/go-prefer-string-builder-test.yml +21 -0
  168. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/hyphenated-svg-attribute-test.yml +11 -0
  169. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-lateinit-test.yml +17 -0
  170. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-nullable-boolean-test.yml +15 -0
  171. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-println-test.yml +19 -0
  172. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-unnecessary-let-test.yml +7 -0
  173. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-unsafe-cast-test.yml +7 -0
  174. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-no-var-test.yml +7 -0
  175. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-prefer-data-class-test.yml +25 -0
  176. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/kotlin-prefer-is-empty-test.yml +11 -0
  177. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-important-test.yml +21 -0
  178. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-raw-types-test.yml +30 -0
  179. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-string-concat-in-loop-test.yml +100 -0
  180. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/no-system-out-println-test.yml +7 -0
  181. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/prefer-string-is-empty-test.yml +7 -0
  182. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/python-optional-type-test.yml +9 -0
  183. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/reducible-list-comprehension-test.yml +11 -0
  184. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/ruby-symbol-to-proc-candidate-test.yml +10 -0
  185. package/node_modules/pi-lens/rules/ast-grep-rules/rule-tests/rust-no-chars-enumerate-test.yml +7 -0
  186. package/node_modules/pi-lens/rules/ast-grep-rules/rules/c-no-malloc-free.yml +39 -0
  187. package/node_modules/pi-lens/rules/ast-grep-rules/rules/cpp-no-malloc-free.yml +45 -0
  188. package/node_modules/pi-lens/rules/ast-grep-rules/rules/cpp-no-printf.yml +41 -0
  189. package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-no-fmt-println.yml +43 -0
  190. package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-no-panic-in-lib.yml +41 -0
  191. package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-no-underscore-func-name.yml +35 -0
  192. package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-prefer-errors-is.yml +40 -0
  193. package/node_modules/pi-lens/rules/ast-grep-rules/rules/go-prefer-string-builder.yml +43 -0
  194. package/node_modules/pi-lens/rules/ast-grep-rules/rules/hyphenated-svg-attribute.yml +32 -0
  195. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-lateinit.yml +37 -0
  196. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-nullable-boolean.yml +33 -0
  197. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-println.yml +42 -0
  198. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-unnecessary-let.yml +28 -0
  199. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-unsafe-cast.yml +28 -0
  200. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-no-var.yml +35 -0
  201. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-prefer-data-class.yml +66 -0
  202. package/node_modules/pi-lens/rules/ast-grep-rules/rules/kotlin-prefer-is-empty.yml +35 -0
  203. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-important.yml +21 -0
  204. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-raw-types.yml +33 -0
  205. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-string-concat-in-loop.yml +90 -0
  206. package/node_modules/pi-lens/rules/ast-grep-rules/rules/no-system-out-println.yml +16 -0
  207. package/node_modules/pi-lens/rules/ast-grep-rules/rules/prefer-string-is-empty.yml +11 -0
  208. package/node_modules/pi-lens/rules/ast-grep-rules/rules/python-optional-type.yml +22 -0
  209. package/node_modules/pi-lens/rules/ast-grep-rules/rules/reducible-list-comprehension.yml +29 -0
  210. package/node_modules/pi-lens/rules/ast-grep-rules/rules/ruby-symbol-to-proc-candidate.yml +25 -0
  211. package/node_modules/pi-lens/rules/ast-grep-rules/rules/rust-no-chars-enumerate.yml +19 -0
  212. package/node_modules/pi-mcp-adapter/CHANGELOG.md +15 -0
  213. package/node_modules/pi-mcp-adapter/README.md +2 -2
  214. package/node_modules/pi-mcp-adapter/commands.ts +1 -1
  215. package/node_modules/pi-mcp-adapter/dist/types.d.ts +1 -1
  216. package/node_modules/pi-mcp-adapter/mcp-auth-flow.ts +105 -32
  217. package/node_modules/pi-mcp-adapter/package.json +1 -1
  218. package/node_modules/pi-mcp-adapter/proxy-modes.ts +22 -12
  219. package/node_modules/pi-mcp-adapter/server-manager.ts +6 -0
  220. package/node_modules/pi-mcp-adapter/types.ts +1 -1
  221. package/node_modules/pi-web-access/CHANGELOG.md +20 -0
  222. package/node_modules/pi-web-access/README.md +15 -2
  223. package/node_modules/pi-web-access/extract.ts +57 -12
  224. package/node_modules/pi-web-access/github-extract.ts +47 -3
  225. package/node_modules/pi-web-access/index.ts +1 -1
  226. package/node_modules/pi-web-access/package.json +1 -1
  227. package/node_modules/pi-web-access/page-query.ts +61 -8
  228. package/package.json +16 -16
  229. package/node_modules/@tifan/pi-titlebar-spinner/README.md +0 -24
  230. package/node_modules/@tifan/pi-titlebar-spinner/package.json +0 -39
  231. package/node_modules/@tifan/pi-titlebar-spinner/src/index.ts +0 -49
@@ -7,6 +7,21 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
7
7
 
8
8
  ## [Unreleased]
9
9
 
10
+ ## [2.31.0] - 2026-08-28
11
+
12
+ ### Highlights
13
+ - MCP Apps-aware servers can now recognize Pi as a UI-capable host and expose interactive resources.
14
+ - UI capability advertising works consistently across legacy and modern MCP protocol negotiation.
15
+ - Manual OAuth callback completion now supports HTTPS redirect URLs for pre-registered clients.
16
+
17
+ ### Added
18
+
19
+ - Pi now advertises MCP Apps UI support with the `io.modelcontextprotocol/ui` extension capability, so compatible servers can expose UI resources. Thanks to [@VikashLoomba](https://github.com/VikashLoomba) for #465.
20
+
21
+ ### Fixed
22
+
23
+ - Pre-registered OAuth clients can use HTTPS callback URLs through manual callback completion instead of being rejected as non-local redirects. Thanks to [@jluisrojas](https://github.com/jluisrojas) for PR #464.
24
+
10
25
  ## [2.30.0] - 2026-08-28
11
26
 
12
27
  ### Highlights
@@ -272,7 +272,7 @@ In the configuration examples below, `30000` is illustrative only. If `requestTi
272
272
  | `oauth.clientId` | Pre-registered OAuth client ID. MCP 2026 prefers pre-registered clients or Client ID Metadata Documents; this adapter falls back to Dynamic Client Registration when the ID is omitted and the server supports it. |
273
273
  | `oauth.clientSecret` | OAuth client secret for confidential clients; a value beginning with `!` runs a command when OAuth authenticates, while `!!` escapes a literal leading `!` |
274
274
  | `oauth.scope` | Requested OAuth scopes |
275
- | `oauth.redirectUri` | Exact localhost redirect URI for browser OAuth, including port and path, for providers that pre-register callbacks |
275
+ | `oauth.redirectUri` | Exact redirect URI for browser OAuth. Local `http://` loopback URIs need an explicit port. Pre-registered `https://` callbacks use manual completion by pasting the full callback URL. |
276
276
  | `oauth.clientName` | Client display name advertised during Dynamic Client Registration fallback |
277
277
  | `oauth.clientUri` | Client homepage URI advertised during Dynamic Client Registration fallback. Defaults to `piConfig.clientUri` from the host's manifest when set, and is omitted rather than guessed under a rebranded host |
278
278
  | `oauth.logoUri` | Client logo URL advertised during Dynamic Client Registration fallback (RFC 7591 `logo_uri`). Must be an absolute `http(s)` URL — consent screens fetch it server-side, so local paths render nothing. Omitted from the registration request when unset |
@@ -304,7 +304,7 @@ Use `"2026-07-28"` to pin that revision. Pinning has no legacy or SSE fallback a
304
304
 
305
305
  The stable SDK handles era-specific request envelopes, result decoding, list-changed subscriptions, cancellation, and multi-round-trip sampling/elicitation. The adapter keeps strict OAuth issuer validation in every mode. Adapter-level roots support, standard MCP logging presentation, and configuration/UI for protocol cache hints are not yet implemented.
306
306
 
307
- For pre-registered browser OAuth clients, set `oauth.redirectUri` to the exact callback registered with the provider, for example `"http://localhost:3118/callback"`. Dynamic clients normally omit it and use a lazy OS-assigned localhost callback port.
307
+ For pre-registered browser OAuth clients, set `oauth.redirectUri` to the exact callback registered with the provider, for example `"http://localhost:3118/callback"`. Dynamic clients normally omit it and use a lazy OS-assigned localhost callback port. A configured `https://` callback runs in manual mode because the adapter cannot receive a callback on another host. After authorization, copy the full callback URL from the browser address bar and paste it into `/mcp-auth` or `mcp({ action: "auth-complete", ... })`.
308
308
 
309
309
  If an internal authorization server publishes mismatched OAuth metadata and cannot be fixed immediately, set `oauth.skipIssuerMetadataValidation: true` on that server only. This is security-weakening. It disables the RFC 8414 issuer echo check and should not be used for public or untrusted servers.
310
310
 
@@ -297,7 +297,7 @@ export async function authenticateServer(
297
297
  return ui.input(
298
298
  `Complete ${serverName} OAuth\n\n` +
299
299
  `${terminalHyperlink("Open authorization page", authorizationUrl)}\n${authorizationUrl}\n\n` +
300
- "Approve access, then paste the full localhost callback URL below.",
300
+ "Approve access, then paste the full callback URL from the browser address bar below.",
301
301
  undefined,
302
302
  { signal: inputSignal },
303
303
  );
@@ -228,7 +228,7 @@ export interface OAuthConfig {
228
228
  scope?: string;
229
229
  /** Extra authorization URL parameters for provider-specific extensions. Flow-owned parameters cannot be overridden. */
230
230
  authorizationParams?: Record<string, string>;
231
- /** Exact authorization-code redirect URI for pre-registered clients */
231
+ /** Exact authorization-code redirect URI for pre-registered clients. HTTPS redirects use manual callback URL completion. */
232
232
  redirectUri?: string;
233
233
  /** Client display name for dynamic registration */
234
234
  clientName?: string;
@@ -75,6 +75,8 @@ type PendingAuth = {
75
75
  authProvider: McpOAuthProvider
76
76
  serverUrl: string
77
77
  authorizationUrl: string
78
+ manualRedirect: boolean
79
+ manualCompletionController?: AbortController
78
80
  discovery: AuthDiscovery
79
81
  authStorageOptions: AuthStorageOptions
80
82
  }
@@ -307,7 +309,11 @@ async function probeAuthDiscovery(serverUrl: string, definition?: ServerEntry, s
307
309
  }
308
310
  }
309
311
 
310
- function parseOAuthRedirectUri(redirectUri: string): { port: number; callbackHost: string; callbackPath: string } {
312
+ type OAuthRedirectTarget =
313
+ | { mode: "local"; port: number; callbackHost: string; callbackPath: string }
314
+ | { mode: "manual" }
315
+
316
+ function parseOAuthRedirectUri(redirectUri: string): OAuthRedirectTarget {
311
317
  let url: URL
312
318
  try {
313
319
  url = new URL(redirectUri)
@@ -315,12 +321,6 @@ function parseOAuthRedirectUri(redirectUri: string): { port: number; callbackHos
315
321
  throw new Error(`Invalid OAuth redirectUri: ${redirectUri}`, { cause: error })
316
322
  }
317
323
 
318
- const hostname = url.hostname.toLowerCase()
319
- const isLocalhost = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1"
320
- if (url.protocol !== "http:" || !isLocalhost) {
321
- throw new Error("OAuth redirectUri must be an http:// localhost or loopback URI")
322
- }
323
-
324
324
  if (url.username || url.password) {
325
325
  throw new Error("OAuth redirectUri must not include username or password")
326
326
  }
@@ -329,17 +329,32 @@ function parseOAuthRedirectUri(redirectUri: string): { port: number; callbackHos
329
329
  throw new Error("OAuth redirectUri must not include a fragment")
330
330
  }
331
331
 
332
+ const hostname = url.hostname.toLowerCase()
333
+ const isLocalhost = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1"
334
+ if (url.port) {
335
+ const parsedPort = Number.parseInt(url.port, 10)
336
+ if (!Number.isInteger(parsedPort) || parsedPort <= 0 || parsedPort > 65535) {
337
+ throw new Error("OAuth redirectUri port must be a positive numeric port")
338
+ }
339
+ }
340
+ if (url.protocol === "https:" && !isLocalhost) {
341
+ return { mode: "manual" }
342
+ }
343
+ if (url.protocol !== "http:" || !isLocalhost) {
344
+ throw new Error("OAuth redirectUri must be an https:// URI or an http:// localhost or loopback URI")
345
+ }
346
+
332
347
  if (!url.port) {
333
- throw new Error("OAuth redirectUri must include an explicit numeric port")
348
+ throw new Error("OAuth localhost redirectUri must include an explicit numeric port")
334
349
  }
335
350
 
336
351
  const port = Number.parseInt(url.port, 10)
337
352
  if (!Number.isInteger(port) || port <= 0 || port > 65535) {
338
- throw new Error("OAuth redirectUri must include an explicit numeric port")
353
+ throw new Error("OAuth localhost redirectUri must include an explicit numeric port")
339
354
  }
340
355
 
341
356
  const callbackHost = hostname === "[::1]" ? "::1" : hostname
342
- return { port, callbackHost, callbackPath: url.pathname }
357
+ return { mode: "local", port, callbackHost, callbackPath: url.pathname }
343
358
  }
344
359
 
345
360
  /**
@@ -393,25 +408,30 @@ export async function startAuth(
393
408
  return { authorizationUrl: existingPendingAuth.authorizationUrl }
394
409
  }
395
410
 
396
- const redirectCallback = config.redirectUri !== undefined ? parseOAuthRedirectUri(config.redirectUri) : undefined
411
+ const redirectTarget = config.redirectUri !== undefined ? parseOAuthRedirectUri(config.redirectUri) : undefined
412
+ const manualRedirect = redirectTarget?.mode === "manual"
397
413
  const oauthState = generateState()
398
414
 
399
- try {
400
- await ensureCallbackServer({
401
- strictPort: Boolean(config.clientId) || config.redirectUri !== undefined,
402
- oauthState,
403
- reserveState: true,
404
- ...(redirectCallback ? { port: redirectCallback.port, callbackHost: redirectCallback.callbackHost, callbackPath: redirectCallback.callbackPath } : {}),
405
- })
406
- throwIfAborted(signal)
407
- } catch (error) {
408
- releaseCallbackServer(oauthState)
415
+ if (!manualRedirect) {
409
416
  try {
410
- await cleanupAndReleaseCallbackServerIfIdle(() => clearOAuthState(serverName, authStorageOptions))
411
- } catch (cleanupError) {
412
- throw new AggregateError([error, cleanupError], "OAuth startup cleanup failed")
417
+ await ensureCallbackServer({
418
+ strictPort: Boolean(config.clientId) || config.redirectUri !== undefined,
419
+ oauthState,
420
+ reserveState: true,
421
+ ...(redirectTarget?.mode === "local"
422
+ ? { port: redirectTarget.port, callbackHost: redirectTarget.callbackHost, callbackPath: redirectTarget.callbackPath }
423
+ : {}),
424
+ })
425
+ throwIfAborted(signal)
426
+ } catch (error) {
427
+ releaseCallbackServer(oauthState)
428
+ try {
429
+ await cleanupAndReleaseCallbackServerIfIdle(() => clearOAuthState(serverName, authStorageOptions))
430
+ } catch (cleanupError) {
431
+ throw new AggregateError([error, cleanupError], "OAuth startup cleanup failed")
432
+ }
433
+ throw error
413
434
  }
414
- throw error
415
435
  }
416
436
 
417
437
  let capturedUrl: URL | undefined
@@ -455,7 +475,16 @@ export async function startAuth(
455
475
  if (!capturedUrl) {
456
476
  throw new UnauthorizedError("OAuth authorization URL was not provided")
457
477
  }
458
- await setPendingAuth(runtime, serverName, { serverName, authProvider, serverUrl, authorizationUrl: capturedUrl.toString(), discovery, authStorageOptions }, oauthState, signal, generation)
478
+ await setPendingAuth(runtime, serverName, {
479
+ serverName,
480
+ authProvider,
481
+ serverUrl,
482
+ authorizationUrl: capturedUrl.toString(),
483
+ manualRedirect,
484
+ ...(manualRedirect ? { manualCompletionController: new AbortController() } : {}),
485
+ discovery,
486
+ authStorageOptions,
487
+ }, oauthState, signal, generation)
459
488
  return { authorizationUrl: capturedUrl.toString() }
460
489
  } catch (error) {
461
490
  authProvider.deactivate()
@@ -484,7 +513,13 @@ async function setPendingAuth(
484
513
  state.pendingAuths.set(key, pendingAuth)
485
514
  state.pendingAuthStates.set(key, oauthState)
486
515
  const cleanupTimer = setTimeout(() => {
487
- void clearPendingAuthAndReleaseIfIdle(runtime, serverName, oauthState, pendingAuth.authStorageOptions).catch(error => {
516
+ void clearPendingAuthAndReleaseIfIdle(
517
+ runtime,
518
+ serverName,
519
+ oauthState,
520
+ pendingAuth.authStorageOptions,
521
+ new Error("OAuth authorization timeout - authorization took too long"),
522
+ ).catch(error => {
488
523
  console.error(`MCP Auth: Timed-out flow cleanup failed: ${formatTerminalError(error)}`)
489
524
  })
490
525
  }, MANUAL_AUTH_TIMEOUT_MS)
@@ -492,7 +527,13 @@ async function setPendingAuth(
492
527
  state.pendingAuthCleanupTimers.set(key, cleanupTimer)
493
528
  }
494
529
 
495
- async function clearPendingAuth(runtime: McpOAuthRuntime, serverName: string, oauthState?: string, fallbackStorageOptions: AuthStorageOptions = {}): Promise<void> {
530
+ async function clearPendingAuth(
531
+ runtime: McpOAuthRuntime,
532
+ serverName: string,
533
+ oauthState?: string,
534
+ fallbackStorageOptions: AuthStorageOptions = {},
535
+ reason: Error = new Error("Authorization cancelled"),
536
+ ): Promise<void> {
496
537
  const state = getRuntimeState(runtime)
497
538
  const key = getPendingAuthKey(serverName, fallbackStorageOptions)
498
539
  const pendingAuth = state.pendingAuths.get(key)
@@ -506,6 +547,7 @@ async function clearPendingAuth(runtime: McpOAuthRuntime, serverName: string, oa
506
547
  state.pendingAuthCleanupTimers.delete(key)
507
548
  }
508
549
 
550
+ pendingAuth?.manualCompletionController?.abort(reason)
509
551
  pendingAuth?.authProvider.deactivate()
510
552
  state.pendingAuths.delete(key)
511
553
  state.pendingAuthStates.delete(key)
@@ -524,9 +566,10 @@ async function clearPendingAuthAndReleaseIfIdle(
524
566
  serverName: string,
525
567
  oauthState: string | undefined,
526
568
  fallbackStorageOptions: AuthStorageOptions = {},
569
+ reason?: Error,
527
570
  ): Promise<void> {
528
571
  await cleanupAndReleaseCallbackServerIfIdle(
529
- () => clearPendingAuth(runtime, serverName, oauthState, fallbackStorageOptions),
572
+ () => clearPendingAuth(runtime, serverName, oauthState, fallbackStorageOptions, reason),
530
573
  )
531
574
  }
532
575
 
@@ -629,6 +672,19 @@ type AuthorizationResponse = {
629
672
  source: "callback" | "manual"
630
673
  }
631
674
 
675
+ function waitForManualCompletionCancellation(signal: AbortSignal): Promise<AuthorizationCodeInput> {
676
+ return new Promise((_, reject) => {
677
+ const rejectFromSignal = () => {
678
+ reject(signal.reason instanceof Error ? signal.reason : new Error("Authorization cancelled"))
679
+ }
680
+ if (signal.aborted) {
681
+ rejectFromSignal()
682
+ return
683
+ }
684
+ signal.addEventListener("abort", rejectFromSignal, { once: true })
685
+ })
686
+ }
687
+
632
688
  /**
633
689
  * Wait for either the localhost callback or a manually pasted redirect URL.
634
690
  * The manual input prompt is dismissed as soon as either path finishes.
@@ -686,7 +742,11 @@ export async function completeAuthFromInput(
686
742
  throwIfAborted(signal)
687
743
  const key = getPendingAuthKey(serverName, fallbackAuthStorageOptions)
688
744
  const oauthState = runtimeState.pendingAuthStates.get(key)
745
+ const pendingAuth = runtimeState.pendingAuths.get(key)
689
746
  throwIfAborted(signal)
747
+ if (pendingAuth?.manualRedirect && !getSearchParamsFromInput(input.trim())) {
748
+ throw new Error("Paste the full OAuth callback URL, including its code and state parameters")
749
+ }
690
750
  const parsed = parseAuthorizationRedirectInput(input, oauthState)
691
751
  return completeAuth(serverName, parsed, options)
692
752
  }
@@ -805,15 +865,28 @@ export async function authenticate(
805
865
  try {
806
866
  // Get the state that was already generated and stored in startAuth().
807
867
  // Keep this lookup and its abort check inside the cleanup boundary because
808
- // startAuth has already reserved callback state at this point.
868
+ // startAuth already owns the pending flow at this point.
809
869
  oauthState = runtimeState.pendingAuthStates.get(getPendingAuthKey(serverName, authStorageOptions))
810
870
  throwIfAborted(signal)
811
871
  if (!oauthState) {
812
872
  throw new Error("OAuth state not found - this should not happen")
813
873
  }
814
874
 
815
- // Register the callback BEFORE opening the browser.
816
- const callbackPromise = waitForCallback(oauthState)
875
+ const pendingAuth = runtimeState.pendingAuths.get(getPendingAuthKey(serverName, authStorageOptions))
876
+ if (!pendingAuth) {
877
+ throw new Error(`No pending OAuth flow for server: ${serverName}`)
878
+ }
879
+ if (pendingAuth.manualRedirect && !options.onAuthorizationInput) {
880
+ throw new Error(
881
+ `OAuth for ${serverName} uses a remote redirect URI. Complete it with auth-start/auth-complete or /mcp-auth.`,
882
+ )
883
+ }
884
+
885
+ // Register the localhost callback before opening the browser. Remote
886
+ // pre-registered callbacks are completed by pasting their full URL.
887
+ const callbackPromise: Promise<AuthorizationCodeInput> = pendingAuth.manualRedirect
888
+ ? waitForManualCompletionCancellation(pendingAuth.manualCompletionController!.signal)
889
+ : waitForCallback(oauthState)
817
890
  void callbackPromise.catch(() => {})
818
891
 
819
892
  // Open browser. Always surface the URL first so remote/headless users can copy it
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-mcp-adapter",
3
- "version": "2.30.0",
3
+ "version": "2.31.0",
4
4
  "description": "MCP (Model Context Protocol) adapter extension for Pi coding agent",
5
5
  "type": "module",
6
6
  "types": "./index.ts",
@@ -143,22 +143,30 @@ function getAuthFailedMessage(state: McpExtensionState, serverName: string, mess
143
143
  return `OAuth authentication failed for "${serverName}": ${message}. Run mcp({ action: "auth-start", server: "${serverName}" }) to get a browser URL, or /mcp-auth ${serverName} in an interactive local session.`;
144
144
  }
145
145
 
146
- function getRedirectPort(authorizationUrl: string): number | undefined {
146
+ function getRedirectDetails(authorizationUrl: string): { port?: number; remote: boolean } {
147
147
  try {
148
148
  const redirectUri = new URL(authorizationUrl).searchParams.get("redirect_uri");
149
- if (!redirectUri) return undefined;
150
- const port = Number.parseInt(new URL(redirectUri).port, 10);
151
- return Number.isInteger(port) ? port : undefined;
149
+ if (!redirectUri) return { remote: false };
150
+ const redirect = new URL(redirectUri);
151
+ const hostname = redirect.hostname.toLowerCase();
152
+ const local = hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "::1";
153
+ const port = Number.parseInt(redirect.port, 10);
154
+ return {
155
+ ...(Number.isInteger(port) ? { port } : {}),
156
+ remote: !local,
157
+ };
152
158
  } catch {
153
- return undefined;
159
+ return { remote: false };
154
160
  }
155
161
  }
156
162
 
157
163
  function formatManualAuthInstructions(serverName: string, authorizationUrl: string): string {
158
- const port = getRedirectPort(authorizationUrl);
159
- const portNote = port
160
- ? `\nThe redirect URL will use local port ${port}. On a remote server it is expected for that localhost page to fail locally; copy the address bar URL anyway.`
161
- : "";
164
+ const redirect = getRedirectDetails(authorizationUrl);
165
+ const redirectNote = redirect.remote
166
+ ? "The provider uses a pre-registered HTTPS callback. Copy its full URL from the browser address bar, even if the destination page reports an error."
167
+ : redirect.port
168
+ ? `The redirect URL will use local port ${redirect.port}. On a remote server it is expected for that localhost page to fail locally; copy the address bar URL anyway.`
169
+ : "";
162
170
 
163
171
  return [
164
172
  `MCP OAuth required for "${serverName}".`,
@@ -167,11 +175,13 @@ function formatManualAuthInstructions(serverName: string, authorizationUrl: stri
167
175
  "",
168
176
  authorizationUrl,
169
177
  "",
170
- "After approving, copy the full redirected localhost URL from your browser address bar and send it back with:",
178
+ "After approving, copy the full callback URL from your browser address bar and send it back with:",
171
179
  `mcp({ action: "auth-complete", server: "${serverName}", args: { redirectUrl: "PASTE_REDIRECT_URL_HERE" } })`,
172
180
  "",
173
- 'You can also pass just the `code` query parameter as `args: { code: "PASTE_CODE_HERE" }`. JSON-string args remain supported.',
174
- portNote.trimEnd(),
181
+ redirect.remote
182
+ ? "Remote HTTPS callbacks must include the full callback URL so the OAuth state can be checked. JSON-string args remain supported."
183
+ : 'You can also pass just the `code` query parameter as `args: { code: "PASTE_CODE_HERE" }`. JSON-string args remain supported.',
184
+ redirectNote,
175
185
  ].filter(Boolean).join("\n");
176
186
  }
177
187
 
@@ -34,6 +34,7 @@ import {
34
34
  import { resolveNpxBinary } from "./npx-resolver.ts";
35
35
  import { createJsonSchemaValidator } from "./json-schema-validator.ts";
36
36
  import { logger } from "./logger.ts";
37
+ import { RESOURCE_MIME_TYPE } from "./ui-app-bridge-helpers.ts";
37
38
  import { McpOAuthProvider } from "./mcp-oauth-provider.ts";
38
39
  import { extractOAuthConfig, supportsOAuth, type McpOAuthRuntime } from "./mcp-auth-flow.ts";
39
40
  import { invalidateAuthEntryCache, type AuthStorageOptions } from "./mcp-auth.ts";
@@ -725,6 +726,11 @@ export class McpServerManager {
725
726
 
726
727
  private buildClientCapabilities() {
727
728
  return {
729
+ extensions: {
730
+ "io.modelcontextprotocol/ui": {
731
+ mimeTypes: [RESOURCE_MIME_TYPE],
732
+ },
733
+ },
728
734
  ...(this.samplingConfig ? { sampling: {} } : {}),
729
735
  ...(this.elicitationConfig
730
736
  ? {
@@ -378,7 +378,7 @@ export interface OAuthConfig {
378
378
  scope?: string;
379
379
  /** Extra authorization URL parameters for provider-specific extensions. Flow-owned parameters cannot be overridden. */
380
380
  authorizationParams?: Record<string, string>;
381
- /** Exact authorization-code redirect URI for pre-registered clients */
381
+ /** Exact authorization-code redirect URI for pre-registered clients. HTTPS redirects use manual callback URL completion. */
382
382
  redirectUri?: string;
383
383
  /** Client display name for dynamic registration */
384
384
  clientName?: string;
@@ -4,6 +4,26 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  ## [Unreleased]
6
6
 
7
+ ## [0.27.0] - 2026-08-28
8
+
9
+ ### Highlights
10
+
11
+ - `fetch_content` now lets you tune direct HTTP and Jina Reader timeouts from config.
12
+ - Answer mode can use a configured default model while still allowing per-call overrides.
13
+ - HTML fallback parsing is quieter for pages with relative canonical links.
14
+ - GitHub repository fetching is safer when more than one Pi process is running.
15
+
16
+ ### Added
17
+
18
+ - Added opt-in `fetch.timeout` configuration in seconds for the direct HTTP and Jina Reader `fetch_content` paths, with per-call timeout overrides taking precedence. Thanks to [@linuxtextadventurer](https://github.com/linuxtextadventurer) for PR #327.
19
+ - Added opt-in `fetch.answerProvider` and `fetch.answerModel` defaults for `fetch_content` answer mode, with per-call `answerModel` overrides taking precedence. Thanks to [@linuxtextadventurer](https://github.com/linuxtextadventurer) for PR #328.
20
+
21
+ ### Fixed
22
+
23
+ - Set the Defuddle fallback document URL before parsing pages with relative canonical links, preventing `ERR_INVALID_URL` warnings. Thanks to [@bin115885](https://github.com/bin115885) for issue #322.
24
+ - Isolated GitHub clone workdirs per extension runtime so cleanup in one process cannot delete another process's clone. Thanks to [@MDGChamomile](https://github.com/MDGChamomile) for PR #323.
25
+
26
+
7
27
  ## [0.26.0] - 2026-08-28
8
28
 
9
29
  ### Highlights
@@ -159,11 +159,15 @@ fetch_content({ url: "https://example.com/diagram.png" })
159
159
  | `url` / `urls` | Single URL/path or multiple URLs |
160
160
  | `prompt` | Question for video analysis, or the page-local question required by `mode: "answer"` |
161
161
  | `mode` | `readable` (default), `raw` for exact textual HTTP bodies, or `answer` for a grounded answer from fetched content |
162
- | `answerModel` | Optional `provider/model-id` override for answer mode; defaults to the current enabled Pi model |
162
+ | `answerModel` | Optional `provider/model-id` override for answer mode; defaults to the configured `fetch.answerProvider` + `fetch.answerModel` pair, or the current enabled Pi model when no pair is configured |
163
163
  | `timestamp` | Extract frame(s) — single (`"23:41"`), range (`"23:41-25:00"`), or seconds (`"85"`) |
164
164
  | `frames` | Number of frames to extract (max 12) |
165
165
  | `forceClone` | Clone GitHub repos that exceed the 350MB size threshold |
166
166
 
167
+ For a standing answer-mode model, set both `fetch.answerProvider` and `fetch.answerModel` in `web-search.json`; a per-call `answerModel` takes precedence. Configured answer defaults are opt-in and can send fetched page text to a different provider/model, which may change privacy and cost behavior.
168
+
169
+ Thanks to [@linuxtextadventurer](https://github.com/linuxtextadventurer) for PR #328.
170
+
167
171
  ### get_search_content
168
172
 
169
173
  Retrieve stored content from previous searches or fetches. Fetched URL content is stored in full in a private `web-search-cache` directory under the Pi config directory, not in the session JSONL. This includes `fetch_content` answer mode, which stores the original page content. The cache has a one-hour lifetime and fixed limits of 128 entries and 128 MiB; when either limit is reached, the oldest entries are removed first. On macOS and Linux the cache directory and files are kept at permissions `0700` and `0600`, respectively. Use `findText` to locate bounded matching passages without paging through a large page, or use `offset` and `limit` to retrieve slices intentionally.
@@ -403,6 +407,11 @@ Config defaults to `~/.pi/web-search.json`, or `web-search.json` under `PI_CODIN
403
407
  "providers": ["http", "firecrawl", "jina", "tinyfish", "search1api", "querit", "kagi", "ollama", "parallel", "brightdata", "gemini"],
404
408
  "allowRemoteHostedProviders": false
405
409
  },
410
+ "fetch": {
411
+ "timeout": 30,
412
+ "answerProvider": "openai",
413
+ "answerModel": "gpt-5.6"
414
+ },
406
415
  "webSearch": {
407
416
  "enabled": true
408
417
  },
@@ -502,6 +511,10 @@ Set `braveBaseUrl`, `exaBaseUrl`, or `tavilyBaseUrl` to route those providers th
502
511
 
503
512
  `fetchContent.domainPolicy` is an optional hostname allow/deny policy for `fetch_content` target URLs. It is off when omitted. Each bare hostname matches itself and its subdomains; `deny` wins when a hostname matches both lists. The policy is checked before HTTP(S) target handling and before each redirect followed by this extension's own fetch path. Local file paths and non-HTTP sources are not subject to this policy. It is an additional restriction: the existing SSRF guard still blocks private and internal destinations. Remote extraction services can still perform their own DNS, redirects, and egress after this extension preflights the submitted target URL, so third-party hosted HTTP(S) fallbacks stay disabled unless `fetchRouting.allowRemoteHostedProviders` is enabled for separately isolated provider deployments.
504
513
 
514
+ `fetch.timeout` is an optional positive finite number of seconds for direct HTTP fetches and the Jina Reader fallback. When omitted, both use a 30-second budget. Fractional values are supported and rounded up to at least 1 millisecond; values that cannot be converted to a finite safe integer delay from 1 through Node's 2,147,483,647 ms timer maximum are rejected. An invalid declared value fails closed with an error naming `web-search.json`. An internal/per-call `timeoutMs` override takes precedence over this setting. Other remote extraction fallbacks keep their own documented budgets.
515
+
516
+ `fetch.answerProvider` and `fetch.answerModel` are an optional pair that selects the model used by `fetch_content` answer mode when no per-call `answerModel` is supplied. Both values must be non-empty strings and must identify an enabled text-capable model available in Pi's model registry; invalid or partial configuration fails closed. This is opt-in: answering with the configured provider/model can send fetched page text outside the current session and may incur that provider's costs. A per-call `answerModel` override is resolved first and remains usable even when these configured defaults are malformed.
517
+
505
518
  Set `searxngBaseUrl` or `SEARXNG_BASE_URL` to use a self-hosted SearXNG JSON API. A configured endpoint is preferred first in `auto` mode for local/private search. Its base URL and redirects remain subject to the SSRF guard; add only the narrowest self-hosted range to `ssrf.allowRanges` when it resolves to a private or synthetic range. Optional `searxngHeaders` merges extra HTTP headers into each SearXNG request (string values only; invalid header names are ignored), which is useful for reverse-proxy or Zero Trust auth such as Cloudflare Access service tokens (`CF-Access-Client-Id` / `CF-Access-Client-Secret`). Configured headers override the default `Accept: application/json` when the same name is supplied. Thanks to Marcos A. Núñez (@marnunez) for PR #107 and Avinash Kanaujiya (@avinashkanaujiya) for issue #105.
506
519
 
507
520
  **DuckDuckGo.** DuckDuckGo HTML search is keyless and explicit-only. Select it with `provider: "duckduckgo"` or place it in `searchRouting`; it is never chosen by `auto` and never participates in `provider: "all"`. Domain filters are enforced locally after DuckDuckGo redirect URLs are decoded. `recencyFilter` is not guaranteed because the HTML endpoint has no documented stable time parameter. A 200 page with no parseable results is reported as an invalid response, so routing can continue when `fallbackOn` includes `"invalid-response"`.
@@ -868,7 +881,7 @@ Values use the same format as pi keybindings (e.g. `ctrl+s`, `ctrl+shift+s`, `al
868
881
 
869
882
  Set `"enabled": false` under `tools`, `commands`, `image`, or `pdf` to disable that feature. Tool-specific settings override the legacy `webSearch.enabled` shorthand; without an override, it still disables `web_search` and `source_check`. `image.enabled: false` blocks direct image fetches and video frame extraction, and prevents video thumbnails. `pdf.enabled: false` blocks PDF extraction. For GitHub specifically, `githubClone.enabled: false` only skips clone/API specialization, and `githubPrIssue.enabled: false` only skips PR/issue specialization; neither setting unregisters `fetch_content` or blocks generic URL extraction. Pi restart is required for tool and command registration changes.
870
883
 
871
- Rate limits: Perplexity is capped at 10 requests/minute (client-side). Jina Search, TinyFish, Search1API, and Searchinfinity apply the plan limits documented by their APIs. Querit Search and Contents subscriptions are independent. Content fetches run 3 concurrent with a 30s timeout for the direct HTTP fetch of each URL. Remote extraction fallbacks carry their own budgets and are not covered by that number: Jina Reader 30s, Firecrawl 60s, Kagi Extract 60s, Ollama Web Fetch 60s, Bright Data Web Unlocker 60s, TinyFish up to 150s, Gemini 120s, Datalab 120s (capped at 300s, rate-limited to 25 requests/minute on the free tier). `pdf.maxSizeMB` defaults to 20 and is capped at 50. `pdf.maxPages` defaults to 100 and limits every PDF provider to the first N pages.
884
+ Rate limits: Perplexity is capped at 10 requests/minute (client-side). Jina Search, TinyFish, Search1API, and Searchinfinity apply the plan limits documented by their APIs. Querit Search and Contents subscriptions are independent. Content fetches run 3 concurrent; direct HTTP fetches and Jina Reader use a 30s timeout by default, configurable together with `fetch.timeout` in seconds. Remote extraction fallbacks carry their own budgets and are not covered by that setting: Firecrawl 60s, Kagi Extract 60s, Ollama Web Fetch 60s, Bright Data Web Unlocker 60s, TinyFish up to 150s, Gemini 120s, Datalab 120s (capped at 300s, rate-limited to 25 requests/minute on the free tier). `pdf.maxSizeMB` defaults to 20 and is capped at 50. `pdf.maxPages` defaults to 100 and limits every PDF provider to the first N pages.
872
885
 
873
886
  ## Limitations
874
887
 
@@ -31,12 +31,45 @@ import { getBrowserCookiesForHosts, getLastBrowserCookieDiagnostic } from "./chr
31
31
  import { sanitizeInlineDataUris } from "./data-uri-sanitize.ts";
32
32
 
33
33
  const DEFAULT_TIMEOUT_MS = 30000;
34
+ const MAX_CONFIGURED_TIMEOUT_MS = 2_147_483_647;
34
35
  const CONCURRENT_LIMIT = 3;
36
+ const WEB_SEARCH_CONFIG_PATH = getWebSearchConfigPath();
37
+
38
+ function loadFetchTimeoutMs(): number {
39
+ if (!existsSync(WEB_SEARCH_CONFIG_PATH)) return DEFAULT_TIMEOUT_MS;
40
+
41
+ let raw: unknown;
42
+ try {
43
+ raw = JSON.parse(readFileSync(WEB_SEARCH_CONFIG_PATH, "utf-8"));
44
+ } catch (err) {
45
+ const message = err instanceof Error ? err.message : String(err);
46
+ throw new Error(`Failed to parse ${WEB_SEARCH_CONFIG_PATH}: ${message}`);
47
+ }
48
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
49
+ throw new Error(`Invalid config in ${WEB_SEARCH_CONFIG_PATH}: expected a JSON object`);
50
+ }
51
+
52
+ const fetchConfig = (raw as Record<string, unknown>).fetch;
53
+ if (fetchConfig === undefined) return DEFAULT_TIMEOUT_MS;
54
+ if (!fetchConfig || typeof fetchConfig !== "object" || Array.isArray(fetchConfig)) {
55
+ throw new Error(`fetch in ${WEB_SEARCH_CONFIG_PATH} must be an object`);
56
+ }
57
+
58
+ const value = (fetchConfig as Record<string, unknown>).timeout;
59
+ if (value === undefined) return DEFAULT_TIMEOUT_MS;
60
+ if (typeof value !== "number" || !Number.isFinite(value) || value <= 0) {
61
+ throw new Error(`Invalid fetch.timeout in ${WEB_SEARCH_CONFIG_PATH}: expected a positive finite number of seconds, got ${JSON.stringify(value)}`);
62
+ }
63
+ const timeoutMs = Math.ceil(value * 1000);
64
+ if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1 || timeoutMs > MAX_CONFIGURED_TIMEOUT_MS) {
65
+ throw new Error(`Invalid fetch.timeout in ${WEB_SEARCH_CONFIG_PATH}: converted timeout must be a finite safe integer from 1 through ${MAX_CONFIGURED_TIMEOUT_MS} milliseconds`);
66
+ }
67
+ return Math.max(1, timeoutMs);
68
+ }
35
69
 
36
70
  const NON_RECOVERABLE_ERRORS = ["Unsupported content type", "Response too large", "PDF extraction is disabled", "Image fetching is disabled"];
37
71
  const MIN_USEFUL_CONTENT = 500;
38
72
  const SUPPORTED_IMAGE_TYPES = new Set(["image/png", "image/jpeg", "image/webp", "image/gif"]);
39
- const WEB_SEARCH_CONFIG_PATH = getWebSearchConfigPath();
40
73
  const FETCH_PROVIDERS = ["http", "firecrawl", "jina", "tinyfish", "search1api", "querit", "kagi", "ollama", "parallel", "parallel-mcp", "brightdata", "gemini"] as const;
41
74
  type FetchProvider = typeof FETCH_PROVIDERS[number];
42
75
  type FetchRouting = { providers: FetchProvider[]; allowRemoteHostedProviders: boolean };
@@ -51,6 +84,10 @@ function isDefuddleConsoleError(args: Parameters<typeof console.error>): boolean
51
84
  async function extractWithDefuddle(text: string, url: string): Promise<{ title: string; content: string } | null> {
52
85
  const { Defuddle } = await import("defuddle/node");
53
86
  const { document } = parseHTML(text);
87
+ Object.defineProperty(document, "location", {
88
+ value: new URL(url),
89
+ configurable: true,
90
+ });
54
91
  let processingError: unknown;
55
92
  const originalConsoleError = console.error;
56
93
  console.error = (...args) => {
@@ -298,11 +335,16 @@ export interface ExtractOptions {
298
335
  lookup?: Lookup;
299
336
  }
300
337
 
338
+ /** Resolve the direct HTTP/Jina fetch budget, with a per-call override taking precedence. */
339
+ export function resolveFetchTimeoutMs(options?: Pick<ExtractOptions, "timeoutMs">): number {
340
+ return options?.timeoutMs ?? loadFetchTimeoutMs();
341
+ }
342
+
301
343
  const JINA_READER_BASE = "https://r.jina.ai/";
302
- const JINA_TIMEOUT_MS = 30000;
303
344
 
304
345
  async function extractWithJinaReader(
305
346
  url: string,
347
+ timeoutMs: number,
306
348
  signal?: AbortSignal,
307
349
  lookup?: Lookup,
308
350
  ): Promise<ExtractedContent | null> {
@@ -325,7 +367,7 @@ async function extractWithJinaReader(
325
367
  "X-No-Cache": "true",
326
368
  },
327
369
  signal: AbortSignal.any([
328
- AbortSignal.timeout(JINA_TIMEOUT_MS),
370
+ AbortSignal.timeout(timeoutMs),
329
371
  ...(signal ? [signal] : []),
330
372
  ]),
331
373
  });
@@ -480,18 +522,14 @@ export async function extractContent(
480
522
  }
481
523
  }
482
524
 
483
- if (options?.authFetchProfile) {
525
+ if (options?.authFetchProfile || options?.mode === "raw") {
484
526
  try {
485
- return await extractViaHttp(url, signal, options);
527
+ return await extractViaHttp(url, resolveFetchTimeoutMs(options), signal, options);
486
528
  } catch (err) {
487
529
  return { url, title: "", content: "", error: errorMessage(err) };
488
530
  }
489
531
  }
490
532
 
491
- if (options?.mode === "raw") {
492
- return extractViaHttp(url, signal, options);
493
- }
494
-
495
533
  if (options?.frames || options?.timestamp) {
496
534
  const disabled = imageGateError();
497
535
  if (disabled) return { url, title: "", content: "", error: disabled };
@@ -703,6 +741,13 @@ export async function extractContent(
703
741
 
704
742
  if (signal?.aborted) return abortedResult(url);
705
743
 
744
+ let fetchTimeoutMs: number;
745
+ try {
746
+ fetchTimeoutMs = resolveFetchTimeoutMs(options);
747
+ } catch (err) {
748
+ return { url, title: "", content: "", error: errorMessage(err) };
749
+ }
750
+
706
751
  let fetchRouting: FetchRouting;
707
752
  try {
708
753
  fetchRouting = loadFetchRouting();
@@ -731,7 +776,7 @@ export async function extractContent(
731
776
  ? { ...httpResult, error: message }
732
777
  : { url, title: "", content: "", error: message };
733
778
  const runHttpProvider = async (): Promise<ExtractedContent | null> => {
734
- const { declaredLinks: discoveredLinks = [], ...result } = await extractViaHttp(url, signal, options);
779
+ const { declaredLinks: discoveredLinks = [], ...result } = await extractViaHttp(url, fetchTimeoutMs, signal, options);
735
780
  httpResult = result;
736
781
  declaredLinks = discoveredLinks;
737
782
  if (signal?.aborted) return abortedResult(url);
@@ -786,7 +831,7 @@ export async function extractContent(
786
831
  }
787
832
 
788
833
  if (provider === "jina") {
789
- const jinaResult = await extractWithJinaReader(url, signal, options?.lookup);
834
+ const jinaResult = await extractWithJinaReader(url, fetchTimeoutMs, signal, options?.lookup);
790
835
  if (jinaResult) return withDeclaredLinks(jinaResult);
791
836
  continue;
792
837
  }
@@ -1066,10 +1111,10 @@ function responseSizeLimitError(maxBytes: number): Error {
1066
1111
 
1067
1112
  async function extractViaHttp(
1068
1113
  url: string,
1114
+ timeoutMs: number,
1069
1115
  signal?: AbortSignal,
1070
1116
  options?: ExtractOptions,
1071
1117
  ): Promise<HttpExtractedContent> {
1072
- const timeoutMs = options?.timeoutMs ?? DEFAULT_TIMEOUT_MS;
1073
1118
  const activityId = activityMonitor.logStart({ type: "fetch", url });
1074
1119
 
1075
1120
  const controller = new AbortController();