@oh-my-pi/pi-coding-agent 16.1.16 → 16.1.18

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 (75) hide show
  1. package/CHANGELOG.md +49 -0
  2. package/dist/cli.js +16646 -16608
  3. package/dist/types/auto-thinking/classifier.d.ts +4 -2
  4. package/dist/types/cli/usage-cli.d.ts +14 -0
  5. package/dist/types/commands/token.d.ts +9 -0
  6. package/dist/types/config/model-discovery.d.ts +1 -0
  7. package/dist/types/config/model-registry.d.ts +4 -0
  8. package/dist/types/extensibility/plugins/legacy-pi-bundled-registry.d.ts +7 -0
  9. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +34 -15
  10. package/dist/types/extensibility/plugins/loader.d.ts +16 -9
  11. package/dist/types/extensibility/tool-event-input.d.ts +2 -0
  12. package/dist/types/hindsight/content.d.ts +2 -10
  13. package/dist/types/modes/components/chat-transcript-builder.d.ts +2 -0
  14. package/dist/types/modes/components/custom-editor.d.ts +3 -0
  15. package/dist/types/modes/components/extensions/extension-dashboard.d.ts +26 -10
  16. package/dist/types/modes/components/extensions/extension-list.d.ts +13 -0
  17. package/dist/types/modes/components/status-line/types.d.ts +1 -0
  18. package/dist/types/modes/controllers/command-controller.d.ts +2 -0
  19. package/dist/types/modes/controllers/input-controller.d.ts +14 -0
  20. package/dist/types/modes/controllers/selector-controller.d.ts +11 -0
  21. package/dist/types/session/agent-session.d.ts +6 -6
  22. package/dist/types/session/provider-image-budget.d.ts +3 -0
  23. package/dist/types/session/session-context.d.ts +6 -5
  24. package/dist/types/task/parallel.d.ts +4 -0
  25. package/dist/types/thinking.d.ts +8 -1
  26. package/dist/types/tiny/title-client.d.ts +2 -2
  27. package/dist/types/utils/tools-manager.d.ts +2 -0
  28. package/package.json +12 -12
  29. package/scripts/build-binary.ts +9 -16
  30. package/src/auto-thinking/classifier.ts +7 -2
  31. package/src/cli/profile-alias.ts +38 -7
  32. package/src/cli/usage-cli.ts +40 -5
  33. package/src/commands/token.ts +54 -0
  34. package/src/config/model-discovery.ts +59 -8
  35. package/src/config/model-registry.ts +74 -3
  36. package/src/discovery/omp-extension-roots.ts +1 -3
  37. package/src/extensibility/extensions/wrapper.ts +3 -2
  38. package/src/extensibility/hooks/tool-wrapper.ts +4 -3
  39. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +40 -0
  40. package/src/extensibility/plugins/legacy-pi-compat.ts +240 -90
  41. package/src/extensibility/plugins/loader.ts +71 -23
  42. package/src/extensibility/plugins/marketplace/manager.ts +134 -0
  43. package/src/extensibility/tool-event-input.ts +57 -0
  44. package/src/hindsight/content.ts +21 -12
  45. package/src/mcp/tool-bridge.ts +27 -2
  46. package/src/mnemopi/state.ts +5 -2
  47. package/src/modes/components/agent-transcript-viewer.ts +193 -41
  48. package/src/modes/components/chat-transcript-builder.ts +6 -0
  49. package/src/modes/components/custom-editor.test.ts +18 -1
  50. package/src/modes/components/custom-editor.ts +77 -45
  51. package/src/modes/components/extensions/extension-dashboard.ts +221 -165
  52. package/src/modes/components/extensions/extension-list.ts +66 -33
  53. package/src/modes/components/hook-editor.ts +15 -2
  54. package/src/modes/components/settings-selector.ts +2 -2
  55. package/src/modes/components/status-line/component.ts +52 -8
  56. package/src/modes/components/status-line/segments.ts +5 -1
  57. package/src/modes/components/status-line/types.ts +1 -0
  58. package/src/modes/components/welcome.ts +12 -14
  59. package/src/modes/controllers/command-controller.ts +16 -5
  60. package/src/modes/controllers/input-controller.ts +115 -3
  61. package/src/modes/controllers/selector-controller.ts +23 -1
  62. package/src/modes/interactive-mode.ts +3 -3
  63. package/src/modes/utils/ui-helpers.ts +3 -3
  64. package/src/sdk.ts +8 -10
  65. package/src/session/agent-session.ts +193 -49
  66. package/src/session/provider-image-budget.ts +86 -0
  67. package/src/session/session-context.ts +14 -7
  68. package/src/session/session-storage.ts +24 -2
  69. package/src/session/snapcompact-inline.ts +19 -3
  70. package/src/slash-commands/builtin-registry.ts +0 -22
  71. package/src/slash-commands/helpers/usage-report.ts +9 -1
  72. package/src/task/parallel.ts +6 -1
  73. package/src/thinking.ts +9 -2
  74. package/src/tiny/title-client.ts +75 -21
  75. package/src/utils/tools-manager.ts +67 -10
