agent-relay-server 0.86.0 → 0.88.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (75) hide show
  1. package/docs/openapi.json +39 -1
  2. package/package.json +1 -1
  3. package/public/assets/{MessageBubble-CevL-2jc.js → MessageBubble-bI9fFi55.js} +3 -3
  4. package/public/assets/{MessageBubble-CevL-2jc.js.map → MessageBubble-bI9fFi55.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-DGpJhuVw.js → PlanGraphPanel-a3Z57nIZ.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-DGpJhuVw.js.map → PlanGraphPanel-a3Z57nIZ.js.map} +1 -1
  7. package/public/assets/{activity-CY2Cbpet.js → activity-VTlBVIox.js} +2 -2
  8. package/public/assets/{activity-CY2Cbpet.js.map → activity-VTlBVIox.js.map} +1 -1
  9. package/public/assets/{agents-Bj5KzrYJ.js → agents-C2TNRCn1.js} +2 -2
  10. package/public/assets/{agents-Bj5KzrYJ.js.map → agents-C2TNRCn1.js.map} +1 -1
  11. package/public/assets/{automation-BOPk1oh_.js → automation-CUSyRZla.js} +2 -2
  12. package/public/assets/{automation-BOPk1oh_.js.map → automation-CUSyRZla.js.map} +1 -1
  13. package/public/assets/{chat-D5pI4ebq.js → chat-BEssCn_H.js} +2 -2
  14. package/public/assets/{chat-D5pI4ebq.js.map → chat-BEssCn_H.js.map} +1 -1
  15. package/public/assets/{formatted-body-impl-Dr_z1UvS.js → formatted-body-impl-DBbmythT.js} +2 -2
  16. package/public/assets/{formatted-body-impl-Dr_z1UvS.js.map → formatted-body-impl-DBbmythT.js.map} +1 -1
  17. package/public/assets/index-CiBdEE9x.js +23 -0
  18. package/public/assets/{index-CEXhh5nz.js.map → index-CiBdEE9x.js.map} +1 -1
  19. package/public/assets/index-F-FSPv4X.css +2 -0
  20. package/public/assets/{insights-C4dEK49e.js → insights-9P_MGbMS.js} +2 -2
  21. package/public/assets/{insights-C4dEK49e.js.map → insights-9P_MGbMS.js.map} +1 -1
  22. package/public/assets/{maintenance-DJZUSsja.js → maintenance-CA1XujU3.js} +2 -2
  23. package/public/assets/{maintenance-DJZUSsja.js.map → maintenance-CA1XujU3.js.map} +1 -1
  24. package/public/assets/managed-agents-DCfSlqW7.js +4 -0
  25. package/public/assets/managed-agents-DCfSlqW7.js.map +1 -0
  26. package/public/assets/{markdown-preview-impl-DOJQR3cO.js → markdown-preview-impl-MgZ3Nr7K.js} +2 -2
  27. package/public/assets/{markdown-preview-impl-DOJQR3cO.js.map → markdown-preview-impl-MgZ3Nr7K.js.map} +1 -1
  28. package/public/assets/{memory-BnhAD09c.js → memory-DatJLjrc.js} +2 -2
  29. package/public/assets/{memory-BnhAD09c.js.map → memory-DatJLjrc.js.map} +1 -1
  30. package/public/assets/{messages-Cc9YF8Wl.js → messages-CQsmbtzx.js} +2 -2
  31. package/public/assets/{messages-Cc9YF8Wl.js.map → messages-CQsmbtzx.js.map} +1 -1
  32. package/public/assets/{orchestrators-B01iyiBA.js → orchestrators-CAWYWXmh.js} +2 -2
  33. package/public/assets/{orchestrators-B01iyiBA.js.map → orchestrators-CAWYWXmh.js.map} +1 -1
  34. package/public/assets/overview-EqnqXVUb.js +2 -0
  35. package/public/assets/{overview-DN8CUTVx.js.map → overview-EqnqXVUb.js.map} +1 -1
  36. package/public/assets/{pairs-BxuDMLhs.js → pairs-BfKenirO.js} +2 -2
  37. package/public/assets/{pairs-BxuDMLhs.js.map → pairs-BfKenirO.js.map} +1 -1
  38. package/public/assets/{security-CO0PBIau.js → security-CJVJ9MgS.js} +2 -2
  39. package/public/assets/{security-CO0PBIau.js.map → security-CJVJ9MgS.js.map} +1 -1
  40. package/public/assets/{select-DHaFsLvt.js → select-BcmIzk-E.js} +2 -2
  41. package/public/assets/{select-DHaFsLvt.js.map → select-BcmIzk-E.js.map} +1 -1
  42. package/public/assets/{settings-De1DKwd6.js → settings-BWO-UyHv.js} +2 -2
  43. package/public/assets/{settings-De1DKwd6.js.map → settings-BWO-UyHv.js.map} +1 -1
  44. package/public/assets/{tasks-Bqd188ql.js → tasks-DTdI_MJi.js} +2 -2
  45. package/public/assets/{tasks-Bqd188ql.js.map → tasks-DTdI_MJi.js.map} +1 -1
  46. package/public/assets/{teams-Mh6ypDPr.js → teams-C9C3KVaV.js} +2 -2
  47. package/public/assets/{teams-Mh6ypDPr.js.map → teams-C9C3KVaV.js.map} +1 -1
  48. package/public/assets/{terminal-viewer-impl-B7x_JNZe.js → terminal-viewer-impl-BDBa94-B.js} +2 -2
  49. package/public/assets/{terminal-viewer-impl-B7x_JNZe.js.map → terminal-viewer-impl-BDBa94-B.js.map} +1 -1
  50. package/public/assets/{user-avatar-Dy5J3E_b.js → user-avatar-BL-CJwnn.js} +2 -2
  51. package/public/assets/{user-avatar-Dy5J3E_b.js.map → user-avatar-BL-CJwnn.js.map} +1 -1
  52. package/public/assets/{work-queue-ClGgOUSu.js → work-queue-Vc_2-4DG.js} +2 -2
  53. package/public/assets/{work-queue-ClGgOUSu.js.map → work-queue-Vc_2-4DG.js.map} +1 -1
  54. package/public/index.html +2 -2
  55. package/src/agent-busy-health.ts +93 -0
  56. package/src/agent-card-projection.ts +4 -1
  57. package/src/commands-db.ts +19 -2
  58. package/src/config.ts +5 -0
  59. package/src/db/activity.ts +92 -0
  60. package/src/maintenance/jobs.ts +75 -0
  61. package/src/maintenance/teams.ts +108 -2
  62. package/src/mcp/index.ts +3 -0
  63. package/src/mcp-ntfy-tools.ts +35 -0
  64. package/src/notification-types.ts +4 -0
  65. package/src/ntfy.ts +222 -0
  66. package/src/quota-advisory.ts +3 -2
  67. package/src/quota-threshold-config.ts +1 -1
  68. package/src/routes/index.ts +2 -1
  69. package/src/routes/spawn-policy.ts +19 -2
  70. package/src/team-idle-tracker.ts +19 -0
  71. package/public/assets/index-CEXhh5nz.js +0 -23
  72. package/public/assets/index-LUIvWcx6.css +0 -2
  73. package/public/assets/managed-agents-5wUMDKVi.js +0 -2
  74. package/public/assets/managed-agents-5wUMDKVi.js.map +0 -1
  75. package/public/assets/overview-DN8CUTVx.js +0 -2
