agent-relay-server 0.60.0 → 0.61.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 (172) hide show
  1. package/docs/openapi.json +516 -1
  2. package/package.json +3 -2
  3. package/public/assets/MessageBubble-CX63ZV4X.js +2 -0
  4. package/public/assets/MessageBubble-CX63ZV4X.js.map +1 -0
  5. package/public/assets/{PlanGraphPanel-CXix1C-H.js → PlanGraphPanel-Dby7wq9c.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-CXix1C-H.js.map → PlanGraphPanel-Dby7wq9c.js.map} +1 -1
  7. package/public/assets/{activity-CRafQ1Je.js → activity-2xPOScu_.js} +2 -2
  8. package/public/assets/{activity-CRafQ1Je.js.map → activity-2xPOScu_.js.map} +1 -1
  9. package/public/assets/{agent-profiles-DW4o5-ND.js → agent-profiles-BfGTTbeY.js} +2 -2
  10. package/public/assets/{agent-profiles-DW4o5-ND.js.map → agent-profiles-BfGTTbeY.js.map} +1 -1
  11. package/public/assets/agents-Bg3WOBRV.js +2 -0
  12. package/public/assets/{agents-Cbegv0Hm.js.map → agents-Bg3WOBRV.js.map} +1 -1
  13. package/public/assets/{analytics-DX6T7JX3.js → analytics-De0cEZGx.js} +3 -3
  14. package/public/assets/{analytics-DX6T7JX3.js.map → analytics-De0cEZGx.js.map} +1 -1
  15. package/public/assets/{automation-CzSBv4gg.js → automation-CHxhzq2t.js} +2 -2
  16. package/public/assets/{automation-CzSBv4gg.js.map → automation-CHxhzq2t.js.map} +1 -1
  17. package/public/assets/{badge-Cy6qO_Jz.js → badge-DhLyCPag.js} +2 -2
  18. package/public/assets/{badge-Cy6qO_Jz.js.map → badge-DhLyCPag.js.map} +1 -1
  19. package/public/assets/branch-state-badge-DSyWKluD.js +2 -0
  20. package/public/assets/{branch-state-badge-CRA50r81.js.map → branch-state-badge-DSyWKluD.js.map} +1 -1
  21. package/public/assets/{button-73wpRw65.js → button-K20wAZG1.js} +2 -2
  22. package/public/assets/{button-73wpRw65.js.map → button-K20wAZG1.js.map} +1 -1
  23. package/public/assets/card-CcZ9hLI7.js +2 -0
  24. package/public/assets/{card-BU6PvVHv.js.map → card-CcZ9hLI7.js.map} +1 -1
  25. package/public/assets/{channels-B1MQCPCT.js → channels-BG0Nib7y.js} +2 -2
  26. package/public/assets/{channels-B1MQCPCT.js.map → channels-BG0Nib7y.js.map} +1 -1
  27. package/public/assets/chat-CjP_crSl.js +2 -0
  28. package/public/assets/{chat-BqIaFYGJ.js.map → chat-CjP_crSl.js.map} +1 -1
  29. package/public/assets/{connectors-C9aFJIrh.js → connectors-DWh7MwV_.js} +2 -2
  30. package/public/assets/{connectors-C9aFJIrh.js.map → connectors-DWh7MwV_.js.map} +1 -1
  31. package/public/assets/{copy-button-HRopUwui.js → copy-button-eF3cKp4Q.js} +2 -2
  32. package/public/assets/{copy-button-HRopUwui.js.map → copy-button-eF3cKp4Q.js.map} +1 -1
  33. package/public/assets/display-DzNvOu1j.js +3 -0
  34. package/public/assets/{display-CketawEF.js.map → display-DzNvOu1j.js.map} +1 -1
  35. package/public/assets/{dist-Kkpnjx_M.js → dist-1dhfdhm9.js} +2 -2
  36. package/public/assets/{dist-Kkpnjx_M.js.map → dist-1dhfdhm9.js.map} +1 -1
  37. package/public/assets/dist-BLBF7qqr.js +2 -0
  38. package/public/assets/{dist-De2oguBm.js.map → dist-BLBF7qqr.js.map} +1 -1
  39. package/public/assets/{dist-T7u7IZGJ.js → dist-BrQsqBbc.js} +2 -2
  40. package/public/assets/{dist-T7u7IZGJ.js.map → dist-BrQsqBbc.js.map} +1 -1
  41. package/public/assets/{dist-Cko2KPW8.js → dist-COQuunEv.js} +2 -2
  42. package/public/assets/{dist-Cko2KPW8.js.map → dist-COQuunEv.js.map} +1 -1
  43. package/public/assets/{dist-CLHMeXYi.js → dist-Ci5Y37nj.js} +2 -2
  44. package/public/assets/{dist-CLHMeXYi.js.map → dist-Ci5Y37nj.js.map} +1 -1
  45. package/public/assets/{dist-Ci1MM51P.js → dist-CoROafNg.js} +2 -2
  46. package/public/assets/{dist-Ci1MM51P.js.map → dist-CoROafNg.js.map} +1 -1
  47. package/public/assets/{dist-CC5f7oYw.js → dist-I-MFUv51.js} +2 -2
  48. package/public/assets/{dist-CC5f7oYw.js.map → dist-I-MFUv51.js.map} +1 -1
  49. package/public/assets/{dist-DFLciw0h.js → dist-UQGKwqZH.js} +2 -2
  50. package/public/assets/{dist-DFLciw0h.js.map → dist-UQGKwqZH.js.map} +1 -1
  51. package/public/assets/dist-YpHfnnIA.js +2 -0
  52. package/public/assets/{dist-DUoZnlB2.js.map → dist-YpHfnnIA.js.map} +1 -1
  53. package/public/assets/dist-qd198ib9.js +2 -0
  54. package/public/assets/{dist-D41tzJYg.js.map → dist-qd198ib9.js.map} +1 -1
  55. package/public/assets/{dist-DmN94rc8.js → dist-yGOq6sB3.js} +2 -2
  56. package/public/assets/{dist-DmN94rc8.js.map → dist-yGOq6sB3.js.map} +1 -1
  57. package/public/assets/{es2015-Df08Opjx.js → es2015-CRxz9lOj.js} +2 -2
  58. package/public/assets/{es2015-Df08Opjx.js.map → es2015-CRxz9lOj.js.map} +1 -1
  59. package/public/assets/{formatted-body-impl-DOXH7oDF.js → formatted-body-impl-u0W_hWYA.js} +2 -2
  60. package/public/assets/{formatted-body-impl-DOXH7oDF.js.map → formatted-body-impl-u0W_hWYA.js.map} +1 -1
  61. package/public/assets/index-BwfVOzUG.js +21 -0
  62. package/public/assets/{index-br_Y6Cvt.js.map → index-BwfVOzUG.js.map} +1 -1
  63. package/public/assets/index-Dn_GDXbi.css +2 -0
  64. package/public/assets/{input-B2EDz-BE.js → input-BHYNpXAG.js} +2 -2
  65. package/public/assets/{input-B2EDz-BE.js.map → input-BHYNpXAG.js.map} +1 -1
  66. package/public/assets/{insights-B1edq5lk.js → insights-CAF9Hi5D.js} +2 -2
  67. package/public/assets/{insights-B1edq5lk.js.map → insights-CAF9Hi5D.js.map} +1 -1
  68. package/public/assets/{integrations-Cm4fjp7_.js → integrations-CG9qyHUf.js} +2 -2
  69. package/public/assets/{integrations-Cm4fjp7_.js.map → integrations-CG9qyHUf.js.map} +1 -1
  70. package/public/assets/{lib-BZrjIp-o.js → lib-Df4aph5u.js} +2 -2
  71. package/public/assets/{lib-BZrjIp-o.js.map → lib-Df4aph5u.js.map} +1 -1
  72. package/public/assets/lucide-react-L2UxFDic.js +9 -0
  73. package/public/assets/lucide-react-L2UxFDic.js.map +1 -0
  74. package/public/assets/{maintenance-D6zvw0fU.js → maintenance-DgHA35ve.js} +2 -2
  75. package/public/assets/{maintenance-D6zvw0fU.js.map → maintenance-DgHA35ve.js.map} +1 -1
  76. package/public/assets/{managed-agents-CzfEznM1.js → managed-agents-Rk6ghKR9.js} +2 -2
  77. package/public/assets/{managed-agents-CzfEznM1.js.map → managed-agents-Rk6ghKR9.js.map} +1 -1
  78. package/public/assets/{markdown-preview-impl-DZlCaTHQ.js → markdown-preview-impl-NWMtJdYz.js} +2 -2
  79. package/public/assets/{markdown-preview-impl-DZlCaTHQ.js.map → markdown-preview-impl-NWMtJdYz.js.map} +1 -1
  80. package/public/assets/memory-DamwWa9A.js +2 -0
  81. package/public/assets/{memory-CUUAzJDc.js.map → memory-DamwWa9A.js.map} +1 -1
  82. package/public/assets/{messages-6QZH46LG.js → messages--NU-YIL6.js} +2 -2
  83. package/public/assets/{messages-6QZH46LG.js.map → messages--NU-YIL6.js.map} +1 -1
  84. package/public/assets/{orchestrators-CrcqkPuc.js → orchestrators-DQnIn6qx.js} +2 -2
  85. package/public/assets/{orchestrators-CrcqkPuc.js.map → orchestrators-DQnIn6qx.js.map} +1 -1
  86. package/public/assets/{overview-418xXZO_.js → overview-DCmNgXDf.js} +2 -2
  87. package/public/assets/{overview-418xXZO_.js.map → overview-DCmNgXDf.js.map} +1 -1
  88. package/public/assets/{pairs-CnedATgT.js → pairs-DNELeC9P.js} +2 -2
  89. package/public/assets/{pairs-CnedATgT.js.map → pairs-DNELeC9P.js.map} +1 -1
  90. package/public/assets/{react-dom-WI6TjVe7.js → react-dom-CrP_AtcL.js} +2 -2
  91. package/public/assets/{react-dom-WI6TjVe7.js.map → react-dom-CrP_AtcL.js.map} +1 -1
  92. package/public/assets/{security-DS13pbag.js → security-Dd3Kh0oC.js} +2 -2
  93. package/public/assets/{security-DS13pbag.js.map → security-Dd3Kh0oC.js.map} +1 -1
  94. package/public/assets/{select-BOprGZDS.js → select-DQbeJZX0.js} +2 -2
  95. package/public/assets/{select-BOprGZDS.js.map → select-DQbeJZX0.js.map} +1 -1
  96. package/public/assets/settings-BMAJrBIH.js +4 -0
  97. package/public/assets/settings-BMAJrBIH.js.map +1 -0
  98. package/public/assets/store-B8xP6z6T.js +9 -0
  99. package/public/assets/store-B8xP6z6T.js.map +1 -0
  100. package/public/assets/tasks-BYR4L9GP.js +2 -0
  101. package/public/assets/{tasks-B-4oCPuv.js.map → tasks-BYR4L9GP.js.map} +1 -1
  102. package/public/assets/teams-CoTifrq_.js +2 -0
  103. package/public/assets/{teams-sCQAs_QA.js.map → teams-CoTifrq_.js.map} +1 -1
  104. package/public/assets/{terminal-viewer-impl-BhqS2XpS.js → terminal-viewer-impl-B1YzfpE6.js} +3 -3
  105. package/public/assets/{terminal-viewer-impl-BhqS2XpS.js.map → terminal-viewer-impl-B1YzfpE6.js.map} +1 -1
  106. package/public/assets/{use-keyboard-viewport-fOL_3RHB.js → use-keyboard-viewport-DrRH_QzG.js} +2 -2
  107. package/public/assets/{use-keyboard-viewport-fOL_3RHB.js.map → use-keyboard-viewport-DrRH_QzG.js.map} +1 -1
  108. package/public/assets/{work-queue-BDhjlwUH.js → work-queue-x57vlgyZ.js} +2 -2
  109. package/public/assets/{work-queue-BDhjlwUH.js.map → work-queue-x57vlgyZ.js.map} +1 -1
  110. package/public/assets/{workspaces-6I-C69nf.js → workspaces-CYm5bLg9.js} +3 -3
  111. package/public/assets/{workspaces-6I-C69nf.js.map → workspaces-CYm5bLg9.js.map} +1 -1
  112. package/public/index.html +22 -22
  113. package/runner/src/config.ts +66 -2
  114. package/src/automations.ts +3 -4
  115. package/src/cli/index.ts +11 -0
  116. package/src/cli/merges.ts +89 -0
  117. package/src/config-store.ts +5 -6
  118. package/src/config.ts +4 -0
  119. package/src/connectors.ts +5 -0
  120. package/src/db/merge-lease.ts +95 -1
  121. package/src/db/migrations.ts +21 -0
  122. package/src/db/schema.ts +1 -1
  123. package/src/index.ts +5 -2
  124. package/src/maintenance.ts +8 -8
  125. package/src/mcp-merge-tools.ts +66 -0
  126. package/src/mcp-provider-catalog.ts +9 -0
  127. package/src/mcp.ts +7 -8
  128. package/src/merge-pause-maintenance.ts +13 -0
  129. package/src/provider-catalog-store.ts +33 -4
  130. package/src/provider-config-store.ts +216 -0
  131. package/src/recipe-runner.ts +2 -1
  132. package/src/recipe-validator.ts +4 -3
  133. package/src/routes/agent-sessions.ts +4 -2
  134. package/src/routes/agents-spawn.ts +2 -2
  135. package/src/routes/commands.ts +20 -0
  136. package/src/routes/index.ts +19 -2
  137. package/src/routes/insights.ts +4 -3
  138. package/src/routes/merges.ts +95 -0
  139. package/src/routes/provider-config.ts +89 -41
  140. package/src/routes/settings.ts +127 -0
  141. package/src/routes/steward.ts +5 -5
  142. package/src/routes/tokens.ts +32 -1
  143. package/src/security.ts +36 -37
  144. package/src/services/merge-pause.ts +37 -0
  145. package/src/services/spawn-agent.ts +10 -8
  146. package/src/settings/modules.ts +149 -0
  147. package/src/settings/registry.ts +397 -0
  148. package/src/spawn-command.ts +5 -1
  149. package/src/team-template-config.ts +2 -2
  150. package/src/token-db.ts +20 -0
  151. package/src/workspace-pr-completion.ts +70 -0
  152. package/public/assets/MessageBubble-BrEKymoV.js +0 -2
  153. package/public/assets/MessageBubble-BrEKymoV.js.map +0 -1
  154. package/public/assets/agents-Cbegv0Hm.js +0 -2
  155. package/public/assets/branch-state-badge-CRA50r81.js +0 -2
  156. package/public/assets/card-BU6PvVHv.js +0 -2
  157. package/public/assets/chat-BqIaFYGJ.js +0 -2
  158. package/public/assets/display-CketawEF.js +0 -3
  159. package/public/assets/dist-D41tzJYg.js +0 -2
  160. package/public/assets/dist-DUoZnlB2.js +0 -2
  161. package/public/assets/dist-De2oguBm.js +0 -2
  162. package/public/assets/index-DaNOQJci.css +0 -2
  163. package/public/assets/index-br_Y6Cvt.js +0 -21
  164. package/public/assets/lucide-react-H_-8G79A.js +0 -9
  165. package/public/assets/lucide-react-H_-8G79A.js.map +0 -1
  166. package/public/assets/memory-CUUAzJDc.js +0 -2
  167. package/public/assets/settings-BgInn_f3.js +0 -10
  168. package/public/assets/settings-BgInn_f3.js.map +0 -1
  169. package/public/assets/store-DHO_a8BD.js +0 -9
  170. package/public/assets/store-DHO_a8BD.js.map +0 -1
  171. package/public/assets/tasks-B-4oCPuv.js +0 -2
  172. package/public/assets/teams-sCQAs_QA.js +0 -2
