agent-comms 8.15.8 → 8.15.10

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 (184) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/dist/bridges/user/web/server.d.ts.map +1 -1
  4. package/dist/bridges/user/web/server.js +44 -24
  5. package/dist/bridges/user/web/server.js.map +1 -1
  6. package/dist/bridges/user/web/test/web-assets.unit.test.d.ts +5 -0
  7. package/dist/bridges/user/web/test/web-assets.unit.test.d.ts.map +1 -0
  8. package/dist/bridges/user/web/test/web-assets.unit.test.js +90 -0
  9. package/dist/bridges/user/web/test/web-assets.unit.test.js.map +1 -0
  10. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.d.ts +2 -0
  11. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.d.ts.map +1 -0
  12. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.js +75 -0
  13. package/dist/bridges/user/web/test/web-server-unbuilt-frontend.integration.test.js.map +1 -0
  14. package/dist/bridges/user/web/web-assets.d.ts +28 -0
  15. package/dist/bridges/user/web/web-assets.d.ts.map +1 -0
  16. package/dist/bridges/user/web/web-assets.js +67 -0
  17. package/dist/bridges/user/web/web-assets.js.map +1 -0
  18. package/package.json +8 -2
  19. package/src/account-cli.ts +195 -0
  20. package/src/account-join-cli.ts +191 -0
  21. package/src/bridges/cc-peer/approval-commands.ts +82 -0
  22. package/src/bridges/cc-peer/bridge.ts +138 -0
  23. package/src/bridges/cc-peer/default-front.ts +58 -0
  24. package/src/bridges/cc-peer/front-controller.ts +160 -0
  25. package/src/bridges/cc-peer/front-relay.ts +191 -0
  26. package/src/bridges/cc-peer/front-runtime.ts +167 -0
  27. package/src/bridges/cc-peer/front.ts +59 -0
  28. package/src/bridges/cc-peer/reply-aliases.ts +90 -0
  29. package/src/bridges/cc-peer/run.ts +100 -0
  30. package/src/bridges/cc-peer/shared-peer.ts +28 -0
  31. package/src/bridges/claude-code/channel.ts +318 -0
  32. package/src/bridges/codex/tool.ts +132 -0
  33. package/src/bridges/mcp/server.ts +137 -0
  34. package/src/bridges/opencode/plugin.ts +130 -0
  35. package/src/bridges/pi/extension.ts +330 -0
  36. package/src/bridges/registry.ts +17 -0
  37. package/src/bridges/user/cli.ts +87 -0
  38. package/src/bridges/user/controller.ts +345 -0
  39. package/src/bridges/user/tui.ts +300 -0
  40. package/src/bridges/user/web/actions.ts +173 -0
  41. package/src/bridges/user/web/contract.ts +222 -0
  42. package/src/bridges/user/web/dist/bundle.js +49 -0
  43. package/src/bridges/user/web/dist/icons/icon-192x192.svg +9 -0
  44. package/src/bridges/user/web/dist/icons/icon-512x512.svg +9 -0
  45. package/src/bridges/user/web/dist/icons/icon-96x96.svg +9 -0
  46. package/src/bridges/user/web/dist/index.html +21 -0
  47. package/src/bridges/user/web/dist/manifest.json +26 -0
  48. package/src/bridges/user/web/dist/mesh-worker.js +64 -0
  49. package/src/bridges/user/web/dist/sw.js +1 -0
  50. package/src/bridges/user/web/event-publisher.ts +114 -0
  51. package/src/bridges/user/web/port-discovery.ts +49 -0
  52. package/src/bridges/user/web/reads.ts +35 -0
  53. package/src/bridges/user/web/router.ts +158 -0
  54. package/src/bridges/user/web/server.ts +782 -0
  55. package/src/bridges/user/web/vite.config.ts +71 -0
  56. package/src/bridges/user/web/web-access.ts +404 -0
  57. package/src/bridges/user/web/web-assets.ts +90 -0
  58. package/src/bridges/user/web/web-beacon.ts +69 -0
  59. package/src/bridges/user/web/web-console-static.ts +90 -0
  60. package/src/cli-main.ts +635 -0
  61. package/src/cli.ts +13 -0
  62. package/src/core/account-bundle.ts +264 -0
  63. package/src/core/account-join.ts +289 -0
  64. package/src/core/account-ledger-crypto.ts +154 -0
  65. package/src/core/account-ledger-store.ts +66 -0
  66. package/src/core/account-ledger.ts +385 -0
  67. package/src/core/agent-details.ts +216 -0
  68. package/src/core/agent-list-format.ts +97 -0
  69. package/src/core/agent-registry.ts +282 -0
  70. package/src/core/agent-table.ts +58 -0
  71. package/src/core/atomic-file.ts +46 -0
  72. package/src/core/bind-retry.ts +38 -0
  73. package/src/core/bridge-mesh.ts +176 -0
  74. package/src/core/bridge.ts +718 -0
  75. package/src/core/capability-ask.ts +162 -0
  76. package/src/core/capability-bubble-up.ts +237 -0
  77. package/src/core/comms-store.ts +130 -0
  78. package/src/core/connection-approval.ts +134 -0
  79. package/src/core/connection-code-tool.ts +92 -0
  80. package/src/core/connection-code.ts +296 -0
  81. package/src/core/coordinator-role.ts +224 -0
  82. package/src/core/data-directory.ts +32 -0
  83. package/src/core/data-frame-routing.ts +309 -0
  84. package/src/core/default-command.ts +12 -0
  85. package/src/core/delegation-policy.ts +41 -0
  86. package/src/core/delivery-engine.ts +649 -0
  87. package/src/core/device-membership-verification.ts +125 -0
  88. package/src/core/device-membership.ts +130 -0
  89. package/src/core/directory-admission.ts +155 -0
  90. package/src/core/discovery-mdns.ts +247 -0
  91. package/src/core/discovery-tailscale.ts +224 -0
  92. package/src/core/discovery.ts +303 -0
  93. package/src/core/display-name.ts +73 -0
  94. package/src/core/dm-grant-actions.ts +72 -0
  95. package/src/core/dm-send-delegation.ts +73 -0
  96. package/src/core/dm-token-verification.ts +76 -0
  97. package/src/core/election-sessions.ts +135 -0
  98. package/src/core/error-reporting.ts +85 -0
  99. package/src/core/first-contact.ts +242 -0
  100. package/src/core/gateway-trust-actions.ts +184 -0
  101. package/src/core/gateway-trust.ts +305 -0
  102. package/src/core/gossip-directory.ts +276 -0
  103. package/src/core/gossip-extensions.ts +123 -0
  104. package/src/core/group-proofs.ts +205 -0
  105. package/src/core/handshake.ts +354 -0
  106. package/src/core/hub-forwarding.ts +24 -0
  107. package/src/core/hub-link.ts +176 -0
  108. package/src/core/hub-session.ts +374 -0
  109. package/src/core/identity-store.ts +715 -0
  110. package/src/core/identity.ts +489 -0
  111. package/src/core/index.ts +65 -0
  112. package/src/core/issuer-grouping.ts +165 -0
  113. package/src/core/issuer-identity-file.ts +187 -0
  114. package/src/core/lan-web-actions.ts +34 -0
  115. package/src/core/lan-web-uis.ts +55 -0
  116. package/src/core/ledger-lock.ts +81 -0
  117. package/src/core/listener-registry.ts +133 -0
  118. package/src/core/loopback-address.ts +22 -0
  119. package/src/core/machine-identity.ts +77 -0
  120. package/src/core/machine-list-groups.ts +53 -0
  121. package/src/core/mcp-registry-retry.ts +69 -0
  122. package/src/core/mcp-registry-version.ts +18 -0
  123. package/src/core/membership-proof.ts +139 -0
  124. package/src/core/membership-proofs.ts +149 -0
  125. package/src/core/mesh-graph-trace-tool.ts +81 -0
  126. package/src/core/mesh-graph.ts +89 -0
  127. package/src/core/mesh-network-actions.ts +183 -0
  128. package/src/core/mesh-store-options.ts +23 -0
  129. package/src/core/mesh-store-shared.ts +134 -0
  130. package/src/core/mesh-store.ts +1114 -0
  131. package/src/core/name-claim.ts +145 -0
  132. package/src/core/naming-actions.ts +90 -0
  133. package/src/core/naming.ts +138 -0
  134. package/src/core/nanoid.ts +20 -0
  135. package/src/core/network-interfaces.ts +24 -0
  136. package/src/core/package-version.ts +34 -0
  137. package/src/core/peer-lifecycle.ts +377 -0
  138. package/src/core/peer-link-error.ts +41 -0
  139. package/src/core/peer-versions.ts +98 -0
  140. package/src/core/pending-connection.ts +53 -0
  141. package/src/core/petnames.ts +121 -0
  142. package/src/core/pgp-keyserver.ts +32 -0
  143. package/src/core/pid-lock-file.ts +122 -0
  144. package/src/core/push-manager.ts +76 -0
  145. package/src/core/random-id.ts +9 -0
  146. package/src/core/receiver-set.ts +42 -0
  147. package/src/core/relay-role.ts +182 -0
  148. package/src/core/relay-selection.ts +120 -0
  149. package/src/core/relay-server.ts +198 -0
  150. package/src/core/relay-uplink.ts +78 -0
  151. package/src/core/request-timeouts.ts +37 -0
  152. package/src/core/room-discovery.ts +48 -0
  153. package/src/core/room-lifecycle.ts +838 -0
  154. package/src/core/room-lookup.ts +41 -0
  155. package/src/core/room-messaging.ts +247 -0
  156. package/src/core/room-notice-log.ts +61 -0
  157. package/src/core/room-path.ts +84 -0
  158. package/src/core/room-protocol.ts +893 -0
  159. package/src/core/room-router.ts +180 -0
  160. package/src/core/room-token-verification.ts +85 -0
  161. package/src/core/room-wire-extensions.ts +60 -0
  162. package/src/core/self-name-claim.ts +67 -0
  163. package/src/core/send-outcome.ts +69 -0
  164. package/src/core/shutdown-signals.ts +57 -0
  165. package/src/core/stale-agent-checker.ts +108 -0
  166. package/src/core/store.ts +704 -0
  167. package/src/core/token-text.ts +40 -0
  168. package/src/core/tool.ts +853 -0
  169. package/src/core/transport-events.ts +73 -0
  170. package/src/core/transport-queries.ts +54 -0
  171. package/src/core/transport.ts +367 -0
  172. package/src/core/types.ts +550 -0
  173. package/src/core/user-identity.ts +290 -0
  174. package/src/core/vapid.ts +81 -0
  175. package/src/core/version-check.ts +147 -0
  176. package/src/core/version-report-actions.ts +79 -0
  177. package/src/core/web-beacon.ts +31 -0
  178. package/src/core/web-push.ts +322 -0
  179. package/src/core/wire-mesh-core-version.ts +25 -0
  180. package/src/core/wire-mesh-identity.ts +36 -0
  181. package/src/core/wire-mesh-transport-options.ts +82 -0
  182. package/src/core/wire-mesh-transport.ts +1007 -0
  183. package/src/core/wire-protocol.ts +153 -0
  184. package/src/core/ws-dial.ts +191 -0