@@ -5,6 +5,144 @@ import { isCompiledBinary } from "@oh-my-pi/pi-utils";
5
5
 
6
6
  const IS_COMPILED_BINARY = isCompiledBinary();
7
7
 
8
+ // === Bundled host-package registry (issue #3423) ===
9
+ //
10
+ // Bun 1.3.14 stopped exposing `--compile` extras through any filesystem-style
11
+ // API: `fs.existsSync`, `Bun.file().exists()`, `Bun.resolveSync`, and even
12
+ // `import("/$bunfs/...")` / `import("file:///$bunfs/...")` all fail for the
13
+ // embedded entries — only the main binary itself answers from
14
+ // `/$bunfs/root/<binary-name>`. The previous strategy of rewriting
15
+ // `@(scope)/pi-*` imports to a `file:///$bunfs/...` URL therefore breaks
16
+ // every legacy extension in compiled mode (issue #3423; see also issues
17
+ // #3329, #2168). Bun.plugin `onResolve` for bare specifiers also no longer
18
+ // fires for transitive imports inside runtime-loaded extensions, so the
19
+ // fallback hook in `installLegacyPiSpecifierShim()` cannot rescue them.
20
+ //
21
+ // Instead we keep a JS-heap reference to every bundled pi-* surface (the
22
+ // canonical host packages and the legacy shims) and re-export them through a
23
+ // Bun.plugin `onLoad` against a custom namespace. Extension source rewrites
24
+ // emit `omp-legacy-pi-bundled:<key>` specifiers that the synthetic loader
25
+ // resolves against the registry — no bunfs path ever leaves this module in
26
+ // compiled mode. Dev / source-link / installed-package modes keep the
27
+ // historical `file://` rewrite (the source files exist on disk and load fine
28
+ // through Bun's standard URL loader).
29
+ //
30
+ // The registry lives in a sibling file (`legacy-pi-bundled-registry.ts`)
31
+ // loaded via a conditional dynamic import: its transitive deps include the
32
+ // coding-agent root which pulls in generated artifacts (e.g.
33
+ // `export/html/tool-views.generated.js`) that only exist after a build, so a
34
+ // static import would crash every dev/test run that touches
35
+ // `legacy-pi-compat.ts`. This is the documented "conditional platform code"
36
+ // exception to the static-import rule.
37
+ const BUNDLED_VIRTUAL_SCHEME = "omp-legacy-pi-bundled:";
38
+ const BUNDLED_VIRTUAL_NAMESPACE = "omp-legacy-pi-bundled";
39
+ const BUNDLED_REGISTRY_GLOBAL = "__ompLegacyPiBundledRegistry";
40
+ const TYPEBOX_BUNDLED_REGISTRY_KEY = "typebox";
41
+
42
+ type BundledRegistry = Readonly<Record<string, Readonly<Record<string, unknown>>>>;
43
+
44
+ let bundledRegistryPromise: Promise<BundledRegistry> | null = null;
45
+
46
+ /**
47
+ * Lazy-load the bundled host-package registry and stash it on `globalThis`
48
+ * for the synthetic loader emitted by `synthesizeBundledModuleSource`.
49
+ *
50
+ * `globalThis` is the bridge, not laziness: each synthesized
51
+ * `omp-legacy-pi-bundled:<key>` module is a *separate* ES module Bun compiles
52
+ * from a source string, so it cannot close over the registry in this file's
53
+ * lexical scope and the live (non-serializable) function/object exports cannot
54
+ * be inlined — the only runtime channel back to the host objects is a global.
55
+ *
56
+ * The dynamic import is gated by `IS_COMPILED_BINARY` so dev/test runs (where
57
+ * the registry's transitive deps include build-time-generated artifacts)
58
+ * never trigger the cascade.
59
+ */
60
+ function ensureBundledRegistryLoaded(): Promise<BundledRegistry> {
61
+ if (!IS_COMPILED_BINARY) {
62
+ return Promise.reject(
63
+ new Error("omp:legacy-pi-shim: bundled registry is only available in compiled-binary mode"),
64
+ );
65
+ }
66
+ if (!bundledRegistryPromise) {
67
+ bundledRegistryPromise = import("./legacy-pi-bundled-registry").then(m => {
68
+ (globalThis as Record<string, unknown>)[BUNDLED_REGISTRY_GLOBAL] = m.BUNDLED_PI_REGISTRY;
69
+ return m.BUNDLED_PI_REGISTRY;
70
+ });
71
+ }
72
+ return bundledRegistryPromise;
73
+ }
74
+
75
+ function bundledRegistryVirtualSpecifier(registryKey: string): string {
76
+ return `${BUNDLED_VIRTUAL_SCHEME}${registryKey}`;
77
+ }
78
+
79
+ function isBundledVirtualSpecifier(value: string): boolean {
80
+ return value.startsWith(BUNDLED_VIRTUAL_SCHEME);
81
+ }
82
+
83
+ /**
84
+ * Build the synthetic ES module source for a `omp-legacy-pi-bundled:<key>`
85
+ * import against an explicit registry. Pure: takes the live module namespace
86
+ * and emits a string of ES exports rooted in `globalThis[BUNDLED_REGISTRY_GLOBAL]`.
87
+ * `synthesizeBundledModuleSource` wraps this with the lazy registry load —
88
+ * tests use this sync helper directly to assert export-shape preservation.
89
+ */
90
+ function synthesizeBundledModuleSourceFromRegistry(registryKey: string, registry: BundledRegistry): string {
91
+ const mod = registry[registryKey];
92
+ if (!mod) {
93
+ throw new Error(`omp:legacy-pi-shim: no bundled module registered for ${registryKey}`);
94
+ }
95
+ const lines: string[] = [
96
+ `const __omp_bundled = globalThis[${JSON.stringify(BUNDLED_REGISTRY_GLOBAL)}][${JSON.stringify(registryKey)}];`,
97
+ ];
98
+ let hasDefault = false;
99
+ for (const exportName in mod) {
100
+ if (exportName === "default") {
101
+ hasDefault = true;
102
+ continue;
103
+ }
104
+ lines.push(`export const ${exportName} = __omp_bundled[${JSON.stringify(exportName)}];`);
105
+ }
106
+ if (hasDefault) {
107
+ lines.push("export default __omp_bundled.default;");
108
+ }
109
+ lines.push("");
110
+ return lines.join("\n");
111
+ }
112
+
113
+ /**
114
+ * Build the synthetic ES module source served for an
115
+ * `omp-legacy-pi-bundled:<key>` import. Enumerates the live module namespace
116
+ * so legacy extensions see the same named/default exports they would have
117
+ * gotten from a real `file://` load — without touching the inaccessible bunfs
118
+ * filesystem.
119
+ */
120
+ async function synthesizeBundledModuleSource(registryKey: string): Promise<string> {
121
+ const registry = await ensureBundledRegistryLoaded();
122
+ return synthesizeBundledModuleSourceFromRegistry(registryKey, registry);
123
+ }
124
+
125
+ /**
126
+ * Test seam: builds the synthetic ES module source for a virtual specifier
127
+ * against an explicit registry. Pure (no globalThis read); the emitted source
128
+ * still routes runtime lookups through `globalThis[BUNDLED_REGISTRY_GLOBAL]`.
129
+ */
130
+ export function __synthesizeLegacyPiBundledSourceWithRegistry(
131
+ registryKey: string,
132
+ registry: Readonly<Record<string, Readonly<Record<string, unknown>>>>,
133
+ ): string {
134
+ return synthesizeBundledModuleSourceFromRegistry(registryKey, registry);
135
+ }
136
+
137
+ /**
138
+ * Test seam: returns the globalThis key the synthetic loader reads from. Tests
139
+ * assert that the emitted source addresses the exact stash key the install
140
+ * function writes to, so a rename can't break extension loads silently.
141
+ */
142
+ export function __getLegacyPiBundledRegistryGlobal(): string {
143
+ return BUNDLED_REGISTRY_GLOBAL;
144
+ }
145
+
8
146
  // Canonical scope for in-process pi packages. Plugins published against any of
