agent-relay-server 0.120.4 → 0.120.5

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 (88) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +3 -3
  3. package/public/assets/{MessageBubble-Cqi3XsZ7.js → MessageBubble-BVff6PSi.js} +2 -2
  4. package/public/assets/{MessageBubble-Cqi3XsZ7.js.map → MessageBubble-BVff6PSi.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-D6Um05oW.js → PlanGraphPanel-BjrVU5Tg.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-D6Um05oW.js.map → PlanGraphPanel-BjrVU5Tg.js.map} +1 -1
  7. package/public/assets/{activity-B0LsQHEz.js → activity-Bk9LEUr4.js} +2 -2
  8. package/public/assets/{activity-B0LsQHEz.js.map → activity-Bk9LEUr4.js.map} +1 -1
  9. package/public/assets/{agent-profiles-O--pe5Ic.js → agent-profiles-DSVDoQ_N.js} +2 -2
  10. package/public/assets/{agent-profiles-O--pe5Ic.js.map → agent-profiles-DSVDoQ_N.js.map} +1 -1
  11. package/public/assets/{agents-Ii6XlVwR.js → agents-8rQuJqii.js} +2 -2
  12. package/public/assets/{agents-Ii6XlVwR.js.map → agents-8rQuJqii.js.map} +1 -1
  13. package/public/assets/{analytics-BCzdYWRT.js → analytics-DifGnk7q.js} +2 -2
  14. package/public/assets/{analytics-BCzdYWRT.js.map → analytics-DifGnk7q.js.map} +1 -1
  15. package/public/assets/{automation-Crr3Xzui.js → automation-CMxQAf3l.js} +2 -2
  16. package/public/assets/{automation-Crr3Xzui.js.map → automation-CMxQAf3l.js.map} +1 -1
  17. package/public/assets/{branch-state-badge-Bhw2rloy.js → branch-state-badge-CyC5Qrhc.js} +2 -2
  18. package/public/assets/{branch-state-badge-Bhw2rloy.js.map → branch-state-badge-CyC5Qrhc.js.map} +1 -1
  19. package/public/assets/{channels-CKs9rvsn.js → channels-BLkdt2XR.js} +2 -2
  20. package/public/assets/{channels-CKs9rvsn.js.map → channels-BLkdt2XR.js.map} +1 -1
  21. package/public/assets/{chat-BCwEUj5R.js → chat-jlXywb9G.js} +2 -2
  22. package/public/assets/{chat-BCwEUj5R.js.map → chat-jlXywb9G.js.map} +1 -1
  23. package/public/assets/{connectors-D_kQR_vw.js → connectors-C0n12Je8.js} +2 -2
  24. package/public/assets/{connectors-D_kQR_vw.js.map → connectors-C0n12Je8.js.map} +1 -1
  25. package/public/assets/{formatted-body-q_qzt1L-.js → formatted-body-CzehtPtx.js} +3 -3
  26. package/public/assets/{formatted-body-q_qzt1L-.js.map → formatted-body-CzehtPtx.js.map} +1 -1
  27. package/public/assets/{formatted-body-impl-CSRGuMcM.js → formatted-body-impl-D3Bnt0Fj.js} +2 -2
  28. package/public/assets/{formatted-body-impl-CSRGuMcM.js.map → formatted-body-impl-D3Bnt0Fj.js.map} +1 -1
  29. package/public/assets/{index-Ceiwd02X.js → index-4g3PbGVN.js} +6 -6
  30. package/public/assets/{index-Ceiwd02X.js.map → index-4g3PbGVN.js.map} +1 -1
  31. package/public/assets/{insights-DUhXqZTZ.js → insights-3H6KttBi.js} +2 -2
  32. package/public/assets/{insights-DUhXqZTZ.js.map → insights-3H6KttBi.js.map} +1 -1
  33. package/public/assets/{integrations-lbg5CjtE.js → integrations-v14ehskT.js} +2 -2
  34. package/public/assets/{integrations-lbg5CjtE.js.map → integrations-v14ehskT.js.map} +1 -1
  35. package/public/assets/{maintenance-CQgneSVf.js → maintenance-BANYcBcn.js} +2 -2
  36. package/public/assets/{maintenance-CQgneSVf.js.map → maintenance-BANYcBcn.js.map} +1 -1
  37. package/public/assets/{managed-agents-DsnYf6fm.js → managed-agents-DCFMWKqt.js} +2 -2
  38. package/public/assets/{managed-agents-DsnYf6fm.js.map → managed-agents-DCFMWKqt.js.map} +1 -1
  39. package/public/assets/{markdown-preview-impl-C1ZtGJp6.js → markdown-preview-impl-CfaR-GRX.js} +2 -2
  40. package/public/assets/{markdown-preview-impl-C1ZtGJp6.js.map → markdown-preview-impl-CfaR-GRX.js.map} +1 -1
  41. package/public/assets/{memory-SSLUTOI2.js → memory-BmkUEyd5.js} +2 -2
  42. package/public/assets/{memory-SSLUTOI2.js.map → memory-BmkUEyd5.js.map} +1 -1
  43. package/public/assets/{messages-BY26OP4K.js → messages-BQ_fVnSA.js} +2 -2
  44. package/public/assets/{messages-BY26OP4K.js.map → messages-BQ_fVnSA.js.map} +1 -1
  45. package/public/assets/{orchestrators-OY7yPzVI.js → orchestrators-DC7y9vQD.js} +2 -2
  46. package/public/assets/{orchestrators-OY7yPzVI.js.map → orchestrators-DC7y9vQD.js.map} +1 -1
  47. package/public/assets/{overview-Dj0u1tPG.js → overview-6lzywFHq.js} +2 -2
  48. package/public/assets/{overview-Dj0u1tPG.js.map → overview-6lzywFHq.js.map} +1 -1
  49. package/public/assets/{pairs-CrUR50ou.js → pairs-CRrRIaHI.js} +2 -2
  50. package/public/assets/{pairs-CrUR50ou.js.map → pairs-CRrRIaHI.js.map} +1 -1
  51. package/public/assets/{projects-B_AtxHtH.js → projects-CZJW35C7.js} +2 -2
  52. package/public/assets/{projects-B_AtxHtH.js.map → projects-CZJW35C7.js.map} +1 -1
  53. package/public/assets/{prompt-settings-CPtYSban.js → prompt-settings-DvlnMq8H.js} +2 -2
  54. package/public/assets/{prompt-settings-CPtYSban.js.map → prompt-settings-DvlnMq8H.js.map} +1 -1
  55. package/public/assets/{registry-BgUALWn5.js → registry-3TWg4zbE.js} +2 -2
  56. package/public/assets/{registry-BgUALWn5.js.map → registry-3TWg4zbE.js.map} +1 -1
  57. package/public/assets/{security-5lHfGk7g.js → security-A2VYKHeI.js} +2 -2
  58. package/public/assets/{security-5lHfGk7g.js.map → security-A2VYKHeI.js.map} +1 -1
  59. package/public/assets/{select-Cgx4QSLj.js → select-_gK5Ojjc.js} +2 -2
  60. package/public/assets/{select-Cgx4QSLj.js.map → select-_gK5Ojjc.js.map} +1 -1
  61. package/public/assets/{settings-DFGgxyun.js → settings-BZ26ewcu.js} +2 -2
  62. package/public/assets/{settings-DFGgxyun.js.map → settings-BZ26ewcu.js.map} +1 -1
  63. package/public/assets/store-nXGxgz2Q.js +9 -0
  64. package/public/assets/store-nXGxgz2Q.js.map +1 -0
  65. package/public/assets/{tasks-BvH5rluu.js → tasks-DlokSl_P.js} +2 -2
  66. package/public/assets/{tasks-BvH5rluu.js.map → tasks-DlokSl_P.js.map} +1 -1
  67. package/public/assets/{teams-VZOIOdoE.js → teams-t7M1HMgS.js} +2 -2
  68. package/public/assets/{teams-VZOIOdoE.js.map → teams-t7M1HMgS.js.map} +1 -1
  69. package/public/assets/{terminal-viewer-impl-DYY3Wmoe.js → terminal-viewer-impl-DxgrO-jH.js} +2 -2
  70. package/public/assets/{terminal-viewer-impl-DYY3Wmoe.js.map → terminal-viewer-impl-DxgrO-jH.js.map} +1 -1
  71. package/public/assets/types-uVOXgvMT.js.map +1 -1
  72. package/public/assets/{work-queue-B6Xk7QNe.js → work-queue-V8LR-3Gi.js} +2 -2
  73. package/public/assets/{work-queue-B6Xk7QNe.js.map → work-queue-V8LR-3Gi.js.map} +1 -1
  74. package/public/assets/{workspaces-CPIzAHAe.js → workspaces-DI317fMk.js} +2 -2
  75. package/public/assets/{workspaces-CPIzAHAe.js.map → workspaces-DI317fMk.js.map} +1 -1
  76. package/public/index.html +3 -3
  77. package/runner/plugins/claude/.claude-plugin/plugin.json +1 -1
  78. package/src/auto-spawn-dispatch.ts +2 -2
  79. package/src/db/index.ts +2 -0
  80. package/src/db/migrations.ts +2 -1
  81. package/src/db/provisioning-callmux.ts +180 -0
  82. package/src/db/provisioning-registry.ts +45 -374
  83. package/src/db/provisioning-resolve.ts +245 -0
  84. package/src/routes/workspaces.ts +58 -22
  85. package/src/task-dispatcher.ts +12 -3
  86. package/src/workspace-orphans.ts +5 -1
  87. package/public/assets/store-BM4zM89B.js +0 -9
  88. package/public/assets/store-BM4zM89B.js.map +0 -1
