@omercnet/paseo-omp 0.2.1 → 0.3.0-next.101.1

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 (64) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +25 -13
  3. package/SUPPORT.md +7 -3
  4. package/TESTING.md +21 -18
  5. package/client/composer-pill-settings.tsx +157 -0
  6. package/client/external-url.ts +15 -0
  7. package/client/mcp-authorization.tsx +169 -0
  8. package/client/mcp-popover.tsx +155 -0
  9. package/client/memory-panel.tsx +8 -3
  10. package/client/memory-popover.tsx +8 -4
  11. package/client/omp-config-surface.tsx +189 -29
  12. package/client/omp-plugin-manager.tsx +302 -131
  13. package/client/omp-store-picker.tsx +89 -0
  14. package/client/omp-store-state.ts +45 -0
  15. package/client/paseo-types.ts +9 -0
  16. package/client/provider-diagnostics-state.ts +18 -7
  17. package/client/quota-popover.tsx +8 -3
  18. package/client/quota-state.ts +16 -7
  19. package/client/sessions-popover.tsx +8 -3
  20. package/docs/alpha-release-checklist.md +6 -8
  21. package/docs/configuration.md +8 -4
  22. package/docs/core-provider-issue-audit.md +3 -2
  23. package/docs/images/mcp-authorization-compact.png +0 -0
  24. package/docs/images/mcp-controls-wide.png +0 -0
  25. package/docs/images/plugin-manager.png +0 -0
  26. package/docs/images/workspace-settings.png +0 -0
  27. package/docs/installation.md +35 -19
  28. package/index.client.tsx +339 -123
  29. package/index.server.ts +44 -14
  30. package/package.json +7 -8
  31. package/paseo-plugin.json +2 -2
  32. package/scripts/prepare-dependencies.mjs +24 -0
  33. package/server/mcp-browser.ts +95 -0
  34. package/server/memory.ts +2 -2
  35. package/server/omp-config.ts +16 -7
  36. package/server/omp-plugins.ts +70 -21
  37. package/server/omp-settings.ts +232 -24
  38. package/server/paths.ts +128 -11
  39. package/server/provider/catalog.ts +3 -4
  40. package/server/provider/connection.ts +248 -17
  41. package/server/provider/host-tools.ts +294 -26
  42. package/server/provider/omp-rpc.ts +499 -72
  43. package/server/provider/profile-providers.ts +249 -0
  44. package/server/provider/registration.ts +11 -0
  45. package/server/provider/security.ts +8 -10
  46. package/server/provider/session-descriptors.ts +340 -1
  47. package/server/provider/session.ts +716 -249
  48. package/server/provider/subsessions.ts +25 -2
  49. package/server/provider/timeline-projector.ts +104 -44
  50. package/server/provider-diagnostics.ts +122 -36
  51. package/server/quota.ts +3 -2
  52. package/server/sessions.ts +2 -2
  53. package/shared/composer-pill-settings.ts +28 -0
  54. package/shared/external-url.ts +21 -0
  55. package/shared/hub.ts +3 -3
  56. package/shared/mcp.ts +47 -0
  57. package/shared/memory.ts +2 -1
  58. package/shared/omp-config.ts +5 -1
  59. package/shared/omp-plugins.ts +74 -33
  60. package/shared/omp-settings.ts +8 -1
  61. package/shared/omp-store.ts +58 -0
  62. package/shared/provider-diagnostics.ts +12 -3
  63. package/shared/quota.ts +2 -1
  64. package/shared/sessions.ts +2 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@omercnet/paseo-omp",
3
- "version": "0.2.1",
3
+ "version": "0.3.0-next.101.1",
4
4
  "type": "module",
5
5
  "description": "Paseo integration for OMP, including its direct provider and workspace tooling.",
6
6
  "license": "MIT",
@@ -36,6 +36,7 @@
36
36
  "SUPPORT.md",
37
37
  "TESTING.md",
38
38
  "docs",
39
+ "scripts/prepare-dependencies.mjs",
39
40
  "index.client.tsx",
40
41
  "index.server.ts",
41
42
  "client",
@@ -51,26 +52,24 @@
51
52
  "test:coverage": "PASEO_OMP_COVERAGE=1 vitest run --coverage",
