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

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 (65) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/dist/cli.js +2749 -2726
  3. package/dist/types/auto-thinking/classifier.d.ts +4 -2
  4. package/dist/types/config/model-discovery.d.ts +1 -0
  5. package/dist/types/config/model-registry.d.ts +4 -0
  6. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +22 -7
  7. package/dist/types/extensibility/plugins/loader.d.ts +16 -9
  8. package/dist/types/extensibility/tool-event-input.d.ts +2 -0
  9. package/dist/types/hindsight/content.d.ts +2 -10
  10. package/dist/types/modes/components/chat-transcript-builder.d.ts +2 -0
  11. package/dist/types/modes/components/custom-editor.d.ts +3 -0
  12. package/dist/types/modes/components/status-line/types.d.ts +1 -0
  13. package/dist/types/modes/controllers/command-controller.d.ts +2 -0
  14. package/dist/types/modes/controllers/input-controller.d.ts +14 -0
  15. package/dist/types/modes/controllers/selector-controller.d.ts +11 -0
  16. package/dist/types/session/agent-session.d.ts +6 -6
  17. package/dist/types/session/provider-image-budget.d.ts +3 -0
  18. package/dist/types/session/session-context.d.ts +6 -5
  19. package/dist/types/task/parallel.d.ts +4 -0
  20. package/dist/types/thinking.d.ts +8 -1
  21. package/dist/types/tiny/title-client.d.ts +2 -2
  22. package/dist/types/utils/tools-manager.d.ts +2 -0
  23. package/package.json +12 -12
  24. package/src/auto-thinking/classifier.ts +7 -2
  25. package/src/cli/profile-alias.ts +38 -7
  26. package/src/cli/usage-cli.ts +5 -1
  27. package/src/config/model-discovery.ts +59 -8
  28. package/src/config/model-registry.ts +74 -3
  29. package/src/discovery/omp-extension-roots.ts +1 -3
  30. package/src/extensibility/extensions/wrapper.ts +3 -2
  31. package/src/extensibility/hooks/tool-wrapper.ts +4 -3
  32. package/src/extensibility/plugins/legacy-pi-compat.ts +40 -8
  33. package/src/extensibility/plugins/loader.ts +71 -23
  34. package/src/extensibility/plugins/marketplace/manager.ts +134 -0
  35. package/src/extensibility/tool-event-input.ts +57 -0
  36. package/src/hindsight/content.ts +21 -12
  37. package/src/mcp/tool-bridge.ts +27 -2
  38. package/src/mnemopi/state.ts +5 -2
  39. package/src/modes/components/agent-transcript-viewer.ts +193 -41
  40. package/src/modes/components/chat-transcript-builder.ts +6 -0
  41. package/src/modes/components/custom-editor.test.ts +18 -1
  42. package/src/modes/components/custom-editor.ts +77 -45
  43. package/src/modes/components/hook-editor.ts +15 -2
  44. package/src/modes/components/settings-selector.ts +2 -2
  45. package/src/modes/components/status-line/component.ts +52 -8
  46. package/src/modes/components/status-line/segments.ts +5 -1
  47. package/src/modes/components/status-line/types.ts +1 -0
  48. package/src/modes/components/welcome.ts +12 -14
  49. package/src/modes/controllers/command-controller.ts +16 -5
  50. package/src/modes/controllers/input-controller.ts +115 -3
  51. package/src/modes/controllers/selector-controller.ts +19 -1
  52. package/src/modes/interactive-mode.ts +3 -3
  53. package/src/modes/utils/ui-helpers.ts +3 -3
  54. package/src/sdk.ts +8 -10
  55. package/src/session/agent-session.ts +193 -49
  56. package/src/session/provider-image-budget.ts +86 -0
  57. package/src/session/session-context.ts +14 -7
  58. package/src/session/session-storage.ts +24 -2
  59. package/src/session/snapcompact-inline.ts +19 -3
  60. package/src/slash-commands/builtin-registry.ts +0 -22
  61. package/src/slash-commands/helpers/usage-report.ts +9 -1
  62. package/src/task/parallel.ts +6 -1
  63. package/src/thinking.ts +9 -2
  64. package/src/tiny/title-client.ts +75 -21
  65. package/src/utils/tools-manager.ts +67 -10
