rivet-design 0.9.3 → 0.9.4

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 (90) hide show
  1. package/dist/mcp/agent-variants/SessionStore.d.ts +25 -10
  2. package/dist/mcp/agent-variants/SessionStore.d.ts.map +1 -1
  3. package/dist/mcp/agent-variants/SessionStore.js +186 -44
  4. package/dist/mcp/agent-variants/SessionStore.js.map +1 -1
  5. package/dist/mcp/agent-variants/WorktreeOrchestrator.d.ts +146 -13
  6. package/dist/mcp/agent-variants/WorktreeOrchestrator.d.ts.map +1 -1
  7. package/dist/mcp/agent-variants/WorktreeOrchestrator.js +1223 -58
  8. package/dist/mcp/agent-variants/WorktreeOrchestrator.js.map +1 -1
  9. package/dist/mcp/agent-variants/WorktreeOrchestrator.testHelpers.d.ts +65 -0
  10. package/dist/mcp/agent-variants/WorktreeOrchestrator.testHelpers.d.ts.map +1 -0
  11. package/dist/mcp/agent-variants/WorktreeOrchestrator.testHelpers.js +162 -0
  12. package/dist/mcp/agent-variants/WorktreeOrchestrator.testHelpers.js.map +1 -0
  13. package/dist/mcp/agent-variants/contracts.d.ts +1470 -64
  14. package/dist/mcp/agent-variants/contracts.d.ts.map +1 -1
  15. package/dist/mcp/agent-variants/contracts.js +173 -9
  16. package/dist/mcp/agent-variants/contracts.js.map +1 -1
  17. package/dist/mcp/agent-variants/createProjectArtifacts.d.ts +78 -0
  18. package/dist/mcp/agent-variants/createProjectArtifacts.d.ts.map +1 -0
  19. package/dist/mcp/agent-variants/createProjectArtifacts.js +123 -0
  20. package/dist/mcp/agent-variants/createProjectArtifacts.js.map +1 -0
  21. package/dist/mcp/agent-variants/createZeroToOneTool.d.ts +60 -24
  22. package/dist/mcp/agent-variants/createZeroToOneTool.d.ts.map +1 -1
  23. package/dist/mcp/agent-variants/createZeroToOneTool.js +38 -120
  24. package/dist/mcp/agent-variants/createZeroToOneTool.js.map +1 -1
  25. package/dist/mcp/agent-variants/errors.d.ts +1 -1
  26. package/dist/mcp/agent-variants/errors.d.ts.map +1 -1
  27. package/dist/mcp/agent-variants/errors.js +1 -0
  28. package/dist/mcp/agent-variants/errors.js.map +1 -1
  29. package/dist/mcp/agent-variants/index.d.ts +3 -4
  30. package/dist/mcp/agent-variants/index.d.ts.map +1 -1
  31. package/dist/mcp/agent-variants/index.js +2 -2
  32. package/dist/mcp/agent-variants/index.js.map +1 -1
  33. package/dist/mcp/agent-variants/inspirationDesignContext.d.ts +10 -0
  34. package/dist/mcp/agent-variants/inspirationDesignContext.d.ts.map +1 -1
  35. package/dist/mcp/agent-variants/inspirationDesignContext.js +104 -16
  36. package/dist/mcp/agent-variants/inspirationDesignContext.js.map +1 -1
  37. package/dist/mcp/agent-variants/pendingChangesAdapter.d.ts.map +1 -1
  38. package/dist/mcp/agent-variants/pendingChangesAdapter.js +11 -0
  39. package/dist/mcp/agent-variants/pendingChangesAdapter.js.map +1 -1
  40. package/dist/mcp/agent-variants/previewQa.d.ts +61 -0
  41. package/dist/mcp/agent-variants/previewQa.d.ts.map +1 -0
  42. package/dist/mcp/agent-variants/previewQa.js +374 -0
  43. package/dist/mcp/agent-variants/previewQa.js.map +1 -0
  44. package/dist/mcp/agent-variants/sourceContext.d.ts +1 -0
  45. package/dist/mcp/agent-variants/sourceContext.d.ts.map +1 -1
  46. package/dist/mcp/agent-variants/sourceContext.js +28 -3
  47. package/dist/mcp/agent-variants/sourceContext.js.map +1 -1
  48. package/dist/mcp/agent-variants/tools.d.ts +23 -1
  49. package/dist/mcp/agent-variants/tools.d.ts.map +1 -1
  50. package/dist/mcp/agent-variants/tools.js +144 -27
  51. package/dist/mcp/agent-variants/tools.js.map +1 -1
  52. package/dist/mcp/server.d.ts.map +1 -1
  53. package/dist/mcp/server.js +25 -6
  54. package/dist/mcp/server.js.map +1 -1
  55. package/dist/prompts/agentModPrompts.js +4 -4
  56. package/dist/prompts/agentModPrompts.js.map +1 -1
  57. package/dist/routes/agentVariants.d.ts.map +1 -1
  58. package/dist/routes/agentVariants.js +8 -1
  59. package/dist/routes/agentVariants.js.map +1 -1
  60. package/dist/server.d.ts.map +1 -1
  61. package/dist/server.js +21 -3
  62. package/dist/server.js.map +1 -1
  63. package/dist/services/ProjectDetectionService.d.ts.map +1 -1
  64. package/dist/services/ProjectDetectionService.js +9 -0
  65. package/dist/services/ProjectDetectionService.js.map +1 -1
  66. package/dist/services/WorktreeManager.d.ts +67 -9
  67. package/dist/services/WorktreeManager.d.ts.map +1 -1
  68. package/dist/services/WorktreeManager.js +209 -26
  69. package/dist/services/WorktreeManager.js.map +1 -1
  70. package/dist/services/agent/AgentModService.js +6 -6
  71. package/dist/services/agent/AgentModService.js.map +1 -1
  72. package/dist/services/templates/viteReactTs.d.ts +6 -0
  73. package/dist/services/templates/viteReactTs.d.ts.map +1 -1
  74. package/dist/services/templates/viteReactTs.js +8 -1
  75. package/dist/services/templates/viteReactTs.js.map +1 -1
  76. package/dist/types/change-request-types.d.ts +14 -0
  77. package/dist/types/change-request-types.d.ts.map +1 -1
  78. package/dist/utils/skills/claude-skill.d.ts +1 -1
  79. package/dist/utils/skills/claude-skill.js +2 -2
  80. package/dist/utils/skills/cursor-rules.d.ts +1 -1
  81. package/dist/utils/skills/cursor-rules.js +1 -1
  82. package/dist/utils/skills/shared-variants-protocol.d.ts +1 -1
  83. package/dist/utils/skills/shared-variants-protocol.d.ts.map +1 -1
  84. package/dist/utils/skills/shared-variants-protocol.js +31 -32
  85. package/dist/utils/skills/shared-variants-protocol.js.map +1 -1
  86. package/package.json +3 -4
  87. package/src/ui/dist/assets/{main-DejhsBWR.js → main-CpX7fB64.js} +113 -113
  88. package/src/ui/dist/assets/main-Qqe2_oMT.css +1 -0
  89. package/src/ui/dist/index.html +2 -2
  90. package/src/ui/dist/assets/main-C9jfEp80.css +0 -1
@@ -4,6 +4,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.AgentVariantsOrchestrator = void 0;
7
+ const crypto_1 = require("crypto");
7
8
  const events_1 = require("events");
8
9
  const fs_1 = __importDefault(require("fs"));
9
10
  const path_1 = __importDefault(require("path"));
@@ -11,11 +12,180 @@ const child_process_1 = require("child_process");
11
12
  const simple_git_1 = require("simple-git");
12
13
  const logger_1 = require("../../utils/logger");
13
14
  const errors_1 = require("./errors");
15
+ const createProjectArtifacts_1 = require("./createProjectArtifacts");
14
16
  const contracts_1 = require("./contracts");
15
17
  const viteReactTs_1 = require("../../services/templates/viteReactTs");
16
18
  const designCatalog_1 = require("../../services/templates/designCatalog");
19
+ const previewQa_1 = require("./previewQa");
17
20
  const log = (0, logger_1.createLogger)('AgentVariantsOrchestrator');
18
21
  const FRESH_DEV_SERVER_HOST = '127.0.0.1';
