agent-relay-server 0.121.5 → 0.122.1

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 (179) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +6 -6
  3. package/public/assets/{MessageBubble-CLqxCG9b.js → MessageBubble-DRdUD-kW.js} +2 -2
  4. package/public/assets/{MessageBubble-CLqxCG9b.js.map → MessageBubble-DRdUD-kW.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-BkkToFEb.js → PlanGraphPanel-siKMPCln.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-BkkToFEb.js.map → PlanGraphPanel-siKMPCln.js.map} +1 -1
  7. package/public/assets/{activity-D-ocGQGs.js → activity-D3MNcSaV.js} +2 -2
  8. package/public/assets/{activity-D-ocGQGs.js.map → activity-D3MNcSaV.js.map} +1 -1
  9. package/public/assets/{agent-profiles-CRPgYTyb.js → agent-profiles-CKzttq6G.js} +2 -2
  10. package/public/assets/{agent-profiles-CRPgYTyb.js.map → agent-profiles-CKzttq6G.js.map} +1 -1
  11. package/public/assets/{agent-type-icon-CeEoMouY.js → agent-type-icon-0ZJRP1Vy.js} +2 -2
  12. package/public/assets/{agent-type-icon-CeEoMouY.js.map → agent-type-icon-0ZJRP1Vy.js.map} +1 -1
  13. package/public/assets/{agents-Chw5E_2q.js → agents-CuVj_rfG.js} +2 -2
  14. package/public/assets/{agents-Chw5E_2q.js.map → agents-CuVj_rfG.js.map} +1 -1
  15. package/public/assets/{analytics-BmNDG0hR.js → analytics-iob6C27d.js} +2 -2
  16. package/public/assets/{analytics-BmNDG0hR.js.map → analytics-iob6C27d.js.map} +1 -1
  17. package/public/assets/{automation-D7g90kTv.js → automation-B2ixxs4q.js} +2 -2
  18. package/public/assets/{automation-D7g90kTv.js.map → automation-B2ixxs4q.js.map} +1 -1
  19. package/public/assets/{branch-state-badge-Bv7DhLBZ.js → branch-state-badge-CafBJWKo.js} +2 -2
  20. package/public/assets/{branch-state-badge-Bv7DhLBZ.js.map → branch-state-badge-CafBJWKo.js.map} +1 -1
  21. package/public/assets/{channels-CLwPeEPi.js → channels-C-WsbL2H.js} +2 -2
  22. package/public/assets/{channels-CLwPeEPi.js.map → channels-C-WsbL2H.js.map} +1 -1
  23. package/public/assets/{chat-Cgj7VKzc.js → chat-DeoVTVob.js} +2 -2
  24. package/public/assets/{chat-Cgj7VKzc.js.map → chat-DeoVTVob.js.map} +1 -1
  25. package/public/assets/{connectors-Br6fiTny.js → connectors-C2Ac03LJ.js} +2 -2
  26. package/public/assets/{connectors-Br6fiTny.js.map → connectors-C2Ac03LJ.js.map} +1 -1
  27. package/public/assets/display-CJzSoTMq.js +3 -0
  28. package/public/assets/display-CJzSoTMq.js.map +1 -0
  29. package/public/assets/{formatted-body-CmkuD23M.js → formatted-body-35XBOY5L.js} +3 -3
  30. package/public/assets/{formatted-body-CmkuD23M.js.map → formatted-body-35XBOY5L.js.map} +1 -1
  31. package/public/assets/{formatted-body-impl-DbdFmbwW.js → formatted-body-impl-RmDxI5Ux.js} +2 -2
  32. package/public/assets/{formatted-body-impl-DbdFmbwW.js.map → formatted-body-impl-RmDxI5Ux.js.map} +1 -1
  33. package/public/assets/index-D0xMQQb_.js +25 -0
  34. package/public/assets/{index-D9OogaB5.js.map → index-D0xMQQb_.js.map} +1 -1
  35. package/public/assets/{insights-qvaPqWCV.js → insights-B643SFvm.js} +2 -2
  36. package/public/assets/{insights-qvaPqWCV.js.map → insights-B643SFvm.js.map} +1 -1
  37. package/public/assets/{integrations-DYEGSqq_.js → integrations-hFIHeCF4.js} +2 -2
  38. package/public/assets/{integrations-DYEGSqq_.js.map → integrations-hFIHeCF4.js.map} +1 -1
  39. package/public/assets/{maintenance-C0HIEB-J.js → maintenance-CURvdp_Z.js} +2 -2
  40. package/public/assets/{maintenance-C0HIEB-J.js.map → maintenance-CURvdp_Z.js.map} +1 -1
  41. package/public/assets/{managed-agents-DdS7aI38.js → managed-agents-DrZtdlSn.js} +2 -2
  42. package/public/assets/{managed-agents-DdS7aI38.js.map → managed-agents-DrZtdlSn.js.map} +1 -1
  43. package/public/assets/{markdown-preview-impl-BnXMH1z1.js → markdown-preview-impl-CVDO--ek.js} +2 -2
  44. package/public/assets/{markdown-preview-impl-BnXMH1z1.js.map → markdown-preview-impl-CVDO--ek.js.map} +1 -1
  45. package/public/assets/{memory-CSwx7bz8.js → memory-Bm7dt_Qf.js} +2 -2
  46. package/public/assets/{memory-CSwx7bz8.js.map → memory-Bm7dt_Qf.js.map} +1 -1
  47. package/public/assets/{messages-Be9CmvDv.js → messages-DTq4i2KZ.js} +2 -2
  48. package/public/assets/{messages-Be9CmvDv.js.map → messages-DTq4i2KZ.js.map} +1 -1
  49. package/public/assets/{orchestrators-BgtvvHvo.js → orchestrators-Dra318Ap.js} +2 -2
  50. package/public/assets/{orchestrators-BgtvvHvo.js.map → orchestrators-Dra318Ap.js.map} +1 -1
  51. package/public/assets/{overview-BvhbPWtF.js → overview-yr1Vmk64.js} +2 -2
  52. package/public/assets/{overview-BvhbPWtF.js.map → overview-yr1Vmk64.js.map} +1 -1
  53. package/public/assets/{pairs-CpXSOMx0.js → pairs-mnlKDBGp.js} +2 -2
  54. package/public/assets/{pairs-CpXSOMx0.js.map → pairs-mnlKDBGp.js.map} +1 -1
  55. package/public/assets/projects-Dzdf-gkK.js +2 -0
  56. package/public/assets/{projects-DdPNPFJI.js.map → projects-Dzdf-gkK.js.map} +1 -1
  57. package/public/assets/prompt-settings-ByL8SNCi.js +2 -0
  58. package/public/assets/{prompt-settings-BpZse7-X.js.map → prompt-settings-ByL8SNCi.js.map} +1 -1
  59. package/public/assets/{registry-pb1gTZEg.js → registry-CXWyOtpr.js} +2 -2
  60. package/public/assets/{registry-pb1gTZEg.js.map → registry-CXWyOtpr.js.map} +1 -1
  61. package/public/assets/{security-BgcX1n9D.js → security-0SBsc_4W.js} +2 -2
  62. package/public/assets/{security-BgcX1n9D.js.map → security-0SBsc_4W.js.map} +1 -1
  63. package/public/assets/{select-DZPVpKPv.js → select-Bw5z2KJ_.js} +2 -2
  64. package/public/assets/{select-DZPVpKPv.js.map → select-Bw5z2KJ_.js.map} +1 -1
  65. package/public/assets/{settings-hd5kzdRB.js → settings-78Cyq_tA.js} +4 -4
  66. package/public/assets/{settings-hd5kzdRB.js.map → settings-78Cyq_tA.js.map} +1 -1
  67. package/public/assets/{store-DKTLubBT.js → store-BPrVuKiv.js} +4 -4
  68. package/public/assets/store-BPrVuKiv.js.map +1 -0
  69. package/public/assets/{tasks-C5d2LU5E.js → tasks-Cg2MMeri.js} +2 -2
  70. package/public/assets/{tasks-C5d2LU5E.js.map → tasks-Cg2MMeri.js.map} +1 -1
  71. package/public/assets/teams-DWv6UwFV.js +2 -0
  72. package/public/assets/{teams-B8f2pGJe.js.map → teams-DWv6UwFV.js.map} +1 -1
  73. package/public/assets/{terminal-viewer-impl-CxAscH0U.js → terminal-viewer-impl-DBLDjbIg.js} +2 -2
  74. package/public/assets/{terminal-viewer-impl-CxAscH0U.js.map → terminal-viewer-impl-DBLDjbIg.js.map} +1 -1
  75. package/public/assets/{types-uVOXgvMT.js → types-BeJUSfDj.js} +2 -2
  76. package/public/assets/types-BeJUSfDj.js.map +1 -0
  77. package/public/assets/{user-avatar-Dw6mzWhv.js → user-avatar-BsOLyVJj.js} +2 -2
  78. package/public/assets/{user-avatar-Dw6mzWhv.js.map → user-avatar-BsOLyVJj.js.map} +1 -1
  79. package/public/assets/{work-queue-9imc7aNK.js → work-queue-DmwvnF6F.js} +2 -2
  80. package/public/assets/{work-queue-9imc7aNK.js.map → work-queue-DmwvnF6F.js.map} +1 -1
  81. package/public/assets/{workspaces-CLYc3yF3.js → workspaces-dIBivJN8.js} +2 -2
  82. package/public/assets/{workspaces-CLYc3yF3.js.map → workspaces-dIBivJN8.js.map} +1 -1
  83. package/public/index.html +6 -6
  84. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  85. package/runner/plugins/claude/monitors/relay-monitor.provisioned.mjs +44 -938
  86. package/runner/src/adapter.ts +53 -18
  87. package/runner/src/adapters/claude-delivery.ts +127 -0
  88. package/runner/src/adapters/claude-quota-harvest.ts +92 -0
  89. package/runner/src/adapters/claude-session-probe.ts +225 -0
  90. package/runner/src/adapters/claude-status-detectors.ts +147 -0
  91. package/runner/src/adapters/claude-transcript-tail.ts +128 -0
  92. package/runner/src/adapters/claude-transcript.ts +414 -0
  93. package/runner/src/adapters/claude.ts +672 -0
  94. package/runner/src/adapters/codex-agent-message-capture.ts +139 -0
  95. package/runner/src/adapters/codex-client.ts +279 -0
  96. package/runner/src/adapters/codex.ts +1345 -0
  97. package/runner/src/attachment-cache.ts +189 -0
  98. package/runner/src/busy-reconciler.ts +102 -0
  99. package/runner/src/claim-tracker.ts +140 -0
  100. package/runner/src/claude-prompt-gates.ts +268 -0
  101. package/runner/src/config.ts +1 -4
  102. package/runner/src/context-probe-state.ts +6 -0
  103. package/runner/src/continuation-archive.ts +179 -0
  104. package/runner/src/control-server.ts +639 -0
  105. package/runner/src/index.ts +396 -0
  106. package/runner/src/launch-assembly.ts +508 -0
  107. package/runner/src/liveness.ts +50 -0
  108. package/runner/src/logger.ts +99 -0
  109. package/runner/src/mcp-outbox.ts +120 -0
  110. package/runner/src/message-body-config/config.ts +3 -0
  111. package/runner/src/message-body-config.ts +1 -0
  112. package/runner/src/native-self-resume.ts +202 -0
  113. package/runner/src/outbox.ts +342 -0
  114. package/runner/src/process-meta.ts +9 -0
  115. package/runner/src/profile-home.ts +260 -0
  116. package/runner/src/profile-projection.ts +305 -0
  117. package/runner/src/providers.ts +20 -0
  118. package/runner/src/provisioning.ts +314 -0
  119. package/runner/src/quota.ts +40 -0
  120. package/runner/src/rate-limit.ts +189 -0
  121. package/runner/src/relay-injection-events.ts +102 -0
  122. package/runner/src/relay-instructions.ts +60 -0
  123. package/runner/src/relay-mcp-proxy.ts +383 -0
  124. package/runner/src/relay-mcp.ts +156 -0
  125. package/runner/src/reply-obligation-cache.ts +136 -0
  126. package/runner/src/response-capture-report.ts +63 -0
  127. package/runner/src/runner-core.ts +2409 -0
  128. package/runner/src/runner-helpers.ts +435 -0
  129. package/runner/src/runner-insights.ts +105 -0
  130. package/runner/src/runner.ts +14 -0
  131. package/runner/src/session-destroy.ts +22 -0
  132. package/runner/src/session-insights.ts +118 -0
  133. package/runner/src/session-scratch.ts +271 -0
  134. package/runner/src/template-resolver.ts +29 -0
  135. package/runner/src/version.ts +43 -0
  136. package/src/agent-card-projection.ts +5 -1
  137. package/src/agent-command-activity.ts +39 -0
  138. package/src/automations/reconcile.ts +2 -2
  139. package/src/cli/workspace.ts +3 -1
  140. package/src/config.ts +10 -1
  141. package/src/db/agents.ts +118 -37
  142. package/src/db/message-reads.ts +50 -0
  143. package/src/db/messages.ts +48 -9
  144. package/src/db/migrations.ts +54 -0
  145. package/src/db/provider-quota-burn.ts +96 -67
  146. package/src/db/provider-quotas.ts +29 -7
  147. package/src/db/scheduled-timers.ts +21 -0
  148. package/src/db/schema.ts +1 -0
  149. package/src/db/tasks.ts +33 -3
  150. package/src/db/work-evidence.ts +10 -7
  151. package/src/event-loop-lag.ts +15 -0
  152. package/src/gate-resolver.ts +19 -1
  153. package/src/maintenance/jobs.ts +23 -6
  154. package/src/managed-policy.ts +5 -12
  155. package/src/mcp-plan-tools.ts +13 -1
  156. package/src/project-instructions.ts +36 -2
  157. package/src/quota-advisory.ts +6 -4
  158. package/src/quota-band-transition.ts +23 -14
  159. package/src/routes/agent-sessions.ts +39 -7
  160. package/src/routes/agents.ts +3 -2
  161. package/src/routes/tasks.ts +16 -4
  162. package/src/routes/workspaces.ts +35 -10
  163. package/src/services/plan-graph.ts +27 -9
  164. package/src/services/register-agent.ts +12 -12
  165. package/src/services/send-message.ts +43 -15
  166. package/src/services/spawn-agent.ts +3 -0
  167. package/src/spawn-command.ts +8 -0
  168. package/src/spawn-targets.ts +19 -34
  169. package/src/sse.ts +25 -3
  170. package/src/workspace-merge.ts +25 -5
  171. package/src/workspace-orphans.ts +13 -6
  172. package/public/assets/display-exQDWum3.js +0 -3
  173. package/public/assets/display-exQDWum3.js.map +0 -1
  174. package/public/assets/index-D9OogaB5.js +0 -25
  175. package/public/assets/projects-DdPNPFJI.js +0 -2
  176. package/public/assets/prompt-settings-BpZse7-X.js +0 -2
  177. package/public/assets/store-DKTLubBT.js.map +0 -1
  178. package/public/assets/teams-B8f2pGJe.js +0 -2
  179. package/public/assets/types-uVOXgvMT.js.map +0 -1