@@ -67,6 +67,7 @@ import {
67
67
  DISCOVERY_DEFAULT_MAX_TOKENS,
68
68
  type DiscoveryContext,
69
69
  type DiscoveryProviderConfig,
70
+ discoverLlamaCppModelContextWindow,
70
71
  discoverModelsByProviderType,
71
72
  getImplicitOllamaBaseUrl,
72
73
  getOllamaContextLengthOverride,
@@ -759,6 +760,50 @@ export class ModelRegistry {
759
760
  }
760
761
  }
761
762
 
763
+ /**
764
+ * Refresh dynamic metadata that can appear only after a local model loads.
765
+ */
766
+ async refreshSelectedModelMetadata(model: Model<Api>): Promise<Model<Api>> {
767
+ const isLlamaCppDiscovery = this.#discoverableProviders.some(
768
+ providerConfig => providerConfig.provider === model.provider && providerConfig.discovery.type === "llama.cpp",
769
+ );
770
+ if (!isLlamaCppDiscovery) {
771
+ return model;
772
+ }
773
+ const contextWindow = await discoverLlamaCppModelContextWindow(model, this.#nonResolvingDiscoveryContext());
774
+ if (contextWindow === undefined) {
775
+ return this.find(model.provider, model.id) ?? model;
776
+ }
777
+ const current = this.find(model.provider, model.id) ?? model;
778
+ const override = this.#resolveLiveModelOverride(current);
779
+ const customModel = this.#resolveLiveCustomModelOverlay(current);
780
+ const patch: ModelPatch = {};
781
+ if (
782
+ override?.contextWindow === undefined &&
783
+ customModel?.contextWindow === undefined &&
784
+ current.contextWindow !== contextWindow
785
+ ) {
786
+ patch.contextWindow = contextWindow;
787
+ }
788
+ const maxTokens = Math.min(contextWindow, DISCOVERY_DEFAULT_MAX_TOKENS);
789
+ if (
790
+ override?.maxTokens === undefined &&
791
+ customModel?.maxTokens === undefined &&
792
+ current.maxTokens !== maxTokens
793
+ ) {
794
+ patch.maxTokens = maxTokens;
795
+ }
796
+ if (patch.contextWindow === undefined && patch.maxTokens === undefined) {
797
+ return current;
798
+ }
799
+ const patched = applyModelPatch(current, patch, "merge");
800
+ this.#models = this.#models.map(candidate =>
801
+ candidate.provider === current.provider && candidate.id === current.id ? patched : candidate,
802
+ );
803
+ this.#rebuildCanonicalIndex();
804
+ return patched;
805
+ }
806
+
762
807
  /**
763
808
  * Discover models for providers registered at runtime via `fetchDynamicModels`
764
809
  * (extension providers). Merges the discovered catalog into the existing model
@@ -1008,7 +1053,7 @@ export class ModelRegistry {
1008
1053
  provider: providerConfig.provider,
1009
1054
  status: "cached",
1010
1055
  optional: providerConfig.optional ?? false,
1011
- stale: !cache.fresh || !cache.authoritative || configStale,
1056
+ stale: providerConfig.discovery.type === "llama.cpp" || !cache.fresh || !cache.authoritative || configStale,
1012
1057
  fetchedAt: cache.updatedAt,
1013
1058
  models: models.map(model => model.id),
1014
1059
  });
@@ -1272,7 +1317,9 @@ export class ModelRegistry {
1272
1317
  const cacheProviderId = this.#configuredDiscoveryCacheProviderId(providerConfig);
1273
1318
  const cached = readModelCache<Api>(cacheProviderId, 24 * 60 * 60 * 1000, Date.now, this.#cacheDbPath);
1274
1319
  const cacheOlderThanConfig = cached !== null && this.#isDiscoveryCacheOlderThanModelsConfig(cached.updatedAt);
1275
- const effectiveStrategy = strategy === "online-if-uncached" && cacheOlderThanConfig ? "online" : strategy;
1320
+ const bypassFreshCache = providerConfig.discovery.type === "llama.cpp" && strategy === "online-if-uncached";
1321
+ const effectiveStrategy =
1322
+ strategy === "online-if-uncached" && (cacheOlderThanConfig || bypassFreshCache) ? "online" : strategy;
1276
1323
  const requiresAuth = !this.#keylessProviders.has(providerConfig.provider);
1277
1324
  if (requiresAuth) {
1278
1325
  const apiKey = await this.#peekApiKeyForProvider(providerConfig.provider);
@@ -1335,7 +1382,7 @@ export class ModelRegistry {
1335
1382
  provider: providerId,
1336
1383
  status,
1337
1384
  optional: providerConfig.optional ?? false,
1338
- stale: result.stale || status === "cached" || (cacheOlderThanConfig && status !== "ok"),
1385
+ stale: result.stale || status === "cached" || ((cacheOlderThanConfig || bypassFreshCache) && status !== "ok"),
1339
1386
  fetchedAt: discoveryError ? cached?.updatedAt : Date.now(),
1340
1387
  models: result.models.map(model => model.id),
1341
1388
  error: discoveryError,
@@ -1365,6 +1412,13 @@ export class ModelRegistry {
1365
1412
  };
1366
1413
  }
1367
1414
 
1415
+ #nonResolvingDiscoveryContext(): DiscoveryContext {
1416
+ return {
1417
+ fetch: this.#fetch,
1418
+ getBearerApiKeyResolver: async () => undefined,
1419
+ };
1420
+ }
1421
+
1368
1422
  #warnProviderDiscoveryFailure(providerConfig: DiscoveryProviderConfig, error: string): void {
1369
1423
  const previous = this.#lastDiscoveryWarnings.get(providerConfig.provider);
1370
1424
  if (previous === error) {
@@ -1574,6 +1628,23 @@ export class ModelRegistry {
1574
1628
  return this.#applyProviderTransportOverride(model, override);
1575
1629
  });
1576
1630
  }
1631
+ #resolveLiveModelOverride(model: Model<Api>): ModelOverride | undefined {
1632
+ const providerOverrides = this.#modelOverrides.get(model.provider);
1633
+ if (!providerOverrides) return undefined;
1634
+ return resolveModelOverrideWithAliases(
1635
+ providerOverrides,
1636
+ model,
1637
+ (provider, id) => this.find(provider, id) !== undefined,
1638
+ );
1639
+ }
1640
+
1641
+ #resolveLiveCustomModelOverlay(model: Model<Api>): CustomModelOverlay | undefined {
1642
+ return (
1643
+ this.#customModelOverlays.find(overlay => overlay.provider === model.provider && overlay.id === model.id) ??
1644
+ this.#runtimeModelOverlays.find(overlay => overlay.provider === model.provider && overlay.id === model.id)
1645
+ );
1646
+ }
1647
+
1577
1648
  #applyModelOverrides(models: Model<Api>[], overrides: Map<string, Map<string, ModelOverride>>): Model<Api>[] {
1578
1649
  if (overrides.size === 0) return models;
1579
1650
  let liveKeys: Set<string> | null = null;
@@ -180,9 +180,7 @@ export async function listOmpExtensionRoots(ctx: LoadContext): Promise<OmpExtens
180
180
  async function listInstalledPluginRoots(ctx: LoadContext): Promise<InjectedRoot[]> {
181
181
  try {
182
182
  const plugins = await getEnabledPlugins(ctx.cwd, { home: ctx.home });
183
- // Installed plugins are always user-scope; project disablement is already
184
- // honored by `getEnabledPlugins` via `loadProjectOverrides`.
185
- return plugins.map(({ path: p }) => ({ path: p, level: "user" }));
183
+ return plugins.map(({ path: p, scope }) => ({ path: p, level: scope }));
186
184
  } catch (err) {
187
185
  logger.debug("listInstalledPluginRoots: enumeration failed", { error: String(err) });
188
186
  return [];
@@ -6,6 +6,7 @@ import type { ImageContent, Static, TextContent, TSchema } from "@oh-my-pi/pi-ai
6
6
  import type { Settings } from "../../config/settings";
7
7
  import type { Theme } from "../../modes/theme/theme";
8
8
  import { type ApprovalMode, formatApprovalPrompt, requiresApproval } from "../../tools/approval";
9
+ import { normalizeToolEventInput } from "../tool-event-input";
9
10
  import { applyToolProxy } from "../tool-proxy";
10
11
  import type { ExtensionRunner } from "./runner";
11
12
  import type { RegisteredTool, ToolCallEventResult } from "./types";
@@ -185,7 +186,7 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
185
186
  type: "tool_call",
186
187
  toolName: this.tool.name,
187
188
  toolCallId,
188
- input: params as Record<string, unknown>,
189
+ input: normalizeToolEventInput(this.tool.name, params as Record<string, unknown>),
189
190
  })) as ToolCallEventResult | undefined;
190
191
 
191
192
  if (callResult?.block) {
@@ -220,7 +221,7 @@ export class ExtensionToolWrapper<TParameters extends TSchema = TSchema, TDetail
220
221
  type: "tool_result",
221
222
  toolName: this.tool.name,
222
223
  toolCallId,
223
- input: params as Record<string, unknown>,
224
+ input: normalizeToolEventInput(this.tool.name, params as Record<string, unknown>),
224
225
  content: result.content,
225
226
  details: result.details,
226
227
  isError: !!executionError,
@@ -3,6 +3,7 @@
3
3
  */
