@oh-my-pi/pi-coding-agent 15.10.7 → 15.10.9

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 (80) hide show
  1. package/CHANGELOG.md +27 -0
  2. package/dist/types/config/model-registry.d.ts +4 -2
  3. package/dist/types/config/model-resolver.d.ts +2 -0
  4. package/dist/types/config/settings-schema.d.ts +9 -0
  5. package/dist/types/extensibility/custom-tools/loader.d.ts +22 -3
  6. package/dist/types/extensibility/custom-tools/types.d.ts +3 -1
  7. package/dist/types/extensibility/extensions/index.d.ts +1 -1
  8. package/dist/types/extensibility/extensions/loader.d.ts +17 -1
  9. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +8 -0
  10. package/dist/types/mcp/oauth-discovery.d.ts +4 -1
  11. package/dist/types/mcp/oauth-flow.d.ts +6 -1
  12. package/dist/types/mcp/transports/stdio.d.ts +12 -0
  13. package/dist/types/modes/components/custom-editor.d.ts +3 -2
  14. package/dist/types/sdk.d.ts +42 -2
  15. package/dist/types/task/executor.d.ts +16 -0
  16. package/dist/types/tools/fetch.d.ts +2 -1
  17. package/dist/types/tools/index.d.ts +20 -1
  18. package/dist/types/tools/report-tool-issue.d.ts +5 -0
  19. package/dist/types/tui/hyperlink.d.ts +8 -0
  20. package/dist/types/web/kagi.d.ts +2 -1
  21. package/dist/types/web/parallel.d.ts +3 -0
  22. package/dist/types/web/search/providers/anthropic.d.ts +2 -1
  23. package/dist/types/web/search/providers/base.d.ts +2 -1
  24. package/dist/types/web/search/providers/brave.d.ts +2 -1
  25. package/dist/types/web/search/providers/codex.d.ts +2 -1
  26. package/dist/types/web/search/providers/exa.d.ts +2 -1
  27. package/dist/types/web/search/providers/gemini.d.ts +2 -1
  28. package/dist/types/web/search/providers/jina.d.ts +7 -2
  29. package/dist/types/web/search/providers/kagi.d.ts +7 -2
  30. package/dist/types/web/search/providers/kimi.d.ts +7 -2
  31. package/dist/types/web/search/providers/parallel.d.ts +2 -1
  32. package/dist/types/web/search/providers/perplexity.d.ts +2 -1
  33. package/dist/types/web/search/providers/searxng.d.ts +2 -1
  34. package/dist/types/web/search/providers/synthetic.d.ts +7 -3
  35. package/dist/types/web/search/providers/tavily.d.ts +2 -1
  36. package/dist/types/web/search/providers/zai.d.ts +2 -1
  37. package/package.json +9 -9
  38. package/src/config/model-registry.ts +13 -7
  39. package/src/config/model-resolver.ts +57 -2
  40. package/src/config/settings-schema.ts +6 -0
  41. package/src/extensibility/custom-tools/loader.ts +43 -19
  42. package/src/extensibility/custom-tools/types.ts +3 -1
  43. package/src/extensibility/extensions/index.ts +1 -0
  44. package/src/extensibility/extensions/loader.ts +29 -6
  45. package/src/extensibility/plugins/legacy-pi-compat.ts +30 -6
  46. package/src/internal-urls/docs-index.generated.ts +1 -1
  47. package/src/mcp/oauth-discovery.ts +8 -3
  48. package/src/mcp/oauth-flow.ts +12 -5
  49. package/src/mcp/transports/stdio.ts +139 -3
  50. package/src/modes/components/assistant-message.ts +28 -6
  51. package/src/modes/components/custom-editor.ts +69 -9
  52. package/src/modes/components/transcript-container.ts +77 -25
  53. package/src/modes/controllers/input-controller.ts +1 -1
  54. package/src/modes/controllers/mcp-command-controller.ts +2 -2
  55. package/src/sdk.ts +138 -56
  56. package/src/ssh/ssh-executor.ts +60 -4
  57. package/src/task/executor.ts +19 -0
  58. package/src/task/index.ts +4 -0
  59. package/src/tools/fetch.ts +22 -5
  60. package/src/tools/image-gen.ts +33 -11
  61. package/src/tools/index.ts +21 -2
  62. package/src/tools/report-tool-issue.ts +7 -1
  63. package/src/tui/hyperlink.ts +27 -3
  64. package/src/web/kagi.ts +5 -2
  65. package/src/web/parallel.ts +7 -3
  66. package/src/web/search/providers/anthropic.ts +5 -1
  67. package/src/web/search/providers/base.ts +2 -1
  68. package/src/web/search/providers/brave.ts +5 -2
  69. package/src/web/search/providers/codex.ts +6 -2
  70. package/src/web/search/providers/exa.ts +91 -8
  71. package/src/web/search/providers/gemini.ts +6 -0
  72. package/src/web/search/providers/jina.ts +15 -5
  73. package/src/web/search/providers/kagi.ts +9 -2
  74. package/src/web/search/providers/kimi.ts +18 -4
  75. package/src/web/search/providers/parallel.ts +6 -2
  76. package/src/web/search/providers/perplexity.ts +7 -4
  77. package/src/web/search/providers/searxng.ts +6 -2
  78. package/src/web/search/providers/synthetic.ts +9 -5
  79. package/src/web/search/providers/tavily.ts +4 -2
  80. package/src/web/search/providers/zai.ts +15 -4
