@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,152 @@
1
+ import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, dirname, join } from "node:path";
4
+ import {
5
+ createPluginLogger,
6
+ registerMcpInjection,
7
+ type McpInjectionHookHandler,
8
+ type McpInjectionServer,
9
+ type McpStdioInjectionConfig,
10
+ } from "paseo-plugin-helper/server";
11
+ import type { PluginServerContext } from "@getpaseo/plugin/server";
12
+ import { serverPath } from "./server-status.ts";
13
+ import { stateDir } from "./registry.ts";
14
+
15
+ const log = createPluginLogger("paseo-x-comms", { subsystem: "injection" });
16
+
17
+ /**
18
+ * Injection key scheme: `x-comms_<serverId>`, namespaced per daemon so key
19
+ * collisions are impossible by construction and the owning daemon is visible
20
+ * in agent configs (the visibility layer reads this back). Falls back to
21
+ * plain `x-comms` only when the local server id is unreadable.
22
+ *
23
+ * The separator MUST be `_` (or `-`), never `.`: the name is forwarded to
24
+ * provider ACP servers, and Gemini's validates it against `^[a-zA-Z0-9_-]+$`.
25
+ * A dotted name makes `session/new` fail with JSON-RPC -32602 "Invalid params",
26
+ * which breaks every newborn Antigravity agent (#243).
27
+ */
28
+ export const INJECTION_KEY_PREFIX = "x-comms_";
29
+ export const INJECTION_FALLBACK_KEY = "x-comms";
30
+
31
+ export function readLocalServerId(): string | null {
32
+ try {
33
+ const id = readFileSync(join(homedir(), ".paseo", "server-id"), "utf8").trim();
34
+ return id || null;
35
+ } catch {
36
+ return null;
37
+ }
38
+ }
39
+
40
+ export function injectionServerName(readId: () => string | null = readLocalServerId): string {
41
+ const id = readId();
42
+ if (!id) {
43
+ log.error("injection: local server id unreadable, using fallback key (collision risk if paired daemons do the same)");
44
+ return INJECTION_FALLBACK_KEY;
45
+ }
46
+ return `${INJECTION_KEY_PREFIX}${id}`;
47
+ }
48
+
49
+ export function injectionServerConfig(): McpStdioInjectionConfig {
50
+ return {
51
+ type: "stdio",
52
+ command: resolveNodeCommand(process.execPath),
53
+ args: [syncStableServer()],
54
+ };
55
+ }
56
+
57
+ /**
58
+ * Stable install path for the injected server. Plugin checkouts live under
59
+ * content-addressed directories that are replaced on every update, so any
60
+ * absolute checkout path baked into a saved agent config rots. The state
61
+ * dir survives updates, making this path stable across them.
62
+ */
63
+ export const STABLE_BIN_DIRNAME = "bin";
64
+ export const STABLE_SERVER_FILENAME = "paseo-x-comms.bundled.mjs";
65
+
66
+ export function stableServerPath(stateDirPath: string = stateDir()): string {
67
+ return join(stateDirPath, STABLE_BIN_DIRNAME, STABLE_SERVER_FILENAME);
68
+ }
69
+
70
+ /**
71
+ * Copy the bundled server into the stable path when missing or changed.
72
+ * Content comparison (not version plumbing) decides refreshes, so any
73
+ * bundle change propagates on next plugin startup. Returns the stable path.
74
+ */
75
+ export function syncStableServer(
76
+ source: string = serverPath(),
77
+ dest: string = stableServerPath(),
78
+ ): string {
79
+ mkdirSync(dirname(dest), { recursive: true });
80
+ let current: Buffer | null = null;
81
+ try {
82
+ current = readFileSync(dest);
83
+ } catch {
84
+ current = null;
85
+ }
86
+ const next = readFileSync(source);
87
+ if (!current || !current.equals(next)) {
88
+ writeFileSync(dest, next, { mode: 0o755 });
89
+ log.info(`injection: installed stable server at ${dest}`);
90
+ }
91
+ return dest;
92
+ }
93
+
94
+ /**
95
+ * Resolve a JS runtime command for spawned MCP servers. Under Electron
96
+ * (ELECTRON_RUN_AS_NODE) process.execPath is the app GUI binary, which
97
+ * cannot execute scripts, so fall back to PATH-resolved node there.
98
+ */
99
+ export function resolveNodeCommand(execPath: string): string {
100
+ const base = basename(execPath).toLowerCase();
101
+ if (/^(node|bun|deno)/.test(base)) {
102
+ return execPath;
103
+ }
104
+ return "node";
105
+ }
106
+
107
+ export interface InjectionGate {
108
+ enabled: boolean;
109
+ }
110
+
111
+ /**
112
+ * Adapt the SDK's generically-typed hook registry to the helper's
113
+ * string-typed structural registrar. The literal "agent.create" is a valid
114
+ * key of the SDK registry, so this is a type-level bridge only: merge
115
+ * semantics stay entirely inside the helper. Remove if the helper widens
116
+ * McpInjectionServer to accept the SDK BeforeRegistrar directly.
117
+ */
118
+ export function toInjectionServer(server: PluginServerContext): McpInjectionServer {
119
+ return {
120
+ before: (name, handler) =>
121
+ (server.before as (
122
+ n: string,
123
+ h: McpInjectionHookHandler,
124
+ ) => () => void)(name, handler),
125
+ };
126
+ }
127
+
128
+ /**
129
+ * Register MCP injection when the daemon-wide toggle is on, otherwise do
130
+ * nothing. Toggle changes take effect on plugin reload. Returns the
131
+ * helper's remover for cleanup.
132
+ */
133
+ export function maybeRegisterInjection(
134
+ server: McpInjectionServer,
135
+ gate: InjectionGate,
136
+ overrides?: { serverName?: string; config?: McpStdioInjectionConfig },
137
+ ): () => void {
138
+ if (!gate.enabled) {
139
+ log.info("injection: disabled by settings, skipping agent.create hook");
140
+ return () => {};
141
+ }
142
+ const serverName = overrides?.serverName ?? injectionServerName();
143
+ let config: McpStdioInjectionConfig;
144
+ try {
145
+ config = overrides?.config ?? injectionServerConfig();
146
+ } catch (cause) {
147
+ log.error(`injection: stable server unavailable, skipping agent.create hook: ${cause instanceof Error ? cause.message : String(cause)}`);
148
+ return () => {};
149
+ }
150
+ log.info(`injection: registering agent.create hook under key '${serverName}'`);
151
+ return registerMcpInjection(server, { serverName, config });
152
+ }
@@ -0,0 +1,153 @@
1
+ import { hostname } from "node:os";
2
+ import type { PaseoApi } from "@getpaseo/client";
3
+ import { localServerId } from "./peer-channel";
4
+
5
+ /**
6
+ * Native send for targets that live on THIS daemon.
7
+ *
8
+ * `PaseoApi.send()` is bound to one daemon connection — it carries no host or
9
+ * serverId (see PaseoAgentSendOptions) — so it can only reach agents on the
10
+ * daemon the handle was created for. The plugin server's `context.paseo` is
11
+ * exactly that local daemon handle. Remote peers still go through the bundled
12
+ * MCP server's `paseo send --host` path; there is no host-targeted SDK call.
13
+ *
14
+ * The envelope is the wire contract. This producer must stay byte-compatible
15
+ * with the MCP server's `senderMetaBlock` (mcp/paseo-x-comms.mjs), which stamps
16
+ * the remote path: same prefix, same version, same field set. `parseEnvelope`
17
+ * in shared/envelope.ts reads both.
18
+ */
19
+
20
+ export const ENVELOPE_PREFIX = "[x-comms] ";
21
+
22
+ /**
23
+ * Sending a message to your own agent is a mistake (the envelope tells the
24
+ * recipient it is from itself). Same fixed label the MCP server uses, so the
25
+ * failure is greppable and identical on both delivery paths.
26
+ */
27
+ export const SELF_MESSAGE_LABEL = "x-comms self-message";
28
+
29
+ export function assertNotSelfMessage(agentId: string, fromAgentId: string | null): void {
30
+ if (fromAgentId && agentId === fromAgentId) {
31
+ throw new Error(
32
+ `${SELF_MESSAGE_LABEL}: target agentId '${agentId}' is your own agent — choose a different agent.`,
33
+ );
34
+ }
35
+ }
36
+
37
+ export interface SenderIdentity {
38
+ agentId: string | null;
39
+ agentName: string | null;
40
+ host: string;
41
+ daemonServerId: string | null;
42
+ cwd: string | null;
43
+ }
44
+
45
+ export interface LocalSendInput {
46
+ agentId: string;
47
+ prompt: string;
48
+ fromAgentId?: string | null;
49
+ fromAgentName?: string | null;
50
+ targetDaemon: string;
51
+ }
52
+
53
+ /**
54
+ * Build the `[x-comms] {…}` envelope exactly as the MCP server's version-4
55
+ * stamp does. Pure so the contract is testable without a daemon.
56
+ */
57
+ export function buildSenderEnvelope(args: {
58
+ sender: SenderIdentity;
59
+ target: { daemon: string | null; agentId: string | null };
60
+ sentAt: string;
61
+ }): string {
62
+ const envelope = {
63
+ xComms: {
64
+ version: 4,
65
+ type: "x-comms.message",
66
+ direction: "outgoing",
67
+ sender: {
68
+ agentId: args.sender.agentId,
69
+ agentName: args.sender.agentName,
70
+ host: args.sender.host,
71
+ daemonServerId: args.sender.daemonServerId,
72
+ cwd: args.sender.cwd,
73
+ },
74
+ target: {
75
+ daemon: args.target.daemon ?? null,
76
+ agentId: args.target.agentId ?? null,
77
+ },
78
+ sentAt: args.sentAt,
79
+ },
80
+ };
81
+ return `${ENVELOPE_PREFIX}${JSON.stringify(envelope)}`;
82
+ }
83
+
84
+ /** True when a resolved target serverId is this daemon's own serverId. */
85
+ export function isLocalIdentity(
86
+ targetServerId: string | null,
87
+ selfServerId: string | null,
88
+ ): boolean {
89
+ return targetServerId !== null && selfServerId !== null && targetServerId === selfServerId;
90
+ }
91
+
92
+ export type SendRoute = "local" | "remote";
93
+
94
+ /**
95
+ * Pick the delivery path for a resolved target. Local requires all three: a
96
+ * local PaseoApi handle, a resolved target serverId, and a match with this
97
+ * daemon's serverId. Anything unresolved stays remote, so an unidentifiable
98
+ * direct host conservatively uses the CLI path rather than guessing.
99
+ */
100
+ export function resolveSendRoute(args: {
101
+ hasLocalPaseo: boolean;
102
+ targetServerId: string | null;
103
+ selfServerId: string | null;
104
+ }): SendRoute {
105
+ if (!args.hasLocalPaseo) return "remote";
106
+ return isLocalIdentity(args.targetServerId, args.selfServerId) ? "local" : "remote";
107
+ }
108
+
109
+ /**
110
+ * Best-effort sender identity for the local stamp. The sender agent's title
111
+ * and cwd come from its snapshot; a lookup failure degrades to null fields
112
+ * (the envelope stays valid, matching the MCP fallback behavior).
113
+ */
114
+ export async function gatherLocalSenderIdentity(
115
+ paseo: PaseoApi,
116
+ input: Pick<LocalSendInput, "fromAgentId" | "fromAgentName">,
117
+ ): Promise<SenderIdentity> {
118
+ const agentId = input.fromAgentId ?? null;
119
+ let agentName = input.fromAgentName ?? null;
120
+ let cwd: string | null = null;
121
+ if (agentId) {
122
+ try {
123
+ const result = await paseo.agents.ref(agentId).refresh();
124
+ const agent = result?.agent ?? null;
125
+ if (!agentName) agentName = agent?.title ?? null;
126
+ cwd = agent?.cwd ?? null;
127
+ } catch {
128
+ // id-only identity is still a valid envelope.
129
+ }
130
+ }
131
+ let daemonServerId: string | null = null;
132
+ try {
133
+ daemonServerId = await localServerId();
134
+ } catch {
135
+ // unreadable server-id: omit it rather than fail the send.
136
+ }
137
+ return { agentId, agentName, host: hostname(), daemonServerId, cwd };
138
+ }
139
+
140
+ /**
141
+ * Send to a local agent via the native SDK. Mirrors `paseo send --no-wait`:
142
+ * `send()` dispatches the message and resolves without waiting for the turn.
143
+ */
144
+ export async function sendLocalNative(paseo: PaseoApi, input: LocalSendInput): Promise<void> {
145
+ assertNotSelfMessage(input.agentId, input.fromAgentId ?? null);
146
+ const sender = await gatherLocalSenderIdentity(paseo, input);
147
+ const stamped = `${buildSenderEnvelope({
148
+ sender,
149
+ target: { daemon: input.targetDaemon, agentId: input.agentId },
150
+ sentAt: new Date().toISOString(),
151
+ })}\n\n${input.prompt}`;
152
+ await paseo.agents.ref(input.agentId).send(stamped);
153
+ }
@@ -0,0 +1,47 @@
1
+ import { McpClient } from "paseo-plugin-helper/mcp";
2
+ import { createPluginLogger } from "paseo-plugin-helper/server";
3
+
4
+ const log = createPluginLogger("paseo-x-comms", { subsystem: "mcp-client" });
5
+
6
+ /**
7
+ * Thin wrapper around the helper McpClient for talking to the installed
8
+ * paseo-x-comms server over stdio. Keeps the call sites stable: connect
9
+ * returns server info plus tool names, callTool returns parsed JSON payloads,
10
+ * and close releases the child process tree.
11
+ */
12
+ export class McpStdioClient {
13
+ private readonly client: McpClient;
14
+
15
+ constructor(serverPath: string, args: string[] = []) {
16
+ this.client = McpClient.forStdio(process.execPath, [serverPath, ...args], undefined, {
17
+ clientInfo: { name: "paseo-x-comms", version: "0.3.0" },
18
+ timeoutMs: 15000,
19
+ });
20
+ }
21
+
22
+ async connect(): Promise<{ serverInfo: unknown; tools: Array<{ name: string }> }> {
23
+ await this.client.initialize();
24
+ const tools = await this.client.listTools();
25
+ return { serverInfo: this.client.serverInfo, tools: tools.map((tool) => ({ name: tool.name })) };
26
+ }
27
+
28
+ async callTool(name: string, arguments_: Record<string, unknown>): Promise<unknown> {
29
+ const result = await this.client.callTool(name, arguments_);
30
+ if (result.isError) {
31
+ const text = result.content?.map((part) => part.text ?? "").join("\n") ?? "tool error";
32
+ throw new Error(text.slice(0, 400));
33
+ }
34
+ const text = result.content?.map((part) => part.text ?? "").join("\n") ?? "";
35
+ try {
36
+ return JSON.parse(text);
37
+ } catch {
38
+ return text;
39
+ }
40
+ }
41
+
42
+ close(): void {
43
+ this.client.close().catch((error: unknown) => {
44
+ log.error(`mcp client close failed: ${error instanceof Error ? error.message : String(error)}`);
45
+ });
46
+ }
47
+ }
@@ -0,0 +1,233 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { PluginStorage } from "paseo-plugin-helper/server";
3
+
4
+ /**
5
+ * Outbox for outbound conversation messages that could not be delivered.
6
+ *
7
+ * Retry strategy: per-entry exponential backoff, a periodic sweep, and an
8
+ * explicit immediate retry when a peer is observed reconnecting. Entries expire
9
+ * after a configurable window; expiry notifies the local sender with the reason.
10
+ *
11
+ * Idempotency is deliberately absent: the conversation protocol has no
12
+ * message-UUID slot and receivers keep no seen-id set for messages (see #12),
13
+ * so a retry after an ambiguous failure can re-deliver. Retry, expiry, and
14
+ * notify only.
15
+ */
16
+
17
+ export const OUTBOX_FILE = "outbox.json";
18
+ export const DEFAULT_OUTBOX_EXPIRY_MS = 10 * 60 * 1000;
19
+ export const OUTBOX_BACKOFF_BASE_MS = 5_000;
20
+ export const OUTBOX_BACKOFF_MAX_MS = 60_000;
21
+ export const OUTBOX_POLL_INTERVAL_MS = 15_000;
22
+
23
+ export interface OutboxMessageInput {
24
+ daemon: string;
25
+ agentId: string;
26
+ prompt: string;
27
+ fromAgentId?: string | null;
28
+ fromAgentName?: string | null;
29
+ }
30
+
31
+ export interface OutboxEntry {
32
+ id: string;
33
+ daemon: string;
34
+ agentId: string;
35
+ prompt: string;
36
+ fromAgentId: string | null;
37
+ fromAgentName: string | null;
38
+ createdAt: string;
39
+ expiresAt: string;
40
+ attempts: number;
41
+ lastAttemptAt: string | null;
42
+ lastError: string | null;
43
+ nextAttemptAt: string;
44
+ }
45
+
46
+ export interface OutboxState {
47
+ entries: OutboxEntry[];
48
+ }
49
+
50
+ export function emptyOutboxState(): OutboxState {
51
+ return { entries: [] };
52
+ }
53
+
54
+ /**
55
+ * Delay before the next attempt after `attempts` failures:
56
+ * `base * 2^(attempts-1)`, capped at `maxMs`.
57
+ */
58
+ export function outboxBackoffMs(
59
+ attempts: number,
60
+ baseMs: number = OUTBOX_BACKOFF_BASE_MS,
61
+ maxMs: number = OUTBOX_BACKOFF_MAX_MS,
62
+ ): number {
63
+ if (attempts <= 0) return 0;
64
+ return Math.min(baseMs * 2 ** (attempts - 1), maxMs);
65
+ }
66
+
67
+ export interface HoldOptions {
68
+ id?: string;
69
+ nowMs?: number;
70
+ expiryMs?: number;
71
+ /** Failure that caused the hold; counts as the first attempt. */
72
+ error?: string | null;
73
+ }
74
+
75
+ /** Append a held message and schedule its first retry after the backoff. */
76
+ export function holdMessage(
77
+ state: OutboxState,
78
+ input: OutboxMessageInput,
79
+ options: HoldOptions = {},
80
+ ): OutboxEntry {
81
+ const nowMs = options.nowMs ?? Date.now();
82
+ const expiryMs = options.expiryMs ?? DEFAULT_OUTBOX_EXPIRY_MS;
83
+ const error = options.error ?? null;
84
+ const attempts = error ? 1 : 0;
85
+ const entry: OutboxEntry = {
86
+ id: options.id ?? randomUUID(),
87
+ daemon: input.daemon,
88
+ agentId: input.agentId,
89
+ prompt: input.prompt,
90
+ fromAgentId: input.fromAgentId ?? null,
91
+ fromAgentName: input.fromAgentName ?? null,
92
+ createdAt: new Date(nowMs).toISOString(),
93
+ expiresAt: new Date(nowMs + expiryMs).toISOString(),
94
+ attempts,
95
+ lastAttemptAt: attempts > 0 ? new Date(nowMs).toISOString() : null,
96
+ lastError: error,
97
+ nextAttemptAt: new Date(nowMs + outboxBackoffMs(attempts)).toISOString(),
98
+ };
99
+ state.entries.push(entry);
100
+ return entry;
101
+ }
102
+
103
+ export function dueEntries(state: OutboxState, nowMs: number): OutboxEntry[] {
104
+ return state.entries.filter((entry) => Date.parse(entry.nextAttemptAt) <= nowMs);
105
+ }
106
+
107
+ export function expiredEntries(state: OutboxState, nowMs: number): OutboxEntry[] {
108
+ return state.entries.filter((entry) => Date.parse(entry.expiresAt) <= nowMs);
109
+ }
110
+
111
+ export function removeEntries(state: OutboxState, ids: Iterable<string>): void {
112
+ const drop = new Set(ids);
113
+ state.entries = state.entries.filter((entry) => !drop.has(entry.id));
114
+ }
115
+
116
+ export interface OutboxDelivery {
117
+ /** Resolve when delivered; reject to schedule a backoff retry. */
118
+ deliver(entry: OutboxEntry): Promise<void>;
119
+ /** Best-effort notice to the sender for an entry that expired. */
120
+ notify(entry: OutboxEntry, reason: string): Promise<void>;
121
+ }
122
+
123
+ export interface OutboxPassOptions {
124
+ nowMs?: number;
125
+ baseDelayMs?: number;
126
+ maxDelayMs?: number;
127
+ /** Retry every held entry for this daemon immediately (peer reconnect). */
128
+ forceDaemon?: string;
129
+ }
130
+
131
+ export interface OutboxPassResult {
132
+ delivered: string[];
133
+ retried: string[];
134
+ expired: string[];
135
+ notified: string[];
136
+ notifyFailed: string[];
137
+ }
138
+
139
+ export function formatOutboxDuration(ms: number): string {
140
+ const seconds = Math.max(0, Math.round(ms / 1000));
141
+ if (seconds < 60) return `${seconds}s`;
142
+ const minutes = Math.floor(seconds / 60);
143
+ const rest = seconds % 60;
144
+ return rest === 0 ? `${minutes}m` : `${minutes}m ${rest}s`;
145
+ }
146
+
147
+ export function outboxExpiryReason(entry: OutboxEntry, nowMs: number): string {
148
+ const held = formatOutboxDuration(nowMs - Date.parse(entry.createdAt));
149
+ const attempts = entry.attempts === 1 ? "1 attempt" : `${entry.attempts} attempts`;
150
+ const last = entry.lastError ? `; last error: ${entry.lastError}` : "";
151
+ return `could not deliver to '${entry.daemon}/${entry.agentId}' after ${held} (${attempts}${last})`;
152
+ }
153
+
154
+ /**
155
+ * One sweep: expire overdue entries (notify the sender, then drop), then retry
156
+ * every due entry once. Mutates `state`; returns what happened.
157
+ */
158
+ export async function runOutboxPass(
159
+ state: OutboxState,
160
+ delivery: OutboxDelivery,
161
+ options: OutboxPassOptions = {},
162
+ ): Promise<OutboxPassResult> {
163
+ const nowMs = options.nowMs ?? Date.now();
164
+ const baseMs = options.baseDelayMs ?? OUTBOX_BACKOFF_BASE_MS;
165
+ const maxMs = options.maxDelayMs ?? OUTBOX_BACKOFF_MAX_MS;
166
+ const result: OutboxPassResult = {
167
+ delivered: [],
168
+ retried: [],
169
+ expired: [],
170
+ notified: [],
171
+ notifyFailed: [],
172
+ };
173
+
174
+ const expired = expiredEntries(state, nowMs);
175
+ for (const entry of expired) {
176
+ const reason = outboxExpiryReason(entry, nowMs);
177
+ try {
178
+ await delivery.notify(entry, reason);
179
+ result.notified.push(entry.id);
180
+ } catch {
181
+ result.notifyFailed.push(entry.id);
182
+ }
183
+ }
184
+ removeEntries(state, expired.map((entry) => entry.id));
185
+ result.expired.push(...expired.map((entry) => entry.id));
186
+
187
+ if (options.forceDaemon) {
188
+ const forcedAt = new Date(nowMs).toISOString();
189
+ for (const entry of state.entries) {
190
+ if (entry.daemon === options.forceDaemon) entry.nextAttemptAt = forcedAt;
191
+ }
192
+ }
193
+
194
+ for (const entry of dueEntries(state, nowMs)) {
195
+ try {
196
+ await delivery.deliver(entry);
197
+ removeEntries(state, [entry.id]);
198
+ result.delivered.push(entry.id);
199
+ } catch (cause) {
200
+ entry.attempts += 1;
201
+ entry.lastAttemptAt = new Date(nowMs).toISOString();
202
+ entry.lastError = cause instanceof Error ? cause.message : String(cause);
203
+ entry.nextAttemptAt = new Date(nowMs + outboxBackoffMs(entry.attempts, baseMs, maxMs)).toISOString();
204
+ result.retried.push(entry.id);
205
+ }
206
+ }
207
+
208
+ return result;
209
+ }
210
+
211
+ const outboxStore = new PluginStorage<OutboxState>("paseo-x-comms", OUTBOX_FILE, {
212
+ defaultData: emptyOutboxState(),
213
+ });
214
+
215
+ export function outboxPath(): string {
216
+ return outboxStore.filePath;
217
+ }
218
+
219
+ export function readOutbox(): OutboxState {
220
+ try {
221
+ const data = outboxStore.read();
222
+ if (data && typeof data === "object" && Array.isArray(data.entries)) {
223
+ return { entries: data.entries as OutboxEntry[] };
224
+ }
225
+ } catch {
226
+ // Corrupt state falls back to empty; the held message is lost and logged by callers.
227
+ }
228
+ return emptyOutboxState();
229
+ }
230
+
231
+ export function writeOutbox(state: OutboxState): void {
232
+ outboxStore.write(state);
233
+ }