@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,149 @@
1
+ import type { HostToast } from "../host";
2
+ import { getOptionalClientHost } from "../host";
3
+
4
+ export interface CopyToClipboardOptions {
5
+ toast?: HostToast;
6
+ toastMessage?: string;
7
+ }
8
+
9
+ export type ClipboardTier = "navigator" | "host" | "rnAsync" | "rnSync" | "execCommand";
10
+
11
+ export interface ClipboardEnvironment {
12
+ hasNavigatorClipboard: boolean;
13
+ hasHostCopyText: boolean;
14
+ hasRnClipboard: boolean;
15
+ hasRnSetStringAsync: boolean;
16
+ isDom: boolean;
17
+ }
18
+
19
+ /**
20
+ * Deterministic tier order for an explicit copy.
21
+ *
22
+ * Web's `navigator.clipboard.writeText` is the only API that rejects when the
23
+ * clipboard did not change, so it leads. The synchronous
24
+ * `react-native-web` `Clipboard.setString` reports success even when its
25
+ * `document.execCommand("copy")` fails, which leaves the previous clipboard
26
+ * item in place while the UI claims success (xpufx-org/paseo#278); it is only
27
+ * usable off-DOM (native), where it is the real platform clipboard. In a DOM
28
+ * the checked `execCommand` fallback is preferred to that unverifiable path.
29
+ */
30
+ export function clipboardTierOrder(env: ClipboardEnvironment): ClipboardTier[] {
31
+ const tiers: ClipboardTier[] = [];
32
+ if (env.hasNavigatorClipboard) tiers.push("navigator");
33
+ if (env.hasHostCopyText) tiers.push("host");
34
+ if (env.hasRnSetStringAsync) {
35
+ tiers.push("rnAsync");
36
+ } else if (env.hasRnClipboard && !env.isDom) {
37
+ tiers.push("rnSync");
38
+ }
39
+ tiers.push("execCommand");
40
+ return tiers;
41
+ }
42
+
43
+ /**
44
+ * Robust cross-platform clipboard copy helper for Paseo plugins.
45
+ * Works seamlessly across React Native (mobile), web, and desktop.
46
+ *
47
+ * Tier order comes from `clipboardTierOrder`; every tier reports failure
48
+ * honestly so a denied or blocked write never leaves the previous clipboard
49
+ * item behind under a fake success.
50
+ */
51
+ export async function copyToClipboard(
52
+ text: string,
53
+ options?: CopyToClipboardOptions,
54
+ ): Promise<boolean> {
55
+ if (text === null || text === undefined) return false;
56
+ const str = String(text);
57
+
58
+ const globalObj = typeof globalThis !== "undefined" ? (globalThis as any) : {};
59
+
60
+ let rn: any;
61
+ try {
62
+ rn = require("react-native");
63
+ } catch {
64
+ // Ignore require error if not in RN context
65
+ }
66
+ const rnClipboard = rn?.Clipboard;
67
+ const rnSetStringAsync =
68
+ typeof rnClipboard?.setStringAsync === "function" ? rnClipboard.setStringAsync : undefined;
69
+ const rnSetString =
70
+ typeof rnClipboard?.setString === "function" ? rnClipboard.setString : undefined;
71
+ const copyText = getOptionalClientHost()?.copyText;
72
+
73
+ const isDom =
74
+ typeof globalObj.document !== "undefined" &&
75
+ typeof globalObj.document?.createElement === "function";
76
+
77
+ const tiers = clipboardTierOrder({
78
+ hasNavigatorClipboard: Boolean(globalObj.navigator?.clipboard?.writeText),
79
+ hasHostCopyText: Boolean(copyText),
80
+ hasRnClipboard: Boolean(rnSetStringAsync ?? rnSetString),
81
+ hasRnSetStringAsync: Boolean(rnSetStringAsync),
82
+ isDom,
83
+ });
84
+
85
+ let success = false;
86
+ for (const tier of tiers) {
87
+ if (success) break;
88
+ try {
89
+ switch (tier) {
90
+ case "navigator":
91
+ await globalObj.navigator.clipboard.writeText(str);
92
+ success = true;
93
+ break;
94
+ case "host":
95
+ if (copyText) {
96
+ await copyText(str);
97
+ success = true;
98
+ }
99
+ break;
100
+ case "rnAsync":
101
+ // Expo's async API returns a boolean; a rejected promise falls through.
102
+ success = (await rnSetStringAsync(str)) !== false;
103
+ break;
104
+ case "rnSync":
105
+ // Only reached off-DOM, where this is the native platform clipboard.
106
+ rnSetString(str);
107
+ success = true;
108
+ break;
109
+ case "execCommand": {
110
+ const doc = globalObj.document;
111
+ if (doc?.createElement && doc?.body) {
112
+ const textarea = doc.createElement("textarea");
113
+ textarea.value = str;
114
+ textarea.style.position = "fixed";
115
+ textarea.style.opacity = "0";
116
+ textarea.style.left = "-9999px";
117
+ doc.body.appendChild(textarea);
118
+ textarea.focus();
119
+ textarea.select();
120
+ const res = doc.execCommand("copy");
121
+ doc.body.removeChild(textarea);
122
+ success = res === true;
123
+ }
124
+ break;
125
+ }
126
+ }
127
+ } catch {
128
+ // Tier failed; try the next one.
129
+ }
130
+ }
131
+
132
+ if (success && options?.toast) {
133
+ try {
134
+ const toastAny = options.toast as any;
135
+ if (typeof toastAny.copied === "function") {
136
+ toastAny.copied(options.toastMessage);
137
+ } else if (typeof toastAny.show === "function") {
138
+ const msg = options.toastMessage
139
+ ? `Copied ${options.toastMessage} to clipboard`
140
+ : "Copied to clipboard";
141
+ toastAny.show(msg, { variant: "success" });
142
+ }
143
+ } catch {
144
+ // Ignore toast failure
145
+ }
146
+ }
147
+
148
+ return success;
149
+ }
@@ -0,0 +1,34 @@
1
+ export type HapticFeedbackType =
2
+ | "light"
3
+ | "medium"
4
+ | "heavy"
5
+ | "success"
6
+ | "warning"
7
+ | "error";
8
+
9
+ const PATTERNS: Record<HapticFeedbackType, number | number[]> = {
10
+ light: 10,
11
+ medium: 25,
12
+ heavy: 45,
13
+ success: [15, 40, 20],
14
+ warning: [30, 50, 30],
15
+ error: [40, 60, 40, 60, 40],
16
+ };
17
+
18
+ /**
19
+ * Cross-platform haptic feedback helper for Paseo plugins.
20
+ * Supports web vibration API and graceful fallback when vibration is unavailable.
21
+ */
22
+ export function triggerHaptic(type: HapticFeedbackType = "light"): boolean {
23
+ try {
24
+ const globalObj = typeof globalThis !== "undefined" ? (globalThis as any) : {};
25
+ const nav = globalObj.navigator;
26
+ if (typeof nav?.vibrate === "function") {
27
+ const pattern = PATTERNS[type] ?? 15;
28
+ return Boolean(nav.vibrate(pattern));
29
+ }
30
+ } catch {
31
+ // Ignore environments where vibration permissions are denied or unavailable
32
+ }
33
+ return false;
34
+ }
@@ -0,0 +1,15 @@
1
+ import { Text } from "react-native";
2
+ import type { PluginTheme } from "@getpaseo/plugin";
3
+ import { XCOMMS_VIA_LABEL } from "./attribution";
4
+
5
+ /**
6
+ * Tiny muted source footer for every shared-surface row this plugin
7
+ * renders: timeline cards, panels, surfaces, modal content.
8
+ */
9
+ export function ViaXComms({ theme }: { theme: PluginTheme }) {
10
+ return (
11
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 10, textAlign: "right" as const }}>
12
+ {XCOMMS_VIA_LABEL}
13
+ </Text>
14
+ );
15
+ }
@@ -0,0 +1,449 @@
1
+ import { usePaseo, useRpc } from "@getpaseo/plugin/client";
2
+ import type { PluginTheme } from "@getpaseo/plugin";
3
+ import { Modal, ScrollView } from "@getpaseo/plugin/client/react-native";
4
+ import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
5
+ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6
+ import { ActivityIndicator, Clipboard, Pressable, Text, View } from "react-native";
7
+ import type { NativeScrollEvent, NativeSyntheticEvent, ScrollView as NativeScrollView, StyleProp, ViewStyle } from "react-native";
8
+ import { ModalContent, TextInput } from "paseo-plugin-helper/client";
9
+ import { conversationSendRpc, introspectAgentsRpc, registryReadRpc } from "../shared/registry";
10
+ import { deriveConversationThreads, deriveConversations, isCounterpartyMatch, mergeMessages, threadKeyForCounterparty, type ConversationMessage, type ConversationPartner, type ConversationThread } from "./conversations";
11
+ import { formatCounterparty, formatPeerDisplay, splitCounterparty, useCounterpartyLabel, usePeerDisplay, type CounterpartyRef } from "./peer-label";
12
+ import { ViaXComms } from "./via-x-comms";
13
+
14
+ const draftCache = new Map<string, string>();
15
+ const targetCache = new Map<string, ConversationPartner | null>();
16
+ const sentCache = new Map<string, Map<string, ConversationMessage[]>>();
17
+
18
+ function PeerText({ counterparty }: { counterparty: CounterpartyRef }) {
19
+ return <>{useCounterpartyLabel(counterparty)}</>;
20
+ }
21
+
22
+ /**
23
+ * Host ScrollView that sticks to the bottom as content appends. Tracks
24
+ * whether the user is near the bottom on every scroll; only auto-scrolls
25
+ * while stuck, so reading history never yanks. resetKey re-arms the stick
26
+ * (e.g. when switching threads).
27
+ */
28
+ function StickBottomScrollView({
29
+ style,
30
+ resetKey,
31
+ children,
32
+ }: {
33
+ style?: StyleProp<ViewStyle>;
34
+ resetKey: string;
35
+ children: React.ReactNode;
36
+ }) {
37
+ const ref = useRef<NativeScrollView | null>(null);
38
+ const stick = useRef(true);
39
+ useEffect(() => {
40
+ stick.current = true;
41
+ }, [resetKey]);
42
+ const onScroll = useCallback((e: NativeSyntheticEvent<NativeScrollEvent>) => {
43
+ const { layoutMeasurement, contentOffset, contentSize } = e.nativeEvent;
44
+ stick.current = layoutMeasurement.height + contentOffset.y >= contentSize.height - 24;
45
+ }, []);
46
+ const onContentSizeChange = useCallback(() => {
47
+ if (stick.current) ref.current?.scrollToEnd({ animated: true });
48
+ }, []);
49
+ return (
50
+ <ScrollView
51
+ ref={ref}
52
+ style={style}
53
+ onScroll={onScroll}
54
+ scrollEventThrottle={16}
55
+ onContentSizeChange={onContentSizeChange}
56
+ >
57
+ {children}
58
+ </ScrollView>
59
+ );
60
+ }
61
+
62
+ /**
63
+ * Reactive hook: refresh an agent's conversation queries whenever the agent
64
+ * store reports an upsert for it.
65
+ */
66
+ function useAgentConversationsRefresh(
67
+ paseo: ReturnType<typeof usePaseo>,
68
+ agentId: string,
69
+ queryClient: ReturnType<typeof useQueryClient>,
70
+ ): void {
71
+ useEffect(() => {
72
+ const unsub = paseo.agents.subscribe((update) => {
73
+ if (update.kind === "upsert" && update.agent.id === agentId) {
74
+ void queryClient.invalidateQueries({ queryKey: ["x-comms-conversations", agentId] });
75
+ void queryClient.invalidateQueries({ queryKey: ["x-comms-threads", agentId] });
76
+ }
77
+ });
78
+ return unsub;
79
+ }, [paseo, agentId, queryClient]);
80
+ }
81
+
82
+ export function CrossDaemonConversation({
83
+ theme,
84
+ agentId,
85
+ onSent,
86
+ }: {
87
+ theme: PluginTheme;
88
+ agentId: string;
89
+ onSent?: () => void;
90
+ }) {
91
+ const paseo = usePaseo();
92
+ const callSend = useRpc(conversationSendRpc);
93
+ const callIntrospect = useRpc(introspectAgentsRpc);
94
+ const callRegistryRead = useRpc(registryReadRpc);
95
+ const registry = useQuery({
96
+ queryKey: ["registry-read"],
97
+ queryFn: () => callRegistryRead({}),
98
+ staleTime: 300_000,
99
+ refetchOnWindowFocus: false,
100
+ });
101
+ const serverIdByName = useMemo(() => {
102
+ const map = new Map<string, string>();
103
+ for (const daemon of registry.data?.daemons ?? []) {
104
+ if (daemon.serverId) map.set(daemon.name, daemon.serverId);
105
+ }
106
+ return map;
107
+ }, [registry.data]);
108
+ const aliasByServerId = useMemo(() => {
109
+ const map = new Map<string, string>();
110
+ for (const daemon of registry.data?.daemons ?? []) {
111
+ if (daemon.serverId) map.set(daemon.serverId, daemon.name);
112
+ }
113
+ return map;
114
+ }, [registry.data]);
115
+ const peerLabelForCounterparty = useCallback(
116
+ (cp: CounterpartyRef) => {
117
+ const { alias, serverId } = splitCounterparty(cp);
118
+ const id = serverId ?? (alias ? serverIdByName.get(alias) ?? null : null);
119
+ const name = alias ?? (id ? aliasByServerId.get(id) ?? null : null);
120
+ return formatPeerDisplay(name, id);
121
+ },
122
+ [serverIdByName, aliasByServerId],
123
+ );
124
+ const peerLabelForName = useCallback(
125
+ (name: string) => formatPeerDisplay(name, serverIdByName.get(name) ?? null),
126
+ [serverIdByName],
127
+ );
128
+ const [draft, setDraft] = useState(() => draftCache.get(agentId) ?? "");
129
+ const [target, setTarget] = useState<ConversationPartner | null>(() => targetCache.get(agentId) ?? null);
130
+ const [pickerOpen, setPickerOpen] = useState(false);
131
+
132
+ const queryClient = useQueryClient();
133
+ const threads = useQuery({
134
+ queryKey: ["x-comms-threads", agentId],
135
+ queryFn: () => deriveConversationThreads(paseo, agentId),
136
+ refetchOnMount: "always",
137
+ });
138
+ const conversations = useQuery({
139
+ queryKey: ["x-comms-conversations", agentId],
140
+ queryFn: () => deriveConversations(paseo, agentId),
141
+ refetchOnMount: "always",
142
+ });
143
+ useAgentConversationsRefresh(paseo, agentId, queryClient);
144
+ const introspect = useQuery({
145
+ queryKey: ["x-comms-introspect"],
146
+ queryFn: () => callIntrospect({}),
147
+ staleTime: 30000,
148
+ });
149
+
150
+ const [lastSent, setLastSent] = useState<{ at: string; to: string } | null>(null);
151
+ const [sentTick, setSentTick] = useState(0);
152
+ const send = useMutation({
153
+ mutationFn: () =>
154
+ callSend({
155
+ daemon: target?.counterparty.daemonServerId ?? target?.counterparty.daemon ?? "",
156
+ agentId: target?.counterparty.agentId ?? "",
157
+ prompt: draft,
158
+ fromAgentId: agentId,
159
+ fromAgentName: "User",
160
+ }),
161
+ onSuccess: (data) => {
162
+ if (data.ok && target) {
163
+ const now = new Date().toISOString();
164
+ const msg: ConversationMessage = {
165
+ id: `sent-${now}-${Math.random().toString(36).slice(2, 6)}`,
166
+ body: draft,
167
+ sentAt: now,
168
+ isIncoming: false,
169
+ senderName: "You",
170
+ daemon: target.counterparty.daemon ?? target.counterparty.daemonServerId,
171
+ };
172
+ const byAgent = sentCache.get(agentId) ?? new Map<string, ConversationMessage[]>();
173
+ const list = byAgent.get(target.conversationId) ?? [];
174
+ byAgent.set(target.conversationId, [...list, msg]);
175
+ sentCache.set(agentId, byAgent);
176
+ setSentTick((x) => x + 1);
177
+ setLastSent({ at: new Date().toLocaleTimeString(), to: `${target.counterparty.agentName ?? target.counterparty.agentId} @ ${peerLabelForCounterparty(target.counterparty)}` });
178
+ setDraftCached("");
179
+ void queryClient.invalidateQueries({ queryKey: ["x-comms-conversations", agentId] });
180
+ void queryClient.invalidateQueries({ queryKey: ["x-comms-threads", agentId] });
181
+ }
182
+ onSent?.();
183
+ },
184
+ });
185
+
186
+ const setDraftCached = useCallback((v: string) => {
187
+ draftCache.set(agentId, v);
188
+ setDraft(v);
189
+ }, [agentId]);
190
+ const setTargetCached = useCallback((c: ConversationPartner | null) => {
191
+ targetCache.set(agentId, c);
192
+ setTarget(c);
193
+ }, [agentId]);
194
+ const pickTarget = useCallback((c: ConversationPartner) => setTargetCached(c), [setTargetCached]);
195
+ const pickPeer = useCallback((daemon: string, a: { agentId: string; shortId: string; name: string }) => {
196
+ // Resolve the alias to its server id so the picked target keys exactly
197
+ // like the envelope-derived thread. A bare alias key never matches the
198
+ // merge lookup and the incoming side silently drops.
199
+ const daemonServerId = serverIdByName.get(daemon) ?? null;
200
+ const counterparty = { daemon, agentId: a.agentId, agentName: a.name, daemonServerId };
201
+ setTargetCached({
202
+ conversationId: threadKeyForCounterparty(counterparty),
203
+ counterparty,
204
+ lastActivity: new Date().toISOString(),
205
+ messageCount: 0,
206
+ });
207
+ setPickerOpen(false);
208
+ }, [setTargetCached, serverIdByName]);
209
+
210
+ return (
211
+ <View style={{ padding: 12, flex: 1 }}>
212
+ <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", marginBottom: 8 }}>
213
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 13 }}>X-comms conversations</Text>
214
+ <Pressable onPress={() => setPickerOpen(true)} style={({ pressed }) => [{ padding: 6, borderRadius: 6, borderWidth: 1, borderColor: theme.colors.accent }, pressed && { opacity: 0.7 }]}>
215
+ <Text style={{ color: theme.colors.accent, fontSize: 12, fontWeight: "600" as const }}>New</Text>
216
+ </Pressable>
217
+ </View>
218
+ {conversations.isLoading ? (
219
+ <ActivityIndicator />
220
+ ) : conversations.data && conversations.data.length > 0 ? (
221
+ <StickBottomScrollView style={{ marginBottom: 12, maxHeight: 160 }} resetKey="threads">
222
+ {conversations.data.map((c) => (
223
+ <Pressable
224
+ key={c.conversationId}
225
+ onPress={() => pickTarget(c)}
226
+ style={({ pressed }) => [
227
+ {
228
+ padding: 10,
229
+ borderRadius: 6,
230
+ borderWidth: 1,
231
+ borderColor: target?.conversationId === c.conversationId ? theme.colors.accent : theme.colors.border,
232
+ marginBottom: 6,
233
+ },
234
+ pressed && { opacity: 0.7 },
235
+ ]}
236
+ >
237
+ <Text style={{ color: theme.colors.foreground, fontSize: 13, fontWeight: "600" as const }}>
238
+ {c.counterparty.agentName ?? c.counterparty.agentId ?? "unknown"}
239
+ </Text>
240
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12 }}>
241
+ <PeerText counterparty={c.counterparty} /> · {c.messageCount} msgs
242
+ </Text>
243
+ </Pressable>
244
+ ))}
245
+ </StickBottomScrollView>
246
+ ) : (
247
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 13, marginBottom: 8 }}>
248
+ No x-comms conversations yet - pick a peer to start.
249
+ </Text>
250
+ )}
251
+ {target ? (
252
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12, marginBottom: 8 }}>
253
+ To: {target.counterparty.agentName ?? target.counterparty.agentId} on <PeerText counterparty={target.counterparty} />
254
+ </Text>
255
+ ) : null}
256
+ {target ? (
257
+ (() => {
258
+ const thread = threads.data?.find(
259
+ (t: ConversationThread) =>
260
+ t.partner.conversationId === target.conversationId ||
261
+ isCounterpartyMatch(t.partner.counterparty, target.counterparty),
262
+ );
263
+ const byAgent = sentCache.get(agentId);
264
+ const cachedSent: ConversationMessage[] = [];
265
+ if (byAgent) {
266
+ for (const [cId, msgs] of byAgent.entries()) {
267
+ if (cId === target.conversationId || cId.endsWith(`/${target.counterparty.agentId}`)) {
268
+ cachedSent.push(...msgs);
269
+ }
270
+ }
271
+ }
272
+ const incoming = thread?.messages ?? [];
273
+ const all = mergeMessages(incoming, cachedSent);
274
+ if (all.length === 0) {
275
+ return (
276
+ <View style={{ padding: 14, borderWidth: 1, borderColor: theme.colors.border, borderRadius: 8, marginBottom: 10, backgroundColor: theme.colors.surface1, alignItems: "center" }}>
277
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12, fontStyle: "italic" as const }}>
278
+ No messages yet in this thread - send the first message below.
279
+ </Text>
280
+ </View>
281
+ );
282
+ }
283
+ return (
284
+ <StickBottomScrollView
285
+ style={{
286
+ maxHeight: 260,
287
+ minHeight: 120,
288
+ marginBottom: 10,
289
+ borderWidth: 1,
290
+ borderColor: theme.colors.border,
291
+ borderRadius: 8,
292
+ padding: 8,
293
+ backgroundColor: theme.colors.surface1 + "30",
294
+ }}
295
+ resetKey={target.conversationId}
296
+ >
297
+ {all.map((m) => {
298
+ const isIncoming = m.isIncoming;
299
+ const isUserSent = !isIncoming && (m.userSent ?? (m.senderName === "You"));
300
+
301
+ // Direction & color coding: red reserved for user-sent messages only
302
+ const bubbleBg = isIncoming
303
+ ? theme.colors.surface1
304
+ : isUserSent
305
+ ? (theme.colors.statusDanger + "1A")
306
+ : (theme.colors.accent + "1A");
307
+
308
+ const bubbleBorder = isIncoming
309
+ ? theme.colors.border
310
+ : isUserSent
311
+ ? theme.colors.statusDanger
312
+ : theme.colors.accent;
313
+
314
+ const markerColor = isIncoming
315
+ ? theme.colors.foregroundMuted
316
+ : isUserSent
317
+ ? theme.colors.statusDanger
318
+ : theme.colors.accent;
319
+
320
+ const markerText = isIncoming ? "[INCOMING · ✉]" : isUserSent ? "[USER-SENT · ✉]" : "[OUTGOING · ✉]";
321
+ const senderTitle = isIncoming ? (m.senderName ?? "Peer") : "You";
322
+
323
+ return (
324
+ <View
325
+ key={m.id}
326
+ style={{
327
+ marginBottom: 8,
328
+ maxWidth: "85%",
329
+ alignSelf: isIncoming ? ("flex-start" as const) : ("flex-end" as const),
330
+ backgroundColor: bubbleBg,
331
+ borderWidth: 1,
332
+ borderColor: bubbleBorder,
333
+ borderTopLeftRadius: isIncoming ? 2 : 12,
334
+ borderTopRightRadius: isIncoming ? 12 : 2,
335
+ borderBottomLeftRadius: 12,
336
+ borderBottomRightRadius: 12,
337
+ padding: 8,
338
+ shadowColor: "#000",
339
+ shadowOpacity: 0.05,
340
+ shadowOffset: { width: 0, height: 1 },
341
+ shadowRadius: 2,
342
+ }}
343
+ >
344
+ <View style={{ flexDirection: "row", alignItems: "center", justifyContent: "space-between", gap: 8, marginBottom: 4 }}>
345
+ <Text style={{ color: markerColor, fontSize: 10, fontWeight: "700" as const, letterSpacing: 0.5 }}>
346
+ {markerText}
347
+ </Text>
348
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 10 }}>
349
+ {senderTitle} · {new Date(m.sentAt).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", second: "2-digit" })}
350
+ </Text>
351
+ </View>
352
+ <Text style={{ color: theme.colors.foreground, fontSize: 13, lineHeight: 18 }} selectable>
353
+ {m.body}
354
+ </Text>
355
+ </View>
356
+ );
357
+ })}
358
+ </StickBottomScrollView>
359
+ );
360
+ })()
361
+ ) : null}
362
+
363
+ <TextInput
364
+ value={draft}
365
+ onChangeText={setDraftCached}
366
+ placeholder="Message the selected counterparty…"
367
+ style={{ marginBottom: 8 }}
368
+ inputStyle={{ fontSize: 13 }}
369
+ multiline
370
+ />
371
+ <Pressable
372
+ disabled={!target || !draft.trim() || send.isPending}
373
+ onPress={() => send.mutate()}
374
+ style={({ pressed }) => [
375
+ { padding: 10, borderRadius: 6, borderWidth: 1.5, borderColor: theme.colors.accent, alignSelf: "flex-start" as const, minHeight: 44, justifyContent: "center" as const },
376
+ pressed && { opacity: 0.7 },
377
+ ]}
378
+ >
379
+ <Text style={{ color: theme.colors.accent, fontWeight: "600" as const }}>
380
+ {send.isPending ? "Sending…" : "Send"}
381
+ </Text>
382
+ </Pressable>
383
+ {send.isSuccess && send.data?.ok ? (
384
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8, marginTop: 6 }}>
385
+ <Text style={{ color: theme.colors.statusSuccess, fontSize: 12, flexShrink: 1 }}>
386
+ ✓ Sent to {lastSent?.to ?? target?.counterparty.agentName ?? target?.counterparty.agentId} at {lastSent?.at ?? ""}
387
+ </Text>
388
+ <Pressable
389
+ onPress={() => {
390
+ setLastSent(null);
391
+ send.reset();
392
+ }}
393
+ hitSlop={10}
394
+ >
395
+ <Text style={{ color: theme.colors.accent, fontSize: 12, paddingHorizontal: 6 }}>Dismiss</Text>
396
+ </Pressable>
397
+ </View>
398
+ ) : null}
399
+ {send.error ? (
400
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8, marginTop: 6 }}>
401
+ <Text style={{ color: theme.colors.statusDanger, fontSize: 12, flexShrink: 1 }}>{String(send.error)}</Text>
402
+ <Pressable onPress={() => void Clipboard.setString(String(send.error))} hitSlop={10}>
403
+ <Text style={{ color: theme.colors.accent, fontSize: 16, paddingHorizontal: 6 }}>⧉</Text>
404
+ </Pressable>
405
+ </View>
406
+ ) : null}
407
+ {send.data && !send.data.ok ? (
408
+ <View style={{ flexDirection: "row", alignItems: "center", gap: 8, marginTop: 6 }}>
409
+ <Text style={{ color: theme.colors.statusDanger, fontSize: 12, flexShrink: 1 }}>{send.data.error}</Text>
410
+ <Pressable onPress={() => void Clipboard.setString(String(send.data?.error ?? ""))} hitSlop={10}>
411
+ <Text style={{ color: theme.colors.accent, fontSize: 16, paddingHorizontal: 6 }}>⧉</Text>
412
+ </Pressable>
413
+ </View>
414
+ ) : null}
415
+ <Modal title="New conversation" open={pickerOpen} onOpenChange={setPickerOpen}>
416
+ <ModalContent>
417
+ {introspect.isPending ? <Text style={{ color: theme.colors.foregroundMuted, fontSize: 13 }}>Loading agents…</Text> : null}
418
+ {introspect.error ? <Text style={{ color: theme.colors.statusDanger, fontSize: 12 }}>{String(introspect.error)}</Text> : null}
419
+ <View>
420
+ {(introspect.data?.daemons ?? []).map((daemon) => (
421
+ <View key={daemon.name}>
422
+ <Text style={{ color: daemon.reachable ? theme.colors.accent : theme.colors.foregroundMuted, fontSize: 12, fontWeight: "700" as const, marginTop: 10, textTransform: "uppercase" as const }}>
423
+ {daemon.reachable ? peerLabelForName(daemon.name) : `${peerLabelForName(daemon.name)} (unreachable)`}
424
+ </Text>
425
+ {daemon.projects.map((project) => (
426
+ <View key={`${daemon.name}-${project.project}`}>
427
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 11, marginTop: 6, paddingLeft: 10 }}>{project.project}</Text>
428
+ {project.workspaces.map((ws) => (
429
+ <View key={`${daemon.name}-${project.project}-${ws.name}`}>
430
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12, marginTop: 4, paddingLeft: 20 }}>⌂ {ws.name}</Text>
431
+ {ws.agents.map((a) => (
432
+ <Pressable key={a.agentId} onPress={() => pickPeer(daemon.name, a)} style={({ pressed }) => [{ flexDirection: "row", alignItems: "center", paddingVertical: 6, paddingLeft: 30 }, pressed && { opacity: 0.7 }]}>
433
+ <Text style={{ color: theme.colors.foreground, fontSize: 13, flexShrink: 1 }}>{a.name} ({a.shortId}) · {a.status}</Text>
434
+ </Pressable>
435
+ ))}
436
+ </View>
437
+ ))}
438
+ </View>
439
+ ))}
440
+ </View>
441
+ ))}
442
+ </View>
443
+ <ViaXComms theme={theme} />
444
+ </ModalContent>
445
+ </Modal>
446
+ <ViaXComms theme={theme} />
447
+ </View>
448
+ );
449
+ }
@@ -0,0 +1,6 @@
1
+ import { type PluginAgentPanelProps } from "@getpaseo/plugin/client";
2
+ import { CrossDaemonConversation } from "./x-comms-conversation";
3
+
4
+ export function CrossDaemonPanel({ theme, agentId }: PluginAgentPanelProps) {
5
+ return <CrossDaemonConversation theme={theme} agentId={agentId} />;
6
+ }