agent-relay-server 0.99.1 → 0.101.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 (111) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +5 -2
  3. package/public/assets/{MessageBubble-DiorZXyB.js → MessageBubble-BEjK1-bi.js} +3 -3
  4. package/public/assets/{MessageBubble-DiorZXyB.js.map → MessageBubble-BEjK1-bi.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-DY1p8v4p.js → PlanGraphPanel-AqFEg7Yg.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-DY1p8v4p.js.map → PlanGraphPanel-AqFEg7Yg.js.map} +1 -1
  7. package/public/assets/{activity-rIxqUtFC.js → activity-D5EZw4bj.js} +2 -2
  8. package/public/assets/{activity-rIxqUtFC.js.map → activity-D5EZw4bj.js.map} +1 -1
  9. package/public/assets/agent-profiles-CSISRm_L.js +2 -0
  10. package/public/assets/agent-profiles-CSISRm_L.js.map +1 -0
  11. package/public/assets/agents-szyYnkgD.js +2 -0
  12. package/public/assets/agents-szyYnkgD.js.map +1 -0
  13. package/public/assets/{analytics-CUJbpoA7.js → analytics-KzLQfGMO.js} +2 -2
  14. package/public/assets/{analytics-CUJbpoA7.js.map → analytics-KzLQfGMO.js.map} +1 -1
  15. package/public/assets/{automation-B0cyCT5l.js → automation-DVSq4A1b.js} +2 -2
  16. package/public/assets/{automation-B0cyCT5l.js.map → automation-DVSq4A1b.js.map} +1 -1
  17. package/public/assets/branch-state-badge-DgwkJ2rN.js +2 -0
  18. package/public/assets/branch-state-badge-DgwkJ2rN.js.map +1 -0
  19. package/public/assets/{channels-Tg8l4n5J.js → channels-Dh_r7SnM.js} +2 -2
  20. package/public/assets/{channels-Tg8l4n5J.js.map → channels-Dh_r7SnM.js.map} +1 -1
  21. package/public/assets/chat-patMKjsi.js +2 -0
  22. package/public/assets/chat-patMKjsi.js.map +1 -0
  23. package/public/assets/{connectors-B5wbjyUd.js → connectors-DVKBHNRV.js} +2 -2
  24. package/public/assets/{connectors-B5wbjyUd.js.map → connectors-DVKBHNRV.js.map} +1 -1
  25. package/public/assets/{formatted-body-impl-ZwgTH3jh.js → formatted-body-impl-Csr2DuEk.js} +2 -2
  26. package/public/assets/{formatted-body-impl-ZwgTH3jh.js.map → formatted-body-impl-Csr2DuEk.js.map} +1 -1
  27. package/public/assets/index-D3woTUwp.js +23 -0
  28. package/public/assets/index-D3woTUwp.js.map +1 -0
  29. package/public/assets/{insights-C3mlXZdd.js → insights-D2_Qry06.js} +2 -2
  30. package/public/assets/{insights-C3mlXZdd.js.map → insights-D2_Qry06.js.map} +1 -1
  31. package/public/assets/{integrations-sYKtupOq.js → integrations-AW8b4h7T.js} +2 -2
  32. package/public/assets/{integrations-sYKtupOq.js.map → integrations-AW8b4h7T.js.map} +1 -1
  33. package/public/assets/maintenance-Dm56u8F9.js +2 -0
  34. package/public/assets/{maintenance-DL1lnqmG.js.map → maintenance-Dm56u8F9.js.map} +1 -1
  35. package/public/assets/{managed-agents-7A6WH4Rn.js → managed-agents-DpX-cHsm.js} +2 -2
  36. package/public/assets/{managed-agents-7A6WH4Rn.js.map → managed-agents-DpX-cHsm.js.map} +1 -1
  37. package/public/assets/{markdown-preview-impl-THat8xv0.js → markdown-preview-impl-Df6EWwbg.js} +2 -2
  38. package/public/assets/{markdown-preview-impl-THat8xv0.js.map → markdown-preview-impl-Df6EWwbg.js.map} +1 -1
  39. package/public/assets/{memory-C349UJe9.js → memory-b2AgvV7H.js} +2 -2
  40. package/public/assets/{memory-C349UJe9.js.map → memory-b2AgvV7H.js.map} +1 -1
  41. package/public/assets/{messages-DXoLFj2P.js → messages-C5yDD_oq.js} +2 -2
  42. package/public/assets/{messages-DXoLFj2P.js.map → messages-C5yDD_oq.js.map} +1 -1
  43. package/public/assets/{orchestrators-CTXu4jAn.js → orchestrators-D3LecmEw.js} +2 -2
  44. package/public/assets/{orchestrators-CTXu4jAn.js.map → orchestrators-D3LecmEw.js.map} +1 -1
  45. package/public/assets/{overview-dWiN6TyN.js → overview-CnGkrBNe.js} +2 -2
  46. package/public/assets/{overview-dWiN6TyN.js.map → overview-CnGkrBNe.js.map} +1 -1
  47. package/public/assets/{pairs-KIXektDv.js → pairs-DXUdZOqn.js} +2 -2
  48. package/public/assets/{pairs-KIXektDv.js.map → pairs-DXUdZOqn.js.map} +1 -1
  49. package/public/assets/{projects-BPI8z55X.js → projects-1h41atqJ.js} +2 -2
  50. package/public/assets/{projects-BPI8z55X.js.map → projects-1h41atqJ.js.map} +1 -1
  51. package/public/assets/{registry-C-Y41yGB.js → registry-BeGP37ot.js} +2 -2
  52. package/public/assets/{registry-C-Y41yGB.js.map → registry-BeGP37ot.js.map} +1 -1
  53. package/public/assets/{security-BUymkwuj.js → security-BMh_y43L.js} +2 -2
  54. package/public/assets/{security-BUymkwuj.js.map → security-BMh_y43L.js.map} +1 -1
  55. package/public/assets/{select-DV25WRP5.js → select-Cvi5OHz0.js} +2 -2
  56. package/public/assets/{select-DV25WRP5.js.map → select-Cvi5OHz0.js.map} +1 -1
  57. package/public/assets/settings-DKSEwIVG.js +6 -0
  58. package/public/assets/{settings-C1En58bx.js.map → settings-DKSEwIVG.js.map} +1 -1
  59. package/public/assets/store-Ch-QzxPq.js +9 -0
  60. package/public/assets/store-Ch-QzxPq.js.map +1 -0
  61. package/public/assets/{tasks-BkpnKA63.js → tasks-BybIAX-y.js} +2 -2
  62. package/public/assets/{tasks-BkpnKA63.js.map → tasks-BybIAX-y.js.map} +1 -1
  63. package/public/assets/{teams-D-UYp7yH.js → teams-CMteOToR.js} +2 -2
  64. package/public/assets/{teams-D-UYp7yH.js.map → teams-CMteOToR.js.map} +1 -1
  65. package/public/assets/{terminal-viewer-impl-Bif-tkvD.js → terminal-viewer-impl-DanOi7lh.js} +2 -2
  66. package/public/assets/{terminal-viewer-impl-Bif-tkvD.js.map → terminal-viewer-impl-DanOi7lh.js.map} +1 -1
  67. package/public/assets/{user-avatar--czLv_AJ.js → user-avatar-DG629icI.js} +2 -2
  68. package/public/assets/{user-avatar--czLv_AJ.js.map → user-avatar-DG629icI.js.map} +1 -1
  69. package/public/assets/{work-queue-DN4oTe7w.js → work-queue-DqW80DIy.js} +2 -2
  70. package/public/assets/{work-queue-DN4oTe7w.js.map → work-queue-DqW80DIy.js.map} +1 -1
  71. package/public/assets/{workspaces-BolccWZy.js → workspaces-CccVI6lA.js} +2 -2
  72. package/public/assets/{workspaces-BolccWZy.js.map → workspaces-CccVI6lA.js.map} +1 -1
  73. package/public/index.html +2 -2
  74. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  75. package/runner/plugins/claude/hooks/kill-guard.sh +300 -0
  76. package/runner/plugins/claude/hooks/read-only-bash-guard.sh +38 -140
  77. package/runner/src/adapter.ts +2 -2
  78. package/src/agent-death-diagnosis.ts +12 -3
  79. package/src/bus.ts +6 -45
  80. package/src/config-store.ts +13 -9
  81. package/src/db/provider-quota-burn.ts +79 -6
  82. package/src/issue-tracker/adapter.ts +11 -1
  83. package/src/issue-tracker/github.ts +83 -2
  84. package/src/issue-tracker/index.ts +1 -0
  85. package/src/lifecycle-manager.ts +9 -22
  86. package/src/provider-state.ts +9 -18
  87. package/src/provider-timeline-event.ts +70 -0
  88. package/src/quota-advisory.ts +4 -7
  89. package/src/quota-band-transition.ts +101 -11
  90. package/src/routes/agent-sessions.ts +4 -2
  91. package/src/services/issue-lifecycle.ts +54 -8
  92. package/src/services/provider-state-audit.ts +3 -3
  93. package/src/services/rate-limit-resume.ts +6 -4
  94. package/src/services/register-agent.ts +2 -1
  95. package/src/settings/registry.ts +34 -0
  96. package/src/spawn-targets.ts +80 -10
  97. package/src/sse.ts +5 -4
  98. package/public/assets/agent-profiles-Cu-VkJm4.js +0 -2
  99. package/public/assets/agent-profiles-Cu-VkJm4.js.map +0 -1
  100. package/public/assets/agents-dRHOBDUe.js +0 -2
  101. package/public/assets/agents-dRHOBDUe.js.map +0 -1
  102. package/public/assets/branch-state-badge-CNko2_BL.js +0 -2
  103. package/public/assets/branch-state-badge-CNko2_BL.js.map +0 -1
  104. package/public/assets/chat-jit-e6S_.js +0 -2
  105. package/public/assets/chat-jit-e6S_.js.map +0 -1
  106. package/public/assets/index-GoC6YrKn.js +0 -23
  107. package/public/assets/index-GoC6YrKn.js.map +0 -1
  108. package/public/assets/maintenance-DL1lnqmG.js +0 -2
  109. package/public/assets/settings-C1En58bx.js +0 -6
  110. package/public/assets/store-pKbf_WYd.js +0 -9
  111. package/public/assets/store-pKbf_WYd.js.map +0 -1
