@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,1081 @@
1
+ import { readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { createPeriodicTask, createPluginLogger, safeSpawn } from "paseo-plugin-helper/server";
4
+ import type { PaseoApi } from "@getpaseo/client";
5
+ import { withTimeout } from "paseo-plugin-helper/shared";
6
+ import { getSnapshotFresh, agentCountFor, refreshSnapshot, initializeSnapshot } from "./snapshot";
7
+ import {
8
+ registryReadRpc,
9
+ daemonAddRpc,
10
+ daemonUpdateRpc,
11
+ daemonRemoveRpc,
12
+ daemonHealthRpc,
13
+ } from "../shared/registry";
14
+ import {
15
+ parseRegistry,
16
+ validateDaemonHost,
17
+ validateDaemonName,
18
+ currentRegistryPath,
19
+ readRegistry,
20
+ mutateRegistry,
21
+ deriveHostFromValue,
22
+ } from "./registry";
23
+ import { serverPath } from "./server-status";
24
+ import { readRelayStatus } from "./relay-status";
25
+ import { resolveSendRoute, sendLocalNative } from "./local-send";
26
+
27
+ // Startup check: validate whatever is already in the registry as soon as the
28
+ // plugin backend loads, so a corrupt or invalid config is caught early and
29
+ // visible in `paseo plugin logs`.
30
+ const log = createPluginLogger("paseo-x-comms");
31
+
32
+ // The host Paseo API, remembered from the most recent handler/hook context so
33
+ // the periodic outbox worker can append expiry notices to a local agent's
34
+ // timeline even when no RPC is in flight.
35
+ let paseoRef: PaseoApi | null = null;
36
+
37
+ export function rememberPaseo(paseo: PaseoApi | null | undefined): void {
38
+ if (paseo) paseoRef = paseo;
39
+ }
40
+
41
+ export function runStartupCheck(): void {
42
+ const registryPath = currentRegistryPath();
43
+ const current = readRegistry(registryPath);
44
+ if (!current.exists) {
45
+ log.info(`no registry at ${registryPath} (will be created on first save)`);
46
+ return;
47
+ }
48
+ if (!current.ok) {
49
+ log.error(`existing registry at ${registryPath} is corrupt: ${current.parseError}`);
50
+ return;
51
+ }
52
+ const invalid = current.daemons.filter((daemon) => !daemon.valid);
53
+ if (invalid.length > 0) {
54
+ log.error(
55
+ `${invalid.length} invalid entr${invalid.length === 1 ? "y" : "ies"} at ${registryPath}: ${invalid
56
+ .map((daemon) => `${daemon.name} (${daemon.error})`)
57
+ .join(", ")}`,
58
+ );
59
+ } else {
60
+ log.info(`${current.daemons.length} daemon(s) at ${registryPath}, all valid`);
61
+ }
62
+ const flags = resolveFeatureFlags(readUiPrefs());
63
+ log.info(`presence ${flags.presenceEnabled ? "enabled" : "disabled"}, injection ${flags.injectionEnabled ? "enabled" : "disabled"}`);
64
+ }
65
+
66
+ // Runs when this module has fully evaluated (invoked at the bottom of this
67
+ // file): module-level const initializers below must exist first, since the
68
+ // daemon compiles to CJS where top-level calls execute in source order.
69
+
70
+ export function hostnameFor(daemon: string): string | null {
71
+ const hostnames = readUiPrefs().daemonHostnames ?? {};
72
+ return hostnames[daemon] ?? null;
73
+ }
74
+
75
+ export async function handleRegistryRead() {
76
+ const registryPath = currentRegistryPath();
77
+ const current = readRegistry(registryPath);
78
+ const daemons = current.daemons.map((daemon) => ({
79
+ ...daemon,
80
+ serverId: identityFor(daemon.name),
81
+ hostname: hostnameFor(daemon.name),
82
+ }));
83
+ return { registryPath, exists: current.exists, validJson: current.ok, parseError: current.parseError, daemons };
84
+ }
85
+
86
+ export async function handleDaemonAdd(input: { name: string; value: string }) {
87
+ const registryPath = currentRegistryPath();
88
+ const nameCheck = validateDaemonName(input.name);
89
+ if (!nameCheck.valid) {
90
+ return { saved: false, error: nameCheck.error, registryPath, daemons: readRegistry(registryPath).daemons };
91
+ }
92
+ const result = mutateRegistry(registryPath, (daemons) => {
93
+ if (daemons[input.name] !== undefined) {
94
+ throw new Error(`daemon '${input.name}' already exists`);
95
+ }
96
+ return { ...daemons, [input.name]: input.value };
97
+ });
98
+ if (result.saved) await refreshSnapshot();
99
+ return result;
100
+ }
101
+
102
+ export async function handleDaemonUpdate(input: { name: string; rename?: string; value?: string }) {
103
+ const registryPath = currentRegistryPath();
104
+ const nameCheck = validateDaemonName(input.name);
105
+ if (!nameCheck.valid) {
106
+ return { saved: false, error: nameCheck.error, registryPath, daemons: readRegistry(registryPath).daemons };
107
+ }
108
+ const rename = input.rename?.trim();
109
+ if (rename !== undefined) {
110
+ const renameCheck = validateDaemonName(rename);
111
+ if (!renameCheck.valid) {
112
+ return { saved: false, error: renameCheck.error, registryPath, daemons: readRegistry(registryPath).daemons };
113
+ }
114
+ }
115
+ const result = mutateRegistry(registryPath, (daemons) => {
116
+ if (daemons[input.name] === undefined) {
117
+ throw new Error(`daemon '${input.name}' does not exist`);
118
+ }
119
+ const target = rename && rename !== input.name ? rename : input.name;
120
+ if (target !== input.name && daemons[target] !== undefined) {
121
+ throw new Error(`daemon '${target}' already exists`);
122
+ }
123
+ const value = input.value?.trim() ?? daemons[input.name];
124
+ const next = { ...daemons };
125
+ delete next[input.name];
126
+ return { ...next, [target]: value };
127
+ });
128
+ if (result.saved) await refreshSnapshot();
129
+ return result;
130
+ }
131
+
132
+ export async function handleDaemonRemove(input: { name: string }) {
133
+ const registryPath = currentRegistryPath();
134
+ const nameCheck = validateDaemonName(input.name);
135
+ if (!nameCheck.valid) {
136
+ return { saved: false, error: nameCheck.error, registryPath, daemons: readRegistry(registryPath).daemons };
137
+ }
138
+ const result = mutateRegistry(registryPath, (daemons) => {
139
+ if (daemons[input.name] === undefined) {
140
+ throw new Error(`daemon '${input.name}' does not exist`);
141
+ }
142
+ const next = { ...daemons };
143
+ delete next[input.name];
144
+ return next;
145
+ });
146
+ if (result.saved) await refreshSnapshot();
147
+ return result;
148
+ }
149
+
150
+ export async function handleDaemonHealth(_input?: unknown, context?: PluginHandlerContext) {
151
+ rememberPaseo(context?.paseo);
152
+ const snapshot = await getSnapshotFresh();
153
+ for (const entry of snapshot.daemons) notePeerReachability(entry.name, entry.reachable);
154
+ return {
155
+ results: snapshot.daemons.map((entry) => ({
156
+ name: entry.name,
157
+ reachable: entry.reachable,
158
+ error: entry.error,
159
+ agentCount: agentCountFor(entry),
160
+ })),
161
+ };
162
+ }
163
+
164
+ export async function handleIntrospectAgents() {
165
+ const snapshot = await getSnapshotFresh();
166
+ return {
167
+ daemons: snapshot.daemons.map(({ name, reachable, error, projects }) => ({ name, reachable, error, projects })),
168
+ };
169
+ }
170
+
171
+ import { McpStdioClient } from "./mcp-client";
172
+
173
+ // Introduce sends go through the bundled paseo-x-comms server over stdio MCP,
174
+ // so every message carries the meta envelope (sender identity) stamped by the
175
+ // server itself. (Local `conversation.send` targets skip this path and send
176
+ // natively; see deliverConversationMessage.) Each recipient gets the other
177
+ // party's address so a real two-way reply is possible, not just two one-way drops.
178
+ export async function handleIntroduceAgents(input: {
179
+ first: { daemon: string; agentId: string; shortId: string; name: string };
180
+ second: { daemon: string; agentId: string; shortId: string; name: string };
181
+ message: string;
182
+ }) {
183
+ let path: string;
184
+ try {
185
+ path = serverPath();
186
+ } catch (cause) {
187
+ const msg = cause instanceof Error ? cause.message : String(cause);
188
+ return { sends: [{ daemon: input.first.daemon, agentId: input.first.agentId, ok: false, error: msg }, { daemon: input.second.daemon, agentId: input.second.agentId, ok: false, error: msg }] };
189
+ }
190
+ const firstLabel = `Agent ${input.first.shortId} (${input.first.name}) on daemon "${input.first.daemon}"`;
191
+ const secondLabel = `Agent ${input.second.shortId} (${input.second.name}) on daemon "${input.second.daemon}"`;
192
+ const firstMessage = `${input.message.trim()}\n\nYou have been introduced to ${secondLabel}. To reply, use x_comms_send with daemon="${input.second.daemon}" and agentId="${input.second.agentId}". Your messages will be delivered with a sender envelope the other agent can use to reply.`;
193
+ const secondMessage = `${input.message.trim()}\n\nYou have been introduced to ${firstLabel}. To reply, use x_comms_send with daemon="${input.first.daemon}" and agentId="${input.first.agentId}". Your messages will be delivered with a sender envelope the other agent can use to reply.`;
194
+
195
+ const client = new McpStdioClient(path);
196
+ try {
197
+ await client.connect();
198
+ const targets = [
199
+ { daemon: input.first.daemon, agentId: input.first.agentId, fromAgentId: input.first.agentId, fromAgentName: input.first.name, message: firstMessage },
200
+ { daemon: input.second.daemon, agentId: input.second.agentId, fromAgentId: input.second.agentId, fromAgentName: input.second.name, message: secondMessage },
201
+ ];
202
+ const sends = await Promise.all(
203
+ targets.map(async (target) => {
204
+ try {
205
+ await client.callTool("x_comms_send", {
206
+ daemon: target.daemon,
207
+ agentId: target.agentId,
208
+ prompt: target.message,
209
+ fromAgentId: target.fromAgentId ?? null,
210
+ fromAgentName: target.fromAgentName ?? null,
211
+ });
212
+ return { daemon: target.daemon, agentId: target.agentId, ok: true, error: null };
213
+ } catch (cause) {
214
+ return {
215
+ daemon: target.daemon,
216
+ agentId: target.agentId,
217
+ ok: false,
218
+ error: cause instanceof Error ? cause.message : String(cause),
219
+ };
220
+ }
221
+ }),
222
+ );
223
+ for (const send of sends) {
224
+ if (!send.ok) continue;
225
+ const target = targets.find((t) => t.agentId === send.agentId && t.daemon === send.daemon);
226
+ const introduced = send.agentId === input.first.agentId && send.daemon === input.first.daemon
227
+ ? input.first
228
+ : input.second;
229
+ recordOutboundSend({
230
+ daemon: send.daemon,
231
+ agentId: send.agentId,
232
+ peerAgentName: introduced.name,
233
+ localAgentId: target?.fromAgentId ?? null,
234
+ });
235
+ }
236
+ return { sends };
237
+ } finally {
238
+ client.close();
239
+ }
240
+ }
241
+
242
+ export async function handleServerStatus() {
243
+ try {
244
+ const path = serverPath();
245
+ const version = extractServerVersion(path);
246
+ return { installPath: path, installed: true, configured: true, version, syntaxOk: true, error: null };
247
+ } catch (cause) {
248
+ return { installPath: "", installed: false, configured: false, version: null, syntaxOk: false, error: cause instanceof Error ? cause.message : String(cause) };
249
+ }
250
+ }
251
+
252
+ // Best-effort scrape of the version constant from the bundled server source;
253
+ // a null version never fails the status diagnostic.
254
+ function extractServerVersion(serverPath: string): string | null {
255
+ try {
256
+ const source = readFileSync(serverPath, "utf8");
257
+ const match = source.match(/\bVERSION\s*=\s*"([^"]+)"/);
258
+ return match ? match[1] : null;
259
+ } catch {
260
+ return null;
261
+ }
262
+ }
263
+
264
+ export interface ConversationSendInput {
265
+ daemon: string;
266
+ agentId: string;
267
+ prompt: string;
268
+ fromAgentId?: string | null;
269
+ fromAgentName?: string | null;
270
+ }
271
+
272
+ /**
273
+ * Deliver to a target that resolves to THIS daemon: native SDK send. Falls
274
+ * back to null when the target is remote, so callers use the MCP/CLI path.
275
+ * `paseo` is required — without a local handle there is no native route.
276
+ */
277
+ async function tryDeliverLocalNative(
278
+ input: ConversationSendInput,
279
+ paseo: PaseoApi | null,
280
+ ): Promise<boolean> {
281
+ if (!paseo) return false;
282
+ const targetServerId = targetServerIdFor(input.daemon);
283
+ if (!targetServerId) return false;
284
+ let self: string | null = null;
285
+ try {
286
+ self = await localServerId();
287
+ } catch {
288
+ return false;
289
+ }
290
+ if (resolveSendRoute({ hasLocalPaseo: true, targetServerId, selfServerId: self }) !== "local") {
291
+ return false;
292
+ }
293
+ await sendLocalNative(paseo, {
294
+ agentId: input.agentId,
295
+ prompt: input.prompt,
296
+ fromAgentId: input.fromAgentId ?? null,
297
+ fromAgentName: input.fromAgentName ?? null,
298
+ targetDaemon: input.daemon,
299
+ });
300
+ return true;
301
+ }
302
+
303
+ /**
304
+ * Deliver a conversation message. Local targets go out natively via the host
305
+ * PaseoApi; remote targets go through the bundled MCP server, which stamps the
306
+ * envelope and shells out to `paseo send --host` (no host-targeted SDK call
307
+ * exists). Rejects on failure; callers record the send or hold it in the outbox.
308
+ */
309
+ async function deliverConversationMessage(
310
+ input: ConversationSendInput,
311
+ paseo: PaseoApi | null = paseoRef,
312
+ ): Promise<void> {
313
+ if (await tryDeliverLocalNative(input, paseo)) return;
314
+ let sendDaemon = daemonNameForServerId(input.daemon) ?? input.daemon;
315
+ // Fallback: if daemon is a serverId (srv_…) and not in registry, scan registry values' offer serverId
316
+ if (sendDaemon === input.daemon && input.daemon.startsWith("srv_")) {
317
+ const byOffer = readRegistry(currentRegistryPath()).daemons.find((d) => parseOffer(d.value)?.serverId === input.daemon);
318
+ if (byOffer) sendDaemon = byOffer.name;
319
+ }
320
+ const client = new McpStdioClient(serverPath());
321
+ try {
322
+ await client.connect();
323
+ await client.callTool("x_comms_send", {
324
+ daemon: sendDaemon,
325
+ agentId: input.agentId,
326
+ prompt: input.prompt,
327
+ fromAgentId: input.fromAgentId ?? null,
328
+ fromAgentName: input.fromAgentName ?? null,
329
+ });
330
+ } finally {
331
+ client.close();
332
+ }
333
+ }
334
+
335
+ export async function handleConversationSend(input: ConversationSendInput, context?: PluginHandlerContext) {
336
+ rememberPaseo(context?.paseo);
337
+ try {
338
+ await deliverConversationMessage(input, context?.paseo ?? paseoRef);
339
+ } catch (cause) {
340
+ const error = cause instanceof Error ? cause.message : String(cause);
341
+ const entry = await withOutboxLock(() => {
342
+ const state = readOutbox();
343
+ const held = holdMessage(state, input, {
344
+ nowMs: Date.now(),
345
+ expiryMs: resolveOutboxExpiryMs(readUiPrefs()),
346
+ error,
347
+ });
348
+ writeOutbox(state);
349
+ return held;
350
+ });
351
+ log.warn(`outbox: held ${entry.id} for '${input.daemon}/${input.agentId}' until ${entry.expiresAt}: ${error}`);
352
+ return {
353
+ daemon: input.daemon,
354
+ agentId: input.agentId,
355
+ ok: false,
356
+ error: `undelivered; held in the outbox for retry until ${entry.expiresAt}: ${error}`,
357
+ };
358
+ }
359
+ recordOutboundSend({
360
+ daemon: input.daemon,
361
+ agentId: input.agentId,
362
+ localAgentId: input.fromAgentId ?? null,
363
+ });
364
+ return { daemon: input.daemon, agentId: input.agentId, ok: true, error: null };
365
+ }
366
+
367
+
368
+ const PROBE_TIMEOUT_MS = 8000;
369
+
370
+ function runProbe(value: string): Promise<void> {
371
+ return withTimeout(
372
+ safeSpawn("paseo", ["ls", "--host", value, "--json"], { timeoutMs: PROBE_TIMEOUT_MS }).then((r) => {
373
+ if (r.code !== 0) throw new Error((r.stderr || `exit ${r.code}`).trim());
374
+ }),
375
+ PROBE_TIMEOUT_MS,
376
+ "daemon probe",
377
+ );
378
+ }
379
+
380
+ export async function handleDaemonProbe(input: { value: string }) {
381
+ const format = validateDaemonHost(input.value);
382
+ if (!format.valid) {
383
+ return { valid: false, formatError: format.error, reachable: false, error: null };
384
+ }
385
+ try {
386
+ await runProbe(input.value);
387
+ return { valid: true, formatError: null, reachable: true, error: null };
388
+ } catch (cause) {
389
+ return {
390
+ valid: true,
391
+ formatError: null,
392
+ reachable: false,
393
+ error: cause instanceof Error ? cause.message : String(cause),
394
+ };
395
+ }
396
+ }
397
+
398
+
399
+
400
+ import { PluginStorage } from "paseo-plugin-helper/server";
401
+ import { resolveFeatureFlags, resolveInjectionEnabled, resolveOutboxExpiryMs, resolvePresenceEnabled, applyFeaturePrefsUpdate } from "./settings.ts";
402
+ import {
403
+ OUTBOX_POLL_INTERVAL_MS,
404
+ holdMessage,
405
+ outboxPath,
406
+ readOutbox,
407
+ runOutboxPass,
408
+ writeOutbox,
409
+ type OutboxEntry,
410
+ } from "./outbox";
411
+ import { OUTBOX_NOTICE_KIND, OUTBOX_NOTICE_VERSION } from "../shared/outbox.ts";
412
+ import { stateDir, migrateFromRoot } from "./registry";
413
+
414
+ const UI_PREFS_FILE = join(stateDir(), "plugin.json");
415
+ migrateFromRoot("paseo-x-comms-plugin.json", UI_PREFS_FILE);
416
+
417
+ interface UiPrefsState {
418
+ prereqsCollapsed?: boolean;
419
+ presenceEnabled?: boolean;
420
+ injectionEnabled?: boolean;
421
+ outboxExpirySeconds?: number;
422
+ daemonEnabled?: Record<string, boolean>;
423
+ daemonIdentities?: Record<string, string>;
424
+ daemonHostnames?: Record<string, string>;
425
+ serverPath?: string;
426
+ serverPathSet?: boolean;
427
+ }
428
+
429
+ const uiPrefsStore = new PluginStorage<UiPrefsState>("paseo-x-comms", "plugin.json", { defaultData: {} });
430
+
431
+ function readUiPrefs(): UiPrefsState {
432
+ try {
433
+ return uiPrefsStore.read();
434
+ } catch (err) {
435
+ log.error(`corrupt ${UI_PREFS_FILE}, ignoring: ${err instanceof Error ? err.message : String(err)}`);
436
+ }
437
+ return {};
438
+ }
439
+
440
+ function writeUiPrefs(state: UiPrefsState): void {
441
+ uiPrefsStore.write(state);
442
+ }
443
+
444
+ export function identityFor(daemon: string): string | null {
445
+ const identities = readUiPrefs().daemonIdentities ?? {};
446
+ return identities[daemon] ?? null;
447
+ }
448
+
449
+ /**
450
+ * The daemon serverId a send target resolves to, when it can be determined
451
+ * without a network probe: a raw `srv_…` id, a synced alias, or an embedded
452
+ * relay-offer id. A bare direct host has no identity, so it returns null and
453
+ * the send conservatively stays on the remote path.
454
+ */
455
+ export function targetServerIdFor(daemon: string): string | null {
456
+ if (daemon.startsWith("srv_")) return daemon;
457
+ const byIdentity = identityFor(daemon);
458
+ if (byIdentity) return byIdentity;
459
+ const entry = readRegistry(currentRegistryPath()).daemons.find((d) => d.name === daemon);
460
+ return deriveHostFromValue(entry?.value ?? daemon);
461
+ }
462
+
463
+ // The registry is keyed by daemon *name*, but x-comms envelopes carry the
464
+ // peer's serverId. identitySync stores name -> serverId, so invert it to map a
465
+ // sender's serverId back to the registered daemon name the send tool expects.
466
+ export function daemonNameForServerId(serverId: string | null): string | null {
467
+ if (!serverId) return null;
468
+ const identities = readUiPrefs().daemonIdentities ?? {};
469
+ for (const [name, id] of Object.entries(identities)) {
470
+ if (id === serverId) return name;
471
+ }
472
+ return null;
473
+ }
474
+
475
+ async function fetchPeerServerInfo(value: string): Promise<{ serverId: string; hostname: string | null } | null> {
476
+ const offer = parseOffer(value);
477
+ if (offer?.serverId) return { serverId: offer.serverId, hostname: null };
478
+ return null;
479
+ }
480
+
481
+ /**
482
+ * Fetch the real serverId from every registered daemon and store it in the
483
+ * plugin state (name -> serverId). Keeps the registry untouched so the MCP
484
+ * server's string-host contract is unaffected; identity is additive.
485
+ */
486
+ export async function handleIdentitySync() {
487
+ const daemons = readRegistry(currentRegistryPath()).daemons;
488
+ const state = readUiPrefs();
489
+ const identities: Record<string, string> = { ...(state.daemonIdentities ?? {}) };
490
+ const hostnames: Record<string, string> = { ...(state.daemonHostnames ?? {}) };
491
+ for (const daemon of daemons) {
492
+ const info = await fetchPeerServerInfo(daemon.value);
493
+ if (info?.serverId) identities[daemon.name] = info.serverId;
494
+ if (info?.hostname) hostnames[daemon.name] = info.hostname;
495
+ }
496
+ writeUiPrefs({ ...state, daemonIdentities: identities, daemonHostnames: hostnames });
497
+ return { identities, hostnames };
498
+ }
499
+
500
+ export async function handleUiPrefsGet() {
501
+ const prefs = readUiPrefs();
502
+ return {
503
+ prereqsCollapsed: prefs.prereqsCollapsed === true,
504
+ daemonEnabled: prefs.daemonEnabled ?? {},
505
+ outboxExpirySeconds: prefs.outboxExpirySeconds,
506
+ ...resolveFeatureFlags(prefs),
507
+ };
508
+ }
509
+
510
+ export async function handleUiPrefsSet(input: { prereqsCollapsed: boolean; presenceEnabled?: boolean; injectionEnabled?: boolean; outboxExpirySeconds?: number; daemonEnabled?: Record<string, boolean> }) {
511
+ const state = readUiPrefs();
512
+ writeUiPrefs({
513
+ ...state,
514
+ prereqsCollapsed: input.prereqsCollapsed,
515
+ ...applyFeaturePrefsUpdate(state, input),
516
+ });
517
+ const next = readUiPrefs();
518
+ return {
519
+ prereqsCollapsed: next.prereqsCollapsed === true,
520
+ daemonEnabled: next.daemonEnabled ?? {},
521
+ outboxExpirySeconds: next.outboxExpirySeconds,
522
+ ...resolveFeatureFlags(next),
523
+ };
524
+ }
525
+
526
+ export function presenceEnabled(): boolean {
527
+ return resolvePresenceEnabled(readUiPrefs());
528
+ }
529
+
530
+ export function injectionEnabled(): boolean {
531
+ return resolveInjectionEnabled(readUiPrefs());
532
+ }
533
+
534
+ export async function handleSnapshotRefresh(_input?: unknown, context?: PluginHandlerContext) {
535
+ rememberPaseo(context?.paseo);
536
+ const snapshot = await refreshSnapshot();
537
+ for (const entry of snapshot.daemons) notePeerReachability(entry.name, entry.reachable);
538
+ if (context?.paseo) await reconcileInbound(context.paseo);
539
+ return { updatedAt: snapshot.updatedAt };
540
+ }
541
+
542
+ async function runPaseoDumpJson(args: string[]): Promise<unknown> {
543
+ const r = await withTimeout(safeSpawn("paseo", args, { timeoutMs: 20000 }), 20000, "paseo dump");
544
+ if (r.code !== 0) throw new Error((r.stderr || `exit ${r.code}`).trim().slice(0, 200));
545
+ try { return JSON.parse(r.stdout); } catch { throw new Error("non-JSON output"); }
546
+ }
547
+
548
+ function parseOffer(value: string): {
549
+ serverId: string | null;
550
+ daemonPublicKeyB64: string | null;
551
+ relayEndpoint: string | null;
552
+ useTls: boolean;
553
+ } | null {
554
+ const m = value.match(/#offer=([A-Za-z0-9_-]+)/);
555
+ if (!m) return null;
556
+ try {
557
+ const payload = JSON.parse(Buffer.from(m[1], "base64").toString("utf8"));
558
+ return {
559
+ serverId: typeof payload.serverId === "string" ? payload.serverId : null,
560
+ daemonPublicKeyB64: typeof payload.daemonPublicKeyB64 === "string" ? payload.daemonPublicKeyB64 : null,
561
+ relayEndpoint: typeof payload.relay?.endpoint === "string" ? payload.relay.endpoint : null,
562
+ useTls: payload.relay?.useTls === true,
563
+ };
564
+ } catch {
565
+ return null;
566
+ }
567
+ }
568
+
569
+ function safe<T,>(raw: unknown, selector: (x: Record<string, unknown>) => T): T[] {
570
+ return Array.isArray(raw) ? (raw as Record<string, unknown>[]).map(selector) : [];
571
+ }
572
+ function str(value: unknown): string {
573
+ return typeof value === "string" ? value : String(value ?? "");
574
+ }
575
+
576
+ // The paseo CLI has varied its output shape across versions: some commands wrap
577
+ // their list in { data: [...] }, { schedules: [...] }, { terminals: [...] },
578
+ // etc. unwrapList tries the known wrapper keys in order before falling back to
579
+ // treating the raw value itself as a list.
580
+ function unwrapList(raw: unknown, ...subkeys: string[]): Record<string, unknown>[] {
581
+ if (Array.isArray(raw)) return raw as Record<string, unknown>[];
582
+ const obj = raw as Record<string, unknown> | null;
583
+ if (!obj || typeof obj !== "object") return [];
584
+ for (const key of subkeys) {
585
+ if (Array.isArray(obj[key])) return obj[key] as Record<string, unknown>[];
586
+ }
587
+ return [];
588
+ }
589
+
590
+ // daemon status is sometimes { data: { ... } } and sometimes the payload directly.
591
+ function unwrapStatusPayload(raw: unknown): Record<string, unknown> | null {
592
+ if (!raw || typeof raw !== "object") return null;
593
+ const obj = raw as Record<string, unknown>;
594
+ const data = obj.data;
595
+ if (data && typeof data === "object" && !Array.isArray(data)) return data as Record<string, unknown>;
596
+ return obj;
597
+ }
598
+
599
+ // Null-safe field access on an unwrapped status payload.
600
+ function field(payload: Record<string, unknown> | null, ...keys: string[]): unknown {
601
+ if (!payload) return undefined;
602
+ for (const key of keys) {
603
+ if (payload[key] !== undefined && payload[key] !== null && payload[key] !== "") return payload[key];
604
+ }
605
+ return undefined;
606
+ }
607
+
608
+ function notReachedResult(name: string, error: string, offer: ReturnType<typeof parseOffer>, transport: string): { name: string; reached: boolean; error: string | null; serverId: string | null; hostname: string | null; version: string | null; desktopManaged: boolean | null; capabilities: Record<string, unknown> | null; features: Record<string, boolean> | null; listen: string | null; pid: number | null; nodePath: string | null; startedAt: string | null; relayEndpoints: string[] | null; relayEnabled: boolean | null; transport: string; agents: { agentId: string; shortId: string; name: string; status: string; provider: string; model: string | null; providerOptions: Record<string, unknown> | null; cwd: string | null; workspaceId: string | null; projectName: string | null; createdAt: string | null; archived: boolean | null }[]; workspaces: { id: string; name: string; project: string; isolation: string; cwd: string | null }[]; projects: { id: string; name: string; source: string | null }[]; providers: { provider: string; available: boolean; error: string | null }[]; providerCount: number; permissions: { id: string; agentId: string; name: string }[]; schedules: { id: string; name: string; state: string }[]; terminals: { id: string; name: string; cwd: string | null; status: string | null }[] } {
609
+ return { name, reached: false, error, serverId: offer?.serverId ?? null, hostname: null, version: null, desktopManaged: null, capabilities: null, features: null, listen: null, pid: null, nodePath: null, startedAt: null, relayEndpoints: null, relayEnabled: null, transport, agents: [], workspaces: [], projects: [], providers: [], providerCount: 0, permissions: [], schedules: [], terminals: [] };
610
+ }
611
+
612
+ export async function handleDaemonDump(input: { daemon: string }) {
613
+ const daemons = readRegistry(currentRegistryPath()).daemons;
614
+ const entry = daemons.find((d) => d.name === input.daemon);
615
+ if (!entry) {
616
+ return notReachedResult(input.daemon, `unknown daemon '${input.daemon}' — pairing is required: add it via x_comms_add_daemon or pair the target daemon first`, null, "");
617
+ }
618
+ const offer = parseOffer(entry.value);
619
+ const transport = offer ? "relay" : "direct";
620
+ const hostValue = entry.value;
621
+ async function tryHost(args: string[]): Promise<unknown> {
622
+ try {
623
+ return await runPaseoDumpJson([...args, "--host", hostValue, "--json"]);
624
+ } catch {
625
+ return null;
626
+ }
627
+ }
628
+ try {
629
+ const [status, agentsRes, workspacesRes, projectsRes, schedRes, termRes] = await Promise.all([
630
+ tryHost(["daemon", "status"]),
631
+ tryHost(["ls", "--global"]),
632
+ tryHost(["workspace", "ls"]),
633
+ tryHost(["project", "ls"]),
634
+ tryHost(["schedule", "ls"]),
635
+ tryHost(["terminal", "ls"]),
636
+ ]);
637
+
638
+ const p = unwrapStatusPayload(status);
639
+ const agentsEntries = unwrapList(agentsRes, "data");
640
+ const workspacesEntries = unwrapList(workspacesRes, "data");
641
+ const projectsEntries = unwrapList(projectsRes, "data", "projects");
642
+ const schedEntries = unwrapList(schedRes, "schedules", "data");
643
+ const termEntries = unwrapList(termRes, "terminals", "data");
644
+
645
+ const providersRaw = field(p, "providers") ?? field({ providers: (status as Record<string, unknown>)?.providers }, "providers") ?? [];
646
+ const providersFromStatus = safe(providersRaw, (prov) => ({
647
+ provider: str(prov.provider),
648
+ available: (prov as Record<string, unknown>).available === true,
649
+ error: ((prov as Record<string, unknown>).error as string | null) ?? null,
650
+ }));
651
+
652
+ const serverId = str(field(p, "serverId") ?? offer?.serverId ?? "");
653
+ const hostname = str(field(p, "hostname") ?? "");
654
+ const version = str(field(p, "daemonVersion", "version") ?? "");
655
+ const relay = readRelayStatus(field(p, "relay"));
656
+
657
+ const reached = status !== null || agentsRes !== null || workspacesRes !== null;
658
+ if (!reached) throw new Error("all peer probes failed");
659
+
660
+ return {
661
+ name: input.daemon,
662
+ reached: true,
663
+ error: null,
664
+ serverId,
665
+ hostname,
666
+ version,
667
+ desktopManaged: (field(p, "desktopManaged") as boolean | null) ?? null,
668
+ capabilities: null,
669
+ features: null,
670
+ listen: str(field(p, "listen") ?? ""),
671
+ pid: (field(p, "pid") as number | null) ?? null,
672
+ nodePath: str(field(p, "daemonNode", "nodePath") ?? ""),
673
+ startedAt: str(field(p, "startedAt") ?? ""),
674
+ relayEndpoints: relay.endpoints,
675
+ relayEnabled: relay.enabled,
676
+ transport,
677
+ agents: agentsEntries.map((a) => {
678
+ // ls entries are sometimes { agent: { ... }, project: { ... } }, sometimes flat.
679
+ const agent = (a.agent as Record<string, unknown>) ?? a;
680
+ return {
681
+ agentId: str(agent.id), shortId: str(agent.shortId), name: str(agent.name), status: str(agent.status),
682
+ provider: str(agent.provider),
683
+ model: (agent.model as string | null) ?? null,
684
+ providerOptions: (agent.providerOptions as Record<string, unknown> | null) ?? null,
685
+ cwd: str(agent.cwd ?? ""),
686
+ workspaceId: str((a.project as Record<string, unknown>)?.workspaceId ?? agent.workspaceId ?? ""),
687
+ projectName: str((a.project as Record<string, unknown>)?.name ?? agent.project ?? ""),
688
+ createdAt: str(agent.created ?? agent.createdAt ?? ""),
689
+ archived: (agent.archived as boolean | null) ?? null,
690
+ };
691
+ }),
692
+ workspaces: workspacesEntries.map((w) => ({
693
+ id: str(w.id ?? w.workspaceId ?? ""), name: str(w.name), project: str(w.project), isolation: str(w.isolation), cwd: str(w.cwd ?? ""),
694
+ })),
695
+ projects: projectsEntries.map((pr) => ({
696
+ id: str(pr.id ?? pr.projectId ?? ""), name: str(pr.name), source: str(pr.source ?? ""),
697
+ })),
698
+ providers: providersFromStatus,
699
+ providerCount: providersFromStatus.length,
700
+ permissions: [],
701
+ schedules: schedEntries.map((sc) => ({
702
+ id: str(sc.id ?? sc.scheduleId ?? ""), name: str(sc.name ?? ""), state: str(sc.state ?? sc.enabled ?? ""),
703
+ })),
704
+ terminals: termEntries.map((t) => ({
705
+ id: str(t.id ?? t.terminalId ?? ""), name: str(t.name ?? t.title ?? ""),
706
+ cwd: (t.cwd as string | null) ?? null, status: (t.status as string | null) ?? null,
707
+ })),
708
+ };
709
+ } catch (cause) {
710
+ return notReachedResult(input.daemon, cause instanceof Error ? cause.message : String(cause), offer, transport);
711
+ }
712
+ }
713
+
714
+ import { randomUUID } from "node:crypto";
715
+ import type { PluginHandlerContext } from "@getpaseo/plugin/server";
716
+ import {
717
+ applyAnnounce,
718
+ applyRetract,
719
+ pendingForPeer,
720
+ queueRetract,
721
+ readPresence,
722
+ sweepExpired,
723
+ writePresence,
724
+ type PresenceBirth,
725
+ } from "./presence";
726
+ import { invokePeerRpc, localServerId, resolvePeerTarget, type PeerTarget } from "./peer-channel";
727
+ import {
728
+ detachLocalAgent,
729
+ prunePeer,
730
+ readConversationsSnapshot,
731
+ reconcileTimelines,
732
+ recordSend,
733
+ scanLocalTimelines,
734
+ writeConversationsSnapshot,
735
+ type TimelineScanner,
736
+ } from "./conversations-snapshot.ts";
737
+
738
+ /**
739
+ * serverId to registry alias for snapshot display. Offer-embedded ids first,
740
+ * synced identities second. Unknown ids render with a fallback alias.
741
+ */
742
+ function peerAliasMap(): Map<string, string> {
743
+ const map = new Map<string, string>();
744
+ for (const daemon of readRegistry(currentRegistryPath()).daemons) {
745
+ if (!daemon.valid) continue;
746
+ const offerId = deriveHostFromValue(daemon.value);
747
+ if (offerId && !map.has(offerId)) map.set(offerId, daemon.name);
748
+ }
749
+ for (const [name, id] of Object.entries(readUiPrefs().daemonIdentities ?? {})) {
750
+ if (id && !map.has(id)) map.set(id, name);
751
+ }
752
+ return map;
753
+ }
754
+
755
+ function peerAliasFor(serverId: string): string | null {
756
+ return peerAliasMap().get(serverId) ?? null;
757
+ }
758
+
759
+ function recordOutboundSend(args: {
760
+ daemon: string;
761
+ agentId: string;
762
+ peerAgentName?: string | null;
763
+ localAgentId?: string | null;
764
+ }): void {
765
+ const sendDaemon = daemonNameForServerId(args.daemon) ?? args.daemon;
766
+ const entry = readRegistry(currentRegistryPath()).daemons.find((d) => d.name === sendDaemon);
767
+ const peerServerId = identityFor(sendDaemon) ?? (entry ? deriveHostFromValue(entry.value) : null) ?? "";
768
+ const snapshot = readConversationsSnapshot();
769
+ writeConversationsSnapshot(recordSend(snapshot, {
770
+ peerAlias: sendDaemon,
771
+ peerServerId,
772
+ peerAgentId: args.agentId,
773
+ peerAgentName: args.peerAgentName ?? null,
774
+ localAgentId: args.localAgentId ?? null,
775
+ at: new Date().toISOString(),
776
+ }));
777
+ }
778
+
779
+ /**
780
+ * Reconcile inbound envelopes from local timelines into the snapshot.
781
+ * Best-effort: timeline failures never fail the calling RPC.
782
+ */
783
+ async function reconcileInbound(paseo: TimelineScanner): Promise<void> {
784
+ try {
785
+ const timelines = await scanLocalTimelines(paseo);
786
+ const snapshot = readConversationsSnapshot();
787
+ writeConversationsSnapshot(reconcileTimelines(snapshot, timelines, peerAliasFor));
788
+ } catch (cause) {
789
+ log.error(`conversations: timeline reconcile failed: ${cause instanceof Error ? cause.message : String(cause)}`);
790
+ }
791
+ }
792
+ function knownPeerServerIds(): Set<string> {
793
+ const ids = new Set<string>();
794
+ for (const daemon of readRegistry(currentRegistryPath()).daemons) {
795
+ if (!daemon.valid) continue;
796
+ const id = deriveHostFromValue(daemon.value);
797
+ if (id) ids.add(id);
798
+ }
799
+ return ids;
800
+ }
801
+
802
+ function validPeerTargets(): PeerTarget[] {
803
+ const targets: PeerTarget[] = [];
804
+ for (const daemon of readRegistry(currentRegistryPath()).daemons) {
805
+ if (!daemon.valid) continue;
806
+ try {
807
+ targets.push(resolvePeerTarget(daemon.name, daemon.value));
808
+ } catch (cause) {
809
+ log.error(`presence: skipping undialable peer '${daemon.name}': ${cause instanceof Error ? cause.message : String(cause)}`);
810
+ }
811
+ }
812
+ return targets;
813
+ }
814
+
815
+ export async function handlePresenceAnnounce(input: { messageId: string; entries: PresenceBirth[] }) {
816
+ const known = knownPeerServerIds();
817
+ const state = readPresence();
818
+ let accepted = 0;
819
+ let rejected = 0;
820
+ for (const entry of input.entries.slice(0, 500)) {
821
+ if (!known.has(entry.serverId)) {
822
+ rejected += 1;
823
+ continue;
824
+ }
825
+ const outcome = applyAnnounce(state, entry, `${input.messageId}:${entry.serverId}/${entry.agentId}`, "remote");
826
+ if (outcome.result === "accepted") accepted += 1;
827
+ else rejected += 1;
828
+ }
829
+ writePresence(state);
830
+ return { accepted, rejected };
831
+ }
832
+
833
+ export async function handlePresenceRetract(input: { messageId: string; serverId: string; agentId: string; timestamp: string }) {
834
+ const known = knownPeerServerIds();
835
+ if (!known.has(input.serverId)) return { applied: false };
836
+ const state = readPresence();
837
+ const outcome = applyRetract(state, input.serverId, input.agentId, input.timestamp, input.messageId);
838
+ writePresence(state);
839
+ if (outcome.applied) {
840
+ const conversations = readConversationsSnapshot();
841
+ const pruned = prunePeer(conversations, input.serverId, input.agentId);
842
+ if (pruned.removed) writeConversationsSnapshot(pruned.snapshot);
843
+ }
844
+ return { applied: outcome.applied };
845
+ }
846
+
847
+ export async function handlePresenceList() {
848
+ const state = readPresence();
849
+ const swept = sweepExpired(state);
850
+ if (swept.expiredLive.length > 0 || swept.expiredTombstones.length > 0) {
851
+ log.error(
852
+ `presence: TTL sweep fired (live: ${swept.expiredLive.join(", ") || "none"}; tombstones: ${swept.expiredTombstones.join(", ") || "none"}). ` +
853
+ `TTL is a safety net only; announcements or retracts stopped flowing.`,
854
+ );
855
+ writePresence(state);
856
+ }
857
+ return {
858
+ live: Object.values(state.live),
859
+ tombstones: Object.values(state.tombstones),
860
+ pendingRetracts: state.pendingRetracts.length,
861
+ };
862
+ }
863
+
864
+ async function flushPendingRetracts(target: PeerTarget): Promise<void> {
865
+ const state = readPresence();
866
+ const pending = pendingForPeer(state, target.name);
867
+ for (const item of pending) {
868
+ try {
869
+ await invokePeerRpc(target, "presence.retract", {
870
+ messageId: item.messageId,
871
+ serverId: item.serverId,
872
+ agentId: item.agentId,
873
+ timestamp: item.timestamp,
874
+ });
875
+ state.pendingRetracts = state.pendingRetracts.filter((p) => p.messageId !== item.messageId);
876
+ writePresence(state);
877
+ } catch (cause) {
878
+ item.attempts += 1;
879
+ writePresence(state);
880
+ log.error(`presence: queued retract still failing for '${target.name}': ${cause instanceof Error ? cause.message : String(cause)}`);
881
+ return;
882
+ }
883
+ }
884
+ }
885
+
886
+ /**
887
+ * Local hook: buffer a birth and announce it to every dialable peer.
888
+ * Retries for queued retracts piggyback on the same outbound pass.
889
+ * Missed births while a peer is offline are not backfilled in this slice.
890
+ */
891
+ export async function onLocalAgentCreated(agent: { id: string; title: string | null; provider: string }): Promise<void> {
892
+ if (!presenceEnabled()) return;
893
+ let self: string;
894
+ try {
895
+ self = await localServerId();
896
+ } catch (cause) {
897
+ log.error(`presence: cannot announce birth without local serverId: ${cause instanceof Error ? cause.message : String(cause)}`);
898
+ return;
899
+ }
900
+ const now = new Date().toISOString();
901
+ const birth: PresenceBirth = {
902
+ serverId: self,
903
+ agentId: agent.id,
904
+ name: agent.title ?? agent.id.slice(0, 8),
905
+ provider: agent.provider,
906
+ timestamp: now,
907
+ };
908
+ const state = readPresence();
909
+ applyAnnounce(state, birth, randomUUID(), "local", now);
910
+ writePresence(state);
911
+ for (const target of validPeerTargets()) {
912
+ await flushPendingRetracts(target);
913
+ try {
914
+ await invokePeerRpc(target, "presence.announce", { messageId: randomUUID(), entries: [birth] });
915
+ } catch (cause) {
916
+ log.error(`presence: announce to '${target.name}' failed: ${cause instanceof Error ? cause.message : String(cause)}`);
917
+ }
918
+ }
919
+ }
920
+
921
+ /**
922
+ * Local hook: tombstone immediately, retract everywhere now, queue the
923
+ * retract for peers that are offline and retry on the next outbound pass.
924
+ */
925
+ export async function onLocalAgentArchived(agent: { id: string }): Promise<void> {
926
+ if (!presenceEnabled()) return;
927
+ let self: string;
928
+ try {
929
+ self = await localServerId();
930
+ } catch (cause) {
931
+ log.error(`presence: cannot retract without local serverId: ${cause instanceof Error ? cause.message : String(cause)}`);
932
+ return;
933
+ }
934
+ const now = new Date().toISOString();
935
+ const state = readPresence();
936
+ applyRetract(state, self, agent.id, now, randomUUID());
937
+ writePresence(state);
938
+ const conversations = readConversationsSnapshot();
939
+ const detached = detachLocalAgent(conversations, agent.id);
940
+ if (detached.removed) writeConversationsSnapshot(detached.snapshot);
941
+ for (const target of validPeerTargets()) {
942
+ await flushPendingRetracts(target);
943
+ const messageId = randomUUID();
944
+ try {
945
+ await invokePeerRpc(target, "presence.retract", {
946
+ messageId,
947
+ serverId: self,
948
+ agentId: agent.id,
949
+ timestamp: now,
950
+ });
951
+ } catch (cause) {
952
+ log.error(`presence: retract to '${target.name}' failed, queued: ${cause instanceof Error ? cause.message : String(cause)}`);
953
+ const retry = readPresence();
954
+ queueRetract(retry, {
955
+ peer: target.name,
956
+ serverId: self,
957
+ agentId: agent.id,
958
+ timestamp: now,
959
+ messageId,
960
+ queuedAt: now,
961
+ attempts: 1,
962
+ });
963
+ writePresence(retry);
964
+ }
965
+ }
966
+ }
967
+
968
+ // Outbox: retry, expiry, and sender notification for undelivered messages.
969
+ // Idempotency (UUID-keyed receiver dedup) is out of scope here — the
970
+ // conversation protocol has no message-UUID slot (see #12).
971
+
972
+ const peerReachability = new Map<string, boolean>();
973
+
974
+ // The outbox read-modify-write straddles awaits (delivery/notification), so a
975
+ // hold landing mid-pass could otherwise be clobbered by the pass's write.
976
+ let outboxLock: Promise<unknown> = Promise.resolve();
977
+
978
+ function withOutboxLock<T>(operation: () => Promise<T> | T): Promise<T> {
979
+ const run = outboxLock.then(operation, operation);
980
+ outboxLock = run.catch(() => {});
981
+ return run;
982
+ }
983
+
984
+ /**
985
+ * Track a peer's reachability and kick an immediate outbox retry when it flips
986
+ * from unreachable back to reachable. First observation is not a reconnect.
987
+ */
988
+ function notePeerReachability(name: string, reachable: boolean): void {
989
+ const previous = peerReachability.get(name);
990
+ peerReachability.set(name, reachable);
991
+ if (!reachable || previous !== false) return;
992
+ log.info(`outbox: peer '${name}' reconnected; retrying held messages`);
993
+ void flushOutbox(name).catch((cause) => {
994
+ log.error(`outbox: reconnect flush for '${name}' failed: ${cause instanceof Error ? cause.message : String(cause)}`);
995
+ });
996
+ }
997
+
998
+ /**
999
+ * Append the expiry notice to the sender's local timeline. `fromAgentId` is the
1000
+ * local sender agent; without it (or without a remembered Paseo API) there is
1001
+ * no timeline to notify, so this resolves after logging.
1002
+ */
1003
+ async function notifyOutboxExpiry(entry: OutboxEntry, reason: string): Promise<void> {
1004
+ const paseo = paseoRef;
1005
+ if (!entry.fromAgentId || !paseo) {
1006
+ log.warn(`outbox: expiry notice for ${entry.id} not appended (${entry.fromAgentId ? "no paseo handle" : "no local sender agent"}): ${reason}`);
1007
+ return;
1008
+ }
1009
+ await paseo.agents.ref(entry.fromAgentId).timeline.append({
1010
+ type: "plugin",
1011
+ id: `x-comms-outbox-${entry.id}`,
1012
+ kind: OUTBOX_NOTICE_KIND,
1013
+ version: OUTBOX_NOTICE_VERSION,
1014
+ data: {
1015
+ daemon: entry.daemon,
1016
+ agentId: entry.agentId,
1017
+ reason,
1018
+ attempts: entry.attempts,
1019
+ heldForMs: Math.max(0, Date.now() - Date.parse(entry.createdAt)),
1020
+ },
1021
+ });
1022
+ }
1023
+
1024
+ export interface OutboxFlushSummary {
1025
+ delivered: number;
1026
+ retried: number;
1027
+ expired: number;
1028
+ notified: number;
1029
+ }
1030
+
1031
+ /** One outbox sweep: expire overdue held messages, then retry the due ones. */
1032
+ export async function flushOutbox(forceDaemon?: string): Promise<OutboxFlushSummary> {
1033
+ return withOutboxLock(async () => {
1034
+ const state = readOutbox();
1035
+ if (state.entries.length === 0) return { delivered: 0, retried: 0, expired: 0, notified: 0 };
1036
+ const result = await runOutboxPass(
1037
+ state,
1038
+ {
1039
+ deliver: async (entry) => {
1040
+ await deliverConversationMessage(entry);
1041
+ recordOutboundSend({ daemon: entry.daemon, agentId: entry.agentId, localAgentId: entry.fromAgentId });
1042
+ },
1043
+ notify: notifyOutboxExpiry,
1044
+ },
1045
+ { nowMs: Date.now(), forceDaemon },
1046
+ );
1047
+ writeOutbox(state);
1048
+ if (result.delivered.length || result.retried.length || result.expired.length) {
1049
+ log.info(`outbox: delivered ${result.delivered.length}, retried ${result.retried.length}, expired ${result.expired.length} (path: ${outboxPath()})`);
1050
+ }
1051
+ return {
1052
+ delivered: result.delivered.length,
1053
+ retried: result.retried.length,
1054
+ expired: result.expired.length,
1055
+ notified: result.notified.length,
1056
+ };
1057
+ });
1058
+ }
1059
+
1060
+ export const outboxWorker = createPeriodicTask({
1061
+ intervalMs: OUTBOX_POLL_INTERVAL_MS,
1062
+ runImmediately: true,
1063
+ task: async () => {
1064
+ await flushOutbox();
1065
+ },
1066
+ onError: (cause) => {
1067
+ log.error(`outbox: periodic flush failed: ${cause instanceof Error ? cause.message : String(cause)}`);
1068
+ },
1069
+ });
1070
+
1071
+ export function stopOutboxWorker(): void {
1072
+ outboxWorker.stop();
1073
+ }
1074
+
1075
+ // Runs when this module has fully evaluated. Placed last so every
1076
+ // module-level const above (stores, registries) exists before the first
1077
+ // startup read in the daemon's CJS-compiled bundle.
1078
+ runStartupCheck();
1079
+ // Prime the fleet snapshot so daemon health, agent counts, and the Introduce
1080
+ // pickers are ready immediately instead of fetching lazily on first request.
1081
+ initializeSnapshot();