agent-relay-server 0.92.0 → 0.93.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 (157) hide show
  1. package/docs/openapi.json +29 -1
  2. package/package.json +2 -2
  3. package/public/assets/MessageBubble-DX16Hbb5.js +3 -0
  4. package/public/assets/MessageBubble-DX16Hbb5.js.map +1 -0
  5. package/public/assets/{PlanGraphPanel-CpIBMvnu.js → PlanGraphPanel-BuWerKhs.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-CpIBMvnu.js.map → PlanGraphPanel-BuWerKhs.js.map} +1 -1
  7. package/public/assets/activity-tuTEn3vn.js +2 -0
  8. package/public/assets/{activity-B6WPWNsX.js.map → activity-tuTEn3vn.js.map} +1 -1
  9. package/public/assets/{agent-profiles-CLJNeVxc.js → agent-profiles-CAwmjdDx.js} +2 -2
  10. package/public/assets/{agent-profiles-CLJNeVxc.js.map → agent-profiles-CAwmjdDx.js.map} +1 -1
  11. package/public/assets/agents-B-BSBrM5.js +2 -0
  12. package/public/assets/{agents-BebF-yQr.js.map → agents-B-BSBrM5.js.map} +1 -1
  13. package/public/assets/{analytics-C1F28oqr.js → analytics-aI_oGHtR.js} +3 -3
  14. package/public/assets/{analytics-C1F28oqr.js.map → analytics-aI_oGHtR.js.map} +1 -1
  15. package/public/assets/{automation-CyPXC1A8.js → automation-BAQM1dBh.js} +2 -2
  16. package/public/assets/{automation-CyPXC1A8.js.map → automation-BAQM1dBh.js.map} +1 -1
  17. package/public/assets/{badge-CK6ejiOh.js → badge-fjSgjnDc.js} +2 -2
  18. package/public/assets/{badge-CK6ejiOh.js.map → badge-fjSgjnDc.js.map} +1 -1
  19. package/public/assets/branch-state-badge-DaafW7sN.js +2 -0
  20. package/public/assets/{branch-state-badge-Q4W68jxj.js.map → branch-state-badge-DaafW7sN.js.map} +1 -1
  21. package/public/assets/{button-BAwKuRIq.js → button-DidMOp8n.js} +2 -2
  22. package/public/assets/{button-BAwKuRIq.js.map → button-DidMOp8n.js.map} +1 -1
  23. package/public/assets/card-CpLnx1JJ.js +2 -0
  24. package/public/assets/{card-DDxxo-Fo.js.map → card-CpLnx1JJ.js.map} +1 -1
  25. package/public/assets/{channels-BQbNtrtF.js → channels-DHjTCcE8.js} +2 -2
  26. package/public/assets/{channels-BQbNtrtF.js.map → channels-DHjTCcE8.js.map} +1 -1
  27. package/public/assets/chat-uWKl3MpS.js +2 -0
  28. package/public/assets/{chat-DfqC3kFF.js.map → chat-uWKl3MpS.js.map} +1 -1
  29. package/public/assets/{connectors-CmARSKoV.js → connectors-CI97PYOl.js} +2 -2
  30. package/public/assets/{connectors-CmARSKoV.js.map → connectors-CI97PYOl.js.map} +1 -1
  31. package/public/assets/copy-button-CKAwTG9g.js +2 -0
  32. package/public/assets/{copy-button-D3B_s7J7.js.map → copy-button-CKAwTG9g.js.map} +1 -1
  33. package/public/assets/{dist-Dmiv1tSO.js → dist-BEpGArlE.js} +2 -2
  34. package/public/assets/{dist-Dmiv1tSO.js.map → dist-BEpGArlE.js.map} +1 -1
  35. package/public/assets/dist-BZ-CpDXy.js +2 -0
  36. package/public/assets/{dist-CIbvPDQV.js.map → dist-BZ-CpDXy.js.map} +1 -1
  37. package/public/assets/{dist-DLsSqk6h.js → dist-BaDWyAAG.js} +2 -2
  38. package/public/assets/{dist-DLsSqk6h.js.map → dist-BaDWyAAG.js.map} +1 -1
  39. package/public/assets/{dist-CAFUEpK2.js → dist-BivRSJg7.js} +2 -2
  40. package/public/assets/{dist-CAFUEpK2.js.map → dist-BivRSJg7.js.map} +1 -1
  41. package/public/assets/{dist-CL2npVDe.js → dist-C9ntDbUp.js} +2 -2
  42. package/public/assets/{dist-CL2npVDe.js.map → dist-C9ntDbUp.js.map} +1 -1
  43. package/public/assets/dist-CEb4q1gZ.js +2 -0
  44. package/public/assets/{dist-DyqEeyBI.js.map → dist-CEb4q1gZ.js.map} +1 -1
  45. package/public/assets/dist-DBDYIgS5.js +2 -0
  46. package/public/assets/{dist-CoRABSO3.js.map → dist-DBDYIgS5.js.map} +1 -1
  47. package/public/assets/{dist-ERYvDMdt.js → dist-DNxK5Ibj.js} +2 -2
  48. package/public/assets/{dist-ERYvDMdt.js.map → dist-DNxK5Ibj.js.map} +1 -1
  49. package/public/assets/{dist-YjP8Gd1C.js → dist-DayffsX1.js} +2 -2
  50. package/public/assets/{dist-YjP8Gd1C.js.map → dist-DayffsX1.js.map} +1 -1
  51. package/public/assets/{dist-YQVQrtnc.js → dist-L3vQoV3T.js} +2 -2
  52. package/public/assets/{dist-YQVQrtnc.js.map → dist-L3vQoV3T.js.map} +1 -1
  53. package/public/assets/{dist-CnRv0y2x.js → dist-uFOP-AfS.js} +2 -2
  54. package/public/assets/{dist-CnRv0y2x.js.map → dist-uFOP-AfS.js.map} +1 -1
  55. package/public/assets/{es2015-Yto-4EBw.js → es2015-CJr6bLcU.js} +2 -2
  56. package/public/assets/{es2015-Yto-4EBw.js.map → es2015-CJr6bLcU.js.map} +1 -1
  57. package/public/assets/{formatted-body-impl-CrE-hFvy.js → formatted-body-impl-DuqZunxL.js} +2 -2
  58. package/public/assets/{formatted-body-impl-CrE-hFvy.js.map → formatted-body-impl-DuqZunxL.js.map} +1 -1
  59. package/public/assets/index-DLjkMB-b.css +2 -0
  60. package/public/assets/index-dMB8klzy.js +23 -0
  61. package/public/assets/index-dMB8klzy.js.map +1 -0
  62. package/public/assets/{input-CLW2gDUM.js → input-BbH2Jq-f.js} +2 -2
  63. package/public/assets/{input-CLW2gDUM.js.map → input-BbH2Jq-f.js.map} +1 -1
  64. package/public/assets/{insights-BribQHXe.js → insights-C0lZ7n8D.js} +2 -2
  65. package/public/assets/{insights-BribQHXe.js.map → insights-C0lZ7n8D.js.map} +1 -1
  66. package/public/assets/integrations-DXh00Ss-.js +2 -0
  67. package/public/assets/{integrations-D9Cx-7ma.js.map → integrations-DXh00Ss-.js.map} +1 -1
  68. package/public/assets/{lib-DdCM7GfN.js → lib-B0r9alsu.js} +2 -2
  69. package/public/assets/{lib-DdCM7GfN.js.map → lib-B0r9alsu.js.map} +1 -1
  70. package/public/assets/{lucide-react-CsPXvXMC.js → lucide-react-BCarrGVa.js} +2 -2
  71. package/public/assets/{lucide-react-CsPXvXMC.js.map → lucide-react-BCarrGVa.js.map} +1 -1
  72. package/public/assets/{maintenance-Cjol3fS_.js → maintenance-Dh9nzjYV.js} +2 -2
  73. package/public/assets/{maintenance-Cjol3fS_.js.map → maintenance-Dh9nzjYV.js.map} +1 -1
  74. package/public/assets/{managed-agents-1lZwQNHl.js → managed-agents-Bsveljn4.js} +4 -4
  75. package/public/assets/{managed-agents-1lZwQNHl.js.map → managed-agents-Bsveljn4.js.map} +1 -1
  76. package/public/assets/{markdown-preview-impl-C2WeUNFR.js → markdown-preview-impl-CYpMFis9.js} +2 -2
  77. package/public/assets/{markdown-preview-impl-C2WeUNFR.js.map → markdown-preview-impl-CYpMFis9.js.map} +1 -1
  78. package/public/assets/{memory-B7mcELEW.js → memory-DMFQMXrp.js} +2 -2
  79. package/public/assets/{memory-B7mcELEW.js.map → memory-DMFQMXrp.js.map} +1 -1
  80. package/public/assets/messages-Cnzc7j50.js +2 -0
  81. package/public/assets/{messages-B8jF9p2l.js.map → messages-Cnzc7j50.js.map} +1 -1
  82. package/public/assets/{orchestrators-CQjvBXDx.js → orchestrators-DSoDPNVS.js} +2 -2
  83. package/public/assets/{orchestrators-CQjvBXDx.js.map → orchestrators-DSoDPNVS.js.map} +1 -1
  84. package/public/assets/{overview-CgDUJOG-.js → overview-OsRxo5XQ.js} +2 -2
  85. package/public/assets/{overview-CgDUJOG-.js.map → overview-OsRxo5XQ.js.map} +1 -1
  86. package/public/assets/{pairs-BdPKQyzE.js → pairs-D4UiUhyW.js} +2 -2
  87. package/public/assets/{pairs-BdPKQyzE.js.map → pairs-D4UiUhyW.js.map} +1 -1
  88. package/public/assets/{projects-hU5KGW_T.js → projects-CYzU-Cf3.js} +2 -2
  89. package/public/assets/{projects-hU5KGW_T.js.map → projects-CYzU-Cf3.js.map} +1 -1
  90. package/public/assets/{react-dom-CEl58oxP.js → react-dom-CYpnckci.js} +2 -2
  91. package/public/assets/{react-dom-CEl58oxP.js.map → react-dom-CYpnckci.js.map} +1 -1
  92. package/public/assets/{security-BlZQ0ECI.js → security-CC2F6xdd.js} +2 -2
  93. package/public/assets/{security-BlZQ0ECI.js.map → security-CC2F6xdd.js.map} +1 -1
  94. package/public/assets/{select-CjpI0JtN.js → select-CD38f1WQ.js} +2 -2
  95. package/public/assets/{select-CjpI0JtN.js.map → select-CD38f1WQ.js.map} +1 -1
  96. package/public/assets/settings-T3B40Amb.js +6 -0
  97. package/public/assets/settings-T3B40Amb.js.map +1 -0
  98. package/public/assets/{store-DsqUEZOf.js → store-CJeafbP1.js} +3 -3
  99. package/public/assets/{store-DsqUEZOf.js.map → store-CJeafbP1.js.map} +1 -1
  100. package/public/assets/{tasks-BcHWptn4.js → tasks-BkrhWMw7.js} +2 -2
  101. package/public/assets/{tasks-BcHWptn4.js.map → tasks-BkrhWMw7.js.map} +1 -1
  102. package/public/assets/{teams-BGL-VpqZ.js → teams-DyzaREAg.js} +2 -2
  103. package/public/assets/{teams-BGL-VpqZ.js.map → teams-DyzaREAg.js.map} +1 -1
  104. package/public/assets/{terminal-viewer-impl-D8bnx7Z7.js → terminal-viewer-impl-B5JeHof3.js} +3 -3
  105. package/public/assets/{terminal-viewer-impl-D8bnx7Z7.js.map → terminal-viewer-impl-B5JeHof3.js.map} +1 -1
  106. package/public/assets/{use-keyboard-viewport-DABeIMEJ.js → use-keyboard-viewport-BYCJkZCN.js} +2 -2
  107. package/public/assets/{use-keyboard-viewport-DABeIMEJ.js.map → use-keyboard-viewport-BYCJkZCN.js.map} +1 -1
  108. package/public/assets/user-avatar-C7i11_ju.js +2 -0
  109. package/public/assets/{user-avatar-DZ1g4X7l.js.map → user-avatar-C7i11_ju.js.map} +1 -1
  110. package/public/assets/{work-queue-CIy6fe6y.js → work-queue-DHp4p1Zk.js} +2 -2
  111. package/public/assets/{work-queue-CIy6fe6y.js.map → work-queue-DHp4p1Zk.js.map} +1 -1
  112. package/public/assets/workspaces-BzwZvbsq.js +3 -0
  113. package/public/assets/{workspaces-C740m1ys.js.map → workspaces-BzwZvbsq.js.map} +1 -1
  114. package/public/index.html +21 -21
  115. package/src/agent-lifecycle-events.ts +0 -10
  116. package/src/branch-landed.ts +17 -29
  117. package/src/config-store.ts +13 -26
  118. package/src/context-advisory.ts +20 -10
  119. package/src/db/migrations.ts +17 -0
  120. package/src/db/scheduled-timers.ts +33 -12
  121. package/src/db/schema.ts +4 -0
  122. package/src/mcp/index.ts +1 -1
  123. package/src/mcp/tools-messaging.ts +13 -3
  124. package/src/mcp-notification-subscription-tools.ts +55 -2
  125. package/src/notification-router.ts +163 -9
  126. package/src/notification-rules.ts +7 -1
  127. package/src/notification-settings-store.ts +47 -0
  128. package/src/notification-settings.ts +155 -0
  129. package/src/notification-types.ts +2 -0
  130. package/src/quota-advisory.ts +0 -3
  131. package/src/routes/index.ts +2 -0
  132. package/src/routes/notifications.ts +36 -0
  133. package/src/routes/settings.ts +6 -0
  134. package/src/security.ts +1 -0
  135. package/src/services/scheduler.ts +29 -1
  136. package/src/settings/registry.ts +30 -44
  137. package/src/sse.ts +1 -0
  138. package/public/assets/MessageBubble-CUddRP1E.js +0 -3
  139. package/public/assets/MessageBubble-CUddRP1E.js.map +0 -1
  140. package/public/assets/activity-B6WPWNsX.js +0 -2
  141. package/public/assets/agents-BebF-yQr.js +0 -2
  142. package/public/assets/branch-state-badge-Q4W68jxj.js +0 -2
  143. package/public/assets/card-DDxxo-Fo.js +0 -2
  144. package/public/assets/chat-DfqC3kFF.js +0 -2
  145. package/public/assets/copy-button-D3B_s7J7.js +0 -2
  146. package/public/assets/dist-CIbvPDQV.js +0 -2
  147. package/public/assets/dist-CoRABSO3.js +0 -2
  148. package/public/assets/dist-DyqEeyBI.js +0 -2
  149. package/public/assets/index-CdDX5NR1.js +0 -23
  150. package/public/assets/index-CdDX5NR1.js.map +0 -1
  151. package/public/assets/index-umexz3cs.css +0 -2
  152. package/public/assets/integrations-D9Cx-7ma.js +0 -2
  153. package/public/assets/messages-B8jF9p2l.js +0 -2
  154. package/public/assets/settings-CMgqpUY-.js +0 -4
  155. package/public/assets/settings-CMgqpUY-.js.map +0 -1
  156. package/public/assets/user-avatar-DZ1g4X7l.js +0 -2
  157. package/public/assets/workspaces-C740m1ys.js +0 -3
