@swarm-agent/sdk 0.1.47 → 0.1.48

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,408 @@
1
+ import { confirmPermissionResolution, permissionId as validatePermissionId, permissionResolutionBody, PermissionResolutionError } from './permission-ui.js';
2
+ export * from './permission-ui.js';
3
+ /**
4
+ * Universal browser-side WebSocket client for Swarm.
5
+ * Hooks directly into the Swarm WebSocket Bridge with typed callbacks for live
6
+ * assistant tokens, model reasoning deltas, real-time tool events, and permission approvals.
7
+ */
8
+ export class SwarmBrowserChat {
9
+ options;
10
+ ws = null;
11
+ currentSessionId = null;
12
+ isDisposed = false;
13
+ reconnectAttempts = 0;
14
+ reconnectTimer;
15
+ subscribedSessionId = null;
16
+ permissionsLive = false;
17
+ cancelSubscription;
18
+ connectPromise = null;
19
+ listeners = new Map();
20
+ pendingPermissions = new Map();
21
+ decisions = new Map();
22
+ get permissions() { return [...this.pendingPermissions.values()]; }
23
+ rejectDecisions(message) {
24
+ for (const [key, decision] of this.decisions) {
25
+ clearTimeout(decision.timer);
26
+ this.decisions.delete(key);
27
+ const error = new Error(message);
28
+ decision.reject(error);
29
+ this.emit('permission_error', decision.permissionId, error);
30
+ }
31
+ }
32
+ removePermission(id) {
33
+ if (this.pendingPermissions.delete(id)) {
34
+ this.emit('permission_removed', id);
35
+ this.emit('permissions', this.permissions);
36
+ }
37
+ }
38
+ constructor(options = {}) {
39
+ this.options = options;
40
+ if (options.autoConnect !== false) {
41
+ void this.connect().catch(() => { });
42
+ }
43
+ }
44
+ get sessionId() { return this.currentSessionId; }
45
+ get isSubscribed() { return this.isConnected && !!this.currentSessionId && this.subscribedSessionId === this.currentSessionId; }
46
+ get isConnected() {
47
+ return this.ws !== null && this.ws.readyState === 1; // 1 = OPEN
48
+ }
49
+ resolveUrl() {
50
+ if (this.options.url)
51
+ return this.options.url;
52
+ if (typeof location !== 'undefined') {
53
+ const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
54
+ return `${proto}//${location.host}/ws`;
55
+ }
56
+ return 'ws://localhost:3456/ws';
57
+ }
58
+ /**
59
+ * Connects to the Swarm WebSocket Bridge. Returns a Promise that resolves when connected.
60
+ */
61
+ async connect() {
62
+ if (this.isDisposed)
63
+ throw new Error('Chat client disposed');
64
+ if (this.isConnected)
65
+ return;
66
+ if (this.connectPromise)
67
+ return this.connectPromise;
68
+ this.connectPromise = new Promise((resolve, reject) => {
69
+ try {
70
+ const targetUrl = this.resolveUrl();
71
+ const WsClass = this.options.webSocketClass || (typeof WebSocket !== 'undefined' ? WebSocket : null);
72
+ if (!WsClass) {
73
+ throw new Error('No WebSocket implementation available. In Node.js environments, pass webSocketClass in options.');
74
+ }
75
+ const socket = new WsClass(targetUrl);
76
+ this.ws = socket;
77
+ let hasOpened = false;
78
+ socket.onopen = () => {
79
+ hasOpened = true;
80
+ this.reconnectAttempts = 0;
81
+ if (this.currentSessionId) {
82
+ this.send({ type: 'subscribe', sessionId: this.currentSessionId });
83
+ }
84
+ resolve();
85
+ };
86
+ socket.onmessage = (event) => {
87
+ if (this.ws !== socket)
88
+ return;
89
+ try {
90
+ const raw = typeof event.data === 'string' ? event.data : event.data?.toString('utf8');
91
+ const msg = JSON.parse(raw);
92
+ this.handleServerMessage(msg);
93
+ }
94
+ catch (error) {
95
+ this.emit('error', error instanceof Error ? error.message : 'Malformed server frame');
96
+ }
97
+ };
98
+ socket.onclose = () => {
99
+ if (this.ws !== socket)
100
+ return;
101
+ this.ws = null;
102
+ this.subscribedSessionId = null;
103
+ this.permissionsLive = false;
104
+ this.rejectDecisions('Disconnected before permission acknowledgement; outcome unknown. Reconnect and review pending requests before retrying.');
105
+ this.emit('disconnected');
106
+ if (!hasOpened) {
107
+ reject(new Error('WebSocket closed before opening'));
108
+ }
109
+ this.maybeReconnect();
110
+ };
111
+ socket.onerror = (err) => {
112
+ this.emit('disconnected');
113
+ if (!hasOpened) {
114
+ reject(err instanceof Error ? err : new Error('WebSocket connection error'));
115
+ }
116
+ };
117
+ }
118
+ catch (err) {
119
+ this.emit('error', err.message);
120
+ this.maybeReconnect();
121
+ reject(err);
122
+ }
123
+ }).finally(() => {
124
+ this.connectPromise = null;
125
+ });
126
+ return this.connectPromise;
127
+ }
128
+ maybeReconnect() {
129
+ if (this.isDisposed || this.options.autoReconnect === false || this.reconnectTimer)
130
+ return;
131
+ const max = this.options.maxReconnectAttempts ?? 10;
132
+ if (this.reconnectAttempts >= max)
133
+ return;
134
+ const delay = Math.min(500 * Math.pow(1.5, this.reconnectAttempts++), 10_000);
135
+ this.reconnectTimer = setTimeout(() => {
136
+ this.reconnectTimer = undefined;
137
+ if (!this.isDisposed) {
138
+ void this.connect().catch(() => { });
139
+ }
140
+ }, delay);
141
+ }
142
+ handleServerMessage(msg) {
143
+ if (msg.sessionId && msg.sessionId !== this.currentSessionId)
144
+ return;
145
+ switch (msg.type) {
146
+ case 'connected':
147
+ this.emit('connected');
148
+ break;
149
+ case 'state':
150
+ if (msg.state) {
151
+ this.permissionsLive = msg.state.status === 'live';
152
+ const rawPending = msg.state.snapshot.session_views_by_id?.[this.currentSessionId]?.pending_permissions;
153
+ const pending = rawPending === null ? [] : rawPending;
154
+ if (this.permissionsLive && Array.isArray(pending)) {
155
+ if (pending.some(p => !p || typeof p.id !== 'string' || !p.id || p.session_id !== this.currentSessionId)) {
156
+ this.permissionsLive = false;
157
+ throw new Error('Invalid pending permission snapshot; refresh the conversation');
158
+ }
159
+ const records = pending.filter(p => p && p.session_id === this.currentSessionId && p.status === 'pending');
160
+ const ids = new Set(records.map(p => p.id));
161
+ for (const id of this.pendingPermissions.keys())
162
+ if (!ids.has(id))
163
+ this.removePermission(id);
164
+ for (const p of records)
165
+ this.pendingPermissions.set(p.id, p);
166
+ this.emit('permissions', this.permissions);
167
+ }
168
+ this.emit('state', msg.state);
169
+ }
170
+ break;
171
+ case 'accepted':
172
+ this.emit('accepted', msg.requestId ?? '');
173
+ break;
174
+ case 'subscribed':
175
+ if (msg.sessionId) {
176
+ this.subscribedSessionId = msg.sessionId;
177
+ this.emit('subscribed', msg.sessionId);
178
+ }
179
+ break;
180
+ case 'unsubscribed':
181
+ this.emit('unsubscribed');
182
+ break;
183
+ case 'text':
184
+ this.emit('text', msg.delta || '', msg.fullText || '');
185
+ break;
186
+ case 'reasoning':
187
+ this.emit('reasoning', msg.delta || '', msg.fullText || '');
188
+ break;
189
+ case 'tool_start':
190
+ if (msg.tool)
191
+ this.emit('tool_start', msg.tool);
192
+ break;
193
+ case 'tool_delta':
194
+ if (msg.tool)
195
+ this.emit('tool_delta', msg.tool, msg.delta || '');
196
+ break;
197
+ case 'tool_done':
198
+ if (msg.tool)
199
+ this.emit('tool_done', msg.tool);
200
+ break;
201
+ case 'tool_permission':
202
+ if (msg.permission && msg.permission.session_id === this.currentSessionId && msg.permission.status === 'pending') {
203
+ this.pendingPermissions.set(msg.permission.id, msg.permission);
204
+ this.emit('tool_permission', msg.permission);
205
+ this.emit('permissions', this.permissions);
206
+ }
207
+ break;
208
+ case 'permission_resolved': {
209
+ const decision = msg.requestId ? this.decisions.get(msg.requestId) : undefined;
210
+ if (!decision || msg.sessionId !== decision.sessionId || msg.permissionId !== decision.permissionId)
211
+ break;
212
+ clearTimeout(decision.timer);
213
+ this.decisions.delete(msg.requestId);
214
+ try {
215
+ const result = confirmPermissionResolution(msg.result, decision.sessionId, decision.permissionId, decision.action, decision.options);
216
+ this.removePermission(decision.permissionId);
217
+ decision.resolve(result);
218
+ this.emit('permission_resolved', decision.permissionId, result);
219
+ }
220
+ catch (error) {
221
+ const err = error instanceof Error ? error : new Error(String(error));
222
+ decision.reject(err);
223
+ this.emit('permission_error', decision.permissionId, err);
224
+ }
225
+ break;
226
+ }
227
+ case 'permission_removed':
228
+ if (msg.permissionId)
229
+ this.removePermission(msg.permissionId);
230
+ break;
231
+ case 'permission_error': {
232
+ const decision = msg.requestId ? this.decisions.get(msg.requestId) : undefined;
233
+ const result = msg.result?.session_id === this.currentSessionId && msg.result?.permission?.id === msg.permissionId ? msg.result : undefined;
234
+ const error = new PermissionResolutionError(msg.error || 'Permission resolution failed; refresh before retrying', result);
235
+ if (decision && msg.sessionId === decision.sessionId && msg.permissionId === decision.permissionId) {
236
+ clearTimeout(decision.timer);
237
+ this.decisions.delete(msg.requestId);
238
+ decision.reject(error);
239
+ }
240
+ this.emit('permission_error', msg.permissionId ?? '', error);
241
+ break;
242
+ }
243
+ case 'done':
244
+ this.emit('done', msg.messages || []);
245
+ break;
246
+ case 'error':
247
+ this.emit('error', msg.error || 'Unknown error');
248
+ break;
249
+ }
250
+ }
251
+ send(msg) {
252
+ if (!this.isConnected)
253
+ throw new Error('Chat disconnected; reconnect and subscribe before sending');
254
+ this.ws.send(JSON.stringify(msg));
255
+ }
256
+ /**
257
+ * Subscribes to real-time events for a specific session.
258
+ * Returns a promise that resolves once the session subscription is acknowledged.
259
+ */
260
+ async subscribe(sessionId, autoApprovePermissions) {
261
+ if (typeof sessionId !== 'string' || !sessionId.trim()) {
262
+ throw new Error('sessionId must be a non-empty string; create or select a conversation before subscribing');
263
+ }
264
+ sessionId = sessionId.trim();
265
+ this.cancelSubscription?.('Subscription superseded');
266
+ this.rejectDecisions('Subscription changed before permission acknowledgement; outcome unknown');
267
+ if (this.currentSessionId !== sessionId) {
268
+ this.permissionsLive = false;
269
+ this.pendingPermissions.clear();
270
+ this.emit('permissions', []);
271
+ }
272
+ this.currentSessionId = sessionId;
273
+ this.subscribedSessionId = null;
274
+ await this.connect();
275
+ if (this.currentSessionId !== sessionId)
276
+ throw new Error('Subscription superseded');
277
+ return new Promise((resolve, reject) => {
278
+ const cleanup = () => { clearTimeout(timer); unsub(); offError(); offClose(); this.cancelSubscription = undefined; };
279
+ const fail = (error) => { cleanup(); reject(new Error(error)); };
280
+ const unsub = this.on('subscribed', (subId) => {
281
+ if (subId === sessionId) {
282
+ cleanup();
283
+ resolve(subId);
284
+ }
285
+ });
286
+ const offError = this.on('error', fail);
287
+ const offClose = this.on('disconnected', () => fail('Disconnected before subscription acknowledgement'));
288
+ this.cancelSubscription = fail;
289
+ const timer = setTimeout(() => fail('Subscription acknowledgement timed out'), this.options.subscribeTimeoutMs ?? 45_000);
290
+ try {
291
+ this.send({ type: 'subscribe', sessionId, autoApprovePermissions });
292
+ }
293
+ catch (error) {
294
+ fail(error instanceof Error ? error.message : String(error));
295
+ }
296
+ });
297
+ }
298
+ /** Unsubscribes from active session streaming */
299
+ unsubscribe() {
300
+ this.cancelSubscription?.('Unsubscribed');
301
+ this.rejectDecisions('Unsubscribed before permission acknowledgement; outcome unknown');
302
+ this.pendingPermissions.clear();
303
+ this.emit('permissions', []);
304
+ this.currentSessionId = null;
305
+ this.subscribedSessionId = null;
306
+ if (this.isConnected)
307
+ this.send({ type: 'unsubscribe' });
308
+ }
309
+ /** Sends a chat message over the WebSocket */
310
+ sendMessage(message, sessionId, autoApprovePermissions, requestId) {
311
+ const targetSession = sessionId || this.currentSessionId;
312
+ if (!targetSession) {
313
+ throw new Error('No active session. Call subscribe(sessionId) or provide sessionId');
314
+ }
315
+ if (!this.isSubscribed || targetSession !== this.currentSessionId)
316
+ throw new Error('Await subscribe(sessionId) before sending');
317
+ const id = requestId ?? globalThis.crypto.randomUUID();
318
+ this.send({
319
+ type: 'chat_message',
320
+ requestId: id,
321
+ sessionId: targetSession,
322
+ message,
323
+ autoApprovePermissions,
324
+ });
325
+ return id;
326
+ }
327
+ /** Resolve only after a user gesture. Await confirmation; on failure keep the form recoverable. */
328
+ async resolvePermission(permissionId, action, options = {}) {
329
+ permissionId = validatePermissionId(permissionId);
330
+ permissionResolutionBody(action, options);
331
+ const targetSession = options.sessionId === undefined ? this.currentSessionId : validatePermissionId(options.sessionId, 'sessionId');
332
+ if (!this.isSubscribed || !targetSession || targetSession !== this.currentSessionId)
333
+ throw new Error('Await subscribe(sessionId) before resolving permissions');
334
+ if (!this.permissionsLive)
335
+ throw new Error('Permission state is reconnecting; wait for fresh hydration');
336
+ if (!this.pendingPermissions.has(permissionId))
337
+ throw new Error('Permission is not pending in this conversation; refresh before replying');
338
+ if ([...this.decisions.values()].some(d => d.permissionId === permissionId))
339
+ throw new Error('Permission resolution already in flight');
340
+ const requestId = globalThis.crypto.randomUUID();
341
+ return new Promise((resolve, reject) => {
342
+ const timer = setTimeout(() => {
343
+ this.decisions.delete(requestId);
344
+ this.permissionsLive = false;
345
+ const error = new Error('Permission acknowledgement timed out; outcome unknown. Refresh before retrying.');
346
+ reject(error);
347
+ this.emit('permission_error', permissionId, error);
348
+ }, this.options.permissionTimeoutMs ?? 45_000);
349
+ this.decisions.set(requestId, { permissionId, sessionId: targetSession, action, options, resolve, reject, timer });
350
+ try {
351
+ this.send({ type: 'resolve_permission', sessionId: targetSession, permissionId, action, requestId,
352
+ reason: options.reason, approvedArguments: options.approvedArguments });
353
+ }
354
+ catch (error) {
355
+ clearTimeout(timer);
356
+ this.decisions.delete(requestId);
357
+ reject(error);
358
+ }
359
+ });
360
+ }
361
+ on(event, handler) {
362
+ if (!this.listeners.has(event)) {
363
+ this.listeners.set(event, new Set());
364
+ }
365
+ this.listeners.get(event).add(handler);
366
+ // If already connected and listener is for connected, fire immediately
367
+ if (event === 'connected' && this.isConnected) {
368
+ setTimeout(() => handler(), 0);
369
+ }
370
+ return () => this.off(event, handler);
371
+ }
372
+ off(event, handler) {
373
+ const handlers = this.listeners.get(event);
374
+ if (handlers)
375
+ handlers.delete(handler);
376
+ }
377
+ emit(event, ...args) {
378
+ const handlers = this.listeners.get(event);
379
+ if (handlers) {
380
+ for (const h of handlers) {
381
+ try {
382
+ h(...args);
383
+ }
384
+ catch { }
385
+ }
386
+ }
387
+ }
388
+ /** Closes and disposes the WebSocket connection */
389
+ dispose() {
390
+ this.isDisposed = true;
391
+ this.rejectDecisions('Client disposed before permission acknowledgement; outcome unknown');
392
+ clearTimeout(this.reconnectTimer);
393
+ this.emit('disconnected');
394
+ if (this.ws) {
395
+ try {
396
+ if (typeof this.ws.terminate === 'function') {
397
+ this.ws.terminate();
398
+ }
399
+ else {
400
+ this.ws.close();
401
+ }
402
+ }
403
+ catch { }
404
+ this.ws = null;
405
+ }
406
+ this.listeners.clear();
407
+ }
408
+ }
package/dist/chat.d.ts ADDED
@@ -0,0 +1,134 @@
1
+ import type { PendingPermissionRecord } from './permissions.js';
2
+ import type { PermissionResolver } from './permissions.js';
3
+ import type { SessionWatch, SessionWatchState, V3Message } from './realtime.js';
4
+ import type { SwarmTransport } from './transport.js';
5
+ import type { SessionDetail, SessionRecord } from './types.js';
6
+ import { SwarmWebSocketBridge, type SwarmWebSocketBridgeOptions } from './websocket.js';
7
+ export interface CreateChatSessionOptions {
8
+ /** Descriptive title for this conversation */
9
+ title?: string;
10
+ /** Agent profile name (defaults to 'swarm') */
11
+ agent_name?: string;
12
+ /** Workspace filesystem path */
13
+ workspace_path?: string;
14
+ /** Workspace catalog identity */
15
+ workspace_id?: string;
16
+ /** Project identity if linked to a project */
17
+ project_id?: string;
18
+ /**
19
+ * Mode:
20
+ * - 'auto' (default): Autonomous execution with tool capabilities.
21
+ * - 'plan': Plan-mode requiring explicit checkpoint plan submission.
22
+ */
23
+ mode?: 'auto' | 'plan';
24
+ }
25
+ export interface ToolStreamItem {
26
+ id: string;
27
+ name: string;
28
+ identity?: string;
29
+ display?: string;
30
+ arguments?: string;
31
+ output?: string;
32
+ status: 'running' | 'completed' | 'failed' | 'waiting_approval' | 'cancelled';
33
+ error?: string;
34
+ durationMs?: number;
35
+ }
36
+ export interface ChatRunOptions {
37
+ /** User message to send */
38
+ message: string;
39
+ /** Max milliseconds to wait for response (default: 180,000ms / 3min) */
40
+ timeoutMs?: number;
41
+ /** Polling interval in ms (default: 1,000ms) */
42
+ pollIntervalMs?: number;
43
+ /**
44
+ * Explicit opt-in policy for ordinary tools only. Ask-user always needs a human answer.
45
+ * Prefer stream() and an explicit permission UI.
46
+ */
47
+ autoApprovePermissions?: boolean;
48
+ /** Optional callback invoked whenever a tool execution starts or completes */
49
+ onToolEvent?: (event: ToolStreamItem) => void;
50
+ /** Optional AbortSignal to cancel waiting */
51
+ signal?: AbortSignal;
52
+ }
53
+ export interface ChatRunResult {
54
+ /** The final text response from the assistant */
55
+ reply: string;
56
+ /** Target session ID */
57
+ sessionId: string;
58
+ /** Active or completed run ID */
59
+ runId?: string;
60
+ /** All messages in the session */
61
+ messages: V3Message[];
62
+ /** Any permissions that were requested during this run */
63
+ pendingPermissions?: PendingPermissionRecord[];
64
+ /** Full session detail */
65
+ session: SessionDetail;
66
+ }
67
+ export interface ChatStreamOptions {
68
+ signal?: AbortSignal;
69
+ surface?: string;
70
+ socketFactory?: import('./realtime.js').RealtimeSocketFactory;
71
+ /** Authoritative conversation snapshot on hydrate, live change and reconnect. */
72
+ onState?: (state: SessionWatchState) => void;
73
+ /** Invoked with incremental and cumulative assistant response text */
74
+ onText?: (delta: string, fullText: string) => void;
75
+ /** Invoked with incremental and cumulative reasoning/thinking text */
76
+ onReasoning?: (delta: string, fullText: string) => void;
77
+ /** Invoked when a tool call starts running */
78
+ onToolStarted?: (tool: ToolStreamItem) => void;
79
+ /** Invoked with streamed incremental output from a running tool */
80
+ onToolDelta?: (tool: ToolStreamItem, delta: string) => void;
81
+ /** Invoked when a tool call finishes or fails */
82
+ onToolCompleted?: (tool: ToolStreamItem) => void;
83
+ /** Invoked when a tool requires approval before execution */
84
+ onPermissionRequested?: (permission: PendingPermissionRecord, resolver: PermissionResolver) => void | Promise<void>;
85
+ /** Request no longer pending in a durable snapshot; this does not imply approval. */
86
+ onPermissionRemoved?: (permissionId: string) => void;
87
+ /** Recoverable UI/resolution failure; the watch stays open. Resolver also rejects. */
88
+ onPermissionError?: (error: Error, permission: PendingPermissionRecord) => void;
89
+ /** Invoked when the agent turn completes */
90
+ onComplete?: (messages: V3Message[]) => void;
91
+ /** Invoked if an unrecoverable streaming error occurs */
92
+ onError?: (err: Error) => void;
93
+ }
94
+ /**
95
+ * Dedicated high-level Chat namespace.
96
+ * Provides streamlined building blocks for interactive AI chat, streaming responses,
97
+ * live tool call monitoring, and automatic or custom permission handling.
98
+ */
99
+ export declare class SwarmChatNamespace {
100
+ private readonly transport;
101
+ private readonly sessions;
102
+ private readonly permissions;
103
+ private readonly realtime;
104
+ constructor(transport: SwarmTransport);
105
+ /**
106
+ * Attaches a native bidirectional WebSocket bridge to an HTTP or HTTPS server.
107
+ * Enables browsers to stream tokens, live tool events, and send messages directly over WebSockets.
108
+ */
109
+ attachWebSocket(server: import('node:http').Server | import('node:https').Server, options?: SwarmWebSocketBridgeOptions): SwarmWebSocketBridge;
110
+ /**
111
+ * Creates an interactive chat session.
112
+ * Standalone workspace chat uses Swarm. For project orchestration use
113
+ * projects.createConversation(projectId); project membership is not workspace authority.
114
+ */
115
+ createSession(options?: CreateChatSessionOptions): Promise<SessionRecord>;
116
+ /**
117
+ * Appends a message to a chat conversation.
118
+ */
119
+ sendMessage(sessionId: string, content: string, role?: 'user' | 'assistant' | 'system', clientRequestId?: string): Promise<any>;
120
+ /**
121
+ * Lists messages from a session conversation.
122
+ */
123
+ listMessages(sessionId: string): Promise<V3Message[]>;
124
+ /**
125
+ * High-level chat execution: sends a message, monitors execution, handles or surfaces
126
+ * pending permissions, and returns the final assistant reply.
127
+ */
128
+ run(sessionId: string, options: ChatRunOptions): Promise<ChatRunResult>;
129
+ /**
130
+ * Establishes a real-time event stream for a chat session.
131
+ * Streams text deltas, reasoning deltas, and live tool start/output/completion events like on Swarm.
132
+ */
133
+ stream(sessionId: string, options?: ChatStreamOptions): Promise<SessionWatch>;
134
+ }