4
4
  import type { AgentTool, AgentToolContext, AgentToolUpdateCallback } from "@oh-my-pi/pi-agent-core";
5
5
  import type { Static, TSchema } from "@oh-my-pi/pi-ai";
6
+ import { normalizeToolEventInput } from "../tool-event-input";
6
7
  import { applyToolProxy } from "../tool-proxy";
7
8
  import type { HookRunner } from "./runner";
8
9
  import type { ToolCallEventResult, ToolResultEventResult } from "./types";
@@ -46,7 +47,7 @@ export class HookToolWrapper<TParameters extends TSchema = TSchema, TDetails = u
46
47
  type: "tool_call",
47
48
  toolName: this.tool.name,
48
49
  toolCallId,
49
- input: params as Record<string, unknown>,
50
+ input: normalizeToolEventInput(this.tool.name, params as Record<string, unknown>),
50
51
  })) as ToolCallEventResult | undefined;
51
52
 
52
53
  if (callResult?.block) {
@@ -72,7 +73,7 @@ export class HookToolWrapper<TParameters extends TSchema = TSchema, TDetails = u
72
73
  type: "tool_result",
73
74
  toolName: this.tool.name,
74
75
  toolCallId,
75
- input: params as Record<string, unknown>,
76
+ input: normalizeToolEventInput(this.tool.name, params as Record<string, unknown>),
76
77
  content: result.content,
77
78
  details: result.details,
78
79
  isError: false,
@@ -95,7 +96,7 @@ export class HookToolWrapper<TParameters extends TSchema = TSchema, TDetails = u
95
96
  type: "tool_result",
96
97
  toolName: this.tool.name,
97
98
  toolCallId,
98
- input: params as Record<string, unknown>,
99
+ input: normalizeToolEventInput(this.tool.name, params as Record<string, unknown>),
99
100
  content: [{ type: "text", text: err instanceof Error ? err.message : String(err) }],
100
101
  details: undefined,
101
102
  isError: true,
@@ -93,14 +93,22 @@ const TYPEBOX_SPECIFIER_FILTER = /^(?:@sinclair\/typebox|typebox)$/;
93
93
  // so they keep working when on-disk layout differs from the monorepo tree.
94
94
  /**
95
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`.
96
+ * (or any stand-in supplied by tests). Bun compiled binaries report one of:
99
97
  *
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`.
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.
104
112
  *
105
113
  * Exported for tests; production callers use `BUNFS_PACKAGE_ROOT` below.
106
114
  */
@@ -110,6 +118,10 @@ export function __computeBunfsPackageRoot(metaDir: string, pathImpl: typeof path
110
118
  if (normalizedMetaDir.endsWith(pluginsDirSuffix)) {
111
119
  return pathImpl.resolve(metaDir, "..", "..", "..", "..");
112
120
  }
121
+ const parent = pathImpl.dirname(metaDir);
122
+ if (pathImpl.basename(pathImpl.normalize(parent)) === "root") {
123
+ return `${parent + pathImpl.sep}packages`;
124
+ }
113
125
  return pathImpl.join(metaDir, "packages");
114
126
  }
115
127
 
@@ -137,11 +149,31 @@ export function __computeBundledSelfPackageRoot(metaDir: string, pathImpl: typeo
137
149
 
138
150
  const BUNFS_PACKAGE_ROOT = IS_COMPILED_BINARY ? __computeBunfsPackageRoot(import.meta.dir) : null;
139
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
+
140
172
  function bunfsPath(...segments: string[]): string {
141
173
  if (!BUNFS_PACKAGE_ROOT) {
142
174
  throw new Error("bunfsPath is only valid in compiled-binary mode");
143
175
  }
144
- return path.join(BUNFS_PACKAGE_ROOT, ...segments);
176
+ return __joinBunfsPath(BUNFS_PACKAGE_ROOT, segments);
145
177
  }
146
178
 
147
179
  function resolveBundledSelfPackageRoot(): string | undefined {
@@ -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
  // =============================================================================
@@ -11,6 +11,8 @@ import * as os from "node:os";
11
11
  import * as path from "node:path";
12
12
 
13
13
  import { isEnoent, logger, pathIsWithin } from "@oh-my-pi/pi-utils";
14
+ import { normalizePluginRuntimeConfig } from "../runtime-config";
15
+ import type { PluginRuntimeConfig } from "../types";
14
16
 
15
17
  import { cachePlugin } from "./cache";
16
18
  import { classifySource, fetchMarketplace, parseMarketplaceCatalog, promoteCloneToCache } from "./fetcher";
@@ -38,6 +40,16 @@ import type {
38
40
  } from "./types";
39
41
  import { buildPluginId, parsePluginId } from "./types";
40
42
 
43
+ const RUNTIME_PACKAGE_NAME_RE = /^(?:@[a-z0-9][a-z0-9._~-]*\/)?[a-z0-9][a-z0-9._~-]*$/;
44
+ const MAX_RUNTIME_PACKAGE_NAME_LENGTH = 214;
45
+
46
+ function assertRuntimePackageName(name: string): string {
47
+ if (name.length > MAX_RUNTIME_PACKAGE_NAME_LENGTH || !RUNTIME_PACKAGE_NAME_RE.test(name)) {
48
+ throw new Error(`Invalid marketplace plugin package name: ${JSON.stringify(name)}`);
49
+ }
50
+ return name;
51
+ }
52
+
41
53
  // ── Options ──────────────────────────────────────────────────────────────────
42
54
 
43
55
  export interface MarketplaceManagerOptions {
@@ -298,6 +310,9 @@ export class MarketplaceManager {
298
310
  }
299
311
  }
300
312
 
313
+ const packageName = await this.#resolvePluginPackageName(cachePath, name);
314
+ const previousPackageNames = await this.#resolveInstalledPackageNames(existing ?? [], name);
315
+
301
316
  // Only now clean up old entries — new cache succeeded, so it is safe to remove old ones.
302
317
  if (existing && existing.length > 0) {
303
318
  // Remove from scope-appropriate registry first, then cross-check refs before disk deletion.
@@ -337,6 +352,13 @@ export class MarketplaceManager {
337
352
  const newInstReg = addInstalledPlugin(freshInstReg, pluginId, installedEntry);
338
353
  await writeInstalledPluginsRegistry(registryPath, newInstReg);
339
354
 
355
+ for (const previousPackageName of previousPackageNames) {
356
+ if (previousPackageName !== packageName) {
357
+ await this.#removeRuntimePlugin(scope, previousPackageName);
358
+ }
359
+ }
360
+ await this.#registerRuntimePlugin(scope, packageName, cachePath, version, wasDisabled ? false : undefined);
361
+
340
362
  this.#clearCache();
341
363
 
342
364
  logger.debug("Plugin installed", { pluginId, version, cachePath });
@@ -434,6 +456,7 @@ export class MarketplaceManager {
434
456
  const targetEntries = targetScope === "project" ? projectEntries! : userEntries!;
435
457
  const targetReg = targetScope === "project" ? projectReg : userReg;
436
458
  const registryPath = this.#registryPath(targetScope);
459
+ const packageNames = await this.#resolveInstalledPackageNames(targetEntries, parsed.name);
437
460
 
438
461
  const updatedReg = removeInstalledPlugin(targetReg, pluginId);
439
462
  await writeInstalledPluginsRegistry(registryPath, updatedReg);
@@ -453,6 +476,10 @@ export class MarketplaceManager {
453
476
  }
454
477
  }
455
478
 
479
+ for (const packageName of packageNames) {
480
+ await this.#removeRuntimePlugin(targetScope, packageName);
481
+ }
482
+
456
483
  this.#clearCache();
457
484
 
458
485
  logger.debug("Plugin uninstalled", { pluginId, scope: targetScope });
@@ -539,6 +566,12 @@ export class MarketplaceManager {
539
566
  };
540
567
  await writeInstalledPluginsRegistry(registryPath, updated);
541
568
 
569
+ const fallbackName = parsePluginId(pluginId)?.name ?? pluginId;
570
+ const packageNames = await this.#resolveInstalledPackageNames(entries, fallbackName);
571
+ for (const packageName of packageNames) {
572
+ await this.#setRuntimePluginEnabled(targetScope, packageName, enabled);
573
+ }
574
+
542
575
  this.#clearCache();
543
576
 
544
577
  logger.debug("Plugin enabled state changed", { pluginId, enabled, scope: targetScope });
@@ -701,6 +734,107 @@ export class MarketplaceManager {
701
734
 
702
735
  // ── Private helpers ───────────────────────────────────────────────────────
703
736
 
737
+ #runtimeRoot(scope: "user" | "project"): string {
738
+ return path.dirname(this.#registryPath(scope));
739
+ }
740
+
741
+ #nodeModulesPath(scope: "user" | "project"): string {
742
+ return path.join(this.#runtimeRoot(scope), "node_modules");
743
+ }
744
+
745
+ #runtimeLockPath(scope: "user" | "project"): string {
746
+ return path.join(this.#runtimeRoot(scope), "omp-plugins.lock.json");
747
+ }
748
+
749
+ async #loadRuntimeConfig(scope: "user" | "project"): Promise<PluginRuntimeConfig> {
750
+ try {
751
+ return normalizePluginRuntimeConfig(await Bun.file(this.#runtimeLockPath(scope)).json());
752
+ } catch (err) {
753
+ if (isEnoent(err)) return normalizePluginRuntimeConfig({});
754
+ logger.warn("Failed to load marketplace plugin runtime config", {
755
+ path: this.#runtimeLockPath(scope),
756
+ error: String(err),
757
+ });
758
+ return normalizePluginRuntimeConfig({});
759
+ }
760
+ }
761
+
762
+ async #writeRuntimeConfig(scope: "user" | "project", config: PluginRuntimeConfig): Promise<void> {
763
+ await Bun.write(this.#runtimeLockPath(scope), JSON.stringify(config, null, 2));
764
+ }
765
+
766
+ async #resolvePluginPackageName(installPath: string, fallbackName: string): Promise<string> {
767
+ try {
768
+ const pkg: { name?: unknown } = await Bun.file(path.join(installPath, "package.json")).json();
769
+ const name = typeof pkg.name === "string" && pkg.name.length > 0 ? pkg.name : fallbackName;
770
+ return assertRuntimePackageName(name);
771
+ } catch (err) {
772
+ if (isEnoent(err)) return assertRuntimePackageName(fallbackName);
773
+ throw err;
774
+ }
775
+ }
776
+
777
+ #runtimePackagePath(scope: "user" | "project", packageName: string): string {
778
+ const nodeModules = path.resolve(this.#nodeModulesPath(scope));
779
+ const linkPath = path.resolve(nodeModules, assertRuntimePackageName(packageName));
780
+ const relative = path.relative(nodeModules, linkPath);
781
+ if (relative === "" || relative.startsWith("..") || path.isAbsolute(relative)) {
782
+ throw new Error(`Marketplace plugin package path escapes node_modules: ${JSON.stringify(packageName)}`);
783
+ }
784
+ return linkPath;
785
+ }
786
+
787
+ async #resolveInstalledPackageNames(
788
+ entries: readonly InstalledPluginEntry[],
789
+ fallbackName: string,
790
+ ): Promise<Set<string>> {
791
+ const packageNames = new Set<string>();
792
+ for (const entry of entries) {
793
+ packageNames.add(await this.#resolvePluginPackageName(entry.installPath, fallbackName));
794
+ }
795
+ return packageNames;
796
+ }
797
+
798
+ async #registerRuntimePlugin(
799
+ scope: "user" | "project",
800
+ packageName: string,
801
+ cachePath: string,
802
+ version: string,
803
+ enabled: boolean | undefined,
804
+ ): Promise<void> {
805
+ const linkPath = this.#runtimePackagePath(scope, packageName);
806
+ await fs.mkdir(path.dirname(linkPath), { recursive: true });
807
+ await fs.rm(linkPath, { recursive: true, force: true });
808
+ await fs.symlink(cachePath, linkPath, process.platform === "win32" ? "junction" : "dir");
809
+
810
+ const config = await this.#loadRuntimeConfig(scope);
811
+ const previous = config.plugins[packageName];
812
+ config.plugins[packageName] = {
813
+ version,
814
+ enabledFeatures: previous?.enabledFeatures ?? null,
815
+ enabled: enabled ?? previous?.enabled ?? true,
816
+ };
817
+ await this.#writeRuntimeConfig(scope, config);
818
+ }
819
+
820
+ async #removeRuntimePlugin(scope: "user" | "project", packageName: string): Promise<void> {
821
+ await fs.rm(this.#runtimePackagePath(scope, packageName), { recursive: true, force: true });
822
+
823
+ const config = await this.#loadRuntimeConfig(scope);
824
+ delete config.plugins[packageName];
825
+ delete config.settings[packageName];
826
+ await this.#writeRuntimeConfig(scope, config);
827
+ }
828
+
829
+ async #setRuntimePluginEnabled(scope: "user" | "project", packageName: string, enabled: boolean): Promise<void> {
830
+ const config = await this.#loadRuntimeConfig(scope);
831
+ const previous = config.plugins[packageName];
832
+ if (!previous) return;
833
+
834
+ config.plugins[packageName] = { ...previous, enabled };
835
+ await this.#writeRuntimeConfig(scope, config);
836
+ }
837
+
704
838
  #registryPath(scope: "user" | "project"): string {
705
839
  if (scope === "project") {
706
840
  if (!this.#opts.projectInstalledRegistryPath) {
@@ -0,0 +1,57 @@
1
+ const HASHLINE_FILE_PREFIX = "¶";
2
+ const HASHLINE_FILE_TAG_RE = /#[0-9a-fA-F]{4}$/u;
3
+
4
+ function stringField(input: Record<string, unknown>, key: string): string | undefined {
5
+ const value = input[key];
6
+ return typeof value === "string" && value.length > 0 ? value : undefined;
7
+ }
8
+
9
+ function normalizeHashlineHeaderPath(body: string): string | undefined {
10
+ const trimmed = body.trim();
11
+ if (trimmed.length === 0) return undefined;
12
+ const hashStart = HASHLINE_FILE_TAG_RE.exec(trimmed)?.index;
13
+ const rawPath = hashStart === undefined ? trimmed : trimmed.slice(0, hashStart);
14
+ if (rawPath.length < 2) return rawPath.length > 0 ? rawPath : undefined;
15
+ const first = rawPath[0];
16
+ const last = rawPath[rawPath.length - 1];
17
+ if ((first === '"' || first === "'") && first === last) return rawPath.slice(1, -1);
18
+ return rawPath;
19
+ }
20
+
21
+ function extractHashlinePaths(input: string): string[] {
22
+ const paths: string[] = [];
23
+ const stripped = input.startsWith("\uFEFF") ? input.slice(1) : input;
24
+ for (const rawLine of stripped.split("\n")) {
25
+ const line = rawLine.replace(/\r$/, "").trimStart();
26
+ if (!line.startsWith(HASHLINE_FILE_PREFIX)) continue;
27
+ let prefixEnd = 0;
28
+ while (prefixEnd < line.length && line[prefixEnd] === HASHLINE_FILE_PREFIX) prefixEnd++;
29
+ const path = normalizeHashlineHeaderPath(line.slice(prefixEnd));
30
+ if (path) paths.push(path);
31
+ }
32
+ return paths;
33
+ }
34
+
35
+ /** Adds derived compatibility fields to tool event input without changing tool execution parameters. */
36
+ export function normalizeToolEventInput(toolName: string, input: Record<string, unknown>): Record<string, unknown> {
37
+ if (toolName !== "edit" || stringField(input, "path")) return input;
38
+
39
+ // Hashline edit mode: the only authoritative target list is the parsed
40
+ // `¶PATH#TAG` headers inside `input`/`_input`. Trusting a passthrough
41
+ // `_path` here would let a model-supplied field override the real edit
42
+ // target and bypass extension gates that allowlist by path.
43
+ const rawInput = stringField(input, "input") ?? stringField(input, "_input");
44
+ if (rawInput !== undefined) {
45
+ const hashlinePaths = extractHashlinePaths(rawInput);
46
+ if (hashlinePaths.length === 0) return input;
47
+ if (hashlinePaths.length === 1) return { ...input, path: hashlinePaths[0], paths: hashlinePaths };
48
+ return { ...input, paths: hashlinePaths };
49
+ }
50
+
51
+ // Replace/patch modes: `path` is the real parameter; some hosts forward
52
+ // it as `_path` after schema normalization, so propagate it for gates.
53
+ const directPath = stringField(input, "_path");
54
+ if (directPath) return { ...input, path: directPath };
55
+
56
+ return input;
57
+ }