agent-relay-server 0.58.0 → 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 (135) 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/db/stats.ts +209 -38
  113. package/src/maintenance.ts +9 -2
  114. package/src/mcp.ts +3 -0
  115. package/src/project-instructions.ts +176 -0
  116. package/src/routes/agents-spawn.ts +2 -0
  117. package/src/routes/commands.ts +11 -5
  118. package/src/services/spawn-agent.ts +26 -1
  119. package/src/workspace-merge.ts +11 -0
  120. package/src/workspace-pr-completion.ts +57 -1
  121. package/public/assets/agents-DZBM5dp_.js +0 -2
  122. package/public/assets/analytics-bvjb3Urs.js +0 -910
  123. package/public/assets/chat-YzX1z76l.js +0 -2
  124. package/public/assets/dist-DmhyU0Na.js +0 -2
  125. package/public/assets/dist-DxkMwkrr.js +0 -2
  126. package/public/assets/dist-yYauqYKR.js +0 -2
  127. package/public/assets/index-Ba2RDJaA.js +0 -21
  128. package/public/assets/index-CtFXWxBJ.css +0 -2
  129. package/public/assets/lucide-react-CMhBract.js +0 -9
  130. package/public/assets/memory-DeLwJFwH.js +0 -2
  131. package/public/assets/messages-Cww6dZnM.js +0 -2
  132. package/public/assets/overview-DjcRomU_.js +0 -2
  133. package/public/assets/pairs-DT0u2fnW.js +0 -2
  134. package/public/assets/teams-TqFntFB_.js +0 -2
  135. 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 {
@@ -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
package/src/mcp.ts CHANGED
@@ -50,6 +50,7 @@ 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 { PROJECT_INSTRUCTION_POLICY_NAMES, parseProjectInstructions } from "./project-instructions";
53
54
  import { AGENT_ACTION_TOOL, relayAgentAction } from "./mcp-agent-action";
54
55
  import type { ActivityKind, AgentLifecycle, ArtifactKind, ArtifactSensitivity, AttachmentRef, Command, SendMessageInput, Message, SpawnApprovalMode, SpawnProvider, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceMode, WorkspaceRecord } from "./types";
55
56
  import { LAND_STRATEGIES, applyWorkspaceAction, waitForWorkspaceStatus, type WorkspaceAction } from "./workspace-actions";
@@ -310,6 +311,7 @@ const TOOLS: ToolDefinition[] = [
310
311
  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
312
  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
313
  systemPromptAppend: { type: "string" },
314
+ 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
315
  profile: { type: "string", description: "Agent profile name to apply (env, instructions, permissions, MCP/skills, spawn quota)." },
314
316
  tags: { type: "array", items: { type: "string" } },
315
317
  capabilities: { type: "array", items: { type: "string" } },
@@ -874,6 +876,7 @@ async function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknow
874
876
  prompt: optionalString(args.prompt, "prompt", 16_000),
875
877
  expectReply: typeof args.expectReply === "boolean" ? args.expectReply : undefined,
876
878
  systemPromptAppend: optionalString(args.systemPromptAppend, "systemPromptAppend", 64_000),
879
+ projectInstructions: parseProjectInstructions(args.projectInstructions, (m) => new ValidationError(m)),
877
880
  tags: optionalStringArray(args.tags, "tags"),
878
881
  capabilities: optionalStringArray(args.capabilities, "capabilities"),
879
882
  providerArgs: optionalStringArray(args.providerArgs, "providerArgs"),
@@ -0,0 +1,176 @@
1
+ // Project instruction projection (#409) — the relay-owned replacement for host
2
+ // CLAUDE.md bleed-in. Given a resolved project, render its `rule`/`fact` entries
3
+ // (own + inherited `contains`-ancestor entries from #406's cascade) into a compact,
4
+ // DETERMINISTIC, size-bounded instruction block that can be prepended to a spawned
5
+ // agent's systemPromptAppend.
6
+ //
7
+ // ADDITIVE-FROM-EMPTY (#403 decision 4): this composer builds the instruction world
8
+ // from NOTHING and ADDS project knowledge. It NEVER reads host CLAUDE.md or subtracts
9
+ // from it. Going fully vanilla later = simply not including host-derived (`ingested`)
10
+ // content — a policy switch here, zero schema/composer change. `project_entries.source`
11
+ // is that lever: the included-source set is a policy PARAMETER, and the `vanilla`
12
+ // policy includes `manual`+`promoted` while excluding `ingested`.
13
+ import { resolveProjectEntries } from "./db";
14
+ import { PROJECT_ENTRY_SOURCES, type Project, type ProjectEntryKind, type ProjectEntrySource, type ResolvedProjectEntry } from "./types";
15
+
16
+ /** Named source policies — THE additive-from-empty lever (#403 decision 4).
17
+ * `all` — every source (manual + promoted + ingested host-CLAUDE.md content).
18
+ * `vanilla` — manual + promoted only; EXCLUDES `ingested`, so a vanilla agent gets
19
+ * zero host-CLAUDE.md bleed-in while still receiving relay-native knowledge. */
20
+ export const PROJECT_INSTRUCTION_SOURCE_SETS = {
21
+ all: PROJECT_ENTRY_SOURCES,
22
+ vanilla: ["manual", "promoted"],
23
+ } as const satisfies Record<string, readonly ProjectEntrySource[]>;
24
+
25
+ export const PROJECT_INSTRUCTION_POLICY_NAMES = ["all", "vanilla"] as const;
26
+ export type ProjectInstructionPolicyName = (typeof PROJECT_INSTRUCTION_POLICY_NAMES)[number];
27
+
28
+ /** Per-spawn projection request. Presence (a non-undefined value on the spawn input)
29
+ * means "project the preamble"; absence means OFF. `policy` selects the source set. */
30
+ export interface ProjectInstructionPolicy {
31
+ policy?: ProjectInstructionPolicyName;
32
+ }
33
+
34
+ export interface ComposeProjectInstructionsOptions {
35
+ /** Named policy selecting the included source set. Default `all`. */
36
+ policy?: ProjectInstructionPolicyName;
37
+ /** Explicit source set; overrides `policy` when given (the low-level lever). */
38
+ includeSources?: readonly ProjectEntrySource[];
39
+ /** Entry kinds rendered, in this order. Default `["rule", "fact"]` (the directive +
40
+ * truth kinds; `decision`/`memory` are log/scratch, not preamble material). */
41
+ includeKinds?: readonly ProjectEntryKind[];
42
+ /** Merge transitive `contains`-ancestor entries (#406 cascade). Default true. */
43
+ includeInherited?: boolean;
44
+ /** Hard byte cap on the rendered block (UTF-8). Default 8192. The block is truncated
45
+ * deterministically (lowest-priority entries dropped first) to stay within it. */
46
+ maxBytes?: number;
47
+ }
48
+
49
+ const DEFAULT_KINDS: readonly ProjectEntryKind[] = ["rule", "fact"];
50
+ const DEFAULT_MAX_BYTES = 8192;
51
+ const TRUNCATION_NOTICE = "_(… additional project entries omitted to fit the instruction budget.)_";
52
+
53
+ const KIND_HEADINGS: Record<ProjectEntryKind, string> = {
54
+ rule: "Rules",
55
+ fact: "Facts",
56
+ decision: "Decisions",
57
+ memory: "Memory",
58
+ };
59
+
60
+ function byteLen(value: string): number {
61
+ return Buffer.byteLength(value, "utf8");
62
+ }
63
+
64
+ /** Stable dedup key — same kind + same (trimmed) title + body collapses to one entry.
65
+ * Uses NUL separators so distinct fields can't collide by concatenation. */
66
+ function dedupKey(entry: ResolvedProjectEntry): string {
67
+ return `${entry.kind}\u0000${entry.title.trim()}\u0000${(entry.body ?? "").trim()}`;
68
+ }
69
+
70
+ function entryLine(entry: ResolvedProjectEntry): string {
71
+ const body = entry.body?.trim();
72
+ const tag = entry.inherited ? " _(inherited)_" : "";
73
+ return body ? `- ${entry.title.trim()}: ${body}${tag}` : `- ${entry.title.trim()}${tag}`;
74
+ }
75
+
76
+ /**
77
+ * Render a project's instruction block. Deterministic: ordering is by kind (in
78
+ * `includeKinds` order), then own-before-inherited, then title (code-point), then id —
79
+ * fully independent of DB row timestamps. Output is deduped and hard-bounded to
80
+ * `maxBytes`. Returns "" when no entry survives the source/kind filter (the caller then
81
+ * leaves systemPromptAppend untouched, so the spawn stays byte-identical).
82
+ */
83
+ export function composeProjectInstructions(
84
+ project: Pick<Project, "id" | "slug" | "title">,
85
+ opts: ComposeProjectInstructionsOptions = {},
86
+ ): string {
87
+ const kinds = opts.includeKinds ?? DEFAULT_KINDS;
88
+ const kindRank = new Map<ProjectEntryKind, number>(kinds.map((kind, index) => [kind, index]));
89
+ const sources = new Set<ProjectEntrySource>(
90
+ opts.includeSources ?? PROJECT_INSTRUCTION_SOURCE_SETS[opts.policy ?? "all"],
91
+ );
92
+ const includeInherited = opts.includeInherited !== false;
93
+ const maxBytes = opts.maxBytes ?? DEFAULT_MAX_BYTES;
94
+
95
+ const resolved = resolveProjectEntries(project.id, { includeInherited });
96
+ const filtered = resolved.filter((entry) => kindRank.has(entry.kind) && sources.has(entry.source));
97
+
98
+ // Deterministic ordering, independent of DB row order/timestamps.
99
+ filtered.sort((a, b) => {
100
+ const byKind = (kindRank.get(a.kind) ?? 0) - (kindRank.get(b.kind) ?? 0);
101
+ if (byKind !== 0) return byKind;
102
+ const byInherited = Number(a.inherited) - Number(b.inherited); // own (false) first
103
+ if (byInherited !== 0) return byInherited;
104
+ const ta = a.title.trim();
105
+ const tb = b.title.trim();
106
+ if (ta !== tb) return ta < tb ? -1 : 1;
107
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
108
+ });
109
+
110
+ // Dedup, own-wins (own sorts before inherited above).
111
+ const seen = new Set<string>();
112
+ const deduped: ResolvedProjectEntry[] = [];
113
+ for (const entry of filtered) {
114
+ const key = dedupKey(entry);
115
+ if (seen.has(key)) continue;
116
+ seen.add(key);
117
+ deduped.push(entry);
118
+ }
119
+ if (deduped.length === 0) return "";
120
+
121
+ // Greedy size-bounded render. Entries are contiguous by kind (primary sort key), so a
122
+ // per-kind `## heading` is emitted once when the first entry of that kind is included.
123
+ const header = `# Project knowledge: ${project.title?.trim() || project.slug}`;
124
+ const budget = maxBytes - byteLen(`\n\n${TRUNCATION_NOTICE}`);
125
+ const lines: string[] = [header];
126
+ let bytes = byteLen(header);
127
+ let lastKind: ProjectEntryKind | null = null;
128
+ let truncated = false;
129
+
130
+ for (const entry of deduped) {
131
+ const segments: string[] = [];
132
+ if (entry.kind !== lastKind) segments.push("", `## ${KIND_HEADINGS[entry.kind]}`);
133
+ segments.push(entryLine(entry));
134
+ const addition = `\n${segments.join("\n")}`;
135
+ if (bytes + byteLen(addition) > budget) {
136
+ truncated = true;
137
+ break;
138
+ }
139
+ lines.push(...segments);
140
+ bytes += byteLen(addition);
141
+ lastKind = entry.kind;
142
+ }
143
+
144
+ if (truncated) lines.push("", TRUNCATION_NOTICE);
145
+ let out = lines.join("\n");
146
+ // Safety net: guarantee the hard byte cap even if the header + first entry overshoot.
147
+ if (byteLen(out) > maxBytes) {
148
+ out = Buffer.from(out, "utf8").subarray(0, maxBytes).toString("utf8");
149
+ }
150
+ return out;
151
+ }
152
+
153
+ /**
154
+ * Parse a spawn-transport's `projectInstructions` wire value into the normalized policy
155
+ * (or undefined = OFF). Shared by the MCP and HTTP spawn paths so they cannot drift.
156
+ * - undefined / null / false → undefined (OFF)
157
+ * - true → {} (ON, default policy)
158
+ * - { policy } → validated policy
159
+ * Throws `onInvalid(message)` for a malformed shape or unknown policy name.
160
+ */
161
+ export function parseProjectInstructions(
162
+ value: unknown,
163
+ onInvalid: (message: string) => Error,
164
+ ): ProjectInstructionPolicy | undefined {
165
+ if (value === undefined || value === null || value === false) return undefined;
166
+ if (value === true) return {};
167
+ if (typeof value !== "object") {
168
+ throw onInvalid("projectInstructions must be a boolean or { policy } object");
169
+ }
170
+ const policy = (value as Record<string, unknown>).policy;
171
+ if (policy === undefined) return {};
172
+ if (typeof policy !== "string" || !PROJECT_INSTRUCTION_POLICY_NAMES.includes(policy as ProjectInstructionPolicyName)) {
173
+ throw onInvalid(`projectInstructions.policy must be one of: ${PROJECT_INSTRUCTION_POLICY_NAMES.join(", ")}`);
174
+ }
175
+ return { policy: policy as ProjectInstructionPolicyName };
176
+ }
@@ -6,6 +6,7 @@ import { ValidationError, listAgents } from "../db";
6
6
  import { cleanMeta, cleanNullableString, cleanString, cleanStringArray, optionalEnum } from "../validation";
7
7
  import { registerAgent } from "../services/register-agent";
8
8
  import { spawnAgent, type SpawnAgentInput } from "../services/spawn-agent";
9
+ import { parseProjectInstructions } from "../project-instructions";
9
10
  import { authContextFromRequest } from "../services/auth-context";
10
11
  import { listHostDirectories } from "../agent-spawn";
11
12
  import { rankRouteCandidates, type RouteAdvisorInput } from "../context-router";
@@ -116,6 +117,7 @@ export const postAgentSpawn: Handler = async (req) => {
116
117
  profile: cleanString(parsed.body.profile, "profile", { max: 120 }),
117
118
  prompt: cleanString(parsed.body.prompt, "prompt", { max: 16000 }),
118
119
  systemPromptAppend: cleanString(parsed.body.systemPromptAppend, "systemPromptAppend", { max: 64_000 }),
120
+ projectInstructions: parseProjectInstructions(parsed.body.projectInstructions, (m) => new ValidationError(m)),
119
121
  tags: cleanStringArray(parsed.body.tags, "tags", { itemMax: 80, maxItems: 50 }),
120
122
  capabilities: cleanStringArray(parsed.body.capabilities, "capabilities", { itemMax: 80, maxItems: 50 }),
121
123
  providerArgs: cleanStringArray(parsed.body.providerArgs, "providerArgs", { itemMax: 80, maxItems: 50 }),
@@ -229,15 +229,21 @@ export const patchCommand: Handler = async (req, params) => {
229
229
  if (newBranch) {
230
230
  setWorkspaceBranch(workspaceId, newBranch, baseSha ?? mergedSha);
231
231
  }
232
- // #239 — push the author a "your branch landed" notice (no polling). Only on a
232
+ // #239 — push the author a "your branch landed" notice (no polling). Fires on a
233
233
  // real land; a no-op resolution (#230) merged nothing, so it earns no notice.
234
- if (command.result.merged === true && landedWorkspace) {
234
+ // #423 — a PR-land RECYCLE is a no-op merge (`merged:false`) but a land DID happen
235
+ // on the remote: the relay carries that ground truth in `params.prLanded`, so the
236
+ // notice fires from it with the recycled `newBranch` — full rebase-ff parity.
237
+ const prLanded = isRecord(command.params.prLanded) ? command.params.prLanded : undefined;
238
+ if ((command.result.merged === true || prLanded) && landedWorkspace) {
235
239
  notifyBranchLanded({
236
240
  workspace: landedWorkspace,
237
- mergedSha,
238
- subject: cleanString(command.result.subject, "result.subject", { max: 200 }),
241
+ mergedSha: prLanded ? cleanString(prLanded.sha, "params.prLanded.sha", { max: 64 }) ?? mergedSha : mergedSha,
242
+ subject: prLanded
243
+ ? cleanString(prLanded.subject, "params.prLanded.subject", { max: 200 })
244
+ : cleanString(command.result.subject, "result.subject", { max: 200 }),
239
245
  newBranch,
240
- pushed: typeof command.result.pushed === "boolean" ? command.result.pushed : undefined,
246
+ pushed: prLanded ? true : typeof command.result.pushed === "boolean" ? command.result.pushed : undefined,
241
247
  });
242
248
  }
243
249
  }
@@ -32,6 +32,7 @@ import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams } fr
32
32
  import { runnerRuntimeTokenEnv } from "../runtime-tokens";
33
33
  import { selectSpawnOrchestrator } from "../spawn-targets";
34
34
  import { getAgentProfile } from "../config-store";
35
+ import { composeProjectInstructions, type ProjectInstructionPolicy } from "../project-instructions";
35
36
  import { hasComponentScope, isComponentAuthorizedFor } from "../security";
36
37
  import { isPathWithinBase } from "../utils";
37
38
  import { McpAuthError, McpNotFoundError } from "../mcp-errors";
@@ -62,6 +63,16 @@ export interface SpawnAgentInput {
62
63
  profile?: string;
63
64
  prompt?: string;
64
65
  systemPromptAppend?: string;
66
+ /**
67
+ * #409 — project instruction projection. When set, relay composes an instruction
68
+ * preamble from the spawn's resolved project (own entries + inherited `contains`-
69
+ * ancestors) and PREPENDS it to systemPromptAppend. Additive-from-empty (#403
70
+ * decision 4): the block is built only from `project_entries`, never from host
71
+ * CLAUDE.md. Absent (undefined) = OFF → the spawn command is byte-identical to today.
72
+ * `policy` selects the included source set: `all` (manual+promoted+ingested) or
73
+ * `vanilla` (manual+promoted; excludes `ingested` host-CLAUDE.md content).
74
+ */
75
+ projectInstructions?: ProjectInstructionPolicy;
65
76
  /**
66
77
  * #413 — does this spawn carry a report-back contract? When true, relay seeds a pending
67
78
  * reply obligation from the spawner to the child at registration, so the child can't go
@@ -197,6 +208,20 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
197
208
  // resolves it. Absent (graceful) when the cwd is inside no registered root.
198
209
  const callerProject = resolveProjectForCwd(resolvedCwd);
199
210
 
211
+ // #409 — instruction projection (default OFF). Only when the caller opts in AND the
212
+ // spawn cwd resolves to a project do we compose a knowledge preamble and PREPEND it to
213
+ // systemPromptAppend. Absent flag / no project / empty block ⇒ systemPromptAppend is
214
+ // left exactly as the caller passed it, so the spawn command stays byte-identical.
215
+ let systemPromptAppend = input.systemPromptAppend;
216
+ if (input.projectInstructions && callerProject) {
217
+ const block = composeProjectInstructions(callerProject, {
218
+ ...(input.projectInstructions.policy ? { policy: input.projectInstructions.policy } : {}),
219
+ });
220
+ if (block) {
221
+ systemPromptAppend = systemPromptAppend ? `${block}\n\n${systemPromptAppend}` : block;
222
+ }
223
+ }
224
+
200
225
  // Child runner token. `agentInitiated` (set whenever an agent is the caller) forces a
201
226
  // non-spawn-capable child (no grandchildren); `spawnedBy` stamps authoritative lineage the
202
227
  // child can't forge (it registers with spawnedBy = caller, read from the signed token).
@@ -244,7 +269,7 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
244
269
  permissionMode: approvalMode,
245
270
  providerArgs: input.providerArgs ?? [],
246
271
  prompt: input.prompt,
247
- systemPromptAppend: input.systemPromptAppend,
272
+ systemPromptAppend,
248
273
  ...(expectReply ? { expectReply } : {}),
249
274
  policyName: input.policyName,
250
275
  spawnRequestId,