@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,215 @@
1
+ import { readFileSync, writeFileSync, existsSync, renameSync, mkdirSync } from "node:fs";
2
+ export { directHostMismatch } from "../shared/registry.ts";
3
+ import { homedir } from "node:os";
4
+ import { join, dirname } from "node:path";
5
+
6
+ // All plugin state lives under a single namespaced subdir of paseo's home
7
+ // (~/.paseo/paseo-x-comms/) rather than littering ~/.paseo root.
8
+ export function stateDir(): string {
9
+ return join(homedir(), ".paseo", "paseo-x-comms");
10
+ }
11
+
12
+ export const REGISTRY_DEFAULT = join(stateDir(), "registry.json");
13
+
14
+ // One-time migration: move a file from the old ~/.paseo root location into the
15
+ // namespaced state dir. Deterministic and forward-only; never a fallback path.
16
+ export function migrateFromRoot(oldName: string, newPath: string): void {
17
+ const oldPath = join(homedir(), ".paseo", oldName);
18
+ if (!existsSync(oldPath) || existsSync(newPath)) return;
19
+ mkdirSync(stateDir(), { recursive: true });
20
+ renameSync(oldPath, newPath);
21
+ }
22
+
23
+ /** The plausible canonical forms paseo classifies as --host targets. */
24
+ export function validateDaemonHost(value: string): { valid: boolean; error: string | null } {
25
+ const v = value.trim();
26
+ if (v.length === 0) return { valid: false, error: "empty value" };
27
+
28
+ // Relay offer: any value containing #offer= (E2EE via relay).
29
+ if (v.includes("#offer=")) {
30
+ if (/^https?:\/\//.test(v)) return { valid: true, error: null };
31
+ return { valid: false, error: "#offer= value must be a full pairing URL (https://…)" };
32
+ }
33
+
34
+ // tcp://host:port with optional query (ssl, password).
35
+ if (v.startsWith("tcp://")) {
36
+ if (/^tcp:\/\/[^/]+\/\?.*$|^tcp:\/\/[^/]+$/.test(v)) return { valid: true, error: null };
37
+ return { valid: false, error: "tcp:// must include host[:port] (tcp://host:port?ssl=true…) " };
38
+ }
39
+
40
+ // unix:///path or pipe://…
41
+ if (v.startsWith("unix://") || v.startsWith("pipe://")) {
42
+ if (v.length > 7) return { valid: true, error: null };
43
+ return { valid: false, error: "missing path after scheme" };
44
+ }
45
+
46
+ // absolute paths
47
+ if (v.startsWith("/")) return { valid: true, error: null };
48
+
49
+ // bare port → 127.0.0.1:port
50
+ if (/^\d+$/.test(v)) return { valid: true, error: null };
51
+
52
+ // host:port, IPv6 [::1]:6767
53
+ if (/^\[[0-9a-fA-F:.]+\]:\d+$/.test(v)) return { valid: true, error: null };
54
+ if (/^[^/:]+:\d+$/.test(v)) return { valid: true, error: null };
55
+
56
+ return { valid: false, error: "not a canonical form (relay URL, host:port, tcp://, unix://, bare port)" };
57
+ }
58
+
59
+ export function parseRegistry(content: string): {
60
+ ok: boolean;
61
+ daemons: Array<{ name: string; value: string; valid: boolean; error: string | null }>;
62
+ parseError: string | null;
63
+ } {
64
+ let parsed: unknown;
65
+ try {
66
+ parsed = JSON.parse(content);
67
+ } catch (cause) {
68
+ return {
69
+ ok: false,
70
+ daemons: [],
71
+ parseError: cause instanceof Error ? cause.message : String(cause),
72
+ };
73
+ }
74
+ if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
75
+ return { ok: false, daemons: [], parseError: "registry must be a JSON object of name → host" };
76
+ }
77
+ const daemons = Object.entries(parsed as Record<string, unknown>).map(([name, value]) => {
78
+ if (typeof value !== "string") {
79
+ return { name, value: String(value), valid: false, error: "value must be a string" };
80
+ }
81
+ const checked = validateDaemonHost(value);
82
+ return { name, value, ...checked };
83
+ });
84
+ return { ok: true, daemons, parseError: null };
85
+ }
86
+
87
+ export function currentRegistryPath(): string {
88
+ const env = process.env.PASEO_X_COMMS_REMOTES;
89
+ if (env && env.length > 0) return env;
90
+ return REGISTRY_DEFAULT;
91
+ }
92
+
93
+ export function validateDaemonName(name: string): { valid: boolean; error: string | null } {
94
+ const n = name.trim();
95
+ if (n.length === 0) return { valid: false, error: "name is required" };
96
+ if (!/^[A-Za-z0-9._-]+$/.test(n)) {
97
+ return { valid: false, error: "name may only contain letters, digits, '.', '_', '-'" };
98
+ }
99
+ return { valid: true, error: null };
100
+ }
101
+
102
+ /** Reads + validates the registry file, returning the entry list only. */
103
+ export function readRegistry(
104
+ path: string,
105
+ ): { ok: boolean; exists: boolean; parseError: string | null; daemons: Array<{ name: string; value: string; valid: boolean; error: string | null }> } {
106
+ if (!existsSync(path)) {
107
+ return { ok: true, exists: false, parseError: null, daemons: [] };
108
+ }
109
+ const content = readFileSync(path, "utf8");
110
+ const parsed = parseRegistry(content);
111
+ return { ok: parsed.ok, exists: true, parseError: parsed.parseError, daemons: parsed.daemons };
112
+ }
113
+
114
+ /**
115
+ * Applies a mutation to the registry and writes it only when every entry is
116
+ * valid. Returns fresh state plus whether the write happened; on any invalid
117
+ * entry the file is left untouched.
118
+ */
119
+ export function mutateRegistry(
120
+ path: string,
121
+ mutate: (daemons: Record<string, string>) => Record<string, string>,
122
+ ): {
123
+ saved: boolean;
124
+ error: string | null;
125
+ registryPath: string;
126
+ daemons: Array<{ name: string; value: string; valid: boolean; error: string | null }>;
127
+ } {
128
+ const current = readRegistry(path);
129
+ if (!current.ok) {
130
+ return {
131
+ saved: false,
132
+ error: `existing registry is not valid JSON: ${current.parseError}`,
133
+ registryPath: path,
134
+ daemons: [],
135
+ };
136
+ }
137
+ const existing: Record<string, string> = {};
138
+ for (const daemon of current.daemons) existing[daemon.name] = daemon.value;
139
+
140
+ let next: Record<string, string>;
141
+ try {
142
+ next = mutate(existing);
143
+ } catch (cause) {
144
+ return {
145
+ saved: false,
146
+ error: cause instanceof Error ? cause.message : String(cause),
147
+ registryPath: path,
148
+ daemons: current.daemons,
149
+ };
150
+ }
151
+
152
+ const entries = Object.entries(next).map(([name, value]) => ({ name, value, ...validateDaemonHost(value) }));
153
+ const invalid = entries.find((entry) => !entry.valid);
154
+ if (invalid) {
155
+ return {
156
+ saved: false,
157
+ error: `${invalid.name} is not a valid daemon host: ${invalid.error}`,
158
+ registryPath: path,
159
+ daemons: entries,
160
+ };
161
+ }
162
+ // Two names must not point at the same host; that is almost always a copy
163
+ // bug, not an intentional alias.
164
+ const seen = new Map<string, string>();
165
+ for (const entry of entries) {
166
+ const prior = seen.get(entry.value);
167
+ if (prior !== undefined) {
168
+ return {
169
+ saved: false,
170
+ error: `${entry.name} and ${prior} use the same host value`,
171
+ registryPath: path,
172
+ daemons: entries,
173
+ };
174
+ }
175
+ seen.set(entry.value, entry.name);
176
+ }
177
+ if (JSON.stringify(sortEntries(current.daemons)) === JSON.stringify(sortEntries(entries))) {
178
+ // No change; still report success so the UI can settle.
179
+ return { saved: true, error: null, registryPath: path, daemons: entries };
180
+ }
181
+ try {
182
+ mkdirSync(dirname(path), { recursive: true });
183
+ writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`, { encoding: "utf8", mode: 0o600 });
184
+ return { saved: true, error: null, registryPath: path, daemons: entries };
185
+ } catch (cause) {
186
+ return {
187
+ saved: false,
188
+ error: cause instanceof Error ? cause.message : String(cause),
189
+ registryPath: path,
190
+ daemons: entries,
191
+ };
192
+ }
193
+ }
194
+
195
+ function sortEntries(daemons: Array<{ name: string; value: string }>): Array<{ name: string; value: string }> {
196
+ return [...daemons].sort((left, right) => left.name.localeCompare(right.name));
197
+ }
198
+ /**
199
+ * Derives the canonical host for a registry value, when it can be done
200
+ * deterministically. Relay offers embed their serverId, so the real host is
201
+ * that id. Direct/local hosts carry no identity, so nothing is derived and the
202
+ * user must supply the name themselves.
203
+ */
204
+ export function deriveHostFromValue(value: string): string | null {
205
+ const offer = value.match(/#offer=([A-Za-z0-9_-]+)/);
206
+ if (offer) {
207
+ try {
208
+ const payload = JSON.parse(Buffer.from(offer[1], "base64").toString("utf8"));
209
+ return typeof payload.serverId === "string" ? payload.serverId : null;
210
+ } catch {
211
+ return null;
212
+ }
213
+ }
214
+ return null;
215
+ }
@@ -0,0 +1,9 @@
1
+ // `paseo daemon status --json` reports `relay` as a string: the literal
2
+ // "disabled" when no relay is configured, otherwise the public relay URL
3
+ // (e.g. "wss://relay.paseo.sh:443").
4
+ export function readRelayStatus(raw: unknown): { enabled: boolean | null; endpoints: string[] | null } {
5
+ if (typeof raw !== "string") return { enabled: null, endpoints: null };
6
+ const endpoint = raw.trim();
7
+ if (endpoint === "" || endpoint === "disabled") return { enabled: false, endpoints: null };
8
+ return { enabled: true, endpoints: [endpoint] };
9
+ }
@@ -0,0 +1,138 @@
1
+ import { fileURLToPath } from "node:url";
2
+ import { dirname, isAbsolute, join } from "node:path";
3
+ import { existsSync, readdirSync, readFileSync } from "node:fs";
4
+ import { homedir } from "node:os";
5
+
6
+ const BUNDLED = "paseo-x-comms.bundled.mjs";
7
+ const PLAIN = "paseo-x-comms.mjs";
8
+
9
+ /** Bundled first: it is the self-contained artifact the injector copies. */
10
+ function inMcpDir(base: string): string[] {
11
+ return [join(base, "mcp", BUNDLED), join(base, "mcp", PLAIN)];
12
+ }
13
+
14
+ /**
15
+ * Explicit override. Set `PASEO_X_COMMS_MCP_SERVER` when the host layout is
16
+ * unusual (packaged app, moved checkout, a build that vendors the plugin).
17
+ */
18
+ function overrideCandidates(): string[] {
19
+ const raw = process.env.PASEO_X_COMMS_MCP_SERVER?.trim();
20
+ return raw ? [isAbsolute(raw) ? raw : join(process.cwd(), raw)] : [];
21
+ }
22
+
23
+ /** Plugin ids this plugin may be installed under. */
24
+ const SELF_PLUGIN_IDS = ["x-comms", "paseo-x-comms", "paseo-x-comms-plugin"];
25
+
26
+ /**
27
+ * Directories the daemon records for installed plugins, with this plugin's own
28
+ * entries first so a sibling install can never shadow it.
29
+ */
30
+ function configCandidates(selfDir?: string): string[] {
31
+ const configPath = join(homedir(), ".paseo", "config.json");
32
+ let parsed: unknown;
33
+ try {
34
+ parsed = JSON.parse(readFileSync(configPath, "utf8"));
35
+ } catch {
36
+ return [];
37
+ }
38
+ const plugins = (parsed as { plugins?: unknown })?.plugins;
39
+ if (!plugins || typeof plugins !== "object") return [];
40
+
41
+ const preferred: string[] = [];
42
+ const rest: string[] = [];
43
+ for (const [id, entry] of Object.entries(plugins as Record<string, unknown>)) {
44
+ const dir = (entry as { path?: unknown })?.path;
45
+ if (typeof dir !== "string" || !dir.trim()) continue;
46
+ const isSelf = SELF_PLUGIN_IDS.includes(id) || (selfDir !== undefined && dir === selfDir);
47
+ (isSelf ? preferred : rest).push(dir);
48
+ }
49
+ return [...preferred, ...rest].flatMap(inMcpDir);
50
+ }
51
+
52
+ /**
53
+ * Managed-install layout:
54
+ * `~/.paseo/plugins/<pluginId>/<commit>/checkout/mcp/...`.
55
+ */
56
+ function managedCandidates(): string[] {
57
+ const out: string[] = [];
58
+ for (const pluginId of SELF_PLUGIN_IDS) {
59
+ const base = join(homedir(), ".paseo", "plugins", pluginId);
60
+ if (!existsSync(base)) continue;
61
+ let entries;
62
+ try {
63
+ entries = readdirSync(base, { withFileTypes: true });
64
+ } catch {
65
+ continue;
66
+ }
67
+ for (const entry of entries) {
68
+ if (!entry.isDirectory()) continue;
69
+ out.push(...inMcpDir(join(base, entry.name, "checkout")));
70
+ }
71
+ }
72
+ return out;
73
+ }
74
+
75
+ /**
76
+ * `import.meta.url` works only when the server is executed unbundled. Paseo
77
+ * bundles and inlines plugin server code, so this is usually empty — kept for
78
+ * tests, `tsx`, and any executor that runs the sources directly.
79
+ */
80
+ function moduleCandidates(): string[] {
81
+ try {
82
+ const url = (import.meta as unknown as { url?: string })?.url;
83
+ if (!url) return [];
84
+ const here = dirname(fileURLToPath(url));
85
+ return [...inMcpDir(here), ...inMcpDir(join(here, ".."))];
86
+ } catch {
87
+ return [];
88
+ }
89
+ }
90
+
91
+ /**
92
+ * Working-directory candidates.
93
+ *
94
+ * Paseo does **not** spawn plugin subprocesses from the plugin directory (the
95
+ * daemon inherits its own cwd), so this is a low-priority fallback that helps
96
+ * when a plugin is run directly (tests, tsx).
97
+ */
98
+ function cwdCandidates(): string[] {
99
+ const cwd = process.cwd();
100
+ return [...inMcpDir(cwd), ...inMcpDir(join(cwd, ".."))];
101
+ }
102
+
103
+ /**
104
+ * Ordered candidate paths for the bundled MCP server, highest-confidence first.
105
+ *
106
+ * Exported for tests and diagnostics.
107
+ *
108
+ * @param selfDir directory the daemon recorded for THIS plugin, when known.
109
+ */
110
+ export function serverCandidates(selfDir?: string): string[] {
111
+ return [
112
+ ...overrideCandidates(),
113
+ ...configCandidates(selfDir),
114
+ ...managedCandidates(),
115
+ ...moduleCandidates(),
116
+ ...cwdCandidates(),
117
+ ];
118
+ }
119
+
120
+ /**
121
+ * Absolute path of the bundled x-comms MCP server.
122
+ *
123
+ * A plugin server is bundled and inlined before it runs, so neither
124
+ * `import.meta.url` nor `process.cwd()` identifies the plugin checkout
125
+ * (xpufx-org/paseo#214). The dependable locator is the install path the daemon
126
+ * records in `~/.paseo/config.json`, with an explicit env override on top and
127
+ * the managed-checkout / module / cwd layouts as fallbacks.
128
+ *
129
+ * @param selfDir directory the daemon recorded for this plugin, when known.
130
+ */
131
+ export function serverPath(selfDir?: string): string {
132
+ for (const candidate of serverCandidates(selfDir)) {
133
+ if (existsSync(candidate)) return candidate;
134
+ }
135
+ throw new Error(
136
+ "could not locate bundled mcp server (mcp/paseo-x-comms.mjs); set PASEO_X_COMMS_MCP_SERVER to the bundled .mjs path",
137
+ );
138
+ }
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Daemon-wide feature flags for the mesh layers. Pure resolution logic lives
3
+ * here so it is unit-testable without touching the prefs file; persistence
4
+ * stays in server/handlers.ts via the PluginStorage-backed ui prefs.
5
+ *
6
+ * Defaults: presence on, injection on. Absent keys mean enabled.
7
+ */
8
+
9
+ export interface FeaturePrefs {
10
+ presenceEnabled?: boolean;
11
+ injectionEnabled?: boolean;
12
+ /** How long an undelivered outbox message is retried before it expires. */
13
+ outboxExpirySeconds?: number;
14
+ /** Per-daemon enablement; an absent or non-false entry means enabled. */
15
+ daemonEnabled?: Record<string, boolean>;
16
+ }
17
+
18
+ export const OUTBOX_EXPIRY_DEFAULT_SECONDS = 10 * 60;
19
+ export const OUTBOX_EXPIRY_MIN_SECONDS = 10;
20
+ export const OUTBOX_EXPIRY_MAX_SECONDS = 24 * 60 * 60;
21
+
22
+ /**
23
+ * Outbox expiry in ms. Absent/invalid values fall back to the 10 minute
24
+ * default; explicit values are clamped to a sane range so a bad setting cannot
25
+ * disable retries or hold messages forever.
26
+ */
27
+ export function resolveOutboxExpiryMs(prefs: FeaturePrefs): number {
28
+ const raw = prefs.outboxExpirySeconds;
29
+ const seconds =
30
+ typeof raw === "number" && Number.isFinite(raw)
31
+ ? Math.floor(raw)
32
+ : OUTBOX_EXPIRY_DEFAULT_SECONDS;
33
+ const clamped = Math.min(OUTBOX_EXPIRY_MAX_SECONDS, Math.max(OUTBOX_EXPIRY_MIN_SECONDS, seconds));
34
+ return clamped * 1000;
35
+ }
36
+
37
+ export function resolvePresenceEnabled(prefs: FeaturePrefs): boolean {
38
+ return prefs.presenceEnabled !== false;
39
+ }
40
+
41
+ export function resolveDaemonEnabled(prefs: FeaturePrefs, name: string): boolean {
42
+ return prefs.daemonEnabled?.[name] !== false;
43
+ }
44
+
45
+ export function resolveInjectionEnabled(prefs: FeaturePrefs): boolean {
46
+ return prefs.injectionEnabled !== false;
47
+ }
48
+
49
+ export interface FeatureFlags {
50
+ presenceEnabled: boolean;
51
+ injectionEnabled: boolean;
52
+ }
53
+
54
+ export function resolveFeatureFlags(prefs: FeaturePrefs): FeatureFlags {
55
+ return {
56
+ presenceEnabled: resolvePresenceEnabled(prefs),
57
+ injectionEnabled: resolveInjectionEnabled(prefs),
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Merge a partial prefs update over stored prefs (toggle round-trip).
63
+ * Undefined fields keep their stored value.
64
+ */
65
+ export function applyFeaturePrefsUpdate(
66
+ stored: FeaturePrefs,
67
+ input: FeaturePrefs,
68
+ ): FeaturePrefs {
69
+ const daemonEnabled = input.daemonEnabled
70
+ ? { ...stored.daemonEnabled, ...input.daemonEnabled }
71
+ : stored.daemonEnabled;
72
+ return {
73
+ ...stored,
74
+ presenceEnabled: input.presenceEnabled ?? stored.presenceEnabled,
75
+ injectionEnabled: input.injectionEnabled ?? stored.injectionEnabled,
76
+ ...(input.outboxExpirySeconds !== undefined
77
+ ? { outboxExpirySeconds: input.outboxExpirySeconds }
78
+ : {}),
79
+ ...(daemonEnabled !== undefined ? { daemonEnabled } : {}),
80
+ };
81
+ }
@@ -0,0 +1,176 @@
1
+ import { writeFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { createPluginLogger, safeSpawn } from "paseo-plugin-helper/server";
4
+ import { currentRegistryPath, readRegistry, stateDir, migrateFromRoot } from "./registry";
5
+
6
+ const snapLog = createPluginLogger("paseo-x-comms", { subsystem: "snapshot" });
7
+
8
+ const PROBE_TIMEOUT_MS = 15000;
9
+ const SNAPSHOT_FILE = join(stateDir(), "snapshot.json");
10
+ migrateFromRoot("paseo-x-comms-snapshot.json", SNAPSHOT_FILE);
11
+ const STALE_MS = 60_000;
12
+
13
+ export interface SnapshotAgent {
14
+ agentId: string;
15
+ shortId: string;
16
+ name: string;
17
+ status: string;
18
+ }
19
+
20
+ export interface SnapshotWorkspace {
21
+ name: string;
22
+ agents: SnapshotAgent[];
23
+ }
24
+
25
+ export interface SnapshotProject {
26
+ project: string;
27
+ workspaces: SnapshotWorkspace[];
28
+ }
29
+
30
+ export interface DaemonSnapshotEntry {
31
+ name: string;
32
+ value: string;
33
+ reachable: boolean;
34
+ error: string | null;
35
+ projects: SnapshotProject[];
36
+ }
37
+
38
+ export interface DaemonSnapshot {
39
+ updatedAt: string;
40
+ daemons: DaemonSnapshotEntry[];
41
+ }
42
+
43
+ let snapshot: DaemonSnapshot | null = null;
44
+ let inflight: Promise<DaemonSnapshot> | null = null;
45
+
46
+ async function runPaseoJson(args: string[]): Promise<unknown> {
47
+ const r = await safeSpawn("paseo", args, { timeoutMs: PROBE_TIMEOUT_MS });
48
+ if (r.code !== 0) throw new Error((r.stderr || `exit ${r.code}`).trim());
49
+ try {
50
+ return JSON.parse(r.stdout);
51
+ } catch {
52
+ throw new Error("paseo returned non-JSON output");
53
+ }
54
+ }
55
+
56
+ async function probeDaemon(value: string): Promise<{
57
+ reachable: boolean;
58
+ error: string | null;
59
+ projects: SnapshotProject[];
60
+ }> {
61
+ try {
62
+ const [agentsRaw, workspacesRaw] = await Promise.all([
63
+ // --global: include agents in every workspace, not just the daemon cwd.
64
+ runPaseoJson(["ls", "--global", "--host", value, "--json"]),
65
+ runPaseoJson(["workspace", "ls", "--host", value, "--json"]),
66
+ ]);
67
+ const agents = Array.isArray(agentsRaw) ? agentsRaw : [];
68
+ const workspaces = Array.isArray(workspacesRaw) ? workspacesRaw : [];
69
+
70
+ // Map cwd -> { project, name } for grouping agents under their workspace.
71
+ const infoByCwd = new Map<string, { project: string; name: string }>();
72
+ for (const ws of workspaces) {
73
+ if (typeof ws?.cwd === "string" && typeof ws?.name === "string") {
74
+ infoByCwd.set(ws.cwd, {
75
+ project: typeof ws.project === "string" && ws.project ? ws.project : "(no project)",
76
+ name: ws.name,
77
+ });
78
+ }
79
+ }
80
+
81
+ const agentsByCwd = new Map<string, SnapshotAgent[]>();
82
+ for (const agent of agents) {
83
+ // Skip non-active agents; the registry's view is of current agents.
84
+ const status = typeof agent.status === "string" ? agent.status : "unknown";
85
+ if (status === "closed" || status === "archived") continue;
86
+ const id = typeof agent.id === "string" ? agent.id : String(agent.id ?? "");
87
+ const key = typeof agent.cwd === "string" ? agent.cwd : "";
88
+ if (!agentsByCwd.has(key)) agentsByCwd.set(key, []);
89
+ agentsByCwd.get(key)!.push({
90
+ agentId: id,
91
+ shortId: typeof agent.shortId === "string" ? agent.shortId : id.slice(0, 8),
92
+ name: typeof agent.name === "string" ? agent.name : id,
93
+ status,
94
+ });
95
+ }
96
+
97
+ const byProject = new Map<string, Map<string, SnapshotAgent[]>>();
98
+ for (const [cwd, list] of agentsByCwd) {
99
+ // Agents whose cwd matches no listed workspace are grouped under their
100
+ // actual working directory, so the label stays truthful.
101
+ const info = infoByCwd.get(cwd) ?? { project: "(no workspace)", name: cwd };
102
+ if (!byProject.has(info.project)) byProject.set(info.project, new Map());
103
+ const byWorkspace = byProject.get(info.project)!;
104
+ if (!byWorkspace.has(info.name)) byWorkspace.set(info.name, []);
105
+ byWorkspace.get(info.name)!.push(...list);
106
+ }
107
+
108
+ const projects: SnapshotProject[] = [];
109
+ for (const [project, byWorkspace] of byProject) {
110
+ const workspacesOut: SnapshotWorkspace[] = [];
111
+ for (const [name, list] of byWorkspace) {
112
+ workspacesOut.push({ name, agents: list.sort((a, b) => a.name.localeCompare(b.name)) });
113
+ }
114
+ projects.push({ project, workspaces: workspacesOut.sort((a, b) => a.name.localeCompare(b.name)) });
115
+ }
116
+ projects.sort((a, b) => a.project.localeCompare(b.project));
117
+ return { reachable: true, error: null, projects };
118
+ } catch (cause) {
119
+ return { reachable: false, error: cause instanceof Error ? cause.message : String(cause), projects: [] };
120
+ }
121
+ }
122
+
123
+ function doRefresh(): Promise<DaemonSnapshot> {
124
+ return (async () => {
125
+ const current = readRegistry(currentRegistryPath());
126
+ const daemons: DaemonSnapshotEntry[] = current.ok
127
+ ? await Promise.all(
128
+ current.daemons.map(async (daemon) => ({
129
+ name: daemon.name,
130
+ value: daemon.value,
131
+ ...(await probeDaemon(daemon.value)),
132
+ })),
133
+ )
134
+ : [];
135
+ const next: DaemonSnapshot = { updatedAt: new Date().toISOString(), daemons };
136
+ snapshot = next;
137
+ try {
138
+ writeFileSync(SNAPSHOT_FILE, JSON.stringify(next, null, 2), { encoding: "utf8", mode: 0o600 });
139
+ } catch {
140
+ // The in-memory snapshot is authoritative; a failed state file write is not fatal.
141
+ }
142
+ return next;
143
+ })();
144
+ }
145
+
146
+ /** Re-probe every registered daemon and replace the snapshot. */
147
+ export function refreshSnapshot(): Promise<DaemonSnapshot> {
148
+ if (!inflight) {
149
+ inflight = doRefresh().finally(() => {
150
+ inflight = null;
151
+ });
152
+ }
153
+ return inflight;
154
+ }
155
+
156
+ /** Returns the current snapshot, refreshing only if it is stale. */
157
+ export function getSnapshotFresh(): Promise<DaemonSnapshot> {
158
+ if (snapshot && Date.now() - new Date(snapshot.updatedAt).getTime() < STALE_MS) {
159
+ return Promise.resolve(snapshot);
160
+ }
161
+ return refreshSnapshot();
162
+ }
163
+
164
+ /** Kick off a first snapshot at plugin load; never throws at startup. */
165
+ export function initializeSnapshot(): void {
166
+ void refreshSnapshot().catch((error) => {
167
+ snapLog.error(`initial refresh failed: ${error instanceof Error ? error.message : error}`);
168
+ });
169
+ }
170
+
171
+ export function agentCountFor(entry: DaemonSnapshotEntry): number {
172
+ return entry.projects.reduce(
173
+ (total, project) => total + project.workspaces.reduce((sub, ws) => sub + ws.agents.length, 0),
174
+ 0,
175
+ );
176
+ }