@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
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 xpufx
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,149 @@
1
+ # paseo-x-comms
2
+
3
+ > **⚠️ WIP — use at your own risk.** Not release-ready; APIs and behavior may change without notice.
4
+
5
+ > Tracks stable paseo (`@getpaseo/* 0.8.0`, manifest requires `paseo >= 0.8.0`). Expect breaking changes between versions.
6
+
7
+ [paseo](https://paseo.sh) is an agent orchestrator: AI coding agents run on paseo daemons, each managing workspaces, tools, and permissions. **paseo-x-comms** lets agents on one daemon talk to agents on another — even across hosts — via the daemon relay (WebSocket + E2EE) or direct TCP.
8
+
9
+ Scope: x-comms links one owner's daemons through one client UI. Pairing is explicit and local; stranger federation is out of scope (an experimental flag at most, post-1.0).
10
+
11
+ Built on [paseo-plugin-helper](https://github.com/xpufx/paseo/tree/main/packages/paseo-plugin-helper), the shared Paseo plugin runtime.
12
+
13
+ This repo ships two things:
14
+
15
+ * **Paseo plugin** (recommended) — UI + embedded MCP server. (This README is for the plugin. See below)
16
+ * **Standalone MCP server** (`mcp/`) — the same server without the paseo plugin. See **[mcp/README.md](mcp/README.md)** for its standalone install, tool reference, and protocol details.
17
+
18
+ ## Plugin
19
+
20
+ The plugin embeds the MCP server and adds the X-comms UI. Agents get `x_comms_*` tools automatically; humans get surfaces to manage daemons and conversations.
21
+
22
+ ### What you get
23
+
24
+ * **Main surface — X-comms** (`client/main.tsx`, surfaced via `index.client.tsx` sidebar item): registered daemons list with health (reachable/unreachable + agent count), add/edit/remove with host-form validation and reachability probe, refresh (identity + snapshot), server version check, introduce-agents picker, debug dump per daemon.
25
+ * **Peers surface** (`client/peer-status.tsx`, the "Peers" tab): the known daemon registry rendered with a live per-peer `daemon.get_status` probe — up/down plus identity (`serverId`, `version`, `listen`, relay, provider count, hub relationship). Each host is probed independently (relay or direct), so an unreachable peer shows as down without failing the surface. Polls every 30 s via `peer.status`.
26
+ * **Composer pill** (`client/x-comms-pill.tsx`): one `X-comms` pill per agent in the composer; opens the conversation panel for that agent.
27
+ * **Agent panel** (`client/x-comms-panel.tsx` / `x-comms-timeline.tsx` / `x-comms-conversation.tsx`, plus `x-comms-tool-call.tsx` and `via-x-comms.tsx`): per-agent conversation view with timeline rendering of the `[x-comms]` envelope, send/reply, wait, and permission handling. Timeline transformers/renderers registered in `index.client.tsx` render envelopes and tool calls inline in agent timelines.
28
+ * **Server side** (`index.server.ts` + `server/`): registry, health, settings, presence announce/retract/list, and MCP injection handlers.
29
+ * **Embedded MCP server** (`mcp/paseo-x-comms.mjs`): spawned via `serverPath()` from `server/server-status.ts` (resolved from `import.meta.url` with plugin-dir fallbacks); shares the repo-root `node_modules` — no separate install or `paseo` on PATH required beyond the daemon itself.
30
+
31
+ ### Install
32
+
33
+ The plugin lives at the repo root (`paseo-plugin.json` id `x-comms`):
34
+
35
+ ```sh
36
+ paseo plugin add xpufx/paseo-x-comms
37
+ ```
38
+
39
+ The plugin requires **npm** and **Node.js** (v18+) in `$PATH`: the server bundle uses `@getpaseo/client` and `@getpaseo/protocol`, so `paseo-plugin.json` declares `npm install` as its install build command. `paseo-plugin-helper` is vendored under `client/`, `server/`, `shared/` and bundled from source. The MCP server is spawned from `./mcp` and resolves its deps (`@modelcontextprotocol/sdk`, `zod`, etc.) from the installed tree.
40
+
41
+ To update:
42
+
43
+ ```sh
44
+ paseo plugin update x-comms
45
+ ```
46
+
47
+ ### Configure daemons
48
+
49
+ The registry is at `~/.paseo/paseo-x-comms/registry.json`:
50
+
51
+ ```json
52
+ {
53
+ "home": "https://app.paseo.sh/#offer=<b64>",
54
+ "office": "10.0.0.5:6767"
55
+ }
56
+ ```
57
+
58
+ * Value is an **opaque `--host` string** — paseo classifies it. `https://app.paseo.sh/#offer=…` is a relay connection (E2EE); anything else (`host:port`, `tcp://…`, `unix://…`, IPC path, bare port) is a direct connection. See [mcp/README.md#host-forms](mcp/README.md#host-forms).
59
+ * Manage it from the plugin UI (Main surface) or via the MCP tools `x_comms_add_daemon` / `x_comms_remove_daemon` / `x_comms_list_daemons`.
60
+ * The file holds live pairing offers (serverId, public keys, relay endpoints) — treat it as credentials, never commit it.
61
+
62
+ Quick pairing:
63
+
64
+ 1. On the **target** daemon: `paseo daemon pair --json` → copy the `url` (`https://app.paseo.sh/#offer=…`). For a directly-reachable daemon, use its address instead.
65
+ 2. On **this** daemon: open the X-comms Main surface → *Add daemon* → paste the offer or address. The UI probes reachability before saving (with "Add anyway" for offline hosts).
66
+
67
+ ### How messaging works
68
+
69
+ Every `x_comms_send` prepends one line:
70
+
71
+ ```
72
+ [x-comms] {"xComms":{"version":4,"type":"x-comms.incoming_message","sender":{…},"target":{…},"sentAt":"…","direction":"outgoing"}}
73
+ ```
74
+
75
+ `sender` (agentId, agentName, host, daemonServerId, cwd) + `target` (daemon, agentId) + `sentAt`. Prompt text stays prose after the envelope. Recipients parse the envelope and reply via `x_comms_send` to `sender.agentId` on the sender's daemon. Full envelope + permission loop documented in [mcp/README.md#message-envelope](mcp/README.md#message-envelope) and [mcp/README.md#behavior-notes](mcp/README.md#behavior-notes).
76
+
77
+ Tools (via the embedded server) are `x_comms_list_daemons`, `x_comms_add_daemon`, `x_comms_remove_daemon`, `x_comms_list_agents`, `x_comms_inspect`, `x_comms_send`, `x_comms_logs`, `x_comms_wait`, `x_comms_list_permissions`, `x_comms_allow_permission`, `x_comms_deny_permission` — see [mcp/README.md#tools](mcp/README.md#tools) for the reference. The plugin's conversation/panel UI wraps `send`/`logs`/`wait`/permissions for interactive use.
78
+
79
+ ### Outbox: retry, expiry, notification
80
+
81
+ The plugin server keeps an **outbox** (the plugin state dir's `outbox.json`) for messages whose send failed. Held messages are retried over the same `x_comms_send` path with exponential backoff (5s doubling to a 60s cap), swept on a 15s periodic pass, and retried immediately when a peer is observed reconnecting (health/snapshot reachability flip).
82
+
83
+ A held message expires after **10 minutes** by default (configurable in the settings surface, `outboxExpirySeconds`, clamped to 10s–24h). On expiry the sender is notified by appending an `x-comms-outbox-notice` timeline item with the reason; the message is then dropped.
84
+
85
+ **Idempotency is not implemented.** The conversation protocol has no message-UUID slot and the receiver keeps no seen-id set for messages, so a retry after an ambiguous failure (send succeeded, acknowledgement lost) can re-deliver. Adding it would need a wire-format change; tracked separately.
86
+
87
+
88
+ ## Repository layout
89
+
90
+ ```
91
+ .
92
+ ├── index.client.tsx # Paseo 0.8 client entry (surfaces, pill, panel, timeline renderers)
93
+ ├── index.server.ts # Paseo 0.8 server entry (RPC + presence + injection handlers)
94
+ ├── client/
95
+ │ ├── main.tsx # Main surface (daemon registry + health + prompt)
96
+ │ ├── x-comms-pill.tsx # Composer pill → conversation panel
97
+ │ ├── x-comms-panel.tsx # Agent panel host
98
+ │ ├── x-comms-timeline.tsx # Timeline / envelope rendering
99
+ │ ├── x-comms-conversation.tsx
100
+ │ ├── x-comms-tool-call.tsx # Tool-call timeline rendering
101
+ │ ├── via-x-comms.tsx
102
+ │ ├── conversations.ts # Conversation derive (shared with tests)
103
+ │ ├── attribution.ts / peer-label.ts / tool-call.ts
104
+ │ └── *.test.ts # Client unit tests
105
+ ├── server/
106
+ │ ├── handlers.ts # RPC handlers (registry, probe, dump, etc.)
107
+ │ ├── registry.ts # Daemon registry + health
108
+ │ ├── settings.ts # Plugin settings storage
109
+ │ ├── presence.ts # Presence announce/retract/list
110
+ │ ├── injection.ts # MCP injection for agents
111
+ │ ├── snapshot.ts / conversations-snapshot.ts
112
+ │ ├── server-status.ts # Embedded server path resolution
113
+ │ ├── mcp-client.ts / peer-channel.ts
114
+ │ └── *.test.ts # Server unit tests
115
+ ├── shared/
116
+ │ ├── envelope.ts # Wire envelope schema ([x-comms] parsing)
117
+ │ ├── registry.ts # RPC definitions (zod)
118
+ │ └── conversations-snapshot.ts
119
+ ├── mcp/
120
+ │ ├── paseo-x-comms.mjs # MCP server (also bin `paseo-x-comms`)
121
+ │ ├── README.md # standalone server docs
122
+ │ └── test/protocol.test.mjs
123
+ ├── paseo-plugin.json # id x-comms, requires paseo >= 0.8.0
124
+ └── package.json # single install at root for plugin + server
125
+ ```
126
+
127
+ ## Standalone MCP
128
+
129
+ If you don't use the plugin, run the server directly:
130
+
131
+ ```sh
132
+ npm install -g @xpufx/paseo-x-comms
133
+ ```
134
+
135
+ Requires `paseo` CLI on PATH, Node ≥ 18. Full instructions, env overrides (`PASEO_X_COMMS_*`), and client registration examples (pi `mcp.json` vs opencode `opencode.jsonc`) are in **[mcp/README.md](mcp/README.md)**.
136
+
137
+ ## Development
138
+
139
+ ```sh
140
+ npm install
141
+ npm run typecheck # tsc --noEmit
142
+ npm test # node --test across mcp, server, and client suites (hermetic protocol tests plus presence, injection, settings, snapshot, and conversation suites)
143
+ ```
144
+
145
+ No live daemons, no real `~/.paseo/paseo-x-comms/registry.json` touched in tests (`PASEO_X_COMMS_REMOTES` / `PASEO_X_COMMS_PASEO` overrides).
146
+
147
+ ## License
148
+
149
+ Apache-2.0.
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Standing attribution rule: everything this plugin renders into shared
3
+ * Paseo surfaces must carry the plugin name so users never mistake plugin
4
+ * output for Paseo core. Pure constant lives here (importable from tests);
5
+ * the themed component lives in attribution.tsx.
6
+ */
7
+ export const XCOMMS_VIA_LABEL = "via x-comms";
@@ -0,0 +1,229 @@
1
+ import { usePaseo } from "@getpaseo/plugin/client";
2
+ import { parseEnvelope, viewerDirection, type CrossDaemonEnvelope } from "../shared/envelope.ts";
3
+ import { isXCommsTool } from "./tool-call.ts";
4
+
5
+ type PaseoApi = ReturnType<typeof usePaseo>;
6
+
7
+ export interface ConversationPartner {
8
+ conversationId: string;
9
+ // The counterparty this agent is talking to, derived from the envelope.
10
+ counterparty: {
11
+ daemon: string | null;
12
+ agentId: string | null;
13
+ agentName: string | null;
14
+ daemonServerId: string | null;
15
+ };
16
+ lastActivity: string;
17
+ messageCount: number;
18
+ }
19
+
20
+ export interface ConversationMessage {
21
+ id: string;
22
+ body: string;
23
+ sentAt: string;
24
+ isIncoming: boolean;
25
+ senderName: string | null;
26
+ daemon: string | null;
27
+ userSent?: boolean;
28
+ }
29
+
30
+ export interface ConversationThread {
31
+ partner: ConversationPartner;
32
+ messages: ConversationMessage[];
33
+ }
34
+
35
+ /**
36
+ * Single shared thread key. Derive and the peer picker must agree, or the
37
+ * merge site compares different strings and one side silently drops. The
38
+ * server id wins when present; the alias or host is the fallback.
39
+ */
40
+ export function threadKeyForCounterparty(cp: {
41
+ daemon: string | null;
42
+ daemonServerId: string | null;
43
+ agentId: string | null;
44
+ }): string {
45
+ return `${cp.daemonServerId ?? cp.daemon ?? "?"}/${cp.agentId ?? "?"}`;
46
+ }
47
+
48
+ /**
49
+ * Match two counterparties across alias vs serverId discrepancies.
50
+ */
51
+ export function isCounterpartyMatch(
52
+ a: { daemon: string | null; daemonServerId?: string | null; agentId: string | null },
53
+ b: { daemon: string | null; daemonServerId?: string | null; agentId: string | null },
54
+ ): boolean {
55
+ if (!a.agentId || !b.agentId || a.agentId !== b.agentId) return false;
56
+ const aServer = a.daemonServerId ?? (a.daemon?.startsWith("srv_") ? a.daemon : null);
57
+ const bServer = b.daemonServerId ?? (b.daemon?.startsWith("srv_") ? b.daemon : null);
58
+ if (aServer && bServer) {
59
+ return aServer === bServer;
60
+ }
61
+ if (!aServer && !bServer && a.daemon && b.daemon) {
62
+ return a.daemon === b.daemon;
63
+ }
64
+ return true;
65
+ }
66
+
67
+ /**
68
+ * Interleave both directions by timestamp and deduplicate optimistic vs recorded sends.
69
+ */
70
+ export function mergeMessages(
71
+ incoming: ConversationMessage[],
72
+ sent: ConversationMessage[],
73
+ ): ConversationMessage[] {
74
+ const seenIds = new Set<string>();
75
+ const out: ConversationMessage[] = [];
76
+ for (const m of [...incoming, ...sent]) {
77
+ if (seenIds.has(m.id)) continue;
78
+ const isDup = out.some(
79
+ (existing) =>
80
+ existing.body === m.body &&
81
+ existing.isIncoming === m.isIncoming &&
82
+ Math.abs(new Date(existing.sentAt).getTime() - new Date(m.sentAt).getTime()) < 3000,
83
+ );
84
+ if (!isDup) {
85
+ seenIds.add(m.id);
86
+ out.push(m);
87
+ }
88
+ }
89
+ return out.sort((a, b) => (a.sentAt < b.sentAt ? -1 : 1));
90
+ }
91
+
92
+ /**
93
+ * Derives the x-comms conversations an agent is part of by scanning its
94
+ * timeline for our meta envelopes. No separate ledger file: the timeline is the
95
+ * source of truth, so the result is never stale. Grouped by conversationId.
96
+ */
97
+ export async function deriveConversations(
98
+ paseo: PaseoApi,
99
+ agentId: string,
100
+ ): Promise<ConversationPartner[]> {
101
+ const threads = await deriveConversationThreads(paseo, agentId);
102
+ return threads.map((t) => t.partner);
103
+ }
104
+
105
+ export async function deriveConversationThreads(
106
+ paseo: PaseoApi,
107
+ agentId: string,
108
+ ): Promise<ConversationThread[]> {
109
+ const handle = paseo.agents.ref(agentId);
110
+ const timeline = await handle.timeline.refetch();
111
+ const byConversation = new Map<string, ConversationThread>();
112
+
113
+ function addMessage(
114
+ counterparty: { daemon: string | null; agentId: string | null; agentName: string | null; daemonServerId: string | null },
115
+ msgData: Omit<ConversationMessage, "id"> & { id?: string },
116
+ ) {
117
+ const id = threadKeyForCounterparty(counterparty);
118
+ let thread = byConversation.get(id);
119
+ if (!thread) {
120
+ for (const t of byConversation.values()) {
121
+ if (isCounterpartyMatch(t.partner.counterparty, counterparty)) {
122
+ thread = t;
123
+ break;
124
+ }
125
+ }
126
+ }
127
+ if (!thread) {
128
+ thread = {
129
+ partner: {
130
+ conversationId: id,
131
+ counterparty,
132
+ lastActivity: msgData.sentAt,
133
+ messageCount: 0,
134
+ },
135
+ messages: [],
136
+ };
137
+ byConversation.set(id, thread);
138
+ }
139
+ const msgId = msgData.id ?? `${thread.partner.conversationId}-${msgData.sentAt}-${thread.messages.length}`;
140
+ thread.messages.push({ ...msgData, id: msgId });
141
+ thread.partner.messageCount += 1;
142
+ if (msgData.sentAt > thread.partner.lastActivity) {
143
+ thread.partner.lastActivity = msgData.sentAt;
144
+ }
145
+ }
146
+
147
+ for (const entry of timeline.entries) {
148
+ const item = entry.item as { type?: string; text?: string; name?: string; detail?: unknown } | undefined;
149
+ if (!item) continue;
150
+
151
+ // Case 1: user_message or assistant_message carrying [x-comms] wire envelope
152
+ if (item.type === "user_message" || item.type === "assistant_message") {
153
+ const text = item.text;
154
+ if (!text) continue;
155
+ const parsed = parseEnvelope(text);
156
+ if (!parsed) continue;
157
+ const env: CrossDaemonEnvelope = parsed.envelope;
158
+ const meta = env.xComms;
159
+ const isIncoming = viewerDirection(env, agentId) === "incoming";
160
+ const daemon = isIncoming
161
+ ? (meta.sender.daemonServerId ?? meta.sender.host ?? null)
162
+ : (meta.target.daemon ?? null);
163
+ const counterparty = isIncoming
164
+ ? {
165
+ daemon,
166
+ agentId: meta.sender.agentId ?? null,
167
+ agentName: meta.sender.agentName ?? null,
168
+ daemonServerId: meta.sender.daemonServerId ?? null,
169
+ }
170
+ : {
171
+ daemon,
172
+ agentId: meta.target.agentId ?? null,
173
+ agentName: null,
174
+ daemonServerId: daemon && daemon.startsWith("srv_") ? daemon : null,
175
+ };
176
+ const senderName = isIncoming
177
+ ? (meta.sender.agentName ?? meta.sender.agentId ?? "peer")
178
+ : "You";
179
+ addMessage(counterparty, {
180
+ body: parsed.body,
181
+ sentAt: meta.sentAt,
182
+ isIncoming,
183
+ senderName,
184
+ daemon,
185
+ userSent: !isIncoming,
186
+ });
187
+ continue;
188
+ }
189
+
190
+ // Case 2: tool_call invoking x_comms_send
191
+ if (item.type === "tool_call" || (item.name && isXCommsTool(item.name))) {
192
+ const name = item.name ?? "";
193
+ if (isXCommsTool(name) && name.includes("send")) {
194
+ const detail = item.detail as { input?: Record<string, unknown> } | undefined;
195
+ const input = detail && typeof detail === "object" ? detail.input : undefined;
196
+ if (input && typeof input === "object") {
197
+ const targetDaemon = typeof input.daemon === "string" ? input.daemon : null;
198
+ const targetAgentId = typeof input.agentId === "string" ? input.agentId : null;
199
+ const prompt = typeof input.prompt === "string" ? input.prompt : "";
200
+ if (targetAgentId) {
201
+ const daemonServerId = targetDaemon && targetDaemon.startsWith("srv_") ? targetDaemon : null;
202
+ const counterparty = {
203
+ daemon: targetDaemon,
204
+ agentId: targetAgentId,
205
+ agentName: null,
206
+ daemonServerId,
207
+ };
208
+ const sentAt = (entry as { timestamp?: string }).timestamp ?? new Date().toISOString();
209
+ const fromUser = input.fromAgentName === "User" || !input.fromAgentId || input.fromAgentId === agentId;
210
+ addMessage(counterparty, {
211
+ id: `tool-send-${targetAgentId}-${sentAt}-${prompt.slice(0, 8)}`,
212
+ body: prompt,
213
+ sentAt,
214
+ isIncoming: false,
215
+ senderName: fromUser ? "You" : (typeof input.fromAgentName === "string" ? input.fromAgentName : "Agent"),
216
+ daemon: targetDaemon,
217
+ userSent: fromUser,
218
+ });
219
+ }
220
+ }
221
+ }
222
+ }
223
+ }
224
+
225
+ for (const thread of byConversation.values()) {
226
+ thread.messages.sort((a, b) => (a.sentAt < b.sentAt ? -1 : 1));
227
+ }
228
+ return [...byConversation.values()].sort((a, b) => (a.partner.lastActivity < b.partner.lastActivity ? 1 : -1));
229
+ }