@xpufx/paseo-x-comms 0.3.0

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 (152) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +149 -0
  3. package/client/attribution.ts +7 -0
  4. package/client/conversations.ts +229 -0
  5. package/client/main.tsx +885 -0
  6. package/client/peer-label.ts +98 -0
  7. package/client/peer-status.tsx +201 -0
  8. package/client/settings-prototype.tsx +395 -0
  9. package/client/tool-call.ts +115 -0
  10. package/client/vendor/paseo-plugin-helper/command-center.ts +43 -0
  11. package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +493 -0
  12. package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +250 -0
  13. package/client/vendor/paseo-plugin-helper/components/Badge.tsx +156 -0
  14. package/client/vendor/paseo-plugin-helper/components/Button.tsx +178 -0
  15. package/client/vendor/paseo-plugin-helper/components/Card.tsx +225 -0
  16. package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +196 -0
  17. package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +277 -0
  18. package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +172 -0
  19. package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +180 -0
  20. package/client/vendor/paseo-plugin-helper/components/DataTable.tsx +200 -0
  21. package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +97 -0
  22. package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +70 -0
  23. package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +73 -0
  24. package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +446 -0
  25. package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +247 -0
  26. package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +117 -0
  27. package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +53 -0
  28. package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +118 -0
  29. package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +80 -0
  30. package/client/vendor/paseo-plugin-helper/components/Select.tsx +215 -0
  31. package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +80 -0
  32. package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +319 -0
  33. package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +150 -0
  34. package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +163 -0
  35. package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +157 -0
  36. package/client/vendor/paseo-plugin-helper/components/index.ts +25 -0
  37. package/client/vendor/paseo-plugin-helper/custom-pills.tsx +224 -0
  38. package/client/vendor/paseo-plugin-helper/forge-icon.tsx +79 -0
  39. package/client/vendor/paseo-plugin-helper/host.ts +277 -0
  40. package/client/vendor/paseo-plugin-helper/icon.tsx +39 -0
  41. package/client/vendor/paseo-plugin-helper/index.ts +28 -0
  42. package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +49 -0
  43. package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +103 -0
  44. package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +65 -0
  45. package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +378 -0
  46. package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +49 -0
  47. package/client/vendor/paseo-plugin-helper/layout/Row.tsx +39 -0
  48. package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +39 -0
  49. package/client/vendor/paseo-plugin-helper/layout/index.ts +7 -0
  50. package/client/vendor/paseo-plugin-helper/panel.tsx +81 -0
  51. package/client/vendor/paseo-plugin-helper/pill.tsx +884 -0
  52. package/client/vendor/paseo-plugin-helper/query-refresh.ts +79 -0
  53. package/client/vendor/paseo-plugin-helper/query.ts +66 -0
  54. package/client/vendor/paseo-plugin-helper/settings-screen.tsx +372 -0
  55. package/client/vendor/paseo-plugin-helper/settings.ts +181 -0
  56. package/client/vendor/paseo-plugin-helper/shared-settings.ts +46 -0
  57. package/client/vendor/paseo-plugin-helper/snapshot.ts +68 -0
  58. package/client/vendor/paseo-plugin-helper/surface.tsx +80 -0
  59. package/client/vendor/paseo-plugin-helper/theme/color-utils.ts +118 -0
  60. package/client/vendor/paseo-plugin-helper/theme/flair.ts +76 -0
  61. package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +121 -0
  62. package/client/vendor/paseo-plugin-helper/theme/index.ts +7 -0
  63. package/client/vendor/paseo-plugin-helper/theme/provider.tsx +214 -0
  64. package/client/vendor/paseo-plugin-helper/theme/responsive.ts +213 -0
  65. package/client/vendor/paseo-plugin-helper/theme/tokens.ts +161 -0
  66. package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +57 -0
  67. package/client/vendor/paseo-plugin-helper/utils/clipboard.ts +149 -0
  68. package/client/vendor/paseo-plugin-helper/utils/haptics.ts +34 -0
  69. package/client/via-x-comms.tsx +15 -0
  70. package/client/x-comms-conversation.tsx +449 -0
  71. package/client/x-comms-panel.tsx +6 -0
  72. package/client/x-comms-pill.tsx +193 -0
  73. package/client/x-comms-timeline.tsx +176 -0
  74. package/client/x-comms-tool-call.tsx +85 -0
  75. package/docs/mesh.md +115 -0
  76. package/mcp/LICENSE +202 -0
  77. package/mcp/README.md +287 -0
  78. package/mcp/mcp-config.example.json +8 -0
  79. package/mcp/package-lock.json +1186 -0
  80. package/mcp/package.json +43 -0
  81. package/mcp/paseo-cross-daemon-comms.example.json +5 -0
  82. package/mcp/paseo-x-comms.bundled.mjs +36964 -0
  83. package/mcp/paseo-x-comms.mjs +630 -0
  84. package/mcp/test/fixtures/extensions/block/10-block.mjs +4 -0
  85. package/mcp/test/fixtures/extensions/broken-hook/10-thrower.mjs +6 -0
  86. package/mcp/test/fixtures/extensions/broken-hook/20-good.mjs +4 -0
  87. package/mcp/test/fixtures/extensions/broken-load/10-thrower.mjs +4 -0
  88. package/mcp/test/fixtures/extensions/broken-load/20-good.mjs +4 -0
  89. package/mcp/test/fixtures/extensions/custom-tool/10-tool.mjs +12 -0
  90. package/mcp/test/fixtures/extensions/tool-block/10-block.mjs +8 -0
  91. package/mcp/test/fixtures/extensions/transform/10-transform.mjs +15 -0
  92. package/mcp/test/fixtures/fake-paseo.mjs +92 -0
  93. package/mcp/test/register-ts-hooks.mjs +6 -0
  94. package/mcp/test/resolve-ts-hooks.mjs +46 -0
  95. package/package.json +58 -0
  96. package/paseo-plugin.json +7 -0
  97. package/server/conversations-snapshot.ts +249 -0
  98. package/server/handlers.ts +1081 -0
  99. package/server/injection.ts +152 -0
  100. package/server/local-send.ts +153 -0
  101. package/server/mcp-client.ts +47 -0
  102. package/server/outbox.ts +233 -0
  103. package/server/peer-channel.ts +151 -0
  104. package/server/peer-status.ts +233 -0
  105. package/server/presence.ts +204 -0
  106. package/server/registry.ts +215 -0
  107. package/server/relay-status.ts +9 -0
  108. package/server/server-status.ts +138 -0
  109. package/server/settings.ts +81 -0
  110. package/server/snapshot.ts +176 -0
  111. package/server/vendor/paseo-plugin-helper/agent.ts +85 -0
  112. package/server/vendor/paseo-plugin-helper/custom-pills.ts +344 -0
  113. package/server/vendor/paseo-plugin-helper/index.ts +18 -0
  114. package/server/vendor/paseo-plugin-helper/jsonc.ts +78 -0
  115. package/server/vendor/paseo-plugin-helper/logger.ts +210 -0
  116. package/server/vendor/paseo-plugin-helper/mcp/client.ts +349 -0
  117. package/server/vendor/paseo-plugin-helper/mcp/http-client.ts +399 -0
  118. package/server/vendor/paseo-plugin-helper/mcp/index.ts +5 -0
  119. package/server/vendor/paseo-plugin-helper/mcp/process-killer.ts +65 -0
  120. package/server/vendor/paseo-plugin-helper/mcp/ring-buffer.ts +29 -0
  121. package/server/vendor/paseo-plugin-helper/mcp/types.ts +41 -0
  122. package/server/vendor/paseo-plugin-helper/mcp-config.ts +367 -0
  123. package/server/vendor/paseo-plugin-helper/mcp-injection.ts +85 -0
  124. package/server/vendor/paseo-plugin-helper/network.ts +91 -0
  125. package/server/vendor/paseo-plugin-helper/plugins.ts +160 -0
  126. package/server/vendor/paseo-plugin-helper/process.ts +186 -0
  127. package/server/vendor/paseo-plugin-helper/redact.ts +86 -0
  128. package/server/vendor/paseo-plugin-helper/rpc-guard.ts +77 -0
  129. package/server/vendor/paseo-plugin-helper/settings.ts +97 -0
  130. package/server/vendor/paseo-plugin-helper/shared-settings.ts +243 -0
  131. package/server/vendor/paseo-plugin-helper/storage.ts +244 -0
  132. package/server/vendor/paseo-plugin-helper/system.ts +128 -0
  133. package/server/vendor/paseo-plugin-helper/task.ts +116 -0
  134. package/server/vendor/paseo-plugin-helper/version.ts +153 -0
  135. package/server/vendor/paseo-plugin-helper/workspace-beacon.ts +418 -0
  136. package/shared/conversations-snapshot.ts +39 -0
  137. package/shared/envelope.ts +92 -0
  138. package/shared/outbox.ts +21 -0
  139. package/shared/registry.ts +366 -0
  140. package/shared/vendor/paseo-plugin-helper/README.md +11 -0
  141. package/shared/vendor/paseo-plugin-helper/async.ts +35 -0
  142. package/shared/vendor/paseo-plugin-helper/custom-pills.ts +169 -0
  143. package/shared/vendor/paseo-plugin-helper/forge.ts +110 -0
  144. package/shared/vendor/paseo-plugin-helper/formatters.ts +271 -0
  145. package/shared/vendor/paseo-plugin-helper/highlight.ts +184 -0
  146. package/shared/vendor/paseo-plugin-helper/index.ts +10 -0
  147. package/shared/vendor/paseo-plugin-helper/rpc.ts +72 -0
  148. package/shared/vendor/paseo-plugin-helper/settings.ts +138 -0
  149. package/shared/vendor/paseo-plugin-helper/suite-settings.ts +17 -0
  150. package/shared/vendor/paseo-plugin-helper/suppressed.ts +31 -0
  151. package/shared/vendor/paseo-plugin-helper/types.ts +36 -0
  152. package/shared/version.ts +2 -0
