agent-relay-server 0.102.1 → 0.103.4

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 (132) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +4 -2
  3. package/public/assets/MessageBubble-BpiTbbnp.js +4 -0
  4. package/public/assets/{MessageBubble-Dppo7pil.js.map → MessageBubble-BpiTbbnp.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-DO5OY2qa.js → PlanGraphPanel-k8Aa98g5.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-DO5OY2qa.js.map → PlanGraphPanel-k8Aa98g5.js.map} +1 -1
  7. package/public/assets/{activity-BhuOs5ha.js → activity-CxKi5Erb.js} +2 -2
  8. package/public/assets/{activity-BhuOs5ha.js.map → activity-CxKi5Erb.js.map} +1 -1
  9. package/public/assets/agent-profiles-DMZXdN6K.js +2 -0
  10. package/public/assets/agent-profiles-DMZXdN6K.js.map +1 -0
  11. package/public/assets/agents-BrvpH3gn.js +2 -0
  12. package/public/assets/agents-BrvpH3gn.js.map +1 -0
  13. package/public/assets/{analytics-BxGRljQc.js → analytics-1fvh-9ok.js} +2 -2
  14. package/public/assets/{analytics-BxGRljQc.js.map → analytics-1fvh-9ok.js.map} +1 -1
  15. package/public/assets/automation-BCgX5WOH.js +2 -0
  16. package/public/assets/automation-BCgX5WOH.js.map +1 -0
  17. package/public/assets/{branch-state-badge-olhanssg.js → branch-state-badge-DYA7bU0i.js} +2 -2
  18. package/public/assets/{branch-state-badge-olhanssg.js.map → branch-state-badge-DYA7bU0i.js.map} +1 -1
  19. package/public/assets/{channels-yHkm1dux.js → channels-CK80oJYG.js} +2 -2
  20. package/public/assets/{channels-yHkm1dux.js.map → channels-CK80oJYG.js.map} +1 -1
  21. package/public/assets/chat-D_2PEz06.js +2 -0
  22. package/public/assets/chat-D_2PEz06.js.map +1 -0
  23. package/public/assets/{connectors-DpR0V2hy.js → connectors-ChGyht5m.js} +2 -2
  24. package/public/assets/{connectors-DpR0V2hy.js.map → connectors-ChGyht5m.js.map} +1 -1
  25. package/public/assets/display-D1xB8NJZ.js +3 -0
  26. package/public/assets/display-D1xB8NJZ.js.map +1 -0
  27. package/public/assets/{formatted-body-impl-BTeCfK4I.js → formatted-body-impl-BJ4WCF5J.js} +2 -2
  28. package/public/assets/{formatted-body-impl-BTeCfK4I.js.map → formatted-body-impl-BJ4WCF5J.js.map} +1 -1
  29. package/public/assets/index-BMO_Sblt.css +2 -0
  30. package/public/assets/index-CN7QZlMb.js +23 -0
  31. package/public/assets/index-CN7QZlMb.js.map +1 -0
  32. package/public/assets/{insights-C4aYlgsY.js → insights-BCZRV0hX.js} +2 -2
  33. package/public/assets/{insights-C4aYlgsY.js.map → insights-BCZRV0hX.js.map} +1 -1
  34. package/public/assets/{integrations-CEmYa5mW.js → integrations-C7UGs00U.js} +2 -2
  35. package/public/assets/{integrations-CEmYa5mW.js.map → integrations-C7UGs00U.js.map} +1 -1
  36. package/public/assets/maintenance-ChYno3Fm.js +2 -0
  37. package/public/assets/{maintenance-DVx3SiC_.js.map → maintenance-ChYno3Fm.js.map} +1 -1
  38. package/public/assets/{managed-agents-DUyOTdz1.js → managed-agents-DjxZQNIR.js} +4 -4
  39. package/public/assets/managed-agents-DjxZQNIR.js.map +1 -0
  40. package/public/assets/{markdown-preview-impl-D3hYfzGD.js → markdown-preview-impl-DFa5HF1H.js} +2 -2
  41. package/public/assets/{markdown-preview-impl-D3hYfzGD.js.map → markdown-preview-impl-DFa5HF1H.js.map} +1 -1
  42. package/public/assets/{memory-2IfPgcnN.js → memory-CTw6kadT.js} +2 -2
  43. package/public/assets/{memory-2IfPgcnN.js.map → memory-CTw6kadT.js.map} +1 -1
  44. package/public/assets/{messages-CkOwABhw.js → messages-Cjr816D_.js} +2 -2
  45. package/public/assets/{messages-CkOwABhw.js.map → messages-Cjr816D_.js.map} +1 -1
  46. package/public/assets/{orchestrators-CEKO3eQ1.js → orchestrators-CdmFKA7K.js} +2 -2
  47. package/public/assets/{orchestrators-CEKO3eQ1.js.map → orchestrators-CdmFKA7K.js.map} +1 -1
  48. package/public/assets/overview-DBOnjfpP.js +2 -0
  49. package/public/assets/{overview-D9BxevAq.js.map → overview-DBOnjfpP.js.map} +1 -1
  50. package/public/assets/{pairs-CqT4D1uL.js → pairs-BkcdKdF7.js} +2 -2
  51. package/public/assets/{pairs-CqT4D1uL.js.map → pairs-BkcdKdF7.js.map} +1 -1
  52. package/public/assets/{projects-C_zwR-Fn.js → projects-ClKbDQB1.js} +2 -2
  53. package/public/assets/{projects-C_zwR-Fn.js.map → projects-ClKbDQB1.js.map} +1 -1
  54. package/public/assets/registry-Bsf3RtOO.js +2 -0
  55. package/public/assets/registry-Bsf3RtOO.js.map +1 -0
  56. package/public/assets/{security-8a5_4U3S.js → security-Iyu5bhJw.js} +2 -2
  57. package/public/assets/{security-8a5_4U3S.js.map → security-Iyu5bhJw.js.map} +1 -1
  58. package/public/assets/{select-s4BE-T62.js → select-Bi6tNJDx.js} +2 -2
  59. package/public/assets/{select-s4BE-T62.js.map → select-Bi6tNJDx.js.map} +1 -1
  60. package/public/assets/settings-CbHp2F9r.js +6 -0
  61. package/public/assets/settings-CbHp2F9r.js.map +1 -0
  62. package/public/assets/store-zeyDiSKE.js +9 -0
  63. package/public/assets/store-zeyDiSKE.js.map +1 -0
  64. package/public/assets/{tasks-Bcr17zR3.js → tasks-CPlf-5Tr.js} +2 -2
  65. package/public/assets/{tasks-Bcr17zR3.js.map → tasks-CPlf-5Tr.js.map} +1 -1
  66. package/public/assets/teams-D2uUNhlY.js +2 -0
  67. package/public/assets/{teams-B_HSkU9T.js.map → teams-D2uUNhlY.js.map} +1 -1
  68. package/public/assets/{terminal-viewer-impl-Ctp4csJ3.js → terminal-viewer-impl-uRzbrlXy.js} +2 -2
  69. package/public/assets/{terminal-viewer-impl-Ctp4csJ3.js.map → terminal-viewer-impl-uRzbrlXy.js.map} +1 -1
  70. package/public/assets/types-DorDDvEb.js +2 -0
  71. package/public/assets/types-DorDDvEb.js.map +1 -0
  72. package/public/assets/{user-avatar-RliaJcaG.js → user-avatar-GFWMuAGK.js} +2 -2
  73. package/public/assets/{user-avatar-RliaJcaG.js.map → user-avatar-GFWMuAGK.js.map} +1 -1
  74. package/public/assets/{work-queue-C0hpmlv8.js → work-queue-B30SNmuq.js} +2 -2
  75. package/public/assets/{work-queue-C0hpmlv8.js.map → work-queue-B30SNmuq.js.map} +1 -1
  76. package/public/assets/{workspaces-QXbQx_AG.js → workspaces-CSpmgbPT.js} +3 -3
  77. package/public/assets/{workspaces-QXbQx_AG.js.map → workspaces-CSpmgbPT.js.map} +1 -1
  78. package/public/index.html +5 -5
  79. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  80. package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +238 -16
  81. package/runner/src/config.ts +8 -3
  82. package/src/automations/validation.ts +3 -3
  83. package/src/cli/orchestrator.ts +7 -5
  84. package/src/cli/provider.ts +26 -10
  85. package/src/cli/upgrade.ts +5 -4
  86. package/src/commands-db.ts +1 -1
  87. package/src/config-store.ts +3 -3
  88. package/src/config.ts +3 -3
  89. package/src/context-advisory.ts +3 -3
  90. package/src/db/channels.ts +30 -2
  91. package/src/db/provider-quota-burn.ts +37 -5
  92. package/src/db/provider-quotas.ts +11 -2
  93. package/src/db/provisioning-registry.ts +49 -61
  94. package/src/managed-policy.ts +7 -2
  95. package/src/mcp/tools-spawn.ts +2 -2
  96. package/src/mcp-team-template-tools.ts +2 -2
  97. package/src/provider-catalog-store.ts +1 -1
  98. package/src/quota-band-transition.ts +59 -8
  99. package/src/recipe-validator.ts +4 -2
  100. package/src/routes/agent-profiles.ts +2 -2
  101. package/src/routes/agent-sessions.ts +18 -23
  102. package/src/routes/provider-config.ts +1 -1
  103. package/src/spawn-targets.ts +32 -4
  104. package/src/sse.ts +12 -1
  105. package/src/upgrade.ts +120 -44
  106. package/src/workspace-phase.ts +1 -0
  107. package/public/assets/MessageBubble-Dppo7pil.js +0 -4
  108. package/public/assets/agent-profiles-CT-vbfzd.js +0 -2
  109. package/public/assets/agent-profiles-CT-vbfzd.js.map +0 -1
  110. package/public/assets/agents-CLcgi8AD.js +0 -2
  111. package/public/assets/agents-CLcgi8AD.js.map +0 -1
  112. package/public/assets/automation-CE1i1zpw.js +0 -2
  113. package/public/assets/automation-CE1i1zpw.js.map +0 -1
  114. package/public/assets/chat-DHdFZvAg.js +0 -2
  115. package/public/assets/chat-DHdFZvAg.js.map +0 -1
  116. package/public/assets/display-DRiGi1zj.js +0 -3
  117. package/public/assets/display-DRiGi1zj.js.map +0 -1
  118. package/public/assets/index-CKhh9XRZ.css +0 -2
  119. package/public/assets/index-DeDosfVE.js +0 -23
  120. package/public/assets/index-DeDosfVE.js.map +0 -1
  121. package/public/assets/maintenance-DVx3SiC_.js +0 -2
  122. package/public/assets/managed-agents-DUyOTdz1.js.map +0 -1
  123. package/public/assets/overview-D9BxevAq.js +0 -2
  124. package/public/assets/registry-BLjwVXCW.js +0 -2
  125. package/public/assets/registry-BLjwVXCW.js.map +0 -1
  126. package/public/assets/settings-BR30UpbJ.js +0 -6
  127. package/public/assets/settings-BR30UpbJ.js.map +0 -1
  128. package/public/assets/store-DNbkrqbU.js +0 -9
  129. package/public/assets/store-DNbkrqbU.js.map +0 -1
  130. package/public/assets/teams-B_HSkU9T.js +0 -2
  131. package/public/assets/types-Tv67voFu.js +0 -2
  132. package/public/assets/types-Tv67voFu.js.map +0 -1
