agent-relay-server 0.83.1 → 0.85.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 (91) hide show
  1. package/docs/openapi.json +151 -1
  2. package/package.json +2 -2
  3. package/public/assets/{MessageBubble-BTBQr8uG.js → MessageBubble-DqFyach0.js} +2 -2
  4. package/public/assets/{MessageBubble-BTBQr8uG.js.map → MessageBubble-DqFyach0.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-CoWJJVyD.js → PlanGraphPanel-CCvKbYWe.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-CoWJJVyD.js.map → PlanGraphPanel-CCvKbYWe.js.map} +1 -1
  7. package/public/assets/{activity-XqRFu_9T.js → activity-C6RvknQQ.js} +2 -2
  8. package/public/assets/{activity-XqRFu_9T.js.map → activity-C6RvknQQ.js.map} +1 -1
  9. package/public/assets/{agents-BWmmWj4Q.js → agents-D7HZJ5eG.js} +2 -2
  10. package/public/assets/{agents-BWmmWj4Q.js.map → agents-D7HZJ5eG.js.map} +1 -1
  11. package/public/assets/{automation-DRtOENs0.js → automation-BUazA3lr.js} +2 -2
  12. package/public/assets/{automation-DRtOENs0.js.map → automation-BUazA3lr.js.map} +1 -1
  13. package/public/assets/{chat-gfUxbTus.js → chat-DBTEaf-H.js} +2 -2
  14. package/public/assets/{chat-gfUxbTus.js.map → chat-DBTEaf-H.js.map} +1 -1
  15. package/public/assets/display-CWMHll1J.js.map +1 -1
  16. package/public/assets/{formatted-body-impl-DpNHGXXQ.js → formatted-body-impl-KG0iExZ-.js} +2 -2
  17. package/public/assets/{formatted-body-impl-DpNHGXXQ.js.map → formatted-body-impl-KG0iExZ-.js.map} +1 -1
  18. package/public/assets/index-LUIvWcx6.css +2 -0
  19. package/public/assets/index-TZskQfQL.js +23 -0
  20. package/public/assets/index-TZskQfQL.js.map +1 -0
  21. package/public/assets/{insights-DPaJ5Hme.js → insights-JL1s7Uul.js} +2 -2
  22. package/public/assets/{insights-DPaJ5Hme.js.map → insights-JL1s7Uul.js.map} +1 -1
  23. package/public/assets/{maintenance-DsszLb92.js → maintenance-CTMfhXFM.js} +2 -2
  24. package/public/assets/{maintenance-DsszLb92.js.map → maintenance-CTMfhXFM.js.map} +1 -1
  25. package/public/assets/{managed-agents-DqWPxFE0.js → managed-agents-C4cb3xQ4.js} +2 -2
  26. package/public/assets/{managed-agents-DqWPxFE0.js.map → managed-agents-C4cb3xQ4.js.map} +1 -1
  27. package/public/assets/{markdown-preview-impl-CWR4ILHK.js → markdown-preview-impl-DQIi0O7r.js} +2 -2
  28. package/public/assets/{markdown-preview-impl-CWR4ILHK.js.map → markdown-preview-impl-DQIi0O7r.js.map} +1 -1
  29. package/public/assets/{memory-C2s9bJ38.js → memory-DJPXFdgw.js} +2 -2
  30. package/public/assets/{memory-C2s9bJ38.js.map → memory-DJPXFdgw.js.map} +1 -1
  31. package/public/assets/{messages-DHRJNAvy.js → messages-RSD2g4Wx.js} +2 -2
  32. package/public/assets/{messages-DHRJNAvy.js.map → messages-RSD2g4Wx.js.map} +1 -1
  33. package/public/assets/{orchestrators-BGNNllJc.js → orchestrators-BGIkULBF.js} +2 -2
  34. package/public/assets/{orchestrators-BGNNllJc.js.map → orchestrators-BGIkULBF.js.map} +1 -1
  35. package/public/assets/{overview-C1vZfyhW.js → overview-ByE9V8pe.js} +2 -2
  36. package/public/assets/{overview-C1vZfyhW.js.map → overview-ByE9V8pe.js.map} +1 -1
  37. package/public/assets/{pairs-C1uzo8x0.js → pairs-C284ntvD.js} +2 -2
  38. package/public/assets/{pairs-C1uzo8x0.js.map → pairs-C284ntvD.js.map} +1 -1
  39. package/public/assets/{security-XhAAP6q3.js → security-vDglScsg.js} +2 -2
  40. package/public/assets/{security-XhAAP6q3.js.map → security-vDglScsg.js.map} +1 -1
  41. package/public/assets/{select-DEGUFEjx.js → select-DaOiWDBR.js} +2 -2
  42. package/public/assets/{select-DEGUFEjx.js.map → select-DaOiWDBR.js.map} +1 -1
  43. package/public/assets/{settings-DTbVUdlZ.js → settings-DiLcbSqx.js} +2 -2
  44. package/public/assets/{settings-DTbVUdlZ.js.map → settings-DiLcbSqx.js.map} +1 -1
  45. package/public/assets/{tasks-zwGo7hon.js → tasks-DWJdJra3.js} +2 -2
  46. package/public/assets/{tasks-zwGo7hon.js.map → tasks-DWJdJra3.js.map} +1 -1
  47. package/public/assets/{teams-Brc5D8ox.js → teams-FejXkix4.js} +2 -2
  48. package/public/assets/{teams-Brc5D8ox.js.map → teams-FejXkix4.js.map} +1 -1
  49. package/public/assets/{terminal-viewer-impl-C6yUAL8G.js → terminal-viewer-impl-Bkt1w42o.js} +2 -2
  50. package/public/assets/{terminal-viewer-impl-C6yUAL8G.js.map → terminal-viewer-impl-Bkt1w42o.js.map} +1 -1
  51. package/public/assets/{user-avatar-Bw1R011j.js → user-avatar-ClE2dMD8.js} +2 -2
  52. package/public/assets/{user-avatar-Bw1R011j.js.map → user-avatar-ClE2dMD8.js.map} +1 -1
  53. package/public/assets/{work-queue-ge4R7keo.js → work-queue-DZdv9kOb.js} +2 -2
  54. package/public/assets/{work-queue-ge4R7keo.js.map → work-queue-DZdv9kOb.js.map} +1 -1
  55. package/public/index.html +2 -2
  56. package/src/agent-death-diagnosis.ts +255 -0
  57. package/src/agent-lifecycle-events.ts +42 -19
  58. package/src/auth/index.ts +23 -0
  59. package/src/auth/password.ts +122 -0
  60. package/src/auth/provider.ts +60 -0
  61. package/src/auth/session.ts +92 -0
  62. package/src/bus.ts +18 -4
  63. package/src/db/agents.ts +35 -2
  64. package/src/db/auth-identities.ts +119 -0
  65. package/src/db/crash-reports.ts +115 -0
  66. package/src/db/index.ts +2 -0
  67. package/src/db/migrations.ts +11 -0
  68. package/src/index.ts +6 -1
  69. package/src/maintenance/jobs.ts +6 -1
  70. package/src/maintenance/teams.ts +68 -1
  71. package/src/mcp/index.ts +2 -1
  72. package/src/mcp/tools-messaging.ts +38 -0
  73. package/src/notification-types.ts +2 -0
  74. package/src/routes/_shared.ts +40 -2
  75. package/src/routes/agents.ts +11 -1
  76. package/src/routes/auth.ts +107 -0
  77. package/src/routes/commands.ts +21 -3
  78. package/src/routes/index.ts +10 -1
  79. package/src/routes/orchestrator.ts +14 -1
  80. package/src/routes/tokens.ts +8 -2
  81. package/src/security.ts +1 -1
  82. package/src/services/terminal-provider-exit.ts +33 -0
  83. package/src/spawn-targets.ts +19 -8
  84. package/src/team-idle-tracker.ts +15 -0
  85. package/src/validation.ts +2 -0
  86. package/src/workspace-actions.ts +5 -1
  87. package/src/workspace-auto-merge.ts +87 -4
  88. package/src/workspace-merge.ts +10 -0
  89. package/public/assets/index-D8TVUdlC.js +0 -22
  90. package/public/assets/index-D8TVUdlC.js.map +0 -1
  91. package/public/assets/index-DgZLDL0K.css +0 -2