@@ -0,0 +1,186 @@
1
+ import { spawn, type SpawnOptions } from "node:child_process";
2
+
3
+ export interface SafeSpawnOptions extends SpawnOptions {
4
+ timeoutMs?: number;
5
+ maxBuffer?: number;
6
+ }
7
+
8
+ /**
9
+ * Kills a spawned child together with everything it started. Plain
10
+ * child.kill() only signals the direct child, so grandchildren (df, git
11
+ * helpers, shell pipelines) orphan and accumulate. Negative-pid kill targets
12
+ * the whole process group, which requires the child to lead its own group
13
+ * (detached spawn, POSIX only).
14
+ */
15
+ function killProcessGroup(
16
+ child: ReturnType<typeof spawn>,
17
+ signal: NodeJS.Signals,
18
+ ): void {
19
+ try {
20
+ if (
21
+ process.platform !== "win32" &&
22
+ child.pid !== undefined
23
+ ) {
24
+ process.kill(-child.pid, signal);
25
+ return;
26
+ }
27
+ } catch {
28
+ // Group kill missed (already gone or no permission); fall through.
29
+ }
30
+ try {
31
+ child.kill(signal);
32
+ } catch {
33
+ // Already gone.
34
+ }
35
+ }
36
+
37
+ export interface SafeSpawnResult {
38
+ stdout: string;
39
+ stderr: string;
40
+ code: number | null;
41
+ signal: NodeJS.Signals | null;
42
+ durationMs: number;
43
+ }
44
+
45
+ /**
46
+ * Spawns a process safely with timeout management, stdout/stderr capture,
47
+ * and clean process group termination without shell vulnerabilities.
48
+ */
49
+ export function safeSpawn(
50
+ command: string,
51
+ args: string[] = [],
52
+ options: SafeSpawnOptions = {},
53
+ ): Promise<SafeSpawnResult> {
54
+ return new Promise((resolve, reject) => {
55
+ const startTime = Date.now();
56
+ const timeoutMs = options.timeoutMs ?? 15000;
57
+ const maxBuffer = options.maxBuffer ?? 10 * 1024 * 1024; // 10MB
58
+
59
+ const child = spawn(command, args, {
60
+ ...options,
61
+ shell: false,
62
+ detached: options.detached ?? process.platform !== "win32",
63
+ });
64
+
65
+ let stdout = "";
66
+ let stderr = "";
67
+ let exited = false;
68
+ let timedOut = false;
69
+ let timer: ReturnType<typeof setTimeout> | null = null;
70
+
71
+ if (timeoutMs > 0) {
72
+ timer = setTimeout(() => {
73
+ timedOut = true;
74
+ killProcessGroup(child, "SIGTERM");
75
+ setTimeout(() => {
76
+ if (!exited) killProcessGroup(child, "SIGKILL");
77
+ }, 2000);
78
+ }, timeoutMs);
79
+ }
80
+
81
+ child.stdout?.on("data", (chunk: Buffer | string) => {
82
+ if (stdout.length < maxBuffer) {
83
+ stdout += chunk.toString();
84
+ }
85
+ });
86
+
87
+ child.stderr?.on("data", (chunk: Buffer | string) => {
88
+ if (stderr.length < maxBuffer) {
89
+ stderr += chunk.toString();
90
+ }
91
+ });
92
+
93
+ child.on("error", (err) => {
94
+ if (timer) clearTimeout(timer);
95
+ reject(err);
96
+ });
97
+
98
+ child.on("close", (code, signal) => {
99
+ exited = true;
100
+ if (timer) clearTimeout(timer);
101
+ const durationMs = Date.now() - startTime;
102
+ if (timedOut) {
103
+ reject(new Error(`Command '${command}' timed out after ${timeoutMs}ms`));
104
+ return;
105
+ }
106
+ resolve({
107
+ stdout: stdout.trim(),
108
+ stderr: stderr.trim(),
109
+ code,
110
+ signal,
111
+ durationMs,
112
+ });
113
+ });
114
+ });
115
+ }
116
+
117
+ /**
118
+ * Executes a shell command line string with timeout protection, stdout/stderr capture,
119
+ * and clean process termination.
120
+ */
121
+ export function safeExec(
122
+ command: string,
123
+ options: SafeSpawnOptions = {},
124
+ ): Promise<SafeSpawnResult> {
125
+ return new Promise((resolve, reject) => {
126
+ const startTime = Date.now();
127
+ const timeoutMs = options.timeoutMs ?? 15000;
128
+ const maxBuffer = options.maxBuffer ?? 10 * 1024 * 1024; // 10MB
129
+
130
+ const child = spawn(command, {
131
+ ...options,
132
+ shell: true,
133
+ detached: options.detached ?? process.platform !== "win32",
134
+ });
135
+
136
+ let stdout = "";
137
+ let stderr = "";
138
+ let exited = false;
139
+ let timedOut = false;
140
+ let timer: ReturnType<typeof setTimeout> | null = null;
141
+
142
+ if (timeoutMs > 0) {
143
+ timer = setTimeout(() => {
144
+ timedOut = true;
145
+ killProcessGroup(child, "SIGTERM");
146
+ setTimeout(() => {
147
+ if (!exited) killProcessGroup(child, "SIGKILL");
148
+ }, 2000);
149
+ }, timeoutMs);
150
+ }
151
+
152
+ child.stdout?.on("data", (chunk: Buffer | string) => {
153
+ if (stdout.length < maxBuffer) {
154
+ stdout += chunk.toString();
155
+ }
156
+ });
157
+
158
+ child.stderr?.on("data", (chunk: Buffer | string) => {
159
+ if (stderr.length < maxBuffer) {
160
+ stderr += chunk.toString();
161
+ }
162
+ });
163
+
164
+ child.on("error", (err) => {
165
+ if (timer) clearTimeout(timer);
166
+ reject(err);
167
+ });
168
+
169
+ child.on("close", (code, signal) => {
170
+ exited = true;
171
+ if (timer) clearTimeout(timer);
172
+ const durationMs = Date.now() - startTime;
173
+ if (timedOut) {
174
+ reject(new Error(`Command '${command}' timed out after ${timeoutMs}ms`));
175
+ return;
176
+ }
177
+ resolve({
178
+ stdout: stdout.trim(),
179
+ stderr: stderr.trim(),
180
+ code,
181
+ signal,
182
+ durationMs,
183
+ });
184
+ });
185
+ });
186
+ }
@@ -0,0 +1,86 @@
1
+ export interface RedactOptions {
2
+ mask?: string;
3
+ customSensitiveKeys?: string[];
4
+ preserveLength?: boolean;
5
+ }
6
+
7
+ const DEFAULT_SENSITIVE_KEYS = [
8
+ "password",
9
+ "passwd",
10
+ "secret",
11
+ "token",
12
+ "apikey",
13
+ "api_key",
14
+ "access_token",
15
+ "refresh_token",
16
+ "privatekey",
17
+ "private_key",
18
+ "authorization",
19
+ "auth",
20
+ "credential",
21
+ "credentials",
22
+ "cert",
23
+ "certificate",
24
+ ];
25
+
26
+ function isSensitiveKey(key: string, customKeys: string[] = []): boolean {
27
+ const normalized = key.toLowerCase().replace(/[-_]/g, "");
28
+ return [...DEFAULT_SENSITIVE_KEYS, ...customKeys].some((k) =>
29
+ normalized.includes(k.replace(/[-_]/g, "")),
30
+ );
31
+ }
32
+
33
+ function maskString(val: string, mask = "[REDACTED]"): string {
34
+ if (val.length <= 8) return mask;
35
+ // Keep first 3 and last 3 characters if long enough, using custom mask if provided
36
+ const placeholder = mask === "[REDACTED]" ? "..." : mask;
37
+ return `${val.slice(0, 3)}${placeholder}${val.slice(-3)}`;
38
+ }
39
+
40
+ /**
41
+ * Deeply redacts sensitive keys and values in an object or primitive before logging or sending over RPC.
42
+ */
43
+ export function redactSecrets<T>(target: T, options: RedactOptions = {}): T {
44
+ const mask = options.mask ?? "[REDACTED]";
45
+ const customKeys = options.customSensitiveKeys ?? [];
46
+
47
+ if (target === null || target === undefined) return target;
48
+
49
+ if (typeof target === "string") {
50
+ // Redact Bearer tokens in headers
51
+ let result = target.replace(/(Bearer\s+)[A-Za-z0-9\-._~+/]+=*/gi, `$1${mask}`);
52
+ // Redact basic auth in URLs
53
+ result = result.replace(/(https?:\/\/[^:]+:)[^@]+(@)/gi, `$1${mask}$2`);
54
+ // Redact Paseo pairing offers (password-equivalent): app.paseo.sh/#offer=
55
+ // plus the base64 tail, optionally JSON-sniffed by serverId key below.
56
+ // Tail covers standard (+/=) and URL-safe (-_) base64 so partial
57
+ // offers never leak through a truncated match.
58
+ result = result.replace(
59
+ /(https?:\/\/app\.paseo\.sh\/#offer=)[A-Za-z0-9\-_.~+/]+=*/gi,
60
+ `$1${mask}`,
61
+ );
62
+ return result as unknown as T;
63
+ }
64
+
65
+ if (Array.isArray(target)) {
66
+ return target.map((item) => redactSecrets(item, options)) as unknown as T;
67
+ }
68
+
69
+ if (typeof target === "object") {
70
+ const clone: Record<string, any> = {};
71
+ for (const [key, value] of Object.entries(target)) {
72
+ if (isSensitiveKey(key, customKeys)) {
73
+ clone[key] = typeof value === "string" ? maskString(value, mask) : mask;
74
+ } else if (typeof value === "object" && value !== null) {
75
+ clone[key] = redactSecrets(value, options);
76
+ } else if (typeof value === "string") {
77
+ clone[key] = redactSecrets(value, options);
78
+ } else {
79
+ clone[key] = value;
80
+ }
81
+ }
82
+ return clone as T;
83
+ }
84
+
85
+ return target;
86
+ }
@@ -0,0 +1,77 @@
1
+ import { withTimeout, TimeoutError } from "../../../shared/vendor/paseo-plugin-helper/async";
2
+
3
+ export interface RpcGuardOptions<T> {
4
+ timeoutMs?: number;
5
+ maxInflight?: number;
6
+ getStale?: () => T | null | undefined;
7
+ onTimeout?: (info: { timeoutMs: number; inflight: number }) => void;
8
+ onSaturated?: (info: { maxInflight: number }) => void;
9
+ }
10
+
11
+ export interface GuardedRpcHandler<TInput, TOutput> {
12
+ (input?: TInput): Promise<TOutput>;
13
+ inflight(): number;
14
+ }
15
+
16
+ export function guardRpcHandler<TInput, TOutput>(
17
+ handler: (input?: TInput) => Promise<TOutput> | TOutput,
18
+ options: RpcGuardOptions<TOutput> = {},
19
+ ): GuardedRpcHandler<TInput, TOutput> {
20
+ const timeoutMs = options.timeoutMs ?? 5000;
21
+ const maxInflight = options.maxInflight ?? 4;
22
+ let inflight = 0;
23
+
24
+ const guarded = (async (input?: TInput): Promise<TOutput> => {
25
+ if (inflight >= maxInflight) {
26
+ options.onSaturated?.({ maxInflight });
27
+ const stale = options.getStale?.();
28
+ if (stale != null) {
29
+ return stale;
30
+ }
31
+ throw new Error(
32
+ `RPC handler saturated (${inflight} inflight, cap ${maxInflight}): shedding load`,
33
+ );
34
+ }
35
+ inflight += 1;
36
+ try {
37
+ return await withTimeout(
38
+ Promise.resolve().then(() => handler(input)),
39
+ timeoutMs,
40
+ "RPC handler",
41
+ );
42
+ } catch (err) {
43
+ if (err instanceof TimeoutError) {
44
+ options.onTimeout?.({ timeoutMs, inflight });
45
+ }
46
+ throw err;
47
+ } finally {
48
+ inflight -= 1;
49
+ }
50
+ }) as GuardedRpcHandler<TInput, TOutput>;
51
+ guarded.inflight = () => inflight;
52
+ return guarded;
53
+ }
54
+
55
+ export interface LoopWatchdogOptions {
56
+ thresholdMs?: number;
57
+ intervalMs?: number;
58
+ onLag?: (lagMs: number) => void;
59
+ }
60
+
61
+ export function createLoopWatchdog(options: LoopWatchdogOptions = {}): () => void {
62
+ const thresholdMs = options.thresholdMs ?? 1000;
63
+ const intervalMs = options.intervalMs ?? 1000;
64
+ let last = Date.now();
65
+ const timer = setInterval(() => {
66
+ const now = Date.now();
67
+ const lagMs = now - last - intervalMs;
68
+ last = now;
69
+ if (lagMs >= thresholdMs) {
70
+ options.onLag?.(lagMs);
71
+ }
72
+ }, intervalMs);
73
+ if (typeof (timer as unknown as { unref?: unknown }).unref === "function") {
74
+ (timer as unknown as { unref: () => void }).unref();
75
+ }
76
+ return () => clearInterval(timer);
77
+ }
@@ -0,0 +1,97 @@
1
+ import type { SettingsContract } from "../../../shared/vendor/paseo-plugin-helper/settings";
2
+ import type { PluginStorage } from "./storage";
3
+
4
+ /**
5
+ * Structural server context interface satisfied by both Paseo v0.7 PluginContext
6
+ * and Paseo v0.8 PluginServerContext.
7
+ */
8
+ export interface HandleableServerContext {
9
+ handle(contract: any, handler: (input?: any) => any): any;
10
+ }
11
+
12
+ export interface RegisterSettingsRpcOptions<TSettings> {
13
+ /**
14
+ * Optional callback invoked whenever settings are updated.
15
+ */
16
+ onUpdate?: (newSettings: TSettings, prevSettings: TSettings) => void | Promise<void>;
17
+ /**
18
+ * Optional callback invoked whenever settings are reset.
19
+ */
20
+ onReset?: (defaultSettings: TSettings, prevSettings: TSettings) => void | Promise<void>;
21
+ }
22
+
23
+ /**
24
+ * Registers RPC handlers on the daemon PluginContext/PluginServerContext for a SettingsContract.
25
+ * Connects get, update, and reset RPCs directly to atomic PluginStorage persistence.
26
+ * Works with both Paseo v0.7 and Paseo v0.8.
27
+ */
28
+ export function registerSettingsRpc<TSettings extends Record<string, any>>(
29
+ context: HandleableServerContext,
30
+ contract: SettingsContract<TSettings>,
31
+ storage: PluginStorage<TSettings>,
32
+ options: RegisterSettingsRpcOptions<TSettings> = {},
33
+ ): void {
34
+ // Register GET handler
35
+ context.handle(contract.get, async () => {
36
+ return storage.readAsync();
37
+ });
38
+
39
+ // Register UPDATE handler
40
+ context.handle(contract.update, async (input) => {
41
+ const prev = await storage.readAsync();
42
+ const updated = await storage.updateAsync((current) => {
43
+ return { ...current, ...(input as Partial<TSettings>) };
44
+ });
45
+ if (options.onUpdate) {
46
+ await options.onUpdate(updated, prev);
47
+ }
48
+ return updated;
49
+ });
50
+
51
+ // Register RESET handler
52
+ context.handle(contract.reset, async () => {
53
+ const prev = await storage.readAsync();
54
+ storage.reset();
55
+ const fresh = await storage.readAsync();
56
+ if (options.onReset) {
57
+ await options.onReset(fresh, prev);
58
+ }
59
+ return fresh;
60
+ });
61
+ }
62
+
63
+ /**
64
+ * Creates individual RPC handler functions (get, update, reset) for a SettingsContract.
65
+ * Useful when registering handlers in root index.ts via plugin.handle() so that
66
+ * Paseo's AST-based client compiler can cleanly strip all server registrations.
67
+ */
68
+ export function createSettingsHandlers<TSettings extends Record<string, any>>(
69
+ contract: SettingsContract<TSettings>,
70
+ storage: PluginStorage<TSettings>,
71
+ options: RegisterSettingsRpcOptions<TSettings> = {},
72
+ ) {
73
+ return {
74
+ get: async () => {
75
+ return storage.readAsync();
76
+ },
77
+ update: async (input: unknown) => {
78
+ const prev = await storage.readAsync();
79
+ const updated = await storage.updateAsync((current) => {
80
+ return { ...current, ...(input as Partial<TSettings>) };
81
+ });
82
+ if (options.onUpdate) {
83
+ await options.onUpdate(updated, prev);
84
+ }
85
+ return updated;
86
+ },
87
+ reset: async () => {
88
+ const prev = await storage.readAsync();
89
+ storage.reset();
90
+ const fresh = await storage.readAsync();
91
+ if (options.onReset) {
92
+ await options.onReset(fresh, prev);
93
+ }
94
+ return fresh;
95
+ },
96
+ };
97
+ }
@@ -0,0 +1,243 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import type { ZodType } from "zod";
4
+ import { defineSettingsContract, type SettingsContract } from "../../../shared/vendor/paseo-plugin-helper/settings";
5
+ import { PluginStorage } from "./storage";
6
+ import {
7
+ createSettingsHandlers,
8
+ registerSettingsRpc,
9
+ type HandleableServerContext,
10
+ type RegisterSettingsRpcOptions,
11
+ } from "./settings";
12
+
13
+ export interface SharedPluginSettingsOptions<TSettings extends Record<string, any>> {
14
+ suite: string;
15
+ filename?: string;
16
+ schema: ZodType<TSettings> & { partial?: () => ZodType<Partial<TSettings>> };
17
+ defaultData?: Partial<TSettings>;
18
+ contract?: SettingsContract<TSettings>;
19
+ contractName?: string;
20
+ description?: string;
21
+ namespace?: string;
22
+ baseDir?: string;
23
+ watchDebounceMs?: number;
24
+ }
25
+
26
+ export type SharedSettingsListener<TSettings> = (settings: TSettings) => void;
27
+
28
+ export interface SharedPluginSettings<TSettings extends Record<string, any>> {
29
+ readonly suite: string;
30
+ readonly contract: SettingsContract<TSettings>;
31
+ readonly storage: PluginStorage<TSettings>;
32
+ readonly filePath: string;
33
+ get(): Promise<TSettings>;
34
+ update(patch: Partial<TSettings>): Promise<TSettings>;
35
+ reset(): Promise<TSettings>;
36
+ read(): TSettings;
37
+ reload(): TSettings;
38
+ subscribe(listener: SharedSettingsListener<TSettings>): () => void;
39
+ watch(listener: SharedSettingsListener<TSettings>): () => void;
40
+ register(
41
+ context: HandleableServerContext,
42
+ options?: RegisterSettingsRpcOptions<TSettings>,
43
+ ): void;
44
+ createHandlers(options?: RegisterSettingsRpcOptions<TSettings>): {
45
+ get: () => Promise<TSettings>;
46
+ update: (input: unknown) => Promise<TSettings>;
47
+ reset: () => Promise<TSettings>;
48
+ };
49
+ dispose(): void;
50
+ }
51
+
52
+ function safeSerialize(value: unknown): string {
53
+ try {
54
+ return JSON.stringify(value) ?? "";
55
+ } catch {
56
+ return "";
57
+ }
58
+ }
59
+
60
+ /**
61
+ * Creates a suite-scoped settings store shared across independent sibling plugins.
62
+ * Every plugin in the suite points at the same file
63
+ * (`~/.paseo/plugin-data/xpufx/<suite>/<filename>`) through an atomic PluginStorage,
64
+ * so an update written by Plugin A is immediately readable by Plugin B.
65
+ */
66
+ export function createSharedPluginSettings<TSettings extends Record<string, any>>(
67
+ options: SharedPluginSettingsOptions<TSettings>,
68
+ ): SharedPluginSettings<TSettings> {
69
+ const {
70
+ suite,
71
+ filename = "settings.json",
72
+ schema,
73
+ defaultData,
74
+ description,
75
+ namespace,
76
+ baseDir,
77
+ watchDebounceMs = 25,
78
+ } = options;
79
+ const contractName = options.contractName ?? `${suite}.shared-settings`;
80
+
81
+ const contract =
82
+ options.contract ??
83
+ defineSettingsContract({
84
+ name: contractName,
85
+ schema,
86
+ ...(defaultData !== undefined ? { defaultData } : {}),
87
+ ...(description !== undefined ? { description } : {}),
88
+ });
89
+
90
+ const storage = new PluginStorage<TSettings>(suite, filename, {
91
+ ...(namespace !== undefined ? { namespace } : {}),
92
+ ...(baseDir !== undefined ? { baseDir } : {}),
93
+ schema: contract.schema,
94
+ defaultData: contract.defaultSettings,
95
+ });
96
+
97
+ const listeners = new Set<SharedSettingsListener<TSettings>>();
98
+ let watcher: fs.FSWatcher | null = null;
99
+ let debounceTimer: NodeJS.Timeout | null = null;
100
+ let lastSnapshot = "";
101
+
102
+ function snapshot(): string {
103
+ return safeSerialize(storage.read());
104
+ }
105
+
106
+ function emitIfChanged(): void {
107
+ const next = storage.read();
108
+ const nextSnapshot = safeSerialize(next);
109
+ if (nextSnapshot !== lastSnapshot) {
110
+ lastSnapshot = nextSnapshot;
111
+ for (const listener of [...listeners]) {
112
+ try {
113
+ listener(next);
114
+ } catch {
115
+ // Listener errors must not break sibling notification fan-out
116
+ }
117
+ }
118
+ }
119
+ }
120
+
121
+ function scheduleEmit(): void {
122
+ if (debounceTimer) clearTimeout(debounceTimer);
123
+ // @ts-ignore
124
+ debounceTimer = setTimeout(() => {
125
+ debounceTimer = null;
126
+ try {
127
+ emitIfChanged();
128
+ } catch {
129
+ // Disk reads may race atomic renames; next event retries
130
+ }
131
+ }, watchDebounceMs);
132
+ }
133
+
134
+ function ensureWatcher(): void {
135
+ if (watcher) return;
136
+ const dir = path.dirname(storage.filePath);
137
+ if (!fs.existsSync(dir)) {
138
+ fs.mkdirSync(dir, { recursive: true });
139
+ }
140
+ lastSnapshot = snapshot();
141
+ watcher = fs.watch(dir, (_event, watchedFile) => {
142
+ if (watchedFile && watchedFile.toString() !== path.basename(storage.filePath)) return;
143
+ scheduleEmit();
144
+ });
145
+ watcher.on("error", () => {});
146
+ if (typeof (watcher as unknown as { unref?: () => void }).unref === "function") {
147
+ (watcher as unknown as { unref: () => void }).unref();
148
+ }
149
+ }
150
+
151
+ function disposeWatcher(): void {
152
+ if (debounceTimer) {
153
+ // @ts-ignore
154
+ clearTimeout(debounceTimer);
155
+ debounceTimer = null;
156
+ }
157
+ if (watcher) {
158
+ try {
159
+ watcher.close();
160
+ } catch {}
161
+ watcher = null;
162
+ }
163
+ }
164
+
165
+ function subscribe(listener: SharedSettingsListener<TSettings>): () => void {
166
+ listeners.add(listener);
167
+ try {
168
+ ensureWatcher();
169
+ } catch {
170
+ // Watching is best-effort; polling via reload() still works
171
+ }
172
+ return () => {
173
+ listeners.delete(listener);
174
+ if (listeners.size === 0) disposeWatcher();
175
+ };
176
+ }
177
+
178
+ return {
179
+ suite,
180
+ contract,
181
+ storage,
182
+ filePath: storage.filePath,
183
+
184
+ async get(): Promise<TSettings> {
185
+ return storage.readAsync();
186
+ },
187
+
188
+ async update(patch: Partial<TSettings>): Promise<TSettings> {
189
+ const updated = await storage.updateAsync((current) => ({
190
+ ...current,
191
+ ...patch,
192
+ }));
193
+ lastSnapshot = safeSerialize(updated);
194
+ for (const listener of [...listeners]) {
195
+ try {
196
+ listener(updated);
197
+ } catch {}
198
+ }
199
+ return updated;
200
+ },
201
+
202
+ async reset(): Promise<TSettings> {
203
+ storage.reset();
204
+ const fresh = await storage.readAsync();
205
+ lastSnapshot = safeSerialize(fresh);
206
+ for (const listener of [...listeners]) {
207
+ try {
208
+ listener(fresh);
209
+ } catch {}
210
+ }
211
+ return fresh;
212
+ },
213
+
214
+ read(): TSettings {
215
+ return storage.read();
216
+ },
217
+
218
+ reload(): TSettings {
219
+ const next = storage.read();
220
+ lastSnapshot = safeSerialize(next);
221
+ return next;
222
+ },
223
+
224
+ subscribe,
225
+ watch: subscribe,
226
+
227
+ register(
228
+ context: HandleableServerContext,
229
+ rpcOptions: RegisterSettingsRpcOptions<TSettings> = {},
230
+ ): void {
231
+ registerSettingsRpc(context, contract, storage, rpcOptions);
232
+ },
233
+
234
+ createHandlers(rpcOptions: RegisterSettingsRpcOptions<TSettings> = {}) {
235
+ return createSettingsHandlers(contract, storage, rpcOptions);
236
+ },
237
+
238
+ dispose(): void {
239
+ listeners.clear();
240
+ disposeWatcher();
241
+ },
242
+ };
243
+ }