agent-relay-server 0.17.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/src/routes.ts CHANGED
@@ -85,6 +85,8 @@ import {
85
85
  getWorkspace,
86
86
  listWorkspaces,
87
87
  updateWorkspaceStatus,
88
+ setWorkspaceBranch,
89
+ patchWorkspaceMetadata,
88
90
  releaseMergeLease,
89
91
  listRepoStewards,
90
92
  listMergeLeases,
@@ -96,6 +98,9 @@ import {
96
98
  setMessageReaction,
97
99
  ValidationError,
98
100
  } from "./db";
101
+ import { cleanString, cleanStringArray, optionalEnum } from "./validation";
102
+ import { listManagedOrchestratorsForAgent } from "./orchestrator-lookup";
103
+ import { bytesToStream, readBodyBytes } from "./http-body";
99
104
  import { getArtifactStorage, maxArtifactBytes, normalizeDigest } from "./artifact-storage";
100
105
  import {
101
106
  deleteConfig,
@@ -110,7 +115,6 @@ import {
110
115
  setInsightsConfig,
111
116
  getWorkspaceConfigEntry,
112
117
  setWorkspaceConfig,
113
- workspaceSpawnParams,
114
118
  listAgentProfiles,
115
119
  listSpawnPolicies,
116
120
  listConfig,
@@ -152,10 +156,14 @@ import { CONTRACT_VERSIONS, parseRuntimeCapabilities, parseRuntimeContracts, par
152
156
  import { listHostDirectories } from "./agent-spawn";
153
157
  import { defaultProviderConfig, loadProviderConfig, providerConfigPublic, writeProviderConfig } from "../runner/src/config";
154
158
  import type { ProviderConfig } from "../runner/src/adapter";
155
- import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog";
159
+ import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
160
+ import { errMessage, isRecord, SPAWN_PROVIDERS, VALID_WORKSPACE_MODES, VALID_EFFORTS, APPROVAL_MODES, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
156
161
  import { effectiveProviderCatalogList } from "./provider-catalog-store";
157
162
  import { buildManagedSpawnParams, effectiveManagedPolicyWorkspaceMode } from "./managed-policy";
163
+ import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams, type SpawnModelParams } from "./spawn-command";
158
164
  import { requestWorkspaceMerge } from "./workspace-merge";
165
+ import { claimMetadataPatch, workspaceActiveClaim } from "./workspace-claim";
166
+ import type { WorkspaceDiagnostics, WorkspaceGitState, WorkspaceRecord } from "./types";
159
167
  import {
160
168
  getComponentAuth,
161
169
  getIntegrationAuth,
@@ -192,7 +200,8 @@ import { assertMemoryCreateAllowed, assertMemoryUpdateAllowed } from "./memory-s
192
200
  import { captureTaskResultMemory, clearActiveMemories, injectAlwaysReloadMemories, injectMemoryContext, injectMemoryForMessageDelivery, injectMemoryForTaskClaim, memoryBroker, memoryBrokerConfig } from "./memory-service";
193
201
  import { postMcp } from "./mcp";
194
202
  import { readFileSync } from "node:fs";
195
- import { isAbsolute, relative, resolve } from "node:path";
203
+ import { resolve } from "node:path";
204
+ import { isPathWithinBase } from "./utils";
196
205
  import type { ArtifactKind, ArtifactSensitivity, AttachmentRef, ContextBudget, CreateMemoryInput, MemoryBrokerContext, MemoryConfidence, MemoryQuery, MemoryRedactionState, MemorySensitivity, MemoryType, MemoryVisibility, TaskRoutingHints, TokenConstraints, UpdateMemoryInput } from "./types";
197
206
  import { issueIntegrationRuntimeToken, issueInteractiveRunnerRuntimeToken, issueMcpRuntimeToken, issueOrchestratorRuntimeToken, reissueRunnerRuntimeToken, runnerRuntimeTokenEnv } from "./runtime-tokens";
198
207
  import { listMaintenanceJobs, runLegacyMaintenanceReaper, runMaintenanceJobNow } from "./maintenance";
@@ -256,44 +265,11 @@ type ParseBodyResult<T> =
256
265
 
257
266
  async function parseBody<T>(req: Request): Promise<ParseBodyResult<T>> {
258
267
  if (!req.body) return { ok: true, body: null };
259
-
260
- const reader = req.body.getReader();
261
- const chunks: Uint8Array[] = [];
262
- let totalBytes = 0;
263
-
264
- while (true) {
265
- const { done, value } = await reader.read();
266
- if (done) break;
267
- if (!value) continue;
268
-
269
- totalBytes += value.byteLength;
270
- if (totalBytes > MAX_BODY_BYTES) {
271
- try {
272
- await reader.cancel();
273
- } catch {
274
- // Ignore cancellation errors — we already know this request is too large.
275
- }
276
- return {
277
- ok: false,
278
- status: 413,
279
- error: `request body exceeds ${MAX_BODY_BYTES} bytes`,
280
- };
281
- }
282
-
283
- chunks.push(value);
284
- }
285
-
286
- if (totalBytes === 0) return { ok: true, body: null };
287
-
288
- const merged = new Uint8Array(totalBytes);
289
- let offset = 0;
290
- for (const chunk of chunks) {
291
- merged.set(chunk, offset);
292
- offset += chunk.byteLength;
293
- }
294
-
268
+ const read = await readBodyBytes(req.body, MAX_BODY_BYTES);
269
+ if (!read.ok) return { ok: false, status: read.status, error: read.error };
270
+ if (read.bytes.byteLength === 0) return { ok: true, body: null };
295
271
  try {
296
- const decoded = new TextDecoder().decode(merged);
272
+ const decoded = new TextDecoder().decode(read.bytes);
297
273
  return { ok: true, body: JSON.parse(decoded) as T };
298
274
  } catch {
299
275
  return { ok: false, status: 400, error: "invalid JSON body" };
@@ -323,14 +299,10 @@ function parseQueryInt(
323
299
  const VALID_AGENT_STATUSES = ["online", "idle", "busy", "stale", "offline"] as const;
324
300
  const VALID_AGENT_KINDS = ["provider", "channel", "orchestrator", "system", "user"] as const;
325
301
  const VALID_CHANNEL_BINDING_TARGET_TYPES = ["agent", "label", "tag", "capability", "broadcast", "orchestrator", "pool", "policy"] as const;
326
- const VALID_WORKSPACE_MODES = ["isolated", "shared", "inherit"] as const;
327
302
  const VALID_WORKSPACE_STATUSES = ["active", "ready", "conflict", "review_requested", "merge_planned", "merged", "abandoned", "cleanup_requested", "cleaned"] as const;
328
303
  const VALID_CHANNEL_BINDING_MODES = ["exclusive", "broadcast"] as const;
329
304
  const VALID_AGENT_ACTIONS = ["restart", "shutdown", "reconnect", "compact", "clearContext", "resume", "interrupt"] as const;
330
305
  const CLAUDE_RESUME_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
331
- const VALID_AGENT_SPAWN_PROVIDERS = ["codex"] as const;
332
- const VALID_CODEX_SPAWN_APPROVALS = ["open", "guarded", "read-only"] as const;
333
- const VALID_PROVIDER_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
334
306
  const VALID_CONNECTOR_ACTIONS = ["install", "uninstall", "enable", "disable", "start", "stop", "restart", "status", "doctor"] as const;
335
307
  const VALID_TASK_SEVERITIES = ["info", "warning", "critical"] as const;
336
308
  const VALID_TASK_STATUSES = ["open", "claimed", "in_progress", "blocked", "orphaned", "done", "failed", "canceled"] as const;
@@ -349,34 +321,6 @@ const VALID_ARTIFACT_SENSITIVITIES = ["public", "normal", "sensitive", "secret"]
349
321
  const VALID_ARTIFACT_ROLES = ["media", "patch", "report", "log", "output", "input"] as const;
350
322
  const integrationRateBuckets = new Map<string, { windowStart: number; count: number }>();
351
323
 
352
- function isRecord(value: unknown): value is Record<string, unknown> {
353
- return typeof value === "object" && value !== null && !Array.isArray(value);
354
- }
355
-
356
- function pathWithinBase(path: string, baseDir: string): boolean {
357
- const base = resolve(baseDir);
358
- const target = resolve(path);
359
- const rel = relative(base, target);
360
- return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
361
- }
362
-
363
- function cleanString(
364
- value: unknown,
365
- field: string,
366
- opts: { required?: boolean; max?: number } = {},
367
- ): string | undefined {
368
- if (value === undefined || value === null) {
369
- if (opts.required) throw new ValidationError(`${field} required`);
370
- return undefined;
371
- }
372
- if (typeof value !== "string") throw new ValidationError(`${field} must be a string`);
373
- const trimmed = value.trim();
374
- if (opts.required && !trimmed) throw new ValidationError(`${field} required`);
375
- if (opts.max && trimmed.length > opts.max) {
376
- throw new ValidationError(`${field} must be ${opts.max} characters or fewer`);
377
- }
378
- return trimmed || undefined;
379
- }
380
324
 
381
325
  function cleanNullableString(value: unknown, field: string, max: number): string | null | undefined {
382
326
  if (value === undefined) return undefined;
@@ -384,14 +328,6 @@ function cleanNullableString(value: unknown, field: string, max: number): string
384
328
  return cleanString(value, field, { max }) ?? null;
385
329
  }
386
330
 
387
- function cleanStringArray(value: unknown, field: string): string[] | undefined {
388
- if (value === undefined || value === null) return undefined;
389
- if (!Array.isArray(value)) throw new ValidationError(`${field} must be an array of strings`);
390
- const cleaned = value.map((item) => cleanString(item, `${field} item`, { max: 80 })).filter(Boolean) as string[];
391
- if (cleaned.length > 50) throw new ValidationError(`${field} can contain at most 50 values`);
392
- return [...new Set(cleaned)];
393
- }
394
-
395
331
  function cleanConstraintStringArray(value: unknown, field: string): string[] | undefined {
396
332
  if (value === undefined || value === null) return undefined;
397
333
  if (!Array.isArray(value)) throw new ValidationError(`${field} must be an array of strings`);
@@ -447,33 +383,20 @@ function cleanParams(value: unknown, field = "params"): Record<string, unknown>
447
383
  return value;
448
384
  }
449
385
 
450
- function cleanEnum<T extends readonly string[]>(
451
- value: unknown,
452
- field: string,
453
- valid: T,
454
- fallback?: T[number],
455
- ): T[number] | undefined {
456
- if (value === undefined || value === null) return fallback;
457
- if (typeof value !== "string" || !valid.includes(value)) {
458
- throw new ValidationError(`${field} must be one of: ${valid.join(", ")}`);
459
- }
460
- return value as T[number];
461
- }
462
-
463
386
  function cleanWorkspaceMetadata(value: unknown, field: string): WorkspaceMetadata | undefined {
464
387
  if (value === undefined || value === null) return undefined;
465
388
  if (!isRecord(value)) throw new ValidationError(`${field} must be an object`);
466
389
  return {
467
390
  id: cleanString(value.id, `${field}.id`, { max: 160 }),
468
- mode: cleanEnum(value.mode, `${field}.mode`, VALID_WORKSPACE_MODES, "shared") as WorkspaceMode,
469
- requestedMode: cleanEnum(value.requestedMode, `${field}.requestedMode`, VALID_WORKSPACE_MODES) as WorkspaceMode | undefined,
391
+ mode: optionalEnum(value.mode, `${field}.mode`, VALID_WORKSPACE_MODES, "shared") as WorkspaceMode,
392
+ requestedMode: optionalEnum(value.requestedMode, `${field}.requestedMode`, VALID_WORKSPACE_MODES) as WorkspaceMode | undefined,
470
393
  repoRoot: cleanString(value.repoRoot, `${field}.repoRoot`, { max: 1000 }),
471
394
  sourceCwd: cleanString(value.sourceCwd, `${field}.sourceCwd`, { max: 1000 }),
472
395
  worktreePath: cleanString(value.worktreePath, `${field}.worktreePath`, { max: 1000 }),
473
396
  branch: cleanString(value.branch, `${field}.branch`, { max: 240 }),
474
397
  baseRef: cleanString(value.baseRef, `${field}.baseRef`, { max: 240 }),
475
398
  baseSha: cleanString(value.baseSha, `${field}.baseSha`, { max: 80 }),
476
- status: cleanEnum(value.status, `${field}.status`, VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined,
399
+ status: optionalEnum(value.status, `${field}.status`, VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined,
477
400
  stewardAgentId: cleanString(value.stewardAgentId, `${field}.stewardAgentId`, { max: 240 }),
478
401
  probe: isRecord(value.probe) ? value.probe as unknown as WorkspaceProbe : undefined,
479
402
  };
@@ -547,16 +470,16 @@ function normalizeAgentInput(body: unknown): RegisterAgentInput {
547
470
  const input: RegisterAgentInput = {
548
471
  id: cleanString(body.id, "id", { required: true, max: 200 })!,
549
472
  name: cleanString(body.name, "name", { required: true, max: 200 })!,
550
- kind: cleanEnum(body.kind, "kind", VALID_AGENT_KINDS) as AgentKind | undefined,
473
+ kind: optionalEnum(body.kind, "kind", VALID_AGENT_KINDS) as AgentKind | undefined,
551
474
  status: status as RegisterAgentInput["status"] | undefined,
552
475
  ready: body.ready as boolean | undefined,
553
476
  };
554
477
 
555
478
  const label = cleanNullableString(body.label, "label", 120);
556
479
  if (label !== undefined) input.label = label;
557
- const tags = cleanStringArray(body.tags, "tags");
480
+ const tags = cleanStringArray(body.tags, "tags", { itemMax: 80, maxItems: 50 });
558
481
  if (tags) input.tags = tags;
559
- const capabilities = cleanStringArray(body.capabilities, "capabilities");
482
+ const capabilities = cleanStringArray(body.capabilities, "capabilities", { itemMax: 80, maxItems: 50 });
560
483
  if (capabilities) input.capabilities = capabilities;
561
484
  const machine = cleanString(body.machine, "machine", { max: 120 });
562
485
  if (machine) input.machine = machine;
@@ -655,14 +578,14 @@ function normalizeChannelBindingInput(body: unknown): {
655
578
  if (!isRecord(body)) throw new ValidationError("JSON object body required");
656
579
  const channelId = cleanString(body.channelId, "channelId", { required: true, max: 200 })!;
657
580
  const conversationId = cleanString(body.conversationId, "conversationId", { max: 300 });
658
- const mode = cleanEnum(body.mode, "mode", VALID_CHANNEL_BINDING_MODES, "exclusive") as ChannelBindingMode | undefined;
581
+ const mode = optionalEnum(body.mode, "mode", VALID_CHANNEL_BINDING_MODES, "exclusive") as ChannelBindingMode | undefined;
659
582
  const priority = typeof body.priority === "number" && Number.isSafeInteger(body.priority) ? body.priority : undefined;
660
583
 
661
584
  let target: ChannelRouteTarget | undefined;
662
585
  if (typeof body.target === "string") {
663
586
  target = routeTargetFromAddress(body.target);
664
587
  } else if (isRecord(body.target)) {
665
- const type = cleanEnum(body.target.type, "target.type", VALID_CHANNEL_BINDING_TARGET_TYPES)!;
588
+ const type = optionalEnum(body.target.type, "target.type", VALID_CHANNEL_BINDING_TARGET_TYPES)!;
666
589
  const id = type === "broadcast" ? undefined : cleanString(body.target.id, "target.id", { required: true, max: 240 })!;
667
590
  if (type === "orchestrator") throw new ValidationError("orchestrator channel targets are not supported yet");
668
591
  target = type === "broadcast" ? { type: "broadcast" } : { type, id } as ChannelRouteTarget;
@@ -783,8 +706,8 @@ function validateChannelAttachmentItem(item: unknown, field: string): void {
783
706
  if (!isRecord(item)) throw new ValidationError(`${field} must be an object`);
784
707
  const artifactId = cleanString(item.artifactId, `${field}.artifactId`, { max: 120 });
785
708
  if (artifactId) {
786
- cleanEnum(item.kind, `${field}.kind`, VALID_ARTIFACT_KINDS);
787
- cleanEnum(item.role, `${field}.role`, VALID_ARTIFACT_ROLES);
709
+ optionalEnum(item.kind, `${field}.kind`, VALID_ARTIFACT_KINDS);
710
+ optionalEnum(item.role, `${field}.role`, VALID_ARTIFACT_ROLES);
788
711
  cleanString(item.title, `${field}.title`, { max: 240 });
789
712
  if (item.ref !== undefined) validateChannelAttachmentSourceRef(item.ref, `${field}.ref`);
790
713
  return;
@@ -819,8 +742,8 @@ function cleanAttachmentRefs(value: unknown, field = "attachments"): AttachmentR
819
742
  const normalizedRef = isRecord(ref) ? { ref: { ...ref } as AttachmentRef["ref"] } : {};
820
743
  return {
821
744
  artifactId: cleanString(item.artifactId, `${field}[${index}].artifactId`, { required: true, max: 120 })!,
822
- kind: cleanEnum(item.kind, `${field}[${index}].kind`, VALID_ARTIFACT_KINDS) as ArtifactKind | undefined,
823
- role: cleanEnum(item.role, `${field}[${index}].role`, VALID_ARTIFACT_ROLES) as "media" | "patch" | "report" | "log" | "output" | "input" | undefined,
745
+ kind: optionalEnum(item.kind, `${field}[${index}].kind`, VALID_ARTIFACT_KINDS) as ArtifactKind | undefined,
746
+ role: optionalEnum(item.role, `${field}[${index}].role`, VALID_ARTIFACT_ROLES) as "media" | "patch" | "report" | "log" | "output" | "input" | undefined,
824
747
  title: cleanString(item.title, `${field}[${index}].title`, { max: 240 }),
825
748
  ...normalizedRef,
826
749
  ...(isRecord(item.metadata) ? { metadata: item.metadata } : {}),
@@ -828,14 +751,6 @@ function cleanAttachmentRefs(value: unknown, field = "attachments"): AttachmentR
828
751
  });
829
752
  }
830
753
 
831
- function bytesToStream(bytes: Uint8Array): ReadableStream<Uint8Array> {
832
- return new ReadableStream<Uint8Array>({
833
- start(controller) {
834
- controller.enqueue(bytes);
835
- controller.close();
836
- },
837
- });
838
- }
839
754
 
840
755
  function normalizeChannelEventBody(body: unknown): {
841
756
  body: string;
@@ -869,11 +784,11 @@ function requireChannelSession(req: Request, body: unknown, channel: ChannelSumm
869
784
 
870
785
  function normalizeIntegrationEvent(body: unknown): IntegrationEventInput {
871
786
  if (!isRecord(body)) throw new ValidationError("JSON object body required");
872
- const status = cleanEnum(body.status, "status", [...VALID_TASK_STATUSES, "resolved"] as const);
787
+ const status = optionalEnum(body.status, "status", [...VALID_TASK_STATUSES, "resolved"] as const);
873
788
  return {
874
789
  source: cleanString(body.source, "source", { max: 120 }),
875
790
  type: cleanString(body.type, "type", { max: 80 }) ?? "event",
876
- severity: cleanEnum(body.severity, "severity", VALID_TASK_SEVERITIES, "info"),
791
+ severity: optionalEnum(body.severity, "severity", VALID_TASK_SEVERITIES, "info"),
877
792
  status,
878
793
  title: cleanString(body.title, "title", { required: true, max: 240 })!,
879
794
  body: cleanString(body.body, "body", { required: true, max: MAX_BODY_BYTES })!,
@@ -911,13 +826,13 @@ function normalizeIntegrationRegistryInput(name: string, body: unknown): {
911
826
  displayName: cleanString(body.displayName, "displayName", { max: 120 }),
912
827
  description: cleanString(body.description, "description", { max: 1000 }),
913
828
  enabled: body.enabled as boolean | undefined,
914
- scopes: cleanStringArray(body.scopes, "scopes"),
915
- targets: cleanStringArray(body.targets, "targets"),
916
- channels: cleanStringArray(body.channels, "channels"),
829
+ scopes: cleanStringArray(body.scopes, "scopes", { itemMax: 80, maxItems: 50 }),
830
+ targets: cleanStringArray(body.targets, "targets", { itemMax: 80, maxItems: 50 }),
831
+ channels: cleanStringArray(body.channels, "channels", { itemMax: 80, maxItems: 50 }),
917
832
  type: cleanString(body.type, "type", { max: 80 }),
918
833
  icon: cleanString(body.icon, "icon", { max: 80 }),
919
834
  accentColor: cleanString(body.accentColor, "accentColor", { max: 32 }),
920
- tags: cleanStringArray(body.tags, "tags"),
835
+ tags: cleanStringArray(body.tags, "tags", { itemMax: 80, maxItems: 50 }),
921
836
  homepageUrl: cleanString(body.homepageUrl, "homepageUrl", { max: 500 }),
922
837
  repositoryUrl: cleanString(body.repositoryUrl, "repositoryUrl", { max: 500 }),
923
838
  docsUrl: cleanString(body.docsUrl, "docsUrl", { max: 500 }),
@@ -975,7 +890,7 @@ function resolveIntegrationEventTarget(input: IntegrationEventInput): Integratio
975
890
 
976
891
  function normalizeTaskStatusInput(body: unknown): TaskStatusInput {
977
892
  if (!isRecord(body)) throw new ValidationError("JSON object body required");
978
- const status = cleanEnum(body.status, "status", VALID_TASK_STATUSES);
893
+ const status = optionalEnum(body.status, "status", VALID_TASK_STATUSES);
979
894
  if (!status) throw new ValidationError("status required");
980
895
  return {
981
896
  status: status as TaskStatus,
@@ -1111,7 +1026,7 @@ function normalizeActivityInput(body: unknown): ActivityEventInput {
1111
1026
  return {
1112
1027
  operatorId: cleanOperatorId(body.operatorId),
1113
1028
  clientId: cleanString(body.clientId, "clientId", { max: 240 }),
1114
- kind: cleanEnum(body.kind, "kind", VALID_ACTIVITY_KINDS, "operator") as ActivityKind,
1029
+ kind: optionalEnum(body.kind, "kind", VALID_ACTIVITY_KINDS, "operator") as ActivityKind,
1115
1030
  title: cleanString(body.title, "title", { required: true, max: 200 })!,
1116
1031
  body: cleanString(body.body, "body", { max: 1000 }),
1117
1032
  meta: cleanString(body.meta, "meta", { max: 500 }),
@@ -1241,7 +1156,7 @@ async function postCallback(deliveryId: number, url: string, payload: unknown):
1241
1156
  });
1242
1157
  finishCallbackDelivery(deliveryId, response.ok, response.ok ? undefined : `${response.status} ${response.statusText}`);
1243
1158
  } catch (e) {
1244
- finishCallbackDelivery(deliveryId, false, e instanceof Error ? e.message : String(e));
1159
+ finishCallbackDelivery(deliveryId, false, errMessage(e));
1245
1160
  } finally {
1246
1161
  clearTimeout(timeout);
1247
1162
  }
@@ -1558,8 +1473,8 @@ async function artifactUploadInput(req: Request): Promise<{
1558
1473
  const file = form.get("file");
1559
1474
  if (!(file instanceof File)) throw new ValidationError("multipart upload requires file");
1560
1475
  if (file.size > maxBytes) throw new ValidationError(`artifact exceeds ${maxBytes} bytes`);
1561
- const sensitivity = cleanEnum(form.get("sensitivity"), "sensitivity", VALID_ARTIFACT_SENSITIVITIES, "normal") as ArtifactSensitivity;
1562
- const kind = cleanEnum(form.get("kind"), "kind", VALID_ARTIFACT_KINDS) as ArtifactKind | undefined;
1476
+ const sensitivity = optionalEnum(form.get("sensitivity"), "sensitivity", VALID_ARTIFACT_SENSITIVITIES, "normal") as ArtifactSensitivity;
1477
+ const kind = optionalEnum(form.get("kind"), "kind", VALID_ARTIFACT_KINDS) as ArtifactKind | undefined;
1563
1478
  const expiresAtRaw = form.get("expiresAt");
1564
1479
  const expiresAt = typeof expiresAtRaw === "string" && expiresAtRaw.trim() ? Number(expiresAtRaw) : undefined;
1565
1480
  if (expiresAt !== undefined && (!Number.isSafeInteger(expiresAt) || expiresAt <= Date.now())) throw new ValidationError("expiresAt must be a future unix timestamp in milliseconds");
@@ -1587,8 +1502,8 @@ async function artifactUploadInput(req: Request): Promise<{
1587
1502
  filename: cleanString(req.headers.get("X-Artifact-Filename"), "filename", { max: 240 }),
1588
1503
  mediaType: contentType || undefined,
1589
1504
  digest: cleanString(req.headers.get("X-Artifact-Digest"), "digest", { max: 80 }),
1590
- sensitivity: cleanEnum(req.headers.get("X-Artifact-Sensitivity"), "sensitivity", VALID_ARTIFACT_SENSITIVITIES, "normal") as ArtifactSensitivity,
1591
- kind: cleanEnum(req.headers.get("X-Artifact-Kind"), "kind", VALID_ARTIFACT_KINDS) as ArtifactKind | undefined,
1505
+ sensitivity: optionalEnum(req.headers.get("X-Artifact-Sensitivity"), "sensitivity", VALID_ARTIFACT_SENSITIVITIES, "normal") as ArtifactSensitivity,
1506
+ kind: optionalEnum(req.headers.get("X-Artifact-Kind"), "kind", VALID_ARTIFACT_KINDS) as ArtifactKind | undefined,
1592
1507
  metadata: undefined,
1593
1508
  expiresAt,
1594
1509
  };
@@ -1601,10 +1516,10 @@ function cleanMemoryQueryFromParams(params: URLSearchParams): MemoryQuery {
1601
1516
  const visibility = params.get("visibility");
1602
1517
  const minRelevance = params.get("minRelevance");
1603
1518
  const limit = params.get("limit");
1604
- if (type) query.type = cleanEnum(type, "type", VALID_MEMORY_TYPES) as MemoryType;
1519
+ if (type) query.type = optionalEnum(type, "type", VALID_MEMORY_TYPES) as MemoryType;
1605
1520
  if (scope) query.scope = cleanString(scope, "scope", { max: 240 });
1606
1521
  const tags = [...params.getAll("tag"), ...(params.get("tags")?.split(",") ?? [])].map((tag) => tag.trim()).filter(Boolean);
1607
- if (tags.length) query.tags = cleanStringArray(tags, "tags");
1522
+ if (tags.length) query.tags = cleanStringArray(tags, "tags", { itemMax: 80, maxItems: 50 });
1608
1523
  if (minRelevance !== null) {
1609
1524
  const parsed = Number(minRelevance);
1610
1525
  if (!Number.isFinite(parsed) || parsed < 0 || parsed > 1) throw new ValidationError("minRelevance must be between 0 and 1");
@@ -1615,7 +1530,7 @@ function cleanMemoryQueryFromParams(params: URLSearchParams): MemoryQuery {
1615
1530
  if (!Number.isSafeInteger(parsed) || parsed <= 0 || parsed > 100) throw new ValidationError("limit must be an integer between 1 and 100");
1616
1531
  query.limit = parsed;
1617
1532
  }
1618
- if (visibility) query.visibility = cleanEnum(visibility, "visibility", VALID_MEMORY_VISIBILITIES) as MemoryVisibility;
1533
+ if (visibility) query.visibility = optionalEnum(visibility, "visibility", VALID_MEMORY_VISIBILITIES) as MemoryVisibility;
1619
1534
  query.includeExpired = params.get("includeExpired") === "true" ? true : undefined;
1620
1535
  query.includeSensitive = params.get("includeSensitive") === "true" ? true : undefined;
1621
1536
  return query;
@@ -1624,9 +1539,9 @@ function cleanMemoryQueryFromParams(params: URLSearchParams): MemoryQuery {
1624
1539
  function cleanMemoryQueryFromBody(body: unknown): MemoryQuery {
1625
1540
  if (!isRecord(body)) throw new ValidationError("memory query body must be an object");
1626
1541
  const query: MemoryQuery = {};
1627
- query.type = cleanEnum(body.type, "type", VALID_MEMORY_TYPES) as MemoryType | undefined;
1542
+ query.type = optionalEnum(body.type, "type", VALID_MEMORY_TYPES) as MemoryType | undefined;
1628
1543
  query.scope = cleanString(body.scope, "scope", { max: 240 });
1629
- query.tags = cleanStringArray(body.tags, "tags");
1544
+ query.tags = cleanStringArray(body.tags, "tags", { itemMax: 80, maxItems: 50 });
1630
1545
  if (body.minRelevance !== undefined) {
1631
1546
  if (typeof body.minRelevance !== "number" || body.minRelevance < 0 || body.minRelevance > 1) throw new ValidationError("minRelevance must be between 0 and 1");
1632
1547
  query.minRelevance = body.minRelevance;
@@ -1636,7 +1551,7 @@ function cleanMemoryQueryFromBody(body: unknown): MemoryQuery {
1636
1551
  query.limit = body.limit;
1637
1552
  }
1638
1553
  query.includeExpired = typeof body.includeExpired === "boolean" ? body.includeExpired : undefined;
1639
- query.visibility = cleanEnum(body.visibility, "visibility", VALID_MEMORY_VISIBILITIES) as MemoryVisibility | undefined;
1554
+ query.visibility = optionalEnum(body.visibility, "visibility", VALID_MEMORY_VISIBILITIES) as MemoryVisibility | undefined;
1640
1555
  query.includeSensitive = typeof body.includeSensitive === "boolean" ? body.includeSensitive : undefined;
1641
1556
  return query;
1642
1557
  }
@@ -1658,15 +1573,15 @@ function cleanCreateMemoryInput(body: unknown, ctx: MemoryBrokerContext): Create
1658
1573
  ? undefined
1659
1574
  : cleanMemoryPositiveInteger(body.ttlMs, "ttlMs");
1660
1575
  return {
1661
- type: cleanEnum(body.type, "type", VALID_MEMORY_TYPES) as MemoryType,
1576
+ type: optionalEnum(body.type, "type", VALID_MEMORY_TYPES) as MemoryType,
1662
1577
  scope: cleanString(body.scope, "scope", { required: true, max: 240 })!,
1663
1578
  title: cleanString(body.title, "title", { required: true, max: 240 })!,
1664
1579
  content: cleanString(body.content, "content", { required: true, max: MAX_BODY_BYTES })!,
1665
- tags: cleanStringArray(body.tags, "tags"),
1666
- visibility: cleanEnum(body.visibility, "visibility", VALID_MEMORY_VISIBILITIES) as MemoryVisibility | undefined,
1667
- sensitivity: cleanEnum(body.sensitivity, "sensitivity", VALID_MEMORY_SENSITIVITIES) as MemorySensitivity | undefined,
1668
- confidence: cleanEnum(body.confidence, "confidence", VALID_MEMORY_CONFIDENCES) as MemoryConfidence | undefined,
1669
- redactionState: cleanEnum(body.redactionState, "redactionState", VALID_MEMORY_REDACTION_STATES) as MemoryRedactionState | undefined,
1580
+ tags: cleanStringArray(body.tags, "tags", { itemMax: 80, maxItems: 50 }),
1581
+ visibility: optionalEnum(body.visibility, "visibility", VALID_MEMORY_VISIBILITIES) as MemoryVisibility | undefined,
1582
+ sensitivity: optionalEnum(body.sensitivity, "sensitivity", VALID_MEMORY_SENSITIVITIES) as MemorySensitivity | undefined,
1583
+ confidence: optionalEnum(body.confidence, "confidence", VALID_MEMORY_CONFIDENCES) as MemoryConfidence | undefined,
1584
+ redactionState: optionalEnum(body.redactionState, "redactionState", VALID_MEMORY_REDACTION_STATES) as MemoryRedactionState | undefined,
1670
1585
  relevanceScore: cleanMemoryScore(body.relevanceScore, "relevanceScore"),
1671
1586
  sourceAgent: cleanString(body.sourceAgent, "sourceAgent", { max: 200 }),
1672
1587
  sourceTask: body.sourceTask === undefined ? undefined : cleanMemoryPositiveInteger(body.sourceTask, "sourceTask"),
@@ -1681,11 +1596,11 @@ function cleanUpdateMemoryInput(body: unknown): UpdateMemoryInput {
1681
1596
  const patch: UpdateMemoryInput = {};
1682
1597
  const title = cleanString(body.title, "title", { max: 240 });
1683
1598
  const content = cleanString(body.content, "content", { max: MAX_BODY_BYTES });
1684
- const tags = cleanStringArray(body.tags, "tags");
1685
- const visibility = cleanEnum(body.visibility, "visibility", VALID_MEMORY_VISIBILITIES) as MemoryVisibility | undefined;
1686
- const sensitivity = cleanEnum(body.sensitivity, "sensitivity", VALID_MEMORY_SENSITIVITIES) as MemorySensitivity | undefined;
1687
- const confidence = cleanEnum(body.confidence, "confidence", VALID_MEMORY_CONFIDENCES) as MemoryConfidence | undefined;
1688
- const redactionState = cleanEnum(body.redactionState, "redactionState", VALID_MEMORY_REDACTION_STATES) as MemoryRedactionState | undefined;
1599
+ const tags = cleanStringArray(body.tags, "tags", { itemMax: 80, maxItems: 50 });
1600
+ const visibility = optionalEnum(body.visibility, "visibility", VALID_MEMORY_VISIBILITIES) as MemoryVisibility | undefined;
1601
+ const sensitivity = optionalEnum(body.sensitivity, "sensitivity", VALID_MEMORY_SENSITIVITIES) as MemorySensitivity | undefined;
1602
+ const confidence = optionalEnum(body.confidence, "confidence", VALID_MEMORY_CONFIDENCES) as MemoryConfidence | undefined;
1603
+ const redactionState = optionalEnum(body.redactionState, "redactionState", VALID_MEMORY_REDACTION_STATES) as MemoryRedactionState | undefined;
1689
1604
  const relevanceScore = cleanMemoryScore(body.relevanceScore, "relevanceScore");
1690
1605
  const metadata = cleanParams(body.metadata, "metadata");
1691
1606
  if (title !== undefined) patch.title = title;
@@ -1714,7 +1629,7 @@ function cleanMemoryInjectInput(body: unknown): {
1714
1629
  } {
1715
1630
  if (!isRecord(body)) throw new ValidationError("memory injection body must be an object");
1716
1631
  const agentId = cleanString(body.agentId ?? body.target, "agentId", { required: true, max: 200 })!;
1717
- const memoryIds = cleanStringArray(body.memoryIds, "memoryIds") ?? [];
1632
+ const memoryIds = cleanStringArray(body.memoryIds, "memoryIds", { itemMax: 80, maxItems: 50 }) ?? [];
1718
1633
  const query = body.query === undefined ? undefined : cleanMemoryQueryFromBody(body.query);
1719
1634
  if (memoryIds.length === 0 && !query) throw new ValidationError("memoryIds or query required");
1720
1635
  return {
@@ -1736,8 +1651,8 @@ function cleanTaskRoutingHints(value: unknown): TaskRoutingHints | undefined {
1736
1651
  title: cleanString(value.title, "task.title", { max: 240 }),
1737
1652
  text: cleanString(value.text, "task.text", { max: 4000 }),
1738
1653
  scope: cleanString(value.scope, "task.scope", { max: 240 }),
1739
- tags: cleanStringArray(value.tags, "task.tags"),
1740
- capabilities: cleanStringArray(value.capabilities, "task.capabilities"),
1654
+ tags: cleanStringArray(value.tags, "task.tags", { itemMax: 80, maxItems: 50 }),
1655
+ capabilities: cleanStringArray(value.capabilities, "task.capabilities", { itemMax: 80, maxItems: 50 }),
1741
1656
  target: cleanString(value.target, "task.target", { max: 200 }),
1742
1657
  };
1743
1658
  }
@@ -2100,7 +2015,7 @@ const patchAgentTags: Handler = async (req, params) => {
2100
2015
  const parsed = await parseBody<unknown>(req);
2101
2016
  if (!parsed.ok) return error(parsed.error, parsed.status);
2102
2017
  try {
2103
- const tags = isRecord(parsed.body) ? cleanStringArray(parsed.body.tags, "tags") : undefined;
2018
+ const tags = isRecord(parsed.body) ? cleanStringArray(parsed.body.tags, "tags", { itemMax: 80, maxItems: 50 }) : undefined;
2104
2019
  if (!tags) return error("tags field required");
2105
2020
  const before = getAgent(params.id!);
2106
2021
  const agent = setTags(params.id!, tags);
@@ -2237,18 +2152,15 @@ function agentRuntimeProvider(agent: AgentCard): string | undefined {
2237
2152
 
2238
2153
  function managedControlOrchestrator(agent: AgentCard): NonNullable<ReturnType<typeof getOrchestrator>> | null {
2239
2154
  if (agent.meta?.runnerManaged !== true) return null;
2240
- const metaSessionName = typeof agent.meta.sessionName === "string" ? agent.meta.sessionName : "";
2241
- const metaTmuxSession = typeof agent.meta.tmuxSession === "string" ? agent.meta.tmuxSession : "";
2242
- const metaPolicyName = typeof agent.meta.policyName === "string" ? agent.meta.policyName : "";
2243
- const metaSpawnRequestId = typeof agent.meta.spawnRequestId === "string" ? agent.meta.spawnRequestId : "";
2155
+ const str = (v: unknown): string | undefined => (typeof v === "string" ? v : undefined);
2244
2156
  const candidates = [
2245
- ...listOrchestrators().filter((orch) => orch.managedAgents.some((managed) =>
2246
- managed.agentId === agent.id ||
2247
- (!!metaSessionName && managed.sessionName === metaSessionName) ||
2248
- (!!metaTmuxSession && managed.tmuxSession === metaTmuxSession) ||
2249
- (!!metaPolicyName && managed.policyName === metaPolicyName) ||
2250
- (!!metaSpawnRequestId && managed.spawnRequestId === metaSpawnRequestId)
2251
- )),
2157
+ ...listManagedOrchestratorsForAgent({
2158
+ agentId: agent.id,
2159
+ sessionName: str(agent.meta.sessionName),
2160
+ tmuxSession: str(agent.meta.tmuxSession),
2161
+ policyName: str(agent.meta.policyName),
2162
+ spawnRequestId: str(agent.meta.spawnRequestId),
2163
+ }),
2252
2164
  ...(agent.machine ? [getOrchestrator(agent.machine)].filter((orch): orch is NonNullable<ReturnType<typeof getOrchestrator>> => Boolean(orch)) : []),
2253
2165
  ];
2254
2166
  return candidates.find((orch) => orch.status === "online") ?? null;
@@ -2281,39 +2193,22 @@ function restartSpawnParamsForAgent(
2281
2193
  const profileName = metaString(agent.meta, "profile");
2282
2194
  const agentProfile = profileName ? getAgentProfile(profileName)?.value : undefined;
2283
2195
  const workspaceMode = metaString(agent.meta, "workspaceMode") ?? "inherit";
2284
- let resolvedModel: Record<string, string> = {};
2285
- if (model || effort) {
2286
- try {
2287
- const selection = resolveProviderSelection({ provider, model, effort });
2288
- resolvedModel = {
2289
- ...(selection.modelAlias ? { model: selection.modelAlias } : {}),
2290
- ...(selection.providerModel ? { providerModel: selection.providerModel } : {}),
2291
- ...(selection.effort ? { effort: selection.effort } : {}),
2292
- };
2293
- } catch {
2294
- resolvedModel = {
2295
- ...(model ? { model } : {}),
2296
- ...(effort ? { effort } : {}),
2297
- };
2298
- }
2299
- }
2300
- const params = {
2301
- action: "spawn",
2196
+ const resolvedModel = resolveSpawnModelParams(provider, model, effort, { onError: "passthrough", skipDefaultWhenEmpty: true });
2197
+ const params = buildSpawnCommand({
2302
2198
  provider,
2303
- ...resolvedModel,
2199
+ modelParams: resolvedModel,
2304
2200
  cwd,
2305
2201
  workspaceMode,
2306
- ...(profileName ? { profile: profileName } : {}),
2307
- ...(agentProfile ? { agentProfile } : {}),
2308
- ...workspaceSpawnParams(),
2309
- ...(label ? { label } : {}),
2202
+ profile: profileName || undefined,
2203
+ agentProfile,
2204
+ label: label || undefined,
2310
2205
  agentId: agent.id,
2311
2206
  tags: agent.tags,
2312
2207
  capabilities: agent.capabilities,
2313
2208
  approvalMode: approvalMode ?? "guarded",
2314
2209
  permissionMode: approvalMode ?? "guarded",
2315
- ...(providerArgs.length ? { providerArgs } : {}),
2316
- ...(policyName ? { policyName } : {}),
2210
+ providerArgs: providerArgs.length ? providerArgs : undefined,
2211
+ policyName: policyName || undefined,
2317
2212
  headless: true,
2318
2213
  spawnRequestId: requestId,
2319
2214
  env: runnerRuntimeTokenEnv({
@@ -2327,12 +2222,12 @@ function restartSpawnParamsForAgent(
2327
2222
  }),
2328
2223
  requestedBy,
2329
2224
  requestedAt: Date.now(),
2330
- };
2225
+ });
2331
2226
  return opts.resumeId ? withClaudeResumeParams(params, opts.resumeId, agent.id) : params;
2332
2227
  }
2333
2228
 
2334
2229
  function spawnRequestIdForRestart(): string {
2335
- return `sp_${crypto.randomUUID()}`;
2230
+ return generateSpawnRequestId();
2336
2231
  }
2337
2232
 
2338
2233
  function withClaudeResumeParams(params: Record<string, unknown>, resumeId: string, agentId: string): Record<string, unknown> {
@@ -2388,7 +2283,7 @@ const postAgentAction: Handler = async (req, params) => {
2388
2283
  if (!parsed.ok) return error(parsed.error, parsed.status);
2389
2284
  try {
2390
2285
  if (!isRecord(parsed.body)) return error("action required");
2391
- const action = cleanEnum(parsed.body.action, "action", VALID_AGENT_ACTIONS);
2286
+ const action = optionalEnum(parsed.body.action, "action", VALID_AGENT_ACTIONS);
2392
2287
  if (!action) return error("action required");
2393
2288
  const agent = getAgent(params.id!);
2394
2289
  if (!agent) return error("agent not found", 404);
@@ -2504,7 +2399,7 @@ const postAgentPermissionDecision: Handler = async (req, params) => {
2504
2399
  try {
2505
2400
  if (!isRecord(parsed.body)) return error("permission decision required");
2506
2401
  const approvalId = cleanString(parsed.body.approvalId, "approvalId", { required: true, max: 240 })!;
2507
- const decision = cleanEnum(parsed.body.decision, "decision", ["approve", "approve-session", "deny", "abort", "answer"] as const);
2402
+ const decision = optionalEnum(parsed.body.decision, "decision", ["approve", "approve-session", "deny", "abort", "answer"] as const);
2508
2403
  if (!decision) return error("decision required");
2509
2404
  const reason = cleanString(parsed.body.reason, "reason", { max: 500 });
2510
2405
  // AskUserQuestion answers: { "<question text>": "<chosen label(s)>" }
@@ -2633,20 +2528,20 @@ const postAgentSpawn: Handler = async (req) => {
2633
2528
  if (!parsed.ok) return error(parsed.error, parsed.status);
2634
2529
  try {
2635
2530
  if (!isRecord(parsed.body)) return error("provider required");
2636
- const provider = cleanEnum(parsed.body.provider, "provider", [...VALID_AGENT_SPAWN_PROVIDERS, "claude"] as const);
2531
+ const provider = optionalEnum(parsed.body.provider, "provider", SPAWN_PROVIDERS);
2637
2532
  if (!provider) return error("provider required");
2638
2533
  const selection = cleanSpawnSelection(parsed.body, provider as SpawnProvider);
2639
- const approvalMode = cleanEnum(parsed.body.approvalMode, "approvalMode", VALID_CODEX_SPAWN_APPROVALS, "guarded") as SpawnApprovalMode;
2534
+ const approvalMode = optionalEnum(parsed.body.approvalMode, "approvalMode", APPROVAL_MODES, "guarded") as SpawnApprovalMode;
2640
2535
  const cwd = cleanString(parsed.body.cwd, "cwd", { max: 500 });
2641
2536
  const label = cleanString(parsed.body.label, "label", { max: 120 });
2642
- const workspaceMode = cleanEnum(parsed.body.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES, "inherit") as WorkspaceMode;
2537
+ const workspaceMode = optionalEnum(parsed.body.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES, "inherit") as WorkspaceMode;
2643
2538
 
2644
2539
  const orchestrators = listOrchestrators().filter(
2645
2540
  (o) => o.status === "online" && o.providers.includes(provider as SpawnProvider),
2646
2541
  );
2647
2542
  const orch = orchestrators[0];
2648
2543
  if (!orch) return error("no orchestrator available for provider: " + provider);
2649
- if (cwd && !pathWithinBase(cwd, orch.baseDir)) {
2544
+ if (cwd && !isPathWithinBase(cwd, orch.baseDir)) {
2650
2545
  return error(`cwd must be within orchestrator base directory: ${orch.baseDir}`);
2651
2546
  }
2652
2547
  const requestId = spawnRequestId();
@@ -2660,10 +2555,9 @@ const postAgentSpawn: Handler = async (req) => {
2660
2555
  source: "system",
2661
2556
  target: orch.agentId,
2662
2557
  correlationId: requestId,
2663
- params: {
2664
- action: "spawn",
2558
+ params: buildSpawnCommand({
2665
2559
  provider,
2666
- ...selection,
2560
+ modelParams: selection,
2667
2561
  cwd: cwd || orch.baseDir,
2668
2562
  workspaceMode,
2669
2563
  label,
@@ -2679,7 +2573,7 @@ const postAgentSpawn: Handler = async (req) => {
2679
2573
  spawnRequestId: requestId,
2680
2574
  createdBy: "dashboard",
2681
2575
  }),
2682
- },
2576
+ }),
2683
2577
  });
2684
2578
  emitCommand(command);
2685
2579
  auditEvent({
@@ -2837,7 +2731,7 @@ function cleanTokenProfileInput(body: unknown, partial = false) {
2837
2731
  const name = cleanString(body.name, "name", { required: !partial, max: 120 });
2838
2732
  const description = cleanString(body.description, "description", { max: 500 });
2839
2733
  const role = cleanString(body.role, "role", { required: !partial, max: 80 });
2840
- const scope = cleanStringArray(body.scope ?? body.scopes, "scope");
2734
+ const scope = cleanStringArray(body.scope ?? body.scopes, "scope", { itemMax: 80, maxItems: 50 });
2841
2735
  if (!partial && !scope?.length) throw new ValidationError("scope must contain at least one scope");
2842
2736
  const constraints = body.constraints === null ? undefined : cleanTokenConstraints(body.constraints);
2843
2737
  const ttlSeconds = cleanTtlSeconds(body.ttlSeconds);
@@ -2861,7 +2755,7 @@ const postToken: Handler = async (req) => {
2861
2755
  const role = cleanString(parsed.body.role, "role", { max: 80 }) ?? profile?.role;
2862
2756
  if (!role) return error("role required");
2863
2757
  const sub = cleanString(parsed.body.sub, "sub", { max: 160 }) ?? role;
2864
- const scope = cleanStringArray(parsed.body.scope ?? parsed.body.scopes, "scope");
2758
+ const scope = cleanStringArray(parsed.body.scope ?? parsed.body.scopes, "scope", { itemMax: 80, maxItems: 50 });
2865
2759
  const constraints = cleanTokenConstraints(parsed.body.constraints);
2866
2760
  const createdBy = cleanString(parsed.body.createdBy, "createdBy", { max: 120 });
2867
2761
  const ttlSeconds = cleanTtlSeconds(parsed.body.ttlSeconds);
@@ -2949,7 +2843,7 @@ const postInteractiveRunnerRuntimeToken: Handler = async (req) => {
2949
2843
  if (!parsed.ok) return error(parsed.error, parsed.status);
2950
2844
  try {
2951
2845
  if (!isRecord(parsed.body)) return error("runtime token body required");
2952
- const provider = cleanEnum(parsed.body.provider, "provider", VALID_ORCHESTRATOR_PROVIDERS)! as SpawnProvider;
2846
+ const provider = optionalEnum(parsed.body.provider, "provider", SPAWN_PROVIDERS)! as SpawnProvider;
2953
2847
  const cwd = cleanString(parsed.body.cwd, "cwd", { required: true, max: 500 })!;
2954
2848
  const runnerId = cleanString(parsed.body.runnerId, "runnerId", { required: true, max: 240 })!;
2955
2849
  const agentId = cleanString(parsed.body.agentId, "agentId", { max: 240 });
@@ -2996,9 +2890,9 @@ const postMcpRuntimeToken: Handler = async (req) => {
2996
2890
  if (!isRecord(parsed.body)) return error("runtime token body required");
2997
2891
  const sessionId = cleanString(parsed.body.sessionId, "sessionId", { required: true, max: 240 })!;
2998
2892
  const agentId = cleanString(parsed.body.agentId, "agentId", { max: 240 });
2999
- const targets = cleanStringArray(parsed.body.targets, "targets");
3000
- const channels = cleanStringArray(parsed.body.channels, "channels");
3001
- const memoryScopes = cleanStringArray(parsed.body.memoryScopes, "memoryScopes");
2893
+ const targets = cleanStringArray(parsed.body.targets, "targets", { itemMax: 80, maxItems: 50 });
2894
+ const channels = cleanStringArray(parsed.body.channels, "channels", { itemMax: 80, maxItems: 50 });
2895
+ const memoryScopes = cleanStringArray(parsed.body.memoryScopes, "memoryScopes", { itemMax: 80, maxItems: 50 });
3002
2896
  const runtimeToken = issueMcpRuntimeToken({
3003
2897
  sessionId,
3004
2898
  agentId,
@@ -3037,8 +2931,8 @@ const postIntegrationRuntimeToken: Handler = async (req) => {
3037
2931
  try {
3038
2932
  if (!isRecord(parsed.body)) return error("runtime token body required");
3039
2933
  const name = cleanString(parsed.body.name, "name", { required: true, max: 120 })!;
3040
- const targets = cleanStringArray(parsed.body.targets, "targets");
3041
- const channels = cleanStringArray(parsed.body.channels, "channels");
2934
+ const targets = cleanStringArray(parsed.body.targets, "targets", { itemMax: 80, maxItems: 50 });
2935
+ const channels = cleanStringArray(parsed.body.channels, "channels", { itemMax: 80, maxItems: 50 });
3042
2936
  const runtimeToken = issueIntegrationRuntimeToken({
3043
2937
  name,
3044
2938
  targets,
@@ -3131,9 +3025,6 @@ const postTokenRevoke: Handler = (_req, params) => {
3131
3025
 
3132
3026
  // --- Orchestrator routes ---
3133
3027
 
3134
- const VALID_ORCHESTRATOR_PROVIDERS = ["claude", "codex"] as const;
3135
- const VALID_SPAWN_APPROVALS = ["open", "guarded", "read-only"] as const;
3136
-
3137
3028
  function cleanJsonArray(value: unknown, field: string): unknown[] | undefined {
3138
3029
  if (value === undefined || value === null) return undefined;
3139
3030
  if (!Array.isArray(value)) throw new ValidationError(`${field} must be an array`);
@@ -3141,27 +3032,17 @@ function cleanJsonArray(value: unknown, field: string): unknown[] | undefined {
3141
3032
  return value;
3142
3033
  }
3143
3034
 
3144
- function cleanSpawnSelection(body: Record<string, unknown>, provider: SpawnProvider): { model?: string; effort?: ProviderEffort; providerModel?: string } {
3035
+ function cleanSpawnSelection(body: Record<string, unknown>, provider: SpawnProvider): SpawnModelParams {
3145
3036
  const model = cleanString(body.model, "model", { max: 120 });
3146
- const effort = cleanEnum(body.effort, "effort", VALID_PROVIDER_EFFORTS) as ProviderEffort | undefined;
3147
- let resolved;
3148
- try {
3149
- resolved = resolveProviderSelection({ provider, model, effort });
3150
- } catch (error) {
3151
- throw new ValidationError(error instanceof Error ? error.message : String(error));
3152
- }
3153
- return {
3154
- model: resolved.modelAlias,
3155
- providerModel: resolved.providerModel,
3156
- effort: resolved.effort,
3157
- };
3037
+ const effort = optionalEnum(body.effort, "effort", VALID_EFFORTS) as ProviderEffort | undefined;
3038
+ return resolveSpawnModelParams(provider, model, effort);
3158
3039
  }
3159
3040
 
3160
3041
  function validateSpawnSelectionForOrchestrator(
3161
3042
  orch: NonNullable<ReturnType<typeof getOrchestrator>>,
3162
3043
  provider: SpawnProvider,
3163
3044
  body: Record<string, unknown>,
3164
- ): { model?: string; effort?: ProviderEffort; providerModel?: string } | Response {
3045
+ ): SpawnModelParams | Response {
3165
3046
  if (!orch.providers.includes(provider)) {
3166
3047
  return error(`orchestrator does not have provider available: ${provider}`, 409);
3167
3048
  }
@@ -3218,15 +3099,15 @@ const postOrchestrator: Handler = async (req) => {
3218
3099
  const id = cleanString(parsed.body.id, "id", { required: true, max: 120 })!;
3219
3100
  const hostname = cleanString(parsed.body.hostname, "hostname", { required: true, max: 120 })!;
3220
3101
  const baseDir = cleanString(parsed.body.baseDir, "baseDir", { required: true, max: 500 })!;
3221
- const providers = cleanStringArray(parsed.body.providers, "providers") as SpawnProvider[] | undefined;
3102
+ const providers = cleanStringArray(parsed.body.providers, "providers", { itemMax: 80, maxItems: 50 }) as SpawnProvider[] | undefined;
3222
3103
  if (providers) {
3223
3104
  for (const p of providers) {
3224
- if (!VALID_ORCHESTRATOR_PROVIDERS.includes(p as any)) {
3225
- return error(`invalid provider: ${p}. Must be one of: ${VALID_ORCHESTRATOR_PROVIDERS.join(", ")}`);
3105
+ if (!SPAWN_PROVIDERS.includes(p as any)) {
3106
+ return error(`invalid provider: ${p}. Must be one of: ${SPAWN_PROVIDERS.join(", ")}`);
3226
3107
  }
3227
3108
  }
3228
3109
  }
3229
- const envKeys = cleanStringArray(parsed.body.envKeys, "envKeys");
3110
+ const envKeys = cleanStringArray(parsed.body.envKeys, "envKeys", { itemMax: 80, maxItems: 50 });
3230
3111
  const apiUrl = cleanString(parsed.body.apiUrl, "apiUrl", { max: 500 });
3231
3112
  const packageMetadata = cleanRuntimePackageMetadata(parsed.body.package);
3232
3113
  const contracts = cleanRuntimeContracts(parsed.body.contracts);
@@ -3299,13 +3180,13 @@ const postOrchestratorHeartbeat: Handler = async (req, params) => {
3299
3180
  version: cleanString(body.version, "version", { max: 80 }),
3300
3181
  protocolVersion: cleanProtocolVersion(body.protocolVersion),
3301
3182
  gitSha: cleanString(body.gitSha, "gitSha", { max: 80 }),
3302
- providers: cleanStringArray(body.providers, "providers") as SpawnProvider[] | undefined,
3183
+ providers: cleanStringArray(body.providers, "providers", { itemMax: 80, maxItems: 50 }) as SpawnProvider[] | undefined,
3303
3184
  providerStatus: cleanJsonArray(body.providerStatus, "providerStatus") as OrchestratorRuntimeInput["providerStatus"],
3304
3185
  providerCatalog: cleanJsonArray(body.providerCatalog, "providerCatalog") as OrchestratorRuntimeInput["providerCatalog"],
3305
3186
  };
3306
3187
  if (runtime.providers) {
3307
3188
  for (const p of runtime.providers) {
3308
- if (!VALID_ORCHESTRATOR_PROVIDERS.includes(p as any)) throw new ValidationError(`invalid provider: ${p}. Must be one of: ${VALID_ORCHESTRATOR_PROVIDERS.join(", ")}`);
3189
+ if (!SPAWN_PROVIDERS.includes(p as any)) throw new ValidationError(`invalid provider: ${p}. Must be one of: ${SPAWN_PROVIDERS.join(", ")}`);
3309
3190
  }
3310
3191
  }
3311
3192
  const orch = orchestratorHeartbeat(params.id!, runtime);
@@ -3325,16 +3206,16 @@ const postOrchestratorBootstrap: Handler = async (req) => {
3325
3206
  if (!isRecord(parsed.body)) return error("body required");
3326
3207
  const id = cleanString(parsed.body.id, "id", { required: true, max: 120 })!;
3327
3208
  const baseDir = cleanString(parsed.body.baseDir, "baseDir", { required: true, max: 500 })!;
3328
- const providers = cleanStringArray(parsed.body.providers, "providers") as SpawnProvider[] | undefined;
3209
+ const providers = cleanStringArray(parsed.body.providers, "providers", { itemMax: 80, maxItems: 50 }) as SpawnProvider[] | undefined;
3329
3210
  const apiPort = cleanSafeNumber(parsed.body.apiPort) ?? 4860;
3330
3211
  if (!Number.isInteger(apiPort) || apiPort < 1 || apiPort > 65535) throw new ValidationError("apiPort must be an integer from 1 to 65535");
3331
3212
  const relayUrl = cleanString(parsed.body.relayUrl, "relayUrl", { max: 500 }) ?? new URL(req.url).origin;
3332
3213
  const version = cleanString(parsed.body.version, "version", { max: 80 }) ?? VERSION;
3333
- const pathPrefix = cleanStringArray(parsed.body.pathPrefix, "pathPrefix");
3214
+ const pathPrefix = cleanStringArray(parsed.body.pathPrefix, "pathPrefix", { itemMax: 80, maxItems: 50 });
3334
3215
  const providerList = providers?.length ? providers : ["claude", "codex"];
3335
3216
  for (const p of providerList) {
3336
- if (!VALID_ORCHESTRATOR_PROVIDERS.includes(p as any)) {
3337
- throw new ValidationError(`invalid provider: ${p}. Must be one of: ${VALID_ORCHESTRATOR_PROVIDERS.join(", ")}`);
3217
+ if (!SPAWN_PROVIDERS.includes(p as any)) {
3218
+ throw new ValidationError(`invalid provider: ${p}. Must be one of: ${SPAWN_PROVIDERS.join(", ")}`);
3338
3219
  }
3339
3220
  }
3340
3221
  const bootstrapToken = createToken({
@@ -3425,19 +3306,19 @@ const patchOrchestratorAgents: Handler = async (req, params) => {
3425
3306
  ?? cleanString(a.tmuxSession, "tmuxSession", { required: true, max: 240 })!;
3426
3307
  return {
3427
3308
  agentId: cleanString(a.agentId, "agentId", { max: 240 }) || "",
3428
- provider: cleanEnum(a.provider, "provider", VALID_ORCHESTRATOR_PROVIDERS)! as SpawnProvider,
3309
+ provider: optionalEnum(a.provider, "provider", SPAWN_PROVIDERS)! as SpawnProvider,
3429
3310
  sessionName,
3430
3311
  tmuxSession: cleanString(a.tmuxSession, "tmuxSession", { max: 240 }) ?? sessionName,
3431
- supervisor: cleanEnum(a.supervisor, "supervisor", ["process", "systemd", "launchd", "unknown"] as const),
3312
+ supervisor: optionalEnum(a.supervisor, "supervisor", ["process", "systemd", "launchd", "unknown"] as const),
3432
3313
  systemdUnit: cleanString(a.systemdUnit, "systemdUnit", { max: 240 }),
3433
3314
  terminalSession: cleanString(a.terminalSession, "terminalSession", { max: 240 }),
3434
3315
  terminalAvailable: typeof a.terminalAvailable === "boolean" ? a.terminalAvailable : undefined,
3435
3316
  cwd: cleanString(a.cwd, "cwd", { required: true, max: 500 })!,
3436
3317
  profile: cleanString(a.profile, "profile", { max: 120 }),
3437
- workspaceMode: cleanEnum(a.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES),
3318
+ workspaceMode: optionalEnum(a.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES),
3438
3319
  workspace: cleanWorkspaceMetadata(a.workspace, "workspace"),
3439
3320
  label: cleanString(a.label, "label", { max: 120 }),
3440
- approvalMode: (cleanEnum(a.approvalMode, "approvalMode", VALID_SPAWN_APPROVALS, "guarded") ?? "guarded") as SpawnApprovalMode,
3321
+ approvalMode: (optionalEnum(a.approvalMode, "approvalMode", APPROVAL_MODES, "guarded") ?? "guarded") as SpawnApprovalMode,
3441
3322
  policyName: cleanString(a.policyName, "policyName", { max: 120 }),
3442
3323
  spawnRequestId: cleanString(a.spawnRequestId, "spawnRequestId", { max: 160 }),
3443
3324
  automationRunId: cleanString(a.automationRunId, "automationRunId", { max: 160 }),
@@ -3502,7 +3383,7 @@ const patchOrchestratorAgents: Handler = async (req, params) => {
3502
3383
 
3503
3384
  function cleanManagedSessionExitDiagnostics(value: unknown, index: number): ManagedSessionExitDiagnostics {
3504
3385
  if (!isRecord(value)) throw new ValidationError(`exitedAgents[${index}] must be an object`);
3505
- const provider = cleanEnum(value.provider, `exitedAgents[${index}].provider`, VALID_ORCHESTRATOR_PROVIDERS);
3386
+ const provider = optionalEnum(value.provider, `exitedAgents[${index}].provider`, SPAWN_PROVIDERS);
3506
3387
  if (!provider) throw new ValidationError(`exitedAgents[${index}].provider required`);
3507
3388
  const systemd = isRecord(value.systemd)
3508
3389
  ? {
@@ -3531,7 +3412,7 @@ function cleanManagedSessionExitDiagnostics(value: unknown, index: number): Mana
3531
3412
  const diagnostics: ManagedSessionExitDiagnostics = {
3532
3413
  agentId: cleanString(value.agentId, `exitedAgents[${index}].agentId`, { required: true, max: 240 })!,
3533
3414
  provider: provider as SpawnProvider,
3534
- workspaceMode: cleanEnum(value.workspaceMode, `exitedAgents[${index}].workspaceMode`, VALID_WORKSPACE_MODES),
3415
+ workspaceMode: optionalEnum(value.workspaceMode, `exitedAgents[${index}].workspaceMode`, VALID_WORKSPACE_MODES),
3535
3416
  workspace: cleanWorkspaceMetadata(value.workspace, `exitedAgents[${index}].workspace`),
3536
3417
  sessionName: cleanString(value.sessionName, `exitedAgents[${index}].sessionName`, { max: 240 }),
3537
3418
  tmuxSession: cleanString(value.tmuxSession, `exitedAgents[${index}].tmuxSession`, { required: true, max: 240 })!,
@@ -3540,7 +3421,7 @@ function cleanManagedSessionExitDiagnostics(value: unknown, index: number): Mana
3540
3421
  policyName: cleanString(value.policyName, `exitedAgents[${index}].policyName`, { max: 120 }),
3541
3422
  spawnRequestId: cleanString(value.spawnRequestId, `exitedAgents[${index}].spawnRequestId`, { max: 160 }),
3542
3423
  automationRunId: cleanString(value.automationRunId, `exitedAgents[${index}].automationRunId`, { max: 160 }),
3543
- supervisor: cleanEnum(value.supervisor, `exitedAgents[${index}].supervisor`, ["process", "systemd", "launchd", "unknown"] as const, "unknown")!,
3424
+ supervisor: optionalEnum(value.supervisor, `exitedAgents[${index}].supervisor`, ["process", "systemd", "launchd", "unknown"] as const, "unknown")!,
3544
3425
  systemdUnit: cleanString(value.systemdUnit, `exitedAgents[${index}].systemdUnit`, { max: 240 }),
3545
3426
  terminalSession: cleanString(value.terminalSession, `exitedAgents[${index}].terminalSession`, { max: 240 }),
3546
3427
  terminalAvailable: typeof value.terminalAvailable === "boolean" ? value.terminalAvailable : undefined,
@@ -3576,22 +3457,22 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
3576
3457
  if (orch.status !== "online") return error("orchestrator is offline", 409);
3577
3458
 
3578
3459
  if (!isRecord(parsed.body)) return error("body required");
3579
- const provider = cleanEnum(parsed.body.provider, "provider", VALID_ORCHESTRATOR_PROVIDERS)! as SpawnProvider;
3460
+ const provider = optionalEnum(parsed.body.provider, "provider", SPAWN_PROVIDERS)! as SpawnProvider;
3580
3461
  const selection = validateSpawnSelectionForOrchestrator(orch, provider, parsed.body);
3581
3462
  if (selection instanceof Response) return selection;
3582
3463
  const cwd = cleanString(parsed.body.cwd, "cwd", { max: 500 });
3583
- if (cwd && !pathWithinBase(cwd, orch.baseDir)) {
3464
+ if (cwd && !isPathWithinBase(cwd, orch.baseDir)) {
3584
3465
  return error(`cwd must be within orchestrator base directory: ${orch.baseDir}`);
3585
3466
  }
3586
3467
  const label = cleanString(parsed.body.label, "label", { max: 120 });
3587
- const approvalMode = cleanEnum(parsed.body.approvalMode, "approvalMode", VALID_SPAWN_APPROVALS, "guarded") as SpawnApprovalMode;
3468
+ const approvalMode = optionalEnum(parsed.body.approvalMode, "approvalMode", APPROVAL_MODES, "guarded") as SpawnApprovalMode;
3588
3469
  const prompt = cleanString(parsed.body.prompt, "prompt", { max: 16000 });
3589
3470
  const systemPromptAppend = cleanString(parsed.body.systemPromptAppend, "systemPromptAppend", { max: 64_000 });
3590
- const tags = cleanStringArray(parsed.body.tags, "tags") ?? [];
3591
- const capabilities = cleanStringArray(parsed.body.capabilities, "capabilities") ?? [];
3592
- const providerArgs = cleanStringArray(parsed.body.providerArgs, "providerArgs") ?? [];
3471
+ const tags = cleanStringArray(parsed.body.tags, "tags", { itemMax: 80, maxItems: 50 }) ?? [];
3472
+ const capabilities = cleanStringArray(parsed.body.capabilities, "capabilities", { itemMax: 80, maxItems: 50 }) ?? [];
3473
+ const providerArgs = cleanStringArray(parsed.body.providerArgs, "providerArgs", { itemMax: 80, maxItems: 50 }) ?? [];
3593
3474
  const profile = cleanString(parsed.body.profile, "profile", { max: 120 });
3594
- const workspaceMode = cleanEnum(parsed.body.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES, "inherit") as WorkspaceMode;
3475
+ const workspaceMode = optionalEnum(parsed.body.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES, "inherit") as WorkspaceMode;
3595
3476
  const agentProfile = profile ? getAgentProfile(profile)?.value : undefined;
3596
3477
  if (profile && !agentProfile) return error("agent profile not found", 404);
3597
3478
  const policyName = cleanString(parsed.body.policyName, "policyName", { max: 120 });
@@ -3607,12 +3488,9 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
3607
3488
  source: "system",
3608
3489
  target: orch.agentId,
3609
3490
  correlationId: requestId,
3610
- params: {
3611
- action: "spawn",
3491
+ params: buildSpawnCommand({
3612
3492
  provider,
3613
- model: selection.model,
3614
- providerModel: selection.providerModel,
3615
- effort: selection.effort,
3493
+ modelParams: { model: selection.model, providerModel: selection.providerModel, effort: selection.effort },
3616
3494
  cwd: cwd || orch.baseDir,
3617
3495
  workspaceMode,
3618
3496
  label,
@@ -3622,7 +3500,6 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
3622
3500
  permissionMode: approvalMode,
3623
3501
  profile,
3624
3502
  agentProfile,
3625
- ...workspaceSpawnParams(),
3626
3503
  providerArgs,
3627
3504
  prompt,
3628
3505
  systemPromptAppend,
@@ -3639,7 +3516,7 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
3639
3516
  }),
3640
3517
  requestedBy: "dashboard",
3641
3518
  requestedAt: Date.now(),
3642
- },
3519
+ }),
3643
3520
  });
3644
3521
  emitCommand(command);
3645
3522
  auditEvent({
@@ -3769,7 +3646,7 @@ const postOrchestratorAction: Handler = async (req, params) => {
3769
3646
  if (!orch) return error("orchestrator not found", 404);
3770
3647
 
3771
3648
  if (!isRecord(parsed.body)) return error("body required");
3772
- const action = cleanEnum(parsed.body.action, "action", ["restart", "shutdown", "upgrade"] as const);
3649
+ const action = optionalEnum(parsed.body.action, "action", ["restart", "shutdown", "upgrade"] as const);
3773
3650
  if (!action) return error("action required");
3774
3651
 
3775
3652
  if (action === "upgrade") {
@@ -3778,7 +3655,7 @@ const postOrchestratorAction: Handler = async (req, params) => {
3778
3655
  const targetVersion = cleanString(parsed.body.targetVersion, "targetVersion", { max: 80 }) ?? VERSION;
3779
3656
  if (!ORCH_UPGRADE_SEMVER_RE.test(targetVersion)) return error("targetVersion must be a semver like 0.10.20");
3780
3657
  const force = parsed.body.force === true;
3781
- const providers = (cleanStringArray(parsed.body.providers, "providers") ?? ["orchestrator"])
3658
+ const providers = (cleanStringArray(parsed.body.providers, "providers", { itemMax: 80, maxItems: 50 }) ?? ["orchestrator"])
3782
3659
  .filter((p) => (VALID_ORCH_UPGRADE_PROVIDERS as readonly string[]).includes(p));
3783
3660
  if (!providers.length) return error(`providers must be any of: ${VALID_ORCH_UPGRADE_PROVIDERS.join(", ")}`);
3784
3661
  const selfUpgradeError = orchestratorSelfUpgradeError(orch);
@@ -3908,7 +3785,7 @@ const getWorkspaces: Handler = (req) => {
3908
3785
  const url = new URL(req.url);
3909
3786
  const repoRoot = cleanString(url.searchParams.get("repoRoot") ?? undefined, "repoRoot", { max: 1000 });
3910
3787
  const ownerAgentId = cleanString(url.searchParams.get("agentId") ?? undefined, "agentId", { max: 240 });
3911
- const status = cleanEnum(url.searchParams.get("status") ?? undefined, "status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined;
3788
+ const status = optionalEnum(url.searchParams.get("status") ?? undefined, "status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined;
3912
3789
  return json(listWorkspaces({ repoRoot, ownerAgentId, status }));
3913
3790
  } catch (e) {
3914
3791
  if (e instanceof ValidationError) return error(e.message, 400);
@@ -3936,7 +3813,7 @@ async function proxyWorkspaceHostGet(workspaceId: string, hostPath: string, extr
3936
3813
  return json({ available: false, reason: "no isolated worktree" });
3937
3814
  }
3938
3815
  const orch = listOrchestrators().find(
3939
- (candidate) => candidate.status === "online" && candidate.apiUrl && pathWithinBase(workspace.sourceCwd, candidate.baseDir),
3816
+ (candidate) => candidate.status === "online" && candidate.apiUrl && isPathWithinBase(workspace.sourceCwd, candidate.baseDir),
3940
3817
  );
3941
3818
  if (!orch?.apiUrl) return json({ available: false, reason: "owning orchestrator offline" });
3942
3819
  const query = new URLSearchParams({ path: workspace.worktreePath });
@@ -3945,7 +3822,7 @@ async function proxyWorkspaceHostGet(workspaceId: string, hostPath: string, extr
3945
3822
  for (const [key, value] of Object.entries(extraQuery ?? {})) query.set(key, value);
3946
3823
  const headers: Record<string, string> = {};
3947
3824
  const relayToken = process.env.AGENT_RELAY_TOKEN;
3948
- if (relayToken) headers["X-Agent-Relay-Token"] = relayToken;
3825
+ if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
3949
3826
  try {
3950
3827
  const res = await fetch(`${orch.apiUrl}${hostPath}?${query.toString()}`, {
3951
3828
  headers,
@@ -3987,11 +3864,11 @@ const getWorkspaceOrphans: Handler = async () => {
3987
3864
  const repoRoots = [...new Set(all.map((ws) => ws.repoRoot).filter(Boolean))];
3988
3865
  const headers: Record<string, string> = {};
3989
3866
  const relayToken = process.env.AGENT_RELAY_TOKEN;
3990
- if (relayToken) headers["X-Agent-Relay-Token"] = relayToken;
3867
+ if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
3991
3868
  const orphans: WorkspaceOrphan[] = [];
3992
3869
 
3993
3870
  for (const repoRoot of repoRoots) {
3994
- const orch = orchestrators.find((candidate) => candidate.apiUrl && pathWithinBase(repoRoot, candidate.baseDir));
3871
+ const orch = orchestrators.find((candidate) => candidate.apiUrl && isPathWithinBase(repoRoot, candidate.baseDir));
3995
3872
  if (!orch?.apiUrl) continue;
3996
3873
  let probe: WorkspaceProbe | undefined;
3997
3874
  try {
@@ -4029,7 +3906,7 @@ const postWorkspaceOrphanReclaim: Handler = async (req) => {
4029
3906
  // Refuse to reclaim a path that still backs a live workspace row.
4030
3907
  const live = listWorkspaces().find((ws) => ws.worktreePath && resolve(ws.worktreePath) === resolve(worktreePath) && !TERMINAL_WORKSPACE_STATUSES.has(ws.status));
4031
3908
  if (live) return error(`path backs live workspace ${live.id}; clean it through the workspace, not orphan reclaim`, 409);
4032
- const orch = listOrchestrators().find((candidate) => candidate.status === "online" && pathWithinBase(repoRoot, candidate.baseDir));
3909
+ const orch = listOrchestrators().find((candidate) => candidate.status === "online" && isPathWithinBase(repoRoot, candidate.baseDir));
4033
3910
  if (!orch) return error("no online orchestrator owns this path", 409);
4034
3911
  const command = createCommand({
4035
3912
  type: "workspace.cleanup",
@@ -4055,6 +3932,191 @@ const postWorkspaceOrphanReclaim: Handler = async (req) => {
4055
3932
  }
4056
3933
  };
4057
3934
 
3935
+ // Build a `workspace.cleanup` command for a worktree's owning orchestrator. Shared
3936
+ // by the manual cleanup action and the cleanup-stale sweep (#208) so both resolve
3937
+ // the owner and shape params identically. Queues (no TTL) when the owner is offline;
3938
+ // hard-fails only when no orchestrator owns the path (DELETE is then the escape).
3939
+ function buildWorkspaceCleanupCommand(workspace: WorkspaceRecord, requestedBy: string): { ok: true; command: Command } | { ok: false; status: number; error: string } {
3940
+ const owners = listOrchestrators().filter((candidate) => isPathWithinBase(workspace.sourceCwd, candidate.baseDir));
3941
+ const owner = owners.find((candidate) => candidate.status === "online") ?? owners[0];
3942
+ if (!owner) return { ok: false, status: 409, error: "no orchestrator owns this workspace path; use DELETE /api/workspaces/:id to purge the record" };
3943
+ const command = createCommand({
3944
+ type: "workspace.cleanup",
3945
+ source: "system",
3946
+ target: owner.agentId,
3947
+ correlationId: workspace.id,
3948
+ params: {
3949
+ action: "cleanup",
3950
+ workspaceId: workspace.id,
3951
+ repoRoot: workspace.repoRoot,
3952
+ worktreePath: workspace.worktreePath,
3953
+ branch: workspace.branch,
3954
+ requestedBy,
3955
+ requestedAt: Date.now(),
3956
+ deleteBranch: true,
3957
+ queued: owner.status !== "online",
3958
+ },
3959
+ });
3960
+ return { ok: true, command };
3961
+ }
3962
+
3963
+ // Build a `workspace.deps-refresh` command for a worktree's owning orchestrator
3964
+ // (issue #51). Re-provisions deps the shared symlinked node_modules has gone stale
3965
+ // on, or — checkOnly — just reports staleness. Same owner-resolution as cleanup.
3966
+ function buildWorkspaceDepsRefreshCommand(workspace: WorkspaceRecord, requestedBy: string, checkOnly: boolean): { ok: true; command: Command } | { ok: false; status: number; error: string } {
3967
+ const owners = listOrchestrators().filter((candidate) => isPathWithinBase(workspace.sourceCwd, candidate.baseDir));
3968
+ const owner = owners.find((candidate) => candidate.status === "online") ?? owners[0];
3969
+ if (!owner) return { ok: false, status: 409, error: "no online orchestrator owns this workspace path" };
3970
+ const command = createCommand({
3971
+ type: "workspace.deps-refresh",
3972
+ source: "system",
3973
+ target: owner.agentId,
3974
+ correlationId: workspace.id,
3975
+ params: {
3976
+ action: "deps-refresh",
3977
+ workspaceId: workspace.id,
3978
+ repoRoot: workspace.repoRoot,
3979
+ worktreePath: workspace.worktreePath,
3980
+ checkOnly,
3981
+ requestedBy,
3982
+ requestedAt: Date.now(),
3983
+ queued: owner.status !== "online",
3984
+ },
3985
+ });
3986
+ return { ok: true, command };
3987
+ }
3988
+
3989
+ // Fetch + parse a workspace's live git state from its owning host, or report why
3990
+ // it's unavailable. Thin typed wrapper over the same host route the proxy uses.
3991
+ async function fetchWorkspaceGitState(workspace: WorkspaceRecord): Promise<{ state: WorkspaceGitState } | { unavailable: string }> {
3992
+ if (workspace.mode !== "isolated" || !workspace.worktreePath) return { unavailable: "no isolated worktree" };
3993
+ const orch = listOrchestrators().find(
3994
+ (candidate) => candidate.status === "online" && candidate.apiUrl && isPathWithinBase(workspace.sourceCwd, candidate.baseDir),
3995
+ );
3996
+ if (!orch?.apiUrl) return { unavailable: "owning orchestrator offline" };
3997
+ const query = new URLSearchParams({ path: workspace.worktreePath });
3998
+ if (workspace.baseRef) query.set("baseRef", workspace.baseRef);
3999
+ if (workspace.baseSha) query.set("baseSha", workspace.baseSha);
4000
+ const headers: Record<string, string> = {};
4001
+ const relayToken = process.env.AGENT_RELAY_TOKEN;
4002
+ if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
4003
+ try {
4004
+ const res = await fetch(`${orch.apiUrl}/api/workspace/state?${query.toString()}`, { headers, signal: AbortSignal.timeout(10_000) });
4005
+ if (!res.ok) return { unavailable: `host returned ${res.status}` };
4006
+ return { state: await res.json() as WorkspaceGitState };
4007
+ } catch (e) {
4008
+ return { unavailable: `orchestrator unreachable: ${(e as Error).message}` };
4009
+ }
4010
+ }
4011
+
4012
+ // Recommend the next action for a workspace from its joined state — the steward's
4013
+ // (and a release agent's) "what should happen here?" without reconstructing it.
4014
+ function recommendWorkspaceAction(input: { workspace: WorkspaceRecord; ownerOnline: boolean; gitState?: WorkspaceGitState; claim: ReturnType<typeof workspaceActiveClaim> }): WorkspaceDiagnostics["recommendation"] {
4015
+ const { workspace, ownerOnline, gitState, claim } = input;
4016
+ if (claim) return { action: "wait", confidence: "high", reason: `claimed by ${claim.by ?? "steward"}` };
4017
+ if (TERMINAL_WORKSPACE_STATUSES.has(workspace.status)) return { action: "none", confidence: "high", reason: `workspace is ${workspace.status}` };
4018
+ if (!gitState || gitState.error) return { action: "review", confidence: "low", reason: gitState?.error ? `git state error: ${gitState.error}` : "git state unavailable" };
4019
+ if (gitState.missing) return { action: "cleanup", confidence: "high", reason: "worktree no longer exists on disk" };
4020
+ const ahead = gitState.unmergedAhead ?? gitState.ahead ?? 0;
4021
+ const landed = gitState.landed === true;
4022
+ if ((gitState.dirtyCount ?? 0) > 0) return { action: "review", confidence: "medium", reason: `${gitState.dirtyCount} uncommitted change(s)` };
4023
+ if (ahead === 0 || landed) {
4024
+ if (!ownerOnline) return { action: "cleanup", confidence: "high", reason: landed ? "work already landed; owner offline" : "no unmerged commits; owner offline" };
4025
+ return { action: "none", confidence: "medium", reason: "nothing to merge; owner active" };
4026
+ }
4027
+ if (ownerOnline && workspace.status !== "review_requested" && workspace.status !== "conflict") {
4028
+ return { action: "wait", confidence: "medium", reason: "owner active and not awaiting review" };
4029
+ }
4030
+ if (workspace.status === "conflict") return { action: "rebase", confidence: "high", reason: "conflict — rebase onto base and resolve" };
4031
+ if ((gitState.behind ?? 0) > 0) return { action: "rebase", confidence: "medium", reason: `${gitState.behind} behind base — rebase then merge` };
4032
+ return { action: "merge", confidence: "high", reason: `${ahead} commit(s) ready to land` };
4033
+ }
4034
+
4035
+ // Joined steward briefing for one workspace (#208): row + owner/orchestrator
4036
+ // liveness + live git state + branch mismatch + active claim + recommended action.
4037
+ const getWorkspaceDiagnostics: Handler = async (_req, params) => {
4038
+ const workspace = getWorkspace(params.id!);
4039
+ if (!workspace) return error("workspace not found", 404);
4040
+ const owner = workspace.ownerAgentId ? getAgent(workspace.ownerAgentId) : null;
4041
+ const ownerOnline = Boolean(owner) && owner!.status !== "offline";
4042
+ const orch = listOrchestrators().find((candidate) => isPathWithinBase(workspace.sourceCwd, candidate.baseDir));
4043
+ const orchOnline = Boolean(orch) && orch!.status === "online";
4044
+ const fetched = await fetchWorkspaceGitState(workspace);
4045
+ const gitState = "state" in fetched ? fetched.state : undefined;
4046
+ const claim = workspaceActiveClaim(workspace);
4047
+ const liveBranch = gitState?.branch;
4048
+ const diagnostics: WorkspaceDiagnostics = {
4049
+ workspaceId: workspace.id,
4050
+ status: workspace.status,
4051
+ mode: workspace.mode,
4052
+ repoRoot: workspace.repoRoot,
4053
+ worktreePath: workspace.worktreePath,
4054
+ recordedBranch: workspace.branch,
4055
+ liveBranch,
4056
+ baseRef: workspace.baseRef,
4057
+ branchMismatch: workspace.branch && liveBranch ? workspace.branch !== liveBranch : undefined,
4058
+ owner: { id: workspace.ownerAgentId, status: owner?.status, online: ownerOnline },
4059
+ orchestrator: { id: orch?.id, online: orchOnline },
4060
+ ...(claim ? { claim: { by: claim.by, purpose: claim.purpose, expiresAt: claim.expiresAt } } : {}),
4061
+ ...(gitState ? { gitState } : { gitStateUnavailable: "unavailable" in fetched ? fetched.unavailable : "unknown" }),
4062
+ recommendation: recommendWorkspaceAction({ workspace, ownerOnline, gitState, claim }),
4063
+ };
4064
+ return json(diagnostics);
4065
+ };
4066
+
4067
+ // Guarded batch cleanup of stale worktrees (#208 / steward report §3). Defaults to
4068
+ // dry-run and only ever proposes/cleans worktrees that are provably safe: offline
4069
+ // owner, not claimed, clean tree, and (landed-only) work already in base or empty.
4070
+ // Never touches a live owner. Reuses the same cleanup command path as the manual action.
4071
+ const postWorkspaceCleanupStale: Handler = async (req) => {
4072
+ const denied = authorizeRoute(req, { scope: "command:write" });
4073
+ if (denied) return denied;
4074
+ const parsed = await parseBody<unknown>(req);
4075
+ if (!parsed.ok) return error(parsed.error, parsed.status);
4076
+ const body = isRecord(parsed.body) ? parsed.body : {};
4077
+ const repoRoot = cleanString(body.repoRoot, "repoRoot", { max: 1000 });
4078
+ const dryRun = body.dryRun !== false; // safe by default
4079
+ const landedOnly = body.landedOnly !== false;
4080
+ const offlineOwnerOnly = body.offlineOwnerOnly !== false;
4081
+
4082
+ const candidates = listWorkspaces().filter((ws) =>
4083
+ ws.mode === "isolated" && Boolean(ws.worktreePath) && !TERMINAL_WORKSPACE_STATUSES.has(ws.status) && (!repoRoot || ws.repoRoot === repoRoot),
4084
+ );
4085
+
4086
+ const rows: Array<Record<string, unknown>> = [];
4087
+ const cleaned: string[] = [];
4088
+ for (const ws of candidates) {
4089
+ const owner = ws.ownerAgentId ? getAgent(ws.ownerAgentId) : null;
4090
+ const ownerOnline = Boolean(owner) && owner!.status !== "offline";
4091
+ if (ownerOnline) continue; // never clean a live owner's worktree
4092
+ if (offlineOwnerOnly && !ws.ownerAgentId) { /* no owner recorded — still eligible */ }
4093
+ if (workspaceActiveClaim(ws)) continue; // respect steward claims
4094
+ const fetched = await fetchWorkspaceGitState(ws);
4095
+ const gitState = "state" in fetched ? fetched.state : undefined;
4096
+ const missing = gitState?.missing === true;
4097
+ const dirtyCount = gitState?.dirtyCount;
4098
+ const ahead = gitState?.unmergedAhead ?? gitState?.ahead ?? 0;
4099
+ const landed = gitState?.landed === true;
4100
+ // Safe = gone from disk, OR clean tree with no unmerged work (landed/empty).
4101
+ const safe = missing || (gitState !== undefined && (dirtyCount ?? 1) === 0 && (!landedOnly || landed || ahead === 0));
4102
+ const proof = { ownerStatus: owner?.status ?? "missing", ownerOnline, ahead, behind: gitState?.behind, landed, dirtyCount, missing, gitStateUnavailable: gitState ? undefined : ("unavailable" in fetched ? fetched.unavailable : undefined) };
4103
+ const row: Record<string, unknown> = { workspaceId: ws.id, branch: ws.branch, worktreePath: ws.worktreePath, repoRoot: ws.repoRoot, safe, proof };
4104
+ if (safe && !dryRun) {
4105
+ const built = buildWorkspaceCleanupCommand(ws, "cleanup-stale");
4106
+ if (built.ok) {
4107
+ updateWorkspaceStatus(ws.id, "cleanup_requested", { lastWorkspaceAction: "cleanup-stale", lastWorkspaceActionAt: Date.now(), cleanupProof: proof });
4108
+ emitCommand(built.command);
4109
+ row.commandId = built.command.id;
4110
+ cleaned.push(ws.id);
4111
+ } else {
4112
+ row.cleanupError = built.error;
4113
+ }
4114
+ }
4115
+ rows.push(row);
4116
+ }
4117
+ return json({ dryRun, landedOnly, offlineOwnerOnly, repoRoot, scanned: candidates.length, eligible: rows.filter((r) => r.safe).length, cleaned, candidates: rows }, dryRun ? 200 : 202);
4118
+ };
4119
+
4058
4120
  const postWorkspaceAction: Handler = async (req, params) => {
4059
4121
  const parsed = await parseBody<unknown>(req);
4060
4122
  if (!parsed.ok) return error(parsed.error, parsed.status);
@@ -4062,7 +4124,7 @@ const postWorkspaceAction: Handler = async (req, params) => {
4062
4124
  if (!isRecord(parsed.body)) return error("body required");
4063
4125
  const workspace = getWorkspace(params.id!);
4064
4126
  if (!workspace) return error("workspace not found", 404);
4065
- const action = cleanEnum(parsed.body.action, "action", ["status", "ready", "conflict-found", "request-review", "merge-plan", "merge", "abandon", "cleanup"] as const);
4127
+ const action = optionalEnum(parsed.body.action, "action", ["status", "ready", "conflict-found", "request-review", "merge-plan", "merge", "abandon", "cleanup", "claim", "release-claim", "deps-refresh"] as const);
4066
4128
  if (!action) return error("action required", 400);
4067
4129
  const agentId = cleanString(parsed.body.agentId, "agentId", { max: 240 });
4068
4130
  const detail = cleanString(parsed.body.detail, "detail", { max: 4000 });
@@ -4076,10 +4138,30 @@ const postWorkspaceAction: Handler = async (req, params) => {
4076
4138
  const denied = authorizeRoute(req, { scope: requiresCommand ? "command:write" : "agent:write", resource: { agentId, cwd: workspace.worktreePath } });
4077
4139
  if (denied) return denied;
4078
4140
  if (action === "status") return json(workspace);
4141
+ // Steward claim/lease (#208): a TTL'd metadata lease that auto-merge yields to,
4142
+ // so deterministic landing can't race a steward mid-validation. No status change.
4143
+ if (action === "claim" || action === "release-claim") {
4144
+ const release = action === "release-claim";
4145
+ const purpose = cleanString(parsed.body.purpose, "purpose", { max: 120 });
4146
+ const updated = patchWorkspaceMetadata(workspace.id, claimMetadataPatch(release, agentId ?? "steward", purpose));
4147
+ if (!updated) return error("workspace not found", 404);
4148
+ auditEvent({
4149
+ clientId: `workspace-${action}-${workspace.id}-${Date.now()}`,
4150
+ kind: "state",
4151
+ title: release ? "Workspace claim released" : "Workspace claimed",
4152
+ body: detail ?? purpose ?? workspace.worktreePath,
4153
+ meta: workspace.branch ?? workspace.id,
4154
+ icon: "ti-lock",
4155
+ view: "orchestrators",
4156
+ agentId,
4157
+ metadata: { action, workspaceId: workspace.id, repoRoot: workspace.repoRoot, ...authAuditMetadata(req) },
4158
+ });
4159
+ return json({ workspace: updated, claim: workspaceActiveClaim(updated) });
4160
+ }
4079
4161
  // Base merges go through the shared helper (lease + command + bind), the same
4080
4162
  // path the auto-merge job uses, so both serialize per repo (issue #157).
4081
4163
  if (action === "merge") {
4082
- const strategy = cleanEnum(parsed.body.strategy, "strategy", ["pr", "rebase-ff", "auto"] as const, "auto") as WorkspaceMergeStrategy;
4164
+ const strategy = optionalEnum(parsed.body.strategy, "strategy", ["pr", "rebase-ff", "auto"] as const, "auto") as WorkspaceMergeStrategy;
4083
4165
  const result = requestWorkspaceMerge(workspace, {
4084
4166
  requestedBy: agentId ?? "dashboard",
4085
4167
  strategy,
@@ -4103,6 +4185,31 @@ const postWorkspaceAction: Handler = async (req, params) => {
4103
4185
  });
4104
4186
  return json({ workspace: result.workspace, command: result.command }, 202);
4105
4187
  }
4188
+ // Deps refresh (#51): self-service for the owning agent — re-provision deps the
4189
+ // shared symlinked node_modules has gone stale on. Emits a host command but is
4190
+ // authorized at agent:write (it only touches the agent's own worktree), so the
4191
+ // agent can run it themselves the moment typecheck reports a missing module.
4192
+ if (action === "deps-refresh") {
4193
+ if (workspace.mode !== "isolated" || !workspace.worktreePath) {
4194
+ return error(`workspace ${workspace.id} has no isolated worktree to refresh`, 422);
4195
+ }
4196
+ const checkOnly = parsed.body.checkOnly === true;
4197
+ const built = buildWorkspaceDepsRefreshCommand(workspace, agentId ?? "agent", checkOnly);
4198
+ if (!built.ok) return error(built.error, built.status);
4199
+ emitCommand(built.command);
4200
+ auditEvent({
4201
+ clientId: `workspace-deps-refresh-${workspace.id}-${Date.now()}`,
4202
+ kind: "state",
4203
+ title: checkOnly ? "Workspace deps check" : "Workspace deps refresh",
4204
+ body: detail ?? workspace.worktreePath,
4205
+ meta: workspace.branch ?? workspace.id,
4206
+ icon: "ti-package",
4207
+ view: "orchestrators",
4208
+ agentId,
4209
+ metadata: { action, workspaceId: workspace.id, repoRoot: workspace.repoRoot, worktreePath: workspace.worktreePath, checkOnly, commandId: built.command.id, ...authAuditMetadata(req) },
4210
+ });
4211
+ return json({ workspace, command: built.command }, 202);
4212
+ }
4106
4213
  const statusByAction: Record<string, WorkspaceStatus | undefined> = {
4107
4214
  status: undefined,
4108
4215
  ready: "ready",
@@ -4123,30 +4230,9 @@ const postWorkspaceAction: Handler = async (req, params) => {
4123
4230
  let command: Command | undefined;
4124
4231
  if (requiresCommand) {
4125
4232
  // Only `cleanup` reaches here — `merge` returned early via the shared helper.
4126
- // Cleanup may queue: if the owning orchestrator is offline, the command (no
4127
- // TTL) waits and reconciles when it reconnects. Only hard-fail when no
4128
- // orchestrator owns the path at all — then DELETE is the escape.
4129
- const owners = listOrchestrators().filter((candidate) => pathWithinBase(workspace.sourceCwd, candidate.baseDir));
4130
- const onlineOwner = owners.find((candidate) => candidate.status === "online");
4131
- const owner = onlineOwner ?? owners[0];
4132
- if (!owner) return error("no orchestrator owns this workspace path; use DELETE /api/workspaces/:id to purge the record", 409);
4133
- command = createCommand({
4134
- type: "workspace.cleanup",
4135
- source: "system",
4136
- target: owner.agentId,
4137
- correlationId: workspace.id,
4138
- params: {
4139
- action: "cleanup",
4140
- workspaceId: workspace.id,
4141
- repoRoot: workspace.repoRoot,
4142
- worktreePath: workspace.worktreePath,
4143
- branch: workspace.branch,
4144
- requestedBy: agentId ?? "dashboard",
4145
- requestedAt: Date.now(),
4146
- deleteBranch: true,
4147
- queued: owner.status !== "online",
4148
- },
4149
- });
4233
+ const built = buildWorkspaceCleanupCommand(workspace, agentId ?? "dashboard");
4234
+ if (!built.ok) return error(built.error, built.status);
4235
+ command = built.command;
4150
4236
  emitCommand(command);
4151
4237
  }
4152
4238
  auditEvent({
@@ -4198,7 +4284,7 @@ async function proxyOrchestratorGet(req: Request, orchestratorId: string, path:
4198
4284
  const proxyUrl = `${orch.apiUrl}${path}${incoming.search}`;
4199
4285
  const headers: Record<string, string> = {};
4200
4286
  const relayToken = process.env.AGENT_RELAY_TOKEN;
4201
- if (relayToken) headers["X-Agent-Relay-Token"] = relayToken;
4287
+ if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
4202
4288
  try {
4203
4289
  const res = await fetch(proxyUrl, { headers, signal: AbortSignal.timeout(10_000) });
4204
4290
  const contentType = res.headers.get("content-type") ?? "";
@@ -4223,7 +4309,7 @@ async function proxyOrchestratorPost(req: Request, orchestratorId: string, path:
4223
4309
  "Content-Type": req.headers.get("content-type") ?? "application/json",
4224
4310
  };
4225
4311
  const relayToken = process.env.AGENT_RELAY_TOKEN;
4226
- if (relayToken) headers["X-Agent-Relay-Token"] = relayToken;
4312
+ if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
4227
4313
  try {
4228
4314
  const body = await req.text();
4229
4315
  const res = await fetch(proxyUrl, {
@@ -4248,7 +4334,7 @@ async function proxyOrchestratorDelete(req: Request, orchestratorId: string, pat
4248
4334
  const proxyUrl = `${orch.apiUrl}${path}${incoming.search}`;
4249
4335
  const headers: Record<string, string> = {};
4250
4336
  const relayToken = process.env.AGENT_RELAY_TOKEN;
4251
- if (relayToken) headers["X-Agent-Relay-Token"] = relayToken;
4337
+ if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
4252
4338
  try {
4253
4339
  const res = await fetch(proxyUrl, {
4254
4340
  method: "DELETE",
@@ -4269,7 +4355,7 @@ async function proxyOrchestratorJson(orchestratorId: string, path: string, metho
4269
4355
  if (orch.status !== "online") return error("orchestrator is offline", 422);
4270
4356
  const headers: Record<string, string> = { "Content-Type": "application/json" };
4271
4357
  const relayToken = process.env.AGENT_RELAY_TOKEN;
4272
- if (relayToken) headers["X-Agent-Relay-Token"] = relayToken;
4358
+ if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
4273
4359
  try {
4274
4360
  const res = await fetch(`${orch.apiUrl}${path}`, {
4275
4361
  method,
@@ -4294,11 +4380,11 @@ const getOrchestratorProviders: Handler = async (req, params) => {
4294
4380
  const payload = await res.clone().json().catch(() => null) as unknown;
4295
4381
  if (!isRecord(payload)) return res;
4296
4382
  try {
4297
- const providers = cleanStringArray(payload.providers, "providers") as SpawnProvider[] | undefined;
4383
+ const providers = cleanStringArray(payload.providers, "providers", { itemMax: 80, maxItems: 50 }) as SpawnProvider[] | undefined;
4298
4384
  if (providers) {
4299
4385
  for (const p of providers) {
4300
- if (!VALID_ORCHESTRATOR_PROVIDERS.includes(p as any)) {
4301
- throw new ValidationError(`invalid provider: ${p}. Must be one of: ${VALID_ORCHESTRATOR_PROVIDERS.join(", ")}`);
4386
+ if (!SPAWN_PROVIDERS.includes(p as any)) {
4387
+ throw new ValidationError(`invalid provider: ${p}. Must be one of: ${SPAWN_PROVIDERS.join(", ")}`);
4302
4388
  }
4303
4389
  }
4304
4390
  }
@@ -4438,7 +4524,7 @@ const patchCommand: Handler = async (req, params) => {
4438
4524
  if (!current) return error("command not found", 404);
4439
4525
  const denied = authorizeRoute(req, { scope: "command:write", resource: commandAuthorizationResource(current) });
4440
4526
  if (denied) return denied;
4441
- const status = cleanEnum(parsed.body.status, "status", VALID_COMMAND_STATUSES);
4527
+ const status = optionalEnum(parsed.body.status, "status", VALID_COMMAND_STATUSES);
4442
4528
  const result = cleanParams(parsed.body.result, "result");
4443
4529
  const err = cleanString(parsed.body.error, "error", { max: 4000 });
4444
4530
  const command = updateCommand(params.id!, { status: status as CommandStatus | undefined, result, error: err });
@@ -4476,13 +4562,25 @@ const patchCommand: Handler = async (req, params) => {
4476
4562
  }
4477
4563
  if (command.status === "succeeded" && isRecord(command.result)) {
4478
4564
  const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
4479
- const resultStatus = cleanEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined;
4565
+ const resultStatus = optionalEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined;
4480
4566
  if (workspaceId && resultStatus) {
4481
4567
  updateWorkspaceStatus(workspaceId, resultStatus, {
4482
4568
  mergeResult: command.result,
4483
4569
  mergeCommandId: command.id,
4484
4570
  mergedAt: Date.now(),
4571
+ // The land consumes the steward's claim (#208 steward contract / vent
4572
+ // #62): a successful land must release it, or `steward inspect` keeps
4573
+ // showing a stale claim on the recycled workspace and blocks the next
4574
+ // automation. Clearing a null claim (auto-merge path) is a harmless no-op.
4575
+ ...claimMetadataPatch(true),
4485
4576
  });
4577
+ // Land-and-continue (#206): the worktree was recycled onto a fresh branch.
4578
+ // Repoint the row so the next merge targets the live branch, not the deleted one.
4579
+ const newBranch = cleanString(command.result.newBranch, "result.newBranch", { max: 240 });
4580
+ if (newBranch) {
4581
+ const mergedSha = cleanString(command.result.mergedSha, "result.mergedSha", { max: 64 });
4582
+ setWorkspaceBranch(workspaceId, newBranch, mergedSha);
4583
+ }
4486
4584
  }
4487
4585
  } else if (command.status === "failed" && command.correlationId) {
4488
4586
  // Merge couldn't complete — don't leave it stuck in merge_planned.
@@ -4497,7 +4595,7 @@ const patchCommand: Handler = async (req, params) => {
4497
4595
  }
4498
4596
  if (command.type === "workspace.reconcile" && command.status === "succeeded" && isRecord(command.result)) {
4499
4597
  const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
4500
- const resultStatus = cleanEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined;
4598
+ const resultStatus = optionalEnum(command.result.status, "result.status", VALID_WORKSPACE_STATUSES) as WorkspaceStatus | undefined;
4501
4599
  const removed = command.result.removed === true;
4502
4600
  if (workspaceId && resultStatus) {
4503
4601
  // Only act on workspaces the agent left in a live state; never overwrite
@@ -4530,6 +4628,14 @@ const patchCommand: Handler = async (req, params) => {
4530
4628
  }
4531
4629
  }
4532
4630
  }
4631
+ if (command.type === "workspace.deps-refresh" && command.status === "succeeded" && isRecord(command.result)) {
4632
+ // Record the outcome on the row without touching status (#51) — observability
4633
+ // for the dashboard; the CLI reads the result straight off the command.
4634
+ const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
4635
+ if (workspaceId) {
4636
+ patchWorkspaceMetadata(workspaceId, { lastDepsRefresh: command.result, lastDepsRefreshCommandId: command.id, lastDepsRefreshAt: Date.now() });
4637
+ }
4638
+ }
4533
4639
  settleFailedOrchestratorUpgrade(command);
4534
4640
  emitCommand(command);
4535
4641
  auditCommandOutcome(command);
@@ -4774,7 +4880,7 @@ const getConfigKeyHistory: Handler = (req, params) => {
4774
4880
  // --- Spawn policy routes ---
4775
4881
 
4776
4882
  function spawnRequestId(): string {
4777
- return `sp_${crypto.randomUUID()}`;
4883
+ return generateSpawnRequestId();
4778
4884
  }
4779
4885
 
4780
4886
  function policyStatusPayload(policy: SpawnPolicy) {
@@ -4996,13 +5102,13 @@ const getProviderConfigsRoute: Handler = () => {
4996
5102
  };
4997
5103
 
4998
5104
  const getProviderConfigRoute: Handler = (_req, params) => {
4999
- const provider = cleanEnum(params.provider, "provider", VALID_PROVIDER_CONFIGS);
5105
+ const provider = optionalEnum(params.provider, "provider", VALID_PROVIDER_CONFIGS);
5000
5106
  if (!provider) return error("provider required");
5001
5107
  return json(providerConfigPublic(loadProviderConfig(provider)));
5002
5108
  };
5003
5109
 
5004
5110
  const putProviderConfigRoute: Handler = async (req, params) => {
5005
- const provider = cleanEnum(params.provider, "provider", VALID_PROVIDER_CONFIGS);
5111
+ const provider = optionalEnum(params.provider, "provider", VALID_PROVIDER_CONFIGS);
5006
5112
  if (!provider) return error("provider required");
5007
5113
  const parsed = await parseBody<unknown>(req);
5008
5114
  if (!parsed.ok) return error(parsed.error, parsed.status);
@@ -5012,13 +5118,13 @@ const putProviderConfigRoute: Handler = async (req, params) => {
5012
5118
  const headless = isRecord(parsed.body.headless) ? parsed.body.headless : {};
5013
5119
  const config: ProviderConfig = {
5014
5120
  command: cleanString(parsed.body.command, "command", { required: true, max: 500 })!,
5015
- defaultArgs: cleanStringArray(parsed.body.defaultArgs, "defaultArgs") ?? defaults.defaultArgs,
5121
+ defaultArgs: cleanStringArray(parsed.body.defaultArgs, "defaultArgs", { itemMax: 80, maxItems: 50 }) ?? defaults.defaultArgs,
5016
5122
  env: cleanEnvRecord(parsed.body.env),
5017
- pluginDirs: cleanStringArray(parsed.body.pluginDirs, "pluginDirs") ?? defaults.pluginDirs,
5018
- defaultCapabilities: cleanStringArray(parsed.body.defaultCapabilities, "defaultCapabilities") ?? defaults.defaultCapabilities,
5123
+ pluginDirs: cleanStringArray(parsed.body.pluginDirs, "pluginDirs", { itemMax: 80, maxItems: 50 }) ?? defaults.pluginDirs,
5124
+ defaultCapabilities: cleanStringArray(parsed.body.defaultCapabilities, "defaultCapabilities", { itemMax: 80, maxItems: 50 }) ?? defaults.defaultCapabilities,
5019
5125
  defaultApprovalMode: cleanString(parsed.body.defaultApprovalMode, "defaultApprovalMode", { max: 80 }) ?? defaults.defaultApprovalMode,
5020
- defaultTags: cleanStringArray(parsed.body.defaultTags, "defaultTags") ?? defaults.defaultTags,
5021
- chatCaptureMode: (cleanEnum(parsed.body.chatCaptureMode, "chatCaptureMode", ["final", "full"]) ?? defaults.chatCaptureMode) as "final" | "full",
5126
+ defaultTags: cleanStringArray(parsed.body.defaultTags, "defaultTags", { itemMax: 80, maxItems: 50 }) ?? defaults.defaultTags,
5127
+ chatCaptureMode: (optionalEnum(parsed.body.chatCaptureMode, "chatCaptureMode", ["final", "full"]) ?? defaults.chatCaptureMode) as "final" | "full",
5022
5128
  headless: {
5023
5129
  tmuxPrefix: cleanString(headless.tmuxPrefix, "headless.tmuxPrefix", { max: 120 }) ?? defaults.headless.tmuxPrefix,
5024
5130
  shutdownTimeoutMs: cleanPositiveInt(headless.shutdownTimeoutMs, "headless.shutdownTimeoutMs") ?? defaults.headless.shutdownTimeoutMs,
@@ -5032,7 +5138,7 @@ const putProviderConfigRoute: Handler = async (req, params) => {
5032
5138
  };
5033
5139
 
5034
5140
  const postProviderConfigTestRoute: Handler = async (_req, params) => {
5035
- const provider = cleanEnum(params.provider, "provider", VALID_PROVIDER_CONFIGS);
5141
+ const provider = optionalEnum(params.provider, "provider", VALID_PROVIDER_CONFIGS);
5036
5142
  if (!provider) return error("provider required");
5037
5143
  const config = loadProviderConfig(provider);
5038
5144
  const proc = Bun.spawn(["bash", "-lc", `command -v "$1"`, "bash", config.command], {
@@ -5107,7 +5213,7 @@ const postMessageDeliveryAttempt: Handler = async (req, params) => {
5107
5213
  if (!parsed.ok) return error(parsed.error, parsed.status);
5108
5214
  try {
5109
5215
  if (!isRecord(parsed.body)) throw new ValidationError("body must be an object");
5110
- const status = cleanEnum(parsed.body.status, "status", VALID_DELIVERY_STATUSES);
5216
+ const status = optionalEnum(parsed.body.status, "status", VALID_DELIVERY_STATUSES);
5111
5217
  if (!status) throw new ValidationError("status required");
5112
5218
  const nextRetryAt = cleanPositiveId(parsed.body.nextRetryAt, "nextRetryAt");
5113
5219
  const result = recordMessageDeliveryAttempt(id, {
@@ -5133,7 +5239,7 @@ const postMessageDeliveryAction: Handler = async (req, params) => {
5133
5239
  if (!parsed.ok) return error(parsed.error, parsed.status);
5134
5240
  try {
5135
5241
  if (!isRecord(parsed.body)) throw new ValidationError("body must be an object");
5136
- const action = cleanEnum(parsed.body.action, "action", VALID_DELIVERY_ACTIONS);
5242
+ const action = optionalEnum(parsed.body.action, "action", VALID_DELIVERY_ACTIONS);
5137
5243
  if (!action) throw new ValidationError("action required");
5138
5244
  const result = applyMessageDeliveryAction(id, {
5139
5245
  action,
@@ -5182,7 +5288,7 @@ const postMessage: Handler = async (req) => {
5182
5288
  const memoryInjection = await injectMemoryForMessageDelivery(result.message, autoMemoryTarget, memoryContext(req));
5183
5289
  if (memoryInjection) emitCommand(memoryInjection.command);
5184
5290
  } catch (e) {
5185
- console.warn(`[memory] automatic message context assembly failed: ${e instanceof Error ? e.message : String(e)}`);
5291
+ console.warn(`[memory] automatic message context assembly failed: ${errMessage(e)}`);
5186
5292
  }
5187
5293
  }
5188
5294
  if (result.message.deliveryStatus === "queued") {
@@ -5686,7 +5792,7 @@ const postConnectorAction: Handler = async (req, params) => {
5686
5792
  if (!parsed.ok) return error(parsed.error, parsed.status);
5687
5793
  try {
5688
5794
  if (!isRecord(parsed.body)) throw new ValidationError("JSON object body required");
5689
- const action = cleanEnum(parsed.body.action, "action", VALID_CONNECTOR_ACTIONS)!;
5795
+ const action = optionalEnum(parsed.body.action, "action", VALID_CONNECTOR_ACTIONS)!;
5690
5796
  const result = await runConnectorAction(params.id!, action);
5691
5797
  return json(result, result.ok ? 200 : 502);
5692
5798
  } catch (e) {
@@ -6166,7 +6272,7 @@ const postClaimTask: Handler = async (req, params) => {
6166
6272
  const memoryInjection = await injectMemoryForTaskClaim(result.task!, agentId);
6167
6273
  if (memoryInjection) emitCommand(memoryInjection.command);
6168
6274
  } catch (e) {
6169
- console.warn(`[memory] automatic task context assembly failed: ${e instanceof Error ? e.message : String(e)}`);
6275
+ console.warn(`[memory] automatic task context assembly failed: ${errMessage(e)}`);
6170
6276
  }
6171
6277
  void dispatchTaskCallbacks(id, "task.claimed");
6172
6278
  return json(result.task);
@@ -6572,9 +6678,11 @@ const routes: Route[] = [
6572
6678
  route("GET", "/api/workspaces/orphans", getWorkspaceOrphans),
6573
6679
  route("POST", "/api/workspaces/orphans/reclaim", postWorkspaceOrphanReclaim),
6574
6680
  route("GET", "/api/workspaces/stewards", getWorkspaceStewards),
6681
+ route("POST", "/api/workspaces/actions/cleanup-stale", postWorkspaceCleanupStale),
6575
6682
  route("GET", "/api/workspaces/:id", getWorkspaceById),
6576
6683
  route("GET", "/api/workspaces/:id/git-state", getWorkspaceGitState),
6577
6684
  route("GET", "/api/workspaces/:id/merge-preview", getWorkspaceMergePreview),
6685
+ route("GET", "/api/workspaces/:id/diagnostics", getWorkspaceDiagnostics),
6578
6686
  route("GET", "/api/workspaces/:id/diff", getWorkspaceDiff),
6579
6687
  route("POST", "/api/workspaces/:id/actions", postWorkspaceAction),
6580
6688
  route("DELETE", "/api/workspaces/:id", deleteWorkspaceById),