agent-relay-server 0.18.0 → 0.19.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-relay-server",
3
- "version": "0.18.0",
3
+ "version": "0.19.0",
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
@@ -44,7 +44,9 @@ import {
44
44
  import { formatMemoryBrokerSmokeResult, runMemoryBrokerSmoke } from "./memory-broker-smoke";
45
45
  import { MAX_BODY_BYTES, VERSION } from "./config";
46
46
  import { DEFAULT_CONTEXT_PROBE_STATE_DIR, runContextProbe } from "agent-relay-sdk/context-probe";
47
+ import { shellQuote } from "agent-relay-sdk/shell-utils";
47
48
  import { RELAY_TOKEN_HEADER } from "agent-relay-sdk";
49
+ import type { WorkspaceDepsRefreshResult } from "agent-relay-sdk";
48
50
 
49
51
  const HELP = `
50
52
  agent-relay ${VERSION}
@@ -63,7 +65,7 @@ Usage:
63
65
  agent-relay context-probe [print-status-line] [--wrap COMMAND] [--agent-id ID] [--state-dir DIR] [--standalone]
64
66
  agent-relay token <create|list|revoke|verify> [options]
65
67
  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]
68
+ agent-relay workspace <status|diagnostics|ready|land|claim|release|list|cleanup-stale|deps> [--id ID] [--strategy …] [--purpose TEXT] [--repo PATH] [--check] [--execute] [--json]
67
69
  agent-relay steward <queue|inspect|checks> [WORKSPACE_ID] [--repo PATH] [--json]
68
70
  agent-relay message <target> <body> [options]
69
71
  agent-relay get-message <messageId> [--json|--body]
@@ -231,6 +233,11 @@ Isolated workspaces
231
233
  agent-relay workspace status Show your workspace's branch, base, status.
232
234
  The base branch will move as other agents land in parallel — that is normal,
233
235
  let the merge handle it. Never push your branch yourself; it is local-only.
236
+ If typecheck/build fails on a missing module (a dep added to the base after
237
+ your worktree was created), do NOT run a clean install — it mutates the shared
238
+ node_modules. Instead refresh your worktree's deps in isolation:
239
+ agent-relay workspace deps Re-provision deps that have gone stale.
240
+ agent-relay workspace deps --check Report staleness without installing.
234
241
 
235
242
  Rules of thumb
236
243
  If you are handling relay message #123, reply with:
@@ -626,10 +633,6 @@ async function readStdin(): Promise<string> {
626
633
  return value;
627
634
  }
628
635
 
629
- function shellQuote(value: string): string {
630
- return `'${value.replace(/'/g, `'\\''`)}'`;
631
- }
632
-
633
636
  function currentClaudeStatusLineCommand(): string | undefined {
634
637
  const settingsPath = join(process.env.HOME || homedir(), ".claude", "settings.json");
635
638
  try {
@@ -1421,6 +1424,35 @@ function formatWorkspaceStatus(ws: any): string {
1421
1424
  return lines.join("\n");
1422
1425
  }
1423
1426
 
1427
+ // Poll a command to a terminal state (succeeded/failed). Returns undefined on
1428
+ // timeout so the caller can degrade to "dispatched, check later".
1429
+ async function pollCommand(id: string, timeoutMs: number): Promise<{ status?: string; result?: unknown; error?: string } | undefined> {
1430
+ const deadline = Date.now() + timeoutMs;
1431
+ while (Date.now() < deadline) {
1432
+ const cmd = await apiRequest("GET", `/api/commands/${encodeURIComponent(id)}`) as { status?: string; result?: unknown; error?: string };
1433
+ if (cmd.status === "succeeded" || cmd.status === "failed") return cmd;
1434
+ await new Promise((r) => setTimeout(r, 1000));
1435
+ }
1436
+ return undefined;
1437
+ }
1438
+
1439
+ function formatDepsRefresh(result: WorkspaceDepsRefreshResult, checkOnly: boolean): string {
1440
+ if (result.error && (!result.dirs || result.dirs.length === 0)) return `Deps ${checkOnly ? "check" : "refresh"}: ${result.error}`;
1441
+ const lines: string[] = [];
1442
+ for (const d of result.dirs) {
1443
+ const icon = d.status === "installed" ? "↻" : d.status === "stale" ? "✗" : d.status === "failed" ? "!" : "✓";
1444
+ const detail = d.status === "ok" ? "up to date"
1445
+ : d.status === "installed" ? `reinstalled${d.wasSymlink ? " (was symlinked)" : ""}`
1446
+ : d.status === "stale" ? `stale — missing ${d.missing?.join(", ") ?? "?"}`
1447
+ : `failed — ${d.error ?? "unknown"}`;
1448
+ lines.push(` ${icon} ${d.dir}: ${detail}`);
1449
+ }
1450
+ const header = checkOnly
1451
+ ? (result.stale ? "Deps check: stale dirs found — run `agent-relay workspace deps` to refresh" : "Deps check: all dirs up to date")
1452
+ : (result.refreshed ? "Deps refreshed" : result.error ? "Deps refresh hit errors" : "Deps already up to date");
1453
+ return [header, ...lines].join("\n");
1454
+ }
1455
+
1424
1456
  // Self-service workspace lifecycle for agents in isolated worktrees (#205) plus
1425
1457
  // steward coordination (#208).
1426
1458
  // status — read your workspace row ready — hand off for review/landing
@@ -1430,9 +1462,9 @@ function formatWorkspaceStatus(ws: any): string {
1430
1462
  // cleanup-stale — guarded batch cleanup of stale worktrees (dry-run by default)
1431
1463
  async function handleWorkspaceCommand(args: string[]): Promise<void> {
1432
1464
  const action = args[0];
1433
- const valid = new Set(["status", "ready", "land", "list", "diagnostics", "diag", "claim", "release", "cleanup-stale"]);
1465
+ const valid = new Set(["status", "ready", "land", "list", "diagnostics", "diag", "claim", "release", "cleanup-stale", "deps"]);
1434
1466
  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]");
1467
+ 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
1468
  }
1437
1469
 
1438
1470
  let id = currentWorkspaceId();
@@ -1440,6 +1472,7 @@ async function handleWorkspaceCommand(args: string[]): Promise<void> {
1440
1472
  let purpose: string | undefined;
1441
1473
  let repo: string | undefined;
1442
1474
  let execute = false;
1475
+ let check = false;
1443
1476
  let json = false;
1444
1477
  for (let i = 1; i < args.length; i++) {
1445
1478
  const arg = args[i];
@@ -1448,6 +1481,8 @@ async function handleWorkspaceCommand(args: string[]): Promise<void> {
1448
1481
  else if (arg === "--purpose" && i + 1 < args.length) purpose = args[++i];
1449
1482
  else if (arg === "--repo" && i + 1 < args.length) repo = args[++i];
1450
1483
  else if (arg === "--execute") execute = true;
1484
+ else if (arg === "--check") check = true;
1485
+ else if (arg === "--refresh") check = false; // explicit no-op default for clarity
1451
1486
  else if (arg === "--json") json = true;
1452
1487
  else throw new Error(`Unknown workspace option "${arg}".`);
1453
1488
  }
@@ -1477,6 +1512,32 @@ async function handleWorkspaceCommand(args: string[]): Promise<void> {
1477
1512
  return;
1478
1513
  }
1479
1514
 
1515
+ // Refresh (or --check) deps the shared symlinked node_modules has gone stale on
1516
+ // (#51). Emits a host command; poll it to a terminal state so the agent gets a
1517
+ // synchronous result and knows when to re-run typecheck.
1518
+ if (action === "deps") {
1519
+ const from = await detectAgentId();
1520
+ const res = await apiRequest("POST", `/api/workspaces/${encodeURIComponent(id)}/actions`, { action: "deps-refresh", agentId: from, checkOnly: check }) as { command?: { id?: string } };
1521
+ const commandId = res.command?.id;
1522
+ const settled = commandId ? await pollCommand(commandId, 180_000) : undefined;
1523
+ const result = (settled?.result ?? null) as WorkspaceDepsRefreshResult | null;
1524
+ if (json) {
1525
+ console.log(JSON.stringify(settled ?? res, null, 2));
1526
+ return;
1527
+ }
1528
+ if (settled?.status === "failed") {
1529
+ console.error(`Deps ${check ? "check" : "refresh"} failed: ${settled.error ?? "unknown error"}`);
1530
+ process.exitCode = 1;
1531
+ return;
1532
+ }
1533
+ if (!result) {
1534
+ console.log(`Deps ${check ? "check" : "refresh"} dispatched (command ${commandId ?? "?"}) — host did not report back in time. Check \`agent-relay workspace deps --json\`.`);
1535
+ return;
1536
+ }
1537
+ console.log(formatDepsRefresh(result, check));
1538
+ return;
1539
+ }
1540
+
1480
1541
  const from = await detectAgentId();
1481
1542
  const actionBody: Record<string, unknown> =
1482
1543
  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.
package/src/daemon.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { shellQuote } from "agent-relay-sdk/shell-utils";
2
3
  import { constants, existsSync } from "node:fs";
3
4
  import { access, mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
5
  import { homedir } from "node:os";
@@ -460,10 +461,6 @@ function quoteSystemdArg(value: string): string {
460
461
  return `"${value.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
461
462
  }
462
463
 
463
- function shellQuote(value: string): string {
464
- return `'${value.replace(/'/g, "'\\''")}'`;
465
- }
466
-
467
464
  function xmlEscape(value: string): string {
468
465
  return value
469
466
  .replace(/&/g, "&amp;")
package/src/db.ts CHANGED
@@ -5079,14 +5079,21 @@ function upsertWorkspaceFromManagedAgent(agent: ManagedAgent): WorkspaceRecord |
5079
5079
  // preserved and metadata is merged, not replaced.
5080
5080
  const existing = getWorkspace(workspace.id);
5081
5081
  const preserveStatus = existing != null && existing.status !== "active";
5082
+ // The branch (and advanced base) change ONLY via the relay's own land-and-continue
5083
+ // recycle (setWorkspaceBranch repoints to `<branch>-N` and bumps base_sha). The
5084
+ // runner keeps re-reporting its spawn-time branch on every heartbeat, and the
5085
+ // recycle returns status to "active" — so without this the next heartbeat clobbers
5086
+ // the repoint back to the original branch, and the next land targets a deleted
5087
+ // branch and strands the work (vent #62 follow-up). Trust the existing row's
5088
+ // branch/base over registration; only a brand-new row takes the runner's values.
5082
5089
  return upsertWorkspace({
5083
5090
  id: workspace.id,
5084
5091
  repoRoot: workspace.repoRoot,
5085
5092
  sourceCwd: workspace.sourceCwd ?? agent.cwd,
5086
5093
  worktreePath: workspace.worktreePath,
5087
- branch: workspace.branch,
5094
+ branch: existing?.branch ?? workspace.branch,
5088
5095
  baseRef: workspace.baseRef,
5089
- baseSha: workspace.baseSha,
5096
+ baseSha: existing?.baseSha ?? workspace.baseSha,
5090
5097
  mode: workspace.mode,
5091
5098
  requestedMode: workspace.requestedMode,
5092
5099
  status: preserveStatus ? existing!.status : (workspace.status ?? "active"),
package/src/dev.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execFile } from "node:child_process";
2
+ import { shellQuote } from "agent-relay-sdk/shell-utils";
2
3
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
3
4
  import { mkdir, rm, writeFile } from "node:fs/promises";
4
5
  import { homedir, hostname as osHostname } from "node:os";
@@ -504,7 +505,3 @@ function quote(value: string): string {
504
505
  if (/^[A-Za-z0-9_@%+=:,./-]+$/.test(value)) return value;
505
506
  return `"${value.replace(/(["\\$`])/g, "\\$1")}"`;
506
507
  }
507
-
508
- function shellQuote(value: string): string {
509
- return `'${value.replace(/'/g, "'\\''")}'`;
510
- }
@@ -0,0 +1,49 @@
1
+ /** Concatenate body chunks into a single contiguous Uint8Array. */
2
+ export function concatBytes(chunks: Uint8Array[]): Uint8Array {
3
+ const total = chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0);
4
+ const output = new Uint8Array(total);
5
+ let offset = 0;
6
+ for (const chunk of chunks) {
7
+ output.set(chunk, offset);
8
+ offset += chunk.byteLength;
9
+ }
10
+ return output;
11
+ }
12
+
13
+ /** Wrap bytes in a single-chunk ReadableStream (e.g. for artifact `storage.store`). */
14
+ export function bytesToStream(bytes: Uint8Array): ReadableStream<Uint8Array> {
15
+ return new ReadableStream<Uint8Array>({
16
+ start(controller) {
17
+ controller.enqueue(bytes);
18
+ controller.close();
19
+ },
20
+ });
21
+ }
22
+
23
+ /**
24
+ * Drain a request body stream into a single Uint8Array, enforcing a byte cap.
25
+ * Returns a 413 result the moment the running total exceeds `maxBytes` (cancels
26
+ * the reader). Single home for the read-loop that `parseBody` (routes) and
27
+ * `parseJsonRpcRequest` (mcp) each open-coded — they differed only in the cap.
28
+ * Callers handle body-absence and decoding.
29
+ */
30
+ export async function readBodyBytes(
31
+ body: ReadableStream<Uint8Array>,
32
+ maxBytes: number,
33
+ ): Promise<{ ok: true; bytes: Uint8Array } | { ok: false; status: 413; error: string }> {
34
+ const reader = body.getReader();
35
+ const chunks: Uint8Array[] = [];
36
+ let total = 0;
37
+ while (true) {
38
+ const { done, value } = await reader.read();
39
+ if (done) break;
40
+ if (!value) continue;
41
+ total += value.byteLength;
42
+ if (total > maxBytes) {
43
+ await reader.cancel().catch(() => {});
44
+ return { ok: false, status: 413, error: `request body exceeds ${maxBytes} bytes` };
45
+ }
46
+ chunks.push(value);
47
+ }
48
+ return { ok: true, bytes: concatBytes(chunks) };
49
+ }
package/src/index.ts CHANGED
@@ -30,6 +30,7 @@ import {
30
30
  } from "./security";