22
+ /**
23
+ * Allowlist of asset file extensions an agent-planned source may have.
24
+ * `assetPlan` is sized for large local *assets* (3D models, images,
25
+ * audio, video, fonts) — never code, dotfiles, secrets, or arbitrary
26
+ * configs. The check is defense-in-depth alongside the path / symlink
27
+ * filters in `copyAssetIntoWorktree`; without it, a prompt-injected
28
+ * source plan could copy readable host files such as SSH keys or
29
+ * project secrets into the generated app.
30
+ *
31
+ * Intentionally excludes `.json`, `.txt`, `.md`, `.csv`: these are
32
+ * common carriers for credentials (`credentials.json`, `.aws/config`,
33
+ * shell history `.txt`, README-shaped secrets) and have no legitimate
34
+ * use as binary preview assets. If a future flow needs structured data
35
+ * it should ship it through the manifest instead of file copy.
36
+ */
37
+ const ALLOWED_ASSET_EXTENSIONS = new Set([
38
+ // 3D / models
39
+ '.glb', '.gltf', '.obj', '.fbx', '.usdz',
40
+ // images
41
+ '.png', '.jpg', '.jpeg', '.gif', '.webp', '.svg', '.avif', '.bmp', '.ico',
42
+ // video
43
+ '.mp4', '.webm', '.mov',
44
+ // audio
45
+ '.mp3', '.wav', '.ogg', '.m4a',
46
+ // fonts
47
+ '.woff', '.woff2', '.ttf', '.otf', '.eot',
48
+ // PDFs
49
+ '.pdf',
50
+ ]);
51
+ /**
52
+ * Reject paths that traverse through a "sensitive" directory anywhere
53
+ * in their ancestry — `.ssh`, `.aws`, `.config`, `.gnupg`, etc. Even if
54
+ * a copy passes the extension allowlist, an agent-controlled source
55
+ * resolving inside `~/.ssh/` is almost certainly an exfiltration
56
+ * attempt rather than a legitimate asset reference.
57
+ */
58
+ const SENSITIVE_PATH_SEGMENTS = new Set([
59
+ '.ssh',
60
+ '.aws',
61
+ '.gnupg',
62
+ '.kube',
63
+ '.docker',
64
+ '.config',
65
+ '.npm',
66
+ '.cargo',
67
+ '.gem',
68
+ '.netrc',
69
+ 'private',
70
+ 'secrets',
71
+ 'credentials',
72
+ ]);
73
+ function hasSensitivePathSegment(absolutePath) {
74
+ // Split on any path separator; check each segment case-insensitively.
75
+ const segments = absolutePath.split(/[/\\]/).filter(Boolean);
76
+ return segments.some((segment) => SENSITIVE_PATH_SEGMENTS.has(segment.toLowerCase()));
77
+ }
78
+ /**
79
+ * Returns true when `absolutePath` is the root itself or one of its
80
+ * descendants after both paths have been resolved.
81
+ */
82
+ function isPathWithinRoot(absolutePath, root) {
83
+ const relative = path_1.default.relative(root, absolutePath);
84
+ return (relative === '' ||
85
+ (relative.length > 0 &&
86
+ !relative.startsWith('..') &&
87
+ !path_1.default.isAbsolute(relative)));
88
+ }
89
+ /**
90
+ * Copy one agent-planned asset file into a variant worktree at the
91
+ * project-relative `destination`. The agent decides routing
92
+ * (`destination` like `public/assets/avatar.glb`) so all variants share
93
+ * a consistent layout. Fails loudly when the source is missing or the
94
+ * destination tries to escape the worktree — silent fallbacks here
95
+ * would re-introduce the broken-asset failure mode this routing is
96
+ * supposed to prevent.
97
+ *
98
+ * Security: `entry.source` comes from `report_source_plan` and is
99
+ * agent-controlled. To prevent a prompt-injected source plan from
100
+ * exfiltrating arbitrary readable host files (SSH keys, dotfiles,
101
+ * cloud credentials, project secrets) into the generated app, the
102
+ * source must (1) be an absolute path that resolves via realpath
103
+ * (no traversal, no symlinks pointing outside the resolved location),
104
+ * (2) be a regular file (not a device, socket, fifo, or directory),
105
+ * (3) live under the approved asset root, (4) have an extension on the
106
+ * asset allowlist.
107
+ */
108
+ function copyAssetIntoWorktree(worktreePath, entry, assetSourceRoot) {
109
+ if (!assetSourceRoot) {
110
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source requires an approved asset root before copying '${entry.source}'`);
111
+ }
112
+ if (!path_1.default.isAbsolute(assetSourceRoot)) {
113
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetSourceRoot must be an absolute path, got '${assetSourceRoot}'`);
114
+ }
115
+ if (!path_1.default.isAbsolute(entry.source)) {
116
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source must be an absolute path, got '${entry.source}'`);
117
+ }
118
+ const ext = path_1.default.extname(entry.source).toLowerCase();
119
+ if (!ALLOWED_ASSET_EXTENSIONS.has(ext)) {
120
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source extension '${ext || '(none)'}' is not on the allowlist (got '${entry.source}'). Only inert media/font/document assets may be copied.`);
121
+ }
122
+ // lstat first: a symlink whose target lies outside the source path
123
+ // could otherwise smuggle in arbitrary files. Reject symlinks outright;
124
+ // realpath would resolve them but the user-visible intent of an
125
+ // assetPlan entry is to name a concrete file.
126
+ let lstat;
127
+ try {
128
+ lstat = fs_1.default.lstatSync(entry.source);
129
+ }
130
+ catch {
131
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source not found on disk: ${entry.source}`);
132
+ }
133
+ if (lstat.isSymbolicLink()) {
134
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source must not be a symlink: ${entry.source}`);
135
+ }
136
+ if (!lstat.isFile()) {
137
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source must be a regular file: ${entry.source}`);
138
+ }
139
+ // Resolve realpath as additional defense: even with no symlinks at the
140
+ // leaf, an intermediate directory could be a symlink. Cross-check the
141
+ // file kind on the resolved path.
142
+ let resolvedSource;
143
+ try {
144
+ resolvedSource = fs_1.default.realpathSync(entry.source);
145
+ }
146
+ catch {
147
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source could not be resolved: ${entry.source}`);
148
+ }
149
+ let resolvedAssetSourceRoot;
150
+ try {
151
+ resolvedAssetSourceRoot = fs_1.default.realpathSync(assetSourceRoot);
152
+ }
153
+ catch {
154
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetSourceRoot could not be resolved: ${assetSourceRoot}`);
155
+ }
156
+ const resolvedStat = fs_1.default.statSync(resolvedSource);
157
+ if (!resolvedStat.isFile()) {
158
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source resolved target must be a regular file: ${entry.source}`);
159
+ }
160
+ // Symlinked parent directory defense: even though we rejected a symlink
161
+ // leaf and confirmed the resolved file is regular, an intermediate dir
162
+ // could have been a symlink that quietly forwards into a sensitive
163
+ // ancestor (e.g. `<asset-root>/avatar -> ~/.ssh`). Cross-check that
164
+ // NEITHER the user-supplied path NOR its realpath traverses a known
165
+ // sensitive segment such as `.ssh`, `.aws`, `credentials`, etc. Also
166
+ // re-verify the extension on the resolved path so a `.glb` symlink
167
+ // chain cannot smuggle in a `.json` realpath.
168
+ if (hasSensitivePathSegment(entry.source) ||
169
+ hasSensitivePathSegment(resolvedSource)) {
170
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source traverses a sensitive directory and is refused: ${entry.source}`);
171
+ }
172
+ if (!isPathWithinRoot(resolvedSource, resolvedAssetSourceRoot)) {
173
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source must stay inside the approved asset root: ${entry.source}`);
174
+ }
175
+ const resolvedExt = path_1.default.extname(resolvedSource).toLowerCase();
176
+ if (!ALLOWED_ASSET_EXTENSIONS.has(resolvedExt)) {
177
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.source resolved extension '${resolvedExt || '(none)'}' is not on the allowlist (resolved from '${entry.source}').`);
178
+ }
179
+ const normalizedDest = path_1.default.normalize(entry.destination);
180
+ if (normalizedDest.startsWith('..') ||
181
+ path_1.default.isAbsolute(normalizedDest)) {
182
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `assetPlan.destination must stay inside the worktree, got '${entry.destination}'`);
183
+ }
184
+ const absDest = path_1.default.join(worktreePath, normalizedDest);
185
+ fs_1.default.mkdirSync(path_1.default.dirname(absDest), { recursive: true });
186
+ fs_1.default.copyFileSync(resolvedSource, absDest);
187
+ }
188
+ const defaultPreviewQaRunner = ({ html }) => (0, previewQa_1.runPreviewQa)({ html });
19
189
  const NOOP_TELEMETRY = { track: () => undefined };
20
190
  /**
21
191
  * Wraps SessionStore for the operations that have side effects: approve
@@ -37,7 +207,29 @@ class AgentVariantsOrchestrator {
37
207
  telemetry;
38
208
  installDependencies;
39
209
  materializeProject;
210
+ previewQaRunner;
211
+ switchPreviewPort;
40
212
  resources = new Map();
213
+ /**
214
+ * Committed dev servers from prior sessions that survived teardown. The
215
+ * orchestrator outlives any single session — once a new session starts, the
216
+ * prior committed dev server is no longer needed (the next session opens
217
+ * its own preview chip), so stop it before the new session's first preview
218
+ * goes live. Keyed by sessionId for diagnostics; values are kept alive
219
+ * intentionally between commit and next `propose` / `shutdown`.
220
+ */
221
+ lingeringCommittedDevServers = new Map();
222
+ /**
223
+ * Per-session in-flight commit chain. Two overlapping commitVariant calls
224
+ * would otherwise both pass the `getVariantPick` idempotency check (the
225
+ * pick isn't recorded until after `applyDiffToProject`) and double-invoke
226
+ * `git apply` — which corrupts the working tree on the second run. The
227
+ * chain serializes commits per session so the second caller waits for the
228
+ * first to finish and then hits the idempotency check (returning
229
+ * `duplicate: true` for the same variantId, or `PENDING_CHANGE_CONFLICT`
230
+ * for a different one).
231
+ */
232
+ inflightCommits = new Map();
41
233
  /** Most recent agent-variants sessionId — read by the iframe chip via
42
234
  * GET /api/variants/active. Cleared on cancel or commit. */
43
235
  activeSessionId = null;
@@ -54,9 +246,17 @@ class AgentVariantsOrchestrator {
54
246
  deps.installDependencies ?? defaultInstallDependencies;
55
247
  this.materializeProject =
56
248
  deps.materializeProject ?? defaultMaterializeProject;
249
+ this.previewQaRunner = deps.previewQaRunner ?? defaultPreviewQaRunner;
250
+ this.switchPreviewPort = deps.switchPreviewPort;
57
251
  }
58
252
  // --- Pure delegations (no side effects) ---------------------------------
59
253
  propose(args) {
254
+ // A new session means any lingering committed dev server from a prior
255
+ // session is no longer needed — the next session opens its own preview.
256
+ // Best-effort stop; failures here don't block propose.
257
+ void this.stopLingeringCommittedDevServers().catch((err) => {
258
+ log.warn('Stopping lingering committed dev servers failed', err);
259
+ });
60
260
  const result = this.store.propose(args);
61
261
  this.activeSessionId = result.sessionId;
62
262
  this.ensureResources(result.sessionId);
@@ -99,8 +299,10 @@ class AgentVariantsOrchestrator {
99
299
  ? this.store.getSummary(sessionId)
100
300
  : null;
101
301
  const variants = this.getVariants(sessionId);
102
- const projectContext = toActiveProjectContext(this.store.getProjectContext(sessionId));
302
+ const sessionProjectContext = this.store.getProjectContext(sessionId);
303
+ const projectContext = toActiveProjectContext(sessionProjectContext);
103
304
  const destinationPath = projectContext.kind === 'fresh' ? projectContext.workspacePath : undefined;
305
+ const artifacts = buildSessionArtifacts(sessionProjectContext);
104
306
  return {
105
307
  active: true,
106
308
  sessionId,
@@ -111,6 +313,7 @@ class AgentVariantsOrchestrator {
111
313
  variants,
112
314
  projectContext,
113
315
  ...(destinationPath ? { destinationPath } : {}),
316
+ ...(artifacts.length > 0 ? { artifacts } : {}),
114
317
  };
115
318
  }
116
319
  emitChange() {
@@ -134,14 +337,14 @@ class AgentVariantsOrchestrator {
134
337
  this.emitChange();
135
338
  return result;
136
339
  }
137
- reportSourceContext(args) {
138
- const result = this.store.reportSourceContext(args);
340
+ reportSourcePlan(args) {
341
+ const result = this.store.reportSourcePlan(args);
139
342
  this.telemetry.trackAgentVariantsSourceContextQuality?.({
140
343
  sessionId: args.sessionId,
141
- sourceUrlCount: args.sourceContext.sourceRoles.length,
142
- artifactCount: args.sourceContext.sourceFindings.length,
143
- hasScreenshotReferences: (args.sourceContext.screenshotReferences?.length ?? 0) > 0,
144
- preserveBrand: args.sourceContext.sourceRoles.some((entry) => entry.role === 'primary'),
344
+ sourceUrlCount: args.sourcePlan.sourceIntent.sources.length,
345
+ artifactCount: args.sourcePlan.sourceContext.sourceFindings.length,
346
+ hasScreenshotReferences: (args.sourcePlan.sourceContext.screenshotReferences?.length ?? 0) > 0,
347
+ preserveBrand: args.sourcePlan.sourceContext.sourceRoles.some((entry) => entry.role === 'primary'),
145
348
  });
146
349
  this.emitChange();
147
350
  return result;
@@ -226,14 +429,20 @@ class AgentVariantsOrchestrator {
226
429
  if (!preview && port) {
227
430
  preview = { kind: 'dev_server', port };
228
431
  }
432
+ const qa = resources?.qaResults.get(variant.workItemId);
229
433
  const isSucceeded = variant.status === 'succeeded';
434
+ const qaFailed = qa?.status === 'failed';
230
435
  const canView = Boolean(preview) || (isSucceeded && Boolean(port));
231
- const canCommit = isSucceeded;
436
+ const canCommit = isSucceeded && !qaFailed;
437
+ const commitDisabledReason = qaFailed
438
+ ? qa?.summary ?? 'Variant failed QA'
439
+ : 'Wait for a successful variant';
232
440
  return {
233
441
  ...variant,
234
442
  design: enrichDesignSource(variant.design),
235
443
  ...(preview ? { preview } : {}),
236
444
  port,
445
+ ...(qa ? { qa } : {}),
237
446
  actions: {
238
447
  view: canView
239
448
  ? { enabled: true }
@@ -247,7 +456,7 @@ class AgentVariantsOrchestrator {
247
456
  ? { enabled: true }
248
457
  : {
249
458
  enabled: false,
250
- reason: 'Wait for a successful variant',
459
+ reason: commitDisabledReason,
251
460
  },
252
461
  },
253
462
  };
@@ -344,26 +553,40 @@ class AgentVariantsOrchestrator {
344
553
  * work items via continue_variants(action="request_work").
345
554
  */
346
555
  async startUnified(args) {
347
- const count = args.count ?? 4;
556
+ const count = args.briefs?.length ?? args.count ?? 4;
348
557
  const projectContext = args.projectContext ?? { kind: 'existing' };
558
+ const sourceContext = projectContext.kind === 'fresh' ? projectContext.sourceContext : undefined;
559
+ const isSourceGrounded = Boolean(sourceContext?.sourceUrls?.length) ||
560
+ Boolean(sourceContext?.sourceArtifacts?.length) ||
561
+ Boolean(sourceContext?.sourceIntent) ||
562
+ Boolean(sourceContext?.artifact);
563
+ if (isSourceGrounded) {
564
+ throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', 'start_variants cannot handle source-grounded sessions. Use create_zero_to_one_project for inspiration-grounded fresh projects.');
565
+ }
349
566
  const proposeResult = this.propose({
350
567
  prompt: args.prompt,
351
568
  count,
352
569
  target: args.target,
353
570
  projectContext,
354
571
  });
355
- if (proposeResult.stage === 'awaiting_source_research') {
356
- throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', 'start_variants cannot handle source-research sessions. Use create_zero_to_one_project for inspiration-grounded fresh projects.');
357
- }
358
572
  if (proposeResult.stage !== 'awaiting_briefs' ||
359
573
  !proposeResult.briefWorkItem) {
360
574
  throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', `start_variants expected awaiting_briefs from propose, got ${proposeResult.stage}`);
361
575
  }
362
- const briefs = Array.from({ length: count }, (_, i) => ({
363
- briefId: `v${i + 1}`,
364
- label: `Variant ${i + 1}`,
365
- body: synthesizeUnifiedBriefBody(args.prompt, i + 1, count),
366
- }));
576
+ const briefs = args.briefs
577
+ ? args.briefs.map((b, i) => ({
578
+ briefId: `v${i + 1}`,
579
+ label: b.label,
580
+ body: b.body,
581
+ ...(b.visualReferenceUrl
582
+ ? { visualReferenceUrl: b.visualReferenceUrl }
583
+ : {}),
584
+ }))
585
+ : Array.from({ length: count }, (_, i) => ({
586
+ briefId: `v${i + 1}`,
587
+ label: `Variant ${i + 1}`,
588
+ body: synthesizeUnifiedBriefBody(args.prompt, i + 1, count),
589
+ }));
367
590
  this.store.reportBriefs({
368
591
  sessionId: proposeResult.sessionId,
369
592
  workItemId: proposeResult.briefWorkItem.id,
@@ -420,14 +643,21 @@ class AgentVariantsOrchestrator {
420
643
  };
421
644
  }
422
645
  async reportComplete(args) {
423
- const result = this.store.reportComplete(args);
646
+ // QA gate: for `succeeded` static_preview reports, run preview QA
647
+ // synchronously before recording success in the store. A failed QA
648
+ // verdict converts the report to `failed` with code `VARIANT_QA_FAILED`
649
+ // so the variant never reaches `ready` and `getVariants` can disable
650
+ // its commit action.
651
+ const qaOverride = await this.evaluateQaForReport(args);
652
+ const effectiveArgs = qaOverride?.overrideArgs ?? args;
653
+ const result = this.store.reportComplete(effectiveArgs);
424
654
  this.emitChange();
425
655
  // Per-variant telemetry on terminal item statuses (skip 'running'
426
656
  // heartbeats and the brief work item — only code_gen / scaffold_base
427
657
  // matter for the funnel).
428
- if (args.status === 'succeeded' ||
429
- args.status === 'failed' ||
430
- args.status === 'cancelled') {
658
+ if (effectiveArgs.status === 'succeeded' ||
659
+ effectiveArgs.status === 'failed' ||
660
+ effectiveArgs.status === 'cancelled') {
431
661
  const resources = this.resources.get(args.sessionId);
432
662
  const leasedAt = resources?.leasedAt.get(args.workItemId);
433
663
  const durationMs = leasedAt !== undefined ? Date.now() - leasedAt : null;
@@ -440,12 +670,12 @@ class AgentVariantsOrchestrator {
440
670
  source: 'mcp',
441
671
  sessionId: args.sessionId,
442
672
  workItemId: args.workItemId,
443
- status: args.status,
673
+ status: effectiveArgs.status,
444
674
  attempt: args.attempt,
445
675
  durationMs,
446
676
  approveToCompleteMs,
447
- hasError: Boolean(args.error),
448
- errorCode: args.error?.code ?? null,
677
+ hasError: Boolean(effectiveArgs.error),
678
+ errorCode: effectiveArgs.error?.code ?? null,
449
679
  isScaffold: resources?.scaffoldBaseWorkItemId === args.workItemId,
450
680
  projectContextKind,
451
681
  tokensIn: args.tokensIn ?? null,
@@ -476,7 +706,7 @@ class AgentVariantsOrchestrator {
476
706
  });
477
707
  }
478
708
  }
479
- if (args.status === 'succeeded') {
709
+ if (effectiveArgs.status === 'succeeded') {
480
710
  void this.handleSucceededReport({
481
711
  sessionId: args.sessionId,
482
712
  workItemId: args.workItemId,
@@ -549,6 +779,29 @@ class AgentVariantsOrchestrator {
549
779
  * the call returns duplicate=true without re-enqueueing.
550
780
  */
551
781
  async commitVariant(args) {
782
+ // Serialize per-session so two overlapping commits can't both run
783
+ // `applyDiffToProject` (or the fresh rename) before the first records
784
+ // its pick. The second caller waits for the first to settle, then runs
785
+ // commitVariantImpl which hits the idempotency check in
786
+ // `getVariantPick` / `recordVariantPick`.
787
+ const prior = this.inflightCommits.get(args.sessionId);
788
+ const run = (async () => {
789
+ if (prior) {
790
+ await prior.catch(() => undefined);
791
+ }
792
+ return this.commitVariantImpl(args);
793
+ })();
794
+ this.inflightCommits.set(args.sessionId, run);
795
+ try {
796
+ return await run;
797
+ }
798
+ finally {
799
+ if (this.inflightCommits.get(args.sessionId) === run) {
800
+ this.inflightCommits.delete(args.sessionId);
801
+ }
802
+ }
803
+ }
804
+ async commitVariantImpl(args) {
552
805
  // Idempotent path — SessionStore.recordVariantPick is the source of truth
553
806
  // for which variant the user picked. If the same variant is being
554
807
  // committed again, replay the previously stored envelope's metadata
@@ -573,39 +826,162 @@ class AgentVariantsOrchestrator {
573
826
  if (!variantSnapshot || variantSnapshot.actions?.commit?.enabled !== true) {
574
827
  throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', variantSnapshot?.actions?.commit?.reason ?? 'Variant is not committable');
575
828
  }
576
- const env = await this.resolveEnv(args.sessionId);
577
829
  const input = this.store.getWorkItemInput(args.sessionId, args.variantId);
578
830
  const projectContext = this.store.getProjectContext(args.sessionId);
579
831
  let payload;
580
832
  let envelopeDestination;
581
833
  let changedFilesCount;
834
+ let freshVariantFolderName;
582
835
  if (projectContext.kind === 'fresh') {
583
- // Fresh sessions: static preview HTML is the deliverable. Write index.html
584
- // to the destination directory.
585
836
  const destinationPath = projectContext.workspacePath;
586
837
  this.assertDestinationAvailable(destinationPath);
587
- const staticPreview = resources.staticPreviews.get(args.variantId);
588
- if (!staticPreview) {
589
- throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', `No static preview found for variant ${args.variantId} — wait for report_variant_complete(succeeded) first`);
590
- }
591
- try {
592
- fs_1.default.mkdirSync(destinationPath, { recursive: true });
593
- fs_1.default.writeFileSync(path_1.default.join(destinationPath, 'index.html'), staticPreview.html, 'utf8');
838
+ const variantFolderName = this.getFreshVariantFolderName({
839
+ sessionId: args.sessionId,
840
+ variantId: args.variantId,
841
+ variantName: input.briefLabel,
842
+ });
843
+ freshVariantFolderName = variantFolderName;
844
+ const freshMode = projectContext.executionPlan?.mode === 'vite_app'
845
+ ? 'vite_app'
846
+ : 'static_preview';
847
+ if (freshMode === 'vite_app') {
848
+ // Vite_app: the deliverable is the entire variant worktree, not a
849
+ // single HTML file. When the worktree lives on the same volume as
850
+ // the destination (the common case — provisionFreshWorktrees scaffolds
851
+ // under destinationParent/.rivet-variants/) we can rename it into
852
+ // place. That moves the entire tree — including the materialized
853
+ // node_modules — in O(1) with no copy, and the rename is atomic.
854
+ // For cross-volume cases (user passed a destinationParent on a
855
+ // different mount than the .rivet-variants dir, e.g. tests stubbing
856
+ // workspacePath to a tmp dir), fall back to the recursive-copy +
857
+ // background-install path so the feature still works.
858
+ const record = resources.worktrees.get(args.variantId);
859
+ if (!record) {
860
+ throw new errors_1.AgentVariantsError('WORK_ITEM_NOT_FOUND', `Unknown variantId ${args.variantId} for session ${args.sessionId}`);
861
+ }
862
+ let usedRename = false;
863
+ let isDependencyInstallRequired = false;
864
+ try {
865
+ if (areSameVolume(record.worktreePath, destinationPath)) {
866
+ // Vite's dev server writes its module cache to `<worktreePath>/.vite/`
867
+ // and recreates that directory on the fly whenever it's missing. If
868
+ // we rename a worktree out from under a running dev server, Vite
869
+ // will materialize an empty `.vite/` shell back at the OLD path —
870
+ // leaving behind orphan stub dirs in `.rivet-variants/` and
871
+ // confusing later cleanup. Stop every variant's dev server first
872
+ // (including the chosen variant, which is about to move) so no
873
+ // process is holding the old paths open during the rename.
874
+ //
875
+ // We stop them eagerly here rather than relying on teardownSession
876
+ // because teardown runs AFTER the rename — too late to prevent the
877
+ // stub-dir race.
878
+ await this.stopAllDevServersForSession(resources);
879
+ // node_modules in fresh variants 1..N is a symlink into variant 0
880
+ // (see runBackgroundInstall). After rename, variant 0's
881
+ // node_modules vanishes during cleanup of the sibling worktrees,
882
+ // leaving the chosen variant with a dangling link. Resolve the
883
+ // symlink to a real directory first so the destination has real
884
+ // deps and the rename is self-contained.
885
+ isDependencyInstallRequired = await resolveNodeModulesSymlink(record.worktreePath);
886
+ await this.renameWorktreeIntoDestination(record.worktreePath, destinationPath);
887
+ usedRename = true;
888
+ }
889
+ else {
890
+ await this.materializeProject(record.worktreePath, destinationPath);
891
+ }
892
+ }
893
+ catch (err) {
894
+ const message = err instanceof Error ? err.message : String(err);
895
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `Failed to materialize vite_app variant to ${destinationPath}: ${message}`);
896
+ }
897
+ changedFilesCount = countWorktreeFiles(destinationPath);
898
+ if (!usedRename) {
899
+ // Cross-volume fallback: materializeProject excludes node_modules
900
+ // so we have to re-install at the destination. Don't await the
901
+ // install — the agent's commit_variant call should return
902
+ // immediately — but chain the dev-server start to it so the iframe
903
+ // recovers without manual intervention once deps are ready.
904
+ void this.installDependencies(destinationPath)
905
+ .then(() => this.startCommittedDevServer({
906
+ resources,
907
+ destinationPath,
908
+ }))
909
+ .catch((err) => {
910
+ log.warn(`Background install + committed dev server start after vite_app commit failed for ${destinationPath}`, err);
911
+ });
912
+ }
913
+ else if (isDependencyInstallRequired) {
914
+ await this.installDependencies(destinationPath);
915
+ }
916
+ if (usedRename) {
917
+ // Preserve the unchosen variants as runnable siblings of the
918
+ // chosen project. Best-effort: failures here log and continue so
919
+ // a partial history never blocks the commit handoff.
920
+ try {
921
+ this.preserveUnchosenVariants({
922
+ sessionId: args.sessionId,
923
+ chosenVariantId: args.variantId,
924
+ destinationPath,
925
+ });
926
+ }
927
+ catch (err) {
928
+ log.warn(`preserveUnchosenVariants failed for session ${args.sessionId}; continuing without history`, err);
929
+ }
930
+ // Stopped every dev server pre-rename to dodge the .vite/ stub-dir
931
+ // race. The iframe is still pointed at the (now-dead) chosen
932
+ // variant's port via the proxy, so without a fresh server at the
933
+ // destination the user would see "preview disconnected" the moment
934
+ // they commit. Spawn one and retarget the proxy. Best-effort: if
935
+ // it fails, the user can `npm run dev` themselves at destination.
936
+ await this.startCommittedDevServer({
937
+ resources,
938
+ destinationPath,
939
+ });
940
+ }
941
+ payload = {
942
+ kind: 'project-created',
943
+ destinationPath,
944
+ changedFilesCount,
945
+ note: usedRename
946
+ ? 'Vite app moved to destinationPath (rename); node_modules came along.'
947
+ : 'Vite app materialized at destinationPath; `npm install` running in the background.',
948
+ };
949
+ envelopeDestination = destinationPath;
594
950
  }
595
- catch (err) {
596
- const message = err instanceof Error ? err.message : String(err);
597
- throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `Failed to write static preview to ${destinationPath}: ${message}`);
951
+ else {
952
+ // Static_preview: HTML is the entire deliverable. Write index.html.
953
+ const staticPreview = resources.staticPreviews.get(args.variantId);
954
+ const persistedSnapshotIndexPath = (0, createProjectArtifacts_1.createProjectVariantIndexPath)(destinationPath, variantFolderName);
955
+ const htmlFromSnapshot = fs_1.default.existsSync(persistedSnapshotIndexPath)
956
+ ? fs_1.default.readFileSync(persistedSnapshotIndexPath, 'utf8')
957
+ : null;
958
+ if (!staticPreview && !htmlFromSnapshot) {
959
+ throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', `No static preview found for variant ${args.variantId} — wait for report_variant_complete(succeeded) first`);
960
+ }
961
+ try {
962
+ fs_1.default.mkdirSync(destinationPath, { recursive: true });
963
+ fs_1.default.writeFileSync(path_1.default.join(destinationPath, 'index.html'), staticPreview?.html ?? htmlFromSnapshot ?? '', 'utf8');
964
+ }
965
+ catch (err) {
966
+ const message = err instanceof Error ? err.message : String(err);
967
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `Failed to write static preview to ${destinationPath}: ${message}`);
968
+ }
969
+ changedFilesCount = 1;
970
+ payload = {
971
+ kind: 'project-created',
972
+ destinationPath,
973
+ changedFilesCount,
974
+ note: 'Static preview written to index.html at destinationPath.',
975
+ };
976
+ envelopeDestination = destinationPath;
598
977
  }
599
- changedFilesCount = 1;
600
- payload = {
601
- kind: 'project-created',
602
- destinationPath,
603
- changedFilesCount,
604
- note: 'Static preview written to index.html at destinationPath.',
605
- };
606
- envelopeDestination = destinationPath;
607
978
  }
608
979
  else {
980
+ // Existing project: diff against the user's repo. resolveEnv lives in
981
+ // this branch only — fresh sessions don't have a user-supplied project
982
+ // and the bridge.getProjectPath() it reads from would either be stale
983
+ // or empty for them.
984
+ const env = await this.resolveEnv(args.sessionId);
609
985
  const record = resources.worktrees.get(args.variantId);
610
986
  if (!record) {
611
987
  throw new errors_1.AgentVariantsError('WORK_ITEM_NOT_FOUND', `Unknown variantId ${args.variantId} for session ${args.sessionId}`);
@@ -614,11 +990,26 @@ class AgentVariantsOrchestrator {
614
990
  throw new errors_1.AgentVariantsError('INVALID_STAGE_ACTION', `Variant ${args.variantId} has no captured diff yet — wait for report_variant_complete(succeeded) first`);
615
991
  }
616
992
  changedFilesCount = countDiffFiles(record.diff);
993
+ // Apply the diff to the user's working tree right now so the variant
994
+ // lands on the current branch (uncommitted). Previously this just
995
+ // enqueued the diff for an agent to apply; non-MCP sessions had no
996
+ // agent and the variant was effectively lost.
997
+ if (!this.worktrees.applyDiffToProject) {
998
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', 'WorktreeManager does not support applying diffs to the project tree');
999
+ }
1000
+ try {
1001
+ await this.worktrees.applyDiffToProject(record.diff, env.projectPath);
1002
+ }
1003
+ catch (err) {
1004
+ const message = err instanceof Error ? err.message : String(err);
1005
+ throw new errors_1.AgentVariantsError('RUNTIME_VALIDATION_FAILED', `Failed to apply variant ${args.variantId} to ${env.projectPath}: ${message}`);
1006
+ }
617
1007
  payload = {
618
- kind: 'diff',
1008
+ kind: 'diff-applied',
619
1009
  diff: record.diff,
620
1010
  target: input.target,
621
1011
  changedFilesCount,
1012
+ note: 'Variant diff applied to the user\'s working tree (uncommitted).',
622
1013
  };
623
1014
  envelopeDestination = env.projectPath;
624
1015
  }
@@ -637,6 +1028,27 @@ class AgentVariantsOrchestrator {
637
1028
  sessionId: args.sessionId,
638
1029
  envelope,
639
1030
  });
1031
+ if (projectContext.kind === 'fresh') {
1032
+ // Manifest persistence is bookkeeping for the .rivet/ artifacts and must
1033
+ // never strand the session — `recordVariantPick` above has already marked
1034
+ // this variant as the selection, so a retry would short-circuit via the
1035
+ // `duplicate: true` path and skip the enqueue below. Catch and log
1036
+ // instead of letting a filesystem hiccup block the handoff to pending-
1037
+ // changes.
1038
+ try {
1039
+ this.persistFreshSelectionMetadata({
1040
+ sessionId: args.sessionId,
1041
+ variantId: args.variantId,
1042
+ variantName: input.briefLabel,
1043
+ projectPath: projectContext.workspacePath,
1044
+ variantFolderName: freshVariantFolderName ?? (0, createProjectArtifacts_1.createProjectVariantSlug)(input.briefLabel),
1045
+ changedFilesCount,
1046
+ });
1047
+ }
1048
+ catch (err) {
1049
+ log.warn(`persistFreshSelectionMetadata failed for session ${args.sessionId} variant ${args.variantId} — continuing with enqueue`, err);
1050
+ }
1051
+ }
640
1052
  const enqueueResult = this.adapter.enqueue(envelope);
641
1053
  resources.committedVariantIds.add(args.variantId);
642
1054
  if (this.activeSessionId === args.sessionId) {
@@ -683,9 +1095,10 @@ class AgentVariantsOrchestrator {
683
1095
  if (!fs_1.default.existsSync(destinationPath))
684
1096
  return;
685
1097
  const entries = fs_1.default.readdirSync(destinationPath);
686
- if (entries.length === 0)
1098
+ const userVisibleEntries = entries.filter((entry) => entry !== '.rivet');
1099
+ if (userVisibleEntries.length === 0)
687
1100
  return;
688
- throw new errors_1.AgentVariantsError('DESTINATION_NOT_EMPTY', `Destination ${destinationPath} is not empty (${entries.length} entries) — refuse to materialize.`);
1101
+ throw new errors_1.AgentVariantsError('DESTINATION_NOT_EMPTY', `Destination ${destinationPath} is not empty (${userVisibleEntries.length} entries) — refuse to materialize.`);
689
1102
  }
690
1103
  /** Read the captured diff for a code-gen variant, if available. */
691
1104
  getVariantDiff(sessionId, variantId) {
@@ -695,8 +1108,13 @@ class AgentVariantsOrchestrator {
695
1108
  async provisionWorktrees(sessionId, approveResult) {
696
1109
  const projectContext = this.store.getProjectContext(sessionId);
697
1110
  if (projectContext.kind === 'fresh') {
698
- // Fresh sessions use static_preview as the final deliverable no
699
- // worktrees, no scaffold, no npm install needed.
1111
+ // Fresh + static_preview: no worktrees, no scaffold, HTML is the
1112
+ // deliverable. Fresh + vite_app: provision a Vite skeleton per variant
1113
+ // and copy the agent-planned assetPlan files before the agent leases
1114
+ // its code_gen items.
1115
+ if (approveResult.scaffoldBaseWorkItemId) {
1116
+ await this.provisionFreshWorktrees(sessionId, approveResult, projectContext);
1117
+ }
700
1118
  return;
701
1119
  }
702
1120
  await this.provisionExistingWorktrees(sessionId, approveResult);
@@ -769,8 +1187,13 @@ class AgentVariantsOrchestrator {
769
1187
  designContext: summarizeDesignContext(designContext),
770
1188
  });
771
1189
  log.info(`Provisioning ${codeGenIds.length} fresh worktree(s) for session ${sessionId}`);
772
- const paths = await createFresh.call(this.worktrees, sessionId, codeGenIds.length, viteReactTs_1.VITE_REACT_TS_TEMPLATE, designContext, sourceContext);
1190
+ // destinationParent for fresh worktrees: same parent the materialized
1191
+ // project will land in. Keeping the worktree on the same volume turns
1192
+ // commit into a directory rename instead of a recursive copy.
1193
+ const destinationParent = path_1.default.dirname(projectContext.workspacePath);
1194
+ const paths = await createFresh.call(this.worktrees, sessionId, codeGenIds.length, viteReactTs_1.VITE_REACT_TS_TEMPLATE, designContext, sourceContext, destinationParent);
773
1195
  resources.scaffoldBaseWorkItemId = scaffoldId;
1196
+ resources.freshDestinationParent = destinationParent;
774
1197
  // Each code_gen item maps 1:1 to a fresh worktree. The scaffold_base
775
1198
  // work item is internal — no dedicated worktree.
776
1199
  for (let i = 0; i < codeGenIds.length; i++) {
@@ -779,6 +1202,45 @@ class AgentVariantsOrchestrator {
779
1202
  worktreePath: paths[i],
780
1203
  });
781
1204
  }
1205
+ // Copy agent-planned assets (e.g. avatar.glb) into each worktree so
1206
+ // generated code can reference them via plan.referenceAs. Done before
1207
+ // the background install so the assets are on disk by the time the
1208
+ // agent leases its code_gen item.
1209
+ // If copy throws (e.g. source asset deleted between planning and
1210
+ // provisioning), fail scaffold_base immediately so dependent code_gen
1211
+ // items cascade DEPENDENCY_FAILED instead of leaving the session
1212
+ // permanently stuck in `work_items_ready`.
1213
+ const assetPlan = projectContext.executionPlan?.assetPlan ?? [];
1214
+ if (assetPlan.length > 0) {
1215
+ try {
1216
+ for (const worktreePath of paths) {
1217
+ for (const entry of assetPlan) {
1218
+ copyAssetIntoWorktree(worktreePath, entry, projectContext.assetSourceRoot);
1219
+ }
1220
+ }
1221
+ }
1222
+ catch (err) {
1223
+ const message = err instanceof Error ? err.message : String(err);
1224
+ log.warn(`asset copy failed for session ${sessionId}; marking scaffold_base failed`, err);
1225
+ trackScaffoldFailed(this.telemetry, {
1226
+ sessionId,
1227
+ errorCode: 'SCAFFOLD_FAILED',
1228
+ durationMs: Date.now() - scaffoldStartedAt,
1229
+ });
1230
+ try {
1231
+ this.store.failInternal({
1232
+ sessionId,
1233
+ workItemId: scaffoldId,
1234
+ error: { code: 'SCAFFOLD_FAILED', message },
1235
+ });
1236
+ this.emitChange();
1237
+ }
1238
+ catch (storeErr) {
1239
+ log.error(`failInternal failed for ${sessionId}/${scaffoldId}`, storeErr);
1240
+ }
1241
+ return;
1242
+ }
1243
+ }
782
1244
  // Background install: don't await. The orchestrator finishes the
783
1245
  // approve→provision call immediately; the agent polls for scaffold
784
1246
  // completion via continue_variants(action='check').
@@ -794,7 +1256,22 @@ class AgentVariantsOrchestrator {
794
1256
  async runBackgroundInstall(sessionId, scaffoldWorkItemId, worktreePaths, scaffoldStartedAt) {
795
1257
  const installStartedAt = Date.now();
796
1258
  try {
797
- await Promise.all(worktreePaths.map((p) => this.installDependencies(p)));
1259
+ const [firstWorktree, ...rest] = worktreePaths;
1260
+ await this.installDependencies(firstWorktree);
1261
+ await Promise.all(rest.map(async (p) => {
1262
+ const target = path_1.default.join(firstWorktree, 'node_modules');
1263
+ const link = path_1.default.join(p, 'node_modules');
1264
+ // The fresh template has no node_modules until install runs, so
1265
+ // the link target is guaranteed absent in `p` — no need to remove
1266
+ // first.
1267
+ try {
1268
+ fs_1.default.symlinkSync(target, link, 'dir');
1269
+ }
1270
+ catch (symlinkErr) {
1271
+ log.warn(`Symlink node_modules failed for ${p}, falling back to install`, symlinkErr);
1272
+ await this.installDependencies(p);
1273
+ }
1274
+ }));
798
1275
  }
799
1276
  catch (err) {
800
1277
  const message = err instanceof Error ? err.message : String(err);
@@ -835,6 +1312,65 @@ class AgentVariantsOrchestrator {
835
1312
  variantCount: worktreePaths.length,
836
1313
  });
837
1314
  }
1315
+ /**
1316
+ * Gate `succeeded` reports for `static_preview` items through preview QA
1317
+ * before they reach the store. Returns either `null` (caller continues
1318
+ * with the original args — non-static-preview report, non-succeeded
1319
+ * status, no parseable HTML, or QA passed) or an override carrying a
1320
+ * `failed` rewrite of the report. The QA result is also memoized on the
1321
+ * session resources so `getVariants` can surface it.
1322
+ *
1323
+ * Only fresh-project sessions go through this gate today; existing-
1324
+ * project `code_gen` variants produce a diff with no preview document
1325
+ * to inspect, so they skip QA and behave exactly as before.
1326
+ */
1327
+ async evaluateQaForReport(args) {
1328
+ if (args.status !== 'succeeded')
1329
+ return null;
1330
+ if (!this.store.hasSession(args.sessionId))
1331
+ return null;
1332
+ const projectContext = this.store.getProjectContext(args.sessionId);
1333
+ if (projectContext.kind !== 'fresh')
1334
+ return null;
1335
+ const parsed = parseStaticPreviewOutput(normalizeOutput(args.output));
1336
+ if (!parsed)
1337
+ return null;
1338
+ let qa;
1339
+ try {
1340
+ qa = await this.previewQaRunner({
1341
+ sessionId: args.sessionId,
1342
+ workItemId: args.workItemId,
1343
+ html: parsed.html,
1344
+ });
1345
+ }
1346
+ catch (err) {
1347
+ const message = err instanceof Error ? err.message : String(err);
1348
+ qa = {
1349
+ status: 'failed',
1350
+ issues: [
1351
+ {
1352
+ kind: 'preview_unavailable',
1353
+ detail: 'qa_runner_error',
1354
+ message,
1355
+ },
1356
+ ],
1357
+ summary: `Preview QA runner threw: ${message}`,
1358
+ };
1359
+ }
1360
+ const resources = this.ensureResources(args.sessionId);
1361
+ resources.qaResults.set(args.workItemId, qa);
1362
+ if (qa.status === 'failed') {
1363
+ return {
1364
+ qa,
1365
+ overrideArgs: {
1366
+ ...args,
1367
+ status: 'failed',
1368
+ error: { code: 'VARIANT_QA_FAILED', message: qa.summary },
1369
+ },
1370
+ };
1371
+ }
1372
+ return { qa };
1373
+ }
838
1374
  async handleSucceededReport(args) {
839
1375
  const { sessionId, workItemId } = args;
840
1376
  const resources = this.resources.get(sessionId);
@@ -850,6 +1386,13 @@ class AgentVariantsOrchestrator {
850
1386
  html: staticPreview.html,
851
1387
  };
852
1388
  resources.staticPreviews.set(workItemId, record);
1389
+ if (this.store.getProjectContext(sessionId).kind === 'fresh') {
1390
+ this.persistFreshVariantArtifacts({
1391
+ sessionId,
1392
+ workItemId,
1393
+ html: staticPreview.html,
1394
+ });
1395
+ }
853
1396
  const leasedAt = resources.leasedAt.get(workItemId);
854
1397
  trackStaticPreviewCompleted(this.telemetry, {
855
1398
  sessionId,
@@ -890,6 +1433,19 @@ class AgentVariantsOrchestrator {
890
1433
  const proc = await this.worktrees.startDevServer(dev.cwd, port, dev.cmd, dev.args, dev.env);
891
1434
  record.port = port;
892
1435
  record.devServerProcess = proc;
1436
+ // If the dev server dies on its own (crash, OOM, Vite hard-fail),
1437
+ // clear the port so the iframe stops routing the chip onto a dead
1438
+ // socket. Without this the proxy retargets onto an unreachable port
1439
+ // and the chip shows "upstream_unreachable" instead of the accurate
1440
+ // "Preview is unavailable for this variant".
1441
+ proc.once('exit', (code, signal) => {
1442
+ if (record.devServerProcess !== proc)
1443
+ return;
1444
+ record.port = undefined;
1445
+ record.devServerProcess = undefined;
1446
+ log.warn(`Variant ${workItemId} dev server exited unexpectedly (code=${code ?? 'null'}, signal=${signal ?? 'null'}); preview disabled`);
1447
+ this.emitChange();
1448
+ });
893
1449
  this.emitChange();
894
1450
  trackFreshDevServerStarted(this.telemetry, {
895
1451
  sessionId,
@@ -907,6 +1463,93 @@ class AgentVariantsOrchestrator {
907
1463
  log.warn(`Failed to start dev server for variant ${workItemId}; live preview disabled for this variant`, err);
908
1464
  }
909
1465
  }
1466
+ persistFreshVariantArtifacts(args) {
1467
+ const projectContext = this.store.getProjectContext(args.sessionId);
1468
+ if (projectContext.kind !== 'fresh') {
1469
+ return;
1470
+ }
1471
+ const projectPath = projectContext.workspacePath;
1472
+ const now = new Date().toISOString();
1473
+ const projectManifestPath = (0, createProjectArtifacts_1.createProjectManifestPath)(projectPath);
1474
+ const existingProjectManifest = this.readManifest(projectManifestPath);
1475
+ (0, createProjectArtifacts_1.writeCreateProjectManifestFile)(projectManifestPath, (0, createProjectArtifacts_1.createProjectManifest)({
1476
+ createdAt: existingProjectManifest?.createdAt ?? now,
1477
+ selectedDesignSlug: existingProjectManifest?.selectedDesignSlug,
1478
+ latestVariantSessionId: args.sessionId,
1479
+ selectedVariantId: existingProjectManifest?.selectedVariantId,
1480
+ }));
1481
+ const briefInput = this.store.getWorkItemInput(args.sessionId, args.workItemId);
1482
+ const variantFolderName = this.getFreshVariantFolderName({
1483
+ sessionId: args.sessionId,
1484
+ variantId: args.workItemId,
1485
+ variantName: briefInput.briefLabel,
1486
+ });
1487
+ const snapshotPath = (0, createProjectArtifacts_1.createProjectVariantSnapshotPath)(projectPath, variantFolderName);
1488
+ fs_1.default.rmSync(snapshotPath, { recursive: true, force: true });
1489
+ fs_1.default.mkdirSync(snapshotPath, { recursive: true });
1490
+ const briefPath = (0, createProjectArtifacts_1.createProjectVariantBriefPath)(projectPath, variantFolderName);
1491
+ fs_1.default.writeFileSync(briefPath, `# ${briefInput.briefLabel}\n\n${briefInput.briefBody}\n`, 'utf8');
1492
+ const variantManifestPath = (0, createProjectArtifacts_1.createProjectVariantManifestPath)(projectPath, variantFolderName);
1493
+ (0, createProjectArtifacts_1.writeCreateProjectManifestFile)(variantManifestPath, {
1494
+ schemaVersion: createProjectArtifacts_1.CREATE_PROJECT_MANIFEST_SCHEMA_VERSION,
1495
+ createdAt: now,
1496
+ sessionId: args.sessionId,
1497
+ variantId: args.workItemId,
1498
+ variantName: briefInput.briefLabel,
1499
+ variantFolderName,
1500
+ changedFilesCount: 1,
1501
+ });
1502
+ fs_1.default.writeFileSync((0, createProjectArtifacts_1.createProjectVariantIndexPath)(projectPath, variantFolderName), args.html, 'utf8');
1503
+ }
1504
+ persistFreshSelectionMetadata(args) {
1505
+ const now = new Date().toISOString();
1506
+ const projectManifestPath = (0, createProjectArtifacts_1.createProjectManifestPath)(args.projectPath);
1507
+ const existingProjectManifest = this.readManifest(projectManifestPath);
1508
+ (0, createProjectArtifacts_1.writeCreateProjectManifestFile)(projectManifestPath, (0, createProjectArtifacts_1.createProjectManifest)({
1509
+ createdAt: existingProjectManifest?.createdAt ?? now,
1510
+ selectedDesignSlug: existingProjectManifest?.selectedDesignSlug,
1511
+ latestVariantSessionId: args.sessionId,
1512
+ selectedVariantId: args.variantId,
1513
+ }));
1514
+ const variantManifestPath = (0, createProjectArtifacts_1.createProjectVariantManifestPath)(args.projectPath, args.variantFolderName);
1515
+ const existingVariantManifest = this.readManifest(variantManifestPath);
1516
+ (0, createProjectArtifacts_1.writeCreateProjectManifestFile)(variantManifestPath, {
1517
+ ...existingVariantManifest,
1518
+ schemaVersion: createProjectArtifacts_1.CREATE_PROJECT_MANIFEST_SCHEMA_VERSION,
1519
+ createdAt: existingVariantManifest?.createdAt ?? now,
1520
+ sessionId: args.sessionId,
1521
+ variantId: args.variantId,
1522
+ variantName: args.variantName,
1523
+ variantFolderName: args.variantFolderName,
1524
+ changedFilesCount: args.changedFilesCount,
1525
+ selectedVariantId: args.variantId,
1526
+ });
1527
+ }
1528
+ getFreshVariantFolderName(args) {
1529
+ const baseSlug = (0, createProjectArtifacts_1.createProjectVariantSlug)(args.variantName);
1530
+ const variants = this.store.getVariants(args.sessionId);
1531
+ const matchingVariants = variants.filter((variant) => {
1532
+ const input = this.store.getWorkItemInput(args.sessionId, variant.workItemId);
1533
+ return (0, createProjectArtifacts_1.createProjectVariantSlug)(input.briefLabel ?? '') === baseSlug;
1534
+ });
1535
+ const index = matchingVariants.findIndex((variant) => variant.workItemId === args.variantId);
1536
+ if (index <= 0) {
1537
+ return baseSlug;
1538
+ }
1539
+ return `${baseSlug}-${index + 1}`;
1540
+ }
1541
+ readManifest(manifestPath) {
1542
+ if (!fs_1.default.existsSync(manifestPath)) {
1543
+ return undefined;
1544
+ }
1545
+ try {
1546
+ const raw = fs_1.default.readFileSync(manifestPath, 'utf8');
1547
+ return JSON.parse(raw);
1548
+ }
1549
+ catch {
1550
+ return undefined;
1551
+ }
1552
+ }
910
1553
  /**
911
1554
  * Resolve dev server invocation for a worktree. Fresh-project worktrees
912
1555
  * always use the Vite template's npm command at the worktree root; existing
@@ -941,6 +1584,301 @@ class AgentVariantsOrchestrator {
941
1584
  env: { PORT: String(port) },
942
1585
  };
943
1586
  }
1587
+ /**
1588
+ * After the chosen variant has been renamed into `destinationPath`, move
1589
+ * each unchosen sibling worktree into a `<destinationParent>/<slug>-variants/NN-<label>/`
1590
+ * folder and repoint its `node_modules` symlink at the chosen project's
1591
+ * real install. Writes a manifest documenting all 5 variants (the chosen
1592
+ * one with `folder: null` and `chosenPath: '../<slug>'`).
1593
+ *
1594
+ * Sets `vitePreservedSiblings = true` on session resources so the
1595
+ * subsequent teardown skips `cleanupFreshWorktrees` (which would otherwise
1596
+ * delete the just-preserved history). Dev servers still get stopped.
1597
+ */
1598
+ preserveUnchosenVariants(args) {
1599
+ const resources = this.resources.get(args.sessionId);
1600
+ if (!resources)
1601
+ return;
1602
+ const destinationParent = path_1.default.dirname(args.destinationPath);
1603
+ const projectSlug = path_1.default.basename(args.destinationPath);
1604
+ const historyDir = (0, createProjectArtifacts_1.createVariantsHistoryPath)(destinationParent, projectSlug);
1605
+ const sharedNodeModulesAbs = path_1.default.join(args.destinationPath, 'node_modules');
1606
+ const briefs = this.store.getBriefs(args.sessionId);
1607
+ const variants = this.store.getVariants(args.sessionId);
1608
+ const briefIndexById = new Map(briefs.map((b, i) => [b.briefId, i]));
1609
+ const manifestEntries = [];
1610
+ let chosenSlug = '';
1611
+ for (const variant of variants) {
1612
+ const input = this.store.getWorkItemInput(args.sessionId, variant.workItemId);
1613
+ const label = input.briefLabel ?? variant.label ?? 'variant';
1614
+ const slug = (0, createProjectArtifacts_1.createProjectVariantSlug)(label);
1615
+ const briefIdx = briefIndexById.get(variant.briefId) ?? 0;
1616
+ const numericPrefix = String(briefIdx + 1).padStart(2, '0');
1617
+ const folderName = `${numericPrefix}-${slug}`;
1618
+ if (variant.workItemId === args.chosenVariantId) {
1619
+ chosenSlug = slug;
1620
+ manifestEntries.push({
1621
+ variantId: variant.workItemId,
1622
+ label,
1623
+ slug,
1624
+ folder: null,
1625
+ isChosen: true,
1626
+ chosenPath: `../${projectSlug}`,
1627
+ });
1628
+ continue;
1629
+ }
1630
+ const record = resources.worktrees.get(variant.workItemId);
1631
+ if (!record) {
1632
+ // No worktree (e.g. failed variant) — still list it in manifest with
1633
+ // null folder so the user can see why it's absent.
1634
+ manifestEntries.push({
1635
+ variantId: variant.workItemId,
1636
+ label,
1637
+ slug,
1638
+ folder: null,
1639
+ isChosen: false,
1640
+ });
1641
+ continue;
1642
+ }
1643
+ if (!fs_1.default.existsSync(record.worktreePath)) {
1644
+ manifestEntries.push({
1645
+ variantId: variant.workItemId,
1646
+ label,
1647
+ slug,
1648
+ folder: null,
1649
+ isChosen: false,
1650
+ });
1651
+ continue;
1652
+ }
1653
+ try {
1654
+ const newPath = this.worktrees.moveWorktreeToHistory
1655
+ ? this.worktrees.moveWorktreeToHistory(record.worktreePath, historyDir, folderName)
1656
+ : (() => {
1657
+ fs_1.default.mkdirSync(historyDir, { recursive: true });
1658
+ const dest = path_1.default.join(historyDir, folderName);
1659
+ fs_1.default.renameSync(record.worktreePath, dest);
1660
+ return dest;
1661
+ })();
1662
+ // Repoint node_modules at the chosen project's real install.
1663
+ try {
1664
+ if (this.worktrees.repointNodeModulesSymlink) {
1665
+ this.worktrees.repointNodeModulesSymlink(newPath, sharedNodeModulesAbs);
1666
+ }
1667
+ else {
1668
+ const link = path_1.default.join(newPath, 'node_modules');
1669
+ try {
1670
+ fs_1.default.rmSync(link, { force: true, recursive: true });
1671
+ }
1672
+ catch {
1673
+ /* may not exist */
1674
+ }
1675
+ const target = path_1.default.relative(path_1.default.dirname(link), sharedNodeModulesAbs);
1676
+ fs_1.default.symlinkSync(target, link, 'dir');
1677
+ }
1678
+ }
1679
+ catch (err) {
1680
+ log.warn(`repointNodeModulesSymlink failed for ${newPath}; variant still runnable after npm install`, err);
1681
+ }
1682
+ // Update the in-memory record so teardown doesn't try to operate on
1683
+ // the stale path.
1684
+ record.worktreePath = newPath;
1685
+ manifestEntries.push({
1686
+ variantId: variant.workItemId,
1687
+ label,
1688
+ slug,
1689
+ folder: folderName,
1690
+ isChosen: false,
1691
+ });
1692
+ }
1693
+ catch (err) {
1694
+ log.warn(`moveWorktreeToHistory failed for variant ${variant.workItemId}`, err);
1695
+ manifestEntries.push({
1696
+ variantId: variant.workItemId,
1697
+ label,
1698
+ slug,
1699
+ folder: null,
1700
+ isChosen: false,
1701
+ });
1702
+ }
1703
+ }
1704
+ const manifest = {
1705
+ schemaVersion: createProjectArtifacts_1.CREATE_PROJECT_MANIFEST_SCHEMA_VERSION,
1706
+ createdAt: new Date().toISOString(),
1707
+ sessionId: args.sessionId,
1708
+ chosenVariantId: args.chosenVariantId,
1709
+ chosenSlug: chosenSlug || projectSlug,
1710
+ variants: manifestEntries,
1711
+ };
1712
+ try {
1713
+ (0, createProjectArtifacts_1.writeCreateProjectManifestFile)((0, createProjectArtifacts_1.createVariantsHistoryManifestPath)(destinationParent, projectSlug), manifest);
1714
+ }
1715
+ catch (err) {
1716
+ log.warn(`Writing variants history manifest failed for ${historyDir}`, err);
1717
+ }
1718
+ resources.vitePreservedSiblings = true;
1719
+ }
1720
+ /**
1721
+ * Rename `sourceWorktreePath` into `destinationPath`, then replace the
1722
+ * worktree's `.git` with a fresh-history init + initial commit. Matches
1723
+ * the historical materializeProject behavior of "user sees a fresh repo
1724
+ * at destinationPath" so we don't surprise them with a `rivet: variant
1725
+ * baseline` commit they didn't intend.
1726
+ *
1727
+ * The rename is the load-bearing operation: it moves the entire variant
1728
+ * tree (template files + materialized node_modules) in O(1) when the
1729
+ * source and destination share a volume. The git reset that follows is
1730
+ * best-effort housekeeping; a failure there leaves files in place.
1731
+ */
1732
+ async renameWorktreeIntoDestination(sourceWorktreePath, destinationPath) {
1733
+ // destinationPath must not exist for fs.rename to behave as a move.
1734
+ // assertDestinationAvailable already enforced that any existing directory
1735
+ // contains only `.rivet`, which we preserve across the move.
1736
+ const destinationParent = path_1.default.dirname(destinationPath);
1737
+ await fs_1.default.promises.mkdir(destinationParent, { recursive: true });
1738
+ let preservedRivetParent;
1739
+ const existingRivetPath = path_1.default.join(destinationPath, '.rivet');
1740
+ if (fs_1.default.existsSync(destinationPath)) {
1741
+ preservedRivetParent = await fs_1.default.promises.mkdtemp(path_1.default.join(destinationParent, '.rivet-preserve-'));
1742
+ if (fs_1.default.existsSync(existingRivetPath)) {
1743
+ await fs_1.default.promises.rename(existingRivetPath, path_1.default.join(preservedRivetParent, '.rivet'));
1744
+ }
1745
+ await fs_1.default.promises.rm(destinationPath, { recursive: true, force: true });
1746
+ }
1747
+ try {
1748
+ await fs_1.default.promises.rename(sourceWorktreePath, destinationPath);
1749
+ }
1750
+ catch (err) {
1751
+ if (preservedRivetParent && !fs_1.default.existsSync(destinationPath)) {
1752
+ await fs_1.default.promises.mkdir(destinationPath, { recursive: true });
1753
+ const preservedRivetPath = path_1.default.join(preservedRivetParent, '.rivet');
1754
+ if (fs_1.default.existsSync(preservedRivetPath)) {
1755
+ await fs_1.default.promises.rename(preservedRivetPath, existingRivetPath);
1756
+ }
1757
+ }
1758
+ throw err;
1759
+ }
1760
+ if (preservedRivetParent) {
1761
+ const preservedRivetPath = path_1.default.join(preservedRivetParent, '.rivet');
1762
+ if (fs_1.default.existsSync(preservedRivetPath)) {
1763
+ await fs_1.default.promises.rename(preservedRivetPath, existingRivetPath);
1764
+ }
1765
+ await fs_1.default.promises.rm(preservedRivetParent, {
1766
+ recursive: true,
1767
+ force: true,
1768
+ });
1769
+ }
1770
+ // Replace the variant-baseline git history with a fresh init so the
1771
+ // user doesn't inherit a `rivet: variant baseline` commit.
1772
+ const dotGit = path_1.default.join(destinationPath, '.git');
1773
+ try {
1774
+ fs_1.default.rmSync(dotGit, { recursive: true, force: true });
1775
+ }
1776
+ catch {
1777
+ // Best-effort — leaving the baseline .git is preferable to failing
1778
+ // the commit after we've moved the tree.
1779
+ }
1780
+ try {
1781
+ const git = (0, simple_git_1.simpleGit)(destinationPath);
1782
+ await git.raw(['init']);
1783
+ await git.raw(['add', '-A']);
1784
+ await git
1785
+ .raw([
1786
+ '-c',
1787
+ 'user.name=Rivet',
1788
+ '-c',
1789
+ 'user.email=hello@tryrivet.design',
1790
+ 'commit',
1791
+ '--no-gpg-sign',
1792
+ '-m',
1793
+ 'Initial commit (created with Rivet)',
1794
+ ])
1795
+ .catch(() => {
1796
+ // Best-effort: missing user identity or empty tree leaves the
1797
+ // commit step un-applied. Files are still in place.
1798
+ });
1799
+ }
1800
+ catch (err) {
1801
+ log.warn(`Initial git history setup at ${destinationPath} failed`, err);
1802
+ }
1803
+ }
1804
+ /**
1805
+ * Stop every variant dev server currently running for `resources`. Clears
1806
+ * the `devServerProcess` / `port` fields on each record so subsequent
1807
+ * teardown logic doesn't try to stop them a second time. Called from
1808
+ * `commitVariant` (vite_app rename path) BEFORE the worktree directory is
1809
+ * renamed — leaving Vite alive across the rename leaks empty `.vite/`
1810
+ * stub dirs at the old paths.
1811
+ */
1812
+ async stopAllDevServersForSession(resources) {
1813
+ const stops = [];
1814
+ for (const record of resources.worktrees.values()) {
1815
+ if (!record.devServerProcess)
1816
+ continue;
1817
+ const proc = record.devServerProcess;
1818
+ stops.push(this.worktrees
1819
+ .stopDevServer(proc)
1820
+ .catch((err) => log.warn(`stopDevServer pre-commit failed for ${resources.sessionId}/${record.workItemId}`, err)));
1821
+ record.devServerProcess = undefined;
1822
+ record.port = undefined;
1823
+ }
1824
+ await Promise.all(stops);
1825
+ }
1826
+ /**
1827
+ * Spawn a fresh dev server at the user-facing destinationPath and retarget
1828
+ * the proxy so the iframe stays live across commit. Stores the process on
1829
+ * `resources.committedDevServer` so teardown can hand it off to the
1830
+ * lingering registry instead of killing it. Best-effort: a failure here is
1831
+ * non-fatal — the commit still succeeds; the user just has to run
1832
+ * `npm run dev` themselves to bring the preview back.
1833
+ */
1834
+ async startCommittedDevServer(args) {
1835
+ try {
1836
+ const port = await this.worktrees.getFreePort();
1837
+ const proc = await this.worktrees.startDevServer(args.destinationPath, port, 'npm', [
1838
+ 'run',
1839
+ 'dev',
1840
+ '--',
1841
+ '--port',
1842
+ String(port),
1843
+ '--host',
1844
+ FRESH_DEV_SERVER_HOST,
1845
+ ], { PORT: String(port) });
1846
+ args.resources.committedDevServer = {
1847
+ proc,
1848
+ port,
1849
+ path: args.destinationPath,
1850
+ };
1851
+ try {
1852
+ this.switchPreviewPort?.(port);
1853
+ }
1854
+ catch (err) {
1855
+ log.warn(`switchPreviewPort(${port}) after committed dev server start failed`, err);
1856
+ }
1857
+ log.info(`Committed dev server up at ${args.destinationPath} on port ${port}`);
1858
+ }
1859
+ catch (err) {
1860
+ log.warn(`Failed to start committed dev server at ${args.destinationPath} — iframe may show "preview disconnected" until user runs npm run dev`, err);
1861
+ }
1862
+ }
1863
+ /**
1864
+ * Stop any committed dev servers that were left alive past their session's
1865
+ * teardown. Called at the start of a new `propose` (so the next session's
1866
+ * preview doesn't compete with stale processes) and exposed for explicit
1867
+ * shutdown hooks. Best-effort: per-server failures are logged but don't
1868
+ * propagate.
1869
+ */
1870
+ async stopLingeringCommittedDevServers() {
1871
+ const entries = [...this.lingeringCommittedDevServers.entries()];
1872
+ this.lingeringCommittedDevServers.clear();
1873
+ await Promise.all(entries.map(async ([sessionId, entry]) => {
1874
+ try {
1875
+ await this.worktrees.stopDevServer(entry.proc);
1876
+ }
1877
+ catch (err) {
1878
+ log.warn(`stopDevServer for lingering committed server (session ${sessionId}, port ${entry.port}) failed`, err);
1879
+ }
1880
+ }));
1881
+ }
944
1882
  async teardownSession(sessionId, reason) {
945
1883
  const resources = this.resources.get(sessionId);
946
1884
  if (!resources)
@@ -950,17 +1888,46 @@ class AgentVariantsOrchestrator {
950
1888
  resources.cleanupStarted = true;
951
1889
  log.info(`Tearing down session ${sessionId} (reason: ${reason})`);
952
1890
  // Stop dev servers in parallel; ignore individual failures.
1891
+ // NOTE: For the fresh vite_app rename path, commitVariant already stopped
1892
+ // these dev servers BEFORE the rename (so Vite couldn't recreate `.vite/`
1893
+ // stub dirs at the old worktree paths). In that case devServerProcess is
1894
+ // already cleared and this loop is a no-op — exactly the desired behavior.
953
1895
  const stops = [...resources.worktrees.values()]
954
1896
  .filter((r) => r.devServerProcess)
955
1897
  .map((r) => this.worktrees
956
1898
  .stopDevServer(r.devServerProcess)
957
1899
  .catch((err) => log.warn(`stopDevServer failed for ${sessionId}/${r.workItemId}`, err)));
958
1900
  await Promise.all(stops);
959
- try {
960
- await this.worktrees.cleanupSession(sessionId);
1901
+ // The committed dev server (spawned at destinationPath after a fresh
1902
+ // vite_app commit) intentionally outlives this teardown — the iframe is
1903
+ // pointed at it via the proxy and the user just chose this project. Hand
1904
+ // it off to the lingering registry; it'll be stopped on the next
1905
+ // `propose` or explicit shutdown.
1906
+ if (resources.committedDevServer && reason === 'committed') {
1907
+ this.lingeringCommittedDevServers.set(sessionId, resources.committedDevServer);
961
1908
  }
962
- catch (err) {
963
- log.warn(`cleanupSession (worktree removal) failed for ${sessionId}`, err);
1909
+ else if (resources.committedDevServer) {
1910
+ // Non-committed teardown (cancel/shutdown) with a committed dev server
1911
+ // shouldn't happen in current code paths, but stop it defensively so we
1912
+ // don't leak procs.
1913
+ try {
1914
+ await this.worktrees.stopDevServer(resources.committedDevServer.proc);
1915
+ }
1916
+ catch (err) {
1917
+ log.warn(`stopDevServer for committed dev server (session ${sessionId}) failed`, err);
1918
+ }
1919
+ }
1920
+ // When a vite_app commit already moved the unchosen worktrees into a
1921
+ // sibling history dir, calling cleanupSession would re-delete them. Skip
1922
+ // the worktree removal step in that case — the move already cleared the
1923
+ // .rivet-variants/ entries for the chosen + preserved variants.
1924
+ if (!resources.vitePreservedSiblings) {
1925
+ try {
1926
+ await this.worktrees.cleanupSession(sessionId, resources.freshDestinationParent);
1927
+ }
1928
+ catch (err) {
1929
+ log.warn(`cleanupSession (worktree removal) failed for ${sessionId}`, err);
1930
+ }
964
1931
  }
965
1932
  this.resources.delete(sessionId);
966
1933
  }
@@ -975,6 +1942,8 @@ class AgentVariantsOrchestrator {
975
1942
  committedVariantIds: new Set(),
976
1943
  startedAt: Date.now(),
977
1944
  leasedAt: new Map(),
1945
+ qaResults: new Map(),
1946
+ vitePreservedSiblings: false,
978
1947
  };
979
1948
  this.resources.set(sessionId, r);
980
1949
  }
@@ -1048,6 +2017,35 @@ function countDiffFiles(diff) {
1048
2017
  // Each file in a unified diff starts with "diff --git ".
1049
2018
  return (diff.match(/^diff --git /gm) ?? []).length;
1050
2019
  }
2020
+ /**
2021
+ * Count source files under a materialized fresh-project destination,
2022
+ * skipping install artifacts (`node_modules`, `.git`, build outputs) so the
2023
+ * commit telemetry / chip count reflects the agent-visible surface area
2024
+ * rather than the install footprint.
2025
+ */
2026
+ function countWorktreeFiles(destinationPath) {
2027
+ let count = 0;
2028
+ const walk = (dir) => {
2029
+ let entries;
2030
+ try {
2031
+ entries = fs_1.default.readdirSync(dir, { withFileTypes: true });
2032
+ }
2033
+ catch {
2034
+ return;
2035
+ }
2036
+ for (const entry of entries) {
2037
+ if (MATERIALIZE_EXCLUDE.has(entry.name))
2038
+ continue;
2039
+ const full = path_1.default.join(dir, entry.name);
2040
+ if (entry.isDirectory())
2041
+ walk(full);
2042
+ else if (entry.isFile())
2043
+ count += 1;
2044
+ }
2045
+ };
2046
+ walk(destinationPath);
2047
+ return count;
2048
+ }
1051
2049
  const enrichDesignSource = (design) => {
1052
2050
  if (!design || design.kind !== 'slug')
1053
2051
  return design;
@@ -1075,8 +2073,94 @@ const toActiveProjectContext = (projectContext) => {
1075
2073
  },
1076
2074
  }
1077
2075
  : {}),
2076
+ ...(projectContext.executionPlan
2077
+ ? {
2078
+ executionPlan: {
2079
+ mode: projectContext.executionPlan.mode,
2080
+ confidence: projectContext.executionPlan.confidence,
2081
+ reason: projectContext.executionPlan.reason,
2082
+ assetCount: projectContext.executionPlan.assetPlan?.length ?? 0,
2083
+ runtimeRequirementCount: projectContext.executionPlan.runtimeRequirements?.length ?? 0,
2084
+ },
2085
+ }
2086
+ : {}),
1078
2087
  };