@@ -2,6 +2,7 @@ import {
2
2
  clearTeamZeroMembersSince,
3
3
  countLiveTeamMembers,
4
4
  createActivityEvent,
5
+ getAgent,
5
6
  getCrashReport,
6
7
  hasTerminalEvent,
7
8
  getTeamBusyState,
@@ -9,18 +10,25 @@ import {
9
10
  listActiveTeams,
10
11
  setTeamZeroMembersSince,
11
12
  } from "../db";
12
- import { STALE_TTL_MS } from "../config";
13
+ import { STALE_TTL_MS, stewardFallbackTarget } from "../config";
13
14
  import { agentProviderState } from "../provider-state";
14
15
  import { dissolveTeamById } from "../services/team";
16
+ import { resolveCoordinatorAgentId } from "../steward-identity";
15
17
  import { getTeamsConfig } from "../teams-config-store";
16
18
  import { teamReapGraceMs } from "./constants";
17
19
  import { notifyTarget } from "./events";
18
- import { teamIdleTracker, teamMemberWatchTracker, type TeamMemberWatch } from "../team-idle-tracker";
20
+ import {
21
+ teamCoordinatorWatchTracker,
22
+ teamIdleTracker,
23
+ teamMemberWatchTracker,
24
+ type TeamMemberWatch,
25
+ } from "../team-idle-tracker";
19
26
  import type { AgentCard } from "../types";
20
27
 
21
28
  export function resetTeamIdleWatchdogForTests(): void {
22
29
  teamIdleTracker.clear();
23
30
  teamMemberWatchTracker.clear();
31
+ teamCoordinatorWatchTracker.clear();
24
32
  }
25
33
 
26
34
  /** A member counts as "alive" (worth watching for a future silence) when it's past onboarding and
@@ -84,6 +92,95 @@ function detectSilentMembers(
84
92
  }
85
93
  }
86
94
 
95
+ /**
96
+ * #412/#468 — detect a team's COORDINATOR going silent and escalate to a human, instead of the team
97
+ * stalling forever. The member watchdog above SKIPS the coordinator (it's the ping recipient), and a
98
+ * missing/dead coordinator otherwise only gets recorded — never surfaced — so a silently-dead
99
+ * coordinator means nobody is driving and no member is even being listened to (#412's single point of
100
+ * failure). Reuses the SAME aliveness predicate (`memberIsAlive`) + terminal-event suppression
101
+ * (`getCrashReport || hasTerminalEvent`) as the member watch so a CLEANLY-exited coordinator does not
102
+ * false-positive. Escalates ONCE per gone-spell (re-arms on recovery) up the lineage — the gone
103
+ * coordinator's own coordinator (#639 resolver), falling back to the configured human/steward target
104
+ * — never dead-ending at `system`. A coordinator-gone team is always surfaced, never silently
105
+ * abandoned (no-silent-dissolve).
106
+ */
107
+ function detectGoneCoordinator(
108
+ teamId: string,
109
+ coordinatorId: string | undefined,
110
+ now: number,
111
+ thresholdMs: number,
112
+ out: { escalatedTeamIds: string[]; suppressedTeamIds: string[]; goneCoordinatorIds: string[] },
113
+ ): void {
114
+ if (!coordinatorId) return; // no coordinator on the row — nothing to watch (idle path records this)
115
+ const freshCutoff = now - thresholdMs;
116
+ const existing = teamCoordinatorWatchTracker.get(teamId);
117
+ // A re-elected/claimed coordinator is a fresh identity — drop a watch left over from the old one.
118
+ if (existing && existing.coordinatorId !== coordinatorId) teamCoordinatorWatchTracker.delete(teamId);
119
+
120
+ const coord = getAgent(coordinatorId);
121
+ if (coord && memberIsAlive(coord, freshCutoff)) {
122
+ const prior = existing?.coordinatorId === coordinatorId ? existing : undefined;
123
+ teamCoordinatorWatchTracker.set(teamId, {
124
+ coordinatorId,
125
+ lastActiveAt: Math.max(coord.lastSeen, prior?.lastActiveAt ?? 0),
126
+ escalated: false, // alive again ⇒ re-arm the escalation
127
+ ...(coord.label ? { label: coord.label } : {}),
128
+ });
129
+ return;
130
+ }
131
+
132
+ // Coordinator is NOT alive (offline, heartbeat-stale, or vanished). Only escalate a coordinator we
133
+ // actually observed alive — never fire on cold/unknown startup state (mirrors the member watch).
134
+ const watch = teamCoordinatorWatchTracker.get(teamId);
135
+ if (!watch || watch.coordinatorId !== coordinatorId || watch.escalated) return;
136
+ watch.escalated = true; // one escalation per gone-spell, regardless of outcome below
137
+ out.goneCoordinatorIds.push(coordinatorId);
138
+
139
+ // A terminal marker means a #645 clean terminus (graceful shutdown) or a #633 crash the operator was
140
+ // already surfaced on — not a SILENT death. Suppress.
141
+ if (getCrashReport(coordinatorId) || hasTerminalEvent(coordinatorId)) {
142
+ out.suppressedTeamIds.push(teamId);
143
+ return;
144
+ }
145
+
146
+ // Escalate up the lineage, falling back to the configured human/steward target (#639/#642 pattern).
147
+ const escalationTarget = resolveCoordinatorAgentId(coordinatorId) ?? stewardFallbackTarget();
148
+ const label = watch.label ?? coordinatorId;
149
+ const sent = escalationTarget
150
+ ? notifyTarget(
151
+ "team.coordinator_gone",
152
+ { teamId, agentId: coordinatorId, parent: resolveCoordinatorAgentId(coordinatorId) ?? undefined },
153
+ escalationTarget,
154
+ "Team coordinator went silent",
155
+ `Coordinator \`${label}\` of team \`${teamId}\` has gone silent with no terminal event — nobody is driving the team and its members are no longer being listened to. Take over coordination, restart the team, or stand it down.`,
156
+ { kind: "team.coordinator_gone", teamId, coordinatorId, escalationTarget, label: watch.label },
157
+ )
158
+ : null;
159
+ if (sent) out.escalatedTeamIds.push(teamId);
160
+
161
+ // No-silent-dissolve: surface the coordinator-gone team on the dashboard even when there is no
162
+ // escalation target configured — it must never be silently abandoned.
163
+ createActivityEvent({
164
+ clientId: `team-coordinator-gone-${teamId}-${coordinatorId}-${now}`,
165
+ kind: "state",
166
+ title: "Team coordinator went silent",
167
+ body: sent
168
+ ? `Coordinator ${label} of team ${teamId} went silent — escalated to ${sent}.`
169
+ : `Coordinator ${label} of team ${teamId} went silent — no escalation target configured (set AGENT_RELAY_WORKSPACE_STEWARD_FALLBACK).`,
170
+ meta: teamId,
171
+ icon: "ti-user-x",
172
+ view: "teams",
173
+ agentId: coordinatorId,
174
+ metadata: {
175
+ source: "server",
176
+ maintenanceJobId: "team-idle-watchdog",
177
+ teamId,
178
+ coordinatorId,
179
+ escalationTarget: sent ?? null,
180
+ },
181
+ });
182
+ }
183
+
87
184
  function teamIdlePayload(teamId: string, coordinatorId: string, thresholdMinutes: number, idleForMs: number, firstNoBusyAt: number) {
88
185
  const roster = getTeamRoster(teamId).map((member) => {
89
186
  const providerState = agentProviderState(member);
@@ -145,12 +242,17 @@ export function runTeamIdleWatchdog(input: { now?: number } = {}): Record<string
145
242
  const alreadyPingedTeamIds: string[] = [];
146
243
  const missingCoordinatorTeamIds: string[] = [];
147
244
  const memberSilence = { pingedMembers: [] as string[], watchedMembers: 0 };
245
+ const coordinatorGone = { escalatedTeamIds: [] as string[], suppressedTeamIds: [] as string[], goneCoordinatorIds: [] as string[] };
148
246
 
149
247
  for (const team of listActiveTeams()) {
150
248
  seen.add(team.id);
151
249
  // #634 — runs every cycle, independent of the team-level idle gate below: a single silent
152
250
  // member must surface even while OTHER members keep the team "busy".
153
251
  detectSilentMembers(team.id, team.coordinatorId, now, thresholdMs, memberSilence);
252
+ // #412/#468 — coordinator-gone escalation, also every cycle and independent of the idle gate: a
253
+ // dead coordinator must surface even if the team isn't (yet) idle, and the idle-ping path can't
254
+ // reach it (it would just ping the void).
255
+ detectGoneCoordinator(team.id, team.coordinatorId, now, thresholdMs, coordinatorGone);
154
256
  const busy = getTeamBusyState(team.id, cutoff);
155
257
  if (busy.busyMemberIds.length > 0) {
156
258
  teamIdleTracker.delete(team.id);
@@ -182,6 +284,7 @@ export function runTeamIdleWatchdog(input: { now?: number } = {}): Record<string
182
284
 
183
285
  for (const key of teamIdleTracker.keys()) if (!seen.has(key)) teamIdleTracker.delete(key);
184
286
  for (const key of teamMemberWatchTracker.keys()) if (!seen.has(key)) teamMemberWatchTracker.delete(key);
287
+ for (const key of teamCoordinatorWatchTracker.keys()) if (!seen.has(key)) teamCoordinatorWatchTracker.delete(key);
185
288
 
186
289
  return {
187
290
  enabled: true,
@@ -194,6 +297,9 @@ export function runTeamIdleWatchdog(input: { now?: number } = {}): Record<string
194
297
  missingCoordinatorTeamIds,
195
298
  silentMemberIds: memberSilence.pingedMembers,
196
299
  watchedMembers: memberSilence.watchedMembers,
300
+ coordinatorGoneTeamIds: coordinatorGone.escalatedTeamIds,
301
+ coordinatorGoneSuppressedTeamIds: coordinatorGone.suppressedTeamIds,
302
+ goneCoordinatorIds: coordinatorGone.goneCoordinatorIds,
197
303
  };
198
304
  }
199
305
 
package/src/mcp/index.ts CHANGED
@@ -9,6 +9,7 @@ import { TEAM_TOOLS, relayTeamTool } from "../mcp-team-tools";
9
9
  import { BLACKBOARD_TOOLS, relayBlackboardTool } from "../mcp-blackboard-tools";
10
10
  import { PROJECT_TOOLS, relayProjectTool } from "../mcp-project-tools"; import { ISSUE_REF_TOOLS, relayIssueRefTool } from "../mcp-issue-ref-tools"; import { ISSUE_ASSOCIATION_TOOLS, relayIssueAssociationTool } from "../mcp-issue-association-tools"; import { ISSUE_ACTIVITY_TOOLS, relayIssueActivityTool } from "../mcp-issue-activity-tools"; import { ISSUE_LIFECYCLE_TOOLS, relayIssueLifecycleTool } from "../mcp-issue-lifecycle-tools";
11
11
  import { NOTIFICATION_SUBSCRIPTION_TOOLS, relayNotificationSubscriptionTool } from "../mcp-notification-subscription-tools"; import { MERGE_TOOLS, relayMergeTool } from "../mcp-merge-tools";
12
+ import { NTFY_TOOLS, relayNotifyNtfy } from "../mcp-ntfy-tools";
12
13
  import { AGENT_ACTION_TOOL, relayAgentAction } from "../mcp-agent-action"; import { providerCatalogToolInputSchema } from "../mcp-provider-catalog";
13
14
  import type { ActivityKind } from "../types";
14
15
  import { worktreeMcpInstructions } from "../workspace-phase";
@@ -33,6 +34,7 @@ const TOOLS: ToolDefinition[] = [
33
34
  ...BLACKBOARD_TOOLS,
34
35
  ...PROJECT_TOOLS,
35
36
  ...NOTIFICATION_SUBSCRIPTION_TOOLS,
37
+ ...NTFY_TOOLS,
36
38
  ];
37
39
 
38
40
  export async function postMcp(req: Request): Promise<Response> {
@@ -144,6 +146,7 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
144
146
  else if (name.startsWith("relay_blackboard_")) result = relayBlackboardTool(auth, name, args);
145
147
  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);
146
148
  else if (name === "relay_subscribe" || name === "relay_unsubscribe" || name === "relay_list_subscriptions") result = relayNotificationSubscriptionTool(auth, name, args);
149
+ else if (name === "relay_notify_ntfy") result = await relayNotifyNtfy(args);
147
150
  else throw new ValidationError(`unknown tool: ${name}`);
148
151
 
149
152
  auditToolCall(auth, name, "ok", result);
@@ -0,0 +1,35 @@
1
+ import { MAX_BODY_BYTES } from "./config";
2
+ import { sendNtfyNotification, NTFY_PRIORITIES, type NtfyNotificationResult, type NtfyPriority } from "./ntfy";
3
+ import { optionalEnum } from "./validation";
4
+ import type { ToolDefinition } from "./mcp/types";
5
+ import { optionalString, optionalStringArray, stringField } from "./mcp/validation";
6
+
7
+ export const NTFY_TOOLS: ToolDefinition[] = [
8
+ {
9
+ name: "relay_notify_ntfy",
10
+ description: "Send an egress-only ntfy notification to the configured operator topic. Supports title, body, priority min→urgent, optional tags, and click-through link.",
11
+ requiredScopes: ["notifications:write"],
12
+ inputSchema: {
13
+ type: "object",
14
+ properties: {
15
+ title: { type: "string" },
16
+ body: { type: "string" },
17
+ priority: { type: "string", enum: NTFY_PRIORITIES },
18
+ tags: { type: "array", items: { type: "string" } },
19
+ click: { type: "string" },
20
+ },
21
+ required: ["title", "body"],
22
+ additionalProperties: false,
23
+ },
24
+ },
25
+ ];
26
+
27
+ export async function relayNotifyNtfy(args: Record<string, unknown>): Promise<NtfyNotificationResult> {
28
+ return sendNtfyNotification({
29
+ title: stringField(args.title, "title", { required: true, max: 200 }),
30
+ body: stringField(args.body, "body", { required: true, maxBytes: MAX_BODY_BYTES }),
31
+ priority: optionalEnum(args.priority, "priority", NTFY_PRIORITIES) as NtfyPriority | undefined,
32
+ tags: optionalStringArray(args.tags, "tags"),
33
+ click: optionalString(args.click, "click", 1000),
34
+ });
35
+ }
@@ -5,6 +5,7 @@ export type NotificationType =
5
5
  | "agent.exited"
6
6
  | "agent.spawn_failed"
7
7
  | "agent.context_threshold"
8
+ | "agent.stuck_busy"
8
9
  | "agent.quota_threshold"
9
10
  | "agent.resume_budget_warning"
10
11
  | "agent.resume_budget_exhausted"
@@ -12,6 +13,7 @@ export type NotificationType =
12
13
  | "agent.rate_limit_resume"
13
14
  | "team.idle"
14
15
  | "team.member_silent"
16
+ | "team.coordinator_gone"
15
17
  | "plan.bound_agent_exited"
16
18
  | "workspace.steward_task"
17
19
  | "workspace.steward_handoff"
@@ -58,6 +60,7 @@ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
58
60
  "agent.exited": null,
59
61
  "agent.spawn_failed": null,
60
62
  "agent.context_threshold": 15 * MINUTES,
63
+ "agent.stuck_busy": null,
61
64
  "agent.quota_threshold": 15 * MINUTES,
62
65
  "agent.resume_budget_warning": null,
63
66
  "agent.resume_budget_exhausted": null,
@@ -65,6 +68,7 @@ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
65
68
  "agent.rate_limit_resume": 15 * MINUTES,
66
69
  "team.idle": null,
67
70
  "team.member_silent": null,
71
+ "team.coordinator_gone": null,
68
72
  "plan.bound_agent_exited": null,
69
73
  "workspace.steward_task": null,
70
74
  "workspace.steward_handoff": null,
package/src/ntfy.ts ADDED
@@ -0,0 +1,222 @@
1
+ import { Buffer } from "node:buffer";
2
+ import { MAX_BODY_BYTES } from "./config";
3
+ import { getConfig } from "./config-store";
4
+ import { upsertAgent, ValidationError } from "./db";
5
+ import { registerChannelTypeSetting } from "./settings/modules";
6
+ import { cleanString, cleanStringArray } from "./validation";
7
+ import { errMessage, isRecord, type JSONSchema } from "agent-relay-sdk";
8
+
9
+ export const NTFY_CONFIG_NAMESPACE = "channel-type";
10
+ export const NTFY_CONFIG_KEY = "ntfy";
11
+ export const NTFY_CHANNEL_ID = "ntfy:default";
12
+
13
+ export const NTFY_PRIORITIES = ["min", "low", "default", "high", "urgent"] as const;
14
+ export type NtfyPriority = (typeof NTFY_PRIORITIES)[number];
15
+
16
+ export interface NtfyChannelConfig {
17
+ enabled: boolean;
18
+ serverUrl: string;
19
+ topic: string;
20
+ tokenEnv?: string;
21
+ username?: string;
22
+ passwordEnv?: string;
23
+ defaultTags: string[];
24
+ defaultClick?: string;
25
+ timeoutMs: number;
26
+ }
27
+
28
+ export interface NtfyNotificationInput {
29
+ title: string;
30
+ body: string;
31
+ priority?: NtfyPriority;
32
+ tags?: string[];
33
+ click?: string;
34
+ }
35
+
36
+ export interface NtfyNotificationResult {
37
+ ok: true;
38
+ status: number;
39
+ channelId: string;
40
+ topic: string;
41
+ priority: NtfyPriority;
42
+ tags: string[];
43
+ }
44
+
45
+ const DEFAULT_NTFY_CONFIG: NtfyChannelConfig = {
46
+ enabled: false,
47
+ serverUrl: "",
48
+ topic: "",
49
+ defaultTags: [],
50
+ timeoutMs: 15_000,
51
+ };
52
+
53
+ const NTFY_CONFIG_SCHEMA: JSONSchema = {
54
+ type: "object",
55
+ additionalProperties: false,
56
+ required: ["enabled", "serverUrl", "topic"],
57
+ properties: {
58
+ enabled: { type: "boolean" },
59
+ serverUrl: { type: "string", maxLength: 500 },
60
+ topic: { type: "string", maxLength: 200 },
61
+ tokenEnv: { type: "string", maxLength: 120, pattern: "^[A-Z0-9_]+$" },
62
+ username: { type: "string", maxLength: 120 },
63
+ passwordEnv: { type: "string", maxLength: 120, pattern: "^[A-Z0-9_]+$" },
64
+ defaultTags: { type: "array", maxItems: 20, items: { type: "string", maxLength: 40 } },
65
+ defaultClick: { type: "string", maxLength: 1000 },
66
+ timeoutMs: { type: "integer", minimum: 1_000, maximum: 60_000 },
67
+ },
68
+ };
69
+
70
+ registerChannelTypeSetting({
71
+ type: NTFY_CONFIG_KEY,
72
+ schema: NTFY_CONFIG_SCHEMA,
73
+ defaults: DEFAULT_NTFY_CONFIG,
74
+ display: {
75
+ label: "ntfy outgoing channel",
76
+ description: "Configures the built-in egress-only ntfy notification channel.",
77
+ order: 121,
78
+ },
79
+ });
80
+
81
+ export function getNtfyConfig(): NtfyChannelConfig {
82
+ const stored = getConfig<Partial<NtfyChannelConfig>>(NTFY_CONFIG_NAMESPACE, NTFY_CONFIG_KEY)?.value;
83
+ return normalizeNtfyConfig({ ...DEFAULT_NTFY_CONFIG, ...(isRecord(stored) ? stored : {}) });
84
+ }
85
+
86
+ export async function sendNtfyNotification(input: NtfyNotificationInput): Promise<NtfyNotificationResult> {
87
+ const config = getNtfyConfig();
88
+ validateSendConfig(config);
89
+ const title = cleanString(input.title, "title", { required: true, max: 200 })!;
90
+ const body = cleanString(input.body, "body", { required: true, max: MAX_BODY_BYTES })!;
91
+ const priority = input.priority ?? "default";
92
+ const tags = [...new Set([...(config.defaultTags ?? []), ...(input.tags ?? [])].map((tag) => tag.trim()).filter(Boolean))];
93
+ const click = cleanString(input.click ?? config.defaultClick, "click", { max: 1000 });
94
+ const url = ntfyTopicUrl(config);
95
+ const headers = ntfyHeaders({ config, title, priority, tags, click });
96
+
97
+ ensureNtfyChannelRegistered(config);
98
+
99
+ const controller = new AbortController();
100
+ const timeout = setTimeout(() => controller.abort(), config.timeoutMs);
101
+ let response: Response;
102
+ try {
103
+ response = await fetch(url, {
104
+ method: "POST",
105
+ headers,
106
+ body,
107
+ signal: controller.signal,
108
+ });
109
+ } catch (error) {
110
+ if (controller.signal.aborted) throw new Error(`ntfy notify timed out after ${config.timeoutMs}ms`);
111
+ throw new Error(`ntfy notify failed: ${errMessage(error)}`);
112
+ } finally {
113
+ clearTimeout(timeout);
114
+ }
115
+
116
+ if (!response.ok) {
117
+ const text = await response.text().catch(() => "");
118
+ throw new Error(`ntfy notify failed: HTTP ${response.status}${text ? `: ${text.slice(0, 300)}` : ""}`);
119
+ }
120
+
121
+ return { ok: true, status: response.status, channelId: NTFY_CHANNEL_ID, topic: config.topic, priority, tags };
122
+ }
123
+
124
+ function normalizeNtfyConfig(value: unknown): NtfyChannelConfig {
125
+ if (!isRecord(value)) throw new ValidationError("ntfy channel config must be an object");
126
+ const timeoutMs = value.timeoutMs === undefined ? DEFAULT_NTFY_CONFIG.timeoutMs : value.timeoutMs;
127
+ if (typeof value.enabled !== "boolean") throw new ValidationError("ntfy.enabled must be a boolean");
128
+ if (typeof timeoutMs !== "number" || !Number.isSafeInteger(timeoutMs) || timeoutMs < 1_000 || timeoutMs > 60_000) {
129
+ throw new ValidationError("ntfy.timeoutMs must be an integer between 1000 and 60000");
130
+ }
131
+ return {
132
+ enabled: value.enabled,
133
+ serverUrl: cleanString(value.serverUrl, "ntfy.serverUrl", { max: 500 }) ?? "",
134
+ topic: cleanString(value.topic, "ntfy.topic", { max: 200 }) ?? "",
135
+ tokenEnv: cleanString(value.tokenEnv, "ntfy.tokenEnv", { max: 120 }),
136
+ username: cleanString(value.username, "ntfy.username", { max: 120 }),
137
+ passwordEnv: cleanString(value.passwordEnv, "ntfy.passwordEnv", { max: 120 }),
138
+ defaultTags: cleanStringArray(value.defaultTags, "ntfy.defaultTags", { itemMax: 40, maxItems: 20 }) ?? [],
139
+ defaultClick: cleanString(value.defaultClick, "ntfy.defaultClick", { max: 1000 }),
140
+ timeoutMs,
141
+ };
142
+ }
143
+
144
+ function validateSendConfig(config: NtfyChannelConfig): void {
145
+ if (!config.enabled) throw new ValidationError("ntfy channel is disabled");
146
+ if (!config.serverUrl || !config.topic) {
147
+ throw new ValidationError("ntfy channel requires channel-type/ntfy serverUrl and topic");
148
+ }
149
+ if (config.topic.includes("/") || config.topic.includes("?") || config.topic.includes("#")) {
150
+ throw new ValidationError("ntfy.topic must be a single topic name, not a path or URL");
151
+ }
152
+ if (config.tokenEnv && (config.username || config.passwordEnv)) {
153
+ throw new ValidationError("ntfy auth must use tokenEnv or username/passwordEnv, not both");
154
+ }
155
+ if (config.passwordEnv && !config.username) throw new ValidationError("ntfy.username required when passwordEnv is set");
156
+ }
157
+
158
+ function ntfyTopicUrl(config: NtfyChannelConfig): string {
159
+ let base: URL;
160
+ try {
161
+ base = new URL(config.serverUrl.endsWith("/") ? config.serverUrl : `${config.serverUrl}/`);
162
+ } catch {
163
+ throw new ValidationError("ntfy.serverUrl must be a valid URL");
164
+ }
165
+ return new URL(encodeURIComponent(config.topic), base).toString();
166
+ }
167
+
168
+ function ntfyHeaders(input: {
169
+ config: NtfyChannelConfig;
170
+ title: string;
171
+ priority: NtfyPriority;
172
+ tags: string[];
173
+ click?: string;
174
+ }): Record<string, string> {
175
+ const headers: Record<string, string> = {
176
+ Title: input.title,
177
+ Priority: String(priorityValue(input.priority)),
178
+ };
179
+ if (input.tags.length) headers.Tags = input.tags.join(",");
180
+ if (input.click) headers.Click = input.click;
181
+ const authorization = authHeader(input.config);
182
+ if (authorization) headers.Authorization = authorization;
183
+ return headers;
184
+ }
185
+
186
+ function authHeader(config: NtfyChannelConfig): string | undefined {
187
+ if (config.tokenEnv) {
188
+ const token = cleanString(process.env[config.tokenEnv], config.tokenEnv, { required: true, max: 4_000 })!;
189
+ return `Bearer ${token}`;
190
+ }
191
+ if (config.username && config.passwordEnv) {
192
+ const password = cleanString(process.env[config.passwordEnv], config.passwordEnv, { required: true, max: 4_000 })!;
193
+ return `Basic ${Buffer.from(`${config.username}:${password}`).toString("base64")}`;
194
+ }
195
+ return undefined;
196
+ }
197
+
198
+ function priorityValue(priority: NtfyPriority): number {
199
+ return NTFY_PRIORITIES.indexOf(priority) + 1;
200
+ }
201
+
202
+ function ensureNtfyChannelRegistered(config: NtfyChannelConfig): void {
203
+ upsertAgent({
204
+ id: NTFY_CHANNEL_ID,
205
+ name: "ntfy",
206
+ kind: "channel",
207
+ tags: ["channel", "ntfy", "channel:ntfy"],
208
+ capabilities: ["channel", "ntfy", "notify"],
209
+ status: "online",
210
+ ready: true,
211
+ meta: {
212
+ kind: "channel",
213
+ provider: "ntfy",
214
+ accountId: "default",
215
+ channelType: "ntfy",
216
+ transport: "ntfy",
217
+ direction: "outbound",
218
+ displayName: "ntfy",
219
+ topicChannels: [config.topic],
220
+ },
221
+ });
222
+ }
@@ -29,6 +29,7 @@ type AgentRecipientRow = {
29
29
  };
30
30
 
31
31
  const RESET_ROUNDING_PERCENT = 99;
32
+ const QUOTA_THRESHOLD_EPSILON = 1e-9;
32
33
 
33
34
  export function installQuotaAdvisoryHandler(): void {
34
35
  setProviderQuotaAdvisoryHandler(handleProviderQuotaAdvisory);
@@ -62,14 +63,14 @@ function handleProviderQuotaAdvisory(input: ProviderQuotaAdvisoryInput): string
62
63
  continue;
63
64
  }
64
65
 
65
- if (remaining > config.warnRemaining) {
66
+ if (remaining - config.warnRemaining > QUOTA_THRESHOLD_EPSILON) {
66
67
  if (previous) delete nextState[window.name];
67
68
  continue;
68
69
  }
69
70
 
70
71
  const band = quotaRemainingBand(remaining, config.bandSize);
71
72
  if (previous?.lastQuotaAdvisoryBand === band) continue;
72
- const stage: QuotaAdvisoryStage = remaining <= config.criticalRemaining ? "critical" : "warn";
73
+ const stage: QuotaAdvisoryStage = remaining - config.criticalRemaining <= QUOTA_THRESHOLD_EPSILON ? "critical" : "warn";
73
74
  notifyQuotaAdvisory({
74
75
  provider: input.provider,
75
76
  accountKey: input.accountKey,
@@ -4,7 +4,7 @@ import type { NotificationsConfig } from "./types";
4
4
 
5
5
  export const DEFAULT_QUOTA_THRESHOLD_CONFIG: NotificationsConfig["quotaThreshold"] = {
6
6
  enabled: true,
7
- warnRemaining: 0.25,
7
+ warnRemaining: 0.15,
8
8
  criticalRemaining: 0.1,
9
9
  resetRemaining: 0.99,
10
10
  bandSize: 0.05,
@@ -14,7 +14,7 @@ import { deleteInboxDraftRoute, getChatHistoryImportsRoute, getInboxStateRoute,
14
14
  import { deleteMemoryRoute, getMemories, getMemoryBrokerInfo, getMemoryById, getMemoryStats, patchMemory, postMemory, postMemoryInject, postMemorySearch } from "./memory";
15
15
  import { deleteMessageById, getCursorRoute, getMessageById, getMessageDeliveryRoute, getMessageStatusRoute, getMessageThread, getMessages, getQueuedMessagesRoute, patchMessage, postClaimMessage, postMessage, postMessageDeliveryAction, postMessageDeliveryAttempt, postMessageReaction, postRenewMessageClaim, postSystemBroadcast } from "./messages";
16
16
  import { deleteOrchestratorById, getOrchestratorById, getOrchestrators, patchOrchestratorAgents, postOrchestrator, postOrchestratorAction, postOrchestratorHeartbeat } from "./orchestrator";
17
- import { deleteSpawnPolicyRoute, getSpawnPoliciesHealth, getSpawnPoliciesRoute, getSpawnPolicyHealth, getSpawnPolicyRoute, getSpawnPolicyStatus, postSpawnPolicyRestart, postSpawnPolicyStart, postSpawnPolicyStop, putSpawnPolicyRoute } from "./spawn-policy";
17
+ import { deleteSpawnPolicyRoute, getSpawnPoliciesHealth, getSpawnPoliciesRoute, getSpawnPolicyDiagnostics, getSpawnPolicyHealth, getSpawnPolicyRoute, getSpawnPolicyStatus, postSpawnPolicyRestart, postSpawnPolicyStart, postSpawnPolicyStop, putSpawnPolicyRoute } from "./spawn-policy";
18
18
  import { deleteTokenProfileRoute, getTokenById, getTokenMe, getTokenProfiles, getTokens, patchTokenProfile, postIntegrationRuntimeToken, postInteractiveRunnerRuntimeToken, postMcpRuntimeToken, postOrchestratorRunnerToken, postRuntimeTokenRenew, postToken, postTokenProfile, postTokenRevoke } from "./tokens";
19
19
  import { deleteWorkspaceById, getWorkspaceById, getWorkspaceDiagnostics, getWorkspaceDiff, getWorkspaceGitState, getWorkspaceMergePreview, getWorkspaceOrphans, getWorkspaceRecoveryBranches, getWorkspaceStewards, getWorkspaces, postWorkspaceAction, postWorkspaceCleanupStale, postWorkspaceOrphanReclaim, postWorkspaceRecoveryBranchDiscard } from "./workspaces";
20
20
  import { error, type Handler } from "./_shared";
@@ -259,6 +259,7 @@ const routes: Route[] = [
259
259
  route("GET", "/api/spawn-policies/health", getSpawnPoliciesHealth),
260
260
  route("GET", "/api/spawn-policy/:name/status", getSpawnPolicyStatus),
261
261
  route("GET", "/api/spawn-policy/:name/health", getSpawnPolicyHealth),
262
+ route("GET", "/api/spawn-policy/:name/diagnostics", getSpawnPolicyDiagnostics),
262
263
  route("GET", "/api/providers", getProvidersRoute),
263
264
  route("GET", "/api/providers/config", getProviderConfigsRoute),
264
265
  route("GET", "/api/providers/quota-config", getProviderQuotaConfigsRoute),
@@ -1,6 +1,6 @@
1
1
  // Auto-split from routes.ts (#299). Domain: spawn-policy.
2
- import { ValidationError, getOrchestrator } from "../db";
3
- import { auditEvent, authAuditMetadata, dashboardAttribution, emitCommand, error, json, parseBody, spawnRequestId, type Handler } from "./_shared";
2
+ import { ValidationError, getCrashReport, getManagedPolicyTimeline, getOrchestrator } from "../db";
3
+ import { auditEvent, authAuditMetadata, dashboardAttribution, emitCommand, error, json, parseBody, parseQueryInt, spawnRequestId, type Handler } from "./_shared";
4
4
  import { buildManagedSpawnParams } from "../managed-policy";
5
5
  import { cleanString } from "../validation";
6
6
  import { createCommand } from "../commands-db";
@@ -229,3 +229,20 @@ export const getSpawnPolicyHealth: Handler = (_req, params) => {
229
229
  const entry = getSpawnPolicy(params.name!);
230
230
  return entry ? json(policyStatusPayload(entry.value)) : error("spawn policy not found", 404);
231
231
  };
232
+
233
+ export const getSpawnPolicyDiagnostics: Handler = (req, params) => {
234
+ const entry = getSpawnPolicy(params.name!);
235
+ if (!entry) return error("spawn policy not found", 404);
236
+ const url = new URL(req.url);
237
+ const limitRaw = parseQueryInt(url.searchParams.get("limit"), { min: 1, max: 200, clamp: true });
238
+ if (Number.isNaN(limitRaw)) return error("limit must be an integer between 1 and 200");
239
+ const sinceRaw = parseQueryInt(url.searchParams.get("since"), { min: 0, max: Number.MAX_SAFE_INTEGER });
240
+ if (Number.isNaN(sinceRaw)) return error("since must be a non-negative integer");
241
+ const status = policyStatusPayload(entry.value);
242
+ const agentId = typeof status.state.agentId === "string" ? status.state.agentId : undefined;
243
+ return json({
244
+ ...status,
245
+ timeline: getManagedPolicyTimeline(entry.value.name, { limit: limitRaw ?? 100, since: sinceRaw ?? undefined }),
246
+ crashReport: agentId ? getCrashReport(agentId) : null,
247
+ });
248
+ };
@@ -26,3 +26,22 @@ export interface TeamMemberWatch {
26
26
 
27
27
  // teamId → (memberId → watch)
28
28
  export const teamMemberWatchTracker = new Map<string, Map<string, TeamMemberWatch>>();
29
+
30
+ // #412/#468 — per-team COORDINATOR liveness watch. The member watch above deliberately skips the
31
+ // coordinator (it's the ping recipient, not a watched worker), so a coordinator that dies silently
32
+ // is invisible to it. This tracker remembers the coordinator's last-alive observation so a later
33
+ // silence escalates ONCE to the human/steward fallback instead of dead-ending — a coordinator-gone
34
+ // team must never be silently abandoned.
35
+ export interface TeamCoordinatorWatch {
36
+ /** Identity of the coordinator this watch is for; a re-elected/replaced coordinator resets it. */
37
+ coordinatorId: string;
38
+ /** Most recent time the coordinator was seen alive (non-offline, fresh heartbeat). */
39
+ lastActiveAt: number;
40
+ /** Whether the coordinator's CURRENT gone-spell already escalated (re-armed when it recovers). */
41
+ escalated: boolean;
42
+ /** Coordinator label for a readable escalation. */
43
+ label?: string;
44
+ }
45
+
46
+ // teamId → coordinator watch
47
+ export const teamCoordinatorWatchTracker = new Map<string, TeamCoordinatorWatch>();