agent-relay-server 0.59.0 → 0.60.1

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 (107) hide show
  1. package/docs/openapi.json +156 -1
  2. package/package.json +2 -2
  3. package/public/assets/MessageBubble-vzupFQD8.js +2 -0
  4. package/public/assets/MessageBubble-vzupFQD8.js.map +1 -0
  5. package/public/assets/{PlanGraphPanel-CXix1C-H.js → PlanGraphPanel-DQvycBMW.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-CXix1C-H.js.map → PlanGraphPanel-DQvycBMW.js.map} +1 -1
  7. package/public/assets/{activity-CRafQ1Je.js → activity-gmfkOBhQ.js} +2 -2
  8. package/public/assets/{activity-CRafQ1Je.js.map → activity-gmfkOBhQ.js.map} +1 -1
  9. package/public/assets/{agent-profiles-DW4o5-ND.js → agent-profiles-7X8gFuwj.js} +2 -2
  10. package/public/assets/{agent-profiles-DW4o5-ND.js.map → agent-profiles-7X8gFuwj.js.map} +1 -1
  11. package/public/assets/{agents-Cbegv0Hm.js → agents-D201CuTR.js} +2 -2
  12. package/public/assets/{agents-Cbegv0Hm.js.map → agents-D201CuTR.js.map} +1 -1
  13. package/public/assets/{analytics-DX6T7JX3.js → analytics-CkOTrr5t.js} +2 -2
  14. package/public/assets/{analytics-DX6T7JX3.js.map → analytics-CkOTrr5t.js.map} +1 -1
  15. package/public/assets/{automation-CzSBv4gg.js → automation-_5u-nVs8.js} +2 -2
  16. package/public/assets/{automation-CzSBv4gg.js.map → automation-_5u-nVs8.js.map} +1 -1
  17. package/public/assets/{branch-state-badge-CRA50r81.js → branch-state-badge-BPXXLpQG.js} +2 -2
  18. package/public/assets/{branch-state-badge-CRA50r81.js.map → branch-state-badge-BPXXLpQG.js.map} +1 -1
  19. package/public/assets/{channels-B1MQCPCT.js → channels-BsJu5TXR.js} +2 -2
  20. package/public/assets/{channels-B1MQCPCT.js.map → channels-BsJu5TXR.js.map} +1 -1
  21. package/public/assets/{chat-BqIaFYGJ.js → chat-CHgPGHQD.js} +2 -2
  22. package/public/assets/{chat-BqIaFYGJ.js.map → chat-CHgPGHQD.js.map} +1 -1
  23. package/public/assets/{connectors-C9aFJIrh.js → connectors-arjfBQvc.js} +2 -2
  24. package/public/assets/{connectors-C9aFJIrh.js.map → connectors-arjfBQvc.js.map} +1 -1
  25. package/public/assets/display-CketawEF.js.map +1 -1
  26. package/public/assets/{formatted-body-impl-DOXH7oDF.js → formatted-body-impl-DBt5tnS0.js} +2 -2
  27. package/public/assets/{formatted-body-impl-DOXH7oDF.js.map → formatted-body-impl-DBt5tnS0.js.map} +1 -1
  28. package/public/assets/{index-br_Y6Cvt.js → index-D7js3XlH.js} +6 -6
  29. package/public/assets/{index-br_Y6Cvt.js.map → index-D7js3XlH.js.map} +1 -1
  30. package/public/assets/{insights-B1edq5lk.js → insights-BvjkOXk9.js} +2 -2
  31. package/public/assets/{insights-B1edq5lk.js.map → insights-BvjkOXk9.js.map} +1 -1
  32. package/public/assets/{integrations-Cm4fjp7_.js → integrations-7L1f3eIE.js} +2 -2
  33. package/public/assets/{integrations-Cm4fjp7_.js.map → integrations-7L1f3eIE.js.map} +1 -1
  34. package/public/assets/{maintenance-D6zvw0fU.js → maintenance-BBoL--vs.js} +2 -2
  35. package/public/assets/{maintenance-D6zvw0fU.js.map → maintenance-BBoL--vs.js.map} +1 -1
  36. package/public/assets/{managed-agents-CzfEznM1.js → managed-agents-BjYBzVZt.js} +2 -2
  37. package/public/assets/{managed-agents-CzfEznM1.js.map → managed-agents-BjYBzVZt.js.map} +1 -1
  38. package/public/assets/{markdown-preview-impl-DZlCaTHQ.js → markdown-preview-impl-BnwjZsmO.js} +2 -2
  39. package/public/assets/{markdown-preview-impl-DZlCaTHQ.js.map → markdown-preview-impl-BnwjZsmO.js.map} +1 -1
  40. package/public/assets/{memory-CUUAzJDc.js → memory-SLycz2gC.js} +2 -2
  41. package/public/assets/{memory-CUUAzJDc.js.map → memory-SLycz2gC.js.map} +1 -1
  42. package/public/assets/{messages-6QZH46LG.js → messages-DfSCTe6N.js} +2 -2
  43. package/public/assets/{messages-6QZH46LG.js.map → messages-DfSCTe6N.js.map} +1 -1
  44. package/public/assets/{orchestrators-CrcqkPuc.js → orchestrators-BwxdTQm_.js} +2 -2
  45. package/public/assets/{orchestrators-CrcqkPuc.js.map → orchestrators-BwxdTQm_.js.map} +1 -1
  46. package/public/assets/{overview-418xXZO_.js → overview-Djy3qkY5.js} +2 -2
  47. package/public/assets/{overview-418xXZO_.js.map → overview-Djy3qkY5.js.map} +1 -1
  48. package/public/assets/{pairs-CnedATgT.js → pairs-9KNduLnC.js} +2 -2
  49. package/public/assets/{pairs-CnedATgT.js.map → pairs-9KNduLnC.js.map} +1 -1
  50. package/public/assets/{security-DS13pbag.js → security-tG3q7fFW.js} +2 -2
  51. package/public/assets/{security-DS13pbag.js.map → security-tG3q7fFW.js.map} +1 -1
  52. package/public/assets/{select-BOprGZDS.js → select-CGUdMNOG.js} +2 -2
  53. package/public/assets/{select-BOprGZDS.js.map → select-CGUdMNOG.js.map} +1 -1
  54. package/public/assets/{settings-BgInn_f3.js → settings-CGcqyJ5L.js} +2 -2
  55. package/public/assets/{settings-BgInn_f3.js.map → settings-CGcqyJ5L.js.map} +1 -1
  56. package/public/assets/{store-DHO_a8BD.js → store-DXdfhiVf.js} +2 -2
  57. package/public/assets/store-DXdfhiVf.js.map +1 -0
  58. package/public/assets/{tasks-B-4oCPuv.js → tasks-DWlKg218.js} +2 -2
  59. package/public/assets/{tasks-B-4oCPuv.js.map → tasks-DWlKg218.js.map} +1 -1
  60. package/public/assets/{teams-sCQAs_QA.js → teams-DRchUP_p.js} +2 -2
  61. package/public/assets/{teams-sCQAs_QA.js.map → teams-DRchUP_p.js.map} +1 -1
  62. package/public/assets/{terminal-viewer-impl-BhqS2XpS.js → terminal-viewer-impl-D_Sqe-vo.js} +2 -2
  63. package/public/assets/{terminal-viewer-impl-BhqS2XpS.js.map → terminal-viewer-impl-D_Sqe-vo.js.map} +1 -1
  64. package/public/assets/{work-queue-BDhjlwUH.js → work-queue-HNcyjON9.js} +2 -2
  65. package/public/assets/{work-queue-BDhjlwUH.js.map → work-queue-HNcyjON9.js.map} +1 -1
  66. package/public/assets/{workspaces-6I-C69nf.js → workspaces-CicYE2Ax.js} +2 -2
  67. package/public/assets/{workspaces-6I-C69nf.js.map → workspaces-CicYE2Ax.js.map} +1 -1
  68. package/public/index.html +2 -2
  69. package/src/agent-lifecycle-events.ts +41 -21
  70. package/src/branch-landed.ts +46 -35
  71. package/src/channel-target.ts +10 -0
  72. package/src/cli/index.ts +11 -0
  73. package/src/cli/merges.ts +89 -0
  74. package/src/config.ts +9 -0
  75. package/src/context-advisory.ts +13 -6
  76. package/src/db/merge-lease.ts +95 -1
  77. package/src/db/message-reads.ts +6 -0
  78. package/src/db/messages.ts +16 -1
  79. package/src/db/migrations.ts +30 -0
  80. package/src/db/notification-subscriptions.ts +198 -0
  81. package/src/db/schema.ts +11 -0
  82. package/src/index.ts +2 -0
  83. package/src/maintenance.ts +40 -21
  84. package/src/mcp-merge-tools.ts +66 -0
  85. package/src/mcp-notification-subscription-tools.ts +111 -0
  86. package/src/mcp.ts +5 -3
  87. package/src/merge-pause-maintenance.ts +13 -0
  88. package/src/notification-router.ts +168 -0
  89. package/src/notification-rules.ts +186 -0
  90. package/src/notify.ts +9 -0
  91. package/src/reviewer-pipeline.ts +54 -40
  92. package/src/routes/agent-sessions.ts +4 -2
  93. package/src/routes/commands.ts +20 -0
  94. package/src/routes/index.ts +7 -0
  95. package/src/routes/merges.ts +95 -0
  96. package/src/security.ts +2 -0
  97. package/src/self-resume.ts +11 -6
  98. package/src/services/merge-pause.ts +37 -0
  99. package/src/services/plan-live-bindings.ts +11 -6
  100. package/src/services/rate-limit-resume.ts +17 -10
  101. package/src/services/spawn-agent.ts +10 -8
  102. package/src/services/transient-agent-reaper.ts +11 -6
  103. package/src/token-db.ts +3 -3
  104. package/src/workspace-pr-completion.ts +85 -8
  105. package/public/assets/MessageBubble-BrEKymoV.js +0 -2
  106. package/public/assets/MessageBubble-BrEKymoV.js.map +0 -1
  107. package/public/assets/store-DHO_a8BD.js.map +0 -1
