@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,630 @@
1
+ #!/usr/bin/env node
2
+ // paseo-x-comms: MCP server for x-comms agent conversation over Paseo Relay.
3
+ //
4
+ // Lets agents on one paseo daemon talk to agents on another paseo daemon, even
5
+ // across hosts, via the paseo CLI's `--host <opaque>` flag. paseo classifies
6
+ // the host string itself: a value containing `#offer=<b64>` is a relay
7
+ // connection (E2EE); anything else is a direct host target (`host:port`,
8
+ // `tcp://…`, `unix://…`, IPC paths, bare port).
9
+ //
10
+ // Daemons: ~/.paseo/paseo-x-comms/registry.json { name: "<offer URL | direct host>" }
11
+ // (the registry file is the primary way to configure daemons;
12
+ // offer from `paseo daemon pair` on the target, or a direct host)
13
+ //
14
+ // Built on the official MCP SDK (`McpServer` + `StdioServerTransport`), the
15
+ // same server paseo itself uses for its agent MCP control plane. The SDK owns
16
+ // framing, JSON-RPC, schema validation, and cancellation signals; this file
17
+ // only implements the tool logic.
18
+ //
19
+ // Env overrides (testability / power users):
20
+ // PASEO_X_COMMS_REMOTES registry file path
21
+ // (default ~/.paseo/paseo-x-comms/registry.json)
22
+ // PASEO_X_COMMS_PASEO paseo binary (default "paseo")
23
+ // PASEO_X_COMMS_TIMEOUT_MS per paseo call timeout (default 120000)
24
+ // PASEO_X_COMMS_EXTENSIONS extension dir (default <registry dir>/extensions)
25
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
26
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
27
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
28
+ import { homedir, hostname } from "node:os";
29
+ import { join, dirname } from "node:path";
30
+ import { pathToFileURL } from "node:url";
31
+ import { execFile } from "node:child_process";
32
+
33
+ const VERSION = "0.3.0";
34
+ import { z } from "zod";
35
+
36
+ const REMOTES_DIR = join(homedir(), ".paseo", "paseo-x-comms");
37
+
38
+ const REMOTES_FILE =
39
+ process.env.PASEO_X_COMMS_REMOTES ||
40
+ join(REMOTES_DIR, "registry.json");
41
+
42
+ const PASEO = process.env.PASEO_X_COMMS_PASEO || "paseo";
43
+ const DEFAULT_TIMEOUT_MS = Number(process.env.PASEO_X_COMMS_TIMEOUT_MS || 120000);
44
+
45
+ const EXTENSIONS_DIR =
46
+ process.env.PASEO_X_COMMS_EXTENSIONS || join(REMOTES_DIR, "extensions");
47
+
48
+ // daemons registry
49
+
50
+ function loadDaemons() {
51
+ if (!existsSync(REMOTES_FILE)) return {};
52
+ try {
53
+ return JSON.parse(readFileSync(REMOTES_FILE, "utf8"));
54
+ } catch {
55
+ throw new Error(`cannot read daemons registry at ${REMOTES_FILE} (corrupt JSON?)`);
56
+ }
57
+ }
58
+
59
+ function saveDaemons(daemons) {
60
+ mkdirSync(dirname(REMOTES_FILE), { recursive: true });
61
+ writeFileSync(REMOTES_FILE, JSON.stringify(daemons, null, 2) + "\n", "utf8");
62
+ }
63
+
64
+ // `--host` is opaque; paseo classifies it (a value containing `#offer=` is a
65
+ // relay connection, anything else is a direct host target). We pass the value
66
+ // through untouched (no wrapping, no legacy formats).
67
+ //
68
+ // The alias is resolved to a host target up front, so an unknown alias fails
69
+ // before any paseo attempt with the exact string and the reason (pairing).
70
+ const PAIRING_HINT = `pairing is required: run \`paseo daemon pair\` on the target and register the offer, or add a direct host, via x_comms_add_daemon (registry: ${REMOTES_FILE})`;
71
+
72
+ function hostTargetFor(daemon, daemons) {
73
+ const value = daemons[daemon];
74
+ if (value === undefined) {
75
+ throw new Error(`unknown daemon '${daemon}' — ${PAIRING_HINT}`);
76
+ }
77
+ const trimmed = String(value).trim();
78
+ if (!trimmed) throw new Error(`daemon '${daemon}' has an empty host value`);
79
+ return trimmed;
80
+ }
81
+
82
+ // Sending a message to your own agent is almost always a mistake (the envelope
83
+ // tells the recipient it is from itself). Fixed, labeled error so it is
84
+ // greppable and testable.
85
+ const SELF_MESSAGE_LABEL = "x-comms self-message";
86
+
87
+ async function assertNotSelfMessage(message, signal) {
88
+ const sender = await gatherSenderMeta(signal);
89
+ const senderAgentId = message.fromAgentId ?? sender.agentId;
90
+ if (senderAgentId && message.agentId === senderAgentId) {
91
+ throw new Error(
92
+ `${SELF_MESSAGE_LABEL}: target agentId '${senderAgentId}' is your own agent — choose a different agent.`,
93
+ );
94
+ }
95
+ // TODO(#9): same-daemon-but-different-agent is the locality rule, not self.
96
+ // Once #9 lands, route those natively instead of via x_comms.
97
+ }
98
+
99
+ // paseo shell-out with cancellation
100
+
101
+ function runPaseo(args, { timeoutMs = DEFAULT_TIMEOUT_MS, signal } = {}) {
102
+ return new Promise((resolve, reject) => {
103
+ const child = execFile(
104
+ PASEO,
105
+ args,
106
+ { timeout: timeoutMs, maxBuffer: 16 * 1024 * 1024 },
107
+ (err, stdout, stderr) => {
108
+ if (err) {
109
+ if (signal?.aborted) {
110
+ reject(Object.assign(new Error("request cancelled"), { code: "CANCELLED" }));
111
+ return;
112
+ }
113
+ if (err.killed) {
114
+ reject(new Error(`paseo ${args[0]} timed out after ${timeoutMs}ms`));
115
+ return;
116
+ }
117
+ reject(new Error((stderr || err.message || "").trim().split("\n")[0] || String(err)));
118
+ return;
119
+ }
120
+ try {
121
+ resolve(JSON.parse(stdout));
122
+ } catch {
123
+ resolve(stdout.trim());
124
+ }
125
+ },
126
+ );
127
+ if (signal) {
128
+ if (signal.aborted) {
129
+ child.kill("SIGTERM");
130
+ } else {
131
+ signal.addEventListener("abort", () => child.kill("SIGTERM"), { once: true });
132
+ }
133
+ }
134
+ });
135
+ }
136
+
137
+ // sender identity (so recipients know who is talking and from where)
138
+
139
+ let senderCache = null;
140
+
141
+ async function gatherSenderMeta(signal) {
142
+ if (senderCache) return senderCache;
143
+ const meta = {
144
+ agentId: process.env.PASEO_AGENT_ID || null,
145
+ agentName: null,
146
+ cwd: process.env.PASEO_AGENT_CWD || null,
147
+ host: hostname(),
148
+ serverId: null,
149
+ };
150
+ try {
151
+ const status = await runPaseo(["daemon", "status", "--json"], {
152
+ timeoutMs: 15000,
153
+ signal,
154
+ });
155
+ if (status && typeof status === "object") {
156
+ if (status.hostname) meta.host = status.hostname;
157
+ meta.serverId = status.serverId || null;
158
+ }
159
+ } catch (err) {
160
+ if (err?.code === "CANCELLED") throw err;
161
+ /* keep os hostname */
162
+ }
163
+ if (meta.agentId) {
164
+ try {
165
+ const info = await runPaseo(["inspect", meta.agentId, "--json"], {
166
+ timeoutMs: 15000,
167
+ signal,
168
+ });
169
+ if (info && typeof info === "object" && info.Name) meta.agentName = info.Name;
170
+ } catch (err) {
171
+ if (err?.code === "CANCELLED") throw err;
172
+ /* id only is fine */
173
+ }
174
+ }
175
+ senderCache = meta;
176
+ return meta;
177
+ }
178
+
179
+ async function senderMetaBlock(signal, target = {}, sender = {}) {
180
+ const m = await gatherSenderMeta(signal);
181
+ const envelope = {
182
+ xComms: {
183
+ version: 4,
184
+ // Neutral type: at stamp time the message is leaving, not arriving.
185
+ // Direction of travel lives in `direction`; viewers derive
186
+ // incoming vs outgoing by comparing sender.agentId to self.
187
+ type: "x-comms.message",
188
+ // Direction of travel as stamped by the sender. Every message leaves
189
+ // its sender, so this is always "outgoing" on the wire; viewers
190
+ // derive incoming vs outgoing by comparing sender.agentId to self.
191
+ direction: "outgoing",
192
+ sender: {
193
+ agentId: sender.agentId ?? m.agentId,
194
+ agentName: sender.agentName ?? m.agentName,
195
+ host: m.host,
196
+ daemonServerId: m.serverId,
197
+ cwd: m.cwd,
198
+ },
199
+ target: {
200
+ daemon: target.daemon ?? null,
201
+ agentId: target.agentId ?? null,
202
+ },
203
+ sentAt: new Date().toISOString(),
204
+ },
205
+ };
206
+ return `[x-comms] ${JSON.stringify(envelope)}`;
207
+ }
208
+
209
+ // tools
210
+
211
+ const TOOL_SCHEMAS = {
212
+ listDaemons: {},
213
+ addDaemon: {
214
+ name: z.string(),
215
+ offer: z
216
+ .string()
217
+ .describe(
218
+ "Full pairing link (https://app.paseo.sh/#offer=…) or a direct daemon host (host:port, tcp://…, unix://…).",
219
+ ),
220
+ },
221
+ removeDaemon: { name: z.string() },
222
+ listAgents: { daemon: z.string() },
223
+ inspect: { daemon: z.string(), agentId: z.string() },
224
+ send: { daemon: z.string(), agentId: z.string(), prompt: z.string(), fromAgentId: z.string().nullable().optional(), fromAgentName: z.string().nullable().optional() },
225
+ logs: { daemon: z.string(), agentId: z.string() },
226
+ wait: {
227
+ daemon: z.string(),
228
+ agentId: z.string(),
229
+ timeoutSeconds: z.number().int().positive().optional(),
230
+ },
231
+ listPermissions: { daemon: z.string() },
232
+ allowPermission: {
233
+ daemon: z.string(),
234
+ agentId: z.string(),
235
+ reqId: z.string().optional(),
236
+ all: z.boolean().optional(),
237
+ input: z.string().optional(),
238
+ },
239
+ denyPermission: {
240
+ daemon: z.string(),
241
+ agentId: z.string(),
242
+ reqId: z.string().optional(),
243
+ all: z.boolean().optional(),
244
+ message: z.string().optional(),
245
+ interrupt: z.boolean().optional(),
246
+ },
247
+ };
248
+
249
+ const PREFIX = "x_comms_";
250
+
251
+ // Surfaced to clients via the MCP `instructions` field (initialize result) so
252
+ // every model using this server gets the behavioral contract automatically.
253
+ const INSTRUCTIONS = `paseo-x-comms: cross-daemon messaging between paseo agents on different daemons/hosts. A client may prefix the x_comms_* tool names with its registration name; match the tools actually exposed.
254
+
255
+ An inbound message carrying the [x-comms] envelope is from another daemon's agent, not a user: reply to the sender via x_comms_send (daemon=sender.daemon, agentId=sender.agentId); on finish, error, or permission block, notify the sender the same way (include permission details when blocked).
256
+
257
+ x_comms_send is preemptive: if the target may be busy, x_comms_wait first. x_comms_wait -> idle | permission | timeout; on permission, list_permissions to see prompts, then allow_permission/deny_permission, then wait again.`;
258
+
259
+ // One tool result shape, mirroring paseo's own PaseoToolResult: text content for
260
+ // every client plus structuredContent (a record) for clients that consume it.
261
+ // Arrays are wrapped so structuredContent stays a record, matching paseo's
262
+ // `ensureValidJson({ … })` convention.
263
+ function result(data) {
264
+ const text = typeof data === "string" ? data : JSON.stringify(data, null, 2);
265
+ const isRecord = data !== null && typeof data === "object" && !Array.isArray(data);
266
+ return {
267
+ content: [{ type: "text", text }],
268
+ ...(isRecord ? { structuredContent: data } : {}),
269
+ };
270
+ }
271
+
272
+ // Third-party extensions
273
+ //
274
+ // ⚠️ TRUSTED, UNSANDBOXED SERVER-TIER CODE. An extension is a `.mjs` file in the
275
+ // extension dir that exports `register(api)` (default or named). It runs in
276
+ // THIS process with full Node privileges — filesystem, network, child
277
+ // processes — exactly like the server itself and like any installed paseo
278
+ // plugin. There is no sandbox, no permission prompt, and no isolation boundary.
279
+ // Only drop in extensions you would run yourself.
280
+ //
281
+ // API (version 1; bump API_VERSION on any breaking change to the surface):
282
+ // api.version -> 1
283
+ // api.onSend(fn) -> outbound message filter
284
+ // api.onReceive(fn) -> payload received from a daemon
285
+ // api.onToolCall(fn) -> every tool invocation (raw args)
286
+ // api.registerTool(name, cfg, fn) -> register on this same McpServer
287
+ // api.log(message) -> stderr
288
+ //
289
+ // A filter is `(payload, context) => outcome`, run in filename order, and
290
+ // returns one of:
291
+ // undefined | null | { action: "pass" } -> passthrough
292
+ // any other value -> transform (new payload)
293
+ // { action: "transform", value: <payload> } -> transform
294
+ // { action: "block", reason: "<why>" } -> block (reason is surfaced)
295
+ //
296
+ // A block stops the chain. A hook that throws is logged and treated as
297
+ // passthrough; a file that fails to import or register is skipped. One bad
298
+ // extension cannot take down the server or the other extensions.
299
+
300
+ const API_VERSION = 1;
301
+
302
+ const extensionHooks = { onSend: [], onReceive: [], onToolCall: [] };
303
+
304
+ function extensionLog(message) {
305
+ process.stderr.write(`[paseo-x-comms:extensions] ${message}\n`);
306
+ }
307
+
308
+ function describeError(cause) {
309
+ return cause instanceof Error ? cause.message : String(cause);
310
+ }
311
+
312
+ async function runFilter(hookName, payload, context) {
313
+ let current = payload;
314
+ for (const { filename, fn } of extensionHooks[hookName]) {
315
+ let outcome;
316
+ try {
317
+ outcome = await fn(current, context);
318
+ } catch (cause) {
319
+ extensionLog(`${filename}: ${hookName} threw, passing through: ${describeError(cause)}`);
320
+ continue;
321
+ }
322
+ if (outcome === undefined || outcome === null) continue;
323
+ if (typeof outcome === "object" && typeof outcome.action === "string") {
324
+ if (outcome.action === "pass" || outcome.action === "passthrough") continue;
325
+ if (outcome.action === "block") {
326
+ const reason =
327
+ typeof outcome.reason === "string" && outcome.reason.trim()
328
+ ? outcome.reason.trim()
329
+ : `${filename} blocked this ${hookName}`;
330
+ return { blocked: true, reason };
331
+ }
332
+ if (outcome.action === "transform") {
333
+ current = outcome.value;
334
+ continue;
335
+ }
336
+ extensionLog(`${filename}: unknown ${hookName} action '${outcome.action}', passing through`);
337
+ continue;
338
+ }
339
+ current = outcome;
340
+ }
341
+ return { blocked: false, value: current };
342
+ }
343
+
344
+ async function filterOrThrow(hookName, payload, context) {
345
+ const outcome = await runFilter(hookName, payload, context);
346
+ if (outcome.blocked) throw new Error(`x-comms extension blocked ${context.tool}: ${outcome.reason}`);
347
+ return outcome.value;
348
+ }
349
+
350
+ function extensionApi(filename) {
351
+ const add = (hookName) => (fn) => {
352
+ if (typeof fn !== "function") {
353
+ extensionLog(`${filename}: ${hookName} expects a function, ignoring`);
354
+ return;
355
+ }
356
+ extensionHooks[hookName].push({ filename, fn });
357
+ };
358
+ return {
359
+ version: API_VERSION,
360
+ onSend: add("onSend"),
361
+ onReceive: add("onReceive"),
362
+ onToolCall: add("onToolCall"),
363
+ registerTool: (name, config, handler) => registerTool(name, config, handler),
364
+ log: (message) => extensionLog(`${filename}: ${message}`),
365
+ };
366
+ }
367
+
368
+ async function loadExtensions(dir = EXTENSIONS_DIR) {
369
+ let files;
370
+ try {
371
+ files = readdirSync(dir).filter((name) => name.endsWith(".mjs")).sort();
372
+ } catch (cause) {
373
+ if (cause?.code !== "ENOENT") extensionLog(`cannot read ${dir}: ${describeError(cause)}`);
374
+ return;
375
+ }
376
+ if (files.length === 0) return;
377
+ extensionLog(`loading ${files.length} extension(s) from ${dir} (trusted, unsandboxed)`);
378
+ for (const filename of files) {
379
+ try {
380
+ const mod = await import(pathToFileURL(join(dir, filename)).href);
381
+ const register = mod.default ?? mod.register;
382
+ if (typeof register !== "function") {
383
+ extensionLog(`${filename}: no default/register export, skipping`);
384
+ continue;
385
+ }
386
+ await register(extensionApi(filename));
387
+ extensionLog(`${filename}: loaded`);
388
+ } catch (cause) {
389
+ extensionLog(`${filename}: failed to load, skipping: ${describeError(cause)}`);
390
+ }
391
+ }
392
+ }
393
+
394
+ // Every tool — built-in or extension-registered — runs its raw args through
395
+ // the onToolCall chain first. A block becomes a tool error; a transform
396
+ // replaces the args.
397
+ function registerTool(name, config, handler) {
398
+ server.registerTool(name, config, async (input, extra) => {
399
+ const args = await filterOrThrow("onToolCall", input, { tool: name });
400
+ return handler(args, extra);
401
+ });
402
+ }
403
+
404
+ // Tool calls that hit a daemon expose the response to onReceive before it
405
+ // reaches the model.
406
+ async function callPaseo(tool, args, { signal, daemon = null, agentId = null } = {}) {
407
+ const data = await runPaseo(args, { signal });
408
+ return await filterOrThrow("onReceive", data, { tool, daemon, agentId });
409
+ }
410
+
411
+ async function handleSend(input, signal) {
412
+ const message = await filterOrThrow(
413
+ "onSend",
414
+ {
415
+ daemon: input.daemon,
416
+ agentId: input.agentId,
417
+ prompt: input.prompt,
418
+ fromAgentId: input.fromAgentId ?? null,
419
+ fromAgentName: input.fromAgentName ?? null,
420
+ },
421
+ { tool: `${PREFIX}send` },
422
+ );
423
+ const target = hostTargetFor(message.daemon, loadDaemons());
424
+ await assertNotSelfMessage(message, signal);
425
+ const stamped = `${await senderMetaBlock(signal, {
426
+ agentId: message.agentId,
427
+ daemon: message.daemon,
428
+ }, {
429
+ agentId: message.fromAgentId ?? null,
430
+ agentName: message.fromAgentName ?? null,
431
+ })}\n\n${message.prompt}`;
432
+ return await callPaseo(
433
+ `${PREFIX}send`,
434
+ ["send", message.agentId, "--host", target, "--json", "--no-wait", stamped],
435
+ { signal, daemon: message.daemon, agentId: message.agentId },
436
+ );
437
+ }
438
+
439
+ function registerTools(server) {
440
+ registerTool(
441
+ `${PREFIX}list_daemons`,
442
+ { title: "List daemons", description: "List configured paseo daemons (names only).", inputSchema: TOOL_SCHEMAS.listDaemons },
443
+ () => result(Object.keys(loadDaemons())),
444
+ );
445
+
446
+ registerTool(
447
+ `${PREFIX}add_daemon`,
448
+ {
449
+ title: "Add daemon",
450
+ description:
451
+ "Register a paseo daemon by name. offer is a full pairing link (https://app.paseo.sh/#offer=…) from `paseo daemon pair` or a direct daemon host (host:port, tcp://…, unix://…).",
452
+ inputSchema: TOOL_SCHEMAS.addDaemon,
453
+ },
454
+ (input) => {
455
+ const daemons = loadDaemons();
456
+ daemons[input.name] = input.offer;
457
+ saveDaemons(daemons);
458
+ return result({ ok: true, daemons: Object.keys(daemons) });
459
+ },
460
+ );
461
+
462
+ registerTool(
463
+ `${PREFIX}remove_daemon`,
464
+ {
465
+ title: "Remove daemon",
466
+ description: "Forget a registered daemon.",
467
+ inputSchema: TOOL_SCHEMAS.removeDaemon,
468
+ },
469
+ (input) => {
470
+ const daemons = loadDaemons();
471
+ if (!(input.name in daemons)) throw new Error(`unknown daemon '${input.name}'`);
472
+ delete daemons[input.name];
473
+ saveDaemons(daemons);
474
+ return result({ ok: true, daemons: Object.keys(daemons) });
475
+ },
476
+ );
477
+
478
+ registerTool(
479
+ `${PREFIX}list_agents`,
480
+ {
481
+ title: "List agents",
482
+ description: "List agents on a daemon.",
483
+ inputSchema: TOOL_SCHEMAS.listAgents,
484
+ },
485
+ async (input, extra) =>
486
+ result(
487
+ await callPaseo(`${PREFIX}list_agents`, ["ls", "--host", hostTargetFor(input.daemon, loadDaemons()), "--json"], {
488
+ signal: extra.signal,
489
+ daemon: input.daemon,
490
+ }),
491
+ ),
492
+ );
493
+
494
+ registerTool(
495
+ `${PREFIX}inspect`,
496
+ {
497
+ title: "Inspect agent",
498
+ description: "Inspect an agent on a daemon.",
499
+ inputSchema: TOOL_SCHEMAS.inspect,
500
+ },
501
+ async (input, extra) =>
502
+ result(
503
+ await callPaseo(
504
+ `${PREFIX}inspect`,
505
+ ["inspect", input.agentId, "--host", hostTargetFor(input.daemon, loadDaemons()), "--json"],
506
+ { signal: extra.signal, daemon: input.daemon, agentId: input.agentId },
507
+ ),
508
+ ),
509
+ );
510
+
511
+ registerTool(
512
+ `${PREFIX}send`,
513
+ {
514
+ title: "Send message",
515
+ description:
516
+ "Send a message/task to an agent on a daemon (starts it if idle). Dispatches immediately (fire-and-forget, like paseo send --no-wait). Follow up with wait/logs to track the agent.",
517
+ inputSchema: TOOL_SCHEMAS.send,
518
+ },
519
+ async (input, extra) => result(await handleSend(input, extra.signal)),
520
+ );
521
+
522
+ registerTool(
523
+ `${PREFIX}logs`,
524
+ {
525
+ title: "View agent logs",
526
+ description: "View the activity/timeline of an agent on a daemon.",
527
+ inputSchema: TOOL_SCHEMAS.logs,
528
+ },
529
+ async (input, extra) =>
530
+ result(
531
+ await callPaseo(
532
+ `${PREFIX}logs`,
533
+ ["logs", input.agentId, "--host", hostTargetFor(input.daemon, loadDaemons()), "--json"],
534
+ { signal: extra.signal, daemon: input.daemon, agentId: input.agentId },
535
+ ),
536
+ ),
537
+ );
538
+
539
+ registerTool(
540
+ `${PREFIX}wait`,
541
+ {
542
+ title: "Wait for agent",
543
+ description:
544
+ "Block until an agent on a daemon becomes idle. Returns status \"permission\" (with kind) the moment the agent is blocked on a permission prompt, \"timeout\" if --timeoutSeconds elapses, or \"idle\" when done.",
545
+ inputSchema: TOOL_SCHEMAS.wait,
546
+ },
547
+ async (input, extra) => {
548
+ const args = ["wait", input.agentId];
549
+ if (input.timeoutSeconds !== undefined) args.push("--timeout", String(input.timeoutSeconds));
550
+ args.push("--host", hostTargetFor(input.daemon, loadDaemons()), "--json");
551
+ return result(await callPaseo(`${PREFIX}wait`, args, { signal: extra.signal, daemon: input.daemon, agentId: input.agentId }));
552
+ },
553
+ );
554
+
555
+ registerTool(
556
+ `${PREFIX}list_permissions`,
557
+ {
558
+ title: "List permissions",
559
+ description: "List pending permission requests on a daemon.",
560
+ inputSchema: TOOL_SCHEMAS.listPermissions,
561
+ },
562
+ async (input, extra) =>
563
+ result(
564
+ await callPaseo(
565
+ `${PREFIX}list_permissions`,
566
+ ["permit", "ls", "--host", hostTargetFor(input.daemon, loadDaemons()), "--json"],
567
+ { signal: extra.signal, daemon: input.daemon },
568
+ ),
569
+ ),
570
+ );
571
+
572
+ registerTool(
573
+ `${PREFIX}allow_permission`,
574
+ {
575
+ title: "Allow permission",
576
+ description:
577
+ "Allow an agent's permission request (reqId) or all its pending requests (all=true). input is optional modified input JSON.",
578
+ inputSchema: TOOL_SCHEMAS.allowPermission,
579
+ },
580
+ async (input, extra) => {
581
+ if (!input.reqId && !input.all) throw new Error("provide reqId or all=true");
582
+ const args = ["permit", "allow", input.agentId];
583
+ if (input.reqId) args.push(input.reqId);
584
+ if (input.all) args.push("--all");
585
+ if (input.input !== undefined) args.push("--input", input.input);
586
+ args.push("--host", hostTargetFor(input.daemon, loadDaemons()), "--json");
587
+ return result(await callPaseo(`${PREFIX}allow_permission`, args, { signal: extra.signal, daemon: input.daemon, agentId: input.agentId }));
588
+ },
589
+ );
590
+
591
+ registerTool(
592
+ `${PREFIX}deny_permission`,
593
+ {
594
+ title: "Deny permission",
595
+ description:
596
+ "Deny an agent's permission request (reqId) or all its pending requests (all=true). Optional message and interrupt.",
597
+ inputSchema: TOOL_SCHEMAS.denyPermission,
598
+ },
599
+ async (input, extra) => {
600
+ if (!input.reqId && !input.all) throw new Error("provide reqId or all=true");
601
+ const args = ["permit", "deny", input.agentId];
602
+ if (input.reqId) args.push(input.reqId);
603
+ if (input.all) args.push("--all");
604
+ if (input.message !== undefined) args.push("--message", input.message);
605
+ if (input.interrupt) args.push("--interrupt");
606
+ args.push("--host", hostTargetFor(input.daemon, loadDaemons()), "--json");
607
+ return result(await callPaseo(`${PREFIX}deny_permission`, args, { signal: extra.signal, daemon: input.daemon, agentId: input.agentId }));
608
+ },
609
+ );
610
+ }
611
+
612
+ // entry
613
+
614
+ const server = new McpServer(
615
+ {
616
+ name: "paseo-x-comms",
617
+ version: VERSION,
618
+ },
619
+ {
620
+ capabilities: { tools: { listChanged: false } },
621
+ instructions: INSTRUCTIONS,
622
+ },
623
+ );
624
+
625
+ registerTools(server);
626
+
627
+ await loadExtensions();
628
+
629
+ const transport = new StdioServerTransport();
630
+ await server.connect(transport);
@@ -0,0 +1,4 @@
1
+ // Test fixture: a hook that blocks with a reason.
2
+ export default function register(api) {
3
+ api.onSend(() => ({ action: "block", reason: "blocked by fixture" }));
4
+ }
@@ -0,0 +1,6 @@
1
+ // Test fixture: throws at call time, so the runner must isolate it and pass through.
2
+ export default function register(api) {
3
+ api.onSend(() => {
4
+ throw new Error("fixture hook failure");
5
+ });
6
+ }
@@ -0,0 +1,4 @@
1
+ // Test fixture: the good neighbour that must still transform after a throwing hook.
2
+ export default function register(api) {
3
+ api.onSend((message) => ({ ...message, prompt: `[GOOD] ${message.prompt}` }));
4
+ }
@@ -0,0 +1,4 @@
1
+ // Test fixture: throws during register(), so the loader must skip it.
2
+ export default function register() {
3
+ throw new Error("fixture load failure");
4
+ }
@@ -0,0 +1,4 @@
1
+ // Test fixture: the good neighbour that must keep working next to a broken one.
2
+ export default function register(api) {
3
+ api.onSend((message) => ({ ...message, prompt: `[GOOD] ${message.prompt}` }));
4
+ }
@@ -0,0 +1,12 @@
1
+ // Test fixture: registers a custom tool on the same McpServer instance.
2
+ export default function register(api) {
3
+ api.registerTool(
4
+ "x_comms_ext_echo",
5
+ {
6
+ title: "Extension echo",
7
+ description: "Test-only tool registered by an extension.",
8
+ inputSchema: {},
9
+ },
10
+ () => ({ content: [{ type: "text", text: "echo-from-extension" }] }),
11
+ );
12
+ }
@@ -0,0 +1,8 @@
1
+ // Test fixture: a hook that blocks a specific tool call with a reason.
2
+ export default function register(api) {
3
+ api.onToolCall((args, context) =>
4
+ context.tool === "x_comms_list_daemons"
5
+ ? { action: "block", reason: "denied by fixture" }
6
+ : undefined,
7
+ );
8
+ }