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
@@ -2,8 +2,8 @@ import { execFileSync } from "node:child_process";
2
2
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
3
  import { homedir, hostname } from "node:os";
4
4
  import { join, resolve } from "node:path";
5
- import { DEFAULT_RELAY_URL, stringValue } from "agent-relay-sdk";
6
- import type { WorkspaceMetadata } from "agent-relay-sdk";
5
+ import { DEFAULT_RELAY_URL, RELAY_TOKEN_HEADER, errMessage, stringValue } from "agent-relay-sdk";
6
+ import type { SettingEntry, WorkspaceMetadata } from "agent-relay-sdk";
7
7
  import { sanitizeFsName } from "agent-relay-sdk/fs-name";
8
8
  import type { ProviderConfig } from "./adapter";
9
9
 
@@ -36,6 +36,7 @@ export function defaultProviderConfig(provider: string): ProviderConfig {
36
36
  defaultApprovalMode: "guarded",
37
37
  defaultTags: [],
38
38
  chatCaptureMode: "final",
39
+ reasoningCapture: true,
39
40
  headless: {
40
41
  tmuxPrefix: `${provider}-relay`,
41
42
  shutdownTimeoutMs: 10_000,
@@ -67,6 +68,7 @@ export function loadProviderConfig(provider: string, home = agentRelayHome()): L
67
68
  defaultApprovalMode: stringValue(raw.defaultApprovalMode) ?? defaults.defaultApprovalMode,
68
69
  defaultTags: stringArray(raw.defaultTags) ?? defaults.defaultTags,
69
70
  chatCaptureMode: enumValue(raw.chatCaptureMode, ["final", "full"]) ?? defaults.chatCaptureMode,
71
+ reasoningCapture: booleanValue(raw.reasoningCapture) ?? defaults.reasoningCapture,
70
72
  headless: {
71
73
  tmuxPrefix: stringValue(recordValue(raw.headless).tmuxPrefix) ?? defaults.headless.tmuxPrefix,
72
74
  shutdownTimeoutMs: positiveInteger(recordValue(raw.headless).shutdownTimeoutMs) ?? defaults.headless.shutdownTimeoutMs,
@@ -74,6 +76,24 @@ export function loadProviderConfig(provider: string, home = agentRelayHome()): L
74
76
  };
75
77
  }
76
78
 
79
+ export async function loadProviderConfigFromRelay(
80
+ provider: string,
81
+ opts: { relayUrl: string; token?: string; home?: string; host?: string },
82
+ ): Promise<LoadedProviderConfig> {
83
+ const host = opts.host ?? hostname();
84
+ const home = opts.home ?? agentRelayHome();
85
+ const local = loadProviderConfig(provider, home);
86
+ const key = `${host}/${provider}`;
87
+
88
+ try {
89
+ const fetched = await fetchProviderConfig(provider, opts.relayUrl, opts.token, key);
90
+ if (fetched) return fetched;
91
+ } catch (error) {
92
+ console.warn(`[agent-relay] provider config relay read failed (${errMessage(error)}); using local fallback ${local.path}.`);
93
+ }
94
+ return local;
95
+ }
96
+
77
97
  export function writeProviderConfig(provider: string, config: ProviderConfig, home = agentRelayHome()): LoadedProviderConfig {
78
98
  const dir = providersDir(home);
79
99
  mkdirSync(dir, { recursive: true });
@@ -93,6 +113,7 @@ export function providerConfigPublic(config: LoadedProviderConfig): Record<strin
93
113
  defaultApprovalMode: config.defaultApprovalMode,
94
114
  defaultTags: config.defaultTags,
95
115
  chatCaptureMode: config.chatCaptureMode,
116
+ reasoningCapture: config.reasoningCapture,
96
117
  headless: config.headless,
97
118
  };
98
119
  }
@@ -156,6 +177,10 @@ function enumValue<T extends string>(value: unknown, allowed: T[]): T | undefine
156
177
  return typeof value === "string" && allowed.includes(value as T) ? (value as T) : undefined;
157
178
  }
158
179
 
180
+ function booleanValue(value: unknown): boolean | undefined {
181
+ return typeof value === "boolean" ? value : undefined;
182
+ }
183
+
159
184
  function stringArray(value: unknown): string[] | undefined {
160
185
  return Array.isArray(value) && value.every((item) => typeof item === "string") ? value : undefined;
161
186
  }
@@ -178,3 +203,42 @@ function maskEnv(env: Record<string, string>): Record<string, string> {
178
203
  }
179
204
  return result;
180
205
  }
