@agentuity/core 2.0.9 → 2.0.11

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 (62) hide show
  1. package/dist/services/coder/agents.d.ts +170 -0
  2. package/dist/services/coder/agents.d.ts.map +1 -0
  3. package/dist/services/coder/agents.js +77 -0
  4. package/dist/services/coder/agents.js.map +1 -0
  5. package/dist/services/coder/api-reference.d.ts.map +1 -1
  6. package/dist/services/coder/api-reference.js +393 -41
  7. package/dist/services/coder/api-reference.js.map +1 -1
  8. package/dist/services/coder/client.d.ts +44 -2
  9. package/dist/services/coder/client.d.ts.map +1 -1
  10. package/dist/services/coder/client.js +89 -3
  11. package/dist/services/coder/client.js.map +1 -1
  12. package/dist/services/coder/close-codes.d.ts +76 -0
  13. package/dist/services/coder/close-codes.d.ts.map +1 -0
  14. package/dist/services/coder/close-codes.js +77 -0
  15. package/dist/services/coder/close-codes.js.map +1 -0
  16. package/dist/services/coder/discover.d.ts +1 -1
  17. package/dist/services/coder/discover.js +2 -2
  18. package/dist/services/coder/discover.js.map +1 -1
  19. package/dist/services/coder/index.d.ts +9 -2
  20. package/dist/services/coder/index.d.ts.map +1 -1
  21. package/dist/services/coder/index.js +6 -1
  22. package/dist/services/coder/index.js.map +1 -1
  23. package/dist/services/coder/protocol.d.ts +1855 -0
  24. package/dist/services/coder/protocol.d.ts.map +1 -0
  25. package/dist/services/coder/protocol.js +976 -0
  26. package/dist/services/coder/protocol.js.map +1 -0
  27. package/dist/services/coder/sessions.d.ts +9 -0
  28. package/dist/services/coder/sessions.d.ts.map +1 -1
  29. package/dist/services/coder/sessions.js +30 -6
  30. package/dist/services/coder/sessions.js.map +1 -1
  31. package/dist/services/coder/sse.d.ts +255 -0
  32. package/dist/services/coder/sse.d.ts.map +1 -0
  33. package/dist/services/coder/sse.js +676 -0
  34. package/dist/services/coder/sse.js.map +1 -0
  35. package/dist/services/coder/types.d.ts +1013 -0
  36. package/dist/services/coder/types.d.ts.map +1 -1
  37. package/dist/services/coder/types.js +215 -1
  38. package/dist/services/coder/types.js.map +1 -1
  39. package/dist/services/coder/websocket.d.ts +346 -0
  40. package/dist/services/coder/websocket.d.ts.map +1 -0
  41. package/dist/services/coder/websocket.js +791 -0
  42. package/dist/services/coder/websocket.js.map +1 -0
  43. package/dist/services/oauth/types.d.ts +10 -0
  44. package/dist/services/oauth/types.d.ts.map +1 -1
  45. package/dist/services/oauth/types.js +3 -0
  46. package/dist/services/oauth/types.js.map +1 -1
  47. package/dist/services/project/deploy.d.ts +1 -1
  48. package/dist/services/sandbox/run.d.ts +2 -2
  49. package/dist/services/sandbox/types.d.ts +2 -2
  50. package/package.json +2 -2
  51. package/src/services/coder/agents.ts +148 -0
  52. package/src/services/coder/api-reference.ts +411 -45
  53. package/src/services/coder/client.ts +133 -2
  54. package/src/services/coder/close-codes.ts +83 -0
  55. package/src/services/coder/discover.ts +2 -2
  56. package/src/services/coder/index.ts +29 -1
  57. package/src/services/coder/protocol.ts +1200 -0
  58. package/src/services/coder/sessions.ts +40 -10
  59. package/src/services/coder/sse.ts +796 -0
  60. package/src/services/coder/types.ts +249 -1
  61. package/src/services/coder/websocket.ts +943 -0
  62. package/src/services/oauth/types.ts +3 -0