@@ -4,14 +4,18 @@
4
4
  * Uses the Jina Reader `s.jina.ai` endpoint to fetch search results with
5
5
  * cleaned content.
6
6
  */
7
- import { type AuthStorage } from "@oh-my-pi/pi-ai";
7
+ import { type AuthStorage, type FetchImpl } from "@oh-my-pi/pi-ai";
8
8
  import type { SearchResponse } from "../../../web/search/types";
9
9
  import type { SearchParams } from "./base";
10
10
  import { SearchProvider } from "./base";
11
+ type SearchParamsWithFetch = SearchParams & {
12
+ fetch?: FetchImpl;
13
+ };
11
14
  export interface JinaSearchParams {
12
15
  query: string;
13
16
  num_results?: number;
14
17
  signal?: AbortSignal;
18
+ fetch?: FetchImpl;
15
19
  }
16
20
  /** Find JINA_API_KEY from environment or .env files. */
17
21
  export declare function findApiKey(): string | null;
@@ -22,5 +26,6 @@ export declare class JinaProvider extends SearchProvider {
22
26
  readonly id = "jina";
23
27
  readonly label = "Jina";
24
28
  isAvailable(_authStorage: AuthStorage): boolean;
25
- search(params: SearchParams): Promise<SearchResponse>;
29
+ search(params: SearchParamsWithFetch): Promise<SearchResponse>;
26
30
  }
31
+ export {};
@@ -3,10 +3,13 @@
3
3
  *
4
4
  * Thin wrapper that adapts shared Kagi API utilities to SearchResponse shape.
5
5
  */
6
- import type { AuthStorage } from "@oh-my-pi/pi-ai";
6
+ import type { AuthStorage, FetchImpl } from "@oh-my-pi/pi-ai";
7
7
  import type { SearchResponse } from "../../../web/search/types";
8
8
  import type { SearchParams } from "./base";
9
9
  import { SearchProvider } from "./base";
10
+ type SearchParamsWithFetch = SearchParams & {
11
+ fetch?: FetchImpl;
12
+ };
10
13
  /** Execute Kagi web search. */
