agent-relay-server 0.20.0 → 0.22.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/src/mcp.ts CHANGED
@@ -9,22 +9,30 @@ import { bytesToStream, readBodyBytes } from "./http-body";
9
9
  import { MAX_BODY_BYTES, VERSION } from "./config";
10
10
  import { getManagedAgentState, getSpawnPolicy, listSpawnPolicies } from "./config-store";
11
11
  import {
12
+ countLiveSpawnedAgents,
12
13
  createArtifact,
13
14
  createActivityEvent,
14
15
  getAgent,
15
16
  getArtifact,
16
17
  getMessage,
17
18
  getOrchestrator,
19
+ getRepoSteward,
18
20
  getTask,
19
21
  getThread,
22
+ getWorkspace,
20
23
  linkArtifact,
21
24
  listAgents,
25
+ listWorkspaces,
26
+ searchAgents,
27
+ type AgentSearchFilter,
28
+ type AgentSearchSort,
22
29
  listArtifactsForEntity,
23
30
  listOrchestrators,
24
31
  sendMessageWithResult,
25
32
  upsertArtifactBlob,
26
33
  ValidationError,
27
34
  } from "./db";
35
+ import { planSend, type DeliveryReceipt } from "./agent-ref";
28
36
  import { emitRelayEvent } from "./events";
29
37
  import { emitMessageQueued, emitNewMessage } from "./sse";