@@ -1,8 +1,7 @@
1
1
  import { errMessage } from "agent-relay-sdk";
2
2
  import { emitRelayEvent } from "./events";
3
- import { getNotificationsConfig } from "./config-store";
4
3
  import { routeNotification, INFORMATIONAL_TTL_MS } from "./notification-router";
5
- import { registerDefaultAudienceRules, resolveAudience, audiencePredicates } from "./notification-rules";
4
+ import { registerDefaultAudienceRules, audiencePredicates } from "./notification-rules";
6
5
  import { createActivityEvent, getAgent, updateWorkspaceStatus } from "./db";
7
6
  import { handleLandedIssueLifecycle } from "./services/issue-lifecycle";
8
7
  import type { WorkspaceMergePreview, WorkspaceRecord } from "./types";
@@ -135,9 +134,6 @@ export function notifyBranchLanded(input: BranchLandedInput): void {
135
134
  console.warn(`issue lifecycle land hook failed for ${workspace.id}: ${errMessage(error)}`);
136
135
  });
137
136
 
138
- const config = getNotificationsConfig();
139
- if (!config.enabled || !config.branchLanded) return;
140
-
141
137
  const author = workspace.ownerAgentId;
142
138
  const shaLabel = shortSha ? ` as \`${shortSha}\`` : "";