@@ -0,0 +1,66 @@
1
+ import { ValidationError } from "./db";
2
+ import { McpNotFoundError } from "./mcp-errors";
3
+ import { authContextFromMcp } from "./services/auth-context";
4
+ import { listAutoMergePauses, pauseAutoMerge, renewAutoMergePause, resumeAutoMerge } from "./services/merge-pause";
5
+ import { cleanString } from "./validation";
6
+
7
+ export const MERGE_TOOLS = [
8
+ { name: "relay_merge_pauses", description: "List active auto-merge pauses. These are repo/base-level holds that make the workspace auto-merge job yield during release or build critical sections.", requiredScopes: ["command:read"], inputSchema: { type: "object", properties: {}, additionalProperties: false } },
9
+ {
10
+ name: "relay_merge_pause",
11
+ description: "Pause workspace auto-merge for one repo/base pair with a TTL. Existing active pause for the same target is returned as a conflict instead of replacing its holder.",
12
+ requiredScopes: ["command:write"],
13
+ inputSchema: {
14
+ type: "object",
15
+ properties: {
16
+ repoRoot: { type: "string", description: "Repository root whose auto-merge should pause." },
17
+ baseRef: { type: "string", description: "Base branch/ref to pause (default main)." },
18
+ ttlMs: { type: "integer", minimum: 1, description: "Pause TTL in milliseconds. Defaults to the server's merge-pause TTL." },
19
+ reason: { type: "string" },
20
+ holder: { type: "string", description: "Optional human-readable holder. Defaults to the authenticated caller." },
21
+ },
22
+ required: ["repoRoot"],
23
+ additionalProperties: false,
24
+ },
25
+ },
26
+ { name: "relay_merge_renew", description: "Renew an active auto-merge pause by id, extending its TTL from now.", requiredScopes: ["command:write"], inputSchema: { type: "object", properties: { id: { type: "string" }, ttlMs: { type: "integer", minimum: 1 } }, required: ["id"], additionalProperties: false } },
27
+ { name: "relay_merge_resume", description: "Release an auto-merge pause by id, or by repoRoot/baseRef.", requiredScopes: ["command:write"], inputSchema: { type: "object", properties: { id: { type: "string" }, repoRoot: { type: "string" }, baseRef: { type: "string", description: "Base branch/ref (default main when repoRoot is used)." } }, additionalProperties: false } },
28
+ ] satisfies Array<{ name: string; description: string; requiredScopes: string[]; inputSchema: Record<string, unknown> }>;
29
+
30
+ type McpAuthLike = Parameters<typeof authContextFromMcp>[0];
31
+
32
+ export function relayMergeTool(auth: McpAuthLike, name: string, args: Record<string, unknown>): Record<string, unknown> {
33
+ const ctx = authContextFromMcp(auth);
34
+ if (name === "relay_merge_pauses") {
35
+ const pauses = listAutoMergePauses();
36
+ return { pauses, count: pauses.length };
37
+ }
38
+ if (name === "relay_merge_pause") {
39
+ const result = pauseAutoMerge({
40
+ repoRoot: cleanString(args.repoRoot, "repoRoot", { required: true, max: 1024 })!,
41
+ baseRef: cleanString(args.baseRef, "baseRef", { max: 240 }),
42
+ holder: cleanString(args.holder, "holder", { max: 240 }) ?? ctx.callerAgentId ?? ctx.actor.id,
43
+ reason: cleanString(args.reason, "reason", { max: 1000 }),
44
+ ttlMs: optionalPositiveInt(args.ttlMs, "ttlMs"),
45
+ });
46
+ return result.ok ? { ok: true, pause: result.pause } : { ok: false, error: "merge target already paused", pause: result.pause };
47
+ }
48
+ if (name === "relay_merge_renew") {
49
+ const pause = renewAutoMergePause(cleanString(args.id, "id", { required: true, max: 120 })!, optionalPositiveInt(args.ttlMs, "ttlMs"));
50
+ if (!pause) throw new McpNotFoundError("merge pause not found or expired");
51
+ return { pause };
52
+ }
53
+ if (name === "relay_merge_resume") {
54
+ const id = cleanString(args.id, "id", { max: 120 });
55
+ const repoRoot = cleanString(args.repoRoot, "repoRoot", { max: 1024 });
56
+ if (!id && !repoRoot) throw new ValidationError("id or repoRoot required");
57
+ return { released: resumeAutoMerge({ id, repoRoot, baseRef: cleanString(args.baseRef, "baseRef", { max: 240 }) }) };
58
+ }
59
+ throw new ValidationError(`unknown tool: ${name}`);
60
+ }
61
+
62
+ function optionalPositiveInt(value: unknown, field: string): number | undefined {
63
+ if (value === undefined || value === null) return undefined;
64
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) throw new ValidationError(`${field} must be a positive integer`);
65
+ return value;
66
+ }
@@ -0,0 +1,9 @@
1
+ import { VALID_EFFORTS, isRecord } from "agent-relay-sdk";
2
+ import { effectiveProviderEffortLevels } from "./provider-catalog-store";
3
+
4
+ export function providerCatalogToolInputSchema(name: string, inputSchema: Record<string, unknown>): Record<string, unknown> {
5
+ if (name !== "relay_spawn_agent") return inputSchema;
6
+ const efforts = effectiveProviderEffortLevels();
7
+ const properties = isRecord(inputSchema.properties) ? inputSchema.properties : {};
8
+ return { ...inputSchema, properties: { ...properties, effort: { type: "string", enum: efforts.length ? efforts : VALID_EFFORTS } } };
9
+ }
package/src/mcp.ts CHANGED
@@ -50,9 +50,9 @@ import { PLAN_TOOLS, relayPlanTool } from "./mcp-plan-tools";
50
50
  import { TEAM_TOOLS, relayTeamTool } from "./mcp-team-tools";
