agent-relay-server 0.71.0 → 0.73.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 (142) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +2 -2
  3. package/public/assets/MessageBubble-BWdM9UCM.js +3 -0
  4. package/public/assets/MessageBubble-BWdM9UCM.js.map +1 -0
  5. package/public/assets/{PlanGraphPanel-BYmE8ldg.js → PlanGraphPanel-icESxd82.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-BYmE8ldg.js.map → PlanGraphPanel-icESxd82.js.map} +1 -1
  7. package/public/assets/{activity-BzU196gP.js → activity-BR9I23hc.js} +2 -2
  8. package/public/assets/{activity-BzU196gP.js.map → activity-BR9I23hc.js.map} +1 -1
  9. package/public/assets/{agent-profiles-CQuoP8Nr.js → agent-profiles-BB0V-Tg1.js} +2 -2
  10. package/public/assets/{agent-profiles-CQuoP8Nr.js.map → agent-profiles-BB0V-Tg1.js.map} +1 -1
  11. package/public/assets/{agents-qH6q44nK.js → agents-Clkboz93.js} +2 -2
  12. package/public/assets/{agents-qH6q44nK.js.map → agents-Clkboz93.js.map} +1 -1
  13. package/public/assets/{analytics-EJH9GFID.js → analytics-DRlHs5ho.js} +3 -3
  14. package/public/assets/{analytics-EJH9GFID.js.map → analytics-DRlHs5ho.js.map} +1 -1
  15. package/public/assets/{automation-Dahmxg07.js → automation-DlnH_MPy.js} +2 -2
  16. package/public/assets/{automation-Dahmxg07.js.map → automation-DlnH_MPy.js.map} +1 -1
  17. package/public/assets/{badge-C6NJQ60B.js → badge-YQhT30dy.js} +2 -2
  18. package/public/assets/{badge-C6NJQ60B.js.map → badge-YQhT30dy.js.map} +1 -1
  19. package/public/assets/branch-state-badge-Dg1qnmF5.js +2 -0
  20. package/public/assets/{branch-state-badge-C7H8HY22.js.map → branch-state-badge-Dg1qnmF5.js.map} +1 -1
  21. package/public/assets/{button-DD2CCM3t.js → button-CaFI76Ja.js} +2 -2
  22. package/public/assets/{button-DD2CCM3t.js.map → button-CaFI76Ja.js.map} +1 -1
  23. package/public/assets/{card-GZqj3rMm.js → card-B_fQtKce.js} +2 -2
  24. package/public/assets/{card-GZqj3rMm.js.map → card-B_fQtKce.js.map} +1 -1
  25. package/public/assets/{channels-VhlwES_A.js → channels-BVYLrKUL.js} +2 -2
  26. package/public/assets/{channels-VhlwES_A.js.map → channels-BVYLrKUL.js.map} +1 -1
  27. package/public/assets/chat-ml_YzbK4.js +2 -0
  28. package/public/assets/chat-ml_YzbK4.js.map +1 -0
  29. package/public/assets/{connectors-C03pRoXx.js → connectors-BOyk5hC3.js} +2 -2
  30. package/public/assets/{connectors-C03pRoXx.js.map → connectors-BOyk5hC3.js.map} +1 -1
  31. package/public/assets/copy-button-m00JmflM.js +2 -0
  32. package/public/assets/{copy-button-DqAoeiPC.js.map → copy-button-m00JmflM.js.map} +1 -1
  33. package/public/assets/display-LAWaAboe.js.map +1 -1
  34. package/public/assets/{dist-DByqf8W0.js → dist-B_yrHbsb.js} +2 -2
  35. package/public/assets/{dist-DByqf8W0.js.map → dist-B_yrHbsb.js.map} +1 -1
  36. package/public/assets/{dist-C_XTOHbv.js → dist-C-viWls-.js} +2 -2
  37. package/public/assets/{dist-C_XTOHbv.js.map → dist-C-viWls-.js.map} +1 -1
  38. package/public/assets/{dist-DMD9fD99.js → dist-CYS2puT9.js} +2 -2
  39. package/public/assets/{dist-DMD9fD99.js.map → dist-CYS2puT9.js.map} +1 -1
  40. package/public/assets/{dist-CpugiXtB.js → dist-ChP4KvKi.js} +2 -2
  41. package/public/assets/{dist-CpugiXtB.js.map → dist-ChP4KvKi.js.map} +1 -1
  42. package/public/assets/{dist-CePvvSwo.js → dist-Cnlh_NpI.js} +2 -2
  43. package/public/assets/{dist-CePvvSwo.js.map → dist-Cnlh_NpI.js.map} +1 -1
  44. package/public/assets/{dist-DJ6ZVqRl.js → dist-CydxJz3m.js} +2 -2
  45. package/public/assets/{dist-DJ6ZVqRl.js.map → dist-CydxJz3m.js.map} +1 -1
  46. package/public/assets/{dist-Djf_WfhW.js → dist-D7K3qgp7.js} +2 -2
  47. package/public/assets/{dist-Djf_WfhW.js.map → dist-D7K3qgp7.js.map} +1 -1
  48. package/public/assets/{dist-otKoCUDq.js → dist-D8Yr1AIJ.js} +2 -2
  49. package/public/assets/{dist-otKoCUDq.js.map → dist-D8Yr1AIJ.js.map} +1 -1
  50. package/public/assets/{dist-BauUE17c.js → dist-DeAdFM5_.js} +2 -2
  51. package/public/assets/{dist-BauUE17c.js.map → dist-DeAdFM5_.js.map} +1 -1
  52. package/public/assets/{dist-CROpCfON.js → dist-DeM72J6a.js} +2 -2
  53. package/public/assets/{dist-CROpCfON.js.map → dist-DeM72J6a.js.map} +1 -1
  54. package/public/assets/{dist-CW_EpVCQ.js → dist-DfQP9-VC.js} +2 -2
  55. package/public/assets/{dist-CW_EpVCQ.js.map → dist-DfQP9-VC.js.map} +1 -1
  56. package/public/assets/{es2015-t3pwjvKi.js → es2015-C3DnXBqu.js} +2 -2
  57. package/public/assets/{es2015-t3pwjvKi.js.map → es2015-C3DnXBqu.js.map} +1 -1
  58. package/public/assets/{formatted-body-impl-DhvR8Z-A.js → formatted-body-impl-D2F76gPY.js} +2 -2
  59. package/public/assets/{formatted-body-impl-DhvR8Z-A.js.map → formatted-body-impl-D2F76gPY.js.map} +1 -1
  60. package/public/assets/index-CQQTCZxg.css +2 -0
  61. package/public/assets/index-DYmgI4s4.js +22 -0
  62. package/public/assets/{index-VGRP16ua.js.map → index-DYmgI4s4.js.map} +1 -1
  63. package/public/assets/{input-08ItMIki.js → input-Cqqq0OFl.js} +2 -2
  64. package/public/assets/{input-08ItMIki.js.map → input-Cqqq0OFl.js.map} +1 -1
  65. package/public/assets/{insights-v9noub5E.js → insights-DylYp2q4.js} +2 -2
  66. package/public/assets/{insights-v9noub5E.js.map → insights-DylYp2q4.js.map} +1 -1
  67. package/public/assets/{integrations-BEa3ArUw.js → integrations-B9ViWoJy.js} +2 -2
  68. package/public/assets/{integrations-BEa3ArUw.js.map → integrations-B9ViWoJy.js.map} +1 -1
  69. package/public/assets/{lib-CMqRPJG-.js → lib-6L4m0E3M.js} +2 -2
  70. package/public/assets/{lib-CMqRPJG-.js.map → lib-6L4m0E3M.js.map} +1 -1
  71. package/public/assets/{lucide-react-BbMYCUlW.js → lucide-react-DI8kTavp.js} +2 -2
  72. package/public/assets/{lucide-react-BbMYCUlW.js.map → lucide-react-DI8kTavp.js.map} +1 -1
  73. package/public/assets/{maintenance-DyRfVkhp.js → maintenance-inyZGLys.js} +2 -2
  74. package/public/assets/{maintenance-DyRfVkhp.js.map → maintenance-inyZGLys.js.map} +1 -1
  75. package/public/assets/{managed-agents-BSdMoBgG.js → managed-agents-CEFvMPN-.js} +2 -2
  76. package/public/assets/{managed-agents-BSdMoBgG.js.map → managed-agents-CEFvMPN-.js.map} +1 -1
  77. package/public/assets/{markdown-preview-impl-DidsrMmn.js → markdown-preview-impl-CniyG4_g.js} +2 -2
  78. package/public/assets/{markdown-preview-impl-DidsrMmn.js.map → markdown-preview-impl-CniyG4_g.js.map} +1 -1
  79. package/public/assets/memory-DDYGuRzr.js +2 -0
  80. package/public/assets/{memory-DM8muGVW.js.map → memory-DDYGuRzr.js.map} +1 -1
  81. package/public/assets/{messages-BqjAkcBF.js → messages-BMFcsMMa.js} +2 -2
  82. package/public/assets/{messages-BqjAkcBF.js.map → messages-BMFcsMMa.js.map} +1 -1
  83. package/public/assets/{orchestrators-C_mjUDZg.js → orchestrators-D5nL4a52.js} +2 -2
  84. package/public/assets/{orchestrators-C_mjUDZg.js.map → orchestrators-D5nL4a52.js.map} +1 -1
  85. package/public/assets/{overview-Cpu2plX-.js → overview-DywDrPKE.js} +2 -2
  86. package/public/assets/{overview-Cpu2plX-.js.map → overview-DywDrPKE.js.map} +1 -1
  87. package/public/assets/{pairs--8_0A0lq.js → pairs-UvIyBk4w.js} +2 -2
  88. package/public/assets/{pairs--8_0A0lq.js.map → pairs-UvIyBk4w.js.map} +1 -1
  89. package/public/assets/{projects-nCww9PG1.js → projects-DXBjwPU5.js} +2 -2
  90. package/public/assets/{projects-nCww9PG1.js.map → projects-DXBjwPU5.js.map} +1 -1
  91. package/public/assets/{react-dom-HJwa4sZ-.js → react-dom-Ct8qXivQ.js} +2 -2
  92. package/public/assets/{react-dom-HJwa4sZ-.js.map → react-dom-Ct8qXivQ.js.map} +1 -1
  93. package/public/assets/{security-CwgoEvJT.js → security-Boe7MEIz.js} +2 -2
  94. package/public/assets/{security-CwgoEvJT.js.map → security-Boe7MEIz.js.map} +1 -1
  95. package/public/assets/{select-Bhx6zSZr.js → select-Dh5Q8rJ8.js} +2 -2
  96. package/public/assets/{select-Bhx6zSZr.js.map → select-Dh5Q8rJ8.js.map} +1 -1
  97. package/public/assets/{settings-BNQBnKXg.js → settings-Ch4jMDrE.js} +3 -3
  98. package/public/assets/{settings-BNQBnKXg.js.map → settings-Ch4jMDrE.js.map} +1 -1
  99. package/public/assets/{store-BB-YW24l.js → store-BRvMMH5T.js} +2 -2
  100. package/public/assets/{store-BB-YW24l.js.map → store-BRvMMH5T.js.map} +1 -1
  101. package/public/assets/{tasks-C5Kv18N7.js → tasks-DrW8R52c.js} +2 -2
  102. package/public/assets/{tasks-C5Kv18N7.js.map → tasks-DrW8R52c.js.map} +1 -1
  103. package/public/assets/{teams-D_Ttv7qD.js → teams-CJqpAwmO.js} +2 -2
  104. package/public/assets/{teams-D_Ttv7qD.js.map → teams-CJqpAwmO.js.map} +1 -1
  105. package/public/assets/{terminal-viewer-impl-5y8v9jNx.js → terminal-viewer-impl-1_7v8GfU.js} +3 -3
  106. package/public/assets/{terminal-viewer-impl-5y8v9jNx.js.map → terminal-viewer-impl-1_7v8GfU.js.map} +1 -1
  107. package/public/assets/{use-keyboard-viewport-CEmOxaNm.js → use-keyboard-viewport-DxmCAbQi.js} +2 -2
  108. package/public/assets/{use-keyboard-viewport-CEmOxaNm.js.map → use-keyboard-viewport-DxmCAbQi.js.map} +1 -1
  109. package/public/assets/work-queue-BnIi7evb.js +2 -0
  110. package/public/assets/{work-queue-BB7bYLIK.js.map → work-queue-BnIi7evb.js.map} +1 -1
  111. package/public/assets/workspaces-u5j6mOlA.js +3 -0
  112. package/public/assets/{workspaces-CrtAeVnv.js.map → workspaces-u5j6mOlA.js.map} +1 -1
  113. package/public/index.html +21 -21
  114. package/runner/src/adapter.ts +7 -1
  115. package/runner/src/config.ts +4 -0
  116. package/src/db/message-reads.ts +8 -46
  117. package/src/db/messages.ts +208 -0
  118. package/src/db/notification-subscriptions.ts +68 -12
  119. package/src/index.ts +1 -1
  120. package/src/mcp/tools-spawn.ts +1 -1
  121. package/src/mcp-notification-subscription-tools.ts +90 -4
  122. package/src/memory-service.ts +14 -0
  123. package/src/notification-router.ts +9 -75
  124. package/src/notification-rules.ts +3 -3
  125. package/src/notification-subscription-rules.ts +11 -0
  126. package/src/notification-types.ts +70 -0
  127. package/src/project-instructions.ts +82 -9
  128. package/src/services/send-message.ts +5 -0
  129. package/src/services/spawn-agent.ts +13 -12
  130. package/src/spawn-command.ts +2 -0
  131. package/src/sse.ts +39 -2
  132. package/public/assets/MessageBubble-DPcJxTJj.js +0 -3
  133. package/public/assets/MessageBubble-DPcJxTJj.js.map +0 -1
  134. package/public/assets/branch-state-badge-C7H8HY22.js +0 -2
  135. package/public/assets/chat-DJqvtoFT.js +0 -2
  136. package/public/assets/chat-DJqvtoFT.js.map +0 -1
  137. package/public/assets/copy-button-DqAoeiPC.js +0 -2
  138. package/public/assets/index-Dh7NjR-l.css +0 -2
  139. package/public/assets/index-VGRP16ua.js +0 -22
  140. package/public/assets/memory-DM8muGVW.js +0 -2
  141. package/public/assets/work-queue-BB7bYLIK.js +0 -2
  142. package/public/assets/workspaces-CrtAeVnv.js +0 -3
