@poodle64/librarian 2026.9.21 → 2026.9.23

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 (28) hide show
  1. package/README.md +131 -8
  2. package/dist/chat.svelte.d.ts +189 -0
  3. package/dist/chat.svelte.js +554 -0
  4. package/dist/client.d.ts +29 -6
  5. package/dist/client.js +11 -3
  6. package/dist/components/agent-transcript/agent-transcript.svelte +40 -6
  7. package/dist/components/agent-transcript/agent-transcript.svelte.d.ts +9 -0
  8. package/dist/components/answer-mark/answer-mark.svelte +223 -0
  9. package/dist/components/answer-mark/answer-mark.svelte.d.ts +11 -0
  10. package/dist/components/answer-mark/index.d.ts +2 -0
  11. package/dist/components/answer-mark/index.js +2 -0
  12. package/dist/components/conversation/conversation.svelte +21 -3
  13. package/dist/components/conversation/conversation.svelte.d.ts +9 -0
  14. package/dist/components/conversation-list/conversation-list.svelte +496 -0
  15. package/dist/components/conversation-list/conversation-list.svelte.d.ts +26 -0
  16. package/dist/components/conversation-list/index.d.ts +2 -0
  17. package/dist/components/conversation-list/index.js +2 -0
  18. package/dist/components/fair-use-notice/fair-use-notice.svelte +97 -0
  19. package/dist/components/fair-use-notice/fair-use-notice.svelte.d.ts +11 -0
  20. package/dist/components/fair-use-notice/index.d.ts +2 -0
  21. package/dist/components/fair-use-notice/index.js +2 -0
  22. package/dist/components/working/working.svelte +8 -3
  23. package/dist/components/working/working.svelte.d.ts +3 -0
  24. package/dist/copy.d.ts +40 -0
  25. package/dist/copy.js +36 -1
  26. package/dist/session.svelte.js +1 -1
  27. package/dist/transcript.svelte.js +1 -1
  28. package/package.json +8 -3
