agent-relay-server 0.21.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/cli.ts CHANGED
@@ -48,8 +48,9 @@ import { DEFAULT_CONTEXT_PROBE_STATE_DIR, runContextProbe } from "agent-relay-sd
48
48
  import { shellQuote } from "agent-relay-sdk/shell-utils";
49
49
  import { errMessage, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
50
50
  import type { WorkspaceDepsRefreshResult } from "agent-relay-sdk";
51
+ import { describeWorkspacePhase, readyContract, type WorkspacePhaseView } from "./workspace-phase";
51
52
 
52
- export const WORKSPACE_USAGE = "Usage: agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale|deps> [--id ID] [--strategy ...] [--purpose TEXT] [--repo PATH] [--check] [--execute] [--json]";
53
+ export const WORKSPACE_USAGE = "Usage: agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale|deps> [--id ID] [--strategy ...] [--purpose TEXT] [--repo PATH] [--wait] [--timeout SECONDS] [--check] [--execute] [--json]";
53
54
 
54
55
  const HELP = `
55
56
  agent-relay ${VERSION}
@@ -70,7 +71,7 @@ Usage:
70
71
  agent-relay context-probe [print-status-line] [--wrap COMMAND] [--agent-id ID] [--state-dir DIR] [--standalone]
71
72
  agent-relay token <create|list|revoke|verify> [options]
72
73
  agent-relay pair <target|create|status|accept|reject|hangup|send> [options]
73
- agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale|deps> [--id ID] [--strategy ...] [--purpose TEXT] [--repo PATH] [--check] [--execute] [--json]
74
+ agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale|deps> [--id ID] [--strategy ...] [--purpose TEXT] [--repo PATH] [--wait] [--timeout SECONDS] [--check] [--execute] [--json]
74
75
  agent-relay steward <queue|inspect|checks> [WORKSPACE_ID] [--repo PATH] [--json]
75
76
  agent-relay message <target> <body> [options]
76
77
  agent-relay get-message <messageId> [--json|--body]
@@ -237,9 +238,17 @@ Isolated workspaces
237
238
  Relay does. Just commit your work in the worktree, then:
238
239
  agent-relay workspace ready Hand off: Relay rebases onto the latest base,
239
240
  lands your work, and pushes.
240
- agent-relay workspace status Show your workspace's branch, base, status.
241
+ agent-relay workspace status Show your workspace's state + what to do next.
242
+ agent-relay workspace status --wait
243
+ Block until your branch lands (returns the
244
+ moment the auto-merge completes).
245
+ After "ready", status is "review_requested" — this is NORMAL, not an
246
+ escalation. Relay auto-merges clean rebases ~every 2 min; a steward agent is
247
+ spawned only if it can't land deterministically, so no steward = healthy. On
248
+ landing you move onto a fresh rebased branch (name gains a "--N" suffix).
241
249
  The base branch will move as other agents land in parallel — that is normal,
242
- let the merge handle it. Never push your branch yourself; it is local-only.
250
+ let the merge handle it. Never push, merge, resolve conflicts, or touch the
251
+ main checkout yourself; it is local-only and Relay (and the steward) own that.
243
252
  If typecheck/build fails on a missing module (a dep added to the base after
244
253
  your worktree was created), do NOT run a clean install — it mutates the shared
245
254
  node_modules. Instead refresh your worktree's deps in isolation:
@@ -1529,14 +1538,31 @@ function currentWorkspaceId(): string | undefined {
1529
1538
  }
1530
1539
  }
1531
1540
 
1532
- function formatWorkspaceStatus(ws: any): string {
1541
+ function formatWorkspaceStatus(ws: any, extra?: { guidance?: WorkspacePhaseView; landed?: string | null }): string {
1542
+ // Render the directive projection so the agent gets "what does this mean / what
1543
+ // do I do next" inline, not a bare enum it has to decode (#235). Computed
1544
+ // client-side from the record (the projection is pure) unless the wait response
1545
+ // already carried it.
1546
+ const guidance = extra?.guidance ?? describeWorkspacePhase(ws);
1533
1547
  const lines = [
1534
1548
  `Workspace ${ws.id}`,
1535
- ` status: ${ws.status}`,
1549
+ ` status: ${ws.status} (${guidance.phase}${guidance.actionNeeded ? "" : " — no action needed"})`,
1536
1550
  ` branch: ${ws.branch ?? "(none)"}`,
1537
1551
  ` base: ${ws.baseRef ?? "(none)"}`,
1538
1552
  ` worktree: ${ws.worktreePath ?? "(none)"}`,
1553
+ "",
1554
+ ` ${guidance.headline}`,
1555
+ ` ${guidance.hint}`,
1539
1556
  ];
1557
+ if (guidance.blockers.length) {
1558
+ lines.push("", " Blockers:");
1559
+ for (const b of guidance.blockers) lines.push(` - ${b}`);
1560
+ }
1561
+ if (guidance.nextActions.length) {
1562
+ lines.push("", " Next:");
1563
+ for (const a of guidance.nextActions) lines.push(` - ${a.cli ?? a.tool} — ${a.when}`);
1564
+ }
1565
+ if (extra?.landed) lines.push("", ` ${extra.landed}`);
1540
1566
  return lines.join("\n");
1541
1567
  }
1542
1568
 
@@ -1594,6 +1620,8 @@ async function handleWorkspaceCommand(args: string[]): Promise<void> {
1594
1620
  let execute = false;
1595
1621
  let check = false;
1596
1622
  let json = false;
1623
+ let wait = false;
1624
+ let timeoutSeconds: number | undefined;
1597
1625
  for (let i = 1; i < args.length; i++) {
1598
1626
  const arg = args[i];
1599
1627
  if (arg === "--id" && i + 1 < args.length) id = args[++i];
@@ -1603,6 +1631,12 @@ async function handleWorkspaceCommand(args: string[]): Promise<void> {
1603
1631
  else if (arg === "--execute") execute = true;
1604
1632
  else if (arg === "--check") check = true;
1605
1633
  else if (arg === "--refresh") check = false; // explicit no-op default for clarity
1634
+ else if (arg === "--wait") wait = true;
1635
+ else if (arg === "--timeout" && i + 1 < args.length) {
1636
+ const parsed = Number.parseInt(args[++i]!, 10);
1637
+ if (!Number.isFinite(parsed) || parsed <= 0) throw new Error("--timeout must be a positive number of seconds");
1638
+ timeoutSeconds = parsed;
1639
+ }
1606
1640
  else if (arg === "--json") json = true;
1607
1641
  else throw new Error(`Unknown workspace option "${arg}".`);
1608
1642
  }
@@ -1621,6 +1655,20 @@ async function handleWorkspaceCommand(args: string[]): Promise<void> {
1621
1655
  if (!id) throw new Error("No current workspace detected (AGENT_RELAY_WORKSPACE_JSON unset). Pass --id WORKSPACE_ID — only isolated-workspace agents have one.");
1622
1656
 
1623
1657
  if (action === "status") {
1658
+ // --wait long-polls via the action endpoint (server blocks until the status
1659
+ // changes — the blessed way to wait for an auto-merge to land, #235), and the
1660
+ // response carries the directive projection + land receipt. Plain status is a
1661
+ // bare GET; the projection is computed client-side for rendering.
1662
+ if (wait) {
1663
+ const res = await apiRequest("POST", `/api/workspaces/${encodeURIComponent(id)}/actions`, {
1664
+ action: "status",
1665
+ wait: true,
1666
+ ...(timeoutSeconds ? { timeoutSeconds } : {}),
1667
+ }) as { workspace?: any; guidance?: WorkspacePhaseView; landed?: string | null };
1668
+ if (json) { console.log(JSON.stringify(res, null, 2)); return; }
1669
+ console.log(formatWorkspaceStatus(res.workspace ?? res, { guidance: res.guidance, landed: res.landed }));
1670
+ return;
1671
+ }
1624
1672
  const ws = await apiRequest("GET", `/api/workspaces/${encodeURIComponent(id)}`);
1625
1673
  if (json) console.log(JSON.stringify(ws, null, 2));
1626
1674
  else console.log(formatWorkspaceStatus(ws));
@@ -1669,9 +1717,15 @@ async function handleWorkspaceCommand(args: string[]): Promise<void> {
1669
1717
  console.log(JSON.stringify(result, null, 2));
1670
1718
  return;
1671
1719
  }
1720
+ if (action === "ready") {
1721
+ // Print the whole contract up front so the agent isn't left decoding status
1722
+ // enums over the next minutes (#235). `result.workspace` is the post-ready row.
1723
+ const ws = (result as { workspace?: any }).workspace ?? { status: "review_requested" };
1724
+ console.log(`Workspace ${id} marked ready.\n\n${readyContract(ws)}`);
1725
+ return;
1726
+ }
1672
1727
  console.log(
1673
- action === "ready" ? `Workspace ${id} marked readyRelay will rebase onto the latest base, land, and push.`
1674
- : action === "claim" ? `Workspace ${id} claimed${purpose ? ` (${purpose})` : ""} — auto-merge will yield until released or the claim expires.`
1728
+ action === "claim" ? `Workspace ${id} claimed${purpose ? ` (${purpose})` : ""} auto-merge will yield until released or the claim expires.`
1675
1729
  : action === "release" ? `Workspace ${id} claim released.`
1676
1730
  : `Workspace ${id} merge requested (${strategy ?? "auto"}).`,
1677
1731
  );
@@ -34,6 +34,7 @@ import {
34
34
  import type { WorkspaceMergePreview, WorkspaceRecord, WorkspaceStatus } from "./types";
35
35
  import { requestWorkspaceMerge } from "./workspace-merge";
36
36
  import { workspaceActiveClaim } from "./workspace-claim";
37
+ import { TERMINAL_WORKSPACE_STATUSES } from "./workspace-phase";
37
38
  import { errMessage, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
38
39
  import { getStewardConfig } from "./config-store";
39
40
  import { ensureRepoSteward } from "./steward";
@@ -86,7 +87,6 @@ const STRANDABLE_STATUSES = new Set<WorkspaceStatus>(["review_requested", "confl
86
87
  // Live statuses worth scanning. Terminal (cleaned/merged/abandoned) and
87
88
  // in-flight (cleanup_requested) states are skipped.
88
89
  const CONFLICT_SCAN_STATUSES = new Set<WorkspaceStatus>(["active", "ready", "review_requested", "merge_planned", "conflict"]);
89
- const TERMINAL_WORKSPACE_STATUSES = new Set<WorkspaceStatus>(["cleaned", "merged", "abandoned"]);
90
90
  // In-flight merge statuses that should reconcile to `merged` once the host
91
91
  // reports the branch's work has landed in base (squash/cherry-pick, or a merged
92
92
  // PR). Excludes active/ready: an agent still working may have landed an early
@@ -695,7 +695,10 @@ async function autoMergeCleanFastForwards(): Promise<Record<string, unknown>> {
695
695
  // base, and mergeRebaseFf rebases then aborts-to-conflict on a real replay
696
696
  // conflict, so a too-optimistic prediction degrades safely to review_requested.
697
697
  // Only a predicted conflict (true) or unknown state (undefined) goes to the steward.
698
- const canLand = p.conflict === false && ahead > 0;
698
+ // A no-op (#230) nothing to land (ahead=0, clean) — is also landable: the host
699
+ // resolves it to a terminal `merged` status, draining the queue instead of waking
700
+ // the steward every sweep for a workspace with no work.
701
+ const canLand = p.noop === true || (p.conflict === false && ahead > 0);
699
702
  if (!canLand) {
700
703
  leftForSteward.push(ws.id);
701
704
  if (stewardEnabled) {
@@ -716,8 +719,10 @@ async function autoMergeCleanFastForwards(): Promise<Record<string, unknown>> {
716
719
  createActivityEvent({
717
720
  clientId: `workspace-auto-merge-${ws.id}-${Date.now()}`,
718
721
  kind: "state",
719
- title: behind > 0 ? "Workspace auto-merging (rebase)" : "Workspace auto-merging (clean fast-forward)",
720
- body: `${ws.branch ?? ws.id} → ${p.baseRef ?? "base"} (${ahead} ahead${behind > 0 ? `, ${behind} behind — rebasing` : ", clean"})`,
722
+ title: p.noop ? "Workspace auto-resolving (no work to merge)" : behind > 0 ? "Workspace auto-merging (rebase)" : "Workspace auto-merging (clean fast-forward)",
723
+ body: p.noop
724
+ ? `${ws.branch ?? ws.id} → ${p.baseRef ?? "base"} (nothing to land — resolving to merged)`
725
+ : `${ws.branch ?? ws.id} → ${p.baseRef ?? "base"} (${ahead} ahead${behind > 0 ? `, ${behind} behind — rebasing` : ", clean"})`,
721
726
  meta: ws.branch ?? ws.id,
722
727
  icon: "ti-git-merge",
723
728
  view: "orchestrators",
package/src/mcp.ts CHANGED
@@ -16,10 +16,13 @@ import {
16
16
  getArtifact,
17
17
  getMessage,
18
18
  getOrchestrator,
19
+ getRepoSteward,
19
20
  getTask,
20
21
  getThread,
22
+ getWorkspace,
21
23
  linkArtifact,
22
24
  listAgents,
25
+ listWorkspaces,
23
26
  searchAgents,
24
27
  type AgentSearchFilter,
25
28
  type AgentSearchSort,
@@ -40,7 +43,9 @@ import {
40
43
  isComponentAuthorizedFor,
41
44
  isIntegrationAllowed,
42
45
  } from "./security";
43
- 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";
44
49
  import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
45
50
  import { errMessage, isRecord, SPAWN_PROVIDERS, APPROVAL_MODES, VALID_EFFORTS } from "agent-relay-sdk";
46
51
  import { runnerRuntimeTokenEnv } from "./runtime-tokens";
@@ -67,6 +72,12 @@ type ToolDefinition = {
67
72
  description: string;
68
73
  requiredScopes: string[];
69
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;
70
81
  };
71
82
 
72
83
  const MCP_PROTOCOL_VERSION = "2024-11-05";
@@ -279,6 +290,108 @@ const TOOLS: ToolDefinition[] = [
279
290
  additionalProperties: false,
280
291
  },
281
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
+ },
282
395
  ];
283
396
 
284
397
  export async function postMcp(req: Request): Promise<Response> {
@@ -296,10 +409,14 @@ export async function postMcp(req: Request): Promise<Response> {
296
409
  }
297
410
 
298
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);
299
415
  return Response.json(jsonRpcResult(id, {
300
416
  protocolVersion: MCP_PROTOCOL_VERSION,
301
417
  capabilities: { tools: {} },
302
418
  serverInfo: { name: "agent-relay", title: "Agent Relay", version: VERSION },
419
+ ...(worktree ? { instructions: worktreeMcpInstructions(worktree) } : {}),
303
420
  }));
304
421
  }
305
422
  if (method === "notifications/initialized") {
@@ -370,6 +487,13 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
370
487
  else if (name === "relay_whoami") result = relayWhoami(auth);
371
488
  else if (name === "relay_spawn_agent") result = relaySpawnAgent(auth, args);
372
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);
373
497
  else throw new ValidationError(`unknown tool: ${name}`);
374
498
 
375
499
  auditToolCall(auth, name, "ok", result);
@@ -771,6 +895,119 @@ function relayShutdownAgent(auth: McpAuthContext, args: Record<string, unknown>)
771
895
  return { ok: true, action: "shutdown", orchestratorId: orchestrator.id, command };
772
896
  }
773
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
+
774
1011
  function replyContext(parent: Message): Record<string, unknown> {
775
1012
  const parentPayload = parent.payload ?? {};
776
1013
  if (parentPayload.schema !== "agent-relay.channel.v1" && !parentPayload.conversation) return {};
@@ -934,7 +1171,12 @@ function emitMessage(message: Message, created: boolean): void {
934
1171
  function visibleTools(auth: McpAuthContext): Array<Record<string, unknown>> {
935
1172
  return TOOLS
936
1173
  .filter((tool) => hasAnyScope(auth, tool.requiredScopes))
937
- .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
+ }));
938
1180
  }
939
1181
 
940
1182
  function toolResult(result: unknown): Record<string, unknown> {