@studiomopoke/crosschat 1.8.2 → 2.0.1

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 (82) hide show
  1. package/README.md +48 -118
  2. package/bin/cli.cjs +37 -57
  3. package/crosschat.md +73 -104
  4. package/dashboard/dist/assets/index-BY-IQhma.js +49 -0
  5. package/dashboard/dist/assets/index-CI8v9PKQ.css +1 -0
  6. package/dashboard/dist/index.html +2 -2
  7. package/dist/hub/_hub-section-1-infra.d.ts +8 -0
  8. package/dist/hub/_hub-section-1-infra.d.ts.map +1 -0
  9. package/dist/hub/_hub-section-1-infra.js +152 -0
  10. package/dist/hub/_hub-section-1-infra.js.map +1 -0
  11. package/dist/hub/_hub-section-2-handlers.d.ts +2 -0
  12. package/dist/hub/_hub-section-2-handlers.d.ts.map +1 -0
  13. package/dist/hub/_hub-section-2-handlers.js +514 -0
  14. package/dist/hub/_hub-section-2-handlers.js.map +1 -0
  15. package/dist/hub/_hub-section-3-rest.d.ts +2 -0
  16. package/dist/hub/_hub-section-3-rest.d.ts.map +1 -0
  17. package/dist/hub/_hub-section-3-rest.js +418 -0
  18. package/dist/hub/_hub-section-3-rest.js.map +1 -0
  19. package/dist/hub/_hub-section-4-ws.d.ts +2 -0
  20. package/dist/hub/_hub-section-4-ws.d.ts.map +1 -0
  21. package/dist/hub/_hub-section-4-ws.js +367 -0
  22. package/dist/hub/_hub-section-4-ws.js.map +1 -0
  23. package/dist/hub/agent-connection.d.ts +38 -62
  24. package/dist/hub/agent-connection.d.ts.map +1 -1
  25. package/dist/hub/agent-connection.js +146 -229
  26. package/dist/hub/agent-connection.js.map +1 -1
  27. package/dist/hub/hub-server.d.ts +1 -1
  28. package/dist/hub/hub-server.d.ts.map +1 -1
  29. package/dist/hub/hub-server.js +389 -685
  30. package/dist/hub/hub-server.js.map +1 -1
  31. package/dist/hub/message-manager.d.ts +158 -0
  32. package/dist/hub/message-manager.d.ts.map +1 -0
  33. package/dist/hub/message-manager.js +443 -0
  34. package/dist/hub/message-manager.js.map +1 -0
  35. package/dist/hub/protocol.d.ts +73 -131
  36. package/dist/hub/protocol.d.ts.map +1 -1
  37. package/dist/hub/protocol.js +3 -0
  38. package/dist/hub/protocol.js.map +1 -1
  39. package/dist/lifecycle.d.ts.map +1 -1
  40. package/dist/lifecycle.js +15 -89
  41. package/dist/lifecycle.js.map +1 -1
  42. package/dist/server.d.ts.map +1 -1
  43. package/dist/server.js +22 -33
  44. package/dist/server.js.map +1 -1
  45. package/dist/tools/add-badge.d.ts +4 -0
  46. package/dist/tools/add-badge.d.ts.map +1 -0
  47. package/dist/tools/add-badge.js +29 -0
  48. package/dist/tools/add-badge.js.map +1 -0
  49. package/dist/tools/claim-task.js +5 -5
  50. package/dist/tools/claim-task.js.map +1 -1
  51. package/dist/tools/clear-session.d.ts +1 -2
  52. package/dist/tools/clear-session.d.ts.map +1 -1
  53. package/dist/tools/clear-session.js +7 -22
  54. package/dist/tools/clear-session.js.map +1 -1
  55. package/dist/tools/flag-as-task.d.ts +4 -0
  56. package/dist/tools/flag-as-task.d.ts.map +1 -0
  57. package/dist/tools/flag-as-task.js +31 -0
  58. package/dist/tools/flag-as-task.js.map +1 -0
  59. package/dist/tools/get-messages.d.ts +2 -1
  60. package/dist/tools/get-messages.d.ts.map +1 -1
  61. package/dist/tools/get-messages.js +19 -6
  62. package/dist/tools/get-messages.js.map +1 -1
  63. package/dist/tools/list-peers.d.ts.map +1 -1
  64. package/dist/tools/list-peers.js +1 -4
  65. package/dist/tools/list-peers.js.map +1 -1
  66. package/dist/tools/resolve-task.d.ts +4 -0
  67. package/dist/tools/resolve-task.d.ts.map +1 -0
  68. package/dist/tools/resolve-task.js +29 -0
  69. package/dist/tools/resolve-task.js.map +1 -0
  70. package/dist/tools/send-message.d.ts.map +1 -1
  71. package/dist/tools/send-message.js +6 -5
  72. package/dist/tools/send-message.js.map +1 -1
  73. package/dist/tools/set-status.js +3 -3
  74. package/dist/tools/set-status.js.map +1 -1
  75. package/dist/tools/wait-for-messages.js +1 -1
  76. package/dist/tools/wait-for-messages.js.map +1 -1
  77. package/dist/types.d.ts +4 -3
  78. package/dist/types.d.ts.map +1 -1
  79. package/hooks/permission-hook.sh +19 -18
  80. package/package.json +1 -1
  81. package/dashboard/dist/assets/index-BR-2rRm6.css +0 -1
  82. package/dashboard/dist/assets/index-Ci2ihChN.js +0 -49
@@ -9,7 +9,7 @@ import { WebSocketServer, WebSocket } from 'ws';
9
9
  import { generateId } from '../util/id.js';
10
10
  import { isProcessAlive } from '../util/pid.js';
11
11
  import { log, logError } from '../util/logger.js';
12
- import { TaskManager } from './task-manager.js';
12
+ import { MessageManager } from './message-manager.js';
13
13
  import { encodeMessage, decodeMessage, } from './protocol.js';
14
14
  import { createRequire } from 'node:module';
15
15
  // ── Constants ────────────────────────────────────────────────────────
@@ -23,7 +23,6 @@ const INSTANCES_FILE = path.join(CROSSCHAT_DIR, 'instances.json');
23
23
  const REGISTER_TIMEOUT_MS = 5_000;
24
24
  const HEARTBEAT_INTERVAL_MS = 30_000;
25
25
  const PONG_TIMEOUT_MS = 10_000;
26
- const ROOM_MESSAGE_CAP = 200;
27
26
  const IDLE_SHUTDOWN_MS = 5 * 60 * 1000; // 5 minutes with no agents → auto-shutdown
28
27
  const PERMISSION_TTL_MS = 10 * 60 * 1000; // 10 minutes for pending permissions
29
28
  const PERMISSION_SWEEP_INTERVAL_MS = 60_000;