@@ -0,0 +1,245 @@
1
+ // Profile provisioning resolution lives outside provisioning-registry.ts so the
2
+ // registry CRUD/vendoring module stays under the file-size ratchet.
3
+
4
+ import { existsSync, readdirSync } from "node:fs";
5
+ import { join, resolve } from "node:path";
6
+ import { getManifest, type ProviderManifest } from "agent-relay-providers";
7
+ import { isRecord } from "agent-relay-sdk";
8
+ import type {
9
+ AgentProfile,
10
+ AgentProfileAssetRef,
11
+ AgentProfileProvider,
12
+ PluginAssetDefinition,
13
+ ProvisioningAssetKind,
14
+ ProvisioningMcpServer,
15
+ ProvisioningSkip,
16
+ ResolvedAssetDefinition,
17
+ ResolvedProvisioning,
18
+ SkillAssetDefinition,
19
+ SpawnProvider,
20
+ } from "agent-relay-sdk";
21
+ import { getDb } from "./connection.ts";
22
+ import { parseJson } from "../utils";
23
+
24
+ type ProviderBundledSkillSetDescriptor = NonNullable<NonNullable<ProviderManifest["provisioning"]>["bundledSkills"]>;
25
+
26
+ interface ProvisioningCapabilityResolutionRow {
27
+ id: string;
28
+ slug: string;
29
+ kind: ProvisioningAssetKind;
30
+ status: string;
31
+ }
32
+
33
+ interface ProvisioningAssetResolutionRow {
34
+ kind: ProvisioningAssetKind;
35
+ name: string;
36
+ provider: AgentProfileProvider;
37
+ definition: string;
38
+ enabled: number;
39
+ approval_status?: string | null;
40
+ validation_status?: string | null;
41
+ validation_errors?: string | null;
42
+ }
43
+
44
+ interface RegistryResolution {
45
+ definition: ResolvedAssetDefinition | null;
46
+ skip?: ProvisioningSkip;
47
+ }
48
+
49
+ export function bundledSkillRefs(descriptor: ProviderBundledSkillSetDescriptor): string[] {
50
+ const skillsDir = resolve(import.meta.dir, "../../", descriptor.dir);
51
+ const entryFile = descriptor.entryFile ?? "SKILL.md";
52
+ if (!existsSync(skillsDir)) return [];
53
+ return readdirSync(skillsDir, { withFileTypes: true }).filter((entry) => entry.isDirectory() && existsSync(join(skillsDir, entry.name, entryFile)))
54
+ .map((entry) => descriptor.refPrefix + entry.name).sort();
55
+ }
56
+
57
+ export function stringRecord(value: Record<string, unknown>): Record<string, string> {
58
+ const out: Record<string, string> = {};
59
+ for (const [k, v] of Object.entries(value)) {
60
+ if (typeof v === "string") out[k] = v;
61
+ }
62
+ return out;
63
+ }
64
+
65
+ function rowDefinition(row: ProvisioningAssetResolutionRow): ResolvedAssetDefinition {
66
+ return parseJson<ResolvedAssetDefinition>(row.definition, { kind: row.kind, name: row.name } as ResolvedAssetDefinition);
67
+ }
68
+
69
+ function skip(kind: ProvisioningAssetKind, ref: string, provider: SpawnProvider, reason: ProvisioningSkip["reason"], detail: string): ProvisioningSkip {
70
+ return { kind, ref, provider, reason, detail };
71
+ }
72
+
73
+ function variantSkipReason(row: ProvisioningAssetResolutionRow): Pick<ProvisioningSkip, "reason" | "detail"> | null {
74
+ if (row.enabled !== 1) return { reason: "disabled", detail: "Registry variant is disabled." };
75
+ if (row.approval_status !== "approved") return { reason: "unapproved", detail: `Registry variant approval_status is ${row.approval_status ?? "unset"}.` };
76
+ if (row.validation_status !== "valid") {
77
+ const errors = parseJson<unknown[]>(row.validation_errors ?? "[]", []);
78
+ const detail = errors.length ? `Registry variant validation_status is ${row.validation_status}; ${JSON.stringify(errors)}` : `Registry variant validation_status is ${row.validation_status ?? "unset"}.`;
79
+ return { reason: "invalid", detail };
80
+ }
81
+ return null;
82
+ }
83
+
84
+ // Resolve a registry ref for a target provider: an approved/valid/enabled exact
85
+ // provider match wins, else provider-neutral "any". MCP is intentionally
86
+ // provider-neutral in v1, so only "any" MCP variants are candidates.
87
+ function resolveRegistryDefinition(kind: ProvisioningAssetKind, ref: string, provider: SpawnProvider): RegistryResolution {
88
+ const capability = getDb()
89
+ .query("SELECT id, slug, kind, status FROM provisioning_capabilities WHERE kind = ? AND slug = ?")
90
+ .get(kind, ref) as ProvisioningCapabilityResolutionRow | undefined;
91
+ if (!capability) {
92
+ return { definition: null, skip: skip(kind, ref, provider, "missing", "No provisioning capability exists for this ref.") };
93
+ }
94
+
95
+ const rows = getDb()
96
+ .query("SELECT * FROM provisioning_assets WHERE capability_id = ? ORDER BY CASE provider WHEN ? THEN 0 WHEN 'any' THEN 1 ELSE 2 END, provider")
97
+ .all(capability.id, provider) as ProvisioningAssetResolutionRow[];
98
+ const providerCandidates = rows.filter((row) => kind === "mcp" ? row.provider === "any" : row.provider === provider || row.provider === "any");
99
+ if (!providerCandidates.length) {
100
+ const label = getManifest(provider)?.label ?? provider;
101
+ return { definition: null, skip: skip(kind, ref, provider, "provider_mismatch", `No ${label} variant exists for capability ${ref}.`) };
102
+ }
103
+
104
+ for (const row of providerCandidates) {
105
+ if (!variantSkipReason(row)) return { definition: rowDefinition(row) };
106
+ }
107
+
108
+ const blocked = variantSkipReason(providerCandidates[0]!);
109
+ return { definition: null, skip: skip(kind, ref, provider, blocked?.reason ?? "missing", blocked?.detail ?? "No resolvable registry variant exists.") };
110
+ }
111
+
112
+ // A ref applies to a launch iff it's enabled and its provider scope includes the
113
+ // target ("any"/undefined → all providers).
114
+ function refAppliesToProvider(ref: AgentProfileAssetRef, provider: SpawnProvider): boolean {
115
+ return ref.enabled && (!ref.provider || ref.provider === "any" || ref.provider === provider);
116
+ }
117
+
118
+ function hasAssetRef(refs: AgentProfileAssetRef[], source: AgentProfileAssetRef["source"], ref: string): boolean {
119
+ return refs.some((item) => item.source === source && item.ref === ref);
120
+ }
121
+
122
+ function relayBundledProvisioningRefs(profile: AgentProfile, provider: SpawnProvider): Pick<AgentProfile, "skills" | "plugins"> {
123
+ const skills = [...(profile.skills ?? [])];
124
+ const plugins = [...(profile.plugins ?? [])];
125
+ const provisioning = getManifest(provider)?.provisioning;
126
+ if (profile.relay.skills !== false && provisioning?.bundledSkills) {
127
+ for (const ref of bundledSkillRefs(provisioning.bundledSkills)) {
128
+ if (!hasAssetRef(skills, "relay", ref)) skills.push({ source: "relay", ref, enabled: true, provider });
129
+ }
130
+ }
131
+ if (profile.relay.plugins !== false && provisioning?.bundledPlugin && !hasAssetRef(plugins, "relay", provisioning.bundledPlugin.ref)) {
132
+ plugins.push({ source: "relay", ref: provisioning.bundledPlugin.ref, enabled: true, provider });
133
+ }
134
+ return { skills, plugins };
135
+ }
136
+
137
+ function withRelayBundledProvisioningRefs(profile: AgentProfile, provider: SpawnProvider): AgentProfile {
138
+ const { skills, plugins } = relayBundledProvisioningRefs(profile, provider);
139
+ return { ...profile, skills, plugins };
140
+ }
141
+
142
+ function inlineFilesFromMeta(meta: Record<string, unknown> | undefined): { path: string; content: string }[] | undefined {
143
+ const raw = meta?.files;
144
+ if (!Array.isArray(raw)) return undefined;
145
+ const files = raw
146
+ .filter(isRecord)
147
+ .filter((f) => typeof f.path === "string" && typeof f.content === "string")
148
+ .map((f) => ({ path: f.path as string, content: f.content as string }));
149
+ return files.length ? files : undefined;
150
+ }
151
+
152
+ // Resolve a skill/plugin asset ref into a concrete definition. relay → registry;
153
+ // inline → built from ref.meta (files or dir); repo → a cwd-relative dir token the
154
+ // runner resolves against the launch cwd; provider → skipped (provider built-in).
155
+ function resolveDirAssetRef(
156
+ kind: "skill" | "plugin",
157
+ ref: AgentProfileAssetRef,
158
+ provider: SpawnProvider,
159
+ ): { definition: SkillAssetDefinition | PluginAssetDefinition | null; skip?: ProvisioningSkip } {
160
+ if (ref.source === "relay") {
161
+ const resolved = resolveRegistryDefinition(kind, ref.ref, provider);
162
+ const def = resolved.definition;
163
+ return { definition: def && def.kind === kind ? def : null, skip: resolved.skip };
164
+ }
165
+ if (ref.source === "inline") {
166
+ const files = inlineFilesFromMeta(ref.meta);
167
+ const dir = typeof ref.meta?.dir === "string" ? ref.meta.dir : undefined;
168
+ if (!files && !dir) return { definition: null, skip: skip(kind, ref.ref, provider, "invalid_inline", `Inline ${kind} ref requires files or dir metadata.`) };
169
+ return { definition: { kind, name: ref.ref, ...(files ? { files } : {}), ...(dir ? { dir } : {}) } };
170
+ }
171
+ if (ref.source === "repo") {
172
+ return { definition: { kind, name: ref.ref, dir: ref.ref } };
173
+ }
174
+ return { definition: null };
175
+ }
176
+
177
+ // A profile.mcp.servers entry is either an inline server spec or a registry
178
+ // reference (`{ source: "relay", ref }`). Returns the concrete server spec.
179
+ function resolveMcpEntry(name: string, value: unknown, provider: SpawnProvider): { server: ProvisioningMcpServer | null; skip?: ProvisioningSkip } {
180
+ if (!isRecord(value)) return { server: null };
181
+ if (value.source === "relay" && typeof value.ref === "string") {
182
+ const resolved = resolveRegistryDefinition("mcp", value.ref, provider);
183
+ const def = resolved.definition;
184
+ return { server: def && def.kind === "mcp" ? def.server : null, skip: resolved.skip };
185
+ }
186
+ if (value.enabled === false) return { server: null };
187
+ // Inline spec: keep the recognized server fields only.
188
+ const server: ProvisioningMcpServer = {};
189
+ if (typeof value.type === "string") server.type = value.type as ProvisioningMcpServer["type"];
190
+ if (typeof value.command === "string") server.command = value.command;
191
+ if (Array.isArray(value.args)) server.args = value.args.filter((a): a is string => typeof a === "string");
192
+ if (isRecord(value.env)) server.env = stringRecord(value.env);
193
+ if (isRecord(value.configEnv)) server.configEnv = stringRecord(value.configEnv);
194
+ if (typeof value.url === "string") server.url = value.url;
195
+ if (isRecord(value.headers)) server.headers = stringRecord(value.headers);
196
+ return { server: server.command || server.url ? server : null, skip: server.command || server.url ? undefined : skip("mcp", name, provider, "invalid_inline", "Inline MCP server requires command or url.") };
197
+ }
198
+
199
+ // Resolve a profile's declared skill/plugin/MCP refs against the registry into the
200
+ // concrete bundle the runner materializes. Relay-side (needs the DB); the result is
201
+ // attached to the in-flight profile (profile.provisioning) before it's serialized
202
+ // into the spawn env. Pure read — never mutates the registry.
203
+ export function resolveProfileProvisioning(profile: AgentProfile, provider: SpawnProvider): ResolvedProvisioning {
204
+ const skills: SkillAssetDefinition[] = [];
205
+ const skipped: ProvisioningSkip[] = [];
206
+ for (const ref of profile.skills ?? []) {
207
+ if (!refAppliesToProvider(ref, provider)) {
208
+ if (ref.enabled && ref.source === "relay") skipped.push(skip("skill", ref.ref, provider, "provider_mismatch", `Profile ref is scoped to provider ${ref.provider}.`));
209
+ continue;
210
+ }
211
+ const resolved = resolveDirAssetRef("skill", ref, provider);
212
+ if (resolved.definition) skills.push(resolved.definition as SkillAssetDefinition);
213
+ else if (resolved.skip) skipped.push(resolved.skip);
214
+ }
215
+
216
+ const plugins: PluginAssetDefinition[] = [];
217
+ for (const ref of profile.plugins ?? []) {
218
+ if (!refAppliesToProvider(ref, provider)) {
219
+ if (ref.enabled && ref.source === "relay") skipped.push(skip("plugin", ref.ref, provider, "provider_mismatch", `Profile ref is scoped to provider ${ref.provider}.`));
220
+ continue;
221
+ }
222
+ const resolved = resolveDirAssetRef("plugin", ref, provider);
223
+ if (resolved.definition) plugins.push(resolved.definition as PluginAssetDefinition);
224
+ else if (resolved.skip) skipped.push(resolved.skip);
225
+ }
226
+
227
+ const mcpServers: Record<string, ProvisioningMcpServer> = {};
228
+ for (const [name, value] of Object.entries(profile.mcp?.servers ?? {})) {
229
+ const resolved = resolveMcpEntry(name, value, provider);
230
+ if (resolved.server) mcpServers[name] = resolved.server;
231
+ else if (resolved.skip) skipped.push(resolved.skip);
232
+ }
233
+
234
+ return { skills, plugins, mcpServers, ...(skipped.length ? { skipped } : {}) };
235
+ }
236
+
237
+ // Attach the resolved bundle to a spawn-bound profile. Returns a shallow copy so the
238
+ // cached config-store value is never mutated. undefined passes through (an unresolved
239
+ // profile name stays undefined so callers' not-found guards still fire); the runner
240
+ // treats an absent and an empty bundle identically.
241
+ export function withResolvedProvisioning(profile: AgentProfile | undefined, provider: SpawnProvider): AgentProfile | undefined {
242
+ if (!profile) return undefined;
243
+ const composed = withRelayBundledProvisioningRefs(profile, provider);
244
+ return { ...composed, provisioning: resolveProfileProvisioning(composed, provider) };
245
+ }
@@ -104,12 +104,27 @@ function latestWorkspaceByBranch(workspaces: WorkspaceRecord[]): Map<string, Wor
104
104
  return byBranch;