@@ -7,6 +7,13 @@
7
7
  // prevents rapid flapping from bursty burn rates. Low-confidence windows
8
8
  // (burnRatio: null) are skipped entirely.
9
9
  //
10
+ // #756 — asymmetric hysteresis deadband: recovery (improving) direction
11
+ // requires the penalty to drop HYSTERESIS_MARGIN below the band entry
12
+ // threshold before the transition is recognised, so a penalty parked on a
13
+ // boundary can't ping-pong. Recovery notifications are also rate-limited to
14
+ // at most one per RECOVERY_MIN_INTERVAL_MS — degradation is the actionable
15
+ // signal; fleet-wake on recovery churn is suppressed.
16
+ //
10
17
  // Called from handleProviderQuotaAdvisory in quota-advisory.ts so the check
11
18
  // runs on the same event hook as the utilization-threshold advisory — no
12
19
  // parallel delivery channel.
@@ -14,15 +21,27 @@ import { emitRelayEvent } from "./events";
14
21
  import { routeNotification } from "./notification-router";
15
22
  import { getSpawnCapableAgentIds } from "./routing-policy-push";
16
23
  import { getProviderBandInfo } from "./spawn-targets";
17
- import type { ProviderBandWindow, QuotaBand } from "./spawn-targets";
24
+ import type { ProviderBandInfo, ProviderBandWindow, QuotaBand } from "./spawn-targets";
18
25
  import type { ProviderQuotaAdvisoryInput } from "./db/provider-quotas";
19
26
 
20
27
  const DEBOUNCE_SAMPLES = 3;
28
+ // Penalty must drop this far below the band entry threshold before recovery is recognised.
29
+ // #760: widened 0.05 → 0.10. Live evidence (Claude 7d parked at avoidLargeAt with burn×1.4)
30
+ // showed the 0.05 deadband was too tight for real penalty jitter — it still ping-ponged
31
+ // avoid-large → caution → avoid-large. Degradation stays faster-to-fire than recovery
32
+ // (recovery alone carries the rate-limit), so the actionable direction is unaffected.
33
+ const HYSTERESIS_MARGIN = 0.1;
34
+ // #760 — slack must fall this far below preferSlackAt before "prefer" is released, so a provider
35
+ // parked near the prefer boundary doesn't ping-pong normal↔prefer.
36
+ const PREFER_HYSTERESIS_MARGIN = 0.05;
37
+ // Minimum ms between recovery notifications — degradation fires immediately; recovery is rate-limited.
38
+ const RECOVERY_MIN_INTERVAL_MS = 5 * 60_000;
21
39
 