206
+
207
+ async function fetchProviderConfig(
208
+ provider: string,
209
+ relayUrl: string,
210
+ token: string | undefined,
211
+ key: string,
212
+ ): Promise<LoadedProviderConfig | null> {
213
+ const url = new URL(`/api/settings/provider-config/${encodeURIComponent(key)}`, relayUrl);
214
+ const res = await fetch(url, {
215
+ headers: token ? { [RELAY_TOKEN_HEADER]: token } : undefined,
216
+ signal: AbortSignal.timeout(5_000),
217
+ });
218
+ if (res.status === 404) return null;
219
+ const body = await res.json().catch(() => null) as (Partial<SettingEntry> & { error?: string }) | null;
220
+ if (!res.ok) throw new Error(body?.error ? `${res.status}: ${body.error}` : `HTTP ${res.status}`);
221
+ if (body?.version === 0) return null;
222
+ return providerConfigFromValue(provider, body?.value, `config:provider-config/${key}`);
223
+ }
224
+
225
+ function providerConfigFromValue(provider: string, value: unknown, path: string): LoadedProviderConfig {
226
+ const raw = recordValue(value);
227
+ const defaults = defaultProviderConfig(provider);
228
+ return {
229
+ path,
230
+ command: stringValue(raw.command) ?? defaults.command,
231
+ defaultArgs: stringArray(raw.defaultArgs) ?? defaults.defaultArgs,
232
+ env: stringRecord(raw.env) ?? defaults.env,
233
+ pluginDirs: stringArray(raw.pluginDirs) ?? defaults.pluginDirs,
234
+ defaultCapabilities: stringArray(raw.defaultCapabilities) ?? defaults.defaultCapabilities,
235
+ defaultApprovalMode: stringValue(raw.defaultApprovalMode) ?? defaults.defaultApprovalMode,
236
+ defaultTags: stringArray(raw.defaultTags) ?? defaults.defaultTags,
237
+ chatCaptureMode: enumValue(raw.chatCaptureMode, ["final", "full"]) ?? defaults.chatCaptureMode,
238
+ reasoningCapture: booleanValue(raw.reasoningCapture) ?? defaults.reasoningCapture,
239
+ headless: {
240
+ tmuxPrefix: stringValue(recordValue(raw.headless).tmuxPrefix) ?? defaults.headless.tmuxPrefix,
241
+ shutdownTimeoutMs: positiveInteger(recordValue(raw.headless).shutdownTimeoutMs) ?? defaults.headless.shutdownTimeoutMs,
242
+ },
243
+ };
244
+ }
@@ -16,7 +16,7 @@ import { createCommand } from "./commands-db";
16
16
  import { cleanEnum, cleanString, cleanStringArray, optionalEnum } from "./validation";
17
17
  import { getAgentProfile, getSpawnPolicy } from "./config-store";
18
18
  import { buildSpawnCommand, resolveSpawnModelParams } from "./spawn-command";
