@omercnet/paseo-omp 0.2.1-next.72.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 (78) hide show
  1. package/CHANGELOG.md +87 -0
  2. package/LICENSE +21 -0
  3. package/README.md +120 -0
  4. package/SUPPORT.md +42 -0
  5. package/TESTING.md +150 -0
  6. package/client/composer-pill-settings.tsx +157 -0
  7. package/client/hub-icon.tsx +12 -0
  8. package/client/hub-popover.tsx +132 -0
  9. package/client/hub-status.ts +29 -0
  10. package/client/mcp-authorization.tsx +168 -0
  11. package/client/mcp-popover.tsx +155 -0
  12. package/client/memory-panel.tsx +76 -0
  13. package/client/memory-popover.tsx +74 -0
  14. package/client/omp-config-surface.tsx +1433 -0
  15. package/client/omp-doc-links.ts +117 -0
  16. package/client/omp-plugin-manager.tsx +1004 -0
  17. package/client/omp-store-picker.tsx +89 -0
  18. package/client/omp-store-state.ts +45 -0
  19. package/client/provider-diagnostics-state.ts +262 -0
  20. package/client/provider-icon.tsx +27 -0
  21. package/client/provider-image.tsx +66 -0
  22. package/client/quota-popover.tsx +155 -0
  23. package/client/quota-state.ts +140 -0
  24. package/client/sessions-popover.tsx +78 -0
  25. package/docs/alpha-release-checklist.md +68 -0
  26. package/docs/configuration.md +126 -0
  27. package/docs/core-provider-issue-audit.md +108 -0
  28. package/docs/images/mcp-authorization-compact.png +0 -0
  29. package/docs/images/mcp-controls-wide.png +0 -0
  30. package/docs/images/plugin-manager.png +0 -0
  31. package/docs/images/workspace-settings.png +0 -0
  32. package/docs/installation.md +67 -0
  33. package/index.client.tsx +488 -0
  34. package/index.server.ts +81 -0
  35. package/package.json +84 -0
  36. package/paseo-plugin.json +5 -0
  37. package/scripts/prepare-dependencies.mjs +20 -0
  38. package/server/hub.ts +145 -0
  39. package/server/mcp-browser.ts +95 -0
  40. package/server/memory.ts +86 -0
  41. package/server/mutation-queue.ts +12 -0
  42. package/server/omp-config.ts +135 -0
  43. package/server/omp-plugins.ts +676 -0
  44. package/server/omp-settings.ts +499 -0
  45. package/server/paths.ts +181 -0
  46. package/server/provider/catalog.ts +172 -0
  47. package/server/provider/config-normalization.ts +148 -0
  48. package/server/provider/connection.ts +1196 -0
  49. package/server/provider/host-tools.ts +777 -0
  50. package/server/provider/image.ts +143 -0
  51. package/server/provider/mcp-transport.ts +394 -0
  52. package/server/provider/omp-rpc.ts +2806 -0
  53. package/server/provider/omp.svg +5 -0
  54. package/server/provider/profile-providers.ts +249 -0
  55. package/server/provider/provider-options.ts +27 -0
  56. package/server/provider/registration.ts +162 -0
  57. package/server/provider/security.ts +317 -0
  58. package/server/provider/session-descriptors.ts +736 -0
  59. package/server/provider/session.ts +4796 -0
  60. package/server/provider/settings.ts +78 -0
  61. package/server/provider/subsessions.ts +850 -0
  62. package/server/provider/timeline-projector.ts +1801 -0
  63. package/server/provider-diagnostics.ts +1143 -0
  64. package/server/quota.ts +55 -0
  65. package/server/sessions.ts +58 -0
  66. package/shared/composer-pill-settings.ts +28 -0
  67. package/shared/hub.ts +43 -0
  68. package/shared/mcp.ts +47 -0
  69. package/shared/memory.ts +24 -0
  70. package/shared/omp-config.ts +85 -0
  71. package/shared/omp-plugins.ts +264 -0
  72. package/shared/omp-settings.ts +214 -0
  73. package/shared/omp-store.ts +58 -0
  74. package/shared/provider-diagnostics.ts +126 -0
  75. package/shared/provider-image.ts +160 -0
  76. package/shared/quota.ts +23 -0
  77. package/shared/sessions.ts +24 -0
  78. package/tsconfig.json +16 -0
