agent-relay-runner 0.89.2 → 0.91.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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
- "version": "0.89.2",
3
+ "version": "0.91.0",
4
4
  "description": "Unified provider lifecycle runner for Agent Relay",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,7 +20,7 @@
20
20
  "directory": "runner"
21
21
  },
22
22
  "dependencies": {
23
- "agent-relay-sdk": "0.2.68"
23
+ "agent-relay-sdk": "0.2.70"
24
24
  },
25
25
  "devDependencies": {
26
26
  "@types/bun": "latest",
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "agent-relay-runner",
3
3
  "description": "Thin Agent Relay runner bridge for Claude Code",
4
- "version": "0.89.2",
4
+ "version": "0.91.0",
5
5
  "agentRelayContracts": {
6
6
  "providerPluginProtocol": 1
7
7
  }
package/src/adapter.ts CHANGED
@@ -100,6 +100,10 @@ export interface RunnerSpawnConfig {
100
100
  // Stage 2 (#215): the MCP endpoint the agent connects to — the runner-local proxy URL when the
101
101
  // proxy is active. Undefined → the adapter targets the relay's MCP endpoint directly (Stage 1).
102
102
  relayMcpEndpoint?: string;
103
+ // #672: the shared host-listener URL the per-agent `callmux bridge` connects to, when the
104
+ // profile/spawn opted into `agentProfile.sharedMcp`. Resolved runner-side (env → default);
105
+ // the launch assembler only emits the bridge entry when sharedMcp is on.
106
+ sharedMcpUrl?: string;
103
107
  monitor?: {
104
108
  deliver(messages: Message[]): Promise<number[]>;
105
109
  };
@@ -1,6 +1,6 @@
1
1
  import { existsSync, readdirSync } from "node:fs";
2
2
  import { resolve, join } from "node:path";
3
- import type { AgentProfile, AgentProfileBase, SpawnProvider } from "agent-relay-sdk";
3
+ import type { AgentProfile, AgentProfileBase, ProvisioningMcpServer, SpawnProvider } from "agent-relay-sdk";
4
4
  import {
5
5
  profileAllowsRelayFeature,
6
6
  profileIsVanillaBase,
@@ -13,14 +13,18 @@ import {
13
13
  resolveClaudeProvisionedPlugins,
14
14
  resolveClaudeProvisionedSkills,
15
15
  resolveCodexProvisionedSkills,
16
+ resolveProjectMcpServers,
16
17
  profileProvisioning,
17
18
  } from "./provisioning";
18
19
  import {
19
20
  claudeMcpLaunchArgs,
20
21
  codexProvisionedMcpArgs,
21
22
  relayMcpCodexConfigArgs,
23
+ resolveSharedMcpUrl,
24
+ sharedMcpBridgeServer,
22
25
  tomlString,
23
26
  RELAY_MCP_SERVER_NAME,
27
+ SHARED_MCP_SERVER_NAME,
24
28
  } from "./relay-mcp";
25
29
 
26
30
  // #557 — THE single launch-assembly contract. Given a resolved AgentProfile (+ its
@@ -73,11 +77,24 @@ export interface AssembledMcp {
73
77
  strict: boolean;
74
78
  /** Relay-provisioned server names (excludes the relay endpoint itself). */
75
79
  servers: string[];
80
+ /** #667 — project `.mcp.json` server names projected from the spawn cwd's committed
81
+ * `.mcp.json` (via `--mcp-config`, so they survive strict). Sourced from the project,
82
+ * not ambient host MCP. Empty unless the profile/spawn opted into `projectMcp`, and a
83
+ * name already declared by relay-provisioned `servers` wins (not double-listed here). */
84
+ projectServers: string[];
76
85
  /** #662 — host USER-scope MCP server names (`~/.claude.json` `mcpServers`) re-injected
77
86
  * into `--mcp-config` for an `mcp.mode:"host"` profile, so `--strict-mcp-config` keeps
78
87
  * the ambient host MCP (callmux/qmd/gh) instead of dropping it. Empty for non-host mcp
79
88
  * modes, isolated bases (own config home, never had host MCP), and Codex. */
80
89
  hostServers: string[];
90
+ /** #672 — when the profile/spawn opted into `sharedMcp`, the agent's NON-IDENTITY MCP
91
+ * (tokenlean, github) is routed through one orchestrator-owned shared `callmux` listener via
92
+ * a per-agent `callmux bridge --cwd "$WORKTREE"` (session-cwd isolation), injected through
93
+ * the same `--mcp-config` / `-c` channel so it survives strict. `url` is the listener the
94
+ * bridge connects to. Undefined when projection is OFF (the default). The bridge is opaque
95
+ * at assembly time — its server SET is owned by the listener config, not enumerated here.
96
+ * The identity-bearing relay endpoint is never on the shared listener (#215). */
97
+ sharedListener?: { url: string };
81
98
  }
82
99
 
83
100
  export interface AssembledLaunch {
@@ -202,6 +219,34 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
202
219
  const mcpServers = Object.keys(provisionedMcp).filter((name) => name !== RELAY_MCP_SERVER_NAME);
203
220
  const relayEndpoint = profileAllowsRelayFeature(config, "mcp");
204
221
 
222
+ // #667 — project `.mcp.json` projection (opt-in). The spawn cwd's committed servers are
223
+ // injected through the SAME `--mcp-config` channel as relay-provisioned servers, so they
224
+ // survive `--strict-mcp-config` without the ambient project-discovery modal. A relay-
225
+ // provisioned server of the same name wins (the profile is the explicit, relay-owned
226
+ // source); the relay endpoint name is reserved. `declaredMcp` is the union relay injects.
227
+ const projectMcpRaw = resolveProjectMcpServers(config);
228
+ const projectMcp = Object.fromEntries(
229
+ Object.entries(projectMcpRaw).filter(
230
+ ([name]) => name !== RELAY_MCP_SERVER_NAME && !(name in provisionedMcp),
231
+ ),
232
+ );
233
+ const projectServers = Object.keys(projectMcp);
234
+
235
+ // #672 — shared host-listener MCP (opt-in, default OFF). When on, route the agent's
236
+ // NON-IDENTITY MCP (tokenlean, github) through ONE orchestrator-owned shared `callmux`
237
+ // listener via a per-agent `callmux bridge --url <listener> --cwd "$WORKTREE"` stdio entry:
238
+ // the MCP session survives a listener/downstream restart, and --cwd gives each isolated
239
+ // worktree session-cwd isolation. It's a plain stdio server descriptor, so it rides the same
240
+ // controlled --mcp-config channel as provisioned servers (survives --strict-mcp-config) — no
241
+ // ambient discovery, no modal. Relay-owned infra → authoritative on its name (spread last);
242
+ // the relay endpoint stays per-agent (#215), NEVER on the shared listener.
243
+ const sharedListenerOn = Boolean(config.agentProfile?.sharedMcp);
244
+ const sharedListenerUrl = sharedListenerOn ? resolveSharedMcpUrl(config.sharedMcpUrl) : undefined;
245
+ const sharedMcp: Record<string, ProvisioningMcpServer> = sharedListenerUrl
246
+ ? { [SHARED_MCP_SERVER_NAME]: sharedMcpBridgeServer(sharedListenerUrl, config.cwd) }
247
+ : {};
248
+ const declaredMcp = { ...projectMcp, ...provisionedMcp, ...sharedMcp };
249
+
205
250
  // #662 — EVERY managed Claude launch is --strict-mcp-config, not just vanilla. A managed
206
251
  // agent runs headless (no human at the keyboard), so Claude's blocking "N new MCP servers
207
252
  // found in this project — select to enable" modal (raised on UNDECIDED cwd `.mcp.json`
@@ -224,7 +269,7 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
224
269
  && (!config.agentProfile || config.agentProfile.mcp.mode === "host");
225
270
  const hostMcp = usesHostMcp ? hostUserScopeMcpServers() : {};
226
271
  const hostMcpServers = Object.keys(hostMcp).filter(
227
- (name) => name !== RELAY_MCP_SERVER_NAME && !(name in provisionedMcp),
272
+ (name) => name !== RELAY_MCP_SERVER_NAME && !(name in declaredMcp),
228
273
  );
229
274
 
230
275
  const instructions = assembleInstructions(config);
@@ -238,7 +283,7 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
238
283
  relayUrl: config.relayUrl,
239
284
  ...(config.relayMcpEndpoint ? { endpoint: config.relayMcpEndpoint } : {}),
240
285
  includeRelay: relayEndpoint,
241
- servers: provisionedMcp,
286
+ servers: declaredMcp,
242
287
  hostServers: hostMcp,
243
288
  strict: strictMcp,
244
289
  }),
@@ -258,7 +303,10 @@ function assembleClaude(config: RunnerSpawnConfig, providerConfig: ProviderConfi
258
303
  statusLine,
259
304
  skills: skills.map((s) => ({ name: s.name, dir: s.dir })),
260
305
  plugins: provisionedPlugins.map((p) => ({ name: p.name, dir: p.dir })),
261
- mcp: { relayEndpoint, strict: strictMcp, servers: mcpServers, hostServers: hostMcpServers },
306
+ mcp: {
307
+ relayEndpoint, strict: strictMcp, servers: mcpServers, projectServers, hostServers: hostMcpServers,
308
+ ...(sharedListenerUrl ? { sharedListener: { url: sharedListenerUrl } } : {}),
309
+ },
262
310
  instructions,
263
311
  args,
264
312
  env: configHome ? { CLAUDE_CONFIG_DIR: configHome } : {},
@@ -278,6 +326,29 @@ function assembleCodex(config: RunnerSpawnConfig, _providerConfig: ProviderConfi
278
326
  const provisionedMcp = profileProvisioning(config).mcpServers;
279
327
  const mcpServers = Object.keys(provisionedMcp).filter((name) => name !== RELAY_MCP_SERVER_NAME);
280
328
 
329
+ // #667 — project `.mcp.json` projection (opt-in), same union rule as Claude: a relay-
330
+ // provisioned server of the same name wins; the relay endpoint name is reserved. Codex has
331
+ // no `.mcp.json` discovery of its own, so this is purely additive (no strict/modal concern).
332
+ const projectMcpRaw = resolveProjectMcpServers(config);
333
+ const projectMcp = Object.fromEntries(
334
+ Object.entries(projectMcpRaw).filter(
335
+ ([name]) => name !== RELAY_MCP_SERVER_NAME && !(name in provisionedMcp),
336
+ ),
337
+ );
338
+ const projectServers = Object.keys(projectMcp);
339
+
340
+ // #672 — shared host-listener MCP (opt-in, default OFF), same as Claude: a per-agent
341
+ // `callmux bridge --url <listener> --cwd "$WORKTREE"` stdio entry routes the agent's
342
+ // NON-IDENTITY MCP (tokenlean, github) through the shared listener (session survives a
343
+ // restart; --cwd gives session-cwd isolation). Emitted via the same `-c mcp_servers.*`
344
+ // overrides as provisioned servers. The relay endpoint stays per-agent (#215).
345
+ const sharedListenerOn = Boolean(config.agentProfile?.sharedMcp);
346
+ const sharedListenerUrl = sharedListenerOn ? resolveSharedMcpUrl(config.sharedMcpUrl) : undefined;
347
+ const sharedMcp: Record<string, ProvisioningMcpServer> = sharedListenerUrl
348
+ ? { [SHARED_MCP_SERVER_NAME]: sharedMcpBridgeServer(sharedListenerUrl, config.cwd) }
349
+ : {};
350
+ const declaredMcp = { ...projectMcp, ...provisionedMcp, ...sharedMcp };
351
+
281
352
  const instructions = assembleInstructions(config);
282
353
 
283
354
  // The skills/MCP/project-doc block; core launch config (model/approval/listen) stays
@@ -285,7 +356,7 @@ function assembleCodex(config: RunnerSpawnConfig, _providerConfig: ProviderConfi
285
356
  const args = [
286
357
  ...bundledSkillConfigArgs(skillDirs),
287
358
  ...(relayEndpoint ? relayMcpCodexConfigArgs(config.relayUrl, config.relayMcpEndpoint) : []),
288
- ...codexProvisionedMcpArgs(provisionedMcp),
359
+ ...codexProvisionedMcpArgs(declaredMcp),
289
360
  ...codexProjectDocSuppressionArgs(config),
290
361
  ];
291
362
 
@@ -301,7 +372,10 @@ function assembleCodex(config: RunnerSpawnConfig, _providerConfig: ProviderConfi
301
372
  statusLine: false,
302
373
  skills: provisionedSkills.map((s) => ({ name: s.name, dir: s.dir })),
303
374
  plugins: [],
304
- mcp: { relayEndpoint, strict: false, servers: mcpServers, hostServers: [] },
375
+ mcp: {
376
+ relayEndpoint, strict: false, servers: mcpServers, projectServers, hostServers: [],
377
+ ...(sharedListenerUrl ? { sharedListener: { url: sharedListenerUrl } } : {}),
378
+ },
305
379
  instructions,
306
380
  args,
307
381
  env: configHome ? { CODEX_HOME: configHome } : {},
@@ -33,6 +33,8 @@ export function agentProfileProjectionReport(input: AgentProfileProjectionInput)
33
33
  add(provisioningSkillsEntry(assembled));
34
34
  add(provisioningPluginsEntry(assembled));
35
35
  add(provisioningMcpEntry(assembled));
36
+ add(projectMcpEntry(assembled));
37
+ add(sharedMcpEntry(assembled));
36
38
  if (profile) add(filesystemEntry(profile));
37
39
  add(configHomeEntry(assembled));
38
40
  if (assembled.vanilla) add(vanillaLaunchEntry(provider));
@@ -143,6 +145,33 @@ function provisioningMcpEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
143
145
  : `Relay-provisioned MCP servers emitted as -c mcp_servers.* overrides (${names.join(", ")}).`);
144
146
  }
145
147
 
148
+ // #667 — project `.mcp.json` projection provenance. `not-applicable` when the profile/spawn
149
+ // didn't opt in (the default); `applied` lists the spawn-cwd servers relay injected through
150
+ // the controlled --mcp-config / -c path (sourced from the project, not ambient host MCP).
151
+ function projectMcpEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
152
+ const optedIn = Boolean(a.profile?.projectMcp);
153
+ const names = a.mcp.projectServers;
154
+ if (!optedIn) return notApplicable("projectMcp", "off", "Project .mcp.json projection is off (opt in via the projectMcp profile/spawn option).");
155
+ if (!names.length) return applied("projectMcp", "none", "Project .mcp.json projection is on, but the spawn cwd declared no injectable MCP servers.");
156
+ const label = `${names.length} server${names.length === 1 ? "" : "s"}`;
157
+ return applied("projectMcp", label, a.provider === "claude"
158
+ ? `Project .mcp.json servers composed into --mcp-config (${names.join(", ")})${a.mcp.strict ? "; survives --strict-mcp-config" : ""}.`
159
+ : `Project .mcp.json servers emitted as -c mcp_servers.* overrides (${names.join(", ")}).`);
160
+ }
161
+
162
+ // #672 — shared host-listener MCP provenance. `not-applicable` when the profile/spawn didn't
163
+ // opt in (the default); `applied` reports the per-agent `callmux bridge` routing the agent's
164
+ // non-identity MCP (tokenlean, github) through the shared listener it points at. The server SET
165
+ // is owned by the listener config, so this surfaces the route + URL, not an enumerated list.
166
+ function sharedMcpEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
167
+ const optedIn = Boolean(a.profile?.sharedMcp);
168
+ const listener = a.mcp.sharedListener;
169
+ if (!optedIn || !listener) return notApplicable("sharedMcp", "off", "Shared host-listener MCP is off (opt in via the sharedMcp profile/spawn option); non-identity MCP is injected per-agent.");
170
+ return applied("sharedMcp", "on", a.provider === "claude"
171
+ ? `Non-identity MCP (e.g. tokenlean, github) routed through the shared callmux listener (${listener.url}) via a per-agent 'callmux bridge --cwd "$WORKTREE"', injected into --mcp-config (survives --strict-mcp-config); session survives a listener restart, session-cwd isolated. Relay endpoint stays per-agent (#215).`
172
+ : `Non-identity MCP (e.g. tokenlean, github) routed through the shared callmux listener (${listener.url}) via a per-agent 'callmux bridge --cwd "$WORKTREE"' emitted as -c mcp_servers.* overrides; session survives a listener restart, session-cwd isolated. Relay endpoint stays per-agent (#215).`);
173
+ }
174
+
146
175
  function globalInstructionsEntry(a: AssembledLaunch): AgentProfileProjectionEntry {
147
176
  const disposition = a.instructions.global;
148
177
  if (disposition === "allow") return applied("instructions.global", "allow", "Host/global provider instructions are left available.");
@@ -1,7 +1,7 @@
1
- import { existsSync, mkdirSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
1
+ import { existsSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from "node:fs";
2
2
  import { dirname, isAbsolute, join, resolve } from "node:path";
3
3
  import { sanitizeFsName } from "agent-relay-sdk/fs-name";
4
- import type { ProvisioningAssetFile, ResolvedProvisioning } from "agent-relay-sdk";
4
+ import type { ProvisioningAssetFile, ProvisioningMcpServer, ResolvedProvisioning } from "agent-relay-sdk";
5
5
  import type { RunnerSpawnConfig } from "./adapter";
6
6
 
7
7
  // Runner-side materialization of the relay-resolved provisioning bundle
@@ -24,6 +24,56 @@ export function profileProvisioning(config: Pick<RunnerSpawnConfig, "agentProfil
24
24
  return { skills: p.skills ?? [], plugins: p.plugins ?? [], mcpServers: p.mcpServers ?? {} };
25
25
  }
26
26
 
27
+ // The `mcpServers` keys a Claude `.mcp.json` entry may carry; coerced into the
28
+ // provider-neutral ProvisioningMcpServer shape so the launch assembler projects them the
29
+ // same way it does relay-provisioned servers (Claude `--mcp-config`, Codex `-c mcp_servers.*`).
30
+ function coerceProjectMcpServer(raw: unknown): ProvisioningMcpServer | null {
31
+ if (!raw || typeof raw !== "object") return null;
32
+ const r = raw as Record<string, unknown>;
33
+ const server: ProvisioningMcpServer = {};
34
+ if (r.type === "stdio" || r.type === "http" || r.type === "sse") server.type = r.type;
35
+ if (typeof r.command === "string") server.command = r.command;
36
+ if (Array.isArray(r.args) && r.args.every((a) => typeof a === "string")) server.args = r.args as string[];
37
+ if (r.env && typeof r.env === "object") server.env = r.env as Record<string, string>;
38
+ if (typeof r.url === "string") server.url = r.url;
39
+ if (r.headers && typeof r.headers === "object") server.headers = r.headers as Record<string, string>;
40
+ // Drop an entry that names no transport (neither a command nor a url) — nothing to launch.
41
+ return server.command || server.url ? server : null;
42
+ }
43
+
44
+ // #667 — project `.mcp.json` MCP projection. OPT-IN via the resolved profile's `projectMcp`
45
+ // (set per-spawn or by profile, relay-side). Reads the spawn cwd's COMMITTED `.mcp.json` and
46
+ // returns its `mcpServers` as a provider-neutral record, so the launch assembler injects them
47
+ // through the CONTROLLED `--mcp-config` (Claude) / `-c mcp_servers.*` (Codex) path that
48
+ // survives `--strict-mcp-config` — the project's own servers travel with the repo like they do
49
+ // for a human who clones it, WITHOUT enabling Claude's ambient project-discovery trust modal
50
+ // (which wedges headless agents, the reason #662 made every managed launch strict). The runner
51
+ // is the filesystem authority for the spawn cwd (relay may run on another host), so the read
52
+ // lands here. Best-effort: a missing/malformed file (or one with no `mcpServers`) yields {} and
53
+ // never aborts the launch. Returns {} when projection is OFF.
54
+ export function resolveProjectMcpServers(
55
+ config: Pick<RunnerSpawnConfig, "agentProfile" | "cwd">,
56
+ ): Record<string, ProvisioningMcpServer> {
57
+ if (!config.agentProfile?.projectMcp) return {};
58
+ const file = join(config.cwd, ".mcp.json");
59
+ if (!existsSync(file)) return {};
60
+ let parsed: unknown;
61
+ try {
62
+ parsed = JSON.parse(readFileSync(file, "utf8"));
63
+ } catch (err) {
64
+ console.warn(`project .mcp.json projection: failed to parse ${file}: ${(err as Error).message}`);
65
+ return {};
66
+ }
67
+ const servers = (parsed as { mcpServers?: unknown } | null)?.mcpServers;
68
+ if (!servers || typeof servers !== "object") return {};
69
+ const out: Record<string, ProvisioningMcpServer> = {};
70
+ for (const [name, raw] of Object.entries(servers as Record<string, unknown>)) {
71
+ const server = coerceProjectMcpServer(raw);
72
+ if (server) out[name] = server;
73
+ }
74
+ return out;
75
+ }
76
+
27
77
  // A resolved provisioning asset: WHERE it lands (`dir`) and HOW it materializes
28
78
  // (`source`). `files` writes the inline set into `dir`; `link` symlinks a pre-existing
29
79
  // source dir at `dir`; `inPlace` uses the source dir as-is (no write, dir IS the source).
@@ -39,6 +39,7 @@ function launchInjectionEvents(input: RunnerLaunchInjectionInput): KeyedRelayInj
39
39
  if (!supportsLaunchAssembly(input.provider)) return events;
40
40
  const assembled = assembleLaunch(input.provider, input.config, input.providerConfig);
41
41
  const toolCount = assembled.skills.length + assembled.plugins.length + assembled.mcp.servers.length
42
+ + assembled.mcp.projectServers.length
42
43
  + (assembled.relaySkills ? 1 : 0)
43
44
  + (assembled.relayPlugin ? 1 : 0)
44
45
  + (assembled.mcp.relayEndpoint ? 1 : 0)
@@ -52,6 +53,7 @@ function launchInjectionEvents(input: RunnerLaunchInjectionInput): KeyedRelayInj
52
53
  assembled.skills.length ? `${assembled.skills.length} skill${assembled.skills.length === 1 ? "" : "s"}` : "",
53
54
  assembled.plugins.length ? `${assembled.plugins.length} plugin${assembled.plugins.length === 1 ? "" : "s"}` : "",
54
55
  assembled.mcp.servers.length ? `${assembled.mcp.servers.length} MCP server${assembled.mcp.servers.length === 1 ? "" : "s"}` : "",
56
+ assembled.mcp.projectServers.length ? `${assembled.mcp.projectServers.length} project MCP server${assembled.mcp.projectServers.length === 1 ? "" : "s"}` : "",
55
57
  ].filter(Boolean);
56
58
  events.push({
57
59
  key: "tools",
package/src/relay-mcp.ts CHANGED
@@ -19,6 +19,24 @@ export const RELAY_MCP_SERVER_NAME = "agent-relay";
19
19
  export const RELAY_MCP_PATH = "/api/mcp";
20
20
  export const RELAY_MCP_TOKEN_ENV = "AGENT_RELAY_SESSION_TOKEN";
21
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
+
22
40
  export function relayMcpEndpoint(relayUrl: string): string {
23
41
  return `${relayUrl.replace(/\/+$/, "")}${RELAY_MCP_PATH}`;
24
42
  }
@@ -51,6 +69,13 @@ export function tomlString(value: string): string {
51
69
  // strict then preserves exactly that set and nothing else.
52
70
  import type { ProvisioningMcpServer } from "agent-relay-sdk";
53
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 { command: "callmux", args: ["bridge", "--url", url, "--cwd", cwd] };
77
+ }
78
+
54
79
  function claudeMcpServerEntry(server: ProvisioningMcpServer): Record<string, unknown> {
55
80
  const entry: Record<string, unknown> = {};
56
81
  if (server.type) entry.type = server.type;
@@ -17,7 +17,7 @@ import { ReplyObligationCache, obligationRequiresExplicitReply, responseCaptureR
17
17
  import { Outbox, type OutboxRecord } from "./outbox";
18
18
  import { extractLastAssistantTurn, extractLastAssistantTurnAfterEntry, countTranscriptEntries, extractFinalAssistantMessage, extractFinalAssistantMessageAfterEntry, extractHookAssistantMessage, extractLatestTurnSteps, stepDedupKeys, transcriptLooksComplete } from "./adapters/claude-transcript";
19
19
  import { profileUsesHostProviderGlobals } from "./profile-home";
20
- import { RELAY_MCP_TOKEN_ENV, relayMcpEndpoint } from "./relay-mcp";
20
+ import { RELAY_MCP_TOKEN_ENV, relayMcpEndpoint, resolveSharedMcpUrl } from "./relay-mcp";
21
21
  import { RelayMcpProxy } from "./relay-mcp-proxy";
22
22
  import { runtimeMetadata } from "./version";
23
23
  import { logger, parseLogLevel } from "./logger";
@@ -581,6 +581,9 @@ export class AgentRunner {
581
581
  // Stage 2 (#215): the MCP endpoint the agent's client should target — the runner-local
582
582
  // proxy when active, undefined when disabled (adapters fall back to the direct relay URL).
583
583
  ...(this.mcpProxyEndpoint ? { relayMcpEndpoint: this.mcpProxyEndpoint } : {}),
584
+ // #672: the shared host-listener URL the `callmux bridge` connects to (env → default).
585
+ // Always resolved; the assembler only emits the bridge when `agentProfile.sharedMcp` is on.
586
+ sharedMcpUrl: resolveSharedMcpUrl(),
584
587
  monitor: {
585
588
  deliver: (messages) => this.control!.deliverToMonitor(messages),
586
589
  },