9
147
  // the aliased scopes below (mariozechner's original publish, earendil-works'
10
148
  // fork, or the canonical @oh-my-pi scope itself) are remapped to this scope and
@@ -73,45 +211,14 @@ const PACKAGE_IMPORT_EXCLUDED = Symbol("packageImportExcluded");
73
211
  // not provide and plugins relying on them must vendor TypeBox directly.
74
212
  const TYPEBOX_SPECIFIER_FILTER = /^(?:@sinclair\/typebox|typebox)$/;
75
213
 
76
- // Compat shim and bundled-package paths used in compiled-binary mode. The shim
77
- // paths must point at files that ship inside the bunfs root; in dev /
78
- // source-link / installed-package mode the canonical specifier resolves via
79
- // `Bun.resolveSync` so only the shim files need explicit paths there.
80
- //
81
- // `BUNFS_PACKAGE_ROOT` is derived from `import.meta.dir` rather than hardcoded
82
- // as `/$bunfs/root/packages` so the prefix stays platform-native: on Windows
83
- // the bunfs mount appears as `<drive>:\~BUN\root\…` (see oven-sh/bun#15766),
84
- // and a hardcoded POSIX literal would normalize to `\$bunfs\root\…` and fail
85
- // to resolve. Compiled Bun modules currently report the bunfs root itself from
86
- // `import.meta.dir`, so appending `packages` lands on the `--root ../..`
87
- // package directory used by `scripts/build-binary.ts`.
88
- //
89
- // Every shim listed below must also be registered as an explicit `--compile`
90
- // entrypoint in `scripts/build-binary.ts` or release builds fail with
91
- // missing-module errors. Non-shim bundled packages are resolved via
92
- // `Bun.resolveSync` (see `resolveCanonicalPiSpecifier`) outside compiled mode,
93
- // so they keep working when on-disk layout differs from the monorepo tree.
94
- /**
95
- * Compute the bunfs package root from the compiled binary's `import.meta.dir`
96
- * (or any stand-in supplied by tests). Bun 1.3 reports the bunfs mount root
97
- * (`/$bunfs/root` or `<drive>:\~BUN\root`) for imported modules as well as the
98
- * entrypoint, so the normal path is `<root>/packages`.
99
- *
100
- * The suffix branch preserves correctness if a future Bun release switches to
101
- * module-specific `import.meta.dir` values inside compiled binaries, matching
102
- * the source layout:
103
- * `<bunfs>/packages/coding-agent/src/extensibility/plugins`.
104
- *
105
- * Exported for tests; production callers use `BUNFS_PACKAGE_ROOT` below.
106
- */
107
- export function __computeBunfsPackageRoot(metaDir: string, pathImpl: typeof path = path): string {
108
- const pluginsDirSuffix = pathImpl.join("packages", "coding-agent", "src", "extensibility", "plugins");
109
- const normalizedMetaDir = pathImpl.normalize(metaDir);
110
- if (normalizedMetaDir.endsWith(pluginsDirSuffix)) {
111
- return pathImpl.resolve(metaDir, "..", "..", "..", "..");
112
- }
113
- return pathImpl.join(metaDir, "packages");
114
- }
214
+ // Compat-shim path resolution. In compiled-binary mode every bundled surface
215
+ // is served through the `omp-legacy-pi-bundled:` virtual namespace (see the
216
+ // registry block above) bunfs paths are unreachable on Bun 1.3.14+, so the
217
+ // pre-#3423 helpers that derived `/$bunfs/root/...` paths from
218
+ // `import.meta.dir` are gone. Dev / source-link / installed-package modes
219
+ // still need a real filesystem path for the source shims, which
220
+ // `sourceShimPath` computes either from the npm prebuilt `dist/cli.js`
221
+ // bundle (`PI_BUNDLED=true`) or directly from the monorepo source tree.
115
222
 