@@ -22,6 +22,7 @@ import { enforceQueueLimit, isChannelAgentId, matchingDeliveryAgents } from "./d
22
22
  import { MSG_SELECT, normalizeReactionEmoji, rowToMessage } from "./mappers.ts";
23
23
  import { claimMessageRow } from "./message-claims.ts";
24
24
  import { getMessage } from "./message-queries.ts";
25
+ import { childStreamSubscribed, type ChildStream } from "./notification-subscriptions.ts";
25
26
  import { runningAgentForPolicy } from "./policy-runtime.ts";
26
27
  import { getTask, releaseExpiredClaims } from "./tasks.ts";
27
28
  import { insertTaskEvent } from "./task-core.ts";
@@ -157,6 +158,7 @@ function inferMessageKind(input: SendMessageInput): Message["kind"] {
157
158
  }
158
159
 
159
160
  const REPLY_DUPLICATE_WINDOW_MS = 2 * 60 * 1000;
161
+ const LINEAGE_REPORT_DUPLICATE_WINDOW_MS = 2 * 60 * 1000;
160
162
 
161
163
  function findRecentDuplicateReply(input: SendMessageInput, threadId: number | null, now: number, hasAttachments: boolean): Message | null {
162
164
  if (!input.replyTo || threadId === null || hasAttachments) return null;
@@ -186,6 +188,209 @@ function findRecentDuplicateReply(input: SendMessageInput, threadId: number | nu
186
188
  return row ? rowToMessage(row) : null;
187
189
  }
188
190
 
191
+ function sessionMeta(input: Message | SendMessageInput): Record<string, unknown> | undefined {
192
+ return isRecord(input.payload?.session) ? input.payload.session : undefined;
193
+ }
194
+
195
+ function responseCaptureMeta(input: Message | SendMessageInput): Record<string, unknown> | undefined {
196
+ return isRecord(input.payload?.responseCapture) ? input.payload.responseCapture : undefined;
197
+ }
198
+
199
+ function isFinalSessionResponse(session: Record<string, unknown>): boolean {
200
+ return session.type === "response" && session.final === true;
201
+ }
202
+
203
+ function childStreamForSession(session: Record<string, unknown>): ChildStream | null {
204
+ if (session.type === "response") return "responses";
205
+ if (session.type === "tool") return "toolCalls";
206
+ if (session.type === "reasoning") return "reasoning";
207
+ if (session.type === "narration" || session.type === "narrative") return "narrative";
208
+ return null;
209
+ }
210
+
211
+ function captureTurnId(capture: Record<string, unknown>): string | undefined {
212
+ return stringValue(capture.turnId);
213
+ }
214
+
215
+ function lineageResponseTurnId(message: Message, session: Record<string, unknown>): string {
216
+ return stringValue(session.turnId) ?? `message-${message.id}`;
217
+ }
218
+
219
+ function lineageResponseReportKeyForTurn(childId: string, turnId: string): string {
220
+ return `response-report:${childId}:${turnId}`;
221
+ }
222
+
223
+ function lineageResponseReportKey(message: Message, session: Record<string, unknown>): string {
224
+ return lineageResponseReportKeyForTurn(message.from, lineageResponseTurnId(message, session));
225
+ }
226
+
227
+ function lineageStreamTurnId(message: Message, session: Record<string, unknown>): string {
228
+ return stringValue(session.turnId) ?? `message-${message.id}`;
229
+ }
230
+
231
+ function lineageStreamEventId(message: Message, session: Record<string, unknown>): string {
232
+ const occurredAt = message.occurredAt ?? message.createdAt;
233
+ const stepId = stringValue(session.stepId);
234
+ return stepId ? `${stepId}:${occurredAt}` : `message:${occurredAt}`;
235
+ }
236
+
237
+ function lineageStreamReportKey(message: Message, session: Record<string, unknown>, stream: ChildStream): string {
238
+ if (stream === "responses") return lineageResponseReportKey(message, session);
239
+ return `${stream}-report:${message.from}:${lineageStreamTurnId(message, session)}:${lineageStreamEventId(message, session)}`;
240
+ }
241
+
242
+ function findLineageReportByTurn(input: {
243
+ from: string;
244
+ to: string;
245
+ turnId: string;
246
+ since: number;
247
+ }): Message | null {
248
+ const lineageKey = lineageResponseReportKeyForTurn(input.from, input.turnId);
249
+ const row = getDb().query(`
250
+ ${MSG_SELECT}
251
+ WHERE m.from_agent = ?
252
+ AND m.to_target = ?
253
+ AND m.kind = 'chat'
254
+ AND m.created_at >= ?
255
+ AND (
256
+ m.idempotency_key = ?
257
+ OR m.idempotency_key LIKE ?
258
+ OR (
259
+ json_extract(m.payload, '$.responseCapture.type') = 'response'
260
+ AND json_extract(m.payload, '$.responseCapture.turnId') = ?
261
+ )
262
+ )
263
+ ORDER BY m.id DESC
264
+ LIMIT 1
265
+ `).get(
266
+ input.from,
267
+ input.to,
268
+ input.since,
269
+ lineageKey,
270
+ `response-report:${input.from}:%:${input.turnId}`,
271
+ input.turnId,
272
+ ) as any;
273
+ return row ? rowToMessage(row) : null;
274
+ }
275
+
276
+ function findRecentLineageReport(input: SendMessageInput, now: number): Message | null {
277
+ if ((input.kind ?? "chat") !== "chat") return null;
278
+ const from = input.from;
279
+ const to = input.to;
280
+ if (!from || !to) return null;
281
+ const child = getAgent(from);
282
+ if (!child?.spawnedBy || child.spawnedBy !== to) return null;
283
+ const responseCapture = responseCaptureMeta(input);
284
+ const turnId = responseCapture ? captureTurnId(responseCapture) : undefined;
285
+ if (turnId) {
286
+ const duplicate = findLineageReportByTurn({
287
+ from,
288
+ to,
289
+ turnId,
290
+ since: now - LINEAGE_REPORT_DUPLICATE_WINDOW_MS,
291
+ });
292
+ if (duplicate) return duplicate;
293
+ }
294
+ if (responseCapture) return null;
295
+ const row = getDb().query(`
296
+ ${MSG_SELECT}
297
+ WHERE m.from_agent = ?
298
+ AND m.to_target = ?
299
+ AND m.kind = 'chat'
300
+ AND m.body = ?
301
+ AND m.created_at >= ?
302
+ AND (
303
+ m.idempotency_key LIKE ?
304
+ OR json_extract(m.payload, '$.responseCapture.type') = 'response'
305
+ )
306
+ ORDER BY m.id DESC
307
+ LIMIT 1
308
+ `).get(from, to, input.body, now - LINEAGE_REPORT_DUPLICATE_WINDOW_MS, `response-report:${from}:%`) as any;
309
+ return row ? rowToMessage(row) : null;
310
+ }
311
+
312
+ function findRecentExplicitLineageDuplicate(input: {
313
+ from: string;
314
+ to: string;
315
+ body: string;
316
+ since: number;
317
+ }): Message | null {
318
+ const row = getDb().query(`
319
+ ${MSG_SELECT}
320
+ WHERE m.from_agent = ?
321
+ AND m.to_target = ?
322
+ AND m.kind = 'chat'
323
+ AND m.body = ?
324
+ AND m.created_at >= ?
325
+ ORDER BY m.id DESC
326
+ LIMIT 1
327
+ `).get(
328
+ input.from,
329
+ input.to,
330
+ input.body,
331
+ input.since,
332
+ ) as any;
333
+ return row ? rowToMessage(row) : null;
334
+ }
335
+
336
+ export function promoteLineageCapturedResponse(message: Message): { message: Message; created: boolean } | null {
337
+ if (message.kind !== "session") return null;
338
+ const session = sessionMeta(message);
339
+ if (!session) return null;
340
+ const stream = childStreamForSession(session);
341
+ if (!stream) return null;
342
+ if (stream === "responses" && !isFinalSessionResponse(session)) return null;
343
+ const child = getAgent(message.from);
344
+ const parent = child?.spawnedBy;
345
+ if (!parent) return null;
346
+ if (stream !== "responses" && !childStreamSubscribed({ parentId: parent, child, stream })) return null;
347
+
348
+ const idempotencyKey = lineageStreamReportKey(message, session, stream);
349
+ const existing = findMessageByIdempotencyKey(message.from, idempotencyKey);
350
+ if (existing) return { message: existing, created: false };
351
+ if (stream === "responses") {
352
+ const turnId = captureTurnId(session);
353
+ if (turnId) {
354
+ const crossKeyDuplicate = findLineageReportByTurn({
355
+ from: message.from,
356
+ to: parent,
357
+ turnId,
358
+ since: message.createdAt - LINEAGE_REPORT_DUPLICATE_WINDOW_MS,
359
+ });
360
+ if (crossKeyDuplicate) return { message: crossKeyDuplicate, created: false };
361
+ }
362
+
363
+ const duplicate = findRecentExplicitLineageDuplicate({
364
+ from: message.from,
365
+ to: parent,
366
+ body: message.body,
367
+ since: message.createdAt - LINEAGE_REPORT_DUPLICATE_WINDOW_MS,
368
+ });
369
+ if (duplicate && !responseCaptureMeta(duplicate)) {
370
+ getDb().query(`
371
+ UPDATE messages
372
+ SET idempotency_key = coalesce(idempotency_key, ?),
373
+ payload = ?
374
+ WHERE id = ?
375
+ `).run(idempotencyKey, JSON.stringify({ ...(duplicate.payload ?? {}), responseCapture: { ...session } }), duplicate.id);
376
+ return { message: getMessage(duplicate.id) ?? duplicate, created: false };
377
+ }
378
+ }
379
+
380
+ return sendMessageWithResult({
381
+ from: message.from,
382
+ to: parent,
383
+ kind: "chat",
384
+ body: message.body,
385
+ replyExpected: false,
386
+ idempotencyKey,
387
+ payload: stream === "responses"
388
+ ? { responseCapture: { ...session } }
389
+ : { childStreamCapture: { stream, ...session } },
390
+ occurredAt: message.occurredAt ?? message.createdAt,
391
+ });
392
+ }
393
+
189
394
  // Event time may be queued-then-backfilled, so it can legitimately be older than the
190
395
  // receive time — but it must be a sane epoch-ms value. Returns null (column stays NULL, so
191
396
  // readers fall back to created_at) for absent/invalid values or anything more than a minute
@@ -206,6 +411,9 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
206
411
  }
