agent-relay-server 0.6.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/routes.ts CHANGED
@@ -775,6 +775,44 @@ function auditEvent(input: ActivityEventInput): void {
775
775
  }
776
776
  }
777
777
 
778
+ type AgentActivityState = {
779
+ key: "available" | "busy" | "not-ready" | "offline";
780
+ title: string;
781
+ icon: string;
782
+ };
783
+
784
+ function agentActivityState(agent?: Pick<AgentCard, "ready" | "status"> | null): AgentActivityState | null {
785
+ if (!agent) return null;
786
+ if (agent.status === "offline") return { key: "offline", title: "Agent offline", icon: "ti-plug-off" };
787
+ if (!agent.ready) return { key: "not-ready", title: "Agent not ready", icon: "ti-loader" };
788
+ if (agent.status === "busy") return { key: "busy", title: "Agent busy", icon: "ti-activity" };
789
+ return { key: "available", title: "Agent available", icon: "ti-circle-check" };
790
+ }
791
+
792
+ function auditAgentStateTransition(agentId: string, before: AgentCard | null | undefined, after: AgentCard | null | undefined): void {
793
+ const previous = agentActivityState(before);
794
+ const next = agentActivityState(after);
795
+ if (!next || previous?.key === next.key) return;
796
+ auditEvent({
797
+ clientId: "server-agent-" + agentId + "-state-" + next.key + "-" + Date.now(),
798
+ kind: "state",
799
+ title: next.title,
800
+ meta: agentId,
801
+ icon: next.icon,
802
+ view: "agents",
803
+ agentId,
804
+ });
805
+ }
806
+
807
+ function orchestratorControlMeta(control: Record<string, unknown>): Record<string, unknown> {
808
+ return {
809
+ delivery: "interrupt",
810
+ priority: "urgent",
811
+ // Legacy orchestrators read control details from message meta.
812
+ orchestratorControl: control,
813
+ };
814
+ }
815
+
778
816
  // --- Agent routes ---
779
817
 
780
818
  const postAgent: Handler = async (req) => {
@@ -836,21 +874,14 @@ const patchAgentStatus: Handler = async (req, params) => {
836
874
  const guard = normalizeAgentSessionGuard(req, body);
837
875
  const session = validateAgentSession(params.id!, guard);
838
876
  if (!session.ok) return error(session.error!, agentSessionStatus(session.error));
877
+ const before = getAgent(params.id!);
839
878
  if (!setStatus(params.id!, body.status as any, guard)) return error("agent not found", 404);
879
+ auditAgentStateTransition(params.id!, before, getAgent(params.id!));
840
880
  } catch (e) {
841
881
  if (e instanceof ValidationError) return error(e.message, 400);
842
882
  throw e;
843
883
  }
844
884
  emitAgentStatus(params.id!);
845
- auditEvent({
846
- clientId: "server-agent-" + params.id! + "-status-" + body.status + "-" + Date.now(),
847
- kind: "state",
848
- title: "Agent " + body.status,
849
- meta: params.id!,
850
- icon: body.status === "offline" ? "ti-plug-off" : "ti-activity",
851
- view: "agents",
852
- agentId: params.id!,
853
- });
854
885
  return json({ ok: true });
855
886
  };
856
887
 
@@ -903,21 +934,14 @@ const patchAgentReady: Handler = async (req, params) => {
903
934
  const guard = normalizeAgentSessionGuard(req, body);
904
935
  const session = validateAgentSession(params.id!, guard);
905
936
  if (!session.ok) return error(session.error!, agentSessionStatus(session.error));
937
+ const before = getAgent(params.id!);
906
938
  if (!markReady(params.id!, body.ready, guard)) return error("agent not found", 404);
939
+ auditAgentStateTransition(params.id!, before, getAgent(params.id!));
907
940
  } catch (e) {
908
941
  if (e instanceof ValidationError) return error(e.message, 400);
909
942
  throw e;
910
943
  }
911
944
  emitAgentStatus(params.id!);
912
- auditEvent({
913
- clientId: "server-agent-" + params.id! + "-ready-" + body.ready + "-" + Date.now(),
914
- kind: "state",
915
- title: body.ready ? "Agent ready" : "Agent not ready",
916
- meta: params.id!,
917
- icon: body.ready ? "ti-circle-check" : "ti-loader",
918
- view: "agents",
919
- agentId: params.id!,
920
- });
921
945
  return json({ ok: true });
