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/docs/openapi.json +4 -1
- package/package.json +2 -2
- package/public/index.html +227 -94
- package/src/cli.ts +62 -8
- package/src/maintenance.ts +9 -4
- package/src/mcp.ts +244 -2
- package/src/routes.ts +79 -167
- package/src/workspace-actions.ts +336 -0
- package/src/workspace-phase.ts +181 -0
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
|
|
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
|
|
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 === "
|
|
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
|
);
|
package/src/maintenance.ts
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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 }) => ({
|
|
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> {
|