51
51
  import { BLACKBOARD_TOOLS, relayBlackboardTool } from "./mcp-blackboard-tools";
52
52
  import { PROJECT_TOOLS, relayProjectTool } from "./mcp-project-tools";
53
- import { NOTIFICATION_SUBSCRIPTION_TOOLS, relayNotificationSubscriptionTool } from "./mcp-notification-subscription-tools";
53
+ import { NOTIFICATION_SUBSCRIPTION_TOOLS, relayNotificationSubscriptionTool } from "./mcp-notification-subscription-tools"; import { MERGE_TOOLS, relayMergeTool } from "./mcp-merge-tools";
54
54
  import { PROJECT_INSTRUCTION_POLICY_NAMES, parseProjectInstructions } from "./project-instructions";
55
- import { AGENT_ACTION_TOOL, relayAgentAction } from "./mcp-agent-action";
55
+ import { AGENT_ACTION_TOOL, relayAgentAction } from "./mcp-agent-action"; import { providerCatalogToolInputSchema } from "./mcp-provider-catalog";
56
56
  import type { ActivityKind, AgentLifecycle, ArtifactKind, ArtifactSensitivity, AttachmentRef, Command, SendMessageInput, Message, SpawnApprovalMode, SpawnProvider, WorkspaceAutoMergePolicy, WorkspaceMergeStrategy, WorkspaceMode, WorkspaceRecord } from "./types";