207
412
  validateAttachmentRefs(attachmentRefs);
208
413
 
414
+ const lineageDuplicate = findRecentLineageReport(input, now);
415
+ if (lineageDuplicate) return { message: lineageDuplicate, created: false };
416
+
209
417
  if (input.idempotencyKey) {
210
418
  const existing = findMessageByIdempotencyKey(input.from, input.idempotencyKey);
211
419
  if (existing) {
@@ -1,16 +1,34 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { isRecord } from "agent-relay-sdk";
3
- import { DEFAULT_TTL_MS, type NotificationScope, type NotificationType } from "../notification-router";
4
- import { setSubscriptionRules, type NotificationRule, type NotificationRuleContext, type RuleDecision } from "../notification-rules";
3
+ import { DEFAULT_TTL_MS, type NotificationScope, type NotificationType } from "../notification-types";
5
4
  import type { AgentCard } from "../types";
6
5
  import { parseJson } from "../utils";
7
6
  import { getDb, ValidationError } from "./connection";
8
7
 
9
- export const NOTIFICATION_SUBSCRIPTION_TYPES = ["*", ...Object.keys(DEFAULT_TTL_MS)] as Array<NotificationType | "*">;
8
+ export const CHILD_STREAMS = ["responses", "toolCalls", "reasoning", "narrative"] as const;
9
+ const CONFIGURABLE_CHILD_STREAMS = ["toolCalls", "reasoning", "narrative"] as const;
10
+ export type ChildStream = (typeof CHILD_STREAMS)[number];
11
+ export type ConfigurableChildStream = (typeof CONFIGURABLE_CHILD_STREAMS)[number];
12
+ type ChildStreamSubscriptionType = `child_stream.${ConfigurableChildStream}`;
13
+
14
+ const CHILD_STREAM_SUBSCRIPTION_TYPES = CONFIGURABLE_CHILD_STREAMS.map(
15
+ (stream) => `child_stream.${stream}` as ChildStreamSubscriptionType,
16
+ );
17
+ export const NOTIFICATION_SUBSCRIPTION_TYPES = [
18
+ "*",
19
+ ...Object.keys(DEFAULT_TTL_MS),
20
+ ...CHILD_STREAM_SUBSCRIPTION_TYPES,
21
+ ] as Array<NotificationSubscriptionType>;
10
22
  export const NOTIFICATION_SUBSCRIPTION_MODES = ["opt_in", "opt_out", "mute"] as const;
11
23
 
12
- type NotificationSubscriptionType = NotificationType | "*";
24
+ type NotificationSubscriptionType = NotificationType | "*" | ChildStreamSubscriptionType;
13
25
  export type NotificationSubscriptionMode = (typeof NOTIFICATION_SUBSCRIPTION_MODES)[number];
26
+ type SubscriptionRuleDecision = "deliver" | "suppress" | "abstain";
27
+ interface SubscriptionRuleContext {
28
+ type: NotificationType;
29
+ scope: NotificationScope;
30
+ now: number;
31
+ }
14
32
 
15
33
  interface NotificationSubscription {
16
34
  id: string;
@@ -100,14 +118,14 @@ export function listSubscriptions(agentId: string): NotificationSubscription[] {
100
118
  return rows.map(rowToSubscription);
101
119
  }
102
120
 
103
- export function subscriptionDecisionFor(agent: AgentCard, ctx: NotificationRuleContext): RuleDecision {
121
+ export function subscriptionDecisionFor(agent: AgentCard, ctx: SubscriptionRuleContext): SubscriptionRuleDecision {
104
122
  const rows = getDb().query(`
105
123
  SELECT * FROM notification_subscriptions
106
124
  WHERE agent_id = ? AND (type = ? OR type = '*')
107
125
  ORDER BY created_at ASC, id ASC
108
126
  `).all(agent.id, ctx.type) as NotificationSubscriptionRow[];
109
127
 
110
- let decision: RuleDecision = "abstain";
128
+ let decision: SubscriptionRuleDecision = "abstain";
111
129
  for (const row of rows) {
112
130
  const subscription = rowToSubscription(row);
113
131
  if (!scopeMatches(subscription.scopeMatch, ctx.scope)) continue;
@@ -116,13 +134,51 @@ export function subscriptionDecisionFor(agent: AgentCard, ctx: NotificationRuleC
116
134
  return decision;
117
135
  }
118
136
 
119
- const subscriptionRule: NotificationRule = {
120
- name: "subscription:store",
121
- evaluate: subscriptionDecisionFor,
122
- };
137
+ export function childStreamSubscriptionType(stream: ConfigurableChildStream): ChildStreamSubscriptionType {
138
+ return `child_stream.${stream}`;
139
+ }
140
+
141
+ export function childStreamFromSubscriptionType(type: NotificationSubscriptionType): ConfigurableChildStream | null {
142
+ if (!type.startsWith("child_stream.")) return null;
143
+ const stream = type.slice("child_stream.".length);
144
+ return CONFIGURABLE_CHILD_STREAMS.includes(stream as ConfigurableChildStream)
145
+ ? stream as ConfigurableChildStream
146
+ : null;
147
+ }
148
+
149
+ export function cleanChildStream(value: unknown): ChildStream {
150
+ if (typeof value !== "string" || !CHILD_STREAMS.includes(value as ChildStream)) {
151
+ throw new ValidationError(`stream must be one of: ${CHILD_STREAMS.join(", ")}`);
152
+ }
153
+ return value as ChildStream;
154
+ }
123
155
 
124
- export function installNotificationSubscriptionRule(): void {
125
- setSubscriptionRules([subscriptionRule]);
156
+ export function childStreamSubscribed(input: {
157
+ parentId: string;
158
+ child: Pick<AgentCard, "id" | "teamId">;
159
+ stream: ChildStream;
160
+ }): boolean {
161
+ if (input.stream === "responses") return true;
162
+ const parentId = cleanAgentId(input.parentId);
163
+ const type = childStreamSubscriptionType(input.stream);
164
+ const scope: NotificationScope = {
165
+ parent: parentId,
166
+ agentId: input.child.id,
167
+ ...(input.child.teamId ? { teamId: input.child.teamId } : {}),
168
+ };
169
+ const rows = getDb().query(`
170
+ SELECT * FROM notification_subscriptions
171
+ WHERE agent_id = ? AND type = ?
172
+ ORDER BY created_at ASC, id ASC
173
+ `).all(parentId, type) as NotificationSubscriptionRow[];
174
+
175
+ let subscribed = false;
176
+ for (const row of rows) {
177
+ const subscription = rowToSubscription(row);
178
+ if (!scopeMatches(subscription.scopeMatch, scope)) continue;
179
+ subscribed = subscription.mode === "opt_in";
180
+ }
181
+ return subscribed;
126
182
  }
127
183
 
128
184
  function getSubscription(id: string): NotificationSubscription | null {
package/src/index.ts CHANGED
@@ -10,7 +10,7 @@ import { getLifecycleManager } from "./lifecycle-manager";
10
10
  import { getCompactionWatch } from "./compaction-watch";
11
11
  import { getConfig, setConfig } from "./config-store";
12
12
  import { startConnectorStatusPoller } from "./connectors";
13
- import { installNotificationSubscriptionRule } from "./db/notification-subscriptions";
13
+ import { installNotificationSubscriptionRule } from "./notification-subscription-rules";
14
14
  import { startPlanLiveBindings } from "./services/plan-live-bindings";
15
15
  import { resolve, sep } from "path";
16
16
  import { gzipSync, brotliCompressSync, constants as zlibConstants } from "node:zlib";
@@ -30,7 +30,7 @@ export const SPAWN_TOOLS: ToolDefinition[] = [
30
30
  effort: { type: "string", enum: VALID_EFFORTS },
31
31
  approvalMode: { type: "string", enum: APPROVAL_MODES, description: "Permission posture for the worker. Defaults to YOUR OWN approval mode when omitted (a headless `guarded` worker wedges on the first approval prompt), so a coordinator running `open` spawns workers that can act autonomously. Pass an explicit value to narrow a child (e.g. `read-only` reviewer)." },
32
32
  prompt: { type: "string", description: "Initial task/message delivered to the agent on launch — spawn and hand it its first instruction in one call (no separate follow-up message needed)." },
33
- expectReply: { type: "boolean", description: "Require the worker to report back: it gets a pending reply obligation to you and CANNOT go idle until it delivers its result (which routes to your inbox and wakes you). Defaults true when you pass a prompt set false for a true fire-and-forget worker that needn't report (e.g. one that only lands a branch)." },
33
+ expectReply: { type: "boolean", description: "Legacy compatibility flag. Spawned workers now report up automatically: each turn-final response is routed to the spawner by lineage, without a reply obligation or idle nag." },
34
34
  systemPromptAppend: { type: "string" },
35
35
  projectInstructions: { type: "object", description: "#409 — project instruction projection (default OFF). When set, relay composes a knowledge preamble from the spawn cwd's resolved project (own + inherited entries) and prepends it to systemPromptAppend, instead of relying on host CLAUDE.md. `policy: \"all\"` includes ingested host content; `\"vanilla\"` excludes it (manual+promoted only). Omit to keep current behavior.", properties: { policy: { type: "string", enum: PROJECT_INSTRUCTION_POLICY_NAMES } }, additionalProperties: false },
36
36
  profile: { type: "string", description: `Agent profile name to apply (env, instructions, permissions, MCP/skills, spawn quota). Omit to use \`${DEFAULT_SPAWN_PROFILE_NAME}\`.` },
@@ -1,23 +1,29 @@
1
1
  import {
2
2
  addSubscription,
3
+ childStreamFromSubscriptionType,
4
+ childStreamSubscriptionType,
5
+ cleanChildStream,
3
6
  cleanSubscriptionMode,
4
7
  cleanSubscriptionType,
8
+ CHILD_STREAMS,
5
9
  listSubscriptions,
6
10
  NOTIFICATION_SUBSCRIPTION_MODES,
7
11
  NOTIFICATION_SUBSCRIPTION_TYPES,
8
12
  removeSubscription,
13
+ type ConfigurableChildStream,
14
+ type ChildStream,
9
15
  type NotificationSubscriptionMode,
10
16
  } from "./db/notification-subscriptions";
11
17
  import { ValidationError } from "./db";
12
18
  import { McpAuthError } from "./mcp-errors";
13
19
  import { authContextFromMcp } from "./services/auth-context";
14
20
  import { cleanString } from "./validation";
15
- import type { NotificationScope } from "./notification-router";
21
+ import type { NotificationScope } from "./notification-types";
16
22
 
17
23
  export const NOTIFICATION_SUBSCRIPTION_TOOLS = [
18
24
  {
19
25
  name: "relay_subscribe",
20
- description: "Subscribe, opt out, or mute this caller agent for a notification type. The caller's token identity is the subscription owner.",
26
+ description: "Subscribe, opt out, or mute this caller agent for a notification type, or opt into child streams. Child response streams are always on; toolCalls/reasoning/narrative are opt-in.",
21
27
  requiredScopes: ["notifications:write"],
22
28
  inputSchema: {
23
29
  type: "object",
@@ -25,8 +31,11 @@ export const NOTIFICATION_SUBSCRIPTION_TOOLS = [
25
31
  type: { type: "string", enum: NOTIFICATION_SUBSCRIPTION_TYPES },
26
32
  mode: { type: "string", enum: NOTIFICATION_SUBSCRIPTION_MODES, description: "Default opt_in. mute and opt_out suppress matching default deliveries." },
27
33
  scope: { type: "object", description: "Optional partial notification scope match, such as { teamId } or { repoRoot }." },
34
+ streams: { type: "array", items: { type: "string", enum: CHILD_STREAMS }, description: "Child streams to opt into. responses is always on and is not persisted." },
35
+ childAgentId: { type: "string", description: "Limit child stream subscriptions to one spawned child." },
36
+ teamId: { type: "string", description: "Limit child stream subscriptions to children on a team." },
37
+ allChildren: { type: "boolean", description: "Subscribe for all children spawned by this caller. This is the default when childAgentId/teamId are omitted." },
28
38
  },
29
- required: ["type"],
30
39
  additionalProperties: false,
31
40
  },
32
41
  },
@@ -41,6 +50,10 @@ export const NOTIFICATION_SUBSCRIPTION_TOOLS = [
41
50
  type: { type: "string", enum: NOTIFICATION_SUBSCRIPTION_TYPES },
42
51
  mode: { type: "string", enum: NOTIFICATION_SUBSCRIPTION_MODES },
43
52
  scope: { type: "object" },
53
+ streams: { type: "array", items: { type: "string", enum: CHILD_STREAMS } },
54
+ childAgentId: { type: "string" },
55
+ teamId: { type: "string" },
56
+ allChildren: { type: "boolean" },
44
57
  },
45
58
  additionalProperties: false,
46
59
  },
@@ -66,6 +79,27 @@ export function relayNotificationSubscriptionTool(
66
79
  ): Record<string, unknown> {
67
80
  const agentId = callerAgentId(auth);
68
81
  if (name === "relay_subscribe") {
82
+ if (Object.prototype.hasOwnProperty.call(args, "streams")) {
83
+ const scope = cleanChildStreamScope(args, agentId);
84
+ const streams = cleanChildStreams(args.streams);
85
+ const subscriptions = streams
86
+ .filter((stream): stream is ConfigurableChildStream => stream !== "responses")
87
+ .map((stream) => addSubscription({
88
+ agentId,
89
+ type: childStreamSubscriptionType(stream),
90
+ mode: cleanModeOrDefault(args.mode),
91
+ scopeMatch: scope,
92
+ }));
93
+ return {
94
+ subscriptions,
95
+ childStreamFilter: {
96
+ scope,
97
+ active: ["responses", ...streams.filter((stream) => stream !== "responses")],
98
+ responsesAlwaysOn: true,
99
+ },
100
+ };
101
+ }
102
+ if (args.type === undefined || args.type === null) throw new ValidationError("type or streams required");
69
103
  const subscription = addSubscription({
70
104
  agentId,
71
105
  type: cleanSubscriptionType(args.type),
@@ -76,6 +110,21 @@ export function relayNotificationSubscriptionTool(
76
110
  }
77
111
  if (name === "relay_unsubscribe") {
78
112
  const id = cleanString(args.id, "id", { max: 120 });
113
+ if (Object.prototype.hasOwnProperty.call(args, "streams")) {
114
+ const scope = cleanChildStreamScope(args, agentId);
115
+ const streams = cleanChildStreams(args.streams);
116
+ let removed = 0;
117
+ for (const stream of streams) {
118
+ if (stream === "responses") continue;
119
+ removed += removeSubscription({
120
+ agentId,
121
+ type: childStreamSubscriptionType(stream),
122
+ mode: args.mode === undefined || args.mode === null ? undefined : cleanSubscriptionMode(args.mode),
123
+ scopeMatch: scope,
124
+ });
125
+ }
126
+ return { removed, responsesAlwaysOn: true };
127
+ }
79
128
  const type = args.type === undefined || args.type === null ? undefined : cleanSubscriptionType(args.type);
80
129
  if (!id && !type) throw new ValidationError("id or type required");
81
130
  const removed = removeSubscription({
@@ -88,7 +137,17 @@ export function relayNotificationSubscriptionTool(
88
137
  return { removed };
89
138
  }
90
139
  if (name === "relay_list_subscriptions") {
91
- return { subscriptions: listSubscriptions(agentId) };
140
+ const subscriptions = listSubscriptions(agentId);
141
+ return {
142
+ subscriptions,
143
+ childStreamFilter: {
144
+ default: ["responses"],
145
+ responsesAlwaysOn: true,
146
+ subscriptions: subscriptions
147
+ .map((subscription) => ({ ...subscription, stream: childStreamFromSubscriptionType(subscription.type) }))
148
+ .filter((subscription) => subscription.stream !== null),
149
+ },
150
+ };
92
151
  }
93
152
  throw new ValidationError(`unknown tool: ${name}`);
94
153
  }
@@ -109,3 +168,30 @@ function cleanScope(value: unknown): Partial<NotificationScope> | null {
109
168
  if (typeof value !== "object" || Array.isArray(value)) throw new ValidationError("scope must be an object");
110
169
  return value as Partial<NotificationScope>;
111
170
  }
171
+
172
+ function cleanChildStreams(value: unknown): ChildStream[] {
173
+ if (!Array.isArray(value) || value.length === 0) throw new ValidationError("streams must be a non-empty array");
174
+ return [...new Set(value.map(cleanChildStream))];
175
+ }
176
+
177
+ function cleanChildStreamScope(args: Record<string, unknown>, parentId: string): Partial<NotificationScope> {
178
+ const childAgentId = cleanString(args.childAgentId, "childAgentId", { max: 160 });
179
+ const teamId = cleanString(args.teamId, "teamId", { max: 160 });
180
+ if (childAgentId && teamId) throw new ValidationError("childAgentId and teamId are mutually exclusive");
181
+ const allChildren = args.allChildren === undefined || args.allChildren === null ? false : args.allChildren === true;
182
+ if (args.allChildren !== undefined && args.allChildren !== null && typeof args.allChildren !== "boolean") {
183
+ throw new ValidationError("allChildren must be a boolean");
184
+ }
185
+ if (allChildren && (childAgentId || teamId)) throw new ValidationError("allChildren cannot be combined with childAgentId or teamId");
186
+ const explicitScope = cleanScope(args.scope);
187
+ if (explicitScope) {
188
+ if (explicitScope.parent && explicitScope.parent !== parentId) throw new ValidationError("child stream scope.parent must match the caller");
189
+ if (childAgentId || teamId || allChildren) throw new ValidationError("scope cannot be combined with childAgentId, teamId, or allChildren");
190
+ return { ...explicitScope, parent: parentId };
191
+ }
192
+ return {
193
+ parent: parentId,
194
+ ...(childAgentId ? { agentId: childAgentId } : {}),
195
+ ...(teamId ? { teamId } : {}),
196
+ };
197
+ }
@@ -5,6 +5,7 @@ import { assembleContextPackage, contextBudgetForAgent, estimateMemoryTokens } f
5
5
  import { createMemoryBrokerRegistry, memoryBrokerConfigFromEnv } from "./memory-broker-registry";
6
6
  import { filterMemoriesForInjection, formatUntrustedMemoryBlock } from "./memory-broker-contract";
7
7
  import { assertMemoryCreateAllowed } from "./memory-security";
8
+ import { emitRelayInjectionEvent } from "./sse";
8
9
  import { basename } from "node:path";
9
10
  import type {
10
11
  ActiveMemoryClearReason,
@@ -88,6 +89,19 @@ export async function injectMemoryContext(input: MemoryInjectionInput): Promise<
88
89
  package: pkg,
89
90
  },
90
91
  });
92
+ emitRelayInjectionEvent({
93
+ agentId: input.agent.id,
94
+ category: "memory",
95
+ summary: `injected ${loadedMemoryIds.length} memor${loadedMemoryIds.length === 1 ? "y" : "ies"}: ${input.reason}`,
96
+ detail: {
97
+ source: input.source ?? "system",
98
+ reason: input.reason,
99
+ memoryIds: loadedMemoryIds,
100
+ content: formatUntrustedMemoryBlock(pkg, { generatedAt: input.ctx.now }),
101
+ package: pkg,
102
+ },
103
+ timestamp: input.ctx.now,
104
+ });
91
105
  return { command, package: pkg };
92
106
  }
93
107
 
@@ -1,5 +1,14 @@
1
1
  import { notifySystemMessage } from "./notify";
2
2
  import type { Message, MessageKind } from "./types";
3
+ import { DEFAULT_TTL_MS } from "./notification-types";
4
+ import type { NotificationScope, NotificationType } from "./notification-types";
5
+
6
+ export {
7
+ DEFAULT_TTL_MS,
8
+ INFORMATIONAL_TTL_MS,
9
+ type NotificationScope,
10
+ type NotificationType,
11
+ } from "./notification-types";
3
12
 
4
13
  /**
5
14
  * #440 — the single seam every agent-facing "wake" notification flows through. Before this,
@@ -16,48 +25,6 @@ import type { Message, MessageKind } from "./types";
16
25
  * `scope` (the entities a notification is ABOUT) is captured now even though #441 doesn't route on
17
26
  * it, so emitters are converted exactly once and the rule engine has its inputs the day it lands.
18
27
  */
19
- export type NotificationType =
20
- | "landed-failure"
21
- | "branch.landed"
22
- | "agent.ready"
23
- | "agent.exited"
24
- | "agent.spawn_failed"
25
- | "agent.context_threshold"
26
- | "agent.resume_budget_exhausted"
27
- | "agent.transient_land_hold"
28
- | "agent.rate_limit_resume"
29
- | "plan.bound_agent_exited"
30
- | "workspace.steward_task"
31
- | "workspace.conflict"
32
- | "workspace.steward_reassigned"
33
- | "workspace.stranded_escalation"
34
- | "workspace.auto_abandoned"
35
- | "workspace.review_request"
36
- | "workspace.review_changes_requested";
37
-
38
- /**
39
- * The entities a notification is ABOUT — the future routing inputs (#442) and the explainability
40
- * subject (#446). All optional: each emitter fills the fields it has in scope at the call site.
41
- */
42
- export interface NotificationScope {
43
- /** The agent whose work the notification concerns (branch author, workspace owner). */
44
- author?: string;
45
- /** The agent the notification is primarily about (lifecycle subject, advisory target). */
46
- agentId?: string;
47
- /** The spawn-parent that should hear about a child's lifecycle. */
48
- parent?: string;
49
- /** Team the notification belongs to (plan/coordination scope). */
50
- teamId?: string;
51
- /** Plan the notification concerns. */
52
- planId?: string;
53
- /** Repo checkout the notification concerns. */
54
- repoRoot?: string;
55
- /** Workspace the notification concerns. */
56
- workspaceId?: string;
57
- /** Files the notification concerns (path-scoped routing, #442). */
58
- paths?: string[];
59
- }
60
-
61
28
  interface NotificationMessage {
62
29
  subject?: string;
63
30
  body: string;
@@ -89,39 +56,6 @@ interface RouteNotificationInput {
89
56
  emittedAt?: number;
90
57
  }
91
58
 
92
- const MINUTES = 60 * 1000;
93
-
94
- /**
95
- * Per-type default TTL (#451). Default-SAFE: anything about the recipient's own work or any
96
- * obligation is `null` (never auto-expires — store-ahead #234 must still deliver it). Only the
97
- * genuinely now-or-never pings get a short default; a stale one of those is actively misleading:
98
- * - `agent.context_threshold` — the advisory band may have changed; old advice is wrong.
99
- * - `agent.rate_limit_resume` — "your hold cleared, resume" is pointless once the window moved.
100
- * Emitters opt INTO a shorter TTL per-call for the bystander/informational cases.
101
- */
102
- export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
103
- "landed-failure": null,
104
- "branch.landed": null,
105
- "agent.ready": null,
106
- "agent.exited": null,
107
- "agent.spawn_failed": null,
108
- "agent.context_threshold": 15 * MINUTES,
109
- "agent.resume_budget_exhausted": null,
110
- "agent.transient_land_hold": null,
111
- "agent.rate_limit_resume": 15 * MINUTES,
112
- "plan.bound_agent_exited": null,
113
- "workspace.steward_task": null,
114
- "workspace.conflict": null,
115
- "workspace.steward_reassigned": null,
116
- "workspace.stranded_escalation": null,
117
- "workspace.auto_abandoned": null,
118
- "workspace.review_request": null,
119
- "workspace.review_changes_requested": null,
120
- };
121
-
122
- /** Convenience TTL for emitter-supplied overrides on bystander/informational notifications. */
123
- export const INFORMATIONAL_TTL_MS = 30 * MINUTES;
124
-
125
59
  /**
126
60
  * Resolve the effective TTL: an explicit per-call `ttlMs` wins (including an explicit `null` =
127
61
  * never); `undefined` falls back to the per-type default.
@@ -1,7 +1,7 @@
1
1
  import type { AgentCard } from "./types";
2
2
  import { listAgents } from "./db";
3
3
  import { isAgentOnline } from "./agent-ref";
4
- import type { NotificationType, NotificationScope } from "./notification-router";
4
+ import type { NotificationType, NotificationScope } from "./notification-types";
5
5
 
6
6
  /**
7
7
  * #442 — the declarative rule engine that computes a notification's recipients FROM its `scope`,
@@ -18,9 +18,9 @@ import type { NotificationType, NotificationScope } from "./notification-router"
18
18
  * #441 explicit-recipients path. Nothing here is wired into production delivery yet — #444 does that.
19
19
  */
20
20
 
21
- export type RuleDecision = "deliver" | "suppress" | "abstain";
21
+ type RuleDecision = "deliver" | "suppress" | "abstain";
22
22
 
23
- export interface NotificationRuleContext {
23
+ interface NotificationRuleContext {
24
24
  type: NotificationType;
25
25
  scope: NotificationScope;
26
26
  /** Evaluation clock — passed in (never read via Date.now() inside a rule) so eval is testable. */