1079
2088
  };
2089
+ /**
2090
+ * Resolve the user-facing supporting artifacts for a session.
2091
+ *
2092
+ * For 0→1 (`fresh`) sessions with a populated `designContext`, each slot is
2093
+ * turned into a `design_context` artifact carrying the full DESIGN.md
2094
+ * markdown:
2095
+ * - `slug` entries resolve bundled catalog markdown via the design catalog.
2096
+ * - `markdown` entries (Agent Browser / inspiration extractor output) carry
2097
+ * their stored markdown verbatim.
2098
+ * Slots whose markdown can't be resolved are skipped so the UI never renders
2099
+ * a metadata-only DESIGN.md row.
2100
+ */
2101
+ const buildSessionArtifacts = (projectContext) => {
2102
+ if (projectContext.kind !== 'fresh')
2103
+ return [];
2104
+ const designContext = projectContext.designContext;
2105
+ if (!designContext || designContext.length === 0)
2106
+ return [];
2107
+ const artifactsByContent = new Map();
2108
+ designContext.forEach((entry, slot) => {
2109
+ if (!entry)
2110
+ return;
2111
+ if (entry.kind === 'slug') {
2112
+ const markdown = (0, designCatalog_1.loadDesignSystemMarkdown)(entry.slug);
2113
+ if (!markdown)
2114
+ return;
2115
+ const catalogEntry = (0, designCatalog_1.getDesignSystemBySlug)(entry.slug);
2116
+ addDesignContextArtifact(artifactsByContent, {
2117
+ id: `design_context:${slot}:${entry.slug}`,
2118
+ kind: 'design_context',
2119
+ label: catalogEntry?.name ?? entry.slug,
2120
+ ...(catalogEntry?.description ? { summary: catalogEntry.description } : {}),
2121
+ status: 'ready',
2122
+ source: 'static',
2123
+ contentType: 'text/markdown',
2124
+ content: markdown,
2125
+ });
2126
+ return;
2127
+ }
2128
+ if (!entry.content)
2129
+ return;
2130
+ addDesignContextArtifact(artifactsByContent, {
2131
+ id: `design_context:${slot}:${entry.label}`,
2132
+ kind: 'design_context',
2133
+ label: entry.label,
2134
+ status: 'ready',
2135
+ source: 'agent_browser',
2136
+ contentType: 'text/markdown',
2137
+ content: entry.content,
2138
+ });
2139
+ });
2140
+ return [...artifactsByContent.values()].map((artifact) => {
2141
+ if (artifact.usedByVariantCount <= 1)
2142
+ return artifact;
2143
+ return {
2144
+ ...artifact,
2145
+ summary: artifact.summary
2146
+ ? `${artifact.summary} Used by ${artifact.usedByVariantCount} variants.`
2147
+ : `Used by ${artifact.usedByVariantCount} variants.`,
2148
+ };
2149
+ });
2150
+ };
2151
+ const addDesignContextArtifact = (artifactsByContent, artifact) => {
2152
+ if (!artifact.content)
2153
+ return;
2154
+ const existing = artifactsByContent.get(artifact.content);
2155
+ if (existing) {
2156
+ existing.usedByVariantCount += 1;
2157
+ return;
2158
+ }
2159
+ artifactsByContent.set(artifact.content, {
2160
+ ...artifact,
2161
+ usedByVariantCount: 1,
2162
+ });
2163
+ };
1080
2164
  const summarizeDesignContext = (designContext) => {
1081
2165
  if (!designContext)
1082
2166
  return null;
@@ -1196,6 +2280,87 @@ const defaultInstallDependencies = (worktreePath) => {
1196
2280
  });
