@oh-my-pi/pi-coding-agent 15.10.8 → 15.10.10

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 (52) hide show
  1. package/CHANGELOG.md +41 -1
  2. package/dist/types/config/model-registry.d.ts +13 -0
  3. package/dist/types/config/settings-schema.d.ts +0 -9
  4. package/dist/types/debug/terminal-info.d.ts +0 -1
  5. package/dist/types/extensibility/custom-tools/loader.d.ts +22 -3
  6. package/dist/types/extensibility/extensions/index.d.ts +1 -1
  7. package/dist/types/extensibility/extensions/loader.d.ts +17 -1
  8. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +8 -0
  9. package/dist/types/mcp/transports/stdio.d.ts +12 -0
  10. package/dist/types/modes/components/custom-editor.d.ts +3 -2
  11. package/dist/types/modes/components/transcript-container.d.ts +12 -26
  12. package/dist/types/sdk.d.ts +42 -2
  13. package/dist/types/task/discovery.d.ts +1 -2
  14. package/dist/types/task/executor.d.ts +16 -0
  15. package/dist/types/tiny/title-client.d.ts +1 -1
  16. package/dist/types/tools/index.d.ts +17 -0
  17. package/dist/types/tools/todo.d.ts +2 -0
  18. package/dist/types/tui/hyperlink.d.ts +8 -0
  19. package/package.json +9 -9
  20. package/src/cli/list-models.ts +5 -11
  21. package/src/config/model-registry.ts +91 -20
  22. package/src/config/settings-schema.ts +0 -10
  23. package/src/debug/terminal-info.ts +0 -3
  24. package/src/edit/diff.ts +48 -15
  25. package/src/eval/js/shared/rewrite-imports.ts +9 -1
  26. package/src/extensibility/custom-tools/loader.ts +43 -19
  27. package/src/extensibility/extensions/index.ts +1 -0
  28. package/src/extensibility/extensions/loader.ts +29 -6
  29. package/src/extensibility/plugins/legacy-pi-compat.ts +30 -6
  30. package/src/internal-urls/docs-index.generated.ts +4 -4
  31. package/src/mcp/transports/stdio.ts +139 -3
  32. package/src/modes/components/custom-editor.ts +69 -9
  33. package/src/modes/components/model-selector.ts +62 -52
  34. package/src/modes/components/transcript-container.ts +204 -125
  35. package/src/modes/controllers/event-controller.ts +0 -45
  36. package/src/modes/controllers/input-controller.ts +5 -5
  37. package/src/modes/controllers/mcp-command-controller.ts +2 -2
  38. package/src/modes/controllers/selector-controller.ts +0 -4
  39. package/src/modes/interactive-mode.ts +2 -10
  40. package/src/prompts/system/system-prompt.md +3 -3
  41. package/src/prompts/tools/bash.md +3 -3
  42. package/src/prompts/tools/todo.md +5 -1
  43. package/src/sdk.ts +138 -56
  44. package/src/ssh/ssh-executor.ts +60 -4
  45. package/src/task/discovery.ts +17 -24
  46. package/src/task/executor.ts +19 -0
  47. package/src/task/index.ts +4 -0
  48. package/src/tiny/title-client.ts +6 -3
  49. package/src/tools/index.ts +17 -0
  50. package/src/tools/todo.ts +16 -7
  51. package/src/tui/hyperlink.ts +27 -3
  52. package/src/web/search/providers/anthropic.ts +8 -2
@@ -428,6 +428,12 @@ export interface CanonicalModelQueryOptions {
428
428
  candidates?: readonly Model<Api>[];
429
429
  }
430
430
 
431
+ /** A canonical record (with query-filtered variants) plus the variant model selected for it. */
432
+ export interface CanonicalModelSelection {
433
+ record: CanonicalModelRecord;
434
+ model: Model<Api>;
435
+ }
436
+
431
437
  /** Result of loading custom models from models.json */