105
105
  }
106
106
 
107
- function owningOrchestratorForRepo(repoRoot: string) {
108
- return listOrchestrators().find((candidate) => candidate.status === "online" && candidate.apiUrl && isPathWithinBase(repoRoot, candidate.baseDir));
107
+ function owningOrchestratorsForRepo(repoRoot: string, orchestratorId?: string) {
108
+ const candidates = listOrchestrators().filter((candidate) => candidate.status === "online" && candidate.apiUrl);
109
+ if (orchestratorId) {
110
+ return candidates.filter((candidate) => candidate.id === orchestratorId);
111
+ }
112
+ const ownerIds = new Set(
113
+ listWorkspaces()
114
+ .filter((workspace) => workspace.orchestratorId && resolve(workspace.repoRoot) === resolve(repoRoot))
115
+ .map((workspace) => workspace.orchestratorId!),
116
+ );
117
+ const owners = candidates.filter((candidate) => ownerIds.has(candidate.id));
118
+ if (owners.length > 0) return owners;
119
+ const legacy = candidates.find((candidate) => isPathWithinBase(repoRoot, candidate.baseDir));
120
+ return legacy ? [legacy] : [];
121
+ }
122
+
123
+ function owningOrchestratorForRepo(repoRoot: string, orchestratorId?: string) {
124
+ return owningOrchestratorsForRepo(repoRoot, orchestratorId)[0];
109
125
  }
110
126
 
111
- async function fetchRecoveryBranchesForRepo(repoRoot: string, opts: { baseRef?: string; baseSha?: string } = {}): Promise<{ branches: WorkspaceRecoveryBranch[]; unavailable?: string }> {
112
- const orch = owningOrchestratorForRepo(repoRoot);
127
+ async function fetchRecoveryBranchesFromOrchestrator(repoRoot: string, orch: NonNullable<ReturnType<typeof owningOrchestratorForRepo>>, opts: { baseRef?: string; baseSha?: string } = {}): Promise<{ branches: WorkspaceRecoveryBranch[]; unavailable?: string }> {
113
128
  if (!orch?.apiUrl) return { branches: [], unavailable: "owning orchestrator offline" };
114
129
  const query = new URLSearchParams({ repoRoot });
115
130
  if (opts.baseRef) query.set("baseRef", opts.baseRef);
@@ -122,16 +137,36 @@ async function fetchRecoveryBranchesForRepo(repoRoot: string, opts: { baseRef?:
122
137
  if (!res.ok) return { branches: [], unavailable: `host returned ${res.status}` };
123
138
  const payload = await res.json() as unknown;
124
139
  if (!isRecord(payload) || !Array.isArray(payload.branches)) return { branches: [], unavailable: "host returned malformed recovery branch list" };
125
- return { branches: payload.branches.filter(isRecord) as unknown as WorkspaceRecoveryBranch[] };
140
+ return {
141
+ branches: (payload.branches.filter(isRecord) as unknown as WorkspaceRecoveryBranch[]).map((branch) => ({
142
+ ...branch,
143
+ orchestratorId: orch.id,
144
+ orchestratorAgentId: orch.agentId,
145
+ })),
146
+ };
126
147
  } catch (e) {
127
148
  return { branches: [], unavailable: `orchestrator unreachable: ${(e as Error).message}` };
128
149
  }
129
150
  }
130
151
 
152
+ async function fetchRecoveryBranchesForRepo(repoRoot: string, opts: { baseRef?: string; baseSha?: string; orchestratorId?: string } = {}): Promise<{ branches: WorkspaceRecoveryBranch[]; unavailable?: string }> {
153
+ const hosts = owningOrchestratorsForRepo(repoRoot, opts.orchestratorId);
154
+ if (hosts.length === 0) return { branches: [], unavailable: "owning orchestrator offline" };
155
+ const branches: WorkspaceRecoveryBranch[] = [];
156
+ const unavailable: string[] = [];
157
+ for (const orch of hosts) {
158
+ const fetched = await fetchRecoveryBranchesFromOrchestrator(repoRoot, orch, opts);
159
+ branches.push(...fetched.branches);
160
+ if (fetched.unavailable) unavailable.push(`${orch.id}: ${fetched.unavailable}`);
161
+ }
162
+ return { branches, ...(branches.length === 0 && unavailable.length > 0 ? { unavailable: unavailable.join("; ") } : {}) };
163
+ }
164
+
131
165
  export const getWorkspaceRecoveryBranches: Handler = async (req) => {
132
166
  try {
133
167
  const url = new URL(req.url);
134
168
  const repoRoot = cleanString(url.searchParams.get("repoRoot") ?? undefined, "repoRoot", { max: 1000 });
169
+ const orchestratorId = cleanString(url.searchParams.get("orchestratorId") ?? undefined, "orchestratorId", { max: 200 });
135
170
  const baseRef = cleanString(url.searchParams.get("baseRef") ?? undefined, "baseRef", { max: 240 });
136
171
  const baseSha = cleanString(url.searchParams.get("baseSha") ?? undefined, "baseSha", { max: 80 });
137
172
  const includeActive = url.searchParams.get("includeActive") === "1";
@@ -142,7 +177,7 @@ export const getWorkspaceRecoveryBranches: Handler = async (req) => {
142
177
  const unavailable: Record<string, string> = {};
143
178
 
144
179
  for (const repo of repoRoots) {
145
- const fetched = await fetchRecoveryBranchesForRepo(repo, { baseRef, baseSha });
180
+ const fetched = await fetchRecoveryBranchesForRepo(repo, { baseRef, baseSha, orchestratorId });
146
181
  if (fetched.unavailable) {
147
182
  unavailable[repo] = fetched.unavailable;
148
183
  continue;
@@ -186,6 +221,7 @@ export const postWorkspaceRecoveryBranchDiscard: Handler = async (req) => {
186
221
  if (!isRecord(parsed.body)) return error("body required");
187
222
  const repoRoot = cleanString(parsed.body.repoRoot, "repoRoot", { max: 1000 });
188
223
  const branch = cleanString(parsed.body.branch, "branch", { max: 240 });
224
+ const requestedOrchestratorId = cleanString(parsed.body.orchestratorId, "orchestratorId", { max: 200 });
189
225
  const baseRef = cleanString(parsed.body.baseRef, "baseRef", { max: 240 });
190
226
  const baseSha = cleanString(parsed.body.baseSha, "baseSha", { max: 80 });
191
227
  const reason = cleanString(parsed.body.reason, "reason", { max: 1000 });
@@ -193,11 +229,11 @@ export const postWorkspaceRecoveryBranchDiscard: Handler = async (req) => {
193
229
  if (!repoRoot || !branch) return error("repoRoot and branch required", 400);
194
230
  if (!branch.startsWith("agent/")) return error("only agent/* branches can be discarded through recovery cleanup", 400);
195
231
  if (force && !reason) return error("reason required for forced recovery branch discard", 400);
196
- const orch = owningOrchestratorForRepo(repoRoot);
232
+ const fetched = await fetchRecoveryBranchesForRepo(repoRoot, { baseRef, baseSha, orchestratorId: requestedOrchestratorId });
233
+ const target = fetched.branches.find((item) => item.branch === branch);
234
+ const orch = target?.orchestratorId ? owningOrchestratorForRepo(repoRoot, target.orchestratorId) : owningOrchestratorForRepo(repoRoot);
197
235
  if (!orch?.agentId) return error("no online orchestrator owns this repo", 409);
198
- const fetched = await fetchRecoveryBranchesForRepo(repoRoot, { baseRef, baseSha });
199
236
  if (fetched.unavailable) return error(fetched.unavailable, 409);
200
- const target = fetched.branches.find((item) => item.branch === branch);
201
237
  if (!target) return error("recovery branch not found", 404);
202
238
  if (target.safeToDelete !== true && !force) {
203
239
  return error(`branch holds work or cannot be proven safe (${target.preserveReason ?? `${target.unmergedAhead ?? target.ahead ?? "?"} unlanded commit(s)`}); land it first or pass force:true with reason to discard`, 409);
@@ -242,25 +278,25 @@ export const postWorkspaceOrphanReclaim: Handler = async (req) => {
242
278
  // Refuse to reclaim a path that still backs a live workspace row.
243
279
  const live = listWorkspaces().find((ws) => ws.worktreePath && resolve(ws.worktreePath) === resolve(worktreePath) && !TERMINAL_WORKSPACE_STATUSES.has(ws.status));
244
280
  if (live) return error(`path backs live workspace ${live.id}; clean it through the workspace, not orphan reclaim`, 409);
245
- const orch = listOrchestrators().find((candidate) => candidate.status === "online" && isPathWithinBase(repoRoot, candidate.baseDir));
246
- if (!orch) return error("no online orchestrator owns this path", 409);
247
281
  // Land-safety gate (#244): reclaim force-removes the worktree, so refuse when
248
282
  // it holds un-landed work unless the caller explicitly opts into discarding
249
283
  // it. Mirrors the scheduled reaper — never destroy work on uncertainty.
250
284
  let baseRef: string | undefined;
251
285
  let baseSha: string | undefined;
252
- if (!force) {
253
- const { orphans } = await collectWorkspaceOrphans();
254
- const target = orphans.find((o) => resolve(o.worktreePath) === resolve(worktreePath));
255
- if (target && target.safeToReap !== true) {
256
- const why = target.safeToReap === undefined
257
- ? "land-state could not be determined"
258
- : target.dirty ? "uncommitted changes" : `${target.unmergedAhead ?? target.ahead ?? "?"} un-landed commit(s)`;
259
- return error(`worktree holds un-landed work (${why}); recover it first, or pass {"force":true} to discard`, 409);
260
- }
261
- baseRef = target?.baseRef;
262
- baseSha = target?.baseSha;
286
+ const { orphans } = await collectWorkspaceOrphans();
287
+ const target = orphans.find((o) => resolve(o.worktreePath) === resolve(worktreePath));
288
+ if (target && !force && target.safeToReap !== true) {
289
+ const why = target.safeToReap === undefined
290
+ ? "land-state could not be determined"
291
+ : target.dirty ? "uncommitted changes" : `${target.unmergedAhead ?? target.ahead ?? "?"} un-landed commit(s)`;
292
+ return error(`worktree holds un-landed work (${why}); recover it first, or pass {"force":true} to discard`, 409);
263
293
  }
294
+ baseRef = target?.baseRef;
295
+ baseSha = target?.baseSha;
296
+ const orch = target?.orchestratorId
297
+ ? listOrchestrators().find((candidate) => candidate.id === target.orchestratorId && candidate.status === "online")
298
+ : listOrchestrators().find((candidate) => candidate.status === "online" && isPathWithinBase(repoRoot, candidate.baseDir));
299
+ if (!orch) return error("no online orchestrator owns this path", 409);
264
300
  const command = createCommand({
265
301
  type: "workspace.cleanup",
266
302
  source: "system",
@@ -382,7 +382,8 @@ function rerouteForBand(
382
382
  // no burn data yet. Prefer "prefer" band, then "normal"; never a blocked one.
383
383
  //
384
384
  // When mustResolveAvailable is set (#901 degrade-never-refuse), widens search in tiers when
385
- // no prefer/normal provider is available: caution → avoid-large → least-utilized blockedNow.
385
+ // no prefer/normal provider is available: caution → avoid-large → hard-avoid-but-not-blocked
386
+ // → least-utilized blockedNow.
386
387
  // A `degraded` flag + `degradedReason` signals to the caller to emit a louder warning.
387
388
  function healthiestAlternative(
388
389
  avoid: SpawnProvider,
@@ -415,11 +416,19 @@ function healthiestAlternative(
415
416
  .sort((a, b) => bandRank(a.band) - bandRank(b.band));
416
417
  if (avoidLargeAlt[0]) return { ...avoidLargeAlt[0], degraded: true, degradedReason: "avoid-large band (tier 2 degraded)" };
417
418
 
418
- // Tier 3 degradation: least-utilized blockedNow (last resort emit the loudest warning).
419
+ // Tier 3 degradation: hard-avoid is still better than a provider that is actively blocked.
420
+ // This is the exact shape a release auto-heal can hit: Claude session limit is blockedNow,
421
+ // Codex is over pace (hard-avoid) but still runnable.
422
+ const hardAvoidAlt = all
423
+ .filter((c) => !c.blockedNow && c.band === "hard-avoid")
424
+ .sort((a, b) => bandRank(a.band) - bandRank(b.band));
425
+ if (hardAvoidAlt[0]) return { ...hardAvoidAlt[0], degraded: true, degradedReason: "hard-avoid band (tier 3 degraded)" };
426
+
427
+ // Tier 4 degradation: least-utilized blockedNow (last resort — emit the loudest warning).
419
428
  // Sort by bandRank so the healthiest (lowest penalty) blocked provider is picked first,
420
429
  // not whichever happens to be earliest in catalog order.
421
430
  const blockedAlts = all.filter((c) => c.blockedNow).sort((a, b) => bandRank(a.band) - bandRank(b.band));
422
- if (blockedAlts[0]) return { ...blockedAlts[0], degraded: true, degradedReason: "blockedNow provider (tier 3 last-resort — ALL providers at capacity)" };
431
+ if (blockedAlts[0]) return { ...blockedAlts[0], degraded: true, degradedReason: "blockedNow provider (tier 4 last-resort — ALL providers at capacity)" };
423
432
 
424
433
  return undefined;
425
434
  }
@@ -252,6 +252,8 @@ export async function collectWorkspaceOrphans(): Promise<CollectOrphansResult> {
252
252
  const orphan: WorkspaceOrphan = {
253
253
  worktreePath: worktree.path,
254
254
  repoRoot,
255
+ orchestratorId: orch.id,
256
+ orchestratorAgentId: orch.agentId,
255
257
  branch: worktree.branch,
256
258
  headSha: worktree.headSha,
257
259
  baseRef: probe.branch,
@@ -366,7 +368,9 @@ export async function reapOrphanedWorktrees(): Promise<Record<string, unknown>>
366
368
  const liveCwds = liveAgentCwds();
367
369
 
368
370
  for (const orphan of orphans) {
369
- const orch = orchestrators.find((candidate) => isPathWithinBase(orphan.repoRoot, candidate.baseDir));
371
+ const orch = orphan.orchestratorId
372
+ ? orchestrators.find((candidate) => candidate.id === orphan.orchestratorId)
373
+ : orchestrators.find((candidate) => isPathWithinBase(orphan.repoRoot, candidate.baseDir));
370
374
  if (!orch) continue;
371
375
 
372
376
  if (orphan.safeToReap === true) {