52
53
  "test:integration:docker": "node --import tsx scripts/test-docker-host-tools.ts",
53
54
  "test:integration:wsl": "node --import tsx scripts/test-wsl-host-tools.ts",
55
+ "test:integration:canary": "node --import tsx scripts/test-canary.ts",
54
56
  "test:integration:install": "node --import tsx scripts/test-package-install.ts",
55
- "package:release": "node --import tsx scripts/package-release.ts",
56
57
  "typecheck": "tsc --noEmit"
57
58
  },
58
59
  "dependencies": {
60
+ "@getpaseo/protocol": "0.9.0-beta.1",
59
61
  "@modelcontextprotocol/sdk": "1.29.0",
60
62
  "yaml": "^2.9.0"
61
63
  },
62
64
  "devDependencies": {
63
65
  "@biomejs/biome": "^2.5.10",
64
- "@getpaseo/cli": "0.8.0",
65
- "@getpaseo/client": "0.8.0",
66
- "@getpaseo/plugin": "0.8.0",
67
- "@getpaseo/protocol": "0.8.0",
66
+ "@getpaseo/cli": "0.9.0-beta.1",
67
+ "@getpaseo/client": "0.9.0-beta.1",
68
+ "@getpaseo/plugin": "0.9.0-beta.1",
68
69
  "@tanstack/react-query": "^5.102.3",
69
70
  "@types/node": "^24.10.1",
70
71
  "@types/react": "^19.2.18",
71
- "esbuild": "^0.28.0",
72
72
  "@vitest/coverage-v8": "^5.0.0",
73
- "fflate": "^0.8.3",
74
73
  "react": "19.1.0",
75
74
  "react-native": "0.81.5",
76
75
  "tsx": "^4.20.6",
package/paseo-plugin.json CHANGED
@@ -1,5 +1,5 @@
1
1
  {
2
2
  "id": "paseo-omp",
3
- "requirements": { "paseo": "^0.8.0" },
4
- "build": [["npm", "ci", "--ignore-scripts"]]
3
+ "requirements": { "paseo": ">=0.8.0 <0.10.0" },
4
+ "build": [["node", "scripts/prepare-dependencies.mjs"]]
5
5
  }
@@ -0,0 +1,24 @@
1
+ import { execFileSync } from "node:child_process";
2
+ import { existsSync } from "node:fs";
3
+ import { dirname, join } from "node:path";
4
+ import { fileURLToPath, pathToFileURL } from "node:url";
5
+
6
+ const projectRoot = dirname(dirname(fileURLToPath(import.meta.url)));
7
+
8
+ export function prepareDependencies(root = projectRoot, execute = execFileSync) {
9
+ if (!existsSync(join(root, "package-lock.json"))) return false;
10
+
11
+ execute(
12
+ process.platform === "win32" ? "npm.cmd" : "npm",
13
+ ["ci", "--omit=dev", "--ignore-scripts"],
14
+ {
15
+ cwd: root,
16
+ stdio: "inherit",
17
+ },
18
+ );
19
+ return true;
20
+ }
21
+
22
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
23
+ prepareDependencies();
24
+ }
@@ -0,0 +1,95 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import type { RpcInput } from "@getpaseo/plugin";
3
+ import type { openOmpMcpAuthorizationInPaseoBrowser } from "../shared/mcp";
4
+ import { OmpPublicError } from "./provider/security";
5
+
6
+ const MAX_REGISTERED_SESSIONS = 32;
7
+ const MAX_AUTHORIZATIONS_PER_SESSION = 16;
8
+
9
+ type BrowserAuthorizationOpener = (url: string) => Promise<void>;
10
+
11
+ type RegisteredSession = {
12
+ token: symbol;
13
+ open: BrowserAuthorizationOpener;
14
+ authorizations: Set<string>;
15
+ };
16
+
17
+ type RegisteredAuthorization = {
18
+ agentId: string;
19
+ sessionToken: symbol;
20
+ url: string;
21
+ open: BrowserAuthorizationOpener;
22
+ };
23
+
24
+ export interface OmpBrowserAuthorizationRegistration {
25
+ issue(url: string): string | undefined;
26
+ remove(): void;
27
+ }
28
+
29
+ export class OmpBrowserAuthorizationRegistry {
30
+ private readonly sessions = new Map<string, RegisteredSession>();
31
+ private readonly authorizations = new Map<string, RegisteredAuthorization>();
32
+
33
+ register(agentId: string, open: BrowserAuthorizationOpener): OmpBrowserAuthorizationRegistration {
34
+ if (this.sessions.has(agentId)) {
35
+ throw new OmpPublicError("OMP browser authorization is already registered for this agent");
36
+ }
37
+ if (this.sessions.size >= MAX_REGISTERED_SESSIONS) {
38
+ throw new OmpPublicError("OMP browser authorization session limit reached");
39
+ }
40
+ const session: RegisteredSession = { token: Symbol(agentId), open, authorizations: new Set() };
41
+ this.sessions.set(agentId, session);
42
+
43
+ return {
44
+ issue: (url) => {
45
+ if (this.sessions.get(agentId) !== session) return;
46
+ if (session.authorizations.size >= MAX_AUTHORIZATIONS_PER_SESSION) {
47
+ const oldest = session.authorizations.values().next().value;
48
+ if (oldest) {
49
+ session.authorizations.delete(oldest);
50
+ this.authorizations.delete(oldest);
51
+ }
52
+ }
53
+ const authorizationToken = randomUUID();
54
+ session.authorizations.add(authorizationToken);
55
+ this.authorizations.set(authorizationToken, {
56
+ agentId,
57
+ sessionToken: session.token,
58
+ url,
59
+ open,
60
+ });
61
+ return authorizationToken;
62
+ },
63
+ remove: () => {
64
+ if (this.sessions.get(agentId) !== session) return;
65
+ this.sessions.delete(agentId);
66
+ for (const authorizationToken of session.authorizations) {
67
+ this.authorizations.delete(authorizationToken);
68
+ }
69
+ session.authorizations.clear();
70
+ },
71
+ };
72
+ }
73
+
74
+ async open(authorizationToken: string): Promise<void> {
75
+ const authorization = this.authorizations.get(authorizationToken);
76
+ const session = authorization ? this.sessions.get(authorization.agentId) : undefined;
77
+ if (!authorization || session?.token !== authorization.sessionToken) {
78
+ throw new OmpPublicError("The OMP browser authorization is no longer available");
79
+ }
80
+ await authorization.open(authorization.url);
81
+ }
82
+
83
+ clear(): void {
84
+ this.authorizations.clear();
85
+ this.sessions.clear();
86
+ }
87
+ }
88
+
89
+ export async function resolveOpenOmpMcpAuthorizationInPaseoBrowser(
90
+ input: RpcInput<typeof openOmpMcpAuthorizationInPaseoBrowser>,
91
+ registry: OmpBrowserAuthorizationRegistry,
92
+ ): Promise<{ opened: true }> {
93
+ await registry.open(input.authorizationToken);
94
+ return { opened: true };
95
+ }
package/server/memory.ts CHANGED
@@ -4,7 +4,7 @@ import { DatabaseSync } from "node:sqlite";
4
4
  import type { RpcInput } from "@getpaseo/plugin";
