@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 +5 -5
- package/client/configured-hosts.ts +64 -0
- package/client/main.tsx +1 -1
- package/client/peer-status.tsx +1 -1
- package/client/settings-prototype.tsx +1 -1
- package/client/vendor/paseo-plugin-helper/utils/clipboard.ts +11 -4
- package/client/x-comms-conversation.tsx +96 -10
- package/client/x-comms-pill.tsx +1 -1
- package/client/x-comms-tool-call.tsx +1 -1
- package/index.client.tsx +78 -0
- package/index.server.ts +84 -0
- package/package.json +11 -6
- package/paseo-plugin.json +2 -2
- package/server/conversations-snapshot.ts +1 -1
- package/server/handlers.ts +3 -3
- package/server/injection.ts +1 -1
- package/server/mcp-client.ts +2 -2
- package/server/outbox.ts +1 -1
- package/server/peer-channel.ts +2 -2
- package/server/peer-status.ts +2 -2
- package/server/presence.ts +1 -1
- package/server/snapshot.ts +2 -2
- package/shared/envelope.ts +28 -0
- package/shared/registry.ts +1 -1
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
|
|
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
|
|
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.
|
|
93
|
-
├── index.server.ts # Paseo 0.
|
|
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.
|
|
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/
|
|
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";
|
package/client/peer-status.tsx
CHANGED
|
@@ -13,7 +13,7 @@ import {
|
|
|
13
13
|
StatusDot,
|
|
14
14
|
usePluginTheme,
|
|
15
15
|
useRpcQuery,
|
|
16
|
-
} from "paseo-plugin-helper/
|
|
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";
|
|
@@ -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.
|
|
28
|
-
*
|
|
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
|
-
|
|
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 &&
|
|
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/
|
|
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
|
-
|
|
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<
|
|
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
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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:
|
|
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>
|
package/client/x-comms-pill.tsx
CHANGED
|
@@ -15,7 +15,7 @@ import {
|
|
|
15
15
|
type ComposerPillRegistrar,
|
|
16
16
|
type RenderModalProps,
|
|
17
17
|
type RenderPillProps,
|
|
18
|
-
} from "paseo-plugin-helper/
|
|
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/
|
|
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";
|
package/index.client.tsx
ADDED
|
@@ -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
|
+
}
|
package/index.server.ts
ADDED
|
@@ -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.
|
|
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.
|
|
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
|
}
|
package/server/handlers.ts
CHANGED
|
@@ -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/
|
|
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/
|
|
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/
|
|
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,
|
package/server/injection.ts
CHANGED
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
type McpInjectionHookHandler,
|
|
8
8
|
type McpInjectionServer,
|
|
9
9
|
type McpStdioInjectionConfig,
|
|
10
|
-
} from "paseo-plugin-helper/
|
|
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";
|
package/server/mcp-client.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { McpClient } from "paseo-plugin-helper/mcp";
|
|
2
|
-
import { createPluginLogger } from "paseo-plugin-helper/
|
|
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
package/server/peer-channel.ts
CHANGED
|
@@ -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/
|
|
8
|
-
import { withTimeout } from "paseo-plugin-helper/
|
|
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;
|
package/server/peer-status.ts
CHANGED
|
@@ -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/
|
|
4
|
-
import { withTimeout } from "paseo-plugin-helper/
|
|
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
|
|
package/server/presence.ts
CHANGED
package/server/snapshot.ts
CHANGED
|
@@ -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/
|
|
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
|
+
}
|
package/shared/envelope.ts
CHANGED
|
@@ -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.
|
package/shared/registry.ts
CHANGED