@oh-my-pi/pi-coding-agent 15.9.1 → 15.9.3

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 (57) hide show
  1. package/CHANGELOG.md +29 -1
  2. package/dist/types/cli/dry-balance-cli.d.ts +104 -0
  3. package/dist/types/commands/dry-balance.d.ts +31 -0
  4. package/dist/types/config/model-registry.d.ts +2 -0
  5. package/dist/types/config/models-config-schema.d.ts +3 -0
  6. package/dist/types/config/settings.d.ts +11 -0
  7. package/dist/types/discovery/helpers.d.ts +1 -0
  8. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +2 -3
  9. package/dist/types/hindsight/bank.d.ts +17 -9
  10. package/dist/types/hindsight/mental-models.d.ts +1 -1
  11. package/dist/types/hindsight/state.d.ts +9 -3
  12. package/dist/types/mcp/manager.d.ts +1 -1
  13. package/dist/types/modes/components/transcript-container.d.ts +3 -2
  14. package/dist/types/session/agent-session.d.ts +9 -0
  15. package/dist/types/session/auth-storage.d.ts +2 -2
  16. package/dist/types/task/types.d.ts +2 -0
  17. package/dist/types/tools/index.d.ts +16 -0
  18. package/dist/types/tools/path-utils.d.ts +11 -0
  19. package/package.json +9 -9
  20. package/src/cli/dry-balance-cli.ts +823 -0
  21. package/src/cli-commands.ts +1 -0
  22. package/src/commands/dry-balance.ts +43 -0
  23. package/src/config/model-registry.ts +6 -0
  24. package/src/config/models-config-schema.ts +2 -0
  25. package/src/config/settings.ts +38 -0
  26. package/src/discovery/builtin-rules/ts-no-tiny-functions.md +1 -0
  27. package/src/discovery/github.ts +37 -1
  28. package/src/discovery/helpers.ts +3 -1
  29. package/src/extensibility/plugins/legacy-pi-compat.ts +245 -25
  30. package/src/hindsight/backend.ts +184 -35
  31. package/src/hindsight/bank.ts +32 -22
  32. package/src/hindsight/mental-models.ts +1 -1
  33. package/src/hindsight/state.ts +21 -7
  34. package/src/internal-urls/docs-index.generated.ts +4 -4
  35. package/src/internal-urls/omp-protocol.ts +8 -2
  36. package/src/mcp/manager.ts +40 -21
  37. package/src/modes/components/transcript-container.ts +14 -3
  38. package/src/modes/components/tree-selector.ts +29 -2
  39. package/src/modes/controllers/input-controller.ts +8 -2
  40. package/src/modes/setup-wizard/scenes/sign-in.ts +27 -7
  41. package/src/prompts/agents/explore.md +1 -0
  42. package/src/prompts/agents/librarian.md +1 -0
  43. package/src/prompts/dry-balance-bench.md +8 -0
  44. package/src/sdk.ts +82 -9
  45. package/src/session/agent-session.ts +66 -7
  46. package/src/session/auth-storage.ts +4 -0
  47. package/src/task/executor.ts +6 -2
  48. package/src/task/index.ts +8 -7
  49. package/src/task/types.ts +2 -0
  50. package/src/tools/bash.ts +3 -4
  51. package/src/tools/index.ts +16 -0
  52. package/src/tools/job.ts +3 -3
  53. package/src/tools/memory-reflect.ts +2 -2
  54. package/src/tools/path-utils.ts +21 -0
  55. package/src/tools/search.ts +18 -1
  56. package/src/utils/file-mentions.ts +7 -107
  57. package/src/utils/title-generator.ts +58 -37
