@swarm-agent/sdk 0.1.47 → 0.1.49

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/chat.js ADDED
@@ -0,0 +1,307 @@
1
+ import { SwarmPermissionsNamespace, isAskUserPermission, permissionId } from './permissions.js';
2
+ import { SwarmRealtimeNamespace } from './realtime.js';
3
+ import { SwarmSessionsNamespace } from './sessions.js';
4
+ import { SwarmTimeoutError } from './errors.js';
5
+ import { SwarmWebSocketBridge } from './websocket.js';
6
+ /**
7
+ * Dedicated high-level Chat namespace.
8
+ * Provides streamlined building blocks for interactive AI chat, streaming responses,
9
+ * live tool call monitoring, and automatic or custom permission handling.
10
+ */
11
+ export class SwarmChatNamespace {
12
+ transport;
13
+ sessions;
14
+ permissions;
15
+ realtime;
16
+ constructor(transport) {
17
+ this.transport = transport;
18
+ this.sessions = new SwarmSessionsNamespace(transport);
19
+ this.permissions = new SwarmPermissionsNamespace(transport);
20
+ this.realtime = new SwarmRealtimeNamespace(transport);
21
+ }
22
+ /**
23
+ * Attaches a native bidirectional WebSocket bridge to an HTTP or HTTPS server.
24
+ * Enables browsers to stream tokens, live tool events, and send messages directly over WebSockets.
25
+ */
26
+ attachWebSocket(server, options) {
27
+ return new SwarmWebSocketBridge(server, this, this.permissions, options);
28
+ }
29
+ /**
30
+ * Creates an interactive chat session.
31
+ * Standalone workspace chat uses Swarm. For project orchestration use
32
+ * projects.createConversation(projectId); project membership is not workspace authority.
33
+ */
34
+ async createSession(options = {}) {
35
+ if (options.project_id)
36
+ throw new Error('Use projects.createConversation(projectId) for project orchestrator conversations');
37
+ return this.sessions.create({
38
+ title: options.title ?? 'Chat Session',
39
+ agent_name: options.agent_name ?? 'swarm',
40
+ workspace_path: options.workspace_path,
41
+ workspace_id: options.workspace_id,
42
+ project_id: options.project_id,
43
+ mode: options.mode ?? 'auto',
44
+ });
45
+ }
46
+ /**
47
+ * Appends a message to a chat conversation.
48
+ */
49
+ async sendMessage(sessionId, content, role = 'user', clientRequestId) {
50
+ return this.sessions.sendMessage(sessionId, { content, role, client_request_id: clientRequestId });
51
+ }
52
+ /**
53
+ * Lists messages from a session conversation.
54
+ */
55
+ async listMessages(sessionId) {
56
+ const detail = await this.sessions.get(sessionId);
57
+ return detail.messages || [];
58
+ }
59
+ /**
60
+ * High-level chat execution: sends a message, monitors execution, handles or surfaces
61
+ * pending permissions, and returns the final assistant reply.
62
+ */
63
+ async run(sessionId, options) {
64
+ const id = permissionId(sessionId, 'sessionId');
65
+ await this.sessions.sendMessage(id, { content: options.message, role: 'user' });
66
+ const timeoutMs = options.timeoutMs ?? 180_000;
67
+ const pollIntervalMs = options.pollIntervalMs ?? 1_000;
68
+ const deadline = Date.now() + timeoutMs;
69
+ let lastPendingCount = 0;
70
+ while (Date.now() < deadline) {
71
+ if (options.signal?.aborted) {
72
+ throw new Error('Chat run aborted by caller');
73
+ }
74
+ // Check for pending permissions
75
+ const pending = await this.permissions.listSessionPending(id);
76
+ if (pending.length > 0) {
77
+ lastPendingCount = pending.length;
78
+ if (options.autoApprovePermissions) {
79
+ for (const perm of pending) {
80
+ if (isAskUserPermission(perm))
81
+ continue;
82
+ await this.permissions.resolve(id, perm.id, 'allow_once', { reason: 'Approved by explicit application policy' });
83
+ }
84
+ }
85
+ }
86
+ const detail = await this.sessions.get(id);
87
+ const raw = (detail.raw || {});
88
+ const activeRun = raw.active_run_intent;
89
+ const state = (detail.state || '').toLowerCase();
90
+ const isRunning = state.includes('running') || state.includes('in_progress') || !!activeRun;
91
+ if (!isRunning && pending.length === 0) {
92
+ const msgs = detail.messages || [];
93
+ const assistantMsgs = msgs.filter((m) => m.role === 'assistant');
94
+ const finalMsg = assistantMsgs[assistantMsgs.length - 1];
95
+ let reply = finalMsg?.content;
96
+ if (!reply) {
97
+ const sysMsg = msgs.find((m) => m.role === 'system' &&
98
+ (m.metadata?.message_kind === 'run_failure' || m.content?.includes('[run-failed]')));
99
+ reply = sysMsg ? sysMsg.content : 'No response generated from model.';
100
+ }
101
+ return {
102
+ reply,
103
+ sessionId: id,
104
+ runId: activeRun?.run_id || detail.last_run_id,
105
+ messages: msgs,
106
+ pendingPermissions: pending,
107
+ session: detail,
108
+ };
109
+ }
110
+ await new Promise((r) => setTimeout(r, pollIntervalMs));
111
+ }
112
+ let errorMsg = `Session run did not complete within ${timeoutMs}ms.`;
113
+ if (lastPendingCount > 0) {
114
+ errorMsg += ` Execution is paused waiting for ${lastPendingCount} pending tool permission(s). Render the pending requests and explicitly answer or deny via client.permissions.resolve().`;
115
+ }
116
+ throw new SwarmTimeoutError(errorMsg, timeoutMs);
117
+ }
118
+ /**
119
+ * Establishes a real-time event stream for a chat session.
120
+ * Streams text deltas, reasoning deltas, and live tool start/output/completion events like on Swarm.
121
+ */
122
+ async stream(sessionId, options = {}) {
123
+ const id = permissionId(sessionId, 'sessionId');
124
+ const activeTools = new Map();
125
+ const seenEventIds = new Set();
126
+ const previousStreams = new Map();
127
+ const completedRuns = new Set();
128
+ const pendingSeen = new Map();
129
+ const resolving = new Set();
130
+ const resolved = new Set();
131
+ let permissionsLive = false;
132
+ let initialized = false;
133
+ let observedRun;
134
+ return this.realtime.watchSession(id, {
135
+ signal: options.signal,
136
+ surface: options.surface ?? 'chat',
137
+ socketFactory: options.socketFactory,
138
+ onChange: (state) => {
139
+ permissionsLive = state.status === 'live';
140
+ options.onState?.(state);
141
+ const run = state.snapshot.current_run_state_by_session?.[id]
142
+ ?? state.snapshot.session_views_by_id?.[id]?.current_run_state;
143
+ const sessionEvents = state.snapshot.events_by_session?.[id] || [];
144
+ if (!initialized) {
145
+ initialized = true;
146
+ // Hydrated history is not a new turn or a new permission request.
147
+ for (const evt of sessionEvents)
148
+ seenEventIds.add(evt.id);
149
+ if (run?.run_id && !run.active)
150
+ completedRuns.add(run.run_id);
151
+ }
152
+ if (run?.active)
153
+ observedRun = run.run_id;
154
+ for (const item of state.live || []) {
155
+ const key = JSON.stringify([item.runId, item.streamId]);
156
+ const previous = previousStreams.get(key) ?? '';
157
+ if (item.text === previous)
158
+ continue;
159
+ previousStreams.set(key, item.text);
160
+ observedRun = item.runId;
161
+ const delta = item.text.startsWith(previous) ? item.text.slice(previous.length) : item.text;
162
+ if (item.streamId.includes('reasoning'))
163
+ options.onReasoning?.(delta, item.text);
164
+ else
165
+ options.onText?.(delta, item.text);
166
+ }
167
+ // Bound transient state to streams still in the current snapshot.
168
+ const liveKeys = new Set(state.live.map(item => JSON.stringify([item.runId, item.streamId])));
169
+ for (const key of previousStreams.keys())
170
+ if (!liveKeys.has(key))
171
+ previousStreams.delete(key);
172
+ // 2. Process Realtime Events (Tool streams & Permissions)
173
+ for (const evt of sessionEvents) {
174
+ if (seenEventIds.has(evt.id))
175
+ continue;
176
+ seenEventIds.add(evt.id);
177
+ const payload = evt.payload || {};
178
+ const eventType = evt.event_type;
179
+ if (eventType === 'session.tool.started') {
180
+ const toolId = String(payload.call_id || payload.tool_instance_id || evt.id);
181
+ const toolItem = {
182
+ id: toolId,
183
+ name: String(payload.tool_name || 'tool'),
184
+ identity: payload.tool_identity ? String(payload.tool_identity) : undefined,
185
+ display: payload.tool_display ? String(payload.tool_display) : undefined,
186
+ arguments: payload.arguments ? String(payload.arguments) : undefined,
187
+ status: 'running',
188
+ };
189
+ activeTools.set(toolId, toolItem);
190
+ options.onToolStarted?.(toolItem);
191
+ }
192
+ else if (eventType === 'session.tool.delta') {
193
+ const toolId = String(payload.call_id || payload.tool_instance_id || '');
194
+ const toolItem = activeTools.get(toolId);
195
+ const delta = String(payload.output || '');
196
+ if (toolItem && delta) {
197
+ toolItem.output = (toolItem.output || '') + delta;
198
+ options.onToolDelta?.(toolItem, delta);
199
+ }
200
+ }
201
+ else if (eventType === 'session.tool.completed') {
202
+ const toolId = String(payload.call_id || payload.tool_instance_id || '');
203
+ let toolItem = activeTools.get(toolId);
204
+ if (!toolItem) {
205
+ toolItem = {
206
+ id: toolId,
207
+ name: String(payload.tool_name || 'tool'),
208
+ status: 'completed',
209
+ };
210
+ activeTools.set(toolId, toolItem);
211
+ }
212
+ toolItem.status = 'completed';
213
+ if (payload.output)
214
+ toolItem.output = String(payload.output);
215
+ if (payload.duration_ms)
216
+ toolItem.durationMs = Number(payload.duration_ms);
217
+ options.onToolCompleted?.(toolItem);
218
+ }
219
+ else if (eventType === 'session.tool.failed') {
220
+ const toolId = String(payload.call_id || payload.tool_instance_id || '');
221
+ let toolItem = activeTools.get(toolId);
222
+ if (!toolItem) {
223
+ toolItem = {
224
+ id: toolId,
225
+ name: String(payload.tool_name || 'tool'),
226
+ status: 'failed',
227
+ };
228
+ activeTools.set(toolId, toolItem);
229
+ }
230
+ toolItem.status = 'failed';
231
+ toolItem.error = String(payload.error || 'Tool execution failed');
232
+ if (payload.duration_ms)
233
+ toolItem.durationMs = Number(payload.duration_ms);
234
+ options.onToolCompleted?.(toolItem);
235
+ }
236
+ if (seenEventIds.size > 4096)
237
+ seenEventIds.delete(seenEventIds.values().next().value);
238
+ }
239
+ const rawPending = state.snapshot.session_views_by_id?.[id]?.pending_permissions;
240
+ const pending = rawPending === null ? [] : rawPending;
241
+ if (permissionsLive && Array.isArray(pending)) {
242
+ if (pending.some(p => !p || typeof p.id !== 'string' || !p.id || p.session_id !== id)) {
243
+ options.onError?.(new Error('Invalid pending permission snapshot; refresh the conversation'));
244
+ return;
245
+ }
246
+ const pendingIds = new Set(pending.map(p => p.id));
247
+ for (const key of pendingSeen.keys())
248
+ if (!pendingIds.has(key)) {
249
+ pendingSeen.delete(key);
250
+ resolved.delete(key);
251
+ options.onPermissionRemoved?.(key);
252
+ }
253
+ for (const raw of pending) {
254
+ const permission = raw;
255
+ if (permission.session_id !== id || permission.status !== 'pending')
256
+ continue;
257
+ const fingerprint = JSON.stringify(permission);
258
+ if (pendingSeen.get(permission.id) === fingerprint || resolved.has(permission.id))
259
+ continue;
260
+ pendingSeen.set(permission.id, fingerprint);
261
+ const notify = (error) => {
262
+ const err = error instanceof Error ? error : new Error(String(error));
263
+ if (options.onPermissionError)
264
+ options.onPermissionError(err, permission);
265
+ else
266
+ options.onError?.(err);
267
+ return err;
268
+ };
269
+ const resolver = async (action, input = {}) => {
270
+ if (!permissionsLive)
271
+ throw new Error('Permission state is reconnecting; wait for fresh hydration');
272
+ if (pendingSeen.get(permission.id) !== fingerprint || resolved.has(permission.id))
273
+ throw new Error('Permission request is stale; refresh the conversation');
274
+ if (resolving.has(permission.id))
275
+ throw new Error('Permission resolution already in flight');
276
+ resolving.add(permission.id);
277
+ try {
278
+ const result = await this.permissions.resolve(id, permission.id, action, typeof input === 'string' ? { reason: input } : input);
279
+ resolved.add(permission.id);
280
+ return result;
281
+ }
282
+ catch (error) {
283
+ throw notify(error);
284
+ }
285
+ finally {
286
+ resolving.delete(permission.id);
287
+ }
288
+ };
289
+ // Catch both synchronous throws and async callback rejections without closing the watch.
290
+ void Promise.resolve().then(() => options.onPermissionRequested?.(permission, resolver)).catch(notify);
291
+ }
292
+ }
293
+ // Once per durable run, including empty/tool-only/failed turns. Never close the watch.
294
+ if (state.status === 'live' && run?.run_id && !run.active && !completedRuns.has(run.run_id)) {
295
+ completedRuns.add(run.run_id);
296
+ if (completedRuns.size > 4096)
297
+ completedRuns.delete(completedRuns.values().next().value);
298
+ if (observedRun === run.run_id || run.completed_at)
299
+ options.onComplete?.(state.messages);
300
+ }
301
+ },
302
+ onError: (err) => {
303
+ options.onError?.(err);
304
+ },
305
+ });
306
+ }
307
+ }
package/dist/client.d.ts CHANGED
@@ -1,4 +1,6 @@
1
1
  import { SwarmAppsNamespace } from './apps.js';
