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