@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.
- package/CHANGELOG.md +30 -0
- package/dist/cli.js +2749 -2726
- package/dist/types/auto-thinking/classifier.d.ts +4 -2
- package/dist/types/config/model-discovery.d.ts +1 -0
- package/dist/types/config/model-registry.d.ts +4 -0
- package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +22 -7
- package/dist/types/extensibility/plugins/loader.d.ts +16 -9
- package/dist/types/extensibility/tool-event-input.d.ts +2 -0
- package/dist/types/hindsight/content.d.ts +2 -10
- package/dist/types/modes/components/chat-transcript-builder.d.ts +2 -0
- package/dist/types/modes/components/custom-editor.d.ts +3 -0
- package/dist/types/modes/components/status-line/types.d.ts +1 -0
- package/dist/types/modes/controllers/command-controller.d.ts +2 -0
- package/dist/types/modes/controllers/input-controller.d.ts +14 -0
- package/dist/types/modes/controllers/selector-controller.d.ts +11 -0
- package/dist/types/session/agent-session.d.ts +6 -6
- package/dist/types/session/provider-image-budget.d.ts +3 -0
- package/dist/types/session/session-context.d.ts +6 -5
- package/dist/types/task/parallel.d.ts +4 -0
- package/dist/types/thinking.d.ts +8 -1
- package/dist/types/tiny/title-client.d.ts +2 -2
- package/dist/types/utils/tools-manager.d.ts +2 -0
- package/package.json +12 -12
- package/src/auto-thinking/classifier.ts +7 -2
- package/src/cli/profile-alias.ts +38 -7
- package/src/cli/usage-cli.ts +5 -1
- package/src/config/model-discovery.ts +59 -8
- package/src/config/model-registry.ts +74 -3
- package/src/discovery/omp-extension-roots.ts +1 -3
- package/src/extensibility/extensions/wrapper.ts +3 -2
- package/src/extensibility/hooks/tool-wrapper.ts +4 -3
- package/src/extensibility/plugins/legacy-pi-compat.ts +40 -8
- package/src/extensibility/plugins/loader.ts +71 -23
- package/src/extensibility/plugins/marketplace/manager.ts +134 -0
- package/src/extensibility/tool-event-input.ts +57 -0
- package/src/hindsight/content.ts +21 -12
- package/src/mcp/tool-bridge.ts +27 -2
- package/src/mnemopi/state.ts +5 -2
- package/src/modes/components/agent-transcript-viewer.ts +193 -41
- package/src/modes/components/chat-transcript-builder.ts +6 -0
- package/src/modes/components/custom-editor.test.ts +18 -1
- package/src/modes/components/custom-editor.ts +77 -45
- package/src/modes/components/hook-editor.ts +15 -2
- package/src/modes/components/settings-selector.ts +2 -2
- package/src/modes/components/status-line/component.ts +52 -8
- package/src/modes/components/status-line/segments.ts +5 -1
- package/src/modes/components/status-line/types.ts +1 -0
- package/src/modes/components/welcome.ts +12 -14
- package/src/modes/controllers/command-controller.ts +16 -5
- package/src/modes/controllers/input-controller.ts +115 -3
- package/src/modes/controllers/selector-controller.ts +19 -1
- package/src/modes/interactive-mode.ts +3 -3
- package/src/modes/utils/ui-helpers.ts +3 -3
- package/src/sdk.ts +8 -10
- package/src/session/agent-session.ts +193 -49
- package/src/session/provider-image-budget.ts +86 -0
- package/src/session/session-context.ts +14 -7
- package/src/session/session-storage.ts +24 -2
- package/src/session/snapcompact-inline.ts +19 -3
- package/src/slash-commands/builtin-registry.ts +0 -22
- package/src/slash-commands/helpers/usage-report.ts +9 -1
- package/src/task/parallel.ts +6 -1
- package/src/thinking.ts +9 -2
- package/src/tiny/title-client.ts +75 -21
- 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
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
101
|
-
*
|
|
102
|
-
* the
|
|
103
|
-
* `<
|
|
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
|
|
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 {
|
|
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
|
-
*
|
|
55
|
-
*
|
|
56
|
-
*
|
|
57
|
-
*
|
|
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
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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 =
|
|
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 `<
|
|
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
|
|
85
|
-
|
|
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:
|
|
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
|
+
}
|