agenthooksprotocol 0.0.0-stage → 0.1.1

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 (151) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +69 -2
  3. package/dist/src/client/auth.d.ts +147 -0
  4. package/dist/src/client/auth.d.ts.map +1 -0
  5. package/dist/src/client/auth.js +629 -0
  6. package/dist/src/client/auth.js.map +1 -0
  7. package/dist/src/client/composition.d.ts +44 -0
  8. package/dist/src/client/composition.d.ts.map +1 -0
  9. package/dist/src/client/composition.js +531 -0
  10. package/dist/src/client/composition.js.map +1 -0
  11. package/dist/src/client/content.d.ts +75 -0
  12. package/dist/src/client/content.d.ts.map +1 -0
  13. package/dist/src/client/content.js +415 -0
  14. package/dist/src/client/content.js.map +1 -0
  15. package/dist/src/client/hooks.d.ts +66 -0
  16. package/dist/src/client/hooks.d.ts.map +1 -0
  17. package/dist/src/client/hooks.js +1084 -0
  18. package/dist/src/client/hooks.js.map +1 -0
  19. package/dist/src/client/index.d.ts +12 -0
  20. package/dist/src/client/index.d.ts.map +1 -0
  21. package/dist/src/client/index.js +10 -0
  22. package/dist/src/client/index.js.map +1 -0
  23. package/dist/src/client/transport.d.ts +12 -0
  24. package/dist/src/client/transport.d.ts.map +1 -0
  25. package/dist/src/client/transport.js +347 -0
  26. package/dist/src/client/transport.js.map +1 -0
  27. package/dist/src/client/types.d.ts +117 -0
  28. package/dist/src/client/types.d.ts.map +1 -0
  29. package/dist/src/client/types.js +10 -0
  30. package/dist/src/client/types.js.map +1 -0
  31. package/dist/src/client/validation.d.ts +3 -0
  32. package/dist/src/client/validation.d.ts.map +1 -0
  33. package/dist/src/client/validation.js +25 -0
  34. package/dist/src/client/validation.js.map +1 -0
  35. package/dist/src/compaction.d.ts +27 -0
  36. package/dist/src/compaction.d.ts.map +1 -0
  37. package/dist/src/compaction.js +155 -0
  38. package/dist/src/compaction.js.map +1 -0
  39. package/dist/src/content-upload.d.ts +59 -0
  40. package/dist/src/content-upload.d.ts.map +1 -0
  41. package/dist/src/content-upload.js +133 -0
  42. package/dist/src/content-upload.js.map +1 -0
  43. package/dist/src/draft/generated.d.ts +3111 -0
  44. package/dist/src/draft/generated.d.ts.map +1 -0
  45. package/dist/src/draft/generated.js +15623 -0
  46. package/dist/src/draft/generated.js.map +1 -0
  47. package/dist/src/draft/index.d.ts +34 -0
  48. package/dist/src/draft/index.d.ts.map +1 -0
  49. package/dist/src/draft/index.js +131 -0
  50. package/dist/src/draft/index.js.map +1 -0
  51. package/dist/src/draft/schemas.d.ts +8920 -0
  52. package/dist/src/draft/schemas.d.ts.map +1 -0
  53. package/dist/src/draft/schemas.js +9257 -0
  54. package/dist/src/draft/schemas.js.map +1 -0
  55. package/dist/src/draft-atomic.d.ts +18 -0
  56. package/dist/src/draft-atomic.d.ts.map +1 -0
  57. package/dist/src/draft-atomic.js +102 -0
  58. package/dist/src/draft-atomic.js.map +1 -0
  59. package/dist/src/draft-runtime.d.ts +89 -0
  60. package/dist/src/draft-runtime.d.ts.map +1 -0
  61. package/dist/src/draft-runtime.js +240 -0
  62. package/dist/src/draft-runtime.js.map +1 -0
  63. package/dist/src/elicitation.d.ts +18 -0
  64. package/dist/src/elicitation.d.ts.map +1 -0
  65. package/dist/src/elicitation.js +191 -0
  66. package/dist/src/elicitation.js.map +1 -0
  67. package/dist/src/errors.d.ts +7 -0
  68. package/dist/src/errors.d.ts.map +1 -0
  69. package/dist/src/errors.js +15 -0
  70. package/dist/src/errors.js.map +1 -0
  71. package/dist/src/framing.d.ts +7 -0
  72. package/dist/src/framing.d.ts.map +1 -0
  73. package/dist/src/framing.js +39 -0
  74. package/dist/src/framing.js.map +1 -0
  75. package/dist/src/generated.d.ts +304 -0
  76. package/dist/src/generated.d.ts.map +1 -0
  77. package/dist/src/generated.js +1885 -0
  78. package/dist/src/generated.js.map +1 -0
  79. package/dist/src/hook-runner.d.ts +11 -0
  80. package/dist/src/hook-runner.d.ts.map +1 -0
  81. package/dist/src/hook-runner.js +223 -0
  82. package/dist/src/hook-runner.js.map +1 -0
  83. package/dist/src/index.d.ts +8 -0
  84. package/dist/src/index.d.ts.map +1 -0
  85. package/dist/src/index.js +8 -0
  86. package/dist/src/index.js.map +1 -0
  87. package/dist/src/observation.d.ts +26 -0
  88. package/dist/src/observation.d.ts.map +1 -0
  89. package/dist/src/observation.js +28 -0
  90. package/dist/src/observation.js.map +1 -0
  91. package/dist/src/server/attachments.d.ts +29 -0
  92. package/dist/src/server/attachments.d.ts.map +1 -0
  93. package/dist/src/server/attachments.js +112 -0
  94. package/dist/src/server/attachments.js.map +1 -0
  95. package/dist/src/server/hooks.d.ts +20 -0
  96. package/dist/src/server/hooks.d.ts.map +1 -0
  97. package/dist/src/server/hooks.js +131 -0
  98. package/dist/src/server/hooks.js.map +1 -0
  99. package/dist/src/server/index.d.ts +9 -0
  100. package/dist/src/server/index.d.ts.map +1 -0
  101. package/dist/src/server/index.js +5 -0
  102. package/dist/src/server/index.js.map +1 -0
  103. package/dist/src/server/stdio.d.ts +15 -0
  104. package/dist/src/server/stdio.d.ts.map +1 -0
  105. package/dist/src/server/stdio.js +69 -0
  106. package/dist/src/server/stdio.js.map +1 -0
  107. package/dist/src/transport.d.ts +7 -0
  108. package/dist/src/transport.d.ts.map +1 -0
  109. package/dist/src/transport.js +195 -0
  110. package/dist/src/transport.js.map +1 -0
  111. package/dist/src/types.d.ts +116 -0
  112. package/dist/src/types.d.ts.map +1 -0
  113. package/dist/src/types.js +14 -0
  114. package/dist/src/types.js.map +1 -0
  115. package/dist/src/validation.d.ts +7 -0
  116. package/dist/src/validation.d.ts.map +1 -0
  117. package/dist/src/validation.js +209 -0
  118. package/dist/src/validation.js.map +1 -0
  119. package/package.json +62 -4
  120. package/src/client/auth.ts +1076 -0
  121. package/src/client/composition.ts +717 -0
  122. package/src/client/content.ts +522 -0
  123. package/src/client/hooks.ts +1477 -0
  124. package/src/client/index.ts +30 -0
  125. package/src/client/transport.ts +408 -0
  126. package/src/client/types.ts +173 -0
  127. package/src/client/validation.ts +24 -0
  128. package/src/compaction.ts +196 -0
  129. package/src/content-upload.ts +206 -0
  130. package/src/draft/README.md +16 -0
  131. package/src/draft/ahp-codegen.lock.json +131 -0
  132. package/src/draft/generated.ts +19449 -0
  133. package/src/draft/index.ts +251 -0
  134. package/src/draft/schemas.ts +9453 -0
  135. package/src/draft-atomic.ts +119 -0
  136. package/src/draft-runtime.ts +363 -0
  137. package/src/elicitation.ts +254 -0
  138. package/src/errors.ts +30 -0
  139. package/src/framing.ts +56 -0
  140. package/src/generated.ts +2408 -0
  141. package/src/hook-runner.ts +310 -0
  142. package/src/index.ts +8 -0
  143. package/src/observation.ts +65 -0
  144. package/src/server/attachments.ts +128 -0
  145. package/src/server/hooks.ts +206 -0
  146. package/src/server/index.ts +23 -0
  147. package/src/server/node-crypto.d.ts +7 -0
  148. package/src/server/stdio.ts +81 -0
  149. package/src/transport.ts +273 -0
  150. package/src/types.ts +124 -0
  151. package/src/validation.ts +372 -0