31
31
  import { handleCli } from "./cli";
32
32
  import { startMaintenanceScheduler } from "./maintenance";
33
+ import { errMessage } from "agent-relay-sdk";
33
34
 
34
35
  async function main(): Promise<void> {
35
36
  const result = await handleCli(process.argv.slice(2));
@@ -393,7 +394,7 @@ function staticContentType(pathname: string): string | undefined {
393
394
 
394
395
  if (import.meta.main) {
395
396
  main().catch((error) => {
396
- console.error(error instanceof Error ? error.message : String(error));
397
+ console.error(errMessage(error));
397
398
  process.exit(1);
398
399
  });
399
400
  }
@@ -34,7 +34,7 @@ import {
34
34
  import type { WorkspaceMergePreview, WorkspaceRecord, WorkspaceStatus } from "./types";
35
35
  import { requestWorkspaceMerge } from "./workspace-merge";
36
36
  import { workspaceActiveClaim } from "./workspace-claim";
37
- import { RELAY_TOKEN_HEADER } from "agent-relay-sdk";
37
+ import { errMessage, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
38
38
  import { getStewardConfig } from "./config-store";
39
39
  import { ensureRepoSteward } from "./steward";
40
40
  import { emitRelayEvent } from "./events";
@@ -931,7 +931,7 @@ async function runDueMaintenanceJobs(): Promise<void> {
931
931
  for (const row of rows) {
932
932
  const definition = definitions.find((job) => job.id === row.id);
933
933
  if (definition) void runJob(definition).catch((error) => {
934
- console.warn(`maintenance job ${definition.id} failed: ${error instanceof Error ? error.message : String(error)}`);
934
+ console.warn(`maintenance job ${definition.id} failed: ${errMessage(error)}`);
935
935
  });
936
936
  }
937
937
  }
@@ -999,7 +999,7 @@ async function runJob(definition: MaintenanceJobDefinition, options: { force?: b
999
999
  } catch (error) {
1000
1000
  const finishedAt = Date.now();
1001
1001
  const durationMs = finishedAt - startedAt;
1002
- const message = error instanceof Error ? error.message : String(error);
1002
+ const message = errMessage(error);
1003
1003
  db.query(`
1004
1004
  UPDATE maintenance_jobs
1005
1005
  SET last_run_at = ?, next_run_at = ?, last_duration_ms = ?, last_status = 'failed',