agent-relay-server 0.16.0 → 0.18.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,7 @@ import {
96
98
  setMessageReaction,
97
99
  ValidationError,
98
100
  } from "./db";
101
+ import { cleanString } from "./validation";
99
102
  import { getArtifactStorage, maxArtifactBytes, normalizeDigest } from "./artifact-storage";
100
103
  import {
101
104
  deleteConfig,
@@ -108,6 +111,8 @@ import {
108
111
  getStewardConfigEntry,
109
112
  getInsightsConfigEntry,
110
113
  setInsightsConfig,
114
+ getWorkspaceConfigEntry,
115
+ setWorkspaceConfig,
111
116
  listAgentProfiles,
112
117
  listSpawnPolicies,
113
118
  listConfig,
@@ -149,10 +154,14 @@ import { CONTRACT_VERSIONS, parseRuntimeCapabilities, parseRuntimeContracts, par
149
154
  import { listHostDirectories } from "./agent-spawn";
150
155
  import { defaultProviderConfig, loadProviderConfig, providerConfigPublic, writeProviderConfig } from "../runner/src/config";
151
156
  import type { ProviderConfig } from "../runner/src/adapter";
152
- import { resolveProviderSelection, type ProviderEffort } from "agent-relay-sdk/provider-catalog";
157
+ import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
158
+ import { isRecord, SPAWN_PROVIDERS, VALID_WORKSPACE_MODES, VALID_EFFORTS, APPROVAL_MODES, RELAY_TOKEN_HEADER } from "agent-relay-sdk";
153
159
  import { effectiveProviderCatalogList } from "./provider-catalog-store";
154
160
  import { buildManagedSpawnParams, effectiveManagedPolicyWorkspaceMode } from "./managed-policy";
161
+ import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams, type SpawnModelParams } from "./spawn-command";
155
162
  import { requestWorkspaceMerge } from "./workspace-merge";
163
+ import { claimMetadataPatch, workspaceActiveClaim } from "./workspace-claim";
164
+ import type { WorkspaceDiagnostics, WorkspaceGitState, WorkspaceRecord } from "./types";
156
165
  import {
157
166
  getComponentAuth,
158
167
  getIntegrationAuth,
@@ -189,7 +198,8 @@ import { assertMemoryCreateAllowed, assertMemoryUpdateAllowed } from "./memory-s
189
198
  import { captureTaskResultMemory, clearActiveMemories, injectAlwaysReloadMemories, injectMemoryContext, injectMemoryForMessageDelivery, injectMemoryForTaskClaim, memoryBroker, memoryBrokerConfig } from "./memory-service";
190
199
  import { postMcp } from "./mcp";
191
200
  import { readFileSync } from "node:fs";
192
- import { isAbsolute, relative, resolve } from "node:path";
201
+ import { resolve } from "node:path";
202
+ import { isPathWithinBase } from "./utils";
193
203
  import type { ArtifactKind, ArtifactSensitivity, AttachmentRef, ContextBudget, CreateMemoryInput, MemoryBrokerContext, MemoryConfidence, MemoryQuery, MemoryRedactionState, MemorySensitivity, MemoryType, MemoryVisibility, TaskRoutingHints, TokenConstraints, UpdateMemoryInput } from "./types";
194
204
  import { issueIntegrationRuntimeToken, issueInteractiveRunnerRuntimeToken, issueMcpRuntimeToken, issueOrchestratorRuntimeToken, reissueRunnerRuntimeToken, runnerRuntimeTokenEnv } from "./runtime-tokens";
195
205
  import { listMaintenanceJobs, runLegacyMaintenanceReaper, runMaintenanceJobNow } from "./maintenance";
@@ -320,14 +330,10 @@ function parseQueryInt(
320
330
  const VALID_AGENT_STATUSES = ["online", "idle", "busy", "stale", "offline"] as const;
321
331
  const VALID_AGENT_KINDS = ["provider", "channel", "orchestrator", "system", "user"] as const;
322
332
  const VALID_CHANNEL_BINDING_TARGET_TYPES = ["agent", "label", "tag", "capability", "broadcast", "orchestrator", "pool", "policy"] as const;
323
- const VALID_WORKSPACE_MODES = ["isolated", "shared", "inherit"] as const;
324
333
  const VALID_WORKSPACE_STATUSES = ["active", "ready", "conflict", "review_requested", "merge_planned", "merged", "abandoned", "cleanup_requested", "cleaned"] as const;
325
334
  const VALID_CHANNEL_BINDING_MODES = ["exclusive", "broadcast"] as const;
326
335
  const VALID_AGENT_ACTIONS = ["restart", "shutdown", "reconnect", "compact", "clearContext", "resume", "interrupt"] as const;
327
336
  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;
328
- const VALID_AGENT_SPAWN_PROVIDERS = ["codex"] as const;
329
- const VALID_CODEX_SPAWN_APPROVALS = ["open", "guarded", "read-only"] as const;
330
- const VALID_PROVIDER_EFFORTS = ["low", "medium", "high", "xhigh", "max"] as const;
331
337
  const VALID_CONNECTOR_ACTIONS = ["install", "uninstall", "enable", "disable", "start", "stop", "restart", "status", "doctor"] as const;
332
338
  const VALID_TASK_SEVERITIES = ["info", "warning", "critical"] as const;
333
339
  const VALID_TASK_STATUSES = ["open", "claimed", "in_progress", "blocked", "orphaned", "done", "failed", "canceled"] as const;
@@ -346,34 +352,6 @@ const VALID_ARTIFACT_SENSITIVITIES = ["public", "normal", "sensitive", "secret"]
346
352
  const VALID_ARTIFACT_ROLES = ["media", "patch", "report", "log", "output", "input"] as const;
347
353
  const integrationRateBuckets = new Map<string, { windowStart: number; count: number }>();
348
354
 
349
- function isRecord(value: unknown): value is Record<string, unknown> {
350
- return typeof value === "object" && value !== null && !Array.isArray(value);
351
- }
352
-
353
- function pathWithinBase(path: string, baseDir: string): boolean {
354
- const base = resolve(baseDir);
355
- const target = resolve(path);
356
- const rel = relative(base, target);
357
- return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
358
- }
359
-
360
- function cleanString(
361
- value: unknown,
362
- field: string,
363
- opts: { required?: boolean; max?: number } = {},
364
- ): string | undefined {
365
- if (value === undefined || value === null) {
366
- if (opts.required) throw new ValidationError(`${field} required`);
367
- return undefined;
368
- }
369
- if (typeof value !== "string") throw new ValidationError(`${field} must be a string`);
370
- const trimmed = value.trim();
371
- if (opts.required && !trimmed) throw new ValidationError(`${field} required`);
372
- if (opts.max && trimmed.length > opts.max) {
373
- throw new ValidationError(`${field} must be ${opts.max} characters or fewer`);
374
- }
375
- return trimmed || undefined;
376
- }
377
355
 
378
356
  function cleanNullableString(value: unknown, field: string, max: number): string | null | undefined {
379
357
  if (value === undefined) return undefined;
@@ -594,6 +572,12 @@ function normalizeMessageInput(body: unknown): SendMessageInput {
594
572
  }
595
573
  input.maxAgeSeconds = body.maxAgeSeconds;
596
574
  }
575
+ if (body.occurredAt !== undefined) {
576
+ if (typeof body.occurredAt !== "number" || !Number.isFinite(body.occurredAt) || body.occurredAt <= 0) {
577
+ throw new ValidationError("occurredAt must be a positive epoch-ms number");
578
+ }
579
+ input.occurredAt = body.occurredAt;
580
+ }
597
581
 
598
582
  const channel = cleanString(body.channel, "channel", { max: 120 });
599
583
  if (channel) input.channel = channel;
@@ -2272,38 +2256,22 @@ function restartSpawnParamsForAgent(
2272
2256
  const profileName = metaString(agent.meta, "profile");
2273
2257
  const agentProfile = profileName ? getAgentProfile(profileName)?.value : undefined;
2274
2258
  const workspaceMode = metaString(agent.meta, "workspaceMode") ?? "inherit";
2275
- let resolvedModel: Record<string, string> = {};
2276
- if (model || effort) {
2277
- try {
2278
- const selection = resolveProviderSelection({ provider, model, effort });
2279
- resolvedModel = {
2280
- ...(selection.modelAlias ? { model: selection.modelAlias } : {}),
2281
- ...(selection.providerModel ? { providerModel: selection.providerModel } : {}),
2282
- ...(selection.effort ? { effort: selection.effort } : {}),
2283
- };
2284
- } catch {
2285
- resolvedModel = {
2286
- ...(model ? { model } : {}),
2287
- ...(effort ? { effort } : {}),
2288
- };
2289
- }
2290
- }
2291
- const params = {
2292
- action: "spawn",
2259
+ const resolvedModel = resolveSpawnModelParams(provider, model, effort, { onError: "passthrough", skipDefaultWhenEmpty: true });
2260
+ const params = buildSpawnCommand({
2293
2261
  provider,
2294
- ...resolvedModel,
2262
+ modelParams: resolvedModel,
2295
2263
  cwd,
2296
2264
  workspaceMode,
2297
- ...(profileName ? { profile: profileName } : {}),
2298
- ...(agentProfile ? { agentProfile } : {}),
2299
- ...(label ? { label } : {}),
2265
+ profile: profileName || undefined,
2266
+ agentProfile,
2267
+ label: label || undefined,
2300
2268
  agentId: agent.id,
2301
2269
  tags: agent.tags,
2302
2270
  capabilities: agent.capabilities,
2303
2271
  approvalMode: approvalMode ?? "guarded",
2304
2272
  permissionMode: approvalMode ?? "guarded",
2305
- ...(providerArgs.length ? { providerArgs } : {}),
2306
- ...(policyName ? { policyName } : {}),
2273
+ providerArgs: providerArgs.length ? providerArgs : undefined,
2274
+ policyName: policyName || undefined,
2307
2275
  headless: true,
2308
2276
  spawnRequestId: requestId,
2309
2277
  env: runnerRuntimeTokenEnv({
@@ -2317,12 +2285,12 @@ function restartSpawnParamsForAgent(
2317
2285
  }),
2318
2286
  requestedBy,
2319
2287
  requestedAt: Date.now(),
2320
- };
2288
+ });
2321
2289
  return opts.resumeId ? withClaudeResumeParams(params, opts.resumeId, agent.id) : params;
2322
2290
  }
2323
2291
 
2324
2292
  function spawnRequestIdForRestart(): string {
2325
- return `sp_${crypto.randomUUID()}`;
2293
+ return generateSpawnRequestId();
2326
2294
  }
2327
2295
 
2328
2296
  function withClaudeResumeParams(params: Record<string, unknown>, resumeId: string, agentId: string): Record<string, unknown> {
@@ -2623,10 +2591,10 @@ const postAgentSpawn: Handler = async (req) => {
2623
2591
  if (!parsed.ok) return error(parsed.error, parsed.status);
2624
2592
  try {
2625
2593
  if (!isRecord(parsed.body)) return error("provider required");
2626
- const provider = cleanEnum(parsed.body.provider, "provider", [...VALID_AGENT_SPAWN_PROVIDERS, "claude"] as const);
2594
+ const provider = cleanEnum(parsed.body.provider, "provider", SPAWN_PROVIDERS);
2627
2595
  if (!provider) return error("provider required");
2628
2596
  const selection = cleanSpawnSelection(parsed.body, provider as SpawnProvider);
2629
- const approvalMode = cleanEnum(parsed.body.approvalMode, "approvalMode", VALID_CODEX_SPAWN_APPROVALS, "guarded") as SpawnApprovalMode;
2597
+ const approvalMode = cleanEnum(parsed.body.approvalMode, "approvalMode", APPROVAL_MODES, "guarded") as SpawnApprovalMode;
2630
2598
  const cwd = cleanString(parsed.body.cwd, "cwd", { max: 500 });
2631
2599
  const label = cleanString(parsed.body.label, "label", { max: 120 });
2632
2600
  const workspaceMode = cleanEnum(parsed.body.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES, "inherit") as WorkspaceMode;
@@ -2636,7 +2604,7 @@ const postAgentSpawn: Handler = async (req) => {
2636
2604
  );
2637
2605
  const orch = orchestrators[0];
2638
2606
  if (!orch) return error("no orchestrator available for provider: " + provider);
2639
- if (cwd && !pathWithinBase(cwd, orch.baseDir)) {
2607
+ if (cwd && !isPathWithinBase(cwd, orch.baseDir)) {
2640
2608
  return error(`cwd must be within orchestrator base directory: ${orch.baseDir}`);
2641
2609
  }
2642
2610
  const requestId = spawnRequestId();
@@ -2650,10 +2618,9 @@ const postAgentSpawn: Handler = async (req) => {
2650
2618
  source: "system",
2651
2619
  target: orch.agentId,
2652
2620
  correlationId: requestId,
2653
- params: {
2654
- action: "spawn",
2621
+ params: buildSpawnCommand({
2655
2622
  provider,
2656
- ...selection,
2623
+ modelParams: selection,
2657
2624
  cwd: cwd || orch.baseDir,
2658
2625
  workspaceMode,
2659
2626
  label,
@@ -2669,7 +2636,7 @@ const postAgentSpawn: Handler = async (req) => {
2669
2636
  spawnRequestId: requestId,
2670
2637
  createdBy: "dashboard",
2671
2638
  }),
2672
- },
2639
+ }),
2673
2640
  });
2674
2641
  emitCommand(command);
2675
2642
  auditEvent({
@@ -2939,7 +2906,7 @@ const postInteractiveRunnerRuntimeToken: Handler = async (req) => {
2939
2906
  if (!parsed.ok) return error(parsed.error, parsed.status);
2940
2907
  try {
2941
2908
  if (!isRecord(parsed.body)) return error("runtime token body required");
2942
- const provider = cleanEnum(parsed.body.provider, "provider", VALID_ORCHESTRATOR_PROVIDERS)! as SpawnProvider;
2909
+ const provider = cleanEnum(parsed.body.provider, "provider", SPAWN_PROVIDERS)! as SpawnProvider;
2943
2910
  const cwd = cleanString(parsed.body.cwd, "cwd", { required: true, max: 500 })!;
2944
2911
  const runnerId = cleanString(parsed.body.runnerId, "runnerId", { required: true, max: 240 })!;
2945
2912
  const agentId = cleanString(parsed.body.agentId, "agentId", { max: 240 });
@@ -3121,9 +3088,6 @@ const postTokenRevoke: Handler = (_req, params) => {
3121
3088
 
3122
3089
  // --- Orchestrator routes ---
3123
3090
 
3124
- const VALID_ORCHESTRATOR_PROVIDERS = ["claude", "codex"] as const;
3125
- const VALID_SPAWN_APPROVALS = ["open", "guarded", "read-only"] as const;
3126
-
3127
3091
  function cleanJsonArray(value: unknown, field: string): unknown[] | undefined {
3128
3092
  if (value === undefined || value === null) return undefined;
3129
3093
  if (!Array.isArray(value)) throw new ValidationError(`${field} must be an array`);
@@ -3131,27 +3095,17 @@ function cleanJsonArray(value: unknown, field: string): unknown[] | undefined {
3131
3095
  return value;
3132
3096
  }
3133
3097
 
3134
- function cleanSpawnSelection(body: Record<string, unknown>, provider: SpawnProvider): { model?: string; effort?: ProviderEffort; providerModel?: string } {
3098
+ function cleanSpawnSelection(body: Record<string, unknown>, provider: SpawnProvider): SpawnModelParams {
3135
3099
  const model = cleanString(body.model, "model", { max: 120 });
3136
- const effort = cleanEnum(body.effort, "effort", VALID_PROVIDER_EFFORTS) as ProviderEffort | undefined;
3137
- let resolved;
3138
- try {
3139
- resolved = resolveProviderSelection({ provider, model, effort });
3140
- } catch (error) {
3141
- throw new ValidationError(error instanceof Error ? error.message : String(error));
3142
- }
3143
- return {
3144
- model: resolved.modelAlias,
3145
- providerModel: resolved.providerModel,
3146
- effort: resolved.effort,
3147
- };
3100
+ const effort = cleanEnum(body.effort, "effort", VALID_EFFORTS) as ProviderEffort | undefined;
3101
+ return resolveSpawnModelParams(provider, model, effort);
3148
3102
  }
3149
3103
 
3150
3104
  function validateSpawnSelectionForOrchestrator(
3151
3105
  orch: NonNullable<ReturnType<typeof getOrchestrator>>,
3152
3106
  provider: SpawnProvider,
3153
3107
  body: Record<string, unknown>,
3154
- ): { model?: string; effort?: ProviderEffort; providerModel?: string } | Response {
3108
+ ): SpawnModelParams | Response {
3155
3109
  if (!orch.providers.includes(provider)) {
3156
3110
  return error(`orchestrator does not have provider available: ${provider}`, 409);
3157
3111
  }
@@ -3211,8 +3165,8 @@ const postOrchestrator: Handler = async (req) => {
3211
3165
  const providers = cleanStringArray(parsed.body.providers, "providers") as SpawnProvider[] | undefined;
3212
3166
  if (providers) {
3213
3167
  for (const p of providers) {
3214
- if (!VALID_ORCHESTRATOR_PROVIDERS.includes(p as any)) {
3215
- return error(`invalid provider: ${p}. Must be one of: ${VALID_ORCHESTRATOR_PROVIDERS.join(", ")}`);
3168
+ if (!SPAWN_PROVIDERS.includes(p as any)) {
3169
+ return error(`invalid provider: ${p}. Must be one of: ${SPAWN_PROVIDERS.join(", ")}`);
3216
3170
  }
3217
3171
  }
3218
3172
  }
@@ -3295,7 +3249,7 @@ const postOrchestratorHeartbeat: Handler = async (req, params) => {
3295
3249
  };
3296
3250
  if (runtime.providers) {
3297
3251
  for (const p of runtime.providers) {
3298
- if (!VALID_ORCHESTRATOR_PROVIDERS.includes(p as any)) throw new ValidationError(`invalid provider: ${p}. Must be one of: ${VALID_ORCHESTRATOR_PROVIDERS.join(", ")}`);
3252
+ if (!SPAWN_PROVIDERS.includes(p as any)) throw new ValidationError(`invalid provider: ${p}. Must be one of: ${SPAWN_PROVIDERS.join(", ")}`);
3299
3253
  }
3300
3254
  }
3301
3255
  const orch = orchestratorHeartbeat(params.id!, runtime);
@@ -3323,8 +3277,8 @@ const postOrchestratorBootstrap: Handler = async (req) => {
3323
3277
  const pathPrefix = cleanStringArray(parsed.body.pathPrefix, "pathPrefix");
3324
3278
  const providerList = providers?.length ? providers : ["claude", "codex"];
3325
3279
  for (const p of providerList) {
3326
- if (!VALID_ORCHESTRATOR_PROVIDERS.includes(p as any)) {
3327
- throw new ValidationError(`invalid provider: ${p}. Must be one of: ${VALID_ORCHESTRATOR_PROVIDERS.join(", ")}`);
3280
+ if (!SPAWN_PROVIDERS.includes(p as any)) {
3281
+ throw new ValidationError(`invalid provider: ${p}. Must be one of: ${SPAWN_PROVIDERS.join(", ")}`);
3328
3282
  }
3329
3283
  }
3330
3284
  const bootstrapToken = createToken({
@@ -3415,7 +3369,7 @@ const patchOrchestratorAgents: Handler = async (req, params) => {
3415
3369
  ?? cleanString(a.tmuxSession, "tmuxSession", { required: true, max: 240 })!;
3416
3370
  return {
3417
3371
  agentId: cleanString(a.agentId, "agentId", { max: 240 }) || "",
3418
- provider: cleanEnum(a.provider, "provider", VALID_ORCHESTRATOR_PROVIDERS)! as SpawnProvider,
3372
+ provider: cleanEnum(a.provider, "provider", SPAWN_PROVIDERS)! as SpawnProvider,
3419
3373
  sessionName,
3420
3374
  tmuxSession: cleanString(a.tmuxSession, "tmuxSession", { max: 240 }) ?? sessionName,
3421
3375
  supervisor: cleanEnum(a.supervisor, "supervisor", ["process", "systemd", "launchd", "unknown"] as const),
@@ -3427,7 +3381,7 @@ const patchOrchestratorAgents: Handler = async (req, params) => {
3427
3381
  workspaceMode: cleanEnum(a.workspaceMode, "workspaceMode", VALID_WORKSPACE_MODES),
3428
3382
  workspace: cleanWorkspaceMetadata(a.workspace, "workspace"),
3429
3383
  label: cleanString(a.label, "label", { max: 120 }),
3430
- approvalMode: (cleanEnum(a.approvalMode, "approvalMode", VALID_SPAWN_APPROVALS, "guarded") ?? "guarded") as SpawnApprovalMode,
3384
+ approvalMode: (cleanEnum(a.approvalMode, "approvalMode", APPROVAL_MODES, "guarded") ?? "guarded") as SpawnApprovalMode,
3431
3385
  policyName: cleanString(a.policyName, "policyName", { max: 120 }),
3432
3386
  spawnRequestId: cleanString(a.spawnRequestId, "spawnRequestId", { max: 160 }),
3433
3387
  automationRunId: cleanString(a.automationRunId, "automationRunId", { max: 160 }),
@@ -3492,7 +3446,7 @@ const patchOrchestratorAgents: Handler = async (req, params) => {
3492
3446
 
3493
3447
  function cleanManagedSessionExitDiagnostics(value: unknown, index: number): ManagedSessionExitDiagnostics {
3494
3448
  if (!isRecord(value)) throw new ValidationError(`exitedAgents[${index}] must be an object`);
3495
- const provider = cleanEnum(value.provider, `exitedAgents[${index}].provider`, VALID_ORCHESTRATOR_PROVIDERS);
3449
+ const provider = cleanEnum(value.provider, `exitedAgents[${index}].provider`, SPAWN_PROVIDERS);
3496
3450
  if (!provider) throw new ValidationError(`exitedAgents[${index}].provider required`);
3497
3451
  const systemd = isRecord(value.systemd)
3498
3452
  ? {
@@ -3566,15 +3520,15 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
3566
3520
  if (orch.status !== "online") return error("orchestrator is offline", 409);
3567
3521
 
3568
3522
  if (!isRecord(parsed.body)) return error("body required");
3569
- const provider = cleanEnum(parsed.body.provider, "provider", VALID_ORCHESTRATOR_PROVIDERS)! as SpawnProvider;
3523
+ const provider = cleanEnum(parsed.body.provider, "provider", SPAWN_PROVIDERS)! as SpawnProvider;
3570
3524
  const selection = validateSpawnSelectionForOrchestrator(orch, provider, parsed.body);
3571
3525
  if (selection instanceof Response) return selection;
3572
3526
  const cwd = cleanString(parsed.body.cwd, "cwd", { max: 500 });
3573
- if (cwd && !pathWithinBase(cwd, orch.baseDir)) {
3527
+ if (cwd && !isPathWithinBase(cwd, orch.baseDir)) {
3574
3528
  return error(`cwd must be within orchestrator base directory: ${orch.baseDir}`);
3575
3529
  }
3576
3530
  const label = cleanString(parsed.body.label, "label", { max: 120 });
3577
- const approvalMode = cleanEnum(parsed.body.approvalMode, "approvalMode", VALID_SPAWN_APPROVALS, "guarded") as SpawnApprovalMode;
3531
+ const approvalMode = cleanEnum(parsed.body.approvalMode, "approvalMode", APPROVAL_MODES, "guarded") as SpawnApprovalMode;
3578
3532
  const prompt = cleanString(parsed.body.prompt, "prompt", { max: 16000 });
3579
3533
  const systemPromptAppend = cleanString(parsed.body.systemPromptAppend, "systemPromptAppend", { max: 64_000 });
3580
3534
  const tags = cleanStringArray(parsed.body.tags, "tags") ?? [];
@@ -3597,12 +3551,9 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
3597
3551
  source: "system",
3598
3552
  target: orch.agentId,
3599
3553
  correlationId: requestId,
3600
- params: {
3601
- action: "spawn",
3554
+ params: buildSpawnCommand({
3602
3555
  provider,
3603
- model: selection.model,
3604
- providerModel: selection.providerModel,
3605
- effort: selection.effort,
3556
+ modelParams: { model: selection.model, providerModel: selection.providerModel, effort: selection.effort },
3606
3557
  cwd: cwd || orch.baseDir,
3607
3558
  workspaceMode,
3608
3559
  label,
@@ -3628,7 +3579,7 @@ const postOrchestratorSpawn: Handler = async (req, params) => {
3628
3579
  }),
3629
3580
  requestedBy: "dashboard",
3630
3581
  requestedAt: Date.now(),
3631
- },
3582
+ }),
3632
3583
  });
3633
3584
  emitCommand(command);
3634
3585
  auditEvent({
@@ -3925,7 +3876,7 @@ async function proxyWorkspaceHostGet(workspaceId: string, hostPath: string, extr
3925
3876
  return json({ available: false, reason: "no isolated worktree" });
3926
3877
  }
3927
3878
  const orch = listOrchestrators().find(
3928
- (candidate) => candidate.status === "online" && candidate.apiUrl && pathWithinBase(workspace.sourceCwd, candidate.baseDir),
3879
+ (candidate) => candidate.status === "online" && candidate.apiUrl && isPathWithinBase(workspace.sourceCwd, candidate.baseDir),
3929
3880
  );
3930
3881
  if (!orch?.apiUrl) return json({ available: false, reason: "owning orchestrator offline" });
3931
3882
  const query = new URLSearchParams({ path: workspace.worktreePath });
@@ -3934,7 +3885,7 @@ async function proxyWorkspaceHostGet(workspaceId: string, hostPath: string, extr
3934
3885
  for (const [key, value] of Object.entries(extraQuery ?? {})) query.set(key, value);
3935
3886
  const headers: Record<string, string> = {};
3936
3887
  const relayToken = process.env.AGENT_RELAY_TOKEN;
3937
- if (relayToken) headers["X-Agent-Relay-Token"] = relayToken;
3888
+ if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
3938
3889
  try {
3939
3890
  const res = await fetch(`${orch.apiUrl}${hostPath}?${query.toString()}`, {
3940
3891
  headers,
@@ -3976,11 +3927,11 @@ const getWorkspaceOrphans: Handler = async () => {
3976
3927
  const repoRoots = [...new Set(all.map((ws) => ws.repoRoot).filter(Boolean))];
3977
3928
  const headers: Record<string, string> = {};
3978
3929
  const relayToken = process.env.AGENT_RELAY_TOKEN;
3979
- if (relayToken) headers["X-Agent-Relay-Token"] = relayToken;
3930
+ if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
3980
3931
  const orphans: WorkspaceOrphan[] = [];
3981
3932
 
3982
3933
  for (const repoRoot of repoRoots) {
3983
- const orch = orchestrators.find((candidate) => candidate.apiUrl && pathWithinBase(repoRoot, candidate.baseDir));
3934
+ const orch = orchestrators.find((candidate) => candidate.apiUrl && isPathWithinBase(repoRoot, candidate.baseDir));
3984
3935
  if (!orch?.apiUrl) continue;
3985
3936
  let probe: WorkspaceProbe | undefined;
3986
3937
  try {
@@ -4018,7 +3969,7 @@ const postWorkspaceOrphanReclaim: Handler = async (req) => {
4018
3969
  // Refuse to reclaim a path that still backs a live workspace row.
4019
3970
  const live = listWorkspaces().find((ws) => ws.worktreePath && resolve(ws.worktreePath) === resolve(worktreePath) && !TERMINAL_WORKSPACE_STATUSES.has(ws.status));
4020
3971
  if (live) return error(`path backs live workspace ${live.id}; clean it through the workspace, not orphan reclaim`, 409);
4021
- const orch = listOrchestrators().find((candidate) => candidate.status === "online" && pathWithinBase(repoRoot, candidate.baseDir));
3972
+ const orch = listOrchestrators().find((candidate) => candidate.status === "online" && isPathWithinBase(repoRoot, candidate.baseDir));
4022
3973
  if (!orch) return error("no online orchestrator owns this path", 409);
4023
3974
  const command = createCommand({
4024
3975
  type: "workspace.cleanup",
@@ -4044,6 +3995,165 @@ const postWorkspaceOrphanReclaim: Handler = async (req) => {
4044
3995
  }
4045
3996
  };
4046
3997
 
3998
+ // Build a `workspace.cleanup` command for a worktree's owning orchestrator. Shared
3999
+ // by the manual cleanup action and the cleanup-stale sweep (#208) so both resolve
4000
+ // the owner and shape params identically. Queues (no TTL) when the owner is offline;
4001
+ // hard-fails only when no orchestrator owns the path (DELETE is then the escape).
4002
+ function buildWorkspaceCleanupCommand(workspace: WorkspaceRecord, requestedBy: string): { ok: true; command: Command } | { ok: false; status: number; error: string } {
4003
+ const owners = listOrchestrators().filter((candidate) => isPathWithinBase(workspace.sourceCwd, candidate.baseDir));
4004
+ const owner = owners.find((candidate) => candidate.status === "online") ?? owners[0];
4005
+ if (!owner) return { ok: false, status: 409, error: "no orchestrator owns this workspace path; use DELETE /api/workspaces/:id to purge the record" };
4006
+ const command = createCommand({
4007
+ type: "workspace.cleanup",
4008
+ source: "system",
4009
+ target: owner.agentId,
4010
+ correlationId: workspace.id,
4011
+ params: {
4012
+ action: "cleanup",
4013
+ workspaceId: workspace.id,
4014
+ repoRoot: workspace.repoRoot,
4015
+ worktreePath: workspace.worktreePath,
4016
+ branch: workspace.branch,
4017
+ requestedBy,
4018
+ requestedAt: Date.now(),
4019
+ deleteBranch: true,
4020
+ queued: owner.status !== "online",
4021
+ },
4022
+ });
4023
+ return { ok: true, command };
4024
+ }
4025
+
4026
+ // Fetch + parse a workspace's live git state from its owning host, or report why
4027
+ // it's unavailable. Thin typed wrapper over the same host route the proxy uses.
4028
+ async function fetchWorkspaceGitState(workspace: WorkspaceRecord): Promise<{ state: WorkspaceGitState } | { unavailable: string }> {
4029
+ if (workspace.mode !== "isolated" || !workspace.worktreePath) return { unavailable: "no isolated worktree" };
4030
+ const orch = listOrchestrators().find(
4031
+ (candidate) => candidate.status === "online" && candidate.apiUrl && isPathWithinBase(workspace.sourceCwd, candidate.baseDir),
4032
+ );
4033
+ if (!orch?.apiUrl) return { unavailable: "owning orchestrator offline" };
4034
+ const query = new URLSearchParams({ path: workspace.worktreePath });
4035
+ if (workspace.baseRef) query.set("baseRef", workspace.baseRef);
4036
+ if (workspace.baseSha) query.set("baseSha", workspace.baseSha);
4037
+ const headers: Record<string, string> = {};
4038
+ const relayToken = process.env.AGENT_RELAY_TOKEN;
4039
+ if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
4040
+ try {
4041
+ const res = await fetch(`${orch.apiUrl}/api/workspace/state?${query.toString()}`, { headers, signal: AbortSignal.timeout(10_000) });
4042
+ if (!res.ok) return { unavailable: `host returned ${res.status}` };
4043
+ return { state: await res.json() as WorkspaceGitState };
4044
+ } catch (e) {
4045
+ return { unavailable: `orchestrator unreachable: ${(e as Error).message}` };
4046
+ }
4047
+ }
4048
+
4049
+ // Recommend the next action for a workspace from its joined state — the steward's
4050
+ // (and a release agent's) "what should happen here?" without reconstructing it.
4051
+ function recommendWorkspaceAction(input: { workspace: WorkspaceRecord; ownerOnline: boolean; gitState?: WorkspaceGitState; claim: ReturnType<typeof workspaceActiveClaim> }): WorkspaceDiagnostics["recommendation"] {
4052
+ const { workspace, ownerOnline, gitState, claim } = input;
4053
+ if (claim) return { action: "wait", confidence: "high", reason: `claimed by ${claim.by ?? "steward"}` };
4054
+ if (TERMINAL_WORKSPACE_STATUSES.has(workspace.status)) return { action: "none", confidence: "high", reason: `workspace is ${workspace.status}` };
4055
+ if (!gitState || gitState.error) return { action: "review", confidence: "low", reason: gitState?.error ? `git state error: ${gitState.error}` : "git state unavailable" };
4056
+ if (gitState.missing) return { action: "cleanup", confidence: "high", reason: "worktree no longer exists on disk" };
4057
+ const ahead = gitState.unmergedAhead ?? gitState.ahead ?? 0;
4058
+ const landed = gitState.landed === true;
4059
+ if ((gitState.dirtyCount ?? 0) > 0) return { action: "review", confidence: "medium", reason: `${gitState.dirtyCount} uncommitted change(s)` };
4060
+ if (ahead === 0 || landed) {
4061
+ if (!ownerOnline) return { action: "cleanup", confidence: "high", reason: landed ? "work already landed; owner offline" : "no unmerged commits; owner offline" };
4062
+ return { action: "none", confidence: "medium", reason: "nothing to merge; owner active" };
4063
+ }
4064
+ if (ownerOnline && workspace.status !== "review_requested" && workspace.status !== "conflict") {
4065
+ return { action: "wait", confidence: "medium", reason: "owner active and not awaiting review" };
4066
+ }
4067
+ if (workspace.status === "conflict") return { action: "rebase", confidence: "high", reason: "conflict — rebase onto base and resolve" };
4068
+ if ((gitState.behind ?? 0) > 0) return { action: "rebase", confidence: "medium", reason: `${gitState.behind} behind base — rebase then merge` };
4069
+ return { action: "merge", confidence: "high", reason: `${ahead} commit(s) ready to land` };
4070
+ }
4071
+
4072
+ // Joined steward briefing for one workspace (#208): row + owner/orchestrator
4073
+ // liveness + live git state + branch mismatch + active claim + recommended action.
4074
+ const getWorkspaceDiagnostics: Handler = async (_req, params) => {
4075
+ const workspace = getWorkspace(params.id!);
4076
+ if (!workspace) return error("workspace not found", 404);
4077
+ const owner = workspace.ownerAgentId ? getAgent(workspace.ownerAgentId) : null;
4078
+ const ownerOnline = Boolean(owner) && owner!.status !== "offline";
4079
+ const orch = listOrchestrators().find((candidate) => isPathWithinBase(workspace.sourceCwd, candidate.baseDir));
4080
+ const orchOnline = Boolean(orch) && orch!.status === "online";
4081
+ const fetched = await fetchWorkspaceGitState(workspace);
4082
+ const gitState = "state" in fetched ? fetched.state : undefined;
4083
+ const claim = workspaceActiveClaim(workspace);
4084
+ const liveBranch = gitState?.branch;
4085
+ const diagnostics: WorkspaceDiagnostics = {
4086
+ workspaceId: workspace.id,
4087
+ status: workspace.status,
4088
+ mode: workspace.mode,
4089
+ repoRoot: workspace.repoRoot,
4090
+ worktreePath: workspace.worktreePath,
4091
+ recordedBranch: workspace.branch,
4092
+ liveBranch,
4093
+ baseRef: workspace.baseRef,
4094
+ branchMismatch: workspace.branch && liveBranch ? workspace.branch !== liveBranch : undefined,
4095
+ owner: { id: workspace.ownerAgentId, status: owner?.status, online: ownerOnline },
4096
+ orchestrator: { id: orch?.id, online: orchOnline },
4097
+ ...(claim ? { claim: { by: claim.by, purpose: claim.purpose, expiresAt: claim.expiresAt } } : {}),
4098
+ ...(gitState ? { gitState } : { gitStateUnavailable: "unavailable" in fetched ? fetched.unavailable : "unknown" }),
4099
+ recommendation: recommendWorkspaceAction({ workspace, ownerOnline, gitState, claim }),
4100
+ };
4101
+ return json(diagnostics);
4102
+ };
4103
+
4104
+ // Guarded batch cleanup of stale worktrees (#208 / steward report §3). Defaults to
4105
+ // dry-run and only ever proposes/cleans worktrees that are provably safe: offline
4106
+ // owner, not claimed, clean tree, and (landed-only) work already in base or empty.
4107
+ // Never touches a live owner. Reuses the same cleanup command path as the manual action.
4108
+ const postWorkspaceCleanupStale: Handler = async (req) => {
4109
+ const denied = authorizeRoute(req, { scope: "command:write" });
4110
+ if (denied) return denied;
4111
+ const parsed = await parseBody<unknown>(req);
4112
+ if (!parsed.ok) return error(parsed.error, parsed.status);
4113
+ const body = isRecord(parsed.body) ? parsed.body : {};
4114
+ const repoRoot = cleanString(body.repoRoot, "repoRoot", { max: 1000 });
4115
+ const dryRun = body.dryRun !== false; // safe by default
4116
+ const landedOnly = body.landedOnly !== false;
4117
+ const offlineOwnerOnly = body.offlineOwnerOnly !== false;
4118
+
4119
+ const candidates = listWorkspaces().filter((ws) =>
4120
+ ws.mode === "isolated" && Boolean(ws.worktreePath) && !TERMINAL_WORKSPACE_STATUSES.has(ws.status) && (!repoRoot || ws.repoRoot === repoRoot),
4121
+ );
4122
+
4123
+ const rows: Array<Record<string, unknown>> = [];
4124
+ const cleaned: string[] = [];
4125
+ for (const ws of candidates) {
4126
+ const owner = ws.ownerAgentId ? getAgent(ws.ownerAgentId) : null;
4127
+ const ownerOnline = Boolean(owner) && owner!.status !== "offline";
4128
+ if (ownerOnline) continue; // never clean a live owner's worktree
4129
+ if (offlineOwnerOnly && !ws.ownerAgentId) { /* no owner recorded — still eligible */ }
4130
+ if (workspaceActiveClaim(ws)) continue; // respect steward claims
4131
+ const fetched = await fetchWorkspaceGitState(ws);
4132
+ const gitState = "state" in fetched ? fetched.state : undefined;
4133
+ const missing = gitState?.missing === true;
4134
+ const dirtyCount = gitState?.dirtyCount;
4135
+ const ahead = gitState?.unmergedAhead ?? gitState?.ahead ?? 0;
4136
+ const landed = gitState?.landed === true;
4137
+ // Safe = gone from disk, OR clean tree with no unmerged work (landed/empty).
4138
+ const safe = missing || (gitState !== undefined && (dirtyCount ?? 1) === 0 && (!landedOnly || landed || ahead === 0));
4139
+ const proof = { ownerStatus: owner?.status ?? "missing", ownerOnline, ahead, behind: gitState?.behind, landed, dirtyCount, missing, gitStateUnavailable: gitState ? undefined : ("unavailable" in fetched ? fetched.unavailable : undefined) };
4140
+ const row: Record<string, unknown> = { workspaceId: ws.id, branch: ws.branch, worktreePath: ws.worktreePath, repoRoot: ws.repoRoot, safe, proof };
4141
+ if (safe && !dryRun) {
4142
+ const built = buildWorkspaceCleanupCommand(ws, "cleanup-stale");
4143
+ if (built.ok) {
4144
+ updateWorkspaceStatus(ws.id, "cleanup_requested", { lastWorkspaceAction: "cleanup-stale", lastWorkspaceActionAt: Date.now(), cleanupProof: proof });
4145
+ emitCommand(built.command);
4146
+ row.commandId = built.command.id;
4147
+ cleaned.push(ws.id);
4148
+ } else {
4149
+ row.cleanupError = built.error;
4150
+ }
4151
+ }
4152
+ rows.push(row);
4153
+ }
4154
+ return json({ dryRun, landedOnly, offlineOwnerOnly, repoRoot, scanned: candidates.length, eligible: rows.filter((r) => r.safe).length, cleaned, candidates: rows }, dryRun ? 200 : 202);
4155
+ };
4156
+
4047
4157
  const postWorkspaceAction: Handler = async (req, params) => {
4048
4158
  const parsed = await parseBody<unknown>(req);
4049
4159
  if (!parsed.ok) return error(parsed.error, parsed.status);
@@ -4051,7 +4161,7 @@ const postWorkspaceAction: Handler = async (req, params) => {
4051
4161
  if (!isRecord(parsed.body)) return error("body required");
4052
4162
  const workspace = getWorkspace(params.id!);
4053
4163
  if (!workspace) return error("workspace not found", 404);
4054
- const action = cleanEnum(parsed.body.action, "action", ["status", "ready", "conflict-found", "request-review", "merge-plan", "merge", "abandon", "cleanup"] as const);
4164
+ const action = cleanEnum(parsed.body.action, "action", ["status", "ready", "conflict-found", "request-review", "merge-plan", "merge", "abandon", "cleanup", "claim", "release-claim"] as const);
4055
4165
  if (!action) return error("action required", 400);
4056
4166
  const agentId = cleanString(parsed.body.agentId, "agentId", { max: 240 });
4057
4167
  const detail = cleanString(parsed.body.detail, "detail", { max: 4000 });
@@ -4065,6 +4175,26 @@ const postWorkspaceAction: Handler = async (req, params) => {
4065
4175
  const denied = authorizeRoute(req, { scope: requiresCommand ? "command:write" : "agent:write", resource: { agentId, cwd: workspace.worktreePath } });
4066
4176
  if (denied) return denied;
4067
4177
  if (action === "status") return json(workspace);
4178
+ // Steward claim/lease (#208): a TTL'd metadata lease that auto-merge yields to,
4179
+ // so deterministic landing can't race a steward mid-validation. No status change.
4180
+ if (action === "claim" || action === "release-claim") {
4181
+ const release = action === "release-claim";
4182
+ const purpose = cleanString(parsed.body.purpose, "purpose", { max: 120 });
4183
+ const updated = patchWorkspaceMetadata(workspace.id, claimMetadataPatch(release, agentId ?? "steward", purpose));
4184
+ if (!updated) return error("workspace not found", 404);
4185
+ auditEvent({
4186
+ clientId: `workspace-${action}-${workspace.id}-${Date.now()}`,
4187
+ kind: "state",
4188
+ title: release ? "Workspace claim released" : "Workspace claimed",
4189
+ body: detail ?? purpose ?? workspace.worktreePath,
4190
+ meta: workspace.branch ?? workspace.id,
4191
+ icon: "ti-lock",
4192
+ view: "orchestrators",
4193
+ agentId,
4194
+ metadata: { action, workspaceId: workspace.id, repoRoot: workspace.repoRoot, ...authAuditMetadata(req) },
4195
+ });
4196
+ return json({ workspace: updated, claim: workspaceActiveClaim(updated) });
4197
+ }
4068
4198
  // Base merges go through the shared helper (lease + command + bind), the same
4069
4199
  // path the auto-merge job uses, so both serialize per repo (issue #157).
4070
4200
  if (action === "merge") {
@@ -4112,30 +4242,9 @@ const postWorkspaceAction: Handler = async (req, params) => {
4112
4242
  let command: Command | undefined;
4113
4243
  if (requiresCommand) {
4114
4244
  // Only `cleanup` reaches here — `merge` returned early via the shared helper.
4115
- // Cleanup may queue: if the owning orchestrator is offline, the command (no
4116
- // TTL) waits and reconciles when it reconnects. Only hard-fail when no
4117
- // orchestrator owns the path at all — then DELETE is the escape.
4118
- const owners = listOrchestrators().filter((candidate) => pathWithinBase(workspace.sourceCwd, candidate.baseDir));
4119
- const onlineOwner = owners.find((candidate) => candidate.status === "online");
4120
- const owner = onlineOwner ?? owners[0];
4121
- if (!owner) return error("no orchestrator owns this workspace path; use DELETE /api/workspaces/:id to purge the record", 409);
4122
- command = createCommand({
4123
- type: "workspace.cleanup",
4124
- source: "system",
4125
- target: owner.agentId,
4126
- correlationId: workspace.id,
4127
- params: {
4128
- action: "cleanup",
4129
- workspaceId: workspace.id,
4130
- repoRoot: workspace.repoRoot,
4131
- worktreePath: workspace.worktreePath,
4132
- branch: workspace.branch,
4133
- requestedBy: agentId ?? "dashboard",
4134
- requestedAt: Date.now(),
4135
- deleteBranch: true,
4136
- queued: owner.status !== "online",
4137
- },
4138
- });
4245
+ const built = buildWorkspaceCleanupCommand(workspace, agentId ?? "dashboard");
4246
+ if (!built.ok) return error(built.error, built.status);
4247
+ command = built.command;
4139
4248
  emitCommand(command);
4140
4249
  }
4141
4250
  auditEvent({
@@ -4187,7 +4296,7 @@ async function proxyOrchestratorGet(req: Request, orchestratorId: string, path:
4187
4296
  const proxyUrl = `${orch.apiUrl}${path}${incoming.search}`;
4188
4297
  const headers: Record<string, string> = {};
4189
4298
  const relayToken = process.env.AGENT_RELAY_TOKEN;
4190
- if (relayToken) headers["X-Agent-Relay-Token"] = relayToken;
4299
+ if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
4191
4300
  try {
4192
4301
  const res = await fetch(proxyUrl, { headers, signal: AbortSignal.timeout(10_000) });
4193
4302
  const contentType = res.headers.get("content-type") ?? "";
@@ -4212,7 +4321,7 @@ async function proxyOrchestratorPost(req: Request, orchestratorId: string, path:
4212
4321
  "Content-Type": req.headers.get("content-type") ?? "application/json",
4213
4322
  };
4214
4323
  const relayToken = process.env.AGENT_RELAY_TOKEN;
4215
- if (relayToken) headers["X-Agent-Relay-Token"] = relayToken;
4324
+ if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
4216
4325
  try {
4217
4326
  const body = await req.text();
4218
4327
  const res = await fetch(proxyUrl, {
@@ -4237,7 +4346,7 @@ async function proxyOrchestratorDelete(req: Request, orchestratorId: string, pat
4237
4346
  const proxyUrl = `${orch.apiUrl}${path}${incoming.search}`;
4238
4347
  const headers: Record<string, string> = {};
4239
4348
  const relayToken = process.env.AGENT_RELAY_TOKEN;
4240
- if (relayToken) headers["X-Agent-Relay-Token"] = relayToken;
4349
+ if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
4241
4350
  try {
4242
4351
  const res = await fetch(proxyUrl, {
4243
4352
  method: "DELETE",
@@ -4258,7 +4367,7 @@ async function proxyOrchestratorJson(orchestratorId: string, path: string, metho
4258
4367
  if (orch.status !== "online") return error("orchestrator is offline", 422);
4259
4368
  const headers: Record<string, string> = { "Content-Type": "application/json" };
4260
4369
  const relayToken = process.env.AGENT_RELAY_TOKEN;
4261
- if (relayToken) headers["X-Agent-Relay-Token"] = relayToken;
4370
+ if (relayToken) headers[RELAY_TOKEN_HEADER] = relayToken;
4262
4371
  try {
4263
4372
  const res = await fetch(`${orch.apiUrl}${path}`, {
4264
4373
  method,
@@ -4286,8 +4395,8 @@ const getOrchestratorProviders: Handler = async (req, params) => {
4286
4395
  const providers = cleanStringArray(payload.providers, "providers") as SpawnProvider[] | undefined;
4287
4396
  if (providers) {
4288
4397
  for (const p of providers) {
4289
- if (!VALID_ORCHESTRATOR_PROVIDERS.includes(p as any)) {
4290
- throw new ValidationError(`invalid provider: ${p}. Must be one of: ${VALID_ORCHESTRATOR_PROVIDERS.join(", ")}`);
4398
+ if (!SPAWN_PROVIDERS.includes(p as any)) {
4399
+ throw new ValidationError(`invalid provider: ${p}. Must be one of: ${SPAWN_PROVIDERS.join(", ")}`);
4291
4400
  }
4292
4401
  }
4293
4402
  }
@@ -4472,6 +4581,13 @@ const patchCommand: Handler = async (req, params) => {
4472
4581
  mergeCommandId: command.id,
4473
4582
  mergedAt: Date.now(),
4474
4583
  });
4584
+ // Land-and-continue (#206): the worktree was recycled onto a fresh branch.
4585
+ // Repoint the row so the next merge targets the live branch, not the deleted one.
4586
+ const newBranch = cleanString(command.result.newBranch, "result.newBranch", { max: 240 });
4587
+ if (newBranch) {
4588
+ const mergedSha = cleanString(command.result.mergedSha, "result.mergedSha", { max: 64 });
4589
+ setWorkspaceBranch(workspaceId, newBranch, mergedSha);
4590
+ }
4475
4591
  }
4476
4592
  } else if (command.status === "failed" && command.correlationId) {
4477
4593
  // Merge couldn't complete — don't leave it stuck in merge_planned.
@@ -4599,6 +4715,25 @@ const putStewardConfigRoute: Handler = async (req) => {
4599
4715
  }
4600
4716
  };
4601
4717
 
4718
+ const getWorkspaceConfigRoute: Handler = () => json(getWorkspaceConfigEntry());
4719
+
4720
+ const putWorkspaceConfigRoute: Handler = async (req) => {
4721
+ const parsed = await parseBody<unknown>(req);
4722
+ if (!parsed.ok) return error(parsed.error, parsed.status);
4723
+ try {
4724
+ const value = isRecord(parsed.body) && Object.prototype.hasOwnProperty.call(parsed.body, "value")
4725
+ ? parsed.body.value
4726
+ : parsed.body;
4727
+ const updatedBy = isRecord(parsed.body) ? cleanString(parsed.body.updatedBy, "updatedBy", { max: 200 }) : undefined;
4728
+ const entry = setWorkspaceConfig(value, updatedBy);
4729
+ emitConfigChanged(entry.namespace, entry.key, entry.version);
4730
+ return json(entry, entry.version === 1 ? 201 : 200);
4731
+ } catch (e) {
4732
+ if (e instanceof ValidationError) return error(e.message, 400);
4733
+ throw e;
4734
+ }
4735
+ };
4736
+
4602
4737
  // --- Insights / self-improvement (epic #183, docs/self-improvement.md) ---
4603
4738
 
4604
4739
  const getInsightsConfigRoute: Handler = () => json(getInsightsConfigEntry());
@@ -4635,6 +4770,14 @@ const getInsightsObservationsRoute: Handler = (req) => {
4635
4770
  });
4636
4771
  };
4637
4772
 
4773
+ // Accept a backfilled event time only when it's a sane epoch-ms value within a minute of
4774
+ // now (clock-skew guard); otherwise let recordObservation default to receive time.
4775
+ function sanitizeObservationOccurredAt(occurredAt: unknown): number | undefined {
4776
+ if (typeof occurredAt !== "number" || !Number.isFinite(occurredAt)) return undefined;
4777
+ if (occurredAt <= 0 || occurredAt > Date.now() + 60_000) return undefined;
4778
+ return Math.floor(occurredAt);
4779
+ }
4780
+
4638
4781
  const postInsightsObservationRoute: Handler = async (req) => {
4639
4782
  const parsed = await parseBody<unknown>(req);
4640
4783
  if (!parsed.ok) return error(parsed.error, parsed.status);
@@ -4656,6 +4799,10 @@ const postInsightsObservationRoute: Handler = async (req) => {
4656
4799
  value: isRecord(body.value) ? body.value : {},
4657
4800
  outcome: isRecord(body.outcome) ? body.outcome : undefined,
4658
4801
  source: body.source === "server" ? "server" : "agent",
4802
+ // For insights the event time IS the record time (end-of-session). When the Runner
4803
+ // backfilled this through its durable outbox (#196), occurredAt preserves the real
4804
+ // moment rather than the later server-receive time.
4805
+ createdAt: sanitizeObservationOccurredAt(body.occurredAt),
4659
4806
  });
4660
4807
  return json(observation, 201);
4661
4808
  } catch (e) {
@@ -4732,7 +4879,7 @@ const getConfigKeyHistory: Handler = (req, params) => {
4732
4879
  // --- Spawn policy routes ---
4733
4880
 
4734
4881
  function spawnRequestId(): string {
4735
- return `sp_${crypto.randomUUID()}`;
4882
+ return generateSpawnRequestId();
4736
4883
  }
4737
4884
 
4738
4885
  function policyStatusPayload(policy: SpawnPolicy) {
@@ -6530,9 +6677,11 @@ const routes: Route[] = [
6530
6677
  route("GET", "/api/workspaces/orphans", getWorkspaceOrphans),
6531
6678
  route("POST", "/api/workspaces/orphans/reclaim", postWorkspaceOrphanReclaim),
6532
6679
  route("GET", "/api/workspaces/stewards", getWorkspaceStewards),
6680
+ route("POST", "/api/workspaces/actions/cleanup-stale", postWorkspaceCleanupStale),
6533
6681
  route("GET", "/api/workspaces/:id", getWorkspaceById),
6534
6682
  route("GET", "/api/workspaces/:id/git-state", getWorkspaceGitState),
6535
6683
  route("GET", "/api/workspaces/:id/merge-preview", getWorkspaceMergePreview),
6684
+ route("GET", "/api/workspaces/:id/diagnostics", getWorkspaceDiagnostics),
6536
6685
  route("GET", "/api/workspaces/:id/diff", getWorkspaceDiff),
6537
6686
  route("POST", "/api/workspaces/:id/actions", postWorkspaceAction),
6538
6687
  route("DELETE", "/api/workspaces/:id", deleteWorkspaceById),
@@ -6569,6 +6718,8 @@ const routes: Route[] = [
6569
6718
  route("DELETE", "/api/agent-profiles/:name", deleteAgentProfileRoute),
6570
6719
  route("GET", "/api/steward-config", getStewardConfigRoute),
6571
6720
  route("PUT", "/api/steward-config", putStewardConfigRoute),
6721
+ route("GET", "/api/workspace-config", getWorkspaceConfigRoute),
6722
+ route("PUT", "/api/workspace-config", putWorkspaceConfigRoute),
6572
6723
  route("GET", "/api/insights/config", getInsightsConfigRoute),
6573
6724
  route("PUT", "/api/insights/config", putInsightsConfigRoute),
6574
6725
  route("GET", "/api/insights/observations", getInsightsObservationsRoute),