agent-relay-server 0.99.0 → 0.100.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 (90) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +5 -2
  3. package/public/assets/{MessageBubble-DiorZXyB.js → MessageBubble-DZ3cUoHO.js} +2 -2
  4. package/public/assets/{MessageBubble-DiorZXyB.js.map → MessageBubble-DZ3cUoHO.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-DY1p8v4p.js → PlanGraphPanel-B6G_RSZ3.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-DY1p8v4p.js.map → PlanGraphPanel-B6G_RSZ3.js.map} +1 -1
  7. package/public/assets/{activity-rIxqUtFC.js → activity-BefDMYiE.js} +2 -2
  8. package/public/assets/{activity-rIxqUtFC.js.map → activity-BefDMYiE.js.map} +1 -1
  9. package/public/assets/{agent-profiles-Cu-VkJm4.js → agent-profiles-D5VW943m.js} +2 -2
  10. package/public/assets/agent-profiles-D5VW943m.js.map +1 -0
  11. package/public/assets/agents-CtsNY05B.js +2 -0
  12. package/public/assets/agents-CtsNY05B.js.map +1 -0
  13. package/public/assets/{automation-B0cyCT5l.js → automation-B5TFhCGs.js} +2 -2
  14. package/public/assets/{automation-B0cyCT5l.js.map → automation-B5TFhCGs.js.map} +1 -1
  15. package/public/assets/branch-state-badge-BLF9LPtu.js +2 -0
  16. package/public/assets/branch-state-badge-BLF9LPtu.js.map +1 -0
  17. package/public/assets/chat-CoZcF6dA.js +2 -0
  18. package/public/assets/chat-CoZcF6dA.js.map +1 -0
  19. package/public/assets/{formatted-body-impl-ZwgTH3jh.js → formatted-body-impl-Zk5WjWS7.js} +2 -2
  20. package/public/assets/{formatted-body-impl-ZwgTH3jh.js.map → formatted-body-impl-Zk5WjWS7.js.map} +1 -1
  21. package/public/assets/{index-GoC6YrKn.js → index-B4cTW4A9.js} +5 -5
  22. package/public/assets/index-B4cTW4A9.js.map +1 -0
  23. package/public/assets/{insights-C3mlXZdd.js → insights-C8iC-IB1.js} +2 -2
  24. package/public/assets/{insights-C3mlXZdd.js.map → insights-C8iC-IB1.js.map} +1 -1
  25. package/public/assets/{maintenance-DL1lnqmG.js → maintenance-pl6U2a_y.js} +2 -2
  26. package/public/assets/{maintenance-DL1lnqmG.js.map → maintenance-pl6U2a_y.js.map} +1 -1
  27. package/public/assets/{managed-agents-7A6WH4Rn.js → managed-agents-1CynkXEy.js} +2 -2
  28. package/public/assets/{managed-agents-7A6WH4Rn.js.map → managed-agents-1CynkXEy.js.map} +1 -1
  29. package/public/assets/{markdown-preview-impl-THat8xv0.js → markdown-preview-impl-Dj6MExTR.js} +2 -2
  30. package/public/assets/{markdown-preview-impl-THat8xv0.js.map → markdown-preview-impl-Dj6MExTR.js.map} +1 -1
  31. package/public/assets/{memory-C349UJe9.js → memory-DyWT5mwx.js} +2 -2
  32. package/public/assets/{memory-C349UJe9.js.map → memory-DyWT5mwx.js.map} +1 -1
  33. package/public/assets/{messages-DXoLFj2P.js → messages-LqXFX2yc.js} +2 -2
  34. package/public/assets/{messages-DXoLFj2P.js.map → messages-LqXFX2yc.js.map} +1 -1
  35. package/public/assets/{orchestrators-CTXu4jAn.js → orchestrators-xdpBJOt1.js} +2 -2
  36. package/public/assets/{orchestrators-CTXu4jAn.js.map → orchestrators-xdpBJOt1.js.map} +1 -1
  37. package/public/assets/{overview-dWiN6TyN.js → overview-BDetOVf7.js} +2 -2
  38. package/public/assets/{overview-dWiN6TyN.js.map → overview-BDetOVf7.js.map} +1 -1
  39. package/public/assets/{pairs-KIXektDv.js → pairs-BxVd-YVM.js} +2 -2
  40. package/public/assets/{pairs-KIXektDv.js.map → pairs-BxVd-YVM.js.map} +1 -1
  41. package/public/assets/{registry-C-Y41yGB.js → registry-BeoPmyhz.js} +2 -2
  42. package/public/assets/{registry-C-Y41yGB.js.map → registry-BeoPmyhz.js.map} +1 -1
  43. package/public/assets/{security-BUymkwuj.js → security-DKDgxf96.js} +2 -2
  44. package/public/assets/{security-BUymkwuj.js.map → security-DKDgxf96.js.map} +1 -1
  45. package/public/assets/{select-DV25WRP5.js → select-DpRN9C_1.js} +2 -2
  46. package/public/assets/{select-DV25WRP5.js.map → select-DpRN9C_1.js.map} +1 -1
  47. package/public/assets/{settings-C1En58bx.js → settings-D_mLzA5O.js} +2 -2
  48. package/public/assets/{settings-C1En58bx.js.map → settings-D_mLzA5O.js.map} +1 -1
  49. package/public/assets/{tasks-BkpnKA63.js → tasks-D5OVfd-K.js} +2 -2
  50. package/public/assets/{tasks-BkpnKA63.js.map → tasks-D5OVfd-K.js.map} +1 -1
  51. package/public/assets/{teams-D-UYp7yH.js → teams-CFJM0XlG.js} +2 -2
  52. package/public/assets/{teams-D-UYp7yH.js.map → teams-CFJM0XlG.js.map} +1 -1
  53. package/public/assets/{terminal-viewer-impl-Bif-tkvD.js → terminal-viewer-impl-B-2tqoEB.js} +2 -2
  54. package/public/assets/{terminal-viewer-impl-Bif-tkvD.js.map → terminal-viewer-impl-B-2tqoEB.js.map} +1 -1
  55. package/public/assets/{user-avatar--czLv_AJ.js → user-avatar-BGXTBaME.js} +2 -2
  56. package/public/assets/{user-avatar--czLv_AJ.js.map → user-avatar-BGXTBaME.js.map} +1 -1
  57. package/public/assets/{work-queue-DN4oTe7w.js → work-queue-DMwtDbBg.js} +2 -2
  58. package/public/assets/{work-queue-DN4oTe7w.js.map → work-queue-DMwtDbBg.js.map} +1 -1
  59. package/public/index.html +1 -1
  60. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  61. package/runner/plugins/claude/hooks/kill-guard.sh +300 -0
  62. package/runner/plugins/claude/hooks/read-only-bash-guard.sh +38 -140
  63. package/runner/src/adapter.ts +2 -2
  64. package/src/agent-death-diagnosis.ts +12 -3
  65. package/src/bus.ts +6 -45
  66. package/src/config-store.ts +13 -9
  67. package/src/db/provider-quota-burn.ts +2 -1
  68. package/src/db/provider-quotas.ts +3 -2
  69. package/src/issue-tracker/adapter.ts +11 -1
  70. package/src/issue-tracker/github.ts +83 -2
  71. package/src/issue-tracker/index.ts +1 -0
  72. package/src/lifecycle-manager.ts +9 -22
  73. package/src/provider-state.ts +9 -18
  74. package/src/provider-timeline-event.ts +70 -0
  75. package/src/quota-advisory.ts +4 -7
  76. package/src/routes/agent-sessions.ts +4 -2
  77. package/src/services/issue-lifecycle.ts +54 -8
  78. package/src/services/provider-state-audit.ts +3 -3
  79. package/src/services/rate-limit-resume.ts +6 -4
  80. package/src/services/register-agent.ts +2 -1
  81. package/src/spawn-targets.ts +1 -0
  82. package/src/sse.ts +5 -4
  83. package/public/assets/agent-profiles-Cu-VkJm4.js.map +0 -1
  84. package/public/assets/agents-dRHOBDUe.js +0 -2
  85. package/public/assets/agents-dRHOBDUe.js.map +0 -1
  86. package/public/assets/branch-state-badge-CNko2_BL.js +0 -2
  87. package/public/assets/branch-state-badge-CNko2_BL.js.map +0 -1
  88. package/public/assets/chat-jit-e6S_.js +0 -2
  89. package/public/assets/chat-jit-e6S_.js.map +0 -1
  90. package/public/assets/index-GoC6YrKn.js.map +0 -1