@@ -0,0 +1,132 @@
1
+ import { type PluginButtonContentProps, useAgent, useRpc } from "@getpaseo/plugin/client";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { useMemo, useState } from "react";
4
+ import { Pressable, Text, View } from "react-native";
5
+ import { type HubProcess, listHubProcesses, tailHubLog } from "../shared/hub";
6
+ import { type HubProcessTone, hubProcessTone } from "./hub-status";
7
+
8
+ const PROCESS_POLL_MS = 3_000;
9
+ const LOG_POLL_MS = 2_000;
10
+
11
+ function age(timestamp: number | null): string {
12
+ if (timestamp === null) return "";
13
+ const seconds = Math.max(0, Math.floor((Date.now() - timestamp) / 1_000));
14
+ if (seconds < 60) return `${seconds}s`;
15
+ const minutes = Math.floor(seconds / 60);
16
+ if (minutes < 60) return `${minutes}m`;
17
+ const hours = Math.floor(minutes / 60);
18
+ if (hours < 24) return `${hours}h`;
19
+ return `${Math.floor(hours / 24)}d`;
20
+ }
21
+
22
+ function command(process: HubProcess): string {
23
+ return [process.application, ...process.args].join(" ");
24
+ }
25
+
26
+ function toneColor(
27
+ tone: HubProcessTone,
28
+ colors: PluginButtonContentProps["theme"]["colors"],
29
+ ): string {
30
+ if (tone === "danger") return colors.statusDanger;
31
+ if (tone === "warning") return colors.statusWarning;
32
+ if (tone === "success") return colors.statusSuccess;
33
+ return colors.foregroundMuted;
34
+ }
35
+
36
+ export function HubPopover(props: PluginButtonContentProps) {
37
+ const { theme, layout } = props;
38
+ const agentId = props.context === "agent" ? props.agentId : "";
39
+ const cwd = useAgent(agentId, (agent) => agent.cwd) ?? "";
40
+ const loadProcesses = useRpc(listHubProcesses);
41
+ const loadLog = useRpc(tailHubLog);
42
+ const [selectedName, setSelectedName] = useState<string | null>(null);
43
+ const processes = useQuery({
44
+ queryKey: ["paseo-omp", "processes", cwd],
45
+ queryFn: () => loadProcesses({ cwd }),
46
+ enabled: cwd.length > 0,
47
+ refetchInterval: PROCESS_POLL_MS,
48
+ });
49
+ const log = useQuery({
50
+ queryKey: ["paseo-omp", "log", cwd, selectedName],
51
+ queryFn: () => loadLog({ cwd, name: selectedName ?? "" }),
52
+ enabled: cwd.length > 0 && selectedName !== null,
53
+ refetchInterval: LOG_POLL_MS,
54
+ });
55
+ const styles = useMemo(
56
+ () => ({
57
+ root: { gap: layout.compact ? 8 : 10 },
58
+ muted: { color: theme.colors.foregroundMuted, fontSize: 13 },
59
+ error: { color: theme.colors.statusDanger, fontSize: 13 },
60
+ card: {
61
+ gap: 4,
62
+ padding: layout.compact ? 10 : 12,
63
+ borderRadius: 10,
64
+ borderWidth: 1,
65
+ borderColor: theme.colors.border,
66
+ backgroundColor: theme.colors.surface1,
67
+ },
68
+ row: { flexDirection: "row" as const, justifyContent: "space-between" as const, gap: 12 },
69
+ name: { color: theme.colors.foreground, fontSize: 14, fontWeight: "600" as const, flex: 1 },
70
+ command: { color: theme.colors.foregroundMuted, fontSize: 12 },
71
+ log: {
72
+ color: theme.colors.foreground,
73
+ fontFamily: "monospace",
74
+ fontSize: 11,
75
+ padding: 10,
76
+ borderRadius: 8,
77
+ backgroundColor: theme.colors.surface2,
78
+ },
79
+ }),
80
+ [layout.compact, theme],
81
+ );
82
+
83
+ if (processes.isLoading) return <Text style={styles.muted}>Loading hub processes…</Text>;
84
+ if (processes.error) return <Text style={styles.error}>Could not read omp hub state.</Text>;
85
+ const items = processes.data?.processes ?? [];
86
+ if (items.length === 0) return <Text style={styles.muted}>No hub-supervised processes.</Text>;
87
+
88
+ return (
89
+ <View style={styles.root}>
90
+ {items.map((process) => {
91
+ const tone = hubProcessTone(process);
92
+ const selected = selectedName === process.name;
93
+ const timestamp = process.exitedAt ?? process.startedAt ?? process.createdAt;
94
+ return (
95
+ <Pressable
96
+ key={process.name}
97
+ accessibilityRole="button"
98
+ accessibilityLabel={`${selected ? "Hide" : "Show"} logs for ${process.name}`}
99
+ onPress={() => setSelectedName(selected ? null : process.name)}
100
+ style={styles.card}
101
+ >
102
+ <View style={styles.row}>
103
+ <Text numberOfLines={1} style={styles.name}>
104
+ {process.name}
105
+ </Text>
106
+ <Text style={{ color: toneColor(tone, theme.colors), fontSize: 12 }}>
107
+ {process.state}
108
+ {process.exitCode !== null ? ` · ${process.exitCode}` : ""}
109
+ {timestamp !== null ? ` · ${age(timestamp)}` : ""}
110
+ </Text>
111
+ </View>
112
+ <Text numberOfLines={2} style={styles.command}>
113
+ {command(process)}
114
+ </Text>
115
+ {selected ? (
116
+ log.isLoading ? (
117
+ <Text style={styles.muted}>Loading logs…</Text>
118
+ ) : log.error ? (
119
+ <Text style={styles.error}>Could not read this process log.</Text>
120
+ ) : (
121
+ <Text selectable style={styles.log}>
122
+ {log.data?.truncated ? "… showing the latest 64 KiB\n" : ""}
123
+ {log.data?.content || "No output."}
124
+ </Text>
125
+ )
126
+ ) : null}
127
+ </Pressable>
128
+ );
129
+ })}
130
+ </View>
131
+ );
132
+ }
@@ -0,0 +1,29 @@
1
+ import type { HubProcess } from "../shared/hub";
2
+
3
+ export type HubProcessTone = "success" | "warning" | "danger" | "muted";
4
+
5
+ const FAILED_STATES: Record<string, true> = { crashed: true, error: true, failed: true };
6
+ const ACTIVE_STATES: Record<string, true> = { ready: true, running: true };
7
+ const TERMINAL_STATES: Record<string, true> = { exited: true, stopped: true };
8
+
9
+ export function hubProcessTone(process: HubProcess): HubProcessTone {
10
+ const state = process.state.toLowerCase();
11
+ if (FAILED_STATES[state] || (process.exitCode !== null && process.exitCode !== 0)) {
12
+ return "danger";
13
+ }
14
+ if (ACTIVE_STATES[state]) return "success";
15
+ if (TERMINAL_STATES[state]) return "muted";
16
+ return "warning";
17
+ }
18
+
19
+ export function summarizeHubProcesses(processes: readonly HubProcess[]): {
20
+ visible: boolean;
21
+ label: string;
22
+ } {
23
+ if (processes.length === 0) return { visible: false, label: "Hub" };
24
+ const hasFailure = processes.some((process) => hubProcessTone(process) === "danger");
25
+ return {
26
+ visible: true,
27
+ label: `Hub · ${processes.length}${hasFailure ? " !" : ""}`,
28
+ };
29
+ }
@@ -0,0 +1,168 @@
1
+ import { type PluginTimelineItemProps, useRpc } from "@getpaseo/plugin/client";
2
+ import { Icon } from "@getpaseo/plugin/client/react-native";
3
+ import { useMemo, useState } from "react";
4
+ import { Linking, Pressable, Text, View } from "react-native";
5
+ import {
6
+ type OmpMcpAuthorizationTimeline,
7
+ openOmpMcpAuthorizationInPaseoBrowser,
8
+ } from "../shared/mcp";
9
+
10
+ function browserAuthorizationError(error: unknown): string {
11
+ const detail = error instanceof Error ? error.message : String(error);
12
+ if (/No Paseo desktop browser host/iu.test(detail)) {
13
+ return "No Paseo desktop browser host is connected. Open the authorization on this device.";
14
+ }
15
+ if (/browser tools are disabled/iu.test(detail)) {
16
+ return "Paseo browser tools are disabled. Open the authorization on this device.";
17
+ }
18
+ if (/browser tools are unavailable/iu.test(detail)) {
19
+ return "Paseo tools are not available in this OMP session. Open the authorization on this device.";
20
+ }
21
+ if (/session is no longer available|session is closed/iu.test(detail)) {
22
+ return "The OMP authorization session ended. Start authorization again or open the URL on this device.";
23
+ }
24
+ return "Could not open a Paseo Browser tab. Open the authorization on this device.";
25
+ }
26
+
27
+ export function OmpMcpAuthorizationCard({
28
+ item,
29
+ theme,
30
+ layout,
31
+ }: PluginTimelineItemProps<OmpMcpAuthorizationTimeline>) {
32
+ const openInPaseoBrowser = useRpc(openOmpMcpAuthorizationInPaseoBrowser);
33
+ const [opening, setOpening] = useState<"paseo" | "device" | null>(null);
34
+ const [message, setMessage] = useState<string | null>(null);
35
+ const [error, setError] = useState<string | null>(null);
36
+ const styles = useMemo(
37
+ () => ({
38
+ card: {
39
+ gap: layout.compact ? 10 : 12,
40
+ padding: layout.compact ? 12 : 14,
41
+ borderWidth: 1,
42
+ borderColor: theme.colors.border,
43
+ borderRadius: 10,
44
+ backgroundColor: theme.colors.surface1,
45
+ },
46
+ header: { flexDirection: "row" as const, alignItems: "center" as const, gap: 8 },
47
+ title: { color: theme.colors.foreground, fontSize: 14, fontWeight: "700" as const },
48
+ text: { color: theme.colors.foregroundMuted, fontSize: 13, lineHeight: 18 },
49
+ url: { color: theme.colors.accent, fontSize: 12, lineHeight: 17 },
50
+ buttonRow: { flexDirection: "row" as const, flexWrap: "wrap" as const, gap: 8 },
51
+ button: {
52
+ flexDirection: "row" as const,
53
+ alignItems: "center" as const,
54
+ alignSelf: "flex-start" as const,
55
+ gap: 7,
56
+ paddingHorizontal: 12,
57
+ paddingVertical: 9,
58
+ borderRadius: 8,
59
+ backgroundColor: theme.colors.accent,
60
+ },
61
+ buttonSecondary: {
62
+ borderWidth: 1,
63
+ borderColor: theme.colors.border,
64
+ backgroundColor: theme.colors.surface2,
65
+ },
66
+ buttonPressed: { opacity: 0.78 },
67
+ buttonDisabled: { opacity: 0.55 },
68
+ buttonText: {
69
+ color: theme.colors.accentForeground,
70
+ fontSize: 13,
71
+ fontWeight: "600" as const,
72
+ },
73
+ buttonTextSecondary: { color: theme.colors.foreground },
74
+ success: { color: theme.colors.statusSuccess, fontSize: 12 },
75
+ error: { color: theme.colors.statusDanger, fontSize: 12 },
76
+ }),
77
+ [layout.compact, theme],
78
+ );
79
+
80
+ async function openPaseoBrowser(): Promise<void> {
81
+ setOpening("paseo");
82
+ setError(null);
83
+ setMessage(null);
84
+ try {
85
+ const authorizationToken = item.data.browserAuthorizationToken;
86
+ if (!authorizationToken) throw new Error("Browser authorization is unavailable");
87
+ await openInPaseoBrowser({ authorizationToken });
88
+ setMessage("Opened in a Paseo Browser tab for this workspace.");
89
+ } catch (cause) {
90
+ setError(browserAuthorizationError(cause));
91
+ } finally {
92
+ setOpening(null);
93
+ }
94
+ }
95
+
96
+ async function openOnDevice(): Promise<void> {
97
+ setOpening("device");
98
+ setError(null);
99
+ setMessage(null);
100
+ try {
101
+ await Linking.openURL(item.data.url);
102
+ } catch {
103
+ setError("Could not open the authorization URL. Copy the URL below into a browser.");
104
+ } finally {
105
+ setOpening(null);
106
+ }
107
+ }
108
+
109
+ return (
110
+ <View style={styles.card}>
111
+ <View style={styles.header}>
112
+ <Icon name="KeyRound" size={16} color={theme.colors.accent} />
113
+ <Text style={styles.title}>OMP MCP authorization</Text>
114
+ </View>
115
+ {item.data.instructions ? <Text style={styles.text}>{item.data.instructions}</Text> : null}
116
+ <View style={styles.buttonRow}>
117
+ {item.data.browserAuthorizationToken ? (
118
+ <Pressable
119
+ accessibilityRole="button"
120
+ accessibilityLabel="Open MCP authorization in Paseo Browser"
121
+ disabled={opening !== null}
122
+ onPress={() => void openPaseoBrowser()}
123
+ style={({ pressed }) => [
124
+ styles.button,
125
+ pressed ? styles.buttonPressed : null,
126
+ opening !== null ? styles.buttonDisabled : null,
127
+ ]}
128
+ >
129
+ <Icon name="PanelsTopLeft" size={15} color={theme.colors.accentForeground} />
130
+ <Text style={styles.buttonText}>
131
+ {opening === "paseo" ? "Opening…" : "Open in Paseo Browser"}
132
+ </Text>
133
+ </Pressable>
134
+ ) : null}
135
+ <Pressable
136
+ accessibilityRole="link"
137
+ accessibilityLabel="Open MCP authorization on this device"
138
+ disabled={opening !== null}
139
+ onPress={() => void openOnDevice()}
140
+ style={({ pressed }) => [
141
+ styles.button,
142
+ styles.buttonSecondary,
143
+ pressed ? styles.buttonPressed : null,
144
+ opening !== null ? styles.buttonDisabled : null,
145
+ ]}
146
+ >
147
+ <Icon name="ExternalLink" size={15} color={theme.colors.foreground} />
148
+ <Text style={[styles.buttonText, styles.buttonTextSecondary]}>
149
+ {opening === "device" ? "Opening…" : "Open on this device"}
150
+ </Text>
151
+ </Pressable>
152
+ </View>
153
+ <Text selectable style={styles.url}>
154
+ {item.data.url}
155
+ </Text>
156
+ {item.data.loopbackCallback ? (
157
+ <Text style={styles.text}>
158
+ Paseo Browser keeps authorization in this workspace when a desktop browser host is
159
+ connected. For a localhost callback, that browser host must run on the daemon machine.
160
+ Otherwise, copy the final URL or authorization code and paste it into the OMP prompt in
161
+ this chat.
162
+ </Text>
163
+ ) : null}
164
+ {message ? <Text style={styles.success}>{message}</Text> : null}
165
+ {error ? <Text style={styles.error}>{error}</Text> : null}
166
+ </View>
167
+ );
168
+ }
@@ -0,0 +1,155 @@
1
+ import { type PluginButtonContentProps, useAgent, usePaseo } from "@getpaseo/plugin/client";
2
+ import { TextInput } from "@getpaseo/plugin/client/react-native";
3
+ import { useMemo, useState } from "react";
4
+ import { Pressable, Text, View } from "react-native";
5
+ import { buildOmpMcpServerCommand, type OmpMcpServerAction } from "../shared/mcp";
6
+
7
+ import { isOmpPluginProvider } from "./omp-store-state";
8
+
9
+ const GENERAL_ACTIONS = [
10
+ { label: "List servers", command: "/mcp list" },
11
+ { label: "Add server", command: "/mcp add" },
12
+ { label: "Reload", command: "/mcp reload" },
13
+ { label: "Resources", command: "/mcp resources" },
14
+ { label: "Prompts", command: "/mcp prompts" },
15
+ { label: "Notifications", command: "/mcp notifications" },
16
+ ] as const;
17
+
18
+ const SERVER_ACTIONS: ReadonlyArray<{ label: string; action: OmpMcpServerAction }> = [
19
+ { label: "Test", action: "test" },
20
+ { label: "Authorize", action: "reauth" },
21
+ { label: "Enable", action: "enable" },
22
+ { label: "Disable", action: "disable" },
23
+ ];
24
+
25
+ export function McpPopover(props: PluginButtonContentProps) {
26
+ const { theme, layout, close } = props;
27
+ const agentId = props.context === "agent" ? props.agentId : "";
28
+ const agent = useAgent(agentId, ({ provider, status }) => ({ provider, status }));
29
+ const paseo = usePaseo();
30
+ const [serverName, setServerName] = useState("");
31
+ const [pendingCommand, setPendingCommand] = useState<string | null>(null);
32
+ const [error, setError] = useState<string | null>(null);
33
+ const styles = useMemo(
34
+ () => ({
35
+ root: { gap: layout.compact ? 10 : 12, width: layout.compact ? undefined : 340 },
36
+ title: { color: theme.colors.foreground, fontSize: 14, fontWeight: "700" as const },
37
+ muted: { color: theme.colors.foregroundMuted, fontSize: 12, lineHeight: 17 },
38
+ error: { color: theme.colors.statusDanger, fontSize: 12 },
39
+ actions: { flexDirection: "row" as const, flexWrap: "wrap" as const, gap: 7 },
40
+ action: {
41
+ paddingHorizontal: 10,
42
+ paddingVertical: 8,
43
+ borderWidth: 1,
44
+ borderColor: theme.colors.border,
45
+ borderRadius: 8,
46
+ backgroundColor: theme.colors.surface1,
47
+ },
48
+ actionPressed: { opacity: 0.72 },
49
+ actionDisabled: { opacity: 0.45 },
50
+ actionText: { color: theme.colors.foreground, fontSize: 12, fontWeight: "600" as const },
51
+ input: {
52
+ color: theme.colors.foreground,
53
+ borderWidth: 1,
54
+ borderColor: theme.colors.border,
55
+ borderRadius: 8,
56
+ backgroundColor: theme.colors.surface0,
57
+ paddingHorizontal: 10,
58
+ paddingVertical: 8,
59
+ fontSize: 13,
60
+ },
61
+ }),
62
+ [layout.compact, theme],
63
+ );
64
+
65
+ const targetCommands = SERVER_ACTIONS.map(({ label, action }) => ({
66
+ label,
67
+ command: buildOmpMcpServerCommand(action, serverName),
68
+ }));
69
+ const unavailable = !isOmpPluginProvider(agent?.provider);
70
+
71
+ async function send(command: string): Promise<void> {
72
+ setPendingCommand(command);
73
+ setError(null);
74
+ try {
75
+ await paseo.agents.ref(agentId).send(command);
76
+ close();
77
+ } catch (cause) {
78
+ setError(cause instanceof Error ? cause.message : "Could not send the OMP MCP command.");
79
+ } finally {
80
+ setPendingCommand(null);
81
+ }
82
+ }
83
+
84
+ if (unavailable)
85
+ return <Text style={styles.muted}>MCP controls require an OMP Plugin agent.</Text>;
86
+
87
+ return (
88
+ <View style={styles.root}>
89
+ <Text style={styles.title}>OMP MCP</Text>
90
+ <Text style={styles.muted}>
91
+ Commands run in this OMP session. Results, setup questions, and authorization stay in the
92
+ chat timeline so they remain usable from remote and mobile clients.
93
+ </Text>
94
+ <View style={styles.actions}>
95
+ {GENERAL_ACTIONS.map((action) => (
96
+ <Pressable
97
+ key={action.command}
98
+ accessibilityRole="button"
99
+ accessibilityLabel={action.label}
100
+ disabled={pendingCommand !== null}
101
+ onPress={() => void send(action.command)}
102
+ style={({ pressed }) => [
103
+ styles.action,
104
+ pressed ? styles.actionPressed : null,
105
+ pendingCommand !== null ? styles.actionDisabled : null,
106
+ ]}
107
+ >
108
+ <Text style={styles.actionText}>
109
+ {pendingCommand === action.command ? "Sending…" : action.label}
110
+ </Text>
111
+ </Pressable>
112
+ ))}
113
+ </View>
114
+ <TextInput
115
+ accessibilityLabel="OMP MCP server name"
116
+ autoCapitalize="none"
117
+ autoCorrect={false}
118
+ onChangeText={setServerName}
119
+ placeholder="Server name"
120
+ placeholderTextColor={theme.colors.foregroundMuted}
121
+ style={styles.input}
122
+ value={serverName}
123
+ />
124
+ <View style={styles.actions}>
125
+ {targetCommands.map((action) => (
126
+ <Pressable
127
+ key={action.label}
128
+ accessibilityRole="button"
129
+ accessibilityLabel={`${action.label} MCP server`}
130
+ disabled={!action.command || pendingCommand !== null}
131
+ onPress={() => action.command && void send(action.command)}
132
+ style={({ pressed }) => [
133
+ styles.action,
134
+ pressed ? styles.actionPressed : null,
135
+ !action.command || pendingCommand !== null ? styles.actionDisabled : null,
136
+ ]}
137
+ >
138
+ <Text style={styles.actionText}>
139
+ {pendingCommand === action.command ? "Sending…" : action.label}
140
+ </Text>
141
+ </Pressable>
142
+ ))}
143
+ </View>
144
+ {agent?.status === "running" ? (
145
+ <Text style={styles.muted}>The command may wait until the active turn finishes.</Text>
146
+ ) : null}
147
+ {serverName.trim() && targetCommands.every((action) => !action.command) ? (
148
+ <Text style={styles.error}>
149
+ Server names may contain letters, numbers, dash, underscore, dot, and colon.
150
+ </Text>
151
+ ) : null}
152
+ {error ? <Text style={styles.error}>{error}</Text> : null}
153
+ </View>
154
+ );
155
+ }
@@ -0,0 +1,76 @@
1
+ import type { PluginWorkspacePanelProps } from "@getpaseo/plugin/client";
2
+ import { useRpc, useWorkspace } from "@getpaseo/plugin/client";
3
+ import { useQuery } from "@tanstack/react-query";
4
+ import { useMemo, useState } from "react";
5
+ import { ScrollView, Text, View } from "react-native";
6
+ import { listOmpMemory } from "../shared/memory";
7
+ import type { OmpStore } from "../shared/omp-store";
8
+ import { OmpStorePicker } from "./omp-store-picker";
9
+ import { ompStoreKey } from "./omp-store-state";
10
+
11
+ const MEMORY_POLL_MS = 15_000;
12
+
13
+ export function OmpMemoryPanel({ theme, layout, workspaceId }: PluginWorkspacePanelProps) {
14
+ const directory = useWorkspace(workspaceId, (workspace) => workspace.directory) ?? "";
15
+ const [store, setStore] = useState<OmpStore>();
16
+ const loadMemory = useRpc(listOmpMemory);
17
+ const memory = useQuery({
18
+ queryKey: ["paseo-omp", "memory", ompStoreKey(store), directory],
19
+ queryFn: () => loadMemory({ cwd: directory, store }),
20
+ enabled: directory.length > 0,
21
+ refetchInterval: MEMORY_POLL_MS,
22
+ });
23
+ const styles = useMemo(
24
+ () => ({
25
+ root: {
26
+ flex: 1,
27
+ gap: layout.compact ? 10 : 14,
28
+ padding: layout.compact ? 16 : 24,
29
+ backgroundColor: theme.colors.surface0,
30
+ },
31
+ title: {
32
+ color: theme.colors.foreground,
33
+ fontSize: layout.compact ? 20 : 24,
34
+ fontWeight: "700" as const,
35
+ },
36
+ subtitle: { color: theme.colors.foregroundMuted, fontSize: 13 },
37
+ card: {
38
+ gap: 6,
39
+ padding: layout.compact ? 10 : 12,
40
+ borderWidth: 1,
41
+ borderColor: theme.colors.border,
42
+ borderRadius: 10,
43
+ backgroundColor: theme.colors.surface1,
44
+ },
45
+ fact: { color: theme.colors.foreground, fontSize: 14 },
46
+ detail: { color: theme.colors.foregroundMuted, fontSize: 12 },
47
+ error: { color: theme.colors.statusDanger, fontSize: 13 },
48
+ }),
49
+ [layout.compact, theme],
50
+ );
51
+
52
+ return (
53
+ <ScrollView contentContainerStyle={styles.root}>
54
+ <View style={{ gap: 4 }}>
55
+ <Text style={styles.title}>OMP Memory</Text>
56
+ <Text style={styles.subtitle}>
57
+ {memory.data?.bank ? `Bank: ${memory.data.bank}` : "Retained workspace facts"}
58
+ </Text>
59
+ </View>
60
+ <OmpStorePicker theme={theme} store={store} onChange={setStore} />
61
+ {memory.isLoading ? <Text style={styles.subtitle}>Loading retained facts…</Text> : null}
62
+ {memory.error ? <Text style={styles.error}>Could not read workspace memory.</Text> : null}
63
+ {!memory.isLoading && !memory.error && (memory.data?.facts.length ?? 0) === 0 ? (
64
+ <Text style={styles.subtitle}>No retained facts for this workspace.</Text>
65
+ ) : null}
66
+ {memory.data?.facts.map((fact) => (
67
+ <View key={fact.id} style={styles.card}>
68
+ <Text style={styles.fact}>{`${fact.subject} ${fact.predicate} ${fact.object}`}</Text>
69
+ <Text style={styles.detail}>
70
+ {`${Math.round(fact.confidence * 100)}% confidence${fact.timestamp ? ` · ${fact.timestamp}` : ""}`}
71
+ </Text>
72
+ </View>
73
+ ))}
74
+ </ScrollView>
75
+ );
76
+ }
@@ -0,0 +1,74 @@
1
+ import { type PluginButtonContentProps, useAgent, useRpc } from "@getpaseo/plugin/client";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { useMemo } from "react";
4
+ import { Text, View } from "react-native";
5
+ import { listOmpMemory } from "../shared/memory";
6
+ import { storeForProvider, storeLabel } from "../shared/omp-store";
7
+ import { ompStoreKey } from "./omp-store-state";
8
+
9
+ const MEMORY_POLL_MS = 15_000;
10
+ const PREVIEW_LIMIT = 20;
11
+
12
+ export function MemoryPopover(props: PluginButtonContentProps) {
13
+ const { theme, layout } = props;
14
+ const agentId = props.context === "agent" ? props.agentId : "";
15
+ const agent = useAgent(agentId, ({ cwd, provider }) => ({ cwd, provider }));
16
+ const cwd = agent?.cwd ?? "";
17
+ const store = storeForProvider(agent?.provider);
18
+ const loadMemory = useRpc(listOmpMemory);
19
+ const memory = useQuery({
20
+ queryKey: ["paseo-omp", "memory", ompStoreKey(store), cwd],
21
+ queryFn: () => loadMemory({ cwd, store }),
22
+ enabled: cwd.length > 0,
23
+ refetchInterval: MEMORY_POLL_MS,
24
+ });
25
+ const styles = useMemo(
26
+ () => ({
27
+ root: { gap: layout.compact ? 8 : 10, minWidth: layout.compact ? undefined : 260 },
28
+ muted: { color: theme.colors.foregroundMuted, fontSize: 12 },
29
+ error: { color: theme.colors.statusDanger, fontSize: 13 },
30
+ header: { flexDirection: "row" as const, justifyContent: "space-between" as const, gap: 12 },
31
+ title: { color: theme.colors.foreground, fontSize: 14, fontWeight: "700" as const },
32
+ card: {
33
+ gap: 4,
34
+ padding: layout.compact ? 9 : 10,
35
+ borderWidth: 1,
36
+ borderColor: theme.colors.border,
37
+ borderRadius: 9,
38
+ backgroundColor: theme.colors.surface1,
39
+ },
40
+ fact: { color: theme.colors.foreground, fontSize: 13 },
41
+ detail: { color: theme.colors.foregroundMuted, fontSize: 11 },
42
+ }),
43
+ [layout.compact, theme],
44
+ );
45
+
46
+ if (memory.isLoading) return <Text style={styles.muted}>Loading retained facts…</Text>;
47
+ if (memory.error) return <Text style={styles.error}>Could not read workspace memory.</Text>;
48
+
49
+ const facts = memory.data?.facts ?? [];
50
+ if (facts.length === 0) {
51
+ return <Text style={styles.muted}>No retained facts for this workspace.</Text>;
52
+ }
53
+
54
+ return (
55
+ <View style={styles.root}>
56
+ <View style={styles.header}>
57
+ <Text style={styles.title}>OMP Memory · {storeLabel(store)}</Text>
58
+ <Text style={styles.muted}>{facts.length} facts</Text>
59
+ </View>
60
+ <Text style={styles.muted}>{memory.data?.bank ?? "Workspace memory"}</Text>
61
+ {facts.slice(0, PREVIEW_LIMIT).map((fact) => (
62
+ <View key={fact.id} style={styles.card}>
63
+ <Text style={styles.fact}>{`${fact.subject} ${fact.predicate} ${fact.object}`}</Text>
64
+ <Text style={styles.detail}>
65
+ {`${Math.round(fact.confidence * 100)}% confidence${fact.timestamp ? ` · ${fact.timestamp}` : ""}`}
66
+ </Text>
67
+ </View>
68
+ ))}
69
+ {facts.length > PREVIEW_LIMIT ? (
70
+ <Text style={styles.muted}>{`Showing ${PREVIEW_LIMIT} of ${facts.length}`}</Text>
71
+ ) : null}
72
+ </View>
73
+ );
74
+ }