agent-relay-server 0.91.4 → 0.93.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (179) hide show
  1. package/docs/openapi.json +29 -1
  2. package/package.json +2 -2
  3. package/public/assets/MessageBubble-DX16Hbb5.js +3 -0
  4. package/public/assets/MessageBubble-DX16Hbb5.js.map +1 -0
  5. package/public/assets/{PlanGraphPanel-BwEOOqdj.js → PlanGraphPanel-BuWerKhs.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-BwEOOqdj.js.map → PlanGraphPanel-BuWerKhs.js.map} +1 -1
  7. package/public/assets/activity-tuTEn3vn.js +2 -0
  8. package/public/assets/{activity-DD9bl5uf.js.map → activity-tuTEn3vn.js.map} +1 -1
  9. package/public/assets/{agent-profiles-CLJNeVxc.js → agent-profiles-CAwmjdDx.js} +2 -2
  10. package/public/assets/{agent-profiles-CLJNeVxc.js.map → agent-profiles-CAwmjdDx.js.map} +1 -1
  11. package/public/assets/agents-B-BSBrM5.js +2 -0
  12. package/public/assets/{agents-DJgqCIqi.js.map → agents-B-BSBrM5.js.map} +1 -1
  13. package/public/assets/{analytics-C1F28oqr.js → analytics-aI_oGHtR.js} +3 -3
  14. package/public/assets/{analytics-C1F28oqr.js.map → analytics-aI_oGHtR.js.map} +1 -1
  15. package/public/assets/{automation-Vbei8P9W.js → automation-BAQM1dBh.js} +2 -2
  16. package/public/assets/{automation-Vbei8P9W.js.map → automation-BAQM1dBh.js.map} +1 -1
  17. package/public/assets/{badge-CK6ejiOh.js → badge-fjSgjnDc.js} +2 -2
  18. package/public/assets/{badge-CK6ejiOh.js.map → badge-fjSgjnDc.js.map} +1 -1
  19. package/public/assets/branch-state-badge-DaafW7sN.js +2 -0
  20. package/public/assets/{branch-state-badge-Q4W68jxj.js.map → branch-state-badge-DaafW7sN.js.map} +1 -1
  21. package/public/assets/{button-BAwKuRIq.js → button-DidMOp8n.js} +2 -2
  22. package/public/assets/{button-BAwKuRIq.js.map → button-DidMOp8n.js.map} +1 -1
  23. package/public/assets/card-CpLnx1JJ.js +2 -0
  24. package/public/assets/{card-DDxxo-Fo.js.map → card-CpLnx1JJ.js.map} +1 -1
  25. package/public/assets/{channels-BQbNtrtF.js → channels-DHjTCcE8.js} +2 -2
  26. package/public/assets/{channels-BQbNtrtF.js.map → channels-DHjTCcE8.js.map} +1 -1
  27. package/public/assets/chat-uWKl3MpS.js +2 -0
  28. package/public/assets/{chat-Dw5xpc5W.js.map → chat-uWKl3MpS.js.map} +1 -1
  29. package/public/assets/{connectors-CmARSKoV.js → connectors-CI97PYOl.js} +2 -2
  30. package/public/assets/{connectors-CmARSKoV.js.map → connectors-CI97PYOl.js.map} +1 -1
  31. package/public/assets/copy-button-CKAwTG9g.js +2 -0
  32. package/public/assets/{copy-button-D3B_s7J7.js.map → copy-button-CKAwTG9g.js.map} +1 -1
  33. package/public/assets/display-CWMHll1J.js.map +1 -1
  34. package/public/assets/{dist-Dmiv1tSO.js → dist-BEpGArlE.js} +2 -2
  35. package/public/assets/{dist-Dmiv1tSO.js.map → dist-BEpGArlE.js.map} +1 -1
  36. package/public/assets/dist-BZ-CpDXy.js +2 -0
  37. package/public/assets/{dist-CIbvPDQV.js.map → dist-BZ-CpDXy.js.map} +1 -1
  38. package/public/assets/{dist-DLsSqk6h.js → dist-BaDWyAAG.js} +2 -2
  39. package/public/assets/{dist-DLsSqk6h.js.map → dist-BaDWyAAG.js.map} +1 -1
  40. package/public/assets/{dist-CAFUEpK2.js → dist-BivRSJg7.js} +2 -2
  41. package/public/assets/{dist-CAFUEpK2.js.map → dist-BivRSJg7.js.map} +1 -1
  42. package/public/assets/{dist-CL2npVDe.js → dist-C9ntDbUp.js} +2 -2
  43. package/public/assets/{dist-CL2npVDe.js.map → dist-C9ntDbUp.js.map} +1 -1
  44. package/public/assets/dist-CEb4q1gZ.js +2 -0
  45. package/public/assets/{dist-DyqEeyBI.js.map → dist-CEb4q1gZ.js.map} +1 -1
  46. package/public/assets/dist-DBDYIgS5.js +2 -0
  47. package/public/assets/{dist-CoRABSO3.js.map → dist-DBDYIgS5.js.map} +1 -1
  48. package/public/assets/{dist-ERYvDMdt.js → dist-DNxK5Ibj.js} +2 -2
  49. package/public/assets/{dist-ERYvDMdt.js.map → dist-DNxK5Ibj.js.map} +1 -1
  50. package/public/assets/{dist-YjP8Gd1C.js → dist-DayffsX1.js} +2 -2
  51. package/public/assets/{dist-YjP8Gd1C.js.map → dist-DayffsX1.js.map} +1 -1
  52. package/public/assets/{dist-YQVQrtnc.js → dist-L3vQoV3T.js} +2 -2
  53. package/public/assets/{dist-YQVQrtnc.js.map → dist-L3vQoV3T.js.map} +1 -1
  54. package/public/assets/{dist-CnRv0y2x.js → dist-uFOP-AfS.js} +2 -2
  55. package/public/assets/{dist-CnRv0y2x.js.map → dist-uFOP-AfS.js.map} +1 -1
  56. package/public/assets/{es2015-Yto-4EBw.js → es2015-CJr6bLcU.js} +2 -2
  57. package/public/assets/{es2015-Yto-4EBw.js.map → es2015-CJr6bLcU.js.map} +1 -1
  58. package/public/assets/{formatted-body-impl-BisZ0FtO.js → formatted-body-impl-DuqZunxL.js} +2 -2
  59. package/public/assets/{formatted-body-impl-BisZ0FtO.js.map → formatted-body-impl-DuqZunxL.js.map} +1 -1
  60. package/public/assets/index-DLjkMB-b.css +2 -0
  61. package/public/assets/index-dMB8klzy.js +23 -0
  62. package/public/assets/index-dMB8klzy.js.map +1 -0
  63. package/public/assets/{input-CLW2gDUM.js → input-BbH2Jq-f.js} +2 -2
  64. package/public/assets/{input-CLW2gDUM.js.map → input-BbH2Jq-f.js.map} +1 -1
  65. package/public/assets/{insights-D9ytYZyE.js → insights-C0lZ7n8D.js} +2 -2
  66. package/public/assets/{insights-D9ytYZyE.js.map → insights-C0lZ7n8D.js.map} +1 -1
  67. package/public/assets/integrations-DXh00Ss-.js +2 -0
  68. package/public/assets/{integrations-D9Cx-7ma.js.map → integrations-DXh00Ss-.js.map} +1 -1
  69. package/public/assets/{lib-DdCM7GfN.js → lib-B0r9alsu.js} +2 -2
  70. package/public/assets/{lib-DdCM7GfN.js.map → lib-B0r9alsu.js.map} +1 -1
  71. package/public/assets/{lucide-react-CsPXvXMC.js → lucide-react-BCarrGVa.js} +2 -2
  72. package/public/assets/{lucide-react-CsPXvXMC.js.map → lucide-react-BCarrGVa.js.map} +1 -1
  73. package/public/assets/{maintenance-BA93FvLR.js → maintenance-Dh9nzjYV.js} +2 -2
  74. package/public/assets/{maintenance-BA93FvLR.js.map → maintenance-Dh9nzjYV.js.map} +1 -1
  75. package/public/assets/{managed-agents-DGwdEIzA.js → managed-agents-Bsveljn4.js} +4 -4
  76. package/public/assets/{managed-agents-DGwdEIzA.js.map → managed-agents-Bsveljn4.js.map} +1 -1
  77. package/public/assets/{markdown-preview-impl-C7FDXM5c.js → markdown-preview-impl-CYpMFis9.js} +2 -2
  78. package/public/assets/{markdown-preview-impl-C7FDXM5c.js.map → markdown-preview-impl-CYpMFis9.js.map} +1 -1
  79. package/public/assets/{memory-CHzkRJqf.js → memory-DMFQMXrp.js} +2 -2
  80. package/public/assets/{memory-CHzkRJqf.js.map → memory-DMFQMXrp.js.map} +1 -1
  81. package/public/assets/messages-Cnzc7j50.js +2 -0
  82. package/public/assets/{messages-B7CJ5tgT.js.map → messages-Cnzc7j50.js.map} +1 -1
  83. package/public/assets/{orchestrators-DOy9xIx2.js → orchestrators-DSoDPNVS.js} +2 -2
  84. package/public/assets/{orchestrators-DOy9xIx2.js.map → orchestrators-DSoDPNVS.js.map} +1 -1
  85. package/public/assets/{overview-DLbv1BWa.js → overview-OsRxo5XQ.js} +2 -2
  86. package/public/assets/{overview-DLbv1BWa.js.map → overview-OsRxo5XQ.js.map} +1 -1
  87. package/public/assets/{pairs-BDQo-k3F.js → pairs-D4UiUhyW.js} +2 -2
  88. package/public/assets/{pairs-BDQo-k3F.js.map → pairs-D4UiUhyW.js.map} +1 -1
  89. package/public/assets/{projects-hU5KGW_T.js → projects-CYzU-Cf3.js} +2 -2
  90. package/public/assets/{projects-hU5KGW_T.js.map → projects-CYzU-Cf3.js.map} +1 -1
  91. package/public/assets/{react-dom-CEl58oxP.js → react-dom-CYpnckci.js} +2 -2
  92. package/public/assets/{react-dom-CEl58oxP.js.map → react-dom-CYpnckci.js.map} +1 -1
  93. package/public/assets/{security-9WrCcrWs.js → security-CC2F6xdd.js} +2 -2
  94. package/public/assets/{security-9WrCcrWs.js.map → security-CC2F6xdd.js.map} +1 -1
  95. package/public/assets/{select-CRjeGZ9a.js → select-CD38f1WQ.js} +2 -2
  96. package/public/assets/{select-CRjeGZ9a.js.map → select-CD38f1WQ.js.map} +1 -1
  97. package/public/assets/settings-T3B40Amb.js +6 -0
  98. package/public/assets/settings-T3B40Amb.js.map +1 -0
  99. package/public/assets/{store-DsqUEZOf.js → store-CJeafbP1.js} +3 -3
  100. package/public/assets/{store-DsqUEZOf.js.map → store-CJeafbP1.js.map} +1 -1
  101. package/public/assets/{tasks-86St2vpn.js → tasks-BkrhWMw7.js} +2 -2
  102. package/public/assets/{tasks-86St2vpn.js.map → tasks-BkrhWMw7.js.map} +1 -1
  103. package/public/assets/{teams-C9WUxzud.js → teams-DyzaREAg.js} +2 -2
  104. package/public/assets/{teams-C9WUxzud.js.map → teams-DyzaREAg.js.map} +1 -1
  105. package/public/assets/{terminal-viewer-impl-yXWyt2fm.js → terminal-viewer-impl-B5JeHof3.js} +3 -3
  106. package/public/assets/{terminal-viewer-impl-yXWyt2fm.js.map → terminal-viewer-impl-B5JeHof3.js.map} +1 -1
  107. package/public/assets/{use-keyboard-viewport-DABeIMEJ.js → use-keyboard-viewport-BYCJkZCN.js} +2 -2
  108. package/public/assets/{use-keyboard-viewport-DABeIMEJ.js.map → use-keyboard-viewport-BYCJkZCN.js.map} +1 -1
  109. package/public/assets/user-avatar-C7i11_ju.js +2 -0
  110. package/public/assets/{user-avatar-tomKla2H.js.map → user-avatar-C7i11_ju.js.map} +1 -1
  111. package/public/assets/{work-queue-BtLMzf0g.js → work-queue-DHp4p1Zk.js} +2 -2
  112. package/public/assets/{work-queue-BtLMzf0g.js.map → work-queue-DHp4p1Zk.js.map} +1 -1
  113. package/public/assets/workspaces-BzwZvbsq.js +3 -0
  114. package/public/assets/{workspaces-C740m1ys.js.map → workspaces-BzwZvbsq.js.map} +1 -1
  115. package/public/index.html +21 -21
  116. package/src/agent-lifecycle-events.ts +0 -10
  117. package/src/branch-landed.ts +17 -29
  118. package/src/bus.ts +9 -2
  119. package/src/config-store.ts +13 -26
  120. package/src/config.ts +1 -0
  121. package/src/context-advisory.ts +20 -10
  122. package/src/db/agent-predicates.ts +21 -0
  123. package/src/db/agents.ts +3 -3
  124. package/src/db/delivery.ts +38 -0
  125. package/src/db/messages.ts +18 -1
  126. package/src/db/migrations.ts +17 -0
  127. package/src/db/scheduled-timers.ts +33 -12
  128. package/src/db/schema.ts +4 -0
  129. package/src/db/workspaces.ts +44 -2
  130. package/src/maintenance/constants.ts +2 -0
  131. package/src/maintenance/jobs.ts +21 -8
  132. package/src/maintenance/workspaces.ts +76 -0
  133. package/src/mcp/index.ts +1 -1
  134. package/src/mcp/tools-messaging.ts +13 -3
  135. package/src/mcp/tools-spawn.ts +30 -2
  136. package/src/mcp-notification-subscription-tools.ts +55 -2
  137. package/src/notification-router.ts +163 -9
  138. package/src/notification-rules.ts +7 -1
  139. package/src/notification-settings-store.ts +47 -0
  140. package/src/notification-settings.ts +155 -0
  141. package/src/notification-types.ts +2 -0
  142. package/src/quota-advisory.ts +0 -3
  143. package/src/routes/agents-spawn.ts +15 -2
  144. package/src/routes/agents.ts +17 -2
  145. package/src/routes/commands.ts +5 -0
  146. package/src/routes/index.ts +2 -0
  147. package/src/routes/notifications.ts +36 -0
  148. package/src/routes/settings.ts +6 -0
  149. package/src/security.ts +1 -0
  150. package/src/services/managed-running.ts +18 -1
  151. package/src/services/rate-limit-resume.ts +3 -0
  152. package/src/services/register-agent.ts +7 -1
  153. package/src/services/scheduler.ts +29 -1
  154. package/src/services/spawn-agent.ts +62 -2
  155. package/src/settings/registry.ts +30 -44
  156. package/src/spawn-command.ts +10 -0
  157. package/src/sse.ts +1 -0
  158. package/src/validation.ts +7 -2
  159. package/src/workspace-auto-merge.ts +53 -9
  160. package/public/assets/MessageBubble-BORB9AKu.js +0 -3
  161. package/public/assets/MessageBubble-BORB9AKu.js.map +0 -1
  162. package/public/assets/activity-DD9bl5uf.js +0 -2
  163. package/public/assets/agents-DJgqCIqi.js +0 -2
  164. package/public/assets/branch-state-badge-Q4W68jxj.js +0 -2
  165. package/public/assets/card-DDxxo-Fo.js +0 -2
  166. package/public/assets/chat-Dw5xpc5W.js +0 -2
  167. package/public/assets/copy-button-D3B_s7J7.js +0 -2
  168. package/public/assets/dist-CIbvPDQV.js +0 -2
  169. package/public/assets/dist-CoRABSO3.js +0 -2
  170. package/public/assets/dist-DyqEeyBI.js +0 -2
  171. package/public/assets/index-Bsi2NH0v.js +0 -23
  172. package/public/assets/index-Bsi2NH0v.js.map +0 -1
  173. package/public/assets/index-umexz3cs.css +0 -2
  174. package/public/assets/integrations-D9Cx-7ma.js +0 -2
  175. package/public/assets/messages-B7CJ5tgT.js +0 -2
  176. package/public/assets/settings-Durch6xW.js +0 -4
  177. package/public/assets/settings-Durch6xW.js.map +0 -1
  178. package/public/assets/user-avatar-tomKla2H.js +0 -2
  179. package/public/assets/workspaces-C740m1ys.js +0 -3
