pilotswarm-sdk 0.3.2 → 0.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (120) hide show
  1. package/README.md +21 -4
  2. package/api/README.md +44 -0
  3. package/api/index.d.ts +86 -0
  4. package/api/index.js +16 -0
  5. package/api/src/api-client.js +363 -0
  6. package/api/src/http-api-transport.js +342 -0
  7. package/api/src/protocol.js +230 -0
  8. package/dist/client.d.ts +2 -1
  9. package/dist/client.d.ts.map +1 -1
  10. package/dist/client.js +9 -0
  11. package/dist/client.js.map +1 -1
  12. package/dist/cms-migrations.d.ts.map +1 -1
  13. package/dist/cms-migrations.js +250 -0
  14. package/dist/cms-migrations.js.map +1 -1
  15. package/dist/cms.d.ts +10 -0
  16. package/dist/cms.d.ts.map +1 -1
  17. package/dist/cms.js +5 -0
  18. package/dist/cms.js.map +1 -1
  19. package/dist/index.d.ts +25 -7
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +24 -7
  22. package/dist/index.js.map +1 -1
  23. package/dist/managed-session.d.ts +35 -0
  24. package/dist/managed-session.d.ts.map +1 -1
  25. package/dist/managed-session.js +74 -0
  26. package/dist/managed-session.js.map +1 -1
  27. package/dist/management-client.d.ts +88 -3
  28. package/dist/management-client.d.ts.map +1 -1
  29. package/dist/management-client.js +259 -2
  30. package/dist/management-client.js.map +1 -1
  31. package/dist/orchestration/index.d.ts +2 -2
  32. package/dist/orchestration/index.js +1 -1
  33. package/dist/orchestration/runtime.d.ts +1 -1
  34. package/dist/orchestration/turn.d.ts +13 -0
  35. package/dist/orchestration/turn.d.ts.map +1 -1
  36. package/dist/orchestration/turn.js +317 -163
  37. package/dist/orchestration/turn.js.map +1 -1
  38. package/dist/orchestration-registry.d.ts.map +1 -1
  39. package/dist/orchestration-registry.js +4 -2
  40. package/dist/orchestration-registry.js.map +1 -1
  41. package/dist/orchestration-version.d.ts +1 -1
  42. package/dist/orchestration-version.js +1 -1
  43. package/dist/orchestration.d.ts +2 -2
  44. package/dist/orchestration.js +1 -1
  45. package/dist/orchestration_1_0_46.d.ts +1 -1
  46. package/dist/orchestration_1_0_47.d.ts +1 -1
  47. package/dist/orchestration_1_0_48.d.ts +1 -1
  48. package/dist/orchestration_1_0_49.d.ts +1 -1
  49. package/dist/orchestration_1_0_50.d.ts +1 -1
  50. package/dist/orchestration_1_0_55/agents.d.ts +41 -0
  51. package/dist/orchestration_1_0_55/agents.d.ts.map +1 -0
  52. package/dist/orchestration_1_0_55/agents.js +758 -0
  53. package/dist/orchestration_1_0_55/agents.js.map +1 -0
  54. package/dist/orchestration_1_0_55/index.d.ts +24 -0
  55. package/dist/orchestration_1_0_55/index.d.ts.map +1 -0
  56. package/dist/orchestration_1_0_55/index.js +13 -0
  57. package/dist/orchestration_1_0_55/index.js.map +1 -0
  58. package/dist/orchestration_1_0_55/lifecycle.d.ts +41 -0
  59. package/dist/orchestration_1_0_55/lifecycle.d.ts.map +1 -0
  60. package/dist/orchestration_1_0_55/lifecycle.js +580 -0
  61. package/dist/orchestration_1_0_55/lifecycle.js.map +1 -0
  62. package/dist/orchestration_1_0_55/queue.d.ts +7 -0
  63. package/dist/orchestration_1_0_55/queue.d.ts.map +1 -0
  64. package/dist/orchestration_1_0_55/queue.js +644 -0
  65. package/dist/orchestration_1_0_55/queue.js.map +1 -0
  66. package/dist/orchestration_1_0_55/runtime.d.ts +29 -0
  67. package/dist/orchestration_1_0_55/runtime.d.ts.map +1 -0
  68. package/dist/orchestration_1_0_55/runtime.js +194 -0
  69. package/dist/orchestration_1_0_55/runtime.js.map +1 -0
  70. package/dist/orchestration_1_0_55/state.d.ts +126 -0
  71. package/dist/orchestration_1_0_55/state.d.ts.map +1 -0
  72. package/dist/orchestration_1_0_55/state.js +106 -0
  73. package/dist/orchestration_1_0_55/state.js.map +1 -0
  74. package/dist/orchestration_1_0_55/turn.d.ts +17 -0
  75. package/dist/orchestration_1_0_55/turn.d.ts.map +1 -0
  76. package/dist/orchestration_1_0_55/turn.js +921 -0
  77. package/dist/orchestration_1_0_55/turn.js.map +1 -0
  78. package/dist/orchestration_1_0_55/utils.d.ts +22 -0
  79. package/dist/orchestration_1_0_55/utils.d.ts.map +1 -0
  80. package/dist/orchestration_1_0_55/utils.js +226 -0
  81. package/dist/orchestration_1_0_55/utils.js.map +1 -0
  82. package/dist/session-manager.d.ts +20 -1
  83. package/dist/session-manager.d.ts.map +1 -1
  84. package/dist/session-manager.js +62 -0
  85. package/dist/session-manager.js.map +1 -1
  86. package/dist/session-proxy.d.ts +7 -0
  87. package/dist/session-proxy.d.ts.map +1 -1
  88. package/dist/session-proxy.js +35 -1
  89. package/dist/session-proxy.js.map +1 -1
  90. package/dist/session-store.d.ts.map +1 -1
  91. package/dist/session-store.js +13 -3
  92. package/dist/session-store.js.map +1 -1
  93. package/dist/types.d.ts +39 -0
  94. package/dist/types.d.ts.map +1 -1
  95. package/dist/types.js +10 -0
  96. package/dist/types.js.map +1 -1
  97. package/dist/web/api-connection.d.ts +20 -0
  98. package/dist/web/api-connection.d.ts.map +1 -0
  99. package/dist/web/api-connection.js +23 -0
  100. package/dist/web/api-connection.js.map +1 -0
  101. package/dist/web/web-client.d.ts +91 -0
  102. package/dist/web/web-client.d.ts.map +1 -0
  103. package/dist/web/web-client.js +378 -0
  104. package/dist/web/web-client.js.map +1 -0
  105. package/dist/web/web-fact-store.d.ts +78 -0
  106. package/dist/web/web-fact-store.d.ts.map +1 -0
  107. package/dist/web/web-fact-store.js +112 -0
  108. package/dist/web/web-fact-store.js.map +1 -0
  109. package/dist/web/web-graph-store.d.ts +38 -0
  110. package/dist/web/web-graph-store.d.ts.map +1 -0
  111. package/dist/web/web-graph-store.js +68 -0
  112. package/dist/web/web-graph-store.js.map +1 -0
  113. package/dist/web/web-management-client.d.ts +125 -0
  114. package/dist/web/web-management-client.d.ts.map +1 -0
  115. package/dist/web/web-management-client.js +256 -0
  116. package/dist/web/web-management-client.js.map +1 -0
  117. package/dist/worker.d.ts.map +1 -1
  118. package/dist/worker.js +10 -0
  119. package/dist/worker.js.map +1 -1
  120. package/package.json +16 -6