57
57
  import { LAND_STRATEGIES, applyWorkspaceAction, waitForWorkspaceStatus, type WorkspaceAction } from "./workspace-actions";
58
58
  import { AUTO_MERGE_POLICIES } from "./workspace-merge";
@@ -460,7 +460,7 @@ const TOOLS: ToolDefinition[] = [
460
460
  additionalProperties: false,
461
461
  },
462
462
  },
463
- ...TEAM_TOOLS,
463
+ ...MERGE_TOOLS, ...TEAM_TOOLS,
464
464
  ...PLAN_TOOLS,
465
465
  ...BLACKBOARD_TOOLS,
466
466
  ...PROJECT_TOOLS,
@@ -577,7 +577,7 @@ async function callTool(auth: McpAuthContext, params: unknown): Promise<Record<s
577
577
  else if (name === "relay_workspace_claim") result = relayWorkspaceMutation(auth, "claim", args);
578
578
  else if (name === "relay_workspace_release") result = relayWorkspaceMutation(auth, "release-claim", args);
579
579
  else if (name === "relay_workspace_land") result = relayWorkspaceMutation(auth, "merge", args);
580
- else if (name.startsWith("relay_team_")) result = await relayTeamTool(auth, name, args);
580
+ else if (name.startsWith("relay_merge_")) result = relayMergeTool(auth, name, args); else if (name.startsWith("relay_team_")) result = await relayTeamTool(auth, name, args);
581
581
  else if (name.startsWith("relay_plan_")) result = relayPlanTool(auth, name, args);
582
582
  else if (name.startsWith("relay_blackboard_")) result = relayBlackboardTool(auth, name, args);
583
583
  else if (name.startsWith("relay_project_")) result = relayProjectTool(auth, name, args);
@@ -869,7 +869,7 @@ async function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknow
869
869
  orchestratorId: optionalString(args.orchestratorId, "orchestratorId", 200),
870
870
  cwd: optionalString(args.cwd, "cwd", 500),
871
871
  model: optionalString(args.model, "model", 120),
872
- effort: optionalEnum(args.effort, "effort", VALID_EFFORTS) as ProviderEffort | undefined,
872
+ effort: optionalString(args.effort, "effort", 120) as ProviderEffort | undefined,
873
873
  approvalMode: optionalEnum(args.approvalMode, "approvalMode", APPROVAL_MODES) as SpawnApprovalMode | undefined,
874
874
  workspaceMode: optionalEnum(args.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES) as WorkspaceMode | undefined,
875
875
  lifecycle: (optionalEnum(args.lifecycle, "lifecycle", VALID_AGENT_LIFECYCLES) as AgentLifecycle | undefined) ?? "persistent",
@@ -1043,7 +1043,6 @@ function relayWorkspaceMutation(auth: McpAuthContext, action: WorkspaceAction, a
1043
1043
  return payload;
1044
1044
  }
1045
1045
 