19
- import { resolveProviderSelection, type ProviderEffort, VALID_EFFORTS } from "agent-relay-sdk/provider-catalog";
19
+ import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog"; import { effectiveProviderCatalogList } from "./provider-catalog-store";
20
20
  import { errMessage, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
21
21
  import { runnerRuntimeTokenEnv } from "./runtime-tokens";
22
22
  import type {
@@ -38,7 +38,6 @@ import type {
38
38
  TaskSeverity,
39
39
  UpdateAutomationInput,
40
40
  } from "./types";
41
-
42
41
  const AUTOMATION_KIND = "scheduled_task";
43
42
  const DEFAULT_TIMEZONE = "UTC";
44
43
  const DEFAULT_CATCH_UP: AutomationCatchUpPolicy = "skip";
@@ -200,11 +199,11 @@ function normalizeTargetPolicy(value: unknown): AutomationTargetPolicy {
200
199
  }
201
200
  const provider = optionalEnum(input.provider, "targetPolicy.provider", ["claude", "codex"] as const, "codex");
202
201
  const model = cleanString(input.model, "targetPolicy.model", { max: 120 });
203
- const effort = input.effort === undefined || input.effort === null ? undefined : cleanEnum(input.effort, "targetPolicy.effort", VALID_EFFORTS) as ProviderEffort;
202
+ const effort = cleanString(input.effort, "targetPolicy.effort", { max: 120 }) as ProviderEffort | undefined;
204
203
  const profile = cleanString(input.profile, "targetPolicy.profile", { max: 120 });
205
204
  if (profile && !getAgentProfile(profile)) throw new ValidationError("agent profile not found");
206
205
  try {
207
- resolveProviderSelection({ provider, model, effort });
206
+ resolveProviderSelection({ provider, model, effort }, effectiveProviderCatalogList());
208
207
  } catch (error) {
209
208
  throw new ValidationError(errMessage(error));
210
209
  }
package/src/cli/index.ts CHANGED
@@ -14,6 +14,7 @@ import { handleDevCommand } from "./dev";
14
14
  import { handleMemoryCommand } from "./memory";
15
15
  import { handleSlashOrPairCommand } from "./pair";
16
16
  import { handleWorkspaceCommand } from "./workspace";
17
+ import { handleMergesCommand } from "./merges";
17
18
  import { handleStewardCommand } from "./steward";
18
19
  import { handleMessageCommand, handleGetMessageCommand, handleReactCommand } from "./message";
19
20
  import { handleReplyCommand } from "./reply";
@@ -44,6 +45,7 @@ Usage:
44
45
  agent-relay context-probe [print-status-line] [--wrap COMMAND] [--agent-id ID] [--state-dir DIR] [--standalone]
45
46
  agent-relay token <create|list|revoke|verify> [options]
46
47
  agent-relay pair <target|create|status|accept|reject|hangup|send> [options]
48
+ agent-relay merges <pause|resume|renew|list> [--repo PATH] [--base BRANCH] [--ttl 3m|--ttl-ms N] [--reason TEXT] [--holder TEXT] [--id ID] [--json]
47
49
  agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale|deps> [--id ID] [--strategy ...] [--auto-merge on-green|on-approval|manual] [--reviewer HANDLE] [--purpose TEXT] [--repo PATH] [--wait] [--timeout SECONDS] [--check] [--execute] [--json]
48
50
  agent-relay steward <queue|inspect|checks> [WORKSPACE_ID] [--repo PATH] [--json]
49
51
  agent-relay message <target> <body> [options]
@@ -141,6 +143,11 @@ Token examples:
141
143
  agent-relay token list
142
144
  agent-relay token revoke JTI
143
145
 
146
+ Merge pause examples:
147
+ agent-relay merges pause --repo /repo --reason release --ttl 5m
148
+ agent-relay merges renew --id PAUSE_ID --ttl 5m
149
+ agent-relay merges resume --id PAUSE_ID
150
+
144
151
  Context probe examples:
145
152
  agent-relay context-probe print-status-line --wrap 'ccstatusline'
146
153
  agent-relay context-probe --wrap 'ccstatusline'
@@ -345,6 +352,10 @@ export async function handleCli(args: string[]): Promise<"start" | "handled"> {
345
352
  await handleTagsCommand(args.slice(1));
346
353
  return "handled";
347
354
  }
355
+ if (command === "merges" || command === "merge") {
356
+ await handleMergesCommand(args.slice(1));
357
+ return "handled";
358
+ }
348
359
  if (command === "workspace" || command === "workspaces") {
349
360
  await handleWorkspaceCommand(args.slice(1));
350
361
  return "handled";
@@ -0,0 +1,89 @@
1
+ // Merge coordination CLI. Generic pause/resume lease for relay-owned auto-merge
2
+ // sweeps; useful for release/build critical sections (#448).
3
+ import { spawnSync } from "node:child_process";
4
+ import { apiRequest } from "./_shared";
5
+
6
+ const MERGES_USAGE = "Usage: agent-relay merges <pause|resume|renew|list> [--repo PATH] [--base BRANCH] [--ttl 3m|--ttl-ms N] [--reason TEXT] [--holder TEXT] [--id ID] [--json]";
7
+
8
+ function gitRoot(): string | undefined {
9
+ const r = spawnSync("git", ["rev-parse", "--show-toplevel"], { encoding: "utf8", stdio: "pipe" });
10
+ return r.status === 0 ? r.stdout.trim() : undefined;
11
+ }
12
+
13
+ function parseDurationMs(raw: string): number {
14
+ const match = raw.trim().match(/^(\d+)(ms|s|m|h)?$/);
15
+ if (!match) throw new Error("--ttl must be a positive duration like 180s, 3m, 1h, or use --ttl-ms");
16
+ const value = Number(match[1]);
17
+ const unit = match[2] ?? "ms";
18
+ const multiplier = unit === "h" ? 60 * 60_000 : unit === "m" ? 60_000 : unit === "s" ? 1000 : 1;
19
+ const ms = value * multiplier;
20
+ if (!Number.isSafeInteger(ms) || ms <= 0) throw new Error("--ttl is out of range");
21
+ return ms;
22
+ }
23
+
24
+ function formatPause(pause: any): string {
25
+ const until = new Date(pause.expiresAt).toISOString();
26
+ return `Merge pause ${pause.id}: ${pause.repoRoot}#${pause.baseRef} held by ${pause.holder} until ${until}${pause.reason ? ` (${pause.reason})` : ""}`;
27
+ }
28
+
29
+ export async function handleMergesCommand(args: string[]): Promise<void> {
30
+ const action = args[0];
31
+ if (action === "--help" || action === "-h" || action === "help") {
32
+ console.log(MERGES_USAGE);
33
+ return;
34
+ }
35
+ if (!action || !["pause", "resume", "renew", "list"].includes(action)) throw new Error(MERGES_USAGE);
36
+
37
+ let repoRoot: string | undefined;
38
+ let baseRef = "main";
39
+ let ttlMs: number | undefined;
40
+ let reason: string | undefined;
41
+ let holder: string | undefined;
42
+ let id: string | undefined;
43
+ let json = false;
44
+
45
+ for (let i = 1; i < args.length; i++) {
46
+ const arg = args[i];
47
+ if (arg === "--repo" && i + 1 < args.length) repoRoot = args[++i];
48
+ else if (arg === "--base" && i + 1 < args.length) baseRef = args[++i]!;
49
+ else if (arg === "--ttl" && i + 1 < args.length) ttlMs = parseDurationMs(args[++i]!);
50
+ else if (arg === "--ttl-ms" && i + 1 < args.length) {
51
+ ttlMs = Number.parseInt(args[++i]!, 10);
52
+ if (!Number.isSafeInteger(ttlMs) || ttlMs <= 0) throw new Error("--ttl-ms must be a positive integer");
53
+ }
54
+ else if (arg === "--reason" && i + 1 < args.length) reason = args[++i];
55
+ else if (arg === "--holder" && i + 1 < args.length) holder = args[++i];
56
+ else if (arg === "--id" && i + 1 < args.length) id = args[++i];
57
+ else if (arg === "--json") json = true;
58
+ else throw new Error(`Unknown merges option "${arg}".`);
59
+ }
60
+
61
+ if (action === "list") {
62
+ const result = await apiRequest("GET", "/api/merges/pauses");
63
+ console.log(JSON.stringify(result, null, 2));
64
+ return;
65
+ }
66
+
67
+ if (action === "renew") {
68
+ if (!id) throw new Error("merges renew requires --id");
69
+ const result = await apiRequest("POST", "/api/merges/renew", { id, ttlMs });
70
+ if (json) console.log(JSON.stringify(result, null, 2));
71
+ else console.log(formatPause((result as any).pause));
72
+ return;
73
+ }
74
+
75
+ repoRoot = repoRoot ?? gitRoot();
76
+ if (action === "pause" && !repoRoot) throw new Error("No git repo detected. Pass --repo PATH.");
77
+ if (action === "resume" && !id && !repoRoot) throw new Error("No git repo detected. Pass --id ID or --repo PATH.");
78
+
79
+ if (action === "pause") {
80
+ const result = await apiRequest("POST", "/api/merges/pause", { repoRoot, baseRef, ttlMs, reason, holder });
81
+ if (json) console.log(JSON.stringify(result, null, 2));
82
+ else console.log(formatPause((result as any).pause));
83
+ return;
84
+ }
85
+
86
+ const result = await apiRequest("POST", "/api/merges/resume", id ? { id } : { repoRoot, baseRef });
87
+ if (json) console.log(JSON.stringify(result, null, 2));
88
+ else console.log((result as any).released ? "Merge pause released." : "No matching merge pause was active.");
89
+ }
@@ -4,7 +4,7 @@ import { DEFAULT_CONTEXT_THRESHOLD_CONFIG, validateContextThresholdConfig } from
4
4
  import { DEFAULT_SELF_RESUME_CONFIG, validateSelfResumeConfig } from "./self-resume-config";
5
5
  import { TEAM_TEMPLATE_NAMESPACE, validateTeamTemplate } from "./team-template-config";
6
6
  import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog";
7
- import { errMessage, isRecord, SPAWN_PROVIDERS, VALID_EFFORTS, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
7
+ import { errMessage, isRecord, SPAWN_PROVIDERS, VALID_WORKSPACE_MODES } from "agent-relay-sdk"; import { effectiveProviderCatalogList } from "./provider-catalog-store";
8
8
  import type {
9
9
  AgentProfile,
10
10
  AgentProfileBase,
@@ -23,7 +23,6 @@ import type {
23
23
  WorkspaceConfig,
24
24
  WorkspaceLandingPolicy,
25
25
  } from "./types";
26
-
27
26
  const CONFIG_HISTORY_LIMIT = 50;
28
27
  const SPAWN_POLICY_NAMESPACE = "spawn-policy";
29
28
  const AGENT_PROFILE_NAMESPACE = "agent-profile";
@@ -342,7 +341,7 @@ function validateSpawnPolicy(key: string, value: unknown): SpawnPolicy {
342
341
  workspaceMode: value.workspaceMode === undefined || value.workspaceMode === null ? "inherit" : cleanEnum(value.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES),
343
342
  rig: cleanString(value.rig, "rig", { max: 120 }),
344
343
  model: cleanString(value.model, "model", { max: 120 }),
345
- effort: value.effort === undefined || value.effort === null ? undefined : cleanEnum(value.effort, "effort", VALID_EFFORTS) as ProviderEffort,
344
+ effort: cleanString(value.effort, "effort", { max: 120 }) as ProviderEffort | undefined,
346
345
  profile: cleanString(value.profile, "profile", { max: 120 }),
347
346
  providerArgs: cleanStringArray(value.providerArgs, "providerArgs", { itemMax: 120, maxItems: 100 }) ?? [],
348
347
  prompt: cleanString(value.prompt, "prompt", { max: 16_000 }),
@@ -356,7 +355,7 @@ function validateSpawnPolicy(key: string, value: unknown): SpawnPolicy {
356
355
  backoff: cleanBackoff(value.backoff),
357
356
  };
358
357
  try {
359
- resolveProviderSelection({ provider: policy.provider, model: policy.model, effort: policy.effort });
358
+ resolveProviderSelection({ provider: policy.provider, model: policy.model, effort: policy.effort }, effectiveProviderCatalogList());
360
359
  } catch (error) {
361
360
  throw new ValidationError(errMessage(error));
362
361
  }
@@ -408,7 +407,7 @@ function validateStewardConfig(value: unknown): StewardConfig {
408
407
  enabled: value.enabled === undefined ? false : cleanBoolean(value.enabled, "enabled"),
409
408
  provider: cleanEnum(value.provider, "provider", SPAWN_PROVIDERS) as SpawnProvider,
410
409
  model: cleanString(value.model, "model", { max: 120 }),
411
- effort: value.effort === undefined || value.effort === null ? undefined : cleanEnum(value.effort, "effort", VALID_EFFORTS) as ProviderEffort,
410
+ effort: cleanString(value.effort, "effort", { max: 120 }) as ProviderEffort | undefined,
412
411
  permissionMode: (value.permissionMode === undefined || value.permissionMode === null
413
412
  ? "open"
414
413
  : cleanEnum(value.permissionMode, "permissionMode", VALID_PERMISSION_MODES)) as SpawnApprovalMode,
@@ -419,7 +418,7 @@ function validateStewardConfig(value: unknown): StewardConfig {
419
418
  // Reject a provider/model/effort combo the catalog can't resolve before it ever
420
419
  // reaches a spawn (same guard as spawn policies).
421
420
  try {
422
- resolveProviderSelection({ provider: config.provider, model: config.model, effort: config.effort });
421
+ resolveProviderSelection({ provider: config.provider, model: config.model, effort: config.effort }, effectiveProviderCatalogList());
423
422
  } catch (error) {
424
423
  throw new ValidationError(errMessage(error));
425
424
  }
package/src/config.ts CHANGED
@@ -33,6 +33,10 @@ export const POOL_CLAIM_LEASE_MS = envPositiveInt("AGENT_RELAY_POOL_CLAIM_LEASE_
33
33
  // repo. Held from when a workspace.merge command is dispatched until it settles
34
34
  // (or this TTL expires, in case the orchestrator never reports back).
35
35
  export const WORKSPACE_MERGE_LEASE_MS = envPositiveInt("AGENT_RELAY_WORKSPACE_MERGE_LEASE_MS", 900_000); // 15min
36
+ // Opt-in release/build critical-section pause for relay-owned auto-merge sweeps.
37
+ // This narrows the race window while the release script creates and pushes a tag;
38
+ // expiry is the safety net so a killed holder cannot wedge the repo.
39
+ export const MAIN_MERGE_PAUSE_TTL_MS = envPositiveInt("AGENT_RELAY_MAIN_MERGE_PAUSE_TTL_MS", 5 * 60_000); // 5min
36
40
 
37
41
  // Max body size for any POST/PATCH request (64 KiB).
38
42
  export const MAX_BODY_BYTES = 64 * 1024;
package/src/connectors.ts CHANGED
@@ -4,6 +4,7 @@ import { dirname, join } from "node:path";
4
4
  import type { ConnectorAction, ConnectorActionResult, ConnectorManifest, ConnectorSummary } from "./types";
5
5
  import { ValidationError } from "./db";
6
6
  import { isRecord } from "agent-relay-sdk";
7
+ import { registerConnectorConfigSetting, validateConnectorConfig } from "./settings/modules";
7
8
 
8
9
  const CONNECTOR_SCHEMA = "agent-relay.connector.v1";
9
10
  const VALID_KINDS = new Set(["channel", "event", "provider", "orchestrator"]);
@@ -99,6 +100,7 @@ function stringArray(value: unknown, field: string): string[] {
99
100
 
100
101
  export function registerConnectorManifest(manifest: ConnectorManifest, opts: { config?: Record<string, unknown>; state?: Record<string, unknown> } = {}): ConnectorSummary {
101
102
  const normalized = normalizeConnectorManifest(manifest);
103
+ registerConnectorConfigSetting(normalized, { defaults: opts.config });
102
104
  const dir = connectorDir(normalized.id);
103
105
  mkdirSync(dir, { recursive: true, mode: 0o700 });
104
106
  writeFileSync(join(dir, "manifest.json"), JSON.stringify(normalized, null, 2) + "\n", { mode: 0o600 });
@@ -165,6 +167,9 @@ export function readConnectorConfig(id: string): Record<string, unknown> {
165
167
 
166
168
  export function writeConnectorConfig(id: string, config: Record<string, unknown>): Record<string, unknown> {
167
169
  if (!isRecord(config)) throw new ValidationError("connector config must be an object");
170
+ const connector = getConnector(id);
171
+ if (!connector) throw new ValidationError(`connector ${id} not found`);
172
+ validateConnectorConfig(connector.manifest, config);
168
173
  const path = join(connectorDir(id), "config.json");
169
174
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
170
175
  writeFileSync(path, JSON.stringify(config, null, 2) + "\n", { mode: 0o600 });
@@ -12,7 +12,7 @@ import {
12
12
  parseRuntimePackage,
13
13
  type RuntimeContracts,
14
14
  } from "../contracts";
15
- import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS } from "../config";
15
+ import { STALE_TTL_MS, DAY_MS, CLAIM_LEASE_MS, POOL_CLAIM_LEASE_MS, WORKSPACE_MERGE_LEASE_MS, MAIN_MERGE_PAUSE_TTL_MS } from "../config";
16
16
  import { matchAgents } from "../agent-ref";
17
17
  import { getDb } from "./connection.ts";
18
18
  import type {
@@ -158,3 +158,97 @@ export function releaseMergeLease(opts: { repoRoot?: string; commandId?: string;
158
158
  return getDb().query(`DELETE FROM workspace_merge_leases WHERE ${where.join(" AND ")}`).run(...params).changes > 0;
159
159
  }
160
160
 
161
+ // --- Per-target merge pause lease (issue #448) -----------------------------
162
+
163
+ export interface MergePauseRecord {
164
+ id: string;
165
+ repoRoot: string;
166
+ baseRef: string;
167
+ holder: string;
168
+ reason?: string;
169
+ createdAt: number;
170
+ updatedAt: number;
171
+ expiresAt: number;
172
+ }
173
+
174
+ export function rowToMergePause(row: any): MergePauseRecord {
175
+ return {
176
+ id: row.id,
177
+ repoRoot: row.repo_root,
178
+ baseRef: row.base_ref,
179
+ holder: row.holder,
180
+ reason: row.reason ?? undefined,
181
+ createdAt: row.created_at,
182
+ updatedAt: row.updated_at,
183
+ expiresAt: row.expires_at,
184
+ };
185
+ }
186
+
187
+ export function getMergePause(id: string): MergePauseRecord | null {
188
+ const row = getDb().query("SELECT * FROM workspace_merge_pauses WHERE id = ?").get(id) as any;
189
+ return row ? rowToMergePause(row) : null;
190
+ }
191
+
192
+ export function getActiveMergePause(repoRoot: string, baseRef = "main", now: number = Date.now()): MergePauseRecord | null {
193
+ const row = getDb().query("SELECT * FROM workspace_merge_pauses WHERE repo_root = ? AND base_ref = ? AND expires_at > ?")
194
+ .get(repoRoot, baseRef, now) as any;
195
+ return row ? rowToMergePause(row) : null;
196
+ }
197
+
198
+ export function listMergePauses(): MergePauseRecord[] {
199
+ return (getDb().query("SELECT * FROM workspace_merge_pauses ORDER BY updated_at DESC").all() as any[]).map(rowToMergePause);
200
+ }
201
+
202
+ export function acquireMergePause(input: {
203
+ repoRoot: string;
204
+ baseRef?: string;
205
+ holder: string;
206
+ reason?: string;
207
+ ttlMs?: number;
208
+ now?: number;
209
+ }): { ok: true; pause: MergePauseRecord } | { ok: false; pause: MergePauseRecord } {
210
+ return getDb().transaction(() => {
211
+ const now = input.now ?? Date.now();
212
+ const baseRef = input.baseRef || "main";
213
+ const existing = getActiveMergePause(input.repoRoot, baseRef, now);
214
+ if (existing) return { ok: false as const, pause: existing };
215
+ const id = randomUUID();
216
+ const expiresAt = now + (input.ttlMs ?? MAIN_MERGE_PAUSE_TTL_MS);
217
+ getDb().query(`
218
+ INSERT INTO workspace_merge_pauses (id, repo_root, base_ref, holder, reason, created_at, updated_at, expires_at)
219
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
220
+ ON CONFLICT(repo_root, base_ref) DO UPDATE SET
221
+ id = excluded.id,
222
+ holder = excluded.holder,
223
+ reason = excluded.reason,
224
+ created_at = excluded.created_at,
225
+ updated_at = excluded.updated_at,
226
+ expires_at = excluded.expires_at
227
+ `).run(id, input.repoRoot, baseRef, input.holder, input.reason ?? null, now, now, expiresAt);
228
+ return { ok: true as const, pause: getMergePause(id)! };
229
+ })();
230
+ }
231
+
232
+ export function renewMergePause(id: string, ttlMs: number = MAIN_MERGE_PAUSE_TTL_MS, now: number = Date.now()): MergePauseRecord | null {
233
+ const current = getMergePause(id);
234
+ if (!current || current.expiresAt <= now) return null;
235
+ getDb().query("UPDATE workspace_merge_pauses SET updated_at = ?, expires_at = ? WHERE id = ?").run(now, now + ttlMs, id);
236
+ return getMergePause(id);
237
+ }
238
+
239
+ export function releaseMergePause(opts: { id?: string; repoRoot?: string; baseRef?: string }): boolean {
240
+ const where: string[] = [];
241
+ const params: string[] = [];
242
+ if (opts.id) { where.push("id = ?"); params.push(opts.id); }
243
+ if (opts.repoRoot) { where.push("repo_root = ?"); params.push(opts.repoRoot); }
244
+ if (opts.baseRef) { where.push("base_ref = ?"); params.push(opts.baseRef); }
245
+ if (!where.length) return false;
246
+ return getDb().query(`DELETE FROM workspace_merge_pauses WHERE ${where.join(" AND ")}`).run(...params).changes > 0;
247
+ }
248
+
249
+ export function releaseExpiredMergePauses(now: number = Date.now()): string[] {
250
+ const expired = getDb().query("SELECT id FROM workspace_merge_pauses WHERE expires_at <= ?").all(now) as Array<{ id: string }>;
251
+ if (!expired.length) return [];
252
+ getDb().query("DELETE FROM workspace_merge_pauses WHERE expires_at <= ?").run(now);
253
+ return expired.map((r) => r.id);
254
+ }
@@ -117,6 +117,21 @@ export function applyMigrations(): void {
117
117
  normalizeExistingMessageReactions();
118
118
 
119
119
  // Migrations
120
+ getDb().run(`
121
+ CREATE TABLE IF NOT EXISTS workspace_merge_pauses (
122
+ id TEXT PRIMARY KEY,
123
+ repo_root TEXT NOT NULL,
124
+ base_ref TEXT NOT NULL,
125
+ holder TEXT NOT NULL,
126
+ reason TEXT,
127
+ created_at INTEGER NOT NULL,
128
+ updated_at INTEGER NOT NULL,
129
+ expires_at INTEGER NOT NULL,
130
+ UNIQUE(repo_root, base_ref)
131
+ )
132
+ `);
133
+ getDb().run("CREATE INDEX IF NOT EXISTS idx_workspace_merge_pauses_expires ON workspace_merge_pauses(expires_at)");
134
+
120
135
  const cols = getDb().query("PRAGMA table_info(messages)").all() as any[];
121
136
  const colNames = cols.map((c: any) => c.name);
122
137
  if (!colNames.includes("thread_id")) {
@@ -379,6 +394,12 @@ export function applyMigrations(): void {
379
394
  "UPDATE tasks SET claim_expires_at = coalesce(claimed_at, ?) + ? WHERE claimed_by IS NOT NULL AND claim_expires_at IS NULL",
380
395
  ).run(Date.now(), CLAIM_LEASE_MS);
381
396
 
397
+ const providerModelOverrideCols = getDb().query("PRAGMA table_info(provider_model_overrides)").all() as any[];
398
+ const providerModelOverrideColNames = providerModelOverrideCols.map((c: any) => c.name);
399
+ if (!providerModelOverrideColNames.includes("disabled")) {
400
+ getDb().run("ALTER TABLE provider_model_overrides ADD COLUMN disabled INTEGER NOT NULL DEFAULT 0");
401
+ }
402
+
382
403
  // Migration: orchestrators.api_url
383
404
  const orchCols = getDb().query("PRAGMA table_info(orchestrators)").all() as any[];
384
405
  const orchColNames = orchCols.map((c: any) => c.name);
package/src/db/schema.ts CHANGED
@@ -829,7 +829,7 @@ export function initDb(path: string = "agent-relay.db"): Database {
829
829
  provider TEXT NOT NULL,
830
830
  alias TEXT NOT NULL,
831
831
  entry TEXT NOT NULL DEFAULT '{}',
832
- deleted INTEGER NOT NULL DEFAULT 0,
832
+ deleted INTEGER NOT NULL DEFAULT 0, disabled INTEGER NOT NULL DEFAULT 0,
833
833
  updated_at INTEGER NOT NULL,
834
834
  updated_by TEXT,
835
835
  PRIMARY KEY (provider, alias)
package/src/index.ts CHANGED
@@ -268,13 +268,16 @@ export function createFetchHandler(
268
268
  if (!publicPaths.includes(url.pathname)) {
269
269
  const integrationAuth = getIntegrationAuth(req);
270
270
  const componentAuth = getComponentAuth(req);
271
+ const handlerOwnsScopedAuth = url.pathname === "/api/tokens/me" ||
272
+ url.pathname === "/api/settings" ||
273
+ url.pathname.startsWith("/api/settings/");
271
274
  if (!isAuthorized(req)) {
272
275
  if (!integrationAuth) {
273
276
  return unauthorized(req);
274
277
  }
275
- if (!isScopedRequestAuthorized(req)) return forbidden(req);
278
+ if (!handlerOwnsScopedAuth && !isScopedRequestAuthorized(req)) return forbidden(req);
276
279
  }
277
- if (componentAuth && !isScopedRequestAuthorized(req)) return forbidden(req);
280
+ if (componentAuth && !handlerOwnsScopedAuth && !isScopedRequestAuthorized(req)) return forbidden(req);
278
281
  }
279
282
  const response = await matched.handler(req, matched.params);
280
283
  applyCors(req, response);
@@ -13,6 +13,7 @@ import {
13
13
  expireQueuedMessages,
14
14
  getAgent,
15
15
  getDb,
16
+ getActiveMergePause,
16
17
  getRepoSteward,
17
18
  getWorkspace,
18
19
  listActiveTeams,
@@ -64,6 +65,7 @@ import { pruneExpiredTokenRecords } from "./token-db";
64
65
  import { dissolveTeamById } from "./services/team";
65
66
  import { reapTransientAgents, resetTransientTrackerForTests } from "./services/transient-agent-reaper";
66
67
  import { resumeRateLimitedAgents, resetRateLimitResumeTrackerForTests } from "./services/rate-limit-resume";
68
+ import { MERGE_PAUSE_MAINTENANCE_JOB } from "./merge-pause-maintenance";
67
69
  import type { Command, MaintenanceJob, MaintenanceJobRun } from "./types";
68
70
 
69
71
  const DEFAULT_TIMEOUT_MS = 30_000;
@@ -197,6 +199,7 @@ const definitions: MaintenanceJobDefinition[] = [
197
199
  };
198
200
  },
199
201
  },
202
+ MERGE_PAUSE_MAINTENANCE_JOB,
200
203
  {
201
204
  id: "task-orphan-release",
202
205
  title: "Orphaned task release",
@@ -659,9 +662,8 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
659
662
  const candidates = listWorkspaces().filter(
660
663
  (ws) => ws.mode === "isolated" && Boolean(ws.worktreePath) && CONFLICT_SCAN_STATUSES.has(ws.status),
661
664
  );
662
- const flagged: string[] = [], cleared: string[] = [], merged: string[] = [], notifiedStewards: string[] = [];
665
+ const flagged: string[] = [], cleared: string[] = [], merged: string[] = [], notifiedStewards: string[] = [], prRecycled: string[] = [];
663
666
  const reviewerArmed: string[] = [], reviewerChangesRequested: string[] = [], relayPrMerged: string[] = [], relayPrWaiting: string[] = [];
664
- const prRecycled: string[] = [];
665
667
 
666
668
  for (const ws of candidates) {
667
669
  const orch = orchestrators.find((candidate) => workspacePathWithinBase(ws.sourceCwd, candidate.baseDir));
@@ -708,6 +710,7 @@ async function scanWorkspaceConflicts(): Promise<Record<string, unknown>> {
708
710
  const prAction = preparePrCompletionScan(ws, p, orch.agentId);
709
711
  if (prAction.armCommand) { emitCommand(prAction.armCommand); reviewerArmed.push(ws.id); continue; }
710
712
  if (prAction.notifiedOwner) { reviewerChangesRequested.push(ws.id); continue; }
713
+ if (prAction.refreshCommand) { emitCommand(prAction.refreshCommand); continue; }
711
714
  if (prAction.mergeCommand) { emitCommand(prAction.mergeCommand); relayPrMerged.push(ws.id); continue; }
712
715
  if (prAction.waitReason) relayPrWaiting.push(`${ws.id}:${prAction.waitReason}`);
713
716
  if (prAction.stop) continue;
@@ -786,16 +789,13 @@ async function autoMergeCleanFastForwards(): Promise<Record<string, unknown>> {
786
789
  );
787
790
  const stewardEnabled = getStewardConfig().enabled;
788
791
  const merged: string[] = [];
789
- const heldByLease: string[] = [];
790
- const heldByClaim: string[] = [];
791
- const heldAwaitingApproval: string[] = [];
792
- const leftForSteward: string[] = [];
793
- const wokeStewards: string[] = [];
792
+ const heldByLease: string[] = [], heldByClaim: string[] = [], heldByPause: string[] = [], heldAwaitingApproval: string[] = [], leftForSteward: string[] = [], wokeStewards: string[] = [];
794
793
 
795
794
  for (const ws of candidates) {
796
795
  if (isAwaitingReviewerApproval(ws)) { heldAwaitingApproval.push(ws.id); continue; }
797
796
  // A claimed workspace is being validated by a steward — don't race it (#208).
798
797
  if (workspaceActiveClaim(ws)) { heldByClaim.push(ws.id); continue; }
798
+ if (getActiveMergePause(ws.repoRoot, ws.baseRef ?? "main")) { heldByPause.push(ws.id); continue; }
799
799
  const orch = orchestrators.find((candidate) => workspacePathWithinBase(ws.sourceCwd, candidate.baseDir));
800
800
  if (!orch?.apiUrl) continue;
801
801
  const preview = await fetchHostMergePreview(orch.apiUrl, ws);
@@ -857,7 +857,7 @@ async function autoMergeCleanFastForwards(): Promise<Record<string, unknown>> {
857
857
  });
858
858
  }
859
859
 
860
- return { scanned: candidates.length, merged, heldByLease, heldByClaim, heldAwaitingApproval, leftForSteward, wokeStewards };
860
+ return { scanned: candidates.length, merged, heldByLease, heldByClaim, heldByPause, heldAwaitingApproval, leftForSteward, wokeStewards };
861
861
  }
862
862
 
863
863
  // Route a GC notification, swallowing failures (a stale/missing/misconfigured target must never