@@ -0,0 +1,345 @@
1
+ /**
2
+ * ChatController — shared logic for user-facing UIs (TUI, web, CLI).
3
+ *
4
+ * Wraps MeshStore + CommsTool with an EventEmitter interface.
5
+ * UIs subscribe to events and call methods — no direct MeshStore access needed.
6
+ */
7
+
8
+ import { EventEmitter } from "node:events";
9
+ import type { MeshStore } from "../../core/index.js";
10
+ import {
11
+ CommsTool,
12
+ createBridgeMesh,
13
+ ensureRegistered,
14
+ formatDeliveryEvent,
15
+ formatMeshError,
16
+ } from "../../core/index.js";
17
+ import {
18
+ releaseIdentityLock,
19
+ type IdentitySlot,
20
+ } from "../../core/identity-store.js";
21
+ import { wireDefaultCcPeerFront } from "../cc-peer/default-front.js";
22
+ import type { CommsContext, CommsResult } from "../../core/tool.js";
23
+ import type {
24
+ AgentIdentity,
25
+ DeliveryEvent,
26
+ Room,
27
+ RoomMessage,
28
+ } from "../../core/types.js";
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Event types
32
+ // ---------------------------------------------------------------------------
33
+
34
+ export class ChatController extends EventEmitter {
35
+ // Assigned either by init() (this controller owns its identity, the createBridgeMesh path) or by fromExisting() (it wraps an already-built store) -- always populated before any other method runs, definite-assignment-asserted the same way ctx already was.
36
+ private store!: MeshStore;
37
+ /** Set only when this controller owns its persisted identity (standalone mode). */
38
+ private ownedIdentitySlot: IdentitySlot | undefined;
39
+
40
+ /** Expose the underlying MeshStore for handle cleanup. */
41
+ get meshStore(): MeshStore {
42
+ return this.store;
43
+ }
44
+ private tool!: CommsTool;
45
+ private ctx!: CommsContext;
46
+ private currentRoom: string | undefined;
47
+ private readonly userName: string;
48
+ private readonly coordinatorPort: number | undefined;
49
+ /** Overrides the hub this controller's own store dials (createBridgeMesh's own hubUrl, defaulting to DEFAULT_HUB_URL) -- every real bridge entry point wants the real public hub, but a test constructing a controller of its own wants a hermetic, deterministically-unreachable one instead of silently depending on live production infrastructure (see hub-helpers.ts's own unreachableHubUrl). */
50
+ private readonly hubUrl: string | undefined;
51
+ /** Overrides the UDP port this controller's own store runs first-contact presence on (createBridgeMesh's own firstContactPort, defaulting to the machine-wide well-known port). A test that runs several dashboards at once passes an OS-assigned port so they never discover each other. */
52
+ private readonly firstContactPort: number | undefined;
53
+
54
+ constructor(
55
+ userName: string,
56
+ options?: {
57
+ coordinatorPort?: number | undefined;
58
+ hubUrl?: string | undefined;
59
+ firstContactPort?: number | undefined;
60
+ },
61
+ ) {
62
+ super();
63
+ this.userName = userName;
64
+ this.coordinatorPort = options?.coordinatorPort;
65
+ this.hubUrl = options?.hubUrl;
66
+ this.firstContactPort = options?.firstContactPort;
67
+ }
68
+
69
+ /**
70
+ * Create a ChatController wrapping an existing MeshStore and identity.
71
+ *
72
+ * Used by bridges that already have a store and agent registration
73
+ * (e.g. pi, Claude Code) so the web UI shares the same mesh peer
74
+ * instead of creating a redundant one.
75
+ */
76
+ static fromExisting(
77
+ store: MeshStore,
78
+ ctx: Readonly<CommsContext>,
79
+ ): ChatController {
80
+ const ctrl = new ChatController("");
81
+ // Replace the store with the existing one
82
+ ctrl.store = store;
83
+ ctrl.tool = new CommsTool(store, {
84
+ discovery: store.discovery,
85
+ naming: store.naming,
86
+ });
87
+ ctrl.ctx = ctx;
88
+
89
+ // Push delivery events to UIs, alongside whatever the bridge that owns this store already does with them
90
+ store.addDeliveryListener((_agentId: string, event: DeliveryEvent) => {
91
+ ctrl.emit("message", event);
92
+ });
93
+
94
+ return ctrl;
95
+ }
96
+
97
+ async init(): Promise<void> {
98
+ // Persistent identity for the web user's slot so the chat identity survives relaunches of the standalone web CLI
99
+ const identitySlot: IdentitySlot = { harness: "user", cwd: process.cwd() };
100
+ this.ownedIdentitySlot = identitySlot;
101
+ const { store, tool } = await createBridgeMesh(identitySlot, {
102
+ coordinatorPort: this.coordinatorPort,
103
+ hubUrl: this.hubUrl,
104
+ firstContactPort: this.firstContactPort,
105
+ });
106
+ this.store = store;
107
+ this.tool = tool;
108
+ this.store.onError = (error) => {
109
+ // The TUI listens for "error". A host with no listener (the web-only server) would have an emitted "error" event throw out of the store's callback, so those get stderr instead.
110
+ if (this.listenerCount("error") > 0) {
111
+ this.emit("error", error);
112
+ return;
113
+ }
114
+ process.stderr.write(formatMeshError(error));
115
+ };
116
+ this.store.onCoordinatorRoleChanged = wireDefaultCcPeerFront(this.store, {
117
+ coordinatorPort: this.coordinatorPort,
118
+ hubUrl: this.hubUrl,
119
+ });
120
+
121
+ // Push delivery events to UIs
122
+ this.store.onDelivery = (_agentId: string, event: DeliveryEvent) => {
123
+ this.emit("message", event);
124
+ };
125
+
126
+ await this.store.init();
127
+
128
+ const reg = await ensureRegistered({
129
+ store: this.store,
130
+ cwd: process.cwd(),
131
+ harness: "user",
132
+ defaultName: `${this.userName} (user)`,
133
+ visibility: "visible",
134
+ tags: ["user"],
135
+ });
136
+
137
+ this.ctx = {
138
+ agentId: reg.agentId,
139
+ harness: "user",
140
+ cwd: process.cwd(),
141
+ pid: process.pid,
142
+ };
143
+ }
144
+
145
+ get agentId(): string {
146
+ return this.ctx.agentId;
147
+ }
148
+
149
+ get activeRoom(): string | undefined {
150
+ return this.currentRoom;
151
+ }
152
+
153
+ // -----------------------------------------------------------------------
154
+ // Actions
155
+ // -----------------------------------------------------------------------
156
+
157
+ async listAgents(): Promise<CommsResult> {
158
+ return this.tool.handle(this.ctx, { action: "list_agents" });
159
+ }
160
+
161
+ async listRooms(): Promise<CommsResult> {
162
+ return this.tool.handle(this.ctx, { action: "list_rooms" });
163
+ }
164
+
165
+ async createRoom(
166
+ name: string,
167
+ options?: {
168
+ type?: "public" | "private" | "secret";
169
+ description?: string;
170
+ },
171
+ ): Promise<CommsResult> {
172
+ const { type = "public", description = "" } = options ?? {};
173
+ const result = await this.tool.handle(this.ctx, {
174
+ action: "create_room",
175
+ name,
176
+ type,
177
+ description,
178
+ });
179
+ // Auto-switch to the created room
180
+ this.currentRoom = name;
181
+ return result;
182
+ }
183
+
184
+ async joinRoom(roomId: string): Promise<CommsResult> {
185
+ const result = await this.tool.handle(this.ctx, {
186
+ action: "join_room",
187
+ room: roomId,
188
+ });
189
+ this.currentRoom = roomId;
190
+ return result;
191
+ }
192
+
193
+ async leaveRoom(roomId?: string): Promise<CommsResult> {
194
+ const target = roomId ?? this.currentRoom;
195
+ if (target === undefined || target === "") {
196
+ return { content: "No room to leave. Join a room first.", isError: true };
197
+ }
198
+ const result = await this.tool.handle(this.ctx, {
199
+ action: "leave_room",
200
+ room: target,
201
+ });
202
+ if (this.currentRoom === target) {
203
+ this.currentRoom = undefined;
204
+ }
205
+ return result;
206
+ }
207
+
208
+ async send(target: string, content: string): Promise<CommsResult> {
209
+ return this.tool.handle(this.ctx, { action: "send", target, content });
210
+ }
211
+
212
+ async sendToCurrentRoom(content: string): Promise<CommsResult> {
213
+ if (this.currentRoom === undefined || this.currentRoom === "") {
214
+ return { content: "No active room. Join a room first.", isError: true };
215
+ }
216
+ return this.send(this.currentRoom, content);
217
+ }
218
+
219
+ async dm(targetAgentId: string, content: string): Promise<CommsResult> {
220
+ return this.tool.handle(this.ctx, {
221
+ action: "dm",
222
+ target: targetAgentId,
223
+ content,
224
+ });
225
+ }
226
+
227
+ async readRoom(options?: {
228
+ roomId?: string | undefined;
229
+ since?: string | undefined;
230
+ }): Promise<CommsResult> {
231
+ const { roomId, since } = options ?? {};
232
+ const target = roomId ?? this.currentRoom;
233
+ if (target === undefined || target === "") {
234
+ return { content: "No room to read. Join a room first.", isError: true };
235
+ }
236
+ return this.tool.handle(this.ctx, {
237
+ action: "read_room",
238
+ room: target,
239
+ ...(since !== undefined && since !== "" && { since }),
240
+ });
241
+ }
242
+
243
+ async invite(roomId: string, agentId: string): Promise<CommsResult> {
244
+ return this.tool.handle(this.ctx, {
245
+ action: "invite",
246
+ room: roomId,
247
+ agent: agentId,
248
+ });
249
+ }
250
+
251
+ async declineInvite(roomId: string, reason: string): Promise<CommsResult> {
252
+ return this.tool.handle(this.ctx, {
253
+ action: "decline_invite",
254
+ room: roomId,
255
+ reason,
256
+ });
257
+ }
258
+
259
+ async kick(roomId: string, agentId: string): Promise<CommsResult> {
260
+ return this.tool.handle(this.ctx, {
261
+ action: "kick",
262
+ room: roomId,
263
+ agent: agentId,
264
+ });
265
+ }
266
+
267
+ async renameAgent(agentId: string, newName: string): Promise<CommsResult> {
268
+ const agent = await this.store.getAgent(agentId);
269
+ if (!agent) {
270
+ return { content: `Agent ${agentId} not found.`, isError: true };
271
+ }
272
+ const oldName = agent.name;
273
+ await this.store.updateAgent(agentId, { name: newName });
274
+ return {
275
+ content: `Renamed ${oldName} to ${newName}.`,
276
+ isError: false,
277
+ };
278
+ }
279
+
280
+ async destroyRoom(roomId: string): Promise<CommsResult> {
281
+ const result = await this.tool.handle(this.ctx, {
282
+ action: "destroy_room",
283
+ room: roomId,
284
+ });
285
+ if (this.currentRoom === roomId) {
286
+ this.currentRoom = undefined;
287
+ }
288
+ return result;
289
+ }
290
+
291
+ async switchRoom(roomId: string): Promise<CommsResult> {
292
+ // Check if already a member — if not, join first
293
+ const rooms = await this.store.listRooms(this.ctx.agentId);
294
+ const room = rooms.find((r) => r.id === roomId || r.name === roomId);
295
+ if (!room) {
296
+ return { content: `Room "${roomId}" not found.`, isError: true };
297
+ }
298
+
299
+ if (!room.members.includes(this.ctx.agentId)) {
300
+ const result = await this.joinRoom(room.id);
301
+ if (result.isError) return result;
302
+ }
303
+
304
+ this.currentRoom = room.id;
305
+ return {
306
+ content: `Switched to ${room.name}.`,
307
+ isError: false,
308
+ };
309
+ }
310
+
311
+ // -----------------------------------------------------------------------
312
+ // Raw access for web API
313
+ // -----------------------------------------------------------------------
314
+
315
+ async getAgents(): Promise<AgentIdentity[]> {
316
+ return this.store.listAgents(this.ctx.agentId);
317
+ }
318
+
319
+ async getRooms(): Promise<Room[]> {
320
+ return this.store.listRooms(this.ctx.agentId);
321
+ }
322
+
323
+ async getRoomMessages(
324
+ roomId: string,
325
+ since?: string,
326
+ ): Promise<RoomMessage[]> {
327
+ return this.store.readRoomMessages(roomId, since);
328
+ }
329
+
330
+ formatEvent(event: DeliveryEvent): string {
331
+ return formatDeliveryEvent(event);
332
+ }
333
+
334
+ // -----------------------------------------------------------------------
335
+ // Lifecycle
336
+ // -----------------------------------------------------------------------
337
+
338
+ async shutdown(): Promise<void> {
339
+ await this.store.setAgentOffline(this.ctx.agentId);
340
+ await this.store.shutdown();
341
+ if (this.ownedIdentitySlot !== undefined) {
342
+ releaseIdentityLock(this.ownedIdentitySlot);
343
+ }
344
+ }
345
+ }
@@ -0,0 +1,300 @@
1
+ /**
2
+ * TUI — terminal chat interface using readline + ANSI escape codes.
3
+ *
4
+ * Commands:
5
+ * /join <room> Join or switch to a room
6
+ * /leave [room] Leave the current (or specified) room
7
+ * /rooms List all rooms
8
+ * /agents List all agents
9
+ * /read [room] Read messages in current (or specified) room
10
+ * /dm <agent> <msg> Send a direct message
11
+ * /create <name> Create a public room
12
+ * /invite <room> <agent> Invite an agent to a room
13
+ * /decline <room> <reason> Decline a room invite
14
+ * /kick <room> <agent> Kick an agent from a room
15
+ * /destroy <room> Destroy a room
16
+ * /help Show commands
17
+ * /quit Exit
18
+ *
19
+ * Anything without / is sent to the current room.
20
+ */
21
+
22
+ import * as readline from "node:readline";
23
+ import { ChatController } from "./controller.js";
24
+ import type { DeliveryEvent } from "../../core/types.js";
25
+ import { describeDeliveryFor } from "../../core/send-outcome.js";
26
+
27
+ // ANSI helpers
28
+ const BOLD = "\x1b[1m";
29
+ const DIM = "\x1b[2m";
30
+ const CYAN = "\x1b[36m";
31
+ const GREEN = "\x1b[32m";
32
+ const YELLOW = "\x1b[33m";
33
+ const RED = "\x1b[31m";
34
+ const MAGENTA = "\x1b[35m";
35
+ const RESET = "\x1b[0m";
36
+ const CLEAR_LINE = "\r\x1b[2K";
37
+
38
+ function prompt(controller: ChatController): string {
39
+ const room = controller.activeRoom;
40
+ return room !== undefined ? `${GREEN}${room}${RESET} > ` : `${DIM}>${RESET} `;
41
+ }
42
+
43
+ export async function runTui(userName: string): Promise<void> {
44
+ const controller = new ChatController(userName);
45
+
46
+ await controller.init();
47
+ console.log(
48
+ `${CYAN}Connected as ${BOLD}${userName} (user)${RESET} [${controller.agentId}]`,
49
+ );
50
+ console.log(
51
+ `${DIM}Type /help for commands. Anything else goes to the current room.${RESET}\n`,
52
+ );
53
+
54
+ const rl = readline.createInterface({
55
+ input: process.stdin,
56
+ output: process.stdout,
57
+ });
58
+
59
+ // Delivery event handler — print above the prompt
60
+ function onMessage(event: DeliveryEvent): void {
61
+ const line = formatForTerminal(event);
62
+ process.stdout.write(`${CLEAR_LINE}${line}\n${prompt(controller)}`);
63
+ }
64
+ controller.on("message", onMessage);
65
+
66
+ function onError(err: Error): void {
67
+ process.stdout.write(
68
+ `${CLEAR_LINE}${RED}Error: ${err.message}${RESET}\n${prompt(controller)}`,
69
+ );
70
+ }
71
+ controller.on("error", onError);
72
+
73
+ // Input loop
74
+ async function handleInput(input: string): Promise<void> {
75
+ const trimmed = input.trim();
76
+ if (trimmed.length === 0) return;
77
+
78
+ if (trimmed.startsWith("/")) {
79
+ await handleCommand(trimmed, controller);
80
+ } else if (controller.activeRoom !== undefined) {
81
+ const result = await controller.sendToCurrentRoom(trimmed);
82
+ if (result.isError) {
83
+ process.stdout.write(`${RED}${result.content}${RESET}\n`);
84
+ }
85
+ } else {
86
+ process.stdout.write(
87
+ `${YELLOW}No active room. /join a room first or use /dm.${RESET}\n`,
88
+ );
89
+ }
90
+ }
91
+
92
+ function ask(): void {
93
+ rl.question(prompt(controller), (input) => {
94
+ void handleInput(input).then(() => {
95
+ ask();
96
+ });
97
+ });
98
+ }
99
+
100
+ ask();
101
+
102
+ // Graceful shutdown
103
+ rl.on("close", () => {
104
+ controller.off("message", onMessage);
105
+ controller.off("error", onError);
106
+ void controller.shutdown().then(() => process.exit(0));
107
+ });
108
+ }
109
+
110
+ // ---------------------------------------------------------------------------
111
+ // Command handler
112
+ // ---------------------------------------------------------------------------
113
+
114
+ async function handleCommand(input: string, c: ChatController): Promise<void> {
115
+ const parts = input.slice(1).split(/\s+/);
116
+ const cmd = parts[0]?.toLowerCase();
117
+ const arg1 = parts[1];
118
+ const arg2 = parts.slice(2).join(" ");
119
+
120
+ switch (cmd) {
121
+ case "join": {
122
+ if (arg1 === undefined) {
123
+ process.stdout.write(`${YELLOW}Usage: /join <room>${RESET}\n`);
124
+ return;
125
+ }
126
+ const result = await c.switchRoom(arg1);
127
+ printResult(result);
128
+ if (!result.isError) {
129
+ const read = await c.readRoom();
130
+ if (!read.isError && read.content !== "No messages.") {
131
+ process.stdout.write(`${DIM}${read.content}${RESET}\n`);
132
+ }
133
+ }
134
+ return;
135
+ }
136
+ case "leave": {
137
+ const result = await c.leaveRoom(arg1);
138
+ printResult(result);
139
+ return;
140
+ }
141
+ case "rooms": {
142
+ const result = await c.listRooms();
143
+ printResult(result);
144
+ return;
145
+ }
146
+ case "agents": {
147
+ const result = await c.listAgents();
148
+ printResult(result);
149
+ return;
150
+ }
151
+ case "read": {
152
+ const result = await c.readRoom({ roomId: arg1 });
153
+ printResult(result);
154
+ return;
155
+ }
156
+ case "dm": {
157
+ if (arg1 === undefined || !arg2) {
158
+ process.stdout.write(`${YELLOW}Usage: /dm <agent> <message>${RESET}\n`);
159
+ return;
160
+ }
161
+ const result = await c.dm(arg1, arg2);
162
+ printResult(result);
163
+ return;
164
+ }
165
+ case "create": {
166
+ if (arg1 === undefined) {
167
+ process.stdout.write(`${YELLOW}Usage: /create <name>${RESET}\n`);
168
+ return;
169
+ }
170
+ const result = await c.createRoom(arg1, {
171
+ type: "public",
172
+ description: arg2,
173
+ });
174
+ printResult(result);
175
+ return;
176
+ }
177
+ case "invite": {
178
+ const agentId = parts[2];
179
+ if (arg1 === undefined || agentId === undefined) {
180
+ process.stdout.write(
181
+ `${YELLOW}Usage: /invite <room> <agent>${RESET}\n`,
182
+ );
183
+ return;
184
+ }
185
+ const result = await c.invite(arg1, agentId);
186
+ printResult(result);
187
+ return;
188
+ }
189
+ case "decline": {
190
+ const reason = parts.slice(2).join(" ");
191
+ if (arg1 === undefined || !reason) {
192
+ process.stdout.write(
193
+ `${YELLOW}Usage: /decline <room> <reason>${RESET}\n`,
194
+ );
195
+ return;
196
+ }
197
+ const result = await c.declineInvite(arg1, reason);
198
+ printResult(result);
199
+ return;
200
+ }
201
+ case "kick": {
202
+ const agentId = parts[2];
203
+ if (arg1 === undefined || agentId === undefined) {
204
+ process.stdout.write(`${YELLOW}Usage: /kick <room> <agent>${RESET}\n`);
205
+ return;
206
+ }
207
+ const result = await c.kick(arg1, agentId);
208
+ printResult(result);
209
+ return;
210
+ }
211
+ case "destroy": {
212
+ if (arg1 === undefined) {
213
+ process.stdout.write(`${YELLOW}Usage: /destroy <room>${RESET}\n`);
214
+ return;
215
+ }
216
+ const result = await c.destroyRoom(arg1);
217
+ printResult(result);
218
+ return;
219
+ }
220
+ case "help":
221
+ process.stdout.write(`${CYAN}Commands:${RESET}
222
+ ${GREEN}/join${RESET} <room> Join or switch to a room
223
+ ${GREEN}/leave${RESET} [room] Leave current (or specified) room
224
+ ${GREEN}/rooms${RESET} List all rooms
225
+ ${GREEN}/agents${RESET} List all agents
226
+ ${GREEN}/read${RESET} [room] Read messages in room
227
+ ${GREEN}/dm${RESET} <agent> <msg> Send a direct message
228
+ ${GREEN}/create${RESET} <name> Create a public room
229
+ ${GREEN}/invite${RESET} <room> <id> Invite agent to room
230
+ ${GREEN}/decline${RESET} <room> <reason> Decline a room invite
231
+ ${GREEN}/kick${RESET} <room> <id> Kick agent from room
232
+ ${GREEN}/destroy${RESET} <room> Destroy a room
233
+ ${GREEN}/help${RESET} Show this help
234
+ ${GREEN}/quit${RESET} Exit
235
+ \n${DIM}Anything without / is sent to the current room.${RESET}\n`);
236
+ return;
237
+ case "quit":
238
+ process.stdout.write(`${DIM}Goodbye!${RESET}\n`);
239
+ process.exit(0);
240
+ return;
241
+ case undefined:
242
+ default:
243
+ process.stdout.write(
244
+ `${YELLOW}Unknown command: /${String(cmd)}. Type /help for commands.${RESET}\n`,
245
+ );
246
+ }
247
+ }
248
+
249
+ // ---------------------------------------------------------------------------
250
+ // Formatting
251
+ // ---------------------------------------------------------------------------
252
+
253
+ function printResult(
254
+ result: Readonly<{ content: string; isError: boolean }>,
255
+ ): void {
256
+ if (result.isError) {
257
+ process.stdout.write(`${RED}${result.content}${RESET}\n`);
258
+ } else {
259
+ process.stdout.write(`${result.content}\n`);
260
+ }
261
+ }
262
+
263
+ function formatForTerminal(event: DeliveryEvent): string {
264
+ switch (event.type) {
265
+ case "room_message":
266
+ return `${BOLD}[${event.message.room}] ${event.message.from}:${RESET} ${event.message.content}`;
267
+ case "dm":
268
+ return `${MAGENTA}DM from ${event.message.from}:${RESET} ${event.message.content}`;
269
+ case "member_joined":
270
+ return `${GREEN}→ ${event.agent} joined ${event.room}${RESET}`;
271
+ case "member_left":
272
+ return `${YELLOW}← ${event.agent} left ${event.room}${RESET}`;
273
+ case "member_status":
274
+ return `${CYAN}● ${event.agent} is now ${event.status} in ${event.room}${RESET}`;
275
+ case "delivery_status":
276
+ return `${DIM}✓ Message ${event.messageId} ${describeDeliveryFor(event.delivery, event.agent)}${RESET}`;
277
+ case "room_invite": {
278
+ const desc = event.roomDescription ? ` — ${event.roomDescription}` : "";
279
+ return `${CYAN}✉ ${event.fromName} invited you to "${event.room}"${desc}${RESET}`;
280
+ }
281
+ case "invite_declined":
282
+ return `${YELLOW}✗ ${event.agentName} declined invite to ${event.room}: "${event.reason}"${RESET}`;
283
+ case "room_members": {
284
+ const names = event.members
285
+ .map((m) => `${m.name} (${m.status})`)
286
+ .join(", ");
287
+ return `${DIM}Members of ${event.room}: ${names}${RESET}`;
288
+ }
289
+ case "name_changed":
290
+ return `${CYAN}✎ ${event.oldName} is now ${event.newName}${RESET}`;
291
+ case "connection_request":
292
+ return `${CYAN}🔗 Connection request from ${event.peerId} (${event.name})${RESET}`;
293
+ case "capability_request":
294
+ return `${CYAN}🔑 ${event.requesterDevice} is asking for "${event.capability}" (${event.requestId})${RESET}`;
295
+ case "room_join_request":
296
+ return `${CYAN}🚪 ${event.requesterId} is asking to join ${event.room}${RESET}`;
297
+ default:
298
+ return event satisfies never;
299
+ }
300
+ }