@agent-relay/wrapper 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.
Files changed (115) hide show
  1. package/dist/__fixtures__/claude-outputs.d.ts +49 -0
  2. package/dist/__fixtures__/claude-outputs.d.ts.map +1 -0
  3. package/dist/__fixtures__/claude-outputs.js +443 -0
  4. package/dist/__fixtures__/claude-outputs.js.map +1 -0
  5. package/dist/__fixtures__/codex-outputs.d.ts +9 -0
  6. package/dist/__fixtures__/codex-outputs.d.ts.map +1 -0
  7. package/dist/__fixtures__/codex-outputs.js +94 -0
  8. package/dist/__fixtures__/codex-outputs.js.map +1 -0
  9. package/dist/__fixtures__/gemini-outputs.d.ts +19 -0
  10. package/dist/__fixtures__/gemini-outputs.d.ts.map +1 -0
  11. package/dist/__fixtures__/gemini-outputs.js +144 -0
  12. package/dist/__fixtures__/gemini-outputs.js.map +1 -0
  13. package/dist/__fixtures__/index.d.ts +68 -0
  14. package/dist/__fixtures__/index.d.ts.map +1 -0
  15. package/dist/__fixtures__/index.js +44 -0
  16. package/dist/__fixtures__/index.js.map +1 -0
  17. package/dist/auth-detection.d.ts +49 -0
  18. package/dist/auth-detection.d.ts.map +1 -0
  19. package/dist/auth-detection.js +199 -0
  20. package/dist/auth-detection.js.map +1 -0
  21. package/dist/base-wrapper.d.ts +225 -0
  22. package/dist/base-wrapper.d.ts.map +1 -0
  23. package/dist/base-wrapper.js +572 -0
  24. package/dist/base-wrapper.js.map +1 -0
  25. package/dist/client.d.ts +254 -0
  26. package/dist/client.d.ts.map +1 -0
  27. package/dist/client.js +801 -0
  28. package/dist/client.js.map +1 -0
  29. package/dist/id-generator.d.ts +35 -0
  30. package/dist/id-generator.d.ts.map +1 -0
  31. package/dist/id-generator.js +60 -0
  32. package/dist/id-generator.js.map +1 -0
  33. package/dist/idle-detector.d.ts +110 -0
  34. package/dist/idle-detector.d.ts.map +1 -0
  35. package/dist/idle-detector.js +304 -0
  36. package/dist/idle-detector.js.map +1 -0
  37. package/dist/inbox.d.ts +37 -0
  38. package/dist/inbox.d.ts.map +1 -0
  39. package/dist/inbox.js +73 -0
  40. package/dist/inbox.js.map +1 -0
  41. package/dist/index.d.ts +37 -0
  42. package/dist/index.d.ts.map +1 -0
  43. package/dist/index.js +47 -0
  44. package/dist/index.js.map +1 -0
  45. package/dist/parser.d.ts +236 -0
  46. package/dist/parser.d.ts.map +1 -0
  47. package/dist/parser.js +1238 -0
  48. package/dist/parser.js.map +1 -0
  49. package/dist/prompt-composer.d.ts +67 -0
  50. package/dist/prompt-composer.d.ts.map +1 -0
  51. package/dist/prompt-composer.js +168 -0
  52. package/dist/prompt-composer.js.map +1 -0
  53. package/dist/relay-pty-orchestrator.d.ts +407 -0
  54. package/dist/relay-pty-orchestrator.d.ts.map +1 -0
  55. package/dist/relay-pty-orchestrator.js +1885 -0
  56. package/dist/relay-pty-orchestrator.js.map +1 -0
  57. package/dist/shared.d.ts +201 -0
  58. package/dist/shared.d.ts.map +1 -0
  59. package/dist/shared.js +341 -0
  60. package/dist/shared.js.map +1 -0
  61. package/dist/stuck-detector.d.ts +161 -0
  62. package/dist/stuck-detector.d.ts.map +1 -0
  63. package/dist/stuck-detector.js +402 -0
  64. package/dist/stuck-detector.js.map +1 -0
  65. package/dist/tmux-resolver.d.ts +55 -0
  66. package/dist/tmux-resolver.d.ts.map +1 -0
  67. package/dist/tmux-resolver.js +175 -0
  68. package/dist/tmux-resolver.js.map +1 -0
  69. package/dist/tmux-wrapper.d.ts +345 -0
  70. package/dist/tmux-wrapper.d.ts.map +1 -0
  71. package/dist/tmux-wrapper.js +1747 -0
  72. package/dist/tmux-wrapper.js.map +1 -0
  73. package/dist/trajectory-integration.d.ts +292 -0
  74. package/dist/trajectory-integration.d.ts.map +1 -0
  75. package/dist/trajectory-integration.js +979 -0
  76. package/dist/trajectory-integration.js.map +1 -0
  77. package/dist/wrapper-types.d.ts +41 -0
  78. package/dist/wrapper-types.d.ts.map +1 -0
  79. package/dist/wrapper-types.js +7 -0
  80. package/dist/wrapper-types.js.map +1 -0
  81. package/package.json +63 -0
  82. package/src/__fixtures__/claude-outputs.ts +471 -0
  83. package/src/__fixtures__/codex-outputs.ts +99 -0
  84. package/src/__fixtures__/gemini-outputs.ts +151 -0
  85. package/src/__fixtures__/index.ts +47 -0
  86. package/src/auth-detection.ts +244 -0
  87. package/src/base-wrapper.test.ts +540 -0
  88. package/src/base-wrapper.ts +741 -0
  89. package/src/client.test.ts +262 -0
  90. package/src/client.ts +984 -0
  91. package/src/id-generator.test.ts +71 -0
  92. package/src/id-generator.ts +69 -0
  93. package/src/idle-detector.test.ts +390 -0
  94. package/src/idle-detector.ts +370 -0
  95. package/src/inbox.test.ts +233 -0
  96. package/src/inbox.ts +89 -0
  97. package/src/index.ts +170 -0
  98. package/src/parser.regression.test.ts +251 -0
  99. package/src/parser.test.ts +1359 -0
  100. package/src/parser.ts +1477 -0
  101. package/src/prompt-composer.test.ts +219 -0
  102. package/src/prompt-composer.ts +231 -0
  103. package/src/relay-pty-orchestrator.test.ts +1027 -0
  104. package/src/relay-pty-orchestrator.ts +2270 -0
  105. package/src/shared.test.ts +221 -0
  106. package/src/shared.ts +454 -0
  107. package/src/stuck-detector.test.ts +303 -0
  108. package/src/stuck-detector.ts +511 -0
  109. package/src/tmux-resolver.test.ts +104 -0
  110. package/src/tmux-resolver.ts +207 -0
  111. package/src/tmux-wrapper.test.ts +316 -0
  112. package/src/tmux-wrapper.ts +2010 -0
  113. package/src/trajectory-detection.test.ts +151 -0
  114. package/src/trajectory-integration.ts +1261 -0
  115. package/src/wrapper-types.ts +45 -0
