@oh-my-pi/pi-coding-agent 16.1.17 → 16.1.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (40) hide show
  1. package/CHANGELOG.md +28 -0
  2. package/dist/cli.js +17872 -17571
  3. package/dist/types/cli/usage-cli.d.ts +14 -0
  4. package/dist/types/commands/token.d.ts +9 -0
  5. package/dist/types/edit/hashline/filesystem.d.ts +1 -18
  6. package/dist/types/extensibility/plugins/legacy-pi-bundled-keys.d.ts +10 -0
  7. package/dist/types/extensibility/plugins/legacy-pi-bundled-registry.d.ts +10 -0
  8. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +43 -27
  9. package/dist/types/internal-urls/skill-protocol.d.ts +2 -2
  10. package/dist/types/internal-urls/types.d.ts +3 -0
  11. package/dist/types/modes/components/custom-editor.d.ts +0 -2
  12. package/dist/types/modes/components/extensions/extension-dashboard.d.ts +26 -10
  13. package/dist/types/modes/components/extensions/extension-list.d.ts +13 -0
  14. package/dist/types/modes/controllers/input-controller.d.ts +0 -1
  15. package/dist/types/tools/path-utils.d.ts +3 -0
  16. package/package.json +12 -12
  17. package/scripts/build-binary.ts +29 -16
  18. package/scripts/generate-legacy-pi-bundled-registry.ts +404 -0
  19. package/src/cli/usage-cli.ts +35 -4
  20. package/src/commands/token.ts +54 -0
  21. package/src/edit/hashline/filesystem.ts +14 -0
  22. package/src/extensibility/plugins/legacy-pi-bundled-keys.ts +987 -0
  23. package/src/extensibility/plugins/legacy-pi-bundled-registry.ts +3352 -0
  24. package/src/extensibility/plugins/legacy-pi-compat.ts +264 -131
  25. package/src/internal-urls/local-protocol.ts +116 -9
  26. package/src/internal-urls/skill-protocol.ts +3 -3
  27. package/src/internal-urls/types.ts +3 -0
  28. package/src/modes/components/custom-editor.test.ts +7 -5
  29. package/src/modes/components/custom-editor.ts +6 -16
  30. package/src/modes/components/extensions/extension-dashboard.ts +221 -165
  31. package/src/modes/components/extensions/extension-list.ts +66 -33
  32. package/src/modes/controllers/input-controller.ts +0 -71
  33. package/src/modes/controllers/selector-controller.ts +4 -0
  34. package/src/session/messages.ts +70 -47
  35. package/src/tools/ast-edit.ts +1 -0
  36. package/src/tools/ast-grep.ts +1 -0
  37. package/src/tools/find.ts +1 -0
  38. package/src/tools/path-utils.ts +4 -0
  39. package/src/tools/read.ts +43 -17
  40. package/src/tools/search.ts +4 -0
@@ -2,9 +2,148 @@ 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";
5
+ import { BUNDLED_PI_REGISTRY_KEYS } from "./legacy-pi-bundled-keys";
5
6
 
6
7
  const IS_COMPILED_BINARY = isCompiledBinary();
7
8
 
