agent-relay-server 0.58.0 → 0.60.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 (156) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +2 -2
  3. package/public/assets/{MessageBubble-CHKzD4RY.js → MessageBubble-BrEKymoV.js} +2 -2
  4. package/public/assets/{MessageBubble-CHKzD4RY.js.map → MessageBubble-BrEKymoV.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-BFVTJa0q.js → PlanGraphPanel-CXix1C-H.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-BFVTJa0q.js.map → PlanGraphPanel-CXix1C-H.js.map} +1 -1
  7. package/public/assets/{activity-M-dFLU_O.js → activity-CRafQ1Je.js} +2 -2
  8. package/public/assets/{activity-M-dFLU_O.js.map → activity-CRafQ1Je.js.map} +1 -1
  9. package/public/assets/{agent-profiles-CvHSoyCX.js → agent-profiles-DW4o5-ND.js} +2 -2
  10. package/public/assets/{agent-profiles-CvHSoyCX.js.map → agent-profiles-DW4o5-ND.js.map} +1 -1
  11. package/public/assets/agents-Cbegv0Hm.js +2 -0
  12. package/public/assets/{agents-DZBM5dp_.js.map → agents-Cbegv0Hm.js.map} +1 -1
  13. package/public/assets/analytics-DX6T7JX3.js +910 -0
  14. package/public/assets/{analytics-bvjb3Urs.js.map → analytics-DX6T7JX3.js.map} +1 -1
  15. package/public/assets/{automation-CDSn9Uq-.js → automation-CzSBv4gg.js} +2 -2
  16. package/public/assets/{automation-CDSn9Uq-.js.map → automation-CzSBv4gg.js.map} +1 -1
  17. package/public/assets/{badge-Dj6PREuI.js → badge-Cy6qO_Jz.js} +2 -2
  18. package/public/assets/{badge-Dj6PREuI.js.map → badge-Cy6qO_Jz.js.map} +1 -1
  19. package/public/assets/{branch-state-badge-CdYg2HYc.js → branch-state-badge-CRA50r81.js} +2 -2
  20. package/public/assets/{branch-state-badge-CdYg2HYc.js.map → branch-state-badge-CRA50r81.js.map} +1 -1
  21. package/public/assets/{button-Cqpufqam.js → button-73wpRw65.js} +2 -2
  22. package/public/assets/{button-Cqpufqam.js.map → button-73wpRw65.js.map} +1 -1
  23. package/public/assets/{card-OmDF73CE.js → card-BU6PvVHv.js} +2 -2
  24. package/public/assets/{card-OmDF73CE.js.map → card-BU6PvVHv.js.map} +1 -1
  25. package/public/assets/{channels-BkxrdOFZ.js → channels-B1MQCPCT.js} +2 -2
  26. package/public/assets/{channels-BkxrdOFZ.js.map → channels-B1MQCPCT.js.map} +1 -1
  27. package/public/assets/chat-BqIaFYGJ.js +2 -0
  28. package/public/assets/{chat-YzX1z76l.js.map → chat-BqIaFYGJ.js.map} +1 -1
  29. package/public/assets/{connectors-Bg3rHgl_.js → connectors-C9aFJIrh.js} +2 -2
  30. package/public/assets/{connectors-Bg3rHgl_.js.map → connectors-C9aFJIrh.js.map} +1 -1
  31. package/public/assets/{copy-button-DI7b8A7_.js → copy-button-HRopUwui.js} +2 -2
  32. package/public/assets/{copy-button-DI7b8A7_.js.map → copy-button-HRopUwui.js.map} +1 -1
  33. package/public/assets/display-CketawEF.js.map +1 -1
  34. package/public/assets/{dist-CNjnTZEI.js → dist-CC5f7oYw.js} +2 -2
  35. package/public/assets/{dist-CNjnTZEI.js.map → dist-CC5f7oYw.js.map} +1 -1
  36. package/public/assets/{dist-DkeTrbTA.js → dist-CLHMeXYi.js} +2 -2
  37. package/public/assets/{dist-DkeTrbTA.js.map → dist-CLHMeXYi.js.map} +1 -1
  38. package/public/assets/{dist-C5t_OxI0.js → dist-Ci1MM51P.js} +2 -2
  39. package/public/assets/{dist-C5t_OxI0.js.map → dist-Ci1MM51P.js.map} +1 -1
  40. package/public/assets/{dist-CV3sgFiv.js → dist-Cko2KPW8.js} +2 -2
  41. package/public/assets/{dist-CV3sgFiv.js.map → dist-Cko2KPW8.js.map} +1 -1
  42. package/public/assets/dist-D41tzJYg.js +2 -0
  43. package/public/assets/{dist-yYauqYKR.js.map → dist-D41tzJYg.js.map} +1 -1
  44. package/public/assets/{dist-CoUvwLTy.js → dist-DFLciw0h.js} +2 -2
  45. package/public/assets/{dist-CoUvwLTy.js.map → dist-DFLciw0h.js.map} +1 -1
  46. package/public/assets/dist-DUoZnlB2.js +2 -0
  47. package/public/assets/{dist-DmhyU0Na.js.map → dist-DUoZnlB2.js.map} +1 -1
  48. package/public/assets/dist-De2oguBm.js +2 -0
  49. package/public/assets/{dist-DxkMwkrr.js.map → dist-De2oguBm.js.map} +1 -1
  50. package/public/assets/{dist-1x-LE9Uk.js → dist-DmN94rc8.js} +2 -2
  51. package/public/assets/{dist-1x-LE9Uk.js.map → dist-DmN94rc8.js.map} +1 -1
  52. package/public/assets/{dist-DV2tZ2fq.js → dist-Kkpnjx_M.js} +2 -2
  53. package/public/assets/{dist-DV2tZ2fq.js.map → dist-Kkpnjx_M.js.map} +1 -1
  54. package/public/assets/{dist-Ca3ujqG_.js → dist-T7u7IZGJ.js} +2 -2
  55. package/public/assets/{dist-Ca3ujqG_.js.map → dist-T7u7IZGJ.js.map} +1 -1
  56. package/public/assets/{es2015-DcavLcQ0.js → es2015-Df08Opjx.js} +2 -2
  57. package/public/assets/{es2015-DcavLcQ0.js.map → es2015-Df08Opjx.js.map} +1 -1
  58. package/public/assets/{formatted-body-impl-D8i9qqL1.js → formatted-body-impl-DOXH7oDF.js} +2 -2
  59. package/public/assets/{formatted-body-impl-D8i9qqL1.js.map → formatted-body-impl-DOXH7oDF.js.map} +1 -1
  60. package/public/assets/index-DaNOQJci.css +2 -0
  61. package/public/assets/index-br_Y6Cvt.js +21 -0
  62. package/public/assets/{index-Ba2RDJaA.js.map → index-br_Y6Cvt.js.map} +1 -1
  63. package/public/assets/{input-C_dl_9fG.js → input-B2EDz-BE.js} +2 -2
  64. package/public/assets/{input-C_dl_9fG.js.map → input-B2EDz-BE.js.map} +1 -1
  65. package/public/assets/{insights-D1aANwZ2.js → insights-B1edq5lk.js} +2 -2
  66. package/public/assets/{insights-D1aANwZ2.js.map → insights-B1edq5lk.js.map} +1 -1
  67. package/public/assets/{integrations-BrAr9bap.js → integrations-Cm4fjp7_.js} +2 -2
  68. package/public/assets/{integrations-BrAr9bap.js.map → integrations-Cm4fjp7_.js.map} +1 -1
  69. package/public/assets/{lib-B3iMVsf-.js → lib-BZrjIp-o.js} +3 -3
  70. package/public/assets/{lib-B3iMVsf-.js.map → lib-BZrjIp-o.js.map} +1 -1
  71. package/public/assets/lucide-react-H_-8G79A.js +9 -0
  72. package/public/assets/{lucide-react-CMhBract.js.map → lucide-react-H_-8G79A.js.map} +1 -1
  73. package/public/assets/{maintenance-C-xspsMG.js → maintenance-D6zvw0fU.js} +2 -2
  74. package/public/assets/{maintenance-C-xspsMG.js.map → maintenance-D6zvw0fU.js.map} +1 -1
  75. package/public/assets/{managed-agents-Bf3ni0vo.js → managed-agents-CzfEznM1.js} +2 -2
  76. package/public/assets/{managed-agents-Bf3ni0vo.js.map → managed-agents-CzfEznM1.js.map} +1 -1
  77. package/public/assets/{markdown-preview-impl-DKbUBTf_.js → markdown-preview-impl-DZlCaTHQ.js} +2 -2
  78. package/public/assets/{markdown-preview-impl-DKbUBTf_.js.map → markdown-preview-impl-DZlCaTHQ.js.map} +1 -1
  79. package/public/assets/memory-CUUAzJDc.js +2 -0
  80. package/public/assets/{memory-DeLwJFwH.js.map → memory-CUUAzJDc.js.map} +1 -1
  81. package/public/assets/messages-6QZH46LG.js +2 -0
  82. package/public/assets/{messages-Cww6dZnM.js.map → messages-6QZH46LG.js.map} +1 -1
  83. package/public/assets/{orchestrators-DRjwROXw.js → orchestrators-CrcqkPuc.js} +2 -2
  84. package/public/assets/{orchestrators-DRjwROXw.js.map → orchestrators-CrcqkPuc.js.map} +1 -1
  85. package/public/assets/overview-418xXZO_.js +2 -0
  86. package/public/assets/{overview-DjcRomU_.js.map → overview-418xXZO_.js.map} +1 -1
  87. package/public/assets/pairs-CnedATgT.js +2 -0
  88. package/public/assets/{pairs-DT0u2fnW.js.map → pairs-CnedATgT.js.map} +1 -1
  89. package/public/assets/{react-dom-Dtl9-x_1.js → react-dom-WI6TjVe7.js} +2 -2
  90. package/public/assets/{react-dom-Dtl9-x_1.js.map → react-dom-WI6TjVe7.js.map} +1 -1
  91. package/public/assets/{security-Bwg3oFI8.js → security-DS13pbag.js} +2 -2
  92. package/public/assets/{security-Bwg3oFI8.js.map → security-DS13pbag.js.map} +1 -1
  93. package/public/assets/{select-Cariwv69.js → select-BOprGZDS.js} +2 -2
  94. package/public/assets/{select-Cariwv69.js.map → select-BOprGZDS.js.map} +1 -1
  95. package/public/assets/{settings-Bd6Ug3DO.js → settings-BgInn_f3.js} +4 -4
  96. package/public/assets/{settings-Bd6Ug3DO.js.map → settings-BgInn_f3.js.map} +1 -1
  97. package/public/assets/{store-BgJq_lN9.js → store-DHO_a8BD.js} +5 -5
  98. package/public/assets/{store-BgJq_lN9.js.map → store-DHO_a8BD.js.map} +1 -1
  99. package/public/assets/{tasks-DVz1zhOU.js → tasks-B-4oCPuv.js} +2 -2
  100. package/public/assets/{tasks-DVz1zhOU.js.map → tasks-B-4oCPuv.js.map} +1 -1
  101. package/public/assets/teams-sCQAs_QA.js +2 -0
  102. package/public/assets/{teams-TqFntFB_.js.map → teams-sCQAs_QA.js.map} +1 -1
  103. package/public/assets/{terminal-viewer-impl-OsJ5crlx.js → terminal-viewer-impl-BhqS2XpS.js} +3 -3
  104. package/public/assets/{terminal-viewer-impl-OsJ5crlx.js.map → terminal-viewer-impl-BhqS2XpS.js.map} +1 -1
  105. package/public/assets/{use-keyboard-viewport-BYHcL7sP.js → use-keyboard-viewport-fOL_3RHB.js} +2 -2
  106. package/public/assets/{use-keyboard-viewport-BYHcL7sP.js.map → use-keyboard-viewport-fOL_3RHB.js.map} +1 -1
  107. package/public/assets/{work-queue-B4b2jPLR.js → work-queue-BDhjlwUH.js} +2 -2
  108. package/public/assets/{work-queue-B4b2jPLR.js.map → work-queue-BDhjlwUH.js.map} +1 -1
  109. package/public/assets/workspaces-6I-C69nf.js +3 -0
  110. package/public/assets/{workspaces-m97LP4Eu.js.map → workspaces-6I-C69nf.js.map} +1 -1
  111. package/public/index.html +21 -21
  112. package/src/agent-lifecycle-events.ts +41 -21
  113. package/src/branch-landed.ts +46 -35
  114. package/src/channel-target.ts +10 -0
  115. package/src/config.ts +5 -0
  116. package/src/context-advisory.ts +13 -6
  117. package/src/db/message-reads.ts +6 -0
  118. package/src/db/messages.ts +16 -1
  119. package/src/db/migrations.ts +15 -0
  120. package/src/db/notification-subscriptions.ts +198 -0
  121. package/src/db/schema.ts +11 -0
  122. package/src/db/stats.ts +209 -38
  123. package/src/index.ts +2 -0
  124. package/src/maintenance.ts +41 -15
  125. package/src/mcp-notification-subscription-tools.ts +111 -0
  126. package/src/mcp.ts +6 -0
  127. package/src/notification-router.ts +168 -0
  128. package/src/notification-rules.ts +186 -0
  129. package/src/notify.ts +9 -0
  130. package/src/project-instructions.ts +176 -0
  131. package/src/reviewer-pipeline.ts +54 -40
  132. package/src/routes/agents-spawn.ts +2 -0
  133. package/src/routes/commands.ts +11 -5
  134. package/src/self-resume.ts +11 -6
  135. package/src/services/plan-live-bindings.ts +11 -6
  136. package/src/services/rate-limit-resume.ts +17 -10
  137. package/src/services/spawn-agent.ts +26 -1
  138. package/src/services/transient-agent-reaper.ts +11 -6
  139. package/src/token-db.ts +3 -3
  140. package/src/workspace-merge.ts +11 -0
  141. package/src/workspace-pr-completion.ts +72 -9
  142. package/public/assets/agents-DZBM5dp_.js +0 -2
  143. package/public/assets/analytics-bvjb3Urs.js +0 -910
  144. package/public/assets/chat-YzX1z76l.js +0 -2
  145. package/public/assets/dist-DmhyU0Na.js +0 -2
  146. package/public/assets/dist-DxkMwkrr.js +0 -2
  147. package/public/assets/dist-yYauqYKR.js +0 -2
  148. package/public/assets/index-Ba2RDJaA.js +0 -21
  149. package/public/assets/index-CtFXWxBJ.css +0 -2
  150. package/public/assets/lucide-react-CMhBract.js +0 -9
  151. package/public/assets/memory-DeLwJFwH.js +0 -2
  152. package/public/assets/messages-Cww6dZnM.js +0 -2
  153. package/public/assets/overview-DjcRomU_.js +0 -2
  154. package/public/assets/pairs-DT0u2fnW.js +0 -2
  155. package/public/assets/teams-TqFntFB_.js +0 -2
  156. package/public/assets/workspaces-m97LP4Eu.js +0 -3