@@ -1,4 +1,4 @@
1
- import type { AgentProfile, LivenessSignal, Message } from "agent-relay-sdk";
1
+ import type { AgentProfile, LivenessSignal, Message, ProviderState } from "agent-relay-sdk";
2
2
  import { isRecord } from "agent-relay-sdk";
3
3
  import type { LivenessInputs } from "./liveness";
4
4
  import type { SessionEvent } from "./session-insights";
@@ -30,7 +30,7 @@ export interface ProviderStatusEvent {
30
30
  // runner reconciles them against actual liveness and clears the busy marker if
31
31
  // every pid is dead — recovering from a tracked process killed out-of-band.
32
32
  pids?: number[];
33
- providerState?: Record<string, unknown>;
33
+ providerState?: ProviderState;
34
34
  metadata?: Record<string, unknown>;
35
35
  }
36
36
 
@@ -6,7 +6,6 @@
6
6
  // deterministic and unit-testable per failure mode. The output rides the #633 terminal event and
7
7
  // is persisted as a CrashReport so the WHY survives the reaper.
8
8
 
9
- import { extractClaudeModelUnavailableMessage } from "agent-relay-sdk";
10
9
  import type { AgentCard, AgentDeathCause, AgentDeathDiagnosis, CrashReport, ManagedSessionExitDiagnostics } from "./types";
