@swarm-agent/sdk 0.1.48 → 0.1.50

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 (46) hide show
  1. package/HEADLESS_UI.md +4 -6
  2. package/README.md +121 -56
  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 +68 -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 -3
  28. package/dist/projects.js +65 -6
  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 +70 -2
  42. package/dist/websocket.d.ts +3 -1
  43. package/dist/websocket.js +1 -1
  44. package/dist/workspaces.d.ts +11 -0
  45. package/dist/workspaces.js +17 -0
  46. package/package.json +1 -1
@@ -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
+ }
package/dist/remote.js ADDED
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Owner administration of the machine's relay connection (`/v1/remote*`).
3
+ * The daemon refuses scoped tokens here: call it only from a trusted backend
4
+ * on the private local transport, never from a browser.
5
+ */
6
+ export class SwarmRemoteNamespace {
7
+ transport;
8
+ constructor(transport) {
9
+ this.transport = transport;
10
+ }
11
+ async call(path, method, body) {
12
+ const res = await this.transport.request(`/v1/remote${path}`, { method, ...(method === 'POST' ? { body: body ?? {} } : {}) });
13
+ return res.data.remote;
14
+ }
15
+ status() {
16
+ return this.call('', 'GET');
17
+ }
18
+ /** Creates this machine's device key for one relay. Does not connect. */
19
+ init(input) {
20
+ return this.call('/init', 'POST', {
21
+ relay_url: input.relay_url, device_name: input.device_name,
22
+ allow_write: input.allow_write === true, allow_approve: input.allow_approve === true, allow_manage: input.allow_manage === true,
23
+ });
24
+ }
25
+ enable() {
26
+ return this.call('/enable', 'POST');
27
+ }
28
+ disable() {
29
+ return this.call('/disable', 'POST');
30
+ }
31
+ /**
32
+ * Approves or denies an AI client's authorization request shown on the
33
+ * relay's consent page. Approved scopes are clamped to the request and to
34
+ * this machine's ceiling by the daemon.
35
+ */
36
+ async decideConsent(code, approve, scopes) {
37
+ const res = await this.transport.request(`/v1/remote/consents/${approve ? 'approve' : 'deny'}`, {
38
+ method: 'POST', body: approve && scopes ? { code, scopes } : { code },
39
+ });
40
+ return res.data.consent;
41
+ }
42
+ /** Disconnects, revokes the device token and deletes the device key. */
43
+ reset() {
44
+ return this.call('/reset', 'POST');
45
+ }
46
+ }
@@ -35,14 +35,44 @@ export declare class SwarmSessionsNamespace {
35
35
  * Deletes a session and triggers cascading cancellation of associated runs and worktrees.
36
36
  */
37
37
  delete(sessionId: string): Promise<boolean>;
38
+ /**
39
+ * Renames a session or project conversation you own (`POST /v3/sessions/{id}/title`).
40
+ * The title is trimmed and must be 1–200 characters. A renamed conversation keeps its
41
+ * title: Swarm no longer titles it automatically. (Untitled conversations are named
42
+ * from their first message.) Retrying with the same `clientRequestId` is idempotent.
43
+ */
44
+ rename(sessionId: string, title: string, options?: {
45
+ clientRequestId?: string;
46
+ }): Promise<{
47
+ ok: boolean;
48
+ session_id: string;
49
+ title: string;
50
+ }>;
38
51
  /**
39
52
  * Appends a message to a session conversation.
40
53
  */
41
54
  sendMessage(sessionId: string, params: {
42
55
  content: string;
43
- role?: 'user' | 'assistant' | 'system';
56
+ role?: 'user';
44
57
  client_request_id?: string;
45
58
  }): Promise<any>;
59
+ /**
60
+ * Starts a fresh model context in the same conversation (`POST /v3/sessions/{id}/context/clear`).
61
+ * History stays visible and the session ID is unchanged; the next turn starts without the
62
+ * earlier provider context. The daemon accepts this only for an idle project conversation
63
+ * without an unresolved plan, and only when `expectedLastEventSeq` equals the session's current
64
+ * durable sequence (`state.snapshot.projections_by_session[id].last_event_seq`); otherwise it
65
+ * rejects with `SwarmConflictError` and changes nothing. A standalone chat session cannot be
66
+ * cleared: create a new session instead. Retrying with the same `clientRequestId` is idempotent.
67
+ */
68
+ clearContext(sessionId: string, params: {
69
+ expectedLastEventSeq: number;
70
+ clientRequestId?: string;
71
+ }): Promise<{
72
+ ok: boolean;
73
+ session_id: string;
74
+ mutation: Record<string, unknown>;
75
+ }>;
46
76
  /**
47
77
  * Stops an active execution run for a session.
48
78
  */
package/dist/sessions.js CHANGED
@@ -84,10 +84,14 @@ export class SwarmSessionsNamespace {
84
84
  const data = res.data;
85
85
  if (data && typeof data === 'object') {
86
86
  const sessionObj = data.session || data;
87
+ // V3 sessions carry their source workspace as the primary grant.
88
+ const primaryGrant = Array.isArray(sessionObj.workspace_grants)
89
+ ? sessionObj.workspace_grants.find((grant) => grant?.kind === 'primary')
90
+ : undefined;
87
91
  return {
88
92
  id: sessionObj.id || sessionObj.session_id || sessionId,
89
93
  title: sessionObj.title || '',
90
- workspace_id: sessionObj.workspace_id,
94
+ workspace_id: sessionObj.workspace_id || primaryGrant?.workspace_id,
91
95
  workspace_path: sessionObj.workspace_path,
92
96
  state: sessionObj.state || data.projection?.state || 'idle',
93
97
  mode: sessionObj.mode,
@@ -145,6 +149,27 @@ export class SwarmSessionsNamespace {
145
149
  });
146
150
  return res.data?.ok === true;
147
151
  }
152
+ /**
153
+ * Renames a session or project conversation you own (`POST /v3/sessions/{id}/title`).
154
+ * The title is trimmed and must be 1–200 characters. A renamed conversation keeps its
155
+ * title: Swarm no longer titles it automatically. (Untitled conversations are named
156
+ * from their first message.) Retrying with the same `clientRequestId` is idempotent.
157
+ */
158
+ async rename(sessionId, title, options = {}) {
159
+ const id = sessionId.trim();
160
+ if (!id)
161
+ throw new Error('sessionId is required');
162
+ const trimmed = typeof title === 'string' ? title.trim() : '';
163
+ if (!trimmed)
164
+ throw new Error('title is required');
165
+ if ([...trimmed].length > 200)
166
+ throw new Error('title must be 200 characters or fewer');
167
+ const res = await this.transport.request(`/v3/sessions/${encodeURIComponent(id)}/title`, {
168
+ method: 'POST',
169
+ body: { title: trimmed, client_request_id: options.clientRequestId || generateRequestId() },
170
+ });
171
+ return res.data;
172
+ }
148
173
  /**
149
174
  * Appends a message to a session conversation.
150
175
  */
@@ -161,6 +186,29 @@ export class SwarmSessionsNamespace {
161
186
  });
162
187
  return res.data;
163
188
  }