@@ -0,0 +1,796 @@
1
+ /**
2
+ * Server-Sent Events (SSE) client for observing Coder Hub sessions.
3
+ *
4
+ * SSE provides a unidirectional stream of events from the server, ideal for
5
+ * observers who want to watch session activity without sending commands.
6
+ *
7
+ * @module coder/sse
8
+ *
9
+ * @example Class-based API with callbacks
10
+ * ```typescript
11
+ * import { CoderSSEClient } from '@agentuity/core/coder';
12
+ *
13
+ * const client = new CoderSSEClient({
14
+ * apiKey: 'your-api-key',
15
+ * sessionId: 'session-123',
16
+ * onSnapshot: (data) => {
17
+ * console.log('Session state:', data);
18
+ * },
19
+ * onBroadcast: (data) => {
20
+ * console.log('Broadcast event:', data.event, data.data);
21
+ * },
22
+ * onPresence: (data) => {
23
+ * console.log('Participant:', data.event, data.participant);
24
+ * },
25
+ * onError: (err) => {
26
+ * console.error('SSE error:', err);
27
+ * },
28
+ * });
29
+ *
30
+ * client.connect();
31
+ *
32
+ * // Later: close the connection
33
+ * client.close();
34
+ * ```
35
+ *
36
+ * @example Async iterator API
37
+ * ```typescript
38
+ * import { streamCoderSessionSSE } from '@agentuity/core/coder';
39
+ *
40
+ * const controller = new AbortController();
41
+ *
42
+ * for await (const event of streamCoderSessionSSE({
43
+ * sessionId: 'session-123',
44
+ * signal: controller.signal,
45
+ * })) {
46
+ * console.log('Event:', event.event, event.data);
47
+ *
48
+ * if (event.event === 'broadcast' && event.data.event === 'session_complete') {
49
+ * controller.abort(); // Stop the stream
50
+ * }
51
+ * }
52
+ * ```
53
+ */
54
+
55
+ import { z } from 'zod/v4';
56
+ import { StructuredError } from '../../error.ts';
57
+ import type { Logger } from '../../logger.ts';
58
+ import { APIClient } from '../api.ts';
59
+ import { getServiceUrls } from '../config.ts';
60
+ import { createMinimalLogger } from '../logger.ts';
61
+ import { getEnv } from '../env.ts';
62
+ import { discoverUrl } from './discover.ts';
63
+ import type {
64
+ BroadcastEventMessage,
65
+ ObserverSseMessage,
66
+ PresenceEventMessage,
67
+ SseHydrationMessage,
68
+ SseSessionSnapshotMessage,
69
+ } from './protocol.ts';
70
+ import { ObserverSseMessageSchema } from './protocol.ts';
71
+ import { normalizeCoderUrl } from './util.ts';
72
+
73
+ /**
74
+ * Options for the SSE client (both class-based and async iterator APIs).
75
+ */
76
+ export const CoderSSEOptionsSchema = z.object({
77
+ /** API key for authentication. Falls back to AGENTUITY_SDK_KEY or AGENTUITY_CLI_KEY env vars. */
78
+ apiKey: z.string().optional().describe('API key for authentication'),
79
+ /** Organization ID for multi-tenant operations */
80
+ orgId: z.string().optional().describe('Organization ID for multi-tenant operations'),
81
+ /** Session ID to observe (required) */
82
+ sessionId: z.string().describe('Session ID to observe'),
83
+ /** Base URL for the Coder Hub. Falls back to AGENTUITY_CODER_URL env var. */
84
+ url: z.string().optional().describe('Base URL for the Coder Hub'),
85
+ /** Region used for Catalyst URL resolution when no explicit URL is provided */
86
+ region: z.string().optional().describe('Region used for Catalyst URL resolution'),
87
+ /** Event filters to subscribe to. Empty subscribes to default observer events. */
88
+ subscribe: z.array(z.string()).optional().describe('Event filters to subscribe to'),
89
+ /** Custom logger implementation */
90
+ logger: z.custom<Logger>().optional().describe('Custom logger implementation'),
91
+ /** Enable automatic reconnection on disconnect (default: true) */
92
+ reconnect: z.boolean().optional().describe('Enable automatic reconnection'),
93
+ /** Maximum reconnection attempts before giving up (default: 10) */
94
+ maxReconnectAttempts: z.number().optional().describe('Maximum reconnection attempts'),
95
+ /** Initial reconnection delay in milliseconds (default: 1000) */
96
+ reconnectDelayMs: z.number().optional().describe('Initial reconnection delay'),
97
+ /** Maximum reconnection delay in milliseconds (default: 30000) */
98
+ maxReconnectDelayMs: z.number().optional().describe('Maximum reconnection delay'),
99
+ /** AbortSignal to stop the subscription (async iterator only) */
100
+ signal: z.custom<AbortSignal>().optional().describe('AbortSignal to stop the subscription'),
101
+ });
102
+ export type CoderSSEOptions = z.infer<typeof CoderSSEOptionsSchema>;
103
+
104
+ /**
105
+ * Options for the class-based SSE client.
106
+ *
107
+ * Extends the base options with callbacks for each event type.
108
+ */
109
+ export const CoderSSEClientOptionsSchema = CoderSSEOptionsSchema.extend({
110
+ /** Called when the initial session snapshot is received */
111
+ onSnapshot: z
112
+ .custom<(data: SseSessionSnapshotMessage) => void>()
113
+ .optional()
114
+ .describe('Callback for snapshot events'),
115
+ /** Called when hydration data (conversation history) is received */
116
+ onHydration: z
117
+ .custom<(data: SseHydrationMessage) => void>()
118
+ .optional()
119
+ .describe('Callback for hydration events'),
120
+ /** Called when presence events (join/leave) occur */
121
+ onPresence: z
122
+ .custom<(data: PresenceEventMessage) => void>()
123
+ .optional()
124
+ .describe('Callback for presence events'),
125
+ /** Called for broadcast events (session activity updates) */
126
+ onBroadcast: z
127
+ .custom<(data: BroadcastEventMessage) => void>()
128
+ .optional()
129
+ .describe('Callback for broadcast events'),
130
+ /** Called for any SSE event (catch-all) */
131
+ onEvent: z
132
+ .custom<(event: CoderSSEEvent) => void>()
133
+ .optional()
134
+ .describe('Callback for all events'),
135
+ /** Called when connection is established */
136
+ onOpen: z.custom<() => void>().optional().describe('Callback when connection opens'),
137
+ /** Called when connection closes */
138
+ onClose: z.custom<() => void>().optional().describe('Callback when connection closes'),
139
+ /** Called on errors */
140
+ onError: z.custom<(error: Error) => void>().optional().describe('Callback on error'),
141
+ });
142
+ export type CoderSSEClientOptions = z.infer<typeof CoderSSEClientOptionsSchema>;
143
+
144
+ /**
145
+ * Error type for SSE operations.
146
+ *
147
+ * @example
148
+ * ```typescript
149
+ * try {
150
+ * for await (const event of streamCoderSessionSSE({ sessionId: 'invalid' })) {
151
+ * // ...
152
+ * }
153
+ * } catch (err) {
154
+ * if (err instanceof CoderSSEError) {
155
+ * console.log('SSE error code:', err.code);
156
+ * }
157
+ * }
158
+ * ```
159
+ */
160
+ export const CoderSSEError = StructuredError('CoderSSEError')<{
161
+ code: 'connection_failed' | 'auth_failed' | 'max_reconnects_exceeded' | 'parse_error';
162
+ sessionId?: string;
163
+ }>();
164
+
165
+ /**
166
+ * A single SSE event with its event name and parsed data.
167
+ */
168
+ export interface CoderSSEEvent {
169
+ /** The SSE event name (e.g., 'snapshot', 'broadcast', 'presence') */
170
+ event: string;
171
+ /** The parsed event data */
172
+ data: ObserverSseMessage;
173
+ }
174
+
175
+ /**
176
+ * Connection state for the SSE client.
177
+ */
178
+ export type CoderSSEState = 'connecting' | 'connected' | 'reconnecting' | 'closed';
179
+
180
+ async function buildSSEUrl(
181
+ sessionId: string,
182
+ options: {
183
+ url?: string;
184
+ apiKey?: string;
185
+ orgId?: string;
186
+ subscribe?: string[];
187
+ region?: string;
188
+ logger?: Logger;
189
+ }
190
+ ): Promise<string> {
191
+ let baseUrl = options.url;
192
+ if (!baseUrl) {
193
+ const envUrl = getEnv('AGENTUITY_CODER_URL');
194
+ if (envUrl) {
195
+ baseUrl = normalizeCoderUrl(envUrl);
196
+ } else {
197
+ const region = options.region ?? getEnv('AGENTUITY_REGION') ?? 'usc';
198
+ const catalystUrl = getServiceUrls(region).catalyst;
199
+ const headers: Record<string, string> = {};
200
+ if (options.orgId) {
201
+ headers['x-agentuity-orgid'] = options.orgId;
202
+ }
203
+ const logger = options.logger ?? createMinimalLogger();
204
+ const catalystClient = new APIClient(catalystUrl, logger, options.apiKey ?? '', {
205
+ headers,
206
+ });
207
+ try {
208
+ baseUrl = await discoverUrl(catalystClient);
209
+ } catch (err) {
210
+ throw new CoderSSEError({
211
+ message: `Failed to discover Coder URL: ${err instanceof Error ? err.message : String(err)}`,
212
+ code: 'connection_failed',
213
+ sessionId,
214
+ });
215
+ }
216
+ }
217
+ }
218
+
219
+ baseUrl = baseUrl.replace(/\/$/, '');
220
+ const path = `/api/hub/session/${encodeURIComponent(sessionId)}/events`;
221
+
222
+ const params = new URLSearchParams();
223
+ if (options.subscribe && options.subscribe.length > 0) {
224
+ params.set('subscribe', options.subscribe.join(','));
225
+ }
226
+ if (options.apiKey) {
227
+ params.set('api_key', options.apiKey);
228
+ }
229
+ if (options.orgId) {
230
+ params.set('org_id', options.orgId);
231
+ }
232
+
233
+ const queryString = params.toString();
234
+ return queryString ? `${baseUrl}${path}?${queryString}` : `${baseUrl}${path}`;
235
+ }
236
+
237
+ function getSSEData(event: Event): string | null {
238
+ const msgEvent = event as unknown as { data?: unknown };
239
+ if (typeof msgEvent.data === 'string') {
240
+ return msgEvent.data;
241
+ }
242
+ return null;
243
+ }
244
+
245
+ /**
246
+ * Class-based SSE client for observing Coder Hub sessions.
247
+ *
248
+ * Provides callback-based event handling for session observation via Server-Sent Events.
249
+ * Automatically reconnects on disconnection with exponential backoff.
250
+ *
251
+ * @example
252
+ * ```typescript
253
+ * const client = new CoderSSEClient({
254
+ * apiKey: 'your-api-key',
255
+ * sessionId: 'session-123',
256
+ * onSnapshot: (data) => console.log('Snapshot:', data),
257
+ * onBroadcast: (data) => console.log('Broadcast:', data),
258
+ * onPresence: (data) => console.log('Presence:', data),
259
+ * });
260
+ *
261
+ * client.connect();
262
+ *
263
+ * // Check connection state
264
+ * console.log('State:', client.state);
265
+ * console.log('Connected:', client.isConnected);
266
+ *
267
+ * // Close when done
268
+ * client.close();
269
+ * ```
270
+ */
271
+ export class CoderSSEClient {
272
+ readonly #options: {
273
+ sessionId: string;
274
+ url: string | undefined;
275
+ region: string;
276
+ apiKey: string;
277
+ orgId: string;
278
+ subscribe: string[] | undefined;
279
+ logger: Logger;
280
+ reconnect: boolean;
281
+ maxReconnectAttempts: number;
282
+ reconnectDelayMs: number;
283
+ maxReconnectDelayMs: number;
284
+ onSnapshot?: (data: SseSessionSnapshotMessage) => void;
285
+ onHydration?: (data: SseHydrationMessage) => void;
286
+ onPresence?: (data: PresenceEventMessage) => void;
287
+ onBroadcast?: (data: BroadcastEventMessage) => void;
288
+ onEvent?: (event: CoderSSEEvent) => void;
289
+ onOpen?: () => void;
290
+ onClose?: () => void;
291
+ onError?: (error: Error) => void;
292
+ };
293
+ #state: CoderSSEState = 'closed';
294
+ #eventSource: EventSource | null = null;
295
+ #reconnectAttempts = 0;
296
+ #reconnectTimer: ReturnType<typeof setTimeout> | null = null;
297
+ #intentionallyClosed = false;
298
+
299
+ constructor(options: CoderSSEClientOptions) {
300
+ this.#options = {
301
+ sessionId: options.sessionId,
302
+ url: options.url,
303
+ region: options.region ?? getEnv('AGENTUITY_REGION') ?? 'usc',
304
+ apiKey: options.apiKey ?? getEnv('AGENTUITY_SDK_KEY') ?? getEnv('AGENTUITY_CLI_KEY') ?? '',
305
+ orgId: options.orgId ?? '',
306
+ subscribe: options.subscribe,
307
+ logger: options.logger ?? createMinimalLogger(),
308
+ reconnect: options.reconnect ?? true,
309
+ maxReconnectAttempts: options.maxReconnectAttempts ?? 10,
310
+ reconnectDelayMs: options.reconnectDelayMs ?? 1000,
311
+ maxReconnectDelayMs: options.maxReconnectDelayMs ?? 30000,
312
+ onSnapshot: options.onSnapshot,
313
+ onHydration: options.onHydration,
314
+ onPresence: options.onPresence,
315
+ onBroadcast: options.onBroadcast,
316
+ onEvent: options.onEvent,
317
+ onOpen: options.onOpen,
318
+ onClose: options.onClose,
319
+ onError: options.onError,
320
+ };
321
+ }
322
+
323
+ /**
324
+ * The current connection state.
325
+ *
326
+ * - `'connecting'` - Initial connection in progress
327
+ * - `'connected'` - Connected and receiving events
328
+ * - `'reconnecting'` - Reconnecting after disconnect
329
+ * - `'closed'` - Connection closed (manually or after max retries)
330
+ */
331
+ get state(): CoderSSEState {
332
+ return this.#state;
333
+ }
334
+
335
+ /**
336
+ * Whether the client is currently connected and receiving events.
337
+ */
338
+ get isConnected(): boolean {
339
+ return this.#state === 'connected' && this.#eventSource?.readyState === 1;
340
+ }
341
+
342
+ /**
343
+ * Establish the SSE connection and start receiving events.
344
+ *
345
+ * If already connected or connecting, this is a no-op.
346
+ * Automatically reconnects on disconnection unless `close()` was called.
347
+ */
348
+ connect(): void {
349
+ if (this.#state !== 'closed') {
350
+ return;
351
+ }
352
+ this.#intentionallyClosed = false;
353
+ this.#reconnectAttempts = 0;
354
+ if (this.#reconnectTimer !== null) {
355
+ clearTimeout(this.#reconnectTimer);
356
+ this.#reconnectTimer = null;
357
+ }
358
+ this.#connectInternal();
359
+ }
360
+
361
+ /**
362
+ * Close the SSE connection and stop receiving events.
363
+ *
364
+ * After calling `close()`, you can call `connect()` again to reconnect.
365
+ */
366
+ close(): void {
367
+ this.#intentionallyClosed = true;
368
+ if (this.#reconnectTimer !== null) {
369
+ clearTimeout(this.#reconnectTimer);
370
+ this.#reconnectTimer = null;
371
+ }
372
+ if (this.#eventSource) {
373
+ this.#eventSource.close();
374
+ this.#eventSource = null;
375
+ }
376
+ this.#state = 'closed';
377
+ this.#options.onClose?.();
378
+ }
379
+
380
+ #setState(state: CoderSSEState): void {
381
+ this.#state = state;
382
+ }
383
+
384
+ #handleEvent(eventName: string, typeOverride?: string): void {
385
+ this.#eventSource!.addEventListener(eventName, (event: Event) => {
386
+ const data = getSSEData(event);
387
+ if (!data) return;
388
+
389
+ try {
390
+ const parsed = JSON.parse(data);
391
+ const payload = typeOverride ? { type: typeOverride, ...parsed } : parsed;
392
+ const result = ObserverSseMessageSchema.safeParse(payload);
393
+
394
+ if (result.success) {
395
+ const semanticEvent = typeOverride || result.data.type;
396
+ const sseEvent: CoderSSEEvent = { event: semanticEvent, data: result.data };
397
+ this.#options.onEvent?.(sseEvent);
398
+
399
+ if (result.data.type === 'snapshot') {
400
+ this.#options.onSnapshot?.(result.data);
401
+ } else if (result.data.type === 'hydration') {
402
+ this.#options.onHydration?.(result.data);
403
+ } else if (result.data.type === 'presence') {
404
+ this.#options.onPresence?.(result.data);
405
+ } else if (result.data.type === 'broadcast') {
406
+ this.#options.onBroadcast?.(result.data);
407
+ }
408
+ } else {
409
+ const parseError = new CoderSSEError({
410
+ message: `Invalid SSE ${eventName} event format`,
411
+ code: 'parse_error',
412
+ sessionId: this.#options.sessionId,
413
+ });
414
+ this.#options.onError?.(parseError);
415
+ }
416
+ } catch (err) {
417
+ const parseError = new CoderSSEError({
418
+ message: `Failed to parse SSE ${eventName} event: ${err instanceof Error ? err.message : String(err)}`,
419
+ code: 'parse_error',
420
+ sessionId: this.#options.sessionId,
421
+ });
422
+ this.#options.onError?.(parseError);
423
+ }
424
+ });
425
+ }
426
+
427
+ async #connectInternal(): Promise<void> {
428
+ if (this.#intentionallyClosed) {
429
+ return;
430
+ }
431
+
432
+ this.#setState(this.#reconnectAttempts > 0 ? 'reconnecting' : 'connecting');
433
+
434
+ let url: string;
435
+ try {
436
+ url = await buildSSEUrl(this.#options.sessionId, this.#options);
437
+ } catch (err) {
438
+ this.#setState('closed');
439
+ this.#options.onError?.(err as Error);
440
+ return;
441
+ }
442
+
443
+ if (this.#intentionallyClosed || this.#state === 'closed') {
444
+ return;
445
+ }
446
+
447
+ // Workaround for bun-types EventSource constructor typing issue.
448
+ // The type definitions don't match the runtime signature, so we use
449
+ // a double type assertion to construct EventSource with a URL parameter.
450
+ try {
451
+ const EventSourceCtor: typeof EventSource = EventSource;
452
+ this.#eventSource = new (EventSourceCtor as unknown as new (url: string) => EventSource)(
453
+ url
454
+ );
455
+ } catch (err) {
456
+ this.#setState('closed');
457
+ this.#options.onError?.(
458
+ new CoderSSEError({
459
+ message: `Failed to create EventSource: ${err instanceof Error ? err.message : String(err)}`,
460
+ code: 'connection_failed',
461
+ sessionId: this.#options.sessionId,
462
+ })
463
+ );
464
+ this.#scheduleReconnect();
465
+ return;
466
+ }
467
+
468
+ this.#eventSource.onerror = () => {
469
+ // Notify caller of transient error before reconnecting
470
+ this.#options.onError?.(new Error('EventSource transient error'));
471
+
472
+ if (this.#eventSource) {
473
+ this.#eventSource.close();
474
+ this.#eventSource = null;
475
+ }
476
+
477
+ if (this.#intentionallyClosed) {
478
+ return;
479
+ }
480
+
481
+ this.#scheduleReconnect();
482
+ };
483
+
484
+ this.#eventSource.onopen = () => {
485
+ this.#reconnectAttempts = 0;
486
+ this.#setState('connected');
487
+ this.#options.logger.debug(
488
+ 'SSE connection established for session %s',
489
+ this.#options.sessionId
490
+ );
491
+ this.#options.onOpen?.();
492
+ };
493
+
494
+ this.#handleEvent('snapshot', 'snapshot');
495
+ this.#handleEvent('hydration', 'hydration');
496
+ this.#handleEvent('presence', 'presence');
497
+ this.#handleEvent('broadcast', 'broadcast');
498
+ this.#handleEvent('message');
499
+ }
500
+
501
+ #scheduleReconnect(): void {
502
+ if (this.#intentionallyClosed || !this.#options.reconnect) {
503
+ this.#setState('closed');
504
+ return;
505
+ }
506
+
507
+ if (this.#reconnectAttempts >= this.#options.maxReconnectAttempts) {
508
+ this.#setState('closed');
509
+ this.#options.onError?.(
510
+ new CoderSSEError({
511
+ message: `Exceeded maximum reconnection attempts (${this.#options.maxReconnectAttempts})`,
512
+ code: 'max_reconnects_exceeded',
513
+ sessionId: this.#options.sessionId,
514
+ })
515
+ );
516
+ return;
517
+ }
518
+
519
+ const baseDelay = this.#options.reconnectDelayMs * 2 ** this.#reconnectAttempts;
520
+ const jitter = 0.5 + Math.random() * 0.5;
521
+ const delay = Math.min(Math.floor(baseDelay * jitter), this.#options.maxReconnectDelayMs);
522
+
523
+ this.#reconnectAttempts++;
524
+ this.#setState('reconnecting');
525
+ this.#options.logger.debug(
526
+ 'SSE connection lost, reconnecting in %dms (attempt %d)',
527
+ delay,
528
+ this.#reconnectAttempts
529
+ );
530
+
531
+ this.#reconnectTimer = setTimeout(() => {
532
+ this.#reconnectTimer = null;
533
+ this.#connectInternal();
534
+ }, delay);
535
+ }
536
+ }
537
+
538
+ /**
539
+ * Stream Coder Hub session events via Server-Sent Events (SSE).
540
+ *
541
+ * Returns an async iterator that yields events as they arrive from the server.
542
+ * The connection is automatically managed (reconnection, cleanup).
543
+ *
544
+ * @param options - Configuration for the SSE subscription
545
+ * @yields Events from the session as they arrive
546
+ * @throws {CoderSSEError} If connection fails or max reconnection attempts exceeded
547
+ *
548
+ * @example
549
+ * ```typescript
550
+ * import { streamCoderSessionSSE } from '@agentuity/core/coder';
551
+ *
552
+ * // Basic usage
553
+ * for await (const event of streamCoderSessionSSE({
554
+ * sessionId: 'session-123',
555
+ * })) {
556
+ * if (event.event === 'snapshot') {
557
+ * console.log('Session:', event.data.label);
558
+ * } else if (event.event === 'broadcast') {
559
+ * console.log('Event:', event.data.event);
560
+ * }
561
+ * }
562
+ * ```
563
+ *
564
+ * @example With abort signal
565
+ * ```typescript
566
+ * const controller = new AbortController();
567
+ *
568
+ * // Stop after 60 seconds
569
+ * setTimeout(() => controller.abort(), 60000);
570
+ *
571
+ * for await (const event of streamCoderSessionSSE({
572
+ * sessionId: 'session-123',
573
+ * signal: controller.signal,
574
+ * })) {
575
+ * console.log(event);
576
+ * }
577
+ * ```
578
+ *
579
+ * @example With event filtering
580
+ * ```typescript
581
+ * for await (const event of streamCoderSessionSSE({
582
+ * sessionId: 'session-123',
583
+ * subscribe: ['task_*', 'agent_*'], // Only task and agent events
584
+ * })) {
585
+ * console.log(event);
586
+ * }
587
+ * ```
588
+ */
589
+ export async function* streamCoderSessionSSE(
590
+ options: CoderSSEOptions
591
+ ): AsyncGenerator<CoderSSEEvent, void, unknown> {
592
+ const logger = options.logger ?? createMinimalLogger();
593
+ const signal = options.signal;
594
+ const reconnect = options.reconnect ?? true;
595
+ const maxReconnectAttempts = options.maxReconnectAttempts ?? 10;
596
+ const reconnectDelayMs = options.reconnectDelayMs ?? 1000;
597
+ const maxReconnectDelayMs = options.maxReconnectDelayMs ?? 30000;
598
+
599
+ if (signal?.aborted) {
600
+ return;
601
+ }
602
+
603
+ let eventSource: EventSource | null = null;
604
+ let reconnectAttempts = 0;
605
+ const buffer: CoderSSEEvent[] = [];
606
+ const MAX_BUFFER = 1000;
607
+ let resolve: (() => void) | null = null;
608
+ let done = false;
609
+ let terminalError: Error | null = null;
610
+
611
+ const wake = () => {
612
+ if (resolve) {
613
+ resolve();
614
+ resolve = null;
615
+ }
616
+ };
617
+
618
+ const cleanup = () => {
619
+ if (eventSource) {
620
+ eventSource.close();
621
+ eventSource = null;
622
+ }
623
+ };
624
+
625
+ const handleSSEEvent = (eventName: string, typeOverride?: string) => {
626
+ eventSource!.addEventListener(eventName, (event: Event) => {
627
+ const data = getSSEData(event);
628
+ if (!data) return;
629
+ try {
630
+ const parsed = JSON.parse(data);
631
+ const payload = typeOverride ? { type: typeOverride, ...parsed } : parsed;
632
+ const result = ObserverSseMessageSchema.safeParse(payload);
633
+ if (result.success) {
634
+ if (buffer.length >= MAX_BUFFER) {
635
+ buffer.shift();
636
+ logger.debug('SSE buffer full, dropped oldest event');
637
+ }
638
+ const semanticEvent = typeOverride || result.data.type;
639
+ buffer.push({ event: semanticEvent, data: result.data });
640
+ wake();
641
+ } else {
642
+ terminalError = new CoderSSEError({
643
+ message: `Invalid SSE ${eventName} event format`,
644
+ code: 'parse_error',
645
+ sessionId: options.sessionId,
646
+ });
647
+ done = true;
648
+ wake();
649
+ return;
650
+ }
651
+ } catch (err) {
652
+ terminalError = new CoderSSEError({
653
+ message: `Failed to parse SSE ${eventName} event: ${err instanceof Error ? err.message : String(err)}`,
654
+ code: 'parse_error',
655
+ sessionId: options.sessionId,
656
+ });
657
+ done = true;
658
+ wake();
659
+ return;
660
+ }
661
+ });
662
+ };
663
+
664
+ const connect = async (): Promise<void> => {
665
+ if (done || signal?.aborted) {
666
+ return;
667
+ }
668
+
669
+ let url: string;
670
+ try {
671
+ url = await buildSSEUrl(options.sessionId, {
672
+ ...options,
673
+ logger,
674
+ });
675
+ } catch (err) {
676
+ terminalError = err as Error;
677
+ done = true;
678
+ wake();
679
+ return;
680
+ }
681
+
682
+ if (signal?.aborted) {
683
+ done = true;
684
+ wake();
685
+ return;
686
+ }
687
+
688
+ // Workaround for bun-types EventSource constructor typing issue (see above).
689
+ try {
690
+ const EventSourceCtor: typeof EventSource = EventSource;
691
+ eventSource = new (EventSourceCtor as unknown as new (url: string) => EventSource)(url);
692
+ } catch (err) {
693
+ terminalError = new CoderSSEError({
694
+ message: `Failed to create EventSource: ${err instanceof Error ? err.message : String(err)}`,
695
+ code: 'connection_failed',
696
+ sessionId: options.sessionId,
697
+ });
698
+ done = true;
699
+ wake();
700
+ return;
701
+ }
702
+
703
+ if (signal?.aborted) {
704
+ cleanup();
705
+ done = true;
706
+ wake();
707
+ return;
708
+ }
709
+
710
+ eventSource.onerror = () => {
711
+ cleanup();
712
+
713
+ if (signal?.aborted) {
714
+ done = true;
715
+ wake();
716
+ return;
717
+ }
718
+
719
+ if (reconnect && reconnectAttempts < maxReconnectAttempts) {
720
+ const baseDelay = reconnectDelayMs * 2 ** reconnectAttempts;
721
+ const jitter = 0.5 + Math.random() * 0.5;
722
+ const delay = Math.min(Math.floor(baseDelay * jitter), maxReconnectDelayMs);
723
+
724
+ reconnectAttempts++;
725
+ logger.debug(
726
+ 'SSE connection lost, reconnecting in %dms (attempt %d)',
727
+ delay,
728
+ reconnectAttempts
729
+ );
730
+
731
+ setTimeout(() => {
732
+ connect();
733
+ }, delay);
734
+ } else if (reconnect) {
735
+ terminalError = new CoderSSEError({
736
+ message: `Exceeded maximum reconnection attempts (${maxReconnectAttempts})`,
737
+ code: 'max_reconnects_exceeded',
738
+ sessionId: options.sessionId,
739
+ });
740
+ done = true;
741
+ wake();
742
+ } else {
743
+ done = true;
744
+ wake();
745
+ }
746
+ };
747
+
748
+ eventSource.onopen = () => {
749
+ reconnectAttempts = 0;
750
+ logger.debug('SSE connection established for session %s', options.sessionId);
751
+ };
752
+
753
+ handleSSEEvent('snapshot', 'snapshot');
754
+ handleSSEEvent('hydration', 'hydration');
755
+ handleSSEEvent('presence', 'presence');
756
+ handleSSEEvent('broadcast', 'broadcast');
757
+ handleSSEEvent('message');
758
+ };
759
+
760
+ const onAbort = () => {
761
+ done = true;
762
+ cleanup();
763
+ wake();
764
+ };
765
+
766
+ signal?.addEventListener('abort', onAbort, { once: true });
767
+
768
+ await connect();
769
+
770
+ try {
771
+ while (!done) {
772
+ while (buffer.length > 0) {
773
+ yield buffer.shift()!;
774
+ }
775
+
776
+ if (done) {
777
+ break;
778
+ }
779
+
780
+ await new Promise<void>((r) => {
781
+ resolve = r;
782
+ });
783
+ }
784
+
785
+ while (buffer.length > 0) {
786
+ yield buffer.shift()!;
787
+ }
788
+
789
+ if (terminalError) {
790
+ throw terminalError;
791
+ }
792
+ } finally {
793
+ signal?.removeEventListener('abort', onAbort);
794
+ cleanup();
795
+ }
796
+ }