package/src/client.ts ADDED
@@ -0,0 +1,984 @@
1
+ /**
2
+ * Relay Client
3
+ * Connects to the daemon and handles message sending/receiving.
4
+ *
5
+ * @deprecated **MIGRATION REQUIRED** - This module will be removed in a future version.
6
+ *
7
+ * ## Migration Path
8
+ *
9
+ * Replace imports from `@agent-relay/wrapper` with `@agent-relay/sdk`:
10
+ *
11
+ * ```typescript
12
+ * // BEFORE (deprecated)
13
+ * import { RelayClient } from '@agent-relay/wrapper';
14
+ *
15
+ * // AFTER (recommended)
16
+ * import { RelayClient } from '@agent-relay/sdk';
17
+ * ```
18
+ *
19
+ * The `@agent-relay/sdk` provides the same API with:
20
+ * - Identical method signatures
21
+ * - Same configuration options
22
+ * - Compatible event handlers
23
+ *
24
+ * ## Timeline
25
+ *
26
+ * - Current: Deprecation warning on first use
27
+ * - Next minor: TypeScript deprecation errors
28
+ * - Next major: This module will be removed
29
+ *
30
+ * ## Internal Use Only
31
+ *
32
+ * This client is retained only for internal daemon/wrapper integration.
33
+ * External consumers should always use `@agent-relay/sdk`.
34
+ *
35
+ * Optimizations:
36
+ * - Monotonic ID generation (faster than UUID)
37
+ * - Write coalescing (batch socket writes)
38
+ * - Circular dedup cache (O(1) eviction)
39
+ */
40
+
41
+ import net from 'node:net';
42
+ import { randomUUID } from 'node:crypto';
43
+ import { generateId } from './id-generator.js';
44
+ // Import types from SDK (re-exported via protocol for compatibility)
45
+ import {
46
+ type Envelope,
47
+ type HelloPayload,
48
+ type WelcomePayload,
49
+ type SendPayload,
50
+ type SendMeta,
51
+ type SendEnvelope,
52
+ type DeliverEnvelope,
53
+ type AckPayload,
54
+ type ErrorPayload,
55
+ type PayloadKind,
56
+ type SpeakOnTrigger,
57
+ type LogPayload,
58
+ type EntityType,
59
+ PROTOCOL_VERSION,
60
+ } from '@agent-relay/protocol/types';
61
+ import type {
62
+ ChannelMessagePayload,
63
+ ChannelJoinEnvelope,
64
+ ChannelLeaveEnvelope,
65
+ ChannelMessageEnvelope,
66
+ MessageAttachment,
67
+ } from '@agent-relay/protocol/channels';
68
+ import { encodeFrameLegacy, FrameParser } from '@agent-relay/protocol/framing';
69
+ import { DEFAULT_SOCKET_PATH } from '@agent-relay/config/relay-config';
70
+
71
+ export type ClientState = 'DISCONNECTED' | 'CONNECTING' | 'HANDSHAKING' | 'READY' | 'BACKOFF';
72
+
73
+ export interface SyncOptions {
74
+ timeoutMs?: number;
75
+ kind?: PayloadKind;
76
+ data?: Record<string, unknown>;
77
+ thread?: string;
78
+ }
79
+
80
+ export interface ClientConfig {
81
+ socketPath: string;
82
+ agentName: string;
83
+ /** Entity type: 'agent' (default) or 'user' for human users */
84
+ entityType?: EntityType;
85
+ /** Optional CLI identifier to surface to the dashboard */
86
+ cli?: string;
87
+ /** Optional program identifier (e.g., 'claude', 'gpt-4o') */
88
+ program?: string;
89
+ /** Optional model identifier (e.g., 'claude-3-opus-2024-xx') */
90
+ model?: string;
91
+ /** Optional task description for registry/dashboard */
92
+ task?: string;
93
+ /** Optional working directory to surface in registry/dashboard */
94
+ workingDirectory?: string;
95
+ /** Display name for human users */
96
+ displayName?: string;
97
+ /** Avatar URL for human users */
98
+ avatarUrl?: string;
99
+ /** Suppress client-side console logging */
100
+ quiet?: boolean;
101
+ reconnect: boolean;
102
+ maxReconnectAttempts: number;
103
+ reconnectDelayMs: number;
104
+ reconnectMaxDelayMs: number;
105
+ /** Internal flag to suppress deprecation warning (for wrapper internal use only) */
106
+ _internal?: boolean;
107
+ }
108
+
109
+ const DEFAULT_CLIENT_CONFIG: ClientConfig = {
110
+ socketPath: DEFAULT_SOCKET_PATH,
111
+ agentName: 'agent',
112
+ cli: undefined,
113
+ quiet: false,
114
+ reconnect: true,
115
+ maxReconnectAttempts: 10,
116
+ reconnectDelayMs: 100,
117
+ reconnectMaxDelayMs: 30000,
118
+ };
119
+
120
+ /**
121
+ * Circular buffer for O(1) deduplication with bounded memory.
122
+ */
123
+ class CircularDedupeCache {
124
+ private ids: Set<string> = new Set();
125
+ private ring: string[];
126
+ private head = 0;
127
+ private readonly capacity: number;
128
+
129
+ constructor(capacity = 2000) {
130
+ this.capacity = capacity;
131
+ this.ring = new Array(capacity);
132
+ }
133
+
134
+ /** Returns true if duplicate (already seen) */
135
+ check(id: string): boolean {
136
+ if (this.ids.has(id)) return true;
137
+
138
+ // Evict oldest if at capacity
139
+ if (this.ids.size >= this.capacity) {
140
+ const oldest = this.ring[this.head];
141
+ if (oldest) this.ids.delete(oldest);
142
+ }
143
+
144
+ // Add new ID
145
+ this.ring[this.head] = id;
146
+ this.ids.add(id);
147
+ this.head = (this.head + 1) % this.capacity;
148
+
149
+ return false;
150
+ }
151
+
152
+ clear(): void {
153
+ this.ids.clear();
154
+ this.ring = new Array(this.capacity);
155
+ this.head = 0;
156
+ }
157
+ }
158
+
159
+ /**
160
+ * @deprecated Use `RelayClient` from `@agent-relay/sdk` instead.
161
+ * This class will be removed in a future major version.
162
+ */
163
+ export class RelayClient {
164
+ /** Track if deprecation warning has been shown (warn once per process) */
165
+ private static _deprecationWarningShown = false;
166
+
167
+ private config: ClientConfig;
168
+ private socket?: net.Socket;
169
+ private parser: FrameParser;
170
+
171
+ private _state: ClientState = 'DISCONNECTED';
172
+ private sessionId?: string;
173
+ private resumeToken?: string;
174
+ private reconnectAttempts = 0;
175
+ private reconnectDelay: number;
176
+ private reconnectTimer?: NodeJS.Timeout;
177
+ private _destroyed = false;
178
+
179
+ // Circular dedup cache (O(1) eviction vs O(n) array shift)
180
+ private dedupeCache = new CircularDedupeCache(2000);
181
+
182
+ // Write coalescing: batch multiple writes into single syscall
183
+ private writeQueue: Buffer[] = [];
184
+ private writeScheduled = false;
185
+
186
+ private pendingSyncAcks: Map<string, { resolve: (ack: AckPayload) => void; reject: (err: Error) => void; timeoutHandle: NodeJS.Timeout }> = new Map();
187
+
188
+ // Event handlers
189
+ /**
190
+ * Handler for incoming messages.
191
+ * @param from - The sender agent name
192
+ * @param payload - The message payload
193
+ * @param messageId - Unique message ID
194
+ * @param meta - Optional message metadata
195
+ * @param originalTo - Original 'to' field from sender (e.g., '*' for broadcasts)
196
+ */
197
+ onMessage?: (from: string, payload: SendPayload, messageId: string, meta?: SendMeta, originalTo?: string) => void;
198
+ /**
199
+ * Callback for channel messages.
200
+ * @param from - Sender name
201
+ * @param channel - Channel name
202
+ * @param body - Message content
203
+ * @param envelope - Full envelope for additional data
204
+ */
205
+ onChannelMessage?: (from: string, channel: string, body: string, envelope: Envelope<ChannelMessagePayload>) => void;
206
+ onStateChange?: (state: ClientState) => void;
207
+ onError?: (error: Error) => void;
208
+
209
+ constructor(config: Partial<ClientConfig> = {}) {
210
+ // Show deprecation warning once per process (skip for internal wrapper usage)
211
+ if (!RelayClient._deprecationWarningShown && !config._internal) {
212
+ RelayClient._deprecationWarningShown = true;
213
+ console.warn(
214
+ '\x1b[33m[DEPRECATION WARNING]\x1b[0m RelayClient from @agent-relay/wrapper is deprecated.\n' +
215
+ ' Migrate to @agent-relay/sdk:\n' +
216
+ ' import { RelayClient } from \'@agent-relay/sdk\';\n' +
217
+ ' This module will be removed in a future major version.'
218
+ );
219
+ }
220
+
221
+ this.config = { ...DEFAULT_CLIENT_CONFIG, ...config };
222
+ this.parser = new FrameParser();
223
+ this.parser.setLegacyMode(true); // Use 4-byte header for backwards compatibility
224
+ this.reconnectDelay = this.config.reconnectDelayMs;
225
+ }
226
+
227
+ get state(): ClientState {
228
+ return this._state;
229
+ }
230
+
231
+ get agentName(): string {
232
+ return this.config.agentName;
233
+ }
234
+
235
+ /** Get the session ID assigned by the server */
236
+ get currentSessionId(): string | undefined {
237
+ return this.sessionId;
238
+ }
239
+
240
+ /**
241
+ * Connect to the relay daemon.
242
+ */
243
+ connect(): Promise<void> {
244
+ if (this._state !== 'DISCONNECTED' && this._state !== 'BACKOFF') {
245
+ return Promise.resolve();
246
+ }
247
+
248
+ return new Promise((resolve, reject) => {
249
+ let settled = false;
250
+ const settleResolve = (): void => {
251
+ if (settled) return;
252
+ settled = true;
253
+ resolve();
254
+ };
255
+ const settleReject = (err: Error): void => {
256
+ if (settled) return;
257
+ settled = true;
258
+ reject(err);
259
+ };
260
+
261
+ this.setState('CONNECTING');
262
+
263
+ this.socket = net.createConnection(this.config.socketPath, () => {
264
+ this.setState('HANDSHAKING');
265
+ this.sendHello();
266
+ });
267
+
268
+ this.socket.on('data', (data) => this.handleData(data));
269
+
270
+ this.socket.on('close', () => {
271
+ this.handleDisconnect();
272
+ });
273
+
274
+ this.socket.on('error', (err) => {
275
+ if (this._state === 'CONNECTING') {
276
+ settleReject(err);
277
+ }
278
+ this.handleError(err);
279
+ });
280
+
281
+ // Wait for WELCOME
282
+ const checkReady = setInterval(() => {
283
+ if (this._state === 'READY') {
284
+ clearInterval(checkReady);
285
+ clearTimeout(timeout);
286
+ settleResolve();
287
+ }
288
+ }, 10);
289
+
290
+ // Timeout
291
+ const timeout = setTimeout(() => {
292
+ if (this._state !== 'READY') {
293
+ clearInterval(checkReady);
294
+ this.socket?.destroy();
295
+ settleReject(new Error('Connection timeout'));
296
+ }
297
+ }, 5000);
298
+ });
299
+ }
300
+
301
+ /**
302
+ * Disconnect from the relay daemon.
303
+ */
304
+ disconnect(): void {
305
+ if (this.reconnectTimer) {
306
+ clearTimeout(this.reconnectTimer);
307
+ this.reconnectTimer = undefined;
308
+ }
309
+
310
+ if (this.socket) {
311
+ this.send({
312
+ v: PROTOCOL_VERSION,
313
+ type: 'BYE',
314
+ id: generateId(),
315
+ ts: Date.now(),
316
+ payload: {},
317
+ });
318
+ this.socket.end();
319
+ this.socket = undefined;
320
+ }
321
+
322
+ this.setState('DISCONNECTED');
323
+ }
324
+
325
+ /**
326
+ * Permanently destroy the client. Disconnects and prevents any reconnection.
327
+ */
328
+ destroy(): void {
329
+ this._destroyed = true;
330
+ this.disconnect();
331
+ }
332
+
333
+ /**
334
+ * Send a message to another agent.
335
+ * @param to - Target agent name or '*' for broadcast
336
+ * @param body - Message body
337
+ * @param kind - Message type (default: 'message')
338
+ * @param data - Optional structured data
339
+ * @param thread - Optional thread ID for grouping related messages
340
+ * @param meta - Optional message metadata (importance, replyTo, etc.)
341
+ */
342
+ sendMessage(to: string, body: string, kind: PayloadKind = 'message', data?: Record<string, unknown>, thread?: string, meta?: SendMeta): boolean {
343
+ if (this._state !== 'READY') {
344
+ return false;
345
+ }
346
+
347
+ const envelope: SendEnvelope = {
348
+ v: PROTOCOL_VERSION,
349
+ type: 'SEND',
350
+ id: generateId(),
351
+ ts: Date.now(),
352
+ to,
353
+ payload: {
354
+ kind,
355
+ body,
356
+ data,
357
+ thread,
358
+ },
359
+ payload_meta: meta,
360
+ };
361
+
362
+ return this.send(envelope);
363
+ }
364
+
365
+ /**
366
+ * Send an ACK for a delivered message.
367
+ */
368
+ sendAck(payload: AckPayload): boolean {
369
+ if (this._state !== 'READY') {
370
+ return false;
371
+ }
372
+
373
+ const envelope: Envelope<AckPayload> = {
374
+ v: PROTOCOL_VERSION,
375
+ type: 'ACK',
376
+ id: generateId(),
377
+ ts: Date.now(),
378
+ payload,
379
+ };
380
+
381
+ return this.send(envelope);
382
+ }
383
+
384
+ /**
385
+ * Send a message and wait for a correlated ACK response.
386
+ */
387
+ async sendAndWait(to: string, body: string, options: SyncOptions = {}): Promise<AckPayload> {
388
+ if (this._state !== 'READY') {
389
+ throw new Error('Client not ready');
390
+ }
391
+
392
+ const correlationId = randomUUID();
393
+ const timeoutMs = options.timeoutMs ?? 30000;
394
+ const kind = options.kind ?? 'message';
395
+
396
+ return new Promise<AckPayload>((resolve, reject) => {
397
+ const timeoutHandle = setTimeout(() => {
398
+ this.pendingSyncAcks.delete(correlationId);
399
+ reject(new Error(`ACK timeout after ${timeoutMs}ms`));
400
+ }, timeoutMs);
401
+
402
+ this.pendingSyncAcks.set(correlationId, { resolve, reject, timeoutHandle });
403
+
404
+ const envelope: SendEnvelope = {
405
+ v: PROTOCOL_VERSION,
406
+ type: 'SEND',
407
+ id: generateId(),
408
+ ts: Date.now(),
409
+ to,
410
+ payload: {
411
+ kind,
412
+ body,
413
+ data: options.data,
414
+ thread: options.thread,
415
+ },
416
+ payload_meta: {
417
+ sync: {
418
+ correlationId,
419
+ timeoutMs,
420
+ blocking: true,
421
+ },
422
+ },
423
+ };
424
+
425
+ const sent = this.send(envelope);
426
+ if (!sent) {
427
+ clearTimeout(timeoutHandle);
428
+ this.pendingSyncAcks.delete(correlationId);
429
+ reject(new Error('Failed to send message'));
430
+ }
431
+ });
432
+ }
433
+
434
+ /**
435
+ * Broadcast a message to all agents.
436
+ */
437
+ broadcast(body: string, kind: PayloadKind = 'message', data?: Record<string, unknown>): boolean {
438
+ return this.sendMessage('*', body, kind, data);
439
+ }
440
+
441
+ // =============================================================================
442
+ // Channel Operations
443
+ // =============================================================================
444
+
445
+ /**
446
+ * Join a channel.
447
+ * @param channel - Channel name (e.g., '#general', 'dm:alice:bob')
448
+ * @param displayName - Optional display name for this member
449
+ */
450
+ joinChannel(channel: string, displayName?: string): boolean {
451
+ if (this._state !== 'READY') {
452
+ return false;
453
+ }
454
+
455
+ const envelope: ChannelJoinEnvelope = {
456
+ v: PROTOCOL_VERSION,
457
+ type: 'CHANNEL_JOIN',
458
+ id: generateId(),
459
+ ts: Date.now(),
460
+ payload: {
461
+ channel,
462
+ displayName,
463
+ },
464
+ };
465
+
466
+ return this.send(envelope);
467
+ }
468
+
469
+ /**
470
+ * Admin join: Add any member to a channel (does not require member to be connected).
471
+ * Used by dashboard to sync channel memberships for agents.
472
+ * @param channel - Channel name (e.g., '#general')
473
+ * @param member - Name of the member to add
474
+ */
475
+ adminJoinChannel(channel: string, member: string): boolean {
476
+ if (this._state !== 'READY') {
477
+ return false;
478
+ }
479
+
480
+ const envelope: ChannelJoinEnvelope = {
481
+ v: PROTOCOL_VERSION,
482
+ type: 'CHANNEL_JOIN',
483
+ id: generateId(),
484
+ ts: Date.now(),
485
+ payload: {
486
+ channel,
487
+ member, // Admin mode: specify member to add
488
+ },
489
+ };
490
+
491
+ return this.send(envelope);
492
+ }
493
+
494
+ /**
495
+ * Leave a channel.
496
+ * @param channel - Channel name to leave
497
+ * @param reason - Optional reason for leaving
498
+ */
499
+ leaveChannel(channel: string, reason?: string): boolean {
500
+ if (this._state !== 'READY') return false;
501
+
502
+ const envelope: ChannelLeaveEnvelope = {
503
+ v: PROTOCOL_VERSION,
504
+ type: 'CHANNEL_LEAVE',
505
+ id: generateId(),
506
+ ts: Date.now(),
507
+ payload: {
508
+ channel,
509
+ reason,
510
+ },
511
+ };
512
+
513
+ return this.send(envelope);
514
+ }
515
+
516
+ /**
517
+ * Admin remove: Remove any member from a channel (does not require member to be connected).
518
+ * Used by dashboard to remove channel members.
519
+ * @param channel - Channel name (e.g., '#general')
520
+ * @param member - Name of the member to remove
521
+ */
522
+ adminRemoveMember(channel: string, member: string): boolean {
523
+ if (this._state !== 'READY') {
524
+ return false;
525
+ }
526
+
527
+ const envelope: ChannelLeaveEnvelope = {
528
+ v: PROTOCOL_VERSION,
529
+ type: 'CHANNEL_LEAVE',
530
+ id: generateId(),
531
+ ts: Date.now(),
532
+ payload: {
533
+ channel,
534
+ member, // Admin mode: specify member to remove
535
+ },
536
+ };
537
+
538
+ return this.send(envelope);
539
+ }
540
+
541
+ /**
542
+ * Send a message to a channel.
543
+ * @param channel - Channel name
544
+ * @param body - Message content
545
+ * @param options - Optional thread, mentions, attachments
546
+ */
547
+ sendChannelMessage(
548
+ channel: string,
549
+ body: string,
550
+ options?: {
551
+ thread?: string;
552
+ mentions?: string[];
553
+ attachments?: MessageAttachment[];
554
+ data?: Record<string, unknown>;
555
+ }
556
+ ): boolean {
557
+ if (this._state !== 'READY') {
558
+ return false;
559
+ }
560
+
561
+ const envelope: ChannelMessageEnvelope = {
562
+ v: PROTOCOL_VERSION,
563
+ type: 'CHANNEL_MESSAGE',
564
+ id: generateId(),
565
+ ts: Date.now(),
566
+ payload: {
567
+ channel,
568
+ body,
569
+ thread: options?.thread,
570
+ mentions: options?.mentions,
571
+ attachments: options?.attachments,
572
+ data: options?.data,
573
+ },
574
+ };
575
+
576
+ return this.send(envelope);
577
+ }
578
+
579
+ /**
580
+ * Subscribe to a topic.
581
+ */
582
+ subscribe(topic: string): boolean {
583
+ if (this._state !== 'READY') return false;
584
+
585
+ return this.send({
586
+ v: PROTOCOL_VERSION,
587
+ type: 'SUBSCRIBE',
588
+ id: generateId(),
589
+ ts: Date.now(),
590
+ topic,
591
+ payload: {},
592
+ });
593
+ }
594
+
595
+ /**
596
+ * Unsubscribe from a topic.
597
+ */
598
+ unsubscribe(topic: string): boolean {
599
+ if (this._state !== 'READY') return false;
600
+
601
+ return this.send({
602
+ v: PROTOCOL_VERSION,
603
+ type: 'UNSUBSCRIBE',
604
+ id: generateId(),
605
+ ts: Date.now(),
606
+ topic,
607
+ payload: {},
608
+ });
609
+ }
610
+
611
+ /**
612
+ * Bind this agent as a shadow to a primary agent.
613
+ * As a shadow, this agent will receive copies of messages to/from the primary.
614
+ * @param primaryAgent - The agent to shadow
615
+ * @param options - Shadow configuration options
616
+ */
617
+ bindAsShadow(
618
+ primaryAgent: string,
619
+ options: {
620
+ /** When this shadow should speak (default: ['EXPLICIT_ASK']) */
621
+ speakOn?: SpeakOnTrigger[];
622
+ /** Receive copies of messages TO the primary (default: true) */
623
+ receiveIncoming?: boolean;
624
+ /** Receive copies of messages FROM the primary (default: true) */
625
+ receiveOutgoing?: boolean;
626
+ } = {}
627
+ ): boolean {
628
+ if (this._state !== 'READY') return false;
629
+
630
+ return this.send({
631
+ v: PROTOCOL_VERSION,
632
+ type: 'SHADOW_BIND',
633
+ id: generateId(),
634
+ ts: Date.now(),
635
+ payload: {
636
+ primaryAgent,
637
+ speakOn: options.speakOn,
638
+ receiveIncoming: options.receiveIncoming,
639
+ receiveOutgoing: options.receiveOutgoing,
640
+ },
641
+ });
642
+ }
643
+
644
+ /**
645
+ * Unbind this agent from a primary agent (stop shadowing).
646
+ * @param primaryAgent - The agent to stop shadowing
647
+ */
648
+ unbindAsShadow(primaryAgent: string): boolean {
649
+ if (this._state !== 'READY') return false;
650
+
651
+ return this.send({
652
+ v: PROTOCOL_VERSION,
653
+ type: 'SHADOW_UNBIND',
654
+ id: generateId(),
655
+ ts: Date.now(),
656
+ payload: {
657
+ primaryAgent,
658
+ },
659
+ });
660
+ }
661
+
662
+
663
+ /**
664
+ * Send log/output data to the daemon for dashboard streaming.
665
+ * Used by daemon-connected agents (not spawned workers) to stream their output.
666
+ * @param data - The log/output data to send
667
+ * @returns true if sent successfully, false otherwise
668
+ */
669
+ sendLog(data: string): boolean {
670
+ if (this._state !== 'READY') {
671
+ return false;
672
+ }
673
+
674
+ const envelope: Envelope<LogPayload> = {
675
+ v: PROTOCOL_VERSION,
676
+ type: 'LOG',
677
+ id: generateId(),
678
+ ts: Date.now(),
679
+ payload: {
680
+ data,
681
+ timestamp: Date.now(),
682
+ },
683
+ };
684
+
685
+ return this.send(envelope);
686
+ }
687
+ private setState(state: ClientState): void {
688
+ this._state = state;
689
+ if (this.onStateChange) {
690
+ this.onStateChange(state);
691
+ }
692
+ }
693
+
694
+ private sendHello(): void {
695
+ const hello: Envelope<HelloPayload> = {
696
+ v: PROTOCOL_VERSION,
697
+ type: 'HELLO',
698
+ id: generateId(),
699
+ ts: Date.now(),
700
+ payload: {
701
+ agent: this.config.agentName,
702
+ entityType: this.config.entityType,
703
+ cli: this.config.cli,
704
+ program: this.config.program,
705
+ model: this.config.model,
706
+ task: this.config.task,
707
+ workingDirectory: this.config.workingDirectory,
708
+ displayName: this.config.displayName,
709
+ avatarUrl: this.config.avatarUrl,
710
+ capabilities: {
711
+ ack: true,
712
+ resume: true,
713
+ max_inflight: 256,
714
+ supports_topics: true,
715
+ },
716
+ session: this.resumeToken ? { resume_token: this.resumeToken } : undefined,
717
+ },
718
+ };
719
+
720
+ this.send(hello);
721
+ }
722
+
723
+ private send(envelope: Envelope): boolean {
724
+ if (!this.socket) return false;
725
+
726
+ try {
727
+ const frame = encodeFrameLegacy(envelope);
728
+ this.writeQueue.push(frame);
729
+
730
+ // Coalesce writes: schedule flush on next tick if not already scheduled
731
+ if (!this.writeScheduled) {
732
+ this.writeScheduled = true;
733
+ setImmediate(() => this.flushWrites());
734
+ }
735
+ return true;
736
+ } catch (err) {
737
+ this.handleError(err as Error);
738
+ return false;
739
+ }
740
+ }
741
+
742
+ /**
743
+ * Flush all queued writes in a single syscall.
744
+ */
745
+ private flushWrites(): void {
746
+ this.writeScheduled = false;
747
+ if (this.writeQueue.length === 0 || !this.socket) return;
748
+
749
+ if (this.writeQueue.length === 1) {
750
+ // Single frame - write directly (no concat needed)
751
+ this.socket.write(this.writeQueue[0]);
752
+ } else {
753
+ // Multiple frames - batch into single write
754
+ this.socket.write(Buffer.concat(this.writeQueue));
755
+ }
756
+ this.writeQueue = [];
757
+ }
758
+
759
+ private handleData(data: Buffer): void {
760
+ try {
761
+ const frames = this.parser.push(data);
762
+ for (const frame of frames) {
763
+ this.processFrame(frame);
764
+ }
765
+ } catch (err) {
766
+ this.handleError(err as Error);
767
+ }
768
+ }
769
+
770
+ private processFrame(envelope: Envelope): void {
771
+ switch (envelope.type) {
772
+ case 'WELCOME':
773
+ this.handleWelcome(envelope as Envelope<WelcomePayload>);
774
+ break;
775
+
776
+ case 'DELIVER':
777
+ this.handleDeliver(envelope as DeliverEnvelope);
778
+ break;
779
+
780
+ case 'CHANNEL_MESSAGE':
781
+ this.handleChannelMessage(envelope as Envelope<ChannelMessagePayload> & { from?: string });
782
+ break;
783
+
784
+ case 'PING':
785
+ this.handlePing(envelope);
786
+ break;
787
+
788
+ case 'ACK':
789
+ this.handleAck(envelope as Envelope<AckPayload>);
790
+ break;
791
+
792
+ case 'ERROR':
793
+ this.handleErrorFrame(envelope as Envelope<ErrorPayload>);
794
+ break;
795
+
796
+ case 'BUSY':
797
+ console.warn('[client] Server busy, backing off');
798
+ break;
799
+ }
800
+ }
801
+
802
+ private handleWelcome(envelope: Envelope<WelcomePayload>): void {
803
+ this.sessionId = envelope.payload.session_id;
804
+ this.resumeToken = envelope.payload.resume_token;
805
+ this.reconnectAttempts = 0;
806
+ this.reconnectDelay = this.config.reconnectDelayMs;
807
+ this.setState('READY');
808
+ if (!this.config.quiet) {
809
+ console.log(`[client] Connected as ${this.config.agentName} (session: ${this.sessionId})`);
810
+ }
811
+ }
812
+
813
+ private handleDeliver(envelope: DeliverEnvelope): void {
814
+ // Send ACK
815
+ this.send({
816
+ v: PROTOCOL_VERSION,
817
+ type: 'ACK',
818
+ id: generateId(),
819
+ ts: Date.now(),
820
+ payload: {
821
+ ack_id: envelope.id,
822
+ seq: envelope.delivery.seq,
823
+ },
824
+ });
825
+
826
+ const duplicate = this.markDelivered(envelope.id);
827
+ if (duplicate) {
828
+ return;
829
+ }
830
+
831
+ // Notify handler
832
+ // Pass originalTo from delivery info so handlers know if this was a broadcast
833
+ if (this.onMessage && envelope.from) {
834
+ this.onMessage(envelope.from, envelope.payload, envelope.id, envelope.payload_meta, envelope.delivery.originalTo);
835
+ }
836
+ }
837
+
838
+ private handleAck(envelope: Envelope<AckPayload>): void {
839
+ const correlationId = envelope.payload.correlationId;
840
+ if (!correlationId) return;
841
+
842
+ const pending = this.pendingSyncAcks.get(correlationId);
843
+ if (!pending) return;
844
+
845
+ clearTimeout(pending.timeoutHandle);
846
+ this.pendingSyncAcks.delete(correlationId);
847
+ pending.resolve(envelope.payload);
848
+ }
849
+
850
+ private handleChannelMessage(envelope: Envelope<ChannelMessagePayload> & { from?: string }): void {
851
+ if (!this.config.quiet) {
852
+ console.log(`[client] handleChannelMessage: from=${envelope.from}, channel=${envelope.payload.channel}`);
853
+ }
854
+
855
+ const duplicate = this.markDelivered(envelope.id);
856
+ if (duplicate) {
857
+ if (!this.config.quiet) {
858
+ console.log(`[client] handleChannelMessage: duplicate message ${envelope.id}, skipping`);
859
+ }
860
+ return;
861
+ }
862
+
863
+ // Notify channel message handler
864
+ if (this.onChannelMessage && envelope.from) {
865
+ if (!this.config.quiet) {
866
+ console.log(`[client] Calling onChannelMessage callback`);
867
+ }
868
+ this.onChannelMessage(
869
+ envelope.from,
870
+ envelope.payload.channel,
871
+ envelope.payload.body,
872
+ envelope as Envelope<ChannelMessagePayload>
873
+ );
874
+ } else if (!this.config.quiet) {
875
+ console.log(`[client] No onChannelMessage handler set (handler=${!!this.onChannelMessage}, from=${envelope.from})`);
876
+ }
877
+
878
+ // Also call onMessage for backwards compatibility
879
+ // Convert to SendPayload format (channel is passed as 5th argument, not in payload)
880
+ if (this.onMessage && envelope.from) {
881
+ const sendPayload: SendPayload = {
882
+ kind: 'message',
883
+ body: envelope.payload.body,
884
+ data: {
885
+ _isChannelMessage: true,
886
+ _channel: envelope.payload.channel,
887
+ _mentions: envelope.payload.mentions,
888
+ },
889
+ thread: envelope.payload.thread,
890
+ };
891
+ this.onMessage(envelope.from, sendPayload, envelope.id, undefined, envelope.payload.channel);
892
+ }
893
+ }
894
+
895
+ private handlePing(envelope: Envelope): void {
896
+ this.send({
897
+ v: PROTOCOL_VERSION,
898
+ type: 'PONG',
899
+ id: generateId(),
900
+ ts: Date.now(),
901
+ payload: (envelope.payload as { nonce?: string }) ?? {},
902
+ });
903
+ }
904
+
905
+ private handleErrorFrame(envelope: Envelope<ErrorPayload>): void {
906
+ console.error('[client] Server error:', envelope.payload);
907
+
908
+ if (envelope.payload.code === 'RESUME_TOO_OLD') {
909
+ if (this.resumeToken) {
910
+ console.warn('[client] Resume token rejected, clearing and requesting new session');
911
+ }
912
+ // Clear resume token so next HELLO starts a fresh session instead of looping on an invalid token
913
+ this.resumeToken = undefined;
914
+ this.sessionId = undefined;
915
+ }
916
+ }
917
+
918
+ private handleDisconnect(): void {
919
+ this.parser.reset();
920
+ this.socket = undefined;
921
+ this.rejectPendingSyncAcks(new Error('Disconnected while awaiting ACK'));
922
+
923
+ // Don't reconnect if permanently destroyed
924
+ if (this._destroyed) {
925
+ this.setState('DISCONNECTED');
926
+ return;
927
+ }
928
+
929
+ if (this.config.reconnect && this.reconnectAttempts < this.config.maxReconnectAttempts) {
930
+ this.scheduleReconnect();
931
+ } else {
932
+ this.setState('DISCONNECTED');
933
+ if (this.reconnectAttempts >= this.config.maxReconnectAttempts) {
934
+ console.error(
935
+ `[client] Max reconnect attempts reached (${this.config.maxReconnectAttempts}), giving up`
936
+ );
937
+ }
938
+ }
939
+ }
940
+
941
+ private handleError(error: Error): void {
942
+ console.error('[client] Error:', error.message);
943
+ if (this.onError) {
944
+ this.onError(error);
945
+ }
946
+ }
947
+
948
+ private rejectPendingSyncAcks(error: Error): void {
949
+ for (const [correlationId, pending] of this.pendingSyncAcks.entries()) {
950
+ clearTimeout(pending.timeoutHandle);
951
+ pending.reject(error);
952
+ this.pendingSyncAcks.delete(correlationId);
953
+ }
954
+ }
955
+
956
+ private scheduleReconnect(): void {
957
+ this.setState('BACKOFF');
958
+ this.reconnectAttempts++;
959
+
960
+ // Exponential backoff with jitter
961
+ const jitter = Math.random() * 0.3 + 0.85; // 0.85 - 1.15
962
+ const delay = Math.min(this.reconnectDelay * jitter, this.config.reconnectMaxDelayMs);
963
+ this.reconnectDelay *= 2;
964
+
965
+ if (!this.config.quiet) {
966
+ console.log(`[client] Reconnecting in ${Math.round(delay)}ms (attempt ${this.reconnectAttempts})`);
967
+ }
968
+
969
+ this.reconnectTimer = setTimeout(() => {
970
+ this.connect().catch(() => {
971
+ // Will trigger another reconnect
972
+ });
973
+ }, delay);
974
+ }
975
+
976
+ /**
977
+ * Check if message was already delivered (deduplication).
978
+ * Uses circular buffer for O(1) eviction.
979
+ * @returns true if the message has already been seen.
980
+ */
981
+ private markDelivered(id: string): boolean {
982
+ return this.dedupeCache.check(id);
983
+ }
984
+ }