@agentuity/core 2.0.10 → 2.0.12

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 (110) hide show
  1. package/dist/services/api.d.ts +1 -1
  2. package/dist/services/api.d.ts.map +1 -1
  3. package/dist/services/api.js +4 -6
  4. package/dist/services/api.js.map +1 -1
  5. package/dist/services/coder/agents.d.ts +172 -0
  6. package/dist/services/coder/agents.d.ts.map +1 -0
  7. package/dist/services/coder/agents.js +77 -0
  8. package/dist/services/coder/agents.js.map +1 -0
  9. package/dist/services/coder/api-reference.d.ts.map +1 -1
  10. package/dist/services/coder/api-reference.js +459 -39
  11. package/dist/services/coder/api-reference.js.map +1 -1
  12. package/dist/services/coder/client.d.ts +47 -1
  13. package/dist/services/coder/client.d.ts.map +1 -1
  14. package/dist/services/coder/client.js +94 -1
  15. package/dist/services/coder/client.js.map +1 -1
  16. package/dist/services/coder/close-codes.d.ts +76 -0
  17. package/dist/services/coder/close-codes.d.ts.map +1 -0
  18. package/dist/services/coder/close-codes.js +77 -0
  19. package/dist/services/coder/close-codes.js.map +1 -0
  20. package/dist/services/coder/index.d.ts +10 -3
  21. package/dist/services/coder/index.d.ts.map +1 -1
  22. package/dist/services/coder/index.js +6 -1
  23. package/dist/services/coder/index.js.map +1 -1
  24. package/dist/services/coder/protocol.d.ts +2225 -0
  25. package/dist/services/coder/protocol.d.ts.map +1 -0
  26. package/dist/services/coder/protocol.js +1122 -0
  27. package/dist/services/coder/protocol.js.map +1 -0
  28. package/dist/services/coder/sessions.d.ts +31 -0
  29. package/dist/services/coder/sessions.d.ts.map +1 -1
  30. package/dist/services/coder/sessions.js +40 -7
  31. package/dist/services/coder/sessions.js.map +1 -1
  32. package/dist/services/coder/sse.d.ts +255 -0
  33. package/dist/services/coder/sse.d.ts.map +1 -0
  34. package/dist/services/coder/sse.js +788 -0
  35. package/dist/services/coder/sse.js.map +1 -0
  36. package/dist/services/coder/types.d.ts +1578 -0
  37. package/dist/services/coder/types.d.ts.map +1 -1
  38. package/dist/services/coder/types.js +377 -1
  39. package/dist/services/coder/types.js.map +1 -1
  40. package/dist/services/coder/websocket.d.ts +358 -0
  41. package/dist/services/coder/websocket.d.ts.map +1 -0
  42. package/dist/services/coder/websocket.js +863 -0
  43. package/dist/services/coder/websocket.js.map +1 -0
  44. package/dist/services/oauth/types.d.ts +10 -0
  45. package/dist/services/oauth/types.d.ts.map +1 -1
  46. package/dist/services/oauth/types.js +3 -0
  47. package/dist/services/oauth/types.js.map +1 -1
  48. package/dist/services/project/deploy.d.ts +1 -1
  49. package/dist/services/sandbox/api-reference.js +7 -7
  50. package/dist/services/sandbox/api-reference.js.map +1 -1
  51. package/dist/services/sandbox/client.d.ts +3 -2
  52. package/dist/services/sandbox/client.d.ts.map +1 -1
  53. package/dist/services/sandbox/client.js.map +1 -1
  54. package/dist/services/sandbox/create.d.ts +5 -0
  55. package/dist/services/sandbox/create.d.ts.map +1 -1
  56. package/dist/services/sandbox/create.js +8 -0
  57. package/dist/services/sandbox/create.js.map +1 -1
  58. package/dist/services/sandbox/get.d.ts +8 -4
  59. package/dist/services/sandbox/get.d.ts.map +1 -1
  60. package/dist/services/sandbox/get.js +28 -3
  61. package/dist/services/sandbox/get.js.map +1 -1
  62. package/dist/services/sandbox/getStatus.d.ts +2 -0
  63. package/dist/services/sandbox/getStatus.d.ts.map +1 -1
  64. package/dist/services/sandbox/getStatus.js +17 -1
  65. package/dist/services/sandbox/getStatus.js.map +1 -1
  66. package/dist/services/sandbox/index.d.ts +1 -1
  67. package/dist/services/sandbox/index.d.ts.map +1 -1
  68. package/dist/services/sandbox/list.d.ts +3 -0
  69. package/dist/services/sandbox/list.d.ts.map +1 -1
  70. package/dist/services/sandbox/list.js +5 -0
  71. package/dist/services/sandbox/list.js.map +1 -1
  72. package/dist/services/sandbox/pause.d.ts +17 -1
  73. package/dist/services/sandbox/pause.d.ts.map +1 -1
  74. package/dist/services/sandbox/pause.js +21 -3
  75. package/dist/services/sandbox/pause.js.map +1 -1
  76. package/dist/services/sandbox/run.d.ts +3 -2
  77. package/dist/services/sandbox/run.d.ts.map +1 -1
  78. package/dist/services/sandbox/run.js +145 -85
  79. package/dist/services/sandbox/run.js.map +1 -1
  80. package/dist/services/sandbox/types.d.ts +10 -4
  81. package/dist/services/sandbox/types.d.ts.map +1 -1
  82. package/dist/services/sandbox/types.js +10 -0
  83. package/dist/services/sandbox/types.js.map +1 -1
  84. package/dist/services/stream/namespaces.d.ts +2 -2
  85. package/dist/services/stream/namespaces.js +2 -2
  86. package/dist/services/stream/namespaces.js.map +1 -1
  87. package/package.json +2 -2
  88. package/src/services/api.ts +6 -7
  89. package/src/services/coder/agents.ts +148 -0
  90. package/src/services/coder/api-reference.ts +479 -43
  91. package/src/services/coder/client.ts +143 -0
  92. package/src/services/coder/close-codes.ts +83 -0
  93. package/src/services/coder/index.ts +32 -1
  94. package/src/services/coder/protocol.ts +1364 -0
  95. package/src/services/coder/sessions.ts +66 -10
  96. package/src/services/coder/sse.ts +955 -0
  97. package/src/services/coder/types.ts +462 -1
  98. package/src/services/coder/websocket.ts +1042 -0
  99. package/src/services/oauth/types.ts +3 -0
  100. package/src/services/sandbox/api-reference.ts +7 -7
  101. package/src/services/sandbox/client.ts +4 -4
  102. package/src/services/sandbox/create.ts +10 -0
  103. package/src/services/sandbox/get.ts +32 -3
  104. package/src/services/sandbox/getStatus.ts +20 -1
  105. package/src/services/sandbox/index.ts +1 -1
  106. package/src/services/sandbox/list.ts +5 -0
  107. package/src/services/sandbox/pause.ts +38 -4
  108. package/src/services/sandbox/run.ts +202 -108
  109. package/src/services/sandbox/types.ts +15 -2
  110. package/src/services/stream/namespaces.ts +2 -2