9
+ // === Bundled host-package registry (issue #3423) ===
10
+ //
11
+ // Bun 1.3.14 stopped exposing `--compile` extras through any filesystem-style
12
+ // API: `fs.existsSync`, `Bun.file().exists()`, `Bun.resolveSync`, and even
13
+ // `import("/$bunfs/...")` / `import("file:///$bunfs/...")` all fail for the
14
+ // embedded entries — only the main binary itself answers from
15
+ // `/$bunfs/root/<binary-name>`. The previous strategy of rewriting
16
+ // `@(scope)/pi-*` imports to a `file:///$bunfs/...` URL therefore breaks
17
+ // every legacy extension in compiled mode (issue #3423; see also issues
18
+ // #3329, #2168). Bun.plugin `onResolve` for bare specifiers also no longer
19
+ // fires for transitive imports inside runtime-loaded extensions, so the
20
+ // fallback hook in `installLegacyPiSpecifierShim()` cannot rescue them.
21
+ //
22
+ // Instead we keep a JS-heap reference to every bundled pi-* surface (the
23
+ // canonical host packages and the legacy shims) and re-export them through a
24
+ // Bun.plugin `onLoad` against a custom namespace. Extension source rewrites
25
+ // emit `omp-legacy-pi-bundled:<key>` specifiers that the synthetic loader
26
+ // resolves against the registry — no bunfs path ever leaves this module in
27
+ // compiled mode. Dev / source-link / installed-package modes keep the
28
+ // historical `file://` rewrite (the source files exist on disk and load fine
29
+ // through Bun's standard URL loader).
30
+ //
31
+ // The registry lives in a sibling file (`legacy-pi-bundled-registry.ts`)
32
+ // loaded via a conditional dynamic import: its transitive deps include the
33
+ // coding-agent root which pulls in generated artifacts (e.g.
34
+ // `export/html/tool-views.generated.js`) that only exist after a build, so a
35
+ // static import would crash every dev/test run that touches
36
+ // `legacy-pi-compat.ts`. This is the documented "conditional platform code"
37
+ // exception to the static-import rule.
38
+ const BUNDLED_VIRTUAL_SCHEME = "omp-legacy-pi-bundled:";
39
+ const BUNDLED_VIRTUAL_NAMESPACE = "omp-legacy-pi-bundled";
40
+ const BUNDLED_REGISTRY_GLOBAL = "__ompLegacyPiBundledRegistry";
41
+ const TYPEBOX_BUNDLED_REGISTRY_KEY = "typebox";
42
+
43
+ type BundledRegistry = Readonly<Record<string, Readonly<Record<string, unknown>>>>;
44
+
45
+ let bundledRegistryPromise: Promise<BundledRegistry> | null = null;
46
+
47
+ /**
48
+ * Lazy-load the bundled host-package registry and stash it on `globalThis`
49
+ * for the synthetic loader emitted by `synthesizeBundledModuleSource`.
50
+ *
51
+ * `globalThis` is the bridge, not laziness: each synthesized
52
+ * `omp-legacy-pi-bundled:<key>` module is a *separate* ES module Bun compiles
53
+ * from a source string, so it cannot close over the registry in this file's
54
+ * lexical scope and the live (non-serializable) function/object exports cannot
55
+ * be inlined — the only runtime channel back to the host objects is a global.
56
+ *
57
+ * The dynamic import is gated by `IS_COMPILED_BINARY` so dev/test runs (where
58
+ * the registry's transitive deps include build-time-generated artifacts)
59
+ * never trigger the cascade.
60
+ */
61
+ function ensureBundledRegistryLoaded(): Promise<BundledRegistry> {
62
+ if (!IS_COMPILED_BINARY) {
63
+ return Promise.reject(
64
+ new Error("omp:legacy-pi-shim: bundled registry is only available in compiled-binary mode"),
65
+ );
66
+ }
67
+ if (!bundledRegistryPromise) {
68
+ bundledRegistryPromise = import("./legacy-pi-bundled-registry").then(m => {
69
+ (globalThis as Record<string, unknown>)[BUNDLED_REGISTRY_GLOBAL] = m.BUNDLED_PI_REGISTRY;
70
+ return m.BUNDLED_PI_REGISTRY;
71
+ });
72
+ }
73
+ return bundledRegistryPromise;
74
+ }
75
+
76
+ function bundledRegistryVirtualSpecifier(registryKey: string): string {
77
+ return `${BUNDLED_VIRTUAL_SCHEME}${registryKey}`;
78
+ }
79
+
80
+ function isBundledVirtualSpecifier(value: string): boolean {
81
+ return value.startsWith(BUNDLED_VIRTUAL_SCHEME);
82
+ }
83
+
84
+ /**
85
+ * Build the synthetic ES module source for a `omp-legacy-pi-bundled:<key>`
86
+ * import against an explicit registry. Pure: takes the live module namespace
87
+ * and emits a string of ES exports rooted in `globalThis[BUNDLED_REGISTRY_GLOBAL]`.
88
+ * `synthesizeBundledModuleSource` wraps this with the lazy registry load —
89
+ * tests use this sync helper directly to assert export-shape preservation.
90
+ */
91
+ function synthesizeBundledModuleSourceFromRegistry(registryKey: string, registry: BundledRegistry): string {
92
+ const mod = registry[registryKey];
93
+ if (!mod) {
94
+ throw new Error(`omp:legacy-pi-shim: no bundled module registered for ${registryKey}`);
95
+ }
96
+ const lines: string[] = [
97
+ `const __omp_bundled = globalThis[${JSON.stringify(BUNDLED_REGISTRY_GLOBAL)}][${JSON.stringify(registryKey)}];`,
98
+ ];
99
+ let hasDefault = false;
100
+ for (const exportName in mod) {
101
+ if (exportName === "default") {
102
+ hasDefault = true;
103
+ continue;
104
+ }
105
+ lines.push(`export const ${exportName} = __omp_bundled[${JSON.stringify(exportName)}];`);
106
+ }
107
+ if (hasDefault) {
108
+ lines.push("export default __omp_bundled.default;");
109
+ }
110
+ lines.push("");
111
+ return lines.join("\n");
112
+ }
113
+
114
+ /**
115
+ * Build the synthetic ES module source served for an
116
+ * `omp-legacy-pi-bundled:<key>` import. Enumerates the live module namespace
117
+ * so legacy extensions see the same named/default exports they would have
118
+ * gotten from a real `file://` load — without touching the inaccessible bunfs
119
+ * filesystem.
120
+ */
121
+ async function synthesizeBundledModuleSource(registryKey: string): Promise<string> {
122
+ const registry = await ensureBundledRegistryLoaded();
123
+ return synthesizeBundledModuleSourceFromRegistry(registryKey, registry);
124
+ }
125
+
126
+ /**
127
+ * Test seam: builds the synthetic ES module source for a virtual specifier
128
+ * against an explicit registry. Pure (no globalThis read); the emitted source
129
+ * still routes runtime lookups through `globalThis[BUNDLED_REGISTRY_GLOBAL]`.
130
+ */
131
+ export function __synthesizeLegacyPiBundledSourceWithRegistry(
132
+ registryKey: string,
133
+ registry: Readonly<Record<string, Readonly<Record<string, unknown>>>>,
134
+ ): string {
135
+ return synthesizeBundledModuleSourceFromRegistry(registryKey, registry);
136
+ }
137
+
138
+ /**
139
+ * Test seam: returns the globalThis key the synthetic loader reads from. Tests
140
+ * assert that the emitted source addresses the exact stash key the install
141
+ * function writes to, so a rename can't break extension loads silently.
142
+ */
143
+ export function __getLegacyPiBundledRegistryGlobal(): string {
144
+ return BUNDLED_REGISTRY_GLOBAL;
145
+ }
146
+
8
147
  // Canonical scope for in-process pi packages. Plugins published against any of