package/README.md CHANGED
@@ -14,6 +14,23 @@ Minimal usage:
14
14
  import { PilotSwarmClient, PilotSwarmWorker, defineTool } from "pilotswarm-sdk";
15
15
  ```
16
16
 
17
+ Client apps connect to a deployment's Web API (web mode):
18
+
19
+ ```ts
20
+ const client = new PilotSwarmClient({ apiUrl: "https://portal.example.com" });
21
+ await client.start();
22
+ const session = await client.createSession();
23
+ const reply = await session.sendAndWait("hello");
24
+ ```
25
+
26
+ Pass `getAccessToken` for authenticated deployments; use `PilotSwarmManagementClient({ apiUrl, getAccessToken? })` for management operations. Constructing a client directly with `{ store }` is internal (worker/portal-host embedding and testing) — see the [Web API reference](https://github.com/affandar/PilotSwarm/blob/main/docs/api/reference.md).
27
+
28
+ Workers are trusted backend components and always attach directly to the store:
29
+
30
+ ```ts
31
+ const worker = new PilotSwarmWorker({ store: process.env.DATABASE_URL });
32
+ ```
33
+
17
34
  `pilotswarm-sdk` ships PilotSwarm's embedded framework prompt, framework skills, and management plugins inside the package. App code should provide its own `plugin/` directory and worker-side tool handlers on top of that base.
18
35
 
19
36
  Artifact note:
@@ -24,9 +41,9 @@ Artifact note:
24
41
 
25
42
  Common docs:
26
43
 
27
- - SDK apps: `https://github.com/affandar/PilotSwarm/blob/main/docs/sdk/building-apps.md`
28
- - SDK agents: `https://github.com/affandar/PilotSwarm/blob/main/docs/sdk/building-agents.md`
29
- - Configuration: `https://github.com/affandar/PilotSwarm/blob/main/docs/configuration.md`
30
- - Architecture: `https://github.com/affandar/PilotSwarm/blob/main/docs/architecture.md`
44
+ - SDK apps: `https://github.com/affandar/PilotSwarm/blob/main/docs/developer/building/sdk-apps.md`
45
+ - SDK agents: `https://github.com/affandar/PilotSwarm/blob/main/docs/developer/building/sdk-agents.md`
46
+ - Configuration: `https://github.com/affandar/PilotSwarm/blob/main/docs/developer/reference/configuration.md`
47
+ - Architecture: `https://github.com/affandar/PilotSwarm/blob/main/docs/architecture/system.md`
31
48
 
