@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.
Files changed (48) hide show
  1. package/HEADLESS_UI.md +4 -6
  2. package/README.md +138 -58
  3. package/dist/agents.d.ts +114 -0
  4. package/dist/agents.js +183 -0
  5. package/dist/auth.d.ts +14 -0
  6. package/dist/auth.js +16 -0
  7. package/dist/box.d.ts +88 -0
  8. package/dist/box.js +13 -0
  9. package/dist/browser.d.ts +6 -2
  10. package/dist/browser.js +11 -3
  11. package/dist/chat.d.ts +41 -7
  12. package/dist/chat.js +148 -83
  13. package/dist/client.d.ts +12 -0
  14. package/dist/client.js +19 -6
  15. package/dist/copy-tags.d.ts +26 -0
  16. package/dist/copy-tags.js +133 -0
  17. package/dist/errors.d.ts +16 -0
  18. package/dist/errors.js +21 -0
  19. package/dist/index.d.ts +6 -0
  20. package/dist/index.js +6 -0
  21. package/dist/local-connection.d.ts +39 -0
  22. package/dist/local-connection.js +56 -0
  23. package/dist/permission-ui.d.ts +1 -0
  24. package/dist/permission-ui.js +37 -0
  25. package/dist/permissions.d.ts +4 -0
  26. package/dist/permissions.js +7 -0
  27. package/dist/projects.d.ts +25 -7
  28. package/dist/projects.js +65 -17
  29. package/dist/provider-auth.d.ts +5 -3
  30. package/dist/provider-auth.js +6 -8
  31. package/dist/realtime.d.ts +40 -6
  32. package/dist/realtime.js +156 -52
  33. package/dist/remote.d.ts +59 -0
  34. package/dist/remote.js +46 -0
  35. package/dist/sessions.d.ts +31 -1
  36. package/dist/sessions.js +49 -1
  37. package/dist/settings.d.ts +3 -0
  38. package/dist/settings.js +19 -3
  39. package/dist/signals.d.ts +148 -0
  40. package/dist/signals.js +97 -0
  41. package/dist/types.d.ts +97 -6
  42. package/dist/websocket.d.ts +3 -1
  43. package/dist/websocket.js +1 -1
  44. package/dist/workers.d.ts +6 -0
  45. package/dist/workers.js +27 -0
  46. package/dist/workspaces.d.ts +20 -0
  47. package/dist/workspaces.js +24 -0
  48. 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 ?? 'Orchestrator AI',
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
- async integrateTask(projectId, taskId, options) {
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
- const res = await this.transport.request(`/v3/projects/${pId}/tasks/${tId}/integrate`, { method: 'POST', ...options });
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
+ }
@@ -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 headless daemon identity is bootstrapped.
189
- * If the daemon requires onboarding, it automatically sets default identity parameters
190
- * so provider authentication and sessions can immediately proceed without manual wizard steps.
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;
@@ -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 headless daemon identity is bootstrapped.
120
- * If the daemon requires onboarding, it automatically sets default identity parameters
121
- * so provider authentication and sessions can immediately proceed without manual wizard steps.
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 this.update({ desktop_onboarding_complete: true });
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. */
@@ -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
- /** Offset authority is UTF-8 bytes (not JavaScript UTF-16 string length). */
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
- /** Offset authority is UTF-8 bytes (not JavaScript UTF-16 string length). */
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
- const run = m.metadata?.run_id ?? m.metadata?.runId;
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
- const run = p.run_id ?? message?.metadata?.run_id;
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 (!['session.assistant.delta', 'session.message.delta'].includes(event.event_type))
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 run !== 'string' || typeof stream !== 'string' || typeof start !== 'number' || typeof end !== 'number' || typeof text !== 'string')
44
- continue;
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
- const suffix = new TextDecoder('utf-8', { fatal: true }).decode(bytes.subarray(offset - start));
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
- const key = JSON.stringify([p.run_id, p.stream_id]);
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 old = this.streams.get(key);
64
- if (old && p.offset_end <= old.offset)
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
- if (p.operation !== 'append' || (old?.seq !== 0 && p.live_seq_start !== (old?.seq ?? 0) + 1) ||
67
- p.offset_start !== (old?.offset ?? 0) || p.live_seq_end < p.live_seq_start ||
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
- this.streams.set(key, { runId: p.run_id, streamId: p.stream_id, text: (old?.text ?? '') + p.text, seq: p.live_seq_end, offset: p.offset_end });
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
- values() { return [...this.streams.values()].map(({ runId, streamId, text }) => ({ runId, streamId, text })); }
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.hydrate(body, signal);
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
- options.onChange({ sessionId: id, snapshot, messages: snapshot.messages_by_session[id] ?? [], live: [], status: 'reconnecting' });
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(snapshot.events_by_session[id] ?? []);
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 = () => { if (!ended)
188
- options.onChange({ sessionId: id, snapshot, messages: snapshot.messages_by_session[id] ?? [], live: live.values(), status: replayed ? 'live' : 'syncing' }); };
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.hydrate(body, requests.signal);
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('Live stream gap; durable rehydration required'));
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
- void refresh();
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
+ }
@@ -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
+ }