116
223
  /**
117
224
  * Compute the package root for the npm prebuilt `dist/cli.js` bundle.
@@ -135,15 +242,6 @@ export function __computeBundledSelfPackageRoot(metaDir: string, pathImpl: typeo
135
242
  return pathImpl.resolve(metaDir);
136
243
  }
137
244
 
138
- const BUNFS_PACKAGE_ROOT = IS_COMPILED_BINARY ? __computeBunfsPackageRoot(import.meta.dir) : null;
139
-
140
- function bunfsPath(...segments: string[]): string {
141
- if (!BUNFS_PACKAGE_ROOT) {
142
- throw new Error("bunfsPath is only valid in compiled-binary mode");
143
- }
144
- return path.join(BUNFS_PACKAGE_ROOT, ...segments);
145
- }
146
-
147
245
  function resolveBundledSelfPackageRoot(): string | undefined {
148
246
  if (!process.env.PI_BUNDLED) return undefined;
149
247
  return __computeBundledSelfPackageRoot(import.meta.dir);
@@ -157,9 +255,36 @@ function sourceShimPath(file: string): string {
157
255
  : path.resolve(import.meta.dir, "..", file);
158
256
  }
159
257
 
160
- const TYPEBOX_SHIM_PATH = BUNFS_PACKAGE_ROOT
161
- ? bunfsPath("coding-agent", "src", "extensibility", "typebox.js")
162
- : sourceShimPath("typebox.ts");
258
+ /**
259
+ * Resolve the path the TypeBox compatibility shim ships at, then drop it when
260
+ * the source file is missing.
261
+ *
262
+ * In compiled-binary mode the shim is served through the
263
+ * `omp-legacy-pi-bundled:` virtual namespace (issue #3423) — bunfs paths are
264
+ * unreachable on Bun 1.3.14+, so the virtual specifier is always available and
265
+ * needs no filesystem probe. In dev / source-link / installed-package mode the
266
+ * shim is an on-disk source file; validation mirrors
267
+ * `__validateLegacyPiPackageRootOverrides` (#2168): if the computed candidate
268
+ * doesn't exist (e.g. an install that dropped the source — issue #3414),
269
+ * `resolveTypeBoxSpecifier` returns `undefined` and
270
+ * `rewriteLegacyExtensionSource` leaves bare `typebox` / `@sinclair/typebox`
271
+ * specifiers alone, so Bun falls through to native resolution against the
272
+ * extension's own `node_modules`.
273
+ *
274
+ * Exported for tests; production callers use `TYPEBOX_SHIM_PATH`.
275
+ */
276
+ export function __resolveTypeBoxShimPath(
277
+ isCompiled: boolean,
278
+ sourcePath: string,
279
+ pathExistsSync: (p: string) => boolean = fs.existsSync,
280
+ ): string | null {
281
+ if (isCompiled) {
282
+ return bundledRegistryVirtualSpecifier(TYPEBOX_BUNDLED_REGISTRY_KEY);
283
+ }
284
+ return pathExistsSync(sourcePath) ? sourcePath : null;
285
+ }
286
+
287
+ const TYPEBOX_SHIM_PATH = __resolveTypeBoxShimPath(IS_COMPILED_BINARY, sourceShimPath("typebox.ts"));
163
288
 