package/src/db/stats.ts CHANGED
@@ -135,20 +135,35 @@ export const ANALYTICS_PERIODS = {
135
135
 
136
136
  export type AnalyticsPeriod = keyof typeof ANALYTICS_PERIODS;
137
137
 
138
- // Message category, the ONE place this mapping lives (server-side SQL). Order is
139
- // significant: a claimable/system/pair/channel message is classified as such even
140
- // when it is also a reply; only an otherwise-plain reply counts as "Replies".
141
- export const ANALYTICS_CATEGORIES = ["Messages", "Replies", "Work items", "System", "Pair", "Channel"] as const;
142
- export type AnalyticsCategory = (typeof ANALYTICS_CATEGORIES)[number];
143
- export const ANALYTICS_CATEGORY_SQL = `
138
+ // ─── Analytics taxonomy ─────────────────────────────────────────────────────
139
+ // The relay carries three distinct streams of activity, and analytics keeps them
140
+ // SEPARATE rather than collapsing into one "messages" count (which is ~94%
141
+ // session-mirror noise on a busy relay see the work layer below):
142
+ // 1. Conversation real inter-agent / human messaging (chat, replies, channel, pair, work items)
143
+ // 2. Work session-mirror cognition: tool calls, reasoning, narration, responses
144
+ // 3. Fleet — agent registrations/spawns + per-agent activity (NOT online/offline:
145
+ // agents start, work, and shut down constantly — point-in-time status is noise here)
146
+
147
+ // Conversation categories. Order is significant: a claimable/channel/pair message
148
+ // is classified as such even when it is also a reply; only an otherwise-plain reply
149
+ // counts as "Replies". Session/control/system rows are EXCLUDED (they're streams 2/mechanical).
150
+ export const CONVERSATION_CATEGORIES = ["Chat", "Replies", "Channel", "Pair", "Work items"] as const;
151
+ export type ConversationCategory = (typeof CONVERSATION_CATEGORIES)[number];
152
+ const CONVERSATION_CATEGORY_SQL = `
144
153
  CASE
145
154
  WHEN claimable = 1 THEN 'Work items'
146
- WHEN kind = 'system' OR from_agent = 'system' THEN 'System'
147
- WHEN kind = 'pair' THEN 'Pair'
148
155
  WHEN kind = 'channel.event' THEN 'Channel'
156
+ WHEN kind = 'pair' THEN 'Pair'
149
157
  WHEN reply_to IS NOT NULL THEN 'Replies'
150
- ELSE 'Messages'
158
+ ELSE 'Chat'
151
159
  END`;
160
+ // Only real messaging — excludes the session-mirror lane and mechanical/system rows.
161
+ const CONVERSATION_FILTER = `kind NOT IN ('session','control','system') AND from_agent != 'system'`;
162
+
163
+ // Session-mirror event sub-types we surface as the "agent work" composition. (prompt
164
+ // is counted in the user-interaction stream; notice is mechanical chatter.)
165
+ export const WORK_TYPES = ["tool", "reasoning", "narration", "response"] as const;
166
+ export type WorkType = (typeof WORK_TYPES)[number];
152
167
 
153
168
  export interface AnalyticsData {
154
169
  period: AnalyticsPeriod;
@@ -156,52 +171,196 @@ export interface AnalyticsData {
156
171
  now: number;
157
172
  bucketMs: number;
158
173
  bucketCount: number;
159
- // Per-bucket category counts, oldest newest, length === bucketCount.
160
- buckets: Array<{ start: number; counts: Record<AnalyticsCategory, number> }>;
161
- // Category totals over the whole period (powers the breakdown donut).
162
- categories: Record<AnalyticsCategory, number>;
163
- // Busiest-hours grid: heatmap[dayOfWeek 0=Sun][hour 0-23], in server-local time.
174
+ // 1. Conversation — real messaging volume, per-bucket (oldest→newest) + totals.
175
+ conversation: {
176
+ buckets: Array<{ start: number; counts: Record<ConversationCategory, number> }>;
177
+ totals: Record<ConversationCategory, number>;
178
+ total: number;
179
+ };
180
+ // 2. Work — session-mirror cognition: per-bucket type counts, totals, tool leaderboard, turn count.
181
+ work: {
182
+ buckets: Array<{ start: number; counts: Record<WorkType, number> }>;
183
+ totals: Record<WorkType, number>;
184
+ topTools: Array<{ name: string; count: number }>;
185
+ turns: number;
186
+ toolCalls: number;
187
+ };
188
+ // 3. User interaction — human prompts, reply rate, and agent reply latency (median is
189
+ // the headline; avg is tail-inflated by agents that were away mid-conversation).
190
+ user: {
191
+ prompts: number;
192
+ replied: number;
193
+ replyRate: number; // 0..1
194
+ latencyMs: { median: number; avg: number; p90: number } | null;
195
+ buckets: Array<{ start: number; prompts: number }>;
196
+ };
197
+ // 4. Fleet — activity-based, never online/offline. Registrations (spawns) over time,
198
+ // how many were agent-spawned, distinct active agents, and a per-agent leaderboard.
199
+ fleet: {
200
+ activeAgents: number;
201
+ registrations: number;
202
+ spawnedByAgent: number;
203
+ buckets: Array<{ start: number; registrations: number }>;
204
+ leaderboard: Array<{ id: string; label: string | null; turns: number; toolCalls: number; messages: number }>;
205
+ };
206
+ // Busiest-hours grid: heatmap[dayOfWeek 0=Sun][hour 0-23], server-local time, all activity.
164
207
  heatmap: number[][];
165
- totalMessages: number;
166
208
  totalReactions: number;
167
209
  }
168
210
 
169
- export function emptyCategoryCounts(): Record<AnalyticsCategory, number> {
170
- return { Messages: 0, Replies: 0, "Work items": 0, System: 0, Pair: 0, Channel: 0 };
211
+ function emptyConversationCounts(): Record<ConversationCategory, number> {
212
+ return { Chat: 0, Replies: 0, Channel: 0, Pair: 0, "Work items": 0 };
213
+ }
214
+ function emptyWorkCounts(): Record<WorkType, number> {
215
+ return { tool: 0, reasoning: 0, narration: 0, response: 0 };
216
+ }
217
+
218
+ // Percentile over an unsorted list of numbers (linear interpolation-free nearest-rank).
219
+ function percentile(sorted: number[], p: number): number {
220
+ if (sorted.length === 0) return 0;
221
+ const idx = Math.min(sorted.length - 1, Math.max(0, Math.ceil((p / 100) * sorted.length) - 1));
222
+ return sorted[idx] ?? 0;
171
223
  }
172
224
 
173
225
  export function getAnalytics(period: AnalyticsPeriod, now: number = Date.now()): AnalyticsData {
174
226
  const { ms, buckets: bucketCount } = ANALYTICS_PERIODS[period] ?? ANALYTICS_PERIODS["24h"];
175
227
  const start = now - ms;
176
228
  const bucketMs = ms / bucketCount;
229
+ const db = getDb();
230
+ // created_at in (start, now] keeps the floored bucket index in [0, bucketCount).
231
+ const clampIdx = (bucket: number) => Math.min(Math.max(bucket, 0), bucketCount - 1);
232
+ const bucketStarts = Array.from({ length: bucketCount }, (_, i) => start + i * bucketMs);
177
233
 
178
- const buckets = Array.from({ length: bucketCount }, (_, i) => ({
179
- start: start + i * bucketMs,
180
- counts: emptyCategoryCounts(),
181
- }));
182
- const categories = emptyCategoryCounts();
183
-
184
- // One pass: bucket index × category counts. CAST floors toward zero; created_at
185
- // in (start, now] keeps the index in [0, bucketCount).
186
- const volumeRows = getDb()
234
+ // ── 1. Conversation: real messaging volume ──
235
+ const conversationBuckets = bucketStarts.map((s) => ({ start: s, counts: emptyConversationCounts() }));
236
+ const conversationTotals = emptyConversationCounts();
237
+ const convRows = db
187
238
  .query(
188
- `SELECT CAST((created_at - ?) / ? AS INTEGER) AS bucket, ${ANALYTICS_CATEGORY_SQL} AS category, COUNT(*) AS c
189
- FROM messages WHERE created_at > ? AND created_at <= ? GROUP BY bucket, category`,
239
+ `SELECT CAST((created_at - ?) / ? AS INTEGER) AS bucket, ${CONVERSATION_CATEGORY_SQL} AS category, COUNT(*) AS c
240
+ FROM messages WHERE created_at > ? AND created_at <= ? AND ${CONVERSATION_FILTER} GROUP BY bucket, category`,
190
241
  )
191
- .all(start, bucketMs, start, now) as Array<{ bucket: number; category: AnalyticsCategory; c: number }>;
192
- for (const row of volumeRows) {
193
- const idx = Math.min(Math.max(row.bucket, 0), bucketCount - 1);
194
- const bucket = buckets[idx];
242
+ .all(start, bucketMs, start, now) as Array<{ bucket: number; category: ConversationCategory; c: number }>;
243
+ for (const row of convRows) {
244
+ const bucket = conversationBuckets[clampIdx(row.bucket)];
195
245
  if (bucket && row.category in bucket.counts) {
196
246
  bucket.counts[row.category] += row.c;
197
- categories[row.category] += row.c;
247
+ conversationTotals[row.category] += row.c;
248
+ }
249
+ }
250
+ const conversationTotal = Object.values(conversationTotals).reduce((sum, v) => sum + v, 0);
251
+
252
+ // ── 2. Work: session-mirror cognition ──
253
+ const workBuckets = bucketStarts.map((s) => ({ start: s, counts: emptyWorkCounts() }));
254
+ const workTotals = emptyWorkCounts();
255
+ const workRows = db
256
+ .query(
257
+ `SELECT CAST((created_at - ?) / ? AS INTEGER) AS bucket,
258
+ json_extract(payload, '$.session.type') AS wtype, COUNT(*) AS c
259
+ FROM messages WHERE created_at > ? AND created_at <= ? AND kind = 'session'
260
+ AND json_extract(payload, '$.session.type') IN ('tool','reasoning','narration','response')
261
+ GROUP BY bucket, wtype`,
262
+ )
263
+ .all(start, bucketMs, start, now) as Array<{ bucket: number; wtype: WorkType; c: number }>;
264
+ for (const row of workRows) {
265
+ const bucket = workBuckets[clampIdx(row.bucket)];
266
+ if (bucket && row.wtype in bucket.counts) {
267
+ bucket.counts[row.wtype] += row.c;
268
+ workTotals[row.wtype] += row.c;
198
269
  }
199
270
  }
200
- const totalMessages = Object.values(categories).reduce((sum, v) => sum + v, 0);
271
+ const topTools = db
272
+ .query(
273
+ `SELECT json_extract(payload, '$.session.label') AS name, COUNT(*) AS count
274
+ FROM messages WHERE created_at > ? AND created_at <= ? AND kind = 'session'
275
+ AND json_extract(payload, '$.session.type') = 'tool'
276
+ AND json_extract(payload, '$.session.label') IS NOT NULL
277
+ GROUP BY name ORDER BY count DESC LIMIT 12`,
278
+ )
279
+ .all(start, now) as Array<{ name: string; count: number }>;
280
+ const turns = (
281
+ db
282
+ .query(
283
+ `SELECT COUNT(DISTINCT json_extract(payload, '$.session.turnId')) AS c
284
+ FROM messages WHERE created_at > ? AND created_at <= ? AND kind = 'session'
285
+ AND json_extract(payload, '$.session.turnId') IS NOT NULL`,
286
+ )
287
+ .get(start, now) as { c: number }
288
+ ).c;
289
+
290
+ // ── 3. User interaction: human prompts + agent reply latency ──
291
+ const userBuckets = bucketStarts.map((s) => ({ start: s, prompts: 0 }));
292
+ const promptRows = db
293
+ .query(
294
+ `SELECT CAST((created_at - ?) / ? AS INTEGER) AS bucket, COUNT(*) AS c
295
+ FROM messages WHERE created_at > ? AND created_at <= ? AND from_agent = 'user' GROUP BY bucket`,
296
+ )
297
+ .all(start, bucketMs, start, now) as Array<{ bucket: number; c: number }>;
298
+ let prompts = 0;
299
+ for (const row of promptRows) {
300
+ const bucket = userBuckets[clampIdx(row.bucket)];
301
+ if (bucket) bucket.prompts += row.c;
302
+ prompts += row.c;
303
+ }
304
+ // Latency = first threaded reply from a non-user agent, in (start, now] by the prompt's time.
305
+ const latencyRows = db
306
+ .query(
307
+ `SELECT (r.created_at - u.created_at) AS delta
308
+ FROM messages u JOIN messages r ON r.reply_to = u.id
309
+ WHERE u.from_agent = 'user' AND r.from_agent != 'user'
310
+ AND u.created_at > ? AND u.created_at <= ? AND r.created_at >= u.created_at`,
311
+ )
312
+ .all(start, now) as Array<{ delta: number }>;
313
+ const deltas = latencyRows.map((r) => r.delta).sort((a, b) => a - b);
314
+ const replied = deltas.length;
315
+ const latencyMs =
316
+ replied > 0
317
+ ? {
318
+ median: percentile(deltas, 50),
319
+ avg: Math.round(deltas.reduce((sum, d) => sum + d, 0) / replied),
320
+ p90: percentile(deltas, 90),
321
+ }
322
+ : null;
323
+
324
+ // ── 4. Fleet: registrations (spawns) over time + per-agent leaderboard ──
325
+ const fleetBuckets = bucketStarts.map((s) => ({ start: s, registrations: 0 }));
326
+ const regRows = db
327
+ .query(
328
+ `SELECT CAST((created_at - ?) / ? AS INTEGER) AS bucket, COUNT(*) AS c,
329
+ SUM(CASE WHEN spawned_by IS NOT NULL THEN 1 ELSE 0 END) AS spawned
330
+ FROM agents WHERE created_at > ? AND created_at <= ? GROUP BY bucket`,
331
+ )
332
+ .all(start, bucketMs, start, now) as Array<{ bucket: number; c: number; spawned: number }>;
333
+ let registrations = 0;
334
+ let spawnedByAgent = 0;
335
+ for (const row of regRows) {
336
+ const bucket = fleetBuckets[clampIdx(row.bucket)];
337
+ if (bucket) bucket.registrations += row.c;
338
+ registrations += row.c;
339
+ spawnedByAgent += row.spawned ?? 0;
340
+ }
341
+ const activeAgents = (
342
+ db
343
+ .query(
344
+ `SELECT COUNT(DISTINCT from_agent) AS c FROM messages
345
+ WHERE created_at > ? AND created_at <= ? AND from_agent NOT IN ('system','user')`,
346
+ )
347
+ .get(start, now) as { c: number }
348
+ ).c;
349
+ const leaderboard = db
350
+ .query(
351
+ `SELECT m.from_agent AS id, a.label AS label,
352
+ COUNT(DISTINCT CASE WHEN m.kind = 'session' THEN json_extract(m.payload, '$.session.turnId') END) AS turns,
353
+ SUM(CASE WHEN m.kind = 'session' AND json_extract(m.payload, '$.session.type') = 'tool' THEN 1 ELSE 0 END) AS toolCalls,
354
+ SUM(CASE WHEN m.kind != 'session' THEN 1 ELSE 0 END) AS messages
355
+ FROM messages m LEFT JOIN agents a ON a.id = m.from_agent
356
+ WHERE m.created_at > ? AND m.created_at <= ? AND m.from_agent NOT IN ('system','user')
357
+ GROUP BY m.from_agent ORDER BY (toolCalls + messages) DESC LIMIT 10`,
358
+ )
359
+ .all(start, now) as Array<{ id: string; label: string | null; turns: number; toolCalls: number; messages: number }>;
201
360
 
202
- // Busiest-hours heatmap (day-of-week × hour), server-local time.
361
+ // ── Busiest-hours heatmap (all activity, day-of-week × hour, server-local time) ──
203
362
  const heatmap: number[][] = Array.from({ length: 7 }, () => new Array(24).fill(0));
204
- const heatRows = getDb()
363
+ const heatRows = db
205
364
  .query(
206
365
  `SELECT CAST(strftime('%w', created_at / 1000, 'unixepoch', 'localtime') AS INTEGER) AS dow,
207
366
  CAST(strftime('%H', created_at / 1000, 'unixepoch', 'localtime') AS INTEGER) AS hour, COUNT(*) AS c
@@ -214,7 +373,7 @@ export function getAnalytics(period: AnalyticsPeriod, now: number = Date.now()):
214
373
  }
