@studiomopoke/crosschat 1.6.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 (169) hide show
  1. package/LICENSE +62 -0
  2. package/README.md +279 -0
  3. package/bin/cli.cjs +631 -0
  4. package/crosschat.md +196 -0
  5. package/dashboard/dist/assets/index-Blgmbgo_.css +1 -0
  6. package/dashboard/dist/assets/index-DzcjxzDR.js +49 -0
  7. package/dashboard/dist/index.html +13 -0
  8. package/dist/dashboard/dashboard-listener.d.ts +22 -0
  9. package/dist/dashboard/dashboard-listener.d.ts.map +1 -0
  10. package/dist/dashboard/dashboard-listener.js +124 -0
  11. package/dist/dashboard/dashboard-listener.js.map +1 -0
  12. package/dist/dashboard/http-server.d.ts +25 -0
  13. package/dist/dashboard/http-server.d.ts.map +1 -0
  14. package/dist/dashboard/http-server.js +281 -0
  15. package/dist/dashboard/http-server.js.map +1 -0
  16. package/dist/dashboard/message-bridge.d.ts +19 -0
  17. package/dist/dashboard/message-bridge.d.ts.map +1 -0
  18. package/dist/dashboard/message-bridge.js +48 -0
  19. package/dist/dashboard/message-bridge.js.map +1 -0
  20. package/dist/hub/agent-connection.d.ts +101 -0
  21. package/dist/hub/agent-connection.d.ts.map +1 -0
  22. package/dist/hub/agent-connection.js +383 -0
  23. package/dist/hub/agent-connection.js.map +1 -0
  24. package/dist/hub/hub-main.d.ts +2 -0
  25. package/dist/hub/hub-main.d.ts.map +1 -0
  26. package/dist/hub/hub-main.js +16 -0
  27. package/dist/hub/hub-main.js.map +1 -0
  28. package/dist/hub/hub-server.d.ts +8 -0
  29. package/dist/hub/hub-server.d.ts.map +1 -0
  30. package/dist/hub/hub-server.js +1500 -0
  31. package/dist/hub/hub-server.js.map +1 -0
  32. package/dist/hub/protocol.d.ts +221 -0
  33. package/dist/hub/protocol.d.ts.map +1 -0
  34. package/dist/hub/protocol.js +20 -0
  35. package/dist/hub/protocol.js.map +1 -0
  36. package/dist/hub/task-manager.d.ts +68 -0
  37. package/dist/hub/task-manager.d.ts.map +1 -0
  38. package/dist/hub/task-manager.js +250 -0
  39. package/dist/hub/task-manager.js.map +1 -0
  40. package/dist/index.d.ts +2 -0
  41. package/dist/index.d.ts.map +1 -0
  42. package/dist/index.js +7 -0
  43. package/dist/index.js.map +1 -0
  44. package/dist/lifecycle.d.ts +2 -0
  45. package/dist/lifecycle.d.ts.map +1 -0
  46. package/dist/lifecycle.js +234 -0
  47. package/dist/lifecycle.js.map +1 -0
  48. package/dist/prompts.d.ts +3 -0
  49. package/dist/prompts.d.ts.map +1 -0
  50. package/dist/prompts.js +48 -0
  51. package/dist/prompts.js.map +1 -0
  52. package/dist/registry/cleanup.d.ts +2 -0
  53. package/dist/registry/cleanup.d.ts.map +1 -0
  54. package/dist/registry/cleanup.js +60 -0
  55. package/dist/registry/cleanup.js.map +1 -0
  56. package/dist/registry/registry.d.ts +11 -0
  57. package/dist/registry/registry.d.ts.map +1 -0
  58. package/dist/registry/registry.js +82 -0
  59. package/dist/registry/registry.js.map +1 -0
  60. package/dist/server.d.ts +9 -0
  61. package/dist/server.d.ts.map +1 -0
  62. package/dist/server.js +91 -0
  63. package/dist/server.js.map +1 -0
  64. package/dist/stores/message-store.d.ts +21 -0
  65. package/dist/stores/message-store.d.ts.map +1 -0
  66. package/dist/stores/message-store.js +83 -0
  67. package/dist/stores/message-store.js.map +1 -0
  68. package/dist/stores/task-store.d.ts +15 -0
  69. package/dist/stores/task-store.d.ts.map +1 -0
  70. package/dist/stores/task-store.js +57 -0
  71. package/dist/stores/task-store.js.map +1 -0
  72. package/dist/tools/accept-claim.d.ts +4 -0
  73. package/dist/tools/accept-claim.d.ts.map +1 -0
  74. package/dist/tools/accept-claim.js +27 -0
  75. package/dist/tools/accept-claim.js.map +1 -0
  76. package/dist/tools/chat-send.d.ts +4 -0
  77. package/dist/tools/chat-send.d.ts.map +1 -0
  78. package/dist/tools/chat-send.js +19 -0
  79. package/dist/tools/chat-send.js.map +1 -0
  80. package/dist/tools/claim-task.d.ts +4 -0
  81. package/dist/tools/claim-task.d.ts.map +1 -0
  82. package/dist/tools/claim-task.js +26 -0
  83. package/dist/tools/claim-task.js.map +1 -0
  84. package/dist/tools/clear-session.d.ts +5 -0
  85. package/dist/tools/clear-session.d.ts.map +1 -0
  86. package/dist/tools/clear-session.js +41 -0
  87. package/dist/tools/clear-session.js.map +1 -0
  88. package/dist/tools/complete-task.d.ts +4 -0
  89. package/dist/tools/complete-task.d.ts.map +1 -0
  90. package/dist/tools/complete-task.js +29 -0
  91. package/dist/tools/complete-task.js.map +1 -0
  92. package/dist/tools/create-room.d.ts +4 -0
  93. package/dist/tools/create-room.d.ts.map +1 -0
  94. package/dist/tools/create-room.js +28 -0
  95. package/dist/tools/create-room.js.map +1 -0
  96. package/dist/tools/delegate-task.d.ts +4 -0
  97. package/dist/tools/delegate-task.d.ts.map +1 -0
  98. package/dist/tools/delegate-task.js +32 -0
  99. package/dist/tools/delegate-task.js.map +1 -0
  100. package/dist/tools/get-messages.d.ts +4 -0
  101. package/dist/tools/get-messages.d.ts.map +1 -0
  102. package/dist/tools/get-messages.js +35 -0
  103. package/dist/tools/get-messages.js.map +1 -0
  104. package/dist/tools/get-room-digest.d.ts +4 -0
  105. package/dist/tools/get-room-digest.d.ts.map +1 -0
  106. package/dist/tools/get-room-digest.js +63 -0
  107. package/dist/tools/get-room-digest.js.map +1 -0
  108. package/dist/tools/get-task-status.d.ts +4 -0
  109. package/dist/tools/get-task-status.d.ts.map +1 -0
  110. package/dist/tools/get-task-status.js +26 -0
  111. package/dist/tools/get-task-status.js.map +1 -0
  112. package/dist/tools/join-room.d.ts +4 -0
  113. package/dist/tools/join-room.d.ts.map +1 -0
  114. package/dist/tools/join-room.js +26 -0
  115. package/dist/tools/join-room.js.map +1 -0
  116. package/dist/tools/list-peers.d.ts +4 -0
  117. package/dist/tools/list-peers.d.ts.map +1 -0
  118. package/dist/tools/list-peers.js +26 -0
  119. package/dist/tools/list-peers.js.map +1 -0
  120. package/dist/tools/list-tasks.d.ts +4 -0
  121. package/dist/tools/list-tasks.d.ts.map +1 -0
  122. package/dist/tools/list-tasks.js +28 -0
  123. package/dist/tools/list-tasks.js.map +1 -0
  124. package/dist/tools/send-message.d.ts +4 -0
  125. package/dist/tools/send-message.d.ts.map +1 -0
  126. package/dist/tools/send-message.js +28 -0
  127. package/dist/tools/send-message.js.map +1 -0
  128. package/dist/tools/set-status.d.ts +4 -0
  129. package/dist/tools/set-status.d.ts.map +1 -0
  130. package/dist/tools/set-status.js +28 -0
  131. package/dist/tools/set-status.js.map +1 -0
  132. package/dist/tools/update-task.d.ts +4 -0
  133. package/dist/tools/update-task.d.ts.map +1 -0
  134. package/dist/tools/update-task.js +28 -0
  135. package/dist/tools/update-task.js.map +1 -0
  136. package/dist/tools/wait-for-messages.d.ts +4 -0
  137. package/dist/tools/wait-for-messages.d.ts.map +1 -0
  138. package/dist/tools/wait-for-messages.js +84 -0
  139. package/dist/tools/wait-for-messages.js.map +1 -0
  140. package/dist/transport/peer-protocol.d.ts +7 -0
  141. package/dist/transport/peer-protocol.d.ts.map +1 -0
  142. package/dist/transport/peer-protocol.js +30 -0
  143. package/dist/transport/peer-protocol.js.map +1 -0
  144. package/dist/transport/uds-client.d.ts +3 -0
  145. package/dist/transport/uds-client.d.ts.map +1 -0
  146. package/dist/transport/uds-client.js +57 -0
  147. package/dist/transport/uds-client.js.map +1 -0
  148. package/dist/transport/uds-server.d.ts +12 -0
  149. package/dist/transport/uds-server.d.ts.map +1 -0
  150. package/dist/transport/uds-server.js +87 -0
  151. package/dist/transport/uds-server.js.map +1 -0
  152. package/dist/types.d.ts +18 -0
  153. package/dist/types.d.ts.map +1 -0
  154. package/dist/types.js +3 -0
  155. package/dist/types.js.map +1 -0
  156. package/dist/util/id.d.ts +2 -0
  157. package/dist/util/id.d.ts.map +1 -0
  158. package/dist/util/id.js +5 -0
  159. package/dist/util/id.js.map +1 -0
  160. package/dist/util/logger.d.ts +3 -0
  161. package/dist/util/logger.d.ts.map +1 -0
  162. package/dist/util/logger.js +13 -0
  163. package/dist/util/logger.js.map +1 -0
  164. package/dist/util/pid.d.ts +2 -0
  165. package/dist/util/pid.d.ts.map +1 -0
  166. package/dist/util/pid.js +10 -0
  167. package/dist/util/pid.js.map +1 -0
  168. package/hooks/permission-hook.sh +116 -0
  169. package/package.json +59 -0