@@ -20,6 +20,7 @@ export const commands: CommandEntry[] = [
20
20
  { name: "completions", load: () => import("./commands/completions").then(m => m.default) },
21
21
  { name: "__complete", load: () => import("./commands/complete").then(m => m.default) },
22
22
  { name: "config", load: () => import("./commands/config").then(m => m.default) },
23
+ { name: "dry-balance", load: () => import("./commands/dry-balance").then(m => m.default) },
23
24
  { name: "grep", load: () => import("./commands/grep").then(m => m.default) },
24
25
  { name: "grievances", load: () => import("./commands/grievances").then(m => m.default) },
25
26
  { name: "install", load: () => import("./commands/install").then(m => m.default) },
@@ -0,0 +1,43 @@
1
+ import { Args, Command, Flags } from "@oh-my-pi/pi-utils/cli";
2
+ import { runDryBalanceCommand } from "../cli/dry-balance-cli";
3
+
4
+ export default class DryBalance extends Command {
5
+ static description = "Dry-run OAuth account balancing across random session ids";
6
+
7
+ static args = {
8
+ model: Args.string({
9
+ description: "Model selector (provider/model or fuzzy id). Defaults to the configured default model.",
10
+ required: false,
11
+ }),
12
+ };
13
+
14
+ static flags = {
15
+ model: Flags.string({ description: "Model selector (same syntax as --model on omp)" }),
16
+ count: Flags.integer({ description: "Number of random session ids to try", default: 100 }),
17
+ concurrency: Flags.integer({ description: "Maximum concurrent credential resolutions", default: 32 }),
18
+ json: Flags.boolean({ description: "Output JSON" }),
19
+ bench: Flags.boolean({ description: "Send one live benchmark request per OAuth account" }),
20
+ };
21
+
22
+ static examples = [
23
+ "# Dry-run the configured default model with 100 random session ids\n omp dry-balance",
24
+ "# Dry-run a specific model\n omp dry-balance anthropic/claude-sonnet-4-5",
25
+ "# Larger run with bounded concurrency\n omp dry-balance --model openai-codex/gpt-5-codex --count 1000 --concurrency 64",
26
+ "# Benchmark every OAuth account in parallel\n omp dry-balance --bench",
27
+ "# Machine-readable output\n omp dry-balance --json",
28
+ ];
29
+
30
+ async run(): Promise<void> {
31
+ const { args, flags } = await this.parse(DryBalance);
32
+ await runDryBalanceCommand({
33
+ model: args.model,
34
+ flags: {
35
+ model: flags.model,
36
+ count: flags.count,
37
+ concurrency: flags.concurrency,
38
+ json: flags.json,
39
+ bench: flags.bench,
40
+ },
41
+ });
42
+ }
43
+ }
@@ -547,6 +547,7 @@ function applyModelOverride(model: Model<Api>, override: ModelOverride): Model<A
547
547
  if (override.input !== undefined) result.input = override.input as ("text" | "image")[];
548
548
  if (override.contextWindow !== undefined) result.contextWindow = override.contextWindow;
549
549
  if (override.maxTokens !== undefined) result.maxTokens = override.maxTokens;
550
+ if (override.omitMaxOutputTokens !== undefined) result.omitMaxOutputTokens = override.omitMaxOutputTokens;
550
551
  if (override.contextPromotionTarget !== undefined) result.contextPromotionTarget = override.contextPromotionTarget;
551
552
  if (override.premiumMultiplier !== undefined) result.premiumMultiplier = override.premiumMultiplier;
552
553
  if (override.cost) {
@@ -575,6 +576,7 @@ interface CustomModelDefinitionLike {
575
576
  cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
576
577
  contextWindow?: number;
577
578
  maxTokens?: number;
579
+ omitMaxOutputTokens?: boolean;
578
580
  headers?: Record<string, string>;
579
581
  compat?: Model<Api>["compat"];
580
582
  contextPromotionTarget?: string;
@@ -597,6 +599,7 @@ type CustomModelOverlay = {
597
599
  cost?: { input: number; output: number; cacheRead: number; cacheWrite: number };
598
600
  contextWindow?: number;
599
601
  maxTokens?: number;
602
+ omitMaxOutputTokens?: boolean;
600
603
  headers?: Record<string, string>;
601
604
  compat?: Model<Api>["compat"];
602
605
  contextPromotionTarget?: string;
@@ -667,6 +670,7 @@ function buildCustomModelOverlay(
667
670
  cost: modelDef.cost,
668
671
  contextWindow: modelDef.contextWindow,
669
672
  maxTokens: modelDef.maxTokens,
673
+ omitMaxOutputTokens: modelDef.omitMaxOutputTokens,
670
674
  headers: mergeCustomModelHeaders(providerHeaders, modelDef.headers, authHeader, providerApiKey),
671
675
  compat: mergeCompat(providerCompat, modelDef.compat),
672
676
  contextPromotionTarget: modelDef.contextPromotionTarget,
@@ -823,6 +827,7 @@ function finalizeCustomModel(model: CustomModelOverlay, options: CustomModelBuil
823
827
  resolvedModel.contextWindow ?? reference?.contextWindow ?? (options.useDefaults ? 128000 : undefined),
824
828
  maxTokens: resolvedModel.maxTokens ?? reference?.maxTokens ?? (options.useDefaults ? 16384 : undefined),
825
829
  headers: resolvedModel.headers,
830
+ omitMaxOutputTokens: resolvedModel.omitMaxOutputTokens ?? reference?.omitMaxOutputTokens,
826
831
  compat: mergeCompat(reference?.compat, resolvedModel.compat),
827
832
  contextPromotionTarget: resolvedModel.contextPromotionTarget,
828
833
  premiumMultiplier: resolvedModel.premiumMultiplier,
@@ -1124,6 +1129,7 @@ export class ModelRegistry {
1124
1129
  cost: customModel.cost ?? existingModel.cost,
1125
1130
  contextWindow: customModel.contextWindow ?? existingModel.contextWindow,
1126
1131
  maxTokens: customModel.maxTokens ?? existingModel.maxTokens,
1132
+ omitMaxOutputTokens: customModel.omitMaxOutputTokens ?? existingModel.omitMaxOutputTokens,
1127
1133
  // Same-id custom definitions replace bundled transport behavior. Provider-level
1128
1134
  // headers/compat were already folded into customModel during parsing; do not
1129
1135
  // re-merge bundled transport metadata here.
@@ -93,6 +93,7 @@ const ModelDefinitionSchema = z.object({
93
93
  premiumMultiplier: z.number().optional(),
94
94
  contextWindow: z.number().optional(),
95
95
  maxTokens: z.number().optional(),
96
+ omitMaxOutputTokens: z.boolean().optional(),
96
97
  headers: z.record(z.string(), z.string()).optional(),
97
98
  compat: OpenAICompatSchema.optional(),
98
99
  contextPromotionTarget: z.string().min(1).optional(),
@@ -114,6 +115,7 @@ export const ModelOverrideSchema = z.object({
114
115
  premiumMultiplier: z.number().optional(),
115
116
  contextWindow: z.number().optional(),
116
117
  maxTokens: z.number().optional(),
118
+ omitMaxOutputTokens: z.boolean().optional(),
117
119
  headers: z.record(z.string(), z.string()).optional(),
118
120
  compat: OpenAICompatSchema.optional(),
119
121
  contextPromotionTarget: z.string().min(1).optional(),
@@ -907,6 +907,9 @@ const SETTING_HOOKS: Partial<Record<SettingPath, SettingHook<any>>> = {
907
907
  for (const cb of appendOnlyModeCallbacks) cb(value);
908
908
  }
909
909
  },
910
+ "hindsight.bankId": () => fireHindsightScopeChanged(),
911
+ "hindsight.bankIdPrefix": () => fireHindsightScopeChanged(),
912
+ "hindsight.scoping": () => fireHindsightScopeChanged(),
910
913
  };
911
914
  /** Callbacks invoked when `provider.appendOnlyContext` changes at runtime. */
912
915
  const appendOnlyModeCallbacks = new Set<(value: string) => void>();
@@ -923,6 +926,41 @@ export function onAppendOnlyModeChanged(cb: (value: string) => void): () => void
923
926
  };
924
927
  }
925
928
 
929
+ /** Callbacks fired when any `hindsight.bankId` / `bankIdPrefix` / `scoping` value changes. */
930
+ const hindsightScopeCallbacks = new Set<() => void>();
931
+
932
+ function fireHindsightScopeChanged(): void {
933
+ // Snapshot the callback set before invoking — a callback's body is allowed
934
+ // to subscribe a NEW callback (the Hindsight backend re-registers the
935
+ // fresh state's listener on every rebuild). Iterating the live Set would
936
+ // re-invoke those just-added callbacks within the same fire, which spins
937
+ // in place: subscribe → invoke → subscribe → invoke → …
938
+ for (const cb of [...hindsightScopeCallbacks]) {
939
+ try {
940
+ cb();
941
+ } catch (err) {
942
+ logger.warn("Settings: hindsight scope hook failed", { error: String(err) });
943
+ }
944
+ }
945
+ }
946
+
947
+ /**
948
+ * Subscribe to changes in the Hindsight bank-scoping settings. Lets the
949
+ * Hindsight backend rebuild the active `HindsightSessionState` when the
950
+ * operator switches `hindsight.bankId`, `hindsight.bankIdPrefix`, or
951
+ * `hindsight.scoping` mid-session so subsequent retain/recall calls land in
952
+ * the new bank instead of the one selected at session start.
953
+ *
954
+ * Returns an unsubscribe function. The callback receives no arguments — the
955
+ * caller is expected to re-read the relevant settings via `Settings.get`.
956
+ */
957
+ export function onHindsightScopeChanged(cb: () => void): () => void {
958
+ hindsightScopeCallbacks.add(cb);
959
+ return () => {
960
+ hindsightScopeCallbacks.delete(cb);
961
+ };
962
+ }
963
+
926
964
  // ═══════════════════════════════════════════════════════════════════════════
927
965
  // Global Singleton
928
966
  // ═══════════════════════════════════════════════════════════════════════════
@@ -46,5 +46,6 @@ const doubled = value * 2;
46
46
  - Callback identity matters.
47
47
  - Type guard preserves narrowing.
48
48
  - Public API, test seam, or DI boundary needs indirection.
49
+ - Names a non-obvious formula or magic-constant computation that the inlined expression would not explain on its own.
49
50
 
50
51
  If none apply, inline it.
@@ -10,6 +10,7 @@
10
10
  * Capabilities:
11
11
  * - context-files: copilot-instructions.md in .github/
12
12
  * - instructions: *.instructions.md in .github/instructions/ with applyTo frontmatter
13
+ * - skills: <name>/SKILL.md in .github/skills/ (GitHub Agent Skills layout)
13
14
  */
14
15
  import * as path from "node:path";
15
16
  import { parseFrontmatter } from "@oh-my-pi/pi-utils";
@@ -17,9 +18,10 @@ import { registerProvider } from "../capability";
17
18
  import { type ContextFile, contextFileCapability } from "../capability/context-file";
18
19
  import { readFile } from "../capability/fs";
19
20
  import { type Instruction, instructionCapability } from "../capability/instruction";
21
+ import { type Skill, skillCapability } from "../capability/skill";
20
22
  import type { LoadContext, LoadResult, SourceMeta } from "../capability/types";
21
23
 
22
- import { calculateDepth, createSourceMeta, getProjectPath, loadFilesFromDir } from "./helpers";
24
+ import { calculateDepth, createSourceMeta, getProjectPath, loadFilesFromDir, scanSkillsFromDir } from "./helpers";
23
25
 
24
26
  const PROVIDER_ID = "github";
25
27
  const DISPLAY_NAME = "GitHub Copilot";
@@ -97,6 +99,32 @@ function transformInstruction(name: string, content: string, filePath: string, s
97
99
  };
98
100
  }
99
101
 
102
+ // =============================================================================
103
+ // Skills
104
+ // =============================================================================
105
+
106
+ /**
107
+ * Load skills from `.github/skills/<name>/SKILL.md`.
108
+ *
109
+ * GitHub documents this layout for Copilot Agent Skills and matches the
110
+ * non-recursive shape `scanSkillsFromDir` already expects. `requireDescription`
111
+ * is on to match the Agent Skills spec (name + description are mandatory) and
112
+ * the sibling `native`/`omp-plugins` providers.
113
+ *
114
+ * @see https://docs.github.com/en/copilot/how-tos/copilot-on-github/customize-copilot/customize-cloud-agent/add-skills
115
+ */
116
+ async function loadSkills(ctx: LoadContext): Promise<LoadResult<Skill>> {
117
+ const skillsDir = getProjectPath(ctx, "github", "skills");
118
+ if (!skillsDir) return { items: [], warnings: [] };
119
+
120
+ return scanSkillsFromDir(ctx, {
121
+ dir: skillsDir,
122
+ providerId: PROVIDER_ID,
123
+ level: "project",
124
+ requireDescription: true,
125
+ });
126
+ }
127
+
100
128
  // =============================================================================
101
129
  // Provider Registration
102
130
  // =============================================================================
@@ -116,3 +144,11 @@ registerProvider(instructionCapability.id, {
116
144
  priority: PRIORITY,
117
145
  load: loadInstructions,
118
146
  });
147
+
148
+ registerProvider<Skill>(skillCapability.id, {
149
+ id: PROVIDER_ID,
150
+ displayName: DISPLAY_NAME,
151
+ description: "Load skills from .github/skills/*/SKILL.md",
152
+ priority: PRIORITY,
153
+ load: loadSkills,
154
+ });
@@ -212,6 +212,7 @@ export interface ParsedAgentFields {
212
212
  output?: unknown;
213
213
  thinkingLevel?: ThinkingLevel;
214
214
  autoloadSkills?: string[];
215
+ readSummarize?: boolean;
215
216
  blocking?: boolean;
216
217
  }
217
218
 
@@ -265,10 +266,11 @@ export function parseAgentFields(frontmatter: Record<string, unknown>): ParsedAg
265
266
  const thinkingLevel = parseThinkingLevel(rawThinkingLevel);
266
267
  const model = parseModelList(frontmatter.model);
267
268
  const blocking = parseBoolean(frontmatter.blocking);
269
+ const readSummarize = parseBoolean(frontmatter.readSummarize);
268
270
  const autoloadSkills = parseArrayOrCSV(frontmatter.autoloadSkills)
269
271
  ?.map(s => s.trim())
270
272
  .filter(Boolean);
271
- return { name, description, tools, spawns, model, output, thinkingLevel, blocking, autoloadSkills };
273
+ return { name, description, tools, spawns, model, output, thinkingLevel, blocking, autoloadSkills, readSummarize };
272
274
  }
273
275
 
274
276
  async function globIf(
@@ -41,10 +41,15 @@ const PI_SUBPATH_REMAPS: ReadonlyMap<string, string> = new Map<string, string>([
41
41
 
42
42
  const LEGACY_PI_SPECIFIER_FILTER = new RegExp(`^@(?:${PI_SCOPE_ALTERNATION})/(?:${PI_PACKAGE_ALTERNATION})(?:/.*)?$`);
43
43
  const LEGACY_PI_IMPORT_SPECIFIER_REGEX = new RegExp(
44
- `((?:from\\s+|import\\s*\\(\\s*)["'])(@(?:${PI_SCOPE_ALTERNATION})/(?:${PI_PACKAGE_ALTERNATION})(?:/[^"'()\\s]+)?)(["'])`,
44
+ `((?:from\\s+|import\\s+|import\\s*\\(\\s*)["'])(@(?:${PI_SCOPE_ALTERNATION})/(?:${PI_PACKAGE_ALTERNATION})(?:/[^"'()\\s]+)?)(["'])`,
45
45
  "g",
46
46
  );
47
47
  const resolvedSpecifierFallbacks = new Map<string, string>();
48
+ const SOURCE_MODULE_EXTENSIONS = [".ts", ".tsx", ".mts", ".cts", ".js", ".jsx", ".mjs", ".cjs"] as const;
49
+ const SUPPORTED_PACKAGE_IMPORT_CONDITIONS = new Set(["bun", "node", "import", "default"]);
50
+ const packageRootCache = new Map<string, string | null>();
51
+ const packageImportsCache = new Map<string, Record<string, unknown> | null>();
52
+ const PACKAGE_IMPORT_EXCLUDED = Symbol("packageImportExcluded");
48
53
 
49
54
  // Extensions that imported `@sinclair/typebox` directly used to resolve against a
50
55
  // real `@sinclair/typebox` install. The runtime dep was replaced with the Zod-backed
@@ -221,33 +226,245 @@ function rewriteLegacyPiImports(source: string): string {
221
226
  // Match the bare `@sinclair/typebox` import specifier (static + dynamic).
222
227
  // Subpath imports like `@sinclair/typebox/compiler` are intentionally excluded —
223
228
  // they expose TypeBox-only APIs the Zod-backed shim does not provide.
224
- const TYPEBOX_IMPORT_SPECIFIER_REGEX = /((?:from\s+|import\s*\(\s*)["'])(@sinclair\/typebox)(["'])/g;
229
+ const TYPEBOX_IMPORT_SPECIFIER_REGEX = /((?:from\s+|import\s+|import\s*\(\s*)["'])(@sinclair\/typebox)(["'])/g;
225
230
 
226
231
  /**
227
- * Rewrite the legacy specifiers a Pi extension may import `@(scope)/pi-*` and
228
- * the bare `@sinclair/typebox` root to absolute `file://` URLs pointing at the
229
- * bundled package or compat shim. Every other specifier (relative siblings, the
230
- * extension's own bare dependencies) is left untouched so Bun resolves it
232
+ * Rewrite the extension-owned specifiers OMP must host-resolvelegacy
233
+ * `@(scope)/pi-*`, bare `@sinclair/typebox`, and package `imports` aliases like
234
+ * `#src/*` to absolute `file://` URLs. Every other specifier (relative
235
+ * siblings and third-party dependencies) is left untouched so Bun resolves it
231
236
  * natively from the extension's real on-disk location.
232
237
  */
233
- function rewriteLegacyExtensionSource(source: string): string {
238
+ async function rewriteLegacyExtensionSource(source: string, importerPath: string): Promise<string> {
234
239
  const withPi = rewriteLegacyPiImports(source);
235
- return withPi.replace(
240
+ const withTypeBox = withPi.replace(
236
241
  TYPEBOX_IMPORT_SPECIFIER_REGEX,
237
242
  (_match, prefix: string, _specifier: string, suffix: string) => {
238
243
  return `${prefix}${toImportSpecifier(TYPEBOX_SHIM_PATH)}${suffix}`;
239
244
  },
240
245
  );
246
+ return rewriteExtensionPackageImports(withTypeBox, importerPath);
247
+ }
248
+
249
+ function isRecord(value: unknown): value is Record<string, unknown> {
250
+ return typeof value === "object" && value !== null && !Array.isArray(value);
251
+ }
252
+
253
+ async function pathExists(p: string): Promise<boolean> {
254
+ try {
255
+ await fs.stat(p);
256
+ return true;
257
+ } catch {
258
+ return false;
259
+ }
260
+ }
261
+
262
+ function hasSourceModuleExtension(p: string): boolean {
263
+ const ext = path.extname(p).toLowerCase();
264
+ return (SOURCE_MODULE_EXTENSIONS as readonly string[]).includes(ext);
265
+ }
266
+
267
+ async function resolveSourceModuleFile(basePath: string): Promise<string | null> {
268
+ try {
269
+ const stats = await fs.stat(basePath);
270
+ if (stats.isFile()) {
271
+ // Non-source files (JSON, WASM, text assets, etc.) bypass the on-load
272
+ // rewrite hook so Bun's native loaders handle them; our hook would
273
+ // otherwise pass them through `getLoader()` which falls back to `js`.
274
+ return hasSourceModuleExtension(basePath) ? realpathOrSelf(basePath) : null;
275
+ }
276
+ if (stats.isDirectory()) {
277
+ for (const extension of SOURCE_MODULE_EXTENSIONS) {
278
+ const resolved = await resolveSourceModuleFile(path.join(basePath, `index${extension}`));
279
+ if (resolved) return resolved;
280
+ }
281
+ }
282
+ } catch {
283
+ // Fall through to extension candidates below.
284
+ }
285
+
286
+ if (path.extname(basePath)) {
287
+ return null;
288
+ }
289
+
290
+ for (const extension of SOURCE_MODULE_EXTENSIONS) {
291
+ const resolved = await resolveSourceModuleFile(`${basePath}${extension}`);
292
+ if (resolved) return resolved;
293
+ }
294
+ return null;
295
+ }
296
+
297
+ async function findPackageRoot(importerPath: string): Promise<string | null> {
298
+ let dir = path.dirname(importerPath);
299
+ while (true) {
300
+ const cached = packageRootCache.get(dir);
301
+ if (cached !== undefined) {
302
+ return cached;
303
+ }
304
+
305
+ if (await pathExists(path.join(dir, "package.json"))) {
306
+ packageRootCache.set(path.dirname(importerPath), dir);
307
+ return dir;
308
+ }
309
+
310
+ const parent = path.dirname(dir);
311
+ if (parent === dir) {
312
+ packageRootCache.set(path.dirname(importerPath), null);
313
+ return null;
314
+ }
315
+ dir = parent;
316
+ }
317
+ }
318
+
319
+ async function readPackageImports(packageRoot: string): Promise<Record<string, unknown> | null> {
320
+ const cached = packageImportsCache.get(packageRoot);
321
+ if (cached !== undefined) {
322
+ return cached;
323
+ }
324
+
325
+ let imports: Record<string, unknown> | null = null;
326
+ try {
327
+ const pkg = await Bun.file(path.join(packageRoot, "package.json")).json();
328
+ if (isRecord(pkg) && isRecord(pkg.imports)) {
329
+ imports = pkg.imports;
330
+ }
331
+ } catch {
332
+ imports = null;
333
+ }
334
+ packageImportsCache.set(packageRoot, imports);
335
+ return imports;
336
+ }
337
+
338
+ type PackageImportTargetSelection = string | typeof PACKAGE_IMPORT_EXCLUDED | null;
339
+ type ResolvedPackageImportTargetSelection = string | typeof PACKAGE_IMPORT_EXCLUDED;
340
+
341
+ function selectPackageImportTarget(entry: unknown): PackageImportTargetSelection {
342
+ if (entry === null) {
343
+ return PACKAGE_IMPORT_EXCLUDED;
344
+ }
345
+ if (typeof entry === "string") {
346
+ return entry;
347
+ }
348
+ if (Array.isArray(entry)) {
349
+ for (const item of entry) {
350
+ const target = selectPackageImportTarget(item);
351
+ if (target !== null) return target;
352
+ }
353
+ return null;
354
+ }
355
+ if (!isRecord(entry)) {
356
+ return null;
357
+ }
358
+ for (const [condition, value] of Object.entries(entry)) {
359
+ if (!SUPPORTED_PACKAGE_IMPORT_CONDITIONS.has(condition)) {
360
+ continue;
361
+ }
362
+ const target = selectPackageImportTarget(value);
363
+ if (target !== null) return target;
364
+ }
365
+ return null;
366
+ }
367
+
368
+ async function resolvePackageImportTarget(
369
+ packageRoot: string,
370
+ target: string,
371
+ wildcard: string | null,
372
+ ): Promise<string | null> {
373
+ if (!target.startsWith("./")) {
374
+ return null;
375
+ }
376
+ const substituted = wildcard === null ? target : target.replaceAll("*", wildcard);
377
+ return resolveSourceModuleFile(path.resolve(packageRoot, substituted));
378
+ }
379
+
380
+ async function resolvePackageImportSpecifier(specifier: string, importerPath: string): Promise<string | null> {
381
+ if (!specifier.startsWith("#")) {
382
+ return null;
383
+ }
384
+
385
+ const packageRoot = await findPackageRoot(importerPath);
386
+ if (!packageRoot) {
387
+ return null;
388
+ }
389
+
390
+ const imports = await readPackageImports(packageRoot);
391
+ if (!imports) {
392
+ return null;
393
+ }
394
+
395
+ const exactTarget = selectPackageImportTarget(imports[specifier]);
396
+ if (exactTarget === PACKAGE_IMPORT_EXCLUDED) {
397
+ return null;
398
+ }
399
+ if (exactTarget !== null) {
400
+ return resolvePackageImportTarget(packageRoot, exactTarget, null);
401
+ }
402
+
403
+ let bestMatch: { keyLength: number; target: ResolvedPackageImportTargetSelection; wildcard: string } | null = null;
404
+ for (const [key, entry] of Object.entries(imports)) {
405
+ const starIndex = key.indexOf("*");
406
+ if (starIndex === -1) continue;
407
+
408
+ const prefix = key.slice(0, starIndex);
409
+ const suffix = key.slice(starIndex + 1);
410
+ if (!specifier.startsWith(prefix) || !specifier.endsWith(suffix)) {
411
+ continue;
412
+ }
413
+
414
+ const target = selectPackageImportTarget(entry);
415
+ if (target === null) {
416
+ continue;
417
+ }
418
+
419
+ if (!bestMatch || key.length > bestMatch.keyLength) {
420
+ bestMatch = {
421
+ keyLength: key.length,
422
+ target,
423
+ wildcard: specifier.slice(prefix.length, specifier.length - suffix.length),
424
+ };
425
+ }
426
+ }
427
+
428
+ if (!bestMatch || bestMatch.target === PACKAGE_IMPORT_EXCLUDED) {
429
+ return null;
430
+ }
431
+ return resolvePackageImportTarget(packageRoot, bestMatch.target, bestMatch.wildcard);
432
+ }
433
+
434
+ const PACKAGE_IMPORT_SPECIFIER_REGEX = /((?:from\s+|import\s+|import\s*\(\s*)["'])(#[^"'()\s]+)(["'])/g;
435
+
436
+ async function rewriteExtensionPackageImports(source: string, importerPath: string): Promise<string> {
437
+ let rewritten = "";
438
+ let lastIndex = 0;
439
+ for (const match of source.matchAll(PACKAGE_IMPORT_SPECIFIER_REGEX)) {
440
+ const matchIndex = match.index;
441
+ if (matchIndex === undefined) continue;
442
+
443
+ const [fullMatch, prefix, specifier, suffix] = match;
444
+ if (!prefix || !specifier || !suffix) continue;
445
+
446
+ const resolved = await resolvePackageImportSpecifier(specifier, importerPath);
447
+ if (!resolved) continue;
448
+
449
+ rewritten += source.slice(lastIndex, matchIndex);
450
+ rewritten += `${prefix}${toImportSpecifier(resolved)}${suffix}`;
451
+ lastIndex = matchIndex + fullMatch.length;
452
+ }
453
+
454
+ if (lastIndex === 0) {
455
+ return source;
456
+ }
457
+ return `${rewritten}${source.slice(lastIndex)}`;
241
458
  }
242
459
 
243
460
  function escapeRegExp(value: string): string {
244
461
  return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
245
462
  }
246
463
 
247
- // Match relative import specifiers (static `from "./…"` and dynamic
248
- // `import("./…")`). Used to walk an extension's own module graph; bare and
249
- // absolute specifiers are deliberately excluded.
250
- const RELATIVE_IMPORT_SPECIFIER_REGEX = /(?:from\s+|import\s*\(\s*)["'](\.\.?\/[^"']+)["']/g;
464
+ // Match source modules in an extension graph (relative imports and package
465
+ // `imports` aliases such as `#src/*`). Bare third-party dependencies remain
466
+ // native Bun resolutions.
467
+ const EXTENSION_GRAPH_SPECIFIER_REGEX = /(?:from\s+|import\s+|import\s*\(\s*)["']((?:\.\.?\/|#)[^"']+)["']/g;
251
468
 
252
469
  // Extension entry realpaths that already have a load-time rewrite hook
253
470
  // installed. Each `Bun.plugin()` registration is process-global and permanent,
@@ -287,10 +504,14 @@ async function collectExtensionModules(entryRealPath: string): Promise<Set<strin
287
504
  }
288
505
  modules.add(file);
289
506
  const dir = path.dirname(file);
290
- for (const match of source.matchAll(RELATIVE_IMPORT_SPECIFIER_REGEX)) {
507
+ for (const match of source.matchAll(EXTENSION_GRAPH_SPECIFIER_REGEX)) {
508
+ const specifier = match[1];
509
+ if (!specifier) continue;
291
510
  try {
292
- const resolved = await realpathOrSelf(Bun.resolveSync(match[1], dir));
293
- if (!modules.has(resolved)) {
511
+ const resolved = specifier.startsWith("#")
512
+ ? await resolvePackageImportSpecifier(specifier, file)
513
+ : await realpathOrSelf(Bun.resolveSync(specifier, dir));
514
+ if (resolved && !modules.has(resolved)) {
294
515
  queue.push(resolved);
295
516
  }
296
517
  } catch {
@@ -303,11 +524,12 @@ async function collectExtensionModules(entryRealPath: string): Promise<Set<strin
303
524
 
304
525
  /**
305
526
  * Install a `Bun.plugin()` `onLoad` hook scoped to exactly the modules in an
306
- * extension's relative-import graph, so their legacy `@(scope)/pi-*` and bare
307
- * `@sinclair/typebox` imports are rewritten at load time. A runtime `onLoad`
308
- * cannot fall through (Bun requires a result object), so the filter is an
309
- * exact-path alternation of the graph's realpaths — it never matches the host,
310
- * other extensions, `node_modules` deps, or unrelated project source.
527
+ * extension's source graph, so their legacy `@(scope)/pi-*`, bare
528
+ * `@sinclair/typebox`, and local package-import aliases are rewritten at load
529
+ * time. A runtime `onLoad` cannot fall through (Bun requires a result object),
530
+ * so the filter is an exact-path alternation of the graph's realpaths — it
531
+ * never matches the host, other extensions, `node_modules` deps, or unrelated
532
+ * project source.
311
533
  */
312
534
  async function ensureExtensionGraphHook(entryRealPath: string): Promise<void> {
313
535
  if (hookedExtensionEntries.has(entryRealPath)) {
@@ -322,9 +544,8 @@ async function ensureExtensionGraphHook(entryRealPath: string): Promise<void> {
322
544
  name: `omp:legacy-pi-ext:${Bun.hash(entryRealPath).toString(36)}`,
323
545
  setup(build) {
324
546
  build.onLoad({ filter, namespace: "file" }, async args => {
325
- // Re-read on every load so a `?mtime` reload picks up edited source.
326
547
  const raw = await Bun.file(args.path).text();
327
- return { contents: rewriteLegacyExtensionSource(raw), loader: getLoader(args.path) };
548
+ return { contents: await rewriteLegacyExtensionSource(raw, args.path), loader: getLoader(args.path) };
328
549
  });
329
550
  },
330
551
  });
@@ -337,9 +558,8 @@ async function ensureExtensionGraphHook(entryRealPath: string): Promise<void> {
337
558
  * and `__dirname`-relative `readFileSync` asset loads (HTML/CSS bundled next to
338
559
  * the entry) resolve exactly as they do under the original Pi runtime — no
339
560
  * temp-directory mirroring and no asset copying. An `onLoad` hook scoped to the
340
- * entry's relative-import graph rewrites only the legacy `@(scope)/pi-*` and
341
- * `@sinclair/typebox` imports in the extension's own source; everything else
342
- * resolves natively.
561
+ * entry's source graph rewrites only host-resolved compatibility imports in the
562
+ * extension's own source; everything else resolves natively.
343
563
  */
344
564
  export async function loadLegacyPiModule(resolvedPath: string): Promise<unknown> {
345
565
  // Bun reports the realpath of a loaded module to `onLoad` and exposes it as