143
139
  const subjectLabel = input.subject ? ` — "${input.subject}"` : "";
@@ -186,25 +182,22 @@ export function notifyBranchLanded(input: BranchLandedInput): void {
186
182
  // online agents whose cwd is the main checkout, author-excluded, team-scoped. Empty for a solo
187
183
  // land with no other main agents → no-op.
188
184
  const mainScope = { author, repoRoot: workspace.repoRoot, workspaceId: workspace.id, teamId: author ? getAgent(author)?.teamId : undefined };
189
- const mainRecipients = resolveAudience("branch.landed", mainScope).map((member) => member.agentId);
190
- if (mainRecipients.length > 0) {
191
- const branchLabel = landedBranch ? `\`${landedBranch}\`` : "A branch";
192
- const authorLabel = author ? ` by \`${author}\`` : "";
193
- // Bystander fan-out (ttl informational): a "merged" notice is only useful while fresh — a
194
- // late-joining main agent shouldn't be woken by an hours-old land.
195
- routeNotification({
196
- type: "branch.landed",
197
- scope: mainScope,
198
- recipients: mainRecipients,
199
- ttlMs: INFORMATIONAL_TTL_MS,
200
- message: {
201
- subject: `Merged to ${base}`,
202
- body: `🔀 ${branchLabel}${authorLabel} merged to \`${base}\`${shaLabel}${subjectLabel}.${publishLabel}`,
203
- payload: { ...payload, notification: "landed-success" },
204
- replyExpected: false,
205
- },
206
- });
207
- }
185
+ const branchLabel = landedBranch ? `\`${landedBranch}\`` : "A branch";
186
+ const authorLabel = author ? ` by \`${author}\`` : "";
187
+ // Bystander fan-out (ttl informational): a "merged" notice is only useful while fresh — a
188
+ // late-joining main agent shouldn't be woken by an hours-old land. Omit explicit recipients so
189
+ // the notification router records the full #442 rule decision for #446's routed-because trace.
190
+ routeNotification({
191
+ type: "branch.landed",
192
+ scope: mainScope,
193
+ ttlMs: INFORMATIONAL_TTL_MS,
194
+ message: {
195
+ subject: `Merged to ${base}`,
196
+ body: `🔀 ${branchLabel}${authorLabel} merged to \`${base}\`${shaLabel}${subjectLabel}.${publishLabel}`,
197
+ payload: { ...payload, notification: "landed-success" },
198
+ replyExpected: false,
199
+ },
200
+ });
208
201
  }