215
374
 
216
375
  const totalReactions = (
217
- getDb()
376
+ db
218
377
  .query(
219
378
  `SELECT COUNT(*) AS c FROM message_reactions r
220
379
  JOIN messages m ON m.id = r.message_id WHERE m.created_at > ? AND m.created_at <= ?`,
@@ -222,7 +381,19 @@ export function getAnalytics(period: AnalyticsPeriod, now: number = Date.now()):
222
381
  .get(start, now) as { c: number }
223
382
  ).c;
224
383
 
225
- return { period, start, now, bucketMs, bucketCount, buckets, categories, heatmap, totalMessages, totalReactions };
384
+ return {
385
+ period,
386
+ start,
387
+ now,
388
+ bucketMs,
389
+ bucketCount,
390
+ conversation: { buckets: conversationBuckets, totals: conversationTotals, total: conversationTotal },
391
+ work: { buckets: workBuckets, totals: workTotals, topTools, turns, toolCalls: workTotals.tool },
392
+ user: { prompts, replied, replyRate: prompts > 0 ? replied / prompts : 0, latencyMs, buckets: userBuckets },
393
+ fleet: { activeAgents, registrations, spawnedByAgent, buckets: fleetBuckets, leaderboard },
394
+ heatmap,
395
+ totalReactions,
396
+ };
226
397
  }