164
289
  // Legacy extensions historically imported `Type` (and `Static`/`TSchema`) from
165
290
  // the package root of `@(scope)/pi-ai`. pi-ai 15.1.0 removed the runtime `Type`
@@ -169,59 +294,67 @@ const TYPEBOX_SHIM_PATH = BUNFS_PACKAGE_ROOT
169
294
  // plus the borrowed `Type` runtime from the Zod-backed TypeBox shim. Subpath
170
295
  // imports such as `@oh-my-pi/pi-ai/oauth` continue to resolve directly
171
296
  // against the bundled pi-ai package.
172
- const LEGACY_PI_AI_SHIM_PATH = BUNFS_PACKAGE_ROOT
173
- ? bunfsPath("coding-agent", "src", "extensibility", "legacy-pi-ai-shim.js")
297
+ const LEGACY_PI_AI_SHIM_PATH = IS_COMPILED_BINARY
298
+ ? bundledRegistryVirtualSpecifier(`${CANONICAL_PI_SCOPE}/pi-ai`)
174
299
  : sourceShimPath("legacy-pi-ai-shim.ts");
175
300
 
176
301
  // The coding-agent's own `./src/index.ts` cannot be listed as an extra
177
302
  // `bun --compile` entrypoint alongside the CLI entry without breaking binary
178
- // startup (issue #1474 follow-up). Legacy `@(scope)/pi-coding-agent` root
179
- // imports therefore resolve through a sibling shim whose distinct file path
180
- // avoids that collision while re-exporting the canonical package surface.
181
- const LEGACY_PI_CODING_AGENT_SHIM_PATH = BUNFS_PACKAGE_ROOT
182
- ? bunfsPath("coding-agent", "src", "extensibility", "legacy-pi-coding-agent-shim.js")
303
+ // startup (issue #1474 follow-up). In compiled-binary mode the legacy
304
+ // `@(scope)/pi-coding-agent` root therefore resolves through the bundled
305
+ // registry shim; in dev / source-link / installed-package mode it points at
306
+ // the sibling source shim whose distinct file path avoids the #1474 collision
307
+ // while still re-exporting the canonical package surface.
308
+ const LEGACY_PI_CODING_AGENT_SHIM_PATH = IS_COMPILED_BINARY
309
+ ? bundledRegistryVirtualSpecifier(`${CANONICAL_PI_SCOPE}/pi-coding-agent`)
183
310
  : sourceShimPath("legacy-pi-coding-agent-shim.ts");
184
311
 
185
- // Package-root overrides. Shim entries are always applied because they replace
186
- // (or augment) the canonical surface even in non-compiled installs. The bunfs
187
- // entries are added only in compiled-binary mode — in dev / source-link /
188
- // installed-package mode the canonical specifier resolves cleanly through
189
- // `Bun.resolveSync`, and hardcoding a relative source-tree path would break
190
- // installs where the bundled packages live at `node_modules/@oh-my-pi/pi-*`
191
- // rather than `packages/*`.
312
+ // Package-root overrides. Shim entries (`pi-ai`, `pi-coding-agent`) always
313
+ // replace the canonical surface so the legacy `Type` runtime and the legacy
314
+ // helpers stay reachable. The bundled host packages (`pi-agent-core`,
315
+ // `pi-natives`, `pi-tui`, `pi-utils`) are added only in compiled-binary mode
316
+ // to route extensions onto the in-process module instance — in dev /
317
+ // source-link / installed-package mode the canonical specifier resolves
318
+ // cleanly through `Bun.resolveSync` and hardcoding a source-tree path would
319
+ // miss installs where the bundled packages live at `node_modules/@oh-my-pi/pi-*`.
192
320
  //
193
- // Every override target is validated against the on-disk filesystem at module
194
- // init: any entry whose file is missing (e.g. a compiled binary where Bun's
195
- // `--compile` quietly dropped an additional entrypoint — issue #2168) is left
196
- // out so `resolveCanonicalPiSpecifier` falls through to `getResolvedSpecifier`,
197
- // which throws under bunfs and triggers the catch in `rewriteLegacyPiImports`.
198
- // That catch leaves the specifier untouched so Bun resolves the canonical
199
- // `@oh-my-pi/pi-*` import from the extension's own `node_modules` instead of
200
- // emitting a bunfs `file://` URL to a module that isn't actually present.
321
+ // Compiled-binary entries are `omp-legacy-pi-bundled:<key>` specifiers handed
322
+ // to the synthetic onLoad in `installLegacyPiSpecifierShim()` bunfs paths
323
+ // are unusable on Bun 1.3.14+ (issue #3423). Filesystem-shaped overrides are
324
+ // still validated against on-disk presence so a missing dev-mode shim falls
325
+ // through to `getResolvedSpecifier`.
201
326
 
