agent-relay-server 0.62.2 → 0.63.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.
Files changed (161) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +2 -2
  3. package/public/assets/{MessageBubble-DRrxfjyT.js → MessageBubble-Cl6ufPHw.js} +2 -2
  4. package/public/assets/MessageBubble-Cl6ufPHw.js.map +1 -0
  5. package/public/assets/{PlanGraphPanel-Dp2eYazz.js → PlanGraphPanel-BnfjmkQ8.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-Dp2eYazz.js.map → PlanGraphPanel-BnfjmkQ8.js.map} +1 -1
  7. package/public/assets/{activity--t0U2yzq.js → activity-aFxvGLtd.js} +2 -2
  8. package/public/assets/{activity--t0U2yzq.js.map → activity-aFxvGLtd.js.map} +1 -1
  9. package/public/assets/{agent-profiles-BMtJYzxn.js → agent-profiles-B4mleUDz.js} +2 -2
  10. package/public/assets/{agent-profiles-BMtJYzxn.js.map → agent-profiles-B4mleUDz.js.map} +1 -1
  11. package/public/assets/{agents-Ck9AePlc.js → agents-DW5djToQ.js} +2 -2
  12. package/public/assets/{agents-Ck9AePlc.js.map → agents-DW5djToQ.js.map} +1 -1
  13. package/public/assets/{analytics-CzfK387L.js → analytics-CRi6thtg.js} +2 -2
  14. package/public/assets/{analytics-CzfK387L.js.map → analytics-CRi6thtg.js.map} +1 -1
  15. package/public/assets/{automation-Cgw8vtE8.js → automation-Do4OpauI.js} +2 -2
  16. package/public/assets/{automation-Cgw8vtE8.js.map → automation-Do4OpauI.js.map} +1 -1
  17. package/public/assets/{branch-state-badge-DnEEPtOP.js → branch-state-badge-D3W3w7zz.js} +2 -2
  18. package/public/assets/{branch-state-badge-DnEEPtOP.js.map → branch-state-badge-D3W3w7zz.js.map} +1 -1
  19. package/public/assets/{channels-Dc3L2MQs.js → channels-BytdR9NS.js} +2 -2
  20. package/public/assets/{channels-Dc3L2MQs.js.map → channels-BytdR9NS.js.map} +1 -1
  21. package/public/assets/{chat-Fc6iJ7sa.js → chat-CvaQ2czL.js} +2 -2
  22. package/public/assets/{chat-Fc6iJ7sa.js.map → chat-CvaQ2czL.js.map} +1 -1
  23. package/public/assets/{connectors-Dc2ttGWs.js → connectors-BLQDp8Bc.js} +2 -2
  24. package/public/assets/{connectors-Dc2ttGWs.js.map → connectors-BLQDp8Bc.js.map} +1 -1
  25. package/public/assets/{display-DzNvOu1j.js → display-B06I5sqa.js} +3 -3
  26. package/public/assets/display-B06I5sqa.js.map +1 -0
  27. package/public/assets/{formatted-body-impl-Bg-gx7rG.js → formatted-body-impl-0Lf8e1Ic.js} +2 -2
  28. package/public/assets/{formatted-body-impl-Bg-gx7rG.js.map → formatted-body-impl-0Lf8e1Ic.js.map} +1 -1
  29. package/public/assets/{index-BeDncjN4.js → index-C3su8ezq.js} +6 -6
  30. package/public/assets/{index-BeDncjN4.js.map → index-C3su8ezq.js.map} +1 -1
  31. package/public/assets/{insights-c08aW-7n.js → insights-64uExH1E.js} +2 -2
  32. package/public/assets/{insights-c08aW-7n.js.map → insights-64uExH1E.js.map} +1 -1
  33. package/public/assets/{integrations-BwpgcqnF.js → integrations-HyC1EaIp.js} +2 -2
  34. package/public/assets/{integrations-BwpgcqnF.js.map → integrations-HyC1EaIp.js.map} +1 -1
  35. package/public/assets/{maintenance-CTKP0kWB.js → maintenance-EVBpKH07.js} +2 -2
  36. package/public/assets/{maintenance-CTKP0kWB.js.map → maintenance-EVBpKH07.js.map} +1 -1
  37. package/public/assets/{managed-agents-BofCxo8d.js → managed-agents-BLZPxGuw.js} +2 -2
  38. package/public/assets/{managed-agents-BofCxo8d.js.map → managed-agents-BLZPxGuw.js.map} +1 -1
  39. package/public/assets/{markdown-preview-impl-CQ-6BIb8.js → markdown-preview-impl-Bg6rCWYN.js} +2 -2
  40. package/public/assets/{markdown-preview-impl-CQ-6BIb8.js.map → markdown-preview-impl-Bg6rCWYN.js.map} +1 -1
  41. package/public/assets/{memory-DvfXjMoi.js → memory-Bjr8bgjj.js} +2 -2
  42. package/public/assets/{memory-DvfXjMoi.js.map → memory-Bjr8bgjj.js.map} +1 -1
  43. package/public/assets/{messages-C9VsPzZ_.js → messages-CRYw1F4z.js} +2 -2
  44. package/public/assets/{messages-C9VsPzZ_.js.map → messages-CRYw1F4z.js.map} +1 -1
  45. package/public/assets/{orchestrators-MffVueeE.js → orchestrators-D-co1vdp.js} +2 -2
  46. package/public/assets/{orchestrators-MffVueeE.js.map → orchestrators-D-co1vdp.js.map} +1 -1
  47. package/public/assets/{overview-BSVajefM.js → overview-Ddy5czri.js} +2 -2
  48. package/public/assets/{overview-BSVajefM.js.map → overview-Ddy5czri.js.map} +1 -1
  49. package/public/assets/{pairs-B5711-Cs.js → pairs-CFVM8uHL.js} +2 -2
  50. package/public/assets/{pairs-B5711-Cs.js.map → pairs-CFVM8uHL.js.map} +1 -1
  51. package/public/assets/{security-DkUwK9Vj.js → security-Df4_76bh.js} +2 -2
  52. package/public/assets/{security-DkUwK9Vj.js.map → security-Df4_76bh.js.map} +1 -1
  53. package/public/assets/{select-DrM2RRiT.js → select-Cq3BBnL9.js} +2 -2
  54. package/public/assets/{select-DrM2RRiT.js.map → select-Cq3BBnL9.js.map} +1 -1
  55. package/public/assets/{settings-DdKCb-vy.js → settings-DOLEYXZL.js} +2 -2
  56. package/public/assets/{settings-DdKCb-vy.js.map → settings-DOLEYXZL.js.map} +1 -1
  57. package/public/assets/{store-iTiheuTD.js → store-C1E8jmNE.js} +4 -4
  58. package/public/assets/store-C1E8jmNE.js.map +1 -0
  59. package/public/assets/{tasks-e1y1PFfO.js → tasks-BcsmPi9_.js} +2 -2
  60. package/public/assets/{tasks-e1y1PFfO.js.map → tasks-BcsmPi9_.js.map} +1 -1
  61. package/public/assets/{teams-BUcxoGC_.js → teams-BQrV3eKI.js} +2 -2
  62. package/public/assets/{teams-BUcxoGC_.js.map → teams-BQrV3eKI.js.map} +1 -1
  63. package/public/assets/{terminal-viewer-impl-CmZjQOB2.js → terminal-viewer-impl-Dle04qpH.js} +2 -2
  64. package/public/assets/{terminal-viewer-impl-CmZjQOB2.js.map → terminal-viewer-impl-Dle04qpH.js.map} +1 -1
  65. package/public/assets/{work-queue-CXBQx43n.js → work-queue-BLhTLg4o.js} +2 -2
  66. package/public/assets/{work-queue-CXBQx43n.js.map → work-queue-BLhTLg4o.js.map} +1 -1
  67. package/public/assets/{workspaces-D2EfZh9a.js → workspaces-4NCNTmRQ.js} +3 -3
  68. package/public/assets/{workspaces-D2EfZh9a.js.map → workspaces-4NCNTmRQ.js.map} +1 -1
  69. package/public/index.html +3 -3
  70. package/scripts/orchestrator-spawn-smoke.ts +87 -67
  71. package/src/agent-ref.ts +8 -6
  72. package/src/automations/constants.ts +12 -0
  73. package/src/automations/cron.ts +129 -0
  74. package/src/automations/crud.ts +84 -0
  75. package/src/automations/dispatch.ts +275 -0
  76. package/src/automations/index.ts +5 -0
  77. package/src/automations/reconcile.ts +245 -0
  78. package/src/automations/storage.ts +181 -0
  79. package/src/automations/types.ts +16 -0
  80. package/src/automations/validation.ts +140 -0
  81. package/src/automations.ts +1 -1045
  82. package/src/db/activity.ts +1 -1
  83. package/src/db/agent-predicates.ts +9 -0
  84. package/src/db/agent-search.ts +2 -2
  85. package/src/db/agents.ts +13 -41
  86. package/src/db/artifacts.ts +8 -8
  87. package/src/db/channel-identity.ts +38 -0
  88. package/src/db/channels.ts +37 -61
  89. package/src/db/continuation-archives.ts +4 -4
  90. package/src/db/delivery.ts +75 -30
  91. package/src/db/inbox.ts +1 -1
  92. package/src/db/integrations.ts +4 -4
  93. package/src/db/issue-lifecycle.ts +6 -6
  94. package/src/db/mappers.ts +1 -2
  95. package/src/db/merge-lease.ts +3 -3
  96. package/src/db/message-claims.ts +16 -0
  97. package/src/db/message-queries.ts +8 -0
  98. package/src/db/message-reads.ts +157 -18
  99. package/src/db/messages.ts +23 -48
  100. package/src/db/notification-subscriptions.ts +2 -2
  101. package/src/db/orchestrators.ts +4 -4
  102. package/src/db/pair-maintenance.ts +9 -0
  103. package/src/db/pairs.ts +10 -18
  104. package/src/db/plans.ts +1 -1
  105. package/src/db/policy-runtime.ts +14 -0
  106. package/src/db/project-entries.ts +1 -1
  107. package/src/db/projects.ts +2 -2
  108. package/src/db/shared.ts +7 -0
  109. package/src/db/stats.ts +2 -2
  110. package/src/db/task-core.ts +18 -0
  111. package/src/db/task-maintenance.ts +24 -0
  112. package/src/db/tasks.ts +5 -17
  113. package/src/db/teams.ts +3 -3
  114. package/src/db/workspaces.ts +6 -6
  115. package/src/issue-tracker/github.ts +3 -3
  116. package/src/maintenance/constants.ts +35 -0
  117. package/src/maintenance/events.ts +32 -0
  118. package/src/maintenance/index.ts +7 -0
  119. package/src/maintenance/jobs.ts +340 -0
  120. package/src/maintenance/orphaned-sessions.ts +86 -0
  121. package/src/maintenance/scheduler.ts +231 -0
  122. package/src/maintenance/teams.ts +65 -0
  123. package/src/maintenance/types.ts +31 -0
  124. package/src/maintenance/workspaces.ts +251 -0
  125. package/src/maintenance.ts +1 -1099
  126. package/src/mcp/identity.ts +131 -0
  127. package/src/mcp/index.ts +208 -0
  128. package/src/mcp/tools-messaging.ts +459 -0
  129. package/src/mcp/tools-spawn.ts +145 -0
  130. package/src/mcp/tools-workspace.ts +227 -0
  131. package/src/mcp/types.ts +24 -0
  132. package/src/mcp/validation.ts +104 -0
  133. package/src/mcp.ts +1 -1319
  134. package/src/notification-router.ts +2 -2
  135. package/src/notification-rules.ts +2 -2
  136. package/src/project-ingest.ts +3 -3
  137. package/src/project-instructions.ts +1 -1
  138. package/src/provider-config-store.ts +3 -3
  139. package/src/provider-state.ts +1 -1
  140. package/src/reviewer-pipeline.ts +1 -1
  141. package/src/self-resume.ts +3 -7
  142. package/src/services/agent-action.ts +2 -2
  143. package/src/services/issue-activity.ts +0 -4
  144. package/src/services/issue-associations.ts +2 -2
  145. package/src/services/issue-lifecycle.ts +7 -7
  146. package/src/services/managed-running.ts +1 -1
  147. package/src/services/parity-harness.ts +3 -3
  148. package/src/services/plan-graph.ts +3 -3
  149. package/src/services/send-message.ts +16 -12
  150. package/src/services/shutdown-agent.ts +2 -2
  151. package/src/services/team-templates.ts +2 -2
  152. package/src/services/team.ts +2 -2
  153. package/src/settings/modules.ts +1 -1
  154. package/src/settings/registry.ts +3 -3
  155. package/src/spawn-command.ts +1 -1
  156. package/src/sse.ts +0 -4
  157. package/src/workspace-actions.ts +1 -1
  158. package/src/workspace-probe.ts +3 -7
  159. package/public/assets/MessageBubble-DRrxfjyT.js.map +0 -1
  160. package/public/assets/display-DzNvOu1j.js.map +0 -1
  161. package/public/assets/store-iTiheuTD.js.map +0 -1