1046
-
1047
1046
  function payloadWithAttachments(
1048
1047
  payload: Record<string, unknown> | undefined,
1049
1048
  attachments: AttachmentRef[] | undefined,
@@ -1141,10 +1140,10 @@ function sniffMediaType(bytes: Uint8Array, hinted?: string, filename?: string):
1141
1140
  function visibleTools(auth: McpAuthContext): Array<Record<string, unknown>> {
1142
1141
  return TOOLS
1143
1142
  .filter((tool) => hasAnyScope(auth, tool.requiredScopes))
1144
- .map(({ name, description, inputSchema, alwaysLoad }) => ({
1143
+ .map(({ name, description, alwaysLoad, ...tool }) => ({
1145
1144
  name,
1146
1145
  description,
1147
- inputSchema,
1146
+ inputSchema: providerCatalogToolInputSchema(name, tool.inputSchema),
1148
1147
  ...(alwaysLoad ? { _meta: { "anthropic/alwaysLoad": true } } : {}),
1149
1148
  }));
1150
1149
  }
@@ -0,0 +1,13 @@
1
+ import { REAP_INTERVAL_MS } from "./config";
2
+ import { releaseExpiredMergePauses } from "./db";
3
+
4
+ export const MERGE_PAUSE_MAINTENANCE_JOB = {
5
+ id: "merge-pause-expiration",
6
+ title: "Merge pause expiration",
7
+ description: "Release expired repo/base auto-merge pauses.",
8
+ intervalMs: REAP_INTERVAL_MS,
9
+ runOnStart: true,
10
+ handler() {
11
+ return { releasedMergePauseIds: releaseExpiredMergePauses() };
12
+ },
13
+ };
@@ -13,6 +13,7 @@ interface ProviderModelOverrideRow {
13
13
  alias: string;
14
14
  entry: string;
15
15
  deleted: number;
16
+ disabled: number;
16
17
  updated_at: number;
17
18
  updated_by?: string | null;
18
19
  }
@@ -22,6 +23,7 @@ interface ProviderModelOverride {
22
23
  alias: string;
23
24
  entry: ProviderModelCatalogEntry;
24
25
  deleted: boolean;
26
+ disabled: boolean;
25
27
  updatedAt: number;
26
28
  updatedBy?: string;
27
29
  }
@@ -31,6 +33,7 @@ interface ProviderModelOverrideInput {
31
33
  alias: string;
32
34
  entry: ProviderModelCatalogEntry;
33
35
  deleted?: boolean;
36
+ disabled?: boolean;
34
37
  updatedBy?: string;
35
38
  }
36
39
 
@@ -45,11 +48,12 @@ export function upsertProviderModelOverride(input: ProviderModelOverrideInput):
45
48
  const normalized = normalizeOverrideInput(input);
46
49
  const now = Date.now();
47
50
  getDb().query(`
48
- INSERT INTO provider_model_overrides (provider, alias, entry, deleted, updated_at, updated_by)
49
- VALUES (?, ?, ?, ?, ?, ?)
51
+ INSERT INTO provider_model_overrides (provider, alias, entry, deleted, disabled, updated_at, updated_by)
52
+ VALUES (?, ?, ?, ?, ?, ?, ?)
50
53
  ON CONFLICT(provider, alias) DO UPDATE SET
51
54
  entry = excluded.entry,
52
55
  deleted = excluded.deleted,
56
+ disabled = excluded.disabled,
53
57
  updated_at = excluded.updated_at,
54
58
  updated_by = excluded.updated_by
55
59
  `).run(
@@ -57,6 +61,7 @@ export function upsertProviderModelOverride(input: ProviderModelOverrideInput):
57
61
  normalized.alias,
58
62
  JSON.stringify(normalized.entry),
59
63
  normalized.deleted ? 1 : 0,
64
+ normalized.disabled ? 1 : 0,
60
65
  now,
61
66
  normalized.updatedBy ?? null,
62
67
  );
@@ -66,6 +71,7 @@ export function upsertProviderModelOverride(input: ProviderModelOverrideInput):
66
71
  alias: normalized.alias,
67
72
  entry: normalized.entry,
68
73
  deleted: Boolean(normalized.deleted),
74
+ disabled: Boolean(normalized.disabled),
69
75
  updatedAt: now,
70
76
  updatedBy: normalized.updatedBy,
71
77
  };
@@ -83,10 +89,26 @@ export function removeProviderModelOverride(provider: SpawnProvider, alias: stri
83
89
  });
84
90
  }
85
91
 
92
+ export function setProviderModelDisabled(provider: SpawnProvider, alias: string, disabled: boolean, updatedBy?: string): ProviderModelOverride {
93
+ const existing = findModelEntry(provider, alias);
94
+ if (!existing) throw new ValidationError(`unknown ${provider} model: ${alias}`);
95
+ return upsertProviderModelOverride({
96
+ provider,
97
+ alias,
98
+ entry: existing,
99
+ disabled,
100
+ updatedBy,
101
+ });
102
+ }
103
+
86
104
  export function effectiveProviderCatalogList(): ProviderCatalogEntry[] {
87
105
  return mergeProviderCatalog(providerCatalogList(), listProviderModelOverrides());
88
106
  }
89
107
 
108
+ export function effectiveProviderEffortLevels(): string[] {
109
+ return [...new Set(effectiveProviderCatalogList().flatMap((provider) => provider.models.flatMap((model) => model.efforts)))].sort();
110
+ }
111
+
90
112
  export function mergeProviderCatalog(
91
113
  baseCatalog: ProviderCatalogEntry[],
92
114
  overrides: ProviderModelOverride[],
@@ -102,7 +124,11 @@ export function mergeProviderCatalog(
102
124
  if (existingIndex >= 0) providerEntry.models.splice(existingIndex, 1);
103
125
  continue;
104
126
  }
105
- const nextEntry = cloneModelEntry({ ...override.entry, alias: override.alias });
127
+ const nextEntry = cloneModelEntry({
128
+ ...override.entry,
129
+ alias: override.alias,
130
+ ...(override.disabled ? { disabled: true } : { disabled: undefined }),
131
+ });
106
132
  if (existingIndex >= 0) providerEntry.models[existingIndex] = nextEntry;
107
133
  else providerEntry.models.push(nextEntry);
108
134
  }
@@ -118,10 +144,12 @@ function normalizeOverrideInput(input: ProviderModelOverrideInput): ProviderMode
118
144
  if (!input.entry.label?.trim()) throw new ValidationError("model label is required");
119
145
  if (!input.entry.providerModel?.trim()) throw new ValidationError("provider model is required");
120
146
  if (!Array.isArray(input.entry.efforts)) throw new ValidationError("model efforts must be an array");
147
+ const entry = cloneModelEntry({ ...input.entry, alias });
148
+ delete entry.disabled;
121
149
  return {
122
150
  ...input,
123
151
  alias,
124
- entry: cloneModelEntry({ ...input.entry, alias }),
152
+ entry,
125
153
  updatedBy: input.updatedBy?.trim() || undefined,
126
154
  };
127
155
  }
@@ -134,6 +162,7 @@ function rowToOverride(row: ProviderModelOverrideRow): ProviderModelOverride | u
134
162
  alias: row.alias,
135
163
  entry: cloneModelEntry({ ...parsed, alias: row.alias }),
136
164
  deleted: row.deleted === 1,
165
+ disabled: row.disabled === 1,
137
166
  updatedAt: row.updated_at,
138
167
  updatedBy: row.updated_by ?? undefined,
139
168
  };