@@ -0,0 +1,1477 @@
1
+ import {
2
+ toEventInput,
3
+ contentSlots,
4
+ CapabilityBuilder,
5
+ Permission,
6
+ } from "../draft/generated.js";
7
+ import { HookOperationalError } from "../errors.js";
8
+ import { randomUUID } from "node:crypto";
9
+ import type {
10
+ Authentication,
11
+ Backend,
12
+ Capabilities,
13
+ Effect,
14
+ InterceptResponse,
15
+ InterceptSubscription,
16
+ ObserveSubscription,
17
+ Registration,
18
+ StaticCapabilityManifest,
19
+ } from "../draft/generated.js";
20
+ import {
21
+ validateCapabilities,
22
+ validateInterceptRequest,
23
+ validateInterceptResponse,
24
+ } from "../draft/index.js";
25
+ import { BackendTransport } from "./transport.js";
26
+ import { ContentManager, ContentSource } from "./content.js";
27
+ import { composeResponseAsync, normalizeEffects } from "./composition.js";
28
+ import { auth } from "./auth.js";
29
+ import { validateWire } from "./validation.js";
30
+ import {
31
+ ConfigurationError,
32
+ type BoundaryInput,
33
+ type EventInput,
34
+ type BoundaryOptions,
35
+ type BoundaryResult,
36
+ type Event,
37
+ type EventType,
38
+ type HooksOptions,
39
+ type DeliveryError,
40
+ type DeliveryDiagnostic,
41
+ } from "./types.js";
42
+
43
+ type Subscription = InterceptSubscription | ObserveSubscription;
44
+ type Route = {
45
+ backend: Backend;
46
+ subscription: Subscription;
47
+ index: number;
48
+ transport: BackendTransport;
49
+ };
50
+ const interceptable = new Set<EventType>([
51
+ "tool.before",
52
+ "tool.after",
53
+ "session.start",
54
+ "config.change.before",
55
+ "turn.start",
56
+ "turn.finish.before",
57
+ "model.request.before",
58
+ "model.response.after",
59
+ "model.switch.before",
60
+ "tool.permission.request",
61
+ "tool.batch.after",
62
+ "context.compact.before",
63
+ "context.compact.after",
64
+ "task.change.before",
65
+ "user.elicitation.request",
66
+ "user.elicitation.result",
67
+ "user.message.inbound",
68
+ "user.message.outbound",
69
+ "workspace.change.before",
70
+ ]);
71
+ const eventTypes: EventType[] = [
72
+ "tool.before",
73
+ "tool.after",
74
+ "session.start",
75
+ "session.end",
76
+ "config.change.before",
77
+ "config.change.after",
78
+ "turn.start",
79
+ "turn.finish.before",
80
+ "turn.end",
81
+ "turn.progress",
82
+ "model.request.before",
83
+ "model.response.after",
84
+ "model.error",
85
+ "model.switch.before",
86
+ "model.switch.after",
87
+ "tool.permission.request",
88
+ "tool.permission.resolved",
89
+ "tool.progress",
90
+ "tool.batch.after",
91
+ "context.compact.before",
92
+ "context.compact.after",
93
+ "task.change.before",
94
+ "task.change.after",
95
+ "user.attention",
96
+ "user.elicitation.request",
97
+ "user.elicitation.result",
98
+ "user.message.inbound",
99
+ "user.message.outbound",
100
+ "workspace.change.before",
101
+ "workspace.change.after",
102
+ "file.changed",
103
+ "hook.failure",
104
+ ];
105
+ const matches = (selector: string, type: string) =>
106
+ selector === "*" ||
107
+ selector === type ||
108
+ (selector.endsWith(".*") && selector.slice(0, -2) === type.split(".")[0]);
109
+
110
+ type ElicitationLifetime = {
111
+ id: string;
112
+ sessionId: string | undefined;
113
+ retired: boolean;
114
+ };
115
+
116
+ /** Configuration-driven, harness-facing AHP client. No host operation is executed. */
117
+ export class Hooks {
118
+ readonly initialized: Promise<void>;
119
+ private readonly lifetime = new AbortController();
120
+ private readonly options: HooksOptions;
121
+ private readonly routes: Route[] = [];
122
+ private readonly managers = new Set<ContentManager>();
123
+ private readonly pending = new Set<Promise<unknown>>();
124
+ private readonly provider;
125
+ private readonly authContexts = new Map<string, any>();
126
+ private manifest!: StaticCapabilityManifest;
127
+ private closed?: Promise<void>;
128
+ private readonly elicitations = new Map<
129
+ string,
130
+ { event: any; bytes: Uint8Array }
131
+ >();
132
+ private elicitationBytes = 0;
133
+ // Tokens exist only for active calls, including preparation before any bytes
134
+ // can be retained. Retirement invalidates those calls without tombstones.
135
+ private readonly activeElicitations = new Set<ElicitationLifetime>();
136
+
137
+ constructor(config: unknown, options: HooksOptions) {
138
+ this.options = {
139
+ ...options,
140
+ capabilities: structuredClone(
141
+ Array.isArray(
142
+ (options.capabilities as StaticCapabilityManifest)?.events,
143
+ )
144
+ ? options.capabilities
145
+ : Object.fromEntries(
146
+ Object.entries(options.capabilities ?? {}).map(
147
+ ([event, grant]) => [
148
+ event,
149
+ grant instanceof CapabilityBuilder ? grant.build() : grant,
150
+ ],
151
+ ),
152
+ ),
153
+ ),
154
+ };
155
+ this.provider = options.auth ?? auth();
156
+ // No credentials or processes are acquired until a matching boundary needs them.
157
+ this.initialized = Promise.resolve().then(() => this.initialize(config));
158
+ // An optional initialized wait must not cause an unhandled rejection.
159
+ void this.initialized.catch(() => {});
160
+ }
161
+
162
+ private initialize(config: unknown): void {
163
+ const issues = validateWire("registration", config).map((path) => ({
164
+ path,
165
+ code: "INVALID_REGISTRATION",
166
+ }));
167
+ for (const key of ["maxContentBytes", "observationTimeoutMs"] as const) {
168
+ const value = this.options[key];
169
+ if (
170
+ value !== undefined &&
171
+ (!Number.isSafeInteger(value) ||
172
+ value <= 0 ||
173
+ (key === "observationTimeoutMs" && value > 2147483647))
174
+ )
175
+ issues.push({ path: key, code: "INVALID_LIMIT" });
176
+ }
177
+ if (!this.options.source || typeof this.options.source !== "string")
178
+ issues.push({ path: "source", code: "INVALID_SOURCE" });
179
+ if (issues.length) throw new ConfigurationError(issues);
180
+ const registration = structuredClone(config) as Registration;
181
+ const c = this.options.capabilities;
182
+ if (!c || typeof c !== "object" || Array.isArray(c))
183
+ throw new ConfigurationError([
184
+ { path: "capabilities", code: "INVALID_CAPABILITIES" },
185
+ ]);
186
+ this.manifest = Array.isArray((c as StaticCapabilityManifest).events)
187
+ ? (c as StaticCapabilityManifest)
188
+ : {
189
+ authentication: ["bearer", "oauth"],
190
+ contentCategories: [
191
+ "text",
192
+ "images",
193
+ "audio",
194
+ "video",
195
+ "files",
196
+ "reasoning",
197
+ ],
198
+ correlationIdentityFields: [
199
+ "session.id",
200
+ "turn.id",
201
+ "call.id",
202
+ "parentEventId",
203
+ ],
204
+ events: Object.entries(c).map(([event, grant]) => {
205
+ const explicit = Object.hasOwn(grant ?? {}, "modes");
206
+ return {
207
+ event,
208
+ modes: explicit ? grant.modes : ["intercept"],
209
+ ...(explicit
210
+ ? grant.capabilities === undefined
211
+ ? {}
212
+ : { capabilities: grant.capabilities }
213
+ : { capabilities: grant }),
214
+ };
215
+ }),
216
+ gaps: [],
217
+ limits: {
218
+ maxUploadBytes: this.options.maxContentBytes ?? 64 * 1024 * 1024,
219
+ },
220
+ managedPolicy: {
221
+ disableable: false,
222
+ scopes: ["user", "project", "managed"],
223
+ },
224
+ toolPaths: ["*"],
225
+ transports: ["stdio", "http"],
226
+ };
227
+ const manifestErrors = validateWire("capabilities-response", {
228
+ jsonrpc: "2.0",
229
+ id: "init",
230
+ result: { protocolVersion: "draft", manifest: this.manifest },
231
+ });
232
+ for (const path of manifestErrors)
233
+ issues.push({
234
+ path: `capabilities${path}`,
235
+ code: "INVALID_CAPABILITIES",
236
+ });
237
+ for (const item of this.manifest.events) {
238
+ if (item.capabilities && !validateCapabilities(item.capabilities).ok)
239
+ issues.push({
240
+ path: `capabilities.${item.event}`,
241
+ code: "INVALID_CAPABILITIES",
242
+ });
243
+ if (
244
+ item.modes.includes("intercept") &&
245
+ !interceptable.has(item.event as EventType)
246
+ )
247
+ issues.push({
248
+ path: `capabilities.${item.event}`,
249
+ code: "NOT_INTERCEPTABLE",
250
+ });
251
+ }
252
+ const ids = new Set<string>();
253
+ for (const backend of registration.hooks) {
254
+ if (ids.has(backend.id))
255
+ issues.push({ path: backend.id, code: "DUPLICATE_BACKEND" });
256
+ ids.add(backend.id);
257
+ if (backend.transport.type === "http") {
258
+ try {
259
+ secureEndpoint(String(backend.transport.url));
260
+ } catch {
261
+ issues.push({ path: backend.id, code: "UNSAFE_ENDPOINT" });
262
+ }
263
+ }
264
+ if (!["stdio", "http"].includes(backend.transport.type))
265
+ issues.push({ path: backend.id, code: "UNSUPPORTED_TRANSPORT" });
266
+ for (const [index, raw] of backend.subscriptions.entries()) {
267
+ const s = raw as Subscription;
268
+ if (s.scope === "managed" && s.disableable !== false)
269
+ issues.push({
270
+ path: `${backend.id}.${index}`,
271
+ code: "MANDATORY_SCOPE_DISABLEABLE",
272
+ });
273
+ if (s.scope && !this.manifest.managedPolicy.scopes.includes(s.scope))
274
+ issues.push({
275
+ path: `${backend.id}.${index}`,
276
+ code: "UNSUPPORTED_SCOPE",
277
+ });
278
+ for (const selector of s.events) {
279
+ if (!eventTypes.some((e) => matches(selector, e)))
280
+ issues.push({
281
+ path: `${backend.id}.${index}`,
282
+ code: "UNSUPPORTED_EVENT",
283
+ });
284
+ if (
285
+ !selector.includes("*") &&
286
+ !this.manifest.events.some(
287
+ (e) => e.event === selector && e.modes.includes(s.mode),
288
+ )
289
+ )
290
+ issues.push({
291
+ path: `${backend.id}.${index}`,
292
+ code:
293
+ s.mode === "intercept" ? "NOT_INTERCEPTABLE" : "NOT_OBSERVABLE",
294
+ });
295
+ }
296
+ }
297
+ }
298
+ if (issues.length) throw new ConfigurationError(issues);
299
+ for (const backend of registration.hooks) {
300
+ const transport = new BackendTransport(backend, (url, init) =>
301
+ this.fetchAuthenticated(
302
+ url,
303
+ init,
304
+ backend.authentication,
305
+ "event",
306
+ backend.id,
307
+ ),
308
+ );
309
+ backend.subscriptions.forEach((subscription, index) =>
310
+ this.routes.push({
311
+ backend,
312
+ subscription: subscription as Subscription,
313
+ index,
314
+ transport,
315
+ }),
316
+ );
317
+ }
318
+ }
319
+
320
+ // Each endpoint binding has its own provider context; upload auth never inherits
321
+ // event auth. Contexts belong to this Hooks instance, even with a shared provider.
322
+ private async fetchAuthenticated(
323
+ url: string,
324
+ init: RequestInit,
325
+ authentication: Authentication | undefined,
326
+ purpose: "event" | "upload",
327
+ backendId: string,
328
+ ): Promise<Response> {
329
+ secureEndpoint(url);
330
+ const signal = init.signal ?? this.lifetime.signal;
331
+ signal.throwIfAborted();
332
+ const key = JSON.stringify([
333
+ backendId,
334
+ purpose,
335
+ url,
336
+ authentication ?? null,
337
+ ]);
338
+ let context = this.authContexts.get(key);
339
+ if (!context) {
340
+ context = {
341
+ url,
342
+ backendId,
343
+ purpose,
344
+ ...(authentication ? { authentication } : {}),
345
+ cache: new Map(),
346
+ };
347
+ this.authContexts.set(key, context);
348
+ }
349
+ // Challenge state belongs to this delivery attempt, never another caller.
350
+ context = { ...context };
351
+ for (let attempt = 0; attempt < 2; attempt++) {
352
+ const credential = await raceAbort(
353
+ Promise.resolve(
354
+ "credential" in this.provider
355
+ ? this.provider.credential({ ...context, signal })
356
+ : this.provider.authenticate({ ...context, signal }, {}),
357
+ ),
358
+ signal,
359
+ );
360
+ if (
361
+ !credential &&
362
+ (authentication !== undefined || context.challenged === true)
363
+ )
364
+ throw new Error("Authentication credential required");
365
+ const headers = new Headers(init.headers);
366
+ headers.delete("authorization");
367
+ if (credential) {
368
+ if (
369
+ typeof credential.token !== "string" ||
370
+ !credential.token ||
371
+ /[\s\x00-\x1f\x7f]/.test(credential.token) ||
372
+ ("credential" in this.provider &&
373
+ (!("type" in credential) || credential.type !== "bearer")) ||
374
+ ("expiresAt" in credential &&
375
+ credential.expiresAt !== undefined &&
376
+ (!Number.isFinite(credential.expiresAt) ||
377
+ credential.expiresAt <= Date.now()))
378
+ )
379
+ throw new Error("Invalid authentication credential");
380
+ headers.set("authorization", `Bearer ${credential.token}`);
381
+ }
382
+ const response = await raceAbort(
383
+ (this.options.fetch ?? fetch)(url, {
384
+ ...init,
385
+ headers,
386
+ redirect: "error",
387
+ signal,
388
+ }).then((response) => {
389
+ if (signal.aborted) {
390
+ void response.body?.cancel().catch(() => {});
391
+ signal.throwIfAborted();
392
+ }
393
+ return response;
394
+ }),
395
+ signal,
396
+ );
397
+ if (response.status === 401 && "credential" in this.provider) {
398
+ try {
399
+ await raceAbort(
400
+ Promise.resolve(
401
+ this.provider.challenge({
402
+ ...context,
403
+ signal,
404
+ response,
405
+ credential,
406
+ }),
407
+ ),
408
+ signal,
409
+ );
410
+ } catch (error) {
411
+ void response.body?.cancel().catch(() => {});
412
+ throw error;
413
+ }
414
+ }
415
+ if (response.status !== 401 || attempt === 1) {
416
+ delete context.challenge;
417
+ delete context.challenged;
418
+ return response;
419
+ }
420
+ context.challenged = true;
421
+ context.challenge = response.headers.get("www-authenticate") ?? undefined;
422
+ void response.body?.cancel().catch(() => {});
423
+ }
424
+ throw new Error("Authentication failed");
425
+ }
426
+
427
+ /** Generic typed boundary dispatch; named methods below cover the complete catalogue. */
428
+ async dispatch<K extends EventType>(
429
+ type: K,
430
+ input: BoundaryInput<K>,
431
+ options: BoundaryOptions = {},
432
+ ): Promise<BoundaryResult<K>> {
433
+ const manager = new ContentManager({
434
+ // Invalid configuration still transfers unused-source cleanup ownership;
435
+ // initialize rejects it before any preparation can use this fallback.
436
+ maxSnapshotBytes:
437
+ Number.isSafeInteger(this.options.maxContentBytes) &&
438
+ this.options.maxContentBytes! > 0
439
+ ? this.options.maxContentBytes!
440
+ : 64 * 1024 * 1024,
441
+ allowLoopback: true,
442
+ });
443
+ manager.own(input);
444
+ manager.own(options.contentSources);
445
+ this.managers.add(manager);
446
+ const signal = combineSignals(this.lifetime.signal, options.signal);
447
+ const exchange: ElicitationLifetime | undefined =
448
+ type === "user.elicitation.request"
449
+ ? {
450
+ id: input.id ?? randomUUID(),
451
+ sessionId: (input as any).session?.id,
452
+ retired: false,
453
+ }
454
+ : undefined;
455
+ if (exchange) this.activeElicitations.add(exchange);
456
+ let completed = false;
457
+ const work = (async () => {
458
+ // Calls after close are misuse. Active calls return interruption evidence.
459
+ this.lifetime.signal.throwIfAborted();
460
+ await this.initialized;
461
+ return this.run(type, input, options, signal, manager, exchange);
462
+ })();
463
+ this.pending.add(work);
464
+ try {
465
+ const result = await work;
466
+ completed = true;
467
+ return result;
468
+ } finally {
469
+ // Terminal exchanges retire even when validation or cancellation bypasses run.
470
+ if (type === "user.elicitation.result")
471
+ this.forgetElicitation((input as any).parentEventId);
472
+ if (
473
+ type === "user.elicitation.request" &&
474
+ (!completed || signal.aborted) &&
475
+ input.id
476
+ )
477
+ this.forgetElicitation(input.id);
478
+ if (type === "session.end")
479
+ this.retireSessionElicitations((input as any).session?.id);
480
+ if (exchange) this.activeElicitations.delete(exchange);
481
+ await manager.close();
482
+ this.managers.delete(manager);
483
+ this.pending.delete(work);
484
+ }
485
+ }
486
+
487
+ private async run<K extends EventType>(
488
+ type: K,
489
+ input: BoundaryInput<K>,
490
+ options: BoundaryOptions,
491
+ signal: AbortSignal,
492
+ manager: ContentManager,
493
+ exchange?: ElicitationLifetime,
494
+ ): Promise<BoundaryResult<K>> {
495
+ let event: any = cloneInput({
496
+ ...input,
497
+ type,
498
+ source: this.options.source,
499
+ id: exchange?.id ?? input.id ?? randomUUID(),
500
+ time: input.time ?? new Date().toISOString(),
501
+ });
502
+ bindContentSources(type, event, options.contentSources);
503
+ if (type === "session.start")
504
+ event.manifest = structuredClone(this.manifest);
505
+ const requestId = event.id;
506
+ // Project actual settled state, never replay cumulative effects: that would
507
+ // duplicate injections/instructions and lose candidate invalidation.
508
+ const snapshotState = (
509
+ value: BoundaryOptions["initialState"],
510
+ ): BoundaryResult<K>["state"] => {
511
+ const snapshot = structuredClone(
512
+ value ?? { candidate: null, permission: "none" },
513
+ );
514
+ if (
515
+ !Object.values(Permission).includes(snapshot.permission as Permission)
516
+ )
517
+ throw new ConfigurationError([
518
+ { path: "initialState.permission", code: "INVALID_BOUNDARY_STATE" },
519
+ ]);
520
+ const freeze = (value: unknown): void => {
521
+ if (value !== null && typeof value === "object") {
522
+ for (const child of Object.values(value)) freeze(child);
523
+ Object.freeze(value);
524
+ }
525
+ };
526
+ freeze(snapshot);
527
+ return snapshot;
528
+ };
529
+ if (signal.aborted)
530
+ return {
531
+ event: await resultEvent(event, manager),
532
+ response: {
533
+ jsonrpc: "2.0",
534
+ id: requestId,
535
+ result: { protocolVersion: "draft", effects: [] },
536
+ },
537
+ state: snapshotState(options.initialState),
538
+ get permission() {
539
+ return this.state.permission as `${Permission}`;
540
+ },
541
+ get input(): unknown {
542
+ return (this.event as any).tool?.input;
543
+ },
544
+ errors: [],
545
+ diagnostics: [],
546
+ observations: Promise.resolve([]),
547
+ interrupted: true,
548
+ } as BoundaryResult<K>;
549
+ const localSources = new Set<ReadableStream<Uint8Array>>();
550
+ const originalRequest = this.elicitationFor(event);
551
+ manager.own(originalRequest);
552
+ let effects: Effect[] = [];
553
+ let state = structuredClone(options.initialState);
554
+ // A supplied snapshot is input to the first receiver, not a newly accepted
555
+ // result in this chain. Composition settles terminal state after acceptance.
556
+ let shortCircuit = false;
557
+ let interrupted = false;
558
+ const errors: DeliveryError[] = [];
559
+ const advertised = this.manifest.events.find((e) => e.event === type);
560
+ if (!advertised)
561
+ throw new ConfigurationError([
562
+ { path: type, code: "UNADVERTISED_EVENT" },
563
+ ]);
564
+ const staticCaps = advertised.capabilities ?? { effects: [] };
565
+ const caps: Capabilities = structuredClone(
566
+ options.capabilities ?? staticCaps,
567
+ );
568
+ // Reject widening before deriving occurrence counters or clamping budgets.
569
+ if (!validateCapabilities(caps).ok || !isNarrower(caps, staticCaps))
570
+ throw new ConfigurationError([
571
+ { path: type, code: "INVALID_CAPABILITY_NARROWING" },
572
+ ]);
573
+ if (type === "turn.finish.before" && caps.flow) {
574
+ caps.flow.continuationCount = event.continuationCount;
575
+ if (caps.flow.maxContinuations !== undefined)
576
+ caps.flow.remainingContinuations = Math.max(
577
+ 0,
578
+ Math.min(
579
+ caps.flow.remainingContinuations ?? 0,
580
+ caps.flow.maxContinuations - event.continuationCount,
581
+ ),
582
+ );
583
+ }
584
+ if (
585
+ !validateCapabilities(caps).ok ||
586
+ !isNarrower(caps, advertised.capabilities ?? { effects: [] })
587
+ )
588
+ throw new ConfigurationError([
589
+ { path: type, code: "INVALID_CAPABILITY_NARROWING" },
590
+ ]);
591
+ const called = new Set<Route>();
592
+ const matching = this.routes.filter(
593
+ (r) =>
594
+ r.subscription.events.some((s) => matches(s, type)) &&
595
+ advertised.modes.includes(r.subscription.mode),
596
+ );
597
+ try {
598
+ // Validate a metadata-only view before any delivery (this does not consume bytes).
599
+ const metadata = await manager.prepare(
600
+ event,
601
+ { default: "metadata" },
602
+ undefined,
603
+ () => Promise.reject(new Error("Unexpected upload")),
604
+ signal,
605
+ );
606
+ const invalid = validateWire("observe-notification", {
607
+ jsonrpc: "2.0",
608
+ method: "hooks/observe",
609
+ params: { protocolVersion: "draft", event: metadata },
610
+ });
611
+ if (invalid.length)
612
+ throw new ConfigurationError(
613
+ invalid.map((path) => ({ path, code: "INVALID_EVENT" })),
614
+ );
615
+ const invalidState =
616
+ state === undefined
617
+ ? []
618
+ : validateWire("intercept-request", {
619
+ jsonrpc: "2.0",
620
+ id: requestId,
621
+ method: "hooks/intercept",
622
+ params: {
623
+ protocolVersion: "draft",
624
+ event: metadata,
625
+ capabilities: caps,
626
+ ...(state !== undefined ? { state } : {}),
627
+ },
628
+ });
629
+ if (invalidState.length)
630
+ throw new ConfigurationError(
631
+ invalidState.map((path) => ({
632
+ path,
633
+ code: "INVALID_BOUNDARY_STATE",
634
+ })),
635
+ );
636
+ for (const route of matching) {
637
+ if (route.subscription.mode !== "intercept" || shortCircuit) continue;
638
+ if (signal.aborted) {
639
+ interrupted = true;
640
+ break;
641
+ }
642
+ called.add(route);
643
+ const s = route.subscription;
644
+ let phase: DeliveryError["phase"] = "preparation";
645
+ let deadline: AbortSignal | undefined;
646
+ let received = false;
647
+ let budget: ReturnType<typeof deadlineAfter> | undefined;
648
+ try {
649
+ const projected = await raceAbort(
650
+ manager.prepare(
651
+ projectNative(event, s.includeNative),
652
+ s.content,
653
+ s.upload,
654
+ (url, init, upload) =>
655
+ this.fetchAuthenticated(
656
+ url,
657
+ init,
658
+ upload.auth,
659
+ "upload",
660
+ route.backend.id,
661
+ ),
662
+ signal,
663
+ ),
664
+ signal,
665
+ );
666
+ if (
667
+ type === "user.elicitation.request" &&
668
+ projected.elicitation?.request?.selection === "body" &&
669
+ event.elicitation?.request?.body instanceof ReadableStream
670
+ ) {
671
+ const bytes = await manager.readBody(
672
+ event.elicitation.request.body,
673
+ signal,
674
+ );
675
+ this.rememberElicitation(event, bytes, exchange!);
676
+ }
677
+ const request = {
678
+ jsonrpc: "2.0",
679
+ id: requestId,
680
+ method: "hooks/intercept",
681
+ params: {
682
+ protocolVersion: "draft",
683
+ event: projected,
684
+ capabilities: caps,
685
+ ...(state ? { state } : {}),
686
+ },
687
+ };
688
+ const valid = validateInterceptRequest(request);
689
+ if (!valid.ok) throw new Error("Invalid interception request");
690
+ phase = "interception";
691
+ budget = deadlineAfter(s.timeoutMs, signal);
692
+ deadline = budget.signal;
693
+ const reply = await route.transport.request(request, deadline);
694
+ received = true;
695
+ budget.check();
696
+ if (reply && typeof reply === "object" && "error" in reply)
697
+ throw new HookOperationalError(
698
+ "JSON_RPC_ERROR",
699
+ "Backend returned a JSON-RPC error",
700
+ );
701
+ const decoded = validateInterceptResponse(reply);
702
+ if (!decoded.ok || decoded.value.id !== requestId)
703
+ throw new Error("Invalid interception response");
704
+ // Elicitation terminals are mutually exclusive. Check the raw reply
705
+ // before generic composition normalizes return + deny to denial.
706
+ if (
707
+ type === "user.elicitation.request" &&
708
+ decoded.value.result.effects.some(
709
+ (effect) => effect.type === "return",
710
+ ) &&
711
+ decoded.value.result.effects.some(
712
+ (effect) => effect.type === "deny",
713
+ )
714
+ )
715
+ throw new Error("Conflicting elicitation terminal effects");
716
+ if (
717
+ type === "user.elicitation.request" &&
718
+ decoded.value.result.effects.some(
719
+ (effect) => effect.type === "return" || effect.type === "deny",
720
+ ) &&
721
+ (projected.elicitation?.request?.selection !== "body" ||
722
+ !projected.elicitation.request.body ||
723
+ projected.elicitation.request.gap !== undefined)
724
+ )
725
+ throw new Error(
726
+ "Elicitation terminals require selected request body",
727
+ );
728
+ const staged = await raceAbort(
729
+ composeResponseAsync(event, effects, decoded.value, caps, {
730
+ readContent: (body) => manager.readBody(body, deadline),
731
+ ownContent: (body) => {
732
+ manager.own(body);
733
+ localSources.add(body);
734
+ },
735
+ selectedEvent: projected,
736
+ state,
737
+ ...(originalRequest
738
+ ? { elicitationRequest: originalRequest }
739
+ : {}),
740
+ }),
741
+ deadline,
742
+ );
743
+ budget.check();
744
+ event = staged.event;
745
+ effects = staged.effects;
746
+ state = staged.state;
747
+ shortCircuit = staged.shortCircuit;
748
+ } catch (cause) {
749
+ interrupted = signal.aborted;
750
+ const syntheticDenial =
751
+ !interrupted && s.failurePolicy === "fail-closed";
752
+ const error: DeliveryError = {
753
+ backendId: route.backend.id,
754
+ subscriptionIndex: route.index,
755
+ phase,
756
+ code: interrupted
757
+ ? cancellationCode(signal)
758
+ : deadline?.aborted
759
+ ? "DEADLINE_EXCEEDED"
760
+ : phase === "preparation"
761
+ ? "PREPARATION_FAILED"
762
+ : "DELIVERY_FAILED",
763
+ failurePolicy: s.failurePolicy as "fail-open" | "fail-closed",
764
+ syntheticDenial,
765
+ };
766
+ diagnosticCauses.set(
767
+ error,
768
+ classifyDiagnostic(
769
+ cause,
770
+ signal.aborted ? signal : deadline,
771
+ phase === "preparation",
772
+ received,
773
+ ),
774
+ );
775
+ errors.push(error);
776
+ if (interrupted) break;
777
+ if (syntheticDenial) {
778
+ const denial: Effect = {
779
+ type: "deny",
780
+ reason: "Required policy backend unavailable.",
781
+ };
782
+ const settled = normalizeEffects([denial], state);
783
+ effects = normalizeEffects([...effects, denial]).effects;
784
+ state = settled.state;
785
+ shortCircuit = settled.shortCircuit;
786
+ }
787
+ } finally {
788
+ budget?.dispose();
789
+ }
790
+ }
791
+ interrupted ||= signal.aborted;
792
+ if (interrupted)
793
+ effects = effects.filter(
794
+ (e) =>
795
+ e.type !== "allow" &&
796
+ e.type !== "return" &&
797
+ !(e.type === "flow" && e.operation === "continue"),
798
+ );
799
+ // Observation deliveries are owned by this call, but cannot change its decision.
800
+ const observations = matching.filter(
801
+ (r) =>
802
+ advertised.modes.includes("observe") &&
803
+ (r.subscription.mode === "observe" ||
804
+ ((shortCircuit || interrupted) && !called.has(r))),
805
+ );
806
+ let observed: Promise<DeliveryError[]> = Promise.resolve([]);
807
+ // Cancellation stops new work, including settlement notifications. Each
808
+ // observer budget can shorten, but never extend, the operation signal.
809
+ if (!signal.aborted && observations.length) {
810
+ const snapshot = cloneInput(event);
811
+ const jobs = observations.map((route) =>
812
+ this.observe(route, snapshot, manager, signal),
813
+ );
814
+ observed = Promise.all(jobs).then((results) =>
815
+ results.filter(
816
+ (result): result is DeliveryError => result !== undefined,
817
+ ),
818
+ );
819
+ await observed;
820
+ }
821
+ interrupted ||= signal.aborted;
822
+ if (type === "user.elicitation.result")
823
+ this.forgetElicitation(event.parentEventId);
824
+ if (
825
+ type === "user.elicitation.request" &&
826
+ (interrupted ||
827
+ errors.length > 0 ||
828
+ (await observed).length > 0 ||
829
+ effects.some((e) => e.type === "deny"))
830
+ )
831
+ this.forgetElicitation(event.id);
832
+ if (type === "session.end")
833
+ this.retireSessionElicitations(event.session?.id);
834
+ const response = {
835
+ jsonrpc: "2.0",
836
+ id: requestId,
837
+ result: { protocolVersion: "draft", effects },
838
+ } as InterceptResponse;
839
+ return {
840
+ event: await resultEvent(event, manager, localSources),
841
+ response,
842
+ state: snapshotState(state),
843
+ get permission() {
844
+ return this.state.permission as `${Permission}`;
845
+ },
846
+ get input(): unknown {
847
+ return (this.event as any).tool?.input;
848
+ },
849
+ errors,
850
+ diagnostics: [...errors, ...(await observed)].map(deliveryDiagnostic),
851
+ observations: observed,
852
+ interrupted,
853
+ } as BoundaryResult<K>;
854
+ } catch (error) {
855
+ if (type === "user.elicitation.request")
856
+ this.forgetElicitation(requestId);
857
+ await manager.close();
858
+ this.managers.delete(manager);
859
+ throw error;
860
+ }
861
+ }
862
+
863
+ private async observe(
864
+ route: Route,
865
+ event: any,
866
+ manager: ContentManager,
867
+ parentSignal: AbortSignal,
868
+ ): Promise<DeliveryError | undefined> {
869
+ const budget = deadlineAfter(
870
+ this.options.observationTimeoutMs ?? 15000,
871
+ parentSignal,
872
+ );
873
+ const signal = budget.signal;
874
+ let preparing = true;
875
+ try {
876
+ const s = route.subscription;
877
+ const projected = await raceAbort(
878
+ manager.prepare(
879
+ projectNative(event, s.includeNative),
880
+ s.content,
881
+ s.upload,
882
+ (url, init, upload) =>
883
+ this.fetchAuthenticated(
884
+ url,
885
+ init,
886
+ upload.auth,
887
+ "upload",
888
+ route.backend.id,
889
+ ),
890
+ signal,
891
+ ),
892
+ signal,
893
+ );
894
+ preparing = false;
895
+ await route.transport.notify(
896
+ {
897
+ jsonrpc: "2.0",
898
+ method: "hooks/observe",
899
+ params: { protocolVersion: "draft", event: projected },
900
+ },
901
+ signal,
902
+ );
903
+ } catch (cause) {
904
+ const error: DeliveryError = {
905
+ backendId: route.backend.id,
906
+ subscriptionIndex: route.index,
907
+ phase: "observation",
908
+ code: parentSignal.aborted
909
+ ? cancellationCode(parentSignal)
910
+ : signal.aborted
911
+ ? "DEADLINE_EXCEEDED"
912
+ : "DELIVERY_FAILED",
913
+ syntheticDenial: false,
914
+ };
915
+ diagnosticCauses.set(
916
+ error,
917
+ classifyDiagnostic(cause, signal, preparing, false),
918
+ );
919
+ return error;
920
+ } finally {
921
+ budget.dispose();
922
+ }
923
+ }
924
+
925
+ private rememberElicitation(
926
+ event: any,
927
+ bytes: Uint8Array,
928
+ exchange: ElicitationLifetime,
929
+ ): void {
930
+ if (exchange.retired || this.lifetime.signal.aborted) return;
931
+ if (this.elicitations.has(event.id)) return;
932
+ const limit = this.options.maxContentBytes ?? 64 * 1024 * 1024;
933
+ if (bytes.byteLength > limit)
934
+ throw new Error("Elicitation request exceeds retention budget");
935
+ if (
936
+ this.elicitationBytes + bytes.byteLength > limit ||
937
+ this.elicitations.size >= 128
938
+ )
939
+ throw new Error("Active elicitation exchanges exceed retention budget");
940
+ // Pending exchanges own only correlation facts and selected request bytes.
941
+ const stored = {
942
+ type: event.type,
943
+ id: event.id,
944
+ source: event.source,
945
+ session: event.session ? { id: event.session.id } : undefined,
946
+ elicitation: {
947
+ mode: event.elicitation.mode,
948
+ server: event.elicitation.server,
949
+ request: { mediaType: event.elicitation.request.mediaType },
950
+ },
951
+ };
952
+ this.elicitations.set(event.id, { event: stored, bytes });
953
+ this.elicitationBytes += bytes.byteLength;
954
+ }
955
+ private elicitationFor(event: any): Event | undefined {
956
+ if (event.type !== "user.elicitation.result") return undefined;
957
+ const entry = this.elicitations.get(event.parentEventId);
958
+ if (!entry) return undefined;
959
+ const copy = cloneInput(entry.event),
960
+ bytes = entry.bytes.slice();
961
+ copy.elicitation.request.body = new ReadableStream<Uint8Array>({
962
+ start(c) {
963
+ c.enqueue(bytes);
964
+ c.close();
965
+ },
966
+ });
967
+ return copy;
968
+ }
969
+ /** End an abandoned host elicitation exchange without delivering a result. */
970
+ discardElicitation(requestEventId: string): void {
971
+ this.forgetElicitation(requestEventId);
972
+ }
973
+
974
+ private retireSessionElicitations(sessionId: string | undefined): void {
975
+ for (const exchange of this.activeElicitations)
976
+ if (exchange.sessionId === sessionId) exchange.retired = true;
977
+ for (const [id, entry] of this.elicitations)
978
+ if (entry.event.session?.id === sessionId) this.forgetElicitation(id);
979
+ }
980
+
981
+ private forgetElicitation(id: string): void {
982
+ for (const exchange of this.activeElicitations)
983
+ if (exchange.id === id) exchange.retired = true;
984
+ const entry = this.elicitations.get(id);
985
+ if (entry) this.elicitationBytes -= entry.bytes.byteLength;
986
+ this.elicitations.delete(id);
987
+ }
988
+
989
+ /** Cancel pending work and release SDK-owned resources; injected stores remain owned by the host. */
990
+ close(): Promise<void> {
991
+ if (!this.closed) {
992
+ for (const exchange of this.activeElicitations) exchange.retired = true;
993
+ this.lifetime.abort(new Error("Hooks closed"));
994
+ this.closed = (async () => {
995
+ await this.initialized.catch(() => {});
996
+ await Promise.allSettled(
997
+ [...new Set(this.routes.map((r) => r.transport))].map((t) =>
998
+ t.close(),
999
+ ),
1000
+ );
1001
+ await Promise.allSettled([...this.managers].map((m) => m.close()));
1002
+ await Promise.allSettled([...this.pending]);
1003
+ this.authContexts.clear();
1004
+ this.elicitations.clear();
1005
+ this.elicitationBytes = 0;
1006
+ })();
1007
+ }
1008
+ return this.closed;
1009
+ }
1010
+ private boundary<K extends EventType>(
1011
+ type: K,
1012
+ input: EventInput<K>,
1013
+ options?: BoundaryOptions,
1014
+ ): Promise<BoundaryResult<K>> {
1015
+ // Generated projection preserves owned source identities; dispatch clones
1016
+ // host facts, owns cleanup, and validates the canonical request before I/O.
1017
+ return this.dispatch(
1018
+ type,
1019
+ toEventInput(type, input as any) as unknown as BoundaryInput<K>,
1020
+ options,
1021
+ );
1022
+ }
1023
+
1024
+ toolBefore(
1025
+ input: EventInput<"tool.before">,
1026
+ options?: BoundaryOptions,
1027
+ ): Promise<BoundaryResult<"tool.before">> {
1028
+ return this.boundary("tool.before", input, options);
1029
+ }
1030
+ toolAfter(
1031
+ input: EventInput<"tool.after">,
1032
+ options?: BoundaryOptions,
1033
+ ): Promise<BoundaryResult<"tool.after">> {
1034
+ return this.boundary("tool.after", input, options);
1035
+ }
1036
+ sessionStart(
1037
+ input: EventInput<"session.start">,
1038
+ options?: BoundaryOptions,
1039
+ ): Promise<BoundaryResult<"session.start">> {
1040
+ return this.boundary("session.start", input, options);
1041
+ }
1042
+ sessionEnd(
1043
+ input: EventInput<"session.end">,
1044
+ options?: BoundaryOptions,
1045
+ ): Promise<BoundaryResult<"session.end">> {
1046
+ return this.boundary("session.end", input, options);
1047
+ }
1048
+ configChangeBefore(
1049
+ input: EventInput<"config.change.before">,
1050
+ options?: BoundaryOptions,
1051
+ ): Promise<BoundaryResult<"config.change.before">> {
1052
+ return this.boundary("config.change.before", input, options);
1053
+ }
1054
+ configChangeAfter(
1055
+ input: EventInput<"config.change.after">,
1056
+ options?: BoundaryOptions,
1057
+ ): Promise<BoundaryResult<"config.change.after">> {
1058
+ return this.boundary("config.change.after", input, options);
1059
+ }
1060
+ turnStart(
1061
+ input: EventInput<"turn.start">,
1062
+ options?: BoundaryOptions,
1063
+ ): Promise<BoundaryResult<"turn.start">> {
1064
+ return this.boundary("turn.start", input, options);
1065
+ }
1066
+ turnFinishBefore(
1067
+ input: EventInput<"turn.finish.before">,
1068
+ options?: BoundaryOptions,
1069
+ ): Promise<BoundaryResult<"turn.finish.before">> {
1070
+ return this.boundary("turn.finish.before", input, options);
1071
+ }
1072
+ turnEnd(
1073
+ input: EventInput<"turn.end">,
1074
+ options?: BoundaryOptions,
1075
+ ): Promise<BoundaryResult<"turn.end">> {
1076
+ return this.boundary("turn.end", input, options);
1077
+ }
1078
+ turnProgress(
1079
+ input: EventInput<"turn.progress">,
1080
+ options?: BoundaryOptions,
1081
+ ): Promise<BoundaryResult<"turn.progress">> {
1082
+ return this.boundary("turn.progress", input, options);
1083
+ }
1084
+ modelRequestBefore(
1085
+ input: EventInput<"model.request.before">,
1086
+ options?: BoundaryOptions,
1087
+ ): Promise<BoundaryResult<"model.request.before">> {
1088
+ return this.boundary("model.request.before", input, options);
1089
+ }
1090
+ modelResponseAfter(
1091
+ input: EventInput<"model.response.after">,
1092
+ options?: BoundaryOptions,
1093
+ ): Promise<BoundaryResult<"model.response.after">> {
1094
+ return this.boundary("model.response.after", input, options);
1095
+ }
1096
+ modelError(
1097
+ input: EventInput<"model.error">,
1098
+ options?: BoundaryOptions,
1099
+ ): Promise<BoundaryResult<"model.error">> {
1100
+ return this.boundary("model.error", input, options);
1101
+ }
1102
+ modelSwitchBefore(
1103
+ input: EventInput<"model.switch.before">,
1104
+ options?: BoundaryOptions,
1105
+ ): Promise<BoundaryResult<"model.switch.before">> {
1106
+ return this.boundary("model.switch.before", input, options);
1107
+ }
1108
+ modelSwitchAfter(
1109
+ input: EventInput<"model.switch.after">,
1110
+ options?: BoundaryOptions,
1111
+ ): Promise<BoundaryResult<"model.switch.after">> {
1112
+ return this.boundary("model.switch.after", input, options);
1113
+ }
1114
+ toolPermissionRequest(
1115
+ input: EventInput<"tool.permission.request">,
1116
+ options?: BoundaryOptions,
1117
+ ): Promise<BoundaryResult<"tool.permission.request">> {
1118
+ return this.boundary("tool.permission.request", input, options);
1119
+ }
1120
+ toolPermissionResolved(
1121
+ input: EventInput<"tool.permission.resolved">,
1122
+ options?: BoundaryOptions,
1123
+ ): Promise<BoundaryResult<"tool.permission.resolved">> {
1124
+ return this.boundary("tool.permission.resolved", input, options);
1125
+ }
1126
+ toolProgress(
1127
+ input: EventInput<"tool.progress">,
1128
+ options?: BoundaryOptions,
1129
+ ): Promise<BoundaryResult<"tool.progress">> {
1130
+ return this.boundary("tool.progress", input, options);
1131
+ }
1132
+ toolBatchAfter(
1133
+ input: EventInput<"tool.batch.after">,
1134
+ options?: BoundaryOptions,
1135
+ ): Promise<BoundaryResult<"tool.batch.after">> {
1136
+ return this.boundary("tool.batch.after", input, options);
1137
+ }
1138
+ contextCompactBefore(
1139
+ input: EventInput<"context.compact.before">,
1140
+ options?: BoundaryOptions,
1141
+ ): Promise<BoundaryResult<"context.compact.before">> {
1142
+ return this.boundary("context.compact.before", input, options);
1143
+ }
1144
+ contextCompactAfter(
1145
+ input: EventInput<"context.compact.after">,
1146
+ options?: BoundaryOptions,
1147
+ ): Promise<BoundaryResult<"context.compact.after">> {
1148
+ return this.boundary("context.compact.after", input, options);
1149
+ }
1150
+ taskChangeBefore(
1151
+ input: EventInput<"task.change.before">,
1152
+ options?: BoundaryOptions,
1153
+ ): Promise<BoundaryResult<"task.change.before">> {
1154
+ return this.boundary("task.change.before", input, options);
1155
+ }
1156
+ taskChangeAfter(
1157
+ input: EventInput<"task.change.after">,
1158
+ options?: BoundaryOptions,
1159
+ ): Promise<BoundaryResult<"task.change.after">> {
1160
+ return this.boundary("task.change.after", input, options);
1161
+ }
1162
+ userAttention(
1163
+ input: EventInput<"user.attention">,
1164
+ options?: BoundaryOptions,
1165
+ ): Promise<BoundaryResult<"user.attention">> {
1166
+ return this.boundary("user.attention", input, options);
1167
+ }
1168
+ userElicitationRequest(
1169
+ input: EventInput<"user.elicitation.request">,
1170
+ options?: BoundaryOptions,
1171
+ ): Promise<BoundaryResult<"user.elicitation.request">> {
1172
+ return this.boundary("user.elicitation.request", input, options);
1173
+ }
1174
+ userElicitationResult(
1175
+ input: EventInput<"user.elicitation.result">,
1176
+ options?: BoundaryOptions,
1177
+ ): Promise<BoundaryResult<"user.elicitation.result">> {
1178
+ return this.boundary("user.elicitation.result", input, options);
1179
+ }
1180
+ userMessageInbound(
1181
+ input: EventInput<"user.message.inbound">,
1182
+ options?: BoundaryOptions,
1183
+ ): Promise<BoundaryResult<"user.message.inbound">> {
1184
+ return this.boundary("user.message.inbound", input, options);
1185
+ }
1186
+ userMessageOutbound(
1187
+ input: EventInput<"user.message.outbound">,
1188
+ options?: BoundaryOptions,
1189
+ ): Promise<BoundaryResult<"user.message.outbound">> {
1190
+ return this.boundary("user.message.outbound", input, options);
1191
+ }
1192
+ workspaceChangeBefore(
1193
+ input: EventInput<"workspace.change.before">,
1194
+ options?: BoundaryOptions,
1195
+ ): Promise<BoundaryResult<"workspace.change.before">> {
1196
+ return this.boundary("workspace.change.before", input, options);
1197
+ }
1198
+ workspaceChangeAfter(
1199
+ input: EventInput<"workspace.change.after">,
1200
+ options?: BoundaryOptions,
1201
+ ): Promise<BoundaryResult<"workspace.change.after">> {
1202
+ return this.boundary("workspace.change.after", input, options);
1203
+ }
1204
+ fileChanged(
1205
+ input: EventInput<"file.changed">,
1206
+ options?: BoundaryOptions,
1207
+ ): Promise<BoundaryResult<"file.changed">> {
1208
+ return this.boundary("file.changed", input, options);
1209
+ }
1210
+ hookFailure(
1211
+ input: EventInput<"hook.failure">,
1212
+ options?: BoundaryOptions,
1213
+ ): Promise<BoundaryResult<"hook.failure">> {
1214
+ return this.boundary("hook.failure", input, options);
1215
+ }
1216
+ }
1217
+
1218
+ // Return independent copies of prepared or synthesized payloads. Never read an
1219
+ // unselected producer stream solely to construct a result.
1220
+ async function resultEvent(
1221
+ value: any,
1222
+ manager: ContentManager,
1223
+ localSources = new Set<ReadableStream<Uint8Array>>(),
1224
+ ): Promise<any> {
1225
+ if (value instanceof ContentSource) value = value.stream;
1226
+ if (value instanceof ReadableStream) {
1227
+ const bytes = localSources.has(value)
1228
+ ? await manager.readBody(value)
1229
+ : await manager.copySnapshot(value);
1230
+ if (!bytes) return undefined;
1231
+ return new ReadableStream<Uint8Array>({
1232
+ start(controller) {
1233
+ controller.enqueue(bytes);
1234
+ controller.close();
1235
+ },
1236
+ });
1237
+ }
1238
+ if (Array.isArray(value))
1239
+ return Promise.all(
1240
+ value.map((child) => resultEvent(child, manager, localSources)),
1241
+ );
1242
+ if (value && typeof value === "object") {
1243
+ const entries = await Promise.all(
1244
+ Object.entries(value).map(async ([key, child]) =>
1245
+ [key, await resultEvent(child, manager, localSources)] as const),
1246
+ );
1247
+ const copy = Object.fromEntries(
1248
+ entries.filter(([, child]) => child !== undefined),
1249
+ );
1250
+ if (
1251
+ (value.body instanceof ReadableStream || value.body instanceof ContentSource) &&
1252
+ !copy.body
1253
+ )
1254
+ copy.selection = "metadata";
1255
+ return copy;
1256
+ }
1257
+ return value;
1258
+ }
1259
+
1260
+ function cloneInput(value: any): any {
1261
+ if (value instanceof ContentSource) return value.stream;
1262
+ if (value instanceof ReadableStream) return value;
1263
+ if (Array.isArray(value)) return value.map(cloneInput);
1264
+ if (value && typeof value === "object")
1265
+ return Object.fromEntries(
1266
+ Object.entries(value).map(([k, v]) => [k, cloneInput(v)]),
1267
+ );
1268
+ return value;
1269
+ }
1270
+ function projectNative(event: any, includeNative?: boolean): any {
1271
+ const copy = cloneInput(event);
1272
+ if (includeNative !== true) delete copy.native;
1273
+ return copy;
1274
+ }
1275
+ // Only known capability fields carry permission semantics. Extension fields at
1276
+ // every level remain opaque; their values must not affect local narrowing.
1277
+ const modifyOperations = { replace: true, merge: true };
1278
+ const capabilitySemantics = {
1279
+ effects: true,
1280
+ elicitation: { form: {}, url: {} },
1281
+ flow: {
1282
+ operations: true,
1283
+ continuationCount: true,
1284
+ maxContinuations: true,
1285
+ remainingContinuations: true,
1286
+ },
1287
+ inject: { context: { append: true, deliverAt: true } },
1288
+ modify: Object.fromEntries(
1289
+ [
1290
+ "content",
1291
+ "input",
1292
+ "instructions",
1293
+ "output",
1294
+ "prompt",
1295
+ "request",
1296
+ "response",
1297
+ "summary",
1298
+ "workspace",
1299
+ ].map((target) => [target, modifyOperations]),
1300
+ ),
1301
+ };
1302
+ function isNarrower(
1303
+ next: any,
1304
+ previous: any,
1305
+ semantics: any = capabilitySemantics,
1306
+ ): boolean {
1307
+ if (semantics === true) {
1308
+ if (Array.isArray(next))
1309
+ return Array.isArray(previous) && next.every((v) => previous.includes(v));
1310
+ if (typeof next === "number" && typeof previous === "number")
1311
+ return next <= previous;
1312
+ return next === previous || next === false;
1313
+ }
1314
+ return Object.entries(next).every(
1315
+ ([key, value]) =>
1316
+ !Object.hasOwn(semantics, key) ||
1317
+ (key === "continuationCount"
1318
+ ? typeof value === "number" && value >= (previous?.[key] ?? 0)
1319
+ : (semantics[key] === true || previous?.[key] !== undefined) &&
1320
+ isNarrower(value, previous?.[key], semantics[key])),
1321
+ );
1322
+ }
1323
+ function combineSignals(first: AbortSignal, second?: AbortSignal): AbortSignal {
1324
+ return second ? AbortSignal.any([first, second]) : first;
1325
+ }
1326
+ function raceAbort<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
1327
+ return new Promise((resolve, reject) => {
1328
+ const abort = () => reject(signal.reason);
1329
+ signal.addEventListener("abort", abort, { once: true });
1330
+ promise
1331
+ .then(resolve, reject)
1332
+ .finally(() => signal.removeEventListener("abort", abort));
1333
+ // The operation already exists: own its rejection even if cancellation
1334
+ // occurred synchronously while constructing it.
1335
+ if (signal.aborted) abort();
1336
+ });
1337
+ }
1338
+
1339
+ function secureEndpoint(value: string): void {
1340
+ const url = new URL(value);
1341
+ const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
1342
+ if (
1343
+ url.username ||
1344
+ url.password ||
1345
+ url.hash ||
1346
+ !(url.protocol === "https:" || (url.protocol === "http:" && loopback))
1347
+ )
1348
+ throw new Error("Unsafe endpoint");
1349
+ }
1350
+
1351
+ function deadlineAfter(ms: number, parent: AbortSignal) {
1352
+ const controller = new AbortController();
1353
+ const expires = Date.now() + ms;
1354
+ const timer = setTimeout(
1355
+ () =>
1356
+ controller.abort(
1357
+ new DOMException("Hook deadline exceeded", "TimeoutError"),
1358
+ ),
1359
+ ms,
1360
+ );
1361
+ const signal = combineSignals(parent, controller.signal);
1362
+ return {
1363
+ signal,
1364
+ dispose: () => clearTimeout(timer),
1365
+ check: () => {
1366
+ if (Date.now() >= expires)
1367
+ controller.abort(
1368
+ new DOMException("Hook deadline exceeded", "TimeoutError"),
1369
+ );
1370
+ signal.throwIfAborted();
1371
+ },
1372
+ };
1373
+ }
1374
+
1375
+ function cancellationCode(
1376
+ signal: AbortSignal,
1377
+ ): "DEADLINE_EXCEEDED" | "INTERRUPTED" {
1378
+ return signal.reason?.name === "TimeoutError"
1379
+ ? "DEADLINE_EXCEEDED"
1380
+ : "INTERRUPTED";
1381
+ }
1382
+
1383
+ // Causes remain separate from delivery stage and never retain raw endpoint data.
1384
+ const diagnosticCauses = new WeakMap<
1385
+ DeliveryError,
1386
+ DeliveryDiagnostic["code"]
1387
+ >();
1388
+ function deliveryDiagnostic(error: DeliveryError): DeliveryDiagnostic {
1389
+ return { ...error, code: diagnosticCauses.get(error) ?? "transport" };
1390
+ }
1391
+ function classifyDiagnostic(
1392
+ cause: unknown,
1393
+ signal: AbortSignal | undefined,
1394
+ preparing: boolean,
1395
+ received: boolean,
1396
+ ): DeliveryDiagnostic["code"] {
1397
+ if (signal?.aborted)
1398
+ return signal.reason?.name === "TimeoutError"
1399
+ ? "deadline_exceeded"
1400
+ : "cancelled";
1401
+ if (cause instanceof Error && cause.name === "TimeoutError")
1402
+ return "deadline_exceeded";
1403
+ if (cause instanceof HookOperationalError) {
1404
+ if (cause.code === "JSON_RPC_ERROR") return "remote_rpc";
1405
+ if (
1406
+ [
1407
+ "MALFORMED_UTF8",
1408
+ "MALFORMED_JSON",
1409
+ "MALFORMED_JSON_RPC",
1410
+ "ID_MISMATCH",
1411
+ "INCOMPATIBLE_VERSION",
1412
+ "UNSUPPORTED_EVENT",
1413
+ "UNSUPPORTED_EFFECT",
1414
+ "MULTIPLE_EFFECTS",
1415
+ ].includes(cause.code)
1416
+ )
1417
+ return "protocol_rejection";
1418
+ }
1419
+ if (preparing) return "preparation";
1420
+ return received ? "protocol_rejection" : "transport";
1421
+ }
1422
+
1423
+ /** Only schema-derived named slots may receive out-of-band sources. */
1424
+ function bindContentSources(
1425
+ type: EventType,
1426
+ event: any,
1427
+ bindings: BoundaryOptions["contentSources"],
1428
+ ): void {
1429
+ const used = new Set<string>();
1430
+ for (const binding of bindings ?? []) {
1431
+ const source =
1432
+ binding.source instanceof ContentSource
1433
+ ? binding.source.stream
1434
+ : binding.source;
1435
+ if (!(source instanceof ReadableStream))
1436
+ throw new TypeError("Expected an owned content source");
1437
+ const path = binding.path;
1438
+ const allowed = Object.values(contentSlots[type]).some((factory) => {
1439
+ const bind = factory as (...args: any[]) => {
1440
+ path: readonly (string | number)[];
1441
+ };
1442
+ const expected = (factory.length === 2 ? bind(0, source) : bind(source))
1443
+ .path;
1444
+ return (
1445
+ path.length === expected.length &&
1446
+ expected.every((part, index) =>
1447
+ typeof part === "number"
1448
+ ? Number.isSafeInteger(path[index]) && Number(path[index]) >= 0
1449
+ : path[index] === part,
1450
+ )
1451
+ );
1452
+ });
1453
+ const key = JSON.stringify(path);
1454
+ if (!allowed || used.has(key))
1455
+ throw new TypeError("Invalid or duplicate content source slot");
1456
+ used.add(key);
1457
+ let parent = event;
1458
+ for (const part of path.slice(0, -1)) {
1459
+ parent = parent?.[part];
1460
+ if (!parent || typeof parent !== "object")
1461
+ throw new TypeError("Missing content source descriptor");
1462
+ }
1463
+ const last = path[path.length - 1]!;
1464
+ const descriptor = parent[last];
1465
+ if (descriptor && typeof descriptor === "object" && "kind" in descriptor) {
1466
+ if (descriptor.body !== undefined)
1467
+ throw new TypeError("Content source conflicts with an existing body");
1468
+ descriptor.body = source;
1469
+ } else {
1470
+ if (descriptor !== undefined)
1471
+ throw new TypeError(
1472
+ "Content source conflicts with an existing reference",
1473
+ );
1474
+ parent[last] = source;
1475
+ }
1476
+ }
1477
+ }