5
5
  import { z } from "zod";
6
6
  import type { listOmpMemory, OmpMemoryFact } from "../shared/memory";
7
- import { ompAgentDir } from "./paths";
7
+ import { ompStateDir } from "./paths";
8
8
 
9
9
  const FactRowSchema = z.object({
10
10
  id: z.string(),
@@ -82,5 +82,5 @@ export async function listOmpMemoryFrom(
82
82
  export async function resolveListOmpMemory({
83
83
  cwd,
84
84
  }: RpcInput<typeof listOmpMemory>): Promise<{ bank: string | null; facts: OmpMemoryFact[] }> {
85
- return listOmpMemoryFrom(join(ompAgentDir(), "memories", "mnemopi", "banks"), cwd);
85
+ return listOmpMemoryFrom(join(ompStateDir(), "memories", "mnemopi", "banks"), cwd);
86
86
  }
@@ -1,5 +1,5 @@
1
1
  import { readFile } from "node:fs/promises";
2
- import { join } from "node:path";
2
+ import { isAbsolute, join } from "node:path";
3
3
  import type { RpcInput } from "@getpaseo/plugin";
4
4
  import { parse as parseYaml } from "yaml";
5
5
  import type { ZodType } from "zod";
@@ -26,8 +26,11 @@ const CONFIG_FILENAMES = ["config.yml", "config.yaml"] as const;
26
26
 
27
27
  type OmpConfigResult = { path: string; available: boolean; config: OmpConfig | null };
28
28
 
29
- async function readFirstExisting(dir: string): Promise<{ path: string; text: string } | undefined> {
30
- for (const filename of CONFIG_FILENAMES) {
29
+ async function readFirstExisting(
30
+ dir: string,
31
+ filenames: readonly string[] = CONFIG_FILENAMES,
32
+ ): Promise<{ path: string; text: string } | undefined> {
33
+ for (const filename of filenames) {
31
34
  const path = join(dir, filename);
32
35
  try {
33
36
  return { path, text: await readFile(path, "utf8") };
@@ -106,9 +109,12 @@ export function parseOmpConfig(raw: unknown): OmpConfig {
106
109
  return config;
107
110
  }
108
111
 
109
- export async function readOmpConfigFrom(dir: string): Promise<OmpConfigResult> {
110
- const defaultPath = join(dir, CONFIG_FILENAMES[0]);
111
- const found = await readFirstExisting(dir);
112
+ export async function readOmpConfigFrom(
113
+ dir: string,
114
+ filenames: readonly string[] = CONFIG_FILENAMES,
115
+ ): Promise<OmpConfigResult> {
116
+ const defaultPath = join(dir, filenames[0] ?? CONFIG_FILENAMES[0]);
117
+ const found = await readFirstExisting(dir, filenames);
112
118
  if (!found) return { path: defaultPath, available: false, config: null };
113
119
  try {
114
120
  const raw: unknown = parseYaml(found.text);
@@ -120,7 +126,10 @@ export async function readOmpConfigFrom(dir: string): Promise<OmpConfigResult> {
120
126
  }
121
127
 
122
128
  export async function resolveListOmpConfig(
123
- _input: RpcInput<typeof listOmpConfig>,
129
+ input: RpcInput<typeof listOmpConfig>,
124
130
  ): Promise<OmpConfigResult> {
131
+ if (input.cwd && isAbsolute(input.cwd) && !input.cwd.includes("\0")) {
132
+ return readOmpConfigFrom(join(input.cwd, ".omp"), [CONFIG_FILENAMES[0]]);
133
+ }
125
134
  return readOmpConfigFrom(ompAgentDir());
126
135
  }
@@ -1,5 +1,5 @@
1
1
  import { readFile, stat } from "node:fs/promises";
2
- import { delimiter, join } from "node:path";
2
+ import { delimiter, isAbsolute, join } from "node:path";
3
3
  import type { RpcInput } from "@getpaseo/plugin";
4
4
  import { z } from "zod";
5
5
  import {
@@ -19,6 +19,7 @@ import {
19
19
  type OmpPluginState,
20
20
  } from "../shared/omp-plugins";
21
21
  import { SerialMutationQueue } from "./mutation-queue";
22
+ import { currentOmpEnvironment } from "./paths";
22
23
  import {
23
24
  type BoundedRun,
24
25
  buildStatefulCommandEnv,
@@ -78,9 +79,14 @@ export interface OmpPluginDependencies {
78
79
  args: readonly string[],
79
80
  outputLimit: number,
80
81
  timeoutMs: number,
82
+ cwd?: string,
81
83
  ): Promise<BoundedRun>;
82
84
  }
83
85
 
86
+ function validWorkspaceCwd(cwd: string | undefined): boolean {
87
+ return cwd === undefined || (isAbsolute(cwd) && !cwd.includes("\0"));
88
+ }
89
+
84
90
  function boundedText(value: unknown, maximum: number): string | null {
85
91
  if (typeof value !== "string" || value.length === 0 || value.length > maximum) return null;
86
92
  if (/[\0\r\n]/u.test(value)) return null;
@@ -130,6 +136,7 @@ function parseNpmPlugin(raw: unknown): OmpInstalledPlugin | null {
130
136
  enabled: candidate.data.enabled,
131
137
  shadowed: false,
132
138
  path,
139
+ configAmbiguous: false,
133
140
  description,
134
141
  enabledFeatures,
135
142
  availableFeatures,
@@ -167,6 +174,7 @@ function parseMarketplacePlugin(raw: unknown): OmpInstalledPlugin | null {
167
174
  shadowed: candidate.data.shadowedBy === "project",
168
175
  path,
169
176
  description: null,
177
+ configAmbiguous: false,
170
178
  enabledFeatures: [],
171
179
  availableFeatures: [],
172
180
  configurable: false,
@@ -185,13 +193,24 @@ function pluginAliases(plugin: OmpInstalledPlugin): string[] {
185
193
  }
186
194
 
187
195
  function markAmbiguousPlugins(plugins: OmpInstalledPlugin[]): OmpInstalledPlugin[] {
188
- const counts = new Map<string, number>();
196
+ const lifecycleCounts = new Map<string, number>();
197
+ const configCounts = new Map<string, number>();
198
+ const lifecycleKey = (plugin: OmpInstalledPlugin) =>
199
+ plugin.source === "marketplace"
200
+ ? `marketplace:${plugin.scope}:${plugin.id}`
201
+ : `npm:${plugin.id}`;
189
202
  for (const plugin of plugins) {
190
- for (const alias of pluginAliases(plugin)) counts.set(alias, (counts.get(alias) ?? 0) + 1);
203
+ const key = lifecycleKey(plugin);
204
+ lifecycleCounts.set(key, (lifecycleCounts.get(key) ?? 0) + 1);
205
+ if (plugin.packageName) {
206
+ configCounts.set(plugin.packageName, (configCounts.get(plugin.packageName) ?? 0) + 1);
207
+ }
191
208
  }
192
209
  return plugins.map((plugin) => ({
193
210
  ...plugin,
194
- ambiguous: pluginAliases(plugin).some((alias) => (counts.get(alias) ?? 0) > 1),
211
+ ambiguous: (lifecycleCounts.get(lifecycleKey(plugin)) ?? 0) > 1,
212
+ configAmbiguous:
213
+ plugin.packageName !== undefined && (configCounts.get(plugin.packageName) ?? 0) > 1,
195
214
  }));
196
215
  }
197
216
 
@@ -237,12 +256,12 @@ function readFailure(result: BoundedRun): string {
237
256
 
238
257
  async function resolveOmpExecutable(): Promise<string | null> {
239
258
  return resolveExecutablePath(
240
- process.env.OMP_COMMAND ?? "omp",
241
- (process.env.PATH ?? "").split(delimiter),
259
+ currentOmpEnvironment().OMP_COMMAND ?? "omp",
260
+ (currentOmpEnvironment().PATH ?? "").split(delimiter),
242
261
  {
243
262
  cwd: process.cwd(),
244
263
  platform: process.platform,
245
- pathExt: process.env.PATHEXT ?? WINDOWS_DEFAULT_PATHEXT,
264
+ pathExt: currentOmpEnvironment().PATHEXT ?? WINDOWS_DEFAULT_PATHEXT,
246
265
  },
247
266
  );
248
267
  }
@@ -252,16 +271,17 @@ async function runOmpPlugin(
252
271
  args: readonly string[],
253
272
  outputLimit: number,
254
273
  timeoutMs: number,
274
+ cwd = process.cwd(),
255
275
  ): Promise<BoundedRun> {
256
276
  return runBounded(
257
277
  defaultSpawn,
258
278
  executable,
259
279
  args,
260
- buildStatefulCommandEnv(process.env),
280
+ buildStatefulCommandEnv(currentOmpEnvironment()),
261
281
  timeoutMs,
262
282
  KILL_GRACE_MS,
263
283
  outputLimit,
264
- process.cwd(),
284
+ cwd,
265
285
  );
266
286
  }
267
287
 
@@ -270,7 +290,7 @@ async function enrichMarketplaceConfiguration(
270
290
  ): Promise<OmpInstalledPlugin[]> {
271
291
  const enriched = await Promise.all(
272
292
  plugins.map(async (plugin) => {
273
- if (plugin.source !== "marketplace" || plugin.scope !== "user" || !plugin.path) return plugin;
293
+ if (plugin.source !== "marketplace" || !plugin.path) return plugin;
274
294
  try {
275
295
  const metadataPath = join(plugin.path, "package.json");
276
296
  const metadata = await stat(metadataPath);
@@ -301,6 +321,7 @@ const DEFAULT_DEPENDENCIES: OmpPluginDependencies = {
301
321
  async function loadPluginState(
302
322
  executable: string | null | undefined,
303
323
  dependencies: OmpPluginDependencies,
324
+ cwd?: string,
304
325
  ): Promise<OmpPluginState> {
305
326
  const resolved = executable === undefined ? await dependencies.resolveExecutable() : executable;
306
327
  if (!resolved) return unavailableState("The OMP executable could not be resolved.");
@@ -309,6 +330,7 @@ async function loadPluginState(
309
330
  ["plugin", "list", "--json"],
310
331
  LIST_OUTPUT_LIMIT,
311
332
  READ_TIMEOUT_MS,
333
+ cwd,
312
334
  );
313
335
  if (!runSucceeded(result)) return unavailableState(readFailure(result));
314
336
  try {
@@ -324,10 +346,11 @@ async function loadPluginState(
324
346
  }
325
347
 
326
348
  export async function listOmpPluginsWithDependencies(
327
- _input: RpcInput<typeof listOmpPlugins>,
349
+ input: RpcInput<typeof listOmpPlugins>,
328
350
  dependencies: OmpPluginDependencies,
329
351
  ): Promise<OmpPluginState> {
330
- return loadPluginState(undefined, dependencies);
352
+ if (!validWorkspaceCwd(input.cwd)) return unavailableState("The workspace path is invalid.");
353
+ return loadPluginState(undefined, dependencies, input.cwd);
331
354
  }
332
355
 
333
356
  export async function resolveListOmpPlugins(
@@ -417,12 +440,14 @@ async function loadPluginConfig(
417
440
  executable: string,
418
441
  plugin: string,
419
442
  dependencies: OmpPluginDependencies,
443
+ cwd?: string,
420
444
  ): Promise<OmpPluginConfigState> {
421
445
  const result = await dependencies.runPlugin(
422
446
  executable,
423
447
  ["plugin", "config", "list", plugin, "--json"],
424
448
  CONFIG_OUTPUT_LIMIT,
425
449
  READ_TIMEOUT_MS,
450
+ cwd,
426
451
  );
427
452
  if (!runSucceeded(result)) {
428
453
  return unavailableConfig(
@@ -443,9 +468,12 @@ export async function inspectOmpPluginConfigWithDependencies(
443
468
  input: RpcInput<typeof inspectOmpPluginConfig>,
444
469
  dependencies: OmpPluginDependencies,
445
470
  ): Promise<OmpPluginConfigState> {
471
+ if (!validWorkspaceCwd(input.cwd)) {
472
+ return unavailableConfig(input.plugin, "The workspace path is invalid.");
473
+ }
446
474
  const executable = await dependencies.resolveExecutable();
447
475
  return executable
448
- ? loadPluginConfig(executable, input.plugin, dependencies)
476
+ ? loadPluginConfig(executable, input.plugin, dependencies, input.cwd)
449
477
  : unavailableConfig(input.plugin, "The OMP executable could not be resolved.");
450
478
  }
451
479
 
@@ -492,13 +520,17 @@ export async function mutateOmpPluginConfigWithDependencies(
492
520
  input: RpcInput<typeof mutateOmpPluginConfig>,
493
521
  dependencies: OmpPluginDependencies,
494
522
  ): Promise<{ ok: boolean; message: string; config: OmpPluginConfigState }> {
523
+ if (!validWorkspaceCwd(input.cwd)) {
524
+ const config = unavailableConfig(input.plugin, "The workspace path is invalid.");
525
+ return { ok: false, message: "The workspace path is invalid.", config };
526
+ }
495
527
  const executable = await dependencies.resolveExecutable();
496
528
  if (!executable) {
497
529
  const config = unavailableConfig(input.plugin, "The OMP executable could not be resolved.");
498
530
  return { ok: false, message: "The OMP executable could not be resolved.", config };
499
531
  }
500
532
 
501
- const current = await loadPluginConfig(executable, input.plugin, dependencies);
533
+ const current = await loadPluginConfig(executable, input.plugin, dependencies, input.cwd);
502
534
  const setting = current.settings.find((candidate) => candidate.key === input.key);
503
535
  if (!current.available || !setting) {
504
536
  return {
@@ -526,8 +558,9 @@ export async function mutateOmpPluginConfigWithDependencies(
526
558
  buildOmpPluginConfigMutationArgs(input),
527
559
  MUTATION_OUTPUT_LIMIT,
528
560
  READ_TIMEOUT_MS,
561
+ input.cwd,
529
562
  );
530
- const config = await loadPluginConfig(executable, input.plugin, dependencies);
563
+ const config = await loadPluginConfig(executable, input.plugin, dependencies, input.cwd);
531
564
  const mutationSucceeded = runSucceeded(mutation);
532
565
  const ok = mutationSucceeded && config.available;
533
566
  return {
@@ -553,7 +586,7 @@ export function resolveMutateOmpPluginConfig(
553
586
 
554
587
  export function buildOmpPluginMutationArgs(input: OmpPluginMutation): string[] {
555
588
  const target = input.action === "install" ? input.source : input.plugin;
556
- return ["plugin", input.action, target, "--scope", "user", "--json"];
589
+ return ["plugin", input.action, target, "--scope", input.scope ?? "user", "--json"];
557
590
  }
558
591
 
559
592
  function mutationFailure(result: BoundedRun): string {
@@ -583,6 +616,10 @@ export async function mutateOmpPluginWithDependencies(
583
616
  input: RpcInput<typeof mutateOmpPlugin>,
584
617
  dependencies: OmpPluginDependencies,
585
618
  ): Promise<{ ok: boolean; message: string; state: OmpPluginState }> {
619
+ if (!validWorkspaceCwd(input.cwd)) {
620
+ const state = unavailableState("The workspace path is invalid.");
621
+ return { ok: false, message: "The workspace path is invalid.", state };
622
+ }
586
623
  const executable = await dependencies.resolveExecutable();
587
624
  if (!executable) {
588
625
  const state = unavailableState("The OMP executable could not be resolved.");
@@ -590,15 +627,26 @@ export async function mutateOmpPluginWithDependencies(
590
627
  }
591
628
 
592
629
  if (input.action !== "install") {
593
- const before = await loadPluginState(executable, dependencies);
630
+ const before = await loadPluginState(executable, dependencies, input.cwd);
594
631
  if (!before.available) {
595
632
  return { ok: false, message: "OMP plugin state is unavailable.", state: before };
596
633
  }
597
- const matches = before.plugins.filter((plugin) => pluginAliases(plugin).includes(input.plugin));
598
- if (matches.length !== 1 || matches[0]?.ambiguous || matches[0]?.scope === "project") {
634
+ const matches = before.plugins.filter(
635
+ (plugin) =>
636
+ pluginAliases(plugin).includes(input.plugin) &&
637
+ (input.scope === undefined || plugin.scope === input.scope),
638
+ );
639
+ if (matches.length !== 1 || matches[0]?.ambiguous) {
640
+ return {
641
+ ok: false,
642
+ message: "The plugin target is ambiguous in this scope.",
643
+ state: before,
644
+ };
645
+ }
646
+ if (matches[0]?.scope === "project" && !input.cwd) {
599
647
  return {
600
648
  ok: false,
601
- message: "The plugin target is ambiguous or not user-scoped.",
649
+ message: "Project-scoped plugin actions require a workspace.",
602
650
  state: before,
603
651
  };
604
652
  }
@@ -609,8 +657,9 @@ export async function mutateOmpPluginWithDependencies(
609
657
  buildOmpPluginMutationArgs(input),
610
658
  MUTATION_OUTPUT_LIMIT,
611
659
  MUTATION_TIMEOUT_MS,
660
+ input.cwd,
612
661
  );
613
- const state = await loadPluginState(executable, dependencies);
662
+ const state = await loadPluginState(executable, dependencies, input.cwd);
614
663
  return {
615
664
  ok: runSucceeded(mutation),
616
665
  message: runSucceeded(mutation) ? mutationSuccess(input.action) : mutationFailure(mutation),