@@ -0,0 +1,216 @@
1
+ import { existsSync, readdirSync } from "node:fs";
2
+ import { hostname } from "node:os";
3
+ import { join } from "node:path";
4
+ import { ValidationError, listOrchestrators } from "./db";
5
+ import { getConfig, listConfig, setConfig } from "./config-store";
6
+ import { cleanString, cleanStringArray, optionalEnum } from "./validation";
7
+ import { emitConfigChanged } from "./sse";
8
+ import {
9
+ PROVIDER_CONFIG_NAMESPACE,
10
+ validateSettingValue,
11
+ } from "./settings/registry";
12
+ import {
13
+ getProviderSettingsDeclaration,
14
+ listProviderSettingsDeclarations,
15
+ providerConfigDefaults,
16
+ registerDeclaredProviderConfigSetting,
17
+ } from "./settings/modules";
18
+ import { defaultProviderConfig, loadProviderConfig as loadLocalProviderConfig } from "../runner/src/config";
19
+ import { SPAWN_PROVIDERS, isRecord, type ConfigEntry, type SpawnProvider } from "agent-relay-sdk";
20
+ import type { ProviderConfig } from "../runner/src/adapter";
21
+
22
+ export const VALID_PROVIDER_CONFIGS = SPAWN_PROVIDERS;
23
+
24
+ type ProviderConfigProvider = string;
25
+ type ProviderConfigEntry = ConfigEntry<ProviderConfig>;
26
+
27
+ export function defaultProviderConfigHost(): string {
28
+ return hostname();
29
+ }
30
+
31
+ export function providerConfigKey(host: string, provider: string): string {
32
+ return `${normalizeProviderConfigHost(host)}/${normalizeProviderConfigProvider(provider)}`;
33
+ }
34
+
35
+ export function parseProviderConfigKey(key: string): { host: string; provider: ProviderConfigProvider } | null {
36
+ const slash = key.lastIndexOf("/");
37
+ if (slash <= 0 || slash === key.length - 1) return null;
38
+ const host = cleanString(key.slice(0, slash), "host", { required: true, max: 120 })!;
39
+ const provider = cleanString(key.slice(slash + 1), "provider", { required: true, max: 80 })!;
40
+ return getProviderSettingsDeclaration(provider) ? { host, provider } : null;
41
+ }
42
+
43
+ export function ensureProviderConfigDescriptors(extraKeys: string[] = []): void {
44
+ const keys = new Set<string>();
45
+ const providerDeclarations = listProviderSettingsDeclarations();
46
+ for (const entry of listConfig(PROVIDER_CONFIG_NAMESPACE)) keys.add(entry.key);
47
+ for (const orchestrator of listOrchestrators()) {
48
+ if (!orchestrator.hostname) continue;
49
+ for (const declaration of providerDeclarations) keys.add(providerConfigKey(orchestrator.hostname, declaration.provider));
50
+ }
51
+ for (const declaration of providerDeclarations) keys.add(providerConfigKey(defaultProviderConfigHost(), declaration.provider));
52
+ for (const key of extraKeys) keys.add(key);
53
+
54
+ for (const key of keys) {
55
+ const parsed = parseProviderConfigKey(key);
56
+ if (!parsed) continue;
57
+ registerDeclaredProviderConfigSetting({
58
+ host: parsed.host,
59
+ provider: parsed.provider,
60
+ });
61
+ }
62
+ }
63
+
64
+ export function getProviderConfigEntry(provider: string, host = defaultProviderConfigHost()): ProviderConfigEntry {
65
+ const cleanProvider = normalizeProviderConfigProvider(provider);
66
+ const cleanHost = normalizeProviderConfigHost(host);
67
+ const key = providerConfigKey(cleanHost, cleanProvider);
68
+ ensureProviderConfigDescriptors([key]);
69
+ const entry = getConfig<ProviderConfig>(PROVIDER_CONFIG_NAMESPACE, key);
70
+ if (entry) return { ...entry, value: mergeProviderConfig(cleanProvider, entry.value) };
71
+ return {
72
+ namespace: PROVIDER_CONFIG_NAMESPACE,
73
+ key,
74
+ value: providerConfigDefaults(cleanProvider),
75
+ version: 0,
76
+ updatedAt: "default",
77
+ updatedBy: "system",
78
+ };
79
+ }
80
+
81
+ export function listProviderConfigEntries(host = defaultProviderConfigHost()): Record<SpawnProvider, ProviderConfigEntry> {
82
+ return Object.fromEntries(
83
+ VALID_PROVIDER_CONFIGS.map((provider) => [provider, getProviderConfigEntry(provider, host)]),
84
+ ) as Record<SpawnProvider, ProviderConfigEntry>;
85
+ }
86
+
87
+ export function setProviderConfig(
88
+ provider: string,
89
+ value: unknown,
90
+ opts: { host?: string; updatedBy?: string } = {},
91
+ ): ProviderConfigEntry {
92
+ const cleanProvider = normalizeProviderConfigProvider(provider);
93
+ const host = normalizeProviderConfigHost(opts.host ?? defaultProviderConfigHost());
94
+ const normalized = cleanProviderConfig(cleanProvider, value);
95
+ const key = providerConfigKey(host, cleanProvider);
96
+ ensureProviderConfigDescriptors([key]);
97
+ const validation = validateSettingValue(PROVIDER_CONFIG_NAMESPACE, key, normalized);
98
+ if (!validation.valid) throw new ValidationError(validation.errors.join("; "));
99
+ return setConfig(PROVIDER_CONFIG_NAMESPACE, key, normalized, opts.updatedBy) as ProviderConfigEntry;
100
+ }
101
+
102
+ export function providerConfigPublic(entry: ProviderConfigEntry, opts: { maskEnv?: boolean } = {}): Record<string, unknown> {
103
+ const parsed = parseProviderConfigKey(entry.key);
104
+ return {
105
+ namespace: entry.namespace,
106
+ key: entry.key,
107
+ path: `config:${entry.namespace}/${entry.key}`,
108
+ host: parsed?.host,
109
+ provider: parsed?.provider,
110
+ version: entry.version,
111
+ updatedAt: entry.updatedAt,
112
+ updatedBy: entry.updatedBy,
113
+ command: entry.value.command,
114
+ defaultArgs: entry.value.defaultArgs,
115
+ env: opts.maskEnv === false ? entry.value.env : maskEnv(entry.value.env),
116
+ pluginDirs: entry.value.pluginDirs,
117
+ defaultCapabilities: entry.value.defaultCapabilities,
118
+ defaultApprovalMode: entry.value.defaultApprovalMode,
119
+ defaultTags: entry.value.defaultTags,
120
+ chatCaptureMode: entry.value.chatCaptureMode,
121
+ reasoningCapture: entry.value.reasoningCapture,
122
+ headless: entry.value.headless,
123
+ };
124
+ }
125
+
126
+ export function migrateLocalProviderConfigs(home = agentRelayHome(), host = defaultProviderConfigHost()): ProviderConfigEntry[] {
127
+ const dir = join(home, "providers");
128
+ if (!existsSync(dir)) return [];
129
+ const migrated: ProviderConfigEntry[] = [];
130
+ for (const file of readdirSync(dir)) {
131
+ const provider = file.endsWith(".json") ? file.slice(0, -".json".length) : "";
132
+ if (!VALID_PROVIDER_CONFIGS.includes(provider as SpawnProvider)) continue;
133
+ const key = providerConfigKey(host, provider);
134
+ if (getConfig(PROVIDER_CONFIG_NAMESPACE, key)) continue;
135
+ const local = loadLocalProviderConfig(provider, home);
136
+ const entry = setProviderConfig(provider, local, { host, updatedBy: `migration:${host}` });
137
+ emitConfigChanged(entry.namespace, entry.key, entry.version);
138
+ migrated.push(entry);
139
+ }
140
+ return migrated;
141
+ }
142
+
143
+ function normalizeProviderConfigHost(host: string): string {
144
+ return cleanString(host, "host", { required: true, max: 120 })!;
145
+ }
146
+
147
+ function normalizeProviderConfigProvider(provider: string): ProviderConfigProvider {
148
+ const clean = cleanString(provider, "provider", { required: true, max: 80 })!;
149
+ if (!getProviderSettingsDeclaration(clean)) {
150
+ throw new ValidationError(`provider must be one of: ${listProviderSettingsDeclarations().map((d) => d.provider).join(", ")}`);
151
+ }
152
+ return clean;
153
+ }
154
+
155
+ function cleanProviderConfig(provider: string, value: unknown): ProviderConfig {
156
+ if (!isRecord(value)) throw new ValidationError("provider config body must be an object");
157
+ const defaults = providerConfigDefaults(provider);
158
+ const headless = isRecord(value.headless) ? value.headless : {};
159
+ return {
160
+ command: cleanString(value.command, "command", { required: true, max: 500 })!,
161
+ defaultArgs: cleanStringArray(value.defaultArgs, "defaultArgs", { itemMax: 80, maxItems: 50 }) ?? defaults.defaultArgs,
162
+ env: cleanEnvRecord(value.env),
163
+ pluginDirs: cleanStringArray(value.pluginDirs, "pluginDirs", { itemMax: 80, maxItems: 50 }) ?? defaults.pluginDirs,
164
+ defaultCapabilities: cleanStringArray(value.defaultCapabilities, "defaultCapabilities", { itemMax: 80, maxItems: 50 }) ?? defaults.defaultCapabilities,
165
+ defaultApprovalMode: cleanString(value.defaultApprovalMode, "defaultApprovalMode", { max: 80 }) ?? defaults.defaultApprovalMode,
166
+ defaultTags: cleanStringArray(value.defaultTags, "defaultTags", { itemMax: 80, maxItems: 50 }) ?? defaults.defaultTags,
167
+ chatCaptureMode: (optionalEnum(value.chatCaptureMode, "chatCaptureMode", ["final", "full"]) ?? defaults.chatCaptureMode) as "final" | "full",
168
+ reasoningCapture: value.reasoningCapture === undefined ? defaults.reasoningCapture ?? true : cleanBoolean(value.reasoningCapture, "reasoningCapture"),
169
+ headless: {
170
+ tmuxPrefix: cleanString(headless.tmuxPrefix, "headless.tmuxPrefix", { max: 120 }) ?? defaults.headless.tmuxPrefix,
171
+ shutdownTimeoutMs: cleanPositiveInt(headless.shutdownTimeoutMs, "headless.shutdownTimeoutMs") ?? defaults.headless.shutdownTimeoutMs,
172
+ },
173
+ };
174
+ }
175
+
176
+ function mergeProviderConfig(provider: string, value: Partial<ProviderConfig>): ProviderConfig {
177
+ const defaults = providerConfigDefaults(provider);
178
+ return cleanProviderConfig(provider, { ...defaults, ...value, headless: { ...defaults.headless, ...value.headless } });
179
+ }
180
+
181
+ function cleanEnvRecord(value: unknown): Record<string, string> {
182
+ if (value === undefined) return {};
183
+ if (!isRecord(value)) throw new ValidationError("env must be an object");
184
+ const env: Record<string, string> = {};
185
+ for (const [key, item] of Object.entries(value)) {
186
+ const cleanKey = cleanString(key, "env key", { required: true, max: 120 })!;
187
+ if (typeof item !== "string") throw new ValidationError(`env.${cleanKey} must be a string`);
188
+ env[cleanKey] = item;
189
+ }
190
+ return env;
191
+ }
192
+
193
+ function cleanBoolean(value: unknown, field: string): boolean {
194
+ if (typeof value !== "boolean") throw new ValidationError(`${field} must be a boolean`);
195
+ return value;
196
+ }
197
+
198
+ function cleanPositiveInt(value: unknown, field: string): number | undefined {
199
+ if (value === undefined) return undefined;
200
+ if (typeof value !== "number" || !Number.isSafeInteger(value) || value <= 0) {
201
+ throw new ValidationError(`${field} must be a positive integer`);
202
+ }
203
+ return value;
204
+ }
205
+
206
+ function maskEnv(env: Record<string, string>): Record<string, string> {
207
+ const result: Record<string, string> = {};
208
+ for (const [key, value] of Object.entries(env)) {
209
+ result[key] = /token|secret|key|password/i.test(key) && !value.startsWith("$env:") ? "********" : value;
210
+ }
211
+ return result;
212
+ }
213
+
214
+ function agentRelayHome(): string {
215
+ return process.env.AGENT_RELAY_HOME || join(process.env.HOME || ".", ".agent-relay");
216
+ }
@@ -2,6 +2,7 @@ import { createCommand } from "./commands-db";
2
2
  import { listOrchestrators } from "./db";