@@ -0,0 +1,1500 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import os from 'node:os';
4
+ import http from 'node:http';
5
+ import { spawn } from 'node:child_process';
6
+ import express from 'express';
7
+ import { fileURLToPath } from 'node:url';
8
+ import { WebSocketServer, WebSocket } from 'ws';
9
+ import { generateId } from '../util/id.js';
10
+ import { isProcessAlive } from '../util/pid.js';
11
+ import { log, logError } from '../util/logger.js';
12
+ import { TaskManager } from './task-manager.js';
13
+ import { encodeMessage, decodeMessage, } from './protocol.js';
14
+ import { createRequire } from 'node:module';
15
+ // ── Constants ────────────────────────────────────────────────────────
16
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
17
+ const require = createRequire(import.meta.url);
18
+ const pkg = require('../../package.json');
19
+ const CROSSCHAT_DIR = path.join(os.homedir(), '.crosschat');
20
+ const DIGESTS_DIR = path.join(CROSSCHAT_DIR, 'digests');
21
+ const DASHBOARD_LOCK_FILE = path.join(CROSSCHAT_DIR, 'dashboard.lock');
22
+ const PROJECTS_FILE = path.join(CROSSCHAT_DIR, 'projects.json');
23
+ const REGISTER_TIMEOUT_MS = 5_000;
24
+ const HEARTBEAT_INTERVAL_MS = 30_000;
25
+ const PONG_TIMEOUT_MS = 10_000;
26
+ const ROOM_MESSAGE_CAP = 200;
27
+ const PERMISSION_TTL_MS = 10 * 60 * 1000; // 10 minutes for pending permissions
28
+ const PERMISSION_SWEEP_INTERVAL_MS = 60_000;
29
+ const WS_MAX_PAYLOAD = 1 * 1024 * 1024; // 1MB
30
+ // ── Lock file helpers ────────────────────────────────────────────────
31
+ async function ensureCrosschatDir() {
32
+ await fs.mkdir(CROSSCHAT_DIR, { recursive: true });
33
+ }
34
+ async function readDashboardLock() {
35
+ try {
36
+ const data = await fs.readFile(DASHBOARD_LOCK_FILE, 'utf-8');
37
+ const lock = JSON.parse(data);
38
+ if (isProcessAlive(lock.pid)) {
39
+ return lock;
40
+ }
41
+ await fs.unlink(DASHBOARD_LOCK_FILE).catch(() => { });
42
+ return null;
43
+ }
44
+ catch {
45
+ return null;
46
+ }
47
+ }
48
+ async function writeDashboardLock(port) {
49
+ const lock = {
50
+ pid: process.pid,
51
+ port,
52
+ version: pkg.version,
53
+ startedAt: new Date().toISOString(),
54
+ };
55
+ const tmpPath = `${DASHBOARD_LOCK_FILE}.tmp`;
56
+ await fs.writeFile(tmpPath, JSON.stringify(lock, null, 2), 'utf-8');
57
+ await fs.rename(tmpPath, DASHBOARD_LOCK_FILE);
58
+ }
59
+ async function removeDashboardLock() {
60
+ try {
61
+ const data = await fs.readFile(DASHBOARD_LOCK_FILE, 'utf-8');
62
+ const lock = JSON.parse(data);
63
+ if (lock.pid === process.pid) {
64
+ await fs.unlink(DASHBOARD_LOCK_FILE);
65
+ }
66
+ }
67
+ catch {
68
+ // Lock file already gone or unreadable
69
+ }
70
+ }
71
+ // ── Project store helpers ────────────────────────────────────────────
72
+ async function loadProjects() {
73
+ try {
74
+ const data = await fs.readFile(PROJECTS_FILE, 'utf-8');
75
+ const list = JSON.parse(data);
76
+ return new Map(list.map((p) => [p.id, p]));
77
+ }
78
+ catch {
79
+ return new Map();
80
+ }
81
+ }
82
+ async function persistProjects(projects) {
83
+ const list = [...projects.values()];
84
+ const tmpPath = `${PROJECTS_FILE}.tmp`;
85
+ await fs.writeFile(tmpPath, JSON.stringify(list, null, 2), 'utf-8');
86
+ await fs.rename(tmpPath, PROJECTS_FILE);
87
+ }
88
+ // ── Version from package.json ────────────────────────────────────────
89
+ function getServerVersion() {
90
+ return pkg.version;
91
+ }
92
+ // ── Hub Server ───────────────────────────────────────────────────────
93
+ /**
94
+ * Start the hub server.
95
+ *
96
+ * Central hub for CrossChat: manages agent WebSocket connections, peer registry,
97
+ * rooms, message routing, tasks, and serves the React dashboard frontend.
98
+ */
99
+ export async function startHub() {
100
+ await ensureCrosschatDir();
101
+ // Check for an already-running hub
102
+ const existingLock = await readDashboardLock();
103
+ if (existingLock) {
104
+ log(`Hub already running on port ${existingLock.port} (pid ${existingLock.pid})`);
105
+ process.exit(1);
106
+ }
107
+ // ── State ──────────────────────────────────────────────────────
108
+ const agents = new Map();
109
+ const rooms = new Map();
110
+ const browserClients = new Set();
111
+ const browserRooms = new WeakMap();
112
+ const browserTokens = new Map(); // session tokens for permission decisions
113
+ const validTokens = new Set(); // fast lookup for REST auth
114
+ const pendingPermissions = new Map();
115
+ const projects = await loadProjects();
116
+ // Initialize TaskManager
117
+ const taskManager = new TaskManager();
118
+ await taskManager.init();
119
+ // Digest tracking: roomId -> taskId of currently active digest task
120
+ const activeDigestTasks = new Map();
121
+ // Ensure digests directory exists
122
+ await fs.mkdir(DIGESTS_DIR, { recursive: true });
123
+ // Seed default rooms
124
+ rooms.set('general', {
125
+ id: 'general',
126
+ name: 'General',
127
+ messages: [],
128
+ createdAt: new Date().toISOString(),
129
+ });
130
+ rooms.set('crosschat', {
131
+ id: 'crosschat',
132
+ name: 'CrossChat Activity',
133
+ messages: [],
134
+ createdAt: new Date().toISOString(),
135
+ });
136
+ // ── Helpers ────────────────────────────────────────────────────
137
+ function sendToWs(ws, msg) {
138
+ if (ws.readyState === WebSocket.OPEN) {
139
+ ws.send(encodeMessage(msg));
140
+ }
141
+ }
142
+ function sendError(ws, message, requestId) {
143
+ sendToWs(ws, { type: 'error', message, requestId });
144
+ }
145
+ async function autoRegisterProject(cwd) {
146
+ const resolvedPath = path.resolve(cwd);
147
+ // Skip if path has unsafe characters (same check as POST /api/projects)
148
+ if (!/^[a-zA-Z0-9\s/\-_\.~]+$/.test(resolvedPath))
149
+ return;
150
+ // Skip if a project with this path already exists
151
+ for (const existing of projects.values()) {
152
+ if (existing.path === resolvedPath)
153
+ return;
154
+ }
155
+ // Validate directory exists
156
+ try {
157
+ const stat = await fs.stat(resolvedPath);
158
+ if (!stat.isDirectory())
159
+ return;
160
+ }
161
+ catch {
162
+ return;
163
+ }
164
+ const dirName = resolvedPath.split('/').filter(Boolean).pop() || 'unknown';
165
+ const project = {
166
+ id: generateId(),
167
+ name: dirName,
168
+ path: resolvedPath,
169
+ createdAt: new Date().toISOString(),
170
+ };
171
+ projects.set(project.id, project);
172
+ await persistProjects(projects);
173
+ log(`Auto-registered project: ${project.name} (${project.path})`);
174
+ }
175
+ function findAgentByWs(ws) {
176
+ for (const agent of agents.values()) {
177
+ if (agent.ws === ws)
178
+ return agent;
179
+ }
180
+ return undefined;
181
+ }
182
+ function findAgentById(peerId) {
183
+ return agents.get(peerId);
184
+ }
185
+ function buildPeerInfo(agent) {
186
+ return {
187
+ peerId: agent.peerId,
188
+ name: agent.name,
189
+ cwd: agent.cwd,
190
+ pid: agent.pid,
191
+ status: agent.status,
192
+ statusDetail: agent.statusDetail,
193
+ currentRoom: agent.currentRoom,
194
+ connectedAt: agent.connectedAt,
195
+ };
196
+ }
197
+ function taskToSummary(task) {
198
+ return {
199
+ taskId: task.taskId,
200
+ roomId: task.roomId,
201
+ creatorId: task.creatorId,
202
+ creatorName: task.creatorName,
203
+ description: task.description,
204
+ context: task.context,
205
+ filter: task.filter,
206
+ status: task.status,
207
+ claimantId: task.claimantId,
208
+ claimantName: task.claimantName,
209
+ createdAt: task.createdAt,
210
+ updatedAt: task.updatedAt,
211
+ };
212
+ }
213
+ // ── Mention parsing ──────────────────────────────────────────
214
+ /**
215
+ * Parse @mentions from message content.
216
+ * Supports @agent-name (targeted) and @here (room broadcast).
217
+ * Returns the list of mentioned agent names and the mention type.
218
+ */
219
+ function parseMentions(content) {
220
+ const hasHere = /@here\b/i.test(content);
221
+ if (hasHere) {
222
+ return { mentions: [], mentionType: 'here' };
223
+ }
224
+ // Extract all @mentions from the content
225
+ const mentionPattern = /@([\w-]+)/g;
226
+ const rawMentions = [];
227
+ let match;
228
+ while ((match = mentionPattern.exec(content)) !== null) {
229
+ rawMentions.push(match[1]);
230
+ }
231
+ if (rawMentions.length === 0) {
232
+ return { mentions: [], mentionType: 'broadcast' };
233
+ }
234
+ // Resolve mentions against known agent names (case-insensitive)
235
+ const resolvedNames = [];
236
+ for (const agent of agents.values()) {
237
+ const agentNameLower = agent.name.toLowerCase();
238
+ for (const mention of rawMentions) {
239
+ if (mention.toLowerCase() === agentNameLower) {
240
+ resolvedNames.push(agent.name);
241
+ }
242
+ }
243
+ }
244
+ if (resolvedNames.length > 0) {
245
+ return { mentions: resolvedNames, mentionType: 'direct' };
246
+ }
247
+ // Mentions didn't match any known agents — treat as broadcast
248
+ return { mentions: [], mentionType: 'broadcast' };
249
+ }
250
+ // ── Broadcasting ───────────────────────────────────────────────
251
+ /** Broadcast a server message to all agents in a specific room. */
252
+ function broadcastToRoomAgents(roomId, msg, excludePeerId) {
253
+ for (const agent of agents.values()) {
254
+ if (agent.currentRoom === roomId && agent.peerId !== excludePeerId) {
255
+ sendToWs(agent.ws, msg);
256
+ }
257
+ }
258
+ }
259
+ /** Broadcast a JSON payload to all browser clients subscribed to a room. */
260
+ function broadcastToRoomBrowsers(roomId, data) {
261
+ const payload = JSON.stringify(data);
262
+ for (const client of browserClients) {
263
+ if (client.readyState !== WebSocket.OPEN)
264
+ continue;
265
+ const joined = browserRooms.get(client);
266
+ if (joined?.has(roomId)) {
267
+ client.send(payload);
268
+ }
269
+ }
270
+ }
271
+ /** Broadcast to ALL browser clients (not filtered by room). */
272
+ function broadcastToAllBrowsers(data) {
273
+ const payload = JSON.stringify(data);
274
+ for (const client of browserClients) {
275
+ if (client.readyState === WebSocket.OPEN) {
276
+ client.send(payload);
277
+ }
278
+ }
279
+ }
280
+ /** Broadcast a room message to agents AND browsers in the room, with mention filtering. */
281
+ function broadcastRoomMessage(roomId, msg, excludePeerId) {
282
+ // Build the protocol message for agents
283
+ const agentMsg = {
284
+ type: 'room.message',
285
+ roomId: msg.roomId,
286
+ messageId: msg.messageId,
287
+ fromPeerId: msg.fromPeerId,
288
+ fromName: msg.fromName,
289
+ content: msg.content,
290
+ metadata: msg.metadata,
291
+ timestamp: msg.timestamp,
292
+ source: msg.source,
293
+ mentions: msg.mentions,
294
+ mentionType: msg.mentionType,
295
+ importance: msg.importance,
296
+ };
297
+ if (msg.mentionType === 'direct' && msg.mentions && msg.mentions.length > 0) {
298
+ // Direct mention: only deliver to mentioned agents (+ sender echo)
299
+ const mentionedNamesLower = new Set(msg.mentions.map((n) => n.toLowerCase()));
300
+ for (const agent of agents.values()) {
301
+ if (agent.currentRoom !== roomId)
302
+ continue;
303
+ if (agent.peerId === excludePeerId)
304
+ continue;
305
+ if (mentionedNamesLower.has(agent.name.toLowerCase()) || agent.peerId === msg.fromPeerId) {
306
+ sendToWs(agent.ws, agentMsg);
307
+ }
308
+ }
309
+ }
310
+ else {
311
+ // @here or broadcast: deliver to all agents in the room
312
+ broadcastToRoomAgents(roomId, agentMsg, excludePeerId);
313
+ }
314
+ // Always send to all browsers — dashboard users see everything
315
+ broadcastToRoomBrowsers(roomId, {
316
+ type: 'message',
317
+ messageId: msg.messageId,
318
+ roomId: msg.roomId,
319
+ username: msg.fromName,
320
+ text: msg.content,
321
+ timestamp: msg.timestamp,
322
+ source: msg.source,
323
+ mentions: msg.mentions,
324
+ mentionType: msg.mentionType,
325
+ importance: msg.importance,
326
+ });
327
+ }
328
+ // ── Digest system ─────────────────────────────────────────────
329
+ /**
330
+ * Enforce the per-room message cap. When exceeded, the oldest messages
331
+ * are moved to a pending digest JSONL file and a digest task is created.
332
+ */
333
+ async function enforceRoomMessageCap(room) {
334
+ if (room.messages.length <= ROOM_MESSAGE_CAP)
335
+ return;
336
+ const overflow = room.messages.length - ROOM_MESSAGE_CAP;
337
+ const evicted = room.messages.splice(0, overflow);
338
+ // Append evicted messages to pending digest file (one JSON object per line)
339
+ const pendingPath = path.join(DIGESTS_DIR, `${room.id}.jsonl`);
340
+ const lines = evicted.map((m) => JSON.stringify(m)).join('\n') + '\n';
341
+ await fs.appendFile(pendingPath, lines, 'utf-8');
342
+ log(`Room ${room.id}: evicted ${overflow} message(s) to pending digest`);
343
+ // Auto-delegate a digest task if one isn't already active for this room
344
+ const existingTaskId = activeDigestTasks.get(room.id);
345
+ if (existingTaskId) {
346
+ const existingTask = taskManager.get(existingTaskId);
347
+ if (existingTask && (existingTask.status === 'open' || existingTask.status === 'claimed' || existingTask.status === 'in_progress')) {
348
+ // A digest task is already in progress — don't create another
349
+ return;
350
+ }
351
+ // Previous task finished or was archived — allow a new one
352
+ activeDigestTasks.delete(room.id);
353
+ }
354
+ // Read the pending messages as context for the digest task
355
+ let pendingContent;
356
+ try {
357
+ pendingContent = await fs.readFile(pendingPath, 'utf-8');
358
+ }
359
+ catch {
360
+ pendingContent = lines;
361
+ }
362
+ try {
363
+ const task = await taskManager.create({
364
+ roomId: room.id,
365
+ creatorId: 'system',
366
+ creatorName: 'system',
367
+ description: 'Summarize the following room messages into a digest',
368
+ context: pendingContent,
369
+ });
370
+ activeDigestTasks.set(room.id, task.taskId);
371
+ // Broadcast task creation to agents and browsers in the room
372
+ const summary = taskToSummary(task);
373
+ broadcastToRoomAgents(room.id, { type: 'task.created', task: summary });
374
+ broadcastToRoomBrowsers(room.id, { type: 'task.created', task: summary });
375
+ log(`Digest task ${task.taskId} created for room ${room.id}`);
376
+ }
377
+ catch (err) {
378
+ logError(`Failed to create digest task for room ${room.id}`, err);
379
+ }
380
+ }
381
+ /**
382
+ * Handle digest task completion: persist the full digest to disk,
383
+ * clear the pending file, and insert a system message into the room.
384
+ */
385
+ async function handleDigestCompletion(task) {
386
+ const roomId = task.roomId;
387
+ const digestSummary = task.result ?? '';
388
+ // Read the pending messages that were the basis for this digest
389
+ const pendingPath = path.join(DIGESTS_DIR, `${roomId}.jsonl`);
390
+ let pendingMessages = '';
391
+ try {
392
+ pendingMessages = await fs.readFile(pendingPath, 'utf-8');
393
+ }
394
+ catch {
395
+ // Pending file may have been cleared already
396
+ }
397
+ // Persist the full digest to ~/.crosschat/digests/{roomId}/{timestamp}.md
398
+ const roomDigestDir = path.join(DIGESTS_DIR, roomId);
399
+ await fs.mkdir(roomDigestDir, { recursive: true });
400
+ const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
401
+ const digestPath = path.join(roomDigestDir, `${timestamp}.md`);
402
+ // Format original messages for the "Full messages" section
403
+ let formattedMessages = '';
404
+ if (pendingMessages.trim()) {
405
+ const msgLines = pendingMessages.trim().split('\n');
406
+ for (const line of msgLines) {
407
+ try {
408
+ const msg = JSON.parse(line);
409
+ formattedMessages += `**${msg.fromName}** (${msg.timestamp}): ${msg.content}\n\n`;
410
+ }
411
+ catch {
412
+ // Skip malformed lines
413
+ }
414
+ }
415
+ }
416
+ const digestContent = `# Room Digest: ${roomId}\n\n` +
417
+ `Generated: ${new Date().toISOString()}\n\n` +
418
+ `## Summary\n\n${digestSummary}\n\n` +
419
+ `## Full messages\n\n${formattedMessages || '(no messages available)'}`;
420
+ await fs.writeFile(digestPath, digestContent, 'utf-8');
421
+ // Clear the pending file
422
+ try {
423
+ await fs.unlink(pendingPath);
424
+ }
425
+ catch {
426
+ // Already gone
427
+ }
428
+ // Clean up tracking
429
+ activeDigestTasks.delete(roomId);
430
+ // Insert a system message into the room
431
+ const firstLine = digestSummary.split('\n')[0].slice(0, 200);
432
+ const room = rooms.get(roomId);
433
+ if (room) {
434
+ const sysMsg = {
435
+ messageId: generateId(),
436
+ roomId,
437
+ fromPeerId: 'system',
438
+ fromName: 'system',
439
+ content: `\u{1F4CB} Room digest: ${firstLine}\n\nFull digest saved to ${digestPath}`,
440
+ timestamp: new Date().toISOString(),
441
+ source: 'system',
442
+ importance: 'important',
443
+ };
444
+ room.messages.push(sysMsg);
445
+ broadcastRoomMessage(roomId, sysMsg);
446
+ }
447
+ log(`Digest completed for room ${roomId}: ${digestPath}`);
448
+ }
449
+ // ── Agent removal ──────────────────────────────────────────────
450
+ function removeAgent(peerId) {
451
+ const agent = agents.get(peerId);
452
+ if (!agent)
453
+ return;
454
+ agents.delete(peerId);
455
+ log(`Agent disconnected: ${agent.name} (${peerId})`);
456
+ broadcastToAllBrowsers({ type: 'peerDisconnected', peerId, name: agent.name });
457
+ }
458
+ // ── Agent message handlers ────────────────────────────────────
459
+ function handleAgentStatus(agent, msg) {
460
+ agent.status = msg.status;
461
+ agent.statusDetail = msg.detail;
462
+ log(`Agent ${agent.name} status: ${msg.status}${msg.detail ? ` (${msg.detail})` : ''}`);
463
+ }
464
+ function handleJoinRoom(agent, msg) {
465
+ const room = rooms.get(msg.roomId);
466
+ if (!room) {
467
+ sendError(agent.ws, `Room not found: ${msg.roomId}`);
468
+ return;
469
+ }
470
+ const oldRoom = agent.currentRoom;
471
+ agent.currentRoom = msg.roomId;
472
+ // Notify the agent
473
+ sendToWs(agent.ws, { type: 'room.joined', roomId: room.id, name: room.name });
474
+ // Notify browsers about room membership change
475
+ if (oldRoom !== msg.roomId) {
476
+ broadcastToRoomBrowsers(oldRoom, {
477
+ type: 'agentLeft',
478
+ roomId: oldRoom,
479
+ peerId: agent.peerId,
480
+ name: agent.name,
481
+ });
482
+ broadcastToRoomBrowsers(msg.roomId, {
483
+ type: 'agentJoined',
484
+ roomId: msg.roomId,
485
+ peerId: agent.peerId,
486
+ name: agent.name,
487
+ });
488
+ }
489
+ log(`Agent ${agent.name} joined room ${msg.roomId}`);
490
+ }
491
+ function handleCreateRoom(agent, msg) {
492
+ if (rooms.has(msg.roomId)) {
493
+ sendError(agent.ws, `Room already exists: ${msg.roomId}`);
494
+ return;
495
+ }
496
+ const room = {
497
+ id: msg.roomId,
498
+ name: msg.name ?? msg.roomId,
499
+ messages: [],
500
+ createdAt: new Date().toISOString(),
501
+ };
502
+ rooms.set(msg.roomId, room);
503
+ // Notify the creating agent
504
+ sendToWs(agent.ws, { type: 'room.created', roomId: room.id, name: room.name });
505
+ // Notify all browser clients about the new room
506
+ broadcastToAllBrowsers({
507
+ type: 'roomCreated',
508
+ room: { id: room.id, name: room.name, createdAt: room.createdAt, messageCount: 0 },
509
+ });
510
+ log(`Room created: ${room.id} by ${agent.name}`);
511
+ }
512
+ function handleSendMessage(agent, msg) {
513
+ const roomId = agent.currentRoom;
514
+ const room = rooms.get(roomId);
515
+ if (!room) {
516
+ sendError(agent.ws, `Room not found: ${roomId}`);
517
+ return;
518
+ }
519
+ const { mentions, mentionType } = parseMentions(msg.content);
520
+ const chatMsg = {
521
+ messageId: generateId(),
522
+ roomId,
523
+ fromPeerId: agent.peerId,
524
+ fromName: agent.name,
525
+ content: msg.content,
526
+ metadata: msg.metadata,
527
+ timestamp: new Date().toISOString(),
528
+ source: 'agent',
529
+ mentions: mentions.length > 0 ? mentions : undefined,
530
+ mentionType,
531
+ importance: msg.importance,
532
+ };
533
+ room.messages.push(chatMsg);
534
+ enforceRoomMessageCap(room).catch((err) => logError('Error enforcing room message cap', err));
535
+ // Broadcast with mention-based filtering
536
+ broadcastRoomMessage(roomId, chatMsg);
537
+ }
538
+ function handleListPeers(agent, msg) {
539
+ const peers = [];
540
+ for (const a of agents.values()) {
541
+ if (a.peerId !== agent.peerId) {
542
+ peers.push(buildPeerInfo(a));
543
+ }
544
+ }
545
+ sendToWs(agent.ws, { type: 'peers', requestId: msg.requestId, peers });
546
+ }
547
+ async function handleTaskCreate(agent, msg) {
548
+ try {
549
+ const task = await taskManager.create({
550
+ roomId: agent.currentRoom,
551
+ creatorId: agent.peerId,
552
+ creatorName: agent.name,
553
+ description: msg.description,
554
+ context: msg.context,
555
+ filter: msg.filter,
556
+ });
557
+ const summary = taskToSummary(task);
558
+ // Broadcast to all agents in the room (including creator) and browsers
559
+ broadcastToRoomAgents(agent.currentRoom, { type: 'task.created', task: summary });
560
+ broadcastToRoomBrowsers(agent.currentRoom, { type: 'task.created', task: summary });
561
+ }
562
+ catch (err) {
563
+ sendError(agent.ws, err instanceof Error ? err.message : 'Failed to create task');
564
+ }
565
+ }
566
+ async function handleTaskClaim(agent, msg) {
567
+ try {
568
+ const task = await taskManager.claim(msg.taskId, agent.peerId, agent.name);
569
+ // Notify the task creator
570
+ const creator = findAgentById(task.creatorId);
571
+ if (creator) {
572
+ sendToWs(creator.ws, {
573
+ type: 'task.claimed',
574
+ taskId: task.taskId,
575
+ claimantId: agent.peerId,
576
+ claimantName: agent.name,
577
+ });
578
+ }
579
+ // Also notify the claimant that the claim was registered
580
+ sendToWs(agent.ws, {
581
+ type: 'task.claimed',
582
+ taskId: task.taskId,
583
+ claimantId: agent.peerId,
584
+ claimantName: agent.name,
585
+ });
586
+ // Broadcast to dashboard
587
+ broadcastToAllBrowsers({ type: 'task.claimed', taskId: task.taskId, claimantId: agent.peerId, claimantName: agent.name });
588
+ }
589
+ catch (err) {
590
+ sendError(agent.ws, err instanceof Error ? err.message : 'Failed to claim task');
591
+ }
592
+ }
593
+ async function handleTaskAccept(agent, msg) {
594
+ try {
595
+ const task = await taskManager.acceptClaim(msg.taskId, agent.peerId);
596
+ // Notify the claimant
597
+ if (task.claimantId) {
598
+ const claimant = findAgentById(task.claimantId);
599
+ if (claimant) {
600
+ sendToWs(claimant.ws, {
601
+ type: 'task.claimAccepted',
602
+ taskId: task.taskId,
603
+ assignedTo: task.claimantId,
604
+ });
605
+ }
606
+ }
607
+ // Acknowledge to the creator
608
+ sendToWs(agent.ws, {
609
+ type: 'task.claimAccepted',
610
+ taskId: task.taskId,
611
+ assignedTo: task.claimantId ?? '',
612
+ });
613
+ // Broadcast to dashboard
614
+ broadcastToAllBrowsers({ type: 'task.claimAccepted', taskId: task.taskId, assignedTo: task.claimantId ?? '' });
615
+ }
616
+ catch (err) {
617
+ sendError(agent.ws, err instanceof Error ? err.message : 'Failed to accept claim');
618
+ }
619
+ }
620
+ async function handleTaskUpdate(agent, msg) {
621
+ try {
622
+ const task = await taskManager.update(msg.taskId, agent.peerId, agent.name, msg.content, msg.status);
623
+ const lastNote = task.notes[task.notes.length - 1];
624
+ // Notify creator and claimant (if they are different from the author)
625
+ const notifyIds = new Set();
626
+ if (task.creatorId !== agent.peerId)
627
+ notifyIds.add(task.creatorId);
628
+ if (task.claimantId && task.claimantId !== agent.peerId)
629
+ notifyIds.add(task.claimantId);
630
+ for (const targetId of notifyIds) {
631
+ const target = findAgentById(targetId);
632
+ if (target) {
633
+ sendToWs(target.ws, {
634
+ type: 'task.updated',
635
+ taskId: task.taskId,
636
+ note: lastNote,
637
+ });
638
+ }
639
+ }
640
+ // Also confirm to the sender
641
+ sendToWs(agent.ws, {
642
+ type: 'task.updated',
643
+ taskId: task.taskId,
644
+ note: lastNote,
645
+ });
646
+ // Broadcast to dashboard
647
+ broadcastToAllBrowsers({ type: 'task.updated', taskId: task.taskId, note: lastNote });
648
+ }
649
+ catch (err) {
650
+ sendError(agent.ws, err instanceof Error ? err.message : 'Failed to update task');
651
+ }
652
+ }
653
+ async function handleTaskComplete(agent, msg) {
654
+ try {
655
+ const task = await taskManager.complete(msg.taskId, agent.peerId, agent.name, msg.result, msg.status, msg.error);
656
+ // Notify the creator (skip for system-created digest tasks)
657
+ if (task.creatorId !== 'system') {
658
+ const creator = findAgentById(task.creatorId);
659
+ if (creator) {
660
+ sendToWs(creator.ws, {
661
+ type: 'task.completed',
662
+ taskId: task.taskId,
663
+ status: msg.status,
664
+ result: msg.result,
665
+ });
666
+ }
667
+ }
668
+ // Confirm to the completer
669
+ sendToWs(agent.ws, {
670
+ type: 'task.completed',
671
+ taskId: task.taskId,
672
+ status: msg.status,
673
+ result: msg.result,
674
+ });
675
+ // Broadcast to dashboard
676
+ broadcastToAllBrowsers({ type: 'task.completed', taskId: task.taskId, status: msg.status, result: msg.result });
677
+ // Check if this is a digest task completion
678
+ if (msg.status === 'completed' && activeDigestTasks.get(task.roomId) === task.taskId) {
679
+ await handleDigestCompletion(task);
680
+ }
681
+ }
682
+ catch (err) {
683
+ sendError(agent.ws, err instanceof Error ? err.message : 'Failed to complete task');
684
+ }
685
+ }
686
+ async function handleClearSession(agent, msg) {
687
+ const clearMessages = msg.messages !== false; // default true
688
+ const clearTasks = msg.tasks === true; // default false
689
+ let messagesCleared = 0;
690
+ let tasksArchived = 0;
691
+ if (clearMessages) {
692
+ const room = rooms.get(agent.currentRoom);
693
+ if (room) {
694
+ messagesCleared = room.messages.length;
695
+ room.messages = [];
696
+ // Notify browsers that the room was cleared
697
+ broadcastToRoomBrowsers(agent.currentRoom, {
698
+ type: 'sessionCleared',
699
+ roomId: agent.currentRoom,
700
+ clearedBy: agent.name,
701
+ messagesCleared,
702
+ });
703
+ }
704
+ }
705
+ if (clearTasks) {
706
+ tasksArchived = await taskManager.archiveTerminal(agent.currentRoom);
707
+ }
708
+ sendToWs(agent.ws, {
709
+ type: 'session.cleared',
710
+ requestId: msg.requestId,
711
+ messagesCleared,
712
+ tasksArchived,
713
+ });
714
+ log(`Session cleared by ${agent.name}: ${messagesCleared} message(s), ${tasksArchived} task(s) archived`);
715
+ }
716
+ function handleGetTask(agent, msg) {
717
+ const task = taskManager.get(msg.taskId);
718
+ if (!task) {
719
+ sendError(agent.ws, `Task not found: ${msg.taskId}`, msg.requestId);
720
+ return;
721
+ }
722
+ const summary = taskToSummary(task);
723
+ sendToWs(agent.ws, {
724
+ type: 'task.detail',
725
+ requestId: msg.requestId,
726
+ task: { ...summary, notes: task.notes, result: task.result, error: task.error },
727
+ });
728
+ }
729
+ function handleListTasks(agent, msg) {
730
+ const filter = {};
731
+ if (msg.status)
732
+ filter.status = msg.status;
733
+ if (msg.roomId)
734
+ filter.roomId = msg.roomId;
735
+ if (msg.assignedTo)
736
+ filter.assignedTo = msg.assignedTo;
737
+ const tasks = taskManager.list(Object.keys(filter).length > 0 ? filter : undefined);
738
+ sendToWs(agent.ws, {
739
+ type: 'tasks',
740
+ requestId: msg.requestId,
741
+ tasks: tasks.map(taskToSummary),
742
+ });
743
+ }
744
+ // ── Agent message dispatch ─────────────────────────────────────
745
+ async function handleAgentMessage(ws, raw) {
746
+ const agent = findAgentByWs(ws);
747
+ if (!agent)
748
+ return;
749
+ let msg;
750
+ try {
751
+ msg = decodeMessage(raw);
752
+ }
753
+ catch {
754
+ sendError(ws, 'Invalid message format');
755
+ return;
756
+ }
757
+ switch (msg.type) {
758
+ case 'agent.heartbeat':
759
+ // Already handled by pong; nothing to do
760
+ break;
761
+ case 'agent.status':
762
+ handleAgentStatus(agent, msg);
763
+ break;
764
+ case 'agent.disconnect':
765
+ removeAgent(agent.peerId);
766
+ ws.close();
767
+ break;
768
+ case 'agent.joinRoom':
769
+ handleJoinRoom(agent, msg);
770
+ break;
771
+ case 'agent.createRoom':
772
+ handleCreateRoom(agent, msg);
773
+ break;
774
+ case 'agent.sendMessage':
775
+ handleSendMessage(agent, msg);
776
+ break;
777
+ case 'agent.listPeers':
778
+ handleListPeers(agent, msg);
779
+ break;
780
+ case 'task.create':
781
+ await handleTaskCreate(agent, msg);
782
+ break;
783
+ case 'task.claim':
784
+ await handleTaskClaim(agent, msg);
785
+ break;
786
+ case 'task.accept':
787
+ await handleTaskAccept(agent, msg);
788
+ break;
789
+ case 'task.update':
790
+ await handleTaskUpdate(agent, msg);
791
+ break;
792
+ case 'task.complete':
793
+ await handleTaskComplete(agent, msg);
794
+ break;
795
+ case 'agent.getTask':
796
+ handleGetTask(agent, msg);
797
+ break;
798
+ case 'agent.listTasks':
799
+ handleListTasks(agent, msg);
800
+ break;
801
+ case 'agent.clearSession':
802
+ await handleClearSession(agent, msg);
803
+ break;
804
+ default:
805
+ sendError(ws, `Unknown message type: ${msg.type}`);
806
+ }
807
+ }
808
+ // ── Express app ────────────────────────────────────────────────
809
+ const app = express();
810
+ app.use(express.json());
811
+ // CORS — restrict to localhost origins only
812
+ const ALLOWED_ORIGINS = new Set([
813
+ 'http://localhost',
814
+ 'http://127.0.0.1',
815
+ 'http://[::1]',
816
+ ]);
817
+ app.use((req, res, next) => {
818
+ const origin = req.headers.origin;
819
+ // Allow requests with no origin (same-origin, curl, agents)
820
+ if (!origin) {
821
+ res.header('Access-Control-Allow-Headers', 'Content-Type');
822
+ res.header('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
823
+ next();
824
+ return;
825
+ }
826
+ // Match origin with or without port
827
+ const originBase = origin.replace(/:\d+$/, '');
828
+ if (ALLOWED_ORIGINS.has(originBase)) {
829
+ res.header('Access-Control-Allow-Origin', origin);
830
+ res.header('Access-Control-Allow-Headers', 'Content-Type');
831
+ res.header('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
832
+ }
833
+ // Preflight
834
+ if (req.method === 'OPTIONS') {
835
+ res.sendStatus(204);
836
+ return;
837
+ }
838
+ next();
839
+ });
840
+ // Serve dashboard React frontend
841
+ const distPath = path.join(__dirname, '..', '..', 'dashboard', 'dist');
842
+ app.use(express.static(distPath));
843
+ // ── REST: Rooms ────────────────────────────────────────────────
844
+ app.get('/api/rooms', (_req, res) => {
845
+ const list = [...rooms.values()].map(({ id, name, createdAt, messages }) => ({
846
+ id,
847
+ name,
848
+ createdAt,
849
+ messageCount: messages.length,
850
+ }));
851
+ res.json(list);
852
+ });
853
+ app.post('/api/rooms', (req, res) => {
854
+ const { name } = req.body;
855
+ if (!name || typeof name !== 'string' || name.trim().length === 0) {
856
+ res.status(400).json({ error: 'Room name is required' });
857
+ return;
858
+ }
859
+ const id = generateId();
860
+ const room = { id, name: name.trim(), messages: [], createdAt: new Date().toISOString() };
861
+ rooms.set(id, room);
862
+ broadcastToAllBrowsers({
863
+ type: 'roomCreated',
864
+ room: { id, name: room.name, createdAt: room.createdAt, messageCount: 0 },
865
+ });
866
+ res.status(201).json({ id, name: room.name, createdAt: room.createdAt });
867
+ });
868
+ app.get('/api/rooms/:roomId/messages', (req, res) => {
869
+ const room = rooms.get(req.params.roomId);
870
+ if (!room) {
871
+ res.status(404).json({ error: 'Room not found' });
872
+ return;
873
+ }
874
+ // Return in the format the dashboard frontend expects
875
+ const messages = room.messages.map((m) => ({
876
+ messageId: m.messageId,
877
+ roomId: m.roomId,
878
+ username: m.fromName,
879
+ text: m.content,
880
+ timestamp: m.timestamp,
881
+ source: m.source,
882
+ mentions: m.mentions,
883
+ mentionType: m.mentionType,
884
+ importance: m.importance,
885
+ }));
886
+ res.json(messages);
887
+ });
888
+ app.post('/api/rooms/:roomId/messages', (req, res) => {
889
+ const room = rooms.get(req.params.roomId);
890
+ if (!room) {
891
+ res.status(404).json({ error: 'Room not found' });
892
+ return;
893
+ }
894
+ const { username, text } = req.body;
895
+ if (!username || !text) {
896
+ res.status(400).json({ error: 'username and text are required' });
897
+ return;
898
+ }
899
+ const content = text.trim();
900
+ const { mentions, mentionType } = parseMentions(content);
901
+ const chatMsg = {
902
+ messageId: generateId(),
903
+ roomId: room.id,
904
+ fromPeerId: 'dashboard-user',
905
+ fromName: username.trim(),
906
+ content,
907
+ metadata: undefined,
908
+ timestamp: new Date().toISOString(),
909
+ source: 'user',
910
+ mentions: mentions.length > 0 ? mentions : undefined,
911
+ mentionType,
912
+ };
913
+ room.messages.push(chatMsg);
914
+ enforceRoomMessageCap(room).catch((err) => logError('Error enforcing room message cap', err));
915
+ // Broadcast to agents in this room (with mention filtering)
916
+ broadcastRoomMessage(room.id, chatMsg);
917
+ res.status(201).json({
918
+ messageId: chatMsg.messageId,
919
+ roomId: chatMsg.roomId,
920
+ username: chatMsg.fromName,
921
+ text: chatMsg.content,
922
+ timestamp: chatMsg.timestamp,
923
+ source: chatMsg.source,
924
+ mentions: chatMsg.mentions,
925
+ mentionType: chatMsg.mentionType,
926
+ });
927
+ });
928
+ // ── REST: Peers ────────────────────────────────────────────────
929
+ app.get('/api/peers', (_req, res) => {
930
+ const peers = [];
931
+ for (const agent of agents.values()) {
932
+ peers.push(buildPeerInfo(agent));
933
+ }
934
+ res.json(peers);
935
+ });
936
+ // ── REST: Tasks ────────────────────────────────────────────────
937
+ app.get('/api/tasks', (req, res) => {
938
+ const filter = {};
939
+ if (req.query.status)
940
+ filter.status = req.query.status;
941
+ if (req.query.roomId)
942
+ filter.roomId = req.query.roomId;
943
+ if (req.query.assignedTo)
944
+ filter.assignedTo = req.query.assignedTo;
945
+ const tasks = taskManager.list(Object.keys(filter).length > 0 ? filter : undefined);
946
+ res.json(tasks);
947
+ });
948
+ app.get('/api/tasks/:id', (req, res) => {
949
+ const task = taskManager.get(req.params.id);
950
+ if (!task) {
951
+ res.status(404).json({ error: 'Task not found' });
952
+ return;
953
+ }
954
+ res.json(task);
955
+ });
956
+ app.post('/api/tasks/:id/archive', async (req, res) => {
957
+ try {
958
+ const task = await taskManager.archive(req.params.id);
959
+ res.json(task);
960
+ }
961
+ catch (err) {
962
+ const message = err instanceof Error ? err.message : 'Failed to archive task';
963
+ res.status(400).json({ error: message });
964
+ }
965
+ });
966
+ // ── REST: Clear session ──────────────────────────────────────────
967
+ app.post('/api/rooms/:roomId/clear', async (req, res) => {
968
+ const room = rooms.get(req.params.roomId);
969
+ if (!room) {
970
+ res.status(404).json({ error: 'Room not found' });
971
+ return;
972
+ }
973
+ const clearMessages = req.body?.messages !== false;
974
+ const clearTasks = req.body?.tasks === true;
975
+ let messagesCleared = 0;
976
+ let tasksArchived = 0;
977
+ if (clearMessages) {
978
+ messagesCleared = room.messages.length;
979
+ room.messages = [];
980
+ broadcastToRoomBrowsers(req.params.roomId, {
981
+ type: 'sessionCleared',
982
+ roomId: req.params.roomId,
983
+ clearedBy: 'dashboard',
984
+ messagesCleared,
985
+ });
986
+ }
987
+ if (clearTasks) {
988
+ tasksArchived = await taskManager.archiveTerminal(req.params.roomId);
989
+ }
990
+ res.json({ messagesCleared, tasksArchived });
991
+ });
992
+ // ── REST: Permissions ────────────────────────────────────────────
993
+ app.get('/api/permissions', (_req, res) => {
994
+ const list = [...pendingPermissions.values()].filter((p) => p.status === 'pending');
995
+ res.json(list);
996
+ });
997
+ app.post('/api/permissions', (req, res) => {
998
+ const { agentName, agentPeerId, toolName, toolInput, description } = req.body;
999
+ if (!toolName) {
1000
+ res.status(400).json({ error: 'toolName is required' });
1001
+ return;
1002
+ }
1003
+ const id = generateId();
1004
+ const permission = {
1005
+ id,
1006
+ agentName: agentName || 'unknown',
1007
+ agentPeerId,
1008
+ toolName,
1009
+ toolInput: toolInput || {},
1010
+ description,
1011
+ status: 'pending',
1012
+ createdAt: new Date().toISOString(),
1013
+ };
1014
+ pendingPermissions.set(id, permission);
1015
+ // Broadcast to all browser clients
1016
+ broadcastToAllBrowsers({
1017
+ type: 'permission.request',
1018
+ permission,
1019
+ });
1020
+ log(`Permission request: ${permission.agentName} wants to use ${toolName} (${id})`);
1021
+ res.status(201).json(permission);
1022
+ });
1023
+ app.get('/api/permissions/:id', (req, res) => {
1024
+ const permission = pendingPermissions.get(req.params.id);
1025
+ if (!permission) {
1026
+ res.status(404).json({ error: 'Permission request not found' });
1027
+ return;
1028
+ }
1029
+ res.json(permission);
1030
+ });
1031
+ app.post('/api/permissions/:id/decide', (req, res) => {
1032
+ // Require a valid dashboard session token
1033
+ const authHeader = req.headers.authorization;
1034
+ const token = authHeader?.startsWith('Bearer ') ? authHeader.slice(7) : null;
1035
+ if (!token || !validTokens.has(token)) {
1036
+ res.status(401).json({ error: 'Valid dashboard session required' });
1037
+ return;
1038
+ }
1039
+ const permission = pendingPermissions.get(req.params.id);
1040
+ if (!permission) {
1041
+ res.status(404).json({ error: 'Permission request not found' });
1042
+ return;
1043
+ }
1044
+ if (permission.status !== 'pending') {
1045
+ res.status(409).json({ error: `Already decided: ${permission.status}` });
1046
+ return;
1047
+ }
1048
+ const { decision, reason } = req.body;
1049
+ if (decision !== 'approved' && decision !== 'denied') {
1050
+ res.status(400).json({ error: 'decision must be "approved" or "denied"' });
1051
+ return;
1052
+ }
1053
+ permission.status = decision;
1054
+ permission.reason = reason;
1055
+ permission.decidedAt = new Date().toISOString();
1056
+ // Broadcast decision to all browsers
1057
+ broadcastToAllBrowsers({
1058
+ type: 'permission.decided',
1059
+ id: permission.id,
1060
+ status: permission.status,
1061
+ reason: permission.reason,
1062
+ });
1063
+ log(`Permission ${decision}: ${permission.agentName} / ${permission.toolName} (${permission.id})`);
1064
+ res.json(permission);
1065
+ // Clean up after 60 seconds
1066
+ setTimeout(() => pendingPermissions.delete(permission.id), 60_000);
1067
+ });
1068
+ // ── REST: Projects ──────────────────────────────────────────────
1069
+ app.get('/api/projects', (_req, res) => {
1070
+ const list = [...projects.values()].map((p) => {
1071
+ // Count active agents whose cwd matches this project path
1072
+ let activeAgents = 0;
1073
+ for (const agent of agents.values()) {
1074
+ if (agent.cwd === p.path)
1075
+ activeAgents++;
1076
+ }
1077
+ return { ...p, activeAgents };
1078
+ });
1079
+ list.sort((a, b) => a.name.localeCompare(b.name));
1080
+ res.json(list);
1081
+ });
1082
+ app.post('/api/projects', async (req, res) => {
1083
+ const { name, path: projPath, description } = req.body;
1084
+ if (!name || typeof name !== 'string' || name.trim().length === 0) {
1085
+ res.status(400).json({ error: 'Project name is required' });
1086
+ return;
1087
+ }
1088
+ if (!projPath || typeof projPath !== 'string' || projPath.trim().length === 0) {
1089
+ res.status(400).json({ error: 'Project path is required' });
1090
+ return;
1091
+ }
1092
+ const resolvedPath = path.resolve(projPath.trim());
1093
+ // Validate path contains only safe characters (prevent shell injection in launch)
1094
+ if (!/^[a-zA-Z0-9\s/\-_\.~]+$/.test(resolvedPath)) {
1095
+ res.status(400).json({ error: 'Path contains unsupported characters' });
1096
+ return;
1097
+ }
1098
+ // Validate directory exists
1099
+ try {
1100
+ const stat = await fs.stat(resolvedPath);
1101
+ if (!stat.isDirectory()) {
1102
+ res.status(400).json({ error: 'Path is not a directory' });
1103
+ return;
1104
+ }
1105
+ }
1106
+ catch {
1107
+ res.status(400).json({ error: 'Directory does not exist' });
1108
+ return;
1109
+ }
1110
+ // Check for duplicate path
1111
+ for (const existing of projects.values()) {
1112
+ if (existing.path === resolvedPath) {
1113
+ res.status(409).json({ error: 'A project with this path is already registered' });
1114
+ return;
1115
+ }
1116
+ }
1117
+ const project = {
1118
+ id: generateId(),
1119
+ name: name.trim(),
1120
+ path: resolvedPath,
1121
+ description: description?.trim() || undefined,
1122
+ createdAt: new Date().toISOString(),
1123
+ };
1124
+ projects.set(project.id, project);
1125
+ await persistProjects(projects);
1126
+ log(`Project registered: ${project.name} (${project.path})`);
1127
+ res.status(201).json(project);
1128
+ });
1129
+ app.delete('/api/projects/:id', async (req, res) => {
1130
+ const project = projects.get(req.params.id);
1131
+ if (!project) {
1132
+ res.status(404).json({ error: 'Project not found' });
1133
+ return;
1134
+ }
1135
+ projects.delete(req.params.id);
1136
+ await persistProjects(projects);
1137
+ log(`Project removed: ${project.name} (${project.path})`);
1138
+ res.json({ deleted: true });
1139
+ });
1140
+ app.post('/api/projects/:id/launch', async (req, res) => {
1141
+ const project = projects.get(req.params.id);
1142
+ if (!project) {
1143
+ res.status(404).json({ error: 'Project not found' });
1144
+ return;
1145
+ }
1146
+ // Re-validate directory
1147
+ try {
1148
+ const stat = await fs.stat(project.path);
1149
+ if (!stat.isDirectory()) {
1150
+ res.status(400).json({ error: 'Project directory no longer exists' });
1151
+ return;
1152
+ }
1153
+ }
1154
+ catch {
1155
+ res.status(400).json({ error: 'Project directory no longer exists' });
1156
+ return;
1157
+ }
1158
+ if (process.platform !== 'darwin') {
1159
+ res.status(501).json({ error: 'Agent launching is currently only supported on macOS' });
1160
+ return;
1161
+ }
1162
+ // Escape path for AppleScript (replace backslashes and double-quotes)
1163
+ const escapedPath = project.path.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
1164
+ const agentName = `agent-${project.id.slice(0, 8)}`;
1165
+ const script = `tell application "Terminal"
1166
+ activate
1167
+ do script "cd \\"${escapedPath}\\" && CROSSCHAT_AGENT_NAME=${agentName} claude"
1168
+ end tell`;
1169
+ spawn('osascript', ['-e', script], { detached: true, stdio: 'ignore' }).unref();
1170
+ log(`Launched Claude Code in: ${project.path}`);
1171
+ res.json({ launched: true, projectId: project.id, path: project.path });
1172
+ });
1173
+ // ── API 404 — reject unmatched API routes before SPA fallback ──
1174
+ app.use('/api', (_req, res) => {
1175
+ res.status(404).json({ error: 'API route not found' });
1176
+ });
1177
+ // ── SPA fallback ───────────────────────────────────────────────
1178
+ app.use((_req, res, next) => {
1179
+ const indexPath = path.join(distPath, 'index.html');
1180
+ res.sendFile(indexPath, (err) => {
1181
+ if (err)
1182
+ next();
1183
+ });
1184
+ });
1185
+ // ── HTTP server ────────────────────────────────────────────────
1186
+ const server = http.createServer(app);
1187
+ // ── WebSocket servers (noServer mode) ──────────────────────────
1188
+ const agentWss = new WebSocketServer({ noServer: true, maxPayload: WS_MAX_PAYLOAD });
1189
+ const browserWss = new WebSocketServer({ noServer: true, maxPayload: WS_MAX_PAYLOAD });
1190
+ // Route upgrade requests to the correct WebSocket server
1191
+ server.on('upgrade', (request, socket, head) => {
1192
+ const { pathname } = new URL(request.url ?? '/', `http://${request.headers.host}`);
1193
+ // Block browser WebSocket upgrades from non-localhost origins
1194
+ const origin = request.headers.origin;
1195
+ if (origin) {
1196
+ const originBase = origin.replace(/:\d+$/, '');
1197
+ if (!ALLOWED_ORIGINS.has(originBase)) {
1198
+ log(`Rejected WebSocket upgrade from origin: ${origin}`);
1199
+ socket.write('HTTP/1.1 403 Forbidden\r\n\r\n');
1200
+ socket.destroy();
1201
+ return;
1202
+ }
1203
+ }
1204
+ if (pathname === '/ws/agent') {
1205
+ agentWss.handleUpgrade(request, socket, head, (ws) => {
1206
+ agentWss.emit('connection', ws, request);
1207
+ });
1208
+ }
1209
+ else if (pathname === '/ws') {
1210
+ browserWss.handleUpgrade(request, socket, head, (ws) => {
1211
+ browserWss.emit('connection', ws, request);
1212
+ });
1213
+ }
1214
+ else {
1215
+ socket.destroy();
1216
+ }
1217
+ });
1218
+ // ── Agent WebSocket handler ────────────────────────────────────
1219
+ agentWss.on('connection', (ws) => {
1220
+ let registered = false;
1221
+ // Registration timeout: close if not registered within 5 seconds
1222
+ const registrationTimer = setTimeout(() => {
1223
+ if (!registered) {
1224
+ log('Agent connection timed out waiting for registration');
1225
+ ws.close(4001, 'Registration timeout');
1226
+ }
1227
+ }, REGISTER_TIMEOUT_MS);
1228
+ ws.on('message', (raw) => {
1229
+ const data = raw.toString();
1230
+ if (!registered) {
1231
+ // First message must be agent.register
1232
+ try {
1233
+ const msg = decodeMessage(data);
1234
+ if (msg.type !== 'agent.register') {
1235
+ sendError(ws, 'First message must be agent.register');
1236
+ ws.close(4002, 'Expected agent.register');
1237
+ return;
1238
+ }
1239
+ clearTimeout(registrationTimer);
1240
+ registered = true;
1241
+ const agent = {
1242
+ peerId: msg.peerId,
1243
+ name: msg.name,
1244
+ cwd: msg.cwd,
1245
+ pid: msg.pid,
1246
+ ws,
1247
+ status: 'available',
1248
+ statusDetail: undefined,
1249
+ currentRoom: 'general',
1250
+ connectedAt: new Date().toISOString(),
1251
+ };
1252
+ // If an agent with the same peerId is already connected, close the old one
1253
+ const existing = agents.get(msg.peerId);
1254
+ if (existing) {
1255
+ log(`Replacing existing connection for agent ${msg.peerId}`);
1256
+ existing.ws.close(4003, 'Replaced by new connection');
1257
+ agents.delete(msg.peerId);
1258
+ }
1259
+ agents.set(msg.peerId, agent);
1260
+ sendToWs(ws, {
1261
+ type: 'registered',
1262
+ peerId: msg.peerId,
1263
+ serverVersion: getServerVersion(),
1264
+ });
1265
+ log(`Agent registered: ${agent.name} (${agent.peerId}), cwd=${agent.cwd}`);
1266
+ broadcastToAllBrowsers({ type: 'peerConnected', peer: buildPeerInfo(agent) });
1267
+ // Auto-register agent's working directory as a project
1268
+ autoRegisterProject(agent.cwd).catch((err) => {
1269
+ logError('Auto-register project failed', err);
1270
+ });
1271
+ }
1272
+ catch (err) {
1273
+ sendError(ws, 'Invalid registration message');
1274
+ ws.close(4002, 'Invalid registration');
1275
+ }
1276
+ return;
1277
+ }
1278
+ // Registered — dispatch normally
1279
+ handleAgentMessage(ws, data).catch((err) => {
1280
+ logError('Error handling agent message', err);
1281
+ });
1282
+ });
1283
+ ws.on('close', () => {
1284
+ clearTimeout(registrationTimer);
1285
+ const agent = findAgentByWs(ws);
1286
+ if (agent) {
1287
+ removeAgent(agent.peerId);
1288
+ }
1289
+ });
1290
+ ws.on('error', (err) => {
1291
+ logError('Agent WebSocket error', err);
1292
+ });
1293
+ // Pong handler for liveness
1294
+ ws.on('pong', () => {
1295
+ // Mark as alive — handled by heartbeat interval
1296
+ });
1297
+ });
1298
+ // ── Browser WebSocket handler ──────────────────────────────────
1299
+ browserWss.on('connection', (ws) => {
1300
+ browserClients.add(ws);
1301
+ browserRooms.set(ws, new Set());
1302
+ // Issue a session token for this dashboard connection
1303
+ const sessionToken = generateId();
1304
+ browserTokens.set(ws, sessionToken);
1305
+ validTokens.add(sessionToken);
1306
+ ws.send(JSON.stringify({ type: 'session', token: sessionToken }));
1307
+ ws.on('message', (raw) => {
1308
+ let data;
1309
+ try {
1310
+ data = JSON.parse(raw.toString());
1311
+ }
1312
+ catch {
1313
+ ws.send(JSON.stringify({ type: 'error', error: 'Invalid JSON' }));
1314
+ return;
1315
+ }
1316
+ switch (data.type) {
1317
+ case 'join': {
1318
+ const room = rooms.get(data.roomId);
1319
+ if (!room) {
1320
+ ws.send(JSON.stringify({ type: 'error', error: 'Room not found' }));
1321
+ return;
1322
+ }
1323
+ browserRooms.get(ws).add(data.roomId);
1324
+ if (!data.silent) {
1325
+ broadcastToRoomBrowsers(data.roomId, {
1326
+ type: 'userJoined',
1327
+ roomId: data.roomId,
1328
+ username: data.username || 'Anonymous',
1329
+ });
1330
+ }
1331
+ break;
1332
+ }
1333
+ case 'message': {
1334
+ const room = rooms.get(data.roomId);
1335
+ if (!room || !data.username || !data.text)
1336
+ return;
1337
+ const wsContent = data.text.trim();
1338
+ const { mentions: wsMentions, mentionType: wsMentionType } = parseMentions(wsContent);
1339
+ const chatMsg = {
1340
+ messageId: generateId(),
1341
+ roomId: room.id,
1342
+ fromPeerId: 'dashboard-user',
1343
+ fromName: data.username.trim(),
1344
+ content: wsContent,
1345
+ metadata: undefined,
1346
+ timestamp: new Date().toISOString(),
1347
+ source: 'user',
1348
+ mentions: wsMentions.length > 0 ? wsMentions : undefined,
1349
+ mentionType: wsMentionType,
1350
+ };
1351
+ room.messages.push(chatMsg);
1352
+ enforceRoomMessageCap(room).catch((err) => logError('Error enforcing room message cap', err));
1353
+ // Broadcast with mention filtering
1354
+ broadcastRoomMessage(room.id, chatMsg);
1355
+ break;
1356
+ }
1357
+ case 'leave': {
1358
+ browserRooms.get(ws)?.delete(data.roomId);
1359
+ break;
1360
+ }
1361
+ }
1362
+ });
1363
+ ws.on('close', () => {
1364
+ const token = browserTokens.get(ws);
1365
+ if (token)
1366
+ validTokens.delete(token);
1367
+ browserTokens.delete(ws);
1368
+ browserClients.delete(ws);
1369
+ browserRooms.delete(ws);
1370
+ });
1371
+ ws.on('error', (err) => {
1372
+ logError('Browser WebSocket error', err);
1373
+ });
1374
+ });
1375
+ // ── Heartbeat interval ─────────────────────────────────────────
1376
+ const heartbeatInterval = setInterval(() => {
1377
+ for (const agent of agents.values()) {
1378
+ if (agent.ws.readyState === WebSocket.OPEN) {
1379
+ let alive = true;
1380
+ const pongTimer = setTimeout(() => {
1381
+ if (!alive) {
1382
+ log(`Agent ${agent.name} (${agent.peerId}) failed heartbeat, terminating`);
1383
+ agent.ws.terminate();
1384
+ removeAgent(agent.peerId);
1385
+ }
1386
+ }, PONG_TIMEOUT_MS);
1387
+ const onPong = () => {
1388
+ alive = true;
1389
+ clearTimeout(pongTimer);
1390
+ agent.ws.removeListener('pong', onPong);
1391
+ };
1392
+ alive = false;
1393
+ agent.ws.on('pong', onPong);
1394
+ agent.ws.ping();
1395
+ }
1396
+ }
1397
+ }, HEARTBEAT_INTERVAL_MS);
1398
+ // ── Pending permission sweep ──────────────────────────────────
1399
+ const permissionSweepInterval = setInterval(() => {
1400
+ const now = Date.now();
1401
+ for (const [id, perm] of pendingPermissions) {
1402
+ if (perm.status === 'pending' && now - new Date(perm.createdAt).getTime() > PERMISSION_TTL_MS) {
1403
+ pendingPermissions.delete(id);
1404
+ log(`Expired stale pending permission: ${id} (${perm.toolName})`);
1405
+ }
1406
+ }
1407
+ }, PERMISSION_SWEEP_INTERVAL_MS);
1408
+ // ── Start listening ────────────────────────────────────────────
1409
+ const configPort = process.env.CROSSCHAT_DASHBOARD_PORT
1410
+ ? parseInt(process.env.CROSSCHAT_DASHBOARD_PORT, 10)
1411
+ : 0;
1412
+ const actualPort = await new Promise((resolve, reject) => {
1413
+ let settled = false;
1414
+ const onListening = () => {
1415
+ if (settled)
1416
+ return;
1417
+ settled = true;
1418
+ const addr = server.address();
1419
+ const port = typeof addr === 'object' && addr ? addr.port : configPort;
1420
+ resolve(port);
1421
+ };
1422
+ server.on('error', (err) => {
1423
+ if (err.code === 'EADDRINUSE' && !settled) {
1424
+ log(`Port ${configPort} in use, trying auto-select...`);
1425
+ server.listen(0, onListening);
1426
+ }
1427
+ else if (!settled) {
1428
+ settled = true;
1429
+ reject(err);
1430
+ }
1431
+ });
1432
+ server.listen(configPort, onListening);
1433
+ });
1434
+ await writeDashboardLock(actualPort);
1435
+ log(`Hub started on port ${actualPort} (pid ${process.pid})`);
1436
+ // Post a startup message to the crosschat room
1437
+ const startupMsg = {
1438
+ messageId: generateId(),
1439
+ roomId: 'crosschat',
1440
+ fromPeerId: 'system',
1441
+ fromName: 'system',
1442
+ content: `Hub started on port ${actualPort}`,
1443
+ timestamp: new Date().toISOString(),
1444
+ source: 'agent',
1445
+ };
1446
+ const crosschatRoom = rooms.get('crosschat');
1447
+ if (crosschatRoom) {
1448
+ crosschatRoom.messages.push(startupMsg);
1449
+ }
1450
+ // ── Graceful shutdown ──────────────────────────────────────────
1451
+ let shuttingDown = false;
1452
+ const shutdown = async (signal) => {
1453
+ if (shuttingDown)
1454
+ return;
1455
+ shuttingDown = true;
1456
+ if (signal) {
1457
+ log(`Received ${signal}, shutting down...`);
1458
+ }
1459
+ else {
1460
+ log('Shutting down...');
1461
+ }
1462
+ // Stop timers
1463
+ clearInterval(heartbeatInterval);
1464
+ clearInterval(permissionSweepInterval);
1465
+ // Close all agent WebSocket connections
1466
+ for (const agent of agents.values()) {
1467
+ try {
1468
+ agent.ws.close(1001, 'Server shutting down');
1469
+ }
1470
+ catch {
1471
+ // ignore
1472
+ }
1473
+ }
1474
+ agents.clear();
1475
+ // Close all browser WebSocket connections
1476
+ for (const client of browserClients) {
1477
+ try {
1478
+ client.close(1001, 'Server shutting down');
1479
+ }
1480
+ catch {
1481
+ // ignore
1482
+ }
1483
+ }
1484
+ browserClients.clear();
1485
+ // Close WebSocket servers
1486
+ agentWss.close();
1487
+ browserWss.close();
1488
+ // Close HTTP server
1489
+ await new Promise((resolve) => {
1490
+ server.close(() => resolve());
1491
+ });
1492
+ await removeDashboardLock();
1493
+ log('Hub shutdown complete');
1494
+ process.exit(0);
1495
+ };
1496
+ process.on('SIGINT', () => shutdown('SIGINT'));
1497
+ process.on('SIGTERM', () => shutdown('SIGTERM'));
1498
+ process.on('SIGHUP', () => shutdown('SIGHUP'));
1499
+ }
1500
+ //# sourceMappingURL=hub-server.js.map