agent-relay-server 0.18.0 → 0.19.1

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-server",
3
- "version": "0.18.0",
3
+ "version": "0.19.1",
4
4
  "description": "Lightweight HTTP message relay for inter-agent communication across machines",
5
5
  "module": "src/index.ts",
6
6
  "type": "module",
@@ -33,7 +33,7 @@
33
33
  "CONTRIBUTING.md"
34
34
  ],
35
35
  "dependencies": {
36
- "agent-relay-sdk": "0.2.9"
36
+ "agent-relay-sdk": "0.2.10"
37
37
  },
38
38
  "scripts": {
39
39
  "prepack": "bun run build:dashboard:bundle >&2",
package/public/index.html CHANGED
@@ -10830,6 +10830,10 @@ function stringValue(value) {
10830
10830
  const trimmed = value.trim();
10831
10831
  return trimmed.length > 0 ? trimmed : void 0;
10832
10832
  }
10833
+ /** Extract a human-readable message from any thrown value. */
10834
+ function errMessage(error) {
10835
+ return error instanceof Error ? error.message : String(error);
10836
+ }
10833
10837
  //#endregion
10834
10838
  //#region src/lib/display.ts
10835
10839
  function toTimestamp(value) {
@@ -123777,7 +123781,7 @@ function TerminalViewer({ orchestratorId, session, interactive: initialInteracti
123777
123781
  try {
123778
123782
  writeSnapshot(await apiCall("GET", `/orchestrators/${encodeURIComponent(orchestratorId)}/terminal/${encodeURIComponent(session)}`));
123779
123783
  } catch (e) {
123780
- setError(e instanceof Error ? e.message : String(e));
123784
+ setError(errMessage(e));
123781
123785
  } finally {
123782
123786
  inFlightRef.current = false;
123783
123787
  }
@@ -123834,7 +123838,7 @@ function TerminalViewer({ orchestratorId, session, interactive: initialInteracti
123834
123838
  for (const chunk of chunks) inputQueueRef.current = inputQueueRef.current.then(() => apiCall("POST", `/orchestrators/${encodeURIComponent(orchestratorId)}/terminal/${encodeURIComponent(session)}/input`, { data: chunk })).then(() => {
123835
123839
  setError(null);
123836
123840
  }).catch((e) => {
123837
- setError(e instanceof Error ? e.message : String(e));
123841
+ setError(errMessage(e));
123838
123842
  });
123839
123843
  }
123840
123844
  function handleTerminalData(data) {
@@ -124429,7 +124433,7 @@ function useFileRead(orchestratorId, selectedPath) {
124429
124433
  }).catch((e) => {
124430
124434
  if (!cancelled) {
124431
124435
  setFile(null);
124432
- setError(e instanceof Error ? e.message : String(e));
124436
+ setError(errMessage(e));
124433
124437
  }
124434
124438
  }).finally(() => {
124435
124439
  if (!cancelled) setLoading(false);
@@ -124467,7 +124471,7 @@ function useFileListing(orchestratorId, selectedPath, git = false) {
124467
124471
  }).catch((e) => {
124468
124472
  if (!cancelled) {
124469
124473
  setListing(null);
124470
- setError(e instanceof Error ? e.message : String(e));
124474
+ setError(errMessage(e));
124471
124475
  }
124472
124476
  }).finally(() => {
124473
124477
  if (!cancelled) setLoading(false);
@@ -124783,7 +124787,7 @@ function FileBrowser({ overlay = false }) {
124783
124787
  setPathDraft(result.path);
124784
124788
  setReadError("");
124785
124789
  } catch (e) {
124786
- setError(e instanceof Error ? e.message : String(e));
124790
+ setError(errMessage(e));
124787
124791
  } finally {
124788
124792
  setLoading(false);
124789
124793
  }
@@ -128124,7 +128128,7 @@ function LogViewer({ orchestratorId, session, lines = 200 }) {
128124
128128
  setLogLines((await apiCall("GET", `/orchestrators/${encodeURIComponent(orchestratorId)}/logs/${encodeURIComponent(session)}?lines=${lines}${stream === "mirror" ? "&stream=mirror" : ""}`)).lines || []);
128125
128129
  setError(null);
128126
128130
  } catch (e) {
128127
- setError(e instanceof Error ? e.message : String(e));
128131
+ setError(errMessage(e));
128128
128132
  }
128129
128133
  }
128130
128134
  (0, import_react.useEffect)(() => {
@@ -152926,7 +152930,7 @@ function InsightsView() {
152926
152930
  setProjects(obs.projects);
152927
152931
  setNow(Date.now());
152928
152932
  } catch (e) {
152929
- setError(e instanceof Error ? e.message : String(e));
152933
+ setError(errMessage(e));
152930
152934
  }
152931
152935
  }
152932
152936
  (0, import_react.useEffect)(() => {
@@ -152946,7 +152950,7 @@ function InsightsView() {
152946
152950
  setVersion(entry.version);
152947
152951
  } catch (e) {
152948
152952
  setConfig(previous);
152949
- setError(e instanceof Error ? e.message : String(e));
152953
+ setError(errMessage(e));
152950
152954
  } finally {
152951
152955
  setSaving(false);
152952
152956
  }
@@ -156470,7 +156474,7 @@ function DirectoryBrowserDialog({ open, onOpenChange, onSelect, orchestratorId,
156470
156474
  const query = path ? `?path=${encodeURIComponent(path)}` : "";
156471
156475
  setListing(await apiCall("GET", `/orchestrators/${encodeURIComponent(orchestratorId)}/directories${query}`));
156472
156476
  } catch (e) {
156473
- setError(e instanceof Error ? e.message : String(e));
156477
+ setError(errMessage(e));
156474
156478
  } finally {
156475
156479
  setLoading(false);
156476
156480
  }
@@ -156494,7 +156498,7 @@ function DirectoryBrowserDialog({ open, onOpenChange, onSelect, orchestratorId,
156494
156498
  setCreating(false);
156495
156499
  setNewDirName("");
156496
156500
  } catch (e) {
156497
- setCreateError(e instanceof Error ? e.message : String(e));
156501
+ setCreateError(errMessage(e));
156498
156502
  }
156499
156503
  }
156500
156504
  function handleSelect() {
@@ -2,6 +2,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
2
2
  import { homedir, hostname } from "node:os";
3
3
  import { join, resolve } from "node:path";
4
4
  import { stringValue } from "agent-relay-sdk";
5
+ import { sanitizeFsName } from "agent-relay-sdk/fs-name";
5
6
  import type { ProviderConfig } from "./adapter";
6
7
 
7
8
  interface GlobalRunnerConfig {
@@ -98,7 +99,7 @@ export function providerConfigPublic(config: LoadedProviderConfig): Record<strin
98
99
 
99
100
  export function runnerId(provider: string, cwd: string, label?: string): string {
100
101
  const project = cwd.split("/").filter(Boolean).at(-1) || "workspace";
101
- const cleanLabel = (label || project).replace(/[^a-zA-Z0-9._-]+/g, "-").toLowerCase();
102
+ const cleanLabel = sanitizeFsName(label || project, { replacement: "-", lowercase: true });
102
103
  return `${hostname()}-${provider}-${cleanLabel}-${crypto.randomUUID().slice(0, 8)}`;
103
104
  }
104
105
 
@@ -13,11 +13,11 @@ import {
13
13
  ValidationError,
14
14
  } from "./db";
15
15
  import { createCommand } from "./commands-db";
16
- import { cleanString } from "./validation";
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
19
  import { resolveProviderSelection, type ProviderEffort, VALID_EFFORTS } from "agent-relay-sdk/provider-catalog";
20
- import { VALID_WORKSPACE_MODES } from "agent-relay-sdk";
20
+ import { errMessage, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
21
21
  import { runnerRuntimeTokenEnv } from "./runtime-tokens";
22
22
  import type {
23
23
  AgentCard,
@@ -119,27 +119,12 @@ function rowToAutomationRun(row: any): AutomationRun {
119
119
  };
120
120
  }
121
121
 
122
- function cleanStringArray(value: unknown, field: string): string[] | undefined {
123
- if (value === undefined || value === null) return undefined;
124
- if (!Array.isArray(value)) throw new ValidationError(`${field} must be an array`);
125
- return [...new Set(value.map((item) => cleanString(item, `${field} item`, { max: 100 })).filter(Boolean) as string[])];
126
- }
127
-
128
122
  function cleanBool(value: unknown, field: string): boolean | undefined {
129
123
  if (value === undefined || value === null) return undefined;
130
124
  if (typeof value !== "boolean") throw new ValidationError(`${field} must be a boolean`);
131
125
  return value;
132
126
  }
133
127
 
134
- function cleanEnum<T extends readonly string[]>(value: unknown, field: string, valid: T, fallback?: T[number]): T[number] {
135
- if (value === undefined || value === null) {
136
- if (fallback !== undefined) return fallback;
137
- throw new ValidationError(`${field} required`);
138
- }
139
- if (typeof value !== "string" || !valid.includes(value)) throw new ValidationError(`${field} must be one of: ${valid.join(", ")}`);
140
- return value as T[number];
141
- }
142
-
143
128
  function cleanMeta(value: unknown, field = "metadata"): Record<string, unknown> | undefined {
144
129
  if (value === undefined || value === null) return undefined;
145
130
  if (typeof value !== "object" || Array.isArray(value)) throw new ValidationError(`${field} must be an object`);
@@ -153,7 +138,7 @@ function normalizeTaskTemplate(value: unknown): AutomationTaskTemplate {
153
138
  return {
154
139
  title: cleanString(input.title, "taskTemplate.title", { required: true, max: 240 })!,
155
140
  body: cleanString(input.body, "taskTemplate.body", { required: true, max: 200_000 })!,
156
- severity: cleanEnum(input.severity, "taskTemplate.severity", ["info", "warning", "critical"] as const, "info") as TaskSeverity,
141
+ severity: optionalEnum(input.severity, "taskTemplate.severity", ["info", "warning", "critical"] as const, "info") as TaskSeverity,
157
142
  dedupeKey: cleanString(input.dedupeKey, "taskTemplate.dedupeKey", { max: 240 }),
158
143
  externalUrl: cleanString(input.externalUrl, "taskTemplate.externalUrl", { max: 1000 }),
159
144
  metadata: cleanMeta(input.metadata, "taskTemplate.metadata"),
@@ -207,13 +192,13 @@ function normalizeTargetPolicy(value: unknown): AutomationTargetPolicy {
207
192
  selector: {
208
193
  provider: cleanString(selectorInput.provider, "targetPolicy.selector.provider", { max: 40 }) as "claude" | "codex" | undefined,
209
194
  label: cleanString(selectorInput.label, "targetPolicy.selector.label", { max: 120 }),
210
- tags: cleanStringArray(selectorInput.tags, "targetPolicy.selector.tags"),
211
- capabilities: cleanStringArray(selectorInput.capabilities, "targetPolicy.selector.capabilities"),
195
+ tags: cleanStringArray(selectorInput.tags, "targetPolicy.selector.tags", { itemMax: 100 }),
196
+ capabilities: cleanStringArray(selectorInput.capabilities, "targetPolicy.selector.capabilities", { itemMax: 100 }),
212
197
  },
213
- ifNoMatch: cleanEnum(input.ifNoMatch, "targetPolicy.ifNoMatch", ["fail", "spawn"] as const, "fail"),
198
+ ifNoMatch: optionalEnum(input.ifNoMatch, "targetPolicy.ifNoMatch", ["fail", "spawn"] as const, "fail"),
214
199
  };
215
200
  }
216
- const provider = cleanEnum(input.provider, "targetPolicy.provider", ["claude", "codex"] as const, "codex");
201
+ const provider = optionalEnum(input.provider, "targetPolicy.provider", ["claude", "codex"] as const, "codex");
217
202
  const model = cleanString(input.model, "targetPolicy.model", { max: 120 });
218
203
  const effort = input.effort === undefined || input.effort === null ? undefined : cleanEnum(input.effort, "targetPolicy.effort", VALID_EFFORTS) as ProviderEffort;
219
204
  const profile = cleanString(input.profile, "targetPolicy.profile", { max: 120 });
@@ -221,7 +206,7 @@ function normalizeTargetPolicy(value: unknown): AutomationTargetPolicy {
221
206
  try {
222
207
  resolveProviderSelection({ provider, model, effort });
223
208
  } catch (error) {
224
- throw new ValidationError(error instanceof Error ? error.message : String(error));
209
+ throw new ValidationError(errMessage(error));
225
210
  }
226
211
  return {
227
212
  mode,
@@ -229,9 +214,9 @@ function normalizeTargetPolicy(value: unknown): AutomationTargetPolicy {
229
214
  model,
230
215
  effort,
231
216
  cwd: cleanString(input.cwd, "targetPolicy.cwd", { max: 500 }),
232
- workspaceMode: cleanEnum(input.workspaceMode, "targetPolicy.workspaceMode", VALID_WORKSPACE_MODES, "inherit"),
217
+ workspaceMode: optionalEnum(input.workspaceMode, "targetPolicy.workspaceMode", VALID_WORKSPACE_MODES, "inherit"),
233
218
  profile,
234
- approvalMode: cleanEnum(input.approvalMode, "targetPolicy.approvalMode", ["open", "guarded", "read-only"] as const, "guarded"),
219
+ approvalMode: optionalEnum(input.approvalMode, "targetPolicy.approvalMode", ["open", "guarded", "read-only"] as const, "guarded"),
235
220
  keepAlive: cleanBool(input.keepAlive, "targetPolicy.keepAlive") ?? false,
236
221
  runtimeBudget: normalizeRuntimeBudget(input),
237
222
  shutdownAfterMs: typeof input.shutdownAfterMs === "number" && Number.isSafeInteger(input.shutdownAfterMs) && input.shutdownAfterMs >= 0
@@ -251,8 +236,8 @@ function normalizeCreateInput(input: CreateAutomationInput): Required<Omit<Creat
251
236
  enabled: cleanBool(input.enabled, "enabled") ?? true,
252
237
  schedule,
253
238
  timezone,
254
- catchUpPolicy: cleanEnum(input.catchUpPolicy, "catchUpPolicy", ["skip", "run_once", "run_all"] as const, DEFAULT_CATCH_UP),
255
- concurrencyPolicy: cleanEnum(input.concurrencyPolicy, "concurrencyPolicy", ["skip", "queue", "replace"] as const, DEFAULT_CONCURRENCY),
239
+ catchUpPolicy: optionalEnum(input.catchUpPolicy, "catchUpPolicy", ["skip", "run_once", "run_all"] as const, DEFAULT_CATCH_UP),
240
+ concurrencyPolicy: optionalEnum(input.concurrencyPolicy, "concurrencyPolicy", ["skip", "queue", "replace"] as const, DEFAULT_CONCURRENCY),
256
241
  orchestratorId: cleanString(input.orchestratorId, "orchestratorId", { required: true, max: 160 })!,
257
242
  targetPolicy: normalizeTargetPolicy(input.targetPolicy),
258
243
  taskTemplate: normalizeTaskTemplate(input.taskTemplate),
@@ -511,7 +496,7 @@ function dispatchAutomationRun(automation: Automation, run: AutomationRun, now:
511
496
  updateRun(run.id, {
512
497
  status: "failed",
513
498
  finishedAt: now,
514
- error: e instanceof Error ? e.message : String(e),
499
+ error: errMessage(e),
515
500
  }, now);
516
501
  return { automation, run: getAutomationRun(run.id)! };
517
502
  }
package/src/bus.ts CHANGED
@@ -14,7 +14,7 @@ import {
14
14
  type BusFrame,
15
15
  type RegisterFrame,
16
16
  } from "agent-relay-sdk/protocol";
17
- import { isRecord, stringValue } from "agent-relay-sdk";
17
+ import { errMessage, isRecord, stringValue } from "agent-relay-sdk";
18
18
  import { getComponentAuth, isComponentAuthorizedFor, isAuthorized, isOriginAllowed, unauthorized } from "./security";
19
19
  import type { AgentCard, Command, ComponentToken, ContextState, Message, ProviderCapabilities, Task } from "./types";
20
20
 
@@ -79,7 +79,7 @@ export function busHandleMessage(ws: BusWebSocket, data: string | Buffer): void
79
79
  try {
80
80
  handleFrame(ws, frame);
81
81
  } catch (error) {
82
- sendError(ws, frame.id, "FRAME_FAILED", error instanceof Error ? error.message : String(error));
82
+ sendError(ws, frame.id, "FRAME_FAILED", errMessage(error));
83
83
  }
84
84
  }
85
85
 
package/src/cli.ts CHANGED
@@ -39,12 +39,15 @@ import {
39
39
  detectUpgradeSnapshot,
40
40
  executeUpgradePlan,
41
41
  formatUpgradePlan,
42
+ resolveLocalOrchestratorId,
42
43
  type UpgradeProvider,
43
44
  } from "./upgrade";
44
45
  import { formatMemoryBrokerSmokeResult, runMemoryBrokerSmoke } from "./memory-broker-smoke";
45
46
  import { MAX_BODY_BYTES, VERSION } from "./config";
46
47
  import { DEFAULT_CONTEXT_PROBE_STATE_DIR, runContextProbe } from "agent-relay-sdk/context-probe";
47
- import { RELAY_TOKEN_HEADER } from "agent-relay-sdk";
48
+ import { shellQuote } from "agent-relay-sdk/shell-utils";
49
+ import { errMessage, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
50
+ import type { WorkspaceDepsRefreshResult } from "agent-relay-sdk";
48
51
 
49
52
  const HELP = `
50
53
  agent-relay ${VERSION}
@@ -53,6 +56,8 @@ Usage:
53
56
  agent-relay [start]
54
57
  agent-relay setup [--yes] [--dry-run] [--force] [--env-file PATH] [--runtime-prefix DIR] [--host HOST] [--port PORT] [--db-path PATH] [--token TOKEN|--no-token]
55
58
  agent-relay upgrade [--dry-run] [--version VERSION] [--runtime-prefix DIR] [--providers auto|all|codex|claude|orchestrator] [--no-restart] [--yes]
59
+ agent-relay upgrade --host ID [--host ID2 ...] [--version VERSION] [--providers ...] (upgrade remote orchestrator hosts over the relay)
60
+ agent-relay upgrade --all-hosts [...] (upgrade this host, then every behind remote host)
56
61
  agent-relay setup upgrade [same options as upgrade]
57
62
  agent-relay daemon <install|uninstall|start|stop|restart|enable|disable|status|logs> [options]
58
63
  agent-relay orchestrator install [options]
@@ -63,7 +68,7 @@ Usage:
63
68
  agent-relay context-probe [print-status-line] [--wrap COMMAND] [--agent-id ID] [--state-dir DIR] [--standalone]
64
69
  agent-relay token <create|list|revoke|verify> [options]
65
70
  agent-relay pair <target|create|status|accept|reject|hangup|send> [options]
66
- agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale> [--id ID] [--strategy …] [--purpose TEXT] [--repo PATH] [--execute] [--json]
71
+ agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale|deps> [--id ID] [--strategy …] [--purpose TEXT] [--repo PATH] [--check] [--execute] [--json]
67
72
  agent-relay steward <queue|inspect|checks> [WORKSPACE_ID] [--repo PATH] [--json]
68
73
  agent-relay message <target> <body> [options]
69
74
  agent-relay get-message <messageId> [--json|--body]
@@ -133,6 +138,8 @@ Upgrade options:
133
138
  --version VERSION Target version (default: latest published server version)
134
139
  --runtime-prefix DIR Isolated Agent Relay npm prefix (default: ~/.agent-relay/runtime)
135
140
  --providers LIST Provider integrations to upgrade: auto, all, codex, claude, orchestrator
141
+ --host ID Upgrade a remote orchestrator host over the relay (repeatable). Skips the local upgrade
142
+ --all-hosts Upgrade this host, then fan out to every connected remote host that is behind
136
143
  --no-restart Do not restart agent-relay.service
137
144
  --dry-run Print detected state and planned commands
138
145
  --yes Skip confirmation prompts
@@ -231,6 +238,11 @@ Isolated workspaces
231
238
  agent-relay workspace status Show your workspace's branch, base, status.
232
239
  The base branch will move as other agents land in parallel — that is normal,
233
240
  let the merge handle it. Never push your branch yourself; it is local-only.
241
+ If typecheck/build fails on a missing module (a dep added to the base after
242
+ your worktree was created), do NOT run a clean install — it mutates the shared
243
+ node_modules. Instead refresh your worktree's deps in isolation:
244
+ agent-relay workspace deps Re-provision deps that have gone stale.
245
+ agent-relay workspace deps --check Report staleness without installing.
234
246
 
235
247
  Rules of thumb
236
248
  If you are handling relay message #123, reply with:
@@ -384,6 +396,8 @@ async function handleUpgradeCommand(args: string[]): Promise<void> {
384
396
  let runtimePrefix: string | undefined;
385
397
  const pathPrefix: string[] = [];
386
398
  const providers: UpgradeProvider[] = [];
399
+ const hosts: string[] = [];
400
+ let allHosts = false;
387
401
 
388
402
  for (let i = 0; i < args.length; i++) {
389
403
  const arg = args[i];
@@ -392,6 +406,8 @@ async function handleUpgradeCommand(args: string[]): Promise<void> {
392
406
  else if (arg === "--providers" && i + 1 < args.length) {
393
407
  for (const provider of args[++i]!.split(",")) providers.push(parseUpgradeProvider(provider));
394
408
  } else if (arg === "--provider" && i + 1 < args.length) providers.push(parseUpgradeProvider(args[++i]!));
409
+ else if (arg === "--host" && i + 1 < args.length) hosts.push(args[++i]!);
410
+ else if (arg === "--all-hosts") allHosts = true;
395
411
  else if (arg === "--codex") providers.push("codex");
396
412
  else if (arg === "--claude") providers.push("claude");
397
413
  else if (arg === "--orchestrator") providers.push("orchestrator");
@@ -403,6 +419,14 @@ async function handleUpgradeCommand(args: string[]): Promise<void> {
403
419
  else throw new Error(`Unknown upgrade option "${arg}"`);
404
420
  }
405
421
 
422
+ // Remote-only: drive named hosts' self-upgrade over the relay command bus and
423
+ // skip the local upgrade entirely (#210). `--all-hosts` instead upgrades this
424
+ // host first, then fans out to every behind remote (handled after the local run).
425
+ if (hosts.length && !allHosts) {
426
+ await runRemoteOrchestratorUpgrades({ hosts, targetVersion, providers, json, dryRun, yes });
427
+ return;
428
+ }
429
+
406
430
  const snapshot = await detectUpgradeSnapshot({
407
431
  ...(targetVersion ? { targetVersion } : {}),
408
432
  ...(runtimePrefix ? { runtimePrefix } : {}),
@@ -418,11 +442,13 @@ async function handleUpgradeCommand(args: string[]): Promise<void> {
418
442
 
419
443
  if (json) {
420
444
  console.log(JSON.stringify({ plan }, null, 2));
445
+ if (allHosts) await runRemoteOrchestratorUpgrades({ allBehind: true, targetVersion: plan.targetVersion, providers, json, dryRun: true });
421
446
  return;
422
447
  }
423
448
 
424
449
  if (dryRun) {
425
450
  console.log(formatUpgradePlan(plan, { dryRun: true }));
451
+ if (allHosts) await runRemoteOrchestratorUpgrades({ allBehind: true, targetVersion: plan.targetVersion, providers, dryRun: true });
426
452
  return;
427
453
  }
428
454
 
@@ -436,6 +462,101 @@ async function handleUpgradeCommand(args: string[]): Promise<void> {
436
462
  }
437
463
 
438
464
  console.log(await executeUpgradePlan(plan));
465
+
466
+ if (allHosts) {
467
+ await runRemoteOrchestratorUpgrades({ allBehind: true, targetVersion: plan.targetVersion, providers, json, yes: true });
468
+ }
469
+ }
470
+
471
+ /**
472
+ * Trigger orchestrator self-upgrade on remote hosts via the relay command bus
473
+ * (#210). Each host installs its own runtime + self-restarts; the relay settles
474
+ * the version when it re-registers. Replaces the manual ssh + npm install dance.
475
+ */
476
+ async function runRemoteOrchestratorUpgrades(opts: {
477
+ hosts?: string[];
478
+ allBehind?: boolean;
479
+ targetVersion?: string;
480
+ providers: UpgradeProvider[];
481
+ json?: boolean;
482
+ dryRun?: boolean;
483
+ yes?: boolean;
484
+ }): Promise<void> {
485
+ const targetVersion = opts.targetVersion ?? VERSION;
486
+ const orchestrators = (await apiRequest("GET", "/api/orchestrators")) as Array<{
487
+ id: string;
488
+ version?: string;
489
+ }>;
490
+ const byId = new Map(orchestrators.map((orch) => [orch.id, orch]));
491
+ const localId = resolveLocalOrchestratorId();
492
+ // Default to "all" so a remote host's provider runner is upgraded too, not just
493
+ // the orchestrator package (matters for hosts running claude/codex agents).
494
+ const remoteProviders: UpgradeProvider[] = opts.providers.length ? opts.providers : ["all"];
495
+
496
+ let targets: string[];
497
+ if (opts.allBehind) {
498
+ targets = orchestrators
499
+ .filter((orch) => orch.id !== localId && orch.version && orch.version !== targetVersion)
500
+ .map((orch) => orch.id);
501
+ if (!targets.length) {
502
+ if (opts.json) console.log(JSON.stringify({ remoteUpgrades: [] }, null, 2));
503
+ else console.log(`No remote orchestrators behind ${targetVersion}.`);
504
+ return;
505
+ }
506
+ } else {
507
+ targets = opts.hosts ?? [];
508
+ }
509
+
510
+ if (opts.dryRun) {
511
+ const lines = targets.map((id) => {
512
+ const orch = byId.get(id);
513
+ const from = orch ? orch.version ?? "unknown" : "(not connected)";
514
+ return ` ${id}: ${from} → ${targetVersion} (providers: ${remoteProviders.join(",")})`;
515
+ });
516
+ if (opts.json) console.log(JSON.stringify({ remoteUpgrades: targets.map((id) => ({ id, targetVersion, providers: remoteProviders, dryRun: true })) }, null, 2));
517
+ else console.log(`Remote orchestrator upgrade plan → ${targetVersion}:\n${lines.join("\n")}`);
518
+ return;
519
+ }
520
+
521
+ if (!opts.yes && !opts.json) {
522
+ console.log(`Trigger remote orchestrator upgrade → ${targetVersion} for: ${targets.join(", ")}`);
523
+ const ok = await confirm("Send remote upgrade command(s)?");
524
+ if (!ok) {
525
+ console.log("Remote upgrade cancelled.");
526
+ return;
527
+ }
528
+ }
529
+
530
+ const results: Array<{ id: string; ok: boolean; message: string }> = [];
531
+ for (const id of targets) {
532
+ if (!byId.has(id)) {
533
+ results.push({ id, ok: false, message: "not connected to the relay" });
534
+ continue;
535
+ }
536
+ try {
537
+ const res = (await apiRequest("POST", `/api/orchestrators/${encodeURIComponent(id)}/action`, {
538
+ action: "upgrade",
539
+ targetVersion,
540
+ providers: remoteProviders,
541
+ })) as { command?: { id?: string } };
542
+ const from = byId.get(id)?.version;
543
+ results.push({ id, ok: true, message: `queued ${from ?? "?"} → ${targetVersion} (command ${res?.command?.id ?? "?"})` });
544
+ } catch (err) {
545
+ results.push({ id, ok: false, message: errMessage(err) });
546
+ }
547
+ }
548
+
549
+ if (opts.json) {
550
+ console.log(JSON.stringify({ remoteUpgrades: results }, null, 2));
551
+ return;
552
+ }
553
+ console.log(`\nRemote orchestrator upgrades → ${targetVersion}:`);
554
+ for (const result of results) console.log(` ${result.ok ? "✓" : "✗"} ${result.id}: ${result.message}`);
555
+ console.log("\nEach host installs and self-restarts; the relay reconciles the version when it re-registers.");
556
+ console.log("Track progress in the dashboard Orchestrators view or via GET /api/orchestrators.");
557
+ if (results.some((result) => !result.ok)) {
558
+ process.exitCode = 1;
559
+ }
439
560
  }
440
561
 
441
562
  function parseUpgradeProvider(value: string): UpgradeProvider {
@@ -626,10 +747,6 @@ async function readStdin(): Promise<string> {
626
747
  return value;
627
748
  }
628
749
 
629
- function shellQuote(value: string): string {
630
- return `'${value.replace(/'/g, `'\\''`)}'`;
631
- }
632
-
633
750
  function currentClaudeStatusLineCommand(): string | undefined {
634
751
  const settingsPath = join(process.env.HOME || homedir(), ".claude", "settings.json");
635
752
  try {
@@ -1421,6 +1538,35 @@ function formatWorkspaceStatus(ws: any): string {
1421
1538
  return lines.join("\n");
1422
1539
  }
1423
1540
 
1541
+ // Poll a command to a terminal state (succeeded/failed). Returns undefined on
1542
+ // timeout so the caller can degrade to "dispatched, check later".
1543
+ async function pollCommand(id: string, timeoutMs: number): Promise<{ status?: string; result?: unknown; error?: string } | undefined> {
1544
+ const deadline = Date.now() + timeoutMs;
1545
+ while (Date.now() < deadline) {
1546
+ const cmd = await apiRequest("GET", `/api/commands/${encodeURIComponent(id)}`) as { status?: string; result?: unknown; error?: string };
1547
+ if (cmd.status === "succeeded" || cmd.status === "failed") return cmd;
1548
+ await new Promise((r) => setTimeout(r, 1000));
1549
+ }
1550
+ return undefined;
1551
+ }
1552
+
1553
+ function formatDepsRefresh(result: WorkspaceDepsRefreshResult, checkOnly: boolean): string {
1554
+ if (result.error && (!result.dirs || result.dirs.length === 0)) return `Deps ${checkOnly ? "check" : "refresh"}: ${result.error}`;
1555
+ const lines: string[] = [];
1556
+ for (const d of result.dirs) {
1557
+ const icon = d.status === "installed" ? "↻" : d.status === "stale" ? "✗" : d.status === "failed" ? "!" : "✓";
1558
+ const detail = d.status === "ok" ? "up to date"
1559
+ : d.status === "installed" ? `reinstalled${d.wasSymlink ? " (was symlinked)" : ""}`
1560
+ : d.status === "stale" ? `stale — missing ${d.missing?.join(", ") ?? "?"}`
1561
+ : `failed — ${d.error ?? "unknown"}`;
1562
+ lines.push(` ${icon} ${d.dir}: ${detail}`);
1563
+ }
1564
+ const header = checkOnly
1565
+ ? (result.stale ? "Deps check: stale dirs found — run `agent-relay workspace deps` to refresh" : "Deps check: all dirs up to date")
1566
+ : (result.refreshed ? "Deps refreshed" : result.error ? "Deps refresh hit errors" : "Deps already up to date");
1567
+ return [header, ...lines].join("\n");
1568
+ }
1569
+
1424
1570
  // Self-service workspace lifecycle for agents in isolated worktrees (#205) plus
1425
1571
  // steward coordination (#208).
1426
1572
  // status — read your workspace row ready — hand off for review/landing
@@ -1430,9 +1576,9 @@ function formatWorkspaceStatus(ws: any): string {
1430
1576
  // cleanup-stale — guarded batch cleanup of stale worktrees (dry-run by default)
1431
1577
  async function handleWorkspaceCommand(args: string[]): Promise<void> {
1432
1578
  const action = args[0];
1433
- const valid = new Set(["status", "ready", "land", "list", "diagnostics", "diag", "claim", "release", "cleanup-stale"]);
1579
+ const valid = new Set(["status", "ready", "land", "list", "diagnostics", "diag", "claim", "release", "cleanup-stale", "deps"]);
1434
1580
  if (!action || !valid.has(action)) {
1435
- throw new Error("Usage: agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale> [--id ID] [--strategy …] [--purpose TEXT] [--repo PATH] [--execute] [--json]");
1581
+ throw new Error("Usage: agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale|deps> [--id ID] [--strategy …] [--purpose TEXT] [--repo PATH] [--check] [--execute] [--json]");
1436
1582
  }
1437
1583
 
1438
1584
  let id = currentWorkspaceId();
@@ -1440,6 +1586,7 @@ async function handleWorkspaceCommand(args: string[]): Promise<void> {
1440
1586
  let purpose: string | undefined;
1441
1587
  let repo: string | undefined;
1442
1588
  let execute = false;
1589
+ let check = false;
1443
1590
  let json = false;
1444
1591
  for (let i = 1; i < args.length; i++) {
1445
1592
  const arg = args[i];
@@ -1448,6 +1595,8 @@ async function handleWorkspaceCommand(args: string[]): Promise<void> {
1448
1595
  else if (arg === "--purpose" && i + 1 < args.length) purpose = args[++i];
1449
1596
  else if (arg === "--repo" && i + 1 < args.length) repo = args[++i];
1450
1597
  else if (arg === "--execute") execute = true;
1598
+ else if (arg === "--check") check = true;
1599
+ else if (arg === "--refresh") check = false; // explicit no-op default for clarity
1451
1600
  else if (arg === "--json") json = true;
1452
1601
  else throw new Error(`Unknown workspace option "${arg}".`);
1453
1602
  }
@@ -1477,6 +1626,32 @@ async function handleWorkspaceCommand(args: string[]): Promise<void> {
1477
1626
  return;
1478
1627
  }
1479
1628
 
1629
+ // Refresh (or --check) deps the shared symlinked node_modules has gone stale on
1630
+ // (#51). Emits a host command; poll it to a terminal state so the agent gets a
1631
+ // synchronous result and knows when to re-run typecheck.
1632
+ if (action === "deps") {
1633
+ const from = await detectAgentId();
1634
+ const res = await apiRequest("POST", `/api/workspaces/${encodeURIComponent(id)}/actions`, { action: "deps-refresh", agentId: from, checkOnly: check }) as { command?: { id?: string } };
1635
+ const commandId = res.command?.id;
1636
+ const settled = commandId ? await pollCommand(commandId, 180_000) : undefined;
1637
+ const result = (settled?.result ?? null) as WorkspaceDepsRefreshResult | null;
1638
+ if (json) {
1639
+ console.log(JSON.stringify(settled ?? res, null, 2));
1640
+ return;
1641
+ }
1642
+ if (settled?.status === "failed") {
1643
+ console.error(`Deps ${check ? "check" : "refresh"} failed: ${settled.error ?? "unknown error"}`);
1644
+ process.exitCode = 1;
1645
+ return;
1646
+ }
1647
+ if (!result) {
1648
+ console.log(`Deps ${check ? "check" : "refresh"} dispatched (command ${commandId ?? "?"}) — host did not report back in time. Check \`agent-relay workspace deps --json\`.`);
1649
+ return;
1650
+ }
1651
+ console.log(formatDepsRefresh(result, check));
1652
+ return;
1653
+ }
1654
+
1480
1655
  const from = await detectAgentId();
1481
1656
  const actionBody: Record<string, unknown> =
1482
1657
  action === "ready" ? { action: "request-review", agentId: from }
@@ -1,7 +1,7 @@
1
1
  import { getDb, ValidationError } from "./db";
2
- import { cleanString } from "./validation";
2
+ import { cleanEnum, cleanString, cleanStringArray } from "./validation";
3
3
  import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog";
4
- import { isRecord, SPAWN_PROVIDERS, VALID_EFFORTS, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
4
+ import { errMessage, isRecord, SPAWN_PROVIDERS, VALID_EFFORTS, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
5
5
  import type {
6
6
  AgentProfile,
7
7
  AgentProfileBase,
@@ -155,17 +155,6 @@ function rowToManagedAgentState(row: ManagedAgentStateRow): ManagedAgentState {
155
155
  };
156
156
  }
157
157
 
158
- function cleanStringArray(value: unknown, field: string, opts: { required?: boolean } = {}): string[] {
159
- if (value === undefined || value === null) {
160
- if (opts.required) throw new ValidationError(`${field} required`);
161
- return [];
162
- }
163
- if (!Array.isArray(value)) throw new ValidationError(`${field} must be an array of strings`);
164
- const cleaned = value.map((item) => cleanString(item, `${field} item`, { max: 120 })).filter(Boolean) as string[];
165
- if (cleaned.length > 100) throw new ValidationError(`${field} can contain at most 100 values`);
166
- return [...new Set(cleaned)];
167
- }
168
-
169
158
  function cleanStringRecord(value: unknown, field: string): Record<string, string> {
170
159
  if (value === undefined || value === null) return {};
171
160
  if (!isRecord(value)) throw new ValidationError(`${field} must be an object`);
@@ -196,13 +185,6 @@ function cleanNumber(value: unknown, field: string, opts: { min: number; max: nu
196
185
  return value;
197
186
  }
198
187
 
199
- function cleanEnum<T extends readonly string[]>(value: unknown, field: string, valid: T): T[number] {
200
- if (typeof value !== "string" || !valid.includes(value)) {
201
- throw new ValidationError(`${field} must be one of: ${valid.join(", ")}`);
202
- }
203
- return value as T[number];
204
- }
205
-
206
188
  function agentProfileDefaults(input: Pick<AgentProfile, "name" | "base"> & Partial<AgentProfile>): AgentProfile {
207
189
  const isolated = input.base !== "host";
208
190
  return {
@@ -279,7 +261,7 @@ function validateAgentProfile(key: string, value: unknown): AgentProfile {
279
261
  : cleanEnum(value.provider, "provider", VALID_PROFILE_PROVIDERS),
280
262
  instructions: {
281
263
  system: cleanString(instructions.system, "instructions.system", { max: 16_000 }),
282
- append: cleanStringArray(instructions.append, "instructions.append"),
264
+ append: cleanStringArray(instructions.append, "instructions.append", { itemMax: 120, maxItems: 100 }) ?? [],
283
265
  repoInstructions: instructions.repoInstructions === undefined
284
266
  ? defaults.instructions.repoInstructions
285
267
  : cleanEnum(instructions.repoInstructions, "instructions.repoInstructions", VALID_PROFILE_INSTRUCTION_POLICIES),
@@ -332,10 +314,10 @@ function validateSpawnPolicy(key: string, value: unknown): SpawnPolicy {
332
314
  model: cleanString(value.model, "model", { max: 120 }),
333
315
  effort: value.effort === undefined || value.effort === null ? undefined : cleanEnum(value.effort, "effort", VALID_EFFORTS) as ProviderEffort,
334
316
  profile: cleanString(value.profile, "profile", { max: 120 }),
335
- providerArgs: cleanStringArray(value.providerArgs, "providerArgs"),
317
+ providerArgs: cleanStringArray(value.providerArgs, "providerArgs", { itemMax: 120, maxItems: 100 }) ?? [],
336
318
  prompt: cleanString(value.prompt, "prompt", { max: 16_000 }),
337
- tags: cleanStringArray(value.tags, "tags"),
338
- capabilities: cleanStringArray(value.capabilities, "capabilities"),
319
+ tags: cleanStringArray(value.tags, "tags", { itemMax: 120, maxItems: 100 }) ?? [],
320
+ capabilities: cleanStringArray(value.capabilities, "capabilities", { itemMax: 120, maxItems: 100 }) ?? [],
339
321
  label: cleanString(value.label, "label", { max: 120 }),
340
322
  mode,
341
323
  permissionMode: cleanEnum(value.permissionMode, "permissionMode", VALID_PERMISSION_MODES),
@@ -346,7 +328,7 @@ function validateSpawnPolicy(key: string, value: unknown): SpawnPolicy {
346
328
  try {
347
329
  resolveProviderSelection({ provider: policy.provider, model: policy.model, effort: policy.effort });
348
330
  } catch (error) {
349
- throw new ValidationError(error instanceof Error ? error.message : String(error));
331
+ throw new ValidationError(errMessage(error));
350
332
  }
351
333
  if (policy.profile && !getAgentProfile(policy.profile)) throw new ValidationError("agent profile not found");
352
334
  if (mode === "on-demand") policy.onDemand = cleanOnDemand(value.onDemand);
@@ -409,7 +391,7 @@ function validateStewardConfig(value: unknown): StewardConfig {
409
391
  try {
410
392
  resolveProviderSelection({ provider: config.provider, model: config.model, effort: config.effort });
411
393
  } catch (error) {
412
- throw new ValidationError(error instanceof Error ? error.message : String(error));
394
+ throw new ValidationError(errMessage(error));
413
395
  }
414
396
  return config;
415
397
  }
@@ -467,7 +449,7 @@ const WORKSPACE_CONFIG_DEFAULTS: WorkspaceConfig = {
467
449
 
468
450
  function validateWorkspaceConfig(value: unknown): WorkspaceConfig {
469
451
  if (!isRecord(value)) throw new ValidationError("workspace config value must be an object");
470
- const symlinkPaths = cleanStringArray(value.symlinkPaths, "symlinkPaths");
452
+ const symlinkPaths = cleanStringArray(value.symlinkPaths, "symlinkPaths", { itemMax: 120, maxItems: 100 }) ?? [];
471
453
  // Reject absolute paths and parent-traversal up front: symlink sources must stay
472
454
  // inside the main checkout. The orchestrator re-checks containment at link time,
473
455
  // but failing here gives the operator immediate feedback in the dashboard.