@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.
package/dist/sessions.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { SwarmTimeoutError } from './errors.js';
2
+ import { SwarmPermissionsNamespace, isAskUserPermission } from './permissions.js';
2
3
  function generateRequestId() {
3
4
  if (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function') {
4
5
  return `sdk-session-${crypto.randomUUID()}`;
@@ -22,6 +23,7 @@ export class SwarmSessionsNamespace {
22
23
  client_request_id: clientRequestId,
23
24
  agent_name: agentName,
24
25
  title: params.title,
26
+ project_id: params.project_id,
25
27
  workspace_id: params.workspace_id,
26
28
  workspace_path: params.workspace_path,
27
29
  mode: params.mode ?? 'auto',
@@ -43,6 +45,8 @@ export class SwarmSessionsNamespace {
43
45
  q.set('limit', params.limit.toString());
44
46
  if (params.state)
45
47
  q.set('state', params.state);
48
+ if (params.project_id)
49
+ q.set('project_id', params.project_id);
46
50
  if (params.workspace_id)
47
51
  q.set('workspace_id', params.workspace_id);
48
52
  if (params.category)
@@ -52,13 +56,22 @@ export class SwarmSessionsNamespace {
52
56
  const queryStr = q.toString() ? `?${q.toString()}` : '';
53
57
  const res = await this.transport.request(`/v3/sessions${queryStr}`, { method: 'GET' });
54
58
  const data = res.data;
59
+ let list = [];
55
60
  if (Array.isArray(data)) {
56
- return data;
61
+ list = data;
57
62
  }
58
- if (data && typeof data === 'object' && 'sessions' in data && Array.isArray(data.sessions)) {
59
- return data.sessions;
63
+ else if (data && typeof data === 'object' && 'sessions' in data && Array.isArray(data.sessions)) {
64
+ list = data.sessions;
60
65
  }
61
- return [];
66
+ return list.map((item) => {
67
+ if (item && item.session && typeof item.session === 'object') {
68
+ return {
69
+ ...item.session,
70
+ projection: item.projection,
71
+ };
72
+ }
73
+ return item;
74
+ });
62
75
  }
63
76
  /**
64
77
  * Retrieves full details, history, and state for a single session.
@@ -173,22 +186,36 @@ export class SwarmSessionsNamespace {
173
186
  }
174
187
  /**
175
188
  * Helper that polls a session until its active run completes, or times out.
189
+ * Explicit autoApprovePermissions applies only to ordinary tools; questions still require user input.
176
190
  */
177
191
  async waitForRun(sessionId, options = {}) {
178
- const timeoutMs = options.timeoutMs ?? 60_000;
192
+ const timeoutMs = options.timeoutMs ?? 180_000;
179
193
  const pollIntervalMs = options.pollIntervalMs ?? 1_000;
180
194
  const deadline = Date.now() + timeoutMs;
195
+ const permissions = new SwarmPermissionsNamespace(this.transport);
196
+ let lastPendingPerms = [];
181
197
  while (Date.now() < deadline) {
198
+ lastPendingPerms = await permissions.listSessionPending(sessionId, 20);
199
+ if (options.autoApprovePermissions) {
200
+ for (const p of lastPendingPerms) {
201
+ if (!isAskUserPermission(p))
202
+ await permissions.resolve(sessionId, p.id, 'allow_once', { reason: 'Approved by explicit application policy' });
203
+ }
204
+ }
182
205
  const detail = await this.get(sessionId);
183
206
  const raw = (detail.raw || {});
184
207
  const activeRun = raw.active_run_intent;
185
208
  const state = (detail.state || '').toLowerCase();
186
209
  const isRunning = state.includes('running') || state.includes('in_progress') || !!activeRun;
187
- if (!isRunning) {
210
+ if (!isRunning && lastPendingPerms.length === 0) {
188
211
  return detail;
189
212
  }
190
213
  await new Promise((r) => setTimeout(r, pollIntervalMs));
191
214
  }
192
- throw new SwarmTimeoutError(`Session run did not complete within ${timeoutMs}ms`, timeoutMs);
215
+ let msg = `Session run did not complete within ${timeoutMs}ms`;
216
+ if (lastPendingPerms.length > 0) {
217
+ msg += `. Session has ${lastPendingPerms.length} pending tool permission(s) requiring approval: ${lastPendingPerms.map((p) => p.tool_name).join(', ')}. Render the requests and explicitly answer or deny via client.permissions.resolve().`;
218
+ }
219
+ throw new SwarmTimeoutError(msg, timeoutMs);
193
220
  }
194
221
  }
@@ -36,6 +36,12 @@ export interface AgentModelSettingsResponse {
36
36
  agent_model_settings: AgentModelSettings;
37
37
  roles: Record<string, string>[];
38
38
  }
39
+ export interface ModelRecommendation {
40
+ role: string;
41
+ thinking?: string;
42
+ serving?: string;
43
+ [key: string]: unknown;
44
+ }
39
45
  /** Catalog extensions are preserved as unknown, never interpreted as runtime capability grants. */
40
46
  export interface ModelCatalogEntry {
41
47
  provider: string;
@@ -50,6 +56,7 @@ export interface ModelCatalogEntry {
50
56
  default_thinking?: string;
51
57
  service_tiers?: string[];
52
58
  default_service_tier?: string;
59
+ recommendations?: ModelRecommendation[];
53
60
  context_modes?: {
54
61
  mode: string;
55
62
  label?: string;
@@ -72,6 +79,13 @@ export interface ModelCatalogResponse {
72
79
  [key: string]: unknown;
73
80
  };
74
81
  }
82
+ export interface FleetModelAssignments {
83
+ swarm: {
84
+ action: ModelAssignment;
85
+ plan: ModelAssignment;
86
+ };
87
+ system_agents: Record<SystemAgentSlot, ModelAssignment>;
88
+ }
75
89
  /** Account-scoped settings. Requires the local administrative connection, not a session token. */
76
90
  export declare class SwarmSettingsNamespace {
77
91
  private readonly transport;
@@ -81,5 +95,19 @@ export declare class SwarmSettingsNamespace {
81
95
  agentModels(): Promise<AgentModelSettingsResponse>;
82
96
  setSwarmModel(slot: 'action' | 'plan', assignment: ModelAssignment): Promise<AgentModelSettingsResponse>;
83
97
  setSystemAgentModel(slot: SystemAgentSlot, assignment: ModelAssignment): Promise<AgentModelSettingsResponse>;
84
- private patch;
98
+ /**
99
+ * Restores verified recommended defaults across all system agent and swarm core roles.
100
+ * If expectedUpdatedAt is omitted, automatically reads the current setting's revision first.
101
+ */
102
+ restoreDefaults(expectedUpdatedAt?: number): Promise<AgentModelSettingsResponse>;
103
+ /**
104
+ * Resolves the recommended model and thinking level for each role from a provider's catalog.
105
+ */
106
+ getRecommendationsForProvider(provider: string): Promise<FleetModelAssignments>;
107
+ /**
108
+ * Applies verified onboarding model recommendations for a specific provider across the entire fleet:
109
+ * Swarm Action, Swarm Plan, and System Agents (Coder, Finder, Designer, Router, Compact).
110
+ */
111
+ applyProviderFleet(provider: string): Promise<AgentModelSettingsResponse>;
112
+ patch(body: object): Promise<AgentModelSettingsResponse>;
85
113
  }
package/dist/settings.js CHANGED
@@ -20,6 +20,75 @@ export class SwarmSettingsNamespace {
20
20
  async setSystemAgentModel(slot, assignment) {
21
21
  return this.patch({ system_agents: { [slot]: assignment } });
22
22
  }
23
+ /**
24
+ * Restores verified recommended defaults across all system agent and swarm core roles.
25
+ * If expectedUpdatedAt is omitted, automatically reads the current setting's revision first.
26
+ */
27
+ async restoreDefaults(expectedUpdatedAt) {
28
+ let updatedAt = expectedUpdatedAt;
29
+ if (typeof updatedAt !== 'number') {
30
+ const current = await this.agentModels();
31
+ updatedAt = current.agent_model_settings.updated_at;
32
+ }
33
+ return (await this.transport.request('/v1/agent-model-settings/restore-defaults', {
34
+ method: 'POST',
35
+ body: { expected_updated_at: updatedAt },
36
+ })).data;
37
+ }
38
+ /**
39
+ * Resolves the recommended model and thinking level for each role from a provider's catalog.
40
+ */
41
+ async getRecommendationsForProvider(provider) {
42
+ const normalizedProvider = provider.toLowerCase().trim();
43
+ const catalog = await this.models(normalizedProvider);
44
+ const records = catalog.records || [];
45
+ const findForRole = (role) => {
46
+ for (const entry of records) {
47
+ if (!entry.recommendations)
48
+ continue;
49
+ const rec = entry.recommendations.find((r) => r.role?.toLowerCase().trim() === role.toLowerCase().trim());
50
+ if (rec) {
51
+ return {
52
+ model: entry.model,
53
+ thinking: rec.thinking || entry.default_thinking || 'medium',
54
+ };
55
+ }
56
+ }
57
+ return null;
58
+ };
59
+ const autoMatch = findForRole('auto') || {
60
+ model: records[0]?.model || 'default',
61
+ thinking: records[0]?.default_thinking || 'high',
62
+ };
63
+ const planMatch = findForRole('plan') || autoMatch;
64
+ const coderMatch = findForRole('coder') || autoMatch;
65
+ const utilityMatch = findForRole('utility');
66
+ const routerMatch = findForRole('router') || utilityMatch || autoMatch;
67
+ const compactMatch = findForRole('compact') || utilityMatch || autoMatch;
68
+ const finderMatch = findForRole('finder') || utilityMatch || autoMatch;
69
+ const designerMatch = findForRole('designer') || planMatch || autoMatch;
70
+ return {
71
+ swarm: {
72
+ action: { provider: normalizedProvider, model: autoMatch.model, thinking: autoMatch.thinking },
73
+ plan: { provider: normalizedProvider, model: planMatch.model, thinking: planMatch.thinking },
74
+ },
75
+ system_agents: {
76
+ coder: { provider: normalizedProvider, model: coderMatch.model, thinking: coderMatch.thinking },
77
+ finder: { provider: normalizedProvider, model: finderMatch.model, thinking: finderMatch.thinking },
78
+ designer: { provider: normalizedProvider, model: designerMatch.model, thinking: designerMatch.thinking },
79
+ router: { provider: normalizedProvider, model: routerMatch.model, thinking: routerMatch.thinking },
80
+ compact: { provider: normalizedProvider, model: compactMatch.model, thinking: compactMatch.thinking },
81
+ },
82
+ };
83
+ }
84
+ /**
85
+ * Applies verified onboarding model recommendations for a specific provider across the entire fleet:
86
+ * Swarm Action, Swarm Plan, and System Agents (Coder, Finder, Designer, Router, Compact).
87
+ */
88
+ async applyProviderFleet(provider) {
89
+ const fleet = await this.getRecommendationsForProvider(provider);
90
+ return this.patch(fleet);
91
+ }
23
92
  async patch(body) {
24
93
  return (await this.transport.request('/v1/agent-model-settings', { method: 'PATCH', body })).data;
25
94
  }
package/dist/types.d.ts CHANGED
@@ -290,6 +290,7 @@ export interface SessionRecord {
290
290
  }
291
291
  export interface CreateSessionParams {
292
292
  title?: string;
293
+ project_id?: string;
293
294
  workspace_id?: string;
294
295
  workspace_path?: string;
295
296
  agent_name?: string;
@@ -301,6 +302,7 @@ export interface CreateSessionParams {
301
302
  export interface ListSessionsParams {
302
303
  limit?: number;
303
304
  state?: string;
305
+ project_id?: string;
304
306
  workspace_id?: string;
305
307
  category?: 'video' | 'needs_review' | 'blocked' | 'in_progress' | 'active_chats' | 'archived';
306
308
  cursor?: string;
@@ -447,7 +449,15 @@ export interface ProjectTaskMediaRef {
447
449
  size_bytes?: number;
448
450
  created_at?: number;
449
451
  }
452
+ export interface ProjectContextGeneration {
453
+ status: 'pending' | 'running' | 'ready' | 'failed';
454
+ attempt: number;
455
+ lease_until?: number;
456
+ error?: string;
457
+ router_alert?: string;
458
+ }
450
459
  export interface ProjectRecord {
460
+ context_generation?: ProjectContextGeneration;
451
461
  id: string;
452
462
  account_id?: string;
453
463
  name: string;
@@ -462,6 +472,8 @@ export interface ProjectRecord {
462
472
  updated_at: number;
463
473
  }
464
474
  export interface CreateProjectParams {
475
+ /** Reuse this key with the same input after a lost creation response. */
476
+ client_request_id?: string;
465
477
  id?: string;
466
478
  name: string;
467
479
  description?: string;
@@ -949,6 +961,7 @@ export interface WorkerRevisionRecord {
949
961
  change_summary?: string;
950
962
  }
951
963
  export interface WorkerRunRecord {
964
+ placement?: import('./worker-control.js').WorkerRunPlacement;
952
965
  cancel_requested?: boolean;
953
966
  id: string;
954
967
  account_scope_id: string;
@@ -0,0 +1,60 @@
1
+ import type { IncomingMessage, Server as HttpServer } from 'node:http';
2
+ import type { Server as HttpsServer } from 'node:https';
3
+ import { WebSocket } from 'ws';
4
+ import type { SwarmChatNamespace, ToolStreamItem } from './chat.js';
5
+ import type { PendingPermissionRecord, SwarmPermissionsNamespace, PermissionResolver, ResolvePermissionOptions, ResolvePermissionResult } from './permissions.js';
6
+ import type { SessionWatchState, V3Message } from './realtime.js';
7
+ export interface SwarmWebSocketBridgeOptions {
8
+ path?: string;
9
+ /** Server-owned policy. Browser frames cannot enable auto approval. Questions always require a reply. */
10
+ autoApprovePermissions?: boolean;
11
+ socketFactory?: import('./realtime.js').RealtimeSocketFactory;
12
+ /** Authenticate/authorize upgrades for non-loopback or multi-user applications. */
13
+ authorize?: (request: IncomingMessage) => boolean | Promise<boolean>;
14
+ onPermissionRequested?: (permission: PendingPermissionRecord, clientSocket: WebSocket, resolve: PermissionResolver) => void | Promise<void>;
15
+ }
16
+ export interface SwarmWebSocketClientMessage {
17
+ type: 'subscribe' | 'chat_message' | 'resolve_permission' | 'ping' | 'unsubscribe';
18
+ sessionId?: string;
19
+ message?: string;
20
+ requestId?: string;
21
+ permissionId?: string;
22
+ action?: 'allow_once' | 'deny' | 'deny_once' | 'allow_always' | 'deny_always';
23
+ reason?: string;
24
+ approvedArguments?: ResolvePermissionOptions['approvedArguments'];
25
+ /** @deprecated Configure permission policy on the server instead. */
26
+ autoApprovePermissions?: boolean;
27
+ }
28
+ export interface SwarmWebSocketServerMessage {
29
+ type: 'connected' | 'subscribed' | 'unsubscribed' | 'state' | 'accepted' | 'text' | 'reasoning' | 'tool_start' | 'tool_delta' | 'tool_done' | 'tool_permission' | 'permission_resolved' | 'permission_removed' | 'permission_error' | 'done' | 'error' | 'pong';
30
+ sessionId?: string;
31
+ requestId?: string;
32
+ state?: SessionWatchState;
33
+ delta?: string;
34
+ fullText?: string;
35
+ tool?: ToolStreamItem;
36
+ permission?: PendingPermissionRecord;
37
+ result?: ResolvePermissionResult;
38
+ permissionId?: string;
39
+ messages?: V3Message[];
40
+ error?: string;
41
+ }
42
+ /** Application-owned bridge to canonical V3 realtime. A subscription survives every turn.
43
+ * One socket selects one conversation; reconnect rehydrates that exact conversation.
44
+ * Never expose this privileged bridge publicly without application authentication.
45
+ */
46
+ export declare class SwarmWebSocketBridge {
47
+ private readonly chat;
48
+ private readonly permissions;
49
+ private readonly options;
50
+ private readonly wss;
51
+ private readonly activeWatches;
52
+ constructor(server: HttpServer | HttpsServer, chat: SwarmChatNamespace, permissions: SwarmPermissionsNamespace, options?: SwarmWebSocketBridgeOptions);
53
+ private send;
54
+ private handleConnection;
55
+ private handleMessage;
56
+ private cleanupSocket;
57
+ private subscribeSocket;
58
+ close(): Promise<void>;
59
+ }
60
+ export declare function createSwarmWebSocketBridge(server: HttpServer | HttpsServer, chat: SwarmChatNamespace, permissions: SwarmPermissionsNamespace, options?: SwarmWebSocketBridgeOptions): SwarmWebSocketBridge;
@@ -0,0 +1,207 @@
1
+ import { WebSocketServer, WebSocket } from 'ws';
2
+ import { isAskUserPermission, permissionId, permissionResolutionBody, PermissionResolutionError } from './permission-ui.js';
3
+ /** Application-owned bridge to canonical V3 realtime. A subscription survives every turn.
4
+ * One socket selects one conversation; reconnect rehydrates that exact conversation.
5
+ * Never expose this privileged bridge publicly without application authentication.
6
+ */
7
+ export class SwarmWebSocketBridge {
8
+ chat;
9
+ permissions;
10
+ options;
11
+ wss;
12
+ activeWatches = new Map();
13
+ constructor(server, chat, permissions, options = {}) {
14
+ this.chat = chat;
15
+ this.permissions = permissions;
16
+ this.options = options;
17
+ this.wss = new WebSocketServer({
18
+ server, path: options.path ?? '/ws', maxPayload: 1024 * 1024,
19
+ verifyClient: (info, done) => {
20
+ // Prevent a foreign website from driving a localhost privileged bridge.
21
+ let sameOrigin = true;
22
+ try {
23
+ if (info.origin)
24
+ sameOrigin = new URL(info.origin).host === info.req.headers.host;
25
+ }
26
+ catch {
27
+ sameOrigin = false;
28
+ }
29
+ if (!sameOrigin) {
30
+ done(false, 403);
31
+ return;
32
+ }
33
+ Promise.resolve().then(() => options.authorize?.(info.req) ?? true)
34
+ .then(allowed => done(allowed, allowed ? undefined : 403), () => done(false, 403));
35
+ },
36
+ });
37
+ this.wss.on('connection', ws => this.handleConnection(ws));
38
+ }
39
+ send(ws, message) {
40
+ if (ws.readyState !== WebSocket.OPEN)
41
+ return;
42
+ if (ws.bufferedAmount > 8 * 1024 * 1024) {
43
+ ws.close(1013, 'Slow consumer; reconnect to rehydrate');
44
+ return;
45
+ }
46
+ ws.send(JSON.stringify(message));
47
+ }
48
+ handleConnection(ws) {
49
+ this.send(ws, { type: 'connected' });
50
+ // Ordered handling prevents subscribe/send/switch races. Bound queued work.
51
+ let queue = Promise.resolve();
52
+ let queued = 0;
53
+ const permissionRequests = new Set();
54
+ ws.on('message', raw => {
55
+ if (++queued > 64) {
56
+ ws.close(1008, 'Too many pending requests');
57
+ return;
58
+ }
59
+ queue = queue.then(async () => {
60
+ if (ws.readyState !== WebSocket.OPEN)
61
+ return;
62
+ let data;
63
+ try {
64
+ data = JSON.parse(raw.toString());
65
+ if (!data || typeof data !== 'object')
66
+ throw new Error('Expected a message object');
67
+ if (data.type === 'resolve_permission') {
68
+ const requestId = permissionId(data.requestId, 'requestId');
69
+ if (permissionRequests.has(requestId))
70
+ throw new Error('Duplicate permission requestId; refresh before retrying');
71
+ // Bound replay memory; close rather than evict and accidentally replay an old decision.
72
+ if (permissionRequests.size >= 4096) {
73
+ ws.close(1008, 'Reconnect to refresh permission state');
74
+ return;
75
+ }
76
+ permissionRequests.add(requestId);
77
+ }
78
+ await this.handleMessage(ws, data);
79
+ }
80
+ catch (error) {
81
+ this.send(ws, { type: data?.type === 'resolve_permission' ? 'permission_error' : 'error', permissionId: data?.permissionId, sessionId: data?.sessionId, requestId: data?.requestId,
82
+ result: error instanceof PermissionResolutionError ? error.result : undefined,
83
+ error: error instanceof Error ? error.message : String(error) });
84
+ }
85
+ }).finally(() => { queued--; });
86
+ });
87
+ ws.on('close', () => this.cleanupSocket(ws));
88
+ ws.on('error', () => this.cleanupSocket(ws));
89
+ }
90
+ async handleMessage(ws, data) {
91
+ switch (data.type) {
92
+ case 'ping':
93
+ this.send(ws, { type: 'pong' });
94
+ return;
95
+ case 'unsubscribe':
96
+ this.cleanupSocket(ws);
97
+ this.send(ws, { type: 'unsubscribed' });
98
+ return;
99
+ case 'subscribe':
100
+ await this.subscribeSocket(ws, permissionId(data.sessionId, 'sessionId'));
101
+ return;
102
+ case 'chat_message': {
103
+ const sessionId = data.sessionId || this.activeWatches.get(ws)?.sessionId;
104
+ // Never guess the newest account session or silently create one.
105
+ if (!sessionId)
106
+ throw new Error('Select or create a conversation before sending a message');
107
+ if (typeof data.message !== 'string' || !data.message.trim())
108
+ throw new Error('message is required');
109
+ await this.subscribeSocket(ws, sessionId);
110
+ if (ws.readyState !== WebSocket.OPEN)
111
+ return;
112
+ await this.chat.sendMessage(sessionId, data.message, 'user', data.requestId);
113
+ this.send(ws, { type: 'accepted', sessionId, requestId: data.requestId });
114
+ return;
115
+ }
116
+ case 'resolve_permission': {
117
+ if (!data.sessionId || data.sessionId !== this.activeWatches.get(ws)?.sessionId)
118
+ throw new Error('Permission reply must target the selected conversation');
119
+ if (!data.permissionId || !['allow_once', 'deny', 'deny_once', 'allow_always', 'deny_always'].includes(data.action ?? ''))
120
+ throw new Error('Valid permissionId and action are required');
121
+ permissionResolutionBody(data.action, { reason: data.reason, approvedArguments: data.approvedArguments });
122
+ const result = await this.permissions.resolve(data.sessionId, data.permissionId, data.action, { reason: data.reason, approvedArguments: data.approvedArguments });
123
+ this.send(ws, { type: 'permission_resolved', sessionId: data.sessionId, permissionId: data.permissionId, requestId: data.requestId, result });
124
+ return;
125
+ }
126
+ default: throw new Error(`Unknown message type: ${data.type}`);
127
+ }
128
+ }
129
+ cleanupSocket(ws) {
130
+ this.activeWatches.get(ws)?.watch.dispose();
131
+ this.activeWatches.delete(ws);
132
+ }
133
+ async subscribeSocket(ws, sessionId) {
134
+ const existing = this.activeWatches.get(ws);
135
+ if (existing?.sessionId === sessionId) {
136
+ await existing.watch.ready;
137
+ this.send(ws, { type: 'subscribed', sessionId });
138
+ return;
139
+ }
140
+ this.cleanupSocket(ws);
141
+ let watch;
142
+ const send = (message) => {
143
+ if (this.activeWatches.get(ws)?.watch === watch)
144
+ this.send(ws, { ...message, sessionId });
145
+ };
146
+ watch = await this.chat.stream(sessionId, {
147
+ socketFactory: this.options.socketFactory,
148
+ onState: state => send({ type: 'state', state }),
149
+ onText: (delta, fullText) => send({ type: 'text', delta, fullText }),
150
+ onReasoning: (delta, fullText) => send({ type: 'reasoning', delta, fullText }),
151
+ onToolStarted: tool => send({ type: 'tool_start', tool }),
152
+ onToolDelta: (tool, delta) => send({ type: 'tool_delta', tool, delta }),
153
+ onToolCompleted: tool => send({ type: 'tool_done', tool }),
154
+ onPermissionRequested: async (permission, resolve) => {
155
+ if (this.activeWatches.get(ws)?.watch !== watch)
156
+ return;
157
+ if (this.options.autoApprovePermissions && !isAskUserPermission(permission)) {
158
+ await resolve('allow_once', 'Approved by application server policy');
159
+ }
160
+ else if (this.options.onPermissionRequested) {
161
+ await this.options.onPermissionRequested(permission, ws, resolve);
162
+ }
163
+ else
164
+ send({ type: 'tool_permission', permission });
165
+ },
166
+ onPermissionRemoved: permissionId => send({ type: 'permission_removed', permissionId }),
167
+ onPermissionError: (error, permission) => send({ type: 'permission_error', permissionId: permission.id, error: error.message }),
168
+ onComplete: messages => send({ type: 'done', messages }),
169
+ onError: error => {
170
+ send({ type: 'error', error: error.message });
171
+ if (this.activeWatches.get(ws)?.watch === watch) {
172
+ this.cleanupSocket(ws);
173
+ ws.close(1011, 'Conversation stream unavailable; reconnect');
174
+ }
175
+ },
176
+ });
177
+ this.activeWatches.set(ws, { sessionId, watch });
178
+ // A failed watch must not leave an apparently subscribed but dead socket.
179
+ void watch.done.catch(() => { }).finally(() => {
180
+ if (this.activeWatches.get(ws)?.watch === watch)
181
+ this.activeWatches.delete(ws);
182
+ });
183
+ try {
184
+ await watch.ready;
185
+ if (ws.readyState !== WebSocket.OPEN) {
186
+ this.cleanupSocket(ws);
187
+ return;
188
+ }
189
+ send({ type: 'subscribed' });
190
+ }
191
+ catch (error) {
192
+ if (this.activeWatches.get(ws)?.watch === watch)
193
+ this.cleanupSocket(ws);
194
+ throw error;
195
+ }
196
+ }
197
+ async close() {
198
+ for (const ws of this.wss.clients) {
199
+ this.cleanupSocket(ws);
200
+ ws.terminate();
201
+ }
202
+ await new Promise(resolve => this.wss.close(() => resolve()));
203
+ }
204
+ }
205
+ export function createSwarmWebSocketBridge(server, chat, permissions, options = {}) {
206
+ return new SwarmWebSocketBridge(server, chat, permissions, options);
207
+ }
@@ -0,0 +1,133 @@
1
+ import type { SwarmTransport } from './transport.js';
2
+ import type { WorkerRunRecord } from './types.js';
3
+ export interface WorkerSSHRegistration {
4
+ workspace_id: string;
5
+ name: string;
6
+ host: string;
7
+ user: string;
8
+ port: number;
9
+ idempotency_key: string;
10
+ /** Absolute hub-side trust-store reference; never key contents or automatic trust enrollment. */
11
+ known_hosts_file?: string;
12
+ }
13
+ export interface WorkerGCPRegistration {
14
+ name: string;
15
+ runtime_id: string;
16
+ desktop_url?: string;
17
+ idempotency_key: string;
18
+ }
19
+ export interface WorkerTargetReference {
20
+ kind: 'ssh' | 'gcp';
21
+ workspace_id?: string;
22
+ reference_id: string;
23
+ reference_digest?: string;
24
+ capacity: number;
25
+ }
26
+ export interface WorkerContextUpdate {
27
+ expected_revision: number;
28
+ text: string;
29
+ provenance: string;
30
+ references?: {
31
+ kind: 'artifact' | 'checkpoint';
32
+ reference: string;
33
+ }[];
34
+ }
35
+ export interface WorkerContextRecord {
36
+ worker_id: string;
37
+ revision: number;
38
+ text: string;
39
+ provenance: string;
40
+ references?: WorkerContextUpdate['references'];
41
+ published_by: string;
42
+ published_at: number;
43
+ }
44
+ export interface WorkerDeploymentRequest {
45
+ worker_revision: number;
46
+ context_revision: number;
47
+ target: WorkerTargetReference;
48
+ lifecycle: 'persistent' | 'on_demand';
49
+ idempotency_key: string;
50
+ }
51
+ export interface WorkerDeploymentRecord {
52
+ id: string;
53
+ account_scope_id: string;
54
+ worker_id: string;
55
+ worker_revision: number;
56
+ context_revision: number;
57
+ target: WorkerTargetReference;
58
+ lifecycle: 'persistent' | 'on_demand';
59
+ revision: number;
60
+ generation: number;
61
+ desired_state: 'stopped' | 'running';
62
+ observed_state: 'unavailable';
63
+ approval_state: 'pending' | 'approved';
64
+ approval_digest: string;
65
+ approved_by?: string;
66
+ cleanup_scope: 'owned_runtime';
67
+ cleanup_state: 'not_allocated';
68
+ active_job_id?: string;
69
+ created_at: number;
70
+ updated_at: number;
71
+ }
72
+ export interface WorkerRunPlacement {
73
+ deployment_id: string;
74
+ deployment_revision: number;
75
+ target: WorkerTargetReference;
76
+ context_revision: number;
77
+ generation: number;
78
+ attempt_id: string;
79
+ attempt_number: number;
80
+ state: 'pending_adapter' | 'cancelled_before_dispatch';
81
+ }
82
+ export interface WorkerCommandRequest {
83
+ expected_revision: number;
84
+ generation: number;
85
+ kind: 'stop' | 'start';
86
+ idempotency_key: string;
87
+ }
88
+ export interface WorkerCommandRecord {
89
+ id: string;
90
+ deployment_id: string;
91
+ generation: number;
92
+ kind: 'stop';
93
+ status: 'pending' | 'acknowledged' | 'rejected';
94
+ requested_by: string;
95
+ created_at: number;
96
+ }
97
+ export interface WorkerCommandAcknowledgement {
98
+ generation: number;
99
+ status: 'acknowledged' | 'rejected';
100
+ evidence_digest: string;
101
+ }
102
+ export interface QueueWorkerDeploymentJob {
103
+ worker_revision: number;
104
+ deployment_revision: number;
105
+ context_revision: number;
106
+ idempotency_key: string;
107
+ input: {
108
+ prompt: string;
109
+ [key: string]: unknown;
110
+ };
111
+ }
112
+ /** Durable configuration/queued intent only. No SSH, cloud credentials or remote executor. */
113
+ export declare class SwarmWorkerControlNamespace {
114
+ private transport;
115
+ constructor(transport: SwarmTransport);
116
+ private path;
117
+ private call;
118
+ registerSSHTarget(worker: string, request: WorkerSSHRegistration): Promise<WorkerTargetReference>;
119
+ registerGCPTarget(worker: string, request: WorkerGCPRegistration): Promise<WorkerTargetReference>;
120
+ resolveTarget(worker: string, target: WorkerTargetReference): Promise<WorkerTargetReference>;
121
+ getContext(worker: string, atRevision?: number): Promise<WorkerContextRecord>;
122
+ updateContext(worker: string, request: WorkerContextUpdate): Promise<WorkerContextRecord>;
123
+ proposeDeployment(worker: string, request: WorkerDeploymentRequest): Promise<WorkerDeploymentRecord>;
124
+ listDeployments(worker: string): Promise<WorkerDeploymentRecord[]>;
125
+ getDeployment(worker: string, deployment: string): Promise<WorkerDeploymentRecord>;
126
+ /** Explicit user approval of the exact digest; does not start a runtime. */
127
+ approveDeployment(worker: string, deployment: string, expectedRevision: number, approvalDigest: string): Promise<WorkerDeploymentRecord>;
128
+ queueJob(worker: string, deployment: string, request: QueueWorkerDeploymentJob): Promise<WorkerRunRecord>;
129
+ /** Start rejects with service unavailable. Stop is durable pending intent, not acknowledgement. */
130
+ command(worker: string, deployment: string, request: WorkerCommandRequest): Promise<WorkerCommandRecord>;
131
+ commands(worker: string, deployment: string): Promise<WorkerCommandRecord[]>;
132
+ acknowledgeCommand(worker: string, deployment: string, commandId: string, ack: WorkerCommandAcknowledgement): Promise<WorkerCommandRecord>;
133
+ }