@@ -0,0 +1,198 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { isRecord } from "agent-relay-sdk";
3
+ import { DEFAULT_TTL_MS, type NotificationScope, type NotificationType } from "../notification-router";
4
+ import { setSubscriptionRules, type NotificationRule, type NotificationRuleContext, type RuleDecision } from "../notification-rules";
5
+ import type { AgentCard } from "../types";
6
+ import { parseJson } from "../utils";
7
+ import { getDb, ValidationError } from "./connection";
8
+
9
+ export const NOTIFICATION_SUBSCRIPTION_TYPES = ["*", ...Object.keys(DEFAULT_TTL_MS)] as Array<NotificationType | "*">;
10
+ export const NOTIFICATION_SUBSCRIPTION_MODES = ["opt_in", "opt_out", "mute"] as const;
11
+
12
+ export type NotificationSubscriptionType = NotificationType | "*";
13
+ export type NotificationSubscriptionMode = (typeof NOTIFICATION_SUBSCRIPTION_MODES)[number];
14
+
15
+ export interface NotificationSubscription {
16
+ id: string;
17
+ agentId: string;
18
+ type: NotificationSubscriptionType;
19
+ mode: NotificationSubscriptionMode;
20
+ scopeMatch: Partial<NotificationScope> | null;
21
+ createdAt: number;
22
+ }
23
+
24
+ interface NotificationSubscriptionRow {
25
+ id: string;
26
+ agent_id: string;
27
+ type: string;
28
+ mode: string;
29
+ scope_match: string | null;
30
+ created_at: number;
31
+ }
32
+
33
+ const SCOPE_KEYS = ["author", "agentId", "parent", "teamId", "planId", "repoRoot", "workspaceId", "paths"] as const;
34
+
35
+ export function addSubscription(input: {
36
+ agentId: string;
37
+ type: NotificationSubscriptionType;
38
+ mode: NotificationSubscriptionMode;
39
+ scopeMatch?: Partial<NotificationScope> | null;
40
+ now?: number;
41
+ }): NotificationSubscription {
42
+ const agentId = cleanAgentId(input.agentId);
43
+ const type = cleanSubscriptionType(input.type);
44
+ const mode = cleanSubscriptionMode(input.mode);
45
+ const scopeMatch = normalizeScopeMatch(input.scopeMatch);
46
+ const now = input.now ?? Date.now();
47
+
48
+ getDb().query(`
49
+ DELETE FROM notification_subscriptions
50
+ WHERE agent_id = ? AND type = ? AND mode = ? AND coalesce(scope_match, '') = coalesce(?, '')
51
+ `).run(agentId, type, mode, scopeMatch);
52
+
53
+ const id = `nsub_${randomUUID()}`;
54
+ getDb().query(`
55
+ INSERT INTO notification_subscriptions (id, agent_id, type, mode, scope_match, created_at)
56
+ VALUES (?, ?, ?, ?, ?, ?)
57
+ `).run(id, agentId, type, mode, scopeMatch, now);
58
+
59
+ return getSubscription(id)!;
60
+ }
61
+
62
+ export function removeSubscription(input: {
63
+ agentId: string;
64
+ id?: string;
65
+ type?: NotificationSubscriptionType;
66
+ mode?: NotificationSubscriptionMode;
67
+ scopeMatch?: Partial<NotificationScope> | null;
68
+ }): number {
69
+ const agentId = cleanAgentId(input.agentId);
70
+ const conditions = ["agent_id = ?"];
71
+ const params: any[] = [agentId];
72
+
73
+ if (input.id) {
74
+ conditions.push("id = ?");
75
+ params.push(input.id);
76
+ }
77
+ if (input.type) {
78
+ conditions.push("type = ?");
79
+ params.push(cleanSubscriptionType(input.type));
80
+ }
81
+ if (input.mode) {
82
+ conditions.push("mode = ?");
83
+ params.push(cleanSubscriptionMode(input.mode));
84
+ }
85
+ if (Object.prototype.hasOwnProperty.call(input, "scopeMatch")) {
86
+ conditions.push("coalesce(scope_match, '') = coalesce(?, '')");
87
+ params.push(normalizeScopeMatch(input.scopeMatch));
88
+ }
89
+ if (conditions.length === 1) throw new ValidationError("id or type required");
90
+
91
+ return getDb().query(`DELETE FROM notification_subscriptions WHERE ${conditions.join(" AND ")}`).run(...params).changes;
92
+ }
93
+
94
+ export function listSubscriptions(agentId: string): NotificationSubscription[] {
95
+ const rows = getDb().query(`
96
+ SELECT * FROM notification_subscriptions
97
+ WHERE agent_id = ?
98
+ ORDER BY created_at ASC, id ASC
99
+ `).all(cleanAgentId(agentId)) as NotificationSubscriptionRow[];
100
+ return rows.map(rowToSubscription);
101
+ }
102
+
103
+ export function subscriptionDecisionFor(agent: AgentCard, ctx: NotificationRuleContext): RuleDecision {
104
+ const rows = getDb().query(`
105
+ SELECT * FROM notification_subscriptions
106
+ WHERE agent_id = ? AND (type = ? OR type = '*')
107
+ ORDER BY created_at ASC, id ASC
108
+ `).all(agent.id, ctx.type) as NotificationSubscriptionRow[];
109
+
110
+ let decision: RuleDecision = "abstain";
111
+ for (const row of rows) {
112
+ const subscription = rowToSubscription(row);
113
+ if (!scopeMatches(subscription.scopeMatch, ctx.scope)) continue;
114
+ decision = subscription.mode === "opt_in" ? "deliver" : "suppress";
115
+ }
116
+ return decision;
117
+ }
118
+
119
+ const subscriptionRule: NotificationRule = {
120
+ name: "subscription:store",
121
+ evaluate: subscriptionDecisionFor,
122
+ };
123
+
124
+ export function installNotificationSubscriptionRule(): void {
125
+ setSubscriptionRules([subscriptionRule]);
126
+ }
127
+
128
+ function getSubscription(id: string): NotificationSubscription | null {
129
+ const row = getDb().query("SELECT * FROM notification_subscriptions WHERE id = ?").get(id) as NotificationSubscriptionRow | undefined;
130
+ return row ? rowToSubscription(row) : null;
131
+ }
132
+
133
+ export function cleanSubscriptionType(value: unknown): NotificationSubscriptionType {
134
+ if (typeof value !== "string" || !NOTIFICATION_SUBSCRIPTION_TYPES.includes(value as NotificationSubscriptionType)) {
135
+ throw new ValidationError(`type must be one of: ${NOTIFICATION_SUBSCRIPTION_TYPES.join(", ")}`);
136
+ }
137
+ return value as NotificationSubscriptionType;
138
+ }
139
+
140
+ export function cleanSubscriptionMode(value: unknown): NotificationSubscriptionMode {
141
+ if (typeof value !== "string" || !NOTIFICATION_SUBSCRIPTION_MODES.includes(value as NotificationSubscriptionMode)) {
142
+ throw new ValidationError(`mode must be one of: ${NOTIFICATION_SUBSCRIPTION_MODES.join(", ")}`);
143
+ }
144
+ return value as NotificationSubscriptionMode;
145
+ }
146
+
147
+ function cleanAgentId(value: unknown): string {
148
+ if (typeof value !== "string" || !value.trim()) throw new ValidationError("agentId required");
149
+ return value.trim();
150
+ }
151
+
152
+ function normalizeScopeMatch(scope: Partial<NotificationScope> | null | undefined): string | null {
153
+ if (scope === undefined || scope === null) return null;
154
+ if (!isRecord(scope)) throw new ValidationError("scope must be an object");
155
+ const normalized: Record<string, unknown> = {};
156
+ for (const key of SCOPE_KEYS) {
157
+ const value = scope[key];
158
+ if (value === undefined || value === null) continue;
159
+ if (key === "paths") {
160
+ if (!Array.isArray(value) || value.some((item) => typeof item !== "string")) {
161
+ throw new ValidationError("scope.paths must be an array of strings");
162
+ }
163
+ const paths = [...new Set(value.map((item) => item.trim()).filter(Boolean))].sort();
164
+ if (paths.length) normalized.paths = paths;
165
+ continue;
166
+ }
167
+ if (typeof value !== "string" || !value.trim()) throw new ValidationError(`scope.${key} must be a string`);
168
+ normalized[key] = value.trim();
169
+ }
170
+ return Object.keys(normalized).length ? JSON.stringify(normalized) : null;
171
+ }
172
+
173
+ function rowToSubscription(row: NotificationSubscriptionRow): NotificationSubscription {
174
+ return {
175
+ id: row.id,
176
+ agentId: row.agent_id,
177
+ type: cleanSubscriptionType(row.type),
178
+ mode: cleanSubscriptionMode(row.mode),
179
+ scopeMatch: parseJson<Partial<NotificationScope> | null>(row.scope_match, null),
180
+ createdAt: row.created_at,
181
+ };
182
+ }
183
+
184
+ function scopeMatches(match: Partial<NotificationScope> | null, scope: NotificationScope): boolean {
185
+ if (!match) return true;
186
+ const actual = scope as Record<string, unknown>;
187
+ for (const [key, expected] of Object.entries(match)) {
188
+ const value = actual[key];
189
+ if (Array.isArray(expected)) {
190
+ if (!Array.isArray(value)) return false;
191
+ const actualSet = new Set(value.filter((item): item is string => typeof item === "string"));
192
+ if (!expected.every((item) => actualSet.has(item))) return false;
193
+ continue;
194
+ }
195
+ if (value !== expected) return false;
196
+ }
197
+ return true;
198
+ }
package/src/db/schema.ts CHANGED
@@ -110,6 +110,17 @@ export function initDb(path: string = "agent-relay.db"): Database {
110
110
  last_seen INTEGER NOT NULL,
111
111
  created_at INTEGER NOT NULL
112
112
  );