@@ -74,10 +74,15 @@ import type {
74
74
  ProviderCapabilities,
75
75
  TaskStatusInput,
76
76
  WorkspaceMetadata,
77
+ WorkspaceAutoMergePolicy,
78
+ WorkspaceLandingPolicy,
77
79
  WorkspaceRecord,
78
80
  WorkspaceStatus,
79
81
  } from "../types";
80
82
 
83
+ const SPAWN_LAND_STRATEGIES = ["direct", "pr"] as const;
84
+ const SPAWN_AUTO_MERGE_POLICIES = ["on-green", "on-approval", "manual"] as const;
85
+
81
86
  function rowToWorkspace(row: any): WorkspaceRecord {
82
87
  return {
83
88
  id: row.id,
@@ -102,6 +107,36 @@ function rowToWorkspace(row: any): WorkspaceRecord {
102
107
  };
103
108
  }
104
109
 
110
+ function workspaceLandMetadataFrom(value: Record<string, unknown> | undefined): {
111
+ landStrategy?: WorkspaceLandingPolicy;
112
+ autoMerge?: WorkspaceAutoMergePolicy;
113
+ reviewer?: string;
114
+ } {
115
+ if (!value) return {};
116
+ const landStrategy = typeof value.landStrategy === "string" && (SPAWN_LAND_STRATEGIES as readonly string[]).includes(value.landStrategy)
117
+ ? value.landStrategy as WorkspaceLandingPolicy
118
+ : undefined;
119
+ const autoMerge = typeof value.autoMerge === "string" && (SPAWN_AUTO_MERGE_POLICIES as readonly string[]).includes(value.autoMerge)
120
+ ? value.autoMerge as WorkspaceAutoMergePolicy
121
+ : undefined;
122
+ const reviewer = typeof value.reviewer === "string" && value.reviewer.trim() ? value.reviewer.trim() : undefined;
123
+ return {
124
+ ...(landStrategy ? { landStrategy } : {}),
125
+ ...(autoMerge ? { autoMerge } : {}),
126
+ ...(reviewer ? { reviewer } : {}),
127
+ };
128
+ }
129
+
130
+ function spawnCommandLandMetadata(spawnRequestId: string | undefined): ReturnType<typeof workspaceLandMetadataFrom> {
131
+ if (!spawnRequestId) return {};
132
+ const row = getDb().query(`
133
+ SELECT params FROM commands
134
+ WHERE type = 'agent.spawn' AND correlation_id = ?
135
+ ORDER BY created_at DESC
136
+ LIMIT 1
137
+ `).get(spawnRequestId) as { params?: string } | undefined;
138
+ return workspaceLandMetadataFrom(parseJson<Record<string, unknown>>(row?.params, {}));
139
+ }
105
140
 
106
141
  export function upsertWorkspaceFromManagedAgent(agent: ManagedAgent): WorkspaceRecord | null {
107
142
  const workspace = agent.workspace;
@@ -140,7 +175,15 @@ export function upsertWorkspaceFromManagedAgent(agent: ManagedAgent): WorkspaceR
140
175
  ownerAgentId: agent.agentId || undefined,
141
176
  ownerPolicyName: agent.policyName,
142
177
  ownerAutomationRunId: agent.automationRunId,
143
- metadata: { ...(existing?.metadata ?? {}), provider: agent.provider, label: agent.label, sessionName: agent.sessionName, tmuxSession: agent.tmuxSession },
178
+ metadata: {
179
+ ...(existing?.metadata ?? {}),
180
+ ...spawnCommandLandMetadata(agent.spawnRequestId),
181
+ ...workspaceLandMetadataFrom(workspace as unknown as Record<string, unknown>),
182
+ provider: agent.provider,
183
+ label: agent.label,
184
+ sessionName: agent.sessionName,
185
+ tmuxSession: agent.tmuxSession,
186
+ },
144
187
  });
