agent-relay-server 0.91.3 → 0.92.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.
Files changed (86) hide show
  1. package/docs/openapi.json +1 -1
  2. package/package.json +2 -2
  3. package/public/assets/MessageBubble-CUddRP1E.js +3 -0
  4. package/public/assets/{MessageBubble-Cl3gC5sO.js.map → MessageBubble-CUddRP1E.js.map} +1 -1
  5. package/public/assets/{PlanGraphPanel-BAu-lTm9.js → PlanGraphPanel-CpIBMvnu.js} +2 -2
  6. package/public/assets/{PlanGraphPanel-BAu-lTm9.js.map → PlanGraphPanel-CpIBMvnu.js.map} +1 -1
  7. package/public/assets/{activity-navjipA_.js → activity-B6WPWNsX.js} +2 -2
  8. package/public/assets/{activity-navjipA_.js.map → activity-B6WPWNsX.js.map} +1 -1
  9. package/public/assets/{agents-B09rJRik.js → agents-BebF-yQr.js} +2 -2
  10. package/public/assets/{agents-B09rJRik.js.map → agents-BebF-yQr.js.map} +1 -1
  11. package/public/assets/{automation-BsXT9mKj.js → automation-CyPXC1A8.js} +2 -2
  12. package/public/assets/{automation-BsXT9mKj.js.map → automation-CyPXC1A8.js.map} +1 -1
  13. package/public/assets/chat-DfqC3kFF.js +2 -0
  14. package/public/assets/{chat-Cmv-YVII.js.map → chat-DfqC3kFF.js.map} +1 -1
  15. package/public/assets/display-CWMHll1J.js.map +1 -1
  16. package/public/assets/{formatted-body-impl-CwziXint.js → formatted-body-impl-CrE-hFvy.js} +2 -2
  17. package/public/assets/{formatted-body-impl-CwziXint.js.map → formatted-body-impl-CrE-hFvy.js.map} +1 -1
  18. package/public/assets/{index-Dm1zlklN.js → index-CdDX5NR1.js} +5 -5
  19. package/public/assets/{index-Dm1zlklN.js.map → index-CdDX5NR1.js.map} +1 -1
  20. package/public/assets/index-umexz3cs.css +2 -0
  21. package/public/assets/{insights-CGH0lI9i.js → insights-BribQHXe.js} +2 -2
  22. package/public/assets/{insights-CGH0lI9i.js.map → insights-BribQHXe.js.map} +1 -1
  23. package/public/assets/{maintenance-BhpfPv6u.js → maintenance-Cjol3fS_.js} +2 -2
  24. package/public/assets/{maintenance-BhpfPv6u.js.map → maintenance-Cjol3fS_.js.map} +1 -1
  25. package/public/assets/{managed-agents-CwjcuvR6.js → managed-agents-1lZwQNHl.js} +2 -2
  26. package/public/assets/{managed-agents-CwjcuvR6.js.map → managed-agents-1lZwQNHl.js.map} +1 -1
  27. package/public/assets/{markdown-preview-impl-KBRWvxHt.js → markdown-preview-impl-C2WeUNFR.js} +2 -2
  28. package/public/assets/{markdown-preview-impl-KBRWvxHt.js.map → markdown-preview-impl-C2WeUNFR.js.map} +1 -1
  29. package/public/assets/{memory-CgpkkEKK.js → memory-B7mcELEW.js} +2 -2
  30. package/public/assets/{memory-CgpkkEKK.js.map → memory-B7mcELEW.js.map} +1 -1
  31. package/public/assets/{messages-BV1z5tMO.js → messages-B8jF9p2l.js} +2 -2
  32. package/public/assets/{messages-BV1z5tMO.js.map → messages-B8jF9p2l.js.map} +1 -1
  33. package/public/assets/{orchestrators-Bv6T_n-l.js → orchestrators-CQjvBXDx.js} +2 -2
  34. package/public/assets/{orchestrators-Bv6T_n-l.js.map → orchestrators-CQjvBXDx.js.map} +1 -1
  35. package/public/assets/{overview-BdErDjo5.js → overview-CgDUJOG-.js} +2 -2
  36. package/public/assets/{overview-BdErDjo5.js.map → overview-CgDUJOG-.js.map} +1 -1
  37. package/public/assets/{pairs-BpzbJ5OL.js → pairs-BdPKQyzE.js} +2 -2
  38. package/public/assets/{pairs-BpzbJ5OL.js.map → pairs-BdPKQyzE.js.map} +1 -1
  39. package/public/assets/{security-CRsIxNpi.js → security-BlZQ0ECI.js} +2 -2
  40. package/public/assets/{security-CRsIxNpi.js.map → security-BlZQ0ECI.js.map} +1 -1
  41. package/public/assets/{select-DmZ1eEzM.js → select-CjpI0JtN.js} +2 -2
  42. package/public/assets/{select-DmZ1eEzM.js.map → select-CjpI0JtN.js.map} +1 -1
  43. package/public/assets/{settings-BqmYUpOu.js → settings-CMgqpUY-.js} +2 -2
  44. package/public/assets/{settings-BqmYUpOu.js.map → settings-CMgqpUY-.js.map} +1 -1
  45. package/public/assets/{tasks-DZ8C8K2N.js → tasks-BcHWptn4.js} +2 -2
  46. package/public/assets/{tasks-DZ8C8K2N.js.map → tasks-BcHWptn4.js.map} +1 -1
  47. package/public/assets/{teams-VmX_6z_9.js → teams-BGL-VpqZ.js} +2 -2
  48. package/public/assets/{teams-VmX_6z_9.js.map → teams-BGL-VpqZ.js.map} +1 -1
  49. package/public/assets/{terminal-viewer-impl-BJilWDSR.js → terminal-viewer-impl-D8bnx7Z7.js} +2 -2
  50. package/public/assets/{terminal-viewer-impl-BJilWDSR.js.map → terminal-viewer-impl-D8bnx7Z7.js.map} +1 -1
  51. package/public/assets/{user-avatar-jqI4eKDf.js → user-avatar-DZ1g4X7l.js} +2 -2
  52. package/public/assets/{user-avatar-jqI4eKDf.js.map → user-avatar-DZ1g4X7l.js.map} +1 -1
  53. package/public/assets/{work-queue-lFDBNpds.js → work-queue-CIy6fe6y.js} +2 -2
  54. package/public/assets/{work-queue-lFDBNpds.js.map → work-queue-CIy6fe6y.js.map} +1 -1
  55. package/public/index.html +2 -2
  56. package/src/automations/dispatch.ts +3 -1
  57. package/src/bus.ts +9 -2
  58. package/src/config-store.ts +24 -0
  59. package/src/config.ts +1 -0
  60. package/src/db/agent-predicates.ts +21 -0
  61. package/src/db/agents.ts +3 -3
  62. package/src/db/delivery.ts +38 -0
  63. package/src/db/messages.ts +18 -1
  64. package/src/db/workspaces.ts +44 -2
  65. package/src/maintenance/constants.ts +2 -0
  66. package/src/maintenance/jobs.ts +21 -8
  67. package/src/maintenance/workspaces.ts +76 -0
  68. package/src/managed-policy.ts +3 -1
  69. package/src/mcp/tools-spawn.ts +43 -3
  70. package/src/project-settings.ts +27 -0
  71. package/src/project-skills.ts +27 -0
  72. package/src/routes/agent-sessions.ts +3 -1
  73. package/src/routes/agents-spawn.ts +19 -2
  74. package/src/routes/agents.ts +17 -2
  75. package/src/routes/commands.ts +5 -0
  76. package/src/services/managed-running.ts +18 -1
  77. package/src/services/rate-limit-resume.ts +3 -0
  78. package/src/services/register-agent.ts +7 -1
  79. package/src/services/spawn-agent.ts +75 -11
  80. package/src/shared-mcp.ts +46 -17
  81. package/src/spawn-command.ts +10 -0
  82. package/src/validation.ts +7 -2
  83. package/src/workspace-auto-merge.ts +53 -9
  84. package/public/assets/MessageBubble-Cl3gC5sO.js +0 -3
  85. package/public/assets/chat-Cmv-YVII.js +0 -2
  86. package/public/assets/index-F-FSPv4X.css +0 -2