@@ -71,7 +71,7 @@ export const SPAWN_TOOLS: ToolDefinition[] = [
71
71
  required: ["branch", "mode"],
72
72
  additionalProperties: false,
73
73
  },
74
- waitForRegistrationMs: { type: "integer", minimum: 0, maximum: 30000, description: "How long to wait for the spawned agent to register before returning, so the response carries its resolved agent id (default 8000; 0 = return immediately with just spawnRequestId)." },
74
+ waitForRegistrationMs: { type: "integer", minimum: 0, maximum: 30000, description: "How long to wait for the spawned agent to register before returning, so the response carries its resolved agent id (default 0 = fire-and-forget; you will be PUSHED agent.ready when the child is ready)." },
75
75
  },
76
76
  required: ["provider"],
77
77
  additionalProperties: false,
@@ -158,7 +158,7 @@ export async function relaySpawnAgent(auth: McpAuthContext, args: Record<string,
158
158
  providerArgs: optionalStringArray(args.providerArgs, "providerArgs"),
159
159
  spawnRequestId: optionalString(args.spawnRequestId, "spawnRequestId", 160),
160
160
  requestedVia: "mcp",
161
- waitForRegistrationMs: optionalNonNegativeInt(args.waitForRegistrationMs, "waitForRegistrationMs") ?? 8000,
161
+ waitForRegistrationMs: optionalNonNegativeInt(args.waitForRegistrationMs, "waitForRegistrationMs") ?? 0,
162
162
  resumeWorkspace: parseResumeWorkspace(args.resumeWorkspace),
163
163
  };