@@ -0,0 +1,639 @@
1
+ import type { Server, ServerWebSocket } from "bun";
2
+ import type { InteractivePrompt, Message, ReplyObligation } from "agent-relay-sdk";
3
+ import { errMessage, isRecord } from "agent-relay-sdk";
4
+ import type { ProviderPermissionDecisionInput, ProviderStatusEvent, SemanticStatus, TerminalAttachSpec } from "./adapter";
5
+ import { obligationRequiresExplicitReply } from "./reply-obligation-cache";
6
+ import { relayReplyActionText } from "./relay-instructions";
7
+ import { logger, parseLogLevel, LOG_LEVELS } from "./logger";
8
+ import { buildRateLimitProviderState } from "./rate-limit";
9
+
10
+ // The AskUserQuestion / PermissionRequest hook (runner/plugins/claude/hooks/hooks.json)
11
+ // has a 900s Claude-side timeout. Resolve the server-side wait with a margin UNDER that
12
+ // so the server always returns a deterministic decision before Claude abandons the hook —
13
+ // at exact equality the two timers race with no winner (#693).
14
+ const PERMISSION_WAIT_MS = 880_000;
15
+ // Distinct from a human dismissal ("Dismissed/Denied from Agent Relay dashboard") so the
16
+ // agent can tell "nobody answered in time" (retriable) from "the human said no" (#693).
17
+ const PERMISSION_TIMEOUT_REASON =
18
+ "No response received within ~15 minutes (timeout, not a dismissal). Re-ask if you still need a decision.";
19
+
20
+ // A hook that failed in a way it could not handle itself reports here so the
21
+ // failure is never silent (#198 item 5). Phase 1 logs it FATAL to the per-agent
22
+ // log; Phase 2 (#196) will additionally route it through the runner outbox to the
23
+ // server.
24
+ export interface HookFatalReport {
25
+ hook: string;
26
+ error: string;
27
+ }
28
+
29
+ interface MonitorSocketData {
30
+ kind: "monitor";
31
+ }
32
+
33
+ type MonitorSocket = ServerWebSocket<MonitorSocketData>;
34
+
35
+ export interface ControlServer {
36
+ port: number;
37
+ url: string;
38
+ stop(): void;
39
+ deliverToMonitor(messages: Message[], timeoutMs?: number): Promise<number[]>;
40
+ resolvePermissionDecision(input: ProviderPermissionDecisionInput): boolean;
41
+ }
42
+
43
+ interface ControlServerOptions {
44
+ onStatus(status: ProviderStatusEvent): void;
45
+ onTerminalAttachSpec?(): Promise<TerminalAttachSpec>;
46
+ onReplyObligations?(): Promise<ReplyObligation[]>;
47
+ // Phase 1 live-session lane: a provider Stop hook hands over its transcript
48
+ // path so the runner can capture the assistant turn and surface it in the
49
+ // dashboard chat without the agent re-emitting it via /reply.
50
+ // isPreFlush: true signals a mid-turn call from a PreToolUse hook — flush any
51
+ // un-mirrored narrative before the interactive form appears in chat (#435).
52
+ onSessionTurn?(input: { transcriptPath: string; lastAssistantMessage?: unknown; isPreFlush?: boolean }): Promise<void>;
53
+ onPermissionResolved?(input: ResolvedPermissionPrompt): Promise<void> | void;
54
+ // A provider UserPromptSubmit hook hands over the prompt the human typed
55
+ // directly into the session (web terminal / TUI) so the runner can mirror it
56
+ // into the dashboard chat and start tailing the turn transcript for reasoning.
57
+ onUserPrompt?(input: { prompt: string; transcriptPath?: string }): Promise<void>;
58
+ // A provider session-boundary hook (Claude PreCompact / SessionEnd) signals an imminent
59
+ // context reset or termination so the runner can run end-of-session work (#183 pre-destroy
60
+ // seam: #184 context-ratio capture) before the invasive operation. `reason` is the raw
61
+ // provider reason (compact, clear, logout, …); transcriptPath is optional — the runner
62
+ // falls back to the last path it saw during the session.
63
+ onSessionBoundary?(input: { reason?: string; transcriptPath?: string }): Promise<void>;
64
+ // Phase 1 observability (#198): a hook reporting an unhandled failure. The
65
+ // control server already logs it FATAL; this is the seam for Phase 2 to also
66
+ // surface it to the server via the runner outbox.
67
+ onHookFatal?(report: HookFatalReport): void;
68
+ // Test seam: how long the permission hook waits for a dashboard decision before
69
+ // resolving as a timeout. Defaults to PERMISSION_WAIT_MS (margin under the 900s hook).
70
+ permissionWaitMs?: number;
71
+ }
72
+
73
+ export interface ResolvedPermissionPrompt {
74
+ provider: "claude";
75
+ kind: "questions" | "plan" | "command" | "tool";
76
+ title: string;
77
+ body: string;
78
+ promptBody?: string;
79
+ toolName: string;
80
+ hookEventName: string;
81
+ approvalId: string;
82
+ decision: ProviderPermissionDecisionInput["decision"];
83
+ decisionLabel: string;
84
+ questions?: unknown[];
85
+ answers?: Record<string, string>;
86
+ }
87
+
88
+ export function startControlServer(options: ControlServerOptions): ControlServer {
89
+ const monitors = new Set<MonitorSocket>();
90
+ const pendingDeliveries = new Map<string, { resolve(ids: number[]): void; timer: Timer }>();
91
+ const pendingPermissionRequests = new Map<string, { resolve(input: ProviderPermissionDecisionInput): void; timer: Timer }>();
92
+ // approvalIds already resolved, so a duplicate decision (e.g. bus replay after a
93
+ // reconnect, or both the PreToolUse and PermissionRequest hooks racing one answer)
94
+ // is an idempotent no-op instead of a silent command failure (#693).
95
+ const resolvedApprovals = new Set<string>();
96
+ let server!: Server<MonitorSocketData>;
97
+
98
+ server = Bun.serve<MonitorSocketData>({
99
+ hostname: "127.0.0.1",
100
+ port: 0,
101
+ fetch(req, srv) {
102
+ const url = new URL(req.url);
103
+ if (url.pathname === "/health") return Response.json({ ok: true });
104
+ if (url.pathname === "/status" && req.method === "GET") return Response.json({ ok: true });
105
+ if (url.pathname === "/status" && req.method === "POST") {
106
+ return handleStatus(req, options);
107
+ }
108
+ if (url.pathname === "/terminal/attach-spec" && req.method === "GET") {
109
+ if (!options.onTerminalAttachSpec) return Response.json({ error: "terminal attach is unavailable" }, { status: 404 });
110
+ return options.onTerminalAttachSpec()
111
+ .then((spec) => Response.json(spec))
112
+ .catch((error) => Response.json({ error: errMessage(error) }, { status: 409 }));
113
+ }
114
+ if (url.pathname === "/reply-obligations" && req.method === "GET") {
115
+ if (!options.onReplyObligations) return Response.json({ pending: false, count: 0 });
116
+ return options.onReplyObligations()
117
+ .then((obligations) => Response.json(replyObligationSummary(obligations)))
118
+ .catch((error) => Response.json({ error: errMessage(error) }, { status: 503 }));
119
+ }
120
+ if (url.pathname === "/reply-obligations/claude-stop" && req.method === "GET") {
121
+ if (!options.onReplyObligations) return Response.json({});
122
+ return options.onReplyObligations()
123
+ .then((obligations) => Response.json(replyObligationStopDecision(obligations)))
124
+ .catch((error) => Response.json({ error: errMessage(error) }, { status: 503 }));
125
+ }
126
+ if (url.pathname === "/permissions/request" && req.method === "POST") {
127
+ return handlePermissionRequest(req, options, pendingPermissionRequests);
128
+ }
129
+ if (url.pathname === "/session-turn" && req.method === "POST") {
130
+ return handleSessionTurn(req, options);
131
+ }
132
+ if (url.pathname === "/user-prompt" && req.method === "POST") {
133
+ return handleUserPrompt(req, options);
134
+ }
135
+ if (url.pathname === "/session-boundary" && req.method === "POST") {
136
+ return handleSessionBoundary(req, options);
137
+ }
138
+ if (url.pathname === "/log-level" && req.method === "GET") {
139
+ return Response.json({ level: logger.getLevel(), levels: LOG_LEVELS });
140
+ }
141
+ if (url.pathname === "/log-level" && req.method === "POST") {
142
+ return handleLogLevel(req);
143
+ }
144
+ if (url.pathname === "/hook-fatal" && req.method === "POST") {
145
+ return handleHookFatal(req, options);
146
+ }
147
+ if (url.pathname === "/rate-limit" && req.method === "POST") {
148
+ return handleRateLimit(req, options);
149
+ }
150
+ if (url.pathname === "/monitor") {
151
+ const upgraded = srv.upgrade(req, { data: { kind: "monitor" } });
152
+ return upgraded ? undefined : new Response("WebSocket upgrade failed", { status: 400 });
153
+ }
154
+ return new Response("not found", { status: 404 });
155
+ },
156
+ websocket: {
157
+ open(ws) {
158
+ monitors.add(ws);
159
+ },
160
+ message(_ws, raw) {
161
+ const parsed = parseJson(raw);
162
+ if (parsed?.type === "message.delivered" && Array.isArray(parsed.messageIds)) {
163
+ const deliveryId = typeof parsed.deliveryId === "string" ? parsed.deliveryId : "";
164
+ const pending = pendingDeliveries.get(deliveryId);
165
+ if (pending) {
166
+ clearTimeout(pending.timer);
167
+ pendingDeliveries.delete(deliveryId);
168
+ pending.resolve(parsed.messageIds.filter((id): id is number => Number.isSafeInteger(id)));
169
+ }
170
+ }
171
+ },
172
+ close(ws) {
173
+ monitors.delete(ws);
174
+ },
175
+ },
176
+ });
177
+
178
+ const port = server.port;
179
+ if (port === undefined) throw new Error("runner control server did not bind a port");
180
+
181
+ return {
182
+ port,
183
+ url: `http://127.0.0.1:${port}`,
184
+ stop() {
185
+ for (const pending of pendingDeliveries.values()) clearTimeout(pending.timer);
186
+ pendingDeliveries.clear();
187
+ for (const pending of pendingPermissionRequests.values()) clearTimeout(pending.timer);
188
+ pendingPermissionRequests.clear();
189
+ server.stop(true);
190
+ },
191
+ async deliverToMonitor(messages: Message[], timeoutMs = 30_000): Promise<number[]> {
192
+ if (monitors.size === 0) throw new Error("no Claude monitor connected");
193
+ const deliveryId = crypto.randomUUID();
194
+ const payload = JSON.stringify({ type: "message.deliver", deliveryId, messages });
195
+ return await new Promise<number[]>((resolve, reject) => {
196
+ const timer = setTimeout(() => {
197
+ pendingDeliveries.delete(deliveryId);
198
+ reject(new Error("monitor delivery timed out"));
199
+ }, timeoutMs);
200
+ pendingDeliveries.set(deliveryId, { resolve, timer });
201
+ for (const monitor of monitors) monitor.send(payload);
202
+ });
203
+ },
204
+ resolvePermissionDecision(input: ProviderPermissionDecisionInput): boolean {
205
+ const pending = pendingPermissionRequests.get(input.approvalId);
206
+ if (!pending) {
207
+ // Idempotent: a duplicate decision for an already-resolved approval is a
208
+ // benign no-op, not a dropped answer that fails the command (#693).
209
+ return resolvedApprovals.has(input.approvalId);
210
+ }
211
+ clearTimeout(pending.timer);
212
+ pendingPermissionRequests.delete(input.approvalId);
213
+ resolvedApprovals.add(input.approvalId);
214
+ if (resolvedApprovals.size > 1024) resolvedApprovals.clear();
215
+ pending.resolve(input);
216
+ options.onStatus({
217
+ status: "busy",
218
+ reason: "provider-turn",
219
+ providerState: {
220
+ state: "active",
221
+ reason: "permissionResolved",
222
+ label: "Claude approval resolved",
223
+ source: "claude",
224
+ updatedAt: Date.now(),
225
+ },
226
+ });
227
+ return true;
228
+ },
229
+ };
230
+ }
231
+
232
+ function replyObligationSummary(obligations: ReplyObligation[]): Record<string, unknown> {
233
+ const obligation = obligations[0];
234
+ if (!obligation) return { pending: false, count: 0 };
235
+ const subject = obligation.subject ? ` (${obligation.subject})` : "";
236
+ return {
237
+ pending: true,
238
+ count: obligations.length,
239
+ obligation,
240
+ reason: `Pending Agent Relay response for message #${obligation.messageId} from ${obligation.from}${subject}. If it still needs an answer, use ${relayReplyActionText(obligation.messageId)}. If multiple delivered messages are part of one current request, answer once to the latest relevant message. If the useful response was already delivered through Relay, do not send a separate status-only confirmation.`,
241
+ };
242
+ }
243
+
244
+ function replyObligationStopDecision(obligations: ReplyObligation[]): Record<string, unknown> {
245
+ // #418 — never block the turn to nag for a `user` obligation: the operator already receives
246
+ // this turn via the session mirror, so an explicit /reply double-delivers. Only agent↔agent /
247
+ // spawner obligations (#413) gate the turn here; the mirror's replyTo-linking clears the user
248
+ // one. (The /reply-obligations summary endpoint is left unfiltered — the obligation IS pending
249
+ // until the mirror lands, so observability stays accurate.)
250
+ const enforceable = obligations.filter(obligationRequiresExplicitReply);
251
+ const summary = replyObligationSummary(enforceable);
252
+ if (summary.pending !== true) return {};
253
+ return {
254
+ decision: "block",
255
+ reason: summary.reason,
256
+ };
257
+ }
258
+
259
+ async function handlePermissionRequest(
260
+ req: Request,
261
+ options: ControlServerOptions,
262
+ pendingPermissionRequests: Map<string, { resolve(input: ProviderPermissionDecisionInput): void; timer: Timer }>,
263
+ ): Promise<Response> {
264
+ const body = await req.json().catch(() => null);
265
+ if (!isRecord(body)) return Response.json({});
266
+ // #435: flush any un-mirrored narrative from the transcript before the form
267
+ // appears in chat — the transcript already has the assistant text block at
268
+ // PreToolUse time, but the Stop hook (which normally captures it) won't fire
269
+ // until after the user answers, so the form would otherwise arrive first.
270
+ const transcriptPath = typeof body.transcript_path === "string" ? body.transcript_path : "";
271
+ if (transcriptPath && options.onSessionTurn) {
272
+ await options.onSessionTurn({ transcriptPath, isPreFlush: true }).catch(() => {});
273
+ }
274
+ const approvalId = crypto.randomUUID();
275
+ const view = claudePermissionApprovalView(approvalId, body);
276
+ options.onStatus({
277
+ status: "busy",
278
+ reason: "provider-turn",
279
+ providerState: {
280
+ state: "blocked",
281
+ reason: "permissionRequest",
282
+ label: view.title,
283
+ recommendedAction: "Review the permission request in Agent Relay.",
284
+ source: "claude",
285
+ pendingApproval: view,
286
+ updatedAt: Date.now(),
287
+ },
288
+ });
289
+
290
+ const decision = await new Promise<ProviderPermissionDecisionInput>((resolve) => {
291
+ const timer = setTimeout(() => {
292
+ pendingPermissionRequests.delete(approvalId);
293
+ resolve({ approvalId, decision: "deny", reason: PERMISSION_TIMEOUT_REASON });
294
+ }, options.permissionWaitMs ?? PERMISSION_WAIT_MS);
295
+ pendingPermissionRequests.set(approvalId, { resolve, timer });
296
+ });
297
+
298
+ if (decision.reason !== PERMISSION_TIMEOUT_REASON && options.onPermissionResolved) {
299
+ await Promise.resolve(options.onPermissionResolved(claudeResolvedPermissionPrompt(view, decision, body))).catch(() => {});
300
+ }
301
+
302
+ return Response.json(claudePermissionHookResponse(decision, body));
303
+ }
304
+
305
+ // Build the normalized InteractivePrompt for a Claude PreToolUse permission/question hook.
306
+ // reasoningSettled is stamped `true` because handlePermissionRequest pre-flushes the
307
+ // transcript narrative (onSessionTurn isPreFlush) BEFORE this view is built and emitted, so
308
+ // the turn's reasoning is already mirrored ahead of the form (#435/#723). No `provider` tag —
309
+ // the server reads the typed contract, not provider identity.
310
+ function claudePermissionApprovalView(id: string, body: Record<string, unknown>): InteractivePrompt {
311
+ const toolName = typeof body.tool_name === "string" ? body.tool_name : "Tool";
312
+ const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
313
+ // AskUserQuestion is not a yes/no gate — it asks the user to pick answers.
314
+ // Surface the structured questions so the dashboard can render a form and
315
+ // return the selections via an "answer" decision (handled in the hook
316
+ // response below as a PreToolUse allow + updatedInput).
317
+ if (toolName === "AskUserQuestion" && Array.isArray(toolInput.questions) && toolInput.questions.length) {
318
+ const count = toolInput.questions.length;
319
+ return {
320
+ id,
321
+ kind: "questions",
322
+ title: count > 1 ? `Claude is asking ${count} questions` : "Claude is asking a question",
323
+ body: "",
324
+ questions: toolInput.questions as InteractivePrompt["questions"],
325
+ choices: [],
326
+ reasoningSettled: true,
327
+ };
328
+ }
329
+ // ExitPlanMode arrives through the generic PermissionRequest hook (it doesn't
330
+ // match the AskUserQuestion matcher), which used to render the raw tool_input
331
+ // JSON with generic Approve/Deny buttons. Surface the plan as markdown with the
332
+ // real plan-mode choices instead. approve → allow (exit plan mode and proceed);
333
+ // deny → keep planning.
334
+ if (toolName === "ExitPlanMode") {
335
+ const plan = typeof toolInput.plan === "string" && toolInput.plan.trim()
336
+ ? toolInput.plan
337
+ : JSON.stringify(toolInput);
338
+ return {
339
+ id,
340
+ kind: "plan",
341
+ title: "Claude is ready to code",
342
+ body: plan,
343
+ choices: [
344
+ { id: "approve", label: "Approve plan" },
345
+ { id: "deny", label: "Keep planning" },
346
+ ],
347
+ reasoningSettled: true,
348
+ };
349
+ }
350
+ const command = typeof toolInput.command === "string" ? toolInput.command : "";
351
+ const description = typeof toolInput.description === "string" ? toolInput.description : "";
352
+ const bodyText = [
353
+ command || description || JSON.stringify(toolInput),
354
+ typeof body.cwd === "string" ? `CWD: ${body.cwd}` : "",
355
+ typeof body.permission_mode === "string" ? `Mode: ${body.permission_mode}` : "",
356
+ ].filter(Boolean).join("\n");
357
+ return {
358
+ id,
359
+ kind: toolName.toLowerCase() === "bash" ? "command" : "tool",
360
+ title: `Approve ${toolName}`,
361
+ body: bodyText,
362
+ choices: [
363
+ { id: "approve", label: "Approve" },
364
+ { id: "approve-session", label: "Approve session" },
365
+ { id: "deny", label: "Deny" },
366
+ { id: "abort", label: "Abort" },
367
+ ],
368
+ reasoningSettled: true,
369
+ };
370
+ }
371
+
372
+ function claudePermissionHookResponse(decision: ProviderPermissionDecisionInput, body: Record<string, unknown>): Record<string, unknown> {
373
+ // AskUserQuestion comes through a PreToolUse hook. The only way to satisfy it
374
+ // headlessly is permissionDecision "allow" + updatedInput carrying the answers
375
+ // (echoing back the original questions). A bare "allow" is not sufficient, so
376
+ // anything that is not a populated answer is treated as a deny.
377
+ if (body.hook_event_name === "PreToolUse") {
378
+ const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
379
+ if (decision.decision === "answer" && decision.answers && Object.keys(decision.answers).length) {
380
+ return {
381
+ hookSpecificOutput: {
382
+ hookEventName: "PreToolUse",
383
+ permissionDecision: "allow",
384
+ updatedInput: { ...toolInput, answers: decision.answers },
385
+ },
386
+ };
387
+ }
388
+ return {
389
+ hookSpecificOutput: {
390
+ hookEventName: "PreToolUse",
391
+ permissionDecision: "deny",
392
+ permissionDecisionReason: decision.reason || "Dismissed from Agent Relay dashboard",
393
+ },
394
+ };
395
+ }
396
+ const hookEventName = "PermissionRequest";
397
+ // AskUserQuestion can be gated by the PermissionRequest hook instead of PreToolUse
398
+ // (e.g. when the PreToolUse hook returned no decision and Claude fell back to the
399
+ // default permission flow). The answer must still be honored, not turned into a deny
400
+ // (#693). PermissionRequest supports updatedInput on an allow, same as PreToolUse.
401
+ if (decision.decision === "answer" && decision.answers && Object.keys(decision.answers).length) {
402
+ const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
403
+ return {
404
+ hookSpecificOutput: {
405
+ hookEventName,
406
+ decision: {
407
+ behavior: "allow",
408
+ updatedInput: { ...toolInput, answers: decision.answers },
409
+ },
410
+ },
411
+ };
412
+ }
413
+ if (decision.decision === "approve" || decision.decision === "approve-session") {
414
+ const suggestions = Array.isArray(body.permission_suggestions) ? body.permission_suggestions : [];
415
+ return {
416
+ hookSpecificOutput: {
417
+ hookEventName,
418
+ decision: {
419
+ behavior: "allow",
420
+ ...(decision.decision === "approve-session" && suggestions.length ? { updatedPermissions: suggestions } : {}),
421
+ },
422
+ },
423
+ };
424
+ }
425
+ return {
426
+ hookSpecificOutput: {
427
+ hookEventName,
428
+ decision: {
429
+ behavior: "deny",
430
+ message: decision.reason || "Denied from Agent Relay dashboard",
431
+ interrupt: decision.decision === "abort",
432
+ },
433
+ },
434
+ };
435
+ }
436
+
437
+ function claudeResolvedPermissionPrompt(view: InteractivePrompt, decision: ProviderPermissionDecisionInput, body: Record<string, unknown>): ResolvedPermissionPrompt {
438
+ const toolName = typeof body.tool_name === "string" ? body.tool_name : "Tool";
439
+ const toolInput = isRecord(body.tool_input) ? body.tool_input : {};
440
+ const kind = view.kind === "questions" || view.kind === "plan" || view.kind === "command" || view.kind === "tool"
441
+ ? view.kind
442
+ : toolName.toLowerCase() === "bash" ? "command" : "tool";
443
+ const questions = Array.isArray(view.questions)
444
+ ? view.questions
445
+ : Array.isArray(toolInput.questions) ? toolInput.questions : undefined;
446
+ const promptBody = typeof view.body === "string" ? view.body : "";
447
+ const decisionLabel = claudePermissionDecisionLabel(kind, decision.decision);
448
+ return {
449
+ provider: "claude",
450
+ kind,
451
+ title: typeof view.title === "string" && view.title ? view.title : `Approve ${toolName}`,
452
+ body: claudeResolvedPermissionBody({ kind, title: typeof view.title === "string" && view.title ? view.title : `Approve ${toolName}`, toolName, promptBody, decisionLabel, questions, answers: decision.answers }),
453
+ ...(promptBody ? { promptBody } : {}),
454
+ toolName,
455
+ hookEventName: typeof body.hook_event_name === "string" ? body.hook_event_name : "PermissionRequest",
456
+ approvalId: decision.approvalId,
457
+ decision: decision.decision,
458
+ decisionLabel,
459
+ ...(questions ? { questions } : {}),
460
+ ...(decision.answers ? { answers: decision.answers } : {}),
461
+ };
462
+ }
463
+
464
+ function claudeResolvedPermissionBody(input: { kind: ResolvedPermissionPrompt["kind"]; title: string; toolName: string; promptBody: string; decisionLabel: string; questions?: unknown[]; answers?: Record<string, string> }): string {
465
+ if (input.kind === "questions") {
466
+ return [
467
+ input.title,
468
+ input.decisionLabel,
469
+ ...resolvedQuestionRows(input.questions, input.answers, input.decisionLabel).map((row) => `Q: ${row.question}\nA: ${row.answer}`),
470
+ ].filter(Boolean).join("\n\n");
471
+ }
472
+ const subject = input.kind === "plan" ? "Plan prompt resolved" : `${input.toolName} permission resolved`;
473
+ return [subject, `Decision: ${input.decisionLabel}`, input.promptBody.trim()].filter(Boolean).join("\n\n");
474
+ }
475
+
476
+ function resolvedQuestionRows(questions: unknown[] | undefined, answers: Record<string, string> | undefined, fallback: string): Array<{ question: string; answer: string }> {
477
+ const answerMap = answers ?? {};
478
+ const rows = (questions ?? []).map((question, index) => {
479
+ const record = question && typeof question === "object" && !Array.isArray(question) ? question as Record<string, unknown> : {};
480
+ const label = typeof record.question === "string" && record.question.trim()
481
+ ? record.question.trim()
482
+ : typeof record.header === "string" && record.header.trim() ? record.header.trim() : `Question ${index + 1}`;
483
+ const rawAnswer = answerMap[label] ?? answerMap[String(record.question ?? "")] ?? answerMap[String(record.header ?? "")];
484
+ return { question: label, answer: typeof rawAnswer === "string" && rawAnswer.trim() ? rawAnswer.trim() : fallback };
485
+ });
486
+ return rows.length ? rows : Object.entries(answerMap).map(([question, answer]) => ({ question, answer }));
487
+ }
488
+
489
+ function claudePermissionDecisionLabel(kind: ResolvedPermissionPrompt["kind"], decision: ProviderPermissionDecisionInput["decision"]): string {
490
+ if (kind === "questions") return decision === "answer" ? "Answered" : "Dismissed";
491
+ if (kind === "plan") return decision === "approve" || decision === "approve-session" ? "Approved plan" : "Kept planning";
492
+ if (decision === "approve-session") return "Approved for session";
493
+ if (decision === "approve") return "Approved";
494
+ if (decision === "abort") return "Aborted";
495
+ if (decision === "answer") return "Answered";
496
+ return "Denied";
497
+ }
498
+
499
+ async function handleSessionTurn(req: Request, options: ControlServerOptions): Promise<Response> {
500
+ if (!options.onSessionTurn) return Response.json({ ok: false, reason: "session capture unavailable" });
501
+ const body = await req.json().catch(() => null);
502
+ const transcriptPath = isRecord(body) && typeof body.transcriptPath === "string" ? body.transcriptPath : "";
503
+ const lastAssistantMessage = isRecord(body) ? body.lastAssistantMessage : undefined;
504
+ // transcriptPath is the primary source, but it can be absent (known CC Stop edge case).
505
+ // lastAssistantMessage is the fallback for exactly that — accept either; reject only when
506
+ // both are missing so the turn still reaches the dashboard chat (#264).
507
+ if (!transcriptPath && lastAssistantMessage === undefined) {
508
+ return Response.json({ ok: false, reason: "transcriptPath or lastAssistantMessage required" }, { status: 400 });
509
+ }
510
+ // Awaited on purpose: the Stop hook posts this synchronously before its
511
+ // reply-obligation check, so the captured turn must be persisted (and the
512
+ // obligation cleared) before this response returns.
513
+ try {
514
+ await options.onSessionTurn({ transcriptPath, lastAssistantMessage });
515
+ return Response.json({ ok: true });
516
+ } catch (error) {
517
+ return Response.json({ ok: false, reason: errMessage(error) }, { status: 500 });
518
+ }
519
+ }
520
+
521
+ async function handleUserPrompt(req: Request, options: ControlServerOptions): Promise<Response> {
522
+ if (!options.onUserPrompt) return Response.json({ ok: false, reason: "prompt echo unavailable" });
523
+ const body = await req.json().catch(() => null);
524
+ const prompt = isRecord(body) && typeof body.prompt === "string" ? body.prompt : "";
525
+ if (!prompt.trim()) return Response.json({ ok: false, reason: "prompt required" }, { status: 400 });
526
+ const transcriptPath = isRecord(body) && typeof body.transcriptPath === "string" ? body.transcriptPath : undefined;
527
+ // Fire-and-forget: the hook must not block Claude's turn on relay round-trips.
528
+ void Promise.resolve(options.onUserPrompt({ prompt, transcriptPath })).catch(() => {});
529
+ return Response.json({ ok: true });
530
+ }
531
+
532
+ async function handleSessionBoundary(req: Request, options: ControlServerOptions): Promise<Response> {
533
+ if (!options.onSessionBoundary) return Response.json({ ok: false, reason: "session-boundary capture unavailable" });
534
+ const body = await req.json().catch(() => null);
535
+ const reason = isRecord(body) && typeof body.reason === "string" ? body.reason : undefined;
536
+ const transcriptPath = isRecord(body) && typeof body.transcriptPath === "string" ? body.transcriptPath : undefined;
537
+ // Fire-and-forget: a PreCompact/SessionEnd hook must not block Claude compacting or exiting.
538
+ void Promise.resolve(options.onSessionBoundary({ reason, transcriptPath })).catch(() => {});
539
+ return Response.json({ ok: true });
540
+ }
541
+
542
+ async function handleLogLevel(req: Request): Promise<Response> {
543
+ const body = await req.json().catch(() => null);
544
+ const level = parseLogLevel(isRecord(body) && typeof body.level === "string" ? body.level : undefined);
545
+ if (!level) return Response.json({ error: `level must be one of: ${LOG_LEVELS.join(", ")}` }, { status: 400 });
546
+ const previous = logger.getLevel();
547
+ logger.setLevel(level);
548
+ logger.info("logger", `log level set to ${level} (was ${previous}) via control port`);
549
+ return Response.json({ ok: true, level, previous });
550
+ }
551
+
552
+ async function handleHookFatal(req: Request, options: ControlServerOptions): Promise<Response> {
553
+ const body = await req.json().catch(() => null);
554
+ const hook = isRecord(body) && typeof body.hook === "string" && body.hook.trim() ? body.hook.trim() : "unknown";
555
+ const error = isRecord(body) && typeof body.error === "string" ? body.error : "(no detail)";
556
+ // Never silent: a hook that couldn't handle its own failure lands here as FATAL.
557
+ logger.fatal(`hook:${hook}`, error);
558
+ try { options.onHookFatal?.({ hook, error }); } catch { /* reporting must never throw back at the hook */ }
559
+ return Response.json({ ok: true });
560
+ }
561
+
562
+ async function handleStatus(req: Request, options: ControlServerOptions): Promise<Response> {
563
+ const body = await req.json().catch(() => null) as Partial<ProviderStatusEvent> | null;
564
+ const status = body?.status;
565
+ if (status !== "idle" && status !== "busy" && status !== "offline" && status !== "error") {
566
+ return Response.json({ error: "status must be idle, busy, offline, or error" }, { status: 400 });
567
+ }
568
+ const reason = body?.reason;
569
+ if (reason !== undefined && reason !== "provider-turn" && reason !== "subagent" && reason !== "background-script") {
570
+ return Response.json({ error: "reason must be provider-turn, subagent, or background-script" }, { status: 400 });
571
+ }
572
+ const isWorkKind = (item: unknown): item is "provider-turn" | "subagent" | "background-script" =>
573
+ item === "provider-turn" || item === "subagent" || item === "background-script";
574
+ const clear = Array.isArray(body?.clear) ? body.clear.filter(isWorkKind) : undefined;
575
+ // #650: optional pids the runner reconciles against process liveness so a tracked
576
+ // command/shell killed out-of-band clears the busy marker instead of stranding it.
577
+ const pidsInput = Array.isArray((body as { pids?: unknown })?.pids) ? (body as { pids: unknown[] }).pids : undefined;
578
+ const pids = pidsInput
579
+ ?.map((p) => (typeof p === "number" ? p : Number(p)))
580
+ .filter((p) => Number.isInteger(p) && p > 0);
581
+ const timeline = statusTimelineEvent(body);
582
+ const update: ProviderStatusEvent = {
583
+ status,
584
+ ...(typeof body?.providerSessionId === "string" ? { providerSessionId: body.providerSessionId } : {}),
585
+ ...(reason ? { reason } : {}),
586
+ ...(clear?.length ? { clear: [...new Set(clear)] } : {}),
587
+ ...(timeline ? { timeline } : {}),
588
+ ...(typeof body?.id === "string" ? { id: body.id } : {}),
589
+ ...(pids?.length ? { pids } : {}),
590
+ ...(typeof body?.label === "string" ? { label: body.label } : {}),
591
+ ...(typeof body?.role === "string" ? { role: body.role } : {}),
592
+ ...(typeof body?.parentId === "string" ? { parentId: body.parentId } : {}),
593
+ ...(body?.metadata && typeof body.metadata === "object" && !Array.isArray(body.metadata) ? { metadata: body.metadata as Record<string, unknown> } : {}),
594
+ };
595
+ options.onStatus(update);
596
+ return Response.json({ ok: true, ...update });
597
+ }
598
+
599
+ // #286: the stop-failure hook reports a usage/rate-limit stall here. Build the
600
+ // provider-neutral `blocked` state (reason rate_limit) — the same seam Claude
601
+ // model-unavailable and Codex approvals ride — so the dashboard shows it
602
+ // distinctly (never as idle/available) and the relay's resume sweep can lift it
603
+ // at reset. The agent stays `idle` underneath (the turn truly ended); the runner
604
+ // carries the hold via its rateLimitHold field, not an active-work claim.
605
+ async function handleRateLimit(req: Request, options: ControlServerOptions): Promise<Response> {
606
+ const body = await req.json().catch(() => null);
607
+ if (!isRecord(body)) return Response.json({ error: "invalid body" }, { status: 400 });
608
+ const errorType = typeof body.errorType === "string" && body.errorType ? body.errorType : "rate_limit";
609
+ // CC's reset_time is unix SECONDS ("if available"); normalize to ms for the relay.
610
+ const resetTimeSec = typeof body.resetTime === "number" && Number.isFinite(body.resetTime) ? body.resetTime : undefined;
611
+ const resetAt = resetTimeSec !== undefined ? Math.round(resetTimeSec * 1000) : undefined;
612
+ const errorMessage = typeof body.errorMessage === "string" && body.errorMessage ? body.errorMessage : undefined;
613
+ options.onStatus({
614
+ status: "idle",
615
+ clear: ["subagent"],
616
+ providerState: buildRateLimitProviderState({ errorType, resetAt, message: errorMessage, source: "claude" }),
617
+ });
618
+ return Response.json({ ok: true, errorType, ...(resetAt ? { resetAt } : {}) });
619
+ }
620
+
621
+ function statusTimelineEvent(body: Partial<ProviderStatusEvent> | null): ProviderStatusEvent["timeline"] | undefined {
622
+ const timeline = body?.timeline;
623
+ if (!timeline || typeof timeline !== "object" || Array.isArray(timeline)) return undefined;
624
+ if (typeof timeline.status !== "string" || !timeline.status) return undefined;
625
+ return {
626
+ status: timeline.status,
627
+ ...(typeof timeline.id === "string" ? { id: timeline.id } : {}),
628
+ ...(typeof timeline.timestamp === "number" && Number.isFinite(timeline.timestamp) ? { timestamp: timeline.timestamp } : {}),
629
+ };
630
+ }
631
+
632
+ function parseJson(raw: string | Buffer): Record<string, unknown> | null {
633
+ try {
634
+ const parsed = JSON.parse(typeof raw === "string" ? raw : raw.toString());
635
+ return parsed && typeof parsed === "object" && !Array.isArray(parsed) ? parsed as Record<string, unknown> : null;
636
+ } catch {
637
+ return null;
638
+ }
639
+ }