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,168 @@
1
+ import { notifySystemMessage } from "./notify";
2
+ import type { Message, MessageKind } from "./types";
3
+
4
+ /**
5
+ * #440 — the single seam every agent-facing "wake" notification flows through. Before this,
6
+ * ~10 emitter sites each hand-rolled their own recipient policy and called `notifySystemMessage`
7
+ * directly, with no central rule, no staleness guard, and no record of *why* an agent was chosen.
8
+ *
9
+ * This is the #441 slice: the seam + taxonomy + the #451 TTL guard. It is **behavior-preserving** —
10
+ * emitters still compute and pass their recipients (`recipients`); the router does not yet derive
11
+ * them from `scope`. The follow-ups move the *deciding* in:
12
+ * - #442 introduces the rule engine that computes recipients FROM `scope`.
13
+ * - #444 flips `branch.landed` to be the first rule-driven type (retiring its hand-rolled fan-out).
14
+ * - #446 turns the routing decision recorded here into an explainable trace.
15
+ *
16
+ * `scope` (the entities a notification is ABOUT) is captured now even though #441 doesn't route on
17
+ * it, so emitters are converted exactly once and the rule engine has its inputs the day it lands.
18
+ */
19
+ export type NotificationType =
20
+ | "branch.landed"
21
+ | "agent.ready"
22
+ | "agent.exited"
23
+ | "agent.spawn_failed"
24
+ | "agent.context_threshold"
25
+ | "agent.resume_budget_exhausted"
26
+ | "agent.transient_land_hold"
27
+ | "agent.rate_limit_resume"
28
+ | "plan.bound_agent_exited"
29
+ | "workspace.steward_task"
30
+ | "workspace.conflict"
31
+ | "workspace.steward_reassigned"
32
+ | "workspace.stranded_escalation"
33
+ | "workspace.auto_abandoned"
34
+ | "workspace.review_request"
35
+ | "workspace.review_changes_requested";
36
+
37
+ /**
38
+ * The entities a notification is ABOUT — the future routing inputs (#442) and the explainability
39
+ * subject (#446). All optional: each emitter fills the fields it has in scope at the call site.
40
+ */
41
+ export interface NotificationScope {
42
+ /** The agent whose work the notification concerns (branch author, workspace owner). */
43
+ author?: string;
44
+ /** The agent the notification is primarily about (lifecycle subject, advisory target). */
45
+ agentId?: string;
46
+ /** The spawn-parent that should hear about a child's lifecycle. */
47
+ parent?: string;
48
+ /** Team the notification belongs to (plan/coordination scope). */
49
+ teamId?: string;
50
+ /** Plan the notification concerns. */
51
+ planId?: string;
52
+ /** Repo checkout the notification concerns. */
53
+ repoRoot?: string;
54
+ /** Workspace the notification concerns. */
55
+ workspaceId?: string;
56
+ /** Files the notification concerns (path-scoped routing, #442). */
57
+ paths?: string[];
58
+ }
59
+
60
+ export interface NotificationMessage {
61
+ subject?: string;
62
+ body: string;
63
+ payload?: Record<string, unknown>;
64
+ kind?: MessageKind;
65
+ from?: string;
66
+ /** Default true. Set false for fire-and-forget pushes (no reply scaffold, no obligation). */
67
+ replyExpected?: boolean;
68
+ }
69
+
70
+ export interface RouteNotificationInput {
71
+ type: NotificationType;
72
+ scope: NotificationScope;
73
+ /**
74
+ * Recipients chosen by the emitter (#441, behavior-preserving). May be concrete agent ids OR
75
+ * durable targets (`policy:`, `team:`, `label:`, `broadcast`, …) — the delivery layer resolves
76
+ * durable forms at poll time. #442 will derive this set from `scope` via the rule engine.
77
+ */
78
+ recipients: Array<string | undefined | null>;
79
+ message: NotificationMessage;
80
+ /**
81
+ * #451 — time-to-live in ms from `emittedAt`, after which the notification is stale and must not
82
+ * wake a recipient. `undefined` → the per-type default ({@link DEFAULT_TTL_MS}); `null` → never
83
+ * expires (store-ahead). Emitters override per-call when the type default isn't right for the
84
+ * audience (e.g. `branch.landed`'s bystander fan-out is ephemeral while the author copy is durable).
85
+ */
86
+ ttlMs?: number | null;
87
+ /** When the underlying event occurred; defaults to now. Drives the staleness guard. */
88
+ emittedAt?: number;
89
+ }
90
+
91
+ const MINUTES = 60 * 1000;
92
+
93
+ /**
94
+ * Per-type default TTL (#451). Default-SAFE: anything about the recipient's own work or any
95
+ * obligation is `null` (never auto-expires — store-ahead #234 must still deliver it). Only the
96
+ * genuinely now-or-never pings get a short default; a stale one of those is actively misleading:
97
+ * - `agent.context_threshold` — the advisory band may have changed; old advice is wrong.
98
+ * - `agent.rate_limit_resume` — "your hold cleared, resume" is pointless once the window moved.
99
+ * Emitters opt INTO a shorter TTL per-call for the bystander/informational cases.
100
+ */
101
+ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
102
+ "branch.landed": null,
103
+ "agent.ready": null,
104
+ "agent.exited": null,
105
+ "agent.spawn_failed": null,
106
+ "agent.context_threshold": 15 * MINUTES,
107
+ "agent.resume_budget_exhausted": null,
108
+ "agent.transient_land_hold": null,
109
+ "agent.rate_limit_resume": 15 * MINUTES,
110
+ "plan.bound_agent_exited": null,
111
+ "workspace.steward_task": null,
112
+ "workspace.conflict": null,
113
+ "workspace.steward_reassigned": null,
114
+ "workspace.stranded_escalation": null,
115
+ "workspace.auto_abandoned": null,
116
+ "workspace.review_request": null,
117
+ "workspace.review_changes_requested": null,
118
+ };
119
+
120
+ /** Convenience TTL for emitter-supplied overrides on bystander/informational notifications. */
121
+ export const INFORMATIONAL_TTL_MS = 30 * MINUTES;
122
+
123
+ /**
124
+ * Resolve the effective TTL: an explicit per-call `ttlMs` wins (including an explicit `null` =
125
+ * never); `undefined` falls back to the per-type default.
126
+ */
127
+ function effectiveTtlMs(type: NotificationType, ttlMs: number | null | undefined): number | null {
128
+ return ttlMs === undefined ? DEFAULT_TTL_MS[type] : ttlMs;
129
+ }
130
+
131
+ /**
132
+ * Route a wake-notification to its recipients. Applies the #451 staleness guard, stamps the TTL as
133
+ * the delivered message's `maxAgeSeconds` (so the poll path drops it once stale), and sends one
134
+ * system message per unique recipient. Returns the delivered messages (most callers ignore this).
135
+ *
136
+ * Recipients are de-duplicated and emptied of falsy entries, so emitters can pass optional ids
137
+ * without guarding. A notification with zero resolved recipients is a no-op.
138
+ */
139
+ export function routeNotification(input: RouteNotificationInput): Message[] {
140
+ const ttl = effectiveTtlMs(input.type, input.ttlMs);
141
+ const emittedAt = input.emittedAt ?? Date.now();
142
+
143
+ // Staleness guard (#451): if the triggering event is already older than its TTL, drop before
144
+ // sending. On the normal synchronous path emittedAt ≈ now so this rarely fires, but it makes the
145
+ // router correct for any caller that routes a delayed/replayed event.
146
+ if (ttl !== null && Date.now() - emittedAt > ttl) return [];
147
+
148
+ const recipients = [...new Set(input.recipients.filter((r): r is string => Boolean(r && r.trim())))];
149
+ if (recipients.length === 0) return [];
150
+
151
+ // Stamp the TTL on the message so the delivery/poll path (pollMessages) enforces it for offline
152
+ // or late-joining recipients too. An explicit `null` (never-expire) is authoritative: it suppresses
153
+ // sendMessage's fan-out default, so a store-ahead type sent to `team:`/`broadcast` keeps its
154
+ // never-expire intent instead of inheriting the moderate baseline.
155
+ const maxAgeSeconds = ttl === null ? null : Math.max(1, Math.ceil(ttl / 1000));
156
+
157
+ return recipients.map((to) =>
158
+ notifySystemMessage(to, {
159
+ subject: input.message.subject,
160
+ body: input.message.body,
161
+ payload: input.message.payload,
162
+ kind: input.message.kind,
163
+ from: input.message.from,
164
+ replyExpected: input.message.replyExpected,
165
+ maxAgeSeconds,
166
+ }),
167
+ );
168
+ }
@@ -0,0 +1,186 @@
1
+ import type { AgentCard } from "./types";
2
+ import { listAgents } from "./db";
3
+ import { isAgentOnline } from "./agent-ref";
4
+ import type { NotificationType, NotificationScope } from "./notification-router";
5
+
6
+ /**
7
+ * #442 — the declarative rule engine that computes a notification's recipients FROM its `scope`,
8
+ * replacing the hand-rolled recipient policy each emitter used to carry (#441 kept those inline as
9
+ * the behavior-preserving step). This is the **model + evaluator**: a library of typed predicates,
10
+ * an ordered evaluator that yields a per-agent decision + the matched rule, and the seam where
11
+ * #443 subscriptions (opt-in / opt-out / mute) plug in as override rules.
12
+ *
13
+ * Core design (from epic #440): **rules-as-CODE, subscriptions/overrides-as-DATA.** The default
14
+ * audience for each type is a composition of these predicates in versioned, tested code — extending
15
+ * the engine means adding a typed predicate, never authoring a stored rule DSL. Per-type compositions
16
+ * are REGISTERED by each type's migration (#444 registers `branch.landed` first and flips its call
17
+ * site); until a type is registered, {@link resolveAudience} returns `[]` and that type stays on the
18
+ * #441 explicit-recipients path. Nothing here is wired into production delivery yet — #444 does that.
19
+ */
20
+
21
+ export type RuleDecision = "deliver" | "suppress" | "abstain";
22
+
23
+ export interface NotificationRuleContext {
24
+ type: NotificationType;
25
+ scope: NotificationScope;
26
+ /** Evaluation clock — passed in (never read via Date.now() inside a rule) so eval is testable. */
27
+ now: number;
28
+ }
29
+
30
+ export interface NotificationRule {
31
+ /** Stable identifier recorded as the matched rule — the #446 "why did I get this?" answer. */
32
+ name: string;
33
+ evaluate(agent: AgentCard, ctx: NotificationRuleContext): RuleDecision;
34
+ }
35
+
36
+ export interface AudienceMember {
37
+ agentId: string;
38
+ /** The rule whose decision was decisive (last non-abstain wins) — carried for explainability (#446). */
39
+ matchedRule: string;
40
+ }
41
+
42
+ /**
43
+ * Predicate library — the reusable building blocks each type composes its default audience from.
44
+ * Each factory returns a single-concern rule. `deliver` includes the agent; `abstain` leaves the
45
+ * running decision unchanged; `suppress` removes an agent a prior rule delivered (the opt-out/mute
46
+ * shape #443 builds on). Compose them ordered: later rules override earlier (see {@link evaluateAudience}).
47
+ */
48
+ export const audiencePredicates = {
49
+ /** The agent whose work the notification is about — delivered even when offline (store-ahead #234). */
50
+ author: (): NotificationRule => ({
51
+ name: "author",
52
+ evaluate: (a, { scope }) => (scope.author && a.id === scope.author ? "deliver" : "abstain"),
53
+ }),
54
+ /** The spawn-parent that should hear about a child's lifecycle — online-agnostic (store-ahead). */
55
+ spawnParent: (): NotificationRule => ({
56
+ name: "spawn-parent",
57
+ evaluate: (a, { scope }) => (scope.parent && a.id === scope.parent ? "deliver" : "abstain"),
58
+ }),
59
+ /** The notification's primary subject agent (advisory target, lifecycle subject). */
60
+ subjectAgent: (): NotificationRule => ({
61
+ name: "subject-agent",
62
+ evaluate: (a, { scope }) => (scope.agentId && a.id === scope.agentId ? "deliver" : "abstain"),
63
+ }),
64
+ /** Every member of the notification's team (coordination/plan scope). Online-agnostic by default. */
65
+ sameTeam: (): NotificationRule => ({
66
+ name: "same-team",
67
+ evaluate: (a, { scope }) => (scope.teamId && a.teamId === scope.teamId ? "deliver" : "abstain"),
68
+ }),
69
+ /**
70
+ * Team-scoping SUPPRESSOR (#444): removes an agent that's on a DIFFERENT team than the
71
+ * notification's. Compose it AFTER a deliver rule (last-wins) to scope a fan-out to the relevant
72
+ * team — e.g. a Team-A branch land no longer wakes Team-B agents sharing the main checkout (the
73
+ * epic #440 dogfooded symptom). Deliberately permissive at the edges: a teamless agent
74
+ * (`a.teamId` unset) and a teamless notification (`scope.teamId` unset) both ABSTAIN, so solo/
75
+ * unteamed work and cross-team-by-default notifications keep their prior broadcast reach.
76
+ */
77
+ differentTeam: (): NotificationRule => ({
78
+ name: "different-team",
79
+ evaluate: (a, { scope }) => (scope.teamId && a.teamId && a.teamId !== scope.teamId ? "suppress" : "abstain"),
80
+ }),
81
+ /**
82
+ * Agents working IN the repo's main checkout (cwd === repoRoot) — the `agentsOnMain` fan-out.
83
+ * Defaults match the legacy behavior exactly: online-only (a stale main session needs no wake)
84
+ * and author-excluded (the author gets its own store-ahead copy via the `author` rule).
85
+ */
86
+ onRepoRoot: (opts: { onlineOnly?: boolean; excludeAuthor?: boolean } = { onlineOnly: true, excludeAuthor: true }): NotificationRule => ({
87
+ name: "on-repo-root",
88
+ evaluate: (a, { scope, now }) => {
89
+ if (!scope.repoRoot || a.meta?.cwd !== scope.repoRoot) return "abstain";
90
+ if (opts.excludeAuthor && scope.author && a.id === scope.author) return "abstain";
91
+ if (opts.onlineOnly && !isAgentOnline(a, now)) return "abstain";
92
+ return "deliver";
93
+ },
94
+ }),
95
+ } as const;
96
+
97
+ /**
98
+ * The candidate pool for rule evaluation: every registered agent EXCEPT the pseudo-identities
99
+ * (`system`/`user`) and channel agents, which are never wake-notification recipients. Offline agents
100
+ * stay IN the pool deliberately — a store-ahead rule (author/spawn-parent) must be able to select an
101
+ * offline target; per-rule `onlineOnly` gates the cases that need liveness (e.g. on-repo-root fan-out).
102
+ */
103
+ export function audienceCandidates(): AgentCard[] {
104
+ return listAgents().filter(
105
+ (a) => a.id !== "system" && a.id !== "user" && a.kind !== "channel" && a.meta?.kind !== "channel",
106
+ );
107
+ }
108
+
109
+ /**
110
+ * Evaluate an ordered rule list against each candidate. Decision fold: a later non-abstain decision
111
+ * overrides an earlier one (last-wins), so subscription overrides — appended after the defaults —
112
+ * can suppress a default deliver (opt-out/mute) or deliver a default-suppress (opt-in). The matched
113
+ * rule recorded is whichever rule set the final decision. Only `deliver` agents make the audience.
114
+ */
115
+ export function evaluateAudience(
116
+ rules: NotificationRule[],
117
+ ctx: NotificationRuleContext,
118
+ candidates: AgentCard[],
119
+ ): AudienceMember[] {
120
+ const audience: AudienceMember[] = [];
121
+ for (const agent of candidates) {
122
+ let decision: RuleDecision = "abstain";
123
+ let matchedRule = "";
124
+ for (const rule of rules) {
125
+ const d = rule.evaluate(agent, ctx);
126
+ if (d !== "abstain") {
127
+ decision = d;
128
+ matchedRule = rule.name;
129
+ }
130
+ }
131
+ if (decision === "deliver") audience.push({ agentId: agent.id, matchedRule });
132
+ }
133
+ return audience;
134
+ }
135
+
136
+ // Per-type active rule compositions. Production defaults are mirrored in `defaultAudienceRegistry`
137
+ // so the test reset seam can clear transient overrides without losing import-time registrations.
138
+ const audienceRegistry = new Map<NotificationType, NotificationRule[]>();
139
+ const defaultAudienceRegistry = new Map<NotificationType, NotificationRule[]>();
140
+
141
+ /** Register (or replace) the active audience rules for a notification type. Tests use this for
142
+ * transient compositions; production type modules should use registerDefaultAudienceRules. */
143
+ export function registerAudienceRules(type: NotificationType, rules: NotificationRule[]): void {
144
+ audienceRegistry.set(type, rules);
145
+ }
146
+
147
+ /** Register (or replace) the production default audience rules for a notification type. Called at
148
+ * module init by each type's migration. Idempotent — re-registering replaces, so hot reload is safe. */
149
+ export function registerDefaultAudienceRules(type: NotificationType, rules: NotificationRule[]): void {
150
+ defaultAudienceRegistry.set(type, rules);
151
+ audienceRegistry.set(type, rules);
152
+ }
153
+
154
+ // #443 subscription seam: opt-in / opt-out / mute become override rules injected here. They run AFTER
155
+ // a type's default rules so they win the last-wins fold. Empty until #443 wires a subscription store.
156
+ let subscriptionRules: NotificationRule[] = [];
157
+
158
+ /** #443 hook: install the subscription override rules (opt-in/opt-out/mute). Pass [] to clear. */
159
+ export function setSubscriptionRules(rules: NotificationRule[]): void {
160
+ subscriptionRules = rules;
161
+ }
162
+
163
+ /** The effective ordered rule list for a type: its registered defaults, then subscription overrides. */
164
+ export function audienceRulesFor(type: NotificationType): NotificationRule[] {
165
+ const defaults = audienceRegistry.get(type);
166
+ if (!defaults) return [];
167
+ return subscriptionRules.length === 0 ? defaults : [...defaults, ...subscriptionRules];
168
+ }
169
+
170
+ /**
171
+ * Resolve the concrete recipients for a notification from its scope, via the registered rules. Returns
172
+ * `[]` for an unregistered (not-yet-rule-driven) type — callers fall back to explicit recipients.
173
+ * This is what #444 will call in place of the hand-rolled `agentsOnMain` + author selection.
174
+ */
175
+ export function resolveAudience(type: NotificationType, scope: NotificationScope, now: number = Date.now()): AudienceMember[] {
176
+ const rules = audienceRulesFor(type);
177
+ if (rules.length === 0) return [];
178
+ return evaluateAudience(rules, { type, scope, now }, audienceCandidates());
179
+ }
180
+
181
+ /** Test seam: clear the per-type registry and subscription rules between tests. */
182
+ export function __resetAudienceRules(): void {
183
+ audienceRegistry.clear();
184
+ for (const [type, rules] of defaultAudienceRegistry) audienceRegistry.set(type, rules);
185
+ subscriptionRules = [];
186
+ }
package/src/notify.ts CHANGED
@@ -17,6 +17,14 @@ interface SystemNotifyOptions {
17
17
  * (steward task assignments, conflict handoffs).
18
18
  */
19
19
  replyExpected?: boolean;
20
+ /**
21
+ * #451 — TTL in seconds after which the message is stale and must not wake a recipient (the poll
22
+ * path drops it). Omit to use sendMessage's defaults (a moderate TTL for fire-and-forget fan-out,
23
+ * none for direct DMs / obligations / claimable). routeNotification sets this per notification type:
24
+ * a number for ephemeral types, explicit `null` for store-ahead types (authoritative never-expire,
25
+ * suppressing the fan-out default so the router's per-type decision wins).
26
+ */
27
+ maxAgeSeconds?: number | null;
20
28
  }
