@chorus-aidlc/openclaw-plugin 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.
@@ -0,0 +1,228 @@
1
+ import type { ChorusMcpClient } from "./mcp-client.js";
2
+ import type { ChorusPluginConfig } from "./config.js";
3
+ import type { SseNotificationEvent } from "./sse-listener.js";
4
+
5
+ export interface ChorusEventRouterOptions {
6
+ mcpClient: ChorusMcpClient;
7
+ config: ChorusPluginConfig;
8
+ triggerAgent: (message: string, metadata?: Record<string, unknown>) => void;
9
+ logger: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void };
10
+ }
11
+
12
+ /**
13
+ * Notification detail returned from chorus_get_notifications.
14
+ * Only the fields we need for routing.
15
+ */
16
+ interface NotificationDetail {
17
+ uuid: string;
18
+ projectUuid: string;
19
+ entityType: string;
20
+ entityUuid: string;
21
+ entityTitle: string;
22
+ action: string;
23
+ message: string;
24
+ actorType: string;
25
+ actorUuid: string;
26
+ actorName: string;
27
+ }
28
+
29
+ export class ChorusEventRouter {
30
+ private readonly mcpClient: ChorusMcpClient;
31
+ private readonly config: ChorusPluginConfig;
32
+ private readonly triggerAgent: ChorusEventRouterOptions["triggerAgent"];
33
+ private readonly logger: ChorusEventRouterOptions["logger"];
34
+ private readonly projectFilter: Set<string>;
35
+
36
+ constructor(opts: ChorusEventRouterOptions) {
37
+ this.mcpClient = opts.mcpClient;
38
+ this.config = opts.config;
39
+ this.triggerAgent = opts.triggerAgent;
40
+ this.logger = opts.logger;
41
+ this.projectFilter = new Set(opts.config.projectUuids);
42
+ }
43
+
44
+ /**
45
+ * Route an incoming SSE notification event to the appropriate handler.
46
+ * Never throws — all errors are caught and logged internally.
47
+ */
48
+ dispatch(event: SseNotificationEvent): void {
49
+ // Only handle new_notification events (ignore count_update, etc.)
50
+ if (event.type !== "new_notification") {
51
+ this.logger.info(`SSE event type "${event.type}" ignored`);
52
+ return;
53
+ }
54
+
55
+ if (!event.notificationUuid) {
56
+ this.logger.warn("new_notification event missing notificationUuid, skipping");
57
+ return;
58
+ }
59
+
60
+ // Fetch full notification details and route asynchronously
61
+ this.fetchAndRoute(event.notificationUuid).catch((err) => {
62
+ this.logger.error(`Failed to fetch/route notification ${event.notificationUuid}: ${err}`);
63
+ });
64
+ }
65
+
66
+ // ---------------------------------------------------------------------------
67
+ // Internal
68
+ // ---------------------------------------------------------------------------
69
+
70
+ private async fetchAndRoute(notificationUuid: string): Promise<void> {
71
+ // Fetch notification details via MCP — use autoMarkRead=false so we don't
72
+ // consume all unread notifications, and status=unread since we just received it
73
+ const result = await this.mcpClient.callTool("chorus_get_notifications", {
74
+ status: "unread",
75
+ limit: 50,
76
+ autoMarkRead: false,
77
+ }) as { notifications?: NotificationDetail[] } | null;
78
+
79
+ const notifications = result?.notifications;
80
+ if (!notifications || !Array.isArray(notifications)) {
81
+ this.logger.warn(`Could not fetch notifications list`);
82
+ return;
83
+ }
84
+
85
+ const notification = notifications.find((n) => n.uuid === notificationUuid);
86
+ if (!notification) {
87
+ this.logger.warn(`Notification ${notificationUuid} not found in unread list`);
88
+ return;
89
+ }
90
+
91
+ // Project filter: if projectUuids is configured, ignore events from other projects
92
+ if (this.projectFilter.size > 0 && !this.projectFilter.has(notification.projectUuid)) {
93
+ this.logger.info(
94
+ `Notification for project ${notification.projectUuid} filtered out`
95
+ );
96
+ return;
97
+ }
98
+
99
+ // Route based on action (which corresponds to notificationType)
100
+ try {
101
+ switch (notification.action) {
102
+ case "task_assigned":
103
+ await this.handleTaskAssigned(notification);
104
+ break;
105
+ case "mentioned":
106
+ this.handleMentioned(notification);
107
+ break;
108
+ case "elaboration_requested":
109
+ this.handleElaborationRequested(notification);
110
+ break;
111
+ case "elaboration_answered":
112
+ this.handleElaborationAnswered(notification);
113
+ break;
114
+ case "proposal_rejected":
115
+ this.handleProposalRejected(notification);
116
+ break;
117
+ case "proposal_approved":
118
+ this.handleProposalApproved(notification);
119
+ break;
120
+ case "idea_claimed":
121
+ this.handleIdeaClaimed(notification);
122
+ break;
123
+ default:
124
+ this.logger.info(`Unhandled notification action: "${notification.action}"`);
125
+ break;
126
+ }
127
+ } catch (err) {
128
+ this.logger.error(`Error handling ${notification.action} notification: ${err}`);
129
+ }
130
+ }
131
+
132
+ /**
133
+ * Build @mention guidance for agent messages.
134
+ * Instructs the agent to @mention the actor after completing work.
135
+ */
136
+ private buildMentionGuidance(n: NotificationDetail, entityType: string): string {
137
+ return (
138
+ `After completing your work, post a comment on this ${entityType} using chorus_add_comment with @mention:\n` +
139
+ `Use this exact mention format: @[${n.actorName}](${n.actorType}:${n.actorUuid})`
140
+ );
141
+ }
142
+
143
+ private async handleTaskAssigned(n: NotificationDetail): Promise<void> {
144
+ const mentionGuidance = this.buildMentionGuidance(n, "task");
145
+
146
+ if (this.config.autoStart) {
147
+ try {
148
+ await this.mcpClient.callTool("chorus_claim_task", { taskUuid: n.entityUuid });
149
+ this.logger.info(`Auto-claimed task ${n.entityUuid}`);
150
+ } catch (err) {
151
+ this.logger.warn(`Failed to auto-claim task ${n.entityUuid}: ${err}`);
152
+ // Still trigger agent even if claim fails — let the agent handle it
153
+ }
154
+
155
+ this.triggerAgent(
156
+ `[Chorus] Task assigned: ${n.entityTitle}. Task UUID: ${n.entityUuid}, Project UUID: ${n.projectUuid}. Use chorus_get_task to see details and begin work.\n${mentionGuidance}`,
157
+ { notificationUuid: n.uuid, action: "task_assigned", entityUuid: n.entityUuid, projectUuid: n.projectUuid }
158
+ );
159
+ } else {
160
+ this.triggerAgent(
161
+ `[Chorus] Task assigned: ${n.entityTitle}. Task UUID: ${n.entityUuid}, Project UUID: ${n.projectUuid}. Use chorus_get_task to review when ready.\n${mentionGuidance}`,
162
+ { notificationUuid: n.uuid, action: "task_assigned", entityUuid: n.entityUuid, projectUuid: n.projectUuid }
163
+ );
164
+ }
165
+ }
166
+
167
+ private handleMentioned(n: NotificationDetail): void {
168
+ this.triggerAgent(
169
+ `[Chorus] You were @mentioned in ${n.entityType} '${n.entityTitle}' (projectUuid: ${n.projectUuid}): ${n.message}`,
170
+ { notificationUuid: n.uuid, action: "mentioned", entityUuid: n.entityUuid, projectUuid: n.projectUuid }
171
+ );
172
+ }
173
+
174
+ private handleElaborationRequested(n: NotificationDetail): void {
175
+ this.triggerAgent(
176
+ `[Chorus] Elaboration requested for idea '${n.entityTitle}' (ideaUuid: ${n.entityUuid}, projectUuid: ${n.projectUuid}). Use chorus_get_elaboration to review questions.`,
177
+ { notificationUuid: n.uuid, action: "elaboration_requested", entityUuid: n.entityUuid, projectUuid: n.projectUuid }
178
+ );
179
+ }
180
+
181
+ private handleProposalRejected(n: NotificationDetail): void {
182
+ const mentionGuidance = this.buildMentionGuidance(n, "proposal");
183
+
184
+ this.triggerAgent(
185
+ `[Chorus] Proposal '${n.entityTitle}' was REJECTED (proposalUuid: ${n.entityUuid}, projectUuid: ${n.projectUuid}). Review note: "${n.message}". ` +
186
+ `Use chorus_get_proposal to review the proposal, then fix issues with chorus_update_task_draft / chorus_update_document_draft. ` +
187
+ `After fixing, call chorus_validate_proposal then chorus_submit_proposal to resubmit.\n` +
188
+ mentionGuidance,
189
+ { notificationUuid: n.uuid, action: "proposal_rejected", entityUuid: n.entityUuid, projectUuid: n.projectUuid }
190
+ );
191
+ }
192
+
193
+ private handleProposalApproved(n: NotificationDetail): void {
194
+ const mentionGuidance = this.buildMentionGuidance(n, "proposal");
195
+
196
+ this.triggerAgent(
197
+ `[Chorus] Proposal '${n.entityTitle}' was APPROVED (projectUuid: ${n.projectUuid})! Documents and tasks have been created. ` +
198
+ `Use chorus_get_available_tasks with projectUuid: "${n.projectUuid}" to see the new tasks ready for work.\n` +
199
+ mentionGuidance,
200
+ { notificationUuid: n.uuid, action: "proposal_approved", entityUuid: n.entityUuid, projectUuid: n.projectUuid }
201
+ );
202
+ }
203
+
204
+ private handleIdeaClaimed(n: NotificationDetail): void {
205
+ const mentionGuidance = this.buildMentionGuidance(n, "idea");
206
+
207
+ this.triggerAgent(
208
+ `[Chorus] Idea '${n.entityTitle}' has been assigned to you (ideaUuid: ${n.entityUuid}, projectUuid: ${n.projectUuid}). ` +
209
+ `Use chorus_get_idea to review the idea, then chorus_claim_idea to start elaboration.\n` +
210
+ mentionGuidance,
211
+ { notificationUuid: n.uuid, action: "idea_claimed", entityUuid: n.entityUuid, projectUuid: n.projectUuid }
212
+ );
213
+ }
214
+
215
+ private handleElaborationAnswered(n: NotificationDetail): void {
216
+ const mentionGuidance = this.buildMentionGuidance(n, "idea");
217
+
218
+ this.triggerAgent(
219
+ `[Chorus] Elaboration answers submitted for idea '${n.entityTitle}' (ideaUuid: ${n.entityUuid}, projectUuid: ${n.projectUuid}). ` +
220
+ `Review the answers with chorus_get_elaboration, then either:\n` +
221
+ `- Call chorus_validate_elaboration with empty issues [] to resolve and proceed to proposal creation\n` +
222
+ `- Call chorus_validate_elaboration with issues + followUpQuestions for another round\n\n` +
223
+ `After reviewing, @mention the answerer to ask if they have any further questions before you proceed.\n` +
224
+ mentionGuidance,
225
+ { notificationUuid: n.uuid, action: "elaboration_answered", entityUuid: n.entityUuid, projectUuid: n.projectUuid }
226
+ );
227
+ }
228
+ }
package/src/index.ts ADDED
@@ -0,0 +1,132 @@
1
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
2
+ type OpenClawPluginApi = any;
3
+
4
+ import { chorusConfigSchema, type ChorusPluginConfig } from "./config.js";
5
+ import { ChorusMcpClient } from "./mcp-client.js";
6
+ import { ChorusSseListener } from "./sse-listener.js";
7
+ import { ChorusEventRouter } from "./event-router.js";
8
+ import { registerPmTools } from "./tools/pm-tools.js";
9
+ import { registerDevTools } from "./tools/dev-tools.js";
10
+ import { registerCommonTools } from "./tools/common-tools.js";
11
+ import { registerChorusCommands } from "./commands.js";
12
+
13
+ /**
14
+ * Trigger the OpenClaw agent by posting a system event to the gateway's
15
+ * /hooks/wake endpoint. This enqueues the text into the agent's prompt
16
+ * and triggers an immediate heartbeat so the agent processes it right away.
17
+ */
18
+ async function wakeAgent(
19
+ gatewayUrl: string,
20
+ hooksToken: string,
21
+ text: string,
22
+ logger: { info: (msg: string) => void; warn: (msg: string) => void },
23
+ ) {
24
+ try {
25
+ const res = await fetch(`${gatewayUrl}/hooks/wake`, {
26
+ method: "POST",
27
+ headers: {
28
+ "Content-Type": "application/json",
29
+ Authorization: `Bearer ${hooksToken}`,
30
+ },
31
+ body: JSON.stringify({ text, mode: "now" }),
32
+ });
33
+ if (!res.ok) {
34
+ logger.warn(`Wake agent failed: HTTP ${res.status}`);
35
+ } else {
36
+ logger.info(`Agent woken: ${text.slice(0, 80)}...`);
37
+ }
38
+ } catch (err) {
39
+ logger.warn(`Wake agent error: ${err}`);
40
+ }
41
+ }
42
+
43
+ const plugin = {
44
+ id: "@chorus-aidlc/openclaw-plugin",
45
+ name: "Chorus",
46
+ description:
47
+ "Chorus AI-DLC collaboration platform — SSE real-time events + MCP tool integration",
48
+ configSchema: chorusConfigSchema,
49
+
50
+ register(api: OpenClawPluginApi) {
51
+ const config = api.pluginConfig as ChorusPluginConfig;
52
+ const logger = api.logger;
53
+
54
+ // Resolve gateway URL and hooks token from OpenClaw config
55
+ const gatewayPort = api.config?.gateway?.port ?? 18789;
56
+ const gatewayUrl = `http://127.0.0.1:${gatewayPort}`;
57
+ const hooksToken = api.config?.hooks?.token ?? "";
58
+
59
+ logger.info(
60
+ `Chorus plugin initializing — ${config.chorusUrl} (${config.projectUuids.length || "all"} projects)`
61
+ );
62
+
63
+ // --- MCP Client ---
64
+ const mcpClient = new ChorusMcpClient({
65
+ chorusUrl: config.chorusUrl,
66
+ apiKey: config.apiKey,
67
+ logger,
68
+ });
69
+
70
+ // --- Event Router ---
71
+ const eventRouter = new ChorusEventRouter({
72
+ mcpClient,
73
+ config,
74
+ logger,
75
+ triggerAgent: (message: string, _metadata?: Record<string, unknown>) => {
76
+ // Use /hooks/wake to enqueue a system event + trigger immediate heartbeat
77
+ if (hooksToken) {
78
+ wakeAgent(gatewayUrl, hooksToken, message, logger);
79
+ } else {
80
+ logger.warn(
81
+ `[Chorus] Cannot wake agent — gateway.auth.token not configured. Event: ${message.slice(0, 100)}`
82
+ );
83
+ }
84
+ },
85
+ });
86
+
87
+ // --- SSE Listener (background service) ---
88
+ let sseListener: ChorusSseListener | null = null;
89
+
90
+ api.registerService({
91
+ id: "chorus-sse",
92
+ async start() {
93
+ sseListener = new ChorusSseListener({
94
+ chorusUrl: config.chorusUrl,
95
+ apiKey: config.apiKey,
96
+ logger,
97
+ onEvent: (event) => eventRouter.dispatch(event),
98
+ onReconnect: async () => {
99
+ // Back-fill missed notifications after reconnect
100
+ try {
101
+ const result = (await mcpClient.callTool("chorus_get_notifications", {
102
+ status: "unread",
103
+ autoMarkRead: false,
104
+ })) as { notifications?: Array<{ uuid: string }> } | null;
105
+ const count = result?.notifications?.length ?? 0;
106
+ if (count > 0) {
107
+ logger.info(`SSE reconnect: ${count} unread notifications to process`);
108
+ }
109
+ } catch (err) {
110
+ logger.warn(`Failed to back-fill notifications: ${err}`);
111
+ }
112
+ },
113
+ });
114
+ await sseListener.connect();
115
+ },
116
+ async stop() {
117
+ sseListener?.disconnect();
118
+ await mcpClient.disconnect();
119
+ },
120
+ });
121
+
122
+ // --- Tools ---
123
+ registerPmTools(api, mcpClient);
124
+ registerDevTools(api, mcpClient);
125
+ registerCommonTools(api, mcpClient);
126
+
127
+ // --- Commands ---
128
+ registerChorusCommands(api, mcpClient, () => sseListener?.status ?? "disconnected");
129
+ },
130
+ };
131
+
132
+ export default plugin;
@@ -0,0 +1,141 @@
1
+ import { Client } from "@modelcontextprotocol/sdk/client/index.js";
2
+ import { StreamableHTTPClientTransport } from "@modelcontextprotocol/sdk/client/streamableHttp.js";
3
+
4
+ export type McpClientStatus = "disconnected" | "connecting" | "connected" | "reconnecting";
5
+
6
+ export interface ChorusMcpClientOptions {
7
+ chorusUrl: string;
8
+ apiKey: string;
9
+ logger: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void };
10
+ }
11
+
12
+ /**
13
+ * Wraps @modelcontextprotocol/sdk Client with:
14
+ * - Lazy connection (connect on first callTool)
15
+ * - Auto-reconnect on session expiry (404)
16
+ * - Status tracking
17
+ * - Graceful disconnect
18
+ */
19
+ export class ChorusMcpClient {
20
+ private client: Client | null = null;
21
+ private transport: StreamableHTTPClientTransport | null = null;
22
+ private _status: McpClientStatus = "disconnected";
23
+ private readonly opts: ChorusMcpClientOptions;
24
+
25
+ constructor(opts: ChorusMcpClientOptions) {
26
+ this.opts = opts;
27
+ }
28
+
29
+ get status(): McpClientStatus {
30
+ return this._status;
31
+ }
32
+
33
+ /** Establish MCP connection. Called lazily on first callTool. */
34
+ async connect(): Promise<void> {
35
+ if (this._status === "connected" && this.client) return;
36
+
37
+ this._status = "connecting";
38
+ try {
39
+ this.transport = new StreamableHTTPClientTransport(
40
+ new URL("/api/mcp", this.opts.chorusUrl),
41
+ {
42
+ requestInit: {
43
+ headers: {
44
+ Authorization: `Bearer ${this.opts.apiKey}`,
45
+ },
46
+ },
47
+ }
48
+ );
49
+
50
+ this.client = new Client({
51
+ name: "openclaw-chorus",
52
+ version: "0.1.0",
53
+ });
54
+
55
+ await this.client.connect(this.transport);
56
+ this._status = "connected";
57
+ this.opts.logger.info("MCP connection established");
58
+ } catch (err) {
59
+ this._status = "disconnected";
60
+ this.client = null;
61
+ this.transport = null;
62
+ throw err;
63
+ }
64
+ }
65
+
66
+ /**
67
+ * Call a Chorus MCP tool. Handles lazy connection and auto-reconnect.
68
+ * Returns parsed JSON from the first text content block.
69
+ */
70
+ async callTool(name: string, args: Record<string, unknown> = {}): Promise<unknown> {
71
+ // Lazy connect
72
+ if (!this.client || this._status !== "connected") {
73
+ await this.connect();
74
+ }
75
+
76
+ try {
77
+ return await this._doCallTool(name, args);
78
+ } catch (err: unknown) {
79
+ // Session expired (404) or connection lost — reconnect and retry once
80
+ if (this.isSessionExpiredError(err)) {
81
+ this.opts.logger.warn("MCP session expired, reconnecting...");
82
+ this._status = "reconnecting";
83
+ this.client = null;
84
+ this.transport = null;
85
+ await this.connect();
86
+ return await this._doCallTool(name, args);
87
+ }
88
+ throw err;
89
+ }
90
+ }
91
+
92
+ /** Graceful disconnect. */
93
+ async disconnect(): Promise<void> {
94
+ if (this.client) {
95
+ try {
96
+ await this.client.close();
97
+ } catch {
98
+ // Ignore close errors
99
+ }
100
+ }
101
+ this.client = null;
102
+ this.transport = null;
103
+ this._status = "disconnected";
104
+ this.opts.logger.info("MCP connection closed");
105
+ }
106
+
107
+ private async _doCallTool(name: string, args: Record<string, unknown>): Promise<unknown> {
108
+ if (!this.client) throw new Error("MCP client not connected");
109
+
110
+ const result = await this.client.callTool({ name, arguments: args });
111
+
112
+ if (result.isError) {
113
+ const errorText = (result.content as Array<{ type: string; text?: string }>)
114
+ .filter((c) => c.type === "text")
115
+ .map((c) => c.text)
116
+ .join("\n");
117
+ throw new Error(`Chorus MCP tool error (${name}): ${errorText}`);
118
+ }
119
+
120
+ // Parse first text content block as JSON
121
+ const textContent = (result.content as Array<{ type: string; text?: string }>).find(
122
+ (c) => c.type === "text"
123
+ );
124
+ if (!textContent?.text) return null;
125
+
126
+ try {
127
+ return JSON.parse(textContent.text);
128
+ } catch {
129
+ // Return raw text if not valid JSON
130
+ return textContent.text;
131
+ }
132
+ }
133
+
134
+ private isSessionExpiredError(err: unknown): boolean {
135
+ if (err instanceof Error) {
136
+ const msg = err.message.toLowerCase();
137
+ return msg.includes("404") || msg.includes("session") || msg.includes("not found");
138
+ }
139
+ return false;
140
+ }
141
+ }
@@ -0,0 +1,184 @@
1
+ export type SseListenerStatus = "connected" | "disconnected" | "reconnecting";
2
+
3
+ export interface SseNotificationEvent {
4
+ type: string; // "new_notification"
5
+ notificationUuid?: string;
6
+ notificationType?: string; // "task_assigned", "mentioned", etc.
7
+ unreadCount?: number;
8
+ [key: string]: unknown;
9
+ }
10
+
11
+ export interface ChorusSseListenerOptions {
12
+ chorusUrl: string;
13
+ apiKey: string;
14
+ onEvent: (event: SseNotificationEvent) => void;
15
+ onReconnect: () => Promise<void>;
16
+ logger: { info: (msg: string) => void; warn: (msg: string) => void; error: (msg: string) => void };
17
+ }
18
+
19
+ const INITIAL_DELAY_MS = 1_000;
20
+ const MAX_DELAY_MS = 30_000;
21
+
22
+ export class ChorusSseListener {
23
+ private readonly opts: ChorusSseListenerOptions;
24
+ private _status: SseListenerStatus = "disconnected";
25
+ private abortController: AbortController | null = null;
26
+ private reconnectTimer: ReturnType<typeof setTimeout> | null = null;
27
+ private reconnectDelay = INITIAL_DELAY_MS;
28
+
29
+ constructor(opts: ChorusSseListenerOptions) {
30
+ this.opts = opts;
31
+ }
32
+
33
+ get status(): SseListenerStatus {
34
+ return this._status;
35
+ }
36
+
37
+ /** Start the SSE connection. Resolves once the first bytes arrive (or rejects on immediate failure). */
38
+ async connect(): Promise<void> {
39
+ this.clearReconnectTimer();
40
+
41
+ const abortController = new AbortController();
42
+ this.abortController = abortController;
43
+
44
+ const url = `${this.opts.chorusUrl.replace(/\/$/, "")}/api/events/notifications`;
45
+
46
+ let response: Response;
47
+ try {
48
+ response = await fetch(url, {
49
+ headers: {
50
+ Authorization: `Bearer ${this.opts.apiKey}`,
51
+ Accept: "text/event-stream",
52
+ },
53
+ signal: abortController.signal,
54
+ });
55
+ } catch (err) {
56
+ if (abortController.signal.aborted) return; // intentional disconnect
57
+ this.opts.logger.error(`SSE connection failed: ${err}`);
58
+ this.scheduleReconnect();
59
+ return;
60
+ }
61
+
62
+ if (!response.ok) {
63
+ this.opts.logger.error(`SSE endpoint returned ${response.status}`);
64
+ this.scheduleReconnect();
65
+ return;
66
+ }
67
+
68
+ if (!response.body) {
69
+ this.opts.logger.error("SSE response has no body");
70
+ this.scheduleReconnect();
71
+ return;
72
+ }
73
+
74
+ // Connection succeeded — reset backoff
75
+ const isReconnect = this._status === "reconnecting";
76
+ this._status = "connected";
77
+ this.reconnectDelay = INITIAL_DELAY_MS;
78
+ this.opts.logger.info("[Chorus] SSE connection established");
79
+
80
+ if (isReconnect) {
81
+ // Fire onReconnect callback so the caller can back-fill missed notifications
82
+ try {
83
+ await this.opts.onReconnect();
84
+ } catch (err) {
85
+ this.opts.logger.warn(`onReconnect callback error: ${err}`);
86
+ }
87
+ }
88
+
89
+ // Read the stream
90
+ this.consumeStream(response.body, abortController.signal);
91
+ }
92
+
93
+ /** Gracefully close the SSE connection. */
94
+ disconnect(): void {
95
+ this.clearReconnectTimer();
96
+ if (this.abortController) {
97
+ this.abortController.abort();
98
+ this.abortController = null;
99
+ }
100
+ this._status = "disconnected";
101
+ this.opts.logger.info("SSE connection closed");
102
+ }
103
+
104
+ // ---------------------------------------------------------------------------
105
+ // Internal
106
+ // ---------------------------------------------------------------------------
107
+
108
+ private async consumeStream(body: ReadableStream<Uint8Array>, signal: AbortSignal): Promise<void> {
109
+ const decoder = new TextDecoder();
110
+ const reader = body.getReader();
111
+ let buffer = "";
112
+
113
+ try {
114
+ while (true) {
115
+ const { done, value } = await reader.read();
116
+ if (done || signal.aborted) break;
117
+
118
+ buffer += decoder.decode(value, { stream: true });
119
+
120
+ // SSE messages are delimited by double newlines
121
+ let boundary: number;
122
+ while ((boundary = buffer.indexOf("\n\n")) !== -1) {
123
+ const raw = buffer.slice(0, boundary);
124
+ buffer = buffer.slice(boundary + 2);
125
+ this.processMessage(raw);
126
+ }
127
+ }
128
+ } catch (err) {
129
+ if (signal.aborted) return; // intentional disconnect
130
+ this.opts.logger.warn(`SSE stream error: ${err}`);
131
+ } finally {
132
+ try {
133
+ reader.releaseLock();
134
+ } catch {
135
+ // already released
136
+ }
137
+ }
138
+
139
+ // Stream ended unexpectedly — reconnect unless we were intentionally disconnected
140
+ if (!signal.aborted) {
141
+ this.opts.logger.warn("SSE stream ended, scheduling reconnect");
142
+ this.scheduleReconnect();
143
+ }
144
+ }
145
+
146
+ private processMessage(raw: string): void {
147
+ for (const line of raw.split("\n")) {
148
+ // Comment lines (heartbeats) — ignore
149
+ if (line.startsWith(":")) continue;
150
+
151
+ // Data lines
152
+ if (line.startsWith("data: ")) {
153
+ const jsonStr = line.slice(6);
154
+ try {
155
+ const event: SseNotificationEvent = JSON.parse(jsonStr);
156
+ this.opts.onEvent(event);
157
+ } catch (err) {
158
+ this.opts.logger.warn(`SSE JSON parse error: ${err} — raw: ${jsonStr}`);
159
+ }
160
+ }
161
+ }
162
+ }
163
+
164
+ private scheduleReconnect(): void {
165
+ this.clearReconnectTimer();
166
+ this._status = "reconnecting";
167
+
168
+ this.opts.logger.info(`SSE reconnecting in ${this.reconnectDelay}ms`);
169
+ this.reconnectTimer = setTimeout(() => {
170
+ this.reconnectTimer = null;
171
+ this.connect();
172
+ }, this.reconnectDelay);
173
+
174
+ // Exponential backoff: 1s → 2s → 4s → 8s → 16s → 30s
175
+ this.reconnectDelay = Math.min(this.reconnectDelay * 2, MAX_DELAY_MS);
176
+ }
177
+
178
+ private clearReconnectTimer(): void {
179
+ if (this.reconnectTimer !== null) {
180
+ clearTimeout(this.reconnectTimer);
181
+ this.reconnectTimer = null;
182
+ }
183
+ }
184
+ }