9
148
  // the aliased scopes below (mariozechner's original publish, earendil-works'
10
149
  // fork, or the canonical @oh-my-pi scope itself) are remapped to this scope and
@@ -73,57 +212,14 @@ const PACKAGE_IMPORT_EXCLUDED = Symbol("packageImportExcluded");
73
212
  // not provide and plugins relying on them must vendor TypeBox directly.
74
213
  const TYPEBOX_SPECIFIER_FILTER = /^(?:@sinclair\/typebox|typebox)$/;
75
214
 
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 compiled binaries report one of:
97
- *
98
- * - the bunfs mount root itself — `/$bunfs/root` or `<drive>:\~BUN\root` (Bun
99
- * 1.2.x and early 1.3.x). Append `packages` for the canonical layout.
100
- * - the bunfs mount root followed by the binary's basename — `//root/<bin>`
101
- * on POSIX or `<drive>:\~BUN\root\<bin>.exe` on Windows (observed on Bun
102
- * 1.3.14 with the cross-compiled `omp-darwin-arm64` release asset — issue
103
- * #3329). The trailing segment is stripped so the result still lands on
104
- * `<root>/packages`.
105
- * - the module's own source directory if a future Bun release switches to
106
- * module-specific `import.meta.dir` values:
107
- * `<bunfs>/packages/coding-agent/src/extensibility/plugins`.
108
- * The bunfs-root-with-binary branch slices the original `metaDir`, and
109
- * `bunfsPath` uses a matching double-slash-preserving join, so the bunfs-native
110
- * prefix is preserved verbatim — `path.posix.join` collapses `//root` to
111
- * `/root`, but Bun's bunfs lookup is keyed on the exact `//root` form.
112
- *
113
- * Exported for tests; production callers use `BUNFS_PACKAGE_ROOT` below.
114
- */
115
- export function __computeBunfsPackageRoot(metaDir: string, pathImpl: typeof path = path): string {
116
- const pluginsDirSuffix = pathImpl.join("packages", "coding-agent", "src", "extensibility", "plugins");
117
- const normalizedMetaDir = pathImpl.normalize(metaDir);
118
- if (normalizedMetaDir.endsWith(pluginsDirSuffix)) {
119
- return pathImpl.resolve(metaDir, "..", "..", "..", "..");
120
- }
121
- const parent = pathImpl.dirname(metaDir);
122
- if (pathImpl.basename(pathImpl.normalize(parent)) === "root") {
123
- return `${parent + pathImpl.sep}packages`;
124
- }
125
- return pathImpl.join(metaDir, "packages");
126
- }
215
+ // Compat-shim path resolution. In compiled-binary mode every bundled surface
216
+ // is served through the `omp-legacy-pi-bundled:` virtual namespace (see the
217
+ // registry block above) bunfs paths are unreachable on Bun 1.3.14+, so the
218
+ // pre-#3423 helpers that derived `/$bunfs/root/...` paths from
219
+ // `import.meta.dir` are gone. Dev / source-link / installed-package modes
220
+ // still need a real filesystem path for the source shims, which
221
+ // `sourceShimPath` computes either from the npm prebuilt `dist/cli.js`
222
+ // bundle (`PI_BUNDLED=true`) or directly from the monorepo source tree.
127
223
 
128
224
  /**
129
225
  * Compute the package root for the npm prebuilt `dist/cli.js` bundle.
@@ -147,35 +243,6 @@ export function __computeBundledSelfPackageRoot(metaDir: string, pathImpl: typeo
147
243
  return pathImpl.resolve(metaDir);
148
244
  }
149
245
 
150
- const BUNFS_PACKAGE_ROOT = IS_COMPILED_BINARY ? __computeBunfsPackageRoot(import.meta.dir) : null;
151
-
152
- /**
153
- * Join a computed bunfs package root with descendants without collapsing
154
- * Bun's POSIX `//root` mount prefix.
155
- *
156
- * Exported for tests; production callers use `bunfsPath` below.
157
- */
158
- export function __joinBunfsPath(root: string, segments: readonly string[], pathImpl: typeof path = path): string {
159
- const joined = pathImpl.join(root, ...segments);
160
- const doubleRootPrefix = pathImpl.sep + pathImpl.sep;
161
- const tripleRootPrefix = doubleRootPrefix + pathImpl.sep;
162
- if (
163
- root.startsWith(doubleRootPrefix) &&
164
- !root.startsWith(tripleRootPrefix) &&
165
- !joined.startsWith(doubleRootPrefix)
166
- ) {
167
- return pathImpl.sep + joined;
168
- }
169
- return joined;
170
- }
171
-
172
- function bunfsPath(...segments: string[]): string {
173
- if (!BUNFS_PACKAGE_ROOT) {
174
- throw new Error("bunfsPath is only valid in compiled-binary mode");
175
- }
176
- return __joinBunfsPath(BUNFS_PACKAGE_ROOT, segments);
177
- }
178
-
179
246
  function resolveBundledSelfPackageRoot(): string | undefined {
180
247
  if (!process.env.PI_BUNDLED) return undefined;
181
248
  return __computeBundledSelfPackageRoot(import.meta.dir);
@@ -189,9 +256,36 @@ function sourceShimPath(file: string): string {
189
256
  : path.resolve(import.meta.dir, "..", file);
190
257
  }
191
258
 
192
- const TYPEBOX_SHIM_PATH = BUNFS_PACKAGE_ROOT
193
- ? bunfsPath("coding-agent", "src", "extensibility", "typebox.js")
194
- : sourceShimPath("typebox.ts");
259
+ /**
260
+ * Resolve the path the TypeBox compatibility shim ships at, then drop it when
261
+ * the source file is missing.
262
+ *
263
+ * In compiled-binary mode the shim is served through the
264
+ * `omp-legacy-pi-bundled:` virtual namespace (issue #3423) — bunfs paths are
265
+ * unreachable on Bun 1.3.14+, so the virtual specifier is always available and
266
+ * needs no filesystem probe. In dev / source-link / installed-package mode the
267
+ * shim is an on-disk source file; validation mirrors
268
+ * `__validateLegacyPiPackageRootOverrides` (#2168): if the computed candidate
269
+ * doesn't exist (e.g. an install that dropped the source — issue #3414),
270
+ * `resolveTypeBoxSpecifier` returns `undefined` and
271
+ * `rewriteLegacyExtensionSource` leaves bare `typebox` / `@sinclair/typebox`
272
+ * specifiers alone, so Bun falls through to native resolution against the
273
+ * extension's own `node_modules`.
274
+ *
275
+ * Exported for tests; production callers use `TYPEBOX_SHIM_PATH`.
276
+ */
277
+ export function __resolveTypeBoxShimPath(
278
+ isCompiled: boolean,
279
+ sourcePath: string,
280
+ pathExistsSync: (p: string) => boolean = fs.existsSync,
281
+ ): string | null {
282
+ if (isCompiled) {
283
+ return bundledRegistryVirtualSpecifier(TYPEBOX_BUNDLED_REGISTRY_KEY);
284
+ }
285
+ return pathExistsSync(sourcePath) ? sourcePath : null;
286
+ }
287
+
288
+ const TYPEBOX_SHIM_PATH = __resolveTypeBoxShimPath(IS_COMPILED_BINARY, sourceShimPath("typebox.ts"));
195
289
 
196
290
  // Legacy extensions historically imported `Type` (and `Static`/`TSchema`) from
197
291
  // the package root of `@(scope)/pi-ai`. pi-ai 15.1.0 removed the runtime `Type`
@@ -201,62 +295,84 @@ const TYPEBOX_SHIM_PATH = BUNFS_PACKAGE_ROOT
201
295
  // plus the borrowed `Type` runtime from the Zod-backed TypeBox shim. Subpath
202
296
  // imports such as `@oh-my-pi/pi-ai/oauth` continue to resolve directly
203
297
  // against the bundled pi-ai package.
204
- const LEGACY_PI_AI_SHIM_PATH = BUNFS_PACKAGE_ROOT
205
- ? bunfsPath("coding-agent", "src", "extensibility", "legacy-pi-ai-shim.js")
298
+ const LEGACY_PI_AI_SHIM_PATH = IS_COMPILED_BINARY
299
+ ? bundledRegistryVirtualSpecifier(`${CANONICAL_PI_SCOPE}/pi-ai`)
206
300
  : sourceShimPath("legacy-pi-ai-shim.ts");
207
301
 
208
302
  // The coding-agent's own `./src/index.ts` cannot be listed as an extra
209
303
  // `bun --compile` entrypoint alongside the CLI entry without breaking binary
210
- // startup (issue #1474 follow-up). Legacy `@(scope)/pi-coding-agent` root
211
- // imports therefore resolve through a sibling shim whose distinct file path
212
- // avoids that collision while re-exporting the canonical package surface.
213
- const LEGACY_PI_CODING_AGENT_SHIM_PATH = BUNFS_PACKAGE_ROOT
214
- ? bunfsPath("coding-agent", "src", "extensibility", "legacy-pi-coding-agent-shim.js")
304
+ // startup (issue #1474 follow-up). In compiled-binary mode the legacy
305
+ // `@(scope)/pi-coding-agent` root therefore resolves through the bundled
306
+ // registry shim; in dev / source-link / installed-package mode it points at
307
+ // the sibling source shim whose distinct file path avoids the #1474 collision
308
+ // while still re-exporting the canonical package surface.
309
+ const LEGACY_PI_CODING_AGENT_SHIM_PATH = IS_COMPILED_BINARY
310
+ ? bundledRegistryVirtualSpecifier(`${CANONICAL_PI_SCOPE}/pi-coding-agent`)
215
311
  : sourceShimPath("legacy-pi-coding-agent-shim.ts");
216
312
 
217
- // Package-root overrides. Shim entries are always applied because they replace
218
- // (or augment) the canonical surface even in non-compiled installs. The bunfs
219
- // entries are added only in compiled-binary mode — in dev / source-link /
220
- // installed-package mode the canonical specifier resolves cleanly through
221
- // `Bun.resolveSync`, and hardcoding a relative source-tree path would break
222
- // installs where the bundled packages live at `node_modules/@oh-my-pi/pi-*`
223
- // rather than `packages/*`.
313
+ // Package-root overrides. Shim entries (`pi-ai`, `pi-coding-agent`) always
314
+ // replace the canonical surface so the legacy `Type` runtime and the legacy
315
+ // helpers stay reachable. The bundled host packages (`pi-agent-core`,
316
+ // `pi-natives`, `pi-tui`, `pi-utils`) are added only in compiled-binary mode
317
+ // to route extensions onto the in-process module instance — in dev /
318
+ // source-link / installed-package mode the canonical specifier resolves
319
+ // cleanly through `Bun.resolveSync` and hardcoding a source-tree path would
320
+ // miss installs where the bundled packages live at `node_modules/@oh-my-pi/pi-*`.
224
321
  //
225
- // Every override target is validated against the on-disk filesystem at module
226
- // init: any entry whose file is missing (e.g. a compiled binary where Bun's
227
- // `--compile` quietly dropped an additional entrypoint — issue #2168) is left
228
- // out so `resolveCanonicalPiSpecifier` falls through to `getResolvedSpecifier`,
229
- // which throws under bunfs and triggers the catch in `rewriteLegacyPiImports`.
230
- // That catch leaves the specifier untouched so Bun resolves the canonical
231
- // `@oh-my-pi/pi-*` import from the extension's own `node_modules` instead of
232
- // emitting a bunfs `file://` URL to a module that isn't actually present.
322
+ // Compiled-binary entries are `omp-legacy-pi-bundled:<key>` specifiers handed
323
+ // to the synthetic onLoad in `installLegacyPiSpecifierShim()` bunfs paths
324
+ // are unusable on Bun 1.3.14+ (issue #3423). Filesystem-shaped overrides are
325
+ // still validated against on-disk presence so a missing dev-mode shim falls
326
+ // through to `getResolvedSpecifier`.
233
327
 
234
328
  /**
235
- * Drop overrides whose targets are missing on disk so they can fall through to
236
- * the canonical-resolution path. Exported for the test seam in #2168.
329
+ * Drop overrides whose filesystem targets are missing so they can fall
330
+ * through to the canonical-resolution path. Virtual `omp-legacy-pi-bundled:`
331
+ * entries always pass — the bundled registry is the source of truth in
332
+ * compiled-binary mode where bunfs paths are unreachable (issue #3423).
237
333
  *
238
- * `pathExistsSync` defaults to `fs.existsSync`; the tests inject a stub to
334
+ * `pathExistsSync` defaults to `fs.existsSync`; tests inject a stub to
239
335
  * simulate the missing-entrypoint failure mode without touching the real FS.
240
336
  */
241
337
  export function __validateLegacyPiPackageRootOverrides(
242
338
  candidates: Record<string, string>,
243
339
  pathExistsSync: (p: string) => boolean = fs.existsSync,
244
340
  ): Record<string, string> {
245
- return Object.fromEntries(Object.entries(candidates).filter(([, candidate]) => pathExistsSync(candidate)));
246
- }
247
-
248
- const LEGACY_PI_PACKAGE_ROOT_OVERRIDES = __validateLegacyPiPackageRootOverrides({
249
- [`${CANONICAL_PI_SCOPE}/pi-ai`]: LEGACY_PI_AI_SHIM_PATH,
250
- [`${CANONICAL_PI_SCOPE}/pi-coding-agent`]: LEGACY_PI_CODING_AGENT_SHIM_PATH,
251
- ...(BUNFS_PACKAGE_ROOT
252
- ? {
253
- [`${CANONICAL_PI_SCOPE}/pi-agent-core`]: bunfsPath("agent", "src", "index.js"),
254
- [`${CANONICAL_PI_SCOPE}/pi-natives`]: bunfsPath("natives", "native", "index.js"),
255
- [`${CANONICAL_PI_SCOPE}/pi-tui`]: bunfsPath("tui", "src", "index.js"),
256
- [`${CANONICAL_PI_SCOPE}/pi-utils`]: bunfsPath("utils", "src", "index.js"),
257
- }
258
- : {}),
259
- });
341
+ return Object.fromEntries(
342
+ Object.entries(candidates).filter(
343
+ ([, candidate]) => isBundledVirtualSpecifier(candidate) || pathExistsSync(candidate),
344
+ ),
345
+ );
346
+ }
347
+
348
+ /**
349
+ * Compute the override map keyed by every canonical specifier the host serves
350
+ * directly: the pi-ai / pi-coding-agent roots (compat shims that re-attach
351
+ * legacy helpers) plus, in compiled-binary mode, every other canonical pi-*
352
+ * package root AND every non-wildcard subpath registered in the bundled
353
+ * registry (see `legacy-pi-bundled-keys.ts`). Subpath coverage is what stops
354
+ * `@(scope)/pi-ai/oauth` and friends from falling through to the extension's
355
+ * own — possibly absent — peer install when bunfs filesystem walks fail
356
+ * (issue #3442 follow-up to #3423). Exported as a test seam so the
357
+ * compiled-binary branch is verifiable from dev tests.
358
+ */
359
+ export function __buildLegacyPiPackageRootOverrides(isCompiled: boolean): Record<string, string> {
360
+ const candidates: Record<string, string> = {
361
+ [`${CANONICAL_PI_SCOPE}/pi-ai`]: LEGACY_PI_AI_SHIM_PATH,
362
+ [`${CANONICAL_PI_SCOPE}/pi-coding-agent`]: LEGACY_PI_CODING_AGENT_SHIM_PATH,
363
+ };
364
+ if (isCompiled) {
365
+ for (const key of BUNDLED_PI_REGISTRY_KEYS) {
366
+ // Shim-bearing roots above already mapped to their compat surface;
367
+ // the bundled typebox shim has a dedicated TYPEBOX_SHIM_PATH route.
368
+ if (key in candidates || key === TYPEBOX_BUNDLED_REGISTRY_KEY) continue;
369
+ candidates[key] = bundledRegistryVirtualSpecifier(key);
370
+ }
371
+ }
372
+ return __validateLegacyPiPackageRootOverrides(candidates);
373
+ }
374
+
375
+ const LEGACY_PI_PACKAGE_ROOT_OVERRIDES = __buildLegacyPiPackageRootOverrides(IS_COMPILED_BINARY);
260
376
 
261
377
  let isLegacyPiSpecifierShimInstalled = false;
262
378
 
@@ -302,6 +418,12 @@ function resolveCanonicalPiSpecifier(remappedSpecifier: string): string {
302
418
  }
303
419
 
304
420
  function toImportSpecifier(resolvedPath: string): string {
421
+ // Virtual `omp-legacy-pi-bundled:` specifiers are served by the synthetic
422
+ // onLoad in `installLegacyPiSpecifierShim()`; wrapping them as `file://`
423
+ // would corrupt the scheme and bypass the bundled registry.
424
+ if (isBundledVirtualSpecifier(resolvedPath)) {
425
+ return resolvedPath;
426
+ }
305
427
  return url.pathToFileURL(resolvedPath).href;
306
428
  }
307
429
 
@@ -341,12 +463,17 @@ const TYPEBOX_IMPORT_SPECIFIER_REGEX = /((?:from\s+|import\s+|import\s*\(\s*)["'
341
463
  */
342
464
  async function rewriteLegacyExtensionSource(source: string, importerPath: string): Promise<string> {
343
465
  const withPi = rewriteLegacyPiImports(source);
344
- const withTypeBox = withPi.replace(
345
- TYPEBOX_IMPORT_SPECIFIER_REGEX,
346
- (_match, prefix: string, _specifier: string, suffix: string) => {
347
- return `${prefix}${toImportSpecifier(TYPEBOX_SHIM_PATH)}${suffix}`;
348
- },
349
- );
466
+ // When the TypeBox shim is missing (release build dropped the entrypoint —
467
+ // issue #3414), leave bare specifiers untouched so Bun resolves a real
468
+ // `typebox` / `@sinclair/typebox` install from the extension's own
469
+ // `node_modules`. `resolveTypeBoxSpecifier` mirrors the fall-through.
470
+ const withTypeBox = TYPEBOX_SHIM_PATH
471
+ ? withPi.replace(
472
+ TYPEBOX_IMPORT_SPECIFIER_REGEX,
473
+ (_match, prefix: string, _specifier: string, suffix: string) =>
474
+ `${prefix}${toImportSpecifier(TYPEBOX_SHIM_PATH)}${suffix}`,
475
+ )
476
+ : withPi;
350
477
  return rewriteExtensionPackageImports(withTypeBox, importerPath);
351
478
  }
352
479
 
@@ -719,8 +846,8 @@ function resolveLegacyPiSpecifier(args: { path: string; importer: string }): { p
719
846
  }
720
847
  }
721
848
 
722
- function resolveTypeBoxSpecifier(): { path: string } {
723
- return { path: TYPEBOX_SHIM_PATH };
849
+ function resolveTypeBoxSpecifier(): { path: string } | undefined {
850
+ return TYPEBOX_SHIM_PATH ? { path: TYPEBOX_SHIM_PATH } : undefined;
724
851
  }
725
852
 
726
853
  export function installLegacyPiSpecifierShim(): void {
@@ -734,6 +861,12 @@ export function installLegacyPiSpecifierShim(): void {
734
861
  setup(build) {
735
862
  build.onResolve({ filter: LEGACY_PI_SPECIFIER_FILTER, namespace: "file" }, resolveLegacyPiSpecifier);
736
863
  build.onResolve({ filter: TYPEBOX_SPECIFIER_FILTER, namespace: "file" }, resolveTypeBoxSpecifier);
864
+ // Compiled-binary mode: serve `omp-legacy-pi-bundled:<key>` imports
865
+ // from the JS-heap registry. The rewrite path emits these specifiers
866
+ // in place of unreachable `file:///$bunfs/...` URLs (issue #3423).
867
+ build.onLoad({ filter: /.*/, namespace: BUNDLED_VIRTUAL_NAMESPACE }, async args => {
868
+ return { contents: await synthesizeBundledModuleSource(args.path), loader: "js" };
869
+ });
737
870
  },
738
871
  });
739
872
  }
@@ -49,6 +49,121 @@ function getContentType(filePath: string): InternalResource["contentType"] {
49
49
  return "text/plain";
50
50
  }
51
51
 
52
+ const LOCAL_TEXT_SNIFF_BYTES = 8 * 1024;
53
+ const LOCAL_TEXT_RESOURCE_MAX_BYTES = 1024 * 1024;
54
+ const BINARY_FILE_EXTENSIONS = new Set([
55
+ ".7z",
56
+ ".avi",
57
+ ".bmp",
58
+ ".bz2",
59
+ ".db",
60
+ ".doc",
61
+ ".docx",
62
+ ".gif",
63
+ ".gz",
64
+ ".ico",
65
+ ".jpeg",
66
+ ".jpg",
67
+ ".m4v",
68
+ ".mkv",
69
+ ".mov",
70
+ ".mp4",
71
+ ".pdf",
72
+ ".png",
73
+ ".ppt",
74
+ ".pptx",
75
+ ".rar",
76
+ ".sqlite",
77
+ ".tgz",
78
+ ".webm",
79
+ ".webp",
80
+ ".wmv",
81
+ ".xls",
82
+ ".xlsx",
83
+ ".xz",
84
+ ".zip",
85
+ ]);
86
+
87
+ function formatLocalByteSize(bytes: number): string {
88
+ if (bytes < 1024) return `${bytes} B`;
89
+ const kib = bytes / 1024;
90
+ if (kib < 1024) return `${kib.toFixed(1)} KiB`;
91
+ const mib = kib / 1024;
92
+ if (mib < 1024) return `${mib.toFixed(1)} MiB`;
93
+ return `${(mib / 1024).toFixed(1)} GiB`;
94
+ }
95
+
96
+ function buildNonTextLocalResource(url: InternalUrl, filePath: string, size: number, reason: string): InternalResource {
97
+ const content = `[Cannot read binary local:// file '${url.href}' (${formatLocalByteSize(size)}): ${reason}. This resource is not text. Use a metadata/key-frame/video-specific workflow instead.]`;
98
+ return {
99
+ url: url.href,
100
+ content,
101
+ contentType: "text/plain",
102
+ size: Buffer.byteLength(content, "utf-8"),
103
+ sourcePath: filePath,
104
+ notes: [LOCAL_WRITE_NOTE],
105
+ };
106
+ }
107
+
108
+ function buildLargeLocalTextResource(url: InternalUrl, filePath: string, size: number): InternalResource {
109
+ const content = `[Cannot materialize local:// file '${url.href}' as an internal text resource (${formatLocalByteSize(size)} exceeds ${formatLocalByteSize(LOCAL_TEXT_RESOURCE_MAX_BYTES)}). Use the read tool's filesystem path handling or a line selector so content is streamed with file-size safeguards.]`;
110
+ return {
111
+ url: url.href,
112
+ content,
113
+ contentType: "text/plain",
114
+ size: Buffer.byteLength(content, "utf-8"),
115
+ sourcePath: filePath,
116
+ notes: [LOCAL_WRITE_NOTE],
117
+ };
118
+ }
119
+
120
+ async function readFilePrefix(filePath: string, maxBytes: number): Promise<Uint8Array> {
121
+ if (maxBytes <= 0) return new Uint8Array();
122
+ const handle = await fs.open(filePath, "r");
123
+ try {
124
+ const buffer = Buffer.allocUnsafe(maxBytes);
125
+ const { bytesRead } = await handle.read(buffer, 0, maxBytes, 0);
126
+ return buffer.subarray(0, bytesRead);
127
+ } finally {
128
+ await handle.close();
129
+ }
130
+ }
131
+
132
+ function isUtf8Text(bytes: Uint8Array): boolean {
133
+ if (bytes.indexOf(0) !== -1) return false;
134
+ try {
135
+ new TextDecoder("utf-8", { fatal: true }).decode(bytes);
136
+ return true;
137
+ } catch {
138
+ return false;
139
+ }
140
+ }
141
+
142
+ async function buildFileResource(
143
+ url: InternalUrl,
144
+ resolved: Extract<ResolvedLocalTarget, { kind: "file" }>,
145
+ ): Promise<InternalResource> {
146
+ if (BINARY_FILE_EXTENSIONS.has(path.extname(resolved.path).toLowerCase())) {
147
+ return buildNonTextLocalResource(url, resolved.path, resolved.size, "extension is a known binary/container type");
148
+ }
149
+ const sniffBytes = await readFilePrefix(resolved.path, Math.min(resolved.size, LOCAL_TEXT_SNIFF_BYTES));
150
+ if (!isUtf8Text(sniffBytes)) {
151
+ return buildNonTextLocalResource(url, resolved.path, resolved.size, "content is not valid UTF-8 text");
152
+ }
153
+ if (resolved.size > LOCAL_TEXT_RESOURCE_MAX_BYTES) {
154
+ return buildLargeLocalTextResource(url, resolved.path, resolved.size);
155
+ }
156
+ const content = await Bun.file(resolved.path).text();
157
+ return {
158
+ url: url.href,
159
+ content,
160
+ contentType: getContentType(resolved.path),
161
+ size: Buffer.byteLength(content, "utf-8"),
162
+ sourcePath: resolved.path,
163
+ notes: [LOCAL_WRITE_NOTE],
164
+ };
165
+ }
166
+
52
167
  async function listFilesRecursively(rootPath: string): Promise<string[]> {
53
168
  const pending = [""];
54
169
  const files: string[] = [];
@@ -336,15 +451,7 @@ export class LocalProtocolHandler implements ProtocolHandler {
336
451
  return buildDirectoryResource(url.href, resolved.path, [LOCAL_WRITE_NOTE]);
337
452
  }
338
453
 
339
- const content = await Bun.file(resolved.path).text();
340
- return {
341
- url: url.href,
342
- content,
343
- contentType: getContentType(resolved.path),
344
- size: Buffer.byteLength(content, "utf-8"),
345
- sourcePath: resolved.path,
346
- notes: [LOCAL_WRITE_NOTE],
347
- };
454
+ return buildFileResource(url, resolved);
348
455
  }
349
456
 
350
457
  async complete(_query?: string, context?: ResolveContext): Promise<UrlCompletion[]> {
@@ -13,7 +13,7 @@ import * as path from "node:path";
13
13
  import { isEnoent } from "@oh-my-pi/pi-utils";
14
14
  import { getActiveSkills } from "../extensibility/skills";
15
15
  import { buildDirectoryResource } from "./filesystem-resource";
16
- import type { InternalResource, InternalUrl, ProtocolHandler, UrlCompletion } from "./types";
16
+ import type { InternalResource, InternalUrl, ProtocolHandler, ResolveContext, UrlCompletion } from "./types";
17
17
 
18
18
  function getContentType(filePath: string): InternalResource["contentType"] {
19
19
  const ext = path.extname(filePath).toLowerCase();
@@ -42,8 +42,8 @@ export class SkillProtocolHandler implements ProtocolHandler {
42
42
  readonly scheme = "skill";
43
43
  readonly immutable = true;
44
44
 
45
- async resolve(url: InternalUrl): Promise<InternalResource> {
46
- const skills = getActiveSkills();
45
+ async resolve(url: InternalUrl, context?: ResolveContext): Promise<InternalResource> {
46
+ const skills = context?.skills ?? getActiveSkills();
47
47
 
48
48
  const skillName = url.rawHost || url.hostname;
49
49
  if (!skillName) {
@@ -5,6 +5,7 @@
5
5
  * providing access to agent outputs and server resources without exposing filesystem paths.
6
6
  */
7
7
 
8
+ import type { Skill } from "../extensibility/skills";
8
9
  import type { LocalProtocolOptions } from "./local-protocol";
9
10
 
10
11
  /**
@@ -90,6 +91,8 @@ export interface ResolveContext {
90
91
  * [#1608](https://github.com/can1357/oh-my-pi/issues/1608).
91
92
  */
92
93
  localProtocolOptions?: LocalProtocolOptions;
94
+ /** Calling session's loaded skills. Prefer this over process-global skill state. */
95
+ skills?: readonly Skill[];
93
96
  }
94
97
 
95
98
  /**