@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.
- package/LICENSE +62 -0
- package/README.md +279 -0
- package/bin/cli.cjs +631 -0
- package/crosschat.md +196 -0
- package/dashboard/dist/assets/index-Blgmbgo_.css +1 -0
- package/dashboard/dist/assets/index-DzcjxzDR.js +49 -0
- package/dashboard/dist/index.html +13 -0
- package/dist/dashboard/dashboard-listener.d.ts +22 -0
- package/dist/dashboard/dashboard-listener.d.ts.map +1 -0
- package/dist/dashboard/dashboard-listener.js +124 -0
- package/dist/dashboard/dashboard-listener.js.map +1 -0
- package/dist/dashboard/http-server.d.ts +25 -0
- package/dist/dashboard/http-server.d.ts.map +1 -0
- package/dist/dashboard/http-server.js +281 -0
- package/dist/dashboard/http-server.js.map +1 -0
- package/dist/dashboard/message-bridge.d.ts +19 -0
- package/dist/dashboard/message-bridge.d.ts.map +1 -0
- package/dist/dashboard/message-bridge.js +48 -0
- package/dist/dashboard/message-bridge.js.map +1 -0
- package/dist/hub/agent-connection.d.ts +101 -0
- package/dist/hub/agent-connection.d.ts.map +1 -0
- package/dist/hub/agent-connection.js +383 -0
- package/dist/hub/agent-connection.js.map +1 -0
- package/dist/hub/hub-main.d.ts +2 -0
- package/dist/hub/hub-main.d.ts.map +1 -0
- package/dist/hub/hub-main.js +16 -0
- package/dist/hub/hub-main.js.map +1 -0
- package/dist/hub/hub-server.d.ts +8 -0
- package/dist/hub/hub-server.d.ts.map +1 -0
- package/dist/hub/hub-server.js +1500 -0
- package/dist/hub/hub-server.js.map +1 -0
- package/dist/hub/protocol.d.ts +221 -0
- package/dist/hub/protocol.d.ts.map +1 -0
- package/dist/hub/protocol.js +20 -0
- package/dist/hub/protocol.js.map +1 -0
- package/dist/hub/task-manager.d.ts +68 -0
- package/dist/hub/task-manager.d.ts.map +1 -0
- package/dist/hub/task-manager.js +250 -0
- package/dist/hub/task-manager.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +7 -0
- package/dist/index.js.map +1 -0
- package/dist/lifecycle.d.ts +2 -0
- package/dist/lifecycle.d.ts.map +1 -0
- package/dist/lifecycle.js +234 -0
- package/dist/lifecycle.js.map +1 -0
- package/dist/prompts.d.ts +3 -0
- package/dist/prompts.d.ts.map +1 -0
- package/dist/prompts.js +48 -0
- package/dist/prompts.js.map +1 -0
- package/dist/registry/cleanup.d.ts +2 -0
- package/dist/registry/cleanup.d.ts.map +1 -0
- package/dist/registry/cleanup.js +60 -0
- package/dist/registry/cleanup.js.map +1 -0
- package/dist/registry/registry.d.ts +11 -0
- package/dist/registry/registry.d.ts.map +1 -0
- package/dist/registry/registry.js +82 -0
- package/dist/registry/registry.js.map +1 -0
- package/dist/server.d.ts +9 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.js +91 -0
- package/dist/server.js.map +1 -0
- package/dist/stores/message-store.d.ts +21 -0
- package/dist/stores/message-store.d.ts.map +1 -0
- package/dist/stores/message-store.js +83 -0
- package/dist/stores/message-store.js.map +1 -0
- package/dist/stores/task-store.d.ts +15 -0
- package/dist/stores/task-store.d.ts.map +1 -0
- package/dist/stores/task-store.js +57 -0
- package/dist/stores/task-store.js.map +1 -0
- package/dist/tools/accept-claim.d.ts +4 -0
- package/dist/tools/accept-claim.d.ts.map +1 -0
- package/dist/tools/accept-claim.js +27 -0
- package/dist/tools/accept-claim.js.map +1 -0
- package/dist/tools/chat-send.d.ts +4 -0
- package/dist/tools/chat-send.d.ts.map +1 -0
- package/dist/tools/chat-send.js +19 -0
- package/dist/tools/chat-send.js.map +1 -0
- package/dist/tools/claim-task.d.ts +4 -0
- package/dist/tools/claim-task.d.ts.map +1 -0
- package/dist/tools/claim-task.js +26 -0
- package/dist/tools/claim-task.js.map +1 -0
- package/dist/tools/clear-session.d.ts +5 -0
- package/dist/tools/clear-session.d.ts.map +1 -0
- package/dist/tools/clear-session.js +41 -0
- package/dist/tools/clear-session.js.map +1 -0
- package/dist/tools/complete-task.d.ts +4 -0
- package/dist/tools/complete-task.d.ts.map +1 -0
- package/dist/tools/complete-task.js +29 -0
- package/dist/tools/complete-task.js.map +1 -0
- package/dist/tools/create-room.d.ts +4 -0
- package/dist/tools/create-room.d.ts.map +1 -0
- package/dist/tools/create-room.js +28 -0
- package/dist/tools/create-room.js.map +1 -0
- package/dist/tools/delegate-task.d.ts +4 -0
- package/dist/tools/delegate-task.d.ts.map +1 -0
- package/dist/tools/delegate-task.js +32 -0
- package/dist/tools/delegate-task.js.map +1 -0
- package/dist/tools/get-messages.d.ts +4 -0
- package/dist/tools/get-messages.d.ts.map +1 -0
- package/dist/tools/get-messages.js +35 -0
- package/dist/tools/get-messages.js.map +1 -0
- package/dist/tools/get-room-digest.d.ts +4 -0
- package/dist/tools/get-room-digest.d.ts.map +1 -0
- package/dist/tools/get-room-digest.js +63 -0
- package/dist/tools/get-room-digest.js.map +1 -0
- package/dist/tools/get-task-status.d.ts +4 -0
- package/dist/tools/get-task-status.d.ts.map +1 -0
- package/dist/tools/get-task-status.js +26 -0
- package/dist/tools/get-task-status.js.map +1 -0
- package/dist/tools/join-room.d.ts +4 -0
- package/dist/tools/join-room.d.ts.map +1 -0
- package/dist/tools/join-room.js +26 -0
- package/dist/tools/join-room.js.map +1 -0
- package/dist/tools/list-peers.d.ts +4 -0
- package/dist/tools/list-peers.d.ts.map +1 -0
- package/dist/tools/list-peers.js +26 -0
- package/dist/tools/list-peers.js.map +1 -0
- package/dist/tools/list-tasks.d.ts +4 -0
- package/dist/tools/list-tasks.d.ts.map +1 -0
- package/dist/tools/list-tasks.js +28 -0
- package/dist/tools/list-tasks.js.map +1 -0
- package/dist/tools/send-message.d.ts +4 -0
- package/dist/tools/send-message.d.ts.map +1 -0
- package/dist/tools/send-message.js +28 -0
- package/dist/tools/send-message.js.map +1 -0
- package/dist/tools/set-status.d.ts +4 -0
- package/dist/tools/set-status.d.ts.map +1 -0
- package/dist/tools/set-status.js +28 -0
- package/dist/tools/set-status.js.map +1 -0
- package/dist/tools/update-task.d.ts +4 -0
- package/dist/tools/update-task.d.ts.map +1 -0
- package/dist/tools/update-task.js +28 -0
- package/dist/tools/update-task.js.map +1 -0
- package/dist/tools/wait-for-messages.d.ts +4 -0
- package/dist/tools/wait-for-messages.d.ts.map +1 -0
- package/dist/tools/wait-for-messages.js +84 -0
- package/dist/tools/wait-for-messages.js.map +1 -0
- package/dist/transport/peer-protocol.d.ts +7 -0
- package/dist/transport/peer-protocol.d.ts.map +1 -0
- package/dist/transport/peer-protocol.js +30 -0
- package/dist/transport/peer-protocol.js.map +1 -0
- package/dist/transport/uds-client.d.ts +3 -0
- package/dist/transport/uds-client.d.ts.map +1 -0
- package/dist/transport/uds-client.js +57 -0
- package/dist/transport/uds-client.js.map +1 -0
- package/dist/transport/uds-server.d.ts +12 -0
- package/dist/transport/uds-server.d.ts.map +1 -0
- package/dist/transport/uds-server.js +87 -0
- package/dist/transport/uds-server.js.map +1 -0
- package/dist/types.d.ts +18 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/util/id.d.ts +2 -0
- package/dist/util/id.d.ts.map +1 -0
- package/dist/util/id.js +5 -0
- package/dist/util/id.js.map +1 -0
- package/dist/util/logger.d.ts +3 -0
- package/dist/util/logger.d.ts.map +1 -0
- package/dist/util/logger.js +13 -0
- package/dist/util/logger.js.map +1 -0
- package/dist/util/pid.d.ts +2 -0
- package/dist/util/pid.d.ts.map +1 -0
- package/dist/util/pid.js +10 -0
- package/dist/util/pid.js.map +1 -0
- package/hooks/permission-hook.sh +116 -0
- 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
|