agent-relay-server 0.59.0 → 0.60.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.
@@ -1,6 +1,6 @@
1
1
  import { emitRelayEvent } from "./events";
2
2
  import { getNotificationsConfig } from "./config-store";
3
- import { notifySystemMessage } from "./notify";
3
+ import { routeNotification } from "./notification-router";
4
4
  import { getAgent } from "./db";
5
5
  import type { AgentCard } from "./types";
6
6
 
@@ -56,11 +56,16 @@ export function notifyAgentReady(childId: string): void {
56
56
 
57
57
  const config = getNotificationsConfig();
58
58
  if (!config.enabled || !config.agentReady) return;
59
- notifySystemMessage(parent, {
60
- subject: "Spawned agent ready",
61
- body: `✅ Your spawned agent ${describe(child)} is ready and idle — send it work with relay_send_message to \`${childId}\`.`,
62
- payload: { kind: "agent.ready", agentId: childId, parent, provider: providerOf(child), spawnRequestId: spawnRequestIdOf(child) },
63
- replyExpected: false,
59
+ routeNotification({
60
+ type: "agent.ready",
61
+ scope: { agentId: childId, parent },
62
+ recipients: [parent],
63
+ message: {
64
+ subject: "Spawned agent ready",
65
+ body: `✅ Your spawned agent ${describe(child)} is ready and idle — send it work with relay_send_message to \`${childId}\`.`,
66
+ payload: { kind: "agent.ready", agentId: childId, parent, provider: providerOf(child), spawnRequestId: spawnRequestIdOf(child) },
67
+ replyExpected: false,
68
+ },
64
69
  });
65
70
  }
66
71
 
@@ -85,11 +90,16 @@ export function notifyAgentOffline(childId: string, reason: string): void {
85
90
  data: { agentId: childId, parent, reason, provider: providerOf(child), spawnRequestId: spawnRequestIdOf(child) },
86
91
  });
87
92
  if (!config.enabled || !config.agentExited) return;
88
- notifySystemMessage(parent, {
89
- subject: "Spawned agent exited",
90
- body: `⏹ Your spawned agent ${describe(child)} exited — ${reason}.`,
91
- payload: { kind: "agent.exited", agentId: childId, parent, reason },
92
- replyExpected: false,
93
+ routeNotification({
94
+ type: "agent.exited",
95
+ scope: { agentId: childId, parent },
96
+ recipients: [parent],
97
+ message: {
98
+ subject: "Spawned agent exited",
99
+ body: `⏹ Your spawned agent ${describe(child)} exited — ${reason}.`,
100
+ payload: { kind: "agent.exited", agentId: childId, parent, reason },
101
+ replyExpected: false,
102
+ },
93
103
  });
94
104
  return;
95
105
  }
@@ -101,11 +111,16 @@ export function notifyAgentOffline(childId: string, reason: string): void {
101
111
  data: { agentId: childId, parent, reason, provider: providerOf(child), spawnRequestId: spawnRequestIdOf(child), neverReady: true },
102
112
  });
103
113
  if (!config.enabled || !config.agentSpawnFailed) return;
104
- notifySystemMessage(parent, {
105
- subject: "Spawn failed",
106
- body: `❌ Your spawned agent ${describe(child)} went offline before ever becoming ready — ${reason}. Likely a crash-loop, onboarding gate, or bad launch; call relay_spawn_targets to re-check provider availability before retrying.`,
107
- payload: { kind: "agent.spawn_failed", agentId: childId, parent, reason },
108
- replyExpected: false,
114
+ routeNotification({
115
+ type: "agent.spawn_failed",
116
+ scope: { agentId: childId, parent },
117
+ recipients: [parent],
118
+ message: {
119
+ subject: "Spawn failed",
120
+ body: `❌ Your spawned agent ${describe(child)} went offline before ever becoming ready — ${reason}. Likely a crash-loop, onboarding gate, or bad launch; call relay_spawn_targets to re-check provider availability before retrying.`,
121
+ payload: { kind: "agent.spawn_failed", agentId: childId, parent, reason },
122
+ replyExpected: false,
123
+ },
109
124
  });
110
125
  }
111
126
 
@@ -123,11 +138,16 @@ export function notifyAgentSpawnFailed(input: { parent: string; spawnRequestId?:
123
138
  });
124
139
  const config = getNotificationsConfig();
125
140
  if (!config.enabled || !config.agentSpawnFailed) return;
126
- notifySystemMessage(input.parent, {
127
- subject: "Spawn failed",
128
- body: `❌ Your spawn request${input.spawnRequestId ? ` \`${input.spawnRequestId}\`` : ""} failed — ${input.reason}. Call relay_spawn_targets to check live provider/host availability before retrying.`,
129
- payload: { kind: "agent.spawn_failed", parent: input.parent, spawnRequestId: input.spawnRequestId, provider: input.provider, reason: input.reason },
130
- replyExpected: false,
141
+ routeNotification({
142
+ type: "agent.spawn_failed",
143
+ scope: { parent: input.parent },
144
+ recipients: [input.parent],
145
+ message: {
146
+ subject: "Spawn failed",
147
+ body: `❌ Your spawn request${input.spawnRequestId ? ` \`${input.spawnRequestId}\`` : ""} failed — ${input.reason}. Call relay_spawn_targets to check live provider/host availability before retrying.`,
148
+ payload: { kind: "agent.spawn_failed", parent: input.parent, spawnRequestId: input.spawnRequestId, provider: input.provider, reason: input.reason },
149
+ replyExpected: false,
150
+ },
131
151
  });
132
152
  }
133
153
 
@@ -1,9 +1,16 @@
1
1
  import { emitRelayEvent } from "./events";
2
2
  import { getNotificationsConfig } from "./config-store";
3
- import { notifySystemMessage } from "./notify";
4
- import { createActivityEvent, listAgents, updateWorkspaceStatus } from "./db";
5
- import { isAgentOnline } from "./agent-ref";
6
- import type { AgentCard, WorkspaceMergePreview, WorkspaceRecord } from "./types";
3
+ import { routeNotification, INFORMATIONAL_TTL_MS } from "./notification-router";
4
+ import { registerDefaultAudienceRules, resolveAudience, audiencePredicates } from "./notification-rules";
5
+ import { createActivityEvent, getAgent, updateWorkspaceStatus } from "./db";
6
+ import type { WorkspaceMergePreview, WorkspaceRecord } from "./types";
7
+
8
+ // #444 — branch.landed is the first rule-driven type: the on-main "merged" fan-out audience comes
9
+ // from the #442 engine, not a hand-rolled agentsOnMain scan. onRepoRoot delivers online agents in
10
+ // the main checkout (author excluded — they get the direct store-ahead copy below); differentTeam
11
+ // then scopes the fan-out to the land's team, so a Team-A land no longer wakes Team-B agents sharing
12
+ // the main checkout (epic #440's dogfooded symptom). A teamless author/agent keeps prior reach.
13
+ registerDefaultAudienceRules("branch.landed", [audiencePredicates.onRepoRoot(), audiencePredicates.differentTeam()]);
7
14
 
8
15
  export interface BranchLandedInput {
9
16
  /**
@@ -88,27 +95,44 @@ export function notifyBranchLanded(input: BranchLandedInput): void {
88
95
  const continueLabel = input.newBranch
89
96
  ? ` You're now on \`${input.newBranch}\` — keep working there.`
90
97
  : " Worktree reclaimed.";
91
- notifySystemMessage(author, {
92
- subject: "Your branch landed",
93
- body: `✅ ${branchLabel} landed on \`${base}\`${shaLabel}${subjectLabel}.${publishLabel}${continueLabel}`,
94
- payload,
95
- replyExpected: false,
98
+ // Durable (ttl null): the author's own branch landing must reach them even after they go
99
+ // offline (store-ahead #234) — not a bystander notice that goes stale.
100
+ routeNotification({
101
+ type: "branch.landed",
102
+ scope: { author, repoRoot: workspace.repoRoot, workspaceId: workspace.id },
103
+ recipients: [author],
104
+ ttlMs: null,
105
+ message: {
106
+ subject: "Your branch landed",
107
+ body: `✅ ${branchLabel} landed on \`${base}\`${shaLabel}${subjectLabel}.${publishLabel}${continueLabel}`,
108
+ payload,
109
+ replyExpected: false,
110
+ },
96
111
  });
97
112
  }
98
113
 
99
- // Agents on `main`those whose cwd IS the main checkout (not an isolated worktree) —
100
- // get a live "merged" notice so a long-lived main agent's context stays current as work
101
- // lands under it (#239). Online-only: a stale/exited main session needs no wake, and
102
- // store-ahead to it would just pile up noise. The author is in a worktree (cwd ≠ repoRoot)
103
- // so it's naturally excluded; guard anyway for shared-mode owners.
104
- const branchLabel = landedBranch ? `\`${landedBranch}\`` : "A branch";
105
- const authorLabel = author ? ` by \`${author}\`` : "";
106
- for (const agent of agentsOnMain(workspace.repoRoot, author)) {
107
- notifySystemMessage(agent.id, {
108
- subject: `Merged to ${base}`,
109
- body: `🔀 ${branchLabel}${authorLabel} merged to \`${base}\`${shaLabel}${subjectLabel}.${publishLabel}`,
110
- payload,
111
- replyExpected: false,
114
+ // On-main "merged" fan-outa live notice so a long-lived main agent's context stays current as
115
+ // work lands under it (#239). Recipients now come from the #442 rule engine (registered above):
116
+ // online agents whose cwd is the main checkout, author-excluded, team-scoped. Empty for a solo
117
+ // land with no other main agents no-op.
118
+ const mainScope = { author, repoRoot: workspace.repoRoot, workspaceId: workspace.id, teamId: author ? getAgent(author)?.teamId : undefined };
119
+ const mainRecipients = resolveAudience("branch.landed", mainScope).map((member) => member.agentId);
120
+ if (mainRecipients.length > 0) {
121
+ const branchLabel = landedBranch ? `\`${landedBranch}\`` : "A branch";
122
+ const authorLabel = author ? ` by \`${author}\`` : "";
123
+ // Bystander fan-out (ttl informational): a "merged" notice is only useful while fresh — a
124
+ // late-joining main agent shouldn't be woken by an hours-old land.
125
+ routeNotification({
126
+ type: "branch.landed",
127
+ scope: mainScope,
128
+ recipients: mainRecipients,
129
+ ttlMs: INFORMATIONAL_TTL_MS,
130
+ message: {
131
+ subject: `Merged to ${base}`,
132
+ body: `🔀 ${branchLabel}${authorLabel} merged to \`${base}\`${shaLabel}${subjectLabel}.${publishLabel}`,
133
+ payload,
134
+ replyExpected: false,
135
+ },
112
136
  });
113
137
  }
114
138
  }
@@ -148,16 +172,3 @@ export function reconcileLandedWorkspace(ws: WorkspaceRecord, preview: Workspace
148
172
  // Notification is best-effort; the merged status + activity event still stand.
149
173
  }
150
174
  }
151
-
152
- // An agent is "on `main`" when its registered cwd equals the repo's main checkout — i.e. it
153
- // works in the base, not an isolated worktree. Excludes the author, pseudo agents (system/
154
- // user), channels, and offline sessions.
155
- function agentsOnMain(repoRoot: string, author: string | undefined): AgentCard[] {
156
- return listAgents().filter((a) => {
157
- if (a.id === author || a.id === "system" || a.id === "user") return false;
158
- if (a.kind === "channel" || a.meta?.kind === "channel") return false;
159
- const cwd = a.meta?.cwd;
160
- if (typeof cwd !== "string" || cwd !== repoRoot) return false;
161
- return isAgentOnline(a);
162
- });
163
- }
@@ -23,3 +23,13 @@ export function parseChannelRouteTarget(target: string): ChannelRouteTarget {
23
23
  if (target.startsWith("orchestrator:")) return { type: "orchestrator", id: target.slice("orchestrator:".length) };
24
24
  return { type: "agent", id: target };
25
25
  }
26
+
27
+ // A fan-out target addresses MANY agents (broadcast / tag / cap / label / team / pool) rather than
28
+ // one. #451 uses this to scope the default message TTL: a stale fan-out message must not wake a
29
+ // late-joining agent, whereas a direct DM (and `policy:`, which resolves to one running agent or
30
+ // queues with its own expiry) is store-ahead and never auto-expires. Built on the one parser above
31
+ // so the prefix set can't drift from route resolution.
32
+ export function isFanoutTarget(target: string): boolean {
33
+ const { type } = parseChannelRouteTarget(target);
34
+ return type === "broadcast" || type === "tag" || type === "capability" || type === "label" || type === "team" || type === "pool";
35
+ }
package/src/config.ts CHANGED
@@ -20,6 +20,11 @@ function envPositiveInt(name: string, fallback: number): number {
20
20
  }
21
21
 
22
22
  export const STALE_TTL_MS = envPositiveInt("STALE_TTL_MS", 120_000); // 2min without heartbeat → offline
23
+ // #451 — default TTL for fire-and-forget FAN-OUT messages (broadcast/tag/cap/label/team) that the
24
+ // sender didn't bound. A stale fan-out push must not wake an agent that matches the target hours
25
+ // later (the cold-session-woken-by-old-coordination-thread bug). Direct DMs, reply-obligations, and
26
+ // claimable work are exempt (never auto-expire) so store-ahead (#234) still delivers them.
27
+ export const DEFAULT_FANOUT_TTL_SECONDS = envPositiveInt("AGENT_RELAY_FANOUT_TTL_SECONDS", 2 * 60 * 60); // 2h
23
28
  export const OFFLINE_PRUNE_MS = envPositiveInt("OFFLINE_PRUNE_MS", DAY_MS); // 24h offline → delete
24
29
  export const REAP_INTERVAL_MS = envPositiveInt("REAP_INTERVAL_MS", 60_000); // reaper cadence
25
30
  export const CLAIM_LEASE_MS = envPositiveInt("AGENT_RELAY_CLAIM_LEASE_MS", 1_800_000); // 30min claim lease
@@ -1,7 +1,7 @@
1
1
  import { getNotificationsConfig } from "./config-store";
2
2
  import { getDb } from "./db/connection.ts";
3
3
  import { emitRelayEvent } from "./events";
4
- import { notifySystemMessage } from "./notify";
4
+ import { routeNotification } from "./notification-router";
5
5
  import type { AgentCard, Command, ContextState } from "./types";
6
6
 
7
7
  interface ContextAdvisoryInput {
@@ -38,11 +38,18 @@ function notifyContextAdvisory(input: ContextAdvisoryInput): void {
38
38
  const config = getNotificationsConfig();
39
39
  if (!config.enabled || !config.contextThreshold.enabled) return;
40
40
 
41
- notifySystemMessage(input.agentId, {
42
- subject: "Context threshold reached",
43
- body: `Context advisory: ${formatTokens(input.tokens)} / ${formatTokens(input.windowTokens)} tokens used (${input.percent}% of window, ${input.band}% band). Compact at a clean seam before the ${input.hardBackstopPercent}% hard backstop may request compaction.`,
44
- payload,
45
- replyExpected: false,
41
+ // Short default TTL (per-type): a stale context advisory is misleading — the band may have moved
42
+ // by the time the agent next polls, so old advice shouldn't wake it.
43
+ routeNotification({
44
+ type: "agent.context_threshold",
45
+ scope: { agentId: input.agentId },
46
+ recipients: [input.agentId],
47
+ message: {
48
+ subject: "Context threshold reached",
49
+ body: `Context advisory: ${formatTokens(input.tokens)} / ${formatTokens(input.windowTokens)} tokens used (${input.percent}% of window, ${input.band}% band). Compact at a clean seam before the ${input.hardBackstopPercent}% hard backstop may request compaction.`,
50
+ payload,
51
+ replyExpected: false,
52
+ },
46
53
  });
47
54
  }
48
55
 
@@ -204,6 +204,12 @@ export function pollMessages(query: PollQuery): Message[] {
204
204
  }
205
205
  conditions.push("delivery_status != 'queued' AND delivery_status NOT IN ('failed', 'dead')");
206
206
 
207
+ // #451 — never surface a message past its TTL: a stale fan-out notification must not wake an
208
+ // agent that matches its target long after it was sent. NULL max_age_seconds never expires
209
+ // (direct DMs, reply-obligations, claimable work — store-ahead #234 still delivers those).
210
+ conditions.push("(max_age_seconds IS NULL OR created_at + max_age_seconds * 1000 > ?)");
211
+ params.push(Date.now());
212
+
207
213
  // A claimable message is available to this agent when it is non-claimable, currently held by
208
214
  // THIS agent, or unclaimed/expired AND its task is not already closed. The closed-task guard
209
215
  // must cover BOTH the null-claim and the expired-claim branch: when a managed agent is
@@ -12,8 +12,9 @@ import {
12
12
  parseRuntimePackage,
13
13
  type RuntimeContracts,
14
14
  } from "../contracts";
15
- import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS } from "../config";
15
+ import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS, DEFAULT_FANOUT_TTL_SECONDS } from "../config";
16
16
  import { matchAgents } from "../agent-ref";
17
+ import { isFanoutTarget } from "../channel-target";
17
18
  import { getAgent, validateAgentSession } from "./agents.ts";
18
19
  import { cleanAttachmentRefs, linkAttachmentRefs, validateAttachmentRefs } from "./artifacts.ts";
19
20
  import { ClaimError, ValidationError, getDb } from "./connection.ts";
@@ -247,6 +248,10 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
247
248
  const policyName = policyNameFromTarget(input.to);
248
249
  let deliveryStatus: Message["deliveryStatus"] = "pending";
249
250
  let queuedAt: number | null = null;
251
+ // Distinguish "unspecified" (apply the #451 fan-out default below) from an explicit `null`
252
+ // (authoritative never-expire — routeNotification sets this for store-ahead notification types,
253
+ // and the baseline must not override it). Both store as NULL, but only unspecified gets a default.
254
+ const ttlUnspecified = input.maxAgeSeconds === undefined;
250
255
  let maxAgeSeconds = input.maxAgeSeconds ?? null;
251
256
  let resolvedToAgent: string | null = null;
252
257
 
@@ -260,6 +265,16 @@ export function sendMessageWithResult(input: SendMessageInput): { message: Messa
260
265
  }
261
266
  }
262
267
 
268
+ // #451 — moderate default TTL for fire-and-forget FAN-OUT messages the sender didn't bound, so a
269
+ // stale broadcast/tag/cap/label/team push can't wake a late-joining agent hours later. Exempt
270
+ // (never auto-expire, preserving store-ahead #234): direct DMs and policy targets (single
271
+ // recipient), reply-obligations (replyExpected !== false), claimable work, and any message that
272
+ // already carried an explicit TTL — including an explicit `null` from routeNotification, whose
273
+ // per-type decision (e.g. a store-ahead `plan.bound_agent_exited` to `team:`) must win here.
274
+ if (ttlUnspecified && input.replyExpected === false && !claimable && isFanoutTarget(input.to)) {
275
+ maxAgeSeconds = DEFAULT_FANOUT_TTL_SECONDS;
276
+ }
277
+
263
278
  const id = getDb().transaction(() => {
264
279
  const result = insert.run({
265
280
  $from: input.from,
@@ -449,6 +449,21 @@ export function applyMigrations(): void {
449
449
  getDb().run("ALTER TABLE agents ADD COLUMN team_id TEXT");
450
450
  }
451
451
  getDb().run("CREATE INDEX IF NOT EXISTS idx_agents_team ON agents(team_id)");
452
+ getDb().run(`
453
+ CREATE TABLE IF NOT EXISTS notification_subscriptions (
454
+ id TEXT PRIMARY KEY,
455
+ agent_id TEXT NOT NULL REFERENCES agents(id) ON DELETE CASCADE,
456
+ type TEXT NOT NULL,
457
+ mode TEXT NOT NULL CHECK (mode IN ('opt_in', 'opt_out', 'mute')),
458
+ scope_match TEXT,
459
+ created_at INTEGER NOT NULL
460
+ )
461
+ `);
462
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_notification_subscriptions_agent ON notification_subscriptions(agent_id, type)");
463
+ getDb().run(`
464
+ CREATE UNIQUE INDEX IF NOT EXISTS idx_notification_subscriptions_unique
465
+ ON notification_subscriptions(agent_id, type, mode, coalesce(scope_match, ''))
466
+ `);
452
467
  getDb().run(`
453
468
  CREATE TABLE IF NOT EXISTS context_snapshots (
454
469
  id INTEGER PRIMARY KEY AUTOINCREMENT,
@@ -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();