@omercnet/paseo-omp 0.2.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 (63) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/LICENSE +21 -0
  3. package/README.md +110 -0
  4. package/SUPPORT.md +40 -0
  5. package/TESTING.md +147 -0
  6. package/client/hub-icon.tsx +12 -0
  7. package/client/hub-popover.tsx +132 -0
  8. package/client/hub-status.ts +29 -0
  9. package/client/memory-panel.tsx +71 -0
  10. package/client/memory-popover.tsx +70 -0
  11. package/client/omp-config-surface.tsx +1274 -0
  12. package/client/omp-doc-links.ts +117 -0
  13. package/client/omp-plugin-manager.tsx +833 -0
  14. package/client/provider-diagnostics-state.ts +250 -0
  15. package/client/provider-icon.tsx +27 -0
  16. package/client/provider-image.tsx +66 -0
  17. package/client/quota-popover.tsx +150 -0
  18. package/client/quota-state.ts +131 -0
  19. package/client/sessions-popover.tsx +73 -0
  20. package/docs/alpha-release-checklist.md +70 -0
  21. package/docs/configuration.md +122 -0
  22. package/docs/core-provider-issue-audit.md +108 -0
  23. package/docs/installation.md +73 -0
  24. package/index.client.tsx +272 -0
  25. package/index.server.ts +51 -0
  26. package/package.json +84 -0
  27. package/paseo-plugin.json +5 -0
  28. package/server/hub.ts +145 -0
  29. package/server/memory.ts +86 -0
  30. package/server/mutation-queue.ts +12 -0
  31. package/server/omp-config.ts +126 -0
  32. package/server/omp-plugins.ts +627 -0
  33. package/server/omp-settings.ts +291 -0
  34. package/server/paths.ts +64 -0
  35. package/server/provider/catalog.ts +173 -0
  36. package/server/provider/config-normalization.ts +148 -0
  37. package/server/provider/connection.ts +992 -0
  38. package/server/provider/host-tools.ts +706 -0
  39. package/server/provider/image.ts +143 -0
  40. package/server/provider/mcp-transport.ts +394 -0
  41. package/server/provider/omp-rpc.ts +2739 -0
  42. package/server/provider/omp.svg +5 -0
  43. package/server/provider/provider-options.ts +27 -0
  44. package/server/provider/registration.ts +151 -0
  45. package/server/provider/security.ts +317 -0
  46. package/server/provider/session-descriptors.ts +431 -0
  47. package/server/provider/session.ts +4451 -0
  48. package/server/provider/settings.ts +78 -0
  49. package/server/provider/subsessions.ts +847 -0
  50. package/server/provider/timeline-projector.ts +1764 -0
  51. package/server/provider-diagnostics.ts +1057 -0
  52. package/server/quota.ts +54 -0
  53. package/server/sessions.ts +58 -0
  54. package/shared/hub.ts +43 -0
  55. package/shared/memory.ts +23 -0
  56. package/shared/omp-config.ts +81 -0
  57. package/shared/omp-plugins.ts +223 -0
  58. package/shared/omp-settings.ts +207 -0
  59. package/shared/provider-diagnostics.ts +117 -0
  60. package/shared/provider-image.ts +160 -0
  61. package/shared/quota.ts +22 -0
  62. package/shared/sessions.ts +23 -0
  63. package/tsconfig.json +16 -0