209
202
 
210
203
  /**
@@ -240,8 +233,6 @@ export function notifyBranchLandFailed(input: {
240
233
  });
241
234
 
242
235
  if (!author) return;
243
- const config = getNotificationsConfig();
244
- if (!config.enabled || !config.branchLanded) return;
245
236
  const branchLabel = workspace.branch ? `\`${workspace.branch}\`` : "Your branch";
246
237
  routeNotification({
247
238
  type: "landed-failure",
@@ -298,9 +289,6 @@ export function notifyStewardHandoff(input: {
298
289
 
299
290
  emitRelayEvent({ type: "workspace.steward_handoff", source: "server", subject: workspace.id, data: payload });
300
291
 
301
- const config = getNotificationsConfig();
302
- if (!config.enabled) return;
303
-
304
292
  // #641 — tell the owner branch agent which steward now holds its merge, so it stops
305
293
  // thinking the branch silently failed. Durable (ttl null): the owner must learn this
306
294
  // even if it went idle. Mirror the "don't touch main" guardrail of the landed-failure wake.
@@ -1,7 +1,13 @@
1
1
  import { getDb, ValidationError } from "./db";
2
2
  import { cleanEnum, cleanString, cleanStringArray } from "./validation";
3
- import { DEFAULT_CONTEXT_THRESHOLD_CONFIG, validateContextThresholdConfig } from "./context-threshold-config";
4
- import { DEFAULT_QUOTA_THRESHOLD_CONFIG, validateQuotaThresholdConfig } from "./quota-threshold-config";
3
+ import {
4
+ NOTIFICATIONS_CONFIG_DEFAULTS,
5
+ NOTIFICATIONS_DEFAULT_KEY,
6
+ NOTIFICATIONS_NAMESPACE,
7
+ parseNotificationSettingsKey,
8
+ validateNotificationSettingsLayer,
9
+ validateNotificationsConfig,
10
+ } from "./notification-settings";
5
11
  import { DEFAULT_SELF_RESUME_CONFIG, validateSelfResumeConfig } from "./self-resume-config";
6
12
  import { TEAM_TEMPLATE_NAMESPACE, validateTeamTemplate } from "./team-template-config";
7
13
  import { PROJECT_INSTRUCTION_POLICY_NAMES } from "./project-instructions";
@@ -36,8 +42,6 @@ const STEWARD_NAMESPACE = "steward";
36
42
  const STEWARD_KEY = "default";
37
43
  const INSIGHTS_NAMESPACE = "insights";
38
44
  const INSIGHTS_KEY = "default";
39
- const NOTIFICATIONS_NAMESPACE = "notifications";
40
- const NOTIFICATIONS_KEY = "default";
41
45
  const SELF_RESUME_NAMESPACE = "self-resume";
42
46
  const SELF_RESUME_KEY = "default";
43
47
  const WORKSPACE_NAMESPACE = "workspace";
@@ -549,26 +553,6 @@ function validateInsightsConfig(value: unknown): InsightsConfig {
549
553
  };
550
554
  }
551
555
 
552
- // Relay-driven lifecycle push notifications (#239 event bus). Default-on; the
553
- // operator can flip the master switch or individual events off via the generic
554
- // config route. Push messages wake recipients, so they must be suppressible.
555
- const NOTIFICATIONS_CONFIG_DEFAULTS: NotificationsConfig = { enabled: true, branchLanded: true, agentReady: true, agentExited: true, agentSpawnFailed: true, contextThreshold: DEFAULT_CONTEXT_THRESHOLD_CONFIG, quotaThreshold: DEFAULT_QUOTA_THRESHOLD_CONFIG };
556
-
557
- function validateNotificationsConfig(value: unknown): NotificationsConfig {
558
- if (!isRecord(value)) throw new ValidationError("notifications config value must be an object");
559
- const bool = (key: "enabled" | "branchLanded" | "agentReady" | "agentExited" | "agentSpawnFailed"): boolean =>
560
- value[key] === undefined ? NOTIFICATIONS_CONFIG_DEFAULTS[key] : cleanBoolean(value[key], key);
561
- return {
562
- enabled: bool("enabled"),
563
- branchLanded: bool("branchLanded"),
564
- agentReady: bool("agentReady"),
565
- agentExited: bool("agentExited"),
566
- agentSpawnFailed: bool("agentSpawnFailed"),
567
- contextThreshold: validateContextThresholdConfig(value.contextThreshold),
568
- quotaThreshold: validateQuotaThresholdConfig(value.quotaThreshold),
569
- };
570
- }
571
-
572
556
  // Global workspace provisioning config for isolated worktrees (#159 follow-up).
573
557
  // Defaults seed the two untracked paths an isolated agent almost always needs:
574
558
  // the agent guide and the rig config, both gitignored so a fresh worktree lacks them.
@@ -609,7 +593,10 @@ function normalizeValue(namespace: string, key: string, value: unknown): unknown
609
593
  if (namespace === AGENT_PROFILE_NAMESPACE) return validateAgentProfile(key, value);
610
594
  if (namespace === STEWARD_NAMESPACE) return validateStewardConfig(value);
611
595
  if (namespace === INSIGHTS_NAMESPACE) return validateInsightsConfig(value);
612
- if (namespace === NOTIFICATIONS_NAMESPACE) return validateNotificationsConfig(value);
596
+ if (namespace === NOTIFICATIONS_NAMESPACE) {
597
+ if (!parseNotificationSettingsKey(key)) throw new ValidationError("notifications config key must be default, team:<teamId>, or agent:<agentId>");
598
+ return key === NOTIFICATIONS_DEFAULT_KEY ? validateNotificationsConfig(value) : validateNotificationSettingsLayer(value);
599
+ }
613
600
  if (namespace === SELF_RESUME_NAMESPACE) return validateSelfResumeConfig(value);
614
601
  if (namespace === WORKSPACE_NAMESPACE) return validateWorkspaceConfig(value);
615
602
  if (namespace === LANDING_NAMESPACE) return validateLandingConfig(value);
@@ -748,7 +735,7 @@ export function getInsightsConfigEntry(): ConfigEntry<InsightsConfig> {
748
735
 
749
736
  /** Lifecycle-notification config (#239), merged over defaults (always usable). */