11
14
  export declare function searchKagi(params: {
12
15
  query: string;
@@ -15,11 +18,13 @@ export declare function searchKagi(params: {
15
18
  signal?: AbortSignal;
16
19
  authStorage: AuthStorage;
17
20
  sessionId?: string;
21
+ fetch?: FetchImpl;
18
22
  }): Promise<SearchResponse>;
19
23
  /** Search provider for Kagi web search. */
20
24
  export declare class KagiProvider extends SearchProvider {
21
25
  readonly id = "kagi";
22
26
  readonly label = "Kagi";
23
27
  isAvailable(authStorage: AuthStorage): boolean;
24
- search(params: SearchParams): Promise<SearchResponse>;
28
+ search(params: SearchParamsWithFetch): Promise<SearchResponse>;
25
29
  }
30
+ export {};
@@ -4,10 +4,13 @@
4
4
  * Uses Moonshot Kimi Code search API to retrieve web results.
5
5
  * Endpoint: POST https://api.kimi.com/coding/v1/search
6
6
  */
7
- import { type AuthStorage } from "@oh-my-pi/pi-ai";
7
+ import { type AuthStorage, type FetchImpl } from "@oh-my-pi/pi-ai";
8
8
  import type { SearchResponse } from "../../../web/search/types";
9
9
  import type { SearchParams } from "./base";
10
10
  import { SearchProvider } from "./base";
11
+ type SearchParamsWithFetch = SearchParams & {
12
+ fetch?: FetchImpl;
13
+ };
11
14
  export interface KimiSearchParams {
12
15
  query: string;
13
16
  num_results?: number;
@@ -15,6 +18,7 @@ export interface KimiSearchParams {
15
18
  signal?: AbortSignal;
16
19
  authStorage: AuthStorage;
17
20
  sessionId?: string;
21
+ fetch?: FetchImpl;
18
22
  }
19
23
  /** Execute Kimi web search. */
20
24
  export declare function searchKimi(params: KimiSearchParams): Promise<SearchResponse>;
@@ -23,5 +27,6 @@ export declare class KimiProvider extends SearchProvider {
23
27
  readonly id = "kimi";
24
28
  readonly label = "Kimi";
25
29
  isAvailable(authStorage: AuthStorage): boolean;
26
- search(params: SearchParams): Promise<SearchResponse>;
30
+ search(params: SearchParamsWithFetch): Promise<SearchResponse>;
27
31
  }
32
+ export {};
@@ -1,4 +1,4 @@
1
- import { type AuthStorage } from "@oh-my-pi/pi-ai";
1
+ import { type AuthStorage, type FetchImpl } from "@oh-my-pi/pi-ai";
2
2
  import type { SearchResponse } from "../../../web/search/types";
3
3
  import type { SearchParams } from "./base";
4
4
  import { SearchProvider } from "./base";
@@ -6,6 +6,7 @@ export declare function searchParallel(params: {
6
6
  query: string;
7
7
  num_results?: number;
8
8
  signal?: AbortSignal;
9
+ fetch?: FetchImpl;
9
10
  }, authStorage: AuthStorage, sessionId?: string): Promise<SearchResponse>;
10
11
  export declare class ParallelProvider extends SearchProvider {
11
12
  readonly id = "parallel";
@@ -7,7 +7,7 @@
7
7
  * - API key (`PERPLEXITY_API_KEY`) via `api.perplexity.ai/chat/completions`
8
8
  * - Anonymous via `www.perplexity.ai/rest/sse/perplexity_ask`
9
9
  */
10
- import { type AuthStorage } from "@oh-my-pi/pi-ai";
10
+ import { type AuthStorage, type FetchImpl } from "@oh-my-pi/pi-ai";
11
11
  import type { SearchResponse } from "../../../web/search/types";
12
12
  import type { SearchParams } from "./base";
13
13
  import { SearchProvider } from "./base";
@@ -25,6 +25,7 @@ export interface PerplexitySearchParams {
25
25
  num_search_results?: number;
26
26
  authStorage: AuthStorage;
27
27
  sessionId?: string;
28
+ fetch?: FetchImpl;
28
29
  }
29
30
  /** Find PERPLEXITY_API_KEY from environment or .env files (also checks PPLX_API_KEY) */
30
31
  export declare function findApiKey(): string | null;
@@ -24,7 +24,7 @@
24
24
  *
25
25
  * Reference: https://docs.searxng.org/dev/search_api.html
26
26
  */
27
- import type { AuthStorage } from "@oh-my-pi/pi-ai";
27
+ import type { AuthStorage, FetchImpl } from "@oh-my-pi/pi-ai";
28
28
  import type { SearchResponse } from "../../../web/search/types";
29
29
  import type { SearchParams } from "./base";
30
30
  import { SearchProvider } from "./base";
@@ -34,6 +34,7 @@ export declare function searchSearXNG(params: {
34
34
  num_results?: number;
35
35
  recency?: "day" | "week" | "month" | "year";
36
36
  signal?: AbortSignal;
37
+ fetch?: FetchImpl;
37
38
  }): Promise<SearchResponse>;
38
39
  /** Search provider for SearXNG web search. */
39
40
  export declare class SearXNGProvider extends SearchProvider {
@@ -4,18 +4,22 @@
4
4
  * Uses Synthetic's zero-data-retention web search API for coding agents.
5
5
  * Endpoint: POST https://api.synthetic.new/v2/search
6
6
  */
7
- import { type AuthStorage } from "@oh-my-pi/pi-ai";
7
+ import { type AuthStorage, type FetchImpl } from "@oh-my-pi/pi-ai";
8
8
  import type { SearchResponse } from "../../../web/search/types";
9
9
  import type { SearchParams } from "./base";
10
10
  import { SearchProvider } from "./base";
11
+ type SearchParamsWithFetch = SearchParams & {
12
+ fetch?: FetchImpl;
13
+ };
11
14
  /** Resolve Synthetic API key through the shared auth storage pipeline. */
12
15
  export declare function findApiKey(authStorage: AuthStorage, sessionId?: string, signal?: AbortSignal): Promise<string | undefined>;
13
16
  /** Execute Synthetic web search. */
14
- export declare function searchSynthetic(params: SearchParams): Promise<SearchResponse>;
17
+ export declare function searchSynthetic(params: SearchParamsWithFetch): Promise<SearchResponse>;
15
18
  /** Search provider for Synthetic. */
16
19
  export declare class SyntheticProvider extends SearchProvider {
17
20
  readonly id = "synthetic";
18
21
  readonly label = "Synthetic";
19
22
  isAvailable(authStorage: AuthStorage): boolean;
20
- search(params: SearchParams): Promise<SearchResponse>;
23
+ search(params: SearchParamsWithFetch): Promise<SearchResponse>;
21
24
  }
25
+ export {};
@@ -4,7 +4,7 @@
4
4
  * Uses Tavily's agent-focused search API to return structured results with an
5
5
  * optional synthesized answer.
6
6
  */
7
- import { type AuthStorage } from "@oh-my-pi/pi-ai";
7
+ import { type AuthStorage, type FetchImpl } from "@oh-my-pi/pi-ai";
8
8
  import type { SearchResponse } from "../../../web/search/types";
9
9
  import type { SearchParams } from "./base";
10
10
  import { SearchProvider } from "./base";
@@ -13,6 +13,7 @@ export interface TavilySearchParams {
13
13
  num_results?: number;
14
14
  recency?: "day" | "week" | "month" | "year";
15
15
  signal?: AbortSignal;
16
+ fetch?: FetchImpl;
16
17
  }
17
18
  /** Find Tavily API key through AuthStorage's unified refresh pipeline. */
18
19
  export declare function findApiKey(authStorage: AuthStorage, sessionId: string | undefined, signal: AbortSignal | undefined): Promise<string | null>;
@@ -4,7 +4,7 @@
4
4
  * Calls Z.AI's remote MCP server (`webSearchPrime`) and adapts results into
5
5
  * the unified SearchResponse shape used by the web search tool.
6
6
  */
7
- import { type AuthStorage } from "@oh-my-pi/pi-ai";
7
+ import { type AuthStorage, type FetchImpl } from "@oh-my-pi/pi-ai";
8
8
  import type { SearchResponse } from "../../../web/search/types";
9
9
  import type { SearchParams } from "./base";
10
10
  import { SearchProvider } from "./base";
@@ -12,6 +12,7 @@ export interface ZaiSearchParams {
12
12
  query: string;
13
13
  num_results?: number;
14
14
  signal?: AbortSignal;
15
+ fetch?: FetchImpl;
15
16
  authStorage: AuthStorage;
16
17
  sessionId?: string;
17
18
  }
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@oh-my-pi/pi-coding-agent",
4
- "version": "15.10.7",
4
+ "version": "15.10.9",
5
5
  "description": "Coding agent CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://omp.sh",
7
7
  "author": "Can Boluk",
@@ -47,14 +47,14 @@
47
47
  "@agentclientprotocol/sdk": "0.22.1",
48
48
  "@babel/parser": "^7.29.7",
49
49
  "@mozilla/readability": "^0.6.0",
50
- "@oh-my-pi/hashline": "15.10.7",
51
- "@oh-my-pi/omp-stats": "15.10.7",
52
- "@oh-my-pi/pi-agent-core": "15.10.7",
53
- "@oh-my-pi/pi-ai": "15.10.7",
54
- "@oh-my-pi/pi-mnemopi": "15.10.7",
55
- "@oh-my-pi/pi-natives": "15.10.7",
56
- "@oh-my-pi/pi-tui": "15.10.7",
57
- "@oh-my-pi/pi-utils": "15.10.7",
50
+ "@oh-my-pi/hashline": "15.10.9",
51
+ "@oh-my-pi/omp-stats": "15.10.9",
52
+ "@oh-my-pi/pi-agent-core": "15.10.9",
53
+ "@oh-my-pi/pi-ai": "15.10.9",
54
+ "@oh-my-pi/pi-mnemopi": "15.10.9",
55
+ "@oh-my-pi/pi-natives": "15.10.9",
56
+ "@oh-my-pi/pi-tui": "15.10.9",
57
+ "@oh-my-pi/pi-utils": "15.10.9",
58
58
  "@opentelemetry/api": "^1.9.1",
59
59
  "@opentelemetry/context-async-hooks": "^2.7.1",
60
60
  "@opentelemetry/exporter-trace-otlp-proto": "^0.218.0",
@@ -95,7 +95,7 @@ const STARTUP_MODEL_CACHE_PROVIDER_IDS: readonly string[] = [
95
95
  ...SPECIAL_MODEL_MANAGER_PROVIDER_IDS,
96
96
  ];
97
97
 
98
- import type { ApiKeyResolver } from "@oh-my-pi/pi-ai";
98
+ import type { ApiKeyResolver, FetchImpl } from "@oh-my-pi/pi-ai";
99
99
  import { registerOAuthProvider, unregisterOAuthProviders } from "@oh-my-pi/pi-ai/oauth";
100
100
  import type { OAuthCredentials, OAuthLoginCallbacks } from "@oh-my-pi/pi-ai/oauth/types";
101
101
  import { isRecord, logger } from "@oh-my-pi/pi-utils";
@@ -927,6 +927,7 @@ export class ModelRegistry {
927
927
  #runtimeModelManagers: Map<string, { options: ModelManagerOptions<Api>; sourceId: string }> = new Map();
928
928
  #rebuildPending: boolean = false;
929
929
  #rebuildSuspended: number = 0;
930
+ #fetch: FetchImpl;
930
931
 
931
932
  /**
932
933
  * @param authStorage - Auth storage for API key resolution
@@ -940,7 +941,9 @@ export class ModelRegistry {
940
941
  constructor(
941
942
  readonly authStorage: AuthStorage,
942
943
  modelsPath?: string,
944
+ options?: { fetch?: FetchImpl },
943
945
  ) {
946
+ this.#fetch = options?.fetch ?? fetch;
944
947
  this.#modelsConfigFile = ModelsConfigFile.relocate(modelsPath);
945
948
  this.#cacheDbPath = modelsPath ? path.join(path.dirname(modelsPath), "models.db") : undefined;
946
949
  // Set up fallback resolver for custom provider API keys
@@ -1629,6 +1632,7 @@ export class ModelRegistry {
1629
1632
  googleAntigravityModelManagerOptions({
1630
1633
  oauthToken,
1631
1634
  endpoint: this.getProviderBaseUrl("google-antigravity"),
1635
+ fetch: this.#fetch,
1632
1636
  }),
1633
1637
  },
1634
1638
  {
@@ -1638,6 +1642,7 @@ export class ModelRegistry {
1638
1642
  googleGeminiCliModelManagerOptions({
1639
1643
  oauthToken,
1640
1644
  endpoint: this.getProviderBaseUrl("google-gemini-cli"),
1645
+ fetch: this.#fetch,
1641
1646
  }),
1642
1647
  },
1643
1648
  {
@@ -1676,6 +1681,7 @@ export class ModelRegistry {
1676
1681
  descriptor.createModelManagerOptions({
1677
1682
  apiKey: isAuthenticated(apiKey) ? apiKey : undefined,
1678
1683
  baseUrl: this.getProviderBaseUrl(descriptor.providerId),
1684
+ fetch: this.#fetch,
1679
1685
  }),
1680
1686
  );
1681
1687
  }
@@ -1727,7 +1733,7 @@ export class ModelRegistry {
1727
1733
  ): Promise<OllamaDiscoveredModelMetadata | null> {
1728
1734
  const showUrl = `${endpoint}/api/show`;
1729
1735
  try {
1730
- const response = await fetch(showUrl, {
1736
+ const response = await this.#fetch(showUrl, {
1731
1737
  method: "POST",
1732
1738
  headers: { ...(headers ?? {}), "Content-Type": "application/json" },
1733
1739
  body: JSON.stringify({ model: modelId }),
@@ -1775,7 +1781,7 @@ export class ModelRegistry {
1775
1781
  const endpoint = this.#normalizeOllamaBaseUrl(providerConfig.baseUrl);
1776
1782
  const tagsUrl = `${endpoint}/api/tags`;
1777
1783
  const headers = { ...(providerConfig.headers ?? {}) };
1778
- const response = await fetch(tagsUrl, {
1784
+ const response = await this.#fetch(tagsUrl, {
1779
1785
  headers,
1780
1786
  signal: AbortSignal.timeout(250),
1781
1787
  });
@@ -1819,7 +1825,7 @@ export class ModelRegistry {
1819
1825
  ): Promise<LlamaCppDiscoveredServerMetadata | null> {
1820
1826
  const propsUrl = `${this.#toLlamaCppNativeBaseUrl(baseUrl)}/props`;
1821
1827
  try {
1822
- const response = await fetch(propsUrl, {
1828
+ const response = await this.#fetch(propsUrl, {
1823
1829
  headers,
1824
1830
  signal: AbortSignal.timeout(150),
1825
1831
  });
@@ -1850,7 +1856,7 @@ export class ModelRegistry {
1850
1856
  }
1851
1857
 
1852
1858
  const [response, serverMetadata] = await Promise.all([
1853
- fetch(modelsUrl, {
1859
+ this.#fetch(modelsUrl, {
1854
1860
  headers,
1855
1861
  signal: AbortSignal.timeout(250),
1856
1862
  }),
@@ -1902,7 +1908,7 @@ export class ModelRegistry {
1902
1908
  headers.Authorization = `Bearer ${apiKey}`;
1903
1909
  }
1904
1910
 
1905
- const response = await fetch(modelsUrl, {
1911
+ const response = await this.#fetch(modelsUrl, {
1906
1912
  headers,
1907
1913
  signal: AbortSignal.timeout(10_000),
1908
1914
  });
@@ -1964,7 +1970,7 @@ export class ModelRegistry {
1964
1970
  headers.Authorization = `Bearer ${apiKey}`;
1965
1971
  }
1966
1972
 
1967
- const response = await fetch(modelsUrl, {
1973
+ const response = await this.#fetch(modelsUrl, {
1968
1974
  headers,
1969
1975
  signal: AbortSignal.timeout(10_000),
1970
1976
  });
@@ -118,6 +118,43 @@ function cloneModelWithRequestedId(model: Model<Api>, requestedId: string): Mode
118
118
  };
119
119
  }
120
120
 
121
+ const UPSTREAM_ROUTING_SLUG = /^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$/i;
122
+
123
+ /**
124
+ * Split a trailing `@<upstream>` provider-routing selector off a model pattern.
125
+ *
126
+ * `openrouter/z-ai/glm-4.7@cerebras` -> base `openrouter/z-ai/glm-4.7`, upstream
127
+ * `cerebras`. A `:thinking` suffix after the slug is kept on the base
128
+ * (`...@cerebras:high` -> base `...:high`). Returns undefined when there is no
129
+ * `@` or the suffix is not a bare provider slug, so model ids that legitimately
130
+ * contain `@` (`claude-opus-4-8@default`, `workers-ai/@cf/...`) are never split.
131
+ */
132
+ function splitUpstreamRouting(pattern: string): { base: string; upstream: string } | undefined {
133
+ const at = pattern.lastIndexOf("@");
134
+ if (at <= 0) return undefined;
135
+ const rest = pattern.slice(at + 1);
136
+ const colon = rest.indexOf(":");
137
+ const upstream = colon === -1 ? rest : rest.slice(0, colon);
138
+ if (!UPSTREAM_ROUTING_SLUG.test(upstream)) return undefined;
139
+ const trailing = colon === -1 ? "" : rest.slice(colon);
140
+ return { base: pattern.slice(0, at) + trailing, upstream };
141
+ }
142
+
143
+ /** OpenRouter and Vercel AI Gateway are the aggregators that honor per-request upstream routing. */
144
+ function supportsUpstreamRouting(model: Model<Api>): boolean {
145
+ return model.baseUrl.includes("openrouter.ai") || model.baseUrl.includes("ai-gateway.vercel.sh");
146
+ }
147
+
148
+ /** Pin a resolved aggregator model to a single upstream provider via its compat routing block. */
149
+ function applyUpstreamRouting(model: Model<Api>, upstream: string): Model<Api> {
150
+ const aggregatorModel = model as Model<"openai-completions">;
151
+ const routing = { only: [upstream] };
152
+ const compat = model.baseUrl.includes("ai-gateway.vercel.sh")
153
+ ? { ...aggregatorModel.compat, vercelGatewayRouting: routing }
154
+ : { ...aggregatorModel.compat, openRouterRouting: routing };
155
+ return { ...model, compat } as Model<Api>;
156
+ }
157
+
121
158
  const kProviderModelIndex = Symbol("model-resolver.providerIndex");
122
159
  type ModelsWithProviderIndex = readonly Model<Api>[] & {
123
160
  [kProviderModelIndex]?: Map<string, Model<Api> | null>;
@@ -442,6 +479,8 @@ export interface ParsedModelResult {
442
479
  model: Model<Api> | undefined;
443
480
  /** Thinking level if explicitly specified in pattern, undefined otherwise */
444
481
  thinkingLevel?: ThinkingLevel;
482
+ /** Upstream provider slug from an `@upstream` routing selector, if present. */
483
+ upstream?: string;
445
484
  warning: string | undefined;
446
485
  explicitThinkingLevel: boolean;
447
486
  }
@@ -523,7 +562,20 @@ export function parseModelPattern(
523
562
  options?: { allowInvalidThinkingSelectorFallback?: boolean; modelRegistry?: CanonicalModelRegistry },
524
563
  ): ParsedModelResult {
525
564
  const context = buildPreferenceContext(availableModels, preferences);
526
- return parseModelPatternWithContext(pattern, availableModels, context, options);
565
+ const direct = parseModelPatternWithContext(pattern, availableModels, context, options);
566
+ if (direct.model) return direct;
567
+
568
+ // No direct match: a trailing `@upstream` may be a provider-routing selector.
569
+ // Only honor it when the base resolves to an aggregator model (OpenRouter /
570
+ // Vercel Gateway); otherwise `@` stays part of the id and `direct` stands.
571
+ const routing = splitUpstreamRouting(pattern);
572
+ if (routing) {
573
+ const routed = parseModelPatternWithContext(routing.base, availableModels, context, options);
574
+ if (routed.model && supportsUpstreamRouting(routed.model)) {
575
+ return { ...routed, model: applyUpstreamRouting(routed.model, routing.upstream), upstream: routing.upstream };
576
+ }
577
+ }
578
+ return direct;
527
579
  }
528
580
 
529
581
  const PREFIX_MODEL_ROLE = "pi/";
@@ -1143,7 +1195,7 @@ export function resolveCliModel(options: {
1143
1195
  }
1144
1196
 
1145
1197
  const candidates = provider ? availableModels.filter(model => model.provider === provider) : availableModels;
1146
- const { model, thinkingLevel, warning } = parseModelPattern(pattern, candidates, preferences, {
1198
+ const { model, thinkingLevel, warning, upstream } = parseModelPattern(pattern, candidates, preferences, {
1147
1199
  allowInvalidThinkingSelectorFallback: false,
1148
1200
  modelRegistry,
1149
1201
  });
@@ -1173,6 +1225,9 @@ export function resolveCliModel(options: {
1173
1225
  }
1174
1226
  }
1175
1227
  }
1228
+ if (selector !== undefined && upstream) {
1229
+ selector = `${selector}@${upstream}`;
1230
+ }
1176
1231
 
1177
1232
  return {
1178
1233
  model,
@@ -2187,6 +2187,12 @@ export const SETTINGS_SCHEMA = {
2187
2187
  },
2188
2188
  },
2189
2189
 
2190
+ "bash.enabled": {
2191
+ type: "boolean",
2192
+ default: true,
2193
+ ui: { tab: "tools", label: "Bash", description: "Enable the bash tool for shell command execution" },
2194
+ },
2195
+
2190
2196
  // Search and AST tools
2191
2197
  "find.enabled": {
2192
2198
  type: "boolean",
@@ -66,8 +66,10 @@ async function loadTool(
66
66
  }
67
67
  }
68
68
 
69
- /** Tool path with optional source metadata */
70
- interface ToolPathWithSource {
69
+ /** Tool path with optional source metadata, suitable for forwarding from a
70
+ * parent session to a subagent so the subagent can re-bind tools to its own
71
+ * `CustomToolAPI` without redoing the filesystem scan. */
72
+ export interface ToolPathWithSource {
71
73
  path: string;
72
74
  source?: { provider: string; providerName: string; level: "user" | "project" };
73
75
  }
@@ -189,26 +191,19 @@ export async function loadCustomTools(
189
191
  }
190
192
 
191
193
  /**
192
- * Discover and load tools from standard locations via capability system:
193
- * 1. User and project tools discovered by capability providers
194
- * 2. Installed plugins (~/.omp/plugins/node_modules/*)
195
- * 3. Explicitly configured paths from settings or CLI
194
+ * Collect the absolute tool-source paths to load, without importing or
195
+ * binding factories. Hot path on session startup the scan walks
196
+ * `.omp/tools/`, `.claude/tools/`, the plugin tree, and any configured paths.
197
+ *
198
+ * Subagents reuse the parent's collected paths via the SDK's
199
+ * `preloadedCustomToolPaths` option, then call `loadCustomTools` themselves
200
+ * so each session re-binds factories with its own session-scoped
201
+ * `CustomToolAPI` (cwd, exec, pushPendingAction, UI).
196
202
  *
197
203
  * @param configuredPaths - Explicit paths from settings.json and CLI --tool flags
198
204
  * @param cwd - Current working directory
199
- * @param builtInToolNames - Names of built-in tools to check for conflicts
200
205
  */
201
- export async function discoverAndLoadCustomTools(
202
- configuredPaths: string[],
203
- cwd: string,
204
- builtInToolNames: string[],
205
- pushPendingAction?: (action: {
206
- label: string;
207
- sourceToolName: string;
208
- apply(reason: string): Promise<AgentToolResult<unknown>>;
209
- reject?(reason: string): Promise<AgentToolResult<unknown> | undefined>;
210
- }) => void,
211
- ) {
206
+ export async function discoverCustomToolPaths(configuredPaths: string[], cwd: string): Promise<ToolPathWithSource[]> {
212
207
  const allPathsWithSources: ToolPathWithSource[] = [];
213
208
  const seen = new Set<string>();
214
209
 
@@ -241,5 +236,34 @@ export async function discoverAndLoadCustomTools(
241
236
  addPath(resolvePath(configPath, cwd), { provider: "config", providerName: "Config", level: "project" });
242
237
  }
243
238
 
244
- return loadCustomTools(allPathsWithSources, cwd, builtInToolNames, pushPendingAction);
239
+ return allPathsWithSources;
240
+ }
241
+
242
+ /**
243
+ * Discover and load tools from standard locations via capability system:
244
+ * 1. User and project tools discovered by capability providers
245
+ * 2. Installed plugins (~/.omp/plugins/node_modules/*)
246
+ * 3. Explicitly configured paths from settings or CLI
247
+ *
248
+ * Composed of {@link discoverCustomToolPaths} (FS scan) + {@link loadCustomTools}
249
+ * (per-session binding). Subagents skip the first step and just call
250
+ * `loadCustomTools` against the parent's collected paths.
251
+ *
252
+ * @param configuredPaths - Explicit paths from settings.json and CLI --tool flags
253
+ * @param cwd - Current working directory
254
+ * @param builtInToolNames - Names of built-in tools to check for conflicts
255
+ */
256
+ export async function discoverAndLoadCustomTools(
257
+ configuredPaths: string[],
258
+ cwd: string,
259
+ builtInToolNames: string[],
260
+ pushPendingAction?: (action: {
261
+ label: string;
262
+ sourceToolName: string;
263
+ apply(reason: string): Promise<AgentToolResult<unknown>>;
264
+ reject?(reason: string): Promise<AgentToolResult<unknown> | undefined>;
265
+ }) => void,
266
+ ) {
267
+ const pathsWithSources = await discoverCustomToolPaths(configuredPaths, cwd);
268
+ return loadCustomTools(pathsWithSources, cwd, builtInToolNames, pushPendingAction);
245
269
  }
@@ -12,7 +12,7 @@ import type {
12
12
  ToolTier,
13
13
  } from "@oh-my-pi/pi-agent-core";
14
14
  import type { CompactionResult } from "@oh-my-pi/pi-agent-core/compaction";
15
- import type { Model, Static, TSchema } from "@oh-my-pi/pi-ai";
15
+ import type { FetchImpl, Model, Static, TSchema } from "@oh-my-pi/pi-ai";
16
16
  import type { Component } from "@oh-my-pi/pi-tui";
17
17
  import type { Rule } from "../../capability/rule";
18
18
  import type { ModelRegistry } from "../../config/model-registry";
@@ -86,6 +86,8 @@ export interface CustomToolContext {
86
86
  abort(): void;
87
87
  /** Settings instance for the current session. Prefer over the global singleton. */
88
88
  settings?: Settings;
89
+ /** Fetch implementation for outbound HTTP; defaults to global fetch when omitted. */
90
+ fetch?: FetchImpl;
89
91
  /** Whether to auto-approve all destructive tool operations (--auto-approve CLI flag) */
90
92
  autoApprove?: boolean;
91
93
  }
@@ -5,6 +5,7 @@
5
5
  export type { SlashCommandInfo, SlashCommandLocation, SlashCommandSource } from "../slash-commands";
6
6
  export {
7
7
  discoverAndLoadExtensions,
8
+ discoverExtensionPaths,
8
9
  ExtensionRuntimeNotInitializedError,
9
10
  loadExtensionFromFactory,
10
11
  loadExtensions,
@@ -475,16 +475,24 @@ async function discoverExtensionsInDir(dir: string): Promise<string[]> {
475
475
 
476
476
  return discovered;
477
477
  }
478
-
479
478
  /**
480
- * Discover and load extensions from standard locations.
479
+ * Discover absolute paths of extensions to load, without importing or
480
+ * binding factories. Hot path on session startup — the scan walks native
481
+ * `.omp`/`.pi` extension capabilities, the installed-plugin tree, and any
482
+ * configured paths.
483
+ *
484
+ * Subagents reuse the parent's collected paths via the SDK's
485
+ * `preloadedExtensionPaths` option, then call {@link loadExtensions} themselves
486
+ * so each session rebuilds Extension instances bound to its OWN
487
+ * `ExtensionAPI` (cwd, eventBus, runtime). Forwarding the parent's
488
+ * `LoadExtensionsResult` directly would reuse handlers/tools/commands that
489
+ * closed over the parent's `cwd` and event bus.
481
490
  */
482
- export async function discoverAndLoadExtensions(
491
+ export async function discoverExtensionPaths(
483
492
  configuredPaths: string[],
484
493
  cwd: string,
485
- eventBus?: EventBus,
486
494
  disabledExtensionIds: string[] = [],
487
- ): Promise<LoadExtensionsResult> {
495
+ ): Promise<string[]> {
488
496
  const allPaths: string[] = [];
489
497
  const seen = new Set<string>();
490
498
  const disabled = new Set(disabledExtensionIds);
@@ -545,5 +553,20 @@ export async function discoverAndLoadExtensions(
545
553
  addPath(resolved);
546
554
  }
547
555
 
548
- return loadExtensions(allPaths, cwd, eventBus);
556
+ return allPaths;
557
+ }
558
+
559
+ /**
560
+ * Discover and load extensions from standard locations. Composed of
561
+ * {@link discoverExtensionPaths} (FS scan) + {@link loadExtensions}
562
+ * (per-session binding).
563
+ */
564
+ export async function discoverAndLoadExtensions(
565
+ configuredPaths: string[],
566
+ cwd: string,
567
+ eventBus?: EventBus,
568
+ disabledExtensionIds: string[] = [],
569
+ ): Promise<LoadExtensionsResult> {
570
+ const paths = await discoverExtensionPaths(configuredPaths, cwd, disabledExtensionIds);
571
+ return loadExtensions(paths, cwd, eventBus);
549
572
  }