145
188
  }
146
189
  // Shared-mode occupancy: one row per agent sharing a git repo, with no
@@ -438,4 +481,3 @@ export function electWorkspaceStewardsForAgent(agentId: string): void {
438
481
  `).all(agentId) as Array<{ repo_root: string }>;
439
482
  for (const r of repos) electWorkspaceStewards(r.repo_root);
440
483
  }
441
-
@@ -11,6 +11,7 @@ import {
11
11
  TOKEN_RECORD_RETENTION_SECONDS,
12
12
  WORKSPACE_AUTO_MERGE_INTERVAL_MS,
13
13
  WORKSPACE_GC_INTERVAL_MS,
14
+ WORKSPACE_IDLE_REFRESH_INTERVAL_MS,
14
15
  WORKSPACE_ORPHAN_REAPER_INTERVAL_MS,
15
16
  WORKSPACE_RETENTION_MS,
16
17
  WORKSPACE_REVIEW_TTL_MS,
@@ -45,6 +46,7 @@ export {
45
46
  TOKEN_RECORD_RETENTION_SECONDS,
46
47
  WORKSPACE_AUTO_MERGE_INTERVAL_MS,
47
48
  WORKSPACE_GC_INTERVAL_MS,
49
+ WORKSPACE_IDLE_REFRESH_INTERVAL_MS,
48
50
  WORKSPACE_ORPHAN_REAPER_INTERVAL_MS,
49
51
  WORKSPACE_RETENTION_MS,
50
52
  WORKSPACE_REVIEW_TTL_MS,
@@ -53,6 +53,7 @@ import {
53
53
  QUOTA_HISTORY_RETENTION_MS,
54
54
  TOKEN_RECORD_RETENTION_SECONDS,
55
55
  WORKSPACE_AUTO_MERGE_INTERVAL_MS,
56
+ WORKSPACE_IDLE_REFRESH_INTERVAL_MS,
56
57
  WORKSPACE_GC_INTERVAL_MS,
57
58
  WORKSPACE_ORPHAN_REAPER_INTERVAL_MS,
58
59
  CONFLICT_SCAN_INTERVAL_MS,
@@ -61,7 +62,7 @@ import {
61
62
  import { emitCommand } from "./events";
62
63
  import { reapOrphanedSessions } from "./orphaned-sessions";
63
64
  import { reapZeroMemberTeams, runTeamIdleWatchdog } from "./teams";
64
- import { scanWorkspaceConflicts, workspaceGC } from "./workspaces";
65
+ import { refreshIdleWorkspaces, scanWorkspaceConflicts, workspaceGC } from "./workspaces";
65
66
  import type { MaintenanceJobDefinition } from "./types";
66
67
 
67
68
  let dbMaintenanceRuns = 0;
@@ -227,6 +228,14 @@ export const definitions: MaintenanceJobDefinition[] = [
227
228
  runOnStart: true,
228
229
  handler() {
229
230
  const expired = expireStaleBusAgents();
231
+ for (const id of expired.agentIds) {
232
+ getLifecycleManager().onAgentDisappeared(id);
233
+ // #636/#659 — only after the stale reconnect grace expires do we turn a bare
234
+ // heartbeat gap into a terminal parent notification. A live runner that
235
+ // reconnects before this point heals from `stale` without a false exit.
236
+ const diagnosis = diagnoseAgentDeath({ agent: getAgent(id), reason: "heartbeat lost" });
237
+ notifyAgentOffline(id, "heartbeat lost", diagnosis);
238
+ }
230
239
  for (const task of expired.orphanedTasks) emitTaskChanged(task, "task.updated");
231
240
  return {
232
241
  expiredBusAgentIds: expired.agentIds,
@@ -244,16 +253,11 @@ export const definitions: MaintenanceJobDefinition[] = [
244
253
  const reapedAgentIds = reapStaleAgents(STALE_TTL_MS);
245
254
  for (const id of reapedAgentIds) {
246
255
  emitAgentStatus(id);
247
- getLifecycleManager().onAgentDisappeared(id);
248
- // #636 — a heartbeat-lost death still gets a best-effort post-mortem from the agent's last
249
- // known meta (lastExit/lastError/auth) + heartbeat gap, riding the terminal event.
250
- const diagnosis = diagnoseAgentDeath({ agent: getAgent(id), reason: "heartbeat lost" });
251
- notifyAgentOffline(id, "heartbeat lost", diagnosis);
252
256
  createActivityEvent({
253
257
  clientId: "server-agent-" + id + "-heartbeat-lost-" + Date.now(),
254
258
  kind: "state",
255
- title: "Heartbeat lost",
256
- body: "Agent marked offline",
259
+ title: "Heartbeat stale",
260
+ body: "Agent entered reconnect grace",
257
261
  meta: id,
258
262
  icon: "ti-heart-off",
259
263
  view: "chat",
@@ -442,6 +446,15 @@ export const definitions: MaintenanceJobDefinition[] = [
442
446
  timeoutMs: 60 * 1000,
443
447
  handler: autoMergeCleanFastForwards,
444
448
  },
449
+ {
450
+ id: "workspace-idle-refresh",
451
+ title: "Workspace idle refresh",
452
+ description: "Proactively advance idle branch-agent workspaces to origin/main (#478): for each active, clean (dirty=0, ahead=0), owner-idle workspace, dispatch a workspace.idle-refresh command so the agent wakes already current and needs no ready/status dance.",
453
+ intervalMs: WORKSPACE_IDLE_REFRESH_INTERVAL_MS,
454
+ runOnStart: false,
455
+ timeoutMs: 60 * 1000,
456
+ handler: refreshIdleWorkspaces,
457
+ },
445
458
  {
446
459
  id: "workspace-gc",
447
460
  title: "Workspace GC",
@@ -11,6 +11,7 @@ import {
11
11
  reelectRepoSteward,
12
12
  releaseExpiredMergeLeases,
13
13
  updateWorkspaceStatus,
14
+ setWorkspaceBranch,
14
15
  } from "../db";
15
16
  import type { WorkspaceMergePreview, WorkspaceStatus } from "../types";
16
17
  import { notifyStewardHandoff, reconcileLandedWorkspace } from "../branch-landed";
@@ -262,3 +263,78 @@ export async function workspaceGC(): Promise<Record<string, unknown>> {
262
263
  escalationTargets,
263
264
  };
264
265
  }
266
+
267
+ /**
268
+ * Proactively refresh idle branch-agent workspaces against origin/main (#478).
269
+ *
270
+ * For each `active` isolated workspace whose owner agent is NOT busy and whose
271
+ * stored probe data indicates no WIP (dirty=0, ahead=0), dispatch a
272
+ * `workspace.idle-refresh` command to the owning orchestrator. The orchestrator
273
+ * re-verifies liveness, fetches origin, and — when the worktree is cleanly
274
+ * behind — performs a `checkout -B <fresh> origin/main` so the agent is already
275
+ * current when it next wakes. Workspaces with WIP, commits ahead, or busy owners
276
+ * are skipped silently.
277
+ */
278
+ export async function refreshIdleWorkspaces(): Promise<Record<string, unknown>> {
279
+ const orchestrators = listOrchestrators().filter((orch) => orch.status === "online" && orch.agentId);
280
+ if (!orchestrators.length) return { dispatched: 0, skipped: 0, reason: "no online orchestrators" };
281
+
282
+ const candidates = listWorkspaces().filter(
283
+ (ws) => ws.mode === "isolated" && ws.status === "active" && Boolean(ws.worktreePath),
284
+ );
285
+
286
+ const dispatched: string[] = [];
287
+ const skipped: string[] = [];
288
+ const now = Date.now();
289
+
290
+ for (const ws of candidates) {
291
+ const orch = orchestrators.find((candidate) => workspacePathWithinBase(ws.sourceCwd, candidate.baseDir));
292
+ if (!orch?.agentId) { skipped.push(`${ws.id}:no-orchestrator`); continue; }
293
+
294
+ // Gate: owner must exist and not be busy.
295
+ const owner = ws.ownerAgentId ? getAgent(ws.ownerAgentId) : null;
296
+ if (!owner || owner.status === "busy") { skipped.push(`${ws.id}:owner-busy`); continue; }
297
+
298
+ // Gate: skip if stored metadata shows WIP or commits ahead.
299
+ // The orchestrator re-checks live before mutating; stored data is a fast pre-filter.
300
+ const meta = ws.metadata as Record<string, unknown>;
301
+ const gitAhead = typeof meta.gitAhead === "number" ? meta.gitAhead : undefined;
302
+ const gitDirtyCount = typeof meta.gitDirtyCount === "number" ? meta.gitDirtyCount : undefined;
303
+ if (gitAhead !== undefined && gitAhead > 0) { skipped.push(`${ws.id}:ahead`); continue; }
304
+ if (gitDirtyCount !== undefined && gitDirtyCount > 0) { skipped.push(`${ws.id}:dirty`); continue; }
305
+
306
+ const command = createCommand({
307
+ type: "workspace.idle-refresh",
308
+ source: "system",
309
+ target: orch.agentId,
310
+ correlationId: ws.id,
311
+ params: {
312
+ workspaceId: ws.id,
313
+ repoRoot: ws.repoRoot,
314
+ worktreePath: ws.worktreePath,
315
+ branch: ws.branch,
316
+ baseRef: ws.baseRef,
317
+ baseSha: ws.baseSha,
318
+ requestedBy: "workspace-idle-refresh",
319
+ requestedAt: now,
320
+ },
321
+ });
322
+ emitCommand(command);
323
+ dispatched.push(ws.id);
324
+ }
325
+
326
+ return { dispatched, skipped };
327
+ }
328
+
329
+ /**
330
+ * Apply the result of a settled `workspace.idle-refresh` command.
331
+ * When the orchestrator refreshed the worktree, repoint the DB row to the
332
+ * new branch and update base_sha so the next merge targets the live branch.
333
+ */
334
+ export function applyIdleRefreshResult(workspaceId: string, result: Record<string, unknown>): boolean {
335
+ const newBranch = typeof result.newBranch === "string" ? result.newBranch : undefined;
336
+ const baseSha = typeof result.baseSha === "string" ? result.baseSha : undefined;
337
+ if (!newBranch || result.refreshed !== true) return false;
338
+ setWorkspaceBranch(workspaceId, newBranch, baseSha);
339
+ return true;
340
+ }
package/src/mcp/index.ts CHANGED
@@ -150,7 +150,7 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
150
150
  else if (name.startsWith("relay_plan_")) result = relayPlanTool(auth, name, args);
151
151
  else if (name.startsWith("relay_blackboard_")) result = relayBlackboardTool(auth, name, args);
152
152
  else if (name.startsWith("relay_project_")) result = relayProjectTool(auth, name, args); else if (name.startsWith("relay_issue_ref_")) result = relayIssueRefTool(auth, name, args); else if (name.startsWith("relay_issue_association_")) result = relayIssueAssociationTool(auth, name, args); else if (name.startsWith("relay_issue_activity_")) result = relayIssueActivityTool(auth, name, args); else if (name.startsWith("relay_issue_lifecycle_")) result = await relayIssueLifecycleTool(auth, name, args);
153
- else if (name === "relay_subscribe" || name === "relay_unsubscribe" || name === "relay_list_subscriptions") result = relayNotificationSubscriptionTool(auth, name, args);
153
+ else if (name === "relay_subscribe" || name === "relay_unsubscribe" || name === "relay_list_subscriptions" || name === "relay_why_notified" || name === "relay_notification_preview") result = relayNotificationSubscriptionTool(auth, name, args);
154
154
  else if (name === "relay_notify_ntfy") result = await relayNotifyNtfy(args);
155
155
  else throw new ValidationError(`unknown tool: ${name}`);
156
156
 
@@ -54,6 +54,7 @@ export const MESSAGING_TOOLS: ToolDefinition[] = [
54
54
  from: { type: "string", description: "Deprecated/optional. Your identity is taken from your auth token; ignored for managed agents. You never need to set this." },
55
55
  to: { type: "string", description: "Target: an agent id, label, name, or a unique id segment (e.g. the number part) — resolved automatically. Or a fan-out selector: tag:, cap:, label:, team:, policy:, or broadcast." },
56
56
  body: { type: "string" },
57
+ kind: { type: "string", enum: ["chat", "channel.event", "task", "pair", "control", "system", "session"] },
57
58
  subject: { type: "string" },
58
59
  channel: { type: "string" },
59
60
  claimable: { type: "boolean" },
@@ -226,6 +227,10 @@ function runMcpSend(auth: McpAuthContext, input: SendMessageInput): Message & {
226
227
  export function relaySendMessage(auth: McpAuthContext, args: Record<string, unknown>): (Message & { delivery: DeliveryReceipt }) | Record<string, unknown> {
227
228
  const attachments = optionalAttachments(args.attachments);
228
229
  const payload = payloadWithAttachments(optionalRecord(args.payload, "payload"), attachments);
230
+ const kind = optionalEnum(args.kind, "kind", ["chat", "channel.event", "task", "pair", "control", "system", "session"] as const);
231
+ const channel = optionalString(args.channel, "channel", 120);
232
+ const claimable = optionalBoolean(args.claimable, "claimable");
233
+ const meta = optionalRecord(args.meta, "meta");
229
234
  const deliveryTime = optionalString(args.deliveryTime, "deliveryTime", 80);
230
235
  if (deliveryTime) {
231
236
  if (!hasScope(auth, "schedule:write")) throw new McpAuthError("deliveryTime requires schedule:write");
@@ -233,9 +238,13 @@ export function relaySendMessage(auth: McpAuthContext, args: Record<string, unkn
233
238
  const timer = scheduleTimer({
234
239
  target: stringField(args.to, "to", { required: true, max: 200 }),
235
240
  at: deliveryTime,
241
+ kind,
242
+ channel,
236
243
  body: stringField(args.body, "body", { required: true, maxBytes: MAX_BODY_BYTES }),
237
244
  subject: optionalString(args.subject, "subject", 200),
245
+ claimable,
238
246
  payload,
247
+ meta,
239
248
  idempotencyKey: optionalString(args.idempotencyKey, "idempotencyKey", 240),
240
249
  }, authContextFromMcp({ kind: auth.kind, actor: auth.actor, scopes: auth.scopes, component: auth.component }));
241
250
  return { scheduled: true, timer };
@@ -247,14 +256,15 @@ export function relaySendMessage(auth: McpAuthContext, args: Record<string, unkn
247
256
  const input: SendMessageInput = {
248
257
  from: optionalString(args.from, "from", 200) ?? "",
249
258
  to: stringField(args.to, "to", { required: true, max: 200 }),
259
+ kind,
260
+ channel,
250
261
  body: stringField(args.body, "body", { required: true, maxBytes: MAX_BODY_BYTES }),
251
262
  subject: optionalString(args.subject, "subject", 200),
252
- channel: optionalString(args.channel, "channel", 120),
253
- claimable: optionalBoolean(args.claimable, "claimable"),
263
+ claimable,
254
264
  idempotencyKey: optionalString(args.idempotencyKey, "idempotencyKey", 240),
255
265
  attachments,
256
266
  payload,
257
- meta: optionalRecord(args.meta, "meta"),
267
+ meta,
258
268
  };
259
269
  return runMcpSend(auth, input);
260
270
  }
@@ -12,13 +12,15 @@ import { countLiveSpawnedAgents, listAgents, listWorkspaces, ValidationError } f
12
12
  import { listCommands } from "../commands-db";
13
13
  import { DEFAULT_SPAWN_PROFILE_NAME } from "../config-store";
14
14
  import { optionalEnum } from "../validation";
15
- import { APPROVAL_MODES, SPAWN_PROVIDERS, stringValue, VALID_AGENT_LIFECYCLES, VALID_EFFORTS, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
15
+ import { APPROVAL_MODES, AUTO_MERGE_POLICIES, SPAWN_PROVIDERS, stringValue, VALID_AGENT_LIFECYCLES, VALID_EFFORTS, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
16
16
  import type { ProviderEffort } from "agent-relay-sdk/provider-catalog";
17
- import type { AgentCard, AgentLifecycle, Command, SpawnApprovalMode, SpawnProvider, WorkspaceMode, WorkspaceRecord } from "../types";
17
+ import type { AgentCard, AgentLifecycle, Command, SpawnApprovalMode, SpawnProvider, WorkspaceAutoMergePolicy, WorkspaceLandingPolicy, WorkspaceMode, WorkspaceRecord } from "../types";
18
18
  import type { McpAuthContext, ToolDefinition } from "./types";
19
19
  import { callerAgentId } from "./identity";
20
20
  import { enumField, optionalBoolean, optionalNonNegativeInt, optionalPositiveInt, optionalString, optionalStringArray } from "./validation";
21
21
 
22
+ const SPAWN_LAND_STRATEGIES = ["direct", "pr"] as const;
23
+
22
24
  export const SPAWN_TOOLS: ToolDefinition[] = [
23
25
  {
24
26
  name: "relay_spawn_agent",
@@ -48,6 +50,9 @@ export const SPAWN_TOOLS: ToolDefinition[] = [
48
50
  ],
49
51
  description: "#689 — shared host-listener MCP. Host-MCP profiles default ON: the worker's NON-IDENTITY MCP (tokenlean, github) is routed through one orchestrator-owned shared `callmux` listener via a per-agent `callmux bridge --cwd \"$WORKTREE\"`, so the MCP session survives listener/downstream restart and keeps isolated worktree cwd routing. Profiles that suppress host MCP stay OFF unless explicitly opted in. Pass `false` as a per-spawn kill-switch to force direct/per-agent MCP injection. The identity-bearing relay endpoint stays per-agent (#215), never on the shared listener.",
50
52
  },