750
737
  export function getNotificationsConfig(): NotificationsConfig {
751
- const entry = getConfig<Partial<NotificationsConfig>>(NOTIFICATIONS_NAMESPACE, NOTIFICATIONS_KEY);
738
+ const entry = getConfig<Partial<NotificationsConfig>>(NOTIFICATIONS_NAMESPACE, NOTIFICATIONS_DEFAULT_KEY);
752
739
  if (!entry) return { ...NOTIFICATIONS_CONFIG_DEFAULTS };
753
740
  return validateNotificationsConfig({ ...NOTIFICATIONS_CONFIG_DEFAULTS, ...entry.value });
754
741
  }
@@ -1,7 +1,9 @@
1
1
  import { deriveContextThresholdFromWindowTokens, findProviderCatalogModel, resolveProviderContextThreshold, type ProviderModelCatalogEntry, type ResolvedProviderContextThreshold } from "agent-relay-sdk/provider-catalog";
2
2
  import { isRecord, SPAWN_PROVIDERS } from "agent-relay-sdk";
3
- import { getConfig, getNotificationsConfig } from "./config-store";
4
- import { DEFAULT_CONTEXT_THRESHOLD_CONFIG, validateContextThresholdConfig } from "./context-threshold-config";
3
+ import { getConfig } from "./config-store";
4
+ import { validateContextThresholdConfig } from "./context-threshold-config";
5
+ import { getResolvedNotificationsConfig } from "./notification-settings-store";
6
+ import { NOTIFICATIONS_DEFAULT_KEY, NOTIFICATIONS_NAMESPACE, notificationAgentKey, notificationTeamKey } from "./notification-settings";
5
7
  import { getDb } from "./db/connection.ts";
6
8
  import { emitRelayEvent } from "./events";
7
9
  import { routeNotification } from "./notification-router";
@@ -53,9 +55,6 @@ function notifyContextAdvisory(input: ContextAdvisoryInput): void {
53
55
  data: payload,
54
56
  });
55
57
 
56
- const config = getNotificationsConfig();
57
- if (!config.enabled || !config.contextThreshold.enabled) return;
58
-
59
58
  // Short default TTL (per-type): a stale context advisory is misleading — the band may have moved
60
59
  // by the time the agent next polls, so old advice shouldn't wake it.