3
3
  import { resolveProviderSelection } from "agent-relay-sdk/provider-catalog";
4
4
  import { getRecipe } from "./recipe-loader";
5
+ import { effectiveProviderCatalogList } from "./provider-catalog-store";
5
6
  import {
6
7
  createRecipeInstance,
7
8
  getRecipeInstance,
@@ -56,7 +57,7 @@ export function startRecipe(input: StartRecipeInput): StartRecipeResult {
56
57
  for (const agent of recipe.agents) {
57
58
  const count = agent.count ?? 1;
58
59
  for (let index = 0; index < count; index++) {
59
- const selection = resolveProviderSelection({ provider: agent.provider, model: agent.model, effort: agent.effort });
60
+ const selection = resolveProviderSelection({ provider: agent.provider, model: agent.model, effort: agent.effort }, effectiveProviderCatalogList());
60
61
  const label = count > 1 ? `${recipe.name}:${agent.role}:${index + 1}` : `${recipe.name}:${agent.role}`;
61
62
  const memoryTags = [...new Set([...(recipe.lifecycle?.memory?.memoryTags ?? []), ...(agent.memoryTags ?? [])])];
62
63
  const alwaysReload = recipe.lifecycle?.memory?.alwaysReload ?? [];
@@ -1,6 +1,7 @@
1
1
  import type { MemoryType, Recipe, RecipeAgent, RecipeMemoryPolicy } from "./types";
2
2
  import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog";
3
3
  import { errMessage, isRecord } from "agent-relay-sdk";
4
+ import { effectiveProviderCatalogList } from "./provider-catalog-store";
4
5
 
5
6
  class RecipeValidationError extends Error {}
6
7
 
@@ -50,7 +51,7 @@ function validateAgent(value: unknown): RecipeAgent {
50
51
  const model = optionalString(value.model, "agent.model");
51
52
  const effort = optionalEffort(value.effort, "agent.effort");
52
53
  try {
53
- resolveProviderSelection({ provider, model, effort });
54
+ resolveProviderSelection({ provider, model, effort }, effectiveProviderCatalogList());
54
55
  } catch (error) {
55
56
  throw new RecipeValidationError(errMessage(error));
56
57
  }
@@ -103,8 +104,8 @@ function optionalString(value: unknown, field: string): string | undefined {
103
104
 
104
105
  function optionalEffort(value: unknown, field: string): ProviderEffort | undefined {
105
106
  if (value === undefined || value === null) return undefined;
106
- if (value === "low" || value === "medium" || value === "high" || value === "xhigh" || value === "max") return value;
107
- throw new RecipeValidationError(`${field} must be low, medium, high, xhigh, or max`);
107
+ if (typeof value !== "string") throw new RecipeValidationError(`${field} must be a string`);
108
+ return value.trim() as ProviderEffort || undefined;
108
109
  }
109
110
 
110
111
  function optionalBoolean(value: unknown, field: string): boolean | undefined {
@@ -1,6 +1,6 @@
1
1
  // Auto-split from routes.ts (#299). Domain: agent-sessions.
2
2
  import { RELAY_TOKEN_HEADER, isRecord } from "agent-relay-sdk";
3
- import { VALID_AGENT_ACTIONS, agentControlActionIcon, auditEvent, authAuditMetadata, authorizeRoute, dashboardAttribution, emitCommand, error, json, metaString, parseBody, spawnRequestId, type AgentControlAction, type Handler } from "./_shared";
3
+ import { VALID_AGENT_ACTIONS, agentControlActionIcon, auditEvent, authAuditMetadata, authorizeRoute, cleanAttachmentRefs, dashboardAttribution, emitCommand, error, json, metaString, parseBody, spawnRequestId, type AgentControlAction, type Handler } from "./_shared";
4
4
  import { ValidationError, getAgent, getAgentTimeline, getOrchestrator, markReady, mergeAgentMeta, sendMessage } from "../db";
5
5
  import { buildManagedSpawnParams } from "../managed-policy";
6
6
  import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams } from "../spawn-command";
@@ -306,6 +306,7 @@ export const postAgentPrompt: Handler = async (req, params) => {
306
306
  try {
307
307
  if (!isRecord(parsed.body)) return error("body required");
308
308
  const body = cleanString(parsed.body.body, "body", { required: true, max: 100_000 })!;
309
+ const attachments = cleanAttachmentRefs(parsed.body.attachments ?? parsed.body.artifacts, "attachments");
309
310
  const agent = getAgent(params.id!);
310
311
  if (!agent) return error("agent not found", 404);
311
312
  if (agent.status === "offline") return error("agent is offline", 422);
@@ -319,12 +320,13 @@ export const postAgentPrompt: Handler = async (req, params) => {
319
320
  to: agent.id,
320
321
  kind: "session",
321
322
  body,
323
+ ...(attachments ? { payload: { attachments } } : {}),
322
324
  });
323
325
  const command = createCommand({
324
326
  type: "prompt.inject",
325
327
  source: "dashboard",
326
328
  target: agent.id,
327
- params: { body, messageId: message.id },
329
+ params: { body, messageId: message.id, ...(attachments ? { attachments } : {}) },
328
330
  });
329
331
  emitCommand(command);
330
332
  emitNewMessage(message);
@@ -1,5 +1,5 @@
1
1
  // Auto-split from routes.ts (#299). Domain: agents-spawn.
2
- import { APPROVAL_MODES, SPAWN_PROVIDERS, VALID_EFFORTS, VALID_WORKSPACE_MODES, isRecord } from "agent-relay-sdk";
2
+ import { APPROVAL_MODES, SPAWN_PROVIDERS, VALID_WORKSPACE_MODES, isRecord } from "agent-relay-sdk";
3
3
  import { McpAuthError, McpNotFoundError } from "../mcp-errors";
4
4
  import { VALID_AGENT_STATUSES, error, json, parseBody, type Handler } from "./_shared";
5
5
  import { ValidationError, listAgents } from "../db";
@@ -109,7 +109,7 @@ export const postAgentSpawn: Handler = async (req) => {
109
109
  orchestratorId: cleanString(parsed.body.orchestratorId, "orchestratorId", { max: 200 }),
110
110
  cwd: cleanString(parsed.body.cwd, "cwd", { max: 500 }),
111
111
  model: cleanString(parsed.body.model, "model", { max: 120 }),
112
- effort: optionalEnum(parsed.body.effort, "effort", VALID_EFFORTS) as ProviderEffort | undefined,
112
+ effort: cleanString(parsed.body.effort, "effort", { max: 120 }) as ProviderEffort | undefined,
113
113
  approvalMode: optionalEnum(parsed.body.approvalMode, "approvalMode", APPROVAL_MODES) as SpawnApprovalMode | undefined,
114
114
  workspaceMode: optionalEnum(parsed.body.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES) as WorkspaceMode | undefined,
115
115
  label: cleanString(parsed.body.label, "label", { max: 120 }),
@@ -315,6 +315,26 @@ export const patchCommand: Handler = async (req, params) => {
315
315
  });
316
316
  }
317
317
  }
318
+ if (command.type === "workspace.pr-refresh") {
319
+ const resultWorkspaceId = isRecord(command.result) ? cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 }) : undefined;
320
+ const workspaceId = resultWorkspaceId ?? command.correlationId;
321
+ if (workspaceId && command.status === "succeeded" && isRecord(command.result) && command.result.prRefreshed === true) {
322
+ patchWorkspaceMetadata(workspaceId, {
323
+ prRefreshCommandId: command.id,
324
+ prRefreshedAt: Date.now(),
325
+ prRefreshResult: command.result,
326
+ prRefreshError: undefined,
327
+ prRefreshFailedAt: undefined,
328
+ });
329
+ } else if (workspaceId && (command.status === "failed" || command.status === "timed_out" || command.status === "rejected" || command.status === "canceled")) {
330
+ patchWorkspaceMetadata(workspaceId, {
331
+ prRefreshCommandId: command.id,
332
+ prRefreshError: command.error ?? "PR branch refresh failed",
333
+ prRefreshFailedAt: Date.now(),
334
+ prRefreshResult: command.result,
335
+ });
336
+ }
337
+ }
318
338
  if (command.type === "workspace.deps-refresh" && command.status === "succeeded" && isRecord(command.result)) {
319
339
  // Record the outcome on the row without touching status (#51) — observability
320
340
  // for the dashboard; the CLI reads the result straight off the command.