53
+ landStrategy: { type: "string", enum: SPAWN_LAND_STRATEGIES, description: "#421 — per-spawn workspace land strategy. Omit to use the instance landing.strategy. `direct` keeps deterministic rebase/fast-forward landing; `pr` opens a PR." },
54
+ autoMerge: { type: "string", enum: AUTO_MERGE_POLICIES, description: "#421/#305 — PR auto-merge policy when landStrategy resolves to `pr`. `on-green` auto-merges after checks, `on-approval` waits for reviewer approval, `manual` opens the PR only." },
55
+ reviewer: { type: "string", description: "#421/#305 — optional reviewer handle or policy target for reviewer-first PR lands, used with landStrategy `pr` and autoMerge `on-approval`." },
51
56
  profile: { type: "string", description: `Agent profile name to apply (env, instructions, permissions, MCP/skills, spawn quota). Omit to use \`${DEFAULT_SPAWN_PROFILE_NAME}\`.` },
52
57
  tags: { type: "array", items: { type: "string" } },
53
58
  capabilities: { type: "array", items: { type: "string" } },
@@ -56,6 +61,16 @@ export const SPAWN_TOOLS: ToolDefinition[] = [
56
61
  spawnRequestId: { type: "string" },
57
62
  lifecycle: { type: "string", enum: VALID_AGENT_LIFECYCLES, description: "Lifecycle mode. `persistent` keeps the agent until explicitly stopped. `transient` lets Relay auto-shutdown the child after it is idle and, for isolated workers, after its branch has landed. Default: persistent." },
58
63
  workspaceMode: { type: "string", enum: VALID_WORKSPACE_MODES, description: "Git workspace for the worker. `isolated` = its own worktree on an `agent/session-*` branch off main that auto-lands when ready (delegate a unit of work, get a branch worker). `shared` = edits your live checkout (a sidecar). `inherit` = let the orchestrator decide. DEFAULT for agent-initiated spawns is `isolated` so a delegated worker lands to main instead of editing your tree; pass `shared`/`inherit` to override." },
64
+ resumeWorkspace: {
65
+ type: "object",
66
+ description: "#635 — attach a new worker to an EXISTING managed worktree/branch instead of always creating a fresh one. `attach`: reuse the worktree as-is (crash recovery — WIP is preserved); `branch-from`: fresh worktree off the existing branch HEAD (phased work, sequential hand-off). Guard: `attach` is rejected when the original owner is still alive. The resumed worker's branch auto-lands normally on completion.",
67
+ properties: {
68
+ branch: { type: "string", description: "The existing agent branch to attach to or branch from (e.g. `agent/build-389/.../...`)." },
69
+ mode: { type: "string", enum: ["attach", "branch-from"], description: "`attach` = reuse the existing worktree directory (WIP preserved). `branch-from` = fresh worktree off the branch HEAD (committed history carried, WIP not)." },
70
+ },
71
+ required: ["branch", "mode"],
72
+ additionalProperties: false,
73
+ },
59
74
  waitForRegistrationMs: { type: "integer", minimum: 0, maximum: 30000, description: "How long to wait for the spawned agent to register before returning, so the response carries its resolved agent id (default 8000; 0 = return immediately with just spawnRequestId)." },
60
75
  },