22
40
  interface TransitionState {
23
41
  lastNotifiedBand: QuotaBand;
24
42
  pendingBand: QuotaBand | null;
25
43
  pendingCount: number;
44
+ lastTransitionAt: number;
26
45
  }
27
46
 
28
47
  const bandState = new Map<string, TransitionState>();
@@ -31,36 +50,63 @@ export function _resetBandTransitionStateForTesting(): void {
31
50
  bandState.clear();
32
51
  }
33
52
 
53
+ // Test hook: supply a controlled band-info function to exercise hysteresis logic
54
+ // without a full DB setup. Set to null to restore the real implementation.
55
+ let _bandInfoSupplier: ((provider: string, now: number) => ProviderBandInfo | undefined) | null = null;
56
+ export function _setBandInfoSupplierForTesting(fn: (provider: string, now: number) => ProviderBandInfo | undefined): void {
57
+ _bandInfoSupplier = fn;
58
+ }
59
+ export function _clearBandInfoSupplierForTesting(): void {
60
+ _bandInfoSupplier = null;
61
+ }
62
+
34
63
  export function processBandTransitions(input: ProviderQuotaAdvisoryInput): void {
35
- const info = getProviderBandInfo(input.provider, input.now);
64
+ const info = (_bandInfoSupplier ?? getProviderBandInfo)(input.provider, input.now);
36
65
  if (!info) return;
37
66
 
38
67
  for (const window of info.windows) {
39
68
  if (window.confidence === "low" || window.burnRatio === null) continue;
40
69
 
41
70
  const key = `${input.provider}:${window.role}`;
42
- const state: TransitionState = bandState.get(key) ?? { lastNotifiedBand: "normal", pendingBand: null, pendingCount: 0 };
43
- const currentBand = window.band;
44
-
45
- if (currentBand === state.lastNotifiedBand) {
71
+ const state: TransitionState = bandState.get(key) ?? {
72
+ lastNotifiedBand: "normal",
73
+ pendingBand: null,
74
+ pendingCount: 0,
75
+ lastTransitionAt: 0,
76
+ };
77
+
78
+ // #756: asymmetric hysteresis — recovery 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);
82
+
83
+ if (effectiveBand === state.lastNotifiedBand) {
46
84
  if (state.pendingBand !== null) {
47
85
  bandState.set(key, { ...state, pendingBand: null, pendingCount: 0 });
48
86
  }
49
87
  continue;
50
88
  }
51
89
 
52
- if (currentBand === state.pendingBand) {
90
+ if (effectiveBand === state.pendingBand) {
53
91
  state.pendingCount += 1;
54
92
  } else {
55
- state.pendingBand = currentBand;
93
+ state.pendingBand = effectiveBand;
56
94
  state.pendingCount = 1;
57
95
  }
58
96
 
59
97
  if (state.pendingCount >= DEBOUNCE_SAMPLES) {
98
+ // #756: rate-limit recovery — it is not an actionable signal for coordinators.
99
+ const isRecovery = bandRank(effectiveBand) < bandRank(state.lastNotifiedBand);
100
+ if (isRecovery && input.now - state.lastTransitionAt < RECOVERY_MIN_INTERVAL_MS) {
101
+ bandState.set(key, state);
102
+ continue;
103
+ }
104
+
60
105
  const previousBand = state.lastNotifiedBand;
61
- state.lastNotifiedBand = currentBand;
106
+ state.lastNotifiedBand = effectiveBand;
62
107
  state.pendingBand = null;
63
108
  state.pendingCount = 0;
109
+ state.lastTransitionAt = input.now;
64
110
  bandState.set(key, state);
65
111
  emitBandTransition({
66
112
  provider: input.provider,
@@ -75,6 +121,43 @@ export function processBandTransitions(input: ProviderQuotaAdvisoryInput): void
75
121
  }
76
122
  }
77
123
 
124
+ // Asymmetric hysteresis: degradation uses the normal (entry) threshold; recovery requires
125
+ // the penalty to fall HYSTERESIS_MARGIN below the entry threshold of the last notified band.
126
+ // #760: the "prefer" boundary uses a slack deadband instead (penalty is 0 throughout prefer).
127
+ function applyHysteresis(
128
+ rawBand: QuotaBand,
129
+ penalty: number,
130
+ slack: number | null,
131
+ lastNotifiedBand: QuotaBand,
132
+ quotaBands: { cautionAt: number; avoidLargeAt: number; hardAvoidAt: number },
133
+ preferSlackAt: number,
134
+ ): QuotaBand {
135
+ // #760 — hold "prefer" until slack falls PREFER_HYSTERESIS_MARGIN below the entry threshold;
136
+ // a real over-pace penalty (rawBand worse than normal) exits prefer immediately.
137
+ if (lastNotifiedBand === "prefer") {
138
+ if (rawBand === "prefer") return "prefer";
139
+ if (rawBand === "normal" && slack !== null && slack >= preferSlackAt - PREFER_HYSTERESIS_MARGIN) return "prefer";
140
+ return rawBand;
141
+ }
142
+ // #760 — entering prefer: the slack entry threshold is already applied upstream (window.prefer).
143
+ // Debounce + recovery rate-limit downstream keep prefer from fleet-spamming.
144
+ if (rawBand === "prefer") return "prefer";
145
+ if (bandRank(rawBand) >= bandRank(lastNotifiedBand)) return rawBand;
146
+ const exitThreshold = bandEntryThreshold(lastNotifiedBand, quotaBands) - HYSTERESIS_MARGIN;
147
+ if (penalty >= exitThreshold) return lastNotifiedBand;
148
+ return rawBand;
149
+ }
150
+
151
+ function bandEntryThreshold(
152
+ band: QuotaBand,
153
+ quotaBands: { cautionAt: number; avoidLargeAt: number; hardAvoidAt: number },
154
+ ): number {
155
+ if (band === "hard-avoid") return quotaBands.hardAvoidAt;
156
+ if (band === "avoid-large") return quotaBands.avoidLargeAt;
157
+ if (band === "caution") return quotaBands.cautionAt;
158
+ return 0;
159
+ }
160
+
78
161
  function emitBandTransition(input: {
79
162
  provider: string;
80
163
  window: ProviderBandWindow;
@@ -95,6 +178,9 @@ function emitBandTransition(input: {
95
178
  utilization: window.utilization,
96
179
  burnRatio: window.burnRatio,
97
180
  projectedWallAt: window.projectedWallAt,
181
+ // #760 — carry the symmetric underburn signal so coordinators see "prefer", not just "avoid".
182
+ slack: window.slack,
183
+ prefer: window.band === "prefer",
98
184
  recommendedAction,
99
185
  };
100
186
 
@@ -134,11 +220,15 @@ function buildBody(input: {
134
220
  const proj = window.projectedWallAt !== null
135
221
  ? ` (wall ~${new Date(window.projectedWallAt).toISOString()})`
136
222
  : "";
137
- return `${providerLabel(provider)} ${window.role} quota band: ${previousBand} ${window.band}. Utilization ${util}%${burn}${proj}. ${recommendedAction}`;
223
+ // #760 for the prefer band there is no early wall; surface the projected unused headroom instead.
224
+ const slackHint = window.band === "prefer" && window.slack !== null ? ` slack ${Math.round(window.slack * 100)}%` : "";
225
+ return `${providerLabel(provider)} ${window.role} quota band: ${previousBand} → ${window.band}. Utilization ${util}%${burn}${proj}${slackHint}. ${recommendedAction}`;
138
226
  }
139
227
 
140
228
  function bandRank(band: QuotaBand): number {
141
- const ranks: Record<QuotaBand, number> = { "normal": 0, "caution": 1, "avoid-large": 2, "hard-avoid": 3 };
229
+ // #760 "prefer" is the best state, ranked below "normal", so normal→prefer reads as a
230
+ // recovery (rate-limited; no fleet-spam) and prefer→normal reads as a degradation.
231
+ const ranks: Record<QuotaBand, number> = { "prefer": 0, "normal": 1, "caution": 2, "avoid-large": 3, "hard-avoid": 4 };
142
232
  return ranks[band];
143
233
  }
144
234
 
@@ -13,6 +13,7 @@ import { emitAgentStatus, emitDashboardPresence, emitNewMessage } from "../sse";
13
13
  import { getAgentProfile, getSpawnPolicy } from "../config-store";
14
14
  import { listManagedOrchestratorsForAgent } from "../orchestrator-lookup";
15
15
  import { runnerRuntimeTokenEnv } from "../runtime-tokens";
16
+ import { agentProviderState } from "../provider-state";
16
17
  import { authContextFromRequest } from "../services/auth-context";
17
18
  import { ShutdownAuthError, ShutdownTargetError, shutdownAgent } from "../services/shutdown-agent";
18
19
  import { type AgentCard, type SpawnApprovalMode } from "../types";
@@ -414,8 +415,9 @@ export const postAgentPermissionDecision: Handler = async (req, params) => {
414
415
  const arb = arbitrateDrive(requestUserId(req), agent.id);
415
416
  if (!arb.allowed) return driveBlockedResponse(arb);
416
417
  if (!agentIsControlEligible(agent)) return error("agent cannot receive permission decisions", 400);
417
- const pendingApproval = isRecord(agent.meta?.providerState) && isRecord(agent.meta.providerState.pendingApproval)
418
- ? agent.meta.providerState.pendingApproval
418
+ const providerState = agentProviderState(agent);
419
+ const pendingApproval = isRecord(providerState?.pendingApproval)
420
+ ? providerState.pendingApproval
419
421
  : null;
420
422
  const pendingId = typeof pendingApproval?.id === "string" ? pendingApproval.id : "";
421
423
  if (pendingId && pendingId !== approvalId) return error("approval request is no longer current", 409);
@@ -15,7 +15,7 @@ import { defaultIssueRepo } from "../config";
15
15
  // existing importers and tests keep their entry point.
16
16
  import { parseOwnerRepoFromRemote, repoFromGitRemote, __resetRemoteRepoCacheForTests } from "./git-remote";
17
17
  export { parseOwnerRepoFromRemote, repoFromGitRemote, __resetRemoteRepoCacheForTests };
18
- import type { IssueTrackerAdapter } from "../issue-tracker";
18
+ import type { IssueTrackerAdapter, IssueTrackerIssueSummary } from "../issue-tracker";
19
19
  import { recordAndEmitIssueActivity } from "./issue-activity";
20
20
  import type { IssueAssociationEntityType, IssueProvider, IssueRef, WorkspaceRecord } from "../types";
21
21
 
@@ -208,14 +208,41 @@ function closeAuditComment(input: LandedIssueLifecycleInput): string {
208
208
  return `Closed by \`${commit}\` on merge to \`main\` - reopen if release surfaces more work.`;
209
209
  }
210
210
 
211
- function issueLabel(ref: IssueRef, parent: IssueRef): string {
211
+ function issueLabel(ref: Pick<IssueRef, "repo" | "externalKey">, parent: IssueRef): string {
212
212
  return ref.repo === parent.repo ? `#${ref.externalKey}` : `${ref.repo}#${ref.externalKey}`;
213
213
  }
214
214
 
215
- function backlinkProgressComment(input: LandedIssueLifecycleInput, parent: IssueRef, openSubIssues: IssueRef[]): string {
215
+ function openSubIssuesComment(input: LandedIssueLifecycleInput, parent: IssueRef, openSubIssues: IssueTrackerIssueSummary[]): string {
216
216
  const commit = input.mergedSha ? input.mergedSha.slice(0, 12) : input.workspace.branch ?? input.workspace.id;
217
217
  const childList = openSubIssues.map((ref) => issueLabel(ref, parent)).join(", ");
218
- return `Linked landed slice \`${commit}\` to this parent issue, but Agent Relay left it open because these sub-issues are still open: ${childList}.`;
218
+ return `Referenced by landed commit \`${commit}\`; left open because these sub-issues are still open: ${childList}.`;
219
+ }
220
+
221
+ function issueSummaryKey(ref: Pick<IssueTrackerIssueSummary, "provider" | "repo" | "externalKey">): string {
222
+ return `${ref.provider}:${ref.repo}#${ref.externalKey}`;
223
+ }
224
+
225
+ async function listOpenSubIssuesForLand(issueRef: IssueRef, opts: IssueLifecycleOptions): Promise<IssueTrackerIssueSummary[]> {
226
+ const open = new Map<string, IssueTrackerIssueSummary>();
227
+ const adapter = adapterFor(issueRef, opts);
228
+ if (adapter.listOpenSubIssues) {
229
+ for (const ref of await adapter.listOpenSubIssues(issueRef)) {
230
+ if (ref.status === "open") open.set(issueSummaryKey(ref), ref);
231
+ }
232
+ }
233
+ for (const item of listIssuesForEntity("epic", issueRef.id)) {
234
+ const ref = item.issueRef;
235
+ if (ref.status !== "open") continue;
236
+ open.set(issueSummaryKey(ref), {
237
+ provider: ref.provider,
238
+ repo: ref.repo,
239
+ externalKey: ref.externalKey,
240
+ url: ref.url,
241
+ title: ref.title,
242
+ status: "open",
243
+ });
244
+ }
245
+ return [...open.values()];
219
246
  }
220
247
 
221
248
  async function reconcileClosedIssueRefBeforeSkip(issueRef: IssueRef, opts: IssueLifecycleOptions): Promise<{
@@ -250,11 +277,30 @@ async function closeIssueRefForLand(
250
277
  if (reconciled.liveStatus !== "open") return { issueRef: latest, action: "skipped", reason: "already-closed", landKey: key };
251
278
  }
252
279
 
253
- const openSubIssues = source === "backlink"
254
- ? listIssuesForEntity("epic", latest.id).map((item) => item.issueRef).filter((ref) => ref.status === "open")
255
- : [];
280
+ let openSubIssues: IssueTrackerIssueSummary[];
281
+ try {
282
+ openSubIssues = await listOpenSubIssuesForLand(latest, opts);
283
+ } catch (error) {
284
+ const message = `sub-issue lookup failed: ${errMessage(error)}`;
285
+ upsertIssueLifecycleAction({
286
+ issueRefId: latest.id,
287
+ action: "close",
288
+ landKey: key,
289
+ workspaceId: input.workspace.id,
290
+ branch: input.workspace.branch,
291
+ baseRef: input.workspace.baseRef ?? "main",
292
+ commitSha: input.mergedSha,
293
+ commitSubject: input.subject,
294
+ agentId: input.workspace.ownerAgentId,
295
+ source,
296
+ status: "failed",
297
+ error: message,
298
+ });
299
+ logFailure(opts, `issue lifecycle sub-issue lookup failed for ${latest.id} on ${key}: ${errMessage(error)}`);
300
+ return { issueRef: latest, action: "failed", error: message, landKey: key };
301
+ }
256
302
  if (openSubIssues.length > 0) {
257
- const comment = backlinkProgressComment(input, latest, openSubIssues);
303
+ const comment = openSubIssuesComment(input, latest, openSubIssues);
258
304
  try {
259
305
  await adapterFor(latest, opts).comment(latest, comment);
260
306
  } catch (error) {
@@ -1,6 +1,6 @@
1
1
  import { createActivityEvent } from "../db";
2
2
  import { routeNotification } from "../notification-router";
3
- import { agentProviderState } from "../provider-state";
3
+ import { agentProviderState, type ProviderState } from "../provider-state";
4
4
  import type { AgentCard } from "../types";
5
5
 
6
6
  export function auditProviderStateTransition(agentId: string, before: AgentCard | null | undefined, after: AgentCard | null | undefined): void {
@@ -46,7 +46,7 @@ export function auditProviderStateTransition(agentId: string, before: AgentCard
46
46
  }
47
47
  }
48
48
 
49
- function providerStateKey(state: Record<string, unknown> | null): string | null {
49
+ function providerStateKey(state: ProviderState | null): string | null {
50
50
  if (!state) return null;
51
- return [state.state, typeof state.reason === "string" ? state.reason : ""].join(":");
51
+ return [state.state, state.reason ?? ""].join(":");
52
52
  }
@@ -6,6 +6,7 @@ import {
6
6
  agentProviderState,
7
7
  isRateLimitHold,
8
8
  isTransientRateLimitHold,
9
+ type ProviderState,
9
10
  rateLimitResumeAt,
10
11
  transientRateLimitAttempt,
11
12
  transientRateLimitExhausted,
@@ -46,7 +47,7 @@ export function resetRateLimitResumeTrackerForTests(): void {
46
47
  const RESUME_BODY =
47
48
  "Your provider limit/overload hold is ready to retry — resuming. Continue the task you were working on when the provider interrupted you. If you had already finished, no action is needed.";
48
49
 
49
- function nextTransientState(state: Record<string, unknown>, now: number, attempts: number): Record<string, unknown> {
50
+ function nextTransientState(state: ProviderState, now: number, attempts: number): ProviderState {
50
51
  const nextAttempts = attempts + 1;
51
52
  const nextDelay = TRANSIENT_RATE_LIMIT_BACKOFF_MS[nextAttempts];
52
53
  return {
@@ -58,7 +59,7 @@ function nextTransientState(state: Record<string, unknown>, now: number, attempt
58
59
  };
59
60
  }
60
61
 
61
- function notifyTransientExhausted(agent: { id: string; label?: string | null; spawnedBy?: string | null }, state: Record<string, unknown>, now: number): void {
62
+ function notifyTransientExhausted(agent: { id: string; label?: string | null; spawnedBy?: string | null }, state: ProviderState, now: number): void {
62
63
  mergeAgentMeta(agent.id, { providerState: { ...state, exhaustedAt: now, updatedAt: now } });
63
64
  if (!agent.spawnedBy) return;
64
65
  routeNotification({
@@ -88,6 +89,7 @@ export function resumeRateLimitedAgents(): Record<string, unknown> {
88
89
  seen.add(agent.id);
89
90
 
90
91
  const state = agentProviderState(agent);
92
+ if (!state) continue;
91
93
  const resumeAt = rateLimitResumeAt(state);
92
94
  if (resumeAt !== null && now < resumeAt) { waiting.push(agent.id); continue; }
93
95
 
@@ -96,7 +98,7 @@ export function resumeRateLimitedAgents(): Record<string, unknown> {
96
98
  const attempts = transient ? Math.max(transientRateLimitAttempt(state), tracked.attempts ?? 0) : 0;
97
99
  if (transient && (attempts >= TRANSIENT_RATE_LIMIT_BACKOFF_MS.length || transientRateLimitExhausted(state))) {
98
100
  if (!tracked.exhaustedNotified) {
99
- notifyTransientExhausted(agent, state as Record<string, unknown>, now);
101
+ notifyTransientExhausted(agent, state, now);
100
102
  resumeTracker.set(agent.id, { ...tracked, attempts, exhaustedNotified: true });
101
103
  }
102
104
  waiting.push(agent.id);
@@ -133,7 +135,7 @@ export function resumeRateLimitedAgents(): Record<string, unknown> {
133
135
  continue;
134
136
  }
135
137
 
136
- if (transient) mergeAgentMeta(agent.id, { providerState: nextTransientState(state as Record<string, unknown>, now, attempts) });
138
+ if (transient) mergeAgentMeta(agent.id, { providerState: nextTransientState(state, now, attempts) });
137
139
 
138
140
  // #416 — flush messages queued while the agent was rate-limited.
139
141
  flushQueuedDirectMessages(agent.id);
@@ -13,6 +13,7 @@ import { isReservedAgentId } from "agent-relay-sdk";
13
13
  import { ValidationError, createActivityEvent, getAgent, seedSpawnPromptMirror, seedSpawnReplyObligation, upsertAgent } from "../db";
14
14
  import { emitAgentStatus, emitNewMessage } from "../sse";
15
15
  import { noteAgentTimelineEvent } from "../compaction-watch";
16
+ import { agentProviderTimelineEvent } from "../provider-timeline-event";
16
17
  import { notifyAgentReady } from "../agent-lifecycle-events";
17
18
  import { resolveSpawnLineage, resolveTeamLineage } from "../security";
18
19
  import { flushQueuedDirectMessages, markManagedAgentRunning } from "./managed-running";
@@ -65,7 +66,7 @@ export function registerAgent(input: RegisterAgentInput, ctx: AuthContext): Agen
65
66
  // PreCompact/SessionStart hook arrives as a latched meta.timelineEvent — clears any
66
67
  // pending stall watch; the timestamp guard inside ignores stale latched events.
67
68
  emitAgentStatus(agent.id);
68
- noteAgentTimelineEvent(agent.id, agent.meta?.timelineEvent);
69
+ noteAgentTimelineEvent(agent.id, agentProviderTimelineEvent(agent));
69
70
 
70
71
  // #308/#351 — a spawned child registering already-ready (the common isolated-worktree
71
72
  // case) wakes its block-polling parent. Fire on the first-ever ready, regardless of
@@ -234,6 +234,29 @@ const DISPLAY_PREFS_SCHEMA: JSONSchema = {
234
234
  },
235
235
  };
236
236
 
237
+ // Per-conversation chat scroll memory (#759), tier:"user" so a human's reading position +
238
+ // sticky-bottom intent follow them across devices instead of dying in per-browser module state.
239
+ // The value is a MAP keyed by conversation/agent id — one user-settings row holds every
240
+ // conversation's anchor, so this is ONE descriptor (not a per-agent descriptor explosion).
241
+ // Each entry is a LOGICAL anchor, never a pixel scrollTop: `topKey` is the timeline entry key
242
+ // (`msg-<id>` — a server-stable id), `offset` the within-row pixel offset, `stick` the
243
+ // follow-the-bottom intent. `maxProperties` bounds unbounded conversation growth; the dashboard
244
+ // store evicts oldest-touched before it can be hit.
245
+ const CHAT_SCROLL_SCHEMA: JSONSchema = {
246
+ type: "object",
247
+ maxProperties: 200,
248
+ additionalProperties: {
249
+ type: "object",
250
+ additionalProperties: false,
251
+ required: ["stick", "topKey", "offset"],
252
+ properties: {
253
+ stick: { type: "boolean" },
254
+ topKey: { type: ["string", "null"], maxLength: 200 },
255
+ offset: { type: "number", minimum: 0, maximum: 10_000_000 },
256
+ },
257
+ },
258
+ };
259
+
237
260
  const VOICE_PREFS_SCHEMA: JSONSchema = {
238
261
  type: "object",
239
262
  additionalProperties: false,
@@ -404,6 +427,17 @@ function seedBuiltInSettings(): void {
404
427
  display: { group: "Preferences", label: "Display", description: "Per-user theme and reasoning visibility (follows you across devices).", order: 70 },
405
428
  });
406
429
 
430
+ // Per-conversation chat scroll memory (#759) — one row per user, keyed internally by agent id.
431
+ registerSetting({
432
+ namespace: "chat",
433
+ key: "scroll",
434
+ schema: CHAT_SCROLL_SCHEMA,
435
+ defaults: {},
436
+ scope: { read: "settings:read:chat", write: "settings:write:chat" },
437
+ tier: "user",
438
+ display: { group: "Preferences", label: "Chat scroll", description: "Per-conversation scroll position and sticky-bottom flag (follows you across devices).", order: 72 },
439
+ });
440
+
407
441
  registerSetting({
408
442
  namespace: "voice",
409
443
  key: "default",
@@ -4,6 +4,7 @@ import { effectiveProviderCatalogList } from "./provider-catalog-store";
4
4
  import { getRoutingPolicy } from "./routing-policy-store";
5
5
  import { isPathWithinBase } from "./utils";
6
6
  import { McpNotFoundError } from "./mcp-errors";
7
+ import { DEFAULT_QUOTA_PREFER_SLACK_AT, DEFAULT_QUOTA_TARGET_UTILIZATION, quotaPaceMetrics } from "agent-relay-sdk/provider-quota";
7
8
  import type { ProviderQuotaRoutingConfig } from "agent-relay-sdk";
8
9
  import type { Orchestrator, ProviderCatalogSummary, ProviderQuotaBurnRole, ProviderQuotaBurnWindow, ProviderQuotaRecord, QuotaState, SpawnProvider } from "./types";
9
10
 
@@ -64,7 +65,8 @@ interface SpawnCatalogish {
64
65
  models: Array<{ alias: string; providerModel: string; efforts: readonly string[]; defaultEffort?: string; disabled?: boolean; unavailable?: string }>;
65
66
  }
66
67
 
67
- type SpawnQuotaBurnBand = "normal" | "caution" | "avoid-large" | "hard-avoid";
68
+ // #760 "prefer" is the underburn counterpart to the over-pace penalty ladder.
69
+ type SpawnQuotaBurnBand = "prefer" | "normal" | "caution" | "avoid-large" | "hard-avoid";
68
70
 
69
71
  interface SpawnQuotaBurnWindow {
70
72
  role: ProviderQuotaBurnRole;
@@ -79,6 +81,11 @@ interface SpawnQuotaBurnWindow {
79
81
  projectedWallAt: number | null;
80
82
  earlyWallMs: number | null;
81
83
  earlyFraction: number | null;
84
+ // #760 — symmetric underburn signal (see ProviderQuotaBurnWindow.slack), re-derived against
85
+ // the per-provider target, plus the "prefer" flag for this window.
86
+ projectedUtilAtReset: number | null;
87
+ slack: number | null;
88
+ prefer: boolean;
82
89
  weight: 1 | 3;
83
90
  penalty: number;
84
91
  blockedNow: boolean;
@@ -88,6 +95,9 @@ interface SpawnQuotaAdvisory {
88
95
  quotaBurn: { windows: SpawnQuotaBurnWindow[] };
89
96
  quotaPenalty: number;
90
97
  quotaPenaltyBand: SpawnQuotaBurnBand;
98
+ // #760 — provider underburn: every selected window projects ≥ preferSlackAt headroom at reset.
99
+ prefer: boolean;
100
+ slack: number | null;
91
101
  blockedNow: boolean;
92
102
  recommendedAction: string;
93
103
  }
@@ -96,6 +106,8 @@ const DEFAULT_QUOTA_ADVISORY: SpawnQuotaAdvisory = {
96
106
  quotaBurn: { windows: [] },
97
107
  quotaPenalty: 0,
98
108
  quotaPenaltyBand: "normal",
109
+ prefer: false,
110
+ slack: null,
99
111
  blockedNow: false,
100
112
  recommendedAction: "No quota burn projection available; spawn normally.",
101
113
  };
@@ -173,13 +185,23 @@ function quotaAdvisoryFor(
173
185
  const windows = [...selected.values()].sort((a, b) => roleSort(a.role) - roleSort(b.role));
174
186
  const quotaPenalty = Math.max(0, ...windows.map((window) => window.penalty));
175
187
  const blockedNow = windows.some((window) => window.blockedNow);
176
- const quotaPenaltyBand = quotaPenaltyBandFor(quotaPenalty, quotaBands);
188
+ const penaltyBand = quotaPenaltyBandFor(quotaPenalty, quotaBands);
189
+ // #760 — provider-level "prefer": no over-pace penalty AND every selected window is projected
190
+ // to land ≥ preferSlackAt below target. The binding constraint is the LEAST-slack window, so a
191
+ // tight 5h window vetoes prefer even if 7d is wide open.
192
+ const preferSlackAt = providerCfg?.preferSlackAt ?? DEFAULT_QUOTA_PREFER_SLACK_AT;
193
+ const slacks = windows.map((window) => window.slack).filter((slack): slack is number => slack !== null);
194
+ const minSlack = slacks.length === windows.length && slacks.length > 0 ? Math.min(...slacks) : null;
195
+ const prefer = !blockedNow && penaltyBand === "normal" && minSlack !== null && minSlack >= preferSlackAt;
196
+ const quotaPenaltyBand: SpawnQuotaBurnBand = prefer ? "prefer" : penaltyBand;
177
197
  return {
178
198
  quotaBurn: { windows },
179
199
  quotaPenalty,
180
200
  quotaPenaltyBand,
201
+ prefer,
202
+ slack: minSlack,
181
203
  blockedNow,
182
- recommendedAction: recommendedAction({ quotaPenaltyBand, blockedNow, windows }),
204
+ recommendedAction: recommendedAction({ quotaPenaltyBand, blockedNow, prefer, slack: minSlack, windows }),
183
205
  };
184
206
  }
185
207
 
@@ -199,14 +221,37 @@ function spawnQuotaWindow(
199
221
  ): SpawnQuotaBurnWindow | undefined {
200
222
  if (!quotaWindow) return undefined;
201
223
  const timeToResetMs = typeof quotaWindow.resetsAt === "number" ? Math.max(0, quotaWindow.resetsAt - now) : null;
224
+ // earlyWallMs may be negative (#760 symmetric); the over-pace penalty only sees the positive half.
202
225
  const earlyFraction = burnWindow.earlyWallMs !== null && timeToResetMs && timeToResetMs > 0
203
226
  ? Math.max(0, burnWindow.earlyWallMs) / timeToResetMs
204
227
  : null;
205
228
  const weight = burnWindow.role === "7d" ? 3 : 1;
206
229
  const hardBackstop = providerCfg?.hardBackstopUtilization ?? 0.99;
207
- const utilizationFloor = providerCfg?.burnPenaltyUtilizationFloor ?? 0.5;
230
+ // #760 floor default is now 0 (off). The projection/pace gate (earlyFraction≈0 when the
231
+ // recency-weighted rate projects no early wall) replaces the blunt utilization floor, which
232
+ // used to hide a legitimate low-util-high-burst alarm. Operators may still opt back in.
233
+ const utilizationFloor = providerCfg?.burnPenaltyUtilizationFloor ?? 0;
208
234
  const rawPenalty = (earlyFraction ?? 0) * weight;
209
235
  const penalty = quotaWindow.utilization < utilizationFloor ? 0 : rawPenalty;
236
+
237
+ // #760 — re-derive the pace projection against the PER-PROVIDER target (the stored burn used
238
+ // the default). Slack is the symmetric underburn signal; prefer flags a window with no
239
+ // over-pace penalty and ≥ preferSlackAt projected headroom at reset.
240
+ const target = providerCfg?.targetUtilization ?? DEFAULT_QUOTA_TARGET_UTILIZATION;
241
+ const preferSlackAt = providerCfg?.preferSlackAt ?? DEFAULT_QUOTA_PREFER_SLACK_AT;
242
+ const blockedNow = quotaWindow.utilization >= hardBackstop;
243
+ let burnRatio = burnWindow.burnRatio;
244
+ let projectedUtilAtReset = burnWindow.projectedUtilAtReset;
245
+ let slack = burnWindow.slack;
246
+ if (burnWindow.observedRatePerMs !== null && timeToResetMs !== null && timeToResetMs > 0) {
247
+ const pace = quotaPaceMetrics({ utilization: quotaWindow.utilization, observedRatePerMs: burnWindow.observedRatePerMs, timeToResetMs, target });
248
+ burnRatio = pace.burnRatio;
249
+ projectedUtilAtReset = pace.projectedUtilAtReset;
250
+ slack = pace.slack;
251
+ }
252
+ const prefer = !blockedNow && penalty === 0
253
+ && quotaPenaltyBandFor(penalty) === "normal"
254
+ && slack !== null && slack >= preferSlackAt;
210
255
  return {
211
256
  role: burnWindow.role,
212
257
  name: burnWindow.name,
@@ -215,27 +260,34 @@ function spawnQuotaWindow(
215
260
  resetsAt: quotaWindow.resetsAt ?? null,
216
261
  timeToResetMs,
217
262
  confidence: burnWindow.confidence,
218
- burnRatio: burnWindow.burnRatio,
263
+ burnRatio,
219
264
  timeToWallMs: burnWindow.timeToWallMs,
220
265
  projectedWallAt: burnWindow.projectedWallAt,
221
266
  earlyWallMs: burnWindow.earlyWallMs,
222
267
  earlyFraction,
268
+ projectedUtilAtReset,
269
+ slack,
270
+ prefer,
223
271
  weight,
224
272
  penalty,
225
- blockedNow: quotaWindow.utilization >= hardBackstop,
273
+ blockedNow,
226
274
  };
227
275
  }
228
276
 
229
277
  // === Public band-info API for quota-band-transition (#738) ===
230
278
 
231
- export type QuotaBand = "normal" | "caution" | "avoid-large" | "hard-avoid";
279
+ // #760 "prefer" ranks below "normal" (it is the BEST state): see bandRank in quota-band-transition.
280
+ export type QuotaBand = "prefer" | "normal" | "caution" | "avoid-large" | "hard-avoid";
232
281
 
233
282
  export interface ProviderBandWindow {
234
283
  role: ProviderQuotaBurnRole;
235
284
  band: QuotaBand;
285
+ penalty: number;
236
286
  utilization: number;
237
287
  burnRatio: number | null;
238
288
  projectedWallAt: number | null;
289
+ // #760 — symmetric underburn signal; the band-transition deadband uses it for the prefer boundary.
290
+ slack: number | null;
239
291
  confidence: "high" | "low";
240
292
  }
241
293
 
@@ -243,6 +295,9 @@ export interface ProviderBandInfo {
243
295
  windows: ProviderBandWindow[];
244
296
  providerBand: QuotaBand;
245
297
  recommendedAction: string;
298
+ quotaBands: { cautionAt: number; avoidLargeAt: number; hardAvoidAt: number };
299
+ // #760 — slack threshold at which a window enters "prefer"; the deadband holds it preferMargin below.
300
+ preferSlackAt: number;
246
301
  }
247
302
 
248
303
  /**
@@ -255,15 +310,24 @@ export function getProviderBandInfo(provider: string, now: number): ProviderBand
255
310
  const records = listProviderQuotas({ provider, historyLimit: 0, now }).quotas;
256
311
  if (records.length === 0) return undefined;
257
312
  const advisory = quotaAdvisoryFor(records, now, policy.quotaBands, policy.providerQuota?.[provider as SpawnProvider]);
313
+ const quotaBands = {
314
+ cautionAt: policy.quotaBands?.cautionAt ?? 0.15,
315
+ avoidLargeAt: policy.quotaBands?.avoidLargeAt ?? 0.5,
316
+ hardAvoidAt: policy.quotaBands?.hardAvoidAt ?? 1.0,
317
+ };
258
318
  const windows: ProviderBandWindow[] = advisory.quotaBurn.windows.map((w) => ({
259
319
  role: w.role,
260
- band: quotaPenaltyBandFor(w.penalty, policy.quotaBands),
320
+ // #760 — per-window prefer overlay: w.prefer already encodes "no penalty + ≥ preferSlackAt slack".
321
+ band: w.prefer ? "prefer" : quotaPenaltyBandFor(w.penalty, policy.quotaBands),
322
+ penalty: w.penalty,
261
323
  utilization: w.utilization,
262
324
  burnRatio: w.burnRatio,
263
325
  projectedWallAt: w.projectedWallAt,
326
+ slack: w.slack,
264
327
  confidence: w.confidence,
265
328
  }));
266
- return { windows, providerBand: advisory.quotaPenaltyBand, recommendedAction: advisory.recommendedAction };
329
+ const preferSlackAt = policy.providerQuota?.[provider as SpawnProvider]?.preferSlackAt ?? DEFAULT_QUOTA_PREFER_SLACK_AT;
330
+ return { windows, providerBand: advisory.quotaPenaltyBand, recommendedAction: advisory.recommendedAction, quotaBands, preferSlackAt };
267
331
  }
268
332
 
269
333
  function compareQuotaWindows(a: SpawnQuotaBurnWindow, b: SpawnQuotaBurnWindow): number {
@@ -285,7 +349,7 @@ function quotaPenaltyBandFor(
285
349
  return "normal";
286
350
  }
287
351
 
288
- function recommendedAction(input: { quotaPenaltyBand: SpawnQuotaBurnBand; blockedNow: boolean; windows: SpawnQuotaBurnWindow[] }): string {
352
+ function recommendedAction(input: { quotaPenaltyBand: SpawnQuotaBurnBand; blockedNow: boolean; prefer: boolean; slack: number | null; windows: SpawnQuotaBurnWindow[] }): string {
289
353
  if (input.blockedNow) {
290
354
  const hasSevenDayBlock = input.windows.some((window) => window.role === "7d" && window.blockedNow);
291
355
  return hasSevenDayBlock
@@ -295,6 +359,11 @@ function recommendedAction(input: { quotaPenaltyBand: SpawnQuotaBurnBand; blocke
295
359
  if (input.quotaPenaltyBand === "hard-avoid") return "Hard avoid unless explicitly requested or no healthier alternate exists.";
296
360
  if (input.quotaPenaltyBand === "avoid-large") return "Avoid large jobs if a healthier alternate exists.";
297
361
  if (input.quotaPenaltyBand === "caution") return "Caution: prefer cheaper effort/model when practical.";
362
+ // #760 — surface the underburn signal so coordinators load-shift big/deferrable jobs here.
363
+ if (input.quotaPenaltyBand === "prefer") {
364
+ const headroom = input.slack !== null ? ` (~${Math.round(input.slack * 100)}% quota would otherwise go unused at reset)` : "";
365
+ return `Prefer for large or deferrable jobs${headroom}.`;
366
+ }
298
367
  return "Spawn normally.";
299
368
  }
300
369
 
@@ -306,6 +375,7 @@ function spawnProfilesSummary(): Array<Record<string, unknown>> {
306
375
  return listAgentProfiles().map(({ key, value }) => ({
307
376
  name: value.name ?? key,
308
377
  description: value.description,
378
+ ...(value.icon ? { icon: value.icon } : {}),
309
379
  provider: value.provider,
310
380
  approvalMode: value.permissions?.mode,
311
381
  ...(value.maxSpawnedAgents !== undefined ? { maxSpawnedAgents: value.maxSpawnedAgents } : {}),