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
@@ -72,15 +72,15 @@ const BUILT_IN_AGENT_PROFILE_NAMES = new Set([DEFAULT_SPAWN_PROFILE_NAME, "minim
72
72
  const WORKER_TOOL_GROUPS = ["core", "workspace", "memory", "artifacts", "blackboard", "subscriptions"];
73
73
 
74
74
  const BUILT_IN_AGENT_PROFILES: AgentProfile[] = [
75
- agentProfileDefaults({ name: DEFAULT_SPAWN_PROFILE_NAME, description: "Relay-aware spawned agent with bundled Relay context, skills, plugins, and status integration.", base: "host", mcp: { mode: "none" }, relay: { tools: { groups: WORKER_TOOL_GROUPS } } }),
76
- agentProfileDefaults({ name: "minimal", description: "Repo-focused spawned agent with Relay affordances disabled and host/global extras avoided where supported.", base: "minimal" }),
77
- agentProfileDefaults({ name: "isolated-research", description: "Strict profile for autonomous research runs: repo context only, no Relay prompt surface, no global extras where supported.", base: "isolated" }),
78
- agentProfileDefaults({ name: "vanilla", description: "Zero host bleed (#552): isolated provider home with host plugins, skills, hooks, MCP, and the CLAUDE.md/AGENTS.md cascade (repo + global) all suppressed at launch. Relay provisions any surface explicitly per profile.", base: "vanilla" }),
79
- agentProfileDefaults({ name: "lean-worker", description: "Lean worker profile (#570): vanilla host-dependency drop with only Relay context/MCP restored and Project knowledge injected from the spawn cwd.", base: "vanilla", relay: { context: true, skills: false, plugins: false, statusLine: false, mcp: true, tools: { groups: WORKER_TOOL_GROUPS } }, projectInstructions: { policy: "all" } }),
80
- agentProfileDefaults({ name: "coordinator", description: "Relay-aware coordination lead for spawning worker teams, maintaining shared state, and driving review and release.", base: "host", instructions: { append: [COORDINATION_CHARTER], repoInstructions: "allow", globalInstructions: "allow" }, maxSpawnedAgents: 8 }),
81
- agentProfileDefaults({ name: "ops-assistant", description: "Relay-aware ops lieutenant for a coordinator: runs tests, releases, smoke probes, gates, and host checks, then reports crisp outcomes back up.", provider: "codex", base: "host", instructions: { append: [OPS_ASSISTANT_BRIEF], repoInstructions: "allow", globalInstructions: "allow" }, permissions: { mode: "open", filesystem: "host" } }),
82
- agentProfileDefaults({ name: "worker", description: "Relay-aware focused implementer with open approval posture and no delegated spawn capability.", base: "host", mcp: { mode: "none" }, permissions: { mode: "open", filesystem: "host" }, relay: { tools: { groups: WORKER_TOOL_GROUPS } } }),
83
- agentProfileDefaults({ name: "reviewer", description: "Relay-aware read-only reviewer for audit and review passes without file writes.", base: "host", mcp: { mode: "none" }, permissions: { mode: "read-only", filesystem: "host" }, relay: { tools: { groups: WORKER_TOOL_GROUPS } } }),
75
+ agentProfileDefaults({ name: DEFAULT_SPAWN_PROFILE_NAME, icon: "🤖", description: "Relay-aware spawned agent with bundled Relay context, skills, plugins, and status integration.", base: "host", mcp: { mode: "none" }, relay: { tools: { groups: WORKER_TOOL_GROUPS } } }),
76
+ agentProfileDefaults({ name: "minimal", icon: "▫️", description: "Repo-focused spawned agent with Relay affordances disabled and host/global extras avoided where supported.", base: "minimal" }),
77
+ agentProfileDefaults({ name: "isolated-research", icon: "🔬", description: "Strict profile for autonomous research runs: repo context only, no Relay prompt surface, no global extras where supported.", base: "isolated" }),
78
+ agentProfileDefaults({ name: "vanilla", icon: "🫥", description: "Zero host bleed (#552): isolated provider home with host plugins, skills, hooks, MCP, and the CLAUDE.md/AGENTS.md cascade (repo + global) all suppressed at launch. Relay provisions any surface explicitly per profile.", base: "vanilla" }),
79
+ agentProfileDefaults({ name: "lean-worker", icon: "🪶", description: "Lean worker profile (#570): vanilla host-dependency drop with only Relay context/MCP restored and Project knowledge injected from the spawn cwd.", base: "vanilla", relay: { context: true, skills: false, plugins: false, statusLine: false, mcp: true, tools: { groups: WORKER_TOOL_GROUPS } }, projectInstructions: { policy: "all" } }),
80
+ agentProfileDefaults({ name: "coordinator", icon: "🧭", description: "Relay-aware coordination lead for spawning worker teams, maintaining shared state, and driving review and release.", base: "host", instructions: { append: [COORDINATION_CHARTER], repoInstructions: "allow", globalInstructions: "allow" }, maxSpawnedAgents: 8 }),
81
+ agentProfileDefaults({ name: "ops-assistant", icon: "🛠️", description: "Relay-aware ops lieutenant for a coordinator: runs tests, releases, smoke probes, gates, and host checks, then reports crisp outcomes back up.", provider: "codex", base: "host", instructions: { append: [OPS_ASSISTANT_BRIEF], repoInstructions: "allow", globalInstructions: "allow" }, permissions: { mode: "open", filesystem: "host" } }),
82
+ agentProfileDefaults({ name: "worker", icon: "🔨", description: "Relay-aware focused implementer with open approval posture and no delegated spawn capability.", base: "host", mcp: { mode: "none" }, permissions: { mode: "open", filesystem: "host" }, relay: { tools: { groups: WORKER_TOOL_GROUPS } } }),
83
+ agentProfileDefaults({ name: "reviewer", icon: "🔍", description: "Relay-aware read-only reviewer for audit and review passes without file writes.", base: "host", mcp: { mode: "none" }, permissions: { mode: "read-only", filesystem: "host" }, relay: { tools: { groups: WORKER_TOOL_GROUPS } } }),
84
84
  ].map((profile) => ({ ...profile, builtIn: true }));
85
85
 
86
86
  interface ConfigRow {
@@ -286,6 +286,7 @@ function agentProfileDefaults(input: Pick<AgentProfile, "name" | "base"> & Omit<
286
286
  return {
287
287
  name: input.name,
288
288
  description: input.description,
289
+ ...(input.icon === undefined ? {} : { icon: input.icon }),
289
290
  provider: input.provider ?? "any",
290
291
  base: input.base,
291
292
  builtIn: input.builtIn,
@@ -361,6 +362,9 @@ function validateAgentProfile(key: string, value: unknown, opts: { allowBuiltIn?
361
362
  return agentProfileDefaults({
362
363
  ...defaults,
363
364
  description: cleanString(value.description, "description", { max: 1000 }),
365
+ // #751: per-profile emoji icon. Short free string (single emoji incl. ZWJ
366
+ // sequences); cap length so it can't smuggle arbitrary text. Empty → undefined.
367
+ icon: cleanString(value.icon, "icon", { max: 8 }) || undefined,
364
368
  provider: value.provider === undefined || value.provider === null
365
369
  ? defaults.provider
366
370
  : cleanEnum(value.provider, "provider", VALID_PROFILE_PROVIDERS),
@@ -1,7 +1,20 @@
1
+ import { DEFAULT_QUOTA_TARGET_UTILIZATION, quotaPaceMetrics, quotaWindowMetrics } from "agent-relay-sdk/provider-quota";
1
2
  import type { ProviderQuotaBurn, ProviderQuotaBurnRole, ProviderQuotaBurnWindow, ProviderQuotaSnapshot, QuotaState } from "../types";
2
3
 
3
4
  const QUOTA_BURN_MIN_SAMPLE_SPAN_MS = 10 * 60_000;
4
5
 
6
+ // #760 — nominal window length per role, used to scale the recency half-life.
7
+ const WINDOW_NOMINAL_MS: Record<ProviderQuotaBurnRole, number> = {
8
+ "5h": 5 * 3_600_000,
9
+ "7d": 7 * 24 * 3_600_000,
10
+ };
11
+
12
+ // #760 — recency half-life as a fraction of the window length. 0.25 → ~75min for the 5h
13
+ // window (within the issue's ~60–90min trailing-window guidance) and ~42h for the 7d window.
14
+ // A burst at the START of the history span decays out as it ages, so an idle stretch pulls the
15
+ // observed rate (and burnRatio) back toward 0 instead of freezing the endpoint slope.
16
+ const RECENCY_HALFLIFE_FRACTION = 0.25;
17
+
5
18
  type QuotaWindow = QuotaState["windows"][number];
6
19
 
7
20
  // Provider-agnostic: window role is expressed by name only. Each adapter uses the
@@ -35,9 +48,38 @@ function lowConfidenceBurnWindow(role: ProviderQuotaBurnRole, window: QuotaWindo
35
48
  timeToWallMs: null,
36
49
  projectedWallAt: null,
37
50
  earlyWallMs: null,
51
+ observedRatePerMs: null,
52
+ projectedUtilAtReset: null,
53
+ slack: null,
54
+ target: null,
38
55
  };
39
56
  }
40
57
 
58
+ // #760 — recency-weighted consumption rate (utilization-fraction per ms). A weighted slope:
59
+ // each consecutive interval contributes its actual Δutilization and Δt, decayed by the age of
60
+ // its midpoint with the given half-life. A recent idle interval (large Δt, ~0 Δutil, high
61
+ // weight) dominates the denominator and drives the rate toward 0; an old burst is down-weighted.
62
+ function recencyWeightedRatePerMs(
63
+ samples: Array<{ capturedAt: number; utilization: number }>,
64
+ now: number,
65
+ halfLifeMs: number,
66
+ ): number {
67
+ let weightedDelta = 0;
68
+ let weightedDt = 0;
69
+ for (let i = 1; i < samples.length; i++) {
70
+ const prev = samples[i - 1]!;
71
+ const cur = samples[i]!;
72
+ const dt = cur.capturedAt - prev.capturedAt;
73
+ if (dt <= 0) continue;
74
+ const midAge = Math.max(0, now - (prev.capturedAt + cur.capturedAt) / 2);
75
+ const weight = Math.pow(0.5, midAge / halfLifeMs);
76
+ weightedDelta += weight * (cur.utilization - prev.utilization);
77
+ weightedDt += weight * dt;
78
+ }
79
+ if (weightedDt <= 0) return 0;
80
+ return Math.max(0, weightedDelta / weightedDt);
81
+ }
82
+
41
83
  function deriveProviderQuotaBurnWindow(input: {
42
84
  provider: string;
43
85
  currentWindow: QuotaWindow;
@@ -78,16 +120,42 @@ function deriveProviderQuotaBurnWindow(input: {
78
120
  return low;
79
121
  }
80
122
 
81
- const remainingQuota = Math.max(0, 1 - utilizationNow);
123
+ const remainingQuota = quotaWindowMetrics(input.currentWindow).remaining;
82
124
  const timeToResetMs = resetAt - input.now;
83
125
  if (remainingQuota <= 0 || timeToResetMs <= 0) return low;
84
- const observedRatePerMs = Math.max(0, (utilizationNow - oldest.utilization) / (current.capturedAt - oldest.capturedAt));
85
- const optimalRatePerMs = remainingQuota / timeToResetMs;
86
- if (observedRatePerMs <= 0 || optimalRatePerMs <= 0) {
126
+
127
+ // #760 (Defect 1) recency-weighted rate so an old burst decays and idle relaxes toward 0,
128
+ // instead of a frozen two-point endpoint slope over the whole span.
129
+ //
130
+ // Idle is invisible in the raw samples: identical utilization snapshots are deduped, so during
131
+ // an idle stretch no new rows arrive and the latest sample stays frozen at the last CHANGE
132
+ // (this is why codex sat at burn×6.3 — a stale burst slope). We make idle observable by
133
+ // appending a synthetic "utilization is still X as of now" point, which adds a recent
134
+ // zero-delta interval [lastSample, now] that the recency weighting lets dominate as it grows.
135
+ const halfLifeMs = WINDOW_NOMINAL_MS[role] * RECENCY_HALFLIFE_FRACTION;
136
+ const rateSamples = input.now > current.capturedAt
137
+ ? [...samples, { capturedAt: input.now, utilization: utilizationNow }]
138
+ : samples;
139
+ const observedRatePerMs = recencyWeightedRatePerMs(rateSamples, input.now, halfLifeMs);
140
+ // #760 (Defect 2) — pace against a configurable target (<100%); slack carries the underburn
141
+ // signal. The query layer (spawn-targets) may re-derive these against a per-provider target;
142
+ // the ingest-time values use the default so direct burn consumers (dashboard) have a number.
143
+ const pace = quotaPaceMetrics({
144
+ utilization: utilizationNow,
145
+ observedRatePerMs,
146
+ timeToResetMs,
147
+ target: DEFAULT_QUOTA_TARGET_UTILIZATION,
148
+ });
149
+ if (observedRatePerMs <= 0) {
150
+ // Idle (or decayed-to-idle): no projected wall, but slack still flags the headroom.
87
151
  return {
88
152
  ...low,
89
153
  confidence: "high",
90
154
  burnRatio: 0,
155
+ observedRatePerMs: 0,
156
+ projectedUtilAtReset: pace.projectedUtilAtReset,
157
+ slack: pace.slack,
158
+ target: pace.target,
91
159
  };
92
160
  }
93
161
  const timeToWallMs = remainingQuota / observedRatePerMs;
@@ -95,10 +163,15 @@ function deriveProviderQuotaBurnWindow(input: {
95
163
  return {
96
164
  ...low,
97
165
  confidence: "high",
98
- burnRatio: observedRatePerMs / optimalRatePerMs,
166
+ burnRatio: pace.burnRatio,
167
+ observedRatePerMs,
99
168
  timeToWallMs,
100
169
  projectedWallAt,
101
- earlyWallMs: Math.max(0, resetAt - projectedWallAt),
170
+ // #760 (Defect 2) symmetric: negative means the wall lands AFTER reset (headroom left).
171
+ earlyWallMs: resetAt - projectedWallAt,
172
+ projectedUtilAtReset: pace.projectedUtilAtReset,
173
+ slack: pace.slack,
174
+ target: pace.target,
102
175
  };
103
176
  }
104
177
 
@@ -1,4 +1,4 @@
1
- import type { IssueRef, IssueRefStatus } from "../types";
1
+ import type { IssueProvider, IssueRef, IssueRefStatus } from "../types";
2
2
 
3
3
  export interface IssueTrackerStatus {
4
4
  status: IssueRefStatus;
@@ -14,9 +14,19 @@ export interface IssueTrackerReopenOptions {
14
14
  comment?: string;
15
15
  }
16
16
 
17
+ export interface IssueTrackerIssueSummary {
18
+ provider: IssueProvider;
19
+ repo: string;
20
+ externalKey: string;
21
+ url?: string;
22
+ title?: string;
23
+ status: IssueRefStatus;
24
+ }
25
+
17
26
  export interface IssueTrackerAdapter {
18
27
  resolveLink(ref: IssueRef): string;
19
28
  getStatus(ref: IssueRef): Promise<IssueTrackerStatus>;
29
+ listOpenSubIssues?(ref: IssueRef): Promise<IssueTrackerIssueSummary[]>;
20
30
  close(ref: IssueRef, options?: IssueTrackerCloseOptions): Promise<void>;
21
31
  reopen(ref: IssueRef, options?: IssueTrackerReopenOptions): Promise<void>;
22
32
  comment(ref: IssueRef, body: string): Promise<void>;
@@ -1,5 +1,5 @@
1
1
  import type { IssueRef, IssueRefStatus } from "../types";
2
- import type { IssueTrackerAdapter, IssueTrackerCloseOptions, IssueTrackerReopenOptions, IssueTrackerStatus } from "./adapter";
2
+ import type { IssueTrackerAdapter, IssueTrackerCloseOptions, IssueTrackerIssueSummary, IssueTrackerReopenOptions, IssueTrackerStatus } from "./adapter";
3
3
 
4
4
  export const GITHUB_FIXED_LABEL = "fixed";
5
5
  const GITHUB_CREDENTIAL_REQUIREMENT = "GitHub issue tracker adapter requires gh authenticated with a token that has issues:write scope";
@@ -20,6 +20,50 @@ interface GitHubIssuePayload {
20
20
  html_url?: unknown;
21
21
  }
22
22
 
23
+ interface GitHubSubIssueNode {
24
+ number?: unknown;
25
+ title?: unknown;
26
+ state?: unknown;
27
+ url?: unknown;
28
+ }
29
+
30
+ interface GitHubSubIssuesPayload {
31
+ data?: {
32
+ repository?: {
33
+ issue?: {
34
+ subIssues?: {
35
+ nodes?: GitHubSubIssueNode[];
36
+ pageInfo?: {
37
+ hasNextPage?: unknown;
38
+ endCursor?: unknown;
39
+ };
40
+ };
41
+ };
42
+ };
43
+ };
44
+ }
45
+
46
+ const SUB_ISSUES_QUERY = `
47
+ query($owner: String!, $repo: String!, $number: Int!, $after: String) {
48
+ repository(owner: $owner, name: $repo) {
49
+ issue(number: $number) {
50
+ subIssues(first: 100, after: $after) {
51
+ nodes {
52
+ number
53
+ title
54
+ state
55
+ url
56
+ }
57
+ pageInfo {
58
+ hasNextPage
59
+ endCursor
60
+ }
61
+ }
62
+ }
63
+ }
64
+ }
65
+ `;
66
+
23
67
  function textFromBytes(value: unknown): string {
24
68
  if (!value) return "";
25
69
  if (typeof value === "string") return value;
@@ -78,7 +122,10 @@ function assertGitHubIssueRef(ref: IssueRef): void {
78
122
  }
79
123
 
80
124
  function statusFromState(state: unknown): IssueRefStatus {
81
- if (state === "open" || state === "closed") return state;
125
+ if (typeof state === "string") {
126
+ const normalized = state.toLowerCase();
127
+ if (normalized === "open" || normalized === "closed") return normalized;
128
+ }
82
129
  return "unknown";
83
130
  }
84
131
 
@@ -105,6 +152,40 @@ class GitHubIssueTrackerAdapter implements IssueTrackerAdapter {
105
152
  return { status: statusFromState(issue.state), title: titleFromIssue(issue) };
106
153
  }
107
154
 
155
+ async listOpenSubIssues(ref: IssueRef): Promise<IssueTrackerIssueSummary[]> {
156
+ assertGitHubIssueRef(ref);
157
+ const [owner, repo] = ref.repo.split("/") as [string, string];
158
+ const open: IssueTrackerIssueSummary[] = [];
159
+ let after: string | undefined;
160
+
161
+ do {
162
+ const response = await this.api({
163
+ method: "POST",
164
+ path: "graphql",
165
+ body: {
166
+ query: SUB_ISSUES_QUERY,
167
+ owner,
168
+ repo,
169
+ number: Number(ref.externalKey),
170
+ after,
171
+ },
172
+ }) as GitHubSubIssuesPayload;
173
+ const subIssues = response.data?.repository?.issue?.subIssues;
174
+ for (const node of subIssues?.nodes ?? []) {
175
+ if (statusFromState(node.state) !== "open") continue;
176
+ const number = typeof node.number === "number" || typeof node.number === "string" ? String(node.number) : "";
177
+ if (!number) continue;
178
+ const title = typeof node.title === "string" ? node.title : undefined;
179
+ const url = typeof node.url === "string" ? node.url : `https://github.com/${ref.repo}/issues/${number}`;
180
+ open.push({ provider: "github", repo: ref.repo, externalKey: number, url, title, status: "open" });
181
+ }
182
+ const pageInfo = subIssues?.pageInfo;
183
+ after = pageInfo?.hasNextPage === true && typeof pageInfo.endCursor === "string" ? pageInfo.endCursor : undefined;
184
+ } while (after);
185
+
186
+ return open;
187
+ }
188
+
108
189
  async close(ref: IssueRef, options: IssueTrackerCloseOptions = {}): Promise<void> {
109
190
  await this.api({ method: "PATCH", path: githubIssuePath(ref), body: { state: "closed" } });
110
191
  await this.applyLabels(ref, [GITHUB_FIXED_LABEL, ...(options.labels ?? [])]);
@@ -1,6 +1,7 @@
1
1
  export type {
2
2
  IssueTrackerAdapter,
3
3
  IssueTrackerCloseOptions,
4
+ IssueTrackerIssueSummary,
4
5
  IssueTrackerReopenOptions,
5
6
  IssueTrackerStatus,
6
7
  } from "./adapter";
@@ -1,6 +1,6 @@
1
1
  import { createCommand } from "./commands-db";
2
- import { extractClaudeModelUnavailableMessage } from "agent-relay-sdk";
3
2
  import { isPathWithinBase } from "./utils";
3
+ import { agentProviderState } from "./provider-state";
4
4
  import { createActivityEvent, deleteWorkspace, getAgent, getDb, getOrchestrator, listOrchestrators, listWorkspaces } from "./db";
5
5
  import {
6
6
  getManagedAgentState,
@@ -413,7 +413,10 @@ export class LifecycleManager {
413
413
 
414
414
  private markPolicyFailure(policy: SpawnPolicy, state: ManagedAgentState, error: string, exited?: ManagedSessionExitDiagnostics): void {
415
415
  if (state.status === "stopping") return;
416
- const modelUnavailable = managedModelUnavailableMessage(policy, error, exited);
416
+ // Read the terminal reason from the adapter-surfaced typed provider state rather
417
+ // than re-parsing raw provider log text (#745 / #725 §4.4 leak #13).
418
+ const agentId = state.agentId || exited?.agentId;
419
+ const modelUnavailable = agentId ? agentTerminalFailureMessage(getAgent(agentId)) : null;
417
420
  if (modelUnavailable) {
418
421
  this.markFailed(policy, state, modelUnavailable);
419
422
  return;
@@ -622,34 +625,18 @@ export function __resetLifecycleManager(): void {
622
625
  singleton = null;
623
626
  }
624
627
 
625
- function managedModelUnavailableMessage(_policy: SpawnPolicy, error: string, exited?: ManagedSessionExitDiagnostics): string | null {
626
- // extractClaudeModelUnavailableMessage returns null for text that doesn't match
627
- // the Claude-specific error pattern, so this is safe to call unconditionally —
628
- // no provider=== branch needed here; the parser is the gate (#743).
629
- return extractClaudeModelUnavailableMessage([
630
- error,
631
- exited?.lastError,
632
- ...(exited?.logTail ?? []),
633
- ...(exited?.unavailable ?? []),
634
- ].join("\n"));
635
- }
636
-
637
628
  function isFastFailStorm(failures: number, exited?: ManagedSessionExitDiagnostics): boolean {
638
629
  return failures >= MAX_FAST_FAILURES && exited !== undefined && exited.runtimeMs < FAST_FAIL_RUNTIME_MS;
639
630
  }
640
631
 
632
+ // Returns the model-unavailable message when the agent's adapter-surfaced typed provider
633
+ // state indicates a terminal model-unavailable failure (#745 / #725 §4.4 leak #13).
641
634
  function agentTerminalFailureMessage(agent: ReturnType<typeof getAgent> | null): string | null {
642
635
  if (!agent?.meta) return null;
643
636
  const reason = typeof agent.meta.terminalFailureReason === "string" ? agent.meta.terminalFailureReason : undefined;
644
637
  const message = typeof agent.meta.terminalFailureMessage === "string" ? agent.meta.terminalFailureMessage : undefined;
645
638
  if (reason === "model-unavailable" && message) return message;
646
- const providerState = isPlainRecord(agent.meta.providerState) ? agent.meta.providerState : undefined;
647
- const stateReason = typeof providerState?.reason === "string" ? providerState.reason : undefined;
648
- const stateMessage = typeof providerState?.message === "string" ? providerState.message : undefined;
649
- if (stateReason === "model-unavailable" && stateMessage) return stateMessage;
639
+ const ps = agentProviderState(agent);
640
+ if (ps?.reason === "model-unavailable" && ps.message) return ps.message;
650
641
  return null;
651
642
  }
652
-
653
- function isPlainRecord(value: unknown): value is Record<string, unknown> {
654
- return Boolean(value) && typeof value === "object" && !Array.isArray(value);
655
- }
@@ -1,4 +1,5 @@
1
1
  import { isRecord } from "agent-relay-sdk";
2
+ import type { ProviderState } from "agent-relay-sdk";
2
3
  import { rateLimitFallbackMs } from "./config";
3
4
 
4
5
  // One server-side home for reading an agent's provider runtime state (the
@@ -7,28 +8,18 @@ import { rateLimitFallbackMs } from "./config";
7
8
  // the transient-agent reaper (don't reap a held agent), and the rate-limit
8
9
  // resume sweep. The dashboard has its own client-side copy in display.ts.
9
10
 
10
- interface ProviderStateView {
11
- state: string;
12
- reason?: string;
13
- label?: string;
14
- /** Unix ms the provider's limit window resets (rate-limit holds). */
15
- resetAt?: number;
16
- /** Unix ms the hold was entered (used for the resume fallback when resetAt is absent). */
17
- enteredAt?: number;
18
- /** Raw provider error_type, kept for empirical observation of which type a real limit fires. */
19
- errorType?: string;
20
- [key: string]: unknown;
21
- }
11
+ // Re-export so callers don't need to import from two places.
12
+ export type { ProviderState };
22
13
 
23
14
  interface HasMeta {
24
15
  meta?: Record<string, unknown> | null;
25
16
  }
26
17
 
27
18
  /** The agent's provider runtime state, or null when none is set / malformed. */
28
- export function agentProviderState(agent: HasMeta | null | undefined): ProviderStateView | null {
19
+ export function agentProviderState(agent: HasMeta | null | undefined): ProviderState | null {
29
20
  const value = agent?.meta?.providerState;
30
21
  if (!isRecord(value) || typeof value.state !== "string") return null;
31
- return value as ProviderStateView;
22
+ return value as ProviderState;
32
23
  }
33
24
 
34
25
  /** True when the agent is in any blocked provider state (rate-limit, approval, model-unavailable). */
@@ -47,16 +38,16 @@ export function isRateLimitHold(agent: HasMeta | null | undefined): boolean {
47
38
  return state?.state === "blocked" && state.reason === RATE_LIMIT_BLOCK_REASON;
48
39
  }
49
40
 
50
- export function isTransientRateLimitHold(state: ProviderStateView | null): boolean {
41
+ export function isTransientRateLimitHold(state: ProviderState | null): boolean {
51
42
  return state?.kind === TRANSIENT_RATE_LIMIT_KIND;
52
43
  }
53
44
 
54
- export function transientRateLimitAttempt(state: ProviderStateView | null): number {
45
+ export function transientRateLimitAttempt(state: ProviderState | null): number {
55
46
  const raw = state?.resumeAttempt;
56
47
  return typeof raw === "number" && Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 0;
57
48
  }
58
49
 
59
- export function transientRateLimitExhausted(state: ProviderStateView | null): boolean {
50
+ export function transientRateLimitExhausted(state: ProviderState | null): boolean {
60
51
  return isTransientRateLimitHold(state) && transientRateLimitAttempt(state) >= TRANSIENT_RATE_LIMIT_BACKOFF_MS.length;
61
52
  }
62
53
 
@@ -69,7 +60,7 @@ export function transientRateLimitExhausted(state: ProviderStateView | null): bo
69
60
  * authoritative; otherwise fall back to `enteredAt + fallback window`. Null only
70
61
  * when neither is known (shouldn't happen — the runner always stamps enteredAt).
71
62
  */
72
- export function rateLimitResumeAt(state: ProviderStateView | null): number | null {
63
+ export function rateLimitResumeAt(state: ProviderState | null): number | null {
73
64
  if (!state) return null;
74
65
  if (isTransientRateLimitHold(state)) {
75
66
  if (typeof state.nextResumeAt === "number" && Number.isFinite(state.nextResumeAt)) return state.nextResumeAt;
@@ -0,0 +1,70 @@
1
+ import { isRecord, stringValue } from "agent-relay-sdk";
2
+ import { createActivityEvent } from "./db";
3
+ import { emitRelayEvent } from "./events";
4
+
5
+ interface HasMeta {
6
+ meta?: Record<string, unknown> | null;
7
+ }
8
+
9
+ export interface ProviderTimelineEvent extends Record<string, unknown> {
10
+ status?: string;
11
+ timestamp?: number;
12
+ metadata?: Record<string, unknown>;
13
+ }
14
+
15
+ /** The provider timeline event latched on the agent card, or null when absent/malformed. */
16
+ export function agentProviderTimelineEvent(agent: HasMeta | null | undefined): ProviderTimelineEvent | null {
17
+ const value = agent?.meta?.timelineEvent;
18
+ if (!isRecord(value)) return null;
19
+ return {
20
+ ...value,
21
+ ...(typeof value.status === "string" ? { status: value.status } : {}),
22
+ ...(typeof value.timestamp === "number" && Number.isFinite(value.timestamp) ? { timestamp: value.timestamp } : {}),
23
+ ...(isRecord(value.metadata) ? { metadata: value.metadata } : {}),
24
+ };
25
+ }
26
+
27
+ function numberValue(value: unknown): number | undefined {
28
+ return typeof value === "number" && Number.isFinite(value) ? value : undefined;
29
+ }
30
+
31
+ export function auditRunnerTimelineEvent(agentId: string, timelineEvent: ProviderTimelineEvent | null): void {
32
+ if (!timelineEvent) return;
33
+ const metadata = isRecord(timelineEvent.metadata) ? timelineEvent.metadata : {};
34
+ if (metadata.source !== "runner") return;
35
+ const status = stringValue(timelineEvent.status);
36
+ if (!status) return;
37
+ const eventType = stringValue(metadata.eventType) ?? status;
38
+ const timestamp = numberValue(timelineEvent.timestamp) ?? Date.now();
39
+ const id = stringValue(timelineEvent.id) ?? `${eventType}-${timestamp}`;
40
+ const title = stringValue(timelineEvent.title) ?? status.replace(/[._-]+/g, " ");
41
+ const body = stringValue(timelineEvent.body);
42
+ const icon = stringValue(timelineEvent.icon) ?? "ti-activity";
43
+ try {
44
+ const event = createActivityEvent({
45
+ clientId: `runner-timeline-${agentId}-${id}`,
46
+ kind: "state",
47
+ title,
48
+ body,
49
+ meta: agentId,
50
+ icon,
51
+ view: "agents",
52
+ agentId,
53
+ metadata: {
54
+ ...metadata,
55
+ eventType,
56
+ timelineStatus: status,
57
+ timelineId: id,
58
+ timelineTimestamp: timestamp,
59
+ },
60
+ });
61
+ emitRelayEvent({
62
+ type: "activity.created",
63
+ source: "server",
64
+ subject: String(event.id),
65
+ data: event as unknown as Record<string, unknown>,
66
+ });
67
+ } catch {
68
+ // Timeline writes must never block status updates.
69
+ }
70
+ }
@@ -1,4 +1,5 @@
1
1
  import { isRecord } from "agent-relay-sdk";
2
+ import { quotaWindowMetrics } from "agent-relay-sdk/provider-quota";
2
3
  import { getDb } from "./db/connection.ts";
3
4
  import { emitRelayEvent } from "./events";
4
5
  import { routeNotification } from "./notification-router";
@@ -46,7 +47,7 @@ function handleProviderQuotaAdvisory(input: ProviderQuotaAdvisoryInput): string
46
47
 
47
48
  for (const window of input.quota.windows) {
48
49
  const previousWindow = input.previousQuota?.windows.find((candidate) => candidate.name === window.name);
49
- const remaining = quotaRemaining(window.utilization);
50
+ const remaining = quotaWindowMetrics(window).remaining;
50
51
  const remainingPercent = percent(remaining);
51
52
  let previous = nextState[window.name];
52
53
  const reset = hasLastQuotaAdvisory(previous) && isQuotaReset(remaining, previousWindow, window, config.resetRemaining);
@@ -279,7 +280,7 @@ function crossProviderHeadroomHint(provider: string): string {
279
280
  return quota.windows.map((window) => ({
280
281
  provider: row.provider,
281
282
  window: window.name,
282
- remainingPercent: percent(quotaRemaining(window.utilization)),
283
+ remainingPercent: percent(quotaWindowMetrics(window).remaining),
283
284
  }));
284
285
  }).sort((a, b) => b.remainingPercent - a.remainingPercent);
285
286
  const best = options[0];
@@ -295,17 +296,13 @@ function isQuotaReset(
295
296
  ): boolean {
296
297
  if (remaining >= resetRemaining) return true;
297
298
  if (!previousWindow?.resetsAt || !window.resetsAt || window.resetsAt <= previousWindow.resetsAt) return false;
298
- return remaining - quotaRemaining(previousWindow.utilization) >= 0.5 && remaining >= 0.8;
299
+ return remaining - quotaWindowMetrics(previousWindow).remaining >= 0.5 && remaining >= 0.8;
299
300
  }
300
301
 
301
302
  function quotaRemainingBand(remaining: number, bandSize: number): number {
302
303
  return Math.min(100, Math.max(0, percent(Math.ceil((remaining / bandSize) - 1e-9) * bandSize)));
303
304
  }
304
305
 
305
- function quotaRemaining(utilization: number): number {
306
- return Math.max(0, Math.min(1, 1 - utilization));
307
- }
308
-
309
306
  function percent(value: number): number {
310
307
  return Math.round(value * 100);
311
308
  }