@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,98 @@
1
+ import { useRpc } from "@getpaseo/plugin/client";
2
+ import { useQuery } from "@tanstack/react-query";
3
+ import { registryReadRpc } from "../shared/registry";
4
+
5
+ /**
6
+ * Canonical daemon display: `alias (srv_xxx)`. Both halves always render
7
+ * together: never a bare id, never a bare alias. Peers without a registry
8
+ * entry (unpaired senders) render as `unknown (srv_xxx)`.
9
+ */
10
+ export function formatPeerDisplay(alias: string | null | undefined, serverId: string | null | undefined): string {
11
+ const id = serverId ?? "unknown-id";
12
+ const name = alias ?? "unknown";
13
+ return `${name} (${id})`;
14
+ }
15
+
16
+ function aliasFor(serverId: string | null | undefined, byServerId: Map<string, string>): string | null {
17
+ if (!serverId) return null;
18
+ return byServerId.get(serverId) ?? null;
19
+ }
20
+
21
+ /**
22
+ * Resolve a serverId to its local registry alias. Returns null while
23
+ * loading or when the id is not paired.
24
+ */
25
+ export function usePeerAlias(serverId: string | null | undefined): string | null {
26
+ const callRead = useRpc(registryReadRpc);
27
+ const read = useQuery({
28
+ queryKey: ["registry-read"],
29
+ queryFn: () => callRead({}),
30
+ staleTime: 300_000,
31
+ refetchOnWindowFocus: false,
32
+ });
33
+ if (!serverId || !read.data) return null;
34
+ const byServerId = new Map<string, string>();
35
+ for (const daemon of read.data.daemons) {
36
+ if (daemon.serverId) byServerId.set(daemon.serverId, daemon.name);
37
+ }
38
+ return aliasFor(serverId, byServerId);
39
+ }
40
+
41
+ /**
42
+ * Format a peer when both halves are already known (list rows, dump views,
43
+ * confirmations). Falls back to `unknown (srv)` when the alias is missing
44
+ * rather than rendering either half bare.
45
+ */
46
+ export function usePeerDisplay(serverId: string | null | undefined, knownAlias?: string | null): string {
47
+ const resolved = usePeerAlias(serverId);
48
+ return formatPeerDisplay(knownAlias ?? resolved, serverId);
49
+ }
50
+
51
+ export interface CounterpartyRef {
52
+ daemon?: string | null;
53
+ daemonServerId?: string | null;
54
+ }
55
+
56
+ /**
57
+ * Split a counterparty ref into display halves. Either field may carry
58
+ * either half depending on where the ref was built, so srv_-shaped values
59
+ * always count as the id half.
60
+ */
61
+ export function splitCounterparty(cp: CounterpartyRef): { alias: string | null; serverId: string | null } {
62
+ const values = [cp.daemon, cp.daemonServerId].filter((v): v is string => !!v);
63
+ const serverId = values.find((v) => v.startsWith("srv_")) ?? null;
64
+ const alias = values.find((v) => !v.startsWith("srv_")) ?? null;
65
+ return { alias, serverId };
66
+ }
67
+
68
+ export function formatCounterparty(cp: CounterpartyRef): string {
69
+ const { alias, serverId } = splitCounterparty(cp);
70
+ return formatPeerDisplay(alias, serverId);
71
+ }
72
+
73
+ /**
74
+ * Full counterparty label with registry enrichment: fills a missing half
75
+ * from the local registry (name to id or id to alias) before formatting.
76
+ * Shares the cached registry-read query with the other hooks.
77
+ */
78
+ export function useCounterpartyLabel(cp: CounterpartyRef): string {
79
+ const callRead = useRpc(registryReadRpc);
80
+ const read = useQuery({
81
+ queryKey: ["registry-read"],
82
+ queryFn: () => callRead({}),
83
+ staleTime: 300_000,
84
+ refetchOnWindowFocus: false,
85
+ });
86
+ const { alias, serverId } = splitCounterparty(cp);
87
+ let id = serverId;
88
+ let name = alias;
89
+ if (read.data) {
90
+ if (!id && name) {
91
+ id = read.data.daemons.find((d) => d.name === name)?.serverId ?? null;
92
+ }
93
+ if (!name && id) {
94
+ name = read.data.daemons.find((d) => d.serverId === id)?.name ?? null;
95
+ }
96
+ }
97
+ return formatPeerDisplay(name, id);
98
+ }
@@ -0,0 +1,201 @@
1
+ import { type PluginSurfaceProps } from "@getpaseo/plugin/client";
2
+ import React, { useMemo } from "react";
3
+ import { Text, View } from "react-native";
4
+ import {
5
+ Badge,
6
+ Button,
7
+ Card,
8
+ EmptyState,
9
+ KeyValue,
10
+ KeyValueGroup,
11
+ ModalBody,
12
+ SectionHeader,
13
+ StatusDot,
14
+ usePluginTheme,
15
+ useRpcQuery,
16
+ } from "paseo-plugin-helper/client";
17
+ import { formatPeerDisplay } from "./peer-label";
18
+ import { ViaXComms } from "./via-x-comms";
19
+ import { peerStatusRpc } from "../shared/registry";
20
+
21
+ const X_COMMS_CONTENT_MAX_WIDTH = 600;
22
+ const PEER_STATUS_POLL_MS = 30_000;
23
+
24
+ type PeerStatusEntry = {
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 ReachabilityBadge({ entry }: { entry: PeerStatusEntry }) {
47
+ if (!entry.reachable) return <Badge label="down" variant="danger" dot />;
48
+ if (!entry.valid) return <Badge label="invalid" variant="warning" dot />;
49
+ return <Badge label="up" variant="success" dot />;
50
+ }
51
+
52
+ function relayLabel(entry: PeerStatusEntry): string {
53
+ if (entry.relayEnabled === null) return "unknown";
54
+ if (!entry.relayEnabled) return "disabled";
55
+ return entry.relayEndpoint ? `enabled · ${entry.relayEndpoint}` : "enabled";
56
+ }
57
+
58
+ function providersLabel(entry: PeerStatusEntry): string {
59
+ if (entry.providerCount === null) return "unknown";
60
+ const available = entry.providersAvailable ?? 0;
61
+ return `${available}/${entry.providerCount} available`;
62
+ }
63
+
64
+ function hubLabel(entry: PeerStatusEntry): string {
65
+ if (!entry.hubState) return "unknown";
66
+ const parts = [entry.hubState];
67
+ if (entry.hubOrigin) parts.push(entry.hubOrigin);
68
+ if (entry.hubLastError) parts.push(`error: ${entry.hubLastError}`);
69
+ return parts.join(" · ");
70
+ }
71
+
72
+ function PeerCard({ entry }: { entry: PeerStatusEntry }) {
73
+ const { colors } = usePluginTheme();
74
+ return (
75
+ <Card variant="elevated">
76
+ <Card.Header
77
+ title={formatPeerDisplay(entry.name, entry.serverId)}
78
+ subtitle={entry.hostname && entry.hostname !== entry.name ? entry.hostname : undefined}
79
+ badge={<ReachabilityBadge entry={entry} />}
80
+ />
81
+ <KeyValue label="Host value" value={entry.value} mono copyable truncate="end" truncateMaxLength={36} />
82
+ <KeyValueGroup>
83
+ <KeyValue label="Transport" value={entry.transport} mono />
84
+ <KeyValue label="Version" value={entry.version ?? ""} mono />
85
+ <KeyValue label="Listen" value={entry.listen ?? ""} copyable mono />
86
+ <KeyValue
87
+ label="PID"
88
+ value={entry.pid === null ? "" : String(entry.pid)}
89
+ mono
90
+ />
91
+ <KeyValue label="Relay" value={relayLabel(entry)} mono />
92
+ <KeyValue label="Providers" value={providersLabel(entry)} mono />
93
+ </KeyValueGroup>
94
+ <KeyValue label="Hub relationship" value={hubLabel(entry)} mono />
95
+ {!entry.reachable ? (
96
+ <Text selectable style={{ color: colors.statusDanger, fontSize: 12 }}>
97
+ {entry.error ?? "unreachable"}
98
+ </Text>
99
+ ) : null}
100
+ </Card>
101
+ );
102
+ }
103
+
104
+ /**
105
+ * Live reachability/identity view of the known peer daemons (#7). Renders the
106
+ * client-side x-comms registry and probes each registered host with its own
107
+ * `daemon.get_status`; an unreachable host shows as down without failing the
108
+ * rest of the surface.
109
+ */
110
+ export function PeerStatusSurface({ theme }: PluginSurfaceProps) {
111
+ const { colors } = usePluginTheme();
112
+ const status = useRpcQuery(peerStatusRpc, {}, {
113
+ staleTime: PEER_STATUS_POLL_MS / 2,
114
+ refetchInterval: PEER_STATUS_POLL_MS,
115
+ refetchOnWindowFocus: false,
116
+ retry: false,
117
+ });
118
+
119
+ const entries = status.data?.results ?? [];
120
+ const summary = useMemo(() => {
121
+ const up = entries.filter((entry) => entry.reachable).length;
122
+ return { up, total: entries.length };
123
+ }, [entries]);
124
+
125
+ return (
126
+ <ModalBody
127
+ headerMode="pinned"
128
+ maxContentWidth={X_COMMS_CONTENT_MAX_WIDTH}
129
+ header={
130
+ <Card variant="elevated">
131
+ <Card.Header
132
+ title="Known peer daemons"
133
+ subtitle="Registry entries probed live with each peer's daemon.get_status."
134
+ badge={
135
+ status.isPending
136
+ ? <Badge label="probing…" variant="neutral" dot />
137
+ : <Badge label={`${summary.up}/${summary.total} up`} variant={summary.total > 0 && summary.up === summary.total ? "success" : "warning"} dot />
138
+ }
139
+ action={
140
+ <Button
141
+ label="Refresh"
142
+ size="sm"
143
+ variant="secondary"
144
+ icon="RefreshCw"
145
+ loading={status.isFetching}
146
+ disabled={status.isFetching}
147
+ onPress={() => void status.refetch()}
148
+ />
149
+ }
150
+ />
151
+ </Card>
152
+ }
153
+ refreshing={status.isFetching}
154
+ onRefresh={() => void status.refetch()}
155
+ contentContainerStyle={{ paddingTop: 6 }}
156
+ >
157
+ <SectionHeader title="Peers" count={entries.length} />
158
+
159
+ {status.isPending ? (
160
+ <Card>
161
+ <Card.Header title="Probing peers…" subtitle="Dialing each registered daemon." />
162
+ </Card>
163
+ ) : null}
164
+
165
+ {status.error ? (
166
+ <Card variant="elevated">
167
+ <Card.Header
168
+ title="Probe RPC failed"
169
+ subtitle={status.error.message}
170
+ badge={<Badge label="error" variant="danger" dot />}
171
+ />
172
+ </Card>
173
+ ) : null}
174
+
175
+ {!status.isPending && !status.error && entries.length === 0 ? (
176
+ <EmptyState
177
+ title="No known peer daemons."
178
+ description="Add a paired daemon in the Current surface; it will appear here with live status."
179
+ />
180
+ ) : null}
181
+
182
+ {entries.map((entry) => (
183
+ <PeerCard key={entry.name} entry={entry} />
184
+ ))}
185
+
186
+ {entries.length > 0 ? (
187
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
188
+ <StatusDot
189
+ variant={summary.total > 0 && summary.up === summary.total ? "success" : "warning"}
190
+ size="sm"
191
+ />
192
+ <Text style={{ color: colors.foregroundMuted, fontSize: 11 }}>
193
+ {summary.up} of {summary.total} known peers reachable.
194
+ </Text>
195
+ </View>
196
+ ) : null}
197
+
198
+ <ViaXComms theme={theme} />
199
+ </ModalBody>
200
+ );
201
+ }
@@ -0,0 +1,395 @@
1
+ import { useMutation, useQuery } from "@tanstack/react-query";
2
+ import { type PluginSurfaceProps, useRpc } from "@getpaseo/plugin/client";
3
+ import React, { useCallback, useMemo, useState } from "react";
4
+ import {
5
+ ActionBar,
6
+ Badge,
7
+ Button,
8
+ Card,
9
+ Collapsible,
10
+ EmptyState,
11
+ FormRow,
12
+ KeyValue,
13
+ KeyValueGroup,
14
+ ModalBody,
15
+ SectionHeader,
16
+ StatusDot,
17
+ TextInput,
18
+ Toggle,
19
+ } from "paseo-plugin-helper/client";
20
+ import { formatPeerDisplay } from "./peer-label";
21
+ import { ViaXComms } from "./via-x-comms";
22
+ import {
23
+ daemonAddRpc,
24
+ daemonHealthRpc,
25
+ daemonRemoveRpc,
26
+ directHostMismatch,
27
+ identitySyncRpc,
28
+ registryReadRpc,
29
+ serverStatusRpc,
30
+ snapshotRefreshRpc,
31
+ uiPrefsGetRpc,
32
+ uiPrefsSetRpc,
33
+ } from "../shared/registry";
34
+
35
+ const HOST_FORM_HINT =
36
+ "Full pairing link (https://app.paseo.sh/#offer=…) or a direct daemon host (host:port, tcp://…, unix://…).";
37
+
38
+ // Keep the settings surface a readable centered column instead of stretching
39
+ // edge-to-edge on large viewports. The cap lives in the helper
40
+ // (`ModalBody maxContentWidth`); this is the only place the surface picks
41
+ // the value.
42
+ const X_COMMS_CONTENT_MAX_WIDTH = 600;
43
+
44
+ // Prototype settings surface for #97. Built only from paseo-plugin-helper/client
45
+ // primitives; the current page in main.tsx is untouched and stays the default tab.
46
+ // Wired to the live registry/health/server/prefs RPCs — no local mock state.
47
+
48
+ function ReachabilityBadge({
49
+ health,
50
+ }: {
51
+ health?: { reachable: boolean; agentCount: number | null } | null;
52
+ }) {
53
+ if (!health) return <Badge label="checking…" variant="neutral" dot />;
54
+ if (health.reachable) {
55
+ return (
56
+ <Badge
57
+ label={health.agentCount !== null ? `reachable (${health.agentCount} agents)` : "reachable"}
58
+ variant="success"
59
+ dot
60
+ />
61
+ );
62
+ }
63
+ return <Badge label="unreachable" variant="danger" dot />;
64
+ }
65
+
66
+ function ErrorRow({ label, message }: { label: string; message?: string | null }) {
67
+ if (!message) return null;
68
+ return (
69
+ <FormRow label={label} description={message}>
70
+ <Badge label="error" variant="danger" dot />
71
+ </FormRow>
72
+ );
73
+ }
74
+
75
+ export function SettingsPrototype({ theme }: PluginSurfaceProps) {
76
+ const callRead = useRpc(registryReadRpc);
77
+ const callHealth = useRpc(daemonHealthRpc);
78
+ const callAdd = useRpc(daemonAddRpc);
79
+ const callRemove = useRpc(daemonRemoveRpc);
80
+ const callPrefsGet = useRpc(uiPrefsGetRpc);
81
+ const callPrefsSet = useRpc(uiPrefsSetRpc);
82
+ const callSnapshotRefresh = useRpc(snapshotRefreshRpc);
83
+ const callIdentitySync = useRpc(identitySyncRpc);
84
+ const callStatus = useRpc(serverStatusRpc);
85
+
86
+ const read = useQuery({ queryKey: ["registry-read"], queryFn: () => callRead({}) });
87
+ const health = useQuery({
88
+ queryKey: ["daemon-health"],
89
+ queryFn: () => callHealth({}),
90
+ staleTime: 30_000,
91
+ refetchOnWindowFocus: false,
92
+ });
93
+ const prefs = useQuery({ queryKey: ["ui-prefs"], queryFn: () => callPrefsGet({}) });
94
+ const status = useQuery({ queryKey: ["server-status"], queryFn: () => callStatus({}) });
95
+
96
+ const [newName, setNewName] = useState("");
97
+ const [newValue, setNewValue] = useState("");
98
+ const [confirmRemove, setConfirmRemove] = useState<string | null>(null);
99
+ const [refreshing, setRefreshing] = useState(false);
100
+
101
+ const refetchAll = useCallback(() => {
102
+ void read.refetch();
103
+ void health.refetch();
104
+ void prefs.refetch();
105
+ }, [read, health, prefs]);
106
+
107
+ const add = useMutation({ mutationFn: callAdd, onSuccess: refetchAll });
108
+ const remove = useMutation({
109
+ mutationFn: callRemove,
110
+ onSuccess: () => {
111
+ setConfirmRemove(null);
112
+ refetchAll();
113
+ },
114
+ });
115
+ const prefsSet = useMutation({ mutationFn: callPrefsSet });
116
+ const identitySync = useMutation({
117
+ mutationFn: () => callIdentitySync({}),
118
+ onSuccess: () => void read.refetch(),
119
+ });
120
+ const snapshotRefresh = useMutation({
121
+ mutationFn: () => callSnapshotRefresh({}),
122
+ onSuccess: () => {
123
+ setRefreshing(false);
124
+ refetchAll();
125
+ void status.refetch();
126
+ },
127
+ onError: () => setRefreshing(false),
128
+ });
129
+
130
+ const handleRefresh = useCallback(() => {
131
+ setRefreshing(true);
132
+ void identitySync
133
+ .mutateAsync()
134
+ .then(() => snapshotRefresh.mutate())
135
+ .catch(() => snapshotRefresh.mutate());
136
+ }, [identitySync, snapshotRefresh]);
137
+
138
+ const healthByName = useMemo(() => {
139
+ const map: Record<string, { reachable: boolean; error: string | null; agentCount: number | null }> = {};
140
+ for (const result of health.data?.results ?? []) map[result.name] = result;
141
+ return map;
142
+ }, [health.data]);
143
+
144
+ const daemonEnabled = prefs.data?.daemonEnabled ?? {};
145
+ const prereqsCollapsed = prefs.data?.prereqsCollapsed ?? true;
146
+ const presenceEnabled = prefs.data?.presenceEnabled ?? true;
147
+ const injectionEnabled = prefs.data?.injectionEnabled ?? true;
148
+ const outboxExpirySeconds = prefs.data?.outboxExpirySeconds ?? 600;
149
+ const [expiryDraft, setExpiryDraft] = useState<string | null>(null);
150
+
151
+ const persistPrefs = useCallback(
152
+ (patch: {
153
+ prereqsCollapsed?: boolean;
154
+ presenceEnabled?: boolean;
155
+ injectionEnabled?: boolean;
156
+ outboxExpirySeconds?: number;
157
+ daemonEnabled?: Record<string, boolean>;
158
+ }) => {
159
+ prefsSet.mutate(
160
+ {
161
+ prereqsCollapsed,
162
+ presenceEnabled,
163
+ injectionEnabled,
164
+ ...patch,
165
+ },
166
+ { onSuccess: () => void prefs.refetch() },
167
+ );
168
+ },
169
+ [prefsSet, prefs, prereqsCollapsed, presenceEnabled, injectionEnabled],
170
+ );
171
+
172
+ const commitExpiry = useCallback(() => {
173
+ const seconds = Number.parseInt(expiryDraft ?? "", 10);
174
+ setExpiryDraft(null);
175
+ if (!Number.isFinite(seconds) || seconds <= 0 || seconds === outboxExpirySeconds) return;
176
+ persistPrefs({ outboxExpirySeconds: seconds });
177
+ }, [expiryDraft, outboxExpirySeconds, persistPrefs]);
178
+
179
+ const canAdd = newName.trim().length > 0 && newValue.trim().length > 0;
180
+ const mismatch =
181
+ newName.trim().length > 0 && newValue.trim().length > 0 && !newValue.includes("#offer=")
182
+ ? directHostMismatch(newName.trim(), newValue.trim())
183
+ : null;
184
+ const daemonCount = read.data?.daemons.length ?? 0;
185
+
186
+ return (
187
+ <ModalBody
188
+ headerMode="pinned"
189
+ maxContentWidth={X_COMMS_CONTENT_MAX_WIDTH}
190
+ header={
191
+ <Card variant="elevated">
192
+ <Card.Header
193
+ title="X-comms settings"
194
+ subtitle="Prototype surface built from paseo-plugin-helper primitives."
195
+ badge={<Badge label="prototype" variant="warning" />}
196
+ action={
197
+ <Button
198
+ label="Refresh"
199
+ size="sm"
200
+ variant="secondary"
201
+ icon="RefreshCw"
202
+ loading={refreshing}
203
+ disabled={refreshing}
204
+ onPress={handleRefresh}
205
+ />
206
+ }
207
+ />
208
+ </Card>
209
+ }
210
+ refreshing={refreshing}
211
+ onRefresh={handleRefresh}
212
+ contentContainerStyle={{ paddingTop: 6 }}
213
+ >
214
+ <SectionHeader title="Server" />
215
+ <Card variant="elevated">
216
+ <Card.Header
217
+ title={status.data?.installed ? `Bundled server · v${status.data.version ?? "?"}` : "Bundled server"}
218
+ subtitle={status.data?.error ?? status.data?.installPath ?? "Locating bundled server…"}
219
+ />
220
+ </Card>
221
+
222
+ <SectionHeader title="Daemons" count={daemonCount} />
223
+ {read.isPending ? (
224
+ <Card>
225
+ <Card.Header title="Loading…" subtitle="Reading the daemon registry." />
226
+ </Card>
227
+ ) : null}
228
+ {!read.isPending && read.data && !read.data.exists ? (
229
+ <EmptyState title="No registry file yet." description="Add your first daemon below." />
230
+ ) : null}
231
+ {read.data && !read.data.validJson ? (
232
+ <Card variant="elevated">
233
+ <Card.Header
234
+ title="Registry is not valid JSON"
235
+ subtitle={read.data.parseError ?? undefined}
236
+ badge={<Badge label="error" variant="danger" dot />}
237
+ />
238
+ </Card>
239
+ ) : null}
240
+ {health.isPending && (read.data?.daemons.length ?? 0) > 0 ? (
241
+ <Card>
242
+ <Card.Header title="Checking health…" subtitle="Probing registered daemons." />
243
+ </Card>
244
+ ) : null}
245
+
246
+ {(read.data?.daemons ?? []).map((daemon) => {
247
+ const h = healthByName[daemon.name];
248
+ const enabled = daemonEnabled[daemon.name] !== false;
249
+ const healthDetail = h
250
+ ? (h.error ?? (h.reachable ? `${h.agentCount ?? 0} agents reachable` : "unreachable"))
251
+ : "Health check pending…";
252
+ return (
253
+ <Card key={daemon.name} variant="elevated">
254
+ <Card.Header
255
+ title={formatPeerDisplay(daemon.name, daemon.serverId)}
256
+ subtitle={daemon.hostname && daemon.hostname !== daemon.name ? daemon.hostname : undefined}
257
+ badge={enabled ? <ReachabilityBadge health={h} /> : <Badge label="disabled" variant="neutral" dot />}
258
+ />
259
+ <KeyValue
260
+ label="Host value"
261
+ value={daemon.value}
262
+ mono
263
+ copyable
264
+ truncate="end"
265
+ truncateMaxLength={36}
266
+ layout="inline"
267
+ />
268
+ <FormRow
269
+ layout="inline"
270
+ label="Health"
271
+ description={enabled ? healthDetail : `Disabled in preferences · ${healthDetail}`}
272
+ >
273
+ <StatusDot
274
+ variant={enabled ? (h?.reachable ? "success" : h ? "danger" : "neutral") : "neutral"}
275
+ size="sm"
276
+ />
277
+ </FormRow>
278
+ <FormRow
279
+ layout="inline"
280
+ label="Enabled"
281
+ description="Include this daemon in the x-comms mesh."
282
+ >
283
+ <Toggle
284
+ value={enabled}
285
+ disabled={prefs.isPending}
286
+ onValueChange={(next) =>
287
+ persistPrefs({ daemonEnabled: { ...daemonEnabled, [daemon.name]: next } })
288
+ }
289
+ />
290
+ </FormRow>
291
+ {confirmRemove === daemon.name ? (
292
+ <ActionBar align="flex-start">
293
+ <Button
294
+ label="Confirm remove"
295
+ size="sm"
296
+ variant="danger"
297
+ loading={remove.isPending}
298
+ disabled={remove.isPending}
299
+ onPress={() => remove.mutate({ name: daemon.name })}
300
+ />
301
+ <Button label="Cancel" size="sm" variant="secondary" onPress={() => setConfirmRemove(null)} />
302
+ </ActionBar>
303
+ ) : (
304
+ <ActionBar align="flex-start">
305
+ <Button label="Remove" size="sm" variant="ghost" onPress={() => setConfirmRemove(daemon.name)} />
306
+ </ActionBar>
307
+ )}
308
+ </Card>
309
+ );
310
+ })}
311
+ <ErrorRow label="Remove failed" message={remove.error?.message} />
312
+
313
+ <SectionHeader title="Add daemon" />
314
+ <Card variant="elevated">
315
+ <FormRow label="Name" description="The daemon's real name; derived automatically for relay links.">
316
+ <TextInput value={newName} onChangeText={setNewName} autoCapitalize="none" autoCorrect={false} />
317
+ </FormRow>
318
+ <FormRow label="Host value" description={HOST_FORM_HINT}>
319
+ <TextInput
320
+ mono
321
+ value={newValue}
322
+ onChangeText={setNewValue}
323
+ autoCapitalize="none"
324
+ autoCorrect={false}
325
+ placeholder={HOST_FORM_HINT}
326
+ errorText={mismatch ?? undefined}
327
+ />
328
+ </FormRow>
329
+ <ErrorRow label="Add failed" message={add.error?.message} />
330
+ <ActionBar align="flex-start">
331
+ <Button
332
+ label="Add daemon"
333
+ variant="primary"
334
+ loading={add.isPending}
335
+ disabled={!canAdd || add.isPending}
336
+ onPress={() => add.mutate({ name: newName.trim(), value: newValue.trim() })}
337
+ />
338
+ </ActionBar>
339
+ </Card>
340
+
341
+ <SectionHeader title="Preferences" />
342
+ <Card variant="elevated">
343
+ <Collapsible
344
+ title="Prerequisites"
345
+ subtitle="Bundled server paths and checks."
346
+ icon="Server"
347
+ isExpanded={!prereqsCollapsed}
348
+ onToggle={(expanded) => persistPrefs({ prereqsCollapsed: !expanded })}
349
+ >
350
+ <KeyValueGroup>
351
+ <KeyValue
352
+ label="Install path"
353
+ value={status.data?.installPath ?? ""}
354
+ mono
355
+ copyable={Boolean(status.data?.installPath)}
356
+ truncate="path"
357
+ />
358
+ </KeyValueGroup>
359
+ </Collapsible>
360
+ <FormRow label="Presence announcements" description="Broadcast this daemon's agents to the x-comms mesh.">
361
+ <Toggle
362
+ value={presenceEnabled}
363
+ disabled={prefs.isPending}
364
+ onValueChange={(next) => persistPrefs({ presenceEnabled: next })}
365
+ />
366
+ </FormRow>
367
+ <FormRow
368
+ label="Agent introduction injection"
369
+ description="Inject x-comms peer context when agents are created."
370
+ >
371
+ <Toggle
372
+ value={injectionEnabled}
373
+ disabled={prefs.isPending}
374
+ onValueChange={(next) => persistPrefs({ injectionEnabled: next })}
375
+ />
376
+ </FormRow>
377
+ <FormRow
378
+ label="Outbox expiry (seconds)"
379
+ description="How long an undelivered message is retried before the sender is notified. Default 600. Press Enter to save."
380
+ >
381
+ <TextInput
382
+ value={expiryDraft ?? String(outboxExpirySeconds)}
383
+ onChangeText={setExpiryDraft}
384
+ keyboardType="number-pad"
385
+ disabled={prefs.isPending}
386
+ onSubmitEditing={commitExpiry}
387
+ />
388
+ </FormRow>
389
+ <ErrorRow label="Preferences update failed" message={prefsSet.error?.message} />
390
+ </Card>
391
+
392
+ <ViaXComms theme={theme} />
393
+ </ModalBody>
394
+ );
395
+ }