@@ -0,0 +1,227 @@
1
+ import { getRepoSteward, getWorkspace, listWorkspaces, ownedIsolatedWorkspace, ValidationError } from "../db";
2
+ import { emitRelayEvent } from "../events";
3
+ import { McpAuthError, McpNotFoundError } from "../mcp-errors";
4
+ import { AUTO_MERGE_POLICIES } from "../workspace-merge";
5
+ import { describeWorkspacePhase, landReceipt, readyContract } from "../workspace-phase";
6
+ import { LAND_STRATEGIES, applyWorkspaceAction, waitForWorkspaceStatus, type WorkspaceAction } from "../workspace-actions";
7
+ import { optionalEnum } from "../validation";
8
+ import type { Command, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceRecord } from "../types";
9
+ import type { McpAuthContext, ToolDefinition } from "./types";
10
+ import { callerAgentId, hasScope } from "./identity";
11
+ import { optionalBoolean, optionalPositiveInt, optionalString } from "./validation";
12
+
13
+ export const WORKSPACE_TOOLS: ToolDefinition[] = [
14
+ {
15
+ name: "relay_workspace_status",
16
+ 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 your own workspace; pass workspaceId to target another.",
17
+ requiredScopes: ["agents:read", "agent:read", "agent:write"],
18
+ alwaysLoad: true,
19
+ inputSchema: {
20
+ type: "object",
21
+ properties: {
22
+ workspaceId: { type: "string", description: "Defaults to your own isolated workspace (resolved from your identity)." },
23
+ wait: { type: "boolean", description: "Block until the workspace status changes (the actionable signal), or until the timeout." },
24
+ timeoutSeconds: { type: "integer", minimum: 1, maximum: 600, description: "Max seconds to wait when wait:true (default 300, max 600). Returns early on any transition." },
25
+ },
26
+ additionalProperties: false,
27
+ },
28
+ },
29
+ {
30
+ name: "relay_workspace_ready",
31
+ 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.",
32
+ requiredScopes: ["agent:write"],
33
+ alwaysLoad: true,
34
+ inputSchema: {
35
+ type: "object",
36
+ properties: {
37
+ workspaceId: { type: "string", description: "Defaults to your own isolated workspace." },
38
+ detail: { type: "string", description: "Optional note recorded on the activity feed." },
39
+ },
40
+ additionalProperties: false,
41
+ },
42
+ },
43
+ {
44
+ name: "relay_workspace_deps",
45
+ 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.",
46
+ requiredScopes: ["agent:write"],
47
+ inputSchema: {
48
+ type: "object",
49
+ properties: {
50
+ workspaceId: { type: "string", description: "Defaults to your own isolated workspace." },
51
+ checkOnly: { type: "boolean", description: "Report staleness only; do not re-provision." },
52
+ detail: { type: "string" },
53
+ },
54
+ additionalProperties: false,
55
+ },
56
+ },
57
+ {
58
+ name: "relay_workspace_list",
59
+ 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.",
60
+ requiredScopes: ["agents:read", "agent:read", "agent:write"],
61
+ inputSchema: {
62
+ type: "object",
63
+ properties: {
64
+ all: { type: "boolean", description: "Admin/server only: list all workspaces, not just your own." },
65
+ ownerAgentId: { type: "string", description: "Admin/server + all:true: filter to one owner." },
66
+ repoRoot: { type: "string", description: "Filter to one repository root." },
67
+ },
68
+ additionalProperties: false,
69
+ },
70
+ },
71
+ {
72
+ name: "relay_workspace_claim",
73
+ 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.",
74
+ requiredScopes: ["agent:write"],
75
+ inputSchema: {
76
+ type: "object",
77
+ properties: {
78
+ workspaceId: { type: "string", description: "Defaults to your own isolated workspace." },
79
+ purpose: { type: "string", description: "Why you're claiming (shown in the activity feed)." },
80
+ detail: { type: "string" },
81
+ },
82
+ additionalProperties: false,
83
+ },
84
+ },
85
+ {
86
+ name: "relay_workspace_release",
87
+ description: "Release a steward claim taken with relay_workspace_claim, letting the auto-merge resume. Owner or assigned steward only.",
88
+ requiredScopes: ["agent:write"],
89
+ inputSchema: {
90
+ type: "object",
91
+ properties: {
92
+ workspaceId: { type: "string", description: "Defaults to your own isolated workspace." },
93
+ purpose: { type: "string" },
94
+ detail: { type: "string" },
95
+ },
96
+ additionalProperties: false,
97
+ },
98
+ },
99
+ {
100
+ name: "relay_workspace_land",
101
+ 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.",
102
+ requiredScopes: ["command:write"],
103
+ inputSchema: {
104
+ type: "object",
105
+ properties: {
106
+ workspaceId: { type: "string", description: "Defaults to your own isolated workspace." },
107
+ strategy: { type: "string", enum: ["pr", "rebase-ff", "auto"], description: "Merge strategy (default auto). Falls back to the instance landing.strategy config when unset." },
108
+ deleteBranch: { type: "boolean", description: "Delete the branch after a successful merge (default true)." },
109
+ prTitle: { type: "string" },
110
+ prBody: { type: "string" },
111
+ autoMerge: { type: "string", enum: AUTO_MERGE_POLICIES, description: "Auto-merge policy for PR-strategy lands. Defaults to 'on-green' when strategy resolves to 'pr'. Ignored for rebase-ff/auto strategies." },
112
+ reviewer: { type: "string", description: "GitHub login or team slug to request as a reviewer on the opened PR (pr strategy only)." },
113
+ detail: { type: "string" },
114
+ },
115
+ additionalProperties: false,
116
+ },
117
+ },
118
+ ];
119
+
120
+ function isWorkspaceAdmin(auth: McpAuthContext): boolean {
121
+ return auth.kind === "server" || hasScope(auth, "*");
122
+ }
123
+
124
+ function assertWorkspaceAccess(auth: McpAuthContext, ws: WorkspaceRecord, caller: string | undefined): void {
125
+ if (isWorkspaceAdmin(auth)) return;
126
+ if (!caller) throw new McpAuthError(`not authorized for workspace ${ws.id} (its owner or assigned steward only)`);
127
+ if (caller === ws.ownerAgentId) return;
128
+ if (caller === getRepoSteward(ws.repoRoot)?.stewardAgentId) return;
129
+ throw new McpAuthError(`not authorized for workspace ${ws.id} (its owner or assigned steward only)`);
130
+ }
131
+
132
+ function resolveWorkspaceForCaller(auth: McpAuthContext, args: Record<string, unknown>): WorkspaceRecord {
133
+ const explicitId = optionalString(args.workspaceId, "workspaceId", 240);
134
+ const caller = callerAgentId(auth);
135
+ if (explicitId) {
136
+ const ws = getWorkspace(explicitId);
137
+ if (!ws) throw new McpNotFoundError(`workspace ${explicitId} not found`);
138
+ assertWorkspaceAccess(auth, ws, caller);
139
+ return ws;
140
+ }
141
+ if (!caller) throw new ValidationError("workspaceId is required: no caller agent identity to resolve your own workspace");
142
+ const first = callerIsolatedWorkspace(auth);
143
+ if (!first) throw new McpNotFoundError("you don't own an isolated workspace; pass workspaceId");
144
+ return first;
145
+ }
146
+
147
+ export function callerIsolatedWorkspace(auth: McpAuthContext): WorkspaceRecord | undefined {
148
+ const caller = callerAgentId(auth);
149
+ if (!caller) return undefined;
150
+ return ownedIsolatedWorkspace(caller);
151
+ }
152
+
153
+ export async function relayWorkspaceStatus(auth: McpAuthContext, args: Record<string, unknown>): Promise<Record<string, unknown>> {
154
+ const ws = resolveWorkspaceForCaller(auth, args);
155
+ if (optionalBoolean(args.wait, "wait") !== true) {
156
+ return { workspace: ws, guidance: describeWorkspacePhase(ws), transitioned: false, timedOut: false };
157
+ }
158
+ const timeoutSeconds = optionalPositiveInt(args.timeoutSeconds, "timeoutSeconds");
159
+ const result = await waitForWorkspaceStatus(ws.id, timeoutSeconds ? { timeoutMs: timeoutSeconds * 1000 } : {});
160
+ if (!result.workspace) throw new McpNotFoundError(`workspace ${ws.id} not found`);
161
+ const landed = result.transitioned ? landReceipt(result.fromStatus, result.workspace) : null;
162
+ return {
163
+ workspace: result.workspace,
164
+ guidance: describeWorkspacePhase(result.workspace),
165
+ ...(landed ? { landed } : {}),
166
+ fromStatus: result.fromStatus,
167
+ transitioned: result.transitioned,
168
+ timedOut: result.timedOut,
169
+ };
170
+ }
171
+
172
+ export function relayWorkspaceList(auth: McpAuthContext, args: Record<string, unknown>): Record<string, unknown> {
173
+ const filter: { ownerAgentId?: string; repoRoot?: string } = {};
174
+ const repoRoot = optionalString(args.repoRoot, "repoRoot", 1024);
175
+ if (repoRoot) filter.repoRoot = repoRoot;
176
+ if (isWorkspaceAdmin(auth) && optionalBoolean(args.all, "all") === true) {
177
+ const owner = optionalString(args.ownerAgentId, "ownerAgentId", 240);
178
+ if (owner) filter.ownerAgentId = owner;
179
+ } else {
180
+ const caller = callerAgentId(auth);
181
+ if (!caller) throw new ValidationError("no caller agent identity to list your workspaces");
182
+ filter.ownerAgentId = caller;
183
+ }
184
+ const workspaces = listWorkspaces(filter);
185
+ return { workspaces, count: workspaces.length };
186
+ }
187
+
188
+ export function relayWorkspaceMutation(auth: McpAuthContext, action: WorkspaceAction, args: Record<string, unknown>): Record<string, unknown> {
189
+ const ws = resolveWorkspaceForCaller(auth, args);
190
+ const result = applyWorkspaceAction(ws, {
191
+ action,
192
+ agentId: callerAgentId(auth) ?? auth.actor,
193
+ detail: optionalString(args.detail, "detail", 4000),
194
+ strategy: action === "merge" ? (optionalEnum(args.strategy, "strategy", LAND_STRATEGIES) as WorkspaceMergeStrategy | undefined) : undefined,
195
+ deleteBranch: action === "merge" ? optionalBoolean(args.deleteBranch, "deleteBranch") : undefined,
196
+ prTitle: optionalString(args.prTitle, "prTitle", 240),
197
+ prBody: optionalString(args.prBody, "prBody", 8000),
198
+ autoMerge: action === "merge" ? (optionalEnum(args.autoMerge, "autoMerge", AUTO_MERGE_POLICIES) as WorkspaceAutoMergePolicy | undefined) : undefined,
199
+ reviewer: action === "merge" ? optionalString(args.reviewer, "reviewer", 240) : undefined,
200
+ purpose: optionalString(args.purpose, "purpose", 120),
201
+ checkOnly: action === "deps-refresh" ? optionalBoolean(args.checkOnly, "checkOnly") === true : undefined,
202
+ auditMetadata: { via: "mcp", actor: auth.actor },
203
+ });
204
+ if (!result.ok) {
205
+ if (result.httpStatus === 404) throw new McpNotFoundError(result.error);
206
+ if (result.httpStatus === 403) throw new McpAuthError(result.error);
207
+ throw new ValidationError(result.error);
208
+ }
209
+ if (result.command) emitCommand(result.command);
210
+ const payload: Record<string, unknown> = { workspace: result.workspace };
211
+ if (result.command) payload.command = result.command;
212
+ if (result.claim !== undefined) payload.claim = result.claim;
213
+ if (action === "ready") {
214
+ payload.contract = readyContract(result.workspace);
215
+ payload.guidance = describeWorkspacePhase(result.workspace);
216
+ }
217
+ return payload;
218
+ }
219
+
220
+ function emitCommand(command: Command): void {
221
+ emitRelayEvent({
222
+ type: command.status === "pending" ? "command.requested" : `command.${command.status}`,
223
+ source: command.source,
224
+ subject: command.id,
225
+ data: { command },
226
+ });
227
+ }
@@ -0,0 +1,24 @@
1
+ import type { IntegrationTokenConfig } from "../config";
2
+ import type { ComponentToken } from "../types";
3
+
4
+ export type JsonRpcId = string | number | null;
5
+
6
+ export type JsonRpcRequest = { jsonrpc?: "2.0"; id?: JsonRpcId; method?: string; params?: unknown };
7
+
8
+ export type McpAuthContext = {
9
+ actor: string;
10
+ kind: "component" | "integration" | "server";
11
+ scopes: string[];
12
+ component?: ComponentToken;
13
+ integration?: IntegrationTokenConfig;
14
+ };
15
+
16
+ export type ToolDefinition = {
17
+ name: string;
18
+ description: string;
19
+ requiredScopes: string[];
20
+ inputSchema: Record<string, unknown>;
21
+ // Emit `_meta: { "anthropic/alwaysLoad": true }` in tools/list so Claude Code
22
+ // loads this tool schema eagerly. Reserve this for the few verbs used every session.
23
+ alwaysLoad?: boolean;
24
+ };
@@ -0,0 +1,104 @@
1
+ import { optionalEnum } from "../validation";
2
+ import { ValidationError } from "../db";
3
+ import type { AttachmentRef } from "../types";
4
+ import { isRecord } from "agent-relay-sdk";
5
+
6
+ export function recordField(value: unknown, field: string): Record<string, unknown> {
7
+ if (!isRecord(value)) throw new ValidationError(`${field} must be an object`);
8
+ return value;
9
+ }
10
+
11
+ export function optionalRecord(value: unknown, field: string): Record<string, unknown> | undefined {
12
+ if (value === undefined || value === null) return undefined;
13
+ return recordField(value, field);
14
+ }
15
+
16
+ export function stringField(
17
+ value: unknown,
18
+ field: string,
19
+ opts: { required?: boolean; max?: number; maxBytes?: number } = {},
20
+ ): string {
21
+ if (value === undefined || value === null) {
22
+ if (opts.required) throw new ValidationError(`${field} required`);
23
+ return "";
24
+ }
25
+ if (typeof value !== "string") throw new ValidationError(`${field} must be a string`);
26
+ const trimmed = value.trim();
27
+ if (opts.required && !trimmed) throw new ValidationError(`${field} required`);
28
+ if (opts.max && trimmed.length > opts.max) throw new ValidationError(`${field} must be ${opts.max} characters or fewer`);
29
+ if (opts.maxBytes && encodedLength(trimmed) > opts.maxBytes) throw new ValidationError(`${field} exceeds ${opts.maxBytes} bytes`);
30
+ return trimmed;
31
+ }
32
+
33
+ export function optionalString(value: unknown, field: string, max: number): string | undefined {
34
+ if (value === undefined || value === null) return undefined;
35
+ const cleaned = stringField(value, field, { max });
36
+ return cleaned || undefined;
37
+ }
38
+
39
+ export function optionalBoolean(value: unknown, field: string): boolean | undefined {
40
+ if (value === undefined || value === null) return undefined;
41
+ if (typeof value !== "boolean") throw new ValidationError(`${field} must be a boolean`);
42
+ return value;
43
+ }
44
+
45
+ export function enumField<T extends readonly string[]>(value: unknown, field: string, valid: T): T[number] {
46
+ const cleaned = optionalEnum(value, field, valid);
47
+ if (!cleaned) throw new ValidationError(`${field} required`);
48
+ return cleaned;
49
+ }
50
+
51
+ export function optionalStringArray(value: unknown, field: string): string[] | undefined {
52
+ if (value === undefined || value === null) return undefined;
53
+ if (!Array.isArray(value)) throw new ValidationError(`${field} must be an array of strings`);
54
+ const cleaned = value.map((item, index) => stringField(item, `${field}[${index}]`, { required: true, max: 120 }));
55
+ if (cleaned.length > 50) throw new ValidationError(`${field} can contain at most 50 values`);
56
+ return [...new Set(cleaned)];
57
+ }
58
+
59
+ export function positiveId(value: unknown, field: string): number {
60
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) throw new ValidationError(`${field} must be a positive integer`);
61
+ return value;
62
+ }
63
+
64
+ export function positiveIdOrUndefined(value: unknown): number | undefined {
65
+ return typeof value === "number" && Number.isSafeInteger(value) && value > 0 ? value : undefined;
66
+ }
67
+
68
+ export function optionalPositiveInt(value: unknown, field: string): number | undefined {
69
+ if (value === undefined || value === null) return undefined;
70
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) throw new ValidationError(`${field} must be a positive integer`);
71
+ return value;
72
+ }
73
+
74
+ export function optionalNonNegativeInt(value: unknown, field: string): number | undefined {
75
+ if (value === undefined || value === null) return undefined;
76
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value < 0) throw new ValidationError(`${field} must be a non-negative integer`);
77
+ return value;
78
+ }
79
+
80
+ export function optionalFutureTimestamp(value: unknown, field: string): number | undefined {
81
+ const timestamp = optionalPositiveInt(value, field);
82
+ if (timestamp !== undefined && timestamp <= Date.now()) throw new ValidationError(`${field} must be a future unix timestamp in milliseconds`);
83
+ return timestamp;
84
+ }
85
+
86
+ export function optionalAttachments(value: unknown): AttachmentRef[] | undefined {
87
+ if (value === undefined || value === null) return undefined;
88
+ if (!Array.isArray(value)) throw new ValidationError("attachments must be an array");
89
+ return value.map((item, index) => {
90
+ const record = recordField(item, `attachments[${index}]`);
91
+ return {
92
+ artifactId: stringField(record.artifactId, `attachments[${index}].artifactId`, { required: true, max: 120 }),
93
+ kind: optionalString(record.kind, `attachments[${index}].kind`, 40) as AttachmentRef["kind"],
94
+ role: optionalString(record.role, `attachments[${index}].role`, 40) as AttachmentRef["role"],
95
+ title: optionalString(record.title, `attachments[${index}].title`, 240),
96
+ ref: optionalRecord(record.ref, `attachments[${index}].ref`) as AttachmentRef["ref"],
97
+ metadata: optionalRecord(record.metadata, `attachments[${index}].metadata`),
98
+ };
99
+ });
100
+ }
101
+
102
+ function encodedLength(value: string): number {
103
+ return new TextEncoder().encode(value).byteLength;
104
+ }