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,60 @@
1
+ import { renderRunnerTemplate } from "./template-resolver";
2
+
3
+ export function claudeRelayManual(): string {
4
+ return renderRunnerTemplate("relay.manual.claude");
5
+ }
6
+
7
+ export function claudeRelayContext(): string {
8
+ return renderRunnerTemplate("relay.context.claude");
9
+ }
10
+
11
+ export function claudeReadOnlyRelayContext(): string {
12
+ return renderRunnerTemplate("relay.context.claude.readonly");
13
+ }
14
+
15
+ export function relayReplyActionText(messageId: number): string {
16
+ return `relay_reply (messageId: ${messageId}, body: "<your reply>"). CLI fallback: agent-relay /reply ${messageId} "<your reply>"`;
17
+ }
18
+
19
+ export function relayReplyReminderText(messageId: number): string {
20
+ return `If this batch needs a response, send one useful reply to the latest relevant message with ${relayReplyActionText(messageId)}`;
21
+ }
22
+
23
+ export function workspaceDepsNote(input: { mode?: string | null; depsMode?: string | null }): string {
24
+ if (input.mode !== "isolated") return "";
25
+ switch (input.depsMode) {
26
+ case "symlink":
27
+ return renderRunnerTemplate("workspace.deps.symlink");
28
+ case "none":
29
+ return renderRunnerTemplate("workspace.deps.none");
30
+ default:
31
+ return "";
32
+ }
33
+ }
34
+
35
+ export function workspaceLifecycleNote(input: { mode?: string | null; branch?: string | null; baseRef?: string | null }): string {
36
+ if (input.mode !== "isolated") return "";
37
+ const branch = input.branch ? `\`${input.branch}\`` : "an isolated agent branch";
38
+ const base = input.baseRef ? `\`${input.baseRef}\`` : "the base branch";
39
+ return renderRunnerTemplate("workspace.lifecycle", { branch, baseRef: base });
40
+ }
41
+
42
+ export function workspaceSymlinksNote(linked: string[]): string {
43
+ if (!linked.length) return "";
44
+ return renderRunnerTemplate("workspace.symlinks", { linked: linked.join(", ") });
45
+ }
46
+
47
+ export function workspaceDepsNoteFromEnv(env: Record<string, string | undefined> = process.env): string {
48
+ const json = env.AGENT_RELAY_WORKSPACE_JSON;
49
+ if (!json) return "";
50
+ try {
51
+ const parsed = JSON.parse(json) as { mode?: string; branch?: string; baseRef?: string; deps?: { mode?: string }; symlinks?: { linked?: string[] } };
52
+ return [
53
+ workspaceLifecycleNote({ mode: parsed.mode ?? null, branch: parsed.branch ?? null, baseRef: parsed.baseRef ?? null }),
54
+ workspaceDepsNote({ mode: parsed.mode ?? null, depsMode: parsed.deps?.mode ?? null }),
55
+ parsed.mode === "isolated" ? workspaceSymlinksNote(parsed.symlinks?.linked ?? []) : "",
56
+ ].filter(Boolean).join("\n\n");
57
+ } catch {
58
+ return "";
59
+ }
60
+ }
@@ -0,0 +1,383 @@
1
+ import { errMessage, isRecord } from "agent-relay-sdk";
2
+ import { logger } from "./logger";
3
+
4
+ // Loose fetch signature so tests can inject a plain async stub without Bun's `preconnect`
5
+ // member; the real global `fetch` satisfies it.
6
+ export type FetchLike = (input: string | URL | Request, init?: RequestInit) => Promise<Response>;
7
+
8
+ // Stage 2 of #213/#215 — the Runner becomes the MCP endpoint the agent connects to,
9
+ // fronting the relay. The agent's MCP client points at this localhost server instead of
10
+ // directly at the relay, so the Runner owns the relay connection, reconnect/backoff, and a
11
+ // durable buffer. A relay restart/crash becomes invisible to the agent.
12
+ //
13
+ // This is a TRANSPARENT JSON-RPC pass-through to the relay's `/api/mcp` (which stays the
14
+ // sole enforcement authority). It intervenes in exactly four places:
15
+ //
16
+ // 1. `initialize` — forward, then advertise `capabilities.tools.listChanged: true`,
17
+ // the live-tool-set capability the relay's static endpoint can't.
18
+ // 2. `tools/list` — forward (the relay already scope-filters per token), then context-
19
+ // NARROW: hide tools that don't apply to this agent's workspace mode/
20
+ // state (e.g. `relay_workspace_*` for a non-worktree agent). Strictly
21
+ // a subset — never widens. Served from a last-known cache during a blip.
22
+ // 3. `tools/call` — forward with the runner's LIVE token; for bufferable writes during a
23
+ // relay outage, enqueue durably + return a synthetic "queued" ok so the
24
+ // call is never lost. Mutating spawn/shutdown are forwarded, never local.
25
+ // 4. `GET` (SSE) — the server→client notification channel; emits
26
+ // `notifications/tools/list_changed` when a transition changes the
27
+ // narrowed set, so the agent's tool menu updates mid-session.
28
+ //
29
+ // Narrow-never-widen is the safety invariant: filtering `tools/list` is UX/token-efficiency,
30
+ // NEVER enforcement. The proxy can only ever surface a subset of what the relay's scope filter
31
+ // already returned, so a proxy bug can never become an auth bypass — the relay owns the lock at
32
+ // `tools/call`, the proxy owns the menu.
33
+
34
+ const PROXY_PATH = "/mcp";
35
+ // Relay-side failures we treat as "relay down" (buffer/serve-cache), as opposed to a real 4xx
36
+ // rejection that must be surfaced to the agent verbatim.
37
+ const GATEWAY_STATUSES = new Set([502, 503, 504]);
38
+ const SSE_KEEPALIVE_MS = 25_000;
39
+
40
+ // The write tools whose loss during a relay outage is unacceptable and whose result the agent
41
+ // does not need synchronously — safe to queue durably and replay on reconnect. Reads, claims
42
+ // (409 contention), spawn/shutdown (need a real ack) are deliberately NOT bufferable.
43
+ const DEFAULT_BUFFERABLE_TOOLS = new Set<string>([
44
+ "relay_send_message",
45
+ "relay_reply",
46
+ "relay_workspace_ready",
47
+ ]);
48
+
49
+ // Tools surfaced ONLY to an agent that owns a live isolated worktree. For any other agent the
50
+ // proxy narrows them out of `tools/list` even though the token scope permits them (the relay
51
+ // would return them). This is the context the coarse token scope can't express (#214/#215).
52
+ const WORKTREE_ONLY_TOOLS = new Set<string>([
53
+ "relay_workspace_status",
54
+ "relay_workspace_ready",
55
+ "relay_workspace_deps",
56
+ "relay_workspace_list",
57
+ "relay_workspace_claim",
58
+ "relay_workspace_release",
59
+ "relay_workspace_land",
60
+ ]);
61
+
62
+ interface ProxyContext {
63
+ // The agent owns a live (non-terminal) isolated git worktree → workspace tools apply.
64
+ isolatedWorktree: boolean;
65
+ }
66
+
67
+ export interface BufferedToolCall {
68
+ tool: string;
69
+ arguments: Record<string, unknown>;
70
+ idempotencyKey: string;
71
+ }
72
+
73
+ export interface RelayMcpProxyOptions {
74
+ // The relay's MCP endpoint, e.g. http://localhost:4850/api/mcp.
75
+ relayMcpEndpoint: string;
76
+ // The runner's LIVE relay token (read on every forward so rotation is invisible to the agent).
77
+ getToken(): string | undefined;
78
+ // The bearer the agent must present to this localhost proxy (a per-session secret the runner
79
+ // mints and injects into the agent env). Decouples the agent from the rotating relay token.
80
+ authSecret: string;
81
+ // Persist a bufferable write durably for replay on reconnect (wired to the runner outbox).
82
+ enqueueBuffered(call: BufferedToolCall): void;
83
+ initialContext?: ProxyContext;
84
+ bufferableTools?: Set<string>;
85
+ // Test seam.
86
+ fetchImpl?: FetchLike;
87
+ }
88
+
89
+ interface JsonRpcMessage {
90
+ jsonrpc?: string;
91
+ id?: string | number | null;
92
+ method?: string;
93
+ params?: unknown;
94
+ }
95
+
96
+ interface SseClient {
97
+ controller: ReadableStreamDefaultController<Uint8Array>;
98
+ keepalive: ReturnType<typeof setInterval>;
99
+ }
100
+
101
+ export class RelayMcpProxy {
102
+ private readonly relayMcpEndpoint: string;
103
+ private readonly getToken: () => string | undefined;
104
+ private readonly authSecret: string;
105
+ private readonly enqueueBuffered: (call: BufferedToolCall) => void;
106
+ private readonly bufferableTools: Set<string>;
107
+ private readonly fetchImpl: FetchLike;
108
+ private readonly encoder = new TextEncoder();
109
+
110
+ private context: ProxyContext;
111
+ private server?: ReturnType<typeof Bun.serve>;
112
+ private readonly sseClients = new Set<SseClient>();
113
+ // Last successful relay tools/list — narrowed and served when the relay is briefly down so a
114
+ // read still works (reads serve from last-known where safe).
115
+ private lastRelayTools: Array<Record<string, unknown>> = [];
116
+ private lastNarrowedNames = "";
117
+
118
+ constructor(options: RelayMcpProxyOptions) {
119
+ this.relayMcpEndpoint = options.relayMcpEndpoint;
120
+ this.getToken = options.getToken;
121
+ this.authSecret = options.authSecret;
122
+ this.enqueueBuffered = options.enqueueBuffered;
123
+ this.bufferableTools = options.bufferableTools ?? DEFAULT_BUFFERABLE_TOOLS;
124
+ this.fetchImpl = options.fetchImpl ?? fetch;
125
+ this.context = options.initialContext ?? { isolatedWorktree: false };
126
+ }
127
+
128
+ start(): { url: string; port: number } {
129
+ const self = this;
130
+ this.server = Bun.serve({
131
+ hostname: "127.0.0.1",
132
+ port: 0,
133
+ // SSE streams are long-lived; disable Bun's idle timeout and keep them alive with pings.
134
+ idleTimeout: 0,
135
+ fetch(req) {
136
+ return self.handle(req);
137
+ },
138
+ });
139
+ const port = this.server.port;
140
+ if (port === undefined) throw new Error("relay MCP proxy did not bind a port");
141
+ return { url: `http://127.0.0.1:${port}${PROXY_PATH}`, port };
142
+ }
143
+
144
+ stop(): void {
145
+ for (const client of this.sseClients) {
146
+ clearInterval(client.keepalive);
147
+ try { client.controller.close(); } catch { /* already closed */ }
148
+ }
149
+ this.sseClients.clear();
150
+ this.server?.stop(true);
151
+ this.server = undefined;
152
+ }
153
+
154
+ // The runner calls this on a workspace mode/state transition (active→ready→merged→terminal,
155
+ // or shared↔worktree). If it changes which tools the agent can see, emit list_changed so the
156
+ // agent's menu updates mid-session instead of staying frozen until reconnect.
157
+ setContext(context: ProxyContext): void {
158
+ this.context = context;
159
+ this.maybeEmitListChanged();
160
+ }
161
+
162
+ // The runner calls this after re-minting its runtime token (scope may have changed — e.g. a
163
+ // profile change grants/revokes command:spawn). Re-fetch the relay's now-differently-scoped
164
+ // tool list with the live token and emit list_changed if the visible set changed. This is the
165
+ // "token scope transition" path; setContext covers the workspace mode/state path. Best-effort —
166
+ // a failed refresh keeps the last-known list (the next tools/list refreshes it anyway).
167
+ async refreshTools(): Promise<void> {
168
+ const relay = await this.forward({ method: "tools/list", id: 0 }).catch(() => null);
169
+ const tools = relay && isRecord(relay.body?.result) && Array.isArray((relay.body!.result as Record<string, unknown>).tools)
170
+ ? ((relay.body!.result as Record<string, unknown>).tools as Array<Record<string, unknown>>)
171
+ : null;
172
+ if (!tools) return;
173
+ this.lastRelayTools = tools;
174
+ this.maybeEmitListChanged();
175
+ }
176
+
177
+ private async handle(req: Request): Promise<Response> {
178
+ const url = new URL(req.url);
179
+ if (url.pathname !== PROXY_PATH) return new Response("not found", { status: 404 });
180
+ if (!this.authorized(req)) {
181
+ return Response.json(jsonRpcError(null, -32001, "proxy auth required"), { status: 401 });
182
+ }
183
+ // GET → open the server→client SSE notification channel (streamable-HTTP transport).
184
+ if (req.method === "GET") return this.openSse();
185
+ if (req.method === "DELETE") return new Response(null, { status: 204 });
186
+ if (req.method !== "POST") return new Response("method not allowed", { status: 405 });
187
+ return this.handleRpc(req);
188
+ }
189
+
190
+ private authorized(req: Request): boolean {
191
+ const header = req.headers.get("authorization") ?? "";
192
+ const bearer = header.startsWith("Bearer ") ? header.slice(7) : "";
193
+ return bearer === this.authSecret;
194
+ }
195
+
196
+ private async handleRpc(req: Request): Promise<Response> {
197
+ let message: JsonRpcMessage;
198
+ try {
199
+ const body = await req.json();
200
+ if (!body || typeof body !== "object" || Array.isArray(body)) {
201
+ return Response.json(jsonRpcError(null, -32600, "JSON-RPC body must be an object"));
202
+ }
203
+ message = body as JsonRpcMessage;
204
+ } catch {
205
+ return Response.json(jsonRpcError(null, -32700, "invalid JSON-RPC body"));
206
+ }
207
+ const id = message.id ?? null;
208
+ const method = message.method;
209
+
210
+ if (method === "initialize") return this.handleInitialize(id, message);
211
+ if (method === "tools/list") return this.handleToolsList(id, message);
212
+ if (method === "tools/call") return this.handleToolsCall(id, message);
213
+ // Everything else (notifications/initialized, ping, …) forwards verbatim.
214
+ return this.forwardRaw(message);
215
+ }
216
+
217
+ private async handleInitialize(id: string | number | null, message: JsonRpcMessage): Promise<Response> {
218
+ const relay = await this.forward({ ...message, method: "initialize", id }).catch((error) => {
219
+ logger.warn("mcp-proxy", `initialize forward failed: ${errMessage(error)}`);
220
+ return null;
221
+ });
222
+ // Degrade gracefully if the relay is momentarily down at connect: still hand the agent a
223
+ // usable initialize result advertising the proxy's capabilities.
224
+ const result: Record<string, unknown> = isRecord(relay?.body?.result) ? { ...(relay!.body!.result as Record<string, unknown>) } : {
225
+ protocolVersion: "2024-11-05",
226
+ serverInfo: { name: "agent-relay", title: "Agent Relay (via runner)", version: "proxy" },
227
+ };
228
+ const caps = isRecord(result.capabilities) ? { ...result.capabilities } : {};
229
+ // The capability the relay's static endpoint doesn't advertise: live tool sets.
230
+ caps.tools = { ...(isRecord(caps.tools) ? caps.tools : {}), listChanged: true };
231
+ result.capabilities = caps;
232
+ return Response.json(jsonRpcResult(id, result));
233
+ }
234
+
235
+ private async handleToolsList(id: string | number | null, message: JsonRpcMessage): Promise<Response> {
236
+ const relay = await this.forward({ ...message, method: "tools/list", id }).catch(() => null);
237
+ const tools = relay && isRecord(relay.body?.result) && Array.isArray((relay.body!.result as Record<string, unknown>).tools)
238
+ ? ((relay.body!.result as Record<string, unknown>).tools as Array<Record<string, unknown>>)
239
+ : null;
240
+ if (tools) this.lastRelayTools = tools; // refresh the last-known base list
241
+ // If the relay is down and we have no cache yet, surface an empty list rather than erroring —
242
+ // the agent can still operate (writes buffer; tools/list refreshes on the next call).
243
+ const base = tools ?? this.lastRelayTools;
244
+ const narrowed = this.narrow(base);
245
+ this.lastNarrowedNames = toolNames(narrowed);
246
+ return Response.json(jsonRpcResult(id, { tools: narrowed }));
247
+ }
248
+
249
+ // Strict subset of the relay's already-scope-filtered list. Only removes — never adds. The one
250
+ // narrowing rule today: workspace tools apply only to an agent that owns a live worktree.
251
+ private narrow(tools: Array<Record<string, unknown>>): Array<Record<string, unknown>> {
252
+ return tools.filter((tool) => {
253
+ const name = typeof tool.name === "string" ? tool.name : "";
254
+ if (WORKTREE_ONLY_TOOLS.has(name) && !this.context.isolatedWorktree) return false;
255
+ return true;
256
+ });
257
+ }
258
+
259
+ private async handleToolsCall(id: string | number | null, message: JsonRpcMessage): Promise<Response> {
260
+ const params = isRecord(message.params) ? message.params : {};
261
+ const toolName = typeof params.name === "string" ? params.name : "";
262
+ const args = isRecord(params.arguments) ? params.arguments : {};
263
+
264
+ const relay = await this.forward(message).catch((error) => {
265
+ // A thrown fetch = transport failure (relay unreachable / DNS / connection refused).
266
+ return { ok: false, status: 0, body: null, transportError: errMessage(error) } as ForwardResult;
267
+ });
268
+
269
+ const relayDown = !relay.ok && (relay.status === 0 || GATEWAY_STATUSES.has(relay.status));
270
+ if (relayDown && this.bufferableTools.has(toolName)) {
271
+ // Durably queue the write and tell the agent it's safely accepted. It replays on reconnect.
272
+ const idempotencyKey = typeof args.idempotencyKey === "string" && args.idempotencyKey
273
+ ? args.idempotencyKey
274
+ : `mcp-${toolName}-${crypto.randomUUID()}`;
275
+ this.enqueueBuffered({ tool: toolName, arguments: { ...args, idempotencyKey }, idempotencyKey });
276
+ logger.info("mcp-proxy", `relay unreachable — buffered ${toolName} (idempotencyKey=${idempotencyKey})`);
277
+ return Response.json(jsonRpcResult(id, toolResult({
278
+ queued: true,
279
+ tool: toolName,
280
+ idempotencyKey,
281
+ note: "Relay was unreachable; your call was queued durably and will be delivered automatically when the relay comes back. It is not lost.",
282
+ })));
283
+ }
284
+
285
+ if (relay.body) return Response.json(relay.body);
286
+ // Relay down and not bufferable: a real error the agent must see (and can retry).
287
+ return Response.json(jsonRpcError(id, -32002, `relay unreachable: ${relay.transportError ?? `status ${relay.status}`}`));
288
+ }
289
+
290
+ // Verbatim forward for methods with no proxy-specific handling. Returns the relay's response
291
+ // body+status unchanged (or a JSON-RPC error if the relay is down).
292
+ private async forwardRaw(message: JsonRpcMessage): Promise<Response> {
293
+ const relay = await this.forward(message).catch((error) => {
294
+ return { ok: false, status: 0, body: null, transportError: errMessage(error) } as ForwardResult;
295
+ });
296
+ if (relay.body) return Response.json(relay.body, { status: relay.status || 200 });
297
+ // Notifications (no id) tolerate a down relay — ack locally; requests get an error.
298
+ if (message.id === undefined || message.id === null) return new Response(null, { status: 202 });
299
+ return Response.json(jsonRpcError(message.id, -32002, `relay unreachable: ${relay.transportError ?? `status ${relay.status}`}`));
300
+ }
301
+
302
+ // POST the JSON-RPC message to the relay with the runner's LIVE token. The agent's incoming
303
+ // bearer is the proxy secret; we substitute the real relay credential here so the agent never
304
+ // holds it and token rotation is invisible. The body is re-serialized from the already-parsed
305
+ // message (the request stream was consumed during dispatch and can't be re-read).
306
+ private async forward(message: JsonRpcMessage): Promise<ForwardResult> {
307
+ const token = this.getToken();
308
+ const headers: Record<string, string> = { "content-type": "application/json" };
309
+ if (token) headers.authorization = `Bearer ${token}`;
310
+ const payload: Record<string, unknown> = { jsonrpc: "2.0", method: message.method };
311
+ if (message.id !== undefined) payload.id = message.id;
312
+ if (message.params !== undefined) payload.params = message.params;
313
+ const response = await this.fetchImpl(this.relayMcpEndpoint, { method: "POST", headers, body: JSON.stringify(payload) });
314
+ const text = await response.text();
315
+ let parsed: Record<string, unknown> | null = null;
316
+ if (text) { try { parsed = JSON.parse(text); } catch { parsed = null; } }
317
+ return { ok: response.ok, status: response.status, body: parsed };
318
+ }
319
+
320
+ private openSse(): Response {
321
+ const self = this;
322
+ let client: SseClient;
323
+ const stream = new ReadableStream<Uint8Array>({
324
+ start(controller) {
325
+ controller.enqueue(self.encoder.encode(": connected\n\n"));
326
+ const keepalive = setInterval(() => {
327
+ try { controller.enqueue(self.encoder.encode(": keepalive\n\n")); } catch { /* closed */ }
328
+ }, SSE_KEEPALIVE_MS);
329
+ keepalive.unref?.();
330
+ client = { controller, keepalive };
331
+ self.sseClients.add(client);
332
+ },
333
+ cancel() {
334
+ if (client) {
335
+ clearInterval(client.keepalive);
336
+ self.sseClients.delete(client);
337
+ }
338
+ },
339
+ });
340
+ return new Response(stream, {
341
+ headers: { "content-type": "text/event-stream", "cache-control": "no-cache", connection: "keep-alive" },
342
+ });
343
+ }
344
+
345
+ private maybeEmitListChanged(): void {
346
+ const narrowed = this.narrow(this.lastRelayTools);
347
+ const names = toolNames(narrowed);
348
+ if (names === this.lastNarrowedNames) return; // no visible change → no notification
349
+ this.lastNarrowedNames = names;
350
+ const frame = this.encoder.encode(`event: message\ndata: ${JSON.stringify({ jsonrpc: "2.0", method: "notifications/tools/list_changed" })}\n\n`);
351
+ for (const client of this.sseClients) {
352
+ try { client.controller.enqueue(frame); } catch { /* dropped client; cancel() cleans up */ }
353
+ }
354
+ logger.debug("mcp-proxy", `tools/list_changed emitted to ${this.sseClients.size} client(s)`);
355
+ }
356
+
357
+ // Test/observability hooks.
358
+ sseClientCount(): number { return this.sseClients.size; }
359
+ narrowedToolNames(): string[] { return this.narrow(this.lastRelayTools).map((t) => String(t.name)); }
360
+ }
361
+
362
+ interface ForwardResult {
363
+ ok: boolean;
364
+ status: number;
365
+ body: Record<string, unknown> | null;
366
+ transportError?: string;
367
+ }
368
+
369
+ function toolNames(tools: Array<Record<string, unknown>>): string {
370
+ return tools.map((t) => String(t.name)).sort().join(",");
371
+ }
372
+
373
+ function jsonRpcResult(id: string | number | null, result: unknown): Record<string, unknown> {
374
+ return { jsonrpc: "2.0", id, result };
375
+ }
376
+
377
+ function jsonRpcError(id: string | number | null, code: number, message: string): Record<string, unknown> {
378
+ return { jsonrpc: "2.0", id, error: { code, message } };
379
+ }
380
+
381
+ function toolResult(result: unknown): Record<string, unknown> {
382
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }], structuredContent: result };
383
+ }
@@ -0,0 +1,156 @@
1
+ // Single home for the relay HTTP MCP endpoint descriptor + per-provider injection
2
+ // args. Both adapters (and Stage 2's proxy) import from here so the endpoint path,
3
+ // server name, and token-handling rules live in exactly one place.
4
+ //
5
+ // Token handling: the bearer token is NEVER placed in argv (it would leak via `ps`
6
+ // and the agent's own process inspection). Claude expands `${AGENT_RELAY_SESSION_TOKEN}`
7
+ // from the env at MCP-config parse time; Codex reads it from the named env var.
8
+ //
9
+ // Why a dedicated var and NOT `AGENT_RELAY_TOKEN`: a managed session inherits the
10
+ // runner's scoped, identity-bearing session token, but Claude Code applies a rig/user
11
+ // `settings.json` `env` block OVER the inherited env. A rig that sets
12
+ // `env.AGENT_RELAY_TOKEN` (e.g. to the admin token, for interactive CLI use) would
13
+ // clobber the scoped token at parse time, so the relay MCP connection authenticates as
14
+ // the `server` actor with no agent identity — `relay_whoami` returns null and
15
+ // `relay_send_message` demands a `from` it can't supply (#233). The runner exports the
16
+ // scoped token under this dedicated name that rigs don't set, so nothing on the host can
17
+ // hijack the managed agent's identity.
18
+ export const RELAY_MCP_SERVER_NAME = "agent-relay";
19
+ export const RELAY_MCP_PATH = "/api/mcp";
20
+ export const RELAY_MCP_TOKEN_ENV = "AGENT_RELAY_SESSION_TOKEN";
21
+
22
+ // #672 — shared host-listener MCP. The agent connects to ONE orchestrator-owned shared
23
+ // `callmux` listener via a per-agent `callmux bridge`, fronting the host's NON-IDENTITY MCP
24
+ // (tokenlean, github). The bridge is a stdio child (auto-reconnect + retry-once on a
25
+ // listener/downstream hiccup; `--cwd` injects `x-callmux-cwd` for per-worktree session-cwd
26
+ // isolation), so it rides the SAME provider injection as any provisioned stdio server. The
27
+ // identity-bearing relay endpoint is NEVER routed here — it stays per-agent on #215.
28
+ export const SHARED_MCP_SERVER_NAME = "callmux";
29
+ // callmux's documented manual-listener endpoint (`callmux --listen 4860`, Streamable HTTP at
30
+ // /mcp). In managed launches the orchestrator owns the real listener URL and passes it through
31
+ // AGENT_RELAY_SHARED_MCP_URL; this fallback is for direct runner/manual-listener use.
32
+ export const DEFAULT_SHARED_MCP_URL = "http://localhost:4860/mcp";
33
+ export const SHARED_MCP_URL_ENV = "AGENT_RELAY_SHARED_MCP_URL";
34
+
35
+ /** Resolve the shared-listener URL: explicit runner option → env → documented default. */
36
+ export function resolveSharedMcpUrl(explicit?: string): string {
37
+ return explicit ?? process.env[SHARED_MCP_URL_ENV] ?? DEFAULT_SHARED_MCP_URL;
38
+ }
39
+
40
+ export function relayMcpEndpoint(relayUrl: string): string {
41
+ return `${relayUrl.replace(/\/+$/, "")}${RELAY_MCP_PATH}`;
42
+ }
43
+
44
+ // Codex: `-c mcp_servers.<name>.*` overrides. `bearer_token_env_var` tells Codex to
45
+ // read the token from the env var itself → transport resolves to streamable_http.
46
+ // `endpoint` overrides the target URL (runner-local proxy, Stage 2 #215) — see above.
47
+ export function relayMcpCodexConfigArgs(relayUrl: string, endpoint?: string): string[] {
48
+ const key = `mcp_servers.${RELAY_MCP_SERVER_NAME}`;
49
+ return [
50
+ "-c",
51
+ `${key}.url=${tomlString(endpoint ?? relayMcpEndpoint(relayUrl))}`,
52
+ "-c",
53
+ `${key}.bearer_token_env_var=${tomlString(RELAY_MCP_TOKEN_ENV)}`,
54
+ ];
55
+ }
56
+
57
+ // Shared TOML string escaper for Codex `-c key=value` overrides. One home; codex.ts
58
+ // imports this rather than re-declaring it.
59
+ export function tomlString(value: string): string {
60
+ return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
61
+ }
62
+
63
+ // #555 — compose the launch MCP set from (the relay endpoint, if relay.mcp) PLUS the
64
+ // profile's relay-provisioned servers, PLUS (#662) the host USER-scope servers for an
65
+ // `mcp.mode:"host"` profile. One `--mcp-config` JSON carries them all. `strict` adds
66
+ // `--strict-mcp-config` so ONLY these load — Claude reads no on-disk config, so the
67
+ // project-cwd `.mcp.json` is never discovered (its enable modal can't wedge a headless
68
+ // agent). Whatever the assembler decides host mode should keep is injected here explicitly;
69
+ // strict then preserves exactly that set and nothing else.
70
+ import { SHARED_CALLMUX_TOOL_CALL_TIMEOUT_MS, type ProvisioningMcpServer } from "agent-relay-sdk";
71
+
72
+ // The shared-listener bridge as a provider-neutral stdio server descriptor: a `callmux bridge`
73
+ // child fronting the host's shared listener, with per-agent `--cwd "$WORKTREE"` for session-cwd
74
+ // isolation (#672). It flows through the existing claude/codex stdio injection unchanged.
75
+ export function sharedMcpBridgeServer(url: string, cwd: string): ProvisioningMcpServer {
76
+ return {
77
+ command: "callmux",
78
+ args: ["bridge", "--url", url, "--cwd", cwd, "--call-timeout", String(SHARED_CALLMUX_TOOL_CALL_TIMEOUT_MS)],
79
+ };
80
+ }
81
+
82
+ function mergedConfigEnv(server: ProvisioningMcpServer): ProvisioningMcpServer {
83
+ if (!server.configEnv || Object.keys(server.configEnv).length === 0) return server;
84
+ return {
85
+ ...server,
86
+ env: { ...server.configEnv, ...(server.env ?? {}) },
87
+ };
88
+ }
89
+
90
+ function claudeMcpServerEntry(server: ProvisioningMcpServer): Record<string, unknown> {
91
+ const merged = mergedConfigEnv(server);
92
+ const entry: Record<string, unknown> = {};
93
+ if (merged.type) entry.type = merged.type;
94
+ if (merged.command) entry.command = merged.command;
95
+ if (merged.args?.length) entry.args = merged.args;
96
+ if (merged.env && Object.keys(merged.env).length) entry.env = merged.env;
97
+ if (merged.url) entry.url = merged.url;
98
+ if (merged.headers && Object.keys(merged.headers).length) entry.headers = merged.headers;
99
+ return entry;
100
+ }
101
+
102
+ export function claudeMcpLaunchArgs(opts: {
103
+ relayUrl: string;
104
+ endpoint?: string;
105
+ includeRelay: boolean;
106
+ servers?: Record<string, ProvisioningMcpServer>;
107
+ /** #662 — host USER-scope MCP servers (`~/.claude.json` `mcpServers`), re-injected for
108
+ * `mcp.mode:"host"` so `--strict-mcp-config` PRESERVES them instead of dropping the
109
+ * ambient host MCP a host-base agent loaded before strict. Lowest precedence: the relay
110
+ * endpoint and relay-provisioned servers win on a name collision (relay decides). */
111
+ hostServers?: Record<string, ProvisioningMcpServer>;
112
+ strict?: boolean;
113
+ }): string[] {
114
+ const mcpServers: Record<string, unknown> = {};
115
+ // Host user-scope servers go in first so relay endpoint + provisioned servers below can
116
+ // override them by name (relay-owned config is authoritative over ambient host MCP).
117
+ for (const [name, server] of Object.entries(opts.hostServers ?? {})) {
118
+ if (name === RELAY_MCP_SERVER_NAME) continue;
119
+ const entry = claudeMcpServerEntry(server);
120
+ if (Object.keys(entry).length) mcpServers[name] = entry;
121
+ }
122
+ if (opts.includeRelay) {
123
+ mcpServers[RELAY_MCP_SERVER_NAME] = {
124
+ type: "http",
125
+ url: opts.endpoint ?? relayMcpEndpoint(opts.relayUrl),
126
+ headers: { Authorization: `Bearer \${${RELAY_MCP_TOKEN_ENV}}` },
127
+ };
128
+ }
129
+ for (const [name, server] of Object.entries(opts.servers ?? {})) {
130
+ // Never let a declared server shadow the relay endpoint (identity-bearing).
131
+ if (name === RELAY_MCP_SERVER_NAME) continue;
132
+ const entry = claudeMcpServerEntry(server);
133
+ if (Object.keys(entry).length) mcpServers[name] = entry;
134
+ }
135
+ // Nothing to inject and not enforcing strict isolation → don't pass an empty config.
136
+ if (Object.keys(mcpServers).length === 0 && !opts.strict) return [];
137
+ return ["--mcp-config", JSON.stringify({ mcpServers }), ...(opts.strict ? ["--strict-mcp-config"] : [])];
138
+ }
139
+
140
+ // #555 (Codex) — `-c mcp_servers.<name>.*` overrides for each relay-provisioned
141
+ // server, the same shape relayMcpCodexConfigArgs uses for the relay endpoint.
142
+ export function codexProvisionedMcpArgs(servers?: Record<string, ProvisioningMcpServer>): string[] {
143
+ const args: string[] = [];
144
+ for (const [name, rawServer] of Object.entries(servers ?? {})) {
145
+ if (name === RELAY_MCP_SERVER_NAME) continue;
146
+ const server = mergedConfigEnv(rawServer);
147
+ const key = `mcp_servers.${name}`;
148
+ if (server.command) args.push("-c", `${key}.command=${tomlString(server.command)}`);
149
+ if (server.args?.length) args.push("-c", `${key}.args=[${server.args.map(tomlString).join(",")}]`);
150
+ if (server.url) args.push("-c", `${key}.url=${tomlString(server.url)}`);
151
+ for (const [envKey, envVal] of Object.entries(server.env ?? {})) {
152
+ args.push("-c", `${key}.env.${envKey}=${tomlString(envVal)}`);
153
+ }
154
+ }
155
+ return args;
156
+ }