@@ -7,6 +7,7 @@ import {
7
7
  createActivityEvent,
8
8
  evaluatePoolBindings,
9
9
  expireQueuedMessages,
10
+ getAgent,
10
11
  pruneOrphanedSharedWorkspaces,
11
12
  pruneOfflineAgents,
12
13
  pruneOldMessages,
@@ -19,6 +20,7 @@ import {
19
20
  sweepArtifacts,
20
21
  } from "../db";
21
22
  import { notifyAgentOffline } from "../agent-lifecycle-events";
23
+ import { diagnoseAgentDeath } from "../agent-death-diagnosis";
22
24
  import { autoMergeCleanFastForwards } from "../workspace-auto-merge";
23
25
  import { reapOrphanedWorktrees } from "../workspace-orphans";
24
26
  import { getLifecycleManager } from "../lifecycle-manager";
@@ -173,7 +175,10 @@ export const definitions: MaintenanceJobDefinition[] = [
173
175
  for (const id of reapedAgentIds) {
174
176
  emitAgentStatus(id);
175
177
  getLifecycleManager().onAgentDisappeared(id);
176
- notifyAgentOffline(id, "heartbeat lost");
178
+ // #636 — a heartbeat-lost death still gets a best-effort post-mortem from the agent's last
179
+ // known meta (lastExit/lastError/auth) + heartbeat gap, riding the terminal event.
180
+ const diagnosis = diagnoseAgentDeath({ agent: getAgent(id), reason: "heartbeat lost" });
181
+ notifyAgentOffline(id, "heartbeat lost", diagnosis);
177
182
  createActivityEvent({
178
183
  clientId: "server-agent-" + id + "-heartbeat-lost-" + Date.now(),
179
184
  kind: "state",
@@ -2,6 +2,7 @@ import {
2
2
  clearTeamZeroMembersSince,
3
3
  countLiveTeamMembers,
4
4
  createActivityEvent,
5
+ getCrashReport,
5
6
  getTeamBusyState,
6
7
  getTeamRoster,
7
8
  listActiveTeams,
@@ -13,10 +14,69 @@ import { dissolveTeamById } from "../services/team";
13
14
  import { getTeamsConfig } from "../teams-config-store";
14
15
  import { teamReapGraceMs } from "./constants";
15
16
  import { notifyTarget } from "./events";
16
- import { teamIdleTracker } from "../team-idle-tracker";
17
+ import { teamIdleTracker, teamMemberWatchTracker, type TeamMemberWatch } from "../team-idle-tracker";
18
+ import type { AgentCard } from "../types";
17
19
 
18
20
  export function resetTeamIdleWatchdogForTests(): void {
19
21
  teamIdleTracker.clear();
22
+ teamMemberWatchTracker.clear();
23
+ }
24
+
25
+ /** A member counts as "alive" (worth watching for a future silence) when it's past onboarding and
26
+ * heartbeating — i.e. a status that implies it became ready, not a never-started spawn. */
27
+ function memberIsAlive(member: AgentCard, freshCutoff: number): boolean {
28
+ return (member.status === "idle" || member.status === "busy" || member.status === "online") && member.lastSeen > freshCutoff;
29
+ }
30
+
31
+ /**
32
+ * #634 — detect a team member that was ready/busy and then went silent (heartbeat-gap), EVEN IF it
33
+ * dropped from the roster entirely, and ping the coordinator once. The in-memory watch remembers
34
+ * each member's last-active time, so a vanished agent (no roster row) is still caught. Re-arms when
35
+ * a member recovers. Skips the ping when a #633 crash report already exists (the coordinator was
36
+ * already woken by the terminal event) — this is the backstop for the NO-terminal-event case.
37
+ */
38
+ function detectSilentMembers(
39
+ teamId: string,
40
+ coordinatorId: string | undefined,
41
+ now: number,
42
+ thresholdMs: number,
43
+ out: { pingedMembers: string[]; watchedMembers: number },
44
+ ): void {
45
+ const freshCutoff = now - thresholdMs;
46
+ const watch = teamMemberWatchTracker.get(teamId) ?? new Map<string, TeamMemberWatch>();
47
+ teamMemberWatchTracker.set(teamId, watch);
48
+
49
+ for (const member of getTeamRoster(teamId)) {
50
+ if (member.id === coordinatorId) continue; // the coordinator is the recipient, not a watched worker
51
+ if (!memberIsAlive(member, freshCutoff)) continue;
52
+ const existing = watch.get(member.id);
53
+ // Recovery re-arms the ping: a member seen alive again after a silence can re-trigger later.
54
+ const recovered = existing ? member.lastSeen > existing.lastActiveAt : false;
55
+ watch.set(member.id, {
56
+ lastActiveAt: Math.max(member.lastSeen, existing?.lastActiveAt ?? 0),
57
+ pinged: recovered ? false : existing?.pinged ?? false,
58
+ ...(member.label ? { label: member.label } : {}),
59
+ });
60
+ }
61
+
62
+ out.watchedMembers += watch.size;
63
+ for (const [memberId, entry] of watch) {
64
+ const gapMs = now - entry.lastActiveAt;
65
+ if (gapMs < thresholdMs || entry.pinged) continue;
66
+ entry.pinged = true;
67
+ if (getCrashReport(memberId)) continue; // terminal event already woke the coordinator (#633)
68
+ if (!coordinatorId) continue;
69
+ const minutes = Math.floor(gapMs / 60_000);
70
+ notifyTarget(
71
+ "team.member_silent",
72
+ { teamId, agentId: memberId },
73
+ coordinatorId,
74
+ "Team member went silent",
75
+ `Member \`${entry.label ?? memberId}\` in team \`${teamId}\` has sent no heartbeat for ${minutes} min — it may have died without a terminal event. Check it and restart its unfinished work, or stand it down.`,
76
+ { kind: "team.member_silent", teamId, coordinatorId, memberId, label: entry.label, silentForMs: gapMs, lastActiveAt: entry.lastActiveAt },
77
+ );
78
+ out.pingedMembers.push(memberId);
79
+ }
20
80
  }
21
81
 
22
82
  function teamIdlePayload(teamId: string, coordinatorId: string, thresholdMinutes: number, idleForMs: number, firstNoBusyAt: number) {
@@ -79,9 +139,13 @@ export function runTeamIdleWatchdog(input: { now?: number } = {}): Record<string
79
139
  const pingedTeamIds: string[] = [];
80
140
  const alreadyPingedTeamIds: string[] = [];
81
141
  const missingCoordinatorTeamIds: string[] = [];
142
+ const memberSilence = { pingedMembers: [] as string[], watchedMembers: 0 };
82
143
 
83
144
  for (const team of listActiveTeams()) {
84
145
  seen.add(team.id);
146
+ // #634 — runs every cycle, independent of the team-level idle gate below: a single silent
147
+ // member must surface even while OTHER members keep the team "busy".
148
+ detectSilentMembers(team.id, team.coordinatorId, now, thresholdMs, memberSilence);
85
149
  const busy = getTeamBusyState(team.id, cutoff);
86
150
  if (busy.busyMemberIds.length > 0) {
87
151
  teamIdleTracker.delete(team.id);
@@ -112,6 +176,7 @@ export function runTeamIdleWatchdog(input: { now?: number } = {}): Record<string
112
176
  }
113
177
 
114
178
  for (const key of teamIdleTracker.keys()) if (!seen.has(key)) teamIdleTracker.delete(key);
179
+ for (const key of teamMemberWatchTracker.keys()) if (!seen.has(key)) teamMemberWatchTracker.delete(key);
115
180
 
116
181
  return {
117
182
  enabled: true,
@@ -122,6 +187,8 @@ export function runTeamIdleWatchdog(input: { now?: number } = {}): Record<string
122
187
  pingedTeamIds,
123
188
  alreadyPingedTeamIds,
124
189
  missingCoordinatorTeamIds,
190
+ silentMemberIds: memberSilence.pingedMembers,
191
+ watchedMembers: memberSilence.watchedMembers,
125
192
  };
126
193
  }
127
194
 
package/src/mcp/index.ts CHANGED
@@ -16,7 +16,7 @@ import { errMessage, isRecord } from "agent-relay-sdk";
16
16
  import type { JsonRpcId, JsonRpcRequest, McpAuthContext, ToolDefinition } from "./types";
17
17
  import { positiveIdOrUndefined, recordField, stringField } from "./validation";
18
18
  import { IDENTITY_TOOLS, hasAnyScope, mcpAuthContext, relayCompactAndResume, relayRecall, relayWhoami } from "./identity";
19
- import { MESSAGING_TOOLS, relayAgentStatus, relayAttachArtifact, relayFindAgents, relayGetMessage, relayGetThread, relayReply, relaySendMessage, relayUploadArtifact } from "./tools-messaging";
19
+ import { MESSAGING_TOOLS, relayAgentCrashReport, relayAgentStatus, relayAttachArtifact, relayFindAgents, relayGetMessage, relayGetThread, relayReply, relaySendMessage, relayUploadArtifact } from "./tools-messaging";
20
20
  import { SPAWN_TOOLS, relayShutdownAgent, relaySpawnAgent, relaySpawnTargets } from "./tools-spawn";
21
21
  import { WORKSPACE_TOOLS, callerIsolatedWorkspace, relayWorkspaceList, relayWorkspaceMutation, relayWorkspaceStatus } from "./tools-workspace";
22
22
 
@@ -122,6 +122,7 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
122
122
  else if (name === "relay_upload_artifact") result = await relayUploadArtifact(auth, args);
123
123
  else if (name === "relay_attach_artifact") result = relayAttachArtifact(auth, args);
124
124
  else if (name === "relay_agent_status") result = relayAgentStatus(args);
125
+ else if (name === "relay_agent_crash_report") result = relayAgentCrashReport(auth, args);
125
126
  else if (name === "relay_find_agents") result = relayFindAgents(auth, args);
126
127
  else if (name === "relay_whoami") result = relayWhoami(auth);
127
128
  else if (name === "relay_compact_and_resume") result = relayCompactAndResume(auth, args);
@@ -8,7 +8,9 @@ import {
8
8
  createArtifact,
9
9
  getAgent,
10
10
  getArtifact,
11
+ getCrashReport,
11
12
  getMessage,
13
+ listCrashReports,
12
14
  getOrchestrator,
13
15
  getTask,
14
16
  getThread,
@@ -190,6 +192,19 @@ export const MESSAGING_TOOLS: ToolDefinition[] = [
190
192
  additionalProperties: false,
191
193
  },
192
194
  },
195
+ {
196
+ name: "relay_agent_crash_report",
197
+ description: "Retrieve the post-mortem for a dead agent (#636): a likely-cause label (oom · killed · clean-exit · token-401 · onboarding-gate · provider-crash · transport-loss · unknown) + log tail + heartbeat/active-work context. Pass agentId for one report (yourself or a child you spawned); omit it to list your own spawned children's reports. The report survives the reaper, so it answers 'why did my worker die?' after it's gone.",
198
+ requiredScopes: ["agents:read", "agent:read"],
199
+ inputSchema: {
200
+ type: "object",
201
+ properties: {
202
+ agentId: { type: "string", description: "The dead agent's id. Must be yourself or one of your spawned children. Omit to list your own children's crash reports." },
203
+ limit: { type: "integer", minimum: 1, maximum: 200, description: "Max reports when listing (default 50)." },
204
+ },
205
+ additionalProperties: false,
206
+ },
207
+ },
193
208
  ];
194
209
 
195
210
  function runMcpSend(auth: McpAuthContext, input: SendMessageInput): Message & { delivery: DeliveryReceipt } {
@@ -450,6 +465,29 @@ export function relayFindAgents(auth: McpAuthContext, args: Record<string, unkno
450
465
  return { agents: safe, total, count: safe.length, truncated: true, nextOffset: offset + safe.length };
451
466
  }
452
467
 
468
+ // #636 — self/parent-scoped crash post-mortem retrieval. A caller may read its OWN report or any of
469
+ // its spawned children's; never an unrelated agent's. Survives the reaper (separate crash_reports table).
470
+ export function relayAgentCrashReport(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
471
+ const caller = callerAgentId(auth);
472
+ const agentId = optionalString(args.agentId, "agentId", 240);
473
+ if (agentId) {
474
+ // Authorize FIRST, regardless of whether a report exists — the self/parent contract must hold
475
+ // even for an agentId with no report (else any agent:read caller leaks `{report:null}`). Prefer
476
+ // the live agent row's lineage; fall back to the report's recorded parent once the agent is reaped.
477
+ const agent = getAgent(agentId);
478
+ const report = getCrashReport(agentId);
479
+ const parent = agent?.spawnedBy ?? report?.parentId;
480
+ const authorized = !!caller && (caller === agentId || (parent !== undefined && caller === parent));
481
+ if (!authorized) throw new McpAuthError("crash report is scoped to the agent itself or its spawn-parent");
482
+ return { agentId, report: report ?? null };
483
+ }
484
+ // No agentId: list the caller's own spawned children's reports.
485
+ if (!caller) throw new McpAuthError("relay_agent_crash_report requires a resolvable caller agent to list reports");
486
+ const limit = optionalPositiveInt(args.limit, "limit");
487
+ const reports = listCrashReports({ parentId: caller, ...(limit ? { limit } : {}) });
488
+ return { reports, count: reports.length };
489
+ }
490
+
453
491
  function payloadWithAttachments(
454
492
  payload: Record<string, unknown> | undefined,
455
493
  attachments: AttachmentRef[] | undefined,
@@ -11,6 +11,7 @@ export type NotificationType =
11
11
  | "agent.transient_land_hold"
12
12
  | "agent.rate_limit_resume"
13
13
  | "team.idle"
14
+ | "team.member_silent"
14
15
  | "plan.bound_agent_exited"
15
16
  | "workspace.steward_task"
16
17
  | "workspace.conflict"
@@ -62,6 +63,7 @@ export const DEFAULT_TTL_MS: Record<NotificationType, number | null> = {
62
63
  "agent.transient_land_hold": null,
63
64
  "agent.rate_limit_resume": 15 * MINUTES,
64
65
  "team.idle": null,
66
+ "team.member_silent": null,
65
67
  "plan.bound_agent_exited": null,
66
68
  "workspace.steward_task": null,
67
69
  "workspace.conflict": null,
@@ -5,7 +5,7 @@ import { ValidationError, createActivityEvent, createCallbackDelivery, finishCal
5
5
  import { cleanEpoch, cleanString, optionalEnum } from "../validation";
6
6
  import { emitActivityEvent, emitMessageQueued, emitNewMessage } from "../sse";
7
7
  import { emitRelayEvent } from "../events";
8
- import { canonicalHumanAgentId, DEFAULT_USER_ID, errMessage, isRecord, userSinkId } from "agent-relay-sdk";
8
+ import { canonicalHumanAgentId, DEFAULT_USER_ID, errMessage, isHumanAgentId, isRecord, userIdFromAddress, userSinkId } from "agent-relay-sdk";
9
9
  import { authorizeAgentForUser, visibleAgentIdsForUser, type AgentAction } from "../agent-authz";
10
10
  import { generateSpawnRequestId } from "../spawn-command";
11
11
  import { getComponentAuth, getIntegrationAuth, isAuthorized, isRequestAuthorizedFor } from "../security";
@@ -58,13 +58,51 @@ export function authAuditMetadata(req: Request): Record<string, unknown> {
58
58
  },
59
59
  };
60
60
  }
61
- return hasPresentedCredential(req) ? { auth: { type: "root" } } : {};
61
+ if (!hasPresentedCredential(req)) return {};
62
+ // Root (god-token) credential: stamp the seeded-admin identity it now carries (#389 break-glass),
63
+ // so the audit trail records *who* acted instead of an anonymous root.
64
+ const acting = resolveActingUser(req);
65
+ return acting
66
+ ? { auth: { type: "root", userId: acting.userId, source: acting.source } }
67
+ : { auth: { type: "root" } };
62
68
  }
63
69
 
64
70
  export function isRootCredentialRequest(req: Request): boolean {
65
71
  return hasPresentedCredential(req) && isAuthorized(req) && !getComponentAuth(req) && !getIntegrationAuth(req);
66
72
  }
67
73
 
74
+ /**
75
+ * Which human is acting, and how their request was authenticated (#389). The single bridge from a
76
+ * request to a `users.id`:
77
+ * - a human session token (component token whose `sub` is a `user:<id>` address) → that user, via
78
+ * the SDK resolver (never a hand-rolled namespace) — `source: "session"`;
79
+ * - the raw god-token / root credential (`isRootCredentialRequest`) → the seeded admin
80
+ * `DEFAULT_USER_ID` with `source: "break-glass"`. This is what gives the env token an *identity*
81
+ * instead of the anonymous "server" it carried before, while it KEEPS bypassing scope checks
82
+ * unchanged (the gate in src/index.ts is untouched). Works with zero `auth_identities` rows, so
83
+ * it is simultaneously the break-glass path and the first-user bootstrap.
84
+ *
85
+ * Returns null for a machine component/integration token (no human is acting).
86
+ */
87
+ export type ActingUserSource = "session" | "break-glass";
88
+
89
+ export interface ActingUser {
90
+ userId: string;
91
+ source: ActingUserSource;
92
+ }
93
+
94
+ export function resolveActingUser(req: Request): ActingUser | null {
95
+ const component = getComponentAuth(req);
96
+ if (component && isHumanAgentId(component.sub)) {
97
+ const userId = userIdFromAddress(component.sub);
98
+ if (userId) return { userId, source: "session" };
99
+ }
100
+ if (isRootCredentialRequest(req)) {
101
+ return { userId: DEFAULT_USER_ID, source: "break-glass" };
102
+ }
103
+ return null;
104
+ }
105
+
68
106
  function sanitizeAttribution(raw: unknown): string | undefined {
69
107
  if (typeof raw !== "string") return undefined;
70
108
  // eslint-disable-next-line no-control-regex
@@ -1,6 +1,6 @@
1
1
  // Auto-split from routes.ts (#299). Domain: agents.
2
2
  import { VALID_AGENT_STATUSES, agentSessionStatus, auditEvent, canViewAgentRoute, error, json, memoryContext, memoryErrorResponse, normalizeAgentSessionGuard, parseBody, parseId, parseQueryInt, visibleAgentIdsForRequest, type Handler } from "./_shared";
3
- import { ValidationError, deleteAgent, getAgent, getAgentTimeline, heartbeat, listAgentChatHistory, listAgents, listArtifactsForEntity, listContextSnapshots, listPendingReplyObligations, listQuotaSnapshots, markReady, searchAgents, setLabel, setStatus, setTags, validateAgentSession } from "../db";
3
+ import { ValidationError, deleteAgent, getAgent, getAgentTimeline, getCrashReport, heartbeat, listAgentChatHistory, listAgents, listArtifactsForEntity, listContextSnapshots, listPendingReplyObligations, listQuotaSnapshots, markReady, searchAgents, setLabel, setStatus, setTags, validateAgentSession } from "../db";
4
4
  import { attachBranchState, attachBranchStates } from "../agent-branch-state";
5
5
  import { attachIssueRepo, attachIssueRepos } from "../agent-issue-repo";
6
6
  import { cleanStringArray } from "../validation";
@@ -68,6 +68,16 @@ export const getAgentById: Handler = (req, params) => {
68
68
  return json(attachIssueRepo(attachBranchState(agent)));
69
69
  };
70
70
 
71
+ // #636 — "why did it die" data source. The crash report outlives the agent row (separate table),
72
+ // so this resolves even after the reaper removed the agent. Existence-hiding still applies WHILE the
73
+ // agent exists; once reaped, an authenticated operator can read the post-mortem.
74
+ export const getAgentCrashReportRoute: Handler = (req, params) => {
75
+ const id = params.id!;
76
+ const agent = getAgent(id);
77
+ if (agent && !canViewAgentRoute(req, agent.id)) return error("agent not found", 404);
78
+ return json({ agentId: id, report: getCrashReport(id) });
79
+ };
80
+
71
81
  export const getAgentReplyObligations: Handler = (_req, params) => {
72
82
  const agent = getAgent(params.id!);
73
83
  return agent ? json(listPendingReplyObligations(agent.id)) : error("agent not found", 404);
@@ -0,0 +1,107 @@
1
+ // Authentication & session routes (#389). These four paths sit on a pre-auth PUBLIC ALLOWLIST
2
+ // (src/index.ts) so they are reachable unauthenticated — each handler owns its own auth: login
3
+ // verifies credentials, refresh validates the refresh token, logout revokes the presented session.
4
+ // Everything else on the relay still flows through the existing component-token gate.
5
+ import { isRecord, type User } from "agent-relay-sdk";
6
+ import { error, json, parseBody, type Handler } from "./_shared";
7
+ import { getAuthProvider, listAuthProviders, mintUserSession, resolveRefreshToken } from "../auth";
8
+ import { getAuthIdentity, getUser } from "../db";
9
+ import { revokeToken } from "../token-db.ts";
10
+ import { getComponentAuth } from "../security";
11
+
12
+ /** Public projection of a user for login/refresh responses — never leaks internal-only columns. */
13
+ function userView(user: User): Record<string, unknown> {
14
+ return {
15
+ id: user.id,
16
+ handle: user.handle,
17
+ displayName: user.displayName,
18
+ role: user.role,
19
+ tenantId: user.tenantId,
20
+ avatarUrl: user.avatarUrl,
21
+ };
22
+ }
23
+
24
+ function sessionResponse(user: User): Response {
25
+ const session = mintUserSession(user);
26
+ return json({
27
+ tokenType: "Bearer",
28
+ accessToken: session.accessToken,
29
+ refreshToken: session.refreshToken,
30
+ expiresIn: session.expiresIn,
31
+ user: userView(user),
32
+ });
33
+ }
34
+
35
+ /** GET /api/auth/providers — the login screen reads this to render available login methods. */
36
+ export const getAuthProvidersRoute: Handler = () =>
37
+ json({
38
+ providers: listAuthProviders().map((provider) => ({
39
+ id: provider.id,
40
+ displayName: provider.displayName,
41
+ flow: provider.flow,
42
+ })),
43
+ });
44
+
45
+ /**
46
+ * POST /api/auth/login — body `{ provider, ...credentials }`. Resolves the provider, verifies the
47
+ * credentials to a `providerSubject`, maps it through `auth_identities` to a user, and mints a
48
+ * session. Every failure returns the same generic 401 (no provider/user/credential enumeration).
49
+ */
50
+ export const postAuthLoginRoute: Handler = async (req) => {
51
+ const parsed = await parseBody<unknown>(req);
52
+ if (!parsed.ok) return error(parsed.error, parsed.status);
53
+ if (!isRecord(parsed.body)) return error("login body required", 400);
54
+ const providerId = parsed.body.provider;
55
+ if (typeof providerId !== "string" || !providerId) return error("provider required", 400);
56
+
57
+ const provider = getAuthProvider(providerId);
58
+ if (!provider) return error("invalid credentials", 401);
59
+
60
+ const result = await provider.verify(parsed.body);
61
+ if (!result) return error("invalid credentials", 401);
62
+
63
+ const identity = getAuthIdentity(provider.id, result.providerSubject);
64
+ if (!identity) return error("invalid credentials", 401);
65
+
66
+ const user = getUser(identity.userId);
67
+ if (!user || user.status !== "active") return error("invalid credentials", 401);
68
+
69
+ return sessionResponse(user);
70
+ };
71
+
72
+ /**
73
+ * POST /api/auth/refresh — body `{ refreshToken }`. Validates the refresh token and mints a fresh
74
+ * session, rotating the refresh token: the consumed one is revoked so a captured refresh token is
75
+ * single-use. Returns the same generic 401 on any invalid/expired/revoked token.
76
+ */
77
+ export const postAuthRefreshRoute: Handler = async (req) => {
78
+ const parsed = await parseBody<unknown>(req);
79
+ if (!parsed.ok) return error(parsed.error, parsed.status);
80
+ if (!isRecord(parsed.body)) return error("refresh body required", 400);
81
+ const refreshToken = parsed.body.refreshToken;
82
+ if (typeof refreshToken !== "string" || !refreshToken) return error("refreshToken required", 400);
83
+
84
+ const resolved = resolveRefreshToken(refreshToken);
85
+ if (!resolved) return error("invalid refresh token", 401);
86
+
87
+ const response = sessionResponse(resolved.user);
88
+ // Rotate: revoke the consumed refresh token so it can't be replayed (the fresh pair supersedes it).
89
+ if (resolved.payload.jti) revokeToken(resolved.payload.jti, "rotation");
90
+ return response;
91
+ };
92
+
93
+ /**
94
+ * POST /api/auth/logout — revokes the presented session token and, if supplied, its refresh token.
95
+ * Idempotent: revoking an already-revoked or absent token is a no-op. Always returns 204.
96
+ */
97
+ export const postAuthLogoutRoute: Handler = async (req) => {
98
+ const component = getComponentAuth(req);
99
+ if (component?.jti) revokeToken(component.jti, "admin");
100
+
101
+ const parsed = await parseBody<unknown>(req);
102
+ if (parsed.ok && isRecord(parsed.body) && typeof parsed.body.refreshToken === "string") {
103
+ const resolved = resolveRefreshToken(parsed.body.refreshToken);
104
+ if (resolved?.payload.jti) revokeToken(resolved.payload.jti, "admin");
105
+ }
106
+ return new Response(null, { status: 204 });
107
+ };
@@ -16,7 +16,7 @@ import { notifyBranchLandFailed, notifyBranchLanded } from "../branch-landed";
16
16
  import { seedProviderConfigs } from "../provider-config-store";
17
17
  import { notifyAgentSpawnFailed } from "../agent-lifecycle-events";
18
18
  import { handleReviewerMergeResult } from "../reviewer-pipeline";
19
- import { scheduleRepoMerge } from "../workspace-auto-merge";
19
+ import { CLEAR_MERGE_NO_PROGRESS, noteMergeNoProgress, scheduleRepoMerge } from "../workspace-auto-merge";
20
20
  import { enqueueContinuationInjectionAfterSelfResume } from "../self-resume";
21
21
  import { type Command, type CommandStatus, type CreateCommandInput, type WorkspaceStatus } from "../types";
22
22
 
@@ -220,6 +220,11 @@ export const patchCommand: Handler = async (req, params) => {
220
220
  mergeResult: command.result,
221
221
  mergeCommandId: command.id,
222
222
  mergedAt: Date.now(),
223
+ // A merge that settles `succeeded` made genuine progress (land / recycle /
224
+ // noop-resolve / PR-opened) — clear any prior no-progress back-off so the lane
225
+ // is attempted normally next time (#638).
226
+ ...CLEAR_MERGE_NO_PROGRESS,
227
+ mergeError: undefined,
223
228
  ...(normalizedStatus !== resultStatus ? { mergeStatusNormalizedFrom: resultStatus } : {}),
224
229
  // The land consumes the steward's claim (#208 steward contract / vent
225
230
  // #62): a successful land must release it, or `steward inspect` keeps
@@ -259,12 +264,25 @@ export const patchCommand: Handler = async (req, params) => {
259
264
  }
260
265
  }
261
266
  } else if (command.status === "failed" && command.correlationId) {
262
- // Merge couldn't complete — don't leave it stuck in merge_planned.
267
+ // Merge couldn't complete — don't leave it stuck in merge_planned. A no-progress
268
+ // merge now settles `failed` (orchestrator #638) carrying the result; honor the
269
+ // orchestrator's intended target status when it gave a valid one so a real
270
+ // `conflict` routes to the steward (a non-landable status the auto-merge job
271
+ // skips, which itself stops the loop) rather than bouncing back to the landable
272
+ // `review_requested`. Surface the error and count the no-progress attempt so the
273
+ // auto-merge job backs off after N instead of busy-looping (#638).
263
274
  const current = getWorkspace(command.correlationId);
264
275
  if (current && current.status === "merge_planned") {
265
- updateWorkspaceStatus(command.correlationId, "review_requested", {
276
+ const failedResultStatus = isRecord(command.result)
277
+ ? optionalEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined
278
+ : undefined;
279
+ const nextStatus: WorkspaceStatus = failedResultStatus && failedResultStatus !== "merge_planned"
280
+ ? failedResultStatus
281
+ : "review_requested";
282
+ updateWorkspaceStatus(command.correlationId, nextStatus, {
266
283
  mergeCommandId: command.id,
267
284
  mergeError: command.error ?? "merge failed",
285
+ ...noteMergeNoProgress(current.metadata),
268
286
  });
269
287
  notifyBranchLandFailed({
270
288
  workspace: current,
@@ -1,5 +1,5 @@
1
1
  // Auto-split from routes.ts (#299). Router: route table + matchRoute dispatch.
2
- import { deleteAgentActiveMemories, deleteAgentById, findAgents, getAgentActiveMemories, getAgentById, getAgentChatHistory, getAgentContextSnapshots, getAgentQuotaSnapshots, getAgentReplyObligations, getAgentTimelineRoute, getAgents, getMessageArtifactsRoute, getTaskArtifactsRoute, patchAgentLabel, patchAgentReady, patchAgentStatus, patchAgentTags, postHeartbeat } from "./agents";
2
+ import { deleteAgentActiveMemories, deleteAgentById, findAgents, getAgentActiveMemories, getAgentById, getAgentChatHistory, getAgentContextSnapshots, getAgentCrashReportRoute, getAgentQuotaSnapshots, getAgentReplyObligations, getAgentTimelineRoute, getAgents, getMessageArtifactsRoute, getTaskArtifactsRoute, patchAgentLabel, patchAgentReady, patchAgentStatus, patchAgentTags, postHeartbeat } from "./agents";
3
3
  import { deleteAgentProfileRoute, getAgentProfileRoute, getAgentProfilesRoute, putAgentProfileRoute } from "./agent-profiles";
4
4
  import { deleteAgentTerminalSession, getAgentDrive, getAgentPresence, postAgentAction, postAgentDrive, postAgentPermissionDecision, postAgentPresence, postAgentPrompt, postAgentTerminalSession } from "./agent-sessions";
5
5
  import { deleteMyAvatar, deleteUserAvatarById, getMe, getUserAvatar, getUsers, headUserAvatar, patchMe, patchUserById, postMyAvatar, postUserAvatarById } from "./users";
@@ -18,6 +18,7 @@ import { deleteSpawnPolicyRoute, getSpawnPoliciesHealth, getSpawnPoliciesRoute,
18
18
  import { deleteTokenProfileRoute, getTokenById, getTokenMe, getTokenProfiles, getTokens, patchTokenProfile, postIntegrationRuntimeToken, postInteractiveRunnerRuntimeToken, postMcpRuntimeToken, postOrchestratorRunnerToken, postRuntimeTokenRenew, postToken, postTokenProfile, postTokenRevoke } from "./tokens";
19
19
  import { deleteWorkspaceById, getWorkspaceById, getWorkspaceDiagnostics, getWorkspaceDiff, getWorkspaceGitState, getWorkspaceMergePreview, getWorkspaceOrphans, getWorkspaceRecoveryBranches, getWorkspaceStewards, getWorkspaces, postWorkspaceAction, postWorkspaceCleanupStale, postWorkspaceOrphanReclaim, postWorkspaceRecoveryBranchDiscard } from "./workspaces";
20
20
  import { error, type Handler } from "./_shared";
21
+ import { getAuthProvidersRoute, postAuthLoginRoute, postAuthLogoutRoute, postAuthRefreshRoute } from "./auth";
21
22
  import { getActivityEvents, postActivityEvent } from "./activity";
22
23
  import { getAnalyticsRoute, getHealthRoute, getMaintenanceJobs, getStatsRoute, postMaintenanceJobRun, postSystemReap } from "./stats";
23
24
  import { getApiDocs, getApiSpec } from "./spec";
@@ -69,6 +70,13 @@ function route(method: string, path: string, handler: Handler): Route {
69
70
  }
70
71
 
71
72
  const routes: Route[] = [
73
+ // Auth & sessions (#389). On the pre-auth public allowlist (src/index.ts) — each handler owns
74
+ // its own auth (login verifies credentials, refresh validates the refresh token, logout revokes).
75
+ route("GET", "/api/auth/providers", getAuthProvidersRoute),
76
+ route("POST", "/api/auth/login", postAuthLoginRoute),
77
+ route("POST", "/api/auth/refresh", postAuthRefreshRoute),
78
+ route("POST", "/api/auth/logout", postAuthLogoutRoute),
79
+
72
80
  route("POST", "/api/artifacts", postArtifact),
73
81
  route("GET", "/api/artifacts", getArtifacts),
74
82
  route("GET", "/api/artifacts/:id/content", getArtifactContent),
@@ -113,6 +121,7 @@ const routes: Route[] = [
113
121
  route("GET", "/api/agents/:id/timeline", getAgentTimelineRoute),
114
122
  route("GET", "/api/agents/:id/active-memories", getAgentActiveMemories),
115
123
  route("GET", "/api/agents/:id/reply-obligations", getAgentReplyObligations),
124
+ route("GET", "/api/agents/:id/crash-report", getAgentCrashReportRoute),
116
125
  route("DELETE", "/api/agents/:id/active-memories", deleteAgentActiveMemories),
117
126
  route("GET", "/api/agents/:id", getAgentById),
118
127
  route("PATCH", "/api/agents/:id/status", patchAgentStatus),
@@ -2,6 +2,7 @@
2
2
  import { APPROVAL_MODES, SPAWN_PROVIDERS, VALID_WORKSPACE_MODES, isRecord } from "agent-relay-sdk";
3
3
  import { CONTRACT_VERSIONS, parseRuntimeCapabilities, parseRuntimeContracts, parseRuntimePackage, type RuntimeCapabilities, type RuntimeContracts, type RuntimePackageMetadata } from "../contracts";
4
4
  import { notifyAgentOffline } from "../agent-lifecycle-events";
5
+ import { diagnoseAgentDeath } from "../agent-death-diagnosis";
5
6
  import { VERSION } from "../config";
6
7
  import { ValidationError, deleteOrchestrator, getOrchestrator, listOrchestrators, orchestratorHeartbeat, recordAgentExitDiagnostics, setOrchestratorUpgradeState, updateManagedAgents, upsertOrchestrator } from "../db";
7
8
  import { auditEvent, authAuditMetadata, authorizeRoute, cleanJsonArray, cleanSafeNumber, dashboardAttribution, emitCommand, error, isRootCredentialRequest, json, parseBody, spawnRequestId, type Handler } from "./_shared";
@@ -209,7 +210,10 @@ export const patchOrchestratorAgents: Handler = async (req, params) => {
209
210
  // (its WHERE excludes status='offline'), guaranteeing a single notifyAgentOffline emission.
210
211
  // notifyAgentOffline no-ops for non-spawned agents. Only fire when we actually recorded the
211
212
  // exit (agent !== null) so an already-offline row can't double-notify.
212
- notifyAgentOffline(exited.agentId, exitReason(exited));
213
+ // #636 — classify the death from the captured exit diagnostics + agent state and ride the
214
+ // verdict on the terminal event (also persisted as a crash report inside notifyAgentOffline).
215
+ const diagnosis = diagnoseAgentDeath({ agent, reason: exitReason(exited), exit: exited });
216
+ notifyAgentOffline(exited.agentId, exitReason(exited), diagnosis);
213
217
  }
214
218
  auditEvent({
215
219
  clientId: [
@@ -324,6 +328,15 @@ function cleanManagedSessionExitDiagnostics(value: unknown, index: number): Mana
324
328
  runnerInfoFile: cleanString(value.runnerInfoFile, `exitedAgents[${index}].runnerInfoFile`, { max: 500 }),
325
329
  runnerInfoPresent: typeof value.runnerInfoPresent === "boolean" ? value.runnerInfoPresent : undefined,
326
330
  systemd,
331
+ // #636 — termination signal / exit code / OOM probe captured by the orchestrator.
332
+ signal: cleanString(value.signal, `exitedAgents[${index}].signal`, { max: 40 }),
333
+ exitCode: cleanSafeNumber(value.exitCode),
334
+ oom: isRecord(value.oom)
335
+ ? {
336
+ source: cleanString(value.oom.source, `exitedAgents[${index}].oom.source`, { required: true, max: 80 })!,
337
+ detail: cleanString(value.oom.detail, `exitedAgents[${index}].oom.detail`, { max: 240 }),
338
+ }
339
+ : undefined,
327
340
  unavailable,
328
341
  lastError: cleanString(value.lastError, `exitedAgents[${index}].lastError`, { required: true, max: 1000 })!,
329
342
  };
@@ -1,7 +1,7 @@
1
1
  // Auto-split from routes.ts (#299). Domain: tokens.
2
- import { SPAWN_PROVIDERS, isRecord } from "agent-relay-sdk";
2
+ import { SPAWN_PROVIDERS, isRecord, userSinkId } from "agent-relay-sdk";
3
3
  import { ValidationError, getOrchestrator, upsertIntegrationRegistry } from "../db";
4
- import { auditEvent, authAuditMetadata, authorizeRoute, error, isRootCredentialRequest, json, parseBody, type Handler } from "./_shared";
4
+ import { auditEvent, authAuditMetadata, authorizeRoute, error, isRootCredentialRequest, json, parseBody, resolveActingUser, type Handler } from "./_shared";
5
5
  import { cleanString, cleanStringArray, cleanTokenConstraints, optionalEnum } from "../validation";
6
6
  import { createToken, deleteTokenProfile, getToken, getTokenProfile, listTokenProfiles, listTokens, renewToken, revokeToken, updateTokenProfile, upsertTokenProfile } from "../token-db";
7
7
  import { getComponentAuth, getIntegrationAuth, isAuthorized } from "../security";
@@ -36,6 +36,12 @@ export const getTokenMe: Handler = (req) => {
36
36
  }
37
37
 
38
38
  if (isAuthorized(req)) {
39
+ // Root (god-token) now carries the seeded-admin identity (#389): report *who* is acting
40
+ // (break-glass → user:default) instead of the anonymous "server" it showed before.
41
+ const acting = resolveActingUser(req);
42
+ if (acting) {
43
+ return json({ actor: userSinkId(acting.userId), userId: acting.userId, source: acting.source, kind: "server", scopes: ["*"] });
44
+ }
39
45
  return json({ actor: "server", kind: "server", scopes: ["*"] });
40
46
  }
41
47
  return error("unauthorized", 401);
package/src/security.ts CHANGED
@@ -539,7 +539,7 @@ function isTokenConstraints(value: unknown): value is TokenConstraints {
539
539
  for (const [key, item] of Object.entries(record)) {
540
540
  if (["terminalAttach", "logsRead", "canDelegate"].includes(key)) {
541
541
  if (typeof item !== "boolean") return false;
542
- } else if (key === "cwd" || key === "spawnedBy" || key === "teamId" || key === "projectId") {
542
+ } else if (key === "cwd" || key === "spawnedBy" || key === "teamId" || key === "projectId" || key === "tenantId") {
543
543
  if (typeof item !== "string") return false;
544
544
  } else if (key === "maxSpawnedAgents") {
545
545
  if (typeof item !== "number") return false;
@@ -0,0 +1,33 @@
1
+ // #633 — owning service for the silent clean-exit terminal event. A runner whose provider
2
+ // terminally exits stays alive (manual-resume hold), so neither the orchestrator tmux-gone path nor
3
+ // the heartbeat reaper fires. The runner marks the offline status edge with a `terminalProviderExit`
4
+ // blob; this service turns that edge into a terminal `agent.exited` event with a #636 diagnosis, and
5
+ // clears the hold when the provider genuinely resumes. Kept out of the bus transport so the
6
+ // transport stays thin (transport-thinness ratchet, #348).
7
+ import { isRecord } from "agent-relay-sdk";
8
+ import { clearAgentMetaKeys } from "../db";
9
+ import { notifyAgentOffline } from "../agent-lifecycle-events";
10
+ import { diagnoseAgentDeath } from "../agent-death-diagnosis";
11
+ import type { AgentCard } from "../types";
12
+
13
+ /**
14
+ * React to an agent status transition observed on the bus.
15
+ * - dead→live with a stale marker: clear the hold so a keepalive can resurrect normally and a future
16
+ * death isn't mislabelled off a stale marker.
17
+ * - live→offline carrying a `terminalProviderExit` marker: fire the terminal event once (edge), with
18
+ * a death diagnosis (also persisted as a crash report inside notifyAgentOffline). No-op for
19
+ * non-spawned agents (notifyAgentOffline guards on lineage) and graceful shutdowns (no marker).
20
+ */
21
+ export function reconcileTerminalProviderExit(agentId: string, before: AgentCard | null | undefined, after: AgentCard | null | undefined): void {
22
+ if (after && after.status !== "offline" && before?.status === "offline" && isRecord(after.meta?.terminalProviderExit)) {
23
+ clearAgentMetaKeys(agentId, ["terminalProviderExit"]);
24
+ return;
25
+ }
26
+ if (!after || after.status !== "offline") return;
27
+ if (before && before.status === "offline") return;
28
+ const marker = after.meta?.terminalProviderExit;
29
+ if (!isRecord(marker)) return;
30
+ const reasonText = typeof marker.reason === "string" ? marker.reason : "provider exited";
31
+ const reason = `provider exited (${reasonText})`;
32
+ notifyAgentOffline(agentId, reason, diagnoseAgentDeath({ agent: after, reason }));
33
+ }