@swarm-agent/sdk 0.1.49 → 0.1.51
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 +4 -6
- package/README.md +138 -58
- package/dist/agents.d.ts +114 -0
- package/dist/agents.js +183 -0
- package/dist/auth.d.ts +14 -0
- package/dist/auth.js +16 -0
- package/dist/box.d.ts +88 -0
- package/dist/box.js +13 -0
- package/dist/browser.d.ts +6 -2
- package/dist/browser.js +11 -3
- package/dist/chat.d.ts +41 -7
- package/dist/chat.js +148 -83
- package/dist/client.d.ts +12 -0
- package/dist/client.js +19 -6
- package/dist/copy-tags.d.ts +26 -0
- package/dist/copy-tags.js +133 -0
- package/dist/errors.d.ts +16 -0
- package/dist/errors.js +21 -0
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/local-connection.d.ts +39 -0
- package/dist/local-connection.js +56 -0
- package/dist/permission-ui.d.ts +1 -0
- package/dist/permission-ui.js +37 -0
- package/dist/permissions.d.ts +4 -0
- package/dist/permissions.js +7 -0
- package/dist/projects.d.ts +25 -7
- package/dist/projects.js +65 -17
- package/dist/provider-auth.d.ts +5 -3
- package/dist/provider-auth.js +6 -8
- package/dist/realtime.d.ts +40 -6
- package/dist/realtime.js +156 -52
- package/dist/remote.d.ts +59 -0
- package/dist/remote.js +46 -0
- package/dist/sessions.d.ts +31 -1
- package/dist/sessions.js +49 -1
- package/dist/settings.d.ts +3 -0
- package/dist/settings.js +19 -3
- package/dist/signals.d.ts +148 -0
- package/dist/signals.js +97 -0
- package/dist/types.d.ts +97 -6
- package/dist/websocket.d.ts +3 -1
- package/dist/websocket.js +1 -1
- package/dist/workers.d.ts +6 -0
- package/dist/workers.js +27 -0
- package/dist/workspaces.d.ts +20 -0
- package/dist/workspaces.js +24 -0
- package/package.json +1 -1
package/dist/projects.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { SwarmApiError, SwarmTimeoutError, } from './errors.js';
|
|
1
|
+
import { SwarmApiError, SwarmTimeoutError, SwarmValidationError, } from './errors.js';
|
|
2
|
+
import { SwarmSessionsNamespace } from './sessions.js';
|
|
2
3
|
export class SwarmProjectsNamespace {
|
|
3
4
|
transport;
|
|
4
5
|
constructor(transport) {
|
|
@@ -115,9 +116,14 @@ export class SwarmProjectsNamespace {
|
|
|
115
116
|
return session;
|
|
116
117
|
});
|
|
117
118
|
}
|
|
119
|
+
/** Rename a project conversation (a conversation ID is its session ID); see sessions.rename. */
|
|
120
|
+
async renameConversation(conversationId, title, options = {}) {
|
|
121
|
+
return new SwarmSessionsNamespace(this.transport).rename(conversationId, title, options);
|
|
122
|
+
}
|
|
118
123
|
/** Create an independent project conversation without archiving existing conversations.
|
|
119
124
|
* Models/context resolve on the daemon. Project conversations never bind a workspace.
|
|
120
|
-
* Reuse clientRequestId when retrying an uncertain creation result.
|
|
125
|
+
* Reuse clientRequestId when retrying an uncertain creation result. Without a title the
|
|
126
|
+
* conversation is named from its first message; rename it with renameConversation.
|
|
121
127
|
*/
|
|
122
128
|
async createConversation(projectId, options = {}, reqOptions) {
|
|
123
129
|
const id = projectId.trim();
|
|
@@ -132,7 +138,7 @@ export class SwarmProjectsNamespace {
|
|
|
132
138
|
body: {
|
|
133
139
|
client_request_id: options.clientRequestId ?? `proj-orch-${globalThis.crypto.randomUUID()}`,
|
|
134
140
|
agent_name: options.agentName ?? 'system-orchestrator',
|
|
135
|
-
title: options.title
|
|
141
|
+
title: options.title,
|
|
136
142
|
mode: options.mode ?? 'auto',
|
|
137
143
|
},
|
|
138
144
|
...reqOptions,
|
|
@@ -172,17 +178,6 @@ export class SwarmProjectsNamespace {
|
|
|
172
178
|
const res = await this.transport.request(`/v3/projects/${id}`, { method: 'DELETE', ...options });
|
|
173
179
|
return res.data ?? { status: 'deleted', id: projectId.trim() };
|
|
174
180
|
}
|
|
175
|
-
/**
|
|
176
|
-
* Synthesizes Markdown project context from project name and linked workspaces.
|
|
177
|
-
*/
|
|
178
|
-
async synthesizeContext(params, options) {
|
|
179
|
-
const res = await this.transport.request('/v3/projects/synthesize-context', {
|
|
180
|
-
method: 'POST',
|
|
181
|
-
body: params,
|
|
182
|
-
...options,
|
|
183
|
-
});
|
|
184
|
-
return res.data?.project_context ?? '';
|
|
185
|
-
}
|
|
186
181
|
/**
|
|
187
182
|
* Clears orchestrator context for a project, archiving old session and provisioning replacement.
|
|
188
183
|
*/
|
|
@@ -485,11 +480,36 @@ export class SwarmProjectsNamespace {
|
|
|
485
480
|
// ==========================================================================
|
|
486
481
|
/**
|
|
487
482
|
* Integrates task commits into the parent workspace repository.
|
|
488
|
-
|
|
489
|
-
|
|
483
|
+
*
|
|
484
|
+
* The daemon only integrates an exact selection: the task's current session and its
|
|
485
|
+
* captured source/target branches. With no options the SDK fetches the task
|
|
486
|
+
* (`getTask`, which runs a fresh delivery assessment) and builds that selection from
|
|
487
|
+
* it; pass `options.selection` to override individual fields. A stale selection is
|
|
488
|
+
* refused by the daemon with a conflict error rather than integrating the wrong lane.
|
|
489
|
+
*/
|
|
490
|
+
async integrateTask(projectId, taskId, options = {}) {
|
|
491
|
+
const { selection: overrides, body, ...requestOptions } = options;
|
|
490
492
|
const pId = encodeURIComponent(projectId.trim());
|
|
491
493
|
const tId = encodeURIComponent(taskId.trim());
|
|
492
|
-
|
|
494
|
+
// A caller-supplied raw body is treated as overrides too (older workaround shape).
|
|
495
|
+
const given = {
|
|
496
|
+
...(body && typeof body === 'object' ? body : {}),
|
|
497
|
+
...(overrides ?? {}),
|
|
498
|
+
};
|
|
499
|
+
let selection = given;
|
|
500
|
+
if (!given.session_id || !given.source_branch || !given.target_branch) {
|
|
501
|
+
const { task } = await this.getTask(projectId, taskId, {
|
|
502
|
+
signal: requestOptions.signal,
|
|
503
|
+
timeoutMs: requestOptions.timeoutMs,
|
|
504
|
+
headers: requestOptions.headers,
|
|
505
|
+
});
|
|
506
|
+
selection = { ...integrationSelectionFromTask(task), ...stripUndefined(given) };
|
|
507
|
+
}
|
|
508
|
+
const missing = ['session_id', 'source_branch', 'target_branch'].filter((key) => typeof selection[key] !== 'string' || !selection[key].trim());
|
|
509
|
+
if (missing.length > 0) {
|
|
510
|
+
throw new SwarmValidationError(`Task ${taskId} cannot be integrated: no ${missing.join(', ')} on the task record (it has no captured worktree lane yet). Pass options.selection to supply them.`, { missing, selection });
|
|
511
|
+
}
|
|
512
|
+
const res = await this.transport.request(`/v3/projects/${pId}/tasks/${tId}/integrate`, { ...requestOptions, method: 'POST', body: stripUndefined(selection) });
|
|
493
513
|
return res.data;
|
|
494
514
|
}
|
|
495
515
|
/**
|
|
@@ -606,3 +626,31 @@ export class SwarmProjectsNamespace {
|
|
|
606
626
|
}
|
|
607
627
|
}
|
|
608
628
|
}
|
|
629
|
+
function nonEmpty(value) {
|
|
630
|
+
return typeof value === 'string' && value.trim() ? value.trim() : undefined;
|
|
631
|
+
}
|
|
632
|
+
function stripUndefined(value) {
|
|
633
|
+
return Object.fromEntries(Object.entries(value).filter(([, v]) => v !== undefined));
|
|
634
|
+
}
|
|
635
|
+
/**
|
|
636
|
+
* Builds the integrate selection the daemon checks from a fresh task record.
|
|
637
|
+
* Branch precedence: the detail GET's delivery assessment (the daemon's own view of
|
|
638
|
+
* the captured lane), then the task's recorded worktree/base branch, then the active
|
|
639
|
+
* attempt's. The daemon still verifies every field against the owned session.
|
|
640
|
+
*/
|
|
641
|
+
export function integrationSelectionFromTask(task) {
|
|
642
|
+
const assessment = task.delivery_assessment;
|
|
643
|
+
const attempts = Array.isArray(task.attempts) ? task.attempts : [];
|
|
644
|
+
const activeAttempt = attempts.find((a) => a && a.id && a.id === task.active_attempt_id) ??
|
|
645
|
+
attempts.find((a) => a && nonEmpty(a.session_id) && a.session_id === task.session_id);
|
|
646
|
+
const observed = assessment?.freshness === 'observed';
|
|
647
|
+
return stripUndefined({
|
|
648
|
+
session_id: nonEmpty(task.session_id) ?? nonEmpty(assessment?.session_id),
|
|
649
|
+
source_branch: nonEmpty(assessment?.source_branch) ?? nonEmpty(task.worktree_branch) ?? nonEmpty(activeAttempt?.worktree_branch),
|
|
650
|
+
target_branch: nonEmpty(assessment?.target_branch) ?? nonEmpty(task.base_branch) ?? nonEmpty(activeAttempt?.base_branch),
|
|
651
|
+
revision: typeof task.revision === 'number' ? task.revision : undefined,
|
|
652
|
+
attempt_id: nonEmpty(task.active_attempt_id) ?? nonEmpty(assessment?.attempt_id),
|
|
653
|
+
source_head: observed ? nonEmpty(assessment?.source_oid) : undefined,
|
|
654
|
+
target_head: observed ? nonEmpty(assessment?.target_oid) : undefined,
|
|
655
|
+
});
|
|
656
|
+
}
|
package/dist/provider-auth.d.ts
CHANGED
|
@@ -185,9 +185,11 @@ export declare class SwarmOnboardingNamespace {
|
|
|
185
185
|
get(): Promise<OnboardingStatus>;
|
|
186
186
|
update(body: OnboardingUpdate): Promise<OnboardingStatus>;
|
|
187
187
|
/**
|
|
188
|
-
* Ensures the
|
|
189
|
-
*
|
|
190
|
-
*
|
|
188
|
+
* Ensures the daemon identity (owner name and Swarm name) is bootstrapped, so
|
|
189
|
+
* provider authentication and sessions can proceed. The owner name is kept as typed.
|
|
190
|
+
* This only creates identity: it does not mark onboarding finished, so
|
|
191
|
+
* `config.desktop_onboarding_complete` stays false until your app (or the desktop)
|
|
192
|
+
* finishes its own setup and calls `update({ desktop_onboarding_complete: true })`.
|
|
191
193
|
*/
|
|
192
194
|
ensureBootstrapped(options?: {
|
|
193
195
|
username?: string;
|
package/dist/provider-auth.js
CHANGED
|
@@ -116,22 +116,20 @@ export class SwarmOnboardingNamespace {
|
|
|
116
116
|
return (await this.transport.request('/v1/onboarding', { method: 'POST', body })).data;
|
|
117
117
|
}
|
|
118
118
|
/**
|
|
119
|
-
* Ensures the
|
|
120
|
-
*
|
|
121
|
-
*
|
|
119
|
+
* Ensures the daemon identity (owner name and Swarm name) is bootstrapped, so
|
|
120
|
+
* provider authentication and sessions can proceed. The owner name is kept as typed.
|
|
121
|
+
* This only creates identity: it does not mark onboarding finished, so
|
|
122
|
+
* `config.desktop_onboarding_complete` stays false until your app (or the desktop)
|
|
123
|
+
* finishes its own setup and calls `update({ desktop_onboarding_complete: true })`.
|
|
122
124
|
*/
|
|
123
125
|
async ensureBootstrapped(options = {}) {
|
|
124
126
|
const status = await this.get();
|
|
125
|
-
if (!status.needs_onboarding && status.identity?.bootstrapped) {
|
|
126
|
-
return status;
|
|
127
|
-
}
|
|
128
127
|
if (status.identity?.bootstrapped) {
|
|
129
|
-
return
|
|
128
|
+
return status;
|
|
130
129
|
}
|
|
131
130
|
return this.update({
|
|
132
131
|
username: options.username ?? 'owner',
|
|
133
132
|
swarm_name: options.swarm_name ?? 'Headless Swarm',
|
|
134
|
-
desktop_onboarding_complete: true,
|
|
135
133
|
});
|
|
136
134
|
}
|
|
137
135
|
/** First credential only. Use auth.credentials.save for subsequent credentials. */
|
package/dist/realtime.d.ts
CHANGED
|
@@ -155,6 +155,19 @@ export interface V3SessionView {
|
|
|
155
155
|
active_plan?: Record<string, unknown>;
|
|
156
156
|
[key: string]: unknown;
|
|
157
157
|
}
|
|
158
|
+
/**
|
|
159
|
+
* A resource the daemon left out of (or truncated in) a sync snapshot. With
|
|
160
|
+
* `history.manifest_policy` 'manifest' or 'omit' the recent window is still returned and
|
|
161
|
+
* `next_cursor` points at the older history that was left out.
|
|
162
|
+
*/
|
|
163
|
+
export interface V3SyncOmission {
|
|
164
|
+
session_id?: string;
|
|
165
|
+
resource?: 'messages' | 'events' | string;
|
|
166
|
+
reason?: 'requires_manifest' | string;
|
|
167
|
+
next_cursor?: string;
|
|
168
|
+
manifest_ref?: string;
|
|
169
|
+
[key: string]: unknown;
|
|
170
|
+
}
|
|
158
171
|
export interface V3SyncSnapshot {
|
|
159
172
|
ok: boolean;
|
|
160
173
|
rev: number;
|
|
@@ -169,11 +182,7 @@ export interface V3SyncSnapshot {
|
|
|
169
182
|
stream_path: string;
|
|
170
183
|
resume: V3Resume;
|
|
171
184
|
};
|
|
172
|
-
omissions:
|
|
173
|
-
session_id?: string;
|
|
174
|
-
reason?: string;
|
|
175
|
-
[key: string]: unknown;
|
|
176
|
-
}[];
|
|
185
|
+
omissions: V3SyncOmission[];
|
|
177
186
|
pagination: Record<string, unknown>;
|
|
178
187
|
[key: string]: unknown;
|
|
179
188
|
}
|
|
@@ -198,6 +207,13 @@ export interface SessionWatchState {
|
|
|
198
207
|
text: string;
|
|
199
208
|
}>;
|
|
200
209
|
status: 'syncing' | 'live' | 'reconnecting';
|
|
210
|
+
/**
|
|
211
|
+
* True when older history did not fit the hydrate window (for example after a very large
|
|
212
|
+
* tool result) and was left out. `messages`/events still hold the most recent window; the
|
|
213
|
+
* conversation stays usable. `historyOmissions` names each resource and its `next_cursor`.
|
|
214
|
+
*/
|
|
215
|
+
historyOmitted?: boolean;
|
|
216
|
+
historyOmissions?: V3SyncOmission[];
|
|
201
217
|
}
|
|
202
218
|
export interface SessionWatchOptions {
|
|
203
219
|
signal?: AbortSignal;
|
|
@@ -212,21 +228,39 @@ export interface SessionWatch {
|
|
|
212
228
|
done: Promise<void>;
|
|
213
229
|
dispose(): void;
|
|
214
230
|
}
|
|
215
|
-
/**
|
|
231
|
+
/**
|
|
232
|
+
* Transient assistant text assembled from live patches and durable delta checkpoints.
|
|
233
|
+
* UTF-8 byte offsets (not JavaScript string length) are the only placement authority:
|
|
234
|
+
* duplicate and overlapping ranges are trimmed, and a range beyond the contiguous prefix
|
|
235
|
+
* (joining or reconnecting mid-turn, while durable checkpoints lag live output) is held,
|
|
236
|
+
* bounded, until a durable checkpoint fills the gap. Text is never dropped, repeated or
|
|
237
|
+
* shown with a hole. A committed message retires its stream.
|
|
238
|
+
*/
|
|
216
239
|
export declare class V3LiveText {
|
|
217
240
|
private streams;
|
|
218
241
|
private committed;
|
|
219
242
|
reset(): void;
|
|
220
243
|
reconcile(messages: V3Message[]): void;
|
|
221
244
|
durable(events: V3Event[]): void;
|
|
245
|
+
/** Returns false only for a malformed patch; gaps and duplicates are handled here. */
|
|
222
246
|
accept(p: V3LivePatch): boolean;
|
|
223
247
|
values(): SessionWatchState['live'];
|
|
248
|
+
private commit;
|
|
249
|
+
private append;
|
|
224
250
|
}
|
|
225
251
|
export declare class SwarmRealtimeNamespace {
|
|
226
252
|
private readonly transport;
|
|
227
253
|
constructor(transport: SwarmTransport);
|
|
228
254
|
bootstrap(body: V3SyncRequest, signal?: AbortSignal): Promise<V3SyncSnapshot>;
|
|
229
255
|
hydrate(body: V3SyncRequest, signal?: AbortSignal): Promise<V3SyncSnapshot>;
|
|
256
|
+
/**
|
|
257
|
+
* Hydrates a watch window. The daemon's default manifest policy ('error') refuses the whole
|
|
258
|
+
* snapshot when the recent window cannot hold all history (e.g. one ~280 KB tool result
|
|
259
|
+
* exhausts the 256 KB message budget). Both 'omit' and 'manifest' still return the same
|
|
260
|
+
* newest window plus an omission with `next_cursor`; 'manifest' also returns a descriptor
|
|
261
|
+
* of what was included, so it loses the least. The policy then sticks for this watch.
|
|
262
|
+
*/
|
|
263
|
+
private hydrateWindow;
|
|
230
264
|
/** Bounded durable replay. Keep the identical selector/resources/surface for every cursor. */
|
|
231
265
|
replay(body: V3SyncRequest & {
|
|
232
266
|
endpoint_cursor: string;
|
package/dist/realtime.js
CHANGED
|
@@ -1,4 +1,18 @@
|
|
|
1
|
-
|
|
1
|
+
import { SwarmApiError } from './errors.js';
|
|
2
|
+
const LIVE_MAX_STREAM_BYTES = 4 * 1024 * 1024;
|
|
3
|
+
const LIVE_MAX_STREAMS = 64;
|
|
4
|
+
const LIVE_MAX_COMMITTED = 4096;
|
|
5
|
+
const utf8Encoder = new TextEncoder();
|
|
6
|
+
const utf8Decoder = new TextDecoder('utf-8', { fatal: true });
|
|
7
|
+
const DURABLE_TEXT_DELTA_EVENTS = new Set(['session.assistant.delta', 'session.message.delta']);
|
|
8
|
+
/**
|
|
9
|
+
* Transient assistant text assembled from live patches and durable delta checkpoints.
|
|
10
|
+
* UTF-8 byte offsets (not JavaScript string length) are the only placement authority:
|
|
11
|
+
* duplicate and overlapping ranges are trimmed, and a range beyond the contiguous prefix
|
|
12
|
+
* (joining or reconnecting mid-turn, while durable checkpoints lag live output) is held,
|
|
13
|
+
* bounded, until a durable checkpoint fills the gap. Text is never dropped, repeated or
|
|
14
|
+
* shown with a hole. A committed message retires its stream.
|
|
15
|
+
*/
|
|
2
16
|
export class V3LiveText {
|
|
3
17
|
streams = new Map();
|
|
4
18
|
committed = new Set();
|
|
@@ -7,71 +21,104 @@ export class V3LiveText {
|
|
|
7
21
|
for (const m of messages) {
|
|
8
22
|
if (m.role !== 'assistant')
|
|
9
23
|
continue;
|
|
10
|
-
|
|
11
|
-
const stream = m.metadata?.stream_id;
|
|
12
|
-
if (typeof run === 'string' && typeof stream === 'string') {
|
|
13
|
-
const key = JSON.stringify([run, stream]);
|
|
14
|
-
if (!this.committed.has(key) && this.committed.size >= 4096)
|
|
15
|
-
throw new Error('Live stream history limit reached; reconnect required');
|
|
16
|
-
this.streams.delete(key);
|
|
17
|
-
this.committed.add(key);
|
|
18
|
-
}
|
|
24
|
+
this.commit(m.metadata?.run_id ?? m.metadata?.runId, m.metadata?.stream_id);
|
|
19
25
|
}
|
|
20
26
|
}
|
|
21
27
|
durable(events) {
|
|
22
28
|
for (const event of events) {
|
|
23
|
-
const p = event.payload;
|
|
29
|
+
const p = event.payload ?? {};
|
|
24
30
|
if (event.event_type === 'session.assistant.completed') {
|
|
25
31
|
const message = p.message;
|
|
26
32
|
if (message?.role === 'assistant')
|
|
27
33
|
this.reconcile([message]);
|
|
28
|
-
|
|
29
|
-
const stream = p.stream_id ?? message?.metadata?.stream_id;
|
|
30
|
-
if (typeof run === 'string' && typeof stream === 'string') {
|
|
31
|
-
const key = JSON.stringify([run, stream]);
|
|
32
|
-
if (!this.committed.has(key) && this.committed.size >= 4096)
|
|
33
|
-
throw new Error('Live history limit; reconnect required');
|
|
34
|
-
this.streams.delete(key);
|
|
35
|
-
this.committed.add(key);
|
|
36
|
-
}
|
|
34
|
+
this.commit(p.run_id ?? message?.metadata?.run_id, p.stream_id ?? message?.metadata?.stream_id);
|
|
37
35
|
continue;
|
|
38
36
|
}
|
|
39
|
-
if (!
|
|
37
|
+
if (!DURABLE_TEXT_DELTA_EVENTS.has(event.event_type))
|
|
40
38
|
continue;
|
|
41
|
-
const run = p.run_id, stream = p.stream_id, start = p.offset_start, end = p.offset_end;
|
|
42
39
|
const text = p.delta ?? p.text_delta ?? p.content_delta;
|
|
43
|
-
if (typeof
|
|
44
|
-
|
|
45
|
-
const key = JSON.stringify([run, stream]);
|
|
46
|
-
const old = this.streams.get(key);
|
|
47
|
-
if (this.committed.has(key) || end <= (old?.offset ?? 0))
|
|
48
|
-
continue;
|
|
49
|
-
const offset = old?.offset ?? 0;
|
|
50
|
-
const bytes = new TextEncoder().encode(text);
|
|
51
|
-
if (start < 0 || start > offset || end !== start + bytes.length || end > 4 * 1024 * 1024 || (!old && this.streams.size >= 64))
|
|
40
|
+
if (typeof p.run_id !== 'string' || typeof p.stream_id !== 'string' || typeof p.offset_start !== 'number' ||
|
|
41
|
+
typeof p.offset_end !== 'number' || typeof text !== 'string')
|
|
52
42
|
continue;
|
|
53
|
-
|
|
54
|
-
this.streams.set(key, { runId: run, streamId: stream, text: (old?.text ?? '') + suffix, seq: 0, offset: end });
|
|
43
|
+
this.append(p.run_id, p.stream_id, p.offset_start, p.offset_end, text);
|
|
55
44
|
}
|
|
56
45
|
}
|
|
46
|
+
/** Returns false only for a malformed patch; gaps and duplicates are handled here. */
|
|
57
47
|
accept(p) {
|
|
58
48
|
if (p.stream_kind !== 'assistant_text')
|
|
59
49
|
return true;
|
|
60
|
-
|
|
50
|
+
if (p.operation !== 'append' || typeof p.text !== 'string')
|
|
51
|
+
return false;
|
|
52
|
+
return this.append(p.run_id, p.stream_id, p.offset_start, p.offset_end, p.text);
|
|
53
|
+
}
|
|
54
|
+
values() {
|
|
55
|
+
return [...this.streams.values()].filter(s => s.text).map(({ runId, streamId, text }) => ({ runId, streamId, text }));
|
|
56
|
+
}
|
|
57
|
+
commit(run, stream) {
|
|
58
|
+
if (typeof run !== 'string' || typeof stream !== 'string')
|
|
59
|
+
return;
|
|
60
|
+
const key = JSON.stringify([run, stream]);
|
|
61
|
+
if (!this.committed.has(key) && this.committed.size >= LIVE_MAX_COMMITTED)
|
|
62
|
+
throw new Error('Live stream history limit reached; reconnect required');
|
|
63
|
+
this.streams.delete(key);
|
|
64
|
+
this.committed.add(key);
|
|
65
|
+
}
|
|
66
|
+
append(runId, streamId, start, end, text) {
|
|
67
|
+
const key = JSON.stringify([runId, streamId]);
|
|
61
68
|
if (this.committed.has(key))
|
|
62
69
|
return true;
|
|
63
|
-
const
|
|
64
|
-
if (
|
|
70
|
+
const bytes = utf8Encoder.encode(text);
|
|
71
|
+
if (!Number.isSafeInteger(start) || start < 0 || end !== start + bytes.length || end > LIVE_MAX_STREAM_BYTES)
|
|
72
|
+
return false;
|
|
73
|
+
let stream = this.streams.get(key);
|
|
74
|
+
if (!stream) {
|
|
75
|
+
if (this.streams.size >= LIVE_MAX_STREAMS)
|
|
76
|
+
return false;
|
|
77
|
+
stream = { runId, streamId, text: '', offset: 0 };
|
|
78
|
+
this.streams.set(key, stream);
|
|
79
|
+
}
|
|
80
|
+
if (end <= stream.offset)
|
|
81
|
+
return true;
|
|
82
|
+
if (start > stream.offset) {
|
|
83
|
+
holdLiveRange(stream, start, bytes);
|
|
65
84
|
return true;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
p.offset_end !== p.offset_start + new TextEncoder().encode(p.text).length ||
|
|
69
|
-
p.offset_end > 4 * 1024 * 1024 || (!old && this.streams.size >= 64))
|
|
85
|
+
}
|
|
86
|
+
if (!extendLiveStream(stream, bytes.subarray(stream.offset - start)))
|
|
70
87
|
return false;
|
|
71
|
-
|
|
88
|
+
const held = stream.held;
|
|
89
|
+
if (held && held.start <= stream.offset) {
|
|
90
|
+
stream.held = undefined;
|
|
91
|
+
if (held.start + held.bytes.length > stream.offset && !extendLiveStream(stream, held.bytes.subarray(stream.offset - held.start)))
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
72
94
|
return true;
|
|
73
95
|
}
|
|
74
|
-
|
|
96
|
+
}
|
|
97
|
+
function extendLiveStream(stream, suffix) {
|
|
98
|
+
try {
|
|
99
|
+
stream.text += utf8Decoder.decode(suffix);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return false;
|
|
103
|
+
}
|
|
104
|
+
stream.offset += suffix.length;
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
/** Keeps one contiguous range ahead of the prefix; adjoining ranges merge, a later disjoint one replaces it. */
|
|
108
|
+
function holdLiveRange(stream, start, bytes) {
|
|
109
|
+
const held = stream.held;
|
|
110
|
+
const end = start + bytes.length;
|
|
111
|
+
if (held && start <= held.start + held.bytes.length && end >= held.start) {
|
|
112
|
+
const from = Math.min(start, held.start);
|
|
113
|
+
const to = Math.max(end, held.start + held.bytes.length);
|
|
114
|
+
const merged = new Uint8Array(to - from);
|
|
115
|
+
merged.set(held.bytes, held.start - from);
|
|
116
|
+
merged.set(bytes, start - from);
|
|
117
|
+
stream.held = { start: from, bytes: merged };
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
if (!held || end > held.start + held.bytes.length)
|
|
121
|
+
stream.held = { start, bytes: bytes.slice() };
|
|
75
122
|
}
|
|
76
123
|
export class SwarmRealtimeNamespace {
|
|
77
124
|
transport;
|
|
@@ -84,6 +131,24 @@ export class SwarmRealtimeNamespace {
|
|
|
84
131
|
async hydrate(body, signal) {
|
|
85
132
|
return (await this.transport.request('/v3/sync/hydrate', { method: 'POST', body, signal })).data;
|
|
86
133
|
}
|
|
134
|
+
/**
|
|
135
|
+
* Hydrates a watch window. The daemon's default manifest policy ('error') refuses the whole
|
|
136
|
+
* snapshot when the recent window cannot hold all history (e.g. one ~280 KB tool result
|
|
137
|
+
* exhausts the 256 KB message budget). Both 'omit' and 'manifest' still return the same
|
|
138
|
+
* newest window plus an omission with `next_cursor`; 'manifest' also returns a descriptor
|
|
139
|
+
* of what was included, so it loses the least. The policy then sticks for this watch.
|
|
140
|
+
*/
|
|
141
|
+
async hydrateWindow(body, signal) {
|
|
142
|
+
try {
|
|
143
|
+
return await this.hydrate(body, signal);
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
if (body.history?.manifest_policy || !isRequiresManifestError(error))
|
|
147
|
+
throw error;
|
|
148
|
+
body.history = { ...body.history, manifest_policy: 'manifest' };
|
|
149
|
+
return this.hydrate(body, signal);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
87
152
|
/** Bounded durable replay. Keep the identical selector/resources/surface for every cursor. */
|
|
88
153
|
async replay(body, signal) {
|
|
89
154
|
return (await this.transport.request('/v3/sync/stream', { method: 'POST', body, signal })).data;
|
|
@@ -140,16 +205,19 @@ export class SwarmRealtimeNamespace {
|
|
|
140
205
|
const body = { surface, session_ids: [id], selector: { kind: 'session_ids', session_ids: [id] },
|
|
141
206
|
history: { mode: 'tail', max_messages_per_session: 200, max_events_per_session: 200, include_events: true },
|
|
142
207
|
resources: { messages: true, events: true, current_run_state: true, session_view: true } };
|
|
208
|
+
// The budget bounds consecutive failures only; every replayed connection restores it.
|
|
143
209
|
let attempts = 0;
|
|
210
|
+
let latest;
|
|
211
|
+
const onChange = (state) => { latest = state; options.onChange(state); };
|
|
144
212
|
while (!signal.aborted) {
|
|
145
213
|
// Every reconnect hydrates fresh durable authority before replay; no timer polling.
|
|
146
|
-
const snapshot = await this.
|
|
214
|
+
const snapshot = await this.hydrateWindow(body, signal);
|
|
147
215
|
if (signal.aborted)
|
|
148
216
|
return;
|
|
149
217
|
if (!snapshot.sessions_by_id[id] || !snapshot.realtime?.resume.endpoint_cursor)
|
|
150
218
|
throw new Error('Session omitted or realtime bootstrap unavailable');
|
|
151
219
|
try {
|
|
152
|
-
await this.connection(id, body, snapshot, options, signal, ready);
|
|
220
|
+
await this.connection(id, body, snapshot, latest?.snapshot.events_by_session[id] ?? [], { ...options, onChange }, signal, ready, () => { attempts = 0; });
|
|
153
221
|
return;
|
|
154
222
|
}
|
|
155
223
|
catch (error) {
|
|
@@ -157,12 +225,13 @@ export class SwarmRealtimeNamespace {
|
|
|
157
225
|
return;
|
|
158
226
|
if (error instanceof FatalRealtimeError || attempts >= (options.maxReconnects ?? 6))
|
|
159
227
|
throw error;
|
|
160
|
-
|
|
228
|
+
// Keep the last rendered conversation (including partial text) while reconnecting.
|
|
229
|
+
onChange({ ...(latest ?? { sessionId: id, snapshot, messages: snapshot.messages_by_session[id] ?? [], live: [], ...historyOmissionState(snapshot, id) }), status: 'reconnecting' });
|
|
161
230
|
await delay(Math.min(250 * 2 ** attempts++, 8000), signal);
|
|
162
231
|
}
|
|
163
232
|
}
|
|
164
233
|
}
|
|
165
|
-
async connection(id, body, initial, options, signal, ready) {
|
|
234
|
+
async connection(id, body, initial, seenEvents, options, signal, ready, onReplayed) {
|
|
166
235
|
const socket = await (options.socketFactory ?? ((path) => this.socket(path)))(`/v3/realtime/stream?${new URLSearchParams({ surface: body.surface })}`);
|
|
167
236
|
if (signal.aborted) {
|
|
168
237
|
socket.close();
|
|
@@ -179,13 +248,20 @@ export class SwarmRealtimeNamespace {
|
|
|
179
248
|
let dirty = false;
|
|
180
249
|
let readyPending = false;
|
|
181
250
|
let lastSeq = snapshot.projections_by_session[id]?.last_event_seq ?? 0;
|
|
251
|
+
// Durable events delivered on the socket are merged into the hydrated tail, so a
|
|
252
|
+
// consumer sees every event once and immediately, even before the next hydration.
|
|
253
|
+
let events = mergeSessionEvents(seenEvents, snapshot.events_by_session[id] ?? []);
|
|
182
254
|
const live = new V3LiveText();
|
|
183
255
|
live.reconcile(snapshot.messages_by_session[id] ?? []);
|
|
184
|
-
live.durable(
|
|
256
|
+
live.durable(events);
|
|
185
257
|
const subscription = `sdk:session:${id}`;
|
|
186
258
|
const timer = setTimeout(() => finish(new Error('Realtime handshake timed out')), 30_000);
|
|
187
|
-
const emit = () => {
|
|
188
|
-
|
|
259
|
+
const emit = () => {
|
|
260
|
+
if (ended)
|
|
261
|
+
return;
|
|
262
|
+
const view = { ...snapshot, events_by_session: { ...snapshot.events_by_session, [id]: events } };
|
|
263
|
+
options.onChange({ sessionId: id, snapshot: view, messages: snapshot.messages_by_session[id] ?? [], live: live.values(), status: replayed ? 'live' : 'syncing', ...historyOmissionState(snapshot, id) });
|
|
264
|
+
};
|
|
189
265
|
const finish = (error) => {
|
|
190
266
|
if (ended)
|
|
191
267
|
return;
|
|
@@ -208,13 +284,14 @@ export class SwarmRealtimeNamespace {
|
|
|
208
284
|
try {
|
|
209
285
|
while (dirty && !ended) {
|
|
210
286
|
dirty = false;
|
|
211
|
-
const next = await this.
|
|
287
|
+
const next = await this.hydrateWindow(body, requests.signal);
|
|
212
288
|
if (ended)
|
|
213
289
|
return;
|
|
214
290
|
if (!next.sessions_by_id[id])
|
|
215
291
|
throw new FatalRealtimeError('Session no longer available');
|
|
216
292
|
snapshot = next;
|
|
217
293
|
lastSeq = Math.max(lastSeq, next.projections_by_session[id]?.last_event_seq ?? 0);
|
|
294
|
+
events = mergeSessionEvents(events, next.events_by_session[id] ?? []);
|
|
218
295
|
live.reconcile(next.messages_by_session[id] ?? []);
|
|
219
296
|
live.durable(next.events_by_session[id] ?? []);
|
|
220
297
|
emit();
|
|
@@ -267,6 +344,7 @@ export class SwarmRealtimeNamespace {
|
|
|
267
344
|
return;
|
|
268
345
|
if (frame.kind === 'replay.complete') {
|
|
269
346
|
replayed = true;
|
|
347
|
+
onReplayed();
|
|
270
348
|
clearTimeout(timer);
|
|
271
349
|
readyPending = true;
|
|
272
350
|
void refresh();
|
|
@@ -275,7 +353,7 @@ export class SwarmRealtimeNamespace {
|
|
|
275
353
|
if (!liveNegotiated || !replayed)
|
|
276
354
|
return;
|
|
277
355
|
if (!live.accept(frame.live)) {
|
|
278
|
-
finish(new Error('
|
|
356
|
+
finish(new Error('Malformed live patch; durable rehydration required'));
|
|
279
357
|
}
|
|
280
358
|
else
|
|
281
359
|
emit();
|
|
@@ -284,9 +362,13 @@ export class SwarmRealtimeNamespace {
|
|
|
284
362
|
if (frame.event.seq <= lastSeq)
|
|
285
363
|
return;
|
|
286
364
|
lastSeq = frame.event.seq;
|
|
365
|
+
events = mergeSessionEvents(events, [frame.event]);
|
|
287
366
|
live.durable([frame.event]);
|
|
288
367
|
emit();
|
|
289
|
-
|
|
368
|
+
// Streaming checkpoints change no hydrated authority (messages, run state,
|
|
369
|
+
// permissions); re-hydrating on each one would only add load.
|
|
370
|
+
if (!String(frame.event.event_type).endsWith('.delta'))
|
|
371
|
+
void refresh();
|
|
290
372
|
}
|
|
291
373
|
else if (frame.kind === 'projection.high_watermark') {
|
|
292
374
|
void refresh();
|
|
@@ -299,6 +381,20 @@ export class SwarmRealtimeNamespace {
|
|
|
299
381
|
});
|
|
300
382
|
}
|
|
301
383
|
}
|
|
384
|
+
const MAX_WATCH_EVENTS = 1000;
|
|
385
|
+
/** Union by durable sequence, ascending, bounded to the newest events. */
|
|
386
|
+
function mergeSessionEvents(current, incoming) {
|
|
387
|
+
if (!incoming.length)
|
|
388
|
+
return current;
|
|
389
|
+
const bySeq = new Map();
|
|
390
|
+
for (const event of current)
|
|
391
|
+
bySeq.set(event.seq, event);
|
|
392
|
+
for (const event of incoming)
|
|
393
|
+
if (event && Number.isSafeInteger(event.seq) && !bySeq.has(event.seq))
|
|
394
|
+
bySeq.set(event.seq, event);
|
|
395
|
+
const merged = [...bySeq.values()].sort((a, b) => a.seq - b.seq);
|
|
396
|
+
return merged.length > MAX_WATCH_EVENTS ? merged.slice(merged.length - MAX_WATCH_EVENTS) : merged;
|
|
397
|
+
}
|
|
302
398
|
class FatalRealtimeError extends Error {
|
|
303
399
|
}
|
|
304
400
|
function delay(ms, signal) {
|
|
@@ -310,3 +406,11 @@ function delay(ms, signal) {
|
|
|
310
406
|
finish();
|
|
311
407
|
});
|
|
312
408
|
}
|
|
409
|
+
function isRequiresManifestError(error) {
|
|
410
|
+
return error instanceof SwarmApiError && error.status === 400 && /\brequires_manifest\b/.test(error.message);
|
|
411
|
+
}
|
|
412
|
+
function historyOmissionState(snapshot, id) {
|
|
413
|
+
const historyOmissions = (Array.isArray(snapshot.omissions) ? snapshot.omissions : [])
|
|
414
|
+
.filter((o) => o && o.session_id === id && (o.resource === 'messages' || o.resource === 'events'));
|
|
415
|
+
return { historyOmitted: historyOmissions.length > 0, historyOmissions };
|
|
416
|
+
}
|
package/dist/remote.d.ts
ADDED
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { SwarmTransport } from './transport.js';
|
|
2
|
+
/** An AI client's authorization request waiting on this machine's owner. */
|
|
3
|
+
export interface RemoteConsent {
|
|
4
|
+
code: string;
|
|
5
|
+
client_id: string;
|
|
6
|
+
client_name: string;
|
|
7
|
+
client_domain?: string;
|
|
8
|
+
redirect_host: string;
|
|
9
|
+
scopes: string[];
|
|
10
|
+
expires_at: number;
|
|
11
|
+
}
|
|
12
|
+
/** Relay connection state. Never contains the device key or token. */
|
|
13
|
+
export interface RemoteStatus {
|
|
14
|
+
configured: boolean;
|
|
15
|
+
enabled: boolean;
|
|
16
|
+
connected: boolean;
|
|
17
|
+
relay_url?: string;
|
|
18
|
+
device_id?: string;
|
|
19
|
+
device_name?: string;
|
|
20
|
+
public_key?: string;
|
|
21
|
+
allow_write: boolean;
|
|
22
|
+
allow_approve: boolean;
|
|
23
|
+
allow_manage: boolean;
|
|
24
|
+
last_error?: string;
|
|
25
|
+
pending_consents: RemoteConsent[];
|
|
26
|
+
/** Set while the relay waits for an authorized client to pair this machine. */
|
|
27
|
+
pairing_code?: string;
|
|
28
|
+
pairing_expires_at?: number;
|
|
29
|
+
}
|
|
30
|
+
export interface RemoteInitInput {
|
|
31
|
+
relay_url: string;
|
|
32
|
+
device_name: string;
|
|
33
|
+
allow_write?: boolean;
|
|
34
|
+
allow_approve?: boolean;
|
|
35
|
+
allow_manage?: boolean;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Owner administration of the machine's relay connection (`/v1/remote*`).
|
|
39
|
+
* The daemon refuses scoped tokens here: call it only from a trusted backend
|
|
40
|
+
* on the private local transport, never from a browser.
|
|
41
|
+
*/
|
|
42
|
+
export declare class SwarmRemoteNamespace {
|
|
43
|
+
private readonly transport;
|
|
44
|
+
constructor(transport: SwarmTransport);
|
|
45
|
+
private call;
|
|
46
|
+
status(): Promise<RemoteStatus>;
|
|
47
|
+
/** Creates this machine's device key for one relay. Does not connect. */
|
|
48
|
+
init(input: RemoteInitInput): Promise<RemoteStatus>;
|
|
49
|
+
enable(): Promise<RemoteStatus>;
|
|
50
|
+
disable(): Promise<RemoteStatus>;
|
|
51
|
+
/**
|
|
52
|
+
* Approves or denies an AI client's authorization request shown on the
|
|
53
|
+
* relay's consent page. Approved scopes are clamped to the request and to
|
|
54
|
+
* this machine's ceiling by the daemon.
|
|
55
|
+
*/
|
|
56
|
+
decideConsent(code: string, approve: boolean, scopes?: string[]): Promise<RemoteConsent>;
|
|
57
|
+
/** Disconnects, revokes the device token and deletes the device key. */
|
|
58
|
+
reset(): Promise<RemoteStatus>;
|
|
59
|
+
}
|