61
60
  routeNotification({
@@ -140,15 +139,14 @@ function effectiveContextThreshold(agent: AgentCard): EffectiveContextThreshold
140
139
  if (model?.contextThreshold) {
141
140
  return { ...resolveProviderContextThreshold(model), source: "model" };
142
141
  }
143
- const global = explicitContextThresholdConfig();
142
+ const global = explicitContextThresholdConfig(agent);
144
143
  if (global) return global;
145
144
  return { ...deriveContextThresholdFromWindowTokens(windowTokens), source: "window" };
146
145
  }
147
146
 
148
- function explicitContextThresholdConfig(): EffectiveContextThreshold | undefined {
149
- const entry = getConfig("notifications", "default");
150
- if (!entry || !isRecord(entry.value) || !isRecord(entry.value.contextThreshold)) return undefined;
151
- const global = validateContextThresholdConfig({ ...DEFAULT_CONTEXT_THRESHOLD_CONFIG, ...entry.value.contextThreshold });
147
+ function explicitContextThresholdConfig(agent: AgentCard): EffectiveContextThreshold | undefined {
148
+ if (!hasExplicitContextThreshold(agent)) return undefined;
149
+ const global = validateContextThresholdConfig(getResolvedNotificationsConfig({ agentId: agent.id, teamId: agent.teamId }).contextThreshold);
152
150
  const selfCompactUtilization = global.selfCompactUtilization ?? global.advisoryUtilization;
153
151
  return {
154
152
  selfCompactUtilization,
@@ -158,6 +156,18 @@ function explicitContextThresholdConfig(): EffectiveContextThreshold | undefined
158
156
  };
159
157
  }
160
158
 
159
+ function hasExplicitContextThreshold(agent: AgentCard): boolean {
160
+ const keys = [
161
+ NOTIFICATIONS_DEFAULT_KEY,
162
+ ...(agent.teamId ? [notificationTeamKey(agent.teamId)] : []),
163
+ notificationAgentKey(agent.id),
164
+ ];
165
+ return keys.some((key) => {
166
+ const entry = getConfig(NOTIFICATIONS_NAMESPACE, key);
167
+ return isRecord(entry?.value) && isRecord(entry.value.contextThreshold);
168
+ });
169
+ }
170
+
161
171
  function findAgentCatalogModel(agent: AgentCard, observedWindowTokens: number | undefined): ProviderModelCatalogEntry | undefined {
162
172
  const runtime = agent.providerCapabilities?.model;
163
173
  const provider = runtime?.provider;
@@ -896,9 +896,13 @@ export function applyMigrations(): void {
896
896
  next_run_at INTEGER NOT NULL,
897
897
  schedule_kind TEXT NOT NULL,
898
898
  schedule_def TEXT NOT NULL,
899
+ message_kind TEXT,
900
+ channel TEXT,
899
901
  subject TEXT,
900
902
  body TEXT NOT NULL,
903
+ claimable INTEGER NOT NULL DEFAULT 0,
901
904
  payload TEXT NOT NULL DEFAULT '{}',
905
+ meta TEXT NOT NULL DEFAULT '{}',
902
906
  enabled INTEGER NOT NULL DEFAULT 1,
903
907
  run_count INTEGER NOT NULL DEFAULT 0,
904
908
  max_runs INTEGER,
@@ -913,6 +917,19 @@ export function applyMigrations(): void {
913
917
  claim_expires_at INTEGER
914
918
  )
915
919
  `);
920
+ const scheduledTimerCols = (getDb().query("PRAGMA table_info(scheduled_timers)").all() as any[]).map((c: any) => c.name);
921
+ if (!scheduledTimerCols.includes("message_kind")) {
922
+ getDb().run("ALTER TABLE scheduled_timers ADD COLUMN message_kind TEXT");
923
+ }
924
+ if (!scheduledTimerCols.includes("channel")) {
925
+ getDb().run("ALTER TABLE scheduled_timers ADD COLUMN channel TEXT");
926
+ }
927
+ if (!scheduledTimerCols.includes("claimable")) {
928
+ getDb().run("ALTER TABLE scheduled_timers ADD COLUMN claimable INTEGER NOT NULL DEFAULT 0");
929
+ }
930
+ if (!scheduledTimerCols.includes("meta")) {
931
+ getDb().run("ALTER TABLE scheduled_timers ADD COLUMN meta TEXT NOT NULL DEFAULT '{}'");
932
+ }
916
933
  getDb().run("CREATE INDEX IF NOT EXISTS idx_scheduled_timers_due ON scheduled_timers(enabled, next_run_at, claim_expires_at)");
917
934
  getDb().run("CREATE INDEX IF NOT EXISTS idx_scheduled_timers_creator ON scheduled_timers(created_by, enabled, next_run_at)");
918
935
  getDb().run("CREATE INDEX IF NOT EXISTS idx_scheduled_timers_correlation ON scheduled_timers(correlation_id)");
@@ -22,9 +22,13 @@ export interface ScheduledTimer {
22
22
  nextRunAt: number;
23
23
  scheduleKind: TimerScheduleKind;
24
24
  scheduleDef: TimerScheduleDef;
25
+ messageKind?: string;
26
+ channel?: string;
25
27
  subject?: string;
26
28
  body: string;
29
+ claimable?: boolean;
27
30
  payload: Record<string, unknown>;
31
+ meta: Record<string, unknown>;
28
32
  enabled: boolean;
29
33
  runCount: number;
30
34
  maxRuns?: number;
@@ -51,9 +55,13 @@ interface ScheduledTimerRow {
51
55
  next_run_at: number;
52
56
  schedule_kind: string;
53
57
  schedule_def: string;
58
+ message_kind: string | null;
59
+ channel: string | null;
54
60
  subject: string | null;
55
61
  body: string;
62
+ claimable: number | null;
56
63
  payload: string;
64
+ meta: string | null;
57
65
  enabled: number;
58
66
  run_count: number;
59
67
  max_runs: number | null;
@@ -78,9 +86,13 @@ export interface CreateScheduledTimerRowInput {
78
86
  nextRunAt: number;
79
87
  scheduleKind: TimerScheduleKind;
80
88
  scheduleDef: TimerScheduleDef;
89
+ messageKind?: string;
90
+ channel?: string;
81
91
  subject?: string;
82
92
  body: string;
93
+ claimable?: boolean;
83
94
  payload?: Record<string, unknown>;
95
+ meta?: Record<string, unknown>;
84
96
  maxRuns?: number;
85
97
  expiresAt?: number;
86
98
  idempotencyKey?: string;
@@ -92,10 +104,10 @@ export function insertScheduledTimer(input: CreateScheduledTimerRowInput, now =
92
104
  getDb().query(`
93
105
  INSERT INTO scheduled_timers (
94
106
  id, target, created_by, created_by_kind, created_by_scopes, created_by_constraints, delivery_from,
95
- created_at, next_run_at, schedule_kind, schedule_def, subject, body, payload, enabled,
107
+ created_at, next_run_at, schedule_kind, schedule_def, message_kind, channel, subject, body, claimable, payload, meta, enabled,
96
108
  run_count, max_runs, expires_at, idempotency_key, correlation_id
97
109
  )
98
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, ?, ?, ?, ?)
110
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, 0, ?, ?, ?, ?)
99
111
  `).run(
100
112
  id,
101
113
  input.target,
@@ -108,9 +120,13 @@ export function insertScheduledTimer(input: CreateScheduledTimerRowInput, now =
108
120
  input.nextRunAt,
109
121
  input.scheduleKind,
110
122
  JSON.stringify(input.scheduleDef),
123
+ input.messageKind ?? null,
124
+ input.channel ?? null,
111
125
  input.subject ?? null,
112
126
  input.body,
127
+ input.claimable ? 1 : 0,
113
128
  JSON.stringify(input.payload ?? {}),
129
+ JSON.stringify(input.meta ?? {}),
114
130
  input.maxRuns ?? null,
115
131
  input.expiresAt ?? null,
116
132
  input.idempotencyKey ?? null,
@@ -199,20 +215,20 @@ export function completeScheduledTimerRun(input: {
199
215
  claimed_by = NULL,
200
216
  claim_expires_at = NULL
201
217
  WHERE id = ?
202
- `).run(runCount, input.now, input.messageId, candidateNextRunAt, enabled ? 1 : 0, input.timer.id);
218
+ AND claimed_by = ?
219
+ AND enabled = 1
220
+ `).run(runCount, input.now, input.messageId, candidateNextRunAt, enabled ? 1 : 0, input.timer.id, input.timer.claimedBy ?? "");
203
221
  return getScheduledTimer(input.timer.id)!;
204
222
  }
205
223
 
206
224
  export function recordScheduledTimerFailure(input: {
207
- id: string;
225
+ timer: ScheduledTimer;
208
226
  error: string;
209
227
  now: number;
210
228
  retryMs: number;
211
229
  maxFailures: number;
212
230
  }): ScheduledTimer | null {
213
- const current = getScheduledTimer(input.id);
214
- if (!current) return null;
215
- const failures = current.consecutiveFailures + 1;
231
+ const failures = input.timer.consecutiveFailures + 1;
216
232
  const enabled = failures < input.maxFailures;
217
233
  getDb().query(`
218
234
  UPDATE scheduled_timers
@@ -223,17 +239,18 @@ export function recordScheduledTimerFailure(input: {
223
239
  claimed_by = NULL,
224
240
  claim_expires_at = NULL
225
241
  WHERE id = ?
226
- `).run(input.error, failures, input.now + input.retryMs, enabled ? 1 : 0, input.id);
227
- return getScheduledTimer(input.id);
242
+ AND claimed_by = ?
243
+ AND enabled = 1
244
+ `).run(input.error, failures, input.now + input.retryMs, enabled ? 1 : 0, input.timer.id, input.timer.claimedBy ?? "");
245
+ return getScheduledTimer(input.timer.id);
228
246
  }
229
247
 
230
- export function cancelScheduledTimer(id: string, now = Date.now()): ScheduledTimer | null {
231
- const result = getDb().query(`
248
+ export function cancelScheduledTimer(id: string): ScheduledTimer | null {
249
+ getDb().query(`
232
250
  UPDATE scheduled_timers
233
251
  SET enabled = 0, claimed_by = NULL, claim_expires_at = NULL, last_error = COALESCE(last_error, 'cancelled')
234
252
  WHERE id = ? AND enabled = 1
235
253
  `).run(id);
236
- if (result.changes === 0) return getScheduledTimer(id);
237
254
  return getScheduledTimer(id);
238
255
  }
239
256
 
@@ -259,9 +276,13 @@ function rowToTimer(row: ScheduledTimerRow): ScheduledTimer {
259
276
  nextRunAt: row.next_run_at,
260
277
  scheduleKind: row.schedule_kind === "interval" ? "interval" : "once",
261
278
  scheduleDef: parseRecord(row.schedule_def) as TimerScheduleDef,
279
+ messageKind: row.message_kind ?? undefined,
280
+ channel: row.channel ?? undefined,
262
281
  subject: row.subject ?? undefined,
263
282
  body: row.body,
283
+ claimable: row.claimable === 1 ? true : undefined,
264
284
  payload: parseRecord(row.payload) ?? {},
285
+ meta: parseRecord(row.meta) ?? {},
265
286
  enabled: row.enabled === 1,
266
287
  runCount: row.run_count,
267
288
  maxRuns: row.max_runs ?? undefined,
package/src/db/schema.ts CHANGED
@@ -176,9 +176,13 @@ export function initDb(path: string = "agent-relay.db"): Database {
176
176
  next_run_at INTEGER NOT NULL,
177
177
  schedule_kind TEXT NOT NULL,
178
178
  schedule_def TEXT NOT NULL,
179
+ message_kind TEXT,
180
+ channel TEXT,
179
181
  subject TEXT,
180
182
  body TEXT NOT NULL,
183
+ claimable INTEGER NOT NULL DEFAULT 0,
181
184
  payload TEXT NOT NULL DEFAULT '{}',
185
+ meta TEXT NOT NULL DEFAULT '{}',
182
186
  enabled INTEGER NOT NULL DEFAULT 1,
183
187
  run_count INTEGER NOT NULL DEFAULT 0,
184
188
  max_runs INTEGER,
package/src/mcp/index.ts CHANGED
@@ -150,7 +150,7 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
150
150
  else if (name.startsWith("relay_plan_")) result = relayPlanTool(auth, name, args);
151
151
  else if (name.startsWith("relay_blackboard_")) result = relayBlackboardTool(auth, name, args);
152
152
  else if (name.startsWith("relay_project_")) result = relayProjectTool(auth, name, args); else if (name.startsWith("relay_issue_ref_")) result = relayIssueRefTool(auth, name, args); else if (name.startsWith("relay_issue_association_")) result = relayIssueAssociationTool(auth, name, args); else if (name.startsWith("relay_issue_activity_")) result = relayIssueActivityTool(auth, name, args); else if (name.startsWith("relay_issue_lifecycle_")) result = await relayIssueLifecycleTool(auth, name, args);
153
- else if (name === "relay_subscribe" || name === "relay_unsubscribe" || name === "relay_list_subscriptions") result = relayNotificationSubscriptionTool(auth, name, args);
153
+ else if (name === "relay_subscribe" || name === "relay_unsubscribe" || name === "relay_list_subscriptions" || name === "relay_why_notified" || name === "relay_notification_preview") result = relayNotificationSubscriptionTool(auth, name, args);
154
154
  else if (name === "relay_notify_ntfy") result = await relayNotifyNtfy(args);
155
155
  else throw new ValidationError(`unknown tool: ${name}`);
156
156
 
@@ -54,6 +54,7 @@ export const MESSAGING_TOOLS: ToolDefinition[] = [
54
54
  from: { type: "string", description: "Deprecated/optional. Your identity is taken from your auth token; ignored for managed agents. You never need to set this." },
55
55
  to: { type: "string", description: "Target: an agent id, label, name, or a unique id segment (e.g. the number part) — resolved automatically. Or a fan-out selector: tag:, cap:, label:, team:, policy:, or broadcast." },
56
56
  body: { type: "string" },
57
+ kind: { type: "string", enum: ["chat", "channel.event", "task", "pair", "control", "system", "session"] },
57
58
  subject: { type: "string" },
58
59
  channel: { type: "string" },
59
60
  claimable: { type: "boolean" },
@@ -226,6 +227,10 @@ function runMcpSend(auth: McpAuthContext, input: SendMessageInput): Message & {
226
227
  export function relaySendMessage(auth: McpAuthContext, args: Record<string, unknown>): (Message & { delivery: DeliveryReceipt }) | Record<string, unknown> {
227
228
  const attachments = optionalAttachments(args.attachments);
228
229
  const payload = payloadWithAttachments(optionalRecord(args.payload, "payload"), attachments);
230
+ const kind = optionalEnum(args.kind, "kind", ["chat", "channel.event", "task", "pair", "control", "system", "session"] as const);
231
+ const channel = optionalString(args.channel, "channel", 120);
232
+ const claimable = optionalBoolean(args.claimable, "claimable");
233
+ const meta = optionalRecord(args.meta, "meta");
229
234
  const deliveryTime = optionalString(args.deliveryTime, "deliveryTime", 80);
230
235
  if (deliveryTime) {
231
236
  if (!hasScope(auth, "schedule:write")) throw new McpAuthError("deliveryTime requires schedule:write");
@@ -233,9 +238,13 @@ export function relaySendMessage(auth: McpAuthContext, args: Record<string, unkn
233
238
  const timer = scheduleTimer({
234
239
  target: stringField(args.to, "to", { required: true, max: 200 }),
235
240
  at: deliveryTime,
241
+ kind,
242
+ channel,
236
243
  body: stringField(args.body, "body", { required: true, maxBytes: MAX_BODY_BYTES }),
237
244
  subject: optionalString(args.subject, "subject", 200),
245
+ claimable,
238
246
  payload,
247
+ meta,
239
248
  idempotencyKey: optionalString(args.idempotencyKey, "idempotencyKey", 240),
240
249
  }, authContextFromMcp({ kind: auth.kind, actor: auth.actor, scopes: auth.scopes, component: auth.component }));
241
250
  return { scheduled: true, timer };
@@ -247,14 +256,15 @@ export function relaySendMessage(auth: McpAuthContext, args: Record<string, unkn
247
256
  const input: SendMessageInput = {
248
257
  from: optionalString(args.from, "from", 200) ?? "",
249
258
  to: stringField(args.to, "to", { required: true, max: 200 }),
259
+ kind,
260
+ channel,
250
261
  body: stringField(args.body, "body", { required: true, maxBytes: MAX_BODY_BYTES }),
251
262
  subject: optionalString(args.subject, "subject", 200),
252
- channel: optionalString(args.channel, "channel", 120),
253
- claimable: optionalBoolean(args.claimable, "claimable"),
263
+ claimable,
254
264
  idempotencyKey: optionalString(args.idempotencyKey, "idempotencyKey", 240),
255
265
  attachments,
256
266
  payload,
257
- meta: optionalRecord(args.meta, "meta"),
267
+ meta,
258
268
  };
259
269
  return runMcpSend(auth, input);
260
270
  }
@@ -15,10 +15,11 @@ import {
15
15
  type NotificationSubscriptionMode,
16
16
  } from "./db/notification-subscriptions";
17
17
  import { ValidationError } from "./db";
18
- import { McpAuthError } from "./mcp-errors";
18
+ import { McpAuthError, McpNotFoundError } from "./mcp-errors";
19
19
  import { authContextFromMcp } from "./services/auth-context";
20
20
  import { cleanString } from "./validation";
21
- import type { NotificationScope } from "./notification-types";
21
+ import { NOTIFICATION_TYPES, type NotificationScope, type NotificationType } from "./notification-types";
22
+ import { previewNotificationRouting, whyNotified } from "./notification-router";
22
23
 
23
24
  export const NOTIFICATION_SUBSCRIPTION_TOOLS = [
24
25
  {
@@ -68,6 +69,33 @@ export const NOTIFICATION_SUBSCRIPTION_TOOLS = [
68
69
  additionalProperties: false,
69
70
  },
70
71
  },
72
+ {
73
+ name: "relay_why_notified",
74
+ description: "Explain why this caller agent received a delivered notification message.",
75
+ requiredScopes: ["notifications:read"],
76
+ inputSchema: {
77
+ type: "object",
78
+ properties: {
79
+ messageId: { type: "number", description: "Delivered Relay message id." },
80
+ },
81
+ required: ["messageId"],
82
+ additionalProperties: false,
83
+ },
84
+ },
85
+ {
86
+ name: "relay_notification_preview",
87
+ description: "Dry-run notification routing for a type and scope, returning recipients and matched rules without sending.",
88
+ requiredScopes: ["notifications:read"],
89
+ inputSchema: {
90
+ type: "object",
91
+ properties: {
92
+ type: { type: "string", enum: NOTIFICATION_TYPES },
93
+ scope: { type: "object", description: "Notification scope, such as { author, teamId, repoRoot, workspaceId }." },
94
+ },
95
+ required: ["type", "scope"],
96
+ additionalProperties: false,
97
+ },
98
+ },
71
99
  ] satisfies Array<{ name: string; description: string; requiredScopes: string[]; inputSchema: Record<string, unknown> }>;
72
100
 
73
101
  type McpAuthLike = Parameters<typeof authContextFromMcp>[0];
@@ -149,6 +177,17 @@ export function relayNotificationSubscriptionTool(
149
177
  },
150
178
  };
151
179
  }
180
+ if (name === "relay_why_notified") {
181
+ const explanation = whyNotified(cleanMessageId(args.messageId), agentId);
182
+ if (!explanation) throw new McpNotFoundError("notification routing explanation not found for this agent/message");
183
+ return { ...explanation };
184
+ }
185
+ if (name === "relay_notification_preview") {
186
+ return { ...previewNotificationRouting({
187
+ type: cleanNotificationType(args.type),
188
+ scope: cleanScope(args.scope) ?? {},
189
+ }) };
190
+ }
152
191
  throw new ValidationError(`unknown tool: ${name}`);
153
192
  }
154
193
 
@@ -169,6 +208,20 @@ function cleanScope(value: unknown): Partial<NotificationScope> | null {
169
208
  return value as Partial<NotificationScope>;
170
209
  }
171
210
 
211
+ function cleanNotificationType(value: unknown): NotificationType {
212
+ if (typeof value !== "string" || !NOTIFICATION_TYPES.includes(value as NotificationType)) {
213
+ throw new ValidationError(`type must be one of: ${NOTIFICATION_TYPES.join(", ")}`);
214
+ }
215
+ return value as NotificationType;
216
+ }
217
+
218
+ function cleanMessageId(value: unknown): number {
219
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
220
+ throw new ValidationError("messageId must be a positive integer");
221
+ }
222
+ return value;
223
+ }
224
+
172
225
  function cleanChildStreams(value: unknown): ChildStream[] {
173
226
  if (!Array.isArray(value) || value.length === 0) throw new ValidationError("streams must be a non-empty array");
174
227
  return [...new Set(value.map(cleanChildStream))];