61
76
  required: ["provider"],
@@ -105,6 +120,15 @@ export const SPAWN_TOOLS: ToolDefinition[] = [
105
120
  },
106
121
  ];
107
122
 
123
+ function parseResumeWorkspace(value: unknown): { branch: string; mode: "attach" | "branch-from" } | undefined {
124
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
125
+ const obj = value as Record<string, unknown>;
126
+ const branch = optionalString(obj.branch, "resumeWorkspace.branch", 500);
127
+ const mode = obj.mode === "attach" || obj.mode === "branch-from" ? obj.mode : undefined;
128
+ if (!branch || !mode) return undefined;
129
+ return { branch, mode };
130
+ }
131
+
108
132
  export async function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknown>): Promise<Record<string, unknown>> {
109
133
  const input: SpawnAgentInput = {
110
134
  provider: enumField(args.provider, "provider", SPAWN_PROVIDERS) as SpawnProvider,
@@ -126,12 +150,16 @@ export async function relaySpawnAgent(auth: McpAuthContext, args: Record<string,
126
150
  projectSkills: parseProjectSkills(args.projectSkills, (m) => new ValidationError(m)),
127
151
  projectSettings: parseProjectSettings(args.projectSettings, (m) => new ValidationError(m)),
128
152
  sharedMcp: parseSharedMcp(args.sharedMcp, (m) => new ValidationError(m)),
153
+ landStrategy: optionalEnum(args.landStrategy, "landStrategy", SPAWN_LAND_STRATEGIES) as WorkspaceLandingPolicy | undefined,
154
+ autoMerge: optionalEnum(args.autoMerge, "autoMerge", AUTO_MERGE_POLICIES) as WorkspaceAutoMergePolicy | undefined,
155
+ reviewer: optionalString(args.reviewer, "reviewer", 240),
129
156
  tags: optionalStringArray(args.tags, "tags"),
130
157
  capabilities: optionalStringArray(args.capabilities, "capabilities"),
131
158
  providerArgs: optionalStringArray(args.providerArgs, "providerArgs"),
132
159
  spawnRequestId: optionalString(args.spawnRequestId, "spawnRequestId", 160),
133
160
  requestedVia: "mcp",
134
161
  waitForRegistrationMs: optionalNonNegativeInt(args.waitForRegistrationMs, "waitForRegistrationMs") ?? 8000,
162
+ resumeWorkspace: parseResumeWorkspace(args.resumeWorkspace),
135
163
  };
136
164
  const result = await spawnAgent(input, authContextFromMcp(auth));
137
165
  return { ...result };
@@ -15,10 +15,11 @@ import {
15
15
  type NotificationSubscriptionMode,
16
16
  } from "./db/notification-subscriptions";
17
17
  import { ValidationError } from "./db";
18
- import { McpAuthError } from "./mcp-errors";
18
+ import { McpAuthError, McpNotFoundError } from "./mcp-errors";
19
19
  import { authContextFromMcp } from "./services/auth-context";
20
20
  import { cleanString } from "./validation";
21
- import type { NotificationScope } from "./notification-types";
21
+ import { NOTIFICATION_TYPES, type NotificationScope, type NotificationType } from "./notification-types";
22
+ import { previewNotificationRouting, whyNotified } from "./notification-router";
22
23
 
23
24
  export const NOTIFICATION_SUBSCRIPTION_TOOLS = [
24
25
  {
@@ -68,6 +69,33 @@ export const NOTIFICATION_SUBSCRIPTION_TOOLS = [
68
69
  additionalProperties: false,
69
70
  },
70
71
  },
72
+ {
73
+ name: "relay_why_notified",
74
+ description: "Explain why this caller agent received a delivered notification message.",
75
+ requiredScopes: ["notifications:read"],
76
+ inputSchema: {
77
+ type: "object",
78
+ properties: {
79
+ messageId: { type: "number", description: "Delivered Relay message id." },
80
+ },
81
+ required: ["messageId"],
82
+ additionalProperties: false,
83
+ },
84
+ },
85
+ {
86
+ name: "relay_notification_preview",
87
+ description: "Dry-run notification routing for a type and scope, returning recipients and matched rules without sending.",
88
+ requiredScopes: ["notifications:read"],
89
+ inputSchema: {
90
+ type: "object",
91
+ properties: {
92
+ type: { type: "string", enum: NOTIFICATION_TYPES },
93
+ scope: { type: "object", description: "Notification scope, such as { author, teamId, repoRoot, workspaceId }." },
94
+ },
95
+ required: ["type", "scope"],
96
+ additionalProperties: false,
97
+ },
98
+ },
71
99
  ] satisfies Array<{ name: string; description: string; requiredScopes: string[]; inputSchema: Record<string, unknown> }>;
72
100
 
73
101
  type McpAuthLike = Parameters<typeof authContextFromMcp>[0];
@@ -149,6 +177,17 @@ export function relayNotificationSubscriptionTool(
149
177
  },
150
178
  };
151
179
  }