21
29
 
22
30
  /**
@@ -33,6 +41,7 @@ export function notifySystemMessage(to: string, opts: SystemNotifyOptions): Mess
33
41
  body: opts.body,
34
42
  payload: opts.payload,
35
43
  replyExpected: opts.replyExpected,
44
+ maxAgeSeconds: opts.maxAgeSeconds,
36
45
  });
37
46
  emitNewMessage(msg);
38
47
  return msg;
@@ -3,7 +3,7 @@ import { planSend } from "./agent-ref";
3
3
  import { createCommand } from "./commands-db";
4
4
  import { getSpawnPolicy } from "./config-store";
5
5
  import { getLifecycleManager } from "./lifecycle-manager";
6
- import { notifySystemMessage } from "./notify";
6
+ import { routeNotification } from "./notification-router";
7
7
  import { getWorkspace, listAgents, patchWorkspaceMetadata, updateWorkspaceStatus } from "./db";
8
8
  import type { Command, WorkspaceMergePreview, WorkspaceRecord } from "./types";
9
9
 
@@ -60,26 +60,33 @@ export function handleReviewerMergeResult(command: Command, result: Record<strin
60
60
  });
61
61
  if (!updated) return false;
62
62
 
63
- notifySystemMessage(target, {
64
- subject: `Review requested: ${workspace.branch ?? workspace.id}`,
65
- body: [
66
- `Review PR ${prUrl ?? `#${prNumber ?? "unknown"}`} for workspace \`${workspace.id}\`.`,
67
- `Repo: ${workspace.repoRoot}`,
68
- workspace.branch ? `Branch: ${workspace.branch}` : undefined,
69
- workspace.baseRef ? `Base: ${workspace.baseRef}` : undefined,
70
- compareRef ? `Compare: ${compareRef}` : undefined,
71
- "Approve the PR to let Relay arm GitHub auto-merge, or request changes to send it back to the workspace owner.",
72
- ].filter(Boolean).join("\n"),
73
- payload: {
74
- kind: "workspace.review-request",
75
- workspaceId: workspace.id,
76
- repoRoot: workspace.repoRoot,
77
- worktreePath: workspace.worktreePath,
78
- branch: workspace.branch,
79
- baseRef: workspace.baseRef,
80
- prUrl,
81
- prNumber,
82
- compareRef,
63
+ // Obligation (ttl null by type default): a review request must reach the reviewer even if they're
64
+ // offline a while it's not a bystander notice.
65
+ routeNotification({
66
+ type: "workspace.review_request",
67
+ scope: { workspaceId: workspace.id, repoRoot: workspace.repoRoot },
68
+ recipients: [target],
69
+ message: {
70
+ subject: `Review requested: ${workspace.branch ?? workspace.id}`,
71
+ body: [
72
+ `Review PR ${prUrl ?? `#${prNumber ?? "unknown"}`} for workspace \`${workspace.id}\`.`,
73
+ `Repo: ${workspace.repoRoot}`,
74
+ workspace.branch ? `Branch: ${workspace.branch}` : undefined,
75
+ workspace.baseRef ? `Base: ${workspace.baseRef}` : undefined,
76
+ compareRef ? `Compare: ${compareRef}` : undefined,
77
+ "Approve the PR to let Relay arm GitHub auto-merge, or request changes to send it back to the workspace owner.",
78
+ ].filter(Boolean).join("\n"),
79
+ payload: {
80
+ kind: "workspace.review-request",
81
+ workspaceId: workspace.id,
82
+ repoRoot: workspace.repoRoot,
83
+ worktreePath: workspace.worktreePath,
84
+ branch: workspace.branch,
85
+ baseRef: workspace.baseRef,
86
+ prUrl,
87
+ prNumber,
88
+ compareRef,
89
+ },
83
90
  },
84
91
  });
85
92
  if (policyName) getLifecycleManager().onMessageForPolicy(policyName);
@@ -172,26 +179,33 @@ function notifyReviewChangesRequested(
172
179
  ): string | undefined {
173
180
  const target = workspace.ownerAgentId;
174
181
  if (!target) return undefined;
175
- notifySystemMessage(target, {
176
- subject: `Changes requested: ${workspace.branch ?? workspace.id}`,
177
- body: [
178
- `The reviewer requested changes on ${prUrl ?? `PR #${prNumber ?? "unknown"}`}.`,
179
- `Workspace \`${workspace.id}\` remains in review_requested.`,
180
- preview.baseRef ? `Base: ${preview.baseRef}` : undefined,
181
- ].filter(Boolean).join("\n"),
182
- payload: {
183
- kind: "workspace.review-changes-requested",
184
- workspaceId: workspace.id,
185
- repoRoot: workspace.repoRoot,
186
- worktreePath: workspace.worktreePath,
187
- branch: workspace.branch,
188
- baseRef: workspace.baseRef,
189
- prUrl,
190
- prNumber,
191
- reviewDecision: preview.reviewDecision,
192
- statusCheckRollup: preview.statusCheckRollup,
182
+ // Durable (ttl null): the owner must learn changes were requested on THEIR workspace even after
183
+ // being offline store-ahead, not a freshness-gated bystander notice.
184
+ routeNotification({
185
+ type: "workspace.review_changes_requested",
186
+ scope: { workspaceId: workspace.id, repoRoot: workspace.repoRoot, author: target },
187
+ recipients: [target],
188
+ message: {
189
+ subject: `Changes requested: ${workspace.branch ?? workspace.id}`,
190
+ body: [
191
+ `The reviewer requested changes on ${prUrl ?? `PR #${prNumber ?? "unknown"}`}.`,
192
+ `Workspace \`${workspace.id}\` remains in review_requested.`,
193
+ preview.baseRef ? `Base: ${preview.baseRef}` : undefined,
194
+ ].filter(Boolean).join("\n"),
195
+ payload: {
196
+ kind: "workspace.review-changes-requested",
197
+ workspaceId: workspace.id,
198
+ repoRoot: workspace.repoRoot,
199
+ worktreePath: workspace.worktreePath,
200
+ branch: workspace.branch,
201
+ baseRef: workspace.baseRef,
202
+ prUrl,
203
+ prNumber,
204
+ reviewDecision: preview.reviewDecision,
205
+ statusCheckRollup: preview.statusCheckRollup,
206
+ },
207
+ replyExpected: false,
193
208
  },
194
- replyExpected: false,
195
209
  });
196
210
  return target;
197
211
  }
@@ -1,6 +1,6 @@
1
1
  // Auto-split from routes.ts (#299). Domain: agent-sessions.
2
2
  import { RELAY_TOKEN_HEADER, isRecord } from "agent-relay-sdk";
3
- import { VALID_AGENT_ACTIONS, agentControlActionIcon, auditEvent, authAuditMetadata, authorizeRoute, dashboardAttribution, emitCommand, error, json, metaString, parseBody, spawnRequestId, type AgentControlAction, type Handler } from "./_shared";
3
+ import { VALID_AGENT_ACTIONS, agentControlActionIcon, auditEvent, authAuditMetadata, authorizeRoute, cleanAttachmentRefs, dashboardAttribution, emitCommand, error, json, metaString, parseBody, spawnRequestId, type AgentControlAction, type Handler } from "./_shared";
4
4
  import { ValidationError, getAgent, getAgentTimeline, getOrchestrator, markReady, mergeAgentMeta, sendMessage } from "../db";
5
5
  import { buildManagedSpawnParams } from "../managed-policy";
6
6
  import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams } from "../spawn-command";
@@ -306,6 +306,7 @@ export const postAgentPrompt: Handler = async (req, params) => {
306
306
  try {
307
307
  if (!isRecord(parsed.body)) return error("body required");
308
308
  const body = cleanString(parsed.body.body, "body", { required: true, max: 100_000 })!;
309
+ const attachments = cleanAttachmentRefs(parsed.body.attachments ?? parsed.body.artifacts, "attachments");
309
310
  const agent = getAgent(params.id!);
310
311
  if (!agent) return error("agent not found", 404);
311
312
  if (agent.status === "offline") return error("agent is offline", 422);
@@ -319,12 +320,13 @@ export const postAgentPrompt: Handler = async (req, params) => {
319
320
  to: agent.id,
320
321
  kind: "session",
321
322
  body,
323
+ ...(attachments ? { payload: { attachments } } : {}),
322
324
  });
323
325
  const command = createCommand({
324
326
  type: "prompt.inject",
325
327
  source: "dashboard",
326
328
  target: agent.id,
327
- params: { body, messageId: message.id },
329
+ params: { body, messageId: message.id, ...(attachments ? { attachments } : {}) },
328
330
  });
329
331
  emitCommand(command);
330
332
  emitNewMessage(message);
@@ -315,6 +315,26 @@ export const patchCommand: Handler = async (req, params) => {
315
315
  });
316
316
  }
317
317
  }
318
+ if (command.type === "workspace.pr-refresh") {
319
+ const resultWorkspaceId = isRecord(command.result) ? cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 }) : undefined;
320
+ const workspaceId = resultWorkspaceId ?? command.correlationId;
321
+ if (workspaceId && command.status === "succeeded" && isRecord(command.result) && command.result.prRefreshed === true) {
322
+ patchWorkspaceMetadata(workspaceId, {
323
+ prRefreshCommandId: command.id,
324
+ prRefreshedAt: Date.now(),
325
+ prRefreshResult: command.result,
326
+ prRefreshError: undefined,
327
+ prRefreshFailedAt: undefined,
328
+ });
329
+ } else if (workspaceId && (command.status === "failed" || command.status === "timed_out" || command.status === "rejected" || command.status === "canceled")) {
330
+ patchWorkspaceMetadata(workspaceId, {
331
+ prRefreshCommandId: command.id,
332
+ prRefreshError: command.error ?? "PR branch refresh failed",
333
+ prRefreshFailedAt: Date.now(),
334
+ prRefreshResult: command.result,
335
+ });
336
+ }
337
+ }
318
338
  if (command.type === "workspace.deps-refresh" && command.status === "succeeded" && isRecord(command.result)) {
319
339
  // Record the outcome on the row without touching status (#51) — observability
320
340
  // for the dashboard; the CLI reads the result straight off the command.
@@ -35,6 +35,7 @@ import { getTaskById, getTaskEvents, getTasks, patchTaskStatus, postClaimTask, p
35
35
  import { getMyTeamRoute, getTeamOutcomesRoute, getTeamsRoute, postMyTeamDissolveRoute, putMyTeamBriefRoute } from "./teams";
36
36
  import { postTeamDissolveByIdRoute } from "./teams";
37
37
  import { deleteTeamTemplateRoute, getTeamTemplateRoute, getTeamTemplatesRoute, postTeamTemplateRoute, putTeamTemplateRoute } from "./team-templates";
38
+ import { getMergePauseById, getMergePauses, postMergePause, postMergePauseRenew, postMergePauseResume } from "./merges";
38
39
  import { postMcp } from "../mcp";
39
40
  import { postOrchestratorBootstrap, postOrchestratorBootstrapExchange } from "./orchestrator-bootstrap";
40
41
 
@@ -139,6 +140,12 @@ const routes: Route[] = [
139
140
  route("POST", "/api/workspaces/:id/actions", postWorkspaceAction),
140
141
  route("DELETE", "/api/workspaces/:id", deleteWorkspaceById),
141
142
 
143
+ route("GET", "/api/merges/pauses", getMergePauses),
144
+ route("POST", "/api/merges/pause", postMergePause),
145
+ route("POST", "/api/merges/resume", postMergePauseResume),
146
+ route("POST", "/api/merges/renew", postMergePauseRenew),
147
+ route("GET", "/api/merges/pauses/:id", getMergePauseById),
148
+
142
149
  route("POST", "/api/system/broadcast", postSystemBroadcast),
143
150
  route("GET", "/api/teams", getTeamsRoute),
144
151
  route("GET", "/api/teams/outcomes", getTeamOutcomesRoute),