@@ -0,0 +1,554 @@
1
+ /**
2
+ * The page's controller: one conversation with one agent, over a room's routes
3
+ * or a job's watch, message and stop.
4
+ *
5
+ * A room is asked. Each question streams its own turn, the agent names the
6
+ * conversation on its `init` event, and the conversation is read back whole
7
+ * when it is reopened. The turn belongs to the server, not the page: a
8
+ * refresh, a locked phone or a closed tab leaves it running, so a reopened
9
+ * conversation whose answer is still being written shows that, with its
10
+ * clock, and is read again once it settles. Stop is a request to the server
11
+ * for the same reason, never just a closed connection.
12
+ *
13
+ * A job is watched. Somebody else started it; the reader follows it from its
14
+ * first event, says things to it while it works, and stops it. Its stream is
15
+ * folded by `Session`, and a stream that closes is opened again when the
16
+ * reader sends a message, or while the host says the job may carry on.
17
+ *
18
+ * Nothing here knows a URL. The transport is the host's own client over the
19
+ * routes, and its shapes are the routes' own bodies, so a stamped app passes
20
+ * what its client returns straight through.
21
+ */
22
+ import { untrack } from 'svelte';
23
+ import { isQueued } from './client';
24
+ import { Session } from './session.svelte';
25
+ import { fold, foldState } from './transcript.svelte';
26
+ /** The events of a run: the agent has the question. */
27
+ const RUN = new Set(['stream_event', 'assistant', 'user', 'result']);
28
+ let minted = 0;
29
+ export class Chat {
30
+ /** The words in the box and the files with them: bind the composer here,
31
+ * so a question the server refuses comes back exactly as typed. */
32
+ draft = $state('');
33
+ files = $state([]);
34
+ /** When an answer this page is not streaming began, epoch ms: a
35
+ * conversation reopened mid-answer. */
36
+ answering = $state(null);
37
+ /** The agent is answering somebody else's question first. */
38
+ waiting = $state(false);
39
+ /** Today's allowance; null until read, and in a job. */
40
+ quota = $state(null);
41
+ /** The open conversation, or the job; null for a new one. The host keeps
42
+ * it in the address. */
43
+ conversationId = $state(null);
44
+ /** This person's conversations, as `list()` last read them; null until then. */
45
+ conversations = $state(null);
46
+ listFailed = $state(false);
47
+ #room = null;
48
+ #job = null;
49
+ #pollMs;
50
+ #rewatchMs;
51
+ #turns = $state([]);
52
+ #version = $state(0);
53
+ #running = $state(false);
54
+ #session = $state(null);
55
+ #watching = $state(false);
56
+ /** Which conversation the page shows, advanced whenever it leaves one: a
57
+ * stream still arriving for the one it left must not write into this one. */
58
+ #generation = 0;
59
+ #stream = null;
60
+ #poll;
61
+ #watch = null;
62
+ #woken = false;
63
+ #wake = null;
64
+ // Plain, as `Session`'s seen-set: nothing renders it.
65
+ // eslint-disable-next-line svelte/prefer-svelte-reactivity
66
+ #held = new Set();
67
+ constructor(transport, options = {}) {
68
+ if ('watch' in transport)
69
+ this.#job = transport;
70
+ else
71
+ this.#room = transport;
72
+ this.#pollMs = options.pollMs ?? 5000;
73
+ this.#rewatchMs = options.rewatchMs ?? 2000;
74
+ }
75
+ /** Every turn, in order. A live one grows in place; key on `version`. */
76
+ get turns() {
77
+ return this.#session?.turns ?? this.#turns;
78
+ }
79
+ /** Bumped on every event folded, for `Conversation`'s follow-scroll. */
80
+ get version() {
81
+ return this.#version + (this.#session?.version ?? 0);
82
+ }
83
+ /** This page is streaming an answer. */
84
+ get running() {
85
+ if (this.#job)
86
+ return this.#watching && (this.#session?.working ?? false);
87
+ return this.#running;
88
+ }
89
+ /** Something is being answered, here or out of sight: the composer waits
90
+ * and offers Stop. */
91
+ get busy() {
92
+ return this.running || this.answering !== null;
93
+ }
94
+ /** A job reads what the reader says while it works; a room waits for its answer. */
95
+ get sendWhileRunning() {
96
+ return this.#job !== null;
97
+ }
98
+ /** Reopen a conversation, or start watching a job. False when it could not
99
+ * be read, which leaves the page on a new conversation.
100
+ *
101
+ * Safe to call from an effect that follows the page's address: nothing
102
+ * it reads of its own becomes that effect's to follow. */
103
+ open(id) {
104
+ return untrack(() => this.#open(id));
105
+ }
106
+ async #open(id) {
107
+ this.#leave();
108
+ this.conversationId = id;
109
+ if (this.#job) {
110
+ this.#follow(id);
111
+ return true;
112
+ }
113
+ this.#turns = [];
114
+ this.#held.clear();
115
+ void this.refreshQuota();
116
+ const mine = this.#generation;
117
+ try {
118
+ const read = await this.#room.read(id);
119
+ if (mine === this.#generation)
120
+ this.#show(read, mine);
121
+ return mine === this.#generation;
122
+ }
123
+ catch {
124
+ if (mine === this.#generation)
125
+ this.conversationId = null;
126
+ return false;
127
+ }
128
+ }
129
+ /** Back to a new conversation. A turn still being answered goes on without
130
+ * the page, and is there when its conversation is reopened. Safe to call
131
+ * from an effect, as `open` is. */
132
+ new() {
133
+ untrack(() => {
134
+ this.#leave();
135
+ this.conversationId = null;
136
+ this.#turns = [];
137
+ this.#held.clear();
138
+ this.#session = null;
139
+ this.files = [];
140
+ this.#version += 1;
141
+ void this.refreshQuota();
142
+ });
143
+ }
144
+ /**
145
+ * Ask `question`, or what is in the box. Resolves once the turn settles:
146
+ * true if it was asked, false if it was refused, here or by the server, in
147
+ * which case typed words go back into the box.
148
+ */
149
+ async ask(question) {
150
+ const typed = question === undefined;
151
+ const text = (question ?? this.draft).trim();
152
+ if (!text)
153
+ return false;
154
+ if (this.#job)
155
+ return this.#message(text, typed);
156
+ if (this.busy || this.quota?.reached)
157
+ return false;
158
+ const files = typed ? this.files : [];
159
+ if (typed) {
160
+ this.draft = '';
161
+ this.files = [];
162
+ }
163
+ return this.#ask(text, files, typed);
164
+ }
165
+ /** The last question again, as a new turn: the conversation is append-only,
166
+ * because the agent's transcript is. */
167
+ again() {
168
+ const last = this.turns.at(-1)?.question;
169
+ return last ? this.ask(last) : Promise.resolve(false);
170
+ }
171
+ /** Stop the answer being written, wherever it is being written. */
172
+ async stop() {
173
+ const id = this.conversationId;
174
+ if (this.#job) {
175
+ if (id)
176
+ await this.#job.stop(id).catch(() => undefined);
177
+ return;
178
+ }
179
+ const stream = this.#stream;
180
+ if (stream && this.#running) {
181
+ this.#running = false;
182
+ this.waiting = false;
183
+ // Before the agent has named a new conversation there is nothing to
184
+ // address a stop to: the stream is read on, unseen, until it does.
185
+ if (!id) {
186
+ stream.stopWanted = true;
187
+ return;
188
+ }
189
+ stream.controller.abort();
190
+ await this.#room.stop(id).catch(() => undefined);
191
+ return;
192
+ }
193
+ if (this.answering !== null && id) {
194
+ await this.#room.stop(id).catch(() => undefined);
195
+ clearTimeout(this.#poll);
196
+ await this.#settle(id, this.#generation);
197
+ }
198
+ }
199
+ /** Read today's allowance again. One that cannot be read is left as it was. */
200
+ async refreshQuota() {
201
+ if (!this.#room)
202
+ return;
203
+ try {
204
+ this.quota = await this.#room.quota();
205
+ }
206
+ catch {
207
+ // A counter that cannot be read costs the counter, never the ask.
208
+ }
209
+ }
210
+ /** Read this person's conversations. Read again after every answer once
211
+ * read at all, so the list follows what they ask. */
212
+ async list() {
213
+ if (!this.#room)
214
+ return false;
215
+ try {
216
+ this.conversations = await this.#room.list();
217
+ this.listFailed = false;
218
+ return true;
219
+ }
220
+ catch {
221
+ this.listFailed = true;
222
+ return false;
223
+ }
224
+ }
225
+ async rename(id, title) {
226
+ const name = title.trim();
227
+ if (!name || !this.#room)
228
+ return false;
229
+ try {
230
+ await this.#room.rename(id, name);
231
+ }
232
+ catch {
233
+ return false;
234
+ }
235
+ const row = this.conversations?.find((c) => c.id === id);
236
+ if (row)
237
+ row.title = name;
238
+ return true;
239
+ }
240
+ /** Forget a conversation everywhere. The open one, forgotten, leaves a new one. */
241
+ async remove(id) {
242
+ if (!this.#room)
243
+ return false;
244
+ try {
245
+ await this.#room.remove(id);
246
+ }
247
+ catch {
248
+ return false;
249
+ }
250
+ const at = this.conversations?.findIndex((c) => c.id === id) ?? -1;
251
+ if (at >= 0)
252
+ this.conversations?.splice(at, 1);
253
+ if (this.conversationId === id)
254
+ this.new();
255
+ return true;
256
+ }
257
+ /** Save a conversation as the file the server names. Fetched rather than
258
+ * navigated to, so one deleted a moment ago elsewhere is a false here,
259
+ * not the reader's page replaced by an error body. */
260
+ async download(id) {
261
+ if (!this.#room)
262
+ return false;
263
+ try {
264
+ const { name, body } = await this.#room.download(id);
265
+ save(body, name);
266
+ return true;
267
+ }
268
+ catch {
269
+ return false;
270
+ }
271
+ }
272
+ /** Mark an answer the agent holds. False when it could not be recorded. */
273
+ async mark(turn, verdict) {
274
+ const id = this.conversationId;
275
+ if (!id || !this.#room?.mark || !this.#held.has(turn.id))
276
+ return false;
277
+ const position = this.turns.filter((t) => this.#held.has(t.id)).findIndex((t) => t.id === turn.id);
278
+ try {
279
+ await this.#room.mark(id, position, verdict);
280
+ return true;
281
+ }
282
+ catch {
283
+ return false;
284
+ }
285
+ }
286
+ #leave() {
287
+ this.#generation += 1;
288
+ // A stop still waiting to be addressed is carried on the stream it
289
+ // waits on, so leaving does not lose it.
290
+ if (this.#stream && !this.#stream.stopWanted)
291
+ this.#stream.controller.abort();
292
+ this.#stream = null;
293
+ clearTimeout(this.#poll);
294
+ this.#watch?.abort();
295
+ this.#watch = null;
296
+ this.#watching = false;
297
+ this.#running = false;
298
+ this.answering = null;
299
+ this.waiting = false;
300
+ }
301
+ async #ask(text, files, typed) {
302
+ const room = this.#room;
303
+ const mine = this.#generation;
304
+ const stream = { controller: new AbortController(), stopWanted: false };
305
+ this.#stream = stream;
306
+ this.#turns.push({
307
+ id: `asked-${(minted += 1)}`,
308
+ question: text,
309
+ at: Date.now(),
310
+ blocks: [],
311
+ outcome: null,
312
+ citations: [],
313
+ suggestions: []
314
+ });
315
+ const live = this.#turns[this.#turns.length - 1];
316
+ const state = foldState();
317
+ this.#running = true;
318
+ this.waiting = false;
319
+ this.#version += 1;
320
+ const current = () => mine === this.#generation && this.#stream === stream;
321
+ let ending = null;
322
+ for await (const event of room.ask({
323
+ question: text,
324
+ files,
325
+ resume: this.conversationId,
326
+ signal: stream.controller.signal
327
+ })) {
328
+ const named = event.type === 'system' && event.subtype === 'init' ? event.session_id : undefined;
329
+ if (stream.stopWanted) {
330
+ if (!named)
331
+ continue;
332
+ // The agent took the question as it named the conversation.
333
+ if (current()) {
334
+ this.conversationId = named;
335
+ this.#held.add(live.id);
336
+ }
337
+ stream.controller.abort();
338
+ await room.stop(named).catch(() => undefined);
339
+ break;
340
+ }
341
+ if (!current())
342
+ break;
343
+ if (isQueued(event)) {
344
+ this.waiting = true;
345
+ this.#version += 1;
346
+ continue;
347
+ }
348
+ // Waiting ends when the agent starts, and no other frame says so.
349
+ if (named || RUN.has(event.type))
350
+ this.waiting = false;
351
+ if (named) {
352
+ this.conversationId = named;
353
+ this.#held.add(live.id);
354
+ continue;
355
+ }
356
+ if (event.type === 'library_error' && event.status === 429)
357
+ ending = 'refused';
358
+ else if (event.type === 'library_error' && event.status === 409)
359
+ ending = 'answering';
360
+ else if (event.type === 'library_error' && event.dropped && this.conversationId)
361
+ ending = 'dropped';
362
+ if (ending)
363
+ break;
364
+ if (RUN.has(event.type))
365
+ this.#held.add(live.id);
366
+ fold(live, state, event);
367
+ this.#version += 1;
368
+ }
369
+ // Left for another conversation, or stopped and asked again: that one
370
+ // owns the page now.
371
+ if (!current())
372
+ return true;
373
+ const id = this.conversationId;
374
+ if (ending === 'refused' || ending === 'answering') {
375
+ const at = this.#turns.findIndex((t) => t.id === live.id);
376
+ if (at >= 0)
377
+ this.#turns.splice(at, 1);
378
+ if (typed) {
379
+ this.draft = text;
380
+ this.files = files;
381
+ }
382
+ this.#finish();
383
+ // Asked elsewhere and still being answered: show that instead.
384
+ if (ending === 'answering' && id)
385
+ await this.#settle(id, mine);
386
+ return false;
387
+ }
388
+ if (ending === 'dropped' && id) {
389
+ // The page lost the stream, not the turn: read where it has got to.
390
+ try {
391
+ const read = await room.read(id);
392
+ if (current())
393
+ this.#show(read, mine);
394
+ }
395
+ catch {
396
+ if (current())
397
+ fold(live, state, { type: 'library_error' });
398
+ }
399
+ }
400
+ this.#finish();
401
+ return true;
402
+ }
403
+ /** A turn this page streamed has settled, one way or another. */
404
+ #finish() {
405
+ this.#stream = null;
406
+ this.#running = false;
407
+ this.waiting = false;
408
+ this.#version += 1;
409
+ void this.refreshQuota();
410
+ if (this.conversations !== null)
411
+ void this.list();
412
+ }
413
+ /** A conversation as read. One still being answered shows the question in
414
+ * flight with nothing of its answer, which is not written yet, and is
415
+ * read again until it settles. */
416
+ #show(read, mine) {
417
+ clearTimeout(this.#poll);
418
+ const turns = read.turns.map((stored, index) => storedTurn(read.id, index, stored));
419
+ this.#held = new Set(turns.map((t) => t.id));
420
+ const since = epoch(read.answering_since);
421
+ if (since !== null) {
422
+ const last = read.turns.at(-1);
423
+ const asked = epoch(last?.at);
424
+ // The agent writes the question into its transcript as it takes it,
425
+ // so a question asked since the stamp is the one in flight. One still
426
+ // waiting for the agent is not there yet.
427
+ const inFlight = last && (asked !== null ? asked >= since : !last.answer.trim());
428
+ if (inFlight)
429
+ Object.assign(turns[turns.length - 1], { blocks: [], citations: [] });
430
+ else
431
+ turns.push({ id: `${read.id}:${turns.length}`, question: '', blocks: [], outcome: null });
432
+ this.#poll = setTimeout(() => void this.#settle(read.id, mine), this.#pollMs);
433
+ }
434
+ this.answering = since;
435
+ this.#turns = turns;
436
+ this.#version += 1;
437
+ }
438
+ /** Read a conversation answering out of sight again; once it has settled,
439
+ * the allowance and the list have moved too. */
440
+ async #settle(id, mine) {
441
+ clearTimeout(this.#poll);
442
+ try {
443
+ const read = await this.#room.read(id);
444
+ if (mine !== this.#generation)
445
+ return;
446
+ this.#show(read, mine);
447
+ if (this.answering === null) {
448
+ void this.refreshQuota();
449
+ if (this.conversations !== null)
450
+ void this.list();
451
+ }
452
+ }
453
+ catch {
454
+ if (mine === this.#generation) {
455
+ this.#poll = setTimeout(() => void this.#settle(id, mine), this.#pollMs);
456
+ }
457
+ }
458
+ }
459
+ #follow(id) {
460
+ const controller = new AbortController();
461
+ this.#watch = controller;
462
+ this.#session = new Session();
463
+ void this.#watchJob(id, this.#session, controller.signal);
464
+ }
465
+ /** Follow a job's stream, and follow it again whenever it may have more:
466
+ * a replay folds only what is new. */
467
+ async #watchJob(id, session, signal) {
468
+ const job = this.#job;
469
+ while (!signal.aborted) {
470
+ this.#woken = false;
471
+ this.#watching = true;
472
+ try {
473
+ for await (const event of job.watch(id, signal)) {
474
+ if (isQueued(event)) {
475
+ this.waiting = true;
476
+ continue;
477
+ }
478
+ if (RUN.has(event.type))
479
+ this.waiting = false;
480
+ session.apply(event);
481
+ }
482
+ }
483
+ catch {
484
+ // A transport that threw has closed its stream, as one that ended.
485
+ }
486
+ if (signal.aborted)
487
+ return;
488
+ this.#watching = false;
489
+ this.waiting = false;
490
+ await this.#idle(signal);
491
+ }
492
+ }
493
+ /** Until the reader says something, or the job says it carries on. */
494
+ async #idle(signal) {
495
+ const job = this.#job;
496
+ while (!signal.aborted && !this.#woken) {
497
+ await new Promise((resolve) => {
498
+ const done = () => {
499
+ clearTimeout(timer);
500
+ signal.removeEventListener('abort', done);
501
+ this.#wake = null;
502
+ resolve();
503
+ };
504
+ const timer = job.carriesOn ? setTimeout(done, this.#rewatchMs) : undefined;
505
+ this.#wake = done;
506
+ signal.addEventListener('abort', done, { once: true });
507
+ });
508
+ if (job.carriesOn?.())
509
+ return;
510
+ }
511
+ }
512
+ async #message(text, typed) {
513
+ const id = this.conversationId;
514
+ if (!id)
515
+ return false;
516
+ if (typed)
517
+ this.draft = '';
518
+ try {
519
+ await this.#job.message(id, text);
520
+ }
521
+ catch {
522
+ if (typed)
523
+ this.draft = text;
524
+ return false;
525
+ }
526
+ // A settled job takes it as a new run, already started when the
527
+ // message is accepted: its stream is watched again now.
528
+ this.#woken = true;
529
+ this.#wake?.();
530
+ return true;
531
+ }
532
+ }
533
+ function storedTurn(conversation, index, stored) {
534
+ return {
535
+ id: `${conversation}:${index}`,
536
+ question: stored.question,
537
+ at: epoch(stored.at) ?? undefined,
538
+ blocks: stored.answer ? [{ kind: 'text', index: 0, text: stored.answer }] : [],
539
+ outcome: null,
540
+ citations: stored.citations ?? []
541
+ };
542
+ }
543
+ function epoch(value) {
544
+ const at = value ? Date.parse(value) : NaN;
545
+ return Number.isNaN(at) ? null : at;
546
+ }
547
+ function save(body, name) {
548
+ const url = URL.createObjectURL(body);
549
+ const link = document.createElement('a');
550
+ link.href = url;
551
+ link.download = name;
552
+ link.click();
553
+ URL.revokeObjectURL(url);
554
+ }
package/dist/client.d.ts CHANGED
@@ -8,7 +8,14 @@
8
8
  * event type needs no change on either side.
9
9
  */
10
10
  import type { Citation } from './citations';
11
- /** One Claude Code stream event. Typed only where we branch on it. */
11
+ /** A Claude Code message, whole. `content` is a plain string only on a user frame. */
12
+ export interface AgentMessage {
13
+ id?: string;
14
+ content?: Array<Record<string, unknown>> | string;
15
+ }
16
+ /** One Claude Code stream event, or one of the library's own frames. Typed
17
+ * only where we branch on it, and `type` stays open: a new event type needs
18
+ * no change here. */
12
19
  export interface AgentEvent {
13
20
  type: string;
14
21
  subtype?: string;
@@ -27,11 +34,8 @@ export interface AgentEvent {
27
34
  id?: string;
28
35
  };
29
36
  };
30
- /** A whole message; `content` is a plain string only on a user frame. */
31
- message?: {
32
- id?: string;
33
- content?: Array<Record<string, unknown>> | string;
34
- };
37
+ /** A whole message; on the library's `queued` frame, its own sentence. */
38
+ message?: AgentMessage | string;
35
39
  /** A user frame the CLI echoed back as the turn that consumes it starts
36
40
  * (`--replay-user-messages`): the reader's side of a session. */
37
41
  isReplay?: boolean;
@@ -47,6 +51,12 @@ export interface AgentEvent {
47
51
  /** What a run returned under `--json-schema`, on its `result`. */
48
52
  structured_output?: unknown;
49
53
  error?: string;
54
+ /** On a `library_error` this layer raised: the HTTP status of a route that
55
+ * answered and would not stream. 429 is a spent allowance. */
56
+ status?: number;
57
+ /** On a `library_error` this layer raised: a stream that opened and died
58
+ * before its terminal frame. The turn may well go on without the page. */
59
+ dropped?: boolean;
50
60
  detail?: string;
51
61
  tools?: string[];
52
62
  model?: string;
@@ -59,6 +69,19 @@ export interface AgentEvent {
59
69
  items?: Citation[] | string[];
60
70
  [key: string]: unknown;
61
71
  }
72
+ /**
73
+ * The library's word that an ask waits behind somebody else's: repeated at
74
+ * least every 30 s until the agent starts, and what keeps an idle read
75
+ * timeout from firing meanwhile. `message` is the library's own sentence
76
+ * ("another question is being answered; waiting for it to finish"); a page
77
+ * says it in its persona's words (`copy.waiting`).
78
+ */
79
+ export interface QueuedEvent extends AgentEvent {
80
+ type: 'queued';
81
+ message: string;
82
+ }
83
+ /** The frame is the library's `queued`, by its type alone. */
84
+ export declare function isQueued(event: AgentEvent): event is QueuedEvent;
62
85
  export interface AskOptions {
63
86
  question: string;
64
87
  resume?: string | null;
package/dist/client.js CHANGED
@@ -7,6 +7,10 @@
7
7
  * backend sent, so the Console renders the real thing and a new Claude Code
8
8
  * event type needs no change on either side.
9
9
  */
10
+ /** The frame is the library's `queued`, by its type alone. */
11
+ export function isQueued(event) {
12
+ return event.type === 'queued';
13
+ }
10
14
  /**
11
15
  * The request body for one ask.
12
16
  *
@@ -107,7 +111,11 @@ async function* read(open, signal, terminal) {
107
111
  // layer breaking a working consumer over a header — the terminal-frame
108
112
  // check at the end of this function catches it if it really says nothing.
109
113
  const kind = (response.headers.get('content-type') ?? '').toLowerCase();
110
- if (!response.ok || !response.body || (kind && !kind.includes('text/event-stream'))) {
114
+ if (!response.ok) {
115
+ yield { type: 'library_error', status: response.status };
116
+ return;
117
+ }
118
+ if (!response.body || (kind && !kind.includes('text/event-stream'))) {
111
119
  yield { type: 'library_error' };
112
120
  return;
113
121
  }
@@ -146,7 +154,7 @@ async function* read(open, signal, terminal) {
146
154
  catch {
147
155
  if (signal?.aborted)
148
156
  return;
149
- yield { type: 'library_error' };
157
+ yield { type: 'library_error', dropped: true };
150
158
  return;
151
159
  }
152
160
  if (chunk.done)
@@ -176,5 +184,5 @@ async function* read(open, signal, terminal) {
176
184
  yield last;
177
185
  }
178
186
  if (terminal && !ended && !signal?.aborted)
179
- yield { type: 'library_error' };
187
+ yield { type: 'library_error', dropped: true };
180
188
  }