agenthooksprotocol 0.1.0 → 0.2.0

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 (66) hide show
  1. package/README.md +41 -0
  2. package/dist/src/client/composition.d.ts +2 -0
  3. package/dist/src/client/composition.d.ts.map +1 -1
  4. package/dist/src/client/composition.js +14 -6
  5. package/dist/src/client/composition.js.map +1 -1
  6. package/dist/src/client/content.d.ts +58 -20
  7. package/dist/src/client/content.d.ts.map +1 -1
  8. package/dist/src/client/content.js +358 -105
  9. package/dist/src/client/content.js.map +1 -1
  10. package/dist/src/client/hooks.d.ts +4 -0
  11. package/dist/src/client/hooks.d.ts.map +1 -1
  12. package/dist/src/client/hooks.js +162 -54
  13. package/dist/src/client/hooks.js.map +1 -1
  14. package/dist/src/client/index.d.ts +1 -0
  15. package/dist/src/client/index.d.ts.map +1 -1
  16. package/dist/src/client/index.js +1 -0
  17. package/dist/src/client/index.js.map +1 -1
  18. package/dist/src/client/types.d.ts +6 -3
  19. package/dist/src/client/types.d.ts.map +1 -1
  20. package/dist/src/client/types.js.map +1 -1
  21. package/dist/src/content-upload.d.ts +9 -10
  22. package/dist/src/content-upload.d.ts.map +1 -1
  23. package/dist/src/content-upload.js +8 -4
  24. package/dist/src/content-upload.js.map +1 -1
  25. package/dist/src/draft/generated.d.ts +73 -47
  26. package/dist/src/draft/generated.d.ts.map +1 -1
  27. package/dist/src/draft/generated.js +502 -251
  28. package/dist/src/draft/generated.js.map +1 -1
  29. package/dist/src/draft/index.d.ts +4 -1
  30. package/dist/src/draft/index.d.ts.map +1 -1
  31. package/dist/src/draft/index.js +2 -0
  32. package/dist/src/draft/index.js.map +1 -1
  33. package/dist/src/draft/schemas.d.ts +112 -40
  34. package/dist/src/draft/schemas.d.ts.map +1 -1
  35. package/dist/src/draft/schemas.js +158 -120
  36. package/dist/src/draft/schemas.js.map +1 -1
  37. package/dist/src/draft-runtime.d.ts +5 -5
  38. package/dist/src/draft-runtime.d.ts.map +1 -1
  39. package/dist/src/draft-runtime.js +3 -5
  40. package/dist/src/draft-runtime.js.map +1 -1
  41. package/dist/src/server/attachments.d.ts +2 -2
  42. package/dist/src/server/attachments.d.ts.map +1 -1
  43. package/dist/src/server/attachments.js +3 -3
  44. package/dist/src/server/attachments.js.map +1 -1
  45. package/dist/src/server/hooks.d.ts.map +1 -1
  46. package/dist/src/server/hooks.js +4 -2
  47. package/dist/src/server/hooks.js.map +1 -1
  48. package/dist/src/server/index.d.ts +3 -1
  49. package/dist/src/server/index.d.ts.map +1 -1
  50. package/dist/src/server/index.js +1 -0
  51. package/dist/src/server/index.js.map +1 -1
  52. package/package.json +1 -1
  53. package/src/client/composition.ts +15 -6
  54. package/src/client/content.ts +350 -113
  55. package/src/client/hooks.ts +188 -60
  56. package/src/client/index.ts +2 -0
  57. package/src/client/types.ts +6 -3
  58. package/src/content-upload.ts +14 -13
  59. package/src/draft/ahp-codegen.lock.json +14 -6
  60. package/src/draft/generated.ts +733 -427
  61. package/src/draft/index.ts +6 -1
  62. package/src/draft/schemas.ts +160 -120
  63. package/src/draft-runtime.ts +7 -9
  64. package/src/server/attachments.ts +5 -5
  65. package/src/server/hooks.ts +3 -2
  66. package/src/server/index.ts +5 -0
@@ -1,27 +1,285 @@
1
1
  import {
2
- parseContentReference,
2
+ parseContentUploadReceipt,
3
3
  type ContentSelection,
4
4
  type ContentUpload,
5
5
  } from "../draft/generated.js";
6
6
 