189
+ /**
190
+ * Starts a fresh model context in the same conversation (`POST /v3/sessions/{id}/context/clear`).
191
+ * History stays visible and the session ID is unchanged; the next turn starts without the
192
+ * earlier provider context. The daemon accepts this only for an idle project conversation
193
+ * without an unresolved plan, and only when `expectedLastEventSeq` equals the session's current
194
+ * durable sequence (`state.snapshot.projections_by_session[id].last_event_seq`); otherwise it
195
+ * rejects with `SwarmConflictError` and changes nothing. A standalone chat session cannot be
196
+ * cleared: create a new session instead. Retrying with the same `clientRequestId` is idempotent.
197
+ */
198
+ async clearContext(sessionId, params) {
199
+ if (!Number.isSafeInteger(params.expectedLastEventSeq) || params.expectedLastEventSeq < 0) {
200
+ throw new Error('expectedLastEventSeq must be the current non-negative session event sequence');
201
+ }
202
+ const id = encodeURIComponent(sessionId.trim());
203
+ const res = await this.transport.request(`/v3/sessions/${id}/context/clear`, {
204
+ method: 'POST',
205
+ body: {
206
+ client_request_id: params.clientRequestId || generateRequestId(),
207
+ expected_last_event_seq: params.expectedLastEventSeq,
208
+ },
209
+ });
210
+ return res.data;
211
+ }
164
212
  /**
165
213
  * Stops an active execution run for a session.
166
214
  */
@@ -98,6 +98,9 @@ export declare class SwarmSettingsNamespace {
98
98
  /**
99
99
  * Restores verified recommended defaults across all system agent and swarm core roles.
100
100
  * If expectedUpdatedAt is omitted, automatically reads the current setting's revision first.
101
+ * An account with no model settings yet (a provider is connected but its
102
+ * models were never set up) restores from revision 0: the daemon then sets
103
+ * up the recommended models from a ready provider.
101
104
  */
102
105
  restoreDefaults(expectedUpdatedAt?: number): Promise<AgentModelSettingsResponse>;
103
106
  /**