@av-pi-studio/client 0.0.118 → 0.0.119

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/README.md CHANGED
@@ -92,6 +92,7 @@ interface DaemonClientOptions {
92
92
  protocolVersion?: number; // defaults to the current protocol version
93
93
  appVersion?: string;
94
94
  capabilities?: Record<string, boolean>; // CLIENT_CAPS flags to advertise in `hello`
95
+ agentStreams?: "all" | "none"; // "none": the daemon sends no `agent_stream` frames (0.0.119+)
95
96
  transport?: Transport; // inject a stub for tests; defaults to native WebSocket
96
97
  rpcTimeoutMs?: number; // per-request timeout; never tears down the socket
97
98
  now?: () => number; // inject a clock for deterministic tests
@@ -26,6 +26,14 @@ export interface DaemonClientOptions {
26
26
  * with `clientType: "browser"`). Informational; daemons that predate the field ignore it.
27
27
  */
28
28
  platform?: string;
29
+ /**
30
+ * `"none"` opens a quiet connection: the daemon (feature `agentStreamsMode`) sends it no
31
+ * `agent_stream` frame, so no `timeline.subscribe` listener ever fires; `agent_update`,
32
+ * `agent_ui_*` and everything else still arrive. Sent as `hello.agentStreams` only when set.
33
+ * Against a daemon without `agentStreamsMode` the field is ignored and every stream arrives, so
34
+ * a consumer must tolerate them. Fixed for the connection; reconnect to change it.
35
+ */
36
+ agentStreams?: "all" | "none";
29
37
  protocolVersion?: number;
30
38
  appVersion?: string;
31
39
  capabilities?: Record<string, boolean>;
@@ -95,6 +95,7 @@ export class DaemonClient {
95
95
  clientId: this.options.clientId,
96
96
  clientType: this.options.clientType,
97
97
  platform: this.options.platform,
98
+ agentStreams: this.options.agentStreams,
98
99
  protocolVersion: this.options.protocolVersion ?? PROTOCOL_VERSION,
99
100
  appVersion: this.options.appVersion,
100
101
  capabilities: this.options.capabilities,
@@ -20,10 +20,37 @@ export interface FileTransferClientOptions {
20
20
  /** Override the pacing timer (tests); resolves after `ms`. */
21
21
  sleep?(ms: number): Promise<void>;
22
22
  }
23
- export interface DownloadedFile {
24
- bytes: Uint8Array;
23
+ /** What a download reports besides its bytes, as announced by the daemon's `Begin` frame. */
24
+ export interface DownloadResult {
25
25
  fileName?: string;
26
26
  mimeType?: string;
27
+ /** The whole file's size when the transfer began — not the range's. */
28
+ totalBytes: number;
29
+ /** The file's modification time (ms) when the transfer began. */
30
+ mtimeMs: number;
31
+ /** First byte of the range, as served. */
32
+ offset: number;
33
+ /** Bytes in the range, as served (clamped to the end of the file). */
34
+ length: number;
35
+ }
36
+ /** `FileTransferClient.download`'s result: the range's bytes plus {@link DownloadResult}. */
37
+ export interface DownloadedFile extends DownloadResult {
38
+ bytes: Uint8Array;
39
+ }
40
+ export interface DownloadOptions {
41
+ /** First byte to read; default 0. */
42
+ offset?: number;
43
+ /** Bytes to read from `offset`; default (and cap) is to the end of the file. */
44
+ length?: number;
45
+ /** Refuse (`changed`) unless the file still has this size and modification time. */
46
+ ifUnchanged?: {
47
+ size: number;
48
+ mtimeMs: number;
49
+ };
50
+ /** Aborting sends a cancel request for the stream and rejects with an `AbortError`. */
51
+ signal?: AbortSignal;
52
+ /** After each chunk: bytes received so far in this range, and the range's length. */
53
+ onProgress?(received: number, length: number): void;
27
54
  }
28
55
  export declare class FileTransferClient {
29
56
  private readonly daemon;
@@ -31,29 +58,72 @@ export declare class FileTransferClient {
31
58
  /** Rotating hand-out point in the one-byte stream id space — ids are a pool, not a counter. */
32
59
  private streamCursor;
33
60
  private detach;
61
+ /** Present only while a download is pending: rejects them when the connection leaves `open`. */
62
+ private detachState;
34
63
  private readonly uploads;
35
64
  private readonly highWaterBytes;
36
65
  private readonly sleep;
37
66
  constructor(daemon: DaemonClient, options?: FileTransferClientOptions);
38
67
  /** Uploads that have not settled yet (0 once every upload has resolved, rejected, or been aborted). */
39
68
  get pendingUploads(): number;
69
+ /**
70
+ * Downloads still holding a stream id: in flight, or aborted and waiting for the daemon's last
71
+ * frame. 0 once every download has finished and the daemon is done with its stream.
72
+ */
73
+ get pendingDownloads(): number;
40
74
  /** Begin routing inbound file-transfer frames. Idempotent. */
41
75
  start(): void;
42
76
  /** Stop routing inbound frames. Any downloads and uploads still pending are rejected. */
43
77
  stop(): void;
44
78
  /**
45
- * Download the file at `path`: requests a single-use token, then requests the chunked
46
- * transfer, assembling `Begin → Chunk* → End` frames into one buffer.
79
+ * Download a byte range of the file at `path` into `sink`, which is called once per `Chunk` in
80
+ * wire order. The client keeps no reference to a chunk after the call, so memory stays at about
81
+ * one chunk however large the range is. With no `offset`/`length` the whole file is requested.
82
+ *
83
+ * - **`sink` is synchronous.** Frames are handled in arrival order on the transport's delivery
84
+ * path, so the sink cannot make the daemon wait. A consumer that must wait (writing to disk)
85
+ * has to buffer, or pace itself by asking for ranges one after the other. A sink (or
86
+ * `onProgress`) that throws aborts the download: the daemon is told to stop and this rejects
87
+ * with that error.
88
+ * - **Resolves** after `End` with the range's metadata, taken from the `Begin` frame:
89
+ * `{ fileName?, mimeType?, totalBytes, mtimeMs, offset, length }`. `totalBytes`/`mtimeMs`
90
+ * describe the whole file when the transfer began; keep them to resume with `ifUnchanged`.
91
+ * - **`onProgress(received, length)`** fires after every chunk with the bytes received so far in
92
+ * this range (monotonic) and the range length; it ends at `length`.
93
+ * - **Rejects** with the daemon's error code when the request is refused (`not_found`,
94
+ * `changed`, `stream_in_use`, …), with the `Error` frame's message (`read_failed`, `changed`,
95
+ * `cancelled`) when the transfer fails midway, and when the connection closes or {@link stop}
96
+ * is called. It rejects before sending anything when the daemon did not advertise
97
+ * `fileDownloadRanges`.
98
+ * - **Abort.** `signal` aborting rejects with an `AbortError` and sends
99
+ * `file_download_cancel_request { stream }`. The stream id is held until the daemon has said
100
+ * nothing more will arrive on it (a terminal frame, or a refusal), so a late frame can never
101
+ * reach a later download that reuses the id; frames that do arrive meanwhile are ignored.
47
102
  *
48
- * Retries exactly once on an expired token. The daemon's TTL runs from the moment it ISSUES a
49
- * token, but the token only becomes usable once its response reaches us — queued behind every
50
- * `Chunk` frame already in flight on this one socket. A large transfer ahead of it can hand us
51
- * a token that is dead on arrival (relay makes this routine: base64-inflated frames, extra
52
- * hop). By the time we see the rejection that backlog has drained, so a second attempt lands on
53
- * a quiet socket; looping further would only spin on a genuinely broken link.
103
+ * The stream id is registered BEFORE the request is sent: the daemon answers first and streams
104
+ * right after, and a frame for an unregistered id would be dropped.
105
+ */
106
+ downloadTo(path: string, sink: (chunk: Uint8Array) => void, options?: DownloadOptions): Promise<DownloadResult>;
107
+ /**
108
+ * Download the file at `path` (or the range in `options`) into one buffer: {@link downloadTo}
109
+ * collecting the chunks. Prefer `downloadTo` for anything large.
110
+ */
111
+ download(path: string, options?: DownloadOptions): Promise<DownloadedFile>;
112
+ /** Settle a download for its caller, once. The stream id is released separately. */
113
+ private finish;
114
+ /** Give a download's stream id back — only if it still belongs to `entry`. */
115
+ private release;
116
+ /**
117
+ * Reject a download now and tell the daemon to stop it. The id stays claimed until the daemon
118
+ * has nothing more to send on it: a terminal frame (see `dispatch`), a refused request, or a
119
+ * `not_found` cancel (nothing active — whatever it sent is already behind us on the socket).
54
120
  */
55
- download(path: string): Promise<DownloadedFile>;
56
- private attemptDownload;
121
+ private abortDownload;
122
+ /** Reject every download and free every id (stop, or the connection is gone). */
123
+ private failDownloads;
124
+ /** Watch the connection only while a download is pending, so an idle client holds no listener. */
125
+ private watchConnection;
126
+ private unwatchConnection;
57
127
  /**
58
128
  * Upload `source` to `path` on the daemon: requests an upload stream, then pushes
59
129
  * `Begin → Chunk* → End` binary frames the server writes to disk (creating parent dirs,
@@ -1,17 +1,16 @@
1
1
  import { encodeFileTransferFrame, nextFreeSlot, SLOT_SPACE, } from "@av-pi-studio/protocol";
2
2
  import { randomId } from "./daemon-client.js";
3
3
  /**
4
- * Client-side demux + assembly for binary file downloads plus chunked uploads
4
+ * Client-side demux for binary file downloads plus chunked uploads
5
5
  * (features/file-explorer-transfer.md § Binary transfer frames). Mirrors `TerminalStreamRouter`'s
6
6
  * inbound-frame-routing shape, but a download is a one-shot request/response rather than a
7
- * persistent per-slot subscription: request a token, request the download, await the assembled
8
- * bytes. An upload is the inverse: request an upload stream, then push `Begin → Chunk* → End`
9
- * binary frames the server writes to the target path.
7
+ * persistent per-slot subscription: request the download by path (and optional byte range), then
8
+ * hand the `Begin → Chunk* → End` frames that follow to a sink or collect them. An upload is the
9
+ * inverse: request an upload stream, then push `Begin → Chunk* → End` binary frames the server
10
+ * writes to the target path.
10
11
  */
11
12
  /** Matches the server's default chunk size (`FileTransferService` DEFAULT_CHUNK_BYTES). */
12
13
  const UPLOAD_CHUNK_BYTES = 32 * 1024;
13
- /** The daemon's `startDownload` rejection when a token is unknown, spent, or past its TTL. */
14
- const EXPIRED_TOKEN = "invalid_or_expired_token";
15
14
  /**
16
15
  * Upload pacing: `upload` waits before each chunk while the transport reports more than this many
17
16
  * unsent bytes, so the socket queue stays within this plus one chunk. 1 MiB (32 chunks) is a
@@ -21,6 +20,8 @@ const EXPIRED_TOKEN = "invalid_or_expired_token";
21
20
  export const UPLOAD_HIGH_WATER_BYTES = 1024 * 1024;
22
21
  /** How often `upload` re-reads the transport's buffered amount while it is congested. */
23
22
  const UPLOAD_POLL_MS = 10;
23
+ /** The message of the rejection when the daemon predates ranged downloads. */
24
+ const RANGES_UNSUPPORTED = "This daemon does not support file downloads by path and range (feature fileDownloadRanges): update the daemon.";
24
25
  function createRun() {
25
26
  // `new Promise`, not `Promise.withResolvers`: this SDK ships to browsers and React Native
26
27
  // runtimes older than ES2024 (and compiles with `lib: ES2023`).
@@ -44,8 +45,29 @@ function createRun() {
44
45
  },
45
46
  };
46
47
  }
47
- function abortError() {
48
- return new DOMException("The upload was aborted", "AbortError");
48
+ function abortError(what) {
49
+ return new DOMException(`The ${what} was aborted`, "AbortError");
50
+ }
51
+ function asError(error) {
52
+ return error instanceof Error ? error : new Error(String(error));
53
+ }
54
+ /** `Begin`'s range metadata, or null when the daemon left a field out. */
55
+ function downloadResult(meta) {
56
+ const { fileName, mimeType, totalBytes, mtimeMs, offset, length } = meta;
57
+ if (totalBytes === undefined ||
58
+ mtimeMs === undefined ||
59
+ offset === undefined ||
60
+ length === undefined) {
61
+ return null;
62
+ }
63
+ return {
64
+ ...(fileName === undefined ? {} : { fileName }),
65
+ ...(mimeType === undefined ? {} : { mimeType }),
66
+ totalBytes,
67
+ mtimeMs,
68
+ offset,
69
+ length,
70
+ };
49
71
  }
50
72
  function isAbortError(error) {
51
73
  return error instanceof Error && error.name === "AbortError";
@@ -93,6 +115,8 @@ export class FileTransferClient {
93
115
  /** Rotating hand-out point in the one-byte stream id space — ids are a pool, not a counter. */
94
116
  streamCursor = 1;
95
117
  detach = null;
118
+ /** Present only while a download is pending: rejects them when the connection leaves `open`. */
119
+ detachState = null;
96
120
  uploads = new Set();
97
121
  highWaterBytes;
98
122
  sleep;
@@ -105,6 +129,13 @@ export class FileTransferClient {
105
129
  get pendingUploads() {
106
130
  return this.uploads.size;
107
131
  }
132
+ /**
133
+ * Downloads still holding a stream id: in flight, or aborted and waiting for the daemon's last
134
+ * frame. 0 once every download has finished and the daemon is done with its stream.
135
+ */
136
+ get pendingDownloads() {
137
+ return this.pending.size;
138
+ }
108
139
  /** Begin routing inbound file-transfer frames. Idempotent. */
109
140
  start() {
110
141
  if (this.detach)
@@ -115,65 +146,165 @@ export class FileTransferClient {
115
146
  stop() {
116
147
  this.detach?.();
117
148
  this.detach = null;
118
- for (const [stream, pending] of this.pending) {
119
- pending.reject(new Error("file transfer router stopped"));
120
- this.pending.delete(stream);
121
- }
149
+ this.failDownloads(new Error("file transfer router stopped"));
122
150
  for (const run of this.uploads)
123
151
  run.fail(new Error("file transfer router stopped"));
124
152
  }
125
153
  /**
126
- * Download the file at `path`: requests a single-use token, then requests the chunked
127
- * transfer, assembling `Begin → Chunk* → End` frames into one buffer.
154
+ * Download a byte range of the file at `path` into `sink`, which is called once per `Chunk` in
155
+ * wire order. The client keeps no reference to a chunk after the call, so memory stays at about
156
+ * one chunk however large the range is. With no `offset`/`length` the whole file is requested.
157
+ *
158
+ * - **`sink` is synchronous.** Frames are handled in arrival order on the transport's delivery
159
+ * path, so the sink cannot make the daemon wait. A consumer that must wait (writing to disk)
160
+ * has to buffer, or pace itself by asking for ranges one after the other. A sink (or
161
+ * `onProgress`) that throws aborts the download: the daemon is told to stop and this rejects
162
+ * with that error.
163
+ * - **Resolves** after `End` with the range's metadata, taken from the `Begin` frame:
164
+ * `{ fileName?, mimeType?, totalBytes, mtimeMs, offset, length }`. `totalBytes`/`mtimeMs`
165
+ * describe the whole file when the transfer began; keep them to resume with `ifUnchanged`.
166
+ * - **`onProgress(received, length)`** fires after every chunk with the bytes received so far in
167
+ * this range (monotonic) and the range length; it ends at `length`.
168
+ * - **Rejects** with the daemon's error code when the request is refused (`not_found`,
169
+ * `changed`, `stream_in_use`, …), with the `Error` frame's message (`read_failed`, `changed`,
170
+ * `cancelled`) when the transfer fails midway, and when the connection closes or {@link stop}
171
+ * is called. It rejects before sending anything when the daemon did not advertise
172
+ * `fileDownloadRanges`.
173
+ * - **Abort.** `signal` aborting rejects with an `AbortError` and sends
174
+ * `file_download_cancel_request { stream }`. The stream id is held until the daemon has said
175
+ * nothing more will arrive on it (a terminal frame, or a refusal), so a late frame can never
176
+ * reach a later download that reuses the id; frames that do arrive meanwhile are ignored.
128
177
  *
129
- * Retries exactly once on an expired token. The daemon's TTL runs from the moment it ISSUES a
130
- * token, but the token only becomes usable once its response reaches us — queued behind every
131
- * `Chunk` frame already in flight on this one socket. A large transfer ahead of it can hand us
132
- * a token that is dead on arrival (relay makes this routine: base64-inflated frames, extra
133
- * hop). By the time we see the rejection that backlog has drained, so a second attempt lands on
134
- * a quiet socket; looping further would only spin on a genuinely broken link.
178
+ * The stream id is registered BEFORE the request is sent: the daemon answers first and streams
179
+ * right after, and a frame for an unregistered id would be dropped.
135
180
  */
136
- async download(path) {
137
- try {
138
- return await this.attemptDownload(path);
139
- }
140
- catch (error) {
141
- if (!(error instanceof Error) || error.message !== EXPIRED_TOKEN)
142
- throw error;
143
- return await this.attemptDownload(path);
144
- }
145
- }
146
- async attemptDownload(path) {
147
- const tokenResponse = await this.daemon.request("file_download_token_request", {
148
- path,
149
- });
150
- if (!tokenResponse.ok || !tokenResponse.token) {
151
- throw new Error(tokenResponse.error ?? "failed to issue download token");
152
- }
181
+ async downloadTo(path, sink, options = {}) {
182
+ const { offset, length, ifUnchanged, signal, onProgress } = options;
183
+ if (!this.daemon.hasFeature("fileDownloadRanges"))
184
+ throw new Error(RANGES_UNSUPPORTED);
185
+ if (signal?.aborted)
186
+ throw abortError("download");
187
+ if (this.daemon.state !== "open")
188
+ throw new Error("connection is not open");
153
189
  // Stream ids live in a single frame-header byte, so they are recycled as transfers finish;
154
190
  // an ever-incrementing counter would emit 256 and be rejected by the codec from then on.
155
191
  const stream = nextFreeSlot(this.pending, this.streamCursor);
156
192
  if (stream === null)
157
193
  throw new Error("no free download stream");
158
194
  this.streamCursor = (stream + 1) % SLOT_SPACE;
159
- const result = new Promise((resolve, reject) => {
160
- this.pending.set(stream, { chunks: [], meta: {}, resolve, reject });
195
+ return await new Promise((resolve, reject) => {
196
+ const entry = {
197
+ sink,
198
+ onProgress,
199
+ info: undefined,
200
+ received: 0,
201
+ progressed: false,
202
+ settled: false,
203
+ detachAbort: null,
204
+ resolve,
205
+ reject,
206
+ };
207
+ this.pending.set(stream, entry);
208
+ this.watchConnection();
209
+ if (signal) {
210
+ const onAbort = () => this.abortDownload(stream, entry, abortError("download"));
211
+ signal.addEventListener("abort", onAbort, { once: true });
212
+ entry.detachAbort = () => signal.removeEventListener("abort", onAbort);
213
+ }
214
+ // The response only says yes or no: the range metadata arrives in-band on `Begin`, which
215
+ // may be dispatched before this continuation runs.
216
+ this.daemon
217
+ .request("file_download_request", {
218
+ path,
219
+ stream,
220
+ offset,
221
+ length,
222
+ ifUnchanged,
223
+ })
224
+ .then((response) => {
225
+ if (response.ok)
226
+ return;
227
+ // A refused request streams nothing, so nothing else would free the id.
228
+ this.finish(entry, { error: new Error(response.error ?? "download request failed") });
229
+ this.release(stream, entry);
230
+ }, (error) => {
231
+ // Timed out or socket killed: no frame will ever release the id.
232
+ this.finish(entry, { error: asError(error) });
233
+ this.release(stream, entry);
234
+ });
161
235
  });
162
- let requestResponse;
163
- try {
164
- requestResponse = await this.daemon.request("file_download_request", { token: tokenResponse.token, stream });
165
- }
166
- catch (error) {
167
- // A timed-out or socket-killed request never streams `End`, so release the id here or it
168
- // stays claimed for the life of the connection and eats the pool.
169
- this.pending.delete(stream);
170
- throw error;
171
- }
172
- if (!requestResponse.ok) {
173
- this.pending.delete(stream);
174
- throw new Error(requestResponse.error ?? "download request failed");
236
+ }
237
+ /**
238
+ * Download the file at `path` (or the range in `options`) into one buffer: {@link downloadTo}
239
+ * collecting the chunks. Prefer `downloadTo` for anything large.
240
+ */
241
+ async download(path, options = {}) {
242
+ const chunks = [];
243
+ const result = await this.downloadTo(path, (chunk) => {
244
+ chunks.push(chunk);
245
+ }, options);
246
+ return { ...result, bytes: concat(chunks) };
247
+ }
248
+ /** Settle a download for its caller, once. The stream id is released separately. */
249
+ finish(entry, outcome) {
250
+ if (entry.settled)
251
+ return;
252
+ entry.settled = true;
253
+ entry.detachAbort?.();
254
+ entry.detachAbort = null;
255
+ if ("error" in outcome)
256
+ entry.reject(outcome.error);
257
+ else
258
+ entry.resolve(outcome.result);
259
+ }
260
+ /** Give a download's stream id back — only if it still belongs to `entry`. */
261
+ release(stream, entry) {
262
+ if (this.pending.get(stream) !== entry)
263
+ return;
264
+ this.pending.delete(stream);
265
+ if (this.pending.size === 0)
266
+ this.unwatchConnection();
267
+ }
268
+ /**
269
+ * Reject a download now and tell the daemon to stop it. The id stays claimed until the daemon
270
+ * has nothing more to send on it: a terminal frame (see `dispatch`), a refused request, or a
271
+ * `not_found` cancel (nothing active — whatever it sent is already behind us on the socket).
272
+ */
273
+ abortDownload(stream, entry, error) {
274
+ if (entry.settled)
275
+ return;
276
+ this.finish(entry, { error });
277
+ if (this.daemon.state !== "open") {
278
+ this.release(stream, entry);
279
+ return;
175
280
  }
176
- return result;
281
+ void this.daemon
282
+ .request("file_download_cancel_request", { stream })
283
+ .then((response) => {
284
+ if (!response.ok)
285
+ this.release(stream, entry);
286
+ }, () => this.release(stream, entry));
287
+ }
288
+ /** Reject every download and free every id (stop, or the connection is gone). */
289
+ failDownloads(error) {
290
+ const entries = [...this.pending.values()];
291
+ this.pending.clear();
292
+ this.unwatchConnection();
293
+ for (const entry of entries)
294
+ this.finish(entry, { error });
295
+ }
296
+ /** Watch the connection only while a download is pending, so an idle client holds no listener. */
297
+ watchConnection() {
298
+ if (this.detachState)
299
+ return;
300
+ this.detachState = this.daemon.onStateChange((state) => {
301
+ if (state !== "open")
302
+ this.failDownloads(new Error(`connection ${state} during download`));
303
+ });
304
+ }
305
+ unwatchConnection() {
306
+ this.detachState?.();
307
+ this.detachState = null;
177
308
  }
178
309
  /**
179
310
  * Upload `source` to `path` on the daemon: requests an upload stream, then pushes
@@ -198,7 +329,7 @@ export class FileTransferClient {
198
329
  async upload(path, source, options = {}) {
199
330
  const { onProgress, signal } = options;
200
331
  if (signal?.aborted)
201
- throw abortError();
332
+ throw abortError("upload");
202
333
  const run = createRun();
203
334
  this.uploads.add(run);
204
335
  const cleanups = [];
@@ -207,7 +338,7 @@ export class FileTransferClient {
207
338
  let daemonDone = false;
208
339
  try {
209
340
  if (signal) {
210
- const onAbort = () => run.fail(abortError());
341
+ const onAbort = () => run.fail(abortError("upload"));
211
342
  signal.addEventListener("abort", onAbort, { once: true });
212
343
  cleanups.push(() => signal.removeEventListener("abort", onAbort));
213
344
  }
@@ -317,29 +448,75 @@ export class FileTransferClient {
317
448
  }
318
449
  }
319
450
  dispatch(frame) {
320
- const pending = this.pending.get(frame.stream);
321
- if (!pending)
451
+ const entry = this.pending.get(frame.stream);
452
+ if (!entry)
322
453
  return; // not a download stream we're tracking (e.g. an upload) — ignore
454
+ // Everything below runs synchronously and in arrival order (see `transport.ts`): a frame that
455
+ // is handled later than its successors is a truncated download.
323
456
  switch (frame.opcode) {
324
- case "Begin":
325
- pending.meta.fileName = frame.meta.fileName;
326
- pending.meta.mimeType = frame.meta.mimeType;
457
+ case "Begin": {
458
+ if (entry.settled)
459
+ return;
460
+ const info = downloadResult(frame.meta);
461
+ if (!info) {
462
+ this.abortDownload(frame.stream, entry, new Error("daemon sent a Begin frame without the range"));
463
+ return;
464
+ }
465
+ entry.info = info;
327
466
  return;
328
- case "Chunk":
329
- pending.chunks.push(frame.data);
467
+ }
468
+ case "Chunk": {
469
+ if (entry.settled)
470
+ return; // aborted: the daemon may still have frames in flight
471
+ const { info } = entry;
472
+ if (!info) {
473
+ this.abortDownload(frame.stream, entry, new Error("daemon sent a Chunk before Begin"));
474
+ return;
475
+ }
476
+ try {
477
+ entry.sink(frame.data);
478
+ entry.received += frame.data.length;
479
+ entry.progressed = true;
480
+ entry.onProgress?.(entry.received, info.length);
481
+ }
482
+ catch (error) {
483
+ this.abortDownload(frame.stream, entry, asError(error));
484
+ }
330
485
  return;
331
- case "End":
332
- this.pending.delete(frame.stream);
333
- if (frame.ok) {
334
- pending.resolve({ bytes: concat(pending.chunks), ...pending.meta });
486
+ }
487
+ case "End": {
488
+ this.release(frame.stream, entry);
489
+ if (entry.settled)
490
+ return;
491
+ const { info } = entry;
492
+ if (!frame.ok) {
493
+ this.finish(entry, { error: new Error("download failed") });
494
+ }
495
+ else if (!info) {
496
+ this.finish(entry, { error: new Error("download ended before Begin") });
497
+ }
498
+ else if (entry.received !== info.length) {
499
+ this.finish(entry, {
500
+ error: new Error(`download ended after ${entry.received} of ${info.length} bytes`),
501
+ });
335
502
  }
336
503
  else {
337
- pending.reject(new Error("download failed"));
504
+ try {
505
+ // An empty range sends no `Chunk`; progress still ends at `length`.
506
+ if (!entry.progressed)
507
+ entry.onProgress?.(0, info.length);
508
+ }
509
+ catch (error) {
510
+ this.finish(entry, { error: asError(error) });
511
+ return;
512
+ }
513
+ this.finish(entry, { result: info });
338
514
  }
339
515
  return;
516
+ }
340
517
  case "Error":
341
- this.pending.delete(frame.stream);
342
- pending.reject(new Error(frame.message));
518
+ this.release(frame.stream, entry);
519
+ this.finish(entry, { error: new Error(frame.message) });
343
520
  return;
344
521
  }
345
522
  }
@@ -78,7 +78,12 @@ export interface PiStudioAgentTimelineHandle {
78
78
  direction?: TimelineDirection;
79
79
  limit?: number;
80
80
  }): Promise<FetchAgentTimelineResponse>;
81
- /** Subscribe to live `agent_stream` events for this agent. Returns an unsubscribe fn. */
81
+ /**
82
+ * Subscribe to live `agent_stream` events for this agent. Returns an unsubscribe fn. On a
83
+ * connection opened with `agentStreams: "none"` the handler never fires (against a daemon that
84
+ * honours it); against one without `agentStreamsMode` the option is ignored and streams arrive,
85
+ * so a handler must tolerate them.
86
+ */
82
87
  subscribe(handler: PiStudioAgentStreamHandler): () => void;
83
88
  }
84
89
  export interface PiStudioAgentActions {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@av-pi-studio/client",
3
- "version": "0.0.118",
3
+ "version": "0.0.119",
4
4
  "files": [
5
5
  "dist",
6
6
  "!dist/**/*.map",
@@ -20,8 +20,8 @@
20
20
  "clean": "rm -rf dist *.tsbuildinfo"
21
21
  },
22
22
  "dependencies": {
23
- "@av-pi-studio/protocol": "^0.0.118",
24
- "@av-pi-studio/relay": "^0.0.118"
23
+ "@av-pi-studio/protocol": "^0.0.119",
24
+ "@av-pi-studio/relay": "^0.0.119"
25
25
  },
26
26
  "repository": {
27
27
  "type": "git",