432
438
  interface CustomModelsResult {
433
439
  models?: CustomModelOverlay[];
@@ -2217,48 +2223,81 @@ export class ModelRegistry {
2217
2223
  return this.#models;
2218
2224
  }
2219
2225
 
2220
- #isModelAvailable(model: Model<Api>): boolean {
2226
+ /**
2227
+ * Availability predicate with per-provider memoization. Auth lookups
2228
+ * (`authStorage.hasAuth`) and the disabled-provider set are resolved once
2229
+ * per provider instead of once per model, which matters when filtering the
2230
+ * full bundled catalog (thousands of models, ~50 providers).
2231
+ */
2232
+ #createAvailabilityCheck(): (model: Model<Api>) => boolean {
2221
2233
  const disabledProviders = getDisabledProviderIdsFromSettings();
2222
- return (
2223
- !disabledProviders.has(model.provider) &&
2224
- (this.#keylessProviders.has(model.provider) || this.authStorage.hasAuth(model.provider))
2225
- );
2234
+ const byProvider = new Map<string, boolean>();
2235
+ return model => {
2236
+ let available = byProvider.get(model.provider);
2237
+ if (available === undefined) {
2238
+ available =
2239
+ !disabledProviders.has(model.provider) &&
2240
+ (this.#keylessProviders.has(model.provider) || this.authStorage.hasAuth(model.provider));
2241
+ byProvider.set(model.provider, available);
2242
+ }
2243
+ return available;
2244
+ };
2245
+ }
2246
+
2247
+ /**
2248
+ * Build the shared per-query filter state for canonical model queries.
2249
+ * Hoisted out of the per-record loop: building the candidate-selector set
2250
+ * and availability memo once per query instead of once per record is what
2251
+ * keeps `getCanonicalModelSelections` linear instead of O(records × candidates).
2252
+ */
2253
+ #canonicalQueryFilters(options: CanonicalModelQueryOptions | undefined): {
2254
+ candidateKeys: Set<string> | undefined;
2255
+ isAvailable: ((model: Model<Api>) => boolean) | undefined;
2256
+ } {
2257
+ return {
2258
+ candidateKeys: options?.candidates
2259
+ ? new Set(options.candidates.map(candidate => formatCanonicalVariantSelector(candidate)))
2260
+ : undefined,
2261
+ isAvailable: options?.availableOnly ? this.#createAvailabilityCheck() : undefined,
2262
+ };
2226
2263
  }
2227
2264
 
2228
2265
  #filterCanonicalVariants(
2229
2266
  record: CanonicalModelRecord,
2230
- options: CanonicalModelQueryOptions | undefined,
2267
+ candidateKeys: ReadonlySet<string> | undefined,
2268
+ isAvailable: ((model: Model<Api>) => boolean) | undefined,
2231
2269
  ): CanonicalModelVariant[] {
2232
- const candidateKeys = options?.candidates
2233
- ? new Set(options.candidates.map(candidate => formatCanonicalVariantSelector(candidate)))
2234
- : undefined;
2235
2270
  return record.variants.filter(variant => {
2236
2271
  if (candidateKeys && !candidateKeys.has(variant.selector)) {
2237
2272
  return false;
2238
2273
  }
2239
- if (options?.availableOnly && !this.#isModelAvailable(variant.model)) {
2274
+ if (isAvailable && !isAvailable(variant.model)) {
2240
2275
  return false;
2241
2276
  }
2242
2277
  return true;
2243
2278
  });
2244
2279
  }
2245
2280
 
2281
+ #buildModelOrder(candidates: readonly Model<Api>[]): Map<string, number> {
2282
+ const modelOrder = new Map<string, number>();
2283
+ for (let index = 0; index < candidates.length; index += 1) {
2284
+ modelOrder.set(formatCanonicalVariantSelector(candidates[index]!), index);
2285
+ }
2286
+ return modelOrder;
2287
+ }
2288
+
2246
2289
  #providerRank(): Map<string, number> {
