@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,151 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { readFileSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { join } from "node:path";
5
+ import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
6
+ import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints";
7
+ import { createPluginLogger, safeSpawn } from "paseo-plugin-helper/server";
8
+ import { withTimeout } from "paseo-plugin-helper/shared";
9
+
10
+ const log = createPluginLogger("paseo-x-comms", { subsystem: "peer-channel" });
11
+ const CONNECT_TIMEOUT_MS = 8000;
12
+
13
+ export interface PeerTarget {
14
+ name: string;
15
+ endpoint: string;
16
+ url: string;
17
+ e2eePublicKeyB64: string | null;
18
+ expectedServerId: string | null;
19
+ }
20
+
21
+ interface ParsedOffer {
22
+ serverId: string | null;
23
+ daemonPublicKeyB64: string | null;
24
+ relayEndpoint: string | null;
25
+ useTls: boolean;
26
+ }
27
+
28
+ function parseOffer(value: string): ParsedOffer | null {
29
+ const m = value.match(/#offer=([A-Za-z0-9_-]+)/);
30
+ if (!m) return null;
31
+ try {
32
+ const payload = JSON.parse(Buffer.from(m[1], "base64").toString("utf8"));
33
+ return {
34
+ serverId: typeof payload.serverId === "string" ? payload.serverId : null,
35
+ daemonPublicKeyB64: typeof payload.daemonPublicKeyB64 === "string" ? payload.daemonPublicKeyB64 : null,
36
+ relayEndpoint: typeof payload.relay?.endpoint === "string" ? payload.relay.endpoint : null,
37
+ useTls: payload.relay?.useTls === true,
38
+ };
39
+ } catch {
40
+ return null;
41
+ }
42
+ }
43
+
44
+ /**
45
+ * Resolve a registry endpoint to a dialable peer target. Relay offers carry
46
+ * everything needed (relay URL, daemon key, expected serverId). Direct
47
+ * host:port targets carry no identity: expectedServerId stays null and the
48
+ * link check falls back to whatever the handshake reports.
49
+ */
50
+ export function resolvePeerTarget(name: string, endpoint: string): PeerTarget {
51
+ const value = endpoint.trim();
52
+ const offer = parseOffer(value);
53
+ if (offer) {
54
+ if (!offer.relayEndpoint || !offer.daemonPublicKeyB64 || !offer.serverId) {
55
+ throw new Error(`relay offer for '${name}' is missing endpoint, key, or serverId`);
56
+ }
57
+ return {
58
+ name,
59
+ endpoint: value,
60
+ url: buildRelayWebSocketUrl({
61
+ endpoint: offer.relayEndpoint,
62
+ useTls: offer.useTls,
63
+ serverId: offer.serverId,
64
+ role: "client",
65
+ }),
66
+ e2eePublicKeyB64: offer.daemonPublicKeyB64,
67
+ expectedServerId: offer.serverId,
68
+ };
69
+ }
70
+ const direct = value.replace(/^tcp:\/\//, "").split("?")[0];
71
+ if (/^[^/:]+:\d+$/.test(direct) || /^\[[0-9a-fA-F:.]+\]:\d+$/.test(direct)) {
72
+ return {
73
+ name,
74
+ endpoint: value,
75
+ url: buildDaemonWebSocketUrl(direct, { useTls: false }),
76
+ e2eePublicKeyB64: null,
77
+ expectedServerId: null,
78
+ };
79
+ }
80
+ throw new Error(`cannot dial '${name}': unsupported endpoint form for presence RPC`);
81
+ }
82
+
83
+ let cachedLocalServerId: string | null = null;
84
+
85
+ /**
86
+ * This daemon's own serverId. Read from the daemon-persisted server-id file,
87
+ * falling back to a local status probe. Never from agent config or payloads.
88
+ */
89
+ export async function localServerId(): Promise<string> {
90
+ if (cachedLocalServerId) return cachedLocalServerId;
91
+ try {
92
+ const id = readFileSync(join(homedir(), ".paseo", "server-id"), "utf8").trim();
93
+ if (id) {
94
+ cachedLocalServerId = id;
95
+ return id;
96
+ }
97
+ } catch {
98
+ // Fall through to the status probe.
99
+ }
100
+ const r = await withTimeout(
101
+ safeSpawn("paseo", ["daemon", "status", "--json"], { timeoutMs: 8000 }),
102
+ 8000,
103
+ "local daemon status",
104
+ );
105
+ if (r.code !== 0) throw new Error(`local daemon status failed: ${(r.stderr || `exit ${r.code}`).trim()}`);
106
+ const parsed = JSON.parse(r.stdout) as { serverId?: unknown };
107
+ if (typeof parsed.serverId !== "string" || !parsed.serverId) {
108
+ throw new Error("local daemon status reports no serverId");
109
+ }
110
+ cachedLocalServerId = parsed.serverId;
111
+ return parsed.serverId;
112
+ }
113
+
114
+ /**
115
+ * Invoke a plugin RPC on a peer over its authenticated channel and return the
116
+ * link-verified serverId of the far end. The handshake identity is checked
117
+ * against the expected id when the endpoint carries one (relay offers do);
118
+ * a mismatch aborts before any payload is sent.
119
+ */
120
+ export async function invokePeerRpc(
121
+ target: PeerTarget,
122
+ method: string,
123
+ input: unknown,
124
+ ): Promise<{ peerServerId: string | null }> {
125
+ const client = new DaemonClient({
126
+ url: target.url,
127
+ clientId: randomUUID(),
128
+ clientType: "cli",
129
+ appVersion: "paseo-x-comms/0.3.0",
130
+ e2ee: target.e2eePublicKeyB64 ? { enabled: true, daemonPublicKeyB64: target.e2eePublicKeyB64 } : undefined,
131
+ connectTimeoutMs: CONNECT_TIMEOUT_MS,
132
+ reconnect: { enabled: false },
133
+ });
134
+ try {
135
+ await withTimeout(client.connect(), CONNECT_TIMEOUT_MS, `connect ${target.name}`);
136
+ const peerServerId = client.getLastServerInfoMessage()?.serverId ?? null;
137
+ if (target.expectedServerId && peerServerId && peerServerId !== target.expectedServerId) {
138
+ throw new Error(`peer identity mismatch for '${target.name}': link reports ${peerServerId}`);
139
+ }
140
+ await withTimeout(
141
+ client.invokePluginRpc("x-comms", method, input),
142
+ CONNECT_TIMEOUT_MS,
143
+ `peer rpc ${method} ${target.name}`,
144
+ );
145
+ return { peerServerId };
146
+ } finally {
147
+ await client.close().catch((cause: unknown) => {
148
+ log.error(`peer close failed for '${target.name}': ${cause instanceof Error ? cause.message : String(cause)}`);
149
+ });
150
+ }
151
+ }
@@ -0,0 +1,233 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
3
+ import { createPluginLogger } from "paseo-plugin-helper/server";
4
+ import { withTimeout } from "paseo-plugin-helper/shared";
5
+ import { currentRegistryPath, readRegistry } from "./registry";
6
+ import { resolvePeerTarget } from "./peer-channel";
7
+
8
+ const log = createPluginLogger("paseo-x-comms", { subsystem: "peer-status" });
9
+
10
+ const PROBE_TIMEOUT_MS = 10_000;
11
+
12
+ export interface RegistryDaemon {
13
+ name: string;
14
+ value: string;
15
+ valid: boolean;
16
+ error: string | null;
17
+ }
18
+
19
+ /**
20
+ * One row of the known-peer surface. Every field beyond reachability is
21
+ * best-effort: a peer that answers the handshake but not `daemon.get_status`
22
+ * still counts as up with only the handshake identity populated.
23
+ */
24
+ export interface PeerStatusResult {
25
+ name: string;
26
+ value: string;
27
+ valid: boolean;
28
+ reachable: boolean;
29
+ error: string | null;
30
+ transport: "relay" | "direct" | "unknown";
31
+ serverId: string | null;
32
+ hostname: string | null;
33
+ version: string | null;
34
+ pid: number | null;
35
+ listen: string | null;
36
+ relayEnabled: boolean | null;
37
+ relayEndpoint: string | null;
38
+ providerCount: number | null;
39
+ providersAvailable: number | null;
40
+ hubState: string | null;
41
+ hubDaemonId: string | null;
42
+ hubOrigin: string | null;
43
+ hubLastError: string | null;
44
+ }
45
+
46
+ function nullableString(value: unknown): string | null {
47
+ return typeof value === "string" && value.length > 0 ? value : null;
48
+ }
49
+
50
+ function nullableNumber(value: unknown): number | null {
51
+ return typeof value === "number" && Number.isFinite(value) ? value : null;
52
+ }
53
+
54
+ export function unreachableResult(
55
+ daemon: RegistryDaemon,
56
+ transport: PeerStatusResult["transport"],
57
+ error: string,
58
+ ): PeerStatusResult {
59
+ return {
60
+ name: daemon.name,
61
+ value: daemon.value,
62
+ valid: daemon.valid,
63
+ reachable: false,
64
+ error,
65
+ transport,
66
+ serverId: null,
67
+ hostname: null,
68
+ version: null,
69
+ pid: null,
70
+ listen: null,
71
+ relayEnabled: null,
72
+ relayEndpoint: null,
73
+ providerCount: null,
74
+ providersAvailable: null,
75
+ hubState: null,
76
+ hubDaemonId: null,
77
+ hubOrigin: null,
78
+ hubLastError: null,
79
+ };
80
+ }
81
+
82
+ /** Relay block from a `daemon.get_status` payload (object form, not the CLI string). */
83
+ export function mapRelayStatus(raw: unknown): { enabled: boolean | null; endpoint: string | null } {
84
+ if (!raw || typeof raw !== "object") return { enabled: null, endpoint: null };
85
+ const relay = raw as Record<string, unknown>;
86
+ return {
87
+ enabled: typeof relay.enabled === "boolean" ? relay.enabled : null,
88
+ endpoint: nullableString(relay.publicEndpoint) ?? nullableString(relay.endpoint),
89
+ };
90
+ }
91
+
92
+ export function mapProviderCounts(raw: unknown): { total: number | null; available: number | null } {
93
+ if (!Array.isArray(raw)) return { total: null, available: null };
94
+ return {
95
+ total: raw.length,
96
+ available: raw.filter((provider) => (provider as { available?: unknown })?.available === true).length,
97
+ };
98
+ }
99
+
100
+ export function mapHubStatus(raw: unknown): Pick<PeerStatusResult, "hubState" | "hubDaemonId" | "hubOrigin" | "hubLastError"> {
101
+ const status = (raw as { status?: unknown })?.status;
102
+ if (!status || typeof status !== "object") {
103
+ return { hubState: null, hubDaemonId: null, hubOrigin: null, hubLastError: null };
104
+ }
105
+ const hub = status as Record<string, unknown>;
106
+ return {
107
+ hubState: nullableString(hub.state),
108
+ hubDaemonId: nullableString(hub.daemonId),
109
+ hubOrigin: nullableString(hub.hubOrigin),
110
+ hubLastError: nullableString(hub.lastError),
111
+ };
112
+ }
113
+
114
+ export type PeerProber = (daemon: RegistryDaemon) => Promise<PeerStatusResult>;
115
+
116
+ /**
117
+ * Probe every daemon concurrently. A thrown prober is converted to a down
118
+ * entry, so one unreachable peer can never fail the whole surface.
119
+ */
120
+ export async function collectPeerStatus(
121
+ daemons: RegistryDaemon[],
122
+ prober: PeerProber,
123
+ ): Promise<{ results: PeerStatusResult[] }> {
124
+ const results = await Promise.all(
125
+ daemons.map(async (daemon) => {
126
+ try {
127
+ return await prober(daemon);
128
+ } catch (cause) {
129
+ return unreachableResult(daemon, "unknown", cause instanceof Error ? cause.message : String(cause));
130
+ }
131
+ }),
132
+ );
133
+ return { results };
134
+ }
135
+
136
+ /**
137
+ * Dial a registered peer and read its own `daemon.get_status` (identity,
138
+ * listen, relay, providers) plus a best-effort hub relationship. The
139
+ * registry endpoint is resolved the same way presence uses it, so an
140
+ * undialable endpoint surfaces as down rather than throwing.
141
+ */
142
+ export const probePeer: PeerProber = async (daemon) => {
143
+ if (!daemon.valid) {
144
+ return unreachableResult(daemon, "unknown", daemon.error ?? "invalid registry entry");
145
+ }
146
+ let target;
147
+ try {
148
+ target = resolvePeerTarget(daemon.name, daemon.value);
149
+ } catch (cause) {
150
+ return unreachableResult(daemon, "unknown", cause instanceof Error ? cause.message : String(cause));
151
+ }
152
+ const transport = target.expectedServerId ? "relay" : "direct";
153
+
154
+ const client = new DaemonClient({
155
+ url: target.url,
156
+ clientId: randomUUID(),
157
+ clientType: "cli",
158
+ appVersion: "paseo-x-comms/0.3.0",
159
+ e2ee: target.e2eePublicKeyB64 ? { enabled: true, daemonPublicKeyB64: target.e2eePublicKeyB64 } : undefined,
160
+ connectTimeoutMs: PROBE_TIMEOUT_MS,
161
+ reconnect: { enabled: false },
162
+ });
163
+
164
+ try {
165
+ await withTimeout(client.connect(), PROBE_TIMEOUT_MS, `connect ${daemon.name}`);
166
+ const handshake = client.getLastServerInfoMessage();
167
+ const handshakeServerId = handshake?.serverId ?? null;
168
+ if (target.expectedServerId && handshakeServerId && handshakeServerId !== target.expectedServerId) {
169
+ return unreachableResult(
170
+ daemon,
171
+ transport,
172
+ `peer identity mismatch: link reports ${handshakeServerId}`,
173
+ );
174
+ }
175
+
176
+ const result: PeerStatusResult = {
177
+ ...unreachableResult(daemon, transport, ""),
178
+ reachable: true,
179
+ error: null,
180
+ serverId: handshakeServerId,
181
+ hostname: nullableString(handshake?.hostname),
182
+ version: nullableString(handshake?.version),
183
+ };
184
+
185
+ // Identity from the peer's own status is authoritative when available; a
186
+ // peer that does not implement the RPC is still up via the handshake.
187
+ try {
188
+ const status = await withTimeout(
189
+ client.getDaemonStatus({ timeout: PROBE_TIMEOUT_MS }),
190
+ PROBE_TIMEOUT_MS,
191
+ `daemon status ${daemon.name}`,
192
+ );
193
+ const relay = mapRelayStatus(status.relay);
194
+ const providers = mapProviderCounts(status.providers);
195
+ result.serverId = nullableString(status.serverId) ?? result.serverId;
196
+ result.version = nullableString(status.version) ?? result.version;
197
+ result.pid = nullableNumber(status.pid);
198
+ result.listen = nullableString(status.listen);
199
+ result.relayEnabled = relay.enabled;
200
+ result.relayEndpoint = relay.endpoint;
201
+ result.providerCount = providers.total;
202
+ result.providersAvailable = providers.available;
203
+ } catch (cause) {
204
+ log.warn(
205
+ `peer status for '${daemon.name}' unavailable, using handshake identity: ${cause instanceof Error ? cause.message : String(cause)}`,
206
+ );
207
+ }
208
+
209
+ try {
210
+ Object.assign(result, mapHubStatus(await withTimeout(client.getHubStatus(), PROBE_TIMEOUT_MS, `hub status ${daemon.name}`)));
211
+ } catch {
212
+ // Hub relationship is optional; peers without hub support stay null.
213
+ }
214
+
215
+ return result;
216
+ } catch (cause) {
217
+ return unreachableResult(daemon, transport, cause instanceof Error ? cause.message : String(cause));
218
+ } finally {
219
+ await client.close().catch((cause: unknown) => {
220
+ log.error(`peer close failed for '${daemon.name}': ${cause instanceof Error ? cause.message : String(cause)}`);
221
+ });
222
+ }
223
+ };
224
+
225
+ /** Registry read + live per-peer probe; the handler the client surface calls. */
226
+ export async function handlePeerStatus(
227
+ _input?: unknown,
228
+ _context?: unknown,
229
+ prober: PeerProber = probePeer,
230
+ ): Promise<{ results: PeerStatusResult[] }> {
231
+ const current = readRegistry(currentRegistryPath());
232
+ return collectPeerStatus(current.ok ? current.daemons : [], prober);
233
+ }
@@ -0,0 +1,204 @@
1
+ import { PluginStorage } from "paseo-plugin-helper/server";
2
+
3
+ /**
4
+ * Presence store (mesh Layer 1, control plane only).
5
+ *
6
+ * Vocabulary follows docs/mesh.md: announce (birth), retract, tombstones.
7
+ * Live entries are keyed by (serverId, agentId). Identity is never trusted
8
+ * from a payload alone: receivers only accept entries whose serverId matches
9
+ * an explicitly paired peer (seed links stay explicit).
10
+ *
11
+ * Anti-loop rules, in order of importance:
12
+ * 1. Seen-id LRU, bounded: duplicates are dropped on arrival.
13
+ * 2. Never forward gossip: only locally-originated events leave this daemon.
14
+ * Enforced by origin tracking plus a relay filter at the transport edge.
15
+ * 3. Retract-before-reannounce with sticky tombstones: a birth older than
16
+ * (or simultaneous with) its tombstone is refused, so delayed births
17
+ * cannot resurrect the dead.
18
+ * 4. TTL is purely a safety net: expiry sweeps live entries and reports an
19
+ * alert so a real bug surfaces instead of silently healing.
20
+ */
21
+
22
+ export interface PresenceBirth {
23
+ serverId: string;
24
+ agentId: string;
25
+ name: string;
26
+ provider: string;
27
+ timestamp: string;
28
+ }
29
+
30
+ export interface PresenceLiveEntry extends PresenceBirth {
31
+ origin: "local" | "remote";
32
+ lastSeenAt: string;
33
+ }
34
+
35
+ export interface PresenceTombstone {
36
+ serverId: string;
37
+ agentId: string;
38
+ retractedAt: string;
39
+ }
40
+
41
+ export interface PendingRetract {
42
+ peer: string;
43
+ serverId: string;
44
+ agentId: string;
45
+ timestamp: string;
46
+ messageId: string;
47
+ queuedAt: string;
48
+ attempts: number;
49
+ }
50
+
51
+ export interface PresenceState {
52
+ live: Record<string, PresenceLiveEntry>;
53
+ tombstones: Record<string, PresenceTombstone>;
54
+ seenIds: string[];
55
+ pendingRetracts: PendingRetract[];
56
+ }
57
+
58
+ export const PRESENCE_SEEN_CAP = 1000;
59
+ export const PRESENCE_TTL_MS = 7 * 24 * 60 * 60 * 1000;
60
+ export const PRESENCE_TOMBSTONE_TTL_MS = 2 * PRESENCE_TTL_MS;
61
+
62
+ export function presenceKey(serverId: string, agentId: string): string {
63
+ return `${serverId}/${agentId}`;
64
+ }
65
+
66
+ export function emptyPresenceState(): PresenceState {
67
+ return { live: {}, tombstones: {}, seenIds: [], pendingRetracts: [] };
68
+ }
69
+
70
+ function noteSeen(state: PresenceState, messageId: string): boolean {
71
+ if (state.seenIds.includes(messageId)) return false;
72
+ state.seenIds.push(messageId);
73
+ while (state.seenIds.length > PRESENCE_SEEN_CAP) state.seenIds.shift();
74
+ return true;
75
+ }
76
+
77
+ export type AnnounceOutcome =
78
+ | { result: "accepted" }
79
+ | { result: "duplicate" }
80
+ | { result: "refused-tombstoned" }
81
+ | { result: "refused-stale" };
82
+
83
+ /**
84
+ * Apply one birth announcement. Returns the outcome; mutates state.
85
+ * `nowIso` is injectable for tests.
86
+ */
87
+ export function applyAnnounce(
88
+ state: PresenceState,
89
+ birth: PresenceBirth,
90
+ messageId: string,
91
+ origin: "local" | "remote",
92
+ nowIso: string = new Date().toISOString(),
93
+ ): AnnounceOutcome {
94
+ if (!noteSeen(state, messageId)) return { result: "duplicate" };
95
+ const key = presenceKey(birth.serverId, birth.agentId);
96
+ const tombstone = state.tombstones[key];
97
+ if (tombstone && birth.timestamp <= tombstone.retractedAt) {
98
+ return { result: "refused-tombstoned" };
99
+ }
100
+ if (tombstone) delete state.tombstones[key];
101
+ const existing = state.live[key];
102
+ if (existing && birth.timestamp <= existing.timestamp) {
103
+ return { result: "refused-stale" };
104
+ }
105
+ state.live[key] = { ...birth, origin, lastSeenAt: nowIso };
106
+ return { result: "accepted" };
107
+ }
108
+
109
+ export type RetractOutcome = { applied: boolean; duplicate: boolean };
110
+
111
+ /**
112
+ * Apply one retract. Always tombstones, even if no live entry exists, so a
113
+ * delayed birth arriving later is refused. Mutates state.
114
+ */
115
+ export function applyRetract(
116
+ state: PresenceState,
117
+ serverId: string,
118
+ agentId: string,
119
+ timestamp: string,
120
+ messageId: string,
121
+ ): RetractOutcome {
122
+ if (!noteSeen(state, messageId)) return { applied: false, duplicate: true };
123
+ const key = presenceKey(serverId, agentId);
124
+ delete state.live[key];
125
+ const prev = state.tombstones[key];
126
+ if (!prev || timestamp > prev.retractedAt) {
127
+ state.tombstones[key] = { serverId, agentId, retractedAt: timestamp };
128
+ }
129
+ return { applied: true, duplicate: false };
130
+ }
131
+
132
+ /**
133
+ * Relay filter for the transport edge: only locally-originated live entries
134
+ * may leave this daemon. Everything received via gossip stays local.
135
+ */
136
+ export function localLiveEntries(state: PresenceState): PresenceLiveEntry[] {
137
+ return Object.values(state.live).filter((entry) => entry.origin === "local");
138
+ }
139
+
140
+ /**
141
+ * TTL sweep. Returns expired live keys and stale tombstone keys, and mutates
142
+ * state by removing them. Callers must treat any non-empty result as an
143
+ * alert: TTL firing means announcements or retracts stopped flowing.
144
+ */
145
+ export function sweepExpired(
146
+ state: PresenceState,
147
+ nowMs: number = Date.now(),
148
+ ): { expiredLive: string[]; expiredTombstones: string[] } {
149
+ const expiredLive: string[] = [];
150
+ for (const [key, entry] of Object.entries(state.live)) {
151
+ if (nowMs - Date.parse(entry.lastSeenAt) > PRESENCE_TTL_MS) {
152
+ delete state.live[key];
153
+ expiredLive.push(key);
154
+ }
155
+ }
156
+ const expiredTombstones: string[] = [];
157
+ for (const [key, tomb] of Object.entries(state.tombstones)) {
158
+ if (nowMs - Date.parse(tomb.retractedAt) > PRESENCE_TOMBSTONE_TTL_MS) {
159
+ delete state.tombstones[key];
160
+ expiredTombstones.push(key);
161
+ }
162
+ }
163
+ return { expiredLive, expiredTombstones };
164
+ }
165
+
166
+ export function queueRetract(
167
+ state: PresenceState,
168
+ pending: PendingRetract,
169
+ ): void {
170
+ const key = presenceKey(pending.serverId, pending.agentId);
171
+ state.pendingRetracts = state.pendingRetracts.filter(
172
+ (p) => p.peer !== pending.peer || presenceKey(p.serverId, p.agentId) !== key,
173
+ );
174
+ state.pendingRetracts.push(pending);
175
+ }
176
+
177
+ export function pendingForPeer(state: PresenceState, peer: string): PendingRetract[] {
178
+ return state.pendingRetracts.filter((p) => p.peer === peer);
179
+ }
180
+
181
+ const presenceStore = new PluginStorage<PresenceState>("paseo-x-comms", "presence.json", {
182
+ defaultData: emptyPresenceState(),
183
+ });
184
+
185
+ export function readPresence(): PresenceState {
186
+ try {
187
+ const data = presenceStore.read();
188
+ if (data && typeof data === "object") {
189
+ return {
190
+ live: data.live ?? {},
191
+ tombstones: data.tombstones ?? {},
192
+ seenIds: Array.isArray(data.seenIds) ? data.seenIds : [],
193
+ pendingRetracts: Array.isArray(data.pendingRetracts) ? data.pendingRetracts : [],
194
+ };
195
+ }
196
+ } catch {
197
+ // Corrupt state falls back to empty; gossip rebuilds it.
198
+ }
199
+ return emptyPresenceState();
200
+ }
201
+
202
+ export function writePresence(state: PresenceState): void {
203
+ presenceStore.write(state);
204
+ }