11
10
 
12
11
  export interface DeathEvidence {
@@ -87,6 +86,16 @@ function haystack(ev: DeathEvidence): string {
87
86
  return parts.join("\n");
88
87
  }
89
88
 
89
+ function terminalModelUnavailableMessage(meta: Record<string, unknown> | undefined): string | undefined {
90
+ const terminalReason = str(meta?.terminalFailureReason);
91
+ const terminalMessage = str(meta?.terminalFailureMessage);
92
+ if (terminalReason === "model-unavailable" && terminalMessage) return terminalMessage;
93
+ const providerState = record(meta, "providerState");
94
+ const stateReason = str(providerState?.reason);
95
+ const stateMessage = str(providerState?.message);
96
+ return stateReason === "model-unavailable" && stateMessage ? stateMessage : undefined;
97
+ }
98
+
90
99
  function logTailOf(ev: DeathEvidence): string[] | undefined {
91
100
  if (ev.exit?.logTail?.length) return ev.exit.logTail;
92
101
  const lastExit = record(ev.agentMeta, "lastExit");
@@ -159,8 +168,8 @@ export function classifyAgentDeath(ev: DeathEvidence): AgentDeathDiagnosis {
159
168
  return verdict("onboarding-gate", fast ? "high" : "medium", "Stuck at a first-run onboarding/trust gate");
160
169
  }
161
170
 
162
- // 4) provider-crash — CLI panic/segfault/model-unavailable, or a crash signal.
163
- const modelUnavailable = extractClaudeModelUnavailableMessage(text);
171
+ // 4) provider-crash — CLI panic/segfault/adapter-classified model-unavailable, or a crash signal.
172
+ const modelUnavailable = terminalModelUnavailableMessage(ev.agentMeta);
164
173
  if (modelUnavailable) {
165
174
  pushUnique(evidence, modelUnavailable);
166
175
  return verdict("provider-crash", "high", "Provider unavailable (model/CLI error)");
package/src/bus.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import type { Server, ServerWebSocket } from "bun";
2
- import { ValidationError, createActivityEvent, getAgent, getDb, heartbeat, markReady, mergeAgentMeta, orphanTasksForAgent, revokeRuntimeTokensForAgent, setStatus, validateAgentSession } from "./db";
2
+ import { ValidationError, getAgent, getDb, heartbeat, markReady, mergeAgentMeta, orphanTasksForAgent, revokeRuntimeTokensForAgent, setStatus, validateAgentSession } from "./db";
3
3
  import { getOldestOutboxCursor, getOutboxCursor, replayEvents, type BusEvent } from "./bus-outbox";
4
4
  import { projectAgentStatusData } from "./agent-card-projection";
5
5
  import { emitRelayEvent, subscribeRelayEvents, type RelayEvent } from "./events";
@@ -11,6 +11,7 @@ import { registerAgent } from "./services/register-agent";
11
11
  import { authContextFromBus } from "./services/auth-context";
12
12
  import { commandAuthorizationResource, dispatchCommand } from "./services/dispatch-command";
13
13
  import { auditProviderStateTransition } from "./services/provider-state-audit";
14
+ import { agentProviderTimelineEvent, auditRunnerTimelineEvent } from "./provider-timeline-event";
14
15
  import { ServiceAuthError } from "./services/errors";
15
16
  import { ShutdownAuthError, ShutdownTargetError, shutdownAgent, shutdownInputFromBusFrame } from "./services/shutdown-agent";
16
17
  import { flushQueuedDirectMessages } from "./services/managed-running";
@@ -28,7 +29,7 @@ import {
28
29
  type BusFrame,
29
30
  type RegisterFrame,
30
31
  } from "agent-relay-sdk/protocol";
31
- import { errMessage, isRecord, stringValue } from "agent-relay-sdk";
32
+ import { errMessage, isRecord } from "agent-relay-sdk";
32
33
  import { getComponentAuth, isComponentAuthorizedFor, isAuthorized, isOriginAllowed, unauthorized } from "./security";
33
34
  import type { Command, ComponentToken, ContextState, Message, ProviderCapabilities, QuotaState, RegisterAgentInput, Task } from "./types";
34
35
 
@@ -189,10 +190,11 @@ function handleFrame(ws: BusWebSocket, frame: ReturnType<typeof validateClientFr
189
190
  }
190
191
  const after = getAgent(conn.agentId);
191
192
  auditProviderStateTransition(conn.agentId, before, after);
192
- auditRunnerTimelineEvent(conn.agentId, after?.meta?.timelineEvent);
193
+ const timelineEvent = agentProviderTimelineEvent(after);
194
+ auditRunnerTimelineEvent(conn.agentId, timelineEvent);
193
195
  // A real PreCompact/SessionStart hook arrives as a timelineEvent in the
194
196
  // merged meta — clears any pending stall watch (stale events ignored).
195
- noteAgentTimelineEvent(conn.agentId, after?.meta?.timelineEvent);
197
+ noteAgentTimelineEvent(conn.agentId, timelineEvent);
196
198
  emitAgentStatusEvent(conn.agentId);
197
199
  // #237 stop-hook → instant ⚪↔🟡 changes detection on the turn-end edge.
198
200
  probeWorkspaceOnTurnEnd(before, after);
@@ -568,47 +570,6 @@ function emitAgentStatusEvent(agentId: string): void {
568
570
  });
569
571
  }
570
572
 
571
- function auditRunnerTimelineEvent(agentId: string, timelineEvent: unknown): void {
572
- if (!isRecord(timelineEvent)) return;
573
- const metadata = isRecord(timelineEvent.metadata) ? timelineEvent.metadata : {};
574
- if (metadata.source !== "runner") return;
575
- const status = stringValue(timelineEvent.status);
576
- if (!status) return;
577
- const eventType = stringValue(metadata.eventType) ?? status;
578
- const timestamp = numberValue(timelineEvent.timestamp) ?? Date.now();
579
- const id = stringValue(timelineEvent.id) ?? `${eventType}-${timestamp}`;
580
- const title = stringValue(timelineEvent.title) ?? status.replace(/[._-]+/g, " ");
581
- const body = stringValue(timelineEvent.body);
582
- const icon = stringValue(timelineEvent.icon) ?? "ti-activity";
583
- try {
584
- const event = createActivityEvent({
585
- clientId: `runner-timeline-${agentId}-${id}`,
586
- kind: "state",
587
- title,
588
- body,
589
- meta: agentId,
590
- icon,
591
- view: "agents",
592
- agentId,
593
- metadata: {
594
- ...metadata,
595
- eventType,
596
- timelineStatus: status,
597
- timelineId: id,
598
- timelineTimestamp: timestamp,
599
- },
600
- });
601
- emitRelayEvent({
602
- type: "activity.created",
603
- source: "server",
604
- subject: String(event.id),
605
- data: event as unknown as Record<string, unknown>,
606
- });
607
- } catch {
608
- // Timeline writes must never block bus status updates.
609
- }
610
- }
611
-
612
573
  function sendCommandResult(
613
574
  ws: BusWebSocket,
614
575
  commandId: string,
@@ -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,3 +1,4 @@
1
+ import { 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;
@@ -78,7 +79,7 @@ function deriveProviderQuotaBurnWindow(input: {
78
79
  return low;
79
80
  }
80
81
 
81
- const remainingQuota = Math.max(0, 1 - utilizationNow);
82
+ const remainingQuota = quotaWindowMetrics(input.currentWindow).remaining;
82
83
  const timeToResetMs = resetAt - input.now;
83
84
  if (remainingQuota <= 0 || timeToResetMs <= 0) return low;
84
85
  const observedRatePerMs = Math.max(0, (utilizationNow - oldest.utilization) / (current.capturedAt - oldest.capturedAt));
@@ -403,11 +403,12 @@ function mergeQuotaWindows(existing: StoredQuotaState | undefined, incoming: Quo
403
403
  mergedTimes.set(key, incoming.updatedAt); mergedSources.set(key, incoming.source);
404
404
  return incomingWindow;
405
405
  }
406
- // Same-priority sources: prefer the newer reading and guard against an improbable regression.
406
+ // Same-priority sources: prefer the newer reading. Keep the regression guard for noisy
407
+ // self-reports, but let authoritative API ingest correct a stale/maxed WHAM sample (#750).
407
408
  if (incoming.updatedAt < existingUpdatedAt) {
408
409
  mergedTimes.set(key, existingUpdatedAt); mergedSources.set(key, existingSource); return window;
409
410
  }
410
- if (quotaWindowUtilizationRegressed(window, incomingWindow)) {
411
+ if (incoming.source !== "ingest" && quotaWindowUtilizationRegressed(window, incomingWindow)) {
411
412
  mergedTimes.set(key, existingUpdatedAt); mergedSources.set(key, existingSource); return window;
412
413
  }
413
414
  acceptedIncomingWindow = true;
@@ -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
  }
@@ -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);