180
+ if (name === "relay_why_notified") {
181
+ const explanation = whyNotified(cleanMessageId(args.messageId), agentId);
182
+ if (!explanation) throw new McpNotFoundError("notification routing explanation not found for this agent/message");
183
+ return { ...explanation };
184
+ }
185
+ if (name === "relay_notification_preview") {
186
+ return { ...previewNotificationRouting({
187
+ type: cleanNotificationType(args.type),
188
+ scope: cleanScope(args.scope) ?? {},
189
+ }) };
190
+ }
152
191
  throw new ValidationError(`unknown tool: ${name}`);
153
192
  }
154
193
 
@@ -169,6 +208,20 @@ function cleanScope(value: unknown): Partial<NotificationScope> | null {
169
208
  return value as Partial<NotificationScope>;
170
209
  }
171
210
 
211
+ function cleanNotificationType(value: unknown): NotificationType {
212
+ if (typeof value !== "string" || !NOTIFICATION_TYPES.includes(value as NotificationType)) {
213
+ throw new ValidationError(`type must be one of: ${NOTIFICATION_TYPES.join(", ")}`);
214
+ }
215
+ return value as NotificationType;
216
+ }
217
+
218
+ function cleanMessageId(value: unknown): number {
219
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
220
+ throw new ValidationError("messageId must be a positive integer");
221
+ }
222
+ return value;
223
+ }
224
+
172
225
  function cleanChildStreams(value: unknown): ChildStream[] {
173
226
  if (!Array.isArray(value) || value.length === 0) throw new ValidationError("streams must be a non-empty array");
174
227
  return [...new Set(value.map(cleanChildStream))];