202
327
  /**
203
- * Drop overrides whose targets are missing on disk so they can fall through to
204
- * the canonical-resolution path. Exported for the test seam in #2168.
328
+ * Drop overrides whose filesystem targets are missing so they can fall
329
+ * through to the canonical-resolution path. Virtual `omp-legacy-pi-bundled:`
330
+ * entries always pass — the bundled registry is the source of truth in
331
+ * compiled-binary mode where bunfs paths are unreachable (issue #3423).
205
332
  *
206
- * `pathExistsSync` defaults to `fs.existsSync`; the tests inject a stub to
333
+ * `pathExistsSync` defaults to `fs.existsSync`; tests inject a stub to
207
334
  * simulate the missing-entrypoint failure mode without touching the real FS.
208
335
  */
209
336
  export function __validateLegacyPiPackageRootOverrides(
210
337
  candidates: Record<string, string>,
211
338
  pathExistsSync: (p: string) => boolean = fs.existsSync,
212
339
  ): Record<string, string> {
213
- return Object.fromEntries(Object.entries(candidates).filter(([, candidate]) => pathExistsSync(candidate)));
340
+ return Object.fromEntries(
341
+ Object.entries(candidates).filter(
342
+ ([, candidate]) => isBundledVirtualSpecifier(candidate) || pathExistsSync(candidate),
343
+ ),
344
+ );
214
345
  }
215
346
 