164
164
  const result = await spawnAgent(input, authContextFromMcp(auth));
@@ -1,4 +1,4 @@
1
- import { APPROVAL_MODES, VALID_AGENT_LIFECYCLES, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
1
+ import { APPROVAL_MODES, SPAWN_PROVIDERS, VALID_AGENT_LIFECYCLES, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
2
2
  import { McpAuthError } from "./mcp-errors";
3
3
  import { authContextFromMcp } from "./services/auth-context";
4
4
  import { deleteTeamTemplate, getTeamTemplate, instantiateTeamTemplate, listTeamTemplates, saveTeamTemplate } from "./services/team-templates";
@@ -9,7 +9,7 @@ const COMPOSITION_ENTRY_SCHEMA = {
9
9
  type: "object",
10
10
  properties: {
11
11
  label: { type: "string" },
12
- provider: { type: "string", enum: ["claude", "codex"] },
12
+ provider: { type: "string", enum: SPAWN_PROVIDERS },
13
13
  model: { type: "string" },
14
14
  effort: { type: "string" },
15
15
  profile: { type: "string" },
@@ -138,7 +138,7 @@ export function mergeProviderCatalog(
138
138
  }
139
139
 
140
140
  function normalizeOverrideInput(input: ProviderModelOverrideInput): ProviderModelOverrideInput {
141
- if (!SPAWN_PROVIDERS.includes(input.provider)) throw new ValidationError("provider must be claude or codex");
141
+ if (!SPAWN_PROVIDERS.includes(input.provider)) throw new ValidationError(`provider must be one of: ${SPAWN_PROVIDERS.join(", ")}`);
142
142
  const alias = input.alias.trim();
143
143
  if (!alias) throw new ValidationError("model alias is required");
144
144
  if (input.entry.alias !== alias) throw new ValidationError("model entry alias must match override alias");
@@ -14,17 +14,36 @@
14
14
  // at most one per RECOVERY_MIN_INTERVAL_MS — degradation is the actionable
15
15
  // signal; fleet-wake on recovery churn is suppressed.
16
16
  //
17
+ // #798 — principled noisy-signal → discrete-state pipeline so a provider in a
18
+ // steady state stops re-emitting band-change notifications:
19
+ // 1. LOW-PASS (EMA): the raw penalty/slack jitters tick-to-tick (burnRatio
20
+ // recompute, shrinking timeToReset). An exponential moving average with a
21
+ // few-minute time constant is applied to the signal BEFORE it is thresholded
22
+ // into a band — killing the noise at the source, so a transient single-tick
23
+ // excursion never reaches the opposite band.
24
+ // 2. SCHMITT (applyHysteresis): enter/exit thresholds with a gap on the band
25
+ // STATE (not just the routing weight — that's where #756 fell short).
26
+ // 3. MIN-DWELL DEBOUNCE: the new band must hold DEBOUNCE_SAMPLES ticks before
27
+ // the human-facing notification fires.
28
+ // The idle-projection de-bias + position penalty (db/provider-quota-burn.ts,
29
+ // spawn-targets.ts) attack the same flap from the other end: an idle provider's
30
+ // band is now position-pinned, so the input the pipeline sees is already stable.
31
+ //
17
32
  // Called from handleProviderQuotaAdvisory in quota-advisory.ts so the check
18
33
  // runs on the same event hook as the utilization-threshold advisory — no
19
34
  // parallel delivery channel.
20
35
  import { emitRelayEvent } from "./events";
21
36
  import { routeNotification } from "./notification-router";
22
37
  import { getSpawnCapableAgentIds } from "./routing-policy-push";
23
- import { getProviderBandInfo, windowRecommendedAction } from "./spawn-targets";
38
+ import { getProviderBandInfo, quotaBandFromSignal, windowRecommendedAction } from "./spawn-targets";
24
39
  import type { ProviderBandInfo, ProviderBandWindow, QuotaBand } from "./spawn-targets";
25
40
  import type { ProviderQuotaAdvisoryInput } from "./db/provider-quotas";
26
41
 
27
42
  const DEBOUNCE_SAMPLES = 3;
43
+ // #798 — EMA time constant for the low-pass. "A few minutes": longer than the ~5-min poll-cadence
44
+ // tick jitter (so noise is smoothed), far shorter than the 5h/7d utilization trend (so a genuine
45
+ // quota move still surfaces within a poll or two). dt-based, so it is correct at any cadence.
46
+ const EMA_TAU_MS = 6 * 60_000;
28
47
  // Penalty must drop this far below the band entry threshold before recovery is recognised.
29
48
  // #760: widened 0.05 → 0.10. Live evidence (Claude 7d parked at avoidLargeAt with burn×1.4)
30
49
  // showed the 0.05 deadband was too tight for real penalty jitter — it still ping-ponged
@@ -42,6 +61,22 @@ interface TransitionState {
42
61
  pendingBand: QuotaBand | null;
43
62
  pendingCount: number;
44
63
  lastTransitionAt: number;
64
+ // #798 — low-pass state: EMA of the noisy penalty/slack signal, plus the tick time it was last
65
+ // updated (to size the dt-based smoothing factor). null until the first high-confidence sample.
66
+ emaPenalty: number | null;
67
+ emaSlack: number | null;
68
+ emaUpdatedAt: number;
69
+ }
70
+
71
+ // #798 — dt-based EMA blend factor: 1 − e^(−dt/τ). dt = 0 (or first sample) → seed verbatim.
72
+ function emaAlpha(dtMs: number): number {
73
+ if (!(dtMs > 0)) return 1;
74
+ return 1 - Math.exp(-dtMs / EMA_TAU_MS);
75
+ }
76
+
77
+ function emaStep(prev: number | null, value: number, dtMs: number): number {
78
+ if (prev === null) return value;
79
+ return prev + emaAlpha(dtMs) * (value - prev);
45
80
  }
46
81
 
47
82
  const bandState = new Map<string, TransitionState>();
@@ -73,17 +108,33 @@ export function processBandTransitions(input: ProviderQuotaAdvisoryInput): void
73
108
  pendingBand: null,
74
109
  pendingCount: 0,
75
110
  lastTransitionAt: 0,
111
+ emaPenalty: null,
112
+ emaSlack: null,
113
+ emaUpdatedAt: 0,
76
114
  };
77
115
 
78
- // #756: asymmetric hysteresisrecovery needs penalty HYSTERESIS_MARGIN below the entry
79
- // threshold of the current band to prevent ping-pong at a boundary. #760 extends this with a
80
- // slack deadband for the "prefer" boundary.
81
- const effectiveBand = applyHysteresis(window.band, window.penalty, window.slack, state.lastNotifiedBand, info.quotaBands, info.preferSlackAt);
116
+ // #798 (layer 1)low-pass the noisy penalty/slack BEFORE thresholding, then re-classify the
117
+ // smoothed signal into a band. A transient one-tick excursion is averaged out, so it never
118
+ // reaches (and re-emits) the opposite band; a sustained move still crosses within a poll or two.
119
+ const dtMs = state.emaUpdatedAt > 0 ? input.now - state.emaUpdatedAt : 0;
120
+ const emaPenalty = emaStep(state.emaPenalty, window.penalty, dtMs);
121
+ const emaSlack = window.slack === null ? state.emaSlack : emaStep(state.emaSlack, window.slack, dtMs);
122
+ state.emaPenalty = emaPenalty;
123
+ state.emaSlack = emaSlack;
124
+ state.emaUpdatedAt = input.now;
125
+ // Persist immediately so the low-pass state survives every early-continue below (the state is
126
+ // then mutated in place by reference — the EMA must carry across ticks to smooth excursions).
127
+ bandState.set(key, state);
128
+ const smoothedBand = quotaBandFromSignal(emaPenalty, emaSlack, info.quotaBands, info.preferSlackAt);
129
+
130
+ // #756 (layer 2): asymmetric hysteresis — recovery needs penalty HYSTERESIS_MARGIN below the
131
+ // entry threshold of the current band to prevent ping-pong at a boundary. #760 extends this with
132
+ // a slack deadband for the "prefer" boundary. Operates on the smoothed signal.
133
+ const effectiveBand = applyHysteresis(smoothedBand, emaPenalty, emaSlack, state.lastNotifiedBand, info.quotaBands, info.preferSlackAt);
82
134
 
83
135
  if (effectiveBand === state.lastNotifiedBand) {
84
- if (state.pendingBand !== null) {
85
- bandState.set(key, { ...state, pendingBand: null, pendingCount: 0 });
86
- }
136
+ state.pendingBand = null;
137
+ state.pendingCount = 0;
87
138
  continue;
88
139
  }
89
140
 
@@ -1,6 +1,6 @@
1
1
  import type { MemoryType, Recipe, RecipeAgent, RecipeMemoryPolicy } from "./types";
2
2
  import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog";
3
- import { errMessage, isRecord } from "agent-relay-sdk";
3
+ import { errMessage, isRecord, isSpawnProvider, SPAWN_PROVIDERS } from "agent-relay-sdk";
4
4
  import { effectiveProviderCatalogList } from "./provider-catalog-store";
5
5
 
6
6
  class RecipeValidationError extends Error {}
@@ -41,7 +41,9 @@ export function validateRecipe(value: unknown): Recipe {
41
41
  function validateAgent(value: unknown): RecipeAgent {
42
42
  if (!isRecord(value)) throw new RecipeValidationError("agent must be an object");
43
43
  const provider = value.provider;
44
- if (provider !== "claude" && provider !== "codex") throw new RecipeValidationError("agent.provider must be claude or codex");
44
+ if (!isSpawnProvider(provider)) {
45
+ throw new RecipeValidationError(`agent.provider must be one of: ${SPAWN_PROVIDERS.join(", ")}`);
46
+ }
45
47
  const capabilities = stringArray(value.capabilities, "agent.capabilities");
46
48
  if (capabilities.length === 0) throw new RecipeValidationError("agent.capabilities must not be empty");
47
49
  const approvalMode = value.approvalMode;
@@ -5,7 +5,7 @@ import { deleteAgentProfile, getAgentProfile, listAgentProfiles, setAgentProfile
5
5
  import { emitConfigChanged } from "../sse";
6
6
  import { error, isRootCredentialRequest, json, normalizeConfigPathParam, parseBody, type Handler } from "./_shared";
7
7
  import { getComponentAuth, hasComponentScope } from "../security";
8
- import { isRecord } from "agent-relay-sdk";
8
+ import { isRecord, isSpawnProvider } from "agent-relay-sdk";
9
9
  import { SPAWN_PROVIDERS, type AgentProfile, type SpawnProvider } from "../types";
10
10
 
11
11
  export const getAgentProfilesRoute: Handler = () => json(listAgentProfiles());
@@ -29,7 +29,7 @@ export const getResolvedAgentProfileRoute: Handler = (req, params) => {
29
29
  const entry = getAgentProfile(name);
30
30
  if (!entry) return error("agent profile not found", 404);
31
31
  const requestedProvider = new URL(req.url).searchParams.get("provider") ?? undefined;
32
- const profileProvider = entry.value.provider === "claude" || entry.value.provider === "codex" ? entry.value.provider : undefined;
32
+ const profileProvider = isSpawnProvider(entry.value.provider) ? entry.value.provider : undefined;
33
33
  const provider = optionalEnum(requestedProvider, "provider", SPAWN_PROVIDERS, profileProvider ?? "codex") as SpawnProvider;
34
34
  const profile = withResolvedProvisioning(entry.value, provider);
35
35
  return profile ? json({ provider, profile }) : error("agent profile not found", 404);
@@ -1,5 +1,5 @@
1
1
  // Auto-split from routes.ts (#299). Domain: agent-sessions.
2
- import { RELAY_TOKEN_HEADER, isRecord } from "agent-relay-sdk";
2
+ import { RELAY_TOKEN_HEADER, isRecord, isSpawnProvider } from "agent-relay-sdk";
3
3
  import { VALID_AGENT_ACTIONS, agentControlActionIcon, auditEvent, authAuditMetadata, authorizeAgentRoute, authorizeRoute, canViewAgentRoute, cleanAttachmentRefs, dashboardAttribution, emitCommand, error, json, metaString, parseBody, requestUserAddress, requestUserId, spawnRequestId, type AgentControlAction, type Handler } from "./_shared";
4
4
  import { arbitrateDrive, currentDriver, drivingMessage, releaseDrive, type DriveArbitration } from "../agent-drive-arbiter";
5
5
  import { ValidationError, getAgent, getAgentTimeline, getOrchestrator, markReady, mergeAgentMeta, sendMessage, withResolvedProvisioning } from "../db";
@@ -18,10 +18,9 @@ import { authContextFromRequest } from "../services/auth-context";
18
18
  import { ShutdownAuthError, ShutdownTargetError, shutdownAgent } from "../services/shutdown-agent";
19
19
  import { type AgentCard, type SpawnApprovalMode } from "../types";
20
20
  import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
21
+ import { getManifest } from "agent-relay-providers";
21
22
  import { relayToken } from "../config";
22
23
 
23
- const CLAUDE_RESUME_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
24
-
25
24
  // 409 when a second human tries to drive an agent another user is already driving (#394). The
26
25
  // blocked driver learns who holds the wheel so the UI can show "X is driving" + offer "request".
27
26
  function driveBlockedResponse(arb: Extract<DriveArbitration, { allowed: false }>): Response {
@@ -61,7 +60,7 @@ function agentIsControlEligible(agent: AgentCard): boolean {
61
60
 
62
61
  function agentCanReceiveControlAction(agent: AgentCard, action: AgentControlAction): boolean {
63
62
  if (!agentIsControlEligible(agent)) return false;
64
- if (action === "resume") return agentRuntimeProvider(agent) === "claude" && (agent.status === "offline" || agent.status === "stale");
63
+ if (action === "resume") return getManifest(agentRuntimeProvider(agent) ?? "")?.resume?.supported === true && (agent.status === "offline" || agent.status === "stale");
65
64
  // Interrupt only makes sense while the provider is mid-turn, and only if the
66
65
  // provider advertises it can be interrupted from the dashboard.
67
66
  if (action === "interrupt") return agent.providerCapabilities?.liveSession?.interrupt === true && agent.status === "busy";
@@ -109,11 +108,11 @@ function restartSpawnParamsForAgent(
109
108
  const requestedBy = opts.resumeId ? "dashboard-resume" : "dashboard-restart";
110
109
  if (policy) {
111
110
  const params = { ...buildManagedSpawnParams(policy.value, requestId, { createdBy: "managed-agent" }), agentId: agent.id, requestedBy };
112
- return opts.resumeId ? withClaudeResumeParams(params, opts.resumeId, agent.id) : params;
111
+ return opts.resumeId ? withResumeParams(params, opts.resumeId, agent.id) : params;
113
112
  }
114
113
 
115
114
  const provider = metaString(agent.meta, "provider");
116
- if (provider !== "claude" && provider !== "codex") return undefined;
115
+ if (!isSpawnProvider(provider)) return undefined;
117
116
  const cwd = metaString(agent.meta, "cwd") ?? orchestrator.baseDir;
118
117
  const label = metaString(agent.meta, "label") ?? agent.label;
119
118
  const approvalMode = metaString(agent.meta, "approvalMode") as SpawnApprovalMode | undefined;
@@ -155,48 +154,44 @@ function restartSpawnParamsForAgent(
155
154
  requestedBy,
156
155
  requestedAt: Date.now(),
157
156
  });
158
- return opts.resumeId ? withClaudeResumeParams(params, opts.resumeId, agent.id) : params;
157
+ return opts.resumeId ? withResumeParams(params, opts.resumeId, agent.id) : params;
159
158
  }
160
159
 
161
160
  function spawnRequestIdForRestart(): string {
162
161
  return generateSpawnRequestId();
163
162
  }
164
163
 
165
- function withClaudeResumeParams(params: Record<string, unknown>, resumeId: string, agentId: string): Record<string, unknown> {
164
+ function withResumeParams(params: Record<string, unknown>, resumeId: string, agentId: string): Record<string, unknown> {
166
165
  return {
167
166
  ...params,
168
- providerArgs: providerArgsWithClaudeResume(recordStringArray(params.providerArgs), resumeId),
167
+ providerArgs: providerArgsWithResume(metaStringArray(params as Record<string, unknown>, "providerArgs"), resumeId),
169
168
  resumeOfAgentId: agentId,
170
169
  claudeResumeId: resumeId,
171
170
  };
172
171
  }
173
172
 
174
- function providerArgsWithClaudeResume(args: string[], resumeId: string): string[] {
173
+ function providerArgsWithResume(args: string[], resumeId: string): string[] {
175
174
  const cleaned: string[] = [];
176
175
  for (let i = 0; i < args.length; i += 1) {
177
176
  const arg = args[i];
178
177
  if (!arg) continue;
179
- if (arg === "--resume") {
180
- i += 1;
181
- continue;
182
- }
178
+ if (arg === "--resume") { i += 1; continue; }
183
179
  if (arg.startsWith("--resume=")) continue;
184
180
  cleaned.push(arg);
185
181
  }
186
182
  return [...cleaned, "--resume", resumeId];
187
183
  }
188
184
 
189
- function recordStringArray(value: unknown): string[] {
190
- return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string" && item.trim().length > 0) : [];
191
- }
192
-
193
- function latestClaudeResumeIdForAgent(agent: AgentCard): string | undefined {
185
+ function latestResumeIdForAgent(agent: AgentCard): string | undefined {
186
+ const provider = agentRuntimeProvider(agent);
187
+ const resume = getManifest(provider ?? "")?.resume as { supported: boolean; idPattern?: string } | undefined;
188
+ if (!provider || !resume?.supported) return undefined;
189
+ const re = resume.idPattern ? new RegExp(`^${resume.idPattern}$`, "i") : undefined;
194
190
  for (const entry of getAgentTimeline(agent.id, { limit: 50 })) {
195
191
  const metadata = entry.metadata;
196
192
  if (!metadata) continue;
197
- const provider = metaString(metadata, "provider");
198
193
  const resumeId = metaString(metadata, "claudeResumeId");
199
- if (provider === "claude" && resumeId && CLAUDE_RESUME_ID_RE.test(resumeId)) return resumeId;
194
+ if (metaString(metadata, "provider") === provider && resumeId && (!re || re.test(resumeId))) return resumeId;
200
195
  }
201
196
  return undefined;
202
197
  }
@@ -270,9 +265,9 @@ export const postAgentAction: Handler = async (req, params) => {
270
265
  const metaTmuxSession = typeof agent.meta?.tmuxSession === "string" ? agent.meta.tmuxSession : undefined;
271
266
  const metaPolicyName = typeof agent.meta?.policyName === "string" ? agent.meta.policyName : undefined;
272
267
  const metaSpawnRequestId = typeof agent.meta?.spawnRequestId === "string" ? agent.meta.spawnRequestId : undefined;
273
- const resumeId = action === "resume" ? latestClaudeResumeIdForAgent(agent) : undefined;
268
+ const resumeId = action === "resume" ? latestResumeIdForAgent(agent) : undefined;
274
269
  if (action === "resume" && !orchestrator) return error("no online orchestrator available to resume agent", 409);
275
- if (action === "resume" && !resumeId) return error("no Claude resume id recorded for agent", 422);
270
+ if (action === "resume" && !resumeId) return error("no resume id recorded for agent", 422);
276
271
  const denied = authorizeRoute(req, {
277
272
  scope: "agent:write",
278
273
  resource: { agentId: agent.id, orchestratorId: orchestrator?.id, policyName: metaPolicyName, spawnRequestId: metaSpawnRequestId },
@@ -29,7 +29,7 @@ export const getProviderConfigsRoute: Handler = (req) => {
29
29
  migrateLocalProviderConfigs();
30
30
  const entries = listProviderConfigEntries(host);
31
31
  return json(Object.fromEntries(VALID_PROVIDER_CONFIGS.map((provider) => {
32
- return [provider, providerConfigPublic(entries[provider])];
32
+ return [provider, providerConfigPublic(entries[provider] ?? getProviderConfigEntry(provider, host))];
33
33
  })));
34
34
  };
35
35
 
@@ -5,7 +5,7 @@ import { getRoutingPolicy } from "./routing-policy-store";
5
5
  import { isPathWithinBase } from "./utils";
6
6
  import { McpNotFoundError } from "./mcp-errors";
7
7
  import { DEFAULT_QUOTA_PREFER_SLACK_AT, DEFAULT_QUOTA_TARGET_UTILIZATION, quotaPaceMetrics } from "agent-relay-sdk/provider-quota";
8
- import type { ProviderQuotaRoutingConfig } from "agent-relay-sdk";
8
+ import { isSpawnProvider, type ProviderQuotaRoutingConfig } from "agent-relay-sdk";
9
9
  import type { Orchestrator, ProviderCatalogSummary, ProviderQuotaBurnRole, ProviderQuotaBurnWindow, ProviderQuotaRecord, QuotaState, SpawnProvider } from "./types";
10
10
 
11
11
  // #308 — spawn discovery + target selection. Home for the live capability matrix that
@@ -159,7 +159,7 @@ export function providerQuotaAdvisories(now = Date.now()): Map<SpawnProvider, Sp
159
159
  const policy = getRoutingPolicy();
160
160
  const byProvider = new Map<SpawnProvider, ProviderQuotaRecord[]>();
161
161
  for (const record of listProviderQuotas({ historyLimit: 0, now }).quotas) {
162
- if (record.provider !== "claude" && record.provider !== "codex") continue;
162
+ if (!isSpawnProvider(record.provider)) continue;
163
163
  const provider = record.provider;
164
164
  byProvider.set(provider, [...(byProvider.get(provider) ?? []), record]);
165
165
  }
@@ -231,8 +231,7 @@ function spawnQuotaWindow(
231
231
  // recency-weighted rate projects no early wall) replaces the blunt utilization floor, which
232
232
  // used to hide a legitimate low-util-high-burst alarm. Operators may still opt back in.
233
233
  const utilizationFloor = providerCfg?.burnPenaltyUtilizationFloor ?? 0;
234
- const rawPenalty = (earlyFraction ?? 0) * weight;
235
- const penalty = quotaWindow.utilization < utilizationFloor ? 0 : rawPenalty;
234
+ const pacePenalty = (earlyFraction ?? 0) * weight;
236
235
 
237
236
  // #760 — re-derive the pace projection against the PER-PROVIDER target (the stored burn used
238
237
  // the default). Slack is the symmetric underburn signal; prefer flags a window with no
@@ -249,6 +248,19 @@ function spawnQuotaWindow(
249
248
  projectedUtilAtReset = pace.projectedUtilAtReset;
250
249
  slack = pace.slack;
251
250
  }
251
+
252
+ // #798 — position penalty: an idle provider whose (de-biased) projection lands AT/OVER target is
253
+ // already over-extended, but its forward-wall pace penalty is 0 (no burn) — so without this the
254
+ // band would flip to "normal" and route big jobs onto a near-exhausted provider. Derive a STABLE
255
+ // penalty from the overshoot magnitude (slack < 0 ⇒ projectedUtilAtReset − target), decoupled
256
+ // from the jittery forward-burn term. Gated to idle (observedRatePerMs collapsed to 0): an ACTIVE
257
+ // provider is still governed entirely by the pace penalty (effective = max of the two), so this
258
+ // adds a floor for the parked-high case WITHOUT changing active routing. Under-target idle ⇒ 0.
259
+ const idle = burnWindow.observedRatePerMs !== null && burnWindow.observedRatePerMs <= 0;
260
+ const overshoot = slack !== null ? Math.max(0, -slack) : 0;
261
+ const positionPenalty = idle ? Math.min(1, overshoot / Math.max(1e-9, 1 - target)) * weight : 0;
262
+ const rawPenalty = Math.max(pacePenalty, positionPenalty);
263
+ const penalty = quotaWindow.utilization < utilizationFloor ? 0 : rawPenalty;
252
264
  const prefer = !blockedNow && penalty === 0
253
265
  && quotaPenaltyBandFor(penalty) === "normal"
254
266
  && slack !== null && slack >= preferSlackAt;
@@ -349,6 +361,22 @@ function quotaPenaltyBandFor(
349
361
  return "normal";
350
362
  }
351
363
 
364
+ // #798 — classify a (penalty, slack) signal into a band. The single home for the
365
+ // penalty-ladder + prefer-overlay mapping, shared by getProviderBandInfo's per-window band and the
366
+ // band-transition notifier, which re-classifies the EMA-smoothed signal before thresholding. The
367
+ // prefer overlay mirrors spawnQuotaWindow: a normal-band window projected ≥ preferSlackAt under
368
+ // target reads "prefer".
369
+ export function quotaBandFromSignal(
370
+ penalty: number,
371
+ slack: number | null,
372
+ quotaBands: Partial<{ cautionAt: number; avoidLargeAt: number; hardAvoidAt: number }> | undefined,
373
+ preferSlackAt: number,
374
+ ): QuotaBand {
375
+ const base = quotaPenaltyBandFor(penalty, quotaBands);
376
+ if (base === "normal" && slack !== null && slack >= preferSlackAt) return "prefer";
377
+ return base;
378
+ }
379
+
352
380
  function recommendedAction(input: { quotaPenaltyBand: SpawnQuotaBurnBand; blockedNow: boolean; prefer: boolean; slack: number | null; windows: SpawnQuotaBurnWindow[] }): string {
353
381
  if (input.blockedNow) {
354
382
  const hasSevenDayBlock = input.windows.some((window) => window.role === "7d" && window.blockedNow);
package/src/sse.ts CHANGED
@@ -1,4 +1,4 @@
1
- import { getAgent, getOrchestrator, matchingDeliveryAgents, onWorkspaceChange, sendMessageWithResult } from "./db";
1
+ import { getAgent, getOrchestrator, matchingDeliveryAgents, onWorkspaceChange, sendMessageWithResult, setChannelSubscriberProbe } from "./db";
2
2
  import { projectAgentStatusData } from "./agent-card-projection";
3
3
  import { emitRelayEvent, subscribeRelayEvents, type RelayEvent } from "./events";
4
4
  import { messageMatchesAgent, targetMatchesAgent } from "./agent-ref";
@@ -20,6 +20,17 @@ const SSE_RETRY_MS = 5_000;
20
20
 
21
21
  subscribeRelayEvents((event) => fanout(event));
22
22
 
23
+ // #797 — let the db layer derive outbound-channel liveness from live SSE subscriptions.
24
+ // A channel agent is "subscribed" iff the channels-host daemon holds an open
25
+ // GET /api/events?for=<channelAgentId> stream (createSSEStream stores `for` as agentId).
26
+ export function hasActiveSubscriber(agentId: string): boolean {
27
+ for (const conn of connections.values()) {
28
+ if (conn.agentId === agentId) return true;
29
+ }
30
+ return false;
31
+ }
32
+ setChannelSubscriberProbe(hasActiveSubscriber);
33
+
23
34
  export function createSSEStream(agentId: string | null): Response {
24
35
  let connId: string;
25
36