@@ -0,0 +1,1042 @@
1
+ /**
2
+ * WebSocket client for the Coder Hub real-time communication.
3
+ *
4
+ * Provides bidirectional communication between clients and the Coder Hub server,
5
+ * supporting multiple connection roles (lead, observer, controller) with
6
+ * automatic reconnection, heartbeat, and message queuing.
7
+ *
8
+ * @module coder/websocket
9
+ *
10
+ * @example Class-based API with callbacks
11
+ * ```typescript
12
+ * import { CoderHubWebSocketClient } from '@agentuity/core/coder';
13
+ *
14
+ * const client = new CoderHubWebSocketClient({
15
+ * apiKey: 'your-api-key',
16
+ * sessionId: 'session-123',
17
+ * role: 'observer',
18
+ * onInit: (init) => {
19
+ * console.log('Connected to session:', init.sessionId);
20
+ * console.log('Available agents:', init.agents);
21
+ * },
22
+ * onMessage: (msg) => {
23
+ * console.log('Received:', msg);
24
+ * },
25
+ * onStateChange: (state) => {
26
+ * console.log('Connection state:', state);
27
+ * },
28
+ * });
29
+ *
30
+ * client.connect();
31
+ *
32
+ * // Send a message
33
+ * client.send({
34
+ * type: 'ping',
35
+ * timestamp: Date.now(),
36
+ * });
37
+ *
38
+ * // Close when done
39
+ * client.close();
40
+ * ```
41
+ *
42
+ * @example Async iterator API
43
+ * ```typescript
44
+ * import { subscribeToCoderHub } from '@agentuity/core/coder';
45
+ *
46
+ * for await (const message of subscribeToCoderHub({
47
+ * sessionId: 'session-123',
48
+ * role: 'observer',
49
+ * })) {
50
+ * if (message.type === 'broadcast') {
51
+ * console.log('Event:', message.event, message.data);
52
+ * }
53
+ * }
54
+ * ```
55
+ */
56
+
57
+ import { z } from 'zod/v4';
58
+ import { StructuredError } from '../../error.ts';
59
+ import type { Logger } from '../../logger.ts';
60
+ import { APIClient } from '../api.ts';
61
+ import { getServiceUrls } from '../config.ts';
62
+ import { createMinimalLogger } from '../logger.ts';
63
+ import { getEnv } from '../env.ts';
64
+ import { isTerminalCloseCode } from './close-codes.ts';
65
+ import { discoverUrl } from './discover.ts';
66
+ import type {
67
+ ClientMessage,
68
+ CoderHubInitMessage,
69
+ CoderHubResponse,
70
+ ConnectionParams,
71
+ ServerMessage,
72
+ } from './protocol.ts';
73
+ import { CoderHubInitMessageSchema, parseServerMessage } from './protocol.ts';
74
+ import { normalizeCoderUrl } from './util.ts';
75
+
76
+ /**
77
+ * Connection state for the WebSocket client.
78
+ *
79
+ * - `'connecting'` - Initial WebSocket connection in progress
80
+ * - `'authenticating'` - WebSocket connected, sending auth message
81
+ * - `'connected'` - Authenticated and ready to send/receive messages
82
+ * - `'reconnecting'` - Reconnecting after disconnect
83
+ * - `'closed'` - Connection closed (manually or after max retries)
84
+ */
85
+ export type CoderHubWebSocketState =
86
+ | 'connecting'
87
+ | 'authenticating'
88
+ | 'connected'
89
+ | 'reconnecting'
90
+ | 'closed';
91
+
92
+ /**
93
+ * Options for the WebSocket client.
94
+ */
95
+ export const CoderHubWebSocketOptionsSchema = z.object({
96
+ /** API key for authentication. Falls back to AGENTUITY_SDK_KEY or AGENTUITY_CLI_KEY env vars. */
97
+ apiKey: z.string().optional().describe('API key for authentication'),
98
+ /** Organization ID for multi-tenant operations */
99
+ orgId: z.string().optional().describe('Organization ID for multi-tenant operations'),
100
+ /** WebSocket URL for the Coder Hub. Falls back to AGENTUITY_CODER_URL env var. */
101
+ url: z.string().optional().describe('WebSocket URL for the Coder Hub'),
102
+ /** Region used for Catalyst URL resolution when no explicit URL is provided */
103
+ region: z.string().optional().describe('Region used for Catalyst URL resolution'),
104
+ /** Session ID to connect to. For new sessions, leave empty and server will assign one. */
105
+ sessionId: z.string().optional().describe('Session ID to connect to'),
106
+ /**
107
+ * Connection role:
108
+ * - `'lead'` - Primary driver of the session (only one per session)
109
+ * - `'observer'` - Read-only observer (receive broadcasts)
110
+ * - `'controller'` - Bidirectional control (web UI)
111
+ */
112
+ role: z.enum(['lead', 'observer', 'controller']).optional().describe('Connection role'),
113
+ /** Agent role for sub-agent connections (e.g., 'scout', 'builder') */
114
+ agent: z.string().optional().describe('Agent role for sub-agent connections'),
115
+ /** Parent session ID for sub-agent connections */
116
+ parentSessionId: z.string().optional().describe('Parent session ID for sub-agent connections'),
117
+ /** Initial task for driver mode sessions */
118
+ task: z.string().optional().describe('Initial task for driver mode'),
119
+ /** Human-readable session label */
120
+ label: z.string().optional().describe('Session label'),
121
+ /** Observer event filters to request during the initial connection. */
122
+ subscribe: z
123
+ .array(z.string())
124
+ .optional()
125
+ .describe('Observer event filters to request during connection setup'),
126
+ /** Client origin (web, desktop, tui, sdk) */
127
+ origin: z.enum(['web', 'desktop', 'tui', 'sdk']).optional().describe('Client origin'),
128
+ /** Driver mode: 'rpc' for RPC bridge driver */
129
+ driverMode: z.enum(['rpc']).optional().describe('Driver mode'),
130
+ /** Driver instance ID for fencing stale reconnects */
131
+ driverInstanceId: z.string().optional().describe('Driver instance ID'),
132
+ /** Driver version for observability */
133
+ driverVersion: z.string().optional().describe('Driver version'),
134
+ /** Custom logger implementation */
135
+ logger: z.custom<Logger>().optional().describe('Custom logger implementation'),
136
+ /** Enable automatic reconnection on disconnect (default: true) */
137
+ autoReconnect: z.boolean().optional().describe('Enable automatic reconnection'),
138
+ /** Maximum reconnection attempts before giving up (default: 10) */
139
+ maxReconnectAttempts: z.number().optional().describe('Maximum reconnection attempts'),
140
+ /** Initial reconnection delay in milliseconds (default: 1000) */
141
+ reconnectDelayMs: z.number().optional().describe('Initial reconnection delay'),
142
+ /** Maximum reconnection delay in milliseconds (default: 30000) */
143
+ maxReconnectDelayMs: z.number().optional().describe('Maximum reconnection delay'),
144
+ /** Ping interval in milliseconds (default: 10000) */
145
+ heartbeatIntervalMs: z.number().optional().describe('Ping interval'),
146
+ /** Time without response before forcing reconnect in milliseconds (default: 30000) */
147
+ heartbeatTimeoutMs: z.number().optional().describe('Time without response before reconnect'),
148
+ /** Maximum queued messages while disconnected (default: 1000) */
149
+ maxMessageQueueSize: z
150
+ .number()
151
+ .optional()
152
+ .describe('Maximum queued messages while disconnected'),
153
+ /** Callback when connection is authenticated and ready */
154
+ onOpen: z.custom<() => void>().optional().describe('Callback when connection opens'),
155
+ /** Callback when connection closes */
156
+ onClose: z
157
+ .custom<(code: number, reason: string) => void>()
158
+ .optional()
159
+ .describe('Callback when connection closes'),
160
+ /** Callback on errors */
161
+ onError: z.custom<(error: Error) => void>().optional().describe('Callback on error'),
162
+ /** Callback for all incoming messages */
163
+ onMessage: z
164
+ .custom<(message: ServerMessage) => void>()
165
+ .optional()
166
+ .describe('Callback for incoming messages'),
167
+ /** Callback when init message is received (after authentication) */
168
+ onInit: z
169
+ .custom<(message: CoderHubInitMessage) => void>()
170
+ .optional()
171
+ .describe('Callback when init message received'),
172
+ /** Callback when connection state changes */
173
+ onStateChange: z
174
+ .custom<(state: CoderHubWebSocketState) => void>()
175
+ .optional()
176
+ .describe('Callback on state change'),
177
+ });
178
+ export type CoderHubWebSocketOptions = z.infer<typeof CoderHubWebSocketOptionsSchema>;
179
+
180
+ /**
181
+ * Error type for WebSocket operations.
182
+ *
183
+ * @example
184
+ * ```typescript
185
+ * try {
186
+ * await client.sendAndWait({ type: 'tool', name: 'read', ... });
187
+ * } catch (err) {
188
+ * if (err instanceof CoderHubWebSocketError) {
189
+ * if (err.code === 'response_timeout') {
190
+ * console.log('Server did not respond in time');
191
+ * }
192
+ * }
193
+ * }
194
+ * ```
195
+ */
196
+ export const CoderHubWebSocketError = StructuredError('CoderHubWebSocketError')<{
197
+ code:
198
+ | 'connection_failed'
199
+ | 'auth_failed'
200
+ | 'connection_error'
201
+ | 'max_reconnects_exceeded'
202
+ | 'send_while_disconnected'
203
+ | 'response_timeout'
204
+ | 'invalid_response';
205
+ sessionId?: string;
206
+ serverCode?: string;
207
+ serverMessage?: string;
208
+ serverMessageType?: 'connection_rejected' | 'protocol_error';
209
+ closeCode?: number;
210
+ closeReason?: string;
211
+ }>();
212
+ export type CoderHubWebSocketErrorInstance = InstanceType<typeof CoderHubWebSocketError>;
213
+
214
+ interface PendingRequest {
215
+ resolve: (response: CoderHubResponse) => void;
216
+ reject: (error: Error) => void;
217
+ timeout: ReturnType<typeof setTimeout>;
218
+ }
219
+
220
+ /**
221
+ * WebSocket client for real-time Coder Hub communication.
222
+ *
223
+ * Supports multiple connection roles and provides automatic reconnection,
224
+ * heartbeat management, and message queuing for resilient connections.
225
+ *
226
+ * @example Observer connection
227
+ * ```typescript
228
+ * const client = new CoderHubWebSocketClient({
229
+ * sessionId: 'session-123',
230
+ * role: 'observer',
231
+ * onMessage: (msg) => {
232
+ * if (msg.type === 'broadcast') {
233
+ * console.log('Event:', msg.event);
234
+ * }
235
+ * },
236
+ * });
237
+ * client.connect();
238
+ * ```
239
+ *
240
+ * @example Controller connection with sendAndWait
241
+ * ```typescript
242
+ * const client = new CoderHubWebSocketClient({
243
+ * sessionId: 'session-123',
244
+ * role: 'controller',
245
+ * });
246
+ * client.connect();
247
+ *
248
+ * // Wait for connection
249
+ * await new Promise(resolve => {
250
+ * client.onInit = () => resolve(undefined);
251
+ * });
252
+ *
253
+ * // Send a request and wait for response
254
+ * const response = await client.sendAndWait({
255
+ * type: 'event',
256
+ * event: 'steer',
257
+ * data: { direction: 'continue' },
258
+ * });
259
+ * console.log('Response:', response);
260
+ * ```
261
+ *
262
+ * @example Sub-agent connection
263
+ * ```typescript
264
+ * const client = new CoderHubWebSocketClient({
265
+ * role: 'observer', // Sub-agents connect as observers to parent
266
+ * agent: 'scout',
267
+ * parentSessionId: 'parent-session-456',
268
+ * });
269
+ * client.connect();
270
+ * ```
271
+ */
272
+ export class CoderHubWebSocketClient {
273
+ readonly #options: {
274
+ apiKey: string;
275
+ orgId: string;
276
+ url: string;
277
+ region: string;
278
+ sessionId: string;
279
+ role: 'lead' | 'observer' | 'controller';
280
+ agent: string;
281
+ parentSessionId: string;
282
+ task: string;
283
+ label: string;
284
+ subscribe: string[];
285
+ origin: 'web' | 'desktop' | 'tui' | 'sdk';
286
+ driverMode: 'rpc' | undefined;
287
+ driverInstanceId: string;
288
+ driverVersion: string;
289
+ logger: Logger;
290
+ autoReconnect: boolean;
291
+ maxReconnectAttempts: number;
292
+ reconnectDelayMs: number;
293
+ maxReconnectDelayMs: number;
294
+ heartbeatIntervalMs: number;
295
+ heartbeatTimeoutMs: number;
296
+ maxMessageQueueSize: number;
297
+ onOpen: () => void;
298
+ onClose: (code: number, reason: string) => void;
299
+ onError: (error: Error) => void;
300
+ onMessage: (message: ServerMessage) => void;
301
+ onInit: (message: CoderHubInitMessage) => void;
302
+ onStateChange: (state: CoderHubWebSocketState) => void;
303
+ };
304
+ #state: CoderHubWebSocketState = 'closed';
305
+ #ws: WebSocket | null = null;
306
+ #reconnectAttempts = 0;
307
+ #reconnectTimer: ReturnType<typeof setTimeout> | null = null;
308
+ #intentionallyClosed = false;
309
+ #authenticated = false;
310
+ #initMessage: CoderHubInitMessage | null = null;
311
+ #heartbeatTimer: ReturnType<typeof setTimeout> | null = null;
312
+ #lastInboundTimestamp = 0;
313
+ #messageQueue: ClientMessage[] = [];
314
+ #pendingRequests: Map<string, PendingRequest> = new Map();
315
+ #messageId = 0;
316
+ #sessionId: string | null = null;
317
+
318
+ constructor(options: CoderHubWebSocketOptions = {}) {
319
+ const apiKey =
320
+ options.apiKey ?? getEnv('AGENTUITY_SDK_KEY') ?? getEnv('AGENTUITY_CLI_KEY') ?? '';
321
+ this.#options = {
322
+ apiKey,
323
+ orgId: options.orgId ?? '',
324
+ url: options.url ?? '',
325
+ region: options.region ?? getEnv('AGENTUITY_REGION') ?? 'usc',
326
+ sessionId: options.sessionId ?? '',
327
+ role: options.role ?? 'observer',
328
+ agent: options.agent ?? '',
329
+ parentSessionId: options.parentSessionId ?? '',
330
+ task: options.task ?? '',
331
+ label: options.label ?? '',
332
+ subscribe: options.subscribe ?? [],
333
+ origin: options.origin ?? 'sdk',
334
+ driverMode: options.driverMode,
335
+ driverInstanceId: options.driverInstanceId ?? '',
336
+ driverVersion: options.driverVersion ?? '',
337
+ logger: options.logger ?? createMinimalLogger(),
338
+ autoReconnect: options.autoReconnect ?? true,
339
+ maxReconnectAttempts: options.maxReconnectAttempts ?? 10,
340
+ reconnectDelayMs: options.reconnectDelayMs ?? 1000,
341
+ maxReconnectDelayMs: options.maxReconnectDelayMs ?? 30000,
342
+ heartbeatIntervalMs: options.heartbeatIntervalMs ?? 10000,
343
+ heartbeatTimeoutMs: options.heartbeatTimeoutMs ?? 30000,
344
+ maxMessageQueueSize: options.maxMessageQueueSize ?? 1000,
345
+ onOpen: options.onOpen ?? (() => {}),
346
+ onClose: options.onClose ?? (() => {}),
347
+ onError: options.onError ?? (() => {}),
348
+ onMessage: options.onMessage ?? (() => {}),
349
+ onInit: options.onInit ?? (() => {}),
350
+ onStateChange: options.onStateChange ?? (() => {}),
351
+ };
352
+ }
353
+
354
+ /**
355
+ * The current connection state.
356
+ *
357
+ * @see CoderHubWebSocketState for state descriptions
358
+ */
359
+ get state(): CoderHubWebSocketState {
360
+ return this.#state;
361
+ }
362
+
363
+ /**
364
+ * The session ID for this connection.
365
+ *
366
+ * Returns the server-assigned session ID (from init message) if available,
367
+ * otherwise returns the session ID passed in options.
368
+ */
369
+ get sessionId(): string | undefined {
370
+ return this.#sessionId ?? this.#options.sessionId ?? undefined;
371
+ }
372
+
373
+ /**
374
+ * The init message received from the server after authentication.
375
+ *
376
+ * Contains session configuration, available agents, tools, and other metadata.
377
+ * Only available after successful authentication.
378
+ */
379
+ get initMessage(): CoderHubInitMessage | null {
380
+ return this.#initMessage;
381
+ }
382
+
383
+ /**
384
+ * Whether the client is currently connected and authenticated.
385
+ *
386
+ * Returns `true` only when state is 'connected' AND WebSocket is open.
387
+ */
388
+ get isConnected(): boolean {
389
+ return this.#state === 'connected' && this.#ws?.readyState === WebSocket.OPEN;
390
+ }
391
+
392
+ /**
393
+ * Establish the WebSocket connection and authenticate.
394
+ *
395
+ * If already connected or connecting, this is a no-op.
396
+ * Automatically reconnects on disconnection unless `close()` was called.
397
+ *
398
+ * The connection goes through these states:
399
+ * 1. `'connecting'` - WebSocket opening
400
+ * 2. `'authenticating'` - Sending auth message
401
+ * 3. `'connected'` - Received init message
402
+ */
403
+ connect(): void {
404
+ if (this.#state !== 'closed') {
405
+ return;
406
+ }
407
+ this.#intentionallyClosed = false;
408
+ if (this.#reconnectTimer !== null) {
409
+ clearTimeout(this.#reconnectTimer);
410
+ this.#reconnectTimer = null;
411
+ }
412
+ this.#connectInternal();
413
+ }
414
+
415
+ /**
416
+ * Close the WebSocket connection.
417
+ *
418
+ * After calling `close()`, you can call `connect()` again to reconnect.
419
+ * Any pending requests will be rejected with an error.
420
+ *
421
+ * @param code - Optional close code (default: 1000 for normal close)
422
+ * @param reason - Optional close reason string
423
+ */
424
+ close(code?: number, reason?: string): void {
425
+ this.#intentionallyClosed = true;
426
+ this.#clearTimers();
427
+ if (this.#ws) {
428
+ const ws = this.#ws;
429
+ ws.onopen = null;
430
+ ws.onmessage = null;
431
+ ws.onerror = null;
432
+ ws.onclose = null;
433
+ ws.close(code ?? 1000, reason ?? 'Client closed');
434
+ this.#ws = null;
435
+ }
436
+ this.#setState('closed');
437
+ this.#rejectAllPendingRequests('Connection closed');
438
+ }
439
+
440
+ /**
441
+ * Send a message to the server.
442
+ *
443
+ * If not connected, the message will be queued and sent when reconnected
444
+ * (up to `maxMessageQueueSize` messages). If the queue is full, an error
445
+ * is emitted via `onError`.
446
+ *
447
+ * @param message - The message to send
448
+ *
449
+ * @example
450
+ * ```typescript
451
+ * client.send({
452
+ * type: 'ping',
453
+ * timestamp: Date.now(),
454
+ * });
455
+ *
456
+ * client.send({
457
+ * type: 'session_entry',
458
+ * path: 'entries.jsonl',
459
+ * line: JSON.stringify({ type: 'message', content: 'Hello' }),
460
+ * });
461
+ * ```
462
+ */
463
+ send(message: ClientMessage): void {
464
+ if (!this.isConnected) {
465
+ if (this.#messageQueue.length < this.#options.maxMessageQueueSize) {
466
+ this.#messageQueue.push(message);
467
+ } else {
468
+ this.#options.onError(
469
+ new CoderHubWebSocketError({
470
+ message: 'Message queue full, dropping message',
471
+ code: 'send_while_disconnected',
472
+ sessionId: this.sessionId,
473
+ })
474
+ );
475
+ }
476
+ return;
477
+ }
478
+ this.#ws!.send(JSON.stringify(message));
479
+ }
480
+
481
+ /**
482
+ * Send a message and wait for a response.
483
+ *
484
+ * Automatically adds a unique `id` to the message and waits for a
485
+ * response with matching `id`. Useful for request/response patterns
486
+ * like tool calls or RPC commands.
487
+ *
488
+ * @param message - The message to send (without `id` field)
489
+ * @param timeoutMs - Timeout in milliseconds (default: 30000)
490
+ * @returns Promise that resolves with the response
491
+ * @throws {CoderHubWebSocketError} If timeout exceeded or connection closed
492
+ *
493
+ * @example
494
+ * ```typescript
495
+ * try {
496
+ * const response = await client.sendAndWait({
497
+ * type: 'tool',
498
+ * name: 'read_file',
499
+ * toolCallId: 'call-123',
500
+ * params: { path: '/src/index.ts' },
501
+ * });
502
+ * console.log('Tool result:', response.actions);
503
+ * } catch (err) {
504
+ * if (err instanceof CoderHubWebSocketError && err.code === 'response_timeout') {
505
+ * console.log('Tool call timed out');
506
+ * }
507
+ * }
508
+ * ```
509
+ */
510
+ async sendAndWait(
511
+ message: Omit<ClientMessage, 'id'>,
512
+ timeoutMs = 30000
513
+ ): Promise<CoderHubResponse> {
514
+ const id = this.#nextId();
515
+ const fullMessage = { ...message, id } as ClientMessage;
516
+
517
+ return new Promise((resolve, reject) => {
518
+ const timeout = setTimeout(() => {
519
+ this.#pendingRequests.delete(id);
520
+ reject(
521
+ new CoderHubWebSocketError({
522
+ message: `Response timeout for request ${id}`,
523
+ code: 'response_timeout',
524
+ sessionId: this.sessionId,
525
+ })
526
+ );
527
+ }, timeoutMs);
528
+
529
+ this.#pendingRequests.set(id, { resolve, reject, timeout });
530
+ this.send(fullMessage);
531
+ });
532
+ }
533
+
534
+ #nextId(): string {
535
+ return `${Date.now()}-${++this.#messageId}`;
536
+ }
537
+
538
+ #buildHandshakeError(input: {
539
+ code: 'auth_failed' | 'connection_error';
540
+ message: string;
541
+ serverCode?: string;
542
+ serverMessage?: string;
543
+ serverMessageType?: 'connection_rejected' | 'protocol_error';
544
+ closeCode?: number;
545
+ closeReason?: string;
546
+ }): CoderHubWebSocketErrorInstance {
547
+ return new CoderHubWebSocketError({
548
+ code: input.code,
549
+ message: input.message,
550
+ sessionId: this.sessionId,
551
+ serverCode: input.serverCode,
552
+ serverMessage: input.serverMessage,
553
+ serverMessageType: input.serverMessageType,
554
+ closeCode: input.closeCode,
555
+ closeReason: input.closeReason,
556
+ });
557
+ }
558
+
559
+ #markReady(input?: {
560
+ initMessage?: CoderHubInitMessage;
561
+ firstMessage?: ServerMessage;
562
+ sendBootstrapReady?: boolean;
563
+ }): void {
564
+ this.#authenticated = true;
565
+ this.#initMessage = input?.initMessage ?? null;
566
+ this.#sessionId =
567
+ input?.initMessage?.sessionId ??
568
+ (input?.firstMessage && 'sessionId' in input.firstMessage
569
+ ? input.firstMessage.sessionId
570
+ : undefined) ??
571
+ this.#options.sessionId ??
572
+ null;
573
+ this.#reconnectAttempts = 0;
574
+ this.#setState('connected');
575
+ this.#startHeartbeat();
576
+ if (input?.initMessage) {
577
+ this.#options.onInit(input.initMessage);
578
+ }
579
+ if (input?.sendBootstrapReady && this.#ws?.readyState === WebSocket.OPEN) {
580
+ this.#ws.send(JSON.stringify({ type: 'bootstrap_ready' }));
581
+ }
582
+ this.#flushMessageQueue();
583
+ this.#options.onOpen();
584
+ if (input?.firstMessage) {
585
+ this.#options.onMessage(input.firstMessage);
586
+ }
587
+ }
588
+
589
+ #setState(state: CoderHubWebSocketState): void {
590
+ if (this.#state !== state) {
591
+ this.#state = state;
592
+ this.#options.onStateChange(state);
593
+ }
594
+ }
595
+
596
+ #clearTimers(): void {
597
+ if (this.#reconnectTimer !== null) {
598
+ clearTimeout(this.#reconnectTimer);
599
+ this.#reconnectTimer = null;
600
+ }
601
+ if (this.#heartbeatTimer !== null) {
602
+ clearTimeout(this.#heartbeatTimer);
603
+ this.#heartbeatTimer = null;
604
+ }
605
+ }
606
+
607
+ #rejectAllPendingRequests(reason: string): void {
608
+ for (const [, pending] of this.#pendingRequests) {
609
+ clearTimeout(pending.timeout);
610
+ pending.reject(
611
+ new CoderHubWebSocketError({
612
+ message: reason,
613
+ code: 'connection_error',
614
+ sessionId: this.sessionId,
615
+ })
616
+ );
617
+ }
618
+ this.#pendingRequests.clear();
619
+ }
620
+
621
+ async #buildWsUrl(): Promise<string> {
622
+ let baseUrl = this.#options.url;
623
+ if (!baseUrl) {
624
+ const envUrl = getEnv('AGENTUITY_CODER_URL');
625
+ if (envUrl) {
626
+ baseUrl = normalizeCoderUrl(envUrl);
627
+ } else {
628
+ const catalystUrl = getServiceUrls(this.#options.region).catalyst;
629
+ const headers: Record<string, string> = {};
630
+ if (this.#options.orgId) {
631
+ headers['x-agentuity-orgid'] = this.#options.orgId;
632
+ }
633
+ const catalystClient = new APIClient(
634
+ catalystUrl,
635
+ this.#options.logger,
636
+ this.#options.apiKey,
637
+ { headers }
638
+ );
639
+ baseUrl = await discoverUrl(catalystClient);
640
+ }
641
+ }
642
+
643
+ let wsUrl = baseUrl.replace(/^https:\/\//, 'wss://').replace(/^http:\/\//, 'ws://');
644
+ wsUrl = wsUrl.replace(/\/$/, '');
645
+ const path = wsUrl.includes('/api/ws') ? '' : '/api/ws';
646
+ wsUrl = `${wsUrl}${path}`;
647
+
648
+ const params = new URLSearchParams();
649
+ const connectionParams: ConnectionParams = {
650
+ sessionId: this.#sessionId ?? (this.#options.sessionId || undefined),
651
+ role: this.#options.role || undefined,
652
+ agent: this.#options.agent || undefined,
653
+ parent: this.#options.parentSessionId || undefined,
654
+ task: this.#options.task || undefined,
655
+ label: this.#options.label || undefined,
656
+ subscribe:
657
+ this.#options.subscribe.length > 0 ? this.#options.subscribe.join(',') : undefined,
658
+ orgId: this.#options.orgId || undefined,
659
+ origin: this.#options.origin || undefined,
660
+ driverMode: this.#options.driverMode || undefined,
661
+ driverInstanceId: this.#options.driverInstanceId || undefined,
662
+ driverVersion: this.#options.driverVersion || undefined,
663
+ };
664
+
665
+ for (const [key, value] of Object.entries(connectionParams)) {
666
+ if (value !== undefined && value !== '') {
667
+ params.set(key, String(value));
668
+ }
669
+ }
670
+ if (this.#options.apiKey) {
671
+ params.set('api_key', this.#options.apiKey);
672
+ }
673
+
674
+ const queryString = params.toString();
675
+ return queryString ? `${wsUrl}?${queryString}` : wsUrl;
676
+ }
677
+
678
+ async #connectInternal(): Promise<void> {
679
+ if (this.#intentionallyClosed) {
680
+ return;
681
+ }
682
+
683
+ this.#setState(this.#reconnectAttempts > 0 ? 'reconnecting' : 'connecting');
684
+
685
+ let wsUrl: string;
686
+ try {
687
+ wsUrl = await this.#buildWsUrl();
688
+ } catch (err) {
689
+ this.#setState('closed');
690
+ this.#options.onError(err as Error);
691
+ return;
692
+ }
693
+
694
+ try {
695
+ this.#ws = new WebSocket(wsUrl);
696
+ } catch (err) {
697
+ this.#setState('closed');
698
+ this.#options.onError(
699
+ new CoderHubWebSocketError({
700
+ message: `Failed to create WebSocket: ${err instanceof Error ? err.message : String(err)}`,
701
+ code: 'connection_failed',
702
+ sessionId: this.sessionId,
703
+ })
704
+ );
705
+ this.#scheduleReconnect();
706
+ return;
707
+ }
708
+
709
+ const ws = this.#ws;
710
+
711
+ ws.onopen = () => {
712
+ if (ws !== this.#ws) return;
713
+ this.#setState('authenticating');
714
+ if (this.#options.apiKey || this.#options.orgId) {
715
+ const bootstrapPayload: { authorization?: string; org_id?: string } = {};
716
+ if (this.#options.apiKey) {
717
+ bootstrapPayload.authorization = this.#options.apiKey;
718
+ }
719
+ if (this.#options.orgId) {
720
+ bootstrapPayload.org_id = this.#options.orgId;
721
+ }
722
+ ws.send(JSON.stringify(bootstrapPayload));
723
+ }
724
+ };
725
+
726
+ ws.onmessage = (event: MessageEvent) => {
727
+ if (ws !== this.#ws) return;
728
+ this.#lastInboundTimestamp = Date.now();
729
+ const raw = typeof event.data === 'string' ? event.data : String(event.data);
730
+
731
+ let parsed: unknown;
732
+ try {
733
+ parsed = JSON.parse(raw);
734
+ } catch {
735
+ this.#options.logger.debug('Failed to parse WebSocket message: %s', raw);
736
+ return;
737
+ }
738
+
739
+ if (!this.#authenticated) {
740
+ if (parsed && typeof parsed === 'object') {
741
+ const data = parsed as Record<string, unknown>;
742
+ if (data.error) {
743
+ this.#setState('closed');
744
+ this.#options.onError(
745
+ new CoderHubWebSocketError({
746
+ message: `Authentication failed: ${String(data.error)}`,
747
+ code: 'auth_failed',
748
+ sessionId: this.sessionId,
749
+ })
750
+ );
751
+ this.#intentionallyClosed = true;
752
+ ws.close(4401, 'Auth failed');
753
+ return;
754
+ }
755
+
756
+ // Handle protocol failure messages
757
+ const msg = data as { type?: unknown; code?: string; message?: string };
758
+ if (msg.type === 'connection_rejected' || msg.type === 'protocol_error') {
759
+ this.#setState('closed');
760
+ this.#options.onError(
761
+ this.#buildHandshakeError({
762
+ code: 'auth_failed',
763
+ message: `Connection rejected: ${msg.message ?? msg.code ?? 'Unknown error'}`,
764
+ serverCode: msg.code,
765
+ serverMessage: msg.message,
766
+ serverMessageType: msg.type,
767
+ })
768
+ );
769
+ this.#intentionallyClosed = true;
770
+ ws.close(4401, 'Auth failed');
771
+ return;
772
+ }
773
+ }
774
+
775
+ const initResult = CoderHubInitMessageSchema.safeParse(parsed);
776
+ if (initResult.success) {
777
+ const initMsg = initResult.data;
778
+ this.#markReady({
779
+ initMessage: initMsg,
780
+ sendBootstrapReady: this.#options.role === 'controller',
781
+ });
782
+ return;
783
+ }
784
+
785
+ if (this.#options.role === 'observer') {
786
+ const firstObserverMessage = parseServerMessage(parsed);
787
+ if (firstObserverMessage) {
788
+ this.#markReady({ firstMessage: firstObserverMessage });
789
+ }
790
+ }
791
+ return;
792
+ }
793
+
794
+ const message = parsed as ServerMessage;
795
+ this.#options.onMessage(message);
796
+
797
+ if ('type' in message) {
798
+ if (message.type === 'broadcast' || message.type === 'presence') {
799
+ return;
800
+ }
801
+ }
802
+
803
+ if ('id' in message && typeof message.id === 'string') {
804
+ const pending = this.#pendingRequests.get(message.id);
805
+ if (pending) {
806
+ clearTimeout(pending.timeout);
807
+ this.#pendingRequests.delete(message.id);
808
+ if ('actions' in message) {
809
+ pending.resolve(message as CoderHubResponse);
810
+ } else {
811
+ pending.reject(
812
+ new CoderHubWebSocketError({
813
+ message: `Malformed response for request ${message.id}: missing actions`,
814
+ code: 'invalid_response',
815
+ sessionId: this.sessionId,
816
+ })
817
+ );
818
+ }
819
+ }
820
+ }
821
+ };
822
+
823
+ ws.onerror = () => {
824
+ if (ws !== this.#ws) return;
825
+ this.#options.onError(
826
+ new CoderHubWebSocketError({
827
+ message: 'WebSocket connection error',
828
+ code: 'connection_error',
829
+ sessionId: this.sessionId,
830
+ })
831
+ );
832
+ };
833
+
834
+ ws.onclose = (event: CloseEvent) => {
835
+ if (ws !== this.#ws) return;
836
+ this.#ws = null;
837
+ this.#clearTimers();
838
+ this.#setState('closed');
839
+
840
+ const wasAuthenticated = this.#authenticated;
841
+ const hadTerminalError = this.#intentionallyClosed;
842
+ const terminalClose = isTerminalCloseCode(event.code);
843
+
844
+ // Clear auth state for clean reconnect
845
+ this.#authenticated = false;
846
+ this.#initMessage = null;
847
+
848
+ if (terminalClose) {
849
+ this.#intentionallyClosed = true;
850
+ }
851
+
852
+ if (!wasAuthenticated && terminalClose && !hadTerminalError) {
853
+ this.#options.onError(
854
+ this.#buildHandshakeError({
855
+ code: 'connection_error',
856
+ message: `WebSocket closed before connection was ready (code ${event.code})${
857
+ event.reason ? `: ${event.reason}` : ''
858
+ }`,
859
+ closeCode: event.code,
860
+ closeReason: event.reason || undefined,
861
+ })
862
+ );
863
+ }
864
+
865
+ this.#options.onClose(event.code, event.reason);
866
+
867
+ if (!this.#intentionallyClosed) {
868
+ this.#scheduleReconnect();
869
+ }
870
+ };
871
+ }
872
+
873
+ #scheduleReconnect(): void {
874
+ if (this.#intentionallyClosed || !this.#options.autoReconnect) {
875
+ return;
876
+ }
877
+
878
+ if (this.#reconnectAttempts >= this.#options.maxReconnectAttempts) {
879
+ this.#options.onError(
880
+ new CoderHubWebSocketError({
881
+ message: `Exceeded maximum reconnection attempts (${this.#options.maxReconnectAttempts})`,
882
+ code: 'max_reconnects_exceeded',
883
+ sessionId: this.sessionId,
884
+ })
885
+ );
886
+ return;
887
+ }
888
+
889
+ const baseDelay = this.#options.reconnectDelayMs * 2 ** this.#reconnectAttempts;
890
+ const jitter = 0.5 + Math.random() * 0.5;
891
+ const delay = Math.min(Math.floor(baseDelay * jitter), this.#options.maxReconnectDelayMs);
892
+
893
+ this.#reconnectAttempts++;
894
+ this.#reconnectTimer = setTimeout(() => {
895
+ this.#reconnectTimer = null;
896
+ this.#connectInternal();
897
+ }, delay);
898
+ }
899
+
900
+ #startHeartbeat(): void {
901
+ this.#heartbeatTimer = setInterval(() => {
902
+ if (!this.isConnected) {
903
+ return;
904
+ }
905
+
906
+ const elapsed = Date.now() - this.#lastInboundTimestamp;
907
+ if (elapsed > this.#options.heartbeatTimeoutMs) {
908
+ this.#options.logger.debug('Heartbeat timeout, forcing reconnect');
909
+ this.#ws?.close(1000, 'Heartbeat timeout');
910
+ return;
911
+ }
912
+
913
+ this.send({
914
+ type: 'ping',
915
+ timestamp: Date.now(),
916
+ });
917
+ }, this.#options.heartbeatIntervalMs);
918
+ }
919
+
920
+ #flushMessageQueue(): void {
921
+ while (this.#messageQueue.length > 0 && this.isConnected) {
922
+ const message = this.#messageQueue.shift()!;
923
+ this.#ws!.send(JSON.stringify(message));
924
+ }
925
+ }
926
+ }
927
+
928
+ /**
929
+ * Subscribe to a Coder Hub session via WebSocket using async iteration.
930
+ *
931
+ * Returns an async iterator that yields server messages as they arrive.
932
+ * The connection is automatically managed (auth, reconnection, cleanup).
933
+ *
934
+ * @param options - Configuration for the WebSocket connection
935
+ * @yields Server messages as they arrive
936
+ * @throws {CoderHubWebSocketError} If connection fails or max reconnection attempts exceeded
937
+ *
938
+ * @example Basic usage
939
+ * ```typescript
940
+ * import { subscribeToCoderHub } from '@agentuity/core/coder';
941
+ *
942
+ * for await (const message of subscribeToCoderHub({
943
+ * sessionId: 'session-123',
944
+ * role: 'observer',
945
+ * })) {
946
+ * switch (message.type) {
947
+ * case 'broadcast':
948
+ * console.log('Event:', message.event);
949
+ * break;
950
+ * case 'presence':
951
+ * console.log('Participant:', message.participant);
952
+ * break;
953
+ * }
954
+ * }
955
+ * ```
956
+ *
957
+ * @example With error handling
958
+ * ```typescript
959
+ * try {
960
+ * for await (const message of subscribeToCoderHub({ sessionId: 'session-123' })) {
961
+ * console.log(message);
962
+ * }
963
+ * } catch (err) {
964
+ * if (err instanceof CoderHubWebSocketError) {
965
+ * console.log('WebSocket error:', err.code);
966
+ * }
967
+ * }
968
+ * ```
969
+ */
970
+ export async function* subscribeToCoderHub(
971
+ options: CoderHubWebSocketOptions
972
+ ): AsyncGenerator<ServerMessage, void, unknown> {
973
+ const buffer: ServerMessage[] = [];
974
+ let resolve: (() => void) | null = null;
975
+ let done = false;
976
+ let terminalError: Error | null = null;
977
+
978
+ const wake = () => {
979
+ if (resolve) {
980
+ resolve();
981
+ resolve = null;
982
+ }
983
+ };
984
+
985
+ const client = new CoderHubWebSocketClient({
986
+ ...options,
987
+ onMessage: (message) => {
988
+ buffer.push(message);
989
+ wake();
990
+ },
991
+ onError: (error) => {
992
+ if (
993
+ error instanceof CoderHubWebSocketError &&
994
+ (error.code === 'max_reconnects_exceeded' ||
995
+ error.code === 'auth_failed' ||
996
+ (error.code === 'connection_error' &&
997
+ typeof error.closeCode === 'number' &&
998
+ isTerminalCloseCode(error.closeCode)))
999
+ ) {
1000
+ terminalError = error;
1001
+ done = true;
1002
+ wake();
1003
+ }
1004
+ options.onError?.(error);
1005
+ },
1006
+ onClose: (code, reason) => {
1007
+ if (isTerminalCloseCode(code) || options.autoReconnect === false) {
1008
+ done = true;
1009
+ }
1010
+ wake();
1011
+ options.onClose?.(code, reason);
1012
+ },
1013
+ });
1014
+
1015
+ client.connect();
1016
+
1017
+ try {
1018
+ while (!done) {
1019
+ while (buffer.length > 0) {
1020
+ yield buffer.shift()!;
1021
+ }
1022
+
1023
+ if (done) {
1024
+ break;
1025
+ }
1026
+
1027
+ await new Promise<void>((r) => {
1028
+ resolve = r;
1029
+ });
1030
+ }
1031
+
1032
+ while (buffer.length > 0) {
1033
+ yield buffer.shift()!;
1034
+ }
1035
+
1036
+ if (terminalError) {
1037
+ throw terminalError;
1038
+ }
1039
+ } finally {
1040
+ client.close();
1041
+ }
1042
+ }