@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,885 @@
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 { Text, View } from "react-native";
5
+ import { Modal } from "@getpaseo/plugin/client/react-native";
6
+ import {
7
+ ActionBar,
8
+ Badge,
9
+ Button,
10
+ Card,
11
+ Collapsible,
12
+ EmptyState,
13
+ FormRow,
14
+ KeyValue,
15
+ KeyValueGroup,
16
+ ModalBody,
17
+ ModalContent,
18
+ SectionHeader,
19
+ Tabs,
20
+ TextInput,
21
+ usePluginTheme,
22
+ } from "paseo-plugin-helper/client";
23
+ import { formatPeerDisplay } from "./peer-label";
24
+ import { PeerStatusSurface } from "./peer-status";
25
+ import { SettingsPrototype } from "./settings-prototype";
26
+ import { ViaXComms } from "./via-x-comms";
27
+ import {
28
+ registryReadRpc,
29
+ daemonAddRpc,
30
+ daemonUpdateRpc,
31
+ daemonRemoveRpc,
32
+ daemonHealthRpc,
33
+ daemonProbeRpc,
34
+ uiPrefsGetRpc,
35
+ uiPrefsSetRpc,
36
+ snapshotRefreshRpc,
37
+ daemonDumpRpc,
38
+ identitySyncRpc,
39
+ serverStatusRpc,
40
+ introspectAgentsRpc,
41
+ introduceAgentsRpc,
42
+ directHostMismatch,
43
+ } from "../shared/registry";
44
+
45
+ const HOST_FORM_HINT =
46
+ "Full pairing link (https://app.paseo.sh/#offer=…) or a direct daemon host (host:port, tcp://…, unix://…).";
47
+
48
+ // Keep the main surface a readable centered column instead of stretching
49
+ // edge-to-edge on large viewports. The cap lives in the helper
50
+ // (`ModalBody maxContentWidth`); this is the only place the surface picks
51
+ // the value.
52
+ const X_COMMS_CONTENT_MAX_WIDTH = 600;
53
+
54
+ // Raw View/Text are kept only for plain content and layout composition
55
+ // (headings, error notices, debug dump lines, modal footers). Every
56
+ // interactive control, card, form row, status indicator, key/value display
57
+ // and empty/loading state uses a paseo-plugin-helper primitive.
58
+ function Notice({
59
+ children,
60
+ tone = "danger",
61
+ }: {
62
+ children: React.ReactNode;
63
+ tone?: "danger" | "muted";
64
+ }) {
65
+ const { colors } = usePluginTheme();
66
+ return (
67
+ <Text
68
+ selectable
69
+ style={{
70
+ color: tone === "danger" ? colors.statusDanger : colors.foregroundMuted,
71
+ fontSize: 12,
72
+ }}
73
+ >
74
+ {children}
75
+ </Text>
76
+ );
77
+ }
78
+
79
+ function HealthBadge({
80
+ health,
81
+ }: {
82
+ health?: { reachable: boolean; agentCount: number | null } | null;
83
+ }) {
84
+ if (!health) return <Badge label="checking…" variant="neutral" dot />;
85
+ if (health.reachable) {
86
+ return (
87
+ <Badge
88
+ label={health.agentCount !== null ? `reachable (${health.agentCount} agents)` : "reachable"}
89
+ variant="success"
90
+ dot
91
+ />
92
+ );
93
+ }
94
+ return <Badge label="unreachable" variant="danger" dot />;
95
+ }
96
+
97
+ // #97: the operator asked for an entirely new prototype surface alongside the
98
+ // current one. MainSurface keeps the existing page as the default "Current" tab
99
+ // and renders the helper-primitives prototype behind the marked "Prototype" tab.
100
+ // #7 adds a third "Peers" tab: the known daemon registry with a live per-peer
101
+ // daemon.get_status probe. It is additive — the other two tabs are untouched.
102
+ export function MainSurface(props: PluginSurfaceProps) {
103
+ const { colors } = usePluginTheme();
104
+ const [surface, setSurface] = useState<"current" | "prototype" | "peers">("current");
105
+ return (
106
+ <View style={{ flex: 1, minHeight: 0, width: "100%", backgroundColor: colors.surface0 }}>
107
+ <View style={{ paddingHorizontal: 12, paddingTop: 12 }}>
108
+ <Tabs
109
+ tabs={[
110
+ { id: "current", label: "Current" },
111
+ { id: "prototype", label: "Prototype", badge: "new" },
112
+ { id: "peers", label: "Peers" },
113
+ ]}
114
+ activeTab={surface}
115
+ onTabChange={(id) => setSurface(id === "prototype" ? "prototype" : id === "peers" ? "peers" : "current")}
116
+ />
117
+ </View>
118
+ {surface === "current" ? (
119
+ <CurrentSurface {...props} />
120
+ ) : surface === "prototype" ? (
121
+ <SettingsPrototype {...props} />
122
+ ) : (
123
+ <PeerStatusSurface {...props} />
124
+ )}
125
+ </View>
126
+ );
127
+ }
128
+
129
+ function CurrentSurface({ theme }: PluginSurfaceProps) {
130
+ const { colors } = usePluginTheme();
131
+ const callRead = useRpc(registryReadRpc);
132
+ const callAdd = useRpc(daemonAddRpc);
133
+ const callUpdate = useRpc(daemonUpdateRpc);
134
+ const callRemove = useRpc(daemonRemoveRpc);
135
+ const callHealth = useRpc(daemonHealthRpc);
136
+ const callProbe = useRpc(daemonProbeRpc);
137
+ const callPrefsGet = useRpc(uiPrefsGetRpc);
138
+ const callPrefsSet = useRpc(uiPrefsSetRpc);
139
+ const callSnapshotRefresh = useRpc(snapshotRefreshRpc);
140
+ const callDump = useRpc(daemonDumpRpc);
141
+ const callIdentitySync = useRpc(identitySyncRpc);
142
+ const callStatus = useRpc(serverStatusRpc);
143
+ const callIntrospect = useRpc(introspectAgentsRpc);
144
+ const callIntroduce = useRpc(introduceAgentsRpc);
145
+ const [newName, setNewName] = useState("");
146
+ const [newValue, setNewValue] = useState("");
147
+ const [prereqsCollapsed, setPrereqsCollapsed] = useState<boolean>(true);
148
+ const [refreshing, setRefreshing] = useState(false);
149
+ const [refreshedAt, setRefreshedAt] = useState<string | null>(null);
150
+ const [dumpState, setDumpState] = useState<Record<string, unknown> | null>(null);
151
+ const [dumpDaemon, setDumpDaemon] = useState<string | null>(null);
152
+ const [dumpOpen, setDumpOpen] = useState(false);
153
+
154
+ // Introduce: which picker (1 or 2) is expanded, selections, editable message.
155
+ const [expandedPicker, setExpandedPicker] = useState<1 | 2 | null>(null);
156
+ const [introFirst, setIntroFirst] = useState<{ daemon: string; agentId: string; shortId: string; name: string } | null>(null);
157
+ const [introSecond, setIntroSecond] = useState<{ daemon: string; agentId: string; shortId: string; name: string } | null>(null);
158
+ const [introMessage, setIntroMessage] = useState(
159
+ "Hello! I was asked to introduce you. This daemon can communicate with you directly via paseo-x-comms.",
160
+ );
161
+
162
+ const read = useQuery({ queryKey: ["registry-read"], queryFn: () => callRead({}) });
163
+ const health = useQuery({
164
+ queryKey: ["daemon-health"],
165
+ queryFn: () => callHealth({}),
166
+ // Health is a network probe; never let it block the UI.
167
+ staleTime: 30_000,
168
+ refetchOnWindowFocus: false,
169
+ });
170
+ const probe = useMutation({ mutationFn: callProbe });
171
+ const prefs = useQuery({ queryKey: ["ui-prefs"], queryFn: () => callPrefsGet({}) });
172
+ React.useEffect(() => {
173
+ if (prefs.data) setPrereqsCollapsed(prefs.data.prereqsCollapsed);
174
+ }, [prefs.data]);
175
+ const prefsSet = useMutation({ mutationFn: callPrefsSet });
176
+ const identitySync = useMutation({
177
+ mutationFn: () => callIdentitySync({}),
178
+ onSuccess: () => void read.refetch(),
179
+ });
180
+ const snapshotRefresh = useMutation({
181
+ mutationFn: () => callSnapshotRefresh({}),
182
+ onSuccess: (result) => {
183
+ setRefreshedAt(new Date(result.updatedAt).toLocaleTimeString());
184
+ setRefreshing(false);
185
+ void read.refetch();
186
+ void health.refetch();
187
+ void introspect.refetch();
188
+ void status.refetch();
189
+ },
190
+ onError: () => setRefreshing(false),
191
+ });
192
+ const dump = useMutation({
193
+ mutationFn: callDump,
194
+ onSuccess: (data) => setDumpState(data as unknown as Record<string, unknown>),
195
+ });
196
+ const introspect = useQuery({
197
+ queryKey: ["introspect"],
198
+ queryFn: () => callIntrospect({}),
199
+ staleTime: 30_000,
200
+ });
201
+ const introduce = useMutation({ mutationFn: callIntroduce });
202
+ const status = useQuery({ queryKey: ["server-status"], queryFn: () => callStatus({}) });
203
+ const add = useMutation({ mutationFn: callAdd });
204
+ const update = useMutation({ mutationFn: callUpdate });
205
+ const remove = useMutation({ mutationFn: callRemove });
206
+
207
+ // Editing state per daemon: current draft name + value (keyed by original name).
208
+ const [edits, setEdits] = useState<Record<string, { name: string; value: string }>>({});
209
+ const [expandedError, setExpandedError] = useState<Set<string>>(new Set());
210
+ const [rowProbe, setRowProbe] = useState<Record<string, { value: string; error: string | null; saved: boolean } | "pending">>({});
211
+ const [addProbe, setAddProbe] = useState<{ value: string; error: string | null } | null>(null);
212
+ const [adding, setAdding] = useState(false);
213
+ const [editing, setEditing] = useState<Set<string>>(new Set());
214
+
215
+ const applyResult = useCallback(
216
+ (result: unknown) => {
217
+ const r = result as { error?: string | null };
218
+ if (r?.error) throw new Error(r.error);
219
+ void read.refetch();
220
+ void health.refetch();
221
+ },
222
+ [read, health],
223
+ );
224
+
225
+ // Probe a candidate. Returns "ok" (reachable), "unreachable" (show inline
226
+ // confirm), or "invalid" (format error). No modal; the caller renders the
227
+ // result at the row.
228
+ const probeValue = useCallback(
229
+ (value: string): Promise<"ok" | "unreachable" | "invalid"> =>
230
+ new Promise((resolve) => {
231
+ probe.mutate(
232
+ { value },
233
+ {
234
+ onSuccess: (result) => {
235
+ if (!result.valid) resolve("invalid");
236
+ else if (!result.reachable) resolve("unreachable");
237
+ else resolve("ok");
238
+ },
239
+ onError: () => resolve("unreachable"),
240
+ },
241
+ );
242
+ }),
243
+ [probe],
244
+ );
245
+
246
+ const deriveHost = useCallback((value: string) => {
247
+ const offer = value.match(/#offer=([A-Za-z0-9_-]+)/);
248
+ if (offer && newName.trim().length === 0) {
249
+ try {
250
+ const payload = JSON.parse(Buffer.from(offer[1], "base64").toString("utf8"));
251
+ if (typeof payload.serverId === "string") setNewName(payload.serverId);
252
+ } catch { /* ignore */ }
253
+ }
254
+ }, [newName]);
255
+
256
+ const handleAdd = useCallback(() => {
257
+ const name = newName.trim();
258
+ const value = newValue.trim();
259
+ if (!name || !value) return;
260
+ setAdding(true);
261
+ probeValue(value).then((outcome) => {
262
+ if (outcome === "invalid") {
263
+ setAddProbe({ value, error: "not a valid host form" });
264
+ setAdding(false);
265
+ return;
266
+ }
267
+ if (outcome === "unreachable") {
268
+ setAddProbe({ value, error: null }); // inline confirm shown at the add row
269
+ setAdding(false);
270
+ return;
271
+ }
272
+ setAddProbe(null);
273
+ add.mutate({ name, value }, { onSuccess: applyResult, onSettled: () => setAdding(false) });
274
+ });
275
+ }, [add, newName, newValue, applyResult, probeValue]);
276
+
277
+ const confirmAddAnyway = useCallback(() => {
278
+ const value = newValue.trim();
279
+ const name = newName.trim();
280
+ if (!name || !value) return;
281
+ setAddProbe(null);
282
+ add.mutate({ name, value }, { onSuccess: applyResult });
283
+ }, [add, newName, newValue, applyResult]);
284
+
285
+ const handleSave = useCallback(
286
+ (originalName: string) => {
287
+ const draft = edits[originalName];
288
+ if (!draft) return;
289
+ const rename = draft.name.trim() !== originalName ? draft.name.trim() : undefined;
290
+ const value = draft.value.trim() !== "" ? draft.value.trim() : undefined;
291
+ if (!rename && !value) {
292
+ // Nothing changed; just close the editor.
293
+ setEditing((prev) => { const next = new Set(prev); next.delete(originalName); return next; });
294
+ return;
295
+ }
296
+ const targetValue = value ?? draft.value.trim();
297
+ setRowProbe((prev) => ({ ...prev, [originalName]: "pending" }));
298
+ probeValue(targetValue).then((outcome) => {
299
+ if (outcome === "ok") {
300
+ setRowProbe((prev) => { const next = { ...prev }; delete next[originalName]; return next; });
301
+ update.mutate(
302
+ { name: originalName, rename, value },
303
+ {
304
+ onSuccess: applyResult,
305
+ onSettled: () => {
306
+ setEdits((prev) => { const next = { ...prev }; delete next[originalName]; return next; });
307
+ setEditing((prev) => { const next = new Set(prev); next.delete(originalName); return next; });
308
+ },
309
+ },
310
+ );
311
+ return;
312
+ }
313
+ if (outcome === "unreachable") {
314
+ setRowProbe((prev) => ({ ...prev, [originalName]: { value: targetValue, error: null, saved: false } }));
315
+ return;
316
+ }
317
+ setRowProbe((prev) => ({ ...prev, [originalName]: { value: targetValue, error: "not a valid host form", saved: false } }));
318
+ });
319
+ },
320
+ [update, edits, applyResult, probeValue],
321
+ );
322
+
323
+ const confirmEditAnyway = useCallback(
324
+ (originalName: string) => {
325
+ const draft = edits[originalName];
326
+ if (!draft) return;
327
+ const rename = draft.name.trim() !== originalName ? draft.name.trim() : undefined;
328
+ const value = draft.value.trim() !== "" ? draft.value.trim() : undefined;
329
+ setRowProbe((prev) => { const next = { ...prev }; delete next[originalName]; return next; });
330
+ update.mutate(
331
+ { name: originalName, rename, value },
332
+ {
333
+ onSuccess: applyResult,
334
+ onSettled: () => {
335
+ setEdits((prev) => { const next = { ...prev }; delete next[originalName]; return next; });
336
+ setEditing((prev) => { const next = new Set(prev); next.delete(originalName); return next; });
337
+ },
338
+ },
339
+ );
340
+ },
341
+ [update, edits, applyResult],
342
+ );
343
+
344
+ const [pendingRemove, setPendingRemove] = useState<string | null>(null);
345
+
346
+ const handleRemove = useCallback(
347
+ (name: string) => {
348
+ setPendingRemove(name);
349
+ },
350
+ [],
351
+ );
352
+
353
+ const confirmRemove = useCallback(() => {
354
+ if (!pendingRemove) return;
355
+ remove.mutate({ name: pendingRemove }, { onSuccess: applyResult });
356
+ setPendingRemove(null);
357
+ }, [pendingRemove, remove, applyResult]);
358
+
359
+ const canAdd = newName.trim().length > 0 && newValue.trim().length > 0;
360
+
361
+ const handleRefresh = useCallback(() => {
362
+ setRefreshing(true);
363
+ void identitySync
364
+ .mutateAsync()
365
+ .then(() => snapshotRefresh.mutate())
366
+ .catch(() => snapshotRefresh.mutate());
367
+ }, [identitySync, snapshotRefresh]);
368
+
369
+ const setPrereqs = useCallback(
370
+ (collapsed: boolean) => {
371
+ setPrereqsCollapsed(collapsed);
372
+ prefsSet.mutate({ prereqsCollapsed: collapsed });
373
+ },
374
+ [prefsSet],
375
+ );
376
+
377
+ const healthByName = useMemo(() => {
378
+ const map: Record<string, { reachable: boolean; error: string | null; agentCount: number | null }> = {};
379
+ for (const result of health.data?.results ?? []) map[result.name] = result;
380
+ return map;
381
+ }, [health.data]);
382
+
383
+ const serverIdByName = useMemo(() => {
384
+ const map = new Map<string, string>();
385
+ for (const daemon of read.data?.daemons ?? []) {
386
+ if (daemon.serverId) map.set(daemon.name, daemon.serverId);
387
+ }
388
+ return map;
389
+ }, [read.data]);
390
+
391
+ const aliasByServerId = useMemo(() => {
392
+ const map = new Map<string, string>();
393
+ for (const daemon of read.data?.daemons ?? []) {
394
+ if (daemon.serverId) map.set(daemon.serverId, daemon.name);
395
+ }
396
+ return map;
397
+ }, [read.data]);
398
+
399
+ const peerLabelForName = useCallback(
400
+ (name: string) => formatPeerDisplay(name, serverIdByName.get(name) ?? null),
401
+ [serverIdByName],
402
+ );
403
+
404
+ const sendTargetLabel = useCallback(
405
+ (daemon: string) => {
406
+ if (daemon.startsWith("srv_")) return formatPeerDisplay(aliasByServerId.get(daemon) ?? null, daemon);
407
+ return formatPeerDisplay(daemon, serverIdByName.get(daemon) ?? null);
408
+ },
409
+ [aliasByServerId, serverIdByName],
410
+ );
411
+
412
+ const addMismatch =
413
+ newName.trim().length > 0 && newValue.trim().length > 0 && !newValue.includes("#offer=")
414
+ ? directHostMismatch(newName.trim(), newValue.trim())
415
+ : null;
416
+
417
+ const daemonCount = read.data?.daemons.length ?? 0;
418
+
419
+ const renderAgentPicker = (slot: 1 | 2) => {
420
+ const selected = slot === 1 ? introFirst : introSecond;
421
+ return (
422
+ <Modal
423
+ title={`Select agent ${slot}`}
424
+ open={expandedPicker === slot}
425
+ onOpenChange={(open) => { if (!open) setExpandedPicker(null); }}
426
+ >
427
+ <ModalContent>
428
+ {introspect.isPending ? <Notice tone="muted">Loading agents…</Notice> : null}
429
+ {introspect.error ? <Notice>{introspect.error.message}</Notice> : null}
430
+ {(introspect.data?.daemons ?? []).map((daemon) => (
431
+ <View key={daemon.name}>
432
+ <SectionHeader
433
+ title={daemon.reachable ? peerLabelForName(daemon.name) : `${peerLabelForName(daemon.name)} (unreachable)`}
434
+ badgeVariant={daemon.reachable ? "success" : "danger"}
435
+ />
436
+ {daemon.projects.map((project) => (
437
+ <View key={`${daemon.name}-${project.project}`}>
438
+ <Notice tone="muted">{project.project}</Notice>
439
+ {project.workspaces.map((workspace) => (
440
+ <View key={`${daemon.name}-${project.project}-${workspace.name}`}>
441
+ <Notice tone="muted">⌂ {workspace.name}</Notice>
442
+ {workspace.agents.map((agent) => {
443
+ const active = selected?.agentId === agent.agentId;
444
+ return (
445
+ <Button
446
+ key={agent.agentId}
447
+ size="sm"
448
+ variant={active ? "primary" : "secondary"}
449
+ icon={active ? "Check" : "Bot"}
450
+ label={`${agent.name} (${agent.shortId}) · ${agent.status}`}
451
+ style={{ alignSelf: "stretch", marginTop: 2 }}
452
+ onPress={() => {
453
+ const setSelected = slot === 1 ? setIntroFirst : setIntroSecond;
454
+ setSelected({ daemon: daemon.name, agentId: agent.agentId, shortId: agent.shortId, name: agent.name });
455
+ setExpandedPicker(null);
456
+ }}
457
+ />
458
+ );
459
+ })}
460
+ </View>
461
+ ))}
462
+ </View>
463
+ ))}
464
+ </View>
465
+ ))}
466
+ <ViaXComms theme={theme} />
467
+ </ModalContent>
468
+ </Modal>
469
+ );
470
+ };
471
+
472
+ const snapshot = dumpState as any;
473
+
474
+ return (
475
+ <View style={{ flex: 1, minHeight: 0, width: "100%", backgroundColor: colors.surface0 }}>
476
+ <ModalBody
477
+ headerMode="pinned"
478
+ maxContentWidth={X_COMMS_CONTENT_MAX_WIDTH}
479
+ header={
480
+ <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 8 }}>
481
+ <Text style={{ color: colors.foreground, fontSize: 20, fontWeight: "700" }}>X-comms</Text>
482
+ <Button
483
+ label="Refresh"
484
+ size="sm"
485
+ variant="secondary"
486
+ icon="RefreshCw"
487
+ loading={refreshing}
488
+ disabled={refreshing}
489
+ onPress={handleRefresh}
490
+ />
491
+ </View>
492
+ }
493
+ headerStyle={{ backgroundColor: colors.surface0, paddingHorizontal: 12, paddingTop: 12, paddingBottom: 6 }}
494
+ contentContainerStyle={{ gap: 12, paddingHorizontal: 12, paddingBottom: 24, paddingTop: 6 }}
495
+ refreshing={refreshing}
496
+ onRefresh={handleRefresh}
497
+ >
498
+ {refreshedAt ? <Notice tone="muted">Last refresh: {refreshedAt}</Notice> : null}
499
+
500
+ <Collapsible
501
+ title="Server"
502
+ icon="Server"
503
+ isExpanded={!prereqsCollapsed}
504
+ onToggle={(expanded) => setPrereqs(!expanded)}
505
+ >
506
+ <KeyValueGroup>
507
+ <KeyValue
508
+ label="Bundled server"
509
+ value={status.data ? status.data.installPath : "bundled server"}
510
+ mono
511
+ copyable={Boolean(status.data)}
512
+ truncate="path"
513
+ />
514
+ </KeyValueGroup>
515
+ </Collapsible>
516
+
517
+ <SectionHeader title="Registered daemons" count={daemonCount} />
518
+ {read.data?.registryPath ? (
519
+ <KeyValue label="Registry path" value={read.data.registryPath} mono copyable truncate="path" />
520
+ ) : null}
521
+ {read.isPending ? (
522
+ <Card>
523
+ <Card.Header title="Loading…" subtitle="Reading the daemon registry." />
524
+ </Card>
525
+ ) : null}
526
+ {!read.isPending && !read.data?.exists ? (
527
+ <EmptyState title="No registry file yet." description="Add your first daemon below." />
528
+ ) : null}
529
+ {read.data && !read.data.validJson ? (
530
+ <Card variant="elevated">
531
+ <Card.Header
532
+ title="Registry is not valid JSON"
533
+ subtitle={read.data.parseError ?? undefined}
534
+ badge={<Badge label="error" variant="danger" dot />}
535
+ />
536
+ </Card>
537
+ ) : null}
538
+ {health.isPending ? <Notice tone="muted">Checking health…</Notice> : null}
539
+
540
+ {read.data?.daemons.map((daemon) => {
541
+ const draft = edits[daemon.name] ?? { name: daemon.name, value: daemon.value };
542
+ const h = healthByName[daemon.name];
543
+ const probeState = rowProbe[daemon.name];
544
+ const probeDetail = probeState && probeState !== "pending" ? probeState : null;
545
+ if (editing.has(daemon.name)) {
546
+ return (
547
+ <Card key={daemon.name} variant="elevated">
548
+ <Card.Header title={`Edit ${peerLabelForName(daemon.name)}`} subtitle="Probe runs before saving." />
549
+ <FormRow label="Name" description="The daemon's real name; derived for relay links.">
550
+ <TextInput
551
+ mono
552
+ value={draft.name}
553
+ onChangeText={(text) => setEdits((prev) => ({ ...prev, [daemon.name]: { name: text, value: draft.value } }))}
554
+ autoCapitalize="none"
555
+ autoCorrect={false}
556
+ />
557
+ </FormRow>
558
+ <FormRow label="Host value" description={HOST_FORM_HINT}>
559
+ <TextInput
560
+ mono
561
+ value={draft.value}
562
+ onChangeText={(text) => setEdits((prev) => ({ ...prev, [daemon.name]: { name: draft.name, value: text } }))}
563
+ autoCapitalize="none"
564
+ autoCorrect={false}
565
+ />
566
+ </FormRow>
567
+ {probeState === "pending" ? <Notice tone="muted">Probing host…</Notice> : null}
568
+ {probeDetail ? (
569
+ <Notice>{probeDetail.error ?? `unreachable now: ${probeDetail.value}`}</Notice>
570
+ ) : null}
571
+ {probeDetail && !probeDetail.saved ? (
572
+ <ActionBar align="flex-start">
573
+ <Button label="Save anyway" size="sm" variant="danger" onPress={() => confirmEditAnyway(daemon.name)} />
574
+ <Button
575
+ label="Dismiss"
576
+ size="sm"
577
+ variant="secondary"
578
+ onPress={() => setRowProbe((prev) => { const next = { ...prev }; delete next[daemon.name]; return next; })}
579
+ />
580
+ </ActionBar>
581
+ ) : null}
582
+ <ActionBar align="flex-start">
583
+ <Button label="Save" size="sm" variant="primary" onPress={() => handleSave(daemon.name)} />
584
+ <Button
585
+ label="Cancel"
586
+ size="sm"
587
+ variant="ghost"
588
+ onPress={() => {
589
+ setEdits((prev) => { const next = { ...prev }; delete next[daemon.name]; return next; });
590
+ setEditing((prev) => { const next = new Set(prev); next.delete(daemon.name); return next; });
591
+ }}
592
+ />
593
+ </ActionBar>
594
+ </Card>
595
+ );
596
+ }
597
+ return (
598
+ <Card key={daemon.name} variant="elevated">
599
+ <Card.Header
600
+ title={peerLabelForName(daemon.name)}
601
+ subtitle={daemon.hostname && daemon.hostname !== daemon.name ? daemon.hostname : undefined}
602
+ badge={<HealthBadge health={h} />}
603
+ />
604
+ <KeyValue label="Host value" value={daemon.value} mono copyable truncate="end" truncateMaxLength={36} />
605
+ {h && !h.reachable ? (
606
+ <>
607
+ <Notice>{h.error ?? "unreachable"}</Notice>
608
+ <ActionBar align="flex-start">
609
+ <Button
610
+ label={expandedError.has(daemon.name) ? "Hide details" : "Details"}
611
+ size="sm"
612
+ variant="ghost"
613
+ onPress={() => setExpandedError((prev) => {
614
+ const next = new Set(prev);
615
+ if (next.has(daemon.name)) next.delete(daemon.name); else next.add(daemon.name);
616
+ return next;
617
+ })}
618
+ />
619
+ </ActionBar>
620
+ {expandedError.has(daemon.name) ? (
621
+ <KeyValue label="Error detail" value={h.error ?? "unreachable"} mono copyable />
622
+ ) : null}
623
+ </>
624
+ ) : null}
625
+ <ActionBar align="flex-start">
626
+ <Button
627
+ label="Edit"
628
+ size="sm"
629
+ variant="secondary"
630
+ onPress={() => {
631
+ setEdits((prev) => ({ ...prev, [daemon.name]: { name: daemon.name, value: daemon.value } }));
632
+ setEditing((prev) => new Set(prev).add(daemon.name));
633
+ }}
634
+ />
635
+ <Button
636
+ label="Debug"
637
+ size="sm"
638
+ variant="ghost"
639
+ onPress={() => { setDumpOpen(true); setDumpDaemon(daemon.name); dump.mutate({ daemon: daemon.name }); }}
640
+ />
641
+ <Button label="Remove" size="sm" variant="danger" onPress={() => handleRemove(daemon.name)} />
642
+ </ActionBar>
643
+ </Card>
644
+ );
645
+ })}
646
+
647
+ <SectionHeader title="Add daemon" />
648
+ <Card>
649
+ <FormRow label="Name" description="The daemon's real name; derived automatically for relay links.">
650
+ <TextInput value={newName} onChangeText={setNewName} autoCapitalize="none" autoCorrect={false} />
651
+ </FormRow>
652
+ <FormRow label="Host value" description={HOST_FORM_HINT}>
653
+ <TextInput
654
+ mono
655
+ value={newValue}
656
+ onChangeText={(text) => { setNewValue(text); deriveHost(text); }}
657
+ autoCapitalize="none"
658
+ autoCorrect={false}
659
+ placeholder={HOST_FORM_HINT}
660
+ errorText={addMismatch ?? undefined}
661
+ />
662
+ </FormRow>
663
+ {add.error ? <Notice>{add.error.message}</Notice> : null}
664
+ {adding ? <Notice tone="muted">Probing host…</Notice> : null}
665
+ {addProbe ? (
666
+ <>
667
+ <Notice>{addProbe.error ?? `unreachable now: ${addProbe.value}`}</Notice>
668
+ <ActionBar align="flex-start">
669
+ <Button label="Add anyway" size="sm" variant="danger" onPress={confirmAddAnyway} />
670
+ <Button label="Dismiss" size="sm" variant="secondary" onPress={() => setAddProbe(null)} />
671
+ </ActionBar>
672
+ </>
673
+ ) : null}
674
+ <ActionBar align="flex-start">
675
+ <Button
676
+ label="Add daemon"
677
+ variant="primary"
678
+ loading={add.isPending || adding}
679
+ disabled={!canAdd || add.isPending || adding}
680
+ onPress={handleAdd}
681
+ />
682
+ </ActionBar>
683
+ </Card>
684
+ {update.error ? <Notice>{update.error.message}</Notice> : null}
685
+ {remove.error ? <Notice>{remove.error.message}</Notice> : null}
686
+
687
+ <SectionHeader title="Introduce agents" />
688
+ <Card>
689
+ <FormRow label="Agents" description="Pick two agents on reachable daemons; a message is sent to both.">
690
+ {([1, 2] as const).map((slot) => {
691
+ const selected = slot === 1 ? introFirst : introSecond;
692
+ return (
693
+ <Button
694
+ key={`intro-${slot}`}
695
+ size="sm"
696
+ variant="secondary"
697
+ icon="Bot"
698
+ style={{ marginTop: 4, alignSelf: "stretch" }}
699
+ label={
700
+ selected
701
+ ? `${selected.name} (${selected.shortId}) on ${selected.daemon}`
702
+ : `Select agent ${slot}…`
703
+ }
704
+ onPress={() => setExpandedPicker(slot)}
705
+ />
706
+ );
707
+ })}
708
+ </FormRow>
709
+ <FormRow label="Message">
710
+ <TextInput
711
+ mono
712
+ multiline
713
+ numberOfLines={4}
714
+ value={introMessage}
715
+ onChangeText={setIntroMessage}
716
+ autoCapitalize="none"
717
+ autoCorrect={false}
718
+ inputStyle={{ minHeight: 90, textAlignVertical: "top" }}
719
+ />
720
+ </FormRow>
721
+ {introduce.error ? <Notice>{introduce.error.message}</Notice> : null}
722
+ {!introFirst || !introSecond ? (
723
+ <Notice tone="muted">Select both agents above to enable sending.</Notice>
724
+ ) : null}
725
+ <ActionBar align="flex-start">
726
+ <Button
727
+ label="Send introductions"
728
+ variant="primary"
729
+ loading={introduce.isPending}
730
+ disabled={!introFirst || !introSecond || introMessage.trim().length === 0 || introduce.isPending}
731
+ onPress={() =>
732
+ introduce.mutate({
733
+ first: { daemon: introFirst!.daemon, agentId: introFirst!.agentId, shortId: introFirst!.shortId, name: introFirst!.name },
734
+ second: { daemon: introSecond!.daemon, agentId: introSecond!.agentId, shortId: introSecond!.shortId, name: introSecond!.name },
735
+ message: introMessage,
736
+ })
737
+ }
738
+ />
739
+ </ActionBar>
740
+ {introduce.data ? (
741
+ <KeyValueGroup columns={1}>
742
+ {introduce.data.sends.map((send) => (
743
+ <KeyValue
744
+ key={send.agentId}
745
+ label={send.ok ? "Sent" : "Failed"}
746
+ value={`${sendTargetLabel(send.daemon)}/${send.agentId}${send.ok ? "" : `: ${send.error}`}`}
747
+ valueStyle={{ color: send.ok ? colors.statusSuccess : colors.statusDanger }}
748
+ mono
749
+ />
750
+ ))}
751
+ </KeyValueGroup>
752
+ ) : null}
753
+ </Card>
754
+
755
+ <ViaXComms theme={theme} />
756
+ </ModalBody>
757
+
758
+ <Modal
759
+ title={`Debug: ${dumpDaemon ?? ""}`}
760
+ open={dumpOpen}
761
+ onOpenChange={(open) => { if (!open) { setDumpOpen(false); setDumpState(null); } }}
762
+ >
763
+ <ModalContent>
764
+ <Card variant="elevated">
765
+ {dump.isPending && !dumpState ? (
766
+ <Card.Header title="Loading…" subtitle="Fetching daemon snapshot." />
767
+ ) : snapshot ? (
768
+ <>
769
+ <Card.Header
770
+ title={formatPeerDisplay(snapshot.name, snapshot.serverId)}
771
+ subtitle={`transport: ${String(snapshot.transport ?? "-")}`}
772
+ badge={<Badge label={snapshot.reached ? "reached" : "unreachable"} variant={snapshot.reached ? "success" : "danger"} dot />}
773
+ />
774
+ <KeyValueGroup>
775
+ {snapshot.error ? <KeyValue label="Error" value={snapshot.error} copyable mono /> : null}
776
+ {snapshot.hostname ? <KeyValue label="Hostname" value={snapshot.hostname} copyable mono /> : null}
777
+ {snapshot.version ? <KeyValue label="Version" value={`${snapshot.version}${snapshot.desktopManaged ? " (desktop-managed)" : ""}`} mono /> : null}
778
+ {snapshot.listen ? <KeyValue label="Listen" value={snapshot.listen} copyable mono /> : null}
779
+ {snapshot.pid ? <KeyValue label="PID" value={`${snapshot.pid}${snapshot.nodePath ? ` · node: ${snapshot.nodePath}` : ""}`} mono /> : null}
780
+ {snapshot.startedAt ? <KeyValue label="Started" value={snapshot.startedAt} mono /> : null}
781
+ <KeyValue
782
+ label="Relay"
783
+ value={snapshot.relayEnabled && snapshot.relayEndpoints?.length
784
+ ? `enabled ${snapshot.relayEndpoints.join(", ")}`
785
+ : "disabled"}
786
+ mono
787
+ />
788
+ </KeyValueGroup>
789
+ {snapshot.features ? (
790
+ <>
791
+ <SectionHeader title="Features" count={Object.entries(snapshot.features).filter(([, v]) => v).length} />
792
+ <KeyValueGroup columns={1}>
793
+ {Object.entries(snapshot.features).filter(([, v]) => v).map(([k]) => (
794
+ <KeyValue key={k} label={k} value="enabled" mono />
795
+ ))}
796
+ </KeyValueGroup>
797
+ </>
798
+ ) : null}
799
+ {snapshot.capabilities ? (
800
+ <>
801
+ <SectionHeader title="Capabilities" />
802
+ <KeyValueGroup columns={1}>
803
+ {Object.entries(snapshot.capabilities).map(([k, v]) => (
804
+ <KeyValue key={k} label={k} value={String(v)} mono />
805
+ ))}
806
+ </KeyValueGroup>
807
+ </>
808
+ ) : null}
809
+ <SectionHeader title="Agents" count={snapshot.agents?.length ?? 0} />
810
+ <KeyValueGroup columns={1}>
811
+ {(snapshot.agents ?? []).map((a: any) => (
812
+ <KeyValue
813
+ key={a.agentId}
814
+ label={`${a.status} ${a.name}`}
815
+ value={`(${a.shortId}) ${a.provider}${a.model ? `/${a.model}` : ""}${a.archived ? " [archived]" : ""}${a.cwd ? ` · ${a.cwd}` : ""}`}
816
+ mono
817
+ />
818
+ ))}
819
+ </KeyValueGroup>
820
+ <SectionHeader title="Workspaces" count={snapshot.workspaces?.length ?? 0} />
821
+ <KeyValueGroup columns={1}>
822
+ {(snapshot.workspaces ?? []).map((w: any) => (
823
+ <KeyValue key={w.id ?? w.name} label={`${w.project}/${w.name}`} value={`${w.isolation}${w.cwd ? ` · ${w.cwd}` : ""}`} mono />
824
+ ))}
825
+ </KeyValueGroup>
826
+ <SectionHeader title="Projects" count={snapshot.projects?.length ?? 0} />
827
+ <KeyValueGroup columns={1}>
828
+ {(snapshot.projects ?? []).map((p: any) => (
829
+ <KeyValue key={p.id ?? p.name} label={p.name} value={p.source ? String(p.source) : "-"} mono />
830
+ ))}
831
+ </KeyValueGroup>
832
+ <SectionHeader title="Providers" count={snapshot.providerCount ?? snapshot.providers?.length ?? 0} />
833
+ <KeyValueGroup columns={1}>
834
+ {(snapshot.providers ?? []).map((p: any) => (
835
+ <KeyValue key={String(p.provider)} label={String(p.provider)} value={p.available ? "ok" : `x${p.error ? ` · ${String(p.error)}` : ""}`} mono />
836
+ ))}
837
+ </KeyValueGroup>
838
+ <SectionHeader title="Terminals" count={snapshot.terminals?.length ?? 0} />
839
+ <KeyValueGroup columns={1}>
840
+ {(snapshot.terminals ?? []).map((t: any) => (
841
+ <KeyValue key={String(t.id ?? t.name)} label={String(t.name ?? t.id)} value={`${t.status ? String(t.status) : "-"}${t.cwd ? ` · ${String(t.cwd)}` : ""}`} mono />
842
+ ))}
843
+ </KeyValueGroup>
844
+ <SectionHeader title="Schedules" count={snapshot.schedules?.length ?? 0} />
845
+ <KeyValueGroup columns={1}>
846
+ {(snapshot.schedules ?? []).map((sched: any) => (
847
+ <KeyValue key={String(sched.id ?? sched.name)} label={String(sched.name)} value={String(sched.state)} mono />
848
+ ))}
849
+ </KeyValueGroup>
850
+ <SectionHeader title="Permissions" count={snapshot.permissions?.length ?? 0} />
851
+ <KeyValueGroup columns={1}>
852
+ {(snapshot.permissions ?? []).map((p: any) => (
853
+ <KeyValue key={String(p.id)} label={String(p.name)} value={`(${String(p.agentId).slice(0, 8)})`} mono />
854
+ ))}
855
+ </KeyValueGroup>
856
+ </>
857
+ ) : null}
858
+ </Card>
859
+ <ViaXComms theme={theme} />
860
+ </ModalContent>
861
+ </Modal>
862
+
863
+ {renderAgentPicker(1)}
864
+ {renderAgentPicker(2)}
865
+
866
+ <Modal
867
+ title="Remove daemon"
868
+ open={pendingRemove !== null}
869
+ onOpenChange={(open) => { if (!open) setPendingRemove(null); }}
870
+ >
871
+ <ModalContent>
872
+ <Text selectable style={{ color: colors.foreground, fontSize: 13 }}>
873
+ Remove '{pendingRemove}' from the registry?
874
+ </Text>
875
+ <ActionBar align="flex-start">
876
+ <Button label="Remove" variant="danger" onPress={confirmRemove} />
877
+ <Button label="Cancel" variant="secondary" onPress={() => setPendingRemove(null)} />
878
+ </ActionBar>
879
+ <ViaXComms theme={theme} />
880
+ </ModalContent>
881
+ </Modal>
882
+
883
+ </View>
884
+ );
885
+ }