7
- /** An owned native content stream, distinct from a receiver content reference.
8
- * Construction does not read, lock, or upload the stream. Passing this source to
9
- * an awaited SDK operation transfers cleanup responsibility to that operation;
10
- * callers must not consume or reuse the underlying stream afterward. Native
11
- * streams can prefetch independently; use highWaterMark: 0 to disable that.
12
- */
7
+ /** Compatibility adapter for a native lazy stream. The stream is consumed by
8
+ * its attachment owner, not staged in an invocation store. */
13
9
  export class ContentSource {
14
10
  readonly #stream: ReadableStream<Uint8Array>;
15
-
16
11
  constructor(stream: ReadableStream<Uint8Array>) {
17
12
  if (!(stream instanceof ReadableStream))
18
13
  throw new Error("Content source requires a native ReadableStream");
19
14
  this.#stream = stream;
20
15
  }
16
+ get stream(): ReadableStream<Uint8Array> { return this.#stream; }
17
+ }
18
+
19
+ type ContentMetadata = { size?: number | undefined; sha256?: string | undefined };
20
+ type Snapshot = { bytes: Uint8Array; size: number; sha256: string };
21
+ type Budget = { used: number; limit: number };
22
+ type Loader = (signal: AbortSignal, reserve: (bytes: number) => void) => Promise<Uint8Array>;
23
+ // This is only an identity index for legacy native streams, never a byte store.
24
+ const streamOwners = new WeakMap<ReadableStream<Uint8Array>, Attachment>();
25
+ const retainedOwners = new WeakSet<Attachment>();
26
+ let ownerAccess: {
27
+ claim(owner: Attachment, invocation: object, budget: Budget): void;
28
+ snapshot(owner: Attachment, signal?: AbortSignal): Promise<Snapshot>;
29
+ peek(owner: Attachment): Promise<Snapshot> | undefined;
30
+ retain(owner: Attachment): () => Promise<void>;
31
+ adapt(stream: ReadableStream<Uint8Array>): Attachment;
32
+ };
33
+
34
+ /** Sole backing owner for immutable bytes or an unread lazy source. Metadata
35
+ * stays on content items. A handle may be shared within, but not across, calls. */
36
+ export class Attachment extends ContentSource {
37
+ #load: Loader | undefined;
38
+ #snapshot: Promise<Snapshot> | undefined;
39
+ #lifetime = new AbortController();
40
+ #invocation: object | undefined;
41
+ #budget: Budget = { used: 0, limit: 64 * 1024 * 1024 };
42
+ #reserved = 0;
43
+ #dispose: () => void | Promise<void>;
44
+ #disposal: Promise<void> | undefined;
45
+ #closed: Promise<void> | undefined;
46
+ #primaryRelease: Promise<void> | undefined;
47
+ #leases = 1;
48
+
49
+ private constructor(load: Loader, dispose: () => void | Promise<void>, retained = true) {
50
+ let owner: Attachment;
51
+ super(new ReadableStream<Uint8Array>({
52
+ async pull(controller) {
53
+ try {
54
+ controller.enqueue(await owner.read());
55
+ controller.close();
56
+ } catch (error) { controller.error(error); }
57
+ },
58
+ cancel() { return owner.close(); },
59
+ }, { highWaterMark: 0 }));
60
+ owner = this;
61
+ this.#load = load;
62
+ this.#dispose = dispose;
63
+ streamOwners.set(this.stream, this);
64
+ if (retained) retainedOwners.add(this);
65
+ }
66
+
67
+ static {
68
+ ownerAccess = {
69
+ claim(owner, invocation, budget) {
70
+ if (owner.#invocation && owner.#invocation !== invocation)
71
+ throw new Error("Attachment already belongs to another invocation");
72
+ if (owner.#closed) throw new Error("Attachment closed");
73
+ if (!owner.#invocation) {
74
+ if (owner.#snapshot !== undefined)
75
+ throw new Error("Read attachments cannot be transferred to an invocation");
76
+ owner.#invocation = invocation;
77
+ owner.#budget = budget;
78
+ }
79
+ },
80
+ snapshot: (owner, signal) => owner.#materialize(signal),
81
+ peek: (owner) => owner.#snapshot,
82
+ adapt: (stream) => Attachment.#fromStream(stream),
83
+ retain(owner) {
84
+ owner.#lifetime.signal.throwIfAborted();
85
+ owner.#leases++;
86
+ let released: Promise<void> | undefined;
87
+ return () => released ??= owner.#release();
88
+ },
89
+ };
90
+ }
91
+
92
+ /** One defensive input copy, retained directly by this owner. */
93
+ static bytes(bytes: Uint8Array): Attachment {
94
+ let initial: Uint8Array | undefined = new Uint8Array(bytes);
95
+ return new Attachment(async (_signal, reserve) => {
96
+ const bytes = initial!;
97
+ reserve(bytes.byteLength);
98
+ initial = undefined;
99
+ return bytes;
100
+ }, () => { initial = undefined; });
101
+ }
21
102
 
22
- /** Native stream identity is preserved for snapshot sharing and cleanup. */
23
- get stream(): ReadableStream<Uint8Array> {
24
- return this.#stream;
103
+ /** Evaluated once, on demand. The returned mutable buffer is copied once. */
104
+ static lazy(
105
+ open: (signal: AbortSignal) => Uint8Array | Promise<Uint8Array>,
106
+ dispose: () => void | Promise<void> = () => {},
107
+ ): Attachment {
108
+ return new Attachment(async (signal, reserve) => {
109
+ const bytes = await open(signal);
110
+ signal.throwIfAborted();
111
+ if (!(bytes instanceof Uint8Array))
112
+ throw new TypeError("Attachment source must return Uint8Array");
113
+ reserve(bytes.byteLength);
114
+ return new Uint8Array(bytes);
115
+ }, dispose);
116
+ }
117
+
118
+ /** Adapt a native source without a second snapshot cache. */
119
+ static fromStream(stream: ReadableStream<Uint8Array>): Attachment {
120
+ if (!(stream instanceof ReadableStream))
121
+ throw new TypeError("Attachment source requires a native ReadableStream");
122
+ const owner = Attachment.#fromStream(stream);
123
+ retainedOwners.add(owner);
124
+ return owner;
125
+ }
126
+
127
+ static #fromStream(stream: ReadableStream<Uint8Array>): Attachment {
128
+ const existing = streamOwners.get(stream);
129
+ if (existing) return existing;
130
+ let reader: ReadableStreamDefaultReader<Uint8Array> | undefined;
131
+ const owner = new Attachment(async (signal, reserve) => {
132
+ reader = stream.getReader();
133
+ const chunks: Uint8Array[] = [];
134
+ let size = 0;
135
+ const cancel = () => { void reader?.cancel(signal.reason).catch(() => {}); };
136
+ signal.addEventListener("abort", cancel, { once: true });
137
+ try {
138
+ for (;;) {
139
+ const next = await abortable(reader.read(), signal);
140
+ signal.throwIfAborted();
141
+ if (next.done) break;
142
+ if (!(next.value instanceof Uint8Array))
143
+ throw new Error("Content stream must yield Uint8Array chunks");
144
+ reserve(next.value.byteLength);
145
+ chunks.push(new Uint8Array(next.value));
146
+ size += next.value.byteLength;
147
+ }
148
+ const bytes = new Uint8Array(size);
149
+ let offset = 0;
150
+ for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.length; }
151
+ return bytes;
152
+ } finally {
153
+ signal.removeEventListener("abort", cancel);
154
+ reader.releaseLock();
155
+ reader = undefined;
156
+ }
157
+ }, () => {
158
+ // Request cancellation without awaiting producer cleanup, which may never settle.
159
+ if (reader) void reader.cancel().catch(() => {});
160
+ else if (!stream.locked) void stream.cancel().catch(() => {});
161
+ }, false);
162
+ streamOwners.set(stream, owner);
163
+ return owner;
164
+ }
165
+
166
+ #cleanup(): Promise<void> {
167
+ if (!this.#disposal) {
168
+ const dispose = this.#dispose;
169
+ this.#dispose = () => {};
170
+ this.#disposal = Promise.resolve().then(dispose);
171
+ }
172
+ return this.#disposal;
173
+ }
174
+
175
+ #materialize(signal?: AbortSignal): Promise<Snapshot> {
176
+ this.#lifetime.signal.throwIfAborted();
177
+ signal?.throwIfAborted();
178
+ if (!this.#snapshot) {
179
+ const load = this.#load!;
180
+ this.#load = undefined;
181
+ const abort = () => this.#lifetime.abort(signal?.reason);
182
+ signal?.addEventListener("abort", abort, { once: true });
183
+ this.#snapshot = (async () => {
184
+ try {
185
+ const bytes = await abortable(load(this.#lifetime.signal, (size) => {
186
+ this.#lifetime.signal.throwIfAborted();
187
+ if (this.#budget.used + size > this.#budget.limit)
188
+ throw new Error("Content snapshot memory limit exceeded");
189
+ this.#budget.used += size;
190
+ this.#reserved += size;
191
+ }), this.#lifetime.signal);
192
+ this.#lifetime.signal.throwIfAborted();
193
+ // All loaders allocate ordinary ArrayBuffers before returning bytes.
194
+ const digest = new Uint8Array(await crypto.subtle.digest("SHA-256", bytes as Uint8Array<ArrayBuffer>));
195
+ this.#lifetime.signal.throwIfAborted();
196
+ return { bytes, size: bytes.length,
197
+ sha256: Array.from(digest, byte => byte.toString(16).padStart(2, "0")).join("") };
198
+ } catch (error) {
199
+ this.#budget.used -= this.#reserved;
200
+ this.#reserved = 0;
201
+ throw error;
202
+ } finally {
203
+ signal?.removeEventListener("abort", abort);
204
+ await this.#cleanup();
205
+ }
206
+ })();
207
+ }
208
+ return signal ? abortable(this.#snapshot, signal) : this.#snapshot;
209
+ }
210
+
211
+ /** Defensive caller copy; uploads use the same private materialization. */
212
+ async read(signal?: AbortSignal, metadata: ContentMetadata = {}): Promise<Uint8Array> {
213
+ const snapshot = await this.#materialize(signal);
214
+ validateSnapshotMetadata(snapshot, metadata);
215
+ return snapshot.bytes.slice();
216
+ }
217
+
218
+ close(): Promise<void> {
219
+ return this.#primaryRelease ??= this.#release();
220
+ }
221
+
222
+ #release(): Promise<void> {
223
+ if (--this.#leases !== 0) return Promise.resolve();
224
+ if (this.#closed) return this.#closed;
225
+ this.#lifetime.abort(new Error("Attachment closed"));
226
+ this.#load = undefined;
227
+ this.#snapshot = undefined;
228
+ this.#budget.used -= this.#reserved;
229
+ this.#reserved = 0;
230
+ return this.#closed = this.#cleanup();
231
+ }
232
+ }
233
+
234
+ /** Effective slot index. Owners, not an invocation manager, outlive Hooks. */
235
+ export class AttachmentContent {
236
+ private readonly bodies = new Map<string, ContentMetadata & { owner: Attachment }>();
237
+ private readonly owners = new Set<Attachment>();
238
+ private closed: Promise<void> | undefined;
239
+ constructor(event: unknown) {
240
+ const seen = new WeakSet<object>();
241
+ const visit = (value: any, path: string): void => {
242
+ if (!value || typeof value !== "object" || seen.has(value)) return;
243
+ const stream = sourceStream(value);
244
+ if (stream) {
245
+ const owner = ownerAccess.adapt(stream);
246
+ this.bodies.set(path, { owner });
247
+ this.owners.add(owner);
248
+ return;
249
+ }
250
+ seen.add(value);
251
+ const body = sourceStream(value.body);
252
+ if (typeof value.id === "string" && body) {
253
+ const owner = ownerAccess.adapt(body);
254
+ const previous = this.bodies.get(value.id);
255
+ if (previous && (previous.owner !== owner || previous.size !== value.size || previous.sha256 !== value.sha256))
256
+ throw new Error("Ambiguous content item id");
257
+ this.bodies.set(value.id, { owner, size: value.size, sha256: value.sha256 });
258
+ this.owners.add(owner);
259
+ }
260
+ for (const [key, child] of Object.entries(value)) {
261
+ if (key === "body" && body && typeof value.id === "string") continue;
262
+ visit(child, path + "/" + key.replaceAll("~", "~0").replaceAll("/", "~1"));
263
+ }
264
+ };
265
+ visit(event, "");
266
+ }
267
+ get ids(): readonly string[] { return [...this.bodies.keys()]; }
268
+ /** @internal Transfer cleanup responsibility without copying backing bytes. */
269
+ detachFrom(pending: Set<Attachment>): void {
270
+ for (const owner of this.owners) pending.delete(owner);
271
+ }
272
+ async read(id: string, signal?: AbortSignal): Promise<Uint8Array> {
273
+ const body = this.bodies.get(id);
274
+ if (!body) throw new Error("Unknown local content item id");
275
+ return body.owner.read(signal, body);
276
+ }
277
+ close(): Promise<void> {
278
+ if (this.closed) return this.closed;
279
+ this.bodies.clear();
280
+ this.closed = Promise.all([...this.owners].map(owner => owner.close())).then(() => {});
281
+ this.owners.clear();
282
+ return this.closed;
25
283
  }
26
284
  }
27
285
 
@@ -60,28 +318,40 @@ export interface ContentManagerOptions {
60
318
  allowLoopback?: boolean;
61
319
  }
62
320
 
63
- type Snapshot = { bytes: Uint8Array; size: number; sha256: string };
321
+ function validateSnapshotMetadata(snapshot: Snapshot, metadata: ContentMetadata): void {
322
+ if (metadata.size !== undefined && metadata.size !== snapshot.size)
323
+ throw new Error("Content size mismatch");
324
+ if (metadata.sha256 !== undefined && metadata.sha256 !== snapshot.sha256)
325
+ throw new Error("Content SHA-256 mismatch");
326
+ }
64
327
 
65
- /** One manager per producer/session. Selection is NOT authorization: callers must
66
- * authorize/project opaque native/input/output data before prepare. References
67
- * are deliberately never cached: each prepare uploads in its receiver's scope.
328
+ /** Per-invocation delivery and cleanup coordinator, not a content store.
329
+ * Byte materialization lives only in Attachment. This class retains owner
330
+ * identities and non-owning aggregate budget counters, never byte buffers.
331
+ * Selection is not authorization; project opaque host data before prepare.
68
332
  */
69
333
  export class ContentManager {
70
- private snapshots = new WeakMap<
71
- ReadableStream<Uint8Array>,
72
- Promise<Snapshot>
73
- >();
74
- private readers = new Set<ReadableStreamDefaultReader<Uint8Array>>();
75
- private seenStreams = new Set<ReadableStream<Uint8Array>>();
334
+ private hasAttachments = false;
335
+ private readonly ownership = {};
336
+
337
+ /** @internal Build a slot index retaining the existing owners. */
338
+ resultContent(event: unknown): AttachmentContent | undefined {
339
+ return this.hasAttachments ? new AttachmentContent(event) : undefined;
340
+ }
341
+
342
+ private readonly attachments = new Set<Attachment>();
76
343
  private lifetime = new AbortController();
77
- private retainedBytes = 0;
78
344
  private closePromise: Promise<void> | undefined;
79
- private readonly limit: number;
345
+ private readonly budget: Budget;
80
346
  private readonly allowLoopback: boolean;
81
347
 
348
+ /** @internal Detach returned owners; dispose everything else in close(). */
349
+ transfer(content: AttachmentContent): void { content.detachFrom(this.attachments); }
350
+
82
351
  constructor(options: ContentManagerOptions = {}) {
83
- this.limit = options.maxSnapshotBytes ?? 64 * 1024 * 1024;
84
- if (!Number.isSafeInteger(this.limit) || this.limit < 0)
352
+ const limit = options.maxSnapshotBytes ?? 64 * 1024 * 1024;
353
+ this.budget = { used: 0, limit };
354
+ if (!Number.isSafeInteger(limit) || limit < 0)
85
355
  throw new Error("Invalid snapshot byte limit");
86
356
  this.allowLoopback = options.allowLoopback ?? false;
87
357
  }
@@ -93,18 +363,26 @@ export class ContentManager {
93
363
  own(event: unknown): void {
94
364
  this.lifetime.signal.throwIfAborted();
95
365
  const visited = new WeakSet<object>();
366
+ let conflict = false;
96
367
  const visit = (value: unknown): void => {
97
368
  if (value === null || typeof value !== "object" || visited.has(value))
98
369
  return;
99
370
  visited.add(value);
100
371
  const stream = sourceStream(value);
101
372
  if (stream) {
102
- this.seenStreams.add(stream);
373
+ const existing = streamOwners.get(stream);
374
+ const owner = ownerAccess.adapt(stream);
375
+ try { ownerAccess.claim(owner, this.ownership, this.budget); }
376
+ catch { conflict = true; return; }
377
+ // Explicit Attachment sources opt into retained result ownership.
378
+ if (existing && retainedOwners.has(existing)) this.hasAttachments = true;
379
+ this.attachments.add(owner);
103
380
  return;
104
381
  }
105
382
  for (const child of Object.values(value)) visit(child);
106
383
  };
107
384
  visit(event);
385
+ if (conflict) throw new Error("Attachment already belongs to another invocation");
108
386
  }
109
387
 
110
388
  /** Reads local producer bytes for composition, never remote references.
@@ -114,6 +392,7 @@ export class ContentManager {
114
392
  async readBody(
115
393
  body: ContentSource | ReadableStream<Uint8Array>,
116
394
  signal?: AbortSignal,
395
+ metadata: ContentMetadata = {},
117
396
  ): Promise<Uint8Array> {
118
397
  this.lifetime.signal.throwIfAborted();
119
398
  this.own(body);
@@ -135,6 +414,7 @@ export class ContentManager {
135
414
  controller.signal,
136
415
  );
137
416
  controller.signal.throwIfAborted();
417
+ validateSnapshotMetadata(snapshot, metadata);
138
418
  return snapshot.bytes.slice();
139
419
  } finally {
140
420
  signal?.removeEventListener("abort", abort);
@@ -233,10 +513,7 @@ export class ContentManager {
233
513
  this.snapshot(stream, controller.signal),
234
514
  controller.signal,
235
515
  );
236
- if (value.size !== undefined && value.size !== snapshot.size)
237
- throw new Error("Content size mismatch");
238
- if (value.sha256 !== undefined && value.sha256 !== snapshot.sha256)
239
- throw new Error("Content SHA-256 mismatch");
516
+ validateSnapshotMetadata(snapshot, value);
240
517
  if (snapshot.size > upload.maxBytes)
241
518
  throw new Error("Content exceeds upload maxBytes");
242
519
  const response = await abortable(
@@ -264,7 +541,7 @@ export class ContentManager {
264
541
  }),
265
542
  controller.signal,
266
543
  );
267
- const parsed = parseContentReference(
544
+ const parsed = parseContentUploadReceipt(
268
545
  await readConfirmation(response, controller.signal),
269
546
  );
270
547
  if (
@@ -274,11 +551,9 @@ export class ContentManager {
274
551
  ) {
275
552
  throw new Error("Invalid or mismatched upload reference");
276
553
  }
277
- result.body = {
278
- ref: parsed.value.ref,
279
- size: snapshot.size,
280
- sha256: snapshot.sha256,
281
- };
554
+ delete result.size;
555
+ delete result.sha256;
556
+ result.body = { ref: parsed.value.ref };
282
557
  return result;
283
558
  } finally {
284
559
  clearTimeout(timeout);
@@ -296,21 +571,46 @@ export class ContentManager {
296
571
  return visit(event);
297
572
  }
298
573
 
299
- /** Initiates cancellation and releases snapshots; repeated calls return the
300
- * same promise. Never waits for producer-controlled cancellation promises.
301
- */
574
+ /** @internal A protocol correlation lease shares the same owner, never bytes. */
575
+ async retainBody(body: ReadableStream<Uint8Array>, signal: AbortSignal): Promise<{
576
+ owner: Attachment; size: number; release: () => Promise<void>;
577
+ }> {
578
+ const owner = ownerAccess.adapt(body);
579
+ const snapshot = await ownerAccess.snapshot(owner, signal);
580
+ return { owner, size: snapshot.size, release: ownerAccess.retain(owner) };
581
+ }
582
+
583
+ /** @internal Project compatibility results, or preserve the actual owner. */
584
+ async resultBody(body: ReadableStream<Uint8Array>, synthesized: boolean): Promise<Attachment | ReadableStream<Uint8Array> | undefined> {
585
+ const owner = ownerAccess.adapt(body);
586
+ if (this.hasAttachments) return owner;
587
+ const pending = ownerAccess.peek(owner);
588
+ if (!synthesized && !pending) return undefined;
589
+ try {
590
+ if (synthesized) await ownerAccess.snapshot(owner);
591
+ else await pending;
592
+ } catch { return undefined; }
593
+ // Legacy callers receive a lazy view of this same owner, not a queued copy.
594
+ const release = ownerAccess.retain(owner);
595
+ return new ReadableStream<Uint8Array>({
596
+ async pull(controller) {
597
+ try { controller.enqueue(await owner.read()); controller.close(); }
598
+ catch (error) { controller.error(error); }
599
+ finally { await release(); }
600
+ },
601
+ cancel() { return release(); },
602
+ }, { highWaterMark: 0 });
603
+ }
604
+
605
+ /** @internal Invocation-scoped borrow of an active protocol exchange. */
606
+ lease(owner: Attachment): () => Promise<void> { return ownerAccess.retain(owner); }
607
+
608
+ /** Cancel and dispose remaining owners. No backing bytes live here. */
302
609
  close(): Promise<void> {
303
610
  if (this.closePromise) return this.closePromise;
304
- this.closePromise = Promise.resolve();
305
611
  this.lifetime.abort(new Error("Content manager closed"));
306
- const readers = [...this.readers];
307
- this.snapshots = new WeakMap();
308
- const unread = [...this.seenStreams].filter((stream) => !stream.locked);
309
- this.seenStreams.clear();
310
- this.readers.clear();
311
- for (const reader of readers) void reader.cancel().catch(() => undefined);
312
- for (const stream of unread) void stream.cancel().catch(() => undefined);
313
- this.retainedBytes = 0;
612
+ this.closePromise = Promise.all([...this.attachments].map(owner => owner.close())).then(() => {});
613
+ this.attachments.clear();
314
614
  return this.closePromise;
315
615
  }
316
616
 
@@ -335,71 +635,8 @@ export class ContentManager {
335
635
  throw new Error("Invalid upload budget");
336
636
  }
337
637
 
338
- private snapshot(
339
- stream: ReadableStream<Uint8Array>,
340
- signal: AbortSignal,
341
- ): Promise<Snapshot> {
342
- const existing = this.snapshots.get(stream);
343
- if (existing) return existing;
344
- const pending = this.readSnapshot(stream, signal);
345
- this.snapshots.set(stream, pending);
346
- return pending;
347
- }
348
-
349
- private async readSnapshot(
350
- stream: ReadableStream<Uint8Array>,
351
- signal: AbortSignal,
352
- ): Promise<Snapshot> {
353
- signal.throwIfAborted();
354
- const reader = stream.getReader();
355
- this.readers.add(reader);
356
- const chunks: Uint8Array[] = [];
357
- let size = 0;
358
- const cancel = () => {
359
- void reader.cancel(signal.reason).catch(() => undefined);
360
- };
361
- signal.addEventListener("abort", cancel, { once: true });
362
- try {
363
- for (;;) {
364
- const next = await abortable(reader.read(), signal);
365
- signal.throwIfAborted();
366
- if (next.done) break;
367
- if (!(next.value instanceof Uint8Array))
368
- throw new Error("Content stream must yield Uint8Array chunks");
369
- if (this.retainedBytes + next.value.byteLength > this.limit)
370
- throw new Error("Content snapshot memory limit exceeded");
371
- if (next.value.byteLength === 0) continue;
372
- chunks.push(next.value.slice());
373
- size += next.value.byteLength;
374
- this.retainedBytes += next.value.byteLength;
375
- }
376
- const bytes = new Uint8Array(size);
377
- let offset = 0;
378
- for (const chunk of chunks) {
379
- bytes.set(chunk, offset);
380
- offset += chunk.byteLength;
381
- }
382
- chunks.length = 0;
383
- const digest = new Uint8Array(
384
- await crypto.subtle.digest("SHA-256", bytes),
385
- );
386
- signal.throwIfAborted();
387
- return {
388
- bytes,
389
- size,
390
- sha256: Array.from(digest, (byte) =>
391
- byte.toString(16).padStart(2, "0"),
392
- ).join(""),
393
- };
394
- } catch (error) {
395
- if (!this.lifetime.signal.aborted) this.retainedBytes -= size;
396
- void reader.cancel(error).catch(() => undefined);
397
- throw error;
398
- } finally {
399
- signal.removeEventListener("abort", cancel);
400
- this.readers.delete(reader);
401
- reader.releaseLock();
402
- }
638
+ private snapshot(stream: ReadableStream<Uint8Array>, signal: AbortSignal): Promise<Snapshot> {
639
+ return ownerAccess.snapshot(ownerAccess.adapt(stream), signal);
403
640
  }
404
641
  }
405
642