@@ -0,0 +1,833 @@
1
+ import { type PluginSurfaceProps, useRpc } from "@getpaseo/plugin/client";
2
+ import { TextInput } from "@getpaseo/plugin/client/react-native";
3
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
4
+ import { useMemo, useRef, useState } from "react";
5
+ import type { TextStyle, ViewStyle } from "react-native";
6
+ import { Pressable, Switch, Text, View } from "react-native";
7
+ import {
8
+ inspectOmpPluginConfig,
9
+ listOmpPlugins,
10
+ mutateOmpPlugin,
11
+ mutateOmpPluginConfig,
12
+ type OmpInstalledPlugin,
13
+ type OmpPluginConfigMutation,
14
+ OmpPluginConfigMutationSchema,
15
+ type OmpPluginConfigSetting,
16
+ type OmpPluginConfigState,
17
+ OmpPluginInstallSourceSchema,
18
+ type OmpPluginMutation,
19
+ } from "../shared/omp-plugins";
20
+
21
+ const PLUGINS_QUERY_KEY = ["paseo-omp", "plugins"] as const;
22
+
23
+ type PendingConfirmation =
24
+ | {
25
+ kind: "plugin";
26
+ input: OmpPluginMutation;
27
+ title: string;
28
+ warning: string;
29
+ }
30
+ | {
31
+ kind: "config";
32
+ input: OmpPluginConfigMutation;
33
+ title: string;
34
+ warning: string;
35
+ };
36
+
37
+ type PluginManagerStyles = {
38
+ root: ViewStyle;
39
+ header: ViewStyle;
40
+ title: TextStyle;
41
+ muted: TextStyle;
42
+ error: TextStyle;
43
+ success: TextStyle;
44
+ card: ViewStyle;
45
+ cardHeader: ViewStyle;
46
+ cardTitle: TextStyle;
47
+ metadata: ViewStyle;
48
+ metadataRow: ViewStyle;
49
+ metadataLabel: TextStyle;
50
+ metadataValue: TextStyle;
51
+ pluginGrid: ViewStyle;
52
+ pluginCard: ViewStyle;
53
+ actions: ViewStyle;
54
+ button: ViewStyle;
55
+ buttonPrimary: ViewStyle;
56
+ buttonDanger: ViewStyle;
57
+ buttonDisabled: ViewStyle;
58
+ buttonText: TextStyle;
59
+ buttonTextPrimary: TextStyle;
60
+ input: TextStyle;
61
+ scopeRow: ViewStyle;
62
+ scopeButton: ViewStyle;
63
+ scopeButtonActive: ViewStyle;
64
+ scopeText: TextStyle;
65
+ scopeTextActive: TextStyle;
66
+ confirmation: ViewStyle;
67
+ warning: TextStyle;
68
+ settings: ViewStyle;
69
+ setting: ViewStyle;
70
+ settingName: TextStyle;
71
+ settingDescription: TextStyle;
72
+ };
73
+
74
+ function usePluginManagerStyles(
75
+ theme: PluginSurfaceProps["theme"],
76
+ compact: boolean,
77
+ ): PluginManagerStyles {
78
+ return useMemo(
79
+ () => ({
80
+ root: { gap: compact ? 10 : 14 },
81
+ header: {
82
+ flexDirection: "row",
83
+ alignItems: "center",
84
+ justifyContent: "space-between",
85
+ flexWrap: "wrap",
86
+ gap: 8,
87
+ },
88
+ title: { color: theme.colors.foreground, fontSize: compact ? 16 : 18, fontWeight: "600" },
89
+ muted: { color: theme.colors.foregroundMuted, fontSize: 13, lineHeight: 18 },
90
+ error: { color: theme.colors.statusDanger, fontSize: 13, lineHeight: 18 },
91
+ success: { color: theme.colors.statusSuccess, fontSize: 13, lineHeight: 18 },
92
+ card: {
93
+ gap: 10,
94
+ padding: compact ? 10 : 12,
95
+ borderWidth: 1,
96
+ borderColor: theme.colors.border,
97
+ borderRadius: 10,
98
+ backgroundColor: theme.colors.surface1,
99
+ },
100
+ cardHeader: {
101
+ flexDirection: "row",
102
+ alignItems: "center",
103
+ justifyContent: "space-between",
104
+ flexWrap: "wrap",
105
+ gap: 8,
106
+ },
107
+ cardTitle: { color: theme.colors.foreground, fontSize: 14, fontWeight: "600", flexShrink: 1 },
108
+ metadata: { gap: 5 },
109
+ metadataRow: { flexDirection: compact ? "column" : "row", gap: compact ? 1 : 8 },
110
+ metadataLabel: {
111
+ width: compact ? undefined : 80,
112
+ color: theme.colors.foregroundMuted,
113
+ fontSize: 12,
114
+ },
115
+ metadataValue: { color: theme.colors.foreground, fontSize: 12, flexShrink: 1 },
116
+ pluginGrid: { flexDirection: "row", flexWrap: "wrap", alignItems: "flex-start", gap: 10 },
117
+ pluginCard: {
118
+ width: compact ? "100%" : 320,
119
+ flexGrow: compact ? 0 : 1,
120
+ gap: 8,
121
+ padding: compact ? 10 : 12,
122
+ borderWidth: 1,
123
+ borderColor: theme.colors.border,
124
+ borderRadius: 10,
125
+ backgroundColor: theme.colors.surface1,
126
+ },
127
+ actions: { flexDirection: "row", flexWrap: "wrap", gap: 7 },
128
+ button: {
129
+ paddingHorizontal: 10,
130
+ paddingVertical: 7,
131
+ borderWidth: 1,
132
+ borderColor: theme.colors.border,
133
+ borderRadius: 7,
134
+ backgroundColor: theme.colors.surface0,
135
+ },
136
+ buttonPrimary: { borderColor: theme.colors.accent, backgroundColor: theme.colors.accent },
137
+ buttonDanger: { borderColor: theme.colors.statusDanger },
138
+ buttonDisabled: { opacity: 0.5 },
139
+ buttonText: { color: theme.colors.foreground, fontSize: 12, fontWeight: "600" },
140
+ buttonTextPrimary: { color: theme.colors.accentForeground },
141
+ input: {
142
+ color: theme.colors.foreground,
143
+ borderWidth: 1,
144
+ borderColor: theme.colors.border,
145
+ borderRadius: 8,
146
+ backgroundColor: theme.colors.surface0,
147
+ paddingHorizontal: 10,
148
+ paddingVertical: 8,
149
+ fontSize: 13,
150
+ },
151
+ scopeRow: { flexDirection: "row", flexWrap: "wrap", gap: 6 },
152
+ scopeButton: {
153
+ paddingHorizontal: 9,
154
+ paddingVertical: 6,
155
+ borderWidth: 1,
156
+ borderColor: theme.colors.border,
157
+ borderRadius: 7,
158
+ },
159
+ scopeButtonActive: { backgroundColor: theme.colors.surface2 },
160
+ scopeText: { color: theme.colors.foregroundMuted, fontSize: 12 },
161
+ scopeTextActive: { color: theme.colors.foreground, fontWeight: "600" },
162
+ confirmation: {
163
+ gap: 9,
164
+ padding: compact ? 10 : 12,
165
+ borderWidth: 1,
166
+ borderColor: theme.colors.statusWarning,
167
+ borderRadius: 10,
168
+ backgroundColor: theme.colors.surface1,
169
+ },
170
+ warning: { color: theme.colors.statusWarning, fontSize: 13, lineHeight: 18 },
171
+ settings: { gap: 7 },
172
+ setting: { gap: 3, paddingTop: 7, borderTopWidth: 1, borderTopColor: theme.colors.border },
173
+ settingName: { color: theme.colors.foreground, fontSize: 12, fontWeight: "600" },
174
+ settingDescription: { color: theme.colors.foregroundMuted, fontSize: 12, lineHeight: 17 },
175
+ }),
176
+ [compact, theme],
177
+ );
178
+ }
179
+
180
+ function ActionButton({
181
+ label,
182
+ disabled,
183
+ primary,
184
+ danger,
185
+ styles,
186
+ onPress,
187
+ }: {
188
+ label: string;
189
+ disabled: boolean;
190
+ primary?: boolean;
191
+ danger?: boolean;
192
+ styles: PluginManagerStyles;
193
+ onPress(): void;
194
+ }) {
195
+ return (
196
+ <Pressable
197
+ accessibilityRole="button"
198
+ accessibilityState={{ disabled }}
199
+ disabled={disabled}
200
+ onPress={onPress}
201
+ style={[
202
+ styles.button,
203
+ primary ? styles.buttonPrimary : null,
204
+ danger ? styles.buttonDanger : null,
205
+ disabled ? styles.buttonDisabled : null,
206
+ ]}
207
+ >
208
+ <Text style={[styles.buttonText, primary ? styles.buttonTextPrimary : null]}>{label}</Text>
209
+ </Pressable>
210
+ );
211
+ }
212
+
213
+ function MetadataRow({
214
+ label,
215
+ value,
216
+ styles,
217
+ }: {
218
+ label: string;
219
+ value: string;
220
+ styles: PluginManagerStyles;
221
+ }) {
222
+ return (
223
+ <View style={styles.metadataRow}>
224
+ <Text style={styles.metadataLabel}>{label}</Text>
225
+ <Text selectable style={styles.metadataValue}>
226
+ {value}
227
+ </Text>
228
+ </View>
229
+ );
230
+ }
231
+
232
+ function confirmationFor(input: OmpPluginMutation): PendingConfirmation {
233
+ const target = input.action === "install" ? input.source : input.plugin;
234
+ switch (input.action) {
235
+ case "install":
236
+ return {
237
+ kind: "plugin",
238
+ input,
239
+ title: `Install ${target}?`,
240
+ warning:
241
+ "OMP plugins are trusted code. Installing can fetch code and change persistent state; runtime extensions load in a future OMP session.",
242
+ };
243
+ case "enable":
244
+ return {
245
+ kind: "plugin",
246
+ input,
247
+ title: `Enable ${target}?`,
248
+ warning:
249
+ "Enabling changes persistent state and permits this plugin's trusted code to load in future OMP sessions.",
250
+ };
251
+ case "disable":
252
+ return {
253
+ kind: "plugin",
254
+ input,
255
+ title: `Disable ${target}?`,
256
+ warning:
257
+ "Disabling changes persistent state. OMP sessions that are already running are not unloaded.",
258
+ };
259
+ case "uninstall":
260
+ return {
261
+ kind: "plugin",
262
+ input,
263
+ title: `Uninstall ${target}?`,
264
+ warning:
265
+ "Uninstalling removes the selected plugin registration and cached installation. Running OMP sessions are unchanged.",
266
+ };
267
+ case "upgrade":
268
+ return {
269
+ kind: "plugin",
270
+ input,
271
+ title: `Upgrade ${target}?`,
272
+ warning:
273
+ "Upgrading fetches and replaces trusted plugin code. The updated runtime loads in a future OMP session.",
274
+ };
275
+ }
276
+ }
277
+
278
+ function configConfirmationFor(
279
+ input: OmpPluginConfigMutation,
280
+ setting: OmpPluginConfigSetting,
281
+ ): PendingConfirmation {
282
+ if (input.action === "delete") {
283
+ return {
284
+ kind: "config",
285
+ input,
286
+ title: `Delete ${setting.key}?`,
287
+ warning:
288
+ "This changes persistent OMP plugin configuration. Future sessions will use the setting's default or environment fallback.",
289
+ };
290
+ }
291
+ return {
292
+ kind: "config",
293
+ input,
294
+ title: `Set ${setting.key}?`,
295
+ warning: setting.secret
296
+ ? "This writes a secret value to OMP's persistent plugin configuration. The value will remain write-only in Paseo."
297
+ : "This changes persistent OMP plugin configuration for future sessions.",
298
+ };
299
+ }
300
+
301
+ function ConfigSettingEditor({
302
+ plugin,
303
+ setting,
304
+ busy,
305
+ theme,
306
+ styles,
307
+ onConfirm,
308
+ }: {
309
+ plugin: string;
310
+ setting: OmpPluginConfigSetting;
311
+ busy: boolean;
312
+ theme: PluginSurfaceProps["theme"];
313
+ styles: PluginManagerStyles;
314
+ onConfirm(input: OmpPluginConfigMutation, setting: OmpPluginConfigSetting): void;
315
+ }) {
316
+ const [draft, setDraft] = useState<string | boolean>(setting.type === "boolean" ? false : "");
317
+ let value: string | number | boolean | undefined;
318
+ let validationMessage: string | null = null;
319
+ if (!setting.secret) {
320
+ if (setting.type === "boolean") {
321
+ value = draft === true;
322
+ } else if (setting.type === "number") {
323
+ const raw = typeof draft === "string" ? draft.trim() : "";
324
+ const parsed = raw ? Number(raw) : Number.NaN;
325
+ if (!Number.isFinite(parsed)) validationMessage = "Enter a finite number.";
326
+ else if (setting.minimum !== undefined && parsed < setting.minimum) {
327
+ validationMessage = `Minimum: ${setting.minimum}`;
328
+ } else if (setting.maximum !== undefined && parsed > setting.maximum) {
329
+ validationMessage = `Maximum: ${setting.maximum}`;
330
+ } else value = parsed;
331
+ } else if (setting.type === "enum") {
332
+ if (typeof draft !== "string" || !setting.enumValues.includes(draft)) {
333
+ validationMessage = "Choose one of the documented values.";
334
+ } else value = draft;
335
+ } else if (typeof draft === "string") {
336
+ value = draft;
337
+ }
338
+ }
339
+ const candidate =
340
+ value === undefined
341
+ ? null
342
+ : OmpPluginConfigMutationSchema.safeParse({
343
+ action: "set",
344
+ plugin,
345
+ key: setting.key,
346
+ value,
347
+ });
348
+ if (candidate && !candidate.success) validationMessage = "Enter a valid bounded value.";
349
+
350
+ return (
351
+ <View style={styles.setting}>
352
+ <Text selectable style={styles.settingName}>
353
+ {setting.key} · {setting.type}
354
+ </Text>
355
+ <Text style={setting.secret ? styles.warning : styles.muted}>
356
+ {setting.secret
357
+ ? setting.configured
358
+ ? "Secret · configured"
359
+ : "Secret · not configured"
360
+ : setting.configured
361
+ ? "Configured"
362
+ : "Not configured"}
363
+ </Text>
364
+ {setting.description ? (
365
+ <Text style={styles.settingDescription}>{setting.description}</Text>
366
+ ) : null}
367
+ {setting.secret ? (
368
+ <Text style={styles.warning}>
369
+ Secret writes are unavailable because OMP accepts plugin setting values through process
370
+ arguments. Configure this secret directly with OMP.
371
+ </Text>
372
+ ) : setting.type === "boolean" ? (
373
+ <View style={styles.metadataRow}>
374
+ <Switch
375
+ accessibilityLabel={`New value for ${setting.key}`}
376
+ disabled={busy}
377
+ value={draft === true}
378
+ onValueChange={setDraft}
379
+ />
380
+ <Text style={styles.metadataValue}>{draft === true ? "True" : "False"}</Text>
381
+ </View>
382
+ ) : setting.type === "enum" ? (
383
+ <View accessibilityRole="radiogroup" style={styles.scopeRow}>
384
+ {setting.enumValues.map((option) => {
385
+ const selected = draft === option;
386
+ return (
387
+ <Pressable
388
+ key={option}
389
+ accessibilityRole="radio"
390
+ accessibilityState={{ checked: selected, disabled: busy }}
391
+ disabled={busy}
392
+ onPress={() => setDraft(option)}
393
+ style={[styles.scopeButton, selected ? styles.scopeButtonActive : null]}
394
+ >
395
+ <Text style={[styles.scopeText, selected ? styles.scopeTextActive : null]}>
396
+ {option}
397
+ </Text>
398
+ </Pressable>
399
+ );
400
+ })}
401
+ </View>
402
+ ) : (
403
+ <TextInput
404
+ accessibilityLabel={`New value for ${setting.key}`}
405
+ editable={!busy}
406
+ value={typeof draft === "string" ? draft : ""}
407
+ onChangeText={setDraft}
408
+ placeholder="Enter a new value"
409
+ placeholderTextColor={theme.colors.foregroundMuted}
410
+ keyboardType={setting.type === "number" ? "numeric" : "default"}
411
+ autoCapitalize="none"
412
+ autoCorrect={false}
413
+ style={styles.input}
414
+ />
415
+ )}
416
+ {validationMessage && (setting.type !== "boolean" || value === undefined) ? (
417
+ <Text style={styles.error}>{validationMessage}</Text>
418
+ ) : null}
419
+ <View style={styles.actions}>
420
+ {!setting.secret ? (
421
+ <ActionButton
422
+ label="Review change"
423
+ disabled={busy || !candidate?.success}
424
+ primary
425
+ styles={styles}
426
+ onPress={() => {
427
+ if (candidate?.success) onConfirm(candidate.data, setting);
428
+ }}
429
+ />
430
+ ) : null}
431
+ {setting.configured ? (
432
+ <ActionButton
433
+ label="Delete setting"
434
+ disabled={busy}
435
+ danger
436
+ styles={styles}
437
+ onPress={() => onConfirm({ action: "delete", plugin, key: setting.key }, setting)}
438
+ />
439
+ ) : null}
440
+ </View>
441
+ </View>
442
+ );
443
+ }
444
+
445
+ function PluginCard({
446
+ plugin,
447
+ busy,
448
+ inspecting,
449
+ styles,
450
+ onConfirm,
451
+ onInspect,
452
+ }: {
453
+ plugin: OmpInstalledPlugin;
454
+ busy: boolean;
455
+ inspecting: boolean;
456
+ styles: PluginManagerStyles;
457
+ onConfirm(input: OmpPluginMutation): void;
458
+ onInspect(plugin: string): void;
459
+ }) {
460
+ const scope = plugin.scope === "user" ? "user" : undefined;
461
+ return (
462
+ <View style={styles.pluginCard}>
463
+ <View style={styles.cardHeader}>
464
+ <Text selectable style={styles.cardTitle}>
465
+ {plugin.id}
466
+ </Text>
467
+ <Text style={plugin.enabled ? styles.success : styles.muted}>
468
+ {plugin.enabled ? "Enabled" : "Disabled"}
469
+ </Text>
470
+ </View>
471
+ <View style={styles.metadata}>
472
+ <MetadataRow
473
+ styles={styles}
474
+ label="Source"
475
+ value={plugin.source === "marketplace" ? `Marketplace · ${plugin.scope}` : "npm / linked"}
476
+ />
477
+ <MetadataRow styles={styles} label="Version" value={plugin.version ?? "Unknown"} />
478
+ {plugin.path ? <MetadataRow styles={styles} label="Path" value={plugin.path} /> : null}
479
+ {plugin.description ? (
480
+ <MetadataRow styles={styles} label="Description" value={plugin.description} />
481
+ ) : null}
482
+ {plugin.enabledFeatures.length > 0 ? (
483
+ <MetadataRow styles={styles} label="Features" value={plugin.enabledFeatures.join(", ")} />
484
+ ) : null}
485
+ {plugin.usesDefaultFeatures ? (
486
+ <MetadataRow styles={styles} label="Features" value="Manifest defaults" />
487
+ ) : null}
488
+ {plugin.shadowed ? (
489
+ <Text style={styles.warning}>Shadowed by an enabled project-scoped installation.</Text>
490
+ ) : null}
491
+ {plugin.scope === "project" ? (
492
+ <Text style={styles.warning}>
493
+ Project-scoped actions require OMP to run from that project and are read-only here.
494
+ </Text>
495
+ ) : null}
496
+ {plugin.ambiguous ? (
497
+ <Text style={styles.warning}>
498
+ Multiple installations share this package identity, so lifecycle actions are read-only.
499
+ </Text>
500
+ ) : null}
501
+ </View>
502
+ <View style={styles.actions}>
503
+ <ActionButton
504
+ label={plugin.enabled ? "Disable" : "Enable"}
505
+ disabled={busy || plugin.scope === "project" || plugin.ambiguous}
506
+ styles={styles}
507
+ onPress={() =>
508
+ onConfirm({
509
+ action: plugin.enabled ? "disable" : "enable",
510
+ plugin: plugin.id,
511
+ ...(scope ? { scope } : {}),
512
+ })
513
+ }
514
+ />
515
+ {plugin.configurable && plugin.packageName ? (
516
+ <ActionButton
517
+ label={inspecting ? "Loading settings…" : "Configure settings"}
518
+ disabled={busy || inspecting || plugin.scope === "project" || plugin.ambiguous}
519
+ styles={styles}
520
+ onPress={() => onInspect(plugin.packageName ?? plugin.id)}
521
+ />
522
+ ) : null}
523
+ {plugin.source === "marketplace" ? (
524
+ <ActionButton
525
+ label="Upgrade"
526
+ disabled={busy || plugin.scope === "project" || plugin.ambiguous}
527
+ styles={styles}
528
+ onPress={() =>
529
+ onConfirm({ action: "upgrade", plugin: plugin.id, ...(scope ? { scope } : {}) })
530
+ }
531
+ />
532
+ ) : null}
533
+ <ActionButton
534
+ label="Uninstall"
535
+ disabled={busy || plugin.scope === "project" || plugin.ambiguous}
536
+ danger
537
+ styles={styles}
538
+ onPress={() =>
539
+ onConfirm({ action: "uninstall", plugin: plugin.id, ...(scope ? { scope } : {}) })
540
+ }
541
+ />
542
+ </View>
543
+ </View>
544
+ );
545
+ }
546
+
547
+ export function OmpPluginManagerSection({
548
+ theme,
549
+ compact,
550
+ }: {
551
+ theme: PluginSurfaceProps["theme"];
552
+ compact: boolean;
553
+ }) {
554
+ const loadPlugins = useRpc(listOmpPlugins);
555
+ const inspectPluginConfig = useRpc(inspectOmpPluginConfig);
556
+ const mutatePlugin = useRpc(mutateOmpPlugin);
557
+ const mutatePluginConfig = useRpc(mutateOmpPluginConfig);
558
+ const queryClient = useQueryClient();
559
+ const styles = usePluginManagerStyles(theme, compact);
560
+ const [source, setSource] = useState("");
561
+ const [confirmation, setConfirmation] = useState<PendingConfirmation | null>(null);
562
+ const [notice, setNotice] = useState<{ tone: "success" | "error"; text: string } | null>(null);
563
+ const [inspected, setInspected] = useState<OmpPluginConfigState | null>(null);
564
+ const [configEditGenerations, setConfigEditGenerations] = useState<Record<string, number>>({});
565
+ const inspectionGeneration = useRef(0);
566
+ const plugins = useQuery({
567
+ queryKey: PLUGINS_QUERY_KEY,
568
+ queryFn: () => loadPlugins({}),
569
+ staleTime: Number.POSITIVE_INFINITY,
570
+ });
571
+ const mutation = useMutation({
572
+ mutationFn: (input: OmpPluginMutation) => mutatePlugin(input),
573
+ onSuccess: (result) => {
574
+ queryClient.setQueryData(PLUGINS_QUERY_KEY, result.state);
575
+ setNotice({ tone: result.ok ? "success" : "error", text: result.message });
576
+ if (result.ok && confirmation?.kind === "plugin" && confirmation.input.action === "install") {
577
+ setSource("");
578
+ }
579
+ setConfirmation(null);
580
+ },
581
+ onError: () => {
582
+ setNotice({ tone: "error", text: "The plugin operation could not be completed." });
583
+ setConfirmation(null);
584
+ },
585
+ });
586
+ const configInspection = useMutation({
587
+ mutationFn: ({ plugin }: { plugin: string; generation: number }) =>
588
+ inspectPluginConfig({ plugin }),
589
+ onSuccess: (result, request) => {
590
+ if (inspectionGeneration.current === request.generation) setInspected(result);
591
+ },
592
+ onError: (_error, request) => {
593
+ if (inspectionGeneration.current === request.generation) setInspected(null);
594
+ },
595
+ });
596
+ const configMutation = useMutation({
597
+ mutationFn: (input: OmpPluginConfigMutation) => mutatePluginConfig(input),
598
+ onSuccess: (result, input) => {
599
+ if (result.config.available) setInspected(result.config);
600
+ if (result.ok) {
601
+ const identity = `${input.plugin}:${input.key}`;
602
+ setConfigEditGenerations((current) => ({
603
+ ...current,
604
+ [identity]: (current[identity] ?? 0) + 1,
605
+ }));
606
+ }
607
+ setNotice({ tone: result.ok ? "success" : "error", text: result.message });
608
+ setConfirmation(null);
609
+ },
610
+ onError: () => {
611
+ setNotice({ tone: "error", text: "The plugin setting operation could not be completed." });
612
+ setConfirmation(null);
613
+ },
614
+ });
615
+ const busy = mutation.isPending || configMutation.isPending;
616
+
617
+ const requestInstall = () => {
618
+ const parsed = OmpPluginInstallSourceSchema.safeParse(source);
619
+ if (!parsed.success) {
620
+ setNotice({
621
+ tone: "error",
622
+ text: "Enter one valid package, marketplace ID, Git source, or local path without options or control characters.",
623
+ });
624
+ return;
625
+ }
626
+ setNotice(null);
627
+ setConfirmation(confirmationFor({ action: "install", source: parsed.data, scope: "user" }));
628
+ };
629
+ const requestMutation = (input: OmpPluginMutation) => {
630
+ setNotice(null);
631
+ setConfirmation(confirmationFor(input));
632
+ };
633
+ const requestConfigMutation = (
634
+ input: OmpPluginConfigMutation,
635
+ setting: OmpPluginConfigSetting,
636
+ ) => {
637
+ setNotice(null);
638
+ setConfirmation(configConfirmationFor(input, setting));
639
+ };
640
+ const inspect = (plugin: string) => {
641
+ inspectionGeneration.current += 1;
642
+ if (inspected?.plugin === plugin) {
643
+ setInspected(null);
644
+ configInspection.reset();
645
+ setConfirmation(null);
646
+ return;
647
+ }
648
+ const generation = inspectionGeneration.current;
649
+ setInspected(null);
650
+ setConfirmation(null);
651
+ configInspection.mutate({ plugin, generation });
652
+ };
653
+ const applyConfirmation = () => {
654
+ if (!confirmation) return;
655
+ if (confirmation.kind === "plugin") mutation.mutate(confirmation.input);
656
+ else configMutation.mutate(confirmation.input);
657
+ };
658
+
659
+ return (
660
+ <View style={styles.root}>
661
+ <View style={styles.header}>
662
+ <View>
663
+ <Text style={styles.title}>OMP plugins</Text>
664
+ <Text style={styles.muted}>Manage OMP's native plugins, not Paseo plugins.</Text>
665
+ </View>
666
+ <ActionButton
667
+ label={plugins.isFetching ? "Refreshing…" : "Refresh"}
668
+ disabled={plugins.isFetching || busy}
669
+ styles={styles}
670
+ onPress={() => {
671
+ void plugins.refetch();
672
+ }}
673
+ />
674
+ </View>
675
+
676
+ <View style={styles.card}>
677
+ <Text style={styles.cardTitle}>Install a plugin</Text>
678
+ <Text style={styles.muted}>
679
+ Enter one source accepted by OMP, such as a package, name@marketplace, Git URL, or local
680
+ path.
681
+ </Text>
682
+ <TextInput
683
+ accessibilityLabel="OMP plugin source"
684
+ editable={!busy}
685
+ value={source}
686
+ onChangeText={setSource}
687
+ placeholder="name@marketplace or package source"
688
+ placeholderTextColor={theme.colors.foregroundMuted}
689
+ autoCapitalize="none"
690
+ autoCorrect={false}
691
+ style={styles.input}
692
+ />
693
+ <View style={styles.actions}>
694
+ <ActionButton
695
+ label="Review install"
696
+ disabled={busy || source.length === 0}
697
+ primary
698
+ styles={styles}
699
+ onPress={requestInstall}
700
+ />
701
+ </View>
702
+ </View>
703
+
704
+ {confirmation ? (
705
+ <View accessibilityRole="alert" style={styles.confirmation}>
706
+ <Text style={styles.cardTitle}>{confirmation.title}</Text>
707
+ <Text style={styles.warning}>{confirmation.warning}</Text>
708
+ <View style={styles.actions}>
709
+ <ActionButton
710
+ label="Cancel"
711
+ disabled={busy}
712
+ styles={styles}
713
+ onPress={() => setConfirmation(null)}
714
+ />
715
+ <ActionButton
716
+ label={busy ? "Applying…" : "Confirm"}
717
+ disabled={busy}
718
+ primary
719
+ danger={
720
+ confirmation.kind === "plugin"
721
+ ? confirmation.input.action === "uninstall"
722
+ : confirmation.input.action === "delete"
723
+ }
724
+ styles={styles}
725
+ onPress={applyConfirmation}
726
+ />
727
+ </View>
728
+ </View>
729
+ ) : null}
730
+
731
+ {notice ? (
732
+ <Text
733
+ accessibilityRole="alert"
734
+ style={notice.tone === "success" ? styles.success : styles.error}
735
+ >
736
+ {notice.text}
737
+ </Text>
738
+ ) : null}
739
+ {plugins.isLoading ? <Text style={styles.muted}>Loading OMP plugins…</Text> : null}
740
+ {plugins.error ? (
741
+ <Text accessibilityRole="alert" style={styles.error}>
742
+ Could not load OMP plugin state.
743
+ </Text>
744
+ ) : null}
745
+ {plugins.data?.error ? (
746
+ <Text accessibilityRole="alert" style={styles.error}>
747
+ {plugins.data.error}
748
+ </Text>
749
+ ) : null}
750
+ {plugins.data?.available && plugins.data.plugins.length === 0 ? (
751
+ <View style={styles.card}>
752
+ <Text style={styles.cardTitle}>No plugins installed</Text>
753
+ <Text style={styles.muted}>
754
+ OMP reported an empty npm and marketplace plugin catalog.
755
+ </Text>
756
+ </View>
757
+ ) : null}
758
+ {plugins.data?.droppedCount ? (
759
+ <Text style={styles.warning}>
760
+ {plugins.data.droppedCount} invalid or excess plugin records were omitted.
761
+ </Text>
762
+ ) : null}
763
+
764
+ {plugins.data?.plugins.length ? (
765
+ <View style={styles.pluginGrid}>
766
+ {plugins.data.plugins.map((plugin) => (
767
+ <PluginCard
768
+ key={`${plugin.source}:${plugin.scope ?? "global"}:${plugin.path ?? plugin.id}:${plugin.version ?? "unknown"}`}
769
+ plugin={plugin}
770
+ busy={busy}
771
+ inspecting={
772
+ configInspection.isPending &&
773
+ configInspection.variables?.plugin === plugin.packageName
774
+ }
775
+ styles={styles}
776
+ onConfirm={requestMutation}
777
+ onInspect={inspect}
778
+ />
779
+ ))}
780
+ </View>
781
+ ) : null}
782
+
783
+ {configInspection.error || configMutation.error ? (
784
+ <Text accessibilityRole="alert" style={styles.error}>
785
+ Could not update OMP plugin settings.
786
+ </Text>
787
+ ) : null}
788
+ {inspected ? (
789
+ <View style={styles.card}>
790
+ <View style={styles.cardHeader}>
791
+ <Text style={styles.cardTitle}>Settings · {inspected.plugin}</Text>
792
+ <ActionButton
793
+ label="Close"
794
+ disabled={busy}
795
+ styles={styles}
796
+ onPress={() => {
797
+ inspectionGeneration.current += 1;
798
+ setInspected(null);
799
+ setConfirmation(null);
800
+ }}
801
+ />
802
+ </View>
803
+ <Text style={styles.muted}>
804
+ Current values and defaults are withheld. New values are write-only and are never
805
+ returned to the app.
806
+ </Text>
807
+ {inspected.error ? <Text style={styles.error}>{inspected.error}</Text> : null}
808
+ {inspected.droppedCount > 0 ? (
809
+ <Text style={styles.warning}>
810
+ {inspected.droppedCount} invalid or excess setting definitions were omitted.
811
+ </Text>
812
+ ) : null}
813
+ {inspected.available && inspected.settings.length === 0 ? (
814
+ <Text style={styles.muted}>This plugin declares no settings.</Text>
815
+ ) : null}
816
+ <View style={styles.settings}>
817
+ {inspected.settings.map((setting) => (
818
+ <ConfigSettingEditor
819
+ key={`${inspected.plugin}:${setting.key}:${configEditGenerations[`${inspected.plugin}:${setting.key}`] ?? 0}`}
820
+ plugin={inspected.plugin}
821
+ setting={setting}
822
+ busy={busy || confirmation !== null}
823
+ theme={theme}
824
+ styles={styles}
825
+ onConfirm={requestConfigMutation}
826
+ />
827
+ ))}
828
+ </View>
829
+ </View>
830
+ ) : null}
831
+ </View>
832
+ );
833
+ }