@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.
- package/LICENSE +21 -0
- package/README.md +149 -0
- package/client/attribution.ts +7 -0
- package/client/conversations.ts +229 -0
- package/client/main.tsx +885 -0
- package/client/peer-label.ts +98 -0
- package/client/peer-status.tsx +201 -0
- package/client/settings-prototype.tsx +395 -0
- package/client/tool-call.ts +115 -0
- package/client/vendor/paseo-plugin-helper/command-center.ts +43 -0
- package/client/vendor/paseo-plugin-helper/components/AboutSection.tsx +493 -0
- package/client/vendor/paseo-plugin-helper/components/AttentionBeacon.tsx +250 -0
- package/client/vendor/paseo-plugin-helper/components/Badge.tsx +156 -0
- package/client/vendor/paseo-plugin-helper/components/Button.tsx +178 -0
- package/client/vendor/paseo-plugin-helper/components/Card.tsx +225 -0
- package/client/vendor/paseo-plugin-helper/components/CodeBlock.tsx +196 -0
- package/client/vendor/paseo-plugin-helper/components/Collapsible.tsx +277 -0
- package/client/vendor/paseo-plugin-helper/components/CommandBox.tsx +172 -0
- package/client/vendor/paseo-plugin-helper/components/CopyButton.tsx +180 -0
- package/client/vendor/paseo-plugin-helper/components/DataTable.tsx +200 -0
- package/client/vendor/paseo-plugin-helper/components/EmptyState.tsx +97 -0
- package/client/vendor/paseo-plugin-helper/components/HighlightedText.tsx +70 -0
- package/client/vendor/paseo-plugin-helper/components/InlineButton.tsx +73 -0
- package/client/vendor/paseo-plugin-helper/components/KeyValue.tsx +446 -0
- package/client/vendor/paseo-plugin-helper/components/MetricGauge.tsx +247 -0
- package/client/vendor/paseo-plugin-helper/components/ProgressBar.tsx +117 -0
- package/client/vendor/paseo-plugin-helper/components/Responsive.tsx +53 -0
- package/client/vendor/paseo-plugin-helper/components/SearchInput.tsx +118 -0
- package/client/vendor/paseo-plugin-helper/components/SectionHeader.tsx +80 -0
- package/client/vendor/paseo-plugin-helper/components/Select.tsx +215 -0
- package/client/vendor/paseo-plugin-helper/components/StatusDot.tsx +80 -0
- package/client/vendor/paseo-plugin-helper/components/Tabs.tsx +319 -0
- package/client/vendor/paseo-plugin-helper/components/TextInput.tsx +150 -0
- package/client/vendor/paseo-plugin-helper/components/Toggle.tsx +163 -0
- package/client/vendor/paseo-plugin-helper/components/TruncatedText.tsx +157 -0
- package/client/vendor/paseo-plugin-helper/components/index.ts +25 -0
- package/client/vendor/paseo-plugin-helper/custom-pills.tsx +224 -0
- package/client/vendor/paseo-plugin-helper/forge-icon.tsx +79 -0
- package/client/vendor/paseo-plugin-helper/host.ts +277 -0
- package/client/vendor/paseo-plugin-helper/icon.tsx +39 -0
- package/client/vendor/paseo-plugin-helper/index.ts +28 -0
- package/client/vendor/paseo-plugin-helper/layout/ActionBar.tsx +49 -0
- package/client/vendor/paseo-plugin-helper/layout/FormRow.tsx +103 -0
- package/client/vendor/paseo-plugin-helper/layout/Grid.tsx +65 -0
- package/client/vendor/paseo-plugin-helper/layout/ModalBody.tsx +378 -0
- package/client/vendor/paseo-plugin-helper/layout/ModalContent.tsx +49 -0
- package/client/vendor/paseo-plugin-helper/layout/Row.tsx +39 -0
- package/client/vendor/paseo-plugin-helper/layout/Stack.tsx +39 -0
- package/client/vendor/paseo-plugin-helper/layout/index.ts +7 -0
- package/client/vendor/paseo-plugin-helper/panel.tsx +81 -0
- package/client/vendor/paseo-plugin-helper/pill.tsx +884 -0
- package/client/vendor/paseo-plugin-helper/query-refresh.ts +79 -0
- package/client/vendor/paseo-plugin-helper/query.ts +66 -0
- package/client/vendor/paseo-plugin-helper/settings-screen.tsx +372 -0
- package/client/vendor/paseo-plugin-helper/settings.ts +181 -0
- package/client/vendor/paseo-plugin-helper/shared-settings.ts +46 -0
- package/client/vendor/paseo-plugin-helper/snapshot.ts +68 -0
- package/client/vendor/paseo-plugin-helper/surface.tsx +80 -0
- package/client/vendor/paseo-plugin-helper/theme/color-utils.ts +118 -0
- package/client/vendor/paseo-plugin-helper/theme/flair.ts +76 -0
- package/client/vendor/paseo-plugin-helper/theme/host-variables.ts +121 -0
- package/client/vendor/paseo-plugin-helper/theme/index.ts +7 -0
- package/client/vendor/paseo-plugin-helper/theme/provider.tsx +214 -0
- package/client/vendor/paseo-plugin-helper/theme/responsive.ts +213 -0
- package/client/vendor/paseo-plugin-helper/theme/tokens.ts +161 -0
- package/client/vendor/paseo-plugin-helper/theme/useResponsive.ts +57 -0
- package/client/vendor/paseo-plugin-helper/utils/clipboard.ts +149 -0
- package/client/vendor/paseo-plugin-helper/utils/haptics.ts +34 -0
- package/client/via-x-comms.tsx +15 -0
- package/client/x-comms-conversation.tsx +449 -0
- package/client/x-comms-panel.tsx +6 -0
- package/client/x-comms-pill.tsx +193 -0
- package/client/x-comms-timeline.tsx +176 -0
- package/client/x-comms-tool-call.tsx +85 -0
- package/docs/mesh.md +115 -0
- package/mcp/LICENSE +202 -0
- package/mcp/README.md +287 -0
- package/mcp/mcp-config.example.json +8 -0
- package/mcp/package-lock.json +1186 -0
- package/mcp/package.json +43 -0
- package/mcp/paseo-cross-daemon-comms.example.json +5 -0
- package/mcp/paseo-x-comms.bundled.mjs +36964 -0
- package/mcp/paseo-x-comms.mjs +630 -0
- package/mcp/test/fixtures/extensions/block/10-block.mjs +4 -0
- package/mcp/test/fixtures/extensions/broken-hook/10-thrower.mjs +6 -0
- package/mcp/test/fixtures/extensions/broken-hook/20-good.mjs +4 -0
- package/mcp/test/fixtures/extensions/broken-load/10-thrower.mjs +4 -0
- package/mcp/test/fixtures/extensions/broken-load/20-good.mjs +4 -0
- package/mcp/test/fixtures/extensions/custom-tool/10-tool.mjs +12 -0
- package/mcp/test/fixtures/extensions/tool-block/10-block.mjs +8 -0
- package/mcp/test/fixtures/extensions/transform/10-transform.mjs +15 -0
- package/mcp/test/fixtures/fake-paseo.mjs +92 -0
- package/mcp/test/register-ts-hooks.mjs +6 -0
- package/mcp/test/resolve-ts-hooks.mjs +46 -0
- package/package.json +58 -0
- package/paseo-plugin.json +7 -0
- package/server/conversations-snapshot.ts +249 -0
- package/server/handlers.ts +1081 -0
- package/server/injection.ts +152 -0
- package/server/local-send.ts +153 -0
- package/server/mcp-client.ts +47 -0
- package/server/outbox.ts +233 -0
- package/server/peer-channel.ts +151 -0
- package/server/peer-status.ts +233 -0
- package/server/presence.ts +204 -0
- package/server/registry.ts +215 -0
- package/server/relay-status.ts +9 -0
- package/server/server-status.ts +138 -0
- package/server/settings.ts +81 -0
- package/server/snapshot.ts +176 -0
- package/server/vendor/paseo-plugin-helper/agent.ts +85 -0
- package/server/vendor/paseo-plugin-helper/custom-pills.ts +344 -0
- package/server/vendor/paseo-plugin-helper/index.ts +18 -0
- package/server/vendor/paseo-plugin-helper/jsonc.ts +78 -0
- package/server/vendor/paseo-plugin-helper/logger.ts +210 -0
- package/server/vendor/paseo-plugin-helper/mcp/client.ts +349 -0
- package/server/vendor/paseo-plugin-helper/mcp/http-client.ts +399 -0
- package/server/vendor/paseo-plugin-helper/mcp/index.ts +5 -0
- package/server/vendor/paseo-plugin-helper/mcp/process-killer.ts +65 -0
- package/server/vendor/paseo-plugin-helper/mcp/ring-buffer.ts +29 -0
- package/server/vendor/paseo-plugin-helper/mcp/types.ts +41 -0
- package/server/vendor/paseo-plugin-helper/mcp-config.ts +367 -0
- package/server/vendor/paseo-plugin-helper/mcp-injection.ts +85 -0
- package/server/vendor/paseo-plugin-helper/network.ts +91 -0
- package/server/vendor/paseo-plugin-helper/plugins.ts +160 -0
- package/server/vendor/paseo-plugin-helper/process.ts +186 -0
- package/server/vendor/paseo-plugin-helper/redact.ts +86 -0
- package/server/vendor/paseo-plugin-helper/rpc-guard.ts +77 -0
- package/server/vendor/paseo-plugin-helper/settings.ts +97 -0
- package/server/vendor/paseo-plugin-helper/shared-settings.ts +243 -0
- package/server/vendor/paseo-plugin-helper/storage.ts +244 -0
- package/server/vendor/paseo-plugin-helper/system.ts +128 -0
- package/server/vendor/paseo-plugin-helper/task.ts +116 -0
- package/server/vendor/paseo-plugin-helper/version.ts +153 -0
- package/server/vendor/paseo-plugin-helper/workspace-beacon.ts +418 -0
- package/shared/conversations-snapshot.ts +39 -0
- package/shared/envelope.ts +92 -0
- package/shared/outbox.ts +21 -0
- package/shared/registry.ts +366 -0
- package/shared/vendor/paseo-plugin-helper/README.md +11 -0
- package/shared/vendor/paseo-plugin-helper/async.ts +35 -0
- package/shared/vendor/paseo-plugin-helper/custom-pills.ts +169 -0
- package/shared/vendor/paseo-plugin-helper/forge.ts +110 -0
- package/shared/vendor/paseo-plugin-helper/formatters.ts +271 -0
- package/shared/vendor/paseo-plugin-helper/highlight.ts +184 -0
- package/shared/vendor/paseo-plugin-helper/index.ts +10 -0
- package/shared/vendor/paseo-plugin-helper/rpc.ts +72 -0
- package/shared/vendor/paseo-plugin-helper/settings.ts +138 -0
- package/shared/vendor/paseo-plugin-helper/suite-settings.ts +17 -0
- package/shared/vendor/paseo-plugin-helper/suppressed.ts +31 -0
- package/shared/vendor/paseo-plugin-helper/types.ts +36 -0
- 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
|
+
}
|