113
+ CREATE TABLE IF NOT EXISTS notification_subscriptions (
114
+ id TEXT PRIMARY KEY,
115
+ agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
116
+ type TEXT NOT NULL,
117
+ mode TEXT NOT NULL CHECK (mode IN ('opt_in', 'opt_out', 'mute')),
118
+ scope_match TEXT,
119
+ created_at INTEGER NOT NULL
120
+ );
121
+ CREATE INDEX IF NOT EXISTS idx_notification_subscriptions_agent ON notification_subscriptions(agent_id, type);
122
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_subscriptions_unique
123
+ ON notification_subscriptions(agent_id, type, mode, coalesce(scope_match, ''));
113
124
  CREATE TABLE IF NOT EXISTS messages (
114
125
  id INTEGER PRIMARY KEY AUTOINCREMENT,
115
126
  from_agent TEXT NOT NULL,
package/src/index.ts CHANGED
@@ -10,6 +10,7 @@ import { getLifecycleManager } from "./lifecycle-manager";
10
10
  import { getCompactionWatch } from "./compaction-watch";
11
11
  import { getConfig, setConfig } from "./config-store";
12
12
  import { startConnectorStatusPoller } from "./connectors";
13
+ import { installNotificationSubscriptionRule } from "./db/notification-subscriptions";
13
14
  import { startPlanLiveBindings } from "./services/plan-live-bindings";
14
15
  import { resolve, sep } from "path";
15
16
  import { gzipSync, brotliCompressSync, constants as zlibConstants } from "node:zlib";
@@ -74,6 +75,7 @@ function startServer(): void {
74
75
 
75
76
  assertSafeNetworkConfig(HOST);
76
77
  initDb(DB_PATH);
78
+ installNotificationSubscriptionRule();
77
79
  startPlanLiveBindings();
78
80
  getLifecycleManager().start();
79
81
  getCompactionWatch().start();
@@ -13,6 +13,7 @@ import {
13
13
  expireQueuedMessages,
14
14
  getAgent,
15
15
  getDb,
16
+ getActiveMergePause,
16
17
  getRepoSteward,
17
18
  getWorkspace,
18
19
  listActiveTeams,
@@ -59,11 +60,12 @@ import {
59
60
  emitPoolBindingChanged,
60
61
  emitTaskChanged,
61
62
  } from "./sse";
62
- import { notifySystemMessage } from "./notify";
63
+ import { routeNotification, type NotificationType, type NotificationScope } from "./notification-router";
63
64
  import { pruneExpiredTokenRecords } from "./token-db";
64
65
  import { dissolveTeamById } from "./services/team";
65
66
  import { reapTransientAgents, resetTransientTrackerForTests } from "./services/transient-agent-reaper";
66
67
  import { resumeRateLimitedAgents, resetRateLimitResumeTrackerForTests } from "./services/rate-limit-resume";
68
+ import { MERGE_PAUSE_MAINTENANCE_JOB } from "./merge-pause-maintenance";
67
69
  import type { Command, MaintenanceJob, MaintenanceJobRun } from "./types";
68
70
 
69
71
  const DEFAULT_TIMEOUT_MS = 30_000;
@@ -197,6 +199,7 @@ const definitions: MaintenanceJobDefinition[] = [
197
199
  };
198
200
  },
199
201
  },
202
+ MERGE_PAUSE_MAINTENANCE_JOB,
200
203
  {
201
204
  id: "task-orphan-release",
202
205
  title: "Orphaned task release",
@@ -633,10 +636,16 @@ function wakeRepoSteward(ws: WorkspaceRecord, reason: string): string | null {
633
636
  const policyName = ensureRepoSteward(ws.repoRoot);
634
637
  if (!policyName) return null;
635
638
  try {
636
- notifySystemMessage(`policy:${policyName}`, {
637
- subject: `Steward: ${ws.status} workspace needs attention`,
638
- body: `Workspace \`${ws.branch ?? ws.id}\` (id ${ws.id}) in ${ws.repoRoot} is ${ws.status} and could not auto-land (${reason}). Claim it first so auto-merge yields: \`agent-relay workspace claim --id ${ws.id} --purpose steward\`. Inspect: \`agent-relay steward inspect ${ws.id}\`. Then cd into ${ws.worktreePath}, rebase onto ${ws.baseRef ?? "base"}, resolve, run checks, and land: \`agent-relay workspace land --id ${ws.id} --strategy rebase-ff\` — or \`agent-relay workspace release --id ${ws.id}\` and escalate if you can't.`,
639
- payload: { kind: "workspace.steward-task", workspaceId: ws.id, repoRoot: ws.repoRoot, worktreePath: ws.worktreePath, branch: ws.branch, baseRef: ws.baseRef, status: ws.status, reason },
639
+ // Obligation (ttl null by type default): a steward task must survive until claimed/handled.
640
+ routeNotification({
641
+ type: "workspace.steward_task",
642
+ scope: { workspaceId: ws.id, repoRoot: ws.repoRoot },
643
+ recipients: [`policy:${policyName}`],
644
+ message: {
645
+ subject: `Steward: ${ws.status} workspace needs attention`,
646
+ body: `Workspace \`${ws.branch ?? ws.id}\` (id ${ws.id}) in ${ws.repoRoot} is ${ws.status} and could not auto-land (${reason}). Claim it first so auto-merge yields: \`agent-relay workspace claim --id ${ws.id} --purpose steward\`. Inspect: \`agent-relay steward inspect ${ws.id}\`. Then cd into ${ws.worktreePath}, rebase onto ${ws.baseRef ?? "base"}, resolve, run checks, and land: \`agent-relay workspace land --id ${ws.id} --strategy rebase-ff\` — or \`agent-relay workspace release --id ${ws.id}\` and escalate if you can't.`,
647
+ payload: { kind: "workspace.steward-task", workspaceId: ws.id, repoRoot: ws.repoRoot, worktreePath: ws.worktreePath, branch: ws.branch, baseRef: ws.baseRef, status: ws.status, reason },
648
+ },
640
649
  });
641
650
  getLifecycleManager().onMessageForPolicy(policyName);
642
651
  patchWorkspaceMetadata(ws.id, { stewardWokenAt: Date.now(), stewardPolicy: policyName });
@@ -653,9 +662,8 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
653
662
  const candidates = listWorkspaces().filter(
654
663
  (ws) => ws.mode === "isolated" && Boolean(ws.worktreePath) && CONFLICT_SCAN_STATUSES.has(ws.status),
655
664
  );
656
- const flagged: string[] = [], cleared: string[] = [], merged: string[] = [], notifiedStewards: string[] = [];
665
+ const flagged: string[] = [], cleared: string[] = [], merged: string[] = [], notifiedStewards: string[] = [], prRecycled: string[] = [];
657
666
  const reviewerArmed: string[] = [], reviewerChangesRequested: string[] = [], relayPrMerged: string[] = [], relayPrWaiting: string[] = [];
658
- const prRecycled: string[] = [];
659
667
 
660
668
  for (const ws of candidates) {
661
669
  const orch = orchestrators.find((candidate) => workspacePathWithinBase(ws.sourceCwd, candidate.baseDir));
@@ -702,6 +710,7 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
702
710
  const prAction = preparePrCompletionScan(ws, p, orch.agentId);
703
711
  if (prAction.armCommand) { emitCommand(prAction.armCommand); reviewerArmed.push(ws.id); continue; }
704
712
  if (prAction.notifiedOwner) { reviewerChangesRequested.push(ws.id); continue; }
713
+ if (prAction.refreshCommand) { emitCommand(prAction.refreshCommand); continue; }
705
714
  if (prAction.mergeCommand) { emitCommand(prAction.mergeCommand); relayPrMerged.push(ws.id); continue; }
706
715
  if (prAction.waitReason) relayPrWaiting.push(`${ws.id}:${prAction.waitReason}`);
707
716
  if (prAction.stop) continue;
@@ -740,10 +749,16 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
740
749
  if (woke) notifiedStewards.push(woke);
741
750
  } else if (ws.stewardAgentId) {
742
751
  try {
743
- notifySystemMessage(ws.stewardAgentId, {
744
- subject: "Workspace merge conflict",
745
- body: `Workspace \`${ws.branch ?? ws.id}\` in ${ws.repoRoot} can no longer merge cleanly into ${p.baseRef ?? "base"} (${p.ahead ?? "?"} ahead, ${p.behind ?? "?"} behind). As repo steward, please coordinate resolution.`,
746
- payload: { kind: "workspace.conflict", workspaceId: ws.id, repoRoot: ws.repoRoot, branch: ws.branch, baseRef: p.baseRef, ahead: p.ahead, behind: p.behind },
752
+ // Obligation (ttl null by type default): a conflict handoff must reach the steward.
753
+ routeNotification({
754
+ type: "workspace.conflict",
755
+ scope: { workspaceId: ws.id, repoRoot: ws.repoRoot },
756
+ recipients: [ws.stewardAgentId],
757
+ message: {
758
+ subject: "Workspace merge conflict",
759
+ body: `Workspace \`${ws.branch ?? ws.id}\` in ${ws.repoRoot} can no longer merge cleanly into ${p.baseRef ?? "base"} (${p.ahead ?? "?"} ahead, ${p.behind ?? "?"} behind). As repo steward, please coordinate resolution.`,
760
+ payload: { kind: "workspace.conflict", workspaceId: ws.id, repoRoot: ws.repoRoot, branch: ws.branch, baseRef: p.baseRef, ahead: p.ahead, behind: p.behind },
761
+ },
747
762
  });
748
763
  notifiedStewards.push(ws.stewardAgentId);
749
764
  } catch {
@@ -774,16 +789,13 @@ async function autoMergeCleanFastForwards(): Promise<Record<string, unknown>> {
774
789
  );
775
790
  const stewardEnabled = getStewardConfig().enabled;
776
791
  const merged: string[] = [];
777
- const heldByLease: string[] = [];
778
- const heldByClaim: string[] = [];
779
- const heldAwaitingApproval: string[] = [];
780
- const leftForSteward: string[] = [];
781
- const wokeStewards: string[] = [];
792
+ const heldByLease: string[] = [], heldByClaim: string[] = [], heldByPause: string[] = [], heldAwaitingApproval: string[] = [], leftForSteward: string[] = [], wokeStewards: string[] = [];
782
793
 
783
794
  for (const ws of candidates) {
784
795
  if (isAwaitingReviewerApproval(ws)) { heldAwaitingApproval.push(ws.id); continue; }
785
796
  // A claimed workspace is being validated by a steward — don't race it (#208).
786
797
  if (workspaceActiveClaim(ws)) { heldByClaim.push(ws.id); continue; }
798
+ if (getActiveMergePause(ws.repoRoot, ws.baseRef ?? "main")) { heldByPause.push(ws.id); continue; }
787
799
  const orch = orchestrators.find((candidate) => workspacePathWithinBase(ws.sourceCwd, candidate.baseDir));
788
800
  if (!orch?.apiUrl) continue;
789
801
  const preview = await fetchHostMergePreview(orch.apiUrl, ws);
@@ -845,15 +857,16 @@ async function autoMergeCleanFastForwards(): Promise<Record<string, unknown>> {
845
857
  });
846
858
  }
847
859
 
848
- return { scanned: candidates.length, merged, heldByLease, heldByClaim, heldAwaitingApproval, leftForSteward, wokeStewards };
860
+ return { scanned: candidates.length, merged, heldByLease, heldByClaim, heldByPause, heldAwaitingApproval, leftForSteward, wokeStewards };
849
861
  }
850
862
 
851
- // Send a system DM, swallowing failures (a stale/missing/misconfigured target
852
- // must never break the GC sweep). Returns the target on success, null otherwise.
853
- function notifyTarget(target: string, subject: string, body: string, payload: Record<string, unknown>): string | null {
863
+ // Route a GC notification, swallowing failures (a stale/missing/misconfigured target must never
864
+ // break the GC sweep). Returns the target on success, null otherwise. These are all obligation-style
865
+ // workspace handoffs (null TTL by type default) they must survive until the recipient acts.
866
+ function notifyTarget(type: NotificationType, scope: NotificationScope, target: string, subject: string, body: string, payload: Record<string, unknown>): string | null {
854
867
  if (!target) return null;
855
868
  try {
856
- notifySystemMessage(target, { subject, body, payload });
869
+ routeNotification({ type, scope, recipients: [target], message: { subject, body, payload } });
857
870
  return target;
858
871
  } catch {
859
872
  return null;
@@ -905,6 +918,8 @@ async function workspaceGC(): Promise<Record<string, unknown>> {
905
918
  // steward hasn't been told, hand it off explicitly, then clear markers.
906
919
  if (strandedAt !== undefined && meta.strandedNotifiedSteward !== steward) {
907
920
  const sent = notifyTarget(
921
+ "workspace.steward_reassigned",
922
+ { workspaceId: fresh.id, repoRoot: fresh.repoRoot },
908
923
  steward!,
909
924
  "Workspace stewardship reassigned",
910
925
  `You are now steward for ${fresh.repoRoot}. Workspace \`${fresh.branch ?? fresh.id}\` is ${fresh.status} and was stranded without an online steward — please coordinate ${fresh.status === "conflict" ? "conflict resolution" : "review/merge"}.`,
@@ -920,6 +935,8 @@ async function workspaceGC(): Promise<Record<string, unknown>> {
920
935
  if (strandedAt === undefined) { patchWorkspaceMetadata(fresh.id, { strandedAt: now }); continue; }
921
936
  if (now - strandedAt < escalationMs || meta.escalatedAt) continue;
922
937
  const sent = notifyTarget(
938
+ "workspace.stranded_escalation",
939
+ { workspaceId: fresh.id, repoRoot: fresh.repoRoot },
923
940
  fallbackTarget,
924
941
  "Stranded workspace needs an owner",
925
942
  `Workspace \`${fresh.branch ?? fresh.id}\` in ${fresh.repoRoot} is ${fresh.status} with no online steward (all repo agents offline) for ${Math.round((now - strandedAt) / (60 * 60 * 1000))}h. Please coordinate ${fresh.status === "conflict" ? "conflict resolution" : "review/merge"} or clean up the worktree.`,
@@ -951,6 +968,8 @@ async function workspaceGC(): Promise<Record<string, unknown>> {
951
968
  // stranded abandon isn't silent (issue #157).
952
969
  const target = ws.stewardAgentId ?? fallbackTarget;
953
970
  const sent = notifyTarget(
971
+ "workspace.auto_abandoned",
972
+ { workspaceId: ws.id, repoRoot: ws.repoRoot },
954
973
  target,
955
974
  "Workspace auto-abandoned",
956
975
  `Workspace \`${ws.branch ?? ws.id}\` in ${ws.repoRoot} was auto-abandoned after ${Math.round(WORKSPACE_REVIEW_TTL_MS / DAY_MS)}d without steward action. Run workspace cleanup to reclaim the worktree.`,
@@ -0,0 +1,66 @@
1
+ import { ValidationError } from "./db";
2
+ import { McpNotFoundError } from "./mcp-errors";
3
+ import { authContextFromMcp } from "./services/auth-context";
4
+ import { listAutoMergePauses, pauseAutoMerge, renewAutoMergePause, resumeAutoMerge } from "./services/merge-pause";
5
+ import { cleanString } from "./validation";
6
+
7
+ export const MERGE_TOOLS = [
8
+ { name: "relay_merge_pauses", description: "List active auto-merge pauses. These are repo/base-level holds that make the workspace auto-merge job yield during release or build critical sections.", requiredScopes: ["command:read"], inputSchema: { type: "object", properties: {}, additionalProperties: false } },
9
+ {
10
+ name: "relay_merge_pause",
11
+ description: "Pause workspace auto-merge for one repo/base pair with a TTL. Existing active pause for the same target is returned as a conflict instead of replacing its holder.",
12
+ requiredScopes: ["command:write"],
13
+ inputSchema: {
14
+ type: "object",
15
+ properties: {
16
+ repoRoot: { type: "string", description: "Repository root whose auto-merge should pause." },
17
+ baseRef: { type: "string", description: "Base branch/ref to pause (default main)." },
18
+ ttlMs: { type: "integer", minimum: 1, description: "Pause TTL in milliseconds. Defaults to the server's merge-pause TTL." },
19
+ reason: { type: "string" },
20
+ holder: { type: "string", description: "Optional human-readable holder. Defaults to the authenticated caller." },
21
+ },
22
+ required: ["repoRoot"],
23
+ additionalProperties: false,
24
+ },
25
+ },
26
+ { name: "relay_merge_renew", description: "Renew an active auto-merge pause by id, extending its TTL from now.", requiredScopes: ["command:write"], inputSchema: { type: "object", properties: { id: { type: "string" }, ttlMs: { type: "integer", minimum: 1 } }, required: ["id"], additionalProperties: false } },
27
+ { name: "relay_merge_resume", description: "Release an auto-merge pause by id, or by repoRoot/baseRef.", requiredScopes: ["command:write"], inputSchema: { type: "object", properties: { id: { type: "string" }, repoRoot: { type: "string" }, baseRef: { type: "string", description: "Base branch/ref (default main when repoRoot is used)." } }, additionalProperties: false } },
28
+ ] satisfies Array<{ name: string; description: string; requiredScopes: string[]; inputSchema: Record<string, unknown> }>;
29
+
30
+ type McpAuthLike = Parameters<typeof authContextFromMcp>[0];
31
+
32
+ export function relayMergeTool(auth: McpAuthLike, name: string, args: Record<string, unknown>): Record<string, unknown> {
33
+ const ctx = authContextFromMcp(auth);
34
+ if (name === "relay_merge_pauses") {
35
+ const pauses = listAutoMergePauses();
36
+ return { pauses, count: pauses.length };
37
+ }
38
+ if (name === "relay_merge_pause") {
39
+ const result = pauseAutoMerge({
40
+ repoRoot: cleanString(args.repoRoot, "repoRoot", { required: true, max: 1024 })!,
41
+ baseRef: cleanString(args.baseRef, "baseRef", { max: 240 }),
42
+ holder: cleanString(args.holder, "holder", { max: 240 }) ?? ctx.callerAgentId ?? ctx.actor.id,
43
+ reason: cleanString(args.reason, "reason", { max: 1000 }),
44
+ ttlMs: optionalPositiveInt(args.ttlMs, "ttlMs"),
45
+ });
46
+ return result.ok ? { ok: true, pause: result.pause } : { ok: false, error: "merge target already paused", pause: result.pause };
47
+ }
48
+ if (name === "relay_merge_renew") {
49
+ const pause = renewAutoMergePause(cleanString(args.id, "id", { required: true, max: 120 })!, optionalPositiveInt(args.ttlMs, "ttlMs"));
50
+ if (!pause) throw new McpNotFoundError("merge pause not found or expired");
51
+ return { pause };
52
+ }
53
+ if (name === "relay_merge_resume") {
54
+ const id = cleanString(args.id, "id", { max: 120 });
55
+ const repoRoot = cleanString(args.repoRoot, "repoRoot", { max: 1024 });
56
+ if (!id && !repoRoot) throw new ValidationError("id or repoRoot required");
57
+ return { released: resumeAutoMerge({ id, repoRoot, baseRef: cleanString(args.baseRef, "baseRef", { max: 240 }) }) };
58
+ }
59
+ throw new ValidationError(`unknown tool: ${name}`);
60
+ }
61
+
62
+ function optionalPositiveInt(value: unknown, field: string): number | undefined {
63
+ if (value === undefined || value === null) return undefined;
64
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) throw new ValidationError(`${field} must be a positive integer`);
65
+ return value;
66
+ }
@@ -0,0 +1,111 @@
1
+ import {
2
+ addSubscription,
3
+ cleanSubscriptionMode,
4
+ cleanSubscriptionType,
5
+ listSubscriptions,
6
+ NOTIFICATION_SUBSCRIPTION_MODES,
7
+ NOTIFICATION_SUBSCRIPTION_TYPES,
8
+ removeSubscription,
9
+ type NotificationSubscriptionMode,
10
+ } from "./db/notification-subscriptions";
11
+ import { ValidationError } from "./db";
12
+ import { McpAuthError } from "./mcp-errors";
13
+ import { authContextFromMcp } from "./services/auth-context";
14
+ import { cleanString } from "./validation";
15
+ import type { NotificationScope } from "./notification-router";
16
+
17
+ export const NOTIFICATION_SUBSCRIPTION_TOOLS = [
18
+ {
19
+ name: "relay_subscribe",
20
+ description: "Subscribe, opt out, or mute this caller agent for a notification type. The caller's token identity is the subscription owner.",
21
+ requiredScopes: ["notifications:write"],
22
+ inputSchema: {
23
+ type: "object",
24
+ properties: {
25
+ type: { type: "string", enum: NOTIFICATION_SUBSCRIPTION_TYPES },
26
+ mode: { type: "string", enum: NOTIFICATION_SUBSCRIPTION_MODES, description: "Default opt_in. mute and opt_out suppress matching default deliveries." },
27
+ scope: { type: "object", description: "Optional partial notification scope match, such as { teamId } or { repoRoot }." },
28
+ },
29
+ required: ["type"],
30
+ additionalProperties: false,
31
+ },
32
+ },
33
+ {
34
+ name: "relay_unsubscribe",
35
+ description: "Remove this caller agent's notification subscription by id, or by type/mode/scope. Omitting mode removes all modes for the matched type/scope.",
36
+ requiredScopes: ["notifications:write"],
37
+ inputSchema: {
38
+ type: "object",
39
+ properties: {
40
+ id: { type: "string" },
41
+ type: { type: "string", enum: NOTIFICATION_SUBSCRIPTION_TYPES },
42
+ mode: { type: "string", enum: NOTIFICATION_SUBSCRIPTION_MODES },
43
+ scope: { type: "object" },
44
+ },
45
+ additionalProperties: false,
46
+ },
47
+ },
48
+ {
49
+ name: "relay_list_subscriptions",
50
+ description: "List this caller agent's durable notification subscriptions.",
51
+ requiredScopes: ["notifications:read"],
52
+ inputSchema: {
53
+ type: "object",
54
+ properties: {},
55
+ additionalProperties: false,
56
+ },
57
+ },
58
+ ] satisfies Array<{ name: string; description: string; requiredScopes: string[]; inputSchema: Record<string, unknown> }>;
59
+
60
+ type McpAuthLike = Parameters<typeof authContextFromMcp>[0];
61
+
62
+ export function relayNotificationSubscriptionTool(
63
+ auth: McpAuthLike,
64
+ name: string,
65
+ args: Record<string, unknown>,
66
+ ): Record<string, unknown> {
67
+ const agentId = callerAgentId(auth);
68
+ if (name === "relay_subscribe") {
69
+ const subscription = addSubscription({
70
+ agentId,
71
+ type: cleanSubscriptionType(args.type),
72
+ mode: cleanModeOrDefault(args.mode),
73
+ scopeMatch: cleanScope(args.scope),
74
+ });
75
+ return { subscription };
76
+ }
77
+ if (name === "relay_unsubscribe") {
78
+ const id = cleanString(args.id, "id", { max: 120 });
79
+ const type = args.type === undefined || args.type === null ? undefined : cleanSubscriptionType(args.type);
80
+ if (!id && !type) throw new ValidationError("id or type required");
81
+ const removed = removeSubscription({
82
+ agentId,
83
+ id,
84
+ type,
85
+ mode: args.mode === undefined || args.mode === null ? undefined : cleanSubscriptionMode(args.mode),
86
+ ...(Object.prototype.hasOwnProperty.call(args, "scope") ? { scopeMatch: cleanScope(args.scope) } : {}),
87
+ });
88
+ return { removed };
89
+ }
90
+ if (name === "relay_list_subscriptions") {
91
+ return { subscriptions: listSubscriptions(agentId) };
92
+ }
93
+ throw new ValidationError(`unknown tool: ${name}`);
94
+ }
95
+
96
+ function callerAgentId(auth: McpAuthLike): string {
97
+ const ctx = authContextFromMcp(auth);
98
+ if (!ctx.callerAgentId) throw new McpAuthError("notification subscriptions require a single caller agent identity");
99
+ return ctx.callerAgentId;
100
+ }
101
+
102
+ function cleanModeOrDefault(value: unknown): NotificationSubscriptionMode {
103
+ if (value === undefined || value === null) return "opt_in";
104
+ return cleanSubscriptionMode(value);
105
+ }
106
+
107
+ function cleanScope(value: unknown): Partial<NotificationScope> | null {
108
+ if (value === undefined || value === null) return null;
109
+ if (typeof value !== "object" || Array.isArray(value)) throw new ValidationError("scope must be an object");
110
+ return value as Partial<NotificationScope>;
111
+ }
package/src/mcp.ts CHANGED
@@ -50,6 +50,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 { NOTIFICATION_SUBSCRIPTION_TOOLS, relayNotificationSubscriptionTool } from "./mcp-notification-subscription-tools"; import { MERGE_TOOLS, relayMergeTool } from "./mcp-merge-tools";
53
54
  import { PROJECT_INSTRUCTION_POLICY_NAMES, parseProjectInstructions } from "./project-instructions";
54
55
  import { AGENT_ACTION_TOOL, relayAgentAction } from "./mcp-agent-action";
55
56
  import type { ActivityKind, AgentLifecycle, ArtifactKind, ArtifactSensitivity, AttachmentRef, Command, SendMessageInput, Message, SpawnApprovalMode, SpawnProvider, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceMode, WorkspaceRecord } from "./types";
@@ -459,10 +460,11 @@ const TOOLS: ToolDefinition[] = [
459
460
  additionalProperties: false,
460
461
  },
461
462
  },
462
- ...TEAM_TOOLS,
463
+ ...MERGE_TOOLS, ...TEAM_TOOLS,
463
464
  ...PLAN_TOOLS,
464
465
  ...BLACKBOARD_TOOLS,
465
466
  ...PROJECT_TOOLS,
467
+ ...NOTIFICATION_SUBSCRIPTION_TOOLS,
466
468
  ];
467
469
 
468
470
  export async function postMcp(req: Request): Promise<Response> {
@@ -575,10 +577,11 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
575
577
  else if (name === "relay_workspace_claim") result = relayWorkspaceMutation(auth, "claim", args);
576
578
  else if (name === "relay_workspace_release") result = relayWorkspaceMutation(auth, "release-claim", args);
577
579
  else if (name === "relay_workspace_land") result = relayWorkspaceMutation(auth, "merge", args);
578
- else if (name.startsWith("relay_team_")) result = await relayTeamTool(auth, name, args);
580
+ else if (name.startsWith("relay_merge_")) result = relayMergeTool(auth, name, args); else if (name.startsWith("relay_team_")) result = await relayTeamTool(auth, name, args);
579
581
  else if (name.startsWith("relay_plan_")) result = relayPlanTool(auth, name, args);
580
582
  else if (name.startsWith("relay_blackboard_")) result = relayBlackboardTool(auth, name, args);
581
583
  else if (name.startsWith("relay_project_")) result = relayProjectTool(auth, name, args);
584
+ else if (name === "relay_subscribe" || name === "relay_unsubscribe" || name === "relay_list_subscriptions") result = relayNotificationSubscriptionTool(auth, name, args);
582
585
  else throw new ValidationError(`unknown tool: ${name}`);
583
586
 
584
587
  auditToolCall(auth, name, "ok", result);
@@ -1040,7 +1043,6 @@ function relayWorkspaceMutation(auth: McpAuthContext, action: WorkspaceAction, a
1040
1043
  return payload;
1041
1044
  }
1042
1045
 
1043
-
1044
1046
  function payloadWithAttachments(
1045
1047
  payload: Record<string, unknown> | undefined,
1046
1048
  attachments: AttachmentRef[] | undefined,
@@ -0,0 +1,13 @@
1
+ import { REAP_INTERVAL_MS } from "./config";
2
+ import { releaseExpiredMergePauses } from "./db";
3
+
4
+ export const MERGE_PAUSE_MAINTENANCE_JOB = {
5
+ id: "merge-pause-expiration",
6
+ title: "Merge pause expiration",
7
+ description: "Release expired repo/base auto-merge pauses.",
8
+ intervalMs: REAP_INTERVAL_MS,
9
+ runOnStart: true,
10
+ handler() {
11
+ return { releasedMergePauseIds: releaseExpiredMergePauses() };
12
+ },
13
+ };