30
38
  import {
@@ -35,10 +43,12 @@ import {
35
43
  isComponentAuthorizedFor,
36
44
  isIntegrationAllowed,
37
45
  } from "./security";
38
- import type { ActivityKind, AgentCard, ArtifactKind, ArtifactSensitivity, AttachmentRef, Command, SendMessageInput, Message, SpawnApprovalMode, SpawnProvider } from "./types";
46
+ import type { ActivityKind, AgentCard, ArtifactKind, ArtifactSensitivity, AttachmentRef, Command, SendMessageInput, Message, SpawnApprovalMode, SpawnProvider, WorkspaceMergeStrategy, WorkspaceRecord } from "./types";
47
+ import { applyWorkspaceAction, waitForWorkspaceStatus, type WorkspaceAction } from "./workspace-actions";
48
+ import { describeWorkspacePhase, landReceipt, readyContract, TERMINAL_WORKSPACE_STATUSES, worktreeMcpInstructions } from "./workspace-phase";
39
49
  import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
40
50
  import { errMessage, isRecord, SPAWN_PROVIDERS, APPROVAL_MODES, VALID_EFFORTS } from "agent-relay-sdk";
41
- import { childRunnerRuntimeTokenEnv, runnerRuntimeTokenEnv } from "./runtime-tokens";
51
+ import { runnerRuntimeTokenEnv } from "./runtime-tokens";
42
52
 
43
53
  type JsonRpcId = string | number | null;
44
54
 
@@ -62,6 +72,12 @@ type ToolDefinition = {
62
72
  description: string;
63
73
  requiredScopes: string[];
64
74
  inputSchema: Record<string, unknown>;
75
+ // Emit `_meta: { "anthropic/alwaysLoad": true }` in tools/list so Claude Code
76
+ // (v2.1.121+) loads this tool's schema EAGERLY instead of deferring it behind a
77
+ // ToolSearch round-trip. Reserve for the few verbs most agents use every session
78
+ // (the doc warns each one costs context every turn). Verified convention; same
79
+ // mechanism callmux's per-tool alwaysLoad uses.
80
+ alwaysLoad?: boolean;
65
81
  };
66
82
 
67
83
  const MCP_PROTOCOL_VERSION = "2024-11-05";
@@ -75,13 +91,13 @@ const VALID_ARTIFACT_ENTITY_TYPES = ["message", "task", "recipeRun", "recipeStep
75
91
  const TOOLS: ToolDefinition[] = [
76
92
  {
77
93
  name: "relay_send_message",
78
- description: "Send an Agent Relay message with structured fields instead of shell quoting.",
94
+ description: "Send an Agent Relay message. Returns a delivery receipt (delivered/expectReply/recipients): if expectReply is false, no live recipient exists — don't wait for a reply. Unknown or ambiguous targets are rejected up front, never silently dropped.",
79
95
  requiredScopes: ["messages:write", "message:send"],
80
96
  inputSchema: {
81
97
  type: "object",
82
98
  properties: {
83
- from: { type: "string", description: "Sender Relay agent id." },
84
- to: { type: "string", description: "Target agent id, label, tag:, cap:, policy:, or broadcast." },
99
+ from: { type: "string", description: "Deprecated/optional. Your identity is taken from your auth token; ignored for managed agents. You never need to set this." },
100
+ to: { type: "string", description: "Target: an agent id, label, name, or a unique id segment (e.g. the number part) — resolved automatically. Or a fan-out selector: tag:, cap:, label:, policy:, or broadcast." },
85
101
  body: { type: "string" },
86
102
  subject: { type: "string" },
87
103
  channel: { type: "string" },
@@ -91,18 +107,18 @@ const TOOLS: ToolDefinition[] = [
91
107
  payload: { type: "object" },
92
108
  meta: { type: "object" },
93
109
  },
94
- required: ["from", "to", "body"],
110
+ required: ["to", "body"],
95
111
  additionalProperties: false,
96
112
  },
97
113
  },
98
114
  {
99
115
  name: "relay_reply",
100
- description: "Reply to an Agent Relay message by id, preserving reply/thread/channel context.",
116
+ description: "Reply to an Agent Relay message by id, preserving reply/thread/channel context. Returns a delivery receipt; expectReply:false means the original sender is no longer reachable.",
101
117
  requiredScopes: ["messages:write", "message:send"],
102
118
  inputSchema: {
103
119
  type: "object",
104
120
  properties: {
105
- from: { type: "string", description: "Sender Relay agent id." },
121
+ from: { type: "string", description: "Deprecated/optional. Your identity is taken from your auth token; ignored for managed agents. You never need to set this." },
106
122
  messageId: { type: "integer", minimum: 1 },
107
123
  body: { type: "string" },
108
124
  subject: { type: "string" },
@@ -111,7 +127,7 @@ const TOOLS: ToolDefinition[] = [
111
127
  payload: { type: "object" },
112
128
  meta: { type: "object" },
113
129
  },
114
- required: ["from", "messageId", "body"],
130
+ required: ["messageId", "body"],
115
131
  additionalProperties: false,
116
132
  },
117
133
  },
@@ -184,7 +200,7 @@ const TOOLS: ToolDefinition[] = [
184
200
  },
185
201
  {
186
202
  name: "relay_agent_status",
187
- description: "Inspect Relay agent, orchestrator, and managed spawn-policy status.",
203
+ description: "Inspect Relay agent, orchestrator, and managed spawn-policy status. The default agent list shows only RUNNING agents (the ones you can actually message/reply/pair with); pass includeOffline:true for the full roster.",
188
204
  requiredScopes: ["agents:read", "agent:read"],
189
205
  inputSchema: {
190
206
  type: "object",
@@ -192,14 +208,47 @@ const TOOLS: ToolDefinition[] = [
192
208
  agentId: { type: "string" },
193
209
  policyName: { type: "string" },
194
210
  orchestratorId: { type: "string" },
211
+ includeOffline: { type: "boolean", description: "Include offline/stale agents in the default list (default false)." },
195
212
  },
196
213
  additionalProperties: false,
197
214
  },
198
215
  },
216
+ {
217
+ name: "relay_find_agents",
218
+ description: "Search the fleet for agents by label, capability, provider, tag, machine, status, or kind, sorted by last-active/created/label. Defaults to running agents only. Use this to find a reachable, capable peer before messaging or pairing — instead of dumping the whole roster.",
219
+ requiredScopes: ["agents:read", "agent:read"],
220
+ inputSchema: {
221
+ type: "object",
222
+ properties: {
223
+ label: { type: "string", description: "Substring match on label or name." },
224
+ capability: { type: ["string", "array"], items: { type: "string" }, description: "One or more capabilities (OR within)." },
225
+ provider: { type: ["string", "array"], items: { type: "string" }, description: "Model provider, e.g. claude or codex." },
226
+ tag: { type: ["string", "array"], items: { type: "string" } },
227
+ machine: { type: ["string", "array"], items: { type: "string" } },
228
+ kind: { type: ["string", "array"], items: { type: "string" } },
229
+ status: { type: ["string", "array"], items: { type: "string" }, description: "online/idle/busy/offline/stale, the 'running' shorthand, or 'all'. Default 'running'." },
230
+ spawnedBy: { type: "string", description: "Parent agent id, or 'me' for your own spawned children (#221)." },
231
+ sortBy: { type: "string", enum: ["lastActive", "created", "label"] },
232
+ order: { type: "string", enum: ["asc", "desc"] },
233
+ limit: { type: "integer", minimum: 1, maximum: 200 },
234
+ },
235
+ additionalProperties: false,
236
+ },
237
+ },
238
+ {
239
+ name: "relay_whoami",
240
+ description: "Return your own Relay identity and agent card (id, label, status, tags, capabilities). Your identity comes from your auth token — you do NOT need this to send, reply, or pair; use it only when you need to reason about yourself.",
241
+ requiredScopes: ["mcp:use"],
242
+ inputSchema: {
243
+ type: "object",
244
+ properties: {},
245
+ additionalProperties: false,
246
+ },
247
+ },
199
248
  {
200
249
  name: "relay_spawn_agent",
201
- description: "Request a provider agent spawn through Relay's orchestrator command bus.",
202
- requiredScopes: ["agents:write", "agent:write"],
250
+ description: "Spawn a long-living provider agent through Relay's orchestrator. Gated: requires the command:spawn scope, granted only to agents whose profile sets maxSpawnedAgents>0, up to that live-children quota. Spawned agents cannot themselves spawn (no grandchildren).",
251
+ requiredScopes: ["command:spawn"],
203
252
  inputSchema: {
204
253
  type: "object",
205
254
  properties: {
@@ -212,6 +261,7 @@ const TOOLS: ToolDefinition[] = [
212
261
  approvalMode: { type: "string", enum: APPROVAL_MODES },
213
262
  prompt: { type: "string" },
214
263
  systemPromptAppend: { type: "string" },
264
+ profile: { type: "string", description: "Agent profile name to apply (env, instructions, permissions, MCP/skills, spawn quota)." },
215
265
  tags: { type: "array", items: { type: "string" } },
216
266
  capabilities: { type: "array", items: { type: "string" } },
217
267
  providerArgs: { type: "array", items: { type: "string" } },
@@ -224,8 +274,8 @@ const TOOLS: ToolDefinition[] = [
224
274
  },
225
275
  {
226
276
  name: "relay_shutdown_agent",
227
- description: "Request an agent shutdown through Relay's orchestrator command bus.",
228
- requiredScopes: ["agents:write", "agent:write"],
277
+ description: "Shut down an agent through Relay's orchestrator. Gated: requires the command:shutdown scope; an agent caller may only target its OWN live spawned children. Admin tokens keep full reach.",
278
+ requiredScopes: ["command:shutdown"],
229
279
  inputSchema: {
230
280
  type: "object",
231
281
  properties: {
@@ -240,6 +290,108 @@ const TOOLS: ToolDefinition[] = [
240
290
  additionalProperties: false,
241
291
  },
242
292
  },
293
+ {
294
+ name: "relay_workspace_status",
295
+ description: "Get your isolated workspace's lifecycle status (active/ready/conflict/review_requested/merged/...). With wait:true this BLOCKS until the status changes — use it after relay_workspace_ready to wait for the auto-merge to land your branch on main (status leaves 'ready' → merged/recycled-to-active with a fresh rebased branch) or to surface a conflict/review_requested the steward couldn't auto-resolve. Defaults to the workspace you own; pass workspaceId to target another.",
296
+ requiredScopes: ["agents:read", "agent:read", "agent:write"],
297
+ alwaysLoad: true,
298
+ inputSchema: {
299
+ type: "object",
300
+ properties: {
301
+ workspaceId: { type: "string", description: "Defaults to your own isolated workspace (resolved from your identity)." },
302
+ wait: { type: "boolean", description: "Block until the workspace status changes (the actionable signal), or until the timeout." },
303
+ timeoutSeconds: { type: "integer", minimum: 1, maximum: 600, description: "Max seconds to wait when wait:true (default 300, max 600). Returns early on any transition." },
304
+ },
305
+ additionalProperties: false,
306
+ },
307
+ },
308
+ {
309
+ name: "relay_workspace_ready",
310
+ description: "Signal that your branch work is finished and ready to merge back to main. This kicks off the auto-merge-back: a clean merge lands immediately; on conflict a steward agent reconciles and lands it (escalating if it can't). After this, poll relay_workspace_status with wait:true until it lands and you get a fresh rebased branch to continue on. Defaults to your own workspace.",
311
+ requiredScopes: ["agent:write"],
312
+ alwaysLoad: true,
313
+ inputSchema: {
314
+ type: "object",
315
+ properties: {
316
+ workspaceId: { type: "string", description: "Defaults to your own isolated workspace." },
317
+ detail: { type: "string", description: "Optional note recorded on the activity feed." },
318
+ },
319
+ additionalProperties: false,
320
+ },
321
+ },
322
+ {
323
+ name: "relay_workspace_deps",
324
+ description: "Re-provision your worktree's dependencies when the shared symlinked node_modules has gone stale (e.g. typecheck reports a missing module). checkOnly:true just reports staleness without changing anything. Self-service — acts only on your own worktree.",
325
+ requiredScopes: ["agent:write"],
326
+ inputSchema: {
327
+ type: "object",
328
+ properties: {
329
+ workspaceId: { type: "string", description: "Defaults to your own isolated workspace." },
330
+ checkOnly: { type: "boolean", description: "Report staleness only; do not re-provision." },
331
+ detail: { type: "string" },
332
+ },
333
+ additionalProperties: false,
334
+ },
335
+ },
336
+ {
337
+ name: "relay_workspace_list",
338
+ description: "List the isolated workspaces you own (id, branch, status, repo). Use it when you're unsure which workspace is yours or to see a workspace's current state. Admin/server tokens may pass all:true to list every workspace.",
339
+ requiredScopes: ["agents:read", "agent:read", "agent:write"],
340
+ inputSchema: {
341
+ type: "object",
342
+ properties: {
343
+ all: { type: "boolean", description: "Admin/server only: list all workspaces, not just your own." },
344
+ ownerAgentId: { type: "string", description: "Admin/server + all:true: filter to one owner." },
345
+ repoRoot: { type: "string", description: "Filter to one repository root." },
346
+ },
347
+ additionalProperties: false,
348
+ },
349
+ },
350
+ {
351
+ name: "relay_workspace_claim",
352
+ description: "Take a TTL'd steward claim on a workspace so the deterministic auto-merge yields to you while you reconcile a conflict. Renew/hold while validating; relay_workspace_release frees it. Owner or assigned steward only.",
353
+ requiredScopes: ["agent:write"],
354
+ inputSchema: {
355
+ type: "object",
356
+ properties: {
357
+ workspaceId: { type: "string", description: "Defaults to your own isolated workspace." },
358
+ purpose: { type: "string", description: "Why you're claiming (shown in the activity feed)." },
359
+ detail: { type: "string" },
360
+ },
361
+ additionalProperties: false,
362
+ },
363
+ },
364
+ {
365
+ name: "relay_workspace_release",
366
+ description: "Release a steward claim taken with relay_workspace_claim, letting the auto-merge resume. Owner or assigned steward only.",
367
+ requiredScopes: ["agent:write"],
368
+ inputSchema: {
369
+ type: "object",
370
+ properties: {
371
+ workspaceId: { type: "string", description: "Defaults to your own isolated workspace." },
372
+ purpose: { type: "string" },
373
+ detail: { type: "string" },
374
+ },
375
+ additionalProperties: false,
376
+ },
377
+ },
378
+ {
379
+ name: "relay_workspace_land",
380
+ description: "Dispatch a base merge for your workspace directly (lease-serialized per repo, same path the auto-merge job uses). Most branch agents should use relay_workspace_ready instead and let the relay land it; this is the explicit/steward path and requires the command:write scope.",
381
+ requiredScopes: ["command:write"],
382
+ inputSchema: {
383
+ type: "object",
384
+ properties: {
385
+ workspaceId: { type: "string", description: "Defaults to your own isolated workspace." },
386
+ strategy: { type: "string", enum: ["pr", "rebase-ff", "auto"], description: "Merge strategy (default auto)." },
387
+ deleteBranch: { type: "boolean", description: "Delete the branch after a successful merge (default true)." },
388
+ prTitle: { type: "string" },
389
+ prBody: { type: "string" },
390
+ detail: { type: "string" },
391
+ },
392
+ additionalProperties: false,
393
+ },
394
+ },
243
395
  ];
244
396
 
245
397
  export async function postMcp(req: Request): Promise<Response> {
@@ -257,10 +409,14 @@ export async function postMcp(req: Request): Promise<Response> {
257
409
  }
258
410
 
259
411
  if (method === "initialize") {
412
+ // Mode-tailored primer (#214 §3): surface the worktree merge-back map only
413
+ // to callers that own an isolated worktree; shared-workspace agents omit it.
414
+ const worktree = callerIsolatedWorkspace(auth);
260
415
  return Response.json(jsonRpcResult(id, {
261
416
  protocolVersion: MCP_PROTOCOL_VERSION,
262
417
  capabilities: { tools: {} },
263
418
  serverInfo: { name: "agent-relay", title: "Agent Relay", version: VERSION },
419
+ ...(worktree ? { instructions: worktreeMcpInstructions(worktree) } : {}),
264
420
  }));
265
421
  }
266
422
  if (method === "notifications/initialized") {
@@ -327,8 +483,17 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
327
483
  else if (name === "relay_upload_artifact") result = await relayUploadArtifact(auth, args);
328
484
  else if (name === "relay_attach_artifact") result = relayAttachArtifact(auth, args);
329
485
  else if (name === "relay_agent_status") result = relayAgentStatus(args);
486
+ else if (name === "relay_find_agents") result = relayFindAgents(auth, args);
487
+ else if (name === "relay_whoami") result = relayWhoami(auth);
330
488
  else if (name === "relay_spawn_agent") result = relaySpawnAgent(auth, args);
331
489
  else if (name === "relay_shutdown_agent") result = relayShutdownAgent(auth, args);
490
+ else if (name === "relay_workspace_status") result = await relayWorkspaceStatus(auth, args);
491
+ else if (name === "relay_workspace_list") result = relayWorkspaceList(auth, args);
492
+ else if (name === "relay_workspace_ready") result = relayWorkspaceMutation(auth, "ready", args);
493
+ else if (name === "relay_workspace_deps") result = relayWorkspaceMutation(auth, "deps-refresh", args);
494
+ else if (name === "relay_workspace_claim") result = relayWorkspaceMutation(auth, "claim", args);
495
+ else if (name === "relay_workspace_release") result = relayWorkspaceMutation(auth, "release-claim", args);
496
+ else if (name === "relay_workspace_land") result = relayWorkspaceMutation(auth, "merge", args);
332
497
  else throw new ValidationError(`unknown tool: ${name}`);
333
498
 
334
499
  auditToolCall(auth, name, "ok", result);
@@ -340,12 +505,67 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
340
505
  }
341
506
  }
342
507
 
343
- function relaySendMessage(auth: McpAuthContext, args: Record<string, unknown>): Message {
508
+ // A managed/runtime MCP token is minted with `constraints.agents = [its own agent id]`
509
+ // (see issueMcpRuntimeToken). That single allowed agent IS the caller's identity, and the
510
+ // security layer already treats it as the only `from` this token may use. Derive it so
511
+ // agents never need to know — or type — their own id.
512
+ function senderIdentity(auth: McpAuthContext): string | undefined {
513
+ if (auth.kind !== "component") return undefined;
514
+ const agents = auth.component?.constraints?.agents;
515
+ return agents?.length === 1 ? agents[0] : undefined;
516
+ }
517
+
518
+ // Caller's own agent id for spawn/shutdown gating (#221). `senderIdentity` covers
519
+ // identity-bearing tokens (interactive/mcp, constraints.agents). Managed agents spawned by
520
+ // the orchestrator authenticate with a runner token that carries no `agents` constraint but
521
+ // DOES carry its spawnRequestId/policy — resolve those back to the registered agent card.
522
+ // Returns undefined for server/admin tokens (unrestricted by design).
523
+ function callerAgentId(auth: McpAuthContext): string | undefined {
524
+ const direct = senderIdentity(auth);
525
+ if (direct) return direct;
526
+ if (auth.kind !== "component") return undefined;
527
+ const c = auth.component?.constraints;
528
+ const spawnRequestId = c?.spawnRequestIds?.length === 1 ? c.spawnRequestIds[0] : undefined;
529
+ const policyName = c?.policies?.length === 1 ? c.policies[0] : undefined;
530
+ if (!spawnRequestId && !policyName) return undefined;
531
+ const match = listAgents().find((a) =>
532
+ (spawnRequestId !== undefined && a.meta?.spawnRequestId === spawnRequestId) ||
533
+ (policyName !== undefined && a.meta?.policyName === policyName));
534
+ return match?.id;
535
+ }
536
+
537
+ function resolveSender(auth: McpAuthContext, rawFrom: unknown): string {
538
+ // Token identity wins and cannot be spoofed; any provided `from` is ignored when known.
539
+ const identity = senderIdentity(auth);
540
+ if (identity) return identity;
541
+ // Server/integration/multi-agent tokens carry no single identity — keep requiring `from`.
542
+ return stringField(rawFrom, "from", { required: true, max: 200 });
543
+ }
544
+
545
+ function relayWhoami(auth: McpAuthContext): Record<string, unknown> {
546
+ const agentId = senderIdentity(auth);
547
+ const agent = agentId ? getAgent(agentId) : null;
548
+ return {
549
+ agentId: agentId ?? null,
550
+ actor: auth.actor,
551
+ kind: auth.kind,
552
+ scopes: auth.scopes,
553
+ agent: agent ?? null,
554
+ };
555
+ }
556
+
557
+ function relaySendMessage(auth: McpAuthContext, args: Record<string, unknown>): Message & { delivery: DeliveryReceipt } {
344
558
  const attachments = optionalAttachments(args.attachments);
345
559
  const payload = payloadWithAttachments(optionalRecord(args.payload, "payload"), attachments);
560
+ const requestedTo = stringField(args.to, "to", { required: true, max: 200 });
561
+ // Resolve the target to a canonical agent id (so poll-time matching works) and refuse
562
+ // up front when it's unknown or ambiguous — never store a message no one will receive.
563
+ const plan = planSend(requestedTo, listAgents());
564
+ if (plan.kind === "not_found") throw new McpNotFoundError(plan.message);
565
+ if (plan.kind === "ambiguous") throw new ValidationError(plan.message);
346
566
  const input: SendMessageInput = {
347
- from: stringField(args.from, "from", { required: true, max: 200 }),
348
- to: stringField(args.to, "to", { required: true, max: 200 }),
567
+ from: resolveSender(auth, args.from),
568
+ to: plan.to,
349
569
  body: stringField(args.body, "body", { required: true, maxBytes: MAX_BODY_BYTES }),
350
570
  subject: optionalString(args.subject, "subject", 200),
351
571
  channel: optionalString(args.channel, "channel", 120),
@@ -359,10 +579,10 @@ function relaySendMessage(auth: McpAuthContext, args: Record<string, unknown>):
359
579
  assertComponentResourceAllowed(auth, { scope: "message:send", resource: { target: input.to, channel: input.channel, agentId: input.from } });
360
580
  const result = sendMessageWithResult(input);
361
581
  emitMessage(result.message, result.created);
362
- return result.message;
582
+ return { ...result.message, delivery: plan.receipt };
363
583
  }
364
584
 
365
- function relayReply(auth: McpAuthContext, args: Record<string, unknown>): Message {
585
+ function relayReply(auth: McpAuthContext, args: Record<string, unknown>): Message & { delivery: DeliveryReceipt } {
366
586
  const messageId = positiveId(args.messageId, "messageId");
367
587
  const parent = getMessage(messageId);
368
588
  if (!parent) throw new McpNotFoundError(`message ${messageId} not found`);
@@ -375,7 +595,7 @@ function relayReply(auth: McpAuthContext, args: Record<string, unknown>): Messag
375
595
  ...replyContext(parent),
376
596
  }, attachments);
377
597
  const input: SendMessageInput = {
378
- from: stringField(args.from, "from", { required: true, max: 200 }),
598
+ from: resolveSender(auth, args.from),
379
599
  to: parent.from,
380
600
  body: stringField(args.body, "body", { required: true, maxBytes: MAX_BODY_BYTES }),
381
601
  subject: optionalString(args.subject, "subject", 200),
@@ -389,7 +609,13 @@ function relayReply(auth: McpAuthContext, args: Record<string, unknown>): Messag
389
609
  assertComponentResourceAllowed(auth, { scope: "message:send", resource: { target: input.to, channel: input.channel, agentId: input.from } });
390
610
  const result = sendMessageWithResult(input);
391
611
  emitMessage(result.message, result.created);
392
- return result.message;
612
+ // Reply routing is fixed to the parent's sender — never reject, but report whether
613
+ // that original sender is still reachable so the agent doesn't wait forever.
614
+ const plan = planSend(input.to, listAgents());
615
+ const delivery: DeliveryReceipt = plan.kind === "not_found" || plan.kind === "ambiguous"
616
+ ? { delivered: false, expectReply: false, recipients: [], reason: "original sender no longer reachable" }
617
+ : plan.receipt;
618
+ return { ...result.message, delivery };
393
619
  }
394
620
 
395
621
  function relayGetMessage(args: Record<string, unknown>): Record<string, unknown> {
@@ -490,13 +716,49 @@ function relayAgentStatus(args: Record<string, unknown>): Record<string, unknown
490
716
  if (!orchestrator) throw new McpNotFoundError(`orchestrator ${orchestratorId} not found`);
491
717
  return { orchestrator };
492
718
  }
719
+ // Default to running agents only — offline/stale rows are pure noise (you can only
720
+ // message/reply/pair with a live agent) and bloat the payload. includeOffline opts back in.
721
+ const includeOffline = optionalBoolean(args.includeOffline, "includeOffline") === true;
493
722
  return {
494
- agents: listAgents(),
723
+ agents: searchAgents({ status: includeOffline ? "all" : "running" }),
495
724
  spawnPolicies: listSpawnPolicies().map((entry) => policyStatusPayload(entry.value)),
496
725
  orchestrators: listOrchestrators(),
497
726
  };
498
727
  }
499
728
 
729
+ function optionalStringOrArray(value: unknown, field: string): string | string[] | undefined {
730
+ if (value === undefined || value === null) return undefined;
731
+ if (typeof value === "string") return value;
732
+ if (Array.isArray(value)) {
733
+ return value.map((item, i) => stringField(item, `${field}[${i}]`, { required: true, max: 200 }));
734
+ }
735
+ throw new ValidationError(`${field} must be a string or string array`);
736
+ }
737
+
738
+ function relayFindAgents(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
739
+ // `spawnedBy: "me"` resolves to the caller's own id — list the children you spawned (#221).
740
+ const spawnedByArg = optionalString(args.spawnedBy, "spawnedBy", 240);
741
+ const spawnedBy = spawnedByArg === "me" ? callerAgentId(auth) ?? "\0unresolved" : spawnedByArg;
742
+ const filter: AgentSearchFilter = {
743
+ label: optionalString(args.label, "label", 200),
744
+ capability: optionalStringOrArray(args.capability, "capability"),
745
+ provider: optionalStringOrArray(args.provider, "provider"),
746
+ tag: optionalStringOrArray(args.tag, "tag"),
747
+ machine: optionalStringOrArray(args.machine, "machine"),
748
+ kind: optionalStringOrArray(args.kind, "kind"),
749
+ spawnedBy,
750
+ // Default to running — you can only act on a live agent (consistent with #218).
751
+ status: optionalStringOrArray(args.status, "status") ?? "running",
752
+ };
753
+ const sort: AgentSearchSort = {
754
+ sortBy: optionalEnum(args.sortBy, "sortBy", ["lastActive", "created", "label"] as const),
755
+ order: optionalEnum(args.order, "order", ["asc", "desc"] as const),
756
+ limit: optionalPositiveInt(args.limit, "limit"),
757
+ };
758
+ const agents = searchAgents(filter, sort);
759
+ return { agents, count: agents.length };
760
+ }
761
+
500
762
  function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
501
763
  const provider = enumField(args.provider, "provider", SPAWN_PROVIDERS) as SpawnProvider;
502
764
  const cwd = optionalString(args.cwd, "cwd", 500);
@@ -510,28 +772,43 @@ function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknown>): R
510
772
  const spawnRequestId = optionalString(args.spawnRequestId, "spawnRequestId", 160) ?? generateSpawnRequestId();
511
773
  const label = optionalString(args.label, "label", 120);
512
774
  const policyName = optionalString(args.policyName, "policyName", 120);
775
+ const profile = optionalString(args.profile, "profile", 120);
776
+
777
+ // #221 runtime gate (belt; the coarse `command:spawn` scope is enforced in callTool, and is
778
+ // granted only to agents whose profile sets maxSpawnedAgents>0 and never to children).
779
+ // Server/admin tokens have no caller identity → unrestricted by design.
780
+ const callerId = callerAgentId(auth);
781
+ if (callerId) {
782
+ const me = getAgent(callerId);
783
+ if (me?.spawnedBy) {
784
+ throw new McpAuthError("spawned agents cannot spawn further agents (no grandchildren)");
785
+ }
786
+ const quota = auth.component?.constraints?.maxSpawnedAgents ?? 0;
787
+ const live = countLiveSpawnedAgents(callerId);
788
+ if (live >= quota) {
789
+ throw new ValidationError(`spawn quota reached (${live}/${quota} live children) — shut one down or wait for one to exit`);
790
+ }
791
+ }
792
+
513
793
  assertComponentResourceAllowed(auth, {
514
794
  scope: "agent:write",
515
795
  resource: { orchestratorId: orchestrator.id, cwd: resolvedCwd, policyName, spawnRequestId },
516
796
  });
517
- const env = auth.component?.role === "provider"
518
- ? childRunnerEnvForDelegatingComponent(auth, {
519
- orchestratorId: orchestrator.id,
520
- cwd: resolvedCwd,
521
- provider,
522
- label,
523
- policyName,
524
- spawnRequestId,
525
- })
526
- : runnerRuntimeTokenEnv({
527
- orchestratorId: orchestrator.id,
528
- cwd: resolvedCwd,
529
- provider,
530
- label,
531
- policyName,
532
- spawnRequestId,
533
- createdBy: auth.actor,
534
- });
797
+
798
+ // Child runner token: a normal long-living agent that is NOT itself spawn-capable
799
+ // (canSpawn:false → no grandchildren), stamped with authoritative lineage so it registers
800
+ // with spawnedBy = caller (the child can't forge it; it's read from the signed token).
801
+ const env = runnerRuntimeTokenEnv({
802
+ orchestratorId: orchestrator.id,
803
+ cwd: resolvedCwd,
804
+ provider,
805
+ label,
806
+ policyName,
807
+ spawnRequestId,
808
+ createdBy: callerId ?? auth.actor,
809
+ canSpawn: false,
810
+ ...(callerId ? { spawnedBy: callerId } : {}),
811
+ });
535
812
  const command = createCommand({
536
813
  type: "agent.spawn",
537
814
  source: "system",
@@ -542,6 +819,7 @@ function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknown>): R
542
819
  modelParams: selection,
543
820
  cwd: resolvedCwd,
544
821
  label,
822
+ profile: profile || undefined,
545
823
  tags: optionalStringArray(args.tags, "tags") ?? [],
546
824
  capabilities: optionalStringArray(args.capabilities, "capabilities") ?? [],
547
825
  approvalMode,
@@ -562,34 +840,6 @@ function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknown>): R
562
840
  return { ok: true, orchestratorId: orchestrator.id, provider, command };
563
841
  }
564
842
 
565
- function childRunnerEnvForDelegatingComponent(
566
- auth: McpAuthContext,
567
- input: {
568
- orchestratorId: string;
569
- cwd: string;
570
- provider: SpawnProvider;
571
- label?: string;
572
- policyName?: string;
573
- spawnRequestId: string;
574
- },
575
- ): Record<string, string> {
576
- const component = auth.component;
577
- if (!component) throw new McpAuthError("component token required for child-agent delegation");
578
- if (component.constraints?.canDelegate !== true) {
579
- throw new McpAuthError("component token cannot delegate child agents");
580
- }
581
- return childRunnerRuntimeTokenEnv({
582
- parentAgentId: component.sub,
583
- orchestratorId: input.orchestratorId,
584
- cwd: input.cwd,
585
- provider: input.provider,
586
- label: input.label,
587
- policyName: input.policyName,
588
- spawnRequestId: input.spawnRequestId,
589
- createdBy: component.sub,
590
- });
591
- }
592
-
593
843
  function relayShutdownAgent(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
594
844
  const agentId = optionalString(args.agentId, "agentId", 240);
595
845
  const policyName = optionalString(args.policyName, "policyName", 120);
@@ -598,6 +848,21 @@ function relayShutdownAgent(auth: McpAuthContext, args: Record<string, unknown>)
598
848
  if (!agentId && !policyName && !spawnRequestId && !tmuxSession) {
599
849
  throw new ValidationError("agentId, policyName, spawnRequestId, or tmuxSession required");
600
850
  }
851
+
852
+ // #221: an agent caller may only shut down its OWN live spawned children, addressed by
853
+ // agentId. Broad targeting (policy/spawnRequestId/tmux) and cross-agent kills stay admin-only
854
+ // — otherwise spawn permission silently becomes kill-anyone permission. Server/admin tokens
855
+ // (no caller identity) keep full reach.
856
+ const callerId = callerAgentId(auth);
857
+ if (callerId) {
858
+ if (!agentId || policyName || spawnRequestId || tmuxSession) {
859
+ throw new McpAuthError("agents may only shut down their own spawned children, addressed by agentId");
860
+ }
861
+ const target = getAgent(agentId);
862
+ if (!target || target.spawnedBy !== callerId) {
863
+ throw new McpAuthError(`agent ${agentId} is not one of your spawned children`);
864
+ }
865
+ }
601
866
  const orchestrator = selectControlOrchestrator({
602
867
  orchestratorId: optionalString(args.orchestratorId, "orchestratorId", 200),
603
868
  agentId,
@@ -630,6 +895,119 @@ function relayShutdownAgent(auth: McpAuthContext, args: Record<string, unknown>)
630
895
  return { ok: true, action: "shutdown", orchestratorId: orchestrator.id, command };
631
896
  }
632
897
 
898
+ // --- Workspace lifecycle tools (#215) -------------------------------------
899
+ // Thin MCP surface over the shared applyWorkspaceAction core. Coarse scope is
900
+ // enforced in callTool; here we add the resource check the routes' authorizeRoute
901
+ // does: an agent may only act on a workspace it OWNS or stewards (server/wildcard
902
+ // tokens keep full reach). Defaults the target to the caller's own workspace so a
903
+ // branch agent never has to hunt for its workspace id.
904
+
905
+ function isWorkspaceAdmin(auth: McpAuthContext): boolean {
906
+ return auth.kind === "server" || hasScope(auth, "*");
907
+ }
908
+
909
+ function assertWorkspaceAccess(auth: McpAuthContext, ws: WorkspaceRecord, caller: string | undefined): void {
910
+ if (isWorkspaceAdmin(auth)) return;
911
+ if (!caller) throw new McpAuthError(`not authorized for workspace ${ws.id} (its owner or assigned steward only)`);
912
+ if (caller === ws.ownerAgentId) return;
913
+ // The repo's elected steward (authoritative repo_stewards record, not the mirrored
914
+ // workspace column which only updates on steward change) may reconcile any
915
+ // workspace in its repo — the conflict-landing flow it exists for.
916
+ if (caller === getRepoSteward(ws.repoRoot)?.stewardAgentId) return;
917
+ throw new McpAuthError(`not authorized for workspace ${ws.id} (its owner or assigned steward only)`);
918
+ }
919
+
920
+ function resolveWorkspaceForCaller(auth: McpAuthContext, args: Record<string, unknown>): WorkspaceRecord {
921
+ const explicitId = optionalString(args.workspaceId, "workspaceId", 240);
922
+ const caller = callerAgentId(auth);
923
+ if (explicitId) {
924
+ const ws = getWorkspace(explicitId);
925
+ if (!ws) throw new McpNotFoundError(`workspace ${explicitId} not found`);
926
+ assertWorkspaceAccess(auth, ws, caller);
927
+ return ws;
928
+ }
929
+ if (!caller) throw new ValidationError("workspaceId is required: no caller agent identity to resolve your own workspace");
930
+ const first = callerIsolatedWorkspace(auth);
931
+ if (!first) throw new McpNotFoundError("you don't own an isolated workspace; pass workspaceId");
932
+ return first;
933
+ }
934
+
935
+ // Non-throwing variant for the initialize primer: the caller's most recently
936
+ // active isolated worktree, or undefined (no caller identity / shared-only).
937
+ // listWorkspaces is ORDER BY updated_at DESC → most recent worktree first.
938
+ function callerIsolatedWorkspace(auth: McpAuthContext): WorkspaceRecord | undefined {
939
+ const caller = callerAgentId(auth);
940
+ if (!caller) return undefined;
941
+ return listWorkspaces({ ownerAgentId: caller }).find((w) => w.mode === "isolated" && !TERMINAL_WORKSPACE_STATUSES.has(w.status));
942
+ }
943
+
944
+ async function relayWorkspaceStatus(auth: McpAuthContext, args: Record<string, unknown>): Promise<Record<string, unknown>> {
945
+ const ws = resolveWorkspaceForCaller(auth, args);
946
+ if (optionalBoolean(args.wait, "wait") !== true) {
947
+ return { workspace: ws, guidance: describeWorkspacePhase(ws), transitioned: false, timedOut: false };
948
+ }
949
+ const timeoutSeconds = optionalPositiveInt(args.timeoutSeconds, "timeoutSeconds");
950
+ const result = await waitForWorkspaceStatus(ws.id, timeoutSeconds ? { timeoutMs: timeoutSeconds * 1000 } : {});
951
+ if (!result.workspace) throw new McpNotFoundError(`workspace ${ws.id} not found`);
952
+ const landed = result.transitioned ? landReceipt(result.fromStatus, result.workspace) : null;
953
+ return {
954
+ workspace: result.workspace,
955
+ guidance: describeWorkspacePhase(result.workspace),
956
+ ...(landed ? { landed } : {}),
957
+ fromStatus: result.fromStatus,
958
+ transitioned: result.transitioned,
959
+ timedOut: result.timedOut,
960
+ };
961
+ }
962
+
963
+ function relayWorkspaceList(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
964
+ const filter: { ownerAgentId?: string; repoRoot?: string } = {};
965
+ const repoRoot = optionalString(args.repoRoot, "repoRoot", 1024);
966
+ if (repoRoot) filter.repoRoot = repoRoot;
967
+ if (isWorkspaceAdmin(auth) && optionalBoolean(args.all, "all") === true) {
968
+ const owner = optionalString(args.ownerAgentId, "ownerAgentId", 240);
969
+ if (owner) filter.ownerAgentId = owner;
970
+ } else {
971
+ const caller = callerAgentId(auth);
972
+ if (!caller) throw new ValidationError("no caller agent identity to list your workspaces");
973
+ filter.ownerAgentId = caller;
974
+ }
975
+ const workspaces = listWorkspaces(filter);
976
+ return { workspaces, count: workspaces.length };
977
+ }
978
+
979
+ function relayWorkspaceMutation(auth: McpAuthContext, action: WorkspaceAction, args: Record<string, unknown>): Record<string, unknown> {
980
+ const ws = resolveWorkspaceForCaller(auth, args);
981
+ const result = applyWorkspaceAction(ws, {
982
+ action,
983
+ agentId: callerAgentId(auth) ?? auth.actor,
984
+ detail: optionalString(args.detail, "detail", 4000),
985
+ strategy: action === "merge" ? (optionalEnum(args.strategy, "strategy", ["pr", "rebase-ff", "auto"] as const) as WorkspaceMergeStrategy | undefined) : undefined,
986
+ deleteBranch: action === "merge" ? optionalBoolean(args.deleteBranch, "deleteBranch") : undefined,
987
+ prTitle: optionalString(args.prTitle, "prTitle", 240),
988
+ prBody: optionalString(args.prBody, "prBody", 8000),
989
+ purpose: optionalString(args.purpose, "purpose", 120),
990
+ checkOnly: action === "deps-refresh" ? optionalBoolean(args.checkOnly, "checkOnly") === true : undefined,
991
+ auditMetadata: { via: "mcp", actor: auth.actor },
992
+ });
993
+ if (!result.ok) {
994
+ if (result.httpStatus === 404) throw new McpNotFoundError(result.error);
995
+ if (result.httpStatus === 403) throw new McpAuthError(result.error);
996
+ throw new ValidationError(result.error);
997
+ }
998
+ if (result.command) emitCommand(result.command);
999
+ const payload: Record<string, unknown> = { workspace: result.workspace };
1000
+ if (result.command) payload.command = result.command;
1001
+ if (result.claim !== undefined) payload.claim = result.claim;
1002
+ // After a `ready` hand-off, state the whole contract up front + the directive
1003
+ // next-step, so the agent doesn't decode status enums over the next minutes (#235).
1004
+ if (action === "ready") {
1005
+ payload.contract = readyContract(result.workspace);
1006
+ payload.guidance = describeWorkspacePhase(result.workspace);
1007
+ }
1008
+ return payload;
1009
+ }
1010
+
633
1011
  function replyContext(parent: Message): Record<string, unknown> {
634
1012
  const parentPayload = parent.payload ?? {};
635
1013
  if (parentPayload.schema !== "agent-relay.channel.v1" && !parentPayload.conversation) return {};
@@ -793,7 +1171,12 @@ function emitMessage(message: Message, created: boolean): void {
793
1171
  function visibleTools(auth: McpAuthContext): Array<Record<string, unknown>> {
794
1172
  return TOOLS
795
1173
  .filter((tool) => hasAnyScope(auth, tool.requiredScopes))
796
- .map(({ name, description, inputSchema }) => ({ name, description, inputSchema }));
1174
+ .map(({ name, description, inputSchema, alwaysLoad }) => ({
1175
+ name,
1176
+ description,
1177
+ inputSchema,
1178
+ ...(alwaysLoad ? { _meta: { "anthropic/alwaysLoad": true } } : {}),
1179
+ }));
797
1180
  }
798
1181
 
799
1182
  function toolResult(result: unknown): Record<string, unknown> {