1197
2281
  });
1198
2282
  };
2283
+ /**
2284
+ * True when `a` and `b` (or the closest existing ancestor of each) live on
2285
+ * the same filesystem volume. Used by commitVariant to decide between an
2286
+ * O(1) rename and a recursive copy-with-reinstall fallback.
2287
+ *
2288
+ * `destinationPath` for fresh sessions does not exist yet (we'd fail the
2289
+ * "destination must be empty" guard otherwise), so we compare against its
2290
+ * parent directory which is guaranteed to exist (createZeroToOneTool either
2291
+ * created it or validated it).
2292
+ */
2293
+ function areSameVolume(a, b) {
2294
+ try {
2295
+ const statA = fs_1.default.statSync(a);
2296
+ const parentB = fs_1.default.existsSync(b) ? b : path_1.default.dirname(b);
2297
+ const statB = fs_1.default.statSync(parentB);
2298
+ return statA.dev === statB.dev;
2299
+ }
2300
+ catch {
2301
+ return false;
2302
+ }
2303
+ }
2304
+ /**
2305
+ * Materialize node_modules from symlink → real directory at the given path,
2306
+ * so a subsequent rename of that path leaves a self-contained tree.
2307
+ *
2308
+ * Fresh worktrees 1..N share variant 0's node_modules via a symlink (see
2309
+ * runBackgroundInstall). When the user picks variant K and we rename it
2310
+ * into the destination, the sibling worktrees get torn down — which would
2311
+ * otherwise leave a dangling symlink in the destination if variant K was
2312
+ * the one with the symlink (K > 0).
2313
+ *
2314
+ * Returns true when the destination must run a dependency install after the
2315
+ * rename because node_modules is missing or points nowhere.
2316
+ */
2317
+ async function resolveNodeModulesSymlink(worktreePath) {
2318
+ const nodeModulesPath = path_1.default.join(worktreePath, 'node_modules');
2319
+ let lstat;
2320
+ try {
2321
+ lstat = await fs_1.default.promises.lstat(nodeModulesPath);
2322
+ }
2323
+ catch {
2324
+ return true;
2325
+ }
2326
+ if (!lstat.isSymbolicLink())
2327
+ return false;
2328
+ let target;
2329
+ try {
2330
+ target = await fs_1.default.promises.realpath(nodeModulesPath);
2331
+ }
2332
+ catch {
2333
+ await fs_1.default.promises.rm(nodeModulesPath, { force: true });
2334
+ return true;
2335
+ }
2336
+ // Stage the copy under a sibling temp name so a mid-copy failure (disk
2337
+ // full, EACCES, etc.) leaves the original symlink intact rather than
2338
+ // wiping it and leaving the worktree with no node_modules at all.
2339
+ // The final swap is then atomic on the same filesystem (rename + unlink).
2340
+ const stagingPath = `${nodeModulesPath}.materializing-${(0, crypto_1.randomBytes)(4).toString('hex')}`;
2341
+ try {
2342
+ await fs_1.default.promises.cp(target, stagingPath, {
2343
+ recursive: true,
2344
+ dereference: false,
2345
+ });
2346
+ }
2347
+ catch (err) {
2348
+ // Best-effort cleanup of the partial staging dir; leave the symlink
2349
+ // intact so the caller can re-run materialization.
2350
+ await fs_1.default.promises
2351
+ .rm(stagingPath, { recursive: true, force: true })
2352
+ .catch(() => { });
2353
+ throw err;
2354
+ }
2355
+ // Swap: remove the symlink, then rename the staging dir into place.
2356
+ // Both operations are local fs ops on the same parent — fast and
2357
+ // unlikely to fail independently, but if the rename fails the symlink
2358
+ // is already gone, so prefer to leave the staging dir on disk over a
2359
+ // half-broken worktree (the caller will surface the throw).
2360
+ await fs_1.default.promises.rm(nodeModulesPath, { force: true });
2361
+ await fs_1.default.promises.rename(stagingPath, nodeModulesPath);
2362
+ return false;
2363
+ }
1199
2364
  const defaultMaterializeProject = async (sourceWorktreePath, destinationPath) => {
1200
2365
  await fs_1.default.promises.mkdir(destinationPath, { recursive: true });
1201
2366
  await fs_1.default.promises.cp(sourceWorktreePath, destinationPath, {