@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/HEADLESS_UI.md +2 -0
- package/README.md +270 -7
- package/WORKER_CONTROL.md +77 -0
- package/dist/auth.d.ts +26 -0
- package/dist/auth.js +81 -0
- package/dist/browser.d.ts +94 -0
- package/dist/browser.js +408 -0
- package/dist/chat.d.ts +134 -0
- package/dist/chat.js +307 -0
- package/dist/client.d.ts +6 -0
- package/dist/client.js +8 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.js +5 -0
- package/dist/permission-ui.d.ts +50 -0
- package/dist/permission-ui.js +167 -0
- package/dist/permissions.d.ts +126 -0
- package/dist/permissions.js +131 -0
- package/dist/projects.d.ts +36 -1
- package/dist/projects.js +96 -1
- package/dist/provider-auth.d.ts +38 -0
- package/dist/provider-auth.js +87 -0
- package/dist/sessions.d.ts +2 -0
- package/dist/sessions.js +34 -7
- package/dist/settings.d.ts +29 -1
- package/dist/settings.js +69 -0
- package/dist/types.d.ts +13 -0
- package/dist/websocket.d.ts +60 -0
- package/dist/websocket.js +207 -0
- package/dist/worker-control.d.ts +133 -0
- package/dist/worker-control.js +93 -0
- package/dist/workers.d.ts +2 -0
- package/dist/workers.js +3 -0
- package/examples/headless-worker-control.ts +28 -0
- package/package.json +8 -1
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
|
-
|
|
61
|
+
list = data;
|
|
57
62
|
}
|
|
58
|
-
if (data && typeof data === 'object' && 'sessions' in data && Array.isArray(data.sessions)) {
|
|
59
|
-
|
|
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 ??
|
|
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
|
-
|
|
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
|
}
|
package/dist/settings.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|