227
398
 
228
399
  export function getHealth(now: number = Date.now()): HealthReport {
package/src/index.ts CHANGED
@@ -10,6 +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
14
  import { startPlanLiveBindings } from "./services/plan-live-bindings";
14
15
  import { resolve, sep } from "path";
15
16
  import { gzipSync, brotliCompressSync, constants as zlibConstants } from "node:zlib";
@@ -74,6 +75,7 @@ function startServer(): void {
74
75
 
75
76
  assertSafeNetworkConfig(HOST);
76
77
  initDb(DB_PATH);
78
+ installNotificationSubscriptionRule();
77
79
  startPlanLiveBindings();
78
80
  getLifecycleManager().start();
79
81
  getCompactionWatch().start();
@@ -39,7 +39,7 @@ import type { WorkspaceMergePreview, WorkspaceRecord, WorkspaceStatus } from "./
39
39
  import { requestWorkspaceMerge } from "./workspace-merge";
40
40
  import { reconcileLandedWorkspace } from "./branch-landed";
41
41
  import { isAwaitingReviewerApproval } from "./reviewer-pipeline";
42
- import { preparePrCompletionScan } from "./workspace-pr-completion";
42
+ import { preparePrCompletionScan, reconcileLandedPr } from "./workspace-pr-completion";
43
43
  import { notifyAgentOffline } from "./agent-lifecycle-events";
44
44
  import { workspaceActiveClaim } from "./workspace-claim";
45
45
  import { reapOrphanedWorktrees } from "./workspace-orphans";
@@ -59,7 +59,7 @@ import {
59
59
  emitPoolBindingChanged,
60
60
  emitTaskChanged,
61
61
  } from "./sse";
62
- import { notifySystemMessage } from "./notify";
62
+ import { routeNotification, type NotificationType, type NotificationScope } from "./notification-router";
63
63
  import { pruneExpiredTokenRecords } from "./token-db";
64
64
  import { dissolveTeamById } from "./services/team";
65
65
  import { reapTransientAgents, resetTransientTrackerForTests } from "./services/transient-agent-reaper";
@@ -633,10 +633,16 @@ function wakeRepoSteward(ws: WorkspaceRecord, reason: string): string | null {
633
633
  const policyName = ensureRepoSteward(ws.repoRoot);
634
634
  if (!policyName) return null;
635
635
  try {
636
- notifySystemMessage(`policy:${policyName}`, {
637
- subject: `Steward: ${ws.status} workspace needs attention`,
638
- body: `Workspace \`${ws.branch ?? ws.id}\` (id ${ws.id}) in ${ws.repoRoot} is ${ws.status} and could not auto-land (${reason}). Claim it first so auto-merge yields: \`agent-relay workspace claim --id ${ws.id} --purpose steward\`. Inspect: \`agent-relay steward inspect ${ws.id}\`. Then cd into ${ws.worktreePath}, rebase onto ${ws.baseRef ?? "base"}, resolve, run checks, and land: \`agent-relay workspace land --id ${ws.id} --strategy rebase-ff\` — or \`agent-relay workspace release --id ${ws.id}\` and escalate if you can't.`,
639
- payload: { kind: "workspace.steward-task", workspaceId: ws.id, repoRoot: ws.repoRoot, worktreePath: ws.worktreePath, branch: ws.branch, baseRef: ws.baseRef, status: ws.status, reason },
636
+ // Obligation (ttl null by type default): a steward task must survive until claimed/handled.
637
+ routeNotification({
638
+ type: "workspace.steward_task",
639
+ scope: { workspaceId: ws.id, repoRoot: ws.repoRoot },
640
+ recipients: [`policy:${policyName}`],
641
+ message: {
642
+ subject: `Steward: ${ws.status} workspace needs attention`,
643
+ body: `Workspace \`${ws.branch ?? ws.id}\` (id ${ws.id}) in ${ws.repoRoot} is ${ws.status} and could not auto-land (${reason}). Claim it first so auto-merge yields: \`agent-relay workspace claim --id ${ws.id} --purpose steward\`. Inspect: \`agent-relay steward inspect ${ws.id}\`. Then cd into ${ws.worktreePath}, rebase onto ${ws.baseRef ?? "base"}, resolve, run checks, and land: \`agent-relay workspace land --id ${ws.id} --strategy rebase-ff\` — or \`agent-relay workspace release --id ${ws.id}\` and escalate if you can't.`,
644
+ payload: { kind: "workspace.steward-task", workspaceId: ws.id, repoRoot: ws.repoRoot, worktreePath: ws.worktreePath, branch: ws.branch, baseRef: ws.baseRef, status: ws.status, reason },
645
+ },
640
646
  });
641
647
  getLifecycleManager().onMessageForPolicy(policyName);
642
648
  patchWorkspaceMetadata(ws.id, { stewardWokenAt: Date.now(), stewardPolicy: policyName });
@@ -655,6 +661,7 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
655
661
  );
656
662
  const flagged: string[] = [], cleared: string[] = [], merged: string[] = [], notifiedStewards: string[] = [];
657
663
  const reviewerArmed: string[] = [], reviewerChangesRequested: string[] = [], relayPrMerged: string[] = [], relayPrWaiting: string[] = [];
664
+ const prRecycled: string[] = [];
658
665
 
659
666
  for (const ws of candidates) {
660
667
  const orch = orchestrators.find((candidate) => workspacePathWithinBase(ws.sourceCwd, candidate.baseDir));
@@ -687,6 +694,12 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
687
694
  // branch-landed.ts (#304).
688
695
  const landed = p.landed === true || p.prMerged === true;
689
696
  if (landed && LANDED_RECONCILE_STATUSES.has(ws.status)) {
697
+ // A PR-land merges asynchronously on the remote, outside the relay's land path. For a
698
+ // LIVE owner that means recycle (land-and-continue, #423); for a gone owner, finalize
699
+ // terminal `merged` (#304). `reconcileLandedPr` owns that split — see workspace-pr-completion.
700
+ const outcome = reconcileLandedPr(ws, p);
701
+ if (outcome.kind === "recycle") { emitCommand(outcome.command); prRecycled.push(ws.id); continue; }
702
+ if (outcome.kind === "wait") { relayPrWaiting.push(`${ws.id}:${outcome.reason}`); continue; }
690
703
  reconcileLandedWorkspace(ws, p);
691
704
  merged.push(ws.id);
692
705
  continue;
@@ -733,10 +746,16 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
733
746
  if (woke) notifiedStewards.push(woke);
734
747
  } else if (ws.stewardAgentId) {
735
748
  try {
736
- notifySystemMessage(ws.stewardAgentId, {
737
- subject: "Workspace merge conflict",
738
- body: `Workspace \`${ws.branch ?? ws.id}\` in ${ws.repoRoot} can no longer merge cleanly into ${p.baseRef ?? "base"} (${p.ahead ?? "?"} ahead, ${p.behind ?? "?"} behind). As repo steward, please coordinate resolution.`,
739
- payload: { kind: "workspace.conflict", workspaceId: ws.id, repoRoot: ws.repoRoot, branch: ws.branch, baseRef: p.baseRef, ahead: p.ahead, behind: p.behind },
749
+ // Obligation (ttl null by type default): a conflict handoff must reach the steward.
750
+ routeNotification({
751
+ type: "workspace.conflict",
752
+ scope: { workspaceId: ws.id, repoRoot: ws.repoRoot },
753
+ recipients: [ws.stewardAgentId],
754
+ message: {
755
+ subject: "Workspace merge conflict",
756
+ body: `Workspace \`${ws.branch ?? ws.id}\` in ${ws.repoRoot} can no longer merge cleanly into ${p.baseRef ?? "base"} (${p.ahead ?? "?"} ahead, ${p.behind ?? "?"} behind). As repo steward, please coordinate resolution.`,
757
+ payload: { kind: "workspace.conflict", workspaceId: ws.id, repoRoot: ws.repoRoot, branch: ws.branch, baseRef: p.baseRef, ahead: p.ahead, behind: p.behind },
758
+ },
740
759
  });
741
760
  notifiedStewards.push(ws.stewardAgentId);
742
761
  } catch {
@@ -752,7 +771,7 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
752
771
  }
753
772
  }
754
773
 
755
- return { scanned: candidates.length, flagged, cleared, merged, notifiedStewards, reviewerArmed, reviewerChangesRequested, relayPrMerged, relayPrWaiting };
774
+ return { scanned: candidates.length, flagged, cleared, merged, prRecycled, notifiedStewards, reviewerArmed, reviewerChangesRequested, relayPrMerged, relayPrWaiting };
756
775
  }
757
776
 
758
777
  // Deterministic auto-land (Layer 0, #167/#207/#242): land conflict-free ready
@@ -841,12 +860,13 @@ async function autoMergeCleanFastForwards(): Promise<Record<string, unknown>> {
841
860
  return { scanned: candidates.length, merged, heldByLease, heldByClaim, heldAwaitingApproval, leftForSteward, wokeStewards };
842
861
  }
843
862
 
844
- // Send a system DM, swallowing failures (a stale/missing/misconfigured target
845
- // must never break the GC sweep). Returns the target on success, null otherwise.
846
- function notifyTarget(target: string, subject: string, body: string, payload: Record<string, unknown>): string | null {
863
+ // Route a GC notification, swallowing failures (a stale/missing/misconfigured target must never
864
+ // break the GC sweep). Returns the target on success, null otherwise. These are all obligation-style
865
+ // workspace handoffs (null TTL by type default) they must survive until the recipient acts.
866
+ function notifyTarget(type: NotificationType, scope: NotificationScope, target: string, subject: string, body: string, payload: Record<string, unknown>): string | null {
847
867
  if (!target) return null;
848
868
  try {
849
- notifySystemMessage(target, { subject, body, payload });
869
+ routeNotification({ type, scope, recipients: [target], message: { subject, body, payload } });
850
870
  return target;
851
871
  } catch {
852
872
  return null;
@@ -898,6 +918,8 @@ async function workspaceGC(): Promise<Record<string, unknown>> {
898
918
  // steward hasn't been told, hand it off explicitly, then clear markers.
899
919
  if (strandedAt !== undefined && meta.strandedNotifiedSteward !== steward) {
900
920
  const sent = notifyTarget(
921
+ "workspace.steward_reassigned",
922
+ { workspaceId: fresh.id, repoRoot: fresh.repoRoot },
901
923
  steward!,
902
924
  "Workspace stewardship reassigned",
903
925
  `You are now steward for ${fresh.repoRoot}. Workspace \`${fresh.branch ?? fresh.id}\` is ${fresh.status} and was stranded without an online steward — please coordinate ${fresh.status === "conflict" ? "conflict resolution" : "review/merge"}.`,
@@ -913,6 +935,8 @@ async function workspaceGC(): Promise<Record<string, unknown>> {
913
935
  if (strandedAt === undefined) { patchWorkspaceMetadata(fresh.id, { strandedAt: now }); continue; }
914
936
  if (now - strandedAt < escalationMs || meta.escalatedAt) continue;
915
937
  const sent = notifyTarget(
938
+ "workspace.stranded_escalation",
939
+ { workspaceId: fresh.id, repoRoot: fresh.repoRoot },
916
940
  fallbackTarget,
917
941
  "Stranded workspace needs an owner",
918
942
  `Workspace \`${fresh.branch ?? fresh.id}\` in ${fresh.repoRoot} is ${fresh.status} with no online steward (all repo agents offline) for ${Math.round((now - strandedAt) / (60 * 60 * 1000))}h. Please coordinate ${fresh.status === "conflict" ? "conflict resolution" : "review/merge"} or clean up the worktree.`,
@@ -944,6 +968,8 @@ async function workspaceGC(): Promise<Record<string, unknown>> {
944
968
  // stranded abandon isn't silent (issue #157).
945
969
  const target = ws.stewardAgentId ?? fallbackTarget;
946
970
  const sent = notifyTarget(
971
+ "workspace.auto_abandoned",
972
+ { workspaceId: ws.id, repoRoot: ws.repoRoot },
947
973
  target,
948
974
  "Workspace auto-abandoned",
949
975
  `Workspace \`${ws.branch ?? ws.id}\` in ${ws.repoRoot} was auto-abandoned after ${Math.round(WORKSPACE_REVIEW_TTL_MS / DAY_MS)}d without steward action. Run workspace cleanup to reclaim the worktree.`,
@@ -0,0 +1,111 @@
1
+ import {
2
+ addSubscription,
3
+ cleanSubscriptionMode,
4
+ cleanSubscriptionType,
5
+ listSubscriptions,
6
+ NOTIFICATION_SUBSCRIPTION_MODES,
7
+ NOTIFICATION_SUBSCRIPTION_TYPES,
8
+ removeSubscription,
9
+ type NotificationSubscriptionMode,
10
+ } from "./db/notification-subscriptions";
11
+ import { ValidationError } from "./db";
12
+ import { McpAuthError } from "./mcp-errors";
13
+ import { authContextFromMcp } from "./services/auth-context";
14
+ import { cleanString } from "./validation";
15
+ import type { NotificationScope } from "./notification-router";
16
+
17
+ export const NOTIFICATION_SUBSCRIPTION_TOOLS = [
18
+ {
19
+ 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.",
21
+ requiredScopes: ["notifications:write"],
22
+ inputSchema: {
23
+ type: "object",
24
+ properties: {
25
+ type: { type: "string", enum: NOTIFICATION_SUBSCRIPTION_TYPES },
26
+ mode: { type: "string", enum: NOTIFICATION_SUBSCRIPTION_MODES, description: "Default opt_in. mute and opt_out suppress matching default deliveries." },
27
+ scope: { type: "object", description: "Optional partial notification scope match, such as { teamId } or { repoRoot }." },
28
+ },
29
+ required: ["type"],
30
+ additionalProperties: false,
31
+ },
32
+ },
33
+ {
34
+ name: "relay_unsubscribe",
35
+ description: "Remove this caller agent's notification subscription by id, or by type/mode/scope. Omitting mode removes all modes for the matched type/scope.",
36
+ requiredScopes: ["notifications:write"],
37
+ inputSchema: {
38
+ type: "object",
39
+ properties: {
40
+ id: { type: "string" },
41
+ type: { type: "string", enum: NOTIFICATION_SUBSCRIPTION_TYPES },
42
+ mode: { type: "string", enum: NOTIFICATION_SUBSCRIPTION_MODES },
43
+ scope: { type: "object" },
44
+ },
45
+ additionalProperties: false,
46
+ },
47
+ },
48
+ {
49
+ name: "relay_list_subscriptions",
50
+ description: "List this caller agent's durable notification subscriptions.",
51
+ requiredScopes: ["notifications:read"],
52
+ inputSchema: {
53
+ type: "object",
54
+ properties: {},
55
+ additionalProperties: false,
56
+ },
57
+ },
58
+ ] satisfies Array<{ name: string; description: string; requiredScopes: string[]; inputSchema: Record<string, unknown> }>;
59
+
60
+ type McpAuthLike = Parameters<typeof authContextFromMcp>[0];
61
+
62
+ export function relayNotificationSubscriptionTool(
63
+ auth: McpAuthLike,
64
+ name: string,
65
+ args: Record<string, unknown>,
66
+ ): Record<string, unknown> {
67
+ const agentId = callerAgentId(auth);
68
+ if (name === "relay_subscribe") {
69
+ const subscription = addSubscription({
70
+ agentId,
71
+ type: cleanSubscriptionType(args.type),
72
+ mode: cleanModeOrDefault(args.mode),
73
+ scopeMatch: cleanScope(args.scope),
74
+ });
75
+ return { subscription };
76
+ }
77
+ if (name === "relay_unsubscribe") {
78
+ const id = cleanString(args.id, "id", { max: 120 });
79
+ const type = args.type === undefined || args.type === null ? undefined : cleanSubscriptionType(args.type);
80
+ if (!id && !type) throw new ValidationError("id or type required");
81
+ const removed = removeSubscription({
82
+ agentId,
83
+ id,
84
+ type,
85
+ mode: args.mode === undefined || args.mode === null ? undefined : cleanSubscriptionMode(args.mode),
86
+ ...(Object.prototype.hasOwnProperty.call(args, "scope") ? { scopeMatch: cleanScope(args.scope) } : {}),
87
+ });
88
+ return { removed };
89
+ }
90
+ if (name === "relay_list_subscriptions") {
91
+ return { subscriptions: listSubscriptions(agentId) };
92
+ }
93
+ throw new ValidationError(`unknown tool: ${name}`);
94
+ }
95
+
96
+ function callerAgentId(auth: McpAuthLike): string {
97
+ const ctx = authContextFromMcp(auth);
98
+ if (!ctx.callerAgentId) throw new McpAuthError("notification subscriptions require a single caller agent identity");
99
+ return ctx.callerAgentId;
100
+ }
101
+
102
+ function cleanModeOrDefault(value: unknown): NotificationSubscriptionMode {
103
+ if (value === undefined || value === null) return "opt_in";
104
+ return cleanSubscriptionMode(value);
105
+ }
106
+
107
+ function cleanScope(value: unknown): Partial<NotificationScope> | null {
108
+ if (value === undefined || value === null) return null;
109
+ if (typeof value !== "object" || Array.isArray(value)) throw new ValidationError("scope must be an object");
110
+ return value as Partial<NotificationScope>;
111
+ }
package/src/mcp.ts CHANGED
@@ -50,6 +50,8 @@ import { PLAN_TOOLS, relayPlanTool } from "./mcp-plan-tools";
50
50
  import { TEAM_TOOLS, relayTeamTool } from "./mcp-team-tools";
51
51
  import { BLACKBOARD_TOOLS, relayBlackboardTool } from "./mcp-blackboard-tools";
52
52
  import { PROJECT_TOOLS, relayProjectTool } from "./mcp-project-tools";
53
+ import { NOTIFICATION_SUBSCRIPTION_TOOLS, relayNotificationSubscriptionTool } from "./mcp-notification-subscription-tools";
54
+ import { PROJECT_INSTRUCTION_POLICY_NAMES, parseProjectInstructions } from "./project-instructions";
53
55
  import { AGENT_ACTION_TOOL, relayAgentAction } from "./mcp-agent-action";
54
56
  import type { ActivityKind, AgentLifecycle, ArtifactKind, ArtifactSensitivity, AttachmentRef, Command, SendMessageInput, Message, SpawnApprovalMode, SpawnProvider, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceMode, WorkspaceRecord } from "./types";
55
57
  import { LAND_STRATEGIES, applyWorkspaceAction, waitForWorkspaceStatus, type WorkspaceAction } from "./workspace-actions";
@@ -310,6 +312,7 @@ const TOOLS: ToolDefinition[] = [
310
312
  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)." },
311
313
  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)." },
312
314
  systemPromptAppend: { type: "string" },
315
+ 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 },
313
316
  profile: { type: "string", description: "Agent profile name to apply (env, instructions, permissions, MCP/skills, spawn quota)." },
314
317
  tags: { type: "array", items: { type: "string" } },
315
318
  capabilities: { type: "array", items: { type: "string" } },
@@ -461,6 +464,7 @@ const TOOLS: ToolDefinition[] = [
461
464
  ...PLAN_TOOLS,
462
465
  ...BLACKBOARD_TOOLS,
463
466
  ...PROJECT_TOOLS,
467
+ ...NOTIFICATION_SUBSCRIPTION_TOOLS,
464
468
  ];
465
469
 
466
470
  export async function postMcp(req: Request): Promise<Response> {
@@ -577,6 +581,7 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
577
581
  else if (name.startsWith("relay_plan_")) result = relayPlanTool(auth, name, args);
578
582
  else if (name.startsWith("relay_blackboard_")) result = relayBlackboardTool(auth, name, args);
579
583
  else if (name.startsWith("relay_project_")) result = relayProjectTool(auth, name, args);
584
+ else if (name === "relay_subscribe" || name === "relay_unsubscribe" || name === "relay_list_subscriptions") result = relayNotificationSubscriptionTool(auth, name, args);
580
585
  else throw new ValidationError(`unknown tool: ${name}`);
581
586
 
582
587
  auditToolCall(auth, name, "ok", result);
@@ -874,6 +879,7 @@ async function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknow
874
879
  prompt: optionalString(args.prompt, "prompt", 16_000),
875
880
  expectReply: typeof args.expectReply === "boolean" ? args.expectReply : undefined,
876
881
  systemPromptAppend: optionalString(args.systemPromptAppend, "systemPromptAppend", 64_000),
882
+ projectInstructions: parseProjectInstructions(args.projectInstructions, (m) => new ValidationError(m)),
877
883
  tags: optionalStringArray(args.tags, "tags"),
878
884
  capabilities: optionalStringArray(args.capabilities, "capabilities"),
879
885
  providerArgs: optionalStringArray(args.providerArgs, "providerArgs"),