32
49
  If you want the shipped terminal UI, install `pilotswarm-cli`.
package/api/README.md ADDED
@@ -0,0 +1,44 @@
1
+ # pilotswarm-sdk/api
2
+
3
+ Isomorphic protocol client for the [PilotSwarm Web API](../../docs/api/reference.md)
4
+ (`/api/v1` + `/api/v1/ws`). Runs in browsers and Node (≥22) with zero
5
+ dependencies — `fetch` and `WebSocket` come from the environment and are
6
+ injectable for tests.
7
+
8
+ This package is the transport layer under everything that talks to a
9
+ PilotSwarm deployment remotely: the browser portal, the TUI's API mode, and
10
+ the SDK's web provider (`new PilotSwarmClient({ apiUrl })`). Most applications
11
+ should use the [SDK](../sdk/README.md) rather than this package directly.
12
+
13
+ ```js
14
+ import { ApiClient } from "pilotswarm-sdk/api";
15
+
16
+ const api = new ApiClient({
17
+ apiUrl: "https://portal.example.com",
18
+ getAccessToken, // optional; omit for no-auth deployments
19
+ });
20
+
21
+ const session = await api.call("createSession", { model: "anthropic:claude-sonnet-4-6" });
22
+ await api.call("sendMessage", { sessionId: session.sessionId, prompt: "hello" });
23
+ const unsubscribe = api.subscribeSession(session.sessionId, (event) => console.log(event));
24
+ ```
25
+
26
+ ## Modules
27
+
28
+ - `src/protocol.js` — the **operations table**: every JSON operation's name,
29
+ HTTP method, path template, and parameter placement. This is the single
30
+ source of truth shared with the portal server (which generates its routes
31
+ from it) and the API reference doc.
32
+ - `src/api-client.js` — `ApiClient`: request building, `{ ok, result }`
33
+ envelope handling, 401/403 callbacks, and the `/api/v1/ws` WebSocket with
34
+ automatic resubscribe + backoff.
35
+ - `src/http-api-transport.js` — `HttpApiTransport`: the shared-UI transport
36
+ surface (`pilotswarm/ui-core`) over `ApiClient`. Environment conveniences
37
+ (save-to-disk, open-in-app) are injected by the host (browser portal, TUI).
38
+
39
+ ## Auth
40
+
41
+ `getAccessToken` is called before every request and WebSocket connect. HTTP
42
+ 401/403 invoke `onUnauthorized`/`onForbidden`; WebSocket close codes 4401/4403
43
+ do the same and suppress reconnection. Deployment auth mode is discovered from
44
+ the public `GET /api/v1/auth/config` (`api.getAuthConfig()`).
package/api/index.d.ts ADDED
@@ -0,0 +1,86 @@
1
+ /** Typed surface for pilotswarm-sdk/api (implementation is plain ESM JS). */
2
+
3
+ export declare const API_PREFIX: string;
4
+ export declare const API_VERSION: number;
5
+ export declare const WS_PATH: string;
6
+ export declare const WS_CLIENT_MESSAGES: string[];
7
+ export declare const WS_SERVER_MESSAGES: string[];
8
+ export declare const WEB_MODE_UNSUPPORTED: string;
9
+
10
+ export interface OperationParamSpec {
11
+ in: "path" | "query" | "body";
12
+ name?: string;
13
+ type?: "string" | "number" | "boolean" | "json";
14
+ }
15
+
16
+ export interface Operation {
17
+ name: string;
18
+ method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
19
+ path: string;
20
+ params?: Record<string, OperationParamSpec>;
21
+ summary: string;
22
+ }
23
+
24
+ export declare const OPERATIONS: Operation[];
25
+ export declare function getOperation(name: string): Operation | null;
26
+ export declare function buildOperationRequest(name: string, params?: Record<string, unknown>): {
27
+ method: string;
28
+ path: string;
29
+ query: URLSearchParams;
30
+ body: Record<string, unknown> | null;
31
+ };
32
+ export declare function coerceQueryValue(value: unknown, type?: string): unknown;
33
+ export declare function artifactDownloadPath(sessionId: string, filename: string): string;
34
+
35
+ export declare class ApiError extends Error {
36
+ code: string;
37
+ status: number;
38
+ constructor(message: string, opts?: { code?: string; status?: number });
39
+ }
40
+
41
+ export interface ApiClientOptions {
42
+ apiUrl: string;
43
+ getAccessToken?: () => Promise<string | null>;
44
+ onUnauthorized?: () => void;
45
+ onForbidden?: (message: string) => void;
46
+ fetchImpl?: typeof fetch;
47
+ WebSocketImpl?: unknown;
48
+ }
49
+
50
+ export declare class ApiClient {
51
+ constructor(options: ApiClientOptions);
52
+ apiUrl: string;
53
+ call(name: string, params?: Record<string, unknown>): Promise<any>;
54
+ request(method: string, pathWithQuery: string, opts?: { body?: unknown; headers?: Record<string, string> }): Promise<any>;
55
+ health(): Promise<any>;
56
+ getAuthConfig(): Promise<any>;
57
+ getAuthContext(): Promise<any>;
58
+ getBootstrap(): Promise<any>;
59
+ downloadArtifactResponse(sessionId: string, filename: string): Promise<Response>;
60
+ start(): Promise<void>;
61
+ stop(): Promise<void>;
62
+ subscribeSession(sessionId: string, handler: (event: unknown) => void, onResubscribe?: () => void): () => void;
63
+ subscribeLogs(handler: (entry: unknown) => void): () => void;
64
+ }
65
+
66
+ export interface HttpApiTransportHost {
67
+ saveArtifactDownload?: (transport: HttpApiTransport, sessionId: string, filename: string) => Promise<unknown>;
68
+ uploadArtifactFromPath?: (transport: HttpApiTransport, sessionId: string, filePath: string) => Promise<unknown>;
69
+ openPathInDefaultApp?: (targetPath: string) => Promise<unknown>;
70
+ openUrlInDefaultBrowser?: (targetUrl: string) => Promise<unknown>;
71
+ artifactExportDirectory?: string;
72
+ }
73
+
74
+ export interface HttpApiTransportOptions extends ApiClientOptions {
75
+ api?: ApiClient;
76
+ host?: HttpApiTransportHost;
77
+ }
78
+
79
+ export declare class HttpApiTransport {
80
+ constructor(options: HttpApiTransportOptions);
81
+ api: ApiClient;
82
+ bootstrap: any;
83
+ start(): Promise<void>;
84
+ stop(): Promise<void>;
85
+ [method: string]: any;
86
+ }
package/api/index.js ADDED
@@ -0,0 +1,16 @@
1
+ export {
2
+ API_PREFIX,
3
+ API_VERSION,
4
+ WS_PATH,
5
+ WS_CLIENT_MESSAGES,
6
+ WS_SERVER_MESSAGES,
7
+ WEB_MODE_UNSUPPORTED,
8
+ OPERATIONS,
9
+ getOperation,
10
+ buildOperationRequest,
11
+ coerceQueryValue,
12
+ artifactDownloadPath,
13
+ ApiError,
14
+ } from "./src/protocol.js";
15
+ export { ApiClient } from "./src/api-client.js";
16
+ export { HttpApiTransport } from "./src/http-api-transport.js";
@@ -0,0 +1,363 @@
1
+ import { API_PREFIX, ApiError, WS_PATH, artifactDownloadPath, buildOperationRequest } from "./protocol.js";
2
+
3
+ const RECONNECT_DELAY_MS = 1500;
4
+
5
+ function normalizeApiUrl(apiUrl) {
6
+ const raw = String(apiUrl || "").trim();
7
+ if (!raw) throw new Error("ApiClient requires an apiUrl");
8
+ return raw.replace(/\/+$/, "");
9
+ }
10
+
11
+ function toWebSocketUrl(apiUrl) {
12
+ const url = new URL(apiUrl);
13
+ url.protocol = url.protocol === "https:" ? "wss:" : "ws:";
14
+ url.pathname = `${url.pathname.replace(/\/+$/, "")}${WS_PATH}`;
15
+ url.search = "";
16
+ return url.toString();
17
+ }
18
+
19
+ async function readErrorEnvelope(response) {
20
+ let message = response.statusText || `HTTP ${response.status}`;
21
+ let code = response.status === 401 ? "UNAUTHORIZED" : response.status === 403 ? "FORBIDDEN" : "INTERNAL_ERROR";
22
+ try {
23
+ const payload = await response.json();
24
+ const error = payload?.error;
25
+ if (typeof error === "string" && error) message = error;
26
+ else if (error && typeof error === "object") {
27
+ if (error.message) message = error.message;
28
+ if (error.code) code = error.code;
29
+ } else if (payload?.message) {
30
+ message = payload.message;
31
+ }
32
+ } catch {}
33
+ return new ApiError(message, { code, status: response.status });
34
+ }
35
+
36
+ /**
37
+ * Typed low-level client for the PilotSwarm Web API.
38
+ *
39
+ * Isomorphic: runs in browsers and Node. `fetch` and `WebSocket` come from
40
+ * the global environment unless injected (`fetchImpl` / `WebSocketImpl`).
41
+ */
42
+ export class ApiClient {
43
+ constructor({
44
+ apiUrl,
45
+ getAccessToken,
46
+ onUnauthorized,
47
+ onForbidden,
48
+ fetchImpl,
49
+ WebSocketImpl,
50
+ } = {}) {
51
+ this.apiUrl = normalizeApiUrl(apiUrl);
52
+ this.getAccessToken = typeof getAccessToken === "function" ? getAccessToken : async () => null;
53
+ this.onUnauthorized = typeof onUnauthorized === "function" ? onUnauthorized : () => {};
54
+ this.onForbidden = typeof onForbidden === "function" ? onForbidden : () => {};
55
+ this.fetchImpl = fetchImpl || ((...args) => globalThis.fetch(...args));
56
+ this.WebSocketImpl = WebSocketImpl || globalThis.WebSocket;
57
+
58
+ this.socket = null;
59
+ this.socketOpenPromise = null;
60
+ this.reconnectTimer = null;
61
+ this.stopped = false;
62
+ this.hasConnected = false;
63
+ this.sessionSubscribers = new Map();
64
+ this.sessionResubscribeHandlers = new Map();
65
+ this.logSubscribers = new Set();
66
+ }
67
+
68
+ // ── HTTP ────────────────────────────────────────────────────────────
69
+
70
+ async authHeaders(extra = {}) {
71
+ const token = await this.getAccessToken();
72
+ const headers = { ...extra };
73
+ if (token) headers.authorization = `Bearer ${token}`;
74
+ return headers;
75
+ }
76
+
77
+ async request(method, pathWithQuery, { body, headers } = {}) {
78
+ const requestHeaders = await this.authHeaders(headers || {});
79
+ if (body !== undefined && !requestHeaders["content-type"]) {
80
+ requestHeaders["content-type"] = "application/json";
81
+ }
82
+ const response = await this.fetchImpl(`${this.apiUrl}${pathWithQuery}`, {
83
+ method,
84
+ headers: requestHeaders,
85
+ ...(body !== undefined ? { body: JSON.stringify(body) } : {}),
86
+ });
87
+ if (response.status === 401) {
88
+ this.onUnauthorized();
89
+ throw await readErrorEnvelope(response);
90
+ }
91
+ if (response.status === 403) {
92
+ const error = await readErrorEnvelope(response);
93
+ this.onForbidden(error.message || "Forbidden");
94
+ throw error;
95
+ }
96
+ if (!response.ok) {
97
+ throw await readErrorEnvelope(response);
98
+ }
99
+ const payload = await response.json();
100
+ if (payload && payload.ok === false) {
101
+ const error = payload.error;
102
+ throw new ApiError(
103
+ (typeof error === "object" ? error?.message : error) || "Request failed",
104
+ { code: (typeof error === "object" && error?.code) || "INTERNAL_ERROR", status: response.status },
105
+ );
106
+ }
107
+ return payload?.result !== undefined ? payload.result : payload;
108
+ }
109
+
110
+ /** Invoke a protocol operation by name with rpc-shaped params. */
111
+ async call(name, params = {}) {
112
+ const { method, path, query, body } = buildOperationRequest(name, params);
113
+ // Avoid URLSearchParams.prototype.size (absent on Safari 16 / iOS 16,
114
+ // which the portal build targets); toString() is universally supported.
115
+ const queryString = query.toString();
116
+ const suffix = queryString ? `?${queryString}` : "";
117
+ return this.request(method, `${path}${suffix}`, body !== null ? { body } : {});
118
+ }
119
+
120
+ // ── Bespoke (non-table) endpoints ───────────────────────────────────
121
+
122
+ async health() {
123
+ return this.request("GET", `${API_PREFIX}/health`);
124
+ }
125
+
126
+ /** Public: no token required. */
127
+ async getAuthConfig() {
128
+ const response = await this.fetchImpl(`${this.apiUrl}${API_PREFIX}/auth/config`, { method: "GET" });
129
+ if (!response.ok) throw await readErrorEnvelope(response);
130
+ return response.json();
131
+ }
132
+
133
+ async getAuthContext() {
134
+ return this.request("GET", `${API_PREFIX}/auth/me`);
135
+ }
136
+
137
+ async getBootstrap() {
138
+ return this.request("GET", `${API_PREFIX}/bootstrap`);
139
+ }
140
+
141
+ /** Raw artifact download; returns the Response for streaming/blob use. */
142
+ async downloadArtifactResponse(sessionId, filename) {
143
+ const headers = await this.authHeaders();
144
+ const response = await this.fetchImpl(`${this.apiUrl}${artifactDownloadPath(sessionId, filename)}`, {
145
+ method: "GET",
146
+ headers,
147
+ });
148
+ if (response.status === 401) {
149
+ this.onUnauthorized();
150
+ throw await readErrorEnvelope(response);
151
+ }
152
+ if (response.status === 403) {
153
+ const error = await readErrorEnvelope(response);
154
+ this.onForbidden(error.message || "Forbidden");
155
+ throw error;
156
+ }
157
+ if (!response.ok) throw await readErrorEnvelope(response);
158
+ return response;
159
+ }
160
+
161
+ // ── WebSocket (session events + log tail) ───────────────────────────
162
+
163
+ async start() {
164
+ this.stopped = false;
165
+ }
166
+
167
+ async stop() {
168
+ this.stopped = true;
169
+ this.hasConnected = false;
170
+ if (this.reconnectTimer) {
171
+ clearTimeout(this.reconnectTimer);
172
+ this.reconnectTimer = null;
173
+ }
174
+ if (this.socket) {
175
+ try {
176
+ this.socket.close();
177
+ } catch {}
178
+ }
179
+ this.socket = null;
180
+ this.socketOpenPromise = null;
181
+ this.sessionSubscribers.clear();
182
+ this.logSubscribers.clear();
183
+ }
184
+
185
+ scheduleReconnect() {
186
+ if (this.stopped || this.reconnectTimer) return;
187
+ if (this.sessionSubscribers.size === 0 && this.logSubscribers.size === 0) return;
188
+ this.reconnectTimer = setTimeout(() => {
189
+ this.reconnectTimer = null;
190
+ this.ensureSocket().catch(() => {});
191
+ }, RECONNECT_DELAY_MS);
192
+ }
193
+
194
+ async ensureSocket() {
195
+ if (this.stopped) return null;
196
+ const WebSocketImpl = this.WebSocketImpl;
197
+ if (!WebSocketImpl) throw new Error("No WebSocket implementation available");
198
+ if (this.socket && this.socket.readyState === WebSocketImpl.OPEN) {
199
+ return this.socket;
200
+ }
201
+ if (this.socketOpenPromise) {
202
+ return this.socketOpenPromise;
203
+ }
204
+
205
+ this.socketOpenPromise = (async () => {
206
+ let socket;
207
+ try {
208
+ const token = await this.getAccessToken();
209
+ const socketUrl = toWebSocketUrl(this.apiUrl);
210
+ socket = token
211
+ ? new WebSocketImpl(socketUrl, ["access_token", token])
212
+ : new WebSocketImpl(socketUrl);
213
+ } catch (error) {
214
+ // getAccessToken rejected or the constructor threw before any
215
+ // socket exists — no close/error event will fire, so schedule
216
+ // the retry here or the connection dies permanently.
217
+ this.socket = null;
218
+ this.scheduleReconnect();
219
+ throw error;
220
+ }
221
+ this.socket = socket;
222
+
223
+ socket.addEventListener("message", (event) => {
224
+ try {
225
+ const message = JSON.parse(String(event.data || ""));
226
+ if (message.type === "sessionEvent") {
227
+ const handlers = this.sessionSubscribers.get(message.sessionId);
228
+ if (handlers) {
229
+ for (const handler of handlers) handler(message.event);
230
+ }
231
+ return;
232
+ }
233
+ if (message.type === "logEntry") {
234
+ for (const handler of this.logSubscribers) handler(message.entry);
235
+ }
236
+ } catch {}
237
+ });
238
+
239
+ socket.addEventListener("close", (event) => {
240
+ this.socket = null;
241
+ this.socketOpenPromise = null;
242
+ if (event.code === 4401) {
243
+ this.onUnauthorized();
244
+ return;
245
+ }
246
+ if (event.code === 4403) {
247
+ this.onForbidden(event.reason || "Forbidden");
248
+ return;
249
+ }
250
+ this.scheduleReconnect();
251
+ });
252
+
253
+ socket.addEventListener("error", () => {
254
+ this.scheduleReconnect();
255
+ });
256
+
257
+ await new Promise((resolve, reject) => {
258
+ socket.addEventListener("open", resolve, { once: true });
259
+ socket.addEventListener("error", () => reject(new Error("WebSocket connection failed")), { once: true });
260
+ socket.addEventListener("close", (event) => {
261
+ if (event.code === 4401) reject(new ApiError("Unauthorized", { code: "UNAUTHORIZED", status: 401 }));
262
+ if (event.code === 4403) reject(new ApiError(event.reason || "Forbidden", { code: "FORBIDDEN", status: 403 }));
263
+ }, { once: true });
264
+ });
265
+
266
+ const isReconnect = this.hasConnected;
267
+ this.hasConnected = true;
268
+ this.resubscribeAll(isReconnect);
269
+ return socket;
270
+ })();
271
+
272
+ try {
273
+ return await this.socketOpenPromise;
274
+ } finally {
275
+ if (!this.socket || this.socket.readyState !== WebSocketImpl.OPEN) {
276
+ this.socketOpenPromise = null;
277
+ }
278
+ }
279
+ }
280
+
281
+ socketSend(message) {
282
+ if (this.socket && this.socket.readyState === this.WebSocketImpl.OPEN) {
283
+ this.socket.send(JSON.stringify(message));
284
+ }
285
+ }
286
+
287
+ resubscribeAll(isReconnect = false) {
288
+ for (const sessionId of this.sessionSubscribers.keys()) {
289
+ this.socketSend({ type: "subscribeSession", sessionId });
290
+ // On a RECONNECT, live delivery resumes but events emitted during
291
+ // the outage were missed. Signal consumers so they can replay via
292
+ // events?afterSeq. Not on the first connect — the consumer does its
293
+ // own initial catch-up then.
294
+ if (isReconnect) {
295
+ for (const onResubscribe of this.sessionResubscribeHandlers.get(sessionId) || []) {
296
+ try {
297
+ onResubscribe();
298
+ } catch {}
299
+ }
300
+ }
301
+ }
302
+ if (this.logSubscribers.size > 0) {
303
+ this.socketSend({ type: "subscribeLogs" });
304
+ }
305
+ }
306
+
307
+ /**
308
+ * Register a subscription and make sure the server knows about it: when
309
+ * the socket is already open, send the subscribe message directly;
310
+ * otherwise connect, and resubscribeAll() announces it on open.
311
+ */
312
+ announceSubscription(message) {
313
+ if (this.socket && this.socket.readyState === this.WebSocketImpl?.OPEN) {
314
+ this.socketSend(message);
315
+ return;
316
+ }
317
+ this.ensureSocket().catch(() => {});
318
+ }
319
+
320
+ /**
321
+ * Subscribe to a session's events. `onResubscribe` (optional) fires after
322
+ * every reconnect so the caller can replay events missed during the
323
+ * outage — WS delivery is an acceleration path; replay is the correctness
324
+ * mechanism.
325
+ */
326
+ subscribeSession(sessionId, handler, onResubscribe) {
327
+ if (!this.sessionSubscribers.has(sessionId)) {
328
+ this.sessionSubscribers.set(sessionId, new Set());
329
+ }
330
+ const handlers = this.sessionSubscribers.get(sessionId);
331
+ handlers.add(handler);
332
+ if (typeof onResubscribe === "function") {
333
+ if (!this.sessionResubscribeHandlers.has(sessionId)) {
334
+ this.sessionResubscribeHandlers.set(sessionId, new Set());
335
+ }
336
+ this.sessionResubscribeHandlers.get(sessionId).add(onResubscribe);
337
+ }
338
+ this.announceSubscription({ type: "subscribeSession", sessionId });
339
+
340
+ return () => {
341
+ handlers.delete(handler);
342
+ const resubHandlers = this.sessionResubscribeHandlers.get(sessionId);
343
+ if (resubHandlers && typeof onResubscribe === "function") resubHandlers.delete(onResubscribe);
344
+ if (handlers.size === 0) {
345
+ this.sessionSubscribers.delete(sessionId);
346
+ this.sessionResubscribeHandlers.delete(sessionId);
347
+ this.socketSend({ type: "unsubscribeSession", sessionId });
348
+ }
349
+ };
350
+ }
351
+
352
+ subscribeLogs(handler) {
353
+ this.logSubscribers.add(handler);
354
+ this.announceSubscription({ type: "subscribeLogs" });
355
+
356
+ return () => {
357
+ this.logSubscribers.delete(handler);
358
+ if (this.logSubscribers.size === 0) {
359
+ this.socketSend({ type: "unsubscribeLogs" });
360
+ }
361
+ };
362
+ }
363
+ }