@@ -11,6 +11,7 @@ import {
11
11
  reelectRepoSteward,
12
12
  releaseExpiredMergeLeases,
13
13
  updateWorkspaceStatus,
14
+ setWorkspaceBranch,
14
15
  } from "../db";
15
16
  import type { WorkspaceMergePreview, WorkspaceStatus } from "../types";
16
17
  import { notifyStewardHandoff, reconcileLandedWorkspace } from "../branch-landed";
@@ -262,3 +263,78 @@ export async function workspaceGC(): Promise<Record<string, unknown>> {
262
263
  escalationTargets,
263
264
  };
264
265
  }
266
+
267
+ /**
268
+ * Proactively refresh idle branch-agent workspaces against origin/main (#478).
269
+ *
270
+ * For each `active` isolated workspace whose owner agent is NOT busy and whose
271
+ * stored probe data indicates no WIP (dirty=0, ahead=0), dispatch a
272
+ * `workspace.idle-refresh` command to the owning orchestrator. The orchestrator
273
+ * re-verifies liveness, fetches origin, and — when the worktree is cleanly
274
+ * behind — performs a `checkout -B <fresh> origin/main` so the agent is already
275
+ * current when it next wakes. Workspaces with WIP, commits ahead, or busy owners
276
+ * are skipped silently.
277
+ */
278
+ export async function refreshIdleWorkspaces(): Promise<Record<string, unknown>> {
279
+ const orchestrators = listOrchestrators().filter((orch) => orch.status === "online" && orch.agentId);
280
+ if (!orchestrators.length) return { dispatched: 0, skipped: 0, reason: "no online orchestrators" };
281
+
282
+ const candidates = listWorkspaces().filter(
283
+ (ws) => ws.mode === "isolated" && ws.status === "active" && Boolean(ws.worktreePath),
284
+ );
285
+
286
+ const dispatched: string[] = [];
287
+ const skipped: string[] = [];
288
+ const now = Date.now();
289
+
290
+ for (const ws of candidates) {
291
+ const orch = orchestrators.find((candidate) => workspacePathWithinBase(ws.sourceCwd, candidate.baseDir));
292
+ if (!orch?.agentId) { skipped.push(`${ws.id}:no-orchestrator`); continue; }
293
+
294
+ // Gate: owner must exist and not be busy.
295
+ const owner = ws.ownerAgentId ? getAgent(ws.ownerAgentId) : null;
296
+ if (!owner || owner.status === "busy") { skipped.push(`${ws.id}:owner-busy`); continue; }
297
+
298
+ // Gate: skip if stored metadata shows WIP or commits ahead.
299
+ // The orchestrator re-checks live before mutating; stored data is a fast pre-filter.
300
+ const meta = ws.metadata as Record<string, unknown>;
301
+ const gitAhead = typeof meta.gitAhead === "number" ? meta.gitAhead : undefined;
302
+ const gitDirtyCount = typeof meta.gitDirtyCount === "number" ? meta.gitDirtyCount : undefined;
303
+ if (gitAhead !== undefined && gitAhead > 0) { skipped.push(`${ws.id}:ahead`); continue; }
304
+ if (gitDirtyCount !== undefined && gitDirtyCount > 0) { skipped.push(`${ws.id}:dirty`); continue; }
305
+
306
+ const command = createCommand({
307
+ type: "workspace.idle-refresh",
308
+ source: "system",
309
+ target: orch.agentId,
310
+ correlationId: ws.id,
311
+ params: {
312
+ workspaceId: ws.id,
313
+ repoRoot: ws.repoRoot,
314
+ worktreePath: ws.worktreePath,
315
+ branch: ws.branch,
316
+ baseRef: ws.baseRef,
317
+ baseSha: ws.baseSha,
318
+ requestedBy: "workspace-idle-refresh",
319
+ requestedAt: now,
320
+ },
321
+ });
322
+ emitCommand(command);
323
+ dispatched.push(ws.id);
324
+ }
325
+
326
+ return { dispatched, skipped };
327
+ }
328
+
329
+ /**
330
+ * Apply the result of a settled `workspace.idle-refresh` command.
331
+ * When the orchestrator refreshed the worktree, repoint the DB row to the
332
+ * new branch and update base_sha so the next merge targets the live branch.
333
+ */
334
+ export function applyIdleRefreshResult(workspaceId: string, result: Record<string, unknown>): boolean {
335
+ const newBranch = typeof result.newBranch === "string" ? result.newBranch : undefined;
336
+ const baseSha = typeof result.baseSha === "string" ? result.baseSha : undefined;
337
+ if (!newBranch || result.refreshed !== true) return false;
338
+ setWorkspaceBranch(workspaceId, newBranch, baseSha);
339
+ return true;
340
+ }
@@ -2,6 +2,7 @@ import { getAgentProfile } from "./config-store";
2
2
  import { withResolvedProvisioning } from "./db";
3
3
  import { runnerRuntimeTokenEnv } from "./runtime-tokens";
4
4
  import { buildSpawnCommand, resolveSpawnModelParams } from "./spawn-command";
5
+ import { applyDefaultSharedMcp } from "./shared-mcp";
5
6
  import type { SpawnPolicy, WorkspaceMode } from "./types";
6
7
 
