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.
- package/docs/openapi.json +1 -1
- package/package.json +2 -2
- package/public/assets/{MessageBubble-CHKzD4RY.js → MessageBubble-BrEKymoV.js} +2 -2
- package/public/assets/{MessageBubble-CHKzD4RY.js.map → MessageBubble-BrEKymoV.js.map} +1 -1
- package/public/assets/{PlanGraphPanel-BFVTJa0q.js → PlanGraphPanel-CXix1C-H.js} +2 -2
- package/public/assets/{PlanGraphPanel-BFVTJa0q.js.map → PlanGraphPanel-CXix1C-H.js.map} +1 -1
- package/public/assets/{activity-M-dFLU_O.js → activity-CRafQ1Je.js} +2 -2
- package/public/assets/{activity-M-dFLU_O.js.map → activity-CRafQ1Je.js.map} +1 -1
- package/public/assets/{agent-profiles-CvHSoyCX.js → agent-profiles-DW4o5-ND.js} +2 -2
- package/public/assets/{agent-profiles-CvHSoyCX.js.map → agent-profiles-DW4o5-ND.js.map} +1 -1
- package/public/assets/agents-Cbegv0Hm.js +2 -0
- package/public/assets/{agents-DZBM5dp_.js.map → agents-Cbegv0Hm.js.map} +1 -1
- package/public/assets/analytics-DX6T7JX3.js +910 -0
- package/public/assets/{analytics-bvjb3Urs.js.map → analytics-DX6T7JX3.js.map} +1 -1
- package/public/assets/{automation-CDSn9Uq-.js → automation-CzSBv4gg.js} +2 -2
- package/public/assets/{automation-CDSn9Uq-.js.map → automation-CzSBv4gg.js.map} +1 -1
- package/public/assets/{badge-Dj6PREuI.js → badge-Cy6qO_Jz.js} +2 -2
- package/public/assets/{badge-Dj6PREuI.js.map → badge-Cy6qO_Jz.js.map} +1 -1
- package/public/assets/{branch-state-badge-CdYg2HYc.js → branch-state-badge-CRA50r81.js} +2 -2
- package/public/assets/{branch-state-badge-CdYg2HYc.js.map → branch-state-badge-CRA50r81.js.map} +1 -1
- package/public/assets/{button-Cqpufqam.js → button-73wpRw65.js} +2 -2
- package/public/assets/{button-Cqpufqam.js.map → button-73wpRw65.js.map} +1 -1
- package/public/assets/{card-OmDF73CE.js → card-BU6PvVHv.js} +2 -2
- package/public/assets/{card-OmDF73CE.js.map → card-BU6PvVHv.js.map} +1 -1
- package/public/assets/{channels-BkxrdOFZ.js → channels-B1MQCPCT.js} +2 -2
- package/public/assets/{channels-BkxrdOFZ.js.map → channels-B1MQCPCT.js.map} +1 -1
- package/public/assets/chat-BqIaFYGJ.js +2 -0
- package/public/assets/{chat-YzX1z76l.js.map → chat-BqIaFYGJ.js.map} +1 -1
- package/public/assets/{connectors-Bg3rHgl_.js → connectors-C9aFJIrh.js} +2 -2
- package/public/assets/{connectors-Bg3rHgl_.js.map → connectors-C9aFJIrh.js.map} +1 -1
- package/public/assets/{copy-button-DI7b8A7_.js → copy-button-HRopUwui.js} +2 -2
- package/public/assets/{copy-button-DI7b8A7_.js.map → copy-button-HRopUwui.js.map} +1 -1
- package/public/assets/display-CketawEF.js.map +1 -1
- package/public/assets/{dist-CNjnTZEI.js → dist-CC5f7oYw.js} +2 -2
- package/public/assets/{dist-CNjnTZEI.js.map → dist-CC5f7oYw.js.map} +1 -1
- package/public/assets/{dist-DkeTrbTA.js → dist-CLHMeXYi.js} +2 -2
- package/public/assets/{dist-DkeTrbTA.js.map → dist-CLHMeXYi.js.map} +1 -1
- package/public/assets/{dist-C5t_OxI0.js → dist-Ci1MM51P.js} +2 -2
- package/public/assets/{dist-C5t_OxI0.js.map → dist-Ci1MM51P.js.map} +1 -1
- package/public/assets/{dist-CV3sgFiv.js → dist-Cko2KPW8.js} +2 -2
- package/public/assets/{dist-CV3sgFiv.js.map → dist-Cko2KPW8.js.map} +1 -1
- package/public/assets/dist-D41tzJYg.js +2 -0
- package/public/assets/{dist-yYauqYKR.js.map → dist-D41tzJYg.js.map} +1 -1
- package/public/assets/{dist-CoUvwLTy.js → dist-DFLciw0h.js} +2 -2
- package/public/assets/{dist-CoUvwLTy.js.map → dist-DFLciw0h.js.map} +1 -1
- package/public/assets/dist-DUoZnlB2.js +2 -0
- package/public/assets/{dist-DmhyU0Na.js.map → dist-DUoZnlB2.js.map} +1 -1
- package/public/assets/dist-De2oguBm.js +2 -0
- package/public/assets/{dist-DxkMwkrr.js.map → dist-De2oguBm.js.map} +1 -1
- package/public/assets/{dist-1x-LE9Uk.js → dist-DmN94rc8.js} +2 -2
- package/public/assets/{dist-1x-LE9Uk.js.map → dist-DmN94rc8.js.map} +1 -1
- package/public/assets/{dist-DV2tZ2fq.js → dist-Kkpnjx_M.js} +2 -2
- package/public/assets/{dist-DV2tZ2fq.js.map → dist-Kkpnjx_M.js.map} +1 -1
- package/public/assets/{dist-Ca3ujqG_.js → dist-T7u7IZGJ.js} +2 -2
- package/public/assets/{dist-Ca3ujqG_.js.map → dist-T7u7IZGJ.js.map} +1 -1
- package/public/assets/{es2015-DcavLcQ0.js → es2015-Df08Opjx.js} +2 -2
- package/public/assets/{es2015-DcavLcQ0.js.map → es2015-Df08Opjx.js.map} +1 -1
- package/public/assets/{formatted-body-impl-D8i9qqL1.js → formatted-body-impl-DOXH7oDF.js} +2 -2
- package/public/assets/{formatted-body-impl-D8i9qqL1.js.map → formatted-body-impl-DOXH7oDF.js.map} +1 -1
- package/public/assets/index-DaNOQJci.css +2 -0
- package/public/assets/index-br_Y6Cvt.js +21 -0
- package/public/assets/{index-Ba2RDJaA.js.map → index-br_Y6Cvt.js.map} +1 -1
- package/public/assets/{input-C_dl_9fG.js → input-B2EDz-BE.js} +2 -2
- package/public/assets/{input-C_dl_9fG.js.map → input-B2EDz-BE.js.map} +1 -1
- package/public/assets/{insights-D1aANwZ2.js → insights-B1edq5lk.js} +2 -2
- package/public/assets/{insights-D1aANwZ2.js.map → insights-B1edq5lk.js.map} +1 -1
- package/public/assets/{integrations-BrAr9bap.js → integrations-Cm4fjp7_.js} +2 -2
- package/public/assets/{integrations-BrAr9bap.js.map → integrations-Cm4fjp7_.js.map} +1 -1
- package/public/assets/{lib-B3iMVsf-.js → lib-BZrjIp-o.js} +3 -3
- package/public/assets/{lib-B3iMVsf-.js.map → lib-BZrjIp-o.js.map} +1 -1
- package/public/assets/lucide-react-H_-8G79A.js +9 -0
- package/public/assets/{lucide-react-CMhBract.js.map → lucide-react-H_-8G79A.js.map} +1 -1
- package/public/assets/{maintenance-C-xspsMG.js → maintenance-D6zvw0fU.js} +2 -2
- package/public/assets/{maintenance-C-xspsMG.js.map → maintenance-D6zvw0fU.js.map} +1 -1
- package/public/assets/{managed-agents-Bf3ni0vo.js → managed-agents-CzfEznM1.js} +2 -2
- package/public/assets/{managed-agents-Bf3ni0vo.js.map → managed-agents-CzfEznM1.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-DKbUBTf_.js → markdown-preview-impl-DZlCaTHQ.js} +2 -2
- package/public/assets/{markdown-preview-impl-DKbUBTf_.js.map → markdown-preview-impl-DZlCaTHQ.js.map} +1 -1
- package/public/assets/memory-CUUAzJDc.js +2 -0
- package/public/assets/{memory-DeLwJFwH.js.map → memory-CUUAzJDc.js.map} +1 -1
- package/public/assets/messages-6QZH46LG.js +2 -0
- package/public/assets/{messages-Cww6dZnM.js.map → messages-6QZH46LG.js.map} +1 -1
- package/public/assets/{orchestrators-DRjwROXw.js → orchestrators-CrcqkPuc.js} +2 -2
- package/public/assets/{orchestrators-DRjwROXw.js.map → orchestrators-CrcqkPuc.js.map} +1 -1
- package/public/assets/overview-418xXZO_.js +2 -0
- package/public/assets/{overview-DjcRomU_.js.map → overview-418xXZO_.js.map} +1 -1
- package/public/assets/pairs-CnedATgT.js +2 -0
- package/public/assets/{pairs-DT0u2fnW.js.map → pairs-CnedATgT.js.map} +1 -1
- package/public/assets/{react-dom-Dtl9-x_1.js → react-dom-WI6TjVe7.js} +2 -2
- package/public/assets/{react-dom-Dtl9-x_1.js.map → react-dom-WI6TjVe7.js.map} +1 -1
- package/public/assets/{security-Bwg3oFI8.js → security-DS13pbag.js} +2 -2
- package/public/assets/{security-Bwg3oFI8.js.map → security-DS13pbag.js.map} +1 -1
- package/public/assets/{select-Cariwv69.js → select-BOprGZDS.js} +2 -2
- package/public/assets/{select-Cariwv69.js.map → select-BOprGZDS.js.map} +1 -1
- package/public/assets/{settings-Bd6Ug3DO.js → settings-BgInn_f3.js} +4 -4
- package/public/assets/{settings-Bd6Ug3DO.js.map → settings-BgInn_f3.js.map} +1 -1
- package/public/assets/{store-BgJq_lN9.js → store-DHO_a8BD.js} +5 -5
- package/public/assets/{store-BgJq_lN9.js.map → store-DHO_a8BD.js.map} +1 -1
- package/public/assets/{tasks-DVz1zhOU.js → tasks-B-4oCPuv.js} +2 -2
- package/public/assets/{tasks-DVz1zhOU.js.map → tasks-B-4oCPuv.js.map} +1 -1
- package/public/assets/teams-sCQAs_QA.js +2 -0
- package/public/assets/{teams-TqFntFB_.js.map → teams-sCQAs_QA.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-OsJ5crlx.js → terminal-viewer-impl-BhqS2XpS.js} +3 -3
- package/public/assets/{terminal-viewer-impl-OsJ5crlx.js.map → terminal-viewer-impl-BhqS2XpS.js.map} +1 -1
- package/public/assets/{use-keyboard-viewport-BYHcL7sP.js → use-keyboard-viewport-fOL_3RHB.js} +2 -2
- package/public/assets/{use-keyboard-viewport-BYHcL7sP.js.map → use-keyboard-viewport-fOL_3RHB.js.map} +1 -1
- package/public/assets/{work-queue-B4b2jPLR.js → work-queue-BDhjlwUH.js} +2 -2
- package/public/assets/{work-queue-B4b2jPLR.js.map → work-queue-BDhjlwUH.js.map} +1 -1
- package/public/assets/workspaces-6I-C69nf.js +3 -0
- package/public/assets/{workspaces-m97LP4Eu.js.map → workspaces-6I-C69nf.js.map} +1 -1
- package/public/index.html +21 -21
- package/src/agent-lifecycle-events.ts +41 -21
- package/src/branch-landed.ts +46 -35
- package/src/channel-target.ts +10 -0
- package/src/config.ts +5 -0
- package/src/context-advisory.ts +13 -6
- package/src/db/message-reads.ts +6 -0
- package/src/db/messages.ts +16 -1
- package/src/db/migrations.ts +15 -0
- package/src/db/notification-subscriptions.ts +198 -0
- package/src/db/schema.ts +11 -0
- package/src/db/stats.ts +209 -38
- package/src/index.ts +2 -0
- package/src/maintenance.ts +41 -15
- package/src/mcp-notification-subscription-tools.ts +111 -0
- package/src/mcp.ts +6 -0
- package/src/notification-router.ts +168 -0
- package/src/notification-rules.ts +186 -0
- package/src/notify.ts +9 -0
- package/src/project-instructions.ts +176 -0
- package/src/reviewer-pipeline.ts +54 -40
- package/src/routes/agents-spawn.ts +2 -0
- package/src/routes/commands.ts +11 -5
- package/src/self-resume.ts +11 -6
- package/src/services/plan-live-bindings.ts +11 -6
- package/src/services/rate-limit-resume.ts +17 -10
- package/src/services/spawn-agent.ts +26 -1
- package/src/services/transient-agent-reaper.ts +11 -6
- package/src/token-db.ts +3 -3
- package/src/workspace-merge.ts +11 -0
- package/src/workspace-pr-completion.ts +72 -9
- package/public/assets/agents-DZBM5dp_.js +0 -2
- package/public/assets/analytics-bvjb3Urs.js +0 -910
- package/public/assets/chat-YzX1z76l.js +0 -2
- package/public/assets/dist-DmhyU0Na.js +0 -2
- package/public/assets/dist-DxkMwkrr.js +0 -2
- package/public/assets/dist-yYauqYKR.js +0 -2
- package/public/assets/index-Ba2RDJaA.js +0 -21
- package/public/assets/index-CtFXWxBJ.css +0 -2
- package/public/assets/lucide-react-CMhBract.js +0 -9
- package/public/assets/memory-DeLwJFwH.js +0 -2
- package/public/assets/messages-Cww6dZnM.js +0 -2
- package/public/assets/overview-DjcRomU_.js +0 -2
- package/public/assets/pairs-DT0u2fnW.js +0 -2
- package/public/assets/teams-TqFntFB_.js +0 -2
- 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
|
-
//
|
|
139
|
-
//
|
|
140
|
-
//
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
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 '
|
|
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
|
-
//
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
170
|
-
return {
|
|
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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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, ${
|
|
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:
|
|
192
|
-
for (const row of
|
|
193
|
-
const
|
|
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
|
-
|
|
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
|
|
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
|
|
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 =
|
|
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
|
-
|
|
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 {
|
|
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();
|
package/src/maintenance.ts
CHANGED
|
@@ -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 {
|
|
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
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
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
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
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
|
-
//
|
|
845
|
-
//
|
|
846
|
-
|
|
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
|
-
|
|
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"),
|