2247
2290
  return buildModelProviderPriorityRank(getConfiguredProviderOrderFromSettings());
2248
2291
  }
2249
2292
 
2250
2293
  #resolveCanonicalVariant(
2251
2294
  variants: readonly CanonicalModelVariant[],
2252
- allCandidates: readonly Model<Api>[],
2295
+ modelOrder: ReadonlyMap<string, number>,
2296
+ providerRank: ReadonlyMap<string, number>,
2253
2297
  ): CanonicalModelVariant | undefined {
2254
2298
  if (variants.length === 0) {
2255
2299
  return undefined;
2256
2300
  }
2257
- const providerRank = this.#providerRank();
2258
- const modelOrder = new Map<string, number>();
2259
- for (let index = 0; index < allCandidates.length; index += 1) {
2260
- modelOrder.set(formatCanonicalVariantSelector(allCandidates[index]!), index);
2261
- }
2262
2301
  const sourceRank: Record<CanonicalModelVariant["source"], number> = {
2263
2302
  override: 1,
2264
2303
  bundled: 1,
@@ -2289,9 +2328,10 @@ export class ModelRegistry {
2289
2328
  }
2290
2329
 
2291
2330
  getCanonicalModels(options?: CanonicalModelQueryOptions): CanonicalModelRecord[] {
2331
+ const { candidateKeys, isAvailable } = this.#canonicalQueryFilters(options);
2292
2332
  const records: CanonicalModelRecord[] = [];
2293
2333
  for (const record of this.#canonicalIndex.records) {
2294
- const variants = this.#filterCanonicalVariants(record, options);
2334
+ const variants = this.#filterCanonicalVariants(record, candidateKeys, isAvailable);
2295
2335
  if (variants.length === 0) {
2296
2336
  continue;
2297
2337
  }
@@ -2304,12 +2344,43 @@ export class ModelRegistry {
2304
2344
  return records;
2305
2345
  }
2306
2346
 
2347
+ /**
2348
+ * One-pass equivalent of `getCanonicalModels` + `resolveCanonicalModel` per
2349
+ * record. The per-query state (candidate-selector set, availability memo,
2350
+ * provider rank, candidate order) is built once, so the whole catalog
2351
+ * resolves in O(records + candidates) instead of O(records × candidates).
2352
+ * This is the path the model selector hydrates from synchronously on open.
2353
+ */
2354
+ getCanonicalModelSelections(options?: CanonicalModelQueryOptions): CanonicalModelSelection[] {
2355
+ const { candidateKeys, isAvailable } = this.#canonicalQueryFilters(options);
2356
+ const candidates = options?.candidates ?? (options?.availableOnly ? this.getAvailable() : this.getAll());
2357
+ const modelOrder = this.#buildModelOrder(candidates);
2358
+ const providerRank = this.#providerRank();
2359
+ const selections: CanonicalModelSelection[] = [];
2360
+ for (const record of this.#canonicalIndex.records) {
2361
+ const variants = this.#filterCanonicalVariants(record, candidateKeys, isAvailable);
2362
+ if (variants.length === 0) {
2363
+ continue;
2364
+ }
2365
+ const resolved = this.#resolveCanonicalVariant(variants, modelOrder, providerRank);
2366
+ if (!resolved) {
2367
+ continue;
2368
+ }
2369
+ selections.push({
2370
+ record: { id: record.id, name: record.name, variants },
2371
+ model: resolved.model,
2372
+ });
2373
+ }
2374
+ return selections;
2375
+ }
2376
+
2307
2377
  getCanonicalVariants(canonicalId: string, options?: CanonicalModelQueryOptions): CanonicalModelVariant[] {
2308
2378
  const record = this.#canonicalIndex.byId.get(canonicalId.trim().toLowerCase());
2309
2379
  if (!record) {
2310
2380
  return [];
2311
2381
  }
2312
- return this.#filterCanonicalVariants(record, options);
2382
+ const { candidateKeys, isAvailable } = this.#canonicalQueryFilters(options);
2383
+ return this.#filterCanonicalVariants(record, candidateKeys, isAvailable);
2313
2384
  }
2314
2385
 
2315
2386
  resolveCanonicalModel(canonicalId: string, options?: CanonicalModelQueryOptions): Model<Api> | undefined {
@@ -2318,7 +2389,7 @@ export class ModelRegistry {
2318
2389
  return undefined;
2319
2390
  }
2320
2391
  const candidates = options?.candidates ?? (options?.availableOnly ? this.getAvailable() : this.getAll());
2321
- return this.#resolveCanonicalVariant(variants, candidates)?.model;
2392
+ return this.#resolveCanonicalVariant(variants, this.#buildModelOrder(candidates), this.#providerRank())?.model;
2322
2393
  }
2323
2394
 
2324
2395
  getCanonicalId(model: Model<Api>): string | undefined {
@@ -2330,7 +2401,7 @@ export class ModelRegistry {
2330
2401
  * This is a fast check that doesn't refresh OAuth tokens.
2331
2402
  */
2332
2403
  getAvailable(): Model<Api>[] {
2333
- return this.#models.filter(model => this.#isModelAvailable(model));
2404
+ return this.#models.filter(this.#createAvailabilityCheck());
2334
2405
  }
2335
2406
 
2336
2407
  /**
@@ -686,16 +686,6 @@ export const SETTINGS_SCHEMA = {
686
686
  ui: { tab: "appearance", label: "Show Hardware Cursor", description: "Show terminal cursor for IME support" },
687
687
  },
688
688
 
689
- clearOnShrink: {
690
- type: "boolean",
691
- default: false,
692
- ui: {
693
- tab: "appearance",
694
- label: "Clear on Shrink",
695
- description: "Clear empty rows when content shrinks (may cause flicker)",
696
- },
697
- },
698
-
699
689
  // ────────────────────────────────────────────────────────────────────────
700
690
  // Model
701
691
  // ────────────────────────────────────────────────────────────────────────
@@ -36,7 +36,6 @@ export interface TerminalStateInfo {
36
36
  hyperlinks: boolean;
37
37
  deccara: boolean;
38
38
  screenToScrollback: boolean;
39
- eagerEraseScrollbackRisk: boolean;
40
39
  synchronizedOutput: boolean;
41
40
  multiplexer: string | null;
42
41
  env: { TERM?: string; TERM_PROGRAM?: string; TERM_PROGRAM_VERSION?: string; COLORTERM?: string };
@@ -82,7 +81,6 @@ export function collectTerminalState(runtime: TerminalRuntimeState): TerminalSta
82
81
  hyperlinks: TERMINAL.hyperlinks,
83
82
  deccara: TERMINAL.deccara,
84
83
  screenToScrollback: TERMINAL.supportsScreenToScrollback,
85
- eagerEraseScrollbackRisk: TERMINAL.eagerEraseScrollbackRisk,
86
84
  synchronizedOutput: runtime.synchronizedOutput,
87
85
  multiplexer: detectMultiplexer(env),
88
86
  env: {
@@ -115,7 +113,6 @@ export function formatTerminalState(info: TerminalStateInfo): string {
115
113
  "",
116
114
  "Scrollback",
117
115
  ` Screen->history clear: ${info.screenToScrollback ? "CSI 22 J" : "CSI 2 J (redraw)"}`,
118
- ` Eager-erase risk: ${yesNo(info.eagerEraseScrollbackRisk)} (ED3 may yank scrolled readers)`,
119
116
  "",
120
117
  "Detection signals",
121
118
  ` TERM: ${info.env.TERM ?? "(unset)"}`,
package/src/edit/diff.ts CHANGED
@@ -55,13 +55,10 @@ function formatNumberedDiffLine(prefix: "+" | "-" | " ", lineNum: number, conten
55
55
  return `${prefix}${lineNum}|${content}`;
56
56
  }
57
57
 
58
- type DiffSource = "old" | "new";
59
-
60
58
  interface ParsedNumberedDiffRow {
61
59
  prefix: "+" | "-" | " ";
62
60
  lineNumber: number;
63
61
  content: string;
64
- source: DiffSource;
65
62
  }
66
63
 
67
64
  function parseNumberedDiffRow(row: string): ParsedNumberedDiffRow | undefined {
@@ -70,12 +67,7 @@ function parseNumberedDiffRow(row: string): ParsedNumberedDiffRow | undefined {
70
67
  const prefix = match[1] as "+" | "-" | " ";
71
68
  const lineNumber = Number.parseInt(match[2], 10);
72
69
  if (!Number.isFinite(lineNumber)) return undefined;
73
- return {
74
- prefix,
75
- lineNumber,
76
- content: match[3] ?? "",
77
- source: prefix === "+" ? "new" : "old",
78
- };
70
+ return { prefix, lineNumber, content: match[3] ?? "" };
79
71
  }
80
72
 
81
73
  function isDiffChangeRow(row: string | undefined): boolean {
@@ -92,7 +84,6 @@ function adjustedContextInsertIndex(rows: readonly string[], index: number): num
92
84
 
93
85
  function insertBracketContextRows(
94
86
  rows: string[],
95
- source: DiffSource,
96
87
  contextLines: ReadonlyMap<number, string>,
97
88
  seenRows: Set<string>,
98
89
  ): void {
@@ -106,7 +97,7 @@ function insertBracketContextRows(
106
97
  let nextSourceLine: number | undefined;
107
98
  for (let i = 0; i < rows.length; i++) {
108
99
  const parsed = parseNumberedDiffRow(rows[i]);
109
- if (!parsed || parsed.source !== source) continue;
100
+ if (!parsed || parsed.prefix === "+") continue;
110
101
  if (parsed.lineNumber < lineNumber) {
111
102
  previousSourceLine = parsed.lineNumber;
112
103
  continue;
@@ -127,6 +118,16 @@ function insertBracketContextRows(
127
118
  }
128
119
  }
129
120
 
121
+ /**
122
+ * Insert off-window block-boundary rows (enclosing header, matching closing
123
+ * bracket, …) into a numbered diff. Context rows carry pre-edit line numbers —
124
+ * the renumbering contract of `buildCompactDiffPreview` — so boundary lines
125
+ * discovered in the new file are translated back to their pre-edit numbers
126
+ * and merged with the old-file pass before a single insertion sweep. Without
127
+ * the translation, a context line sitting in a net-offset region would be
128
+ * re-inserted under its post-edit number: duplicated, out of order, and
129
+ * renumbered incorrectly by the preview.
130
+ */
130
131
  function addMatchingBracketContextRows(
131
132
  rows: string[],
132
133
  oldLines: readonly string[],
@@ -136,16 +137,48 @@ function addMatchingBracketContextRows(
136
137
  const oldVisible: number[] = [];
137
138
  const newVisible: number[] = [];
138
139
  const seenRows = new Set(rows);
140
+ // Change positions in new-file coordinates, used to translate an unchanged
141
+ // new-file line number back to its pre-edit equivalent.
142
+ const changes: { newPos: number; delta: 1 | -1 }[] = [];
143
+ let offset = 0;
139
144
 
140
145
  for (const row of rows) {
141
146
  const parsed = parseNumberedDiffRow(row);
142
147
  if (!parsed) continue;
143
- if (parsed.source === "old") oldVisible.push(parsed.lineNumber);
144
- else newVisible.push(parsed.lineNumber);
148
+ switch (parsed.prefix) {
149
+ case "-":
150
+ oldVisible.push(parsed.lineNumber);
151
+ changes.push({ newPos: parsed.lineNumber + offset, delta: -1 });
152
+ offset--;
153
+ break;
154
+ case "+":
155
+ newVisible.push(parsed.lineNumber);
156
+ changes.push({ newPos: parsed.lineNumber, delta: 1 });
157
+ offset++;
158
+ break;
159
+ default:
160
+ // Context rows are visible in BOTH files: pre-edit number as
161
+ // written, post-edit number shifted by the net change so far.
162
+ oldVisible.push(parsed.lineNumber);
163
+ newVisible.push(parsed.lineNumber + offset);
164
+ break;
165
+ }
145
166
  }
146
167
 
147
- insertBracketContextRows(rows, "old", findBlockContextLines(oldLines, oldVisible, source), seenRows);
148
- insertBracketContextRows(rows, "new", findBlockContextLines(newLines, newVisible, source), seenRows);
168
+ const toOldLineNumber = (newLineNumber: number): number => {
169
+ let shift = 0;
170
+ for (const change of changes) {
171
+ if (change.newPos <= newLineNumber) shift += change.delta;
172
+ }
173
+ return newLineNumber - shift;
174
+ };
175
+
176
+ const contextRows = findBlockContextLines(oldLines, oldVisible, source);
177
+ for (const [lineNumber, text] of findBlockContextLines(newLines, newVisible, source)) {
178
+ const oldLineNumber = toOldLineNumber(lineNumber);
179
+ if (!contextRows.has(oldLineNumber)) contextRows.set(oldLineNumber, text);
180
+ }
181
+ insertBracketContextRows(rows, contextRows, seenRows);
149
182
  }
150
183
 
151
184
  /**
@@ -82,6 +82,14 @@ function parseProgram(code: string): { program: { body: ReadonlyArray<BabelProgr
82
82
  }
83
83
  }
84
84
 
85
+ // Callee substituted for dynamic `import(...)` calls. Functions handed to puppeteer
86
+ // (`tab.evaluate`, `page.evaluate`, `waitForFunction`, `$$eval`, ...) are serialized with
87
+ // `Function.prototype.toString()` and re-evaluated inside the browser page, where the
88
+ // worker-injected `__omp_import__` global does not exist. The swap therefore guards on the
89
+ // helper's presence and falls back to native dynamic import, so serialized code keeps
90
+ // working in foreign realms while in-worker code still resolves against the session cwd.
91
+ const DYNAMIC_IMPORT_CALLEE = '(typeof __omp_import__ === "function" ? __omp_import__ : (s, o) => import(s, o))';
92
+
85
93
  function buildOmpImportCall(sourceLiteral: string, optionsLiteral: string | undefined): string {
86
94
  // Route every static import through the worker-injected `__omp_import__` helper so the
87
95
  // specifier resolves against the session cwd (and `with`-attribute imports keep working).
@@ -180,7 +188,7 @@ export function rewriteImports(code: string): string {
180
188
  const call = node as unknown as { callee?: { type?: string; start?: number; end?: number } };
181
189
  const callee = call.callee;
182
190
  if (callee?.type !== "Import" || typeof callee.start !== "number" || typeof callee.end !== "number") return;
183
- edits.push({ start: callee.start, end: callee.end, text: "__omp_import__" });
191
+ edits.push({ start: callee.start, end: callee.end, text: DYNAMIC_IMPORT_CALLEE });
184
192
  });
185
193
 
186
194
  if (edits.length === 0) return code;
@@ -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
  }
@@ -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
  }
@@ -1,4 +1,4 @@
1
- import * as fs from "node:fs/promises";
1
+ import * as fs from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import * as url from "node:url";
4
4
  import { isCompiledBinary } from "@oh-my-pi/pi-utils";
@@ -142,7 +142,31 @@ const LEGACY_PI_CODING_AGENT_SHIM_PATH = BUNFS_PACKAGE_ROOT
142
142
  // `Bun.resolveSync`, and hardcoding a relative source-tree path would break
143
143
  // installs where the bundled packages live at `node_modules/@oh-my-pi/pi-*`
144
144
  // rather than `packages/*`.
145
- const LEGACY_PI_PACKAGE_ROOT_OVERRIDES: Record<string, string> = {
145
+ //
146
+ // Every override target is validated against the on-disk filesystem at module
147
+ // init: any entry whose file is missing (e.g. a compiled binary where Bun's
148
+ // `--compile` quietly dropped an additional entrypoint — issue #2168) is left
149
+ // out so `resolveCanonicalPiSpecifier` falls through to `getResolvedSpecifier`,
150
+ // which throws under bunfs and triggers the catch in `rewriteLegacyPiImports`.
151
+ // That catch leaves the specifier untouched so Bun resolves the canonical
152
+ // `@oh-my-pi/pi-*` import from the extension's own `node_modules` instead of
153
+ // emitting a bunfs `file://` URL to a module that isn't actually present.
154
+
155
+ /**
156
+ * Drop overrides whose targets are missing on disk so they can fall through to
157
+ * the canonical-resolution path. Exported for the test seam in #2168.
158
+ *
159
+ * `pathExistsSync` defaults to `fs.existsSync`; the tests inject a stub to
160
+ * simulate the missing-entrypoint failure mode without touching the real FS.
161
+ */
162
+ export function __validateLegacyPiPackageRootOverrides(
163
+ candidates: Record<string, string>,
164
+ pathExistsSync: (p: string) => boolean = fs.existsSync,
165
+ ): Record<string, string> {
166
+ return Object.fromEntries(Object.entries(candidates).filter(([, candidate]) => pathExistsSync(candidate)));
167
+ }
168
+
169
+ const LEGACY_PI_PACKAGE_ROOT_OVERRIDES = __validateLegacyPiPackageRootOverrides({
146
170
  [`${CANONICAL_PI_SCOPE}/pi-ai`]: LEGACY_PI_AI_SHIM_PATH,
147
171
  [`${CANONICAL_PI_SCOPE}/pi-coding-agent`]: LEGACY_PI_CODING_AGENT_SHIM_PATH,
148
172
  ...(BUNFS_PACKAGE_ROOT
@@ -153,7 +177,7 @@ const LEGACY_PI_PACKAGE_ROOT_OVERRIDES: Record<string, string> = {
153
177
  [`${CANONICAL_PI_SCOPE}/pi-utils`]: bunfsPath("utils", "src", "index.js"),
154
178
  }
155
179
  : {}),
156
- };
180
+ });
157
181
 
158
182
  let isLegacyPiSpecifierShimInstalled = false;
159
183
 
@@ -253,7 +277,7 @@ function isRecord(value: unknown): value is Record<string, unknown> {
253
277
 
254
278
  async function pathExists(p: string): Promise<boolean> {
255
279
  try {
256
- await fs.stat(p);
280
+ await fs.promises.stat(p);
257
281
  return true;
258
282
  } catch {
259
283
  return false;
@@ -267,7 +291,7 @@ function hasSourceModuleExtension(p: string): boolean {
267
291
 
268
292
  async function resolveSourceModuleFile(basePath: string): Promise<string | null> {
269
293
  try {
270
- const stats = await fs.stat(basePath);
294
+ const stats = await fs.promises.stat(basePath);
271
295
  if (stats.isFile()) {
272
296
  // Non-source files (JSON, WASM, text assets, etc.) bypass the on-load
273
297
  // rewrite hook so Bun's native loaders handle them; our hook would
@@ -475,7 +499,7 @@ const hookedExtensionEntries = new Set<string>();
475
499
  /** Resolve symlinks in a path, falling back to the input if realpath fails. */
476
500
  async function realpathOrSelf(p: string): Promise<string> {
477
501
  try {
478
- return await fs.realpath(p);
502
+ return await fs.promises.realpath(p);
479
503
  } catch {
480
504
  return p;
481
505
  }