@xagent-ai/cli 1.3.6 → 1.4.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/README.md +9 -0
- package/README_CN.md +9 -0
- package/dist/cli.js +26 -0
- package/dist/cli.js.map +1 -1
- package/dist/mcp.d.ts +8 -1
- package/dist/mcp.d.ts.map +1 -1
- package/dist/mcp.js +53 -20
- package/dist/mcp.js.map +1 -1
- package/dist/sdk-output-adapter.d.ts +79 -0
- package/dist/sdk-output-adapter.d.ts.map +1 -1
- package/dist/sdk-output-adapter.js +118 -0
- package/dist/sdk-output-adapter.js.map +1 -1
- package/dist/session.d.ts +88 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +351 -5
- package/dist/session.js.map +1 -1
- package/dist/slash-commands.d.ts.map +1 -1
- package/dist/slash-commands.js +3 -5
- package/dist/slash-commands.js.map +1 -1
- package/dist/smart-approval.d.ts.map +1 -1
- package/dist/smart-approval.js +1 -0
- package/dist/smart-approval.js.map +1 -1
- package/dist/system-prompt-generator.d.ts +15 -1
- package/dist/system-prompt-generator.d.ts.map +1 -1
- package/dist/system-prompt-generator.js +36 -27
- package/dist/system-prompt-generator.js.map +1 -1
- package/dist/team-manager/index.d.ts +6 -0
- package/dist/team-manager/index.d.ts.map +1 -0
- package/dist/team-manager/index.js +6 -0
- package/dist/team-manager/index.js.map +1 -0
- package/dist/team-manager/message-broker.d.ts +128 -0
- package/dist/team-manager/message-broker.d.ts.map +1 -0
- package/dist/team-manager/message-broker.js +638 -0
- package/dist/team-manager/message-broker.js.map +1 -0
- package/dist/team-manager/team-coordinator.d.ts +45 -0
- package/dist/team-manager/team-coordinator.d.ts.map +1 -0
- package/dist/team-manager/team-coordinator.js +887 -0
- package/dist/team-manager/team-coordinator.js.map +1 -0
- package/dist/team-manager/team-store.d.ts +49 -0
- package/dist/team-manager/team-store.d.ts.map +1 -0
- package/dist/team-manager/team-store.js +436 -0
- package/dist/team-manager/team-store.js.map +1 -0
- package/dist/team-manager/teammate-spawner.d.ts +86 -0
- package/dist/team-manager/teammate-spawner.d.ts.map +1 -0
- package/dist/team-manager/teammate-spawner.js +605 -0
- package/dist/team-manager/teammate-spawner.js.map +1 -0
- package/dist/team-manager/types.d.ts +164 -0
- package/dist/team-manager/types.d.ts.map +1 -0
- package/dist/team-manager/types.js +27 -0
- package/dist/team-manager/types.js.map +1 -0
- package/dist/tools.d.ts +41 -1
- package/dist/tools.d.ts.map +1 -1
- package/dist/tools.js +288 -32
- package/dist/tools.js.map +1 -1
- package/package.json +1 -1
- package/src/cli.ts +20 -0
- package/src/mcp.ts +64 -25
- package/src/sdk-output-adapter.ts +177 -0
- package/src/session.ts +423 -15
- package/src/slash-commands.ts +3 -7
- package/src/smart-approval.ts +1 -0
- package/src/system-prompt-generator.ts +59 -26
- package/src/team-manager/index.ts +5 -0
- package/src/team-manager/message-broker.ts +751 -0
- package/src/team-manager/team-coordinator.ts +1117 -0
- package/src/team-manager/team-store.ts +558 -0
- package/src/team-manager/teammate-spawner.ts +800 -0
- package/src/team-manager/types.ts +206 -0
- package/src/tools.ts +316 -33
|
@@ -0,0 +1,751 @@
|
|
|
1
|
+
import * as net from 'net';
|
|
2
|
+
import { EventEmitter } from 'events';
|
|
3
|
+
import { TeamMessage, MessageAck, MessageDeliveryInfo } from './types.js';
|
|
4
|
+
import crypto from 'crypto';
|
|
5
|
+
|
|
6
|
+
const generateId = () => crypto.randomUUID();
|
|
7
|
+
|
|
8
|
+
export interface MessageBrokerOptions {
|
|
9
|
+
port?: number;
|
|
10
|
+
host?: string;
|
|
11
|
+
ackTimeout?: number;
|
|
12
|
+
maxDeliveryInfoAge?: number; // Max age for delivery info cleanup
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface ConnectedClient {
|
|
16
|
+
memberId: string;
|
|
17
|
+
socket: net.Socket;
|
|
18
|
+
joinedAt: number;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface PendingAck {
|
|
22
|
+
message: TeamMessage;
|
|
23
|
+
targetMemberId: string;
|
|
24
|
+
sentAt: number;
|
|
25
|
+
resolve: (info: MessageDeliveryInfo) => void;
|
|
26
|
+
reject: (error: Error) => void;
|
|
27
|
+
timer: NodeJS.Timeout;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Default timeout for delivery info cleanup (5 minutes)
|
|
31
|
+
const DEFAULT_DELIVERY_INFO_MAX_AGE = 5 * 60 * 1000;
|
|
32
|
+
// Cleanup interval (1 minute)
|
|
33
|
+
const CLEANUP_INTERVAL = 60 * 1000;
|
|
34
|
+
|
|
35
|
+
export class MessageBroker extends EventEmitter {
|
|
36
|
+
private server: net.Server | null = null;
|
|
37
|
+
private clients: Map<string, ConnectedClient> = new Map();
|
|
38
|
+
private port: number;
|
|
39
|
+
private host: string;
|
|
40
|
+
private teamId: string;
|
|
41
|
+
private isRunning: boolean = false;
|
|
42
|
+
private pendingAcks: Map<string, PendingAck> = new Map();
|
|
43
|
+
private ackTimeout: number;
|
|
44
|
+
private deliveryInfo: Map<string, MessageDeliveryInfo> = new Map();
|
|
45
|
+
private maxDeliveryInfoAge: number;
|
|
46
|
+
private cleanupTimer: NodeJS.Timeout | null = null;
|
|
47
|
+
|
|
48
|
+
constructor(teamId: string, options: MessageBrokerOptions = {}) {
|
|
49
|
+
super();
|
|
50
|
+
this.teamId = teamId;
|
|
51
|
+
this.port = options.port || 0;
|
|
52
|
+
this.host = options.host || '127.0.0.1';
|
|
53
|
+
this.ackTimeout = options.ackTimeout || 30000;
|
|
54
|
+
this.maxDeliveryInfoAge = options.maxDeliveryInfoAge || DEFAULT_DELIVERY_INFO_MAX_AGE;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async start(): Promise<number> {
|
|
58
|
+
return new Promise((resolve, reject) => {
|
|
59
|
+
this.server = net.createServer((socket) => {
|
|
60
|
+
this.handleConnection(socket);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
this.server.listen(this.port, this.host, () => {
|
|
64
|
+
const address = this.server?.address() as net.AddressInfo;
|
|
65
|
+
this.port = address.port;
|
|
66
|
+
this.isRunning = true;
|
|
67
|
+
this.emit('started', { port: this.port, host: this.host });
|
|
68
|
+
|
|
69
|
+
// Start cleanup timer
|
|
70
|
+
this.startCleanupTimer();
|
|
71
|
+
|
|
72
|
+
resolve(this.port);
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
this.server.on('error', (err) => {
|
|
76
|
+
this.emit('error', err);
|
|
77
|
+
reject(err);
|
|
78
|
+
});
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
async stop(): Promise<void> {
|
|
83
|
+
// Stop cleanup timer
|
|
84
|
+
this.stopCleanupTimer();
|
|
85
|
+
|
|
86
|
+
// Clean up all pending ACKs
|
|
87
|
+
for (const [_key, pending] of this.pendingAcks) {
|
|
88
|
+
clearTimeout(pending.timer);
|
|
89
|
+
pending.reject(new Error('Broker shutting down'));
|
|
90
|
+
}
|
|
91
|
+
this.pendingAcks.clear();
|
|
92
|
+
|
|
93
|
+
// Clean up delivery info
|
|
94
|
+
this.deliveryInfo.clear();
|
|
95
|
+
|
|
96
|
+
return new Promise((resolve) => {
|
|
97
|
+
for (const [_memberId, client] of this.clients) {
|
|
98
|
+
try {
|
|
99
|
+
client.socket.destroy();
|
|
100
|
+
} catch {
|
|
101
|
+
// ignore
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
this.clients.clear();
|
|
105
|
+
|
|
106
|
+
if (this.server) {
|
|
107
|
+
this.server.close(() => {
|
|
108
|
+
this.isRunning = false;
|
|
109
|
+
this.emit('stopped');
|
|
110
|
+
resolve();
|
|
111
|
+
});
|
|
112
|
+
} else {
|
|
113
|
+
resolve();
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Start periodic cleanup of stale delivery info
|
|
120
|
+
*/
|
|
121
|
+
private startCleanupTimer(): void {
|
|
122
|
+
this.cleanupTimer = setInterval(() => {
|
|
123
|
+
this.cleanupStaleData();
|
|
124
|
+
}, CLEANUP_INTERVAL);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Stop cleanup timer
|
|
129
|
+
*/
|
|
130
|
+
private stopCleanupTimer(): void {
|
|
131
|
+
if (this.cleanupTimer) {
|
|
132
|
+
clearInterval(this.cleanupTimer);
|
|
133
|
+
this.cleanupTimer = null;
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Clean up stale delivery info entries
|
|
139
|
+
*/
|
|
140
|
+
private cleanupStaleData(): void {
|
|
141
|
+
const now = Date.now();
|
|
142
|
+
const staleThreshold = now - this.maxDeliveryInfoAge;
|
|
143
|
+
|
|
144
|
+
// Clean up stale delivery info
|
|
145
|
+
for (const [messageId, info] of this.deliveryInfo) {
|
|
146
|
+
if (info.sentAt < staleThreshold) {
|
|
147
|
+
this.deliveryInfo.delete(messageId);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Clean up orphaned pending ACKs (shouldn't happen, but safety check)
|
|
152
|
+
for (const [key, pending] of this.pendingAcks) {
|
|
153
|
+
if (pending.sentAt < staleThreshold) {
|
|
154
|
+
clearTimeout(pending.timer);
|
|
155
|
+
this.pendingAcks.delete(key);
|
|
156
|
+
|
|
157
|
+
// Resolve with failed status instead of leaving hanging
|
|
158
|
+
const info: MessageDeliveryInfo = {
|
|
159
|
+
messageId: pending.message.messageId,
|
|
160
|
+
status: 'failed',
|
|
161
|
+
sentAt: pending.sentAt,
|
|
162
|
+
failedReason: 'Stale entry cleaned up'
|
|
163
|
+
};
|
|
164
|
+
pending.resolve(info);
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private handleConnection(socket: net.Socket): void {
|
|
170
|
+
let memberId: string | null = null;
|
|
171
|
+
let buffer = '';
|
|
172
|
+
|
|
173
|
+
socket.on('data', (data) => {
|
|
174
|
+
buffer += data.toString();
|
|
175
|
+
|
|
176
|
+
const messages = buffer.split('\n');
|
|
177
|
+
buffer = messages.pop() || '';
|
|
178
|
+
|
|
179
|
+
for (const msgStr of messages) {
|
|
180
|
+
if (!msgStr.trim()) continue;
|
|
181
|
+
|
|
182
|
+
try {
|
|
183
|
+
const msg = JSON.parse(msgStr);
|
|
184
|
+
|
|
185
|
+
if (msg.type === 'register' && msg.memberId) {
|
|
186
|
+
memberId = msg.memberId;
|
|
187
|
+
this.clients.set(memberId as string, {
|
|
188
|
+
memberId: memberId as string,
|
|
189
|
+
socket,
|
|
190
|
+
joinedAt: Date.now()
|
|
191
|
+
});
|
|
192
|
+
this.emit('client:connected', { memberId });
|
|
193
|
+
this.sendToSocket(socket, { type: 'registered', memberId });
|
|
194
|
+
} else if (memberId) {
|
|
195
|
+
this.handleMessage(memberId, msg);
|
|
196
|
+
}
|
|
197
|
+
} catch (parseError) {
|
|
198
|
+
// Log parse errors for debugging but don't crash
|
|
199
|
+
this.emit('parse-error', { data: msgStr, error: parseError });
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
socket.on('close', () => {
|
|
205
|
+
if (memberId) {
|
|
206
|
+
this.clients.delete(memberId);
|
|
207
|
+
this.emit('client:disconnected', { memberId });
|
|
208
|
+
|
|
209
|
+
// Clean up pending ACKs for this member
|
|
210
|
+
this.cleanupPendingAcksForMember(memberId);
|
|
211
|
+
}
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
socket.on('error', (error) => {
|
|
215
|
+
if (memberId) {
|
|
216
|
+
this.clients.delete(memberId);
|
|
217
|
+
this.emit('client:error', { memberId, error });
|
|
218
|
+
|
|
219
|
+
// Clean up pending ACKs for this member
|
|
220
|
+
this.cleanupPendingAcksForMember(memberId);
|
|
221
|
+
}
|
|
222
|
+
});
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Clean up pending ACKs for a disconnected member
|
|
227
|
+
*/
|
|
228
|
+
private cleanupPendingAcksForMember(memberId: string): void {
|
|
229
|
+
for (const [key, pending] of this.pendingAcks) {
|
|
230
|
+
if (pending.targetMemberId === memberId) {
|
|
231
|
+
clearTimeout(pending.timer);
|
|
232
|
+
this.pendingAcks.delete(key);
|
|
233
|
+
|
|
234
|
+
const info = this.deliveryInfo.get(pending.message.messageId);
|
|
235
|
+
if (info) {
|
|
236
|
+
info.status = 'failed';
|
|
237
|
+
info.failedReason = `Client ${memberId} disconnected`;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
pending.reject(new Error(`Client ${memberId} disconnected`));
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
private handleMessage(fromMemberId: string, msg: any): void {
|
|
246
|
+
if (msg.type === 'ack') {
|
|
247
|
+
this.handleAck(fromMemberId, msg);
|
|
248
|
+
} else if (msg.type === 'direct' || msg.type === 'broadcast') {
|
|
249
|
+
this.routeMessage(fromMemberId, msg);
|
|
250
|
+
} else if (msg.type === 'task_update') {
|
|
251
|
+
this.broadcast({
|
|
252
|
+
messageId: generateId(),
|
|
253
|
+
teamId: this.teamId,
|
|
254
|
+
fromMemberId,
|
|
255
|
+
toMemberId: 'broadcast',
|
|
256
|
+
content: msg.content,
|
|
257
|
+
timestamp: Date.now(),
|
|
258
|
+
type: 'task_update',
|
|
259
|
+
read: false
|
|
260
|
+
}, fromMemberId);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
private handleAck(fromMemberId: string, ack: MessageAck): void {
|
|
265
|
+
const pendingKey = `${ack.messageId}:${fromMemberId}`;
|
|
266
|
+
const pending = this.pendingAcks.get(pendingKey);
|
|
267
|
+
|
|
268
|
+
if (pending) {
|
|
269
|
+
clearTimeout(pending.timer);
|
|
270
|
+
this.pendingAcks.delete(pendingKey);
|
|
271
|
+
|
|
272
|
+
const info = this.deliveryInfo.get(ack.messageId);
|
|
273
|
+
if (info) {
|
|
274
|
+
info.status = 'acknowledged';
|
|
275
|
+
info.acknowledgedAt = ack.timestamp;
|
|
276
|
+
if (!info.acknowledgedBy) {
|
|
277
|
+
info.acknowledgedBy = [];
|
|
278
|
+
}
|
|
279
|
+
info.acknowledgedBy.push(fromMemberId);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
this.emit('message:acknowledged', { messageId: ack.messageId, fromMemberId, status: ack.status });
|
|
283
|
+
pending.resolve(this.deliveryInfo.get(ack.messageId)!);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private routeMessage(fromMemberId: string, msg: any): void {
|
|
288
|
+
const message: TeamMessage = {
|
|
289
|
+
messageId: generateId(),
|
|
290
|
+
teamId: this.teamId,
|
|
291
|
+
fromMemberId,
|
|
292
|
+
toMemberId: msg.toMemberId || 'broadcast',
|
|
293
|
+
content: msg.content,
|
|
294
|
+
timestamp: Date.now(),
|
|
295
|
+
type: msg.type || 'direct',
|
|
296
|
+
read: false
|
|
297
|
+
};
|
|
298
|
+
|
|
299
|
+
if (msg.toMemberId === 'broadcast') {
|
|
300
|
+
this.broadcast(message, fromMemberId);
|
|
301
|
+
} else if (msg.toMemberId) {
|
|
302
|
+
this.sendToMember(msg.toMemberId, message);
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
private broadcast(message: TeamMessage, excludeMemberId?: string): void {
|
|
307
|
+
const msgStr = JSON.stringify(message) + '\n';
|
|
308
|
+
|
|
309
|
+
for (const [memberId, client] of this.clients) {
|
|
310
|
+
if (memberId !== excludeMemberId) {
|
|
311
|
+
try {
|
|
312
|
+
client.socket.write(msgStr);
|
|
313
|
+
} catch {
|
|
314
|
+
// socket might be closed
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
this.emit('message:broadcast', message);
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
private sendToMember(memberId: string, message: TeamMessage, requiresAck: boolean = true): Promise<MessageDeliveryInfo> {
|
|
323
|
+
return new Promise((resolve, reject) => {
|
|
324
|
+
const client = this.clients.get(memberId);
|
|
325
|
+
const info: MessageDeliveryInfo = {
|
|
326
|
+
messageId: message.messageId,
|
|
327
|
+
status: 'pending',
|
|
328
|
+
sentAt: Date.now()
|
|
329
|
+
};
|
|
330
|
+
this.deliveryInfo.set(message.messageId, info);
|
|
331
|
+
|
|
332
|
+
if (!client) {
|
|
333
|
+
info.status = 'failed';
|
|
334
|
+
info.failedReason = 'client not found';
|
|
335
|
+
this.emit('message:failed', { memberId, message, reason: 'client not found' });
|
|
336
|
+
reject(new Error(`Client ${memberId} not found`));
|
|
337
|
+
return;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
try {
|
|
341
|
+
const msgWithAck = { ...message, requiresAck };
|
|
342
|
+
client.socket.write(JSON.stringify(msgWithAck) + '\n');
|
|
343
|
+
info.status = 'sent';
|
|
344
|
+
this.emit('message:sent', { memberId, message });
|
|
345
|
+
|
|
346
|
+
if (requiresAck) {
|
|
347
|
+
const pendingKey = `${message.messageId}:${memberId}`;
|
|
348
|
+
const timer = setTimeout(() => {
|
|
349
|
+
// Clean up on timeout
|
|
350
|
+
this.pendingAcks.delete(pendingKey);
|
|
351
|
+
info.status = 'failed';
|
|
352
|
+
info.failedReason = 'ack timeout';
|
|
353
|
+
this.emit('message:timeout', { messageId: message.messageId, memberId });
|
|
354
|
+
reject(new Error(`ACK timeout for message ${message.messageId} to ${memberId}`));
|
|
355
|
+
}, this.ackTimeout);
|
|
356
|
+
|
|
357
|
+
this.pendingAcks.set(pendingKey, {
|
|
358
|
+
message,
|
|
359
|
+
targetMemberId: memberId,
|
|
360
|
+
sentAt: Date.now(),
|
|
361
|
+
resolve,
|
|
362
|
+
reject,
|
|
363
|
+
timer
|
|
364
|
+
});
|
|
365
|
+
} else {
|
|
366
|
+
resolve(info);
|
|
367
|
+
}
|
|
368
|
+
} catch (err) {
|
|
369
|
+
info.status = 'failed';
|
|
370
|
+
info.failedReason = String(err);
|
|
371
|
+
this.emit('message:failed', { memberId, message, error: err });
|
|
372
|
+
reject(err);
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
private sendToSocket(socket: net.Socket, msg: object): void {
|
|
378
|
+
try {
|
|
379
|
+
socket.write(JSON.stringify(msg) + '\n');
|
|
380
|
+
} catch {
|
|
381
|
+
// ignore
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
sendMessage(fromMemberId: string, toMemberId: string | 'broadcast', content: string, type: TeamMessage['type'] = 'direct'): TeamMessage {
|
|
386
|
+
const message: TeamMessage = {
|
|
387
|
+
messageId: generateId(),
|
|
388
|
+
teamId: this.teamId,
|
|
389
|
+
fromMemberId,
|
|
390
|
+
toMemberId,
|
|
391
|
+
content,
|
|
392
|
+
timestamp: Date.now(),
|
|
393
|
+
type,
|
|
394
|
+
read: false,
|
|
395
|
+
requiresAck: true
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
if (toMemberId === 'broadcast') {
|
|
399
|
+
this.broadcast(message, fromMemberId);
|
|
400
|
+
} else {
|
|
401
|
+
this.sendToMember(toMemberId, message);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
return message;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
async sendMessageWithAck(
|
|
408
|
+
fromMemberId: string,
|
|
409
|
+
toMemberId: string | 'broadcast',
|
|
410
|
+
content: string,
|
|
411
|
+
type: TeamMessage['type'] = 'direct'
|
|
412
|
+
): Promise<{ message: TeamMessage; deliveryInfo: MessageDeliveryInfo | MessageDeliveryInfo[] }> {
|
|
413
|
+
const message: TeamMessage = {
|
|
414
|
+
messageId: generateId(),
|
|
415
|
+
teamId: this.teamId,
|
|
416
|
+
fromMemberId,
|
|
417
|
+
toMemberId,
|
|
418
|
+
content,
|
|
419
|
+
timestamp: Date.now(),
|
|
420
|
+
type,
|
|
421
|
+
read: false,
|
|
422
|
+
requiresAck: true
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
if (toMemberId === 'broadcast') {
|
|
426
|
+
const results = await this.broadcastWithAck(message, fromMemberId);
|
|
427
|
+
return { message, deliveryInfo: results };
|
|
428
|
+
} else {
|
|
429
|
+
const info = await this.sendToMember(toMemberId, message, true);
|
|
430
|
+
return { message, deliveryInfo: info };
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
private async broadcastWithAck(message: TeamMessage, excludeMemberId?: string): Promise<MessageDeliveryInfo[]> {
|
|
435
|
+
const promises: Promise<MessageDeliveryInfo>[] = [];
|
|
436
|
+
|
|
437
|
+
for (const [memberId, _client] of this.clients) {
|
|
438
|
+
if (memberId !== excludeMemberId) {
|
|
439
|
+
promises.push(this.sendToMember(memberId, { ...message }, true));
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
return Promise.allSettled(promises).then(results =>
|
|
444
|
+
results.map(r => r.status === 'fulfilled' ? r.value : {
|
|
445
|
+
messageId: message.messageId,
|
|
446
|
+
status: 'failed' as const,
|
|
447
|
+
sentAt: Date.now(),
|
|
448
|
+
failedReason: r.status === 'rejected' ? String(r.reason) : undefined
|
|
449
|
+
})
|
|
450
|
+
);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
getDeliveryInfo(messageId: string): MessageDeliveryInfo | undefined {
|
|
454
|
+
return this.deliveryInfo.get(messageId);
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
getPort(): number {
|
|
458
|
+
return this.port;
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
getConnectedMembers(): string[] {
|
|
462
|
+
return Array.from(this.clients.keys());
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
isClientConnected(memberId: string): boolean {
|
|
466
|
+
return this.clients.has(memberId);
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
isConnected(): boolean {
|
|
470
|
+
return this.isRunning;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Get stats about the broker state
|
|
475
|
+
*/
|
|
476
|
+
getStats(): {
|
|
477
|
+
connectedClients: number;
|
|
478
|
+
pendingAcks: number;
|
|
479
|
+
deliveryInfoEntries: number;
|
|
480
|
+
} {
|
|
481
|
+
return {
|
|
482
|
+
connectedClients: this.clients.size,
|
|
483
|
+
pendingAcks: this.pendingAcks.size,
|
|
484
|
+
deliveryInfoEntries: this.deliveryInfo.size
|
|
485
|
+
};
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
export class MessageClient extends EventEmitter {
|
|
490
|
+
private socket: net.Socket | null = null;
|
|
491
|
+
private connected: boolean = false;
|
|
492
|
+
private buffer: string = '';
|
|
493
|
+
private reconnectAttempts: number = 0;
|
|
494
|
+
private maxReconnectAttempts: number = 5;
|
|
495
|
+
private reconnectDelay: number = 1000;
|
|
496
|
+
private reconnectTimer: NodeJS.Timeout | null = null;
|
|
497
|
+
private isShuttingDown: boolean = false;
|
|
498
|
+
|
|
499
|
+
constructor(
|
|
500
|
+
private teamId: string,
|
|
501
|
+
private memberId: string,
|
|
502
|
+
private port: number,
|
|
503
|
+
private host: string = '127.0.0.1'
|
|
504
|
+
) {
|
|
505
|
+
super();
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async connect(): Promise<void> {
|
|
509
|
+
if (this.isShuttingDown) {
|
|
510
|
+
throw new Error('Client is shutting down');
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
return new Promise((resolve, reject) => {
|
|
514
|
+
this.socket = new net.Socket();
|
|
515
|
+
|
|
516
|
+
const connectHandler = () => {
|
|
517
|
+
this.connected = true;
|
|
518
|
+
this.reconnectAttempts = 0;
|
|
519
|
+
|
|
520
|
+
// Disable idle timeout after connection - we want persistent connections
|
|
521
|
+
// for real-time team communication
|
|
522
|
+
this.socket?.setTimeout(0);
|
|
523
|
+
|
|
524
|
+
const registerMsg = JSON.stringify({
|
|
525
|
+
type: 'register',
|
|
526
|
+
memberId: this.memberId,
|
|
527
|
+
teamId: this.teamId
|
|
528
|
+
}) + '\n';
|
|
529
|
+
|
|
530
|
+
this.socket?.write(registerMsg);
|
|
531
|
+
|
|
532
|
+
this.emit('connected');
|
|
533
|
+
resolve();
|
|
534
|
+
};
|
|
535
|
+
|
|
536
|
+
const errorHandler = (err: Error) => {
|
|
537
|
+
if (!this.connected) {
|
|
538
|
+
reject(err);
|
|
539
|
+
} else {
|
|
540
|
+
this.emit('error', err);
|
|
541
|
+
this.handleDisconnect();
|
|
542
|
+
}
|
|
543
|
+
};
|
|
544
|
+
|
|
545
|
+
// Set initial connection timeout (10 seconds to establish connection)
|
|
546
|
+
this.socket.setTimeout(10000);
|
|
547
|
+
|
|
548
|
+
this.socket.connect(this.port, this.host, connectHandler);
|
|
549
|
+
this.socket.on('error', errorHandler);
|
|
550
|
+
this.socket.on('close', () => this.handleDisconnect());
|
|
551
|
+
// Remove timeout handler since we disable timeout after connection
|
|
552
|
+
this.socket.on('timeout', () => {
|
|
553
|
+
// This should only fire during initial connection phase
|
|
554
|
+
// After connection, timeout is disabled (setTimeout(0))
|
|
555
|
+
if (!this.connected) {
|
|
556
|
+
this.socket?.destroy();
|
|
557
|
+
reject(new Error('Connection timeout'));
|
|
558
|
+
}
|
|
559
|
+
});
|
|
560
|
+
this.socket.on('data', (data) => this.handleData(data));
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
private handleDisconnect(): void {
|
|
565
|
+
const wasConnected = this.connected;
|
|
566
|
+
this.connected = false;
|
|
567
|
+
|
|
568
|
+
if (wasConnected) {
|
|
569
|
+
this.emit('disconnected');
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
// Don't reconnect if we're shutting down
|
|
573
|
+
if (this.isShuttingDown) {
|
|
574
|
+
return;
|
|
575
|
+
}
|
|
576
|
+
|
|
577
|
+
// Attempt reconnection
|
|
578
|
+
if (this.reconnectAttempts < this.maxReconnectAttempts) {
|
|
579
|
+
this.reconnectAttempts++;
|
|
580
|
+
|
|
581
|
+
// Clear any existing reconnect timer
|
|
582
|
+
if (this.reconnectTimer) {
|
|
583
|
+
clearTimeout(this.reconnectTimer);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
const delay = this.reconnectDelay * this.reconnectAttempts;
|
|
587
|
+
this.reconnectTimer = setTimeout(() => {
|
|
588
|
+
this.connect().catch(() => {
|
|
589
|
+
this.emit('reconnect:failed', { attempt: this.reconnectAttempts });
|
|
590
|
+
});
|
|
591
|
+
}, delay);
|
|
592
|
+
|
|
593
|
+
this.emit('reconnecting', { attempt: this.reconnectAttempts, delay });
|
|
594
|
+
} else {
|
|
595
|
+
this.emit('reconnect:exhausted', { attempts: this.reconnectAttempts });
|
|
596
|
+
}
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
private handleData(data: Buffer): void {
|
|
600
|
+
this.buffer += data.toString();
|
|
601
|
+
|
|
602
|
+
const messages = this.buffer.split('\n');
|
|
603
|
+
this.buffer = messages.pop() || '';
|
|
604
|
+
|
|
605
|
+
for (const msgStr of messages) {
|
|
606
|
+
if (!msgStr.trim()) continue;
|
|
607
|
+
|
|
608
|
+
try {
|
|
609
|
+
const msg = JSON.parse(msgStr);
|
|
610
|
+
|
|
611
|
+
if (msg.type === 'registered') {
|
|
612
|
+
this.emit('registered', msg);
|
|
613
|
+
} else {
|
|
614
|
+
if (msg.requiresAck && msg.messageId) {
|
|
615
|
+
this.sendAck(msg.messageId, 'received');
|
|
616
|
+
}
|
|
617
|
+
this.emit('message', msg);
|
|
618
|
+
}
|
|
619
|
+
} catch (parseError) {
|
|
620
|
+
this.emit('parse-error', { data: msgStr, error: parseError });
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
private sendAck(messageId: string, status: 'received' | 'processed', error?: string): void {
|
|
626
|
+
const ack: MessageAck = {
|
|
627
|
+
messageId,
|
|
628
|
+
fromMemberId: this.memberId,
|
|
629
|
+
status,
|
|
630
|
+
timestamp: Date.now(),
|
|
631
|
+
error
|
|
632
|
+
};
|
|
633
|
+
this.send({ type: 'ack', ...ack });
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
acknowledgeMessage(messageId: string, status: 'received' | 'processed' = 'processed', error?: string): void {
|
|
637
|
+
this.sendAck(messageId, status, error);
|
|
638
|
+
}
|
|
639
|
+
|
|
640
|
+
sendDirect(toMemberId: string, content: string): void {
|
|
641
|
+
this.send({
|
|
642
|
+
type: 'direct',
|
|
643
|
+
toMemberId,
|
|
644
|
+
content
|
|
645
|
+
});
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
broadcast(content: string): void {
|
|
649
|
+
this.send({
|
|
650
|
+
type: 'broadcast',
|
|
651
|
+
toMemberId: 'broadcast',
|
|
652
|
+
content
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
sendTaskUpdate(taskId: string, action: string, content: string): void {
|
|
657
|
+
this.send({
|
|
658
|
+
type: 'task_update',
|
|
659
|
+
taskId,
|
|
660
|
+
action,
|
|
661
|
+
content
|
|
662
|
+
});
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
private send(msg: object): void {
|
|
666
|
+
if (this.socket && this.connected) {
|
|
667
|
+
try {
|
|
668
|
+
this.socket.write(JSON.stringify(msg) + '\n');
|
|
669
|
+
} catch (error) {
|
|
670
|
+
this.emit('send:failed', { message: msg, error });
|
|
671
|
+
}
|
|
672
|
+
} else {
|
|
673
|
+
this.emit('send:failed', { message: msg, error: new Error('Not connected') });
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
async disconnect(): Promise<void> {
|
|
678
|
+
this.isShuttingDown = true;
|
|
679
|
+
|
|
680
|
+
// Clear any pending reconnect timer
|
|
681
|
+
if (this.reconnectTimer) {
|
|
682
|
+
clearTimeout(this.reconnectTimer);
|
|
683
|
+
this.reconnectTimer = null;
|
|
684
|
+
}
|
|
685
|
+
|
|
686
|
+
return new Promise((resolve) => {
|
|
687
|
+
if (this.socket) {
|
|
688
|
+
this.socket.destroy();
|
|
689
|
+
this.socket = null;
|
|
690
|
+
}
|
|
691
|
+
this.connected = false;
|
|
692
|
+
resolve();
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
isConnected(): boolean {
|
|
697
|
+
return this.connected;
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Reset reconnection attempts (useful after successful operation)
|
|
702
|
+
*/
|
|
703
|
+
resetReconnectAttempts(): void {
|
|
704
|
+
this.reconnectAttempts = 0;
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
const brokerInstances: Map<string, MessageBroker> = new Map();
|
|
709
|
+
let teammateClientInstance: MessageClient | null = null;
|
|
710
|
+
|
|
711
|
+
export function getMessageBroker(teamId: string): MessageBroker {
|
|
712
|
+
if (!brokerInstances.has(teamId)) {
|
|
713
|
+
brokerInstances.set(teamId, new MessageBroker(teamId));
|
|
714
|
+
}
|
|
715
|
+
return brokerInstances.get(teamId)!;
|
|
716
|
+
}
|
|
717
|
+
|
|
718
|
+
export function removeMessageBroker(teamId: string): void {
|
|
719
|
+
const broker = brokerInstances.get(teamId);
|
|
720
|
+
if (broker) {
|
|
721
|
+
broker.stop().catch(() => {});
|
|
722
|
+
}
|
|
723
|
+
brokerInstances.delete(teamId);
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
/**
|
|
727
|
+
* Set the persistent MessageClient for teammate process
|
|
728
|
+
* This is called once during teammate initialization
|
|
729
|
+
*/
|
|
730
|
+
export function setTeammateClient(client: MessageClient): void {
|
|
731
|
+
teammateClientInstance = client;
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* Get the persistent MessageClient for teammate process
|
|
736
|
+
* Returns null if not a teammate process or not initialized
|
|
737
|
+
*/
|
|
738
|
+
export function getTeammateClient(): MessageClient | null {
|
|
739
|
+
return teammateClientInstance;
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
/**
|
|
743
|
+
* Clear the persistent MessageClient for teammate process
|
|
744
|
+
* Called during cleanup to properly disconnect
|
|
745
|
+
*/
|
|
746
|
+
export function clearTeammateClient(): void {
|
|
747
|
+
if (teammateClientInstance) {
|
|
748
|
+
teammateClientInstance.disconnect().catch(() => {});
|
|
749
|
+
teammateClientInstance = null;
|
|
750
|
+
}
|
|
751
|
+
}
|