@agent-relay/sdk 0.1.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/dist/client.js ADDED
@@ -0,0 +1,695 @@
1
+ /**
2
+ * RelayClient - Agent Relay SDK Client
3
+ * @agent-relay/sdk
4
+ *
5
+ * Lightweight client for agent-to-agent communication via Agent Relay daemon.
6
+ */
7
+ import net from 'node:net';
8
+ import { randomUUID } from 'node:crypto';
9
+ import { PROTOCOL_VERSION, } from './protocol/types.js';
10
+ import { encodeFrameLegacy, FrameParser } from './protocol/framing.js';
11
+ const DEFAULT_SOCKET_PATH = '/tmp/agent-relay.sock';
12
+ const DEFAULT_CLIENT_CONFIG = {
13
+ socketPath: DEFAULT_SOCKET_PATH,
14
+ agentName: 'agent',
15
+ cli: undefined,
16
+ quiet: false,
17
+ reconnect: true,
18
+ maxReconnectAttempts: 10,
19
+ reconnectDelayMs: 1000, // Increased from 100ms to prevent reconnect storms
20
+ reconnectMaxDelayMs: 30000,
21
+ };
22
+ // Simple ID generator
23
+ let idCounter = 0;
24
+ function generateId() {
25
+ return `${Date.now().toString(36)}-${(++idCounter).toString(36)}`;
26
+ }
27
+ /**
28
+ * Circular buffer for O(1) deduplication with bounded memory.
29
+ */
30
+ class CircularDedupeCache {
31
+ ids = new Set();
32
+ ring;
33
+ head = 0;
34
+ capacity;
35
+ constructor(capacity = 2000) {
36
+ this.capacity = capacity;
37
+ this.ring = new Array(capacity);
38
+ }
39
+ check(id) {
40
+ if (this.ids.has(id))
41
+ return true;
42
+ if (this.ids.size >= this.capacity) {
43
+ const oldest = this.ring[this.head];
44
+ if (oldest)
45
+ this.ids.delete(oldest);
46
+ }
47
+ this.ring[this.head] = id;
48
+ this.ids.add(id);
49
+ this.head = (this.head + 1) % this.capacity;
50
+ return false;
51
+ }
52
+ clear() {
53
+ this.ids.clear();
54
+ this.ring = new Array(this.capacity);
55
+ this.head = 0;
56
+ }
57
+ }
58
+ /**
59
+ * RelayClient for agent-to-agent communication.
60
+ */
61
+ export class RelayClient {
62
+ config;
63
+ socket;
64
+ parser;
65
+ _state = 'DISCONNECTED';
66
+ sessionId;
67
+ resumeToken;
68
+ reconnectAttempts = 0;
69
+ reconnectDelay;
70
+ reconnectTimer;
71
+ _destroyed = false;
72
+ dedupeCache = new CircularDedupeCache(2000);
73
+ writeQueue = [];
74
+ writeScheduled = false;
75
+ pendingSyncAcks = new Map();
76
+ // Event handlers
77
+ onMessage;
78
+ /**
79
+ * Callback for channel messages.
80
+ */
81
+ onChannelMessage;
82
+ onStateChange;
83
+ onError;
84
+ constructor(config = {}) {
85
+ this.config = { ...DEFAULT_CLIENT_CONFIG, ...config };
86
+ this.parser = new FrameParser();
87
+ this.parser.setLegacyMode(true);
88
+ this.reconnectDelay = this.config.reconnectDelayMs;
89
+ }
90
+ get state() {
91
+ return this._state;
92
+ }
93
+ get agentName() {
94
+ return this.config.agentName;
95
+ }
96
+ get currentSessionId() {
97
+ return this.sessionId;
98
+ }
99
+ /**
100
+ * Connect to the relay daemon.
101
+ */
102
+ connect() {
103
+ if (this._state !== 'DISCONNECTED' && this._state !== 'BACKOFF') {
104
+ return Promise.resolve();
105
+ }
106
+ return new Promise((resolve, reject) => {
107
+ let settled = false;
108
+ const settleResolve = () => {
109
+ if (settled)
110
+ return;
111
+ settled = true;
112
+ resolve();
113
+ };
114
+ const settleReject = (err) => {
115
+ if (settled)
116
+ return;
117
+ settled = true;
118
+ reject(err);
119
+ };
120
+ this.setState('CONNECTING');
121
+ this.socket = net.createConnection(this.config.socketPath, () => {
122
+ this.setState('HANDSHAKING');
123
+ this.sendHello();
124
+ });
125
+ this.socket.on('data', (data) => this.handleData(data));
126
+ this.socket.on('close', () => {
127
+ this.handleDisconnect();
128
+ });
129
+ this.socket.on('error', (err) => {
130
+ if (this._state === 'CONNECTING') {
131
+ settleReject(err);
132
+ }
133
+ this.handleError(err);
134
+ });
135
+ const checkReady = setInterval(() => {
136
+ if (this._state === 'READY') {
137
+ clearInterval(checkReady);
138
+ clearTimeout(timeout);
139
+ settleResolve();
140
+ }
141
+ }, 10);
142
+ const timeout = setTimeout(() => {
143
+ if (this._state !== 'READY') {
144
+ clearInterval(checkReady);
145
+ this.socket?.destroy();
146
+ settleReject(new Error('Connection timeout'));
147
+ }
148
+ }, 5000);
149
+ });
150
+ }
151
+ /**
152
+ * Disconnect from the relay daemon.
153
+ */
154
+ disconnect() {
155
+ if (this.reconnectTimer) {
156
+ clearTimeout(this.reconnectTimer);
157
+ this.reconnectTimer = undefined;
158
+ }
159
+ if (this.socket) {
160
+ this.send({
161
+ v: PROTOCOL_VERSION,
162
+ type: 'BYE',
163
+ id: generateId(),
164
+ ts: Date.now(),
165
+ payload: {},
166
+ });
167
+ this.socket.end();
168
+ this.socket = undefined;
169
+ }
170
+ this.setState('DISCONNECTED');
171
+ }
172
+ /**
173
+ * Permanently destroy the client.
174
+ */
175
+ destroy() {
176
+ this._destroyed = true;
177
+ this.disconnect();
178
+ }
179
+ /**
180
+ * Send a message to another agent.
181
+ */
182
+ sendMessage(to, body, kind = 'message', data, thread, meta) {
183
+ if (this._state !== 'READY') {
184
+ return false;
185
+ }
186
+ const envelope = {
187
+ v: PROTOCOL_VERSION,
188
+ type: 'SEND',
189
+ id: generateId(),
190
+ ts: Date.now(),
191
+ to,
192
+ payload: {
193
+ kind,
194
+ body,
195
+ data,
196
+ thread,
197
+ },
198
+ payload_meta: meta,
199
+ };
200
+ return this.send(envelope);
201
+ }
202
+ /**
203
+ * Send an ACK for a delivered message.
204
+ */
205
+ sendAck(payload) {
206
+ if (this._state !== 'READY') {
207
+ return false;
208
+ }
209
+ const envelope = {
210
+ v: PROTOCOL_VERSION,
211
+ type: 'ACK',
212
+ id: generateId(),
213
+ ts: Date.now(),
214
+ payload,
215
+ };
216
+ return this.send(envelope);
217
+ }
218
+ /**
219
+ * Send a message and wait for ACK response.
220
+ */
221
+ async sendAndWait(to, body, options = {}) {
222
+ if (this._state !== 'READY') {
223
+ throw new Error('Client not ready');
224
+ }
225
+ const correlationId = randomUUID();
226
+ const timeoutMs = options.timeoutMs ?? 30000;
227
+ const kind = options.kind ?? 'message';
228
+ return new Promise((resolve, reject) => {
229
+ const timeoutHandle = setTimeout(() => {
230
+ this.pendingSyncAcks.delete(correlationId);
231
+ reject(new Error(`ACK timeout after ${timeoutMs}ms`));
232
+ }, timeoutMs);
233
+ this.pendingSyncAcks.set(correlationId, { resolve, reject, timeoutHandle });
234
+ const envelope = {
235
+ v: PROTOCOL_VERSION,
236
+ type: 'SEND',
237
+ id: generateId(),
238
+ ts: Date.now(),
239
+ to,
240
+ payload: {
241
+ kind,
242
+ body,
243
+ data: options.data,
244
+ thread: options.thread,
245
+ },
246
+ payload_meta: {
247
+ sync: {
248
+ correlationId,
249
+ timeoutMs,
250
+ blocking: true,
251
+ },
252
+ },
253
+ };
254
+ const sent = this.send(envelope);
255
+ if (!sent) {
256
+ clearTimeout(timeoutHandle);
257
+ this.pendingSyncAcks.delete(correlationId);
258
+ reject(new Error('Failed to send message'));
259
+ }
260
+ });
261
+ }
262
+ /**
263
+ * Broadcast a message to all agents.
264
+ */
265
+ broadcast(body, kind = 'message', data) {
266
+ return this.sendMessage('*', body, kind, data);
267
+ }
268
+ /**
269
+ * Subscribe to a topic.
270
+ */
271
+ subscribe(topic) {
272
+ if (this._state !== 'READY')
273
+ return false;
274
+ return this.send({
275
+ v: PROTOCOL_VERSION,
276
+ type: 'SUBSCRIBE',
277
+ id: generateId(),
278
+ ts: Date.now(),
279
+ topic,
280
+ payload: {},
281
+ });
282
+ }
283
+ /**
284
+ * Unsubscribe from a topic.
285
+ */
286
+ unsubscribe(topic) {
287
+ if (this._state !== 'READY')
288
+ return false;
289
+ return this.send({
290
+ v: PROTOCOL_VERSION,
291
+ type: 'UNSUBSCRIBE',
292
+ id: generateId(),
293
+ ts: Date.now(),
294
+ topic,
295
+ payload: {},
296
+ });
297
+ }
298
+ /**
299
+ * Bind as a shadow to a primary agent.
300
+ */
301
+ bindAsShadow(primaryAgent, options = {}) {
302
+ if (this._state !== 'READY')
303
+ return false;
304
+ return this.send({
305
+ v: PROTOCOL_VERSION,
306
+ type: 'SHADOW_BIND',
307
+ id: generateId(),
308
+ ts: Date.now(),
309
+ payload: {
310
+ primaryAgent,
311
+ speakOn: options.speakOn,
312
+ receiveIncoming: options.receiveIncoming,
313
+ receiveOutgoing: options.receiveOutgoing,
314
+ },
315
+ });
316
+ }
317
+ /**
318
+ * Unbind from a primary agent.
319
+ */
320
+ unbindAsShadow(primaryAgent) {
321
+ if (this._state !== 'READY')
322
+ return false;
323
+ return this.send({
324
+ v: PROTOCOL_VERSION,
325
+ type: 'SHADOW_UNBIND',
326
+ id: generateId(),
327
+ ts: Date.now(),
328
+ payload: {
329
+ primaryAgent,
330
+ },
331
+ });
332
+ }
333
+ /**
334
+ * Send log output to the daemon for dashboard streaming.
335
+ */
336
+ sendLog(data) {
337
+ if (this._state !== 'READY') {
338
+ return false;
339
+ }
340
+ const envelope = {
341
+ v: PROTOCOL_VERSION,
342
+ type: 'LOG',
343
+ id: generateId(),
344
+ ts: Date.now(),
345
+ payload: {
346
+ data,
347
+ timestamp: Date.now(),
348
+ },
349
+ };
350
+ return this.send(envelope);
351
+ }
352
+ // =============================================================================
353
+ // Channel Operations
354
+ // =============================================================================
355
+ /**
356
+ * Join a channel.
357
+ * @param channel - Channel name (e.g., '#general', 'dm:alice:bob')
358
+ * @param displayName - Optional display name for this member
359
+ */
360
+ joinChannel(channel, displayName) {
361
+ if (this._state !== 'READY') {
362
+ return false;
363
+ }
364
+ const envelope = {
365
+ v: PROTOCOL_VERSION,
366
+ type: 'CHANNEL_JOIN',
367
+ id: generateId(),
368
+ ts: Date.now(),
369
+ payload: {
370
+ channel,
371
+ displayName,
372
+ },
373
+ };
374
+ return this.send(envelope);
375
+ }
376
+ /**
377
+ * Admin join: Add any member to a channel (does not require member to be connected).
378
+ * @param channel - Channel name
379
+ * @param member - Name of the member to add
380
+ */
381
+ adminJoinChannel(channel, member) {
382
+ if (this._state !== 'READY') {
383
+ return false;
384
+ }
385
+ const envelope = {
386
+ v: PROTOCOL_VERSION,
387
+ type: 'CHANNEL_JOIN',
388
+ id: generateId(),
389
+ ts: Date.now(),
390
+ payload: {
391
+ channel,
392
+ member,
393
+ },
394
+ };
395
+ return this.send(envelope);
396
+ }
397
+ /**
398
+ * Leave a channel.
399
+ * @param channel - Channel name to leave
400
+ * @param reason - Optional reason for leaving
401
+ */
402
+ leaveChannel(channel, reason) {
403
+ if (this._state !== 'READY')
404
+ return false;
405
+ const envelope = {
406
+ v: PROTOCOL_VERSION,
407
+ type: 'CHANNEL_LEAVE',
408
+ id: generateId(),
409
+ ts: Date.now(),
410
+ payload: {
411
+ channel,
412
+ reason,
413
+ },
414
+ };
415
+ return this.send(envelope);
416
+ }
417
+ /**
418
+ * Admin remove: Remove any member from a channel.
419
+ * @param channel - Channel name
420
+ * @param member - Name of the member to remove
421
+ */
422
+ adminRemoveMember(channel, member) {
423
+ if (this._state !== 'READY') {
424
+ return false;
425
+ }
426
+ const envelope = {
427
+ v: PROTOCOL_VERSION,
428
+ type: 'CHANNEL_LEAVE',
429
+ id: generateId(),
430
+ ts: Date.now(),
431
+ payload: {
432
+ channel,
433
+ member,
434
+ },
435
+ };
436
+ return this.send(envelope);
437
+ }
438
+ /**
439
+ * Send a message to a channel.
440
+ * @param channel - Channel name
441
+ * @param body - Message content
442
+ * @param options - Optional thread, mentions, attachments
443
+ */
444
+ sendChannelMessage(channel, body, options) {
445
+ if (this._state !== 'READY') {
446
+ return false;
447
+ }
448
+ const envelope = {
449
+ v: PROTOCOL_VERSION,
450
+ type: 'CHANNEL_MESSAGE',
451
+ id: generateId(),
452
+ ts: Date.now(),
453
+ payload: {
454
+ channel,
455
+ body,
456
+ thread: options?.thread,
457
+ mentions: options?.mentions,
458
+ attachments: options?.attachments,
459
+ data: options?.data,
460
+ },
461
+ };
462
+ return this.send(envelope);
463
+ }
464
+ // Private methods
465
+ setState(state) {
466
+ this._state = state;
467
+ if (this.onStateChange) {
468
+ this.onStateChange(state);
469
+ }
470
+ }
471
+ sendHello() {
472
+ const hello = {
473
+ v: PROTOCOL_VERSION,
474
+ type: 'HELLO',
475
+ id: generateId(),
476
+ ts: Date.now(),
477
+ payload: {
478
+ agent: this.config.agentName,
479
+ entityType: this.config.entityType,
480
+ cli: this.config.cli,
481
+ program: this.config.program,
482
+ model: this.config.model,
483
+ task: this.config.task,
484
+ workingDirectory: this.config.workingDirectory,
485
+ displayName: this.config.displayName,
486
+ avatarUrl: this.config.avatarUrl,
487
+ capabilities: {
488
+ ack: true,
489
+ resume: true,
490
+ max_inflight: 256,
491
+ supports_topics: true,
492
+ },
493
+ session: this.resumeToken ? { resume_token: this.resumeToken } : undefined,
494
+ },
495
+ };
496
+ this.send(hello);
497
+ }
498
+ send(envelope) {
499
+ if (!this.socket)
500
+ return false;
501
+ try {
502
+ const frame = encodeFrameLegacy(envelope);
503
+ this.writeQueue.push(frame);
504
+ if (!this.writeScheduled) {
505
+ this.writeScheduled = true;
506
+ setImmediate(() => this.flushWrites());
507
+ }
508
+ return true;
509
+ }
510
+ catch (err) {
511
+ this.handleError(err);
512
+ return false;
513
+ }
514
+ }
515
+ flushWrites() {
516
+ this.writeScheduled = false;
517
+ if (this.writeQueue.length === 0 || !this.socket)
518
+ return;
519
+ if (this.writeQueue.length === 1) {
520
+ this.socket.write(this.writeQueue[0]);
521
+ }
522
+ else {
523
+ this.socket.write(Buffer.concat(this.writeQueue));
524
+ }
525
+ this.writeQueue = [];
526
+ }
527
+ handleData(data) {
528
+ try {
529
+ const frames = this.parser.push(data);
530
+ for (const frame of frames) {
531
+ this.processFrame(frame);
532
+ }
533
+ }
534
+ catch (err) {
535
+ this.handleError(err);
536
+ }
537
+ }
538
+ processFrame(envelope) {
539
+ switch (envelope.type) {
540
+ case 'WELCOME':
541
+ this.handleWelcome(envelope);
542
+ break;
543
+ case 'DELIVER':
544
+ this.handleDeliver(envelope);
545
+ break;
546
+ case 'CHANNEL_MESSAGE':
547
+ this.handleChannelMessage(envelope);
548
+ break;
549
+ case 'PING':
550
+ this.handlePing(envelope);
551
+ break;
552
+ case 'ACK':
553
+ this.handleAck(envelope);
554
+ break;
555
+ case 'ERROR':
556
+ this.handleErrorFrame(envelope);
557
+ break;
558
+ case 'BUSY':
559
+ if (!this.config.quiet) {
560
+ console.warn('[sdk] Server busy, backing off');
561
+ }
562
+ break;
563
+ }
564
+ }
565
+ handleWelcome(envelope) {
566
+ this.sessionId = envelope.payload.session_id;
567
+ this.resumeToken = envelope.payload.resume_token;
568
+ this.reconnectAttempts = 0;
569
+ this.reconnectDelay = this.config.reconnectDelayMs;
570
+ this.setState('READY');
571
+ if (!this.config.quiet) {
572
+ console.log(`[sdk] Connected as ${this.config.agentName} (session: ${this.sessionId})`);
573
+ }
574
+ }
575
+ handleDeliver(envelope) {
576
+ // Send ACK
577
+ this.send({
578
+ v: PROTOCOL_VERSION,
579
+ type: 'ACK',
580
+ id: generateId(),
581
+ ts: Date.now(),
582
+ payload: {
583
+ ack_id: envelope.id,
584
+ seq: envelope.delivery.seq,
585
+ },
586
+ });
587
+ const duplicate = this.dedupeCache.check(envelope.id);
588
+ if (duplicate) {
589
+ return;
590
+ }
591
+ if (this.onMessage && envelope.from) {
592
+ this.onMessage(envelope.from, envelope.payload, envelope.id, envelope.payload_meta, envelope.delivery.originalTo);
593
+ }
594
+ }
595
+ handleChannelMessage(envelope) {
596
+ const duplicate = this.dedupeCache.check(envelope.id);
597
+ if (duplicate) {
598
+ return;
599
+ }
600
+ // Notify channel message handler
601
+ if (this.onChannelMessage && envelope.from) {
602
+ this.onChannelMessage(envelope.from, envelope.payload.channel, envelope.payload.body, envelope);
603
+ }
604
+ // Also call onMessage for backwards compatibility
605
+ if (this.onMessage && envelope.from) {
606
+ const sendPayload = {
607
+ kind: 'message',
608
+ body: envelope.payload.body,
609
+ data: {
610
+ _isChannelMessage: true,
611
+ _channel: envelope.payload.channel,
612
+ _mentions: envelope.payload.mentions,
613
+ },
614
+ thread: envelope.payload.thread,
615
+ };
616
+ this.onMessage(envelope.from, sendPayload, envelope.id, undefined, envelope.payload.channel);
617
+ }
618
+ }
619
+ handleAck(envelope) {
620
+ const correlationId = envelope.payload.correlationId;
621
+ if (!correlationId)
622
+ return;
623
+ const pending = this.pendingSyncAcks.get(correlationId);
624
+ if (!pending)
625
+ return;
626
+ clearTimeout(pending.timeoutHandle);
627
+ this.pendingSyncAcks.delete(correlationId);
628
+ pending.resolve(envelope.payload);
629
+ }
630
+ handlePing(envelope) {
631
+ this.send({
632
+ v: PROTOCOL_VERSION,
633
+ type: 'PONG',
634
+ id: generateId(),
635
+ ts: Date.now(),
636
+ payload: envelope.payload ?? {},
637
+ });
638
+ }
639
+ handleErrorFrame(envelope) {
640
+ if (!this.config.quiet) {
641
+ console.error('[sdk] Server error:', envelope.payload);
642
+ }
643
+ if (envelope.payload.code === 'RESUME_TOO_OLD') {
644
+ this.resumeToken = undefined;
645
+ this.sessionId = undefined;
646
+ }
647
+ }
648
+ handleDisconnect() {
649
+ this.parser.reset();
650
+ this.socket = undefined;
651
+ this.rejectPendingSyncAcks(new Error('Disconnected while awaiting ACK'));
652
+ if (this._destroyed) {
653
+ this.setState('DISCONNECTED');
654
+ return;
655
+ }
656
+ if (this.config.reconnect && this.reconnectAttempts < this.config.maxReconnectAttempts) {
657
+ this.scheduleReconnect();
658
+ }
659
+ else {
660
+ this.setState('DISCONNECTED');
661
+ if (this.reconnectAttempts >= this.config.maxReconnectAttempts && !this.config.quiet) {
662
+ console.error(`[sdk] Max reconnect attempts reached (${this.config.maxReconnectAttempts}), giving up`);
663
+ }
664
+ }
665
+ }
666
+ handleError(error) {
667
+ if (!this.config.quiet) {
668
+ console.error('[sdk] Error:', error.message);
669
+ }
670
+ if (this.onError) {
671
+ this.onError(error);
672
+ }
673
+ }
674
+ rejectPendingSyncAcks(error) {
675
+ for (const [correlationId, pending] of this.pendingSyncAcks.entries()) {
676
+ clearTimeout(pending.timeoutHandle);
677
+ pending.reject(error);
678
+ this.pendingSyncAcks.delete(correlationId);
679
+ }
680
+ }
681
+ scheduleReconnect() {
682
+ this.setState('BACKOFF');
683
+ this.reconnectAttempts++;
684
+ const jitter = Math.random() * 0.3 + 0.85;
685
+ const delay = Math.min(this.reconnectDelay * jitter, this.config.reconnectMaxDelayMs);
686
+ this.reconnectDelay *= 2;
687
+ if (!this.config.quiet) {
688
+ console.log(`[sdk] Reconnecting in ${Math.round(delay)}ms (attempt ${this.reconnectAttempts})`);
689
+ }
690
+ this.reconnectTimer = setTimeout(() => {
691
+ this.connect().catch(() => { });
692
+ }, delay);
693
+ }
694
+ }
695
+ //# sourceMappingURL=client.js.map