922
946
  };
923
947
 
@@ -1008,17 +1032,15 @@ const postAgentSpawn: Handler = async (req) => {
1008
1032
  if (orchestrators.length > 0) {
1009
1033
  // Route through the first available orchestrator
1010
1034
  const orch = orchestrators[0]!;
1035
+ const control = { action: "spawn", provider, cwd, label, approvalMode, requestedBy: "dashboard", requestedAt: Date.now() };
1011
1036
  const msg = sendMessage({
1012
1037
  from: "system",
1013
1038
  to: orch.agentId,
1014
1039
  kind: "control",
1015
1040
  subject: "Spawn agent",
1016
1041
  body: `Spawn ${provider} agent${label ? ` (${label})` : ""}`,
1017
- payload: { orchestratorControl: { action: "spawn", provider, cwd, label, approvalMode, requestedBy: "dashboard", requestedAt: Date.now() } },
1018
- meta: {
1019
- delivery: "interrupt",
1020
- priority: "urgent",
1021
- },
1042
+ payload: { orchestratorControl: control },
1043
+ meta: orchestratorControlMeta(control),
1022
1044
  });
1023
1045
  emitNewMessage(msg);
1024
1046
  auditEvent({
@@ -1104,8 +1126,16 @@ const postOrchestrator: Handler = async (req) => {
1104
1126
  }
1105
1127
  }
1106
1128
  const envKeys = cleanStringArray(parsed.body.envKeys, "envKeys");
1129
+ const version = cleanString(parsed.body.version, "version", { max: 80 });
1130
+ const gitSha = cleanString(parsed.body.gitSha, "gitSha", { max: 80 });
1131
+ const protocolVersionRaw = parsed.body.protocolVersion;
1132
+ const protocolVersion = protocolVersionRaw === undefined
1133
+ ? undefined
1134
+ : typeof protocolVersionRaw === "number" && Number.isInteger(protocolVersionRaw) && protocolVersionRaw > 0
1135
+ ? protocolVersionRaw
1136
+ : (() => { throw new ValidationError("protocolVersion must be a positive integer"); })();
1107
1137
  const meta = cleanMeta(parsed.body.meta);
1108
- const orch = upsertOrchestrator({ id, hostname, providers: providers ?? ["claude", "codex"], baseDir, envKeys, meta });
1138
+ const orch = upsertOrchestrator({ id, hostname, providers: providers ?? ["claude", "codex"], baseDir, envKeys, version, protocolVersion, gitSha, meta });
1109
1139
  auditEvent({
1110
1140
  clientId: "server-orchestrator-register-" + id + "-" + Date.now(),
1111
1141
  kind: "state",
@@ -1192,6 +1222,16 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
1192
1222
  const prompt = cleanString(parsed.body.prompt, "prompt", { max: 4000 });
1193
1223
 
1194
1224
  // Send control message to orchestrator's agent inbox
1225
+ const control = {
1226
+ action: "spawn",
1227
+ provider,
1228
+ cwd: cwd || orch.baseDir,
1229
+ label,
1230
+ approvalMode,
1231
+ prompt,
1232
+ requestedBy: "dashboard",
1233
+ requestedAt: Date.now(),
1234
+ };
1195
1235
  const msg = sendMessage({
1196
1236
  from: "system",
1197
1237
  to: orch.agentId,
@@ -1199,21 +1239,9 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
1199
1239
  subject: "Spawn agent",
1200
1240
  body: `Spawn ${provider} agent${label ? ` (${label})` : ""}`,
1201
1241
  payload: {
1202
- orchestratorControl: {
1203
- action: "spawn",
1204
- provider,
1205
- cwd: cwd || orch.baseDir,
1206
- label,
1207
- approvalMode,
1208
- prompt,
1209
- requestedBy: "dashboard",
1210
- requestedAt: Date.now(),
1211
- },
1212
- },
1213
- meta: {
1214
- delivery: "interrupt",
1215
- priority: "urgent",
1242
+ orchestratorControl: control,
1216
1243
  },
1244
+ meta: orchestratorControlMeta(control),
1217
1245
  });
1218
1246
  emitNewMessage(msg);
1219
1247
  auditEvent({
@@ -1245,6 +1273,12 @@ const postOrchestratorAction: Handler = async (req, params) => {
1245
1273
  if (!action) return error("action required");
1246
1274
  const agentId = cleanString(parsed.body.agentId, "agentId", { max: 240 });
1247
1275
 
1276
+ const control = {
1277
+ action,
1278
+ agentId,
1279
+ requestedBy: "dashboard",
1280
+ requestedAt: Date.now(),
1281
+ };
1248
1282
  const msg = sendMessage({
1249
1283
  from: "system",
1250
1284
  to: orch.agentId,
@@ -1252,17 +1286,9 @@ const postOrchestratorAction: Handler = async (req, params) => {
1252
1286
  subject: action === "restart" ? "Restart agent" : "Shutdown agent",
1253
1287
  body: agentId ? `${action} ${agentId}` : `${action} all managed agents`,
1254
1288
  payload: {
1255
- orchestratorControl: {
1256
- action,
1257
- agentId,
1258
- requestedBy: "dashboard",
1259
- requestedAt: Date.now(),
1260
- },
1261
- },
1262
- meta: {
1263
- delivery: "interrupt",
1264
- priority: "urgent",
1289
+ orchestratorControl: control,
1265
1290
  },
1291
+ meta: orchestratorControlMeta(control),
1266
1292
  });
1267
1293
  emitNewMessage(msg);
1268
1294
  auditEvent({
package/src/types.ts CHANGED
@@ -411,12 +411,25 @@ export interface Orchestrator {
411
411
  providers: SpawnProvider[];
412
412
  baseDir: string;
413
413
  envKeys: string[]; // names only, never values
414
+ version?: string;
415
+ protocolVersion?: number;
416
+ gitSha?: string;
417
+ health?: OrchestratorHealth;
414
418
  meta: Record<string, unknown>;
415
419
  managedAgents: ManagedAgent[];
416
420
  lastSeen: number;
417
421
  createdAt: number;
418
422
  }
419
423
 
424
+ export interface OrchestratorHealth {
425
+ status: "ok" | "warn" | "error";
426
+ restartRequired: boolean;
427
+ issues: Array<{
428
+ code: "missing-version" | "outdated" | "protocol-mismatch" | "restart-required";
429
+ detail: string;
430
+ }>;
431
+ }
432
+
420
433
  export interface ManagedAgent {
421
434
  agentId: string;
422
435
  provider: SpawnProvider;
@@ -434,6 +447,9 @@ export interface RegisterOrchestratorInput {
434
447
  providers: SpawnProvider[];
435
448
  baseDir: string;
436
449
  envKeys?: string[];
450
+ version?: string;
451
+ protocolVersion?: number;
452
+ gitSha?: string;
437
453
  meta?: Record<string, unknown>;
438
454
  }
439
455
 
package/src/upgrade.ts CHANGED
@@ -3,7 +3,7 @@ import { homedir } from "node:os";
3
3
  import { join } from "node:path";
4
4
  import { VERSION } from "./config";
5
5
 
6
- export type UpgradeProvider = "auto" | "all" | "codex" | "claude";
6
+ export type UpgradeProvider = "auto" | "all" | "codex" | "claude" | "orchestrator";
7
7
 
8
8
  type UpgradeOptions = {
9
9
  targetVersion?: string;
@@ -28,12 +28,16 @@ export type UpgradeSnapshot = {
28
28
  targetVersion: string;
29
29
  serverPackage?: InstalledPackage;
30
30
  codexPackage?: InstalledPackage;
31
+ orchestratorPackage?: InstalledPackage;
31
32
  codexCopiedPackage?: InstalledPackage;
32
33
  claudePluginInstalls: ClaudePluginInstall[];
33
34
  hasCodexCommand: boolean;
35
+ hasOrchestratorCommand: boolean;
34
36
  hasClaudeCommand: boolean;
35
37
  hasSystemdUserService: boolean;
38
+ hasSystemdUserOrchestratorService: boolean;
36
39
  runningServerVersion?: string;
40
+ runningOrchestrators?: Array<{ id: string; version?: string; protocolVersion?: number; health?: { status?: string; restartRequired?: boolean } }>;
37
41
  packageManager: "bun" | "npm" | "none";
38
42
  };
39
43
 
@@ -46,7 +50,7 @@ type UpgradeAction = {
46
50
 
47
51
  type UpgradePlan = {
48
52
  targetVersion: string;
49
- providers: { codex: boolean; claude: boolean };
53
+ providers: { codex: boolean; claude: boolean; orchestrator: boolean };
50
54
  snapshot: UpgradeSnapshot;
51
55
  actions: UpgradeAction[];
52
56
  warnings: string[];
@@ -67,9 +71,9 @@ export async function detectUpgradeSnapshot(options: UpgradeOptions = {}): Promi
67
71
  const codexCopiedPackage = readPackageVersion(join(homeDir(), ".agent-relay", "codex", "package", "package.json"));
68
72
  const claudePluginInstalls = readClaudePluginInstalls(join(homeDir(), ".claude", "plugins", "installed_plugins.json"));
69
73
 
70
- const packageManager = bunPackages.has("agent-relay-server") || bunPackages.has("agent-relay-codex")
74
+ const packageManager = bunPackages.has("agent-relay-server") || bunPackages.has("agent-relay-codex") || bunPackages.has("agent-relay-orchestrator")
71
75
  ? "bun"
72
- : npmPackages.has("agent-relay-server") || npmPackages.has("agent-relay-codex")
76
+ : npmPackages.has("agent-relay-server") || npmPackages.has("agent-relay-codex") || npmPackages.has("agent-relay-orchestrator")
73
77
  ? "npm"
74
78
  : commandExists("bun")
75
79
  ? "bun"
@@ -81,14 +85,18 @@ export async function detectUpgradeSnapshot(options: UpgradeOptions = {}): Promi
81
85
  targetVersion,
82
86
  serverPackage: installedPackage("agent-relay-server", bunPackages, npmPackages),
83
87
  codexPackage: installedPackage("agent-relay-codex", bunPackages, npmPackages),
88
+ orchestratorPackage: installedPackage("agent-relay-orchestrator", bunPackages, npmPackages),
84
89
  codexCopiedPackage: codexCopiedPackage
85
90
  ? { version: codexCopiedPackage, source: "copied", path: join(homeDir(), ".agent-relay", "codex", "package") }
86
91
  : undefined,
87
92
  claudePluginInstalls,
88
93
  hasCodexCommand: commandExists("agent-relay-codex") || commandExists("codex-relay") || existsSync(join(homeDir(), ".agent-relay", "codex", "package")),
94
+ hasOrchestratorCommand: commandExists("agent-relay-orchestrator"),
89
95
  hasClaudeCommand: commandExists("claude"),
90
96
  hasSystemdUserService: hasSystemdUserService("agent-relay.service"),
97
+ hasSystemdUserOrchestratorService: hasSystemdUserService("agent-relay-orchestrator.service"),
91
98
  runningServerVersion: await runningServerVersion(),
99
+ runningOrchestrators: await runningOrchestrators(),
92
100
  packageManager,
93
101
  };
94
102
  }
@@ -99,6 +107,7 @@ export function createUpgradePlan(snapshot: UpgradeSnapshot, options: UpgradeOpt
99
107
  const providerSet = new Set(requestedProviders);
100
108
  const codexRequested = providerSet.has("all") || providerSet.has("codex") || (providerSet.has("auto") && isCodexDetected(snapshot));
101
109
  const claudeRequested = providerSet.has("all") || providerSet.has("claude") || (providerSet.has("auto") && isClaudeRelayDetected(snapshot));
110
+ const orchestratorRequested = providerSet.has("all") || providerSet.has("orchestrator") || (providerSet.has("auto") && isOrchestratorDetected(snapshot));
102
111
  const actions: UpgradeAction[] = [];
103
112
  const warnings: string[] = [];
104
113
 
@@ -107,13 +116,14 @@ export function createUpgradePlan(snapshot: UpgradeSnapshot, options: UpgradeOpt
107
116
  } else {
108
117
  const packages = [`agent-relay-server@${targetVersion}`];
109
118
  if (codexRequested) packages.push(`agent-relay-codex@${targetVersion}`);
119
+ if (orchestratorRequested) packages.push(`agent-relay-orchestrator@${targetVersion}`);
110
120
  const command = snapshot.packageManager === "bun"
111
121
  ? ["bun", "add", "-g", ...packages]
112
122
  : ["npm", "install", "-g", ...packages];
113
123
  actions.push({
114
124
  label: "Upgrade global packages",
115
125
  command,
116
- reason: `Update server${codexRequested ? " and Codex integration" : ""} packages to ${targetVersion}.`,
126
+ reason: `Update server${codexRequested ? ", Codex integration" : ""}${orchestratorRequested ? ", and orchestrator" : ""} packages to ${targetVersion}.`,
117
127
  mutates: true,
118
128
  });
119
129
  }
@@ -148,6 +158,10 @@ export function createUpgradePlan(snapshot: UpgradeSnapshot, options: UpgradeOpt
148
158
  warnings.push("Claude Agent Relay plugin not detected; skipping Claude plugin upgrade.");
149
159
  }
150
160
 
161
+ if (!orchestratorRequested && providerSet.has("auto") && !isOrchestratorDetected(snapshot)) {
162
+ warnings.push("Agent Relay orchestrator not detected; skipping orchestrator package upgrade.");
163
+ }
164
+
151
165
  if (snapshot.hasSystemdUserService) {
152
166
  if (options.noRestart) {
153
167
  warnings.push("agent-relay.service detected but --no-restart was set; restart manually to run the upgraded server.");
@@ -163,9 +177,24 @@ export function createUpgradePlan(snapshot: UpgradeSnapshot, options: UpgradeOpt
163
177
  warnings.push("No systemd user service detected; restart any manually running Agent Relay server yourself.");
164
178
  }
165
179
 
180
+ if (snapshot.hasSystemdUserOrchestratorService) {
181
+ if (options.noRestart) {
182
+ warnings.push("agent-relay-orchestrator.service detected but --no-restart was set; restart manually to run the upgraded orchestrator.");
183
+ } else {
184
+ actions.push({
185
+ label: "Restart Agent Relay orchestrator service",
186
+ command: ["systemctl", "--user", "restart", "agent-relay-orchestrator.service"],
187
+ reason: "Restart orchestrator daemon so host lifecycle control uses the upgraded protocol.",
188
+ mutates: true,
189
+ });
190
+ }
191
+ } else if (orchestratorRequested) {
192
+ warnings.push("No agent-relay-orchestrator.service detected; restart any manually running orchestrator yourself.");
193
+ }
194
+
166
195
  return {
167
196
  targetVersion,
168
- providers: { codex: codexRequested, claude: claudeRequested },
197
+ providers: { codex: codexRequested, claude: claudeRequested, orchestrator: orchestratorRequested },
169
198
  snapshot: { ...snapshot, targetVersion },
170
199
  actions,
171
200
  warnings,
@@ -194,6 +223,15 @@ export async function executeUpgradePlan(plan: UpgradePlan, options: { dryRun?:
194
223
  }
195
224
  if (serverVersion) lines.push(`Running server: ${serverVersion}`);
196
225
  }
226
+ if (plan.actions.some((action) => action.command.join(" ") === "systemctl --user restart agent-relay-orchestrator.service")) {
227
+ await new Promise((resolve) => setTimeout(resolve, 1000));
228
+ const orchestrators = await runningOrchestrators() ?? [];
229
+ const mismatched = orchestrators.filter((orch) => orch.version && orch.version !== plan.targetVersion);
230
+ if (mismatched.length > 0) {
231
+ throw new Error(`agent-relay-orchestrator.service restarted but ${mismatched.map((orch) => `${orch.id} reports ${orch.version}`).join(", ")}, expected ${plan.targetVersion}`);
232
+ }
233
+ if (orchestrators.length > 0) lines.push(`Running orchestrator(s): ${orchestrators.map((orch) => `${orch.id}${orch.version ? ` ${orch.version}` : ""}`).join(", ")}`);
234
+ }
197
235
  lines.push("\nUpgrade commands completed.");
198
236
  if (plan.warnings.length > 0) {
199
237
  lines.push("\nWarnings:");
@@ -211,11 +249,14 @@ export function formatUpgradePlan(plan: UpgradePlan, options: { dryRun?: boolean
211
249
  `- running server: ${plan.snapshot.runningServerVersion ?? "unknown"}`,
212
250
  `- codex package: ${formatPackage(plan.snapshot.codexPackage)}`,
213
251
  `- codex copied package: ${formatPackage(plan.snapshot.codexCopiedPackage)}`,
252
+ `- orchestrator package: ${formatPackage(plan.snapshot.orchestratorPackage)}`,
253
+ `- running orchestrators: ${formatRunningOrchestrators(plan.snapshot.runningOrchestrators)}`,
214
254
  `- claude command: ${plan.snapshot.hasClaudeCommand ? "yes" : "no"}`,
215
255
  `- claude agent-relay plugin: ${formatClaudePlugins(plan.snapshot.claudePluginInstalls)}`,
216
256
  `- systemd user service: ${plan.snapshot.hasSystemdUserService ? "yes" : "no"}`,
257
+ `- orchestrator systemd user service: ${plan.snapshot.hasSystemdUserOrchestratorService ? "yes" : "no"}`,
217
258
  "",
218
- `Providers: codex=${plan.providers.codex ? "yes" : "no"}, claude=${plan.providers.claude ? "yes" : "no"}`,
259
+ `Providers: codex=${plan.providers.codex ? "yes" : "no"}, claude=${plan.providers.claude ? "yes" : "no"}, orchestrator=${plan.providers.orchestrator ? "yes" : "no"}`,
219
260
  "",
220
261
  "Actions:",
221
262
  ];
@@ -239,6 +280,10 @@ function isClaudeRelayDetected(snapshot: UpgradeSnapshot): boolean {
239
280
  return snapshot.claudePluginInstalls.length > 0;
240
281
  }
241
282
 
283
+ function isOrchestratorDetected(snapshot: UpgradeSnapshot): boolean {
284
+ return Boolean(snapshot.orchestratorPackage || snapshot.hasOrchestratorCommand || snapshot.hasSystemdUserOrchestratorService || (snapshot.runningOrchestrators?.length ?? 0) > 0);
285
+ }
286
+
242
287
  function installedPackage(name: string, bunPackages: Map<string, string>, npmPackages: Map<string, string>): InstalledPackage | undefined {
243
288
  const bunVersion = bunPackages.get(name);
244
289
  if (bunVersion) return { version: bunVersion, source: "bun" };
@@ -292,6 +337,27 @@ async function runningServerVersion(): Promise<string | undefined> {
292
337
  }
293
338
  }
294
339
 
340
+ async function runningOrchestrators(): Promise<UpgradeSnapshot["runningOrchestrators"]> {
341
+ try {
342
+ const headers: Record<string, string> = {};
343
+ if (process.env.AGENT_RELAY_TOKEN) headers["X-Agent-Relay-Token"] = process.env.AGENT_RELAY_TOKEN;
344
+ const relayUrl = (process.env.AGENT_RELAY_URL || "http://127.0.0.1:4850").replace(/\/+$/, "");
345
+ const response = await fetch(`${relayUrl}/api/orchestrators`, { headers });
346
+ if (!response.ok) return [];
347
+ const payload = await response.json() as Array<{ id?: string; version?: string; protocolVersion?: number; health?: { status?: string; restartRequired?: boolean } }>;
348
+ return payload
349
+ .filter((orch) => typeof orch.id === "string")
350
+ .map((orch) => ({
351
+ id: orch.id!,
352
+ version: orch.version,
353
+ protocolVersion: orch.protocolVersion,
354
+ health: orch.health,
355
+ }));
356
+ } catch {
357
+ return [];
358
+ }
359
+ }
360
+
295
361
  function hasSystemdUserService(name: string): boolean {
296
362
  if (!commandExists("systemctl")) return false;
297
363
  const result = runCommand(["systemctl", "--user", "status", name, "--no-pager"]);
@@ -372,6 +438,13 @@ function formatClaudePlugins(installs: ClaudePluginInstall[]): string {
372
438
  return installs.map((install) => `${install.version ?? "unknown"} (${install.scope ?? "user"})`).join(", ");
373
439
  }
374
440
 
441
+ function formatRunningOrchestrators(orchestrators: UpgradeSnapshot["runningOrchestrators"]): string {
442
+ if (!orchestrators?.length) return "not detected";
443
+ return orchestrators
444
+ .map((orch) => `${orch.id}: ${orch.version ?? "unknown"}${orch.health?.restartRequired ? " (restart required)" : ""}`)
445
+ .join(", ");
446
+ }
447
+
375
448
  function shellQuote(value: string): string {
376
449
  if (/^[a-zA-Z0-9_@%+=:,./-]+$/.test(value)) return value;
377
450
  return `'${value.replaceAll("'", "'\\''")}'`;