@xpufx/paseo-x-comms 0.3.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > **⚠️ WIP — use at your own risk.** Not release-ready; APIs and behavior may change without notice.
4
4
 
5
- > Tracks stable paseo (`@getpaseo/* 0.8.0`, manifest requires `paseo >= 0.8.0`). Expect breaking changes between versions.
5
+ > Tracks Paseo 0.9 (`@getpaseo/* 0.9.0-beta.2`, manifest requires `paseo >= 0.9.0-beta.2`) for multi-host APIs. Expect breaking changes between versions.
6
6
 
7
7
  [paseo](https://paseo.sh) is an agent orchestrator: AI coding agents run on paseo daemons, each managing workspaces, tools, and permissions. **paseo-x-comms** lets agents on one daemon talk to agents on another — even across hosts — via the daemon relay (WebSocket + E2EE) or direct TCP.
8
8
 
@@ -36,7 +36,7 @@ The plugin lives at the repo root (`paseo-plugin.json` id `x-comms`):
36
36
  paseo plugin add xpufx/paseo-x-comms
37
37
  ```
38
38
 
39
- The plugin requires **npm** and **Node.js** (v18+) in `$PATH`: the server bundle uses `@getpaseo/client` and `@getpaseo/protocol`, so `paseo-plugin.json` declares `npm install` as its install build command. `paseo-plugin-helper` is vendored under `client/`, `server/`, `shared/` and bundled from source. The MCP server is spawned from `./mcp` and resolves its deps (`@modelcontextprotocol/sdk`, `zod`, etc.) from the installed tree.
39
+ The plugin requires **npm** and **Node.js** (v18+) in `$PATH`, plus Paseo **0.9.0-beta.2 or later**: the server bundle uses `@getpaseo/client` and `@getpaseo/protocol`, so `paseo-plugin.json` declares its production-only, lifecycle-disabled `npm install` build command. `paseo-plugin-helper` is vendored under `client/`, `server/`, `shared/` and bundled from source. The MCP server is spawned from `./mcp` and resolves its deps (`@modelcontextprotocol/sdk`, `zod`, etc.) from the installed tree.
40
40
 
41
41
  To update:
42
42
 
@@ -89,8 +89,8 @@ A held message expires after **10 minutes** by default (configurable in the sett
89
89
 
90
90
  ```
91
91
  .
92
- ├── index.client.tsx # Paseo 0.8 client entry (surfaces, pill, panel, timeline renderers)
93
- ├── index.server.ts # Paseo 0.8 server entry (RPC + presence + injection handlers)
92
+ ├── index.client.tsx # Paseo 0.9 client entry (surfaces, pill, panel, timeline renderers)
93
+ ├── index.server.ts # Paseo 0.9 server entry (RPC + presence + injection handlers)
94
94
  ├── client/
95
95
  │ ├── main.tsx # Main surface (daemon registry + health + prompt)
96
96
  │ ├── x-comms-pill.tsx # Composer pill → conversation panel
@@ -120,7 +120,7 @@ A held message expires after **10 minutes** by default (configurable in the sett
120
120
  │ ├── paseo-x-comms.mjs # MCP server (also bin `paseo-x-comms`)
121
121
  │ ├── README.md # standalone server docs
122
122
  │ └── test/protocol.test.mjs
123
- ├── paseo-plugin.json # id x-comms, requires paseo >= 0.8.0
123
+ ├── paseo-plugin.json # id x-comms, requires paseo >= 0.9.0-beta.2
124
124
  └── package.json # single install at root for plugin + server
125
125
  ```
126
126
 
@@ -0,0 +1,64 @@
1
+ import type { PaseoApi } from "@getpaseo/client";
2
+ import type { PluginHostSummary } from "@getpaseo/plugin/client";
3
+
4
+ export interface ConfiguredHostAgent {
5
+ serverId: string;
6
+ hostLabel: string;
7
+ agentId: string;
8
+ name: string;
9
+ status: string | null;
10
+ }
11
+
12
+ export interface ConfiguredHostAgents {
13
+ host: PluginHostSummary;
14
+ agents: ConfiguredHostAgent[];
15
+ error: string | null;
16
+ }
17
+
18
+ /** A host/agent pair is the fleet identity; agent ids are host-local. */
19
+ export function configuredHostAgentKey(serverId: string, agentId: string): string {
20
+ return `${serverId}/${agentId}`;
21
+ }
22
+
23
+ /**
24
+ * Query only currently online configured hosts. Each call obtains a fresh
25
+ * borrowed API; callers must discard the result after a host-status change.
26
+ */
27
+ export async function listConfiguredHostAgents(
28
+ hosts: readonly PluginHostSummary[],
29
+ getClient: (serverId: string) => PaseoApi,
30
+ ): Promise<ConfiguredHostAgents[]> {
31
+ return Promise.all(hosts.map(async (host) => {
32
+ if (host.status !== "online") return { host, agents: [], error: null };
33
+ try {
34
+ const result = await getClient(host.serverId).agents.list();
35
+ return {
36
+ host,
37
+ agents: result.entries.map(({ agent }) => ({
38
+ serverId: host.serverId,
39
+ hostLabel: host.label,
40
+ agentId: agent.id,
41
+ name: agent.title ?? agent.id,
42
+ status: agent.status ?? null,
43
+ })),
44
+ error: null,
45
+ };
46
+ } catch (cause) {
47
+ return { host, agents: [], error: cause instanceof Error ? cause.message : String(cause) };
48
+ }
49
+ }));
50
+ }
51
+
52
+ /**
53
+ * Native configured-host send. This deliberately gets exactly one freshly
54
+ * borrowed target handle and sends to that selected agent only—never a peer
55
+ * registry route and never a broadcast.
56
+ */
57
+ export async function sendConfiguredHostAgent(args: {
58
+ serverId: string;
59
+ agentId: string;
60
+ message: string;
61
+ getClient: (serverId: string) => PaseoApi;
62
+ }): Promise<void> {
63
+ await args.getClient(args.serverId).agents.ref(args.agentId).send(args.message);
64
+ }
package/client/main.tsx CHANGED
@@ -19,7 +19,7 @@ import {
19
19
  Tabs,
20
20
  TextInput,
21
21
  usePluginTheme,
22
- } from "paseo-plugin-helper/client";
22
+ } from "./vendor/paseo-plugin-helper/index";
23
23
  import { formatPeerDisplay } from "./peer-label";
24
24
  import { PeerStatusSurface } from "./peer-status";
25
25
  import { SettingsPrototype } from "./settings-prototype";
@@ -13,7 +13,7 @@ import {
13
13
  StatusDot,
14
14
  usePluginTheme,
15
15
  useRpcQuery,
16
- } from "paseo-plugin-helper/client";
16
+ } from "./vendor/paseo-plugin-helper/index";
17
17
  import { formatPeerDisplay } from "./peer-label";
18
18
  import { ViaXComms } from "./via-x-comms";
19
19
  import { peerStatusRpc } from "../shared/registry";
@@ -16,7 +16,7 @@ import {
16
16
  StatusDot,
17
17
  TextInput,
18
18
  Toggle,
19
- } from "paseo-plugin-helper/client";
19
+ } from "./vendor/paseo-plugin-helper/index";
20
20
  import { formatPeerDisplay } from "./peer-label";
21
21
  import { ViaXComms } from "./via-x-comms";
22
22
  import {
@@ -24,16 +24,23 @@ export interface ClipboardEnvironment {
24
24
  * `react-native-web` `Clipboard.setString` reports success even when its
25
25
  * `document.execCommand("copy")` fails, which leaves the previous clipboard
26
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.
27
+ * usable off-DOM (native), where it is the real platform clipboard. The same
28
+ * applies to any `setStringAsync` whose DOM fallback is that unverified
29
+ * `execCommand`. In a DOM the checked `execCommand` fallback is preferred to
30
+ * those unverifiable paths.
29
31
  */
30
32
  export function clipboardTierOrder(env: ClipboardEnvironment): ClipboardTier[] {
31
33
  const tiers: ClipboardTier[] = [];
32
34
  if (env.hasNavigatorClipboard) tiers.push("navigator");
33
35
  if (env.hasHostCopyText) tiers.push("host");
34
- if (env.hasRnSetStringAsync) {
36
+ // On a DOM both RN-web paths are unverifiable: `setString` reports success
37
+ // even when its `execCommand` no-ops, and a `setStringAsync` that falls back
38
+ // to it does the same. Off-DOM they are the real native clipboard, so they
39
+ // lead there; on a DOM only the checked `execCommand` fallback is honest.
40
+ const rnDomOk = !env.isDom;
41
+ if (env.hasRnSetStringAsync && rnDomOk) {
35
42
  tiers.push("rnAsync");
36
- } else if (env.hasRnClipboard && !env.isDom) {
43
+ } else if (env.hasRnClipboard && rnDomOk) {
37
44
  tiers.push("rnSync");
38
45
  }
39
46
  tiers.push("execCommand");
@@ -1,18 +1,22 @@
1
- import { usePaseo, useRpc } from "@getpaseo/plugin/client";
1
+ import { getPaseoClient, useHosts, usePaseo, useRpc } from "@getpaseo/plugin/client";
2
2
  import type { PluginTheme } from "@getpaseo/plugin";
3
3
  import { Modal, ScrollView } from "@getpaseo/plugin/client/react-native";
4
4
  import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
5
5
  import { useCallback, useEffect, useMemo, useRef, useState } from "react";
6
6
  import { ActivityIndicator, Clipboard, Pressable, Text, View } from "react-native";
7
7
  import type { NativeScrollEvent, NativeSyntheticEvent, ScrollView as NativeScrollView, StyleProp, ViewStyle } from "react-native";
8
- import { ModalContent, TextInput } from "paseo-plugin-helper/client";
8
+ import { ModalContent, TextInput } from "./vendor/paseo-plugin-helper/index";
9
+ import { buildXCommsEnvelope } from "../shared/envelope";
9
10
  import { conversationSendRpc, introspectAgentsRpc, registryReadRpc } from "../shared/registry";
10
11
  import { deriveConversationThreads, deriveConversations, isCounterpartyMatch, mergeMessages, threadKeyForCounterparty, type ConversationMessage, type ConversationPartner, type ConversationThread } from "./conversations";
12
+ import { listConfiguredHostAgents, sendConfiguredHostAgent } from "./configured-hosts";
11
13
  import { formatCounterparty, formatPeerDisplay, splitCounterparty, useCounterpartyLabel, usePeerDisplay, type CounterpartyRef } from "./peer-label";
12
14
  import { ViaXComms } from "./via-x-comms";
13
15
 
14
16
  const draftCache = new Map<string, string>();
15
- const targetCache = new Map<string, ConversationPartner | null>();
17
+ type SelectedTarget = ConversationPartner & { configuredHostServerId?: string };
18
+
19
+ const targetCache = new Map<string, SelectedTarget | null>();
16
20
  const sentCache = new Map<string, Map<string, ConversationMessage[]>>();
17
21
 
18
22
  function PeerText({ counterparty }: { counterparty: CounterpartyRef }) {
@@ -89,6 +93,9 @@ export function CrossDaemonConversation({
89
93
  onSent?: () => void;
90
94
  }) {
91
95
  const paseo = usePaseo();
96
+ // This hook is deliberately confined to the mounted client surface. Server,
97
+ // MCP, outbox, and peer-channel code retain their existing routes.
98
+ const hosts = useHosts();
92
99
  const callSend = useRpc(conversationSendRpc);
93
100
  const callIntrospect = useRpc(introspectAgentsRpc);
94
101
  const callRegistryRead = useRpc(registryReadRpc);
@@ -126,7 +133,7 @@ export function CrossDaemonConversation({
126
133
  [serverIdByName],
127
134
  );
128
135
  const [draft, setDraft] = useState(() => draftCache.get(agentId) ?? "");
129
- const [target, setTarget] = useState<ConversationPartner | null>(() => targetCache.get(agentId) ?? null);
136
+ const [target, setTarget] = useState<SelectedTarget | null>(() => targetCache.get(agentId) ?? null);
130
137
  const [pickerOpen, setPickerOpen] = useState(false);
131
138
 
132
139
  const queryClient = useQueryClient();
@@ -146,18 +153,48 @@ export function CrossDaemonConversation({
146
153
  queryFn: () => callIntrospect({}),
147
154
  staleTime: 30000,
148
155
  });
156
+ const hostStatusKey = useMemo(
157
+ () => hosts.map((host) => [host.serverId, host.status] as const),
158
+ [hosts],
159
+ );
160
+ const configuredHosts = useQuery({
161
+ queryKey: ["x-comms-configured-hosts", hostStatusKey],
162
+ // getPaseoClient is called only in this query, when the host is online.
163
+ // A status transition changes the query key and drops the old borrowed API.
164
+ queryFn: () => listConfiguredHostAgents(hosts, getPaseoClient),
165
+ refetchOnWindowFocus: false,
166
+ });
149
167
 
150
168
  const [lastSent, setLastSent] = useState<{ at: string; to: string } | null>(null);
151
169
  const [sentTick, setSentTick] = useState(0);
152
170
  const send = useMutation({
153
- mutationFn: () =>
154
- callSend({
155
- daemon: target?.counterparty.daemonServerId ?? target?.counterparty.daemon ?? "",
156
- agentId: target?.counterparty.agentId ?? "",
171
+ mutationFn: async () => {
172
+ if (!target) throw new Error("Choose a target before sending.");
173
+ if (target.configuredHostServerId) {
174
+ // Acquire immediately before send. A configured host can disconnect or
175
+ // release an earlier borrowed API while this surface remains mounted.
176
+ const stamped = `${buildXCommsEnvelope({
177
+ sender: { agentId, agentName: "User", host: "paseo-client", daemonServerId: null, cwd: null },
178
+ target: { daemon: target.configuredHostServerId, agentId: target.counterparty.agentId },
179
+ sentAt: new Date().toISOString(),
180
+ })}\n\n${draft}`;
181
+ await sendConfiguredHostAgent({
182
+ serverId: target.configuredHostServerId,
183
+ agentId: target.counterparty.agentId ?? "",
184
+ message: stamped,
185
+ getClient: getPaseoClient,
186
+ });
187
+ return { ok: true, error: null };
188
+ }
189
+ // Existing registry/MCP relay and direct-peer route stays exactly here.
190
+ return callSend({
191
+ daemon: target.counterparty.daemonServerId ?? target.counterparty.daemon ?? "",
192
+ agentId: target.counterparty.agentId ?? "",
157
193
  prompt: draft,
158
194
  fromAgentId: agentId,
159
195
  fromAgentName: "User",
160
- }),
196
+ });
197
+ },
161
198
  onSuccess: (data) => {
162
199
  if (data.ok && target) {
163
200
  const now = new Date().toISOString();
@@ -187,7 +224,7 @@ export function CrossDaemonConversation({
187
224
  draftCache.set(agentId, v);
188
225
  setDraft(v);
189
226
  }, [agentId]);
190
- const setTargetCached = useCallback((c: ConversationPartner | null) => {
227
+ const setTargetCached = useCallback((c: SelectedTarget | null) => {
191
228
  targetCache.set(agentId, c);
192
229
  setTarget(c);
193
230
  }, [agentId]);
@@ -206,6 +243,22 @@ export function CrossDaemonConversation({
206
243
  });
207
244
  setPickerOpen(false);
208
245
  }, [setTargetCached, serverIdByName]);
246
+ const pickConfiguredHostAgent = useCallback((host: { serverId: string }, selectedAgent: { agentId: string; name: string }) => {
247
+ const counterparty = {
248
+ daemon: host.serverId,
249
+ daemonServerId: host.serverId,
250
+ agentId: selectedAgent.agentId,
251
+ agentName: selectedAgent.name,
252
+ };
253
+ setTargetCached({
254
+ conversationId: threadKeyForCounterparty(counterparty),
255
+ counterparty,
256
+ lastActivity: new Date().toISOString(),
257
+ messageCount: 0,
258
+ configuredHostServerId: host.serverId,
259
+ });
260
+ setPickerOpen(false);
261
+ }, [setTargetCached]);
209
262
 
210
263
  return (
211
264
  <View style={{ padding: 12, flex: 1 }}>
@@ -414,6 +467,39 @@ export function CrossDaemonConversation({
414
467
  ) : null}
415
468
  <Modal title="New conversation" open={pickerOpen} onOpenChange={setPickerOpen}>
416
469
  <ModalContent>
470
+ <View>
471
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12, fontWeight: "700" as const, marginTop: 4, textTransform: "uppercase" as const }}>
472
+ Configured hosts
473
+ </Text>
474
+ {hosts.map((host) => {
475
+ const hostAgents = configuredHosts.data?.find((entry) => entry.host.serverId === host.serverId);
476
+ const unavailable = host.status !== "online";
477
+ return (
478
+ <View key={host.serverId}>
479
+ <Text style={{ color: unavailable ? theme.colors.foregroundMuted : theme.colors.accent, fontSize: 12, fontWeight: "700" as const, marginTop: 10 }}>
480
+ {host.label} ({host.serverId}) · {host.status}{unavailable ? " (unavailable)" : ""}
481
+ </Text>
482
+ {host.status === "online" && configuredHosts.isPending ? (
483
+ <Text style={{ color: theme.colors.foregroundMuted, fontSize: 12, paddingLeft: 10 }}>Loading agents…</Text>
484
+ ) : null}
485
+ {hostAgents?.error ? (
486
+ <Text style={{ color: theme.colors.statusDanger, fontSize: 12, paddingLeft: 10 }}>Unavailable: {hostAgents.error}</Text>
487
+ ) : null}
488
+ {hostAgents?.agents.map((configuredAgent) => (
489
+ <Pressable
490
+ key={`${configuredAgent.serverId}/${configuredAgent.agentId}`}
491
+ onPress={() => pickConfiguredHostAgent(host, configuredAgent)}
492
+ style={({ pressed }) => [{ flexDirection: "row", alignItems: "center", paddingVertical: 6, paddingLeft: 10 }, pressed && { opacity: 0.7 }]}
493
+ >
494
+ <Text style={{ color: theme.colors.foreground, fontSize: 13, flexShrink: 1 }}>
495
+ {configuredAgent.name} ({configuredAgent.agentId}){configuredAgent.status ? ` · ${configuredAgent.status}` : ""}
496
+ </Text>
497
+ </Pressable>
498
+ ))}
499
+ </View>
500
+ );
501
+ })}
502
+ </View>
417
503
  {introspect.isPending ? <Text style={{ color: theme.colors.foregroundMuted, fontSize: 13 }}>Loading agents…</Text> : null}
418
504
  {introspect.error ? <Text style={{ color: theme.colors.statusDanger, fontSize: 12 }}>{String(introspect.error)}</Text> : null}
419
505
  <View>
@@ -15,7 +15,7 @@ import {
15
15
  type ComposerPillRegistrar,
16
16
  type RenderModalProps,
17
17
  type RenderPillProps,
18
- } from "paseo-plugin-helper/client";
18
+ } from "./vendor/paseo-plugin-helper/index";
19
19
  import { useEffect, useMemo, useState } from "react";
20
20
  import { Text } from "react-native";
21
21
  import { CrossDaemonConversation } from "./x-comms-conversation";
@@ -1,6 +1,6 @@
1
1
  import { type PluginTimelineItemProps, type PluginTimelineTransformerContribution, type PluginTimelineRendererContribution } from "@getpaseo/plugin/client";
2
2
  import { Icon } from "@getpaseo/plugin/client/react-native";
3
- import { Badge, Card, CodeBlock, StatusDot } from "paseo-plugin-helper/client";
3
+ import { Badge, Card, CodeBlock, StatusDot } from "./vendor/paseo-plugin-helper/index";
4
4
  import { Text, View } from "react-native";
5
5
  import { ViaXComms } from "./via-x-comms";
6
6
  import { usePeerDisplay } from "./peer-label";
@@ -0,0 +1,78 @@
1
+ import type { PluginClientContext } from "@getpaseo/plugin/client";
2
+ import { useRpc } from "@getpaseo/plugin/client";
3
+ import { Icon, Modal, useToast, ScrollView, FlatList, TextInput as HostTextInput, copyText } from "@getpaseo/plugin/client/react-native";
4
+ import { initClientHelpers, registerSidebarSurface, type ComposerPillRegistrar } from "./client/vendor/paseo-plugin-helper/index";
5
+ import { MainSurface } from "./client/main";
6
+ import { crossDaemonTransformer, crossDaemonRenderer, outboxNoticeRenderer } from "./client/x-comms-timeline";
7
+ import { crossDaemonToolCallTransformer, crossDaemonToolCallRenderer } from "./client/x-comms-tool-call";
8
+ import { contributeClient } from "./client/x-comms-pill";
9
+ import { CrossDaemonPanel } from "./client/x-comms-panel";
10
+
11
+ initClientHelpers({ Icon, Modal, useRpc, useToast, copyText, ScrollView, FlatList, TextInput: HostTextInput });
12
+
13
+ export default function contribute(client: PluginClientContext) {
14
+ client.addTimelineTransformer(crossDaemonTransformer);
15
+ client.addTimelineRenderer(crossDaemonRenderer);
16
+ client.addTimelineRenderer(outboxNoticeRenderer);
17
+ client.addTimelineTransformer(crossDaemonToolCallTransformer);
18
+ client.addTimelineRenderer(crossDaemonToolCallRenderer);
19
+ client.addWorkspacePanel({
20
+ id: "x-comms",
21
+ title: "X-comms",
22
+ icon: "PhoneOutgoing",
23
+ context: "agent",
24
+ Component: CrossDaemonPanel,
25
+ });
26
+ // registerSidebarSurface injects <PluginThemeProvider> so the surface's
27
+ // helper primitives resolve the host theme/layout (compact + mobile).
28
+ registerSidebarSurface(client, {
29
+ id: "main",
30
+ title: "X-comms",
31
+ icon: "PhoneOutgoing",
32
+ Component: MainSurface,
33
+ });
34
+ const headerButtons = new Map<string, () => void>();
35
+ const addHeaderButtonForWorkspace = (workspaceId: string) => {
36
+ if (!workspaceId || headerButtons.has(workspaceId)) return;
37
+ const registration = client.addHeaderButton({
38
+ id: "x-comms",
39
+ workspaceId,
40
+ button: {
41
+ title: "X-comms",
42
+ icon: "PhoneOutgoing",
43
+ behavior: { kind: "action", onPress: () => client.openSurface("main") },
44
+ },
45
+ });
46
+ headerButtons.set(workspaceId, () => registration.remove());
47
+ };
48
+ const unsubscribeAgents = client.paseo.agents.subscribe((update) => {
49
+ if (update.kind !== "upsert" || !update.agent.workspaceId) return;
50
+ addHeaderButtonForWorkspace(update.agent.workspaceId);
51
+ });
52
+ // Register buttons for agents already present: subscribe only fires on
53
+ // future upserts, so without this the icon is missing after a Paseo
54
+ // restart until the next agent event.
55
+ void Promise.resolve()
56
+ .then(() => client.paseo.agents.list())
57
+ .then(
58
+ (res) => {
59
+ const entries = Array.isArray(
60
+ (res as unknown as { entries?: unknown }).entries,
61
+ )
62
+ ? (res as unknown as { entries: Array<{ agent?: { workspaceId?: unknown } }> }).entries
63
+ : [];
64
+ for (const { agent } of entries) {
65
+ if (typeof agent?.workspaceId === "string") addHeaderButtonForWorkspace(agent.workspaceId);
66
+ }
67
+ },
68
+ (err) => console.warn("[x-comms] Failed to list agents for header buttons:", err),
69
+ );
70
+ // Paseo 0.8 client is button-only; registerComposerPill probes the host shape at runtime.
71
+ const disposePill = contributeClient(client as unknown as ComposerPillRegistrar);
72
+ return () => {
73
+ unsubscribeAgents();
74
+ for (const remove of headerButtons.values()) remove();
75
+ headerButtons.clear();
76
+ disposePill();
77
+ };
78
+ }
@@ -0,0 +1,84 @@
1
+ import type { PluginServerContext } from "@getpaseo/plugin/server";
2
+ import {
3
+ handleRegistryRead,
4
+ handleDaemonAdd,
5
+ handleDaemonUpdate,
6
+ handleDaemonRemove,
7
+ handleDaemonHealth,
8
+ handleServerStatus,
9
+ handleConversationSend,
10
+ handleIntrospectAgents,
11
+ handleIntroduceAgents,
12
+ handleDaemonProbe,
13
+ handleUiPrefsGet,
14
+ handleUiPrefsSet,
15
+ handleSnapshotRefresh,
16
+ handleDaemonDump,
17
+ handleIdentitySync,
18
+ handlePresenceAnnounce,
19
+ handlePresenceRetract,
20
+ handlePresenceList,
21
+ injectionEnabled,
22
+ onLocalAgentCreated,
23
+ onLocalAgentArchived,
24
+ rememberPaseo,
25
+ stopOutboxWorker,
26
+ } from "./server/handlers";
27
+ import { maybeRegisterInjection, toInjectionServer } from "./server/injection";
28
+ import { handlePeerStatus } from "./server/peer-status";
29
+ import {
30
+ registryReadRpc,
31
+ daemonAddRpc,
32
+ daemonUpdateRpc,
33
+ daemonRemoveRpc,
34
+ daemonHealthRpc,
35
+ serverStatusRpc,
36
+ conversationSendRpc,
37
+ introspectAgentsRpc,
38
+ introduceAgentsRpc,
39
+ daemonProbeRpc,
40
+ uiPrefsGetRpc,
41
+ uiPrefsSetRpc,
42
+ snapshotRefreshRpc,
43
+ daemonDumpRpc,
44
+ identitySyncRpc,
45
+ presenceAnnounceRpc,
46
+ presenceRetractRpc,
47
+ presenceListRpc,
48
+ peerStatusRpc,
49
+ } from "./shared/registry";
50
+
51
+ export default function contribute(server: PluginServerContext) {
52
+ server.handle(registryReadRpc, handleRegistryRead);
53
+ server.handle(daemonAddRpc, handleDaemonAdd);
54
+ server.handle(daemonUpdateRpc, handleDaemonUpdate);
55
+ server.handle(daemonRemoveRpc, handleDaemonRemove);
56
+ server.handle(daemonHealthRpc, handleDaemonHealth);
57
+ server.handle(serverStatusRpc, handleServerStatus);
58
+ server.handle(conversationSendRpc, handleConversationSend);
59
+ server.handle(introspectAgentsRpc, handleIntrospectAgents);
60
+ server.handle(introduceAgentsRpc, handleIntroduceAgents);
61
+ server.handle(daemonProbeRpc, handleDaemonProbe);
62
+ server.handle(uiPrefsGetRpc, handleUiPrefsGet);
63
+ server.handle(uiPrefsSetRpc, handleUiPrefsSet);
64
+ server.handle(snapshotRefreshRpc, handleSnapshotRefresh);
65
+ server.handle(daemonDumpRpc, handleDaemonDump);
66
+ server.handle(identitySyncRpc, handleIdentitySync);
67
+ server.handle(presenceAnnounceRpc, handlePresenceAnnounce);
68
+ server.handle(presenceRetractRpc, handlePresenceRetract);
69
+ server.handle(presenceListRpc, handlePresenceList);
70
+ server.handle(peerStatusRpc, handlePeerStatus);
71
+ server.on("agent.created", ({ agent }, context) => {
72
+ rememberPaseo(context.paseo);
73
+ void onLocalAgentCreated(agent).catch(() => {});
74
+ });
75
+ server.on("agent.archived", ({ agent }, context) => {
76
+ rememberPaseo(context.paseo);
77
+ void onLocalAgentArchived(agent).catch(() => {});
78
+ });
79
+ const removeInjection = maybeRegisterInjection(toInjectionServer(server), { enabled: injectionEnabled() });
80
+ return () => {
81
+ stopOutboxWorker();
82
+ removeInjection();
83
+ };
84
+ }
package/package.json CHANGED
@@ -1,16 +1,19 @@
1
1
  {
2
2
  "name": "@xpufx/paseo-x-comms",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "description": "Cross-daemon agent conversation over Paseo Relay: a Paseo plugin that bundles the MCP server so agents on one daemon can talk to agents on another (relay offer or direct host).",
5
5
  "type": "module",
6
6
  "private": false,
7
+ "engines": {
8
+ "node": ">=18"
9
+ },
7
10
  "bin": {
8
11
  "paseo-x-comms": "./mcp/paseo-x-comms.mjs"
9
12
  },
10
13
  "scripts": {
11
14
  "stamp": "node -e \"import('./server/vendor/paseo-plugin-helper/version.ts').then(m => m.stampVersion({ targetFile: 'shared/version.ts' }))\"",
12
15
  "typecheck": "tsc --noEmit",
13
- "test": "node --import ./mcp/test/register-ts-hooks.mjs --test mcp/test/protocol.test.mjs server/presence.test.ts server/outbox.test.ts server/injection.test.ts server/settings.test.ts server/server-status.test.ts server/relay-status.test.ts server/conversations-snapshot.test.ts server/local-send.test.ts server/peer-status.test.ts client/tool-call.test.ts client/conversation-threads.test.ts client/attribution.test.ts client/message-direction.test.ts"
16
+ "test": "node --import ./mcp/test/register-ts-hooks.mjs --test mcp/test/protocol.test.mjs server/presence.test.ts server/outbox.test.ts server/injection.test.ts server/settings.test.ts server/server-status.test.ts server/relay-status.test.ts server/conversations-snapshot.test.ts server/local-send.test.ts server/peer-status.test.ts client/tool-call.test.ts client/configured-hosts.test.ts client/conversation-threads.test.ts client/attribution.test.ts client/message-direction.test.ts"
14
17
  },
15
18
  "keywords": [
16
19
  "paseo",
@@ -25,17 +28,17 @@
25
28
  "license": "MIT",
26
29
  "dependencies": {
27
30
  "@getpaseo/client": "0.9.0-beta.2",
31
+ "@getpaseo/plugin": "0.9.0-beta.2",
28
32
  "@getpaseo/protocol": "0.9.0-beta.2",
29
- "@modelcontextprotocol/sdk": "^1.30.0"
33
+ "@modelcontextprotocol/sdk": "^1.30.0",
34
+ "zod": "^4.4.3"
30
35
  },
31
36
  "devDependencies": {
32
- "@getpaseo/plugin": "0.9.0-beta.2",
33
37
  "@tanstack/react-query": "^5.90.11",
34
38
  "@types/react": "^19.0.10",
35
39
  "react": "19.1.0",
36
40
  "react-native": "0.81.5",
37
- "typescript": "^5.9.3",
38
- "zod": "^4.4.3"
41
+ "typescript": "^5.9.3"
39
42
  },
40
43
  "repository": {
41
44
  "type": "git",
@@ -45,6 +48,8 @@
45
48
  "paseo-plugin.json",
46
49
  "README.md",
47
50
  "LICENSE",
51
+ "index.client.tsx",
52
+ "index.server.ts",
48
53
  "client",
49
54
  "server",
50
55
  "shared",
package/paseo-plugin.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "id": "x-comms",
3
- "requirements": { "paseo": ">=0.8.0" },
3
+ "requirements": { "paseo": ">=0.9.0-beta.2" },
4
4
  "build": [
5
- ["npm", "install", "--omit=dev", "--no-audit", "--no-fund", "--no-workspaces"]
5
+ ["npm", "install", "--omit=dev", "--ignore-scripts", "--no-audit", "--no-fund", "--no-workspaces"]
6
6
  ]
7
7
  }
@@ -1,4 +1,4 @@
1
- import { PluginStorage } from "paseo-plugin-helper/server";
1
+ import { PluginStorage } from "./vendor/paseo-plugin-helper/index";
2
2
  import {
3
3
  emptyConversationsSnapshot,
4
4
  type XCommsConversationsSnapshot,
@@ -1,8 +1,8 @@
1
1
  import { readFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { createPeriodicTask, createPluginLogger, safeSpawn } from "paseo-plugin-helper/server";
3
+ import { createPeriodicTask, createPluginLogger, safeSpawn } from "./vendor/paseo-plugin-helper/index";
4
4
  import type { PaseoApi } from "@getpaseo/client";
5
- import { withTimeout } from "paseo-plugin-helper/shared";
5
+ import { withTimeout } from "../shared/vendor/paseo-plugin-helper/async";
6
6
  import { getSnapshotFresh, agentCountFor, refreshSnapshot, initializeSnapshot } from "./snapshot";
7
7
  import {
8
8
  registryReadRpc,
@@ -397,7 +397,7 @@ export async function handleDaemonProbe(input: { value: string }) {
397
397
 
398
398
 
399
399
 
400
- import { PluginStorage } from "paseo-plugin-helper/server";
400
+ import { PluginStorage } from "./vendor/paseo-plugin-helper/index";
401
401
  import { resolveFeatureFlags, resolveInjectionEnabled, resolveOutboxExpiryMs, resolvePresenceEnabled, applyFeaturePrefsUpdate } from "./settings.ts";
402
402
  import {
403
403
  OUTBOX_POLL_INTERVAL_MS,
@@ -7,7 +7,7 @@ import {
7
7
  type McpInjectionHookHandler,
8
8
  type McpInjectionServer,
9
9
  type McpStdioInjectionConfig,
10
- } from "paseo-plugin-helper/server";
10
+ } from "./vendor/paseo-plugin-helper/index";
11
11
  import type { PluginServerContext } from "@getpaseo/plugin/server";
12
12
  import { serverPath } from "./server-status.ts";
13
13
  import { stateDir } from "./registry.ts";
@@ -1,5 +1,5 @@
1
- import { McpClient } from "paseo-plugin-helper/mcp";
2
- import { createPluginLogger } from "paseo-plugin-helper/server";
1
+ import { McpClient } from "./vendor/paseo-plugin-helper/mcp/index";
2
+ import { createPluginLogger } from "./vendor/paseo-plugin-helper/index";
3
3
 
4
4
  const log = createPluginLogger("paseo-x-comms", { subsystem: "mcp-client" });
5
5
 
package/server/outbox.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { randomUUID } from "node:crypto";
2
- import { PluginStorage } from "paseo-plugin-helper/server";
2
+ import { PluginStorage } from "./vendor/paseo-plugin-helper/index";
3
3
 
4
4
  /**
5
5
  * Outbox for outbound conversation messages that could not be delivered.
@@ -4,8 +4,8 @@ import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
5
  import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
6
6
  import { buildDaemonWebSocketUrl, buildRelayWebSocketUrl } from "@getpaseo/protocol/daemon-endpoints";
7
- import { createPluginLogger, safeSpawn } from "paseo-plugin-helper/server";
8
- import { withTimeout } from "paseo-plugin-helper/shared";
7
+ import { createPluginLogger, safeSpawn } from "./vendor/paseo-plugin-helper/index";
8
+ import { withTimeout } from "../shared/vendor/paseo-plugin-helper/async";
9
9
 
10
10
  const log = createPluginLogger("paseo-x-comms", { subsystem: "peer-channel" });
11
11
  const CONNECT_TIMEOUT_MS = 8000;
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { DaemonClient } from "@getpaseo/client/internal/daemon-client";
3
- import { createPluginLogger } from "paseo-plugin-helper/server";
4
- import { withTimeout } from "paseo-plugin-helper/shared";
3
+ import { createPluginLogger } from "./vendor/paseo-plugin-helper/index";
4
+ import { withTimeout } from "../shared/vendor/paseo-plugin-helper/async";
5
5
  import { currentRegistryPath, readRegistry } from "./registry";
6
6
  import { resolvePeerTarget } from "./peer-channel";
7
7
 
@@ -1,4 +1,4 @@
1
- import { PluginStorage } from "paseo-plugin-helper/server";
1
+ import { PluginStorage } from "./vendor/paseo-plugin-helper/index";
2
2
 
3
3
  /**
4
4
  * Presence store (mesh Layer 1, control plane only).
@@ -1,6 +1,6 @@
1
1
  import { writeFileSync } from "node:fs";
2
2
  import { join } from "node:path";
3
- import { createPluginLogger, safeSpawn } from "paseo-plugin-helper/server";
3
+ import { createPluginLogger, safeSpawn } from "./vendor/paseo-plugin-helper/index";
4
4
  import { currentRegistryPath, readRegistry, stateDir, migrateFromRoot } from "./registry";
5
5
 
6
6
  const snapLog = createPluginLogger("paseo-x-comms", { subsystem: "snapshot" });
@@ -173,4 +173,4 @@ export function agentCountFor(entry: DaemonSnapshotEntry): number {
173
173
  (total, project) => total + project.workspaces.reduce((sub, ws) => sub + ws.agents.length, 0),
174
174
  0,
175
175
  );
176
- }
176
+ }
@@ -32,6 +32,34 @@ export type CrossDaemonEnvelope = z.infer<typeof EnvelopeSchema>;
32
32
 
33
33
  export type MessageDirection = "incoming" | "outgoing";
34
34
 
35
+ /**
36
+ * Produce the version-4 wire prefix shared by every x-comms delivery route.
37
+ * Keeping this browser-safe lets an interactive client send retain the same
38
+ * attribution contract as the server and MCP routes.
39
+ */
40
+ export function buildXCommsEnvelope(args: {
41
+ sender: {
42
+ agentId: string | null;
43
+ agentName: string | null;
44
+ host: string;
45
+ daemonServerId: string | null;
46
+ cwd: string | null;
47
+ };
48
+ target: { daemon: string | null; agentId: string | null };
49
+ sentAt: string;
50
+ }): string {
51
+ return `${META_PREFIX}${JSON.stringify({
52
+ xComms: {
53
+ version: 4,
54
+ type: "x-comms.message",
55
+ direction: "outgoing",
56
+ sender: args.sender,
57
+ target: args.target,
58
+ sentAt: args.sentAt,
59
+ },
60
+ })}`;
61
+ }
62
+
35
63
  /**
36
64
  * Viewer-relative direction. The wire envelope stamps direction "outgoing"
37
65
  * from the sender's side, so only a message from self counts as user-sent.
@@ -1,4 +1,4 @@
1
- import { defineRpc } from "paseo-plugin-helper/shared";
1
+ import { defineRpc } from "./vendor/paseo-plugin-helper/rpc";
2
2
  import { z } from "zod";
3
3
 
4
4
  const DaemonEntrySchema = z.object({