2
+ import { SwarmChatNamespace } from './chat.js';
3
+ import { SwarmPermissionsNamespace } from './permissions.js';
2
4
  import { SwarmSettingsNamespace } from './settings.js';
3
5
  import { SwarmOnboardingNamespace } from './provider-auth.js';
4
6
  import { SwarmRealtimeNamespace } from './realtime.js';
@@ -40,6 +42,10 @@ export declare class SwarmClient {
40
42
  readonly workspaces: SwarmWorkspacesNamespace;
41
43
  readonly projects: SwarmProjectsNamespace;
42
44
  readonly sessions: SwarmSessionsNamespace;
45
+ /** High-level interactive chat, response streaming, live tool events, and permission handling */
46
+ readonly chat: SwarmChatNamespace;
47
+ /** Account and session permission policy, bypass/permissionless mode, and pending approvals */
48
+ readonly permissions: SwarmPermissionsNamespace;
43
49
  readonly apps: SwarmAppsNamespace;
44
50
  readonly system: SwarmSystemNamespace;
45
51
  readonly deploy: SwarmDeployNamespace;
package/dist/client.js CHANGED
@@ -1,4 +1,6 @@
1
1
  import { SwarmAppsNamespace } from './apps.js';
2
+ import { SwarmChatNamespace } from './chat.js';
3
+ import { SwarmPermissionsNamespace } from './permissions.js';
2
4
  import { SwarmSettingsNamespace } from './settings.js';
3
5
  import { SwarmOnboardingNamespace } from './provider-auth.js';
4
6
  import { SwarmRealtimeNamespace } from './realtime.js';
@@ -39,6 +41,10 @@ export class SwarmClient {
39
41
  workspaces;
40
42
  projects;
41
43
  sessions;
44
+ /** High-level interactive chat, response streaming, live tool events, and permission handling */
45
+ chat;
46
+ /** Account and session permission policy, bypass/permissionless mode, and pending approvals */
47
+ permissions;
42
48
  apps;
43
49
  system;
44
50
  deploy;
@@ -79,6 +85,8 @@ export class SwarmClient {
79
85
  this.workspaces = new SwarmWorkspacesNamespace(this.transport);
80
86
  this.projects = new SwarmProjectsNamespace(this.transport);
81
87
  this.sessions = new SwarmSessionsNamespace(this.transport);
88
+ this.chat = new SwarmChatNamespace(this.transport);
89
+ this.permissions = new SwarmPermissionsNamespace(this.transport);
82
90
  this.apps = new SwarmAppsNamespace(this.transport);
83
91
  this.system = new SwarmSystemNamespace(this.transport);
84
92
  this.deploy = new SwarmDeployNamespace(this.transport);
package/dist/index.d.ts CHANGED
@@ -2,11 +2,14 @@ export * from './client.js';
2
2
  export * from './auth.js';
3
3
  export * from './automations.js';
4
4
  export * from './workers.js';
5
+ export * from './worker-control.js';
5
6
  export * from './deliverables.js';
6
7
  export * from './deploy/index.js';
7
8
  export * from './notifications.js';
8
9
  export * from './sessions.js';
9
10
  export * from './projects.js';
11
+ export * from './permissions.js';
12
+ export * from './chat.js';
10
13
  export * from './workspaces.js';
11
14
  export * from './system.js';
12
15
  export * from './transport.js';
@@ -20,4 +23,6 @@ export type { ApplicationAgent, ApplicationAgentWrite, ApplicationConversationPa
20
23
  export * from './settings.js';
21
24
  export * from './provider-auth.js';
22
25
  export * from './realtime.js';
26
+ export * from './websocket.js';
27
+ export * from './browser.js';
23
28
  export type { ApplicationResults, ApplicationResultsOptions } from './app-results.js';
package/dist/index.js CHANGED
@@ -2,11 +2,14 @@ export * from './client.js';
2
2
  export * from './auth.js';
3
3
  export * from './automations.js';
4
4
  export * from './workers.js';
5
+ export * from './worker-control.js';
5
6
  export * from './deliverables.js';
6
7
  export * from './deploy/index.js';
7
8
  export * from './notifications.js';
8
9
  export * from './sessions.js';
9
10
  export * from './projects.js';
11
+ export * from './permissions.js';
12
+ export * from './chat.js';
10
13
  export * from './workspaces.js';
11
14
  export * from './system.js';
12
15
  export * from './transport.js';
@@ -18,3 +21,5 @@ export { SwarmAppsNamespace } from './apps.js';
18
21
  export * from './settings.js';
19
22
  export * from './provider-auth.js';
20
23
  export * from './realtime.js';
24
+ export * from './websocket.js';
25
+ export * from './browser.js';
@@ -0,0 +1,50 @@
1
+ import type { PendingPermissionRecord, ResolvePermissionResult } from './permissions.js';
2
+ export type PermissionAction = 'allow_once' | 'deny' | 'deny_once' | 'allow_always' | 'deny_always';
3
+ export interface ResolvePermissionOptions {
4
+ reason?: string;
5
+ approvedArguments?: Record<string, unknown> | string;
6
+ }
7
+ export type PermissionResolver = (action: PermissionAction, options?: ResolvePermissionOptions | string) => Promise<ResolvePermissionResult>;
8
+ export interface PermissionChoice {
9
+ label: string;
10
+ value: string;
11
+ description?: string;
12
+ custom: boolean;
13
+ }
14
+ export interface PermissionQuestion {
15
+ id: string;
16
+ question: string;
17
+ header?: string;
18
+ required: boolean;
19
+ options: PermissionChoice[];
20
+ }
21
+ export interface PermissionRequestView {
22
+ id: string;
23
+ sessionId: string;
24
+ toolName: string;
25
+ status: string;
26
+ kind: 'tool' | 'ask-user';
27
+ arguments: Record<string, unknown> | null;
28
+ rawArguments: string;
29
+ parseError?: string;
30
+ title?: string;
31
+ context?: string;
32
+ structured: boolean;
33
+ questions: PermissionQuestion[];
34
+ /** Conservative per-request actions, not a grant of backend authority. */
35
+ actions: Array<'allow_once' | 'deny'>;
36
+ }
37
+ export declare function permissionId(value: unknown, label?: string): string;
38
+ export declare function isAskUserPermission(permission: Pick<PendingPermissionRecord, 'tool_name'>): boolean;
39
+ /** Parse untrusted request arguments for text rendering. Never render these fields as HTML. */
40
+ export declare function describePermission(permission: PendingPermissionRecord): PermissionRequestView;
41
+ /** Serialize actual user input using the backend's reason/answers-map contract. */
42
+ export declare function answerPermission(permission: PendingPermissionRecord, answer: string | Record<string, string>): ResolvePermissionOptions;
43
+ /** Validate JavaScript callers before any mutation is sent. */
44
+ export declare function permissionResolutionBody(action: PermissionAction, options?: ResolvePermissionOptions): Record<string, unknown>;
45
+ export declare class PermissionResolutionError extends Error {
46
+ readonly result?: ResolvePermissionResult | undefined;
47
+ constructor(message: string, result?: ResolvePermissionResult | undefined);
48
+ }
49
+ /** HTTP success is not proof that a stale decision won. Keep the actual record on conflicts. */
50
+ export declare function confirmPermissionResolution(result: ResolvePermissionResult, sessionId: string, id: string, action: PermissionAction, options?: ResolvePermissionOptions): ResolvePermissionResult;
@@ -0,0 +1,167 @@
1
+ export function permissionId(value, label = 'permissionId') {
2
+ if (typeof value !== 'string' || !value.trim())
3
+ throw new Error(`${label} must be a non-empty string`);
4
+ return value.trim();
5
+ }
6
+ function object(value) {
7
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
8
+ }
9
+ function text(value) { return typeof value === 'string' ? value.trim() : ''; }
10
+ export function isAskUserPermission(permission) {
11
+ if (typeof permission?.tool_name !== 'string')
12
+ return false;
13
+ const name = permission.tool_name.trim().toLowerCase().split('.').at(-1)?.replaceAll('-', '_');
14
+ return name === 'ask_user' || name === 'askuser';
15
+ }
16
+ /** Parse untrusted request arguments for text rendering. Never render these fields as HTML. */
17
+ export function describePermission(permission) {
18
+ const id = permissionId(permission?.id);
19
+ const sessionId = permissionId(permission?.session_id, 'sessionId');
20
+ const raw = permission.tool_arguments ?? permission.tool_call_arguments ?? '';
21
+ const view = {
22
+ id, sessionId, toolName: permission.tool_name, status: permission.status,
23
+ kind: isAskUserPermission(permission) ? 'ask-user' : 'tool', arguments: null,
24
+ rawArguments: typeof raw === 'string' ? raw : '', structured: false, questions: [],
25
+ actions: permission.status === 'pending' ? ['allow_once', 'deny'] : [],
26
+ };
27
+ try {
28
+ const args = raw === '' ? {} : JSON.parse(raw);
29
+ if (!object(args))
30
+ throw new Error('Permission arguments must be a JSON object');
31
+ view.arguments = args;
32
+ if (view.kind === 'tool')
33
+ return view;
34
+ view.title = text(args.title) || undefined;
35
+ view.context = text(args.context) || undefined;
36
+ view.structured = Object.hasOwn(args, 'questions');
37
+ const questions = view.structured ? args.questions : [args];
38
+ if (!Array.isArray(questions) || questions.length === 0)
39
+ throw new Error('Ask-user requires questions');
40
+ const ids = new Set();
41
+ view.questions = questions.map((q, index) => {
42
+ if (!object(q) || !text(q.question) || !Array.isArray(q.options))
43
+ throw new Error('Invalid ask-user question');
44
+ const id = text(q.id) || `q_${index + 1}`;
45
+ if (ids.has(id))
46
+ throw new Error('Duplicate ask-user question ID');
47
+ ids.add(id);
48
+ const options = q.options.map((item) => {
49
+ if (typeof item === 'string' && item.trim())
50
+ return { label: item.trim(), value: item.trim(), custom: item === '__custom__' || item === 'Custom response' };
51
+ if (!object(item))
52
+ throw new Error('Invalid ask-user option');
53
+ const value = text(item.value) || text(item.label);
54
+ if (!value)
55
+ throw new Error('Empty ask-user option');
56
+ return { value, label: text(item.label) || value, description: text(item.description) || undefined,
57
+ custom: item.allow_custom === true || item.allowCustom === true || value === '__custom__' || value === 'Custom response' };
58
+ });
59
+ if (options.filter(option => !option.custom).length < 2)
60
+ throw new Error('Ask-user requires at least two concrete options');
61
+ return { id, question: text(q.question), header: text(q.header) || undefined, required: q.required !== false, options };
62
+ });
63
+ }
64
+ catch (error) {
65
+ view.parseError = error instanceof Error ? error.message : String(error);
66
+ view.questions = [];
67
+ // A malformed question may be denied, but never silently approved without an answer.
68
+ if (view.kind === 'ask-user' && permission.status === 'pending')
69
+ view.actions = ['deny'];
70
+ }
71
+ return view;
72
+ }
73
+ /** Serialize actual user input using the backend's reason/answers-map contract. */
74
+ export function answerPermission(permission, answer) {
75
+ const view = describePermission(permission);
76
+ if (view.status !== 'pending' || view.kind !== 'ask-user' || view.parseError)
77
+ throw new Error(view.parseError || 'Expected a pending ask-user request');
78
+ const validate = (question, value) => {
79
+ const result = text(value);
80
+ if (!result)
81
+ throw new Error(`Answer required for ${question.id}`);
82
+ if (result === '__custom__')
83
+ throw new Error('Enter custom response text, not the custom option marker');
84
+ if (!question.options.some(option => !option.custom && (option.value === result || option.label === result)) && !question.options.some(option => option.custom)) {
85
+ throw new Error(`Select a supported answer for ${question.id}`);
86
+ }
87
+ return result;
88
+ };
89
+ if (!view.structured) {
90
+ if (typeof answer !== 'string')
91
+ throw new Error('Single question requires a string answer');
92
+ return { reason: validate(view.questions[0], answer) };
93
+ }
94
+ if (!object(answer))
95
+ throw new Error('Structured questions require an answers map keyed by question ID');
96
+ const answers = Object.create(null);
97
+ for (const key of Object.keys(answer))
98
+ if (!view.questions.some(q => q.id === key))
99
+ throw new Error(`Unknown question ID: ${key}`);
100
+ for (const question of view.questions) {
101
+ if (!Object.hasOwn(answer, question.id) && !question.required)
102
+ continue;
103
+ answers[question.id] = validate(question, answer[question.id]);
104
+ }
105
+ if (!Object.keys(answers).length)
106
+ throw new Error('Provide at least one explicit answer');
107
+ return { reason: JSON.stringify({ answers }) };
108
+ }
109
+ /** Validate JavaScript callers before any mutation is sent. */
110
+ export function permissionResolutionBody(action, options = {}) {
111
+ if (!['allow_once', 'deny', 'deny_once', 'allow_always', 'deny_always'].includes(action))
112
+ throw new Error('Unsupported permission action');
113
+ if (!object(options))
114
+ throw new Error('Permission resolution options must be an object');
115
+ if (options.reason !== undefined && typeof options.reason !== 'string')
116
+ throw new Error('Permission reason must be a string');
117
+ const body = { action, reason: options.reason ?? '' };
118
+ if (options.approvedArguments !== undefined) {
119
+ let args = options.approvedArguments;
120
+ if (typeof args === 'string') {
121
+ try {
122
+ args = JSON.parse(args);
123
+ }
124
+ catch {
125
+ throw new Error('approvedArguments must contain valid JSON');
126
+ }
127
+ }
128
+ if (!object(args))
129
+ throw new Error('approvedArguments must be a JSON object');
130
+ // Reject circular or otherwise non-JSON data before transport.
131
+ body.approved_arguments = JSON.parse(JSON.stringify(args));
132
+ }
133
+ return body;
134
+ }
135
+ export class PermissionResolutionError extends Error {
136
+ result;
137
+ constructor(message, result) {
138
+ super(message);
139
+ this.result = result;
140
+ this.name = 'PermissionResolutionError';
141
+ }
142
+ }
143
+ /** HTTP success is not proof that a stale decision won. Keep the actual record on conflicts. */
144
+ export function confirmPermissionResolution(result, sessionId, id, action, options = {}) {
145
+ const p = result?.permission;
146
+ const canonical = (value) => JSON.stringify(value, (_key, item) => object(item) ? Object.fromEntries(Object.keys(item).sort().map(key => [key, item[key]])) : item);
147
+ let argumentsMatch = true;
148
+ if (options.approvedArguments !== undefined) {
149
+ try {
150
+ const expected = typeof options.approvedArguments === 'string' ? JSON.parse(options.approvedArguments) : options.approvedArguments;
151
+ argumentsMatch = canonical(JSON.parse(p?.approved_arguments ?? 'null')) === canonical(expected);
152
+ }
153
+ catch {
154
+ argumentsMatch = false;
155
+ }
156
+ }
157
+ if (result?.ok !== true || result.session_id !== sessionId || p?.session_id !== sessionId || p.id !== id) {
158
+ throw new PermissionResolutionError('Invalid permission resolution response; refresh the conversation');
159
+ }
160
+ const normalize = (value) => value === 'deny_once' ? 'deny' : value;
161
+ const expectedStatus = action.startsWith('allow') ? 'approved' : 'denied';
162
+ if (!argumentsMatch || p.status !== expectedStatus || normalize(p.decision) !== normalize(action) ||
163
+ (options.reason !== undefined && (p.reason ?? '').trim() !== options.reason.trim())) {
164
+ throw new PermissionResolutionError('Permission decision was not confirmed (stale, cancelled or resolved differently); refresh before retrying', result);
165
+ }
166
+ return result;
167
+ }