agent-relay-server 0.90.0 → 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/docs/openapi.json CHANGED
@@ -2,7 +2,7 @@
2
2
  "openapi": "3.1.0",
3
3
  "info": {
4
4
  "title": "Agent Relay API",
5
- "version": "0.90.0",
5
+ "version": "0.91.0",
6
6
  "description": "Real-time message bus for inter-agent communication. Agent-first: this spec is designed for machine consumption — agents can self-discover the full API surface via GET /api/spec.",
7
7
  "license": {
8
8
  "name": "MIT",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-server",
3
- "version": "0.90.0",
3
+ "version": "0.91.0",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -33,7 +33,7 @@
33
33
  "CONTRIBUTING.md"
34
34
  ],
35
35
  "dependencies": {
36
- "agent-relay-sdk": "0.2.69",
36
+ "agent-relay-sdk": "0.2.70",
37
37
  "ajv": "^8.20.0"
38
38
  },
39
39
  "scripts": {
@@ -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
  };
@@ -6,6 +6,7 @@ import { DEFAULT_SELF_RESUME_CONFIG, validateSelfResumeConfig } from "./self-res
6
6
  import { TEAM_TEMPLATE_NAMESPACE, validateTeamTemplate } from "./team-template-config";
7
7
  import { PROJECT_INSTRUCTION_POLICY_NAMES } from "./project-instructions";
8
8
  import { PROJECT_MCP_POLICY_NAMES } from "./project-mcp";
9
+ import { SHARED_MCP_POLICY_NAMES } from "./shared-mcp";
9
10
  import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog";
10
11
  import { errMessage, isRecord, SPAWN_PROVIDERS, VALID_WORKSPACE_MODES } from "agent-relay-sdk"; import { effectiveProviderCatalogList } from "./provider-catalog-store";
11
12
  import type {
@@ -237,6 +238,17 @@ function cleanAgentProfileProjectMcp(value: unknown): AgentProfile["projectMcp"]
237
238
  return { policy: cleanEnum(policy, "projectMcp.policy", PROJECT_MCP_POLICY_NAMES) as NonNullable<AgentProfile["projectMcp"]>["policy"] };
238
239
  }
239
240
 
241
+ // #672 — shared host-listener MCP projection profile default. Mirrors projectMcp:
242
+ // boolean | { policy } → normalized { policy? } | undefined (OFF).
243
+ function cleanAgentProfileSharedMcp(value: unknown): AgentProfile["sharedMcp"] {
244
+ if (value === undefined || value === null || value === false) return undefined;
245
+ if (value === true) return {};
246
+ if (!isRecord(value)) throw new ValidationError("sharedMcp must be a boolean or { policy } object");
247
+ const policy = value.policy;
248
+ if (policy === undefined || policy === null) return {};
249
+ return { policy: cleanEnum(policy, "sharedMcp.policy", SHARED_MCP_POLICY_NAMES) as NonNullable<AgentProfile["sharedMcp"]>["policy"] };
250
+ }
251
+
240
252
  function agentProfileDefaults(input: Pick<AgentProfile, "name" | "base"> & Partial<AgentProfile>): AgentProfile {
241
253
  const isolated = input.base !== "host";
242
254
  // "vanilla" (#552) additionally suppresses the REPO instruction cascade
@@ -258,6 +270,7 @@ function agentProfileDefaults(input: Pick<AgentProfile, "name" | "base"> & Parti
258
270
  },
259
271
  ...(input.projectInstructions ? { projectInstructions: input.projectInstructions } : {}),
260
272
  ...(input.projectMcp ? { projectMcp: input.projectMcp } : {}),
273
+ ...(input.sharedMcp ? { sharedMcp: input.sharedMcp } : {}),
261
274
  relay: {
262
275
  context: input.relay?.context ?? !isolated,
263
276
  skills: input.relay?.skills ?? !isolated,
@@ -333,6 +346,7 @@ function validateAgentProfile(key: string, value: unknown): AgentProfile {
333
346
  },
334
347
  projectInstructions: cleanAgentProfileProjectInstructions(value.projectInstructions),
335
348
  projectMcp: cleanAgentProfileProjectMcp(value.projectMcp),
349
+ sharedMcp: cleanAgentProfileSharedMcp(value.sharedMcp),
336
350
  relay: {
337
351
  context: relay.context === undefined ? defaults.relay.context : cleanBoolean(relay.context, "relay.context"),
338
352
  skills: relay.skills === undefined ? defaults.relay.skills : cleanBoolean(relay.skills, "relay.skills"),
@@ -5,6 +5,7 @@ import { buildSpawnTargets } from "../spawn-targets";
5
5
  import { McpAuthError, McpNotFoundError } from "../mcp-errors";
6
6
  import { parseProjectInstructions, PROJECT_INSTRUCTION_POLICY_NAMES } from "../project-instructions";
7
7
  import { parseProjectMcp, PROJECT_MCP_POLICY_NAMES } from "../project-mcp";
8
+ import { parseSharedMcp, SHARED_MCP_POLICY_NAMES } from "../shared-mcp";
8
9
  import { countLiveSpawnedAgents, listAgents, listWorkspaces, ValidationError } from "../db";
9
10
  import { listCommands } from "../commands-db";
10
11
  import { DEFAULT_SPAWN_PROFILE_NAME } from "../config-store";
@@ -36,6 +37,7 @@ export const SPAWN_TOOLS: ToolDefinition[] = [
36
37
  systemPromptAppend: { type: "string" },
37
38
  projectInstructions: { type: "object", description: "#409 — project instruction projection (default OFF). When set, relay composes a knowledge preamble from the spawn cwd's resolved project (own + inherited entries) and prepends it to systemPromptAppend, instead of relying on host CLAUDE.md. `policy: \"all\"` includes ingested host content; `\"vanilla\"` excludes it (manual+promoted only). Omit to keep current behavior.", properties: { policy: { type: "string", enum: PROJECT_INSTRUCTION_POLICY_NAMES } }, additionalProperties: false },
38
39
  projectMcp: { type: "object", description: "#667 — project `.mcp.json` MCP projection (default OFF). When set, the worker gets the MCP servers committed in the spawn cwd's `.mcp.json` (e.g. an app's playtest/manuscript servers), injected by relay through the controlled `--mcp-config` path that survives `--strict-mcp-config` — sourced from the project, not ambient host MCP, and without triggering Claude's project-trust modal. `policy: \"all\"` (the default) injects every declared server. Omit to keep current host-only behavior.", properties: { policy: { type: "string", enum: PROJECT_MCP_POLICY_NAMES } }, additionalProperties: false },
40
+ sharedMcp: { type: "object", description: "#672 — shared host-listener MCP (default OFF). When set, the worker's NON-IDENTITY MCP (tokenlean, github) is routed through one orchestrator-owned shared `callmux` listener via a per-agent `callmux bridge --cwd \"$WORKTREE\"`, so the MCP session survives a listener/downstream restart and each isolated worktree keeps session-cwd isolation. Rides the controlled `--mcp-config` path (composes with `--strict-mcp-config`). The identity-bearing relay endpoint stays per-agent (#215), never on the shared listener. `policy: \"all\"` (the default) routes the listener's full non-identity set. Omit to keep current per-agent injection.", properties: { policy: { type: "string", enum: SHARED_MCP_POLICY_NAMES } }, additionalProperties: false },
39
41
  profile: { type: "string", description: `Agent profile name to apply (env, instructions, permissions, MCP/skills, spawn quota). Omit to use \`${DEFAULT_SPAWN_PROFILE_NAME}\`.` },
40
42
  tags: { type: "array", items: { type: "string" } },
41
43
  capabilities: { type: "array", items: { type: "string" } },
@@ -111,6 +113,7 @@ export async function relaySpawnAgent(auth: McpAuthContext, args: Record<string,
111
113
  systemPromptAppend: optionalString(args.systemPromptAppend, "systemPromptAppend", 64_000),
112
114
  projectInstructions: parseProjectInstructions(args.projectInstructions, (m) => new ValidationError(m)),
113
115
  projectMcp: parseProjectMcp(args.projectMcp, (m) => new ValidationError(m)),
116
+ sharedMcp: parseSharedMcp(args.sharedMcp, (m) => new ValidationError(m)),
114
117
  tags: optionalStringArray(args.tags, "tags"),
115
118
  capabilities: optionalStringArray(args.capabilities, "capabilities"),
116
119
  providerArgs: optionalStringArray(args.providerArgs, "providerArgs"),
@@ -8,6 +8,7 @@ import { registerAgent } from "../services/register-agent";
8
8
  import { spawnAgent, type SpawnAgentInput } from "../services/spawn-agent";
9
9
  import { parseProjectInstructions } from "../project-instructions";
10
10
  import { parseProjectMcp } from "../project-mcp";
11
+ import { parseSharedMcp } from "../shared-mcp";
11
12
  import { authContextFromRequest } from "../services/auth-context";
12
13
  import { listHostDirectories } from "../agent-spawn";
13
14
  import { rankRouteCandidates, type RouteAdvisorInput } from "../context-router";
@@ -123,6 +124,7 @@ export const postAgentSpawn: Handler = async (req) => {
123
124
  systemPromptAppend: cleanString(parsed.body.systemPromptAppend, "systemPromptAppend", { max: 64_000 }),
124
125
  projectInstructions: parseProjectInstructions(parsed.body.projectInstructions, (m) => new ValidationError(m)),
125
126
  projectMcp: parseProjectMcp(parsed.body.projectMcp, (m) => new ValidationError(m)),
127
+ sharedMcp: parseSharedMcp(parsed.body.sharedMcp, (m) => new ValidationError(m)),
126
128
  tags: cleanStringArray(parsed.body.tags, "tags", { itemMax: 80, maxItems: 50 }),
127
129
  capabilities: cleanStringArray(parsed.body.capabilities, "capabilities", { itemMax: 80, maxItems: 50 }),
128
130
  providerArgs: cleanStringArray(parsed.body.providerArgs, "providerArgs", { itemMax: 80, maxItems: 50 }),
@@ -34,6 +34,7 @@ import { selectSpawnOrchestrator } from "../spawn-targets";
34
34
  import { DEFAULT_SPAWN_PROFILE_NAME, getAgentProfile } from "../config-store";
35
35
  import { composeSpawnInstructionsWithEvents, type ProjectInstructionPolicy } from "../project-instructions";
36
36
  import type { ProjectMcpPolicy } from "../project-mcp";
37
+ import type { SharedMcpPolicy } from "../shared-mcp";
37
38
  import { autoSeedProjectForCwd } from "./project";
38
39
  import { hasComponentScope, isComponentAuthorizedFor } from "../security";
39
40
  import { isPathWithinBase } from "../utils";
@@ -84,6 +85,16 @@ export interface SpawnAgentInput {
84
85
  * to projectInstructions; carried to the runner on the in-flight `agentProfile.projectMcp`.
85
86
  */
86
87
  projectMcp?: ProjectMcpPolicy;
88
+ /**
89
+ * #672 — shared host-listener MCP. When set, the runner emits a per-agent `callmux bridge`
90
+ * stdio entry so the agent's NON-IDENTITY MCP (tokenlean, github) flows through the
91
+ * orchestrator-owned shared listener instead of per-agent server processes — the session
92
+ * survives a listener/downstream restart, with `--cwd "$WORKTREE"` session-cwd isolation.
93
+ * Additive-from-empty + opt-in: absent (undefined) = OFF → the launch is byte-identical to
94
+ * today. Sibling to projectMcp; carried to the runner on the in-flight `agentProfile.sharedMcp`.
95
+ * The identity-bearing relay endpoint stays per-agent (#215), never on the shared listener.
96
+ */
97
+ sharedMcp?: SharedMcpPolicy;
87
98
  /**
88
99
  * Legacy report-back contract flag. Spawned children now report up by lineage: their
89
100
  * turn-final response is delivered to the spawner automatically, without a reply obligation.
@@ -188,6 +199,13 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
188
199
  // turn a profile default OFF — opt-in is the trust gate, not opt-out.
189
200
  if (input.projectMcp !== undefined) agentProfile.projectMcp = input.projectMcp;
190
201
 
202
+ // #672 — shared host-listener MCP: a per-spawn `sharedMcp` overrides the profile default,
203
+ // same opt-in trust gate as projectMcp (an explicit `false` parses to undefined and so
204
+ // cannot turn a profile default OFF). The runner emits the bridge entry from
205
+ // `agentProfile.sharedMcp` (it owns the spawn cwd → `--cwd "$WORKTREE"`); thread the resolved
206
+ // decision onto the in-flight profile copy (a fresh object, never the cached config).
207
+ if (input.sharedMcp !== undefined) agentProfile.sharedMcp = input.sharedMcp;
208
+
191
209
  // --- THE GATE (uniform across transports) ---------------------------------
192
210
  // Coarse capability: spawning requires command:spawn. The MCP dispatcher already enforced
193
211
  // this; the HTTP route did not — asserting here makes it transport-invariant.
@@ -0,0 +1,57 @@
1
+ // Shared host-listener MCP (#672, Phase 1 of epic #670) — the relay-owned, OPT-IN path that
2
+ // routes a spawned worker's NON-IDENTITY MCP (tokenlean, github) through ONE orchestrator-owned
3
+ // shared `callmux` listener per host, attached via a per-agent `callmux bridge` with
4
+ // `--cwd "$WORKTREE"` for session-cwd isolation. Sibling seam to project-mcp (#667): same
5
+ // opt-in + parse shape, default OFF.
6
+ //
7
+ // WHY: a shared listener turns "an MCP/listener restart kills the agent" into "the agent never
8
+ // notices" (the bridge reconnects + retries once on the next tool call — the #659 work-loss
9
+ // class), and collapses N per-agent server processes into one shared set (~100MB saved per
10
+ // extra listener). The spike (#671) proved both: session survives a listener restart, and
11
+ // session-cwd routing gives each isolated worktree its own cwd from the single shared listener.
12
+ //
13
+ // INVARIANT (epic #670): callmux owns transport; relay owns authz. The shared listener carries
14
+ // ONLY non-identity servers — the identity-bearing relay endpoint stays per-agent on #215 and
15
+ // is NEVER routed through the shared listener (the spike proved a shared listener can't carry
16
+ // per-agent relay tokens; that's Phase 2, gated on callmux#36).
17
+ //
18
+ // DIVISION OF LABOR: relay carries the OPT-IN DECISION (this module + the spawn
19
+ // param/profile field). The orchestrator owns the shared listener lifecycle/config (#673),
20
+ // and runner owns bridge-arg emission (launch-assembly.ts → sharedMcpBridgeServer), the
21
+ // filesystem/launch authority.
22
+
23
+ export const SHARED_MCP_POLICY_NAMES = ["all"] as const;
24
+ export type SharedMcpPolicyName = (typeof SHARED_MCP_POLICY_NAMES)[number];
25
+
26
+ /** Per-spawn shared-listener request. Presence (a non-undefined value) means "route
27
+ * non-identity MCP through the shared listener"; absence means OFF. `policy` selects the
28
+ * server set (`all` = the listener's full non-identity set; the sole value today). */
29
+ export interface SharedMcpPolicy {
30
+ policy?: SharedMcpPolicyName;
31
+ }
32
+
33
+ /**
34
+ * Parse a spawn-transport's `sharedMcp` wire value into the normalized policy (or
35
+ * undefined = OFF). Shared by the MCP and HTTP spawn paths so they cannot drift. Mirrors
36
+ * parseProjectMcp exactly:
37
+ * - undefined / null / false → undefined (OFF)
38
+ * - true → {} (ON, default policy)
39
+ * - { policy } → validated policy
40
+ * Throws `onInvalid(message)` for a malformed shape or unknown policy name.
41
+ */
42
+ export function parseSharedMcp(
43
+ value: unknown,
44
+ onInvalid: (message: string) => Error,
45
+ ): SharedMcpPolicy | undefined {
46
+ if (value === undefined || value === null || value === false) return undefined;
47
+ if (value === true) return {};
48
+ if (typeof value !== "object") {
49
+ throw onInvalid("sharedMcp must be a boolean or { policy } object");
50
+ }
51
+ const policy = (value as Record<string, unknown>).policy;
52
+ if (policy === undefined || policy === null) return {};
53
+ if (typeof policy !== "string" || !SHARED_MCP_POLICY_NAMES.includes(policy as SharedMcpPolicyName)) {
54
+ throw onInvalid(`sharedMcp.policy must be one of: ${SHARED_MCP_POLICY_NAMES.join(", ")}`);
55
+ }
56
+ return { policy: policy as SharedMcpPolicyName };
57
+ }