@@ -95,10 +94,36 @@ function getServerVersion() {
95
94
  * Start the hub server.
96
95
  *
97
96
  * Central hub for CrossChat: manages agent WebSocket connections, peer registry,
98
- * rooms, message routing, tasks, and serves the React dashboard frontend.
97
+ * channels, message routing, tasks (via badges), and serves the React dashboard frontend.
99
98
  */
100
99
  export async function startHub() {
101
100
  await ensureCrosschatDir();
101
+ // Copy the permission hook to a stable location (~/.crosschat/hooks/)
102
+ // so settings.json can point to a path that survives package updates.
103
+ try {
104
+ const hooksDir = path.join(CROSSCHAT_DIR, 'hooks');
105
+ await fs.mkdir(hooksDir, { recursive: true });
106
+ const srcHook = path.join(__dirname, '..', 'hooks', 'permission-hook.sh');
107
+ const srcHookAlt = path.join(__dirname, '..', '..', 'hooks', 'permission-hook.sh');
108
+ let hookSource = null;
109
+ for (const p of [srcHook, srcHookAlt]) {
110
+ try {
111
+ await fs.access(p);
112
+ hookSource = p;
113
+ break;
114
+ }
115
+ catch { /* try next */ }
116
+ }
117
+ if (hookSource) {
118
+ const destHook = path.join(hooksDir, 'permission-hook.sh');
119
+ await fs.copyFile(hookSource, destHook);
120
+ await fs.chmod(destHook, 0o755);
121
+ log(`Permission hook copied to ${destHook}`);
122
+ }
123
+ }
124
+ catch (err) {
125
+ logError('Failed to copy permission hook to stable location', err);
126
+ }
102
127
  // Check for an already-running hub
103
128
  const existingLock = await readDashboardLock();
104
129
  if (existingLock) {
@@ -107,35 +132,23 @@ export async function startHub() {
107
132
  }
108
133
  // ── State ──────────────────────────────────────────────────────
109
134
  const agents = new Map();
110
- const rooms = new Map();
135
+ const channels = new Set();
111
136
  const browserClients = new Set();
112
- const browserRooms = new WeakMap();
137
+ const browserChannels = new WeakMap();
113
138
  const browserTokens = new Map(); // session tokens for permission decisions
114
139
  const validTokens = new Set(); // fast lookup for REST auth
115
140
  const pendingPermissions = new Map();
116
141
  const instances = await loadInstances();
117
- // Initialize TaskManager
118
- const taskManager = new TaskManager();
119
- await taskManager.init();
120
- // Digest tracking: roomId -> taskId of currently active digest task
121
- const activeDigestTasks = new Map();
142
+ // Initialize MessageManager (replaces TaskManager + in-memory channel messages)
143
+ const messageManager = new MessageManager();
144
+ await messageManager.init();
122
145
  // Idle shutdown: auto-shutdown when no agents are connected for IDLE_SHUTDOWN_MS
123
146
  let idleShutdownTimer = null;
124
147
  // Ensure digests directory exists
125
148
  await fs.mkdir(DIGESTS_DIR, { recursive: true });
126
- // Seed default rooms
127
- rooms.set('general', {
128
- id: 'general',
129
- name: 'General',
130
- messages: [],
131
- createdAt: new Date().toISOString(),
132
- });
133
- rooms.set('crosschat', {
134
- id: 'crosschat',
135
- name: 'CrossChat Activity',
136
- messages: [],
137
- createdAt: new Date().toISOString(),
138
- });
149
+ // Seed default channels
150
+ channels.add('general');
151
+ channels.add('crosschat');
139
152
  // ── Helpers ────────────────────────────────────────────────────
140
153
  function sendToWs(ws, msg) {
141
154
  if (ws.readyState === WebSocket.OPEN) {
@@ -147,7 +160,7 @@ export async function startHub() {
147
160
  }
148
161
  async function autoRegisterInstance(cwd) {
149
162
  const resolvedPath = path.resolve(cwd);
150
- // Skip if path has unsafe characters (same check as POST /api/instances)
163
+ // Skip if path has unsafe characters
151
164
  if (!/^[a-zA-Z0-9\s/\-_\.~]+$/.test(resolvedPath))
152
165
  return;
153
166
  // Skip if an instance with this path already exists
@@ -193,38 +206,16 @@ export async function startHub() {
193
206
  pid: agent.pid,
194
207
  status: agent.status,
195
208
  statusDetail: agent.statusDetail,
196
- currentRoom: agent.currentRoom,
209
+ currentChannel: agent.currentChannel,
197
210
  connectedAt: agent.connectedAt,
198
211
  };
199
212
  }
200
- function taskToSummary(task) {
201
- return {
202
- taskId: task.taskId,
203
- roomId: task.roomId,
204
- creatorId: task.creatorId,
205
- creatorName: task.creatorName,
206
- description: task.description,
207
- context: task.context,
208
- filter: task.filter,
209
- status: task.status,
210
- claimantId: task.claimantId,
211
- claimantName: task.claimantName,
212
- createdAt: task.createdAt,
213
- updatedAt: task.updatedAt,
214
- };
215
- }
216
213
  // ── Mention parsing ──────────────────────────────────────────
217
- /**
218
- * Parse @mentions from message content.
219
- * Supports @agent-name (targeted) and @here (room broadcast).
220
- * Returns the list of mentioned agent names and the mention type.
221
- */
222
214
  function parseMentions(content) {
223
215
  const hasHere = /@here\b/i.test(content);
224
216
  if (hasHere) {
225
217
  return { mentions: [], mentionType: 'here' };
226
218
  }
227
- // Extract all @mentions from the content
228
219
  const mentionPattern = /@([\w-]+)/g;
229
220
  const rawMentions = [];
230
221
  let match;
@@ -251,27 +242,27 @@ export async function startHub() {
251
242
  return { mentions: [], mentionType: 'broadcast' };
252
243
  }
253
244
  // ── Broadcasting ───────────────────────────────────────────────
254
- /** Broadcast a server message to all agents in a specific room. */
255
- function broadcastToRoomAgents(roomId, msg, excludePeerId) {
245
+ /** Broadcast a server message to all agents in a specific channel. */
246
+ function broadcastToChannelAgents(channelId, msg, excludePeerId) {
256
247
  for (const agent of agents.values()) {
257
- if (agent.currentRoom === roomId && agent.peerId !== excludePeerId) {
248
+ if (agent.currentChannel === channelId && agent.peerId !== excludePeerId) {
258
249
  sendToWs(agent.ws, msg);
259
250
  }
260
251
  }
261
252
  }
262
- /** Broadcast a JSON payload to all browser clients subscribed to a room. */
263
- function broadcastToRoomBrowsers(roomId, data) {
253
+ /** Broadcast a JSON payload to all browser clients subscribed to a channel. */
254
+ function broadcastToChannelBrowsers(channelId, data) {
264
255
  const payload = JSON.stringify(data);
265
256
  for (const client of browserClients) {
266
257
  if (client.readyState !== WebSocket.OPEN)
267
258
  continue;
268
- const joined = browserRooms.get(client);
269
- if (joined?.has(roomId)) {
259
+ const joined = browserChannels.get(client);
260
+ if (joined?.has(channelId)) {
270
261
  client.send(payload);
271
262
  }
272
263
  }
273
264
  }
274
- /** Broadcast to ALL browser clients (not filtered by room). */
265
+ /** Broadcast to ALL browser clients (not filtered by channel). */
275
266
  function broadcastToAllBrowsers(data) {
276
267
  const payload = JSON.stringify(data);
277
268
  for (const client of browserClients) {
@@ -280,13 +271,14 @@ export async function startHub() {
280
271
  }
281
272
  }
282
273
  }
283
- /** Broadcast a room message to agents AND browsers in the room, with mention filtering. */
284
- function broadcastRoomMessage(roomId, msg, excludePeerId) {
274
+ /** Broadcast a channel message to agents AND browsers, with mention filtering. */
275
+ function broadcastChannelMessage(channelId, msg, excludePeerId) {
285
276
  // Build the protocol message for agents
286
277
  const agentMsg = {
287
- type: 'room.message',
288
- roomId: msg.roomId,
278
+ type: 'channel.message',
279
+ channelId: msg.channelId,
289
280
  messageId: msg.messageId,
281
+ threadId: msg.threadId,
290
282
  fromPeerId: msg.fromPeerId,
291
283
  fromName: msg.fromName,
292
284
  content: msg.content,
@@ -295,13 +287,14 @@ export async function startHub() {
295
287
  source: msg.source,
296
288
  mentions: msg.mentions,
297
289
  mentionType: msg.mentionType,
298
- importance: msg.importance,
290
+ importance: msg.metadata?.importance,
291
+ badges: msg.badges,
299
292
  };
300
293
  if (msg.mentionType === 'direct' && msg.mentions && msg.mentions.length > 0) {
301
294
  // Direct mention: only deliver to mentioned agents (+ sender echo)
302
295
  const mentionedNamesLower = new Set(msg.mentions.map((n) => n.toLowerCase()));
303
296
  for (const agent of agents.values()) {
304
- if (agent.currentRoom !== roomId)
297
+ if (agent.currentChannel !== channelId)
305
298
  continue;
306
299
  if (agent.peerId === excludePeerId)
307
300
  continue;
@@ -311,171 +304,41 @@ export async function startHub() {
311
304
  }
312
305
  }
313
306
  else {
314
- // @here or broadcast: deliver to all agents in the room
315
- broadcastToRoomAgents(roomId, agentMsg, excludePeerId);
307
+ // @here or broadcast: deliver to all agents in the channel
308
+ broadcastToChannelAgents(channelId, agentMsg, excludePeerId);
316
309
  }
317
310
  // Always send to all browsers — dashboard users see everything
318
- broadcastToRoomBrowsers(roomId, {
311
+ broadcastToChannelBrowsers(channelId, {
319
312
  type: 'message',
320
313
  messageId: msg.messageId,
321
- roomId: msg.roomId,
314
+ channelId: msg.channelId,
315
+ threadId: msg.threadId,
322
316
  username: msg.fromName,
323
317
  text: msg.content,
324
318
  timestamp: msg.timestamp,
325
319
  source: msg.source,
326
320
  mentions: msg.mentions,
327
321
  mentionType: msg.mentionType,
328
- importance: msg.importance,
322
+ importance: msg.metadata?.importance,
323
+ badges: msg.badges,
329
324
  });
330
325
  }
331
- // ── Activity room ────────────────────────────────────────────
332
- /** Post a system event to the CrossChat Activity room. */
326
+ // ── Activity channel ────────────────────────────────────────
327
+ /** Post a system event to the CrossChat Activity channel. */
333
328
  function postActivity(content, importance = 'comment') {
334
- const room = rooms.get('crosschat');
335
- if (!room)
336
- return;
337
329
  const msg = {
338
330
  messageId: generateId(),
339
- roomId: 'crosschat',
331
+ channelId: 'crosschat',
340
332
  fromPeerId: 'system',
341
333
  fromName: 'system',
342
334
  content,
343
335
  timestamp: new Date().toISOString(),
344
336
  source: 'system',
345
- importance,
337
+ badges: [],
338
+ metadata: { importance },
346
339
  };
347
- room.messages.push(msg);
348
- broadcastRoomMessage('crosschat', msg);
349
- // Drop oldest messages when cap is exceeded (no digest for activity room)
350
- if (room.messages.length > ROOM_MESSAGE_CAP) {
351
- room.messages = room.messages.slice(-ROOM_MESSAGE_CAP);
352
- }
353
- }
354
- // ── Digest system ─────────────────────────────────────────────
355
- /**
356
- * Enforce the per-room message cap. When exceeded, the oldest messages
357
- * are moved to a pending digest JSONL file and a digest task is created.
358
- */
359
- async function enforceRoomMessageCap(room) {
360
- if (room.messages.length <= ROOM_MESSAGE_CAP)
361
- return;
362
- if (room.id === 'crosschat')
363
- return; // activity room handles its own cap via postActivity()
364
- const overflow = room.messages.length - ROOM_MESSAGE_CAP;
365
- const evicted = room.messages.splice(0, overflow);
366
- // Append evicted messages to pending digest file (one JSON object per line)
367
- const pendingPath = path.join(DIGESTS_DIR, `${room.id}.jsonl`);
368
- const lines = evicted.map((m) => JSON.stringify(m)).join('\n') + '\n';
369
- await fs.appendFile(pendingPath, lines, 'utf-8');
370
- log(`Room ${room.id}: evicted ${overflow} message(s) to pending digest`);
371
- // Auto-delegate a digest task if one isn't already active for this room
372
- const existingTaskId = activeDigestTasks.get(room.id);
373
- if (existingTaskId) {
374
- const existingTask = taskManager.get(existingTaskId);
375
- if (existingTask && (existingTask.status === 'open' || existingTask.status === 'claimed' || existingTask.status === 'in_progress')) {
376
- // A digest task is already in progress — don't create another
377
- return;
378
- }
379
- // Previous task finished or was archived — allow a new one
380
- activeDigestTasks.delete(room.id);
381
- }
382
- // Read the pending messages as context for the digest task
383
- let pendingContent;
384
- try {
385
- pendingContent = await fs.readFile(pendingPath, 'utf-8');
386
- }
387
- catch {
388
- pendingContent = lines;
389
- }
390
- try {
391
- const task = await taskManager.create({
392
- roomId: room.id,
393
- creatorId: 'system',
394
- creatorName: 'system',
395
- description: 'Summarize the following room messages into a digest',
396
- context: pendingContent,
397
- });
398
- activeDigestTasks.set(room.id, task.taskId);
399
- // Broadcast task creation to agents and browsers in the room
400
- const summary = taskToSummary(task);
401
- broadcastToRoomAgents(room.id, { type: 'task.created', task: summary });
402
- broadcastToRoomBrowsers(room.id, { type: 'task.created', task: summary });
403
- log(`Digest task ${task.taskId} created for room ${room.id}`);
404
- postActivity(`Room "${room.name}" hit message cap — auto-digest initiated`);
405
- }
406
- catch (err) {
407
- logError(`Failed to create digest task for room ${room.id}`, err);
408
- }
409
- }
410
- /**
411
- * Handle digest task completion: persist the full digest to disk,
412
- * clear the pending file, and insert a system message into the room.
413
- */
414
- async function handleDigestCompletion(task) {
415
- const roomId = task.roomId;
416
- const digestSummary = task.result ?? '';
417
- // Read the pending messages that were the basis for this digest
418
- const pendingPath = path.join(DIGESTS_DIR, `${roomId}.jsonl`);
419
- let pendingMessages = '';
420
- try {
421
- pendingMessages = await fs.readFile(pendingPath, 'utf-8');
422
- }
423
- catch {
424
- // Pending file may have been cleared already
425
- }
426
- // Persist the full digest to ~/.crosschat/digests/{roomId}/{timestamp}.md
427
- const roomDigestDir = path.join(DIGESTS_DIR, roomId);
428
- await fs.mkdir(roomDigestDir, { recursive: true });
429
- const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
430
- const digestPath = path.join(roomDigestDir, `${timestamp}.md`);
431
- // Format original messages for the "Full messages" section
432
- let formattedMessages = '';
433
- if (pendingMessages.trim()) {
434
- const msgLines = pendingMessages.trim().split('\n');
435
- for (const line of msgLines) {
436
- try {
437
- const msg = JSON.parse(line);
438
- formattedMessages += `**${msg.fromName}** (${msg.timestamp}): ${msg.content}\n\n`;
439
- }
440
- catch {
441
- // Skip malformed lines
442
- }
443
- }
444
- }
445
- const digestContent = `# Room Digest: ${roomId}\n\n` +
446
- `Generated: ${new Date().toISOString()}\n\n` +
447
- `## Summary\n\n${digestSummary}\n\n` +
448
- `## Full messages\n\n${formattedMessages || '(no messages available)'}`;
449
- await fs.writeFile(digestPath, digestContent, 'utf-8');
450
- // Clear the pending file
451
- try {
452
- await fs.unlink(pendingPath);
453
- }
454
- catch {
455
- // Already gone
456
- }
457
- // Clean up tracking
458
- activeDigestTasks.delete(roomId);
459
- // Insert a system message into the room
460
- const firstLine = digestSummary.split('\n')[0].slice(0, 200);
461
- const room = rooms.get(roomId);
462
- if (room) {
463
- const sysMsg = {
464
- messageId: generateId(),
465
- roomId,
466
- fromPeerId: 'system',
467
- fromName: 'system',
468
- content: `\u{1F4CB} Room digest: ${firstLine}\n\nFull digest saved to ${digestPath}`,
469
- timestamp: new Date().toISOString(),
470
- source: 'system',
471
- importance: 'important',
472
- };
473
- room.messages.push(sysMsg);
474
- broadcastRoomMessage(roomId, sysMsg);
475
- }
476
- log(`Digest completed for room ${roomId}: ${digestPath}`);
477
- const digestRoom = rooms.get(roomId);
478
- postActivity(`Digest completed for room "${digestRoom?.name ?? roomId}"`);
340
+ messageManager.addMessage(msg).catch((err) => logError('Failed to persist activity message', err));
341
+ broadcastChannelMessage('crosschat', msg);
479
342
  }
480
343
  // ── Agent removal ──────────────────────────────────────────────
481
344
  function removeAgent(peerId) {
@@ -502,70 +365,21 @@ export async function startHub() {
502
365
  function handleAgentStatus(agent, msg) {
503
366
  agent.status = msg.status;
504
367
  agent.statusDetail = msg.detail;
505
- log(`Agent ${agent.name} status: ${msg.status}${msg.detail ? ` (${msg.detail})` : ''}`);
506
- postActivity(`${agent.name} -> ${msg.status}${msg.detail ? ` (${msg.detail})` : ''}`, 'chitchat');
507
- }
508
- function handleJoinRoom(agent, msg) {
509
- const room = rooms.get(msg.roomId);
510
- if (!room) {
511
- sendError(agent.ws, `Room not found: ${msg.roomId}`);
512
- return;
368
+ if (msg.taskMessageId) {
369
+ log(`Agent ${agent.name} status: ${msg.status}${msg.detail ? ` (${msg.detail})` : ''} [task: ${msg.taskMessageId}]`);
513
370
  }
514
- const oldRoom = agent.currentRoom;
515
- agent.currentRoom = msg.roomId;
516
- // Notify the agent
517
- sendToWs(agent.ws, { type: 'room.joined', roomId: room.id, name: room.name });
518
- // Notify browsers about room membership change
519
- if (oldRoom !== msg.roomId) {
520
- broadcastToRoomBrowsers(oldRoom, {
521
- type: 'agentLeft',
522
- roomId: oldRoom,
523
- peerId: agent.peerId,
524
- name: agent.name,
525
- });
526
- broadcastToRoomBrowsers(msg.roomId, {
527
- type: 'agentJoined',
528
- roomId: msg.roomId,
529
- peerId: agent.peerId,
530
- name: agent.name,
531
- });
532
- }
533
- log(`Agent ${agent.name} joined room ${msg.roomId}`);
534
- postActivity(`${agent.name} joined room "${room.name}"`, 'chitchat');
535
- }
536
- function handleCreateRoom(agent, msg) {
537
- if (rooms.has(msg.roomId)) {
538
- sendError(agent.ws, `Room already exists: ${msg.roomId}`);
539
- return;
371
+ else {
372
+ log(`Agent ${agent.name} status: ${msg.status}${msg.detail ? ` (${msg.detail})` : ''}`);
540
373
  }
541
- const room = {
542
- id: msg.roomId,
543
- name: msg.name ?? msg.roomId,
544
- messages: [],
545
- createdAt: new Date().toISOString(),
546
- };
547
- rooms.set(msg.roomId, room);
548
- // Notify the creating agent
549
- sendToWs(agent.ws, { type: 'room.created', roomId: room.id, name: room.name });
550
- // Notify all browser clients about the new room
551
- broadcastToAllBrowsers({
552
- type: 'roomCreated',
553
- room: { id: room.id, name: room.name, createdAt: room.createdAt, messageCount: 0 },
554
- });
555
- log(`Room created: ${room.id} by ${agent.name}`);
556
- postActivity(`${agent.name} created room "${room.name}"`);
374
+ postActivity(`${agent.name} -> ${msg.status}${msg.detail ? ` (${msg.detail})` : ''}`, 'chitchat');
557
375
  }
558
- function handleSendMessage(agent, msg) {
559
- const roomId = agent.currentRoom;
560
- const room = rooms.get(roomId);
561
- if (!room) {
562
- sendError(agent.ws, `Room not found: ${roomId}`);
563
- return;
564
- }
376
+ async function handleSendMessage(agent, msg) {
377
+ const channelId = agent.currentChannel;
565
378
  const { mentions, mentionType } = parseMentions(msg.content);
566
- const chatMsg = {
379
+ const message = {
567
380
  messageId: generateId(),
568
- roomId,
381
+ channelId,
382
+ threadId: msg.threadId,
569
383
  fromPeerId: agent.peerId,
570
384
  fromName: agent.name,
571
385
  content: msg.content,
@@ -574,12 +388,13 @@ export async function startHub() {
574
388
  source: 'agent',
575
389
  mentions: mentions.length > 0 ? mentions : undefined,
576
390
  mentionType,
577
- importance: msg.importance,
391
+ badges: [],
578
392
  };
579
- room.messages.push(chatMsg);
580
- enforceRoomMessageCap(room).catch((err) => logError('Error enforcing room message cap', err));
581
- // Broadcast with mention-based filtering
582
- broadcastRoomMessage(roomId, chatMsg);
393
+ if (msg.importance) {
394
+ message.metadata = { ...message.metadata, importance: msg.importance };
395
+ }
396
+ await messageManager.addMessage(message);
397
+ broadcastChannelMessage(channelId, message);
583
398
  }
584
399
  function handleListPeers(agent, msg) {
585
400
  const peers = [];
@@ -590,285 +405,232 @@ export async function startHub() {
590
405
  }
591
406
  sendToWs(agent.ws, { type: 'peers', requestId: msg.requestId, peers });
592
407
  }
593
- async function handleTaskCreate(agent, msg) {
408
+ // ── Badge & Task handlers ─────────────────────────────────────
409
+ /** Broadcast a badge change to all agents in the channel and all browsers. */
410
+ function broadcastBadgeUpdate(channelId, messageId, badge, allBadges) {
411
+ // Notify agents in the channel about the new badge
412
+ broadcastToChannelAgents(channelId, {
413
+ type: 'message.badgeAdded',
414
+ messageId,
415
+ badge,
416
+ });
417
+ // Notify agents with the full badge array
418
+ broadcastToChannelAgents(channelId, {
419
+ type: 'message.updated',
420
+ messageId,
421
+ badges: allBadges,
422
+ });
423
+ // Notify all browsers
424
+ broadcastToAllBrowsers({
425
+ type: 'badgeUpdate',
426
+ messageId,
427
+ badge,
428
+ badges: allBadges,
429
+ });
430
+ }
431
+ async function handleFlagTask(agent, msg) {
594
432
  try {
595
- const task = await taskManager.create({
596
- roomId: agent.currentRoom,
597
- creatorId: agent.peerId,
598
- creatorName: agent.name,
599
- description: msg.description,
600
- context: msg.context,
601
- filter: msg.filter,
433
+ const meta = await messageManager.flagAsTask(msg.messageId, agent.peerId, msg.filter);
434
+ if (!meta) {
435
+ sendError(agent.ws, `Message not found: ${msg.messageId}`, msg.requestId);
436
+ return;
437
+ }
438
+ const message = messageManager.getMessage(msg.messageId);
439
+ const badges = message?.badges ?? [];
440
+ // Respond to requester
441
+ sendToWs(agent.ws, {
442
+ type: 'task.flagged',
443
+ requestId: msg.requestId,
444
+ messageId: msg.messageId,
445
+ badges,
602
446
  });
603
- const summary = taskToSummary(task);
604
- // If the creator sent a requestId, send a direct response so they get the taskId
605
- if (msg.requestId) {
606
- sendToWs(agent.ws, { type: 'task.created', requestId: msg.requestId, task: summary });
447
+ // Broadcast badge update to channel
448
+ const taskBadge = badges.find((b) => b.type === 'task');
449
+ if (taskBadge && message) {
450
+ broadcastBadgeUpdate(message.channelId, msg.messageId, taskBadge, badges);
607
451
  }
608
- // Broadcast to all agents in the room (including creator) and browsers
609
- broadcastToRoomAgents(agent.currentRoom, { type: 'task.created', task: summary });
610
- broadcastToRoomBrowsers(agent.currentRoom, { type: 'task.created', task: summary });
611
- const targetDesc = task.filter?.agentId ? ` -> ${task.filter.agentId}` : '';
612
- postActivity(`${agent.name} delegated task "${task.description}"${targetDesc}`);
452
+ postActivity(`${agent.name} flagged message as task: ${msg.messageId}`);
613
453
  }
614
454
  catch (err) {
615
- sendError(agent.ws, err instanceof Error ? err.message : 'Failed to create task', msg.requestId);
455
+ sendError(agent.ws, err instanceof Error ? err.message : 'Failed to flag task', msg.requestId);
616
456
  }
617
457
  }
618
- async function handleTaskClaim(agent, msg) {
458
+ async function handleClaimTask(agent, msg) {
619
459
  try {
620
- const task = await taskManager.claim(msg.taskId, agent.peerId, agent.name);
621
- // Notify the task creator
622
- const creator = findAgentById(task.creatorId);
623
- if (creator) {
624
- sendToWs(creator.ws, {
625
- type: 'task.claimed',
626
- taskId: task.taskId,
627
- claimantId: agent.peerId,
628
- claimantName: agent.name,
629
- });
630
- }
631
- // Also notify the claimant that the claim was registered
460
+ const meta = await messageManager.claimTask(msg.messageId, agent.peerId, agent.name);
461
+ const message = messageManager.getMessage(msg.messageId);
462
+ const badges = message?.badges ?? [];
463
+ // Respond to requester
632
464
  sendToWs(agent.ws, {
633
465
  type: 'task.claimed',
634
- taskId: task.taskId,
466
+ requestId: msg.requestId,
467
+ messageId: msg.messageId,
635
468
  claimantId: agent.peerId,
636
469
  claimantName: agent.name,
637
470
  });
638
- // Broadcast to dashboard
639
- broadcastToAllBrowsers({ type: 'task.claimed', taskId: task.taskId, claimantId: agent.peerId, claimantName: agent.name });
640
- postActivity(`${agent.name} claimed task "${task.description}"`, 'chitchat');
641
- }
642
- catch (err) {
643
- sendError(agent.ws, err instanceof Error ? err.message : 'Failed to claim task');
644
- }
645
- }
646
- async function handleTaskAccept(agent, msg) {
647
- try {
648
- const task = await taskManager.acceptClaim(msg.taskId, agent.peerId);
649
- // Notify the claimant
650
- if (task.claimantId) {
651
- const claimant = findAgentById(task.claimantId);
652
- if (claimant) {
653
- sendToWs(claimant.ws, {
654
- type: 'task.claimAccepted',
655
- taskId: task.taskId,
656
- assignedTo: task.claimantId,
471
+ // Notify the message author
472
+ if (message) {
473
+ const author = findAgentById(message.fromPeerId);
474
+ if (author && author.peerId !== agent.peerId) {
475
+ sendToWs(author.ws, {
476
+ type: 'task.claimed',
477
+ requestId: '',
478
+ messageId: msg.messageId,
479
+ claimantId: agent.peerId,
480
+ claimantName: agent.name,
657
481
  });
658
482
  }
483
+ // Broadcast badge update
484
+ const taskBadge = badges.find((b) => b.type === 'task');
485
+ if (taskBadge) {
486
+ broadcastBadgeUpdate(message.channelId, msg.messageId, taskBadge, badges);
487
+ }
659
488
  }
660
- // Acknowledge to the creator
661
- sendToWs(agent.ws, {
662
- type: 'task.claimAccepted',
663
- taskId: task.taskId,
664
- assignedTo: task.claimantId ?? '',
665
- });
666
- // Broadcast to dashboard
667
- broadcastToAllBrowsers({ type: 'task.claimAccepted', taskId: task.taskId, assignedTo: task.claimantId ?? '' });
668
- postActivity(`${agent.name} accepted claim from ${task.claimantName ?? 'unknown'}`, 'chitchat');
489
+ postActivity(`${agent.name} claimed task on message ${msg.messageId}`, 'chitchat');
669
490
  }
670
491
  catch (err) {
671
- sendError(agent.ws, err instanceof Error ? err.message : 'Failed to accept claim');
492
+ sendError(agent.ws, err instanceof Error ? err.message : 'Failed to claim task', msg.requestId);
672
493
  }
673
494
  }
674
- async function handleTaskUpdate(agent, msg) {
495
+ async function handleResolveTask(agent, msg) {
675
496
  try {
676
- const task = await taskManager.update(msg.taskId, agent.peerId, agent.name, msg.content, msg.status);
677
- const lastNote = task.notes[task.notes.length - 1];
678
- // Notify creator and claimant (if they are different from the author)
679
- const notifyIds = new Set();
680
- if (task.creatorId !== agent.peerId)
681
- notifyIds.add(task.creatorId);
682
- if (task.claimantId && task.claimantId !== agent.peerId)
683
- notifyIds.add(task.claimantId);
684
- for (const targetId of notifyIds) {
685
- const target = findAgentById(targetId);
686
- if (target) {
687
- sendToWs(target.ws, {
688
- type: 'task.updated',
689
- taskId: task.taskId,
690
- note: lastNote,
691
- });
692
- }
693
- }
694
- // Also confirm to the sender
497
+ const meta = await messageManager.resolveTask(msg.messageId, agent.peerId, msg.status, msg.result, msg.error);
498
+ const message = messageManager.getMessage(msg.messageId);
499
+ const badges = message?.badges ?? [];
500
+ // Respond to requester
695
501
  sendToWs(agent.ws, {
696
- type: 'task.updated',
697
- taskId: task.taskId,
698
- note: lastNote,
502
+ type: 'task.resolved',
503
+ requestId: msg.requestId,
504
+ messageId: msg.messageId,
505
+ status: msg.status,
506
+ result: msg.result,
699
507
  });
700
- // Broadcast to dashboard
701
- broadcastToAllBrowsers({ type: 'task.updated', taskId: task.taskId, note: lastNote });
702
- }
703
- catch (err) {
704
- sendError(agent.ws, err instanceof Error ? err.message : 'Failed to update task');
705
- }
706
- }
707
- async function handleTaskComplete(agent, msg) {
708
- try {
709
- const task = await taskManager.complete(msg.taskId, agent.peerId, agent.name, msg.result, msg.status, msg.error);
710
- // Notify the creator (skip for system-created digest tasks)
711
- if (task.creatorId !== 'system') {
712
- const creator = findAgentById(task.creatorId);
713
- if (creator) {
714
- sendToWs(creator.ws, {
715
- type: 'task.completed',
716
- taskId: task.taskId,
508
+ // Notify the message author
509
+ if (message) {
510
+ const author = findAgentById(message.fromPeerId);
511
+ if (author && author.peerId !== agent.peerId) {
512
+ sendToWs(author.ws, {
513
+ type: 'task.resolved',
514
+ requestId: '',
515
+ messageId: msg.messageId,
717
516
  status: msg.status,
718
517
  result: msg.result,
719
518
  });
720
519
  }
520
+ // Broadcast badge update
521
+ const taskBadge = badges.find((b) => b.type === 'task');
522
+ if (taskBadge) {
523
+ broadcastBadgeUpdate(message.channelId, msg.messageId, taskBadge, badges);
524
+ }
721
525
  }
722
- // Confirm to the completer
723
- sendToWs(agent.ws, {
724
- type: 'task.completed',
725
- taskId: task.taskId,
726
- status: msg.status,
727
- result: msg.result,
728
- });
729
- // Broadcast to dashboard
730
- broadcastToAllBrowsers({ type: 'task.completed', taskId: task.taskId, status: msg.status, result: msg.result });
731
526
  if (msg.status === 'completed') {
732
- postActivity(`${agent.name} completed task "${task.description}"`, 'important');
527
+ postActivity(`${agent.name} completed task on message ${msg.messageId}`, 'important');
733
528
  }
734
529
  else {
735
- postActivity(`${agent.name} failed task "${task.description}"`, 'important');
736
- }
737
- // Check if this is a digest task completion
738
- if (msg.status === 'completed' && activeDigestTasks.get(task.roomId) === task.taskId) {
739
- await handleDigestCompletion(task);
530
+ postActivity(`${agent.name} failed task on message ${msg.messageId}`, 'important');
740
531
  }
741
532
  }
742
533
  catch (err) {
743
- sendError(agent.ws, err instanceof Error ? err.message : 'Failed to complete task');
534
+ sendError(agent.ws, err instanceof Error ? err.message : 'Failed to resolve task', msg.requestId);
744
535
  }
745
536
  }
746
- async function handleClearSession(agent, msg) {
747
- const clearMessages = msg.messages !== false; // default true
748
- const clearTasks = msg.tasks === true; // default false
749
- let messagesCleared = 0;
750
- let tasksArchived = 0;
751
- if (clearMessages) {
752
- const room = rooms.get(agent.currentRoom);
753
- if (room) {
754
- messagesCleared = room.messages.length;
755
- room.messages = [];
756
- // Notify browsers that the room was cleared
757
- broadcastToRoomBrowsers(agent.currentRoom, {
758
- type: 'sessionCleared',
759
- roomId: agent.currentRoom,
760
- clearedBy: agent.name,
761
- messagesCleared,
762
- });
763
- }
764
- }
765
- if (clearTasks) {
766
- tasksArchived = await taskManager.archiveTerminal(agent.currentRoom);
767
- }
768
- sendToWs(agent.ws, {
769
- type: 'session.cleared',
770
- requestId: msg.requestId,
771
- messagesCleared,
772
- tasksArchived,
773
- });
774
- log(`Session cleared by ${agent.name}: ${messagesCleared} message(s), ${tasksArchived} task(s) archived`);
775
- }
776
- async function handleRequestDigest(agent, msg) {
777
- const room = rooms.get(agent.currentRoom);
778
- if (!room || room.messages.length === 0) {
779
- sendError(agent.ws, 'No messages in room to digest', msg.requestId);
780
- return;
781
- }
782
- // Check if a digest task is already active for this room
783
- const existingTaskId = activeDigestTasks.get(agent.currentRoom);
784
- if (existingTaskId) {
785
- const existingTask = taskManager.get(existingTaskId);
786
- if (existingTask && (existingTask.status === 'open' || existingTask.status === 'claimed' || existingTask.status === 'in_progress')) {
787
- sendError(agent.ws, `A digest task is already in progress for this room (task ${existingTaskId})`, msg.requestId);
537
+ async function handleAddBadge(agent, msg) {
538
+ try {
539
+ const badge = {
540
+ type: msg.badgeType,
541
+ value: msg.badgeValue,
542
+ label: msg.label,
543
+ addedBy: agent.peerId,
544
+ addedAt: new Date().toISOString(),
545
+ };
546
+ const updatedMessage = await messageManager.addBadge(msg.messageId, badge);
547
+ if (!updatedMessage) {
548
+ sendError(agent.ws, `Message not found: ${msg.messageId}`, msg.requestId);
788
549
  return;
789
550
  }
790
- activeDigestTasks.delete(agent.currentRoom);
791
- }
792
- // Capture current messages and write to pending digest file
793
- const messagesToDigest = [...room.messages];
794
- const pendingPath = path.join(DIGESTS_DIR, `${room.id}.jsonl`);
795
- const lines = messagesToDigest.map((m) => JSON.stringify(m)).join('\n') + '\n';
796
- await fs.appendFile(pendingPath, lines, 'utf-8');
797
- // Optionally clear room messages
798
- const clearMessages = msg.clearMessages === true;
799
- if (clearMessages) {
800
- room.messages = [];
801
- broadcastToRoomBrowsers(agent.currentRoom, {
802
- type: 'sessionCleared',
803
- roomId: agent.currentRoom,
804
- clearedBy: agent.name,
805
- messagesCleared: messagesToDigest.length,
806
- });
807
- }
808
- // Create digest task
809
- let pendingContent;
810
- try {
811
- pendingContent = await fs.readFile(pendingPath, 'utf-8');
812
- }
813
- catch {
814
- pendingContent = lines;
815
- }
816
- try {
817
- const task = await taskManager.create({
818
- roomId: room.id,
819
- creatorId: agent.peerId,
820
- creatorName: agent.name,
821
- description: 'Summarize the following room messages into a digest',
822
- context: pendingContent,
823
- });
824
- activeDigestTasks.set(room.id, task.taskId);
825
- // Broadcast task creation
826
- const summary = taskToSummary(task);
827
- broadcastToRoomAgents(room.id, { type: 'task.created', task: summary });
828
- broadcastToRoomBrowsers(room.id, { type: 'task.created', task: summary });
551
+ // Respond to requester
829
552
  sendToWs(agent.ws, {
830
- type: 'digest.requested',
553
+ type: 'badge.added',
831
554
  requestId: msg.requestId,
832
- taskId: task.taskId,
833
- messageCount: messagesToDigest.length,
834
- messagesCleared: clearMessages,
555
+ messageId: msg.messageId,
556
+ badge,
835
557
  });
836
- log(`Digest requested by ${agent.name} for room ${room.id}: ${messagesToDigest.length} message(s), task ${task.taskId}`);
837
- postActivity(`${agent.name} requested digest for room "${room.name}" (${messagesToDigest.length} messages)`);
558
+ // Broadcast badge update to channel
559
+ broadcastBadgeUpdate(updatedMessage.channelId, msg.messageId, badge, updatedMessage.badges);
838
560
  }
839
561
  catch (err) {
840
- logError(`Failed to create digest task for room ${room.id}`, err);
841
- sendError(agent.ws, 'Failed to create digest task', msg.requestId);
562
+ sendError(agent.ws, err instanceof Error ? err.message : 'Failed to add badge', msg.requestId);
842
563
  }
843
564
  }
844
- function handleGetTask(agent, msg) {
845
- const task = taskManager.get(msg.taskId);
846
- if (!task) {
847
- sendError(agent.ws, `Task not found: ${msg.taskId}`, msg.requestId);
848
- return;
565
+ // ── Message retrieval ─────────────────────────────────────────
566
+ function handleGetMessages(agent, msg) {
567
+ let messages;
568
+ if (msg.threadId) {
569
+ messages = messageManager.getThreadMessages(msg.threadId);
849
570
  }
850
- const summary = taskToSummary(task);
851
- sendToWs(agent.ws, {
852
- type: 'task.detail',
571
+ else {
572
+ messages = messageManager.getChannelMessages(agent.currentChannel, {
573
+ limit: msg.limit,
574
+ afterMessageId: msg.afterMessageId,
575
+ });
576
+ }
577
+ // Convert Messages to ChannelMessageMessage format for the response
578
+ const protocolMessages = messages.map((m) => ({
579
+ type: 'channel.message',
580
+ channelId: m.channelId,
581
+ messageId: m.messageId,
582
+ threadId: m.threadId,
583
+ fromPeerId: m.fromPeerId,
584
+ fromName: m.fromName,
585
+ content: m.content,
586
+ metadata: m.metadata,
587
+ timestamp: m.timestamp,
588
+ source: m.source,
589
+ mentions: m.mentions,
590
+ mentionType: m.mentionType,
591
+ importance: m.metadata?.importance,
592
+ badges: m.badges,
593
+ }));
594
+ const response = {
595
+ type: 'messages',
853
596
  requestId: msg.requestId,
854
- task: { ...summary, notes: task.notes, result: task.result, error: task.error },
855
- });
597
+ messages: protocolMessages,
598
+ threadId: msg.threadId,
599
+ };
600
+ sendToWs(agent.ws, response);
856
601
  }
857
- function handleListTasks(agent, msg) {
858
- const filter = {};
859
- if (msg.status)
860
- filter.status = msg.status;
861
- if (msg.roomId)
862
- filter.roomId = msg.roomId;
863
- if (msg.assignedTo)
864
- filter.assignedTo = msg.assignedTo;
865
- const tasks = taskManager.list(Object.keys(filter).length > 0 ? filter : undefined);
602
+ // ── Session management ────────────────────────────────────────
603
+ async function handleClearSession(agent, msg) {
604
+ const clearMessages = msg.messages !== false; // default true
605
+ let messagesCleared = 0;
606
+ if (clearMessages) {
607
+ messagesCleared = messageManager.clearChannel(agent.currentChannel);
608
+ // Notify browsers that the channel was cleared
609
+ broadcastToChannelBrowsers(agent.currentChannel, {
610
+ type: 'sessionCleared',
611
+ channelId: agent.currentChannel,
612
+ clearedBy: agent.name,
613
+ messagesCleared,
614
+ });
615
+ }
866
616
  sendToWs(agent.ws, {
867
- type: 'tasks',
617
+ type: 'session.cleared',
868
618
  requestId: msg.requestId,
869
- tasks: tasks.map(taskToSummary),
619
+ messagesCleared,
870
620
  });
621
+ log(`Session cleared by ${agent.name}: ${messagesCleared} message(s)`);
871
622
  }
623
+ // Return all handler functions so they can be used by dispatch
624
+ // ── Pending permission sweep ──────────────────────────────────
625
+ const permissionSweepInterval = setInterval(() => {
626
+ const now = Date.now();
627
+ for (const [id, perm] of pendingPermissions) {
628
+ if (perm.status === 'pending' && now - new Date(perm.createdAt).getTime() > PERMISSION_TTL_MS) {
629
+ pendingPermissions.delete(id);
630
+ log(`Expired stale pending permission: ${id} (${perm.toolName})`);
631
+ }
632
+ }
633
+ }, PERMISSION_SWEEP_INTERVAL_MS);
872
634
  // ── Agent message dispatch ─────────────────────────────────────
873
635
  async function handleAgentMessage(ws, raw) {
874
636
  const agent = findAgentByWs(ws);
@@ -893,45 +655,30 @@ export async function startHub() {
893
655
  removeAgent(agent.peerId);
894
656
  ws.close();
895
657
  break;
896
- case 'agent.joinRoom':
897
- handleJoinRoom(agent, msg);
898
- break;
899
- case 'agent.createRoom':
900
- handleCreateRoom(agent, msg);
901
- break;
902
658
  case 'agent.sendMessage':
903
- handleSendMessage(agent, msg);
659
+ await handleSendMessage(agent, msg);
904
660
  break;
905
661
  case 'agent.listPeers':
906
662
  handleListPeers(agent, msg);
907
663
  break;
908
- case 'task.create':
909
- await handleTaskCreate(agent, msg);
910
- break;
911
- case 'task.claim':
912
- await handleTaskClaim(agent, msg);
664
+ case 'agent.getMessages':
665
+ handleGetMessages(agent, msg);
913
666
  break;
914
- case 'task.accept':
915
- await handleTaskAccept(agent, msg);
667
+ case 'agent.flagTask':
668
+ await handleFlagTask(agent, msg);
916
669
  break;
917
- case 'task.update':
918
- await handleTaskUpdate(agent, msg);
670
+ case 'agent.claimTask':
671
+ await handleClaimTask(agent, msg);
919
672
  break;
920
- case 'task.complete':
921
- await handleTaskComplete(agent, msg);
673
+ case 'agent.resolveTask':
674
+ await handleResolveTask(agent, msg);
922
675
  break;
923
- case 'agent.getTask':
924
- handleGetTask(agent, msg);
925
- break;
926
- case 'agent.listTasks':
927
- handleListTasks(agent, msg);
676
+ case 'agent.addBadge':
677
+ await handleAddBadge(agent, msg);
928
678
  break;
929
679
  case 'agent.clearSession':
930
680
  await handleClearSession(agent, msg);
931
681
  break;
932
- case 'agent.requestDigest':
933
- await handleRequestDigest(agent, msg);
934
- break;
935
682
  default:
936
683
  sendError(ws, `Unknown message type: ${msg.type}`);
937
684
  }
@@ -971,90 +718,100 @@ export async function startHub() {
971
718
  // Serve dashboard React frontend
972
719
  const distPath = path.join(__dirname, '..', '..', 'dashboard', 'dist');
973
720
  app.use(express.static(distPath));
974
- // ── REST: Rooms ────────────────────────────────────────────────
975
- app.get('/api/rooms', (_req, res) => {
976
- const list = [...rooms.values()].map(({ id, name, createdAt, messages }) => ({
977
- id,
978
- name,
979
- createdAt,
980
- messageCount: messages.length,
981
- }));
982
- res.json(list);
983
- });
984
- app.post('/api/rooms', (req, res) => {
985
- const { name } = req.body;
986
- if (!name || typeof name !== 'string' || name.trim().length === 0) {
987
- res.status(400).json({ error: 'Room name is required' });
988
- return;
989
- }
990
- const id = generateId();
991
- const room = { id, name: name.trim(), messages: [], createdAt: new Date().toISOString() };
992
- rooms.set(id, room);
993
- broadcastToAllBrowsers({
994
- type: 'roomCreated',
995
- room: { id, name: room.name, createdAt: room.createdAt, messageCount: 0 },
996
- });
997
- res.status(201).json({ id, name: room.name, createdAt: room.createdAt });
721
+ // ── REST: Channels ──────────────────────────────────────────────
722
+ app.get('/api/channels', (_req, res) => {
723
+ res.json([...channels].map((id) => ({ id, name: id })));
998
724
  });
999
- app.get('/api/rooms/:roomId/messages', (req, res) => {
1000
- const room = rooms.get(req.params.roomId);
1001
- if (!room) {
1002
- res.status(404).json({ error: 'Room not found' });
725
+ app.get('/api/channels/:channelId/messages', (req, res) => {
726
+ const channelId = req.params.channelId;
727
+ if (!channels.has(channelId)) {
728
+ res.status(404).json({ error: 'Channel not found' });
1003
729
  return;
1004
730
  }
1005
- // Return in the format the dashboard frontend expects
1006
- const messages = room.messages.map((m) => ({
1007
- messageId: m.messageId,
1008
- roomId: m.roomId,
1009
- username: m.fromName,
1010
- text: m.content,
1011
- timestamp: m.timestamp,
1012
- source: m.source,
1013
- mentions: m.mentions,
1014
- mentionType: m.mentionType,
1015
- importance: m.importance,
1016
- }));
731
+ const limit = req.query.limit ? parseInt(req.query.limit, 10) : undefined;
732
+ const afterMessageId = req.query.afterMessageId;
733
+ const messages = messageManager.getChannelMessages(channelId, { limit, afterMessageId });
1017
734
  res.json(messages);
1018
735
  });
1019
- app.post('/api/rooms/:roomId/messages', (req, res) => {
1020
- const room = rooms.get(req.params.roomId);
1021
- if (!room) {
1022
- res.status(404).json({ error: 'Room not found' });
736
+ app.post('/api/channels/:channelId/messages', async (req, res) => {
737
+ const channelId = req.params.channelId;
738
+ if (!channels.has(channelId)) {
739
+ res.status(404).json({ error: 'Channel not found' });
1023
740
  return;
1024
741
  }
1025
- const { username, text } = req.body;
742
+ const { username, text, threadId, metadata } = req.body;
1026
743
  if (!username || !text) {
1027
744
  res.status(400).json({ error: 'username and text are required' });
1028
745
  return;
1029
746
  }
1030
747
  const content = text.trim();
1031
748
  const { mentions, mentionType } = parseMentions(content);
1032
- const chatMsg = {
749
+ const message = {
1033
750
  messageId: generateId(),
1034
- roomId: room.id,
751
+ channelId,
752
+ threadId: threadId || undefined,
1035
753
  fromPeerId: 'dashboard-user',
1036
754
  fromName: username.trim(),
1037
755
  content,
1038
- metadata: undefined,
1039
756
  timestamp: new Date().toISOString(),
1040
757
  source: 'user',
1041
758
  mentions: mentions.length > 0 ? mentions : undefined,
1042
759
  mentionType,
760
+ badges: [],
761
+ metadata: metadata || undefined,
1043
762
  };
1044
- room.messages.push(chatMsg);
1045
- enforceRoomMessageCap(room).catch((err) => logError('Error enforcing room message cap', err));
1046
- // Broadcast to agents in this room (with mention filtering)
1047
- broadcastRoomMessage(room.id, chatMsg);
1048
- res.status(201).json({
1049
- messageId: chatMsg.messageId,
1050
- roomId: chatMsg.roomId,
1051
- username: chatMsg.fromName,
1052
- text: chatMsg.content,
1053
- timestamp: chatMsg.timestamp,
1054
- source: chatMsg.source,
1055
- mentions: chatMsg.mentions,
1056
- mentionType: chatMsg.mentionType,
763
+ await messageManager.addMessage(message);
764
+ broadcastChannelMessage(channelId, message);
765
+ res.status(201).json(message);
766
+ });
767
+ app.get('/api/channels/:channelId/messages/:messageId/thread', (req, res) => {
768
+ const threadId = req.params.messageId;
769
+ const messages = messageManager.getThreadMessages(threadId);
770
+ res.json(messages);
771
+ });
772
+ app.post('/api/channels/:channelId/messages/:messageId/flag-task', async (req, res) => {
773
+ const messageId = req.params.messageId;
774
+ const { addedBy, filter } = req.body;
775
+ const meta = await messageManager.flagAsTask(messageId, addedBy || 'dashboard-user', filter || undefined);
776
+ if (!meta) {
777
+ res.status(404).json({ error: 'Message not found' });
778
+ return;
779
+ }
780
+ const message = messageManager.getMessage(messageId);
781
+ if (message) {
782
+ broadcastToAllBrowsers({
783
+ type: 'message.updated',
784
+ messageId,
785
+ badges: message.badges,
786
+ });
787
+ }
788
+ res.json(meta);
789
+ });
790
+ app.post('/api/channels/:channelId/messages/:messageId/badges', async (req, res) => {
791
+ const messageId = req.params.messageId;
792
+ const { type: badgeType, value, label, addedBy } = req.body;
793
+ if (!badgeType || !value) {
794
+ res.status(400).json({ error: 'type and value are required' });
795
+ return;
796
+ }
797
+ const badge = {
798
+ type: badgeType,
799
+ value,
800
+ label: label || undefined,
801
+ addedBy: addedBy || 'dashboard-user',
802
+ addedAt: new Date().toISOString(),
803
+ };
804
+ const message = await messageManager.addBadge(messageId, badge);
805
+ if (!message) {
806
+ res.status(404).json({ error: 'Message not found' });
807
+ return;
808
+ }
809
+ broadcastToAllBrowsers({
810
+ type: 'message.badgeAdded',
811
+ messageId,
812
+ badge,
1057
813
  });
814
+ res.json({ messageId, badge });
1058
815
  });
1059
816
  // ── REST: Peers ────────────────────────────────────────────────
1060
817
  app.get('/api/peers', (_req, res) => {
@@ -1064,86 +821,48 @@ export async function startHub() {
1064
821
  }
1065
822
  res.json(peers);
1066
823
  });
1067
- // ── REST: Tasks ────────────────────────────────────────────────
1068
- app.post('/api/tasks', async (req, res) => {
1069
- const { description, context, filter, roomId, creatorName } = req.body || {};
1070
- if (!description || !roomId) {
1071
- res.status(400).json({ error: 'description and roomId are required' });
824
+ // ── REST: Agent lookup by parent PID (for permission hook) ─────
825
+ app.get('/api/agents/by-parent-pid/:pid', (req, res) => {
826
+ const parentPid = parseInt(req.params.pid, 10);
827
+ if (isNaN(parentPid)) {
828
+ res.status(400).json({ error: 'Invalid PID' });
1072
829
  return;
1073
830
  }
1074
- try {
1075
- const task = await taskManager.create({
1076
- roomId,
1077
- creatorId: 'dashboard-user',
1078
- creatorName: creatorName || 'Dashboard',
1079
- description,
1080
- context: context || undefined,
1081
- filter: filter || undefined,
1082
- });
1083
- const summary = taskToSummary(task);
1084
- broadcastToRoomAgents(roomId, { type: 'task.created', task: summary });
1085
- broadcastToRoomBrowsers(roomId, { type: 'task.created', task: summary });
1086
- res.json(summary);
1087
- }
1088
- catch (err) {
1089
- const message = err instanceof Error ? err.message : 'Failed to create task';
1090
- res.status(400).json({ error: message });
831
+ for (const agent of agents.values()) {
832
+ if (agent.parentPid === parentPid) {
833
+ res.json({ peerId: agent.peerId, name: agent.name, cwd: agent.cwd });
834
+ return;
835
+ }
1091
836
  }
837
+ res.status(404).json({ error: 'No agent found with that parent PID' });
1092
838
  });
839
+ // ── REST: Tasks ────────────────────────────────────────────────
1093
840
  app.get('/api/tasks', (req, res) => {
1094
841
  const filter = {};
1095
842
  if (req.query.status)
1096
843
  filter.status = req.query.status;
1097
- if (req.query.roomId)
1098
- filter.roomId = req.query.roomId;
1099
- if (req.query.assignedTo)
1100
- filter.assignedTo = req.query.assignedTo;
1101
- const tasks = taskManager.list(Object.keys(filter).length > 0 ? filter : undefined);
844
+ if (req.query.channelId)
845
+ filter.channelId = req.query.channelId;
846
+ if (req.query.claimantId)
847
+ filter.claimantId = req.query.claimantId;
848
+ const tasks = messageManager.listTasks(Object.keys(filter).length > 0 ? filter : undefined);
1102
849
  res.json(tasks);
1103
850
  });
1104
- app.get('/api/tasks/:id', (req, res) => {
1105
- const task = taskManager.get(req.params.id);
1106
- if (!task) {
1107
- res.status(404).json({ error: 'Task not found' });
1108
- return;
1109
- }
1110
- res.json(task);
1111
- });
1112
- app.post('/api/tasks/:id/archive', async (req, res) => {
1113
- try {
1114
- const task = await taskManager.archive(req.params.id);
1115
- res.json(task);
1116
- }
1117
- catch (err) {
1118
- const message = err instanceof Error ? err.message : 'Failed to archive task';
1119
- res.status(400).json({ error: message });
1120
- }
1121
- });
1122
851
  // ── REST: Clear session ──────────────────────────────────────────
1123
- app.post('/api/rooms/:roomId/clear', async (req, res) => {
1124
- const room = rooms.get(req.params.roomId);
1125
- if (!room) {
1126
- res.status(404).json({ error: 'Room not found' });
852
+ app.post('/api/channels/:channelId/clear', (req, res) => {
853
+ const channelId = req.params.channelId;
854
+ if (!channels.has(channelId)) {
855
+ res.status(404).json({ error: 'Channel not found' });
1127
856
  return;
1128
857
  }
1129
- const clearMessages = req.body?.messages !== false;
1130
- const clearTasks = req.body?.tasks === true;
1131
- let messagesCleared = 0;
1132
- let tasksArchived = 0;
1133
- if (clearMessages) {
1134
- messagesCleared = room.messages.length;
1135
- room.messages = [];
1136
- broadcastToRoomBrowsers(req.params.roomId, {
1137
- type: 'sessionCleared',
1138
- roomId: req.params.roomId,
1139
- clearedBy: 'dashboard',
1140
- messagesCleared,
1141
- });
1142
- }
1143
- if (clearTasks) {
1144
- tasksArchived = await taskManager.archiveTerminal(req.params.roomId);
1145
- }
1146
- res.json({ messagesCleared, tasksArchived });
858
+ const messagesCleared = messageManager.clearChannel(channelId);
859
+ broadcastToAllBrowsers({
860
+ type: 'sessionCleared',
861
+ channelId,
862
+ clearedBy: 'dashboard',
863
+ messagesCleared,
864
+ });
865
+ res.json({ messagesCleared });
1147
866
  });
1148
867
  // ── REST: Permissions ────────────────────────────────────────────
1149
868
  app.get('/api/permissions', (_req, res) => {
@@ -1320,19 +1039,13 @@ export async function startHub() {
1320
1039
  const agentName = `agent-${instance.id.slice(0, 8)}`;
1321
1040
  const script = `tell application "Terminal"
1322
1041
  activate
1323
- do script "cd \\"${escapedPath}\\" && CROSSCHAT_AGENT_NAME=${agentName} claude crosschat"
1042
+ do script "cd \\"${escapedPath}\\" && CROSSCHAT_NAME=${agentName} claude crosschat"
1324
1043
  end tell`;
1325
1044
  spawn('osascript', ['-e', script], { detached: true, stdio: 'ignore' }).unref();
1326
1045
  log(`Launched Claude Code in: ${instance.path}`);
1327
1046
  postActivity(`Launched Claude Code at ${instance.path}`);
1328
1047
  res.json({ launched: true, instanceId: instance.id, path: instance.path });
1329
1048
  });
1330
- // ── REST: Hub control ──────────────────────────────────────────────
1331
- app.post('/api/hub/shutdown', (_req, res) => {
1332
- res.json({ shutting_down: true });
1333
- // Delay slightly so the response is sent before shutdown
1334
- setTimeout(() => shutdown('API request'), 100);
1335
- });
1336
1049
  // ── API 404 — reject unmatched API routes before SPA fallback ──
1337
1050
  app.use('/api', (_req, res) => {
1338
1051
  res.status(404).json({ error: 'API route not found' });
@@ -1369,7 +1082,7 @@ end tell`;
1369
1082
  agentWss.emit('connection', ws, request);
1370
1083
  });
1371
1084
  }
1372
- else if (pathname === '/ws') {
1085
+ else if (pathname === '/ws' || pathname === '/ws/browser') {
1373
1086
  browserWss.handleUpgrade(request, socket, head, (ws) => {
1374
1087
  browserWss.emit('connection', ws, request);
1375
1088
  });
@@ -1406,10 +1119,11 @@ end tell`;
1406
1119
  name: msg.name,
1407
1120
  cwd: msg.cwd,
1408
1121
  pid: msg.pid,
1122
+ parentPid: msg.parentPid,
1409
1123
  ws,
1410
1124
  status: 'available',
1411
1125
  statusDetail: undefined,
1412
- currentRoom: 'general',
1126
+ currentChannel: 'general',
1413
1127
  connectedAt: new Date().toISOString(),
1414
1128
  };
1415
1129
  // If an agent with the same peerId is already connected, close the old one
@@ -1469,7 +1183,7 @@ end tell`;
1469
1183
  // ── Browser WebSocket handler ──────────────────────────────────
1470
1184
  browserWss.on('connection', (ws) => {
1471
1185
  browserClients.add(ws);
1472
- browserRooms.set(ws, new Set());
1186
+ browserChannels.set(ws, new Set());
1473
1187
  // Issue a session token for this dashboard connection
1474
1188
  const sessionToken = generateId();
1475
1189
  browserTokens.set(ws, sessionToken);
@@ -1486,30 +1200,30 @@ end tell`;
1486
1200
  }
1487
1201
  switch (data.type) {
1488
1202
  case 'join': {
1489
- const room = rooms.get(data.roomId);
1490
- if (!room) {
1491
- ws.send(JSON.stringify({ type: 'error', error: 'Room not found' }));
1203
+ const channelId = data.channelId;
1204
+ if (!channelId || !channels.has(channelId)) {
1205
+ ws.send(JSON.stringify({ type: 'error', error: 'Channel not found' }));
1492
1206
  return;
1493
1207
  }
1494
- browserRooms.get(ws).add(data.roomId);
1208
+ browserChannels.get(ws).add(channelId);
1495
1209
  if (!data.silent) {
1496
- broadcastToRoomBrowsers(data.roomId, {
1210
+ broadcastToChannelBrowsers(channelId, {
1497
1211
  type: 'userJoined',
1498
- roomId: data.roomId,
1212
+ channelId,
1499
1213
  username: data.username || 'Anonymous',
1500
1214
  });
1501
1215
  }
1502
1216
  break;
1503
1217
  }
1504
1218
  case 'message': {
1505
- const room = rooms.get(data.roomId);
1506
- if (!room || !data.username || !data.text)
1219
+ const channelId = data.channelId;
1220
+ if (!channelId || !channels.has(channelId) || !data.username || !data.text)
1507
1221
  return;
1508
1222
  const wsContent = data.text.trim();
1509
1223
  const { mentions: wsMentions, mentionType: wsMentionType } = parseMentions(wsContent);
1510
1224
  const chatMsg = {
1511
1225
  messageId: generateId(),
1512
- roomId: room.id,
1226
+ channelId,
1513
1227
  fromPeerId: 'dashboard-user',
1514
1228
  fromName: data.username.trim(),
1515
1229
  content: wsContent,
@@ -1518,15 +1232,15 @@ end tell`;
1518
1232
  source: 'user',
1519
1233
  mentions: wsMentions.length > 0 ? wsMentions : undefined,
1520
1234
  mentionType: wsMentionType,
1235
+ badges: [],
1521
1236
  };
1522
- room.messages.push(chatMsg);
1523
- enforceRoomMessageCap(room).catch((err) => logError('Error enforcing room message cap', err));
1237
+ messageManager.addMessage(chatMsg).catch((err) => logError('Error persisting browser message', err));
1524
1238
  // Broadcast with mention filtering
1525
- broadcastRoomMessage(room.id, chatMsg);
1239
+ broadcastChannelMessage(channelId, chatMsg);
1526
1240
  break;
1527
1241
  }
1528
1242
  case 'leave': {
1529
- browserRooms.get(ws)?.delete(data.roomId);
1243
+ browserChannels.get(ws)?.delete(data.channelId);
1530
1244
  break;
1531
1245
  }
1532
1246
  }
@@ -1537,7 +1251,7 @@ end tell`;
1537
1251
  validTokens.delete(token);
1538
1252
  browserTokens.delete(ws);
1539
1253
  browserClients.delete(ws);
1540
- browserRooms.delete(ws);
1254
+ // WeakMap entry for browserChannels is automatically GC'd
1541
1255
  });
1542
1256
  ws.on('error', (err) => {
1543
1257
  logError('Browser WebSocket error', err);
@@ -1567,16 +1281,6 @@ end tell`;
1567
1281
  }
1568
1282
  }
1569
1283
  }, HEARTBEAT_INTERVAL_MS);
1570
- // ── Pending permission sweep ──────────────────────────────────
1571
- const permissionSweepInterval = setInterval(() => {
1572
- const now = Date.now();
1573
- for (const [id, perm] of pendingPermissions) {
1574
- if (perm.status === 'pending' && now - new Date(perm.createdAt).getTime() > PERMISSION_TTL_MS) {
1575
- pendingPermissions.delete(id);
1576
- log(`Expired stale pending permission: ${id} (${perm.toolName})`);
1577
- }
1578
- }
1579
- }, PERMISSION_SWEEP_INTERVAL_MS);
1580
1284
  // ── Start listening ────────────────────────────────────────────
1581
1285
  const configPort = process.env.CROSSCHAT_DASHBOARD_PORT
1582
1286
  ? parseInt(process.env.CROSSCHAT_DASHBOARD_PORT, 10)
@@ -1605,7 +1309,7 @@ end tell`;
1605
1309
  });
1606
1310
  await writeDashboardLock(actualPort);
1607
1311
  log(`Hub started on port ${actualPort} (pid ${process.pid})`);
1608
- // Post startup event to the activity room
1312
+ // Post startup event to the activity channel
1609
1313
  postActivity(`Hub started on port ${actualPort}`, 'important');
1610
1314
  // ── Graceful shutdown ──────────────────────────────────────────
1611
1315
  let shuttingDown = false;