agent-relay-server 0.57.2 → 0.59.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 (148) hide show
  1. package/docs/openapi.json +151 -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/db/index.ts +2 -0
  113. package/src/db/project-entries.ts +270 -0
  114. package/src/db/projects.ts +304 -0
  115. package/src/db/schema.ts +50 -0
  116. package/src/db/stats.ts +209 -38
  117. package/src/db/teams.ts +13 -2
  118. package/src/maintenance.ts +9 -2
  119. package/src/mcp-project-tools.ts +352 -0
  120. package/src/mcp.ts +6 -0
  121. package/src/project-ingest.ts +186 -0
  122. package/src/project-instructions.ts +176 -0
  123. package/src/routes/agents-spawn.ts +2 -0
  124. package/src/routes/commands.ts +11 -5
  125. package/src/routes/index.ts +5 -0
  126. package/src/routes/projects.ts +55 -0
  127. package/src/runtime-tokens.ts +6 -0
  128. package/src/security.ts +4 -0
  129. package/src/services/spawn-agent.ts +33 -2
  130. package/src/services/team.ts +5 -1
  131. package/src/token-db.ts +3 -3
  132. package/src/workspace-merge.ts +11 -0
  133. package/src/workspace-pr-completion.ts +57 -1
  134. package/public/assets/agents-DZBM5dp_.js +0 -2
  135. package/public/assets/analytics-bvjb3Urs.js +0 -910
  136. package/public/assets/chat-YzX1z76l.js +0 -2
  137. package/public/assets/dist-DmhyU0Na.js +0 -2
  138. package/public/assets/dist-DxkMwkrr.js +0 -2
  139. package/public/assets/dist-yYauqYKR.js +0 -2
  140. package/public/assets/index-Ba2RDJaA.js +0 -21
  141. package/public/assets/index-CtFXWxBJ.css +0 -2
  142. package/public/assets/lucide-react-CMhBract.js +0 -9
  143. package/public/assets/memory-DeLwJFwH.js +0 -2
  144. package/public/assets/messages-Cww6dZnM.js +0 -2
  145. package/public/assets/overview-DjcRomU_.js +0 -2
  146. package/public/assets/pairs-DT0u2fnW.js +0 -2
  147. package/public/assets/teams-TqFntFB_.js +0 -2
  148. 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/db/teams.ts CHANGED
@@ -2,6 +2,7 @@ import { randomUUID } from "node:crypto";
2
2
  import { getAgent, setAgentTeam } from "./agents.ts";
3
3
  import { getDb, ValidationError } from "./connection.ts";
4
4
  import { rowToAgent } from "./mappers.ts";
5
+ import { unpromotedBlackboardEntries, type UnpromotedBlackboardSummary } from "./project-entries.ts";
5
6
  import type { AgentCard, Team } from "../types";
6
7
 
7
8
  interface TeamRow {
@@ -273,10 +274,20 @@ export function getTeamOutcomeAggregates(input: { recentLimit?: number } = {}):
273
274
  };
274
275
  }
275
276
 
276
- export function dissolveTeam(teamId: string, input: TeamDissolveInput = {}): Team {
277
+ export interface TeamDissolveResult {
278
+ team: Team;
279
+ /** Active blackboard findings not yet lifted into a project — surfaced (not auto-promoted)
280
+ * so the operator/agent can decide what graduates to durable project knowledge (#404). */
281
+ unpromotedBlackboard: UnpromotedBlackboardSummary;
282
+ }
283
+
284
+ export function dissolveTeam(teamId: string, input: TeamDissolveInput = {}): TeamDissolveResult {
277
285
  const existing = getTeam(teamId);
278
286
  if (!existing) throw new ValidationError(`team ${teamId} not found`);
279
287
  const members = getTeamRoster(teamId);
288
+ // Capture unpromoted findings BEFORE the row flips to dissolved — the blackboard rows
289
+ // survive dissolve, but reading the summary here keeps the surface atomic with the event.
290
+ const unpromotedBlackboard = unpromotedBlackboardEntries(teamId);
280
291
  const now = Date.now();
281
292
  getDb().query(`
282
293
  UPDATE teams
@@ -299,5 +310,5 @@ export function dissolveTeam(teamId: string, input: TeamDissolveInput = {}): Tea
299
310
  now,
300
311
  teamId,
301
312
  );
302
- return getTeam(teamId)!;
313
+ return { team: getTeam(teamId)!, unpromotedBlackboard };
303
314
  }
@@ -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";
@@ -655,6 +655,7 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
655
655
  );
656
656
  const flagged: string[] = [], cleared: string[] = [], merged: string[] = [], notifiedStewards: string[] = [];
657
657
  const reviewerArmed: string[] = [], reviewerChangesRequested: string[] = [], relayPrMerged: string[] = [], relayPrWaiting: string[] = [];
658
+ const prRecycled: string[] = [];
658
659
 
659
660
  for (const ws of candidates) {
660
661
  const orch = orchestrators.find((candidate) => workspacePathWithinBase(ws.sourceCwd, candidate.baseDir));
@@ -687,6 +688,12 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
687
688
  // branch-landed.ts (#304).
688
689
  const landed = p.landed === true || p.prMerged === true;
689
690
  if (landed && LANDED_RECONCILE_STATUSES.has(ws.status)) {
691
+ // A PR-land merges asynchronously on the remote, outside the relay's land path. For a
692
+ // LIVE owner that means recycle (land-and-continue, #423); for a gone owner, finalize
693
+ // terminal `merged` (#304). `reconcileLandedPr` owns that split — see workspace-pr-completion.
694
+ const outcome = reconcileLandedPr(ws, p);
695
+ if (outcome.kind === "recycle") { emitCommand(outcome.command); prRecycled.push(ws.id); continue; }
696
+ if (outcome.kind === "wait") { relayPrWaiting.push(`${ws.id}:${outcome.reason}`); continue; }
690
697
  reconcileLandedWorkspace(ws, p);
691
698
  merged.push(ws.id);
692
699
  continue;
@@ -752,7 +759,7 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
752
759
  }
753
760
  }
754
761
 
755
- return { scanned: candidates.length, flagged, cleared, merged, notifiedStewards, reviewerArmed, reviewerChangesRequested, relayPrMerged, relayPrWaiting };
762
+ return { scanned: candidates.length, flagged, cleared, merged, prRecycled, notifiedStewards, reviewerArmed, reviewerChangesRequested, relayPrMerged, relayPrWaiting };
756
763
  }
757
764
 
758
765
  // Deterministic auto-land (Layer 0, #167/#207/#242): land conflict-free ready