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.
- package/docs/openapi.json +29 -1
- package/package.json +2 -2
- package/public/assets/MessageBubble-DX16Hbb5.js +3 -0
- package/public/assets/MessageBubble-DX16Hbb5.js.map +1 -0
- package/public/assets/{PlanGraphPanel-BwEOOqdj.js → PlanGraphPanel-BuWerKhs.js} +2 -2
- package/public/assets/{PlanGraphPanel-BwEOOqdj.js.map → PlanGraphPanel-BuWerKhs.js.map} +1 -1
- package/public/assets/activity-tuTEn3vn.js +2 -0
- package/public/assets/{activity-DD9bl5uf.js.map → activity-tuTEn3vn.js.map} +1 -1
- package/public/assets/{agent-profiles-CLJNeVxc.js → agent-profiles-CAwmjdDx.js} +2 -2
- package/public/assets/{agent-profiles-CLJNeVxc.js.map → agent-profiles-CAwmjdDx.js.map} +1 -1
- package/public/assets/agents-B-BSBrM5.js +2 -0
- package/public/assets/{agents-DJgqCIqi.js.map → agents-B-BSBrM5.js.map} +1 -1
- package/public/assets/{analytics-C1F28oqr.js → analytics-aI_oGHtR.js} +3 -3
- package/public/assets/{analytics-C1F28oqr.js.map → analytics-aI_oGHtR.js.map} +1 -1
- package/public/assets/{automation-Vbei8P9W.js → automation-BAQM1dBh.js} +2 -2
- package/public/assets/{automation-Vbei8P9W.js.map → automation-BAQM1dBh.js.map} +1 -1
- package/public/assets/{badge-CK6ejiOh.js → badge-fjSgjnDc.js} +2 -2
- package/public/assets/{badge-CK6ejiOh.js.map → badge-fjSgjnDc.js.map} +1 -1
- package/public/assets/branch-state-badge-DaafW7sN.js +2 -0
- package/public/assets/{branch-state-badge-Q4W68jxj.js.map → branch-state-badge-DaafW7sN.js.map} +1 -1
- package/public/assets/{button-BAwKuRIq.js → button-DidMOp8n.js} +2 -2
- package/public/assets/{button-BAwKuRIq.js.map → button-DidMOp8n.js.map} +1 -1
- package/public/assets/card-CpLnx1JJ.js +2 -0
- package/public/assets/{card-DDxxo-Fo.js.map → card-CpLnx1JJ.js.map} +1 -1
- package/public/assets/{channels-BQbNtrtF.js → channels-DHjTCcE8.js} +2 -2
- package/public/assets/{channels-BQbNtrtF.js.map → channels-DHjTCcE8.js.map} +1 -1
- package/public/assets/chat-uWKl3MpS.js +2 -0
- package/public/assets/{chat-Dw5xpc5W.js.map → chat-uWKl3MpS.js.map} +1 -1
- package/public/assets/{connectors-CmARSKoV.js → connectors-CI97PYOl.js} +2 -2
- package/public/assets/{connectors-CmARSKoV.js.map → connectors-CI97PYOl.js.map} +1 -1
- package/public/assets/copy-button-CKAwTG9g.js +2 -0
- package/public/assets/{copy-button-D3B_s7J7.js.map → copy-button-CKAwTG9g.js.map} +1 -1
- package/public/assets/display-CWMHll1J.js.map +1 -1
- package/public/assets/{dist-Dmiv1tSO.js → dist-BEpGArlE.js} +2 -2
- package/public/assets/{dist-Dmiv1tSO.js.map → dist-BEpGArlE.js.map} +1 -1
- package/public/assets/dist-BZ-CpDXy.js +2 -0
- package/public/assets/{dist-CIbvPDQV.js.map → dist-BZ-CpDXy.js.map} +1 -1
- package/public/assets/{dist-DLsSqk6h.js → dist-BaDWyAAG.js} +2 -2
- package/public/assets/{dist-DLsSqk6h.js.map → dist-BaDWyAAG.js.map} +1 -1
- package/public/assets/{dist-CAFUEpK2.js → dist-BivRSJg7.js} +2 -2
- package/public/assets/{dist-CAFUEpK2.js.map → dist-BivRSJg7.js.map} +1 -1
- package/public/assets/{dist-CL2npVDe.js → dist-C9ntDbUp.js} +2 -2
- package/public/assets/{dist-CL2npVDe.js.map → dist-C9ntDbUp.js.map} +1 -1
- package/public/assets/dist-CEb4q1gZ.js +2 -0
- package/public/assets/{dist-DyqEeyBI.js.map → dist-CEb4q1gZ.js.map} +1 -1
- package/public/assets/dist-DBDYIgS5.js +2 -0
- package/public/assets/{dist-CoRABSO3.js.map → dist-DBDYIgS5.js.map} +1 -1
- package/public/assets/{dist-ERYvDMdt.js → dist-DNxK5Ibj.js} +2 -2
- package/public/assets/{dist-ERYvDMdt.js.map → dist-DNxK5Ibj.js.map} +1 -1
- package/public/assets/{dist-YjP8Gd1C.js → dist-DayffsX1.js} +2 -2
- package/public/assets/{dist-YjP8Gd1C.js.map → dist-DayffsX1.js.map} +1 -1
- package/public/assets/{dist-YQVQrtnc.js → dist-L3vQoV3T.js} +2 -2
- package/public/assets/{dist-YQVQrtnc.js.map → dist-L3vQoV3T.js.map} +1 -1
- package/public/assets/{dist-CnRv0y2x.js → dist-uFOP-AfS.js} +2 -2
- package/public/assets/{dist-CnRv0y2x.js.map → dist-uFOP-AfS.js.map} +1 -1
- package/public/assets/{es2015-Yto-4EBw.js → es2015-CJr6bLcU.js} +2 -2
- package/public/assets/{es2015-Yto-4EBw.js.map → es2015-CJr6bLcU.js.map} +1 -1
- package/public/assets/{formatted-body-impl-BisZ0FtO.js → formatted-body-impl-DuqZunxL.js} +2 -2
- package/public/assets/{formatted-body-impl-BisZ0FtO.js.map → formatted-body-impl-DuqZunxL.js.map} +1 -1
- package/public/assets/index-DLjkMB-b.css +2 -0
- package/public/assets/index-dMB8klzy.js +23 -0
- package/public/assets/index-dMB8klzy.js.map +1 -0
- package/public/assets/{input-CLW2gDUM.js → input-BbH2Jq-f.js} +2 -2
- package/public/assets/{input-CLW2gDUM.js.map → input-BbH2Jq-f.js.map} +1 -1
- package/public/assets/{insights-D9ytYZyE.js → insights-C0lZ7n8D.js} +2 -2
- package/public/assets/{insights-D9ytYZyE.js.map → insights-C0lZ7n8D.js.map} +1 -1
- package/public/assets/integrations-DXh00Ss-.js +2 -0
- package/public/assets/{integrations-D9Cx-7ma.js.map → integrations-DXh00Ss-.js.map} +1 -1
- package/public/assets/{lib-DdCM7GfN.js → lib-B0r9alsu.js} +2 -2
- package/public/assets/{lib-DdCM7GfN.js.map → lib-B0r9alsu.js.map} +1 -1
- package/public/assets/{lucide-react-CsPXvXMC.js → lucide-react-BCarrGVa.js} +2 -2
- package/public/assets/{lucide-react-CsPXvXMC.js.map → lucide-react-BCarrGVa.js.map} +1 -1
- package/public/assets/{maintenance-BA93FvLR.js → maintenance-Dh9nzjYV.js} +2 -2
- package/public/assets/{maintenance-BA93FvLR.js.map → maintenance-Dh9nzjYV.js.map} +1 -1
- package/public/assets/{managed-agents-DGwdEIzA.js → managed-agents-Bsveljn4.js} +4 -4
- package/public/assets/{managed-agents-DGwdEIzA.js.map → managed-agents-Bsveljn4.js.map} +1 -1
- package/public/assets/{markdown-preview-impl-C7FDXM5c.js → markdown-preview-impl-CYpMFis9.js} +2 -2
- package/public/assets/{markdown-preview-impl-C7FDXM5c.js.map → markdown-preview-impl-CYpMFis9.js.map} +1 -1
- package/public/assets/{memory-CHzkRJqf.js → memory-DMFQMXrp.js} +2 -2
- package/public/assets/{memory-CHzkRJqf.js.map → memory-DMFQMXrp.js.map} +1 -1
- package/public/assets/messages-Cnzc7j50.js +2 -0
- package/public/assets/{messages-B7CJ5tgT.js.map → messages-Cnzc7j50.js.map} +1 -1
- package/public/assets/{orchestrators-DOy9xIx2.js → orchestrators-DSoDPNVS.js} +2 -2
- package/public/assets/{orchestrators-DOy9xIx2.js.map → orchestrators-DSoDPNVS.js.map} +1 -1
- package/public/assets/{overview-DLbv1BWa.js → overview-OsRxo5XQ.js} +2 -2
- package/public/assets/{overview-DLbv1BWa.js.map → overview-OsRxo5XQ.js.map} +1 -1
- package/public/assets/{pairs-BDQo-k3F.js → pairs-D4UiUhyW.js} +2 -2
- package/public/assets/{pairs-BDQo-k3F.js.map → pairs-D4UiUhyW.js.map} +1 -1
- package/public/assets/{projects-hU5KGW_T.js → projects-CYzU-Cf3.js} +2 -2
- package/public/assets/{projects-hU5KGW_T.js.map → projects-CYzU-Cf3.js.map} +1 -1
- package/public/assets/{react-dom-CEl58oxP.js → react-dom-CYpnckci.js} +2 -2
- package/public/assets/{react-dom-CEl58oxP.js.map → react-dom-CYpnckci.js.map} +1 -1
- package/public/assets/{security-9WrCcrWs.js → security-CC2F6xdd.js} +2 -2
- package/public/assets/{security-9WrCcrWs.js.map → security-CC2F6xdd.js.map} +1 -1
- package/public/assets/{select-CRjeGZ9a.js → select-CD38f1WQ.js} +2 -2
- package/public/assets/{select-CRjeGZ9a.js.map → select-CD38f1WQ.js.map} +1 -1
- package/public/assets/settings-T3B40Amb.js +6 -0
- package/public/assets/settings-T3B40Amb.js.map +1 -0
- package/public/assets/{store-DsqUEZOf.js → store-CJeafbP1.js} +3 -3
- package/public/assets/{store-DsqUEZOf.js.map → store-CJeafbP1.js.map} +1 -1
- package/public/assets/{tasks-86St2vpn.js → tasks-BkrhWMw7.js} +2 -2
- package/public/assets/{tasks-86St2vpn.js.map → tasks-BkrhWMw7.js.map} +1 -1
- package/public/assets/{teams-C9WUxzud.js → teams-DyzaREAg.js} +2 -2
- package/public/assets/{teams-C9WUxzud.js.map → teams-DyzaREAg.js.map} +1 -1
- package/public/assets/{terminal-viewer-impl-yXWyt2fm.js → terminal-viewer-impl-B5JeHof3.js} +3 -3
- package/public/assets/{terminal-viewer-impl-yXWyt2fm.js.map → terminal-viewer-impl-B5JeHof3.js.map} +1 -1
- package/public/assets/{use-keyboard-viewport-DABeIMEJ.js → use-keyboard-viewport-BYCJkZCN.js} +2 -2
- package/public/assets/{use-keyboard-viewport-DABeIMEJ.js.map → use-keyboard-viewport-BYCJkZCN.js.map} +1 -1
- package/public/assets/user-avatar-C7i11_ju.js +2 -0
- package/public/assets/{user-avatar-tomKla2H.js.map → user-avatar-C7i11_ju.js.map} +1 -1
- package/public/assets/{work-queue-BtLMzf0g.js → work-queue-DHp4p1Zk.js} +2 -2
- package/public/assets/{work-queue-BtLMzf0g.js.map → work-queue-DHp4p1Zk.js.map} +1 -1
- package/public/assets/workspaces-BzwZvbsq.js +3 -0
- package/public/assets/{workspaces-C740m1ys.js.map → workspaces-BzwZvbsq.js.map} +1 -1
- package/public/index.html +21 -21
- package/src/agent-lifecycle-events.ts +0 -10
- package/src/branch-landed.ts +17 -29
- package/src/bus.ts +9 -2
- package/src/config-store.ts +13 -26
- package/src/config.ts +1 -0
- package/src/context-advisory.ts +20 -10
- package/src/db/agent-predicates.ts +21 -0
- package/src/db/agents.ts +3 -3
- package/src/db/delivery.ts +38 -0
- package/src/db/messages.ts +18 -1
- package/src/db/migrations.ts +17 -0
- package/src/db/scheduled-timers.ts +33 -12
- package/src/db/schema.ts +4 -0
- package/src/db/workspaces.ts +44 -2
- package/src/maintenance/constants.ts +2 -0
- package/src/maintenance/jobs.ts +21 -8
- package/src/maintenance/workspaces.ts +76 -0
- package/src/mcp/index.ts +1 -1
- package/src/mcp/tools-messaging.ts +13 -3
- package/src/mcp/tools-spawn.ts +30 -2
- package/src/mcp-notification-subscription-tools.ts +55 -2
- package/src/notification-router.ts +163 -9
- package/src/notification-rules.ts +7 -1
- package/src/notification-settings-store.ts +47 -0
- package/src/notification-settings.ts +155 -0
- package/src/notification-types.ts +2 -0
- package/src/quota-advisory.ts +0 -3
- package/src/routes/agents-spawn.ts +15 -2
- package/src/routes/agents.ts +17 -2
- package/src/routes/commands.ts +5 -0
- package/src/routes/index.ts +2 -0
- package/src/routes/notifications.ts +36 -0
- package/src/routes/settings.ts +6 -0
- package/src/security.ts +1 -0
- package/src/services/managed-running.ts +18 -1
- package/src/services/rate-limit-resume.ts +3 -0
- package/src/services/register-agent.ts +7 -1
- package/src/services/scheduler.ts +29 -1
- package/src/services/spawn-agent.ts +62 -2
- package/src/settings/registry.ts +30 -44
- package/src/spawn-command.ts +10 -0
- package/src/sse.ts +1 -0
- package/src/validation.ts +7 -2
- package/src/workspace-auto-merge.ts +53 -9
- package/public/assets/MessageBubble-BORB9AKu.js +0 -3
- package/public/assets/MessageBubble-BORB9AKu.js.map +0 -1
- package/public/assets/activity-DD9bl5uf.js +0 -2
- package/public/assets/agents-DJgqCIqi.js +0 -2
- package/public/assets/branch-state-badge-Q4W68jxj.js +0 -2
- package/public/assets/card-DDxxo-Fo.js +0 -2
- package/public/assets/chat-Dw5xpc5W.js +0 -2
- package/public/assets/copy-button-D3B_s7J7.js +0 -2
- package/public/assets/dist-CIbvPDQV.js +0 -2
- package/public/assets/dist-CoRABSO3.js +0 -2
- package/public/assets/dist-DyqEeyBI.js +0 -2
- package/public/assets/index-Bsi2NH0v.js +0 -23
- package/public/assets/index-Bsi2NH0v.js.map +0 -1
- package/public/assets/index-umexz3cs.css +0 -2
- package/public/assets/integrations-D9Cx-7ma.js +0 -2
- package/public/assets/messages-B7CJ5tgT.js +0 -2
- package/public/assets/settings-Durch6xW.js +0 -4
- package/public/assets/settings-Durch6xW.js.map +0 -1
- package/public/assets/user-avatar-tomKla2H.js +0 -2
- package/public/assets/workspaces-C740m1ys.js +0 -3
package/src/db/workspaces.ts
CHANGED
|
@@ -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: {
|
|
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,
|
package/src/maintenance/jobs.ts
CHANGED
|
@@ -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
|
|
256
|
-
body: "Agent
|
|
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
|
-
|
|
253
|
-
claimable: optionalBoolean(args.claimable, "claimable"),
|
|
263
|
+
claimable,
|
|
254
264
|
idempotencyKey: optionalString(args.idempotencyKey, "idempotencyKey", 240),
|
|
255
265
|
attachments,
|
|
256
266
|
payload,
|
|
257
|
-
meta
|
|
267
|
+
meta,
|
|
258
268
|
};
|
|
259
269
|
return runMcpSend(auth, input);
|
|
260
270
|
}
|
package/src/mcp/tools-spawn.ts
CHANGED
|
@@ -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
|
|
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))];
|