216
347
  const LEGACY_PI_PACKAGE_ROOT_OVERRIDES = __validateLegacyPiPackageRootOverrides({
217
348
  [`${CANONICAL_PI_SCOPE}/pi-ai`]: LEGACY_PI_AI_SHIM_PATH,
218
349
  [`${CANONICAL_PI_SCOPE}/pi-coding-agent`]: LEGACY_PI_CODING_AGENT_SHIM_PATH,
219
- ...(BUNFS_PACKAGE_ROOT
350
+ ...(IS_COMPILED_BINARY
220
351
  ? {
221
- [`${CANONICAL_PI_SCOPE}/pi-agent-core`]: bunfsPath("agent", "src", "index.js"),
222
- [`${CANONICAL_PI_SCOPE}/pi-natives`]: bunfsPath("natives", "native", "index.js"),
223
- [`${CANONICAL_PI_SCOPE}/pi-tui`]: bunfsPath("tui", "src", "index.js"),
224
- [`${CANONICAL_PI_SCOPE}/pi-utils`]: bunfsPath("utils", "src", "index.js"),
352
+ [`${CANONICAL_PI_SCOPE}/pi-agent-core`]: bundledRegistryVirtualSpecifier(
353
+ `${CANONICAL_PI_SCOPE}/pi-agent-core`,
354
+ ),
355
+ [`${CANONICAL_PI_SCOPE}/pi-natives`]: bundledRegistryVirtualSpecifier(`${CANONICAL_PI_SCOPE}/pi-natives`),
356
+ [`${CANONICAL_PI_SCOPE}/pi-tui`]: bundledRegistryVirtualSpecifier(`${CANONICAL_PI_SCOPE}/pi-tui`),
357
+ [`${CANONICAL_PI_SCOPE}/pi-utils`]: bundledRegistryVirtualSpecifier(`${CANONICAL_PI_SCOPE}/pi-utils`),
225
358
  }
226
359
  : {}),
227
360
  });
@@ -270,6 +403,12 @@ function resolveCanonicalPiSpecifier(remappedSpecifier: string): string {
270
403
  }
271
404
 
272
405
  function toImportSpecifier(resolvedPath: string): string {
406
+ // Virtual `omp-legacy-pi-bundled:` specifiers are served by the synthetic
407
+ // onLoad in `installLegacyPiSpecifierShim()`; wrapping them as `file://`
408
+ // would corrupt the scheme and bypass the bundled registry.
409
+ if (isBundledVirtualSpecifier(resolvedPath)) {
410
+ return resolvedPath;
411
+ }
273
412
  return url.pathToFileURL(resolvedPath).href;
274
413
  }
275
414
 
@@ -309,12 +448,17 @@ const TYPEBOX_IMPORT_SPECIFIER_REGEX = /((?:from\s+|import\s+|import\s*\(\s*)["'
309
448
  */
310
449
  async function rewriteLegacyExtensionSource(source: string, importerPath: string): Promise<string> {
311
450
  const withPi = rewriteLegacyPiImports(source);
312
- const withTypeBox = withPi.replace(
313
- TYPEBOX_IMPORT_SPECIFIER_REGEX,
314
- (_match, prefix: string, _specifier: string, suffix: string) => {
315
- return `${prefix}${toImportSpecifier(TYPEBOX_SHIM_PATH)}${suffix}`;
316
- },
317
- );
451
+ // When the TypeBox shim is missing (release build dropped the entrypoint —
452
+ // issue #3414), leave bare specifiers untouched so Bun resolves a real
453
+ // `typebox` / `@sinclair/typebox` install from the extension's own
454
+ // `node_modules`. `resolveTypeBoxSpecifier` mirrors the fall-through.
455
+ const withTypeBox = TYPEBOX_SHIM_PATH
456
+ ? withPi.replace(
457
+ TYPEBOX_IMPORT_SPECIFIER_REGEX,
458
+ (_match, prefix: string, _specifier: string, suffix: string) =>
459
+ `${prefix}${toImportSpecifier(TYPEBOX_SHIM_PATH)}${suffix}`,
460
+ )
461
+ : withPi;
318
462
  return rewriteExtensionPackageImports(withTypeBox, importerPath);
319
463
  }
320
464
 
@@ -687,8 +831,8 @@ function resolveLegacyPiSpecifier(args: { path: string; importer: string }): { p
687
831
  }
688
832
  }
689
833
 
690
- function resolveTypeBoxSpecifier(): { path: string } {
691
- return { path: TYPEBOX_SHIM_PATH };
834
+ function resolveTypeBoxSpecifier(): { path: string } | undefined {
835
+ return TYPEBOX_SHIM_PATH ? { path: TYPEBOX_SHIM_PATH } : undefined;
692
836
  }
693
837
 
694
838
  export function installLegacyPiSpecifierShim(): void {
@@ -702,6 +846,12 @@ export function installLegacyPiSpecifierShim(): void {
702
846
  setup(build) {
703
847
  build.onResolve({ filter: LEGACY_PI_SPECIFIER_FILTER, namespace: "file" }, resolveLegacyPiSpecifier);
704
848
  build.onResolve({ filter: TYPEBOX_SPECIFIER_FILTER, namespace: "file" }, resolveTypeBoxSpecifier);
849
+ // Compiled-binary mode: serve `omp-legacy-pi-bundled:<key>` imports
850
+ // from the JS-heap registry. The rewrite path emits these specifiers
851
+ // in place of unreachable `file:///$bunfs/...` URLs (issue #3423).
852
+ build.onLoad({ filter: /.*/, namespace: BUNDLED_VIRTUAL_NAMESPACE }, async args => {
853
+ return { contents: await synthesizeBundledModuleSource(args.path), loader: "js" };
854
+ });
705
855
  },
706
856
  });
707
857
  }
@@ -6,12 +6,18 @@
6
6
  */
7
7
  import * as fs from "node:fs";
8
8
  import * as path from "node:path";
9
- import { getPluginsLockfile, getPluginsNodeModules, getPluginsPackageJson, isEnoent } from "@oh-my-pi/pi-utils";
9
+ import { getPluginsDir, getPluginsLockfile, isEnoent } from "@oh-my-pi/pi-utils";
10
10
  import { getConfigDirPaths } from "../../config";
11
+ import { resolveActiveProjectRegistryPath } from "../../discovery/helpers";
11
12
  import { installLegacyPiSpecifierShim } from "./legacy-pi-compat";
12
13
  import { normalizePluginRuntimeConfig } from "./runtime-config";
13
14
  import type { InstalledPlugin, PluginManifest, PluginRuntimeConfig, ProjectPluginOverrides } from "./types";
14
15
 
16
+ /** Installed plugin plus the root scope that supplied its runtime metadata. */
17
+ export interface ScopedInstalledPlugin extends InstalledPlugin {
18
+ scope: "user" | "project";
19
+ }
20
+
15
21
  installLegacyPiSpecifierShim();
16
22
 
17
23
  // =============================================================================
@@ -51,38 +57,38 @@ async function loadProjectOverrides(cwd: string): Promise<ProjectPluginOverrides
51
57
  return {};
52
58
  }
53
59
  /**
54
- * Get list of enabled plugins with their resolved configurations.
55
- *
56
- * Respects both global runtime config and project overrides. Iterates the
57
- * union of `<plugins>/package.json#dependencies` (`bun install`-installed
58
- * packages) and `<plugins>/omp-plugins.lock.json#plugins` (so locally
59
- * `plugin link`-symlinked extensions, which never get a dependency entry,
60
- * are still discovered). The optional `home` parameter pins the plugins
61
- * root for callers that need to enumerate plugins relative to a non-default
62
- * home (tests with a tempdir, discovery loaders threaded with
63
- * `LoadContext.home`).
60
+ * Per-root enumeration of plugins from `<root>/node_modules`,
61
+ * `<root>/package.json#dependencies`, and `<root>/omp-plugins.lock.json#plugins`.
62
+ * Honors `projectOverrides.disabled` and `projectOverrides.features`. Returns an
63
+ * empty array when the root has no `node_modules` yet.
64
64
  */
65
- export async function getEnabledPlugins(cwd: string, opts: { home?: string } = {}): Promise<InstalledPlugin[]> {
66
- const { home } = opts;
67
-
68
- const nodeModulesPath = getPluginsNodeModules(home);
69
- if (!fs.existsSync(nodeModulesPath)) {
70
- return [];
71
- }
65
+ async function collectPluginsAtRoot(
66
+ root: string,
67
+ projectOverrides: ProjectPluginOverrides,
68
+ scope: ScopedInstalledPlugin["scope"],
69
+ ): Promise<ScopedInstalledPlugin[]> {
70
+ const nodeModulesPath = path.join(root, "node_modules");
71
+ if (!fs.existsSync(nodeModulesPath)) return [];
72
72
 
73
73
  let depsKeys: string[] = [];
74
- const pkgJsonPath = getPluginsPackageJson(home);
74
+ const pkgJsonPath = path.join(root, "package.json");
75
75
  try {
76
76
  const pkg: { dependencies?: Record<string, string> } = await Bun.file(pkgJsonPath).json();
77
77
  depsKeys = Object.keys(pkg.dependencies ?? {});
78
78
  } catch (err) {
79
- // Linked-only setups may have no `<plugins>/package.json` yet — that's
79
+ // Linked-only setups may have no `<root>/package.json` yet — that's
80
80
  // fine, the lockfile still records the link.
81
81
  if (!isEnoent(err)) throw err;
82
82
  }
83
83
 
84
- const runtimeConfig = await loadRuntimeConfig(home);
85
- const projectOverrides = await loadProjectOverrides(cwd);
84
+ const lockPath = path.join(root, "omp-plugins.lock.json");
85
+ let runtimeConfig: PluginRuntimeConfig;
86
+ try {
87
+ runtimeConfig = normalizePluginRuntimeConfig(await Bun.file(lockPath).json());
88
+ } catch (err) {
89
+ if (!isEnoent(err)) throw err;
90
+ runtimeConfig = normalizePluginRuntimeConfig({});
91
+ }
86
92
 
87
93
  // Union: dependencies (npm/marketplace installs) ∪ runtime-config plugins
88
94
  // (links + already-recorded installs). Set preserves first-seen order,
@@ -92,7 +98,7 @@ export async function getEnabledPlugins(cwd: string, opts: { home?: string } = {
92
98
  names.add(name);
93
99
  }
94
100
 
95
- const plugins: InstalledPlugin[] = [];
101
+ const plugins: ScopedInstalledPlugin[] = [];
96
102
  for (const name of names) {
97
103
  const pluginPkgPath = path.join(nodeModulesPath, name, "package.json");
98
104
  let pluginPkg: { version: string; omp?: PluginManifest; pi?: PluginManifest };
@@ -130,6 +136,7 @@ export async function getEnabledPlugins(cwd: string, opts: { home?: string } = {
130
136
  name,
131
137
  version: pluginPkg.version,
132
138
  path: path.join(nodeModulesPath, name),
139
+ scope,
133
140
  manifest,
134
141
  enabledFeatures,
135
142
  enabled: true,
@@ -139,6 +146,47 @@ export async function getEnabledPlugins(cwd: string, opts: { home?: string } = {
139
146
  return plugins;
140
147
  }
141
148
 
149
+ /**
150
+ * Get list of enabled plugins with their resolved configurations.
151
+ *
152
+ * Enumerates two plugin roots in order: the user root
153
+ * (`getPluginsDir(home)`) and, when a project anchor (`.omp/` or `.git/`)
154
+ * exists at or above `cwd`, the project root
155
+ * (`<projectAnchor>/.omp/plugins`). Each root contributes the union of its
156
+ * `package.json#dependencies` and `omp-plugins.lock.json#plugins`. Project
157
+ * entries shadow user entries with the same package name, matching the
158
+ * shadow semantics of `MarketplaceManager.listInstalledPlugins`.
159
+ *
160
+ * The optional `home` parameter pins the user plugins root for callers that
161
+ * need to enumerate plugins relative to a non-default home (tests with a
162
+ * tempdir, discovery loaders threaded with `LoadContext.home`).
163
+ */
164
+ export async function getEnabledPlugins(cwd: string, opts: { home?: string } = {}): Promise<ScopedInstalledPlugin[]> {
165
+ const { home } = opts;
166
+ const projectOverrides = await loadProjectOverrides(cwd);
167
+
168
+ const userRoot = getPluginsDir(home);
169
+ const userPlugins = await collectPluginsAtRoot(userRoot, projectOverrides, "user");
170
+
171
+ let projectPlugins: ScopedInstalledPlugin[] = [];
172
+ const projectRegistryPath = await resolveActiveProjectRegistryPath(cwd);
173
+ if (projectRegistryPath) {
174
+ const projectRoot = path.dirname(projectRegistryPath);
175
+ if (projectRoot !== userRoot) {
176
+ projectPlugins = await collectPluginsAtRoot(projectRoot, projectOverrides, "project");
177
+ }
178
+ }
179
+
180
+ if (projectPlugins.length === 0) return userPlugins;
181
+ if (userPlugins.length === 0) return projectPlugins;
182
+
183
+ // Project entries shadow user entries with the same package name.
184
+ const merged = new Map<string, ScopedInstalledPlugin>();
185
+ for (const plugin of userPlugins) merged.set(plugin.name, plugin);
186
+ for (const plugin of projectPlugins) merged.set(plugin.name, plugin);
187
+ return Array.from(merged.values());
188
+ }
189
+
142
190
  // =============================================================================
143
191
  // Path Resolution
144
192
  // =============================================================================