7
8
  export function managedPolicyProviderArgs(policy: SpawnPolicy): string[] {
@@ -29,7 +30,8 @@ interface ManagedSpawnContext {
29
30
  export function buildManagedSpawnParams(policy: SpawnPolicy, requestId: string, ctx: ManagedSpawnContext): Record<string, unknown> {
30
31
  const providerArgs = managedPolicyProviderArgs(policy);
31
32
  const prompt = managedPolicyLaunchPrompt(policy);
32
- const agentProfile = withResolvedProvisioning(policy.profile ? getAgentProfile(policy.profile)?.value : undefined, policy.provider);
33
+ const resolvedProfile = withResolvedProvisioning(policy.profile ? getAgentProfile(policy.profile)?.value : undefined, policy.provider);
34
+ const agentProfile = resolvedProfile ? applyDefaultSharedMcp(resolvedProfile) : undefined;
33
35
  return buildSpawnCommand({
34
36
  provider: policy.provider,
35
37
  orchestratorId: policy.orchestratorId,
@@ -5,18 +5,22 @@ import { buildSpawnTargets } from "../spawn-targets";
5
5
  import { McpAuthError, McpNotFoundError } from "../mcp-errors";
6
6
  import { parseProjectInstructions, PROJECT_INSTRUCTION_POLICY_NAMES } from "../project-instructions";
7
7
  import { parseProjectMcp, PROJECT_MCP_POLICY_NAMES } from "../project-mcp";
8
+ import { parseProjectSettings, PROJECT_SETTINGS_POLICY_NAMES } from "../project-settings";
9
+ import { parseProjectSkills, PROJECT_SKILLS_POLICY_NAMES } from "../project-skills";
8
10
  import { parseSharedMcp, SHARED_MCP_POLICY_NAMES } from "../shared-mcp";
9
11
  import { countLiveSpawnedAgents, listAgents, listWorkspaces, ValidationError } from "../db";
10
12
  import { listCommands } from "../commands-db";
11
13
  import { DEFAULT_SPAWN_PROFILE_NAME } from "../config-store";
12
14
  import { optionalEnum } from "../validation";
13
- import { APPROVAL_MODES, SPAWN_PROVIDERS, stringValue, VALID_AGENT_LIFECYCLES, VALID_EFFORTS, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
15
+ import { APPROVAL_MODES, AUTO_MERGE_POLICIES, SPAWN_PROVIDERS, stringValue, VALID_AGENT_LIFECYCLES, VALID_EFFORTS, VALID_WORKSPACE_MODES } from "agent-relay-sdk";
14
16
  import type { ProviderEffort } from "agent-relay-sdk/provider-catalog";
15
- import type { AgentCard, AgentLifecycle, Command, SpawnApprovalMode, SpawnProvider, WorkspaceMode, WorkspaceRecord } from "../types";
17
+ import type { AgentCard, AgentLifecycle, Command, SpawnApprovalMode, SpawnProvider, WorkspaceAutoMergePolicy, WorkspaceLandingPolicy, WorkspaceMode, WorkspaceRecord } from "../types";
16
18
  import type { McpAuthContext, ToolDefinition } from "./types";
17
19
  import { callerAgentId } from "./identity";
18
20
  import { enumField, optionalBoolean, optionalNonNegativeInt, optionalPositiveInt, optionalString, optionalStringArray } from "./validation";
19
21
 
22
+ const SPAWN_LAND_STRATEGIES = ["direct", "pr"] as const;
23
+
20
24
  export const SPAWN_TOOLS: ToolDefinition[] = [
21
25
  {
22
26
  name: "relay_spawn_agent",
@@ -37,7 +41,18 @@ export const SPAWN_TOOLS: ToolDefinition[] = [
37
41
  systemPromptAppend: { type: "string" },
38
42
  projectInstructions: { type: "object", description: "#409 — project instruction projection (default OFF). When set, relay composes a knowledge preamble from the spawn cwd's resolved project (own + inherited entries) and prepends it to systemPromptAppend, instead of relying on host CLAUDE.md. `policy: \"all\"` includes ingested host content; `\"vanilla\"` excludes it (manual+promoted only). Omit to keep current behavior.", properties: { policy: { type: "string", enum: PROJECT_INSTRUCTION_POLICY_NAMES } }, additionalProperties: false },
39
43
  projectMcp: { type: "object", description: "#667 — project `.mcp.json` MCP projection (default OFF). When set, the worker gets the MCP servers committed in the spawn cwd's `.mcp.json` (e.g. an app's playtest/manuscript servers), injected by relay through the controlled `--mcp-config` path that survives `--strict-mcp-config` — sourced from the project, not ambient host MCP, and without triggering Claude's project-trust modal. `policy: \"all\"` (the default) injects every declared server. Omit to keep current host-only behavior.", properties: { policy: { type: "string", enum: PROJECT_MCP_POLICY_NAMES } }, additionalProperties: false },
40
- sharedMcp: { type: "object", description: "#672shared host-listener MCP (default OFF). When set, the worker's NON-IDENTITY MCP (tokenlean, github) is routed through one orchestrator-owned shared `callmux` listener via a per-agent `callmux bridge --cwd \"$WORKTREE\"`, so the MCP session survives a listener/downstream restart and each isolated worktree keeps session-cwd isolation. Rides the controlled `--mcp-config` path (composes with `--strict-mcp-config`). The identity-bearing relay endpoint stays per-agent (#215), never on the shared listener. `policy: \"all\"` (the default) routes the listener's full non-identity set. Omit to keep current per-agent injection.", properties: { policy: { type: "string", enum: SHARED_MCP_POLICY_NAMES } }, additionalProperties: false },
44
+ projectSkills: { type: "object", description: "#668project `.claude/skills` projection for Claude workers (default OFF). When set, the worker gets skill dirs committed under the spawn cwd's `.claude/skills`, materialized into Relay's managed Claude config home. Host/global skills stay excluded. `policy: \"all\"` (the default) includes every valid skill dir. Omit to keep current behavior.", properties: { policy: { type: "string", enum: PROJECT_SKILLS_POLICY_NAMES } }, additionalProperties: false },
45
+ projectSettings: { type: "object", description: "#669 — project `.claude/settings.json` projection for Claude workers (default OFF). When set, Relay reads the spawn cwd's committed settings, strips permission/security keys that could widen the worker, and injects the safe remainder through `--settings`. Relay approvalMode/permission gates stay authoritative. Host/global settings stay excluded. `policy: \"all\"` (the default) applies the settings file. Omit to keep current behavior.", properties: { policy: { type: "string", enum: PROJECT_SETTINGS_POLICY_NAMES } }, additionalProperties: false },
46
+ sharedMcp: {
47
+ anyOf: [
48
+ { type: "boolean" },
49
+ { type: "object", properties: { policy: { type: "string", enum: SHARED_MCP_POLICY_NAMES } }, additionalProperties: false },
50
+ ],
51
+ description: "#689 — shared host-listener MCP. Host-MCP profiles default ON: the worker's NON-IDENTITY MCP (tokenlean, github) is routed through one orchestrator-owned shared `callmux` listener via a per-agent `callmux bridge --cwd \"$WORKTREE\"`, so the MCP session survives listener/downstream restart and keeps isolated worktree cwd routing. Profiles that suppress host MCP stay OFF unless explicitly opted in. Pass `false` as a per-spawn kill-switch to force direct/per-agent MCP injection. The identity-bearing relay endpoint stays per-agent (#215), never on the shared listener.",
52
+ },
53
+ landStrategy: { type: "string", enum: SPAWN_LAND_STRATEGIES, description: "#421 — per-spawn workspace land strategy. Omit to use the instance landing.strategy. `direct` keeps deterministic rebase/fast-forward landing; `pr` opens a PR." },
54
+ autoMerge: { type: "string", enum: AUTO_MERGE_POLICIES, description: "#421/#305 — PR auto-merge policy when landStrategy resolves to `pr`. `on-green` auto-merges after checks, `on-approval` waits for reviewer approval, `manual` opens the PR only." },
55
+ reviewer: { type: "string", description: "#421/#305 — optional reviewer handle or policy target for reviewer-first PR lands, used with landStrategy `pr` and autoMerge `on-approval`." },
41
56
  profile: { type: "string", description: `Agent profile name to apply (env, instructions, permissions, MCP/skills, spawn quota). Omit to use \`${DEFAULT_SPAWN_PROFILE_NAME}\`.` },
42
57
  tags: { type: "array", items: { type: "string" } },
43
58
  capabilities: { type: "array", items: { type: "string" } },
@@ -46,6 +61,16 @@ export const SPAWN_TOOLS: ToolDefinition[] = [
46
61
  spawnRequestId: { type: "string" },
47
62
  lifecycle: { type: "string", enum: VALID_AGENT_LIFECYCLES, description: "Lifecycle mode. `persistent` keeps the agent until explicitly stopped. `transient` lets Relay auto-shutdown the child after it is idle and, for isolated workers, after its branch has landed. Default: persistent." },
48
63
  workspaceMode: { type: "string", enum: VALID_WORKSPACE_MODES, description: "Git workspace for the worker. `isolated` = its own worktree on an `agent/session-*` branch off main that auto-lands when ready (delegate a unit of work, get a branch worker). `shared` = edits your live checkout (a sidecar). `inherit` = let the orchestrator decide. DEFAULT for agent-initiated spawns is `isolated` so a delegated worker lands to main instead of editing your tree; pass `shared`/`inherit` to override." },
64
+ resumeWorkspace: {
65
+ type: "object",
66
+ description: "#635 — attach a new worker to an EXISTING managed worktree/branch instead of always creating a fresh one. `attach`: reuse the worktree as-is (crash recovery — WIP is preserved); `branch-from`: fresh worktree off the existing branch HEAD (phased work, sequential hand-off). Guard: `attach` is rejected when the original owner is still alive. The resumed worker's branch auto-lands normally on completion.",
67
+ properties: {
68
+ branch: { type: "string", description: "The existing agent branch to attach to or branch from (e.g. `agent/build-389/.../...`)." },
69
+ mode: { type: "string", enum: ["attach", "branch-from"], description: "`attach` = reuse the existing worktree directory (WIP preserved). `branch-from` = fresh worktree off the branch HEAD (committed history carried, WIP not)." },
70
+ },
71
+ required: ["branch", "mode"],
72
+ additionalProperties: false,
73
+ },
49
74
  waitForRegistrationMs: { type: "integer", minimum: 0, maximum: 30000, description: "How long to wait for the spawned agent to register before returning, so the response carries its resolved agent id (default 8000; 0 = return immediately with just spawnRequestId)." },
50
75
  },
51
76
  required: ["provider"],
@@ -95,6 +120,15 @@ export const SPAWN_TOOLS: ToolDefinition[] = [
95
120
  },
96
121
  ];
97
122
 
123
+ function parseResumeWorkspace(value: unknown): { branch: string; mode: "attach" | "branch-from" } | undefined {
124
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
125
+ const obj = value as Record<string, unknown>;
126
+ const branch = optionalString(obj.branch, "resumeWorkspace.branch", 500);
127
+ const mode = obj.mode === "attach" || obj.mode === "branch-from" ? obj.mode : undefined;
128
+ if (!branch || !mode) return undefined;
129
+ return { branch, mode };
130
+ }
131
+
98
132
  export async function relaySpawnAgent(auth: McpAuthContext, args: Record<string, unknown>): Promise<Record<string, unknown>> {
99
133
  const input: SpawnAgentInput = {
100
134
  provider: enumField(args.provider, "provider", SPAWN_PROVIDERS) as SpawnProvider,
@@ -113,13 +147,19 @@ export async function relaySpawnAgent(auth: McpAuthContext, args: Record<string,
113
147
  systemPromptAppend: optionalString(args.systemPromptAppend, "systemPromptAppend", 64_000),
114
148
  projectInstructions: parseProjectInstructions(args.projectInstructions, (m) => new ValidationError(m)),
115
149
  projectMcp: parseProjectMcp(args.projectMcp, (m) => new ValidationError(m)),
150
+ projectSkills: parseProjectSkills(args.projectSkills, (m) => new ValidationError(m)),
151
+ projectSettings: parseProjectSettings(args.projectSettings, (m) => new ValidationError(m)),
116
152
  sharedMcp: parseSharedMcp(args.sharedMcp, (m) => new ValidationError(m)),
153
+ landStrategy: optionalEnum(args.landStrategy, "landStrategy", SPAWN_LAND_STRATEGIES) as WorkspaceLandingPolicy | undefined,
154
+ autoMerge: optionalEnum(args.autoMerge, "autoMerge", AUTO_MERGE_POLICIES) as WorkspaceAutoMergePolicy | undefined,
155
+ reviewer: optionalString(args.reviewer, "reviewer", 240),
117
156
  tags: optionalStringArray(args.tags, "tags"),
118
157
  capabilities: optionalStringArray(args.capabilities, "capabilities"),
119
158
  providerArgs: optionalStringArray(args.providerArgs, "providerArgs"),
120
159
  spawnRequestId: optionalString(args.spawnRequestId, "spawnRequestId", 160),
121
160
  requestedVia: "mcp",
122
161
  waitForRegistrationMs: optionalNonNegativeInt(args.waitForRegistrationMs, "waitForRegistrationMs") ?? 8000,
162
+ resumeWorkspace: parseResumeWorkspace(args.resumeWorkspace),
123
163
  };
124
164
  const result = await spawnAgent(input, authContextFromMcp(auth));
125
165
  return { ...result };
@@ -0,0 +1,27 @@
1
+ // Project `.claude/settings.json` projection (#669) — sibling to projectMcp (#667).
2
+ // Relay carries only the opt-in decision; the runner reads/sanitizes the project settings
3
+ // and injects them through the controlled Claude `--settings` path.
4
+
5
+ export const PROJECT_SETTINGS_POLICY_NAMES = ["all"] as const;
6
+ export type ProjectSettingsPolicyName = (typeof PROJECT_SETTINGS_POLICY_NAMES)[number];
7
+
8
+ export interface ProjectSettingsPolicy {
9
+ policy?: ProjectSettingsPolicyName;
10
+ }
11
+
12
+ export function parseProjectSettings(
13
+ value: unknown,
14
+ onInvalid: (message: string) => Error,
15
+ ): ProjectSettingsPolicy | undefined {
16
+ if (value === undefined || value === null || value === false) return undefined;
17
+ if (value === true) return {};
18
+ if (typeof value !== "object") {
19
+ throw onInvalid("projectSettings must be a boolean or { policy } object");
20
+ }
21
+ const policy = (value as Record<string, unknown>).policy;
22
+ if (policy === undefined || policy === null) return {};
23
+ if (typeof policy !== "string" || !PROJECT_SETTINGS_POLICY_NAMES.includes(policy as ProjectSettingsPolicyName)) {
24
+ throw onInvalid(`projectSettings.policy must be one of: ${PROJECT_SETTINGS_POLICY_NAMES.join(", ")}`);
25
+ }
26
+ return { policy: policy as ProjectSettingsPolicyName };
27
+ }
@@ -0,0 +1,27 @@
1
+ // Project `.claude/skills` projection (#668) — sibling to projectMcp (#667).
2
+ // Relay carries only the opt-in decision; the runner reads the spawn cwd and materializes
3
+ // project-committed skills into the managed Claude config home.
4
+
5
+ export const PROJECT_SKILLS_POLICY_NAMES = ["all"] as const;
6
+ export type ProjectSkillsPolicyName = (typeof PROJECT_SKILLS_POLICY_NAMES)[number];
7
+
8
+ export interface ProjectSkillsPolicy {
9
+ policy?: ProjectSkillsPolicyName;
10
+ }
11
+
12
+ export function parseProjectSkills(
13
+ value: unknown,
14
+ onInvalid: (message: string) => Error,
15
+ ): ProjectSkillsPolicy | undefined {
16
+ if (value === undefined || value === null || value === false) return undefined;
17
+ if (value === true) return {};
18
+ if (typeof value !== "object") {
19
+ throw onInvalid("projectSkills must be a boolean or { policy } object");
20
+ }
21
+ const policy = (value as Record<string, unknown>).policy;
22
+ if (policy === undefined || policy === null) return {};
23
+ if (typeof policy !== "string" || !PROJECT_SKILLS_POLICY_NAMES.includes(policy as ProjectSkillsPolicyName)) {
24
+ throw onInvalid(`projectSkills.policy must be one of: ${PROJECT_SKILLS_POLICY_NAMES.join(", ")}`);
25
+ }
26
+ return { policy: policy as ProjectSkillsPolicyName };
27
+ }
@@ -6,6 +6,7 @@ import { ValidationError, getAgent, getAgentTimeline, getOrchestrator, markReady
6
6
  import { announcePresence, viewersForAgent } from "../dashboard-presence";
7
7
  import { buildManagedSpawnParams } from "../managed-policy";
8
8
  import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams } from "../spawn-command";
9
+ import { applyDefaultSharedMcp } from "../shared-mcp";
9
10
  import { cleanString, optionalEnum } from "../validation";
10
11
  import { createCommand } from "../commands-db";
11
12
  import { emitAgentStatus, emitDashboardPresence, emitNewMessage } from "../sse";
@@ -119,7 +120,8 @@ function restartSpawnParamsForAgent(
119
120
  const effort = metaString(agent.meta, "effort") as ProviderEffort | undefined;
120
121
  const providerArgs = metaStringArray(agent.meta, "providerArgs");
121
122
  const profileName = metaString(agent.meta, "profile");
122
- const agentProfile = withResolvedProvisioning(profileName ? getAgentProfile(profileName)?.value : undefined, provider);
123
+ const resolvedProfile = withResolvedProvisioning(profileName ? getAgentProfile(profileName)?.value : undefined, provider);
124
+ const agentProfile = resolvedProfile ? applyDefaultSharedMcp(resolvedProfile) : undefined;
123
125
  const workspaceMode = metaString(agent.meta, "workspaceMode") ?? "inherit";
124
126
  const resolvedModel = resolveSpawnModelParams(provider, model, effort, { onError: "passthrough", skipDefaultWhenEmpty: true });
125
127
  const params = buildSpawnCommand({
@@ -1,5 +1,5 @@
1
1
  // Auto-split from routes.ts (#299). Domain: agents-spawn.
2
- import { APPROVAL_MODES, SPAWN_PROVIDERS, VALID_WORKSPACE_MODES, isRecord } from "agent-relay-sdk";
2
+ import { APPROVAL_MODES, AUTO_MERGE_POLICIES, SPAWN_PROVIDERS, VALID_WORKSPACE_MODES, isRecord } from "agent-relay-sdk";
3
3
  import { McpAuthError, McpNotFoundError } from "../mcp-errors";
4
4
  import { VALID_AGENT_STATUSES, error, json, parseBody, type Handler } from "./_shared";
5
5
  import { ValidationError, listAgents } from "../db";
@@ -8,15 +8,26 @@ import { registerAgent } from "../services/register-agent";
8
8
  import { spawnAgent, type SpawnAgentInput } from "../services/spawn-agent";
9
9
  import { parseProjectInstructions } from "../project-instructions";
10
10
  import { parseProjectMcp } from "../project-mcp";
11
+ import { parseProjectSettings } from "../project-settings";
12
+ import { parseProjectSkills } from "../project-skills";
11
13
  import { parseSharedMcp } from "../shared-mcp";
12
14
  import { authContextFromRequest } from "../services/auth-context";
13
15
  import { listHostDirectories } from "../agent-spawn";
14
16
  import { rankRouteCandidates, type RouteAdvisorInput } from "../context-router";
15
17
  import { quotaStateFromUnknown } from "../quota";
16
- import { type AgentKind, type RegisterAgentInput, type SpawnApprovalMode, type SpawnProvider, type WorkspaceMode } from "../types";
18
+ import { type AgentKind, type RegisterAgentInput, type SpawnApprovalMode, type SpawnProvider, type WorkspaceAutoMergePolicy, type WorkspaceLandingPolicy, type WorkspaceMode } from "../types";
17
19
  import { type ProviderEffort } from "agent-relay-sdk/provider-catalog";
18
20
 
19
21
  const VALID_AGENT_KINDS = ["provider", "channel", "orchestrator", "system", "user"] as const;
22
+ const SPAWN_LAND_STRATEGIES = ["direct", "pr"] as const;
23
+
24
+ function parseResumeWorkspaceHttp(value: unknown): { branch: string; mode: "attach" | "branch-from" } | undefined {
25
+ if (!value || !isRecord(value)) return undefined;
26
+ const branch = cleanString(value.branch, "resumeWorkspace.branch", { max: 500 });
27
+ const mode = value.mode === "attach" || value.mode === "branch-from" ? value.mode : undefined;
28
+ if (!branch || !mode) return undefined;
29
+ return { branch, mode };
30
+ }
20
31
 
21
32
  function normalizeAgentInput(body: unknown): RegisterAgentInput {
22
33
  if (!isRecord(body)) throw new ValidationError("JSON object body required");
@@ -124,13 +135,19 @@ export const postAgentSpawn: Handler = async (req) => {
124
135
  systemPromptAppend: cleanString(parsed.body.systemPromptAppend, "systemPromptAppend", { max: 64_000 }),
125
136
  projectInstructions: parseProjectInstructions(parsed.body.projectInstructions, (m) => new ValidationError(m)),
126
137
  projectMcp: parseProjectMcp(parsed.body.projectMcp, (m) => new ValidationError(m)),
138
+ projectSkills: parseProjectSkills(parsed.body.projectSkills, (m) => new ValidationError(m)),
139
+ projectSettings: parseProjectSettings(parsed.body.projectSettings, (m) => new ValidationError(m)),
127
140
  sharedMcp: parseSharedMcp(parsed.body.sharedMcp, (m) => new ValidationError(m)),
141
+ landStrategy: optionalEnum(parsed.body.landStrategy, "landStrategy", SPAWN_LAND_STRATEGIES) as WorkspaceLandingPolicy | undefined,
142
+ autoMerge: optionalEnum(parsed.body.autoMerge, "autoMerge", AUTO_MERGE_POLICIES) as WorkspaceAutoMergePolicy | undefined,
143
+ reviewer: cleanString(parsed.body.reviewer, "reviewer", { max: 240 }),
128
144
  tags: cleanStringArray(parsed.body.tags, "tags", { itemMax: 80, maxItems: 50 }),
129
145
  capabilities: cleanStringArray(parsed.body.capabilities, "capabilities", { itemMax: 80, maxItems: 50 }),
130
146
  providerArgs: cleanStringArray(parsed.body.providerArgs, "providerArgs", { itemMax: 80, maxItems: 50 }),
131
147
  spawnRequestId: cleanString(parsed.body.spawnRequestId, "spawnRequestId", { max: 160 }),
132
148
  requestedVia: "dashboard",
133
149
  requestedBy: "dashboard",
150
+ resumeWorkspace: parseResumeWorkspaceHttp(parsed.body.resumeWorkspace),
134
151
  // HTTP is fire-and-forget (202): the dashboard polls registration separately via the
135
152
  // returned spawnRequestId.
136
153
  waitForRegistrationMs: 0,
@@ -7,6 +7,8 @@ import { cleanStringArray } from "../validation";
7
7
  import { clearActiveMemories, memoryBroker } from "../memory-service";
8
8
  import { emitAgentRemoved, emitAgentStatus } from "../sse";
9
9
  import { notifyAgentReady } from "../agent-lifecycle-events";
10
+ import { flushQueuedDirectMessages } from "../services/managed-running";
11
+ import { isAgentUnavailable } from "../db/agent-predicates";
10
12
  import { getCompactionWatch } from "../compaction-watch";
11
13
  import { isRecord } from "agent-relay-sdk";
12
14
  import { type AgentCard } from "../types";
@@ -210,7 +212,15 @@ export const postHeartbeat: Handler = async (req, params) => {
210
212
  const guard = normalizeAgentSessionGuard(req, parsed.body);
211
213
  const session = validateAgentSession(params.id!, guard);
212
214
  if (!session.ok) return error(session.error!, agentSessionStatus(session.error));
213
- return heartbeat(params.id!, guard) ? json({ ok: true }) : error("agent not found", 404);
215
+ // #416 capture unavailability before heartbeat to detect the transition.
216
+ const beforeHeartbeat = getAgent(params.id!);
217
+ const wasUnavailable = beforeHeartbeat ? isAgentUnavailable(beforeHeartbeat) : false;
218
+ if (!heartbeat(params.id!, guard)) return error("agent not found", 404);
219
+ if (wasUnavailable) {
220
+ const after = getAgent(params.id!);
221
+ if (after && !isAgentUnavailable(after)) flushQueuedDirectMessages(params.id!);
222
+ }
223
+ return json({ ok: true });
214
224
  } catch (e) {
215
225
  if (e instanceof ValidationError) return error(e.message, 400);
216
226
  throw e;
@@ -287,7 +297,12 @@ export const patchAgentReady: Handler = async (req, params) => {
287
297
  // #308 — a spawned child flipping false→true ready is "genuinely ready" (past onboarding):
288
298
  // wake its parent so it can stop block-polling. notifyAgentReady no-ops for non-spawned
289
299
  // agents and dedups repeat readies.
290
- if (body.ready && !before?.ready) notifyAgentReady(params.id!);
300
+ if (body.ready && !before?.ready) {
301
+ notifyAgentReady(params.id!);
302
+ // #416 — also flush queued direct messages: a not-ready agent that becomes ready
303
+ // may have queued messages waiting (e.g. starting up, clearing context).
304
+ flushQueuedDirectMessages(params.id!);
305
+ }
291
306
  } catch (e) {
292
307
  if (e instanceof ValidationError) return error(e.message, 400);
293
308
  throw e;
@@ -13,6 +13,7 @@ import { authContextFromRequest } from "../services/auth-context";
13
13
  import { commandAuthorizationResource as dispatchCommandAuthorizationResource, dispatchCommand } from "../services/dispatch-command";
14
14
  import { ServiceAuthError } from "../services/errors";
15
15
  import { notifyBranchLandFailed, notifyBranchLanded } from "../branch-landed";
16
+ import { applyIdleRefreshResult } from "../maintenance/workspaces";
16
17
  import { seedProviderConfigs } from "../provider-config-store";
17
18
  import { notifyAgentSpawnFailed } from "../agent-lifecycle-events";
18
19
  import { handleReviewerMergeResult } from "../reviewer-pipeline";
@@ -186,6 +187,10 @@ export const patchCommand: Handler = async (req, params) => {
186
187
  });
187
188
  }
188
189
  }
190
+ if (command.type === "workspace.idle-refresh" && command.status === "succeeded" && isRecord(command.result)) {
191
+ const workspaceId = cleanString(command.result.workspaceId, "result.workspaceId", { max: 160 });
192
+ if (workspaceId) applyIdleRefreshResult(workspaceId, command.result);
193
+ }
189
194
  if (command.type === "workspace.merge") {
190
195
  // Merge settled (either way) — free the per-repo merge lease so the next
191
196
  // base merge can proceed (issue #157).
@@ -12,7 +12,7 @@
12
12
  // Composed by `registerAgent` (HTTP + bus registration) and called directly by the
13
13
  // orchestrator-report projection in lifecycle-manager. It does NOT upsert the agent
14
14
  // row — the agent registers itself over HTTP/bus; this only reconciles managed state.
15
- import { createActivityEvent, resolveQueuedPolicyMessages } from "../db";
15
+ import { createActivityEvent, resolveQueuedDirectMessages, resolveQueuedPolicyMessages } from "../db";
16
16
  import { getManagedAgentState, updateManagedAgentState } from "../config-store";
17
17
  import { emitRelayEvent } from "../events";
18
18
  import { emitMessageAvailable, emitMessageDeliveryUpdated, emitNewMessage } from "../sse";
@@ -129,3 +129,20 @@ function recordManagedTransition(
129
129
  },
130
130
  });
131
131
  }
132
+
133
+ /**
134
+ * Flush queued messages addressed directly to `agentId` (#416). Called when a registered agent
135
+ * transitions back to available: heartbeat stale→idle, re-registration after restart/compact, or
136
+ * rate-limit resume. Emits the same `message.available` / `message.new` / `message.delivery_updated`
137
+ * events that the policy flush emits so SSE subscribers and the dashboard react identically.
138
+ * No-op when there are no queued direct messages.
139
+ */
140
+ export function flushQueuedDirectMessages(agentId: string): void {
141
+ const available = resolveQueuedDirectMessages(agentId);
142
+ if (!available.length) return;
143
+ emitMessageAvailable("direct", agentId, available);
144
+ for (const message of available) {
145
+ emitNewMessage(message);
146
+ emitMessageDeliveryUpdated(message);
147
+ }
148
+ }
@@ -1,5 +1,6 @@
1
1
  import { createActivityEvent, listAgents, mergeAgentMeta } from "../db";
2
2
  import { routeNotification } from "../notification-router";
3
+ import { flushQueuedDirectMessages } from "./managed-running";
3
4
  import {
4
5
  TRANSIENT_RATE_LIMIT_BACKOFF_MS,
5
6
  agentProviderState,
@@ -134,6 +135,8 @@ export function resumeRateLimitedAgents(): Record<string, unknown> {
134
135
 
135
136
  if (transient) mergeAgentMeta(agent.id, { providerState: nextTransientState(state as Record<string, unknown>, now, attempts) });
136
137
 
138
+ // #416 — flush messages queued while the agent was rate-limited.
139
+ flushQueuedDirectMessages(agent.id);
137
140
  resumed.push(agent.id);
138
141
  createActivityEvent({
139
142
  clientId: `rate-limit-resume-${agent.id}-${now}`,
@@ -15,7 +15,8 @@ import { emitAgentStatus, emitNewMessage } from "../sse";
15
15
  import { noteAgentTimelineEvent } from "../compaction-watch";
16
16
  import { notifyAgentReady } from "../agent-lifecycle-events";
17
17
  import { resolveSpawnLineage, resolveTeamLineage } from "../security";
18
- import { markManagedAgentRunning } from "./managed-running";
18
+ import { flushQueuedDirectMessages, markManagedAgentRunning } from "./managed-running";
19
+ import { isAgentUnavailable } from "../db/agent-predicates";
19
20
  import type { AuthContext } from "./auth-context";
20
21
  import type { AgentCard, RegisterAgentInput } from "../types";
21
22
 
@@ -71,6 +72,11 @@ export function registerAgent(input: RegisterAgentInput, ctx: AuthContext): Agen
71
72
  // transport. notifyAgentReady no-ops for non-spawned agents and dedups repeats.
72
73
  if (agent.ready && !existing?.ready) notifyAgentReady(agent.id);
73
74
 
75
+ // #416 — flush queued direct messages when a re-registering agent was previously unavailable
76
+ // (stale, compacting, blocked) or a new agent becomes ready. No-op for brand-new agents
77
+ // (no queued messages yet) and when no queued messages exist.
78
+ if (existing && isAgentUnavailable(existing) && agent.ready) flushQueuedDirectMessages(agent.id);
79
+
74
80
  if (!existing) {
75
81
  // #413 — a spawned worker with a report-back contract gets a pending reply obligation to its
76
82
  // spawner so it can't idle with its verdict undelivered (vent #121). First-registration only.
@@ -25,7 +25,7 @@
25
25
  // deliberately out of scope here — this slice converges the two external TRANSPORTS.
26
26
  import type { SpawnProvider } from "agent-relay-sdk";
27
27
  import { errMessage, stringValue } from "agent-relay-sdk";
28
- import { countLiveSpawnedAgents, createActivityEvent, ensureTeam, getAgent, listAgents, resolveProjectForCwd, setAgentTeam, ValidationError, withResolvedProvisioning } from "../db";
28
+ import { countLiveSpawnedAgents, createActivityEvent, ensureTeam, getAgent, listAgents, listWorkspaces, resolveProjectForCwd, setAgentTeam, ValidationError, withResolvedProvisioning } from "../db";
29
29
  import { createCommand } from "../commands-db";
30
30
  import { emitCommandEvent } from "../command-events";
31
31
  import { buildSpawnCommand, generateSpawnRequestId, resolveSpawnModelParams } from "../spawn-command";
@@ -34,13 +34,17 @@ import { selectSpawnOrchestrator } from "../spawn-targets";
34
34
  import { DEFAULT_SPAWN_PROFILE_NAME, getAgentProfile } from "../config-store";
35
35
  import { composeSpawnInstructionsWithEvents, type ProjectInstructionPolicy } from "../project-instructions";
36
36
  import type { ProjectMcpPolicy } from "../project-mcp";
37
- import type { SharedMcpPolicy } from "../shared-mcp";
37
+ import type { ProjectSettingsPolicy } from "../project-settings";
38
+ import type { ProjectSkillsPolicy } from "../project-skills";
39
+ import { applyDefaultSharedMcp, type SharedMcpSpawnOverride } from "../shared-mcp";
38
40
  import { autoSeedProjectForCwd } from "./project";
39
41
  import { hasComponentScope, isComponentAuthorizedFor } from "../security";
40
42
  import { isPathWithinBase } from "../utils";
41
43
  import { McpAuthError, McpNotFoundError } from "../mcp-errors";
42
44
  import type { AuthContext } from "./auth-context";
43
- import type { AgentCard, AgentLifecycle, Command, SpawnApprovalMode, WorkspaceMode } from "../types";
45
+ import type { AgentCard, AgentLifecycle, Command, SpawnApprovalMode, WorkspaceAutoMergePolicy, WorkspaceLandingPolicy, WorkspaceMode } from "../types";
46
+ import { isOwnerAlive, liveAgentCwds, worktreeInUseByLiveAgent } from "../workspace-merge";
47
+ import { TERMINAL_WORKSPACE_STATUSES } from "../workspace-phase";
44
48
 
45
49
  /** The spawn scope every transport must hold (admin `*` / `admin:*` pass; `system` bypasses). */
46
50
  const SPAWN_SCOPE = "command:spawn";
@@ -85,6 +89,8 @@ export interface SpawnAgentInput {
85
89
  * to projectInstructions; carried to the runner on the in-flight `agentProfile.projectMcp`.
86
90
  */
87
91
  projectMcp?: ProjectMcpPolicy;
92
+ projectSkills?: ProjectSkillsPolicy;
93
+ projectSettings?: ProjectSettingsPolicy;
88
94
  /**
89
95
  * #672 — shared host-listener MCP. When set, the runner emits a per-agent `callmux bridge`
90
96
  * stdio entry so the agent's NON-IDENTITY MCP (tokenlean, github) flows through the
@@ -94,7 +100,10 @@ export interface SpawnAgentInput {
94
100
  * today. Sibling to projectMcp; carried to the runner on the in-flight `agentProfile.sharedMcp`.
95
101
  * The identity-bearing relay endpoint stays per-agent (#215), never on the shared listener.
96
102
  */
97
- sharedMcp?: SharedMcpPolicy;
103
+ sharedMcp?: SharedMcpSpawnOverride;
104
+ landStrategy?: WorkspaceLandingPolicy;
105
+ autoMerge?: WorkspaceAutoMergePolicy;
106
+ reviewer?: string;
98
107
  /**
99
108
  * Legacy report-back contract flag. Spawned children now report up by lineage: their
100
109
  * turn-final response is delivered to the spawner automatically, without a reply obligation.
@@ -111,6 +120,23 @@ export interface SpawnAgentInput {
111
120
  requestedBy?: string;
112
121
  /** Poll for the spawned child's registration this long (ms). 0 = fire-and-forget (HTTP). */
113
122
  waitForRegistrationMs?: number;
123
+ /**
124
+ * #635 — attach to or branch off an existing managed worktree. Relay validates existence
125
+ * + live-agent containment before enriching with the resolved worktreePath/workspaceId and
126
+ * threading to the orchestrator. User provides only `branch` + `mode`; relay fills the rest.
127
+ */
128
+ resumeWorkspace?: {
129
+ branch: string;
130
+ mode: "attach" | "branch-from";
131
+ /** Relay-enriched: resolved worktree path from the DB (set during validation, not user input). */
132
+ worktreePath?: string;
133
+ /** Relay-enriched: existing workspace id so the record is updated, not duplicated (attach only). */
134
+ workspaceId?: string;
135
+ /** Relay-enriched: existing baseRef. */
136
+ baseRef?: string;
137
+ /** Relay-enriched: existing baseSha. */
138
+ baseSha?: string;
139
+ };
114
140
  }
115
141
 
116
142
  export interface SpawnAgentResult {
@@ -188,7 +214,7 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
188
214
  // the name through unvalidated). Omitted profile uses Relay's spawn default so coordinator
189
215
  // spawns still get the bundled Relay context/affordances. Explicit profile always wins.
190
216
  const profileName = input.profile ?? DEFAULT_SPAWN_PROFILE_NAME;
191
- const agentProfile = withResolvedProvisioning(getAgentProfile(profileName)?.value, input.provider);
217
+ let agentProfile = withResolvedProvisioning(getAgentProfile(profileName)?.value, input.provider);
192
218
  if (!agentProfile) throw new McpNotFoundError(input.profile ? "agent profile not found" : `default agent profile ${DEFAULT_SPAWN_PROFILE_NAME} not found`);
193
219
 
194
220
  // #667 — project `.mcp.json` projection: a per-spawn `projectMcp` overrides the profile
@@ -198,13 +224,13 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
198
224
  // config). Like projectInstructions, an explicit `false` parses to undefined and so cannot
199
225
  // turn a profile default OFF — opt-in is the trust gate, not opt-out.
200
226
  if (input.projectMcp !== undefined) agentProfile.projectMcp = input.projectMcp;
227
+ if (input.projectSkills !== undefined) agentProfile.projectSkills = input.projectSkills;
228
+ if (input.projectSettings !== undefined) agentProfile.projectSettings = input.projectSettings;
201
229
 
202
- // #672 — shared host-listener MCP: a per-spawn `sharedMcp` overrides the profile default,
203
- // same opt-in trust gate as projectMcp (an explicit `false` parses to undefined and so
204
- // cannot turn a profile default OFF). The runner emits the bridge entry from
205
- // `agentProfile.sharedMcp` (it owns the spawn cwd → `--cwd "$WORKTREE"`); thread the resolved
206
- // decision onto the in-flight profile copy (a fresh object, never the cached config).
207
- if (input.sharedMcp !== undefined) agentProfile.sharedMcp = input.sharedMcp;
230
+ // #689 — shared host-listener MCP defaults on only for profiles that would otherwise receive
231
+ // host non-identity MCP. Per-spawn false and the global env kill-switch force direct MCP.
232
+ // Explicit opt-in still works for zero-host profiles.
233
+ agentProfile = applyDefaultSharedMcp(agentProfile, input.sharedMcp);
208
234
 
209
235
  // --- THE GATE (uniform across transports) ---------------------------------
210
236
  // Coarse capability: spawning requires command:spawn. The MCP dispatcher already enforced
@@ -232,6 +258,40 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
232
258
  // outside its cwd prefix, and MCP now honors the same bound.
233
259
  assertResourceAllowed(ctx, { scope: "agent:write", resource: { orchestratorId: orchestrator.id, cwd: resolvedCwd } });
234
260
 
261
+ // #635 — validate and enrich resumeWorkspace before dispatching to the orchestrator.
262
+ // The relay has the workspace DB records; the orchestrator has disk access. We do the
263
+ // "is this a real managed workspace and is it free?" check here, the orchestrator
264
+ // does the "does the worktree actually exist on disk?" check in resolveSpawnWorkspace.
265
+ if (input.resumeWorkspace) {
266
+ const { branch, mode } = input.resumeWorkspace;
267
+ const existingWs = listWorkspaces().find(
268
+ (ws) => ws.branch === branch && ws.mode === "isolated" && !TERMINAL_WORKSPACE_STATUSES.has(ws.status),
269
+ );
270
+ if (!existingWs || !existingWs.worktreePath) {
271
+ throw new ValidationError(`resumeWorkspace: no active isolated workspace found for branch "${branch}"`);
272
+ }
273
+ if (!isPathWithinBase(existingWs.worktreePath, orchestrator.baseDir)) {
274
+ throw new ValidationError(`resumeWorkspace: worktree "${existingWs.worktreePath}" is outside orchestrator base dir "${orchestrator.baseDir}"`);
275
+ }
276
+ if (mode === "attach") {
277
+ if (isOwnerAlive(existingWs.ownerAgentId)) {
278
+ throw new ValidationError(`resumeWorkspace: workspace owner "${existingWs.ownerAgentId}" is still alive — cannot double-attach`);
279
+ }
280
+ if (worktreeInUseByLiveAgent(existingWs.worktreePath, liveAgentCwds())) {
281
+ throw new ValidationError(`resumeWorkspace: worktree "${existingWs.worktreePath}" is still in use by a live agent`);
282
+ }
283
+ }
284
+ // Enrich with relay-resolved fields the orchestrator needs.
285
+ input.resumeWorkspace = {
286
+ branch,
287
+ mode,
288
+ worktreePath: existingWs.worktreePath,
289
+ workspaceId: existingWs.id,
290
+ baseRef: existingWs.baseRef,
291
+ baseSha: existingWs.baseSha,
292
+ };
293
+ }
294
+
235
295
  // Provenance actor: the resolved caller agent when known, else the transport's label, else
236
296
  // the raw token actor. Recorded in the command payload, the token mint, and the audit row.
237
297
  const requestedBy = callerId ?? input.requestedBy ?? ctx.actor.id;
@@ -317,6 +377,10 @@ export async function spawnAgent(input: SpawnAgentInput, ctx: AuthContext): Prom
317
377
  prompt: input.prompt,
318
378
  systemPromptAppend,
319
379
  relayInjectionEvents: composedInstructions.relayInjectionEvents,
380
+ landStrategy: input.landStrategy,
381
+ autoMerge: input.autoMerge,
382
+ reviewer: input.reviewer,
383
+ resumeWorkspace: input.resumeWorkspace,
320
384
  ...(expectReply ? { expectReply } : {}),
321
385
  policyName: input.policyName,
322
386
  spawnRequestId,